interactive-chat / engine /router.py
Junhoee's picture
Upload 140 files
c609294 verified
Raw
History Blame Contribute Delete
12 kB
"""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)
@dataclass
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,
}
# ── μ„Έμ…˜ ────────────────────────────────────────────────────
@app.post("/api/session")
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}
@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 = _pager.complete(ps.engine, 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, 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)}
# ── κ°œμž… 포켓 ───────────────────────────────────────────────
@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.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": "μž λ“€μ§€ μ•ŠλŠ” λ°€ Β· λ„μž… 아크",
"intro_images": INTRO_IMAGES}
# ── 정적 ν”„λ‘ νŠΈ ─────────────────────────────────────────────
@app.get("/")
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")