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:
Giorgio Gilestro 2026-05-18 14:16:57 +01:00
parent 480fd311c5
commit 6e7f57c6b2
54 changed files with 5005 additions and 916 deletions

View file

@ -17,9 +17,11 @@ from app.models import AICall, Headline, JobRun, Quote, StrategicLog
from app.services.cadence import DEFAULT_POLICY
from app.services.openrouter import (
PROMPT_VERSION,
active_model,
build_system_prompt,
build_user_prompt,
call_openrouter,
call_llm,
llm_configured,
month_start,
)
@ -98,8 +100,8 @@ async def run() -> None:
if jr.status == "skipped":
return
s = get_settings()
if not s.OPENROUTER_API_KEY:
log.warning("ai_log.skipped_no_key")
if not llm_configured():
log.warning("ai_log.skipped_no_key", provider=s.LLM_PROVIDER)
jr.status = "skipped"
return
@ -153,47 +155,71 @@ async def run() -> None:
previous_log=previous_log,
)
system_prompt = build_system_prompt(s.CASSANDRA_TONE, s.CASSANDRA_ANALYSIS)
try:
async with httpx.AsyncClient(follow_redirects=True) as client:
result = await call_openrouter(
client,
[{"role": "system", "content": system_prompt},
{"role": "user", "content": user_prompt}],
model=s.OPENROUTER_MODEL,
)
except Exception as e:
session.add(AICall(
model=s.OPENROUTER_MODEL, status="error", error=str(e)[:500],
))
await session.commit()
raise
# Phase 2 voice pivot (PROMPT_VERSION 6): generate both tones per
# run so the dashboard toggle is instant. Analysis stays on the
# operator-configured default (DRY|SPECULATIVE is a system-wide
# preference, not a per-user toggle). PRO was dropped.
analysis = (s.CASSANDRA_ANALYSIS or "SPECULATIVE").upper()
variants = [
("NOVICE", analysis),
("INTERMEDIATE", analysis),
]
written = 0
async with httpx.AsyncClient(follow_redirects=True) as client:
for tone, analysis in variants:
# Re-check cost cap between variants so a runaway run is
# bounded.
spent = await _month_spend(session)
if spent >= s.OPENROUTER_MONTHLY_CAP_USD:
log.warning("ai_log.cap_reached_midrun",
spent=spent, completed=written)
break
session.add(StrategicLog(
generated_at=utcnow(),
model=result.model,
anchor_date=anchor,
prompt_version=PROMPT_VERSION,
tone=s.CASSANDRA_TONE.upper(),
analysis=s.CASSANDRA_ANALYSIS.upper(),
content=result.content,
prompt_tokens=result.prompt_tokens,
completion_tokens=result.completion_tokens,
cost_usd=result.cost_usd,
))
session.add(AICall(
model=result.model,
prompt_tokens=result.prompt_tokens,
completion_tokens=result.completion_tokens,
cost_usd=result.cost_usd,
status="ok",
))
await session.commit()
jr.items_written = 1
log.info("ai_log.done",
model=result.model,
prompt_tokens=result.prompt_tokens,
completion_tokens=result.completion_tokens)
system_prompt = build_system_prompt(tone, analysis)
try:
result = await call_llm(
client,
[{"role": "system", "content": system_prompt},
{"role": "user", "content": user_prompt}],
)
except Exception as e:
session.add(AICall(
model=active_model(), status="error",
error=f"{tone}/{analysis}: {str(e)[:480]}",
))
await session.commit()
log.error("ai_log.variant_failed",
tone=tone, analysis=analysis, error=str(e)[:200])
continue
session.add(StrategicLog(
generated_at=utcnow(),
model=result.model,
anchor_date=anchor,
prompt_version=PROMPT_VERSION,
tone=tone,
analysis=analysis,
content=result.content,
prompt_tokens=result.prompt_tokens,
completion_tokens=result.completion_tokens,
cost_usd=result.cost_usd,
))
session.add(AICall(
model=result.model,
prompt_tokens=result.prompt_tokens,
completion_tokens=result.completion_tokens,
cost_usd=result.cost_usd,
status="ok",
))
await session.commit()
written += 1
log.info("ai_log.variant_done",
tone=tone, analysis=analysis,
prompt_tokens=result.prompt_tokens,
completion_tokens=result.completion_tokens)
jr.items_written = written
log.info("ai_log.done", variants=written, total=len(variants))
if __name__ == "__main__":