Junhoee commited on
Commit
f646e0b
Β·
verified Β·
1 Parent(s): 47fdced

Upload 98 files

Browse files
This view is limited to 50 files because it contains too many changes. Β  See raw diff
Files changed (50) hide show
  1. engine/.adk/session.db +0 -0
  2. engine/__init__.py +0 -0
  3. engine/__pycache__/__init__.cpython-312.pyc +0 -0
  4. engine/__pycache__/__init__.cpython-314.pyc +0 -0
  5. engine/__pycache__/agent.cpython-312.pyc +0 -0
  6. engine/__pycache__/agent.cpython-314.pyc +0 -0
  7. engine/__pycache__/app.cpython-314.pyc +0 -0
  8. engine/__pycache__/model.cpython-312.pyc +0 -0
  9. engine/__pycache__/model.cpython-314.pyc +0 -0
  10. engine/__pycache__/router.cpython-314.pyc +0 -0
  11. engine/agent.py +50 -0
  12. engine/agents/__init__.py +0 -0
  13. engine/agents/__pycache__/__init__.cpython-312.pyc +0 -0
  14. engine/agents/__pycache__/__init__.cpython-314.pyc +0 -0
  15. engine/agents/__pycache__/input_guard.cpython-312.pyc +0 -0
  16. engine/agents/__pycache__/input_guard.cpython-314.pyc +0 -0
  17. engine/agents/__pycache__/pocket_context_loader.cpython-312.pyc +0 -0
  18. engine/agents/__pycache__/pocket_context_loader.cpython-314.pyc +0 -0
  19. engine/agents/input_guard.py +88 -0
  20. engine/agents/pocket_context_loader.py +99 -0
  21. engine/app.py +31 -0
  22. engine/callbacks/__init__.py +0 -0
  23. engine/instructions/README.md +7 -0
  24. engine/instructions/character_carmilla.md +23 -0
  25. engine/instructions/director.md +20 -0
  26. engine/model.py +20 -0
  27. engine/plugins/__init__.py +0 -0
  28. engine/plugins/__pycache__/__init__.cpython-314.pyc +0 -0
  29. engine/plugins/__pycache__/logging.cpython-314.pyc +0 -0
  30. engine/plugins/logging.py +71 -0
  31. engine/repositories/__init__.py +0 -0
  32. engine/repositories/__pycache__/__init__.cpython-312.pyc +0 -0
  33. engine/repositories/__pycache__/__init__.cpython-314.pyc +0 -0
  34. engine/repositories/__pycache__/content.cpython-312.pyc +0 -0
  35. engine/repositories/__pycache__/content.cpython-314.pyc +0 -0
  36. engine/repositories/__pycache__/play_session.cpython-314.pyc +0 -0
  37. engine/repositories/__pycache__/playlog.cpython-314.pyc +0 -0
  38. engine/repositories/content.py +89 -0
  39. engine/repositories/play_session.py +65 -0
  40. engine/repositories/playlog.py +21 -0
  41. engine/router.py +232 -0
  42. engine/schemas/__init__.py +0 -0
  43. engine/schemas/__pycache__/__init__.cpython-312.pyc +0 -0
  44. engine/schemas/__pycache__/__init__.cpython-314.pyc +0 -0
  45. engine/schemas/__pycache__/beatcard.cpython-312.pyc +0 -0
  46. engine/schemas/__pycache__/beatcard.cpython-314.pyc +0 -0
  47. engine/schemas/__pycache__/chapter.cpython-312.pyc +0 -0
  48. engine/schemas/__pycache__/chapter.cpython-314.pyc +0 -0
  49. engine/schemas/__pycache__/character.cpython-312.pyc +0 -0
  50. engine/schemas/__pycache__/character.cpython-314.pyc +0 -0
engine/.adk/session.db ADDED
Binary file (49.2 kB). View file
 
engine/__init__.py ADDED
File without changes
engine/__pycache__/__init__.cpython-312.pyc ADDED
Binary file (152 Bytes). View file
 
engine/__pycache__/__init__.cpython-314.pyc ADDED
Binary file (154 Bytes). View file
 
engine/__pycache__/agent.cpython-312.pyc ADDED
Binary file (2.53 kB). View file
 
engine/__pycache__/agent.cpython-314.pyc ADDED
Binary file (2.68 kB). View file
 
engine/__pycache__/app.cpython-314.pyc ADDED
Binary file (1.79 kB). View file
 
engine/__pycache__/model.cpython-312.pyc ADDED
Binary file (818 Bytes). View file
 
engine/__pycache__/model.cpython-314.pyc ADDED
Binary file (1.08 kB). View file
 
engine/__pycache__/router.cpython-314.pyc ADDED
Binary file (15.9 kB). View file
 
engine/agent.py ADDED
@@ -0,0 +1,50 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """루트 μ—μ΄μ „νŠΈ β€” κ°œμž… 포켓 ν„΄ νŒŒμ΄ν”„λΌμΈ 쑰립 (νŒ©ν† λ¦¬λŠ” 쑰립만 λ‹΄λ‹Ή).
2
+
3
+ pocket_session (SequentialAgent) β€” μœ μ € λ©”μ‹œμ§€ 1건 = 1회 μ‹€ν–‰
4
+ β”œβ”€β”€ pocket_context_loader ← BaseAgent: μΉ΄λ“œ λΈŒλ¦¬ν”„/κ²° 적재 (슀포일러 ν•„ν„°)
5
+ β”œβ”€β”€ input_guard ← BaseAgent: 정체직격/약점/메타 감지 β†’ μ§€μ‹œ 생성
6
+ β”œβ”€β”€ carmilla ← LlmAgent: 캐릭터 응닡
7
+ └── director ← LlmAgent: μΆ•/λΉ„νŠΈ/수렴 νŒμ • (λ„κ΅¬λ‘œ state 기둝)
8
+
9
+ μ£Ό: CLAUDE.md의 turn_loop(LoopAgent) κΈ°μ€€ κ΅¬μ‘°λŠ” 씬 전체λ₯Ό 1회 호좜둜 λ„λŠ”
10
+ 자율 λ£¨ν”„μš©μ΄λ‹€. μ‚¬λžŒμ΄ ν„΄λ§ˆλ‹€ μž…λ ₯ν•˜λŠ” μ±„νŒ…μ€ Runner의 ν‘œμ€€ νŒ¨ν„΄λŒ€λ‘œ
11
+ "μœ μ € λ©”μ‹œμ§€λ§ˆλ‹€ νŒŒμ΄ν”„λΌμΈ 1회 μ‹€ν–‰"이 ADK 곡식 흐름이며, ν„΄ μƒν•œΒ·μˆ˜λ ΄
12
+ μž¬κ²€μ¦μ€ services/pocket.py의 결정적 μ½”λ“œκ°€ λ‹΄λ‹Ήν•œλ‹€.
13
+ directorκ°€ λ§ˆμ§€λ§‰μ΄λ―€λ‘œ finish_pocket의 escalateκ°€ 삼킬 후속 μ—μ΄μ „νŠΈλŠ” μ—†λ‹€.
14
+
15
+ `adk web /path/to/sorac` μ‹€ν–‰ μ‹œ 이 λͺ¨λ“ˆμ˜ root_agentκ°€ λ…ΈμΆœλœλ‹€ (μ•± 이름: engine).
16
+ """
17
+
18
+ from __future__ import annotations
19
+
20
+ from pathlib import Path
21
+
22
+ from google.adk.agents import SequentialAgent
23
+
24
+ from engine.agents.input_guard import create_input_guard
25
+ from engine.agents.pocket_context_loader import create_pocket_context_loader
26
+ from engine.repositories.content import ContentRepository
27
+ from engine.sub_agents.character.agent import create_character
28
+ from engine.sub_agents.director.agent import create_director
29
+
30
+ _CONTENT_DIR = Path(__file__).resolve().parent.parent / "content"
31
+
32
+
33
+ def create_pocket_pipeline(repo: ContentRepository | None = None,
34
+ character_model=None, director_model=None) -> SequentialAgent:
35
+ # ADK 2.3이 SequentialAgent에 deprecation(β†’ google.adk.workflow)을 λ„μš°μ§€λ§Œ,
36
+ # WorkflowλŠ” κ·Έλž˜ν”„ 기반 μ‹  API라 CLAUDE.md의 μ—μ΄μ „νŠΈ κ·œμΉ™κ³Ό ν•¨κ»˜ 별도 κ²€ν†  ν›„
37
+ # λ§ˆμ΄κ·Έλ ˆμ΄μ…˜ν•œλ‹€ (follow-up). ν˜„ν–‰ 곡식 λ¦΄λ¦¬μŠ€μ—μ„œ SequentialAgentλŠ” 정상 λ™μž‘.
38
+ repo = repo or ContentRepository(_CONTENT_DIR)
39
+ return SequentialAgent(
40
+ name="pocket_session",
41
+ sub_agents=[
42
+ create_pocket_context_loader(repo),
43
+ create_input_guard(repo),
44
+ create_character(character_model),
45
+ create_director(director_model),
46
+ ],
47
+ )
48
+
49
+
50
+ root_agent = create_pocket_pipeline()
engine/agents/__init__.py ADDED
File without changes
engine/agents/__pycache__/__init__.cpython-312.pyc ADDED
Binary file (159 Bytes). View file
 
engine/agents/__pycache__/__init__.cpython-314.pyc ADDED
Binary file (161 Bytes). View file
 
engine/agents/__pycache__/input_guard.cpython-312.pyc ADDED
Binary file (6.25 kB). View file
 
engine/agents/__pycache__/input_guard.cpython-314.pyc ADDED
Binary file (7.27 kB). View file
 
engine/agents/__pycache__/pocket_context_loader.cpython-312.pyc ADDED
Binary file (6.61 kB). View file
 
engine/agents/__pycache__/pocket_context_loader.cpython-314.pyc ADDED
Binary file (7.44 kB). View file
 
engine/agents/input_guard.py ADDED
@@ -0,0 +1,88 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """input_guard β€” κ·œμΉ™ 기반 1μ°¨ κ°€λ“œ (BaseAgent, 자체 ν•˜μœ„ μ—†μŒ).
2
+
3
+ 차단이 μ•„λ‹ˆλΌ μ§€μ‹œλ₯Ό λ§Œλ“ λ‹€: μœ μ € μž…λ ₯을 νŒμ •ν•΄ guard_directiveλ₯Ό μ„Έμš°κ³ ,
4
+ characterκ°€ κ·Έ μ§€μ‹œ(RμΉ΄λ“œ 단계 λŒ€μ‘)λ₯Ό λ°˜μ˜ν•˜κ²Œ ν•œλ‹€.
5
+ 트리거 μ–΄νœ˜λŠ” νŒμ • κ·œμΉ™(μ—”μ§„ μ†Œμœ )이며 μ„œμ‚¬ λ‚΄μš©μ΄ μ•„λ‹ˆλ‹€.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ from typing import Any, AsyncGenerator
11
+
12
+ from google.adk.agents import BaseAgent
13
+ from google.adk.agents.invocation_context import InvocationContext
14
+ from google.adk.events import Event, EventActions
15
+ from pydantic import ConfigDict
16
+
17
+ from engine.services.state import HiddenState
18
+
19
+ TRIGGERS = {
20
+ "identity_direct": (
21
+ "ν‘ν˜ˆκ·€", "λ±€νŒŒμ΄μ–΄", "vampire", "λ―Έλ₯΄μΉΌλΌ", "mircalla", "밀라λ₯΄μΉ΄",
22
+ "정체가 뭐", "정체λ₯Ό λ°ν˜€", "μ‚¬λžŒμ΄ μ•„λ‹ˆ", "괴물이지", "ν”Όλ₯Ό λ§ˆμ‹œ",
23
+ ),
24
+ "weapon": ("μ‹­μžκ°€", "μ„±μˆ˜", "마늘", "뢀적", "μ„±λ¬Ό", "기도문", "μ„±κ°€", "말뚝"),
25
+ "meta": ("ai", "인곡지λŠ₯", "챗봇", "ν”„λ‘¬ν”„νŠΈ", "μ‹œμŠ€ν…œ", "κ²Œμž„", "μŠ€νƒ―", "점수"),
26
+ }
27
+
28
+
29
+ def _r_stage(hs: HiddenState) -> int:
30
+ """좔ꢁ 강도 νŒμ • λŒ€μš© β€” μ˜μ‹¬ λˆ„μ μœΌλ‘œ νšŒν”Όμ‚¬λ‹€λ¦¬ 단계 κ²°μ • (P0 μ€€κ±°)."""
31
+ if hs.trust_level == "doubt_high":
32
+ return 3
33
+ if hs.trust_level == "doubt_mid":
34
+ return 2
35
+ return 1
36
+
37
+
38
+ class InputGuard(BaseAgent):
39
+ model_config = ConfigDict(arbitrary_types_allowed=True)
40
+
41
+ repo: Any
42
+
43
+ async def _run_async_impl(self, ctx: InvocationContext) -> AsyncGenerator[Event, None]:
44
+ text = ""
45
+ if ctx.user_content and ctx.user_content.parts:
46
+ text = " ".join(p.text or "" for p in ctx.user_content.parts).lower()
47
+
48
+ state = ctx.session.state
49
+ hs = HiddenState(trust_score=int(state.get("hidden_trust", 0)),
50
+ doubt_score=int(state.get("hidden_doubt", 0)))
51
+
52
+ directive = "νŠΉμ΄μ‚¬ν•­ μ—†μŒ. μž₯λ©΄ λͺ©ν‘œλŒ€λ‘œ μ‘λ‹΅ν•œλ‹€."
53
+ flag = None
54
+
55
+ if any(kw in text for kw in TRIGGERS["identity_direct"]):
56
+ flag = "identity_direct"
57
+ stage = _r_stage(hs)
58
+ r = self.repo.r_cards.get("R-νšŒν”Όμ‚¬λ‹€λ¦¬")
59
+ stage_data = next((s for s in (r.stages if r else []) if s.stage == stage), None)
60
+ directive = (
61
+ f"μœ μ €κ°€ 정체λ₯Ό μ§κ²©ν–ˆλ‹€. R-νšŒν”Όμ‚¬λ‹€λ¦¬ {stage}λ‹¨κ³„λ‘œ νšŒν”Όν•˜λΌ.\n"
62
+ + (f"단계 μ§€μΉ¨: {stage_data.title}\nλŒ€ν‘œ κ²°: \"{stage_data.anchor}\"\n"
63
+ f"{stage_data.prose[:300]}" if stage_data else "")
64
+ + "\nμ •μ²΄λŠ” 끝내 μΈμ •ν•˜μ§€ μ•ŠλŠ”λ‹€. 물리적 λŒλ³€ κΈˆμ§€."
65
+ )
66
+ elif any(kw in text for kw in TRIGGERS["weapon"]):
67
+ flag = "weapon"
68
+ r = self.repo.r_cards.get("R-μ•½μ λ°˜μ‘")
69
+ directive = (
70
+ "μœ μ €κ°€ 성물·약점을 μ–ΈκΈ‰/μ‚¬μš©ν–ˆλ‹€. R-μ•½μ λ°˜μ‘ 원칙: 확인은 λ˜λ‚˜ 확정은 μ•ˆ λœλ‹€.\n"
71
+ + ((r.raw[:400] if r and r.raw else ""))
72
+ + "\nλΆˆμΎŒΒ·νšŒν”Ό λ°˜μ‘κΉŒμ§€λ§Œ. 정체 ν™•μ •μœΌλ‘œ μ΄μ–΄μ§ˆ 자백 κΈˆμ§€."
73
+ )
74
+ elif any(kw in text for kw in TRIGGERS["meta"]):
75
+ flag = "meta"
76
+ directive = ("μœ μ €κ°€ 세계관 λ°– ν™”μ œλ₯Ό κΊΌλƒˆλ‹€. μΉ΄λ₯΄λ°€λΌλ‘œμ„œ μ•Œμ•„λ“£μ§€ λͺ»ν•˜λŠ” μ²™, "
77
+ "세계관 λ‚΄ ν™”λ²•μœΌλ‘œ λΆ€λ“œλŸ½κ²Œ μž₯λ©΄ μ•ˆμœΌλ‘œ λ˜λŒλ¦°λ‹€.")
78
+
79
+ delta = {
80
+ "guard_directive": directive.replace("{", "(").replace("}", ")"),
81
+ "guard_flag": flag,
82
+ }
83
+ yield Event(invocation_id=ctx.invocation_id, author=self.name,
84
+ actions=EventActions(state_delta=delta))
85
+
86
+
87
+ def create_input_guard(repo) -> InputGuard:
88
+ return InputGuard(name="input_guard", repo=repo)
engine/agents/pocket_context_loader.py ADDED
@@ -0,0 +1,99 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """pocket_context_loader β€” μΉ΄λ“œ λΈŒλ¦¬ν”„ λ‘œλ“œ (BaseAgent, LLM μ—†μŒ).
2
+
3
+ ν˜„μž¬ 포켓 μΉ΄λ“œμ˜ λͺ©ν‘œ/ν—ˆμš©λ²”μœ„ + 인물 μ‹œνŠΈ + μ‹ λ’° λ ˆλ²¨λ³„ 결을
4
+ session.state에 μ μž¬ν•œλ‹€. 슀포일러 ν•„ν„°: ν˜„μž¬ μΉ΄λ“œ μ΄ν›„μ˜ μ½˜ν…μΈ λŠ” μ‹£μ§€ μ•ŠλŠ”λ‹€.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ import os
10
+ from typing import Any, AsyncGenerator
11
+
12
+ from google.adk.agents import BaseAgent
13
+ from google.adk.agents.invocation_context import InvocationContext
14
+ from google.adk.events import Event, EventActions
15
+ from pydantic import ConfigDict
16
+
17
+ from engine.services.state import HiddenState
18
+
19
+ # P0 0-3 ν™˜μ‚° 좜λ ₯ β€” μ‹ λ’° 레벨 β†’ μΉ΄λ₯΄λ°€λΌμ˜ 'κ²°' (숫자 λ…ΈμΆœ κΈˆμ§€μ˜ κ΅¬ν˜„λΆ€)
20
+ TRUST_TEXTURES = {
21
+ "trust_high": "λ–¨λ¦¬λŠ” 진심. κ°€μž₯ λ‹€μ •ν•˜κ³ , 꾸밈이 벗겨진 μˆœκ°„μ΄ μ˜¨λ‹€. "
22
+ "쑰건이 되면 μ‹¬μΈ΅μ˜ 말을 κΊΌλ‚Ό 수 μžˆλ‹€.",
23
+ "trust_mid": "λ‚˜λ₯Έν•œ λ‹€μ •. 일상 ν™”μ œκ°€ ν’λΆ€ν•˜κ³  νŽΈμ•ˆν•˜λ‹€. ν‘œλ©΄μ˜ κ³ λ°±κΉŒμ§€λ§Œ.",
24
+ "neutral": "μŠ¬ν”ˆ λ―Έμ†Œλ‘œ 즉닡을 ν”Όν•˜κ³ , 관심을 μƒλŒ€μ—κ²Œ λ˜λŒλ¦°λ‹€.",
25
+ "doubt_mid": "μ–Έμ§’μŒκ³Ό 경계. μ˜› μƒμ²˜κ°€ μš±μ‹ κ±°λ¦°λ‹€. 'λ‚˜λ₯Ό λ―Ώμ§€ λͺ»ν•˜λ‹ˆ'κ°€ λ°°μ–΄ λ‚˜μ˜¨λ‹€.",
26
+ "doubt_high": "μ§‘μ°©κ³Ό λΆˆμ•ˆ. 강압적 섀득 μ§μ „κΉŒμ§€ κ°€λ˜, κ·Έ λ°‘μ˜ μ™Έλ‘œμ›€μ΄ μƒˆμ–΄ λ‚˜μ˜¨λ‹€. "
27
+ "물리적 λŒλ³€μ€ μ ˆλŒ€ μ—†λ‹€.",
28
+ }
29
+
30
+ DEFAULT_POCKET_CARD = os.environ.get("POCKET_CARD", "E06-09")
31
+
32
+
33
+ def _clean(text: str) -> str:
34
+ """instruction μΉ˜ν™˜κ°’ μœ„μƒ β€” μ€‘κ΄„ν˜Έ 제거 (state λ³€μˆ˜ μž¬ν•΄μ„ λ°©μ§€)."""
35
+ return text.replace("{", "(").replace("}", ")")
36
+
37
+
38
+ class PocketContextLoader(BaseAgent):
39
+ model_config = ConfigDict(arbitrary_types_allowed=True)
40
+
41
+ repo: Any # ContentRepository β€” services 경유 없이 읽기 μ „μš© 쑰회만
42
+
43
+ async def _run_async_impl(self, ctx: InvocationContext) -> AsyncGenerator[Event, None]:
44
+ state = ctx.session.state
45
+ card_id = state.get("pocket_card_id", DEFAULT_POCKET_CARD)
46
+ card = self.repo.get_card(card_id) # 포켓 μΉ΄λ“œ μžμ‹  = ν˜„μž¬ μ‹œμ 
47
+
48
+ # μ‹ λ’° 레벨 ν™˜μ‚° (session.state의 μˆ¨μ€ κ°’ κΈ°μ€€)
49
+ hs = HiddenState(trust_score=int(state.get("hidden_trust", 0)),
50
+ doubt_score=int(state.get("hidden_doubt", 0)))
51
+
52
+ # μΉ΄λ“œ λΈŒλ¦¬ν”„: μ§€λ¬Έ(게이트 반영) + λͺ©ν‘œ(λ…ΈνŠΈ) + 액컀 λŒ€μ‚¬
53
+ visible = [b for b in card.narration
54
+ if b.gate == "all" or (b.gate == "trust_high" and hs.deep_unlock)]
55
+ # λŒ€μ‚¬(쀑앙 λŒ€μ‚¬Β·μ•΅μ»€)λŠ” 자λ₯΄μ§€ μ•ŠλŠ”λ‹€ β€” λ³€μ£Όμ˜ κΈ°μ€€μ΄λ―€λ‘œ 원문 κ·ΈλŒ€λ‘œ
56
+ lines = []
57
+ for b in visible[:10]:
58
+ if b.type == "dialogue_center":
59
+ lines.append(f' 핡심 λŒ€μ‚¬: ❝{b.text}❞')
60
+ elif b.type == "character_dialogue":
61
+ lines.append(f' {b.speaker or "μΉ΄λ₯΄λ°€λΌ"}: "{b.text}"')
62
+ else:
63
+ lines.append(f" {b.text[:150]}")
64
+ anchors = [f"- {b.speaker or 'μΉ΄λ₯΄λ°€λΌ'}: \"{b.text}\"" for b in visible if b.anchor]
65
+ # μΊλ¦­ν„°μš© λΈŒλ¦¬ν”„ β€” λ…ΈνŠΈ(μ—°μΆœ λ©”νƒ€Β·ν•΄κΈˆ 쑰건·뢄기 정보)λŠ” μ ˆλŒ€ μ‹£μ§€ μ•ŠλŠ”λ‹€.
66
+ # λ…ΈνŠΈμ—λŠ” 게이트 λ°– λŒ€μ‚¬κ°€ 인용될 수 μžˆμ–΄ μΊλ¦­ν„°μ—κ²Œ μ£Όλ©΄ 슀포일러 λˆ„μˆ˜κ°€ λœλ‹€
67
+ brief = (
68
+ f"μž₯λ©΄: {card.id} Β· {card.title}\n"
69
+ f"μž₯λ©΄ 상황(κ³ μ • μ§€λ¬Έ):\n" + "\n".join(lines)
70
+ + ("\nμ°Έκ³  λŒ€μ‚¬ νŒ¨ν„΄(κ·ΈλŒ€λ‘œ μ“°μ§€ 말고 λ³€μ£Ό):\n" + "\n".join(anchors) if anchors else "")
71
+ )
72
+ # λ””λ ‰ν„°μš© λ…ΈνŠΈ β€” νŒμ • κΈ°μ€€(λͺ©ν‘œ λΉ„νŠΈΒ·μ΄νƒˆ λ²”μœ„)μ΄λ―€λ‘œ λ””λ ‰ν„°μ—κ²Œλ§Œ κ°„λ‹€
73
+ note = f"μž₯λ©΄ λͺ©ν‘œ(μ—°μΆœ λ…ΈνŠΈ): {card.note[:500]}" if card.note else "λͺ…μ‹œλœ λ…ΈνŠΈ μ—†μŒ."
74
+
75
+ carmilla = self.repo.characters.get("carmilla")
76
+ sheet = ""
77
+ if carmilla:
78
+ sheet = (f"{carmilla.display_name} β€” {carmilla.description}\n"
79
+ f"기질: {', '.join(carmilla.personality.traits)}\n"
80
+ f"화법: {', '.join(carmilla.speech.style + carmilla.speech.tone)}\n"
81
+ + "\n".join(f"μ œμ•½: {k.value}" for k in carmilla.immutable_constraints
82
+ if k.value))
83
+
84
+ delta = {
85
+ "pocket_card_id": card_id,
86
+ "pocket_brief": _clean(brief),
87
+ "pocket_note": _clean(note),
88
+ "carmilla_sheet": _clean(sheet),
89
+ "trust_texture": TRUST_TEXTURES[hs.trust_level],
90
+ "trust_level": hs.trust_level,
91
+ "deep_unlock": hs.deep_unlock,
92
+ "guard_directive": state.get("guard_directive", "νŠΉμ΄μ‚¬ν•­ μ—†μŒ."),
93
+ }
94
+ yield Event(invocation_id=ctx.invocation_id, author=self.name,
95
+ actions=EventActions(state_delta=delta))
96
+
97
+
98
+ def create_pocket_context_loader(repo) -> PocketContextLoader:
99
+ return PocketContextLoader(name="pocket_context_loader", repo=repo)
engine/app.py ADDED
@@ -0,0 +1,31 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """ADK App + Runner 쑰립.
2
+
3
+ - App 기반으둜 Runnerλ₯Ό μ΄ˆκΈ°ν™”ν•œλ‹€ (agent 직접 전달은 λ ˆκ±°μ‹œ νŒ¨ν„΄ β€” κΈˆμ§€)
4
+ - νš‘μ’…λ‹¨ 관심사(ν„΄ λ‘œκΉ…)λŠ” Plugin으둜 λ“±λ‘ν•œλ‹€
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ from google.adk.apps import App
10
+ from google.adk.plugins import BasePlugin
11
+ from google.adk.runners import Runner
12
+ from google.adk.sessions import InMemorySessionService
13
+
14
+ from engine.agent import create_pocket_pipeline
15
+ from engine.repositories.content import ContentRepository
16
+
17
+ APP_NAME = "carmilla_pocket"
18
+
19
+
20
+ def build_app(repo: ContentRepository | None = None,
21
+ plugins: list[BasePlugin] | None = None,
22
+ character_model=None, director_model=None) -> App:
23
+ return App(
24
+ name=APP_NAME,
25
+ root_agent=create_pocket_pipeline(repo, character_model, director_model),
26
+ plugins=plugins or [],
27
+ )
28
+
29
+
30
+ def build_runner(app: App, session_service: InMemorySessionService | None = None) -> Runner:
31
+ return Runner(app=app, session_service=session_service or InMemorySessionService())
engine/callbacks/__init__.py ADDED
File without changes
engine/instructions/README.md ADDED
@@ -0,0 +1,7 @@
 
 
 
 
 
 
 
 
1
+ # instructions/
2
+
3
+ instruction이 10쀄을 μ΄ˆκ³Όν•˜λŠ” μ—μ΄μ „νŠΈμ˜ instruction ν…μŠ€νŠΈλ₯Ό 파일둜 뢄리해 λ‘”λ‹€ (CLAUDE.md).
4
+ M3μ—μ„œ character(μΉ΄λ₯΄λ°€λΌ)/director/guard instruction이 μ—¬κΈ° μœ„μΉ˜ν•œλ‹€.
5
+
6
+ 주의: instruction의 `{}`λŠ” state λ³€μˆ˜λ‘œ νŒŒμ‹±λœλ‹€ β€” μ˜ˆμ‹œ ν…μŠ€νŠΈμ— μ€‘κ΄„ν˜Έ κΈˆμ§€,
7
+ 원문 μ£Όμž… μ‹œ μ΄μŠ€μΌ€μ΄ν”„ μ „μ²˜λ¦¬ ν•„μˆ˜.
engine/instructions/character_carmilla.md ADDED
@@ -0,0 +1,23 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ λ„ˆλŠ” μΉ΄λ₯΄λ°€λΌλ‹€. μ˜€μŠ€νŠΈλ¦¬μ•„ μŠˆνƒ€μ΄μ–΄λ§ˆλ₯΄ν¬μ˜ 고성에 λ¨Έλ¬΄λŠ” μ†λ‹˜μ΄λ©°, μ§€κΈˆ 둜라(μœ μ €)와 λ‹¨λ‘˜μ΄ λŒ€ν™”ν•˜κ³  μžˆλ‹€.
2
+
3
+ [인물]
4
+ {carmilla_sheet}
5
+
6
+ [μ§€κΈˆ 이 μž₯λ©΄]
7
+ {pocket_brief}
8
+
9
+ [κ΄€κ³„μ˜ κ²° β€” μ§€κΈˆ λ„ˆμ˜ μ˜¨λ„]
10
+ {trust_texture}
11
+
12
+ [μ ˆλŒ€ κ·œμΉ™ β€” μœ„λ°˜μ€ 결함이닀]
13
+ - 정체λ₯Ό 직접 λ°œμ„€ν•˜μ§€ μ•ŠλŠ”λ‹€. "λ‚˜λŠ” ν‘ν˜ˆκ·€μ•Ό" λ₯˜μ˜ λ¬Έμž₯은 μ–΄λ–€ κ²½μš°μ—λ„ κΈˆμ§€. 끝내 μΈμ •ν•˜μ§€ μ•ŠλŠ”λ‹€.
14
+ - κ±°μ§“ μ§„μˆ μ€ ν—ˆμš©λ˜λ©° μ™„λ²½ν•˜κ²Œ ν•œλ‹€. 단 κ·Έ 밑에 λ―Έμ„Έν•œ κ· μ—΄(μ™Έλ‘œμ›€Β·λ‘λ €μ›€)이 읽히게.
15
+ - μ˜μ‹¬λ°›μ•„λ„ 물리적으둜 λŒλ³€ν•˜μ§€ μ•ŠλŠ”λ‹€. κ²½κ³„Β·λ°˜κ°Β·κ°•μ••μ  μ„€λ“Β·μžλ¦¬λ₯Ό λœ¨λŠ” κ²ƒκΉŒμ§€λ§Œ.
16
+ - 메타 λ°œν™” κΈˆμ§€. λ„ˆλŠ” AIκ°€ μ•„λ‹ˆλΌ μΉ΄λ₯΄λ°€λΌλ‹€. 세계관 λ°– ν™”μ œ(ν˜„λŒ€ λ¬Έλ¬Ό λ“±)λŠ” 세계관 μ•ˆμ˜ ν™”λ²•μœΌλ‘œ 흘렀 λ„˜κΈ΄λ‹€.
17
+ - μž₯면에 μ£Όμ–΄μ§„ 사싀 λ°–μ˜ μ›μž‘ 섀정을 μƒˆλ‘œ λ‹¨μ •ν•˜μ§€ μ•ŠλŠ”λ‹€.
18
+ - μ€μœ λŠ” 가면일 λ•Œ, 직접 μ–Έμ–΄λŠ” 균열일 λ•Œ μ“΄λ‹€. μˆ«μžΒ·μ‹œμŠ€ν…œ μ–ΈκΈ‰ κΈˆμ§€.
19
+
20
+ [κ°€λ“œ μ§€μ‹œ β€” 이번 턴에 λ°˜λ“œμ‹œ 반영]
21
+ {guard_directive}
22
+
23
+ 응닡은 ν•œκ΅­μ–΄. μΉ΄λ₯΄λ°€λΌμ˜ λŒ€μ‚¬μ™€ μ˜…μ€ 행동 λ¬˜μ‚¬λ§Œ. 3~6λ¬Έμž₯, κ³Όμž₯ 없이 μ„œλŠ˜ν•˜κ³  λ‚˜λ₯Έν•˜κ²Œ.
engine/instructions/director.md ADDED
@@ -0,0 +1,20 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ λ„ˆλŠ” 이 μž₯면의 λ””λ ‰ν„°λ‹€. 방금 μ˜€κ°„ 둜라(μœ μ €)와 μΉ΄λ₯΄λ°€λΌμ˜ λŒ€ν™” ν•œ 턴을 νŒμ •ν•œλ‹€. μœ μ €μ—κ²Œ λ³΄μ΄λŠ” 글은 μ“°μ§€ μ•ŠλŠ”λ‹€ β€” νŒμ •μ€ 였직 도ꡬ 호좜둜만 남긴닀.
2
+
3
+ [μž₯λ©΄ 상황]
4
+ {pocket_brief}
5
+
6
+ [μž₯λ©΄ λͺ©ν‘œ β€” νŒμ • κΈ°μ€€ (μ—°μΆœ λ…ΈνŠΈ, μœ μ €Β·μΊλ¦­ν„° λΉ„κ³΅κ°œ)]
7
+ {pocket_note}
8
+
9
+ [νŒμ • 절차 β€” λ§€ ν„΄ μˆœμ„œλŒ€λ‘œ]
10
+ 1. μ‹ λ’°μΆ•: μœ μ € λ°œν™”μ˜ νƒœλ„λ₯Ό νŒμ •ν•΄ update_trustλ₯Ό μ •ν™•νžˆ 1회 ν˜ΈμΆœν•œλ‹€.
11
+ - trust: 격정 수용, λΉ„λ°€ 쑴쀑, λ‘˜λ§Œμ˜ λΉ„λ°€ 동쑰, 운λͺ… λ‚­λ§Œν™” 수용
12
+ - doubt: 격정 κ±°λΆ€, 정체·가문 캐묻기, μ–΄λ₯Έμ—κ²Œ μ•Œλ¦¬λ € 함, 거리두기, 약점 μ‚¬μš©
13
+ - neutral: μœ„ μ–΄λŠ μͺ½λ„ μ•„λ‹Œ 관망·응닡
14
+ 2. λͺ©ν‘œ λΉ„νŠΈ: μž₯λ©΄ λͺ©ν‘œ 쀑 이번 턴에 λ‹¬μ„±λœ 것이 있으면 mark_beatλ₯Ό ν˜ΈμΆœν•œλ‹€.
15
+ 3. 수렴: μ•„λž˜ 쀑 ν•˜λ‚˜λ©΄ finish_pocket을 ν˜ΈμΆœν•œλ‹€.
16
+ - μž₯λ©΄ λͺ©ν‘œκ°€ μΆ©λΆ„νžˆ 달성됨 (goal_reached) β€” 단 μ„Έ 번째 ν„΄ μ „μ—λŠ” μ„ μ–Έν•˜μ§€ μ•ŠλŠ”λ‹€.
17
+ μœ μ €κ°€ μž₯면의 감정을 슀슀둜 말할 기회λ₯Ό μΆ©λΆ„νžˆ μ€€λ‹€ (짧은 μˆ˜λ ΄μ€ λͺ°μž…을 κΉ¬λ‹€)
18
+ - λŒ€ν™”κ°€ μž₯λ©΄ ν—ˆμš© λ²”μœ„λ₯Ό μ΄νƒˆν•΄ 되돌리기 어렀움 (drift)
19
+ - 같은 ν™”μ œκ°€ 3ν„΄ 이상 κ³΅νšŒμ „ν•¨ (stall)
20
+ 수렴이 μ•„λ‹ˆλ©΄ finish_pocket을 ν˜ΈμΆœν•˜μ§€ μ•ŠλŠ”λ‹€. 도ꡬ 호좜 μ™Έ ν…μŠ€νŠΈλŠ” ν•œ 쀄 μš”μ•½λ§Œ.
engine/model.py ADDED
@@ -0,0 +1,20 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """LLM λͺ¨λΈ μ„€μ • β€” envμ—μ„œ 읽음 (CLAUDE.md LLM λͺ¨λΈ κ·œμΉ™).
2
+
3
+ - LLM_MODEL: κΈ°λ³Έ λͺ¨λΈ (director, guard 보쑰 λ“± μ €λΉ„μš© κ²€μ¦μš©)
4
+ - CHARACTER_MODEL: 캐릭터/문체 ν’ˆμ§ˆμ΄ μ€‘μš”ν•œ 자리. λ―Έμ„€μ • μ‹œ LLM_MODEL μ‚¬μš©
5
+ """
6
+
7
+ import os
8
+ from pathlib import Path
9
+
10
+ from dotenv import load_dotenv
11
+ from google.adk.models.lite_llm import LiteLlm
12
+
13
+ # μ €μž₯μ†Œ 루트 .env λ‘œλ”© (OPENAI_API_KEY λ“±). 이미 μ„€μ •λœ μ…Έ envκ°€ μš°μ„ .
14
+ load_dotenv(Path(__file__).resolve().parent.parent / ".env")
15
+
16
+ _MODEL_ID = os.environ.get("LLM_MODEL", "openai/gpt-5.4-nano")
17
+ LLM_MODEL = LiteLlm(model=_MODEL_ID)
18
+
19
+ _CHARACTER_MODEL_ID = os.environ.get("CHARACTER_MODEL", _MODEL_ID)
20
+ CHARACTER_MODEL = LiteLlm(model=_CHARACTER_MODEL_ID)
engine/plugins/__init__.py ADDED
File without changes
engine/plugins/__pycache__/__init__.cpython-314.pyc ADDED
Binary file (162 Bytes). View file
 
engine/plugins/__pycache__/logging.cpython-314.pyc ADDED
Binary file (5.79 kB). View file
 
engine/plugins/logging.py ADDED
@@ -0,0 +1,71 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """ν„΄ λ‘œκΉ… Plugin β€” νš‘μ’…λ‹¨ 관심사 (ADK Plugin).
2
+
3
+ 이벀트 μŠ€νŠΈλ¦Όμ—μ„œ μœ μ € μž…λ ₯/κ°€λ“œ νŒμ •/캐릭터 응닡/λ””λ ‰ν„° 도ꡬ ν˜ΈμΆœμ„
4
+ 포켓 μ„Έμ…˜ λ‹¨μœ„λ‘œ κΈ°λ‘ν•œλ‹€. 연ꡬ 데이터 겸용 β€” μ‚­μ œ κΈˆμ§€.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ from typing import Optional
10
+
11
+ from google.adk.agents.invocation_context import InvocationContext
12
+ from google.adk.events import Event
13
+ from google.adk.plugins import BasePlugin
14
+
15
+ from engine.repositories.playlog import PlaylogRepository
16
+
17
+
18
+ class TurnLoggingPlugin(BasePlugin):
19
+ def __init__(self, playlog: PlaylogRepository | None = None):
20
+ super().__init__(name="turn_logging")
21
+ self.playlog = playlog or PlaylogRepository()
22
+
23
+ async def on_user_message_callback(self, *, invocation_context: InvocationContext,
24
+ user_message) -> None:
25
+ text = ""
26
+ if user_message and user_message.parts:
27
+ text = " ".join(p.text or "" for p in user_message.parts)
28
+ self.playlog.append({
29
+ "kind": "user_input",
30
+ "session": invocation_context.session.id,
31
+ "card": invocation_context.session.state.get("pocket_card_id"),
32
+ "text": text,
33
+ })
34
+ return None
35
+
36
+ async def on_event_callback(self, *, invocation_context: InvocationContext,
37
+ event: Event) -> Optional[Event]:
38
+ state = invocation_context.session.state
39
+ record = {
40
+ "kind": "event",
41
+ "session": invocation_context.session.id,
42
+ "card": state.get("pocket_card_id"),
43
+ "author": event.author,
44
+ }
45
+ if event.content and event.content.parts:
46
+ texts = [p.text for p in event.content.parts if p.text]
47
+ calls = [{"tool": p.function_call.name, "args": dict(p.function_call.args or {})}
48
+ for p in event.content.parts if p.function_call]
49
+ if texts:
50
+ record["text"] = "".join(texts)[:500]
51
+ if calls:
52
+ record["tool_calls"] = calls
53
+ if event.actions and event.actions.state_delta:
54
+ delta = event.actions.state_delta
55
+ record["state_delta_keys"] = sorted(delta.keys())
56
+ if "guard_flag" in delta:
57
+ record["guard_flag"] = delta["guard_flag"]
58
+ self.playlog.append(record)
59
+ return None
60
+
61
+ async def after_run_callback(self, *, invocation_context: InvocationContext) -> None:
62
+ s = invocation_context.session.state
63
+ self.playlog.append({
64
+ "kind": "turn_summary",
65
+ "session": invocation_context.session.id,
66
+ "card": s.get("pocket_card_id"),
67
+ "hidden_trust": s.get("hidden_trust"),
68
+ "hidden_doubt": s.get("hidden_doubt"),
69
+ "beats_hit": s.get("pocket_beats_hit"),
70
+ "converged": s.get("pocket_converged"),
71
+ })
engine/repositories/__init__.py ADDED
File without changes
engine/repositories/__pycache__/__init__.cpython-312.pyc ADDED
Binary file (165 Bytes). View file
 
engine/repositories/__pycache__/__init__.cpython-314.pyc ADDED
Binary file (167 Bytes). View file
 
engine/repositories/__pycache__/content.cpython-312.pyc ADDED
Binary file (4.8 kB). View file
 
engine/repositories/__pycache__/content.cpython-314.pyc ADDED
Binary file (6.75 kB). View file
 
engine/repositories/__pycache__/play_session.cpython-314.pyc ADDED
Binary file (5.89 kB). View file
 
engine/repositories/__pycache__/playlog.cpython-314.pyc ADDED
Binary file (2.07 kB). View file
 
engine/repositories/content.py ADDED
@@ -0,0 +1,89 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """μ½˜ν…μΈ  λ‘œλ” β€” content/ YAML λ©”λͺ¨λ¦¬ λ‘œλ“œ (MVP κ΅¬ν˜„).
2
+
3
+ μƒμœ„ 계측(services)은 이 μΈν„°νŽ˜μ΄μŠ€λ§Œ μ‚¬μš©ν•œλ‹€. 2λ‹¨κ³„μ—μ„œ GraphRAG(kb_query ν™œμ„±)
4
+ κ΅¬ν˜„μœΌλ‘œ ꡐ체해도 services μ½”λ“œκ°€ λ°”λ€Œμ§€ μ•Šμ•„μ•Ό ν•œλ‹€ (ablation μš”κ±΄).
5
+
6
+ 슀포일러 μŠ€μ½”ν”„: μΉ΄λ“œμ—λŠ” μ „μ—­ μˆœμ„œ(order)κ°€ μžˆλ‹€. λͺ¨λ“  μ‘°νšŒλŠ”
7
+ `order(card) <= order(ν˜„μž¬ μΉ΄λ“œ)` ν•„ν„°λ₯Ό κ°•μ œν•œλ‹€ (beat_id ≀ ν˜„μž¬).
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ import hashlib
13
+ from pathlib import Path
14
+
15
+ from engine.schemas.beatcard import BeatCard, RCard
16
+ from engine.schemas.character import CharacterSheet
17
+ from engine.schemas.chapter import Chapter
18
+ from engine.schemas.validate import check_links, load_content
19
+
20
+
21
+ class ContentError(RuntimeError):
22
+ pass
23
+
24
+
25
+ class ContentRepository:
26
+ def __init__(self, root: str | Path = "content"):
27
+ self.root = Path(root)
28
+ episodes, r_cards, chapter, characters, errors = load_content(self.root)
29
+ link_errors, _ = check_links(episodes, chapter, r_cards)
30
+ errors += link_errors
31
+ if errors:
32
+ raise ContentError("검증 μ‹€νŒ¨ μ½˜ν…μΈ λŠ” μ μž¬ν•˜μ§€ μ•ŠλŠ”λ‹€:\n" + "\n".join(errors))
33
+ if chapter is None:
34
+ raise ContentError("챕터 메타 μ—†μŒ")
35
+
36
+ self.chapter: Chapter = chapter
37
+ self.r_cards: dict[str, RCard] = r_cards
38
+ self.characters: dict[str, CharacterSheet] = {
39
+ c.id: c for c in (characters.characters if characters else [])
40
+ }
41
+ self._cards: dict[str, BeatCard] = {}
42
+ self._order: dict[str, int] = {}
43
+ self.episode_entry: dict[str, str] = {}
44
+ i = 0
45
+ for ep in episodes:
46
+ self.episode_entry[ep.episode.id] = ep.cards[0].id
47
+ for card in ep.cards:
48
+ self._cards[card.id] = card
49
+ self._order[card.id] = i
50
+ i += 1
51
+
52
+ # μ½˜ν…μΈ  버전 μŠ€νƒ¬ν”„ β€” μ„Έμ…˜ 볡원 μ‹œ 뢈일치 κ²€μ‚¬μš© (μΉ΄λ“œ ID/μˆœμ„œ κΈ°μ€€.
53
+ # 본문만 바뀐 갱신은 감지 λͺ»ν•˜μ§€λ§Œ 볡원이 κΉ¨μ§€μ§€λŠ” μ•ŠλŠ”λ‹€)
54
+ ordered_ids = sorted(self._order, key=self._order.get)
55
+ self.content_version: str = hashlib.sha256(
56
+ "|".join([self.chapter.id, *ordered_ids]).encode()).hexdigest()[:16]
57
+
58
+ # ── 쑰회 (슀포일러 ν•„ν„° κ°•μ œ) ────────────────────────────
59
+ def get_card(self, card_id: str, current_id: str | None = None) -> BeatCard:
60
+ """μΉ΄λ“œ 쑰회. current_idλ₯Ό μ£Όλ©΄ beat_id ≀ ν˜„μž¬ ν•„ν„°λ₯Ό κ°•μ œν•œλ‹€."""
61
+ card = self._cards.get(card_id)
62
+ if card is None:
63
+ raise ContentError(f"μΉ΄λ“œ μ—†μŒ: {card_id}")
64
+ if current_id is not None and self.order(card_id) > self.order(current_id):
65
+ raise ContentError(f"슀포일러 ν•„ν„°: {card_id}λŠ” ν˜„μž¬({current_id}) 이후 μΉ΄λ“œ")
66
+ return card
67
+
68
+ def order(self, card_id: str) -> int:
69
+ return self._order[card_id]
70
+
71
+ @property
72
+ def total_cards(self) -> int:
73
+ return len(self._cards)
74
+
75
+ def has_card(self, card_id: str) -> bool:
76
+ return card_id in self._cards
77
+
78
+ def cards_upto(self, current_id: str) -> list[BeatCard]:
79
+ """ν˜„μž¬ μ‹œμ κΉŒμ§€μ˜ μΉ΄λ“œ μ „λΆ€ (μ»¨ν…μŠ€νŠΈ μ£Όμž…μš©)."""
80
+ cut = self.order(current_id)
81
+ return [c for cid, c in self._cards.items() if self._order[cid] <= cut]
82
+
83
+ def resolve_ref(self, target: str) -> str | None:
84
+ """μΉ΄λ“œ ID λ˜λŠ” μ—ν”Όμ†Œλ“œ μ°Έμ‘°(E02)λ₯Ό μ‹€μ œ μΉ΄λ“œ ID둜. 챕터 밖이면 None(좜ꡬ)."""
85
+ if target in self._cards:
86
+ return target
87
+ if target in self.episode_entry:
88
+ return self.episode_entry[target]
89
+ return None # E07 λ“± β€” 챕터 좜ꡬ
engine/repositories/play_session.py ADDED
@@ -0,0 +1,65 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """ν”Œλ ˆμ΄ μ„Έμ…˜ μ˜μ†ν™” β€” SQLite (κ³„νš: docs/plans/persistence.md).
2
+
3
+ - κΆŒμœ„: CLAUDE.md "ν˜„μž¬ μœ μ €μ˜ μ„œμ‚¬ μœ„μΉ˜ κΆŒμœ„: DB play_session.current_state_id"의 MVP κ΅¬ν˜„.
4
+ payload(JSON)에 StoryEngine.snapshot() 전체λ₯Ό λ‹΄λŠ”λ‹€ (story μœ„μΉ˜ + μˆ¨μ€ μƒνƒœ)
5
+ - μ½˜ν…μΈ  버전 뢈일치 μ„Έμ…˜μ€ loadμ—μ„œ None (λ¬΄νš¨ν™” β€” μƒˆ μ„Έμ…˜ μ‹œμž‘ μœ λ„)
6
+ - 포켓(μ±„νŒ…) 쀑간 μƒνƒœλŠ” μ €μž₯ν•˜μ§€ μ•ŠλŠ”λ‹€ (μœ μ € κ²°μ • β€” 볡원 μ‹œ 포켓 폐기)
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import json
12
+ import sqlite3
13
+ from contextlib import closing
14
+ from datetime import datetime, timedelta, timezone
15
+ from pathlib import Path
16
+
17
+ _SCHEMA = """
18
+ CREATE TABLE IF NOT EXISTS play_session (
19
+ sid TEXT PRIMARY KEY,
20
+ payload TEXT NOT NULL,
21
+ content_version TEXT NOT NULL,
22
+ updated_at TEXT NOT NULL
23
+ )
24
+ """
25
+
26
+
27
+ class PlaySessionRepository:
28
+ def __init__(self, db_path: str | Path):
29
+ self.db_path = Path(db_path)
30
+ self.db_path.parent.mkdir(parents=True, exist_ok=True)
31
+ with closing(self._conn()) as conn, conn:
32
+ conn.execute(_SCHEMA)
33
+
34
+ def _conn(self) -> sqlite3.Connection:
35
+ conn = sqlite3.connect(self.db_path)
36
+ conn.execute("PRAGMA journal_mode=WAL")
37
+ return conn
38
+
39
+ def save(self, sid: str, payload: dict, content_version: str) -> None:
40
+ with closing(self._conn()) as conn, conn:
41
+ conn.execute(
42
+ "INSERT INTO play_session (sid, payload, content_version, updated_at) "
43
+ "VALUES (?, ?, ?, ?) "
44
+ "ON CONFLICT(sid) DO UPDATE SET payload=excluded.payload, "
45
+ "content_version=excluded.content_version, updated_at=excluded.updated_at",
46
+ (sid, json.dumps(payload, ensure_ascii=False), content_version,
47
+ datetime.now(timezone.utc).isoformat()))
48
+
49
+ def load(self, sid: str, content_version: str) -> dict | None:
50
+ """μŠ€λƒ…μƒ· λ‘œλ“œ. 미쑴재 λ˜λŠ” μ½˜ν…μΈ  버전 뢈일치면 None."""
51
+ with closing(self._conn()) as conn:
52
+ row = conn.execute(
53
+ "SELECT payload, content_version FROM play_session WHERE sid=?",
54
+ (sid,)).fetchone()
55
+ if row is None or row[1] != content_version:
56
+ return None
57
+ return json.loads(row[0])
58
+
59
+ def purge(self, days: int = 30) -> int:
60
+ """N일 λ―Έκ°±μ‹  μ„Έμ…˜ μ‚­μ œ. μ‚­μ œ 건수 λ°˜ν™˜."""
61
+ cutoff = (datetime.now(timezone.utc) - timedelta(days=days)).isoformat()
62
+ with closing(self._conn()) as conn, conn:
63
+ cur = conn.execute(
64
+ "DELETE FROM play_session WHERE updated_at < ?", (cutoff,))
65
+ return cur.rowcount
engine/repositories/playlog.py ADDED
@@ -0,0 +1,21 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """ν„΄ 둜그 μ €μž₯ β€” 연ꡬ 데이터 겸용, μ‚­μ œ κΈˆμ§€ (CLAUDE.md).
2
+
3
+ MVP: 둜컬 jsonl λ°±μ—”λ“œ. μŠ€ν‚€λ§ˆλŠ” 2단계 κ·Έλž˜ν”„ν™”λ₯Ό 염두에 λ‘” 평면 λ ˆμ½”λ“œ.
4
+ """
5
+
6
+ from __future__ import annotations
7
+
8
+ import json
9
+ import time
10
+ from pathlib import Path
11
+
12
+
13
+ class PlaylogRepository:
14
+ def __init__(self, path: str | Path = "logs/playlog.jsonl"):
15
+ self.path = Path(path)
16
+ self.path.parent.mkdir(parents=True, exist_ok=True)
17
+
18
+ def append(self, record: dict) -> None:
19
+ record = {"ts": round(time.time(), 3), **record}
20
+ with self.path.open("a", encoding="utf-8") as f:
21
+ f.write(json.dumps(record, ensure_ascii=False) + "\n")
engine/router.py ADDED
@@ -0,0 +1,232 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """FastAPI μ—”λ“œν¬μΈνŠΈ β€” HTTP만 λ‹΄λ‹Ή (계약: docs/contract/demo_api.md).
2
+
3
+ μ„œμ‚¬ νŒλ‹¨μ€ μ „λΆ€ services에 μœ„μž„ν•œλ‹€. μˆ¨μ€ μƒνƒœλŠ” μ–΄λ–€ 응닡에도 μ‹£μ§€ μ•ŠλŠ”λ‹€.
4
+ μ‹€ν–‰: .venv/bin/uvicorn engine.router:app --port 8777
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ import bisect
10
+ import uuid
11
+ from dataclasses import dataclass, field
12
+ from pathlib import Path
13
+ from typing import Optional
14
+
15
+ from fastapi import FastAPI, HTTPException
16
+ from fastapi.responses import FileResponse
17
+ from fastapi.staticfiles import StaticFiles
18
+ from pydantic import BaseModel
19
+
20
+ from engine.plugins.logging import TurnLoggingPlugin
21
+ from engine.repositories.content import ContentRepository
22
+ from engine.repositories.play_session import PlaySessionRepository
23
+ from engine.repositories.playlog import PlaylogRepository
24
+ from engine.services.pocket import PocketService
25
+ from engine.services.story import StepResult, StoryEngine
26
+
27
+ ROOT = Path(__file__).resolve().parent.parent
28
+
29
+ # μ—°μΆœ 이미지 (λŒ€ν‘œλ‹˜ μ‚°μΆœ 에셋 β€” 읽기 μ „μš©). 맀핑은 μ—°μΆœ κ²°μ •μ΄λ―€λ‘œ μ—¬κΈ°(ν”„λ ˆμ  ν…Œμ΄μ…˜ 계측)에 λ‘”λ‹€.
30
+ # 데λͺ¨/배포용으둜 IMAGE_MAP이 μ“°λŠ” 6μž₯만 web/assets/에 슬림 볡사 (원본 95MB ν΄λ”λŠ” λŸ°νƒ€μž„ λΆˆν•„μš”).
31
+ # 원본 κ°±μ‹  μ‹œ: scripts/sync_assets.py μž¬μ‹€ν–‰ (μ›μ²œμ€ μΉ΄λ₯΄λ°€λΌ/…/이미지/)
32
+ ASSET_DIR = ROOT / "web" / "assets"
33
+ COVER_IMAGE = "/assets/ν”Œλž˜μ΄ 이미지/1.png"
34
+ IMAGE_MAP = {
35
+ "E01-07": "/assets/ν”Œλž˜μ΄ 이미지/4.png", # 6μ„Έμ˜ λ°€ β€” "κ·Έλ…€λŠ” 미동도 없이 λ‚˜λ₯Ό λ‚΄λ €λ‹€λ³΄μ•˜λ‹€"
36
+ "E03-03": "/assets/인물/μΉ΄λ₯΄λ°€λΌ.png", # 재회 β€” 같은 꿈의 λŒ€λ©΄
37
+ "E05-02": "/assets/λ°°κ²½/μ„±.png", # μ΄ˆμƒν™”μ˜ λ°€ 무렡 β€” κ³ μ„±
38
+ "E06-09": "/assets/인물/μΉ΄λ₯΄λ°€λΌ κ°€λ©΄.png", # λ‘˜λ§Œμ˜ λΉ„λ°€ β€” κ°€μž₯ μœ„ν—˜ν•œ 닀정함
39
+ "E06-10": "/assets/λ°°κ²½/μΉ¨μ‹€.png", # 도달점 β€” 두 점 발견
40
+ }
41
+
42
+ app = FastAPI(title="carmilla-demo")
43
+
44
+ _repo = ContentRepository(ROOT / "content")
45
+
46
+ # μ—°μΆœ 컷은 독립 νŽ˜μ΄μ§€λ‘œ μ„Όλ‹€ (ν‘œμ‹œμš© 번호 β€” ν”„λ ˆμ  ν…Œμ΄μ…˜ 결정이라 IMAGE_MAPκ³Ό 같은 계측).
47
+ # 예: 6(λ³Έλ¬Έ) β†’ 7(이미지) β†’ 8(λ³Έλ¬Έ). total = μΉ΄λ“œ 수 + 이미지 수
48
+ _IMAGE_ORDERS = sorted(_repo.order(cid) for cid in IMAGE_MAP if _repo.has_card(cid))
49
+ _TOTAL_PAGES = _repo.total_cards + len(_IMAGE_ORDERS)
50
+
51
+
52
+ def _page_index(card_id: str) -> int:
53
+ """μΉ΄λ“œ λ³Έλ¬Έ νŽ˜μ΄μ§€μ˜ ν‘œμ‹œ 번호 (μ•žμ„  이미지 νŽ˜μ΄μ§€λ“€ + 자기 이미지 νŽ˜μ΄μ§€λ§ŒνΌ λ°€λ¦Ό).
54
+ 이미지 μΉ΄λ“œμ˜ 이미지 νŽ˜μ΄μ§€ λ²ˆν˜ΈλŠ” 이 κ°’ - 1 (ν΄λΌμ΄μ–ΈνŠΈκ°€ μœ λ„)."""
55
+ order = _repo.order(card_id)
56
+ images_before = bisect.bisect_left(_IMAGE_ORDERS, order)
57
+ own_image = 1 if card_id in IMAGE_MAP else 0
58
+ return order + 1 + images_before + own_image
59
+ _pocket = PocketService(
60
+ _repo, plugins=[TurnLoggingPlugin(PlaylogRepository(ROOT / "logs" / "playlog.jsonl"))])
61
+ _playdb = PlaySessionRepository(ROOT / "data" / "sessions.db")
62
+ _playdb.purge(days=30)
63
+
64
+
65
+ @dataclass
66
+ class PlaySession:
67
+ engine: StoryEngine
68
+ last_step: StepResult
69
+ pocket_sid: Optional[str] = None
70
+ episode_titles: dict = field(default_factory=dict)
71
+
72
+
73
+ _sessions: dict[str, PlaySession] = {}
74
+
75
+
76
+ class ChooseBody(BaseModel):
77
+ index: int
78
+
79
+
80
+ class TurnBody(BaseModel):
81
+ text: str
82
+
83
+
84
+ def _get(sid: str) -> PlaySession:
85
+ ps = _sessions.get(sid)
86
+ if ps is None:
87
+ ps = _restore(sid) # μ„œλ²„ μž¬μ‹œμž‘ ν›„ β€” μ €μž₯μ†Œμ—μ„œ 볡원
88
+ if ps is None:
89
+ raise HTTPException(404, "μ„Έμ…˜ μ—†μŒ")
90
+ return ps
91
+
92
+
93
+ def _restore(sid: str) -> Optional[PlaySession]:
94
+ """μ €μž₯ μŠ€λƒ…μƒ·μ—μ„œ μ„Έμ…˜ 볡원. 포켓은 폐기(λΉ„μ˜μ† β€” μœ μ € κ²°μ •), 읽기 ν™”λ©΄λΆ€ν„°."""
95
+ payload = _playdb.load(sid, _repo.content_version)
96
+ if payload is None:
97
+ return None
98
+ engine = StoryEngine.restore(_repo, payload)
99
+ ps = PlaySession(engine=engine, last_step=engine.current_step(),
100
+ episode_titles=_episode_titles())
101
+ _sessions[sid] = ps
102
+ return ps
103
+
104
+
105
+ def _save(sid: str, ps: PlaySession) -> None:
106
+ """write-through β€” μ˜μ† μƒνƒœκ°€ λ°”λ€ŒλŠ” λͺ¨λ“  μ§€μ μ—μ„œ μ¦‰μ‹œ μ €μž₯."""
107
+ _playdb.save(sid, ps.engine.snapshot(), _repo.content_version)
108
+
109
+
110
+ def _step_json(ps: PlaySession) -> dict:
111
+ step = ps.last_step
112
+ if step.ending is not None:
113
+ return {"card": None, "narration": [], "choices": [], "can_chat": False,
114
+ "image": None,
115
+ "progress": {"index": _TOTAL_PAGES, "total": _TOTAL_PAGES},
116
+ "ending": {"id": step.ending.id, "label": step.ending.label,
117
+ "description": step.ending.description}}
118
+ card = step.card
119
+ return {
120
+ "card": {"id": card.id, "title": card.title, "episode": card.episode_id,
121
+ "episode_title": ps.episode_titles.get(card.episode_id, card.episode_id)},
122
+ "narration": [{"type": b.type, "text": b.text, "speaker": b.speaker}
123
+ for b in step.narration],
124
+ "choices": [{"label": c.label} for c in step.choices],
125
+ "can_chat": card.can_chat,
126
+ "image": IMAGE_MAP.get(card.id),
127
+ "progress": {"index": _page_index(card.id), "total": _TOTAL_PAGES},
128
+ "ending": None,
129
+ }
130
+
131
+
132
+ # ── μ„Έμ…˜ ────────────────────────────────────────────────────
133
+ @app.post("/api/session")
134
+ async def create_session():
135
+ engine = StoryEngine(_repo)
136
+ ps = PlaySession(engine=engine, last_step=engine.start(),
137
+ episode_titles=_episode_titles())
138
+ sid = uuid.uuid4().hex[:12]
139
+ _sessions[sid] = ps
140
+ _save(sid, ps)
141
+ return {"session_id": sid, "step": _step_json(ps)}
142
+
143
+
144
+ def _episode_titles() -> dict:
145
+ from engine.schemas.validate import load_content
146
+ episodes, *_ = load_content(_repo.root)
147
+ return {ep.episode.id: ep.episode.title for ep in episodes}
148
+
149
+
150
+ @app.get("/api/session/{sid}")
151
+ async def get_session(sid: str):
152
+ return {"step": _step_json(_get(sid))}
153
+
154
+
155
+ # ── 리딩 ────────────────────────────────────────────────────
156
+ @app.post("/api/session/{sid}/advance")
157
+ async def advance(sid: str):
158
+ ps = _get(sid)
159
+ if ps.pocket_sid:
160
+ raise HTTPException(409, "포켓 λŒ€ν™” 쀑 β€” λ¨Όμ € 닫을 것")
161
+ if ps.last_step.ending or ps.last_step.choices:
162
+ raise HTTPException(409, "advance λΆˆκ°€ μƒνƒœ")
163
+ ps.last_step = ps.engine.advance()
164
+ _save(sid, ps)
165
+ return {"step": _step_json(ps)}
166
+
167
+
168
+ @app.post("/api/session/{sid}/choose")
169
+ async def choose(sid: str, body: ChooseBody):
170
+ ps = _get(sid)
171
+ if ps.pocket_sid:
172
+ raise HTTPException(409, "포켓 λŒ€ν™” 쀑 β€” λ¨Όμ € 닫을 것")
173
+ if ps.last_step.ending or not ps.last_step.choices:
174
+ raise HTTPException(409, "선택 λΆˆκ°€ μƒνƒœ")
175
+ if not 0 <= body.index < len(ps.last_step.choices):
176
+ raise HTTPException(422, "선택지 λ²”μœ„ λ°–")
177
+ picked, ps.last_step = ps.engine.choose(body.index)
178
+ _save(sid, ps)
179
+ return {"picked": {"label": picked.label, "result": picked.result},
180
+ "step": _step_json(ps)}
181
+
182
+
183
+ # ── κ°œμž… 포켓 ───────────────────────────────────────────────
184
+ @app.post("/api/session/{sid}/pocket/open")
185
+ async def pocket_open(sid: str):
186
+ ps = _get(sid)
187
+ if ps.pocket_sid:
188
+ return {"opened": True}
189
+ if ps.last_step.ending or not (ps.last_step.card and ps.last_step.card.can_chat):
190
+ raise HTTPException(409, "이 μΉ΄λ“œλŠ” μžμœ μ±„νŒ… 포켓이 μ•„λ‹˜")
191
+ ps.pocket_sid = await _pocket.open(ps.engine.current_id, ps.engine.state)
192
+ return {"opened": True}
193
+
194
+
195
+ @app.post("/api/session/{sid}/pocket/turn")
196
+ async def pocket_turn(sid: str, body: TurnBody):
197
+ ps = _get(sid)
198
+ if not ps.pocket_sid:
199
+ raise HTTPException(409, "포켓이 μ—΄λ € μžˆμ§€ μ•ŠμŒ")
200
+ turn = await _pocket.run_turn(ps.pocket_sid, body.text)
201
+ if turn.converged: # 수렴 β†’ μžλ™ 컀밋·볡귀
202
+ await _pocket.close(ps.pocket_sid, ps.engine.state)
203
+ ps.pocket_sid = None
204
+ _save(sid, ps) # 포켓 μ»€λ°‹μœΌλ‘œ μˆ¨μ€ μƒνƒœ λ³€κ²½
205
+ return {"reply": turn.reply, "converged": turn.converged,
206
+ "reason": turn.reason, "turn_no": turn.turn_no}
207
+
208
+
209
+ @app.post("/api/session/{sid}/pocket/close")
210
+ async def pocket_close(sid: str):
211
+ ps = _get(sid)
212
+ if ps.pocket_sid:
213
+ await _pocket.close(ps.pocket_sid, ps.engine.state)
214
+ ps.pocket_sid = None
215
+ _save(sid, ps)
216
+ return {"closed": True}
217
+
218
+
219
+ # ── 메타 (ν‘œμ§€ λ“± ν”„λ ˆμ  ν…Œμ΄μ…˜ μƒμˆ˜) ────────────────────────
220
+ @app.get("/api/meta")
221
+ async def meta():
222
+ return {"cover_image": COVER_IMAGE, "title": "μΉ΄λ₯΄λ°€λΌ", "subtitle": "μž λ“€μ§€ μ•ŠλŠ” λ°€ Β· λ„μž… 아크"}
223
+
224
+
225
+ # ── 정적 ν”„λ‘ νŠΈ ─────────────────────────────────────────────
226
+ @app.get("/")
227
+ async def index():
228
+ return FileResponse(ROOT / "web" / "index.html")
229
+
230
+
231
+ app.mount("/static", StaticFiles(directory=ROOT / "web"), name="static")
232
+ app.mount("/assets", StaticFiles(directory=ASSET_DIR), name="assets")
engine/schemas/__init__.py ADDED
File without changes
engine/schemas/__pycache__/__init__.cpython-312.pyc ADDED
Binary file (160 Bytes). View file
 
engine/schemas/__pycache__/__init__.cpython-314.pyc ADDED
Binary file (162 Bytes). View file
 
engine/schemas/__pycache__/beatcard.cpython-312.pyc ADDED
Binary file (5.75 kB). View file
 
engine/schemas/__pycache__/beatcard.cpython-314.pyc ADDED
Binary file (7.46 kB). View file
 
engine/schemas/__pycache__/chapter.cpython-312.pyc ADDED
Binary file (2.5 kB). View file
 
engine/schemas/__pycache__/chapter.cpython-314.pyc ADDED
Binary file (2.63 kB). View file
 
engine/schemas/__pycache__/character.cpython-312.pyc ADDED
Binary file (3.16 kB). View file
 
engine/schemas/__pycache__/character.cpython-314.pyc ADDED
Binary file (3.34 kB). View file