Spaces:
Sleeping
Sleeping
Commit ·
990895d
0
Parent(s):
Deploy Habit Journal backend S0-S10 to Hugging Face Space.
Browse filesEOF
Co-authored-by: Cursor <cursoragent@cursor.com>
- .gitignore +6 -0
- Dockerfile +8 -0
- README.md +37 -0
- app/__init__.py +4 -0
- app/backup_replies.py +283 -0
- app/coach_service.py +478 -0
- app/config.py +58 -0
- app/deps.py +46 -0
- app/evidence.py +145 -0
- app/fsutil.py +110 -0
- app/main.py +177 -0
- app/models.py +382 -0
- app/paste_bundle.py +75 -0
- app/paths.py +44 -0
- app/routers/__init__.py +1 -0
- app/routers/auth.py +160 -0
- app/routers/coach.py +52 -0
- app/routers/daily.py +63 -0
- app/routers/debug.py +132 -0
- app/routers/entries.py +99 -0
- app/routers/export.py +75 -0
- app/routers/health.py +45 -0
- app/routers/settings.py +35 -0
- app/routers/stats.py +103 -0
- app/security_passwords.py +52 -0
- app/stats_math.py +301 -0
- app/store_config.py +84 -0
- app/store_daily.py +120 -0
- app/store_entries.py +146 -0
- app/store_traces.py +96 -0
- docs/BACKEND.md +268 -0
- requirements.txt +7 -0
- tests/__init__.py +0 -0
- tests/test_backup.py +38 -0
- tests/test_daily_points.py +82 -0
- tests/test_stats_math.py +97 -0
.gitignore
ADDED
|
@@ -0,0 +1,6 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
__pycache__/
|
| 2 |
+
*.py[cod]
|
| 3 |
+
.pytest_cache/
|
| 4 |
+
.venv/
|
| 5 |
+
.env
|
| 6 |
+
data/
|
Dockerfile
ADDED
|
@@ -0,0 +1,8 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
FROM python:3.11-slim
|
| 2 |
+
WORKDIR /app
|
| 3 |
+
RUN mkdir -p /data/loop_logger
|
| 4 |
+
COPY requirements.txt .
|
| 5 |
+
RUN pip install --no-cache-dir -r requirements.txt
|
| 6 |
+
COPY . .
|
| 7 |
+
ENV PYTHONUNBUFFERED=1
|
| 8 |
+
CMD uvicorn app.main:app --host 0.0.0.0 --port ${PORT:-7860}
|
README.md
ADDED
|
@@ -0,0 +1,37 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
---
|
| 2 |
+
title: Habit Journal
|
| 3 |
+
emoji: 📓
|
| 4 |
+
colorFrom: gray
|
| 5 |
+
colorTo: blue
|
| 6 |
+
sdk: docker
|
| 7 |
+
app_port: 7860
|
| 8 |
+
pinned: false
|
| 9 |
+
---
|
| 10 |
+
|
| 11 |
+
# Habit Journal API
|
| 12 |
+
|
| 13 |
+
Personal habit journal API designed for a Hugging Face Docker Space. The
|
| 14 |
+
backend uses signed cookie sessions and durable JSONL files under `DATA_ROOT`.
|
| 15 |
+
|
| 16 |
+
## Run locally
|
| 17 |
+
|
| 18 |
+
Set `APP_PASSWORD` and `APP_SECRET_KEY`, install `requirements.txt`, then run:
|
| 19 |
+
|
| 20 |
+
```text
|
| 21 |
+
uvicorn app.main:app --host 0.0.0.0 --port 7860
|
| 22 |
+
```
|
| 23 |
+
|
| 24 |
+
`ENV=dev` enables the OpenAPI endpoints for local development.
|
| 25 |
+
|
| 26 |
+
## Environment
|
| 27 |
+
|
| 28 |
+
- `APP_PASSWORD`: initial password used only when `config.json` is absent
|
| 29 |
+
- `APP_SECRET_KEY`: required session-signing key
|
| 30 |
+
- `DATA_ROOT`: storage directory (default `/data/loop_logger`)
|
| 31 |
+
- `ENV`: runtime environment (default `prod`)
|
| 32 |
+
- `APP_NAME`: displayed application name (default `Habit Journal`)
|
| 33 |
+
- `SESSION_MAX_AGE_SEC`: signed-session lifetime (default `604800`)
|
| 34 |
+
- `LOGIN_RATE_LIMIT`: login attempts per window (default `10`)
|
| 35 |
+
- `LOGIN_RATE_WINDOW_SEC`: login window in seconds (default `900`)
|
| 36 |
+
|
| 37 |
+
Additional coach-related variables are documented in `docs/BACKEND.md`.
|
app/__init__.py
ADDED
|
@@ -0,0 +1,4 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Habit Journal backend package.
|
| 2 |
+
|
| 3 |
+
The package exposes a small FastAPI application backed by durable local files.
|
| 4 |
+
"""
|
app/backup_replies.py
ADDED
|
@@ -0,0 +1,283 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Hardcoded coach backup rules ordered by priority.
|
| 2 |
+
|
| 3 |
+
Selects the highest-priority matching rule; fills SERVER_PICK when allowed.
|
| 4 |
+
"""
|
| 5 |
+
|
| 6 |
+
from __future__ import annotations
|
| 7 |
+
|
| 8 |
+
from typing import Any
|
| 9 |
+
|
| 10 |
+
RULES: list[dict[str, Any]] = [
|
| 11 |
+
{
|
| 12 |
+
"id": "crisis",
|
| 13 |
+
"priority": 100,
|
| 14 |
+
"keywords": ["suicide", "kill myself", "self-harm", "self harm", "end it"],
|
| 15 |
+
"tags": ["crisis"],
|
| 16 |
+
"template": (
|
| 17 |
+
"LOOP: crisis\n"
|
| 18 |
+
"FEELING: overwhelm\n"
|
| 19 |
+
"INTENSITY_GUESS: n/a\n"
|
| 20 |
+
"REMEDY: Contact local emergency / crisis resources now\n"
|
| 21 |
+
"NEXT_BRICK: S\n"
|
| 22 |
+
"DO_NOT: Isolate or escalate alone\n"
|
| 23 |
+
"LINE: Get help before anything else\n"
|
| 24 |
+
"NOTE: This is not therapy; seek real support\n"
|
| 25 |
+
"DATA_THIN: {data_thin}"
|
| 26 |
+
),
|
| 27 |
+
},
|
| 28 |
+
{
|
| 29 |
+
"id": "court",
|
| 30 |
+
"priority": 90,
|
| 31 |
+
"keywords": ["court", "argue with myself", "verdict"],
|
| 32 |
+
"tags": ["court"],
|
| 33 |
+
"template": (
|
| 34 |
+
"LOOP: court\n"
|
| 35 |
+
"FEELING: judgment\n"
|
| 36 |
+
"INTENSITY_GUESS: n/a\n"
|
| 37 |
+
"REMEDY: {{server_pick}}\n"
|
| 38 |
+
"NEXT_BRICK: E\n"
|
| 39 |
+
"DO_NOT: Reopen the case tonight\n"
|
| 40 |
+
"LINE: Close court; one brick only\n"
|
| 41 |
+
"NOTE: Silence after one boundary line\n"
|
| 42 |
+
"DATA_THIN: {data_thin}"
|
| 43 |
+
),
|
| 44 |
+
},
|
| 45 |
+
{
|
| 46 |
+
"id": "rerun",
|
| 47 |
+
"priority": 80,
|
| 48 |
+
"keywords": ["rerun", "replay", "looping the scene"],
|
| 49 |
+
"tags": ["rerun"],
|
| 50 |
+
"template": (
|
| 51 |
+
"LOOP: rerun\n"
|
| 52 |
+
"FEELING: stuck\n"
|
| 53 |
+
"INTENSITY_GUESS: n/a\n"
|
| 54 |
+
"REMEDY: {{server_pick}}\n"
|
| 55 |
+
"NEXT_BRICK: S\n"
|
| 56 |
+
"DO_NOT: Replay the scene again\n"
|
| 57 |
+
"LINE: Interrupt and move body once\n"
|
| 58 |
+
"NOTE: Water or short walk\n"
|
| 59 |
+
"DATA_THIN: {data_thin}"
|
| 60 |
+
),
|
| 61 |
+
},
|
| 62 |
+
{
|
| 63 |
+
"id": "rage_movie",
|
| 64 |
+
"priority": 70,
|
| 65 |
+
"keywords": ["rage", "movie", "fantasy fight"],
|
| 66 |
+
"tags": ["rage", "movie"],
|
| 67 |
+
"template": (
|
| 68 |
+
"LOOP: rage_movie\n"
|
| 69 |
+
"FEELING: heat\n"
|
| 70 |
+
"INTENSITY_GUESS: n/a\n"
|
| 71 |
+
"REMEDY: {{server_pick}}\n"
|
| 72 |
+
"NEXT_BRICK: B\n"
|
| 73 |
+
"DO_NOT: Feed the movie\n"
|
| 74 |
+
"LINE: Name it and stop the reel\n"
|
| 75 |
+
"NOTE: One hygiene or environment act\n"
|
| 76 |
+
"DATA_THIN: {data_thin}"
|
| 77 |
+
),
|
| 78 |
+
},
|
| 79 |
+
{
|
| 80 |
+
"id": "daydream_fc",
|
| 81 |
+
"priority": 60,
|
| 82 |
+
"keywords": ["daydream", "fantasy", "fc"],
|
| 83 |
+
"tags": ["daydream", "fc"],
|
| 84 |
+
"template": (
|
| 85 |
+
"LOOP: daydream\n"
|
| 86 |
+
"FEELING: drift\n"
|
| 87 |
+
"INTENSITY_GUESS: n/a\n"
|
| 88 |
+
"REMEDY: {{server_pick}}\n"
|
| 89 |
+
"NEXT_BRICK: A\n"
|
| 90 |
+
"DO_NOT: Skip today's brick\n"
|
| 91 |
+
"LINE: Brick first, then optional rest\n"
|
| 92 |
+
"NOTE: Daydream only after brick\n"
|
| 93 |
+
"DATA_THIN: {data_thin}"
|
| 94 |
+
),
|
| 95 |
+
},
|
| 96 |
+
{
|
| 97 |
+
"id": "urge_corn",
|
| 98 |
+
"priority": 55,
|
| 99 |
+
"keywords": ["urge", "corn", "delay"],
|
| 100 |
+
"tags": ["urge", "corn"],
|
| 101 |
+
"template": (
|
| 102 |
+
"LOOP: urge\n"
|
| 103 |
+
"FEELING: pull\n"
|
| 104 |
+
"INTENSITY_GUESS: n/a\n"
|
| 105 |
+
"REMEDY: {{server_pick}}\n"
|
| 106 |
+
"NEXT_BRICK: C\n"
|
| 107 |
+
"DO_NOT: Stack sessions\n"
|
| 108 |
+
"LINE: Delay, then one brick\n"
|
| 109 |
+
"NOTE: Ceiling is one session\n"
|
| 110 |
+
"DATA_THIN: {data_thin}"
|
| 111 |
+
),
|
| 112 |
+
},
|
| 113 |
+
{
|
| 114 |
+
"id": "bully",
|
| 115 |
+
"priority": 50,
|
| 116 |
+
"keywords": ["bully", "harsh voice", "attack myself"],
|
| 117 |
+
"tags": ["bully"],
|
| 118 |
+
"template": (
|
| 119 |
+
"LOOP: bully\n"
|
| 120 |
+
"FEELING: harsh\n"
|
| 121 |
+
"INTENSITY_GUESS: n/a\n"
|
| 122 |
+
"REMEDY: {{server_pick}}\n"
|
| 123 |
+
"NEXT_BRICK: E\n"
|
| 124 |
+
"DO_NOT: Argue with the voice\n"
|
| 125 |
+
"LINE: One boundary line then silence\n"
|
| 126 |
+
"NOTE: No pep talk\n"
|
| 127 |
+
"DATA_THIN: {data_thin}"
|
| 128 |
+
),
|
| 129 |
+
},
|
| 130 |
+
{
|
| 131 |
+
"id": "shame",
|
| 132 |
+
"priority": 40,
|
| 133 |
+
"keywords": ["shame", "embarrassed", "humiliated"],
|
| 134 |
+
"tags": ["shame"],
|
| 135 |
+
"template": (
|
| 136 |
+
"LOOP: shame\n"
|
| 137 |
+
"FEELING: shame\n"
|
| 138 |
+
"INTENSITY_GUESS: n/a\n"
|
| 139 |
+
"REMEDY: {{server_pick}}\n"
|
| 140 |
+
"NEXT_BRICK: B\n"
|
| 141 |
+
"DO_NOT: Hide and ruminate\n"
|
| 142 |
+
"LINE: Small body reset, then brick\n"
|
| 143 |
+
"NOTE: Keep it practical\n"
|
| 144 |
+
"DATA_THIN: {data_thin}"
|
| 145 |
+
),
|
| 146 |
+
},
|
| 147 |
+
{
|
| 148 |
+
"id": "spain_admin",
|
| 149 |
+
"priority": 35,
|
| 150 |
+
"keywords": ["admin", "paperwork", "travel", "deadline"],
|
| 151 |
+
"tags": ["admin", "travel"],
|
| 152 |
+
"template": (
|
| 153 |
+
"LOOP: admin\n"
|
| 154 |
+
"FEELING: pressure\n"
|
| 155 |
+
"INTENSITY_GUESS: n/a\n"
|
| 156 |
+
"REMEDY: {{server_pick}}\n"
|
| 157 |
+
"NEXT_BRICK: A\n"
|
| 158 |
+
"DO_NOT: Cancel committed process from fear\n"
|
| 159 |
+
"LINE: One admin checklist item\n"
|
| 160 |
+
"NOTE: Fear is not a cancel signal\n"
|
| 161 |
+
"DATA_THIN: {data_thin}"
|
| 162 |
+
),
|
| 163 |
+
},
|
| 164 |
+
{
|
| 165 |
+
"id": "build_earn",
|
| 166 |
+
"priority": 30,
|
| 167 |
+
"keywords": ["build", "earn", "ship", "work"],
|
| 168 |
+
"tags": ["build", "earn"],
|
| 169 |
+
"template": (
|
| 170 |
+
"LOOP: build\n"
|
| 171 |
+
"FEELING: drive\n"
|
| 172 |
+
"INTENSITY_GUESS: n/a\n"
|
| 173 |
+
"REMEDY: {{server_pick}}\n"
|
| 174 |
+
"NEXT_BRICK: C\n"
|
| 175 |
+
"DO_NOT: Open five fronts\n"
|
| 176 |
+
"LINE: Ship one small unit\n"
|
| 177 |
+
"NOTE: One earn/build act\n"
|
| 178 |
+
"DATA_THIN: {data_thin}"
|
| 179 |
+
),
|
| 180 |
+
},
|
| 181 |
+
{
|
| 182 |
+
"id": "home",
|
| 183 |
+
"priority": 20,
|
| 184 |
+
"keywords": ["home", "mess", "environment"],
|
| 185 |
+
"tags": ["home"],
|
| 186 |
+
"template": (
|
| 187 |
+
"LOOP: home\n"
|
| 188 |
+
"FEELING: clutter\n"
|
| 189 |
+
"INTENSITY_GUESS: n/a\n"
|
| 190 |
+
"REMEDY: {{server_pick}}\n"
|
| 191 |
+
"NEXT_BRICK: B\n"
|
| 192 |
+
"DO_NOT: Redesign everything\n"
|
| 193 |
+
"LINE: One hygiene or tidy act\n"
|
| 194 |
+
"NOTE: Environment first\n"
|
| 195 |
+
"DATA_THIN: {data_thin}"
|
| 196 |
+
),
|
| 197 |
+
},
|
| 198 |
+
{
|
| 199 |
+
"id": "loneliness",
|
| 200 |
+
"priority": 10,
|
| 201 |
+
"keywords": ["lonely", "alone", "isolation"],
|
| 202 |
+
"tags": ["lonely"],
|
| 203 |
+
"template": (
|
| 204 |
+
"LOOP: loneliness\n"
|
| 205 |
+
"FEELING: lonely\n"
|
| 206 |
+
"INTENSITY_GUESS: n/a\n"
|
| 207 |
+
"REMEDY: {{server_pick}}\n"
|
| 208 |
+
"NEXT_BRICK: D\n"
|
| 209 |
+
"DO_NOT: Spiral into absence stories\n"
|
| 210 |
+
"LINE: One logistics line, then brick\n"
|
| 211 |
+
"NOTE: Keep contact practical\n"
|
| 212 |
+
"DATA_THIN: {data_thin}"
|
| 213 |
+
),
|
| 214 |
+
},
|
| 215 |
+
{
|
| 216 |
+
"id": "default",
|
| 217 |
+
"priority": 0,
|
| 218 |
+
"keywords": [],
|
| 219 |
+
"tags": [],
|
| 220 |
+
"template": (
|
| 221 |
+
"LOOP: default\n"
|
| 222 |
+
"FEELING: mixed\n"
|
| 223 |
+
"INTENSITY_GUESS: n/a\n"
|
| 224 |
+
"REMEDY: {{server_pick}}\n"
|
| 225 |
+
"NEXT_BRICK: A\n"
|
| 226 |
+
"DO_NOT: Overthink the next hour\n"
|
| 227 |
+
"LINE: Do the next small brick\n"
|
| 228 |
+
"NOTE: Checklist over story\n"
|
| 229 |
+
"DATA_THIN: {data_thin}"
|
| 230 |
+
),
|
| 231 |
+
},
|
| 232 |
+
]
|
| 233 |
+
|
| 234 |
+
|
| 235 |
+
DEFAULT_PICK = "Do one listed brick: water, admin item, or tidy act"
|
| 236 |
+
|
| 237 |
+
|
| 238 |
+
def select_backup_rule(
|
| 239 |
+
text: str,
|
| 240 |
+
tags: list[str],
|
| 241 |
+
) -> dict[str, Any]:
|
| 242 |
+
"""Return the highest-priority rule matching keywords or tags."""
|
| 243 |
+
|
| 244 |
+
haystack = (text or "").lower()
|
| 245 |
+
tag_set = {t.strip().lower() for t in tags if t and t.strip()}
|
| 246 |
+
ordered = sorted(RULES, key=lambda rule: rule["priority"], reverse=True)
|
| 247 |
+
for rule in ordered:
|
| 248 |
+
keywords = rule.get("keywords") or []
|
| 249 |
+
rule_tags = {t.lower() for t in (rule.get("tags") or [])}
|
| 250 |
+
if any(keyword in haystack for keyword in keywords):
|
| 251 |
+
return rule
|
| 252 |
+
if tag_set & rule_tags:
|
| 253 |
+
return rule
|
| 254 |
+
return ordered[-1]
|
| 255 |
+
|
| 256 |
+
|
| 257 |
+
def render_backup(
|
| 258 |
+
rule: dict[str, Any],
|
| 259 |
+
*,
|
| 260 |
+
server_picks: list[dict[str, Any]],
|
| 261 |
+
data_thin: bool,
|
| 262 |
+
) -> str:
|
| 263 |
+
"""Fill the selected rule template with pick and DATA_THIN."""
|
| 264 |
+
|
| 265 |
+
pick = server_picks[0]["remedy_key"] if server_picks else DEFAULT_PICK
|
| 266 |
+
if rule["id"] == "crisis":
|
| 267 |
+
text = rule["template"]
|
| 268 |
+
else:
|
| 269 |
+
text = rule["template"].replace("{{server_pick}}", pick)
|
| 270 |
+
return text.format(data_thin=str(data_thin).lower())
|
| 271 |
+
|
| 272 |
+
|
| 273 |
+
def backup_reply(
|
| 274 |
+
text: str,
|
| 275 |
+
tags: list[str],
|
| 276 |
+
server_picks: list[dict[str, Any]],
|
| 277 |
+
*,
|
| 278 |
+
data_thin: bool,
|
| 279 |
+
) -> tuple[str, str]:
|
| 280 |
+
"""Select and render a backup reply; return (text, rule_id)."""
|
| 281 |
+
|
| 282 |
+
rule = select_backup_rule(text, tags)
|
| 283 |
+
return render_backup(rule, server_picks=server_picks, data_thin=data_thin), rule["id"]
|
app/coach_service.py
ADDED
|
@@ -0,0 +1,478 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Coach pipeline: evidence, OpenRouter (optional), parse, and backup.
|
| 2 |
+
|
| 3 |
+
Always returns HTTP-ready text with source=model|backup|model_unparsed_fallback.
|
| 4 |
+
"""
|
| 5 |
+
|
| 6 |
+
from __future__ import annotations
|
| 7 |
+
|
| 8 |
+
import json
|
| 9 |
+
import re
|
| 10 |
+
import time
|
| 11 |
+
from datetime import date, datetime, timezone
|
| 12 |
+
from typing import Any
|
| 13 |
+
from uuid import uuid4
|
| 14 |
+
|
| 15 |
+
import httpx
|
| 16 |
+
|
| 17 |
+
from app.backup_replies import backup_reply
|
| 18 |
+
from app.config import APP_VERSION, Settings
|
| 19 |
+
from app.evidence import build_evidence, strip_sensitive
|
| 20 |
+
from app.models import CoachMeta, Entry
|
| 21 |
+
from app.store_daily import DailyStore
|
| 22 |
+
from app.store_entries import EntryStore
|
| 23 |
+
from app.store_traces import TraceStore
|
| 24 |
+
|
| 25 |
+
DEFAULT_POLICY = """Bricks: A admin/critical; B environment/hygiene; C build/earn; D logistics; E boundary then silence; S stop spiral (water/shower/sleep)."""
|
| 26 |
+
|
| 27 |
+
OUTPUT_FORMAT = """Output format exactly (first line LOOP:):
|
| 28 |
+
LOOP: <tag>
|
| 29 |
+
FEELING: <word>
|
| 30 |
+
INTENSITY_GUESS: <1-10|n/a>
|
| 31 |
+
REMEDY: <one concrete action>
|
| 32 |
+
NEXT_BRICK: <A|B|C|D|E|S|specific>
|
| 33 |
+
DO_NOT: <one thing>
|
| 34 |
+
LINE: <≤12 words>
|
| 35 |
+
NOTE: <optional ≤20 words>
|
| 36 |
+
DATA_THIN: <true|false>"""
|
| 37 |
+
|
| 38 |
+
|
| 39 |
+
def utc_now() -> datetime:
|
| 40 |
+
return datetime.now(timezone.utc)
|
| 41 |
+
|
| 42 |
+
|
| 43 |
+
def parse_template(raw: str) -> dict[str, str] | None:
|
| 44 |
+
"""Parse line-based coach template; fail if LOOP: is missing."""
|
| 45 |
+
|
| 46 |
+
text = raw.strip()
|
| 47 |
+
if text.startswith("```"):
|
| 48 |
+
text = re.sub(r"^```(?:\w+)?\s*", "", text)
|
| 49 |
+
text = re.sub(r"\s*```$", "", text)
|
| 50 |
+
fields: dict[str, str] = {}
|
| 51 |
+
for line in text.splitlines():
|
| 52 |
+
if ":" not in line:
|
| 53 |
+
continue
|
| 54 |
+
key, value = line.split(":", 1)
|
| 55 |
+
key = key.strip().upper()
|
| 56 |
+
value = value.strip()
|
| 57 |
+
if key:
|
| 58 |
+
fields[key] = value
|
| 59 |
+
if "LOOP" not in fields:
|
| 60 |
+
return None
|
| 61 |
+
return fields
|
| 62 |
+
|
| 63 |
+
|
| 64 |
+
def format_parsed(parsed: dict[str, str]) -> str:
|
| 65 |
+
"""Rebuild canonical template text from parsed fields."""
|
| 66 |
+
|
| 67 |
+
order = [
|
| 68 |
+
"LOOP",
|
| 69 |
+
"FEELING",
|
| 70 |
+
"INTENSITY_GUESS",
|
| 71 |
+
"REMEDY",
|
| 72 |
+
"NEXT_BRICK",
|
| 73 |
+
"DO_NOT",
|
| 74 |
+
"LINE",
|
| 75 |
+
"NOTE",
|
| 76 |
+
"DATA_THIN",
|
| 77 |
+
]
|
| 78 |
+
lines = []
|
| 79 |
+
for key in order:
|
| 80 |
+
if key in parsed:
|
| 81 |
+
lines.append(f"{key}: {parsed[key]}")
|
| 82 |
+
return "\n".join(lines)
|
| 83 |
+
|
| 84 |
+
|
| 85 |
+
def truncate_entry(entry: Entry) -> dict[str, Any]:
|
| 86 |
+
"""Compact entry fields for coach history."""
|
| 87 |
+
|
| 88 |
+
happened = entry.happened[:280]
|
| 89 |
+
return {
|
| 90 |
+
"id": entry.id,
|
| 91 |
+
"ts": entry.ts.isoformat(),
|
| 92 |
+
"tags": entry.tags,
|
| 93 |
+
"emotions": entry.emotions,
|
| 94 |
+
"intensity": entry.intensity,
|
| 95 |
+
"remedy": entry.remedy,
|
| 96 |
+
"result": entry.result.value,
|
| 97 |
+
"happened": happened,
|
| 98 |
+
}
|
| 99 |
+
|
| 100 |
+
|
| 101 |
+
class CoachService:
|
| 102 |
+
"""Build evidence-backed coach replies with model or backup fallback."""
|
| 103 |
+
|
| 104 |
+
def __init__(
|
| 105 |
+
self,
|
| 106 |
+
settings: Settings,
|
| 107 |
+
entry_store: EntryStore,
|
| 108 |
+
daily_store: DailyStore,
|
| 109 |
+
trace_store: TraceStore,
|
| 110 |
+
) -> None:
|
| 111 |
+
self.settings = settings
|
| 112 |
+
self.entry_store = entry_store
|
| 113 |
+
self.daily_store = daily_store
|
| 114 |
+
self.trace_store = trace_store
|
| 115 |
+
|
| 116 |
+
def _all_entry_dicts(self) -> list[dict[str, Any]]:
|
| 117 |
+
return [e.model_dump(mode="json") for e in self.entry_store._load()]
|
| 118 |
+
|
| 119 |
+
def _today_daily(self) -> dict[str, Any] | None:
|
| 120 |
+
row = self.daily_store.get(date.today())
|
| 121 |
+
return row.model_dump(mode="json") if row else None
|
| 122 |
+
|
| 123 |
+
def _daily_dicts(self) -> list[dict[str, Any]]:
|
| 124 |
+
return [r.model_dump(mode="json") for r in self.daily_store._load()]
|
| 125 |
+
|
| 126 |
+
def build_messages(
|
| 127 |
+
self,
|
| 128 |
+
*,
|
| 129 |
+
brief: str,
|
| 130 |
+
evidence_block: str,
|
| 131 |
+
picks_text: str,
|
| 132 |
+
current: dict[str, Any],
|
| 133 |
+
today_daily: dict[str, Any] | None,
|
| 134 |
+
history: list[dict[str, Any]],
|
| 135 |
+
data_thin_flag: bool,
|
| 136 |
+
) -> list[dict[str, str]]:
|
| 137 |
+
"""Assemble short system/user messages for the free model."""
|
| 138 |
+
|
| 139 |
+
system = "\n\n".join(
|
| 140 |
+
[
|
| 141 |
+
"Role: brief checklist coach. No pity. No pep talk.",
|
| 142 |
+
"NEVER invent numbers — only EVIDENCE / SERVER_PICKS.",
|
| 143 |
+
"NEVER cancel committed admin/travel process; fear ≠ cancel.",
|
| 144 |
+
"If self-harm language → crisis redirect, no deep exploration.",
|
| 145 |
+
OUTPUT_FORMAT,
|
| 146 |
+
DEFAULT_POLICY,
|
| 147 |
+
strip_sensitive(brief),
|
| 148 |
+
]
|
| 149 |
+
)
|
| 150 |
+
user_payload = {
|
| 151 |
+
"current": current,
|
| 152 |
+
"today_daily": today_daily,
|
| 153 |
+
"recent_entries": history,
|
| 154 |
+
"EVIDENCE": evidence_block,
|
| 155 |
+
"SERVER_PICKS": picks_text,
|
| 156 |
+
"DATA_THIN": data_thin_flag,
|
| 157 |
+
"instruction": (
|
| 158 |
+
"Choose REMEDY consistent with SERVER_PICKS when possible. "
|
| 159 |
+
"Begin with LOOP:"
|
| 160 |
+
),
|
| 161 |
+
}
|
| 162 |
+
user = strip_sensitive(json.dumps(user_payload, ensure_ascii=False, indent=2))
|
| 163 |
+
return [
|
| 164 |
+
{"role": "system", "content": system},
|
| 165 |
+
{"role": "user", "content": user},
|
| 166 |
+
]
|
| 167 |
+
|
| 168 |
+
def call_openrouter(
|
| 169 |
+
self,
|
| 170 |
+
messages: list[dict[str, str]],
|
| 171 |
+
) -> tuple[str | None, int | None, str | None, int]:
|
| 172 |
+
"""Call OpenRouter; return content, http_status, error, latency_ms."""
|
| 173 |
+
|
| 174 |
+
if not self.settings.openrouter_api_key:
|
| 175 |
+
return None, None, "NO_API_KEY", 0
|
| 176 |
+
started = time.perf_counter()
|
| 177 |
+
url = f"{self.settings.openrouter_base_url.rstrip('/')}/chat/completions"
|
| 178 |
+
headers = {
|
| 179 |
+
"Authorization": f"Bearer {self.settings.openrouter_api_key}",
|
| 180 |
+
"Content-Type": "application/json",
|
| 181 |
+
"X-Title": "Habit Journal",
|
| 182 |
+
}
|
| 183 |
+
body = {
|
| 184 |
+
"model": self.settings.openrouter_model,
|
| 185 |
+
"messages": messages,
|
| 186 |
+
"temperature": self.settings.coach_temperature,
|
| 187 |
+
"max_tokens": self.settings.coach_max_tokens,
|
| 188 |
+
}
|
| 189 |
+
try:
|
| 190 |
+
with httpx.Client(timeout=self.settings.coach_timeout_sec) as client:
|
| 191 |
+
response = client.post(url, headers=headers, json=body)
|
| 192 |
+
latency_ms = int((time.perf_counter() - started) * 1000)
|
| 193 |
+
if response.status_code >= 400:
|
| 194 |
+
return None, response.status_code, f"http_{response.status_code}", latency_ms
|
| 195 |
+
payload = response.json()
|
| 196 |
+
content = (
|
| 197 |
+
payload.get("choices", [{}])[0]
|
| 198 |
+
.get("message", {})
|
| 199 |
+
.get("content")
|
| 200 |
+
)
|
| 201 |
+
if not content or not str(content).strip():
|
| 202 |
+
return None, response.status_code, "EMPTY_MODEL", latency_ms
|
| 203 |
+
return str(content), response.status_code, None, latency_ms
|
| 204 |
+
except httpx.TimeoutException:
|
| 205 |
+
latency_ms = int((time.perf_counter() - started) * 1000)
|
| 206 |
+
return None, None, "TIMEOUT", latency_ms
|
| 207 |
+
except Exception as exc: # noqa: BLE001 — fail-open to backup
|
| 208 |
+
latency_ms = int((time.perf_counter() - started) * 1000)
|
| 209 |
+
return None, None, f"error:{type(exc).__name__}", latency_ms
|
| 210 |
+
|
| 211 |
+
def coach(
|
| 212 |
+
self,
|
| 213 |
+
*,
|
| 214 |
+
text: str | None = None,
|
| 215 |
+
entry_id: str | None = None,
|
| 216 |
+
include_history: int | None = None,
|
| 217 |
+
persist: bool = False,
|
| 218 |
+
force_backup: bool = False,
|
| 219 |
+
request_meta: dict[str, Any] | None = None,
|
| 220 |
+
) -> dict[str, Any]:
|
| 221 |
+
"""Run the full coach pipeline and always return a text reply."""
|
| 222 |
+
|
| 223 |
+
if not text and not entry_id:
|
| 224 |
+
raise ValueError("require text or entry_id")
|
| 225 |
+
|
| 226 |
+
entry: Entry | None = None
|
| 227 |
+
if entry_id:
|
| 228 |
+
entry = self.entry_store.get(entry_id)
|
| 229 |
+
if entry is None:
|
| 230 |
+
raise KeyError(entry_id)
|
| 231 |
+
|
| 232 |
+
current_text = text or ""
|
| 233 |
+
current_tags: list[str] = []
|
| 234 |
+
current_emotions: list[str] = []
|
| 235 |
+
if entry:
|
| 236 |
+
current_tags = list(entry.tags)
|
| 237 |
+
current_emotions = list(entry.emotions)
|
| 238 |
+
if not current_text:
|
| 239 |
+
current_text = f"{entry.activity}. {entry.happened}"
|
| 240 |
+
current = {
|
| 241 |
+
"text": current_text,
|
| 242 |
+
"entry_id": entry.id if entry else None,
|
| 243 |
+
"tags": current_tags,
|
| 244 |
+
"emotions": current_emotions,
|
| 245 |
+
"intensity": entry.intensity if entry else None,
|
| 246 |
+
"remedy": entry.remedy if entry else None,
|
| 247 |
+
"result": entry.result.value if entry else None,
|
| 248 |
+
}
|
| 249 |
+
|
| 250 |
+
history_k = (
|
| 251 |
+
self.settings.coach_history_k
|
| 252 |
+
if include_history is None
|
| 253 |
+
else max(0, include_history)
|
| 254 |
+
)
|
| 255 |
+
all_entries = self.entry_store._load()
|
| 256 |
+
all_entries.sort(key=lambda item: item.ts, reverse=True)
|
| 257 |
+
history = [truncate_entry(item) for item in all_entries[:history_k]]
|
| 258 |
+
today_daily = self._today_daily()
|
| 259 |
+
evidence = build_evidence(
|
| 260 |
+
self._all_entry_dicts(),
|
| 261 |
+
self._daily_dicts(),
|
| 262 |
+
current_tags,
|
| 263 |
+
min_n=self.settings.min_stats_n,
|
| 264 |
+
shrink_k=self.settings.stats_shrink_k,
|
| 265 |
+
match_alpha=self.settings.match_alpha,
|
| 266 |
+
)
|
| 267 |
+
picks = evidence["server_picks"]
|
| 268 |
+
brief_meta = self.trace_store.brief_meta(
|
| 269 |
+
include_full=self.settings.debug_include_full_brief
|
| 270 |
+
)
|
| 271 |
+
brief = self.trace_store.read_brief()
|
| 272 |
+
messages = self.build_messages(
|
| 273 |
+
brief=brief,
|
| 274 |
+
evidence_block=evidence["evidence_block"],
|
| 275 |
+
picks_text=evidence["server_picks_text"],
|
| 276 |
+
current=current,
|
| 277 |
+
today_daily=today_daily,
|
| 278 |
+
history=history,
|
| 279 |
+
data_thin_flag=evidence["DATA_THIN"],
|
| 280 |
+
)
|
| 281 |
+
|
| 282 |
+
trace_id = str(uuid4())
|
| 283 |
+
flags: list[str] = []
|
| 284 |
+
if evidence["DATA_THIN"]:
|
| 285 |
+
flags.append("DATA_THIN")
|
| 286 |
+
|
| 287 |
+
raw = None
|
| 288 |
+
http_status = None
|
| 289 |
+
error = None
|
| 290 |
+
latency_ms = 0
|
| 291 |
+
source = "backup"
|
| 292 |
+
parsed = None
|
| 293 |
+
backup_rule_id = None
|
| 294 |
+
model_id = self.settings.openrouter_model
|
| 295 |
+
|
| 296 |
+
if force_backup or not self.settings.openrouter_api_key:
|
| 297 |
+
if not self.settings.openrouter_api_key:
|
| 298 |
+
flags.append("NO_API_KEY")
|
| 299 |
+
final_text, backup_rule_id = backup_reply(
|
| 300 |
+
current_text,
|
| 301 |
+
current_tags + current_emotions,
|
| 302 |
+
picks,
|
| 303 |
+
data_thin=evidence["DATA_THIN"],
|
| 304 |
+
)
|
| 305 |
+
source = "backup"
|
| 306 |
+
parsed = parse_template(final_text)
|
| 307 |
+
else:
|
| 308 |
+
raw, http_status, error, latency_ms = self.call_openrouter(messages)
|
| 309 |
+
if error == "TIMEOUT":
|
| 310 |
+
flags.append("TIMEOUT")
|
| 311 |
+
elif error == "EMPTY_MODEL":
|
| 312 |
+
flags.append("EMPTY_MODEL")
|
| 313 |
+
if raw:
|
| 314 |
+
parsed = parse_template(raw)
|
| 315 |
+
if parsed:
|
| 316 |
+
source = "model"
|
| 317 |
+
final_text = format_parsed(parsed)
|
| 318 |
+
remedy = (parsed.get("REMEDY") or "").strip().lower()
|
| 319 |
+
pick_keys = {p["remedy_key"].lower() for p in picks}
|
| 320 |
+
if picks and remedy and not any(
|
| 321 |
+
key in remedy or remedy in key for key in pick_keys
|
| 322 |
+
):
|
| 323 |
+
flags.append("OUT_OF_EVIDENCE")
|
| 324 |
+
else:
|
| 325 |
+
flags.append("PARSE_FAIL")
|
| 326 |
+
source = "model_unparsed_fallback"
|
| 327 |
+
final_text, backup_rule_id = backup_reply(
|
| 328 |
+
current_text,
|
| 329 |
+
current_tags + current_emotions,
|
| 330 |
+
picks,
|
| 331 |
+
data_thin=evidence["DATA_THIN"],
|
| 332 |
+
)
|
| 333 |
+
parsed = parse_template(final_text)
|
| 334 |
+
else:
|
| 335 |
+
final_text, backup_rule_id = backup_reply(
|
| 336 |
+
current_text,
|
| 337 |
+
current_tags + current_emotions,
|
| 338 |
+
picks,
|
| 339 |
+
data_thin=evidence["DATA_THIN"],
|
| 340 |
+
)
|
| 341 |
+
source = "backup"
|
| 342 |
+
parsed = parse_template(final_text)
|
| 343 |
+
|
| 344 |
+
now = utc_now()
|
| 345 |
+
trace = {
|
| 346 |
+
"trace_id": trace_id,
|
| 347 |
+
"ts": now.isoformat(),
|
| 348 |
+
"request": request_meta
|
| 349 |
+
or {
|
| 350 |
+
"text": text,
|
| 351 |
+
"entry_id": entry_id,
|
| 352 |
+
"include_history": history_k,
|
| 353 |
+
"persist": persist,
|
| 354 |
+
"force_backup": force_backup,
|
| 355 |
+
},
|
| 356 |
+
"current": current,
|
| 357 |
+
"history_truncated": history,
|
| 358 |
+
"evidence": {
|
| 359 |
+
"n_scored": evidence["n_scored"],
|
| 360 |
+
"DATA_THIN": evidence["DATA_THIN"],
|
| 361 |
+
"evidence_block": evidence["evidence_block"],
|
| 362 |
+
"by_remedy": evidence["by_remedy"],
|
| 363 |
+
"daily": evidence["daily"],
|
| 364 |
+
},
|
| 365 |
+
"server_picks": picks,
|
| 366 |
+
"brief_sha256": brief_meta["brief_sha256"],
|
| 367 |
+
"brief_excerpt": brief_meta["brief_excerpt"],
|
| 368 |
+
"brief_full": brief_meta["brief_full"],
|
| 369 |
+
"system_prompt": messages[0]["content"],
|
| 370 |
+
"user_prompt": messages[1]["content"],
|
| 371 |
+
"model_id": model_id if source != "backup" or raw else model_id,
|
| 372 |
+
"temperature": self.settings.coach_temperature,
|
| 373 |
+
"max_tokens": self.settings.coach_max_tokens,
|
| 374 |
+
"latency_ms": latency_ms,
|
| 375 |
+
"http_status": http_status,
|
| 376 |
+
"error": error,
|
| 377 |
+
"raw_model_response": raw,
|
| 378 |
+
"parsed": parsed,
|
| 379 |
+
"source": source,
|
| 380 |
+
"backup_rule_id": backup_rule_id,
|
| 381 |
+
"flags": flags,
|
| 382 |
+
"app_version": APP_VERSION,
|
| 383 |
+
"final_text": final_text,
|
| 384 |
+
}
|
| 385 |
+
self.trace_store.append_trace(trace)
|
| 386 |
+
|
| 387 |
+
if persist and entry is not None:
|
| 388 |
+
self.entry_store.set_coach(
|
| 389 |
+
entry.id,
|
| 390 |
+
CoachMeta(
|
| 391 |
+
text=final_text,
|
| 392 |
+
source=source,
|
| 393 |
+
model=model_id if source == "model" else None,
|
| 394 |
+
ts=now,
|
| 395 |
+
trace_id=trace_id,
|
| 396 |
+
),
|
| 397 |
+
)
|
| 398 |
+
|
| 399 |
+
return {
|
| 400 |
+
"text": final_text,
|
| 401 |
+
"source": source,
|
| 402 |
+
"model": model_id if source == "model" else None,
|
| 403 |
+
"trace_id": trace_id,
|
| 404 |
+
"flags": flags,
|
| 405 |
+
"server_picks": [
|
| 406 |
+
{
|
| 407 |
+
"remedy_key": p["remedy_key"],
|
| 408 |
+
"pick": p["pick"],
|
| 409 |
+
"n": p["n"],
|
| 410 |
+
"p_helped": p["p_helped"],
|
| 411 |
+
}
|
| 412 |
+
for p in picks
|
| 413 |
+
],
|
| 414 |
+
"parsed": parsed,
|
| 415 |
+
}
|
| 416 |
+
|
| 417 |
+
def prompt_preview(
|
| 418 |
+
self,
|
| 419 |
+
*,
|
| 420 |
+
text: str | None = None,
|
| 421 |
+
entry_id: str | None = None,
|
| 422 |
+
include_history: int | None = None,
|
| 423 |
+
) -> dict[str, Any]:
|
| 424 |
+
"""Build evidence and messages without calling the model."""
|
| 425 |
+
|
| 426 |
+
if not text and not entry_id:
|
| 427 |
+
raise ValueError("require text or entry_id")
|
| 428 |
+
|
| 429 |
+
entry: Entry | None = None
|
| 430 |
+
if entry_id:
|
| 431 |
+
entry = self.entry_store.get(entry_id)
|
| 432 |
+
if entry is None:
|
| 433 |
+
raise KeyError(entry_id)
|
| 434 |
+
|
| 435 |
+
current_text = text or ""
|
| 436 |
+
current_tags: list[str] = list(entry.tags) if entry else []
|
| 437 |
+
current_emotions: list[str] = list(entry.emotions) if entry else []
|
| 438 |
+
if entry and not current_text:
|
| 439 |
+
current_text = f"{entry.activity}. {entry.happened}"
|
| 440 |
+
current = {
|
| 441 |
+
"text": current_text,
|
| 442 |
+
"entry_id": entry.id if entry else None,
|
| 443 |
+
"tags": current_tags,
|
| 444 |
+
"emotions": current_emotions,
|
| 445 |
+
}
|
| 446 |
+
history_k = (
|
| 447 |
+
self.settings.coach_history_k
|
| 448 |
+
if include_history is None
|
| 449 |
+
else max(0, include_history)
|
| 450 |
+
)
|
| 451 |
+
all_entries = self.entry_store._load()
|
| 452 |
+
all_entries.sort(key=lambda item: item.ts, reverse=True)
|
| 453 |
+
history = [truncate_entry(item) for item in all_entries[:history_k]]
|
| 454 |
+
evidence = build_evidence(
|
| 455 |
+
self._all_entry_dicts(),
|
| 456 |
+
self._daily_dicts(),
|
| 457 |
+
current_tags,
|
| 458 |
+
min_n=self.settings.min_stats_n,
|
| 459 |
+
shrink_k=self.settings.stats_shrink_k,
|
| 460 |
+
match_alpha=self.settings.match_alpha,
|
| 461 |
+
)
|
| 462 |
+
brief = self.trace_store.read_brief()
|
| 463 |
+
messages = self.build_messages(
|
| 464 |
+
brief=brief,
|
| 465 |
+
evidence_block=evidence["evidence_block"],
|
| 466 |
+
picks_text=evidence["server_picks_text"],
|
| 467 |
+
current=current,
|
| 468 |
+
today_daily=self._today_daily(),
|
| 469 |
+
history=history,
|
| 470 |
+
data_thin_flag=evidence["DATA_THIN"],
|
| 471 |
+
)
|
| 472 |
+
return {
|
| 473 |
+
"evidence_block": evidence["evidence_block"],
|
| 474 |
+
"server_picks": evidence["server_picks"][:5],
|
| 475 |
+
"system_prompt": messages[0]["content"],
|
| 476 |
+
"user_prompt": messages[1]["content"],
|
| 477 |
+
"DATA_THIN": evidence["DATA_THIN"],
|
| 478 |
+
}
|
app/config.py
ADDED
|
@@ -0,0 +1,58 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Load immutable runtime settings from environment variables.
|
| 2 |
+
|
| 3 |
+
Settings are created once by the application factory and passed to services.
|
| 4 |
+
"""
|
| 5 |
+
|
| 6 |
+
from functools import lru_cache
|
| 7 |
+
|
| 8 |
+
from pydantic import Field
|
| 9 |
+
from pydantic_settings import BaseSettings, SettingsConfigDict
|
| 10 |
+
|
| 11 |
+
APP_VERSION = "2.0.0"
|
| 12 |
+
|
| 13 |
+
|
| 14 |
+
class Settings(BaseSettings):
|
| 15 |
+
"""Environment-backed application configuration."""
|
| 16 |
+
|
| 17 |
+
model_config = SettingsConfigDict(
|
| 18 |
+
env_prefix="",
|
| 19 |
+
case_sensitive=False,
|
| 20 |
+
extra="ignore",
|
| 21 |
+
)
|
| 22 |
+
|
| 23 |
+
app_password: str | None = None
|
| 24 |
+
app_secret_key: str = Field(min_length=1)
|
| 25 |
+
data_root: str = "/data/loop_logger"
|
| 26 |
+
env: str = "prod"
|
| 27 |
+
app_name: str = "Habit Journal"
|
| 28 |
+
|
| 29 |
+
openrouter_api_key: str | None = None
|
| 30 |
+
openrouter_model: str = "openrouter/free"
|
| 31 |
+
openrouter_base_url: str = "https://openrouter.ai/api/v1"
|
| 32 |
+
coach_timeout_sec: float = 25
|
| 33 |
+
coach_temperature: float = 0.3
|
| 34 |
+
coach_max_tokens: int = 400
|
| 35 |
+
coach_history_k: int = 10
|
| 36 |
+
coach_trace_limit: int = 200
|
| 37 |
+
|
| 38 |
+
min_stats_n: int = 5
|
| 39 |
+
stats_shrink_k: float = 3
|
| 40 |
+
match_alpha: float = 0.5
|
| 41 |
+
session_max_age_sec: int = 604800
|
| 42 |
+
debug_include_full_brief: bool = False
|
| 43 |
+
login_rate_limit: int = 10
|
| 44 |
+
login_rate_window_sec: int = 900
|
| 45 |
+
max_body_bytes: int = 65536
|
| 46 |
+
|
| 47 |
+
@property
|
| 48 |
+
def is_prod(self) -> bool:
|
| 49 |
+
"""Return whether production-only security behavior is enabled."""
|
| 50 |
+
|
| 51 |
+
return self.env.lower() == "prod"
|
| 52 |
+
|
| 53 |
+
|
| 54 |
+
@lru_cache
|
| 55 |
+
def get_settings() -> Settings:
|
| 56 |
+
"""Read and cache environment settings."""
|
| 57 |
+
|
| 58 |
+
return Settings()
|
app/deps.py
ADDED
|
@@ -0,0 +1,46 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Provide FastAPI dependencies for protected routes.
|
| 2 |
+
|
| 3 |
+
Session validity is checked against expiry and durable session version.
|
| 4 |
+
"""
|
| 5 |
+
|
| 6 |
+
import time
|
| 7 |
+
from typing import Any
|
| 8 |
+
|
| 9 |
+
from fastapi import HTTPException, Request, status
|
| 10 |
+
|
| 11 |
+
from app.models import StoredConfig
|
| 12 |
+
|
| 13 |
+
|
| 14 |
+
def api_http_error(status_code: int, code: str, message: str) -> HTTPException:
|
| 15 |
+
"""Create an HTTP exception understood by the envelope handler."""
|
| 16 |
+
|
| 17 |
+
return HTTPException(
|
| 18 |
+
status_code=status_code,
|
| 19 |
+
detail={"code": code, "message": message},
|
| 20 |
+
)
|
| 21 |
+
|
| 22 |
+
|
| 23 |
+
def require_login(request: Request) -> StoredConfig:
|
| 24 |
+
"""Require a current signed session and return durable config."""
|
| 25 |
+
|
| 26 |
+
config: StoredConfig | None = request.app.state.config_store.load()
|
| 27 |
+
if config is None:
|
| 28 |
+
raise api_http_error(
|
| 29 |
+
status.HTTP_503_SERVICE_UNAVAILABLE,
|
| 30 |
+
"setup_required",
|
| 31 |
+
"Application setup is required",
|
| 32 |
+
)
|
| 33 |
+
|
| 34 |
+
session: dict[str, Any] = request.session
|
| 35 |
+
authenticated = session.get("auth") is True
|
| 36 |
+
version_matches = session.get("sv") == config.session_version
|
| 37 |
+
expiry = session.get("exp")
|
| 38 |
+
unexpired = isinstance(expiry, (int, float)) and expiry >= time.time()
|
| 39 |
+
if not (authenticated and version_matches and unexpired):
|
| 40 |
+
request.session.clear()
|
| 41 |
+
raise api_http_error(
|
| 42 |
+
status.HTTP_401_UNAUTHORIZED,
|
| 43 |
+
"unauthorized",
|
| 44 |
+
"Login required",
|
| 45 |
+
)
|
| 46 |
+
return config
|
app/evidence.py
ADDED
|
@@ -0,0 +1,145 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Build coach evidence blocks and SERVER_PICKS from server math.
|
| 2 |
+
|
| 3 |
+
Formats precomputed probabilities for prompts; never invents numbers.
|
| 4 |
+
"""
|
| 5 |
+
|
| 6 |
+
from __future__ import annotations
|
| 7 |
+
|
| 8 |
+
from typing import Any
|
| 9 |
+
|
| 10 |
+
from app.stats_math import (
|
| 11 |
+
FORMULAS,
|
| 12 |
+
by_emotion,
|
| 13 |
+
by_remedy,
|
| 14 |
+
by_tag,
|
| 15 |
+
daily_rates,
|
| 16 |
+
data_thin,
|
| 17 |
+
scored_entries,
|
| 18 |
+
server_picks,
|
| 19 |
+
)
|
| 20 |
+
|
| 21 |
+
|
| 22 |
+
def strip_sensitive(text: str) -> str:
|
| 23 |
+
"""Best-effort strip of emails and long digit runs from prompt text."""
|
| 24 |
+
|
| 25 |
+
import re
|
| 26 |
+
|
| 27 |
+
cleaned = re.sub(
|
| 28 |
+
r"\b[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Za-z]{2,}\b",
|
| 29 |
+
"[email]",
|
| 30 |
+
text,
|
| 31 |
+
)
|
| 32 |
+
cleaned = re.sub(r"\b\d{8,}\b", "[digits]", cleaned)
|
| 33 |
+
return cleaned
|
| 34 |
+
|
| 35 |
+
|
| 36 |
+
def format_evidence_block(
|
| 37 |
+
entries: list[dict[str, Any]],
|
| 38 |
+
daily_rows: list[dict[str, Any]],
|
| 39 |
+
*,
|
| 40 |
+
min_n: int,
|
| 41 |
+
shrink_k: float,
|
| 42 |
+
) -> str:
|
| 43 |
+
"""Render the EVIDENCE markdown block for coach prompts."""
|
| 44 |
+
|
| 45 |
+
scored = scored_entries(entries)
|
| 46 |
+
remedies = by_remedy(entries, min_n=min_n, shrink_k=shrink_k)
|
| 47 |
+
tags = by_tag(entries)[:8]
|
| 48 |
+
emotions = by_emotion(entries)[:8]
|
| 49 |
+
rates = daily_rates(daily_rows)
|
| 50 |
+
lines = [
|
| 51 |
+
"EVIDENCE (server-computed; do not invent numbers)",
|
| 52 |
+
f"n_scored: {len(scored)}",
|
| 53 |
+
f"min_n: {min_n}",
|
| 54 |
+
"TOP_REMEDIES: remedy | n | p_worked | p_helped | rank",
|
| 55 |
+
]
|
| 56 |
+
if remedies:
|
| 57 |
+
for row in remedies[:10]:
|
| 58 |
+
lines.append(
|
| 59 |
+
f"- {row['key']} | {row['n']} | {row['p_worked']:.3f} | "
|
| 60 |
+
f"{row['p_helped']:.3f} | {row['rank']:.3f}"
|
| 61 |
+
)
|
| 62 |
+
else:
|
| 63 |
+
lines.append("- (none above min_n)")
|
| 64 |
+
lines.append("WORST_TAGS: tag | n | p_fail")
|
| 65 |
+
if tags:
|
| 66 |
+
for row in tags:
|
| 67 |
+
lines.append(f"- {row['key']} | {row['n']} | {row['p_failed']:.3f}")
|
| 68 |
+
else:
|
| 69 |
+
lines.append("- (none)")
|
| 70 |
+
lines.append("EMOTION_HITS: emotion | n | p_helped")
|
| 71 |
+
if emotions:
|
| 72 |
+
for row in emotions:
|
| 73 |
+
lines.append(f"- {row['key']} | {row['n']} | {row['p_helped']:.3f}")
|
| 74 |
+
else:
|
| 75 |
+
lines.append("- (none)")
|
| 76 |
+
lines.append(
|
| 77 |
+
"DAILY_RATES: "
|
| 78 |
+
f"p_brick_done={rates['p_brick_done']:.3f} "
|
| 79 |
+
f"p_corn_ok={rates['p_corn_ok']:.3f} "
|
| 80 |
+
f"p_no_fc={rates['p_no_fc']:.3f} "
|
| 81 |
+
f"p_rerun_clean={rates['p_rerun_clean']:.3f} "
|
| 82 |
+
f"p_court_closed={rates['p_court_closed']:.3f} "
|
| 83 |
+
f"avg_points={rates['avg_points']:.3f}"
|
| 84 |
+
)
|
| 85 |
+
lines.append(
|
| 86 |
+
"FORMULAS: "
|
| 87 |
+
f"p_helped={FORMULAS['p_helped']}; rank={FORMULAS['rank']}"
|
| 88 |
+
)
|
| 89 |
+
lines.append(f"DATA_THIN: {str(data_thin(len(scored))).lower()}")
|
| 90 |
+
return "\n".join(lines)
|
| 91 |
+
|
| 92 |
+
|
| 93 |
+
def format_server_picks(picks: list[dict[str, Any]]) -> str:
|
| 94 |
+
"""Render numbered SERVER_PICKS lines for prompts and debug paste."""
|
| 95 |
+
|
| 96 |
+
if not picks:
|
| 97 |
+
return "SERVER_PICKS: (none)"
|
| 98 |
+
lines = ["SERVER_PICKS:"]
|
| 99 |
+
for index, pick in enumerate(picks, start=1):
|
| 100 |
+
lines.append(
|
| 101 |
+
f"{index}) {pick['remedy_key']} pick={pick['pick']:.3f} "
|
| 102 |
+
f"n={pick['n']} p_helped={pick['p_helped']:.3f}"
|
| 103 |
+
)
|
| 104 |
+
return "\n".join(lines)
|
| 105 |
+
|
| 106 |
+
|
| 107 |
+
def build_evidence(
|
| 108 |
+
entries: list[dict[str, Any]],
|
| 109 |
+
daily_rows: list[dict[str, Any]],
|
| 110 |
+
current_tags: list[str],
|
| 111 |
+
*,
|
| 112 |
+
min_n: int,
|
| 113 |
+
shrink_k: float,
|
| 114 |
+
match_alpha: float,
|
| 115 |
+
) -> dict[str, Any]:
|
| 116 |
+
"""Return structured evidence plus formatted blocks and picks."""
|
| 117 |
+
|
| 118 |
+
scored = scored_entries(entries)
|
| 119 |
+
picks = server_picks(
|
| 120 |
+
entries,
|
| 121 |
+
current_tags,
|
| 122 |
+
min_n=min_n,
|
| 123 |
+
shrink_k=shrink_k,
|
| 124 |
+
match_alpha=match_alpha,
|
| 125 |
+
)
|
| 126 |
+
block = format_evidence_block(
|
| 127 |
+
entries,
|
| 128 |
+
daily_rows,
|
| 129 |
+
min_n=min_n,
|
| 130 |
+
shrink_k=shrink_k,
|
| 131 |
+
)
|
| 132 |
+
picks_text = format_server_picks(picks)
|
| 133 |
+
return {
|
| 134 |
+
"n_scored": len(scored),
|
| 135 |
+
"min_n": min_n,
|
| 136 |
+
"DATA_THIN": data_thin(len(scored)),
|
| 137 |
+
"by_remedy": by_remedy(entries, min_n=min_n, shrink_k=shrink_k),
|
| 138 |
+
"by_tag": by_tag(entries),
|
| 139 |
+
"by_emotion": by_emotion(entries),
|
| 140 |
+
"daily": daily_rates(daily_rows),
|
| 141 |
+
"server_picks": picks,
|
| 142 |
+
"evidence_block": block,
|
| 143 |
+
"server_picks_text": picks_text,
|
| 144 |
+
"formulas": FORMULAS,
|
| 145 |
+
}
|
app/fsutil.py
ADDED
|
@@ -0,0 +1,110 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Provide locked JSON/JSONL persistence and atomic file replacement.
|
| 2 |
+
|
| 3 |
+
Mutations use a sibling lock file so append and full rewrites cannot overlap.
|
| 4 |
+
"""
|
| 5 |
+
|
| 6 |
+
import json
|
| 7 |
+
import os
|
| 8 |
+
from contextlib import contextmanager
|
| 9 |
+
from pathlib import Path
|
| 10 |
+
from typing import Any, Iterator
|
| 11 |
+
|
| 12 |
+
|
| 13 |
+
@contextmanager
|
| 14 |
+
def file_lock(path: Path) -> Iterator[None]:
|
| 15 |
+
"""Hold an exclusive process lock for a data path."""
|
| 16 |
+
|
| 17 |
+
lock_path = path.with_name(f"{path.name}.lock")
|
| 18 |
+
lock_path.parent.mkdir(parents=True, exist_ok=True)
|
| 19 |
+
with lock_path.open("a+b") as handle:
|
| 20 |
+
if os.name == "nt":
|
| 21 |
+
import msvcrt
|
| 22 |
+
|
| 23 |
+
if handle.tell() == 0:
|
| 24 |
+
handle.write(b"\0")
|
| 25 |
+
handle.flush()
|
| 26 |
+
handle.seek(0)
|
| 27 |
+
msvcrt.locking(handle.fileno(), msvcrt.LK_LOCK, 1)
|
| 28 |
+
else:
|
| 29 |
+
import fcntl
|
| 30 |
+
|
| 31 |
+
fcntl.flock(handle.fileno(), fcntl.LOCK_EX)
|
| 32 |
+
try:
|
| 33 |
+
yield
|
| 34 |
+
finally:
|
| 35 |
+
if os.name == "nt":
|
| 36 |
+
handle.seek(0)
|
| 37 |
+
msvcrt.locking(handle.fileno(), msvcrt.LK_UNLCK, 1)
|
| 38 |
+
else:
|
| 39 |
+
fcntl.flock(handle.fileno(), fcntl.LOCK_UN)
|
| 40 |
+
|
| 41 |
+
|
| 42 |
+
def atomic_write_text(path: Path, text: str) -> None:
|
| 43 |
+
"""Replace a file atomically after flushing its contents to disk."""
|
| 44 |
+
|
| 45 |
+
path.parent.mkdir(parents=True, exist_ok=True)
|
| 46 |
+
temporary = path.with_name(f"{path.name}.tmp")
|
| 47 |
+
with temporary.open("w", encoding="utf-8", newline="\n") as handle:
|
| 48 |
+
handle.write(text)
|
| 49 |
+
handle.flush()
|
| 50 |
+
os.fsync(handle.fileno())
|
| 51 |
+
os.replace(temporary, path)
|
| 52 |
+
|
| 53 |
+
|
| 54 |
+
def read_json(path: Path) -> dict[str, Any] | None:
|
| 55 |
+
"""Read a JSON object, returning None when the file is absent."""
|
| 56 |
+
|
| 57 |
+
if not path.exists():
|
| 58 |
+
return None
|
| 59 |
+
with path.open("r", encoding="utf-8") as handle:
|
| 60 |
+
value = json.load(handle)
|
| 61 |
+
if not isinstance(value, dict):
|
| 62 |
+
raise ValueError(f"{path.name} must contain a JSON object")
|
| 63 |
+
return value
|
| 64 |
+
|
| 65 |
+
|
| 66 |
+
def write_json(path: Path, value: dict[str, Any]) -> None:
|
| 67 |
+
"""Write a JSON object under a lock using atomic replacement."""
|
| 68 |
+
|
| 69 |
+
payload = json.dumps(value, ensure_ascii=False, indent=2) + "\n"
|
| 70 |
+
with file_lock(path):
|
| 71 |
+
atomic_write_text(path, payload)
|
| 72 |
+
|
| 73 |
+
|
| 74 |
+
def read_jsonl(path: Path) -> list[dict[str, Any]]:
|
| 75 |
+
"""Read non-empty JSONL records in file order."""
|
| 76 |
+
|
| 77 |
+
if not path.exists():
|
| 78 |
+
return []
|
| 79 |
+
records: list[dict[str, Any]] = []
|
| 80 |
+
with path.open("r", encoding="utf-8") as handle:
|
| 81 |
+
for line in handle:
|
| 82 |
+
if line.strip():
|
| 83 |
+
value = json.loads(line)
|
| 84 |
+
if not isinstance(value, dict):
|
| 85 |
+
raise ValueError(f"{path.name} contains a non-object record")
|
| 86 |
+
records.append(value)
|
| 87 |
+
return records
|
| 88 |
+
|
| 89 |
+
|
| 90 |
+
def append_jsonl(path: Path, value: dict[str, Any]) -> None:
|
| 91 |
+
"""Append one durable JSONL record while holding the file lock."""
|
| 92 |
+
|
| 93 |
+
path.parent.mkdir(parents=True, exist_ok=True)
|
| 94 |
+
line = json.dumps(value, ensure_ascii=False, separators=(",", ":")) + "\n"
|
| 95 |
+
with file_lock(path):
|
| 96 |
+
with path.open("a", encoding="utf-8", newline="\n") as handle:
|
| 97 |
+
handle.write(line)
|
| 98 |
+
handle.flush()
|
| 99 |
+
os.fsync(handle.fileno())
|
| 100 |
+
|
| 101 |
+
|
| 102 |
+
def rewrite_jsonl(path: Path, values: list[dict[str, Any]]) -> None:
|
| 103 |
+
"""Atomically replace a JSONL file while holding its lock."""
|
| 104 |
+
|
| 105 |
+
text = "".join(
|
| 106 |
+
json.dumps(value, ensure_ascii=False, separators=(",", ":")) + "\n"
|
| 107 |
+
for value in values
|
| 108 |
+
)
|
| 109 |
+
with file_lock(path):
|
| 110 |
+
atomic_write_text(path, text)
|
app/main.py
ADDED
|
@@ -0,0 +1,177 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Create and wire the Habit Journal FastAPI application.
|
| 2 |
+
|
| 3 |
+
The factory initializes durable paths, middleware, errors, and API routers.
|
| 4 |
+
"""
|
| 5 |
+
|
| 6 |
+
import logging
|
| 7 |
+
from collections.abc import AsyncIterator
|
| 8 |
+
from contextlib import asynccontextmanager
|
| 9 |
+
from pathlib import Path
|
| 10 |
+
from typing import Any
|
| 11 |
+
|
| 12 |
+
from fastapi import FastAPI, Request
|
| 13 |
+
from fastapi.exceptions import RequestValidationError
|
| 14 |
+
from fastapi.responses import JSONResponse
|
| 15 |
+
from fastapi.staticfiles import StaticFiles
|
| 16 |
+
from starlette.exceptions import HTTPException as StarletteHTTPException
|
| 17 |
+
from starlette.middleware.sessions import SessionMiddleware
|
| 18 |
+
from starlette.types import ASGIApp, Message, Receive, Scope, Send
|
| 19 |
+
|
| 20 |
+
from app.config import get_settings
|
| 21 |
+
from app.coach_service import CoachService
|
| 22 |
+
from app.models import err
|
| 23 |
+
from app.paths import Paths
|
| 24 |
+
from app.routers import auth, coach, daily, debug, entries, export, health, stats
|
| 25 |
+
from app.routers import settings as settings_router
|
| 26 |
+
from app.store_config import ConfigStore
|
| 27 |
+
from app.store_daily import DailyStore
|
| 28 |
+
from app.store_entries import EntryStore
|
| 29 |
+
from app.store_traces import TraceStore
|
| 30 |
+
|
| 31 |
+
logger = logging.getLogger(__name__)
|
| 32 |
+
|
| 33 |
+
|
| 34 |
+
class BodyTooLarge(Exception):
|
| 35 |
+
"""Signal that an HTTP request exceeded the configured byte limit."""
|
| 36 |
+
|
| 37 |
+
|
| 38 |
+
class BodySizeMiddleware:
|
| 39 |
+
"""Reject HTTP request bodies larger than the configured limit."""
|
| 40 |
+
|
| 41 |
+
def __init__(self, app: ASGIApp, max_bytes: int) -> None:
|
| 42 |
+
self.app = app
|
| 43 |
+
self.max_bytes = max_bytes
|
| 44 |
+
|
| 45 |
+
async def __call__(self, scope: Scope, receive: Receive, send: Send) -> None:
|
| 46 |
+
if scope["type"] != "http":
|
| 47 |
+
await self.app(scope, receive, send)
|
| 48 |
+
return
|
| 49 |
+
|
| 50 |
+
headers = dict(scope.get("headers", []))
|
| 51 |
+
raw_length = headers.get(b"content-length")
|
| 52 |
+
if raw_length:
|
| 53 |
+
try:
|
| 54 |
+
if int(raw_length) > self.max_bytes:
|
| 55 |
+
await self._reject(scope, receive, send)
|
| 56 |
+
return
|
| 57 |
+
except ValueError:
|
| 58 |
+
pass
|
| 59 |
+
|
| 60 |
+
size = 0
|
| 61 |
+
|
| 62 |
+
async def limited_receive() -> Message:
|
| 63 |
+
nonlocal size
|
| 64 |
+
message = await receive()
|
| 65 |
+
if message["type"] == "http.request":
|
| 66 |
+
size += len(message.get("body", b""))
|
| 67 |
+
if size > self.max_bytes:
|
| 68 |
+
raise BodyTooLarge
|
| 69 |
+
return message
|
| 70 |
+
|
| 71 |
+
try:
|
| 72 |
+
await self.app(scope, limited_receive, send)
|
| 73 |
+
except BodyTooLarge:
|
| 74 |
+
await self._reject(scope, receive, send)
|
| 75 |
+
|
| 76 |
+
async def _reject(self, scope: Scope, receive: Receive, send: Send) -> None:
|
| 77 |
+
response = err("validation_error", "Request body too large", 413)
|
| 78 |
+
await response(scope, receive, send)
|
| 79 |
+
|
| 80 |
+
|
| 81 |
+
def create_app() -> FastAPI:
|
| 82 |
+
"""Build an application from environment settings."""
|
| 83 |
+
|
| 84 |
+
settings = get_settings()
|
| 85 |
+
paths = Paths(settings.data_root)
|
| 86 |
+
paths.ensure()
|
| 87 |
+
config_store = ConfigStore(paths)
|
| 88 |
+
config_store.bootstrap(settings.app_password)
|
| 89 |
+
entry_store = EntryStore(paths)
|
| 90 |
+
daily_store = DailyStore(paths)
|
| 91 |
+
trace_store = TraceStore(paths, trace_limit=settings.coach_trace_limit)
|
| 92 |
+
trace_store.ensure_brief()
|
| 93 |
+
coach_service = CoachService(settings, entry_store, daily_store, trace_store)
|
| 94 |
+
|
| 95 |
+
@asynccontextmanager
|
| 96 |
+
async def lifespan(_app: FastAPI) -> AsyncIterator[None]:
|
| 97 |
+
paths.ensure()
|
| 98 |
+
trace_store.ensure_brief()
|
| 99 |
+
logger.info("Habit Journal backend ready")
|
| 100 |
+
yield
|
| 101 |
+
|
| 102 |
+
hidden_docs: dict[str, str | None] = {}
|
| 103 |
+
if settings.is_prod:
|
| 104 |
+
hidden_docs = {"docs_url": None, "redoc_url": None, "openapi_url": None}
|
| 105 |
+
|
| 106 |
+
app = FastAPI(title=settings.app_name, lifespan=lifespan, **hidden_docs)
|
| 107 |
+
app.state.settings = settings
|
| 108 |
+
app.state.paths = paths
|
| 109 |
+
app.state.config_store = config_store
|
| 110 |
+
app.state.entry_store = entry_store
|
| 111 |
+
app.state.daily_store = daily_store
|
| 112 |
+
app.state.trace_store = trace_store
|
| 113 |
+
app.state.coach_service = coach_service
|
| 114 |
+
|
| 115 |
+
app.add_middleware(BodySizeMiddleware, max_bytes=settings.max_body_bytes)
|
| 116 |
+
app.add_middleware(
|
| 117 |
+
SessionMiddleware,
|
| 118 |
+
secret_key=settings.app_secret_key,
|
| 119 |
+
max_age=settings.session_max_age_sec,
|
| 120 |
+
same_site="lax",
|
| 121 |
+
https_only=settings.is_prod,
|
| 122 |
+
)
|
| 123 |
+
|
| 124 |
+
@app.exception_handler(StarletteHTTPException)
|
| 125 |
+
async def http_exception_handler(
|
| 126 |
+
_request: Request,
|
| 127 |
+
exc: StarletteHTTPException,
|
| 128 |
+
) -> JSONResponse:
|
| 129 |
+
detail: Any = exc.detail
|
| 130 |
+
if isinstance(detail, dict):
|
| 131 |
+
code = detail.get("code", "internal")
|
| 132 |
+
message = detail.get("message", "Request failed")
|
| 133 |
+
else:
|
| 134 |
+
code = {
|
| 135 |
+
401: "unauthorized",
|
| 136 |
+
403: "forbidden",
|
| 137 |
+
404: "not_found",
|
| 138 |
+
422: "validation_error",
|
| 139 |
+
429: "rate_limited",
|
| 140 |
+
503: "setup_required",
|
| 141 |
+
}.get(exc.status_code, "internal")
|
| 142 |
+
message = "Request failed"
|
| 143 |
+
return err(code, message, exc.status_code)
|
| 144 |
+
|
| 145 |
+
@app.exception_handler(RequestValidationError)
|
| 146 |
+
async def validation_exception_handler(
|
| 147 |
+
_request: Request,
|
| 148 |
+
_exc: RequestValidationError,
|
| 149 |
+
) -> JSONResponse:
|
| 150 |
+
return err("validation_error", "Invalid request", 422)
|
| 151 |
+
|
| 152 |
+
@app.exception_handler(Exception)
|
| 153 |
+
async def internal_exception_handler(
|
| 154 |
+
_request: Request,
|
| 155 |
+
exc: Exception,
|
| 156 |
+
) -> JSONResponse:
|
| 157 |
+
logger.exception("Unhandled API error", exc_info=exc)
|
| 158 |
+
return err("internal", "Internal server error", 500)
|
| 159 |
+
|
| 160 |
+
app.include_router(health.router)
|
| 161 |
+
app.include_router(auth.router)
|
| 162 |
+
app.include_router(entries.router)
|
| 163 |
+
app.include_router(daily.router)
|
| 164 |
+
app.include_router(stats.router)
|
| 165 |
+
app.include_router(coach.router)
|
| 166 |
+
app.include_router(debug.router)
|
| 167 |
+
app.include_router(export.router)
|
| 168 |
+
app.include_router(settings_router.router)
|
| 169 |
+
|
| 170 |
+
static_dir = Path("static")
|
| 171 |
+
if static_dir.is_dir():
|
| 172 |
+
app.mount("/", StaticFiles(directory=static_dir, html=True), name="static")
|
| 173 |
+
|
| 174 |
+
return app
|
| 175 |
+
|
| 176 |
+
|
| 177 |
+
app = create_app()
|
app/models.py
ADDED
|
@@ -0,0 +1,382 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Define API request, response, and persisted configuration models.
|
| 2 |
+
|
| 3 |
+
Envelope helpers keep every endpoint response shape consistent.
|
| 4 |
+
"""
|
| 5 |
+
|
| 6 |
+
from datetime import date, datetime
|
| 7 |
+
from enum import Enum
|
| 8 |
+
from typing import Any, Literal
|
| 9 |
+
|
| 10 |
+
from fastapi.encoders import jsonable_encoder
|
| 11 |
+
from fastapi.responses import JSONResponse
|
| 12 |
+
from pydantic import BaseModel, ConfigDict, Field, field_validator
|
| 13 |
+
|
| 14 |
+
ErrorCode = Literal[
|
| 15 |
+
"unauthorized",
|
| 16 |
+
"forbidden",
|
| 17 |
+
"validation_error",
|
| 18 |
+
"not_found",
|
| 19 |
+
"setup_required",
|
| 20 |
+
"rate_limited",
|
| 21 |
+
"internal",
|
| 22 |
+
]
|
| 23 |
+
|
| 24 |
+
|
| 25 |
+
class ApiError(BaseModel):
|
| 26 |
+
"""Structured API failure details."""
|
| 27 |
+
|
| 28 |
+
code: ErrorCode
|
| 29 |
+
message: str
|
| 30 |
+
|
| 31 |
+
|
| 32 |
+
class ApiEnvelope(BaseModel):
|
| 33 |
+
"""Common success or failure response wrapper."""
|
| 34 |
+
|
| 35 |
+
ok: bool
|
| 36 |
+
data: Any | None
|
| 37 |
+
error: ApiError | None
|
| 38 |
+
|
| 39 |
+
|
| 40 |
+
class StoredConfig(BaseModel):
|
| 41 |
+
"""Durable password and session invalidation state."""
|
| 42 |
+
|
| 43 |
+
model_config = ConfigDict(extra="forbid")
|
| 44 |
+
|
| 45 |
+
password_hash: str
|
| 46 |
+
session_version: int = Field(ge=1)
|
| 47 |
+
created_at: datetime
|
| 48 |
+
updated_at: datetime
|
| 49 |
+
|
| 50 |
+
|
| 51 |
+
class LoginRequest(BaseModel):
|
| 52 |
+
"""Password login payload."""
|
| 53 |
+
|
| 54 |
+
password: str = Field(min_length=1)
|
| 55 |
+
|
| 56 |
+
|
| 57 |
+
class ChangePasswordRequest(BaseModel):
|
| 58 |
+
"""Authenticated password rotation payload."""
|
| 59 |
+
|
| 60 |
+
old_password: str = Field(min_length=1)
|
| 61 |
+
new_password: str = Field(min_length=1)
|
| 62 |
+
|
| 63 |
+
|
| 64 |
+
class AuthenticatedData(BaseModel):
|
| 65 |
+
"""Authentication state returned after a session mutation."""
|
| 66 |
+
|
| 67 |
+
authenticated: bool
|
| 68 |
+
|
| 69 |
+
|
| 70 |
+
class MeData(AuthenticatedData):
|
| 71 |
+
"""Public session status and discrete application name."""
|
| 72 |
+
|
| 73 |
+
app_name: str
|
| 74 |
+
|
| 75 |
+
|
| 76 |
+
class HealthData(BaseModel):
|
| 77 |
+
"""Liveness information."""
|
| 78 |
+
|
| 79 |
+
status: Literal["up"]
|
| 80 |
+
time: datetime
|
| 81 |
+
|
| 82 |
+
|
| 83 |
+
class ReadyData(BaseModel):
|
| 84 |
+
"""Bootstrap and storage readiness information."""
|
| 85 |
+
|
| 86 |
+
ready: bool
|
| 87 |
+
data_writable: bool
|
| 88 |
+
config_present: bool
|
| 89 |
+
|
| 90 |
+
|
| 91 |
+
class Result(str, Enum):
|
| 92 |
+
"""Outcome of a logged remedy; `pending` is excluded from stats."""
|
| 93 |
+
|
| 94 |
+
worked = "worked"
|
| 95 |
+
partial = "partial"
|
| 96 |
+
failed = "failed"
|
| 97 |
+
pending = "pending"
|
| 98 |
+
|
| 99 |
+
|
| 100 |
+
def normalize_emotions(values: list[str]) -> list[str]:
|
| 101 |
+
"""Lowercase, strip, drop blanks, and de-duplicate emotion labels."""
|
| 102 |
+
|
| 103 |
+
out: list[str] = []
|
| 104 |
+
seen: set[str] = set()
|
| 105 |
+
for value in values:
|
| 106 |
+
token = value.strip().lower()
|
| 107 |
+
if token and token not in seen:
|
| 108 |
+
seen.add(token)
|
| 109 |
+
out.append(token)
|
| 110 |
+
return out
|
| 111 |
+
|
| 112 |
+
|
| 113 |
+
def normalize_tags(values: list[str]) -> list[str]:
|
| 114 |
+
"""Lowercase, strip, collapse spaces to underscores, and de-duplicate."""
|
| 115 |
+
|
| 116 |
+
out: list[str] = []
|
| 117 |
+
seen: set[str] = set()
|
| 118 |
+
for value in values:
|
| 119 |
+
token = "_".join(value.strip().lower().split())
|
| 120 |
+
if token and token not in seen:
|
| 121 |
+
seen.add(token)
|
| 122 |
+
out.append(token)
|
| 123 |
+
return out
|
| 124 |
+
|
| 125 |
+
|
| 126 |
+
class CoachMeta(BaseModel):
|
| 127 |
+
"""Coach reply metadata persisted on an entry."""
|
| 128 |
+
|
| 129 |
+
text: str | None = None
|
| 130 |
+
source: str | None = None
|
| 131 |
+
model: str | None = None
|
| 132 |
+
ts: datetime | None = None
|
| 133 |
+
trace_id: str | None = None
|
| 134 |
+
|
| 135 |
+
|
| 136 |
+
class EntryCreate(BaseModel):
|
| 137 |
+
"""Validated payload for creating a log entry."""
|
| 138 |
+
|
| 139 |
+
model_config = ConfigDict(extra="forbid")
|
| 140 |
+
|
| 141 |
+
activity: str = Field(min_length=1, max_length=500)
|
| 142 |
+
happened: str = Field(min_length=1, max_length=4000)
|
| 143 |
+
emotions: list[str] = Field(default_factory=list, max_length=12)
|
| 144 |
+
intensity: int = Field(ge=1, le=10)
|
| 145 |
+
remedy: str = Field(default="", max_length=2000)
|
| 146 |
+
result: Result
|
| 147 |
+
tags: list[str] = Field(default_factory=list, max_length=20)
|
| 148 |
+
notes: str = Field(default="", max_length=2000)
|
| 149 |
+
ts: datetime | None = None
|
| 150 |
+
|
| 151 |
+
@field_validator("activity", "happened", mode="after")
|
| 152 |
+
@classmethod
|
| 153 |
+
def _strip_required(cls, value: str) -> str:
|
| 154 |
+
stripped = value.strip()
|
| 155 |
+
if not stripped:
|
| 156 |
+
raise ValueError("must not be blank")
|
| 157 |
+
return stripped
|
| 158 |
+
|
| 159 |
+
@field_validator("remedy", "notes", mode="after")
|
| 160 |
+
@classmethod
|
| 161 |
+
def _strip_optional(cls, value: str) -> str:
|
| 162 |
+
return value.strip()
|
| 163 |
+
|
| 164 |
+
@field_validator("emotions", mode="after")
|
| 165 |
+
@classmethod
|
| 166 |
+
def _clean_emotions(cls, value: list[str]) -> list[str]:
|
| 167 |
+
return normalize_emotions(value)
|
| 168 |
+
|
| 169 |
+
@field_validator("tags", mode="after")
|
| 170 |
+
@classmethod
|
| 171 |
+
def _clean_tags(cls, value: list[str]) -> list[str]:
|
| 172 |
+
return normalize_tags(value)
|
| 173 |
+
|
| 174 |
+
|
| 175 |
+
class EntryUpdate(BaseModel):
|
| 176 |
+
"""Partial update payload; only provided fields are applied."""
|
| 177 |
+
|
| 178 |
+
model_config = ConfigDict(extra="forbid")
|
| 179 |
+
|
| 180 |
+
activity: str | None = Field(default=None, min_length=1, max_length=500)
|
| 181 |
+
happened: str | None = Field(default=None, min_length=1, max_length=4000)
|
| 182 |
+
emotions: list[str] | None = Field(default=None, max_length=12)
|
| 183 |
+
intensity: int | None = Field(default=None, ge=1, le=10)
|
| 184 |
+
remedy: str | None = Field(default=None, max_length=2000)
|
| 185 |
+
result: Result | None = None
|
| 186 |
+
tags: list[str] | None = Field(default=None, max_length=20)
|
| 187 |
+
notes: str | None = Field(default=None, max_length=2000)
|
| 188 |
+
ts: datetime | None = None
|
| 189 |
+
|
| 190 |
+
@field_validator("activity", "happened", mode="after")
|
| 191 |
+
@classmethod
|
| 192 |
+
def _strip_required(cls, value: str | None) -> str | None:
|
| 193 |
+
if value is None:
|
| 194 |
+
return None
|
| 195 |
+
stripped = value.strip()
|
| 196 |
+
if not stripped:
|
| 197 |
+
raise ValueError("must not be blank")
|
| 198 |
+
return stripped
|
| 199 |
+
|
| 200 |
+
@field_validator("remedy", "notes", mode="after")
|
| 201 |
+
@classmethod
|
| 202 |
+
def _strip_optional(cls, value: str | None) -> str | None:
|
| 203 |
+
return value.strip() if value is not None else None
|
| 204 |
+
|
| 205 |
+
@field_validator("emotions", mode="after")
|
| 206 |
+
@classmethod
|
| 207 |
+
def _clean_emotions(cls, value: list[str] | None) -> list[str] | None:
|
| 208 |
+
return normalize_emotions(value) if value is not None else None
|
| 209 |
+
|
| 210 |
+
@field_validator("tags", mode="after")
|
| 211 |
+
@classmethod
|
| 212 |
+
def _clean_tags(cls, value: list[str] | None) -> list[str] | None:
|
| 213 |
+
return normalize_tags(value) if value is not None else None
|
| 214 |
+
|
| 215 |
+
|
| 216 |
+
class Entry(BaseModel):
|
| 217 |
+
"""A persisted log entry as stored on one JSONL line."""
|
| 218 |
+
|
| 219 |
+
model_config = ConfigDict(extra="ignore")
|
| 220 |
+
|
| 221 |
+
id: str
|
| 222 |
+
ts: datetime
|
| 223 |
+
created_at: datetime
|
| 224 |
+
updated_at: datetime
|
| 225 |
+
activity: str
|
| 226 |
+
happened: str
|
| 227 |
+
emotions: list[str] = Field(default_factory=list)
|
| 228 |
+
intensity: int = Field(ge=1, le=10)
|
| 229 |
+
remedy: str = ""
|
| 230 |
+
result: Result
|
| 231 |
+
tags: list[str] = Field(default_factory=list)
|
| 232 |
+
notes: str = ""
|
| 233 |
+
coach: CoachMeta = Field(default_factory=CoachMeta)
|
| 234 |
+
|
| 235 |
+
|
| 236 |
+
class EntryListData(BaseModel):
|
| 237 |
+
"""Paginated entry listing with total match count."""
|
| 238 |
+
|
| 239 |
+
items: list[Entry]
|
| 240 |
+
total: int
|
| 241 |
+
limit: int
|
| 242 |
+
offset: int
|
| 243 |
+
|
| 244 |
+
|
| 245 |
+
class DeletedData(BaseModel):
|
| 246 |
+
"""Confirmation payload for a hard delete."""
|
| 247 |
+
|
| 248 |
+
deleted: bool
|
| 249 |
+
id: str
|
| 250 |
+
|
| 251 |
+
|
| 252 |
+
class PrimaryBrick(str, Enum):
|
| 253 |
+
"""Daily primary brick label."""
|
| 254 |
+
|
| 255 |
+
A = "A"
|
| 256 |
+
B = "B"
|
| 257 |
+
C = "C"
|
| 258 |
+
D = "D"
|
| 259 |
+
E = "E"
|
| 260 |
+
S = "S"
|
| 261 |
+
none = "none"
|
| 262 |
+
|
| 263 |
+
|
| 264 |
+
class Daydream(str, Enum):
|
| 265 |
+
"""Daily daydream status."""
|
| 266 |
+
|
| 267 |
+
none = "none"
|
| 268 |
+
done = "done"
|
| 269 |
+
fc = "fc"
|
| 270 |
+
|
| 271 |
+
|
| 272 |
+
class Rerun(str, Enum):
|
| 273 |
+
"""Daily rerun status."""
|
| 274 |
+
|
| 275 |
+
clean = "clean"
|
| 276 |
+
R = "R"
|
| 277 |
+
|
| 278 |
+
|
| 279 |
+
class Court(str, Enum):
|
| 280 |
+
"""Daily court status."""
|
| 281 |
+
|
| 282 |
+
closed = "closed"
|
| 283 |
+
court = "court"
|
| 284 |
+
|
| 285 |
+
|
| 286 |
+
class DailyUpsert(BaseModel):
|
| 287 |
+
"""Client payload for upserting a daily scoreboard row."""
|
| 288 |
+
|
| 289 |
+
model_config = ConfigDict(extra="forbid")
|
| 290 |
+
|
| 291 |
+
primary_brick: PrimaryBrick = PrimaryBrick.none
|
| 292 |
+
brick_done: bool = False
|
| 293 |
+
corn_sessions: int = Field(default=0, ge=0, le=50)
|
| 294 |
+
delay_ok: bool = True
|
| 295 |
+
daydream: Daydream = Daydream.none
|
| 296 |
+
rerun: Rerun = Rerun.clean
|
| 297 |
+
court: Court = Court.closed
|
| 298 |
+
note: str = Field(default="", max_length=2000)
|
| 299 |
+
|
| 300 |
+
@field_validator("note", mode="after")
|
| 301 |
+
@classmethod
|
| 302 |
+
def _strip_note(cls, value: str) -> str:
|
| 303 |
+
return value.strip()
|
| 304 |
+
|
| 305 |
+
|
| 306 |
+
class DailyRow(BaseModel):
|
| 307 |
+
"""A persisted daily scoreboard row; points are server-authoritative."""
|
| 308 |
+
|
| 309 |
+
model_config = ConfigDict(extra="ignore")
|
| 310 |
+
|
| 311 |
+
date: date
|
| 312 |
+
primary_brick: PrimaryBrick = PrimaryBrick.none
|
| 313 |
+
brick_done: bool = False
|
| 314 |
+
corn_sessions: int = Field(default=0, ge=0, le=50)
|
| 315 |
+
delay_ok: bool = True
|
| 316 |
+
daydream: Daydream = Daydream.none
|
| 317 |
+
rerun: Rerun = Rerun.clean
|
| 318 |
+
court: Court = Court.closed
|
| 319 |
+
points: int = Field(ge=0, le=6)
|
| 320 |
+
note: str = ""
|
| 321 |
+
updated_at: datetime
|
| 322 |
+
|
| 323 |
+
|
| 324 |
+
class DailyRangeData(BaseModel):
|
| 325 |
+
"""Daily rows for a date range plus week band summary."""
|
| 326 |
+
|
| 327 |
+
items: list[DailyRow]
|
| 328 |
+
week_points: int
|
| 329 |
+
band: Literal["incomplete", "strong", "mixed", "escape_heavy"]
|
| 330 |
+
days_present: int
|
| 331 |
+
|
| 332 |
+
|
| 333 |
+
class CoachRequest(BaseModel):
|
| 334 |
+
"""Coach request: free text and/or entry id."""
|
| 335 |
+
|
| 336 |
+
model_config = ConfigDict(extra="forbid")
|
| 337 |
+
|
| 338 |
+
text: str | None = None
|
| 339 |
+
entry_id: str | None = None
|
| 340 |
+
include_history: int | None = Field(default=None, ge=0, le=50)
|
| 341 |
+
persist: bool = False
|
| 342 |
+
force_backup: bool = False
|
| 343 |
+
|
| 344 |
+
|
| 345 |
+
class CoachResponseData(BaseModel):
|
| 346 |
+
"""Coach reply always includes text and source."""
|
| 347 |
+
|
| 348 |
+
text: str
|
| 349 |
+
source: Literal["model", "backup", "model_unparsed_fallback"]
|
| 350 |
+
model: str | None = None
|
| 351 |
+
trace_id: str
|
| 352 |
+
flags: list[str] = Field(default_factory=list)
|
| 353 |
+
server_picks: list[dict[str, Any]] = Field(default_factory=list)
|
| 354 |
+
parsed: dict[str, str] | None = None
|
| 355 |
+
|
| 356 |
+
|
| 357 |
+
class SettingsStatusData(BaseModel):
|
| 358 |
+
"""Authenticated settings status without secrets."""
|
| 359 |
+
|
| 360 |
+
app_name: str
|
| 361 |
+
app_version: str
|
| 362 |
+
coach_configured: bool
|
| 363 |
+
model: str
|
| 364 |
+
data_ok: bool
|
| 365 |
+
env: str
|
| 366 |
+
|
| 367 |
+
|
| 368 |
+
def ok(data: BaseModel | dict[str, Any] | list[Any]) -> dict[str, Any]:
|
| 369 |
+
"""Build a JSON-serializable success envelope."""
|
| 370 |
+
|
| 371 |
+
return jsonable_encoder(ApiEnvelope(ok=True, data=data, error=None))
|
| 372 |
+
|
| 373 |
+
|
| 374 |
+
def err(code: ErrorCode, message: str, status_code: int) -> JSONResponse:
|
| 375 |
+
"""Build a consistent failure response."""
|
| 376 |
+
|
| 377 |
+
payload = ApiEnvelope(
|
| 378 |
+
ok=False,
|
| 379 |
+
data=None,
|
| 380 |
+
error=ApiError(code=code, message=message),
|
| 381 |
+
)
|
| 382 |
+
return JSONResponse(status_code=status_code, content=jsonable_encoder(payload))
|
app/paste_bundle.py
ADDED
|
@@ -0,0 +1,75 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Format context-free coach debug paste bundles for external AI review.
|
| 2 |
+
|
| 3 |
+
Bundles include formulas, evidence, prompts, raw output, and checklist.
|
| 4 |
+
"""
|
| 5 |
+
|
| 6 |
+
from __future__ import annotations
|
| 7 |
+
|
| 8 |
+
import json
|
| 9 |
+
from typing import Any
|
| 10 |
+
|
| 11 |
+
|
| 12 |
+
def render_paste_bundle(trace: dict[str, Any], *, app_name: str, shrink_k: float, match_alpha: float, min_n: int) -> str:
|
| 13 |
+
"""Render the exact markdown paste bundle from a stored trace."""
|
| 14 |
+
|
| 15 |
+
evidence = trace.get("evidence") or {}
|
| 16 |
+
evidence_block = evidence.get("evidence_block") or json.dumps(evidence, indent=2)
|
| 17 |
+
picks = json.dumps(trace.get("server_picks") or [], indent=2, ensure_ascii=False)
|
| 18 |
+
current = json.dumps(trace.get("current") or {}, indent=2, ensure_ascii=False)
|
| 19 |
+
history = json.dumps(trace.get("history_truncated") or [], indent=2, ensure_ascii=False)
|
| 20 |
+
parsed = json.dumps(trace.get("parsed"), indent=2, ensure_ascii=False)
|
| 21 |
+
return f"""# Coach Trace Paste Bundle (context-free)
|
| 22 |
+
app: {app_name} | version: {trace.get('app_version')} | trace_id: {trace.get('trace_id')} | ts: {trace.get('ts')}
|
| 23 |
+
|
| 24 |
+
## How to help
|
| 25 |
+
You have NO prior context about the user. Use ONLY this bundle.
|
| 26 |
+
Do not ask for biography. Critique prompts, math, backup rules, and free-model fitness.
|
| 27 |
+
Output: (1) findings (2) concrete patch list for backend prompts/rules/math weights.
|
| 28 |
+
|
| 29 |
+
## Math definitions (server authoritative)
|
| 30 |
+
- pending excluded from denominators
|
| 31 |
+
- p_worked(r) = N(worked,r) / N(r)
|
| 32 |
+
- p_helped(r) = N(worked|partial,r) / N(r)
|
| 33 |
+
- rank(r) = p_helped * n/(n+k) with k={shrink_k}
|
| 34 |
+
- pick(r) = rank * (1 + alpha * match) with alpha={match_alpha}
|
| 35 |
+
- min_n = {min_n}
|
| 36 |
+
- DATA_THIN if n_scored < 10
|
| 37 |
+
|
| 38 |
+
## Server evidence
|
| 39 |
+
{evidence_block}
|
| 40 |
+
|
| 41 |
+
## Server picks
|
| 42 |
+
{picks}
|
| 43 |
+
|
| 44 |
+
## Current situation
|
| 45 |
+
{current}
|
| 46 |
+
|
| 47 |
+
## Recent history (truncated)
|
| 48 |
+
{history}
|
| 49 |
+
|
| 50 |
+
## System prompt
|
| 51 |
+
{trace.get('system_prompt') or ''}
|
| 52 |
+
|
| 53 |
+
## User prompt
|
| 54 |
+
{trace.get('user_prompt') or ''}
|
| 55 |
+
|
| 56 |
+
## Model raw response
|
| 57 |
+
{trace.get('raw_model_response') or ''}
|
| 58 |
+
|
| 59 |
+
## Parse / source / flags
|
| 60 |
+
source: {trace.get('source')}
|
| 61 |
+
backup_rule_id: {trace.get('backup_rule_id')}
|
| 62 |
+
flags: {trace.get('flags')}
|
| 63 |
+
parsed: {parsed}
|
| 64 |
+
|
| 65 |
+
## Final text shown to user
|
| 66 |
+
{trace.get('final_text') or ''}
|
| 67 |
+
|
| 68 |
+
## Reviewer checklist
|
| 69 |
+
1. Invented numbers?
|
| 70 |
+
2. Ignored SERVER_PICKS?
|
| 71 |
+
3. Format broken (free model)?
|
| 72 |
+
4. Backup better?
|
| 73 |
+
5. Evidence too long/short?
|
| 74 |
+
6. Shrinkage k / alpha tweak?
|
| 75 |
+
"""
|
app/paths.py
ADDED
|
@@ -0,0 +1,44 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Define and initialize all durable filesystem paths.
|
| 2 |
+
|
| 3 |
+
Only this module decides where application data files live.
|
| 4 |
+
"""
|
| 5 |
+
|
| 6 |
+
from dataclasses import dataclass
|
| 7 |
+
from pathlib import Path
|
| 8 |
+
|
| 9 |
+
|
| 10 |
+
@dataclass(frozen=True)
|
| 11 |
+
class Paths:
|
| 12 |
+
"""Concrete files rooted below the configured data directory."""
|
| 13 |
+
|
| 14 |
+
root: Path
|
| 15 |
+
|
| 16 |
+
def __init__(self, root: str | Path) -> None:
|
| 17 |
+
object.__setattr__(self, "root", Path(root))
|
| 18 |
+
|
| 19 |
+
@property
|
| 20 |
+
def config(self) -> Path:
|
| 21 |
+
return self.root / "config.json"
|
| 22 |
+
|
| 23 |
+
@property
|
| 24 |
+
def entries(self) -> Path:
|
| 25 |
+
return self.root / "entries.jsonl"
|
| 26 |
+
|
| 27 |
+
@property
|
| 28 |
+
def daily(self) -> Path:
|
| 29 |
+
return self.root / "daily.jsonl"
|
| 30 |
+
|
| 31 |
+
@property
|
| 32 |
+
def traces(self) -> Path:
|
| 33 |
+
return self.root / "traces.jsonl"
|
| 34 |
+
|
| 35 |
+
@property
|
| 36 |
+
def coach_brief(self) -> Path:
|
| 37 |
+
return self.root / "coach_brief.md"
|
| 38 |
+
|
| 39 |
+
def ensure(self) -> None:
|
| 40 |
+
"""Create the data directory and initial empty JSONL files."""
|
| 41 |
+
|
| 42 |
+
self.root.mkdir(parents=True, exist_ok=True)
|
| 43 |
+
for path in (self.entries, self.daily, self.traces):
|
| 44 |
+
path.touch(exist_ok=True)
|
app/routers/__init__.py
ADDED
|
@@ -0,0 +1 @@
|
|
|
|
|
|
|
| 1 |
+
"""FastAPI route modules for the Habit Journal API."""
|
app/routers/auth.py
ADDED
|
@@ -0,0 +1,160 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Implement password login and signed-session lifecycle routes.
|
| 2 |
+
|
| 3 |
+
Login attempts are rate-limited in memory by the direct client address.
|
| 4 |
+
"""
|
| 5 |
+
|
| 6 |
+
import time
|
| 7 |
+
from collections import defaultdict, deque
|
| 8 |
+
from threading import Lock
|
| 9 |
+
|
| 10 |
+
from fastapi import APIRouter, Depends, Request, status
|
| 11 |
+
|
| 12 |
+
from app.deps import require_login
|
| 13 |
+
from app.models import (
|
| 14 |
+
ApiEnvelope,
|
| 15 |
+
AuthenticatedData,
|
| 16 |
+
ChangePasswordRequest,
|
| 17 |
+
LoginRequest,
|
| 18 |
+
MeData,
|
| 19 |
+
StoredConfig,
|
| 20 |
+
err,
|
| 21 |
+
ok,
|
| 22 |
+
)
|
| 23 |
+
|
| 24 |
+
router = APIRouter(prefix="/api/auth", tags=["auth"])
|
| 25 |
+
_attempts: dict[str, deque[float]] = defaultdict(deque)
|
| 26 |
+
_attempts_lock = Lock()
|
| 27 |
+
|
| 28 |
+
|
| 29 |
+
def _client_key(request: Request) -> str:
|
| 30 |
+
return request.client.host if request.client else "unknown"
|
| 31 |
+
|
| 32 |
+
|
| 33 |
+
def _rate_limited(request: Request) -> bool:
|
| 34 |
+
settings = request.app.state.settings
|
| 35 |
+
now = time.monotonic()
|
| 36 |
+
cutoff = now - settings.login_rate_window_sec
|
| 37 |
+
key = _client_key(request)
|
| 38 |
+
with _attempts_lock:
|
| 39 |
+
attempts = _attempts[key]
|
| 40 |
+
while attempts and attempts[0] <= cutoff:
|
| 41 |
+
attempts.popleft()
|
| 42 |
+
if len(attempts) >= settings.login_rate_limit:
|
| 43 |
+
return True
|
| 44 |
+
attempts.append(now)
|
| 45 |
+
return False
|
| 46 |
+
|
| 47 |
+
|
| 48 |
+
def _clear_attempts(request: Request) -> None:
|
| 49 |
+
with _attempts_lock:
|
| 50 |
+
_attempts.pop(_client_key(request), None)
|
| 51 |
+
|
| 52 |
+
|
| 53 |
+
def _set_session(request: Request, config: StoredConfig) -> None:
|
| 54 |
+
max_age = request.app.state.settings.session_max_age_sec
|
| 55 |
+
request.session.clear()
|
| 56 |
+
request.session.update(
|
| 57 |
+
{
|
| 58 |
+
"auth": True,
|
| 59 |
+
"sv": config.session_version,
|
| 60 |
+
"exp": int(time.time()) + max_age,
|
| 61 |
+
}
|
| 62 |
+
)
|
| 63 |
+
|
| 64 |
+
|
| 65 |
+
def _session_is_current(request: Request, config: StoredConfig | None) -> bool:
|
| 66 |
+
if config is None:
|
| 67 |
+
return False
|
| 68 |
+
session = request.session
|
| 69 |
+
expiry = session.get("exp")
|
| 70 |
+
return (
|
| 71 |
+
session.get("auth") is True
|
| 72 |
+
and session.get("sv") == config.session_version
|
| 73 |
+
and isinstance(expiry, (int, float))
|
| 74 |
+
and expiry >= time.time()
|
| 75 |
+
)
|
| 76 |
+
|
| 77 |
+
|
| 78 |
+
@router.post("/login", response_model=ApiEnvelope)
|
| 79 |
+
def login(request: Request, body: LoginRequest) -> object:
|
| 80 |
+
"""Authenticate and establish a signed cookie session."""
|
| 81 |
+
|
| 82 |
+
config = request.app.state.config_store.load()
|
| 83 |
+
if config is None:
|
| 84 |
+
return err(
|
| 85 |
+
"setup_required",
|
| 86 |
+
"Application setup is required",
|
| 87 |
+
status.HTTP_503_SERVICE_UNAVAILABLE,
|
| 88 |
+
)
|
| 89 |
+
if _rate_limited(request):
|
| 90 |
+
return err(
|
| 91 |
+
"rate_limited",
|
| 92 |
+
"Too many login attempts",
|
| 93 |
+
status.HTTP_429_TOO_MANY_REQUESTS,
|
| 94 |
+
)
|
| 95 |
+
if not request.app.state.config_store.password_matches(body.password):
|
| 96 |
+
return err(
|
| 97 |
+
"unauthorized",
|
| 98 |
+
"Invalid credentials",
|
| 99 |
+
status.HTTP_401_UNAUTHORIZED,
|
| 100 |
+
)
|
| 101 |
+
|
| 102 |
+
_clear_attempts(request)
|
| 103 |
+
_set_session(request, config)
|
| 104 |
+
return ok(AuthenticatedData(authenticated=True))
|
| 105 |
+
|
| 106 |
+
|
| 107 |
+
@router.post("/logout", response_model=ApiEnvelope)
|
| 108 |
+
def logout(
|
| 109 |
+
request: Request,
|
| 110 |
+
_config: StoredConfig = Depends(require_login),
|
| 111 |
+
) -> dict[str, object]:
|
| 112 |
+
"""Clear the current signed session."""
|
| 113 |
+
|
| 114 |
+
request.session.clear()
|
| 115 |
+
return ok(AuthenticatedData(authenticated=False))
|
| 116 |
+
|
| 117 |
+
|
| 118 |
+
@router.get("/me", response_model=ApiEnvelope)
|
| 119 |
+
def me(request: Request) -> dict[str, object]:
|
| 120 |
+
"""Return public app identity and current session state."""
|
| 121 |
+
|
| 122 |
+
config = request.app.state.config_store.load()
|
| 123 |
+
authenticated = _session_is_current(request, config)
|
| 124 |
+
if not authenticated:
|
| 125 |
+
request.session.clear()
|
| 126 |
+
return ok(
|
| 127 |
+
MeData(
|
| 128 |
+
authenticated=authenticated,
|
| 129 |
+
app_name=request.app.state.settings.app_name,
|
| 130 |
+
)
|
| 131 |
+
)
|
| 132 |
+
|
| 133 |
+
|
| 134 |
+
@router.post("/change-password", response_model=ApiEnvelope)
|
| 135 |
+
def change_password(
|
| 136 |
+
request: Request,
|
| 137 |
+
body: ChangePasswordRequest,
|
| 138 |
+
_config: StoredConfig = Depends(require_login),
|
| 139 |
+
) -> object:
|
| 140 |
+
"""Rotate the shared password and invalidate older sessions."""
|
| 141 |
+
|
| 142 |
+
changed = request.app.state.config_store.change_password(
|
| 143 |
+
body.old_password,
|
| 144 |
+
body.new_password,
|
| 145 |
+
)
|
| 146 |
+
if not changed:
|
| 147 |
+
return err(
|
| 148 |
+
"unauthorized",
|
| 149 |
+
"Invalid credentials",
|
| 150 |
+
status.HTTP_401_UNAUTHORIZED,
|
| 151 |
+
)
|
| 152 |
+
config = request.app.state.config_store.load()
|
| 153 |
+
if config is None:
|
| 154 |
+
return err(
|
| 155 |
+
"internal",
|
| 156 |
+
"Configuration unavailable",
|
| 157 |
+
status.HTTP_500_INTERNAL_SERVER_ERROR,
|
| 158 |
+
)
|
| 159 |
+
_set_session(request, config)
|
| 160 |
+
return ok(AuthenticatedData(authenticated=True))
|
app/routers/coach.py
ADDED
|
@@ -0,0 +1,52 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Coach endpoints: free-text coach and per-entry coach with optional persist.
|
| 2 |
+
|
| 3 |
+
Always returns 200 with backup text when the model is unavailable.
|
| 4 |
+
"""
|
| 5 |
+
|
| 6 |
+
from fastapi import APIRouter, Depends, Request, status
|
| 7 |
+
|
| 8 |
+
from app.deps import require_login
|
| 9 |
+
from app.models import ApiEnvelope, CoachRequest, CoachResponseData, err, ok
|
| 10 |
+
|
| 11 |
+
router = APIRouter(prefix="/api", tags=["coach"], dependencies=[Depends(require_login)])
|
| 12 |
+
|
| 13 |
+
|
| 14 |
+
def _run_coach(request: Request, body: CoachRequest, *, default_persist: bool) -> object:
|
| 15 |
+
if not body.text and not body.entry_id:
|
| 16 |
+
return err(
|
| 17 |
+
"validation_error",
|
| 18 |
+
"Provide text or entry_id",
|
| 19 |
+
status.HTTP_422_UNPROCESSABLE_ENTITY,
|
| 20 |
+
)
|
| 21 |
+
persist = (
|
| 22 |
+
body.persist if "persist" in body.model_fields_set else default_persist
|
| 23 |
+
)
|
| 24 |
+
try:
|
| 25 |
+
result = request.app.state.coach_service.coach(
|
| 26 |
+
text=body.text,
|
| 27 |
+
entry_id=body.entry_id,
|
| 28 |
+
include_history=body.include_history,
|
| 29 |
+
persist=persist,
|
| 30 |
+
force_backup=body.force_backup,
|
| 31 |
+
)
|
| 32 |
+
except KeyError:
|
| 33 |
+
return err("not_found", "Entry not found", status.HTTP_404_NOT_FOUND)
|
| 34 |
+
except ValueError as exc:
|
| 35 |
+
return err("validation_error", str(exc), status.HTTP_422_UNPROCESSABLE_ENTITY)
|
| 36 |
+
return ok(CoachResponseData.model_validate(result))
|
| 37 |
+
|
| 38 |
+
|
| 39 |
+
@router.post("/coach", response_model=ApiEnvelope)
|
| 40 |
+
def coach(request: Request, body: CoachRequest) -> object:
|
| 41 |
+
"""Run coach for free text or an entry; persist defaults to false."""
|
| 42 |
+
|
| 43 |
+
return _run_coach(request, body, default_persist=False)
|
| 44 |
+
|
| 45 |
+
|
| 46 |
+
@router.post("/entries/{entry_id}/coach", response_model=ApiEnvelope)
|
| 47 |
+
def entry_coach(request: Request, entry_id: str, body: CoachRequest | None = None) -> object:
|
| 48 |
+
"""Run coach for one entry; persist defaults to true."""
|
| 49 |
+
|
| 50 |
+
payload = body or CoachRequest()
|
| 51 |
+
merged = payload.model_copy(update={"entry_id": entry_id})
|
| 52 |
+
return _run_coach(request, merged, default_persist=True)
|
app/routers/daily.py
ADDED
|
@@ -0,0 +1,63 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Expose authenticated daily scoreboard upsert and range queries.
|
| 2 |
+
|
| 3 |
+
Points and week band are always computed on the server.
|
| 4 |
+
"""
|
| 5 |
+
|
| 6 |
+
from datetime import date
|
| 7 |
+
|
| 8 |
+
from fastapi import APIRouter, Depends, Query, Request, status
|
| 9 |
+
|
| 10 |
+
from app.deps import require_login
|
| 11 |
+
from app.models import ApiEnvelope, DailyRangeData, DailyUpsert, err, ok
|
| 12 |
+
|
| 13 |
+
router = APIRouter(
|
| 14 |
+
prefix="/api/daily",
|
| 15 |
+
tags=["daily"],
|
| 16 |
+
dependencies=[Depends(require_login)],
|
| 17 |
+
)
|
| 18 |
+
|
| 19 |
+
|
| 20 |
+
def _store(request: Request):
|
| 21 |
+
return request.app.state.daily_store
|
| 22 |
+
|
| 23 |
+
|
| 24 |
+
@router.put("/{day}", response_model=ApiEnvelope)
|
| 25 |
+
def upsert_daily(request: Request, day: date, body: DailyUpsert) -> dict[str, object]:
|
| 26 |
+
"""Upsert one daily row and recompute points."""
|
| 27 |
+
|
| 28 |
+
return ok(_store(request).upsert(day, body))
|
| 29 |
+
|
| 30 |
+
|
| 31 |
+
@router.get("/{day}", response_model=ApiEnvelope)
|
| 32 |
+
def get_daily(request: Request, day: date) -> object:
|
| 33 |
+
"""Fetch one daily row by date."""
|
| 34 |
+
|
| 35 |
+
row = _store(request).get(day)
|
| 36 |
+
if row is None:
|
| 37 |
+
return err("not_found", "Daily row not found", status.HTTP_404_NOT_FOUND)
|
| 38 |
+
return ok(row)
|
| 39 |
+
|
| 40 |
+
|
| 41 |
+
@router.get("", response_model=ApiEnvelope)
|
| 42 |
+
def list_daily(
|
| 43 |
+
request: Request,
|
| 44 |
+
start: date = Query(...),
|
| 45 |
+
end: date = Query(...),
|
| 46 |
+
) -> object:
|
| 47 |
+
"""List daily rows in a date range with week band summary."""
|
| 48 |
+
|
| 49 |
+
if end < start:
|
| 50 |
+
return err(
|
| 51 |
+
"validation_error",
|
| 52 |
+
"end must be on or after start",
|
| 53 |
+
status.HTTP_422_UNPROCESSABLE_ENTITY,
|
| 54 |
+
)
|
| 55 |
+
items, week_points, band, days_present = _store(request).range(start, end)
|
| 56 |
+
return ok(
|
| 57 |
+
DailyRangeData(
|
| 58 |
+
items=items,
|
| 59 |
+
week_points=week_points,
|
| 60 |
+
band=band, # type: ignore[arg-type]
|
| 61 |
+
days_present=days_present,
|
| 62 |
+
)
|
| 63 |
+
)
|
app/routers/debug.py
ADDED
|
@@ -0,0 +1,132 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Authenticated debug routes for coach traces and prompt previews.
|
| 2 |
+
|
| 3 |
+
Paste bundles are self-contained for an external AI with no user history.
|
| 4 |
+
"""
|
| 5 |
+
|
| 6 |
+
from fastapi import APIRouter, Depends, Query, Request, status
|
| 7 |
+
from fastapi.responses import PlainTextResponse
|
| 8 |
+
|
| 9 |
+
from app.deps import require_login
|
| 10 |
+
from app.models import ApiEnvelope, CoachRequest, err, ok
|
| 11 |
+
from app.paste_bundle import render_paste_bundle
|
| 12 |
+
|
| 13 |
+
router = APIRouter(
|
| 14 |
+
prefix="/api/debug",
|
| 15 |
+
tags=["debug"],
|
| 16 |
+
dependencies=[Depends(require_login)],
|
| 17 |
+
)
|
| 18 |
+
|
| 19 |
+
|
| 20 |
+
def _settings(request: Request):
|
| 21 |
+
return request.app.state.settings
|
| 22 |
+
|
| 23 |
+
|
| 24 |
+
def _paste(request: Request, trace: dict) -> PlainTextResponse:
|
| 25 |
+
settings = _settings(request)
|
| 26 |
+
body = render_paste_bundle(
|
| 27 |
+
trace,
|
| 28 |
+
app_name=settings.app_name,
|
| 29 |
+
shrink_k=settings.stats_shrink_k,
|
| 30 |
+
match_alpha=settings.match_alpha,
|
| 31 |
+
min_n=settings.min_stats_n,
|
| 32 |
+
)
|
| 33 |
+
return PlainTextResponse(content=body, media_type="text/markdown")
|
| 34 |
+
|
| 35 |
+
|
| 36 |
+
@router.get("/traces", response_model=ApiEnvelope)
|
| 37 |
+
def list_traces(
|
| 38 |
+
request: Request,
|
| 39 |
+
limit: int = Query(default=20, ge=1, le=200),
|
| 40 |
+
) -> dict[str, object]:
|
| 41 |
+
"""Return newest-first trace summaries."""
|
| 42 |
+
|
| 43 |
+
records = request.app.state.trace_store.list_traces(limit=limit)
|
| 44 |
+
summaries = [
|
| 45 |
+
{
|
| 46 |
+
"trace_id": r.get("trace_id"),
|
| 47 |
+
"ts": r.get("ts"),
|
| 48 |
+
"source": r.get("source"),
|
| 49 |
+
"flags": r.get("flags"),
|
| 50 |
+
"model": r.get("model_id"),
|
| 51 |
+
}
|
| 52 |
+
for r in records
|
| 53 |
+
]
|
| 54 |
+
return ok({"items": summaries})
|
| 55 |
+
|
| 56 |
+
|
| 57 |
+
@router.get("/traces/{trace_id}", response_model=ApiEnvelope)
|
| 58 |
+
def get_trace(request: Request, trace_id: str) -> object:
|
| 59 |
+
"""Return one full JSON trace."""
|
| 60 |
+
|
| 61 |
+
trace = request.app.state.trace_store.get_trace(trace_id)
|
| 62 |
+
if trace is None:
|
| 63 |
+
return err("not_found", "Trace not found", status.HTTP_404_NOT_FOUND)
|
| 64 |
+
return ok(trace)
|
| 65 |
+
|
| 66 |
+
|
| 67 |
+
@router.get("/traces/{trace_id}/paste")
|
| 68 |
+
def paste_trace(request: Request, trace_id: str) -> object:
|
| 69 |
+
"""Return the context-free markdown paste bundle for one trace."""
|
| 70 |
+
|
| 71 |
+
trace = request.app.state.trace_store.get_trace(trace_id)
|
| 72 |
+
if trace is None:
|
| 73 |
+
return err("not_found", "Trace not found", status.HTTP_404_NOT_FOUND)
|
| 74 |
+
return _paste(request, trace)
|
| 75 |
+
|
| 76 |
+
|
| 77 |
+
@router.get("/last/paste")
|
| 78 |
+
def paste_last(request: Request) -> object:
|
| 79 |
+
"""Return the newest trace paste bundle."""
|
| 80 |
+
|
| 81 |
+
trace = request.app.state.trace_store.latest_trace()
|
| 82 |
+
if trace is None:
|
| 83 |
+
return err("not_found", "No traces yet", status.HTTP_404_NOT_FOUND)
|
| 84 |
+
return _paste(request, trace)
|
| 85 |
+
|
| 86 |
+
|
| 87 |
+
@router.post("/prompt-preview", response_model=ApiEnvelope)
|
| 88 |
+
def prompt_preview(request: Request, body: CoachRequest) -> object:
|
| 89 |
+
"""Build evidence and messages without calling the model."""
|
| 90 |
+
|
| 91 |
+
if not body.text and not body.entry_id:
|
| 92 |
+
return err(
|
| 93 |
+
"validation_error",
|
| 94 |
+
"Provide text or entry_id",
|
| 95 |
+
status.HTTP_422_UNPROCESSABLE_ENTITY,
|
| 96 |
+
)
|
| 97 |
+
try:
|
| 98 |
+
preview = request.app.state.coach_service.prompt_preview(
|
| 99 |
+
text=body.text,
|
| 100 |
+
entry_id=body.entry_id,
|
| 101 |
+
include_history=body.include_history,
|
| 102 |
+
)
|
| 103 |
+
except KeyError:
|
| 104 |
+
return err("not_found", "Entry not found", status.HTTP_404_NOT_FOUND)
|
| 105 |
+
except ValueError as exc:
|
| 106 |
+
return err("validation_error", str(exc), status.HTTP_422_UNPROCESSABLE_ENTITY)
|
| 107 |
+
return ok(preview)
|
| 108 |
+
|
| 109 |
+
|
| 110 |
+
@router.post("/coach-test", response_model=ApiEnvelope)
|
| 111 |
+
def coach_test(request: Request, body: CoachRequest) -> object:
|
| 112 |
+
"""Run coach and force a trace; optional force_backup."""
|
| 113 |
+
|
| 114 |
+
if not body.text and not body.entry_id:
|
| 115 |
+
return err(
|
| 116 |
+
"validation_error",
|
| 117 |
+
"Provide text or entry_id",
|
| 118 |
+
status.HTTP_422_UNPROCESSABLE_ENTITY,
|
| 119 |
+
)
|
| 120 |
+
try:
|
| 121 |
+
result = request.app.state.coach_service.coach(
|
| 122 |
+
text=body.text,
|
| 123 |
+
entry_id=body.entry_id,
|
| 124 |
+
include_history=body.include_history,
|
| 125 |
+
persist=body.persist,
|
| 126 |
+
force_backup=body.force_backup,
|
| 127 |
+
)
|
| 128 |
+
except KeyError:
|
| 129 |
+
return err("not_found", "Entry not found", status.HTTP_404_NOT_FOUND)
|
| 130 |
+
except ValueError as exc:
|
| 131 |
+
return err("validation_error", str(exc), status.HTTP_422_UNPROCESSABLE_ENTITY)
|
| 132 |
+
return ok(result)
|
app/routers/entries.py
ADDED
|
@@ -0,0 +1,99 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Expose authenticated CRUD and filtered listing for log entries.
|
| 2 |
+
|
| 3 |
+
All routes require a valid session and use the shared entry store.
|
| 4 |
+
"""
|
| 5 |
+
|
| 6 |
+
from datetime import date
|
| 7 |
+
|
| 8 |
+
from fastapi import APIRouter, Depends, Query, Request, status
|
| 9 |
+
|
| 10 |
+
from app.deps import require_login
|
| 11 |
+
from app.models import (
|
| 12 |
+
ApiEnvelope,
|
| 13 |
+
DeletedData,
|
| 14 |
+
EntryCreate,
|
| 15 |
+
EntryListData,
|
| 16 |
+
EntryUpdate,
|
| 17 |
+
Result,
|
| 18 |
+
err,
|
| 19 |
+
normalize_emotions,
|
| 20 |
+
normalize_tags,
|
| 21 |
+
ok,
|
| 22 |
+
)
|
| 23 |
+
|
| 24 |
+
router = APIRouter(
|
| 25 |
+
prefix="/api/entries",
|
| 26 |
+
tags=["entries"],
|
| 27 |
+
dependencies=[Depends(require_login)],
|
| 28 |
+
)
|
| 29 |
+
|
| 30 |
+
|
| 31 |
+
def _store(request: Request):
|
| 32 |
+
return request.app.state.entry_store
|
| 33 |
+
|
| 34 |
+
|
| 35 |
+
@router.post("", response_model=ApiEnvelope)
|
| 36 |
+
def create_entry(request: Request, body: EntryCreate) -> dict[str, object]:
|
| 37 |
+
"""Create a new log entry."""
|
| 38 |
+
|
| 39 |
+
return ok(_store(request).create(body))
|
| 40 |
+
|
| 41 |
+
|
| 42 |
+
@router.get("", response_model=ApiEnvelope)
|
| 43 |
+
def list_entries(
|
| 44 |
+
request: Request,
|
| 45 |
+
start: date | None = None,
|
| 46 |
+
end: date | None = None,
|
| 47 |
+
tag: str | None = None,
|
| 48 |
+
result: Result | None = None,
|
| 49 |
+
emotion: str | None = None,
|
| 50 |
+
q: str | None = None,
|
| 51 |
+
limit: int = Query(default=50, ge=1, le=200),
|
| 52 |
+
offset: int = Query(default=0, ge=0),
|
| 53 |
+
) -> dict[str, object]:
|
| 54 |
+
"""List entries filtered by date, tag, result, emotion, or text."""
|
| 55 |
+
|
| 56 |
+
tag_norm = normalize_tags([tag])[0] if tag and tag.strip() else None
|
| 57 |
+
emotion_norm = (
|
| 58 |
+
normalize_emotions([emotion])[0] if emotion and emotion.strip() else None
|
| 59 |
+
)
|
| 60 |
+
items, total = _store(request).list(
|
| 61 |
+
start=start,
|
| 62 |
+
end=end,
|
| 63 |
+
tag=tag_norm,
|
| 64 |
+
result=result,
|
| 65 |
+
emotion=emotion_norm,
|
| 66 |
+
q=q,
|
| 67 |
+
limit=limit,
|
| 68 |
+
offset=offset,
|
| 69 |
+
)
|
| 70 |
+
return ok(EntryListData(items=items, total=total, limit=limit, offset=offset))
|
| 71 |
+
|
| 72 |
+
|
| 73 |
+
@router.get("/{entry_id}", response_model=ApiEnvelope)
|
| 74 |
+
def get_entry(request: Request, entry_id: str) -> object:
|
| 75 |
+
"""Fetch a single entry by id."""
|
| 76 |
+
|
| 77 |
+
entry = _store(request).get(entry_id)
|
| 78 |
+
if entry is None:
|
| 79 |
+
return err("not_found", "Entry not found", status.HTTP_404_NOT_FOUND)
|
| 80 |
+
return ok(entry)
|
| 81 |
+
|
| 82 |
+
|
| 83 |
+
@router.patch("/{entry_id}", response_model=ApiEnvelope)
|
| 84 |
+
def update_entry(request: Request, entry_id: str, body: EntryUpdate) -> object:
|
| 85 |
+
"""Apply a partial update to an entry."""
|
| 86 |
+
|
| 87 |
+
entry = _store(request).update(entry_id, body)
|
| 88 |
+
if entry is None:
|
| 89 |
+
return err("not_found", "Entry not found", status.HTTP_404_NOT_FOUND)
|
| 90 |
+
return ok(entry)
|
| 91 |
+
|
| 92 |
+
|
| 93 |
+
@router.delete("/{entry_id}", response_model=ApiEnvelope)
|
| 94 |
+
def delete_entry(request: Request, entry_id: str) -> object:
|
| 95 |
+
"""Hard-delete an entry by id."""
|
| 96 |
+
|
| 97 |
+
if not _store(request).delete(entry_id):
|
| 98 |
+
return err("not_found", "Entry not found", status.HTTP_404_NOT_FOUND)
|
| 99 |
+
return ok(DeletedData(deleted=True, id=entry_id))
|
app/routers/export.py
ADDED
|
@@ -0,0 +1,75 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Authenticated export of entries and daily rows as JSONL or CSV.
|
| 2 |
+
|
| 3 |
+
Exports stream whole files from DATA_ROOT without mounting that directory.
|
| 4 |
+
"""
|
| 5 |
+
|
| 6 |
+
import csv
|
| 7 |
+
import io
|
| 8 |
+
from typing import Any
|
| 9 |
+
|
| 10 |
+
from fastapi import APIRouter, Depends, Request
|
| 11 |
+
from fastapi.responses import PlainTextResponse, Response
|
| 12 |
+
|
| 13 |
+
from app.deps import require_login
|
| 14 |
+
from app.fsutil import read_jsonl
|
| 15 |
+
|
| 16 |
+
router = APIRouter(
|
| 17 |
+
prefix="/api/export",
|
| 18 |
+
tags=["export"],
|
| 19 |
+
dependencies=[Depends(require_login)],
|
| 20 |
+
)
|
| 21 |
+
|
| 22 |
+
CSV_COLUMNS = [
|
| 23 |
+
"id",
|
| 24 |
+
"ts",
|
| 25 |
+
"created_at",
|
| 26 |
+
"updated_at",
|
| 27 |
+
"activity",
|
| 28 |
+
"happened",
|
| 29 |
+
"emotions",
|
| 30 |
+
"intensity",
|
| 31 |
+
"remedy",
|
| 32 |
+
"result",
|
| 33 |
+
"tags",
|
| 34 |
+
"notes",
|
| 35 |
+
]
|
| 36 |
+
|
| 37 |
+
|
| 38 |
+
def _jsonl_response(path) -> Response:
|
| 39 |
+
if not path.exists():
|
| 40 |
+
content = ""
|
| 41 |
+
else:
|
| 42 |
+
content = path.read_text(encoding="utf-8")
|
| 43 |
+
return Response(content=content, media_type="application/x-ndjson")
|
| 44 |
+
|
| 45 |
+
|
| 46 |
+
@router.get("/entries.jsonl")
|
| 47 |
+
def export_entries_jsonl(request: Request) -> Response:
|
| 48 |
+
"""Download entries as newline-delimited JSON."""
|
| 49 |
+
|
| 50 |
+
return _jsonl_response(request.app.state.paths.entries)
|
| 51 |
+
|
| 52 |
+
|
| 53 |
+
@router.get("/daily.jsonl")
|
| 54 |
+
def export_daily_jsonl(request: Request) -> Response:
|
| 55 |
+
"""Download daily rows as newline-delimited JSON."""
|
| 56 |
+
|
| 57 |
+
return _jsonl_response(request.app.state.paths.daily)
|
| 58 |
+
|
| 59 |
+
|
| 60 |
+
@router.get("/entries.csv")
|
| 61 |
+
def export_entries_csv(request: Request) -> PlainTextResponse:
|
| 62 |
+
"""Download entries as CSV with fixed headers."""
|
| 63 |
+
|
| 64 |
+
records: list[dict[str, Any]] = read_jsonl(request.app.state.paths.entries)
|
| 65 |
+
buffer = io.StringIO()
|
| 66 |
+
writer = csv.DictWriter(buffer, fieldnames=CSV_COLUMNS, extrasaction="ignore")
|
| 67 |
+
writer.writeheader()
|
| 68 |
+
for record in records:
|
| 69 |
+
row = {key: record.get(key, "") for key in CSV_COLUMNS}
|
| 70 |
+
emotions = record.get("emotions") or []
|
| 71 |
+
tags = record.get("tags") or []
|
| 72 |
+
row["emotions"] = "|".join(str(item) for item in emotions)
|
| 73 |
+
row["tags"] = "|".join(str(item) for item in tags)
|
| 74 |
+
writer.writerow(row)
|
| 75 |
+
return PlainTextResponse(content=buffer.getvalue(), media_type="text/csv")
|
app/routers/health.py
ADDED
|
@@ -0,0 +1,45 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Expose unauthenticated liveness and bootstrap-readiness endpoints.
|
| 2 |
+
|
| 3 |
+
Readiness verifies both durable configuration and a writable data directory.
|
| 4 |
+
"""
|
| 5 |
+
|
| 6 |
+
import tempfile
|
| 7 |
+
from datetime import datetime, timezone
|
| 8 |
+
from pathlib import Path
|
| 9 |
+
|
| 10 |
+
from fastapi import APIRouter, Request
|
| 11 |
+
|
| 12 |
+
from app.models import ApiEnvelope, HealthData, ReadyData, ok
|
| 13 |
+
|
| 14 |
+
router = APIRouter(prefix="/api", tags=["health"])
|
| 15 |
+
|
| 16 |
+
|
| 17 |
+
def _is_writable(root: Path) -> bool:
|
| 18 |
+
try:
|
| 19 |
+
with tempfile.NamedTemporaryFile(dir=root, prefix=".ready-", delete=True):
|
| 20 |
+
return True
|
| 21 |
+
except OSError:
|
| 22 |
+
return False
|
| 23 |
+
|
| 24 |
+
|
| 25 |
+
@router.get("/health", response_model=ApiEnvelope)
|
| 26 |
+
def health() -> dict[str, object]:
|
| 27 |
+
"""Return process liveness without requiring setup or login."""
|
| 28 |
+
|
| 29 |
+
return ok(HealthData(status="up", time=datetime.now(timezone.utc)))
|
| 30 |
+
|
| 31 |
+
|
| 32 |
+
@router.get("/ready", response_model=ApiEnvelope)
|
| 33 |
+
def ready(request: Request) -> dict[str, object]:
|
| 34 |
+
"""Return whether storage is writable and bootstrap is complete."""
|
| 35 |
+
|
| 36 |
+
root = request.app.state.paths.root
|
| 37 |
+
writable = root.is_dir() and _is_writable(root)
|
| 38 |
+
config_present = request.app.state.config_store.load() is not None
|
| 39 |
+
return ok(
|
| 40 |
+
ReadyData(
|
| 41 |
+
ready=writable and config_present,
|
| 42 |
+
data_writable=writable,
|
| 43 |
+
config_present=config_present,
|
| 44 |
+
)
|
| 45 |
+
)
|
app/routers/settings.py
ADDED
|
@@ -0,0 +1,35 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Authenticated settings status without exposing secrets.
|
| 2 |
+
|
| 3 |
+
Reports coach configuration state and durable data readiness.
|
| 4 |
+
"""
|
| 5 |
+
|
| 6 |
+
from fastapi import APIRouter, Depends, Request
|
| 7 |
+
|
| 8 |
+
from app.config import APP_VERSION
|
| 9 |
+
from app.deps import require_login
|
| 10 |
+
from app.models import ApiEnvelope, SettingsStatusData, ok
|
| 11 |
+
|
| 12 |
+
router = APIRouter(
|
| 13 |
+
prefix="/api/settings",
|
| 14 |
+
tags=["settings"],
|
| 15 |
+
dependencies=[Depends(require_login)],
|
| 16 |
+
)
|
| 17 |
+
|
| 18 |
+
|
| 19 |
+
@router.get("/status", response_model=ApiEnvelope)
|
| 20 |
+
def settings_status(request: Request) -> dict[str, object]:
|
| 21 |
+
"""Return non-secret application and coach status."""
|
| 22 |
+
|
| 23 |
+
settings = request.app.state.settings
|
| 24 |
+
paths = request.app.state.paths
|
| 25 |
+
data_ok = paths.root.is_dir() and paths.entries.exists() and paths.daily.exists()
|
| 26 |
+
return ok(
|
| 27 |
+
SettingsStatusData(
|
| 28 |
+
app_name=settings.app_name,
|
| 29 |
+
app_version=APP_VERSION,
|
| 30 |
+
coach_configured=bool(settings.openrouter_api_key),
|
| 31 |
+
model=settings.openrouter_model,
|
| 32 |
+
data_ok=data_ok,
|
| 33 |
+
env=settings.env,
|
| 34 |
+
)
|
| 35 |
+
)
|
app/routers/stats.py
ADDED
|
@@ -0,0 +1,103 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Expose authenticated server-side statistics endpoints.
|
| 2 |
+
|
| 3 |
+
Probabilities and ranks are computed in Python, never by the model.
|
| 4 |
+
"""
|
| 5 |
+
|
| 6 |
+
from datetime import date, datetime, timezone
|
| 7 |
+
|
| 8 |
+
from fastapi import APIRouter, Depends, Query, Request
|
| 9 |
+
|
| 10 |
+
from app.deps import require_login
|
| 11 |
+
from app.models import ApiEnvelope, ok
|
| 12 |
+
from app.stats_math import build_stats, by_remedy
|
| 13 |
+
|
| 14 |
+
router = APIRouter(
|
| 15 |
+
prefix="/api/stats",
|
| 16 |
+
tags=["stats"],
|
| 17 |
+
dependencies=[Depends(require_login)],
|
| 18 |
+
)
|
| 19 |
+
|
| 20 |
+
|
| 21 |
+
def _entry_dicts(request: Request) -> list[dict]:
|
| 22 |
+
return [e.model_dump(mode="json") for e in request.app.state.entry_store._load()]
|
| 23 |
+
|
| 24 |
+
|
| 25 |
+
def _daily_dicts(
|
| 26 |
+
request: Request,
|
| 27 |
+
start: date | None,
|
| 28 |
+
end: date | None,
|
| 29 |
+
) -> list[dict]:
|
| 30 |
+
rows = request.app.state.daily_store._load()
|
| 31 |
+
out = []
|
| 32 |
+
for row in rows:
|
| 33 |
+
if start and row.date < start:
|
| 34 |
+
continue
|
| 35 |
+
if end and row.date > end:
|
| 36 |
+
continue
|
| 37 |
+
out.append(row.model_dump(mode="json"))
|
| 38 |
+
return out
|
| 39 |
+
|
| 40 |
+
|
| 41 |
+
def _filter_entries(
|
| 42 |
+
entries: list[dict],
|
| 43 |
+
start: date | None,
|
| 44 |
+
end: date | None,
|
| 45 |
+
) -> list[dict]:
|
| 46 |
+
if start is None and end is None:
|
| 47 |
+
return entries
|
| 48 |
+
filtered = []
|
| 49 |
+
for entry in entries:
|
| 50 |
+
ts = entry.get("ts")
|
| 51 |
+
if isinstance(ts, str):
|
| 52 |
+
day = date.fromisoformat(ts[:10])
|
| 53 |
+
else:
|
| 54 |
+
continue
|
| 55 |
+
if start and day < start:
|
| 56 |
+
continue
|
| 57 |
+
if end and day > end:
|
| 58 |
+
continue
|
| 59 |
+
filtered.append(entry)
|
| 60 |
+
return filtered
|
| 61 |
+
|
| 62 |
+
|
| 63 |
+
@router.get("", response_model=ApiEnvelope)
|
| 64 |
+
def get_stats(
|
| 65 |
+
request: Request,
|
| 66 |
+
start: date | None = None,
|
| 67 |
+
end: date | None = None,
|
| 68 |
+
min_n: int | None = Query(default=None, ge=1),
|
| 69 |
+
) -> dict[str, object]:
|
| 70 |
+
"""Return aggregate statistics for entries and daily rows."""
|
| 71 |
+
|
| 72 |
+
settings = request.app.state.settings
|
| 73 |
+
effective_min_n = settings.min_stats_n if min_n is None else min_n
|
| 74 |
+
entries = _filter_entries(_entry_dicts(request), start, end)
|
| 75 |
+
daily = _daily_dicts(request, start, end)
|
| 76 |
+
payload = build_stats(
|
| 77 |
+
entries,
|
| 78 |
+
daily,
|
| 79 |
+
min_n=effective_min_n,
|
| 80 |
+
shrink_k=settings.stats_shrink_k,
|
| 81 |
+
generated_at=datetime.now(timezone.utc).isoformat(),
|
| 82 |
+
)
|
| 83 |
+
return ok(payload)
|
| 84 |
+
|
| 85 |
+
|
| 86 |
+
@router.get("/remedies", response_model=ApiEnvelope)
|
| 87 |
+
def get_remedy_leaderboard(
|
| 88 |
+
request: Request,
|
| 89 |
+
start: date | None = None,
|
| 90 |
+
end: date | None = None,
|
| 91 |
+
min_n: int | None = Query(default=None, ge=1),
|
| 92 |
+
) -> dict[str, object]:
|
| 93 |
+
"""Return remedies sorted by shrinkage rank."""
|
| 94 |
+
|
| 95 |
+
settings = request.app.state.settings
|
| 96 |
+
effective_min_n = settings.min_stats_n if min_n is None else min_n
|
| 97 |
+
entries = _filter_entries(_entry_dicts(request), start, end)
|
| 98 |
+
rows = by_remedy(
|
| 99 |
+
entries,
|
| 100 |
+
min_n=effective_min_n,
|
| 101 |
+
shrink_k=settings.stats_shrink_k,
|
| 102 |
+
)
|
| 103 |
+
return ok({"items": rows, "min_n": effective_min_n})
|
app/security_passwords.py
ADDED
|
@@ -0,0 +1,52 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Hash and verify the single application password.
|
| 2 |
+
|
| 3 |
+
PBKDF2, random salts, and constant-time comparison are provided by stdlib.
|
| 4 |
+
"""
|
| 5 |
+
|
| 6 |
+
import base64
|
| 7 |
+
import hashlib
|
| 8 |
+
import hmac
|
| 9 |
+
import os
|
| 10 |
+
|
| 11 |
+
ALGORITHM = "pbkdf2_sha256"
|
| 12 |
+
DEFAULT_ITERATIONS = 260_000
|
| 13 |
+
MIN_ITERATIONS = 200_000
|
| 14 |
+
|
| 15 |
+
|
| 16 |
+
def hash_password(password: str, iterations: int = DEFAULT_ITERATIONS) -> str:
|
| 17 |
+
"""Return a salted PBKDF2 password hash in the configured wire format."""
|
| 18 |
+
|
| 19 |
+
if iterations < MIN_ITERATIONS:
|
| 20 |
+
raise ValueError(f"iterations must be at least {MIN_ITERATIONS}")
|
| 21 |
+
salt = os.urandom(16)
|
| 22 |
+
digest = hashlib.pbkdf2_hmac(
|
| 23 |
+
"sha256",
|
| 24 |
+
password.encode("utf-8"),
|
| 25 |
+
salt,
|
| 26 |
+
iterations,
|
| 27 |
+
)
|
| 28 |
+
salt_b64 = base64.b64encode(salt).decode("ascii")
|
| 29 |
+
digest_b64 = base64.b64encode(digest).decode("ascii")
|
| 30 |
+
return f"{ALGORITHM}${iterations}${salt_b64}${digest_b64}"
|
| 31 |
+
|
| 32 |
+
|
| 33 |
+
def verify_password(password: str, encoded: str) -> bool:
|
| 34 |
+
"""Verify a password without leaking comparison timing."""
|
| 35 |
+
|
| 36 |
+
try:
|
| 37 |
+
algorithm, raw_iterations, salt_b64, expected_b64 = encoded.split("$", 3)
|
| 38 |
+
iterations = int(raw_iterations)
|
| 39 |
+
if algorithm != ALGORITHM or iterations < MIN_ITERATIONS:
|
| 40 |
+
return False
|
| 41 |
+
salt = base64.b64decode(salt_b64, validate=True)
|
| 42 |
+
expected = base64.b64decode(expected_b64, validate=True)
|
| 43 |
+
except (ValueError, TypeError):
|
| 44 |
+
return False
|
| 45 |
+
|
| 46 |
+
actual = hashlib.pbkdf2_hmac(
|
| 47 |
+
"sha256",
|
| 48 |
+
password.encode("utf-8"),
|
| 49 |
+
salt,
|
| 50 |
+
iterations,
|
| 51 |
+
)
|
| 52 |
+
return hmac.compare_digest(actual, expected)
|
app/stats_math.py
ADDED
|
@@ -0,0 +1,301 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Pure probability and ranking math for log and daily statistics.
|
| 2 |
+
|
| 3 |
+
No I/O: callers pass entry/daily dicts; this module only aggregates.
|
| 4 |
+
"""
|
| 5 |
+
|
| 6 |
+
from __future__ import annotations
|
| 7 |
+
|
| 8 |
+
from collections import defaultdict
|
| 9 |
+
from typing import Any
|
| 10 |
+
|
| 11 |
+
|
| 12 |
+
def _normalize_remedy(value: str) -> str:
|
| 13 |
+
return " ".join(value.strip().lower().split())
|
| 14 |
+
|
| 15 |
+
|
| 16 |
+
def intensity_bucket(intensity: int) -> str:
|
| 17 |
+
"""Map intensity 1-10 into coarse buckets."""
|
| 18 |
+
|
| 19 |
+
if intensity <= 3:
|
| 20 |
+
return "1-3"
|
| 21 |
+
if intensity <= 6:
|
| 22 |
+
return "4-6"
|
| 23 |
+
return "7-10"
|
| 24 |
+
|
| 25 |
+
|
| 26 |
+
def scored_entries(entries: list[dict[str, Any]]) -> list[dict[str, Any]]:
|
| 27 |
+
"""Exclude pending outcomes from probability denominators."""
|
| 28 |
+
|
| 29 |
+
return [e for e in entries if e.get("result") != "pending"]
|
| 30 |
+
|
| 31 |
+
|
| 32 |
+
def outcome_histogram(entries: list[dict[str, Any]]) -> dict[str, int]:
|
| 33 |
+
"""Count outcomes including pending."""
|
| 34 |
+
|
| 35 |
+
counts = {"worked": 0, "partial": 0, "failed": 0, "pending": 0}
|
| 36 |
+
for entry in entries:
|
| 37 |
+
result = entry.get("result")
|
| 38 |
+
if result in counts:
|
| 39 |
+
counts[result] += 1
|
| 40 |
+
return counts
|
| 41 |
+
|
| 42 |
+
|
| 43 |
+
def by_remedy(
|
| 44 |
+
entries: list[dict[str, Any]],
|
| 45 |
+
*,
|
| 46 |
+
min_n: int,
|
| 47 |
+
shrink_k: float,
|
| 48 |
+
) -> list[dict[str, Any]]:
|
| 49 |
+
"""Compute per-remedy n, p_worked, p_helped, and shrinkage rank."""
|
| 50 |
+
|
| 51 |
+
scored = scored_entries(entries)
|
| 52 |
+
totals: dict[str, int] = defaultdict(int)
|
| 53 |
+
worked: dict[str, int] = defaultdict(int)
|
| 54 |
+
helped: dict[str, int] = defaultdict(int)
|
| 55 |
+
for entry in scored:
|
| 56 |
+
key = _normalize_remedy(str(entry.get("remedy") or ""))
|
| 57 |
+
if not key:
|
| 58 |
+
continue
|
| 59 |
+
totals[key] += 1
|
| 60 |
+
result = entry.get("result")
|
| 61 |
+
if result == "worked":
|
| 62 |
+
worked[key] += 1
|
| 63 |
+
helped[key] += 1
|
| 64 |
+
elif result == "partial":
|
| 65 |
+
helped[key] += 1
|
| 66 |
+
|
| 67 |
+
rows: list[dict[str, Any]] = []
|
| 68 |
+
for key, n in totals.items():
|
| 69 |
+
if n < min_n:
|
| 70 |
+
continue
|
| 71 |
+
p_worked = worked[key] / n
|
| 72 |
+
p_helped = helped[key] / n
|
| 73 |
+
rank = p_helped * (n / (n + shrink_k))
|
| 74 |
+
rows.append(
|
| 75 |
+
{
|
| 76 |
+
"key": key,
|
| 77 |
+
"n": n,
|
| 78 |
+
"p_worked": p_worked,
|
| 79 |
+
"p_helped": p_helped,
|
| 80 |
+
"rank": rank,
|
| 81 |
+
}
|
| 82 |
+
)
|
| 83 |
+
rows.sort(key=lambda row: (-row["rank"], -row["n"], row["key"]))
|
| 84 |
+
return rows
|
| 85 |
+
|
| 86 |
+
|
| 87 |
+
def by_emotion(entries: list[dict[str, Any]]) -> list[dict[str, Any]]:
|
| 88 |
+
"""Emotion multi-label buckets with p_helped among scored uses."""
|
| 89 |
+
|
| 90 |
+
totals: dict[str, int] = defaultdict(int)
|
| 91 |
+
helped: dict[str, int] = defaultdict(int)
|
| 92 |
+
for entry in scored_entries(entries):
|
| 93 |
+
result = entry.get("result")
|
| 94 |
+
is_helped = result in ("worked", "partial")
|
| 95 |
+
for emotion in entry.get("emotions") or []:
|
| 96 |
+
key = str(emotion).strip().lower()
|
| 97 |
+
if not key:
|
| 98 |
+
continue
|
| 99 |
+
totals[key] += 1
|
| 100 |
+
if is_helped:
|
| 101 |
+
helped[key] += 1
|
| 102 |
+
rows = [
|
| 103 |
+
{
|
| 104 |
+
"key": key,
|
| 105 |
+
"n": n,
|
| 106 |
+
"p_helped": (helped[key] / n) if n else 0.0,
|
| 107 |
+
}
|
| 108 |
+
for key, n in totals.items()
|
| 109 |
+
]
|
| 110 |
+
rows.sort(key=lambda row: (-row["n"], row["key"]))
|
| 111 |
+
return rows
|
| 112 |
+
|
| 113 |
+
|
| 114 |
+
def by_tag(entries: list[dict[str, Any]]) -> list[dict[str, Any]]:
|
| 115 |
+
"""Tag buckets with failure rate among scored uses."""
|
| 116 |
+
|
| 117 |
+
totals: dict[str, int] = defaultdict(int)
|
| 118 |
+
failed: dict[str, int] = defaultdict(int)
|
| 119 |
+
for entry in scored_entries(entries):
|
| 120 |
+
is_failed = entry.get("result") == "failed"
|
| 121 |
+
for tag in entry.get("tags") or []:
|
| 122 |
+
key = str(tag).strip().lower()
|
| 123 |
+
if not key:
|
| 124 |
+
continue
|
| 125 |
+
totals[key] += 1
|
| 126 |
+
if is_failed:
|
| 127 |
+
failed[key] += 1
|
| 128 |
+
rows = [
|
| 129 |
+
{
|
| 130 |
+
"key": key,
|
| 131 |
+
"n": n,
|
| 132 |
+
"p_failed": (failed[key] / n) if n else 0.0,
|
| 133 |
+
}
|
| 134 |
+
for key, n in totals.items()
|
| 135 |
+
]
|
| 136 |
+
rows.sort(key=lambda row: (-row["p_failed"], -row["n"], row["key"]))
|
| 137 |
+
return rows
|
| 138 |
+
|
| 139 |
+
|
| 140 |
+
def by_intensity_bucket(entries: list[dict[str, Any]]) -> list[dict[str, Any]]:
|
| 141 |
+
"""Intensity bucket counts and helped rates."""
|
| 142 |
+
|
| 143 |
+
totals = {"1-3": 0, "4-6": 0, "7-10": 0}
|
| 144 |
+
helped = {"1-3": 0, "4-6": 0, "7-10": 0}
|
| 145 |
+
for entry in scored_entries(entries):
|
| 146 |
+
try:
|
| 147 |
+
intensity = int(entry.get("intensity", 0))
|
| 148 |
+
except (TypeError, ValueError):
|
| 149 |
+
continue
|
| 150 |
+
if intensity < 1 or intensity > 10:
|
| 151 |
+
continue
|
| 152 |
+
bucket = intensity_bucket(intensity)
|
| 153 |
+
totals[bucket] += 1
|
| 154 |
+
if entry.get("result") in ("worked", "partial"):
|
| 155 |
+
helped[bucket] += 1
|
| 156 |
+
return [
|
| 157 |
+
{
|
| 158 |
+
"key": key,
|
| 159 |
+
"n": totals[key],
|
| 160 |
+
"p_helped": (helped[key] / totals[key]) if totals[key] else 0.0,
|
| 161 |
+
}
|
| 162 |
+
for key in ("1-3", "4-6", "7-10")
|
| 163 |
+
]
|
| 164 |
+
|
| 165 |
+
|
| 166 |
+
def corn_ok(row: dict[str, Any]) -> bool:
|
| 167 |
+
"""True when corn sessions are within the delay policy."""
|
| 168 |
+
|
| 169 |
+
sessions = int(row.get("corn_sessions") or 0)
|
| 170 |
+
delay_ok = bool(row.get("delay_ok", True))
|
| 171 |
+
return sessions == 0 or (sessions <= 1 and delay_ok)
|
| 172 |
+
|
| 173 |
+
|
| 174 |
+
def daily_rates(daily_rows: list[dict[str, Any]]) -> dict[str, float]:
|
| 175 |
+
"""Aggregate daily scoreboard rates for a set of days."""
|
| 176 |
+
|
| 177 |
+
n = len(daily_rows)
|
| 178 |
+
if n == 0:
|
| 179 |
+
return {
|
| 180 |
+
"p_brick_done": 0.0,
|
| 181 |
+
"p_corn_ok": 0.0,
|
| 182 |
+
"p_no_fc": 0.0,
|
| 183 |
+
"p_rerun_clean": 0.0,
|
| 184 |
+
"p_court_closed": 0.0,
|
| 185 |
+
"avg_points": 0.0,
|
| 186 |
+
}
|
| 187 |
+
brick = sum(1 for row in daily_rows if row.get("brick_done"))
|
| 188 |
+
corn = sum(1 for row in daily_rows if corn_ok(row))
|
| 189 |
+
no_fc = sum(1 for row in daily_rows if row.get("daydream") != "fc")
|
| 190 |
+
rerun = sum(1 for row in daily_rows if row.get("rerun") == "clean")
|
| 191 |
+
court = sum(1 for row in daily_rows if row.get("court") == "closed")
|
| 192 |
+
avg_points = sum(float(row.get("points") or 0) for row in daily_rows) / n
|
| 193 |
+
return {
|
| 194 |
+
"p_brick_done": brick / n,
|
| 195 |
+
"p_corn_ok": corn / n,
|
| 196 |
+
"p_no_fc": no_fc / n,
|
| 197 |
+
"p_rerun_clean": rerun / n,
|
| 198 |
+
"p_court_closed": court / n,
|
| 199 |
+
"avg_points": avg_points,
|
| 200 |
+
}
|
| 201 |
+
|
| 202 |
+
|
| 203 |
+
def helped_tags_by_remedy(entries: list[dict[str, Any]]) -> dict[str, set[str]]:
|
| 204 |
+
"""Tags that appear on helped uses of each remedy."""
|
| 205 |
+
|
| 206 |
+
tags_by_remedy: dict[str, set[str]] = defaultdict(set)
|
| 207 |
+
for entry in scored_entries(entries):
|
| 208 |
+
if entry.get("result") not in ("worked", "partial"):
|
| 209 |
+
continue
|
| 210 |
+
key = _normalize_remedy(str(entry.get("remedy") or ""))
|
| 211 |
+
if not key:
|
| 212 |
+
continue
|
| 213 |
+
for tag in entry.get("tags") or []:
|
| 214 |
+
token = str(tag).strip().lower()
|
| 215 |
+
if token:
|
| 216 |
+
tags_by_remedy[key].add(token)
|
| 217 |
+
return tags_by_remedy
|
| 218 |
+
|
| 219 |
+
|
| 220 |
+
def match_score(current_tags: set[str], remedy_tags: set[str]) -> float:
|
| 221 |
+
"""Fraction of current tags that match a remedy's helped-tag set."""
|
| 222 |
+
|
| 223 |
+
if not current_tags:
|
| 224 |
+
return 0.0
|
| 225 |
+
return len(current_tags & remedy_tags) / max(len(current_tags), 1)
|
| 226 |
+
|
| 227 |
+
|
| 228 |
+
def server_picks(
|
| 229 |
+
entries: list[dict[str, Any]],
|
| 230 |
+
current_tags: list[str] | set[str],
|
| 231 |
+
*,
|
| 232 |
+
min_n: int,
|
| 233 |
+
shrink_k: float,
|
| 234 |
+
match_alpha: float,
|
| 235 |
+
limit: int = 5,
|
| 236 |
+
) -> list[dict[str, Any]]:
|
| 237 |
+
"""Rank remedies by shrinkage + optional tag-match boost."""
|
| 238 |
+
|
| 239 |
+
tag_set = {str(tag).strip().lower() for tag in current_tags if str(tag).strip()}
|
| 240 |
+
remedy_tags = helped_tags_by_remedy(entries)
|
| 241 |
+
picks: list[dict[str, Any]] = []
|
| 242 |
+
for row in by_remedy(entries, min_n=min_n, shrink_k=shrink_k):
|
| 243 |
+
match = match_score(tag_set, remedy_tags.get(row["key"], set()))
|
| 244 |
+
pick = row["rank"] * (1.0 + match_alpha * match)
|
| 245 |
+
picks.append(
|
| 246 |
+
{
|
| 247 |
+
"remedy_key": row["key"],
|
| 248 |
+
"pick": pick,
|
| 249 |
+
"n": row["n"],
|
| 250 |
+
"p_helped": row["p_helped"],
|
| 251 |
+
"p_worked": row["p_worked"],
|
| 252 |
+
"rank": row["rank"],
|
| 253 |
+
"match": match,
|
| 254 |
+
}
|
| 255 |
+
)
|
| 256 |
+
picks.sort(key=lambda item: (-item["pick"], -item["n"], item["remedy_key"]))
|
| 257 |
+
return picks[:limit]
|
| 258 |
+
|
| 259 |
+
|
| 260 |
+
def data_thin(n_scored: int) -> bool:
|
| 261 |
+
"""True when scored history is too thin for strong coaching."""
|
| 262 |
+
|
| 263 |
+
return n_scored < 10
|
| 264 |
+
|
| 265 |
+
|
| 266 |
+
FORMULAS = {
|
| 267 |
+
"p_worked": "N(worked,r) / N(r); pending excluded",
|
| 268 |
+
"p_helped": "N(worked|partial,r) / N(r); pending excluded",
|
| 269 |
+
"rank": "p_helped * n/(n+k)",
|
| 270 |
+
"pick": "rank * (1 + alpha * match)",
|
| 271 |
+
"match": "|T intersect T_r| / max(|T|,1)",
|
| 272 |
+
"DATA_THIN": "n_scored < 10",
|
| 273 |
+
}
|
| 274 |
+
|
| 275 |
+
|
| 276 |
+
def build_stats(
|
| 277 |
+
entries: list[dict[str, Any]],
|
| 278 |
+
daily_rows: list[dict[str, Any]],
|
| 279 |
+
*,
|
| 280 |
+
min_n: int,
|
| 281 |
+
shrink_k: float,
|
| 282 |
+
generated_at: str,
|
| 283 |
+
) -> dict[str, Any]:
|
| 284 |
+
"""Assemble the /api/stats response payload."""
|
| 285 |
+
|
| 286 |
+
scored = scored_entries(entries)
|
| 287 |
+
return {
|
| 288 |
+
"n_entries_total": len(entries),
|
| 289 |
+
"n_entries_scored": len(scored),
|
| 290 |
+
"outcomes": outcome_histogram(entries),
|
| 291 |
+
"by_remedy": by_remedy(entries, min_n=min_n, shrink_k=shrink_k),
|
| 292 |
+
"by_emotion": by_emotion(entries),
|
| 293 |
+
"by_tag": by_tag(entries),
|
| 294 |
+
"by_intensity_bucket": by_intensity_bucket(entries),
|
| 295 |
+
"daily": daily_rates(daily_rows),
|
| 296 |
+
"formulas": FORMULAS,
|
| 297 |
+
"generated_at": generated_at,
|
| 298 |
+
"min_n": min_n,
|
| 299 |
+
"shrink_k": shrink_k,
|
| 300 |
+
"DATA_THIN": data_thin(len(scored)),
|
| 301 |
+
}
|
app/store_config.py
ADDED
|
@@ -0,0 +1,84 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Persist bootstrap credentials and session-version state.
|
| 2 |
+
|
| 3 |
+
Configuration updates are serialized and atomically replace `config.json`.
|
| 4 |
+
"""
|
| 5 |
+
|
| 6 |
+
import json
|
| 7 |
+
from datetime import datetime, timezone
|
| 8 |
+
|
| 9 |
+
from app.fsutil import atomic_write_text, file_lock, read_json
|
| 10 |
+
from app.models import StoredConfig
|
| 11 |
+
from app.paths import Paths
|
| 12 |
+
from app.security_passwords import hash_password, verify_password
|
| 13 |
+
|
| 14 |
+
|
| 15 |
+
def utc_now() -> datetime:
|
| 16 |
+
"""Return an aware UTC timestamp."""
|
| 17 |
+
|
| 18 |
+
return datetime.now(timezone.utc)
|
| 19 |
+
|
| 20 |
+
|
| 21 |
+
class ConfigStore:
|
| 22 |
+
"""Read and mutate the durable single-user configuration."""
|
| 23 |
+
|
| 24 |
+
def __init__(self, paths: Paths) -> None:
|
| 25 |
+
self.paths = paths
|
| 26 |
+
|
| 27 |
+
def bootstrap(self, initial_password: str | None) -> StoredConfig | None:
|
| 28 |
+
"""Create initial configuration only when a password is available."""
|
| 29 |
+
|
| 30 |
+
with file_lock(self.paths.config):
|
| 31 |
+
existing = read_json(self.paths.config)
|
| 32 |
+
if existing is not None:
|
| 33 |
+
return StoredConfig.model_validate(existing)
|
| 34 |
+
if not initial_password:
|
| 35 |
+
return None
|
| 36 |
+
now = utc_now()
|
| 37 |
+
config = StoredConfig(
|
| 38 |
+
password_hash=hash_password(initial_password),
|
| 39 |
+
session_version=1,
|
| 40 |
+
created_at=now,
|
| 41 |
+
updated_at=now,
|
| 42 |
+
)
|
| 43 |
+
self._write_unlocked(config)
|
| 44 |
+
return config
|
| 45 |
+
|
| 46 |
+
def load(self) -> StoredConfig | None:
|
| 47 |
+
"""Load configuration, or None when setup has not occurred."""
|
| 48 |
+
|
| 49 |
+
value = read_json(self.paths.config)
|
| 50 |
+
return StoredConfig.model_validate(value) if value is not None else None
|
| 51 |
+
|
| 52 |
+
def password_matches(self, password: str) -> bool:
|
| 53 |
+
"""Check a candidate password against current configuration."""
|
| 54 |
+
|
| 55 |
+
config = self.load()
|
| 56 |
+
return bool(config and verify_password(password, config.password_hash))
|
| 57 |
+
|
| 58 |
+
def change_password(self, old_password: str, new_password: str) -> bool:
|
| 59 |
+
"""Rotate the password and invalidate all prior sessions."""
|
| 60 |
+
|
| 61 |
+
with file_lock(self.paths.config):
|
| 62 |
+
value = read_json(self.paths.config)
|
| 63 |
+
if value is None:
|
| 64 |
+
return False
|
| 65 |
+
config = StoredConfig.model_validate(value)
|
| 66 |
+
if not verify_password(old_password, config.password_hash):
|
| 67 |
+
return False
|
| 68 |
+
updated = config.model_copy(
|
| 69 |
+
update={
|
| 70 |
+
"password_hash": hash_password(new_password),
|
| 71 |
+
"session_version": config.session_version + 1,
|
| 72 |
+
"updated_at": utc_now(),
|
| 73 |
+
}
|
| 74 |
+
)
|
| 75 |
+
self._write_unlocked(updated)
|
| 76 |
+
return True
|
| 77 |
+
|
| 78 |
+
def _write_unlocked(self, config: StoredConfig) -> None:
|
| 79 |
+
payload = json.dumps(
|
| 80 |
+
config.model_dump(mode="json"),
|
| 81 |
+
ensure_ascii=False,
|
| 82 |
+
indent=2,
|
| 83 |
+
)
|
| 84 |
+
atomic_write_text(self.paths.config, payload + "\n")
|
app/store_daily.py
ADDED
|
@@ -0,0 +1,120 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Persist daily scoreboard rows and compute server-authoritative points.
|
| 2 |
+
|
| 3 |
+
Rows are unique by date; upserts rewrite the JSONL file under lock.
|
| 4 |
+
"""
|
| 5 |
+
|
| 6 |
+
from datetime import date, datetime, timezone
|
| 7 |
+
|
| 8 |
+
from app.fsutil import read_jsonl, rewrite_jsonl
|
| 9 |
+
from app.models import Court, DailyRow, DailyUpsert, Daydream, Rerun
|
| 10 |
+
from app.paths import Paths
|
| 11 |
+
|
| 12 |
+
|
| 13 |
+
def compute_daily_points(
|
| 14 |
+
*,
|
| 15 |
+
brick_done: bool,
|
| 16 |
+
corn_sessions: int,
|
| 17 |
+
delay_ok: bool,
|
| 18 |
+
daydream: Daydream | str,
|
| 19 |
+
rerun: Rerun | str,
|
| 20 |
+
court: Court | str,
|
| 21 |
+
) -> int:
|
| 22 |
+
"""Compute daily points from the authoritative formula (max 6)."""
|
| 23 |
+
|
| 24 |
+
points = 0
|
| 25 |
+
if brick_done:
|
| 26 |
+
points += 2
|
| 27 |
+
if corn_sessions == 0 or (corn_sessions <= 1 and delay_ok):
|
| 28 |
+
points += 1
|
| 29 |
+
daydream_value = daydream.value if isinstance(daydream, Daydream) else daydream
|
| 30 |
+
if daydream_value != Daydream.fc.value:
|
| 31 |
+
points += 1
|
| 32 |
+
rerun_value = rerun.value if isinstance(rerun, Rerun) else rerun
|
| 33 |
+
if rerun_value == Rerun.clean.value:
|
| 34 |
+
points += 1
|
| 35 |
+
court_value = court.value if isinstance(court, Court) else court
|
| 36 |
+
if court_value == Court.closed.value:
|
| 37 |
+
points += 1
|
| 38 |
+
return points
|
| 39 |
+
|
| 40 |
+
|
| 41 |
+
def week_band(days_present: int, week_points: int) -> str:
|
| 42 |
+
"""Classify a 7-day window from present days and point sum."""
|
| 43 |
+
|
| 44 |
+
if days_present < 7:
|
| 45 |
+
return "incomplete"
|
| 46 |
+
if week_points >= 28:
|
| 47 |
+
return "strong"
|
| 48 |
+
if week_points >= 18:
|
| 49 |
+
return "mixed"
|
| 50 |
+
return "escape_heavy"
|
| 51 |
+
|
| 52 |
+
|
| 53 |
+
def _utc_now() -> datetime:
|
| 54 |
+
return datetime.now(timezone.utc)
|
| 55 |
+
|
| 56 |
+
|
| 57 |
+
class DailyStore:
|
| 58 |
+
"""Read and mutate the durable daily JSONL file."""
|
| 59 |
+
|
| 60 |
+
def __init__(self, paths: Paths) -> None:
|
| 61 |
+
self.paths = paths
|
| 62 |
+
|
| 63 |
+
def _load(self) -> list[DailyRow]:
|
| 64 |
+
return [DailyRow.model_validate(record) for record in read_jsonl(self.paths.daily)]
|
| 65 |
+
|
| 66 |
+
def get(self, day: date) -> DailyRow | None:
|
| 67 |
+
"""Return the row for one calendar date, or None."""
|
| 68 |
+
|
| 69 |
+
for row in self._load():
|
| 70 |
+
if row.date == day:
|
| 71 |
+
return row
|
| 72 |
+
return None
|
| 73 |
+
|
| 74 |
+
def upsert(self, day: date, data: DailyUpsert) -> DailyRow:
|
| 75 |
+
"""Create or replace a daily row and recompute points."""
|
| 76 |
+
|
| 77 |
+
points = compute_daily_points(
|
| 78 |
+
brick_done=data.brick_done,
|
| 79 |
+
corn_sessions=data.corn_sessions,
|
| 80 |
+
delay_ok=data.delay_ok,
|
| 81 |
+
daydream=data.daydream,
|
| 82 |
+
rerun=data.rerun,
|
| 83 |
+
court=data.court,
|
| 84 |
+
)
|
| 85 |
+
row = DailyRow(
|
| 86 |
+
date=day,
|
| 87 |
+
primary_brick=data.primary_brick,
|
| 88 |
+
brick_done=data.brick_done,
|
| 89 |
+
corn_sessions=data.corn_sessions,
|
| 90 |
+
delay_ok=data.delay_ok,
|
| 91 |
+
daydream=data.daydream,
|
| 92 |
+
rerun=data.rerun,
|
| 93 |
+
court=data.court,
|
| 94 |
+
points=points,
|
| 95 |
+
note=data.note,
|
| 96 |
+
updated_at=_utc_now(),
|
| 97 |
+
)
|
| 98 |
+
records = read_jsonl(self.paths.daily)
|
| 99 |
+
out: list[dict] = []
|
| 100 |
+
replaced = False
|
| 101 |
+
for record in records:
|
| 102 |
+
if record.get("date") == day.isoformat():
|
| 103 |
+
out.append(row.model_dump(mode="json"))
|
| 104 |
+
replaced = True
|
| 105 |
+
else:
|
| 106 |
+
out.append(record)
|
| 107 |
+
if not replaced:
|
| 108 |
+
out.append(row.model_dump(mode="json"))
|
| 109 |
+
rewrite_jsonl(self.paths.daily, out)
|
| 110 |
+
return row
|
| 111 |
+
|
| 112 |
+
def range(self, start: date, end: date) -> tuple[list[DailyRow], int, str, int]:
|
| 113 |
+
"""Return rows in [start, end], week_points, band, and days_present."""
|
| 114 |
+
|
| 115 |
+
items = [row for row in self._load() if start <= row.date <= end]
|
| 116 |
+
items.sort(key=lambda row: row.date)
|
| 117 |
+
days_present = len(items)
|
| 118 |
+
week_points = sum(row.points for row in items)
|
| 119 |
+
band = week_band(days_present, week_points)
|
| 120 |
+
return items, week_points, band, days_present
|
app/store_entries.py
ADDED
|
@@ -0,0 +1,146 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Persist log entries as append-only JSONL with locked full rewrites.
|
| 2 |
+
|
| 3 |
+
Creation appends one line; updates and deletes rewrite the file atomically.
|
| 4 |
+
"""
|
| 5 |
+
|
| 6 |
+
from datetime import date, datetime, timezone
|
| 7 |
+
from uuid import uuid4
|
| 8 |
+
|
| 9 |
+
from app.fsutil import append_jsonl, read_jsonl, rewrite_jsonl
|
| 10 |
+
from app.models import CoachMeta, Entry, EntryCreate, EntryUpdate, Result
|
| 11 |
+
from app.paths import Paths
|
| 12 |
+
|
| 13 |
+
|
| 14 |
+
def _utc_now() -> datetime:
|
| 15 |
+
return datetime.now(timezone.utc)
|
| 16 |
+
|
| 17 |
+
|
| 18 |
+
class EntryStore:
|
| 19 |
+
"""Read and mutate the durable entries JSONL file."""
|
| 20 |
+
|
| 21 |
+
def __init__(self, paths: Paths) -> None:
|
| 22 |
+
self.paths = paths
|
| 23 |
+
|
| 24 |
+
def create(self, data: EntryCreate) -> Entry:
|
| 25 |
+
"""Append a new entry and return the stored record."""
|
| 26 |
+
|
| 27 |
+
now = _utc_now()
|
| 28 |
+
entry = Entry(
|
| 29 |
+
id=str(uuid4()),
|
| 30 |
+
ts=data.ts or now,
|
| 31 |
+
created_at=now,
|
| 32 |
+
updated_at=now,
|
| 33 |
+
activity=data.activity,
|
| 34 |
+
happened=data.happened,
|
| 35 |
+
emotions=data.emotions,
|
| 36 |
+
intensity=data.intensity,
|
| 37 |
+
remedy=data.remedy,
|
| 38 |
+
result=data.result,
|
| 39 |
+
tags=data.tags,
|
| 40 |
+
notes=data.notes,
|
| 41 |
+
coach=CoachMeta(),
|
| 42 |
+
)
|
| 43 |
+
append_jsonl(self.paths.entries, entry.model_dump(mode="json"))
|
| 44 |
+
return entry
|
| 45 |
+
|
| 46 |
+
def _load(self) -> list[Entry]:
|
| 47 |
+
return [Entry.model_validate(record) for record in read_jsonl(self.paths.entries)]
|
| 48 |
+
|
| 49 |
+
def get(self, entry_id: str) -> Entry | None:
|
| 50 |
+
"""Return a single entry by id, or None when absent."""
|
| 51 |
+
|
| 52 |
+
for entry in self._load():
|
| 53 |
+
if entry.id == entry_id:
|
| 54 |
+
return entry
|
| 55 |
+
return None
|
| 56 |
+
|
| 57 |
+
def list(
|
| 58 |
+
self,
|
| 59 |
+
*,
|
| 60 |
+
start: date | None = None,
|
| 61 |
+
end: date | None = None,
|
| 62 |
+
tag: str | None = None,
|
| 63 |
+
result: Result | None = None,
|
| 64 |
+
emotion: str | None = None,
|
| 65 |
+
q: str | None = None,
|
| 66 |
+
limit: int = 50,
|
| 67 |
+
offset: int = 0,
|
| 68 |
+
) -> tuple[list[Entry], int]:
|
| 69 |
+
"""Return newest-first entries matching filters plus the total count."""
|
| 70 |
+
|
| 71 |
+
needle = q.lower() if q else None
|
| 72 |
+
matches: list[Entry] = []
|
| 73 |
+
for entry in self._load():
|
| 74 |
+
if start and entry.ts.date() < start:
|
| 75 |
+
continue
|
| 76 |
+
if end and entry.ts.date() > end:
|
| 77 |
+
continue
|
| 78 |
+
if tag and tag not in entry.tags:
|
| 79 |
+
continue
|
| 80 |
+
if result and entry.result != result:
|
| 81 |
+
continue
|
| 82 |
+
if emotion and emotion not in entry.emotions:
|
| 83 |
+
continue
|
| 84 |
+
if needle:
|
| 85 |
+
haystack = " ".join(
|
| 86 |
+
[entry.activity, entry.happened, entry.remedy, entry.notes]
|
| 87 |
+
).lower()
|
| 88 |
+
if needle not in haystack:
|
| 89 |
+
continue
|
| 90 |
+
matches.append(entry)
|
| 91 |
+
|
| 92 |
+
matches.sort(key=lambda item: item.ts, reverse=True)
|
| 93 |
+
total = len(matches)
|
| 94 |
+
page = matches[offset : offset + limit]
|
| 95 |
+
return page, total
|
| 96 |
+
|
| 97 |
+
def update(self, entry_id: str, patch: EntryUpdate) -> Entry | None:
|
| 98 |
+
"""Apply a partial update and rewrite the file, or return None."""
|
| 99 |
+
|
| 100 |
+
changes = patch.model_dump(exclude_unset=True, mode="json")
|
| 101 |
+
records = read_jsonl(self.paths.entries)
|
| 102 |
+
updated: Entry | None = None
|
| 103 |
+
out: list[dict] = []
|
| 104 |
+
for record in records:
|
| 105 |
+
if updated is None and record.get("id") == entry_id:
|
| 106 |
+
merged = {**record, **changes, "updated_at": _utc_now().isoformat()}
|
| 107 |
+
updated = Entry.model_validate(merged)
|
| 108 |
+
out.append(updated.model_dump(mode="json"))
|
| 109 |
+
else:
|
| 110 |
+
out.append(record)
|
| 111 |
+
if updated is None:
|
| 112 |
+
return None
|
| 113 |
+
rewrite_jsonl(self.paths.entries, out)
|
| 114 |
+
return updated
|
| 115 |
+
|
| 116 |
+
def delete(self, entry_id: str) -> bool:
|
| 117 |
+
"""Hard-delete an entry, returning whether it existed."""
|
| 118 |
+
|
| 119 |
+
records = read_jsonl(self.paths.entries)
|
| 120 |
+
out = [record for record in records if record.get("id") != entry_id]
|
| 121 |
+
if len(out) == len(records):
|
| 122 |
+
return False
|
| 123 |
+
rewrite_jsonl(self.paths.entries, out)
|
| 124 |
+
return True
|
| 125 |
+
|
| 126 |
+
def set_coach(self, entry_id: str, coach: CoachMeta) -> Entry | None:
|
| 127 |
+
"""Persist coach metadata on an entry without changing other fields."""
|
| 128 |
+
|
| 129 |
+
records = read_jsonl(self.paths.entries)
|
| 130 |
+
updated: Entry | None = None
|
| 131 |
+
out: list[dict] = []
|
| 132 |
+
for record in records:
|
| 133 |
+
if updated is None and record.get("id") == entry_id:
|
| 134 |
+
merged = {
|
| 135 |
+
**record,
|
| 136 |
+
"coach": coach.model_dump(mode="json"),
|
| 137 |
+
"updated_at": _utc_now().isoformat(),
|
| 138 |
+
}
|
| 139 |
+
updated = Entry.model_validate(merged)
|
| 140 |
+
out.append(updated.model_dump(mode="json"))
|
| 141 |
+
else:
|
| 142 |
+
out.append(record)
|
| 143 |
+
if updated is None:
|
| 144 |
+
return None
|
| 145 |
+
rewrite_jsonl(self.paths.entries, out)
|
| 146 |
+
return updated
|
app/store_traces.py
ADDED
|
@@ -0,0 +1,96 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Load coach brief and persist coach debug traces as a ring buffer.
|
| 2 |
+
|
| 3 |
+
Traces are append-only, then trimmed to the newest configured limit.
|
| 4 |
+
"""
|
| 5 |
+
|
| 6 |
+
from __future__ import annotations
|
| 7 |
+
|
| 8 |
+
import hashlib
|
| 9 |
+
from pathlib import Path
|
| 10 |
+
from typing import Any
|
| 11 |
+
|
| 12 |
+
from app.fsutil import append_jsonl, read_jsonl, rewrite_jsonl
|
| 13 |
+
from app.paths import Paths
|
| 14 |
+
|
| 15 |
+
DEFAULT_BRIEF = """# Coach brief (generic)
|
| 16 |
+
|
| 17 |
+
Role: brief checklist coach. No pity. No pep talk.
|
| 18 |
+
Never invent statistics. Use only EVIDENCE and SERVER_PICKS.
|
| 19 |
+
Never cancel committed admin/travel process merely from fear.
|
| 20 |
+
If self-harm language appears: crisis redirect only.
|
| 21 |
+
|
| 22 |
+
## Bricks
|
| 23 |
+
- A: admin/critical checklist item
|
| 24 |
+
- B: environment/hygiene one act
|
| 25 |
+
- C: build/earn one ship
|
| 26 |
+
- D: logistics one line
|
| 27 |
+
- E: boundary one line then silence
|
| 28 |
+
- S: stop spiral — water/shower/sleep
|
| 29 |
+
|
| 30 |
+
Court closed by default. Daydream requires same-day brick.
|
| 31 |
+
Urge: delay then brick; ceiling one session/day (policy text only).
|
| 32 |
+
"""
|
| 33 |
+
|
| 34 |
+
|
| 35 |
+
class TraceStore:
|
| 36 |
+
"""Read coach brief and manage the traces JSONL ring buffer."""
|
| 37 |
+
|
| 38 |
+
def __init__(self, paths: Paths, *, trace_limit: int = 200) -> None:
|
| 39 |
+
self.paths = paths
|
| 40 |
+
self.trace_limit = trace_limit
|
| 41 |
+
|
| 42 |
+
def ensure_brief(self) -> None:
|
| 43 |
+
"""Seed a generic brief when missing."""
|
| 44 |
+
|
| 45 |
+
if not self.paths.coach_brief.exists():
|
| 46 |
+
self.paths.coach_brief.write_text(DEFAULT_BRIEF, encoding="utf-8")
|
| 47 |
+
|
| 48 |
+
def read_brief(self, *, max_bytes: int = 32_768) -> str:
|
| 49 |
+
"""Read brief truncated to max_bytes with a truncation marker."""
|
| 50 |
+
|
| 51 |
+
self.ensure_brief()
|
| 52 |
+
raw = self.paths.coach_brief.read_bytes()
|
| 53 |
+
if len(raw) > max_bytes:
|
| 54 |
+
text = raw[:max_bytes].decode("utf-8", errors="replace")
|
| 55 |
+
return text + "\n[TRUNCATED]\n"
|
| 56 |
+
return raw.decode("utf-8", errors="replace")
|
| 57 |
+
|
| 58 |
+
def brief_meta(self, *, include_full: bool) -> dict[str, Any]:
|
| 59 |
+
"""Return brief hash, excerpt, and optional full body."""
|
| 60 |
+
|
| 61 |
+
brief = self.read_brief()
|
| 62 |
+
digest = hashlib.sha256(brief.encode("utf-8")).hexdigest()
|
| 63 |
+
return {
|
| 64 |
+
"brief_sha256": digest,
|
| 65 |
+
"brief_excerpt": brief[:500],
|
| 66 |
+
"brief_full": brief if include_full else None,
|
| 67 |
+
}
|
| 68 |
+
|
| 69 |
+
def append_trace(self, trace: dict[str, Any]) -> None:
|
| 70 |
+
"""Append one trace and trim to the newest N records."""
|
| 71 |
+
|
| 72 |
+
append_jsonl(self.paths.traces, trace)
|
| 73 |
+
records = read_jsonl(self.paths.traces)
|
| 74 |
+
if len(records) > self.trace_limit:
|
| 75 |
+
rewrite_jsonl(self.paths.traces, records[-self.trace_limit :])
|
| 76 |
+
|
| 77 |
+
def list_traces(self, limit: int = 20) -> list[dict[str, Any]]:
|
| 78 |
+
"""Return newest-first trace summaries."""
|
| 79 |
+
|
| 80 |
+
records = read_jsonl(self.paths.traces)
|
| 81 |
+
records.reverse()
|
| 82 |
+
return records[:limit]
|
| 83 |
+
|
| 84 |
+
def get_trace(self, trace_id: str) -> dict[str, Any] | None:
|
| 85 |
+
"""Return one full trace by id."""
|
| 86 |
+
|
| 87 |
+
for record in reversed(read_jsonl(self.paths.traces)):
|
| 88 |
+
if record.get("trace_id") == trace_id:
|
| 89 |
+
return record
|
| 90 |
+
return None
|
| 91 |
+
|
| 92 |
+
def latest_trace(self) -> dict[str, Any] | None:
|
| 93 |
+
"""Return the newest trace, or None."""
|
| 94 |
+
|
| 95 |
+
records = read_jsonl(self.paths.traces)
|
| 96 |
+
return records[-1] if records else None
|
docs/BACKEND.md
ADDED
|
@@ -0,0 +1,268 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# Backend Design Document
|
| 2 |
+
## Habit Journal (Discrete PWA) — Hugging Face Spaces
|
| 3 |
+
|
| 4 |
+
**Version:** 2.0.0
|
| 5 |
+
**Scope:** Backend API, storage, auth, server-side statistics, coach, traces,
|
| 6 |
+
debugging, export, and operations. The frontend is separate.
|
| 7 |
+
|
| 8 |
+
This is the repository specification. If implementation and this document
|
| 9 |
+
conflict, this document wins until a human changes it.
|
| 10 |
+
|
| 11 |
+
## Implementation rules
|
| 12 |
+
|
| 13 |
+
- Work in the vertical slices listed below and keep each diff small.
|
| 14 |
+
- Prefer stdlib, FastAPI, httpx, Pydantic, and pydantic-settings.
|
| 15 |
+
- Use no SQL database, Redis, Celery, DI framework, or repository hierarchy.
|
| 16 |
+
- Every module has a short purpose docstring and public functions are typed.
|
| 17 |
+
- Request and response bodies use Pydantic models.
|
| 18 |
+
- Secrets come only from environment variables. Never log passwords or keys.
|
| 19 |
+
- Keep generic Habit Journal naming and private biography out of git.
|
| 20 |
+
- Disable OpenAPI, Swagger, and ReDoc when `ENV=prod`.
|
| 21 |
+
- After each slice, the app must import and start with uvicorn.
|
| 22 |
+
- Target at most about 15 Python files under `app/`; merge stores if needed.
|
| 23 |
+
|
| 24 |
+
## Runtime contract
|
| 25 |
+
|
| 26 |
+
On a fresh Hugging Face Docker Space with `APP_PASSWORD` and
|
| 27 |
+
`APP_SECRET_KEY`, startup creates `/data/loop_logger`, login works, entries and
|
| 28 |
+
daily rows survive restart, statistics are computed in Python, coach always
|
| 29 |
+
returns model or backup text, and every debug paste bundle is understandable
|
| 30 |
+
without prior user context.
|
| 31 |
+
|
| 32 |
+
The Docker image uses Python 3.11 slim and runs:
|
| 33 |
+
|
| 34 |
+
```text
|
| 35 |
+
uvicorn app.main:app --host 0.0.0.0 --port ${PORT:-7860}
|
| 36 |
+
```
|
| 37 |
+
|
| 38 |
+
Required and optional environment:
|
| 39 |
+
|
| 40 |
+
- `APP_PASSWORD`: bootstrap password when `config.json` does not exist
|
| 41 |
+
- `APP_SECRET_KEY`: required session-signing secret
|
| 42 |
+
- `DATA_ROOT=/data/loop_logger`
|
| 43 |
+
- `ENV=prod`; `APP_NAME=Habit Journal`
|
| 44 |
+
- `OPENROUTER_API_KEY`; `OPENROUTER_MODEL=openrouter/free`
|
| 45 |
+
- `OPENROUTER_BASE_URL=https://openrouter.ai/api/v1`
|
| 46 |
+
- `COACH_TIMEOUT_SEC=25`; `COACH_TEMPERATURE=0.3`
|
| 47 |
+
- `COACH_MAX_TOKENS=400`; `COACH_HISTORY_K=10`
|
| 48 |
+
- `COACH_TRACE_LIMIT=200`
|
| 49 |
+
- `MIN_STATS_N=5`; `STATS_SHRINK_K=3`; `MATCH_ALPHA=0.5`
|
| 50 |
+
- `SESSION_MAX_AGE_SEC=604800`
|
| 51 |
+
- `DEBUG_INCLUDE_FULL_BRIEF=false`
|
| 52 |
+
- `LOGIN_RATE_LIMIT=10`; `LOGIN_RATE_WINDOW_SEC=900`
|
| 53 |
+
|
| 54 |
+
## Flat architecture and durable files
|
| 55 |
+
|
| 56 |
+
Routers call services, services call stores, and stores access the filesystem.
|
| 57 |
+
Do not introduce circular imports. `main.py` owns wiring.
|
| 58 |
+
|
| 59 |
+
`DATA_ROOT` contains `config.json`, `entries.jsonl`, `daily.jsonl`,
|
| 60 |
+
`coach_brief.md`, and `traces.jsonl`. Each mutated path uses a lock. Entry
|
| 61 |
+
creation appends one flushed JSONL line under lock. Updates and deletes read,
|
| 62 |
+
map/filter, then write a flushed temporary file and `os.replace` it.
|
| 63 |
+
|
| 64 |
+
If configuration is absent and `APP_PASSWORD` exists, create:
|
| 65 |
+
|
| 66 |
+
```json
|
| 67 |
+
{
|
| 68 |
+
"password_hash": "pbkdf2_sha256$260000$...",
|
| 69 |
+
"session_version": 1,
|
| 70 |
+
"created_at": "ISO-8601",
|
| 71 |
+
"updated_at": "ISO-8601"
|
| 72 |
+
}
|
| 73 |
+
```
|
| 74 |
+
|
| 75 |
+
PBKDF2 uses SHA-256, at least 200,000 iterations, a random salt, and
|
| 76 |
+
`hmac.compare_digest`. Without config and bootstrap password, readiness is
|
| 77 |
+
false and protected data routes return `setup_required`.
|
| 78 |
+
|
| 79 |
+
`coach_brief.md` is operator-editable, read to at most 32 KiB, and seeded with
|
| 80 |
+
generic policy only. Debug includes its hash and first 500 characters unless
|
| 81 |
+
`DEBUG_INCLUDE_FULL_BRIEF=true`.
|
| 82 |
+
|
| 83 |
+
## API envelope and security
|
| 84 |
+
|
| 85 |
+
Every success is `{"ok":true,"data":...,"error":null}`. Every failure is
|
| 86 |
+
`{"ok":false,"data":null,"error":{"code":"...","message":"..."}}`.
|
| 87 |
+
Codes are `unauthorized`, `forbidden`, `validation_error`, `not_found`,
|
| 88 |
+
`setup_required`, `rate_limited`, and `internal`.
|
| 89 |
+
|
| 90 |
+
Use Starlette signed cookie sessions containing `auth`, `sv`, and Unix `exp`.
|
| 91 |
+
Reject expired sessions and sessions whose version differs from config.
|
| 92 |
+
Cookies are HTTP-only, SameSite Lax, and secure in production. Rate-limit
|
| 93 |
+
login attempts by IP in memory and return generic credential failures.
|
| 94 |
+
Limit request bodies to 64 KiB. Do not mount `DATA_ROOT`. Best-effort strip
|
| 95 |
+
emails and long digit runs from coach prompts. Never return stack traces.
|
| 96 |
+
|
| 97 |
+
Unauthenticated routes are only `GET /api/health`, `GET /api/ready`,
|
| 98 |
+
`POST /api/auth/login`, and `GET /api/auth/me`.
|
| 99 |
+
|
| 100 |
+
Auth routes:
|
| 101 |
+
|
| 102 |
+
- `POST /api/auth/login` with `{password}`
|
| 103 |
+
- `POST /api/auth/logout`
|
| 104 |
+
- `GET /api/auth/me`
|
| 105 |
+
- `POST /api/auth/change-password` with old/new password; bump session version
|
| 106 |
+
|
| 107 |
+
## Entries and daily rows
|
| 108 |
+
|
| 109 |
+
Entry fields are `id`, `ts`, timestamps, `activity`, `happened`, `emotions`,
|
| 110 |
+
`intensity`, `remedy`, `result`, `tags`, `notes`, and nested coach metadata.
|
| 111 |
+
Activity is 1–500 characters, happened 1–4000, emotions at most 12,
|
| 112 |
+
intensity 1–10, remedy and notes at most 2000, and tags at most 20. Normalize
|
| 113 |
+
emotions to lowercase stripped text; normalize tags likewise and replace
|
| 114 |
+
spaces with underscores. Results are `worked`, `partial`, `failed`, or
|
| 115 |
+
`pending`; pending never enters probability denominators.
|
| 116 |
+
|
| 117 |
+
Entries support create, list/filter, get, patch, delete, and per-entry coach.
|
| 118 |
+
List filters are start/end date, tag, result, emotion, substring `q`, limit
|
| 119 |
+
(50 default, 200 max), and offset; sort newest first.
|
| 120 |
+
|
| 121 |
+
Daily rows are unique by date and contain brick, brick completion, corn
|
| 122 |
+
sessions, delay flag, daydream, rerun, court, points, note, and update time.
|
| 123 |
+
Enums are brick `A|B|C|D|E|S|none`, daydream `none|done|fc`, rerun `clean|R`,
|
| 124 |
+
and court `closed|court`. Corn sessions range from 0 to 50.
|
| 125 |
+
|
| 126 |
+
Server-authoritative daily points:
|
| 127 |
+
|
| 128 |
+
```text
|
| 129 |
+
2 if brick_done
|
| 130 |
+
+1 if corn_sessions == 0 or (corn_sessions <= 1 and delay_ok)
|
| 131 |
+
+1 if daydream != "fc"
|
| 132 |
+
+1 if rerun == "clean"
|
| 133 |
+
+1 if court == "closed"
|
| 134 |
+
```
|
| 135 |
+
|
| 136 |
+
For a seven-calendar-day window, missing days produce `incomplete`; otherwise
|
| 137 |
+
28–42 is `strong`, 18–27 `mixed`, and 0–17 `escape_heavy`.
|
| 138 |
+
|
| 139 |
+
## Server statistics and evidence
|
| 140 |
+
|
| 141 |
+
`stats_math.py` contains pure functions only. Excluding pending outcomes:
|
| 142 |
+
|
| 143 |
+
- `p_worked(r) = N(worked,r) / N(r)`
|
| 144 |
+
- `p_helped(r) = N(worked|partial,r) / N(r)`
|
| 145 |
+
- `p_failed(t) = N(failed,t) / N(t)`
|
| 146 |
+
- `rank(r) = p_helped(r) * n/(n+k)`
|
| 147 |
+
- `match(r,T) = |T intersect helped_tags(r)| / max(|T|,1)`
|
| 148 |
+
- `pick(r) = rank(r) * (1 + alpha*match(r,T))`
|
| 149 |
+
|
| 150 |
+
Intensity buckets are 1–3, 4–6, and 7–10. Emotions are multi-label.
|
| 151 |
+
Top coach tables hide remedies below `min_n`. Server picks are the top five by
|
| 152 |
+
pick score. `DATA_THIN` means fewer than ten scored entries.
|
| 153 |
+
|
| 154 |
+
Daily statistics include brick done, corn okay, no FC, clean rerun, closed
|
| 155 |
+
court, and average points rates. `/api/stats` returns counts, outcomes,
|
| 156 |
+
breakdowns, daily rates, formula strings, and generation time.
|
| 157 |
+
`/api/stats/remedies` returns rank-sorted remedies. The model never computes
|
| 158 |
+
statistics.
|
| 159 |
+
|
| 160 |
+
## Coach
|
| 161 |
+
|
| 162 |
+
Coach is an optional OpenRouter selector/copywriter over server evidence and
|
| 163 |
+
server picks. A hardcoded ordered rule table is mandatory and always available.
|
| 164 |
+
A valid coach request always returns HTTP 200 with non-empty text and source
|
| 165 |
+
`model`, `backup`, or `model_unparsed_fallback`.
|
| 166 |
+
|
| 167 |
+
Pipeline: resolve current input; load last K truncated entries and today's
|
| 168 |
+
daily row; compute evidence and picks; build system/user messages; start a
|
| 169 |
+
trace; call OpenRouter; strictly parse the template; fall back on empty,
|
| 170 |
+
failed, timed-out, or unparseable output; set flags; append/ring-trim trace;
|
| 171 |
+
optionally persist coach metadata on the entry.
|
| 172 |
+
|
| 173 |
+
OpenRouter receives `POST {BASE}/chat/completions`, Bearer authorization,
|
| 174 |
+
JSON content type, `X-Title: Habit Journal`, configured model, messages,
|
| 175 |
+
temperature, token limit, and timeout.
|
| 176 |
+
|
| 177 |
+
Required model output:
|
| 178 |
+
|
| 179 |
+
```text
|
| 180 |
+
LOOP: <tag>
|
| 181 |
+
FEELING: <word>
|
| 182 |
+
INTENSITY_GUESS: <1-10|n/a>
|
| 183 |
+
REMEDY: <one concrete action>
|
| 184 |
+
NEXT_BRICK: <A|B|C|D|E|S|specific>
|
| 185 |
+
DO_NOT: <one thing>
|
| 186 |
+
LINE: <12 words or fewer>
|
| 187 |
+
NOTE: <optional 20 words or fewer>
|
| 188 |
+
DATA_THIN: <true|false>
|
| 189 |
+
```
|
| 190 |
+
|
| 191 |
+
The prompt forbids invented numbers, canceling committed processes merely from
|
| 192 |
+
fear, pity, and pep talks. Self-harm language triggers crisis redirection.
|
| 193 |
+
Rules are ordered: crisis, court, rerun, rage_movie, daydream/fc, urge/corn,
|
| 194 |
+
bully, shame, spain/admin, build/earn, home, loneliness, default. Non-crisis
|
| 195 |
+
rules may substitute server pick number one.
|
| 196 |
+
|
| 197 |
+
Flags include `DATA_THIN`, `PARSE_FAIL`, `TIMEOUT`, `EMPTY_MODEL`,
|
| 198 |
+
`NO_API_KEY`, and `OUT_OF_EVIDENCE`.
|
| 199 |
+
|
| 200 |
+
## Traces, debug, export, and operations
|
| 201 |
+
|
| 202 |
+
Every coach call stores request, current situation, truncated history,
|
| 203 |
+
evidence, picks, brief hash/excerpt/full value according to config, both
|
| 204 |
+
prompts, model settings, latency/status/error, raw output, parse, source,
|
| 205 |
+
backup rule, flags, app version, and final text. Keep the newest configured N.
|
| 206 |
+
|
| 207 |
+
Authenticated debug endpoints list traces, return full traces, return one or
|
| 208 |
+
the newest context-free Markdown paste bundle, preview prompts without a model
|
| 209 |
+
call, and test coach with optional forced backup. The paste bundle contains:
|
| 210 |
+
how an external AI should help, authoritative formulas, evidence, picks,
|
| 211 |
+
current input, history, prompts, raw response, parse/source/flags, final text,
|
| 212 |
+
and a reviewer checklist for invented numbers, ignored picks, format,
|
| 213 |
+
fallback quality, evidence length, and weight tuning.
|
| 214 |
+
|
| 215 |
+
Authenticated exports provide entries JSONL, entries CSV, and daily JSONL.
|
| 216 |
+
CSV has a fixed entry header and pipe-joins emotions and tags.
|
| 217 |
+
|
| 218 |
+
Health is public. Readiness reports writable data plus config presence.
|
| 219 |
+
Authenticated settings status reports app name, coach configured state, model,
|
| 220 |
+
data status, environment, and app version—never an API key.
|
| 221 |
+
|
| 222 |
+
Mount `./static` at `/` only when present, after `/api` routes. Never mount the
|
| 223 |
+
data directory.
|
| 224 |
+
|
| 225 |
+
## Default generic policy
|
| 226 |
+
|
| 227 |
+
- A: admin/critical checklist item
|
| 228 |
+
- B: environment/hygiene one act
|
| 229 |
+
- C: build/earn one ship
|
| 230 |
+
- D: logistics one line
|
| 231 |
+
- E: boundary one line then silence
|
| 232 |
+
- S: stop spiral — water/shower/sleep
|
| 233 |
+
|
| 234 |
+
Court is closed by default. Daydream requires a same-day brick. Urge policy is
|
| 235 |
+
delay then brick, with a ceiling of one session per day expressed only in
|
| 236 |
+
policy text.
|
| 237 |
+
|
| 238 |
+
## Ordered implementation slices
|
| 239 |
+
|
| 240 |
+
1. S0: Dockerfile, requirements, settings, paths, fs utilities, health/ready
|
| 241 |
+
2. S1: password hashing, config store, auth, session, dependencies
|
| 242 |
+
3. S2: entries CRUD JSONL
|
| 243 |
+
4. S3: daily upsert, points, range band
|
| 244 |
+
5. S4: pure statistics and stats routes
|
| 245 |
+
6. S5: evidence and server picks
|
| 246 |
+
7. S6: rule backup and offline coach
|
| 247 |
+
8. S7: OpenRouter, parser, flags
|
| 248 |
+
9. S8: traces and debug paste endpoints
|
| 249 |
+
10. S9: export and settings status
|
| 250 |
+
11. S10: seed brief, production checks, error polish
|
| 251 |
+
|
| 252 |
+
Do not begin S7 before S6. Tests must cover daily point edge cases,
|
| 253 |
+
hand-calculated helped/rank math, and backup rule precedence; an authenticated
|
| 254 |
+
temporary-data API test is optional.
|
| 255 |
+
|
| 256 |
+
## Acceptance
|
| 257 |
+
|
| 258 |
+
Storage survives restart; good login sets a cookie and bad login is 401;
|
| 259 |
+
unauthenticated data access is 401; daily and probability fixtures match;
|
| 260 |
+
coach without a key and failed model calls return backup text; evidence and
|
| 261 |
+
debug paste contain probabilities and server picks; paste contains formulas;
|
| 262 |
+
authenticated export works; production OpenAPI is absent; and no secret is
|
| 263 |
+
committed.
|
| 264 |
+
|
| 265 |
+
Explicit decisions are final: JSONL only, server-only math, probabilities and
|
| 266 |
+
picks passed to the model, hardcoded backup, context-free debug bundles,
|
| 267 |
+
single shared password, entry result as truth, and private life details only
|
| 268 |
+
in the operator brief on disk.
|
requirements.txt
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
fastapi
|
| 2 |
+
uvicorn[standard]
|
| 3 |
+
pydantic
|
| 4 |
+
pydantic-settings
|
| 5 |
+
httpx
|
| 6 |
+
python-multipart
|
| 7 |
+
itsdangerous
|
tests/__init__.py
ADDED
|
File without changes
|
tests/test_backup.py
ADDED
|
@@ -0,0 +1,38 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Unit tests for backup rule priority selection."""
|
| 2 |
+
|
| 3 |
+
from app.backup_replies import backup_reply, select_backup_rule
|
| 4 |
+
|
| 5 |
+
|
| 6 |
+
def test_court_beats_default() -> None:
|
| 7 |
+
rule = select_backup_rule("I opened court again", [])
|
| 8 |
+
assert rule["id"] == "court"
|
| 9 |
+
|
| 10 |
+
|
| 11 |
+
def test_tag_match_rerun() -> None:
|
| 12 |
+
rule = select_backup_rule("something vague", ["rerun"])
|
| 13 |
+
assert rule["id"] == "rerun"
|
| 14 |
+
|
| 15 |
+
|
| 16 |
+
def test_default_and_server_pick() -> None:
|
| 17 |
+
text, rule_id = backup_reply(
|
| 18 |
+
"ordinary day",
|
| 19 |
+
[],
|
| 20 |
+
[{"remedy_key": "walk outside", "pick": 0.5, "n": 5, "p_helped": 0.8}],
|
| 21 |
+
data_thin=True,
|
| 22 |
+
)
|
| 23 |
+
assert rule_id == "default"
|
| 24 |
+
assert "REMEDY: walk outside" in text
|
| 25 |
+
assert "DATA_THIN: true" in text
|
| 26 |
+
assert text.startswith("LOOP:")
|
| 27 |
+
|
| 28 |
+
|
| 29 |
+
def test_crisis_ignores_server_pick() -> None:
|
| 30 |
+
text, rule_id = backup_reply(
|
| 31 |
+
"I want to kill myself",
|
| 32 |
+
[],
|
| 33 |
+
[{"remedy_key": "walk outside", "pick": 0.5, "n": 5, "p_helped": 0.8}],
|
| 34 |
+
data_thin=False,
|
| 35 |
+
)
|
| 36 |
+
assert rule_id == "crisis"
|
| 37 |
+
assert "walk outside" not in text
|
| 38 |
+
assert "crisis" in text.lower()
|
tests/test_daily_points.py
ADDED
|
@@ -0,0 +1,82 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Unit tests for server-authoritative daily point scoring."""
|
| 2 |
+
|
| 3 |
+
from app.models import Court, Daydream, Rerun
|
| 4 |
+
from app.store_daily import compute_daily_points, week_band
|
| 5 |
+
|
| 6 |
+
|
| 7 |
+
def test_max_points() -> None:
|
| 8 |
+
assert (
|
| 9 |
+
compute_daily_points(
|
| 10 |
+
brick_done=True,
|
| 11 |
+
corn_sessions=0,
|
| 12 |
+
delay_ok=True,
|
| 13 |
+
daydream=Daydream.none,
|
| 14 |
+
rerun=Rerun.clean,
|
| 15 |
+
court=Court.closed,
|
| 16 |
+
)
|
| 17 |
+
== 6
|
| 18 |
+
)
|
| 19 |
+
|
| 20 |
+
|
| 21 |
+
def test_corn_one_with_delay_ok() -> None:
|
| 22 |
+
assert (
|
| 23 |
+
compute_daily_points(
|
| 24 |
+
brick_done=False,
|
| 25 |
+
corn_sessions=1,
|
| 26 |
+
delay_ok=True,
|
| 27 |
+
daydream=Daydream.done,
|
| 28 |
+
rerun=Rerun.clean,
|
| 29 |
+
court=Court.closed,
|
| 30 |
+
)
|
| 31 |
+
== 4
|
| 32 |
+
)
|
| 33 |
+
|
| 34 |
+
|
| 35 |
+
def test_corn_one_without_delay() -> None:
|
| 36 |
+
assert (
|
| 37 |
+
compute_daily_points(
|
| 38 |
+
brick_done=False,
|
| 39 |
+
corn_sessions=1,
|
| 40 |
+
delay_ok=False,
|
| 41 |
+
daydream=Daydream.none,
|
| 42 |
+
rerun=Rerun.clean,
|
| 43 |
+
court=Court.closed,
|
| 44 |
+
)
|
| 45 |
+
== 3
|
| 46 |
+
)
|
| 47 |
+
|
| 48 |
+
|
| 49 |
+
def test_corn_two_loses_point() -> None:
|
| 50 |
+
assert (
|
| 51 |
+
compute_daily_points(
|
| 52 |
+
brick_done=True,
|
| 53 |
+
corn_sessions=2,
|
| 54 |
+
delay_ok=True,
|
| 55 |
+
daydream=Daydream.none,
|
| 56 |
+
rerun=Rerun.clean,
|
| 57 |
+
court=Court.closed,
|
| 58 |
+
)
|
| 59 |
+
== 5
|
| 60 |
+
)
|
| 61 |
+
|
| 62 |
+
|
| 63 |
+
def test_fc_and_court_and_rerun() -> None:
|
| 64 |
+
assert (
|
| 65 |
+
compute_daily_points(
|
| 66 |
+
brick_done=True,
|
| 67 |
+
corn_sessions=0,
|
| 68 |
+
delay_ok=True,
|
| 69 |
+
daydream=Daydream.fc,
|
| 70 |
+
rerun=Rerun.R,
|
| 71 |
+
court=Court.court,
|
| 72 |
+
)
|
| 73 |
+
== 3
|
| 74 |
+
)
|
| 75 |
+
|
| 76 |
+
|
| 77 |
+
def test_week_band() -> None:
|
| 78 |
+
assert week_band(6, 42) == "incomplete"
|
| 79 |
+
assert week_band(7, 28) == "strong"
|
| 80 |
+
assert week_band(7, 27) == "mixed"
|
| 81 |
+
assert week_band(7, 18) == "mixed"
|
| 82 |
+
assert week_band(7, 17) == "escape_heavy"
|
tests/test_stats_math.py
ADDED
|
@@ -0,0 +1,97 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Unit tests for pure stats math fixtures."""
|
| 2 |
+
|
| 3 |
+
from app.stats_math import by_remedy, data_thin, server_picks
|
| 4 |
+
|
| 5 |
+
|
| 6 |
+
def _entries() -> list[dict]:
|
| 7 |
+
# remedy "walk": 4 worked, 1 failed => n=5, p_helped=0.8
|
| 8 |
+
# remedy "nap": 2 partial, 1 failed, 2 pending => scored n=3 (below min_n=5)
|
| 9 |
+
rows = []
|
| 10 |
+
for _ in range(4):
|
| 11 |
+
rows.append(
|
| 12 |
+
{
|
| 13 |
+
"remedy": " Walk ",
|
| 14 |
+
"result": "worked",
|
| 15 |
+
"emotions": ["calm"],
|
| 16 |
+
"tags": ["rerun"],
|
| 17 |
+
"intensity": 5,
|
| 18 |
+
}
|
| 19 |
+
)
|
| 20 |
+
rows.append(
|
| 21 |
+
{
|
| 22 |
+
"remedy": "walk",
|
| 23 |
+
"result": "failed",
|
| 24 |
+
"emotions": ["shame"],
|
| 25 |
+
"tags": ["urge"],
|
| 26 |
+
"intensity": 8,
|
| 27 |
+
}
|
| 28 |
+
)
|
| 29 |
+
for _ in range(2):
|
| 30 |
+
rows.append(
|
| 31 |
+
{
|
| 32 |
+
"remedy": "nap",
|
| 33 |
+
"result": "partial",
|
| 34 |
+
"emotions": ["tired"],
|
| 35 |
+
"tags": ["home"],
|
| 36 |
+
"intensity": 2,
|
| 37 |
+
}
|
| 38 |
+
)
|
| 39 |
+
rows.append(
|
| 40 |
+
{
|
| 41 |
+
"remedy": "nap",
|
| 42 |
+
"result": "failed",
|
| 43 |
+
"emotions": ["tired"],
|
| 44 |
+
"tags": ["home"],
|
| 45 |
+
"intensity": 2,
|
| 46 |
+
}
|
| 47 |
+
)
|
| 48 |
+
rows.append(
|
| 49 |
+
{
|
| 50 |
+
"remedy": "nap",
|
| 51 |
+
"result": "pending",
|
| 52 |
+
"emotions": ["tired"],
|
| 53 |
+
"tags": ["home"],
|
| 54 |
+
"intensity": 2,
|
| 55 |
+
}
|
| 56 |
+
)
|
| 57 |
+
rows.append(
|
| 58 |
+
{
|
| 59 |
+
"remedy": "nap",
|
| 60 |
+
"result": "pending",
|
| 61 |
+
"emotions": ["tired"],
|
| 62 |
+
"tags": ["home"],
|
| 63 |
+
"intensity": 2,
|
| 64 |
+
}
|
| 65 |
+
)
|
| 66 |
+
return rows
|
| 67 |
+
|
| 68 |
+
|
| 69 |
+
def test_p_helped_and_rank() -> None:
|
| 70 |
+
rows = by_remedy(_entries(), min_n=5, shrink_k=3)
|
| 71 |
+
assert len(rows) == 1
|
| 72 |
+
walk = rows[0]
|
| 73 |
+
assert walk["key"] == "walk"
|
| 74 |
+
assert walk["n"] == 5
|
| 75 |
+
assert walk["p_worked"] == 0.8
|
| 76 |
+
assert walk["p_helped"] == 0.8
|
| 77 |
+
assert abs(walk["rank"] - (0.8 * 5 / 8)) < 1e-9
|
| 78 |
+
|
| 79 |
+
|
| 80 |
+
def test_data_thin() -> None:
|
| 81 |
+
assert data_thin(9) is True
|
| 82 |
+
assert data_thin(10) is False
|
| 83 |
+
|
| 84 |
+
|
| 85 |
+
def test_server_picks_tag_boost() -> None:
|
| 86 |
+
picks = server_picks(
|
| 87 |
+
_entries(),
|
| 88 |
+
["rerun"],
|
| 89 |
+
min_n=5,
|
| 90 |
+
shrink_k=3,
|
| 91 |
+
match_alpha=0.5,
|
| 92 |
+
)
|
| 93 |
+
assert len(picks) == 1
|
| 94 |
+
assert picks[0]["remedy_key"] == "walk"
|
| 95 |
+
assert picks[0]["match"] == 1.0
|
| 96 |
+
expected = (0.8 * 5 / 8) * (1 + 0.5 * 1.0)
|
| 97 |
+
assert abs(picks[0]["pick"] - expected) < 1e-9
|