Spaces:
Sleeping
Sleeping
| title: AlphaBrief | |
| emoji: π°οΈ | |
| colorFrom: indigo | |
| colorTo: gray | |
| sdk: docker | |
| app_port: 7860 | |
| pinned: false | |
| short_description: "Research agents that cannot make a number up" | |
| # AlphaBrief | |
| [](https://github.com/Abdr007/alphabrief/actions/workflows/ci.yml) | |
| **Multi-Agent Research Orchestration System with MCP Tooling & Human-in-the-Loop Governance** | |
| A supervisor-pattern agent system that produces a verified morning research brief | |
| for a watchlist. A supervisor plans the run, a data agent and a news agent work | |
| in parallel through a Model Context Protocol tool server, a writer synthesises a | |
| Pydantic-enforced brief, a deterministic verifier recomputes every number, and a | |
| human approves before anything ships. | |
| > **The LLM never does arithmetic.** Tools compute over MCP, a deterministic node | |
| > recomputes every figure in the final brief, and a human gate signs off β | |
| > hallucinated numbers are impossible by construction, not by prompt-begging. | |
| | | | | |
| | --- | --- | | |
| | **Stack** | LangGraph Β· MCP (Model Context Protocol) Β· Claude Sonnet 4.6 + Haiku 4.5 Β· FastAPI Β· yfinance Β· Next.js 15 Β· Neon Postgres Β· Langfuse Β· Docker Β· GCP Cloud Run Β· Terraform Β· Vercel Cron | | |
| | **Cost** | `$0`. yfinance and RSS are free and keyless; Cloud Run, Vercel, Neon and Langfuse free tiers; ~$0.05β0.15 of Claude per full 5-ticker brief | | |
| | **Quality gates** | ruff Β· ruff-format Β· mypy `--strict` Β· 190 pytest tests Β· eslint Β· tsc Β· `next build` β all zero-warning | | |
| | **Verification** | 100% of numeric claims recomputed from raw price bars before delivery | | |
| --- | |
| ## Why this exists | |
| I did financial data analysis manually for two years β pulling prices, computing | |
| ratios, scanning news, writing the same morning picture. AlphaBrief is that job | |
| as a governed agent system. | |
| The interesting part is not that agents can fetch data. It is that **the system | |
| is built so that a wrong number cannot reach the reader**: | |
| 1. **Agents decide *what* to compute; code computes it.** Every figure comes from | |
| an MCP tool. The model chooses which metric matters, never what it equals. | |
| 2. **The brief cannot contain a typed number.** The Pydantic schema rejects any | |
| narrative string containing a bare numeral. Figures are written as `{{c7}}` | |
| references into a claim table minted from tool output. | |
| 3. **Every claim is recomputed independently.** A deterministic node recalculates | |
| each figure from the raw price bars, through a *different implementation* than | |
| the tool used, and compares to the cent. | |
| 4. **Every quotation is matched.** Quoted headlines must appear verbatim in the | |
| news actually retrieved for that ticker. | |
| 5. **A human signs off.** The graph pauses at a LangGraph interrupt over a | |
| checkpointer. There is no path from the verifier to delivery that skips it. | |
| --- | |
| ## Architecture | |
| ```mermaid | |
| graph TD | |
| TRIGGER["Vercel Cron 07:00 weekdays<br/>or the RUN button"] --> SUP | |
| SUP["<b>SUPERVISOR</b> Β· Haiku 4.5<br/>plans Β· parallel fan-out Β· retries once<br/>hard cap 15 iterations + budget guard"] | |
| SUP -.parallel fan-out.-> DATA["<b>DATA AGENT</b> Β· Sonnet 4.6<br/>price_history Β· fundamentals Β· compute_metrics"] | |
| SUP -.parallel fan-out.-> NEWS["<b>NEWS AGENT</b> Β· Sonnet 4.6<br/>fetch_rss_news β sentiment Β· risk events"] | |
| DATA --> MCP["<b>MCP TOOL SERVER</b><br/>standardised Β· discoverable Β· reusable by any framework"] | |
| NEWS --> MCP | |
| MCP --> STATE["<b>SHARED STATE</b> β typed, reducer-merged<br/>parallel writes merge; no lost updates"] | |
| STATE --> SUP | |
| STATE --> WRITER["<b>WRITER</b> Β· Sonnet 4.6<br/>Pydantic-enforced brief Β· assembles only"] | |
| WRITER --> VERIFY["<b>VERIFICATION NODE</b> β deterministic, never an LLM<br/>recomputes every claim from raw bars"] | |
| VERIFY -.mismatch, once.-> WRITER | |
| VERIFY --> GATE["<b>HUMAN GATE</b> β LangGraph interrupt + checkpointer<br/>Approve / Edit / Reject"] | |
| GATE --> DELIVER["<b>DELIVERY & ARCHIVE</b><br/>SMTP email Β· Neon Postgres Β· Langfuse trace"] | |
| ``` | |
| The graph is assembled in [`app/graph/build.py`](apps/api/app/graph/build.py) and | |
| this diagram is reproducible with: | |
| ```bash | |
| python -c "import sys; sys.path.insert(0,'apps/api'); from app.graph.build import mermaid_diagram; print(mermaid_diagram())" | |
| ``` | |
| ### The parallel fan-out, concretely | |
| The supervisor's conditional edge returns a **list** of node names, which puts | |
| both workers in the same LangGraph superstep. They write to the same state object | |
| concurrently, so every shared channel carries an explicit reducer: | |
| ```python | |
| prices: Annotated[dict[str, PriceHistory], merge_mapping] # data agent | |
| sentiment: Annotated[dict[str, Sentiment], merge_mapping] # news agent | |
| errors: Annotated[list[RunError], append_errors] # BOTH | |
| attempts: Annotated[dict[str, int], merge_counters] # BOTH | |
| token_spend: Annotated[TokenSpend, merge_spend] # BOTH | |
| iterations: Annotated[int, operator.add] # supervisor | |
| ``` | |
| There is no read-modify-write anywhere β only commutative merges applied by the | |
| runtime. `tests/test_reducers.py` proves both agents' writes survive a real | |
| superstep, not a mocked one. | |
| --- | |
| ## The MCP tool server | |
| All market, news and metric capability is exposed over MCP, so the tools are | |
| standardised, discoverable, and reusable by **any** agent framework β not just | |
| this app. The server runs over stdio inside the same container | |
| (`python -m app.mcp_server`), and the same tools are listable over HTTP at | |
| `GET /v1/mcp/tools`. | |
| The tool set is a **closed whitelist**, enforced on both the server and the | |
| client. There is no `run_python`, no `eval`, no shell. | |
| | Tool | Signature | Returns | | |
| | --- | --- | --- | | |
| | `get_price_history` | `(ticker: str, days: int = 120)` | Daily OHLCV bars, oldest β newest, in the quote currency. On an unknown ticker: an `error` string and empty `bars` β never an exception. | | |
| | `get_fundamentals` | `(ticker: str)` | Company name, sector, currency, trailing and forward P/E, market cap. Individual fields may be null (a loss-making company has no trailing P/E). | | |
| | `compute_metrics` | `(ticker: str, bars: list[PriceBar], pe_ratio: float \| None)` | `last_close`, `previous_close`, `change_1d_pct`, `return_30d_pct`, `volatility_annualised_pct`, `max_drawdown_pct`, `pe_ratio`, plus the exact baseline date used for the 30-day return. | | |
| | `fetch_rss_news` | `(ticker: str, limit: int = 6)` | De-duplicated headlines merged from Yahoo Finance and Google News RSS. An empty list with no `error` is a legitimate outcome. | | |
| **Metric definitions** are fixed and published, because the verifier has to | |
| reproduce them exactly: | |
| - `return_30d_pct` β change from the close of the last bar dated on or before | |
| `last_trading_date β 30 calendar days` to the latest close. | |
| - `volatility_annualised_pct` β sample standard deviation (ddof = 1) of daily log | |
| returns over the window, Γ β252, in percent. | |
| - `max_drawdown_pct` β the most negative value of `close / running_peak β 1`, | |
| always β€ 0. | |
| Tools are **cached per run**, **politely rate limited**, and **failure-tolerant | |
| by contract**: they return a structured `error` field rather than raising, which | |
| is what makes graceful degradation possible at all. | |
| --- | |
| ## How a number gets into the brief | |
| ``` | |
| yfinance bars βββΊ compute_metrics (MCP) βββΊ claim table c1..cN | |
| β | |
| writer may cite {{c7}} βββββ€ (schema rejects bare numerals) | |
| β | |
| raw bars βββΊ app/graph/recompute.py βββββββββββββ΄βββΊ compare to the cent | |
| (independent implementation) β | |
| ββ match β human gate | |
| ββ mismatch β 1 regeneration | |
| β HUMAN_REVIEW | |
| ``` | |
| `recompute.py` deliberately does **not** import the tool's maths. It implements | |
| the same published definitions through different code β Welford's online variance | |
| instead of a two-pass mean, `itertools.accumulate` instead of a running-peak loop, | |
| `bisect` instead of a forward scan. A test asserts the two paths agree on live | |
| market data, so the dual-path check is real rather than decorative. | |
| --- | |
| ## Production quality bar | |
| | Category | How it is met | | |
| | --- | --- | | |
| | **Loop safety** | Supervisor hard cap of 15 iterations **and** an independent per-run token/USD budget guard that aborts with `BUDGET_ABORT`. `test_forced_loop_terminates` wires workers that never complete and proves the run still stops. | | |
| | **Race conditions** | Reducer-merged state (above); DB writes are single committed transactions; **one active run per watchlist is enforced by a partial unique index in the database**, not by an application check. | | |
| | **Graceful degradation** | Fake ticker, dead network and empty RSS each have dedicated tests that induce the failure *for real* β an unknown symbol, an unroutable proxy, a genuinely empty feed. The run completes with per-ticker errors and a brief explicitly marked partial. | | |
| | **Numeric integrity** | 100% of claims recomputed; mismatch β exactly one regeneration β `HUMAN_REVIEW`. The eval reports post-verification accuracy across 20 runs. | | |
| | **Security** | MCP tools whitelisted; agents cannot widen their assigned ticker scope; news text is hardened, fenced in `<untrusted_data>` and treated as data; secrets only via env; approval endpoints behind a constant-time bearer check; security headers, body-size cap and per-client rate limiting on every request. | | |
| | **Zero warnings** | ruff, ruff-format, mypy `--strict`, pytest (with `filterwarnings = error`), eslint, tsc and `next build` all clean. | | |
| --- | |
| ## Running it locally | |
| ### Prerequisites | |
| Python 3.12 (via [uv](https://docs.astral.sh/uv/)) and Node 20+. | |
| ### 1. API | |
| ```bash | |
| cd apps/api | |
| uv venv --python 3.12 .venv | |
| uv pip install --python .venv/bin/python -e ".[dev]" | |
| cp ../../.env.example ../../.env # then edit | |
| .venv/bin/uvicorn app.main:app --port 7877 --reload | |
| ``` | |
| Open <http://127.0.0.1:7877/docs>. | |
| **You do not need a Claude API key to run this.** With `ANTHROPIC_API_KEY` | |
| unset, the app uses a deterministic engine that drives the *identical* graph, | |
| the *identical* MCP tools and the *identical* verifier β only the model's | |
| decision-making is replaced by rules. Market data is still live. This is what | |
| keeps CI and the 20-run eval free. Add the key and it switches to Claude. | |
| ### 2. Web console | |
| ```bash | |
| cd apps/web | |
| npm install | |
| ALPHABRIEF_API_URL=http://127.0.0.1:7877 \ | |
| ALPHABRIEF_APPROVAL_TOKEN=<same as APPROVAL_TOKEN> \ | |
| npm run dev | |
| ``` | |
| Open <http://localhost:3001> and press **RUN**. | |
| The browser never talks to the API directly β every call is proxied through a | |
| Next route handler so the approval token stays server-side. | |
| ### 3. Quality gates | |
| ```bash | |
| make check # ruff + format + mypy + pytest + eslint + tsc + next build | |
| make eval # 20 scored runs β eval/results.md | |
| ``` | |
| --- | |
| ## Free deployment map | |
| | Service | Free tier used | Limit to respect | | |
| | --- | --- | --- | | |
| | **GCP Cloud Run** *(primary API host)* | 2M requests + 360k GB-seconds/month, forever | Keep `min_instance_count = 0`; 1 vCPU / 512Mi. Needs a card on file, though this usage does not bill | | |
| | **Koyeb** *(no-card fallback, untested)* | 1 web service from a Docker image | 0.1 vCPU / 512Mi. pandas and numpy arrive via yfinance, so memory is tight β a sibling project with a heavier stack was OOM-killed at this size | | |
| | **Vercel** | Hobby: web + Cron | Cron on Hobby is once/day β exactly the 07:00 run | | |
| | **Neon** | Free Postgres | Ample for runs, briefs, approvals | | |
| | **Langfuse** | Free tier | Ample for traces | | |
| | **yfinance / RSS / Gmail SMTP** | Free, keyless / app password | Cached per run; polite rate limiting | | |
| | **Claude API** | Your key | Haiku-routed supervisor + capped iterations β ~$0.05β0.15 per brief | | |
| ### GCP Cloud Run (recommended) | |
| ```bash | |
| PROJECT=your-project | |
| REGION=us-central1 | |
| REPO=$REGION-docker.pkg.dev/$PROJECT/alphabrief-api-images | |
| gcloud auth login | |
| gcloud config set project $PROJECT | |
| gcloud services enable run.googleapis.com artifactregistry.googleapis.com secretmanager.googleapis.com | |
| gcloud artifacts repositories create alphabrief-api-images --repository-format=docker --location=$REGION | |
| gcloud auth configure-docker $REGION-docker.pkg.dev | |
| docker build -t $REPO/api:v1 -f apps/api/Dockerfile . | |
| docker push $REPO/api:v1 | |
| gcloud run deploy alphabrief-api \ | |
| --image $REPO/api:v1 --region $REGION \ | |
| --port 7860 --allow-unauthenticated \ | |
| --min-instances 0 --max-instances 2 \ | |
| --memory 512Mi --cpu 1 --timeout 900 \ | |
| --set-env-vars ENVIRONMENT=production,MCP_TRANSPORT=stdio,CORS_ALLOW_ORIGINS=https://alphabrief.vercel.app \ | |
| --set-secrets ANTHROPIC_API_KEY=alphabrief-api-anthropic-api-key:latest,APPROVAL_TOKEN=alphabrief-api-approval-token:latest,DATABASE_URL=alphabrief-api-database-url:latest | |
| ``` | |
| Or declaratively β which is what puts *Terraform (IaC)* on the resume honestly: | |
| ```bash | |
| cd infra/terraform | |
| terraform init | |
| terraform apply -var project_id=$PROJECT -var image=$REPO/api:v1 | |
| ``` | |
| ### Koyeb (no card needed) | |
| Create a web service from `apps/api/Dockerfile`. The image already listens on | |
| 7860 and runs as **uid 1000**, so it drops straight onto any host that refuses | |
| root β no changes needed. Set the same environment variables as service secrets. | |
| The free instance is 0.1 vCPU / 512 MiB. `yfinance` pulls in pandas and numpy, so | |
| memory is tight and briefs are slower than the figures in this README, which were | |
| measured on a laptop. Fine for a shareable link; use Cloud Run for a live demo. | |
| ### Vercel (web + cron) | |
| ```bash | |
| cd apps/web | |
| vercel deploy --prod | |
| ``` | |
| Set `ALPHABRIEF_API_URL`, `ALPHABRIEF_APPROVAL_TOKEN` and | |
| `ALPHABRIEF_DEFAULT_WATCHLIST` as project environment variables. `vercel.json` | |
| already declares the weekday 07:00 cron hitting `/api/trigger`; Vercel supplies | |
| `CRON_SECRET`, which the route verifies in constant time. | |
| ### Neon, Langfuse, Gmail | |
| - **Neon** β create a project, copy the connection string into `DATABASE_URL`. | |
| Tables and the LangGraph Postgres checkpointer are created on boot. | |
| - **Langfuse** β create a project, set `LANGFUSE_PUBLIC_KEY` / `LANGFUSE_SECRET_KEY`. | |
| Without them the tracer is a silent no-op. | |
| - **Gmail** β enable 2FA, create an app password, set `SMTP_*`. Without SMTP the | |
| brief is archived but not emailed, and the run says so. | |
| ### Production upgrade path | |
| yfinance is free, real and rate-limited by courtesy rather than contract. For | |
| production the swap is one module: `app/mcp_server/providers.py` is the only | |
| place a provider is called, so moving to Polygon, Databento or a paid feed means | |
| changing that file and nothing else β the MCP tool contracts, the graph, the | |
| verifier and the UI are unaffected. | |
| --- | |
| ## Design decisions worth defending | |
| **Why LangGraph over CrewAI or AutoGen.** I evaluated all three. CrewAI is faster | |
| to a first demo but gives less control over state and routing; AutoGen models work | |
| as a conversation between agents, which is hard to make deterministic. LangGraph | |
| gives an explicit graph, typed reducer-merged state, and β decisively here β | |
| native human-in-the-loop via interrupts over a checkpointer. Production control | |
| beat time-to-first-demo. | |
| **Why MCP for the tool layer.** It is the USB-C of AI tools: the same four tools | |
| are consumable by this LangGraph app, by Claude Desktop, or by any other MCP | |
| client, with schemas and documentation discoverable at runtime. It also draws a | |
| hard security boundary β a closed, whitelisted tool surface. | |
| **Why runtime context, not `config["configurable"]`.** LangGraph filters unknown | |
| `configurable` keys when a checkpointer is attached, so live handles (the open | |
| MCP session, the event bus) must travel in the runtime `context` channel, which | |
| is deliberately not persisted. This was found by testing, not by reading. | |
| **Why n8n is not in the cloud path.** I used n8n for scheduling in an earlier | |
| project. Here Vercel Cron does the same job with one less service to run, and the | |
| scheduled path is a plain authenticated HTTP call that is trivial to test. | |
| --- | |
| ## Visual identity | |
| AlphaBrief is an **amber phosphor terminal**: true black `#08090b`, amber | |
| `#ffb454`, phosphor green `#35d07f` for verified figures, monospace throughout, | |
| ruled columns and CRT scanlines. Not a dashboard β a trading-floor instrument. | |
| That is a deliberate departure from the build spec, which asked all three | |
| portfolio demos to share one dark/cyan design system. Two of them already had | |
| byte-identical tokens, so a third would have read as the same template three | |
| times. The reasoning is recorded in [SPEC_TRACE.md](SPEC_TRACE.md) Β§*Deviations*. | |
| --- | |
| ## Repository layout | |
| ``` | |
| alphabrief/ | |
| βββ apps/ | |
| β βββ api/ FastAPI + LangGraph + MCP server (one container) | |
| β β βββ app/ | |
| β β β βββ graph/ state Β· supervisor Β· data_agent Β· news_agent | |
| β β β β writer Β· verify Β· gate Β· deliver Β· recompute | |
| β β β βββ mcp_server/ prices Β· fundamentals Β· metrics Β· rss_news | |
| β β β βββ core/ claude router Β· langfuse Β· budget guard Β· settings | |
| β β β βββ models/ brief schema Β· market contracts Β· SQLAlchemy tables | |
| β β β βββ services/ runner Β· repository Β· render Β· email | |
| β β β βββ api/ routes Β· schemas | |
| β β βββ tests/ 190 tests incl. every mandated case | |
| β βββ web/ Next.js 15 console | |
| βββ eval/ run_eval.py β results.md | |
| βββ infra/terraform/ Cloud Run + Secret Manager, declaratively | |
| βββ AUDIT.md every Definition-of-Done check, with evidence | |
| βββ SPEC_TRACE.md spec requirement β implementation β test | |
| βββ LEARNING.md the study path through this codebase | |
| βββ RESUME.md resume bullets + ATS skills block | |
| ``` | |
| --- | |
| ## Documentation | |
| - **[AUDIT.md](AUDIT.md)** β the Definition of Done, check by check, with the | |
| command that proves each one. | |
| - **[SPEC_TRACE.md](SPEC_TRACE.md)** β every requirement in the build spec mapped | |
| to the file that implements it and the test that holds it. | |
| - **[LEARNING.md](LEARNING.md)** β the order to read this codebase in, and the | |
| interview question each file answers. | |
| - **[RESUME.md](RESUME.md)** β resume bullets with real measured numbers. | |
| - **[eval/results.md](eval/results.md)** β the latest 20-run evaluation. | |
| ## License | |
| MIT. | |