web (Vite+React19+TS, Cloudflare Pages 배포): - 디자인토큰(세이지틸/테라코타 SSOT), 앱셸, 공통 UI 프리미티브 - 7화면: 로그인/학습자홈/상담세션/회기리뷰/교수자/관리자/설정 - ClientAvatar: SVG 반구상 흉상 4상태 + RMS 립싱크 + 6파라미터 정서 - 회기리뷰는 외부 레퍼런스 디자인을 Vignette 토큰으로 리스킨 api (FastAPI): - 게이트웨이 /v1/generate·/v1/stream 어댑터(상주풀/EngineSession 보존) - services: 페르소나 L0~L6 빌더 / 결정론 상태머신 / 가드레일 / 턴 오케스트레이터 / 회기간 메모리 / 평가AI / 음성 / RAG - store: DB off 폴백(in-memory), sessions 실구현 검증: - web: node22 tsc+vite build 통과(node23 segfault 회피), Pages 배포 200 - api: app.main import 통과 - 핫픽스: Topbar initials undefined-safe (undefined.trim 크래시) - E2E: 서연(P1) 상담 1턴 — 좋은/나쁜 상담에 차등 반응 실증
344 lines
15 KiB
Python
344 lines
15 KiB
Python
"""상담 세션 라우트 — 시작 / 턴 / 스트림 / 종료 (services 실호출).
|
|
|
|
흐름 (설계서 §2 회기 라이프사이클 + 마스터플랜 §2.2 턴 사이클):
|
|
POST /sessions — 회기 시작 (페르소나 핀 + 회상 + 상태머신 init)
|
|
POST /sessions/{id}/turn — 수련생 발화 1턴 (가드레일→상태머신→내담자AI→출력가드)
|
|
GET /sessions/{id}/stream — 내담자 AI 응답 SSE 스트림 (heartbeat 포함)
|
|
POST /sessions/{id}/end — 회기 종료 (무손실 carry-over + 압축 트리거)
|
|
|
|
DB(NAS Postgres)가 SoR 이지만 Docker off 에서도 엔진만 떠 있으면 1턴이 돌도록
|
|
**store(in-memory)** 폴백을 둔다(degraded). 인증도 dev 폴백을 허용한다(개발 편의).
|
|
|
|
상태머신(라포→탐색→개입→정리)은 백엔드가 결정론적으로 소유(LLM 아님, 마스터플랜 §0).
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import asyncio
|
|
import json
|
|
from typing import Annotated, Literal, Optional
|
|
|
|
from fastapi import APIRouter, Depends, HTTPException, status
|
|
from pydantic import BaseModel, Field
|
|
from sse_starlette.sse import EventSourceResponse
|
|
|
|
from fastapi import Cookie
|
|
|
|
from ..config import settings
|
|
from ..deps import Principal, Role
|
|
from ..engine_client import EngineError, engine_client
|
|
from ..services import memory, orchestrator, persona, state_machine
|
|
from ..store import TurnRecord, store
|
|
|
|
router = APIRouter(prefix="/sessions", tags=["sessions"])
|
|
|
|
StageLiteral = Literal["라포", "탐색", "개입", "정리"]
|
|
|
|
|
|
# ── 인증 — dev 폴백 허용 (쿠키 없으면 dev learner) ───────────────────────────
|
|
async def get_principal_dev(
|
|
session_cookie: Annotated[Optional[str], Cookie(alias="__Host-vignette_sid")] = None,
|
|
) -> Principal:
|
|
"""세션 쿠키 → Principal. 미인증(쿠키 없음)이면 dev learner 폴백.
|
|
|
|
개발/시연(쿠키 없음, DB off)에서도 상담 루프가 돌게 한다.
|
|
prod 에선 auth.py BFF + Redis 세션이 완성되면 deps.get_current_principal 로 교체.
|
|
TODO: Redis 세션 룩업으로 user_id/role/cohort 복원.
|
|
"""
|
|
if not session_cookie:
|
|
return Principal(user_id="dev-learner", role=Role.LEARNER, cohort_ids=[])
|
|
# TODO: Redis 세션 검증. 현재는 쿠키 존재만으로 dev learner.
|
|
return Principal(user_id="dev-user", role=Role.LEARNER, cohort_ids=[])
|
|
|
|
|
|
DevPrincipal = Annotated[Principal, Depends(get_principal_dev)]
|
|
|
|
|
|
# ── 요청/응답 모델 ──────────────────────────────────────
|
|
class SessionStartRequest(BaseModel):
|
|
persona_code: str = Field(..., examples=["P1"]) # 시드 페르소나 (P1/P2/P3)
|
|
theory_mode: Literal["humanistic", "cbt", "integrative"] = "humanistic"
|
|
|
|
|
|
class SessionStartResponse(BaseModel):
|
|
session_id: str
|
|
case_id: str
|
|
session_no: int
|
|
stage: StageLiteral
|
|
effective_openness: float
|
|
recall_summary: Optional[str] = None
|
|
degraded: bool = False # DB 미가용 in-proc 모드 여부(시연 투명성)
|
|
|
|
|
|
class TurnRequest(BaseModel):
|
|
text: str = Field(..., min_length=1) # 수련생 발화 (저장 전 PII 마스킹)
|
|
|
|
|
|
class TurnResponse(BaseModel):
|
|
turn_seq: int
|
|
stage: StageLiteral
|
|
effective_openness: float
|
|
client_reply: Optional[str] = None
|
|
safety_flagged: bool = False
|
|
crisis_kind: str = "none"
|
|
|
|
|
|
class SessionEndResponse(BaseModel):
|
|
session_id: str
|
|
session_no: int
|
|
digest_pending: bool # 압축은 비동기 비블로킹 (설계서 §2-C)
|
|
end_state: dict
|
|
|
|
|
|
# ════════════════════════════════════════════════════════════════════════════
|
|
# 회기 시작
|
|
# ════════════════════════════════════════════════════════════════════════════
|
|
@router.post("", response_model=SessionStartResponse, status_code=status.HTTP_201_CREATED)
|
|
async def start_session(
|
|
body: SessionStartRequest,
|
|
principal: DevPrincipal,
|
|
) -> SessionStartResponse:
|
|
"""회기 시작 — 페르소나 핀 + 회상 + 결정론 상태 init (설계서 §2-A).
|
|
|
|
DB 가용 시: persona_card(approved) 조회 + case_profile/직전 summary 회상.
|
|
DB 미가용(degraded): 시드 페르소나(persona.SEED) + 빈 회상(첫 회기)으로 in-proc.
|
|
"""
|
|
card = persona.get_seed_persona(body.persona_code)
|
|
if card is None:
|
|
# TODO: DB app.persona_card WHERE code=$1 AND status='approved' 조회 경로
|
|
raise HTTPException(status.HTTP_404_NOT_FOUND, detail=f"unknown persona {body.persona_code}")
|
|
|
|
# 회상 — DB/RAG 미가용 시 빈 컨텍스트(첫 회기). 가용 시 case_digest/summary/episodic 주입.
|
|
# TODO(Phase 2a): memory.build_recall_context(case_digest=..., prev_summary=..., episodic_snippets=...)
|
|
recall = memory.build_recall_context()
|
|
|
|
# 결정론 상태 init (carry-over 가 있으면 이월; 첫 회기는 None)
|
|
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(),
|
|
carry=recall.carry,
|
|
)
|
|
|
|
sess = store.create(
|
|
learner_id=principal.user_id,
|
|
persona=card,
|
|
theory_mode=body.theory_mode,
|
|
state=st,
|
|
session_no=1,
|
|
)
|
|
# 회상 핀(pinned facts)을 세션에 묶어 둔다(턴마다 재조립). store 는 간단히 state 만 보유하므로
|
|
# recall_summary/pinned 는 in-proc 캐시로 별도 보관.
|
|
_RECALL_CACHE[sess.session_id] = recall
|
|
|
|
return SessionStartResponse(
|
|
session_id=sess.session_id,
|
|
case_id=sess.case_id,
|
|
session_no=sess.session_no,
|
|
stage=st.stage.value, # type: ignore[arg-type]
|
|
effective_openness=round(st.effective_openness, 4),
|
|
recall_summary=recall.recall_summary,
|
|
degraded=True, # 현재 in-proc 경로(DB 붙으면 False 분기)
|
|
)
|
|
|
|
|
|
# 회상 컨텍스트 in-proc 캐시 (회기 내 재사용, recall_context). DB 붙으면 session_state.recall_context.
|
|
_RECALL_CACHE: dict[str, memory.RecallContext] = {}
|
|
|
|
|
|
def _load_session_or_404(session_id: str):
|
|
sess = store.get(session_id)
|
|
if sess is None:
|
|
raise HTTPException(status.HTTP_404_NOT_FOUND, detail="session not found")
|
|
if sess.ended:
|
|
raise HTTPException(status.HTTP_409_CONFLICT, detail="session already ended")
|
|
return sess
|
|
|
|
|
|
# ════════════════════════════════════════════════════════════════════════════
|
|
# 턴 (동기 폴백 — 기본 UX 는 /stream)
|
|
# ════════════════════════════════════════════════════════════════════════════
|
|
@router.post("/{session_id}/turn", response_model=TurnResponse)
|
|
async def submit_turn(
|
|
session_id: str,
|
|
body: TurnRequest,
|
|
principal: DevPrincipal,
|
|
) -> TurnResponse:
|
|
"""수련생 발화 1턴 (마스터플랜 §2.2 / 설계서 §2-B).
|
|
|
|
오케스트레이터로 1~8단계 결정론 파이프라인 실행. 내담자 응답은 동기로 한 번에 받는다
|
|
(기본 UX 는 GET /stream 토큰 스트리밍; 이 경로는 폴백/테스트).
|
|
"""
|
|
sess = _load_session_or_404(session_id)
|
|
recall = _RECALL_CACHE.get(session_id) or memory.RecallContext()
|
|
|
|
ctx = orchestrator.prepare_turn(
|
|
session_id=session_id,
|
|
case_id=sess.case_id,
|
|
card=sess.persona,
|
|
state=sess.state,
|
|
learner_text=body.text,
|
|
recall_summary=recall.recall_summary,
|
|
pinned_facts=recall.pinned_facts,
|
|
recent_turns=sess.recent_turns(),
|
|
)
|
|
|
|
# 수련생 발화 로깅(② episodic 미러) — 마스킹본 저장
|
|
assert ctx.state_after is not None
|
|
store.append_turn(
|
|
session_id,
|
|
TurnRecord(
|
|
turn_seq=ctx.state_after.turn_seq,
|
|
speaker="counselor",
|
|
stage=ctx.state_after.stage.value,
|
|
text=body.text,
|
|
text_masked=ctx.learner_text_masked,
|
|
),
|
|
)
|
|
|
|
try:
|
|
result = await orchestrator.run_turn_generate(ctx, engine_client)
|
|
except EngineError as e:
|
|
raise HTTPException(status.HTTP_503_SERVICE_UNAVAILABLE, detail=f"engine unavailable: {e}")
|
|
|
|
# 내담자 응답 로깅 + 상태 체크포인트(① working UPSERT 미러)
|
|
if result.client_reply:
|
|
store.append_turn(
|
|
session_id,
|
|
TurnRecord(
|
|
turn_seq=result.turn_seq,
|
|
speaker="client",
|
|
stage=result.stage,
|
|
text=result.client_reply,
|
|
text_masked=result.client_reply, # 내담자 응답은 합성(원문PII 없음)
|
|
),
|
|
)
|
|
store.update_state(session_id, result.state_after)
|
|
|
|
return TurnResponse(
|
|
turn_seq=result.turn_seq,
|
|
stage=result.stage, # type: ignore[arg-type]
|
|
effective_openness=round(result.effective_openness, 4),
|
|
client_reply=result.client_reply,
|
|
safety_flagged=result.safety_flagged,
|
|
crisis_kind=result.crisis_kind,
|
|
)
|
|
|
|
|
|
# ════════════════════════════════════════════════════════════════════════════
|
|
# 스트림 (기본 UX — SSE 토큰)
|
|
# ════════════════════════════════════════════════════════════════════════════
|
|
@router.post("/{session_id}/stream")
|
|
async def stream_turn(
|
|
session_id: str,
|
|
body: TurnRequest,
|
|
principal: DevPrincipal,
|
|
):
|
|
"""수련생 발화 1턴을 받아 내담자 AI 응답을 SSE 토큰 스트림으로 흘린다.
|
|
|
|
- Cloudflare 100초 timeout 회피: settings.sse_heartbeat_seconds 마다 ping [R2]
|
|
- 오케스트레이터 run_turn_stream(가드레일·상태머신·페르소나·출력가드 적용)을 프록시
|
|
- 이벤트: token | ping | safety | done | error
|
|
"""
|
|
sess = _load_session_or_404(session_id)
|
|
recall = _RECALL_CACHE.get(session_id) or memory.RecallContext()
|
|
|
|
ctx = orchestrator.prepare_turn(
|
|
session_id=session_id,
|
|
case_id=sess.case_id,
|
|
card=sess.persona,
|
|
state=sess.state,
|
|
learner_text=body.text,
|
|
recall_summary=recall.recall_summary,
|
|
pinned_facts=recall.pinned_facts,
|
|
recent_turns=sess.recent_turns(),
|
|
)
|
|
assert ctx.state_after is not None
|
|
|
|
# 수련생 발화 로깅 + 상태 체크포인트(스트림은 응답 전 상태 갱신 — 결정론이라 무방)
|
|
store.append_turn(
|
|
session_id,
|
|
TurnRecord(
|
|
turn_seq=ctx.state_after.turn_seq,
|
|
speaker="counselor",
|
|
stage=ctx.state_after.stage.value,
|
|
text=body.text,
|
|
text_masked=ctx.learner_text_masked,
|
|
),
|
|
)
|
|
store.update_state(session_id, ctx.state_after)
|
|
|
|
async def event_generator():
|
|
last_beat = asyncio.get_event_loop().time()
|
|
final_reply = ""
|
|
try:
|
|
async for ev in orchestrator.run_turn_stream(ctx, engine_client):
|
|
if ev.event == "token":
|
|
final_reply += ev.data.get("text", "")
|
|
yield {"event": ev.event, "data": json.dumps(ev.data, ensure_ascii=False)}
|
|
now = asyncio.get_event_loop().time()
|
|
if now - last_beat >= settings.sse_heartbeat_seconds:
|
|
yield {"event": "ping", "data": "{}"}
|
|
last_beat = now
|
|
except Exception as e: # 방어 — 어떤 예외도 SSE error 프레임으로
|
|
yield {"event": "error", "data": json.dumps({"detail": str(e)}, ensure_ascii=False)}
|
|
return
|
|
# 내담자 응답 로깅(② episodic) — 스트림 종료 후
|
|
if final_reply:
|
|
store.append_turn(
|
|
session_id,
|
|
TurnRecord(
|
|
turn_seq=ctx.state_after.turn_seq,
|
|
speaker="client",
|
|
stage=ctx.state_after.stage.value,
|
|
text=final_reply,
|
|
text_masked=final_reply,
|
|
),
|
|
)
|
|
|
|
return EventSourceResponse(event_generator())
|
|
|
|
|
|
# ════════════════════════════════════════════════════════════════════════════
|
|
# 회기 종료
|
|
# ════════════════════════════════════════════════════════════════════════════
|
|
@router.post("/{session_id}/end", response_model=SessionEndResponse)
|
|
async def end_session(
|
|
session_id: str,
|
|
principal: DevPrincipal,
|
|
) -> SessionEndResponse:
|
|
"""회기 종료 — 무손실 carry-over + 압축 트리거 (설계서 §2-C, 비동기 비블로킹).
|
|
|
|
(A) 무손실 carry-over: end_state = state.snapshot() (코드 복사, LLM 미경유) [P4]
|
|
(C) narrative 압축(LLM)은 CompressionJob 으로 큐잉(여기선 페이로드만; 실제 호출은 후속 워커)
|
|
"""
|
|
sess = store.get(session_id)
|
|
if sess is None:
|
|
raise HTTPException(status.HTTP_404_NOT_FOUND, detail="session not found")
|
|
|
|
recall = _RECALL_CACHE.get(session_id) or memory.RecallContext()
|
|
carry = memory.make_carry_over(
|
|
state=sess.state,
|
|
session_id=session_id,
|
|
case_id=sess.case_id,
|
|
session_no=sess.session_no,
|
|
masked_turns=sess.masked_turns(),
|
|
prev_rapport_credit=sess.prev_rapport_credit,
|
|
open_threads=recall.open_threads,
|
|
)
|
|
|
|
# TODO(Phase 2a): BackgroundTasks 로 carry.compression_job 을
|
|
# engine_client.generate(GenerateRequest(ai_role='evaluator', tier='feedback',
|
|
# messages=memory.build_compression_messages(job))) 호출 → session_summary UPSERT + 임베딩.
|
|
# 현재는 큐잉만(digest_pending=True). DB 없으면 압축 결과 적재 생략.
|
|
|
|
store.end(session_id)
|
|
_RECALL_CACHE.pop(session_id, None)
|
|
|
|
return SessionEndResponse(
|
|
session_id=session_id,
|
|
session_no=sess.session_no,
|
|
digest_pending=carry.compression_job is not None,
|
|
end_state=carry.end_state,
|
|
)
|