Spaces:
Sleeping
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 +0 -0
- app/api/characters.py +134 -0
- app/main.py +45 -0
- app/web/__init__.py +0 -0
- app/web/routes.py +162 -0
- app/web/templates/base.html +31 -0
- app/web/templates/detail.html +138 -0
- app/web/templates/index.html +44 -0
- app/web/templates/new.html +94 -0
- tests/__init__.py +0 -0
- tests/conftest.py +49 -0
- tests/test_live_llm.py +90 -0
- tests/test_memory_generator.py +135 -0
- tests/test_memory_schema.py +57 -0
- tests/test_preset_seeding.py +47 -0
- tests/test_style.py +65 -0
|
File without changes
|
|
@@ -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()
|
|
@@ -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()
|
|
File without changes
|
|
@@ -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 |
+
)
|
|
@@ -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 & memories only. Gameplay arrives in Phase 2.
|
| 29 |
+
</footer>
|
| 30 |
+
</body>
|
| 31 |
+
</html>
|
|
@@ -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 %}
|
|
@@ -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 %}
|
|
@@ -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 %}
|
|
File without changes
|
|
@@ -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()
|
|
@@ -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
|
|
@@ -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
|
|
@@ -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
|
|
@@ -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
|
|
@@ -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"]
|