phase G: data minimisation + passwordless auth + DeepSeek-first LLM
Server no longer holds portfolios. Holdings live in the browser (localStorage); the server publishes an anonymous ticker_universe and a gzipped /api/universe payload identical for every authenticated user, so access patterns can't betray which tickers a user holds. AI commentary is generated ephemerally from the browser-supplied pie and the cost ledger row records no positions. Migrations 0009-0011 added the universe table and dropped positions / portfolio_snapshots / portfolios. Authentication is now e-mail OTP only. Migration 0010 dropped password_hash and email_verified (every active session is by construction proof of email control). The /signup endpoint is gone; signup and login share a single email-entry page. Email rendering is HTML+plain-text multipart with a shared brand palette (app/branding.py) asserted in sync with the CSS by a drift-detection test. LLM provider defaults to DeepSeek-direct (cheaper, api.deepseek.com) with OpenRouter as automatic fallback if DeepSeek fails. ai_log_job and indicator_summary_job now iterate the two tones (NOVICE, INTERMEDIATE) per cycle so the dashboard's tone toggle is instant; PROMPT_VERSION bumped to 6 with an educational anti-TA / anti-gambling stance baked into _CORE. NOVICE mode renders a curated glossary inline (CBOE VIX, yield curve, HY OAS, etc.) with JS-positioned tooltips that survive viewport edges and sticky bars. Model name and tokens hidden from the user UI; still recorded in StrategicLog.model and AICall for admin. Layout adds a sticky top nav, a sticky bottom markets bar (one chip per exchange with status LED + headline index + 1d change), and Phase H feedback reporting is queued in tasks/todo.md. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
480fd311c5
commit
6e7f57c6b2
54 changed files with 5005 additions and 916 deletions
81
tests/test_branding_consistency.py
Normal file
81
tests/test_branding_consistency.py
Normal file
|
|
@ -0,0 +1,81 @@
|
|||
"""Drift-detection: brand palette in `app/branding.py` must match the CSS.
|
||||
|
||||
Both the website (cassandra.css) and the email templates use the same
|
||||
palette. The CSS hand-authors the values in :root and [data-theme="light"]
|
||||
blocks; this test parses those blocks and asserts every variable matches
|
||||
its counterpart in branding.py. If a colour changes, both must change.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
from app import branding
|
||||
|
||||
|
||||
CSS_PATH = Path(__file__).resolve().parent.parent / "app" / "static" / "css" / "cassandra.css"
|
||||
|
||||
|
||||
def _extract_vars(css: str, selector: str) -> dict[str, str]:
|
||||
"""Parse `--name: value;` declarations inside the first matching
|
||||
selector block. Strips whitespace; lowercases hex values."""
|
||||
# Match the selector followed by its block. Non-greedy on the body to
|
||||
# stop at the first closing brace at the same depth (these blocks
|
||||
# don't nest in cassandra.css).
|
||||
pattern = re.escape(selector) + r"\s*\{([^}]*)\}"
|
||||
m = re.search(pattern, css)
|
||||
if not m:
|
||||
raise AssertionError(f"selector {selector!r} not found in CSS")
|
||||
body = m.group(1)
|
||||
out: dict[str, str] = {}
|
||||
for line in body.splitlines():
|
||||
decl = re.match(r"\s*--([a-z0-9-]+)\s*:\s*([^;]+);", line)
|
||||
if not decl:
|
||||
continue
|
||||
name, value = decl.group(1), decl.group(2).strip().lower()
|
||||
out[name] = value
|
||||
return out
|
||||
|
||||
|
||||
@pytest.fixture(scope="module")
|
||||
def css_text() -> str:
|
||||
return CSS_PATH.read_text(encoding="utf-8")
|
||||
|
||||
|
||||
def test_dark_palette_matches_css(css_text):
|
||||
css_dark = _extract_vars(css_text, ":root")
|
||||
for key, expected in branding.DARK.items():
|
||||
actual = css_dark.get(key)
|
||||
assert actual == expected.lower(), (
|
||||
f"DARK[{key!r}] mismatch: branding.py={expected!r} vs css={actual!r}"
|
||||
)
|
||||
|
||||
|
||||
def test_light_palette_matches_css(css_text):
|
||||
css_light = _extract_vars(css_text, '[data-theme="light"]')
|
||||
for key, expected in branding.LIGHT.items():
|
||||
actual = css_light.get(key)
|
||||
assert actual == expected.lower(), (
|
||||
f"LIGHT[{key!r}] mismatch: branding.py={expected!r} vs css={actual!r}"
|
||||
)
|
||||
|
||||
|
||||
def test_palette_keys_match_between_themes():
|
||||
"""If a colour is defined in dark, it must also be defined in light
|
||||
(and vice versa) — otherwise the theme switch leaves elements
|
||||
unstyled."""
|
||||
assert set(branding.DARK.keys()) == set(branding.LIGHT.keys())
|
||||
|
||||
|
||||
def test_email_uses_branding_palette():
|
||||
"""Sanity: the rendered OTP HTML should contain at least one of each
|
||||
theme's key colours, confirming the substitution actually wired up."""
|
||||
from app.services.email_service import render_otp_email
|
||||
|
||||
_, _, html = render_otp_email("123456", 15)
|
||||
assert branding.LIGHT["accent"] in html
|
||||
assert branding.DARK["accent"] in html
|
||||
assert branding.LIGHT["bg"] in html
|
||||
assert branding.DARK["bg"] in html
|
||||
Loading…
Add table
Add a link
Reference in a new issue