From 3d0275c86c6e9f192ee4e8751a7b01bf1c9d3bdb Mon Sep 17 00:00:00 2001 From: huijin-kang Date: Mon, 14 Sep 2026 11:42:00 +0900 Subject: [PATCH 1/2] =?UTF-8?q?feat(F-SCR-001):=20=ED=8C=90=EC=A0=95?= =?UTF-8?q?=EC=9D=B4=20=EA=B7=BC=EA=B1=B0=20=EB=A3=A8=EB=B8=8C=EB=A6=AD?= =?UTF-8?q?=EC=9D=98=20=EA=B2=80=ED=86=A0=20=EC=83=81=ED=83=9C=EB=A5=BC=20?= =?UTF-8?q?=EB=93=A4=EA=B3=A0=20=EB=82=98=EA=B0=84=EB=8B=A4=20=E2=80=94=20?= =?UTF-8?q?=EA=B3=84=EC=95=BD=C2=B7=EB=A0=88=EC=BD=94=EB=93=9C=20=EC=AA=BD?= =?UTF-8?q?=20(#609=20=E2=91=A0=20=E2=93=91)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- contracts/judgment.schema.json | 7 + .../com/sphinxfin/sphinx/domain/Judgment.java | 38 +++- .../sphinxfin/sphinx/domain/SkippedItem.java | 7 +- .../evidence/StoredEvidenceRecorder.java | 7 + .../aiservice/JudgmentWireContractTest.java | 167 ++++++++++++++++++ .../sphinx/evidence/CanonicalJsonTest.java | 4 + 6 files changed, 225 insertions(+), 5 deletions(-) create mode 100644 server/src/test/java/com/sphinxfin/sphinx/core/aiservice/JudgmentWireContractTest.java diff --git a/contracts/judgment.schema.json b/contracts/judgment.schema.json index 0b11acef..4a8da2f7 100644 --- a/contracts/judgment.schema.json +++ b/contracts/judgment.schema.json @@ -62,6 +62,13 @@ "type": "string", "description": "이 판정을 낸 채점 프롬프트 버전(ai-service scoring.PROMPT_VERSION, 예 F-SCR-001_v2). optional — 이 필드가 생기기 전 레코드는 없다. ❗confidence 의 정의가 프롬프트 버전마다 다르다: v1 은 등급 확신도, v2 는 재현 가능성이다(PR #114). evidence 는 append-only 라 두 정의가 같은 컬럼에 섞이면 감사 시점에 어느 쪽으로 해석할지 판단할 근거가 없어진다 — 그래서 값과 함께 남긴다(결정 10.38 · 이슈 #136)." }, + "rubric_status": { + "enum": [ + "confirmed", + "draft" + ], + "description": "이 판정의 근거가 된 루브릭이 근거자료 검토를 마친 것인가. optional — 이 필드가 생기기 전 레코드에는 없다. ❗없으면 confirmed 로 읽지 않는다: 「검토를 마쳤다」와 「모른다」는 다르다. 기본값을 confirmed 로 두면 이 필드가 생기기 전 레코드 전부가 「검토된 기준으로 판정했다」로 읽히고, 그게 이 필드를 만드는 이유 자체를 지운다(이슈 #609 ①). 모델이 채우지 않는다 — ai-service scoring 이 루브릭 파일에서 읽어 후처리에서 고정한다(prompt_version 과 같은 층). ❗읽을 때 source 와 같이 본다: source=SKIPPED 이면서 비어 있는 것은 「루브릭을 안 본 판정」이고(건너뛴 항목은 채점을 안 지난다), source=MEASURED 이면서 비어 있는 것만 「모른다」다. 두 사실을 한 필드로 합치지 않는 이유는 source 가 이미 그것을 말하기 때문이다." + }, "source": { "enum": [ "MEASURED", diff --git a/server/src/main/java/com/sphinxfin/sphinx/domain/Judgment.java b/server/src/main/java/com/sphinxfin/sphinx/domain/Judgment.java index 4c2ad6f7..fd207e2e 100644 --- a/server/src/main/java/com/sphinxfin/sphinx/domain/Judgment.java +++ b/server/src/main/java/com/sphinxfin/sphinx/domain/Judgment.java @@ -50,7 +50,36 @@ public record Judgment( * 값을 넣으면 {@code confidence} 의 정의를 가리키는 필드가 두 뜻을 갖고, * 그건 그 필드 javadoc 이 막으려는 바로 그 모양이다(결정 10.38 · 이슈 #136). */ - Source source + Source source, + + /** + * 이 판정의 근거가 된 루브릭이 근거자료 검토를 마친 것인가 (이슈 #609 ①). + * + *

{@code "confirmed"} · {@code "draft"} · {@code null}. ai-service 가 루브릭 + * 파일에서 읽어 후처리에서 고정한다 — 모델이 채우지 않는다({@code promptVersion} 과 + * 같은 층이다). + * + *

❗{@code null} 을 {@code "confirmed"} 로 접지 않는다. {@link #source} 와 + * 반대 방향이다 — 저쪽은 없으면 {@code MEASURED} 가 사실과 같지만, 여기서 기본값을 + * 주면 이 필드가 생기기 전 레코드 전부가 「검토된 기준으로 판정했다」로 읽힌다. + * 실제로는 모르는 것이고, 그렇게 접으면 이 필드를 만드는 이유 자체가 없어진다. + * + *

❗읽을 때 {@link #source} 와 같이 본다. 비어 있는 것이 두 가지다. + * + *

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

두 사실을 한 필드로 합치지 않는 이유는 {@code source} 가 이미 그것을 말하기 + * 때문이다. 여기에 {@code "not_applicable"} 같은 값을 만들면 같은 사실이 두 벌이 된다. + * + *

왜 필요한가: 검토 전 기준으로 낸 판정이 레코드에서 확정 기준 판정과 똑같이 + * 생겼다. 지금 실물이 ELS 10종 확정 · 변액 7종 전부 검토 전이라, S-02 에서 + * 변액을 고르면 그 세션의 이해항목 전부가 검토 전 기준으로 채점된다. {@code evidence/} + * 가 append-only 라 나중에 되짚을 수 없다. + */ + String rubricStatus ) { /** * 이 판정의 출처. 등급이 아니라 등급이 나온 방식이다. @@ -111,7 +140,8 @@ public enum Source { */ public Judgment(String itemId, Grade grade, BigDecimal confidence, Evidence evidence, String reason, String misconceptionType) { - this(itemId, grade, confidence, evidence, reason, misconceptionType, null, false, null); + this(itemId, grade, confidence, evidence, reason, misconceptionType, null, false, + null, null); } /** @@ -122,7 +152,7 @@ public Judgment(String itemId, Grade grade, BigDecimal confidence, Evidence evid public Judgment(String itemId, Grade grade, BigDecimal confidence, Evidence evidence, String reason, String misconceptionType, String promptVersion) { this(itemId, grade, confidence, evidence, reason, misconceptionType, promptVersion, - false, null); + false, null, null); } /** @@ -133,7 +163,7 @@ public Judgment(String itemId, Grade grade, BigDecimal confidence, Evidence evid String reason, String misconceptionType, String promptVersion, boolean escalate) { this(itemId, grade, confidence, evidence, reason, misconceptionType, promptVersion, - escalate, null); + escalate, null, null); } public record Evidence(String utteranceQuote, String rubricClause) {} diff --git a/server/src/main/java/com/sphinxfin/sphinx/domain/SkippedItem.java b/server/src/main/java/com/sphinxfin/sphinx/domain/SkippedItem.java index 04e3a0f3..01e790f3 100644 --- a/server/src/main/java/com/sphinxfin/sphinx/domain/SkippedItem.java +++ b/server/src/main/java/com/sphinxfin/sphinx/domain/SkippedItem.java @@ -57,6 +57,10 @@ private SkippedItem() { * *

{@code promptVersion} 은 {@code null} 이다 — 이 판정을 낸 프롬프트가 없다. * 그 {@code null} 이 "버전 미상" 과 겹치는 것은 {@code source} 가 갈라 준다. + * + *

{@code rubricStatus} 도 같다 — 채점을 안 지났으므로 루브릭을 안 봤다. 그 + * {@code null} 이 "이 필드가 생기기 전 레코드" 와 겹치는 것도 {@code source} 가 갈라 + * 준다(이슈 #609 ①). */ public static Judgment judgmentFor(String itemId) { return new Judgment( @@ -68,6 +72,7 @@ public static Judgment judgmentFor(String itemId) { null, // 오해 유형은 발화에서 나온다. 발화가 없으면 없다. null, // 프롬프트가 없다 — source 가 그 사실을 말한다 false, // 상신 신호도 발화에서 나온다 - Judgment.Source.SKIPPED); + Judgment.Source.SKIPPED, + null); // 루브릭을 안 봤다 — 위 source 가 그 사실을 말한다 (#609 ①) } } diff --git a/server/src/main/java/com/sphinxfin/sphinx/evidence/StoredEvidenceRecorder.java b/server/src/main/java/com/sphinxfin/sphinx/evidence/StoredEvidenceRecorder.java index 21f4f058..c30171be 100644 --- a/server/src/main/java/com/sphinxfin/sphinx/evidence/StoredEvidenceRecorder.java +++ b/server/src/main/java/com/sphinxfin/sphinx/evidence/StoredEvidenceRecorder.java @@ -264,6 +264,13 @@ private static Map judgmentPayload(Judgment judgment) { // 레코드에서 측정된 U3 와 똑같이 생긴다. questionSource 가 질문 문면에 하는 일과 같다. // 이 필드가 생기기 전 레코드에는 없고, 그것들은 전부 측정이다(Judgment.Source javadoc). item.put("source", judgment.source()); + // 이 판정의 근거가 된 루브릭이 검토를 마친 것인가 (이슈 #609 ①). ❗담는 이유가 + // escalate 와 같다 — 재계산으로 못 되돌린다. 루브릭 파일의 status 는 나중에 + // confirmed 로 바뀌고, 그러면 "이 판정이 검토 전 기준으로 나왔다" 를 되짚을 근거가 + // 아무 데도 없다. 지금 실물이 변액 7종 전부 draft 라 그 세션은 이해항목 전부가 + // 그 상태다. nullable — 생략하지 않는 것은 위 셋과 같은 규약이고, 여기서 null 은 + // "검토됐다" 가 아니라 "모른다" 다(Judgment.rubricStatus javadoc). + item.put("rubricStatus", judgment.rubricStatus()); return item; } } diff --git a/server/src/test/java/com/sphinxfin/sphinx/core/aiservice/JudgmentWireContractTest.java b/server/src/test/java/com/sphinxfin/sphinx/core/aiservice/JudgmentWireContractTest.java new file mode 100644 index 00000000..5b1e2968 --- /dev/null +++ b/server/src/test/java/com/sphinxfin/sphinx/core/aiservice/JudgmentWireContractTest.java @@ -0,0 +1,167 @@ +package com.sphinxfin.sphinx.core.aiservice; + +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.sphinxfin.sphinx.domain.Judgment; +import com.sphinxfin.sphinx.domain.RiskItem; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.http.MediaType; +import org.springframework.test.web.client.MockRestServiceServer; +import org.springframework.web.client.RestClient; + +import java.lang.reflect.RecordComponent; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.Arrays; +import java.util.LinkedHashMap; +import java.util.Map; +import java.util.Set; +import java.util.TreeSet; +import java.util.stream.Collectors; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.springframework.test.web.client.match.MockRestRequestMatchers.requestTo; +import static org.springframework.test.web.client.response.MockRestResponseCreators.withSuccess; + +/** + * 들어오는 판정 ≡ {@code contracts/judgment.schema.json}. 소유: 강희진 (이슈 #609 ①) + * + *

왜 이 테스트가 필요한가 — 계약이 늘면 채점이 통째로 죽는다

+ * + *

{@code RiskItemWireContractTest} 가 나가는 절반을 덮는다. 들어오는 절반은 + * 아무도 안 봤는데, 그쪽 실패가 더 크다. + * + *

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

그 매퍼는 {@code FAIL_ON_UNKNOWN_PROPERTIES} 가 기본값(on)이라, ai-service 가 계약에 + * 필드를 더하고 이 레코드가 안 따라오면 모든 채점 응답이 역직렬화에서 터진다. 한 + * 항목이 아니라 경로 전체이고, 운영자가 받는 문면은 {@code AI_SERVICE_UNAVAILABLE} 이라 + * ai-service 를 의심하게 된다. 고칠 자리는 여기인데. + * + *

실측(PR #623 리뷰): {@code rubric_status} 하나가 실린 판정을 이 레코드로 읽으면 + * {@code UnrecognizedPropertyException: Unrecognized field "rubric_status" … not marked as + * ignorable}. 그래서 계약·레코드가 ai-service 보다 먼저 들어가야 했다 — 이 테스트가 + * 그 순서를 다음부터 자동으로 알려준다. + * + *

반대 방향도 같은 자리다. {@code #518}({@code source} 를 나가는 본문에 실어 저쪽 + * {@code extra="forbid"} 가 422) 이 대칭이고, 알파에서 재설명이 전부 죽었다. + */ +@DisplayName("들어오는 판정 ≡ contracts/judgment.schema.json (이슈 #609 ①)") +class JudgmentWireContractTest { + + private static final String BASE = "http://ai:8100"; + private static final Path SCHEMA = Path.of("../contracts/judgment.schema.json"); + private static final ObjectMapper MAPPER = new ObjectMapper(); + + private static final RiskItem EXTRACTED = RiskItem.extracted( + "ELS-PRINCIPAL-LOSS-WARNING", "mock-els-001", "원금손실 조건", "required", + new RiskItem.Condition("만기평가일에 …(원문 인용)", new RiskItem.SourceSpan(3, 120, 210))); + + /** + * 계약의 모든 필드에 값을 하나씩 준다 — 이 표가 모자라면 아래 첫 단정이 잡는다. + * + *

❗값을 여기 손으로 적는 이유는 스키마에서 만들어 내면 대조가 스스로를 증명하는 + * 모양이 되기 때문이다. 계약이 필드를 늘리면 사람이 여기 값을 적으면서 그 필드가 + * 무엇인지 보게 된다 — 그 자리가 이 대조의 값이다. + */ + private static final Map CONTRACT_SAMPLE = new LinkedHashMap<>() {{ + put("item_id", "ELS-PRINCIPAL-LOSS-WARNING"); + put("grade", "U1"); + put("confidence", 0.9); + put("evidence", Map.of("utterance_quote", "원금이 깎일 수 있다고 들었어요", + "rubric_clause", "원금손실 조건")); + put("reason", "핵심 조건을 자기 말로 설명했다"); + put("misconception_type", null); + put("escalate", false); + put("prompt_version", "F-SCR-001_v2"); + put("source", "MEASURED"); + put("rubric_status", "draft"); + }}; + + private RestClient.Builder builder; + private MockRestServiceServer server; + private AiServiceClient client; + + @BeforeEach + void setUp() { + builder = RestClient.builder(); + server = MockRestServiceServer.bindTo(builder).build(); + client = new AiServiceClient(builder, BASE, "", new com.sphinxfin.sphinx.core.pii.PiiMeter()); + } + + @Test + @DisplayName("★ 표본이 계약의 모든 필드를 든다 — 모자라면 아래 대조가 덜 재고 통과한다") + void theSampleCoversEveryContractField() throws Exception { + JsonNode properties = MAPPER.readTree(Files.readString(SCHEMA)).get("properties"); + assertThat(properties) + .as("judgment.schema.json 에서 properties 를 못 읽었다 — 스키마 모양이 바뀌었으면 " + + "이 대조도 같이 고친다. 안 그러면 양쪽이 다 비어 조용히 통과한다") + .isNotNull(); + + Set contract = new TreeSet<>(); + properties.fieldNames().forEachRemaining(contract::add); + + assertThat(new TreeSet<>(CONTRACT_SAMPLE.keySet())) + .as("계약이 필드를 늘렸는데 이 표본이 안 따라왔다. 값을 적으면서 그 필드가 " + + "무엇인지 보고, 레코드에 칸이 필요한지 정한다") + .isEqualTo(contract); + } + + @Test + @DisplayName("❗계약이 허용하는 판정을 이 레코드가 읽는다 — 못 읽으면 채점 경로가 통째로 죽는다") + void everyContractFieldSurvivesTheBoundary() throws Exception { + Judgment judgment = scoreWith(MAPPER.writeValueAsString(CONTRACT_SAMPLE)); + + assertThat(judgment.itemId()).isEqualTo("ELS-PRINCIPAL-LOSS-WARNING"); + assertThat(judgment.rubricStatus()) + .as("계약의 값이 레코드까지 와야 한다 — 칸만 만들고 안 실으면 " + + "검토 전 기준으로 낸 판정이 기록에서 확정 기준과 똑같이 생긴다") + .isEqualTo("draft"); + } + + @Test + @DisplayName("★ 계약에 없는 필드는 터진다 — 위 단정이 빈 통과가 아니라는 근거다") + void aFieldTheContractDoesNotHaveBlowsUp() throws Exception { + Map extra = new LinkedHashMap<>(CONTRACT_SAMPLE); + extra.put("rubric_version", "v9"); // 계약에 없는 키 + + assertThatThrownBy(() -> scoreWith(MAPPER.writeValueAsString(extra))) + .as("경계 매퍼가 엄격하지 않다면 위 대조는 아무것도 안 막는다 — " + + "계약이 늘어도 조용히 통과하고, 그 사실을 배포에서 처음 만난다") + .isInstanceOf(Exception.class); + } + + @Test + @DisplayName("❗레코드에만 있는 칸은 없다 — 계약이 약속 안 한 값을 상류에서 기대하면 영원히 null 이다") + void theRecordHasNoFieldTheContractLacks() throws Exception { + JsonNode properties = MAPPER.readTree(Files.readString(SCHEMA)).get("properties"); + Set contract = new TreeSet<>(); + properties.fieldNames().forEachRemaining(contract::add); + + Set record = Arrays.stream(Judgment.class.getRecordComponents()) + .map(RecordComponent::getName) + .map(JudgmentWireContractTest::snake) + .collect(Collectors.toCollection(TreeSet::new)); + + assertThat(record) + .as("레코드에 있는데 계약에 없는 칸이다. 상류는 계약만 보므로 그 값은 절대 " + + "안 온다 — 계약에 올리든지 레코드에서 빼든지 여기서 정한다") + .isEqualTo(contract); + } + + /** 목 응답 본문을 주고 {@code /internal/score} 를 한 번 태운다. */ + private Judgment scoreWith(String responseBody) { + server.expect(requestTo(BASE + "/internal/score")) + .andRespond(withSuccess(responseBody, MediaType.APPLICATION_JSON)); + return client.score(EXTRACTED.itemId(), "질문", "답변", EXTRACTED, "ELS").judgment(); + } + + private static String snake(String camel) { + return camel.replaceAll("([a-z0-9])([A-Z])", "$1_$2").toLowerCase(); + } +} diff --git a/server/src/test/java/com/sphinxfin/sphinx/evidence/CanonicalJsonTest.java b/server/src/test/java/com/sphinxfin/sphinx/evidence/CanonicalJsonTest.java index 57956980..a1b4ae2c 100644 --- a/server/src/test/java/com/sphinxfin/sphinx/evidence/CanonicalJsonTest.java +++ b/server/src/test/java/com/sphinxfin/sphinx/evidence/CanonicalJsonTest.java @@ -310,6 +310,10 @@ void judgmentIsSerializable() { + "\"misconceptionType\":null," + "\"promptVersion\":null," + "\"reason\":\"원금 보장으로 오해\"," + // rubricStatus 도 같은 모양으로 늘었다 (이슈 #609 ①) — 상류가 + // 아직 안 싣는 동안 null 이고, 그 null 은 **"검토됐다" 가 아니라 + // "모른다"** 다. 키 이름 순서상 reason 과 source 사이다. + + "\"rubricStatus\":null," // source 도 같은 모양으로 늘었다 (이슈 #518) — 상류가 안 싣는 // 값이라 MEASURED 로 접혀 들어온다. 키가 **있다**는 것이 요지다: // 룰이 정한 U3(SKIPPED)와 측정된 U3 를 기록에서 갈라야 한다. From 238261194b26476df8ea5bed10977c0c755919df Mon Sep 17 00:00:00 2001 From: huijin-kang Date: Mon, 14 Sep 2026 13:36:55 +0900 Subject: [PATCH 2/2] =?UTF-8?q?docs(F-SCR-001):=20=E3=80=8CMEASURED=20+=20?= =?UTF-8?q?=EB=B9=84=EC=97=88=EC=9D=8C=EB=A7=8C=20=EB=AA=A8=EB=A5=B8?= =?UTF-8?q?=EB=8B=A4=E3=80=8D=EA=B0=80=20=EC=A0=84=EC=88=98=EA=B0=80=20?= =?UTF-8?q?=EC=95=84=EB=8B=88=EB=8B=A4=20=E2=80=94=20=ED=95=A9=EC=84=B1=20?= =?UTF-8?q?=EC=84=B8=EC=85=98=EC=9D=B4=20=EA=B7=B8=20=EC=B9=B8=EC=97=90=20?= =?UTF-8?q?=EB=96=A8=EC=96=B4=EC=A7=84=EB=8B=A4=20(#624=20=EB=A6=AC?= =?UTF-8?q?=EB=B7=B0)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 계약 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 --- contracts/judgment.schema.json | 2 +- .../java/com/sphinxfin/sphinx/domain/Judgment.java | 13 ++++++++++--- 2 files changed, 11 insertions(+), 4 deletions(-) diff --git a/contracts/judgment.schema.json b/contracts/judgment.schema.json index 4a8da2f7..5319be7e 100644 --- a/contracts/judgment.schema.json +++ b/contracts/judgment.schema.json @@ -67,7 +67,7 @@ "confirmed", "draft" ], - "description": "이 판정의 근거가 된 루브릭이 근거자료 검토를 마친 것인가. optional — 이 필드가 생기기 전 레코드에는 없다. ❗없으면 confirmed 로 읽지 않는다: 「검토를 마쳤다」와 「모른다」는 다르다. 기본값을 confirmed 로 두면 이 필드가 생기기 전 레코드 전부가 「검토된 기준으로 판정했다」로 읽히고, 그게 이 필드를 만드는 이유 자체를 지운다(이슈 #609 ①). 모델이 채우지 않는다 — ai-service scoring 이 루브릭 파일에서 읽어 후처리에서 고정한다(prompt_version 과 같은 층). ❗읽을 때 source 와 같이 본다: source=SKIPPED 이면서 비어 있는 것은 「루브릭을 안 본 판정」이고(건너뛴 항목은 채점을 안 지난다), source=MEASURED 이면서 비어 있는 것만 「모른다」다. 두 사실을 한 필드로 합치지 않는 이유는 source 가 이미 그것을 말하기 때문이다." + "description": "이 판정의 근거가 된 루브릭이 근거자료 검토를 마친 것인가. optional — 이 필드가 생기기 전 레코드에는 없다. ❗없으면 confirmed 로 읽지 않는다: 「검토를 마쳤다」와 「모른다」는 다르다. 기본값을 confirmed 로 두면 이 필드가 생기기 전 레코드 전부가 「검토된 기준으로 판정했다」로 읽히고, 그게 이 필드를 만드는 이유 자체를 지운다(이슈 #609 ①). 모델이 채우지 않는다 — ai-service scoring 이 루브릭 파일에서 읽어 후처리에서 고정한다(prompt_version 과 같은 층). ❗읽을 때 source 와 같이 본다: source=SKIPPED 이면서 비어 있는 것은 「루브릭을 안 본 판정」이다(건너뛴 항목은 채점을 안 지난다). source=MEASURED 이면서 비어 있는 것은 대개 「이 필드가 생기기 전 레코드」인데, 합성 세션(F-DSH-003)도 source 를 안 줘서 MEASURED 로 접히므로 「옛 레코드」로 단정하지 않는다. 두 사실을 한 필드로 합치지 않는 이유는 source 가 이미 그것을 말하기 때문이다." }, "source": { "enum": [ diff --git a/server/src/main/java/com/sphinxfin/sphinx/domain/Judgment.java b/server/src/main/java/com/sphinxfin/sphinx/domain/Judgment.java index fd207e2e..5611a508 100644 --- a/server/src/main/java/com/sphinxfin/sphinx/domain/Judgment.java +++ b/server/src/main/java/com/sphinxfin/sphinx/domain/Judgment.java @@ -68,11 +68,18 @@ public record Judgment( * *

          * source=SKIPPED  + null   루브릭을 안 본 판정 — 건너뛴 항목은 채점을 안 지난다
-         * source=MEASURED + null   이 필드가 생기기 전 레코드다      ← 여기만 「모른다」
+         * source=MEASURED + null   대개 이 필드가 생기기 전 레코드다   ← ❗「옛 레코드」로 단정하지 않는다
          * 
* - *

두 사실을 한 필드로 합치지 않는 이유는 {@code source} 가 이미 그것을 말하기 - * 때문이다. 여기에 {@code "not_applicable"} 같은 값을 만들면 같은 사실이 두 벌이 된다. + *

❗둘째 칸이 전수가 아니다(PR #624 리뷰). 합성 세션(F-DSH-003)이 + * {@code source} 를 안 줘서 {@link Source#MEASURED} 로 접히고, 그 판정은 DB 에 + * 남는다({@code SyntheticSessionLoader}). 그 행의 뜻은 "옛 레코드" 가 아니라 + * "합성이라 채점을 안 지났다" 다. + * + *

그래도 값을 더 만들지 않는다. {@code "not_applicable"} 을 넣으면 같은 사실이 + * 두 벌이 되고, 합성에 {@code SKIPPED} 를 주는 것도 아니다 — 합성은 건너뛴 것이 + * 아니라 애초에 채점 경로 밖이고 그 값의 뜻은 집계 쪽이 정할 것이다. 여기서는 + * 단정하지 않는 것으로 족하다. * *

왜 필요한가: 검토 전 기준으로 낸 판정이 레코드에서 확정 기준 판정과 똑같이 * 생겼다. 지금 실물이 ELS 10종 확정 · 변액 7종 전부 검토 전이라, S-02 에서