회기 후속 검증과 표시 경계 보강

This commit is contained in:
Yun Chan 2026-08-10 01:55:21 +09:00
parent 4cd354881f
commit aa81af2958
10 changed files with 70 additions and 21 deletions

View file

@ -238,7 +238,7 @@ test.describe("returned-practice browser-only DB closed loop", () => {
const firstButton = card.getByRole("button", {
name: isDesktop(testInfo)
? "이번 회기를 독립 관찰로 반영"
: "이번 회기를 독립 관찰로 반영",
: "반영 상태 다시 확인",
});
const postUrl = `/api/practice/${encodeURIComponent(

View file

@ -625,6 +625,18 @@ test.describe("session review", () => {
};
response.summary =
"평가 AI는 [NAME]가 [ORG]에서 보인 조심스러운 반응을 분석했습니다.";
response.goodMoments[0] = {
...response.goodMoments[0],
title: "[NAME]가 보인 강점",
body: "[ORG] 장면에서 내담자 [NAME]을 기다렸습니다.",
};
response.growthPoints[0] = {
...response.growthPoints[0],
title: "[NAME] 반응을 서두르지 않기",
body: "[ORG] 장면에서 내담자 [NAME]을 한 번 더 확인합니다.",
};
response.nextLine = "[NAME]의 말을 [ORG] 맥락에서 다시 확인할까요?";
response.clientFeedback = "[NAME] 이야기를 [ORG]에서 안전하게 다뤘어요.";
if (!response.caseWorksheet) {
throw new Error("filled review fixture must include a worksheet");
}
@ -661,8 +673,25 @@ test.describe("session review", () => {
);
await expect(summary).not.toContainText("[NAME]");
await expect(summary).not.toContainText("[ORG]");
await openReviewTab(page, "피드백");
const feedback = page.locator("#sr-panel-feedback");
await expect(feedback).toContainText("익명 내담자가 보인 강점");
await expect(feedback).toContainText("소속 기관 장면에서 내담자 반응을 기다렸습니다.");
await expect(feedback).toContainText(
"익명 내담자의 말을 소속 기관 맥락에서 다시 확인할까요?",
);
await expect(feedback).not.toContainText("[NAME]");
await expect(feedback).not.toContainText("[ORG]");
await openReviewTab(page, "워크시트");
await expect(page.locator(".sr-ws-evidence").filter({ hasText: "[NAME]" })).toBeVisible();
const worksheetPanel = page.locator("#sr-panel-worksheet");
const worksheetEvidence = worksheetPanel.locator(".sr-ws-evidence").filter({
hasText: "저는 익명 내담자이고 소속 기관에서 상담받고 있어요.",
});
await expect(worksheetEvidence).toContainText(
"저는 익명 내담자이고 소속 기관에서 상담받고 있어요.",
);
await expect(worksheetPanel).not.toContainText("[NAME]");
await expect(worksheetPanel).not.toContainText("[ORG]");
});
test("stores learner pre/post pilot evidence without effect claims", async ({ page }) => {

View file

@ -54,6 +54,15 @@ export function displayPiiSafeText(text: string) {
/\[NAME\]는 건지\s+잘 \[NAME\]는데요/g,
"되는 건지 잘 모르겠는데요",
);
// 평가 문장에 이미 역할 명사가 있으면 이름 토큰을 다시 "익명 내담자"로
// 풀지 않는다. 특히 "내담자 [NAME]을 받을 여백"은 저장 마스킹 뒤
// 사람에게 "내담자 반응을 받을 여백"으로 보여야 한다.
next = next
.replace(/내담자\s+\[NAME\](?:을|를)/g, "내담자 반응을")
.replace(/내담자\s+\[NAME\](?:이|가)/g, "내담자가")
.replace(/내담자\s+\[NAME\](?:은|는)/g, "내담자는")
.replace(/내담자\s+\[NAME\]의/g, "내담자의")
.replace(/내담자\s+\[NAME\]/g, "내담자");
for (const [placeholder, label] of Object.entries(DISPLAY_PLACEHOLDERS)) {
next = replacePlaceholderWithNaturalParticle(next, placeholder, label);
}

View file

@ -67,6 +67,7 @@ import {
REVIEW_READY_POLL_INTERVAL_MS,
REVIEW_READY_POLL_LIMIT,
displayEvaluationRetryError,
displayGeneratedReviewText,
displayReviewSummary,
displayTranscriptText,
noteAuthorLabel,
@ -367,15 +368,15 @@ function SupervisorCallout({ note }: { note: ReviewNote }) {
>
<div className="sr-note__head">
<Icon name={iconName} size={13} strokeWidth={2.2} />
{note.title}
{displayGeneratedReviewText(note.title)}
<span className="sr-note__tag">{tag}</span>
</div>
<div className="sr-note__body">
<ReviewMarkdown text={note.body} />
<ReviewMarkdown text={displayGeneratedReviewText(note.body)} />
{note.quote ? (
<blockquote className="sr-note__quote">
<span> </span>
<p>{note.quote}</p>
<p>{displayGeneratedReviewText(note.quote)}</p>
</blockquote>
) : null}
</div>
@ -430,9 +431,11 @@ function JumpablePoint({
return (
<div className="sr-point">
<div>
<div className="sr-point__h">{point.title}</div>
<div className="sr-point__h">
{displayGeneratedReviewText(point.title)}
</div>
<div className="sr-point__d">
{point.body}
{displayGeneratedReviewText(point.body)}
{point.jumpTo && targetTs ? (
<button
type="button"
@ -619,7 +622,7 @@ function CaseWorksheetCard({
onClick={() => onJump(evidence.turnId)}
>
{evidence.speaker === "learner" ? "학습자" : "내담자"}{" "}
· {evidence.quote}
· {displayGeneratedReviewText(evidence.quote)}
</button>
) : null}
</div>
@ -2026,7 +2029,9 @@ export default function SessionReview() {
})}
>
<div className="sr-nextline__lab"> </div>
<div className="sr-nextline__q">{data.nextLine}</div>
<div className="sr-nextline__q">
{displayGeneratedReviewText(data.nextLine)}
</div>
</div>
) : null}
</Card>
@ -2044,7 +2049,7 @@ export default function SessionReview() {
{data.clientFeedback ? (
<>
<p className="sr-feedback__quote">
&ldquo;{data.clientFeedback}&rdquo;
&ldquo;{displayGeneratedReviewText(data.clientFeedback)}&rdquo;
</p>
<div className="sr-feedback__src">
<Icon name="info" size={13} strokeWidth={2} />

View file

@ -125,7 +125,11 @@ export function displayReviewSummary(summary: string) {
) {
return "저장된 축어록은 확인했지만 deep-loop 평가 AI 산출물을 표시하지 못했습니다. AI 평가 재시도가 필요합니다.";
}
return displayPiiSafeText(summary);
return displayGeneratedReviewText(summary);
}
export function displayGeneratedReviewText(text: string) {
return displayPiiSafeText(text);
}
export function displayEvaluationRetryError(message: string) {