Compare commits

..
8 Commits
Author SHA1 Message Date
Eleboba 50b48a7290 Merge pull request 'feat: SaluteSpeech STT для пилота + запрет зарубежного STT вне демо' (#127) from feat/121-salute-stt into dev
CD / ruff · pytest (push) Successful in 2m25s
CI / ruff · pytest (push) Successful in 2m31s
CD / deploy dev (push) Successful in 1m29s
2026-10-31 17:14:08 +03:00
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
20 changed files with 848 additions and 28 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=
+2
View File
@@ -265,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:
+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:
+172 -7
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":
if has_salute:
mode = "salute"
elif not settings.demo_clock:
return None
else:
mode = "openrouter" if settings.openrouter_api_key else "mock" 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":
+23 -6
View File
@@ -5,11 +5,13 @@ activity rows — no own table, no migration (ADR-003), and changing the rules r
«Without pressure»: points only add up (a skipped day takes nothing away), growth counts the fact of an activity «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. 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 import defaultdict
from collections.abc import Iterable from collections.abc import Iterable
from datetime import date, datetime from datetime import date, datetime, timedelta
from functools import lru_cache from functools import lru_cache
from pathlib import Path from pathlib import Path
from typing import Literal from typing import Literal
@@ -21,7 +23,7 @@ from sqlalchemy.ext.asyncio import AsyncSession
from app.core import clock from app.core import clock
from app.core.intake_questions import DIARY_KINDS from app.core.intake_questions import DIARY_KINDS
from app.db.clinical import DailyCheckin, Dose, ScaleResult from app.db.clinical import Alert, DailyCheckin, Dose, ScaleResult
from app.db.intake import QuestionnaireRecord from app.db.intake import QuestionnaireRecord
Activity = Literal["meds", "diary", "questionnaire"] Activity = Literal["meds", "diary", "questionnaire"]
@@ -37,8 +39,15 @@ class TitleRule(BaseModel):
title: str title: str
class QuietRule(BaseModel):
alert_levels: list[str]
hours: int
class GardenConfig(BaseModel): class GardenConfig(BaseModel):
activities: dict[Activity, ActivityRule] activities: dict[Activity, ActivityRule]
answered_dose_statuses: list[str]
quiet: QuietRule
step_points: int step_points: int
stages_per_plant: int stages_per_plant: int
max_plants: int max_plants: int
@@ -73,6 +82,7 @@ class Garden(BaseModel):
points_in_step: int # progress towards the next growth step: «8 из 10» points_in_step: int # progress towards the next growth step: «8 из 10»
plants: list[Plant] plants: list[Plant]
today: list[TodayItem] 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: def compute(events: Iterable[tuple[Activity, date]], today: date, cfg: GardenConfig | None = None) -> Garden:
@@ -113,7 +123,7 @@ async def garden_for(s: AsyncSession, token: str) -> Garden:
doses = (await s.scalars(select(Dose).where(Dose.token == token))).all() doses = (await s.scalars(select(Dose).where(Dose.token == token))).all()
for d in doses: for d in doses:
if d.status == "taken": if d.status in cfg.answered_dose_statuses:
events.append(("meds", _day(d.answered_at or d.planned_at, tz))) events.append(("meds", _day(d.answered_at or d.planned_at, tz)))
checkins = (await s.scalars(select(DailyCheckin).where(DailyCheckin.token == token))).all() checkins = (await s.scalars(select(DailyCheckin).where(DailyCheckin.token == token))).all()
for c in checkins: for c in checkins:
@@ -133,19 +143,26 @@ async def garden_for(s: AsyncSession, token: str) -> Garden:
events.append(("questionnaire", _day(sc.ts, tz))) events.append(("questionnaire", _day(sc.ts, tz)))
garden = compute(events, today, cfg) 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} done_today = {kind for kind, day in events if day == today}
items: list[TodayItem] = [] items: list[TodayItem] = []
due = [d for d in doses if _day(d.planned_at, tz) == today] due = [d for d in doses if _day(d.planned_at, tz) == today]
if due: if due:
taken = sum(d.status == "taken" for d in 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) due_now = sum(d.planned_at <= now for d in due)
items.append( items.append(
TodayItem( TodayItem(
activity="meds", activity="meds",
title=cfg.today["meds"], title=cfg.today["meds"],
done=taken > 0 and taken >= due_now, done=answered > 0 and answered >= due_now,
detail=f"{taken} из {len(due)}", detail=f"{answered} из {len(due)}",
) )
) )
items.append(TodayItem(activity="diary", title=cfg.today["diary"], done="diary" in done_today)) items.append(TodayItem(activity="diary", title=cfg.today["diary"], done="diary" in done_today))
+14 -2
View File
@@ -3,10 +3,13 @@
# 1. Сад не вянет: очки только прибавляются, пропуск ничего не отнимает. # 1. Сад не вянет: очки только прибавляются, пропуск ничего не отнимает.
# 2. Рост не зависит от ответов: считается факт активности, а не баллы шкал или настроение. # 2. Рост не зависит от ответов: считается факт активности, а не баллы шкал или настроение.
# 3. Нет соревнования: только свой сад, никаких сравнений. # 3. Нет соревнования: только свой сад, никаких сравнений.
# 4. Растит честная отметка, а не «правильный» ответ: «Пропущу» по дозе растит сад так же, как «Принял(а)»,
# иначе сад подталкивал бы отмечать приём, которого не было, и искажал бы данные о приёме для врача.
# 5. После кризиса — тишина: рост сохраняется, но без поздравлений и анимации.
# Очки за активность и дневной лимит по каждому типу (чтобы пять одинаковых действий подряд не давали пятикратный рост). # Очки за активность и дневной лимит по каждому типу (чтобы пять одинаковых действий подряд не давали пятикратный рост).
activities: activities:
meds: # отметка «Принял(а)» по назначенной дозе meds: # ответ по назначенной дозе: «Принял(а)» или «Пропущу» (см. answered_dose_statuses)
points: 2 points: 2
daily_cap: 4 daily_cap: 4
diary: # ежедневный чек-ин или запись дневника diary: # ежедневный чек-ин или запись дневника
@@ -16,6 +19,15 @@ activities:
points: 4 points: 4
daily_cap: 1 daily_cap: 1
# Какие ответы по дозе растят сад. missed (доза осталась без ответа) не растит, но и ничего не отнимает.
answered_dose_statuses: [taken, skipped]
# Тишина после кризиса: если за последние hours часов был алерт одного из этих уровней (в т.ч. по шкале
# или опросу, не только в чате), сад не поздравляет и не анимирует рост.
quiet:
alert_levels: [acute, high]
hours: 72
# Каждые step_points очков — один шаг роста. Растения растут по очереди: у каждого stages_per_plant стадий # Каждые step_points очков — один шаг роста. Растения растут по очереди: у каждого stages_per_plant стадий
# (росток → стебель → бутон → цветок); распустился один — проклёвывается следующий. # (росток → стебель → бутон → цветок); распустился один — проклёвывается следующий.
step_points: 10 step_points: 10
@@ -33,6 +45,6 @@ titles:
# Подписи пунктов «Сегодня». # Подписи пунктов «Сегодня».
today: today:
meds: "Приём лекарств" meds: "Отметка о лекарствах"
diary: "Запись в дневнике" diary: "Запись в дневнике"
questionnaire: "Назначенный опрос" 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
+45 -1
View File
@@ -10,7 +10,7 @@ from sqlalchemy import select
from app.config import Settings from app.config import Settings
from app.core import clock from app.core import clock
from app.db import session from app.db import session
from app.db.clinical import DailyCheckin, Dose from app.db.clinical import Alert, DailyCheckin, Dose
from app.main import app from app.main import app
from app.services.garden import compute, load_config from app.services.garden import compute, load_config
from scripts.seed_demo import seed from scripts.seed_demo import seed
@@ -83,6 +83,50 @@ async def test_andrey_has_a_living_garden_that_grows_and_ignores_mood(client: As
assert any(i["activity"] == "diary" for i in after["today"]) 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: async def test_unknown_patient_404(client: AsyncClient) -> None:
await seed(0) await seed(0)
assert (await client.get("/api/v1/me/nobody/garden")).status_code == 404 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)
} }
} }
+17 -1
View File
@@ -47,7 +47,23 @@ 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"),
+2
View File
@@ -192,4 +192,6 @@ export interface Garden {
points_in_step: number; points_in_step: number;
plants: { stage: number }[]; plants: { stage: number }[];
today: { activity: GardenActivity; title: string; done: boolean; detail: string | null }[]; today: { activity: GardenActivity; title: string; done: boolean; detail: string | null }[];
/** Recent acute/high alert (#120): growth stays, no toast or animation. */
quiet: boolean;
} }
+3 -2
View File
@@ -70,9 +70,10 @@ export function Garden({ refreshKey }: { refreshKey?: unknown }) {
setGarden(g); setGarden(g);
const raw = window.localStorage.getItem(seenKey(session.token)); const raw = window.localStorage.getItem(seenKey(session.token));
const seen = raw === null ? null : Number(raw); const seen = raw === null ? null : Number(raw);
// No celebration right after a crisis: growth stays, the toast and the animation do not. // 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"); const recentCrisis = dialog.entries.slice(-12).some((e) => e.from === "assistant" && e.reply.kind === "crisis");
if (seen !== null && g.steps > seen && !recentCrisis) { if (seen !== null && g.steps > seen && !g.quiet && !recentCrisis) {
setFreshFrom(seen); setFreshFrom(seen);
if (!announced.current) toast(ru.garden.grew); if (!announced.current) toast(ru.garden.grew);
announced.current = true; announced.current = true;
+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>
);
}
+16 -3
View File
@@ -88,12 +88,12 @@ export const ru = {
}, },
garden: { garden: {
eyebrow: "Ваш сад", eyebrow: "Ваш сад",
lead: "Растёт вместе с вами", lead: "Растёт от ваших отметок",
next: "Новый росток", next: "Новый росток",
of: (a: number, b: number) => `${a} из ${b}`, of: (a: number, b: number) => `${a} из ${b}`,
today: "Сегодня", today: "Сегодня",
noPressure: "Пропуск — не страшно, сад не вянет", noPressure: "Сад не вянет от пропусков. Честное «Пропущу» по лекарству тоже растит сад",
grew: "Сад подрос — появился новый росток", grew: "Сад подрос",
label: (title: string, bloom: number, growing: number) => label: (title: string, bloom: number, growing: number) =>
`${title}. Цветов: ${bloom}, растёт: ${growing}.`, `${title}. Цветов: ${bloom}, растёт: ${growing}.`,
}, },
@@ -262,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>
+100
View File
@@ -1566,6 +1566,106 @@ 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 (#119, slide 13) */
.garden { .garden {
+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" });
}