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>
99 lines
3.7 KiB
Python
99 lines
3.7 KiB
Python
"""Forms: rich structured questions answered in the PWA."""
|
|
|
|
|
|
from fastapi import APIRouter, HTTPException
|
|
from pydantic import BaseModel
|
|
|
|
import schemas
|
|
from conversations.catalog import _conv_ids_by_session
|
|
from core.http import _json_in_worker, _r
|
|
from core.state import State
|
|
from forms.service import _create_form
|
|
|
|
router = APIRouter()
|
|
|
|
|
|
# ── forms: rich structured questions answered in the PWA (forms.py) ──────────
|
|
class FormCreateBody(BaseModel):
|
|
title: str
|
|
fields: list[dict]
|
|
description: str | None = None
|
|
type: str | None = None
|
|
url: str | None = None
|
|
sessionId: str | None = None
|
|
id: str | None = None
|
|
# True: the hub pushes the "📋 <title>" notification itself, deep-linked
|
|
# to the conversation with ?form=<id> (the notify CLI's way; the legacy
|
|
# skill sent its own push after creating the form).
|
|
notify: bool | None = None
|
|
# Files the spec references as ``{{asset:<name>}}`` (an html block's image,
|
|
# video…), inlined base64 by the CLI so the same payload works through a
|
|
# worker's outbox: ``[{name, contentType?, data}]``. Stored like composer
|
|
# uploads and substituted by their serve URL before validation.
|
|
assets: list[dict] | None = None
|
|
|
|
|
|
class FormSubmitBody(BaseModel):
|
|
answers: dict
|
|
|
|
|
|
@router.post("/api/forms", responses=_r(schemas.FormRecord))
|
|
def form_create(deps: State, body: FormCreateBody):
|
|
"""Create a form (the ask-form skill's entry point) and return it.
|
|
|
|
The caller then sends its own push notification linking to the
|
|
conversation with ``?form=<id>`` and long-polls GET /api/forms/{id}."""
|
|
return _create_form(deps, body.model_dump())
|
|
|
|
|
|
@router.get("/api/forms", responses=_r(schemas.FormsResponse))
|
|
@_json_in_worker
|
|
def forms_list(deps: State, session: str = "", limit: int = 100):
|
|
"""Forms, newest first; ``session`` filters to one conversation. Each form
|
|
with a known transcript carries ``conversationId`` — its way back to the
|
|
conversation it belongs to."""
|
|
items = deps.form_store.list(session_id=session or None, limit=limit)
|
|
if any(f.get("sessionId") for f in items):
|
|
by_sid = _conv_ids_by_session(deps, )
|
|
for f in items:
|
|
cid = by_sid.get(f.get("sessionId") or "")
|
|
if cid:
|
|
f["conversationId"] = cid
|
|
return {"forms": items, "count": len(items)}
|
|
|
|
|
|
@router.get("/api/forms/{form_id}", responses=_r(schemas.FormRecord))
|
|
def form_get(deps: State, form_id: str, waitSecs: float = 0):
|
|
"""The form's current state; ``waitSecs`` long-polls for a submit."""
|
|
form = (deps.form_store.wait(form_id, waitSecs) if waitSecs > 0
|
|
else deps.form_store.get(form_id))
|
|
if not form:
|
|
raise HTTPException(404, "unknown form")
|
|
return form
|
|
|
|
|
|
@router.post("/api/forms/{form_id}/submit", responses=_r(schemas.FormRecord))
|
|
def form_submit(deps: State, form_id: str, body: FormSubmitBody):
|
|
"""Record the user's answers (from the PWA's form card / modal)."""
|
|
try:
|
|
form = deps.form_store.submit(form_id, body.answers)
|
|
except ValueError as e:
|
|
raise HTTPException(400, str(e))
|
|
if not form:
|
|
raise HTTPException(404, "unknown form")
|
|
deps.hub.publish({"type": "form", "id": form_id,
|
|
"sessionId": form.get("sessionId") or ""})
|
|
return form
|
|
|
|
|
|
@router.post("/api/forms/{form_id}/cancel", responses=_r(schemas.FormRecord))
|
|
def form_cancel(deps: State, form_id: str):
|
|
"""Dismiss a form unanswered (the card's Cancel button, or the agent
|
|
giving up on the wait)."""
|
|
form = deps.form_store.cancel(form_id)
|
|
if not form:
|
|
raise HTTPException(404, "unknown form")
|
|
deps.hub.publish({"type": "form", "id": form_id,
|
|
"sessionId": form.get("sessionId") or ""})
|
|
return form
|