Mbonea Cursor commited on
Commit
990895d
·
0 Parent(s):

Deploy Habit Journal backend S0-S10 to Hugging Face Space.

Browse files

EOF

Co-authored-by: Cursor <cursoragent@cursor.com>

.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