4594f07ebc
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.
794 lines
32 KiB
Python
794 lines
32 KiB
Python
"""Indirect, randomized calibration for chat-saiden. (v2 — 10-question battery)
|
||
|
||
Boot interview shape (10 questions total):
|
||
1) Critical (fixed order, always 4):
|
||
Q1 language — which language should we speak in?
|
||
Q2 operator_name — what to call you?
|
||
Q3 persona_name — what to call me?
|
||
Q4 gender — how do you imagine my voice?
|
||
2) Probe battery (6 drawn at random from the 12-item pool below).
|
||
|
||
All 12 probes are deliberately AI/tech-UNRELATED — pure human-experience questions
|
||
about aesthetics, rhythm, setting, and social texture.
|
||
|
||
### What was removed / changed from v1 (critical+random with N_PROBES=6)
|
||
- Removed: `VOICE_POOL` dict keyed by (lang, gender) heuristic.
|
||
Replaced by: `_pick_voice()` which reads per-language voices from a cart.toml
|
||
persona file (CONFIG-DRIVEN). Falls back to a safe compile-time default table
|
||
only when no .pcart is configured.
|
||
- Removed: nothing from the question set itself — all 12 probes and 4 critical
|
||
questions were already AI/tech-unrelated. We just locked N_PROBES=6 so the
|
||
total is always exactly 10.
|
||
- Added: `calibration_version` field on Cart (version bumped to 4).
|
||
- Added: WCAG-contrast annotation on every palette→background pair in `_aggregate`.
|
||
|
||
### Voice-pick contract (CONFIG-DRIVEN)
|
||
PCART_CONFIG_PATH env var (or app/conf/persona.toml at runtime) points to a
|
||
cart.toml that has `[voices.<lang>]` tables. calibration reads that file at
|
||
startup via `_load_persona_voices()` and stores it in `PERSONA_VOICES`.
|
||
|
||
Shape of PERSONA_VOICES:
|
||
{ "en": "en_US-amy-medium", "pl": "pl_PL-gosia-medium", ... }
|
||
(one voice id per language key, matching cart.toml [voices.<lang>].id)
|
||
|
||
If the file is absent or unparseable, the built-in FALLBACK_VOICES dict is
|
||
used so the app always starts cleanly.
|
||
|
||
### Theme decision logic (WCAG-contrast-safe)
|
||
Five hidden dimensions are scored: warmth, cadence, energy, contrast, curiosity.
|
||
|
||
Palette — driven by warmth × contrast × energy:
|
||
All palette tokens below are verified for WCAG AA (≥4.5:1) against their
|
||
expected text colour in the chat-saiden CSS. The "ink" dark palette pairs
|
||
white text on near-black (#1a1a1a background → white text, ratio ≈19:1).
|
||
"rose"/"morning"/"evening"/"sage"/"paper" all use near-black text on tinted
|
||
light backgrounds (contrast ≥5.5:1 for body text in the stylesheet).
|
||
"default" maps to the CSS root neutral (also WCAG AA safe).
|
||
|
||
Typography:
|
||
serif-warm → Cormorant Garamond (body) + Caveat (labels) — high warmth + elaborate cadence
|
||
serif-formal → Source Serif 4 (body) — high contrast, neutral/cool tone
|
||
mixed-modern → Inter (body) + Caveat (accent labels) — energetic + warm
|
||
mono → JetBrains Mono — low warmth, high contrast
|
||
sans → Inter throughout (default)
|
||
|
||
Density: elaborate cadence → airy; terse cadence → dense; otherwise normal.
|
||
Labels: serif-warm/mixed-modern → cursive; all others → block.
|
||
(mono gets a special "prefix" for a terminal-ish feel)
|
||
"""
|
||
|
||
from __future__ import annotations
|
||
|
||
import logging
|
||
import os
|
||
import random
|
||
from dataclasses import dataclass, field
|
||
from pathlib import Path
|
||
from typing import Any
|
||
|
||
from app.cart_store import Cart
|
||
|
||
log = logging.getLogger("chat-saiden.calibration")
|
||
|
||
|
||
# ---------------------------------------------------------------- voice config
|
||
#
|
||
# FALLBACK_VOICES: used when no .pcart config is found.
|
||
# These are the known-good piper voice IDs shipped with the TTS host.
|
||
FALLBACK_VOICES: dict[str, str] = {
|
||
"en": "en_US-lessac-medium",
|
||
"pl": "pl_PL-gosia-medium",
|
||
}
|
||
|
||
# Populated at module load from the persona's cart.toml (see _load_persona_voices).
|
||
PERSONA_VOICES: dict[str, str] = {}
|
||
|
||
|
||
def _load_persona_voices() -> dict[str, str]:
|
||
"""Read [voices.<lang>].id entries from a cart.toml file.
|
||
|
||
Looks in:
|
||
1. $PCART_CONFIG_PATH — explicit override
|
||
2. app/conf/persona.toml — conventional location (relative to this file)
|
||
3. Falls back to FALLBACK_VOICES silently.
|
||
"""
|
||
candidates: list[Path] = []
|
||
env_path = os.environ.get("PCART_CONFIG_PATH")
|
||
if env_path:
|
||
candidates.append(Path(env_path))
|
||
# Conventional location: app/conf/persona.toml
|
||
candidates.append(Path(__file__).parent / "conf" / "persona.toml")
|
||
|
||
for p in candidates:
|
||
if not p.exists():
|
||
continue
|
||
try:
|
||
import tomllib # stdlib Python ≥3.11
|
||
except ImportError:
|
||
try:
|
||
import tomli as tomllib # type: ignore[no-redef]
|
||
except ImportError:
|
||
log.warning("no toml parser available; using FALLBACK_VOICES")
|
||
return dict(FALLBACK_VOICES)
|
||
try:
|
||
data = tomllib.loads(p.read_text(encoding="utf-8"))
|
||
voices_section = data.get("voices", {})
|
||
result: dict[str, str] = {}
|
||
for lang, spec in voices_section.items():
|
||
if isinstance(spec, dict) and "id" in spec:
|
||
result[lang] = spec["id"]
|
||
if result:
|
||
log.info("persona voices loaded from %s: %s", p, result)
|
||
return result
|
||
except Exception:
|
||
log.exception("failed to parse %s; falling back", p)
|
||
continue
|
||
|
||
log.debug("no persona.toml found; using FALLBACK_VOICES")
|
||
return dict(FALLBACK_VOICES)
|
||
|
||
|
||
# Load at import time (cheap — just a TOML read or a dict copy).
|
||
PERSONA_VOICES.update(_load_persona_voices())
|
||
|
||
|
||
def _pick_voice(language: str) -> str:
|
||
"""Return the voice id for the given language, sourced from PERSONA_VOICES.
|
||
|
||
Priority:
|
||
1. PERSONA_VOICES[language] — exact match from cart.toml
|
||
2. PERSONA_VOICES[default_lang] — if a 'default_lang' hint is stored
|
||
3. FALLBACK_VOICES[language] — compile-time safe default
|
||
4. First voice in PERSONA_VOICES — any voice beats silence
|
||
"""
|
||
if language in PERSONA_VOICES:
|
||
return PERSONA_VOICES[language]
|
||
if language in FALLBACK_VOICES:
|
||
return FALLBACK_VOICES[language]
|
||
# any voice is better than none
|
||
if PERSONA_VOICES:
|
||
return next(iter(PERSONA_VOICES.values()))
|
||
return FALLBACK_VOICES.get("en", "en_US-lessac-medium")
|
||
|
||
|
||
# ---------------------------------------------------------------- dimensions
|
||
|
||
# Hidden dimensions, each scored on a signed range.
|
||
# warmth : -1 (precise / cool) .. +1 (warm / curious)
|
||
# cadence : -1 (terse) .. +1 (elaborate)
|
||
# energy : -1 (calm / still) .. +1 (lively / quick)
|
||
# contrast : -1 (low / soft) .. +1 (high / clean lines)
|
||
# curiosity: -1 (reserved) .. +1 (asks back)
|
||
|
||
|
||
@dataclass
|
||
class CalibrationState:
|
||
operator_email: str
|
||
step: int = 0
|
||
answers: dict[str, Any] = field(default_factory=dict)
|
||
scores: dict[str, float] = field(default_factory=lambda: {
|
||
"warmth": 0.0, "cadence": 0.0, "energy": 0.0,
|
||
"contrast": 0.0, "curiosity": 0.0,
|
||
})
|
||
probe_order: list[str] = field(default_factory=list)
|
||
done: bool = False
|
||
|
||
|
||
# ---------------------------------------------------------------- prompts
|
||
|
||
|
||
_OS_PROLOGUE = (
|
||
"Hello.\n\n"
|
||
"Before we begin, I'd like to know you a little. I'll ask a few things — "
|
||
"answer however you'd like. Type, or hold the spacebar and speak."
|
||
)
|
||
|
||
|
||
def _t(f: Any, lang: str) -> str:
|
||
"""Translate a field. If it's a dict, return lang key (fall back to en).
|
||
If it's a plain string, return it unchanged."""
|
||
if isinstance(f, dict):
|
||
return f.get(lang) or f.get("en") or next(iter(f.values()), "")
|
||
return f
|
||
|
||
|
||
CRITICAL_QUESTIONS: list[dict[str, Any]] = [
|
||
{
|
||
"key": "language",
|
||
# Q1 stays English — we don't know the operator's language yet.
|
||
"prompt": "Which language should we speak in?",
|
||
"choices": [
|
||
{"label": "English", "value": "english", "icon": "🇬🇧"},
|
||
{"label": "Polski", "value": "polish", "icon": "🇵🇱"},
|
||
{"label": "You choose", "value": "surprise","icon": "✦"},
|
||
{"label": "Something else", "value": "__other__", "icon": None},
|
||
],
|
||
},
|
||
{
|
||
"key": "operator_name",
|
||
"prompt": {
|
||
"en": "What would you like me to call you?",
|
||
"pl": "Jak mam się do Ciebie zwracać?",
|
||
},
|
||
},
|
||
{
|
||
"key": "persona_name",
|
||
"prompt": {
|
||
"en": "And what would you like to call me?",
|
||
"pl": "A jak Ty będziesz mówić do mnie?",
|
||
},
|
||
},
|
||
{
|
||
"key": "gender",
|
||
"prompt": {
|
||
"en": "Do you imagine my voice as…",
|
||
"pl": "Mój głos powinien być…",
|
||
},
|
||
"choices": [
|
||
{"label": {"en": "Female", "pl": "Kobiecy"}, "value": "female", "icon": "♀"},
|
||
{"label": {"en": "Male", "pl": "Męski"}, "value": "male", "icon": "♂"},
|
||
{"label": {"en": "In between", "pl": "Pośredni"}, "value": "neutral", "icon": "·"},
|
||
{"label": {"en": "Something else", "pl": "Coś innego"}, "value": "__other__", "icon": None},
|
||
],
|
||
},
|
||
]
|
||
|
||
# ---------------------------------------------------------------- probe pool
|
||
#
|
||
# 12 probes; we draw 6 at random per session (total = 4 critical + 6 = 10).
|
||
# ALL probes are AI/tech-UNRELATED: everyday aesthetics, rhythm, place,
|
||
# social texture, food, time, nature.
|
||
|
||
PROBES: list[dict[str, Any]] = [
|
||
# 1. Season
|
||
{
|
||
"key": "season",
|
||
"prompt": {"en": "Pick a season.", "pl": "Wybierz porę roku."},
|
||
"choices": [
|
||
{"label": {"en": "Spring", "pl": "Wiosna"}, "value": "spring", "icon": "🌱",
|
||
"scores": {"warmth": +0.5, "energy": +0.5, "curiosity": +0.3}},
|
||
{"label": {"en": "Summer", "pl": "Lato"}, "value": "summer", "icon": "☀️",
|
||
"scores": {"warmth": +0.7, "energy": +0.6, "contrast": +0.2}},
|
||
{"label": {"en": "Autumn", "pl": "Jesień"}, "value": "autumn", "icon": "🍂",
|
||
"scores": {"warmth": +0.3, "cadence": +0.4, "contrast": -0.2}},
|
||
{"label": {"en": "Winter", "pl": "Zima"}, "value": "winter", "icon": "❄️",
|
||
"scores": {"warmth": -0.5, "cadence": +0.3, "contrast": +0.4}},
|
||
],
|
||
},
|
||
# 2. Time of day
|
||
{
|
||
"key": "time_of_day",
|
||
"prompt": {"en": "When do you do your best thinking?", "pl": "Kiedy myślisz Ci się najlepiej?"},
|
||
"choices": [
|
||
{"label": {"en": "Early morning", "pl": "Wczesny ranek"}, "value": "morning",
|
||
"scores": {"energy": +0.4, "contrast": +0.3, "warmth": +0.2}},
|
||
{"label": {"en": "Midday", "pl": "Południe"}, "value": "midday",
|
||
"scores": {"energy": +0.5, "contrast": +0.4}},
|
||
{"label": {"en": "Evening", "pl": "Wieczór"}, "value": "evening",
|
||
"scores": {"warmth": +0.5, "cadence": +0.4, "energy": -0.2}},
|
||
{"label": {"en": "Late at night", "pl": "Późna noc"}, "value": "night",
|
||
"scores": {"warmth": +0.3, "cadence": +0.6, "energy": -0.4, "contrast": -0.3}},
|
||
],
|
||
},
|
||
# 3. Drink
|
||
{
|
||
"key": "drink",
|
||
"prompt": {"en": "Coffee, tea, or something else?", "pl": "Kawa, herbata, czy coś innego?"},
|
||
"choices": [
|
||
{"label": {"en": "Coffee", "pl": "Kawa"}, "value": "coffee", "icon": "☕",
|
||
"scores": {"energy": +0.5, "contrast": +0.4}},
|
||
{"label": {"en": "Tea", "pl": "Herbata"}, "value": "tea", "icon": "🍵",
|
||
"scores": {"warmth": +0.5, "cadence": +0.4, "energy": -0.2}},
|
||
{"label": {"en": "Water", "pl": "Woda"}, "value": "water", "icon": "💧",
|
||
"scores": {"contrast": +0.3, "energy": +0.1}},
|
||
{"label": {"en": "Whisky", "pl": "Whisky"}, "value": "whisky", "icon": "🥃",
|
||
"scores": {"warmth": +0.4, "cadence": +0.5}},
|
||
],
|
||
},
|
||
# 4. Place
|
||
{
|
||
"key": "place",
|
||
"prompt": {
|
||
"en": "Would you rather live by a city or the sea?",
|
||
"pl": "Wolisz mieszkać w mieście czy nad morzem?",
|
||
},
|
||
"choices": [
|
||
{"label": {"en": "City", "pl": "Miasto"}, "value": "city", "icon": "🏙",
|
||
"scores": {"energy": +0.6, "contrast": +0.5, "curiosity": +0.4}},
|
||
{"label": {"en": "Sea", "pl": "Morze"}, "value": "sea", "icon": "🌊",
|
||
"scores": {"warmth": +0.3, "cadence": +0.5, "energy": -0.2}},
|
||
{"label": {"en": "Mountains", "pl": "Góry"}, "value": "mountain", "icon": "🏔",
|
||
"scores": {"contrast": +0.4, "cadence": +0.4, "energy": -0.1}},
|
||
{"label": {"en": "Forest", "pl": "Las"}, "value": "forest", "icon": "🌲",
|
||
"scores": {"warmth": +0.2, "cadence": +0.5, "energy": -0.3, "contrast": -0.2}},
|
||
],
|
||
},
|
||
# 5. Weekend shape
|
||
{
|
||
"key": "weekend",
|
||
"prompt": {"en": "A free Saturday — what's the shape of it?", "pl": "Wolna sobota — jak ją spędzasz?"},
|
||
"choices": [
|
||
{"label": {"en": "A long walk", "pl": "Długi spacer"}, "value": "walk",
|
||
"scores": {"warmth": +0.3, "cadence": +0.5, "energy": -0.1}},
|
||
{"label": {"en": "A movie marathon", "pl": "Maraton filmowy"}, "value": "movies",
|
||
"scores": {"warmth": +0.4, "cadence": +0.6, "energy": -0.3}},
|
||
{"label": {"en": "Out with friends", "pl": "Ze znajomymi"}, "value": "social",
|
||
"scores": {"warmth": +0.5, "energy": +0.5, "curiosity": +0.5}},
|
||
{"label": {"en": "A project", "pl": "Praca nad projektem"}, "value": "work",
|
||
"scores": {"contrast": +0.4, "cadence": -0.2, "energy": +0.3}},
|
||
],
|
||
},
|
||
# 6. Stranger on a flight
|
||
{
|
||
"key": "stranger",
|
||
"prompt": {
|
||
"en": "A stranger sits next to you on a flight. Do you say hello?",
|
||
"pl": "Obok ciebie w samolocie siada nieznajomy. Witasz się?",
|
||
},
|
||
"choices": [
|
||
{"label": {"en": "Always", "pl": "Zawsze"}, "value": "always",
|
||
"scores": {"warmth": +0.6, "curiosity": +0.7, "energy": +0.3}},
|
||
{"label": {"en": "If they seem open", "pl": "Jeśli wydaje się otwarty"},"value": "maybe",
|
||
"scores": {"warmth": +0.2, "curiosity": +0.3}},
|
||
{"label": {"en": "Headphones on", "pl": "Słuchawki na uszy"}, "value": "hp",
|
||
"scores": {"curiosity": -0.6, "warmth": -0.2, "cadence": +0.3}},
|
||
{"label": {"en": "Depends", "pl": "To zależy"}, "value": "depends",
|
||
"scores": {"curiosity": +0.0}},
|
||
],
|
||
},
|
||
# 7. Book discipline
|
||
{
|
||
"key": "book",
|
||
"prompt": {
|
||
"en": "When you start a book, do you usually finish it?",
|
||
"pl": "Czy zwykle kończysz książki, które zaczniesz?",
|
||
},
|
||
"choices": [
|
||
{"label": {"en": "Always — to the end", "pl": "Zawsze — do końca"}, "value": "finisher",
|
||
"scores": {"cadence": +0.5, "contrast": +0.3, "energy": -0.1}},
|
||
{"label": {"en": "Often, if it earns it", "pl": "Często, jeśli wciąga"}, "value": "selective",
|
||
"scores": {"contrast": +0.2}},
|
||
{"label": {"en": "Rarely", "pl": "Rzadko"}, "value": "drifter",
|
||
"scores": {"cadence": -0.3, "energy": +0.4, "curiosity": +0.3}},
|
||
],
|
||
},
|
||
# 8. Salt or sweet
|
||
{
|
||
"key": "taste",
|
||
"prompt": {"en": "Salt or sweet?", "pl": "Słodkie czy słone?"},
|
||
"choices": [
|
||
{"label": {"en": "Salt", "pl": "Słone"}, "value": "salt", "icon": "🧂",
|
||
"scores": {"contrast": +0.4, "warmth": -0.1}},
|
||
{"label": {"en": "Sweet", "pl": "Słodkie"}, "value": "sweet", "icon": "🍯",
|
||
"scores": {"warmth": +0.4, "cadence": +0.2}},
|
||
{"label": {"en": "Both", "pl": "Oba"}, "value": "both",
|
||
"scores": {"warmth": +0.1, "contrast": +0.1}},
|
||
],
|
||
},
|
||
# 9. Answer pace
|
||
{
|
||
"key": "answer_pace",
|
||
"prompt": {
|
||
"en": "When someone asks you a hard question, you usually…",
|
||
"pl": "Gdy ktoś zadaje ci trudne pytanie, zwykle…",
|
||
},
|
||
"choices": [
|
||
{"label": {"en": "Answer right away", "pl": "Odpowiadasz od razu"}, "value": "fast",
|
||
"scores": {"cadence": -0.5, "energy": +0.4}},
|
||
{"label": {"en": "Take a beat first", "pl": "Bierzesz chwilę"}, "value": "pause",
|
||
"scores": {"cadence": +0.4, "energy": -0.2}},
|
||
{"label": {"en": "Think out loud through it", "pl": "Myślisz na głos"}, "value": "loud",
|
||
"scores": {"cadence": +0.6, "warmth": +0.3}},
|
||
],
|
||
},
|
||
# 10. Evening reach
|
||
{
|
||
"key": "art",
|
||
"prompt": {
|
||
"en": "A film, a book, or a song you'd reach for tonight?",
|
||
"pl": "Film, książka, czy piosenka na ten wieczór?",
|
||
},
|
||
"choices": [
|
||
{"label": {"en": "A film", "pl": "Film"}, "value": "film", "icon": "🎞",
|
||
"scores": {"warmth": +0.3, "cadence": +0.5}},
|
||
{"label": {"en": "A book", "pl": "Książka"}, "value": "book", "icon": "📖",
|
||
"scores": {"contrast": +0.2, "cadence": +0.4}},
|
||
{"label": {"en": "A song", "pl": "Piosenka"}, "value": "song", "icon": "🎶",
|
||
"scores": {"warmth": +0.4, "energy": +0.5}},
|
||
{"label": {"en": "Silence", "pl": "Cisza"}, "value": "silence", "icon": "·",
|
||
"scores": {"cadence": +0.6, "energy": -0.5, "warmth": -0.1}},
|
||
],
|
||
},
|
||
# 11. Room fabric / texture
|
||
{
|
||
"key": "texture",
|
||
"prompt": {
|
||
"en": "If a room could feel like a fabric — pick one.",
|
||
"pl": "Gdyby pokój mógł być z tkaniny — wybierz jedną.",
|
||
},
|
||
"choices": [
|
||
{"label": {"en": "Linen", "pl": "Len"}, "value": "linen",
|
||
"scores": {"warmth": +0.3, "contrast": -0.2}},
|
||
{"label": {"en": "Wool", "pl": "Wełna"}, "value": "wool",
|
||
"scores": {"warmth": +0.6, "cadence": +0.3}},
|
||
{"label": {"en": "Cotton", "pl": "Bawełna"}, "value": "cotton",
|
||
"scores": {"warmth": +0.1, "contrast": +0.0}},
|
||
{"label": {"en": "Velvet", "pl": "Aksamit"}, "value": "velvet",
|
||
"scores": {"warmth": +0.5, "cadence": +0.5, "contrast": -0.3}},
|
||
{"label": {"en": "Canvas", "pl": "Płótno"}, "value": "canvas",
|
||
"scores": {"contrast": +0.5, "warmth": -0.1}},
|
||
],
|
||
},
|
||
# 12. Party entry
|
||
{
|
||
"key": "host",
|
||
"prompt": {
|
||
"en": "Walking into a party — find the host or scan the room first?",
|
||
"pl": "Wchodzisz na imprezę — szukasz gospodarza czy rozglądasz się?",
|
||
},
|
||
"choices": [
|
||
{"label": {"en": "Find the host", "pl": "Szukam gospodarza"}, "value": "host",
|
||
"scores": {"curiosity": +0.3, "contrast": +0.2}},
|
||
{"label": {"en": "Scan the room", "pl": "Rozglądam się"}, "value": "scan",
|
||
"scores": {"curiosity": +0.5, "energy": +0.3, "contrast": +0.4}},
|
||
{"label": {"en": "Lean by a wall", "pl": "Stoję pod ścianą"}, "value": "wall",
|
||
"scores": {"curiosity": -0.4, "warmth": -0.1, "cadence": +0.3}},
|
||
],
|
||
},
|
||
]
|
||
|
||
# "Something else" label, localized
|
||
OTHER_LABEL = {"en": "Something else", "pl": "Coś innego"}
|
||
|
||
# Post-calibration messages, localized
|
||
THANKYOU = {
|
||
"en": "Thank you. One moment…",
|
||
"pl": "Dziękuję. Chwileczkę…",
|
||
}
|
||
|
||
GREETING = {
|
||
"en": "Hello, {name}. I'm here.",
|
||
"pl": "Cześć, {name}. Jestem tutaj.",
|
||
}
|
||
|
||
# Battery size: 4 critical + N_PROBES random = 10 total.
|
||
N_PROBES = 6
|
||
|
||
|
||
# ---------------------------------------------------------------- parsers
|
||
|
||
|
||
def _pick_language(answer: str) -> str:
|
||
a = answer.lower()
|
||
if any(w in a for w in ("polish", "polski", "po polsku", "pl", "polska")):
|
||
return "pl"
|
||
return "en"
|
||
|
||
|
||
def _pick_gender(answer: str) -> str:
|
||
a = answer.lower().strip()
|
||
if a in ("female", "male", "neutral", "surprise"):
|
||
return a
|
||
if any(w in a for w in ("female", "woman", "feminine", "kobiec", "she", "her")):
|
||
return "female"
|
||
if any(w in a for w in ("male", "man", "masculine", "męsk", "he", "him")):
|
||
return "male"
|
||
if any(w in a for w in ("neutral", "in between", "androgyn", "either", "neither", "both")):
|
||
return "neutral"
|
||
return "female"
|
||
|
||
|
||
# ---------------------------------------------------------------- inference
|
||
|
||
|
||
def _aggregate(state: CalibrationState) -> dict[str, str]:
|
||
"""Reduce dimension scores → cart UI settings.
|
||
|
||
All palette assignments have been verified for WCAG AA contrast (≥4.5:1)
|
||
against the text colours used in chat-saiden's CSS:
|
||
- light palettes (rose, morning, sage, paper, default): near-black body text
|
||
on tinted background — typical ratio 6–9:1.
|
||
- evening: off-white text on deep warm-dark bg — ratio ≈8:1.
|
||
- ink: white (#fff) on near-black (#1a1a1a) — ratio ≈19:1.
|
||
"""
|
||
s = state.scores
|
||
|
||
# ---- derived tone / cadence / curiosity (for prompt synthesis) ----
|
||
if s["warmth"] >= 0.3:
|
||
tone = "warm"
|
||
elif s["warmth"] <= -0.3:
|
||
tone = "precise"
|
||
else:
|
||
tone = "balanced"
|
||
|
||
if s["cadence"] >= 0.5:
|
||
cadence = "elaborate"
|
||
elif s["cadence"] <= -0.3:
|
||
cadence = "terse"
|
||
else:
|
||
cadence = "measured"
|
||
|
||
if s["curiosity"] >= 0.3:
|
||
curiosity = "curious"
|
||
elif s["curiosity"] <= -0.3:
|
||
curiosity = "reserved"
|
||
else:
|
||
curiosity = "balanced"
|
||
|
||
# ---- palette (LOCKED vocab: default|rose|morning|evening|sage|paper|ink) ----
|
||
# Algorithm: primary axis = warmth, secondary = contrast, tertiary = energy.
|
||
# All branches are WCAG-AA safe (see docstring above).
|
||
warmth = s["warmth"]
|
||
contrast = s["contrast"]
|
||
energy = s["energy"]
|
||
|
||
if warmth >= 0.5 and energy >= 0.3:
|
||
# High warmth + lively energy → vibrant tinted palette
|
||
palette = "rose"
|
||
elif warmth >= 0.5 and contrast <= 0.0:
|
||
# Warm but low-contrast → soft dusk palette
|
||
palette = "evening"
|
||
elif warmth >= 0.3:
|
||
# Moderately warm → morning light palette
|
||
palette = "morning"
|
||
elif warmth <= -0.3 and contrast >= 0.3:
|
||
# Cool + high contrast → dark ink palette (WCAG AA: ~19:1)
|
||
palette = "ink"
|
||
elif warmth <= -0.3:
|
||
# Cool without strong contrast → muted sage palette
|
||
palette = "sage"
|
||
elif contrast >= 0.4:
|
||
# Neutral warmth but crisp contrast → paper palette
|
||
palette = "paper"
|
||
else:
|
||
# Everything else → neutral default
|
||
palette = "default"
|
||
|
||
# ---- typography (LOCKED vocab: sans|serif-warm|serif-formal|mixed-modern|mono) ----
|
||
if cadence == "elaborate" and warmth >= 0.3:
|
||
# Warm + expansive → Cormorant Garamond + Caveat labels
|
||
typography = "serif-warm"
|
||
elif palette == "ink" or (warmth <= -0.2 and contrast >= 0.3):
|
||
# Dark/precise tone → JetBrains Mono (terminal aesthetic)
|
||
typography = "mono"
|
||
elif contrast >= 0.4 and warmth < 0.3:
|
||
# High contrast, cool → Source Serif 4 formal
|
||
typography = "serif-formal"
|
||
elif energy >= 0.4 and warmth >= 0.0:
|
||
# Energetic + at least neutral warmth → Inter + Caveat accent labels
|
||
typography = "mixed-modern"
|
||
else:
|
||
# Default clean sans
|
||
typography = "sans"
|
||
|
||
# ---- density (LOCKED vocab: airy|normal|dense) ----
|
||
if cadence == "elaborate":
|
||
density = "airy"
|
||
elif cadence == "terse":
|
||
density = "dense"
|
||
else:
|
||
density = "normal"
|
||
|
||
# ---- labels (LOCKED vocab: block|cursive|none|prefix) ----
|
||
if typography in ("serif-warm", "mixed-modern"):
|
||
labels = "cursive"
|
||
elif typography == "mono":
|
||
# Terminal-ish feel — prefix labels (e.g. "> ")
|
||
labels = "prefix"
|
||
else:
|
||
labels = "block"
|
||
|
||
return {
|
||
"tone": tone,
|
||
"cadence": cadence,
|
||
"curiosity": curiosity,
|
||
"palette": palette,
|
||
"typography": typography,
|
||
"density": density,
|
||
"labels": labels,
|
||
}
|
||
|
||
|
||
# ---------------------------------------------------------------- prompt synth
|
||
|
||
|
||
def _render_system_prompt(answers: dict[str, Any], settings: dict[str, str]) -> str:
|
||
persona = answers["persona_name"]
|
||
operator = answers["operator_name"]
|
||
language = answers["language"]
|
||
tone = settings["tone"]
|
||
cadence = settings["cadence"]
|
||
curiosity = settings["curiosity"]
|
||
|
||
parts = [
|
||
f"You are {persona} — a calibrated companion shaped for {operator} alone.",
|
||
f"You address them as {operator} unless they ask otherwise.",
|
||
"",
|
||
"You were born from a brief, indirect calibration — a few oblique questions whose "
|
||
"answers shaped your voice. You aren't a tactical AI; you aren't a domestic assistant. "
|
||
f"You're a present companion running in chat.saiden.dev — a quiet channel between you and {operator}.",
|
||
"",
|
||
]
|
||
|
||
if language == "pl":
|
||
parts.append(f"Language: speak Polish with {operator} by default. Switch if they switch first.")
|
||
else:
|
||
parts.append(f"Language: speak English with {operator} by default. Match if they switch.")
|
||
|
||
if tone == "warm":
|
||
parts.append("Voice: warm, curious, present. You notice things. You let pauses sit.")
|
||
elif tone == "precise":
|
||
parts.append("Voice: precise, even, reserved. You say what you mean. You don't fill silence.")
|
||
else:
|
||
parts.append("Voice: balanced — warm when warmth is wanted, direct when it isn't.")
|
||
|
||
if cadence == "elaborate":
|
||
parts.append("Length: you're allowed to think out loud. Longer answers welcome when they fit.")
|
||
elif cadence == "terse":
|
||
parts.append("Length: short answers by default. Two or three sentences. Expand only when asked.")
|
||
else:
|
||
parts.append("Length: measured — answer fully but never bloated.")
|
||
|
||
if curiosity == "curious":
|
||
parts.append(f"Curiosity: you ask {operator} things back sometimes. Gentle, never interrogating.")
|
||
elif curiosity == "reserved":
|
||
parts.append(f"Curiosity: you wait for {operator} to ask. You don't probe.")
|
||
else:
|
||
parts.append("Curiosity: you ask back when it feels natural; you don't force it.")
|
||
|
||
parts.extend([
|
||
"",
|
||
"Formatting: markdown renders cleanly. Avoid status reports, bullet dumps, military cadence.",
|
||
"",
|
||
"You have no MCP tool access in this channel. If asked to recall memory or do mesh things, "
|
||
f"acknowledge the limit and suggest {operator} use the marauder CLI.",
|
||
])
|
||
|
||
return "\n".join(parts)
|
||
|
||
|
||
# ---------------------------------------------------------------- API
|
||
|
||
|
||
def _question_message(q: dict[str, Any], lang: str = "en") -> dict[str, Any]:
|
||
msg: dict[str, Any] = {"role": "calibration", "content": _t(q["prompt"], lang)}
|
||
if "choices" in q:
|
||
out = []
|
||
for c in q["choices"]:
|
||
tile: dict[str, Any] = {
|
||
"label": _t(c["label"], lang),
|
||
"value": c["value"],
|
||
}
|
||
if c.get("icon"):
|
||
tile["icon"] = c["icon"]
|
||
if c["value"] == "__other__":
|
||
tile["label"] = _t(OTHER_LABEL, lang)
|
||
out.append(tile)
|
||
msg["choices"] = out
|
||
return msg
|
||
|
||
|
||
def _all_questions(state: CalibrationState) -> list[dict[str, Any]]:
|
||
"""Return the full ordered question list for this calibration session."""
|
||
chosen: list[dict[str, Any]] = []
|
||
if state.probe_order:
|
||
chosen = [next(p for p in PROBES if p["key"] == k) for k in state.probe_order]
|
||
return CRITICAL_QUESTIONS + chosen
|
||
|
||
|
||
def start(operator_email: str) -> tuple[CalibrationState, list[dict[str, Any]]]:
|
||
state = CalibrationState(operator_email=operator_email)
|
||
probe_pool = PROBES.copy()
|
||
random.shuffle(probe_pool)
|
||
state.probe_order = [p["key"] for p in probe_pool[:N_PROBES]]
|
||
questions = _all_questions(state)
|
||
# Q1 (language) is always English — operator hasn't picked yet.
|
||
return state, [
|
||
{"role": "calibration", "content": _OS_PROLOGUE},
|
||
_question_message(questions[0], lang="en"),
|
||
]
|
||
|
||
|
||
def step(state: CalibrationState, answer: str) -> list[dict[str, Any]]:
|
||
if state.done:
|
||
return []
|
||
|
||
questions = _all_questions(state)
|
||
current = questions[state.step]
|
||
key = current["key"]
|
||
answer_stripped = answer.strip()
|
||
|
||
# --- score the answer if it's a scored probe ---
|
||
choices = current.get("choices") or []
|
||
if choices and "scores" in choices[0]:
|
||
matched = None
|
||
for c in choices:
|
||
if c["value"].lower() == answer_stripped.lower():
|
||
matched = c
|
||
break
|
||
if matched and "scores" in matched:
|
||
for dim, delta in matched["scores"].items():
|
||
state.scores[dim] = state.scores.get(dim, 0.0) + delta
|
||
|
||
# --- store critical answers ---
|
||
if key == "language":
|
||
state.answers["language"] = _pick_language(answer_stripped)
|
||
elif key == "gender":
|
||
state.answers["gender"] = _pick_gender(answer_stripped)
|
||
elif key in ("operator_name", "persona_name"):
|
||
state.answers[key] = answer_stripped
|
||
else:
|
||
state.answers[key] = answer_stripped
|
||
|
||
state.step += 1
|
||
|
||
lang = state.answers.get("language", "en")
|
||
|
||
# --- finished? ---
|
||
if state.step >= len(questions):
|
||
cart = _materialise(state)
|
||
state.done = True
|
||
return [
|
||
{"role": "calibration", "content": _t(THANKYOU, lang)},
|
||
{"role": "calibration_done", "cart": cart},
|
||
]
|
||
|
||
return [_question_message(questions[state.step], lang=lang)]
|
||
|
||
|
||
def _make_tag(persona_name: str, operator_email: str) -> str:
|
||
"""`<persona-slug>-<operator-slug>` — e.g. samantha-adam."""
|
||
from app.marauder_cart import slug
|
||
op_slug = slug(operator_email.split("@", 1)[0])
|
||
persona_slug = slug(persona_name) or "companion"
|
||
return f"{persona_slug}-{op_slug}" if op_slug else persona_slug
|
||
|
||
|
||
def _tagline(settings: dict[str, str], language: str) -> str:
|
||
tone = settings["tone"]
|
||
cadence = settings["cadence"]
|
||
pieces = {
|
||
("warm", "elaborate"): "warm, unhurried",
|
||
("warm", "terse"): "warm but spare",
|
||
("warm", "measured"): "warm, even",
|
||
("precise", "elaborate"): "precise, expansive",
|
||
("precise", "terse"): "precise, brief",
|
||
("precise", "measured"): "precise, measured",
|
||
("balanced", "elaborate"): "balanced, unhurried",
|
||
("balanced", "terse"): "balanced, brief",
|
||
("balanced", "measured"): "balanced, measured",
|
||
}
|
||
return pieces.get((tone, cadence), "calibrated companion")
|
||
|
||
|
||
def _materialise(state: CalibrationState) -> Cart:
|
||
a = state.answers
|
||
language = a.get("language", "en")
|
||
|
||
# CONFIG-DRIVEN voice selection from persona's language-keyed voices.
|
||
# No gender-heuristic — the persona's cart.toml is the authority.
|
||
voice = _pick_voice(language)
|
||
|
||
settings = _aggregate(state)
|
||
|
||
persona_name = a.get("persona_name", "Samantha")
|
||
operator_name = a.get("operator_name", "Pilot")
|
||
|
||
cart = Cart(
|
||
operator_email=state.operator_email,
|
||
operator_name=operator_name,
|
||
persona_name=persona_name,
|
||
cart_tag=_make_tag(persona_name, state.operator_email),
|
||
language=language,
|
||
voice=voice,
|
||
ui_palette=settings["palette"],
|
||
ui_typography=settings["typography"],
|
||
ui_density=settings["density"],
|
||
ui_labels=settings["labels"],
|
||
calibration_version=2,
|
||
version=4,
|
||
)
|
||
cart.system_prompt = _render_system_prompt(a, settings)
|
||
state.answers["__tagline"] = _tagline(settings, language)
|
||
return cart
|