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

@ -12,9 +12,11 @@ import asyncio
import json
import os
import uuid
from typing import Any, Literal, Optional
from fastapi import FastAPI, HTTPException
from pydantic import BaseModel
from fastapi.responses import StreamingResponse
from pydantic import BaseModel, Field
CLAUDE_BIN = os.environ.get("CLAUDE_BIN", "claude")
DEFAULT_MODEL = os.environ.get("ENGINE_MODEL", "") # 비우면 CLI 기본(Opus 4.8)
@ -99,6 +101,68 @@ class EngineSession:
"error": (result.get("errors") or [None])[0],
}
async def turn_stream(self, content: str, timeout: float = 600.0):
"""한 턴을 *증분 텍스트 청크*로 흘려보낸다 (SSE /v1/stream 용).
기존 turn() result 까지 모아 번에 text 반환한다. 스트림 경로는
assistant 텍스트 델타가 도착하는 즉시 yield 해야 토큰 스트리밍 UX 산다.
마지막에 {"__done__": ...} 메타(누적 cost/turns) yield 한다.
주의: claude -p stream-json assistant 메시지는 청크가 전체 누적본일 수도,
델타일 수도 있어 게이트웨이가 *이미 보낸 접두 길이* 추적해 신규분만 흘린다.
"""
if self.proc is None or self.proc.returncode is not None:
raise RuntimeError("engine session not running")
async with self.lock:
msg = json.dumps(
{"type": "user", "message": {"role": "user", "content": content}},
ensure_ascii=False,
)
self.proc.stdin.write((msg + "\n").encode("utf-8"))
await self.proc.stdin.drain()
emitted = "" # 이미 흘려보낸 누적 텍스트 (델타 산출 기준)
async def _next_line():
return await asyncio.wait_for(self.proc.stdout.readline(), timeout=timeout)
while True:
line = await _next_line()
if not line:
break
try:
obj = json.loads(line)
except json.JSONDecodeError:
continue
t = obj.get("type")
if t == "assistant":
# 이번 메시지의 텍스트 전체를 재구성
full = "".join(
c.get("text", "")
for c in obj.get("message", {}).get("content", [])
if c.get("type") == "text"
)
if full.startswith(emitted):
delta = full[len(emitted):]
else:
# 누적이 아니라 메시지 분절 → 통째로 추가분
delta = full
if delta:
emitted += delta if full.startswith(emitted) else full
yield {"type": "delta", "text": delta}
elif t == "result":
self.cost_usd = obj.get("total_cost_usd", self.cost_usd)
self.turns += 1
yield {
"type": "done",
"text": emitted,
"cost_usd": self.cost_usd,
"turns": self.turns,
"is_error": obj.get("is_error", False),
"error": (obj.get("errors") or [None])[0],
}
return
async def close(self) -> None:
if self.proc and self.proc.returncode is None:
try:
@ -151,3 +215,163 @@ async def close_session(sid: str):
if s:
await s.close()
return {"closed": bool(s)}
# ════════════════════════════════════════════════════════════════════════════
# stateless 어댑터 — apps/api/app/engine_client.py 의 계약과 정합
# POST /v1/generate : EngineMessage[] → 1턴 생성 → GenerateResponse
# POST /v1/stream : 같은 입력 → SSE(text/event-stream) 토큰 스트림
# 상주 풀(/session 계열)은 그대로 유지하고, 단발/스트림은 아래가 흡수한다.
# session_id 가 오면 풀을 재사용해 멀티턴 prompt caching 이점을 살린다.
# ════════════════════════════════════════════════════════════════════════════
AIRole = Literal["client", "counselor", "evaluator"]
class GwMessage(BaseModel):
role: Literal["system", "user", "assistant"]
content: str
cache: bool = False # 프롬프트 캐싱 힌트 (L0~L2 cache_control 대상)
class GwGenerateReq(BaseModel):
ai_role: AIRole = "client"
messages: list[GwMessage]
tier: Literal["client", "feedback", "fast"] = "client"
model: Optional[str] = None
max_tokens: int = 1024
temperature: float = 0.7
structured_schema: Optional[dict[str, Any]] = None
session_id: Optional[str] = None # 풀 재사용 키(있으면 멀티턴 캐시)
metadata: dict[str, Any] = Field(default_factory=dict)
def _split_messages(messages: list[GwMessage]) -> tuple[str, str]:
"""EngineMessage[] → (system_prompt, user_payload).
- system 들은 합쳐서 --append-system-prompt 주입할 텍스트로.
- 마지막 user 발화를 이번 stdin content .
- 직전 assistant/user 히스토리는 (단발 모드라) system 뒤에 맥락으로 직렬화.
(상주 세션 재사용 시에는 풀이 이미 컨텍스트를 들고 있으므로 마지막 user 보냄.)
"""
system_parts: list[str] = []
history_parts: list[str] = []
last_user = ""
for m in messages:
if m.role == "system":
system_parts.append(m.content)
elif m.role == "assistant":
history_parts.append(f"[이전 상담자 발화]\n{m.content}")
elif m.role == "user":
if last_user:
history_parts.append(f"[이전 내담자 발화]\n{last_user}")
last_user = m.content
system_prompt = "\n\n".join(p for p in system_parts if p.strip())
return system_prompt, last_user
def _inject_schema(system_prompt: str, schema: Optional[dict[str, Any]]) -> str:
"""structured_schema 가 오면 system 프롬프트에 JSON 스키마 준수 지시를 주입.
게이트웨이 strict 강제는 후속(claude -p response_format 미지원). 현실적 차선.
"""
if not schema:
return system_prompt
directive = (
"\n\n[출력 형식 — 반드시 준수]\n"
"아래 JSON 스키마에 *정확히* 맞는 단일 JSON 객체만 출력한다. "
"설명·마크다운 코드펜스 없이 JSON 본문만.\n"
f"{json.dumps(schema, ensure_ascii=False)}"
)
return (system_prompt + directive) if system_prompt else directive.lstrip()
async def _resolve_session(req: GwGenerateReq, system_prompt: str) -> tuple[EngineSession, bool]:
"""session_id 가 있고 살아있으면 재사용, 아니면 단발용 임시 세션 생성.
반환: (session, ephemeral). ephemeral=True 호출부가 응답 close 한다.
"""
if req.session_id and req.session_id in SESSIONS:
s = SESSIONS[req.session_id]
if s.proc is not None and s.proc.returncode is None:
return s, False
# 단발(또는 죽은 세션) → 1회성 세션
s = EngineSession(system_prompt=system_prompt or None, budget=DEFAULT_BUDGET)
await s.start()
return s, True
@app.post("/v1/generate")
async def v1_generate(req: GwGenerateReq):
"""단발 생성 (평가 deep-loop, 회기종료 압축 등). GenerateResponse 호환 dict 반환."""
system_prompt, user_payload = _split_messages(req.messages)
system_prompt = _inject_schema(system_prompt, req.structured_schema)
if not user_payload:
raise HTTPException(400, "no user message in payload")
s, ephemeral = await _resolve_session(req, system_prompt)
try:
result = await s.turn(user_payload, timeout=120.0)
finally:
if ephemeral:
await s.close()
if result.get("is_error"):
raise HTTPException(502, f"engine turn error: {result.get('error')}")
structured: Optional[dict] = None
text = result.get("text", "")
if req.structured_schema:
try:
structured = json.loads(text)
except (json.JSONDecodeError, TypeError):
structured = None # 파싱 실패는 호출부가 text 로 폴백
return {
"text": text,
"model": DEFAULT_MODEL or "claude-opus-4-8",
"provider": "claude_cli",
"tokens_in": 0,
"tokens_out": 0,
"cost_usd": result.get("cost_usd", 0.0),
"inference_geo": "us",
"structured": structured,
}
@app.post("/v1/stream")
async def v1_stream(req: GwGenerateReq):
"""SSE 토큰 스트림. data: 라인으로 텍스트 델타를 흘리고 done/error 프레이밍."""
system_prompt, user_payload = _split_messages(req.messages)
system_prompt = _inject_schema(system_prompt, req.structured_schema)
if not user_payload:
raise HTTPException(400, "no user message in payload")
s, ephemeral = await _resolve_session(req, system_prompt)
async def _sse():
try:
async for evt in s.turn_stream(user_payload, timeout=600.0):
if evt.get("type") == "delta":
payload = json.dumps({"text": evt["text"]}, ensure_ascii=False)
yield f"event: token\ndata: {payload}\n\n"
elif evt.get("type") == "done":
if evt.get("is_error"):
err = json.dumps({"detail": str(evt.get("error"))}, ensure_ascii=False)
yield f"event: error\ndata: {err}\n\n"
else:
meta = json.dumps(
{"cost_usd": evt.get("cost_usd", 0.0), "turns": evt.get("turns", 0)},
ensure_ascii=False,
)
yield f"event: done\ndata: {meta}\n\n"
except Exception as e: # 전송 도중 실패도 SSE 프레임으로 알린다
err = json.dumps({"detail": str(e)}, ensure_ascii=False)
yield f"event: error\ndata: {err}\n\n"
finally:
if ephemeral:
await s.close()
return StreamingResponse(
_sse(),
media_type="text/event-stream",
headers={"Cache-Control": "no-cache", "X-Accel-Buffering": "no"},
)