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,
|
||||
|
|
|
|||
594
apps/web/e2e/inner-reaction.spec.ts
Normal file
594
apps/web/e2e/inner-reaction.spec.ts
Normal file
|
|
@ -0,0 +1,594 @@
|
|||
/* =====================================================================
|
||||
inner-reaction.spec.ts — P3a "내담자 속마음" UI(§8.2·§9) 검증 스펙.
|
||||
|
||||
full-sweep-session.spec.ts / session-review-fixture.ts / admin-affect.spec.ts의
|
||||
API route mock 패턴을 그대로 따른다. 실제 AI 엔진 턴 생성·실 로그인 없이
|
||||
전부 fixture로 검증한다(auth/me 도 직접 mock — admin-affect.spec.ts 패턴).
|
||||
===================================================================== */
|
||||
|
||||
import { expect, test, type Page } from "@playwright/test";
|
||||
import type { ClientInnerReactionV1 } from "../src/lib/api";
|
||||
import { filledReviewResponse } from "./session-review-fixture";
|
||||
import { expectNoHorizontalOverflow } from "./support";
|
||||
|
||||
const fixtureSessionId = "66666666-6666-4666-8666-666666666666";
|
||||
const learnerText = "그 이야기를 조금 더 들려주실 수 있을까요?";
|
||||
const clientReply = "음... 그게, 괜찮아요.";
|
||||
|
||||
const innerReactionFixture: ClientInnerReactionV1 = {
|
||||
schema_version: 1,
|
||||
turn_seq: 1,
|
||||
experienced: ["평가받거나 탓을 듣는 것처럼 느꼈다"],
|
||||
feelings: [{ intensity: "뚜렷한", label: "수치심" }],
|
||||
stance: { code: "pull_back", label: "한발 물러났다" },
|
||||
display: {
|
||||
code: "covered_by_agreement",
|
||||
label: "속마음과 달리 겉으로는 수긍하는 말로 덮었다",
|
||||
},
|
||||
hidden_gap: true,
|
||||
};
|
||||
|
||||
interface SessionFixtureOptions {
|
||||
learnerFeedbackEnabled?: boolean;
|
||||
innerReaction?: ClientInnerReactionV1 | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* 이 스펙이 명시적으로 mock 하지 않은 `**\/api/**` 요청을 전부 404로 막는다.
|
||||
* 실 백엔드가 떠 있을 때(PLAYWRIGHT_REUSE_EXISTING_SERVER=1) mock 안 한 보조 API가
|
||||
* 실 서버의 401을 그대로 받으면 AUTH_EXPIRED_EVENT로 이어져 화면이 로그인으로
|
||||
* 튕긴다 — admin-affect.spec.ts 패턴대로 catch-all을 가장 먼저 등록해 두면,
|
||||
* Playwright는 나중에 등록한 route를 먼저 매칭하므로 이후 등록하는 구체 mock들이
|
||||
* 이 catch-all보다 우선한다.
|
||||
*/
|
||||
async function routeUnmockedApiCatchAll(page: Page) {
|
||||
await page.route("**/api/**", async (route) => {
|
||||
const request = route.request();
|
||||
await route.fulfill({
|
||||
status: 404,
|
||||
contentType: "application/json",
|
||||
body: JSON.stringify({
|
||||
detail: `unmocked API in inner-reaction e2e: ${request.method()} ${new URL(request.url()).pathname}`,
|
||||
}),
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
async function routeInnerReactionSessionApi(page: Page, options: SessionFixtureOptions = {}) {
|
||||
const learnerFeedbackEnabled = options.learnerFeedbackEnabled ?? true;
|
||||
const innerReaction =
|
||||
options.innerReaction === undefined ? innerReactionFixture : options.innerReaction;
|
||||
|
||||
await routeUnmockedApiCatchAll(page);
|
||||
|
||||
await page.route("**/api/auth/me", async (route) => {
|
||||
await route.fulfill({
|
||||
status: 200,
|
||||
contentType: "application/json",
|
||||
body: JSON.stringify({
|
||||
user_id: "00000000-0000-0000-0000-0000000ir001",
|
||||
email: "inner-reaction@hs.ac.kr",
|
||||
display_name: "IR Learner",
|
||||
role: "learner",
|
||||
admin_access: false,
|
||||
super_admin: false,
|
||||
account_status: "approved",
|
||||
approval_required: false,
|
||||
cohort_ids: [],
|
||||
consent_at: Math.floor(Date.now() / 1000),
|
||||
onboarding_completed_at: Math.floor(Date.now() / 1000),
|
||||
nickname: "IR Learner",
|
||||
self_introduction: "속마음 UI 검증용 학습자입니다.",
|
||||
avatar_url: "",
|
||||
}),
|
||||
});
|
||||
});
|
||||
|
||||
await page.route("**/api/voice/health", async (route) => {
|
||||
await route.fulfill({
|
||||
status: 200,
|
||||
contentType: "application/json",
|
||||
body: JSON.stringify({ available: true, reason: null }),
|
||||
});
|
||||
});
|
||||
|
||||
await page.route("**/api/personas", async (route) => {
|
||||
await route.fulfill({
|
||||
status: 200,
|
||||
contentType: "application/json",
|
||||
body: JSON.stringify([
|
||||
{
|
||||
code: "P1",
|
||||
display_name: "민서(청소년 우울)",
|
||||
difficulty: "hard",
|
||||
theory_target: ["humanistic"],
|
||||
demographics: { age_band: "10대" },
|
||||
presenting_summary: "속마음 UI 검증용 상담 연습",
|
||||
voice_preset: "soft-young-fem",
|
||||
source: "database",
|
||||
degraded: false,
|
||||
},
|
||||
]),
|
||||
});
|
||||
});
|
||||
|
||||
await page.route("**/api/sessions", async (route) => {
|
||||
if (route.request().method() !== "POST") {
|
||||
await route.fallback();
|
||||
return;
|
||||
}
|
||||
await route.fulfill({
|
||||
status: 201,
|
||||
contentType: "application/json",
|
||||
body: JSON.stringify({
|
||||
session_id: fixtureSessionId,
|
||||
case_id: "ir-case-001",
|
||||
session_no: 1,
|
||||
stage: "라포",
|
||||
effective_openness: 0.21,
|
||||
recall_summary: null,
|
||||
degraded: false,
|
||||
goal_stages: ["라포", "탐색"],
|
||||
duration_limit_seconds: 3600,
|
||||
warning_before_end_seconds: 600,
|
||||
learner_feedback_enabled: learnerFeedbackEnabled,
|
||||
}),
|
||||
});
|
||||
});
|
||||
|
||||
await page.route(`**/api/sessions/${fixtureSessionId}`, async (route) => {
|
||||
await route.fulfill({
|
||||
status: 200,
|
||||
contentType: "application/json",
|
||||
body: JSON.stringify({
|
||||
session_id: fixtureSessionId,
|
||||
case_id: "ir-case-001",
|
||||
persona_code: "P1",
|
||||
persona_name: "민서",
|
||||
session_no: 1,
|
||||
status: "active",
|
||||
stage: "라포",
|
||||
theory_mode: "humanistic",
|
||||
effective_openness: 0.21,
|
||||
started_at: new Date().toISOString(),
|
||||
ended_at: null,
|
||||
review_ready: false,
|
||||
turns: [],
|
||||
goal_stages: ["라포", "탐색"],
|
||||
progress: null,
|
||||
duration_limit_seconds: 3600,
|
||||
warning_before_end_seconds: 600,
|
||||
learner_feedback_enabled: learnerFeedbackEnabled,
|
||||
}),
|
||||
});
|
||||
});
|
||||
|
||||
await page.route(`**/api/sessions/${fixtureSessionId}/alliance-pulses`, async (route) => {
|
||||
await route.fulfill({
|
||||
status: 200,
|
||||
contentType: "application/json",
|
||||
body: JSON.stringify({
|
||||
items: [
|
||||
{
|
||||
pulse_id: "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa",
|
||||
checkpoint: "pre",
|
||||
status: "ready",
|
||||
learner_locked_at: new Date().toISOString(),
|
||||
revealed_at: new Date().toISOString(),
|
||||
error_code: null,
|
||||
self_scores: { goal: 0.5, task: 0.5, bond: 0.5 },
|
||||
measurements: [],
|
||||
},
|
||||
],
|
||||
}),
|
||||
});
|
||||
});
|
||||
|
||||
await page.route(`**/api/sessions/${fixtureSessionId}/stream`, async (route) => {
|
||||
await route.fulfill({
|
||||
status: 200,
|
||||
contentType: "text/event-stream",
|
||||
body: [
|
||||
"event: token",
|
||||
`data: ${clientReply}`,
|
||||
"",
|
||||
"event: done",
|
||||
// turn_seq 를 싣지 않아 텍스트 턴 뒤 TTS(speakClientTurn) 경로를 결정론적으로 생략한다
|
||||
// (full-sweep-session.spec.ts 와 같은 관례).
|
||||
`data: ${JSON.stringify({
|
||||
session_id: fixtureSessionId,
|
||||
stage: "탐색",
|
||||
effective_openness: 0.42,
|
||||
safety_flagged: false,
|
||||
inner_reaction: innerReaction,
|
||||
})}`,
|
||||
"",
|
||||
].join("\n"),
|
||||
});
|
||||
});
|
||||
|
||||
await page.route(`**/api/sessions/${fixtureSessionId}/live-coach`, async (route) => {
|
||||
if (route.request().method() === "GET") {
|
||||
await route.fulfill({
|
||||
status: 200,
|
||||
contentType: "application/json",
|
||||
body: JSON.stringify({
|
||||
source: "database",
|
||||
quota: { remaining: 3, max: 3 },
|
||||
credit_events: [],
|
||||
events: [],
|
||||
}),
|
||||
});
|
||||
return;
|
||||
}
|
||||
await route.fulfill({
|
||||
status: 200,
|
||||
contentType: "application/json",
|
||||
body: JSON.stringify({
|
||||
status: "ready",
|
||||
tone: "pos",
|
||||
focus: "reflection",
|
||||
title: "감정 반영이 선명합니다",
|
||||
message: "학습자가 내담자의 감정을 평가하지 않고 먼저 되짚었습니다.",
|
||||
next_utterance: "그 마음이 가장 크게 올라온 장면을 조금 더 들려줄 수 있을까요?",
|
||||
rationale: "방금 발화는 정서 반영과 개방 질문을 함께 포함합니다.",
|
||||
sources: [],
|
||||
safety_note: null,
|
||||
latency_ms: 12,
|
||||
persistence_source: "database",
|
||||
quota: { remaining: 2, max: 3 },
|
||||
credit_events: [],
|
||||
}),
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
async function startFixtureSession(page: Page) {
|
||||
await page.goto("/learn/session/P1");
|
||||
const startButton = page.getByRole("button", { name: "회기 시작" });
|
||||
await expect(startButton).toBeEnabled();
|
||||
await startButton.click();
|
||||
await expect(page.locator(".sx-page.sx-page--active")).toBeVisible();
|
||||
}
|
||||
|
||||
async function sendLearnerTurn(page: Page) {
|
||||
await page.getByLabel("학습자 발화 입력").fill(learnerText);
|
||||
await page.getByRole("button", { name: "보내기" }).click();
|
||||
}
|
||||
|
||||
const REVIEW_SESSION_ID = "00000000-0000-4000-8000-0000000009ir";
|
||||
|
||||
function reviewResponseWithInnerReaction(sessionId: string) {
|
||||
const response = filledReviewResponse(sessionId);
|
||||
response.turns = response.turns.map((turn) =>
|
||||
turn.id === "t1" ? { ...turn, innerReaction: innerReactionFixture } : turn,
|
||||
);
|
||||
return response;
|
||||
}
|
||||
|
||||
async function routeInnerReactionReviewAuth(page: Page, role: "learner" | "teacher") {
|
||||
await routeUnmockedApiCatchAll(page);
|
||||
|
||||
await page.route("**/api/auth/me", async (route) => {
|
||||
await route.fulfill({
|
||||
status: 200,
|
||||
contentType: "application/json",
|
||||
body: JSON.stringify({
|
||||
user_id:
|
||||
role === "teacher"
|
||||
? "00000000-0000-0000-0000-0000000ir901"
|
||||
: "00000000-0000-0000-0000-0000000ir101",
|
||||
email: role === "teacher" ? "ir.teacher@hs.ac.kr" : "ir.learner@hs.ac.kr",
|
||||
role,
|
||||
display_name: role === "teacher" ? "IR Teacher" : "IR Learner",
|
||||
admin_access: false,
|
||||
super_admin: false,
|
||||
account_status: "approved",
|
||||
approval_required: false,
|
||||
cohort_ids: [],
|
||||
consent_at: 1782820000,
|
||||
onboarding_completed_at: 1782820001,
|
||||
nickname: role === "teacher" ? "IR Teacher" : "IR Learner",
|
||||
self_introduction: "",
|
||||
avatar_url: "",
|
||||
}),
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
async function routeInnerReactionReview(page: Page) {
|
||||
await page.route(`**/api/sessions/${REVIEW_SESSION_ID}/review`, async (route) => {
|
||||
await route.fulfill({
|
||||
status: 200,
|
||||
contentType: "application/json",
|
||||
body: JSON.stringify(reviewResponseWithInnerReaction(REVIEW_SESSION_ID)),
|
||||
});
|
||||
});
|
||||
await page.route(`**/api/sessions/${REVIEW_SESSION_ID}/alliance-pulses`, async (route) => {
|
||||
await route.fulfill({
|
||||
status: 200,
|
||||
contentType: "application/json",
|
||||
body: JSON.stringify({ items: [] }),
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
test.describe("inner reaction — session", () => {
|
||||
test("shows the client inner reaction mark, expands the card, and flags the hidden gap", async ({
|
||||
page,
|
||||
}, testInfo) => {
|
||||
await routeInnerReactionSessionApi(page);
|
||||
await startFixtureSession(page);
|
||||
await sendLearnerTurn(page);
|
||||
|
||||
const mark = page.getByRole("button", { name: "이 발화의 내담자 속마음 보기" });
|
||||
await expect(mark).toBeVisible();
|
||||
await expect(mark).toHaveClass(/is-warn/);
|
||||
await expect(mark).toHaveAttribute("aria-expanded", "false");
|
||||
|
||||
await mark.click();
|
||||
await expect(mark).toHaveAttribute("aria-expanded", "true");
|
||||
|
||||
const card = page.locator(".sx-utt__inner-card");
|
||||
await expect(card).toBeVisible();
|
||||
await expect(card).toContainText("내담자 속마음");
|
||||
await expect(card).toContainText(
|
||||
"가상 내담자의 시뮬레이션 반응이며 평가 점수가 아닙니다",
|
||||
);
|
||||
await expect(card).toContainText("겉과 속이 달랐던 순간");
|
||||
await expect(card).toContainText("평가받거나 탓을 듣는 것처럼 느꼈다");
|
||||
await expect(card).toContainText("뚜렷한 수치심");
|
||||
await expect(card).toContainText(
|
||||
"속마음과 달리 겉으로는 수긍하는 말로 덮었다",
|
||||
);
|
||||
await expect(card).toContainText("한발 물러났다");
|
||||
|
||||
// 숫자·확률·영문 코드는 화면에 남지 않는다.
|
||||
const cardText = await card.innerText();
|
||||
expect(/\d/.test(cardText)).toBe(false);
|
||||
expect(cardText).not.toContain("pull_back");
|
||||
expect(cardText).not.toContain("covered_by_agreement");
|
||||
|
||||
// 스크린샷은 두 project가 같은 파일 경로를 다투지 않도록 데스크톱에서만 남긴다.
|
||||
if (testInfo.project.name === "chromium-desktop") {
|
||||
// 우측 라이브 코칭 패널의 최신 속마음 카드는 패널 내부 스크롤(overflow-y: auto)로
|
||||
// 열리므로, 캡처 전 마지막 줄까지 패널 스크롤을 맞춰 잘림 없이 보이게 한다.
|
||||
await page
|
||||
.locator(".sx-signal__inner-reaction .vg-ir__row", { hasText: "태도" })
|
||||
.scrollIntoViewIfNeeded();
|
||||
await page.screenshot({
|
||||
path: "node_modules/.tmp/inner-reaction-session-1440-expanded.png",
|
||||
fullPage: true,
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// 라이브 코칭 우측 패널은 모바일(≤880px)에서 session.css가 의도적으로 숨긴다
|
||||
// (full-sweep-session.spec.ts "session-live-signal"과 같은 기존 관례).
|
||||
test("shows the latest client inner reaction at the top of the live coaching panel", async ({
|
||||
page,
|
||||
}, testInfo) => {
|
||||
test.skip(
|
||||
testInfo.project.name.includes("mobile"),
|
||||
"라이브 코칭 우측 패널은 모바일에서 의도적으로 숨김",
|
||||
);
|
||||
await routeInnerReactionSessionApi(page);
|
||||
await startFixtureSession(page);
|
||||
await sendLearnerTurn(page);
|
||||
|
||||
const panelCard = page.locator(".sx-signal__inner-reaction");
|
||||
await expect(panelCard).toBeVisible();
|
||||
await expect(panelCard).toContainText("한발 물러났다");
|
||||
await expect(panelCard).toContainText("겉과 속이 달랐던 순간");
|
||||
|
||||
// 패널 높이보다 카드가 길어 "태도" 줄이 첫 화면에서 잘려도 패널 내부
|
||||
// 스크롤(overflow-y: auto)로 닿을 수 있어야 한다.
|
||||
const stanceRow = panelCard.locator(".vg-ir__row", { hasText: "태도" });
|
||||
await stanceRow.scrollIntoViewIfNeeded();
|
||||
await expect(stanceRow).toBeVisible();
|
||||
await expect(stanceRow).toContainText("한발 물러났다");
|
||||
});
|
||||
|
||||
test("keeps the reveal switch track wider than tall and the knob no taller than the track", async ({
|
||||
page,
|
||||
}) => {
|
||||
await routeInnerReactionSessionApi(page);
|
||||
await startFixtureSession(page);
|
||||
await sendLearnerTurn(page);
|
||||
|
||||
const toggle = page.getByRole("switch", { name: "속마음 보기" });
|
||||
await expect(toggle).toBeVisible();
|
||||
|
||||
const trackBox = await page.locator(".sx-inner-toggle__track").boundingBox();
|
||||
const knobBox = await page.locator(".sx-inner-toggle__knob").boundingBox();
|
||||
expect(trackBox).not.toBeNull();
|
||||
expect(knobBox).not.toBeNull();
|
||||
expect(trackBox!.width).toBeGreaterThan(trackBox!.height);
|
||||
expect(knobBox!.height).toBeLessThanOrEqual(trackBox!.height);
|
||||
});
|
||||
|
||||
test("hides inner reaction display when the reveal switch is off and remembers the choice after reload", async ({
|
||||
page,
|
||||
}, testInfo) => {
|
||||
const isMobile = testInfo.project.name.includes("mobile");
|
||||
await routeInnerReactionSessionApi(page);
|
||||
await startFixtureSession(page);
|
||||
await sendLearnerTurn(page);
|
||||
|
||||
const toggle = page.getByRole("switch", { name: "속마음 보기" });
|
||||
await expect(toggle).toHaveAttribute("aria-checked", "true");
|
||||
await expect(page.locator(".sx-utt__inner-mark")).toBeVisible();
|
||||
if (!isMobile) {
|
||||
await expect(page.locator(".sx-signal__inner-reaction")).toBeVisible();
|
||||
}
|
||||
|
||||
await toggle.click();
|
||||
await expect(toggle).toHaveAttribute("aria-checked", "false");
|
||||
await expect(page.locator(".sx-utt__inner-mark")).toHaveCount(0);
|
||||
if (!isMobile) {
|
||||
await expect(page.locator(".sx-signal__inner-reaction")).toHaveCount(0);
|
||||
}
|
||||
|
||||
await page.reload();
|
||||
await expect(page.locator(".sx-page.sx-page--active")).toBeVisible();
|
||||
const toggleAfterReload = page.getByRole("switch", { name: "속마음 보기" });
|
||||
await expect(toggleAfterReload).toHaveAttribute("aria-checked", "false");
|
||||
});
|
||||
|
||||
test("hides inner reaction in immersive feedback mode even when data exists", async ({
|
||||
page,
|
||||
}, testInfo) => {
|
||||
const isMobile = testInfo.project.name.includes("mobile");
|
||||
await routeInnerReactionSessionApi(page);
|
||||
await startFixtureSession(page);
|
||||
await sendLearnerTurn(page);
|
||||
|
||||
await expect(page.locator(".sx-utt__inner-mark")).toBeVisible();
|
||||
if (!isMobile) {
|
||||
await expect(page.locator(".sx-signal__inner-reaction")).toBeVisible();
|
||||
}
|
||||
|
||||
await page.getByRole("button", { name: "몰입" }).click();
|
||||
await expect(page.locator(".sx-utt__inner-mark")).toHaveCount(0);
|
||||
if (!isMobile) {
|
||||
await expect(page.locator(".sx-signal__inner-reaction")).toHaveCount(0);
|
||||
}
|
||||
});
|
||||
|
||||
test("hides the reveal switch when learner feedback is disabled for the session", async ({
|
||||
page,
|
||||
}) => {
|
||||
await routeInnerReactionSessionApi(page, {
|
||||
learnerFeedbackEnabled: false,
|
||||
innerReaction: null,
|
||||
});
|
||||
await startFixtureSession(page);
|
||||
|
||||
await expect(page.getByRole("switch", { name: "속마음 보기" })).toHaveCount(0);
|
||||
});
|
||||
|
||||
test("has no horizontal overflow at 390px and 320px with the inner reaction card expanded", async ({
|
||||
page,
|
||||
}, testInfo) => {
|
||||
await routeInnerReactionSessionApi(page);
|
||||
await startFixtureSession(page);
|
||||
await sendLearnerTurn(page);
|
||||
await page.getByRole("button", { name: "이 발화의 내담자 속마음 보기" }).click();
|
||||
await expect(page.locator(".sx-utt__inner-card")).toBeVisible();
|
||||
|
||||
await page.setViewportSize({ width: 390, height: 844 });
|
||||
await page.evaluate(() => new Promise(requestAnimationFrame));
|
||||
await expectNoHorizontalOverflow(page);
|
||||
if (testInfo.project.name === "chromium-desktop") {
|
||||
await page.screenshot({
|
||||
path: "node_modules/.tmp/inner-reaction-session-390-expanded.png",
|
||||
fullPage: true,
|
||||
});
|
||||
}
|
||||
|
||||
await page.setViewportSize({ width: 320, height: 568 });
|
||||
await page.evaluate(() => new Promise(requestAnimationFrame));
|
||||
await expectNoHorizontalOverflow(page);
|
||||
});
|
||||
});
|
||||
|
||||
test.describe("inner reaction — review", () => {
|
||||
test("expands the client inner reaction fold on the learner review and flags the hidden gap", async ({
|
||||
page,
|
||||
}, testInfo) => {
|
||||
await routeInnerReactionReviewAuth(page, "learner");
|
||||
await routeInnerReactionReview(page);
|
||||
|
||||
await page.goto(`/learn/session/${REVIEW_SESSION_ID}/review`);
|
||||
await expect(page.getByText("세션 트랜스크립트")).toBeVisible();
|
||||
|
||||
const fold = page.locator(".sr-inner-reaction");
|
||||
await expect(fold).toHaveCount(1);
|
||||
await expect(fold).toContainText("내담자 속마음");
|
||||
await expect(fold).toContainText("겉과 속이 달랐던 순간");
|
||||
await expect(fold).toContainText("한발 물러났다");
|
||||
await expect(page.locator(".sr-inner-reaction__card")).toHaveCount(0);
|
||||
|
||||
await page.locator(".sr-inner-reaction__toggle").click();
|
||||
const card = page.locator(".sr-inner-reaction__card");
|
||||
await expect(card).toBeVisible();
|
||||
await expect(card).toContainText("평가받거나 탓을 듣는 것처럼 느꼈다");
|
||||
await expect(card).toContainText("뚜렷한 수치심");
|
||||
await expect(card).toContainText(
|
||||
"속마음과 달리 겉으로는 수긍하는 말로 덮었다",
|
||||
);
|
||||
|
||||
// 펼친 카드는 접힘 요약 줄과 같은 "내담자 속마음" 머리줄을 다시 그리지 않는다.
|
||||
await expect(fold.getByText("내담자 속마음", { exact: true })).toHaveCount(1);
|
||||
|
||||
if (testInfo.project.name === "chromium-desktop") {
|
||||
await page.screenshot({
|
||||
path: "node_modules/.tmp/inner-reaction-review-1440-expanded.png",
|
||||
fullPage: true,
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
test("does not render the fold when a turn has no inner reaction", async ({ page }) => {
|
||||
await routeInnerReactionReviewAuth(page, "learner");
|
||||
await page.route(`**/api/sessions/${REVIEW_SESSION_ID}/review`, async (route) => {
|
||||
await route.fulfill({
|
||||
status: 200,
|
||||
contentType: "application/json",
|
||||
body: JSON.stringify(filledReviewResponse(REVIEW_SESSION_ID)),
|
||||
});
|
||||
});
|
||||
await page.route(`**/api/sessions/${REVIEW_SESSION_ID}/alliance-pulses`, async (route) => {
|
||||
await route.fulfill({
|
||||
status: 200,
|
||||
contentType: "application/json",
|
||||
body: JSON.stringify({ items: [] }),
|
||||
});
|
||||
});
|
||||
|
||||
await page.goto(`/learn/session/${REVIEW_SESSION_ID}/review`);
|
||||
await expect(page.getByText("세션 트랜스크립트")).toBeVisible();
|
||||
await expect(page.locator(".sr-inner-reaction")).toHaveCount(0);
|
||||
});
|
||||
|
||||
test("also shows the inner reaction fold on the /teach/ supervisor review route", async ({
|
||||
page,
|
||||
}) => {
|
||||
await routeInnerReactionReviewAuth(page, "teacher");
|
||||
await routeInnerReactionReview(page);
|
||||
|
||||
await page.goto(`/teach/session/${REVIEW_SESSION_ID}/review`);
|
||||
await expect(page.getByText("세션 트랜스크립트")).toBeVisible();
|
||||
await expect(page.locator(".sr-inner-reaction")).toBeVisible();
|
||||
await expect(page.locator(".sr-inner-reaction")).toContainText("내담자 속마음");
|
||||
});
|
||||
|
||||
test("has no horizontal overflow at 390px and 320px with the inner reaction fold expanded", async ({
|
||||
page,
|
||||
}, testInfo) => {
|
||||
await routeInnerReactionReviewAuth(page, "learner");
|
||||
await routeInnerReactionReview(page);
|
||||
|
||||
await page.goto(`/learn/session/${REVIEW_SESSION_ID}/review`);
|
||||
await expect(page.getByText("세션 트랜스크립트")).toBeVisible();
|
||||
await page.locator(".sr-inner-reaction__toggle").click();
|
||||
await expect(page.locator(".sr-inner-reaction__card")).toBeVisible();
|
||||
|
||||
await page.setViewportSize({ width: 390, height: 844 });
|
||||
await page.evaluate(() => new Promise(requestAnimationFrame));
|
||||
await expectNoHorizontalOverflow(page);
|
||||
// 리사이즈 전 스크롤 오프셋이 새 좁은 레이아웃에서는 다른 내용을 가리킬 수 있어
|
||||
// 스크린샷 전에 카드를 다시 뷰포트로 스크롤한다.
|
||||
await page.locator(".sr-inner-reaction__card").scrollIntoViewIfNeeded();
|
||||
if (testInfo.project.name === "chromium-desktop") {
|
||||
await page.screenshot({
|
||||
path: "node_modules/.tmp/inner-reaction-review-390-expanded.png",
|
||||
fullPage: true,
|
||||
});
|
||||
}
|
||||
|
||||
await page.setViewportSize({ width: 320, height: 568 });
|
||||
await page.evaluate(() => new Promise(requestAnimationFrame));
|
||||
await expectNoHorizontalOverflow(page);
|
||||
});
|
||||
});
|
||||
81
apps/web/src/components/inner-reaction/InnerReactionCard.tsx
Normal file
81
apps/web/src/components/inner-reaction/InnerReactionCard.tsx
Normal file
|
|
@ -0,0 +1,81 @@
|
|||
/* =====================================================================
|
||||
InnerReactionCard — 내담자 속마음 요약(§8.2) 공유 렌더러.
|
||||
회기 화면(Session)·리뷰 화면(SessionReview) 양쪽에서 같은 형태를 그린다.
|
||||
서버가 만든 고정 문구만 표시한다. 숫자·확률·영문 코드는 절대 렌더하지 않는다.
|
||||
===================================================================== */
|
||||
|
||||
import { Badge, Kicker } from "../ui";
|
||||
import type { ClientInnerReactionV1 } from "../../lib/api";
|
||||
import "./inner-reaction.css";
|
||||
|
||||
export interface InnerReactionCardProps {
|
||||
reaction: ClientInnerReactionV1;
|
||||
className?: string;
|
||||
/** 이미 상위(접힘 요약 줄 등)에서 같은 머리줄을 보여줄 때 중복 표시를 막는다. */
|
||||
hideHeader?: boolean;
|
||||
}
|
||||
|
||||
export function InnerReactionCard({ reaction, className, hideHeader }: InnerReactionCardProps) {
|
||||
const experienced = reaction.experienced ?? [];
|
||||
const feelings = reaction.feelings ?? [];
|
||||
const display = reaction.display ?? null;
|
||||
const stance = reaction.stance ?? null;
|
||||
|
||||
if (experienced.length === 0 && feelings.length === 0 && !display && !stance) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const cls = ["vg-ir", className ?? ""].filter(Boolean).join(" ");
|
||||
|
||||
return (
|
||||
<div className={cls}>
|
||||
{hideHeader ? null : (
|
||||
<div className="vg-ir__head">
|
||||
<Kicker dot={false}>내담자 속마음</Kicker>
|
||||
{reaction.hidden_gap ? <Badge tone="warn">겉과 속이 달랐던 순간</Badge> : null}
|
||||
</div>
|
||||
)}
|
||||
<p className="vg-ir__disclaimer">
|
||||
가상 내담자의 시뮬레이션 반응이며 평가 점수가 아닙니다
|
||||
</p>
|
||||
|
||||
{experienced.length > 0 ? (
|
||||
<div className="vg-ir__row">
|
||||
<span className="vg-ir__row-label">받아들인 방식</span>
|
||||
<ul className="vg-ir__list">
|
||||
{experienced.map((item, i) => (
|
||||
<li key={i}>{item}</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{feelings.length > 0 ? (
|
||||
<div className="vg-ir__row">
|
||||
<span className="vg-ir__row-label">속에서 올라온 감정</span>
|
||||
<div className="vg-ir__chips">
|
||||
{feelings.map((feeling, i) => (
|
||||
<span key={i} className="vg-ir__chip">
|
||||
{feeling.intensity} {feeling.label}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{display ? (
|
||||
<div className="vg-ir__row">
|
||||
<span className="vg-ir__row-label">겉으로 드러낸 방식</span>
|
||||
<span className="vg-ir__value">{display.label}</span>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{stance ? (
|
||||
<div className="vg-ir__row">
|
||||
<span className="vg-ir__row-label">태도</span>
|
||||
<span className="vg-ir__value">{stance.label}</span>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
74
apps/web/src/components/inner-reaction/inner-reaction.css
Normal file
74
apps/web/src/components/inner-reaction/inner-reaction.css
Normal file
|
|
@ -0,0 +1,74 @@
|
|||
/* =====================================================================
|
||||
InnerReactionCard — 공유 스타일. 토큰만 참조(tokens.css).
|
||||
철칙: border-left 강조선 없음, 새 raw color 없음, 숫자 강조 없음.
|
||||
===================================================================== */
|
||||
|
||||
.vg-ir {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--sp-2);
|
||||
padding: var(--sp-3);
|
||||
border-radius: var(--radius);
|
||||
background: var(--paper-2);
|
||||
}
|
||||
|
||||
.vg-ir__head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
flex-wrap: wrap;
|
||||
gap: var(--sp-2);
|
||||
}
|
||||
|
||||
.vg-ir__disclaimer {
|
||||
margin: 0;
|
||||
font-size: var(--fs-xs);
|
||||
color: var(--text-muted);
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.vg-ir__row {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.vg-ir__row-label {
|
||||
font-size: var(--fs-xs);
|
||||
font-weight: 600;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.vg-ir__list {
|
||||
margin: 0;
|
||||
padding-left: 1.1em;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
font-size: var(--fs-sm);
|
||||
color: var(--text-strong);
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.vg-ir__chips {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.vg-ir__chip {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
padding: 3px 10px;
|
||||
border-radius: var(--radius-pill);
|
||||
background: var(--accent-tint);
|
||||
color: var(--text-accent);
|
||||
font-size: var(--fs-xs);
|
||||
font-weight: 600;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.vg-ir__value {
|
||||
font-size: var(--fs-sm);
|
||||
color: var(--text-strong);
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
|
@ -2881,7 +2881,8 @@ export interface components {
|
|||
created_at: string;
|
||||
/** Seq */
|
||||
seq: number;
|
||||
trace: components["schemas"]["ClientAffectTraceV1"];
|
||||
/** Trace */
|
||||
trace: components["schemas"]["ClientAffectTraceV1"] | components["schemas"]["ClientAffectTraceV2"];
|
||||
/** Turn Id */
|
||||
turn_id: string;
|
||||
};
|
||||
|
|
@ -3987,6 +3988,31 @@ export interface components {
|
|||
/** Task */
|
||||
task: number;
|
||||
};
|
||||
/**
|
||||
* AppraisalQuestionTraceV1
|
||||
* @description A층 질문 하나의 판정 원자료(§8.1). 보내지 않은 질문은 항목 자체가 없다.
|
||||
*/
|
||||
AppraisalQuestionTraceV1: {
|
||||
/** Choice */
|
||||
choice?: string | null;
|
||||
/** Confidence */
|
||||
confidence?: number | null;
|
||||
/** Decision */
|
||||
decision: string;
|
||||
/** Key */
|
||||
key: string;
|
||||
/**
|
||||
* Kind
|
||||
* @enum {string}
|
||||
*/
|
||||
kind: "noul" | "choice";
|
||||
/** Probabilities */
|
||||
probabilities?: {
|
||||
[key: string]: number;
|
||||
} | null;
|
||||
/** Probability */
|
||||
probability?: number | null;
|
||||
};
|
||||
/** ApprovedCatalogConsumerEntry */
|
||||
ApprovedCatalogConsumerEntry: {
|
||||
/**
|
||||
|
|
@ -4651,6 +4677,40 @@ export interface components {
|
|||
*/
|
||||
version: "jev-affect-v1";
|
||||
};
|
||||
/**
|
||||
* ClientAffectPolicyV2
|
||||
* @description jev-affect-v2 비대칭 기분 전이 계수(공학적 기본값, §6.3).
|
||||
*/
|
||||
ClientAffectPolicyV2: {
|
||||
/** Adjacent Probability Threshold */
|
||||
adjacent_probability_threshold: number;
|
||||
/** Min Confidence */
|
||||
min_confidence: number;
|
||||
/** Recovery Accepted Alpha */
|
||||
recovery_accepted_alpha: number;
|
||||
/** Recovery Accepted Cap */
|
||||
recovery_accepted_cap: number;
|
||||
/** Recovery Tentative Alpha */
|
||||
recovery_tentative_alpha: number;
|
||||
/** Recovery Tentative Cap */
|
||||
recovery_tentative_cap: number;
|
||||
/** Tentative Confidence Floor */
|
||||
tentative_confidence_floor: number;
|
||||
/**
|
||||
* Version
|
||||
* @constant
|
||||
* @enum {string}
|
||||
*/
|
||||
version: "jev-affect-v2";
|
||||
/** Worsening Accepted Alpha */
|
||||
worsening_accepted_alpha: number;
|
||||
/** Worsening Accepted Cap */
|
||||
worsening_accepted_cap: number;
|
||||
/** Worsening Tentative Alpha */
|
||||
worsening_tentative_alpha: number;
|
||||
/** Worsening Tentative Cap */
|
||||
worsening_tentative_cap: number;
|
||||
};
|
||||
/** ClientAffectTraceV1 */
|
||||
ClientAffectTraceV1: {
|
||||
context: components["schemas"]["ClientAffectContextV1"];
|
||||
|
|
@ -4678,6 +4738,40 @@ export interface components {
|
|||
/** Turn Seq */
|
||||
turn_seq: number;
|
||||
};
|
||||
/** ClientAffectTraceV2 */
|
||||
ClientAffectTraceV2: {
|
||||
/** Appraisal */
|
||||
appraisal: components["schemas"]["AppraisalQuestionTraceV1"][];
|
||||
context: components["schemas"]["ClientAffectContextV1"];
|
||||
/** Cost Usd */
|
||||
cost_usd?: number | null;
|
||||
/** Dimensions */
|
||||
dimensions: components["schemas"]["ClientAffectDimensionTraceV1"][];
|
||||
expression: components["schemas"]["ExpressionTraceV1"];
|
||||
/** Input Tokens */
|
||||
input_tokens: number;
|
||||
/** Latency Ms */
|
||||
latency_ms: number;
|
||||
/** Model */
|
||||
model: string;
|
||||
/** Output Tokens */
|
||||
output_tokens: number;
|
||||
policy: components["schemas"]["ClientAffectPolicyV2"];
|
||||
/** Provider */
|
||||
provider: string;
|
||||
/** Reaction */
|
||||
reaction: components["schemas"]["ReactionDimensionTraceV1"][];
|
||||
/**
|
||||
* Schema Version
|
||||
* @constant
|
||||
* @enum {integer}
|
||||
*/
|
||||
schema_version: 2;
|
||||
/** Sore Spot Count */
|
||||
sore_spot_count: number;
|
||||
/** Turn Seq */
|
||||
turn_seq: number;
|
||||
};
|
||||
/** ClientDiagnosticError */
|
||||
ClientDiagnosticError: {
|
||||
/** At */
|
||||
|
|
@ -4743,6 +4837,58 @@ export interface components {
|
|||
/** Visible Nodes */
|
||||
visible_nodes?: number | null;
|
||||
};
|
||||
/** ClientInnerDisplayV1 */
|
||||
ClientInnerDisplayV1: {
|
||||
/**
|
||||
* Code
|
||||
* @enum {string}
|
||||
*/
|
||||
code: "as_felt" | "softened" | "covered_by_agreement" | "masked";
|
||||
/** Label */
|
||||
label: string;
|
||||
};
|
||||
/**
|
||||
* ClientInnerFeelingV1
|
||||
* @description 속마음 요약(§8.2)의 감정 한 항목.
|
||||
*/
|
||||
ClientInnerFeelingV1: {
|
||||
/** Intensity */
|
||||
intensity: string;
|
||||
/** Label */
|
||||
label: string;
|
||||
};
|
||||
/**
|
||||
* ClientInnerReactionV1
|
||||
* @description 학습자·교수자용 속마음 요약(§8.2). 고정 문구 표에서만 만든다.
|
||||
*/
|
||||
ClientInnerReactionV1: {
|
||||
display?: components["schemas"]["ClientInnerDisplayV1"] | null;
|
||||
/** Experienced */
|
||||
experienced: string[];
|
||||
/** Feelings */
|
||||
feelings: components["schemas"]["ClientInnerFeelingV1"][];
|
||||
/** Hidden Gap */
|
||||
hidden_gap: boolean;
|
||||
/**
|
||||
* Schema Version
|
||||
* @constant
|
||||
* @enum {integer}
|
||||
*/
|
||||
schema_version: 1;
|
||||
stance?: components["schemas"]["ClientInnerStanceV1"] | null;
|
||||
/** Turn Seq */
|
||||
turn_seq: number;
|
||||
};
|
||||
/** ClientInnerStanceV1 */
|
||||
ClientInnerStanceV1: {
|
||||
/**
|
||||
* Code
|
||||
* @enum {string}
|
||||
*/
|
||||
code: "engage" | "cautious" | "pull_back" | "push_back";
|
||||
/** Label */
|
||||
label: string;
|
||||
};
|
||||
/**
|
||||
* ClientStateRead
|
||||
* @description 내담자 상태 '읽기' — 학습자 발화 직후 내담자 응답에서 관측된 상태(읽기 채점 근거).
|
||||
|
|
@ -5690,6 +5836,51 @@ export interface components {
|
|||
/** Title Ko */
|
||||
title_ko: string;
|
||||
};
|
||||
/**
|
||||
* ExpressionChoiceTraceV1
|
||||
* @description c_behavior·c_display choice 판정 원자료.
|
||||
*/
|
||||
ExpressionChoiceTraceV1: {
|
||||
/** Choice */
|
||||
choice: string;
|
||||
/** Confidence */
|
||||
confidence?: number | null;
|
||||
/** Decision */
|
||||
decision: string;
|
||||
/** Probabilities */
|
||||
probabilities: {
|
||||
[key: string]: number;
|
||||
};
|
||||
};
|
||||
/**
|
||||
* ExpressionDiscloseTraceV1
|
||||
* @description c_disclose_ready noul 판정 원자료.
|
||||
*/
|
||||
ExpressionDiscloseTraceV1: {
|
||||
/** Confidence */
|
||||
confidence?: number | null;
|
||||
/** Decision */
|
||||
decision: string;
|
||||
/** Probability */
|
||||
probability: number;
|
||||
};
|
||||
/**
|
||||
* ExpressionTraceV1
|
||||
* @description 표현 계획(§6.4) trace — 개방도 게이트 전/후 값을 함께 남긴다.
|
||||
*/
|
||||
ExpressionTraceV1: {
|
||||
behavior: components["schemas"]["ExpressionChoiceTraceV1"];
|
||||
disclose_ready: components["schemas"]["ExpressionDiscloseTraceV1"];
|
||||
display: components["schemas"]["ExpressionChoiceTraceV1"];
|
||||
/** Gate Reason */
|
||||
gate_reason?: ("openness_closed" | "openness_guarded") | null;
|
||||
/** Gated Behavior */
|
||||
gated_behavior: string;
|
||||
/** Hidden Gap */
|
||||
hidden_gap: boolean;
|
||||
/** Stance */
|
||||
stance?: ("engage" | "cautious" | "pull_back" | "push_back") | null;
|
||||
};
|
||||
/** FusionCalibration */
|
||||
FusionCalibration: {
|
||||
/**
|
||||
|
|
@ -9185,6 +9376,18 @@ export interface components {
|
|||
*/
|
||||
session_id: string;
|
||||
};
|
||||
/**
|
||||
* ReactionDimensionTraceV1
|
||||
* @description 이번 턴 반응(§6.2) 9축 고정 순서 trace.
|
||||
*/
|
||||
ReactionDimensionTraceV1: {
|
||||
/** Included */
|
||||
included: boolean;
|
||||
/** Key */
|
||||
key: string;
|
||||
/** Value */
|
||||
value?: number | null;
|
||||
};
|
||||
/** RedTeamFinding */
|
||||
RedTeamFinding: {
|
||||
/**
|
||||
|
|
@ -9588,6 +9791,7 @@ export interface components {
|
|||
ReviewTurn: {
|
||||
/** Id */
|
||||
id: string;
|
||||
innerReaction?: components["schemas"]["ClientInnerReactionV1"] | null;
|
||||
/** Nonverbal */
|
||||
nonverbal?: components["schemas"]["ReviewNonverbalEvent"][];
|
||||
note?: components["schemas"]["ReviewNote"] | null;
|
||||
|
|
@ -11291,6 +11495,7 @@ export interface components {
|
|||
crisis_resource?: components["schemas"]["CrisisResourceResponse"] | null;
|
||||
/** Effective Openness */
|
||||
effective_openness: number;
|
||||
inner_reaction?: components["schemas"]["ClientInnerReactionV1"] | null;
|
||||
/** Output Error */
|
||||
output_error?: string | null;
|
||||
progress?: components["schemas"]["SessionProgress"] | null;
|
||||
|
|
|
|||
|
|
@ -231,6 +231,8 @@ export type SessionStageProgress = ApiSchema<"SessionStageProgress">;
|
|||
|
||||
/** POST /sessions/{id}/turn — sessions.py TurnResponse */
|
||||
export type TurnResponse = ApiSchema<"TurnResponse">;
|
||||
/** 내담자 속마음 요약(§8.2) — 회기 중·회기 후 노출 공통 형태 */
|
||||
export type ClientInnerReactionV1 = ApiSchema<"ClientInnerReactionV1">;
|
||||
export type LiveCoachRequest = ApiSchema<"LiveCoachRequest">;
|
||||
export type LiveCoachCreditEvent = ApiSchema<"LiveCoachCreditEvent">;
|
||||
export type LiveCoachEvent = ApiSchema<"LiveCoachEvent">;
|
||||
|
|
@ -321,6 +323,8 @@ export interface SessionStreamDone {
|
|||
output_error?: string | null;
|
||||
/** P2 단계 누적 게이지·상세 수치 */
|
||||
progress?: SessionProgress | null;
|
||||
/** 내담자 속마음 요약(§8.2). 피드백 꺼짐·미저장이면 null */
|
||||
inner_reaction?: ClientInnerReactionV1 | null;
|
||||
}
|
||||
|
||||
function safeParse(data: string): unknown {
|
||||
|
|
@ -391,6 +395,7 @@ export async function openSessionStream(
|
|||
conversation_stopped: parsed.conversation_stopped,
|
||||
output_error: parsed.output_error,
|
||||
progress: parsed.progress ?? null,
|
||||
inner_reaction: parsed.inner_reaction ?? null,
|
||||
};
|
||||
handlers.onDone?.(donePayload);
|
||||
return;
|
||||
|
|
|
|||
|
|
@ -14,6 +14,17 @@ import {
|
|||
type AffectDimensionKey,
|
||||
type ChangeObservation,
|
||||
} from "./admin-affect/affectMath";
|
||||
import {
|
||||
appraisalChoiceLabel,
|
||||
appraisalDecisionLabel,
|
||||
appraisalItemLabel,
|
||||
behaviorOrUncertainLabel,
|
||||
expressionDecisionLabel,
|
||||
expressionNoulDecisionLabel,
|
||||
gateReasonLabel,
|
||||
orderedAppraisalChoiceEntries,
|
||||
stanceLabel,
|
||||
} from "./admin-affect/affectLabels";
|
||||
import "./admin-affect/admin-affect.css";
|
||||
|
||||
type AffectTrace = AdminAffectSessionDetailResponse["traces"][number]["trace"];
|
||||
|
|
@ -64,6 +75,19 @@ function traceDimension(trace: AffectTrace, key: AffectDimensionKey): AffectDime
|
|||
return trace.dimensions.find((dimension) => dimension.key === key);
|
||||
}
|
||||
|
||||
// v2 trace는 방향별 계수(worsening/recovery)를 쓰므로 v1 전용 공용 계수 필드가 없다.
|
||||
function v1Policy(trace: AffectTrace): Extract<AffectTrace["policy"], { version: "jev-affect-v1" }> | undefined {
|
||||
return trace.policy.version === "jev-affect-v1" ? trace.policy : undefined;
|
||||
}
|
||||
|
||||
function v2Policy(trace: AffectTrace): Extract<AffectTrace["policy"], { version: "jev-affect-v2" }> | undefined {
|
||||
return trace.policy.version === "jev-affect-v2" ? trace.policy : undefined;
|
||||
}
|
||||
|
||||
function isV2Trace(trace: AffectTrace): trace is Extract<AffectTrace, { schema_version: 2 }> {
|
||||
return trace.schema_version === 2;
|
||||
}
|
||||
|
||||
function traceChanges(detail: AdminAffectSessionDetailResponse | null): ChangeObservation[] {
|
||||
if (!detail) return [];
|
||||
return detail.traces.map((item) => ({
|
||||
|
|
@ -405,7 +429,68 @@ export default function AdminAffect() {
|
|||
<div className="aac-table-wrap"><table className="aac-dimension-table"><thead><tr><th>감정 축</th><th>전</th><th>Jev 목표</th><th>후</th><th>변화량</th><th>반영</th><th>판단 확신도</th></tr></thead><tbody>{AFFECT_DIMENSIONS.map(([key, label]) => { const dimension = traceDimension(selectedTrace, key); const delta = dimension && Number.isFinite(dimension.before) && Number.isFinite(dimension.after) ? dimension.after - dimension.before : null; return <tr key={key}><td>{label}</td><td>{intensityLabel(dimension?.before, 1)}</td><td>{intensityLabel(dimension?.target, 1)}</td><td>{intensityLabel(dimension?.after, 1)}</td><td>{changeLabel(delta)}</td><td className={`aac-decision is-${dimension?.decision ?? "held"}`}>{dimension ? decisionLabel(dimension.decision) : "기록 없음"}</td><td>{percentLabel(dimension?.confidence)}</td></tr>; })}</tbody></table></div>
|
||||
<h3 className="aac-note">5단계 강도 분포</h3>
|
||||
{AFFECT_DIMENSIONS.map(([key, label]) => { const dimension = traceDimension(selectedTrace, key); return <section key={key} className="aac-distribution-group"><h4>{label}</h4><div className="aac-distribution" aria-label={`${label} 5단계 강도 분포`}>{(dimension?.probabilities ?? Array<number | null>(5).fill(null)).map((probability, index) => <div key={PROBABILITY_LABELS[index]}><span><i className={probability === null ? "is-missing" : ""} style={{ height: `${typeof probability === "number" ? probability * 100 : 0}%` }} /></span>{PROBABILITY_LABELS[index]}<b>{percentLabel(probability)}</b></div>)}</div></section>; })}
|
||||
<details className="aac-trace-meta"><summary>제공자 · 모델 · 정책 · 당시 상태 · 사용량 모두 보기</summary><dl><dt>제공자</dt><dd>{selectedTrace.provider}</dd><dt>모델</dt><dd>{selectedTrace.model}</dd><dt>응답 지연</dt><dd>{numberLabel(selectedTrace.latency_ms)}ms</dd><dt>입력 사용량</dt><dd>{numberLabel(selectedTrace.input_tokens)} token</dd><dt>출력 사용량</dt><dd>{numberLabel(selectedTrace.output_tokens)} token</dd><dt>비용</dt><dd>{selectedTrace.cost_usd === null ? "기록 없음" : `$${selectedTrace.cost_usd}`}</dd><dt>정책 버전</dt><dd>{selectedTrace.policy.version}</dd><dt>최소 판단 확신도</dt><dd>{percentLabel(selectedTrace.policy.min_confidence)}</dd><dt>반영 비율 · 변화량 상한</dt><dd>{percentLabel(selectedTrace.policy.accepted_alpha)} · {intensityLabel(selectedTrace.policy.accepted_cap, 1)}</dd><dt>잠정반영 최소 판단 확신도</dt><dd>{percentLabel(selectedTrace.policy.tentative_confidence_floor)}</dd><dt>잠정반영 비율 · 변화량 상한</dt><dd>{percentLabel(selectedTrace.policy.tentative_alpha)} · {intensityLabel(selectedTrace.policy.tentative_cap, 1)}</dd><dt>인접 강도 기준</dt><dd>{percentLabel(selectedTrace.policy.adjacent_probability_threshold)}</dd><dt>당시 단계</dt><dd>{selectedTrace.context.stage}</dd><dt>당시 저항</dt><dd>{numberLabel(selectedTrace.context.resistance, 2)}</dd><dt>유효 개방성</dt><dd>{numberLabel(selectedTrace.context.effective_openness, 2)}</dd><dt>라포 잔여값</dt><dd>{numberLabel(selectedTrace.context.rapport_credit, 2)}</dd></dl></details>
|
||||
{isV2Trace(selectedTrace) ? <section className="aac-v2" aria-label="상담자 발화 판정과 표현 계획">
|
||||
<p className="aac-note">판정 확률은 정답 확률이 아닙니다.</p>
|
||||
<h3>상담자 발화 판정</h3>
|
||||
<div className="aac-table-wrap">
|
||||
<table className="aac-table aac-appraisal-table">
|
||||
<thead><tr><th>판정 항목</th><th>판정</th><th>근거 확률</th><th>판단 확신도</th></tr></thead>
|
||||
<tbody>
|
||||
{selectedTrace.appraisal.map((item) => (
|
||||
<tr key={item.key}>
|
||||
<td>{appraisalItemLabel(item.key)}</td>
|
||||
<td>{appraisalDecisionLabel(item)}</td>
|
||||
<td>
|
||||
{item.kind === "noul"
|
||||
? <span>예 확률 {percentLabel(item.probability)}</span>
|
||||
: <div className="aac-choice-probs">
|
||||
{orderedAppraisalChoiceEntries(item.key, item.probabilities ?? {}).map(([choice, probability]) => (
|
||||
<div className="aac-choice-probs__row" key={choice}>
|
||||
<span>{appraisalChoiceLabel(item.key, choice)}</span>
|
||||
<span className="aac-choice-probs__track"><span style={{ width: `${probability * 100}%` }} /></span>
|
||||
<b>{percentLabel(probability)}</b>
|
||||
</div>
|
||||
))}
|
||||
</div>}
|
||||
</td>
|
||||
<td>{percentLabel(item.confidence)}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<h3>표현 계획</h3>
|
||||
<dl className="aac-kv">
|
||||
<dt>행동(원 판정)</dt><dd>{expressionDecisionLabel("behavior", selectedTrace.expression.behavior.decision)}</dd>
|
||||
<dt>개방도 게이트 후 행동</dt><dd>{behaviorOrUncertainLabel(selectedTrace.expression.gated_behavior)}</dd>
|
||||
<dt>게이트 사유</dt><dd>{gateReasonLabel(selectedTrace.expression.gate_reason)}</dd>
|
||||
<dt>태도</dt><dd>{stanceLabel(selectedTrace.expression.stance)}</dd>
|
||||
<dt>드러내는 방식</dt><dd>{expressionDecisionLabel("display", selectedTrace.expression.display.decision)}</dd>
|
||||
<dt>다음에 더 열 준비</dt><dd>{expressionNoulDecisionLabel(selectedTrace.expression.disclose_ready.decision)}</dd>
|
||||
<dt>겉과 속 차이</dt><dd>{selectedTrace.expression.hidden_gap ? "예" : "아니오"}</dd>
|
||||
</dl>
|
||||
|
||||
<h3>이번 턴 반응(감쇠 전)</h3>
|
||||
<div className="aac-table-wrap">
|
||||
<table className="aac-table">
|
||||
<thead><tr><th>감정 축</th><th>반응</th><th>포함 여부</th></tr></thead>
|
||||
<tbody>
|
||||
{AFFECT_DIMENSIONS.map(([key, label]) => {
|
||||
const item = selectedTrace.reaction.find((entry) => entry.key === key);
|
||||
return (
|
||||
<tr key={key}>
|
||||
<td>{label}</td>
|
||||
<td>{intensityLabel(item?.value)}</td>
|
||||
<td>{item?.included ? "포함" : "확신도 부족으로 제외"}</td>
|
||||
</tr>
|
||||
);
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</section> : null}
|
||||
<details className="aac-trace-meta"><summary>제공자 · 모델 · 정책 · 당시 상태 · 사용량 모두 보기</summary><dl><dt>제공자</dt><dd>{selectedTrace.provider}</dd><dt>모델</dt><dd>{selectedTrace.model}</dd><dt>응답 지연</dt><dd>{numberLabel(selectedTrace.latency_ms)}ms</dd><dt>입력 사용량</dt><dd>{numberLabel(selectedTrace.input_tokens)} token</dd><dt>출력 사용량</dt><dd>{numberLabel(selectedTrace.output_tokens)} token</dd><dt>비용</dt><dd>{selectedTrace.cost_usd === null ? "기록 없음" : `$${selectedTrace.cost_usd}`}</dd><dt>정책 버전</dt><dd>{selectedTrace.policy.version}</dd><dt>최소 판단 확신도</dt><dd>{percentLabel(selectedTrace.policy.min_confidence)}</dd>{v1Policy(selectedTrace) ? <><dt>반영 비율 · 변화량 상한</dt><dd>{percentLabel(v1Policy(selectedTrace)?.accepted_alpha)} · {intensityLabel(v1Policy(selectedTrace)?.accepted_cap, 1)}</dd></> : null}<dt>잠정반영 최소 판단 확신도</dt><dd>{percentLabel(selectedTrace.policy.tentative_confidence_floor)}</dd>{v1Policy(selectedTrace) ? <><dt>잠정반영 비율 · 변화량 상한</dt><dd>{percentLabel(v1Policy(selectedTrace)?.tentative_alpha)} · {intensityLabel(v1Policy(selectedTrace)?.tentative_cap, 1)}</dd></> : null}{v2Policy(selectedTrace) ? <><dt>악화 확정 비율 · 변화량 상한</dt><dd>{percentLabel(v2Policy(selectedTrace)?.worsening_accepted_alpha)} · {intensityLabel(v2Policy(selectedTrace)?.worsening_accepted_cap, 1)}</dd><dt>악화 잠정 비율 · 변화량 상한</dt><dd>{percentLabel(v2Policy(selectedTrace)?.worsening_tentative_alpha)} · {intensityLabel(v2Policy(selectedTrace)?.worsening_tentative_cap, 1)}</dd><dt>회복 확정 비율 · 변화량 상한</dt><dd>{percentLabel(v2Policy(selectedTrace)?.recovery_accepted_alpha)} · {intensityLabel(v2Policy(selectedTrace)?.recovery_accepted_cap, 1)}</dd><dt>회복 잠정 비율 · 변화량 상한</dt><dd>{percentLabel(v2Policy(selectedTrace)?.recovery_tentative_alpha)} · {intensityLabel(v2Policy(selectedTrace)?.recovery_tentative_cap, 1)}</dd></> : null}<dt>인접 강도 기준</dt><dd>{percentLabel(selectedTrace.policy.adjacent_probability_threshold)}</dd><dt>당시 단계</dt><dd>{selectedTrace.context.stage}</dd><dt>당시 저항</dt><dd>{numberLabel(selectedTrace.context.resistance, 2)}</dd><dt>유효 개방성</dt><dd>{numberLabel(selectedTrace.context.effective_openness, 2)}</dd><dt>라포 잔여값</dt><dd>{numberLabel(selectedTrace.context.rapport_credit, 2)}</dd></dl></details>
|
||||
</>}
|
||||
</section>
|
||||
|
||||
|
|
|
|||
|
|
@ -25,11 +25,13 @@ import {
|
|||
} from "../components/avatar/ClientAvatar";
|
||||
import type { AvatarState, AvatarAffect } from "../components/avatar/ClientAvatar";
|
||||
import { Kicker, Button, Icon, surfaceClassName } from "../components/ui";
|
||||
import { InnerReactionCard } from "../components/inner-reaction/InnerReactionCard";
|
||||
import {
|
||||
ApiError,
|
||||
apiWsUrl,
|
||||
personaApi,
|
||||
sessionApi,
|
||||
type ClientInnerReactionV1,
|
||||
type CrisisResource,
|
||||
type LiveCoachCreditEvent,
|
||||
type LiveCoachEvent,
|
||||
|
|
@ -77,6 +79,7 @@ import { AllianceCheckpointPrompt } from "./session/AllianceCheckpointPrompt";
|
|||
import {
|
||||
AI_VOICE_DISCLOSURE,
|
||||
DEFAULT_COACH_QUOTA,
|
||||
INNER_REACTION_REVEAL_STORAGE_KEY,
|
||||
SESSION_PHASES,
|
||||
THEORY_MODE_OPTIONS,
|
||||
buildPersonaUi,
|
||||
|
|
@ -219,6 +222,36 @@ export default function Session() {
|
|||
const [micOn, setMicOn] = useState(false);
|
||||
const [paused, setPaused] = useState(false);
|
||||
const [feedbackMode, setFeedbackMode] = useState<FeedbackMode>("ambient");
|
||||
const [learnerFeedbackEnabled, setLearnerFeedbackEnabled] = useState(true);
|
||||
const [innerReactionReveal, setInnerReactionReveal] = useState<boolean>(() => {
|
||||
try {
|
||||
return window.localStorage.getItem(INNER_REACTION_REVEAL_STORAGE_KEY) !== "0";
|
||||
} catch {
|
||||
return true;
|
||||
}
|
||||
});
|
||||
const [openInnerReactionIds, setOpenInnerReactionIds] = useState<ReadonlySet<number>>(
|
||||
() => new Set(),
|
||||
);
|
||||
const toggleInnerReactionReveal = useCallback(() => {
|
||||
setInnerReactionReveal((prev) => {
|
||||
const next = !prev;
|
||||
try {
|
||||
window.localStorage.setItem(INNER_REACTION_REVEAL_STORAGE_KEY, next ? "1" : "0");
|
||||
} catch {
|
||||
// 브라우저 저장소를 쓸 수 없어도 이번 회기의 표시 여부는 그대로 반영한다.
|
||||
}
|
||||
return next;
|
||||
});
|
||||
}, []);
|
||||
const toggleInnerReactionOpen = useCallback((id: number) => {
|
||||
setOpenInnerReactionIds((prev) => {
|
||||
const next = new Set(prev);
|
||||
if (next.has(id)) next.delete(id);
|
||||
else next.add(id);
|
||||
return next;
|
||||
});
|
||||
}, []);
|
||||
const [composeText, setComposeText] = useState("");
|
||||
const [sending, setSending] = useState(false);
|
||||
const [clientReplyPending, setClientReplyPending] = useState(false);
|
||||
|
|
@ -1039,6 +1072,8 @@ export default function Session() {
|
|||
setElapsed(ended ? restoredElapsed : Math.min(restoredElapsed, restoredDurationLimit));
|
||||
setGoalStages(detail.goal_stages ?? []);
|
||||
setProgress(detail.progress ?? null);
|
||||
setLearnerFeedbackEnabled(detail.learner_feedback_enabled);
|
||||
setOpenInnerReactionIds(new Set());
|
||||
if (detail.duration_limit_seconds) setDurationLimitSeconds(detail.duration_limit_seconds);
|
||||
if (detail.warning_before_end_seconds) {
|
||||
setWarningBeforeEndSeconds(detail.warning_before_end_seconds);
|
||||
|
|
@ -1139,6 +1174,8 @@ export default function Session() {
|
|||
setElapsed(0);
|
||||
setGoalStages(res.goal_stages ?? []);
|
||||
setProgress(null);
|
||||
setLearnerFeedbackEnabled(res.learner_feedback_enabled);
|
||||
setOpenInnerReactionIds(new Set());
|
||||
if (res.duration_limit_seconds) setDurationLimitSeconds(res.duration_limit_seconds);
|
||||
if (res.warning_before_end_seconds) setWarningBeforeEndSeconds(res.warning_before_end_seconds);
|
||||
timeWarningShownRef.current = false;
|
||||
|
|
@ -1284,7 +1321,14 @@ export default function Session() {
|
|||
);
|
||||
|
||||
const appendServerClientReply = useCallback(
|
||||
(replyText: string | null, at = elapsed, options?: { suppressEmptyWarning?: boolean }) => {
|
||||
(
|
||||
replyText: string | null,
|
||||
at = elapsed,
|
||||
options?: {
|
||||
suppressEmptyWarning?: boolean;
|
||||
innerReaction?: ClientInnerReactionV1 | null;
|
||||
},
|
||||
) => {
|
||||
setClientReplyPending(false);
|
||||
if (!replyText) {
|
||||
setAvatarState("listening");
|
||||
|
|
@ -1295,7 +1339,13 @@ export default function Session() {
|
|||
}
|
||||
setUtterances((prev) => [
|
||||
...prev,
|
||||
{ id: nextId(), speaker: "client", text: replyText, at },
|
||||
{
|
||||
id: nextId(),
|
||||
speaker: "client",
|
||||
text: replyText,
|
||||
at,
|
||||
innerReaction: options?.innerReaction ?? null,
|
||||
},
|
||||
]);
|
||||
setAvatarState("speaking");
|
||||
pushSignal("neutral", "내담자 응답 수신");
|
||||
|
|
@ -1397,19 +1447,20 @@ export default function Session() {
|
|||
|
||||
if (clientId != null) {
|
||||
const id = clientId;
|
||||
const innerReaction = done.inner_reaction ?? null;
|
||||
setUtterances((prev) => {
|
||||
const existingIndex = prev.findIndex((u) => u.id === id);
|
||||
if (existingIndex === -1) {
|
||||
return [
|
||||
...prev.map((u) => (u.id === learnerId ? { ...u, turnSeq: done.turn_seq } : u)),
|
||||
{ id, speaker: "client", text: clientReply, at },
|
||||
{ id, speaker: "client", text: clientReply, at, innerReaction },
|
||||
];
|
||||
}
|
||||
return prev.map((u) =>
|
||||
u.id === learnerId
|
||||
? { ...u, turnSeq: done.turn_seq }
|
||||
: u.id === id
|
||||
? { ...u, partial: false }
|
||||
? { ...u, partial: false, innerReaction }
|
||||
: u,
|
||||
);
|
||||
});
|
||||
|
|
@ -1928,6 +1979,7 @@ export default function Session() {
|
|||
}
|
||||
appendServerClientReply(payload.text ?? null, elapsed, {
|
||||
suppressEmptyWarning: conversationStopped,
|
||||
innerReaction: payload.inner_reaction ?? null,
|
||||
});
|
||||
const learnerText = pendingVoiceLearnerTextRef.current;
|
||||
pendingVoiceLearnerTextRef.current = "";
|
||||
|
|
@ -2645,6 +2697,14 @@ export default function Session() {
|
|||
: coachSuggestion
|
||||
? "근거 확인 완료"
|
||||
: "턴 완료 후 개입";
|
||||
const latestInnerReactionUtterance = useMemo(() => {
|
||||
for (let i = utterances.length - 1; i >= 0; i -= 1) {
|
||||
const u = utterances[i];
|
||||
if (u.speaker === "client" && u.innerReaction) return u;
|
||||
}
|
||||
return null;
|
||||
}, [utterances]);
|
||||
const showInnerReactions = feedbackMode !== "immersive" && innerReactionReveal;
|
||||
const coachHistoryByTurn = useMemo(() => {
|
||||
const grouped = new Map<number, LiveCoachEvent[]>();
|
||||
for (const event of coachHistory) {
|
||||
|
|
@ -3553,6 +3613,11 @@ export default function Session() {
|
|||
? (coachHistoryByTurn.get(u.turnSeq) ?? [])
|
||||
: [];
|
||||
const latestCoach = turnCoachEvents[turnCoachEvents.length - 1];
|
||||
const innerReaction =
|
||||
u.speaker === "client" && showInnerReactions ? u.innerReaction : null;
|
||||
const innerReactionOpen = innerReaction
|
||||
? openInnerReactionIds.has(u.id)
|
||||
: false;
|
||||
return (
|
||||
<div
|
||||
key={u.id}
|
||||
|
|
@ -3610,7 +3675,27 @@ export default function Session() {
|
|||
) : null}
|
||||
</button>
|
||||
) : null}
|
||||
{innerReaction ? (
|
||||
<button
|
||||
type="button"
|
||||
className={
|
||||
"sx-utt__inner-mark" +
|
||||
(innerReaction.hidden_gap ? " is-warn" : "")
|
||||
}
|
||||
aria-label="이 발화의 내담자 속마음 보기"
|
||||
aria-expanded={innerReactionOpen}
|
||||
onClick={() => toggleInnerReactionOpen(u.id)}
|
||||
>
|
||||
속
|
||||
</button>
|
||||
) : null}
|
||||
</div>
|
||||
{innerReaction && innerReactionOpen ? (
|
||||
<InnerReactionCard
|
||||
reaction={innerReaction}
|
||||
className="sx-utt__inner-card"
|
||||
/>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
|
|
@ -3984,6 +4069,13 @@ export default function Session() {
|
|||
</div>
|
||||
</div>
|
||||
|
||||
{showInnerReactions && latestInnerReactionUtterance?.innerReaction ? (
|
||||
<InnerReactionCard
|
||||
reaction={latestInnerReactionUtterance.innerReaction}
|
||||
className="sx-signal__inner-reaction"
|
||||
/>
|
||||
) : null}
|
||||
|
||||
{liveSignal ? (
|
||||
<div
|
||||
className={"sx-signal__one" + (signalFaded ? " is-faded" : "")}
|
||||
|
|
@ -4363,36 +4455,56 @@ export default function Session() {
|
|||
<em>코칭 열기</em>
|
||||
</button>
|
||||
) : null}
|
||||
<div className="sx-segmented" role="group" aria-label="피드백 모드">
|
||||
<button
|
||||
className={feedbackMode === "immersive" ? "is-on" : ""}
|
||||
onClick={() => setFeedbackMode("immersive")}
|
||||
>
|
||||
몰입
|
||||
</button>
|
||||
<button
|
||||
className={feedbackMode === "ambient" ? "is-on" : ""}
|
||||
onClick={() => setFeedbackMode("ambient")}
|
||||
>
|
||||
상태 신호
|
||||
</button>
|
||||
<button
|
||||
className={feedbackMode === "coached" ? "is-on" : ""}
|
||||
onClick={() => setFeedbackMode("coached")}
|
||||
aria-label={`코칭 모드, 남은 기회 ${coachQuotaRemaining}개`}
|
||||
>
|
||||
<span>코칭</span>
|
||||
<span
|
||||
className={
|
||||
"sx-segmented__badge" +
|
||||
(coachQuotaRemaining <= 0 ? " is-empty" : "") +
|
||||
(coachNudgeVisible ? " is-pulsing" : "")
|
||||
}
|
||||
aria-hidden="true"
|
||||
<div className="sx-seg-block__row">
|
||||
<div className="sx-segmented" role="group" aria-label="피드백 모드">
|
||||
<button
|
||||
className={feedbackMode === "immersive" ? "is-on" : ""}
|
||||
onClick={() => setFeedbackMode("immersive")}
|
||||
>
|
||||
{coachQuotaRemaining}
|
||||
</span>
|
||||
</button>
|
||||
몰입
|
||||
</button>
|
||||
<button
|
||||
className={feedbackMode === "ambient" ? "is-on" : ""}
|
||||
onClick={() => setFeedbackMode("ambient")}
|
||||
>
|
||||
상태 신호
|
||||
</button>
|
||||
<button
|
||||
className={feedbackMode === "coached" ? "is-on" : ""}
|
||||
onClick={() => setFeedbackMode("coached")}
|
||||
aria-label={`코칭 모드, 남은 기회 ${coachQuotaRemaining}개`}
|
||||
>
|
||||
<span>코칭</span>
|
||||
<span
|
||||
className={
|
||||
"sx-segmented__badge" +
|
||||
(coachQuotaRemaining <= 0 ? " is-empty" : "") +
|
||||
(coachNudgeVisible ? " is-pulsing" : "")
|
||||
}
|
||||
aria-hidden="true"
|
||||
>
|
||||
{coachQuotaRemaining}
|
||||
</span>
|
||||
</button>
|
||||
</div>
|
||||
{learnerFeedbackEnabled ? (
|
||||
<div className="sx-inner-toggle-row">
|
||||
<span className="sx-inner-toggle__label" aria-hidden="true">속마음 보기</span>
|
||||
<span className="sx-inner-toggle__label sx-inner-toggle__label--short" aria-hidden="true">속마음</span>
|
||||
<button
|
||||
type="button"
|
||||
role="switch"
|
||||
aria-checked={innerReactionReveal}
|
||||
aria-label="속마음 보기"
|
||||
className={"sx-inner-toggle" + (innerReactionReveal ? " is-on" : "")}
|
||||
onClick={toggleInnerReactionReveal}
|
||||
>
|
||||
<span className="sx-inner-toggle__track" aria-hidden="true">
|
||||
<span className="sx-inner-toggle__knob" aria-hidden="true" />
|
||||
</span>
|
||||
</button>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
|
|
|||
|
|
@ -25,6 +25,7 @@ import {
|
|||
sessionApi,
|
||||
teacherApi,
|
||||
userApi,
|
||||
type ClientInnerReactionV1,
|
||||
type ReviewCaseWorksheet,
|
||||
type ReviewNonverbalEvent,
|
||||
type ReviewNote,
|
||||
|
|
@ -35,6 +36,7 @@ import {
|
|||
type SessionReviewResponse,
|
||||
type UserPrepostMeasuresResponse,
|
||||
} from "../lib/api";
|
||||
import { InnerReactionCard } from "../components/inner-reaction/InnerReactionCard";
|
||||
import { canAccessRole, useAuth } from "../lib/auth";
|
||||
import { displayPiiSafeText } from "../lib/piiDisplay";
|
||||
import {
|
||||
|
|
@ -386,6 +388,34 @@ function SupervisorCallout({ note }: { note: ReviewNote }) {
|
|||
);
|
||||
}
|
||||
|
||||
function InnerReactionFold({ reaction }: { reaction: ClientInnerReactionV1 }) {
|
||||
const [open, setOpen] = useState(false);
|
||||
return (
|
||||
<div className="sr-inner-reaction">
|
||||
<button
|
||||
type="button"
|
||||
className="sr-inner-reaction__toggle"
|
||||
aria-expanded={open}
|
||||
onClick={() => setOpen((prev) => !prev)}
|
||||
>
|
||||
<Kicker dot={false}>내담자 속마음</Kicker>
|
||||
{reaction.hidden_gap ? <Badge tone="warn">겉과 속이 달랐던 순간</Badge> : null}
|
||||
{reaction.stance?.label ? (
|
||||
<span className="sr-inner-reaction__stance">{reaction.stance.label}</span>
|
||||
) : null}
|
||||
<span className="sr-inner-reaction__hint">{open ? "접기" : "펼치기"}</span>
|
||||
</button>
|
||||
{open ? (
|
||||
<InnerReactionCard
|
||||
reaction={reaction}
|
||||
className="sr-inner-reaction__card"
|
||||
hideHeader
|
||||
/>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function EvaluationScopeNotice() {
|
||||
return (
|
||||
<div
|
||||
|
|
@ -1784,6 +1814,9 @@ export default function SessionReview() {
|
|||
{turn.note ? (
|
||||
<SupervisorCallout note={turn.note} />
|
||||
) : null}
|
||||
{turn.speaker === "client" && turn.innerReaction ? (
|
||||
<InnerReactionFold reaction={turn.innerReaction} />
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -172,6 +172,23 @@
|
|||
.aac-distribution i { display: block; width: 100%; background: var(--accent-bright); }
|
||||
.aac-distribution i.is-missing { background: transparent; }
|
||||
|
||||
.aac-v2 { display: grid; gap: var(--sp-2); margin-top: var(--sp-4); }
|
||||
.aac-v2 h3 { margin: var(--sp-2) 0 0; color: var(--text-strong); font-size: var(--fs-sm); }
|
||||
.aac-appraisal-table td { vertical-align: top; }
|
||||
|
||||
.aac-choice-probs { display: grid; gap: 5px; min-width: 160px; }
|
||||
.aac-choice-probs__row { display: grid; grid-template-columns: minmax(64px, auto) minmax(48px, 1fr) 40px; align-items: center; gap: 6px; font: 600 var(--fs-xs)/1.3 var(--font-sans); color: var(--text-muted); }
|
||||
.aac-choice-probs__row b { color: var(--text-body); font-family: var(--font-num); text-align: right; }
|
||||
.aac-choice-probs__track { display: block; height: 6px; overflow: hidden; border-radius: 999px; background: var(--neutral-100); }
|
||||
.aac-choice-probs__track > span { display: block; height: 100%; background: var(--accent-bright); }
|
||||
|
||||
.aac-kv { display: grid; grid-template-columns: 190px minmax(0, 1fr); margin: var(--sp-3) 0 0; border: 1px solid var(--border-subtle); border-radius: var(--radius-sm); overflow: hidden; }
|
||||
.aac-kv dt,
|
||||
.aac-kv dd { min-width: 0; margin: 0; padding: 9px 10px; border-bottom: 1px solid var(--border-subtle); font-size: var(--fs-xs); overflow-wrap: anywhere; }
|
||||
.aac-kv dt { background: var(--bg-surface-2); color: var(--text-muted); font-weight: 700; }
|
||||
.aac-kv dd { color: var(--text-body); }
|
||||
.aac-kv dt:nth-last-of-type(1), .aac-kv dd:nth-last-of-type(1) { border-bottom: 0; }
|
||||
|
||||
.aac-trace-meta { margin-top: var(--sp-3); }
|
||||
.aac-trace-meta summary { cursor: pointer; color: var(--text-accent); font-size: var(--fs-sm); font-weight: 700; }
|
||||
.aac-trace-meta dl { display: grid; grid-template-columns: 150px minmax(0, 1fr); margin: var(--sp-3) 0 0; border: 1px solid var(--border-subtle); border-radius: var(--radius-sm); overflow: hidden; }
|
||||
|
|
@ -208,7 +225,9 @@
|
|||
.aac-toolbar__actions .aac-btn { flex: 1 1 0; }
|
||||
.aac-charts { grid-template-columns: 1fr; }
|
||||
.aac-distribution { grid-template-columns: repeat(5, minmax(58px, 1fr)); overflow-x: auto; }
|
||||
.aac-trace-meta dl { grid-template-columns: 100px minmax(0, 1fr); }
|
||||
.aac-trace-meta dl,
|
||||
.aac-kv { grid-template-columns: 100px minmax(0, 1fr); }
|
||||
.aac-choice-probs__row { grid-template-columns: minmax(52px, auto) minmax(36px, 1fr) 34px; }
|
||||
}
|
||||
|
||||
@media print {
|
||||
|
|
|
|||
156
apps/web/src/pages/admin-affect/affectLabels.ts
Normal file
156
apps/web/src/pages/admin-affect/affectLabels.ts
Normal file
|
|
@ -0,0 +1,156 @@
|
|||
// jev-affect-v2 trace(§8.1)의 코드값을 관리자 화면 한글 라벨로 옮긴다.
|
||||
// 모르는 코드는 원문 코드를 그대로 보여 주지 않고 "알 수 없는 값"으로 대체한다.
|
||||
|
||||
const UNKNOWN_LABEL = "알 수 없는 값";
|
||||
|
||||
const APPRAISAL_ITEM_LABELS: Record<string, string> = {
|
||||
a_understood: "이해받았다고 느낌",
|
||||
a_judged: "평가·비난받는 느낌",
|
||||
a_autonomy: "선택을 강요받는 느낌",
|
||||
a_coping: "제안의 감당 가능성",
|
||||
a_directionless: "방향을 잃은 느낌",
|
||||
a_sore_spot: "민감 지점 자극",
|
||||
a_fact_conflict: "고정 사실과의 충돌",
|
||||
a_move: "상담자 발화 유형",
|
||||
};
|
||||
|
||||
export function appraisalItemLabel(key: string): string {
|
||||
return APPRAISAL_ITEM_LABELS[key] ?? UNKNOWN_LABEL;
|
||||
}
|
||||
|
||||
const A_COPING_LABELS: Record<string, string> = {
|
||||
nothing_asked: "요구 없음",
|
||||
manageable: "감당 가능",
|
||||
stretch: "부담스러움",
|
||||
overwhelming: "벅참",
|
||||
};
|
||||
const A_COPING_ORDER = ["nothing_asked", "manageable", "stretch", "overwhelming"];
|
||||
|
||||
const A_MOVE_LABELS: Record<string, string> = {
|
||||
reflection: "반영",
|
||||
validation: "타당화",
|
||||
open_question: "열린 질문",
|
||||
closed_question: "닫힌 질문",
|
||||
clarification: "명료화",
|
||||
confrontation: "직면",
|
||||
interpretation: "해석",
|
||||
advice: "조언",
|
||||
information: "정보 제공",
|
||||
self_disclosure: "자기 개방",
|
||||
topic_shift: "화제 전환",
|
||||
other: "기타",
|
||||
};
|
||||
const A_MOVE_ORDER = Object.keys(A_MOVE_LABELS);
|
||||
|
||||
function soreSpotLabel(choice: string): string {
|
||||
if (choice === "none") return "해당 없음";
|
||||
const match = /^spot_(\d+)$/.exec(choice);
|
||||
return match ? `민감 지점 ${match[1]}` : UNKNOWN_LABEL;
|
||||
}
|
||||
|
||||
function soreSpotRank(choice: string): number {
|
||||
if (choice === "none") return -1;
|
||||
const match = /^spot_(\d+)$/.exec(choice);
|
||||
return match ? Number(match[1]) : Number.POSITIVE_INFINITY;
|
||||
}
|
||||
|
||||
export function appraisalChoiceLabel(key: string, choice: string): string {
|
||||
if (key === "a_coping") return A_COPING_LABELS[choice] ?? UNKNOWN_LABEL;
|
||||
if (key === "a_move") return A_MOVE_LABELS[choice] ?? UNKNOWN_LABEL;
|
||||
if (key === "a_sore_spot") return soreSpotLabel(choice);
|
||||
return UNKNOWN_LABEL;
|
||||
}
|
||||
|
||||
function sortByOrder(entries: Array<[string, number]>, order: string[]): Array<[string, number]> {
|
||||
return [...entries].sort((a, b) => {
|
||||
const rankA = order.indexOf(a[0]);
|
||||
const rankB = order.indexOf(b[0]);
|
||||
return (rankA === -1 ? order.length : rankA) - (rankB === -1 ? order.length : rankB);
|
||||
});
|
||||
}
|
||||
|
||||
export function orderedAppraisalChoiceEntries(key: string, probabilities: Record<string, number>): Array<[string, number]> {
|
||||
const entries = Object.entries(probabilities);
|
||||
if (key === "a_coping") return sortByOrder(entries, A_COPING_ORDER);
|
||||
if (key === "a_move") return sortByOrder(entries, A_MOVE_ORDER);
|
||||
if (key === "a_sore_spot") return [...entries].sort((a, b) => soreSpotRank(a[0]) - soreSpotRank(b[0]));
|
||||
return entries;
|
||||
}
|
||||
|
||||
function noulDecisionLabel(decision: string): string {
|
||||
if (decision === "true") return "예";
|
||||
if (decision === "false") return "아니오";
|
||||
if (decision === "uncertain") return "판단 보류";
|
||||
return UNKNOWN_LABEL;
|
||||
}
|
||||
|
||||
export function appraisalDecisionLabel(item: { key: string; kind: "noul" | "choice"; decision: string }): string {
|
||||
if (item.kind === "noul") return noulDecisionLabel(item.decision);
|
||||
if (item.decision === "uncertain") return "판단 보류";
|
||||
return appraisalChoiceLabel(item.key, item.decision);
|
||||
}
|
||||
|
||||
const C_BEHAVIOR_LABELS: Record<string, string> = {
|
||||
disclose_more: "더 털어놓음",
|
||||
stay_with_feeling: "감정에 머묾",
|
||||
hold_core: "핵심은 보류",
|
||||
ask_back: "되물음",
|
||||
minimal_response: "최소 반응",
|
||||
shift_topic: "화제 전환",
|
||||
abstract_talk: "추상적으로 말함",
|
||||
appease: "달래며 동의",
|
||||
self_blame: "자기비난",
|
||||
complain: "불만 표현",
|
||||
argue_back: "반박",
|
||||
take_control: "주도권 요구",
|
||||
};
|
||||
|
||||
function behaviorLabel(choice: string): string {
|
||||
return C_BEHAVIOR_LABELS[choice] ?? UNKNOWN_LABEL;
|
||||
}
|
||||
|
||||
export function behaviorOrUncertainLabel(value: string): string {
|
||||
return value === "uncertain" ? "판단 보류" : behaviorLabel(value);
|
||||
}
|
||||
|
||||
const C_DISPLAY_LABELS: Record<string, string> = {
|
||||
as_felt: "그대로",
|
||||
softened: "누그러뜨림",
|
||||
covered_by_agreement: "수긍으로 덮음",
|
||||
masked: "웃음·무덤덤함으로 가림",
|
||||
};
|
||||
|
||||
function displayLabel(choice: string): string {
|
||||
return C_DISPLAY_LABELS[choice] ?? UNKNOWN_LABEL;
|
||||
}
|
||||
|
||||
export function expressionDecisionLabel(kind: "behavior" | "display", decision: string): string {
|
||||
if (decision === "uncertain") return "판단 보류";
|
||||
return kind === "behavior" ? behaviorLabel(decision) : displayLabel(decision);
|
||||
}
|
||||
|
||||
export function expressionNoulDecisionLabel(decision: string): string {
|
||||
return noulDecisionLabel(decision);
|
||||
}
|
||||
|
||||
const GATE_REASON_LABELS: Record<string, string> = {
|
||||
openness_closed: "매우 닫힘으로 조정",
|
||||
openness_guarded: "경계 상태로 조정",
|
||||
};
|
||||
|
||||
export function gateReasonLabel(reason: string | null | undefined): string {
|
||||
if (reason === null || reason === undefined) return "조정 없음";
|
||||
return GATE_REASON_LABELS[reason] ?? UNKNOWN_LABEL;
|
||||
}
|
||||
|
||||
const STANCE_LABELS: Record<string, string> = {
|
||||
engage: "대화에 더 들어옴",
|
||||
cautious: "조심스러운 거리",
|
||||
pull_back: "한발 물러남",
|
||||
push_back: "맞섬·반박",
|
||||
};
|
||||
|
||||
export function stanceLabel(stance: string | null | undefined): string {
|
||||
if (stance === null || stance === undefined) return "판단 보류";
|
||||
return STANCE_LABELS[stance] ?? UNKNOWN_LABEL;
|
||||
}
|
||||
|
|
@ -1171,6 +1171,41 @@
|
|||
font-style: italic;
|
||||
}
|
||||
|
||||
/* 내담자 속마음(§8.2) 접힘 블록 — 기본 접힘, 펼치면 InnerReactionCard */
|
||||
.sr-inner-reaction {
|
||||
max-width: 68ch;
|
||||
margin-top: var(--sp-3);
|
||||
}
|
||||
.sr-inner-reaction__toggle {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
flex-wrap: wrap;
|
||||
gap: var(--sp-2);
|
||||
width: 100%;
|
||||
min-height: 44px;
|
||||
padding: var(--sp-2) var(--sp-3);
|
||||
border: 1px solid var(--border-subtle);
|
||||
border-radius: var(--radius);
|
||||
background: var(--paper-2);
|
||||
color: var(--text-body);
|
||||
cursor: pointer;
|
||||
text-align: left;
|
||||
}
|
||||
.sr-inner-reaction__stance {
|
||||
font-size: var(--fs-xs);
|
||||
color: var(--text-muted);
|
||||
}
|
||||
.sr-inner-reaction__hint {
|
||||
margin-left: auto;
|
||||
font-size: var(--fs-xs);
|
||||
font-weight: 600;
|
||||
color: var(--text-accent);
|
||||
white-space: nowrap;
|
||||
}
|
||||
.sr-inner-reaction__card {
|
||||
margin-top: var(--sp-2);
|
||||
}
|
||||
|
||||
.sr-first-checklist__heading,
|
||||
.sr-first-checklist__item-head {
|
||||
display: flex;
|
||||
|
|
|
|||
|
|
@ -1270,6 +1270,35 @@
|
|||
line-height: 14px;
|
||||
font-weight: 800;
|
||||
}
|
||||
/* 내담자 속마음 마크 — coach-mark와 같은 원형 배지 패턴, 클라이 계열 톤 */
|
||||
.sx-utt__inner-mark {
|
||||
flex: 0 0 auto;
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
border-radius: 50%;
|
||||
border: 1px solid color-mix(in srgb, var(--clay-deep) 36%, transparent);
|
||||
background: var(--paper);
|
||||
color: var(--clay-deep);
|
||||
font-size: 11px;
|
||||
font-weight: 800;
|
||||
line-height: 1;
|
||||
display: inline-grid;
|
||||
place-items: center;
|
||||
cursor: pointer;
|
||||
box-shadow: var(--shadow-sm);
|
||||
}
|
||||
.sx-utt__inner-mark.is-warn {
|
||||
border-color: color-mix(in srgb, var(--warn-solid) 48%, transparent);
|
||||
color: var(--warn-text);
|
||||
}
|
||||
.sx-utt__inner-mark:hover {
|
||||
transform: translateY(-1px);
|
||||
box-shadow: var(--shadow-md);
|
||||
}
|
||||
.sx-utt__inner-card {
|
||||
margin-top: var(--sp-2);
|
||||
width: 100%;
|
||||
}
|
||||
/* partial(진행 중 발화) = ink-3 흐릿 + 캐럿 */
|
||||
.sx-utt.is-partial .sx-utt__line {
|
||||
color: var(--text-muted);
|
||||
|
|
@ -3037,6 +3066,95 @@
|
|||
box-shadow: inset 0 0 0 1px color-mix(in srgb, var(--text-muted) 25%, transparent);
|
||||
}
|
||||
|
||||
/* 피드백 모드 segmented + "속마음 보기" 스위치를 한 줄에 둔다 — 새 행을 만들면
|
||||
하단 컨트롤 바 높이가 늘어 축어록 스크롤 영역을 줄인다(session-layout 회귀). */
|
||||
.sx-seg-block__row {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
flex-wrap: nowrap;
|
||||
gap: var(--sp-2);
|
||||
min-width: 0;
|
||||
}
|
||||
.sx-inner-toggle-row {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
gap: var(--sp-2);
|
||||
flex: none;
|
||||
}
|
||||
.sx-inner-toggle__label {
|
||||
font-size: 10.5px;
|
||||
font-weight: 600;
|
||||
letter-spacing: 0.06em;
|
||||
color: var(--text-muted);
|
||||
font-family: var(--font-num);
|
||||
white-space: nowrap;
|
||||
}
|
||||
/* 좁은 폭에서는 segmented 옆 공간이 빠듯해 짧은 시각 라벨로 바꾼다. 접근 가능한
|
||||
이름은 버튼의 aria-label이 유지하므로 두 시각 라벨은 aria-hidden이다. */
|
||||
.sx-inner-toggle__label--short {
|
||||
display: none;
|
||||
}
|
||||
@media (max-width: 1180px) {
|
||||
.sx-inner-toggle__label {
|
||||
display: none;
|
||||
}
|
||||
.sx-inner-toggle__label--short {
|
||||
display: inline;
|
||||
}
|
||||
}
|
||||
.sx-inner-toggle {
|
||||
position: relative;
|
||||
width: 38px;
|
||||
height: 22px;
|
||||
flex: none;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border: none;
|
||||
background: transparent;
|
||||
padding: 0;
|
||||
cursor: pointer;
|
||||
}
|
||||
.sx-inner-toggle__track {
|
||||
position: relative;
|
||||
flex: none;
|
||||
width: 38px;
|
||||
height: 22px;
|
||||
background: var(--neutral-200);
|
||||
border-radius: var(--radius-pill);
|
||||
transition: background var(--dur-base) var(--ease-out);
|
||||
}
|
||||
.sx-inner-toggle__knob {
|
||||
position: absolute;
|
||||
top: 2px;
|
||||
left: 2px;
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
border-radius: 50%;
|
||||
background: var(--paper);
|
||||
box-shadow: var(--shadow-sm);
|
||||
transition: transform var(--dur-base) var(--ease-out);
|
||||
}
|
||||
.sx-inner-toggle.is-on .sx-inner-toggle__track {
|
||||
background: var(--accent-deep);
|
||||
}
|
||||
.sx-inner-toggle.is-on .sx-inner-toggle__knob {
|
||||
transform: translateX(16px);
|
||||
}
|
||||
.sx-inner-toggle:focus-visible {
|
||||
outline: none;
|
||||
}
|
||||
.sx-inner-toggle:focus-visible .sx-inner-toggle__track {
|
||||
box-shadow: 0 0 0 3px var(--focus-ring);
|
||||
}
|
||||
|
||||
.sx-signal__inner-reaction {
|
||||
order: 0;
|
||||
margin-bottom: 9px;
|
||||
}
|
||||
|
||||
.sx-cb-spacer {
|
||||
flex: 1;
|
||||
}
|
||||
|
|
@ -5223,6 +5341,10 @@
|
|||
.sx-transcript__live-dot.is-live {
|
||||
animation: none !important;
|
||||
}
|
||||
.sx-inner-toggle__track,
|
||||
.sx-inner-toggle__knob {
|
||||
transition-duration: 0.01ms !important;
|
||||
}
|
||||
}
|
||||
|
||||
/* ═══════════════════════════════════════════════════════════════════
|
||||
|
|
|
|||
|
|
@ -1,5 +1,6 @@
|
|||
import type { AvatarAffect, AvatarPersona, AvatarState } from "../../components/avatar/ClientAvatar";
|
||||
import type {
|
||||
ClientInnerReactionV1,
|
||||
LiveCoachQuota,
|
||||
PersonaSummary,
|
||||
SessionDetailResponse,
|
||||
|
|
@ -34,6 +35,8 @@ export interface Utterance {
|
|||
failed?: boolean;
|
||||
/** 실시간 음성 전사의 현재 확정 단계 */
|
||||
voiceTranscriptState?: "interim" | "finalizing";
|
||||
/** 내담자 발화(speaker="client")에 딸린 속마음 요약(§8.2). 학습자 발화엔 없다 */
|
||||
innerReaction?: ClientInnerReactionV1 | null;
|
||||
}
|
||||
|
||||
export interface PhaseInfo {
|
||||
|
|
@ -52,6 +55,9 @@ export interface ClientContext {
|
|||
export const AI_VOICE_DISCLOSURE =
|
||||
"내담자 음성은 AI가 생성한 합성 음성이며 사람의 목소리가 아닙니다.";
|
||||
|
||||
/** "속마음 보기" 스위치 상태 저장 키(§9 노출 계약) */
|
||||
export const INNER_REACTION_REVEAL_STORAGE_KEY = "vignette:inner-reaction-reveal:v1";
|
||||
|
||||
export const SESSION_PHASES: PhaseInfo[] = [
|
||||
{ key: "라포", desc: "첫 인사와 안전감 형성" },
|
||||
{ key: "탐색", desc: "호소 문제와 일상을 함께 이해" },
|
||||
|
|
|
|||
|
|
@ -1,4 +1,9 @@
|
|||
import type { CrisisResource, SessionProgress, SessionStage } from "../../lib/api";
|
||||
import type {
|
||||
ClientInnerReactionV1,
|
||||
CrisisResource,
|
||||
SessionProgress,
|
||||
SessionStage,
|
||||
} from "../../lib/api";
|
||||
|
||||
export type VoiceStatus =
|
||||
| "idle"
|
||||
|
|
@ -30,6 +35,7 @@ export interface VoiceEvent {
|
|||
crisis_resource?: CrisisResource | null;
|
||||
conversation_stopped?: boolean;
|
||||
progress?: SessionProgress | null;
|
||||
inner_reaction?: ClientInnerReactionV1 | null;
|
||||
}
|
||||
|
||||
export const VOICE_TURN_SAVE_FAILED =
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue