diff --git a/README.md b/README.md
index c8bb0cb..0a71223 100644
--- a/README.md
+++ b/README.md
@@ -25,7 +25,7 @@ Spring(BACKEND 리포)이 이 서버를 부르는 **단방향**이다.
전부 `X-Internal-Token` 헤더 필수. 미구현 계층은 **501** 로 거절한다(200 + 빈 결과로 두면
Spring 이 "계층 정상 완료, 산출물 없음"으로 기록해 미구현이 품질 문제로 위장된다).
-지금은 열 개가 다 붙어 있어 501 을 내는 경로가 없다.
+지금은 열한 개가 다 붙어 있어 501 을 내는 경로가 없다.
| ID | 경로 | 상태 |
|---|---|---|
@@ -39,6 +39,7 @@ Spring 이 "계층 정상 완료, 산출물 없음"으로 기록해 미구현이
| **AI-08** | `POST /internal/vector/upsert` | **구현됨** |
| **AI-09** | `POST /internal/similar` | **구현됨** |
| **AI-10** | `GET /internal/health` | **구현됨** |
+| **AI-11** | `POST /internal/layers/overview/summarize-meeting` | **구현됨** |
AI-10 이 돌려주는 `implemented` 목록과 실제 라우팅이 어긋나지 않는지 테스트가 검증한다
(`test_internal_auth.py`). 계층을 붙이고 목록을 잊으면 워커가 미구현 계층을 부른다.
diff --git a/app/clients/gemini.py b/app/clients/gemini.py
index d2c0fc2..5ccf7c9 100644
--- a/app/clients/gemini.py
+++ b/app/clients/gemini.py
@@ -40,6 +40,20 @@ def __init__(self, settings: Settings) -> None:
self._settings = settings
self._client = None # 최초 호출 때 만든다 (DRY_RUN 이면 아예 안 만든다)
+ # 동시에 제공자에게 나가는 호출 수 상한.
+ #
+ # ⚠ **이 객체가 프로세스에 하나여야 상한이 성립한다.** 요청마다 새로 만들면 각자
+ # 자기 몫의 상한을 갖게 되어 아무것도 제한하지 않는다(routers/internal.py 의
+ # _gemini_client 가 하나로 유지한다).
+ #
+ # 왜 필요한가 — Spring 이 주제 단위 계층(L3·L3.5·L4)을 주제별로 동시에 부른다
+ # (BACKEND 주제 병렬화). 회의 하나에 주제가 7개면 그만큼이 한꺼번에 나가고, 분석이
+ # 두 건 겹치면 그 두 배다. 제공자 레이트리밋을 **우리가 스스로 당기는** 모양이 된다.
+ #
+ # 상한을 여기 두는 이유는 Spring 쪽 풀 크기로는 못 막기 때문이다 — 인스턴스가 늘거나
+ # 다른 호출자가 붙으면 그 가정이 깨진다. 제공자로 나가는 문이 여기 하나뿐이다.
+ self._limiter = asyncio.Semaphore(settings.gemini_max_concurrency)
+
def _ensure_client(self):
if self._client is None:
if not self._settings.gemini_api_key:
@@ -72,11 +86,14 @@ async def generate_json(self, *, prompt: str, response_schema: dict) -> tuple[di
for attempt in range(attempts):
try:
- response = await client.aio.models.generate_content(
- model=self._settings.gemini_model,
- contents=prompt,
- config=config,
- )
+ # ⚠ 상한은 **호출 하나만** 감싼다. 아래 백오프 sleep 까지 잡고 있으면 기다리는
+ # 동안 남의 자리를 막는다 — 그때 제공자는 놀고 우리만 줄을 선다.
+ async with self._limiter:
+ response = await client.aio.models.generate_content(
+ model=self._settings.gemini_model,
+ contents=prompt,
+ config=config,
+ )
return self._parse(response)
except LayerError:
raise
diff --git a/app/config.py b/app/config.py
index 2388bf6..72d8e7b 100644
--- a/app/config.py
+++ b/app/config.py
@@ -36,6 +36,19 @@ class Settings(BaseSettings):
# 측정할 때 시드를 바꿔가며 잡음 바닥을 재려면 밖에서 줄 수 있어야 하기 때문이다.
gemini_seed: int = 20260814
+ # 동시에 제공자에게 나가는 호출 수 상한.
+ #
+ # Spring 이 주제 단위 계층을 주제별로 동시에 부르기 시작하면서 필요해졌다 — 회의 하나에
+ # 주제가 7개면 그만큼이 한꺼번에 나가고, 분석이 두 건 겹치면 그 두 배다. **제공자
+ # 레이트리밋을 우리가 스스로 당기는** 모양이 된다.
+ #
+ # Spring 쪽 풀 크기로는 못 막는다 — 인스턴스가 늘거나 다른 호출자가 붙으면 그 가정이
+ # 깨진다. 제공자로 나가는 문이 이 프로세스 하나뿐이라 여기가 상한을 두기에 맞는 자리다.
+ #
+ # 4 는 실측 없이 정한 값이다. 계층 호출이 I/O 대기라 CPU 와 무관하고, 늘리면 지연이
+ # 줄지만 429 가 가까워진다. 실제 쿼터가 정해지면 그 값으로 다시 잡아야 한다.
+ gemini_max_concurrency: int = 4
+
# 임베딩 모델(AI-08·09). 생성 모델과 **따로 둔다** — 계층 모델을 특화 모델로 갈아끼울 때
# 임베딩까지 함께 바뀌면 기존 컬렉션 전체를 못 쓰게 된다. 두 축은 독립적으로 움직인다.
gemini_embed_model: str = "gemini-embedding-001"
diff --git a/app/layers/l5.py b/app/layers/l5.py
index 5ee9482..748e040 100644
--- a/app/layers/l5.py
+++ b/app/layers/l5.py
@@ -21,6 +21,7 @@
import asyncio
import json
+from dataclasses import dataclass
from app.errors import LayerError, LayerErrorKind
from app.layers import formatting as fmt
@@ -66,6 +67,67 @@ def build_response_schema() -> dict:
async def verify(request: VerifyRequest, runner: LayerRunner) -> VerifyResponse:
+ """두 관점을 돌리고, **갈렸을 때만** 두 번 더 돌려 그 갈림이 잡음이었는지 기록한다.
+
+
⚠ 판정은 바꾸지 않는다 — 세는 것까지다
+ 첫 회차가 갈리면 `agree=False` 다. 재실행에서 두 번 다 동의로 나와도 그대로다.
+ 바뀌는 것은 `tie_broken` 이 켜지는 것뿐이다.
+
+ 다수결로 덮고 싶은 유혹이 있다 — 모델이 같은 입력에 같은 출력을 주지 않으므로
+ (2026-08-15 실측: L3.5 가 같은 프롬프트 3회에 항목 14건 중 3건씩 뒤집혔다) 잡음 한 번에
+ 억울하게 검토로 떨어지는 배정이 있을 것이다. 그런데 **그 "있을 것"이 아직 추정이다.**
+ L3.5 에서 잰 값이고 L5 에서 잰 적이 없다.
+
+ 그리고 이 계층이 지키는 것은 정답 선택이 아니라 **불확실성 탐지**다
+ (`test_불일치는_다수결로_덮지_않는다` 가 그 결정을 이름으로 못박고 있다). 뒤집는 쪽이
+ 틀렸을 때의 대가가 비대칭이다 —
+
+ 맞았을 때 검토 목록에서 한두 건이 빠진다(사람 클릭 몇 번)
+ 틀렸을 때 진짜 오배정이 자동확정으로 나가 보드에 꽂힌다. 회수 경로가 없다
+
+ 이 저장소가 반복해서 지켜 온 "틀린 값보다 빈 값"의 방향을 근거 없이 뒤집지 않는다.
+
+ 그래서 지금은 데이터를 모은다
+ `tie_broken` 을 세면 곧 **L5 의 잡음 비율**이다("첫 회차 갈림 중 몇 %가 재실행에서
+ 동의로 뒤집혔나"). 그 값이 나오면 판정까지 뒤집을지 정할 수 있고, 그 전환은 아래
+ `_respond(...)` 에 `agree=True` 를 넘기는 한 줄이다.
+
+ 잡음이 높다 → 다수결로 회수할 값이 있다
+ 잡음이 낮다 → 갈림이 대개 진짜다. 다수결은 물론 이 재실행 자체도 걷어낸다
+
+ 왜 갈렸을 때만 돌리나
+ **행복 경로에 비용을 물리지 않는다.** 첫 회차가 동의하면 거기서 끝이다. Gemini 쿼터가
+ 병목인 상황에서 이 차이가 곧 "같은 한도로 회의를 몇 건 더 돌리는가"다.
+ """
+ first = await _run_once(request, runner)
+ if first.agree:
+ return _respond(first, runner, tie_broken=False)
+
+ # 갈렸다. **판정을 바꾸려는 것이 아니라** 이 갈림이 잡음이었는지 세려고 두 번 더 묻는다.
+ others = await asyncio.gather(
+ _run_once(request, runner),
+ _run_once(request, runner),
+ return_exceptions=False,
+ )
+
+ rounds = [first, *others]
+ # 재실행이 둘 다 동의했다 = 첫 회차가 잡음이었을 가능성이 크다. 그 사실만 남긴다.
+ tie_broken = all(round_.agree for round_ in others)
+ return _respond(first, runner, tie_broken=tie_broken, extra=rounds[1:])
+
+
+@dataclass(frozen=True)
+class _Round:
+ """한 회차의 두 관점과 그 판정. 회차끼리 비교하려면 판정을 값으로 들고 있어야 한다."""
+
+ narrow: ViewResult
+ verify: ViewResult
+ agree: bool
+ disagreements: list[str]
+
+
+async def _run_once(request: VerifyRequest, runner: LayerRunner) -> _Round:
+ """두 관점을 한 번 돌려 판정까지 낸다. 예전 verify() 본문 그대로다."""
narrow, verify_result = await asyncio.gather(
_run_narrow(request, runner),
_run_verify(request, runner),
@@ -85,12 +147,34 @@ async def verify(request: VerifyRequest, runner: LayerRunner) -> VerifyResponse:
# 검토 여부는 **갈린 필드 전부가 아니라 BLOCKING_FIELDS 로만** 정한다. title 표현 차이로
# 사람을 부르면 검토 목록이 부풀어 진짜 오배정이 그 사이에 묻힌다(BLOCKING_FIELDS 주석).
agree = not blocking(disagreements) and not narrow.error and verify_result.verdict == "ACCEPT"
+ return _Round(narrow=narrow, verify=verify_result, agree=agree, disagreements=disagreements)
+
+def _respond(
+ first: _Round,
+ runner: LayerRunner,
+ *,
+ tie_broken: bool,
+ extra: list[_Round] | None = None,
+) -> VerifyResponse:
+ """**첫 회차의 판정이 곧 응답이다.** 재실행은 세기만 하고 결과를 바꾸지 않는다.
+
+ 판정까지 뒤집으려면 여기에 `agree` 를 받는 인자를 더하면 된다 — 그 전환은 `tie_broken`
+ 수치를 보고 정한다(verify 주석). 지금 그 인자를 미리 만들어 두지 않는 이유는, 안 쓰는
+ 경로가 있으면 "이미 그렇게 도는 줄" 알게 되기 때문이다.
+
+ results 에는 돌린 관점을 전부 싣고 usage 도 전부 더한다. 재실행 비용을 응답에서 숨기면
+ QLTY-03 비용 집계가 실제보다 적게 잡히고, **"이 측정이 얼마나 비싼가"** 를 나중에 판단할
+ 수 없다 — 그 값이 재실행을 걷어낼지 정하는 다른 한 축이다.
+ """
+ rounds = [first, *(extra or [])]
+ views = [view for round_ in rounds for view in (round_.narrow, round_.verify)]
return VerifyResponse(
- agree=agree,
- disagreement_fields=disagreements,
- results=[narrow, verify_result],
- usage=_sum_usage(narrow, verify_result),
+ agree=first.agree,
+ disagreement_fields=first.disagreements,
+ tie_broken=tie_broken,
+ results=views,
+ usage=_sum_usage(*views),
model=runner.model_name,
prompt_version=SPEC.prompt_version,
)
@@ -216,12 +300,32 @@ def _match(candidates: list[AssignmentTuple], baseline: AssignmentTuple) -> Assi
def _verify_variables(request: VerifyRequest) -> dict[str, str]:
+ """VERIFY 도 좁은 시야로 본다 — 예전에는 여기만 전체 발화를 실었다.
+
+ 프롬프트(l5_verify.v1.txt)가 이렇게 못박고 있다 —
+
+ 그 발화(와 바로 앞뒤 문맥)만으로 판단한다.
+ 회의 전체를 뒤져 근거를 새로 찾아주지 않는다 — 그렇게 하면 검증이 아니라
+ 두 번째 추출이 된다.
+
+ 전체를 넘기면 그 규칙이 부탁이 된다. `_evidence_window` 주석이 이미 답을 적어 두었다 —
+ **관점을 좁히는 유일하게 확실한 방법은 넘기지 않는 것**이다. NARROW 는 그렇게 하고
+ 있었고 VERIFY 만 아니었다.
+
+ ⚠ 두 관점이 같은 발화를 보게 되는 것 아닌가 — 아니다. 좁히는 것은 **문맥의 폭**이고,
+ 두 관점을 가르는 것은 프롬프트다(NARROW 는 재추출, VERIFY 는 검사). 같은 재료로 다른
+ 질문을 하는 것이 이 계층의 설계이고, 서로 다른 재료를 보게 하는 것이 아니다.
+
+ 덤으로 입력 토큰이 줄어든다. L5 는 실측에서 **회의 입력 토큰의 26%** 였고 그 대부분이
+ 같은 발화를 관점마다 통째로 다시 싣는 데서 나왔다. 쿼터가 병목일 때 이 차이가 곧
+ "같은 한도로 회의를 몇 건 더 돌리는가"가 된다.
+ """
return {
"TOPIC": request.topic,
"MEETING_DATE": fmt.format_meeting_date(request.meeting_date),
"PARTICIPANTS": fmt.format_participants(request.participants),
"TARGET_TUPLE": _format_target(request.tuple, request.participants),
- "UTTERANCES": fmt.format_utterances(request.utterances),
+ "UTTERANCES": fmt.format_utterances(_evidence_window(request)),
}
diff --git a/app/routers/internal.py b/app/routers/internal.py
index 195dbba..ad6a023 100644
--- a/app/routers/internal.py
+++ b/app/routers/internal.py
@@ -7,6 +7,8 @@
미구현이 품질 문제로 위장된다.
"""
+from functools import lru_cache
+
from fastapi import APIRouter, Depends
from app.clients.embedding import TASK_DOCUMENT, EmbeddingClient
@@ -54,8 +56,22 @@
]
+@lru_cache(maxsize=1)
+def _gemini_client() -> GeminiClient:
+ """제공자 클라이언트를 **프로세스에 하나로** 유지한다.
+
+ ⚠ 요청마다 만들면 두 가지가 깨진다 —
+
+ 동시 호출 상한 인스턴스마다 자기 몫의 세마포어를 갖게 되어 아무것도 제한하지 않는다
+ 연결 재사용 genai.Client 가 매 요청 새로 만들어져 커넥션 풀이 그때마다 버려진다
+
+ 설정은 get_settings 가 이미 캐시하므로(lru_cache) 여기서 다시 받아도 같은 객체다.
+ """
+ return GeminiClient(get_settings())
+
+
def get_runner(settings: Settings = Depends(get_settings)) -> LayerRunner:
- return LayerRunner(GeminiClient(settings), settings)
+ return LayerRunner(_gemini_client(), settings)
# ── 계층 (파이프라인 순서대로) ────────────────────────────────────────────────
@@ -132,10 +148,18 @@ async def summarize_meeting(
return await overview.summarize_meeting(request, runner)
-# ── AI-10 · 헬스체크 — 워커 백오프 판단용 ──────────────────────────────────────
-# 인프라 liveness(/health, 무인증)와 구분한다. 이쪽은 "계층을 받을 준비가 됐는지"이므로
-# 토큰을 요구하고 모델 설정 여부까지 본다 — 키가 없는 채로 살아 있으면 워커가
-# 계속 태우다 전부 실패한다.
+# ── AI-10 · 헬스체크 — 운영 점검·구현 목록 노출용 ─────────────────────────────
+# 인프라 liveness(/health, 무인증)와 구분한다. 이쪽은 "계층을 받을 준비가 됐는지"라
+# 토큰을 요구하고 모델 설정 여부까지 본다 — status: UP 만으로는 **떠 있지만 일은 못 하는**
+# 서버(키 없음·dryRun·계층 덜 배포됨)를 구분할 수 없다.
+#
+# ⚠ Spring 소비처 없음(의도) — 2026-08-16 전수 대조로 확인했다. 예전 주석은 '워커 백오프
+# 판단용'이라 적었으나 그렇게 부르는 코드는 없었다. 지금 이 응답은 사람이 배포를 확인할 때
+# 읽는다. 소비처를 지어내지 않으려고 그 문구를 걷어낸다.
+#
+# 응답 필드는 줄이지 않는다 — 언젠가 기계가 읽는다면 geminiConfigured·dryRun·implemented 가
+# 판단의 핵심이 된다. 다만 그 자리는 매 주기 백오프가 아니라 **기동·배포 게이트**가 맞다고
+# 본다(주기마다 왕복이 붙고, 체크와 호출 사이 틈에서 조용히 일을 건너뛰는 실패가 생긴다).
@router.get("/health")
async def internal_health(settings: Settings = Depends(get_settings)) -> dict:
return {
@@ -187,6 +211,7 @@ async def vector_upsert(
# 계층은 이 엔드포인트를 거치지 않고 few_shot.lookup 을 직접 부른다(같은 프로세스 안이라
# 왕복시킬 이유가 없다). 이 엔드포인트는 **같은 조회를 밖에서 확인할 수 있게** 열어 둔다 —
# few-shot 이 이상할 때 계층 전체를 돌리지 않고 검색만 떼어 볼 수 있어야 한다.
+# ⚠ Spring 소비처 없음(의도) — 2026-08-16 전수 대조로 확인했다. 고아로 보인다고 지우지 마라.
@router.post("/similar", response_model=SimilarResponse)
async def similar(
request: SimilarRequest,
diff --git a/app/schemas/l5.py b/app/schemas/l5.py
index 291ce10..76e6b60 100644
--- a/app/schemas/l5.py
+++ b/app/schemas/l5.py
@@ -98,8 +98,23 @@ class VerifyResponse(CamelModel):
agree: bool
# 두 관점이 갈린 필드 **전부**. 검토 여부를 정하는 것은 이 중 BLOCKING_FIELDS 뿐이다.
+ #
disagreement_fields: list[str] = Field(default_factory=list)
+ # 첫 회차가 갈렸는데 **재실행 두 번이 모두 동의**했는가 — 즉 그 갈림이 잡음이었을 가능성.
+ #
+ # ⚠ **이 값이 true 여도 agree 는 바뀌지 않는다.** 판정은 첫 회차 그대로이고 여기서는 세기만
+ # 한다. 모델이 같은 입력에 같은 출력을 주지 않는다는 것은 알지만(2026-08-15 실측),
+ # 그건 L3.5 에서 잰 값이고 **L5 에서 잰 적이 없다.** 미검증 전제로 판정을 뒤집으면
+ # 진짜 오배정이 자동확정으로 나갈 수 있고, 이 계층이 막으려던 것이 정확히 그것이다.
+ #
+ # 이 값을 세면 곧 **L5 의 잡음 비율**이다("첫 회차 갈림 중 몇 %가 재실행에서 뒤집혔나").
+ # 그 수치가 다음 결정의 근거가 된다 —
+ # 높으면 다수결로 회수할 값이 있다 → 판정까지 뒤집는다
+ # 낮으면 갈림이 대개 진짜다 → 다수결은 물론 이 재실행 자체를 걷어낸다
+ tie_broken: bool = False
+
+ # 돌린 관점 전부. 재실행이 있었으면 회차마다 둘씩 쌓여 여섯 개다.
results: list[ViewResult] = Field(default_factory=list)
usage: Usage = Field(default_factory=Usage)
model: str
diff --git a/tests/test_gemini_concurrency.py b/tests/test_gemini_concurrency.py
new file mode 100644
index 0000000..8f58598
--- /dev/null
+++ b/tests/test_gemini_concurrency.py
@@ -0,0 +1,135 @@
+"""제공자 동시 호출 상한.
+
+Spring 이 주제 단위 계층(L3·L3.5·L4)을 **주제별로 동시에** 부르기 시작하면서 필요해졌다.
+회의 하나에 주제가 7개면 그만큼이 한꺼번에 나가고 분석이 두 건 겹치면 그 두 배다 —
+제공자 레이트리밋을 우리가 스스로 당기는 모양이 된다.
+
+여기서 지키는 것은 둘이다. **상한이 실제로 걸리는가**, 그리고 **백오프 대기가 남의 자리를
+막지 않는가**. 후자를 놓치면 한 호출이 30초를 쉬는 동안 그 자리가 비어 있는데도 다른 호출이
+줄을 선다 — 제공자는 놀고 우리만 느려진다.
+"""
+
+import asyncio
+
+import pytest
+
+from app.clients.gemini import GeminiClient
+from app.config import Settings
+
+
+class _FakeModels:
+ """동시 실행 수를 세는 가짜 제공자. 호출을 붙잡아 겹침을 만든다."""
+
+ def __init__(self, hold: asyncio.Event) -> None:
+ self._hold = hold
+ self.in_flight = 0
+ self.peak = 0
+ self.started = asyncio.Event()
+
+ async def generate_content(self, *, model, contents, config):
+ self.in_flight += 1
+ self.peak = max(self.peak, self.in_flight)
+ self.started.set()
+ try:
+ await self._hold.wait()
+ return _FakeResponse()
+ finally:
+ self.in_flight -= 1
+
+
+class _FakeResponse:
+ text = '{"ok": true}'
+ usage_metadata = None
+
+
+def _client(limit: int, models) -> GeminiClient:
+ client = GeminiClient(Settings(internal_token="t", gemini_max_concurrency=limit))
+ fake = type("Fake", (), {"aio": type("Aio", (), {"models": models})()})()
+ client._ensure_client = lambda: fake # type: ignore[method-assign]
+ return client
+
+
+@pytest.mark.asyncio
+async def test_상한을_넘겨_동시에_부르지_않는다():
+ hold = asyncio.Event()
+ models = _FakeModels(hold)
+ client = _client(2, models)
+
+ calls = [
+ asyncio.create_task(client.generate_json(prompt="p", response_schema={"type": "OBJECT"}))
+ for _ in range(5)
+ ]
+ await models.started.wait()
+ # 붙잡힌 동안 나머지가 밀려 들어올 틈을 준다.
+ await asyncio.sleep(0.05)
+
+ peak_while_held = models.peak
+ hold.set()
+ await asyncio.gather(*calls)
+
+ # 5개를 한꺼번에 던졌지만 제공자에게는 2개까지만 나갔다.
+ assert peak_while_held == 2
+ assert models.peak == 2
+
+
+@pytest.mark.asyncio
+async def test_상한이_크면_전부_동시에_나간다():
+ """상한이 병목이 아닐 때는 붙잡지 않는다 — 위 테스트가 우연히 통과한 것이 아님을 본다."""
+ hold = asyncio.Event()
+ models = _FakeModels(hold)
+ client = _client(5, models)
+
+ calls = [
+ asyncio.create_task(client.generate_json(prompt="p", response_schema={"type": "OBJECT"}))
+ for _ in range(5)
+ ]
+ await models.started.wait()
+ await asyncio.sleep(0.05)
+
+ peak_while_held = models.peak
+ hold.set()
+ await asyncio.gather(*calls)
+
+ assert peak_while_held == 5
+
+
+@pytest.mark.asyncio
+async def test_백오프로_쉬는_동안에는_자리를_비운다():
+ """⚠ 상한이 재시도 대기까지 감싸면 기다리는 동안 남의 자리를 막는다.
+
+ 429 는 Retry-After 를 존중해 최대 30초를 쉰다. 그 시간을 잡고 있으면 제공자는 노는데
+ 우리만 줄을 서고, 상한이 처리량을 깎는 장치가 된다.
+ """
+ settings = Settings(
+ internal_token="t",
+ gemini_max_concurrency=1,
+ retry_delays_sec=(0.05,),
+ )
+ client = GeminiClient(settings)
+
+ attempts = {"n": 0}
+ other_ran = asyncio.Event()
+
+ class _FlakyModels:
+ async def generate_content(self, *, model, contents, config):
+ attempts["n"] += 1
+ if attempts["n"] == 1:
+ # 첫 시도는 일시적 실패 — 호출자가 백오프로 쉰다.
+ raise RuntimeError("503 Service Unavailable")
+ return _FakeResponse()
+
+ fake = type("Fake", (), {"aio": type("Aio", (), {"models": _FlakyModels()})()})()
+ client._ensure_client = lambda: fake # type: ignore[method-assign]
+
+ async def other() -> None:
+ # 상한이 1 이므로, 백오프가 자리를 잡고 있으면 이 호출은 그 시간만큼 못 들어간다.
+ await client.generate_json(prompt="other", response_schema={"type": "OBJECT"})
+ other_ran.set()
+
+ first = asyncio.create_task(client.generate_json(prompt="p", response_schema={"type": "OBJECT"}))
+ second = asyncio.create_task(other())
+
+ # 백오프(0.05초)보다 짧게 기다려도 두 번째가 들어갈 수 있어야 한다.
+ await asyncio.wait_for(other_ran.wait(), timeout=1.0)
+
+ await asyncio.gather(first, second)
diff --git a/tests/test_l5_verify.py b/tests/test_l5_verify.py
index 7fe0d1b..ae39e48 100644
--- a/tests/test_l5_verify.py
+++ b/tests/test_l5_verify.py
@@ -12,6 +12,7 @@
NOT_REPRODUCED,
_evidence_window,
_match,
+ _verify_variables,
blocking,
build_response_schema,
compare,
@@ -172,6 +173,32 @@ def test_근거_발화가_문맥에_없으면_전부_넘긴다(self):
assert len(_evidence_window(broken)) == len(UTTERANCES)
+ def test_VERIFY_도_같은_창만_본다(self):
+ """프롬프트가 "회의 전체를 뒤지지 말라"고 못박는데 코드가 전체를 실으면 그건 부탁이 된다.
+
+ 예전에는 NARROW 만 창을 좁히고 VERIFY 는 request.utterances 전체를 실었다. 그래서
+ 검증 관점이 근거 밖에서 새 근거를 찾아 "검증이 아니라 두 번째 추출"이 될 수 있었고,
+ 같은 발화가 관점마다 통째로 다시 실려 입력 토큰의 26% 를 차지했다.
+ """
+ variables = _verify_variables(request())
+
+ # 창 안(302~308)만 있고 밖(300·301·309)은 없다.
+ assert "발화 2" in variables["UTTERANCES"]
+ assert "발화 8" in variables["UTTERANCES"]
+ assert "발화 0" not in variables["UTTERANCES"]
+ assert "발화 9" not in variables["UTTERANCES"]
+
+ def test_근거를_못_찾으면_VERIFY_도_전부_본다(self):
+ # 좁힐 기준이 없으면 좁히지 않는다 — 창을 잘못 잡아 근거를 빼는 것보다 낫다.
+ broken = request().model_copy(
+ update={"tuple": BASELINE.model_copy(update={"evidence_utterance_id": 9999})}
+ )
+
+ variables = _verify_variables(broken)
+
+ assert "발화 0" in variables["UTTERANCES"]
+ assert "발화 9" in variables["UTTERANCES"]
+
class TestMatch:
def test_근거_발화로_짝을_맞춘다(self):
@@ -227,6 +254,74 @@ async def test_불일치는_다수결로_덮지_않는다(self, patch_narrow):
assert response.agree is False
assert response.disagreement_fields == ["assigneeCandidatePersonId"]
+ async def test_재실행이_모두_동의해도_판정은_그대로다(self, monkeypatch, patch_narrow):
+ """2026-08-15 — 갈렸을 때 두 번 더 묻지만 **세기만 한다.**
+
+ 모델이 같은 입력에 같은 출력을 주지 않는 것은 안다(L3.5 에서 쟀다). 하지만 L5 에서
+ 재본 적이 없고, 미검증 전제로 판정을 뒤집으면 진짜 오배정이 자동확정으로 나간다 —
+ 이 계층이 막으려던 것이 그것이다. 그래서 tie_broken 만 켜고 agree 는 건드리지 않는다.
+ """
+ # 1회차는 담당자가 갈리고, 2·3회차는 동의한다.
+ narrow_results = [
+ BASELINE.model_copy(update={"assignee_candidate_person_id": 42}),
+ BASELINE.model_copy(),
+ BASELINE.model_copy(),
+ ]
+ calls = {"n": 0}
+
+ async def fake(request_, runner):
+ index = min(calls["n"], len(narrow_results) - 1)
+ calls["n"] += 1
+ return ExtractTuplesResponse(
+ tuples=[narrow_results[index]],
+ usage=Usage(tokens_in=100, tokens_out=20),
+ model="fake-model",
+ prompt_version="v1",
+ )
+
+ monkeypatch.setattr("app.layers.l5.l4.extract_tuples", fake)
+
+ response = await verify(request(), FakeRunner())
+
+ # 판정은 첫 회차 그대로다 — 여기가 이 테스트의 요점이다.
+ assert response.agree is False
+ assert response.disagreement_fields == ["assigneeCandidatePersonId"]
+ # 다만 "잡음이었을 수 있다"는 사실은 남는다. 이 값을 세면 L5 의 잡음 비율이 된다.
+ assert response.tie_broken is True
+ # 재실행 비용을 숨기지 않는다 — 회차마다 관점 둘이라 셋이면 여섯이다.
+ assert len(response.results) == 6
+
+ async def test_재실행도_갈리면_잡음이_아니다(self, patch_narrow):
+ # 세 회차 모두 같은 불일치 — 이건 흔들림이 아니라 진짜다.
+ patch_narrow(tuples=[BASELINE.model_copy(update={"assignee_candidate_person_id": 42})])
+
+ response = await verify(request(), FakeRunner())
+
+ assert response.agree is False
+ assert response.tie_broken is False
+
+ async def test_첫_회차가_동의하면_다시_묻지_않는다(self, monkeypatch):
+ """행복 경로에 비용을 물리지 않는다 — 쿼터가 병목일 때 이 차이가 크다."""
+ calls = {"n": 0}
+
+ async def fake(request_, runner):
+ calls["n"] += 1
+ return ExtractTuplesResponse(
+ tuples=[BASELINE.model_copy()],
+ usage=Usage(tokens_in=100, tokens_out=20),
+ model="fake-model",
+ prompt_version="v1",
+ )
+
+ monkeypatch.setattr("app.layers.l5.l4.extract_tuples", fake)
+
+ response = await verify(request(), FakeRunner())
+
+ assert response.agree is True
+ assert response.tie_broken is False
+ assert calls["n"] == 1
+ assert len(response.results) == 2
+
async def test_VERIFY가_REJECT면_일치해도_검토로_보낸다(self, patch_narrow):
patch_narrow(tuples=[BASELINE.model_copy()])
runner = FakeRunner({"verdict": "REJECT", "reason": "근거 발화에 담당자 지목이 없음"})