Skip to content

AI Portfolio Advisor — Bedrock Architecture (Phase 1)

Phase 1, in production. This page documents the currently shipping AI Advisor: a Flask + Postgres app on EC2 that streams answers from Amazon Bedrock (Claude 3.5 Sonnet via the Converse API) using a custom agentic tool-use loop over the user's portfolio data.

For the planned self-hosted successor on EKS / vLLM, see AI Advisor — EKS Architecture (Phase 2). The two share the same Flask code; the model client is the only thing that swaps.


1. Architecture Diagram

This diagram shows every runtime component and every data class that passes between them, plus how requests cross the laptop → bastion → AWS boundary.

flowchart LR
    %% ============================================================
    %% Client side
    %% ============================================================
    subgraph Browser["🖥️ Browser (advisor.html)"]
        UI["Advisor UI<br/>preset dropdown · prompt box<br/>SSE consumer · Markdown renderer"]
    end

    %% ============================================================
    %% Flask app on EC2
    %% ============================================================
    subgraph EC2["☁️ EC2 bastion (systemd · gunicorn -k gthread)"]
        direction TB

        subgraph Flask["Flask app — app_portfolio_tracker_aurora"]
            direction TB

            Auth["auth.py<br/>@login_required · g.user"]

            subgraph Views["advisor_views.py (Blueprint)"]
                direction TB
                R1["GET /advisor<br/>renders advisor.html"]
                R2["POST /api/advisor/analyze<br/>SSE stream · persists report"]
                R3["GET  /api/advisor/reports<br/>list recent reports (JSON)"]
                R4["GET  /advisor/report/&lt;id&gt;<br/>permalink view"]
            end

            subgraph AdvisorPkg["advisor/ package"]
                direction TB
                Loop["loop.py · run()<br/>agentic tool-use driver<br/>yields LoopEvent stream"]
                Presets["presets.py · PRESETS<br/>performance_review<br/>rebalance_qualitative<br/>trade_tickets · freeform"]
                Tools["tools.py · TOOL_FUNCTIONS<br/>+ TOOL_SPECS (JSON Schema)<br/>execute(name, args)"]
                Client["client.py · ModelClient (ABC)<br/>BedrockClient · OpenAICompatClient"]
            end

            Calc["calculations.py<br/>NAV · IRR · allocations<br/>monthly returns · cash flows"]
            DB["db.py<br/>get_db() · get_cursor()<br/>DictCursor · psycopg2 pool"]
        end

        Schema[("Postgres (Docker on bastion / RDS in prod)<br/><br/>TRANSACTION · FUND_TRANSACTION<br/>EXTERNAL_CASH_FLOW · BALANCE_SNAPSHOT<br/>DAILY_PRICE · FX_RATE · PORTFOLIO · BROKER<br/>user · ADVISOR_REPORT")]
    end

    %% ============================================================
    %% AWS managed services
    %% ============================================================
    subgraph AWS["AWS (ap-southeast-1)"]
        Bedrock["Amazon Bedrock<br/>bedrock-runtime.converse_stream<br/>anthropic.claude-3-5-sonnet-20240620-v1:0"]
    end

    %% ============================================================
    %% Edges
    %% ============================================================
    UI -- "POST JSON {preset, prompt, currency}<br/>+ session cookie" --> R2
    UI -- "EventSource fallback / fetch stream" --> R3
    UI -- "page nav" --> R1
    UI -- "permalink" --> R4

    R1 --> Auth
    R2 --> Auth
    R3 --> Auth
    R4 --> Auth

    R2 -- "build_client()" --> Client
    R2 -- "loop.run(preset, prompt, currency, client)" --> Loop
    Loop -- "presets.get(name)" --> Presets
    Loop -- "tools.specs_for(allow_list)" --> Tools
    Loop -- "client.stream(system, messages, tools)" --> Client
    Client -- "boto3 Converse Stream<br/>(toolConfig + messages)" --> Bedrock
    Bedrock -- "contentBlockDelta · toolUse · messageStop · metadata" --> Client

    Loop -- "tools.execute(name, args)" --> Tools
    Tools -- "calculate_*" --> Calc
    Tools -- "SELECT FROM TRANSACTION ..." --> DB
    Calc --> DB
    DB --> Schema

    R2 -- "INSERT INTO ADVISOR_REPORT<br/>(prompt, model, tool_calls, structured, usage)" --> DB
    R3 -- "SELECT FROM ADVISOR_REPORT" --> DB
    R4 -- "SELECT FROM ADVISOR_REPORT JOIN user" --> DB

    R2 -. "text/event-stream<br/>events: meta · text · tool_start · tool_result · structured · done" .-> UI

    %% ============================================================
    %% Styling
    %% ============================================================
    classDef external fill:#fff7e6,stroke:#d97706,color:#1f2937;
    classDef internal fill:#eef2ff,stroke:#4f46e5,color:#1f2937;
    classDef store fill:#ecfdf5,stroke:#059669,color:#1f2937;
    classDef ui fill:#fef3f2,stroke:#dc2626,color:#1f2937;
    class Bedrock external;
    class Loop,Presets,Tools,Client,Views,Calc,Auth,R1,R2,R3,R4 internal;
    class Schema,DB store;
    class UI ui;

1.1 Key classes & data structures

Layer Symbol Defined in Purpose
HTTP bp (Blueprint("advisor")) advisor_views.py Hosts all /advisor* routes.
Auth @login_required, g.user auth.py Decorator + per-request user context.
Backend abstraction ModelClient (ABC) advisor/client.py Backend-agnostic streaming interface.
Backend (Phase 1) BedrockClient advisor/client.py Wraps boto3.client("bedrock-runtime").converse_stream(...).
Backend (Phase 2) OpenAICompatClient advisor/client.py Stub for vLLM / Ollama; same stream() signature.
Wire events TextDelta, ToolUseStart, ToolUseDelta, ToolUseComplete, Done, Usage advisor/client.py Backend-agnostic event protocol yielded by stream().
Driver loop.run(...) advisor/loop.py The agentic tool-use loop (≤ max_iterations).
Loop events LoopText, LoopToolStart, LoopToolResult, LoopStructured, LoopDone advisor/loop.py Higher-level events the view turns into SSE frames.
Prompt config PRESETS dict, presets.get(name) advisor/presets.py System prompt + tool allow-list + expects_structured per mode.
Tools TOOL_FUNCTIONS, TOOL_SPECS, execute(), specs_for(), serialise_for_model() advisor/tools.py Read-only, JSON-schema-validated tools the model is allowed to call.
Domain calculations.calculate_securities_nav, …_xirr, get_*_allocation, get_monthly_returns, get_nav_history calculations.py Pure functions over the Postgres schema.
Storage ADVISOR_REPORT (user_id, preset, user_prompt, model_id, backend, response_md, structured JSONB, tool_calls JSONB, latency_ms, input_tokens, output_tokens, created_at) schema.sql + migrations/20260531_add_advisor_report.sql Audit log of every advisor session.

1.2 Tool surface exposed to the model

The model is given a curated allow-list per preset (large surface area = worse tool selection). All tools are read-only except submit_trade_tickets, which is a sentinel that terminates the loop.

Tool Backed by Used in presets
get_performance_summary calculations.get_performance_summary all
get_holdings calculations.calculate_securities_nav all
get_fund_balance calculations.calculate_fund_balance all
get_irr calculations.calculate_portfolio_xirr performance, freeform
get_asset_allocation calculations.get_asset_allocation all
get_currency_allocation calculations.get_currency_allocation all
get_monthly_returns calculations.get_monthly_returns performance, trade_tickets, freeform
get_cash_flows calculations.get_total_deposits_withdrawals performance, rebalance, freeform
get_nav_history calculations.get_nav_history performance, freeform
get_recent_transactions direct SELECT on "TRANSACTION" rebalance, trade_tickets, freeform
submit_trade_tickets (sentinel) echoes input; loop intercepts trade_tickets only

2. End-to-End Sequence Diagram

This is the full life of one user message — from the moment they hit Send in advisor.html to the persisted ADVISOR_REPORT row and the closing SSE frame on the wire.

sequenceDiagram
    autonumber
    actor User as 👤 User
    participant UI as Browser<br/>(advisor.html)
    participant View as advisor_views.analyze<br/>(Flask SSE handler)
    participant Auth as auth.py<br/>@login_required
    participant Factory as client.build_client()
    participant AgentLoop as advisor.loop.run()
    participant Presets as advisor.presets
    participant ToolsReg as advisor.tools
    participant Bedrock as Amazon Bedrock<br/>converse_stream
    participant Calc as calculations.py
    participant DB as Postgres<br/>(psycopg2)

    %% --- 1. User submits ---
    User->>UI: Pick preset, type prompt, click "Analyze"
    UI->>View: POST /api/advisor/analyze<br/>{preset, prompt, currency}<br/>+ session cookie
    View->>Auth: enforce login
    Auth-->>View: g.user resolved
    View->>Factory: build_client()
    Factory-->>View: BedrockClient(model_id, region)

    %% --- 2. Open SSE stream ---
    View-->>UI: 200 text/event-stream<br/>event meta {preset, model_id, backend, currency}

    %% --- 3. Hand off to the agentic loop ---
    View->>AgentLoop: run(preset_name, prompt, currency, client)
    AgentLoop->>Presets: get(preset_name) returns system, tools, max_tokens, expects_structured
    AgentLoop->>ToolsReg: specs_for(allow_list) returns Bedrock toolSpec list
    AgentLoop->>AgentLoop: seed messages with user prompt and base currency

    %% --- 4. First model call ---
    AgentLoop->>Bedrock: converse_stream(modelId, system, messages, toolConfig)
    activate Bedrock

    loop streaming events
        Bedrock-->>AgentLoop: contentBlockDelta text - TextDelta
        AgentLoop-->>View: LoopText(chunk)
        View-->>UI: event text {text}
        UI->>UI: append to Markdown buffer, render
    end

    Bedrock-->>AgentLoop: contentBlockStart toolUse {id, name}
    AgentLoop-->>View: LoopToolStart(name, id)
    View-->>UI: event tool_start {name, id}
    Bedrock-->>AgentLoop: contentBlockDelta toolUse.input partial JSON
    Bedrock-->>AgentLoop: contentBlockStop - ToolUseComplete(id, name, parsed_input)
    Bedrock-->>AgentLoop: messageStop stopReason=tool_use
    Bedrock-->>AgentLoop: metadata usage
    deactivate Bedrock

    %% --- 5. Execute the tool locally ---
    AgentLoop->>ToolsReg: execute(name, args)
    ToolsReg->>Calc: calculate_securities_nav / xirr / allocation / ...
    Calc->>DB: SELECT FROM TRANSACTION, FX_RATE, DAILY_PRICE, ...
    DB-->>Calc: rows
    Calc-->>ToolsReg: dict (NAV / holdings / IRR / ...)
    ToolsReg-->>AgentLoop: success=True, payload
    AgentLoop-->>View: LoopToolResult(name, id, success, preview)
    View-->>UI: event tool_result {name, id, success, preview}
    UI->>UI: render tool-call card

    %% --- 6. Feed result back into the model ---
    AgentLoop->>AgentLoop: append assistant msg (text + toolUse)<br/>append user msg with toolResult
    AgentLoop->>Bedrock: converse_stream with extended messages
    activate Bedrock

    alt model still wants more tools
        Bedrock-->>AgentLoop: more text + toolUse blocks
        Note over AgentLoop,DB: repeat steps 4 to 6 up to max_iterations
    else model returns final answer
        loop final tokens
            Bedrock-->>AgentLoop: contentBlockDelta text - TextDelta
            AgentLoop-->>View: LoopText(chunk)
            View-->>UI: event text {text}
        end
        Bedrock-->>AgentLoop: messageStop stopReason=end_turn plus metadata usage
    end
    deactivate Bedrock

    %% --- 7. Optional: structured-output sentinel ---
    opt preset = trade_tickets
        Note over AgentLoop,ToolsReg: When the model calls submit_trade_tickets,<br/>loop intercepts and short-circuits.
        AgentLoop-->>View: LoopStructured(name, payload)
        View-->>UI: event structured {name, payload}
        UI->>UI: render trade-tickets table
    end

    %% --- 8. Loop terminates ---
    AgentLoop-->>View: LoopDone(stop_reason, iterations, tool_calls, structured, usage)

    %% --- 9. Persist audit record ---
    View->>DB: INSERT INTO ADVISOR_REPORT (user_id, preset, user_prompt,<br/>model_id, backend, base_currency, response_md,<br/>structured, tool_calls, latency_ms, input_tokens, output_tokens)<br/>RETURNING id
    DB-->>View: report_id

    %% --- 10. Close the stream ---
    View-->>UI: event done {report_id, stop_reason, iterations, usage, tool_call_count}
    UI->>User: show Saved link to /advisor/report/{report_id}

2.1 Frame-by-frame: what each SSE event carries

Event Origin in code Payload shape UI reaction
meta View, immediately after stream open {preset, model_id, backend, currency} Show "model: claude-3-5-sonnet" badge.
text LoopTextTextDelta (Bedrock contentBlockDelta) {text} Append to live Markdown buffer; re-render.
tool_start LoopToolStart {name, id} Insert a "calling get_holdings…" card with a spinner.
tool_result LoopToolResult {name, id, success, preview} Replace spinner with success/error pill + first 400 chars of JSON.
structured LoopStructured (only trade_tickets) {name, payload} Render the schema-validated trade tickets table.
error except in event_stream() {message} Show red banner; stop streaming.
done After LoopDone and INSERT {report_id, stop_reason, iterations, usage, tool_call_count} Close stream; show "saved" link.

2.2 Why the loop, not raw tool-calling

A naïve implementation would call converse_stream once and hand the answer to the user. That breaks the moment the model decides to call a tool, because:

  1. Bedrock returns stopReason: "tool_use" — there is no answer yet, just a request for data.
  2. The application has to actually execute the tool, build a toolResult content block, and call the model again with the extended message history.
  3. The model may chain several tools (e.g. performance_summaryholdingsmonthly_returns) before producing prose.

advisor/loop.py runs that cycle up to max_iterations (default 8), accumulating an audit trail that is persisted at the end. The View knows nothing about Bedrock's wire format — it only consumes LoopEvent subclasses. That is what makes the Phase 2 swap to vLLM a one-line change in build_client().


3. ASCII Diagram End-to-End

Browser (SSE client)
   │ POST /api/advisor/analyze  {preset, prompt, currency}
advisor_views.py :: analyze()  ── @login_required
   │ builds ModelClient (build_client()), calls loop.run()
loop.py :: run()  ─── the agentic loop ───────────────────────┐
   │  1. seed messages = [user_prompt + currency hint]         │
   │  2. client.stream(system, messages, tools) → text/toolUse │  (repeat up to
   │  3. execute each requested tool via tools.py               │   max_iterations,
   │  4. append toolResult back into messages                   │   default 8)
   │  5. loop again until stop_reason != "tool_use"             │
   └─────────────────────────────────────────────────────────────┘
   │  yields LoopText / LoopToolStart / LoopToolResult / LoopStructured / LoopDone
advisor_views.py forwards each event as an SSE frame → browser renders incrementally
   │  on LoopDone: INSERT INTO ADVISOR_REPORT (best-effort, must not break the stream)
ADVISOR_REPORT row (audit trail: prompt, model_id, tool_calls JSON, tokens, latency)

4. Cross-references

  • Phase 2 (self-hosted on EKS): advisor-eks-architecture.md
  • Database schema: app_portfolio_tracker_aurora/schema.sql
  • Forward-only migration that introduced ADVISOR_REPORT: app_portfolio_tracker_aurora/migrations/20260531_add_advisor_report.sql
  • Internal implementation backlog: app_portfolio_tracker_aurora/docs/ADVISOR_PLAN.md