Files
ai-agent/backend/forms/routes.py
Gabriel Vidal 0ff9e40242 refactor(backend): split main.py into domain packages with app.state injection
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>
2026-10-06 23:55:47 +02:00

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