세션 평가·라이브코치·교수자 분석 라운드 마감 + 문서 정리 + 코드품질 리팩터
- 누적 작업트리 커밋: 회기 평가 복구·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:
parent
7c41c3ce79
commit
778e8526d4
108 changed files with 6457 additions and 455 deletions
|
|
@ -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>
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue