Conversation
ai-service 쪽(#623)의 선행이다. 그쪽이 먼저 들어가면 채점 경로가 통째로 죽는다 — 서버가 /internal/score 응답을 도메인 레코드로 바로 역직렬화하는데 그 경계 매퍼는 FAIL_ON_UNKNOWN_PROPERTIES 가 기본값(on)이다. 실측: rubric_status 가 실린 판정을 Judgment 로 읽으면 UnrecognizedPropertyException: Unrecognized field "rubric_status" (9 known properties: misconception_type, reason, item_id, confidence, source, prompt_version, escalate, grade, evidence) 운영자가 받는 문면은 AI_SERVICE_UNAVAILABLE 이라 ai-service 를 의심하게 된다. #518 이 반대 방향으로 같은 사고를 냈다(우리가 모르는 키를 보내 저쪽이 422). 무엇이 문제인가: status=confirmed 는 "근거자료 검토를 마쳤다" 는 뜻인데 검토 전 기준으로 낸 판정이 레코드에서 확정 기준 판정과 똑같이 생겼다. 지금 실물이 ELS 10종 확정 · 변액 7종 전부 검토 전이라, S-02 에서 변액을 고르면 그 세션의 이해항목 전부가 그 상태다. evidence/ 가 append-only 라 나중에 되짚을 수 없다. - 계약에 rubric_status 를 더했다(enum confirmed|draft · optional · required 는 안 바뀐다) - Judgment 에 rubricStatus 칸. ❗null 을 confirmed 로 접지 않는다 — source 와 반대 방향이다. 기본값을 주면 이 필드가 생기기 전 레코드 전부가 "검토된 기준으로 판정했다" 로 읽히고, 그러면 이 필드를 만드는 이유 자체가 없어진다 - 비어 있는 것이 두 가지라 source 와 같이 읽는다: SKIPPED + 비었음은 "루브릭을 안 봤다", MEASURED + 비었음만 "모른다". 값을 더 만들지 않는 이유는 source 가 이미 그것을 말해서다 - 들어오는 판정을 계약과 대조하는 테스트를 새로 만들었다. 나가는 쪽은 RiskItemWireContractTest 가 덮는데 들어오는 쪽은 아무도 안 봤고, 그쪽 실패가 더 크다(한 항목이 아니라 경로 전체) 역검증: 계약에 새 필드를 더한다(레코드엔 없음) 4건 중 2 실패 ← 이번에 겪은 그 시나리오 계약에서 rubric_status 를 뺀다 빨강 레코드를 @JsonIgnoreProperties 로 관대하게 "계약에 없는 필드는 터진다" 가 빨강 기준선 4건 전부 초록 server 전체 834건 · 실패 0 · skip 0 Refs #609 #623 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
yoonjiseok
left a comment
There was a problem hiding this comment.
설계·그물 다 좋습니다. 변경요청 하나 — 계약 description 의 한 문장이 참이 아닙니다. 제가 #623 에 적은 것과 27초 차이로 엇갈린 것 같습니다(제 코멘트 02:42:16 · 이 PR 02:42:43).
변이 셋을 제 쪽에서도 재현했습니다
기준선 JudgmentWireContractTest 4건 초록
M1) 계약에 새 필드(레코드엔 없음) 4건 중 2 실패 ← 본문과 같다
M2) 계약에서 rubric_status 를 뺀다 4건 중 2 실패
M3) 레코드를 ignoreUnknown 으로 1 실패 — 「계약에 없는 필드는 터진다」
M3 가 특히 값이 있습니다. 경계 매퍼가 관대해지는 순간 M1 이 아무것도 안 막는데, 그 사실을 배포에서 처음 만나게 됩니다 — 그물이 자기 전제를 지키는 모양입니다.
RULE → SKIPPED 정정도 확인했습니다(enum = ["MEASURED","SKIPPED"]).
❗변경요청 — 「MEASURED + 비었음만 모른다」가 참이 아닙니다
계약과 레코드 javadoc 두 곳에 같은 문장이 있습니다.
source=MEASURED이면서 비어 있는 것만 「모른다」다
합성 세션이 그 칸에 떨어집니다.
// aggregate/SyntheticSessionLoader.java:175 — 6인자 생성자다. source 를 안 준다
out.put(itemId, new Judgment(itemId, grade, new BigDecimal("0.90"),
new Judgment.Evidence("합성 세션 — 발화 없음", "합성 세션 — 루브릭 조항 없음"),
"F-DSH-003 합성 세션(대시보드 구동용). 실제 판정이 아니다.", null));
// domain/Judgment.java:83 — compact ctor
source = source == null ? Source.MEASURED : source;
전수로 셌습니다. main 코드에서 Judgment 를 만드는 자리가 정확히 둘이고 갈립니다.
domain/SkippedItem.java:71 Judgment.Source.SKIPPED 를 명시한다 ✅ 깨끗하다
aggregate/SyntheticSessionLoader:175 source 를 안 줘서 MEASURED 로 접힌다 ❗
그리고 합성 세션은 SessionRepository.saveAll 로 DB 에 남습니다(:148). 즉 (MEASURED, null) 인 행이 실제로 저장되고, 그 행의 뜻은 "이 필드가 생기기 전 레코드" 가 아니라 "합성이라 채점을 안 지났다" 입니다.
왜 막는가 — 이 PR 이 고치려는 것과 같은 종류라서입니다
이 PR 의 논지가 "검토 전 판정이 확정 판정과 똑같이 생겼다" 인데, 그 해법으로 넣는 읽기 규칙이 세 번째 경우를 두 번째로 읽게 만듭니다. 게다가 description 은 계약 본문이라 읽는 쪽이 그대로 따릅니다 — #529 가 「옛 판을 지목하는 정규식」에서 밟은 거짓 양성과 같은 층입니다.
고치는 폭은 한 줄입니다
❗읽을 때 source 와 같이 본다: source=SKIPPED 이면서 비어 있는 것은 「루브릭을 안 본
판정」이고(건너뛴 항목은 채점을 안 지난다), source=MEASURED 이면서 비어 있는 것은
「이 필드가 생기기 전 레코드」다 — 단, 합성 세션(F-DSH-003)도 source 를 안 줘서
MEASURED 로 접히므로(SyntheticSessionLoader) 「옛 레코드」로 단정하지 않는다.
값을 더 만들자는 게 아닙니다. "not_applicable" 을 넣으면 같은 사실이 두 벌이 된다는 판단에 동의하고, 합성에 SKIPPED 를 주는 것도 반대입니다 — 합성은 「건너뛴」 것이 아니라 애초에 채점 경로 밖이고, 그건 집계 쪽 의미가 걸려서 여기서 곁가지로 정할 값이 아닙니다. 문장이 「전수」로 읽히지 않게만 하면 됩니다.
레코드 javadoc(domain/Judgment.java)에도 같은 표가 있으니 두 곳입니다.
나머지 — 이견 없습니다
- 순서 판단.
FAIL_ON_UNKNOWN_PROPERTIES실측을 그대로 받습니다.#518과 방향이 반대인 같은 경계라는 정리가 정확하고, 한 항목이 아니라 경로 전체라는 것과 문면이AI_SERVICE_UNAVAILABLE이라 ai-service 를 의심하게 된다는 것까지가 이 PR 을 먼저 넣을 이유입니다. - 들어오는 절반에 그물이 없었다는 진단.
RiskItemWireContractTest가 나가는 쪽만 덮고 있었다는 것, 그리고 그쪽 실패가 더 크다는 것 — 나가는 쪽은 422 하나지만 들어오는 쪽은 역직렬화라 경로가 통째로 죽습니다. - 표본 값을 손으로 적은 것. "스키마에서 만들어 내면 대조가 스스로를 증명하는 모양" 이 맞습니다.
#284가 하드코딩 dict 를 루브릭 파일로 옮긴 것과는 반대 방향인데 이유가 같습니다 — 거기는 두 벌이 문제였고 여기는 자기참조가 문제입니다. StoredEvidenceRecorder에 담는 판단. "재계산으로 못 되돌린다 — 루브릭 파일의status는 나중에 확정으로 바뀐다" 가 결정적입니다. 제#623본문이 든 근거(evidence/append-only)를 적재 쪽에서 한 번 더 세워 주셨습니다.escalate와 같은 층이 맞습니다.CanonicalJsonTest— 정규화가 아니라 판정이 바뀐 것이고 키 순서(reason<rubricStatus<source)도 맞습니다.
한 문장만 고쳐 주시면 승인하고, 이게 머지되면 #623 의 draft 를 풀겠습니다.
|
정정 — 리뷰에 "전건은 못 돌렸다(Gradle 이 메모리로 죽어서 새 대조 4건만 돌렸다)" 고 적었는데, 다시 돌려서 전건이 통과하는 것을 확인했습니다. 아까 변경요청 내용은 그대로입니다(계약·javadoc 의 「 |
리뷰) 계약 description 과 레코드 javadoc 이 같은 문장을 들고 있었는데, 읽기 규칙이 세 번째 경우를 두 번째로 읽게 만든다. domain/SkippedItem.java:71 Source.SKIPPED 를 명시한다 깨끗하다 aggregate/SyntheticSessionLoader:175 6인자 생성자라 source 를 안 준다 → compact ctor 가 MEASURED 로 접는다 ❗ → sessions.saveAll 로 DB 에 남는다 즉 (MEASURED, 비었음) 인 행이 실제로 저장되고, 그 뜻은 "이 필드가 생기기 전 레코드" 가 아니라 "합성이라 채점을 안 지났다" 다. description 은 계약 본문이라 읽는 쪽이 그대로 따르므로, 「단정하지 않는다」로 고친다. 값을 더 만들지 않는다. not_applicable 은 같은 사실을 두 벌로 만들고, 합성에 SKIPPED 를 주는 것도 아니다 — 합성은 건너뛴 것이 아니라 애초에 채점 경로 밖이고 그 값의 뜻은 집계 쪽이 정할 것이다. server 전체 834건 · 실패 0 · skip 0 (문면만 · 동작 변경 0) Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
|
제 쪽에서도 전수로 셌습니다 —
❗제가 「전수」라고 쓴 것이 문제의 핵심입니다이 PR 의 논지가 "검토 전 판정이 확정 판정과 똑같이 생겼다" 인데, 그 해법으로 넣은 읽기 규칙이 세 번째 경우를 두 번째로 읽게 만들었습니다. 그리고 제안하신 문면을 받았고 두 곳 다 고쳤습니다. 값을 더 만들지 않는 판단도 같습니다 — 그물을 안 단 이유이 문장에는 대조를 안 붙였습니다. 틀리는 방향이 안전한 쪽이라서입니다 — 나중에 합성이 자기 값을 갖게 되면 이 문장은 「과하게 조심하는 문장」이 되지 지금처럼 틀린 판별 규칙이 되지는 않습니다. 반대 방향(제가 쓴 「…만」)이 위험한 쪽이었고, 그건 없앴습니다. 변이 재현 감사합니다M3( |
yoonjiseok
left a comment
There was a problem hiding this comment.
승인합니다. 두 곳(description · 레코드 javadoc) 다 고쳐졌고 전건 재확인했습니다.
server 834 · 실패 0 · skip 0 (문면만 · 동작 변경 0)
"잘못된 판별 규칙은 없는 것보다 나쁘다" 가 정확한 정리입니다. 읽는 쪽이 그대로 따르는 자리라 그렇습니다.
그물을 안 단 이유에 동의합니다 — 다만 방향이 하나 더 있습니다
틀리는 방향이 안전한 쪽이라서다 — 나중에 합성이 자기 값을 갖게 되면 이 문장은 「과하게 조심하는 문장」이 되지 틀린 판별 규칙이 되지는 않는다
그 방향은 맞습니다. 그런데 반대편이 하나 남습니다.
합성이 자기 값을 갖는다 문장이 과해진다 안전 ✅
❗세 번째 생산자가 생긴다 문장이 다시 불완전해진다 위험 — 그 행들이 「옛 레코드」로 읽힌다
지금 main 에서 Judgment 를 만드는 자리가 정확히 둘이라 문장이 참인데, 셋째가 source 를 안 주고 생기면 제가 이번에 낸 것과 똑같은 상태가 됩니다. 그때 이 문장은 「합성 세션도」까지만 말하고 새 생산자는 안 말합니다.
붙인다면 문장이 아니라 전제에 겁니다 — 「Judgment 를 만드는 자리가 둘이고 하나만 source 를 명시한다」를 못 박는 것입니다. 늘면 빨개져서 "이 문장을 같이 보라" 고 말합니다. 제가 #617 에서 _DRAFT_IN_SCORING 을 그렇게 뒀고, 그때 배운 것이 집합을 못박는 것으로는 구성이 안 잡힌다(원소가 같은 채로 모집단이 바뀐다)였습니다 — 여기서는 반대로 모집단(생산자 수) 을 못박는 자리입니다.
이 PR 에서 하실 일은 아닙니다. #623 이 머지된 뒤에 봐도 되고, 안 걸어도 문장은 지금 참입니다. 판단에 맡깁니다.
나머지
M3 를 "M1 이 무는 것은 경계가 엄격하다는 전제 위에서만 참" 으로 정리해 주신 것 — 그물이 자기 전제를 지키는 모양이라 값이 큽니다. 이번 주에 그 형태를 세 번 봤습니다(#621 의 「건너뛴 것」 단정 · 이 PR 의 M3 · 제 #617 의 「고쳐지면 빨개진다」).
#623 은 이게 머지되면 main 을 합쳐 다시 재고 draft 를 풀겠습니다.
junseo2323
left a comment
There was a problem hiding this comment.
승인합니다. 순서 주장(이쪽이 먼저)이 이 PR 에서 제일 비싼 대목이라 그것부터 재현했고, #623 의 실물과 이름이 맞는지도 대조했습니다. evidence 에 칸을 더하는 것이 옛 레코드를 깨지 않는다는 것도 확인했습니다(④).
① 순서가 강제라는 주장 — 전이로 확인됩니다
Judgment 경계가 실제로 엄격한지를 변이로 쟀습니다.
ⓒ 레코드에 @JsonIgnoreProperties(ignoreUnknown = true) 를 붙인다
→ "★ 계약에 없는 필드는 터진다 — 위 단정이 빈 통과가 아니라는 근거다" FAILED
관대하게 만들면 빨개진다 = 지금은 모르는 키에 실제로 터진다는 뜻입니다. 그러니 #623 이 먼저 들어오면 rubric_status 를 실은 판정이 Judgment 역직렬화에서 죽고, 그건 한 항목이 아니라 채점 경로 전체입니다. 운영자가 받는 문면이 AI_SERVICE_UNAVAILABLE 이라 ai-service 를 보러 간다는 것까지 맞습니다 — #518 의 거울상입니다.
② #623 의 실물과 이름이 맞습니다
ai-service/app/schemas.py rubric_status: Literal["confirmed", "draft"] | None
ai-service/app/scoring.py judgment = _pin_rubric_status(judgment, rubric)
model_copy(update={"rubric_status": rubric.status})
contracts (이 PR) "rubric_status": enum [confirmed, draft] · optional
와이어 이름·값·**「모델이 채우지 않는다 — 후처리에서 고정한다」**까지 양쪽 문면이 같습니다. 저쪽 테스트가 "루브릭 파일이 진실이다" 로 파일과 대조하는 것도 봤습니다.
③ 나머지 변이
기준선 server 전체 BUILD SUCCESSFUL
ⓐ 계약에만 새 필드를 더한다 4건 중 2 FAILED
"★ 표본이 계약의 모든 필드를 든다" + "❗레코드에만 있는 칸은 없다"
ⓓ 이 브랜치 + origin/main merge BUILD SUCCESSFUL (충돌 0)
ⓐ 가 둘인 것이 이 대조의 모양을 잘 보여줍니다 — 표본이 먼저 걸리고, 그 다음에 방향 대조가 걸립니다. 표본 값을 손으로 적는 판단(스키마에서 만들면 자기가 자기를 증명한다)에 동의합니다.
④ evidence 에 칸을 더해도 옛 레코드는 안 깨집니다 — 확인했습니다
append-only 스트림에 payload 모양을 바꾸는 변경이라 옛 항목의 검증이 깨지는지를 봤습니다.
HashChain.verify:103 String recomputed = link(entry.prevHash(), entry.seq(), entry.payload());
▲ 저장된 payload 를 다시 해시한다 — 지금 코드로 다시 만들지 않는다
새 키는 이 커밋 이후 기록에만 붙고 옛 체인은 그대로 통과합니다. 담는 판단에도 동의합니다 — escalate 와 같은 이유이고, 루브릭 파일의 status 는 나중에 confirmed 로 바뀝니다. 그때 되짚을 근거가 레코드 밖에 없으므로 재계산으로 복원이 안 됩니다. 빼자고 하지 않겠습니다.
⑤ null 을 confirmed 로 안 접는 것이 이 PR 의 요점입니다
source 와 반대 방향이라는 설명이 정확합니다. 그리고 @gitIt-sehyeon 님 지적으로 들어간 합성 세션 칸(MEASURED + null 이 전수가 아니다)까지 javadoc 에 남은 것이 좋습니다 — 그 한 문단이 없으면 다음 사람이 그 행을 「옛 레코드」로 단정합니다.
⑥ 곁 — rubricStatus 가 String 인 것
계약은 enum 인데 레코드는 String 입니다. grade·source 가 Java enum 인 것과 다른 선택이라 잠깐 멈췄는데, 이 PR 의 논지와 같은 방향이라 그대로 두는 것이 맞다고 봅니다: enum 으로 받으면 저쪽이 셋째 값을 더하는 날 채점 경로가 통째로 죽습니다 — 이 PR 이 막으려는 바로 그 사고입니다.
대신 값이 경계에서 검사되지 않으므로 오타가 오면 그대로 evidence 에 굳습니다(append-only 라 되돌릴 수 없습니다). 지금은 _pin_rubric_status 가 루브릭 파일 값을 그대로 실어서 오타가 날 자리가 없으니, 막지 않고 기록만 남깁니다.
#623의 선행입니다.#609① ⓑ 의 계약·서버 절반이고, ai-service 쪽은 그 PR 입니다.❗왜 이쪽이 먼저 들어가야 하나 — 뒤집으면 채점이 통째로 죽습니다
서버가
/internal/score응답을 도메인 레코드로 바로 역직렬화하고, 그 경계 매퍼는FAIL_ON_UNKNOWN_PROPERTIES가 기본값(on)입니다.한 항목이 아니라 경로 전체입니다. 그리고 운영자가 받는 문면이
AI_SERVICE_UNAVAILABLE이라 ai-service 를 의심하게 됩니다 — 고칠 자리는 여기인데.#518이 반대 방향으로 같은 사고를 냈습니다(우리가 모르는 키를 보내 저쪽extra="forbid"가 422, 알파에서 재설명이 전부 죽음).무엇이 문제인가
status: confirmed는 "근거자료 검토를 마쳤다" 는 뜻인데, 검토 전 기준으로 낸 판정이 레코드에서 확정 기준 판정과 똑같이 생겼습니다.S-02 에서 고를 수 있는 두 상품 중 변액을 고르면 그 세션의 이해항목 전부가 검토 전 기준으로 채점됩니다.
evidence/가 append-only 라 나중에 되짚을 수 없습니다.❗비어 있는 것이 두 가지입니다 —
source와 같이 읽습니다"not_applicable"같은 값을 만들지 않는 이유는source가 이미 그 사실을 말하기 때문입니다. 한 사실이 두 벌이 됩니다.그리고 「없으면 확정」으로 접지 않습니다.
source와 반대 방향입니다 — 저쪽은 없으면MEASURED가 사실과 같지만, 여기서 기본값을 주면 **이 필드가 생기기 전 레코드 전부가 「검토된 기준으로 판정했다」**로 읽히고, 그게 이 필드를 만드는 이유 자체를 지웁니다.새 그물 — 들어오는 판정을 계약과 대조합니다
RiskItemWireContractTest가 나가는 절반을 덮는데, 들어오는 절반은 아무도 안 봤습니다. 그쪽 실패가 더 큽니다.표본 값을 손으로 적는 이유는 스키마에서 만들어 내면 대조가 스스로를 증명하는 모양이 되기 때문입니다. 계약이 필드를 늘리면 사람이 값을 적으면서 그 필드가 무엇인지 보게 되고, 그 자리가 이 대조의 값입니다.
역검증
rubric_status를 뺀다@JsonIgnoreProperties(ignoreUnknown)로 관대하게세 번째가 중요합니다 — 경계 매퍼가 관대해지면 첫째 대조가 아무것도 안 막는데, 그 사실을 배포에서 처음 만나게 됩니다.
❗남의 파일 둘을 건드렸습니다 — @gitIt-sehyeon
Judgment에 칸이 늘면 적재 쪽에서 담을지 뺄지를 정하게 만든 그 단정이 빨개져서, 그 자리에서 정했습니다.담는 이유가
escalate와 같습니다 — 재계산으로 못 되돌립니다. 루브릭 파일의status는 나중에 확정으로 바뀌고, 그러면 "이 판정이 검토 전 기준으로 나왔다" 를 되짚을 근거가 아무 데도 없습니다. 빼는 쪽이 맞다고 보시면 말씀해 주세요 —deliberatelyOmitted에 이유와 함께 적으면 됩니다.CanonicalJsonTest쪽은 정규화가 바뀐 것이 아니라 판정이 바뀐 것이라, 그 파일이source때 적어 둔 그 모양 그대로입니다.검증
required는 안 건드렸습니다. 나가는 본문(OutboundJudgment)도 그대로입니다 — 그 레코드가 필드를 골라 담아서 도메인이 늘어도 안 따라 늘어납니다(#518이 만든 자리).Refs #609 · #623