Compare commits

..
Author SHA1 Message Date
ElebobaandClaude Opus 5.5 2fffb09c42 feat(web): garden on the «Сегодня» screen — stages, progress, today's checklist
CI / ruff · pytest (pull_request) Successful in 2m24s
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:55:06 +03:00
8 changed files with 50 additions and 145 deletions
+6 -23
View File
@@ -5,13 +5,11 @@ 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, timedelta
from datetime import date, datetime
from functools import lru_cache
from pathlib import Path
from typing import Literal
@@ -23,7 +21,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 Alert, DailyCheckin, Dose, ScaleResult
from app.db.clinical import DailyCheckin, Dose, ScaleResult
from app.db.intake import QuestionnaireRecord
Activity = Literal["meds", "diary", "questionnaire"]
@@ -39,15 +37,8 @@ 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
@@ -82,7 +73,6 @@ 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:
@@ -123,7 +113,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 in cfg.answered_dose_statuses:
if d.status == "taken":
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:
@@ -143,26 +133,19 @@ 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:
answered = sum(d.status in cfg.answered_dose_statuses for d in due)
taken = sum(d.status == "taken" 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)}",
done=taken > 0 and taken >= due_now,
detail=f"{taken} из {len(due)}",
)
)
items.append(TodayItem(activity="diary", title=cfg.today["diary"], done="diary" in done_today))
+2 -14
View File
@@ -3,13 +3,10 @@
# 1. Сад не вянет: очки только прибавляются, пропуск ничего не отнимает.
# 2. Рост не зависит от ответов: считается факт активности, а не баллы шкал или настроение.
# 3. Нет соревнования: только свой сад, никаких сравнений.
# 4. Растит честная отметка, а не «правильный» ответ: «Пропущу» по дозе растит сад так же, как «Принял(а)»,
# иначе сад подталкивал бы отмечать приём, которого не было, и искажал бы данные о приёме для врача.
# 5. После кризиса — тишина: рост сохраняется, но без поздравлений и анимации.
# Очки за активность и дневной лимит по каждому типу (чтобы пять одинаковых действий подряд не давали пятикратный рост).
activities:
meds: # ответ по назначенной дозе: «Принял(а)» или «Пропущу» (см. answered_dose_statuses)
meds: # отметка «Принял(а)» по назначенной дозе
points: 2
daily_cap: 4
diary: # ежедневный чек-ин или запись дневника
@@ -19,15 +16,6 @@ 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
@@ -45,6 +33,6 @@ titles:
# Подписи пунктов «Сегодня».
today:
meds: "Отметка о лекарствах"
meds: "Приём лекарств"
diary: "Запись в дневнике"
questionnaire: "Назначенный опрос"
-10
View File
@@ -3,16 +3,6 @@
> Живой журнал: **что** решили и **почему**. Новые записи — сверху. Одна запись = одно решение.
> При конфликте с [`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).
**Решение:**
+1 -45
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 Alert, DailyCheckin, Dose
from app.db.clinical import DailyCheckin, Dose
from app.main import app
from app.services.garden import compute, load_config
from scripts.seed_demo import seed
@@ -83,50 +83,6 @@ 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
@@ -192,6 +192,4 @@ export interface Garden {
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;
}
+2 -3
View File
@@ -70,10 +70,9 @@ export function Garden({ refreshKey }: { refreshKey?: unknown }) {
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.
// No celebration right after a crisis: growth stays, the toast and the animation do not.
const recentCrisis = dialog.entries.slice(-12).some((e) => e.from === "assistant" && e.reply.kind === "crisis");
if (seen !== null && g.steps > seen && !g.quiet && !recentCrisis) {
if (seen !== null && g.steps > seen && !recentCrisis) {
setFreshFrom(seen);
if (!announced.current) toast(ru.garden.grew);
announced.current = true;
+3 -3
View File
@@ -88,12 +88,12 @@ export const ru = {
},
garden: {
eyebrow: "Ваш сад",
lead: "Растёт от ваших отметок",
lead: "Растёт вместе с вами",
next: "Новый росток",
of: (a: number, b: number) => `${a} из ${b}`,
today: "Сегодня",
noPressure: "Сад не вянет от пропусков. Честное «Пропущу» по лекарству тоже растит сад",
grew: "Сад подрос",
noPressure: "Пропуск — не страшно, сад не вянет",
grew: "Сад подрос — появился новый росток",
label: (title: string, bloom: number, growing: number) =>
`${title}. Цветов: ${bloom}, растёт: ${growing}.`,
},
+36 -45
View File
@@ -1621,51 +1621,6 @@ button.row-card:hover:not(:disabled) {
}
.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 {
@@ -1709,6 +1664,42 @@ button.row-card:hover:not(:disabled) {
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;
.garden__progress {
display: grid;
gap: var(--s-2);