From 1823ae12ba07279a9197ce540699a717ddcf6476 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=9C=A4=EC=A7=80=EC=84=9D/AX=EA=B0=9C=EB=B0=9C=ED=8C=80/?= =?UTF-8?q?NE?= Date: Mon, 14 Sep 2026 11:20:20 +0900 Subject: [PATCH] =?UTF-8?q?feat(F-SCR-001):=20=ED=8C=90=EC=A0=95=EC=9D=B4?= =?UTF-8?q?=20=EA=B7=BC=EA=B1=B0=20=EB=A3=A8=EB=B8=8C=EB=A6=AD=EC=9D=98=20?= =?UTF-8?q?=EA=B2=80=ED=86=A0=20=EC=83=81=ED=83=9C=EB=A5=BC=20=EB=93=A4?= =?UTF-8?q?=EA=B3=A0=20=EB=82=98=EA=B0=84=EB=8B=A4=20(#609=20=E2=91=A0=20?= =?UTF-8?q?=E2=93=91)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ❗**계약 승인 대기 중이다 — `contracts/judgment.schema.json` 을 안 건드렸다.** 이 커밋은 ai-service 쪽만이고, 계약·Java 레코드는 강희진 몫이다(CLAUDE.md — 오너 승인 없이 변경 금지). 그래서 draft 로 낸다. ## 왜 `status: confirmed` 는 「근거자료 검토를 마쳤다」는 뜻인데 그 값을 읽는 코드가 채점 경로에 없었다(#617 이 기동 로그와 못 박기로 드러냈다). 실물은 ELS 10종 confirmed · 변액 7종 **전부** draft 다 — S-02 에서 변액을 고르면 그 세션의 이해항목 전부가 검토 전 기준으로 채점된다. 채점은 안 바꾼다. draft 를 `RubricNotFound` 로 빼면 「승인했는데 안 돈다」가 되고(#475 ⓐ 의 승인 흐름이 draft 로 커밋하는 것이라 첫 단계가 막힌다) 데모 채점도 바뀐다. 대신 **판정이 그 사실을 들고 나가** 화면·교부 문서가 말할 수 있게 한다. ## 무엇 - `Judgment.rubric_status: Literal["confirmed","draft"] | None` - `scoring._pin_rubric_status` — `_pin_prompt_version` 옆. **모델에게 안 묻는다**: 루브릭 파일이 무엇인지는 우리가 아는 사실이고, 모델이 채우게 하면 파일을 바꿔도 아무 일이 안 일어난다(결정 10.46 과 같은 논거) - ❗**없으면 confirmed 로 읽지 않는다.** `source` 는 「없으면 MEASURED」인데 여기는 반대다 — 기본값을 confirmed 로 두면 이 필드 이전 레코드 전부가 「검토된 기준으로 판정했다」가 된다. 그게 이 필드를 만드는 이유 자체를 지운다(#284 의 「빈 것 ↔ 없는 것」) `Judgment` 는 LLM 구조화 출력 스키마이기도 해서 모델이 이 칸을 채워 보낼 수 있다 — 핀이 이긴다는 것을 단정으로 박았다. `strict` 자격은 안 바뀐다(이미 False · 선택 필드가 있다). ## 변이 역검증 ⓐ 핀을 사슬에서 뺀다 4 failed ⓑ 루브릭이 아니라 모델 값을 쓴다 4 failed ⓒ 기본값을 confirmed 로 둔다 1 failed ⓓ 항상 confirmed 를 박는다 2 failed ← 두 상태가 안 갈리는 것을 문다 ai-service 1143 passed · skip 0 Refs #609 · #617 · #475 Co-Authored-By: Claude Opus 5 (1M context) --- ai-service/app/schemas.py | 25 +++++ ai-service/app/scoring.py | 26 +++++ .../tests/test_judgment_rubric_status.py | 104 ++++++++++++++++++ 3 files changed, 155 insertions(+) create mode 100644 ai-service/tests/test_judgment_rubric_status.py diff --git a/ai-service/app/schemas.py b/ai-service/app/schemas.py index 1dd7148d..ad01f10b 100644 --- a/ai-service/app/schemas.py +++ b/ai-service/app/schemas.py @@ -128,6 +128,31 @@ class Judgment(Strict): description="이 판정을 낸 채점 프롬프트 버전. **모델이 채우지 않는다** — " "scoring 이 후처리에서 고정한다(결정 10.46 · 계약 10.38)", ) + #: ❗**이 판정의 근거가 된 루브릭이 검토를 마친 것인가** (이슈 #609 ①). + #: + #: `status: confirmed` 는 *"근거자료 검토를 마쳤다"* 는 뜻인데, 그 값을 읽는 코드가 + #: 채점 경로에 없어서 **draft 루브릭이 confirmed 와 똑같이 채점된다**(`#617` 이 그 + #: 사실을 기동 로그와 못 박기로 드러냈다). 지금 실물은 ELS 10종 confirmed · + #: 변액 7종 **전부** draft 다. + #: + #: 채점을 바꾸지 않는다 — draft 를 빼면 「승인했는데 안 돈다」가 된다. 대신 **판정이 + #: 그 사실을 들고 나가서** 화면·교부 문서가 「검토 전 기준」을 말할 수 있게 한다. + #: `prompt_version`·`source` 와 같은 층이다: 셋 다 *"이 판정이 무엇을 근거로 나왔나"* + #: 이고 **값만으로는 복원이 안 된다.** 검토 전 기준으로 낸 판정도 레코드에서 확정 기준 + #: 판정과 똑같이 생겼고, `evidence/` 가 append-only 라 나중에 되짚을 수 없다. + #: + #: ❗**없으면 `confirmed` 로 읽지 않는다.** `source` 는 *"없으면 MEASURED"* 인데 여기는 + #: 반대다 — 기본값을 confirmed 로 두면 **이 필드가 생기기 전 레코드 전부가 「검토된 + #: 기준으로 판정했다」로 읽힌다.** 실제로는 모르는 것이고, 그게 이 필드를 만드는 이유 + #: 자체를 지운다(`unlinked_until` 의 「빈 것 ↔ 없는 것」과 같은 자리 · `#284`). + #: + #: **모델이 채우지 않는다** — `scoring._pin_rubric_status` 가 후처리에서 고정한다. + rubric_status: Literal["confirmed", "draft"] | None = Field( + default=None, + description="이 판정의 근거가 된 루브릭이 검토를 마쳤는가. **모델이 채우지 않는다** — " + "scoring 이 후처리에서 고정한다. ❗없으면 confirmed 로 읽지 않는다 " + "(이 필드가 생기기 전 레코드다 · 이슈 #609)", + ) # ── contracts/parsed_document.schema.json (계약 소유: 정세현) ────────────────── diff --git a/ai-service/app/scoring.py b/ai-service/app/scoring.py index 96485e5b..b130413b 100644 --- a/ai-service/app/scoring.py +++ b/ai-service/app/scoring.py @@ -351,6 +351,7 @@ def score( seed=_attempt_seed(attempt), ) judgment = _pin_prompt_version(judgment) + judgment = _pin_rubric_status(judgment, rubric) judgment = _pin_item_id(judgment, item_id) judgment = _drop_llm_misconception_type(judgment) try: @@ -768,6 +769,31 @@ def _pin_prompt_version(judgment: Judgment) -> Judgment: return judgment.model_copy(update={"prompt_version": PROMPT_VERSION}) +def _pin_rubric_status(judgment: Judgment, rubric: rubrics.Rubric) -> Judgment: + """이 판정의 **근거가 된 루브릭이 검토를 마쳤는지**를 고정한다 (이슈 #609 ①). + + `_pin_prompt_version` 과 같은 층이다 — 루브릭 파일이 실제로 무엇인지는 **우리가 아는 + 사실**이고 모델이 보고할 값이 아니다. 모델에게 물으면 파일을 바꿀 때 그 문면을 같이 + 안 고쳐도 아무 일이 안 일어난다. + + ## 왜 판정에 싣나 + + `status: confirmed` 는 *"근거자료 검토를 마쳤다"* 는 뜻인데 그 값을 읽는 코드가 채점 + 경로에 없었다(`#617` 이 그 사실을 드러냈다). 실물은 ELS 10종 confirmed · 변액 7종 + **전부** draft 다 — S-02 에서 변액을 고르면 **그 세션의 이해항목 전부**가 검토 전 + 기준으로 채점된다. + + 채점은 안 바꾼다. draft 를 `RubricNotFound` 로 빼면 그 항목이 미측정이라 `R-00` 이 + RED 로 막는데, 그러면 *"승인했는데 안 돈다"* 가 된다(`#475` ⓐ 의 승인 흐름이 draft 로 + 커밋하는 것이라 첫 단계가 막힌다). 대신 **판정이 그 사실을 들고 나간다.** + + ❗**값이 항상 있다** — `rubrics.get()` 이 준 루브릭에서 읽으므로 `None` 이 될 수 없다. + 계약에서 optional 인 것은 **이 필드가 생기기 전 레코드** 때문이고, 그때 `None` 은 + 「검토됐다」가 아니라 **「모른다」**다(`#284` 의 「빈 것 ↔ 없는 것」과 같은 자리). + """ + return judgment.model_copy(update={"rubric_status": rubric.status}) + + def _pin_item_id(judgment: Judgment, item_id: str) -> Judgment: """LLM이 item_id를 바꿔 쓰는 것을 허용하지 않는다 — 호출자가 지정한 항목이 진실이다.""" if judgment.item_id == item_id: diff --git a/ai-service/tests/test_judgment_rubric_status.py b/ai-service/tests/test_judgment_rubric_status.py new file mode 100644 index 00000000..69a6eed9 --- /dev/null +++ b/ai-service/tests/test_judgment_rubric_status.py @@ -0,0 +1,104 @@ +"""판정이 **어느 상태의 기준으로 나왔는지**를 들고 나간다. 소유: 윤지석 (이슈 #609 ①) + +## 왜 이 필드가 필요한가 + +`status: confirmed` 는 *"근거자료 검토를 마쳤다"* 는 뜻인데 그 값을 읽는 코드가 채점 경로에 +없었다 — `#617` 이 그 사실을 기동 로그와 못 박기로 드러냈다. 실물은 이렇다. + + ELS 10종 · draft 0종 + 변액 7종 · draft 7종 ← 전부. S-02 에서 변액을 고르면 그 세션 전부가 검토 전 기준이다 + +채점은 안 바꾼다(draft 를 빼면 「승인했는데 안 돈다」가 된다 · `#475` ⓐ). 대신 **판정이 그 +사실을 들고 나가** 화면·교부 문서가 「검토 전 기준」을 말할 수 있게 한다. + +## 이 파일이 잠그는 것 + + ① 값이 루브릭에서 온다 — 모델이 아니라 + ② draft 와 confirmed 가 실제로 갈린다 (`#617` 의 못 박기가 여기서 뒤집힌다) + ③ 모델이 채워 보내도 우리 값이 이긴다 + ④ 계약에 optional 이지만 **우리는 항상 싣는다** — 「없다」는 옛 레코드의 뜻이다 +""" +from __future__ import annotations + +import pytest + +from app import rubrics, scoring +from app.schemas import Condition, Judgment, RiskItem, SourceSpan +from tests.helpers import FakeLlm, make_judgment + +#: 변액 항목 하나 — 지금 변액은 전부 draft 다(`#617` 의 `_DRAFT_IN_SCORING`). +DRAFT_ITEM = "VAR-FEE-DEDUCTION" +#: ELS 는 전부 confirmed 다. +CONFIRMED_ITEM = "ELS-PRINCIPAL-LOSS-WARNING" + + +def _risk_item(item_id: str) -> RiskItem: + return RiskItem( + item_id=item_id, product_id="mock-001", name="항목", importance="required", + status="extracted", + condition=Condition(value_text="원문 인용", + source_span=SourceSpan(page=1, start=0, end=4)), + ) + + +#: 인용이 발화에 축자로 있어야 한다(P4 · `verify_quote_is_verbatim`) — 아니면 재판정 뒤 +#: `MEASUREMENT_INVALID` 로 죽어서 이 파일이 재려는 자리에 닿지 못한다. +ANSWER = "제가 이해하기로는 원금은 지켜지는 거죠" + + +def _score(item_id: str, product_type: str, judgment: Judgment) -> Judgment: + return scoring.score(item_id, "질문?", ANSWER, + _risk_item(item_id), product_type, llm=FakeLlm(judgment)) + + +@pytest.mark.parametrize("item_id,product_type,expected", [ + (CONFIRMED_ITEM, "ELS", "confirmed"), + (DRAFT_ITEM, "VARIABLE_INSURANCE", "draft"), +]) +def test_the_judgment_carries_the_rubric_status(item_id, product_type, expected) -> None: + """★ 두 상태가 판정에서 **실제로 갈린다.** + + `#617` 의 못 박기(`test_scoring_cannot_tell_the_two_apart`)가 *"채점 입력이 두 상태에서 + 글자까지 같다"* 를 단정한다 — 이 테스트는 그 **뒤쪽**을 잰다. 입력은 같아도 **출력이 + 상태를 들고 나간다**는 것이 `#609` ① 의 ⓑ 다. + """ + got = _score(item_id, product_type, make_judgment(item_id=item_id)) + assert got.rubric_status == expected + assert got.rubric_status == rubrics.get(item_id).status, "루브릭 파일이 진실이다" + + +def test_the_model_does_not_get_to_say_it() -> None: + """★ 모델이 채워 보내도 **우리 값이 이긴다** — `_pin_prompt_version` 과 같은 층이다. + + 루브릭 파일이 실제로 무엇인지는 **우리가 아는 사실**이고 모델이 보고할 값이 아니다. + `Judgment` 가 LLM 구조화 출력 스키마이기도 해서(`complete_json(model_cls=Judgment)`) + 모델이 이 칸을 채워 보낼 **수 있다** — 그때 그 값을 쓰면 파일을 바꿔도 아무 일이 안 + 일어난다. + """ + lying = make_judgment(item_id=DRAFT_ITEM).model_copy( + update={"rubric_status": "confirmed"}) + got = _score(DRAFT_ITEM, "VARIABLE_INSURANCE", lying) + assert got.rubric_status == "draft", "모델이 낸 값이 살아남았다" + + +def test_we_always_carry_it_even_though_the_contract_allows_none() -> None: + """❗계약에서 optional 인 것은 **옛 레코드** 때문이지 우리가 비워도 된다는 뜻이 아니다. + + `rubrics.get()` 이 준 루브릭에서 읽으므로 `None` 이 될 수 없다. 비어 나가기 시작하면 + 소비자가 「모른다」와 「검토됐다」를 가르는 근거가 사라진다 — 그 구별이 이 필드의 존재 + 이유다(`#284` 의 「빈 것 ↔ 없는 것」과 같은 자리). + """ + for item_id, product_type in ((CONFIRMED_ITEM, "ELS"), + (DRAFT_ITEM, "VARIABLE_INSURANCE")): + got = _score(item_id, product_type, make_judgment(item_id=item_id)) + assert got.rubric_status is not None, f"{item_id}: 비워 나갔다" + + +def test_absent_is_not_confirmed() -> None: + """❗**기본값이 `confirmed` 가 아니다.** + + `source` 는 *"없으면 MEASURED 로 읽는다"* 인데 여기는 반대다 — 기본값을 confirmed 로 + 두면 이 필드가 생기기 전 레코드 **전부**가 「검토된 기준으로 판정했다」로 읽힌다. + 실제로는 모르는 것이고, 그게 이 필드를 만드는 이유 자체를 지운다. + """ + assert Judgment.model_fields["rubric_status"].default is None