Design spec for the next milestone of hipo (HK IPO Verify). Focuses on Brief A only; Briefs B/C/D are explicitly deferred. Source of truth for requirements is ../../goal.md; product vision in ../../initial-plan.md.
Ship a demoable MVP in two pieces, on top of the already-live infrastructure
(catalog Worker, R2 cache, parse/chunk/validate tools, Factsheet schema,
LLM gateway client):
- Agent backend (AWS Lambda) — turn the currently-deterministic
/factsheet/{id}into a bounded ReAct loop that calls one model via the gateway to extract cited claims, validates against the schema, and emits a replayableagent_trace. It streams reason→act→observe steps live over SSE from a Lambda Function URL, ending at a hard "never auto-act" interrupt. - Analyst-workstation SPA (Cloudflare Pages) — React + Vite. A list of recent PHIP filings (one row per stock, an Analyze button per row) and an analysis view: four factsheet panels, a live-filling agent trace, page-level citations that deep-link to the source PDF, the three degradation states, and a persistent boundary banner.
The product signature is trust: an inspectable agent behind a polished UI, held inside hard boundaries — not autonomy.
- Single model, not multi-model. One cheap extractor per claim. The multi-model agreement panel (Brief B) is deferred; the schema and trace are built so it drops in later without rework.
- SSE streaming from a Lambda Function URL in
RESPONSE_STREAMmode — the agent visibly "thinks", and it sidesteps the ~29s API Gateway ceiling. The existing buffered/factsheet/{id}endpoint stays for tests and non-streaming callers. - React 19 + Vite + TypeScript on Cloudflare Pages (the literal match for Brief A's "React summary" wording).
- PHIP only in the list (richest factsheet; fixture
app-108691-1on hand). The catalog Worker already indexes all three doc types; the list filters todocType === "phip"client-side. - Visual direction: calm-institutional light theme (dense table, restrained
indigo accent, white surfaces) as the default, with a dark-mode toggle
seeded from
prefers-color-schemeand persisted. Analysis view uses the right-rail agent trace layout (reasoning always in view).
Deferred out of this MVP: multi-model agreement (Brief B), CI eval gate (Brief C), trust-degradation alarm (Brief D).
New apps/api/hipo_api/agent/ package, separate from the existing deterministic
tools so each unit stays small and testable.
agent/tools.py— the whitelisted action space, each a plain function with a deterministic schema:parse,chunk,extract(the only model-calling tool, via the gateway client),validate. The registry is built to acceptcompare/arbitratelater but they are not in the MVP whitelist.agent/loop.py— the bounded controller: reason → act → observe, a hard step cap (8), a tool whitelist it cannot escape, and a single terminal actioninterruptthat surfaces the draft and stops. It never calls anything outside the registry. Each iteration appends aTraceStep.agent/trace.py—AgentTrace/TraceStepPydantic models, added topackages/contractsso the frontend gets matching types.agent/stream.py— renders eachTraceStepas an SSEevent: stepand the final payload asevent: result(orevent: rejected).
The loop is code-driven, not a model deciding each step. The model is called
only inside extract, to pull claims + citations from candidate chunks. The
"thoughts" are real controller decisions ("parsed 214 pages", "extracting from 6
candidate chunks", "validation failed on 1 field, degrading to Partial"). Cost is
essentially one extract call per filing, and the trace is honest and inspectable.
Re-planning is genuine: if extract returns a claim whose citation fails
validate, the loop drops that claim, records it in the trace, and ends
Partial rather than fabricating.
lambda_handler.py gains a streaming entry (Lambda RESPONSE_STREAM) alongside
the existing Mangum handler. New route POST /analyze/{id} streams SSE; the
buffered POST /factsheet/{id} is unchanged.
- Full — all extracted fields validated (schema + citations).
- Partial — one or more claims dropped on citation/schema failure; the surviving validated output renders with a degraded badge.
- Rejected — the schema/citation gate fails wholesale; a
Rejectionis emitted, no unsupported claims rendered.
extract receives candidate chunks (each tagged with its source page) and asks
the model for claims in the Factsheet shape, each carrying page citations. Code
— not the model — normalizes the model output into the Pydantic schema before
validate runs. The model never sees or controls the loop; it only returns
candidate claims.
Loop tests with a fake gateway client assert: never exceeds the step cap;
never calls outside the whitelist; always terminates at interrupt; and each of
the three degradation states produces the correct payload. Plus an SSE-framing
test. Existing parse/chunk/validate/retrieval tests stay green.
apps/web/ — React 19 + Vite + TS, deployed to Pages. Small, well-bounded units:
api/client.ts— the only network module. Two calls:GET {CATALOG_BASE_URL}/catalog(filtered todocType === "phip"), and the streamingPOST {API_BASE_URL}/analyze/{id}read viafetch+ReadableStreamreader parsing SSE frames.theme.ts+ a toggle — CSS custom properties,prefers-color-schemedefault, persisted tolocalStorage, switched via<html data-theme>.- Routes:
FilingList(entry screen) andAnalysis/:id(workstation). Analysiscomposes:Overview,FinancialHighlights,KeyRisks,TraceRail(right rail — streams live then stays for replay),BoundaryBanner(persistent),DegradationBadge(Full/Partial/Rejected),CitationLink(deep-links to the cached PDF at the cited page).- Types come from
packages/contracts— theFactsheet+AgentTraceshapes mirrored/generated as TS so front and back share one contract.
list → click Analyze → navigate to /analysis/:id → open SSE stream → each
event: step appends to the trace rail live → terminal event: result renders
the four panels (or event: rejected renders the Rejected state with its reason,
no unsupported claims). Citations render as buttons; clicking opens the cached
PDF at the cited page ({CATALOG_BASE_URL}/filing/{id}#page=N) in a new tab.
- Entry screen: header + "recent PHIP filings, refreshed daily", then a dense table — one row per stock (name, doc type, date, sector/page count) and an Analyze button per row.
- Analysis view: boundary banner pinned at top; factsheet panels on the left
(~2/3), agent-trace + trust rail on the right (~1/3, always visible); citations
as inline
[p.N]buttons. - Theme: calm-institutional light default, indigo accent, excellent
typography; dark-mode toggle seeded from
prefers-color-scheme, persisted.
- Stream error / model timeout mid-analysis → the trace shows the failed step and the UI settles into Partial (if validated claims survived) or Rejected (if not) — never a hanging spinner.
- Catalog fetch failure → a plain "couldn't load filings, retry" state.
- Every response carries the correlation ID; the frontend surfaces it in the trust rail.
Vitest + React Testing Library: list renders rows; Analyze navigates; the SSE
reducer appends steps in order; each degradation state renders correctly; the
citation link builds the right #page=N URL. Accessibility: axe check in tests
plus keyboard-navigable citations and visible focus states.
mise run checkstays the single green gate;apps/weblint/test wire into it as the catalog Worker already does.- Stack conventions (mirroring
~/dev/basis-vol-lab/apps/web): Vite +@vitejs/plugin-react, Tailwind v4 (@tailwindcss/vite), Biome, Bun; acheckscript ofbiome check src/ && tsc -b. React 19 (upgrade from the reference's 18) + React Router. - Hosting: Cloudflare Pages, custom domain hipo.vsh852.com. Following the
catalog split, Terraform owns the Pages project + custom domain + DNS record
(
vsh852.comzone), and CI deploys the builtdist/viawrangler pages deployon green.basis-vol-labdeploys its web app via Docker/nginx, not Pages, so the Pages wiring is net-new here. - CORS: the Lambda Function URL must allow the
https://hipo.vsh852.comorigin (andhttp://localhost:5173for dev). - Auth: the existing API authorizer stays; the SPA calls the API with the
configured key. (This is a public-data demo; no user accounts.) The SPA reads
CATALOG_BASE_URL,API_BASE_URL, and the API key from Vite build-time env (VITE_*) injected by the Pages build; nothing secret is committed. Since the key ships to the browser in this demo, it is a low-privilege demo key scoped to these read/analyze routes only — noted as a demo limitation, not production auth. - No new paid infra — Pages, Lambda Function URL, and the existing R2/KV/gateway all sit in free tiers.
Multi-model comparison, arbiter, eval gate/CI regression test, the observability alarm, user upload, doc types other than PHIP, and any agent action outside the tool whitelist.
- Deployed Pages link (or screenshots) showing: the PHIP list, a live-streaming analysis, and each of Full / Partial / Rejected.
- Every rendered claim deep-links to its cited source page.
- The persistent "never auto-act" boundary banner is always visible on the analysis view.
- The agent trace replays every reason→act→observe step and always ends at the interrupt.
mise run checkgreen (backend + frontend + infra).