계약 DTO SSOT 3차

This commit is contained in:
Yun Chan 2026-06-27 18:27:30 +09:00
parent 1007eaf7d9
commit 480fbd543a
10 changed files with 90 additions and 162 deletions

View file

@ -21,6 +21,10 @@ async function signInAsLearnerEmail(
},
});
expect(res.ok(), await res.text()).toBeTruthy();
const consent = await page.request.post("/api/auth/consent", {
data: { accepted: true },
});
expect(consent.ok(), await consent.text()).toBeTruthy();
}
async function createPracticeSession(page: import("@playwright/test").Page, personaCode?: string) {

View file

@ -197,10 +197,7 @@ export type SessionEndResponse = ApiSchema<"SessionEndResponse">;
export type CrisisResource = ApiSchema<"CrisisResourceResponse">;
export type LearnerSessionSummary = ApiSchema<"LearnerSessionSummary">;
export interface LearnerSessionsResponse {
source: string;
sessions: LearnerSessionSummary[];
}
export type LearnerSessionsResponse = ApiSchema<"LearnerSessionsResponse">;
export type SessionDetailTurn = ApiSchema<"SessionDetailTurn">;
@ -224,16 +221,7 @@ export type ReviewTechnique = ApiSchema<"ReviewTechnique">;
export type ReviewNonverbalEvent = ApiSchema<"ReviewNonverbalEvent">;
export type ReviewNote = ApiSchema<"ReviewNote">;
export interface ReviewTurn {
id: string;
ts: string;
speaker: "learner" | "client";
who: string;
text: string;
techniques: ReviewTechnique[];
nonverbal: ReviewNonverbalEvent[];
note?: ReviewNote | null;
}
export type ReviewTurn = ApiSchema<"ReviewTurn">;
export type ReviewPhaseSegment = ApiSchema<"ReviewPhaseSegment">;
export type ReviewValencePoint = ApiSchema<"ReviewValencePoint">;
@ -241,56 +229,10 @@ export type ReviewRubricRow = ApiSchema<"ReviewRubricRow">;
export type ReviewPoint = ApiSchema<"ReviewPoint">;
export type ReviewWorksheetEvidence = ApiSchema<"ReviewWorksheetEvidence">;
export interface ReviewWorksheetItem {
key: string;
label: string;
value?: string | null;
evidence: ReviewWorksheetEvidence[];
confidence: "none" | "low" | "medium";
emptyReason?: string | null;
}
export interface ReviewWorksheetSection {
key: string;
title: string;
items: ReviewWorksheetItem[];
}
export interface ReviewCaseWorksheet {
status: "empty" | "draft_from_transcript";
generatedBy: string;
sections: ReviewWorksheetSection[];
limitations: string[];
}
export interface SessionReviewResponse {
session_id: string;
client: ReviewClient;
date: string;
durationLabel: string;
durationSeconds: number;
reachedPhase: string;
sessionSignal: string;
supervisorState: string;
supervisorName: string;
summary: string;
phases: ReviewPhaseSegment[];
phaseAxis: string[];
valenceAxis: string[];
clientValence: ReviewValencePoint[];
counselorBaseline: ReviewValencePoint[];
turns: ReviewTurn[];
rubric: ReviewRubricRow[];
goodMoments: ReviewPoint[];
growthPoints: ReviewPoint[];
caseWorksheet?: ReviewCaseWorksheet | null;
nextLine?: string | null;
clientFeedback?: string | null;
audioUrl?: string | null;
pdfExportUrl?: string | null;
degraded: boolean;
reviewReady: boolean;
}
export type ReviewWorksheetItem = ApiSchema<"ReviewWorksheetItem">;
export type ReviewWorksheetSection = ApiSchema<"ReviewWorksheetSection">;
export type ReviewCaseWorksheet = ApiSchema<"ReviewCaseWorksheet">;
export type SessionReviewResponse = ApiSchema<"SessionReviewResponse">;
/* =====================================================================
SSE POST /sessions/{id}/stream
@ -528,53 +470,13 @@ export const adminUsersApi = {
}),
};
export interface TeacherSessionSummary {
session_id: string;
learner_id: string;
learner_label: string;
persona_code: string;
persona_name: string;
session_no: number;
status: "active" | "ended" | string;
stage: string;
turn_count: number;
learner_turn_count: number;
client_turn_count: number;
started_at: string;
ended_at: string | null;
}
export type TeacherSessionSummary = ApiSchema<"TeacherSessionSummary">;
export type TeacherSafetyAlert = ApiSchema<"TeacherSafetyAlert">;
export type TeacherGrowthPoint = ApiSchema<"TeacherGrowthPoint">;
export interface TeacherLearnerGrowth {
learner_id: string;
learner_label: string;
sessions: number;
ended_sessions: number;
latest_at: string;
first_score: number | null;
latest_score: number | null;
score_delta: number | null;
avg_score: number | null;
avg_rapport: number | null;
trend: "up" | "down" | "flat" | "insufficient" | string;
top_techniques: string[];
points: TeacherGrowthPoint[];
}
export interface TeacherDashboardResponse {
source: string;
cohort_label: string;
total_learners: number;
active_sessions: number;
ended_sessions: number;
safety_alerts: TeacherSafetyAlert[];
learner_growth: TeacherLearnerGrowth[];
pending_reviews: TeacherSessionSummary[];
recent_sessions: TeacherSessionSummary[];
message: string;
}
export type TeacherLearnerGrowth = ApiSchema<"TeacherLearnerGrowth">;
export type TeacherDashboardResponse = ApiSchema<"TeacherDashboardResponse">;
export const teacherApi = {
dashboard: () => api.get<TeacherDashboardResponse>("/teacher/dashboard"),

View file

@ -117,7 +117,7 @@ export default function LearnerHome() {
}
if (sessionResult.status === "fulfilled") {
setSessions(sessionResult.value.sessions);
setSessions(sessionResult.value.sessions ?? []);
setSessionLoadState("ready");
} else {
console.warn("[learner-home] failed to load sessions", sessionResult.reason);
@ -438,7 +438,7 @@ const LH_CSS = `
gap:clamp(12px,1.6vw,20px);
padding:clamp(14px,2vw,28px);
background:var(--bg-app);
overflow:hidden;
overflow:clip;
}
.lh-root::before{
content:"";

View file

@ -291,7 +291,7 @@ export default function Professor() {
() => [
{
label: "검토 대기",
value: dashboard?.pending_reviews.length ?? 0,
value: dashboard?.pending_reviews?.length ?? 0,
hint: "종료 회기",
icon: "review" as const,
},
@ -303,13 +303,13 @@ export default function Professor() {
},
{
label: "최근 세션",
value: dashboard?.recent_sessions.length ?? 0,
value: dashboard?.recent_sessions?.length ?? 0,
hint: "종료 · 진행",
icon: "play" as const,
},
{
label: "위기 알림",
value: dashboard?.safety_alerts.length ?? 0,
value: dashboard?.safety_alerts?.length ?? 0,
hint: "109 확인",
icon: "alert" as const,
},
@ -328,12 +328,14 @@ export default function Professor() {
],
[dashboard, personaReviews.length],
);
const pendingCount = dashboard?.pending_reviews.length ?? 0;
const pendingCount = dashboard?.pending_reviews?.length ?? 0;
const hasPending = pendingCount > 0;
const totalSessions = (dashboard?.active_sessions ?? 0) + (dashboard?.ended_sessions ?? 0);
const personaReviewCount = personaReviews.length;
const safetyAlerts = dashboard?.safety_alerts ?? [];
const learnerGrowth = dashboard?.learner_growth ?? [];
const pendingReviews = dashboard?.pending_reviews ?? [];
const recentSessions = dashboard?.recent_sessions ?? [];
return (
<AppShell>
@ -630,13 +632,13 @@ export default function Professor() {
<Kicker> </Kicker>
<h2> </h2>
</div>
<Badge tone="accent">{dashboard?.pending_reviews.length ?? 0}</Badge>
<Badge tone="accent">{pendingReviews.length}</Badge>
</div>
<Card className="pf-panel">
{dashboard && dashboard.pending_reviews.length > 0 ? (
{pendingReviews.length > 0 ? (
<div className="pf-list">
{dashboard.pending_reviews.map((session) => (
{pendingReviews.map((session) => (
<article className="pf-session" key={session.session_id}>
<span className="pf-session__dot" aria-hidden="true" />
<div className="pf-session__main">
@ -647,7 +649,7 @@ export default function Professor() {
<code>{session.session_id}</code>
</div>
<div className="pf-session__meta">
<span>{formatDateTime(session.ended_at)}</span>
<span>{formatDateTime(session.ended_at ?? null)}</span>
<Badge tone="neutral"> </Badge>
</div>
</article>
@ -669,11 +671,11 @@ export default function Professor() {
<Kicker> </Kicker>
<h2> </h2>
</div>
<Badge tone="neutral">{dashboard?.recent_sessions.length ?? 0}</Badge>
<Badge tone="neutral">{recentSessions.length}</Badge>
</div>
<Card className="pf-panel">
{dashboard && dashboard.recent_sessions.length > 0 ? (
{recentSessions.length > 0 ? (
<div className="pf-recent-list">
<div className="pf-recent-head" aria-hidden="true">
<span></span>
@ -684,7 +686,7 @@ export default function Professor() {
<span></span>
<span></span>
</div>
{dashboard.recent_sessions.map((session) => (
{recentSessions.map((session) => (
<article
className="pf-recent-row"
key={session.session_id}
@ -712,7 +714,7 @@ export default function Professor() {
{formatDateTime(session.started_at)}
</span>
<span className="pf-recent__cell tabular" data-label="종료">
{formatDateTime(session.ended_at)}
{formatDateTime(session.ended_at ?? null)}
</span>
</article>
))}
@ -732,7 +734,9 @@ export default function Professor() {
}
function GrowthCard({ learner }: { learner: TeacherLearnerGrowth }) {
const recentPoints = learner.points.slice(-3).reverse();
const points = learner.points ?? [];
const topTechniques = learner.top_techniques ?? [];
const recentPoints = points.slice(-3).reverse();
return (
<article className={`pf-growth-card trend-${learner.trend}`}>
<div className="pf-growth-card__top">
@ -763,14 +767,14 @@ function GrowthCard({ learner }: { learner: TeacherLearnerGrowth }) {
</div>
<div className="pf-growth-bars" aria-label="회기별 적절성 추이">
{learner.points.map((point) => (
{points.map((point) => (
<GrowthBar point={point} key={point.session_id} />
))}
</div>
<div className="pf-growth-card__tags">
{learner.top_techniques.length > 0 ? (
learner.top_techniques.map((tag) => <span key={tag}>{tag}</span>)
{topTechniques.length > 0 ? (
topTechniques.map((tag) => <span key={tag}>{tag}</span>)
) : (
<span> </span>
)}
@ -1092,6 +1096,9 @@ const PF_CSS = `
background:linear-gradient(180deg,var(--bg-surface),color-mix(in srgb,var(--accent-tint) 18%,var(--bg-surface)));
}
.pf-growth-list{
max-height:min(540px,52vh);
overflow:auto;
scrollbar-gutter:stable;
display:grid;
grid-template-columns:repeat(3,minmax(0,1fr));
gap:12px;

View file

@ -138,6 +138,7 @@ function CaseWorksheetCard({
onJump: (id: string) => void;
}) {
const sections = worksheet?.sections ?? [];
const limitations = worksheet?.limitations ?? [];
return (
<Card className="sr-card sr-card--side sr-card--worksheet">
<Kicker> </Kicker>
@ -147,8 +148,8 @@ function CaseWorksheetCard({
<section className="sr-ws-section" key={section.key}>
<h3>{section.title}</h3>
<div className="sr-ws-items">
{section.items.map((item) => {
const evidence = item.evidence[0];
{(section.items ?? []).map((item) => {
const evidence = item.evidence?.[0];
return (
<div className="sr-ws-item" key={item.key}>
<div className="sr-ws-item__head">
@ -180,9 +181,9 @@ function CaseWorksheetCard({
/>
)}
</div>
{(worksheet?.limitations ?? []).length > 0 ? (
{limitations.length > 0 ? (
<div className="sr-ws-limitations">
{worksheet!.limitations.slice(0, 2).map((item) => (
{limitations.slice(0, 2).map((item) => (
<span key={item}>{item}</span>
))}
</div>
@ -242,8 +243,9 @@ export default function SessionReview() {
function jumpToTurn(id: string) {
if (!data) return;
const turns = data.turns ?? [];
setActiveTurn(id);
if (learnerOnly && data.turns.find((turn) => turn.id === id)?.speaker === "client") {
if (learnerOnly && turns.find((turn) => turn.id === id)?.speaker === "client") {
setLearnerOnly(false);
}
requestAnimationFrame(() => {
@ -283,12 +285,21 @@ export default function SessionReview() {
);
}
const hasValence = data.clientValence.length > 0 || data.counselorBaseline.length > 0;
const turns = data.turns ?? [];
const phases = data.phases ?? [];
const phaseAxis = data.phaseAxis ?? [];
const valenceAxis = data.valenceAxis ?? [];
const clientValence = data.clientValence ?? [];
const counselorBaseline = data.counselorBaseline ?? [];
const rubric = data.rubric ?? [];
const goodMoments = data.goodMoments ?? [];
const growthPoints = data.growthPoints ?? [];
const hasValence = clientValence.length > 0 || counselorBaseline.length > 0;
const canOpenAudio = Boolean(data.audioUrl);
const canExportPdf = Boolean(data.pdfExportUrl);
const hasTranscript = data.turns.length > 0;
const hasTranscript = turns.length > 0;
const reviewReadiness = hasTranscript
? `${data.turns.length}개 발화 기반`
? `${turns.length}개 발화 기반`
: "축어록 저장 후 생성";
return (
@ -384,9 +395,9 @@ export default function SessionReview() {
<div style={{ marginTop: "var(--sp-4)" }}>
{hasValence ? (
<ValenceChart
client={data.clientValence}
baseline={data.counselorBaseline}
xLabels={data.valenceAxis}
client={clientValence}
baseline={counselorBaseline}
xLabels={valenceAxis}
/>
) : (
<EmptyBlock
@ -400,10 +411,10 @@ export default function SessionReview() {
<Card className="sr-card sr-card--flow">
<Kicker> </Kicker>
<div className="sr-phasebar" style={{ marginTop: "var(--sp-4)" }}>
{data.phases.length > 0 ? (
{phases.length > 0 ? (
<>
<div className="sr-phasebar__track">
{data.phases.map((phase, index) => (
{phases.map((phase, index) => (
<div
key={`${phase.key}-${index}`}
className="sr-phase"
@ -421,7 +432,7 @@ export default function SessionReview() {
))}
</div>
<div className="sr-phasebar__axis tabular">
{data.phaseAxis.map((label, index) => (
{phaseAxis.map((label, index) => (
<span key={`${label}-${index}`}>{label}</span>
))}
</div>
@ -441,7 +452,7 @@ export default function SessionReview() {
className="sr-chip-toggle"
aria-pressed={!learnerOnly}
onClick={() => setLearnerOnly(false)}
disabled={data.turns.length === 0}
disabled={turns.length === 0}
>
</button>
@ -450,7 +461,7 @@ export default function SessionReview() {
className="sr-chip-toggle"
aria-pressed={learnerOnly}
onClick={() => setLearnerOnly(true)}
disabled={data.turns.length === 0}
disabled={turns.length === 0}
>
</button>
@ -483,7 +494,7 @@ export default function SessionReview() {
<div className="sr-turn__body">
<div className={`sr-turn__who sr-turn__who--${turn.speaker}`}>
{turn.who}
{turn.techniques.map((tech, i) => (
{(turn.techniques ?? []).map((tech, i) => (
<TechniqueChip key={`${tech.label}-${i}`} tech={tech} />
))}
{(turn.nonverbal ?? []).map((event, i) => (
@ -520,8 +531,8 @@ export default function SessionReview() {
<Card className="sr-card sr-card--side sr-card--rubric">
<Kicker> </Kicker>
<div className="sr-rubric" style={{ marginTop: "var(--sp-4)" }}>
{data.rubric.length > 0 ? (
data.rubric.map((row) => (
{rubric.length > 0 ? (
rubric.map((row) => (
<div key={row.name} className="sr-rubric__row">
<div className="sr-rubric__top">
<span className="sr-rubric__name">{row.name}</span>
@ -557,12 +568,12 @@ export default function SessionReview() {
<Card className="sr-card sr-card--side sr-card--good">
<Kicker> </Kicker>
<div className="sr-points sr-points--good" style={{ marginTop: "var(--sp-4)" }}>
{data.goodMoments.length > 0 ? (
data.goodMoments.map((point, i) => (
{goodMoments.length > 0 ? (
goodMoments.map((point, i) => (
<JumpablePoint
key={`${point.title}-${i}`}
point={point}
turns={data.turns}
turns={turns}
onJump={jumpToTurn}
/>
))
@ -578,12 +589,12 @@ export default function SessionReview() {
<Card className="sr-card sr-card--side sr-card--growth">
<Kicker> </Kicker>
<div className="sr-points sr-points--grow" style={{ marginTop: "var(--sp-4)" }}>
{data.growthPoints.length > 0 ? (
data.growthPoints.slice(0, 3).map((point, i) => (
{growthPoints.length > 0 ? (
growthPoints.slice(0, 3).map((point, i) => (
<JumpablePoint
key={`${point.title}-${i}`}
point={point}
turns={data.turns}
turns={turns}
onJump={jumpToTurn}
/>
))