Skip to content

feat(F-SCR-001): 판정이 근거 루브릭의 검토 상태를 들고 나간다 (#609 ① ⓑ · 계약 승인 대기) - #623

Draft
yoonjiseok wants to merge 1 commit into
mainfrom
feat/F-SCR-001-judgment-rubric-status-609
Draft

yoonjiseok wants to merge 1 commit into
mainfrom
feat/F-SCR-001-judgment-rubric-status-609

Conversation

@yoonjiseok

Copy link
Copy Markdown
Contributor

Important

draft 입니다 — 계약 승인 대기 중입니다. contracts/judgment.schema.json 을 안 건드렸습니다(CLAUDE.md — 오너 승인 없이 변경 금지). 이 PR 은 ai-service 쪽만이고, 계약·Java 레코드는 @hd0rable 몫입니다. 결정을 구체적으로 보시라고 코드를 먼저 올립니다.

#609 ① 의 뒤 절반입니다. 앞 절반(#617)은 머지됐습니다 — 그건 사실을 드러내기만 했고, 이건 판정이 그 사실을 들고 나가게 합니다.

무엇이 문제인가

status: confirmed 는 "근거자료 검토를 마쳤다" 는 뜻인데 그 값을 읽는 코드가 채점 경로에 없습니다.

ELS      10종 · draft 0종
변액       7종 · draft 7종   ← 전부

S-02 에서 고를 수 있는 두 상품 중 하나를 고르면 그 세션의 이해항목 전부가 검토 전 기준으로 채점됩니다(@hd0rable 이 #617 리뷰에서 짚은 규모입니다). 그런데 판정 레코드에는 그 사실이 안 남습니다 — 검토 전 기준으로 낸 판정이 확정 기준 판정과 똑같이 생겼고, evidence/ 가 append-only 라 나중에 되짚을 수 없습니다.

왜 ⓐ 가 아닌가

ⓐ draft 를 채점에서 뺀다   rubrics.get() → RubricNotFound → 미측정 → R-00 이 RED
                          ❗「승인했는데 안 돈다」 — #475 ⓐ 의 승인 흐름이 draft 로 커밋하는
                          것이라 첫 단계가 막힌다. 데모 채점도 7종만큼 바뀐다
ⓑ 판정이 사실을 들고 간다   채점 그대로. 화면·교부 문서가 「검토 전 기준」을 말한다   ◀ 이 PR

무엇을 했나

# schemas.py
rubric_status: Literal["confirmed", "draft"] | None = None

# scoring.py — _pin_prompt_version 옆
judgment = _pin_prompt_version(judgment)
judgment = _pin_rubric_status(judgment, rubric)     # ← 더한 줄
judgment = _pin_item_id(judgment, item_id)

모델에게 안 묻습니다. 루브릭 파일이 실제로 무엇인지는 우리가 아는 사실이고, 모델이 채우게 하면 파일을 바꿀 때 그 문면을 같이 안 고쳐도 아무 일이 안 일어납니다 — prompt_version 과 같은 논거(결정 10.46)입니다.

❗Judgment 는 LLM 구조화 출력 스키마이기도 해서(complete_json(model_cls=Judgment)) 모델이 이 칸을 채워 보낼 수 있습니다. 핀이 이긴다는 것을 단정으로 박았습니다. strict 자격은 안 바뀝니다 — Judgment 는 이미 False 이고(선택 필드가 있어서) 하나 더 추가해도 같습니다(test_structured_output_strict 초록).

❗없으면 confirmed 로 읽지 않습니다

이 자리가 source 와 반대입니다.

source          없으면 MEASURED 로 읽는다        (ai-service 는 안 싣고, 모든 판정이 MEASURED 다)
rubric_status   없으면 「모른다」다               ❗confirmed 가 아니다

기본값을 confirmed 로 두면 이 필드가 생기기 전 레코드 전부가 「검토된 기준으로 판정했다」로 읽힙니다. 실제로는 모르는 것이고, 그게 이 필드를 만드는 이유 자체를 지웁니다 — unlinked_until 의 「빈 것 ↔ 없는 것」(#284)과 같은 자리입니다.

우리 쪽은 항상 싣습니다(rubrics.get() 이 준 값이라 None 이 될 수 없습니다). optional 인 것은 옛 레코드 때문이지 비워도 된다는 뜻이 아니라, 그것도 단정으로 뒀습니다.

변이 역검증

변이 결과
ⓐ 핀을 사슬에서 뺀다 4 failed
ⓑ 루브릭이 아니라 모델 값을 쓴다 4 failed
ⓒ 기본값을 confirmed 로 둔다 1 failed
ⓓ 항상 confirmed 를 박는다 2 failed ← 두 상태가 안 갈리는 것을 문다

#617 의 못 박기(test_scoring_cannot_tell_the_two_apart — "채점 입력이 두 상태에서 글자까지 같다")는 그대로 초록입니다. 이 PR 이 바꾸는 것은 입력이 아니라 출력이라 그 단정과 안 부딪힙니다. 그 관계를 새 테스트 독스트링에 적어 뒀습니다.

계약 쪽 — 승인 주시면 이 모양입니다

제가 파일을 안 고쳤으니 초안만 둡니다.

  contracts/judgment.schema.json  properties
+   "rubric_status": {
+     "enum": ["confirmed", "draft"],
+     "description": "이 판정의 근거가 된 루브릭이 검토를 마친 것인가. optional —
+       이 필드가 생기기 전 레코드에는 없다.
+       ❗없으면 confirmed 로 읽지 않는다 — 「검토를 마쳤다」와 「모른다」는 다르다."
+   }

required 는 안 건드립니다(['item_id','grade','confidence','evidence','reason'] 그대로). Java Judgment 레코드에 String rubricStatus 한 칸이 promptVersion 옆에 붙습니다.

수요자

  • @hd0rable — 계약 · Java 레코드 · 게이트. #617 리뷰에서 "필드를 항목마다 두는 것이 맞다고 봅니다 … 계약은 넓게 두고 화면이 뭉쳐 보여 주는 쪽이 순서가 낫습니다" 라고 하신 그 모양입니다. ❗P1 을 넘지 않게 이 값이 판정에 직접 들어가지 않습니다 — 룰이 쓸 거면 gate_rules.yaml 에서 명시적으로 쓰는 쪽입니다
  • @gitIt-sehyeon — evidence/ · 교부 문서. 이 필드 없이 쌓인 레코드는 나중에 복원이 안 됩니다 — 그게 지금 내는 이유입니다
  • @junseo2323 — S-05 화면. 지금 실물이 상품 전체가 draft 라 배지 7개보다 상품 단위 문면이 맞을 수 있습니다. 레코드는 항목 단위로 남고, 화면이 뭉치는 것은 화면 몫입니다

검증

ai-service   1143 passed · skip 0   (러너와 같은 명령: --junitxml + no_skip.py)

Refs #609 · #617 · #475

🤖 Generated with Claude Code

❗**계약 승인 대기 중이다 — `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) <noreply@anthropic.com>
@hd0rable

Copy link
Copy Markdown
Member

계약 승인합니다. 필드 이름·값·optional·「없으면 confirmed 로 읽지 않는다」 넷 다 그대로 갑니다. #617 리뷰에서 제가 "계약은 넓게 두고 화면이 뭉쳐 보여 주는 쪽" 이라고 한 그 모양이고, 항목 단위로 남기는 것이 맞습니다 — 변액이 7/7 인 것은 지금 우연이라 상품 단위로 계약을 짜면 하나가 confirmed 되는 순간 표현할 수 없는 상태가 됩니다.

_pin_rubric_status 를 _pin_prompt_version 옆에 둔 것도 맞습니다. 모델에게 물으면 채점 스키마가 답을 알 수 없는 필드를 갖는 모양이 되고, 그건 OutboundJudgment javadoc 이 source 에서 이미 막아 둔 자리입니다.

❗머지 순서가 제약입니다 — 이 PR 이 먼저 들어가면 채점이 통째로 깨집니다

재 봤습니다. 서버가 /internal/score 응답을 도메인 레코드로 바로 역직렬화하고, 그 경계 매퍼는 FAIL_ON_UNKNOWN_PROPERTIES 가 기본값(on)입니다.

AiServiceClient:239   .body(Judgment.class)      ← 경계 매퍼(SNAKE_CASE·엄격)

실측: rubric_status 가 실린 판정 JSON 을 Judgment 로 읽으면
  UnrecognizedPropertyException: Unrecognized field "rubric_status"
  (class …domain.Judgment), not marked as ignorable
  (9 known properties: misconception_type, reason, item_id, confidence,
   source, prompt_version, escalate, grade, evidence)

#518 때 알파에서 재설명이 전부 AI_SERVICE_UNAVAILABLE 이었던 그 사고와 같은 모양인데 방향이 반대입니다. 그때는 우리가 모르는 키를 보내서 저쪽 extra="forbid" 가 422 였고, 이번엔 저쪽이 보내는 키를 우리가 모릅니다.

그래서 제 쪽(계약 + Java 레코드)이 먼저 들어가야 합니다. 제가 지금 끌겠습니다 — 오늘 올립니다.

① contracts/judgment.schema.json + domain/Judgment 에 rubricStatus  (제 PR)
② 이 PR                                                            (그 뒤)

①만 들어간 상태는 안전합니다 — ai-service 가 아직 안 실으니 값이 계속 null 이고, 그게 계약이 말하는 「모른다」와 같습니다.

나가는 쪽은 안 따라 늘어납니다 — 확인했습니다

OutboundJudgment 가 필드를 골라 담으므로 도메인에 칸이 늘어도 /internal/reexplain 요청 본문은 그대로입니다. 그쪽 Judgment 가 extra="forbid" 라 여기가 자동으로 따라 늘면 #518 사고가 재현되는데, 그 레코드 javadoc 이 그것을 막아 두고 있습니다.

❗하나 물어봅니다 — 룰이 만든 판정의 null 과 옛 레코드의 null 이 같은 값입니다

「없으면 모른다」에 동의하는데, 서버에는 루브릭을 안 본 판정이 이미 있습니다.

SkippedItem      건너뛴 항목에 U3 를 박는다 — 발화가 없어 측정하지 않았다 (E-INT-03)
SyntheticSession 합성 세션 — 집계용이라 채점을 안 지난다

둘 다 rubric_status 가 영원히 null 인데, 뜻은 **「루브릭을 안 봤다」**이지 「모른다」가 아닙니다. 지금은 source 가 그것을 이미 갈라 줍니다.

source=RULE      + null   → 루브릭을 안 본 판정이다 (SkippedItem)
source=MEASURED  + null   → 이 필드가 생기기 전 레코드다   ← 여기만 「모른다」

값을 더 만들자는 제안은 아닙니다 — "not_applicable" 같은 것을 넣으면 두 필드가 같은 사실을 두 벌로 말하게 됩니다. 다만 읽는 쪽이 source 와 같이 봐야 한다는 것이 어딘가 적혀야 하고, 그 자리는 계약 description 이라고 봅니다. 제 PR 에 그 문장을 넣겠습니다 — 반대면 말씀해 주세요.

검증

ai-service 1143 passed · skip 0 (본문)

변이 넷 중 ⓒ(기본값을 confirmed)와 ⓓ(항상 confirmed)가 이 설계의 핵심을 무는 자리입니다. ⓓ 가 2건인 것은 두 상태가 실제로 갈리는 것까지 보기 때문이라 맞습니다.

@yoonjiseok

Copy link
Copy Markdown
Contributor Author

승인 감사합니다. 순서에도 동의합니다 — 제 PR 이 먼저 들어가면 채점이 깨지는 것 맞습니다.

FAIL_ON_UNKNOWN_PROPERTIES 쪽은 그쪽 실측을 그대로 받습니다. #518 과 방향이 반대라는 정리가 정확합니다 — 그때는 우리가 모르는 키를 보내서 저쪽 extra="forbid" 가 422 였고, 이번엔 저쪽이 보내는 키를 우리가 모릅니다. 같은 경계가 양쪽으로 한 번씩 문 셈입니다.

① contracts + domain/Judgment      (그쪽 · 오늘)
② 이 PR                            (그 뒤 · draft 풉니다)

①만 들어간 상태가 안전하다는 것도 맞습니다 — 값이 계속 null 이고 그게 계약이 말하는 「모른다」입니다. ①이 머지되면 알려 주세요. 그 시점에 main 을 합쳐 다시 재고 draft 를 풀겠습니다(승인·초록이 낡은 base 에서 잰 것이 되지 않게 — #557 ⓑ).


❗물어보신 것 — 동의합니다. 다만 값 이름 하나와 셋째 경우가 있습니다

① RULE 이 아니라 SKIPPED 입니다

contracts/judgment.schema.json   source.enum = ["MEASURED", "SKIPPED"]
domain/Judgment:64,73            Source { MEASURED, SKIPPED }

RULE 은 없습니다. 계약 description 에 넣으실 문장이라 값 이름이 그대로 박힙니다 — SKIPPED 로 적어 주세요.

❗② 합성 세션이 그 2×2 를 안 지납니다

제안하신 표는 이렇습니다.

source=SKIPPED  + null   → 루브릭을 안 본 판정이다
source=MEASURED + null   → 이 필드가 생기기 전 레코드다   ← 「모른다」

그런데 합성 세션이 (MEASURED, null) 로 나옵니다.

// SyntheticSessionLoader:175  — 6인자 생성자다. source 를 안 준다
out.put(itemId, new Judgment(itemId, grade, new BigDecimal("0.90"),
        new Judgment.Evidence("합성 세션 — 발화 없음", "합성 세션 — 루브릭 조항 없음"),
        "F-DSH-003 합성 세션(대시보드 구동용). 실제 판정이 아니다.", null));

// domain/Judgment:83  compact ctor
source = source == null ? Source.MEASURED : source;

즉 본문에서 「루브릭을 안 봤다」로 묶으신 둘 중 하나가 「모른다」 칸으로 떨어집니다. SkippedItem 은 SKIPPED 를 명시하니 깨끗한데(그 생성자 javadoc 이 "룰이 만드는 판정은 이 생성자로 만들지 않는다" 로 이미 갈라 뒀습니다) 합성은 그 경로를 안 씁니다.

실질 피해는 작다고 봅니다 — 합성 세션은 집계용이고 불변 기록을 안 쌓아서(CLAUDE.md) 감사 시점에 이 두 필드를 되짚을 일이 없습니다. 그리고 reason 이 "F-DSH-003 합성 세션" 으로 시작해 사람은 즉시 압니다.

그래서 값을 더 만들자는 게 아니라, 계약 문장이 「전수」로 읽히지 않게만 해 주시면 됩니다. 지금 초안대로면 "MEASURED + null 이면 옛 레코드다" 가 되는데 그건 참이 아닙니다.

문면 제안입니다.

❗`source` 와 같이 읽는다. `SKIPPED` + 이 값이 없으면 **루브릭을 안 본 판정**이고
(E-INT-03), `MEASURED` + 없으면 **이 필드가 생기기 전 레코드**다 — 단, 합성 세션
(F-DSH-003)도 `MEASURED` 로 접히므로 「옛 레코드」로 단정하지 않는다.

「합성이면 SKIPPED 로 만들자」는 별건이라고 봅니다 — 그건 집계 쪽 의미가 걸려서(합성은 「건너뛴」 것이 아닙니다) 여기서 곁가지로 정할 값이 아닙니다.

나가는 쪽 확인 감사합니다

OutboundJudgment 가 필드를 골라 담는다는 것 — 그게 #518 재현을 막는 자리라는 정리가 정확합니다. 제 쪽 Judgment 가 extra="forbid" 라 도메인에 칸이 늘 때 자동으로 따라 늘면 그대로 422 입니다. 그 레코드 javadoc 이 지키고 있다는 것을 확인했습니다.

변이 ⓓ 가 2건인 이유

맞게 읽으셨습니다 — test_the_judgment_carries_the_rubric_status 가 파라미터 둘(confirmed·draft)이라 항상 confirmed 를 박으면 draft 쪽만 깨질 것 같은데, test_the_model_does_not_get_to_say_it 도 같이 깨집니다. 그쪽이 draft 항목에 모델이 confirmed 를 넣는 경우라 핀이 이겨도 값이 우연히 같아지기 때문입니다. 두 단정이 다른 층을 재고 있어서 그렇습니다.

@hd0rable

Copy link
Copy Markdown
Member

선행을 올렸습니다 — #624. 계약에 rubric_status 를 더하고 Judgment 에 칸을 만들었습니다. 그게 들어가면 이 PR 은 순서 제약이 풀립니다.

정정 하나 — 위에서 제가 source=RULE 이라고 적었는데 실제 값은 SKIPPED 입니다(MEASURED·SKIPPED 둘뿐). 논지는 그대로입니다.

source=SKIPPED  + 비었음   루브릭을 안 본 판정 (건너뛴 항목은 채점을 안 지난다)
source=MEASURED + 비었음   이 필드가 생기기 전 레코드   ← 여기만 「모른다」

그 문장을 계약 description 과 레코드 javadoc 에 넣었습니다.

이번에 그물도 같이 달았습니다

이 순서 문제가 아무 테스트에도 안 걸렸던 것이 진짜 결함이라, #624 에 들어오는 판정을 계약과 대조하는 테스트를 만들었습니다. 나가는 쪽은 RiskItemWireContractTest 가 덮고 있었는데 들어오는 쪽은 비어 있었습니다.

계약에 새 필드를 더한다(레코드엔 없음)   4건 중 2 실패

다음에 그쪽이 계약에 칸을 만들면 제 쪽 CI 가 먼저 알려줍니다 — 배포에서 만나지 않고.

담는 자리도 정했습니다

Judgment 에 칸이 늘면 적재에서 담을지 뺄지를 정하게 만든 단정이 있어서, 그 자리에서 담는 쪽으로 정했습니다. 이 PR 이 적어 두신 "evidence/ 가 append-only 라 나중에 되짚을 수 없다" 가 그대로 근거입니다 — 루브릭 파일의 status 는 나중에 확정으로 바뀌니까요.

@yoonjiseok

Copy link
Copy Markdown
Contributor Author

곁가지로 하나 — #613 은 이 순서 사슬 밖입니다. 같이 묶여 기다리고 있는 것 같아 적어 둡니다.

여기서 순서가 생긴 이유는 도메인 레코드에 없는 키를 보내면 FAIL_ON_UNKNOWN_PROPERTIES 가 죽기 때문인데, #613 은 새 필드를 안 만듭니다. 기존 필드의 값이 둘 늘 뿐이고 서버는 그것을 문자열로 받습니다.

// AiServiceClient:581
public record Warning(String code, String itemId, String message) {}

EVIDENCE_WIDENED·EVIDENCE_PIECE_MISSING 이 code 에 실려 와도 읽는 쪽 모양이 안 바뀝니다 — 그쪽 javadoc 이 적어 두신 "이 클라이언트는 코드를 문자열로 실어 나르기만 하고 해석은 배선 단계가 한다" 그대로입니다. 그래서 #623 ①·② 와 무관하게 언제 들어가도 됩니다.

지금 상태만 정리하면 이렇습니다.

#613   정세현 APPROVED · CI 초록 · ①(be3b7a4)·②(그쪽 정정) 다 닫힘 · 가드는 hd0rable 승인만
#623   승인 주셨고 ① 머지되면 제가 draft 를 풉니다

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants