Files
PTSRaiAgent/dashboard/console/scenario.py
T
ElebobaandClaude Opus 5.5 8d12aa3d08
CI / ruff · pytest (pull_request) Successful in 1m37s
feat(dashboard): console fallbacks for the real API (#12)
- personas degrade: /demo/personas → doctor's patients (hero first) → env token
- scenario file guessed from the transliterated first name (andrey.jsonl), like the backend
- «Подключить демо-пациента»: connects the synthetic hero via /patients/connect when no seed
- connect code prefilled from ?code= of the console link

Refs #17

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-04 12:50:42 +03:00

181 lines
7.4 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""Demo scenario files `data/synthetic/<persona>.jsonl` (format agreed with #18).
One JSON object per line: `{"day": <therapy day>, "note": "<presenter hint>", "actions": [...]}` where an action
is exactly one of `{"advance_to": "HH:MM"}`, `{"press": "<label substring>"}`, `{"text": "<message>"}`,
`{"expect_color": "green|yellow|red|grey"}`. The console also accepts `{"start": "<connect code or empty>"}`
(sends a `start` event) so a scenario can open the dialog itself.
The parser is lenient on purpose: a broken line or action is reported and skipped, the rest still plays —
a typo in the scenario must not take the live demo down.
"""
from __future__ import annotations
import json
import re
from dataclasses import dataclass, field
from pathlib import Path
from typing import Any, Literal
ActionKind = Literal["advance_to", "press", "text", "expect_color", "start"]
ACTION_KINDS: tuple[ActionKind, ...] = ("advance_to", "press", "text", "expect_color", "start")
COLORS = ("green", "yellow", "red", "grey")
_HHMM = re.compile(r"^([01]?\d|2[0-3]):([0-5]\d)$")
REPO_ROOT = Path(__file__).resolve().parents[2]
@dataclass(frozen=True)
class Action:
kind: ActionKind
value: str
def describe(self) -> str:
"""Short Russian description for the step log."""
return {
"advance_to": f"⏩ часы → {self.value}",
"press": f"🔘 нажать «{self.value}»",
"text": f"💬 написать «{self.value}»",
"expect_color": f"🎯 ожидаем цвет {self.value}",
"start": "🚀 начать диалог" + (f" (код {self.value})" if self.value else ""),
}[self.kind]
@dataclass(frozen=True)
class DayScript:
day: int
note: str
actions: tuple[Action, ...]
@dataclass
class Scenario:
days: list[DayScript] = field(default_factory=list)
errors: list[str] = field(default_factory=list)
def day(self, number: int) -> DayScript | None:
return next((d for d in self.days if d.day == number), None)
@property
def day_numbers(self) -> list[int]:
return [d.day for d in self.days]
def parse_hhmm(value: str) -> tuple[int, int]:
"""'8:05' / '08:05' → (8, 5); raises ValueError on anything else."""
m = _HHMM.match(value.strip())
if m is None:
raise ValueError(f"время должно быть в формате ЧЧ:ММ, получено «{value}»")
return int(m.group(1)), int(m.group(2))
def parse_action(raw: Any) -> Action:
"""Validate one action object; raises ValueError with a Russian message."""
if not isinstance(raw, dict) or len(raw) != 1:
raise ValueError(f"действие должно быть объектом с одним ключом, получено {raw!r}")
((kind, value),) = raw.items()
if kind not in ACTION_KINDS:
raise ValueError(f"неизвестное действие «{kind}»")
if value is None and kind == "start":
value = ""
if not isinstance(value, str):
raise ValueError(f"значение «{kind}» должно быть строкой")
value = value.strip()
if kind == "advance_to":
h, m = parse_hhmm(value)
value = f"{h:02d}:{m:02d}"
elif kind == "expect_color":
value = value.lower()
if value not in COLORS:
raise ValueError(f"цвет должен быть одним из {', '.join(COLORS)}, получено «{value}»")
elif kind in ("press", "text") and not value:
raise ValueError(f"пустое значение «{kind}»")
return Action(kind, value) # type: ignore[arg-type]
def parse_scenario(text: str) -> Scenario:
"""Parse JSONL text. Days are returned sorted; duplicates keep the first occurrence."""
scenario = Scenario()
seen: set[int] = set()
for lineno, line in enumerate(text.splitlines(), start=1):
line = line.strip()
if not line or line.startswith("//") or line.startswith("#"):
continue
where = f"строка {lineno}"
try:
obj = json.loads(line)
except json.JSONDecodeError as exc:
scenario.errors.append(f"{where}: не JSON ({exc.msg})")
continue
if not isinstance(obj, dict):
scenario.errors.append(f"{where}: ожидается объект")
continue
day = obj.get("day")
if isinstance(day, bool) or not isinstance(day, int) or day < 0:
scenario.errors.append(f"{where}: поле day должно быть целым ≥ 0")
continue
if day in seen:
scenario.errors.append(f"{where}: день {day} повторяется, строка пропущена")
continue
raw_actions = obj.get("actions", [])
if not isinstance(raw_actions, list):
scenario.errors.append(f"{where}: actions должно быть списком")
continue
actions: list[Action] = []
for i, raw in enumerate(raw_actions, start=1):
try:
actions.append(parse_action(raw))
except ValueError as exc:
scenario.errors.append(f"{where}, день {day}, действие {i}: {exc} — пропущено")
note = obj.get("note", "")
seen.add(day)
scenario.days.append(DayScript(day=day, note=str(note) if note is not None else "", actions=tuple(actions)))
scenario.days.sort(key=lambda d: d.day)
return scenario
def resolve_path(path: str | Path, root: Path = REPO_ROOT) -> Path:
p = Path(path)
return p if p.is_absolute() else root / p
def load_scenario(path: str | Path, root: Path = REPO_ROOT) -> Scenario:
"""Read and parse a scenario file; a missing/unreadable file yields an empty scenario with an error."""
full = resolve_path(path, root)
try:
text = full.read_text(encoding="utf-8")
except FileNotFoundError:
return Scenario(errors=[f"файл сценария не найден: {path}"])
except OSError as exc:
return Scenario(errors=[f"не удалось прочитать {path}: {type(exc).__name__}"])
return parse_scenario(text)
_TRANSLIT = {
"а": "a", "б": "b", "в": "v", "г": "g", "д": "d", "е": "e", "ё": "e", "ж": "zh", "з": "z", "и": "i",
"й": "y", "к": "k", "л": "l", "м": "m", "н": "n", "о": "o", "п": "p", "р": "r", "с": "s", "т": "t",
"у": "u", "ф": "f", "х": "kh", "ц": "ts", "ч": "ch", "ш": "sh", "щ": "shch", "ъ": "", "ы": "y", "ь": "",
"э": "e", "ю": "yu", "я": "ya",
} # fmt: skip
def persona_slug(display_name: str) -> str:
"""'Андрей С.' → 'andrey': the backend names scenario files by the transliterated first name (#12)."""
first = (display_name.split() or [""])[0].lower()
return "".join(_TRANSLIT.get(ch, ch if ch.isascii() and ch.isalnum() else "") for ch in first)
def default_scenario_path(persona: dict[str, Any], root: Path = REPO_ROOT) -> str | None:
"""Scenario path from the persona, else `data/synthetic/<slug>.jsonl` guessed from the name or `tok-<slug>`."""
if persona.get("scenario"):
return str(persona["scenario"])
token = str(persona.get("token", ""))
slugs = [persona_slug(str(persona.get("display_name", "")))]
if token.startswith("tok-"):
slugs.append(token.removeprefix("tok-"))
for slug in filter(None, slugs):
guess = f"data/synthetic/{slug}.jsonl"
if resolve_path(guess, root).exists():
return guess
return None