Skip to content

Architecture

The big picture

flowchart TB
    subgraph Browser
        UI["React app<br/>Mantine, TanStack Router/Query"]
        GEN["Orval-generated client<br/>CRUD, settings, usage"]
        SSE["useSummaryStream<br/>EventSource, hand-written"]
        UI --- GEN
        UI --- SSE
    end

    subgraph API["FastAPI (async)"]
        R["Routers<br/>stocks, sources, settings, usage"]
        SVC["summary.service<br/>stream_summary"]
        GATE["LLMGate<br/>asyncio.Semaphore"]
        FETCH["NewsFetcher<br/>concurrency, merge"]
        DOWN["FeedDownloader<br/>HTTP, redirects, limits"]
        PARSE["RSS / Atom parser"]
        AG["PydanticAI Agent<br/>structured StockSummary"]
        DB[("SQLite<br/>aiosqlite + SQLModel")]
        R --> SVC
        SVC --> FETCH --> DOWN --> PARSE
        SVC --> GATE --> AG
        R --- DB
    end

    GEN -->|REST JSON| R
    SSE -->|"GET /summary/stream"| R
    DOWN -->|HTTP| FEEDS[("RSS / Atom feeds")]
    AG -->|"OpenAI-compatible API"| LLM{{"Ollama or OpenAI"}}

Design principles

Principle How it shows up
Everything async httpx2.AsyncClient, AsyncSession + aiosqlite, async endpoints, async PydanticAI streaming.
One model call at a time LLMGate is an asyncio.Semaphore (default 1). Extra requests queue and the UI says so.
Abandoned work is cancelled A closed connection cancels the stream, which drops the model call and frees the gate.
Structured output, not prose The model must return a StockSummary; the UI renders fields, not text.
Service knows nothing about HTTP stream_summary yields plain events; api/stocks.py only formats them as SSE.
Config in the database Model, key and thinking mode are editable at runtime; env vars only seed first start.
Contract first FastAPI's OpenAPI schema feeds Orval; CI checks that the committed schema matches the backend.

Backend module map

backend/src/zeroai/
├── asgi.py              process bootstrap and exported ASGI app
├── bootstrap.py         process-wide logging and tracing setup
├── main.py              app factory and lifespan: engine, shared HTTP clients, LLM gate
├── config.py            pydantic-settings (ZEROAI_*), seeds only
├── logging.py           structlog setup + request-id middleware
├── database.py          async engine, Alembic upgrade runner, seeding
├── migrations/          ordered Alembic schema revisions
├── models.py            NewsSource, LLMConfig, UsageRecord tables
├── schemas.py           API shapes incl. StockSummary (the LLM output type)
├── api/
│   ├── deps.py          session, news fetcher, LLM runtime, gate, ticker validation
│   ├── stocks.py        news, summary, summary/stream (SSE)
│   ├── sources.py       news-source CRUD
│   ├── settings.py      LLM configuration (key is write-only)
│   └── usage.py         per-model and recent-run statistics
├── news/
│   ├── fetcher.py       concurrency, source orchestration, merge and sorting
│   ├── downloader.py    HTTP, redirect validation, retries and response limits
│   └── parser.py        RSS/Atom parsing and item normalization
└── summary/
    ├── agent.py         model factory, instructions, reasoning settings, LLMRuntime
    ├── limiter.py       LLMGate
    ├── service.py       fetch -> summarise orchestration and event emission
    ├── stream_parser.py PydanticAI event decoding and partial-summary validation
    └── usage.py         record and aggregate token usage

Frontend module map

frontend/src/
├── api/                 axios-instance.ts (mutator), generated/ (Orval, do not edit)
├── hooks/               useSummaryStream (SSE), useElapsed (timer)
├── components/          SummaryCard, ThinkingPanel, NewsList, BrailleSpinner
├── pages/               SummaryPage, SourcesPage, SettingsPage
├── stores/ui.ts         persisted Zustand store: theme mode, last ticker
├── theme/               classic.ts, terminal.ts (Mantine themes), ThemeMode.tsx
├── router.tsx           TanStack Router, header/nav
└── styles.css, terminal.css  Classic skin and the Terminal skin

The OpenAPI contract loop

flowchart LR
    P["FastAPI routes<br/>+ Pydantic schemas"] -->|"scripts/export_openapi.py"| J[openapi.json]
    J -->|"yarn api:generate"| G["Orval client<br/>typed React Query hooks"]
    G --> UI[Pages]
    CI{{"CI api-contract job"}} -.->|"streams and diffs<br/>the schema"| J

Why one hook is hand-written

OpenAPI cannot describe a Server-Sent Events stream, so Orval cannot generate it. That one hook, useSummaryStream, mirrors the event shapes documented in Streaming.