Compare commits

..
Author SHA1 Message Date
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
10 changed files with 429 additions and 9 deletions
+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
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
from datetime import date, datetime, timedelta
from functools import lru_cache
from pathlib import Path
from typing import Literal
@@ -21,7 +23,7 @@ from sqlalchemy.ext.asyncio import AsyncSession
from app.core import clock
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
Activity = Literal["meds", "diary", "questionnaire"]
@@ -37,8 +39,15 @@ class TitleRule(BaseModel):
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
@@ -73,6 +82,7 @@ class Garden(BaseModel):
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:
@@ -113,7 +123,7 @@ async def garden_for(s: AsyncSession, token: str) -> Garden:
doses = (await s.scalars(select(Dose).where(Dose.token == token))).all()
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)))
checkins = (await s.scalars(select(DailyCheckin).where(DailyCheckin.token == token))).all()
for c in checkins:
@@ -133,19 +143,26 @@ async def garden_for(s: AsyncSession, token: str) -> Garden:
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:
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)
items.append(
TodayItem(
activity="meds",
title=cfg.today["meds"],
done=taken > 0 and taken >= due_now,
detail=f"{taken} из {len(due)}",
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))
+14 -2
View File
@@ -3,10 +3,13 @@
# 1. Сад не вянет: очки только прибавляются, пропуск ничего не отнимает.
# 2. Рост не зависит от ответов: считается факт активности, а не баллы шкал или настроение.
# 3. Нет соревнования: только свой сад, никаких сравнений.
# 4. Растит честная отметка, а не «правильный» ответ: «Пропущу» по дозе растит сад так же, как «Принял(а)»,
# иначе сад подталкивал бы отмечать приём, которого не было, и искажал бы данные о приёме для врача.
# 5. После кризиса — тишина: рост сохраняется, но без поздравлений и анимации.
# Очки за активность и дневной лимит по каждому типу (чтобы пять одинаковых действий подряд не давали пятикратный рост).
activities:
meds: # отметка «Принял(а)» по назначенной дозе
meds: # ответ по назначенной дозе: «Принял(а)» или «Пропущу» (см. answered_dose_statuses)
points: 2
daily_cap: 4
diary: # ежедневный чек-ин или запись дневника
@@ -16,6 +19,15 @@ activities:
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
@@ -33,6 +45,6 @@ titles:
# Подписи пунктов «Сегодня».
today:
meds: "Приём лекарств"
meds: "Отметка о лекарствах"
diary: "Запись в дневнике"
questionnaire: "Назначенный опрос"
+10
View File
@@ -3,6 +3,16 @@
> Живой журнал: **что** решили и **почему**. Новые записи — сверху. Одна запись = одно решение.
> При конфликте с [`docs/info/SPEC.md`](info/SPEC.md) действует ADR.
## 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 только на синтетике
**Контекст:** для гранта ВШЭ и пилота «Династия» нужен голосовой ввод (слайд 11: «Записать → Проверить → Сводка»). Голос — ПДн, а `scrub()` его не обезличивает. OpenRouter — зарубежный сервис (ADR-004, guardrail 9).
**Решение:**
+45 -1
View File
@@ -10,7 +10,7 @@ from sqlalchemy import select
from app.config import Settings
from app.core import clock
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.services.garden import compute, load_config
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"])
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
+2
View File
@@ -4,6 +4,7 @@ import type {
CrisisResource,
DemoPersona,
DialogEvent,
Garden,
Focus,
MedsReference,
Reply,
@@ -69,6 +70,7 @@ export const api = {
catalog: () => request<Catalog>("/instruments/catalog"),
medsReference: () => request<MedsReference>("/meds/reference"),
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`),
sendEvent: async (token: string, event: DialogEvent) =>
(await post<{ replies: Reply[] }>(`/dialog/${encodeURIComponent(token)}/events`, event)).replies,
+15
View File
@@ -180,3 +180,18 @@ export interface MedsReference {
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>
);
}
+11
View File
@@ -86,6 +86,17 @@ export const ru = {
unavailable: "Первичный опрос сейчас отключён на сервере. Кабинет и подключение врача доступны без личных данных.",
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: {
title: "Сегодня",
takeMed: "Принять лекарство",
+2
View File
@@ -2,6 +2,7 @@ import { useNavigate } from "react-router-dom";
import type { TodayDose, TodayPrescription } from "../api/types";
import { AppShell, Notice } from "../components/AppShell";
import { Garden } from "../components/Garden";
import { Icon, type IconName } from "../components/Icon";
import { useCatalog } from "../content/reference";
import { ru } from "../content/ru";
@@ -253,6 +254,7 @@ export function Today() {
</div>
)}
<Garden refreshKey={dialog.pending} />
</section>
<section className="dash__side">
+125
View File
@@ -1666,6 +1666,131 @@ button.row-card:hover:not(:disabled) {
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 */
.sosblock {