Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions contracts/judgment.schema.json
Original file line number Diff line number Diff line change
Expand Up @@ -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 이면서 비어 있는 것은 대개 「이 필드가 생기기 전 레코드」인데, 합성 세션(F-DSH-003)도 source 를 안 줘서 MEASURED 로 접히므로 「옛 레코드」로 단정하지 않는다. 두 사실을 한 필드로 합치지 않는 이유는 source 가 이미 그것을 말하기 때문이다."
},
"source": {
"enum": [
"MEASURED",
Expand Down
45 changes: 41 additions & 4 deletions server/src/main/java/com/sphinxfin/sphinx/domain/Judgment.java
Original file line number Diff line number Diff line change
Expand Up @@ -50,7 +50,43 @@ public record Judgment(
* 값을 넣으면 <b>{@code confidence} 의 정의를 가리키는 필드</b>가 두 뜻을 갖고,
* 그건 그 필드 javadoc 이 막으려는 바로 그 모양이다(결정 10.38 · 이슈 #136).
*/
Source source
Source source,

/**
* 이 판정의 근거가 된 루브릭이 <b>근거자료 검토를 마친 것인가</b> (이슈 #609 ①).
*
* <p>{@code "confirmed"} · {@code "draft"} · {@code null}. ai-service 가 루브릭
* 파일에서 읽어 후처리에서 고정한다 — 모델이 채우지 않는다({@code promptVersion} 과
* 같은 층이다).
*
* <p>❗<b>{@code null} 을 {@code "confirmed"} 로 접지 않는다.</b> {@link #source} 와
* 반대 방향이다 — 저쪽은 없으면 {@code MEASURED} 가 사실과 같지만, 여기서 기본값을
* 주면 <b>이 필드가 생기기 전 레코드 전부가 「검토된 기준으로 판정했다」</b>로 읽힌다.
* 실제로는 모르는 것이고, 그렇게 접으면 이 필드를 만드는 이유 자체가 없어진다.
*
* <p>❗<b>읽을 때 {@link #source} 와 같이 본다.</b> 비어 있는 것이 두 가지다.
*
* <pre>
* source=SKIPPED + null 루브릭을 안 본 판정 — 건너뛴 항목은 채점을 안 지난다
* source=MEASURED + null 대개 이 필드가 생기기 전 레코드다 ← ❗「옛 레코드」로 단정하지 않는다
* </pre>
*
* <p>❗<b>둘째 칸이 전수가 아니다</b>(PR #624 리뷰). 합성 세션(F-DSH-003)이
* {@code source} 를 안 줘서 {@link Source#MEASURED} 로 접히고, 그 판정은 DB 에
* 남는다({@code SyntheticSessionLoader}). 그 행의 뜻은 <i>"옛 레코드"</i> 가 아니라
* <b>"합성이라 채점을 안 지났다"</b> 다.
*
* <p>그래도 값을 더 만들지 않는다. {@code "not_applicable"} 을 넣으면 같은 사실이
* 두 벌이 되고, 합성에 {@code SKIPPED} 를 주는 것도 아니다 — 합성은 <b>건너뛴 것이
* 아니라 애초에 채점 경로 밖</b>이고 그 값의 뜻은 집계 쪽이 정할 것이다. 여기서는
* <b>단정하지 않는 것</b>으로 족하다.
*
* <p>왜 필요한가: 검토 전 기준으로 낸 판정이 레코드에서 확정 기준 판정과 <b>똑같이
* 생겼다.</b> 지금 실물이 ELS 10종 확정 · 변액 7종 <b>전부</b> 검토 전이라, S-02 에서
* 변액을 고르면 그 세션의 이해항목 전부가 검토 전 기준으로 채점된다. {@code evidence/}
* 가 append-only 라 나중에 되짚을 수 없다.
*/
String rubricStatus
) {
/**
* 이 판정의 출처. <b>등급이 아니라 등급이 나온 방식</b>이다.
Expand Down Expand Up @@ -111,7 +147,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);
}

/**
Expand All @@ -122,7 +159,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);
}

/**
Expand All @@ -133,7 +170,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) {}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -57,6 +57,10 @@ private SkippedItem() {
*
* <p>{@code promptVersion} 은 {@code null} 이다 — 이 판정을 낸 프롬프트가 없다.
* 그 {@code null} 이 "버전 미상" 과 겹치는 것은 {@code source} 가 갈라 준다.
*
* <p>{@code rubricStatus} 도 같다 — 채점을 안 지났으므로 루브릭을 안 봤다. 그
* {@code null} 이 "이 필드가 생기기 전 레코드" 와 겹치는 것도 {@code source} 가 갈라
* 준다(이슈 #609 ①).
*/
public static Judgment judgmentFor(String itemId) {
return new Judgment(
Expand All @@ -68,6 +72,7 @@ public static Judgment judgmentFor(String itemId) {
null, // 오해 유형은 발화에서 나온다. 발화가 없으면 없다.
null, // 프롬프트가 없다 — source 가 그 사실을 말한다
false, // 상신 신호도 발화에서 나온다
Judgment.Source.SKIPPED);
Judgment.Source.SKIPPED,
null); // 루브릭을 안 봤다 — 위 source 가 그 사실을 말한다 (#609 ①)
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -264,6 +264,13 @@ private static Map<String, Object> 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;
}
}
Original file line number Diff line number Diff line change
@@ -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;

/**
* <b>들어오는</b> 판정 ≡ {@code contracts/judgment.schema.json}. 소유: 강희진 (이슈 #609 ①)
*
* <h2>왜 이 테스트가 필요한가 — 계약이 늘면 채점이 통째로 죽는다</h2>
*
* <p>{@code RiskItemWireContractTest} 가 <b>나가는</b> 절반을 덮는다. 들어오는 절반은
* 아무도 안 봤는데, 그쪽 실패가 더 크다.
*
* <pre>
* AiServiceClient:239 .body(Judgment.class) ← 경계 매퍼(SNAKE_CASE·엄격)
* </pre>
*
* <p>그 매퍼는 {@code FAIL_ON_UNKNOWN_PROPERTIES} 가 기본값(on)이라, ai-service 가 계약에
* 필드를 더하고 이 레코드가 안 따라오면 <b>모든 채점 응답이 역직렬화에서 터진다.</b> 한
* 항목이 아니라 경로 전체이고, 운영자가 받는 문면은 {@code AI_SERVICE_UNAVAILABLE} 이라
* <b>ai-service 를 의심하게 된다.</b> 고칠 자리는 여기인데.
*
* <p>실측(PR #623 리뷰): {@code rubric_status} 하나가 실린 판정을 이 레코드로 읽으면
* {@code UnrecognizedPropertyException: Unrecognized field "rubric_status" … not marked as
* ignorable}. 그래서 <b>계약·레코드가 ai-service 보다 먼저 들어가야 했다</b> — 이 테스트가
* 그 순서를 다음부터 자동으로 알려준다.
*
* <p>반대 방향도 같은 자리다. {@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)));

/**
* 계약의 <b>모든</b> 필드에 값을 하나씩 준다 — 이 표가 모자라면 아래 첫 단정이 잡는다.
*
* <p>❗값을 여기 손으로 적는 이유는 스키마에서 만들어 내면 <b>대조가 스스로를 증명하는</b>
* 모양이 되기 때문이다. 계약이 필드를 늘리면 <b>사람이 여기 값을 적으면서</b> 그 필드가
* 무엇인지 보게 된다 — 그 자리가 이 대조의 값이다.
*/
private static final Map<String, Object> 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<String> 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<String, Object> 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<String> contract = new TreeSet<>();
properties.fieldNames().forEachRemaining(contract::add);

Set<String> 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();
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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 를 기록에서 갈라야 한다.
Expand Down
Loading