"""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: FEELING: INTENSITY_GUESS: <1-10|n/a> REMEDY: NEXT_BRICK: DO_NOT: LINE: <≤12 words> NOTE: DATA_THIN: """ 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"], }