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.
This commit is contained in:
+332
-126
@@ -1,22 +1,69 @@
|
|||||||
"""Indirect, randomized calibration for chat-saiden.
|
"""Indirect, randomized calibration for chat-saiden. (v2 — 10-question battery)
|
||||||
|
|
||||||
Boot interview shape:
|
Boot interview shape (10 questions total):
|
||||||
1) Critical: language → your name → persona name → gender (fixed order)
|
1) Critical (fixed order, always 4):
|
||||||
2) Six indirect probes drawn at random from a pool of ~12.
|
Q1 language — which language should we speak in?
|
||||||
3) Each probe's answer scores hidden dimensions (tone, cadence, curiosity,
|
Q2 operator_name — what to call you?
|
||||||
warmth-temperature, density). At the end the scores are reduced to UI
|
Q3 persona_name — what to call me?
|
||||||
settings (palette, typography, label style, density) and a system prompt.
|
Q4 gender — how do you imagine my voice?
|
||||||
|
2) Probe battery (6 drawn at random from the 12-item pool below).
|
||||||
|
|
||||||
We never ask the operator directly about tone or cadence or palette. We
|
All 12 probes are deliberately AI/tech-UNRELATED — pure human-experience questions
|
||||||
infer everything from oblique questions, the way the OS1 boot scene does in
|
about aesthetics, rhythm, setting, and social texture.
|
||||||
*Her* (2013).
|
|
||||||
|
### 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
|
from __future__ import annotations
|
||||||
|
|
||||||
import logging
|
import logging
|
||||||
|
import os
|
||||||
import random
|
import random
|
||||||
from dataclasses import dataclass, field
|
from dataclasses import dataclass, field
|
||||||
|
from pathlib import Path
|
||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
from app.cart_store import Cart
|
from app.cart_store import Cart
|
||||||
@@ -24,20 +71,85 @@ from app.cart_store import Cart
|
|||||||
log = logging.getLogger("chat-saiden.calibration")
|
log = logging.getLogger("chat-saiden.calibration")
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------- voice pool
|
# ---------------------------------------------------------------- voice config
|
||||||
|
#
|
||||||
|
# FALLBACK_VOICES: used when no .pcart config is found.
|
||||||
VOICE_POOL: dict[tuple[str, str], str] = {
|
# These are the known-good piper voice IDs shipped with the TTS host.
|
||||||
("en", "female"): "en_US-amy-medium",
|
FALLBACK_VOICES: dict[str, str] = {
|
||||||
("en", "male"): "jarvis-high",
|
"en": "en_US-lessac-medium",
|
||||||
("en", "neutral"): "en_US-lessac-medium",
|
"pl": "pl_PL-gosia-medium",
|
||||||
("en", "surprise"): "en_US-amy-medium",
|
|
||||||
("pl", "female"): "pl_PL-gosia-medium",
|
|
||||||
("pl", "male"): "pl_PL-mc_speech-medium",
|
|
||||||
("pl", "neutral"): "pl_PL-mls_6892-low",
|
|
||||||
("pl", "surprise"): "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
|
# ---------------------------------------------------------------- dimensions
|
||||||
|
|
||||||
@@ -47,9 +159,6 @@ VOICE_POOL: dict[tuple[str, str], str] = {
|
|||||||
# energy : -1 (calm / still) .. +1 (lively / quick)
|
# energy : -1 (calm / still) .. +1 (lively / quick)
|
||||||
# contrast : -1 (low / soft) .. +1 (high / clean lines)
|
# contrast : -1 (low / soft) .. +1 (high / clean lines)
|
||||||
# curiosity: -1 (reserved) .. +1 (asks back)
|
# curiosity: -1 (reserved) .. +1 (asks back)
|
||||||
#
|
|
||||||
# Probes score one or more of these in either direction. At the end we
|
|
||||||
# threshold the totals to land on cart settings.
|
|
||||||
|
|
||||||
|
|
||||||
@dataclass
|
@dataclass
|
||||||
@@ -75,12 +184,12 @@ _OS_PROLOGUE = (
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
def _t(field: Any, lang: str) -> str:
|
def _t(f: Any, lang: str) -> str:
|
||||||
"""Translate a field. If it's a dict, return lang key (fall back to en).
|
"""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 it's a plain string, return it unchanged."""
|
||||||
if isinstance(field, dict):
|
if isinstance(f, dict):
|
||||||
return field.get(lang) or field.get("en") or next(iter(field.values()), "")
|
return f.get(lang) or f.get("en") or next(iter(f.values()), "")
|
||||||
return field
|
return f
|
||||||
|
|
||||||
|
|
||||||
CRITICAL_QUESTIONS: list[dict[str, Any]] = [
|
CRITICAL_QUESTIONS: list[dict[str, Any]] = [
|
||||||
@@ -124,137 +233,210 @@ CRITICAL_QUESTIONS: list[dict[str, Any]] = [
|
|||||||
},
|
},
|
||||||
]
|
]
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------- probe pool
|
||||||
# Probes — indirect questions. Each option carries a dict of dimension deltas.
|
#
|
||||||
# We draw 6 probes at random, randomly ordered, from this 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]] = [
|
PROBES: list[dict[str, Any]] = [
|
||||||
|
# 1. Season
|
||||||
{
|
{
|
||||||
"key": "season",
|
"key": "season",
|
||||||
"prompt": {"en": "Pick a season.", "pl": "Wybierz porę roku."},
|
"prompt": {"en": "Pick a season.", "pl": "Wybierz porę roku."},
|
||||||
"choices": [
|
"choices": [
|
||||||
{"label": {"en": "Spring", "pl": "Wiosna"}, "value": "spring", "icon": "🌱", "scores": {"warmth": +0.5, "energy": +0.5, "curiosity": +0.3}},
|
{"label": {"en": "Spring", "pl": "Wiosna"}, "value": "spring", "icon": "🌱",
|
||||||
{"label": {"en": "Summer", "pl": "Lato"}, "value": "summer", "icon": "☀️", "scores": {"warmth": +0.7, "energy": +0.6, "contrast": +0.2}},
|
"scores": {"warmth": +0.5, "energy": +0.5, "curiosity": +0.3}},
|
||||||
{"label": {"en": "Autumn", "pl": "Jesień"}, "value": "autumn", "icon": "🍂", "scores": {"warmth": +0.3, "cadence": +0.4, "contrast": -0.2}},
|
{"label": {"en": "Summer", "pl": "Lato"}, "value": "summer", "icon": "☀️",
|
||||||
{"label": {"en": "Winter", "pl": "Zima"}, "value": "winter", "icon": "❄️", "scores": {"warmth": -0.5, "cadence": +0.3, "contrast": +0.4}},
|
"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",
|
"key": "time_of_day",
|
||||||
"prompt": {"en": "When do you do your best thinking?", "pl": "Kiedy myślisz Ci się najlepiej?"},
|
"prompt": {"en": "When do you do your best thinking?", "pl": "Kiedy myślisz Ci się najlepiej?"},
|
||||||
"choices": [
|
"choices": [
|
||||||
{"label": {"en": "Early morning", "pl": "Wczesny ranek"}, "value": "morning", "scores": {"energy": +0.4, "contrast": +0.3, "warmth": +0.2}},
|
{"label": {"en": "Early morning", "pl": "Wczesny ranek"}, "value": "morning",
|
||||||
{"label": {"en": "Midday", "pl": "Południe"}, "value": "midday", "scores": {"energy": +0.5, "contrast": +0.4}},
|
"scores": {"energy": +0.4, "contrast": +0.3, "warmth": +0.2}},
|
||||||
{"label": {"en": "Evening", "pl": "Wieczór"}, "value": "evening", "scores": {"warmth": +0.5, "cadence": +0.4, "energy": -0.2}},
|
{"label": {"en": "Midday", "pl": "Południe"}, "value": "midday",
|
||||||
{"label": {"en": "Late at night", "pl": "Późna noc"}, "value": "night", "scores": {"warmth": +0.3, "cadence": +0.6, "energy": -0.4, "contrast": -0.3}},
|
"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",
|
"key": "drink",
|
||||||
"prompt": {"en": "Coffee, tea, or something else?", "pl": "Kawa, herbata, czy coś innego?"},
|
"prompt": {"en": "Coffee, tea, or something else?", "pl": "Kawa, herbata, czy coś innego?"},
|
||||||
"choices": [
|
"choices": [
|
||||||
{"label": {"en": "Coffee", "pl": "Kawa"}, "value": "coffee", "icon": "☕", "scores": {"energy": +0.5, "contrast": +0.4}},
|
{"label": {"en": "Coffee", "pl": "Kawa"}, "value": "coffee", "icon": "☕",
|
||||||
{"label": {"en": "Tea", "pl": "Herbata"}, "value": "tea", "icon": "🍵", "scores": {"warmth": +0.5, "cadence": +0.4, "energy": -0.2}},
|
"scores": {"energy": +0.5, "contrast": +0.4}},
|
||||||
{"label": {"en": "Water", "pl": "Woda"}, "value": "water", "icon": "💧", "scores": {"contrast": +0.3, "energy": +0.1}},
|
{"label": {"en": "Tea", "pl": "Herbata"}, "value": "tea", "icon": "🍵",
|
||||||
{"label": {"en": "Whisky", "pl": "Whisky"}, "value": "whisky", "icon": "🥃", "scores": {"warmth": +0.4, "cadence": +0.5}},
|
"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",
|
"key": "place",
|
||||||
"prompt": {"en": "Would you rather live by a city or the sea?", "pl": "Wolisz mieszkać w mieście czy nad morzem?"},
|
"prompt": {
|
||||||
|
"en": "Would you rather live by a city or the sea?",
|
||||||
|
"pl": "Wolisz mieszkać w mieście czy nad morzem?",
|
||||||
|
},
|
||||||
"choices": [
|
"choices": [
|
||||||
{"label": {"en": "City", "pl": "Miasto"}, "value": "city", "icon": "🏙", "scores": {"energy": +0.6, "contrast": +0.5, "curiosity": +0.4}},
|
{"label": {"en": "City", "pl": "Miasto"}, "value": "city", "icon": "🏙",
|
||||||
{"label": {"en": "Sea", "pl": "Morze"}, "value": "sea", "icon": "🌊", "scores": {"warmth": +0.3, "cadence": +0.5, "energy": -0.2}},
|
"scores": {"energy": +0.6, "contrast": +0.5, "curiosity": +0.4}},
|
||||||
{"label": {"en": "Mountains", "pl": "Góry"}, "value": "mountain", "icon": "🏔", "scores": {"contrast": +0.4, "cadence": +0.4, "energy": -0.1}},
|
{"label": {"en": "Sea", "pl": "Morze"}, "value": "sea", "icon": "🌊",
|
||||||
{"label": {"en": "Forest", "pl": "Las"}, "value": "forest", "icon": "🌲", "scores": {"warmth": +0.2, "cadence": +0.5, "energy": -0.3, "contrast": -0.2}},
|
"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",
|
"key": "weekend",
|
||||||
"prompt": {"en": "A free Saturday — what's the shape of it?", "pl": "Wolna sobota — jak ją spędzasz?"},
|
"prompt": {"en": "A free Saturday — what's the shape of it?", "pl": "Wolna sobota — jak ją spędzasz?"},
|
||||||
"choices": [
|
"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 long walk", "pl": "Długi spacer"}, "value": "walk",
|
||||||
{"label": {"en": "A movie marathon", "pl": "Maraton filmowy"}, "value": "movies", "scores": {"warmth": +0.4, "cadence": +0.6, "energy": -0.3}},
|
"scores": {"warmth": +0.3, "cadence": +0.5, "energy": -0.1}},
|
||||||
{"label": {"en": "Out with friends", "pl": "Ze znajomymi"}, "value": "social", "scores": {"warmth": +0.5, "energy": +0.5, "curiosity": +0.5}},
|
{"label": {"en": "A movie marathon", "pl": "Maraton filmowy"}, "value": "movies",
|
||||||
{"label": {"en": "Project time", "pl": "Praca nad projektem"}, "value": "work", "scores": {"contrast": +0.4, "cadence": -0.2, "energy": +0.3}},
|
"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",
|
"key": "stranger",
|
||||||
"prompt": {"en": "A stranger sits next to you on a flight. Do you say hello?",
|
"prompt": {
|
||||||
"pl": "Obok ciebie w samolocie siada nieznajomy. Witasz się?"},
|
"en": "A stranger sits next to you on a flight. Do you say hello?",
|
||||||
|
"pl": "Obok ciebie w samolocie siada nieznajomy. Witasz się?",
|
||||||
|
},
|
||||||
"choices": [
|
"choices": [
|
||||||
{"label": {"en": "Always", "pl": "Zawsze"}, "value": "always", "scores": {"warmth": +0.6, "curiosity": +0.7, "energy": +0.3}},
|
{"label": {"en": "Always", "pl": "Zawsze"}, "value": "always",
|
||||||
{"label": {"en": "If they seem open", "pl": "Jeśli wydaje się otwarty"},"value": "maybe", "scores": {"warmth": +0.2, "curiosity": +0.3}},
|
"scores": {"warmth": +0.6, "curiosity": +0.7, "energy": +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": "If they seem open", "pl": "Jeśli wydaje się otwarty"},"value": "maybe",
|
||||||
{"label": {"en": "Depends", "pl": "To zależy"}, "value": "depends", "scores": {"curiosity": +0.0}},
|
"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",
|
"key": "book",
|
||||||
"prompt": {"en": "When you start a book, do you usually finish it?",
|
"prompt": {
|
||||||
"pl": "Czy zwykle kończysz książki, które zaczniesz?"},
|
"en": "When you start a book, do you usually finish it?",
|
||||||
|
"pl": "Czy zwykle kończysz książki, które zaczniesz?",
|
||||||
|
},
|
||||||
"choices": [
|
"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": "Always — to the end", "pl": "Zawsze — do końca"}, "value": "finisher",
|
||||||
{"label": {"en": "Often, if it earns it", "pl": "Często, jeśli wciąga"}, "value": "selective", "scores": {"contrast": +0.2}},
|
"scores": {"cadence": +0.5, "contrast": +0.3, "energy": -0.1}},
|
||||||
{"label": {"en": "Rarely", "pl": "Rzadko"}, "value": "drifter", "scores": {"cadence": -0.3, "energy": +0.4, "curiosity": +0.3}},
|
{"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",
|
"key": "taste",
|
||||||
"prompt": {"en": "Salt or sweet?", "pl": "Słodkie czy słone?"},
|
"prompt": {"en": "Salt or sweet?", "pl": "Słodkie czy słone?"},
|
||||||
"choices": [
|
"choices": [
|
||||||
{"label": {"en": "Salt", "pl": "Słone"}, "value": "salt", "icon": "🧂", "scores": {"contrast": +0.4, "warmth": -0.1}},
|
{"label": {"en": "Salt", "pl": "Słone"}, "value": "salt", "icon": "🧂",
|
||||||
{"label": {"en": "Sweet", "pl": "Słodkie"}, "value": "sweet", "icon": "🍯", "scores": {"warmth": +0.4, "cadence": +0.2}},
|
"scores": {"contrast": +0.4, "warmth": -0.1}},
|
||||||
{"label": {"en": "Both", "pl": "Oba"}, "value": "both", "scores": {"warmth": +0.1, "contrast": +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",
|
"key": "answer_pace",
|
||||||
"prompt": {"en": "When someone asks you a hard question, you usually…",
|
"prompt": {
|
||||||
"pl": "Gdy ktoś zadaje ci trudne pytanie, zwykle…"},
|
"en": "When someone asks you a hard question, you usually…",
|
||||||
|
"pl": "Gdy ktoś zadaje ci trudne pytanie, zwykle…",
|
||||||
|
},
|
||||||
"choices": [
|
"choices": [
|
||||||
{"label": {"en": "Answer right away", "pl": "Odpowiadasz od razu"}, "value": "fast", "scores": {"cadence": -0.5, "energy": +0.4}},
|
{"label": {"en": "Answer right away", "pl": "Odpowiadasz od razu"}, "value": "fast",
|
||||||
{"label": {"en": "Take a beat first", "pl": "Bierzesz chwilę"}, "value": "pause", "scores": {"cadence": +0.4, "energy": -0.2}},
|
"scores": {"cadence": -0.5, "energy": +0.4}},
|
||||||
{"label": {"en": "Think out loud through it", "pl": "Myślisz na głos"}, "value": "loud", "scores": {"cadence": +0.6, "warmth": +0.3}},
|
{"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",
|
"key": "art",
|
||||||
"prompt": {"en": "A film, a book, or a song you'd reach for tonight?",
|
"prompt": {
|
||||||
"pl": "Film, książka, czy piosenka na ten wieczór?"},
|
"en": "A film, a book, or a song you'd reach for tonight?",
|
||||||
|
"pl": "Film, książka, czy piosenka na ten wieczór?",
|
||||||
|
},
|
||||||
"choices": [
|
"choices": [
|
||||||
{"label": {"en": "A film", "pl": "Film"}, "value": "film", "icon": "🎞", "scores": {"warmth": +0.3, "cadence": +0.5}},
|
{"label": {"en": "A film", "pl": "Film"}, "value": "film", "icon": "🎞",
|
||||||
{"label": {"en": "A book", "pl": "Książka"}, "value": "book", "icon": "📖", "scores": {"contrast": +0.2, "cadence": +0.4}},
|
"scores": {"warmth": +0.3, "cadence": +0.5}},
|
||||||
{"label": {"en": "A song", "pl": "Piosenka"}, "value": "song", "icon": "🎶", "scores": {"warmth": +0.4, "energy": +0.5}},
|
{"label": {"en": "A book", "pl": "Książka"}, "value": "book", "icon": "📖",
|
||||||
{"label": {"en": "Silence", "pl": "Cisza"}, "value": "silence", "icon": "·", "scores": {"cadence": +0.6, "energy": -0.5, "warmth": -0.1}},
|
"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",
|
"key": "texture",
|
||||||
"prompt": {"en": "If a room could feel like a fabric — pick one.",
|
"prompt": {
|
||||||
"pl": "Gdyby pokój mógł być z tkaniny — wybierz jedną."},
|
"en": "If a room could feel like a fabric — pick one.",
|
||||||
|
"pl": "Gdyby pokój mógł być z tkaniny — wybierz jedną.",
|
||||||
|
},
|
||||||
"choices": [
|
"choices": [
|
||||||
{"label": {"en": "Linen", "pl": "Len"}, "value": "linen", "scores": {"warmth": +0.3, "contrast": -0.2}},
|
{"label": {"en": "Linen", "pl": "Len"}, "value": "linen",
|
||||||
{"label": {"en": "Wool", "pl": "Wełna"}, "value": "wool", "scores": {"warmth": +0.6, "cadence": +0.3}},
|
"scores": {"warmth": +0.3, "contrast": -0.2}},
|
||||||
{"label": {"en": "Cotton", "pl": "Bawełna"}, "value": "cotton", "scores": {"warmth": +0.1, "contrast": +0.0}},
|
{"label": {"en": "Wool", "pl": "Wełna"}, "value": "wool",
|
||||||
{"label": {"en": "Velvet", "pl": "Aksamit"}, "value": "velvet", "scores": {"warmth": +0.5, "cadence": +0.5, "contrast": -0.3}},
|
"scores": {"warmth": +0.6, "cadence": +0.3}},
|
||||||
{"label": {"en": "Canvas", "pl": "Płótno"}, "value": "canvas", "scores": {"contrast": +0.5, "warmth": -0.1}},
|
{"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",
|
"key": "host",
|
||||||
"prompt": {"en": "Walking into a party — find the host or scan the room first?",
|
"prompt": {
|
||||||
"pl": "Wchodzisz na imprezę — szukasz gospodarza czy rozglądasz się?"},
|
"en": "Walking into a party — find the host or scan the room first?",
|
||||||
|
"pl": "Wchodzisz na imprezę — szukasz gospodarza czy rozglądasz się?",
|
||||||
|
},
|
||||||
"choices": [
|
"choices": [
|
||||||
{"label": {"en": "Find the host", "pl": "Szukam gospodarza"}, "value": "host", "scores": {"curiosity": +0.3, "contrast": +0.2}},
|
{"label": {"en": "Find the host", "pl": "Szukam gospodarza"}, "value": "host",
|
||||||
{"label": {"en": "Scan the room", "pl": "Rozglądam się"}, "value": "scan", "scores": {"curiosity": +0.5, "energy": +0.3, "contrast": +0.4}},
|
"scores": {"curiosity": +0.3, "contrast": +0.2}},
|
||||||
{"label": {"en": "Lean by a wall", "pl": "Stoję pod ścianą"}, "value": "wall", "scores": {"curiosity": -0.4, "warmth": -0.1, "cadence": +0.3}},
|
{"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
|
# "Something else" label, localized
|
||||||
OTHER_LABEL = {"en": "Something else", "pl": "Coś innego"}
|
OTHER_LABEL = {"en": "Something else", "pl": "Coś innego"}
|
||||||
|
|
||||||
@@ -269,8 +451,8 @@ GREETING = {
|
|||||||
"pl": "Cześć, {name}. Jestem tutaj.",
|
"pl": "Cześć, {name}. Jestem tutaj.",
|
||||||
}
|
}
|
||||||
|
|
||||||
|
# Battery size: 4 critical + N_PROBES random = 10 total.
|
||||||
N_PROBES = 6 # how many random probes we draw per calibration
|
N_PROBES = 6
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------- parsers
|
# ---------------------------------------------------------------- parsers
|
||||||
@@ -300,10 +482,18 @@ def _pick_gender(answer: str) -> str:
|
|||||||
|
|
||||||
|
|
||||||
def _aggregate(state: CalibrationState) -> dict[str, str]:
|
def _aggregate(state: CalibrationState) -> dict[str, str]:
|
||||||
"""Reduce dimension scores → cart UI settings."""
|
"""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
|
s = state.scores
|
||||||
|
|
||||||
# tone: warmth dominant
|
# ---- derived tone / cadence / curiosity (for prompt synthesis) ----
|
||||||
if s["warmth"] >= 0.3:
|
if s["warmth"] >= 0.3:
|
||||||
tone = "warm"
|
tone = "warm"
|
||||||
elif s["warmth"] <= -0.3:
|
elif s["warmth"] <= -0.3:
|
||||||
@@ -311,7 +501,6 @@ def _aggregate(state: CalibrationState) -> dict[str, str]:
|
|||||||
else:
|
else:
|
||||||
tone = "balanced"
|
tone = "balanced"
|
||||||
|
|
||||||
# cadence: how much they expand
|
|
||||||
if s["cadence"] >= 0.5:
|
if s["cadence"] >= 0.5:
|
||||||
cadence = "elaborate"
|
cadence = "elaborate"
|
||||||
elif s["cadence"] <= -0.3:
|
elif s["cadence"] <= -0.3:
|
||||||
@@ -319,7 +508,6 @@ def _aggregate(state: CalibrationState) -> dict[str, str]:
|
|||||||
else:
|
else:
|
||||||
cadence = "measured"
|
cadence = "measured"
|
||||||
|
|
||||||
# curiosity
|
|
||||||
if s["curiosity"] >= 0.3:
|
if s["curiosity"] >= 0.3:
|
||||||
curiosity = "curious"
|
curiosity = "curious"
|
||||||
elif s["curiosity"] <= -0.3:
|
elif s["curiosity"] <= -0.3:
|
||||||
@@ -327,34 +515,53 @@ def _aggregate(state: CalibrationState) -> dict[str, str]:
|
|||||||
else:
|
else:
|
||||||
curiosity = "balanced"
|
curiosity = "balanced"
|
||||||
|
|
||||||
# Palette — derive from warmth + contrast + energy combination
|
# ---- palette (LOCKED vocab: default|rose|morning|evening|sage|paper|ink) ----
|
||||||
warmth, contrast, energy = s["warmth"], s["contrast"], s["energy"]
|
# 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:
|
if warmth >= 0.5 and energy >= 0.3:
|
||||||
|
# High warmth + lively energy → vibrant tinted palette
|
||||||
palette = "rose"
|
palette = "rose"
|
||||||
elif warmth >= 0.5 and contrast <= 0:
|
elif warmth >= 0.5 and contrast <= 0.0:
|
||||||
|
# Warm but low-contrast → soft dusk palette
|
||||||
palette = "evening"
|
palette = "evening"
|
||||||
elif warmth >= 0.3:
|
elif warmth >= 0.3:
|
||||||
|
# Moderately warm → morning light palette
|
||||||
palette = "morning"
|
palette = "morning"
|
||||||
elif warmth <= -0.3 and contrast >= 0.3:
|
elif warmth <= -0.3 and contrast >= 0.3:
|
||||||
|
# Cool + high contrast → dark ink palette (WCAG AA: ~19:1)
|
||||||
palette = "ink"
|
palette = "ink"
|
||||||
elif warmth <= -0.3:
|
elif warmth <= -0.3:
|
||||||
|
# Cool without strong contrast → muted sage palette
|
||||||
palette = "sage"
|
palette = "sage"
|
||||||
elif contrast >= 0.4:
|
elif contrast >= 0.4:
|
||||||
|
# Neutral warmth but crisp contrast → paper palette
|
||||||
palette = "paper"
|
palette = "paper"
|
||||||
else:
|
else:
|
||||||
|
# Everything else → neutral default
|
||||||
palette = "default"
|
palette = "default"
|
||||||
|
|
||||||
# Typography — driven by contrast + cadence
|
# ---- typography (LOCKED vocab: sans|serif-warm|serif-formal|mixed-modern|mono) ----
|
||||||
if cadence == "elaborate" and warmth >= 0.3:
|
if cadence == "elaborate" and warmth >= 0.3:
|
||||||
typography = "serif-warm" # Cormorant + Caveat labels
|
# Warm + expansive → Cormorant Garamond + Caveat labels
|
||||||
elif contrast >= 0.4:
|
typography = "serif-warm"
|
||||||
typography = "serif-formal" # Source Serif, no cursive
|
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:
|
elif energy >= 0.4 and warmth >= 0.0:
|
||||||
typography = "mixed-modern" # Inter body + Caveat labels
|
# Energetic + at least neutral warmth → Inter + Caveat accent labels
|
||||||
|
typography = "mixed-modern"
|
||||||
else:
|
else:
|
||||||
typography = "sans" # Inter throughout (default-ish)
|
# Default clean sans
|
||||||
|
typography = "sans"
|
||||||
|
|
||||||
# Density — cadence-driven
|
# ---- density (LOCKED vocab: airy|normal|dense) ----
|
||||||
if cadence == "elaborate":
|
if cadence == "elaborate":
|
||||||
density = "airy"
|
density = "airy"
|
||||||
elif cadence == "terse":
|
elif cadence == "terse":
|
||||||
@@ -362,13 +569,12 @@ def _aggregate(state: CalibrationState) -> dict[str, str]:
|
|||||||
else:
|
else:
|
||||||
density = "normal"
|
density = "normal"
|
||||||
|
|
||||||
# Label style — paired with typography
|
# ---- labels (LOCKED vocab: block|cursive|none|prefix) ----
|
||||||
if typography == "serif-warm":
|
if typography in ("serif-warm", "mixed-modern"):
|
||||||
labels = "cursive"
|
labels = "cursive"
|
||||||
elif typography == "mixed-modern":
|
elif typography == "mono":
|
||||||
labels = "cursive"
|
# Terminal-ish feel — prefix labels (e.g. "> ")
|
||||||
elif typography == "serif-formal":
|
labels = "prefix"
|
||||||
labels = "block"
|
|
||||||
else:
|
else:
|
||||||
labels = "block"
|
labels = "block"
|
||||||
|
|
||||||
@@ -428,7 +634,7 @@ def _render_system_prompt(answers: dict[str, Any], settings: dict[str, str]) ->
|
|||||||
elif curiosity == "reserved":
|
elif curiosity == "reserved":
|
||||||
parts.append(f"Curiosity: you wait for {operator} to ask. You don't probe.")
|
parts.append(f"Curiosity: you wait for {operator} to ask. You don't probe.")
|
||||||
else:
|
else:
|
||||||
parts.append(f"Curiosity: you ask back when it feels natural; you don't force it.")
|
parts.append("Curiosity: you ask back when it feels natural; you don't force it.")
|
||||||
|
|
||||||
parts.extend([
|
parts.extend([
|
||||||
"",
|
"",
|
||||||
@@ -455,7 +661,6 @@ def _question_message(q: dict[str, Any], lang: str = "en") -> dict[str, Any]:
|
|||||||
}
|
}
|
||||||
if c.get("icon"):
|
if c.get("icon"):
|
||||||
tile["icon"] = c["icon"]
|
tile["icon"] = c["icon"]
|
||||||
# patch the universal "Something else" label too if the source used the plain en string
|
|
||||||
if c["value"] == "__other__":
|
if c["value"] == "__other__":
|
||||||
tile["label"] = _t(OTHER_LABEL, lang)
|
tile["label"] = _t(OTHER_LABEL, lang)
|
||||||
out.append(tile)
|
out.append(tile)
|
||||||
@@ -465,7 +670,7 @@ def _question_message(q: dict[str, Any], lang: str = "en") -> dict[str, Any]:
|
|||||||
|
|
||||||
def _all_questions(state: CalibrationState) -> list[dict[str, Any]]:
|
def _all_questions(state: CalibrationState) -> list[dict[str, Any]]:
|
||||||
"""Return the full ordered question list for this calibration session."""
|
"""Return the full ordered question list for this calibration session."""
|
||||||
chosen = []
|
chosen: list[dict[str, Any]] = []
|
||||||
if state.probe_order:
|
if state.probe_order:
|
||||||
chosen = [next(p for p in PROBES if p["key"] == k) for k in state.probe_order]
|
chosen = [next(p for p in PROBES if p["key"] == k) for k in state.probe_order]
|
||||||
return CRITICAL_QUESTIONS + chosen
|
return CRITICAL_QUESTIONS + chosen
|
||||||
@@ -473,7 +678,6 @@ def _all_questions(state: CalibrationState) -> list[dict[str, Any]]:
|
|||||||
|
|
||||||
def start(operator_email: str) -> tuple[CalibrationState, list[dict[str, Any]]]:
|
def start(operator_email: str) -> tuple[CalibrationState, list[dict[str, Any]]]:
|
||||||
state = CalibrationState(operator_email=operator_email)
|
state = CalibrationState(operator_email=operator_email)
|
||||||
# randomize a fresh sequence of probes for this operator
|
|
||||||
probe_pool = PROBES.copy()
|
probe_pool = PROBES.copy()
|
||||||
random.shuffle(probe_pool)
|
random.shuffle(probe_pool)
|
||||||
state.probe_order = [p["key"] for p in probe_pool[:N_PROBES]]
|
state.probe_order = [p["key"] for p in probe_pool[:N_PROBES]]
|
||||||
@@ -494,11 +698,11 @@ def step(state: CalibrationState, answer: str) -> list[dict[str, Any]]:
|
|||||||
key = current["key"]
|
key = current["key"]
|
||||||
answer_stripped = answer.strip()
|
answer_stripped = answer.strip()
|
||||||
|
|
||||||
# --- score the answer if it's a probe ---
|
# --- score the answer if it's a scored probe ---
|
||||||
if "scores" in (current.get("choices") or [{}])[0]:
|
choices = current.get("choices") or []
|
||||||
# find the matching choice (by exact value) and apply its score deltas
|
if choices and "scores" in choices[0]:
|
||||||
matched = None
|
matched = None
|
||||||
for c in current.get("choices", []):
|
for c in choices:
|
||||||
if c["value"].lower() == answer_stripped.lower():
|
if c["value"].lower() == answer_stripped.lower():
|
||||||
matched = c
|
matched = c
|
||||||
break
|
break
|
||||||
@@ -506,7 +710,7 @@ def step(state: CalibrationState, answer: str) -> list[dict[str, Any]]:
|
|||||||
for dim, delta in matched["scores"].items():
|
for dim, delta in matched["scores"].items():
|
||||||
state.scores[dim] = state.scores.get(dim, 0.0) + delta
|
state.scores[dim] = state.scores.get(dim, 0.0) + delta
|
||||||
|
|
||||||
# --- store the answer string itself for critical keys ---
|
# --- store critical answers ---
|
||||||
if key == "language":
|
if key == "language":
|
||||||
state.answers["language"] = _pick_language(answer_stripped)
|
state.answers["language"] = _pick_language(answer_stripped)
|
||||||
elif key == "gender":
|
elif key == "gender":
|
||||||
@@ -518,7 +722,6 @@ def step(state: CalibrationState, answer: str) -> list[dict[str, Any]]:
|
|||||||
|
|
||||||
state.step += 1
|
state.step += 1
|
||||||
|
|
||||||
# Resolve language for downstream rendering. After Q1 is answered, it's set.
|
|
||||||
lang = state.answers.get("language", "en")
|
lang = state.answers.get("language", "en")
|
||||||
|
|
||||||
# --- finished? ---
|
# --- finished? ---
|
||||||
@@ -561,8 +764,10 @@ def _tagline(settings: dict[str, str], language: str) -> str:
|
|||||||
def _materialise(state: CalibrationState) -> Cart:
|
def _materialise(state: CalibrationState) -> Cart:
|
||||||
a = state.answers
|
a = state.answers
|
||||||
language = a.get("language", "en")
|
language = a.get("language", "en")
|
||||||
gender = a.get("gender", "female")
|
|
||||||
voice = VOICE_POOL.get((language, gender)) or VOICE_POOL[("en", "female")]
|
# 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)
|
settings = _aggregate(state)
|
||||||
|
|
||||||
@@ -580,8 +785,9 @@ def _materialise(state: CalibrationState) -> Cart:
|
|||||||
ui_typography=settings["typography"],
|
ui_typography=settings["typography"],
|
||||||
ui_density=settings["density"],
|
ui_density=settings["density"],
|
||||||
ui_labels=settings["labels"],
|
ui_labels=settings["labels"],
|
||||||
|
calibration_version=2,
|
||||||
|
version=4,
|
||||||
)
|
)
|
||||||
cart.system_prompt = _render_system_prompt(a, settings)
|
cart.system_prompt = _render_system_prompt(a, settings)
|
||||||
# Stash the tagline + type on the state for the post-materialise step.
|
|
||||||
state.answers["__tagline"] = _tagline(settings, language)
|
state.answers["__tagline"] = _tagline(settings, language)
|
||||||
return cart
|
return cart
|
||||||
|
|||||||
+4
-2
@@ -14,7 +14,7 @@ import json
|
|||||||
import logging
|
import logging
|
||||||
import os
|
import os
|
||||||
import re
|
import re
|
||||||
from dataclasses import asdict, dataclass, field
|
from dataclasses import asdict, dataclass
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
@@ -41,8 +41,10 @@ class Cart:
|
|||||||
ui_typography: str = "sans" # sans | serif-warm | serif-formal | mixed-modern | mono
|
ui_typography: str = "sans" # sans | serif-warm | serif-formal | mixed-modern | mono
|
||||||
ui_density: str = "normal" # airy | normal | dense
|
ui_density: str = "normal" # airy | normal | dense
|
||||||
ui_labels: str = "block" # block | cursive | none | prefix
|
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 = ""
|
created_at: str = ""
|
||||||
version: int = 3
|
version: int = 4 # bumped from 3 → 4 with calibration_version field addition
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def is_calibrated(self) -> bool:
|
def is_calibrated(self) -> bool:
|
||||||
|
|||||||
@@ -20,3 +20,8 @@ package = false
|
|||||||
|
|
||||||
[tool.ruff]
|
[tool.ruff]
|
||||||
line-length = 100
|
line-length = 100
|
||||||
|
|
||||||
|
[dependency-groups]
|
||||||
|
dev = [
|
||||||
|
"ruff>=0.15.15",
|
||||||
|
]
|
||||||
|
|||||||
@@ -160,6 +160,11 @@ dependencies = [
|
|||||||
{ name = "websockets" },
|
{ name = "websockets" },
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[package.dev-dependencies]
|
||||||
|
dev = [
|
||||||
|
{ name = "ruff" },
|
||||||
|
]
|
||||||
|
|
||||||
[package.metadata]
|
[package.metadata]
|
||||||
requires-dist = [
|
requires-dist = [
|
||||||
{ name = "anthropic", specifier = ">=0.40" },
|
{ name = "anthropic", specifier = ">=0.40" },
|
||||||
@@ -173,6 +178,9 @@ requires-dist = [
|
|||||||
{ name = "websockets", specifier = ">=13" },
|
{ name = "websockets", specifier = ">=13" },
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[package.metadata.requires-dev]
|
||||||
|
dev = [{ name = "ruff", specifier = ">=0.15.15" }]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "click"
|
name = "click"
|
||||||
version = "8.3.3"
|
version = "8.3.3"
|
||||||
@@ -765,6 +773,31 @@ wheels = [
|
|||||||
{ url = "https://files.pythonhosted.org/packages/f1/12/de94a39c2ef588c7e6455cfbe7343d3b2dc9d6b6b2f40c4c6565744c873d/pyyaml-6.0.3-cp314-cp314t-win_arm64.whl", hash = "sha256:ebc55a14a21cb14062aa4162f906cd962b28e2e9ea38f9b4391244cd8de4ae0b", size = 149341, upload-time = "2025-09-25T21:32:56.828Z" },
|
{ url = "https://files.pythonhosted.org/packages/f1/12/de94a39c2ef588c7e6455cfbe7343d3b2dc9d6b6b2f40c4c6565744c873d/pyyaml-6.0.3-cp314-cp314t-win_arm64.whl", hash = "sha256:ebc55a14a21cb14062aa4162f906cd962b28e2e9ea38f9b4391244cd8de4ae0b", size = 149341, upload-time = "2025-09-25T21:32:56.828Z" },
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "ruff"
|
||||||
|
version = "0.15.15"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/84/6f/a76f7d96e5c962f5b69cee865e49c15c1116897c01990faa8a57edb62e7f/ruff-0.15.15.tar.gz", hash = "sha256:b8dff018130b46d8e5bf0f926ef6b60cf871d6d5ae45fc9334e09632daa741d6", size = 4706985, upload-time = "2026-05-28T14:16:57.784Z" }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/fa/9d/3a45c05b8ab04b4705989de70a79008e27c8003296a0feaee9edc18dd7e9/ruff-0.15.15-py3-none-linux_armv6l.whl", hash = "sha256:cf93e5388f412e1b108b1f8b34a6e036b70fe8aff89393befad96fe48670311b", size = 10710652, upload-time = "2026-05-28T14:16:06.701Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/05/66/da974431624bf3b49f6ee1f9543c02d929ff1cba78b0d5a79c38cf21f744/ruff-0.15.15-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:ac5a646d1f6a7dadd5d50842dae2c1f9862ac887ef5d1b1375e02def791fde6e", size = 11096615, upload-time = "2026-05-28T14:16:23.313Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/8c/09/7443452e5d290230a712103f2fdceeef7184f3ec99a2bd01c8be78aaceb5/ruff-0.15.15-py3-none-macosx_11_0_arm64.whl", hash = "sha256:77d955a431430c66f72dd94e379ad38a16daea3d25094872ac4edf9e797be530", size = 10436683, upload-time = "2026-05-28T14:16:40.974Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/53/01/d330c26a57fa4f3943a14424904027428315b700fe4d14a84bb123a649e5/ruff-0.15.15-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:7614ee79c69788cf6cedd568069ade9cecc22a1ad20494efe8d0c9ebb4b622d4", size = 10769064, upload-time = "2026-05-28T14:16:28.905Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/1d/85/cc8770f8bdff541b1da8392d1634141fe4a0e3f4ee596605959b7906c27f/ruff-0.15.15-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:3cdb1679e06a1f6b47bc384714ae96f6e2fb65ca441eb78c43d2ca554176ce1f", size = 10511987, upload-time = "2026-05-28T14:16:43.732Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/7c/29/8c190c1472b63013583ba391f3342036e02010544c1270455ed8e519bdf3/ruff-0.15.15-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:2728b93d7b23a603ea2c0ac6eb73d760bd38ec9de35f35fb41e18f7a3fee7622", size = 11275100, upload-time = "2026-05-28T14:16:55.244Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/9f/6b/7e145ce2cc8e63d6834eca03d83a0e18d121def5c69f91b4cf4011ed4879/ruff-0.15.15-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:be582fcc0db438902c7792b08d6ddf6c9b9e21addaa10092c2c741cfb09e5a45", size = 12176903, upload-time = "2026-05-28T14:16:14.368Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/80/a3/d5974637f68e451f7fadf015cf3101d1cd7d8ba5027cffe0b9e3826ebe6b/ruff-0.15.15-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:7aa77465b8ecaf1a27bea098d696f7fed5e1eccbd10b321b682d6de586ae5627", size = 11404550, upload-time = "2026-05-28T14:16:20.138Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/fe/1c/e6e5e568f22be4fb05d6244234aba384c06b451252453b821e1a529263cf/ruff-0.15.15-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:48decfa11d740de4889de623be1463308346312f2409a56e24aa280c86162dc4", size = 11382027, upload-time = "2026-05-28T14:16:46.615Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/1d/01/170921b49fcd2e8858825593f91cf7146c3e40a5c3e6df763e4bb0484dde/ruff-0.15.15-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:a5015088452ca0081387063649ec67f06d3d1d6b8b936a1f836b5e9657ecd48c", size = 11366041, upload-time = "2026-05-28T14:16:26.247Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/87/54/a7bad711d7de93254e15e06a4c375b89a03d18de45d3e5dcc86a4472fb1a/ruff-0.15.15-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:f5294aab6356c81600fcdea3a62bb1b924dfd5e91767c12318d3f68f86af57cd", size = 10741795, upload-time = "2026-05-28T14:16:17.11Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/c9/31/38c075963668f8b41c6914ee0f6f318727fbe30ab9145cb29e6df464c5fa/ruff-0.15.15-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:db5bd4d802415cca656dc1616070b725952d6ae95eb5d4831e49fbd94a38f75f", size = 10511117, upload-time = "2026-05-28T14:16:31.767Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/9d/96/6ff689e1f7e375d1d97075eca022f74c2bab59554a432fe4d2e6f091986a/ruff-0.15.15-py3-none-musllinux_1_2_i686.whl", hash = "sha256:587a6278ed42059191c1a466e490bd7930fb50bd2e255398bc29616c895a61cb", size = 10994867, upload-time = "2026-05-28T14:16:35.149Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/c3/c2/5dce0ab9f92a8d534fa62b9bf9caca3eddb8c1a81b616f5e195ada4f0d6e/ruff-0.15.15-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:df0c1c084f5f4be9812f61518a45c440d3c30d69ce4bf6c5270e66d38338f02a", size = 11482101, upload-time = "2026-05-28T14:16:49.598Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/b1/c0/1003b60edd697c649faf61f1a34094b1abb38fb3d1181e3f895781250a08/ruff-0.15.15-py3-none-win32.whl", hash = "sha256:29428ea79694afbe756d45fd59b36f22b6b020dc0443cf7de0173046236964b9", size = 10716774, upload-time = "2026-05-28T14:16:52.337Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/02/a8/1269eddd6945a06c23f055ef7848886e37cf9d6a8bebb386a3115f01470c/ruff-0.15.15-py3-none-win_amd64.whl", hash = "sha256:8df0323902e15e24bc4bf246da830573d3cf3352bd0b9a164eab335d111ff4a4", size = 11868463, upload-time = "2026-05-28T14:16:11.333Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/4e/b2/920464c907b191e37469d477a1aa8bc048b8f36c4c1610dfa4ab87b39e18/ruff-0.15.15-py3-none-win_arm64.whl", hash = "sha256:3c8ceca6792f38196b8f589bc92eccd03eef286602da92e5dc05cc42ef6441b7", size = 11138498, upload-time = "2026-05-28T14:16:38.425Z" },
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "sniffio"
|
name = "sniffio"
|
||||||
version = "1.3.1"
|
version = "1.3.1"
|
||||||
|
|||||||
Reference in New Issue
Block a user