Jev 내담자 평가·표현 v2와 속마음 공개
상담자 발화 판정(A)·감정(B)·표현(C) 20문항 질문 세트, 감쇠 없는 이번 턴 반응과 비대칭 기분 전이, 개방도 게이트로 생성 지시를 만들고 ccd.coping_strategy 전달 누락을 고친다. trace v2와 고정 문구 속마음 요약(migration 24, AI 경로 차단 RLS)을 같은 트랜잭션에 저장하고 피드백 정책이 켜진 경우에만 done·TurnResponse·음성 reply·리뷰로 노출한다. 회기 화면 속마음 보기 토글, 리뷰 접힘 블록, 관리자 감정 관측 v2 표시를 추가한다.
This commit is contained in:
parent
36cb847e38
commit
29c406d89f
39 changed files with 5726 additions and 343 deletions
|
|
@ -46,11 +46,56 @@ type AffectTrace = {
|
|||
}>;
|
||||
};
|
||||
|
||||
type AppraisalTraceItem =
|
||||
| { key: string; kind: "noul"; probability: number; confidence: number | null; decision: string }
|
||||
| { key: string; kind: "choice"; choice: string; probabilities: Record<string, number>; confidence: number | null; decision: string };
|
||||
|
||||
type ExpressionChoiceTraceItem = { choice: string; probabilities: Record<string, number>; confidence: number | null; decision: string };
|
||||
|
||||
type AffectTraceV2 = {
|
||||
schema_version: 2;
|
||||
provider: string;
|
||||
model: string;
|
||||
latency_ms: number;
|
||||
input_tokens: number;
|
||||
output_tokens: number;
|
||||
cost_usd: number | null;
|
||||
turn_seq: number;
|
||||
policy: {
|
||||
version: "jev-affect-v2";
|
||||
min_confidence: number;
|
||||
tentative_confidence_floor: number;
|
||||
adjacent_probability_threshold: number;
|
||||
worsening_accepted_alpha: number;
|
||||
worsening_accepted_cap: number;
|
||||
worsening_tentative_alpha: number;
|
||||
worsening_tentative_cap: number;
|
||||
recovery_accepted_alpha: number;
|
||||
recovery_accepted_cap: number;
|
||||
recovery_tentative_alpha: number;
|
||||
recovery_tentative_cap: number;
|
||||
};
|
||||
context: { stage: string; resistance: number; effective_openness: number; rapport_credit: number };
|
||||
dimensions: AffectTrace["dimensions"];
|
||||
appraisal: AppraisalTraceItem[];
|
||||
reaction: Array<{ key: Dimension; value: number | null; included: boolean }>;
|
||||
expression: {
|
||||
behavior: ExpressionChoiceTraceItem;
|
||||
gated_behavior: string;
|
||||
gate_reason: "openness_closed" | "openness_guarded" | null;
|
||||
stance: "engage" | "cautious" | "pull_back" | "push_back" | null;
|
||||
display: ExpressionChoiceTraceItem;
|
||||
disclose_ready: { probability: number; confidence: number | null; decision: string };
|
||||
hidden_gap: boolean;
|
||||
};
|
||||
sore_spot_count: number;
|
||||
};
|
||||
|
||||
type AffectDetail = {
|
||||
session_id: string;
|
||||
persona_code: string;
|
||||
current_emotions: Record<Dimension, number | null>;
|
||||
traces: Array<{ turn_id: string; seq: number; created_at: string; trace: AffectTrace }>;
|
||||
traces: Array<{ turn_id: string; seq: number; created_at: string; trace: AffectTrace | AffectTraceV2 }>;
|
||||
total_traces: number;
|
||||
has_more: boolean;
|
||||
};
|
||||
|
|
@ -96,6 +141,105 @@ function trace(seq: number, round: number, anxietyChange: number, sadnessChange:
|
|||
};
|
||||
}
|
||||
|
||||
function traceV2(seq: number, round: number): AffectDetail["traces"][number] {
|
||||
const dimensions = DIMENSIONS.map((key) => ({
|
||||
key,
|
||||
before: 0.4,
|
||||
target: key === "trust" ? null : 0.55,
|
||||
after: 0.5,
|
||||
confidence: key === "trust" ? null : 0.7,
|
||||
probabilities: key === "trust" ? null : [0.02, 0.08, 0.6, 0.25, 0.05],
|
||||
decision: key === "trust" ? "held" : "accepted",
|
||||
}));
|
||||
const reaction = DIMENSIONS.map((key) => ({
|
||||
key,
|
||||
value: key === "trust" ? null : 0.42,
|
||||
included: key !== "trust",
|
||||
}));
|
||||
const trace: AffectTraceV2 = {
|
||||
schema_version: 2,
|
||||
provider: "typesafe",
|
||||
model: "jev-1.13-test",
|
||||
latency_ms: 231,
|
||||
input_tokens: 512,
|
||||
output_tokens: 96,
|
||||
cost_usd: 0.00021,
|
||||
turn_seq: round,
|
||||
policy: {
|
||||
version: "jev-affect-v2",
|
||||
min_confidence: 0.65,
|
||||
tentative_confidence_floor: 0.35,
|
||||
adjacent_probability_threshold: 0.8,
|
||||
worsening_accepted_alpha: 0.35,
|
||||
worsening_accepted_cap: 0.15,
|
||||
worsening_tentative_alpha: 0.15,
|
||||
worsening_tentative_cap: 0.075,
|
||||
recovery_accepted_alpha: 0.2,
|
||||
recovery_accepted_cap: 0.08,
|
||||
recovery_tentative_alpha: 0.08,
|
||||
recovery_tentative_cap: 0.04,
|
||||
},
|
||||
context: { stage: "탐색", resistance: 0.3, effective_openness: 0.55, rapport_credit: 0.3 },
|
||||
dimensions,
|
||||
appraisal: [
|
||||
{ key: "a_understood", kind: "noul", probability: 0.72, confidence: null, decision: "true" },
|
||||
{ key: "a_judged", kind: "noul", probability: 0.3, confidence: null, decision: "false" },
|
||||
{ key: "a_autonomy", kind: "noul", probability: 0.5, confidence: null, decision: "uncertain" },
|
||||
{
|
||||
key: "a_coping",
|
||||
kind: "choice",
|
||||
choice: "stretch",
|
||||
probabilities: { nothing_asked: 0.05, manageable: 0.15, stretch: 0.6, overwhelming: 0.2 },
|
||||
confidence: 0.58,
|
||||
decision: "stretch",
|
||||
},
|
||||
{
|
||||
key: "a_sore_spot",
|
||||
kind: "choice",
|
||||
choice: "spot_2",
|
||||
probabilities: { none: 0.1, spot_1: 0.2, spot_2: 0.65, spot_3: 0.05 },
|
||||
confidence: 0.61,
|
||||
decision: "spot_2",
|
||||
},
|
||||
{
|
||||
key: "a_move",
|
||||
kind: "choice",
|
||||
choice: "mystery_move",
|
||||
probabilities: { mystery_move: 0.9, other: 0.1 },
|
||||
confidence: 0.66,
|
||||
decision: "mystery_move",
|
||||
},
|
||||
],
|
||||
reaction,
|
||||
expression: {
|
||||
behavior: {
|
||||
choice: "hold_core",
|
||||
probabilities: { hold_core: 0.55, disclose_more: 0.2, ask_back: 0.25 },
|
||||
confidence: 0.5,
|
||||
decision: "hold_core",
|
||||
},
|
||||
gated_behavior: "minimal_response",
|
||||
gate_reason: "openness_closed",
|
||||
stance: "cautious",
|
||||
display: {
|
||||
choice: "covered_by_agreement",
|
||||
probabilities: { covered_by_agreement: 0.6, as_felt: 0.4 },
|
||||
confidence: 0.52,
|
||||
decision: "covered_by_agreement",
|
||||
},
|
||||
disclose_ready: { probability: 0.22, confidence: null, decision: "false" },
|
||||
hidden_gap: true,
|
||||
},
|
||||
sore_spot_count: 3,
|
||||
};
|
||||
return {
|
||||
turn_id: `turn-v2-${seq}`,
|
||||
seq,
|
||||
created_at: `2026-09-29T0${Math.min(round, 9)}:00:00Z`,
|
||||
trace,
|
||||
};
|
||||
}
|
||||
|
||||
function detail(sessionId: string, personaCode: string, traces: AffectDetail["traces"][]): AffectDetail {
|
||||
return {
|
||||
session_id: sessionId,
|
||||
|
|
@ -118,6 +262,7 @@ async function mockAffectAdmin(page: Page, options: {
|
|||
olderDetailBySession?: Record<string, AffectDetail>;
|
||||
failDetails?: boolean;
|
||||
detailGate?: (sessionId: string) => Promise<void> | undefined;
|
||||
sessions?: Array<{ session_id: string; persona_code: string; started_at: string; ended: boolean | null; trace_count: number }>;
|
||||
} = {}) {
|
||||
const firstTraces = [
|
||||
trace(2, 1, 0.01, -0.01),
|
||||
|
|
@ -131,6 +276,10 @@ async function mockAffectAdmin(page: Page, options: {
|
|||
"session-alpha": detail("session-alpha", "P1", firstTraces),
|
||||
"session-beta": detail("session-beta", "P2", []),
|
||||
};
|
||||
const sessions = options.sessions ?? [
|
||||
{ session_id: "session-alpha", persona_code: "P1", started_at: "2026-09-23T01:00:00Z", ended: true, trace_count: firstTraces.length },
|
||||
{ session_id: "session-beta", persona_code: "P2", started_at: "2026-09-22T01:00:00Z", ended: false, trace_count: 0 },
|
||||
];
|
||||
await page.route("**/api/**", async (route) => {
|
||||
const request = route.request();
|
||||
const url = new URL(request.url());
|
||||
|
|
@ -140,10 +289,7 @@ async function mockAffectAdmin(page: Page, options: {
|
|||
return;
|
||||
}
|
||||
if (request.method() === "GET" && url.pathname.endsWith("/admin/affect/sessions")) {
|
||||
await fulfill({ runtime: { enabled: true, configured: true, provider: "openai", model: "runtime-model" }, sessions: [
|
||||
{ session_id: "session-alpha", persona_code: "P1", started_at: "2026-09-23T01:00:00Z", ended: true, trace_count: firstTraces.length },
|
||||
{ session_id: "session-beta", persona_code: "P2", started_at: "2026-09-22T01:00:00Z", ended: false, trace_count: 0 },
|
||||
], total: 2, limit: 30, offset: 0 });
|
||||
await fulfill({ runtime: { enabled: true, configured: true, provider: "openai", model: "runtime-model" }, sessions, total: sessions.length, limit: 30, offset: 0 });
|
||||
return;
|
||||
}
|
||||
if (request.method() === "GET" && url.pathname.includes("/admin/affect/sessions/")) {
|
||||
|
|
@ -298,6 +444,108 @@ test.describe("관리자 감정 관측", () => {
|
|||
await page.screenshot({ path: "node_modules/.tmp/admin-affect-focused-320.png", fullPage: true });
|
||||
});
|
||||
|
||||
test("v2 trace의 판정·표현 계획·반응을 한글 라벨로 표시하고 모르는 코드는 알 수 없는 값으로 표시한다", async ({ page }) => {
|
||||
await mockAffectAdmin(page, {
|
||||
sessions: [
|
||||
{ session_id: "session-v2", persona_code: "P9", started_at: "2026-09-29T02:00:00Z", ended: true, trace_count: 1 },
|
||||
],
|
||||
detailBySession: {
|
||||
"session-v2": detail("session-v2", "P9", [traceV2(3, 2)]),
|
||||
},
|
||||
});
|
||||
await page.setViewportSize({ width: 1440, height: 900 });
|
||||
await page.goto("/admin/emotions?session=session-v2");
|
||||
|
||||
await expect(page.getByRole("heading", { name: "상담자 발화 판정" })).toBeVisible();
|
||||
await expect(page.getByText("판정 확률은 정답 확률이 아닙니다.")).toBeVisible();
|
||||
|
||||
const appraisalTable = page.locator(".aac-appraisal-table");
|
||||
const understoodRow = appraisalTable.getByRole("row", { name: /이해받았다고 느낌/ });
|
||||
await expect(understoodRow.locator("td").nth(1)).toHaveText("예");
|
||||
await expect(understoodRow.locator("td").nth(2)).toContainText("예 확률 72%");
|
||||
// a_understood·a_judged·a_autonomy는 noul이며 confidence가 항상 null이다.
|
||||
await expect(understoodRow.locator("td").nth(3)).toHaveText("기록 없음");
|
||||
await expect(appraisalTable.getByRole("row", { name: /평가·비난받는 느낌/ }).locator("td").nth(1)).toHaveText("아니오");
|
||||
await expect(appraisalTable.getByRole("row", { name: /선택을 강요받는 느낌/ }).locator("td").nth(1)).toHaveText("판단 보류");
|
||||
const copingRow = appraisalTable.getByRole("row", { name: /제안의 감당 가능성/ });
|
||||
await expect(copingRow.locator("td").nth(1)).toHaveText("부담스러움");
|
||||
await expect(copingRow.locator("td").nth(2)).toContainText("60%");
|
||||
const soreSpotRow = appraisalTable.getByRole("row", { name: /민감 지점 자극/ });
|
||||
await expect(soreSpotRow.locator("td").nth(1)).toHaveText("민감 지점 2");
|
||||
await expect(soreSpotRow.locator("td").nth(2)).toContainText("해당 없음");
|
||||
await expect(soreSpotRow.locator("td").nth(2)).toContainText("민감 지점 1");
|
||||
await expect(soreSpotRow.locator("td").nth(2)).toContainText("민감 지점 3");
|
||||
const moveRow = appraisalTable.getByRole("row", { name: /상담자 발화 유형/ });
|
||||
await expect(moveRow.locator("td").nth(1)).toHaveText("알 수 없는 값");
|
||||
await expect(moveRow.locator("td").nth(2)).toContainText("알 수 없는 값");
|
||||
await expect(moveRow.locator("td").nth(2)).toContainText("기타");
|
||||
|
||||
await expect(page.getByText("행동(원 판정)")).toBeVisible();
|
||||
const expressionSection = page.locator(".aac-kv").first();
|
||||
await expect(expressionSection).toContainText("핵심은 보류");
|
||||
await expect(expressionSection).toContainText("최소 반응");
|
||||
await expect(expressionSection).toContainText("매우 닫힘으로 조정");
|
||||
await expect(expressionSection).toContainText("조심스러운 거리");
|
||||
await expect(expressionSection).toContainText("수긍으로 덮음");
|
||||
await expect(expressionSection).toContainText("아니오");
|
||||
await expect(expressionSection).toContainText("예");
|
||||
|
||||
await expect(page.getByRole("heading", { name: "이번 턴 반응(감쇠 전)" })).toBeVisible();
|
||||
const reactionTable = page.locator(".aac-v2 table").last();
|
||||
await expect(reactionTable.getByRole("row", { name: /불안/ })).toContainText("포함");
|
||||
await expect(reactionTable.getByRole("row", { name: /신뢰/ })).toContainText("확신도 부족으로 제외");
|
||||
|
||||
await page.getByText("제공자 · 모델 · 정책 · 당시 상태 · 사용량 모두 보기").click();
|
||||
await expect(page.getByText("악화 확정 비율 · 변화량 상한")).toBeVisible();
|
||||
await expect(page.getByText("회복 잠정 비율 · 변화량 상한")).toBeVisible();
|
||||
await expect(page.getByText("반영 비율 · 변화량 상한", { exact: true })).toHaveCount(0);
|
||||
|
||||
await page.locator(".aac-v2").scrollIntoViewIfNeeded();
|
||||
await page.screenshot({ path: "node_modules/.tmp/admin-affect-v2-detail-1440.png", fullPage: true });
|
||||
|
||||
await page.setViewportSize({ width: 390, height: 844 });
|
||||
await expect.poll(() => page.evaluate(() => ({
|
||||
clientWidth: document.documentElement.clientWidth,
|
||||
scrollWidth: document.documentElement.scrollWidth,
|
||||
}))).toEqual({ clientWidth: 390, scrollWidth: 390 });
|
||||
await page.locator(".aac-v2").scrollIntoViewIfNeeded();
|
||||
await page.screenshot({ path: "node_modules/.tmp/admin-affect-v2-detail-390.png", fullPage: true });
|
||||
|
||||
await page.setViewportSize({ width: 320, height: 740 });
|
||||
await expect.poll(() => page.evaluate(() => ({
|
||||
clientWidth: document.documentElement.clientWidth,
|
||||
scrollWidth: document.documentElement.scrollWidth,
|
||||
}))).toEqual({ clientWidth: 320, scrollWidth: 320 });
|
||||
});
|
||||
|
||||
test("v1·v2가 섞인 회기에서 v1 턴은 기존 표시를 유지하고 v2 턴만 새 영역을 보여준다", async ({ page }) => {
|
||||
await mockAffectAdmin(page, {
|
||||
sessions: [
|
||||
{ session_id: "session-mixed", persona_code: "P7", started_at: "2026-09-29T02:00:00Z", ended: true, trace_count: 2 },
|
||||
],
|
||||
detailBySession: {
|
||||
"session-mixed": detail("session-mixed", "P7", [trace(2, 1, 0.01, -0.01), traceV2(6, 3)]),
|
||||
},
|
||||
});
|
||||
await page.goto("/admin/emotions?session=session-mixed&turn=2");
|
||||
|
||||
await expect(page.getByRole("heading", { name: "턴 상세" })).toBeVisible();
|
||||
await expect(page.getByRole("heading", { name: "상담자 발화 판정" })).toHaveCount(0);
|
||||
await page.getByText("제공자 · 모델 · 정책 · 당시 상태 · 사용량 모두 보기").click();
|
||||
await expect(page.getByText("반영 비율 · 변화량 상한", { exact: true })).toBeVisible();
|
||||
await expect(page.getByText("악화 확정 비율 · 변화량 상한")).toHaveCount(0);
|
||||
|
||||
const firstChart = page.locator(".aac-chart").first();
|
||||
await expect(firstChart.locator("path")).toHaveAttribute("d", /M/);
|
||||
await expect(firstChart.locator("circle")).toHaveCount(2);
|
||||
|
||||
await page.getByLabel("상세 턴 선택").selectOption("6");
|
||||
await expect(page).toHaveURL(/turn=6/);
|
||||
await expect(page.getByRole("heading", { name: "상담자 발화 판정" })).toBeVisible();
|
||||
await expect(page.getByText("악화 확정 비율 · 변화량 상한")).toBeVisible();
|
||||
await expect(page.getByText("반영 비율 · 변화량 상한", { exact: true })).toHaveCount(0);
|
||||
});
|
||||
|
||||
test("관계 계산은 양음 상관, 변화 없음, 표본 부족을 정직하게 표시한다", () => {
|
||||
const observations: ChangeObservation[] = [1, 2, 3, 4, 5].map((seq) => ({
|
||||
seq,
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue