Compare commits

...
Author SHA1 Message Date
ElebobaandClaude Opus 5.5 f19ea33937 feat: SaluteSpeech STT for the pilot + no foreign STT outside the demo (ADR-016)
CI / ruff · pytest (pull_request) Successful in 2m16s
Refs #121

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-31 17:11:44 +03:00
Eleboba b70911e182 Merge pull request 'feat: сад — правки по вычитке (честная отметка, тишина после кризиса, тексты)' (#126) from feat/120-garden-review into dev
CD / ruff · pytest (push) Successful in 2m17s
CI / ruff · pytest (push) Successful in 2m28s
CD / deploy dev (push) Successful in 1m10s
2026-10-31 15:50:11 +03:00
ElebobaandClaude Opus 5.5 e2e1283d8e feat: garden review fixes — honest dose answer grows it, quiet after crisis, no-promise texts (ADR-015)
CI / ruff · pytest (pull_request) Successful in 2m21s
Refs #120

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-31 15:47:34 +03:00
Eleboba 78312f5076 Merge pull request 'feat(web): сад на экране «Сегодня»' (#125) from feat/119-garden-web into dev
CI / ruff · pytest (push) Successful in 2m23s
CD / ruff · pytest (push) Successful in 2m24s
CD / deploy dev (push) Successful in 1m21s
2026-10-31 12:02:08 +03:00
ElebobaandClaude Opus 5.5 8bf56291c9 feat(web): garden on the «Сегодня» screen — stages, progress, today's checklist
CI / ruff · pytest (pull_request) Successful in 2m34s
SVG garden drawn from GET /me/{token}/garden: plants grow one after another
(sprout → stem → bud → flower), «Новый росток N из 10», today's activities,
«Пропуск — не страшно, сад не вянет». A gentle grow-in and a toast when the
garden grew since the last visit — but not right after a crisis reply.
No streaks, no «missed» marks, no red.

Refs #119

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-31 11:57:51 +03:00
Eleboba fd9df40c28 Merge pull request 'feat(web): голосовой ввод — запись, проверка текста, отправка' (#124) from feat/117-voice-web into dev
CI / ruff · pytest (push) Successful in 2m37s
CD / ruff · pytest (push) Successful in 2m26s
CD / deploy dev (push) Successful in 1m35s
2026-10-31 11:54:45 +03:00
ElebobaandClaude Opus 5.5 e20da1f21c feat(web): voice input — record, check the recognised text, send
CI / ruff · pytest (pull_request) Successful in 2m24s
Microphone button in the chat composer and in free-text steps. The browser
records 16 kHz mono WAV (no server-side ffmpeg), /me/{token}/stt transcribes,
the text lands in the field for the patient to check and edit — never sent
automatically, so safety and scrub() apply on send. Hidden when the server has
voice input off or the browser lacks a microphone API. nginx: 4 MB body limit.

Refs #117

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-31 11:45:36 +03:00
Eleboba 63cc5372be Merge pull request 'feat: сад пациента — рост от активностей (без давления)' (#123) from feat/118-garden into dev
CI / ruff · pytest (push) Successful in 2m24s
CD / ruff · pytest (push) Successful in 2m16s
CD / deploy dev (push) Successful in 1m17s
Reviewed-on: #123
2026-10-31 11:37:39 +03:00
Eleboba 4eb506bf65 Merge pull request 'feat: распознавание голоса пациента (STT через OpenRouter + mock)' (#122) from feat/116-stt into dev
CI / ruff · pytest (push) Successful in 2m25s
CD / ruff · pytest (push) Successful in 2m29s
CD / deploy dev (push) Successful in 3m8s
Reviewed-on: #122
2026-10-31 11:37:05 +03:00
ElebobaandClaude Opus 5.5 1cafaa87ec feat(backend): patient garden — deterministic growth from activities
CI / ruff · pytest (pull_request) Successful in 2m17s
GET /api/v1/me/{token}/garden computes the garden on the fly from taken doses,
check-ins/diary and finished questionnaires (no new table). Rules and texts in
config/garden.yaml: per-day caps, points only add up, answers never count.

Refs #118

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-31 11:22:44 +03:00
21 changed files with 1463 additions and 14 deletions
+7 -1
View File
@@ -11,9 +11,15 @@ CHAT_AGENT=auto
OPENROUTER_API_KEY= OPENROUTER_API_KEY=
OPENROUTER_MODEL=google/gemini-2.5-flash-lite OPENROUTER_MODEL=google/gemini-2.5-flash-lite
# --- Voice input (speech-to-text): auto | off | mock | openrouter. OpenRouter = synthetic data only (ADR-014). # --- Voice input (speech-to-text): auto | off | mock | openrouter | salute.
# OpenRouter = synthetic data only (ADR-014), the app refuses to start with it when DEMO_CLOCK=false.
# Pilot / real patients: salute (SaluteSpeech, RF contour, ADR-016). auto picks salute when credentials are set.
STT_PROVIDER=auto STT_PROVIDER=auto
STT_MODEL=google/gemini-2.5-flash STT_MODEL=google/gemini-2.5-flash
SALUTE_SPEECH_CREDENTIALS=
SALUTE_SPEECH_SCOPE=SALUTE_SPEECH_PERS
# Path to the Russian Trusted Root CA (Минцифры) — not in certifi, needed to verify Sber's TLS.
SALUTE_SPEECH_CA=
# --- Pseudonymisation key: 32+ random bytes, hex. Generate: python -c "import secrets;print(secrets.token_hex(32))" # --- Pseudonymisation key: 32+ random bytes, hex. Generate: python -c "import secrets;print(secrets.token_hex(32))"
PSEUDO_KEY= PSEUDO_KEY=
+15
View File
@@ -46,6 +46,7 @@ from app.db.session import get_clinical_session, get_identity_session
from app.integrations.emias_mock import CODE_MAX_LEN, EmiasMock from app.integrations.emias_mock import CODE_MAX_LEN, EmiasMock
from app.llm.stt import InvalidAudio, SttError, SttNoSpeech, get_stt, wav_seconds from app.llm.stt import InvalidAudio, SttError, SttNoSpeech, get_stt, wav_seconds
from app.services import anonymous_care from app.services import anonymous_care
from app.services.garden import Garden, garden_for
log = logging.getLogger(__name__) log = logging.getLogger(__name__)
@@ -264,6 +265,8 @@ async def transcribe_voice(token: str, request: Request) -> SttOut:
started = monotonic_time.monotonic() # latency only, not domain time (that is app.core.clock) started = monotonic_time.monotonic() # latency only, not domain time (that is app.core.clock)
try: try:
text = await stt.transcribe(audio, "wav") text = await stt.transcribe(audio, "wav")
except InvalidAudio as exc:
raise HTTPException(415, "expected 16-bit mono PCM WAV") from exc
except SttNoSpeech as exc: except SttNoSpeech as exc:
raise HTTPException(422, "no speech recognised") from exc raise HTTPException(422, "no speech recognised") from exc
except SttError as exc: except SttError as exc:
@@ -426,6 +429,18 @@ async def my_today(token: str) -> TodayOut:
) )
@router.get("/me/{token}/garden", response_model=Garden)
async def my_garden(token: str) -> Garden:
"""The patient's garden (#118): grows from medication marks, check-ins/diary and finished questionnaires.
Never shrinks and never depends on answers. Seen only by the patient — not on the dashboard, not in summaries.
"""
async with get_clinical_session() as s:
if await repo.get_profile(s, token) is None:
raise HTTPException(404, "patient not found")
return await garden_for(s, token)
class ResultFinding(BaseModel): class ResultFinding(BaseModel):
code: str code: str
title: str title: str
+20 -3
View File
@@ -42,12 +42,18 @@ class Settings(BaseSettings):
chat_agent_timeout_s: float = 25.0 chat_agent_timeout_s: float = 25.0
chat_agent_max_steps: int = 5 chat_agent_max_steps: int = 5
# Voice input (#116): speech-to-text for the patient's messages. `auto` = OpenRouter if a key is set, else mock. # Voice input (#116, #121): speech-to-text for the patient's messages. `auto` = SaluteSpeech if credentials are
# OpenRouter STT is synthetic data only (ADR-014): the audio leaves the RF and cannot be scrubbed. # set; otherwise, in the demo only, OpenRouter if a key is set, else mock. OpenRouter STT is synthetic data only
stt_provider: Literal["auto", "off", "mock", "openrouter"] = "auto" # (ADR-014): the audio leaves the RF and cannot be scrubbed — forbidden with DEMO_CLOCK=false (validator below).
stt_provider: Literal["auto", "off", "mock", "openrouter", "salute"] = "auto"
stt_model: str = "google/gemini-2.5-flash" # needs audio input stt_model: str = "google/gemini-2.5-flash" # needs audio input
stt_timeout_s: float = 30.0 stt_timeout_s: float = 30.0
stt_max_seconds: int = Field(default=60, ge=5, le=300) stt_max_seconds: int = Field(default=60, ge=5, le=300)
# SaluteSpeech (Sber, RF contour) — the pilot provider (#121, ADR-016). Credentials = the base64 authorization key.
salute_speech_credentials: SecretStr = SecretStr("")
salute_speech_scope: str = "SALUTE_SPEECH_PERS"
# Russian Trusted Root CA (Минцифры) is not in certifi: point to the .crt/.pem to verify Sber's TLS chain.
salute_speech_ca: Path | None = None
# Telegram is used only for the prototype on synthetic data; prod channel is MAX / Gosuslugi. # Telegram is used only for the prototype on synthetic data; prod channel is MAX / Gosuslugi.
tg_bot_token: str = "" tg_bot_token: str = ""
@@ -65,6 +71,17 @@ class Settings(BaseSettings):
max_ca_bundle: Path | None = None max_ca_bundle: Path | None = None
max_link_ttl_seconds: int = Field(default=600, ge=60, le=1800) max_link_ttl_seconds: int = Field(default=600, ge=60, le=1800)
@model_validator(mode="after")
def validate_stt(self) -> "Settings":
if self.stt_provider == "openrouter" and not self.demo_clock:
raise ValueError(
"STT_PROVIDER=openrouter is for synthetic data only (ADR-014) and is forbidden with DEMO_CLOCK=false: "
"use STT_PROVIDER=salute"
)
if self.salute_speech_ca is not None and not self.salute_speech_ca.is_file():
raise ValueError("SALUTE_SPEECH_CA must point to an existing CA certificate file")
return self
@model_validator(mode="after") @model_validator(mode="after")
def validate_max(self) -> "Settings": def validate_max(self) -> "Settings":
if not self.max_enabled: if not self.max_enabled:
+173 -8
View File
@@ -6,13 +6,18 @@ ordinary text message — safety, `scrub()` and the LLM gateway apply there. Aud
* `OpenRouterSTT` — an audio-capable model via OpenRouter `/chat/completions` (`input_audio`). TEMPORARY, synthetic * `OpenRouterSTT` — an audio-capable model via OpenRouter `/chat/completions` (`input_audio`). TEMPORARY, synthetic
data only (ADR-014): voice is personal data that `scrub()` cannot depersonalise, and OpenRouter is a foreign data only (ADR-014): voice is personal data that `scrub()` cannot depersonalise, and OpenRouter is a foreign
service (152-FZ). Real patients need an RF provider (SaluteSpeech, #121) behind the same `SpeechToText` contract. service (152-FZ). Real patients need an RF provider (SaluteSpeech, #121) behind the same `SpeechToText` contract.
* `SaluteSTT` — Sber SaluteSpeech, synchronous REST recognition of the whole file (RF contour, #121, ADR-016).
The pilot provider: the only one allowed for real patients.
* `MockSTT` — offline, returns a fixed synthetic phrase (ADR-004: the demo works without network). * `MockSTT` — offline, returns a fixed synthetic phrase (ADR-004: the demo works without network).
""" """
import asyncio import asyncio
import base64 import base64
import logging import logging
import ssl
import struct import struct
import time
import uuid
from functools import lru_cache from functools import lru_cache
from pathlib import Path from pathlib import Path
from typing import Protocol from typing import Protocol
@@ -56,23 +61,48 @@ class InvalidAudio(ValueError):
pass pass
def wav_seconds(audio: bytes) -> float: class WavInfo(BaseModel):
"""Duration of a PCM WAV file; raises `InvalidAudio` for anything else.""" audio_format: int # 1 = PCM
channels: int
sample_rate: int
byte_rate: int
bits: int
data_offset: int
data_size: int
def wav_info(audio: bytes) -> WavInfo:
"""Header of a WAV file; raises `InvalidAudio` for anything else."""
if len(audio) < 44 or audio[:4] != b"RIFF" or audio[8:12] != b"WAVE": if len(audio) < 44 or audio[:4] != b"RIFF" or audio[8:12] != b"WAVE":
raise InvalidAudio("not a WAV file") raise InvalidAudio("not a WAV file")
pos, byte_rate = 12, 0 pos, fmt = 12, None
while pos + 8 <= len(audio): while pos + 8 <= len(audio):
chunk_id, size = audio[pos : pos + 4], struct.unpack_from("<I", audio, pos + 4)[0] chunk_id, size = audio[pos : pos + 4], struct.unpack_from("<I", audio, pos + 4)[0]
if chunk_id == b"fmt " and size >= 16: if chunk_id == b"fmt " and size >= 16:
byte_rate = struct.unpack_from("<I", audio, pos + 16)[0] fmt = struct.unpack_from("<HHIIHH", audio, pos + 8)
elif chunk_id == b"data": elif chunk_id == b"data":
if not byte_rate: if not fmt or not fmt[3]:
break break
return min(size, len(audio) - pos - 8) / byte_rate audio_format, channels, rate, byte_rate, _, bits = fmt
return WavInfo(
audio_format=audio_format,
channels=channels,
sample_rate=rate,
byte_rate=byte_rate,
bits=bits,
data_offset=pos + 8,
data_size=min(size, len(audio) - pos - 8),
)
pos += 8 + size + (size & 1) pos += 8 + size + (size & 1)
raise InvalidAudio("malformed WAV") raise InvalidAudio("malformed WAV")
def wav_seconds(audio: bytes) -> float:
"""Duration of a PCM WAV file; raises `InvalidAudio` for anything else."""
info = wav_info(audio)
return info.data_size / info.byte_rate
class SpeechToText(Protocol): class SpeechToText(Protocol):
name: str name: str
@@ -150,11 +180,146 @@ class OpenRouterSTT:
raise SttError("retries exhausted") raise SttError("retries exhausted")
SALUTE_OAUTH_URL = "https://ngw.devices.sberbank.ru:9443/api/v2/oauth"
SALUTE_RECOGNIZE_URL = "https://smartspeech.sber.ru/rest/v1/speech:recognize"
class _SaluteToken:
"""OAuth access token, cached until a minute before expiry (Sber tokens live 30 min) and shared per process:
a new token per request would burn the OAuth rate limit."""
def __init__(self) -> None:
self.value = ""
self.expires = 0.0 # time.monotonic() deadline
self.lock = asyncio.Lock()
@lru_cache(maxsize=4)
def _salute_token(credentials: str, scope: str) -> _SaluteToken:
return _SaluteToken()
class SaluteSTT:
name = "salute"
def __init__(self, settings: Settings, client: httpx.AsyncClient | None = None) -> None:
self._credentials = settings.salute_speech_credentials.get_secret_value()
self._scope = settings.salute_speech_scope
self._timeout = settings.stt_timeout_s
self._ca = settings.salute_speech_ca
self._client = client
self._token = _salute_token(self._credentials, self._scope)
async def transcribe(self, audio: bytes, fmt: str = "wav") -> str:
info = wav_info(audio)
if info.audio_format != 1 or info.bits != 16 or info.channels != 1:
raise InvalidAudio("SaluteSpeech needs 16-bit mono PCM")
pcm = audio[info.data_offset : info.data_offset + info.data_size]
headers = {"Content-Type": f"audio/x-pcm;bit=16;rate={info.sample_rate}"}
data = await self._post(pcm, headers)
if data.get("status", 200) != 200 or not isinstance(data.get("result"), list):
raise SttError("bad response")
text = " ".join(str(part).strip() for part in data["result"] if str(part).strip())
if not text:
raise SttNoSpeech("no speech")
return text
async def _get_token(self, client: httpx.AsyncClient, *, force: bool = False) -> str:
tok = self._token
if not force and tok.value and time.monotonic() < tok.expires:
return tok.value
async with tok.lock:
if not force and tok.value and time.monotonic() < tok.expires:
return tok.value
headers = {
"Authorization": f"Basic {self._credentials}",
"RqUID": str(uuid.uuid4()),
"Content-Type": "application/x-www-form-urlencoded",
"Accept": "application/json",
}
try:
resp = await client.post(SALUTE_OAUTH_URL, headers=headers, data={"scope": self._scope})
except httpx.HTTPError as exc:
raise SttError(f"oauth {type(exc).__name__}") from exc
if resp.status_code != 200:
log.error("stt salute oauth http %s", resp.status_code)
raise SttError(f"oauth http {resp.status_code}")
try:
body = resp.json()
value, expires_at_ms = body["access_token"], float(body["expires_at"])
except (ValueError, KeyError, TypeError) as exc:
raise SttError("oauth bad response") from exc
# expires_at is wall-clock epoch ms from Sber; keep a monotonic deadline with a minute of margin.
ttl = max(0.0, expires_at_ms / 1000 - time.time() - 60)
tok.value, tok.expires = value, time.monotonic() + ttl
log.info("stt salute oauth token refreshed")
return value
def _verify(self) -> ssl.SSLContext:
context = ssl.create_default_context()
if self._ca is not None:
context.load_verify_locations(cafile=str(self._ca))
return context
async def _post(self, pcm: bytes, headers: dict[str, str]) -> dict:
if self._client is not None:
return await self._recognize(self._client, pcm, headers)
async with httpx.AsyncClient(timeout=self._timeout, verify=self._verify()) as client:
return await self._recognize(client, pcm, headers)
async def _recognize(self, client: httpx.AsyncClient, pcm: bytes, headers: dict[str, str]) -> dict:
refreshed = False
for attempt in range(_ATTEMPTS):
token = await self._get_token(client)
try:
resp = await client.post(
SALUTE_RECOGNIZE_URL,
content=pcm,
params={"language": "ru-RU"},
headers={**headers, "Authorization": f"Bearer {token}"},
timeout=self._timeout,
)
except httpx.HTTPError as exc:
if attempt + 1 == _ATTEMPTS:
raise SttError(type(exc).__name__) from exc
await asyncio.sleep(0.5 * 2**attempt)
continue
if resp.status_code == 401 and not refreshed:
# The cached token was revoked or expired early: fetch a new one once.
refreshed = True
await self._get_token(client, force=True)
continue
if resp.status_code in _RETRY_STATUSES and attempt + 1 < _ATTEMPTS:
log.warning("stt salute http %s, retry %d", resp.status_code, attempt + 1)
await asyncio.sleep(0.5 * 2**attempt)
continue
if resp.status_code != 200:
log.error("stt salute http %s: %s", resp.status_code, resp.text[:200])
raise SttError(f"http {resp.status_code}")
try:
return resp.json()
except ValueError as exc:
raise SttError("bad json") from exc
raise SttError("retries exhausted")
def get_stt(settings: Settings) -> SpeechToText | None: def get_stt(settings: Settings) -> SpeechToText | None:
"""The configured provider; `None` = voice input is off. `auto` = OpenRouter if a key is set, else mock.""" """The configured provider; `None` = voice input is off.
`auto` = SaluteSpeech if credentials are set. Otherwise only in the demo (synthetic data): OpenRouter if a key is
set, else mock. Outside the demo `auto` never picks a foreign service or a fake transcript — voice stays off.
"""
mode = settings.stt_provider mode = settings.stt_provider
has_salute = bool(settings.salute_speech_credentials.get_secret_value())
if mode == "auto": if mode == "auto":
mode = "openrouter" if settings.openrouter_api_key else "mock" if has_salute:
mode = "salute"
elif not settings.demo_clock:
return None
else:
mode = "openrouter" if settings.openrouter_api_key else "mock"
if mode == "salute":
return SaluteSTT(settings) if has_salute else None
if mode == "openrouter": if mode == "openrouter":
return OpenRouterSTT(settings) if settings.openrouter_api_key else None return OpenRouterSTT(settings) if settings.openrouter_api_key else None
if mode == "mock": if mode == "mock":
+173
View File
@@ -0,0 +1,173 @@
"""The patient's garden (#118, slide 13): light gamification over all modules.
Deterministic, no LLM (guardrail 3), rules in `config/garden.yaml` (ADR-008). Computed on the fly from existing
activity rows — no own table, no migration (ADR-003), and changing the rules replays the whole history.
«Without pressure»: points only add up (a skipped day takes nothing away), growth counts the fact of an activity
and never its content (scale scores, mood), and there is no comparison between patients. Only the patient sees it.
An honest «Пропущу» on a dose grows the garden like «Принял(а)», so the garden never rewards a false «taken» (#120).
After a recent acute/high alert the garden is `quiet`: growth stays, celebration does not.
"""
from collections import defaultdict
from collections.abc import Iterable
from datetime import date, datetime, timedelta
from functools import lru_cache
from pathlib import Path
from typing import Literal
import yaml
from pydantic import BaseModel
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from app.core import clock
from app.core.intake_questions import DIARY_KINDS
from app.db.clinical import Alert, DailyCheckin, Dose, ScaleResult
from app.db.intake import QuestionnaireRecord
Activity = Literal["meds", "diary", "questionnaire"]
class ActivityRule(BaseModel):
points: int
daily_cap: int
class TitleRule(BaseModel):
from_steps: int
title: str
class QuietRule(BaseModel):
alert_levels: list[str]
hours: int
class GardenConfig(BaseModel):
activities: dict[Activity, ActivityRule]
answered_dose_statuses: list[str]
quiet: QuietRule
step_points: int
stages_per_plant: int
max_plants: int
titles: list[TitleRule]
today: dict[Activity, str]
@lru_cache
def load_config(path: Path | None = None) -> GardenConfig:
from app.config import get_settings
path = path or get_settings().config_dir / "garden.yaml"
return GardenConfig.model_validate(yaml.safe_load(path.read_text(encoding="utf-8")))
class Plant(BaseModel):
stage: int # 0 = not yet sprouted … stages_per_plant = in bloom
class TodayItem(BaseModel):
activity: Activity
title: str
done: bool
detail: str | None = None # e.g. "1 из 2" for doses
class Garden(BaseModel):
points: int
steps: int
title: str
step_points: int
points_in_step: int # progress towards the next growth step: «8 из 10»
plants: list[Plant]
today: list[TodayItem]
quiet: bool = False # recent acute/high alert: no «Сад подрос», no grow-in animation
def compute(events: Iterable[tuple[Activity, date]], today: date, cfg: GardenConfig | None = None) -> Garden:
"""Points from (activity, day) events: per-day caps, events after `today` (demo clock) ignored."""
cfg = cfg or load_config()
per_day: dict[tuple[Activity, date], int] = defaultdict(int)
for kind, day in events:
if day <= today:
per_day[(kind, day)] += 1
points = sum(
min(n, cfg.activities[kind].daily_cap) * cfg.activities[kind].points for (kind, _), n in per_day.items()
)
steps = points // cfg.step_points
plants = [
Plant(stage=max(0, min(cfg.stages_per_plant, steps - i * cfg.stages_per_plant))) for i in range(cfg.max_plants)
]
title = next(t.title for t in sorted(cfg.titles, key=lambda t: -t.from_steps) if steps >= t.from_steps)
return Garden(
points=points,
steps=steps,
title=title,
step_points=cfg.step_points,
points_in_step=points % cfg.step_points,
plants=plants,
today=[],
)
def _day(ts: datetime, tz) -> date:
return ts.astimezone(tz).date()
async def garden_for(s: AsyncSession, token: str) -> Garden:
cfg = load_config()
now = clock.now()
tz, today = now.tzinfo, now.date()
events: list[tuple[Activity, date]] = []
doses = (await s.scalars(select(Dose).where(Dose.token == token))).all()
for d in doses:
if d.status in cfg.answered_dose_statuses:
events.append(("meds", _day(d.answered_at or d.planned_at, tz)))
checkins = (await s.scalars(select(DailyCheckin).where(DailyCheckin.token == token))).all()
for c in checkins:
if c.mood is not None:
events.append(("diary", c.day))
records = (await s.scalars(select(QuestionnaireRecord).where(QuestionnaireRecord.token == token))).all()
for r in records:
if r.status != "complete":
continue
if r.kind in DIARY_KINDS:
events.append(("diary", r.day))
else:
events.append(("questionnaire", _day(r.completed_at, tz) if r.completed_at else r.day))
scales = (await s.scalars(select(ScaleResult).where(ScaleResult.token == token))).all()
for sc in scales:
if sc.complete:
events.append(("questionnaire", _day(sc.ts, tz)))
garden = compute(events, today, cfg)
since = now - timedelta(hours=cfg.quiet.hours)
recent_alert = await s.scalar(
select(Alert.id)
.where(Alert.token == token, Alert.level.in_(cfg.quiet.alert_levels), Alert.ts >= since, Alert.ts <= now)
.limit(1)
)
garden.quiet = recent_alert is not None
done_today = {kind for kind, day in events if day == today}
items: list[TodayItem] = []
due = [d for d in doses if _day(d.planned_at, tz) == today]
if due:
answered = sum(d.status in cfg.answered_dose_statuses for d in due)
due_now = sum(d.planned_at <= now for d in due)
items.append(
TodayItem(
activity="meds",
title=cfg.today["meds"],
done=answered > 0 and answered >= due_now,
detail=f"{answered} из {len(due)}",
)
)
items.append(TodayItem(activity="diary", title=cfg.today["diary"], done="diary" in done_today))
# A questionnaire is not due every day: show it only once done, so an empty day is never a «missed» item.
if "questionnaire" in done_today:
items.append(TodayItem(activity="questionnaire", title=cfg.today["questionnaire"], done=True))
garden.today = items
return garden
+50
View File
@@ -0,0 +1,50 @@
# Сад (#118, слайд 13): лёгкая геймификация поверх модулей. Расчёт детерминированный, без LLM.
# Принципы «без давления» (вычитка — #120):
# 1. Сад не вянет: очки только прибавляются, пропуск ничего не отнимает.
# 2. Рост не зависит от ответов: считается факт активности, а не баллы шкал или настроение.
# 3. Нет соревнования: только свой сад, никаких сравнений.
# 4. Растит честная отметка, а не «правильный» ответ: «Пропущу» по дозе растит сад так же, как «Принял(а)»,
# иначе сад подталкивал бы отмечать приём, которого не было, и искажал бы данные о приёме для врача.
# 5. После кризиса — тишина: рост сохраняется, но без поздравлений и анимации.
# Очки за активность и дневной лимит по каждому типу (чтобы пять одинаковых действий подряд не давали пятикратный рост).
activities:
meds: # ответ по назначенной дозе: «Принял(а)» или «Пропущу» (см. answered_dose_statuses)
points: 2
daily_cap: 4
diary: # ежедневный чек-ин или запись дневника
points: 3
daily_cap: 1
questionnaire: # завершённый опрос (шкала, этап опроса)
points: 4
daily_cap: 1
# Какие ответы по дозе растят сад. missed (доза осталась без ответа) не растит, но и ничего не отнимает.
answered_dose_statuses: [taken, skipped]
# Тишина после кризиса: если за последние hours часов был алерт одного из этих уровней (в т.ч. по шкале
# или опросу, не только в чате), сад не поздравляет и не анимирует рост.
quiet:
alert_levels: [acute, high]
hours: 72
# Каждые step_points очков — один шаг роста. Растения растут по очереди: у каждого stages_per_plant стадий
# (росток → стебель → бутон → цветок); распустился один — проклёвывается следующий.
step_points: 10
stages_per_plant: 4
max_plants: 6
# Название сада по числу шагов роста (берётся последний порог ≤ шагов).
titles:
- {from_steps: 0, title: "Сад ждёт первых ростков"}
- {from_steps: 1, title: "Появились первые ростки"}
- {from_steps: 4, title: "Распустился первый цветок"}
- {from_steps: 10, title: "Сад растёт"}
- {from_steps: 18, title: "Сад цветёт"}
- {from_steps: 24, title: "Цветущий сад"}
# Подписи пунктов «Сегодня».
today:
meds: "Отметка о лекарствах"
diary: "Запись в дневнике"
questionnaire: "Назначенный опрос"
+19
View File
@@ -3,6 +3,25 @@
> Живой журнал: **что** решили и **почему**. Новые записи — сверху. Одна запись = одно решение. > Живой журнал: **что** решили и **почему**. Новые записи — сверху. Одна запись = одно решение.
> При конфликте с [`docs/info/SPEC.md`](info/SPEC.md) действует ADR. > При конфликте с [`docs/info/SPEC.md`](info/SPEC.md) действует ADR.
## 2026-10-31 — ADR-016: Голос в пилоте — SaluteSpeech (REST), зарубежный STT вне демо запрещён
**Контекст:** с реальными пациентами голос — ПДн, и обрабатывать его можно только в РФ-контуре (152-ФЗ, ст. 12 и ст. 18 ч. 5). OpenRouter допустим лишь на синтетике (ADR-014). Пилот «Династия» стартует 01.02.2027.
**Решение:**
1. `SaluteSTT` (`app/llm/stt.py`) за тем же протоколом `SpeechToText`. Используется синхронное REST-распознавание файла (`speech:recognize`): для сообщений до 60 с потоковый gRPC (как в vobot) не нужен, protobuf-стабы и grpc в зависимостях не появляются. Из WAV браузера снимается заголовок, отправляется PCM 16 бит mono.
2. OAuth-токен (30 мин) кешируется на процесс и обновляется за минуту до истечения или по 401 — чтобы не выжигать лимит OAuth Сбера.
3. Креды — `SALUTE_SPEECH_CREDENTIALS` (секрет, только `.env`), `SALUTE_SPEECH_SCOPE`. TLS проверяется всегда; корневой сертификат Минцифры не входит в certifi, путь к нему — `SALUTE_SPEECH_CA` (как `MAX_CA_BUNDLE`).
4. **Guard:** при `DEMO_CLOCK=false` `STT_PROVIDER=openrouter` — ошибка валидации настроек, приложение не стартует. `auto` выбирает SaluteSpeech, если есть креды; вне демо без них голос выключен, а не уходит в OpenRouter или mock (выдуманный текст реальному пациенту).
**Последствия:** для пилота нужны договор SaluteSpeech, ключ и сертификат Минцифры на сервере. До получения ключа живой проверки нет — только тесты с подменой транспорта.
## 2026-10-31 — ADR-015: Сад — растит честная отметка, после кризиса тишина, материалы не считаются
**Контекст:** геймификация при ПТСР и депрессии клинически чувствительна (#120). Сад (#118, #119) не должен давить, подменять клиническую динамику и искажать данные для врача. Решение принято командой и **ждёт вычитки психиатром** (отметка в #120).
**Решение:**
1. Растит **факт честного ответа**, а не «правильный» ответ: «Пропущу» по дозе растит сад так же, как «Принял(а)» (`answered_dose_statuses`). Иначе сад подталкивал бы отмечать приём, которого не было, и врач видел бы ложную приверженность. Доза без ответа (`missed`) не растит, но ничего и не отнимает.
2. Настроение и баллы шкал на рост не влияют. Очки только прибавляются, дневные лимиты есть по каждому типу активности. Сравнения с другими пациентами нет.
3. **Тишина после кризиса:** если за 72 ч был алерт уровня `acute`/`high` из любого источника (чат, шкала, опрос), сервер отдаёт `quiet` — рост сохраняется, но без «Сад подрос» и анимации.
4. Тексты не обещают улучшения: «Растёт от ваших отметок» вместо «Растёт вместе с вами»; о пропуске — «сад не вянет», без «не страшно».
5. **Материалы от врача не растят сад:** просмотр пассивен, награда за него поощряла бы пролистывание. Если такой раздел появится, его можно учесть только как отдельное решение после вычитки.
**Последствия:** правила и пороги — в `config/garden.yaml`, тексты — в `web/src/content/ru.ts`; менять их можно без кода по итогам вычитки.
## 2026-10-31 — ADR-014: Голосовой ввод — расшифровка отдельно от диалога; OpenRouter только на синтетике ## 2026-10-31 — ADR-014: Голосовой ввод — расшифровка отдельно от диалога; OpenRouter только на синтетике
**Контекст:** для гранта ВШЭ и пилота «Династия» нужен голосовой ввод (слайд 11: «Записать → Проверить → Сводка»). Голос — ПДн, а `scrub()` его не обезличивает. OpenRouter — зарубежный сервис (ADR-004, guardrail 9). **Контекст:** для гранта ВШЭ и пилота «Династия» нужен голосовой ввод (слайд 11: «Записать → Проверить → Сводка»). Голос — ПДн, а `scrub()` его не обезличивает. OpenRouter — зарубежный сервис (ADR-004, guardrail 9).
**Решение:** **Решение:**
+106 -1
View File
@@ -1,18 +1,25 @@
"""Speech-to-text providers (#116): OpenRouter request shape, retries, silence, WAV parsing, provider choice.""" """Speech-to-text providers (#116, #121): OpenRouter and SaluteSpeech request shape, retries, silence, WAV parsing,
provider choice and the «no foreign STT outside the demo» guard."""
import base64 import base64
import io import io
import json import json
import time
import wave import wave
import httpx import httpx
import pytest import pytest
from pydantic import ValidationError
from app.config import Settings from app.config import Settings
from app.llm import stt as stt_mod
from app.llm.stt import ( from app.llm.stt import (
SALUTE_OAUTH_URL,
SALUTE_RECOGNIZE_URL,
InvalidAudio, InvalidAudio,
MockSTT, MockSTT,
OpenRouterSTT, OpenRouterSTT,
SaluteSTT,
SttError, SttError,
SttNoSpeech, SttNoSpeech,
get_stt, get_stt,
@@ -105,3 +112,101 @@ def test_provider_choice() -> None:
assert isinstance(get_stt(_settings(stt_provider="auto", openrouter_api_key="")), MockSTT) assert isinstance(get_stt(_settings(stt_provider="auto", openrouter_api_key="")), MockSTT)
assert get_stt(_settings(stt_provider="openrouter", openrouter_api_key="")) is None assert get_stt(_settings(stt_provider="openrouter", openrouter_api_key="")) is None
assert get_stt(_settings(stt_provider="off")) is None assert get_stt(_settings(stt_provider="off")) is None
# SaluteSpeech wins `auto` whenever its credentials are set.
assert isinstance(get_stt(_settings(stt_provider="auto", salute_speech_credentials="c")), SaluteSTT)
assert isinstance(get_stt(_settings(stt_provider="salute", salute_speech_credentials="c")), SaluteSTT)
assert get_stt(_settings(stt_provider="salute")) is None
def test_outside_demo_no_foreign_stt() -> None:
"""#121: with real patients (DEMO_CLOCK=false) OpenRouter refuses to start, `auto` never picks it or mock."""
with pytest.raises(ValidationError, match="DEMO_CLOCK=false"):
_settings(stt_provider="openrouter", demo_clock=False)
assert get_stt(_settings(stt_provider="auto", demo_clock=False)) is None
salute = get_stt(_settings(stt_provider="auto", demo_clock=False, salute_speech_credentials="c"))
assert isinstance(salute, SaluteSTT)
def test_salute_ca_must_exist(tmp_path) -> None:
with pytest.raises(ValidationError, match="SALUTE_SPEECH_CA"):
_settings(salute_speech_ca=tmp_path / "missing.crt")
class _SaluteFake:
"""Fake Sber: OAuth + recognize. `replies` are recognize responses in order."""
def __init__(self, replies: list[httpx.Response]) -> None:
self.replies = iter(replies)
self.oauth_calls = 0
self.recognize: list[httpx.Request] = []
def __call__(self, request: httpx.Request) -> httpx.Response:
if str(request.url) == SALUTE_OAUTH_URL:
self.oauth_calls += 1
assert request.headers["Authorization"] == "Basic cred-1"
assert request.headers["RqUID"] and b"scope=SALUTE_SPEECH_PERS" in request.content
exp_ms = int((time.time() + 1800) * 1000)
return httpx.Response(200, json={"access_token": f"tok-{self.oauth_calls}", "expires_at": exp_ms})
assert str(request.url).startswith(SALUTE_RECOGNIZE_URL)
self.recognize.append(request)
return next(self.replies)
def _salute(replies: list[httpx.Response]) -> tuple[SaluteSTT, _SaluteFake]:
stt_mod._salute_token.cache_clear()
fake = _SaluteFake(replies)
settings = _settings(stt_provider="salute", salute_speech_credentials="cred-1")
return SaluteSTT(settings, client=httpx.AsyncClient(transport=httpx.MockTransport(fake))), fake
def _said(*parts: str) -> httpx.Response:
return httpx.Response(200, json={"status": 200, "result": list(parts), "emotions": []})
async def test_salute_sends_raw_pcm_and_joins_result() -> None:
stt, fake = _salute([_said("сегодня плохо спал", "", "хочу обсудить")])
wav = make_wav(1.0)
assert await stt.transcribe(wav) == "сегодня плохо спал хочу обсудить"
req = fake.recognize[0]
assert req.headers["Content-Type"] == "audio/x-pcm;bit=16;rate=16000"
assert req.headers["Authorization"] == "Bearer tok-1"
assert req.url.params["language"] == "ru-RU"
assert req.content == wav[44:] # the WAV header is stripped: PCM only
async def test_salute_token_cached_and_refreshed_on_401() -> None:
stt, fake = _salute([_said("раз"), _said("два"), httpx.Response(401), _said("три")])
for _ in range(2):
await stt.transcribe(make_wav(0.5))
assert fake.oauth_calls == 1 # one token for both requests
assert await stt.transcribe(make_wav(0.5)) == "три"
assert fake.oauth_calls == 2 and fake.recognize[-1].headers["Authorization"] == "Bearer tok-2"
async def test_salute_empty_result_is_no_speech() -> None:
stt, _ = _salute([_said()])
with pytest.raises(SttNoSpeech):
await stt.transcribe(make_wav(0.5))
async def test_salute_errors(monkeypatch) -> None:
monkeypatch.setattr("app.llm.stt.asyncio.sleep", _no_sleep)
stt, _ = _salute([httpx.Response(503), httpx.Response(503), httpx.Response(503)])
with pytest.raises(SttError):
await stt.transcribe(make_wav(0.5))
stt, _ = _salute([httpx.Response(400, json={"status": 400, "message": "bad audio"})])
with pytest.raises(SttError):
await stt.transcribe(make_wav(0.5))
async def test_salute_rejects_non_pcm_mono() -> None:
stt, fake = _salute([])
buf = io.BytesIO()
with wave.open(buf, "wb") as w:
w.setnchannels(2)
w.setsampwidth(2)
w.setframerate(16_000)
w.writeframes(b"\x00\x00" * 3200)
with pytest.raises(InvalidAudio):
await stt.transcribe(buf.getvalue())
assert fake.oauth_calls == 0
+132
View File
@@ -0,0 +1,132 @@
"""Garden (#118): deterministic growth from activities, daily caps, never shrinks, ignores answers."""
from collections.abc import AsyncIterator
from datetime import date, datetime, timedelta
import pytest
from httpx import ASGITransport, AsyncClient
from sqlalchemy import select
from app.config import Settings
from app.core import clock
from app.db import session
from app.db.clinical import Alert, DailyCheckin, Dose
from app.main import app
from app.services.garden import compute, load_config
from scripts.seed_demo import seed
MEM = "sqlite+aiosqlite:///:memory:"
NOW = datetime(2026, 10, 5, 14, 0)
D = date(2026, 10, 5)
def test_points_steps_and_daily_caps() -> None:
cfg = load_config()
one_diary = compute([("diary", D)], D)
assert one_diary.points == cfg.activities["diary"].points
# Five diary entries on one day count as one; on five days — five times.
assert compute([("diary", D)] * 5, D).points == one_diary.points
five_days = compute([("diary", D - timedelta(days=i)) for i in range(5)], D)
assert five_days.points == 5 * one_diary.points
assert five_days.steps == five_days.points // cfg.step_points
assert five_days.points_in_step == five_days.points % cfg.step_points
def test_never_shrinks_and_future_ignored() -> None:
events = [("meds", D), ("diary", D), ("questionnaire", D)]
now = compute(events, D)
# A week without any activity takes nothing away.
assert compute(events, D + timedelta(days=7)).points == now.points
# Rows dated after the (demo) today are not counted yet.
assert compute([*events, ("diary", D + timedelta(days=1))], D).points == now.points
def test_plants_grow_one_after_another() -> None:
cfg = load_config()
per_plant = cfg.stages_per_plant
g = compute([("questionnaire", D - timedelta(days=i)) for i in range(100)], D)
stages = [p.stage for p in g.plants]
assert stages == sorted(stages, reverse=True) and stages[0] == per_plant
assert sum(stages) == min(g.steps, per_plant * cfg.max_plants)
assert compute([], D).title == cfg.titles[0].title
@pytest.fixture
async def client(monkeypatch: pytest.MonkeyPatch) -> AsyncIterator[AsyncClient]:
monkeypatch.setattr(clock, "get_settings", lambda: Settings(_env_file=None, demo_clock=True))
clock.set_now(NOW)
await session.configure(identity_url=MEM, clinical_url=MEM)
async with AsyncClient(transport=ASGITransport(app=app), base_url="http://test") as c:
yield c
await session.configure()
async def test_andrey_has_a_living_garden_that_grows_and_ignores_mood(client: AsyncClient) -> None:
token = (await seed(12)).andrey_token
url = f"/api/v1/me/{token}/garden"
before = (await client.get(url)).json()
assert before["steps"] >= 4 and before["plants"][0]["stage"] > 0
# Mood is an answer: a bad day and a good day grow the garden the same way.
async with session.get_clinical_session() as s:
rows = (await s.scalars(select(DailyCheckin).where(DailyCheckin.token == token))).all()
for r in rows:
r.mood = 1
assert (await client.get(url)).json()["points"] == before["points"]
# Taking a dose today adds points.
async with session.get_clinical_session() as s:
dose = Dose(token=token, prescription_id=1, planned_at=clock.now(), status="taken", answered_at=clock.now())
s.add(dose)
after = (await client.get(url)).json()
assert after["points"] >= before["points"]
assert any(i["activity"] == "diary" for i in after["today"])
async def test_honest_skip_grows_like_taken_and_missed_does_not(client: AsyncClient) -> None:
"""#120: the garden rewards an honest answer, never a «taken» mark — otherwise it would push to false marks."""
token = (await seed(12)).andrey_token
url = f"/api/v1/me/{token}/garden"
base = (await client.get(url)).json()["points"]
async with session.get_clinical_session() as s:
for d in (await s.scalars(select(Dose).where(Dose.token == token))).all():
await s.delete(d)
none = (await client.get(url)).json()["points"]
async def with_dose(status: str) -> int:
async with session.get_clinical_session() as s:
for d in (await s.scalars(select(Dose).where(Dose.token == token))).all():
await s.delete(d)
s.add(Dose(token=token, prescription_id=1, planned_at=clock.now(), status=status, answered_at=clock.now()))
return (await client.get(url)).json()["points"]
assert await with_dose("taken") == await with_dose("skipped") > none
assert await with_dose("missed") == none
assert base >= none
async def test_quiet_after_recent_high_alert(client: AsyncClient) -> None:
"""#120: no celebration after a crisis signal from any source; an old or moderate alert does not silence it."""
token = (await seed(12)).andrey_token
url = f"/api/v1/me/{token}/garden"
async with session.get_clinical_session() as s:
for a in (await s.scalars(select(Alert).where(Alert.token == token))).all():
await s.delete(a)
assert (await client.get(url)).json()["quiet"] is False
def alert(level: str, hours_ago: int) -> Alert:
ts = clock.now() - timedelta(hours=hours_ago)
return Alert(token=token, ts=ts, level=level, code="test", reason="test", params={})
async with session.get_clinical_session() as s:
s.add_all([alert("moderate", 1), alert("acute", 24 * 10)])
assert (await client.get(url)).json()["quiet"] is False
async with session.get_clinical_session() as s:
s.add(alert("high", 2))
quiet = (await client.get(url)).json()
assert quiet["quiet"] is True and quiet["points"] > 0
async def test_unknown_patient_404(client: AsyncClient) -> None:
await seed(0)
assert (await client.get("/api/v1/me/nobody/garden")).status_code == 404
+1
View File
@@ -14,5 +14,6 @@ server {
proxy_http_version 1.1; proxy_http_version 1.1;
proxy_buffering off; proxy_buffering off;
proxy_read_timeout 120s; proxy_read_timeout 120s;
client_max_body_size 4m; # voice input: up to 60 s of 16 kHz WAV (~1.9 MB)
} }
} }
+19 -1
View File
@@ -4,6 +4,7 @@ import type {
CrisisResource, CrisisResource,
DemoPersona, DemoPersona,
DialogEvent, DialogEvent,
Garden,
Focus, Focus,
MedsReference, MedsReference,
Reply, Reply,
@@ -46,13 +47,30 @@ export const api = {
signInAnonymous: (focus?: Focus) => post<AuthResult>("/auth/anonymous", { focus }), signInAnonymous: (focus?: Focus) => post<AuthResult>("/auth/anonymous", { focus }),
// Presentation entry: a fresh private copy of the demo patient, straight to the chat. // Presentation entry: a fresh private copy of the demo patient, straight to the chat.
signInDemoFresh: () => post<AuthResult>("/auth/demo/fresh", {}), signInDemoFresh: () => post<AuthResult>("/auth/demo/fresh", {}),
patientCapabilities: () => request<{ primary_screening: boolean }>("/patient/capabilities"), patientCapabilities: () =>
request<{ primary_screening: boolean; voice_input?: boolean }>("/patient/capabilities"),
// Voice → text for the patient to check (#117). Raw WAV body; the server only transcribes.
transcribe: async (token: string, wav: Blob): Promise<string> => {
let res: Response;
try {
res = await fetch(`${BASE}/me/${encodeURIComponent(token)}/stt`, {
method: "POST",
headers: { "Content-Type": "audio/wav" },
body: wav,
});
} catch {
throw new ApiError(0, "network");
}
if (!res.ok) throw new ApiError(res.status, res.statusText);
return ((await res.json()) as { text: string }).text;
},
connectDoctor: (token: string) => post<{ has_doctor: boolean }>(`/me/${encodeURIComponent(token)}/doctor/connect`), connectDoctor: (token: string) => post<{ has_doctor: boolean }>(`/me/${encodeURIComponent(token)}/doctor/connect`),
demoPersonas: () => request<DemoPersona[]>("/patient/demo-personas"), demoPersonas: () => request<DemoPersona[]>("/patient/demo-personas"),
crisisResources: () => request<CrisisResource[]>("/crisis/resources"), crisisResources: () => request<CrisisResource[]>("/crisis/resources"),
catalog: () => request<Catalog>("/instruments/catalog"), catalog: () => request<Catalog>("/instruments/catalog"),
medsReference: () => request<MedsReference>("/meds/reference"), medsReference: () => request<MedsReference>("/meds/reference"),
today: (token: string) => request<TodayPlan>(`/me/${encodeURIComponent(token)}/today`), today: (token: string) => request<TodayPlan>(`/me/${encodeURIComponent(token)}/today`),
garden: (token: string) => request<Garden>(`/me/${encodeURIComponent(token)}/garden`),
results: (token: string) => request<Results>(`/me/${encodeURIComponent(token)}/results`), results: (token: string) => request<Results>(`/me/${encodeURIComponent(token)}/results`),
sendEvent: async (token: string, event: DialogEvent) => sendEvent: async (token: string, event: DialogEvent) =>
(await post<{ replies: Reply[] }>(`/dialog/${encodeURIComponent(token)}/events`, event)).replies, (await post<{ replies: Reply[] }>(`/dialog/${encodeURIComponent(token)}/events`, event)).replies,
+15
View File
@@ -180,3 +180,18 @@ export interface MedsReference {
string string
>; >;
} }
/** GET /me/{token}/garden (#118): grows from activities, never shrinks, ignores answers. */
export type GardenActivity = "meds" | "diary" | "questionnaire";
export interface Garden {
points: number;
steps: number;
title: string;
step_points: number;
points_in_step: number;
plants: { stage: number }[];
today: { activity: GardenActivity; title: string; done: boolean; detail: string | null }[];
/** Recent acute/high alert (#120): growth stays, no toast or animation. */
quiet: boolean;
}
+182
View File
@@ -0,0 +1,182 @@
import { useEffect, useRef, useState } from "react";
import { api } from "../api/client";
import type { Garden as GardenData, GardenActivity } from "../api/types";
import { ru } from "../content/ru";
import { useDialog } from "../dialog/DialogContext";
import { useSession } from "../session/SessionContext";
import { Icon, type IconName } from "./Icon";
import { useToast } from "./Toast";
/*
* «Ваш сад» (#119, slide 13): every useful activity grows the garden. Without pressure — it never wilts, does not
* depend on answers, nobody competes; there are no streaks, no «missed» marks and no red. The numbers come from the
* server (#118); this only draws them.
*/
const FLOWERS = ["#d9604c", "#e9a23b", "#c96f9a", "#e07a3f", "#b9577a", "#d4a02a"];
const SLOTS = [44, 92, 140, 188, 236, 284];
const GROUND = 128;
const ICONS: Record<GardenActivity, IconName> = { meds: "pill", diary: "book", questionnaire: "clipboard" };
const seenKey = (token: string) => `ptsr.garden.steps.${token}`;
function Plant({ x, stage, color, fresh }: { x: number; stage: number; color: string; fresh: boolean }) {
if (stage <= 0) return <ellipse cx={x} cy={GROUND + 1} rx={5} ry={2} fill="#c9b08a" opacity={0.7} />;
const h = [0, 18, 38, 54, 62][Math.min(stage, 4)];
const top = GROUND - h;
return (
<g
className={fresh ? "garden__plant garden__plant--fresh" : "garden__plant"}
style={{ transformOrigin: `${x}px ${GROUND}px` }}
>
<path
d={`M${x} ${GROUND} C ${x - 2} ${GROUND - h / 2}, ${x + 2} ${top + h / 3}, ${x} ${top}`}
stroke="#4f7d5a"
strokeWidth={2.4}
fill="none"
strokeLinecap="round"
/>
<path d={`M${x} ${GROUND - h * 0.35} q -11 -3 -13 -11 q 10 0 13 9`} fill="#6f9c6a" />
{stage >= 2 && <path d={`M${x} ${GROUND - h * 0.6} q 11 -3 13 -11 q -10 0 -13 9`} fill="#6f9c6a" />}
{stage === 1 && <path d={`M${x} ${top + 2} q 8 -4 9 -10 q -8 1 -9 8`} fill="#7fae74" />}
{stage === 3 && <ellipse cx={x} cy={top - 3} rx={4.5} ry={7} fill={color} />}
{stage >= 4 && (
<g>
{[0, 72, 144, 216, 288].map((a) => (
<ellipse key={a} cx={x} cy={top - 8} rx={4.6} ry={8} fill={color} transform={`rotate(${a} ${x} ${top})`} />
))}
<circle cx={x} cy={top} r={4.2} fill="#f6d36b" />
</g>
)}
</g>
);
}
export function Garden({ refreshKey }: { refreshKey?: unknown }) {
const { session } = useSession();
const dialog = useDialog();
const toast = useToast();
const [garden, setGarden] = useState<GardenData | null>(null);
const [freshFrom, setFreshFrom] = useState<number | null>(null);
const announced = useRef(false);
useEffect(() => {
if (!session) return;
let alive = true;
api
.garden(session.token)
.then((g) => {
if (!alive) return;
setGarden(g);
const raw = window.localStorage.getItem(seenKey(session.token));
const seen = raw === null ? null : Number(raw);
// No celebration right after a crisis: growth stays, the toast and the animation do not. The server knows
// about alerts from scales and questionnaires too (`quiet`); the chat check covers a crisis reply just shown.
const recentCrisis = dialog.entries.slice(-12).some((e) => e.from === "assistant" && e.reply.kind === "crisis");
if (seen !== null && g.steps > seen && !g.quiet && !recentCrisis) {
setFreshFrom(seen);
if (!announced.current) toast(ru.garden.grew);
announced.current = true;
}
window.localStorage.setItem(seenKey(session.token), String(g.steps));
})
.catch(() => undefined);
return () => {
alive = false;
};
}, [session, refreshKey]);
if (!garden) return null;
const t = ru.garden;
const perPlant = 4;
const bloom = garden.plants.filter((p) => p.stage >= perPlant).length;
const growing = garden.plants.filter((p) => p.stage > 0 && p.stage < perPlant).length;
const share = garden.points_in_step / garden.step_points;
return (
<section className="card garden" aria-labelledby="garden-title">
<div>
<p className="garden__eyebrow">{t.eyebrow}</p>
<h2 id="garden-title" className="h3">
{t.lead}
</h2>
</div>
<svg
className="garden__scene"
viewBox="0 0 328 150"
role="img"
aria-label={t.label(garden.title, bloom, growing)}
>
<defs>
<linearGradient id="garden-sky" x1="0" y1="0" x2="0" y2="1">
<stop offset="0" stopColor="#eef3ea" />
<stop offset="1" stopColor="#f7f3ea" />
</linearGradient>
</defs>
<rect width="328" height="150" rx="12" fill="url(#garden-sky)" />
<circle cx="282" cy="36" r="17" fill="#f2cc72" opacity={0.9} />
<path
d={`M0 ${GROUND + 4} Q 90 ${GROUND - 8} 170 ${GROUND + 2} T 328 ${GROUND - 2} V150 H0 Z`}
fill="#e5d3b0"
/>
<path
d={`M0 ${GROUND + 12} Q 110 ${GROUND + 2} 210 ${GROUND + 12} T 328 ${GROUND + 8} V150 H0 Z`}
fill="#dcc59c"
/>
{garden.plants.map((p, i) => {
// The plant that just grew (its stage covers steps after the last seen count) gets a gentle grow-in.
const fresh =
freshFrom !== null && garden.steps > freshFrom && Math.floor((garden.steps - 1) / perPlant) === i;
return (
<Plant
key={i}
x={SLOTS[i % SLOTS.length]}
stage={p.stage}
color={FLOWERS[i % FLOWERS.length]}
fresh={fresh}
/>
);
})}
</svg>
<p className="garden__title">{garden.title}</p>
<div className="garden__progress">
<div className="garden__progress-head">
<span>{t.next}</span>
<span className="muted small">{t.of(garden.points_in_step, garden.step_points)}</span>
</div>
<div
className="garden__bar"
role="progressbar"
aria-valuemin={0}
aria-valuemax={garden.step_points}
aria-valuenow={garden.points_in_step}
aria-label={t.next}
>
<span style={{ width: `${Math.round(share * 100)}%` }} />
</div>
</div>
{garden.today.length > 0 && (
<ul className="garden__today" aria-label={t.today}>
{garden.today.map((item) => (
<li key={item.activity} className="garden__item">
<span className="garden__icon" aria-hidden="true">
<Icon name={ICONS[item.activity]} size={16} />
</span>
<span className="garden__item-text">
{item.title}
{item.detail && <span className="muted small"> · {item.detail}</span>}
</span>
<span
className={item.done ? "garden__check garden__check--done" : "garden__check"}
aria-label={item.done ? "сделано" : "ещё нет"}
>
{item.done && <Icon name="check" size={14} />}
</span>
</li>
))}
</ul>
)}
<p className="garden__note">{t.noPressure}</p>
</section>
);
}
+6
View File
@@ -32,6 +32,12 @@ const paths = {
arrowLeft: <path d="M19 12H5m6-6l-6 6 6 6" />, arrowLeft: <path d="M19 12H5m6-6l-6 6 6 6" />,
close: <path d="M6 6l12 12M18 6L6 18" />, close: <path d="M6 6l12 12M18 6L6 18" />,
check: <path d="M5 12.5l4.5 4.5L19 7.5" />, check: <path d="M5 12.5l4.5 4.5L19 7.5" />,
mic: (
<>
<rect x="9" y="3" width="6" height="11" rx="3" />
<path d="M5.5 11a6.5 6.5 0 0 0 13 0M12 17.5V21" />
</>
),
x: <path d="M7 7l10 10M17 7L7 17" />, x: <path d="M7 7l10 10M17 7L7 17" />,
info: ( info: (
<> <>
+165
View File
@@ -0,0 +1,165 @@
import { useEffect, useRef, useState } from "react";
import { ApiError, api } from "../api/client";
import { ru } from "../content/ru";
import { useSession } from "../session/SessionContext";
import { MAX_SECONDS, Recorder, voiceSupported } from "../voice/recorder";
import { Icon } from "./Icon";
import { useToast } from "./Toast";
/*
* Voice input next to a free-text field (#117, slide 11: «Записать → Проверить»). The recognised text is put into
* the field for the patient to check and edit — never sent automatically: safety and scrub() run when they send it.
*/
let capability: Promise<boolean> | null = null;
function voiceEnabled(): Promise<boolean> {
capability ??= api
.patientCapabilities()
.then((c) => Boolean(c.voice_input) && voiceSupported())
.catch(() => false);
return capability;
}
const BARS = 14;
type State = "idle" | "recording" | "transcribing";
export function VoiceInput({
disabled,
onText,
compact,
}: {
disabled?: boolean;
onText: (text: string) => void;
/** Chat composer: a round icon button; the recording panel floats above the composer. */
compact?: boolean;
}) {
const { session } = useSession();
const toast = useToast();
const [enabled, setEnabled] = useState(false);
const [state, setState] = useState<State>("idle");
const [seconds, setSeconds] = useState(0);
const [levels, setLevels] = useState<number[]>(() => Array(BARS).fill(0));
const rec = useRef<Recorder | null>(null);
const timer = useRef<number>();
useEffect(() => {
let alive = true;
void voiceEnabled().then((on) => alive && setEnabled(on));
return () => {
alive = false;
window.clearInterval(timer.current);
rec.current?.cancel();
};
}, []);
if (!enabled || !session) return null;
const start = async () => {
const r = new Recorder();
try {
await r.start((level) => setLevels((prev) => [...prev.slice(1), level]));
} catch {
r.cancel();
toast(ru.voice.noMic);
return;
}
rec.current = r;
setSeconds(0);
setState("recording");
const began = Date.now();
timer.current = window.setInterval(() => {
const s = Math.floor((Date.now() - began) / 1000);
setSeconds(s);
if (s >= MAX_SECONDS) void stop();
}, 250);
};
const reset = () => {
window.clearInterval(timer.current);
rec.current = null;
setLevels(Array(BARS).fill(0));
};
const stop = async () => {
const wav = rec.current?.stop() ?? null;
reset();
if (!wav) {
setState("idle");
return;
}
setState("transcribing");
try {
const text = await api.transcribe(session.token, wav);
onText(text);
toast(ru.voice.check);
} catch (e) {
const status = e instanceof ApiError ? e.status : 0;
toast(status === 422 ? ru.voice.noSpeech : status === 413 ? ru.voice.tooLong : ru.voice.failed);
} finally {
setState("idle");
}
};
const cancel = () => {
rec.current?.cancel();
reset();
setState("idle");
};
if (state === "idle" && compact) {
return (
<button
type="button"
className="mic"
disabled={disabled}
onClick={() => void start()}
aria-label={ru.voice.start}
title={ru.voice.start}
>
<Icon name="mic" size={20} />
</button>
);
}
if (state === "idle") {
return (
<button type="button" className="voice-btn" disabled={disabled} onClick={() => void start()}>
<Icon name="mic" size={20} />
{ru.voice.start}
</button>
);
}
const mm = String(Math.floor(seconds / 60)).padStart(2, "0");
const ss = String(seconds % 60).padStart(2, "0");
return (
<div className={compact ? "voice voice--float" : "voice"} role="group" aria-label={ru.voice.recording}>
{state === "recording" ? (
<>
<p className="voice__hint">{ru.voice.recording}</p>
<div className="voice__wave" aria-hidden="true">
{levels.map((l, i) => (
<span key={i} style={{ height: `${Math.max(12, Math.round(l * 100))}%` }} />
))}
</div>
<div className="voice__row">
<span className="voice__time" aria-live="off">
{mm}:{ss}
</span>
<button type="button" className="btn btn--ghost voice__cancel" onClick={cancel}>
{ru.voice.cancel}
</button>
<button type="button" className="btn btn--primary voice__stop" onClick={() => void stop()}>
{ru.voice.stop}
</button>
</div>
<p className="voice__privacy">{ru.voice.privacy}</p>
</>
) : (
<p className="voice__hint" aria-live="polite">
{ru.voice.transcribing}
</p>
)}
</div>
);
}
+24
View File
@@ -86,6 +86,17 @@ export const ru = {
unavailable: "Первичный опрос сейчас отключён на сервере. Кабинет и подключение врача доступны без личных данных.", unavailable: "Первичный опрос сейчас отключён на сервере. Кабинет и подключение врача доступны без личных данных.",
submitting: "Создаём профиль…", submitting: "Создаём профиль…",
}, },
garden: {
eyebrow: "Ваш сад",
lead: "Растёт от ваших отметок",
next: "Новый росток",
of: (a: number, b: number) => `${a} из ${b}`,
today: "Сегодня",
noPressure: "Сад не вянет от пропусков. Честное «Пропущу» по лекарству тоже растит сад",
grew: "Сад подрос",
label: (title: string, bloom: number, growing: number) =>
`${title}. Цветов: ${bloom}, растёт: ${growing}.`,
},
today: { today: {
title: "Сегодня", title: "Сегодня",
takeMed: "Принять лекарство", takeMed: "Принять лекарство",
@@ -251,6 +262,19 @@ export const ru = {
drinksLead: "Посчитайте, сколько порций выходит в обычный день, когда вы выпиваете.", drinksLead: "Посчитайте, сколько порций выходит в обычный день, когда вы выпиваете.",
noticeTitle: "Опрос приостановлен", noticeTitle: "Опрос приостановлен",
}, },
voice: {
start: "Сказать голосом",
stop: "Остановить запись",
cancel: "Отменить",
recording: "Говорите — запись идёт",
transcribing: "Распознаём речь…",
check: "Проверьте распознанный текст — его можно исправить перед отправкой.",
privacy: "Запись не сохраняется: её видит только система распознавания, текст — специалисты вашей программы.",
noMic: "Нет доступа к микрофону. Разрешите его в настройках браузера или напишите текстом.",
noSpeech: "Не удалось разобрать речь. Попробуйте ещё раз или напишите текстом.",
failed: "Распознавание сейчас недоступно. Напишите, пожалуйста, текстом.",
tooLong: "Запись слишком длинная — до минуты за раз.",
},
messages: { messages: {
typing: "Помощник печатает", typing: "Помощник печатает",
title: "Сообщения", title: "Сообщения",
+2
View File
@@ -5,6 +5,7 @@ import type { Catalog, DialogButton } from "../api/types";
import { AppShell, Notice, TopBar } from "../components/AppShell"; import { AppShell, Notice, TopBar } from "../components/AppShell";
import { HotlineCard, telHref, useHelp } from "../components/HelpSheet"; import { HotlineCard, telHref, useHelp } from "../components/HelpSheet";
import { useToast } from "../components/Toast"; import { useToast } from "../components/Toast";
import { VoiceInput } from "../components/VoiceInput";
import { Icon, type IconName } from "../components/Icon"; import { Icon, type IconName } from "../components/Icon";
import { useCatalog } from "../content/reference"; import { useCatalog } from "../content/reference";
import { ru } from "../content/ru"; import { ru } from "../content/ru";
@@ -259,6 +260,7 @@ function FreeText({
/> />
<span className="textarea__count">{text.length}/500</span> <span className="textarea__count">{text.length}/500</span>
</div> </div>
<VoiceInput disabled={busy} onText={(t) => setText((prev) => (prev.trim() ? `${prev.trim()} ${t}` : t).slice(0, 500))} />
<div className="btn-row"> <div className="btn-row">
{skip ? ( {skip ? (
<button type="button" className="btn btn--ghost" disabled={busy} onClick={() => onSkip(skip)}> <button type="button" className="btn btn--ghost" disabled={busy} onClick={() => onSkip(skip)}>
+6
View File
@@ -3,6 +3,7 @@ import { useNavigate } from "react-router-dom";
import { AppShell, TopBar } from "../components/AppShell"; import { AppShell, TopBar } from "../components/AppShell";
import { Icon } from "../components/Icon"; import { Icon } from "../components/Icon";
import { VoiceInput } from "../components/VoiceInput";
import { ru } from "../content/ru"; import { ru } from "../content/ru";
import { useDialog } from "../dialog/DialogContext"; import { useDialog } from "../dialog/DialogContext";
@@ -112,6 +113,11 @@ export function Messages() {
onKeyDown={onKeyDown} onKeyDown={onKeyDown}
maxLength={2000} maxLength={2000}
/> />
<VoiceInput
compact
disabled={dialog.busy}
onText={(text) => setDraft((prev) => (prev.trim() ? `${prev.trim()} ${text}` : text).slice(0, 2000))}
/>
<button type="submit" className="send" disabled={dialog.busy || !draft.trim()} aria-label={ru.common.send}> <button type="submit" className="send" disabled={dialog.busy || !draft.trim()} aria-label={ru.common.send}>
<Icon name="send" size={20} /> <Icon name="send" size={20} />
</button> </button>
+2
View File
@@ -2,6 +2,7 @@ import { useNavigate } from "react-router-dom";
import type { TodayDose, TodayPrescription } from "../api/types"; import type { TodayDose, TodayPrescription } from "../api/types";
import { AppShell, Notice } from "../components/AppShell"; import { AppShell, Notice } from "../components/AppShell";
import { Garden } from "../components/Garden";
import { Icon, type IconName } from "../components/Icon"; import { Icon, type IconName } from "../components/Icon";
import { useCatalog } from "../content/reference"; import { useCatalog } from "../content/reference";
import { ru } from "../content/ru"; import { ru } from "../content/ru";
@@ -253,6 +254,7 @@ export function Today() {
</div> </div>
)} )}
<Garden refreshKey={dialog.pending} />
</section> </section>
<section className="dash__side"> <section className="dash__side">
+225
View File
@@ -1566,6 +1566,231 @@ button.row-card:hover:not(:disabled) {
color: var(--c-text-3); color: var(--c-text-3);
} }
/* ---------------------------------------------------------------- voice input (#117, slide 11) */
.voice-btn {
display: inline-flex;
align-items: center;
gap: var(--s-2);
justify-self: start;
min-height: 44px;
padding: 0 var(--s-4);
border: 1.5px solid var(--c-border-strong);
border-radius: 999px;
background: var(--c-surface);
color: var(--c-teal-strong);
font-weight: 600;
font-size: var(--fs-14);
}
.voice-btn:hover:not(:disabled) {
background: var(--c-teal-soft);
}
.mic {
flex: none;
width: 44px;
height: 44px;
display: grid;
place-items: center;
border: 1.5px solid var(--c-border);
border-radius: 50%;
background: var(--c-surface);
color: var(--c-teal-strong);
}
.mic:hover:not(:disabled) {
background: var(--c-teal-soft);
}
.voice {
display: grid;
gap: var(--s-3);
padding: var(--s-4);
border: 1px solid var(--c-border);
border-radius: var(--r-tile);
background: #fbf6ee; /* warm, as on the slide */
}
.voice--float {
position: absolute;
left: 0;
right: 0;
bottom: calc(100% + var(--s-2));
box-shadow: 0 8px 24px rgb(33 59 56 / 12%);
}
.voice__hint {
margin: 0;
font-weight: 600;
color: var(--c-text);
}
.voice__wave {
display: flex;
align-items: center;
gap: 5px;
height: 44px;
}
.voice__wave span {
flex: 1;
max-width: 6px;
border-radius: 3px;
background: #b5452f;
transition: height 90ms linear;
}
.voice__row {
display: grid;
grid-template-columns: auto 1fr 1.4fr;
align-items: center;
gap: var(--s-2);
}
.voice__time {
font-variant-numeric: tabular-nums;
font-weight: 600;
color: var(--c-text-2);
}
.voice__row .btn {
min-height: 44px;
padding: 0 var(--s-3);
font-size: var(--fs-14);
}
.voice__privacy {
margin: 0;
font-size: var(--fs-12);
color: var(--c-text-3);
}
/* ---------------------------------------------------------------- garden (#119, slide 13) */
.garden {
gap: var(--s-3);
}
.garden__eyebrow {
margin: 0 0 2px;
font-size: var(--fs-12);
font-weight: 700;
letter-spacing: 0.06em;
text-transform: uppercase;
color: var(--c-teal-strong);
}
.garden__scene {
display: block;
width: 100%;
height: auto;
max-height: 190px;
}
.garden__plant--fresh {
animation: garden-grow 1100ms var(--ease) both;
}
@keyframes garden-grow {
from {
transform: scaleY(0.2);
opacity: 0.3;
}
to {
transform: scaleY(1);
opacity: 1;
}
}
.garden__title {
margin: 0;
font-weight: 600;
color: var(--c-text);
}
.garden__progress {
display: grid;
gap: var(--s-2);
padding: var(--s-3);
border-radius: var(--r-btn);
background: var(--c-tile);
}
.garden__progress-head {
display: flex;
justify-content: space-between;
font-weight: 600;
font-size: var(--fs-14);
}
.garden__bar {
height: 8px;
border-radius: 999px;
background: var(--c-border);
overflow: hidden;
}
.garden__bar span {
display: block;
height: 100%;
border-radius: inherit;
background: var(--c-primary);
transition: width 600ms var(--ease);
}
.garden__today {
display: grid;
gap: var(--s-2);
margin: 0;
padding: 0;
list-style: none;
}
.garden__item {
display: flex;
align-items: center;
gap: var(--s-3);
font-size: var(--fs-14);
}
.garden__icon {
flex: none;
width: 32px;
height: 32px;
display: grid;
place-items: center;
border-radius: var(--r-btn);
background: var(--c-teal-soft);
color: var(--c-teal-strong);
}
.garden__item-text {
flex: 1;
}
.garden__check {
width: 24px;
height: 24px;
display: grid;
place-items: center;
border: 1.5px solid var(--c-border-strong);
border-radius: 50%;
color: #fff;
}
.garden__check--done {
border-color: var(--c-primary);
background: var(--c-primary);
}
.garden__note {
margin: 0;
text-align: center;
font-size: var(--fs-12);
color: var(--c-text-3);
}
/* ---------------------------------------------------------------- emergency */ /* ---------------------------------------------------------------- emergency */
.sosblock { .sosblock {
+121
View File
@@ -0,0 +1,121 @@
/*
* Voice recording for the patient's messages (#117, slide 11): microphone → 16 kHz mono 16-bit WAV in the browser,
* so the server needs no ffmpeg and Chrome, Android and iOS Safari produce the same format (ADR-014).
* The audio lives only in memory until it is sent for transcription; nothing is stored on the device.
*/
export const TARGET_RATE = 16_000;
export const MAX_SECONDS = 60;
type WindowWithWebkit = Window & { webkitAudioContext?: typeof AudioContext };
export function voiceSupported(): boolean {
const w = window as WindowWithWebkit;
return (
typeof navigator.mediaDevices?.getUserMedia === "function" && Boolean(window.AudioContext || w.webkitAudioContext)
);
}
export class Recorder {
private ctx: AudioContext | null = null;
private stream: MediaStream | null = null;
private node: ScriptProcessorNode | null = null;
private chunks: Float32Array[] = [];
private rate = 48_000;
/** `onLevel` gets the loudness 0..1 of each audio block (for the waveform). */
async start(onLevel: (level: number) => void): Promise<void> {
this.stream = await navigator.mediaDevices.getUserMedia({
audio: { channelCount: 1, echoCancellation: true, noiseSuppression: true, autoGainControl: true },
});
const Ctx = window.AudioContext || (window as WindowWithWebkit).webkitAudioContext!;
this.ctx = new Ctx();
await this.ctx.resume();
this.rate = this.ctx.sampleRate;
const source = this.ctx.createMediaStreamSource(this.stream);
// ScriptProcessorNode is deprecated but works everywhere (incl. older iOS Safari) without a worklet file.
this.node = this.ctx.createScriptProcessor(4096, 1, 1);
this.node.onaudioprocess = (e) => {
const data = new Float32Array(e.inputBuffer.getChannelData(0));
this.chunks.push(data);
let sum = 0;
for (let i = 0; i < data.length; i++) sum += data[i] * data[i];
onLevel(Math.min(1, Math.sqrt(sum / data.length) * 4));
};
source.connect(this.node);
this.node.connect(this.ctx.destination);
}
/** Stops the microphone and returns the WAV, or null if nothing was recorded. */
stop(): Blob | null {
const chunks = this.chunks;
const rate = this.rate;
this.release();
if (!chunks.length) return null;
return encodeWav(downsample(concat(chunks), rate, TARGET_RATE), TARGET_RATE);
}
cancel(): void {
this.release();
}
private release(): void {
this.node?.disconnect();
if (this.node) this.node.onaudioprocess = null;
this.stream?.getTracks().forEach((t) => t.stop());
void this.ctx?.close();
this.node = null;
this.stream = null;
this.ctx = null;
this.chunks = [];
}
}
function concat(chunks: Float32Array[]): Float32Array {
const out = new Float32Array(chunks.reduce((n, c) => n + c.length, 0));
let pos = 0;
for (const c of chunks) {
out.set(c, pos);
pos += c.length;
}
return out;
}
/** Averaging downsampler: good enough for speech, avoids aliasing better than dropping samples. */
function downsample(input: Float32Array, from: number, to: number): Float32Array {
if (from === to) return input;
const ratio = from / to;
const out = new Float32Array(Math.floor(input.length / ratio));
for (let i = 0; i < out.length; i++) {
const start = Math.floor(i * ratio);
const end = Math.min(input.length, Math.floor((i + 1) * ratio));
let sum = 0;
for (let j = start; j < end; j++) sum += input[j];
out[i] = sum / Math.max(1, end - start);
}
return out;
}
function encodeWav(samples: Float32Array, rate: number): Blob {
const buf = new ArrayBuffer(44 + samples.length * 2);
const v = new DataView(buf);
const str = (o: number, s: string) => [...s].forEach((ch, i) => v.setUint8(o + i, ch.charCodeAt(0)));
str(0, "RIFF");
v.setUint32(4, 36 + samples.length * 2, true);
str(8, "WAVE");
str(12, "fmt ");
v.setUint32(16, 16, true);
v.setUint16(20, 1, true); // PCM
v.setUint16(22, 1, true); // mono
v.setUint32(24, rate, true);
v.setUint32(28, rate * 2, true); // byte rate
v.setUint16(32, 2, true); // block align
v.setUint16(34, 16, true);
str(36, "data");
v.setUint32(40, samples.length * 2, true);
for (let i = 0; i < samples.length; i++) {
const s = Math.max(-1, Math.min(1, samples[i]));
v.setInt16(44 + i * 2, s < 0 ? s * 0x8000 : s * 0x7fff, true);
}
return new Blob([buf], { type: "audio/wav" });
}