interactive-chat / engine /router.py
Junhoee's picture
Upload 98 files
f646e0b verified
Raw
History Blame Contribute Delete
9.37 kB
"""FastAPI μ—”λ“œν¬μΈνŠΈ β€” HTTP만 λ‹΄λ‹Ή (계약: docs/contract/demo_api.md).
μ„œμ‚¬ νŒλ‹¨μ€ μ „λΆ€ services에 μœ„μž„ν•œλ‹€. μˆ¨μ€ μƒνƒœλŠ” μ–΄λ–€ 응닡에도 μ‹£μ§€ μ•ŠλŠ”λ‹€.
μ‹€ν–‰: .venv/bin/uvicorn engine.router:app --port 8777
"""
from __future__ import annotations
import bisect
import uuid
from dataclasses import dataclass, field
from pathlib import Path
from typing import Optional
from fastapi import FastAPI, HTTPException
from fastapi.responses import FileResponse
from fastapi.staticfiles import StaticFiles
from pydantic import BaseModel
from engine.plugins.logging import TurnLoggingPlugin
from engine.repositories.content import ContentRepository
from engine.repositories.play_session import PlaySessionRepository
from engine.repositories.playlog import PlaylogRepository
from engine.services.pocket import PocketService
from engine.services.story import StepResult, StoryEngine
ROOT = Path(__file__).resolve().parent.parent
# μ—°μΆœ 이미지 (λŒ€ν‘œλ‹˜ μ‚°μΆœ 에셋 β€” 읽기 μ „μš©). 맀핑은 μ—°μΆœ κ²°μ •μ΄λ―€λ‘œ μ—¬κΈ°(ν”„λ ˆμ  ν…Œμ΄μ…˜ 계측)에 λ‘”λ‹€.
# 데λͺ¨/배포용으둜 IMAGE_MAP이 μ“°λŠ” 6μž₯만 web/assets/에 슬림 볡사 (원본 95MB ν΄λ”λŠ” λŸ°νƒ€μž„ λΆˆν•„μš”).
# 원본 κ°±μ‹  μ‹œ: scripts/sync_assets.py μž¬μ‹€ν–‰ (μ›μ²œμ€ μΉ΄λ₯΄λ°€λΌ/…/이미지/)
ASSET_DIR = ROOT / "web" / "assets"
COVER_IMAGE = "/assets/ν”Œλž˜μ΄ 이미지/1.png"
IMAGE_MAP = {
"E01-07": "/assets/ν”Œλž˜μ΄ 이미지/4.png", # 6μ„Έμ˜ λ°€ β€” "κ·Έλ…€λŠ” 미동도 없이 λ‚˜λ₯Ό λ‚΄λ €λ‹€λ³΄μ•˜λ‹€"
"E03-03": "/assets/인물/μΉ΄λ₯΄λ°€λΌ.png", # 재회 β€” 같은 꿈의 λŒ€λ©΄
"E05-02": "/assets/λ°°κ²½/μ„±.png", # μ΄ˆμƒν™”μ˜ λ°€ 무렡 β€” κ³ μ„±
"E06-09": "/assets/인물/μΉ΄λ₯΄λ°€λΌ κ°€λ©΄.png", # λ‘˜λ§Œμ˜ λΉ„λ°€ β€” κ°€μž₯ μœ„ν—˜ν•œ 닀정함
"E06-10": "/assets/λ°°κ²½/μΉ¨μ‹€.png", # 도달점 β€” 두 점 발견
}
app = FastAPI(title="carmilla-demo")
_repo = ContentRepository(ROOT / "content")
# μ—°μΆœ 컷은 독립 νŽ˜μ΄μ§€λ‘œ μ„Όλ‹€ (ν‘œμ‹œμš© 번호 β€” ν”„λ ˆμ  ν…Œμ΄μ…˜ 결정이라 IMAGE_MAPκ³Ό 같은 계측).
# 예: 6(λ³Έλ¬Έ) β†’ 7(이미지) β†’ 8(λ³Έλ¬Έ). total = μΉ΄λ“œ 수 + 이미지 수
_IMAGE_ORDERS = sorted(_repo.order(cid) for cid in IMAGE_MAP if _repo.has_card(cid))
_TOTAL_PAGES = _repo.total_cards + len(_IMAGE_ORDERS)
def _page_index(card_id: str) -> int:
"""μΉ΄λ“œ λ³Έλ¬Έ νŽ˜μ΄μ§€μ˜ ν‘œμ‹œ 번호 (μ•žμ„  이미지 νŽ˜μ΄μ§€λ“€ + 자기 이미지 νŽ˜μ΄μ§€λ§ŒνΌ λ°€λ¦Ό).
이미지 μΉ΄λ“œμ˜ 이미지 νŽ˜μ΄μ§€ λ²ˆν˜ΈλŠ” 이 κ°’ - 1 (ν΄λΌμ΄μ–ΈνŠΈκ°€ μœ λ„)."""
order = _repo.order(card_id)
images_before = bisect.bisect_left(_IMAGE_ORDERS, order)
own_image = 1 if card_id in IMAGE_MAP else 0
return order + 1 + images_before + own_image
_pocket = PocketService(
_repo, plugins=[TurnLoggingPlugin(PlaylogRepository(ROOT / "logs" / "playlog.jsonl"))])
_playdb = PlaySessionRepository(ROOT / "data" / "sessions.db")
_playdb.purge(days=30)
@dataclass
class PlaySession:
engine: StoryEngine
last_step: StepResult
pocket_sid: Optional[str] = None
episode_titles: dict = field(default_factory=dict)
_sessions: dict[str, PlaySession] = {}
class ChooseBody(BaseModel):
index: int
class TurnBody(BaseModel):
text: str
def _get(sid: str) -> PlaySession:
ps = _sessions.get(sid)
if ps is None:
ps = _restore(sid) # μ„œλ²„ μž¬μ‹œμž‘ ν›„ β€” μ €μž₯μ†Œμ—μ„œ 볡원
if ps is None:
raise HTTPException(404, "μ„Έμ…˜ μ—†μŒ")
return ps
def _restore(sid: str) -> Optional[PlaySession]:
"""μ €μž₯ μŠ€λƒ…μƒ·μ—μ„œ μ„Έμ…˜ 볡원. 포켓은 폐기(λΉ„μ˜μ† β€” μœ μ € κ²°μ •), 읽기 ν™”λ©΄λΆ€ν„°."""
payload = _playdb.load(sid, _repo.content_version)
if payload is None:
return None
engine = StoryEngine.restore(_repo, payload)
ps = PlaySession(engine=engine, last_step=engine.current_step(),
episode_titles=_episode_titles())
_sessions[sid] = ps
return ps
def _save(sid: str, ps: PlaySession) -> None:
"""write-through β€” μ˜μ† μƒνƒœκ°€ λ°”λ€ŒλŠ” λͺ¨λ“  μ§€μ μ—μ„œ μ¦‰μ‹œ μ €μž₯."""
_playdb.save(sid, ps.engine.snapshot(), _repo.content_version)
def _step_json(ps: PlaySession) -> dict:
step = ps.last_step
if step.ending is not None:
return {"card": None, "narration": [], "choices": [], "can_chat": False,
"image": None,
"progress": {"index": _TOTAL_PAGES, "total": _TOTAL_PAGES},
"ending": {"id": step.ending.id, "label": step.ending.label,
"description": step.ending.description}}
card = step.card
return {
"card": {"id": card.id, "title": card.title, "episode": card.episode_id,
"episode_title": ps.episode_titles.get(card.episode_id, card.episode_id)},
"narration": [{"type": b.type, "text": b.text, "speaker": b.speaker}
for b in step.narration],
"choices": [{"label": c.label} for c in step.choices],
"can_chat": card.can_chat,
"image": IMAGE_MAP.get(card.id),
"progress": {"index": _page_index(card.id), "total": _TOTAL_PAGES},
"ending": None,
}
# ── μ„Έμ…˜ ────────────────────────────────────────────────────
@app.post("/api/session")
async def create_session():
engine = StoryEngine(_repo)
ps = PlaySession(engine=engine, last_step=engine.start(),
episode_titles=_episode_titles())
sid = uuid.uuid4().hex[:12]
_sessions[sid] = ps
_save(sid, ps)
return {"session_id": sid, "step": _step_json(ps)}
def _episode_titles() -> dict:
from engine.schemas.validate import load_content
episodes, *_ = load_content(_repo.root)
return {ep.episode.id: ep.episode.title for ep in episodes}
@app.get("/api/session/{sid}")
async def get_session(sid: str):
return {"step": _step_json(_get(sid))}
# ── 리딩 ────────────────────────────────────────────────────
@app.post("/api/session/{sid}/advance")
async def advance(sid: str):
ps = _get(sid)
if ps.pocket_sid:
raise HTTPException(409, "포켓 λŒ€ν™” 쀑 β€” λ¨Όμ € 닫을 것")
if ps.last_step.ending or ps.last_step.choices:
raise HTTPException(409, "advance λΆˆκ°€ μƒνƒœ")
ps.last_step = ps.engine.advance()
_save(sid, ps)
return {"step": _step_json(ps)}
@app.post("/api/session/{sid}/choose")
async def choose(sid: str, body: ChooseBody):
ps = _get(sid)
if ps.pocket_sid:
raise HTTPException(409, "포켓 λŒ€ν™” 쀑 β€” λ¨Όμ € 닫을 것")
if ps.last_step.ending or not ps.last_step.choices:
raise HTTPException(409, "선택 λΆˆκ°€ μƒνƒœ")
if not 0 <= body.index < len(ps.last_step.choices):
raise HTTPException(422, "선택지 λ²”μœ„ λ°–")
picked, ps.last_step = ps.engine.choose(body.index)
_save(sid, ps)
return {"picked": {"label": picked.label, "result": picked.result},
"step": _step_json(ps)}
# ── κ°œμž… 포켓 ───────────────────────────────────────────────
@app.post("/api/session/{sid}/pocket/open")
async def pocket_open(sid: str):
ps = _get(sid)
if ps.pocket_sid:
return {"opened": True}
if ps.last_step.ending or not (ps.last_step.card and ps.last_step.card.can_chat):
raise HTTPException(409, "이 μΉ΄λ“œλŠ” μžμœ μ±„νŒ… 포켓이 μ•„λ‹˜")
ps.pocket_sid = await _pocket.open(ps.engine.current_id, ps.engine.state)
return {"opened": True}
@app.post("/api/session/{sid}/pocket/turn")
async def pocket_turn(sid: str, body: TurnBody):
ps = _get(sid)
if not ps.pocket_sid:
raise HTTPException(409, "포켓이 μ—΄λ € μžˆμ§€ μ•ŠμŒ")
turn = await _pocket.run_turn(ps.pocket_sid, body.text)
if turn.converged: # 수렴 β†’ μžλ™ 컀밋·볡귀
await _pocket.close(ps.pocket_sid, ps.engine.state)
ps.pocket_sid = None
_save(sid, ps) # 포켓 μ»€λ°‹μœΌλ‘œ μˆ¨μ€ μƒνƒœ λ³€κ²½
return {"reply": turn.reply, "converged": turn.converged,
"reason": turn.reason, "turn_no": turn.turn_no}
@app.post("/api/session/{sid}/pocket/close")
async def pocket_close(sid: str):
ps = _get(sid)
if ps.pocket_sid:
await _pocket.close(ps.pocket_sid, ps.engine.state)
ps.pocket_sid = None
_save(sid, ps)
return {"closed": True}
# ── 메타 (ν‘œμ§€ λ“± ν”„λ ˆμ  ν…Œμ΄μ…˜ μƒμˆ˜) ────────────────────────
@app.get("/api/meta")
async def meta():
return {"cover_image": COVER_IMAGE, "title": "μΉ΄λ₯΄λ°€λΌ", "subtitle": "μž λ“€μ§€ μ•ŠλŠ” λ°€ Β· λ„μž… 아크"}
# ── 정적 ν”„λ‘ νŠΈ ─────────────────────────────────────────────
@app.get("/")
async def index():
return FileResponse(ROOT / "web" / "index.html")
app.mount("/static", StaticFiles(directory=ROOT / "web"), name="static")
app.mount("/assets", StaticFiles(directory=ASSET_DIR), name="assets")