한신대 피드백 개선팩 반영

This commit is contained in:
Yun Chan 2026-07-03 19:53:14 +09:00
parent 5a9c110c11
commit 6b6241f468
25 changed files with 1247 additions and 94 deletions

View file

@ -46,6 +46,31 @@ async function routeSupervisorCapableLearner(page: Page) {
});
}
async function routeLearnerReviewUser(page: 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-000000000101",
email: "learner@hs.ac.kr",
role: "learner",
display_name: "E2E Learner",
admin_access: false,
super_admin: false,
account_status: "approved",
approval_required: false,
cohort_ids: [],
consent_at: 1782820000,
onboarding_completed_at: 1782820001,
nickname: "E2E Learner",
self_introduction: "",
avatar_url: "",
}),
});
});
}
function isReviewResponse(sessionId: string) {
return (response: Response) => {
const url = new URL(response.url());
@ -201,6 +226,19 @@ test.describe("session review", () => {
expect(reviewAttempts).toBeGreaterThanOrEqual(2);
});
test("shows supervisor evaluation scope boundaries", async ({ page }) => {
await routeSupervisorCapableLearner(page);
const sessionId = "supervisor-evaluation-scope";
await routeFilledSessionReview(page, sessionId);
await page.goto(`/teach/session/${sessionId}/review`);
await expect(page.getByText("AI 평가 범위", { exact: true })).toBeVisible();
await expect(page.getByText("현재 구현", { exact: true })).toBeVisible();
await expect(page.getByText(/fast-loop.*deep-loop/)).toBeVisible();
await expect(page.getByText("임상팀 확정 필요", { exact: true })).toBeVisible();
});
test("refreshes an ended review from evaluation pending to ready", async ({ page }) => {
await routeSupervisorCapableLearner(page);
await routePrepostMeasures(page);
@ -376,6 +414,10 @@ test.describe("session review", () => {
await page.goto(`/teach/session/${sessionId}/review`);
await expect(page.getByText("평가 실패")).toBeVisible();
await expect(
page.getByText("저장된 축어록은 확인했지만 deep-loop 평가 AI 산출물을 표시하지 못했습니다. AI 평가 재시도가 필요합니다."),
).toBeVisible();
await expect(page.getByText("engine_error: evaluator timeout")).toHaveCount(0);
await expect(page.getByRole("button", { name: "AI 평가 재시도" })).toBeVisible();
await expect(page.getByRole("button", { name: "검토 완료" })).toBeDisabled();
@ -438,7 +480,10 @@ test.describe("session review", () => {
await page.getByRole("button", { name: "AI 평가 재시도" }).click();
await expect(page.getByText("engine_error: retry timeout")).toBeVisible();
await expect(
page.getByText("AI 평가 재시도를 완료하지 못했습니다. 잠시 뒤 다시 실행해 주세요."),
).toBeVisible();
await expect(page.getByText("engine_error: retry timeout")).toHaveCount(0);
await expect(page.getByText("평가 실패")).toBeVisible();
await expect(page.getByRole("button", { name: "AI 평가 재시도" })).toBeEnabled();
expect(reevaluateRequests).toBe(1);
@ -538,6 +583,50 @@ test.describe("session review", () => {
await expectNoHorizontalOverflow(page);
});
test("keeps masked API transcript while naturalizing transcript placeholders", async ({
page,
}) => {
await routeLearnerReviewUser(page);
await routePrepostMeasures(page);
const sessionId = "review-placeholder-display";
const response = filledReviewResponse(sessionId);
response.turns[0] = {
...response.turns[0],
text: "저는 [NAME]이고 [ORG]에서 상담받고 있어요.",
};
if (!response.caseWorksheet) {
throw new Error("filled review fixture must include a worksheet");
}
response.caseWorksheet.sections[0].items[0].evidence = [
{
turnId: "t1",
speaker: "client",
quote: "저는 [NAME]이고 [ORG]에서 상담받고 있어요.",
},
];
await page.route(`**/api/sessions/${sessionId}/review`, async (route) => {
await route.fulfill({
status: 200,
contentType: "application/json",
body: JSON.stringify(response),
});
});
const reviewResponsePromise = page.waitForResponse(isReviewResponse(sessionId));
await page.goto(`/learn/session/${sessionId}/review`);
const reviewResponse = await reviewResponsePromise;
await expectResponseOk(reviewResponse);
const apiReview = await reviewResponse.json();
expect(apiReview.turns[0].text).toContain("[NAME]");
const transcript = page.locator(".sr-card--transcript");
await expect(transcript).toContainText(
"저는 익명 내담자이고 소속 기관에서 상담받고 있어요.",
);
await expect(transcript).not.toContainText("[NAME]");
await expect(page.locator(".sr-ws-evidence").filter({ hasText: "[NAME]" })).toBeVisible();
});
test("stores learner pre/post pilot evidence without effect claims", async ({ page }) => {
await signInAsLearner(page);
await routeFilledSessionReview(page);

View file

@ -21,6 +21,13 @@ function sleep(ms: number) {
});
}
let e2eEmailCounter = 0;
function uniqueE2EEmail(prefix: string, domain: string) {
e2eEmailCounter += 1;
return `${prefix}.${Date.now()}.${e2eEmailCounter}@${domain}`;
}
export async function withGlobalEngineConfigLock<T>(
label: string,
action: () => Promise<T>,
@ -64,7 +71,7 @@ export async function withGlobalEngineConfigLock<T>(
export async function signInAsLearner(page: Page) {
const res = await page.request.post("/api/auth/dev-login", {
data: {
email: "learner@hs.ac.kr",
email: uniqueE2EEmail("learner", "hs.ac.kr"),
role: "learner",
display_name: "E2E Learner",
},
@ -83,7 +90,7 @@ export async function signInAsLearner(page: Page) {
export async function signInAsTeacher(page: Page) {
const res = await page.request.post("/api/auth/dev-login", {
data: {
email: "teacher@hs.ac.kr",
email: uniqueE2EEmail("teacher", "hs.ac.kr"),
role: "teacher",
display_name: "E2E Teacher",
},

View file

@ -4006,6 +4006,8 @@ export interface components {
crisis_resource?: components["schemas"]["CrisisResourceResponse"] | null;
/** Effective Openness */
effective_openness: number;
/** Output Error */
output_error?: string | null;
/**
* Safety Flagged
* @default false

View file

@ -278,6 +278,7 @@ export interface SessionStreamDone {
crisis_kind?: string;
crisis_resource?: CrisisResource | null;
conversation_stopped?: boolean;
output_error?: string | null;
}
function safeParse(data: string): unknown {
@ -346,6 +347,7 @@ export async function openSessionStream(
crisis_kind: parsed.crisis_kind,
crisis_resource: parsed.crisis_resource,
conversation_stopped: parsed.conversation_stopped,
output_error: parsed.output_error,
};
handlers.onDone?.(donePayload);
return;

View file

@ -1503,6 +1503,11 @@ export default function Session() {
if (done.conversation_stopped || done.crisis_resource) {
conversationStopped = true;
}
const qualityRetryable = done.output_error === "client_reply_quality_retryable";
if (qualityRetryable) {
setTurnError("내담자 응답 품질을 확인하지 못했습니다. 발화를 조금 다듬어 다시 시도해 주세요.");
pushSignal("warn", "내담자 응답 재시도 필요");
}
if (clientId != null) {
const id = clientId;
@ -1528,12 +1533,12 @@ export default function Session() {
setUtterances((prev) =>
prev.map((u) => (u.id === learnerId ? { ...u, turnSeq: done.turn_seq } : u)),
);
if (!conversationStopped) {
if (!conversationStopped && !qualityRetryable) {
pushSignal("warn", "내담자 응답 없음");
}
}
setAvatarState("listening");
if (!conversationStopped) {
if (!conversationStopped && !qualityRetryable) {
void requestLiveCoach({
learnerText: text,
clientReply,

View file

@ -115,6 +115,35 @@ function renderSummary(summary: string) {
});
}
function displayReviewSummary(summary: string) {
if (/(?:사유:\s*)?(?:engine_error|session evaluation timeout|timeout|RuntimeError)/i.test(summary)) {
return "저장된 축어록은 확인했지만 deep-loop 평가 AI 산출물을 표시하지 못했습니다. AI 평가 재시도가 필요합니다.";
}
return summary;
}
function displayEvaluationRetryError(message: string) {
if (!message.trim()) return "AI 평가를 다시 실행하지 못했습니다.";
return "AI 평가 재시도를 완료하지 못했습니다. 잠시 뒤 다시 실행해 주세요.";
}
const REVIEW_DISPLAY_PLACEHOLDERS: Record<string, string> = {
"[NAME]": "익명 내담자",
"[ORG]": "소속 기관",
"[PHONE]": "연락처",
"[EMAIL]": "이메일",
"[RRN]": "주민등록번호",
"[ADDRESS]": "주소",
};
function displayTranscriptText(text: string) {
let next = text;
for (const [placeholder, label] of Object.entries(REVIEW_DISPLAY_PLACEHOLDERS)) {
next = next.split(placeholder).join(label);
}
return next;
}
function shouldPollReviewReady(data: SessionReviewResponse) {
return (
!data.reviewReady &&
@ -298,6 +327,27 @@ function SupervisorCallout({ note }: { note: ReviewNote }) {
);
}
function EvaluationScopeNotice() {
return (
<div className="sr-eval-scope" aria-label="AI 평가 범위 안내">
<div className="sr-eval-scope__title">
<Icon name="info" size={14} strokeWidth={2.2} />
<b>AI </b>
</div>
<dl>
<div>
<dt> </dt>
<dd>fast-loop는 , deep-loop는 .</dd>
</div>
<div>
<dt> </dt>
<dd> , , .</dd>
</div>
</dl>
</div>
);
}
function JumpablePoint({
point,
turns,
@ -838,13 +888,13 @@ export default function SessionReview() {
}
setReviewReloadSeq((seq) => seq + 1);
} catch (err) {
setEvaluationRetryError(
const message =
err instanceof ApiError
? err.detail
: err instanceof Error
? err.message
: "AI 평가를 다시 실행하지 못했습니다.",
);
: "";
setEvaluationRetryError(displayEvaluationRetryError(message));
} finally {
setEvaluationRetrying(false);
}
@ -1042,7 +1092,7 @@ export default function SessionReview() {
<Card className="sr-overview">
<Kicker> </Kicker>
<p className="sr-summary">
{renderSummary(data.summary)}
{renderSummary(displayReviewSummary(data.summary))}
</p>
<div className="sr-readiness" aria-label="리뷰 생성 상태">
<span>
@ -1250,7 +1300,7 @@ export default function SessionReview() {
(turn.speaker === "client" ? " sr-turn__said--client" : "")
}
>
{turn.text}
{displayTranscriptText(turn.text)}
</p>
{turn.note ? <SupervisorCallout note={turn.note} /> : null}
</div>
@ -1279,6 +1329,7 @@ export default function SessionReview() {
{teacherReviewStatusLabel}
</Badge>
</div>
<EvaluationScopeNotice />
<label className="sr-teacher-review__note">
<span> </span>
<textarea
@ -1491,7 +1542,7 @@ export default function SessionReview() {
<div className={`sr-feedback${data.clientFeedback ? " sr-feedback--filled" : ""}`}>
<div className="sr-feedback__kicker">
<span className="sr-technique__dot" aria-hidden="true" />
</div>
{data.clientFeedback ? (
<>

View file

@ -311,6 +311,45 @@
.sr-teacher-review__head--sub {
margin-bottom: var(--sp-2);
}
.sr-eval-scope {
display: grid;
gap: var(--sp-2);
margin-bottom: var(--sp-3);
padding: 10px 11px;
border: 1px solid var(--border-subtle);
border-radius: var(--radius-sm);
background: var(--bg-surface-2);
}
.sr-eval-scope__title {
display: inline-flex;
align-items: center;
gap: 7px;
color: var(--text-strong);
font-size: var(--fs-xs);
line-height: 1.35;
}
.sr-eval-scope dl {
display: grid;
gap: 7px;
margin: 0;
}
.sr-eval-scope dl div {
display: grid;
gap: 2px;
}
.sr-eval-scope dt {
color: var(--text-strong);
font-size: 11px;
font-weight: 740;
line-height: 1.35;
}
.sr-eval-scope dd {
margin: 0;
color: var(--text-muted);
font-size: 11px;
line-height: 1.5;
overflow-wrap: anywhere;
}
.sr-teacher-review__worksheet {
margin-top: var(--sp-4);
padding-top: var(--sp-4);
@ -1468,6 +1507,7 @@
[data-theme="dark"] .sr-readiness span,
[data-theme="dark"] .sr-empty,
[data-theme="dark"] .sr-chip-toggle,
[data-theme="dark"] .sr-eval-scope,
[data-theme="dark"] .sr-ws-item,
[data-theme="dark"] .sr-prepost__field input,
[data-theme="dark"] .sr-feedback,

View file

@ -906,6 +906,8 @@
}
.sx-transcript__scroll {
overflow-y: auto;
overflow-x: hidden;
scrollbar-width: none;
max-height: none;
flex: 1;
min-height: 160px;
@ -913,6 +915,10 @@
display: flex;
flex-direction: column;
}
.sx-transcript__scroll::-webkit-scrollbar {
width: 0;
height: 0;
}
.sx-transcript__empty {
min-height: 100%;
display: grid;
@ -3686,6 +3692,9 @@
.sx-mobile-context__row span {
padding: 7px 6px;
}
.sx-page--active .sx-transcript__scroll {
min-height: 152px;
}
.sx-page--active .sx-controlbar {
padding: 8px;
gap: 8px;
@ -3833,6 +3842,25 @@
.sx-page--active .sx-stage__now {
display: none;
}
.sx-page--active .sx-transcript__scroll {
min-height: 110px;
}
.sx-page--active .sx-transcript__empty {
grid-template-columns: minmax(0, 1fr);
gap: 2px;
padding: 2px 0;
}
.sx-page--active .sx-transcript__empty-dot {
display: none;
}
.sx-page--active .sx-transcript__empty b {
font-size: 12px;
line-height: 1.25;
}
.sx-page--active .sx-transcript__empty span {
font-size: 11px;
line-height: 1.25;
}
.sx-page--active .sx-mic-block__ms {
display: none;
}
@ -3900,14 +3928,18 @@
height: 58px !important;
}
.sx-page--active .sx-transcript {
padding: 6px 8px;
padding: 3px 6px;
}
.sx-page--active .sx-transcript__head {
margin-bottom: 4px;
margin-bottom: 2px;
}
.sx-page--active .sx-transcript__empty {
gap: 8px;
padding: 8px 6px;
}
.sx-page--active .sx-compose {
margin-top: 5px;
padding-top: 5px;
margin-top: 3px;
padding-top: 3px;
}
.sx-page--active .sx-compose textarea {
min-height: 36px;