DeepSeek's native API returns prompt_tokens/completion_tokens but not
`usage.cost`. OpenRouter returns both. Result: with DeepSeek-direct as
primary (current default), every LogResult.cost_usd was None — and
every downstream cost ledger row (AICall, StrategicLog,
IndicatorSummary, translation tables) stored None instead of the real
spend.
Added a per-model rate table and fallback computation in _call_provider:
when the upstream omits cost, multiply tokens by the table rates. If the
upstream DOES return cost, keep it (authoritative). Falls back to None
if both the upstream and the table miss.
deepseek-v4-flash rates: \$0.07/M input, \$0.28/M output (per DeepSeek).
Splits the 2571-line cassandra.css into ten focused stylesheets:
tokens (palette + fonts), layout (chrome), panels, dashboard,
portfolio, log-chat, auth, settings, news, public. base.html and
public_base.html load only what they need; auth pages (login,
verify, unsubscribe confirm) load tokens + layout + auth.
Brand drift-detection test repointed at tokens.css (where the
palette now lives). 291 tests still pass.
api.py was 933 lines mixing four distinct concerns: indicators +
news + strategic log (the JSON/HTMX API proper), the chat endpoint
+ its three private helpers (~200 lines), and the two HTML-only ops
endpoints /markets-bar + /health (~150 lines).
Extracted:
- app/routers/chat.py — POST /api/chat + _latest_quotes_by_group_chat,
_thesis_headlines_for_chat, _month_spend
- app/routers/ops.py — GET /api/markets-bar + GET /api/health +
_fmt_price helper
Both new routers use the same dependencies=[Depends(require_token)]
as api.py and are mounted at the /api prefix in app/main.py.
URL surface is byte-identical with no externally-visible change.
api.py shrinks to ~620 lines focused on indicators+news+log+settings.
Helpers shared with the original api.py (_md_to_html, _resolve_tone_param)
are imported from app.routers.api where needed in chat.py to avoid
duplication.
Also updated tests/test_chat_and_log_gates.py to mount chat_router
in its local test app, since /api/chat now lives there.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
email_service.py was 428 lines covering three different concerns:
SMTP transport, OTP/welcome rendering (tightly coupled — same brand
template + theme), and digest rendering (a totally different shape
of email, different layout, different copy cadence). The two halves
changed at different cadences and made the file noisy to navigate.
Extracted render_digest_email + _DIGEST_HTML_TEMPLATE +
_strip_html_to_text to app/services/digest_email.py. SMTP transport
and the OTP/welcome surface stay in email_service.py.
Import sites updated: email_digest_job and test_email_render now
import render_digest_email from digest_email. The OTP/welcome
import sites (auth router, branding tests, test_email_service) are
untouched.
No behaviour change — pure relocation. Templates byte-identical.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
openrouter.py was 790 lines mixing two orthogonal concerns:
- Prompt engineering (build_system_prompt, build_summary_*,
build_chat_*, build_daily_digest_*, etc.) — ~400 lines, changes
weekly as PROMPT_VERSION bumps
- LLM transport (call_llm, _provider_chain, _call_provider, retry
+ fallback machinery) — ~250 lines, rarely changes
Extracted the prompt-engineering surface to app/services/llm_prompts.py.
Transport stays in openrouter.py (consistent with the filename — the
OpenRouter URL is the transport's anchor).
All import sites (jobs, routers, services, tests) split their
multi-import lines into two: prompt-things from llm_prompts, transport
from openrouter. PROMPT_VERSION constant, _TONE_ALIASES, _resolve_tone,
and SYSTEM_PROMPT moved with the prompt functions.
No behaviour change — pure relocation. Function signatures, body, and
naming all preserved.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Three recently-added tables (strategic_log_translations,
indicator_summary_translations, csv_format_templates) drifted from
the codebase's existing naming convention:
- llm_model -> model
- llm_cost_usd -> cost_usd
- content_md -> content (on the two translation tables; csv_format
doesn't have a content field)
Also added prompt_tokens and completion_tokens to the three tables;
they were silently dropped at write time despite LogResult exposing
them.
All writer call sites (ai_log_job, indicator_summary_job,
llm_csv_parser) and reader call sites (api.py localized helpers)
updated to match. Tests realigned.
Migration 0025 uses batch_alter_table for SQLite compatibility.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
The two largest inline <script> blocks in settings.html — the cloud
sync modal/management UI (~145 lines) and the import widget wiring
(~245 lines) — moved to app/static/js/settings-sync.js and
settings-import.js respectively, included via <script src="..."
defer> at the bottom of the template.
Where the inline code referenced Jinja vars or {% if %} guards,
those values are now passed via data-* attributes on the relevant
DOM elements (or via window.cassandra* config objects for structured
data) and read in the static JS.
Smaller blocks (Stripe portal, digest prefs, language select,
invite copy) stay inline — each <40 lines and easier to follow
next to their markup. settings.html drops from 758 lines to roughly
half that.
The theme toggle's onclick attribute held a 140-character inline
IIFE that was hard to read amongst the other named-function
handlers in the same header. Promoted it to cassandraToggleTheme()
alongside cassandraSetTone / cassandraSetLang.
The HTMX log endpoints in api.py do their own localization via
_localized_content; the pages.py helper was added during the
initial localization wiring but was bypassed once HTMX rendering
landed. No call sites remain.
The /log page renders its content asynchronously by hitting
/api/log/latest?as=html and /api/log/by-date/{day}?as=html via HTMX.
Both endpoints returned StrategicLog.content (English) verbatim,
ignoring the new StrategicLogTranslation table entirely. The
_resolve_log_content helper I added to pages.py earlier was wired
into the page handlers themselves but never reached for HTMX swaps,
so Italian users only ever saw English content despite their
lang='it' preference being persisted and translations being
generated correctly.
Fix: add a _localized_content helper in api.py that looks up the
matching translation row for the requesting principal's lang.
_log_partial_payload gains a content_override arg; both HTMX
endpoints (log_latest, log_by_date) compute the override and pass
it through. JSON paths (?as= other than html) remain English to
avoid changing the public API contract.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Two related schema fixes from the code review:
- users.lang gets a single-column index. The ai_log_job and
email_digest_job both SELECT DISTINCT on this column every cycle;
even at low cardinality an index is the right shape.
- quotes_daily.symbol widened to VARCHAR(128) to match quotes.symbol
(widened back in 0005). Long Eurostat/ONS symbols would silently
truncate during rollup otherwise.
Models updated to match (User.lang gains index=True, QuoteDaily.symbol
goes to String(128)).
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
- pyproject already sets asyncio_mode=auto, so async def tests are
collected as async automatically. Removed the redundant decorator
from four files (test_i18n, test_llm_csv_parser, test_ticker_validate,
test_localization_integration); the bare async def is enough.
- StrategicLogTranslation.log_id used the _PK autoincrement type for
a non-PK FK column. Replaced with a portable BigInteger that emits
Integer on SQLite and BigInteger elsewhere — matches the migration's
sa.BigInteger() declaration.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
- indicator_summary_job.py imported its own copies of _month_spend and
_latest_quotes_by_group; _market_context.py already exposes these.
Switched to the canonical imports. Also fixed _market_context's
latest_quotes_by_group to actually filter null prices (it claimed to
in its docstring but lacked the WHERE clause).
- api.py duplicated REFERENCE_LINE as CHAT_REFERENCE_LINE — same string,
two sources of truth. Now imports REFERENCE_LINE.
- Chat endpoint used the deprecated `call_openrouter` alias and passed
an explicit `model=` that bypassed the provider chain. Switched to
`call_llm` with default model selection, then removed the alias.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Stale comments referencing completed migrations:
- universe.py "remain live until step 10 of Phase G" — endpoints gone
- api.py "Portfolio endpoints moved to universe.py" — empty block
- csv_import.py "persist_pie removed in Phase G" — historical context
Dead Settings fields (all confirmed unreferenced by app code):
- CASSANDRA_PORT — port is hardcoded in docker-compose / uvicorn cmd
- POLAR_API_KEY — Polar was replaced by Stripe
- CASSANDRA_MOCK — env var still set by tests as a sentinel; the
Settings field itself was never read
- CASSANDRA_BASE_CURRENCY — "GBP" hardcoded inline elsewhere
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
The .app-footer rule was kept "for /api/health" but the health page
doesn't reference it. #submit-btn and .form-row were leftovers from
the removed upload page. .pf-restore had a class attribute in
portfolio.js but no CSS rule — dropped the class attribute too.
Also removed the @media (prefers-color-scheme: dark) block — the
dashboard JS always sets data-theme so the media query was unreachable.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
- chat.js: pending indicator class was wrong (.pending instead of
chat-msg--pending) so the … waiting message never got italic/dim
- settings.html + cassandra.css: three invented CSS vars (--panel-bg,
--ok, --surface-1) had hardcoded fallbacks that broke dark mode;
replaced with real tokens (--surface, --positive)
- cassandra.css: .pf-secondary was scoped to .pf-actions but used
standalone in 4 places (sync modal, disable-sync, import cancel,
forget-pie button) — hoisted to a top-level selector
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Two issues addressed:
1. The /settings language <select> was unstyled — .settings-select and
.settings-status classes didn't exist, so the dropdown rendered
with full native browser chrome and clashed visually with the rest
of the panel. Added a terminal-aesthetic select: transparent
background, 1px var(--border), custom chevron via crossed
linear-gradients, accent border on focus/hover. Disabled options
(ES/FR/DE 'coming soon') render in --dim.
2. Added a compact EN | IT pill in the topbar next to the theme
toggle, mirroring the .tone-toggle visual rhythm. Shown only when
a user is signed in (admins skipped). Optimistic UI: clicking
flips the pill immediately, PATCHes /api/settings/language, and
reverts on failure. On /log specifically the page reloads so the
user sees the localized version of the strategic log right away.
The /settings dropdown still surfaces all five languages (with ES/FR/DE
disabled) for visibility; the topbar pill keeps to the two active
languages to stay compact.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Adds module-level _resolve_log_content(session, log_id, lang) helper
to app/routers/pages.py: looks up StrategicLogTranslation by (log_id,
lang) when lang != 'en'; falls back silently to the English original
when no translation row exists yet (the expected case for the first
hour after a new language activates, or when translation fails for a
specific log).
log_page / log_page_day pull cu.user.lang and thread it through
_log_page_context so the template renders the right variant.
Two tests cover both branches.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
- Delete app/templates/upload.html. The /upload route redirected to
/settings#import (302) and never rendered this template; the file
was carrying stale Trading-212-only copy.
- Landing + pricing pages: replace "Trading 212 today, more brokers
planned" with "Trading 212 natively, other formats auto-detected"
to reflect the LLM-fallback parser that's been live for a few days.
The /upload redirect route in app/routers/pages.py stays — it remains
a useful bookmark-forwarder for users with old links.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
A small italic muted line beneath the form explaining the controls:
"Type a symbol, then quantity and cost — or use the calendar to fill
cost from a buy date — then [+] to add. [×] next to an existing row
removes it."
Only renders while the composer itself is visible (i.e. in edit mode),
so it doesn't clutter the dashboard at rest.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Two related polishes:
- The add form was auto-shown by the empty-state path so brand-new
users would see something to act on. That conflicts with the user's
preference for "Edit always toggles the form, no other path." The
empty state now shows guidance copy ("click edit to add one")
instead. exitEditMode always hides the form too.
- The submit "add" word-button is replaced by a square accent-bordered
+ glyph (26×26). Matches the visual weight of the calendar ghost
next to it but stays in the accent colour so it reads as primary.
Adds a tiny active-state scale tick for tactile feedback.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Composer had zero horizontal padding so the leading `$` prompt was
flush with the panel border. Match the panel-header's 12px horizontal
inset so the form sits inside the panel's content gutter.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Subtitle was technical noise that didn't earn its space in the header.
Title alone reads cleaner. Kept the scoped panel-header layout override
in cassandra.css since it's harmless and future-proof against re-adding
header children.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
A single-quoted string literal "couldn't validate" was breaking the
parse because the apostrophe wasn't escaped. The page logged a syntax
error and none of the edit-mode JS ran. Backslash-escape it.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Two interlocking bugs surfaced after the design pass:
1. CSS `display: inline-flex` on .pf-edit-btn/.pf-done-btn overrode the
UA's `[hidden] { display: none }`, so the JS toggling `editBtn.hidden`
had no visual effect — both buttons rendered side by side.
2. portfolio.js's empty-state path sets `form.hidden = false` but the
populated-portfolio render path only removed the `pf-empty` class; it
never reset `form.hidden = true`. So once a user went through the
empty state, the add form stuck around — leaving the Add button
visible on a populated dashboard.
Fixes are surgical: add an explicit `[hidden]` rule for the two
header pills, and re-hide the form in `renderPanel` unless edit mode
is currently active (so we don't yank the form out from under an
edit-in-progress).
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
The previous CSS used invented variable names (--neu-dim, --err, --ok)
that don't exist in the project's design system; the form fell back to
hardcoded hex values and looked disconnected from the rest of the site.
Rebuilt against the real tokens (--border, --dim, --muted, --positive,
--negative, --warning, --accent) and the mono-first 'geopolitical-
terminal aesthetic' the rest of the dashboard uses:
$ ticker ✓ 172.40 USD │ qty @ cost USD 📅 add
────
- No boxed-form chrome. A dashed bottom rule separates the composer
from the table below.
- Inputs lose their card-style boxes; they're underline-only with a
faint accent wash on focus — feels like editing a command line.
- '$' prompt marker, '│' divider, '@' between qty and cost give the
row a terminal grammar without being twee.
- Submit is a ghost pill in the accent colour; lights to solid only
when enabled.
- All controls now respond correctly to the light/dark theme toggle.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Replace the multi-row wizard-style form (Ticker / Qty on row 1, mode
radios on row 2, Date+Cost on row 3) with a single horizontal strip
that sits unobtrusively above the portfolio table. The radio toggle is
gone; a small calendar icon next to the Cost input pops out a date
picker that auto-fills cost on selection and then hides itself.
Same input IDs, so the existing validate/Add/× handlers work unchanged.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
The route's resolve-slice loop is T212-specific — it looks tickers up
against the InstrumentMap, which only has T212's universe. For the LLM
path the ticker is already Yahoo-ready (e.g. VOD.L, ASML.AS), so
sending it through resolve_slice produced spurious "could not be
resolved" warnings and dropped the positions.
Fix: ParsedPie gains a ``tickers_resolved`` flag (default False for
T212 backward-compat); _apply_mapping in the LLM path sets it True
and also extracts currency from the LLM-mapped currency_col into a
new ``ParsedPosition.currency`` field. The route branches on the flag:
LLM-path positions are kept verbatim with a best-effort InstrumentMap
lookup for nicer name/currency overrides, never dropped.
Integration test tightened to assert all 5 IBKR fixture positions
round-trip with the right currencies (USD / GBP / EUR).
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>