"""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.]` 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.].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": "bt7274-en", "pl": "bt7274-pl", } # 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.].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.", ]) 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: """`-` — 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