Skip to content

AI 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.


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 PreciseAmount string 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 truncated marker 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.


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}

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.ts builds AdvisorRouteDeps with getters, so importing app never opens ai-advisor.db or the ledger. container.askAdvisorUC is 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_integrity and fiscal_integrity_rows share GetFiscalIntegrityUseCase; token_history and token_lots share GetTokenHistoryUseCase; the four scenario tools share GetPortfolioScenarioUseCase.

🧭 Agent Topology: Supervisor and Specialists

Section titled “🧭 Agent Topology: Supervisor and Specialists”
  • advisor (agents/advisor.ts) is the supervisor. It owns Memory, an input ToolCallFilter, and the two output processors. It never calls a catalogue tool itself; it delegates to taxAnalyst through the agents map 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 14 core tools 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 trailing Response format line 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 in advisor’s agents map, so nothing can reach it. It exists so a later phase adds tools plus one line of wiring rather than a redesign. advisorTopology.spec.ts asserts 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.


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.

  1. The investment-disclaimer output processor buffers text deltas; if the text touches an investment category it adds a disclaimer string to the finish chunk payload. It never edits a text chunk.
  2. getFinishInfo reduces that to a boolean. The domain completed event and the wire done frame carry disclaimer: boolean; the backend sends no disclaimer wording.
  3. 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.


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.