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

205
apps/api/app/routes/eval.py Normal file
View file

@ -0,0 +1,205 @@
"""평가 라우트 — 교수자/관리자용 평가 조회 + 재평가 트리거 (Features: evaluator).
services/evaluator.py 2-loop 평가(fast/deep) 교수자(TEACHER)·관리자(ADMIN)에게 노출한다.
학습자(LEARNER)에겐 평가 결과가 직접 노출되지 않는다(설계서 §4.1 2-레이어: RBAC × AIView).
라우터는 RBAC(레이어2) 강제한다 require_role(TEACHER, ADMIN). 평가 AI 전부 봐도 되므로
(레이어1 AIView.EVALUATOR) CCD/정답 누설 걱정은 client AI 책임이고 여기선 무관.
엔드포인트:
GET /eval/health 헬스(소유 트랙 전환 확인)
POST /eval/sessions/{id}/turn 단일 fast-loop 재평가 트리거
POST /eval/sessions/{id}/reevaluate 회기 deep-loop 재평가 트리거(전체 축어록)
GET /eval/sessions/{id}/evaluation 회기 평가 조회(분포 + 최근 deep 결과)
DB(feedback_scores/supervisor_comment) SoR 적재는 Phase 2. 현재는 in-proc store + 엔진 직접 호출
(degraded). DB 붙으면 조회 경로를 turns.evaluation / supervisor_comment 조인으로 교체한다.
"""
from __future__ import annotations
from typing import Annotated, Any, Optional
from fastapi import APIRouter, Depends, HTTPException, status
from pydantic import BaseModel, Field
from ..deps import Principal, Role, require_role
from ..engine_client import EngineError, engine_client
from ..services import evaluator
from ..services.evaluator import SessionEvaluation, TurnEvaluation
from ..store import store
router = APIRouter(prefix="/eval", tags=["eval"])
# 교수자/관리자만 평가 조회·트리거 (학습자 비노출)
TeacherOrAdmin = Annotated[Principal, Depends(require_role(Role.TEACHER, Role.ADMIN))]
# ── 요청/응답 모델 ──────────────────────────────────────
class ReevaluateRequest(BaseModel):
scope: str = Field("session_end", description="'session_end' | 'stage_transition'")
class TurnReevaluateRequest(BaseModel):
turn_seq: int = Field(..., ge=0, description="재평가할 상담자 발화의 turn_seq")
class EvaluationSummary(BaseModel):
"""회기 평가 조회 응답(분포 + deep 결과 합본)."""
session_id: str
stage: str
deep: Optional[dict[str, Any]] = None
distribution: dict[str, Any] = Field(default_factory=dict)
# ── in-proc 평가 결과 캐시 (DB 적재 전 degraded 보관) ───────────────────────
# DB 가 붙으면 turns.evaluation / supervisor_comment 로 대체. 지금은 트리거 결과를 보관해
# 조회 GET 이 재호출 없이 마지막 deep 결과를 돌려주게 한다.
_DEEP_CACHE: dict[str, SessionEvaluation] = {}
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")
return sess
def _theory_mode_of(sess) -> Optional[str]:
tt = getattr(sess.persona, "theory_target", None)
if isinstance(tt, (list, tuple)) and tt:
return ", ".join(str(x) for x in tt)
# store 가 theory_mode 문자열도 보유(InProcSession.theory_mode)
return getattr(sess, "theory_mode", None)
@router.get("/health")
async def eval_health() -> dict[str, str]:
"""평가 라우터 헬스 — Features:evaluator 로 전환됨."""
return {"status": "ok", "owner": "features:evaluator", "loops": "fast,deep"}
# ════════════════════════════════════════════════════════════════════════════
# 회기 deep-loop 재평가 트리거 (교수자/관리자)
# ════════════════════════════════════════════════════════════════════════════
@router.post("/sessions/{session_id}/reevaluate", response_model=SessionEvaluation)
async def reevaluate_session(
session_id: str,
body: ReevaluateRequest,
principal: TeacherOrAdmin,
) -> SessionEvaluation:
"""회기 전체 deep-loop 재평가(슈퍼바이저 rationale/critique + 개선점 + 대안발화).
in-proc store 마스킹 축어록을 evaluator.evaluate_session 으로 평가한다.
엔진 장애는 503 으로 변환(평가는 비치명적이지만 트리거는 사용자 명시 요청이라 에러 노출).
"""
sess = _load_session_or_404(session_id)
masked = sess.masked_turns()
# 발화 seq 보강(deep 프롬프트 가독성 — store 가 seq 미포함이라 인덱스로 부여)
enriched: list[dict[str, Any]] = []
for i, t in enumerate(masked):
item = dict(t)
item.setdefault("seq", i)
enriched.append(item)
# 누적 기법 코드 — DB 미가용이라 fast 결과가 없으면 빈 분포(deep LLM 정성 평가는 그대로 유효).
technique_codes: list[str] = []
try:
result = await evaluator.evaluate_session(
session_id=session_id,
stage=sess.state.stage.value,
masked_turns=enriched,
engine=engine_client,
technique_codes=technique_codes,
theory_mode=_theory_mode_of(sess),
scope=body.scope if body.scope in ("session_end", "stage_transition") else "session_end",
)
except EngineError as e:
raise HTTPException(status.HTTP_503_SERVICE_UNAVAILABLE, detail=f"engine unavailable: {e}")
if result.error and result.error.startswith("engine_error"):
raise HTTPException(status.HTTP_503_SERVICE_UNAVAILABLE, detail=result.error)
_DEEP_CACHE[session_id] = result
return result
# ════════════════════════════════════════════════════════════════════════════
# 단일 턴 fast-loop 재평가 트리거 (교수자/관리자)
# ════════════════════════════════════════════════════════════════════════════
@router.post("/sessions/{session_id}/turn", response_model=TurnEvaluation)
async def reevaluate_turn(
session_id: str,
body: TurnReevaluateRequest,
principal: TeacherOrAdmin,
) -> TurnEvaluation:
"""단일 상담자 발화 fast-loop 재평가(기법/내담자상태/적절성/의도이탈).
store 축어록에서 해당 turn_seq 상담자 발화 + 직후 내담자 응답을 재구성해
경량 TurnContext evaluator.evaluate_turn 호출한다.
"""
sess = _load_session_or_404(session_id)
# 대상 상담자 발화 + 직후 내담자 응답 찾기
target_idx: Optional[int] = None
for i, tr in enumerate(sess.turns):
if tr.speaker == "counselor" and tr.turn_seq == body.turn_seq:
target_idx = i
break
if target_idx is None:
raise HTTPException(
status.HTTP_404_NOT_FOUND, detail=f"counselor turn_seq {body.turn_seq} not found"
)
learner = sess.turns[target_idx]
client_reply = ""
if target_idx + 1 < len(sess.turns) and sess.turns[target_idx + 1].speaker == "client":
client_reply = sess.turns[target_idx + 1].text_masked
# 평가용 경량 TurnContext 재구성(prepare_turn 의 결정론 산출과 동형). 엔진 호출 없음.
from ..services.orchestrator import TurnContext # 지연 import(소유권 경계)
recent = [
{"speaker": tr.speaker, "text": tr.text_masked} for tr in sess.turns[max(0, target_idx - 4):target_idx]
]
ctx = TurnContext(
session_id=session_id,
case_id=sess.case_id,
persona=sess.persona,
state_before=sess.state,
learner_text_raw=learner.text,
learner_text_masked=learner.text_masked,
state_after=sess.state, # 조회 시점 상태(정밀 재현은 DB 스냅샷 도입 시)
recent_turns=recent,
)
result = await evaluator.evaluate_turn(ctx, client_reply, engine=engine_client)
if result.error and result.error.startswith("engine_error"):
raise HTTPException(status.HTTP_503_SERVICE_UNAVAILABLE, detail=result.error)
return result
# ════════════════════════════════════════════════════════════════════════════
# 회기 평가 조회 (교수자/관리자) — 마지막 deep 결과 + 분포
# ════════════════════════════════════════════════════════════════════════════
@router.get("/sessions/{session_id}/evaluation", response_model=EvaluationSummary)
async def get_session_evaluation(
session_id: str,
principal: TeacherOrAdmin,
) -> EvaluationSummary:
"""회기 평가 조회(읽기) — 마지막 deep 재평가 결과 + 기법 분포.
DB 적재 degraded: deep 결과는 reevaluate 트리거가 보관한 캐시에서, 분포는 결과에서.
아직 평가 트리거가 없었다면 deep=None + 분포.
"""
_load_session_or_404(session_id)
cached = _DEEP_CACHE.get(session_id)
if cached is None:
return EvaluationSummary(session_id=session_id, stage="", deep=None, distribution={})
return EvaluationSummary(
session_id=session_id,
stage=cached.stage,
deep=cached.to_dict(),
distribution=cached.distribution.model_dump(),
)