계약 DTO SSOT 3차
This commit is contained in:
parent
1007eaf7d9
commit
480fbd543a
10 changed files with 90 additions and 162 deletions
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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"),
|
||||
|
|
|
|||
|
|
@ -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:"";
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
/>
|
||||
))
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue