대시보드 폴드아웃/드릴다운 정리 + 페르소나 역린·misconduct 반응 + 게이트웨이 격리·RAG 비차단 수정
SSOT 대시보드:
- 한신대 기술분석 PDF(19쪽) 정합성 분석 + 이번 세션 발견 섹션 추가
- 섹션 폴드아웃(접기)·상단 목차(드릴다운)·모두 펼치기/접기 — 내용 보존, 레이아웃만 정리
페르소나 반응 강화('저항·반응 조절' 핵심 차별):
- PersonaCard.triggers(역린) 필드 + CCD 핵심상처 파생 역린 블록
- L0에 무례·모욕·조롱 시 현실적 동맹 균열 반응 지침
버그·성능 수정(라이브/E2E로 포착):
- 게이트웨이 페르소나 격리: --append-system-prompt를 --system-prompt(교체)로 + --exclude-dynamic-system-prompt-sections (내담자 캐릭터 붕괴·개발맥락 누출 차단)
- RAG: 임베더 동기 로드(약 7-13초)를 _warm_rag_caches 백그라운드 warm으로(세션 생성 블로킹 회귀 수정)
- voice TTS RMS 데드힌트 제거, init_state OpennessParams 파라미터객체화
- 한국어 PII(날짜·금액·주소) 마스킹 보강
- 레이아웃 시각 게이트: 폼 컨트롤 값 스크롤 오탐 제외(7/7)
검증: 백엔드 84/84, E2E 42(데스크톱 27·모바일 11·아바타 4), 시각 게이트 7/7
This commit is contained in:
parent
cb2aebd76c
commit
085460b5e0
327 changed files with 31226 additions and 1829 deletions
|
|
@ -18,13 +18,13 @@ from fastapi import APIRouter, HTTPException, status
|
|||
from pydantic import BaseModel, Field
|
||||
from sse_starlette.sse import EventSourceResponse
|
||||
|
||||
from .. import session_persistence
|
||||
from .. import db, session_persistence
|
||||
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 ..services import evaluator, memory, orchestrator, state_machine
|
||||
from ..services import evaluator, memory, orchestrator, rag, state_machine
|
||||
from ..store import InProcSession, TurnRecord, store
|
||||
|
||||
router = APIRouter(prefix="/sessions", tags=["sessions"])
|
||||
|
|
@ -193,6 +193,165 @@ class SessionReviewResponse(BaseModel):
|
|||
|
||||
|
||||
_RECALL_CACHE: dict[str, memory.RecallContext] = {}
|
||||
# 세션별 KB 증상 행동단서(회기 1회 산출·캐시). 빈 list 캐시 = 회기 내 재시도 안 함(안정성).
|
||||
_KB_CUES_CACHE: dict[str, list[str]] = {}
|
||||
_LEARNER_VISIBLE_AI_ROLE = "counselor"
|
||||
|
||||
# ────────────────────────────────────────────────────────────────────────────
|
||||
# RAG 배선 헬퍼 — 내담자(CLIENT) 뷰. 임베더/KB/DB 풀 미가용 시 빈 값으로 graceful
|
||||
# degradation: 상담 루프를 절대 막지 않는다(라이브 루프 비차단이 계약). routes/kb.py가
|
||||
# 같은 예외를 503으로 올리는 것과 의도적으로 다르다. 임베딩은 rag가 스레드풀로 offload.
|
||||
# ────────────────────────────────────────────────────────────────────────────
|
||||
_RAG_RECALL_K = 5
|
||||
_KB_CUES_K = 4
|
||||
|
||||
|
||||
def _persona_kb_query(card) -> str:
|
||||
"""페르소나 증상·호소 → KB 행동단서 검색 질의(임베더/tsquery 입력 전용, LLM 미주입).
|
||||
|
||||
질의는 프롬프트에 들어가지 않는다. 회수된 behavior_cue만 L2로 주입되고, CLIENT 정책
|
||||
(expose_body=False)이 본문을 잘라 '행동단서'만 돌려준다(CCD 본문 비노출 자동 보존).
|
||||
"""
|
||||
parts: list[str] = []
|
||||
presenting = getattr(card, "presenting", None) or {}
|
||||
if presenting.get("주호소"):
|
||||
parts.append(str(presenting["주호소"]))
|
||||
if presenting.get("표층"):
|
||||
parts.append(str(presenting["표층"]))
|
||||
dsm = getattr(card, "dsm5_dimensional", None) or {}
|
||||
parts.extend(str(key) for key in dsm.keys() if key != "note")
|
||||
return " ".join(p for p in parts if p).strip()
|
||||
|
||||
|
||||
async def _retrieve_kb_behavior_cues(card) -> list[str]:
|
||||
"""KB 증상 행동단서 회수(CLIENT 정책). 미가용 시 빈 리스트(비차단)."""
|
||||
query = _persona_kb_query(card)
|
||||
if not query:
|
||||
return []
|
||||
try:
|
||||
async with db.acquire(ai_view=rag.AIRole.CLIENT.value) as conn:
|
||||
result = await rag.search_kb(
|
||||
conn,
|
||||
query=query,
|
||||
role=rag.AIRole.CLIENT,
|
||||
k=_KB_CUES_K,
|
||||
)
|
||||
return [c.behavior_cue for c in result.chunks if c.behavior_cue]
|
||||
except Exception:
|
||||
# rag.NotConfigured(임베더/KB 미가용)·RuntimeError(풀 미초기화)·DB 오류 포함.
|
||||
# 비치명적: 빈 단서로 진행. CancelledError는 BaseException이라 미포착.
|
||||
return []
|
||||
|
||||
|
||||
async def _ensure_kb_cues(session_id: str, card) -> list[str]:
|
||||
"""세션별 KB 행동단서(회기 1회 산출·캐시, 서버 재시작/재개 시 lazy 재계산)."""
|
||||
cached = _KB_CUES_CACHE.get(session_id)
|
||||
if cached is not None:
|
||||
return cached
|
||||
cues = await _retrieve_kb_behavior_cues(card)
|
||||
_KB_CUES_CACHE[session_id] = cues
|
||||
return cues
|
||||
|
||||
|
||||
async def _load_prev_case_summary(case_id: str) -> Optional[dict]:
|
||||
"""직전 회기 요약(case 스코프) → build_recall_context 입력. 미존재/미가용 시 None."""
|
||||
try:
|
||||
async with db.acquire(ai_view=rag.AIRole.CLIENT.value) as conn:
|
||||
row = await conn.fetchrow(
|
||||
"""
|
||||
SELECT digest, open_threads, end_state
|
||||
FROM app.session_summary
|
||||
WHERE case_id = $1::uuid
|
||||
ORDER BY session_no DESC, created_at DESC
|
||||
LIMIT 1
|
||||
""",
|
||||
case_id,
|
||||
)
|
||||
except Exception:
|
||||
return None
|
||||
if row is None:
|
||||
return None
|
||||
return {
|
||||
"digest": row["digest"],
|
||||
"open_threads": list(row["open_threads"] or []),
|
||||
"end_state": dict(row["end_state"] or {}),
|
||||
}
|
||||
|
||||
|
||||
async def _hydrate_episodic_text(conn, result) -> list[str]:
|
||||
"""retrieve_persona_memory가 돌려준 turn_id → app.turns 마스킹 본문 조인(내담자 발화)."""
|
||||
turn_ids = [c.meta.get("turn_id") for c in result.chunks if c.meta.get("turn_id")]
|
||||
if not turn_ids:
|
||||
return []
|
||||
rows = await conn.fetch(
|
||||
"""
|
||||
SELECT id, text_masked FROM app.turns
|
||||
WHERE id = ANY($1::uuid[]) AND speaker = 'client'
|
||||
""",
|
||||
turn_ids,
|
||||
)
|
||||
by_id = {str(r["id"]): r["text_masked"] for r in rows}
|
||||
return [by_id[t] for t in turn_ids if by_id.get(t)]
|
||||
|
||||
|
||||
def _recall_query(prev_summary: Optional[dict], card) -> str:
|
||||
"""episodic recall 질의: 직전 open_threads 우선, 없으면 주호소."""
|
||||
if prev_summary:
|
||||
threads = prev_summary.get("open_threads") or []
|
||||
if threads:
|
||||
return " ".join(str(t) for t in threads)
|
||||
presenting = getattr(card, "presenting", None) or {}
|
||||
return str(presenting.get("주호소") or "").strip()
|
||||
|
||||
|
||||
async def _episodic_recall_snippets(case_id: str, query: str) -> list[str]:
|
||||
"""case 스코프 episodic 벡터 recall → 내담자 발화 단편(마스킹본). 미가용 시 []."""
|
||||
if not query:
|
||||
return []
|
||||
try:
|
||||
async with db.acquire(ai_view=rag.AIRole.CLIENT.value) as conn:
|
||||
result = await rag.retrieve_persona_memory(
|
||||
conn, case_id=case_id, query=query, k=_RAG_RECALL_K,
|
||||
)
|
||||
return await _hydrate_episodic_text(conn, result)
|
||||
except Exception:
|
||||
return []
|
||||
|
||||
|
||||
async def _build_start_recall(*, case_id: str, card) -> memory.RecallContext:
|
||||
"""회기 시작 회상 조립: prev_summary(case) + episodic recall을 build_recall_context로
|
||||
합본. 전 구간 graceful(미가용 시 빈 회상).
|
||||
"""
|
||||
try:
|
||||
db.get_pool() # 풀 미초기화 시 RuntimeError → 첫 회기와 동일한 빈 회상
|
||||
except RuntimeError:
|
||||
return memory.build_recall_context()
|
||||
prev_summary = await _load_prev_case_summary(case_id)
|
||||
query = _recall_query(prev_summary, card)
|
||||
episodic = await _episodic_recall_snippets(case_id, query)
|
||||
pinned = list((prev_summary or {}).get("pinned_facts") or [])
|
||||
return memory.build_recall_context(
|
||||
prev_summary=prev_summary,
|
||||
episodic_snippets=episodic,
|
||||
pinned_facts=pinned,
|
||||
)
|
||||
|
||||
|
||||
async def _warm_rag_caches(session_id: str, case_id: str, card) -> None:
|
||||
"""RAG 회상·KB 행동단서를 **백그라운드**로 산출해 캐시한다(요청 경로 비차단).
|
||||
|
||||
BGE-M3 임베더 첫 로드(~수 초)가 회기 시작/턴 응답을 막지 않도록 create_task로 띄운다.
|
||||
warm 완료 전 턴은 빈 회상/단서로 진행(graceful), 이후 턴부터 RAG 주입. 전 구간 비치명적.
|
||||
"""
|
||||
try:
|
||||
_RECALL_CACHE[session_id] = await _build_start_recall(case_id=case_id, card=card)
|
||||
except Exception:
|
||||
pass
|
||||
try:
|
||||
_KB_CUES_CACHE[session_id] = await _retrieve_kb_behavior_cues(card)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
|
||||
_PHASE_KEY_BY_LABEL = {
|
||||
"라포": "rapport",
|
||||
|
|
@ -481,6 +640,92 @@ def _evaluation_payload(record: dict[str, object] | None) -> dict[str, object]:
|
|||
return payload if isinstance(payload, dict) else {}
|
||||
|
||||
|
||||
# fast-loop 턴 평가(TechniqueCategory) → 프론트 sr-technique--{kind} 시각 매핑.
|
||||
_TECHNIQUE_KIND_BY_CATEGORY = {
|
||||
"relational": "empathy",
|
||||
"exploratory": "explore",
|
||||
"intervention": "confront",
|
||||
"stabilizing": "reflect",
|
||||
"structuring": "closed",
|
||||
}
|
||||
|
||||
|
||||
def _review_techniques_from_turn_eval(ev: dict[str, object] | None) -> list[ReviewTechnique]:
|
||||
"""턴 평가의 기법 태그를 리뷰 칩으로. label_ko 우선, category로 색 kind 결정."""
|
||||
if not isinstance(ev, dict):
|
||||
return []
|
||||
out: list[ReviewTechnique] = []
|
||||
for tag in ev.get("techniques") or []:
|
||||
if not isinstance(tag, dict):
|
||||
continue
|
||||
label = str(tag.get("label_ko") or tag.get("code") or "").strip()
|
||||
if not label:
|
||||
continue
|
||||
kind = _TECHNIQUE_KIND_BY_CATEGORY.get(str(tag.get("category") or ""), "explore")
|
||||
out.append(ReviewTechnique(kind=kind, label=label))
|
||||
return out
|
||||
|
||||
|
||||
def _review_note_from_turn_eval(ev: dict[str, object] | None) -> Optional[ReviewNote]:
|
||||
"""의도이탈(있으면 우선) 또는 적절성 신호를 턴 노트로. tone: good|warn(프론트 계약)."""
|
||||
if not isinstance(ev, dict):
|
||||
return None
|
||||
dev = ev.get("intent_deviation")
|
||||
if isinstance(dev, dict):
|
||||
dimension = str(dev.get("dimension") or "").strip()
|
||||
expected = str(dev.get("expected") or "").strip()
|
||||
actual = str(dev.get("actual") or "").strip()
|
||||
body = " / ".join(p for p in (f"권장: {expected}" if expected else "", f"실제: {actual}" if actual else "") if p)
|
||||
return ReviewNote(
|
||||
author="평가 AI",
|
||||
tone="warn",
|
||||
title=f"의도와 다른 부분 · {dimension}".rstrip(" ·") or "의도와 다른 부분",
|
||||
body=body or "권장 반응과 실제 반응에 차이가 있었어요.",
|
||||
)
|
||||
appropriateness = str(ev.get("appropriateness") or "neutral")
|
||||
note_text = str(ev.get("appropriateness_note") or "").strip()
|
||||
if appropriateness == "pos":
|
||||
return ReviewNote(author="평가 AI", tone="good", title="적절한 개입", body=note_text or "이 개입은 흐름에 적절했어요.")
|
||||
if appropriateness == "warn" and note_text:
|
||||
return ReviewNote(author="평가 AI", tone="warn", title="점검해볼 지점", body=note_text)
|
||||
return None
|
||||
|
||||
|
||||
async def _record_safety_event(sess: InProcSession, ctx, result) -> None:
|
||||
"""위기 escalate 시 app.safety_events 적재(교수자 감사·알림 레코드). C2.
|
||||
|
||||
비차단: 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),
|
||||
}),
|
||||
)
|
||||
except Exception:
|
||||
pass # 비차단(R5): 적재 실패가 위기 대응/상담을 막지 않음.
|
||||
|
||||
|
||||
def _learner_visible_turns(sess: InProcSession) -> list[TurnRecord]:
|
||||
return sess.turns_visible_to(_LEARNER_VISIBLE_AI_ROLE)
|
||||
|
||||
|
||||
async def _generate_and_save_session_evaluation(sess: InProcSession) -> None:
|
||||
if not sess.turns:
|
||||
return
|
||||
|
|
@ -535,8 +780,9 @@ def _schedule_session_evaluation(sess: InProcSession) -> None:
|
|||
|
||||
|
||||
def _learner_summary(sess: InProcSession, *, review_ready: bool = False) -> LearnerSessionSummary:
|
||||
learner_turns = sum(1 for turn in sess.turns if turn.speaker == "counselor")
|
||||
client_turns = sum(1 for turn in sess.turns if turn.speaker == "client")
|
||||
turns = _learner_visible_turns(sess)
|
||||
learner_turns = sum(1 for turn in turns if turn.speaker == "counselor")
|
||||
client_turns = sum(1 for turn in turns if turn.speaker == "client")
|
||||
return LearnerSessionSummary(
|
||||
session_id=sess.session_id,
|
||||
persona_code=sess.persona_code,
|
||||
|
|
@ -544,7 +790,7 @@ def _learner_summary(sess: InProcSession, *, review_ready: bool = False) -> Lear
|
|||
session_no=sess.session_no,
|
||||
status="ended" if sess.ended else "active",
|
||||
stage=_stage_label(sess.state.stage),
|
||||
turn_count=len(sess.turns),
|
||||
turn_count=len(turns),
|
||||
learner_turn_count=learner_turns,
|
||||
client_turn_count=client_turns,
|
||||
started_at=_iso(sess.created_at) or "",
|
||||
|
|
@ -554,7 +800,10 @@ def _learner_summary(sess: InProcSession, *, review_ready: bool = False) -> Lear
|
|||
|
||||
|
||||
async def _review_ready(sess: InProcSession, principal: Principal) -> bool:
|
||||
if not sess.ended or not sess.turns:
|
||||
turns = _learner_visible_turns(sess)
|
||||
if not sess.ended or not turns:
|
||||
return False
|
||||
if len(turns) != len(sess.turns):
|
||||
return False
|
||||
evaluation_record, _ = await session_persistence.load_session_evaluation(
|
||||
sess.session_id,
|
||||
|
|
@ -568,6 +817,7 @@ def _session_detail(
|
|||
*,
|
||||
review_ready: bool = False,
|
||||
) -> SessionDetailResponse:
|
||||
turns = _learner_visible_turns(sess)
|
||||
return SessionDetailResponse(
|
||||
session_id=sess.session_id,
|
||||
case_id=sess.case_id,
|
||||
|
|
@ -587,7 +837,7 @@ def _session_detail(
|
|||
text=turn.text_masked,
|
||||
created_at=_iso(turn.created_at) or "",
|
||||
)
|
||||
for turn in sess.turns
|
||||
for turn in turns
|
||||
],
|
||||
review_ready=review_ready,
|
||||
)
|
||||
|
|
@ -649,10 +899,7 @@ async def start_session(
|
|||
|
||||
recall = memory.build_recall_context()
|
||||
st = state_machine.init_state(
|
||||
base_resistance=card.base_resistance(),
|
||||
unlock_rate=card.unlock_rate(),
|
||||
decay_floor=card.decay_floor(),
|
||||
ideation_baseline=card.ideation_baseline(),
|
||||
params=card.openness_params(),
|
||||
carry=recall.carry,
|
||||
)
|
||||
|
||||
|
|
@ -680,7 +927,11 @@ async def start_session(
|
|||
)
|
||||
else:
|
||||
store.put(sess)
|
||||
|
||||
# 즉시 빈/carry 회상으로 응답을 막지 않는다. RAG 회상·KB 단서(임베더 로드 수 초)는
|
||||
# 백그라운드 warm으로 캐시 — 회기 시작/턴 응답이 임베더 로드에 블로킹되지 않게(성능 회귀 방지).
|
||||
_RECALL_CACHE[sess.session_id] = recall
|
||||
asyncio.create_task(_warm_rag_caches(sess.session_id, sess.case_id, card))
|
||||
|
||||
return SessionStartResponse(
|
||||
session_id=sess.session_id,
|
||||
|
|
@ -701,6 +952,8 @@ async def get_session_review(
|
|||
"""Return a learner-safe review built only from the stored session transcript."""
|
||||
_ensure_learner(principal)
|
||||
sess = await _load_session_or_404(session_id, principal, allow_ended=True)
|
||||
visible_turns = _learner_visible_turns(sess)
|
||||
hidden_turns = len(visible_turns) != len(sess.turns)
|
||||
|
||||
end_ts = sess.ended_at or datetime.now().timestamp()
|
||||
duration_seconds = max(0, int(round(end_ts - sess.created_at)))
|
||||
|
|
@ -708,7 +961,7 @@ async def get_session_review(
|
|||
client_initial = client_name[:1] or "내"
|
||||
|
||||
reached_phase = _stage_label(sess.state.stage)
|
||||
stage_labels = [turn.stage for turn in sess.turns] or [reached_phase]
|
||||
stage_labels = [turn.stage for turn in visible_turns] or [reached_phase]
|
||||
axis = ["0:00"]
|
||||
if duration_seconds > 0:
|
||||
axis.append(_offset_label(duration_seconds))
|
||||
|
|
@ -717,16 +970,20 @@ async def get_session_review(
|
|||
session_id,
|
||||
principal,
|
||||
)
|
||||
evaluation_payload = _evaluation_payload(evaluation_record)
|
||||
evaluation_status = str(evaluation_record.get("status") or "") if evaluation_record else ""
|
||||
evaluation_ready = evaluation_status == "ready"
|
||||
evaluation_payload = {} if hidden_turns else _evaluation_payload(evaluation_record)
|
||||
evaluation_status = (
|
||||
"" if hidden_turns else str(evaluation_record.get("status") or "") if evaluation_record else ""
|
||||
)
|
||||
evaluation_ready = not hidden_turns and evaluation_status == "ready"
|
||||
|
||||
first_turn_ts = sess.turns[0].created_at if sess.turns else sess.created_at
|
||||
first_turn_ts = visible_turns[0].created_at if visible_turns else sess.created_at
|
||||
turns: list[ReviewTurn] = []
|
||||
for index, turn in enumerate(sess.turns):
|
||||
for index, turn in enumerate(visible_turns):
|
||||
speaker: Literal["learner", "client"] = (
|
||||
"learner" if turn.speaker == "counselor" else "client"
|
||||
)
|
||||
# 턴별 fast-loop 평가는 학습자 발화에만 부착(기법 태깅·노트). hidden 시 노출 안 함.
|
||||
turn_eval = turn.evaluation if (speaker == "learner" and not hidden_turns) else None
|
||||
turns.append(
|
||||
ReviewTurn(
|
||||
id=f"t{index + 1}",
|
||||
|
|
@ -734,8 +991,8 @@ async def get_session_review(
|
|||
speaker=speaker,
|
||||
who="학습자" if speaker == "learner" else client_name,
|
||||
text=turn.text_masked,
|
||||
techniques=[],
|
||||
note=None,
|
||||
techniques=_review_techniques_from_turn_eval(turn_eval),
|
||||
note=_review_note_from_turn_eval(turn_eval),
|
||||
)
|
||||
)
|
||||
|
||||
|
|
@ -783,10 +1040,10 @@ async def get_session_review(
|
|||
|
||||
summary = _review_summary_from_evaluation(
|
||||
fallback=transcript_summary,
|
||||
evaluation_record=evaluation_record,
|
||||
evaluation_record=None if hidden_turns else evaluation_record,
|
||||
payload=evaluation_payload,
|
||||
)
|
||||
if evaluation_record and not evaluation_durable:
|
||||
if evaluation_record and not hidden_turns and not evaluation_durable:
|
||||
summary += " 현재 평가는 런타임 캐시에서 복원되었습니다."
|
||||
|
||||
return SessionReviewResponse(
|
||||
|
|
@ -832,6 +1089,7 @@ async def submit_turn(
|
|||
_ensure_learner(principal)
|
||||
sess = await _load_session_or_404(session_id, principal)
|
||||
recall = _RECALL_CACHE.get(session_id) or memory.RecallContext()
|
||||
kb_cues = _KB_CUES_CACHE.get(session_id) or [] # 비차단: warm 전이면 빈 단서(graceful)
|
||||
|
||||
ctx = orchestrator.prepare_turn(
|
||||
session_id=session_id,
|
||||
|
|
@ -841,18 +1099,25 @@ async def submit_turn(
|
|||
learner_text=body.text,
|
||||
recall_summary=recall.recall_summary,
|
||||
pinned_facts=recall.pinned_facts,
|
||||
recent_turns=sess.recent_turns(),
|
||||
recent_turns=sess.recent_turns(visible_to="client"),
|
||||
kb_behavior_cues=kb_cues,
|
||||
theory_mode=sess.theory_mode,
|
||||
)
|
||||
assert ctx.state_after is not None
|
||||
|
||||
try:
|
||||
result = await orchestrator.run_turn_generate(ctx, engine_client)
|
||||
result = await orchestrator.run_turn_generate(
|
||||
ctx,
|
||||
engine_client,
|
||||
eval_hook=evaluator.make_eval_hook(engine_client),
|
||||
)
|
||||
except EngineError as exc:
|
||||
raise HTTPException(
|
||||
status.HTTP_503_SERVICE_UNAVAILABLE,
|
||||
detail=f"engine unavailable: {exc}",
|
||||
) from exc
|
||||
|
||||
# 턴별 fast-loop 평가는 학습자(상담자) 발화에 부착(기법 태깅·적절성·의도이탈).
|
||||
await _append_session_turn(
|
||||
sess,
|
||||
TurnRecord(
|
||||
|
|
@ -861,6 +1126,7 @@ async def submit_turn(
|
|||
stage=_stage_label(ctx.state_after.stage),
|
||||
text=body.text,
|
||||
text_masked=ctx.learner_text_masked,
|
||||
evaluation=result.evaluation,
|
||||
),
|
||||
)
|
||||
|
||||
|
|
@ -873,9 +1139,15 @@ async def submit_turn(
|
|||
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 적재(비차단)
|
||||
|
||||
return TurnResponse(
|
||||
turn_seq=result.turn_seq,
|
||||
|
|
@ -897,6 +1169,7 @@ async def stream_turn(
|
|||
_ensure_learner(principal)
|
||||
sess = await _load_session_or_404(session_id, principal)
|
||||
recall = _RECALL_CACHE.get(session_id) or memory.RecallContext()
|
||||
kb_cues = _KB_CUES_CACHE.get(session_id) or [] # 비차단: warm 전이면 빈 단서(graceful)
|
||||
|
||||
ctx = orchestrator.prepare_turn(
|
||||
session_id=session_id,
|
||||
|
|
@ -906,7 +1179,9 @@ async def stream_turn(
|
|||
learner_text=body.text,
|
||||
recall_summary=recall.recall_summary,
|
||||
pinned_facts=recall.pinned_facts,
|
||||
recent_turns=sess.recent_turns(),
|
||||
recent_turns=sess.recent_turns(visible_to="client"),
|
||||
kb_behavior_cues=kb_cues,
|
||||
theory_mode=sess.theory_mode,
|
||||
)
|
||||
assert ctx.state_after is not None
|
||||
|
||||
|
|
@ -941,6 +1216,11 @@ async def stream_turn(
|
|||
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),
|
||||
),
|
||||
)
|
||||
yield {"event": "done", "data": json.dumps(data, ensure_ascii=False)}
|
||||
|
|
@ -980,6 +1260,7 @@ async def end_session(
|
|||
|
||||
await _end_persisted_session(sess, carry)
|
||||
_RECALL_CACHE.pop(session_id, None)
|
||||
_KB_CUES_CACHE.pop(session_id, None)
|
||||
_schedule_session_evaluation(sess)
|
||||
|
||||
return SessionEndResponse(
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue