현재 작업 전체 반영

This commit is contained in:
Yun Chan 2026-06-27 16:08:41 +09:00
parent 5560638e54
commit c0dddab594
85 changed files with 11322 additions and 539 deletions

View file

@ -18,18 +18,19 @@ from fastapi import APIRouter, HTTPException, status
from pydantic import BaseModel, Field
from sse_starlette.sse import EventSourceResponse
from .. import db, session_persistence
from .. import db, session_persistence, turn_runtime
from ..config import settings
from ..deps import CurrentPrincipal, Principal, Role
from ..engine_client import EngineError, engine_client
from ..persona_repository import get_catalog_persona
from ..runtime_policy import require_runtime_fallback_allowed, runtime_fallback_allowed
from ..runtime_policy import require_runtime_fallback_allowed
from ..services import evaluator, memory, orchestrator, rag, state_machine
from ..store import InProcSession, TurnRecord, store
router = APIRouter(prefix="/sessions", tags=["sessions"])
TheoryMode = Literal["humanistic", "cbt", "integrative"]
EndStateValue = str | int | float | bool | None | dict[str, float]
class SessionStartRequest(BaseModel):
@ -51,6 +52,12 @@ class TurnRequest(BaseModel):
text: str = Field(..., min_length=1)
class CrisisResourceResponse(BaseModel):
title: str
number: str
message: str
class TurnResponse(BaseModel):
turn_seq: int
stage: str
@ -58,13 +65,15 @@ class TurnResponse(BaseModel):
client_reply: Optional[str] = None
safety_flagged: bool = False
crisis_kind: str = "none"
crisis_resource: Optional[CrisisResourceResponse] = None
conversation_stopped: bool = False
class SessionEndResponse(BaseModel):
session_id: str
session_no: int
digest_pending: bool
end_state: dict
end_state: dict[str, EndStateValue]
class LearnerSessionSummary(BaseModel):
@ -121,6 +130,12 @@ class ReviewTechnique(BaseModel):
label: str
class ReviewNonverbalEvent(BaseModel):
kind: Literal["audio", "silence", "pace", "barge_in"]
label: str
detail: str
class ReviewNote(BaseModel):
author: str
tone: str
@ -136,6 +151,7 @@ class ReviewTurn(BaseModel):
who: str
text: str
techniques: list[ReviewTechnique] = Field(default_factory=list)
nonverbal: list[ReviewNonverbalEvent] = Field(default_factory=list)
note: Optional[ReviewNote] = None
@ -164,6 +180,34 @@ class ReviewPoint(BaseModel):
jumpTo: Optional[str] = None
class ReviewWorksheetEvidence(BaseModel):
turnId: str
speaker: Literal["learner", "client"]
quote: str
class ReviewWorksheetItem(BaseModel):
key: str
label: str
value: Optional[str] = None
evidence: list[ReviewWorksheetEvidence] = Field(default_factory=list)
confidence: Literal["none", "low", "medium"] = "none"
emptyReason: Optional[str] = None
class ReviewWorksheetSection(BaseModel):
key: str
title: str
items: list[ReviewWorksheetItem] = Field(default_factory=list)
class ReviewCaseWorksheet(BaseModel):
status: Literal["empty", "draft_from_transcript"] = "empty"
generatedBy: str = "rule-based transcript extractor"
sections: list[ReviewWorksheetSection] = Field(default_factory=list)
limitations: list[str] = Field(default_factory=list)
class SessionReviewResponse(BaseModel):
session_id: str
client: ReviewClient
@ -184,6 +228,7 @@ class SessionReviewResponse(BaseModel):
rubric: list[ReviewRubricRow] = Field(default_factory=list)
goodMoments: list[ReviewPoint] = Field(default_factory=list)
growthPoints: list[ReviewPoint] = Field(default_factory=list)
caseWorksheet: ReviewCaseWorksheet = Field(default_factory=ReviewCaseWorksheet)
nextLine: Optional[str] = None
clientFeedback: Optional[str] = None
audioUrl: Optional[str] = None
@ -337,6 +382,27 @@ async def _build_start_recall(*, case_id: str, card) -> memory.RecallContext:
)
async def _build_seed_recall(*, case_id: str | None) -> memory.RecallContext:
if not case_id:
return memory.build_recall_context()
try:
db.get_pool()
except RuntimeError:
return memory.build_recall_context()
prev_summary = await _load_prev_case_summary(case_id)
pinned = list((prev_summary or {}).get("pinned_facts") or [])
return memory.build_recall_context(prev_summary=prev_summary, pinned_facts=pinned)
async def ensure_recall_context(sess: InProcSession) -> memory.RecallContext:
cached = _RECALL_CACHE.get(sess.session_id)
if cached is not None:
return cached
recall = await _build_seed_recall(case_id=sess.case_id)
_RECALL_CACHE[sess.session_id] = recall
return recall
async def _warm_rag_caches(session_id: str, case_id: str, card) -> None:
"""RAG 회상·KB 행동단서를 **백그라운드**로 산출해 캐시한다(요청 경로 비차단).
@ -362,13 +428,7 @@ _PHASE_KEY_BY_LABEL = {
def _stage_label(stage: object) -> str:
name = getattr(stage, "name", "")
return {
"RAPPORT": "라포",
"EXPLORE": "탐색",
"INTERVENE": "개입",
"CLOSE": "정리",
}.get(name, str(getattr(stage, "value", stage)))
return turn_runtime.stage_label(stage)
def _ensure_learner(principal: Principal) -> None:
@ -383,54 +443,22 @@ async def _load_session_or_404(
allow_ended: bool = False,
include_turn_evaluation: bool = False,
) -> InProcSession:
sess = await session_persistence.load_session(
sess, err = await turn_runtime.load_owned_session(
session_id,
principal,
allow_ended=True,
allow_ended=allow_ended,
include_turn_evaluation=include_turn_evaluation,
)
if sess is not None:
store.put(sess)
elif runtime_fallback_allowed():
sess = store.get(session_id)
if sess is None:
if err == turn_runtime.SessionAccessError.NOT_FOUND:
raise HTTPException(status.HTTP_404_NOT_FOUND, detail="session not found")
if sess.learner_id != principal.user_id:
if err == turn_runtime.SessionAccessError.FORBIDDEN:
raise HTTPException(status.HTTP_403_FORBIDDEN, detail="session does not belong to user")
if sess.ended and not allow_ended:
if err == turn_runtime.SessionAccessError.ENDED:
raise HTTPException(status.HTTP_409_CONFLICT, detail="session already ended")
assert sess is not None
return sess
async def _append_session_turn(sess: InProcSession, turn: TurnRecord) -> None:
if await session_persistence.append_turn(
session_id=sess.session_id,
learner_id=sess.learner_id,
turn=turn,
):
sess.turns.append(turn)
store.put(sess)
return
require_runtime_fallback_allowed("session turn append")
store.append_turn(sess.session_id, turn)
async def _update_session_state(
sess: InProcSession,
state: state_machine.SessionState,
) -> None:
if await session_persistence.update_state(
session_id=sess.session_id,
learner_id=sess.learner_id,
state=state,
):
sess.state = state
store.put(sess)
return
require_runtime_fallback_allowed("session state update")
store.update_state(sess.session_id, state)
async def _end_persisted_session(sess: InProcSession, carry: memory.CarryOver) -> None:
if await session_persistence.end_session(sess, carry):
sess.ended = True
@ -639,6 +667,178 @@ def _latest_client_feedback(turns: list[ReviewTurn]) -> str | None:
return None
def _worksheet_evidence(turn: ReviewTurn) -> ReviewWorksheetEvidence:
return ReviewWorksheetEvidence(
turnId=turn.id,
speaker=turn.speaker,
quote=_clip_text(turn.text, 120),
)
def _worksheet_item(
*,
key: str,
label: str,
turns: list[ReviewTurn],
keywords: list[str],
preferred_speaker: Literal["learner", "client"] | None = None,
fallback_turn: ReviewTurn | None = None,
) -> ReviewWorksheetItem:
lowered_keywords = [keyword.lower() for keyword in keywords if keyword]
candidates = turns
if preferred_speaker:
preferred = [turn for turn in turns if turn.speaker == preferred_speaker]
candidates = preferred + [turn for turn in turns if turn.speaker != preferred_speaker]
for turn in candidates:
text = _compact_text(turn.text)
lower_text = text.lower()
if lowered_keywords and any(keyword in lower_text for keyword in lowered_keywords):
return ReviewWorksheetItem(
key=key,
label=label,
value=_clip_text(text, 140),
evidence=[_worksheet_evidence(turn)],
confidence="medium",
)
if fallback_turn is not None:
return ReviewWorksheetItem(
key=key,
label=label,
value=_clip_text(fallback_turn.text, 140),
evidence=[_worksheet_evidence(fallback_turn)],
confidence="low",
)
return ReviewWorksheetItem(
key=key,
label=label,
value=None,
evidence=[],
confidence="none",
emptyReason="저장된 축어록에서 명시 근거를 찾지 못했습니다.",
)
def _worksheet_section(
key: str,
title: str,
specs: list[tuple[str, str, list[str], Literal["learner", "client"] | None]],
turns: list[ReviewTurn],
fallback_client: ReviewTurn | None,
fallback_learner: ReviewTurn | None,
) -> ReviewWorksheetSection:
items: list[ReviewWorksheetItem] = []
for item_key, label, keywords, speaker in specs:
fallback = fallback_client if speaker == "client" else fallback_learner if speaker == "learner" else None
items.append(
_worksheet_item(
key=item_key,
label=label,
turns=turns,
keywords=keywords,
preferred_speaker=speaker,
fallback_turn=fallback if item_key in {"presenting_complaint", "first_goal"} else None,
)
)
return ReviewWorksheetSection(key=key, title=title, items=items)
def _case_worksheet_from_turns(turns: list[ReviewTurn]) -> ReviewCaseWorksheet:
if not turns:
return ReviewCaseWorksheet(
status="empty",
sections=[],
limitations=["저장된 축어록이 없어 사례개념화 워크시트를 생성하지 않았습니다."],
)
fallback_client = next((turn for turn in turns if turn.speaker == "client"), None)
fallback_learner = next((turn for turn in turns if turn.speaker == "learner"), None)
section_specs: list[
tuple[str, str, list[tuple[str, str, list[str], Literal["learner", "client"] | None]]]
] = [
(
"exploration_11",
"탐색 11항목",
[
("presenting_complaint", "주호소", ["힘들", "문제", "걱정", "불안", "우울", "스트레스", "관계"], "client"),
("trigger_context", "계기·상황", ["언제", "상황", "최근", "계기", ""], "client"),
("emotion", "정서", ["불안", "우울", "", "슬프", "답답", "무섭", "외롭", "걱정"], "client"),
("cognition", "생각", ["생각", "느낌", "해야", "", "실패", "의미"], "client"),
("behavior", "행동", ["피하", "", "", "", "", "연락", "공부", ""], "client"),
("body", "신체·수면", ["", "식욕", "", "두통", "심장", "", "피곤"], "client"),
("relationship", "관계", ["친구", "가족", "부모", "엄마", "아빠", "교수", "사람", "관계"], "client"),
("resources", "자원", ["도움", "지지", "친구", "상담", "선생님", "가족"], "client"),
("risk", "위험 신호", ["", "자살", "해치", "사라지고", "끝내", "위험"], "client"),
("motivation", "변화동기", ["", "바라", "변화", "해보고", ""], None),
("first_goal", "상담 목표 초안", ["목표", "계획", "다음", "해볼", "원하"], "learner"),
],
),
(
"five_domains",
"호소 5영역",
[
("domain_emotion", "정서", ["불안", "우울", "", "슬프", "답답", "외롭"], "client"),
("domain_cognition", "인지", ["생각", "걱정", "실패", "", "의미"], "client"),
("domain_behavior", "행동", ["피하", "연락", "공부", "", ""], "client"),
("domain_relationship", "대인관계", ["친구", "가족", "사람", "관계", "부모"], "client"),
("domain_body", "신체", ["", "식욕", "", "두통", "피곤", ""], "client"),
],
),
(
"cognitive_triad_emotions",
"인지삼제·1/2차 감정",
[
("triad_self", "자기", ["나는", "내가", "나 자신", "스스로"], "client"),
("triad_world", "타인·세계", ["사람", "세상", "학교", "가족", "친구"], "client"),
("triad_future", "미래", ["앞으로", "미래", "계속", "나중"], "client"),
("primary_emotion", "1차 감정", ["불안", "슬프", "무섭", "외롭", "걱정"], "client"),
("secondary_emotion", "2차 감정", ["", "짜증", "수치", "죄책", "부끄"], "client"),
],
),
(
"protective_barrier_quadrants",
"보호·방해 4사분면",
[
("internal_protective", "내적 보호요인", ["해보고", "버텼", "노력", "", "견뎠"], None),
("internal_barrier", "내적 방해요인", ["", "두려", "불안", "회피", "걱정"], "client"),
("external_protective", "외적 보호요인", ["친구", "가족", "상담", "교수", "도움"], "client"),
("external_barrier", "외적 방해요인", ["갈등", "압박", "비난", "스트레스", "혼자"], "client"),
],
),
(
"biopsychosocial_goals",
"생물·심리·사회 목표",
[
("bio_goal", "생물", ["", "식사", "운동", "", "피곤"], "client"),
("psy_goal", "심리", ["생각", "감정", "불안", "연습", "조절"], None),
("social_goal", "사회", ["관계", "대화", "연락", "도움", "친구"], None),
],
),
]
sections = [
_worksheet_section(
key,
title,
specs,
turns,
fallback_client,
fallback_learner,
)
for key, title, specs in section_specs
]
return ReviewCaseWorksheet(
status="draft_from_transcript",
sections=sections,
limitations=[
"저장된 축어록에서 키워드 근거를 추출한 1차 초안입니다.",
"임상팀 루브릭, 교수자 검수, 학습자 수정 입력 전에는 확정 사례개념화로 보지 않습니다.",
],
)
def _evaluation_payload(record: dict[str, object] | None) -> dict[str, object]:
if not record:
return {}
@ -697,35 +897,88 @@ def _review_note_from_turn_eval(ev: dict[str, object] | None) -> Optional[Review
return None
async def _record_safety_event(sess: InProcSession, ctx, result) -> None:
"""위기 escalate 시 app.safety_events 적재(교수자 감사·알림 레코드). C2.
def _seconds_label(milliseconds: int) -> str:
seconds = max(0, milliseconds) / 1000.0
if seconds >= 10:
return f"{seconds:.0f}"
return f"{seconds:.1f}"
비차단: DB 미가용(degraded)·FK 미충족(in-memory 세션) graceful skip 상담 루프를
절대 막지 않는다. 실시간 교수자 push 알림은 후속( 레코드가 1 알림원).
"""
crisis = getattr(ctx, "crisis", None)
if crisis is None or not getattr(crisis, "escalate", False):
return
kind = getattr(crisis.kind, "value", None) or str(getattr(crisis, "kind", "crisis"))
try:
async with db.acquire() as conn:
await conn.execute(
"""
INSERT INTO app.safety_events
(session_id, trigger_type, ko_risk_level, escalated, detail)
VALUES ($1::uuid, $2, $3, TRUE, $4::jsonb)
""",
sess.session_id,
kind,
int(getattr(crisis, "risk_level", 0) or 0),
json.dumps({
"matched": list(getattr(crisis, "matched", []) or []),
"stage": getattr(result, "stage", None),
"turn_seq": getattr(result, "turn_seq", None),
}),
def _review_nonverbal_events(turn: TurnRecord) -> list[ReviewNonverbalEvent]:
events: list[ReviewNonverbalEvent] = []
if turn.silence_ms is not None and turn.silence_ms >= 1000:
events.append(
ReviewNonverbalEvent(
kind="silence",
label="침묵",
detail=_seconds_label(turn.silence_ms),
)
)
if turn.speech_rate is not None:
events.append(
ReviewNonverbalEvent(
kind="pace",
label="발화 속도",
detail=f"분당 {turn.speech_rate:.0f}",
)
)
if turn.barge_in is True:
events.append(
ReviewNonverbalEvent(
kind="barge_in",
label="끼어듦",
detail="내담자 발화 중 시작",
)
)
if turn.audio_ref:
events.append(
ReviewNonverbalEvent(
kind="audio",
label="음성 입력",
detail="음성으로 기록됨",
)
)
return events
async def _evaluate_stream_turn(ctx: orchestrator.TurnContext, final_reply: str) -> Optional[dict]:
"""stream 경로 완료 후 fast-loop 평가를 계산한다. 실패는 턴 저장을 막지 않는다."""
if not final_reply:
return None
try:
hook = evaluator.make_eval_hook(
engine_client,
audit_hook=session_persistence.record_llm_call_audit,
)
return await hook(ctx, final_reply)
except Exception:
pass # 비차단(R5): 적재 실패가 위기 대응/상담을 막지 않음.
return None
def _stream_result_from_done(
ctx: orchestrator.TurnContext,
final_reply: str,
data: dict[str, object],
evaluation: Optional[dict],
) -> orchestrator.TurnResult:
assert ctx.state_after is not None
return orchestrator.TurnResult(
turn_seq=ctx.state_after.turn_seq,
stage=_stage_label(ctx.state_after.stage),
effective_openness=ctx.state_after.effective_openness,
client_reply=final_reply or None,
safety_flagged=bool(data.get("safety_flagged")),
state_after=ctx.state_after,
evaluation=evaluation,
crisis_kind=ctx.crisis.kind.value if ctx.crisis else "none",
crisis_resource=data.get("crisis_resource") if isinstance(data.get("crisis_resource"), dict) else None,
conversation_stopped=bool(data.get("conversation_stopped")),
llm_provider=str(data.get("llm_provider") or "") or None,
model=str(data.get("model") or "") or None,
tokens_in=int(data.get("tokens_in") or 0),
tokens_out=int(data.get("tokens_out") or 0),
cost_usd=float(data.get("cost_usd") or 0.0),
)
def _learner_visible_turns(sess: InProcSession) -> list[TurnRecord]:
@ -752,6 +1005,7 @@ async def _generate_and_save_session_evaluation(sess: InProcSession) -> None:
technique_codes=[],
theory_mode=sess.theory_mode,
scope="session_end",
audit_hook=session_persistence.record_llm_call_audit,
),
timeout=min(float(settings.engine_timeout), 45.0),
)
@ -907,7 +1161,12 @@ async def start_session(
raise HTTPException(status.HTTP_404_NOT_FOUND, detail=f"unknown persona {body.persona_code}")
card = catalog_persona.card
recall = memory.build_recall_context()
case_context = await session_persistence.get_case_context(
learner_id=principal.user_id,
persona_id=catalog_persona.persona_id,
)
recall = await _build_seed_recall(case_id=case_context.case_id if case_context else None)
session_no = (case_context.last_session_no + 1) if case_context else 1
st = state_machine.init_state(
params=card.openness_params(),
carry=recall.carry,
@ -919,10 +1178,11 @@ async def start_session(
card=card,
theory_mode=body.theory_mode,
state=st,
session_no=1,
session_no=session_no,
carry_rapport=carry_rapport,
persona_id=catalog_persona.persona_id,
persona_version=catalog_persona.version,
case_id=case_context.case_id if case_context else None,
)
degraded = catalog_persona.degraded or sess is None
if sess is None:
@ -932,7 +1192,7 @@ async def start_session(
persona=card,
theory_mode=body.theory_mode,
state=st,
session_no=1,
session_no=session_no,
carry_rapport=carry_rapport,
)
else:
@ -1007,6 +1267,7 @@ async def get_session_review(
who="학습자" if speaker == "learner" else client_name,
text=turn.text_masked,
techniques=_review_techniques_from_turn_eval(turn_eval),
nonverbal=_review_nonverbal_events(turn) if speaker == "learner" else [],
note=_review_note_from_turn_eval(turn_eval),
)
)
@ -1085,6 +1346,7 @@ async def get_session_review(
rubric=rubric,
goodMoments=good_moments,
growthPoints=growth_points,
caseWorksheet=_case_worksheet_from_turns(turns),
nextLine=next_line,
clientFeedback=client_feedback,
audioUrl=None,
@ -1103,7 +1365,7 @@ async def submit_turn(
"""Submit one trainee utterance and return the generated client reply."""
_ensure_learner(principal)
sess = await _load_session_or_404(session_id, principal)
recall = _RECALL_CACHE.get(session_id) or memory.RecallContext()
recall = await ensure_recall_context(sess)
kb_cues = _KB_CUES_CACHE.get(session_id) or [] # 비차단: warm 전이면 빈 단서(graceful)
ctx = orchestrator.prepare_turn(
@ -1124,7 +1386,11 @@ async def submit_turn(
result = await orchestrator.run_turn_generate(
ctx,
engine_client,
eval_hook=evaluator.make_eval_hook(engine_client),
eval_hook=evaluator.make_eval_hook(
engine_client,
audit_hook=session_persistence.record_llm_call_audit,
),
audit_hook=session_persistence.record_llm_call_audit,
)
except EngineError as exc:
raise HTTPException(
@ -1132,37 +1398,13 @@ async def submit_turn(
detail=f"engine unavailable: {exc}",
) from exc
# 턴별 fast-loop 평가는 학습자(상담자) 발화에 부착(기법 태깅·적절성·의도이탈).
await _append_session_turn(
await turn_runtime.record_completed_turn(
sess,
TurnRecord(
turn_seq=ctx.state_after.turn_seq,
speaker="counselor",
stage=_stage_label(ctx.state_after.stage),
text=body.text,
text_masked=ctx.learner_text_masked,
evaluation=result.evaluation,
),
ctx,
result,
context_prefix="session",
)
if result.client_reply:
await _append_session_turn(
sess,
TurnRecord(
turn_seq=result.turn_seq,
speaker="client",
stage=_stage_label(result.state_after.stage),
text=result.client_reply,
text_masked=result.client_reply,
llm_provider=result.llm_provider,
model=result.model,
tokens_in=result.tokens_in,
tokens_out=result.tokens_out,
cost_usd=result.cost_usd,
),
)
await _update_session_state(sess, result.state_after)
await _record_safety_event(sess, ctx, result) # C2: 위기 escalate 시 safety_events 적재(비차단)
await turn_runtime.record_safety_event(sess, ctx, result)
return TurnResponse(
turn_seq=result.turn_seq,
@ -1171,6 +1413,8 @@ async def submit_turn(
client_reply=result.client_reply,
safety_flagged=result.safety_flagged,
crisis_kind=result.crisis_kind,
crisis_resource=result.crisis_resource,
conversation_stopped=result.conversation_stopped,
)
@ -1183,7 +1427,7 @@ async def stream_turn(
"""Stream a generated client reply for one trainee utterance."""
_ensure_learner(principal)
sess = await _load_session_or_404(session_id, principal)
recall = _RECALL_CACHE.get(session_id) or memory.RecallContext()
recall = await ensure_recall_context(sess)
kb_cues = _KB_CUES_CACHE.get(session_id) or [] # 비차단: warm 전이면 빈 단서(graceful)
ctx = orchestrator.prepare_turn(
@ -1204,40 +1448,26 @@ async def stream_turn(
last_beat = asyncio.get_running_loop().time()
final_reply = ""
try:
async for ev in orchestrator.run_turn_stream(ctx, engine_client):
async for ev in orchestrator.run_turn_stream(
ctx,
engine_client,
audit_hook=session_persistence.record_llm_call_audit,
):
if ev.event == "token":
text = str(ev.data.get("text", ""))
final_reply += text
yield {"event": "token", "data": text}
elif ev.event == "done":
data = {**ev.data, "stage": _stage_label(ctx.state_after.stage)}
await _append_session_turn(
evaluation = await _evaluate_stream_turn(ctx, final_reply)
result = _stream_result_from_done(ctx, final_reply, data, evaluation)
await turn_runtime.record_completed_turn(
sess,
TurnRecord(
turn_seq=ctx.state_after.turn_seq,
speaker="counselor",
stage=_stage_label(ctx.state_after.stage),
text=body.text,
text_masked=ctx.learner_text_masked,
),
ctx,
result,
context_prefix="session",
)
await _update_session_state(sess, ctx.state_after)
if final_reply:
await _append_session_turn(
sess,
TurnRecord(
turn_seq=ctx.state_after.turn_seq,
speaker="client",
stage=_stage_label(ctx.state_after.stage),
text=final_reply,
text_masked=final_reply,
llm_provider=str(ev.data.get("llm_provider") or ""),
model=str(ev.data.get("model") or ""),
tokens_in=int(ev.data.get("tokens_in") or 0),
tokens_out=int(ev.data.get("tokens_out") or 0),
cost_usd=float(ev.data.get("cost_usd") or 0.0),
),
)
await turn_runtime.record_safety_event(sess, ctx, result)
yield {"event": "done", "data": json.dumps(data, ensure_ascii=False)}
else:
yield {"event": ev.event, "data": json.dumps(ev.data, ensure_ascii=False)}