Skip to content

Commit ca8b433

Browse files
authored
[FEAT] AI-05·07 판정 계층 2종 복구 — develop 에서 끊겼던 코드
[FEAT] AI-05·07 판정 계층 2종 복구 — develop 에서 끊겼던 코드
2 parents 91295ed + e18accb commit ca8b433

13 files changed

Lines changed: 1066 additions & 18 deletions

File tree

‎README.md‎

Lines changed: 26 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -32,9 +32,9 @@ Spring 이 "계층 정상 완료, 산출물 없음"으로 기록해 미구현이
3232
| **AI-02** | `POST /internal/layers/l1-5/resolve-reference` | **구현됨** |
3333
| **AI-03** | `POST /internal/layers/l2/segment-topics` | **구현됨** |
3434
| **AI-04** | `POST /internal/layers/l3/summarize-topic` | **구현됨** |
35-
| AI-05 | `POST /internal/layers/l3-5/gate` | 예정 8/6 |
35+
| **AI-05** | `POST /internal/layers/l3-5/gate` | **구현됨** |
3636
| **AI-06** | `POST /internal/layers/l4/extract-tuples` | **구현됨** |
37-
| AI-07 | `POST /internal/layers/l5/verify` | 예정 8/6 |
37+
| **AI-07** | `POST /internal/layers/l5/verify` | **구현됨** |
3838
| AI-08 | `POST /internal/vector/upsert` | 예정 8/9 |
3939
| AI-09 | `POST /internal/similar` | 예정 8/9 |
4040
| **AI-10** | `GET /internal/health` | **구현됨** |
@@ -54,8 +54,26 @@ L4(`app/layers/l4.py`)가 나머지 계층의 원본 틀이다. 복제해서 **
5454
2. **`build_response_schema()`** — 참석자·발화를 enum 으로 박은 응답 스키마
5555
3. **후처리** — 파싱된 dict → 계층 DTO
5656

57-
호출·재시도·토큰 집계는 `app/layers/runner.py` 가 공통으로 갖는다. 계층마다 따로 쓰면
58-
같은 버그를 아홉 번 고치게 된다.
57+
호출·재시도·토큰 집계는 `app/layers/runner.py` 가, 참석자·발화 포맷과 값 되돌리기는
58+
`app/layers/formatting.py` 가 공통으로 갖는다. 계층마다 따로 쓰면 같은 버그를 아홉 번
59+
고치게 되고, 실제로는 아홉 번째를 빠뜨린다.
60+
61+
라우팅을 붙였으면 `internal.py` 의 `IMPLEMENTED` 도 함께 고친다 — 워커가 그 목록을 보고
62+
호출 여부를 정한다.
63+
64+
### 후처리가 프롬프트보다 강한 자리들
65+
66+
계층마다 "프롬프트가 부탁하던 것"을 코드가 강제로 바꾼 지점이 하나씩 있다. 새 계층을
67+
만들 때도 같은 것을 찾아볼 것 — 여기가 이 서버에서 정확도가 실제로 만들어지는 곳이다.
68+
69+
| 계층 | 부탁 대신 강제한 것 |
70+
|---|---|
71+
| L1.5 | `surface` 가 그 발화 문자열에 실제로 있는지 확인. 없으면 버린다 — 근거 강제를 문자열 수준까지 |
72+
| L2 | 모델에게 **시작점만** 받고 구간은 코드가 계산. 겹침·구멍이 생길 방법 자체가 없다. 오버랩 3발화도 코드가 붙인다 |
73+
| L3 | 분류가 깨지면 버리지 않고 `DISCUSSION` 으로 내린다 — 버리면 내용이 사라지고, 내리면 사람이 한 번 더 볼 뿐이다 |
74+
| L3.5 | **판정이 없는 항목은 `DISCUSSED`.** 누락이 통과가 되지 않는다 — precision 우선의 실체 |
75+
| L4 | 참석자·근거 발화를 enum 으로 박고 후처리에서 한 번 더 검증 |
76+
| L5 | 한 관점이 실패하면 `agree=false`. 둘 다 실패하면 **계층 실패로 던진다** — 검증이 안 돈 것을 '갈렸다'로 기록하지 않는다 |
5977

6078
### 정확도 4원칙은 프롬프트가 아니라 스키마로 걸린다
6179

@@ -110,6 +128,10 @@ uv run ruff check . # 린트
110128
`retryable` 을 응답 본문에 넣는다. Spring 이 메시지 문자열로 재시도를 추측하게 두면
111129
영구 실패를 세 번 재시도해 토큰만 태운다 — 판정은 실패를 만든 쪽이 한다.
112130

131+
**실패 사유에 제공자 응답 본문을 싣지 않는다.** L5 의 `results[].error` 는 오류 코드까지만
132+
담는다(`RATE_LIMITED` 등) — 본문에 무엇이 들어 있을지 보증할 수 없고, 그게 우리 API 응답을
133+
타고 나가면 되돌릴 수 없다. 본문이 필요한 진단은 서버 로그를 본다.
134+
113135
---
114136

115137
## 이미지 · 배포

‎app/layers/l3_5.py‎

Lines changed: 147 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,147 @@
1+
"""L3.5 · 확정/논의 게이트 (AI-05).
2+
3+
L4(`l4.py`)를 복제해 세 곳만 바꾼 것이다 — SPEC · build_response_schema · 후처리.
4+
5+
**precision 우선을 프롬프트가 아니라 후처리로 강제한다.** 프롬프트의 "애매하면
6+
DISCUSSED" 는 지켜지지 않을 확률이 남지만, 여기서는 그 확률이 남는 자리를 없앤다:
7+
8+
모델이 판정을 돌려주지 않은 항목 → DISCUSSED
9+
10+
즉 **누락이 통과가 되지 않는다.** 반대로 두면(누락 = CONFIRMED) 응답이 잘리거나
11+
항목 하나를 건너뛴 것만으로 합의되지 않은 일이 배정으로 나간다. 게이트에서 가장
12+
비싼 실패는 그것이다.
13+
"""
14+
15+
from __future__ import annotations
16+
17+
from app.layers import few_shot
18+
from app.layers import formatting as fmt
19+
from app.layers.runner import LayerRunner, LayerSpec
20+
from app.schemas.common import FewShotExample
21+
from app.schemas.l3_5 import (
22+
REASON_MAX,
23+
GateCandidate,
24+
GateRequest,
25+
GateResponse,
26+
GateVerdict,
27+
)
28+
29+
SPEC = LayerSpec(
30+
layer="L3.5",
31+
prompt_file="l3_5_gate.v1.txt",
32+
prompt_version="v1",
33+
dry_run_payload={"verdicts": []},
34+
)
35+
36+
CONFIRMED = "CONFIRMED"
37+
DISCUSSED = "DISCUSSED"
38+
GATE_STATUSES = [CONFIRMED, DISCUSSED]
39+
40+
# 모델이 답하지 않은 항목에 붙는 사유. 사람이 화면에서 이 문구를 보면 "게이트가
41+
# 판정을 못 했다"와 "게이트가 논의로 판정했다"를 구분할 수 있어야 한다.
42+
MISSING_VERDICT_REASON = "게이트가 이 항목을 판정하지 않았다 — 확정으로 올리지 않는다(precision 우선)."
43+
44+
45+
def build_response_schema(items: list[GateCandidate]) -> dict:
46+
"""itemKey 를 요청 항목의 enum 으로 박는다 — 없는 항목에 대한 판정이 나올 수 없게."""
47+
return {
48+
"type": "OBJECT",
49+
"properties": {
50+
"verdicts": {
51+
"type": "ARRAY",
52+
"items": {
53+
"type": "OBJECT",
54+
"properties": {
55+
"itemKey": {"type": "STRING", "enum": [item.item_key for item in items]},
56+
"gateStatus": {"type": "STRING", "enum": GATE_STATUSES},
57+
"reason": {"type": "STRING"},
58+
},
59+
"required": ["itemKey", "gateStatus", "reason"],
60+
"propertyOrdering": ["itemKey", "gateStatus", "reason"],
61+
},
62+
}
63+
},
64+
"required": ["verdicts"],
65+
}
66+
67+
68+
async def gate(request: GateRequest, runner: LayerRunner) -> GateResponse:
69+
examples = await few_shot.lookup(
70+
tenant_id=request.tenant_id,
71+
layer=SPEC.layer,
72+
query_text=request.query_text,
73+
)
74+
75+
if not request.items:
76+
return GateResponse(
77+
verdicts=[],
78+
used_few_shot=examples,
79+
model=runner.model_name,
80+
prompt_version=SPEC.prompt_version,
81+
)
82+
83+
schema = build_response_schema(request.items)
84+
variables = build_prompt_variables(request, examples)
85+
86+
raw, usage = await runner.run(SPEC, variables=variables, response_schema=schema)
87+
88+
return GateResponse(
89+
verdicts=parse_verdicts(raw, request.items),
90+
used_few_shot=examples,
91+
usage=usage,
92+
model=runner.model_name,
93+
prompt_version=SPEC.prompt_version,
94+
)
95+
96+
97+
def build_prompt_variables(request: GateRequest, examples: list[FewShotExample]) -> dict[str, str]:
98+
return {
99+
"TOPIC": request.topic,
100+
"PARTICIPANTS": fmt.format_participants(request.participants),
101+
"ITEMS": _format_items(request.items),
102+
"UTTERANCES": fmt.format_utterances(request.utterances),
103+
"FEW_SHOT": fmt.format_few_shot(examples),
104+
}
105+
106+
107+
def parse_verdicts(raw: dict, items: list[GateCandidate]) -> list[GateVerdict]:
108+
"""**요청 항목 수만큼 정확히 반환한다.** 판정이 없는 항목은 DISCUSSED 로 채운다.
109+
110+
요청 순서를 유지하는 것도 계약이다 — Spring 이 순서로 맞추지는 않지만, 순서가
111+
흔들리면 응답을 눈으로 대조할 때 사람이 항목을 놓친다.
112+
"""
113+
by_key: dict[str, GateVerdict] = {}
114+
115+
for entry in raw.get("verdicts") or []:
116+
if not isinstance(entry, dict):
117+
continue
118+
key = fmt.as_text(entry.get("itemKey"))
119+
status = entry.get("gateStatus")
120+
# 목록 밖 키, 알 수 없는 판정값은 버린다 — 아래에서 DISCUSSED 로 채워진다.
121+
if not key or status not in GATE_STATUSES:
122+
continue
123+
if key in by_key: # 같은 항목을 두 번 판정했다. 먼저 온 것만 쓴다
124+
continue
125+
by_key[key] = GateVerdict(
126+
item_key=key,
127+
gate_status=status,
128+
reason=fmt.clip(fmt.as_text(entry.get("reason")), REASON_MAX)
129+
or "(모델이 판정 근거를 반환하지 않음)",
130+
)
131+
132+
return [
133+
by_key.get(
134+
item.item_key,
135+
GateVerdict(item_key=item.item_key, gate_status=DISCUSSED, reason=MISSING_VERDICT_REASON),
136+
)
137+
for item in items
138+
]
139+
140+
141+
def _format_items(items: list[GateCandidate]) -> str:
142+
lines = [
143+
f"- itemKey={item.item_key} · [{item.item_type}] {item.content}"
144+
f" (근거 발화 id={item.evidence_utterance_id})"
145+
for item in items
146+
]
147+
return "\n".join(lines) or fmt.NONE_MARK

0 commit comments

Comments
 (0)