Fastwhisper / app /routers /agent.py
Mbonea's picture
Close P1 arch-code gaps: loop summary, template-echo Rules, Move now, proof kinds.
57ed4c2
Raw
History Blame Contribute Delete
6.18 kB
"""Agent (Cursor) schedule endpoints using Bearer AGENT_TOKEN or session.
GET context packs priors, risks, and constraints; PUT replaces the day plan.
"""
from __future__ import annotations
import hmac
from datetime import date, datetime, timedelta, timezone
from typing import Any
from fastapi import APIRouter, Depends, Request, status
from app.deps import require_login
from app.models import ApiEnvelope, err, ok
from app.routers.loop import loop_summary_for
from app.schedule_math import capacity_hint, trigger_operators
from app.schedule_risk import risk_from_stores
from app.schedule_store import DayPlanPut
router = APIRouter(prefix="/api/agent", tags=["agent"])
def require_agent_or_login(request: Request) -> str:
"""Allow AGENT_TOKEN bearer or a valid human session."""
settings = request.app.state.settings
auth = request.headers.get("authorization") or ""
if auth.lower().startswith("bearer ") and settings.agent_token:
token = auth[7:].strip()
if hmac.compare_digest(token, settings.agent_token):
return "agent"
# Fall back to session login
require_login(request)
return "session"
@router.get("/context", response_model=ApiEnvelope)
def agent_context(
request: Request,
day: date | None = None,
_auth: str = Depends(require_agent_or_login),
) -> dict[str, Any]:
settings = request.app.state.settings
target = day or datetime.now(timezone.utc).date()
day_s = target.isoformat()
store = request.app.state.schedule_store
entry_store = request.app.state.entry_store
daily_store = request.app.state.daily_store
now = datetime.now(timezone.utc)
since = now - timedelta(hours=48)
risk_tags_extra = {"home", "spain", "build"}
entries_48h = []
for entry in entry_store._load():
ts = entry.ts if entry.ts.tzinfo else entry.ts.replace(tzinfo=timezone.utc)
if ts < since:
continue
entries_48h.append(
{
"id": entry.id,
"ts": entry.ts.isoformat(),
"tags": entry.tags,
"emotions": entry.emotions,
"intensity": entry.intensity,
"remedy": entry.remedy,
"result": entry.result.value,
"happened": entry.happened[:280],
}
)
risk = risk_from_stores(entry_store, daily_store, now=now, target_day=target)
# Keep agent context tags slightly broader for Spain/home signals.
tags_1h = sorted(set(risk["risk_1h_tags"]))
for entry in entry_store._load():
ts = entry.ts if entry.ts.tzinfo else entry.ts.replace(tzinfo=timezone.utc)
if ts < now - timedelta(hours=1):
continue
hit = sorted((set(entry.tags) | set(entry.emotions)) & risk_tags_extra)
tags_1h = sorted(set(tags_1h) | set(hit))
risk_score = float(len(tags_1h))
last_hour_high = risk["last_hour_high"] or bool(
{"urge", "court", "corn", "rerun"} & set(tags_1h)
)
triggers_y = risk["triggers_yesterday"]
cap = capacity_hint(
risk_1h_score=risk_score,
triggers_yesterday=triggers_y,
yesterday_trigger=len(triggers_y) > 0,
last_hour_high=last_hour_high,
)
ops = trigger_operators(risk_1h_tags=tags_1h, triggers_yesterday=triggers_y)
pending = sum(1 for e in entry_store._load() if e.result.value == "pending")
priors = store.load_priors()
y_daily = daily_store.get(target - timedelta(days=1))
t_daily = daily_store.get(target)
movement = daily_store.movement_for(target)
loop = loop_summary_for(
entry_store=entry_store,
daily_store=daily_store,
as_of=target,
days=7,
)
return ok(
{
"date": day_s,
"timezone": settings.schedule_timezone,
"now": now.isoformat(),
"daily_yesterday": y_daily.model_dump(mode="json") if y_daily else None,
"daily_today": t_daily.model_dump(mode="json") if t_daily else None,
"movement": movement,
"indoors_streak": movement["indoors_streak"],
"label_only_fail_rate": loop["label_only_fail_rate"],
"loop_summary": loop,
"entries_last_48h": entries_48h,
"risk_1h": {"score": risk_score, "tags": tags_1h, "high": last_hour_high},
"triggers_yesterday": triggers_y,
"capacity_hint": cap,
"operators": ops,
"priors": list(priors.values()),
"server_priors_markdown": store.priors_table(),
"current_plan": store.plan_with_feedback(day_s),
"pending_entries_count": pending,
"templates": store.templates(),
"constraints": {
"max_blocks": settings.schedule_max_blocks,
"must_include_explore_or_restore": True,
"p0_locked": True,
"pad_with_d_hat": True,
"day_start": settings.schedule_day_start,
"day_end": settings.schedule_day_end,
"scope": "P1_thin_loop",
"no_perma_fields": True,
"ev_formula_locked": True,
"schedule_does_not_replace_daily_or_log": True,
},
"formulas": (
"EV=P(done)*U+纬*I+畏*Fun-位t*d_hat-位m*cost-位f*FSE (locked; no 未/H/PERMA). "
"d_hat=(n/(n+m))*mean_actual+(m/(n+m))*prior. "
"On trigger_day: raise 位_f and 畏; P0 only overrides. "
"Escape鈮爀xplore. Server owns p_helped/rank/picks from logs."
),
}
)
@router.put("/plan/{day}", response_model=ApiEnvelope)
def agent_put_plan(
request: Request,
day: date,
body: DayPlanPut,
_auth: str = Depends(require_agent_or_login),
) -> object:
put = body.model_copy(update={"source": body.source or "cursor"})
try:
request.app.state.schedule_store.save_plan(day.isoformat(), put)
return ok(request.app.state.schedule_store.plan_with_feedback(day.isoformat()))
except ValueError as exc:
return err("validation_error", str(exc), status.HTTP_422_UNPROCESSABLE_ENTITY)