From 70ec5668586fa6b1e18bf7cfcae3dd037a701307 Mon Sep 17 00:00:00 2001 From: dlxodus02 <13579lty0907@gmail.com> Date: Sun, 16 Aug 2026 16:49:13 +0900 Subject: [PATCH 1/6] =?UTF-8?q?[FIX]=20L5=20=EA=B2=80=EC=A6=9D=20=EA=B4=80?= =?UTF-8?q?=EC=A0=90=EC=9D=B4=20=ED=9A=8C=EC=9D=98=20=EC=A0=84=EC=B2=B4?= =?UTF-8?q?=EB=A5=BC=20=EB=B3=B4=EB=8D=98=20=EA=B2=83=20=E2=80=94=20?= =?UTF-8?q?=ED=94=84=EB=A1=AC=ED=94=84=ED=8A=B8=EA=B0=80=20=EB=AA=BB?= =?UTF-8?q?=EB=B0=95=EC=9D=80=20=EC=B0=BD=EB=A7=8C=20=EB=84=98=EA=B8=B4?= =?UTF-8?q?=EB=8B=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit l5_verify.v1.txt 가 이렇게 적고 있다 — 그 발화(와 바로 앞뒤 문맥)만으로 판단한다. 회의 전체를 뒤져 근거를 새로 찾아주지 않는다 — 그렇게 하면 검증이 아니라 두 번째 추출이 된다. 그런데 _verify_variables 가 request.utterances 를 통째로 실었다. NARROW 는 _evidence_window 로 좁히고 있었고 VERIFY 만 아니었다. 그 창 함수의 주석이 이미 답을 적어 두었다 — **관점을 좁히는 유일하게 확실한 방법은 넘기지 않는 것**이다. 전체를 넘기면 프롬프트의 규칙이 부탁이 되고, 검증 관점이 근거 밖에서 새 근거를 찾을 수 있다. 그건 검증이 아니라 두 번째 추출이라 두 관점의 오류가 상관되고, 앙상블이 잡으려던 불확실성을 못 잡는다. ⚠ 두 관점이 같은 발화를 보게 되는 것이 아니냐 — 좁히는 것은 문맥의 폭이고 두 관점을 가르는 것은 프롬프트다(NARROW 는 재추출, VERIFY 는 검사). 같은 재료로 다른 질문을 하는 것이 이 계층의 설계다. 덤으로 입력 토큰이 줄어든다. L5 는 실측에서 회의 입력 토큰의 26% 였고 대부분이 같은 발화를 관점마다 다시 싣는 데서 나왔다. Gemini 쿼터가 병목인 지금 이 차이가 곧 "같은 한도로 회의를 몇 건 더 돌리는가"다. 테스트 2건 — VERIFY 도 같은 창만 본다 · 근거를 못 찾으면 VERIFY 도 전부 본다(좁힐 기준이 없으면 좁히지 않는다). Co-Authored-By: Claude Opus 5 --- app/layers/l5.py | 22 +++++++++++++++++++++- tests/test_l5_verify.py | 27 +++++++++++++++++++++++++++ 2 files changed, 48 insertions(+), 1 deletion(-) diff --git a/app/layers/l5.py b/app/layers/l5.py index 5ee9482..f5236e2 100644 --- a/app/layers/l5.py +++ b/app/layers/l5.py @@ -216,12 +216,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/tests/test_l5_verify.py b/tests/test_l5_verify.py index 7fe0d1b..44c7f93 100644 --- a/tests/test_l5_verify.py +++ b/tests/test_l5_verify.py @@ -11,6 +11,7 @@ from app.layers.l5 import ( NOT_REPRODUCED, _evidence_window, + _verify_variables, _match, blocking, build_response_schema, @@ -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): From 5c4a08165db1234019b3f80e17208fe0d0157fff Mon Sep 17 00:00:00 2001 From: dlxodus02 <13579lty0907@gmail.com> Date: Mon, 17 Aug 2026 01:45:03 +0900 Subject: [PATCH 2/6] =?UTF-8?q?[CHORE]=20import=20=EC=A0=95=EB=A0=AC=20?= =?UTF-8?q?=E2=80=94=20ruff=20I001?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit CI 의 Ruff lint 게이트가 잡았다. 테스트 실패가 아니라 정렬이다. --- tests/test_l5_verify.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/test_l5_verify.py b/tests/test_l5_verify.py index 44c7f93..7d2d2a8 100644 --- a/tests/test_l5_verify.py +++ b/tests/test_l5_verify.py @@ -11,8 +11,8 @@ from app.layers.l5 import ( NOT_REPRODUCED, _evidence_window, - _verify_variables, _match, + _verify_variables, blocking, build_response_schema, compare, From a86818d546acde867b84e9dd143ebb1f9ce8cc2a Mon Sep 17 00:00:00 2001 From: dlxodus02 <13579lty0907@gmail.com> Date: Mon, 17 Aug 2026 01:57:01 +0900 Subject: [PATCH 3/6] =?UTF-8?q?[CHORE]=20=EC=97=94=EB=93=9C=ED=8F=AC?= =?UTF-8?q?=EC=9D=B8=ED=8A=B8=20=ED=91=9C=EC=97=90=20AI-11=20=EC=9D=B4=20?= =?UTF-8?q?=EB=B9=A0=EC=A0=B8=20=EC=9E=88=EB=8D=98=20=EA=B2=83=20+=20AI-09?= =?UTF-8?q?=20=EA=B0=80=20=EA=B3=A0=EC=95=84=EB=A1=9C=20=EC=98=A4=ED=95=B4?= =?UTF-8?q?=EB=B0=9B=EB=8D=98=20=EA=B2=83?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit README 표가 AI-01~AI-10 만 싣고 있어 이 표를 명세로 읽는 사람은 개요 계층이 없다고 판단한다. 라우터(/internal/layers/overview/summarize-meeting)와 IMPLEMENTED 목록·테스트에는 이미 AI-11 이 있으므로 문서만 뒤처진 것이다. Spring 어댑터에도 같은 표류가 있었고 BACKEND PR #561 로 함께 고쳤다. AI-09 는 Spring 이 부르지 않는 것이 사실이지만 그건 설계다 — 계층은 few_shot.lookup 을 같은 프로세스 안에서 부른다. 감사 때마다 "고아니까 지우자" 가 반복해 올라오므로 라우터 주석에 확인 사실을 한 줄로 못박는다. 동작 변경 없음(문서·주석만). Co-Authored-By: Claude Opus 5 --- README.md | 3 ++- app/routers/internal.py | 1 + 2 files changed, 3 insertions(+), 1 deletion(-) 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/routers/internal.py b/app/routers/internal.py index 195dbba..4af41cb 100644 --- a/app/routers/internal.py +++ b/app/routers/internal.py @@ -187,6 +187,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, From 0a53cb46f7e08a136346ec36e2c58653d48768df Mon Sep 17 00:00:00 2001 From: dlxodus02 <13579lty0907@gmail.com> Date: Mon, 17 Aug 2026 02:10:00 +0900 Subject: [PATCH 4/6] =?UTF-8?q?[CHORE]=20AI-10=20=EC=A3=BC=EC=84=9D?= =?UTF-8?q?=EC=9D=B4=20=EC=97=86=EB=8A=94=20=EC=86=8C=EB=B9=84=EC=B2=98?= =?UTF-8?q?=EB=A5=BC=20=EA=B0=80=EB=A6=AC=ED=82=A4=EB=8D=98=20=EA=B2=83=20?= =?UTF-8?q?=E2=80=94=20=EC=86=8C=EB=B9=84=EC=B2=98=20=EC=97=86=EC=9D=8C(?= =?UTF-8?q?=EC=9D=98=EB=8F=84)=EC=9C=BC=EB=A1=9C=20=EB=AA=BB=EB=B0=95?= =?UTF-8?q?=EB=8A=94=EB=8B=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit '워커 백오프 판단용'이라 적혀 있었으나 그렇게 부르는 Spring 코드가 없다 (전 소스 grep 0건, 2026-08-16 확인). 인프라 헬스체크도 이쪽이 아니라 무인증 /health 를 쓴다. 주석이 소비처를 지어내면 다음 사람이 "어딘가 배선돼 있구나" 로 읽고 넘어가고, 다음 감사에 같은 항목이 또 올라온다. 배선하는 쪽(A)이 아니라 현실에 맞추는 쪽(B)을 골랐다. 프리체크는 설정 사고를 고치지 못하고 조용하게 만들 뿐이라, 시끄럽게 실패하는 지금이 더 정직하다. 다만 dryRun·implemented 로 "떠 있지만 일은 못 하는" 서버를 거르는 값은 진짜라 그 자리가 기동·배포 게이트라는 판단을 주석에 남긴다. 응답 필드(status·model·geminiConfigured·dryRun·implemented)는 그대로 둔다 — 나중에 게이트를 만들면 그 값들이 판단의 핵심이 된다. 라우팅·스키마 변경 없음. Co-Authored-By: Claude Opus 5 --- app/routers/internal.py | 16 ++++++++++++---- 1 file changed, 12 insertions(+), 4 deletions(-) diff --git a/app/routers/internal.py b/app/routers/internal.py index 4af41cb..1d97216 100644 --- a/app/routers/internal.py +++ b/app/routers/internal.py @@ -132,10 +132,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 { From 6b9d8b786ebdecd1591f582cb4de141391ed6c99 Mon Sep 17 00:00:00 2001 From: dlxodus02 <13579lty0907@gmail.com> Date: Mon, 17 Aug 2026 02:36:12 +0900 Subject: [PATCH 5/6] =?UTF-8?q?[FEAT]=20L5=20=EA=B0=80=20=EA=B0=88?= =?UTF-8?q?=EB=A0=B8=EC=9D=84=20=EB=95=8C=20=EC=9E=A1=EC=9D=8C=EC=9D=B4?= =?UTF-8?q?=EC=97=88=EB=8A=94=EC=A7=80=20=EC=84=BC=EB=8B=A4=20=E2=80=94=20?= =?UTF-8?q?=ED=8C=90=EC=A0=95=EC=9D=80=20=EB=B0=94=EA=BE=B8=EC=A7=80=20?= =?UTF-8?q?=EC=95=8A=EB=8A=94=EB=8B=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 모델은 같은 입력에 같은 출력을 주지 않는다(2026-08-15 실측: L3.5 가 같은 프롬프트 3회에 항목 14건 중 3건씩 판정이 뒤집혔다. temperature=0 · seed 를 넣어도 같았다). 그 흔들림이 L5 에서는 **억울한 검토 대상**으로 나타날 수 있다 — 잡음 한 번에 agree=false 가 되면 그 배정은 자동확정에서 떨어지고 사람이 손으로 확인해야 한다. 다수결로 덮고 싶지만 **하지 않는다.** 두 가지 이유다. 1) 전제가 미검증이다. 잡음은 L3.5 에서 잰 값이고 L5 에서 잰 적이 없다. 크레딧이 소진돼 지금 잴 수도 없다. 2) 이 계층의 목적이 정답 선택이 아니라 불확실성 탐지다. test_불일치는_다수결로_덮지_않는다 가 그 결정을 이름으로 못박고 있고, 클래스 머리말도 같은 말을 한다. 뒤집는 쪽이 틀렸을 때의 대가가 비대칭이다 — 맞았을 때 검토 목록에서 한두 건이 빠진다(사람 클릭 몇 번) 틀렸을 때 진짜 오배정이 자동확정으로 나가 보드에 꽂힌다. 회수 경로가 없다 이 저장소가 반복해서 지켜 온 "틀린 값보다 빈 값"을 근거 없이 뒤집지 않는다. 그래서 지금은 **데이터를 모은다.** 첫 회차가 갈리면 두 번 더 돌리고, 둘 다 동의하면 tieBroken 만 켠다. agree 는 첫 회차 그대로다. tieBroken 을 세면 곧 L5 의 잡음 비율이다("첫 회차 갈림 중 몇 %가 재실행에서 뒤집혔나"). 그 값이 나오면 다음을 정할 수 있다 — 높으면 다수결로 회수할 값이 있다 → 판정까지 뒤집는다(_respond 에 agree 를 넘기는 한 줄) 낮으면 갈림이 대개 진짜다 → 다수결은 물론 이 재실행 자체를 걷어낸다 행복 경로에는 비용을 물리지 않는다. 첫 회차가 동의하면 거기서 끝이다 — Gemini 쿼터가 병목인 상황에서 이 차이가 곧 "같은 한도로 회의를 몇 건 더 돌리는가"다. 재실행 비용을 응답에서 숨기지 않는다. results 에 관점을 전부 싣고 usage 도 전부 더한다 — "이 측정이 얼마나 비싼가"가 재실행을 걷어낼지 정하는 다른 한 축이다. 테스트 3건 — 재실행이 모두 동의해도 판정은 그대로(tieBroken 만 켜짐) · 재실행도 갈리면 tieBroken 이 false · 첫 회차가 동의하면 다시 묻지 않는다. Co-Authored-By: Claude Opus 5 --- app/layers/l5.py | 92 +++++++++++++++++++++++++++++++++++++++-- app/schemas/l5.py | 15 +++++++ tests/test_l5_verify.py | 68 ++++++++++++++++++++++++++++++ 3 files changed, 171 insertions(+), 4 deletions(-) diff --git a/app/layers/l5.py b/app/layers/l5.py index f5236e2..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, ) 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_l5_verify.py b/tests/test_l5_verify.py index 7d2d2a8..ae39e48 100644 --- a/tests/test_l5_verify.py +++ b/tests/test_l5_verify.py @@ -254,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": "근거 발화에 담당자 지목이 없음"}) From bf14ade7af4c5c46c0a07150add47985c00b9b2f Mon Sep 17 00:00:00 2001 From: dlxodus02 <13579lty0907@gmail.com> Date: Mon, 17 Aug 2026 02:54:46 +0900 Subject: [PATCH 6/6] =?UTF-8?q?[FEAT]=20=EC=A0=9C=EA=B3=B5=EC=9E=90=20?= =?UTF-8?q?=EB=8F=99=EC=8B=9C=20=ED=98=B8=EC=B6=9C=EC=97=90=20=EC=83=81?= =?UTF-8?q?=ED=95=9C=EC=9D=84=20=EB=91=94=EB=8B=A4=20=E2=80=94=20=EC=A3=BC?= =?UTF-8?q?=EC=A0=9C=20=EB=B3=91=EB=A0=AC=ED=99=94=EA=B0=80=20=EB=A0=88?= =?UTF-8?q?=EC=9D=B4=ED=8A=B8=EB=A6=AC=EB=B0=8B=EC=9D=84=20=EC=8A=A4?= =?UTF-8?q?=EC=8A=A4=EB=A1=9C=20=EB=8B=B9=EA=B8=B0=EC=A7=80=20=EC=95=8A?= =?UTF-8?q?=EA=B2=8C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit BACKEND 가 주제 단위 계층(L3·L3.5·L4)을 주제별로 동시에 부르기 시작한다. 회의 하나에 주제가 7개면 그만큼이 한꺼번에 나가고, 분석이 두 건 겹치면 그 두 배다 — **제공자 레이트리밋을 우리가 스스로 당기는** 모양이 된다. 2026-08-15 에 크레딧이 마른 뒤라 더 그렇다. 상한을 Spring 쪽 풀 크기로 두지 않는 이유는, 그 가정이 인스턴스가 늘거나 다른 호출자가 붙으면 깨지기 때문이다. **제공자로 나가는 문이 이 프로세스 하나뿐**이라 여기가 맞는 자리다. ## 클라이언트를 프로세스에 하나로 유지한다 get_runner 가 요청마다 GeminiClient 를 새로 만들고 있었다. 그대로 두면 인스턴스마다 자기 몫의 세마포어를 갖게 되어 **아무것도 제한하지 않는다.** lru_cache 로 하나만 둔다. 덤으로 genai.Client 도 하나가 된다 — 지금까지 매 요청 새로 만들어 커넥션 풀을 그때마다 버리고 있었다. ## 상한은 호출 하나만 감싼다 재시도 백오프(429 는 Retry-After 를 존중해 최대 30초)까지 잡고 있으면 **기다리는 동안 남의 자리를 막는다.** 제공자는 노는데 우리만 줄을 서고, 상한이 처리량을 깎는 장치가 된다. 그래서 async with 를 API 호출에만 두고 sleep 은 밖에 둔다. ## 4 는 실측 없이 정한 값이다 계층 호출이 I/O 대기라 CPU 와 무관하다. 늘리면 지연이 줄고 429 가 가까워진다. 실제 쿼터가 정해지면 그 값으로 다시 잡아야 한다. 테스트 3건 — 상한을 넘겨 부르지 않는다 · 상한이 크면 전부 동시에 나간다(위 테스트가 우연히 통과한 것이 아님을 본다) · 백오프로 쉬는 동안에는 자리를 비운다. Co-Authored-By: Claude Opus 5 --- app/clients/gemini.py | 27 +++++-- app/config.py | 13 +++ app/routers/internal.py | 18 ++++- tests/test_gemini_concurrency.py | 135 +++++++++++++++++++++++++++++++ 4 files changed, 187 insertions(+), 6 deletions(-) create mode 100644 tests/test_gemini_concurrency.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/routers/internal.py b/app/routers/internal.py index 195dbba..22ff8c3 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) # ── 계층 (파이프라인 순서대로) ──────────────────────────────────────────────── 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)