현재 작업 전체 반영
This commit is contained in:
parent
5560638e54
commit
c0dddab594
85 changed files with 11322 additions and 539 deletions
|
|
@ -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)}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue