feat(summarize): presegment PR2 — deep_summary 분기 + HOLD 배선 (TIER1 로컬 map-reduce)
plan ds-presegment-mapreduce-2. TRIGGER(25K tok) 이하 = 기존 단일콜 byte-불변 무회귀. 초과 시 3-way over% 게이트: auto=유닛별 map(26B)→reduce(26B, p3c_deep_summary_reduce 변형) → ai_detail_summary 동일 기록(불일치=reduce+map 합본 dedup) / hybrid·whole= HOLD(payload.presegment.awaiting_split + StageDeferred 24h, 맥미니 미전송 — 알람· 클로드 유인 분할은 PR3). - 유닛 단위 멱등 재개: 성공 유닛 즉시 payload.map_results commit — 502/defer/재시작 후 완료 유닛 skip, 실패 유닛만 raise→기존 attempts/백오프 재사용 - 모든 LLM 콜 캡(12K tok) 이하 — map=greedy-pack 보장, reduce=build_reduce_units_block 비례 절단 보장, est_tokens 로그로 단정 가능 - 콜 사이 gate 해제 → 짧은 인터랙티브 요청 interleave (허브 굶김 해소 본체) - fix: summarize_units 의 `from app.services...` 절대 import — 컨테이너(빌드 컨텍스트 ./app)에 app 패키지가 없어 배선 시 ModuleNotFoundError 나는 PR1 잠복 버그 → 상대 import 로 수정 (컨테이너/repo-root 테스트 양쪽 동작) - tests: 헬퍼 6 + worker seam 5 (map-reduce e2e·재개·유닛실패·drain 보류·HOLD) — PR1 15 포함 26 passed, 인접 policy/hier_decomp/fair_share 123 passed Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,80 @@
|
||||
"""summarize_units PR2 헬퍼 단위테스트 — map/reduce 프롬프트 조립 순수함수.
|
||||
|
||||
핵심 불변식:
|
||||
- render_map_slice: 유닛 위치(1-based)/섹션 라벨 + 본문 그대로 (손실 0).
|
||||
- build_reduce_units_block: 어떤 입력에도 반환 블록 est_tokens <= budget (캡 초과 0
|
||||
검증 게이트의 reduce 측). 절단은 detail 만 — 라벨/TLDR/불일치/순서 보존.
|
||||
|
||||
pytest + 단독 실행 양쪽 지원:
|
||||
PYTHONPATH=. pytest tests/summarize_units/ -q
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from app.services.summarize_units import (
|
||||
SummarizeUnit,
|
||||
build_reduce_units_block,
|
||||
estimate_tokens,
|
||||
render_map_slice,
|
||||
)
|
||||
|
||||
|
||||
def _result(idx: int, detail: str, *, tldr: str = "요약", inc: list | None = None) -> dict:
|
||||
return {
|
||||
"index": idx,
|
||||
"titles": [f"섹션{idx}"],
|
||||
"tldr": tldr,
|
||||
"detail": detail,
|
||||
"inconsistencies": inc or [],
|
||||
}
|
||||
|
||||
|
||||
# ---------- render_map_slice ----------
|
||||
|
||||
def test_render_map_slice_label_and_body():
|
||||
unit = SummarizeUnit(index=2, section_titles=["개요", None, "본론"], text="본문입니다")
|
||||
out = render_map_slice(unit, total_units=5)
|
||||
assert out.startswith("[유닛 3/5 — 섹션: 개요 · 본론]\n")
|
||||
assert out.endswith("본문입니다")
|
||||
|
||||
|
||||
def test_render_map_slice_untitled():
|
||||
unit = SummarizeUnit(index=0, section_titles=[None], text="x")
|
||||
assert "(무제 구간)" in render_map_slice(unit, total_units=1)
|
||||
|
||||
|
||||
# ---------- build_reduce_units_block ----------
|
||||
|
||||
def test_reduce_block_within_budget_untouched():
|
||||
results = [_result(i, "가" * 100) for i in range(3)]
|
||||
block, truncated = build_reduce_units_block(results, budget_tokens=11_000)
|
||||
assert not truncated
|
||||
# 순서/라벨/TLDR 보존
|
||||
assert block.index("[유닛 1/3") < block.index("[유닛 2/3") < block.index("[유닛 3/3")
|
||||
assert "TLDR: 요약" in block
|
||||
assert "가" * 100 in block
|
||||
|
||||
|
||||
def test_reduce_block_truncates_to_budget():
|
||||
# 유닛 8개 × 한글 detail 5,000자 ≈ 21K tok — budget 5,000 으로 절단 강제
|
||||
results = [_result(i, "가" * 5_000) for i in range(8)]
|
||||
block, truncated = build_reduce_units_block(results, budget_tokens=5_000)
|
||||
assert truncated
|
||||
assert estimate_tokens(block) <= 5_000
|
||||
# 라벨(유닛 순서)은 절단 후에도 보존
|
||||
assert "[유닛 1/8" in block
|
||||
|
||||
|
||||
def test_reduce_block_hard_cut_floor():
|
||||
# min_detail_chars floor 에 막혀 비례 절단으로 불충분한 극단 케이스 — 하드 컷 발동
|
||||
results = [_result(i, "가" * 300) for i in range(50)]
|
||||
block, truncated = build_reduce_units_block(results, budget_tokens=500)
|
||||
assert truncated
|
||||
assert estimate_tokens(block) <= 500
|
||||
|
||||
|
||||
def test_reduce_block_preserves_inconsistencies():
|
||||
results = [
|
||||
_result(0, "가" * 50, inc=[{"kind": "version_drift", "desc": "개정판 차이"}]),
|
||||
]
|
||||
block, _ = build_reduce_units_block(results, budget_tokens=10_000)
|
||||
assert "불일치(version_drift): 개정판 차이" in block
|
||||
@@ -0,0 +1,249 @@
|
||||
"""presegment PR2 — deep_summary_worker map-reduce/HOLD 배선 단위테스트.
|
||||
|
||||
worker-process 레벨(DB 필요)의 큐 상태 전이는 라이브 E2E 로 검증하고, 여기서는
|
||||
새 메커니즘의 seam 을 단위 검증한다 (test_fair_share.py 선례):
|
||||
- _hold_awaiting_split: payload 마킹 commit 후 StageDeferred(HOLD_RETRY_MINUTES).
|
||||
- _process_map_reduce: 유닛별 map → reduce → doc 필드 기록 / 모든 콜 캡 준수 /
|
||||
payload.presegment.map_results 유닛 단위 persist(멱등 재개) / 실패 유닛 raise /
|
||||
drain 보류(StageDeferred) 시 완료 유닛 보존.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import sys
|
||||
from types import SimpleNamespace
|
||||
|
||||
import pytest
|
||||
|
||||
sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..", "app"))
|
||||
|
||||
from ai.envelope import EscalationEnvelope # noqa: E402
|
||||
from models.queue import StageDeferred # noqa: E402
|
||||
from services.summarize_units import ( # noqa: E402
|
||||
CAP_TOKENS,
|
||||
estimate_tokens,
|
||||
plan_summarize_units,
|
||||
)
|
||||
import workers.deep_summary_worker as dsw # noqa: E402
|
||||
|
||||
|
||||
# ─── fixtures ────────────────────────────────────────────────────────────────
|
||||
|
||||
# 30 절 × 한글 2,000자 ≈ 31.7K tok (> TRIGGER 25K) · 절당 ≈ 1,060 tok (< CAP) → auto
|
||||
GIANT_AUTO_MD = "\n".join(f"# 절 {i}\n" + ("가" * 2_000) for i in range(30))
|
||||
# 헤딩 1개 + 한글 60,000자 단일 섹션 ≈ 31.7K tok (> CAP) → over% 100 → whole
|
||||
GIANT_WHOLE_MD = "# 통짜\n" + ("가" * 60_000)
|
||||
|
||||
MAP_JSON = (
|
||||
'{"mode": "single", "tldr": "유닛 요약", "detail": "유닛 상세.",'
|
||||
' "inconsistencies": [{"kind": "version_drift", "desc": "개정판 차이"}],'
|
||||
' "confidence": 0.9}'
|
||||
)
|
||||
REDUCE_JSON = (
|
||||
'{"mode": "single", "tldr": "전체 요약", "detail": "최종 상세.",'
|
||||
' "inconsistencies": [], "confidence": 0.8}'
|
||||
)
|
||||
|
||||
|
||||
class FakeSession:
|
||||
def __init__(self):
|
||||
self.commits = 0
|
||||
|
||||
async def commit(self):
|
||||
self.commits += 1
|
||||
|
||||
|
||||
class FakeClient:
|
||||
"""deep 슬롯 보유 클라이언트 — call_deep_or_defer 가 call_deep 을 타게 한다."""
|
||||
|
||||
def __init__(self, responses=None, fail_indexes=frozenset(), defer_from=None):
|
||||
self.ai = SimpleNamespace(
|
||||
deep=SimpleNamespace(model="qwen-macbook", context_char_limit=260_000)
|
||||
)
|
||||
self.prompts: list[str] = []
|
||||
self._fail_indexes = fail_indexes # 이 순번(0-based) 콜은 파싱 불가 응답
|
||||
self._defer_from = defer_from # 이 순번부터 연결 실패(StageDeferred 변환 대상)
|
||||
|
||||
async def call_deep(self, prompt: str, system=None) -> str:
|
||||
import httpx
|
||||
|
||||
idx = len(self.prompts)
|
||||
if self._defer_from is not None and idx >= self._defer_from:
|
||||
raise httpx.ConnectError("macbook down")
|
||||
self.prompts.append(prompt)
|
||||
if idx in self._fail_indexes:
|
||||
return "정상 JSON 아님"
|
||||
if "유닛 요약 (총" in prompt: # reduce 프롬프트 마커
|
||||
return REDUCE_JSON
|
||||
return MAP_JSON
|
||||
|
||||
async def close(self):
|
||||
pass
|
||||
|
||||
|
||||
def _doc():
|
||||
return SimpleNamespace(
|
||||
id=999,
|
||||
extracted_text=GIANT_AUTO_MD,
|
||||
ai_detail_summary=None,
|
||||
ai_inconsistencies=None,
|
||||
ai_analysis_tier="triage",
|
||||
ai_processed_at=None,
|
||||
)
|
||||
|
||||
|
||||
def _envelope():
|
||||
return EscalationEnvelope(
|
||||
from_stage="classify",
|
||||
escalation_reasons=("long_context",),
|
||||
risk_flags=(),
|
||||
distilled_context="4B 요지",
|
||||
original_pointers={"doc_ids": [999]},
|
||||
)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def _patch_telemetry(monkeypatch):
|
||||
events: list[dict] = []
|
||||
|
||||
async def fake_record(**kwargs):
|
||||
events.append(kwargs)
|
||||
|
||||
monkeypatch.setattr(dsw, "record_analyze_event", fake_record)
|
||||
return events
|
||||
|
||||
|
||||
# ─── _hold_awaiting_split ────────────────────────────────────────────────────
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_hold_marks_payload_and_defers():
|
||||
plan = plan_summarize_units(GIANT_WHOLE_MD)
|
||||
assert plan.mode == "map_reduce" and plan.tier == "whole"
|
||||
|
||||
session, row = FakeSession(), SimpleNamespace(payload={"envelope": {"x": 1}})
|
||||
with pytest.raises(StageDeferred) as ei:
|
||||
await dsw._hold_awaiting_split(session, row, plan, document_id=999)
|
||||
|
||||
assert ei.value.retry_after_minutes == dsw.HOLD_RETRY_MINUTES
|
||||
assert session.commits == 1 # 마킹이 defer 전에 commit — consumer 재읽기에서 보존
|
||||
preseg = row.payload["presegment"]
|
||||
assert preseg["awaiting_split"] is True
|
||||
assert preseg["tier"] == "whole"
|
||||
assert preseg["units"] == len(plan.units)
|
||||
assert row.payload["envelope"] == {"x": 1} # 기존 payload 병합 보존
|
||||
|
||||
|
||||
# ─── _process_map_reduce — 정상 경로 ────────────────────────────────────────
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_map_reduce_end_to_end(monkeypatch, _patch_telemetry):
|
||||
plan = plan_summarize_units(GIANT_AUTO_MD)
|
||||
assert plan.mode == "map_reduce" and plan.tier == "auto"
|
||||
n = len(plan.units)
|
||||
assert n >= 2 # greedy-pack 이 실제로 유닛을 나눴는지
|
||||
|
||||
client = FakeClient()
|
||||
monkeypatch.setattr(dsw, "AIClient", lambda: client)
|
||||
doc, session = _doc(), FakeSession()
|
||||
row = SimpleNamespace(payload={"envelope": {"x": 1}})
|
||||
|
||||
await dsw._process_map_reduce(
|
||||
doc, row, _envelope(), "generic", plan, session,
|
||||
defer_on_deep_unavailable=False,
|
||||
)
|
||||
|
||||
# 콜 수 = 유닛 map n + reduce 1
|
||||
assert len(client.prompts) == n + 1
|
||||
# 검증 게이트: 모든 콜 est_tokens <= CAP + 오버헤드(정책 템플릿+envelope ~3K)
|
||||
for p in client.prompts:
|
||||
assert estimate_tokens(p) <= CAP_TOKENS + 3_000
|
||||
# doc 기록 = reduce 출력, 불일치 = map 유닛 합본 dedup
|
||||
assert doc.ai_detail_summary == "최종 상세."
|
||||
assert doc.ai_analysis_tier == "deep"
|
||||
assert doc.ai_inconsistencies == [{"kind": "version_drift", "desc": "개정판 차이"}]
|
||||
# 유닛 단위 persist — 유닛마다 commit
|
||||
assert row.payload["presegment"]["units"] == n
|
||||
assert len(row.payload["presegment"]["map_results"]) == n
|
||||
assert session.commits == n
|
||||
# telemetry 1건 (reduce 기준)
|
||||
events = _patch_telemetry
|
||||
assert len(events) == 1 and events[0]["error_code"] is None
|
||||
|
||||
|
||||
# ─── 멱등 재개 ───────────────────────────────────────────────────────────────
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_map_reduce_resume_skips_done_units(monkeypatch, _patch_telemetry):
|
||||
plan = plan_summarize_units(GIANT_AUTO_MD)
|
||||
n = len(plan.units)
|
||||
|
||||
client = FakeClient()
|
||||
monkeypatch.setattr(dsw, "AIClient", lambda: client)
|
||||
done_unit = {
|
||||
"index": 0, "titles": ["절 0"], "tldr": "이전 요약", "detail": "이전 상세.",
|
||||
"inconsistencies": [],
|
||||
}
|
||||
row = SimpleNamespace(payload={
|
||||
"envelope": {"x": 1},
|
||||
"presegment": {"map_results": {"0": done_unit}},
|
||||
})
|
||||
doc, session = _doc(), FakeSession()
|
||||
|
||||
await dsw._process_map_reduce(
|
||||
doc, row, _envelope(), "generic", plan, session,
|
||||
defer_on_deep_unavailable=False,
|
||||
)
|
||||
|
||||
# 유닛 0 은 재호출 안 함 — map (n-1) + reduce 1
|
||||
assert len(client.prompts) == n
|
||||
assert row.payload["presegment"]["map_results"]["0"]["detail"] == "이전 상세."
|
||||
assert doc.ai_detail_summary == "최종 상세."
|
||||
|
||||
|
||||
# ─── map 유닛 실패 → raise (성공분 persist) ─────────────────────────────────
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_map_unit_parse_failure_raises_but_persists_good_units(
|
||||
monkeypatch, _patch_telemetry
|
||||
):
|
||||
plan = plan_summarize_units(GIANT_AUTO_MD)
|
||||
n = len(plan.units)
|
||||
|
||||
client = FakeClient(fail_indexes={1}) # 두 번째 map 콜만 파싱 불가
|
||||
monkeypatch.setattr(dsw, "AIClient", lambda: client)
|
||||
doc, session = _doc(), FakeSession()
|
||||
row = SimpleNamespace(payload={"envelope": {"x": 1}})
|
||||
|
||||
with pytest.raises(ValueError, match="map 유닛"):
|
||||
await dsw._process_map_reduce(
|
||||
doc, row, _envelope(), "generic", plan, session,
|
||||
defer_on_deep_unavailable=False,
|
||||
)
|
||||
|
||||
# 성공 유닛(n-1)은 persist — 재시도 시 실패 1건만 재호출
|
||||
assert len(row.payload["presegment"]["map_results"]) == n - 1
|
||||
assert "1" not in row.payload["presegment"]["map_results"]
|
||||
assert doc.ai_detail_summary is None # doc 은 미기록
|
||||
assert _patch_telemetry == [] # 가짜 완료 이벤트 없음
|
||||
|
||||
|
||||
# ─── drain 보류 — 완료 유닛 보존 + StageDeferred 전파 ───────────────────────
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_map_defer_propagates_and_keeps_progress(monkeypatch, _patch_telemetry):
|
||||
plan = plan_summarize_units(GIANT_AUTO_MD)
|
||||
|
||||
client = FakeClient(defer_from=1) # 첫 유닛 성공 후 맥북 절단
|
||||
monkeypatch.setattr(dsw, "AIClient", lambda: client)
|
||||
doc, session = _doc(), FakeSession()
|
||||
row = SimpleNamespace(payload={"envelope": {"x": 1}})
|
||||
|
||||
with pytest.raises(StageDeferred):
|
||||
await dsw._process_map_reduce(
|
||||
doc, row, _envelope(), "generic", plan, session,
|
||||
defer_on_deep_unavailable=True, # drain 시멘틱 — 보류 전파
|
||||
)
|
||||
|
||||
assert len(row.payload["presegment"]["map_results"]) == 1
|
||||
assert doc.ai_detail_summary is None
|
||||
Reference in New Issue
Block a user