Spaces:
Running
Running
| """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) | |
| 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, | |
| } | |
| # ββ μΈμ ββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| 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} | |
| async def get_session(sid: str): | |
| return {"step": _step_json(_get(sid))} | |
| # ββ λ¦¬λ© ββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| 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)} | |
| 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)} | |
| # ββ κ°μ ν¬μΌ βββββββββββββββββββββββββββββββββββββββββββββββ | |
| 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} | |
| 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} | |
| 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} | |
| # ββ λ©ν (νμ§ λ± νλ μ ν μ΄μ μμ) ββββββββββββββββββββββββ | |
| async def meta(): | |
| return {"cover_image": COVER_IMAGE, "title": "μΉ΄λ₯΄λ°λΌ", "subtitle": "μ λ€μ§ μλ λ°€ Β· λμ μν¬"} | |
| # ββ μ μ νλ‘ νΈ βββββββββββββββββββββββββββββββββββββββββββββ | |
| 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") | |