File size: 6,182 Bytes
c6253b2
 
 
 
 
 
 
 
 
 
 
 
 
 
 
57ed4c2
c6253b2
9af2b22
c6253b2
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
9af2b22
c6253b2
 
 
 
 
9af2b22
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
c6253b2
9af2b22
 
 
 
c6253b2
 
 
 
 
 
 
9af2b22
c6253b2
9af2b22
 
4c23c1f
57ed4c2
 
 
 
 
 
c6253b2
 
 
 
 
 
 
 
4c23c1f
 
57ed4c2
 
c6253b2
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
"""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)