Spaces:
Sleeping
Sleeping
| """Coach pipeline: evidence, OpenRouter (optional), parse, and backup. | |
| Always returns HTTP-ready text with source=model|backup|model_unparsed_fallback. | |
| """ | |
| from __future__ import annotations | |
| import json | |
| import re | |
| import time | |
| from datetime import date, datetime, timezone | |
| from typing import Any | |
| from uuid import uuid4 | |
| import httpx | |
| from app.backup_replies import backup_reply | |
| from app.config import APP_VERSION, Settings | |
| from app.evidence import build_evidence, strip_sensitive | |
| from app.models import CoachMeta, Entry | |
| from app.store_daily import DailyStore | |
| from app.store_entries import EntryStore | |
| from app.store_traces import TraceStore | |
| DEFAULT_POLICY = """Bricks: A admin/critical; B environment/hygiene; C build/earn; D logistics; E boundary then silence; S stop spiral (water/shower/sleep).""" | |
| OUTPUT_FORMAT = """Output format exactly (first line LOOP:): | |
| LOOP: <tag> | |
| FEELING: <word> | |
| INTENSITY_GUESS: <1-10|n/a> | |
| REMEDY: <one concrete action> | |
| NEXT_BRICK: <A|B|C|D|E|S|specific> | |
| DO_NOT: <one thing> | |
| LINE: <≤12 words> | |
| NOTE: <optional ≤20 words> | |
| DATA_THIN: <true|false>""" | |
| def utc_now() -> datetime: | |
| return datetime.now(timezone.utc) | |
| _PLACEHOLDER_RE = re.compile( | |
| r"<(?:tag|word|one concrete action|1-10\|n/a|A\|B\|C\|D\|E\|S\|specific|" | |
| r"≤12 words|optional ≤20 words|true\|false|[a-z][\w |]{0,40})>", | |
| re.IGNORECASE, | |
| ) | |
| _ANGLE_TOKEN_RE = re.compile(r"<[^>\n]{1,80}>") | |
| def parse_template(raw: str) -> dict[str, str] | None: | |
| """Parse line-based coach template; fail if LOOP: is missing.""" | |
| text = raw.strip() | |
| if text.startswith("```"): | |
| text = re.sub(r"^```(?:\w+)?\s*", "", text) | |
| text = re.sub(r"\s*```$", "", text) | |
| fields: dict[str, str] = {} | |
| for line in text.splitlines(): | |
| if ":" not in line: | |
| continue | |
| key, value = line.split(":", 1) | |
| key = key.strip().upper() | |
| value = value.strip() | |
| if key: | |
| fields[key] = value | |
| if "LOOP" not in fields: | |
| return None | |
| return fields | |
| def is_template_echo(raw: str, parsed: dict[str, str] | None = None) -> bool: | |
| """True when model echoed format placeholders instead of filling them.""" | |
| text = (raw or "").strip() | |
| if not text: | |
| return False | |
| if re.search(r"LOOP:\s*<", text, re.IGNORECASE): | |
| return True | |
| if _PLACEHOLDER_RE.search(text): | |
| return True | |
| fields = parsed if parsed is not None else parse_template(text) | |
| if not fields: | |
| return False | |
| for value in fields.values(): | |
| token = value.strip() | |
| if not token: | |
| continue | |
| if _ANGLE_TOKEN_RE.fullmatch(token) or _PLACEHOLDER_RE.search(token): | |
| return True | |
| if token.startswith("<") and token.endswith(">"): | |
| return True | |
| return False | |
| def format_parsed(parsed: dict[str, str]) -> str: | |
| """Rebuild canonical template text from parsed fields.""" | |
| order = [ | |
| "LOOP", | |
| "FEELING", | |
| "INTENSITY_GUESS", | |
| "REMEDY", | |
| "NEXT_BRICK", | |
| "DO_NOT", | |
| "LINE", | |
| "NOTE", | |
| "DATA_THIN", | |
| ] | |
| lines = [] | |
| for key in order: | |
| if key in parsed: | |
| lines.append(f"{key}: {parsed[key]}") | |
| return "\n".join(lines) | |
| def truncate_entry(entry: Entry) -> dict[str, Any]: | |
| """Compact entry fields for coach history.""" | |
| happened = entry.happened[:280] | |
| return { | |
| "id": entry.id, | |
| "ts": entry.ts.isoformat(), | |
| "tags": entry.tags, | |
| "emotions": entry.emotions, | |
| "intensity": entry.intensity, | |
| "remedy": entry.remedy, | |
| "result": entry.result.value, | |
| "happened": happened, | |
| } | |
| class CoachService: | |
| """Build evidence-backed coach replies with model or backup fallback.""" | |
| def __init__( | |
| self, | |
| settings: Settings, | |
| entry_store: EntryStore, | |
| daily_store: DailyStore, | |
| trace_store: TraceStore, | |
| ) -> None: | |
| self.settings = settings | |
| self.entry_store = entry_store | |
| self.daily_store = daily_store | |
| self.trace_store = trace_store | |
| def _all_entry_dicts(self) -> list[dict[str, Any]]: | |
| return [e.model_dump(mode="json") for e in self.entry_store._load()] | |
| def _today_daily(self) -> dict[str, Any] | None: | |
| row = self.daily_store.get(date.today()) | |
| return row.model_dump(mode="json") if row else None | |
| def _daily_dicts(self) -> list[dict[str, Any]]: | |
| return [r.model_dump(mode="json") for r in self.daily_store._load()] | |
| def build_messages( | |
| self, | |
| *, | |
| brief: str, | |
| evidence_block: str, | |
| picks_text: str, | |
| current: dict[str, Any], | |
| today_daily: dict[str, Any] | None, | |
| history: list[dict[str, Any]], | |
| data_thin_flag: bool, | |
| ) -> list[dict[str, str]]: | |
| """Assemble short system/user messages for the free model.""" | |
| system = "\n\n".join( | |
| [ | |
| "Role: brief checklist coach. No pity. No pep talk.", | |
| "NEVER invent numbers — only EVIDENCE / SERVER_PICKS.", | |
| "NEVER cancel committed admin/travel process; fear ≠ cancel.", | |
| "If self-harm language → crisis redirect, no deep exploration.", | |
| OUTPUT_FORMAT, | |
| DEFAULT_POLICY, | |
| strip_sensitive(brief), | |
| ] | |
| ) | |
| user_payload = { | |
| "current": current, | |
| "today_daily": today_daily, | |
| "recent_entries": history, | |
| "EVIDENCE": evidence_block, | |
| "SERVER_PICKS": picks_text, | |
| "DATA_THIN": data_thin_flag, | |
| "instruction": ( | |
| "Choose REMEDY consistent with SERVER_PICKS when possible. " | |
| "Begin with LOOP:" | |
| ), | |
| } | |
| user = strip_sensitive(json.dumps(user_payload, ensure_ascii=False, indent=2)) | |
| return [ | |
| {"role": "system", "content": system}, | |
| {"role": "user", "content": user}, | |
| ] | |
| def call_openrouter( | |
| self, | |
| messages: list[dict[str, str]], | |
| ) -> tuple[str | None, int | None, str | None, int]: | |
| """Call OpenRouter; return content, http_status, error, latency_ms.""" | |
| if not self.settings.openrouter_api_key: | |
| return None, None, "NO_API_KEY", 0 | |
| started = time.perf_counter() | |
| url = f"{self.settings.openrouter_base_url.rstrip('/')}/chat/completions" | |
| headers = { | |
| "Authorization": f"Bearer {self.settings.openrouter_api_key}", | |
| "Content-Type": "application/json", | |
| "X-Title": "Habit Journal", | |
| } | |
| body = { | |
| "model": self.settings.openrouter_model, | |
| "messages": messages, | |
| "temperature": self.settings.coach_temperature, | |
| "max_tokens": self.settings.coach_max_tokens, | |
| } | |
| try: | |
| with httpx.Client(timeout=self.settings.coach_timeout_sec) as client: | |
| response = client.post(url, headers=headers, json=body) | |
| latency_ms = int((time.perf_counter() - started) * 1000) | |
| if response.status_code >= 400: | |
| return None, response.status_code, f"http_{response.status_code}", latency_ms | |
| payload = response.json() | |
| content = ( | |
| payload.get("choices", [{}])[0] | |
| .get("message", {}) | |
| .get("content") | |
| ) | |
| if not content or not str(content).strip(): | |
| return None, response.status_code, "EMPTY_MODEL", latency_ms | |
| return str(content), response.status_code, None, latency_ms | |
| except httpx.TimeoutException: | |
| latency_ms = int((time.perf_counter() - started) * 1000) | |
| return None, None, "TIMEOUT", latency_ms | |
| except Exception as exc: # noqa: BLE001 — fail-open to backup | |
| latency_ms = int((time.perf_counter() - started) * 1000) | |
| return None, None, f"error:{type(exc).__name__}", latency_ms | |
| def coach( | |
| self, | |
| *, | |
| text: str | None = None, | |
| entry_id: str | None = None, | |
| include_history: int | None = None, | |
| persist: bool = False, | |
| force_backup: bool = False, | |
| request_meta: dict[str, Any] | None = None, | |
| ) -> dict[str, Any]: | |
| """Run the full coach pipeline and always return a text reply.""" | |
| if not text and not entry_id: | |
| raise ValueError("require text or entry_id") | |
| entry: Entry | None = None | |
| if entry_id: | |
| entry = self.entry_store.get(entry_id) | |
| if entry is None: | |
| raise KeyError(entry_id) | |
| current_text = text or "" | |
| current_tags: list[str] = [] | |
| current_emotions: list[str] = [] | |
| if entry: | |
| current_tags = list(entry.tags) | |
| current_emotions = list(entry.emotions) | |
| if not current_text: | |
| current_text = f"{entry.activity}. {entry.happened}" | |
| current = { | |
| "text": current_text, | |
| "entry_id": entry.id if entry else None, | |
| "tags": current_tags, | |
| "emotions": current_emotions, | |
| "intensity": entry.intensity if entry else None, | |
| "remedy": entry.remedy if entry else None, | |
| "result": entry.result.value if entry else None, | |
| } | |
| intensity = entry.intensity if entry else None | |
| history_k = ( | |
| self.settings.coach_history_k | |
| if include_history is None | |
| else max(0, include_history) | |
| ) | |
| all_entries = self.entry_store._load() | |
| all_entries.sort(key=lambda item: item.ts, reverse=True) | |
| history = [truncate_entry(item) for item in all_entries[:history_k]] | |
| today_daily = self._today_daily() | |
| evidence = build_evidence( | |
| self._all_entry_dicts(), | |
| self._daily_dicts(), | |
| current_tags, | |
| min_n=self.settings.min_stats_n, | |
| shrink_k=self.settings.stats_shrink_k, | |
| match_alpha=self.settings.match_alpha, | |
| ) | |
| picks = evidence["server_picks"] | |
| brief_meta = self.trace_store.brief_meta( | |
| include_full=self.settings.debug_include_full_brief | |
| ) | |
| brief = self.trace_store.read_brief() | |
| messages = self.build_messages( | |
| brief=brief, | |
| evidence_block=evidence["evidence_block"], | |
| picks_text=evidence["server_picks_text"], | |
| current=current, | |
| today_daily=today_daily, | |
| history=history, | |
| data_thin_flag=evidence["DATA_THIN"], | |
| ) | |
| trace_id = str(uuid4()) | |
| flags: list[str] = [] | |
| if evidence["DATA_THIN"]: | |
| flags.append("DATA_THIN") | |
| raw = None | |
| http_status = None | |
| error = None | |
| latency_ms = 0 | |
| source = "backup" | |
| parsed = None | |
| backup_rule_id = None | |
| model_id = self.settings.openrouter_model | |
| if force_backup or not self.settings.openrouter_api_key: | |
| if not self.settings.openrouter_api_key: | |
| flags.append("NO_API_KEY") | |
| final_text, backup_rule_id = backup_reply( | |
| current_text, | |
| current_tags + current_emotions, | |
| picks, | |
| data_thin=evidence["DATA_THIN"], | |
| intensity=intensity, | |
| ) | |
| source = "backup" | |
| parsed = parse_template(final_text) | |
| else: | |
| raw, http_status, error, latency_ms = self.call_openrouter(messages) | |
| if error == "TIMEOUT": | |
| flags.append("TIMEOUT") | |
| elif error == "EMPTY_MODEL": | |
| flags.append("EMPTY_MODEL") | |
| if raw: | |
| parsed = parse_template(raw) | |
| if parsed and is_template_echo(raw, parsed): | |
| flags.append("TEMPLATE_ECHO") | |
| flags.append("PARSE_FAIL") | |
| parsed = None | |
| if parsed: | |
| source = "model" | |
| final_text = format_parsed(parsed) | |
| remedy = (parsed.get("REMEDY") or "").strip().lower() | |
| pick_keys = {p["remedy_key"].lower() for p in picks} | |
| if picks and remedy and not any( | |
| key in remedy or remedy in key for key in pick_keys | |
| ): | |
| flags.append("OUT_OF_EVIDENCE") | |
| if intensity is not None and intensity >= 7 and "label" in remedy: | |
| flags.append("LABEL_AT_HIGH_INTENSITY") | |
| final_text, backup_rule_id = backup_reply( | |
| current_text, | |
| current_tags + current_emotions, | |
| picks, | |
| data_thin=evidence["DATA_THIN"], | |
| intensity=intensity, | |
| ) | |
| source = "backup" | |
| parsed = parse_template(final_text) | |
| else: | |
| if "PARSE_FAIL" not in flags: | |
| flags.append("PARSE_FAIL") | |
| source = "backup" | |
| final_text, backup_rule_id = backup_reply( | |
| current_text, | |
| current_tags + current_emotions, | |
| picks, | |
| data_thin=evidence["DATA_THIN"], | |
| intensity=intensity, | |
| ) | |
| parsed = parse_template(final_text) | |
| else: | |
| final_text, backup_rule_id = backup_reply( | |
| current_text, | |
| current_tags + current_emotions, | |
| picks, | |
| data_thin=evidence["DATA_THIN"], | |
| intensity=intensity, | |
| ) | |
| source = "backup" | |
| parsed = parse_template(final_text) | |
| now = utc_now() | |
| trace = { | |
| "trace_id": trace_id, | |
| "ts": now.isoformat(), | |
| "request": request_meta | |
| or { | |
| "text": text, | |
| "entry_id": entry_id, | |
| "include_history": history_k, | |
| "persist": persist, | |
| "force_backup": force_backup, | |
| }, | |
| "current": current, | |
| "history_truncated": history, | |
| "evidence": { | |
| "n_scored": evidence["n_scored"], | |
| "DATA_THIN": evidence["DATA_THIN"], | |
| "evidence_block": evidence["evidence_block"], | |
| "by_remedy": evidence["by_remedy"], | |
| "daily": evidence["daily"], | |
| }, | |
| "server_picks": picks, | |
| "brief_sha256": brief_meta["brief_sha256"], | |
| "brief_excerpt": brief_meta["brief_excerpt"], | |
| "brief_full": brief_meta["brief_full"], | |
| "system_prompt": messages[0]["content"], | |
| "user_prompt": messages[1]["content"], | |
| "model_id": model_id if source != "backup" or raw else model_id, | |
| "temperature": self.settings.coach_temperature, | |
| "max_tokens": self.settings.coach_max_tokens, | |
| "latency_ms": latency_ms, | |
| "http_status": http_status, | |
| "error": error, | |
| "raw_model_response": raw, | |
| "parsed": parsed, | |
| "source": source, | |
| "backup_rule_id": backup_rule_id, | |
| "flags": flags, | |
| "app_version": APP_VERSION, | |
| "final_text": final_text, | |
| } | |
| self.trace_store.append_trace(trace) | |
| if persist and entry is not None: | |
| self.entry_store.set_coach( | |
| entry.id, | |
| CoachMeta( | |
| text=final_text, | |
| source=source, | |
| model=model_id if source == "model" else None, | |
| ts=now, | |
| trace_id=trace_id, | |
| ), | |
| ) | |
| return { | |
| "text": final_text, | |
| "source": source, | |
| "model": model_id if source == "model" else None, | |
| "trace_id": trace_id, | |
| "flags": flags, | |
| "server_picks": [ | |
| { | |
| "remedy_key": p["remedy_key"], | |
| "pick": p["pick"], | |
| "n": p["n"], | |
| "p_helped": p["p_helped"], | |
| } | |
| for p in picks | |
| ], | |
| "parsed": parsed, | |
| } | |
| def prompt_preview( | |
| self, | |
| *, | |
| text: str | None = None, | |
| entry_id: str | None = None, | |
| include_history: int | None = None, | |
| ) -> dict[str, Any]: | |
| """Build evidence and messages without calling the model.""" | |
| if not text and not entry_id: | |
| raise ValueError("require text or entry_id") | |
| entry: Entry | None = None | |
| if entry_id: | |
| entry = self.entry_store.get(entry_id) | |
| if entry is None: | |
| raise KeyError(entry_id) | |
| current_text = text or "" | |
| current_tags: list[str] = list(entry.tags) if entry else [] | |
| current_emotions: list[str] = list(entry.emotions) if entry else [] | |
| if entry and not current_text: | |
| current_text = f"{entry.activity}. {entry.happened}" | |
| current = { | |
| "text": current_text, | |
| "entry_id": entry.id if entry else None, | |
| "tags": current_tags, | |
| "emotions": current_emotions, | |
| } | |
| history_k = ( | |
| self.settings.coach_history_k | |
| if include_history is None | |
| else max(0, include_history) | |
| ) | |
| all_entries = self.entry_store._load() | |
| all_entries.sort(key=lambda item: item.ts, reverse=True) | |
| history = [truncate_entry(item) for item in all_entries[:history_k]] | |
| evidence = build_evidence( | |
| self._all_entry_dicts(), | |
| self._daily_dicts(), | |
| current_tags, | |
| min_n=self.settings.min_stats_n, | |
| shrink_k=self.settings.stats_shrink_k, | |
| match_alpha=self.settings.match_alpha, | |
| ) | |
| brief = self.trace_store.read_brief() | |
| messages = self.build_messages( | |
| brief=brief, | |
| evidence_block=evidence["evidence_block"], | |
| picks_text=evidence["server_picks_text"], | |
| current=current, | |
| today_daily=self._today_daily(), | |
| history=history, | |
| data_thin_flag=evidence["DATA_THIN"], | |
| ) | |
| return { | |
| "evidence_block": evidence["evidence_block"], | |
| "server_picks": evidence["server_picks"][:5], | |
| "system_prompt": messages[0]["content"], | |
| "user_prompt": messages[1]["content"], | |
| "DATA_THIN": evidence["DATA_THIN"], | |
| } | |