세션 평가와 교수자 분석 보강

This commit is contained in:
Yun Chan 2026-07-01 12:10:52 +09:00
parent 5c4ac04e06
commit fe2796f05a
51 changed files with 4928 additions and 240 deletions

View file

@ -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={

View file

@ -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" },

View file

@ -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;

View file

@ -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,

View file

@ -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>
);
}

View file

@ -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}

View file

@ -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>

View file

@ -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;

View file

@ -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;

View file

@ -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);

View file

@ -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;