feat(backend): patient garden — deterministic growth from activities
CI / ruff · pytest (pull_request) Successful in 2m17s
CI / ruff · pytest (pull_request) Successful in 2m17s
GET /api/v1/me/{token}/garden computes the garden on the fly from taken doses,
check-ins/diary and finished questionnaires (no new table). Rules and texts in
config/garden.yaml: per-day caps, points only add up, answers never count.
Refs #118
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,156 @@
|
||||
"""The patient's garden (#118, slide 13): light gamification over all modules.
|
||||
|
||||
Deterministic, no LLM (guardrail 3), rules in `config/garden.yaml` (ADR-008). Computed on the fly from existing
|
||||
activity rows — no own table, no migration (ADR-003), and changing the rules replays the whole history.
|
||||
|
||||
«Without pressure»: points only add up (a skipped day takes nothing away), growth counts the fact of an activity
|
||||
and never its content (scale scores, mood), and there is no comparison between patients. Only the patient sees it.
|
||||
"""
|
||||
|
||||
from collections import defaultdict
|
||||
from collections.abc import Iterable
|
||||
from datetime import date, datetime
|
||||
from functools import lru_cache
|
||||
from pathlib import Path
|
||||
from typing import Literal
|
||||
|
||||
import yaml
|
||||
from pydantic import BaseModel
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.core import clock
|
||||
from app.core.intake_questions import DIARY_KINDS
|
||||
from app.db.clinical import DailyCheckin, Dose, ScaleResult
|
||||
from app.db.intake import QuestionnaireRecord
|
||||
|
||||
Activity = Literal["meds", "diary", "questionnaire"]
|
||||
|
||||
|
||||
class ActivityRule(BaseModel):
|
||||
points: int
|
||||
daily_cap: int
|
||||
|
||||
|
||||
class TitleRule(BaseModel):
|
||||
from_steps: int
|
||||
title: str
|
||||
|
||||
|
||||
class GardenConfig(BaseModel):
|
||||
activities: dict[Activity, ActivityRule]
|
||||
step_points: int
|
||||
stages_per_plant: int
|
||||
max_plants: int
|
||||
titles: list[TitleRule]
|
||||
today: dict[Activity, str]
|
||||
|
||||
|
||||
@lru_cache
|
||||
def load_config(path: Path | None = None) -> GardenConfig:
|
||||
from app.config import get_settings
|
||||
|
||||
path = path or get_settings().config_dir / "garden.yaml"
|
||||
return GardenConfig.model_validate(yaml.safe_load(path.read_text(encoding="utf-8")))
|
||||
|
||||
|
||||
class Plant(BaseModel):
|
||||
stage: int # 0 = not yet sprouted … stages_per_plant = in bloom
|
||||
|
||||
|
||||
class TodayItem(BaseModel):
|
||||
activity: Activity
|
||||
title: str
|
||||
done: bool
|
||||
detail: str | None = None # e.g. "1 из 2" for doses
|
||||
|
||||
|
||||
class Garden(BaseModel):
|
||||
points: int
|
||||
steps: int
|
||||
title: str
|
||||
step_points: int
|
||||
points_in_step: int # progress towards the next growth step: «8 из 10»
|
||||
plants: list[Plant]
|
||||
today: list[TodayItem]
|
||||
|
||||
|
||||
def compute(events: Iterable[tuple[Activity, date]], today: date, cfg: GardenConfig | None = None) -> Garden:
|
||||
"""Points from (activity, day) events: per-day caps, events after `today` (demo clock) ignored."""
|
||||
cfg = cfg or load_config()
|
||||
per_day: dict[tuple[Activity, date], int] = defaultdict(int)
|
||||
for kind, day in events:
|
||||
if day <= today:
|
||||
per_day[(kind, day)] += 1
|
||||
points = sum(
|
||||
min(n, cfg.activities[kind].daily_cap) * cfg.activities[kind].points for (kind, _), n in per_day.items()
|
||||
)
|
||||
steps = points // cfg.step_points
|
||||
plants = [
|
||||
Plant(stage=max(0, min(cfg.stages_per_plant, steps - i * cfg.stages_per_plant))) for i in range(cfg.max_plants)
|
||||
]
|
||||
title = next(t.title for t in sorted(cfg.titles, key=lambda t: -t.from_steps) if steps >= t.from_steps)
|
||||
return Garden(
|
||||
points=points,
|
||||
steps=steps,
|
||||
title=title,
|
||||
step_points=cfg.step_points,
|
||||
points_in_step=points % cfg.step_points,
|
||||
plants=plants,
|
||||
today=[],
|
||||
)
|
||||
|
||||
|
||||
def _day(ts: datetime, tz) -> date:
|
||||
return ts.astimezone(tz).date()
|
||||
|
||||
|
||||
async def garden_for(s: AsyncSession, token: str) -> Garden:
|
||||
cfg = load_config()
|
||||
now = clock.now()
|
||||
tz, today = now.tzinfo, now.date()
|
||||
events: list[tuple[Activity, date]] = []
|
||||
|
||||
doses = (await s.scalars(select(Dose).where(Dose.token == token))).all()
|
||||
for d in doses:
|
||||
if d.status == "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:
|
||||
if c.mood is not None:
|
||||
events.append(("diary", c.day))
|
||||
records = (await s.scalars(select(QuestionnaireRecord).where(QuestionnaireRecord.token == token))).all()
|
||||
for r in records:
|
||||
if r.status != "complete":
|
||||
continue
|
||||
if r.kind in DIARY_KINDS:
|
||||
events.append(("diary", r.day))
|
||||
else:
|
||||
events.append(("questionnaire", _day(r.completed_at, tz) if r.completed_at else r.day))
|
||||
scales = (await s.scalars(select(ScaleResult).where(ScaleResult.token == token))).all()
|
||||
for sc in scales:
|
||||
if sc.complete:
|
||||
events.append(("questionnaire", _day(sc.ts, tz)))
|
||||
|
||||
garden = compute(events, today, cfg)
|
||||
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)
|
||||
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)}",
|
||||
)
|
||||
)
|
||||
items.append(TodayItem(activity="diary", title=cfg.today["diary"], done="diary" in done_today))
|
||||
# A questionnaire is not due every day: show it only once done, so an empty day is never a «missed» item.
|
||||
if "questionnaire" in done_today:
|
||||
items.append(TodayItem(activity="questionnaire", title=cfg.today["questionnaire"], done=True))
|
||||
garden.today = items
|
||||
return garden
|
||||
Reference in New Issue
Block a user