Spaces:
Sleeping
Sleeping
| """FastAPI μλν¬μΈνΈ β HTTPλ§ λ΄λΉ (κ³μ½: docs/contract/demo_api.md). | |
| μμ¬ νλ¨μ μ λΆ servicesμ μμνλ€. μ¨μ μνλ μ΄λ€ μλ΅μλ μ£μ§ μλλ€. | |
| μ€ν: .venv/bin/uvicorn engine.router:app --port 8777 | |
| """ | |
| from __future__ import annotations | |
| 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.paging import Pager, PageView | |
| from engine.services.pocket import PocketService | |
| from engine.services.story import 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", # μ¬ν β κ°μ κΏμ λλ©΄ | |
| "E06-09": "/assets/μΈλ¬Ό/μΉ΄λ₯΄λ°λΌ κ°λ©΄.png", # λλ§μ λΉλ° β κ°μ₯ μνν λ€μ ν¨ | |
| # E05-02(μ±)Β·E06-10(μΉ¨μ€) μ»·μ μ κ±° β μ₯μ λ°°κ²½κ³Ό μ€λ³΅μ΄λΌ μ»· νμ΄μ§κ° κ΅°λλκΈ° (μ μ κ²°μ ) | |
| } | |
| # μΈνΈλ‘ μνμ€ β νμ§ "λ°€μ μ°λ€" λ€, μ μμμλ§ (μ΄μ΄ μ½κΈ°λ 건λλ) | |
| INTRO_IMAGES = [ | |
| "/assets/μ€λ Έλ§xμλ½_λ°©ν₯/κ°μ2.png", # μν μκ° | |
| "/assets/μ€λ Έλ§xμλ½_λ°©ν₯/κ°μ3.png", # λ±μ₯μΈλ¬Ό μκ° | |
| "/assets/νλμ΄ μ΄λ―Έμ§/2.png", # 1μ₯ μμ | |
| ] | |
| # νμ μ΄μ (λΉμ£ΌμΌ λ Έλ²¨ λ°ν β μ°Έκ³ μ°μΆ: νλμ΄ μ΄λ―Έμ§/7.png). character_dialogueμ | |
| # speaker κΈ°μ€. λ§€ν μλ νμ(νμΈΒ·λ°±μλΆμΈΒ·μ½μ₯μ λ±)λ μ΄λ¦νλ§ νμλλ€ | |
| SPEAKER_PORTRAIT = { | |
| "μΉ΄λ₯΄λ°λΌ": "/assets/μΈλ¬Ό/μΉ΄λ₯΄λ°λΌ.png", | |
| "μλ²μ§": "/assets/μΈλ¬Ό/μλ²μ§.png", | |
| # λ§λ΄Β·λ§λλͺ¨μμ €μ μ λ©΄ μ€ν μ΄μ§ λμμμ μ μΈ (μ μ κ²°μ β μμλΈ μ₯λ©΄μ | |
| # λ³Έλ¬Έ νλ¦μΌλ‘. μμ λ§€νλ μΆμ μ΄μμ). νμ μ μ¬κΈ° ν μ€λ‘ 볡μ | |
| } | |
| # μ₯μλ³ λ°°κ²½ (λΉμ£ΌμΌ λ Έλ²¨) β μΉ΄λ νκ·Έ `#μ₯μ/X` κΈ°μ€. λ§€ν μλ μ₯μ(μ μΒ·νλΒ·μμ¬ λ±)λ | |
| # μ§μ λ°°κ²½ μ μ§. μμ μ΄ μκΈ°λ©΄ μ¬κΈ° ν μ€ μΆκ° + scripts/sync_assets.py κ°±μ | |
| BACKGROUND_MAP = { | |
| "μΉ¨μ€": "/assets/λ°°κ²½/μΉ¨μ€.png", | |
| "λ§μ°¬ν": "/assets/λ°°κ²½/μλΉ.png", | |
| "μ²κΈΈ": "/assets/λ°°κ²½/μκ° μ².png", | |
| "ν΄μ": "/assets/λ°°κ²½/μ±.png", | |
| "νν": "/assets/λ°°κ²½/ννμ±λΉ.png", | |
| "μλ°°λΉ": "/assets/λ°°κ²½/ννμ±λΉ.png", | |
| } | |
| app = FastAPI(title="carmilla-demo") | |
| _repo = ContentRepository(ROOT / "content") | |
| # νμ΄μ§ λ³ν© β μ¬λ¬ μΉ΄λλ₯Ό ν νλ©΄μΌλ‘ (λ°λ: chapter.page_target_chars). | |
| # μ°μΆ μ»·Β·μ±ν μ΅μ»€λ λ¨λ νμ΄μ§. μ»·λ νμ 1νμ΄μ§(νλ‘ νΈκ° μ΄λ―Έμ§ μΊλ²μ€ λ¨κ³λ‘ λ λ β | |
| # λ¨λ μΊ‘μ νλ©΄ μμ). νν°μ μ κ²°μ μ (κ²½λ‘ λ¬΄κ΄) | |
| _CUT_IDS = {cid for cid in IMAGE_MAP if _repo.has_card(cid)} | |
| _SOLO_IDS = _CUT_IDS | {c.id for c in _repo.all_cards() if c.can_chat} | |
| _pager = Pager(_repo, solo_ids=_SOLO_IDS, cut_ids=set()) | |
| def _partition_backgrounds() -> list[Optional[str]]: | |
| """νν°μ λ³ μ ν¨ λ°°κ²½ β μΉ΄λμ 첫 `#μ₯μ/` νκ·Έκ° λ§€νλλ©΄ κ°±μ , μλλ©΄ μ§μ λ°°κ²½ μ μ§. | |
| νν°μ μμ(μ μ)μ μμ ν¨μλΌ κ²½λ‘·볡μκ³Ό 무κ΄νκ² κ²°μ μ .""" | |
| bgs: list[Optional[str]] = [] | |
| current: Optional[str] = None | |
| for part in _pager.partitions: | |
| for cid in part: | |
| place = next((t.split("/", 1)[1] for t in _repo.get_card(cid).tags | |
| if t.startswith("#μ₯μ/")), None) | |
| if place and place in BACKGROUND_MAP: | |
| current = BACKGROUND_MAP[place] | |
| bgs.append(current) | |
| return bgs | |
| _PART_BG = _partition_backgrounds() | |
| _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: PageView | |
| 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=_pager.render_current(engine), | |
| 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: | |
| page = ps.last_step | |
| if page.ending is not None: | |
| return {"card": None, "narration": [], "choices": [], "can_chat": False, | |
| "image": None, "background": None, # μλ©μ λ°°κ²½μ κ±·μ΄λΈλ€ | |
| "progress": {"index": _pager.display_total, "total": _pager.display_total}, | |
| "ending": {"id": page.ending.id, "label": page.ending.label, | |
| "description": page.ending.description}} | |
| first, last = page.first, page.last | |
| return { | |
| # idλ νμ΄μ§ λ μΉ΄λ(μ νΒ·μ±ν Β·μ§ν κΈ°μ€), μ λͺ©Β·μνΌμλΒ·μ»·μ 첫 μΉ΄λ κΈ°μ€ | |
| "card": {"id": last.id, "title": first.title, "episode": first.episode_id, | |
| "episode_title": ps.episode_titles.get(first.episode_id, first.episode_id)}, | |
| "narration": [{"type": b.type, "text": b.text, "speaker": b.speaker, | |
| "role": b.role, "card": cid, | |
| "portrait": (SPEAKER_PORTRAIT.get(b.speaker) | |
| if b.type == "character_dialogue" else None)} | |
| for b, cid in zip(page.narration, page.narration_cards)], | |
| "choices": [{"label": c.label} for c in page.choices], | |
| "can_chat": page.can_chat, | |
| "image": IMAGE_MAP.get(first.id), | |
| "background": _PART_BG[_pager.part_of[last.id]], | |
| "progress": {"index": _pager.display_index(last.id), "total": _pager.display_total}, | |
| "ending": None, | |
| } | |
| # ββ μΈμ ββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| async def create_session(): | |
| engine = StoryEngine(_repo) | |
| ps = PlaySession(engine=engine, last_step=_pager.complete(engine, 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 = _pager.complete(ps.engine, 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, step = ps.engine.choose(body.index) | |
| ps.last_step = _pager.complete(ps.engine, step) | |
| _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.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": "μ λ€μ§ μλ λ°€ Β· λμ μν¬", | |
| "intro_images": INTRO_IMAGES} | |
| # ββ μ μ νλ‘ νΈ βββββββββββββββββββββββββββββββββββββββββββββ | |
| async def index(): | |
| # no-cache: λ§€ μ μλ§λ€ μ¬κ²μ¦ β λ°°ν¬ ν λΈλΌμ°μ κ° μ HTMLμ κ³μ 보μ¬μ£Όλ μ¬κ³ λ°©μ§ | |
| return FileResponse(ROOT / "web" / "index.html", | |
| headers={"Cache-Control": "no-cache"}) | |
| app.mount("/static", StaticFiles(directory=ROOT / "web"), name="static") | |
| app.mount("/assets", StaticFiles(directory=ASSET_DIR), name="assets") | |