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:
marauder-actual
2026-05-29 14:00:14 +02:00
parent 96ba8f4b6e
commit 4594f07ebc
4 changed files with 384 additions and 138 deletions
+332 -126
View File
@@ -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 69: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
View File
@@ -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:
+5
View File
@@ -20,3 +20,8 @@ package = false
[tool.ruff] [tool.ruff]
line-length = 100 line-length = 100
[dependency-groups]
dev = [
"ruff>=0.15.15",
]
Generated
+33
View File
@@ -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"