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
|
|
@ -17,11 +17,13 @@ from app.models import AICall, IndicatorSummary, JobRun, Quote
|
|||
from app.services.cadence import DEFAULT_POLICY
|
||||
from app.services.openrouter import (
|
||||
PROMPT_VERSION,
|
||||
active_model,
|
||||
build_aggregate_summary_system_prompt,
|
||||
build_aggregate_summary_user_prompt,
|
||||
build_summary_system_prompt,
|
||||
build_summary_user_prompt,
|
||||
call_openrouter,
|
||||
call_llm,
|
||||
llm_configured,
|
||||
month_start,
|
||||
)
|
||||
|
||||
|
|
@ -173,18 +175,19 @@ async def _generate_one(
|
|||
session, client: httpx.AsyncClient, group: str, quotes: list[dict],
|
||||
system_prompt: str, model: str, tone: str, analysis: str,
|
||||
) -> bool:
|
||||
"""Generate + persist one group's summary. Returns True on success."""
|
||||
"""Generate + persist one group's summary. Returns True on success.
|
||||
`model` is retained for ledger labelling but call_llm now picks the
|
||||
active-provider model itself."""
|
||||
user_prompt = build_summary_user_prompt(group, quotes)
|
||||
try:
|
||||
result = await call_openrouter(
|
||||
result = await call_llm(
|
||||
client,
|
||||
[{"role": "system", "content": system_prompt},
|
||||
{"role": "user", "content": user_prompt}],
|
||||
model=model,
|
||||
max_tokens=800, # DeepSeek sometimes spends 300+ on internal reasoning
|
||||
)
|
||||
except Exception as e:
|
||||
session.add(AICall(model=model, status="error", error=str(e)[:500]))
|
||||
session.add(AICall(model=active_model(), status="error", error=str(e)[:500]))
|
||||
log.warning("ind_summary.failed", group=group, error=str(e)[:120])
|
||||
return False
|
||||
|
||||
|
|
@ -231,7 +234,8 @@ async def run() -> None:
|
|||
if jr.status == "skipped":
|
||||
return
|
||||
s = get_settings()
|
||||
if not s.OPENROUTER_API_KEY:
|
||||
if not llm_configured():
|
||||
log.warning("ind_summary.skipped_no_key", provider=s.LLM_PROVIDER)
|
||||
jr.status = "skipped"
|
||||
return
|
||||
|
||||
|
|
@ -266,62 +270,68 @@ async def run() -> None:
|
|||
jr.status = "skipped"
|
||||
return
|
||||
|
||||
tone = s.CASSANDRA_TONE.upper()
|
||||
analysis = s.CASSANDRA_ANALYSIS.upper()
|
||||
system_prompt = build_summary_system_prompt(tone, analysis)
|
||||
# Phase 2 voice pivot (PROMPT_VERSION 6): generate both tones each
|
||||
# run so the dashboard toggle is instant. ANALYSIS stays on the
|
||||
# operator-configured default.
|
||||
analysis = (s.CASSANDRA_ANALYSIS or "SPECULATIVE").upper()
|
||||
tones = ("NOVICE", "INTERMEDIATE")
|
||||
|
||||
written = 0
|
||||
async with httpx.AsyncClient(follow_redirects=True) as client:
|
||||
# Sequential rather than parallel — OpenRouter free tiers can
|
||||
# throttle bursts; total work is small (~12 calls × ~5s each).
|
||||
for group, quotes in groups.items():
|
||||
ok = await _generate_one(
|
||||
session, client, group, quotes,
|
||||
system_prompt, s.OPENROUTER_MODEL, tone, analysis,
|
||||
)
|
||||
if ok:
|
||||
written += 1
|
||||
await session.commit() # partial progress survives mid-job error
|
||||
# throttle bursts; total work is small (~14-16 calls × ~5s each).
|
||||
for tone in tones:
|
||||
system_prompt = build_summary_system_prompt(tone, analysis)
|
||||
for group, quotes in groups.items():
|
||||
ok = await _generate_one(
|
||||
session, client, group, quotes,
|
||||
system_prompt, active_model(), tone, analysis,
|
||||
)
|
||||
if ok:
|
||||
written += 1
|
||||
await session.commit() # partial progress survives mid-job error
|
||||
|
||||
# One aggregate read across all groups, stored under __all__.
|
||||
agg_system = build_aggregate_summary_system_prompt(tone, analysis)
|
||||
agg_user = build_aggregate_summary_user_prompt(groups)
|
||||
try:
|
||||
result = await call_openrouter(
|
||||
client,
|
||||
[{"role": "system", "content": agg_system},
|
||||
{"role": "user", "content": agg_user}],
|
||||
model=s.OPENROUTER_MODEL,
|
||||
max_tokens=1500, # room for reasoning + 80-word output
|
||||
)
|
||||
session.add(IndicatorSummary(
|
||||
group_name=AGGREGATE_GROUP_NAME,
|
||||
generated_at=utcnow(),
|
||||
model=result.model,
|
||||
tone=tone,
|
||||
analysis=analysis,
|
||||
prompt_version=PROMPT_VERSION,
|
||||
content=clean_summary(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",
|
||||
))
|
||||
written += 1
|
||||
except Exception as e:
|
||||
session.add(AICall(
|
||||
model=s.OPENROUTER_MODEL, status="error", error=str(e)[:500],
|
||||
))
|
||||
log.warning("ind_summary.agg_failed", error=str(e)[:120])
|
||||
await session.commit()
|
||||
# One aggregate read across all groups, stored under __all__.
|
||||
agg_system = build_aggregate_summary_system_prompt(tone, analysis)
|
||||
agg_user = build_aggregate_summary_user_prompt(groups)
|
||||
try:
|
||||
result = await call_llm(
|
||||
client,
|
||||
[{"role": "system", "content": agg_system},
|
||||
{"role": "user", "content": agg_user}],
|
||||
max_tokens=1500, # room for reasoning + 80-word output
|
||||
)
|
||||
session.add(IndicatorSummary(
|
||||
group_name=AGGREGATE_GROUP_NAME,
|
||||
generated_at=utcnow(),
|
||||
model=result.model,
|
||||
tone=tone,
|
||||
analysis=analysis,
|
||||
prompt_version=PROMPT_VERSION,
|
||||
content=clean_summary(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",
|
||||
))
|
||||
written += 1
|
||||
except Exception as e:
|
||||
session.add(AICall(
|
||||
model=active_model(), status="error",
|
||||
error=f"{tone}/agg: {str(e)[:480]}",
|
||||
))
|
||||
log.warning("ind_summary.agg_failed",
|
||||
tone=tone, error=str(e)[:120])
|
||||
await session.commit()
|
||||
|
||||
jr.items_written = written
|
||||
log.info("ind_summary.done", groups=len(groups), written=written)
|
||||
log.info("ind_summary.done",
|
||||
groups=len(groups), tones=len(tones), written=written)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue