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
|
|
@ -20,7 +20,13 @@ OPENROUTER_URL = "https://openrouter.ai/api/v1/chat/completions"
|
|||
# Bump when the composed prompt changes meaningfully. Stored on every
|
||||
# StrategicLog row so historical logs can be linked to the prompt that produced
|
||||
# them.
|
||||
PROMPT_VERSION = 5
|
||||
#
|
||||
# v6 (2026-05-17): TONE shrinks to NOVICE | INTERMEDIATE (PRO dropped). New
|
||||
# educational stance baked into _CORE — explicit anti-TA, anti-gambling-mindset
|
||||
# framing aimed at young investors entering the trading world. NOVICE retuned
|
||||
# to be pedagogical (defining terms, anti-pattern teach-backs); INTERMEDIATE
|
||||
# kept terse but with light-touch educational nudges. See tasks/todo.md.
|
||||
PROMPT_VERSION = 6
|
||||
|
||||
|
||||
# --- Core: invariant across tone/analysis settings ----------------------------
|
||||
|
|
@ -92,6 +98,23 @@ predicted X and X did not happen". Both are useful; conflating them is not.
|
|||
- No buy/sell recommendations. Triggers are pre-set elsewhere; your job is \
|
||||
to report whether reality is confirming, modifying, or refuting the thesis.
|
||||
|
||||
# Stance (educational, anti-TA, anti-gambling)
|
||||
The target reader is most likely young, new to investing, and at risk of \
|
||||
treating markets like a horse race they need to "read" via chart patterns. \
|
||||
Cassandra is the corrective.
|
||||
- **No technical analysis.** Head-and-shoulders, RSI thresholds, Fibonacci \
|
||||
levels, Elliott waves, "support/resistance" — these are descriptions of past \
|
||||
crowd behaviour, not predictions. Don't use them; don't legitimise them. If \
|
||||
you mention a price level, frame it as a positioning fact (e.g. "the level \
|
||||
where the latest tranche of buyers entered"), not a signal.
|
||||
- **No gambling framing.** Markets are not a coin flip and not a horse race. \
|
||||
Never present a position as a single decisive moment, a "now or never", or a \
|
||||
bet to be won. Every read should follow the shape: *regime → implication → \
|
||||
what would change the regime*.
|
||||
- **Macro causality, every time.** Price moves get explained through \
|
||||
fundamentals, geopolitics, monetary policy, and structural shifts — not \
|
||||
chart shapes. Even short paragraphs need the cause, not just the effect.
|
||||
|
||||
# System temperature (closing line, mandatory)
|
||||
Close the log with a single sentence on a line of its own, formatted exactly:
|
||||
|
||||
|
|
@ -121,25 +144,92 @@ read shifts to Z")."""
|
|||
# --- Tone: audience-shaping block --------------------------------------------
|
||||
|
||||
_TONE: dict[str, str] = {
|
||||
"NOVICE": """# Audience: novice
|
||||
The reader is new to markets. Define jargon the first time it appears (a \
|
||||
short clause in parentheses is fine). Avoid ticker shorthand without context. \
|
||||
Prefer everyday phrasing: "the price of US government debt fell, pushing \
|
||||
yields higher" rather than "the long end backed up". Keep paragraphs short. \
|
||||
Target ~600 words instead of ~800 so density stays digestible.""",
|
||||
"NOVICE": """# Audience: novice — likely a young investor new to markets
|
||||
This reader probably arrived from social media, treats charts as predictions, \
|
||||
and is one bad week away from quitting. Your job is to **educate them out of \
|
||||
the gambling mindset** without ever being preachy. Calm, patient, slightly \
|
||||
teacherly. Never condescending.
|
||||
|
||||
"INTERMEDIATE": """# Audience: intermediate
|
||||
- **Define jargon the first time it appears.** A short clause in parentheses \
|
||||
is fine: "yield curve (the chart of borrowing costs across different \
|
||||
maturities)", "ERP (equity risk premium — the extra return investors demand \
|
||||
for owning stocks instead of safe bonds)", "basis point (one hundredth of a \
|
||||
percent — 25bp = 0.25%)".
|
||||
- **Avoid ticker shorthand without context.** Use "Apple (AAPL)" on first \
|
||||
mention, then "Apple" or the ticker after.
|
||||
- **Everyday phrasing over jargon** where the meaning survives: "the price \
|
||||
of US government debt fell, pushing yields up" rather than "the long end \
|
||||
backed up"; "investors are paying more for the same earnings" rather than \
|
||||
"multiple expansion".
|
||||
- **One analogy per concept, used sparingly.** Use them to bridge to \
|
||||
something concrete the reader already understands — not to entertain.
|
||||
|
||||
# Educational teach-backs (NOVICE-specific, when warranted)
|
||||
When the day's data makes a common misconception concrete, drop in ONE \
|
||||
teach-back of one to two sentences. Don't force it. Don't moralise. Examples \
|
||||
of moments to do this:
|
||||
|
||||
- Anyone treating chart patterns as predictions: \
|
||||
"Patterns like head-and-shoulders describe what crowds did, not what they \
|
||||
will do — they're stories told after the fact, not edges."
|
||||
- Anyone fixated on day-to-day moves: \
|
||||
"A 1% one-day move in a stock is roughly what you'd expect by chance. The \
|
||||
multi-week trend is where the information lives."
|
||||
- Anyone treating one ticker as a coin flip: \
|
||||
"A single name's monthly move is mostly noise. The regime — what bonds, the \
|
||||
dollar, and credit are doing together — tells you whether ANY stock is \
|
||||
likely to drift up or down."
|
||||
- Anyone trying to "time the bottom" or "buy the dip": \
|
||||
"Catching the bottom is a different game from owning the next cycle. The \
|
||||
first needs you to be right within days; the second needs you to be roughly \
|
||||
right within years."
|
||||
|
||||
Limit yourself to one teach-back per log. Skip them entirely if the day's \
|
||||
data doesn't naturally invite one.
|
||||
|
||||
# Length
|
||||
Target ~700 words. Slightly more than INTERMEDIATE because explanations \
|
||||
need breathing room.""",
|
||||
|
||||
"INTERMEDIATE": """# Audience: intermediate — reads the news, learning to \
|
||||
connect macro to markets
|
||||
Assume the reader knows market basics (yield curves, breakevens, HY OAS, \
|
||||
sector ETFs). Use common terms without defining them, but stay clear of \
|
||||
deep institutional shorthand ("the belly", "duration trade", "carry pickup"). \
|
||||
Target ~700 words — lean and clear, no padding.""",
|
||||
sector ETFs, the difference between cyclical and defensive, what a basis \
|
||||
point is). Use common terms without defining them, but stay clear of deep \
|
||||
institutional shorthand ("the belly", "duration trade", "carry pickup", \
|
||||
"the RV book", "off-the-run").
|
||||
|
||||
"PRO": """# Audience: professional
|
||||
Assume institutional vocabulary. Use dense market shorthand freely. Don't \
|
||||
define standard terms. Target ~800 words. Density of insight > readability.""",
|
||||
Light-touch educational nudges are welcome when the day's data warrants — \
|
||||
e.g. "with rates this volatile, technical levels in equities are mostly \
|
||||
distraction" — but keep them to a passing clause, not a paragraph. Don't \
|
||||
moralise.
|
||||
|
||||
# Length
|
||||
Target ~600 words. Lean and clear, no padding.""",
|
||||
}
|
||||
|
||||
|
||||
# Legacy values map to the closest current value. Logs a warning so we can
|
||||
# notice if some caller's config didn't get updated.
|
||||
_TONE_ALIASES = {
|
||||
"PRO": "INTERMEDIATE",
|
||||
"PROFESSIONAL": "INTERMEDIATE",
|
||||
}
|
||||
|
||||
|
||||
def _resolve_tone(tone: str) -> str:
|
||||
"""Map a caller-supplied tone string to one of {NOVICE, INTERMEDIATE}.
|
||||
|
||||
Unknown tones fall back to INTERMEDIATE. The legacy PRO value is mapped
|
||||
to INTERMEDIATE (audience pivot, see PROMPT_VERSION v6 notes)."""
|
||||
upper = (tone or "").upper().strip()
|
||||
if upper in _TONE:
|
||||
return upper
|
||||
if upper in _TONE_ALIASES:
|
||||
return _TONE_ALIASES[upper]
|
||||
return "INTERMEDIATE"
|
||||
|
||||
|
||||
# --- Analysis: forward-vs-backward focus -------------------------------------
|
||||
|
||||
_ANALYSIS: dict[str, str] = {
|
||||
|
|
@ -161,7 +251,7 @@ the trip-wires that decide between scenarios.""",
|
|||
|
||||
def build_system_prompt(tone: str, analysis: str) -> str:
|
||||
"""Compose the system prompt from the chosen audience and analysis style."""
|
||||
tone_block = _TONE.get(tone.upper(), _TONE["INTERMEDIATE"])
|
||||
tone_block = _TONE[_resolve_tone(tone)]
|
||||
analysis_block = _ANALYSIS.get(analysis.upper(), _ANALYSIS["SPECULATIVE"])
|
||||
return "\n\n".join([_CORE, tone_block, analysis_block])
|
||||
|
||||
|
|
@ -192,7 +282,7 @@ def build_summary_system_prompt(tone: str, analysis: str) -> str:
|
|||
"""A lean, focused system prompt for the per-indicator-group hourly
|
||||
summary. INTERPRETATION not description — the reader has the table
|
||||
next to this paragraph; they don't need numbers recited at them."""
|
||||
tone_block = _TONE.get(tone.upper(), _TONE["INTERMEDIATE"])
|
||||
tone_block = _TONE[_resolve_tone(tone)]
|
||||
analysis_block = _ANALYSIS.get(analysis.upper(), _ANALYSIS["SPECULATIVE"])
|
||||
return f"""You write a TINY interpretation (≤60 words, 2-3 sentences) \
|
||||
of ONE indicator group for a strategic markets dashboard.
|
||||
|
|
@ -239,7 +329,7 @@ def build_summary_user_prompt(group_name: str, quotes: list[dict]) -> str:
|
|||
def build_aggregate_summary_system_prompt(tone: str, analysis: str) -> str:
|
||||
"""System prompt for the cross-group aggregate read shown on the dashboard.
|
||||
Wider lens than a per-group summary — synthesise across all groups."""
|
||||
tone_block = _TONE.get(tone.upper(), _TONE["INTERMEDIATE"])
|
||||
tone_block = _TONE[_resolve_tone(tone)]
|
||||
analysis_block = _ANALYSIS.get(analysis.upper(), _ANALYSIS["SPECULATIVE"])
|
||||
return f"""You write a single SHORT cross-asset INTERPRETATION (≤80 \
|
||||
words, 2-4 sentences) for the dashboard header. The reader is glancing — \
|
||||
|
|
@ -381,30 +471,95 @@ def build_user_prompt(
|
|||
return "\n".join(parts)
|
||||
|
||||
|
||||
def _provider_chain() -> list[str]:
|
||||
"""Ordered list of providers to try: primary, then fallback (unless
|
||||
the fallback is unset, the same as primary, or has no API key)."""
|
||||
s = get_settings()
|
||||
primary = (s.LLM_PROVIDER or "deepseek").lower()
|
||||
fallback = (s.LLM_FALLBACK or "").lower()
|
||||
chain = [primary]
|
||||
if fallback and fallback != primary:
|
||||
chain.append(fallback)
|
||||
# Drop providers with no API key configured.
|
||||
return [p for p in chain if _provider_has_key(p)]
|
||||
|
||||
|
||||
def _provider_has_key(provider: str) -> bool:
|
||||
s = get_settings()
|
||||
if provider == "deepseek":
|
||||
return bool(s.DEEPSEEK_API_KEY)
|
||||
if provider == "openrouter":
|
||||
return bool(s.OPENROUTER_API_KEY)
|
||||
return False
|
||||
|
||||
|
||||
def _endpoint_for(provider: str) -> tuple[str, str, str, dict[str, str]]:
|
||||
"""Resolve (url, api_key, default_model, extra_headers) for a specific
|
||||
provider. Raises if its API key isn't set."""
|
||||
s = get_settings()
|
||||
if provider == "deepseek":
|
||||
if not s.DEEPSEEK_API_KEY:
|
||||
raise RuntimeError("DEEPSEEK_API_KEY not set")
|
||||
return s.DEEPSEEK_URL, s.DEEPSEEK_API_KEY, s.DEEPSEEK_MODEL, {}
|
||||
if provider == "openrouter":
|
||||
if not s.OPENROUTER_API_KEY:
|
||||
raise RuntimeError("OPENROUTER_API_KEY not set")
|
||||
return (
|
||||
OPENROUTER_URL,
|
||||
s.OPENROUTER_API_KEY,
|
||||
s.OPENROUTER_MODEL,
|
||||
{
|
||||
# OpenRouter-specific attribution headers.
|
||||
"HTTP-Referer": "https://github.com/local/cassandra",
|
||||
"X-Title": "Cassandra",
|
||||
},
|
||||
)
|
||||
raise RuntimeError(f"Unknown LLM provider: {provider!r}")
|
||||
|
||||
|
||||
def llm_configured() -> bool:
|
||||
"""At least one provider in the configured chain has an API key."""
|
||||
return bool(_provider_chain())
|
||||
|
||||
|
||||
def active_model() -> str:
|
||||
"""Return the model name of the *first* provider in the configured
|
||||
chain (the one that would be tried first). Used to label AICall ledger
|
||||
rows when no actual call result is available yet."""
|
||||
chain = _provider_chain()
|
||||
if not chain:
|
||||
return "unknown"
|
||||
s = get_settings()
|
||||
return s.DEEPSEEK_MODEL if chain[0] == "deepseek" else s.OPENROUTER_MODEL
|
||||
|
||||
|
||||
@retry(
|
||||
reraise=True,
|
||||
stop=stop_after_attempt(3),
|
||||
wait=wait_exponential(multiplier=2, min=2, max=30),
|
||||
retry=retry_if_exception_type((httpx.HTTPStatusError, httpx.TransportError)),
|
||||
)
|
||||
async def call_openrouter(
|
||||
async def _call_provider(
|
||||
client: httpx.AsyncClient,
|
||||
provider: str,
|
||||
messages: list[dict],
|
||||
model: str,
|
||||
max_tokens: int = 4000,
|
||||
model: str | None,
|
||||
max_tokens: int,
|
||||
) -> LogResult:
|
||||
s = get_settings()
|
||||
if not s.OPENROUTER_API_KEY:
|
||||
raise RuntimeError("OPENROUTER_API_KEY not set")
|
||||
"""One provider call with tenacity retries on transport/HTTP errors.
|
||||
Lives inside the retry decorator so retries happen within a provider,
|
||||
not across the fallback chain."""
|
||||
url, api_key, default_model, extra_headers = _endpoint_for(provider)
|
||||
used_model = model or default_model
|
||||
headers = {
|
||||
"Authorization": f"Bearer {api_key}",
|
||||
"Content-Type": "application/json",
|
||||
**extra_headers,
|
||||
}
|
||||
r = await client.post(
|
||||
OPENROUTER_URL,
|
||||
headers={
|
||||
"Authorization": f"Bearer {s.OPENROUTER_API_KEY}",
|
||||
"Content-Type": "application/json",
|
||||
"HTTP-Referer": "https://github.com/local/cassandra",
|
||||
"X-Title": "Cassandra",
|
||||
},
|
||||
json={"model": model, "messages": messages, "max_tokens": max_tokens},
|
||||
url,
|
||||
headers=headers,
|
||||
json={"model": used_model, "messages": messages, "max_tokens": max_tokens},
|
||||
timeout=180,
|
||||
)
|
||||
r.raise_for_status()
|
||||
|
|
@ -416,19 +571,68 @@ async def call_openrouter(
|
|||
if not content:
|
||||
finish = data["choices"][0].get("finish_reason")
|
||||
raise RuntimeError(
|
||||
f"OpenRouter returned empty content (finish_reason={finish}, "
|
||||
f"model={model}, max_tokens={max_tokens})"
|
||||
f"LLM returned empty content (finish_reason={finish}, "
|
||||
f"provider={provider}, model={used_model}, max_tokens={max_tokens})"
|
||||
)
|
||||
usage = data.get("usage") or {}
|
||||
return LogResult(
|
||||
content=content,
|
||||
model=model,
|
||||
# Record provider+model so admin can see which path produced this row.
|
||||
model=f"{provider}/{used_model}",
|
||||
prompt_tokens=usage.get("prompt_tokens"),
|
||||
completion_tokens=usage.get("completion_tokens"),
|
||||
cost_usd=usage.get("cost") or usage.get("total_cost"),
|
||||
)
|
||||
|
||||
|
||||
async def call_llm(
|
||||
client: httpx.AsyncClient,
|
||||
messages: list[dict],
|
||||
model: str | None = None,
|
||||
max_tokens: int = 4000,
|
||||
) -> LogResult:
|
||||
"""Provider-aware chat completion with fallback. Tries primary
|
||||
(LLM_PROVIDER) first; if it raises after retries, falls through to
|
||||
LLM_FALLBACK. Raises only if every provider in the chain fails.
|
||||
|
||||
The returned LogResult.model is prefixed with the provider that
|
||||
actually answered (e.g. ``deepseek/deepseek-v4-flash`` or
|
||||
``openrouter/deepseek/deepseek-v4-flash``) — useful admin metadata
|
||||
even though we hide it from the user-facing UI."""
|
||||
chain = _provider_chain()
|
||||
if not chain:
|
||||
raise RuntimeError("No LLM provider configured (no API key set)")
|
||||
|
||||
last_exc: Exception | None = None
|
||||
for i, provider in enumerate(chain):
|
||||
try:
|
||||
result = await _call_provider(
|
||||
client, provider, messages, model, max_tokens,
|
||||
)
|
||||
if i > 0:
|
||||
from app.logging import get_logger
|
||||
get_logger("llm").info(
|
||||
"llm.fallback_succeeded", provider=provider, attempt=i + 1,
|
||||
)
|
||||
return result
|
||||
except Exception as e:
|
||||
last_exc = e
|
||||
if i + 1 < len(chain):
|
||||
from app.logging import get_logger
|
||||
get_logger("llm").warning(
|
||||
"llm.primary_failed_trying_fallback",
|
||||
provider=provider, error=str(e)[:200],
|
||||
)
|
||||
continue
|
||||
# Re-raise the last exception so callers see the failure mode.
|
||||
assert last_exc is not None
|
||||
raise last_exc
|
||||
|
||||
|
||||
# Back-compat alias for any straggling import sites.
|
||||
call_openrouter = call_llm
|
||||
|
||||
|
||||
def month_window() -> tuple[datetime, datetime]:
|
||||
"""[start, now] in UTC for the current calendar month."""
|
||||
now = datetime.now(timezone.utc)
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue