main.py (5146 lines, 106 routes) becomes an assembly only: one package per domain — core, files, settings, dashboards, conversations, diff, notifications, forms, runs, accounts, workers, models, cron, agents, projects, services, goals, memories, plans, templates — each exposing an APIRouter; the flat domain modules move into their package behind a barrel that keeps the old `import conversations` / `import projects` spellings. The shared singletons (store, meta_store, hub, indexer, …) are built once by core.state.build_state() and attached to app.state.ai; routes take them as the `deps: State` dependency and helpers as an explicit `deps: AppState`. conversations/pricing.py carries the per-model rates out of the parser. Verified: route table and OpenAPI byte-identical; 90 read endpoints golden-diffed against the monolith on a copy of the live data (identical); write routes smoke-tested; 66 backend tests pass. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
125 lines
4.9 KiB
Python
125 lines
4.9 KiB
Python
"""Plans routes, with the actuals joined from the linked conversations."""
|
|
|
|
import datetime
|
|
|
|
from fastapi import APIRouter, HTTPException
|
|
from pydantic import BaseModel
|
|
|
|
import forms as forms_mod
|
|
import plans as plans_mod
|
|
import schemas
|
|
from conversations.catalog import _conv_card
|
|
from core.http import _r
|
|
from core.state import State
|
|
|
|
router = APIRouter()
|
|
|
|
|
|
# ── plans (data/plans/, written by the `plan` skill — on-machine, not tracked) ─
|
|
def _duration_ms(started: str | None, ended: str | None) -> int | None:
|
|
if not started or not ended:
|
|
return None
|
|
try:
|
|
a = datetime.datetime.fromisoformat(started.replace("Z", "+00:00"))
|
|
b = datetime.datetime.fromisoformat(ended.replace("Z", "+00:00"))
|
|
return max(0, int((b - a).total_seconds() * 1000))
|
|
except ValueError:
|
|
return None
|
|
|
|
|
|
@router.get("/api/plans", responses=_r(schemas.PlansResponse))
|
|
def plans_list():
|
|
"""All plan summaries (data/plans/*.md), newest-updated first."""
|
|
return {"plans": plans_mod.list_plans()}
|
|
|
|
|
|
@router.get("/api/plan", responses=_r(schemas.PlanDetail))
|
|
def plan_detail(deps: State, slug: str):
|
|
"""One plan + body, with the ACTUAL cost/time/tokens joined from the linked
|
|
conversation transcripts (shown next to the plan's own estimate)."""
|
|
plan = plans_mod.plan_detail(slug)
|
|
if plan is None:
|
|
raise HTTPException(404, "plan not found")
|
|
# sessionId → newest transcript summary for it.
|
|
by_sid: dict[str, tuple[str, dict]] = {}
|
|
for path, s in deps.store.all_summaries():
|
|
sid = s.get("sessionId")
|
|
if not sid or not s.get("messages"):
|
|
continue
|
|
prev = by_sid.get(sid)
|
|
if prev is None or (s.get("endedAt") or "") > (prev[1].get("endedAt") or ""):
|
|
by_sid[sid] = (path, s)
|
|
convs, cost, tokens, dur = [], 0.0, 0, 0
|
|
by_account: dict[str, float] = {}
|
|
for sid in plans_mod.linked_session_ids(plan):
|
|
hit = by_sid.get(sid)
|
|
if not hit:
|
|
continue
|
|
path, s = hit
|
|
card = _conv_card(deps, path, s)
|
|
card["startedAt"] = s.get("startedAt")
|
|
card["durationMs"] = _duration_ms(s.get("startedAt"), s.get("endedAt"))
|
|
convs.append(card)
|
|
cost += s.get("cost") or 0.0
|
|
tokens += s.get("tokens") or 0
|
|
dur += card["durationMs"] or 0
|
|
acct = card["meta"]["account"]
|
|
by_account[acct] = by_account.get(acct, 0.0) + (s.get("cost") or 0.0)
|
|
plan["conversations"] = convs
|
|
# byAccount: {account: cost} — a plan implemented on the work seat shows
|
|
# its actual as the seat's estimate, not as the owner's spend.
|
|
plan["actual"] = {"cost": cost, "tokens": tokens,
|
|
"durationMs": dur or None,
|
|
"byAccount": by_account} if convs else None
|
|
# Persisted answers: the newest submitted form this plan's questions
|
|
# produced (POST /api/plan/answers) — list() is newest-first.
|
|
for f in deps.form_store.list(limit=500):
|
|
if (f.get("type") == "plan" and f.get("status") == "submitted"
|
|
and f.get("url") == f"/plans/{plan['slug']}"):
|
|
plan["answers"] = f.get("answers")
|
|
plan["answersAt"] = f.get("submittedAt")
|
|
plan["answersFormId"] = f.get("id")
|
|
break
|
|
return plan
|
|
|
|
|
|
class PlanAnswersBody(BaseModel):
|
|
slug: str
|
|
answers: dict
|
|
|
|
|
|
@router.post("/api/plan/answers", responses=_r(schemas.FormRecord))
|
|
def plan_answers(deps: State, body: PlanAnswersBody):
|
|
"""Persist a plan's question-form answers.
|
|
|
|
Validates against the plan's ``## Questions`` spec, folds an ``## Answers``
|
|
section into the plan file itself (data/plans is the one writable corner of
|
|
the data mount), and records a submitted form (type ``plan``, linked to the
|
|
planning session) so the answers show on the Forms page and in the store."""
|
|
plan = plans_mod.plan_detail(body.slug)
|
|
if plan is None:
|
|
raise HTTPException(404, "plan not found")
|
|
qform = plan.get("questionForm")
|
|
if not qform or not qform.get("fields"):
|
|
raise HTTPException(400, "plan has no questions")
|
|
try:
|
|
checked = forms_mod.check_answers(qform["fields"], body.answers)
|
|
except ValueError as e:
|
|
raise HTTPException(400, str(e))
|
|
lines = forms_mod.answer_lines(qform["fields"], checked)
|
|
stamp = (datetime.datetime.now(datetime.timezone.utc)
|
|
.strftime("%Y-%m-%d %H:%M UTC"))
|
|
try:
|
|
plans_mod.write_answers(plan["slug"], lines, stamp)
|
|
except OSError as e:
|
|
raise HTTPException(503, f"plan file not writable: {e}")
|
|
form = deps.form_store.create(
|
|
f"Plan: {plan['title']}", qform["fields"],
|
|
description=qform.get("description") or "",
|
|
type_="plan", url=f"/plans/{plan['slug']}",
|
|
session_id=plan.get("sessionId") or "")
|
|
form = deps.form_store.submit(form["id"], checked)
|
|
deps.hub.publish({"type": "form", "id": form["id"],
|
|
"sessionId": form.get("sessionId") or ""})
|
|
return form
|