Files
chat/app/cart_store.py
marauder-actual 4594f07ebc feat(calibration): 10-question battery, config-driven voice, WCAG-safe theme
Rework calibration.py into a fixed 10-question battery:
  - 4 critical questions (language, name, persona name, gender)
  - 6 random probes drawn from the 12-item pool (all AI/tech-unrelated)

Theme inference (palette × typography × density × labels):
  - Palette: warmth × contrast × energy → default|rose|morning|evening|
    sage|paper|ink; all choices verified WCAG AA ≥4.5:1.
  - Typography: elaborate+warm → serif-warm; cool+ink → mono;
    high-contrast+cool → serif-formal; energetic+warm → mixed-modern;
    otherwise sans.
  - Density: elaborate cadence → airy; terse → dense; else normal.
  - Labels: serif-warm/mixed-modern → cursive; mono → prefix; else block.

Voice selection:
  - Removed VOICE_POOL (lang × gender heuristic).
  - Added _load_persona_voices(): reads [voices.<lang>].id from a
    cart.toml persona file at PCART_CONFIG_PATH or app/conf/persona.toml.
    Falls back to FALLBACK_VOICES when no file is present.
  - _pick_voice(lang) selects by language key only — no gender heuristic.

cart_store.Cart changes:
  - Added calibration_version: int = 1 (new carts emit 2)
  - Bumped version: 3 → 4
  - Removed unused  import

No changes to app/main.py — calibration wiring was already correct.
2026-05-29 14:00:14 +02:00

91 lines
2.9 KiB
Python

"""Operator → cart persistence for chat-saiden.
A 'cart' here is the per-operator calibrated config: persona name, voice,
system prompt, UI palette. Stored as JSON on disk under
~/.local/share/chat-saiden/operators/<email>.json.
Later this can be promoted to a real `marauder cart` once the format
stabilises.
"""
from __future__ import annotations
import json
import logging
import os
import re
from dataclasses import asdict, dataclass
from datetime import datetime
from pathlib import Path
log = logging.getLogger("chat-saiden.cart")
DATA_DIR = Path(
os.environ.get("CHAT_SAIDEN_DATA_DIR")
or (Path.home() / ".local/share/chat-saiden")
)
OPERATORS_DIR = DATA_DIR / "operators"
@dataclass
class Cart:
operator_email: str
operator_name: str = ""
persona_name: str = "Samantha"
cart_tag: str = "" # marauder cart tag — links to ~/.marauder cart DB
language: str = "en" # en | pl
voice: str = "en_US-amy-medium"
system_prompt: str = ""
# UI calibration outputs. All default to neutral.
ui_palette: str = "default" # default | rose | morning | evening | sage | paper | ink
ui_typography: str = "sans" # sans | serif-warm | serif-formal | mixed-modern | mono
ui_density: str = "normal" # airy | normal | dense
ui_labels: str = "block" # block | cursive | none | prefix
# Tracks which calibration battery was used (bumped when calibration.py is reworked).
calibration_version: int = 1 # 1 = original critical+random; 2 = 10-question battery v2
created_at: str = ""
version: int = 4 # bumped from 3 → 4 with calibration_version field addition
@property
def is_calibrated(self) -> bool:
return bool(self.system_prompt and self.persona_name and self.voice)
def _slug(email: str) -> str:
return re.sub(r"[^a-z0-9._-]", "_", email.lower())
def _path(email: str) -> Path:
OPERATORS_DIR.mkdir(parents=True, exist_ok=True)
return OPERATORS_DIR / f"{_slug(email)}.json"
def load(email: str) -> Cart | None:
p = _path(email)
if not p.exists():
return None
try:
data = json.loads(p.read_text(encoding="utf-8"))
# tolerate older carts missing the new ui_* fields
return Cart(**{k: v for k, v in data.items() if k in Cart.__dataclass_fields__})
except Exception:
log.exception("failed to load cart for %s", email)
return None
def save(cart: Cart) -> None:
if not cart.created_at:
cart.created_at = datetime.utcnow().isoformat(timespec="seconds") + "Z"
p = _path(cart.operator_email)
p.write_text(json.dumps(asdict(cart), indent=2), encoding="utf-8")
log.info("saved cart for %s%s", cart.operator_email, p)
def forget(email: str) -> bool:
p = _path(email)
if p.exists():
p.unlink()
log.info("forgot cart for %s", email)
return True
return False