From e2e1283d8ed4e53f3f46779c8e61c480d4133fe3 Mon Sep 17 00:00:00 2001 From: Lacky1234union Date: Sat, 31 Oct 2026 15:47:34 +0300 Subject: [PATCH] =?UTF-8?q?feat:=20garden=20review=20fixes=20=E2=80=94=20h?= =?UTF-8?q?onest=20dose=20answer=20grows=20it,=20quiet=20after=20crisis,?= =?UTF-8?q?=20no-promise=20texts=20(ADR-015)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Refs #120 Co-Authored-By: Claude Opus 5.5 --- app/services/garden.py | 29 +++++++++++++++++----- config/garden.yaml | 16 ++++++++++-- docs/DECISIONS.md | 10 ++++++++ tests/test_services_garden.py | 46 ++++++++++++++++++++++++++++++++++- web/src/api/types.ts | 2 ++ web/src/components/Garden.tsx | 5 ++-- web/src/content/ru.ts | 6 ++--- 7 files changed, 100 insertions(+), 14 deletions(-) diff --git a/app/services/garden.py b/app/services/garden.py index ac71ae8..685d11a 100644 --- a/app/services/garden.py +++ b/app/services/garden.py @@ -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)) diff --git a/config/garden.yaml b/config/garden.yaml index 4aec888..d44fe21 100644 --- a/config/garden.yaml +++ b/config/garden.yaml @@ -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: "Назначенный опрос" diff --git a/docs/DECISIONS.md b/docs/DECISIONS.md index c821d9b..d822c36 100644 --- a/docs/DECISIONS.md +++ b/docs/DECISIONS.md @@ -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). **Решение:** diff --git a/tests/test_services_garden.py b/tests/test_services_garden.py index 6959040..12b90e5 100644 --- a/tests/test_services_garden.py +++ b/tests/test_services_garden.py @@ -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 diff --git a/web/src/api/types.ts b/web/src/api/types.ts index c2e52e6..39f5ac3 100644 --- a/web/src/api/types.ts +++ b/web/src/api/types.ts @@ -192,4 +192,6 @@ 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; } diff --git a/web/src/components/Garden.tsx b/web/src/components/Garden.tsx index 70785af..29f00db 100644 --- a/web/src/components/Garden.tsx +++ b/web/src/components/Garden.tsx @@ -70,9 +70,10 @@ 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. + // 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 && !recentCrisis) { + if (seen !== null && g.steps > seen && !g.quiet && !recentCrisis) { setFreshFrom(seen); if (!announced.current) toast(ru.garden.grew); announced.current = true; diff --git a/web/src/content/ru.ts b/web/src/content/ru.ts index 29c0bdf..e38d5c3 100644 --- a/web/src/content/ru.ts +++ b/web/src/content/ru.ts @@ -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}.`, }, -- 2.54.0