feat(simulator): /api/alteration + /api/clips; retire selection endpoints

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Ben Stull
2026-06-07 22:55:10 -07:00
parent c316309fc6
commit eb3aa0949d
2 changed files with 119 additions and 128 deletions
+65 -62
View File
@@ -1,92 +1,95 @@
"""FastAPI service: dials -> real hef.selection -> X-ray (pick + ranked pool)."""
"""FastAPI service: controls -> the real alteration engine -> a RenderPlan.
The simulator's alteration surface (reconciled slice). It calls the canonical
player.alteration.plan_alteration; the browser only renders. The selection-era
endpoints (/api/select, /api/catalog/meta) and the X-ray are retired.
"""
from __future__ import annotations
import os
from collections import Counter
from pathlib import Path
from typing import Literal, Optional
from typing import Optional
from fastapi import FastAPI
from fastapi import FastAPI, HTTPException
from fastapi.staticfiles import StaticFiles
from pydantic import BaseModel, Field
from hef.catalog import load_catalog, record_to_dict
from hef.selection import (
Coordinate,
Weights,
candidates_for_mode,
ranked_candidates,
select,
from hef.selection import Coordinate
from player.alteration import (
DEFAULT_CALIBRATION,
Calibration,
plan_alteration,
render_plan_to_dict,
)
from simulator.fixtures import generate_fixture_catalog
from player.content import resolve_content
from player.controls import CONTENT_POSITIONS
from simulator.clips import load_manifest
STATIC_DIR = Path(__file__).parent / "static"
MEDIA_DIR = Path(__file__).parent / "sample_media"
DEFAULT_MANIFEST = MEDIA_DIR / "manifest.json"
class SelectRequest(BaseModel):
class ControlsModel(BaseModel):
content: str
left: int = Field(ge=0, le=4)
right: int = Field(ge=0, le=4)
dark: int = Field(ge=0, le=4)
light: int = Field(ge=0, le=4)
mode: Literal["none", "audio", "video", "av"]
pool_size: int = Field(default=4, ge=1, le=25)
brain_weight: float = Field(default=1.0, ge=0.0)
mood_weight: float = Field(default=1.0, ge=0.0)
approved_only: bool = False
volume: int = Field(ge=0, le=4)
brightness: int = Field(ge=0, le=4)
def load_catalog_or_fixtures() -> list:
"""Use the real catalog if a non-empty one is configured/exists, else fixtures."""
configured = os.environ.get("HEF_SIM_CATALOG")
path = Path(configured) if configured else Path("catalog/library.jsonl")
if path.exists() and path.stat().st_size > 0:
return load_catalog(path)
return generate_fixture_catalog()
class CalibrationModel(BaseModel):
mood_gain: float = 1.0
overlay_gain: float = 1.0
right_variant_map: list[int] = [0, 1, 2, 3, 4]
def create_app(records: Optional[list] = None) -> FastAPI:
app = FastAPI(title="HEF Experience Simulator")
app.state.catalog = records if records is not None else load_catalog_or_fixtures()
class AlterationRequest(BaseModel):
controls: ControlsModel
calibration: Optional[CalibrationModel] = None
@app.post("/api/select")
def api_select(req: SelectRequest):
catalog = app.state.catalog
coord = Coordinate(req.left, req.right, req.dark, req.light)
weights = Weights(brain=req.brain_weight, mood=req.mood_weight)
if req.mode == "none":
return {"pick": None, "pool": [], "coverage": {"candidates_in_mode": 0}}
pool = catalog
if req.approved_only:
pool = [r for r in pool if r.review_status == "approved"]
eligible = candidates_for_mode(pool, req.mode, req.pool_size)
ranked = ranked_candidates(
catalog, coord, req.mode,
pool_size=req.pool_size, weights=weights, approved_only=req.approved_only,
)
pick = select(
catalog, coord, req.mode,
pool_size=req.pool_size, weights=weights, approved_only=req.approved_only,
rng=None,
def _load_clips(manifest_path: Optional[Path]):
path = Path(manifest_path) if manifest_path else DEFAULT_MANIFEST
if path.exists():
return load_manifest(path)
return []
def create_app(manifest_path: Optional[Path] = None) -> FastAPI:
app = FastAPI(title="HEF Alteration Simulator")
app.state.clips = _load_clips(manifest_path)
@app.post("/api/alteration")
def api_alteration(req: AlterationRequest):
c = req.controls
if c.content not in CONTENT_POSITIONS:
raise HTTPException(status_code=422, detail=f"invalid content {c.content!r}")
coord = Coordinate(c.left, c.right, c.dark, c.light)
cal = (
Calibration(
mood_gain=req.calibration.mood_gain,
overlay_gain=req.calibration.overlay_gain,
right_variant_map=tuple(req.calibration.right_variant_map),
)
if req.calibration
else DEFAULT_CALIBRATION
)
plan = plan_alteration(coord, cal)
content = resolve_content(c.content)
return {
"pick": record_to_dict(pick) if pick else None,
"pool": [
{"record": record_to_dict(r), "distance": d, "rank": i + 1}
for i, (r, d) in enumerate(ranked)
],
"coverage": {"candidates_in_mode": len(eligible)},
"plan": render_plan_to_dict(plan),
"content": {"audio_source": content.audio_source, "video": content.video},
}
@app.get("/api/catalog/meta")
def api_meta():
catalog = app.state.catalog
return {
"total": len(catalog),
"by_mode": dict(Counter(r.mode for r in catalog)),
"by_status": dict(Counter(r.review_status for r in catalog)),
}
@app.get("/api/clips")
def api_clips():
return {"clips": [c.to_dict() for c in app.state.clips]}
if MEDIA_DIR.exists():
app.mount("/media", StaticFiles(directory=MEDIA_DIR), name="media")
if STATIC_DIR.exists():
app.mount("/", StaticFiles(directory=STATIC_DIR, html=True), name="static")