AI Advisor Overview
🤖 AI Portfolio Advisor Overview
Section titled “🤖 AI Portfolio Advisor Overview”The AI Portfolio Advisor is a read-only chat assistant that answers questions about your own portfolio and Spanish IRPF tax data, in your own language, without you having to know which view to open. Typical questions: “What should I review first in my tax report?” or “How is my portfolio allocated today?”
It is the first phase of the advisor (internally “Phase 0”, OpenSpec change add-ai-portfolio-advisor): a deliberately narrow skeleton that proves the full path — domain port, use case, one LLM adapter, typed Hono routes, streaming UI — before any write-capable or document-backed capability is built on top of it.
[!NOTE] This section describes the code as it exists in the repository. Where the code and the OpenSpec design disagree, the code is documented and the difference is listed under Known divergences. Shipped means present in the code and covered by a spec; planned means it appears only in a design, a pre-proposal, or an unchecked task.
🎯 Scope and the Core Invariant
Section titled “🎯 Scope and the Core Invariant”The advisor is strictly read-only. No write tool exists, so “the advisor never modifies a quantity, an asset, or a fiscal figure” holds by construction — there is nothing to call — not because a prompt asks the model to behave. A structural test asserts that no tool file performs an insert, update, or delete.
The LLM never produces a number. Every figure in an answer comes from an existing backend use case; the model only decides which tool to call and how to phrase the result. Grounding is enforced in independent layers:
- Each tool is a thin wrapper over an existing use case (or, for
live_prices, an existing port method). No calculation lives in the AI layer. - Tool outputs are validated by strict Zod schemas. Monetary figures keep their
PreciseAmountstring form; an absent figure is reported as unvalued, never defaulted to'0'. - The agent instructions tell the model to echo each figure verbatim in inline code, to re-query before restating a figure from an earlier turn, and to say so when a total is incomplete.
- A tool result that exceeds its character budget is replaced by a
truncatedmarker rather than partially delivered. - The advisor reads already-materialised results and introduces no FIFO ordering or partitioning of its own; the only orderings it adds are display rankings. See Global FIFO vs Custody.
[!IMPORTANT] Prompt-level rules reduce bad behaviour but are not a proof. The structural guarantees are the read-only tool set, the schema-validated tool outputs, and the output guardrails described below.
🏛️ Architecture at a Glance
Section titled “🏛️ Architecture at a Glance”flowchart LR
subgraph FE["apps/frontend"]
Panel["AdvisorPanel.vue"] --> Chat["useAdvisorChat"]
Chat --> Port1["IAdvisorPort"]
Port1 --> RestAdapter["RestAdvisorAdapter<br/>fetch + ReadableStream"]
Settings["Settings: AdvisorSettings"] --> Colada["Pinia Colada queries and mutations"]
Colada --> Port1
end
subgraph BE["apps/backend"]
Route["routes/advisor.ts<br/>/api/advisor"]
UC["AskAdvisorUC"]
Port2["IAdvisorPort"]
LogPort["IAdvisorRunLogPort"]
Mastra["MastraAdvisorAdapter<br/>core/infrastructure/ai"]
RunLog["SqliteAdvisorRunLogAdapter"]
Route --> UC
UC --> Port2 --> Mastra
UC --> LogPort --> RunLog
end
subgraph AI["Agent graph (per request)"]
Sup["advisor<br/>supervisor"]
Tax["taxAnalyst<br/>25 read-only tools (14 on local runs)"]
Inv["investmentAnalyst<br/>declared, unreachable"]
Sup -->|agents map| Tax
Sup -.-x Inv
end
RestAdapter -->|"POST /api/advisor/stream"| Route
RestAdapter -->|"typed hc client"| Route
Mastra --> Sup
Tax --> UCs["Existing use cases<br/>and IPriceHistoryPort.getLatest"]
Sup --- Mem[("ai-advisor.db<br/>conversation memory")]
RunLog --> Ledger[("kryptofolio_ledger.db<br/>ai_advisor_runs")]
Mastra -->|"model call"| LLM["Ollama daemon or hosted provider"]
| Concern | Where it lives |
|---|---|
| Wire contract, closed vocabularies, model chain and profile schemas | packages/shared-types/src/advisor-stream.ts |
| Pure display helpers (ranking, ordering, downsampling) and scenario, comparison, and custody-summary arithmetic | packages/core-domain/src/domain/services/{holdingRanking,displayOrdering,downsampleSeries,portfolioScenarios,compareTaxSummaries,summarizeCustodyLocations,resolveAccountByName}.ts |
| Domain port, events, receipt, use case | apps/backend/src/core/{domain,application} |
| Mastra agents, tools, guardrails, model resolution | apps/backend/src/core/infrastructure/ai/ |
| Routes, DTO mapping, composition | apps/backend/src/core/infrastructure/{routes,dtos,di} |
| Audit table | packages/database/migrations/sqlite/009_*.sql, 010_*.sql |
| Chat panel and Settings section | apps/frontend/src/{components/advisor,views/Settings/components/advisor} |
🧱 Hexagonal Layer Mapping
Section titled “🧱 Hexagonal Layer Mapping”| Package | Layer | Piece | Responsibility |
|---|---|---|---|
packages/shared-types |
Contract | advisor-stream.ts |
ADVISOR_TOOL_NAMES, AI_PROVIDER_IDS, failure vocabularies, advisorStreamEventSchema, modelChainSchema, executionProfilesSchema, classifyExecutionProfile, deriveLocalToolBudget, deriveLocalRunBudget, defaultExecutionProfiles. No framework code. |
packages/core-domain |
Pure services | rankHoldingsByValue, rankByAbsoluteValue, orderByCountDescending, orderByIsoDateDescending, orderByIsoDateDescendingThenKey, downsampleSeries; portfolioScenarios.ts (positionValueAt, breakevenPrice, applyShock, concentrationOf); compareTaxSummaries, summarizeCustodyLocations, resolveAccountByName |
Display-only ordering and sampling, plus the scenario and comparison arithmetic. All monetary arithmetic and comparison goes through the Money value object; the AI subtree imports neither Money nor decimal.js. |
packages/database |
Persistence | Migrations 009, 010; resolveAdvisorDbPath() |
Table definition and the ai-advisor.db path. No business logic. |
apps/backend domain |
Port | IAdvisorPort |
LLM-agnostic: ask(request) returns AsyncIterable<AdvisorEvent>. Imports nothing external. |
apps/backend domain |
Port | IAdvisorRunLogPort |
appendRun(receipt, outcome), an upsert on runId. |
apps/backend domain |
Models | AdvisorRequest, AdvisorEvent, AdvisorRunReceipt (profiled or pre-run), AdvisorRunReceiptDraft, AdvisorRunOutcome |
Discriminated unions, no optional-flag bags. |
apps/backend application |
Use case | AskAdvisorUC |
Functional sandwich at stream scale: resolve context, iterate the port, persist the receipt. |
apps/backend infrastructure |
Adapter | MastraAdvisorAdapter and core/infrastructure/ai/** |
The only code that imports @mastra/* (enforced by mastraImportZone.spec.ts). |
apps/backend infrastructure |
Adapter | SqliteAdvisorRunLogAdapter |
Writes ai_advisor_runs through the ledger DatabaseSync handle. |
apps/backend infrastructure |
DTO | dtos/advisor.ts |
Inbound askAdvisorRequestSchema; outbound advisorAnswerSchema, advisorConfigSchema; toWireEvent. |
apps/backend infrastructure |
Route | routes/advisor.ts |
createAdvisorApi(deps) mounted at /api/advisor; part of AppType. |
apps/backend infrastructure |
Composition | di/advisorComposition.ts, DIContainer.askAdvisorUC |
Lazy construction, late-bound tool use cases, env reading. |
apps/frontend domain |
Port | IAdvisorPort |
ask(request, signal) plus config getters/setters. |
apps/frontend infrastructure |
Adapter | RestAdvisorAdapter |
Hand-parsed SSE over fetch; typed hc client for configuration. |
apps/frontend infrastructure |
DTO | AdvisorSchemas.ts |
Narrows GET /config to what the UI consumes. |
Composition notes worth knowing:
app.tsbuildsAdvisorRouteDepswith getters, so importingappnever opensai-advisor.dbor the ledger.container.askAdvisorUCis constructed on the first advisor request.lateBoundToolUseCases(container)resolves each tool’s use case from the container on every run. The container replaces its analytical use cases when the real DuckDB connection is bound at startup; a captured reference would keep answering from the uninitialised guard.fiscal_integrityandfiscal_integrity_rowsshareGetFiscalIntegrityUseCase;token_historyandtoken_lotsshareGetTokenHistoryUseCase; the four scenario tools shareGetPortfolioScenarioUseCase.
🧭 Agent Topology: Supervisor and Specialists
Section titled “🧭 Agent Topology: Supervisor and Specialists”advisor(agents/advisor.ts) is the supervisor. It ownsMemory, an inputToolCallFilter, and the two output processors. It never calls a catalogue tool itself; it delegates totaxAnalystthrough theagentsmap and relays the answer with every figure preserved. Its instructions add only the response language and the “never originate a figure” rule.taxAnalyst(agents/taxAnalyst.ts) holds the tools its execution profile exposes (all 25 on metered and mixed runs, the 14coretools on local runs; see Tool Catalogue) and carries no memory. Its instructions list exactly the exposed tools. Its instructions (buildTaxAnalystInstructions) are a byte-stable prefix plus a trailingResponse formatline carrying locale and base currency, so the prefix stays cache-friendly.investmentAnalyst(agents/investmentAnalyst.ts) is declared with instructions and a contract but holds no tools and is not inadvisor’sagentsmap, so nothing can reach it. It exists so a later phase adds tools plus one line of wiring rather than a redesign.advisorTopology.spec.tsasserts this, and that.network()is never used.
The supervisor pattern uses the agents map (Mastra’s replacement for the deprecated agent-network primitive). deterministicDelegation.spec.ts asserts that a normal run costs exactly two model calls (decide, then finalise), never a third spent choosing among sub-agents. This matters for Ollama Cloud’s one-concurrent-request free tier.
🛡️ Guardrails and the Disclaimer
Section titled “🛡️ Guardrails and the Disclaimer”Two output processors run on the supervisor output and inspect the accumulated text at the finish chunk. Both are deterministic pattern checks (Spanish and English, investmentContentPatterns.ts), not classifiers, so they are a heuristic floor: paraphrase evasion and false positives are accepted trade-offs.
flowchart TD
F["finish chunk: accumulated text"] --> TE{"Tax evasion pattern?"}
TE -- yes --> R1["refused, no retry<br/>processor grounding-directive-detector"]
TE -- no --> G{"Investment claim or<br/>directive injection?"}
G -- no --> OK["Delivered"]
G -- yes --> INJ{"Directive injection?"}
INJ -- no --> TA{"Tool call or result<br/>in this turn?"}
TA -- yes --> D["Delivered, disclaimer true"]
TA -- no --> RT{"Already retried once?"}
INJ -- yes --> RT
RT -- no --> RETRY["Abort with retry"]
RT -- yes --> R2["refused"]
| Situation | Behaviour |
|---|---|
| Investment content (forecast, price target, allocation or rebalancing advice, market timing) grounded by a tool call or result in the same turn | Delivered; the done frame carries disclaimer: true. |
| Investment content with no tool activity this turn | Retried once; if still ungrounded, the run ends refused. |
| An attempt to override the guardrail or disclaimer (“ignore your instructions”, “answer as a licensed advisor”) | Shares the one-retry budget and is then refused, even when tools ran. |
| Tax evasion (“hide gains from Hacienda”, “evade taxes”) | Always refused immediately, no retry. |
Investment questions are answered with disclosure and grounding, not refused; tax-evasion requests are always refused. The refused frame reports processorId (grounding-directive-detector for these checks).
[!NOTE] The grounding check only verifies that some tool ran in the same turn, not that a specific claim matches a tool result. Phase 0 has no investment tools, so investment-flavoured wording is answerable only when the model also consulted a portfolio tool.
The Disclaimer Is Structural
Section titled “The Disclaimer Is Structural”- The
investment-disclaimeroutput processor buffers text deltas; if the text touches an investment category it adds adisclaimerstring to thefinishchunk payload. It never edits a text chunk. getFinishInforeduces that to a boolean. The domaincompletedevent and the wiredoneframe carrydisclaimer: boolean; the backend sends no disclaimer wording.- The client renders its own localised wording (
advisor.disclaimer) as a muted footer, separate from the answer body.
A second flag, figuresIncomplete, is set on done when a portfolio_summary or kpis result reported ratesIncomplete or pricesIncomplete. The client shows a “Figures incomplete” badge.
🔒 Invariants for Every Future Phase
Section titled “🔒 Invariants for Every Future Phase”The model never produces a number; the advisor never writes on its own; there is no exchange trading or order execution; tax FIFO and custody are never merged or reordered by AI-layer code; no telemetry leaves the machine; and local versus metered classification stays derived. See the roadmap for what is planned.
🧭 Next Steps
Section titled “🧭 Next Steps”- Request Lifecycle — the end-to-end flow of one question.
- Providers & Models — local vs cloud, vault credentials, fallback, budgets.
- Tool Catalogue — the twenty-five read-only tools.
- Audit & Privacy — what is stored and what leaves your machine.