Skip to content

Repository files navigation

InvestBuddy

Investment strategy assistant for expats in Germany. Tracks ETFs explains German tax rules in plain English, and generates a weekly digest — powered by a LangGraph agent with human-in-the-loop conversation.


What it does

  1. Onboards you through a short questionnaire (savings, emergency fund, monthly budget, risk tolerance, horizon, investing style, salary) in a conversational chat interface — the question list is derived automatically from the strategy blueprint registry, so it always covers exactly what the recommendation logic needs.
  2. Proposes a strategy from a curated, versioned blueprint registry (agent/strategy_blueprints.py): eligibility is filtered deterministically, the LLM picks among only the eligible blueprints and personalises the prose, and the result is conformance-checked against the chosen blueprint. References come from the blueprint's curated sources — never from model memory.
  3. Generate investment suggestions Based on your strategy and tax advantages in Germany.
  4. Calculates taxes — Abgeltungsteuer, Teilfreistellung, Vorabpauschale, Sparerpauschbetrag — using the current German tax rules, without calling an external service.
  5. Generates a weekly digest on demand: portfolio snapshot, tax status, plan check, and one action item.

Tech stack

Layer Technology
Web framework Django 5 + PostgreSQL
Agent / conversation LangGraph 0.6 with PostgresSaver checkpointer
LLM OpenAI Chat Completions API — model set by AGENT_MODEL (currently gpt-5.4); used only for plan and digest nodes
Live prices yfinance (no API key required)
Frontend HTMX (served as local static file, no build step); light theme by default with dark-mode toggle (localStorage), icon-rail navigation, collapsible chat panel on every page
Static files WhiteNoise
German tax engine Pure Python, no external dependencies
Package manager uv
Database driver psycopg3 (psycopg[binary]) + psycopg-pool
Local database Docker — pgvector/pgvector:pg16 container (host port 5433)
Knowledge retrieval Hybrid: pgvector cosine (HNSW index) + Postgres full-text search, fused with Reciprocal Rank Fusion

Project layout

invest-tax/
├── agent/                  # LangGraph agent — no Django views here
│   ├── graph.py            # StateGraph definition, PostgresSaver wiring
│   ├── state.py            # AgentState TypedDict and sub-TypedDicts
│   ├── nodes.py            # All node functions and routing logic
│   ├── strategy_blueprints.py  # versioned blueprint registry: curated sources + eligibility conditions + allocation templates ("blueprints decide, RAG explains")
│   ├── validators.py       # UCITS gate, blueprint conformance, risk alignment
│   ├── catalog.py          # canonical asset types + allocation categories
│   ├── tax_engine.py       # Pure Python German tax calculations
│   ├── price_service.py    # yfinance wrapper
│   └── tests.py
│
├── portfolio/              # Django app — long-term storage
│   ├── models.py           # UserProfile, Goal, Holding, ExitRule,
│   │                       # Strategy, InvestedPortfolio, InvestedPortfolioItem
│   ├── views.py            # Overview, upload CSV, add manual, tax partial,
│   │                       # strategy/portfolio save·switch·delete, catalog browse
│   └── tests.py
│
├── chat/                   # Django app — HTMX chat interface
│   ├── views.py            # Bridges HTTP requests to LangGraph graph
│   └── tests.py
│
├── digest/                 # Django app — weekly digest trigger
│   ├── views.py            # Calls digest_node directly, returns HTML
│   └── tests.py
│
├── rag/                    # Django app — multi-domain knowledge base
│   ├── models.py           # KnowledgeSource (abstract) → TaxSource / StrategySource; shared KnowledgeChunk vector base (pgvector column + HNSW index)
│   ├── ingest.py           # fetch (URL or uploaded PDF) → split on headings/pages → chunk → embed with "{title} — {section}:" prefix; cached query translation
│   ├── retriever.py        # hybrid search: pgvector cosine + Postgres FTS, RRF fusion, per-source cap, calibrated relevance gate
│   ├── admin.py            # add Tax / Investment Strategy sources by URL or PDF upload
│   ├── evals/              # golden Q&A set + eval runner (see `manage.py rag_eval`)
│   └── tests.py
│
├── investbuddy/            # Django project config
│   ├── settings.py
│   └── urls.py
│
├── docker-compose.yml      # pgvector Postgres 16 container (host port 5433)
├── pyproject.toml          # Dependencies (managed by uv)
└── uv.lock                 # Auto-generated lock file — do not edit

Memory architecture

InvestBuddy uses one PostgreSQL database with two logically separate areas — Django ORM tables and LangGraph checkpoint tables. They share the same Postgres instance but never overlap. This is the most important architectural decision in the project.

1. Django tables — long-term memory

Stores structured, durable user data via the Django ORM (django.db.backends.postgresql). This data survives conversation resets.

UserProfile             one per user  savings, emergency fund, risk profile, tax bracket
Goal                    many per user investment goals with target amounts and dates
Holding                 many per user portfolio positions (ticker, units, purchase price)
ExitRule                one per Holding  when to sell or review a position
Strategy                many per user  saved strategies (name, text, allocation data, one active)
InvestedPortfolio       many per user  saved AI-suggested portfolios (name, amount, one active)
InvestedPortfolioItem   many per InvestedPortfolio  suggested pick (ticker, €-amount, rationale)

Note: strategies used to live as flat fields on UserProfile (strategy_approved, approved_strategy_text, …). They moved to the Strategy model — existing dev databases must be reset (delete the DB / wipe the Docker volume and re-migrate); there is no data migration.

These tables are written by the portfolio views (manual entry, CSV upload) and read by upload_node in the agent. They represent facts that persist across sessions: "this user holds 10 units of VWCE.DE bought at €90".

2. LangGraph checkpoint tables — conversation memory

Stores the full LangGraph checkpoint state for each conversation thread. This is what enables the agent to resume mid-conversation — across HTTP requests, server restarts, and browser refreshes.

Tables written by PostgresSaver:

  • checkpoints — one row per graph execution step, keyed by thread_id + checkpoint_id
  • checkpoint_blobs — binary blobs for large state fields (message lists, holdings lists)
  • checkpoint_writes — pending writes that haven't been committed to a checkpoint yet

memory.setup() in agent/graph.py creates these tables on first run; it is idempotent and safe to call every startup.

What a checkpoint contains: the entire AgentState dict at a point in time — all messages, intake_step, savings_total, holdings, tax, approved_strategy, and current_node. LangGraph replays from the latest checkpoint on resume.

Reset behaviour: the chat reset button deletes rows from the three LangGraph tables for the current thread_id. It does not touch the Django ORM tables. After reset, UserProfile and Holding records remain intact — upload_node will re-read them from the Django DB on the next run. The user does not need to re-enter their portfolio.


LangGraph: how the agent works

The graph

        ┌──────────────────────────────────┐
        │                                  │
        ▼                                  │
[INTERRUPT] → intake → route_after_intake ─┤
                                           │
                                     "upload" ▼
                              upload → fetch_prices → analysis
                                                         │
                                                         ▼
                              ┌─────────────────────── plan ◄──── "adjust"
                              │                                       │
                              ▼                                       │
                        [INTERRUPT] → qa (intent router) ────────────┤
                              ▲                │      │               │
                              │       "approve"▼      │"question"     │
                              │            approval   ▼               │
                              └───────────────┘  rewrite_route        │
                              ▲                  (which tools?)       │
                              │                 ┌─────┼─────────┐     │
                              │                 ▼     ▼         ▼     │
                              │            retrieve  simulate  (none) │
                              │            (routed   (projection      │
                              │             domains,  engine)         │
                              │             blueprint-scoped)         │
                              │                 └─────┴───────┐       │
                              │                               ▼       │
                              └──────────────────────────── answer ───┘

[digest] ────────────────────────────────────────► END
(called directly by digest/views.py, outside the graph flow)

interrupt_before — the human-in-the-loop mechanism

The graph is compiled with:

interrupt_before=["intake", "qa"]

This tells LangGraph to pause execution before those nodes and hand control back to the caller. The graph does not run to completion in a single invoke call — it suspends and waits for the next message.

Why two interrupts?

  • intake — pauses before every onboarding question so the user can answer it. The answer is merged into state, then invoke(None) resumes execution so the node can parse the answer and ask the next question.
  • qa — the post-plan conversation loop. Every user message lands here; qa_node classifies it (approve / adjust / question, strict JSON schema) and routes to approval (save), back to plan (revise), or through the tool pipeline (rewrite_route → only the needed tools → answer) before looping back to the interrupt.

The double-invoke pattern

Every user message in the chat requires two sequential graph.invoke() calls:

# Step 1: merge the user's message into the checkpoint state
# This triggers an interrupt (pauses BEFORE intake/approval) with the new message in state
graph.invoke(
    {"messages": [{"role": "user", "content": user_text}]},
    THREAD_CONFIG,
)

# Step 2: resume from the interrupt — the node now runs with the user message available
graph.invoke(None, THREAD_CONFIG)

If you call invoke only once with the user message, it merges the message and immediately re-interrupts before the node runs — the node never sees the answer. The second invoke(None) is what actually executes intake_node or approval_node.

State schema

AgentState extends LangGraph's MessagesState (which provides the messages field with built-in append semantics) and adds typed fields for every piece of data the agent needs:

class AgentState(MessagesState):
    # Flow control
    user_id: str
    current_node: str       # routing signal — not the LangGraph node name
    intake_step: int        # which onboarding question we're on (0–5)

    # User profile (filled by intake_node)
    savings_total: float
    emergency_fund_floor: float
    investable_surplus: float
    monthly_investment_budget: float
    risk_profile: str       # "conservative" | "balanced" | "growth"
    tax_bracket: float      # estimated marginal rate (0.14 – 0.42)
    is_married: bool        # affects Sparerpauschbetrag allowance

    # Portfolio (filled by upload_node + analysis_node)
    holdings: list[HoldingState]
    total_invested: float
    total_current_value: float
    total_unrealised_gain: float
    allocation: dict

    # Tax summary (filled by analysis_node)
    tax: TaxState

    # Strategy (filled by plan_node + approval_node)
    approved_strategy: dict   # plan_text + data (allocation, blueprint_id, version)
    monthly_split: dict

Phase 2 added: blueprint parameters (horizon_years, prefers_simplicity, em_thematic_comfort), intake bookkeeping (intake_plan, intake_reasked), router output (routed_query, routed_domains, wants_projection) and the rolling history summary (summary, summary_msg_count) — see agent/state.py.

current_node is a routing signal written by each node to tell the conditional edge functions where to go next. It is not the LangGraph internal node name — it is a field in the application state used by route_after_intake and route_after_approval.

Nodes summary

Node LLM? Reads Writes
intake_node AGENT_ROUTER_MODEL (question phrasing + answer extraction; canned/regex fallbacks) intake_step, intake_plan, messages, UserProfile knowns profile fields incl. horizon_years, prefers_simplicity, em_thematic_comfort; next question
upload_node No Django DB (Holding, UserProfile) holdings list in AgentState
fetch_prices_node No holdings prices
analysis_node No holdings, prices holdings (with live prices), tax (TaxState), totals
plan_node AGENT_MODEL (strict JSON schema) profile params → eligible blueprints approved_strategy (prose + allocation + blueprint_id), conformance-checked
qa_node AGENT_MODEL (intent, strict JSON schema) last user message current_node (save / adjust / answer)
rewrite_route_node AGENT_ROUTER_MODEL (strict JSON schema) conversation tail routed_query, routed_domains, wants_projection
retrieve_node No (embeddings only) routed_query; active blueprint sources preferred only when the router flags the question as being about the user's own plan retrieved_context
simulate_node No wants_projection, user numbers projection_context
answer_node AGENT_MODEL windowed history + summary + tool context reply (cite-or-say-so framing, fabricated citations stripped), summary
approval_node No approved_strategy UserProfile (saved strategy), confirmation
digest_node AGENT_MODEL Full AgentState context New digest message appended to messages

digest_node is called directly by digest/views.py, bypassing the graph routing entirely. It is also registered as a graph node so it can be called in future automated flows.

PostgresSaver configuration

from psycopg_pool import ConnectionPool
from psycopg.rows import dict_row
from langgraph.checkpoint.postgres import PostgresSaver

pool = ConnectionPool(
    conninfo=settings.LANGGRAPH_DB_URL,
    max_size=20,
    kwargs={"autocommit": True, "prepare_threshold": 0, "row_factory": dict_row},
    open=True,
)
memory = PostgresSaver(pool)
memory.setup()  # idempotent — creates checkpoint tables on first run

A ConnectionPool is used (not a single connection) because Django's threaded request handling requires concurrent database access. The three kwargs are required by PostgresSaver:

  • autocommit=True — LangGraph manages its own transaction boundaries; a connection pool in autocommit mode lets it do so without conflict.
  • prepare_threshold=0 — disables psycopg3's server-side prepared statements, which are incompatible with PgBouncer-style connection poolers.
  • row_factory=dict_rowPostgresSaver expects rows as dicts, not tuples.

LANGGRAPH_DB_URL defaults to the same Postgres instance as the Django ORM (see investbuddy/settings.py). Override it with the LANGGRAPH_DB_URL environment variable to point the checkpointer at a different database.


German tax engine

All calculations are in agent/tax_engine.py. No external service is called. Each constant has a source citation.

Abgeltungsteuer (§32d EStG)

The flat capital gains tax rate in Germany:

  • 25% Abgeltungsteuer + 5.5% Solidaritätszuschlag surcharge
  • Effective rate: 26.375% on gains

Teilfreistellung (§20 InvStG)

Partial tax exemption on investment funds. Rationale: fund companies already pay corporate tax on their income before it reaches investors, so the state reduces the investor's tax to avoid double taxation.

  • Equity ETFs (≥51% stocks): 30% of gains exempt → effective rate ~18.46%
  • Bond ETFs (<25% equities): 0% exempt → full 26.375%
  • Individual stocks: 0% exempt → full 26.375%

This is why accumulating ETFs are tax-efficient for long-term investors in Germany.

Vorabpauschale (§18 InvStG)

An annual advance tax on accumulating (thesaurierend) ETFs. Because acc. ETFs never pay dividends, the tax authority collects a proxy tax each January based on a theoretical return:

Basisertrag = fund_value_jan1 × Basiszins × 0.70
Vorabpauschale = max(0, Basisertrag − distributions_paid) × (1 − 0.30 Teilfreistellung)
Tax = Vorabpauschale × 26.375%

Basiszins is set annually by the Deutsche Bundesbank / BMF. The 2026 rate is 3.20%. Only accumulating ETFs (etf_acc) attract this tax — distributing ETFs (etf_dist) are taxed when dividends are paid, so no advance tax applies.

Sparerpauschbetrag (§20(9) EStG)

Annual tax-free allowance on capital income:

  • Single: €1,000/year
  • Married (filing jointly): €2,000/year

Applied at the portfolio level. InvestBuddy tracks remaining allowance in TaxState.sparerpauschbetrag_remaining and uses it to shade the tax estimates shown in the portfolio view.

Exit tax (§19(3) InvStG + Jahressteuergesetz 2024)

If you leave Germany with a portfolio whose total acquisition cost exceeds €500,000, the departure is treated as a deemed disposal — you owe capital gains tax as if you sold everything on the day you left, even though you haven't.

InvestBuddy flags this as a warning when total_invested > €500,000. This rule applies from 1 January 2025.


Django apps

portfolio — long-term memory + portfolio UI

Owns the four Django models. Data here persists independently of the agent conversation.

Views:

  • GET /portfolio/ — fetches live prices via yfinance, updates holding values, renders the overview
  • POST /portfolio/manual/ — adds a single holding by form
  • POST /portfolio/upload/csv/ — parses a CSV, upserts holdings via update_or_create
  • GET /portfolio/holdings/ — HTMX partial: holdings table
  • GET /portfolio/tax/ — HTMX partial: tax summary panel

chat — conversation interface

A thin bridge between HTMX HTTP requests and the LangGraph graph. Contains no business logic.

Views:

  • GET /chat/ — loads message history from the latest checkpoint; bootstraps the graph (two invokes) if no messages exist yet
  • POST /chat/message/ — double-invoke pattern; returns only the new messages as an HTML partial via chat/message.html
  • POST /chat/reset/ — deletes LangGraph checkpoint rows for the thread, preserves Django DB, re-bootstraps

digest — weekly digest

Views:

  • GET /digest/ — scans the message history for the last assistant message that looks like a digest (heuristic: length > 200, contains "portfolio", "tax", "allowance", or "educational")
  • POST /digest/generate/ — calls digest_node(state.values) directly, returns the result as an HTML partial with characters escaped

Running locally

Prerequisites

  • Docker (for the Postgres container)
  • uv (Python package manager)

Steps

# Install dependencies (creates .venv automatically)
uv sync

# Configure environment
cp .env.example .env        # fill in OPENAI_API_KEY and DJANGO_SECRET_KEY

# Start the Postgres container (host port 5433)
just db-up

# Run Django migrations (also creates LangGraph checkpoint tables via memory.setup())
just migrate

# Collect static files
just static

# Start the dev server
just server

Open http://localhost:8000 — lands on /portfolio/ with the chat panel open on the left (the /chat/ page now redirects there; chat lives in the collapsible panel on every page).

To stop the database container: just db-down

Environment variables

Variable Required Description
OPENAI_API_KEY Yes Used in plan_node, answer_node, digest_node, and the rag app (embeddings + query translation)
AGENT_MODEL No OpenAI model for plan_node and digest_node, called via the Chat Completions API. Defaults to gpt-5.4. Must be a chat-completions-compatible model — *-pro models (e.g. gpt-5.4-pro) are not supported here as they require the Responses API
RAG_EMBEDDING_MODEL No OpenAI embedding model for the knowledge base. Defaults to text-embedding-3-small
RAG_TRANSLATION_MODEL No OpenAI model used to translate queries into a knowledge base's language (also used for the borderline relevance check). Defaults to gpt-4o-mini
AGENT_ROUTER_MODEL No Cheap model for the per-question rewrite/routing call (standalone query + which tools to run). Defaults to gpt-4o-mini
RAG_DEFAULT_QUERY_LANGUAGE No Language user queries arrive in; bases in this language skip translation. Defaults to en
DJANGO_SECRET_KEY Yes (prod) Django session signing key
DEBUG No Defaults to True
POSTGRES_DB No Database name. Defaults to investbuddy
POSTGRES_USER No Database user. Defaults to investbuddy
POSTGRES_PASSWORD No Database password. Defaults to investbuddy
POSTGRES_HOST No Database host. Defaults to localhost
POSTGRES_PORT No Database port. Defaults to 5433 (Docker maps 5433→5432 to avoid clashing with a local Postgres)
LANGGRAPH_DB_URL No Full DSN for the LangGraph checkpointer. Defaults to the same Postgres instance as the ORM
DJANGO_LOG_LEVEL No Level for Django's own loggers. Defaults to INFO
APP_LOG_LEVEL No Level for the app loggers (agent, rag, …). Defaults to DEBUG when DEBUG=True, else INFO
LANGSMITH_TRACING No true to stream traces to LangSmith. Defaults to false
LANGSMITH_API_KEY Only if tracing LangSmith API key (lsv2_...)
LANGSMITH_PROJECT No LangSmith project name. Defaults to kyron-investbuddy

Running tests

just test

132 tests covering: tax engine functions, price service (mocked yfinance), node helpers, routing logic, all portfolio views, chat view double-invoke logic, and digest view HTML escaping.

Evaluating answer quality

uv run python manage.py rag_eval                 # retrieval hit@4 + MRR per domain (cheap)
uv run python manage.py rag_eval --rewrite       # retrieval through the production rewrite router
uv run python manage.py rag_eval --answers       # + fact checks, citation validity, LLM-judge groundedness
uv run python manage.py rag_eval --plans 3       # + plan conformance baseline

The golden question set lives in rag/evals/golden.yaml; results are tracked in the scoreboard of docs/rag-agent-improvement-plan.md.


Observability

Two independent layers: logging (always on, local) and LangSmith tracing (opt-in, remote). Logging answers "what happened on this server"; LangSmith answers "what did the agent do on this run, step by step".

Logging

Logging is configured in the LOGGING dict in kyron/settings.py. There are two formatters, two handlers, and one logger per app.

Piece Value
Console handler concise format (`LEVEL logger
File handler verbose format (timestamp, level, logger:lineno, func()), written to logs/kyron.log
Rotation RotatingFileHandler, 5 MB per file, 5 backups (kyron.log.1kyron.log.5)
App loggers agent, rag, portfolio, chat, digest, accounts

logs/ is gitignored and created automatically on startup. Tune verbosity without touching code via the env vars:

# .env
DJANGO_LOG_LEVEL=INFO     # Django's own loggers (requests, server, ORM)
APP_LOG_LEVEL=DEBUG       # the agent/rag/portfolio/... loggers

Using a logger in code. Get a logger named after the app (so it inherits the configured handlers and level) and log at the appropriate level — never print:

import logging

logger = logging.getLogger("agent.nodes")   # or __name__ inside an app module

logger.debug("fetch_prices: resolved %d/%d tickers", hits, total)
logger.info("answer_node: generating reply (strategy_saved=%s)", saved)
logger.warning("retrieve_tax_context: retrieval failed for %r", query, exc_info=True)

Pass values as %s args (not f-strings) so formatting is skipped when the level is disabled, and add exc_info=True inside except blocks to capture the traceback. The agent nodes and tools already log their key steps this way.

Watching the log:

tail -f logs/kyron.log

LangSmith tracing

LangSmith records every agent run as a tree of spans. With tracing on you get, per chat turn:

  • the graph run — each LangGraph node (intake, qa, plan, answer, …) as a span, with the state in/out;
  • tool spansfetch_prices, retrieve_tax_context, compute_projection, because the tools in agent/tools.py are decorated with @traceable;
  • LLM spans — the raw OpenAI calls, because the client is wrapped with instrument_openai() (latency, tokens, prompt, completion).

Enable it — add a key and flip the switch in .env:

LANGSMITH_TRACING=true
LANGSMITH_API_KEY=lsv2_...
LANGSMITH_PROJECT=kyron-investbuddy   # optional; this is the default

Then restart the server and run a chat turn. Traces appear under your project at https://smith.langchain.com.

How it's wired (agent/observability.py + kyron/settings.py):

  • settings.py normalises LANGSMITH_* (or legacy LANGCHAIN_*) env vars into the LANGCHAIN_* vars LangGraph/LangSmith read at runtime — before agent.graph is imported. Tracing turns on only when both LANGSMITH_TRACING=true and a key are present; otherwise it is forced off so a stale env var can't silently start sending data.
  • observability.py exposes traceable(...), instrument_openai(client) and tracing_enabled(). All three are no-ops when the switch is off or the langsmith package is missing, so nothing breaks and no network calls are made when tracing is disabled — the default.

Tracing a new function. Decorate it; choose a run_type so it renders with the right icon:

from agent.observability import traceable

@traceable(run_type="tool", name="my_new_tool")   # "tool" | "retriever" | "chain" | "llm"
def my_new_tool(arg: str) -> str:
    ...

Promoting the tool to a graph node (a wrapper in agent/nodes.py registered in agent/graph.py) additionally makes it show up as its own step on the graph trace, not just an inline span.


Key design decisions

One Postgres instance, two logical areas. The Django ORM tables and the LangGraph checkpoint tables share the same PostgreSQL database but are kept logically separate. Mixing them at the schema level would mean the conversation checkpoint format leaks into the relational schema, and resetting a conversation would risk deleting portfolio data. The contract is explicit: Django owns facts, LangGraph owns conversation flow. A single Postgres instance simplifies local setup (one Docker container, one connection string) without sacrificing the separation.

Blueprints decide, RAG explains. Strategy methodology lives in a versioned registry (agent/strategy_blueprints.py): each blueprint pairs curated sources with deterministic eligibility conditions and a machine-checkable allocation template. Plan generation never retrieves — the LLM only picks among pre-filtered eligible blueprints and personalises prose, then the output is conformance-validated. RAG serves Q&A (scoped to the active blueprint's sources first) and explanation, not decision-making. References are rendered from the registry by code, so a plan citation can never be fabricated.

Deterministic core, LLM at the edges. All tax calculations, eligibility filtering, conformance validation and projections are deterministic Python — auditable and traceable to statute or registry. LLM calls are confined to: plan personalisation and Q&A answers (AGENT_MODEL, strict JSON schemas where structure matters), and small routing/phrasing/extraction calls (AGENT_ROUTER_MODEL), each with a deterministic fallback so the graph keeps working when a call fails.

Chat Completions API, not Responses. All LLM calls go through OpenAI's chat.completions endpoint. This is a deliberate choice: it is stable, widely documented, and every chat-tier model (including the current gpt-5.4) works as a drop-in via AGENT_MODEL. The trade-off is that reasoning-only flagships such as gpt-5.4-pro are unavailable, because they require the newer Responses API. Adopting a *-pro model would mean migrating the call sites in agent/nodes.py from client.chat.completions.create(...) (reading choices[0].message.content) to client.responses.create(...) (reading output_text), so it is left for a future change if deeper reasoning is ever needed.

interrupt_before, not interrupt_after. The interrupts are placed before intake and approval so that when the graph resumes, the node runs with the user's input already in state. If the interrupt were after the node, the node would run before the user had answered, producing empty or stale output.

WhiteNoise for static files. No nginx or CDN required in development or production. WhiteNoise serves compressed static files directly from Django. The runserver_nostatic app replaces Django's built-in dev static server so the same WhiteNoise path is used in both environments.

HTMX from local static file. The CDN URL was unreliable in the preview browser. HTMX is downloaded to static/js/htmx.min.js and served by WhiteNoise. This also means the app works fully offline once the server is running.

update_or_create for CSV import. The portfolio CSV upload uses update_or_create (not get_or_create) because Holding.units and Holding.avg_purchase_price are required fields with no default. get_or_create would attempt to INSERT without those values and fail on a fresh database. update_or_create passes them in the defaults dict, which is used both for creation and for updating an existing row.


Limitations and known issues

  • Single-user demo. USER_ID = "demo" and THREAD_CONFIG are module-level constants. There is no authentication or multi-tenancy.
  • EIMI.DE (iShares EM IMI) returns €0 from yfinance. This XETRA ticker is not reliably resolved. Use EIMI.L (London) or IS3N.DE as an alternative.
  • Tax calculations are estimates. Church tax (Kirchensteuer), loss carryforward offsets, and foreign tax credits are not modelled. The Sparerpauschbetrag is not automatically deducted from individual position tax figures — it is tracked at the portfolio level only.
  • Vorabpauschale uses a fixed Basiszins. The 2026 rate (3.20%) is hardcoded. Update BASISZINS_2026 in agent/tax_engine.py each January.
  • Approved strategies persist in the Strategy model, intake answers on UserProfile. When the user approves a plan in chat, approval_node writes it to the Django DB; saved strategies and invested portfolios survive conversation resets and can be switched from the Portfolio page. The conversation flow itself still lives in the LangGraph checkpoint.

About

An App using AI assistent to help decide the investment and taxes strategies

Resources

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages