세션 평가와 교수자 분석 보강
This commit is contained in:
parent
5c4ac04e06
commit
fe2796f05a
51 changed files with 4928 additions and 240 deletions
336
apps/web/e2e/dev-dashboard.spec.ts
Normal file
336
apps/web/e2e/dev-dashboard.spec.ts
Normal file
|
|
@ -0,0 +1,336 @@
|
|||
import http, { type Server } from "node:http";
|
||||
import { promises as fs } from "node:fs";
|
||||
import { AddressInfo } from "node:net";
|
||||
import path from "node:path";
|
||||
import { pathToFileURL } from "node:url";
|
||||
import { expect, test, type Page } from "@playwright/test";
|
||||
import { expectNoHorizontalOverflow } from "./support";
|
||||
|
||||
const repoRoot = path.resolve(process.cwd(), "..", "..");
|
||||
const dashboardPath = path.join(repoRoot, "docs", "dev_dashboard.html");
|
||||
|
||||
interface StaticServer {
|
||||
port: number;
|
||||
close: () => Promise<void>;
|
||||
}
|
||||
|
||||
interface DashboardMetrics {
|
||||
total: number;
|
||||
done: number;
|
||||
doing: number;
|
||||
planned: number;
|
||||
ownerCards: number;
|
||||
ownerBoard: number;
|
||||
crit: number;
|
||||
ui: Record<string, string>;
|
||||
ownerColumns: Record<string, number>;
|
||||
visibleCards: number;
|
||||
visibleGroups: number;
|
||||
phaseSegments: number;
|
||||
trackRows: number;
|
||||
trackNames: string[];
|
||||
stackSegments: string[];
|
||||
}
|
||||
|
||||
async function startStaticServer(root: string): Promise<StaticServer> {
|
||||
const server: Server = http.createServer(async (req, res) => {
|
||||
try {
|
||||
const requestUrl = new URL(req.url ?? "/", "http://127.0.0.1");
|
||||
const requestPath =
|
||||
requestUrl.pathname === "/" ? "/docs/dev_dashboard.html" : decodeURIComponent(requestUrl.pathname);
|
||||
const normalized = path.normalize(requestPath).replace(/^([/\\])+/, "");
|
||||
const filePath = path.join(root, normalized);
|
||||
const relative = path.relative(root, filePath);
|
||||
if (relative.startsWith("..") || path.isAbsolute(relative)) {
|
||||
res.writeHead(403).end("Forbidden");
|
||||
return;
|
||||
}
|
||||
|
||||
const body = await fs.readFile(filePath);
|
||||
res.writeHead(200, { "content-type": contentType(filePath) });
|
||||
res.end(body);
|
||||
} catch {
|
||||
res.writeHead(404).end("Not found");
|
||||
}
|
||||
});
|
||||
|
||||
await new Promise<void>((resolve) => server.listen(0, resolve));
|
||||
const address = server.address();
|
||||
if (!address || typeof address === "string") {
|
||||
throw new Error("Static dashboard server did not expose a TCP port");
|
||||
}
|
||||
return {
|
||||
port: (address as AddressInfo).port,
|
||||
close: () => new Promise<void>((resolve, reject) => server.close((err) => (err ? reject(err) : resolve()))),
|
||||
};
|
||||
}
|
||||
|
||||
function contentType(filePath: string) {
|
||||
const ext = path.extname(filePath).toLowerCase();
|
||||
if (ext === ".html") return "text/html; charset=utf-8";
|
||||
if (ext === ".css") return "text/css; charset=utf-8";
|
||||
if (ext === ".js" || ext === ".mjs") return "text/javascript; charset=utf-8";
|
||||
if (ext === ".png") return "image/png";
|
||||
if (ext === ".svg") return "image/svg+xml";
|
||||
return "application/octet-stream";
|
||||
}
|
||||
|
||||
async function openDashboard(page: Page, url: string) {
|
||||
const errors: string[] = [];
|
||||
page.on("console", (msg) => {
|
||||
if (msg.type() === "error") errors.push(msg.text());
|
||||
});
|
||||
page.on("pageerror", (err) => errors.push(err.message));
|
||||
|
||||
const response = await page.goto(url, { waitUntil: "domcontentloaded" });
|
||||
if (url.startsWith("http")) {
|
||||
expect(response?.ok(), `${url} should load over HTTP`).toBeTruthy();
|
||||
}
|
||||
await expect(page.locator("#phase-rail .pr-seg")).toHaveCount(5);
|
||||
expect(errors).toEqual([]);
|
||||
}
|
||||
|
||||
async function readMetrics(page: Page): Promise<DashboardMetrics> {
|
||||
return await page.evaluate(() => {
|
||||
const cards = Array.from(document.querySelectorAll<HTMLElement>(".scard"));
|
||||
const statusCount = (status: string) => cards.filter((card) => card.dataset.status === status).length;
|
||||
const text = (selector: string) => document.querySelector(selector)?.textContent?.trim() ?? "";
|
||||
const countOwnerColumn = (column: string) =>
|
||||
document.querySelector(`[data-owner-col="${column}"]`)?.querySelectorAll('.ocard[data-owner="1"]').length ?? 0;
|
||||
|
||||
return {
|
||||
total: cards.length,
|
||||
done: statusCount("done"),
|
||||
doing: statusCount("doing"),
|
||||
planned: cards.length - statusCount("done") - statusCount("doing"),
|
||||
ownerCards: cards.filter((card) => card.dataset.owner === "1").length,
|
||||
ownerBoard: document.querySelectorAll('.ocard[data-owner="1"]').length,
|
||||
crit: cards.filter((card) => card.querySelector(".critbadge")).length,
|
||||
ui: {
|
||||
kpiDone: text("#kpi-done"),
|
||||
kpiDoing: text("#kpi-doing"),
|
||||
kpiPlan: text("#kpi-plan"),
|
||||
kpiOwner: text("#kpi-owner"),
|
||||
pillOwner: text("#pill-owner"),
|
||||
total: text("#g-total"),
|
||||
legendDone: text("#lg-done"),
|
||||
legendDoing: text("#lg-doing"),
|
||||
legendPlan: text("#lg-plan"),
|
||||
filterAll: text('[data-count="all"]'),
|
||||
filterDone: text('[data-count="done"]'),
|
||||
filterDoing: text('[data-count="doing"]'),
|
||||
filterPlanned: text('[data-count="planned"]'),
|
||||
filterOwner: text('[data-count="owner"]'),
|
||||
filterCrit: text('[data-count="crit"]'),
|
||||
},
|
||||
ownerColumns: {
|
||||
block: countOwnerColumn("block"),
|
||||
decide: countOwnerColumn("decide"),
|
||||
ext: countOwnerColumn("ext"),
|
||||
},
|
||||
visibleCards: cards.filter((card) => !card.classList.contains("hide")).length,
|
||||
visibleGroups: Array.from(document.querySelectorAll<HTMLElement>("#board .track-group")).filter(
|
||||
(group) => getComputedStyle(group).display !== "none",
|
||||
).length,
|
||||
phaseSegments: document.querySelectorAll("#phase-rail .pr-seg").length,
|
||||
trackRows: document.querySelectorAll("#tracks .track").length,
|
||||
trackNames: Array.from(document.querySelectorAll("#tracks .track .tname")).map(
|
||||
(el) => el.textContent?.trim() ?? "",
|
||||
),
|
||||
stackSegments: Array.from(document.querySelectorAll("#stackbar i")).map((el) => el.textContent?.trim() ?? ""),
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
function expectDerivedMetrics(metrics: DashboardMetrics) {
|
||||
expect(metrics.total).toBeGreaterThan(0);
|
||||
expect(metrics.done).toBe(metrics.total);
|
||||
expect(metrics.doing).toBe(0);
|
||||
expect(metrics.planned).toBe(0);
|
||||
expect(metrics.ownerBoard).toBe(
|
||||
metrics.ownerColumns.block + metrics.ownerColumns.decide + metrics.ownerColumns.ext,
|
||||
);
|
||||
expect(metrics.ownerBoard).toBeGreaterThan(0);
|
||||
expect(metrics.crit).toBe(3);
|
||||
expect(metrics.ui).toMatchObject({
|
||||
kpiDone: String(metrics.done),
|
||||
kpiDoing: String(metrics.doing),
|
||||
kpiPlan: String(metrics.planned),
|
||||
kpiOwner: String(metrics.ownerBoard),
|
||||
pillOwner: String(metrics.ownerBoard),
|
||||
total: String(metrics.total),
|
||||
legendDone: String(metrics.done),
|
||||
legendDoing: String(metrics.doing),
|
||||
legendPlan: String(metrics.planned),
|
||||
filterAll: String(metrics.total),
|
||||
filterDone: String(metrics.done),
|
||||
filterDoing: String(metrics.doing),
|
||||
filterPlanned: String(metrics.planned),
|
||||
filterOwner: String(metrics.ownerCards),
|
||||
filterCrit: String(metrics.crit),
|
||||
});
|
||||
expect(metrics.phaseSegments).toBe(5);
|
||||
expect(metrics.trackRows).toBeGreaterThan(1);
|
||||
expect(metrics.trackNames).not.toContain("");
|
||||
expect(metrics.stackSegments).toEqual([String(metrics.done)]);
|
||||
}
|
||||
|
||||
async function expectVisibleCards(page: Page, expected: number) {
|
||||
await expect
|
||||
.poll(async () => (await readMetrics(page)).visibleCards, { message: `expected ${expected} visible cards` })
|
||||
.toBe(expected);
|
||||
}
|
||||
|
||||
test.describe.configure({ mode: "serial" });
|
||||
|
||||
test.describe("dev dashboard static command center", () => {
|
||||
let staticServer: StaticServer;
|
||||
|
||||
test.beforeAll(async () => {
|
||||
staticServer = await startStaticServer(repoRoot);
|
||||
});
|
||||
|
||||
test.afterAll(async () => {
|
||||
await staticServer.close();
|
||||
});
|
||||
|
||||
test("derives counters and graphs on local file and HTTP origins", async ({ page }) => {
|
||||
const urls = [
|
||||
pathToFileURL(dashboardPath).toString(),
|
||||
`http://127.0.0.1:${staticServer.port}/docs/dev_dashboard.html`,
|
||||
`http://localhost:${staticServer.port}/docs/dev_dashboard.html`,
|
||||
];
|
||||
|
||||
for (const url of urls) {
|
||||
await openDashboard(page, url);
|
||||
expectDerivedMetrics(await readMetrics(page));
|
||||
}
|
||||
});
|
||||
|
||||
test("keeps filters stable under empty states, wrong order, and rapid retries", async ({ page }) => {
|
||||
await openDashboard(page, `http://127.0.0.1:${staticServer.port}/docs/dev_dashboard.html`);
|
||||
const initial = await readMetrics(page);
|
||||
expectDerivedMetrics(initial);
|
||||
|
||||
await page.locator('[data-filter="planned"]').click();
|
||||
await expectVisibleCards(page, 0);
|
||||
await expect(page.locator("#board .track-group").first()).not.toBeVisible();
|
||||
await page.locator('[data-filter="done"]').focus();
|
||||
await page.keyboard.press("Enter");
|
||||
await expect(page.locator('[data-filter="done"]')).toHaveAttribute("aria-pressed", "true");
|
||||
await expectVisibleCards(page, initial.done);
|
||||
await page.locator('[data-filter="planned"]').focus();
|
||||
await page.keyboard.press("Space");
|
||||
await expect(page.locator('[data-filter="planned"]')).toHaveAttribute("aria-pressed", "true");
|
||||
await expectVisibleCards(page, 0);
|
||||
|
||||
const rapidSequence = ["all", "crit", "doing", "owner", "planned", "done", "all", "crit", "all"];
|
||||
await page.evaluate((filters) => {
|
||||
for (const filter of filters) {
|
||||
document
|
||||
.querySelector<HTMLElement>(`[data-filter="${filter}"]`)
|
||||
?.dispatchEvent(new MouseEvent("click", { bubbles: true, cancelable: true }));
|
||||
}
|
||||
}, rapidSequence);
|
||||
|
||||
await expect(page.locator('[data-filter="all"]')).toHaveAttribute("aria-pressed", "true");
|
||||
await expectVisibleCards(page, initial.total);
|
||||
expectDerivedMetrics(await readMetrics(page));
|
||||
|
||||
const firstCard = page.locator(".scard").first();
|
||||
const firstHead = firstCard.locator(".scard-head");
|
||||
await firstHead.click();
|
||||
await expect(firstHead).toHaveAttribute("aria-expanded", "true");
|
||||
await expect(firstCard).toHaveClass(/open/);
|
||||
await page.locator('[data-filter="planned"]').click();
|
||||
await expectVisibleCards(page, 0);
|
||||
await page.locator('[data-filter="all"]').click();
|
||||
await expect(firstHead).toHaveAttribute("aria-expanded", "true");
|
||||
await firstHead.click();
|
||||
await expect(firstHead).toHaveAttribute("aria-expanded", "false");
|
||||
});
|
||||
|
||||
test("keeps local links, images, and hash anchors intact", async ({ page }) => {
|
||||
const dashboardUrl = `http://127.0.0.1:${staticServer.port}/docs/dev_dashboard.html`;
|
||||
await openDashboard(page, dashboardUrl);
|
||||
|
||||
await page.locator(".owner-pill").click();
|
||||
await expect(page).toHaveURL(/#owner$/);
|
||||
await expect(page.locator("#owner")).toBeInViewport();
|
||||
await page.locator(".kpi.k-done").click();
|
||||
await expect(page).toHaveURL(/#board$/);
|
||||
await expect(page.locator("#board")).toBeInViewport();
|
||||
|
||||
const references = await page.evaluate(() =>
|
||||
Array.from(document.querySelectorAll<HTMLAnchorElement | HTMLImageElement | HTMLLinkElement>("[href],[src]"))
|
||||
.map((el) => ({
|
||||
tag: el.tagName.toLowerCase(),
|
||||
value: el.getAttribute("href") ?? el.getAttribute("src") ?? "",
|
||||
}))
|
||||
.filter((item) => item.value.length > 0),
|
||||
);
|
||||
|
||||
for (const reference of references) {
|
||||
if (reference.value.startsWith("#")) {
|
||||
await expect(page.locator(reference.value)).toHaveCount(1);
|
||||
continue;
|
||||
}
|
||||
const resolved = new URL(reference.value, dashboardUrl);
|
||||
if (resolved.origin !== new URL(dashboardUrl).origin) {
|
||||
expect(["http:", "https:"], `${reference.tag} ${reference.value}`).toContain(resolved.protocol);
|
||||
continue;
|
||||
}
|
||||
const response = await page.request.get(resolved.toString());
|
||||
expect(response.ok(), `${reference.tag} ${reference.value} should resolve`).toBeTruthy();
|
||||
}
|
||||
});
|
||||
|
||||
test("supports tab click and keyboard navigation after repeated opening", async ({ page }) => {
|
||||
await openDashboard(page, `http://127.0.0.1:${staticServer.port}/docs/dev_dashboard.html`);
|
||||
for (const foldId of ["fold-roadmap", "fold-tabs", "fold-narrative"]) {
|
||||
const fold = page.locator(`#${foldId}`);
|
||||
const summary = fold.locator("summary");
|
||||
await summary.click();
|
||||
await expect(fold).toHaveJSProperty("open", true);
|
||||
await summary.click();
|
||||
await expect(fold).toHaveJSProperty("open", false);
|
||||
await summary.click();
|
||||
await expect(fold).toHaveJSProperty("open", true);
|
||||
}
|
||||
|
||||
const tabs = page.locator('[role="tab"][data-tab]');
|
||||
const tabCount = await tabs.count();
|
||||
expect(tabCount).toBeGreaterThan(3);
|
||||
|
||||
for (let index = tabCount - 1; index >= 0; index -= 1) {
|
||||
const tab = tabs.nth(index);
|
||||
const panelId = await tab.getAttribute("aria-controls");
|
||||
expect(panelId).toBeTruthy();
|
||||
await tab.click();
|
||||
await expect(tab).toHaveAttribute("aria-selected", "true");
|
||||
await expect(page.locator(`#${panelId}`)).toBeVisible();
|
||||
await expect(page.locator('[role="tabpanel"]:not([hidden])')).toHaveCount(1);
|
||||
}
|
||||
|
||||
await tabs.first().focus();
|
||||
await page.keyboard.press("End");
|
||||
await expect(tabs.nth(tabCount - 1)).toHaveAttribute("aria-selected", "true");
|
||||
await page.keyboard.press("Home");
|
||||
await expect(tabs.first()).toHaveAttribute("aria-selected", "true");
|
||||
await page.keyboard.press("ArrowRight");
|
||||
await expect(tabs.nth(1)).toHaveAttribute("aria-selected", "true");
|
||||
await page.keyboard.press("ArrowLeft");
|
||||
await expect(tabs.first()).toHaveAttribute("aria-selected", "true");
|
||||
});
|
||||
|
||||
test("does not overflow at desktop and mobile widths in the simulated external origin", async ({ page }) => {
|
||||
for (const viewport of [
|
||||
{ width: 1440, height: 900 },
|
||||
{ width: 390, height: 844 },
|
||||
]) {
|
||||
await page.setViewportSize(viewport);
|
||||
await openDashboard(page, `http://localhost:${staticServer.port}/docs/dev_dashboard.html`);
|
||||
await expectNoHorizontalOverflow(page);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
|
@ -38,6 +38,93 @@ const GATE_WIDTHS = [
|
|||
] as const;
|
||||
|
||||
const SHOT_DIR = path.join(process.cwd(), "node_modules", ".tmp", "layout-gate");
|
||||
const TEACHER_REVIEW_SESSION_ID = "teacher-review-visual";
|
||||
|
||||
function teacherAnalysisFixture() {
|
||||
const learnerId = "visual-analysis-learner";
|
||||
const sessions = Array.from({ length: 5 }, (_unused, index) => {
|
||||
const sessionNo = index + 1;
|
||||
return {
|
||||
session_id: `${learnerId}-session-${sessionNo}`,
|
||||
learner_id: learnerId,
|
||||
learner_label: "분석 검증 학습자",
|
||||
persona_code: sessionNo % 2 === 0 ? "P2" : "P1",
|
||||
persona_name: sessionNo % 2 === 0 ? "민재" : "서연",
|
||||
session_no: sessionNo,
|
||||
status: "ended",
|
||||
stage: sessionNo >= 4 ? "개입" : "탐색",
|
||||
turn_count: 8 + sessionNo,
|
||||
learner_turn_count: 4 + sessionNo,
|
||||
client_turn_count: 4,
|
||||
started_at: `2026-06-${String(10 + sessionNo).padStart(2, "0")}T09:00:00Z`,
|
||||
ended_at: `2026-06-${String(10 + sessionNo).padStart(2, "0")}T09:30:00Z`,
|
||||
review_status: sessionNo === 3 ? "closed" : "pending",
|
||||
review_note: null,
|
||||
reviewed_at: sessionNo === 3 ? "2026-06-13T10:00:00Z" : null,
|
||||
};
|
||||
});
|
||||
const points = sessions.map((session) => ({
|
||||
session_id: session.session_id,
|
||||
session_no: session.session_no,
|
||||
persona_code: session.persona_code,
|
||||
stage: session.stage,
|
||||
started_at: session.started_at,
|
||||
ended_at: session.ended_at,
|
||||
score: session.session_no >= 4 ? 0.88 : 0.52,
|
||||
rapport: Math.min(0.8, session.session_no * 0.12),
|
||||
technique_count: session.session_no + 1,
|
||||
watch_count: session.session_no < 4 ? 1 : 0,
|
||||
}));
|
||||
const summary = {
|
||||
learner_id: learnerId,
|
||||
learner_label: "분석 검증 학습자",
|
||||
sessions: sessions.length,
|
||||
ended_sessions: sessions.length,
|
||||
latest_at: sessions[sessions.length - 1].ended_at ?? "",
|
||||
first_score: points[0].score,
|
||||
latest_score: points[points.length - 1].score,
|
||||
score_delta: (points[points.length - 1].score ?? 0) - (points[0].score ?? 0),
|
||||
avg_score: 0.72,
|
||||
avg_rapport: 0.44,
|
||||
trend: "up",
|
||||
top_techniques: ["reflection", "summary"],
|
||||
points,
|
||||
};
|
||||
|
||||
return {
|
||||
dashboard: {
|
||||
source: "database",
|
||||
cohort_label: "Visual cohort",
|
||||
total_learners: 1,
|
||||
active_sessions: 0,
|
||||
ended_sessions: sessions.length,
|
||||
learner_growth: [{ ...summary, points: points.slice(-4) }],
|
||||
safety_alerts: [],
|
||||
pending_reviews: [],
|
||||
recent_sessions: sessions.slice(-2),
|
||||
message: "학생 분석 시각 검증 fixture.",
|
||||
},
|
||||
analysis: {
|
||||
source: "database",
|
||||
learner_id: learnerId,
|
||||
learner_label: "분석 검증 학습자",
|
||||
total_sessions: sessions.length,
|
||||
ended_sessions: sessions.length,
|
||||
active_sessions: 0,
|
||||
pending_reviews: 2,
|
||||
summary,
|
||||
points,
|
||||
stage_breakdown: [
|
||||
{ stage: "라포", sessions: 0, turns: 0 },
|
||||
{ stage: "탐색", sessions: 3, turns: 31 },
|
||||
{ stage: "개입", sessions: 2, turns: 22 },
|
||||
{ stage: "정리", sessions: 0, turns: 0 },
|
||||
],
|
||||
sessions,
|
||||
message: "분석 검증 학습자 전체 회기 5건",
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
async function ensureShotDir() {
|
||||
await fs.mkdir(SHOT_DIR, { recursive: true });
|
||||
|
|
@ -284,6 +371,76 @@ async function expectFilledReviewLearnerWorkbench(page: Page) {
|
|||
}
|
||||
}
|
||||
|
||||
async function expectSupervisorReviewNoDeadGaps(page: Page) {
|
||||
const report = await page.evaluate(() => {
|
||||
const cols = document.querySelector<HTMLElement>(".sr-cols--supervisor");
|
||||
const left = document.querySelector<HTMLElement>(".sr-cols--supervisor .sr-left");
|
||||
const right = document.querySelector<HTMLElement>(".sr-cols--supervisor .sr-right");
|
||||
if (!cols || !left || !right) {
|
||||
return {
|
||||
present: false,
|
||||
viewportWidth: window.innerWidth,
|
||||
columnCount: 0,
|
||||
leftDisplay: "",
|
||||
rightDisplay: "",
|
||||
maxMainGap: 0,
|
||||
maxSideGap: 0,
|
||||
};
|
||||
}
|
||||
|
||||
function maxVerticalGap(container: HTMLElement) {
|
||||
const intervals = Array.from(container.children)
|
||||
.map((child) => child.getBoundingClientRect())
|
||||
.filter((rect) => rect.width > 0 && rect.height > 0)
|
||||
.map((rect) => ({ top: rect.top, bottom: rect.bottom }))
|
||||
.sort((a, b) => a.top - b.top || a.bottom - b.bottom);
|
||||
if (intervals.length < 2) return 0;
|
||||
|
||||
let maxGap = 0;
|
||||
let currentBottom = intervals[0].bottom;
|
||||
for (let index = 1; index < intervals.length; index += 1) {
|
||||
const next = intervals[index];
|
||||
if (next.top <= currentBottom) {
|
||||
currentBottom = Math.max(currentBottom, next.bottom);
|
||||
continue;
|
||||
}
|
||||
const gap = next.top - currentBottom;
|
||||
if (gap > maxGap) maxGap = gap;
|
||||
currentBottom = next.bottom;
|
||||
}
|
||||
return Math.round(maxGap);
|
||||
}
|
||||
|
||||
const colsStyle = getComputedStyle(cols);
|
||||
const leftStyle = getComputedStyle(left);
|
||||
const rightStyle = getComputedStyle(right);
|
||||
return {
|
||||
present: true,
|
||||
viewportWidth: window.innerWidth,
|
||||
columnCount: colsStyle.gridTemplateColumns.split(" ").filter(Boolean).length,
|
||||
leftDisplay: leftStyle.display,
|
||||
rightDisplay: rightStyle.display,
|
||||
maxMainGap: maxVerticalGap(left),
|
||||
maxSideGap: maxVerticalGap(right),
|
||||
};
|
||||
});
|
||||
|
||||
expect(report.present, "supervisor review layout should be mounted").toBe(true);
|
||||
if (report.viewportWidth > 1180) {
|
||||
expect(report.columnCount, "desktop supervisor review should use main + review rail").toBe(2);
|
||||
expect(report.leftDisplay).toBe("grid");
|
||||
expect(report.rightDisplay).toBe("grid");
|
||||
expect(
|
||||
report.maxMainGap,
|
||||
"desktop supervisor review main column should not leave a dead vertical void",
|
||||
).toBeLessThanOrEqual(40);
|
||||
expect(
|
||||
report.maxSideGap,
|
||||
"desktop supervisor review side rail should not leave a dead vertical void",
|
||||
).toBeLessThanOrEqual(40);
|
||||
}
|
||||
}
|
||||
|
||||
test.describe("layout visual gate @single-run", () => {
|
||||
test.describe.configure({ mode: "serial" });
|
||||
|
||||
|
|
@ -367,6 +524,18 @@ test.describe("layout visual gate @single-run", () => {
|
|||
});
|
||||
});
|
||||
|
||||
test("professor session review avoids dead vertical gaps across all widths", async ({ page }) => {
|
||||
await signInAsTeacher(page);
|
||||
await routeFilledSessionReview(page, TEACHER_REVIEW_SESSION_ID);
|
||||
await page.goto(`/teach/session/${TEACHER_REVIEW_SESSION_ID}/review`);
|
||||
await gateScreen(page, "session-review-professor", async () => {
|
||||
await expect(page.locator(".sr-cols--supervisor")).toBeVisible({ timeout: 15_000 });
|
||||
await expect(page.getByText("교수자 검토", { exact: true })).toBeVisible();
|
||||
await expect(page.getByText("세션 트랜스크립트")).toBeVisible();
|
||||
await expectSupervisorReviewNoDeadGaps(page);
|
||||
});
|
||||
});
|
||||
|
||||
test("empty session review avoids sparse column gaps across all widths", async ({ page }) => {
|
||||
await signInAsLearner(page);
|
||||
await routeEmptySessionReview(page);
|
||||
|
|
@ -390,6 +559,33 @@ test.describe("layout visual gate @single-run", () => {
|
|||
});
|
||||
});
|
||||
|
||||
test("professor student analysis stays contained across all widths", async ({ page }) => {
|
||||
const fixture = teacherAnalysisFixture();
|
||||
await page.route("**/api/teacher/dashboard", (route) =>
|
||||
route.fulfill({
|
||||
status: 200,
|
||||
contentType: "application/json",
|
||||
body: JSON.stringify(fixture.dashboard),
|
||||
}),
|
||||
);
|
||||
await page.route("**/api/teacher/learners/*/analysis", (route) =>
|
||||
route.fulfill({
|
||||
status: 200,
|
||||
contentType: "application/json",
|
||||
body: JSON.stringify(fixture.analysis),
|
||||
}),
|
||||
);
|
||||
await signInAsTeacher(page);
|
||||
await page.goto("/teach/analysis");
|
||||
await gateScreen(page, "professor-analysis", async () => {
|
||||
await expect(page.locator(".pf-analysis-shell")).toBeVisible({ timeout: 15_000 });
|
||||
await expect(page.locator('[data-learner-analysis-panel="true"]')).toBeVisible({
|
||||
timeout: 15_000,
|
||||
});
|
||||
await expect(page.locator('[data-learner-session-row="true"]')).toHaveCount(5);
|
||||
});
|
||||
});
|
||||
|
||||
test("persona studio stays contained across all widths", async ({ page }) => {
|
||||
await signInAsTeacher(page);
|
||||
await page.goto("/teach/personas");
|
||||
|
|
|
|||
|
|
@ -93,6 +93,43 @@ async function expectReachableLearnerHomeLayout(page: import("@playwright/test")
|
|||
await expectNoHorizontalOverflow(page);
|
||||
}
|
||||
|
||||
async function expectPersonaCardsUseContentHeight(page: import("@playwright/test").Page) {
|
||||
const cards = await page.locator(".lh-persona").evaluateAll((items) =>
|
||||
items.map((item) => {
|
||||
const card = item as HTMLElement;
|
||||
const rect = card.getBoundingClientRect();
|
||||
const summary = card.querySelector<HTMLElement>(".lh-persona__summary");
|
||||
const body = card.querySelector<HTMLElement>(".lh-persona__body");
|
||||
const textNodes = Array.from(
|
||||
card.querySelectorAll<HTMLElement>(
|
||||
".lh-persona__name, .lh-persona__meta, .lh-persona__summary",
|
||||
),
|
||||
);
|
||||
const childBottom = Math.max(
|
||||
0,
|
||||
...textNodes.map((child) => child.getBoundingClientRect().bottom),
|
||||
);
|
||||
return {
|
||||
label: (card.textContent ?? "").replace(/\s+/g, " ").trim().slice(0, 90),
|
||||
height: Math.ceil(rect.height),
|
||||
bodyOverflow: body ? Math.ceil(body.scrollHeight - body.clientHeight) : 0,
|
||||
childBottomOverflow: Math.ceil(childBottom - rect.bottom),
|
||||
cardScrollOverflow: Math.ceil(card.scrollHeight - card.clientHeight),
|
||||
summaryOverflow: summary ? Math.ceil(summary.scrollHeight - summary.clientHeight) : 0,
|
||||
};
|
||||
}),
|
||||
);
|
||||
|
||||
const offenders = cards.filter(
|
||||
(card) =>
|
||||
card.bodyOverflow > 1 ||
|
||||
card.childBottomOverflow > 1 ||
|
||||
card.cardScrollOverflow > 1 ||
|
||||
card.summaryOverflow > 1,
|
||||
);
|
||||
expect(offenders, `Persona card text should fit its fluid-height card: ${JSON.stringify(cards)}`).toEqual([]);
|
||||
}
|
||||
|
||||
function learnerActivityHeading(page: import("@playwright/test").Page) {
|
||||
return page.locator(".lh-activity__head").getByText("기존 회기", { exact: true });
|
||||
}
|
||||
|
|
@ -161,6 +198,7 @@ test.describe("learner app shell and session launcher", () => {
|
|||
await expect(launcher.getByRole("option", { name: new RegExp(persona.code) })).toBeVisible();
|
||||
await expect(launcher.getByRole("option", { name: new RegExp(persona.display_name.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")) })).toBeVisible();
|
||||
}
|
||||
await expectPersonaCardsUseContentHeight(page);
|
||||
|
||||
await expect(page.getByText(/12회/)).toHaveCount(0);
|
||||
await expect(page.getByText(/최근 연습/)).toHaveCount(0);
|
||||
|
|
|
|||
|
|
@ -12,8 +12,15 @@ function deferred() {
|
|||
return { promise, resolve };
|
||||
}
|
||||
|
||||
async function routeMvpApi(page: Page) {
|
||||
interface RouteMvpOptions {
|
||||
endStatus?: number;
|
||||
endBody?: unknown;
|
||||
crisisStream?: boolean;
|
||||
}
|
||||
|
||||
async function routeMvpApi(page: Page, options: RouteMvpOptions = {}) {
|
||||
const sessionStartRequests: unknown[] = [];
|
||||
const liveCoachRequests: unknown[] = [];
|
||||
const streamSeen = deferred();
|
||||
const streamGate = deferred();
|
||||
|
||||
|
|
@ -26,6 +33,10 @@ async function routeMvpApi(page: Page) {
|
|||
email: "mvp.learner@hs.ac.kr",
|
||||
display_name: "MVP 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),
|
||||
|
|
@ -36,6 +47,21 @@ async function routeMvpApi(page: Page) {
|
|||
});
|
||||
});
|
||||
|
||||
await page.route("**/api/users/me/prepost-measures**", async (route) => {
|
||||
await route.fulfill({
|
||||
status: 200,
|
||||
contentType: "application/json",
|
||||
body: JSON.stringify({
|
||||
pilot_id: "phase3-pilot-draft",
|
||||
instrument_version: "pilot-prepost-scaffold-2026-06-28",
|
||||
measures: [],
|
||||
complete_pre_count: 0,
|
||||
complete_post_count: 0,
|
||||
updated_at: null,
|
||||
}),
|
||||
});
|
||||
});
|
||||
|
||||
await page.route("**/api/personas", async (route) => {
|
||||
await route.fulfill({
|
||||
status: 200,
|
||||
|
|
@ -102,6 +128,37 @@ async function routeMvpApi(page: Page) {
|
|||
await page.route(`**/api/sessions/${sessionId}/stream`, async (route) => {
|
||||
streamSeen.resolve();
|
||||
await streamGate.promise;
|
||||
if (options.crisisStream) {
|
||||
const crisisResource = {
|
||||
title: "자살예방상담전화 109",
|
||||
number: "109",
|
||||
message: "지금은 연습을 멈추고 실제 안전 확인이 먼저입니다.",
|
||||
};
|
||||
await route.fulfill({
|
||||
status: 200,
|
||||
contentType: "text/event-stream",
|
||||
body: [
|
||||
"event: safety",
|
||||
`data: ${JSON.stringify({
|
||||
crisis_resource: crisisResource,
|
||||
conversation_stopped: true,
|
||||
})}`,
|
||||
"",
|
||||
"event: done",
|
||||
`data: ${JSON.stringify({
|
||||
session_id: sessionId,
|
||||
stage: "라포",
|
||||
effective_openness: 0.21,
|
||||
turn_seq: 1,
|
||||
safety_flagged: true,
|
||||
crisis_resource: crisisResource,
|
||||
conversation_stopped: true,
|
||||
})}`,
|
||||
"",
|
||||
].join("\n"),
|
||||
});
|
||||
return;
|
||||
}
|
||||
await route.fulfill({
|
||||
status: 200,
|
||||
contentType: "text/event-stream",
|
||||
|
|
@ -123,14 +180,55 @@ async function routeMvpApi(page: Page) {
|
|||
});
|
||||
|
||||
await page.route(`**/api/sessions/${sessionId}/end`, async (route) => {
|
||||
const status = options.endStatus ?? 200;
|
||||
await route.fulfill({
|
||||
status,
|
||||
contentType: "application/json",
|
||||
body: JSON.stringify(
|
||||
options.endBody ?? {
|
||||
session_id: sessionId,
|
||||
session_no: 1,
|
||||
digest_pending: true,
|
||||
end_state: { stage: "탐색", turn_seq: 1, effective_openness: 0.42 },
|
||||
},
|
||||
),
|
||||
});
|
||||
});
|
||||
|
||||
await page.route(`**/api/sessions/${sessionId}/live-coach`, async (route) => {
|
||||
const request = route.request();
|
||||
if (request.method() === "GET") {
|
||||
await route.fulfill({
|
||||
status: 200,
|
||||
contentType: "application/json",
|
||||
body: JSON.stringify({ source: "runtime", events: [] }),
|
||||
});
|
||||
return;
|
||||
}
|
||||
liveCoachRequests.push(request.postDataJSON());
|
||||
await route.fulfill({
|
||||
status: 200,
|
||||
contentType: "application/json",
|
||||
body: JSON.stringify({
|
||||
session_id: sessionId,
|
||||
session_no: 1,
|
||||
digest_pending: true,
|
||||
end_state: { stage: "탐색", turn_seq: 1, effective_openness: 0.42 },
|
||||
status: "ready",
|
||||
tone: "pos",
|
||||
focus: "reflection",
|
||||
title: "감정 반영이 선명합니다",
|
||||
message: "학습자가 내담자의 감정을 평가하지 않고 먼저 되짚었습니다.",
|
||||
next_utterance: "그 마음이 가장 크게 올라온 장면을 조금 더 들려줄 수 있을까요?",
|
||||
rationale: "방금 발화는 정서 반영과 개방 질문을 함께 포함합니다.",
|
||||
sources: [
|
||||
{
|
||||
source_id: "live_coaching_workbook_0615",
|
||||
title: "0615 사례개념화 워크북",
|
||||
locator: "reflection",
|
||||
kb_kind: "source_pack",
|
||||
version: "2026-06-15",
|
||||
citation: "허가된 요약 근거",
|
||||
},
|
||||
],
|
||||
safety_note: null,
|
||||
latency_ms: 12,
|
||||
}),
|
||||
});
|
||||
});
|
||||
|
|
@ -192,7 +290,7 @@ async function routeMvpApi(page: Page) {
|
|||
});
|
||||
});
|
||||
|
||||
return { sessionStartRequests, streamGate, streamSeen };
|
||||
return { sessionStartRequests, liveCoachRequests, streamGate, streamSeen };
|
||||
}
|
||||
|
||||
test.describe("P1 MVP core loop", () => {
|
||||
|
|
@ -239,4 +337,67 @@ test.describe("P1 MVP core loop", () => {
|
|||
await expect(page.locator(".sr-feedback").getByText(clientReply)).toBeVisible();
|
||||
await expect(page.getByText("평가 AI가 저장된 축어록을 분석했습니다.")).toBeVisible();
|
||||
});
|
||||
|
||||
test("shows AI tutor coaching in coached mode after a completed turn", async ({ page }) => {
|
||||
const api = await routeMvpApi(page);
|
||||
|
||||
await page.goto("/learn/session/P1");
|
||||
await page.getByRole("button", { name: "회기 시작" }).click();
|
||||
await page.getByRole("button", { name: "코칭" }).click();
|
||||
await page.getByLabel("학습자 발화 입력").fill(learnerText);
|
||||
await page.getByRole("button", { name: "보내기" }).click();
|
||||
|
||||
await api.streamSeen.promise;
|
||||
api.streamGate.resolve();
|
||||
|
||||
await expect(page.locator(".sx-coach-card").getByText("감정 반영이 선명합니다")).toBeVisible();
|
||||
await expect(
|
||||
page.getByText("학습자가 내담자의 감정을 평가하지 않고 먼저 되짚었습니다."),
|
||||
).toBeVisible();
|
||||
expect(api.liveCoachRequests).toHaveLength(1);
|
||||
expect(api.liveCoachRequests[0]).toMatchObject({
|
||||
learner_text: learnerText,
|
||||
client_reply: clientReply,
|
||||
turn_seq: 1,
|
||||
});
|
||||
});
|
||||
|
||||
test("keeps crisis safety gate visible instead of showing empty client reply @single-run", async ({ page }) => {
|
||||
const api = await routeMvpApi(page, { crisisStream: true });
|
||||
|
||||
await page.goto("/learn/session/P1");
|
||||
await page.getByRole("button", { name: "회기 시작" }).click();
|
||||
await page.getByRole("button", { name: "코칭" }).click();
|
||||
await page.getByLabel("학습자 발화 입력").fill("저 지금 자살하고 싶어요. 도와주세요.");
|
||||
await page.getByRole("button", { name: "보내기" }).click();
|
||||
|
||||
await api.streamSeen.promise;
|
||||
api.streamGate.resolve();
|
||||
|
||||
await expect(page.locator(".sx-crisis-resource").getByText("자살예방상담전화 109")).toBeVisible();
|
||||
await expect(page.getByRole("link", { name: "109" })).toBeVisible();
|
||||
await expect(
|
||||
page.locator(".sx-signal__one-text").filter({ hasText: "위기 안전게이트 작동" }),
|
||||
).toBeVisible();
|
||||
await expect(page.getByText("내담자 응답 없음")).toHaveCount(0);
|
||||
await expect(page.locator(".sx-utt.is-client")).toHaveCount(0);
|
||||
await expect(page.getByLabel("학습자 발화 입력")).toBeDisabled();
|
||||
expect(api.liveCoachRequests).toHaveLength(0);
|
||||
});
|
||||
|
||||
test("does not navigate to review when session end fails", async ({ page }) => {
|
||||
await routeMvpApi(page, {
|
||||
endStatus: 503,
|
||||
endBody: { detail: "session end persistence unavailable" },
|
||||
});
|
||||
|
||||
await page.goto("/learn/session/P1");
|
||||
await page.getByRole("button", { name: "회기 시작" }).click();
|
||||
await page.getByRole("button", { name: "회기 종료" }).click();
|
||||
await page.getByRole("button", { name: "종료하고 리뷰 보기" }).click();
|
||||
|
||||
await expect(page).toHaveURL(new RegExp(`/learn/session/${sessionId}$`));
|
||||
await expect(page.getByRole("alert")).toContainText("회기 종료에 실패했습니다.");
|
||||
await expect(page.getByRole("alert")).toContainText("session end persistence unavailable");
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -6,12 +6,363 @@ interface SessionStartResponse {
|
|||
degraded: boolean;
|
||||
}
|
||||
|
||||
interface HealthResponse {
|
||||
db?: boolean;
|
||||
engine?: boolean;
|
||||
}
|
||||
|
||||
interface ReviewTurn {
|
||||
speaker: string;
|
||||
text: string;
|
||||
nonverbal?: Array<{
|
||||
kind: string;
|
||||
label: string;
|
||||
detail: string;
|
||||
}>;
|
||||
}
|
||||
|
||||
interface SessionReviewResponse {
|
||||
session_id: string;
|
||||
turns: ReviewTurn[];
|
||||
degraded?: boolean;
|
||||
reviewReady?: boolean;
|
||||
supervisorState?: string;
|
||||
summary?: string;
|
||||
caseWorksheet?: {
|
||||
status?: string;
|
||||
sections?: Array<{
|
||||
key: string;
|
||||
items?: Array<{
|
||||
key: string;
|
||||
value?: string | null;
|
||||
}>;
|
||||
}>;
|
||||
};
|
||||
teacherReview?: {
|
||||
worksheetStatus?: string | null;
|
||||
worksheetNote?: string | null;
|
||||
} | null;
|
||||
}
|
||||
|
||||
interface LiveCoachEvent {
|
||||
turn_seq: number;
|
||||
learner_text_excerpt?: string | null;
|
||||
suggestion: {
|
||||
title: string;
|
||||
message: string;
|
||||
};
|
||||
}
|
||||
|
||||
interface LiveCoachHistoryResponse {
|
||||
source: string;
|
||||
events: LiveCoachEvent[];
|
||||
}
|
||||
|
||||
interface VoiceHealthResponse {
|
||||
available?: boolean;
|
||||
}
|
||||
|
||||
interface VoiceWsProbe {
|
||||
code: number;
|
||||
events: Array<{ type?: string; state?: string; [key: string]: unknown }>;
|
||||
binaryChunks: number;
|
||||
}
|
||||
|
||||
interface VoiceUiProbeMessage {
|
||||
direction: "sent" | "received";
|
||||
kind: "text" | "binary";
|
||||
data?: string;
|
||||
byteLength?: number;
|
||||
}
|
||||
|
||||
interface VoiceUiProbeState {
|
||||
getUserMediaCalls: number;
|
||||
workletModuleLoads: number;
|
||||
workletNodes: number;
|
||||
workletChunks: number;
|
||||
trackStops: number;
|
||||
messages: VoiceUiProbeMessage[];
|
||||
}
|
||||
|
||||
interface EvaluationSummaryResponse {
|
||||
session_id: string;
|
||||
status?: string | null;
|
||||
error?: string | null;
|
||||
durable?: boolean;
|
||||
deep?: Record<string, unknown> | null;
|
||||
}
|
||||
|
||||
interface PrepostMeasureItem {
|
||||
measure_name: string;
|
||||
timepoint: string;
|
||||
raw_score: number;
|
||||
normalized_score: number;
|
||||
}
|
||||
|
||||
interface PrepostMeasuresResponse {
|
||||
source: string;
|
||||
durable: boolean;
|
||||
pilot_id: string;
|
||||
complete_measure_pairs: number;
|
||||
measures: PrepostMeasureItem[];
|
||||
}
|
||||
|
||||
async function expectResponseOk(response: { ok: () => boolean; text: () => Promise<string> }) {
|
||||
if (!response.ok()) {
|
||||
expect(response.ok(), await response.text()).toBeTruthy();
|
||||
}
|
||||
}
|
||||
|
||||
async function setupSyntheticVoiceUiProbe(page: Page, transcript: string) {
|
||||
await page.addInitScript((text) => {
|
||||
type ProbeMessage = {
|
||||
direction: "sent" | "received";
|
||||
kind: "text" | "binary";
|
||||
data?: string;
|
||||
byteLength?: number;
|
||||
};
|
||||
type ProbeState = {
|
||||
getUserMediaCalls: number;
|
||||
workletModuleLoads: number;
|
||||
workletNodes: number;
|
||||
workletChunks: number;
|
||||
trackStops: number;
|
||||
messages: ProbeMessage[];
|
||||
};
|
||||
const probe: ProbeState = {
|
||||
getUserMediaCalls: 0,
|
||||
workletModuleLoads: 0,
|
||||
workletNodes: 0,
|
||||
workletChunks: 0,
|
||||
trackStops: 0,
|
||||
messages: [],
|
||||
};
|
||||
(window as Window & { __voiceUiProbe?: ProbeState }).__voiceUiProbe = probe;
|
||||
|
||||
const fakeTrack = {
|
||||
kind: "audio",
|
||||
enabled: true,
|
||||
readyState: "live",
|
||||
stop: () => {
|
||||
probe.trackStops += 1;
|
||||
fakeTrack.readyState = "ended";
|
||||
},
|
||||
};
|
||||
const fakeStream = {
|
||||
active: true,
|
||||
getTracks: () => [fakeTrack],
|
||||
getAudioTracks: () => [fakeTrack],
|
||||
};
|
||||
Object.defineProperty(navigator, "mediaDevices", {
|
||||
configurable: true,
|
||||
value: {
|
||||
getUserMedia: async () => {
|
||||
probe.getUserMediaCalls += 1;
|
||||
return fakeStream;
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const NativeWebSocket = window.WebSocket;
|
||||
const sizeOf = (data: unknown) => {
|
||||
if (typeof data === "string") return data.length;
|
||||
if (data instanceof Blob) return data.size;
|
||||
if (data instanceof ArrayBuffer) return data.byteLength;
|
||||
if (ArrayBuffer.isView(data)) return data.byteLength;
|
||||
return 0;
|
||||
};
|
||||
class ProbeWebSocket extends NativeWebSocket {
|
||||
constructor(url: string | URL, protocols?: string | string[]) {
|
||||
if (protocols === undefined) super(url);
|
||||
else super(url, protocols);
|
||||
this.addEventListener("message", (event) => {
|
||||
if (typeof event.data === "string") {
|
||||
probe.messages.push({ direction: "received", kind: "text", data: event.data });
|
||||
} else {
|
||||
probe.messages.push({
|
||||
direction: "received",
|
||||
kind: "binary",
|
||||
byteLength: sizeOf(event.data),
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
send(data: string | ArrayBufferLike | Blob | ArrayBufferView) {
|
||||
if (typeof data === "string") {
|
||||
probe.messages.push({ direction: "sent", kind: "text", data });
|
||||
try {
|
||||
const parsed = JSON.parse(data) as {
|
||||
type?: string;
|
||||
silence_ms?: number;
|
||||
barge_in?: boolean;
|
||||
provider_events?: unknown[];
|
||||
};
|
||||
if (parsed.type === "audio_end") {
|
||||
super.send(
|
||||
JSON.stringify({
|
||||
type: "stt_result",
|
||||
text,
|
||||
final: true,
|
||||
silence_ms: parsed.silence_ms,
|
||||
barge_in: parsed.barge_in,
|
||||
provider_events: parsed.provider_events,
|
||||
}),
|
||||
);
|
||||
return;
|
||||
}
|
||||
} catch {
|
||||
// Keep non-JSON WebSocket traffic unchanged.
|
||||
}
|
||||
} else {
|
||||
probe.messages.push({ direction: "sent", kind: "binary", byteLength: sizeOf(data) });
|
||||
}
|
||||
super.send(data);
|
||||
}
|
||||
}
|
||||
Object.defineProperty(window, "WebSocket", {
|
||||
configurable: true,
|
||||
value: ProbeWebSocket,
|
||||
});
|
||||
|
||||
class FakeWorkletPort {
|
||||
onmessage: ((event: MessageEvent) => void) | null = null;
|
||||
closed = false;
|
||||
|
||||
postMessage(message: { type?: string }) {
|
||||
if (message?.type === "flush") this.emitChunk();
|
||||
}
|
||||
|
||||
close() {
|
||||
this.closed = true;
|
||||
}
|
||||
|
||||
emitChunk() {
|
||||
if (this.closed) return;
|
||||
probe.workletChunks += 1;
|
||||
const pcm = new Int16Array([0, 4096, -4096, 0]);
|
||||
this.onmessage?.({
|
||||
data: {
|
||||
type: "chunk",
|
||||
pcm: pcm.buffer,
|
||||
metrics: {
|
||||
durationMs: 1600,
|
||||
voiceMs: 360,
|
||||
silenceMs: 1240,
|
||||
trailingSilenceMs: 1240,
|
||||
rms: 0.1,
|
||||
peak: 0.72,
|
||||
},
|
||||
},
|
||||
} as MessageEvent);
|
||||
}
|
||||
}
|
||||
|
||||
class FakeAudioWorkletNode {
|
||||
port = new FakeWorkletPort();
|
||||
|
||||
constructor(_ctx: unknown, _name: string, _options?: unknown) {
|
||||
probe.workletNodes += 1;
|
||||
}
|
||||
|
||||
connect() {
|
||||
return this;
|
||||
}
|
||||
|
||||
disconnect() {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
__start() {
|
||||
window.setTimeout(() => this.port.emitChunk(), 25);
|
||||
}
|
||||
}
|
||||
|
||||
class FakeMediaStreamSource {
|
||||
connect(node: { __start?: () => void }) {
|
||||
node.__start?.();
|
||||
return node;
|
||||
}
|
||||
|
||||
disconnect() {
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
class FakeAudioContext {
|
||||
state = "running";
|
||||
destination = {};
|
||||
sampleRate = 16000;
|
||||
audioWorklet = {
|
||||
addModule: async (_url: string) => {
|
||||
probe.workletModuleLoads += 1;
|
||||
},
|
||||
};
|
||||
|
||||
async resume() {
|
||||
this.state = "running";
|
||||
}
|
||||
|
||||
async close() {
|
||||
this.state = "closed";
|
||||
}
|
||||
|
||||
async decodeAudioData(_data: ArrayBuffer) {
|
||||
return { duration: 0.12 };
|
||||
}
|
||||
|
||||
createAnalyser() {
|
||||
return {
|
||||
fftSize: 0,
|
||||
frequencyBinCount: 4,
|
||||
getByteTimeDomainData: (array: Uint8Array) => array.fill(128),
|
||||
connect: () => undefined,
|
||||
disconnect: () => undefined,
|
||||
};
|
||||
}
|
||||
|
||||
createBufferSource() {
|
||||
return {
|
||||
buffer: null,
|
||||
connect: () => undefined,
|
||||
disconnect: () => undefined,
|
||||
start: () => undefined,
|
||||
stop: () => undefined,
|
||||
onended: null as (() => void) | null,
|
||||
};
|
||||
}
|
||||
|
||||
createMediaStreamSource(_stream: unknown) {
|
||||
return new FakeMediaStreamSource();
|
||||
}
|
||||
}
|
||||
|
||||
Object.defineProperty(window, "AudioWorkletNode", {
|
||||
configurable: true,
|
||||
value: FakeAudioWorkletNode,
|
||||
});
|
||||
Object.defineProperty(window, "AudioContext", {
|
||||
configurable: true,
|
||||
value: FakeAudioContext,
|
||||
});
|
||||
Object.defineProperty(window, "webkitAudioContext", {
|
||||
configurable: true,
|
||||
value: FakeAudioContext,
|
||||
});
|
||||
}, transcript);
|
||||
}
|
||||
|
||||
async function readVoiceUiProbe(page: Page): Promise<VoiceUiProbeState> {
|
||||
return page.evaluate(() => {
|
||||
const probe = (window as Window & { __voiceUiProbe?: VoiceUiProbeState }).__voiceUiProbe;
|
||||
if (!probe) throw new Error("voice UI probe was not installed");
|
||||
return {
|
||||
...probe,
|
||||
messages: [...probe.messages],
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
async function createSessionWithTurn(page: Page) {
|
||||
const persona = await fetchAvailablePersona(page);
|
||||
const startedResponse = await page.request.post("/api/sessions", {
|
||||
|
|
@ -50,8 +401,391 @@ async function createSessionWithTurn(page: Page) {
|
|||
return { sessionId: started.session_id, expectedMinTurns };
|
||||
}
|
||||
|
||||
async function createEndedSessionWithoutTurn(page: Page) {
|
||||
const persona = await fetchAvailablePersona(page);
|
||||
const startedResponse = await page.request.post("/api/sessions", {
|
||||
data: {
|
||||
persona_code: persona.code,
|
||||
theory_mode: "humanistic",
|
||||
},
|
||||
});
|
||||
await expectResponseOk(startedResponse);
|
||||
const started = (await startedResponse.json()) as SessionStartResponse;
|
||||
expect(started.degraded).toBe(false);
|
||||
|
||||
const endedResponse = await page.request.post(`/api/sessions/${started.session_id}/end`);
|
||||
await expectResponseOk(endedResponse);
|
||||
return started.session_id;
|
||||
}
|
||||
|
||||
async function evaluationState(page: Page, sessionId: string) {
|
||||
const response = await page.request.get(`/api/eval/sessions/${sessionId}/evaluation`);
|
||||
if (!response.ok()) {
|
||||
return `http:${response.status()}:${(await response.text()).slice(0, 120)}`;
|
||||
}
|
||||
const body = (await response.json()) as EvaluationSummaryResponse;
|
||||
const loop = typeof body.deep?.loop === "string" ? body.deep.loop : "none";
|
||||
const turnsEvaluated =
|
||||
typeof body.deep?.turns_evaluated === "number" ? body.deep.turns_evaluated : 0;
|
||||
if (body.status === "ready" && body.durable === true && loop === "deep") {
|
||||
return `ready:${turnsEvaluated}`;
|
||||
}
|
||||
if (body.status === "error") {
|
||||
return `error:${body.error || "empty-error"}:${body.durable ? "durable" : "cache"}`;
|
||||
}
|
||||
return `pending:${body.status ?? "none"}:${body.durable ? "durable" : "cache"}:${loop}`;
|
||||
}
|
||||
|
||||
test.describe("session persistence", () => {
|
||||
test("persists the browser SSE stream into DB-backed review @single-run", async ({ page }) => {
|
||||
test.setTimeout(120_000);
|
||||
|
||||
const healthResponse = await page.request.get("/api/health");
|
||||
await expectResponseOk(healthResponse);
|
||||
const health = (await healthResponse.json()) as HealthResponse;
|
||||
test.skip(!health.db || !health.engine, "real stream persistence requires DB and engine");
|
||||
|
||||
await signInAsLearner(page);
|
||||
const persona = await fetchAvailablePersona(page, 1);
|
||||
const learnerText =
|
||||
"지금 많이 버거웠겠어요. 오늘 제일 크게 남아 있는 감정은 무엇인가요?";
|
||||
|
||||
await page.goto(`/learn/session/${persona.code}`);
|
||||
await page.getByRole("button", { name: "회기 시작" }).click();
|
||||
await expect(page.locator(".sx-page.sx-page--active")).toBeVisible({ timeout: 15_000 });
|
||||
await expect(page).toHaveURL(/\/learn\/session\/[0-9a-f-]+$/i);
|
||||
const sessionId = new URL(page.url()).pathname.split("/").at(-1);
|
||||
expect(sessionId).toMatch(/^[0-9a-f-]+$/i);
|
||||
|
||||
const streamResponsePromise = page.waitForResponse((response) => {
|
||||
const url = new URL(response.url());
|
||||
return (
|
||||
response.request().method() === "POST" &&
|
||||
url.pathname.endsWith(`/api/sessions/${sessionId}/stream`)
|
||||
);
|
||||
});
|
||||
|
||||
const input = page.getByLabel("학습자 발화 입력");
|
||||
await input.fill(learnerText);
|
||||
await page.getByRole("button", { name: "보내기" }).click();
|
||||
|
||||
const streamResponse = await streamResponsePromise;
|
||||
await expectResponseOk(streamResponse);
|
||||
await expect(page.locator(".sx-utt.is-learner").filter({ hasText: learnerText })).toBeVisible();
|
||||
await expect(page.locator(".sx-utt.is-client.is-thinking")).toHaveCount(0, {
|
||||
timeout: 90_000,
|
||||
});
|
||||
const clientUtterance = page.locator(".sx-utt.is-client").first();
|
||||
await expect(clientUtterance).toBeVisible();
|
||||
await expect(clientUtterance).not.toContainText("답변을 준비 중입니다.");
|
||||
await expect(input).toBeEnabled({ timeout: 90_000 });
|
||||
|
||||
const reviewResponse = await page.request.get(`/api/sessions/${sessionId}/review`);
|
||||
await expectResponseOk(reviewResponse);
|
||||
const review = (await reviewResponse.json()) as SessionReviewResponse;
|
||||
expect(review.session_id).toBe(sessionId);
|
||||
expect(review.turns.some((turn) => turn.speaker === "learner" && turn.text === learnerText)).toBe(
|
||||
true,
|
||||
);
|
||||
expect(
|
||||
review.turns.some((turn) => turn.speaker === "client" && turn.text.trim().length > 0),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
test("persists AI tutor coaching history through reload @single-run", async ({ page }) => {
|
||||
test.setTimeout(150_000);
|
||||
|
||||
const healthResponse = await page.request.get("/api/health");
|
||||
await expectResponseOk(healthResponse);
|
||||
const health = (await healthResponse.json()) as HealthResponse;
|
||||
test.skip(!health.db || !health.engine, "real live-coach persistence requires DB and engine");
|
||||
|
||||
await signInAsLearner(page);
|
||||
const persona = await fetchAvailablePersona(page, 1);
|
||||
const learnerText =
|
||||
"그 말을 꺼내는 것도 쉽지 않았겠어요. 지금 가장 버거운 감정부터 천천히 말해볼까요?";
|
||||
|
||||
await page.goto(`/learn/session/${persona.code}`);
|
||||
await page.getByRole("button", { name: "회기 시작" }).click();
|
||||
await page.getByRole("button", { name: "코칭" }).click();
|
||||
await expect(page.locator(".sx-page.sx-page--active")).toBeVisible({ timeout: 15_000 });
|
||||
await expect(page).toHaveURL(/\/learn\/session\/[0-9a-f-]+$/i);
|
||||
const sessionId = new URL(page.url()).pathname.split("/").at(-1);
|
||||
expect(sessionId).toMatch(/^[0-9a-f-]+$/i);
|
||||
|
||||
const streamResponsePromise = page.waitForResponse((response) => {
|
||||
const url = new URL(response.url());
|
||||
return (
|
||||
response.request().method() === "POST" &&
|
||||
url.pathname.endsWith(`/api/sessions/${sessionId}/stream`)
|
||||
);
|
||||
});
|
||||
const coachResponsePromise = page.waitForResponse((response) => {
|
||||
const url = new URL(response.url());
|
||||
return (
|
||||
response.request().method() === "POST" &&
|
||||
url.pathname.endsWith(`/api/sessions/${sessionId}/live-coach`)
|
||||
);
|
||||
});
|
||||
|
||||
const input = page.getByLabel("학습자 발화 입력");
|
||||
await input.fill(learnerText);
|
||||
await page.getByRole("button", { name: "보내기" }).click();
|
||||
|
||||
const streamResponse = await streamResponsePromise;
|
||||
await expectResponseOk(streamResponse);
|
||||
await expect(page.locator(".sx-utt.is-client.is-thinking")).toHaveCount(0, {
|
||||
timeout: 90_000,
|
||||
});
|
||||
await expect(input).toBeEnabled({ timeout: 90_000 });
|
||||
|
||||
const coachResponse = await coachResponsePromise;
|
||||
await expectResponseOk(coachResponse);
|
||||
const coachSuggestion = (await coachResponse.json()) as LiveCoachEvent["suggestion"];
|
||||
await expect(page.locator(".sx-coach-card").getByText(coachSuggestion.title)).toBeVisible({
|
||||
timeout: 30_000,
|
||||
});
|
||||
|
||||
const historyResponse = await page.request.get(`/api/sessions/${sessionId}/live-coach`);
|
||||
await expectResponseOk(historyResponse);
|
||||
const history = (await historyResponse.json()) as LiveCoachHistoryResponse;
|
||||
expect(history.source).toBe("database");
|
||||
expect(
|
||||
history.events.some(
|
||||
(event) =>
|
||||
event.turn_seq === 1 &&
|
||||
event.learner_text_excerpt?.includes("가장 버거운 감정") &&
|
||||
event.suggestion.title === coachSuggestion.title,
|
||||
),
|
||||
).toBe(true);
|
||||
|
||||
await page.goto(`/learn/session/${sessionId}`);
|
||||
await expect(page.locator(".sx-utt.is-learner").filter({ hasText: learnerText })).toBeVisible({
|
||||
timeout: 15_000,
|
||||
});
|
||||
await expect(page.locator(".sx-utt.is-learner .sx-utt__coach-mark")).toBeVisible({
|
||||
timeout: 15_000,
|
||||
});
|
||||
});
|
||||
|
||||
test("persists voice nonverbal metadata into DB-backed review @single-run", async ({ page }) => {
|
||||
test.setTimeout(120_000);
|
||||
|
||||
const healthResponse = await page.request.get("/api/health");
|
||||
await expectResponseOk(healthResponse);
|
||||
const health = (await healthResponse.json()) as HealthResponse;
|
||||
const voiceHealthResponse = await page.request.get("/api/voice/health");
|
||||
await expectResponseOk(voiceHealthResponse);
|
||||
const voiceHealth = (await voiceHealthResponse.json()) as VoiceHealthResponse;
|
||||
test.skip(
|
||||
!health.db || !health.engine || !voiceHealth.available,
|
||||
"voice nonverbal persistence requires DB, engine, and voice service",
|
||||
);
|
||||
|
||||
await signInAsLearner(page);
|
||||
const persona = await fetchAvailablePersona(page, 1);
|
||||
const startedResponse = await page.request.post("/api/sessions", {
|
||||
data: {
|
||||
persona_code: persona.code,
|
||||
theory_mode: "humanistic",
|
||||
},
|
||||
});
|
||||
await expectResponseOk(startedResponse);
|
||||
const started = (await startedResponse.json()) as SessionStartResponse;
|
||||
expect(started.degraded).toBe(false);
|
||||
await page.goto(`/learn/session/${started.session_id}`);
|
||||
|
||||
const learnerText = "요즘 잠을 잘 못 자요.";
|
||||
const probe = await page.evaluate(
|
||||
({ sessionId, text }) =>
|
||||
new Promise<VoiceWsProbe>((resolve) => {
|
||||
const wsUrl = new URL(
|
||||
`/api/voice/ws?session_id=${encodeURIComponent(sessionId)}`,
|
||||
window.location.href,
|
||||
);
|
||||
wsUrl.protocol = wsUrl.protocol === "https:" ? "wss:" : "ws:";
|
||||
const ws = new WebSocket(wsUrl.href);
|
||||
ws.binaryType = "arraybuffer";
|
||||
const events: VoiceWsProbe["events"] = [];
|
||||
let binaryChunks = 0;
|
||||
let sawReply = false;
|
||||
const finish = (code: number) => {
|
||||
window.clearTimeout(timeout);
|
||||
resolve({ code, events, binaryChunks });
|
||||
};
|
||||
const timeout = window.setTimeout(() => {
|
||||
ws.close();
|
||||
finish(-1);
|
||||
}, 60_000);
|
||||
|
||||
ws.onopen = () => {
|
||||
ws.send(JSON.stringify({ type: "audio_start", format: "webm" }));
|
||||
window.setTimeout(() => {
|
||||
ws.send(
|
||||
JSON.stringify({
|
||||
type: "stt_result",
|
||||
text,
|
||||
final: true,
|
||||
silence_ms: 1500,
|
||||
barge_in: true,
|
||||
provider_events: [{ type: "sigh", confidence: 0.82, text: "drop raw" }],
|
||||
}),
|
||||
);
|
||||
}, 150);
|
||||
};
|
||||
ws.onmessage = (event) => {
|
||||
if (typeof event.data === "string") {
|
||||
const parsed = JSON.parse(event.data) as { type?: string; state?: string };
|
||||
events.push(parsed);
|
||||
if (parsed.type === "reply") sawReply = true;
|
||||
if (sawReply && parsed.type === "state" && parsed.state === "idle") {
|
||||
ws.send(JSON.stringify({ type: "close" }));
|
||||
}
|
||||
return;
|
||||
}
|
||||
binaryChunks += 1;
|
||||
};
|
||||
ws.onerror = () => {
|
||||
events.push({ type: "error", detail: "browser websocket error" });
|
||||
};
|
||||
ws.onclose = (event) => finish(event.code);
|
||||
}),
|
||||
{ sessionId: started.session_id, text: learnerText },
|
||||
);
|
||||
|
||||
expect(probe.code, JSON.stringify(probe, null, 2)).toBe(1000);
|
||||
expect(probe.events.some((event) => event.type === "error" || event.type === "degraded")).toBe(
|
||||
false,
|
||||
);
|
||||
expect(probe.events).toEqual(
|
||||
expect.arrayContaining([
|
||||
expect.objectContaining({ type: "eot", ready: true }),
|
||||
expect.objectContaining({ type: "transcript", text: learnerText }),
|
||||
expect.objectContaining({ type: "reply" }),
|
||||
]),
|
||||
);
|
||||
|
||||
const reviewResponse = await page.request.get(`/api/sessions/${started.session_id}/review`);
|
||||
await expectResponseOk(reviewResponse);
|
||||
const review = (await reviewResponse.json()) as SessionReviewResponse;
|
||||
const learnerTurn = review.turns.find(
|
||||
(turn) => turn.speaker === "learner" && turn.text === learnerText,
|
||||
);
|
||||
expect(learnerTurn).toBeTruthy();
|
||||
const nonverbalKinds = learnerTurn?.nonverbal?.map((event) => event.kind) ?? [];
|
||||
expect(nonverbalKinds).toEqual(
|
||||
expect.arrayContaining(["silence", "pace", "barge_in", "paralinguistic"]),
|
||||
);
|
||||
expect(learnerTurn?.nonverbal?.some((event) => event.label === "음성 단서")).toBe(true);
|
||||
expect(learnerTurn?.nonverbal?.map((event) => event.detail).join(" ")).not.toContain(
|
||||
"drop raw",
|
||||
);
|
||||
});
|
||||
|
||||
test("persists browser mic UI nonverbal metadata into DB-backed review @single-run", async ({ page }) => {
|
||||
test.setTimeout(150_000);
|
||||
|
||||
const healthResponse = await page.request.get("/api/health");
|
||||
await expectResponseOk(healthResponse);
|
||||
const health = (await healthResponse.json()) as HealthResponse;
|
||||
const voiceHealthResponse = await page.request.get("/api/voice/health");
|
||||
await expectResponseOk(voiceHealthResponse);
|
||||
const voiceHealth = (await voiceHealthResponse.json()) as VoiceHealthResponse;
|
||||
test.skip(
|
||||
!health.db || !health.engine || !voiceHealth.available,
|
||||
"browser mic UI voice persistence requires DB, engine, and voice service",
|
||||
);
|
||||
|
||||
await signInAsLearner(page);
|
||||
const persona = await fetchAvailablePersona(page, 1);
|
||||
const learnerText = "요즘 잠을 잘 못 자요.";
|
||||
await setupSyntheticVoiceUiProbe(page, learnerText);
|
||||
|
||||
await page.goto(`/learn/session/${persona.code}`);
|
||||
await page.getByRole("button", { name: "회기 시작" }).click();
|
||||
await expect(page.locator(".sx-page.sx-page--active")).toBeVisible({ timeout: 15_000 });
|
||||
await expect(page).toHaveURL(/\/learn\/session\/[0-9a-f-]+$/i);
|
||||
const sessionId = new URL(page.url()).pathname.split("/").at(-1);
|
||||
expect(sessionId).toMatch(/^[0-9a-f-]+$/i);
|
||||
|
||||
const mic = page.locator(".sx-mic");
|
||||
await expect(mic).toBeEnabled();
|
||||
await mic.click();
|
||||
await expect
|
||||
.poll(async () => (await readVoiceUiProbe(page)).getUserMediaCalls, { timeout: 10_000 })
|
||||
.toBeGreaterThan(0);
|
||||
await expect
|
||||
.poll(async () => (await readVoiceUiProbe(page)).workletChunks, { timeout: 10_000 })
|
||||
.toBeGreaterThan(0);
|
||||
await expect
|
||||
.poll(async () => {
|
||||
const probe = await readVoiceUiProbe(page);
|
||||
return probe.messages.some(
|
||||
(message) =>
|
||||
message.direction === "sent" &&
|
||||
message.kind === "text" &&
|
||||
message.data?.includes('"audio_start"') &&
|
||||
message.data?.includes('"format":"pcm"'),
|
||||
);
|
||||
}, { timeout: 10_000 })
|
||||
.toBeTruthy();
|
||||
|
||||
await mic.click();
|
||||
await expect
|
||||
.poll(async () => {
|
||||
const probe = await readVoiceUiProbe(page);
|
||||
return probe.messages.some(
|
||||
(message) => message.direction === "sent" && message.kind === "binary",
|
||||
);
|
||||
}, { timeout: 10_000 })
|
||||
.toBeTruthy();
|
||||
await expect
|
||||
.poll(async () => {
|
||||
const probe = await readVoiceUiProbe(page);
|
||||
return probe.messages.some(
|
||||
(message) =>
|
||||
message.direction === "sent" &&
|
||||
message.kind === "text" &&
|
||||
message.data?.includes('"audio_end"') &&
|
||||
message.data?.includes('"silence_ms":1240') &&
|
||||
message.data?.includes('"browser_audio_worklet"'),
|
||||
);
|
||||
}, { timeout: 10_000 })
|
||||
.toBeTruthy();
|
||||
|
||||
await expect
|
||||
.poll(async () => {
|
||||
const probe = await readVoiceUiProbe(page);
|
||||
const events = probe.messages
|
||||
.filter((message) => message.direction === "received" && message.kind === "text")
|
||||
.map((message) => JSON.parse(message.data ?? "{}") as { type?: string; text?: string });
|
||||
return {
|
||||
transcript: events.some((event) => event.type === "transcript" && event.text === learnerText),
|
||||
reply: events.some((event) => event.type === "reply"),
|
||||
errors: events.filter((event) => event.type === "error" || event.type === "degraded").length,
|
||||
};
|
||||
}, { timeout: 60_000 })
|
||||
.toEqual({ transcript: true, reply: true, errors: 0 });
|
||||
|
||||
const reviewResponse = await page.request.get(`/api/sessions/${sessionId}/review`);
|
||||
await expectResponseOk(reviewResponse);
|
||||
const review = (await reviewResponse.json()) as SessionReviewResponse;
|
||||
const learnerTurn = review.turns.find(
|
||||
(turn) => turn.speaker === "learner" && turn.text === learnerText,
|
||||
);
|
||||
expect(learnerTurn).toBeTruthy();
|
||||
const nonverbal = learnerTurn?.nonverbal ?? [];
|
||||
expect(nonverbal.map((event) => event.kind)).toEqual(expect.arrayContaining(["silence", "pace"]));
|
||||
expect(nonverbal.filter((event) => event.kind === "silence")).toHaveLength(1);
|
||||
expect(nonverbal.some((event) => event.label === "침묵" && event.detail === "1.2초")).toBe(
|
||||
true,
|
||||
);
|
||||
expect(nonverbal.map((event) => event.detail).join(" ")).not.toContain("browser_audio_worklet");
|
||||
});
|
||||
|
||||
test("persists learner turns into DB-backed review and teacher dashboard @single-run", async ({ page }) => {
|
||||
test.setTimeout(120_000);
|
||||
|
||||
await signInAsLearner(page);
|
||||
const { sessionId, expectedMinTurns } = await createSessionWithTurn(page);
|
||||
|
||||
|
|
@ -92,4 +826,177 @@ test.describe("session persistence", () => {
|
|||
),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
test("persists learner case worksheet and teacher worksheet review through DB @single-run", async ({
|
||||
page,
|
||||
}) => {
|
||||
test.setTimeout(150_000);
|
||||
|
||||
const healthResponse = await page.request.get("/api/health");
|
||||
await expectResponseOk(healthResponse);
|
||||
const health = (await healthResponse.json()) as HealthResponse;
|
||||
test.skip(!health.db || !health.engine, "case worksheet E2E requires DB and engine-backed turn");
|
||||
|
||||
await signInAsLearner(page);
|
||||
const { sessionId, expectedMinTurns } = await createSessionWithTurn(page);
|
||||
expect(
|
||||
expectedMinTurns,
|
||||
"case worksheet E2E requires a persisted learner/client transcript",
|
||||
).toBeGreaterThanOrEqual(2);
|
||||
|
||||
await page.goto(`/learn/session/${sessionId}/review`);
|
||||
const worksheetCard = page.locator(".sr-card--worksheet");
|
||||
await expect(worksheetCard).toBeVisible({ timeout: 20_000 });
|
||||
await expect(worksheetCard).toContainText("축어록 기반 자동 초안");
|
||||
const worksheetInputs = worksheetCard.locator(".sr-ws-input");
|
||||
await expect(worksheetInputs.first()).toBeVisible();
|
||||
|
||||
const savedWorksheetValue = `E2E 저장 사례개념화 ${Date.now()}`;
|
||||
await worksheetInputs.first().fill(savedWorksheetValue);
|
||||
await worksheetCard.getByRole("button", { name: "저장" }).click();
|
||||
await expect(worksheetCard).toContainText("저장된 학습자 제출본", { timeout: 15_000 });
|
||||
|
||||
const learnerReviewResponse = await page.request.get(`/api/sessions/${sessionId}/review`);
|
||||
await expectResponseOk(learnerReviewResponse);
|
||||
const learnerReview = (await learnerReviewResponse.json()) as SessionReviewResponse;
|
||||
expect(learnerReview.caseWorksheet?.status).toBe("saved_by_learner");
|
||||
const learnerWorksheetValues = (learnerReview.caseWorksheet?.sections ?? []).flatMap((section) =>
|
||||
(section.items ?? []).map((item) => item.value ?? ""),
|
||||
);
|
||||
expect(learnerWorksheetValues).toContain(savedWorksheetValue);
|
||||
|
||||
await signInAsTeacher(page);
|
||||
await page.goto(`/teach/session/${sessionId}/review`);
|
||||
await expect(worksheetCard).toBeVisible({ timeout: 20_000 });
|
||||
await expect(worksheetCard).toContainText("학습자 저장본 읽기 전용");
|
||||
await expect(worksheetCard.locator(".sr-ws-input").first()).toHaveValue(savedWorksheetValue);
|
||||
|
||||
const teacherWorksheetNote = `보호요인 보강 요청 ${Date.now()}`;
|
||||
await page.getByLabel("워크시트 검수 메모").fill(teacherWorksheetNote);
|
||||
await page.getByRole("button", { name: "수정요청" }).click();
|
||||
await expect(page.locator(".sr-teacher-review__worksheet")).toContainText("수정요청", {
|
||||
timeout: 15_000,
|
||||
});
|
||||
await expect(page.locator(".sr-teacher-review__worksheet")).toContainText("워크시트 검수 시각");
|
||||
|
||||
const teacherReviewResponse = await page.request.get(`/api/sessions/${sessionId}/review`);
|
||||
await expectResponseOk(teacherReviewResponse);
|
||||
const teacherReview = (await teacherReviewResponse.json()) as SessionReviewResponse;
|
||||
expect(teacherReview.caseWorksheet?.status).toBe("saved_by_learner");
|
||||
expect(teacherReview.teacherReview?.worksheetStatus).toBe("changes_requested");
|
||||
expect(teacherReview.teacherReview?.worksheetNote).toBe(teacherWorksheetNote);
|
||||
const teacherWorksheetValues = (teacherReview.caseWorksheet?.sections ?? []).flatMap((section) =>
|
||||
(section.items ?? []).map((item) => item.value ?? ""),
|
||||
);
|
||||
expect(teacherWorksheetValues).toContain(savedWorksheetValue);
|
||||
});
|
||||
|
||||
test("persists Phase 3 pre/post pilot scores through the DB-backed review UI @single-run", async ({
|
||||
page,
|
||||
}) => {
|
||||
test.setTimeout(90_000);
|
||||
|
||||
const healthResponse = await page.request.get("/api/health");
|
||||
await expectResponseOk(healthResponse);
|
||||
const health = (await healthResponse.json()) as HealthResponse;
|
||||
test.skip(!health.db, "pre/post pilot score persistence requires DB");
|
||||
|
||||
await signInAsLearner(page);
|
||||
const sessionId = await createEndedSessionWithoutTurn(page);
|
||||
|
||||
await page.goto(`/learn/session/${sessionId}/review`);
|
||||
await expect(page.locator(".sr-card--prepost")).toBeVisible({ timeout: 15_000 });
|
||||
await expect(page.locator(".sr-prepost__actions")).toContainText("저장된 값 기준");
|
||||
|
||||
const inputs = page.locator(".sr-card--prepost input");
|
||||
await expect(inputs).toHaveCount(6);
|
||||
const current = await inputs.evaluateAll((nodes) =>
|
||||
nodes.map((node) => (node as HTMLInputElement).value),
|
||||
);
|
||||
const primaryValues = ["4.9", "4.7", "3.9", "4.1", "2.9", "3.8"];
|
||||
const alternateValues = ["4.8", "4.6", "3.8", "4.2", "2.8", "3.7"];
|
||||
const targetValues =
|
||||
current.join("|") === primaryValues.join("|") ? alternateValues : primaryValues;
|
||||
|
||||
for (let index = 0; index < targetValues.length; index += 1) {
|
||||
await inputs.nth(index).fill(targetValues[index]);
|
||||
}
|
||||
await expect(page.locator(".sr-prepost__actions")).toContainText("6개 변경");
|
||||
await page.getByRole("button", { name: "점수 저장" }).click();
|
||||
await expect(page.getByText("3/3 쌍")).toBeVisible({ timeout: 15_000 });
|
||||
await expect(page.locator(".sr-prepost__actions")).toContainText("저장된 값 기준");
|
||||
await expect(
|
||||
page.getByText("효과 판정과 통계 검정은 평가설계 확정 후 별도 산출합니다."),
|
||||
).toBeVisible();
|
||||
|
||||
const prepostResponse = await page.request.get(
|
||||
"/api/users/me/prepost-measures?pilot_id=phase3-pilot-draft",
|
||||
);
|
||||
await expectResponseOk(prepostResponse);
|
||||
const prepost = (await prepostResponse.json()) as PrepostMeasuresResponse;
|
||||
expect(prepost.source).toBe("database");
|
||||
expect(prepost.durable).toBe(true);
|
||||
expect(prepost.complete_measure_pairs).toBe(3);
|
||||
const byKey = new Map(
|
||||
prepost.measures.map((measure) => [
|
||||
`${measure.measure_name}:${measure.timepoint}`,
|
||||
measure.raw_score,
|
||||
]),
|
||||
);
|
||||
expect(byKey.get("self_efficacy:pre")).toBe(Number(targetValues[0]));
|
||||
expect(byKey.get("self_efficacy:post")).toBe(Number(targetValues[1]));
|
||||
expect(byKey.get("skill_proficiency:pre")).toBe(Number(targetValues[2]));
|
||||
expect(byKey.get("skill_proficiency:post")).toBe(Number(targetValues[3]));
|
||||
expect(byKey.get("training_satisfaction:pre")).toBe(Number(targetValues[4]));
|
||||
expect(byKey.get("training_satisfaction:post")).toBe(Number(targetValues[5]));
|
||||
|
||||
await page.reload();
|
||||
await expect(page.locator(".sr-card--prepost")).toBeVisible({ timeout: 15_000 });
|
||||
await expect(page.getByText("3/3 쌍")).toBeVisible();
|
||||
for (let index = 0; index < targetValues.length; index += 1) {
|
||||
await expect(inputs.nth(index)).toHaveValue(targetValues[index]);
|
||||
}
|
||||
});
|
||||
|
||||
test("finishes session end evaluation into a durable DB-backed teacher review @single-run", async ({
|
||||
page,
|
||||
}) => {
|
||||
test.setTimeout(240_000);
|
||||
|
||||
const healthResponse = await page.request.get("/api/health");
|
||||
await expectResponseOk(healthResponse);
|
||||
const health = (await healthResponse.json()) as HealthResponse;
|
||||
test.skip(!health.db || !health.engine, "session end evaluation requires DB and engine");
|
||||
|
||||
await signInAsLearner(page);
|
||||
const { sessionId, expectedMinTurns } = await createSessionWithTurn(page);
|
||||
expect(
|
||||
expectedMinTurns,
|
||||
"session end evaluation smoke requires a completed learner/client turn",
|
||||
).toBeGreaterThanOrEqual(2);
|
||||
|
||||
await signInAsTeacher(page);
|
||||
await expect
|
||||
.poll(async () => evaluationState(page, sessionId), {
|
||||
timeout: 180_000,
|
||||
intervals: [1_000, 2_000, 5_000, 10_000],
|
||||
message: "session end background evaluation should create a durable ready row",
|
||||
})
|
||||
.toMatch(/^ready:[1-9]\d*$/);
|
||||
|
||||
const reviewResponse = await page.request.get(`/api/sessions/${sessionId}/review`);
|
||||
await expectResponseOk(reviewResponse);
|
||||
const review = (await reviewResponse.json()) as SessionReviewResponse;
|
||||
expect(review.reviewReady).toBe(true);
|
||||
expect(review.degraded).toBe(false);
|
||||
expect(review.supervisorState).toBe("평가 완료");
|
||||
expect(review.summary ?? "").toContain("평가 AI");
|
||||
|
||||
await page.goto(`/teach/session/${sessionId}/review`);
|
||||
await expect(page.locator(".sr-head__stats")).toContainText("평가 완료", {
|
||||
timeout: 15_000,
|
||||
});
|
||||
await expect(page.locator('[aria-label="리뷰 생성 상태"]')).toContainText("준비됨");
|
||||
await expect(page.getByRole("button", { name: "AI 평가 재시도" })).toHaveCount(0);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -1,10 +1,15 @@
|
|||
import { expect, test, type Page, type Response } from "@playwright/test";
|
||||
import {
|
||||
FILLED_REVIEW_SESSION_ID,
|
||||
filledReviewResponse,
|
||||
routeFilledSessionReview,
|
||||
routePrepostMeasures,
|
||||
} from "./session-review-fixture";
|
||||
import { expectNoHorizontalOverflow, fetchAvailablePersona, signInAsLearner } from "./support";
|
||||
import {
|
||||
expectNoHorizontalOverflow,
|
||||
fetchAvailablePersona,
|
||||
signInAsLearner,
|
||||
} from "./support";
|
||||
|
||||
interface SessionStartResponse {
|
||||
session_id: string;
|
||||
|
|
@ -16,6 +21,31 @@ async function expectResponseOk(response: { ok: () => boolean; text: () => Promi
|
|||
}
|
||||
}
|
||||
|
||||
async function routeSupervisorCapableLearner(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-000000000303",
|
||||
email: "yunchan@twentyoz.kr",
|
||||
role: "learner",
|
||||
display_name: "Yun Chan",
|
||||
admin_access: true,
|
||||
super_admin: true,
|
||||
account_status: "approved",
|
||||
approval_required: false,
|
||||
cohort_ids: [],
|
||||
consent_at: 1782820000,
|
||||
onboarding_completed_at: 1782820001,
|
||||
nickname: "Yun Chan",
|
||||
self_introduction: "",
|
||||
avatar_url: "",
|
||||
}),
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function isReviewResponse(sessionId: string) {
|
||||
return (response: Response) => {
|
||||
const url = new URL(response.url());
|
||||
|
|
@ -102,6 +132,243 @@ async function expectReviewRegionsReachable(page: Page) {
|
|||
}
|
||||
|
||||
test.describe("session review", () => {
|
||||
test("redirects supervisor-capable users from learner 403 into the teacher review route", async ({
|
||||
page,
|
||||
}) => {
|
||||
await routeSupervisorCapableLearner(page);
|
||||
await routePrepostMeasures(page);
|
||||
|
||||
const sessionId = "supervisor-redirect-review";
|
||||
let reviewAttempts = 0;
|
||||
await page.route(`**/api/sessions/${sessionId}/review`, async (route) => {
|
||||
reviewAttempts += 1;
|
||||
const referer = route.request().headers().referer ?? page.url();
|
||||
if (!referer.includes(`/teach/session/${sessionId}/review`)) {
|
||||
await route.fulfill({
|
||||
status: 403,
|
||||
contentType: "application/json",
|
||||
body: JSON.stringify({ detail: "session does not belong to user" }),
|
||||
});
|
||||
return;
|
||||
}
|
||||
await route.fulfill({
|
||||
status: 200,
|
||||
contentType: "application/json",
|
||||
body: JSON.stringify(filledReviewResponse(sessionId)),
|
||||
});
|
||||
});
|
||||
|
||||
await page.goto(`/learn/session/${sessionId}/review`);
|
||||
|
||||
await expect(page).toHaveURL(new RegExp(`/teach/session/${sessionId}/review$`));
|
||||
await expect(page.getByText("교수자 검토", { exact: true })).toBeVisible();
|
||||
expect(reviewAttempts).toBeGreaterThanOrEqual(2);
|
||||
});
|
||||
|
||||
test("normalizes a successful supervisor review response away from the learner route", async ({
|
||||
page,
|
||||
}) => {
|
||||
await routeSupervisorCapableLearner(page);
|
||||
|
||||
const sessionId = "supervisor-success-review";
|
||||
let reviewAttempts = 0;
|
||||
await page.route(`**/api/sessions/${sessionId}/review`, async (route) => {
|
||||
reviewAttempts += 1;
|
||||
await route.fulfill({
|
||||
status: 200,
|
||||
contentType: "application/json",
|
||||
body: JSON.stringify({
|
||||
...filledReviewResponse(sessionId),
|
||||
teacherReview: {
|
||||
status: "pending",
|
||||
note: "",
|
||||
reviewedAt: null,
|
||||
reviewerId: null,
|
||||
updatedAt: null,
|
||||
worksheetStatus: "pending",
|
||||
worksheetNote: "",
|
||||
worksheetReviewedAt: null,
|
||||
},
|
||||
}),
|
||||
});
|
||||
});
|
||||
|
||||
await page.goto(`/learn/session/${sessionId}/review`);
|
||||
|
||||
await expect(page).toHaveURL(new RegExp(`/teach/session/${sessionId}/review$`));
|
||||
await expect(page.getByText("교수자 검토", { exact: true })).toBeVisible();
|
||||
await expect(page.locator(".sr-card--prepost")).toHaveCount(0);
|
||||
expect(reviewAttempts).toBeGreaterThanOrEqual(2);
|
||||
});
|
||||
|
||||
test("refreshes an ended review from evaluation pending to ready", async ({ page }) => {
|
||||
await routeSupervisorCapableLearner(page);
|
||||
await routePrepostMeasures(page);
|
||||
|
||||
const sessionId = "review-pending-then-ready";
|
||||
let reviewAttempts = 0;
|
||||
let firstResponseWasPending = false;
|
||||
await page.route(`**/api/sessions/${sessionId}/review`, async (route) => {
|
||||
reviewAttempts += 1;
|
||||
const ready = {
|
||||
...filledReviewResponse(sessionId),
|
||||
summary: "평가 AI가 저장된 축어록을 분석했습니다.",
|
||||
};
|
||||
const pending = {
|
||||
...ready,
|
||||
supervisorState: "평가 대기",
|
||||
summary: "평가 AI가 저장된 축어록을 분석 중입니다.",
|
||||
rubric: [],
|
||||
goodMoments: [],
|
||||
growthPoints: [],
|
||||
degraded: true,
|
||||
reviewReady: false,
|
||||
};
|
||||
if (reviewAttempts === 1) firstResponseWasPending = true;
|
||||
await route.fulfill({
|
||||
status: 200,
|
||||
contentType: "application/json",
|
||||
body: JSON.stringify(reviewAttempts === 1 ? pending : ready),
|
||||
});
|
||||
});
|
||||
|
||||
await page.goto(`/learn/session/${sessionId}/review`);
|
||||
|
||||
await expect(page.getByText("평가 AI가 저장된 축어록을 분석했습니다.")).toBeVisible({
|
||||
timeout: 5000,
|
||||
});
|
||||
await expect(page.getByText("평가 완료")).toBeVisible();
|
||||
expect(firstResponseWasPending).toBe(true);
|
||||
expect(reviewAttempts).toBeGreaterThanOrEqual(2);
|
||||
});
|
||||
|
||||
test("lets supervisor retry a failed AI session evaluation", async ({ page }) => {
|
||||
await routeSupervisorCapableLearner(page);
|
||||
await routePrepostMeasures(page);
|
||||
|
||||
const sessionId = "review-failed-retry";
|
||||
let reevaluateRequests = 0;
|
||||
let retrySucceeded = false;
|
||||
await page.route(`**/api/sessions/${sessionId}/review`, async (route) => {
|
||||
const ready = filledReviewResponse(sessionId);
|
||||
const failed = {
|
||||
...ready,
|
||||
supervisorState: "평가 실패",
|
||||
summary:
|
||||
"저장된 축어록은 확인했지만 평가 AI 산출물이 아직 준비되지 않았습니다. 사유: engine_error: evaluator timeout",
|
||||
rubric: [],
|
||||
goodMoments: [],
|
||||
growthPoints: [],
|
||||
degraded: true,
|
||||
reviewReady: false,
|
||||
teacherReview: {
|
||||
status: "pending",
|
||||
note: "",
|
||||
reviewedAt: null,
|
||||
reviewerId: null,
|
||||
updatedAt: null,
|
||||
worksheetStatus: "pending",
|
||||
worksheetNote: "",
|
||||
worksheetReviewedAt: null,
|
||||
},
|
||||
};
|
||||
await route.fulfill({
|
||||
status: 200,
|
||||
contentType: "application/json",
|
||||
body: JSON.stringify(retrySucceeded ? ready : failed),
|
||||
});
|
||||
});
|
||||
await page.route(`**/api/eval/sessions/${sessionId}/reevaluate`, async (route) => {
|
||||
reevaluateRequests += 1;
|
||||
retrySucceeded = true;
|
||||
await route.fulfill({
|
||||
status: 200,
|
||||
contentType: "application/json",
|
||||
body: JSON.stringify({
|
||||
loop: "deep",
|
||||
session_id: sessionId,
|
||||
stage: "정리",
|
||||
scope: "session_end",
|
||||
turns_evaluated: 2,
|
||||
distribution: {},
|
||||
theory_mode: "humanistic",
|
||||
strengths: [],
|
||||
improvements: [],
|
||||
intent_deviations: [],
|
||||
error: null,
|
||||
}),
|
||||
});
|
||||
});
|
||||
|
||||
await page.goto(`/teach/session/${sessionId}/review`);
|
||||
await expect(page.getByText("평가 실패")).toBeVisible();
|
||||
await expect(page.getByRole("button", { name: "AI 평가 재시도" })).toBeVisible();
|
||||
|
||||
await page.getByRole("button", { name: "AI 평가 재시도" }).click();
|
||||
|
||||
await expect(page.getByText("평가 완료")).toBeVisible();
|
||||
await expect(page.getByText("평가 실패")).toHaveCount(0);
|
||||
expect(reevaluateRequests).toBe(1);
|
||||
});
|
||||
|
||||
test("keeps the failed review visible when AI evaluation retry fails", async ({ page }) => {
|
||||
await routeSupervisorCapableLearner(page);
|
||||
await routePrepostMeasures(page);
|
||||
|
||||
const sessionId = "review-failed-retry-error";
|
||||
let reviewRequests = 0;
|
||||
let reevaluateRequests = 0;
|
||||
await page.route(`**/api/sessions/${sessionId}/review`, async (route) => {
|
||||
reviewRequests += 1;
|
||||
const ready = filledReviewResponse(sessionId);
|
||||
await route.fulfill({
|
||||
status: 200,
|
||||
contentType: "application/json",
|
||||
body: JSON.stringify({
|
||||
...ready,
|
||||
supervisorState: "평가 실패",
|
||||
summary:
|
||||
"저장된 축어록은 확인했지만 평가 AI 산출물이 아직 준비되지 않았습니다. 사유: session evaluation timeout after 45s",
|
||||
rubric: [],
|
||||
goodMoments: [],
|
||||
growthPoints: [],
|
||||
degraded: true,
|
||||
reviewReady: false,
|
||||
teacherReview: {
|
||||
status: "pending",
|
||||
note: "",
|
||||
reviewedAt: null,
|
||||
reviewerId: null,
|
||||
updatedAt: null,
|
||||
worksheetStatus: "pending",
|
||||
worksheetNote: "",
|
||||
worksheetReviewedAt: null,
|
||||
},
|
||||
}),
|
||||
});
|
||||
});
|
||||
await page.route(`**/api/eval/sessions/${sessionId}/reevaluate`, async (route) => {
|
||||
reevaluateRequests += 1;
|
||||
await route.fulfill({
|
||||
status: 503,
|
||||
contentType: "application/json",
|
||||
body: JSON.stringify({ detail: "engine_error: retry timeout" }),
|
||||
});
|
||||
});
|
||||
|
||||
await page.goto(`/teach/session/${sessionId}/review`);
|
||||
await expect(page.getByText("평가 실패")).toBeVisible();
|
||||
const reviewRequestsBeforeRetry = reviewRequests;
|
||||
|
||||
await page.getByRole("button", { name: "AI 평가 재시도" }).click();
|
||||
|
||||
await expect(page.getByText("engine_error: retry timeout")).toBeVisible();
|
||||
await expect(page.getByText("평가 실패")).toBeVisible();
|
||||
await expect(page.getByRole("button", { name: "AI 평가 재시도" })).toBeEnabled();
|
||||
expect(reevaluateRequests).toBe(1);
|
||||
expect(reviewRequests).toBe(reviewRequestsBeforeRetry);
|
||||
});
|
||||
|
||||
test("renders server review data without legacy transcript fixtures", async ({ page }) => {
|
||||
await signInAsLearner(page);
|
||||
await routePrepostMeasures(page);
|
||||
|
|
|
|||
|
|
@ -518,6 +518,141 @@ test.describe("teacher console", () => {
|
|||
await expectNoHorizontalOverflow(page);
|
||||
});
|
||||
|
||||
test("shows selected learner analysis with the full session timeline @single-run", async ({
|
||||
page,
|
||||
}) => {
|
||||
const learnerA = "teacher-analysis-learner-a";
|
||||
const learnerB = "teacher-analysis-learner-b";
|
||||
const sessionFor = (learnerId: string, label: string, index: number) => ({
|
||||
session_id: `${learnerId}-session-${index}`,
|
||||
learner_id: learnerId,
|
||||
learner_label: label,
|
||||
persona_code: index % 2 === 0 ? "P2" : "P1",
|
||||
persona_name: index % 2 === 0 ? "민재" : "서연",
|
||||
session_no: index,
|
||||
status: "ended",
|
||||
stage: index > 4 ? "개입" : "탐색",
|
||||
turn_count: 8 + index,
|
||||
learner_turn_count: 4 + index,
|
||||
client_turn_count: 4,
|
||||
started_at: `2026-06-${String(10 + index).padStart(2, "0")}T09:00:00Z`,
|
||||
ended_at: `2026-06-${String(10 + index).padStart(2, "0")}T09:30:00Z`,
|
||||
review_status: index === 2 ? "closed" : "pending",
|
||||
review_note: null,
|
||||
reviewed_at: index === 2 ? "2026-06-12T10:00:00Z" : null,
|
||||
});
|
||||
const pointFor = (learnerId: string, index: number) => ({
|
||||
session_id: `${learnerId}-session-${index}`,
|
||||
session_no: index,
|
||||
persona_code: index % 2 === 0 ? "P2" : "P1",
|
||||
stage: index > 4 ? "개입" : "탐색",
|
||||
started_at: `2026-06-${String(10 + index).padStart(2, "0")}T09:00:00Z`,
|
||||
ended_at: `2026-06-${String(10 + index).padStart(2, "0")}T09:30:00Z`,
|
||||
score: index >= 5 ? 1 : 0.5,
|
||||
rapport: Math.min(0.8, index * 0.1),
|
||||
technique_count: index + 1,
|
||||
watch_count: index < 5 ? 1 : 0,
|
||||
});
|
||||
const analysisFor = (learnerId: string, label: string, count: number) => {
|
||||
const sessions = Array.from({ length: count }, (_unused, idx) =>
|
||||
sessionFor(learnerId, label, idx + 1),
|
||||
);
|
||||
const points = Array.from({ length: count }, (_unused, idx) =>
|
||||
pointFor(learnerId, idx + 1),
|
||||
);
|
||||
return {
|
||||
source: "database",
|
||||
learner_id: learnerId,
|
||||
learner_label: label,
|
||||
total_sessions: count,
|
||||
active_sessions: 0,
|
||||
ended_sessions: count,
|
||||
pending_reviews: Math.max(0, count - 1),
|
||||
closed_reviews: count > 1 ? 1 : 0,
|
||||
summary: {
|
||||
learner_id: learnerId,
|
||||
learner_label: label,
|
||||
sessions: count,
|
||||
ended_sessions: count,
|
||||
latest_at: sessions[count - 1].ended_at ?? "",
|
||||
first_score: points[0].score,
|
||||
latest_score: points[count - 1].score,
|
||||
score_delta: (points[count - 1].score ?? 0) - (points[0].score ?? 0),
|
||||
avg_score: 0.75,
|
||||
avg_rapport: 0.4,
|
||||
trend: count > 3 ? "up" : "flat",
|
||||
top_techniques: ["reflection", "open question"],
|
||||
points,
|
||||
},
|
||||
points,
|
||||
stage_breakdown: [
|
||||
{ stage: "라포", sessions: 0, turns: 0 },
|
||||
{ stage: "탐색", sessions: Math.min(4, count), turns: 34 },
|
||||
{ stage: "개입", sessions: Math.max(0, count - 4), turns: 42 },
|
||||
{ stage: "정리", sessions: 0, turns: 0 },
|
||||
],
|
||||
sessions,
|
||||
message: `${label} 전체 회기 ${count}건`,
|
||||
};
|
||||
};
|
||||
const analysisA = analysisFor(learnerA, "사용자 A", 7);
|
||||
const analysisB = analysisFor(learnerB, "사용자 B", 2);
|
||||
|
||||
await page.route("**/api/teacher/dashboard", (route) =>
|
||||
route.fulfill({
|
||||
status: 200,
|
||||
contentType: "application/json",
|
||||
body: JSON.stringify({
|
||||
source: "database",
|
||||
cohort_label: "E2E cohort",
|
||||
total_learners: 2,
|
||||
active_sessions: 0,
|
||||
ended_sessions: 9,
|
||||
learner_growth: [
|
||||
{ ...analysisA.summary, points: analysisA.points.slice(-6) },
|
||||
{ ...analysisB.summary, points: analysisB.points },
|
||||
],
|
||||
safety_alerts: [],
|
||||
pending_reviews: [],
|
||||
recent_sessions: analysisA.sessions.slice(-2),
|
||||
message: "사용자별 분석 fixture.",
|
||||
}),
|
||||
}),
|
||||
);
|
||||
await page.route("**/api/teacher/learners/*/analysis", (route) => {
|
||||
const path = new URL(route.request().url()).pathname;
|
||||
const body = path.includes(learnerB) ? analysisB : analysisA;
|
||||
return route.fulfill({
|
||||
status: 200,
|
||||
contentType: "application/json",
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
});
|
||||
await page.route("**/api/personas/review", (route) =>
|
||||
route.fulfill({ status: 200, contentType: "application/json", body: "[]" }),
|
||||
);
|
||||
|
||||
await signInAsTeacher(page);
|
||||
await page.goto("/teach");
|
||||
|
||||
await expect(page.getByRole("link", { name: "학생 분석" })).toBeVisible();
|
||||
await expect(page.locator('[data-learner-analysis-panel="true"]')).toHaveCount(0);
|
||||
await page.getByRole("link", { name: "학생 분석" }).click();
|
||||
await expect(page).toHaveURL(/\/teach\/analysis$/);
|
||||
|
||||
const analysisPanel = page.locator('[data-learner-analysis-panel="true"]');
|
||||
await expect(analysisPanel).toBeVisible();
|
||||
await expect(analysisPanel.locator('[data-learner-session-row="true"]')).toHaveCount(7);
|
||||
await expect(analysisPanel.getByText(`${learnerA}-session-7`)).toBeVisible();
|
||||
await expect(page.getByText("전체 회기 7건")).toBeVisible();
|
||||
|
||||
await page.getByRole("button", { name: "사용자 B 사용자별 분석" }).click();
|
||||
await expect(analysisPanel.locator('[data-learner-session-row="true"]')).toHaveCount(2);
|
||||
await expect(analysisPanel.getByText(`${learnerB}-session-2`)).toBeVisible();
|
||||
await expect(analysisPanel.getByText(`${learnerA}-session-7`)).toHaveCount(0);
|
||||
await expectNoHorizontalOverflow(page);
|
||||
});
|
||||
|
||||
test("opens a pending session review from the teacher queue @single-run", async ({ page }) => {
|
||||
const sessionId = "teacher-review-fixture-session";
|
||||
await page.route("**/api/teacher/dashboard", (route) =>
|
||||
|
|
|
|||
|
|
@ -587,7 +587,20 @@ async function installSyntheticVoiceCapture(
|
|||
if (this.closed) return;
|
||||
probe.workletChunks += 1;
|
||||
const pcm = new Int16Array([0, 1024, -1024, 0]);
|
||||
this.onmessage?.({ data: { type: "chunk", pcm: pcm.buffer } } as MessageEvent);
|
||||
this.onmessage?.({
|
||||
data: {
|
||||
type: "chunk",
|
||||
pcm: pcm.buffer,
|
||||
metrics: {
|
||||
durationMs: 1600,
|
||||
voiceMs: 350,
|
||||
silenceMs: 1250,
|
||||
trailingSilenceMs: 1250,
|
||||
rms: 0.08,
|
||||
peak: 0.64,
|
||||
},
|
||||
},
|
||||
} as MessageEvent);
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -1001,6 +1014,40 @@ test.describe("voice cascade success path", () => {
|
|||
);
|
||||
}, { timeout: 10_000 })
|
||||
.toBeTruthy();
|
||||
const sentAudioEndControls = (await readVoiceUiProbe(page)).messages
|
||||
.filter(
|
||||
(message) =>
|
||||
message.direction === "sent" &&
|
||||
message.kind === "text" &&
|
||||
message.data?.includes('"audio_end"'),
|
||||
)
|
||||
.map((message) => JSON.parse(message.data ?? "{}") as Record<string, unknown>);
|
||||
expect(
|
||||
sentAudioEndControls.some((control) => {
|
||||
const events = Array.isArray(control.provider_events) ? control.provider_events : [];
|
||||
return (
|
||||
control.format === "pcm" &&
|
||||
control.silence_ms === 1250 &&
|
||||
control.barge_in === false &&
|
||||
events.some((event) => {
|
||||
const record = event as Record<string, unknown>;
|
||||
return (
|
||||
record.type === "voice_activity" &&
|
||||
record.source === "browser_audio_worklet" &&
|
||||
record.duration_ms === 350
|
||||
);
|
||||
}) &&
|
||||
events.some((event) => {
|
||||
const record = event as Record<string, unknown>;
|
||||
return (
|
||||
record.type === "silence" &&
|
||||
record.source === "browser_audio_worklet" &&
|
||||
record.duration_ms === 1250
|
||||
);
|
||||
})
|
||||
);
|
||||
}),
|
||||
).toBeTruthy();
|
||||
|
||||
await expect
|
||||
.poll(async () => {
|
||||
|
|
|
|||
|
|
@ -4,6 +4,13 @@ class VoiceCaptureProcessor extends AudioWorkletProcessor {
|
|||
this.pending = [];
|
||||
this.pendingLength = 0;
|
||||
this.flushSamples = Math.max(1024, Math.floor(sampleRate * 0.25));
|
||||
this.totalSamples = 0;
|
||||
this.voiceSamples = 0;
|
||||
this.silentSamples = 0;
|
||||
this.trailingSilentSamples = 0;
|
||||
this.sumSquares = 0;
|
||||
this.peak = 0;
|
||||
this.voiceThreshold = 0.018;
|
||||
this.port.onmessage = (event) => {
|
||||
if (event.data && event.data.type === "flush") {
|
||||
this.flush();
|
||||
|
|
@ -19,6 +26,17 @@ class VoiceCaptureProcessor extends AudioWorkletProcessor {
|
|||
const pcm = new Int16Array(channel.length);
|
||||
for (let i = 0; i < channel.length; i += 1) {
|
||||
const sample = Math.max(-1, Math.min(1, channel[i]));
|
||||
const abs = Math.abs(sample);
|
||||
this.totalSamples += 1;
|
||||
this.sumSquares += sample * sample;
|
||||
if (abs > this.peak) this.peak = abs;
|
||||
if (abs >= this.voiceThreshold) {
|
||||
this.voiceSamples += 1;
|
||||
this.trailingSilentSamples = 0;
|
||||
} else {
|
||||
this.silentSamples += 1;
|
||||
this.trailingSilentSamples += 1;
|
||||
}
|
||||
pcm[i] = sample < 0 ? sample * 0x8000 : sample * 0x7fff;
|
||||
}
|
||||
|
||||
|
|
@ -46,11 +64,28 @@ class VoiceCaptureProcessor extends AudioWorkletProcessor {
|
|||
sampleRate,
|
||||
channels: 1,
|
||||
sampleWidth: 2,
|
||||
metrics: this.metrics(),
|
||||
pcm: merged.buffer,
|
||||
},
|
||||
[merged.buffer],
|
||||
);
|
||||
}
|
||||
|
||||
metrics() {
|
||||
const durationMs = (this.totalSamples / sampleRate) * 1000;
|
||||
const voiceMs = (this.voiceSamples / sampleRate) * 1000;
|
||||
const silenceMs = (this.silentSamples / sampleRate) * 1000;
|
||||
const trailingSilenceMs = (this.trailingSilentSamples / sampleRate) * 1000;
|
||||
const rms = this.totalSamples > 0 ? Math.sqrt(this.sumSquares / this.totalSamples) : 0;
|
||||
return {
|
||||
durationMs,
|
||||
voiceMs,
|
||||
silenceMs,
|
||||
trailingSilenceMs,
|
||||
rms,
|
||||
peak: this.peak,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
registerProcessor("voice-capture-processor", VoiceCaptureProcessor);
|
||||
|
|
|
|||
|
|
@ -6,6 +6,7 @@
|
|||
/learn/session/:sessionId -> Session
|
||||
/learn/session/:sessionId/review -> SessionReview
|
||||
/teach -> Professor (teacher → data-role=instructor)
|
||||
/teach/analysis -> Professor (teacher learner analysis)
|
||||
/teach/personas -> PersonaStudio (teacher/admin)
|
||||
/teach/session/:sessionId/review -> SessionReview (teacher read-only)
|
||||
/admin -> Admin (admin)
|
||||
|
|
@ -209,6 +210,14 @@ function AppRoutes() {
|
|||
</RequireAuth>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="/teach/analysis"
|
||||
element={
|
||||
<RequireAuth roles={["teacher"]}>
|
||||
<Professor view="analysis" />
|
||||
</RequireAuth>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="/teach/personas"
|
||||
element={
|
||||
|
|
|
|||
|
|
@ -23,6 +23,7 @@ const NAV_BY_ROLE: Record<Role, NavItem[]> = {
|
|||
],
|
||||
teacher: [
|
||||
{ to: "/teach", label: "콘솔", icon: "users", end: true },
|
||||
{ to: "/teach/analysis", label: "학생 분석", icon: "review" },
|
||||
{ to: "/teach/personas", label: "페르소나", icon: "review" },
|
||||
{ to: "/settings", label: "설정", icon: "settings" },
|
||||
],
|
||||
|
|
@ -32,6 +33,7 @@ const NAV_BY_ROLE: Record<Role, NavItem[]> = {
|
|||
{ to: "/admin/access", label: "권한", icon: "settings" },
|
||||
{ to: "/admin/tickets", label: "티켓", icon: "review" },
|
||||
{ to: "/teach", label: "교수 콘솔", icon: "users", end: true },
|
||||
{ to: "/teach/analysis", label: "학생 분석", icon: "review" },
|
||||
{ to: "/teach/personas", label: "페르소나", icon: "review" },
|
||||
{ to: "/learn", label: "학습자 홈", icon: "home", end: true },
|
||||
{ to: "/learn/practice", label: "학습", icon: "session" },
|
||||
|
|
|
|||
|
|
@ -1144,6 +1144,26 @@ export interface paths {
|
|||
patch?: never;
|
||||
trace?: never;
|
||||
};
|
||||
"/teacher/learners/{learner_id}/analysis": {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path?: never;
|
||||
cookie?: never;
|
||||
};
|
||||
/**
|
||||
* Learner Analysis
|
||||
* @description Return one learner's full teacher-visible session timeline and trend.
|
||||
*/
|
||||
get: operations["learner_analysis_teacher_learners__learner_id__analysis_get"];
|
||||
put?: never;
|
||||
post?: never;
|
||||
delete?: never;
|
||||
options?: never;
|
||||
head?: never;
|
||||
patch?: never;
|
||||
trace?: never;
|
||||
};
|
||||
"/teacher/sessions/{session_id}/review-status": {
|
||||
parameters: {
|
||||
query?: never;
|
||||
|
|
@ -1987,10 +2007,19 @@ export interface components {
|
|||
deep?: Record<string, never> | null;
|
||||
/** Distribution */
|
||||
distribution?: Record<string, never>;
|
||||
/**
|
||||
* Durable
|
||||
* @default false
|
||||
*/
|
||||
durable: boolean;
|
||||
/** Error */
|
||||
error?: string | null;
|
||||
/** Session Id */
|
||||
session_id: string;
|
||||
/** Stage */
|
||||
stage?: ("라포" | "탐색" | "개입" | "정리") | null;
|
||||
/** Status */
|
||||
status?: string | null;
|
||||
};
|
||||
/** HTTPValidationError */
|
||||
HTTPValidationError: {
|
||||
|
|
@ -3600,6 +3629,37 @@ export interface components {
|
|||
*/
|
||||
watch_count: number;
|
||||
};
|
||||
/** TeacherLearnerAnalysisResponse */
|
||||
TeacherLearnerAnalysisResponse: {
|
||||
/** Active Sessions */
|
||||
active_sessions: number;
|
||||
/** Closed Reviews */
|
||||
closed_reviews: number;
|
||||
/** Ended Sessions */
|
||||
ended_sessions: number;
|
||||
/** Learner Id */
|
||||
learner_id: string;
|
||||
/** Learner Label */
|
||||
learner_label: string;
|
||||
/** Message */
|
||||
message: string;
|
||||
/** Pending Reviews */
|
||||
pending_reviews: number;
|
||||
/** Points */
|
||||
points?: components["schemas"]["TeacherGrowthPoint"][];
|
||||
/** Sessions */
|
||||
sessions?: components["schemas"]["TeacherSessionSummary"][];
|
||||
/**
|
||||
* Source
|
||||
* @default in_memory
|
||||
*/
|
||||
source: string;
|
||||
/** Stage Breakdown */
|
||||
stage_breakdown?: components["schemas"]["TeacherStageBreakdown"][];
|
||||
summary: components["schemas"]["TeacherLearnerGrowth"];
|
||||
/** Total Sessions */
|
||||
total_sessions: number;
|
||||
};
|
||||
/** TeacherLearnerGrowth */
|
||||
TeacherLearnerGrowth: {
|
||||
/** Avg Rapport */
|
||||
|
|
@ -3760,6 +3820,24 @@ export interface components {
|
|||
/** Turn Count */
|
||||
turn_count: number;
|
||||
};
|
||||
/** TeacherStageBreakdown */
|
||||
TeacherStageBreakdown: {
|
||||
/**
|
||||
* Sessions
|
||||
* @default 0
|
||||
*/
|
||||
sessions: number;
|
||||
/**
|
||||
* Stage
|
||||
* @enum {string}
|
||||
*/
|
||||
stage: "라포" | "탐색" | "개입" | "정리";
|
||||
/**
|
||||
* Turns
|
||||
* @default 0
|
||||
*/
|
||||
turns: number;
|
||||
};
|
||||
/**
|
||||
* TechniqueDistribution
|
||||
* @description deep-loop 기법 분포 — 군집별 카운트 + 과다/과소 진단.
|
||||
|
|
@ -6303,6 +6381,40 @@ export interface operations {
|
|||
};
|
||||
};
|
||||
};
|
||||
learner_analysis_teacher_learners__learner_id__analysis_get: {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path: {
|
||||
learner_id: string;
|
||||
};
|
||||
cookie?: {
|
||||
"__Host-vignette_sid"?: string | null;
|
||||
vignette_sid?: string | null;
|
||||
};
|
||||
};
|
||||
requestBody?: never;
|
||||
responses: {
|
||||
/** @description Successful Response */
|
||||
200: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content: {
|
||||
"application/json": components["schemas"]["TeacherLearnerAnalysisResponse"];
|
||||
};
|
||||
};
|
||||
/** @description Validation Error */
|
||||
422: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content: {
|
||||
"application/json": components["schemas"]["HTTPValidationError"];
|
||||
};
|
||||
};
|
||||
};
|
||||
};
|
||||
update_session_review_status_teacher_sessions__session_id__review_status_put: {
|
||||
parameters: {
|
||||
query?: never;
|
||||
|
|
|
|||
|
|
@ -205,6 +205,8 @@ export type LiveCoachEvent = ApiSchema<"LiveCoachEvent">;
|
|||
export type LiveCoachHistoryResponse = ApiSchema<"LiveCoachHistoryResponse">;
|
||||
export type LiveCoachSuggestion = ApiSchema<"LiveCoachSuggestion">;
|
||||
export type LiveCoachSource = ApiSchema<"LiveCoachSource">;
|
||||
export type ReevaluateRequest = ApiSchema<"ReevaluateRequest">;
|
||||
export type SessionEvaluationResponse = ApiSchema<"SessionEvaluationResponse">;
|
||||
|
||||
/** POST /sessions/{id}/end — sessions.py SessionEndResponse */
|
||||
export type SessionEndResponse = ApiSchema<"SessionEndResponse">;
|
||||
|
|
@ -474,6 +476,17 @@ export const sessionApi = {
|
|||
stream: openSessionStream,
|
||||
};
|
||||
|
||||
export const evalApi = {
|
||||
reevaluateSession: (
|
||||
sessionId: string,
|
||||
payload: ReevaluateRequest = { scope: "session_end" },
|
||||
) =>
|
||||
api.post<SessionEvaluationResponse>(
|
||||
`/eval/sessions/${encodeURIComponent(sessionId)}/reevaluate`,
|
||||
payload,
|
||||
),
|
||||
};
|
||||
|
||||
export type AdminHealthStatus = ApiSchema<"AdminHealthResponse">["status"];
|
||||
export type AdminServiceHealth = ApiSchema<"AdminServiceHealth">;
|
||||
export type AdminHealthResponse = ApiSchema<"AdminHealthResponse">;
|
||||
|
|
@ -547,14 +560,20 @@ export type TeacherSessionSummary = ApiSchema<"TeacherSessionSummary">;
|
|||
|
||||
export type TeacherSafetyAlert = ApiSchema<"TeacherSafetyAlert">;
|
||||
export type TeacherGrowthPoint = ApiSchema<"TeacherGrowthPoint">;
|
||||
export type TeacherStageBreakdown = ApiSchema<"TeacherStageBreakdown">;
|
||||
|
||||
export type TeacherLearnerGrowth = ApiSchema<"TeacherLearnerGrowth">;
|
||||
export type TeacherLearnerAnalysisResponse = ApiSchema<"TeacherLearnerAnalysisResponse">;
|
||||
export type TeacherDashboardResponse = ApiSchema<"TeacherDashboardResponse">;
|
||||
export type TeacherSessionReviewStatusRequest = ApiSchema<"TeacherSessionReviewStatusRequest">;
|
||||
export type TeacherSessionReviewStatusResponse = ApiSchema<"TeacherSessionReviewStatusResponse">;
|
||||
|
||||
export const teacherApi = {
|
||||
dashboard: () => api.get<TeacherDashboardResponse>("/teacher/dashboard"),
|
||||
learnerAnalysis: (learnerId: string) =>
|
||||
api.get<TeacherLearnerAnalysisResponse>(
|
||||
`/teacher/learners/${encodeURIComponent(learnerId)}/analysis`,
|
||||
),
|
||||
updateSessionReviewStatus: (
|
||||
sessionId: string,
|
||||
body: TeacherSessionReviewStatusRequest,
|
||||
|
|
|
|||
|
|
@ -9,14 +9,17 @@ import {
|
|||
type PersonaReviewAction,
|
||||
type PersonaReviewStatus,
|
||||
type PersonaReviewSummary,
|
||||
type TeacherLearnerAnalysisResponse,
|
||||
type TeacherLearnerGrowth,
|
||||
type TeacherGrowthPoint,
|
||||
type TeacherSafetyAlert,
|
||||
type TeacherDashboardResponse,
|
||||
type TeacherSessionSummary,
|
||||
} from "../lib/api";
|
||||
import "./professor.css";
|
||||
|
||||
type LoadState = "loading" | "ready" | "error";
|
||||
type ProfessorView = "console" | "analysis";
|
||||
|
||||
function formatDateTime(value: string | null): string {
|
||||
if (!value) return "-";
|
||||
|
|
@ -41,6 +44,11 @@ function formatDelta(value: number | null | undefined): string {
|
|||
return `${sign}${Math.round(value * 100)}%p`;
|
||||
}
|
||||
|
||||
function formatRapport(value: number | null | undefined): string {
|
||||
if (typeof value !== "number" || Number.isNaN(value)) return "라포 부족";
|
||||
return `${Math.round(((value + 1) / 2) * 100)}%`;
|
||||
}
|
||||
|
||||
function trendLabel(value: string): string {
|
||||
if (value === "up") return "상승";
|
||||
if (value === "down") return "하락";
|
||||
|
|
@ -85,16 +93,23 @@ function EmptyState({ title, desc }: { title: string; desc: string }) {
|
|||
</div>
|
||||
);
|
||||
}
|
||||
export default function Professor() {
|
||||
export default function Professor({ view = "console" }: { view?: ProfessorView }) {
|
||||
const navigate = useNavigate();
|
||||
const isAnalysisView = view === "analysis";
|
||||
const [dashboard, setDashboard] = useState<TeacherDashboardResponse | null>(null);
|
||||
const [personaReviews, setPersonaReviews] = useState<PersonaReviewSummary[]>([]);
|
||||
const [loadState, setLoadState] = useState<LoadState>("loading");
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [personaReviewLoading, setPersonaReviewLoading] = useState(true);
|
||||
const [personaReviewLoading, setPersonaReviewLoading] = useState(!isAnalysisView);
|
||||
const [personaReviewError, setPersonaReviewError] = useState<string | null>(null);
|
||||
const [personaDecisionBusy, setPersonaDecisionBusy] = useState<string | null>(null);
|
||||
const [updatedAt, setUpdatedAt] = useState<Date | null>(null);
|
||||
const [selectedLearnerId, setSelectedLearnerId] = useState<string | null>(null);
|
||||
const [learnerAnalysis, setLearnerAnalysis] =
|
||||
useState<TeacherLearnerAnalysisResponse | null>(null);
|
||||
const [learnerAnalysisState, setLearnerAnalysisState] = useState<LoadState>("ready");
|
||||
const [learnerAnalysisError, setLearnerAnalysisError] = useState<string | null>(null);
|
||||
const [learnerAnalysisReloadSeq, setLearnerAnalysisReloadSeq] = useState(0);
|
||||
|
||||
const loadDashboard = useCallback(async () => {
|
||||
setLoadState("loading");
|
||||
|
|
@ -154,8 +169,14 @@ export default function Professor() {
|
|||
|
||||
useEffect(() => {
|
||||
void loadDashboard();
|
||||
void loadPersonaReviews();
|
||||
}, [loadDashboard, loadPersonaReviews]);
|
||||
if (isAnalysisView) {
|
||||
setPersonaReviews([]);
|
||||
setPersonaReviewLoading(false);
|
||||
setPersonaReviewError(null);
|
||||
} else {
|
||||
void loadPersonaReviews();
|
||||
}
|
||||
}, [isAnalysisView, loadDashboard, loadPersonaReviews]);
|
||||
|
||||
const kpis = useMemo(
|
||||
() => [
|
||||
|
|
@ -209,23 +230,89 @@ export default function Professor() {
|
|||
const pendingReviews = dashboard?.pending_reviews ?? [];
|
||||
const recentSessions = dashboard?.recent_sessions ?? [];
|
||||
const firstPendingReview = pendingReviews[0];
|
||||
const selectedLearner = learnerGrowth.find((learner) => learner.learner_id === selectedLearnerId);
|
||||
const isRefreshing =
|
||||
loadState === "loading" ||
|
||||
(!isAnalysisView && personaReviewLoading) ||
|
||||
(isAnalysisView && learnerAnalysisState === "loading");
|
||||
|
||||
useEffect(() => {
|
||||
if (!isAnalysisView) return;
|
||||
if (learnerGrowth.length === 0) {
|
||||
if (selectedLearnerId !== null) setSelectedLearnerId(null);
|
||||
return;
|
||||
}
|
||||
if (!selectedLearnerId || !learnerGrowth.some((learner) => learner.learner_id === selectedLearnerId)) {
|
||||
setSelectedLearnerId(learnerGrowth[0].learner_id);
|
||||
}
|
||||
}, [isAnalysisView, learnerGrowth, selectedLearnerId]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isAnalysisView) {
|
||||
setLearnerAnalysis(null);
|
||||
setLearnerAnalysisState("ready");
|
||||
setLearnerAnalysisError(null);
|
||||
return;
|
||||
}
|
||||
if (!selectedLearnerId) {
|
||||
setLearnerAnalysis(null);
|
||||
setLearnerAnalysisState("ready");
|
||||
setLearnerAnalysisError(null);
|
||||
return;
|
||||
}
|
||||
|
||||
let active = true;
|
||||
setLearnerAnalysisState("loading");
|
||||
setLearnerAnalysisError(null);
|
||||
void teacherApi
|
||||
.learnerAnalysis(selectedLearnerId)
|
||||
.then((next) => {
|
||||
if (!active) return;
|
||||
setLearnerAnalysis(next);
|
||||
setLearnerAnalysisState("ready");
|
||||
})
|
||||
.catch((err) => {
|
||||
if (!active) return;
|
||||
setLearnerAnalysis(null);
|
||||
setLearnerAnalysisError(
|
||||
err instanceof Error ? err.message : "학습자 분석을 불러오지 못했습니다.",
|
||||
);
|
||||
setLearnerAnalysisState("error");
|
||||
});
|
||||
|
||||
return () => {
|
||||
active = false;
|
||||
};
|
||||
}, [isAnalysisView, selectedLearnerId, learnerAnalysisReloadSeq]);
|
||||
|
||||
return (
|
||||
<AppShell navRole="teacher" wide>
|
||||
<main className="pf-root">
|
||||
<main className={`pf-root ${isAnalysisView ? "pf-root--analysis" : "pf-root--console"}`}>
|
||||
<header className="pf-head">
|
||||
<div>
|
||||
<Kicker>교수 콘솔</Kicker>
|
||||
<h1>
|
||||
{hasPending ? `${pendingCount}건의 리뷰가 대기 중입니다.` : "검토할 실제 회기가 없습니다."}
|
||||
</h1>
|
||||
<p>
|
||||
현재 기록된 학습 세션을 기준으로 표시합니다. 기록이 없는 항목은 비워 두며,
|
||||
임의 코호트나 추정 위험도는 보여주지 않습니다.
|
||||
</p>
|
||||
<Kicker>{isAnalysisView ? "학생 분석" : "교수 콘솔"}</Kicker>
|
||||
{isAnalysisView ? (
|
||||
<>
|
||||
<h1>학습자별 추이와 전체 회기를 분리해서 봅니다.</h1>
|
||||
<p>
|
||||
왼쪽에서 학습자를 고르면 점수 변화, 단계 분포, 전체 회기 타임라인을 한 흐름으로
|
||||
확인하고 각 회기의 상세 검토로 들어갑니다.
|
||||
</p>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<h1>
|
||||
{hasPending ? `${pendingCount}건의 리뷰가 대기 중입니다.` : "검토할 실제 회기가 없습니다."}
|
||||
</h1>
|
||||
<p>
|
||||
현재 기록된 학습 세션을 기준으로 운영 큐만 표시합니다. 학생별 장기 추이와 전체
|
||||
회기 분석은 별도 학생 분석 메뉴에서 확인합니다.
|
||||
</p>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
<div className="pf-head__actions">
|
||||
{firstPendingReview ? (
|
||||
{!isAnalysisView && firstPendingReview ? (
|
||||
<Button
|
||||
leading={<Icon name="review" size={16} />}
|
||||
onClick={() => navigate(`/teach/session/${firstPendingReview.session_id}/review`)}
|
||||
|
|
@ -233,20 +320,42 @@ export default function Professor() {
|
|||
첫 검토 열기
|
||||
</Button>
|
||||
) : null}
|
||||
{isAnalysisView ? (
|
||||
<Button
|
||||
variant="secondary"
|
||||
leading={<Icon name="users" size={16} />}
|
||||
onClick={() => navigate("/teach")}
|
||||
>
|
||||
콘솔로
|
||||
</Button>
|
||||
) : (
|
||||
<Button
|
||||
variant="secondary"
|
||||
leading={<Icon name="users" size={16} />}
|
||||
onClick={() => navigate("/teach/analysis")}
|
||||
>
|
||||
학생 분석
|
||||
</Button>
|
||||
)}
|
||||
<Button
|
||||
variant="secondary"
|
||||
leading={<Icon name="review" size={16} />}
|
||||
onClick={() => {
|
||||
void loadDashboard();
|
||||
void loadPersonaReviews();
|
||||
if (isAnalysisView) {
|
||||
setLearnerAnalysisReloadSeq((seq) => seq + 1);
|
||||
} else {
|
||||
void loadPersonaReviews();
|
||||
}
|
||||
}}
|
||||
disabled={loadState === "loading" || personaReviewLoading}
|
||||
disabled={isRefreshing}
|
||||
>
|
||||
{loadState === "loading" || personaReviewLoading ? "확인 중" : "새로고침"}
|
||||
{isRefreshing ? "확인 중" : "새로고침"}
|
||||
</Button>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
{!isAnalysisView ? (
|
||||
<section className="pf-signal-strip" aria-label="교수자 검토 요약">
|
||||
<section className={`pf-triage ${hasPending ? "is-active" : ""}`} role="status">
|
||||
<Dot tone={loadState === "error" ? "crit" : "accent"} size={9} />
|
||||
|
|
@ -289,6 +398,7 @@ export default function Professor() {
|
|||
})}
|
||||
</section>
|
||||
</section>
|
||||
) : null}
|
||||
|
||||
{error ? (
|
||||
<section className="pf-error" role="alert">
|
||||
|
|
@ -297,11 +407,14 @@ export default function Professor() {
|
|||
</section>
|
||||
) : null}
|
||||
|
||||
{isAnalysisView ? (
|
||||
<section className="pf-analysis-shell" aria-label="학생 분석 작업면">
|
||||
<aside className="pf-analysis-rail">
|
||||
<section className="pf-section pf-section--growth">
|
||||
<div className="pf-section__head">
|
||||
<div>
|
||||
<Kicker>학습자 성장 추적</Kicker>
|
||||
<h2>이력·항목별 추이</h2>
|
||||
<Kicker>학습자 목록</Kicker>
|
||||
<h2>사용자별 추이</h2>
|
||||
</div>
|
||||
<Badge tone={learnerGrowth.length > 0 ? "accent" : "neutral"}>
|
||||
{learnerGrowth.length}명
|
||||
|
|
@ -312,7 +425,12 @@ export default function Professor() {
|
|||
{learnerGrowth.length > 0 ? (
|
||||
<div className="pf-growth-list">
|
||||
{learnerGrowth.map((learner) => (
|
||||
<GrowthCard learner={learner} key={learner.learner_id} />
|
||||
<GrowthCard
|
||||
learner={learner}
|
||||
key={learner.learner_id}
|
||||
selected={learner.learner_id === selectedLearnerId}
|
||||
onSelect={() => setSelectedLearnerId(learner.learner_id)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
|
|
@ -323,7 +441,73 @@ export default function Professor() {
|
|||
)}
|
||||
</Card>
|
||||
</section>
|
||||
</aside>
|
||||
|
||||
<section className="pf-analysis-workbench">
|
||||
<nav className="pf-depth-strip" aria-label="학생 분석 단계">
|
||||
<span className="is-current">
|
||||
<small>1</small>
|
||||
학습자
|
||||
</span>
|
||||
<span>
|
||||
<small>2</small>
|
||||
추이
|
||||
</span>
|
||||
<span>
|
||||
<small>3</small>
|
||||
전체 회기
|
||||
</span>
|
||||
<span>
|
||||
<small>4</small>
|
||||
상세 검토
|
||||
</span>
|
||||
</nav>
|
||||
<section className="pf-section pf-section--learner-analysis">
|
||||
<div className="pf-section__head">
|
||||
<div>
|
||||
<Kicker>사용자별 분석</Kicker>
|
||||
<h2>{selectedLearner?.learner_label ?? "학습자 이력"}</h2>
|
||||
</div>
|
||||
<Badge tone={learnerAnalysis ? "accent" : "neutral"}>
|
||||
{learnerAnalysis ? `${learnerAnalysis.total_sessions}회기` : "대기"}
|
||||
</Badge>
|
||||
</div>
|
||||
|
||||
<Card className="pf-panel pf-analysis-panel">
|
||||
{learnerGrowth.length === 0 ? (
|
||||
<EmptyState
|
||||
title={loadState === "loading" ? "이력 계산 중" : "분석할 학습자 없음"}
|
||||
desc="실제 학습자 회기가 쌓이면 사용자별 전체 이력을 표시합니다."
|
||||
/>
|
||||
) : learnerAnalysisState === "loading" ? (
|
||||
<EmptyState
|
||||
title="학습자 이력 계산 중"
|
||||
desc="회기별 점수, 단계, 검토 상태를 불러오고 있습니다."
|
||||
/>
|
||||
) : learnerAnalysisError ? (
|
||||
<div className="pf-empty" role="alert">
|
||||
<Icon name="alert" size={18} />
|
||||
<b>학습자 분석을 불러오지 못했습니다</b>
|
||||
<span>{learnerAnalysisError}</span>
|
||||
</div>
|
||||
) : learnerAnalysis ? (
|
||||
<LearnerAnalysisPanel
|
||||
analysis={learnerAnalysis}
|
||||
onOpenSession={(sessionId) => navigate(`/teach/session/${sessionId}/review`)}
|
||||
/>
|
||||
) : (
|
||||
<EmptyState
|
||||
title="선택된 학습자 없음"
|
||||
desc="실제 회기 기반 사용자별 분석이 이 영역에 표시됩니다."
|
||||
/>
|
||||
)}
|
||||
</Card>
|
||||
</section>
|
||||
</section>
|
||||
</section>
|
||||
) : null}
|
||||
|
||||
{!isAnalysisView ? (
|
||||
<section className="pf-workspace">
|
||||
<div className="pf-queue-stack">
|
||||
<section className="pf-section pf-section--pending">
|
||||
|
|
@ -608,17 +792,197 @@ export default function Professor() {
|
|||
</Card>
|
||||
</section>
|
||||
</section>
|
||||
) : null}
|
||||
</main>
|
||||
</AppShell>
|
||||
);
|
||||
}
|
||||
|
||||
function GrowthCard({ learner }: { learner: TeacherLearnerGrowth }) {
|
||||
function LearnerAnalysisPanel({
|
||||
analysis,
|
||||
onOpenSession,
|
||||
}: {
|
||||
analysis: TeacherLearnerAnalysisResponse;
|
||||
onOpenSession: (sessionId: string) => void;
|
||||
}) {
|
||||
const points = analysis.points ?? [];
|
||||
const sessions = analysis.sessions ?? [];
|
||||
const pointBySession = new Map(points.map((point) => [point.session_id, point]));
|
||||
const stageBreakdown = analysis.stage_breakdown ?? [];
|
||||
const summary = analysis.summary;
|
||||
|
||||
return (
|
||||
<div className="pf-analysis" data-learner-analysis-panel="true">
|
||||
<div className="pf-analysis__top">
|
||||
<div className="pf-analysis__title">
|
||||
<b>{analysis.learner_label}</b>
|
||||
<span>{analysis.message}</span>
|
||||
</div>
|
||||
<div className="pf-analysis__metrics" aria-label="사용자별 분석 요약">
|
||||
<span>
|
||||
<small>전체 회기</small>
|
||||
<b>{analysis.total_sessions}</b>
|
||||
</span>
|
||||
<span>
|
||||
<small>완료/진행</small>
|
||||
<b>
|
||||
{analysis.ended_sessions}/{analysis.active_sessions}
|
||||
</b>
|
||||
</span>
|
||||
<span>
|
||||
<small>검토 대기</small>
|
||||
<b>{analysis.pending_reviews}</b>
|
||||
</span>
|
||||
<span>
|
||||
<small>최근 적절성</small>
|
||||
<b>{formatScore(summary.latest_score)}</b>
|
||||
</span>
|
||||
<span>
|
||||
<small>변화</small>
|
||||
<b>{formatDelta(summary.score_delta)}</b>
|
||||
</span>
|
||||
<span>
|
||||
<small>평균 라포</small>
|
||||
<b>{formatRapport(summary.avg_rapport)}</b>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="pf-analysis__body">
|
||||
<section className="pf-analysis__chart" aria-label="회기별 추이">
|
||||
<div className="pf-analysis__subhead">
|
||||
<b>회기별 추이</b>
|
||||
<span>{trendLabel(summary.trend)}</span>
|
||||
</div>
|
||||
{points.length > 0 ? (
|
||||
<div className="pf-analysis-bars">
|
||||
{points.map((point) => (
|
||||
<GrowthBar point={point} key={point.session_id} />
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<EmptyState title="회기별 점수 없음" desc="평가가 저장된 턴이 아직 없습니다." />
|
||||
)}
|
||||
</section>
|
||||
|
||||
<section className="pf-analysis__stages" aria-label="단계 분포">
|
||||
<div className="pf-analysis__subhead">
|
||||
<b>단계 분포</b>
|
||||
<span>{analysis.total_sessions}회기</span>
|
||||
</div>
|
||||
<div className="pf-stage-list">
|
||||
{stageBreakdown.map((item) => (
|
||||
<div className="pf-stage-item" key={item.stage}>
|
||||
<span>{item.stage}</span>
|
||||
<b>{item.sessions}</b>
|
||||
<small>{item.turns}턴</small>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
<section className="pf-analysis__timeline" aria-label="전체 회기 타임라인">
|
||||
<div className="pf-analysis__subhead">
|
||||
<b>전체 회기</b>
|
||||
<span>{sessions.length}건</span>
|
||||
</div>
|
||||
{sessions.length > 0 ? (
|
||||
<div className="pf-timeline">
|
||||
{sessions.map((session) => (
|
||||
<LearnerSessionRow
|
||||
session={session}
|
||||
point={pointBySession.get(session.session_id)}
|
||||
key={session.session_id}
|
||||
onOpen={() => onOpenSession(session.session_id)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<EmptyState title="회기 없음" desc="표시할 회기 기록이 없습니다." />
|
||||
)}
|
||||
</section>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function LearnerSessionRow({
|
||||
session,
|
||||
point,
|
||||
onOpen,
|
||||
}: {
|
||||
session: TeacherSessionSummary;
|
||||
point: TeacherGrowthPoint | undefined;
|
||||
onOpen: () => void;
|
||||
}) {
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
className="pf-timeline-row"
|
||||
data-learner-session-row="true"
|
||||
aria-label={`${session.learner_label} ${session.persona_code} ${session.session_no}회기 ${sessionOpenLabel(session.status)}`}
|
||||
onClick={onOpen}
|
||||
>
|
||||
<span className="pf-timeline-row__step">{session.session_no}</span>
|
||||
<div className="pf-timeline-row__main">
|
||||
<b>
|
||||
{session.persona_code} · {session.persona_name}
|
||||
</b>
|
||||
<span>
|
||||
{session.stage} · {session.turn_count}턴 · 학습자 {session.learner_turn_count} / 내담자{" "}
|
||||
{session.client_turn_count}
|
||||
</span>
|
||||
<code>{session.session_id}</code>
|
||||
</div>
|
||||
<div className="pf-timeline-row__metrics">
|
||||
<span>
|
||||
<small>적절성</small>
|
||||
<b>{formatScore(point?.score)}</b>
|
||||
</span>
|
||||
<span>
|
||||
<small>라포</small>
|
||||
<b>{formatRapport(point?.rapport)}</b>
|
||||
</span>
|
||||
<span>
|
||||
<small>검토</small>
|
||||
<Badge tone={sessionReviewTone(session.review_status)}>
|
||||
{sessionReviewStatusLabel(session.review_status)}
|
||||
</Badge>
|
||||
</span>
|
||||
</div>
|
||||
<div className="pf-timeline-row__dates">
|
||||
<span>{formatDateTime(session.started_at)}</span>
|
||||
<span>{formatDateTime(session.ended_at ?? null)}</span>
|
||||
</div>
|
||||
<span className="pf-timeline-row__open">
|
||||
<Icon name="review" size={13} />
|
||||
{sessionOpenLabel(session.status)}
|
||||
</span>
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
function GrowthCard({
|
||||
learner,
|
||||
selected,
|
||||
onSelect,
|
||||
}: {
|
||||
learner: TeacherLearnerGrowth;
|
||||
selected: boolean;
|
||||
onSelect: () => void;
|
||||
}) {
|
||||
const points = learner.points ?? [];
|
||||
const topTechniques = learner.top_techniques ?? [];
|
||||
const recentPoints = points.slice(-3).reverse();
|
||||
return (
|
||||
<article className={`pf-growth-card trend-${learner.trend}`}>
|
||||
<button
|
||||
type="button"
|
||||
className={`pf-growth-card trend-${learner.trend} ${selected ? "is-selected" : ""}`}
|
||||
data-learner-growth-card="true"
|
||||
aria-pressed={selected}
|
||||
aria-label={`${learner.learner_label} 사용자별 분석`}
|
||||
onClick={onSelect}
|
||||
>
|
||||
<div className="pf-growth-card__top">
|
||||
<div className="pf-growth-card__id">
|
||||
<b>{learner.learner_label}</b>
|
||||
|
|
@ -679,7 +1043,7 @@ function GrowthCard({ learner }: { learner: TeacherLearnerGrowth }) {
|
|||
</div>
|
||||
)}
|
||||
</div>
|
||||
</article>
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -86,12 +86,22 @@ interface VoiceEvent {
|
|||
type AudioContextWindow = Window & { webkitAudioContext?: typeof AudioContext };
|
||||
type VoiceCaptureMode = "audio-worklet" | "media-recorder";
|
||||
|
||||
interface VoiceProviderEvent {
|
||||
type: string;
|
||||
source: string;
|
||||
duration_ms?: number;
|
||||
confidence?: number;
|
||||
}
|
||||
|
||||
interface VoiceCaptureControl {
|
||||
type: "audio_start" | "audio_end";
|
||||
format: string;
|
||||
sample_rate?: number;
|
||||
channels?: number;
|
||||
sample_width?: number;
|
||||
silence_ms?: number;
|
||||
barge_in?: boolean;
|
||||
provider_events?: VoiceProviderEvent[];
|
||||
}
|
||||
|
||||
interface VoiceCaptureController {
|
||||
|
|
@ -106,6 +116,16 @@ interface VoiceCaptureController {
|
|||
interface VoiceWorkletMessage {
|
||||
type?: string;
|
||||
pcm?: ArrayBuffer;
|
||||
metrics?: VoiceCaptureMetrics;
|
||||
}
|
||||
|
||||
interface VoiceCaptureMetrics {
|
||||
durationMs?: number;
|
||||
voiceMs?: number;
|
||||
silenceMs?: number;
|
||||
trailingSilenceMs?: number;
|
||||
rms?: number;
|
||||
peak?: number;
|
||||
}
|
||||
|
||||
interface Utterance {
|
||||
|
|
@ -446,6 +466,59 @@ function sendVoiceControl(ws: WebSocket, payload: VoiceCaptureControl): void {
|
|||
}
|
||||
}
|
||||
|
||||
function roundedNonnegativeMs(value: number | undefined): number | undefined {
|
||||
if (typeof value !== "number" || !Number.isFinite(value) || value <= 0) return undefined;
|
||||
return Math.max(0, Math.round(value));
|
||||
}
|
||||
|
||||
function voiceActivityConfidence(metrics: VoiceCaptureMetrics): number | undefined {
|
||||
const peak = typeof metrics.peak === "number" && Number.isFinite(metrics.peak) ? metrics.peak : 0;
|
||||
if (peak <= 0) return undefined;
|
||||
return Math.max(0.01, Math.min(0.99, Number(peak.toFixed(2))));
|
||||
}
|
||||
|
||||
function voiceProviderEventsFromMetrics(
|
||||
metrics: VoiceCaptureMetrics | null,
|
||||
source: string,
|
||||
): VoiceProviderEvent[] {
|
||||
if (!metrics) return [];
|
||||
const durationMs = roundedNonnegativeMs(metrics.durationMs);
|
||||
const voiceMs = roundedNonnegativeMs(metrics.voiceMs);
|
||||
const trailingSilenceMs = roundedNonnegativeMs(metrics.trailingSilenceMs);
|
||||
const events: VoiceProviderEvent[] = [];
|
||||
if (voiceMs !== undefined) {
|
||||
const activity: VoiceProviderEvent = {
|
||||
type: "voice_activity",
|
||||
source,
|
||||
duration_ms: voiceMs,
|
||||
};
|
||||
const confidence = voiceActivityConfidence(metrics);
|
||||
if (confidence !== undefined) activity.confidence = confidence;
|
||||
events.push(activity);
|
||||
} else if (durationMs !== undefined) {
|
||||
events.push({ type: "voice_activity", source, duration_ms: durationMs });
|
||||
}
|
||||
if (trailingSilenceMs !== undefined && trailingSilenceMs >= 1000) {
|
||||
events.push({ type: "silence", source, duration_ms: trailingSilenceMs });
|
||||
}
|
||||
return events;
|
||||
}
|
||||
|
||||
function audioEndControlWithMetrics(
|
||||
base: VoiceCaptureControl,
|
||||
metrics: VoiceCaptureMetrics | null,
|
||||
source: string,
|
||||
): VoiceCaptureControl {
|
||||
const silenceMs = roundedNonnegativeMs(metrics?.trailingSilenceMs);
|
||||
const providerEvents = voiceProviderEventsFromMetrics(metrics, source);
|
||||
return {
|
||||
...base,
|
||||
...(silenceMs !== undefined ? { silence_ms: silenceMs } : {}),
|
||||
barge_in: false,
|
||||
...(providerEvents.length ? { provider_events: providerEvents } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
function supportsAudioWorkletCapture(ctx: AudioContext | null): ctx is AudioContext & {
|
||||
audioWorklet: AudioWorklet;
|
||||
} {
|
||||
|
|
@ -477,6 +550,7 @@ function createMediaRecorderCapture(
|
|||
|
||||
let finishRequested = false;
|
||||
let released = false;
|
||||
let startedAt = 0;
|
||||
const release = () => {
|
||||
if (released) return;
|
||||
released = true;
|
||||
|
|
@ -489,13 +563,20 @@ function createMediaRecorderCapture(
|
|||
};
|
||||
recorder.onstop = () => {
|
||||
release();
|
||||
if (finishRequested) sendVoiceControl(ws, endControl);
|
||||
if (finishRequested) {
|
||||
const durationMs = startedAt > 0 ? performance.now() - startedAt : undefined;
|
||||
sendVoiceControl(
|
||||
ws,
|
||||
audioEndControlWithMetrics(endControl, { durationMs, voiceMs: durationMs }, "browser_media_recorder"),
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
return {
|
||||
mode: "media-recorder",
|
||||
startControl,
|
||||
start: () => {
|
||||
startedAt = performance.now();
|
||||
recorder.start(500);
|
||||
},
|
||||
finish: () => {
|
||||
|
|
@ -509,7 +590,11 @@ function createMediaRecorderCapture(
|
|||
}
|
||||
}
|
||||
release();
|
||||
sendVoiceControl(ws, endControl);
|
||||
const durationMs = startedAt > 0 ? performance.now() - startedAt : undefined;
|
||||
sendVoiceControl(
|
||||
ws,
|
||||
audioEndControlWithMetrics(endControl, { durationMs, voiceMs: durationMs }, "browser_media_recorder"),
|
||||
);
|
||||
},
|
||||
abort: () => {
|
||||
finishRequested = false;
|
||||
|
|
@ -549,6 +634,7 @@ async function createAudioWorkletCapture(
|
|||
let recording = false;
|
||||
let released = false;
|
||||
let finishTimer: number | null = null;
|
||||
let latestMetrics: VoiceCaptureMetrics | null = null;
|
||||
|
||||
const release = () => {
|
||||
if (released) return;
|
||||
|
|
@ -576,6 +662,7 @@ async function createAudioWorkletCapture(
|
|||
if (!recording || ws.readyState !== WebSocket.OPEN) return;
|
||||
const payload = event.data;
|
||||
if (payload?.type !== "chunk" || !payload.pcm?.byteLength) return;
|
||||
latestMetrics = payload.metrics ?? latestMetrics;
|
||||
ws.send(payload.pcm);
|
||||
};
|
||||
|
||||
|
|
@ -597,7 +684,7 @@ async function createAudioWorkletCapture(
|
|||
finishTimer = null;
|
||||
recording = false;
|
||||
release();
|
||||
sendVoiceControl(ws, endControl);
|
||||
sendVoiceControl(ws, audioEndControlWithMetrics(endControl, latestMetrics, "browser_audio_worklet"));
|
||||
}, 20);
|
||||
},
|
||||
abort: () => {
|
||||
|
|
@ -1135,11 +1222,13 @@ export default function Session() {
|
|||
}, [acceptConsent, consentChecked, pushSignal]);
|
||||
|
||||
const appendServerClientReply = useCallback(
|
||||
(replyText: string | null, at = elapsed) => {
|
||||
(replyText: string | null, at = elapsed, options?: { suppressEmptyWarning?: boolean }) => {
|
||||
setClientReplyPending(false);
|
||||
if (!replyText) {
|
||||
setAvatarState("listening");
|
||||
pushSignal("warn", "내담자 응답 없음");
|
||||
if (!options?.suppressEmptyWarning) {
|
||||
pushSignal("warn", "내담자 응답 없음");
|
||||
}
|
||||
return;
|
||||
}
|
||||
setUtterances((prev) => [
|
||||
|
|
@ -1180,6 +1269,7 @@ export default function Session() {
|
|||
|
||||
let clientId: number | null = null;
|
||||
let clientReply = "";
|
||||
let conversationStopped = false;
|
||||
try {
|
||||
const done = await sessionApi.stream(liveSessionId, text, {
|
||||
onToken: (chunk) => {
|
||||
|
|
@ -1210,6 +1300,7 @@ export default function Session() {
|
|||
setSafety("방금 발화에서 위기 신호가 감지됐어요. 안전을 우선해 머물러 주세요.");
|
||||
}
|
||||
if (data.conversation_stopped || data.crisis_resource) {
|
||||
conversationStopped = true;
|
||||
applyCrisisGate(data.crisis_resource);
|
||||
}
|
||||
},
|
||||
|
|
@ -1218,10 +1309,16 @@ export default function Session() {
|
|||
if (payload && typeof payload === "object") {
|
||||
const resource = (payload as { crisis_resource?: CrisisResource }).crisis_resource;
|
||||
const stopped = (payload as { conversation_stopped?: boolean }).conversation_stopped;
|
||||
if (resource || stopped) applyCrisisGate(resource);
|
||||
if (resource || stopped) {
|
||||
conversationStopped = true;
|
||||
applyCrisisGate(resource);
|
||||
}
|
||||
}
|
||||
},
|
||||
});
|
||||
if (done.conversation_stopped || done.crisis_resource) {
|
||||
conversationStopped = true;
|
||||
}
|
||||
|
||||
if (clientId != null) {
|
||||
const id = clientId;
|
||||
|
|
@ -1247,14 +1344,18 @@ export default function Session() {
|
|||
setUtterances((prev) =>
|
||||
prev.map((u) => (u.id === learnerId ? { ...u, turnSeq: done.turn_seq } : u)),
|
||||
);
|
||||
pushSignal("warn", "내담자 응답 없음");
|
||||
if (!conversationStopped) {
|
||||
pushSignal("warn", "내담자 응답 없음");
|
||||
}
|
||||
}
|
||||
setAvatarState("listening");
|
||||
void requestLiveCoach({
|
||||
learnerText: text,
|
||||
clientReply,
|
||||
turnSeq: done.turn_seq,
|
||||
});
|
||||
if (!conversationStopped) {
|
||||
void requestLiveCoach({
|
||||
learnerText: text,
|
||||
clientReply,
|
||||
turnSeq: done.turn_seq,
|
||||
});
|
||||
}
|
||||
} catch (err) {
|
||||
setComposeText(text);
|
||||
setClientReplyPending(false);
|
||||
|
|
@ -1606,6 +1707,7 @@ export default function Session() {
|
|||
if (payload.conversation_stopped || payload.crisis_resource) {
|
||||
applyCrisisGate(payload.crisis_resource);
|
||||
}
|
||||
const conversationStopped = !!(payload.conversation_stopped || payload.crisis_resource);
|
||||
const pendingId = pendingVoiceLearnerIdRef.current;
|
||||
if (pendingId != null) {
|
||||
setUtterances((prev) =>
|
||||
|
|
@ -1615,10 +1717,12 @@ export default function Session() {
|
|||
);
|
||||
pendingVoiceLearnerIdRef.current = null;
|
||||
}
|
||||
appendServerClientReply(payload.text ?? null);
|
||||
appendServerClientReply(payload.text ?? null, elapsed, {
|
||||
suppressEmptyWarning: conversationStopped,
|
||||
});
|
||||
const learnerText = pendingVoiceLearnerTextRef.current;
|
||||
pendingVoiceLearnerTextRef.current = "";
|
||||
if (learnerText) {
|
||||
if (learnerText && !conversationStopped) {
|
||||
void requestLiveCoach({
|
||||
learnerText,
|
||||
clientReply: payload.text ?? null,
|
||||
|
|
@ -1830,27 +1934,37 @@ export default function Session() {
|
|||
endPreviousFocusRef.current = null;
|
||||
}, [endDialogOpen, ending]);
|
||||
|
||||
// 회기 종료 — 명시 확인 후 end 호출, 현재 화면을 정지 상태로 둔다.
|
||||
// 회기 종료 — 명시 확인 후 end 호출, 실패하면 리뷰 화면으로 넘어가지 않는다.
|
||||
const handleEnd = useCallback(async () => {
|
||||
if (ending || sessionEnded) return;
|
||||
setEnding(true);
|
||||
setTurnError(null);
|
||||
try {
|
||||
if (liveSessionId) await sessionApi.end(liveSessionId);
|
||||
} catch {
|
||||
/* 종료 실패는 UI 종료 처리를 막지 않는다. */
|
||||
if (!liveSessionId) {
|
||||
throw new Error("회기 ID가 없습니다.");
|
||||
}
|
||||
await sessionApi.end(liveSessionId);
|
||||
} catch (err) {
|
||||
const detail =
|
||||
err instanceof ApiError
|
||||
? err.detail
|
||||
: err instanceof Error
|
||||
? err.message
|
||||
: "알 수 없는 오류";
|
||||
setEndDialogOpen(false);
|
||||
setTurnError(`회기 종료에 실패했습니다. ${detail}`);
|
||||
pushSignal("warn", "회기 종료 실패");
|
||||
setEnding(false);
|
||||
return;
|
||||
}
|
||||
setEndDialogOpen(false);
|
||||
setSessionEnded(true);
|
||||
setReviewReady(true);
|
||||
setReviewReady(false);
|
||||
setMicOn(false);
|
||||
setPaused(true);
|
||||
setAvatarState("idle");
|
||||
pushSignal("neutral", "회기 종료");
|
||||
if (liveSessionId) {
|
||||
navigate(`/learn/session/${liveSessionId}/review`, { replace: true });
|
||||
} else {
|
||||
setEnding(false);
|
||||
}
|
||||
navigate(`/learn/session/${liveSessionId}/review`, { replace: true });
|
||||
}, [ending, liveSessionId, navigate, pushSignal, sessionEnded]);
|
||||
|
||||
const meters = metersFromOpenness(openness);
|
||||
|
|
@ -1914,6 +2028,7 @@ export default function Session() {
|
|||
primaryContext?.v ??
|
||||
`${clientName}님이 당신의 첫 질문을 기다리고 있습니다.`;
|
||||
const safetyStatusText = safety ? "확인 필요" : "안전";
|
||||
const turnErrorTitle = turnError?.startsWith("회기 종료") ? "회기 종료" : "엔진 연결";
|
||||
const personaIsUsable = personaSummary ? isUsablePersona(personaSummary) : false;
|
||||
const consentRequired = user?.role === "learner" && user.consentAt == null;
|
||||
const canStartSession = personaLoadState === "ready" && personaIsUsable && !consentRequired;
|
||||
|
|
@ -2526,7 +2641,7 @@ export default function Session() {
|
|||
<Icon name="alert" size={17} />
|
||||
</span>
|
||||
<span className="sx-safety__text">
|
||||
<b>엔진 연결</b> · {turnError}
|
||||
<b>{turnErrorTitle}</b> · {turnError}
|
||||
</span>
|
||||
</section>
|
||||
) : null}
|
||||
|
|
|
|||
|
|
@ -11,6 +11,8 @@ import {
|
|||
ProgressBar,
|
||||
} from "../components/ui";
|
||||
import {
|
||||
ApiError,
|
||||
evalApi,
|
||||
sessionApi,
|
||||
teacherApi,
|
||||
userApi,
|
||||
|
|
@ -25,6 +27,7 @@ import {
|
|||
type UserPrepostMeasureItem,
|
||||
type UserPrepostMeasuresResponse,
|
||||
} from "../lib/api";
|
||||
import { canAccessRole, useAuth } from "../lib/auth";
|
||||
import { ValenceChart } from "./session-review/ValenceChart";
|
||||
import "./session-review/session-review.css";
|
||||
|
||||
|
|
@ -38,6 +41,8 @@ const PREPOST_PILOT_ID = "phase3-pilot-draft";
|
|||
const PREPOST_INSTRUMENT_VERSION = "pilot-prepost-scaffold-2026-06-28";
|
||||
const PREPOST_SCORE_MIN = 1;
|
||||
const PREPOST_SCORE_MAX = 5;
|
||||
const REVIEW_READY_POLL_INTERVAL_MS = 1500;
|
||||
const REVIEW_READY_POLL_LIMIT = 8;
|
||||
|
||||
const PREPOST_MEASURES: Array<{ key: PrepostMeasureName; label: string; hint: string }> = [
|
||||
{ key: "self_efficacy", label: "자기효능감", hint: "상담 수행 자신감" },
|
||||
|
|
@ -110,6 +115,15 @@ function renderSummary(summary: string) {
|
|||
});
|
||||
}
|
||||
|
||||
function shouldPollReviewReady(data: SessionReviewResponse) {
|
||||
return (
|
||||
!data.reviewReady &&
|
||||
data.degraded &&
|
||||
data.supervisorState === "평가 대기" &&
|
||||
(data.turns?.length ?? 0) > 0
|
||||
);
|
||||
}
|
||||
|
||||
function renderInlineMarkdown(text: string): ReactNode[] {
|
||||
const nodes: ReactNode[] = [];
|
||||
let cursor = 0;
|
||||
|
|
@ -624,6 +638,7 @@ function PrepostMeasureCard() {
|
|||
autoComplete="off"
|
||||
value={draft[key] ?? ""}
|
||||
placeholder="-"
|
||||
disabled={loading || saving}
|
||||
aria-invalid={invalid}
|
||||
aria-label={`${measure.label} ${timepoint.label} 점수`}
|
||||
onChange={(event) => {
|
||||
|
|
@ -674,6 +689,7 @@ export default function SessionReview() {
|
|||
const { sessionId } = useParams<{ sessionId: string }>();
|
||||
const navigate = useNavigate();
|
||||
const location = useLocation();
|
||||
const { user } = useAuth();
|
||||
const [data, setData] = useState<SessionReviewResponse | null>(null);
|
||||
const [loadState, setLoadState] = useState<LoadState>("loading");
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
|
@ -688,6 +704,9 @@ export default function SessionReview() {
|
|||
const [teacherWorksheetSaving, setTeacherWorksheetSaving] =
|
||||
useState<WorksheetDecisionStatus | null>(null);
|
||||
const [teacherReviewError, setTeacherReviewError] = useState<string | null>(null);
|
||||
const [evaluationRetrying, setEvaluationRetrying] = useState(false);
|
||||
const [evaluationRetryError, setEvaluationRetryError] = useState<string | null>(null);
|
||||
const [reviewReloadSeq, setReviewReloadSeq] = useState(0);
|
||||
const turnRefs = useRef<Record<string, HTMLDivElement | null>>({});
|
||||
|
||||
const handleWorksheetSaved = (worksheet: ReviewCaseWorksheet) => {
|
||||
|
|
@ -699,6 +718,8 @@ export default function SessionReview() {
|
|||
|
||||
useEffect(() => {
|
||||
let alive = true;
|
||||
let pollTimer: number | null = null;
|
||||
let pollAttempts = 0;
|
||||
if (!sessionId) {
|
||||
setError("세션 ID가 없습니다.");
|
||||
setLoadState("error");
|
||||
|
|
@ -707,28 +728,51 @@ export default function SessionReview() {
|
|||
};
|
||||
}
|
||||
|
||||
(async () => {
|
||||
setLoadState("loading");
|
||||
const loadReview = async () => {
|
||||
if (pollAttempts === 0) setLoadState("loading");
|
||||
setError(null);
|
||||
try {
|
||||
const next = await sessionApi.review(sessionId);
|
||||
if (!alive) return;
|
||||
if (!isSupervisorView && user && canAccessRole(user, "teacher") && next.teacherReview) {
|
||||
navigate(`/teach/session/${encodeURIComponent(sessionId)}/review`, { replace: true });
|
||||
return;
|
||||
}
|
||||
setData(next);
|
||||
setTeacherNote(next.teacherReview?.note ?? "");
|
||||
setTeacherWorksheetNote(next.teacherReview?.worksheetNote ?? "");
|
||||
setTeacherReviewError(null);
|
||||
setEvaluationRetryError(null);
|
||||
setLoadState("ready");
|
||||
if (shouldPollReviewReady(next) && pollAttempts < REVIEW_READY_POLL_LIMIT) {
|
||||
pollAttempts += 1;
|
||||
pollTimer = window.setTimeout(loadReview, REVIEW_READY_POLL_INTERVAL_MS);
|
||||
}
|
||||
} catch (err) {
|
||||
if (!alive) return;
|
||||
if (
|
||||
err instanceof ApiError &&
|
||||
err.status === 403 &&
|
||||
err.detail === "session does not belong to user" &&
|
||||
!isSupervisorView &&
|
||||
user &&
|
||||
canAccessRole(user, "teacher")
|
||||
) {
|
||||
navigate(`/teach/session/${encodeURIComponent(sessionId)}/review`, { replace: true });
|
||||
return;
|
||||
}
|
||||
setError(err instanceof Error ? err.message : "회기 리뷰를 불러오지 못했습니다.");
|
||||
setLoadState("error");
|
||||
}
|
||||
})();
|
||||
};
|
||||
|
||||
void loadReview();
|
||||
|
||||
return () => {
|
||||
alive = false;
|
||||
if (pollTimer !== null) window.clearTimeout(pollTimer);
|
||||
};
|
||||
}, [sessionId]);
|
||||
}, [isSupervisorView, navigate, reviewReloadSeq, sessionId, user]);
|
||||
|
||||
const visibleTurns = useMemo<ReviewTurn[]>(() => {
|
||||
const turns = data?.turns ?? [];
|
||||
|
|
@ -774,6 +818,30 @@ export default function SessionReview() {
|
|||
}
|
||||
}
|
||||
|
||||
async function handleRetryEvaluation() {
|
||||
if (!sessionId || evaluationRetrying) return;
|
||||
setEvaluationRetrying(true);
|
||||
setEvaluationRetryError(null);
|
||||
try {
|
||||
const result = await evalApi.reevaluateSession(sessionId, { scope: "session_end" });
|
||||
const resultError = typeof result.error === "string" ? result.error : "";
|
||||
if (resultError) {
|
||||
throw new Error(resultError);
|
||||
}
|
||||
setReviewReloadSeq((seq) => seq + 1);
|
||||
} catch (err) {
|
||||
setEvaluationRetryError(
|
||||
err instanceof ApiError
|
||||
? err.detail
|
||||
: err instanceof Error
|
||||
? err.message
|
||||
: "AI 평가를 다시 실행하지 못했습니다.",
|
||||
);
|
||||
} finally {
|
||||
setEvaluationRetrying(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function saveTeacherReviewStatus(status: "viewed" | "closed") {
|
||||
if (!sessionId || !data) return;
|
||||
setTeacherReviewSaving(status);
|
||||
|
|
@ -907,6 +975,7 @@ export default function SessionReview() {
|
|||
const hasLearnerWorksheet = data.caseWorksheet?.status === "saved_by_learner";
|
||||
const canCloseTeacherReview = isSupervisorView && data.sessionSignal === "종료됨";
|
||||
const canDecideWorksheet = canCloseTeacherReview && hasLearnerWorksheet;
|
||||
const canRetryEvaluation = canCloseTeacherReview && hasTranscript && !data.reviewReady;
|
||||
const reviewReadiness = hasTranscript
|
||||
? `${turns.length}개 발화 기반`
|
||||
: "축어록 저장 후 생성";
|
||||
|
|
@ -987,6 +1056,17 @@ export default function SessionReview() {
|
|||
교수 콘솔로
|
||||
</Button>
|
||||
) : null}
|
||||
{canRetryEvaluation ? (
|
||||
<Button
|
||||
variant="primary"
|
||||
size="sm"
|
||||
leading={<Icon name="review" size={14} />}
|
||||
disabled={evaluationRetrying}
|
||||
onClick={handleRetryEvaluation}
|
||||
>
|
||||
{evaluationRetrying ? "AI 평가 실행 중" : "AI 평가 재시도"}
|
||||
</Button>
|
||||
) : null}
|
||||
<Button
|
||||
variant="primary"
|
||||
size="sm"
|
||||
|
|
@ -1031,6 +1111,9 @@ export default function SessionReview() {
|
|||
) : null}
|
||||
</>
|
||||
) : null}
|
||||
{evaluationRetryError ? (
|
||||
<p className="sr-share-note sr-share-note--error">{evaluationRetryError}</p>
|
||||
) : null}
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
|
|
|
|||
|
|
@ -854,10 +854,12 @@
|
|||
position:sticky;
|
||||
top:calc(var(--topbar-h) + 18px);
|
||||
display:grid;
|
||||
grid-template-rows:auto minmax(0,1fr);
|
||||
max-height:calc(100dvh - var(--topbar-h) - 36px);
|
||||
grid-template-rows:auto auto;
|
||||
align-content:start;
|
||||
height:max-content;
|
||||
max-height:none;
|
||||
padding:var(--sp-4);
|
||||
overflow:hidden;
|
||||
overflow:visible;
|
||||
}
|
||||
.lh-pane-head{margin-bottom:var(--sp-3);}
|
||||
.lh-pane-head button{
|
||||
|
|
@ -889,26 +891,30 @@
|
|||
min-height:0;
|
||||
display:grid;
|
||||
align-content:start;
|
||||
gap:8px;
|
||||
overflow:auto;
|
||||
scrollbar-gutter:stable;
|
||||
padding-right:3px;
|
||||
grid-auto-rows:auto;
|
||||
gap:10px;
|
||||
overflow:visible;
|
||||
scrollbar-gutter:auto;
|
||||
padding-right:0;
|
||||
}
|
||||
.lh-persona{
|
||||
position:relative;
|
||||
min-width:0;
|
||||
min-height:92px;
|
||||
width:100%;
|
||||
min-height:0;
|
||||
height:auto;
|
||||
display:grid;
|
||||
grid-template-columns:44px minmax(0,1fr);
|
||||
gap:11px;
|
||||
align-items:start;
|
||||
padding:12px 38px 12px 12px;
|
||||
padding:13px 38px 13px 12px;
|
||||
border:1px solid var(--border-subtle);
|
||||
border-radius:var(--radius);
|
||||
background:var(--bg-surface-2);
|
||||
color:var(--text-body);
|
||||
text-align:left;
|
||||
cursor:pointer;
|
||||
overflow:visible;
|
||||
}
|
||||
.lh-persona:hover{border-color:var(--border-strong);}
|
||||
.lh-persona:disabled{
|
||||
|
|
@ -936,6 +942,7 @@
|
|||
.lh-persona__body{
|
||||
min-width:0;
|
||||
display:grid;
|
||||
align-content:start;
|
||||
gap:5px;
|
||||
}
|
||||
.lh-persona__name{
|
||||
|
|
@ -962,10 +969,8 @@
|
|||
color:var(--text-body);
|
||||
font-size:12.5px;
|
||||
line-height:1.45;
|
||||
overflow:hidden;
|
||||
display:-webkit-box;
|
||||
-webkit-line-clamp:3;
|
||||
-webkit-box-orient:vertical;
|
||||
word-break:keep-all;
|
||||
overflow-wrap:anywhere;
|
||||
}
|
||||
.lh-persona__check{
|
||||
position:absolute;
|
||||
|
|
|
|||
|
|
@ -5,6 +5,9 @@
|
|||
flex-direction:column;
|
||||
gap:16px;
|
||||
}
|
||||
.pf-root--analysis{
|
||||
max-width:1440px;
|
||||
}
|
||||
.pf-head{
|
||||
display:flex;
|
||||
align-items:flex-end;
|
||||
|
|
@ -171,13 +174,75 @@
|
|||
line-height:1.35;
|
||||
}
|
||||
.pf-workspace{
|
||||
order:2;
|
||||
order:4;
|
||||
display:grid;
|
||||
grid-template-columns:minmax(320px,390px) minmax(0,1fr);
|
||||
align-items:start;
|
||||
gap:14px;
|
||||
min-width:0;
|
||||
}
|
||||
.pf-analysis-shell{
|
||||
order:2;
|
||||
display:grid;
|
||||
grid-template-columns:minmax(300px,360px) minmax(0,1fr);
|
||||
align-items:start;
|
||||
gap:14px;
|
||||
min-width:0;
|
||||
}
|
||||
.pf-analysis-rail{
|
||||
position:sticky;
|
||||
top:16px;
|
||||
min-width:0;
|
||||
}
|
||||
.pf-analysis-workbench{
|
||||
min-width:0;
|
||||
display:flex;
|
||||
flex-direction:column;
|
||||
gap:10px;
|
||||
}
|
||||
.pf-depth-strip{
|
||||
display:grid;
|
||||
grid-template-columns:repeat(4,minmax(0,1fr));
|
||||
min-width:0;
|
||||
border:1px solid var(--hair);
|
||||
border-radius:var(--radius);
|
||||
overflow:hidden;
|
||||
background:var(--bg-surface);
|
||||
box-shadow:var(--shadow-sm);
|
||||
}
|
||||
.pf-depth-strip span{
|
||||
min-width:0;
|
||||
display:flex;
|
||||
align-items:center;
|
||||
gap:7px;
|
||||
padding:10px 12px;
|
||||
color:var(--text-muted);
|
||||
font-size:var(--fs-xs);
|
||||
font-weight:760;
|
||||
white-space:nowrap;
|
||||
}
|
||||
.pf-depth-strip span + span{
|
||||
border-left:1px solid var(--hair);
|
||||
}
|
||||
.pf-depth-strip small{
|
||||
width:22px;
|
||||
height:22px;
|
||||
display:grid;
|
||||
place-items:center;
|
||||
border-radius:var(--radius-sm);
|
||||
color:var(--text-muted);
|
||||
background:var(--bg-surface-2);
|
||||
font-family:var(--font-num);
|
||||
line-height:1;
|
||||
}
|
||||
.pf-depth-strip .is-current{
|
||||
color:var(--text-strong);
|
||||
background:color-mix(in srgb,var(--accent-tint) 36%,var(--bg-surface));
|
||||
}
|
||||
.pf-depth-strip .is-current small{
|
||||
color:var(--text-on-accent);
|
||||
background:var(--accent);
|
||||
}
|
||||
.pf-queue-stack{
|
||||
display:flex;
|
||||
flex-direction:column;
|
||||
|
|
@ -250,6 +315,10 @@
|
|||
justify-content:flex-end;
|
||||
}
|
||||
.pf-section--growth{
|
||||
order:2;
|
||||
min-width:0;
|
||||
}
|
||||
.pf-section--learner-analysis{
|
||||
order:3;
|
||||
min-width:0;
|
||||
}
|
||||
|
|
@ -265,7 +334,17 @@
|
|||
gap:12px;
|
||||
padding:12px;
|
||||
}
|
||||
.pf-analysis-rail .pf-growth-list{
|
||||
max-height:min(690px,calc(100vh - 250px));
|
||||
grid-template-columns:1fr;
|
||||
gap:9px;
|
||||
padding:10px;
|
||||
}
|
||||
.pf-growth-card{
|
||||
width:100%;
|
||||
font:inherit;
|
||||
text-align:left;
|
||||
color:inherit;
|
||||
min-width:0;
|
||||
display:flex;
|
||||
flex-direction:column;
|
||||
|
|
@ -275,6 +354,33 @@
|
|||
border-radius:var(--radius-sm);
|
||||
background:color-mix(in srgb,var(--bg-surface) 82%,var(--bg-surface-2));
|
||||
box-shadow:none;
|
||||
cursor:pointer;
|
||||
}
|
||||
.pf-growth-card:hover{
|
||||
border-color:var(--accent);
|
||||
background:var(--bg-surface);
|
||||
}
|
||||
.pf-growth-card:focus-visible{
|
||||
outline:2px solid var(--accent);
|
||||
outline-offset:2px;
|
||||
}
|
||||
.pf-growth-card.is-selected{
|
||||
border-color:var(--accent);
|
||||
background:color-mix(in srgb,var(--accent-tint) 34%,var(--bg-surface));
|
||||
}
|
||||
.pf-analysis-rail .pf-growth-card{
|
||||
gap:9px;
|
||||
padding:11px;
|
||||
border-radius:var(--radius);
|
||||
}
|
||||
.pf-analysis-rail .pf-growth-card__points{
|
||||
display:none;
|
||||
}
|
||||
.pf-analysis-rail .pf-growth-card__tags{
|
||||
min-height:0;
|
||||
}
|
||||
.pf-analysis-rail .pf-growth-bars{
|
||||
height:54px;
|
||||
}
|
||||
.pf-growth-card__top{
|
||||
display:flex;
|
||||
|
|
@ -410,6 +516,262 @@
|
|||
text-overflow:ellipsis;
|
||||
white-space:nowrap;
|
||||
}
|
||||
.pf-analysis-panel{
|
||||
background:linear-gradient(180deg,var(--bg-surface),color-mix(in srgb,var(--bg-surface-2) 72%,var(--bg-surface)));
|
||||
}
|
||||
.pf-analysis{
|
||||
min-width:0;
|
||||
display:grid;
|
||||
gap:12px;
|
||||
padding:12px;
|
||||
}
|
||||
.pf-analysis__top{
|
||||
min-width:0;
|
||||
display:grid;
|
||||
grid-template-columns:minmax(210px,.42fr) minmax(0,1fr);
|
||||
gap:12px;
|
||||
align-items:stretch;
|
||||
}
|
||||
.pf-analysis__title{
|
||||
min-width:0;
|
||||
display:grid;
|
||||
align-content:center;
|
||||
gap:5px;
|
||||
padding:12px;
|
||||
border:1px solid var(--paper-2);
|
||||
border-radius:var(--radius-sm);
|
||||
background:var(--bg-surface-2);
|
||||
}
|
||||
.pf-analysis__title b{
|
||||
color:var(--text-strong);
|
||||
font-size:var(--fs-body);
|
||||
line-height:1.35;
|
||||
overflow:hidden;
|
||||
text-overflow:ellipsis;
|
||||
white-space:nowrap;
|
||||
}
|
||||
.pf-analysis__title span{
|
||||
color:var(--text-muted);
|
||||
font-size:var(--fs-xs);
|
||||
line-height:1.45;
|
||||
overflow-wrap:anywhere;
|
||||
}
|
||||
.pf-analysis__metrics{
|
||||
min-width:0;
|
||||
display:grid;
|
||||
grid-template-columns:repeat(6,minmax(0,1fr));
|
||||
border:1px solid var(--paper-2);
|
||||
border-radius:var(--radius-sm);
|
||||
background:var(--bg-surface-2);
|
||||
overflow:hidden;
|
||||
}
|
||||
.pf-analysis__metrics span{
|
||||
min-width:0;
|
||||
display:grid;
|
||||
align-content:center;
|
||||
gap:4px;
|
||||
padding:10px 11px;
|
||||
}
|
||||
.pf-analysis__metrics span + span{
|
||||
border-left:1px solid var(--hair);
|
||||
}
|
||||
.pf-analysis__metrics small,
|
||||
.pf-stage-item small,
|
||||
.pf-timeline-row__metrics small{
|
||||
color:var(--text-muted);
|
||||
font-size:11px;
|
||||
line-height:1.2;
|
||||
}
|
||||
.pf-analysis__metrics b,
|
||||
.pf-stage-item b,
|
||||
.pf-timeline-row__metrics b{
|
||||
min-width:0;
|
||||
color:var(--text-strong);
|
||||
font-family:var(--font-num);
|
||||
font-size:15px;
|
||||
line-height:1.15;
|
||||
white-space:nowrap;
|
||||
overflow:hidden;
|
||||
text-overflow:ellipsis;
|
||||
}
|
||||
.pf-analysis__body{
|
||||
min-width:0;
|
||||
display:grid;
|
||||
grid-template-columns:minmax(0,1.25fr) minmax(240px,.75fr);
|
||||
gap:12px;
|
||||
}
|
||||
.pf-analysis__chart,
|
||||
.pf-analysis__stages,
|
||||
.pf-analysis__timeline{
|
||||
min-width:0;
|
||||
border:1px solid var(--paper-2);
|
||||
border-radius:var(--radius-sm);
|
||||
background:color-mix(in srgb,var(--bg-surface) 84%,var(--bg-surface-2));
|
||||
overflow:hidden;
|
||||
}
|
||||
.pf-analysis__subhead{
|
||||
display:flex;
|
||||
align-items:center;
|
||||
justify-content:space-between;
|
||||
gap:10px;
|
||||
min-width:0;
|
||||
padding:10px 12px;
|
||||
border-bottom:1px solid var(--hair);
|
||||
}
|
||||
.pf-analysis__subhead b{
|
||||
color:var(--text-strong);
|
||||
font-size:var(--fs-sm);
|
||||
line-height:1.35;
|
||||
}
|
||||
.pf-analysis__subhead span{
|
||||
color:var(--text-muted);
|
||||
font-size:var(--fs-xs);
|
||||
white-space:nowrap;
|
||||
}
|
||||
.pf-analysis-bars{
|
||||
height:118px;
|
||||
display:flex;
|
||||
align-items:flex-end;
|
||||
gap:7px;
|
||||
padding:12px;
|
||||
overflow-x:auto;
|
||||
scrollbar-gutter:stable;
|
||||
}
|
||||
.pf-analysis-bars .pf-growth-bar{
|
||||
flex:0 0 34px;
|
||||
}
|
||||
.pf-stage-list{
|
||||
display:grid;
|
||||
grid-template-columns:repeat(4,minmax(0,1fr));
|
||||
}
|
||||
.pf-stage-item{
|
||||
min-width:0;
|
||||
display:grid;
|
||||
gap:4px;
|
||||
padding:12px;
|
||||
border-left:1px solid var(--hair);
|
||||
}
|
||||
.pf-stage-item:first-child{
|
||||
border-left:0;
|
||||
}
|
||||
.pf-stage-item span{
|
||||
color:var(--text-muted);
|
||||
font-size:var(--fs-xs);
|
||||
font-weight:700;
|
||||
}
|
||||
.pf-timeline{
|
||||
max-height:min(520px,58vh);
|
||||
overflow:auto;
|
||||
scrollbar-gutter:stable;
|
||||
display:flex;
|
||||
flex-direction:column;
|
||||
}
|
||||
.pf-timeline-row{
|
||||
width:100%;
|
||||
font:inherit;
|
||||
text-align:left;
|
||||
color:inherit;
|
||||
background:transparent;
|
||||
display:grid;
|
||||
grid-template-columns:38px minmax(170px,1fr) minmax(210px,.82fr) minmax(112px,.42fr) auto;
|
||||
gap:10px;
|
||||
align-items:center;
|
||||
padding:12px;
|
||||
border:0;
|
||||
border-top:1px solid var(--paper-2);
|
||||
cursor:pointer;
|
||||
}
|
||||
.pf-timeline-row:first-child{
|
||||
border-top:0;
|
||||
}
|
||||
.pf-timeline-row:hover{
|
||||
background:var(--bg-surface-2);
|
||||
}
|
||||
.pf-timeline-row:focus-visible{
|
||||
outline:2px solid var(--accent);
|
||||
outline-offset:-2px;
|
||||
}
|
||||
.pf-timeline-row__step{
|
||||
width:32px;
|
||||
height:32px;
|
||||
display:grid;
|
||||
place-items:center;
|
||||
border-radius:var(--radius-sm);
|
||||
color:var(--accent-deep);
|
||||
background:var(--accent-tint);
|
||||
font-family:var(--font-num);
|
||||
font-weight:800;
|
||||
}
|
||||
.pf-timeline-row__main{
|
||||
min-width:0;
|
||||
display:grid;
|
||||
gap:3px;
|
||||
}
|
||||
.pf-timeline-row__main b{
|
||||
color:var(--text-strong);
|
||||
font-size:var(--fs-sm);
|
||||
line-height:1.35;
|
||||
overflow:hidden;
|
||||
text-overflow:ellipsis;
|
||||
white-space:nowrap;
|
||||
}
|
||||
.pf-timeline-row__main span,
|
||||
.pf-timeline-row__main code,
|
||||
.pf-timeline-row__dates span{
|
||||
color:var(--text-muted);
|
||||
font-size:12px;
|
||||
line-height:1.35;
|
||||
}
|
||||
.pf-timeline-row__main span{
|
||||
overflow:hidden;
|
||||
text-overflow:ellipsis;
|
||||
white-space:nowrap;
|
||||
}
|
||||
.pf-timeline-row__main code{
|
||||
font-family:var(--font-num);
|
||||
overflow:hidden;
|
||||
text-overflow:ellipsis;
|
||||
white-space:nowrap;
|
||||
}
|
||||
.pf-timeline-row__metrics{
|
||||
min-width:0;
|
||||
display:grid;
|
||||
grid-template-columns:minmax(58px,.8fr) minmax(58px,.8fr) minmax(86px,1.12fr);
|
||||
gap:7px;
|
||||
}
|
||||
.pf-timeline-row__metrics span{
|
||||
min-width:0;
|
||||
display:grid;
|
||||
gap:3px;
|
||||
}
|
||||
.pf-timeline-row__metrics .vg-badge{
|
||||
justify-self:start;
|
||||
white-space:nowrap;
|
||||
}
|
||||
.pf-timeline-row__dates{
|
||||
display:grid;
|
||||
gap:3px;
|
||||
min-width:0;
|
||||
font-family:var(--font-num);
|
||||
}
|
||||
.pf-timeline-row__open{
|
||||
display:inline-flex;
|
||||
align-items:center;
|
||||
justify-content:center;
|
||||
gap:4px;
|
||||
min-height:30px;
|
||||
padding:0 8px;
|
||||
border:1px solid var(--hair);
|
||||
border-radius:var(--radius-sm);
|
||||
color:var(--accent-deep);
|
||||
background:var(--accent-tint);
|
||||
font-size:var(--fs-xs);
|
||||
font-weight:760;
|
||||
white-space:nowrap;
|
||||
}
|
||||
.pf-timeline-row:hover .pf-timeline-row__open{
|
||||
border-color:var(--accent);
|
||||
}
|
||||
.pf-empty{
|
||||
min-height:118px;
|
||||
display:grid;
|
||||
|
|
@ -763,9 +1125,13 @@
|
|||
}
|
||||
@media (max-width:1100px){
|
||||
.pf-signal-strip,
|
||||
.pf-workspace{
|
||||
.pf-workspace,
|
||||
.pf-analysis-shell{
|
||||
grid-template-columns:1fr;
|
||||
}
|
||||
.pf-analysis-rail{
|
||||
position:static;
|
||||
}
|
||||
.pf-triage{
|
||||
grid-template-columns:auto minmax(0,max-content) auto;
|
||||
justify-content:start;
|
||||
|
|
@ -778,6 +1144,33 @@
|
|||
.pf-growth-list{
|
||||
grid-template-columns:repeat(2,minmax(0,1fr));
|
||||
}
|
||||
.pf-analysis-rail .pf-growth-list{
|
||||
max-height:360px;
|
||||
grid-template-columns:repeat(2,minmax(0,1fr));
|
||||
}
|
||||
.pf-analysis__top{
|
||||
grid-template-columns:1fr;
|
||||
}
|
||||
.pf-analysis__metrics{
|
||||
grid-template-columns:repeat(3,minmax(0,1fr));
|
||||
}
|
||||
.pf-analysis__metrics span:nth-child(3n+1){
|
||||
border-left:0;
|
||||
}
|
||||
.pf-analysis__metrics span:nth-child(n+4){
|
||||
border-top:1px solid var(--hair);
|
||||
}
|
||||
.pf-analysis__body{
|
||||
grid-template-columns:1fr;
|
||||
}
|
||||
.pf-timeline-row{
|
||||
grid-template-columns:34px minmax(0,1fr) minmax(190px,.85fr) auto;
|
||||
}
|
||||
.pf-timeline-row__dates{
|
||||
grid-column:2 / 4;
|
||||
grid-row:2;
|
||||
grid-template-columns:repeat(2,minmax(0,1fr));
|
||||
}
|
||||
.pf-list,
|
||||
.pf-personas{
|
||||
max-height:360px;
|
||||
|
|
@ -791,6 +1184,15 @@
|
|||
width:100%;
|
||||
justify-content:flex-start;
|
||||
}
|
||||
.pf-depth-strip{
|
||||
grid-template-columns:repeat(2,minmax(0,1fr));
|
||||
}
|
||||
.pf-depth-strip span:nth-child(odd){
|
||||
border-left:0;
|
||||
}
|
||||
.pf-depth-strip span:nth-child(n+3){
|
||||
border-top:1px solid var(--hair);
|
||||
}
|
||||
.pf-triage{
|
||||
grid-template-columns:auto minmax(0,1fr);
|
||||
}
|
||||
|
|
@ -805,6 +1207,62 @@
|
|||
.pf-growth-list{
|
||||
grid-template-columns:1fr;
|
||||
}
|
||||
.pf-analysis-rail .pf-growth-list{
|
||||
grid-template-columns:1fr;
|
||||
}
|
||||
.pf-analysis{
|
||||
padding:10px;
|
||||
}
|
||||
.pf-analysis__metrics{
|
||||
grid-template-columns:repeat(2,minmax(0,1fr));
|
||||
}
|
||||
.pf-analysis__metrics span,
|
||||
.pf-analysis__metrics span:nth-child(3n+1){
|
||||
border-left:0;
|
||||
}
|
||||
.pf-analysis__metrics span:nth-child(even){
|
||||
border-left:1px solid var(--hair);
|
||||
}
|
||||
.pf-analysis__metrics span:nth-child(n+3){
|
||||
border-top:1px solid var(--hair);
|
||||
}
|
||||
.pf-stage-list{
|
||||
grid-template-columns:repeat(2,minmax(0,1fr));
|
||||
}
|
||||
.pf-stage-item,
|
||||
.pf-stage-item:first-child{
|
||||
border-left:0;
|
||||
}
|
||||
.pf-stage-item:nth-child(even){
|
||||
border-left:1px solid var(--hair);
|
||||
}
|
||||
.pf-stage-item:nth-child(n+3){
|
||||
border-top:1px solid var(--hair);
|
||||
}
|
||||
.pf-timeline{
|
||||
max-height:560px;
|
||||
}
|
||||
.pf-timeline-row{
|
||||
grid-template-columns:34px minmax(0,1fr);
|
||||
align-items:start;
|
||||
}
|
||||
.pf-timeline-row__main{
|
||||
grid-column:2;
|
||||
}
|
||||
.pf-timeline-row__metrics,
|
||||
.pf-timeline-row__dates,
|
||||
.pf-timeline-row__open{
|
||||
grid-column:2;
|
||||
}
|
||||
.pf-timeline-row__metrics{
|
||||
grid-template-columns:repeat(3,minmax(0,1fr));
|
||||
}
|
||||
.pf-timeline-row__dates{
|
||||
grid-row:auto;
|
||||
}
|
||||
.pf-timeline-row__open{
|
||||
justify-self:start;
|
||||
}
|
||||
.pf-recent-list{
|
||||
display:flex;
|
||||
flex-direction:column;
|
||||
|
|
@ -877,12 +1335,50 @@
|
|||
.pf-persona__actions .vg-btn{
|
||||
width:100%;
|
||||
}
|
||||
.pf-depth-strip{
|
||||
grid-template-columns:1fr;
|
||||
}
|
||||
.pf-depth-strip span,
|
||||
.pf-depth-strip span:nth-child(odd){
|
||||
border-left:0;
|
||||
}
|
||||
.pf-depth-strip span:nth-child(n+2){
|
||||
border-top:1px solid var(--hair);
|
||||
}
|
||||
.pf-growth-list{
|
||||
padding:10px;
|
||||
}
|
||||
.pf-growth-card__metrics{
|
||||
grid-template-columns:1fr;
|
||||
}
|
||||
.pf-analysis__metrics{
|
||||
grid-template-columns:1fr;
|
||||
}
|
||||
.pf-analysis__metrics span,
|
||||
.pf-analysis__metrics span:nth-child(even),
|
||||
.pf-analysis__metrics span:nth-child(3n+1){
|
||||
border-left:0;
|
||||
}
|
||||
.pf-analysis__metrics span:nth-child(n+2){
|
||||
border-top:1px solid var(--hair);
|
||||
}
|
||||
.pf-stage-list{
|
||||
grid-template-columns:1fr;
|
||||
}
|
||||
.pf-stage-item,
|
||||
.pf-stage-item:nth-child(even),
|
||||
.pf-stage-item:first-child{
|
||||
border-left:0;
|
||||
}
|
||||
.pf-stage-item:nth-child(n+2){
|
||||
border-top:1px solid var(--hair);
|
||||
}
|
||||
.pf-timeline-row__metrics{
|
||||
grid-template-columns:1fr;
|
||||
}
|
||||
.pf-timeline-row__dates{
|
||||
grid-template-columns:1fr;
|
||||
}
|
||||
.pf-growth-point{
|
||||
grid-template-columns:1fr;
|
||||
gap:2px;
|
||||
|
|
|
|||
|
|
@ -242,6 +242,54 @@
|
|||
.sr-card--side {
|
||||
align-self: start;
|
||||
}
|
||||
|
||||
@media (min-width: 1181px) {
|
||||
.sr-cols--supervisor {
|
||||
grid-template-columns: minmax(0, 1fr) minmax(286px, 340px);
|
||||
grid-template-areas: "main side";
|
||||
align-items: start;
|
||||
}
|
||||
.sr-cols--supervisor .sr-left {
|
||||
grid-area: main;
|
||||
min-width: 0;
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
grid-template-areas:
|
||||
"overview overview"
|
||||
"chart flow"
|
||||
"transcript transcript";
|
||||
gap: var(--sp-4);
|
||||
align-items: start;
|
||||
}
|
||||
.sr-cols--supervisor .sr-right {
|
||||
grid-area: side;
|
||||
min-width: 0;
|
||||
display: grid;
|
||||
gap: var(--sp-4);
|
||||
align-items: start;
|
||||
}
|
||||
.sr-cols--supervisor .sr-left > *,
|
||||
.sr-cols--supervisor .sr-right > * {
|
||||
grid-area: auto;
|
||||
}
|
||||
.sr-cols--supervisor .sr-overview {
|
||||
grid-area: overview;
|
||||
position: static;
|
||||
max-height: none;
|
||||
overflow: visible;
|
||||
scrollbar-gutter: auto;
|
||||
}
|
||||
.sr-cols--supervisor .sr-card--chart {
|
||||
grid-area: chart;
|
||||
}
|
||||
.sr-cols--supervisor .sr-card--flow {
|
||||
grid-area: flow;
|
||||
}
|
||||
.sr-cols--supervisor .sr-card--transcript {
|
||||
grid-area: transcript;
|
||||
align-self: start;
|
||||
}
|
||||
}
|
||||
.sr-root--empty .sr-card--side {
|
||||
padding-top: var(--sp-5);
|
||||
padding-bottom: var(--sp-5);
|
||||
|
|
|
|||
|
|
@ -3111,6 +3111,55 @@
|
|||
.sx-page--active .sx-col-right {
|
||||
display: none;
|
||||
}
|
||||
.sx-page--active.sx-feedback-coached .sx-grid {
|
||||
grid-template-rows: auto minmax(0, 1fr) auto;
|
||||
}
|
||||
.sx-page--active.sx-feedback-coached .sx-col-right {
|
||||
display: flex;
|
||||
grid-column: 1;
|
||||
grid-row: 3;
|
||||
min-height: 0;
|
||||
overflow: visible;
|
||||
}
|
||||
.sx-page--active.sx-feedback-coached .sx-col-right .sx-meters,
|
||||
.sx-page--active.sx-feedback-coached .sx-col-right .sx-safety,
|
||||
.sx-page--active.sx-feedback-coached .sx-signal__head,
|
||||
.sx-page--active.sx-feedback-coached .sx-signal__one,
|
||||
.sx-page--active.sx-feedback-coached .sx-signal__rest,
|
||||
.sx-page--active.sx-feedback-coached .sx-signal__wave,
|
||||
.sx-page--active.sx-feedback-coached .sx-signal__rows,
|
||||
.sx-page--active.sx-feedback-coached .sx-signal__seq,
|
||||
.sx-page--active.sx-feedback-coached .sx-signal__defer {
|
||||
display: none;
|
||||
}
|
||||
.sx-page--active.sx-feedback-coached .sx-col-right .sx-signal {
|
||||
padding: 8px;
|
||||
overflow: visible;
|
||||
}
|
||||
.sx-page--active.sx-feedback-coached .sx-coach-card {
|
||||
grid-template-columns: 34px minmax(0, 1fr);
|
||||
gap: 8px;
|
||||
margin-top: 0;
|
||||
}
|
||||
.sx-page--active.sx-feedback-coached .sx-coach-avatar {
|
||||
width: 34px;
|
||||
height: 34px;
|
||||
}
|
||||
.sx-page--active.sx-feedback-coached .sx-coach-avatar__face {
|
||||
left: 9px;
|
||||
right: 9px;
|
||||
top: 12px;
|
||||
}
|
||||
.sx-page--active.sx-feedback-coached .sx-coach-bubble {
|
||||
padding: 8px 10px;
|
||||
gap: 5px;
|
||||
}
|
||||
.sx-page--active.sx-feedback-coached .sx-coach-bubble blockquote {
|
||||
display: none;
|
||||
}
|
||||
.sx-page--active.sx-feedback-coached .sx-coach-bubble__actions {
|
||||
display: none;
|
||||
}
|
||||
.sx-head {
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue