Fastwhisper / app /coach_service.py
Mbonea's picture
Close P1 arch-code gaps: loop summary, template-echo Rules, Move now, proof kinds.
57ed4c2
Raw
History Blame Contribute Delete
18.8 kB
"""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"],
}