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

- 누적 작업트리 커밋: 회기 평가 복구·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

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