Forkei Claude Opus 4.7 (1M context) commited on
Commit
7fedbc5
·
1 Parent(s): 9e8d9b9

Add REST API, HTML UI, and tests

Browse files

- Characters REST endpoints: list / detail (with memory counts by
scope and type) / create (202 + BackgroundTasks) / delete
(soft, rejects presets) / browse memories (paginated, filterable
by scope and type)
- Plain HTML UI via Jinja2 + Tailwind CDN:
/ character grid
/characters/new create form with slider inputs
/characters/{id} detail page with style bars, counts, sample
memories per type; auto-refresh via meta tag
while state = generating_memories
- FastAPI lifespan wires logging, DB, and idempotent preset seeding
- Tests:
style helper bucket boundaries + shape
memory schema validation (valence range, enums, strip/dedup)
memory generator mocked (happy path, LLM failure, too-few guard)
preset seeding idempotency
opt-in live LLM test (pytest.mark.live; skipped unless
RUN_LIVE_LLM_TESTS=1 and real GEMINI_API_KEY present)

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

app/api/__init__.py ADDED
File without changes
app/api/characters.py ADDED
@@ -0,0 +1,134 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from __future__ import annotations
2
+
3
+ import logging
4
+ from datetime import datetime
5
+
6
+ from fastapi import APIRouter, BackgroundTasks, Depends, HTTPException, Query, status
7
+ from sqlalchemy import select
8
+ from sqlalchemy.orm import Session
9
+
10
+ from app.db import get_session
11
+ from app.memory.crud import counts_by_scope, counts_by_type, list_for_character
12
+ from app.models.character import Character, CharacterState
13
+ from app.models.memory import MemoryScope, MemoryType
14
+ from app.schemas.character import (
15
+ CharacterCreate,
16
+ CharacterDetail,
17
+ CharacterRead,
18
+ CharacterSummary,
19
+ MemoryCountsByScope,
20
+ MemoryCountsByType,
21
+ )
22
+ from app.schemas.memory import MemoryRead
23
+
24
+ logger = logging.getLogger(__name__)
25
+
26
+ router = APIRouter(prefix="/api/characters", tags=["characters"])
27
+
28
+
29
+ def _run_generation_bg(character_id: str) -> None:
30
+ from app.characters.memory_generator import generate_and_store
31
+
32
+ try:
33
+ generate_and_store(character_id)
34
+ except Exception:
35
+ logger.exception("Memory generation failed for %s", character_id)
36
+
37
+
38
+ @router.get("", response_model=list[CharacterSummary])
39
+ def list_characters(session: Session = Depends(get_session)) -> list[CharacterSummary]:
40
+ rows = session.execute(
41
+ select(Character).where(Character.deleted_at.is_(None)).order_by(Character.created_at.desc())
42
+ ).scalars()
43
+ return [CharacterSummary.model_validate(c) for c in rows]
44
+
45
+
46
+ @router.get("/{character_id}", response_model=CharacterDetail)
47
+ def get_character(character_id: str, session: Session = Depends(get_session)) -> CharacterDetail:
48
+ character = session.get(Character, character_id)
49
+ if character is None or character.deleted_at is not None:
50
+ raise HTTPException(status_code=404, detail="Character not found")
51
+
52
+ scope_counts = counts_by_scope(session, character_id=character_id)
53
+ type_counts = counts_by_type(session, character_id=character_id)
54
+
55
+ detail = CharacterDetail.model_validate(character).model_copy(
56
+ update={
57
+ "memory_count": sum(scope_counts.values()),
58
+ "memory_counts_by_scope": MemoryCountsByScope(**scope_counts),
59
+ "memory_counts_by_type": MemoryCountsByType(**type_counts),
60
+ }
61
+ )
62
+ return detail
63
+
64
+
65
+ @router.post("", response_model=CharacterRead, status_code=status.HTTP_202_ACCEPTED)
66
+ def create_character(
67
+ payload: CharacterCreate,
68
+ background: BackgroundTasks,
69
+ session: Session = Depends(get_session),
70
+ ) -> CharacterRead:
71
+ character = Character(
72
+ name=payload.name,
73
+ short_description=payload.short_description,
74
+ backstory=payload.backstory,
75
+ avatar_emoji=payload.avatar_emoji,
76
+ aggression=payload.aggression,
77
+ risk_tolerance=payload.risk_tolerance,
78
+ patience=payload.patience,
79
+ trash_talk=payload.trash_talk,
80
+ target_elo=payload.target_elo,
81
+ adaptive=payload.adaptive,
82
+ opening_preferences=list(payload.opening_preferences),
83
+ voice_descriptor=payload.voice_descriptor,
84
+ quirks=payload.quirks,
85
+ state=CharacterState.GENERATING_MEMORIES,
86
+ memory_generation_started_at=datetime.utcnow(),
87
+ is_preset=False,
88
+ )
89
+ session.add(character)
90
+ session.commit()
91
+ session.refresh(character)
92
+
93
+ background.add_task(_run_generation_bg, character.id)
94
+ return CharacterRead.model_validate(character)
95
+
96
+
97
+ @router.get("/{character_id}/memories", response_model=dict)
98
+ def list_memories(
99
+ character_id: str,
100
+ scope: MemoryScope | None = None,
101
+ type: MemoryType | None = None,
102
+ offset: int = Query(0, ge=0),
103
+ limit: int = Query(50, ge=1, le=200),
104
+ session: Session = Depends(get_session),
105
+ ) -> dict:
106
+ character = session.get(Character, character_id)
107
+ if character is None or character.deleted_at is not None:
108
+ raise HTTPException(status_code=404, detail="Character not found")
109
+
110
+ rows, total = list_for_character(
111
+ session,
112
+ character_id=character_id,
113
+ scope=scope,
114
+ type_=type,
115
+ offset=offset,
116
+ limit=limit,
117
+ )
118
+ return {
119
+ "total": total,
120
+ "offset": offset,
121
+ "limit": limit,
122
+ "items": [MemoryRead.model_validate(r).model_dump(mode="json") for r in rows],
123
+ }
124
+
125
+
126
+ @router.delete("/{character_id}", status_code=status.HTTP_204_NO_CONTENT)
127
+ def delete_character(character_id: str, session: Session = Depends(get_session)) -> None:
128
+ character = session.get(Character, character_id)
129
+ if character is None or character.deleted_at is not None:
130
+ raise HTTPException(status_code=404, detail="Character not found")
131
+ if character.is_preset:
132
+ raise HTTPException(status_code=403, detail="Preset characters cannot be deleted")
133
+ character.deleted_at = datetime.utcnow()
134
+ session.commit()
app/main.py ADDED
@@ -0,0 +1,45 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from __future__ import annotations
2
+
3
+ import logging
4
+ from contextlib import asynccontextmanager
5
+
6
+ from fastapi import FastAPI
7
+
8
+ from app.api.characters import router as characters_api
9
+ from app.characters.seed import seed_presets
10
+ from app.config import get_settings
11
+ from app.db import init_db
12
+ from app.logging_config import configure_logging
13
+ from app.web.routes import router as web_router
14
+
15
+ logger = logging.getLogger(__name__)
16
+
17
+
18
+ @asynccontextmanager
19
+ async def lifespan(app: FastAPI):
20
+ configure_logging()
21
+ init_db()
22
+
23
+ settings = get_settings()
24
+ # Only kick off preset memory generation if the API key is present — the app
25
+ # should still come up locally without one so people can inspect the UI.
26
+ run_gen = bool(settings.gemini_api_key)
27
+ results = seed_presets(run_generation=run_gen)
28
+ created = [k for k, v in results.items() if v]
29
+ if created:
30
+ logger.info("Seeded new presets: %s", created)
31
+ if not run_gen:
32
+ logger.warning(
33
+ "GEMINI_API_KEY is not set — presets were seeded but memory generation is skipped."
34
+ )
35
+ yield
36
+
37
+
38
+ def create_app() -> FastAPI:
39
+ app = FastAPI(title="Metropolis Chess Club", lifespan=lifespan)
40
+ app.include_router(characters_api)
41
+ app.include_router(web_router)
42
+ return app
43
+
44
+
45
+ app = create_app()
app/web/__init__.py ADDED
File without changes
app/web/routes.py ADDED
@@ -0,0 +1,162 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from __future__ import annotations
2
+
3
+ from datetime import datetime
4
+ from pathlib import Path
5
+
6
+ from fastapi import APIRouter, BackgroundTasks, Depends, Form, HTTPException, Request
7
+ from fastapi.responses import HTMLResponse, RedirectResponse
8
+ from fastapi.templating import Jinja2Templates
9
+ from sqlalchemy import select
10
+ from sqlalchemy.orm import Session
11
+
12
+ from app.characters.openings import OPENINGS
13
+ from app.characters.style import style_to_prompt_fragments
14
+ from app.db import get_session
15
+ from app.memory.crud import counts_by_scope, counts_by_type, list_for_character
16
+ from app.models.character import Character, CharacterState
17
+ from app.schemas.character import CharacterCreate
18
+
19
+ _TEMPLATE_DIR = Path(__file__).parent / "templates"
20
+ templates = Jinja2Templates(directory=str(_TEMPLATE_DIR))
21
+
22
+ router = APIRouter(tags=["web"])
23
+
24
+
25
+ def _run_generation_bg(character_id: str) -> None:
26
+ import logging
27
+
28
+ from app.characters.memory_generator import generate_and_store
29
+
30
+ try:
31
+ generate_and_store(character_id)
32
+ except Exception:
33
+ logging.getLogger(__name__).exception(
34
+ "Memory generation failed for %s", character_id
35
+ )
36
+
37
+
38
+ @router.get("/", response_class=HTMLResponse)
39
+ def index(request: Request, session: Session = Depends(get_session)) -> HTMLResponse:
40
+ chars = list(
41
+ session.execute(
42
+ select(Character)
43
+ .where(Character.deleted_at.is_(None))
44
+ .order_by(Character.is_preset.desc(), Character.created_at.desc())
45
+ ).scalars()
46
+ )
47
+ return templates.TemplateResponse(
48
+ request, "index.html", {"characters": chars}
49
+ )
50
+
51
+
52
+ @router.get("/characters/new", response_class=HTMLResponse)
53
+ def new_character_form(request: Request) -> HTMLResponse:
54
+ return templates.TemplateResponse(
55
+ request,
56
+ "new.html",
57
+ {"openings": OPENINGS},
58
+ )
59
+
60
+
61
+ @router.post("/characters/new")
62
+ def create_character_html(
63
+ request: Request,
64
+ background: BackgroundTasks,
65
+ name: str = Form(...),
66
+ short_description: str = Form(""),
67
+ backstory: str = Form(""),
68
+ avatar_emoji: str = Form("♟️"),
69
+ aggression: int = Form(5),
70
+ risk_tolerance: int = Form(5),
71
+ patience: int = Form(5),
72
+ trash_talk: int = Form(5),
73
+ target_elo: int = Form(1400),
74
+ adaptive: str = Form(""),
75
+ opening_preferences: list[str] = Form(default=[]),
76
+ voice_descriptor: str = Form(""),
77
+ quirks: str = Form(""),
78
+ session: Session = Depends(get_session),
79
+ ) -> RedirectResponse:
80
+ payload = CharacterCreate(
81
+ name=name,
82
+ short_description=short_description,
83
+ backstory=backstory,
84
+ avatar_emoji=avatar_emoji or "♟️",
85
+ aggression=aggression,
86
+ risk_tolerance=risk_tolerance,
87
+ patience=patience,
88
+ trash_talk=trash_talk,
89
+ target_elo=target_elo,
90
+ adaptive=bool(adaptive),
91
+ opening_preferences=opening_preferences,
92
+ voice_descriptor=voice_descriptor,
93
+ quirks=quirks,
94
+ )
95
+ character = Character(
96
+ name=payload.name,
97
+ short_description=payload.short_description,
98
+ backstory=payload.backstory,
99
+ avatar_emoji=payload.avatar_emoji,
100
+ aggression=payload.aggression,
101
+ risk_tolerance=payload.risk_tolerance,
102
+ patience=payload.patience,
103
+ trash_talk=payload.trash_talk,
104
+ target_elo=payload.target_elo,
105
+ adaptive=payload.adaptive,
106
+ opening_preferences=list(payload.opening_preferences),
107
+ voice_descriptor=payload.voice_descriptor,
108
+ quirks=payload.quirks,
109
+ state=CharacterState.GENERATING_MEMORIES,
110
+ memory_generation_started_at=datetime.utcnow(),
111
+ is_preset=False,
112
+ )
113
+ session.add(character)
114
+ session.commit()
115
+ session.refresh(character)
116
+ background.add_task(_run_generation_bg, character.id)
117
+
118
+ return RedirectResponse(url=f"/characters/{character.id}", status_code=303)
119
+
120
+
121
+ @router.get("/characters/{character_id}", response_class=HTMLResponse)
122
+ def character_detail(
123
+ request: Request,
124
+ character_id: str,
125
+ session: Session = Depends(get_session),
126
+ ) -> HTMLResponse:
127
+ character = session.get(Character, character_id)
128
+ if character is None or character.deleted_at is not None:
129
+ raise HTTPException(status_code=404, detail="Character not found")
130
+
131
+ scope_counts = counts_by_scope(session, character_id=character_id)
132
+ type_counts = counts_by_type(session, character_id=character_id)
133
+ fragments = style_to_prompt_fragments(character)
134
+
135
+ # Sample a handful of memories grouped by type for a quick tour.
136
+ samples_by_type: dict[str, list] = {}
137
+ for type_value in type_counts:
138
+ from app.models.memory import MemoryType as MT
139
+
140
+ try:
141
+ mt = MT(type_value)
142
+ except ValueError:
143
+ continue
144
+ rows, _ = list_for_character(
145
+ session, character_id=character_id, type_=mt, offset=0, limit=3
146
+ )
147
+ samples_by_type[type_value] = rows
148
+
149
+ return templates.TemplateResponse(
150
+ request,
151
+ "detail.html",
152
+ {
153
+ "character": character,
154
+ "scope_counts": scope_counts,
155
+ "type_counts": type_counts,
156
+ "total_memories": sum(scope_counts.values()),
157
+ "fragments": fragments,
158
+ "samples_by_type": samples_by_type,
159
+ "is_generating": character.state == CharacterState.GENERATING_MEMORIES,
160
+ "is_failed": character.state == CharacterState.GENERATION_FAILED,
161
+ },
162
+ )
app/web/templates/base.html ADDED
@@ -0,0 +1,31 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ <!DOCTYPE html>
2
+ <html lang="en">
3
+ <head>
4
+ <meta charset="utf-8" />
5
+ <meta name="viewport" content="width=device-width, initial-scale=1" />
6
+ <title>{% block title %}Metropolis Chess Club{% endblock %}</title>
7
+ <script src="https://cdn.tailwindcss.com"></script>
8
+ {% block head_extra %}{% endblock %}
9
+ </head>
10
+ <body class="min-h-screen bg-neutral-950 text-neutral-100">
11
+ <header class="border-b border-neutral-800 bg-neutral-900/60 backdrop-blur">
12
+ <div class="max-w-6xl mx-auto px-6 py-4 flex items-center justify-between">
13
+ <a href="/" class="text-xl font-semibold tracking-wide">
14
+ <span class="mr-2">♟</span>Metropolis Chess Club
15
+ </a>
16
+ <nav class="text-sm text-neutral-300 space-x-4">
17
+ <a href="/" class="hover:text-white">Characters</a>
18
+ <a href="/characters/new" class="rounded bg-emerald-500 px-3 py-1.5 font-medium text-neutral-900 hover:bg-emerald-400">
19
+ + New character
20
+ </a>
21
+ </nav>
22
+ </div>
23
+ </header>
24
+ <main class="max-w-6xl mx-auto px-6 py-8">
25
+ {% block content %}{% endblock %}
26
+ </main>
27
+ <footer class="max-w-6xl mx-auto px-6 py-8 text-center text-xs text-neutral-500">
28
+ Phase 1 — characters &amp; memories only. Gameplay arrives in Phase 2.
29
+ </footer>
30
+ </body>
31
+ </html>
app/web/templates/detail.html ADDED
@@ -0,0 +1,138 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {% extends "base.html" %}
2
+
3
+ {% block title %}{{ character.name }} — Metropolis Chess Club{% endblock %}
4
+
5
+ {% block head_extra %}
6
+ {% if is_generating %}
7
+ <meta http-equiv="refresh" content="4" />
8
+ {% endif %}
9
+ {% endblock %}
10
+
11
+ {% block content %}
12
+ <div class="flex items-start gap-6">
13
+ <div class="text-6xl">{{ character.avatar_emoji or "♟" }}</div>
14
+ <div class="flex-1">
15
+ <div class="flex items-center gap-3">
16
+ <h1 class="text-3xl font-semibold">{{ character.name }}</h1>
17
+ {% if character.is_preset %}
18
+ <span class="text-[10px] uppercase tracking-wider bg-neutral-800 text-neutral-300 rounded px-1.5 py-0.5">Preset</span>
19
+ {% endif %}
20
+ {% if is_generating %}
21
+ <span class="text-xs bg-amber-900/60 text-amber-200 rounded px-2 py-0.5 animate-pulse">Generating memories…</span>
22
+ {% elif is_failed %}
23
+ <span class="text-xs bg-rose-900/60 text-rose-200 rounded px-2 py-0.5">Generation failed</span>
24
+ {% else %}
25
+ <span class="text-xs bg-emerald-900/60 text-emerald-200 rounded px-2 py-0.5">Ready</span>
26
+ {% endif %}
27
+ </div>
28
+ <p class="text-neutral-300 mt-1">{{ character.short_description }}</p>
29
+ <div class="mt-3 text-sm text-neutral-400">
30
+ <span>Target {{ character.target_elo }} Elo</span>
31
+ {% if character.adaptive %} · <span>adaptive</span>{% endif %}
32
+ {% if character.voice_descriptor %} · <span class="italic">{{ character.voice_descriptor }}</span>{% endif %}
33
+ </div>
34
+ </div>
35
+ </div>
36
+
37
+ {% if is_failed and character.memory_generation_error %}
38
+ <div class="mt-6 rounded border border-rose-900/80 bg-rose-950/40 p-4 text-sm text-rose-200">
39
+ <div class="font-semibold mb-1">Memory generation failed</div>
40
+ <code class="text-xs">{{ character.memory_generation_error }}</code>
41
+ </div>
42
+ {% endif %}
43
+
44
+ <section class="mt-8 grid grid-cols-1 md:grid-cols-2 gap-6">
45
+ <div class="rounded border border-neutral-800 bg-neutral-900 p-5">
46
+ <h2 class="text-sm uppercase tracking-wider text-neutral-400 mb-3">Style</h2>
47
+ <dl class="space-y-2 text-sm">
48
+ {% for key, val in [
49
+ ("Aggression", character.aggression),
50
+ ("Risk tolerance", character.risk_tolerance),
51
+ ("Patience", character.patience),
52
+ ("Trash talk", character.trash_talk),
53
+ ] %}
54
+ <div class="flex items-center gap-3">
55
+ <dt class="w-32 text-neutral-400">{{ key }}</dt>
56
+ <dd class="flex-1">
57
+ <div class="h-2 bg-neutral-800 rounded overflow-hidden">
58
+ <div class="h-full bg-emerald-500" style="width: {{ val * 10 }}%"></div>
59
+ </div>
60
+ </dd>
61
+ <dd class="w-6 text-right text-neutral-300">{{ val }}</dd>
62
+ </div>
63
+ {% endfor %}
64
+ </dl>
65
+ <h3 class="mt-5 text-sm uppercase tracking-wider text-neutral-400 mb-2">Prompt fragments</h3>
66
+ <ul class="text-xs text-neutral-300 space-y-1">
67
+ <li><span class="text-neutral-500">aggression:</span> {{ fragments.aggression }}</li>
68
+ <li><span class="text-neutral-500">risk:</span> {{ fragments.risk_tolerance }}</li>
69
+ <li><span class="text-neutral-500">patience:</span> {{ fragments.patience }}</li>
70
+ <li><span class="text-neutral-500">talk:</span> {{ fragments.trash_talk }}</li>
71
+ </ul>
72
+ </div>
73
+
74
+ <div class="rounded border border-neutral-800 bg-neutral-900 p-5">
75
+ <h2 class="text-sm uppercase tracking-wider text-neutral-400 mb-3">Memory summary</h2>
76
+ <div class="text-3xl font-semibold">{{ total_memories }}</div>
77
+ <div class="text-xs text-neutral-500 mb-4">memories total</div>
78
+ <div class="grid grid-cols-2 gap-2 text-xs">
79
+ {% for scope, n in scope_counts.items() %}
80
+ <div class="flex justify-between rounded bg-neutral-800/60 px-2 py-1">
81
+ <span class="text-neutral-400">{{ scope }}</span><span>{{ n }}</span>
82
+ </div>
83
+ {% endfor %}
84
+ </div>
85
+ <div class="mt-3 grid grid-cols-2 gap-2 text-xs">
86
+ {% for typ, n in type_counts.items() %}
87
+ <div class="flex justify-between rounded bg-neutral-800/60 px-2 py-1">
88
+ <span class="text-neutral-400">{{ typ }}</span><span>{{ n }}</span>
89
+ </div>
90
+ {% endfor %}
91
+ </div>
92
+ {% if character.opening_preferences %}
93
+ <h3 class="mt-5 text-sm uppercase tracking-wider text-neutral-400 mb-2">Openings</h3>
94
+ <ul class="text-xs text-neutral-300 space-y-0.5">
95
+ {% for op in character.opening_preferences %}
96
+ <li>· {{ op }}</li>
97
+ {% endfor %}
98
+ </ul>
99
+ {% endif %}
100
+ </div>
101
+ </section>
102
+
103
+ <section class="mt-8 rounded border border-neutral-800 bg-neutral-900 p-5">
104
+ <h2 class="text-sm uppercase tracking-wider text-neutral-400 mb-3">Backstory</h2>
105
+ <p class="text-sm text-neutral-200 whitespace-pre-wrap leading-relaxed">{{ character.backstory }}</p>
106
+ {% if character.quirks %}
107
+ <h3 class="mt-5 text-sm uppercase tracking-wider text-neutral-400 mb-2">Quirks</h3>
108
+ <p class="text-sm text-neutral-300">{{ character.quirks }}</p>
109
+ {% endif %}
110
+ </section>
111
+
112
+ {% if samples_by_type %}
113
+ <section class="mt-8">
114
+ <h2 class="text-sm uppercase tracking-wider text-neutral-400 mb-3">Sample memories</h2>
115
+ <div class="grid grid-cols-1 md:grid-cols-2 gap-4">
116
+ {% for typ, memories in samples_by_type.items() %}
117
+ <div class="rounded border border-neutral-800 bg-neutral-900 p-5">
118
+ <div class="flex items-center justify-between mb-3">
119
+ <div class="text-xs uppercase tracking-wider text-neutral-400">{{ typ }}</div>
120
+ <div class="text-xs text-neutral-500">{{ type_counts.get(typ, 0) }} total</div>
121
+ </div>
122
+ <ul class="space-y-3">
123
+ {% for m in memories %}
124
+ <li class="text-sm text-neutral-200">
125
+ <div class="leading-relaxed">{{ m.narrative_text }}</div>
126
+ <div class="mt-1 text-xs text-neutral-500">
127
+ valence {{ "%.2f"|format(m.emotional_valence) }} ·
128
+ {% for t in m.triggers[:5] %}<span class="inline-block bg-neutral-800 text-neutral-300 rounded px-1.5 py-0.5 mr-1">{{ t }}</span>{% endfor %}
129
+ </div>
130
+ </li>
131
+ {% endfor %}
132
+ </ul>
133
+ </div>
134
+ {% endfor %}
135
+ </div>
136
+ </section>
137
+ {% endif %}
138
+ {% endblock %}
app/web/templates/index.html ADDED
@@ -0,0 +1,44 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {% extends "base.html" %}
2
+
3
+ {% block content %}
4
+ <div class="flex items-end justify-between mb-6">
5
+ <div>
6
+ <h1 class="text-2xl font-semibold">Characters</h1>
7
+ <p class="text-sm text-neutral-400">Pick one to inspect, or create your own opponent.</p>
8
+ </div>
9
+ </div>
10
+
11
+ <div class="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-4">
12
+ {% for c in characters %}
13
+ <a href="/characters/{{ c.id }}" class="group rounded-xl border border-neutral-800 bg-neutral-900 hover:border-neutral-600 hover:bg-neutral-800/60 transition p-5 block">
14
+ <div class="flex items-start justify-between">
15
+ <div class="text-3xl">{{ c.avatar_emoji or "♟" }}</div>
16
+ <div class="flex items-center gap-2">
17
+ {% if c.is_preset %}
18
+ <span class="text-[10px] uppercase tracking-wider bg-neutral-800 text-neutral-300 rounded px-1.5 py-0.5">Preset</span>
19
+ {% endif %}
20
+ {% if c.state.value == "generating_memories" %}
21
+ <span class="text-[10px] uppercase tracking-wider bg-amber-900/60 text-amber-200 rounded px-1.5 py-0.5">Generating…</span>
22
+ {% elif c.state.value == "generation_failed" %}
23
+ <span class="text-[10px] uppercase tracking-wider bg-rose-900/60 text-rose-200 rounded px-1.5 py-0.5">Failed</span>
24
+ {% else %}
25
+ <span class="text-[10px] uppercase tracking-wider bg-emerald-900/60 text-emerald-200 rounded px-1.5 py-0.5">Ready</span>
26
+ {% endif %}
27
+ </div>
28
+ </div>
29
+ <div class="mt-3">
30
+ <div class="text-lg font-semibold group-hover:text-white">{{ c.name }}</div>
31
+ <div class="text-sm text-neutral-400 mt-1 line-clamp-3">{{ c.short_description }}</div>
32
+ </div>
33
+ <div class="mt-4 flex items-center justify-between text-xs text-neutral-500">
34
+ <span>Target {{ c.target_elo }} Elo{% if c.adaptive %} · adaptive{% endif %}</span>
35
+ <span>agg {{ c.aggression }} · risk {{ c.risk_tolerance }} · pat {{ c.patience }} · talk {{ c.trash_talk }}</span>
36
+ </div>
37
+ </a>
38
+ {% else %}
39
+ <div class="col-span-full text-center py-12 text-neutral-500">
40
+ No characters yet. <a href="/characters/new" class="text-emerald-400 hover:underline">Create one.</a>
41
+ </div>
42
+ {% endfor %}
43
+ </div>
44
+ {% endblock %}
app/web/templates/new.html ADDED
@@ -0,0 +1,94 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {% extends "base.html" %}
2
+
3
+ {% block title %}New character — Metropolis Chess Club{% endblock %}
4
+
5
+ {% block content %}
6
+ <h1 class="text-2xl font-semibold mb-1">New character</h1>
7
+ <p class="text-sm text-neutral-400 mb-6">
8
+ We'll generate their memories from the backstory in the background. Takes ~30–60s.
9
+ </p>
10
+
11
+ <form method="post" action="/characters/new" class="space-y-6 max-w-3xl">
12
+
13
+ <div class="grid grid-cols-1 md:grid-cols-3 gap-4">
14
+ <div class="md:col-span-2">
15
+ <label class="block text-sm mb-1">Name</label>
16
+ <input required name="name" class="w-full rounded bg-neutral-900 border border-neutral-800 px-3 py-2" />
17
+ </div>
18
+ <div>
19
+ <label class="block text-sm mb-1">Avatar emoji</label>
20
+ <input name="avatar_emoji" maxlength="4" value="♟" class="w-full rounded bg-neutral-900 border border-neutral-800 px-3 py-2" />
21
+ </div>
22
+ </div>
23
+
24
+ <div>
25
+ <label class="block text-sm mb-1">Short description</label>
26
+ <input name="short_description" maxlength="280" class="w-full rounded bg-neutral-900 border border-neutral-800 px-3 py-2" />
27
+ <p class="text-xs text-neutral-500 mt-1">Shown on character cards. Keep it punchy.</p>
28
+ </div>
29
+
30
+ <div>
31
+ <label class="block text-sm mb-1">Backstory</label>
32
+ <textarea name="backstory" rows="8" class="w-full rounded bg-neutral-900 border border-neutral-800 px-3 py-2" placeholder="Where did they come from? Who taught them? What shaped their style?"></textarea>
33
+ <p class="text-xs text-neutral-500 mt-1">Feeds memory generation. More specific = richer memories.</p>
34
+ </div>
35
+
36
+ <div class="grid grid-cols-1 md:grid-cols-2 gap-4">
37
+ <div>
38
+ <label class="block text-sm mb-1">Voice / tone</label>
39
+ <input name="voice_descriptor" maxlength="280" placeholder="e.g. gruff Russian grandmaster" class="w-full rounded bg-neutral-900 border border-neutral-800 px-3 py-2" />
40
+ </div>
41
+ <div>
42
+ <label class="block text-sm mb-1">Quirks</label>
43
+ <input name="quirks" placeholder="habits, sayings, small tells" class="w-full rounded bg-neutral-900 border border-neutral-800 px-3 py-2" />
44
+ </div>
45
+ </div>
46
+
47
+ <fieldset class="space-y-3 rounded border border-neutral-800 p-4">
48
+ <legend class="px-2 text-sm text-neutral-300">Style sliders (1–10)</legend>
49
+ {% for key, label in [
50
+ ("aggression", "Aggression"),
51
+ ("risk_tolerance", "Risk tolerance"),
52
+ ("patience", "Patience"),
53
+ ("trash_talk", "Trash talk"),
54
+ ] %}
55
+ <div class="flex items-center gap-4">
56
+ <label class="text-sm w-32">{{ label }}</label>
57
+ <input type="range" min="1" max="10" value="5" name="{{ key }}"
58
+ oninput="this.nextElementSibling.value = this.value"
59
+ class="flex-1 accent-emerald-400" />
60
+ <output class="text-sm w-6 text-right text-neutral-300">5</output>
61
+ </div>
62
+ {% endfor %}
63
+ </fieldset>
64
+
65
+ <fieldset class="space-y-3 rounded border border-neutral-800 p-4">
66
+ <legend class="px-2 text-sm text-neutral-300">Skill</legend>
67
+ <div class="flex items-center gap-4">
68
+ <label class="text-sm w-32">Target Elo</label>
69
+ <input type="number" min="600" max="2600" step="50" value="1400" name="target_elo" class="w-32 rounded bg-neutral-900 border border-neutral-800 px-3 py-2" />
70
+ <label class="text-sm flex items-center gap-2 ml-6">
71
+ <input type="checkbox" name="adaptive" value="1" class="accent-emerald-400" />
72
+ Adaptive (use target_elo as floor)
73
+ </label>
74
+ </div>
75
+ </fieldset>
76
+
77
+ <div>
78
+ <label class="block text-sm mb-1">Opening preferences</label>
79
+ <select name="opening_preferences" multiple size="10" class="w-full rounded bg-neutral-900 border border-neutral-800 px-3 py-2 text-sm">
80
+ {% for op in openings %}
81
+ <option value="{{ op.name }}">{{ op.eco }} — {{ op.name }} ({{ op.group }})</option>
82
+ {% endfor %}
83
+ </select>
84
+ <p class="text-xs text-neutral-500 mt-1">Ctrl/Cmd-click to pick multiple.</p>
85
+ </div>
86
+
87
+ <div class="pt-2">
88
+ <button class="rounded bg-emerald-500 px-5 py-2 font-medium text-neutral-900 hover:bg-emerald-400">
89
+ Create character
90
+ </button>
91
+ <a href="/" class="ml-3 text-sm text-neutral-400 hover:text-white">Cancel</a>
92
+ </div>
93
+ </form>
94
+ {% endblock %}
tests/__init__.py ADDED
File without changes
tests/conftest.py ADDED
@@ -0,0 +1,49 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Test-suite bootstrap.
2
+
3
+ Runs BEFORE any `app.*` module is imported so the env vars pick up the
4
+ test database. Without this, `app.db` would grab the production SQLite
5
+ URL at import time.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import os
11
+ import tempfile
12
+ from pathlib import Path
13
+
14
+ _TMP_DIR = Path(tempfile.mkdtemp(prefix="metropolis_tests_"))
15
+ _TEST_DB_PATH = _TMP_DIR / "test.db"
16
+
17
+ os.environ.setdefault("DATABASE_URL", f"sqlite:///{_TEST_DB_PATH.as_posix()}")
18
+ os.environ.setdefault("GEMINI_API_KEY", "test-key-not-real")
19
+ os.environ.setdefault("LOG_DIR", str(_TMP_DIR / "logs"))
20
+
21
+ import pytest # noqa: E402
22
+ from sqlalchemy import delete # noqa: E402
23
+
24
+ from app.db import SessionLocal, engine, init_db # noqa: E402
25
+ from app.models.character import Character # noqa: E402
26
+ from app.models.memory import Memory # noqa: E402
27
+
28
+
29
+ @pytest.fixture(scope="session", autouse=True)
30
+ def _bootstrap_db() -> None:
31
+ init_db()
32
+
33
+
34
+ @pytest.fixture(autouse=True)
35
+ def _clean_tables():
36
+ """Wipe every row before each test for deterministic state."""
37
+ with engine.begin() as conn:
38
+ conn.execute(delete(Memory))
39
+ conn.execute(delete(Character))
40
+ yield
41
+
42
+
43
+ @pytest.fixture
44
+ def db_session():
45
+ session = SessionLocal()
46
+ try:
47
+ yield session
48
+ finally:
49
+ session.close()
tests/test_live_llm.py ADDED
@@ -0,0 +1,90 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Opt-in test that hits the real Gemini API.
2
+
3
+ Only runs when BOTH:
4
+ - env var RUN_LIVE_LLM_TESTS=1
5
+ - a real GEMINI_API_KEY is present (not the test placeholder)
6
+
7
+ Run it with:
8
+ RUN_LIVE_LLM_TESTS=1 GEMINI_API_KEY=... pytest -m live
9
+ """
10
+
11
+ from __future__ import annotations
12
+
13
+ import os
14
+
15
+ import pytest
16
+ from sqlalchemy import select
17
+
18
+ from app.characters.memory_generator import generate_and_store
19
+ from app.config import get_settings
20
+ from app.db import SessionLocal
21
+ from app.models.character import Character, CharacterState
22
+ from app.models.memory import Memory, MemoryScope, MemoryType
23
+
24
+ pytestmark = pytest.mark.live
25
+
26
+
27
+ def _should_skip() -> tuple[bool, str]:
28
+ if os.environ.get("RUN_LIVE_LLM_TESTS") != "1":
29
+ return True, "RUN_LIVE_LLM_TESTS not set"
30
+ key = os.environ.get("GEMINI_API_KEY", "")
31
+ if not key or key == "test-key-not-real":
32
+ return True, "real GEMINI_API_KEY not present"
33
+ return False, ""
34
+
35
+
36
+ def test_live_generation_produces_valid_memories():
37
+ skip, reason = _should_skip()
38
+ if skip:
39
+ pytest.skip(reason)
40
+
41
+ # The lru_cache in get_settings + get_llm_client may be holding the
42
+ # test-time API key. Refresh them against the current environment.
43
+ from app.config import get_settings as _s
44
+ from app.llm.client import get_llm_client as _c
45
+
46
+ _s.cache_clear()
47
+ _c.cache_clear()
48
+
49
+ settings = get_settings()
50
+
51
+ with SessionLocal() as s:
52
+ char = Character(
53
+ name="Anja Berg",
54
+ short_description="A quiet Norwegian endgame specialist",
55
+ backstory=(
56
+ "Grew up in Trondheim. Learned from her uncle, a retired engineer "
57
+ "who played correspondence chess. Spent her twenties studying Capablanca games."
58
+ ),
59
+ aggression=3,
60
+ risk_tolerance=4,
61
+ patience=9,
62
+ trash_talk=2,
63
+ target_elo=2200,
64
+ adaptive=False,
65
+ opening_preferences=["Catalan Opening", "English Opening"],
66
+ voice_descriptor="calm, precise Scandinavian English",
67
+ quirks="writes notes in tiny handwriting between games",
68
+ state=CharacterState.GENERATING_MEMORIES,
69
+ is_preset=False,
70
+ )
71
+ s.add(char)
72
+ s.commit()
73
+ character_id = char.id
74
+
75
+ count = generate_and_store(character_id)
76
+ assert settings.memory_gen_min // 2 <= count <= settings.memory_gen_max + 10
77
+
78
+ with SessionLocal() as s:
79
+ char = s.get(Character, character_id)
80
+ assert char.state == CharacterState.READY
81
+ rows = list(s.execute(select(Memory).where(Memory.character_id == character_id)).scalars())
82
+
83
+ assert len(rows) == count
84
+ for m in rows:
85
+ assert isinstance(m.scope, MemoryScope)
86
+ assert isinstance(m.type, MemoryType)
87
+ assert -1.0 <= m.emotional_valence <= 1.0
88
+ assert m.narrative_text and len(m.narrative_text) >= 20
89
+ assert isinstance(m.triggers, list) and m.triggers
90
+ assert isinstance(m.relevance_tags, list) and m.relevance_tags
tests/test_memory_generator.py ADDED
@@ -0,0 +1,135 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from __future__ import annotations
2
+
3
+ from sqlalchemy import select
4
+
5
+ from app.characters.memory_generator import GeneratedMemory, build_prompt, generate_and_store
6
+ from app.db import SessionLocal
7
+ from app.models.character import Character, CharacterState
8
+ from app.models.memory import Memory, MemoryScope, MemoryType
9
+
10
+
11
+ class FakeLLMClient:
12
+ """Duck-typed stand-in for `LLMClient` used in tests."""
13
+
14
+ def __init__(self, memories: list[GeneratedMemory] | Exception):
15
+ self._result = memories
16
+
17
+ def generate_structured(self, **kwargs):
18
+ if isinstance(self._result, Exception):
19
+ raise self._result
20
+ return self._result
21
+
22
+
23
+ def _make_character(session) -> str:
24
+ char = Character(
25
+ name="Test Character",
26
+ short_description="A test",
27
+ backstory="Born in a test. Lived in a test. Died in a test.",
28
+ aggression=6,
29
+ risk_tolerance=6,
30
+ patience=5,
31
+ trash_talk=4,
32
+ target_elo=1600,
33
+ adaptive=False,
34
+ opening_preferences=["Ruy Lopez", "Sicilian Najdorf"],
35
+ voice_descriptor="test voice",
36
+ quirks="never blinks",
37
+ state=CharacterState.GENERATING_MEMORIES,
38
+ )
39
+ session.add(char)
40
+ session.commit()
41
+ return char.id
42
+
43
+
44
+ def _fake_memories(n: int = 30) -> list[GeneratedMemory]:
45
+ out: list[GeneratedMemory] = []
46
+ types = list(MemoryType)
47
+ for i in range(n):
48
+ out.append(
49
+ GeneratedMemory(
50
+ scope=MemoryScope.CHARACTER_LORE,
51
+ type=types[i % len(types)],
52
+ emotional_valence=(i % 5 - 2) / 2.0, # spread -1..1
53
+ triggers=[f"trigger_{i}", f"alt_{i}"],
54
+ narrative_text=f"This is the {i}-th fake memory. It has enough length to pass validation.",
55
+ relevance_tags=[f"tag_{i % 3}"],
56
+ )
57
+ )
58
+ return out
59
+
60
+
61
+ def test_build_prompt_contains_character_details():
62
+ char = Character(
63
+ name="Vera",
64
+ short_description="quiet",
65
+ backstory="a long backstory here",
66
+ aggression=9,
67
+ risk_tolerance=2,
68
+ patience=3,
69
+ trash_talk=1,
70
+ target_elo=2000,
71
+ adaptive=True,
72
+ opening_preferences=["Ruy Lopez"],
73
+ voice_descriptor="calm",
74
+ quirks="hums",
75
+ )
76
+ prompt = build_prompt(char, target=40, minimum=30, maximum=50)
77
+ assert "Vera" in prompt
78
+ assert "a long backstory here" in prompt
79
+ assert "Ruy Lopez" in prompt
80
+ assert "2000" in prompt
81
+ assert "adapts" in prompt.lower() # adaptive line
82
+
83
+
84
+ def test_generate_and_store_persists_and_marks_ready():
85
+ with SessionLocal() as s:
86
+ character_id = _make_character(s)
87
+
88
+ fake = FakeLLMClient(_fake_memories(30))
89
+ count = generate_and_store(character_id, client=fake)
90
+ assert count == 30
91
+
92
+ with SessionLocal() as s:
93
+ char = s.get(Character, character_id)
94
+ assert char is not None
95
+ assert char.state == CharacterState.READY
96
+ assert char.memory_generation_error is None
97
+
98
+ memories = list(s.execute(select(Memory).where(Memory.character_id == character_id)).scalars())
99
+ assert len(memories) == 30
100
+ # Spread check — we should see multiple distinct types.
101
+ assert len({m.type for m in memories}) >= 4
102
+
103
+
104
+ def test_generate_and_store_failure_marks_character_failed():
105
+ with SessionLocal() as s:
106
+ character_id = _make_character(s)
107
+
108
+ fake = FakeLLMClient(RuntimeError("fake Gemini outage"))
109
+ import pytest
110
+
111
+ with pytest.raises(RuntimeError):
112
+ generate_and_store(character_id, client=fake)
113
+
114
+ with SessionLocal() as s:
115
+ char = s.get(Character, character_id)
116
+ assert char is not None
117
+ assert char.state == CharacterState.GENERATION_FAILED
118
+ assert char.memory_generation_error is not None
119
+ assert "fake Gemini outage" in char.memory_generation_error
120
+
121
+
122
+ def test_generate_and_store_rejects_too_few():
123
+ """If the LLM returns a suspiciously tiny batch, we fail the character."""
124
+ with SessionLocal() as s:
125
+ character_id = _make_character(s)
126
+
127
+ fake = FakeLLMClient(_fake_memories(3)) # below min//2
128
+ import pytest
129
+
130
+ with pytest.raises(Exception):
131
+ generate_and_store(character_id, client=fake)
132
+
133
+ with SessionLocal() as s:
134
+ char = s.get(Character, character_id)
135
+ assert char.state == CharacterState.GENERATION_FAILED
tests/test_memory_schema.py ADDED
@@ -0,0 +1,57 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from __future__ import annotations
2
+
3
+ import pytest
4
+ from pydantic import ValidationError
5
+
6
+ from app.models.memory import MemoryScope, MemoryType
7
+ from app.schemas.memory import MemoryCreate
8
+
9
+
10
+ def _valid_payload(**overrides) -> dict:
11
+ payload: dict = dict(
12
+ scope=MemoryScope.CHARACTER_LORE,
13
+ type=MemoryType.FORMATIVE,
14
+ emotional_valence=0.0,
15
+ triggers=["grandfather", "Minsk"],
16
+ narrative_text="I remember my grandfather teaching me in a cold room.",
17
+ relevance_tags=["childhood"],
18
+ )
19
+ payload.update(overrides)
20
+ return payload
21
+
22
+
23
+ def test_valid_payload_parses():
24
+ m = MemoryCreate(**_valid_payload())
25
+ assert m.narrative_text.startswith("I remember")
26
+ assert m.scope == MemoryScope.CHARACTER_LORE
27
+ assert m.type == MemoryType.FORMATIVE
28
+
29
+
30
+ def test_rejects_out_of_range_valence():
31
+ with pytest.raises(ValidationError):
32
+ MemoryCreate(**_valid_payload(emotional_valence=1.5))
33
+ with pytest.raises(ValidationError):
34
+ MemoryCreate(**_valid_payload(emotional_valence=-1.5))
35
+
36
+
37
+ def test_rejects_invalid_enum():
38
+ with pytest.raises(ValidationError):
39
+ MemoryCreate(**_valid_payload(scope="not_a_scope"))
40
+ with pytest.raises(ValidationError):
41
+ MemoryCreate(**_valid_payload(type="not_a_type"))
42
+
43
+
44
+ def test_rejects_empty_narrative():
45
+ with pytest.raises(ValidationError):
46
+ MemoryCreate(**_valid_payload(narrative_text=""))
47
+
48
+
49
+ def test_triggers_stripped_and_deduped():
50
+ m = MemoryCreate(**_valid_payload(triggers=[" Sicilian ", "sicilian", "Najdorf", ""]))
51
+ assert m.triggers == ["Sicilian", "Najdorf"]
52
+
53
+
54
+ def test_accepts_enum_values_as_strings():
55
+ m = MemoryCreate(**_valid_payload(scope="character_lore", type="opinion"))
56
+ assert m.scope == MemoryScope.CHARACTER_LORE
57
+ assert m.type == MemoryType.OPINION
tests/test_preset_seeding.py ADDED
@@ -0,0 +1,47 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from __future__ import annotations
2
+
3
+ from sqlalchemy import select
4
+
5
+ from app.characters.presets import PRESETS
6
+ from app.characters.seed import seed_presets
7
+ from app.db import SessionLocal
8
+ from app.models.character import Character
9
+
10
+
11
+ def test_seed_presets_creates_all():
12
+ results = seed_presets(run_generation=False)
13
+ assert set(results.keys()) == {p.preset_key for p in PRESETS}
14
+ assert all(v is True for v in results.values())
15
+
16
+ with SessionLocal() as s:
17
+ rows = list(s.execute(select(Character)).scalars())
18
+ assert len(rows) == len(PRESETS)
19
+ preset_keys = {r.preset_key for r in rows}
20
+ assert preset_keys == {p.preset_key for p in PRESETS}
21
+ assert all(r.is_preset for r in rows)
22
+
23
+
24
+ def test_seed_presets_is_idempotent():
25
+ seed_presets(run_generation=False)
26
+ first_ids = {}
27
+ with SessionLocal() as s:
28
+ for r in s.execute(select(Character)).scalars():
29
+ first_ids[r.preset_key] = r.id
30
+
31
+ results2 = seed_presets(run_generation=False)
32
+ assert all(v is False for v in results2.values())
33
+
34
+ with SessionLocal() as s:
35
+ rows = list(s.execute(select(Character)).scalars())
36
+ assert len(rows) == len(PRESETS)
37
+ for r in rows:
38
+ assert r.id == first_ids[r.preset_key]
39
+
40
+
41
+ def test_seed_presets_skips_generation_when_disabled():
42
+ seed_presets(run_generation=False)
43
+ with SessionLocal() as s:
44
+ rows = list(s.execute(select(Character)).scalars())
45
+ for r in rows:
46
+ # Nothing persisted yet — seed ran without generation.
47
+ assert len(list(r.memories)) == 0
tests/test_style.py ADDED
@@ -0,0 +1,65 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from __future__ import annotations
2
+
3
+ from app.characters.style import StyleFragments, style_summary_line, style_to_prompt_fragments
4
+ from app.models.character import Character
5
+
6
+
7
+ def _char(**overrides) -> Character:
8
+ defaults: dict = dict(
9
+ name="Test",
10
+ aggression=5,
11
+ risk_tolerance=5,
12
+ patience=5,
13
+ trash_talk=5,
14
+ target_elo=1500,
15
+ )
16
+ defaults.update(overrides)
17
+ return Character(**defaults)
18
+
19
+
20
+ def test_fragments_shape_is_stable():
21
+ frags = style_to_prompt_fragments(_char())
22
+ assert set(frags.keys()) == set(StyleFragments.__annotations__.keys())
23
+ for v in frags.values():
24
+ assert isinstance(v, str) and v
25
+
26
+
27
+ def test_low_mid_high_buckets_produce_distinct_aggression_fragments():
28
+ low = style_to_prompt_fragments(_char(aggression=1))["aggression"]
29
+ mid = style_to_prompt_fragments(_char(aggression=5))["aggression"]
30
+ high = style_to_prompt_fragments(_char(aggression=10))["aggression"]
31
+ assert low != mid != high
32
+ assert low != high
33
+
34
+
35
+ def test_bucket_boundaries():
36
+ # 3 is low, 4 is mid; 7 is mid, 8 is high. This encodes the mapping so a
37
+ # future edit doesn't silently shift the buckets.
38
+ assert (
39
+ style_to_prompt_fragments(_char(aggression=3))["aggression"]
40
+ == style_to_prompt_fragments(_char(aggression=1))["aggression"]
41
+ )
42
+ assert (
43
+ style_to_prompt_fragments(_char(aggression=4))["aggression"]
44
+ == style_to_prompt_fragments(_char(aggression=7))["aggression"]
45
+ )
46
+ assert (
47
+ style_to_prompt_fragments(_char(aggression=8))["aggression"]
48
+ == style_to_prompt_fragments(_char(aggression=10))["aggression"]
49
+ )
50
+
51
+
52
+ def test_summary_line_uses_character_name():
53
+ line = style_summary_line(_char(name="Vera"))
54
+ assert line.startswith("Vera ")
55
+ assert line.endswith(".")
56
+
57
+
58
+ def test_each_slider_changes_only_its_own_fragment():
59
+ base = style_to_prompt_fragments(_char())
60
+ with_high_patience = style_to_prompt_fragments(_char(patience=10))
61
+
62
+ assert with_high_patience["patience"] != base["patience"]
63
+ assert with_high_patience["aggression"] == base["aggression"]
64
+ assert with_high_patience["risk_tolerance"] == base["risk_tolerance"]
65
+ assert with_high_patience["trash_talk"] == base["trash_talk"]