MarketCompass — Architecture Summary
A human-readable guide to how the system works today. For implementation details, historical rationale, and the full change log, see architecture-overview.md — that's the source of truth for building and for Cursor. This document exists so a person can understand the shape of the system without reading six revisions of decision history.
What This System Does
MarketCompass is an AI-powered equity research assistant. A user asks a question ("compare Nvidia and AMD's margins"), the system figures out what kind of question it is, gathers the relevant data, and produces an answer — grounded in real financial data and, eventually, explainable business rules rather than an LLM's unaided guess.
The Big Idea, in One Paragraph
A user's question comes in through the API, gets routed to the right Skill (a business capability, like "financial analysis") based on a YAML-configured confidence score, and that Skill pulls in whatever it needs — Tools for external data (company profiles, news, filings), an LLM Service for reasoning, and eventually a Rules Engine for explainable financial thresholds — before producing a response. Every layer is configuration-driven where it matters (routing, business rules) and code where it should be (actual reasoning, actual logic). Nothing is auto-discovered or dynamically loaded; every capability the system has is explicitly registered.
1. The Layers, Top to Bottom
flowchart TD
U["User"] --> API["API Layer\nauth, rate-limiting, streams responses back"]
API --> ORCH["Orchestrator\nthe conductor — owns the request end-to-end"]
ORCH --> ROUTER{"Router\nscores the RAW query\nagainst YAML-configured routes"}
ROUTER -->|"HIGH CONFIDENCE"| SKILLS["Skills\nFinancial Analysis, Company Summary,\nNews, Generic Chat"]
ROUTER -->|"LOW CONFIDENCE /\nNO ROUTE"| CB["Context Builder\nRAG + Memory retrieval — Phase 2+\nno-op passthrough today\ninvoked ONLY on this branch"]
CB --> FALLBACK["Fallback Agent Skill\n(Phase 2+)"]
SKILLS --> TOOLS["Tools\nCompanyProfile, FinancialMetrics,\nNews, SECFilings"]
SKILLS --> LLM["LLM Service\nreasoning, via whichever\nprovider is configured"]
SKILLS -.-> RULES["Rules Engine (Phase 3)\nexplainable thresholds —\nP/E bands, growth bands, etc."]
FALLBACK --> TOOLS
FALLBACK --> LLM
TOOLS --> RESP["Response back to the user"]
LLM --> RESP
RULES --> RESP
OBS(["Observability\nlogs, traces, metrics"])
EVAL(["Evaluation — placeholder\noffline scoring vs. datasets"])
ORCH -.reports to.- OBS
ROUTER -.reports to.- OBS
SKILLS -.reports to.- OBS
TOOLS -.reports to.- OBS
LLM -.reports to.- OBS
OBS -.feeds.- EVAL
The Router always runs first, directly on the raw query — it never waits on the Context Builder. The Context Builder only enters the picture on the low-confidence/no-route branch, immediately before the Fallback Agent Skill; high-confidence Skills never depend on it. Full reasoning: langchain-rag-router-decision.md.
Two things run beside this chain rather than inside it, watching everything without affecting it:
- Observability — every layer reports what it did (latency, cost, confidence, success/failure) to structured logs, traces, and metrics.
- Evaluation (placeholder for now) — will later score routing accuracy and response quality offline, against curated test datasets. Not part of a live request.
2. Why It's Built This Way
Four ideas explain almost every decision in this system:
- Configuration over code, where behavior needs to change often. Routing rules and financial thresholds live in YAML. Changing which questions go to which Skill, or adjusting what counts as an "expensive" P/E ratio, doesn't require a code deploy.
- A capability is either a Tool or a Service — never both, never ambiguous. A Tool is an optional, external, swappable data source (news API, SEC filings API) — a Skill might use several, or none. A Service is a fixed, always-present capability every relevant Skill depends on for its core function — the LLM, and (from Phase 3) the Rules Engine. This distinction determines how something gets built and injected, and it's applied consistently everywhere in the system.
- Explicit over automatic. Nothing is auto-discovered — not Skills, not Tools, not plugins. Everything the system can do is named in a registry file. This is a deliberate trade: slightly more typing to add a new capability, in exchange for a system where "what can this do?" always has one obvious, greppable answer.
- Declarative for what, code for how. Anywhere YAML is used — routing thresholds, financial rules, Skill dependency wiring — it describes facts and references, never logic or behavior. The moment something needs a conditional or a sequence of steps, it belongs in Python, not YAML. This line is held deliberately, everywhere it comes up.
For full justification of why it's built this way, see section 8.
3. The Core Components
API Layer
The front door. FastAPI. Handles the HTTP surface, request validation, streaming responses back to the frontend, and (from Phase 1) baseline security — rate limiting and CORS.
Key endpoints:
| Endpoint | Purpose |
|---|---|
POST /api/chat |
Main entry point — user sends a question, gets a routed, Skill-produced answer |
GET /api/agents |
Introspection — what Skills/agents currently exist |
GET /api/health |
Liveness/readiness check |
GET /api/providers |
Which LLM providers are currently configured/available |
Orchestrator
The conductor. Owns a request from arrival to response. Doesn't do any of the actual work itself — it calls the Router first, then, depending on the confidence of that decision, either the chosen Skill(s) directly or the Context Builder followed by the Fallback Agent Skill — and assembles what comes back. Deliberately kept thin: it coordinates, it doesn't reason or fetch.
Router
Scores the raw query against every configured route in YAML — each route names a confidence threshold and which Skill(s) it requires. Highest-confidence match wins; if nothing scores high enough, a safe fallback path always catches it. The Router never executes anything, never waits on retrieval, and never sees assembled context — it only decides, cheaply, on every request including the ones that turn out to need more.
Phase 2 addition: a query can match a route well but still lean on something outside the query text itself — e.g. "compare AAPL's P/E to the one I asked about earlier" names one company explicitly but points at another only through memory. The Router catches this with a small list of comparison/reference words ("compare," "the one," "earlier," and similar); a match sends the query down the fallback path even though it would otherwise have been confident, specifically so it reaches the Context Builder's memory instead of a Skill that can't see it. Still zero LLM calls — just a second pattern check alongside the confidence score.
Context Builder
A single, well-defined seam for assembling retrieved context — but unlike a typical pre-routing "context assembly" step, it's invoked by the Orchestrator only after the Router has already decided it can't confidently route (low confidence or no match), immediately before the Fallback Agent Skill. High-confidence Skills never see it. Today, in Phase 1, it does nothing but pass data through — but that seam existing at this specific point means Phase 2 can plug in real LangChain-based memory and RAG retrieval without restructuring the Orchestrator, and without adding retrieval cost to the high-confidence path that never needed it. Full reasoning: langchain-rag-router-decision.md.
Fallback Agent Skill (Phase 2+)
An ordinary Skill by contract — built by the Skill Factory like any other, returns a typed SkillResult — invoked only on the low-confidence/no-route branch. Its execute() uses a LangChain agent internally to decide which Tool(s) to call and in what order, since that's the one case where the sequence genuinely isn't known ahead of time. Every other Skill's Tool-call sequence stays fixed, hand-written Python.
Skills
The actual business capabilities. Each Skill represents one thing the system knows how to do:
| Skill | Does |
|---|---|
FinancialSkill |
Financial metric analysis (uses Rules Engine from Phase 3) |
CompanySkill |
Company profile / summary generation |
NewsSkill |
News synthesis |
GenericChatSkill |
Greetings/general chat (the generic_chat route); also Phase 1's fallback until FallbackAgentSkill exists |
FallbackAgentSkill (Phase 2+) |
Takes over the low-confidence/no-route case; LangChain agent internally picks which Tools to call |
A Skill orchestrates whatever it needs — Tools for data, the LLM Service for reasoning — and never calls another Skill directly (that composition stays the Orchestrator's job, which matters for keeping later multi-step planning tractable).
Phase 2 addition: every Skill's prompt now includes one fixed instruction — if part of the question depends on something the Skill has no data for, say so, rather than quietly answering only the part it can. This exists because memory and RAG are deliberately fallback-only (above): the Router's reference-cue check catches most cases where a high-confidence Skill would otherwise be missing context, but not all phrasings, so this is the safety net behind it.
Tools
Wrappers around external, non-LLM data sources. Each Tool does exactly one thing and is independently testable.
| Tool | Fetches |
|---|---|
CompanyProfileTool |
Company overview/profile data |
FinancialMetricsTool |
Financial statement metrics |
NewsTool |
Recent news |
SECFilingsTool |
SEC filings |
LLM Service
The one place in the system that talks to an AI model. Every Skill that needs reasoning goes through here — never directly to a provider SDK. Owns model/provider selection, retries, failover between providers, and token/cost tracking. This centralization is what makes "how much is this costing us" and "is this provider degraded" answerable questions instead of scattered unknowns.
Rules Engine (Phase 3 — designed now, not yet built)
Declarative, YAML-configured financial thresholds (e.g., P/E ratio bands: undervalued / fair / expensive). Evaluates already-fetched metrics after the LLM has reasoned over them, producing classified, explainable findings — not a second reasoning pass, just structured normalization. Built as a Service (like the LLM), not a Tool, because every financial-analysis Skill structurally depends on it.
Observability
Every executing layer emits structured logs, traces, and metrics — routing confidence, tool latency/success, LLM tokens/cost/retries, end-to-end request latency. Purely observational: it never gates or alters a request in flight.
Security
Attached at two chokepoints rather than scattered everywhere: the API boundary (rate limiting, CORS, secrets in .env) and the LLM Service boundary (prompt-injection handling for retrieved content, from Phase 4 on). No paid identity provider anywhere — free/self-hosted tooling only (slowapi, FastAPI Users from Phase 2 onward).
4. How a Request Actually Flows
flowchart TD
S1["1 · User sends a question\nPOST /api/chat"] --> S2["2 · API validates + rate-limits it\npasses to Orchestrator"]
S2 --> S3{"3 · Router scores\nthe RAW query"}
S3 -->|"High confidence"| S4A["4a · Orchestrator builds a trivial\ncontext inline, calls the Skill directly"]
S3 -->|"Low confidence /\nno route"| S4B["4b · Orchestrator calls Context Builder\n(RAG + Memory, Phase 2+), then Fallback Agent Skill"]
S4A --> S5["5 · Skill gathers what it needs\ncalls Tools for data, LLM Service for reasoning\n(+ Rules Engine, Phase 3, for financial Skills)"]
S4B --> S5
S5 --> S6["6 · Skill produces a result\nOrchestrator assembles the final response"]
S6 --> S7["7 · Response streams back\nUser sees the answer"]
S3 -.-> OBS(["Observability"])
S5 -.-> OBS
S6 -.-> OBS
At every arrow, something is also quietly reported to Observability — which route was picked and how confidently, which Tools succeeded or failed, how many tokens the LLM used and what it cost.
5. The Data Contracts (Models)
Every arrow in the flow above is a specific, typed object — not a loose dictionary. This is deliberate: a typo in a dict key fails silently at runtime; a typo in a model field fails immediately, often before the code even runs. All models are Pydantic classes, living in backend/src/models/, one file per producing layer.
| Model | Produced By | Consumed By | Roughly Contains |
|---|---|---|---|
RequestContext |
Orchestrator (inline, high confidence) or Context Builder (low confidence/no route) | Skills only — not the Router | The user's query plus (later) retrieved memory/RAG context |
RoutingDecision |
Router | Orchestrator, Skills | Which Skill(s) were selected, confidence score, whether it was a fallback |
SkillRequest |
Orchestrator | Skills | The context + routing decision, packaged for a Skill to act on |
ToolResult |
Tools | Skills | Whatever external data was fetched, typed per Tool |
LLMRequest / LLMResponse |
Skills / LLM Service | LLM Service / Skills | The prompt sent, and the model's reply plus token/cost/model-used metadata |
SkillResult |
Skills | Orchestrator | The final content, citations, and confidence for this Skill's contribution |
What's deliberately not here yet: business/domain entities like Company, FinancialStatement, or NewsArticle aren't modeled in this layer. This layer defines how components talk to each other — not what the business is actually about. Domain modeling is a Phase 2–3 concern, once real data sources exist to shape it correctly.
6. How to Add Something New
The system is built so that most extensions follow one of a small number of well-worn paths:
- New Tool (new external data source): write a class implementing the Tool contract, add one entry to
config/tools/registry.yaml. No other layer changes. - New Skill (new business capability): write a class implementing the Skill contract (what to do with its Tools + LLM Service), add one line to an explicit class map, add one entry to
config/skills/registry.yaml. The factory handles wiring dependencies in — you still write the class, because a new Skill always means new behavior, but you never hand-write the constructor call. - New routing rule: edit YAML. No code change, no redeploy.
- New financial rule threshold (Phase 3): edit YAML. No code change.
What you will not find anywhere in this system: dynamic plugin discovery, YAML that expresses conditional logic or prompt templates, or Skills calling each other directly. These are deliberate boundaries, not gaps — see architecture-overview.md §16 if you want the reasoning.
7. What's Built Now vs. What's Designed for Later
| Phase 1 (done) | Phase 2 (current — designed, not yet built) | Phase 3 | Phase 4 | |
|---|---|---|---|---|
| Focus | Routing + orchestration foundation | RAG + Fallback Agent Skill + auth + memory | Explainable financial rules | Full analyst report generation |
| Context Builder | No-op passthrough | Real RAG + memory retrieval | — | — |
| Rules Engine | Designed, not built | — | Built | Used |
| Auth | None (no users yet) | FastAPI Users, JWT, per-user data isolation — lands before Memory, since long-term memory needs a real user_id to scope by |
— | — |
| Orchestrator | Single-pass | — | — | Multi-step planning (Investment Thesis Generator) |
Phase 2 breaks down into five milestones, roughly in dependency order: RAG Foundation, Fallback Agent Skill, Auth Foundation, Memory, then Hardening/doc sync — see phases/phase2-implementation-plan.md for exact status.
The point of designing later phases now (Context Builder's seam, the Rules Engine's Service placement, the security posture) is that none of it requires reworking Phase 1's structure when it arrives — it plugs into seams that already exist.
For the full decision history, alternatives considered, exact schemas, and the reasoning behind every choice above, see architecture-overview.md and the referenced design docs (llm-service-design.md, rules-engine-design.md, security-design.md, skill-factory-design.md, context-builder-design.md, observability-design.md).
8. Do the Four Principles Actually Hold Up?
Section 2 stated four principles as given. None of them were invented for this project — each is a well-known pattern, and knowing the pattern it maps to also shows where it can go wrong.
1. Configuration over code, where behavior changes often. This is the standard "externalize your config" idea (the same one behind 12-Factor App's "Config" factor, or any feature-flag system). The important word is where — it's not "put everything in YAML," it's "put the things that change often in YAML." Used well, that's a real win: changing a routing rule or a financial threshold needs no deploy. Used badly — configuring things that never actually change, just to avoid a deploy — it's needless indirection. This project's discipline holds as long as principle 4 keeps YAML from picking up logic; see the closing note below.
2. A capability is either a Tool or a Service, never both. This is the "Ports and Adapters" pattern under a different name: a Tool is a swappable, optional adapter (an external data source a Skill might or might not use); a Service is a mandatory dependency every relevant Skill needs (the LLM, and from Phase 3, the Rules Engine). The payoff isn't just tidiness — it settles failure behavior for free: a Tool failing should degrade gracefully, a Service failing should generally fail the request. The one thing to watch for: real systems eventually produce a capability that's awkward to force into either box (e.g. "usually there, but can be turned off per user"). That's a deliberate simplification this project is choosing to hold, not a guarantee it'll never strain.
3. Explicit over automatic — nothing auto-discovered, everything registered. Every Skill and Tool is named in a YAML registry rather than found by scanning the filesystem or a plugin folder. This is a style choice, not a universal rule — plenty of frameworks (Spring, NestJS) go the other way for speed of development — but it's the right choice here: a system reasoning over financial data benefits from "what can this do?" always having one obvious, greppable answer, and explicit allow-lists are also just safer once the system starts touching retrieved or external content (Phase 2 on).
4. Declarative for what, code for how. The strongest of the four, and the one holding the other three together. YAML expresses facts and references — a threshold, a class name, a dependency list — never a conditional or a sequence of steps. This is the same line that keeps Kubernetes manifests and Terraform config maintainable, and its absence is the classic way config systems rot: YAML slowly grows if/templating logic until it's an undocumented second programming language nobody wants to touch (Helm templates and sprawling Ansible when: conditions are the usual cautionary tale). Holding this line is what stops principle 1 from ever becoming a problem.
Where they reinforce each other, and the one soft spot: principles 2 and 3 both push toward explicitness; principle 4 is what keeps principle 1 from sprawling. The one place this isn't self-enforcing: nothing mechanically stops YAML from picking up a stray conditional someday — the boundary is held by code review discipline, not by a rule the system itself checks. That's worth knowing, not fixing preemptively: a lint rule for "no logic in YAML" would be solving a problem that hasn't happened yet.