feat: P1 풀빌드 — React 프론트 7화면 + 백엔드 상담루프·평가·음성·RAG

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턴 — 좋은/나쁜 상담에 차등 반응 실증
This commit is contained in:
Yun Chan 2026-06-25 23:37:22 +09:00
parent 859ab26314
commit 24b1b7a6e1
84 changed files with 19645 additions and 107 deletions

View file

@ -1,13 +1,15 @@
"""상담 세션 라우트 — 시작 / 턴 / 종료 + SSE 스트림 스텁.
"""상담 세션 라우트 — 시작 / 턴 / 스트림 / 종료 (services 실호출).
흐름 (설계서 §2 회기 라이프사이클 + 마스터플랜 §2.2 사이클):
POST /sessions 회기 시작 (case_profile/summary 회상 + session_state 초기화)
POST /sessions/{id}/turn 수련생 발화 1 (가드레일상태머신내담자AI평가)
GET /sessions/{id}/stream 내담자 AI 응답 SSE 스트림 (Cloudflare 우회 heartbeat)
POST /sessions/{id}/end 회기 종료 (무손실 carry-over + LLM 압축 트리거)
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).
파일은 핸들러 시그니처 + 계약 + TODO. 실제 상태머신/가드레일/압축은 Phase 1~2a 트랙 A.
"""
from __future__ import annotations
@ -15,19 +17,41 @@ from __future__ import annotations
import asyncio
import json
from typing import Annotated, Literal, Optional
from uuid import UUID, uuid4
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 CurrentPrincipal, HumanDB
from ..engine_client import EngineMessage, StreamRequest, engine_client, EngineError
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"])
Stage = Literal["라포", "탐색", "개입", "정리"]
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)]
# ── 요청/응답 모델 ──────────────────────────────────────
@ -37,12 +61,13 @@ class SessionStartRequest(BaseModel):
class SessionStartResponse(BaseModel):
session_id: UUID
case_id: UUID
session_id: str
case_id: str
session_no: int
stage: Stage
# 회기 시작 회상 요약 (큰그림→세부, UI 카드용. CCD/정답은 절대 미포함)
stage: StageLiteral
effective_openness: float
recall_summary: Optional[str] = None
degraded: bool = False # DB 미가용 in-proc 모드 여부(시연 투명성)
class TurnRequest(BaseModel):
@ -51,138 +76,269 @@ class TurnRequest(BaseModel):
class TurnResponse(BaseModel):
turn_seq: int
stage: Stage
stage: StageLiteral
effective_openness: float
# 내담자 응답은 스트림(GET /stream)으로 받는 게 기본. 동기 응답은 폴백/테스트용.
client_reply: Optional[str] = None
safety_flagged: bool = False
crisis_kind: str = "none"
class SessionEndResponse(BaseModel):
session_id: UUID
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: CurrentPrincipal,
conn: HumanDB,
principal: DevPrincipal,
) -> SessionStartResponse:
"""회기 시작 — 회상 + 상태 복원 (설계서 §2-A).
"""회기 시작 — 페르소나 핀 + 회상 + 결정론 상태 init (설계서 §2-A).
절차:
1. persona_card(approved) 조회 + (persona_id, learner_id) -> case_profile upsert
2. case_digest + 직전 session_summary + episodic recall (Phase 2a, 1차는 단일회기)
3. session_state 초기화: stage='라포', carry-over (rapport×0.7, ideation 보수적 유지) [P2]
4. sessions insert
TODO: persona 조회/회상/상태머신 init 구현 (트랙 A). 현재 스텁 응답.
DB 가용 : persona_card(approved) 조회 + case_profile/직전 summary 회상.
DB 미가용(degraded): 시드 페르소나(persona.SEED) + 회상( 회기)으로 in-proc.
"""
# TODO: SELECT persona_id FROM app.persona_card WHERE code=$1 AND status='approved'
# TODO: init_session_state_from_history() — 결정론 carry-over
session_id = uuid4()
case_id = uuid4()
return SessionStartResponse(
session_id=session_id,
case_id=case_id,
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,
stage="라포",
recall_summary=None, # Phase 2a 회상 채움
)
# 회상 핀(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: UUID,
session_id: str,
body: TurnRequest,
principal: CurrentPrincipal,
conn: HumanDB,
principal: DevPrincipal,
) -> TurnResponse:
"""수련생 발화 1턴 (마스터플랜 §2.2 / 설계서 §2-B).
파이프라인 (전부 백엔드 결정론 게이트):
1. [입력 가드레일] Presidio PII 마스킹 + 위기분류(실제위기 vs 페르소나 연기) [R7/F-03]
2. [상태머신] effective_openness = clamp(stage.openness
+ rapport_credit*unlock_rate - resistance*decay, 0, 1) [P2, 결정론]
3. [모순 검사] pinned_fact locked 모순 -> 차단·재생성 (설계서 §2-B)
4. [내담자 AI] engine_client.stream/generate (CCD 직접노출 금지, Structured Outputs)
5. [출력 가드레일] 자살수단 차단, ideation_stage <= 3 상한 [R5]
6. [평가 AI] fast-loop 4차원 태깅 (deep-loop 단계전환/회기말)
7. [working 갱신] session_state UPSERT (체크포인트)
8. [로깅] turns insert + 임베딩 (재귀학습 원천)
TODO: 1~8 구현 (트랙 A). 현재 스텁: 발화 검증만.
오케스트레이터로 1~8단계 결정론 파이프라인 실행. 내담자 응답은 동기로 번에 받는다
(기본 UX GET /stream 토큰 스트리밍; 경로는 폴백/테스트).
"""
# TODO: load session_state, run guardrail + state machine deterministically
# 동기 응답은 폴백. 기본 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=0,
stage="라포",
effective_openness=0.15,
client_reply=None,
safety_flagged=False,
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,
)
@router.get("/{session_id}/stream")
async def stream_client_reply(
session_id: UUID,
principal: CurrentPrincipal,
# ════════════════════════════════════════════════════════════════════════════
# 스트림 (기본 UX — SSE 토큰)
# ════════════════════════════════════════════════════════════════════════════
@router.post("/{session_id}/stream")
async def stream_turn(
session_id: str,
body: TurnRequest,
principal: DevPrincipal,
):
"""내담자 AI 응답 SSE 스트림 (마스터플랜 §1.1 SSE 분리경로).
"""수련생 발화 1턴을 받아 내담자 AI 응답을 SSE 토큰 스트림으로 흘린다.
- Cloudflare 100 timeout 회피: settings.sse_heartbeat_seconds 마다 ping 이벤트 [R2]
- 게이트웨이 SSE(engine_client.stream) 프록시해 토큰을 재방출
- 이벤트: {event: "token"|"done"|"safety"|"ping", data: ...}
TODO: 게이트웨이와 StreamRequest 바디 결합(현재 stage 회상 컨텍스트 없이 placeholder).
상태머신 컨텍스트(L3 stage/openness) + 마스킹된 최근 N턴 주입.
- 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():
# heartbeat 와 엔진 스트림을 병행 (Cloudflare 버퍼링/타임아웃 회피)
last_beat = asyncio.get_event_loop().time()
# TODO: 실제 StreamRequest 조립 — session_state 에서 stage/openness/최근턴 로드
req = StreamRequest(
ai_role="client",
tier="client",
messages=[
EngineMessage(role="system", content="<persona L0~L2 cache_control 주입 TODO>", cache=True),
EngineMessage(role="user", content="<masked latest learner turn TODO>"),
],
)
final_reply = ""
try:
async for chunk in engine_client.stream(req):
yield {"event": "token", "data": chunk}
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
yield {"event": "done", "data": json.dumps({"session_id": str(session_id)})}
except EngineError as e:
yield {"event": "error", "data": json.dumps({"detail": str(e)})}
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: UUID,
principal: CurrentPrincipal,
conn: HumanDB,
session_id: str,
principal: DevPrincipal,
) -> SessionEndResponse:
"""회기 종료 — carry-over + 압축 트리거 (설계서 §2-C, 비동기 비블로킹).
"""회기 종료 — 무손실 carry-over + 압축 트리거 (설계서 §2-C, 비동기 비블로킹).
절차:
(A) 무손실 carry-over: end_state = session_state 종료 snapshot (코드 복사, LLM 미경유) [P4]
(B) salience 산출 -> 망각/유지
(C) narrative 압축 (LLM, 상주 claude -p 재사용) 비동기
(D~G) digest 임베딩 / case_profile 병합 / pinned 모순처리 / RAG 동기화
+ 상주 프로세스 회수 (말투표류 회기경계 차단)
TODO: (A) 동기 수행 (B~G) BackgroundTasks/큐로 비블로킹. 현재 스텁.
(A) 무손실 carry-over: end_state = state.snapshot() (코드 복사, LLM 미경유) [P4]
(C) narrative 압축(LLM) CompressionJob 으로 큐잉(여기선 페이로드만; 실제 호출은 후속 워커)
"""
# TODO: UPDATE app.sessions SET ended_at=now(); copy end_state; enqueue compression
return SessionEndResponse(session_id=session_id, session_no=1, digest_pending=True)
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,
)