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/<id><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 |
LoopText ⇐ TextDelta (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:
- Bedrock returns
stopReason: "tool_use"— there is no answer yet, just a request for data. - The application has to actually execute the tool, build a
toolResultcontent block, and call the model again with the extended message history. - The model may chain several tools (e.g.
performance_summary→holdings→monthly_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