세션 평가·라이브코치·교수자 분석 라운드 마감 + 문서 정리 + 코드품질 리팩터

- 누적 작업트리 커밋: 회기 평가 복구·durable 저장, 라이브 코치 이력/근거, 교수자 학생분석, 음성 비언어 메타, PII 마스킹, 운영 티켓/헬스 등
- 문서: 완료 기록 docs/archive/ 냉동 보관, docs/ 단일 인덱스(docs/README.md)+통합 TODO(docs/TODO.md)로 정리
- 리팩터(행위 보존): Stage enum SSOT(taxonomy 소유·state_machine re-export), store recent/masked_turns 중복 제거, speaker_ko_label 단일 헬퍼, _list_sessions N+1 제거(state/turns 배치 + 턴평가 하이드레이션 배치)
- 검증: 백엔드 pytest 352 passed, _list_sessions E2E chromium-single-run 2 passed
This commit is contained in:
Yun Chan 2026-07-02 02:50:36 +09:00
parent 7c41c3ce79
commit 778e8526d4
108 changed files with 6457 additions and 455 deletions

View file

@ -17,7 +17,7 @@
import type { ReactNode } from "react";
import { BrowserRouter, Navigate, Route, Routes, useLocation } from "react-router-dom";
import { AuthProvider, canAccessRole, useAuth, roleHomePath, type Role } from "./lib/auth";
import { AuthProvider, canAccessRole, useAuth, roleHomePath, type AuthUser, type Role } from "./lib/auth";
import Login from "./pages/Login";
import Onboarding from "./pages/Onboarding";
@ -74,6 +74,17 @@ function RequireAuth({
return <>{children}</>;
}
function isAdminWorkspacePath(path: string) {
return path === "/admin" || path.startsWith("/admin/");
}
function approvedHomePath(user: AuthUser) {
if (user.onboardingCompletedAt == null) {
return canAccessRole(user, "admin") ? "/admin" : "/onboarding";
}
return roleHomePath(user.role);
}
function OnboardingGate({ children }: { children: ReactNode }) {
const { user, loading } = useAuth();
const location = useLocation();
@ -82,6 +93,9 @@ function OnboardingGate({ children }: { children: ReactNode }) {
if (loading) return <BootScreen />;
if (path === "/pending") return <>{children}</>;
if (user && user.onboardingCompletedAt == null && path !== "/onboarding") {
if (isAdminWorkspacePath(path) && canAccessRole(user, "admin")) {
return <>{children}</>;
}
return <Navigate to="/onboarding" replace state={{ from: path }} />;
}
if (user && user.onboardingCompletedAt != null && (path === "/login" || path === "/onboarding")) {
@ -100,12 +114,7 @@ function PendingApprovalGate({ children }: { children: ReactNode }) {
return <Navigate to="/pending" replace state={{ from: path }} />;
}
if (user && user.accountStatus === "approved" && path === "/pending") {
return (
<Navigate
to={user.onboardingCompletedAt == null ? "/onboarding" : roleHomePath(user.role)}
replace
/>
);
return <Navigate to={approvedHomePath(user)} replace />;
}
return <>{children}</>;
}
@ -118,7 +127,7 @@ function RootRedirect() {
return <Navigate to="/pending" replace />;
}
if (user && user.onboardingCompletedAt == null) {
return <Navigate to="/onboarding" replace />;
return <Navigate to={approvedHomePath(user)} replace />;
}
return <Navigate to={user ? roleHomePath(user.role) : "/login"} replace />;
}

View file

@ -2439,6 +2439,33 @@ export interface components {
source_note: string;
terms: components["schemas"]["LegalDocument"];
};
/**
* LiveCoachCreditEvent
* @description / .
*/
LiveCoachCreditEvent: {
/** Balance */
balance: number;
/** Created At */
created_at: string;
/** Delta */
delta: number;
/** Event Id */
event_id: string;
/**
* Event Type
* @enum {string}
*/
event_type: "use" | "recharge";
/** Reason */
reason: string;
/** Session Id */
session_id: string;
/** Stage */
stage?: ("라포" | "탐색" | "개입" | "정리") | null;
/** Turn Seq */
turn_seq: number;
};
/**
* LiveCoachEvent
* @description .
@ -2462,8 +2489,11 @@ export interface components {
};
/** LiveCoachHistoryResponse */
LiveCoachHistoryResponse: {
/** Credit Events */
credit_events?: components["schemas"]["LiveCoachCreditEvent"][];
/** Events */
events?: components["schemas"]["LiveCoachEvent"][];
quota?: components["schemas"]["LiveCoachQuota"];
/**
* Source
* @default runtime
@ -2471,6 +2501,16 @@ export interface components {
*/
source: "database" | "runtime";
};
/**
* LiveCoachQuota
* @description .
*/
LiveCoachQuota: {
/** Max */
max: number;
/** Remaining */
remaining: number;
};
/** LiveCoachRequest */
LiveCoachRequest: {
/** Client Reply */
@ -2544,6 +2584,8 @@ export interface components {
* @description .
*/
LiveCoachSuggestion: {
/** Credit Events */
credit_events?: components["schemas"]["LiveCoachCreditEvent"][];
/**
* Focus
* @default exploration
@ -2559,6 +2601,13 @@ export interface components {
message: string;
/** Next Utterance */
next_utterance?: string | null;
/**
* Persistence Source
* @default database
* @enum {string}
*/
persistence_source: "database" | "runtime";
quota?: components["schemas"]["LiveCoachQuota"] | null;
/** Rationale */
rationale?: string | null;
/** Safety Note */
@ -3784,6 +3833,14 @@ export interface components {
client_turn_count: number;
/** Ended At */
ended_at?: string | null;
/** Evaluation Error */
evaluation_error?: string | null;
/**
* Evaluation Status
* @default pending
* @enum {string}
*/
evaluation_status: "pending" | "ready" | "error";
/** Learner Id */
learner_id: string;
/** Learner Label */
@ -3796,6 +3853,11 @@ export interface components {
persona_name: string;
/** Review Note */
review_note?: string | null;
/**
* Review Ready
* @default false
*/
review_ready: boolean;
/**
* Review Status
* @default pending
@ -3817,6 +3879,12 @@ export interface components {
started_at: string;
/** Status */
status: string;
/**
* Supervisor State
* @default
* @enum {string}
*/
supervisor_state: "기록 대기" | "평가 대기" | "평가 완료" | "평가 실패";
/** Turn Count */
turn_count: number;
};

View file

@ -201,8 +201,10 @@ export type SessionStartResponse = ApiSchema<"SessionStartResponse">;
/** POST /sessions/{id}/turn — sessions.py TurnResponse */
export type TurnResponse = ApiSchema<"TurnResponse">;
export type LiveCoachRequest = ApiSchema<"LiveCoachRequest">;
export type LiveCoachCreditEvent = ApiSchema<"LiveCoachCreditEvent">;
export type LiveCoachEvent = ApiSchema<"LiveCoachEvent">;
export type LiveCoachHistoryResponse = ApiSchema<"LiveCoachHistoryResponse">;
export type LiveCoachQuota = ApiSchema<"LiveCoachQuota">;
export type LiveCoachSuggestion = ApiSchema<"LiveCoachSuggestion">;
export type LiveCoachSource = ApiSchema<"LiveCoachSource">;
export type ReevaluateRequest = ApiSchema<"ReevaluateRequest">;

View file

@ -21,7 +21,19 @@ import "./professor.css";
type LoadState = "loading" | "ready" | "error";
type ProfessorView = "console" | "analysis";
type LearnerSort = "latest" | "sessions" | "score" | "delta";
type LearnerAnalysisTab = "trend" | "sessions" | "stages";
type LearnerAnalysisTab = "personas" | "trend" | "sessions" | "stages";
type PersonaSessionGroup = {
key: string;
personaCode: string;
personaName: string;
sessions: TeacherSessionSummary[];
latestAt: string | null;
endedSessions: number;
pendingReviews: number;
closedReviews: number;
avgScore: number | null;
avgRapport: number | null;
};
function formatDateTime(value: string | null): string {
if (!value) return "-";
@ -51,6 +63,24 @@ function formatRapport(value: number | null | undefined): string {
return `${Math.round(((value + 1) / 2) * 100)}%`;
}
function average(values: Array<number | null | undefined>): number | null {
const numbers = values.filter(
(value): value is number => typeof value === "number" && !Number.isNaN(value),
);
if (numbers.length === 0) return null;
return numbers.reduce((sum, value) => sum + value, 0) / numbers.length;
}
function timeValue(value: string | null | undefined): number {
if (!value) return 0;
const time = new Date(value).getTime();
return Number.isNaN(time) ? 0 : time;
}
function domToken(value: string): string {
return value.replace(/[^A-Za-z0-9_-]+/g, "-") || "unknown";
}
function trendLabel(value: string): string {
if (value === "up") return "상승";
if (value === "down") return "하락";
@ -74,6 +104,16 @@ function sessionReviewTone(status: string | null | undefined): "accent" | "neutr
return "neutral";
}
function sessionSupervisorTone(status: string | null | undefined): "accent" | "neutral" | "warn" {
if (status === "평가 완료") return "accent";
if (status === "평가 실패" || status === "평가 대기") return "warn";
return "neutral";
}
function sessionSupervisorLabel(session: TeacherSessionSummary): string {
return session.supervisor_state ?? (session.review_ready ? "평가 완료" : "기록 대기");
}
function personaReviewStatusLabel(status: PersonaReviewStatus): string {
if (status === "review") return "검수 대기";
if (status === "draft") return "수정 대기";
@ -557,6 +597,9 @@ export default function Professor({ view = "console" }: { view?: ProfessorView }
</div>
<div className="pf-session__meta">
<span>{formatDateTime(session.ended_at ?? null)}</span>
<Badge tone={sessionSupervisorTone(sessionSupervisorLabel(session))}>
{sessionSupervisorLabel(session)}
</Badge>
<Badge tone={sessionReviewTone(session.review_status)}>
{sessionReviewStatusLabel(session.review_status)}
</Badge>
@ -773,6 +816,7 @@ export default function Professor({ view = "console" }: { view?: ProfessorView }
</Badge>
{session.status === "ended" ? (
<small className="pf-recent__review-state">
{sessionSupervisorLabel(session)} ·{" "}
{sessionReviewStatusLabel(session.review_status)}
</small>
) : null}
@ -1033,13 +1077,80 @@ function LearnerAnalysisPanel({
analysis: TeacherLearnerAnalysisResponse;
onOpenSession: (sessionId: string) => void;
}) {
const [activeTab, setActiveTab] = useState<LearnerAnalysisTab>("trend");
const [activeTab, setActiveTab] = useState<LearnerAnalysisTab>("personas");
const [expandedPersonaKey, setExpandedPersonaKey] = useState<string | null>(null);
const points = analysis.points ?? [];
const sessions = analysis.sessions ?? [];
const pointBySession = new Map(points.map((point) => [point.session_id, point]));
const pointBySession = useMemo(
() => new Map(points.map((point) => [point.session_id, point])),
[points],
);
const stageBreakdown = analysis.stage_breakdown ?? [];
const summary = analysis.summary;
const personaGroups = useMemo<PersonaSessionGroup[]>(() => {
const groups = new Map<string, PersonaSessionGroup>();
sessions.forEach((session) => {
const personaCode = session.persona_code || "페르소나";
const personaName = session.persona_name || personaCode;
const key = personaCode || personaName;
const group =
groups.get(key) ??
{
key,
personaCode,
personaName,
sessions: [],
latestAt: null,
endedSessions: 0,
pendingReviews: 0,
closedReviews: 0,
avgScore: null,
avgRapport: null,
};
group.sessions.push(session);
if (session.status === "ended") group.endedSessions += 1;
if (session.review_status === "closed") {
group.closedReviews += 1;
} else {
group.pendingReviews += 1;
}
const sessionTime = timeValue(session.ended_at ?? session.started_at);
if (sessionTime >= timeValue(group.latestAt)) {
group.latestAt = session.ended_at ?? session.started_at;
}
groups.set(key, group);
});
return Array.from(groups.values())
.map((group) => {
const orderedSessions = [...group.sessions].sort(
(left, right) =>
left.session_no - right.session_no ||
timeValue(left.started_at) - timeValue(right.started_at),
);
return {
...group,
sessions: orderedSessions,
avgScore: average(
orderedSessions.map((session) => pointBySession.get(session.session_id)?.score),
),
avgRapport: average(
orderedSessions.map((session) => pointBySession.get(session.session_id)?.rapport),
),
};
})
.sort(
(left, right) =>
timeValue(right.latestAt) - timeValue(left.latestAt) ||
right.sessions.length - left.sessions.length,
);
}, [pointBySession, sessions]);
useEffect(() => {
setActiveTab("personas");
setExpandedPersonaKey(null);
}, [analysis.learner_id]);
const tabs: Array<{ key: LearnerAnalysisTab; label: string; count: string }> = [
{ key: "personas", label: "페르소나별 회기", count: `${personaGroups.length}` },
{ key: "trend", label: "추이", count: `${points.length}` },
{ key: "sessions", label: "전체 회기", count: `${sessions.length}` },
{ key: "stages", label: "단계 분석", count: `${stageBreakdown.length}단계` },
@ -1098,6 +1209,94 @@ function LearnerAnalysisPanel({
))}
</div>
{activeTab === "personas" ? (
<section className="pf-analysis__personas" aria-label="페르소나별 회기">
<div className="pf-analysis__subhead">
<b> </b>
<span>
{personaGroups.length} · {sessions.length}
</span>
</div>
{personaGroups.length > 0 ? (
<div className="pf-persona-drill">
{personaGroups.map((group) => {
const isExpanded = expandedPersonaKey === group.key;
const panelId = `persona-sessions-${domToken(group.key)}`;
return (
<article
className={`pf-persona-group ${isExpanded ? "is-open" : ""}`}
data-learner-persona-group="true"
key={group.key}
>
<button
type="button"
className="pf-persona-group__summary"
aria-expanded={isExpanded}
aria-controls={panelId}
aria-label={`${group.personaCode} ${group.personaName} 회기 ${
isExpanded ? "접기" : "펼치기"
}`}
onClick={() => setExpandedPersonaKey(isExpanded ? null : group.key)}
>
<span className="pf-persona-group__code">{group.personaCode}</span>
<span className="pf-persona-group__main">
<b>{group.personaName}</b>
<small>
{formatDateTime(group.latestAt)} · {group.endedSessions}/
{group.sessions.length} · {group.pendingReviews}
</small>
</span>
<span className="pf-persona-group__metrics">
<span>
<small></small>
<b>{group.sessions.length}</b>
</span>
<span>
<small></small>
<b>{formatScore(group.avgScore)}</b>
</span>
<span>
<small></small>
<b>{formatRapport(group.avgRapport)}</b>
</span>
<span>
<small></small>
<b>
{group.closedReviews}/{group.sessions.length}
</b>
</span>
</span>
<span className="pf-persona-group__open">
<Icon name={isExpanded ? "arrow-up" : "arrow-down"} size={13} />
{isExpanded ? "회기 접기" : "회기 펼치기"}
</span>
</button>
{isExpanded ? (
<div
className="pf-persona-sessions"
id={panelId}
data-learner-persona-sessions="true"
>
{group.sessions.map((session) => (
<LearnerSessionRow
session={session}
point={pointBySession.get(session.session_id)}
key={session.session_id}
onOpen={() => onOpenSession(session.session_id)}
/>
))}
</div>
) : null}
</article>
);
})}
</div>
) : (
<EmptyState title="페르소나 기록 없음" desc="이 학습자의 페르소나별 회기 기록이 없습니다." />
)}
</section>
) : null}
{activeTab === "trend" ? (
<section className="pf-analysis__chart" aria-label="회기별 추이">
<div className="pf-analysis__subhead">
@ -1197,6 +1396,12 @@ function LearnerSessionRow({
<small></small>
<b>{formatRapport(point?.rapport)}</b>
</span>
<span>
<small>AI </small>
<Badge tone={sessionSupervisorTone(sessionSupervisorLabel(session))}>
{sessionSupervisorLabel(session)}
</Badge>
</span>
<span>
<small></small>
<Badge tone={sessionReviewTone(session.review_status)}>

View file

@ -31,7 +31,10 @@ import {
personaApi,
sessionApi,
type CrisisResource,
type LiveCoachCreditEvent,
type LiveCoachEvent,
type LiveCoachHistoryResponse,
type LiveCoachQuota,
type LiveCoachSuggestion,
type PersonaSummary,
type SessionDetailResponse,
@ -70,6 +73,7 @@ type VoiceServerState = "idle" | "listening" | "thinking" | "speaking";
interface VoiceEvent {
type?: string;
code?: string;
state?: VoiceServerState;
text?: string;
final?: boolean;
@ -83,6 +87,22 @@ interface VoiceEvent {
conversation_stopped?: boolean;
}
const VOICE_TURN_SAVE_FAILED =
"음성 발화를 저장하지 못했습니다. 방금 말한 내용을 확인한 뒤 다시 시도해 주세요.";
const VOICE_CONNECTION_SAVE_FAILED =
"음성 연결이 종료되어 발화를 저장하지 못했습니다. 방금 말한 내용을 확인한 뒤 다시 시도해 주세요.";
function userFacingVoiceError(payload: VoiceEvent): string {
const detail = payload.detail ?? "";
if (
payload.code === "turn_persistence_unavailable" ||
(detail.includes("turn append") && detail.includes("persistence unavailable"))
) {
return VOICE_TURN_SAVE_FAILED;
}
return detail || "음성 처리 중 오류가 발생했습니다.";
}
type AudioContextWindow = Window & { webkitAudioContext?: typeof AudioContext };
type VoiceCaptureMode = "audio-worklet" | "media-recorder";
@ -137,6 +157,8 @@ interface Utterance {
at: number;
/** 진행 중(스트리밍/타이핑) 발화 여부 */
partial?: boolean;
/** 음성 WebSocket이 reply 전에 실패해 DB 턴으로 확정되지 않은 발화 */
failed?: boolean;
}
interface PhaseInfo {
@ -164,6 +186,7 @@ const THEORY_MODE_OPTIONS: { value: TheoryMode; label: string; detail: string }[
{ value: "cbt", label: "CBT", detail: "생각·행동" },
{ value: "integrative", label: "통합", detail: "혼합 접근" },
];
const DEFAULT_COACH_QUOTA: LiveCoachQuota = { remaining: 3, max: 3 };
const VOICE_WORKLET_MODULE_URL = "/worklets/voice-capture-worklet.js";
const VOICE_WORKLET_PROCESSOR = "voice-capture-processor";
@ -839,8 +862,15 @@ export default function Session() {
const [coachError, setCoachError] = useState<string | null>(null);
const [coachEvidenceOpen, setCoachEvidenceOpen] = useState(false);
const [coachHistory, setCoachHistory] = useState<LiveCoachEvent[]>([]);
const [coachQuota, setCoachQuota] = useState<LiveCoachQuota>(DEFAULT_COACH_QUOTA);
const [coachCreditEvents, setCoachCreditEvents] = useState<LiveCoachCreditEvent[]>([]);
const [coachCreditPulse, setCoachCreditPulse] = useState<LiveCoachCreditEvent | null>(null);
const [coachHistoryOpen, setCoachHistoryOpen] = useState(false);
const [coachHistoryTurnSeq, setCoachHistoryTurnSeq] = useState<number | null>(null);
const [coachHistoryLoading, setCoachHistoryLoading] = useState(false);
const [coachHistoryError, setCoachHistoryError] = useState<string | null>(null);
const [coachPersistenceSource, setCoachPersistenceSource] = useState<"database" | "runtime" | null>(null);
const [coachSyncWarning, setCoachSyncWarning] = useState<string | null>(null);
// ── 경과 타이머 ──
const [elapsed, setElapsed] = useState(0);
@ -860,6 +890,23 @@ export default function Session() {
const pendingVoiceLearnerTextRef = useRef<string>("");
const coachEvidenceCloseRef = useRef<HTMLButtonElement>(null);
const coachHistoryCloseRef = useRef<HTMLButtonElement>(null);
const coachCreditSeenRef = useRef<Set<string>>(new Set());
const coachCreditPulseTimerRef = useRef<number | null>(null);
const failPendingVoiceLearnerTurn = useCallback(() => {
const pendingId = pendingVoiceLearnerIdRef.current;
pendingVoiceLearnerIdRef.current = null;
pendingVoiceLearnerTextRef.current = "";
if (pendingId == null) return false;
setUtterances((prev) =>
prev.map((utterance) =>
utterance.id === pendingId
? { ...utterance, partial: false, failed: true, turnSeq: undefined }
: utterance,
),
);
return true;
}, []);
const ensureVoiceAudioContext = useCallback(() => {
const existing = audioContextRef.current;
@ -911,6 +958,12 @@ export default function Session() {
setCoachError(null);
setCoachEvidenceOpen(false);
setCoachHistory([]);
setCoachQuota(DEFAULT_COACH_QUOTA);
setCoachCreditEvents([]);
setCoachCreditPulse(null);
setCoachPersistenceSource(null);
setCoachSyncWarning(null);
coachCreditSeenRef.current = new Set();
setCoachHistoryOpen(false);
setCoachHistoryTurnSeq(null);
}, [navigate, routeIsSessionId, routeParam]);
@ -1001,6 +1054,48 @@ export default function Session() {
setSignalSeq((seq) => [...seq.slice(-4), tone]);
}, []);
const showCoachCreditPulse = useCallback(
(event: LiveCoachCreditEvent) => {
setCoachCreditPulse(event);
if (coachCreditPulseTimerRef.current) {
window.clearTimeout(coachCreditPulseTimerRef.current);
}
coachCreditPulseTimerRef.current = window.setTimeout(() => {
setCoachCreditPulse(null);
coachCreditPulseTimerRef.current = null;
}, 2600);
pushSignal(
event.event_type === "recharge" ? "pos" : "neutral",
event.event_type === "recharge" ? "코칭 기회 충전" : "코칭 기회 사용",
);
},
[pushSignal],
);
const applyCoachCreditEvents = useCallback(
(events: LiveCoachCreditEvent[], animate: boolean) => {
setCoachCreditEvents(events);
const fresh: LiveCoachCreditEvent[] = [];
for (const event of events) {
if (coachCreditSeenRef.current.has(event.event_id)) continue;
coachCreditSeenRef.current.add(event.event_id);
fresh.push(event);
}
if (animate && fresh.length > 0) {
showCoachCreditPulse(fresh[fresh.length - 1]);
}
},
[showCoachCreditPulse],
);
useEffect(() => {
return () => {
if (coachCreditPulseTimerRef.current) {
window.clearTimeout(coachCreditPulseTimerRef.current);
}
};
}, []);
const applyCrisisGate = useCallback((resource?: CrisisResource | null) => {
const fallback: CrisisResource = {
title: "자살예방상담전화 109",
@ -1019,29 +1114,61 @@ export default function Session() {
}, [pushSignal]);
const refreshCoachHistory = useCallback(
async (sessionId = liveSessionId) => {
async (
sessionId = liveSessionId,
options: { animateCredits?: boolean; surfaceErrors?: boolean; warnOnFailure?: boolean } = {},
): Promise<LiveCoachHistoryResponse | null> => {
if (options.surfaceErrors) {
setCoachHistoryLoading(true);
setCoachHistoryError(null);
}
if (!sessionId) {
setCoachHistory([]);
return;
setCoachQuota(DEFAULT_COACH_QUOTA);
setCoachCreditEvents([]);
setCoachHistoryError(null);
setCoachPersistenceSource(null);
setCoachSyncWarning(null);
if (options.surfaceErrors) setCoachHistoryLoading(false);
return null;
}
try {
const history = await sessionApi.liveCoachHistory(sessionId);
setCoachHistory(history.events ?? []);
setCoachQuota(history.quota ?? DEFAULT_COACH_QUOTA);
setCoachPersistenceSource(history.source);
setCoachSyncWarning(
history.source === "runtime"
? "코칭 이력이 DB에 확정 저장되지 않아 임시 이력으로 표시됩니다."
: null,
);
setCoachHistoryError(null);
applyCoachCreditEvents(history.credit_events ?? [], !!options.animateCredits);
return history;
} catch {
/* 코칭 이력 조회 실패는 회기 진행을 막지 않는다. */
if (options.surfaceErrors) {
setCoachHistoryError("코칭 이력을 불러오지 못했습니다. 잠시 뒤 다시 열어 주세요.");
}
if (options.warnOnFailure) {
setCoachSyncWarning("코칭은 표시됐지만 이력 동기화를 확인하지 못했습니다. 이력 보기에서 다시 확인해 주세요.");
}
return null;
} finally {
if (options.surfaceErrors) setCoachHistoryLoading(false);
}
},
[liveSessionId],
[applyCoachCreditEvents, liveSessionId],
);
const openCoachHistory = useCallback(
(turnSeq: number | null = null) => {
setCoachHistoryTurnSeq(turnSeq);
setCoachHistoryError(null);
setCoachEvidenceOpen(false);
setCoachHistoryOpen(true);
void refreshCoachHistory();
void refreshCoachHistory(liveSessionId, { surfaceErrors: true });
},
[refreshCoachHistory],
[liveSessionId, refreshCoachHistory],
);
const requestLiveCoach = useCallback(
@ -1055,6 +1182,25 @@ export default function Session() {
turnSeq?: number;
}) => {
if (!liveSessionId || feedbackMode !== "coached") return;
let availableCoachCredits = coachQuota.remaining ?? 0;
if (availableCoachCredits <= 0) {
setCoachError(null);
const refreshed = await refreshCoachHistory(liveSessionId, {
animateCredits: true,
warnOnFailure: true,
});
if (!refreshed) {
setCoachError("코칭 기회를 확인하지 못했습니다. 잠시 뒤 다시 시도해 주세요.");
pushSignal("warn", "코칭 기회 확인 실패");
return;
}
availableCoachCredits = refreshed.quota?.remaining ?? 0;
}
if (availableCoachCredits <= 0) {
setCoachError("코칭 기회를 모두 사용했습니다. 좋은 발화로 내담자의 변화 신호가 생기면 1개씩 다시 충전됩니다.");
pushSignal("warn", "코칭 기회 소진");
return;
}
setCoachLoading(true);
setCoachError(null);
try {
@ -1064,16 +1210,41 @@ export default function Session() {
turn_seq: turnSeq,
});
setCoachSuggestion(suggestion);
setCoachPersistenceSource(suggestion.persistence_source ?? null);
setCoachSyncWarning(
suggestion.persistence_source === "runtime"
? "코칭 이력이 DB에 확정 저장되지 않아 임시 이력으로 표시됩니다."
: null,
);
if (suggestion.quota) {
setCoachQuota(suggestion.quota);
}
if (suggestion.credit_events?.length) {
applyCoachCreditEvents(suggestion.credit_events, true);
}
pushSignal(suggestion.tone, suggestion.title);
void refreshCoachHistory();
} catch {
setCoachError("코칭 근거를 불러오지 못했습니다. 다음 턴에서 다시 시도합니다.");
pushSignal("warn", "코칭 연결 실패");
void refreshCoachHistory(liveSessionId, { animateCredits: false, warnOnFailure: true });
} catch (err) {
if (err instanceof ApiError && err.status === 409) {
setCoachQuota((current) => ({ ...current, remaining: 0 }));
setCoachError("코칭 기회를 모두 사용했습니다. 좋은 발화가 실제 변화로 이어지면 다시 충전됩니다.");
pushSignal("warn", "코칭 기회 소진");
} else {
setCoachError("코칭 근거를 불러오지 못했습니다. 다음 턴에서 다시 시도합니다.");
pushSignal("warn", "코칭 연결 실패");
}
} finally {
setCoachLoading(false);
}
},
[feedbackMode, liveSessionId, pushSignal, refreshCoachHistory],
[
applyCoachCreditEvents,
coachQuota.remaining,
feedbackMode,
liveSessionId,
pushSignal,
refreshCoachHistory,
],
);
useEffect(() => {
@ -1125,6 +1296,13 @@ export default function Session() {
setCoachSuggestion(null);
setCoachError(null);
setCoachEvidenceOpen(false);
setCoachHistory([]);
setCoachQuota(DEFAULT_COACH_QUOTA);
setCoachCreditEvents([]);
setCoachCreditPulse(null);
setCoachPersistenceSource(null);
setCoachSyncWarning(null);
coachCreditSeenRef.current = new Set();
setCoachHistoryOpen(false);
setCoachHistoryTurnSeq(null);
setClientReplyPending(false);
@ -1171,6 +1349,12 @@ export default function Session() {
setCoachError(null);
setCoachEvidenceOpen(false);
setCoachHistory([]);
setCoachQuota(DEFAULT_COACH_QUOTA);
setCoachCreditEvents([]);
setCoachCreditPulse(null);
setCoachPersistenceSource(null);
setCoachSyncWarning(null);
coachCreditSeenRef.current = new Set();
setCoachHistoryOpen(false);
setCoachHistoryTurnSeq(null);
if (res.degraded) {
@ -1684,6 +1868,7 @@ export default function Session() {
}
if (payload.type === "transcript" && payload.final && payload.text) {
failPendingVoiceLearnerTurn();
const id = nextId();
pendingVoiceLearnerIdRef.current = id;
pendingVoiceLearnerTextRef.current = payload.text;
@ -1712,7 +1897,9 @@ export default function Session() {
if (pendingId != null) {
setUtterances((prev) =>
prev.map((u) =>
u.id === pendingId ? { ...u, partial: false, turnSeq: payload.turn_seq } : u,
u.id === pendingId
? { ...u, partial: false, failed: false, turnSeq: payload.turn_seq }
: u,
),
);
pendingVoiceLearnerIdRef.current = null;
@ -1729,6 +1916,9 @@ export default function Session() {
turnSeq: payload.turn_seq,
});
}
if (conversationStopped) {
closeVoiceSocket();
}
return;
}
@ -1739,9 +1929,11 @@ export default function Session() {
if (payload.type === "degraded") {
setClientReplyPending(false);
const hadPendingLearner = failPendingVoiceLearnerTurn();
const fallbackReason = "음성 설정이 완료되지 않아 지금은 텍스트 입력으로 진행합니다.";
const reason =
payload.reason && !payload.reason.includes("OPENAI") ? payload.reason : fallbackReason;
if (hadPendingLearner) setTurnError(fallbackReason);
shutdownVoice("degraded", reason);
pushSignal("warn", "음성 기능 미설정");
return;
@ -1749,24 +1941,24 @@ export default function Session() {
if (payload.type === "error") {
setClientReplyPending(false);
if (pendingVoiceLearnerIdRef.current != null && payload.detail?.includes("engine unavailable")) {
const pendingId = pendingVoiceLearnerIdRef.current;
setUtterances((prev) => prev.filter((u) => u.id !== pendingId));
pendingVoiceLearnerIdRef.current = null;
pendingVoiceLearnerTextRef.current = "";
}
shutdownVoice("error", payload.detail || "음성 처리 중 오류가 발생했습니다.");
failPendingVoiceLearnerTurn();
const detail = userFacingVoiceError(payload);
setTurnError(detail);
shutdownVoice("error", detail);
pushSignal("warn", "음성 오류");
}
};
ws.onerror = () => {
setClientReplyPending(false);
shutdownVoice("error", "음성 연결을 확인하지 못했습니다.");
failPendingVoiceLearnerTurn();
setTurnError(VOICE_CONNECTION_SAVE_FAILED);
shutdownVoice("error", VOICE_CONNECTION_SAVE_FAILED);
pushSignal("warn", "음성 연결 실패");
};
ws.onclose = () => {
const hadPendingLearner = failPendingVoiceLearnerTurn();
const capture = voiceCaptureRef.current;
voiceCaptureRef.current = null;
capture?.abort();
@ -1776,6 +1968,13 @@ export default function Session() {
}
stopMicStream();
setMicOn(false);
setClientReplyPending(false);
if (hadPendingLearner) {
setTurnError(VOICE_CONNECTION_SAVE_FAILED);
setVoiceStatus("error");
setVoiceDetail(VOICE_CONNECTION_SAVE_FAILED);
return;
}
setVoiceStatus((current) => (current === "degraded" || current === "error" ? current : "idle"));
setVoiceDetail((current) => {
if (
@ -1794,6 +1993,7 @@ export default function Session() {
closeVoiceSocket,
elapsed,
ensureVoiceAudioContext,
failPendingVoiceLearnerTurn,
liveSessionId,
paused,
sessionEnded,
@ -2019,7 +2219,7 @@ export default function Session() {
const elapsedLabel = formatElapsed(elapsed);
const recommendedSessionSeconds = 30 * 60;
const remainingLabel = formatElapsed(Math.max(0, recommendedSessionSeconds - elapsed));
const turnCount = utterances.filter((utterance) => !utterance.partial).length;
const turnCount = utterances.filter((utterance) => !utterance.partial && !utterance.failed).length;
const latestClientUtterance = [...utterances]
.reverse()
.find((utterance) => utterance.speaker === "client" && utterance.text.trim());
@ -2051,10 +2251,25 @@ export default function Session() {
: null;
const coachSources = coachSuggestion?.sources ?? [];
const coachTone = coachSuggestion?.tone ?? "neutral";
const coachQuotaMax = Math.max(1, coachQuota.max ?? DEFAULT_COACH_QUOTA.max);
const coachQuotaRemaining = Math.max(
0,
Math.min(coachQuotaMax, coachQuota.remaining ?? DEFAULT_COACH_QUOTA.remaining),
);
const coachQuotaSlots = Array.from({ length: coachQuotaMax }, (_, index) => index);
const coachCreditPulseText =
coachCreditPulse == null
? null
: coachCreditPulse.event_type === "recharge"
? `+1 충전 · ${coachCreditPulse.balance}/${coachQuotaMax}`
: `-1 사용 · ${coachCreditPulse.balance}/${coachQuotaMax}`;
const coachIsDegraded = coachSuggestion?.status === "degraded";
const coachDegradedNote =
"AI 코칭 엔진 응답 대신 워크북 규칙과 현재 턴 신호로 만든 대체 제안입니다.";
const coachStatusText = coachLoading
? "코치가 근거를 확인 중"
: coachSuggestion?.status === "degraded"
? "규칙 기반 코칭"
: coachIsDegraded
? "AI 응답 대체"
: coachSuggestion
? "근거 확인 완료"
: "턴 완료 후 개입";
@ -2525,7 +2740,8 @@ export default function Session() {
className={
"sx-utt " +
(u.speaker === "client" ? "is-client" : "is-learner") +
(u.partial ? " is-partial" : "")
(u.partial ? " is-partial" : "") +
(u.failed ? " is-failed" : "")
}
>
<div className="sx-utt__head">
@ -2539,6 +2755,11 @@ export default function Session() {
{u.text}
{u.partial ? <span className="sx-utt__caret" /> : null}
</div>
{u.failed ? (
<span className="sx-utt__status" role="note">
· .
</span>
) : null}
{turnCoachEvents.length && u.turnSeq ? (
<button
type="button"
@ -2548,9 +2769,14 @@ export default function Session() {
? " is-pos"
: latestCoach?.suggestion.tone === "warn"
? " is-warn"
: "")
: "") +
(latestCoach?.suggestion.status === "degraded" ? " is-degraded" : "")
}
title={
latestCoach?.suggestion.status === "degraded"
? `AI 응답 대체: ${latestCoach.suggestion.title}`
: latestCoach?.suggestion.title ?? "코칭 이력"
}
title={latestCoach?.suggestion.title ?? "코칭 이력"}
onClick={() => openCoachHistory(u.turnSeq ?? null)}
>
C
@ -2739,7 +2965,8 @@ export default function Session() {
className={
"sx-coach-card" +
(coachTone === "pos" ? " is-pos" : coachTone === "warn" ? " is-warn" : "") +
(coachLoading ? " is-loading" : "")
(coachLoading ? " is-loading" : "") +
(coachIsDegraded ? " is-degraded" : "")
}
>
<div className="sx-coach-avatar" aria-hidden="true">
@ -2751,11 +2978,52 @@ export default function Session() {
</div>
<div className="sx-coach-bubble">
<div className="sx-coach-bubble__meta">
<b>AI </b>
<b>{coachIsDegraded ? "대체 코칭" : "AI 코치"}</b>
<span>{coachStatusText}</span>
</div>
{coachIsDegraded ? (
<p className="sx-coach-degraded-note">{coachDegradedNote}</p>
) : null}
{coachSyncWarning ? (
<p
className={
"sx-coach-persistence-note" +
(coachPersistenceSource === "runtime" ? " is-runtime" : "")
}
role="status"
>
{coachSyncWarning}
</p>
) : null}
<div className="sx-coach-quota" aria-live="polite">
<span> </span>
<span className="sx-coach-quota__dots" aria-hidden="true">
{coachQuotaSlots.map((slot) => (
<i
key={slot}
className={slot < coachQuotaRemaining ? "is-filled" : ""}
/>
))}
</span>
<b>
{coachQuotaRemaining}/{coachQuotaMax}
</b>
</div>
{coachCreditPulseText ? (
<div
className={
"sx-coach-credit-pulse" +
(coachCreditPulse?.event_type === "recharge" ? " is-recharge" : "")
}
aria-live="polite"
>
{coachCreditPulseText}
</div>
) : null}
{coachLoading ? (
<p> .</p>
) : coachError ? (
<p>{coachError}</p>
) : coachSuggestion ? (
<>
<strong>{coachSuggestion.title}</strong>
@ -2772,8 +3040,8 @@ export default function Session() {
</button>
</div>
</>
) : coachError ? (
<p>{coachError}</p>
) : coachQuotaRemaining <= 0 ? (
<p> . 1 .</p>
) : (
<p> .</p>
)}
@ -2928,8 +3196,17 @@ export default function Session() {
<button
className={feedbackMode === "coached" ? "is-on" : ""}
onClick={() => setFeedbackMode("coached")}
aria-label={`코칭 모드, 남은 기회 ${coachQuotaRemaining}`}
>
<span></span>
<span
className={
"sx-segmented__badge" + (coachQuotaRemaining <= 0 ? " is-empty" : "")
}
aria-hidden="true"
>
{coachQuotaRemaining}
</span>
</button>
</div>
</div>
@ -2992,6 +3269,24 @@ export default function Session() {
<p id="sx-coach-history-desc">
.
</p>
{coachPersistenceSource === "runtime" ? (
<div className="sx-coach-history__source-alert" role="status">
.
</div>
) : null}
{coachCreditEvents.length > 0 ? (
<div className="sx-coach-credit-log" aria-label="코칭 기회 기록">
{coachCreditEvents.slice(-4).map((event) => (
<span
key={event.event_id}
className={event.event_type === "recharge" ? "is-recharge" : ""}
>
{event.event_type === "recharge" ? "+1 충전" : "-1 사용"} ·{" "}
{event.balance}/{coachQuotaMax}
</span>
))}
</div>
) : null}
</div>
<button
ref={coachHistoryCloseRef}
@ -3004,7 +3299,15 @@ export default function Session() {
</div>
<div className="sx-coach-history__body">
{activeCoachEvents.length ? (
{coachHistoryLoading ? (
<div className="sx-coach-history__empty">
.
</div>
) : coachHistoryError ? (
<div className="sx-coach-history__empty is-error" role="alert">
{coachHistoryError}
</div>
) : activeCoachEvents.length ? (
activeCoachEvents.map((event) => (
<article
key={event.event_id}
@ -3014,11 +3317,15 @@ export default function Session() {
? " is-pos"
: event.suggestion.tone === "warn"
? " is-warn"
: "")
: "") +
(event.suggestion.status === "degraded" ? " is-degraded" : "")
}
>
<div className="sx-coach-history__item-top">
<span>{event.turn_seq} · {event.stage ?? "단계 미상"}</span>
{event.suggestion.status === "degraded" ? (
<b className="sx-coach-degraded-badge">AI </b>
) : null}
<time dateTime={event.created_at}>
{formatCoachTimestamp(event.created_at)}
</time>
@ -3088,7 +3395,11 @@ export default function Session() {
</span>
<div>
<h2 id="sx-coach-modal-title">{coachSuggestion.title}</h2>
<p id="sx-coach-modal-desc"> .</p>
<p id="sx-coach-modal-desc">
{coachIsDegraded
? coachDegradedNote
: "방금 코칭 판단에 사용한 근거와 다음 발화 제안입니다."}
</p>
</div>
</div>

View file

@ -42,7 +42,7 @@ 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 REVIEW_READY_POLL_LIMIT = 60;
const PREPOST_MEASURES: Array<{ key: PrepostMeasureName; label: string; hint: string }> = [
{ key: "self_efficacy", label: "자기효능감", hint: "상담 수행 자신감" },
@ -540,26 +540,32 @@ function PrepostMeasureCard() {
};
}, []);
const dirtyKeys = useMemo(
const changedKeys = useMemo(
() =>
Object.keys(draft).filter((key) => {
const split = splitPrepostDraftKey(key);
if (!split) return false;
const value = draft[key]?.trim() ?? "";
return value !== (baseline[key] ?? "") && parsePrepostScore(value) !== null;
return value !== (baseline[key] ?? "");
}),
[baseline, draft],
);
const dirtyKeys = useMemo(
() => changedKeys.filter((key) => parsePrepostScore(draft[key]?.trim() ?? "") !== null),
[changedKeys, draft],
);
const invalidKeys = useMemo(
() =>
Object.keys(draft).filter((key) => {
const split = splitPrepostDraftKey(key);
if (!split) return false;
const value = draft[key]?.trim() ?? "";
return value.length > 0 && parsePrepostScore(value) === null;
const hadSavedValue = (baseline[key] ?? "").trim().length > 0;
return (value.length === 0 && hadSavedValue) || (value.length > 0 && parsePrepostScore(value) === null);
}),
[draft],
[baseline, draft],
);
const saveDraft = async () => {
@ -657,7 +663,7 @@ function PrepostMeasureCard() {
</div>
{invalidKeys.length > 0 ? (
<p className="sr-prepost__error" role="alert">
1 5 .
1 5 . .
</p>
) : error ? (
<p className="sr-prepost__error" role="alert">
@ -668,9 +674,11 @@ function PrepostMeasureCard() {
<span>
{loading
? "불러오는 중"
: dirtyKeys.length > 0
? `${dirtyKeys.length}개 변경`
: "저장된 값 기준"}
: invalidKeys.length > 0
? "저장되지 않은 입력 있음"
: dirtyKeys.length > 0
? `${dirtyKeys.length}개 변경`
: "저장된 값 기준"}
</span>
<Button
variant="secondary"
@ -973,9 +981,14 @@ export default function SessionReview() {
const worksheetReviewStatus = (teacherReview?.worksheetStatus ?? "pending") as WorksheetReviewStatus;
const worksheetReviewLabel = worksheetReviewStatusLabel(worksheetReviewStatus);
const hasLearnerWorksheet = data.caseWorksheet?.status === "saved_by_learner";
const canCloseTeacherReview = isSupervisorView && data.sessionSignal === "종료됨";
const canDecideWorksheet = canCloseTeacherReview && hasLearnerWorksheet;
const canRetryEvaluation = canCloseTeacherReview && hasTranscript && !data.reviewReady;
const canReviewEndedSession = isSupervisorView && data.sessionSignal === "종료됨";
const canCloseTeacherReview = canReviewEndedSession && data.reviewReady;
const canDecideWorksheet = canReviewEndedSession && hasLearnerWorksheet;
const canRetryEvaluation =
canReviewEndedSession &&
hasTranscript &&
!data.reviewReady &&
data.supervisorState === "평가 실패";
const reviewReadiness = hasTranscript
? `${turns.length}개 발화 기반`
: "축어록 저장 후 생성";
@ -1282,7 +1295,7 @@ export default function SessionReview() {
<p className="sr-teacher-review__meta">
{teacherReview.reviewedAt}
</p>
) : canCloseTeacherReview ? (
) : canReviewEndedSession ? (
<p className="sr-teacher-review__meta">
.
</p>

View file

@ -820,6 +820,7 @@
}
.pf-analysis__chart,
.pf-analysis__stages,
.pf-analysis__personas,
.pf-analysis__timeline{
min-width:0;
border:1px solid var(--paper-2);
@ -877,6 +878,135 @@
font-size:var(--fs-xs);
font-weight:700;
}
.pf-persona-drill{
display:flex;
flex-direction:column;
max-height:min(580px,62vh);
overflow:auto;
scrollbar-gutter:stable;
}
.pf-persona-group{
min-width:0;
border-top:1px solid var(--paper-2);
}
.pf-persona-group:first-child{
border-top:0;
}
.pf-persona-group__summary{
width:100%;
min-width:0;
display:grid;
grid-template-columns:44px minmax(160px,1fr) minmax(260px,.92fr) auto;
gap:10px;
align-items:center;
padding:12px;
border:0;
background:transparent;
color:inherit;
font:inherit;
text-align:left;
cursor:pointer;
}
.pf-persona-group__summary:hover,
.pf-persona-group.is-open .pf-persona-group__summary{
background:var(--bg-surface-2);
}
.pf-persona-group__summary:focus-visible{
outline:2px solid var(--accent);
outline-offset:-2px;
}
.pf-persona-group__code{
width:38px;
height:34px;
display:grid;
place-items:center;
border-radius:var(--radius-sm);
color:var(--accent-deep);
background:var(--accent-tint);
font-family:var(--font-num);
font-size:12px;
font-weight:830;
}
.pf-persona-group__main{
min-width:0;
display:grid;
gap:4px;
}
.pf-persona-group__main b{
color:var(--text-strong);
font-size:var(--fs-sm);
line-height:1.35;
overflow:hidden;
text-overflow:ellipsis;
white-space:nowrap;
}
.pf-persona-group__main small{
color:var(--text-muted);
font-size:12px;
line-height:1.35;
overflow:hidden;
text-overflow:ellipsis;
white-space:nowrap;
}
.pf-persona-group__metrics{
min-width:0;
display:grid;
grid-template-columns:repeat(4,minmax(58px,1fr));
gap:7px;
}
.pf-persona-group__metrics span{
min-width:0;
display:grid;
gap:3px;
}
.pf-persona-group__metrics small{
color:var(--text-muted);
font-size:11px;
line-height:1.2;
}
.pf-persona-group__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-persona-group__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-persona-group__summary:hover .pf-persona-group__open{
border-color:var(--accent);
}
.pf-persona-sessions{
margin:0 12px 12px 56px;
border:1px solid var(--paper-2);
border-radius:var(--radius-sm);
overflow:hidden;
background:var(--bg-surface);
}
.pf-persona-sessions .pf-timeline-row{
grid-template-columns:34px minmax(150px,1fr) minmax(200px,.82fr) minmax(104px,.42fr) auto;
padding:10px;
}
.pf-persona-sessions .pf-timeline-row__step{
width:28px;
height:28px;
}
.pf-timeline{
max-height:min(520px,58vh);
overflow:auto;
@ -1378,6 +1508,16 @@
.pf-analysis__metrics span:nth-child(n+4){
border-top:1px solid var(--hair);
}
.pf-persona-group__summary{
grid-template-columns:44px minmax(0,1fr) auto;
}
.pf-persona-group__metrics{
grid-column:2 / 4;
grid-row:2;
}
.pf-persona-sessions .pf-timeline-row{
grid-template-columns:34px minmax(0,1fr) minmax(190px,.85fr) auto;
}
.pf-timeline-row{
grid-template-columns:34px minmax(0,1fr) minmax(190px,.85fr) auto;
}
@ -1461,6 +1601,31 @@
.pf-stage-item:nth-child(n+3){
border-top:1px solid var(--hair);
}
.pf-persona-drill{
max-height:560px;
}
.pf-persona-group__summary{
grid-template-columns:38px minmax(0,1fr);
align-items:start;
}
.pf-persona-group__metrics,
.pf-persona-group__open{
grid-column:2;
}
.pf-persona-group__metrics{
grid-row:auto;
grid-template-columns:repeat(4,minmax(0,1fr));
}
.pf-persona-group__open{
justify-self:start;
}
.pf-persona-sessions{
margin:0 10px 10px 48px;
}
.pf-persona-sessions .pf-timeline-row{
grid-template-columns:34px minmax(0,1fr);
align-items:start;
}
.pf-timeline{
max-height:560px;
}
@ -1607,6 +1772,28 @@
.pf-stage-item:nth-child(n+2){
border-top:1px solid var(--hair);
}
.pf-persona-group__summary{
grid-template-columns:1fr;
}
.pf-persona-group__code{
width:auto;
min-width:38px;
justify-self:start;
padding:0 10px;
}
.pf-persona-group__metrics,
.pf-persona-group__open{
grid-column:1;
}
.pf-persona-group__metrics{
grid-template-columns:repeat(2,minmax(0,1fr));
}
.pf-persona-sessions{
margin:0 8px 8px;
}
.pf-persona-sessions .pf-timeline-row__metrics{
grid-template-columns:1fr;
}
.pf-timeline-row__metrics{
grid-template-columns:1fr;
}

View file

@ -1030,6 +1030,11 @@
border-color: color-mix(in srgb, var(--warn-solid) 48%, transparent);
color: var(--warn-text);
}
.sx-utt__coach-mark.is-degraded {
border-color: color-mix(in srgb, var(--clay-deep) 55%, transparent);
background: color-mix(in srgb, var(--clay-tint) 68%, var(--paper));
color: var(--clay-deep);
}
.sx-utt__coach-mark:hover {
transform: translateY(-1px);
box-shadow: 0 8px 18px rgba(35, 42, 48, 0.16);
@ -1052,6 +1057,25 @@
.sx-utt.is-partial .sx-utt__line {
color: var(--text-muted);
}
.sx-utt.is-failed .sx-utt__line {
background: color-mix(in srgb, var(--warn-tint) 74%, var(--paper));
border: 1px dashed color-mix(in srgb, var(--warn-solid) 46%, transparent);
color: var(--text-strong);
}
.sx-utt__status {
align-self: center;
display: inline-flex;
flex: 0 0 auto;
white-space: nowrap;
border-radius: 999px;
border: 1px solid color-mix(in srgb, var(--warn-solid) 34%, transparent);
background: color-mix(in srgb, var(--warn-tint) 72%, var(--paper));
color: var(--warn-text);
font-size: 11px;
font-weight: 700;
line-height: 1;
padding: 4px 7px;
}
.sx-utt.is-thinking .sx-utt__line {
display: inline-flex;
align-items: center;
@ -1427,6 +1451,19 @@
.sx-coach-card.is-warn .sx-coach-bubble {
border-color: color-mix(in srgb, var(--warn-solid) 42%, transparent);
}
.sx-coach-card.is-degraded .sx-coach-avatar {
background: color-mix(in srgb, var(--clay-tint) 76%, var(--paper-2));
border-color: color-mix(in srgb, var(--clay-deep) 40%, transparent);
}
.sx-coach-card.is-degraded .sx-coach-bubble {
border-color: color-mix(in srgb, var(--clay-deep) 42%, transparent);
background: color-mix(in srgb, var(--clay-tint) 22%, var(--paper-2));
}
.sx-coach-card.is-degraded .sx-coach-bubble::before {
background: color-mix(in srgb, var(--clay-tint) 22%, var(--paper-2));
border-left-color: color-mix(in srgb, var(--clay-deep) 42%, transparent);
border-bottom-color: color-mix(in srgb, var(--clay-deep) 42%, transparent);
}
.sx-coach-bubble__meta {
display: flex;
align-items: center;
@ -1439,6 +1476,78 @@
color: var(--text-strong);
font-size: 12px;
}
.sx-coach-degraded-note {
padding: 8px 9px;
border-radius: 7px;
border: 1px solid color-mix(in srgb, var(--clay-deep) 30%, transparent);
background: color-mix(in srgb, var(--clay-tint) 38%, transparent);
color: var(--clay-deep) !important;
font-size: 11.5px !important;
line-height: 1.45 !important;
}
.sx-coach-persistence-note {
padding: 8px 9px;
border-radius: 7px;
border: 1px solid color-mix(in srgb, var(--warn-solid) 30%, transparent);
background: color-mix(in srgb, var(--warn-tint) 58%, transparent);
color: var(--warn-text) !important;
font-size: 11.5px !important;
font-weight: 700;
line-height: 1.45 !important;
}
.sx-coach-quota {
min-height: 26px;
display: grid;
grid-template-columns: minmax(0, 1fr) auto auto;
align-items: center;
gap: 8px;
padding: 6px 8px;
border-radius: 7px;
background: color-mix(in srgb, var(--paper) 72%, var(--accent-tint));
color: var(--text-muted);
font-size: 11px;
}
.sx-coach-quota b {
color: var(--accent-deep);
font-family: var(--font-num);
font-size: 11.5px;
font-weight: 800;
font-variant-numeric: tabular-nums;
}
.sx-coach-quota__dots {
display: inline-flex;
align-items: center;
gap: 4px;
}
.sx-coach-quota__dots i {
width: 8px;
height: 8px;
border-radius: 50%;
background: color-mix(in srgb, var(--text-muted) 24%, transparent);
box-shadow: inset 0 0 0 1px color-mix(in srgb, var(--text-muted) 18%, transparent);
}
.sx-coach-quota__dots i.is-filled {
background: var(--accent-deep);
box-shadow: 0 0 0 3px var(--accent-tint);
}
.sx-coach-credit-pulse {
justify-self: start;
min-height: 24px;
display: inline-flex;
align-items: center;
padding: 5px 8px;
border-radius: 999px;
background: var(--paper);
color: var(--text-body);
border: 1px solid color-mix(in srgb, var(--text-muted) 22%, transparent);
font: 760 11px/1 var(--font-num);
animation: sxCoachCreditPulse 900ms var(--ease-out) both;
}
.sx-coach-credit-pulse.is-recharge {
color: var(--pos-solid);
border-color: color-mix(in srgb, var(--pos-solid) 34%, transparent);
background: color-mix(in srgb, var(--pos-solid) 11%, var(--paper));
}
.sx-coach-bubble strong {
display: block;
color: var(--text-strong);
@ -1494,6 +1603,20 @@
opacity: 0.9;
}
}
@keyframes sxCoachCreditPulse {
0% {
opacity: 0;
transform: translateY(4px) scale(0.96);
}
35% {
opacity: 1;
transform: translateY(0) scale(1.03);
}
100% {
opacity: 1;
transform: translateY(0) scale(1);
}
}
.sx-coach-history {
position: fixed;
inset: 0;
@ -1535,6 +1658,26 @@
font-size: 12.5px;
line-height: 1.45;
}
.sx-coach-credit-log {
display: flex;
flex-wrap: wrap;
gap: 6px;
margin-top: 9px;
}
.sx-coach-credit-log span {
display: inline-flex;
align-items: center;
min-height: 24px;
padding: 4px 8px;
border-radius: 999px;
background: var(--paper-2);
color: var(--text-muted);
font: 720 11px/1 var(--font-num);
}
.sx-coach-credit-log span.is-recharge {
background: color-mix(in srgb, var(--pos-solid) 12%, var(--paper));
color: var(--pos-solid);
}
.sx-coach-history__head button {
width: 34px;
height: 34px;
@ -1568,6 +1711,10 @@
.sx-coach-history__item.is-warn {
border-color: color-mix(in srgb, var(--warn-solid) 40%, transparent);
}
.sx-coach-history__item.is-degraded {
border-color: color-mix(in srgb, var(--clay-deep) 42%, transparent);
background: color-mix(in srgb, var(--clay-tint) 18%, var(--paper));
}
.sx-coach-history__item-top {
display: flex;
align-items: baseline;
@ -1576,6 +1723,18 @@
color: var(--text-muted);
font-size: 11.5px;
}
.sx-coach-degraded-badge {
display: inline-flex;
align-items: center;
min-height: 22px;
padding: 4px 7px;
border-radius: 999px;
background: color-mix(in srgb, var(--clay-tint) 70%, transparent);
color: var(--clay-deep);
font-size: 11px;
line-height: 1;
white-space: nowrap;
}
.sx-coach-history__item-top time {
font-family: var(--font-num);
font-variant-numeric: tabular-nums;
@ -1623,6 +1782,23 @@
font-size: 13px;
text-align: center;
}
.sx-coach-history__empty.is-error {
border: 1px solid color-mix(in srgb, var(--warn-solid) 34%, transparent);
background: color-mix(in srgb, var(--warn-tint) 68%, var(--paper));
color: var(--warn-text);
font-weight: 700;
}
.sx-coach-history__source-alert {
margin-top: 8px;
display: inline-flex;
padding: 6px 8px;
border-radius: 7px;
border: 1px solid color-mix(in srgb, var(--warn-solid) 28%, transparent);
background: color-mix(in srgb, var(--warn-tint) 54%, var(--paper));
color: var(--warn-text);
font-size: 12px;
font-weight: 700;
}
.sx-coach-source-list--compact {
margin-top: 0 !important;
}
@ -2124,6 +2300,10 @@
padding: 7px 12px;
border-radius: 6px;
cursor: pointer;
display: inline-flex;
align-items: center;
justify-content: center;
gap: 6px;
transition:
background var(--dur-fast) var(--ease-out),
color var(--dur-fast) var(--ease-out);
@ -2138,6 +2318,28 @@
font-weight: 600;
box-shadow: var(--shadow-sm);
}
.sx-segmented__badge {
min-width: 18px;
height: 18px;
display: inline-grid;
place-items: center;
border-radius: 999px;
background: var(--accent-deep);
color: #fff;
font: 800 10.5px/1 var(--font-num);
font-variant-numeric: tabular-nums;
box-shadow: 0 0 0 2px var(--accent-tint);
}
.sx-segmented button:not(.is-on) .sx-segmented__badge {
background: color-mix(in srgb, var(--accent-deep) 72%, var(--text-muted));
box-shadow: none;
}
.sx-segmented__badge.is-empty,
.sx-segmented button:not(.is-on) .sx-segmented__badge.is-empty {
background: color-mix(in srgb, var(--text-muted) 38%, transparent);
color: var(--text-muted);
box-shadow: inset 0 0 0 1px color-mix(in srgb, var(--text-muted) 25%, transparent);
}
.sx-cb-spacer {
flex: 1;
@ -3722,6 +3924,8 @@
.sx-mic.is-on::after,
.sx-utt__caret,
.sx-utt__dots i,
.sx-coach-credit-pulse,
.sx-coach-card.is-loading .sx-coach-avatar__lens,
.sx-transcript__live-dot.is-live {
animation: none !important;
}