Compare commits

..

No commits in common. "f9534f7ad697d8330aa1593fba1faf5baec1b8c2" and "11662c0ea8ba0e20947b016c8d7c2f6d2b8251a8" have entirely different histories.

88 changed files with 4745 additions and 10352 deletions

1
.gitignore vendored
View file

@ -16,4 +16,3 @@ build/
dist/
.coverage
.mypy_cache/
.superpowers/

View file

@ -6,17 +6,11 @@ ENV PIP_DISABLE_PIP_VERSION_CHECK=1 \
PYTHONDONTWRITEBYTECODE=1
WORKDIR /build
COPY pyproject.toml requirements.lock ./
COPY pyproject.toml ./
COPY app ./app
# requirements.lock pins every transitive dependency to the known-good
# versions captured by `pip freeze` against a clean install. Install
# from it first, then add the project itself with --no-deps so the
# lockfile is the single source of truth and pyproject's range pins
# (>=) can't drift on rebuild.
RUN python -m venv /opt/venv \
&& /opt/venv/bin/pip install --upgrade pip \
&& /opt/venv/bin/pip install -r requirements.lock \
&& /opt/venv/bin/pip install --no-deps .
&& /opt/venv/bin/pip install .
FROM python:3.13-slim AS runtime
@ -55,7 +49,7 @@ ENV PYTHONUNBUFFERED=1 \
COPY --from=builder /opt/venv /opt/venv
WORKDIR /app
COPY pyproject.toml requirements.lock ./
COPY pyproject.toml ./
COPY app ./app
COPY alembic ./alembic
COPY alembic.ini ./
@ -63,10 +57,6 @@ COPY alembic.ini ./
# a shipped image). docker-compose.test.yml bind-mounts ./tests:/app/tests
# at run time, so the suite is always available without baking it in.
# The lockfile already contains the dev extras (pytest, ruff, aiosqlite,
# ...) because it was generated against a test-stage install. Same
# install pattern as the builder stage: lockfile first, project --no-deps.
RUN /opt/venv/bin/pip install -r requirements.lock \
&& /opt/venv/bin/pip install --no-deps .
RUN /opt/venv/bin/pip install ".[dev]"
CMD ["pytest", "tests/", "-v"]

View file

@ -44,17 +44,10 @@ def run_migrations_offline() -> None:
def do_run_migrations(connection: Connection) -> None:
# render_as_batch is required for SQLite, which doesn't support
# most ALTER COLUMN / ADD CONSTRAINT operations natively. With
# batch mode enabled, Alembic emits a copy-and-rename dance under
# SQLite while still producing plain ALTER on MariaDB / Postgres,
# so prod migrations are unchanged. Detect via the dialect name.
render_as_batch = connection.dialect.name == "sqlite"
context.configure(
connection=connection,
target_metadata=target_metadata,
compare_type=True,
render_as_batch=render_as_batch,
)
with context.begin_transaction():
context.run_migrations()

View file

@ -17,14 +17,8 @@ depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
# batch_alter_table wraps the ALTER in a copy-and-rename dance for
# SQLite (which doesn't support ALTER COLUMN TYPE) while remaining a
# plain ALTER on MariaDB / Postgres. Required for `alembic upgrade
# head` to work against a fresh SQLite database during local tooling
# or test bootstrap.
with op.batch_alter_table("quotes") as bop:
bop.alter_column(
"symbol",
op.alter_column(
"quotes", "symbol",
existing_type=sa.String(64),
type_=sa.String(128),
existing_nullable=False,
@ -32,9 +26,8 @@ def upgrade() -> None:
def downgrade() -> None:
with op.batch_alter_table("quotes") as bop:
bop.alter_column(
"symbol",
op.alter_column(
"quotes", "symbol",
existing_type=sa.String(128),
type_=sa.String(64),
existing_nullable=False,

View file

@ -30,18 +30,20 @@ depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
# batch_alter_table wraps ADD CONSTRAINT in a copy-and-rename for
# SQLite (no native ALTER constraints support); on MariaDB/Postgres
# it falls through to plain ALTER statements.
with op.batch_alter_table("users") as bop:
bop.add_column(sa.Column("referral_code", sa.String(16), nullable=True))
bop.create_unique_constraint(
"uq_users_referral_code", ["referral_code"],
)
bop.add_column(sa.Column("referred_by_user_id", sa.Integer, nullable=True))
bop.create_foreign_key(
"fk_users_referred_by",
op.add_column(
"users",
sa.Column("referral_code", sa.String(16), nullable=True),
)
op.create_unique_constraint(
"uq_users_referral_code", "users", ["referral_code"],
)
op.add_column(
"users",
sa.Column("referred_by_user_id", sa.Integer, nullable=True),
)
op.create_foreign_key(
"fk_users_referred_by",
"users", "users",
["referred_by_user_id"], ["id"],
ondelete="SET NULL",
)
@ -69,8 +71,7 @@ def upgrade() -> None:
def downgrade() -> None:
op.drop_index("ix_referrals_referrer", table_name="referrals")
op.drop_table("referrals")
with op.batch_alter_table("users") as bop:
bop.drop_constraint("fk_users_referred_by", type_="foreignkey")
bop.drop_column("referred_by_user_id")
bop.drop_constraint("uq_users_referral_code", type_="unique")
bop.drop_column("referral_code")
op.drop_constraint("fk_users_referred_by", "users", type_="foreignkey")
op.drop_column("users", "referred_by_user_id")
op.drop_constraint("uq_users_referral_code", "users", type_="unique")
op.drop_column("users", "referral_code")

View file

@ -17,11 +17,16 @@ depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
with op.batch_alter_table("users") as bop:
bop.add_column(sa.Column("polar_customer_id", sa.String(length=64), nullable=True))
bop.add_column(sa.Column("polar_subscription_id", sa.String(length=64), nullable=True))
bop.create_unique_constraint(
"uq_users_polar_customer", ["polar_customer_id"],
op.add_column(
"users",
sa.Column("polar_customer_id", sa.String(length=64), nullable=True),
)
op.add_column(
"users",
sa.Column("polar_subscription_id", sa.String(length=64), nullable=True),
)
op.create_unique_constraint(
"uq_users_polar_customer", "users", ["polar_customer_id"],
)
op.create_table(
@ -45,7 +50,6 @@ def upgrade() -> None:
def downgrade() -> None:
op.drop_index("ix_polar_events_type_received", table_name="polar_events")
op.drop_table("polar_events")
with op.batch_alter_table("users") as bop:
bop.drop_constraint("uq_users_polar_customer", type_="unique")
bop.drop_column("polar_subscription_id")
op.drop_constraint("uq_users_polar_customer", "users", type_="unique")
op.drop_column("users", "polar_subscription_id")
op.drop_column("users", "polar_customer_id")

View file

@ -18,11 +18,16 @@ depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
with op.batch_alter_table("users") as bop:
bop.add_column(sa.Column("stripe_customer_id", sa.String(length=64), nullable=True))
bop.add_column(sa.Column("stripe_subscription_id", sa.String(length=64), nullable=True))
bop.create_unique_constraint(
"uq_users_stripe_customer", ["stripe_customer_id"],
op.add_column(
"users",
sa.Column("stripe_customer_id", sa.String(length=64), nullable=True),
)
op.add_column(
"users",
sa.Column("stripe_subscription_id", sa.String(length=64), nullable=True),
)
op.create_unique_constraint(
"uq_users_stripe_customer", "users", ["stripe_customer_id"],
)
op.create_table(
@ -46,7 +51,6 @@ def upgrade() -> None:
def downgrade() -> None:
op.drop_index("ix_stripe_events_type_received", table_name="stripe_events")
op.drop_table("stripe_events")
with op.batch_alter_table("users") as bop:
bop.drop_constraint("uq_users_stripe_customer", type_="unique")
bop.drop_column("stripe_subscription_id")
bop.drop_column("stripe_customer_id")
op.drop_constraint("uq_users_stripe_customer", "users", type_="unique")
op.drop_column("users", "stripe_subscription_id")
op.drop_column("users", "stripe_customer_id")

View file

@ -1,46 +0,0 @@
"""localization: users.lang + strategic_log_translations.
Revision ID: 0022
Revises: 0021
Create Date: 2026-05-27
"""
from typing import Sequence, Union
import sqlalchemy as sa
from alembic import op
revision: str = "0022"
down_revision: Union[str, None] = "0021"
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
op.add_column(
"users",
sa.Column(
"lang", sa.String(length=8), nullable=False,
server_default="en",
),
)
op.create_table(
"strategic_log_translations",
sa.Column("id", sa.BigInteger(), primary_key=True, autoincrement=True),
sa.Column("log_id", sa.BigInteger(), nullable=False),
sa.Column("lang", sa.String(length=8), nullable=False),
sa.Column("content_md", sa.Text(), nullable=False),
sa.Column("generated_at", sa.DateTime(timezone=True), nullable=False),
sa.Column("llm_model", sa.String(length=64), nullable=True),
sa.Column("llm_cost_usd", sa.Float(), nullable=True),
sa.ForeignKeyConstraint(
["log_id"], ["strategic_logs.id"],
ondelete="CASCADE", name="fk_slt_log",
),
sa.UniqueConstraint("log_id", "lang", name="uq_slt_log_lang"),
)
def downgrade() -> None:
op.drop_table("strategic_log_translations")
op.drop_column("users", "lang")

View file

@ -1,38 +0,0 @@
"""users.lang index + widen quotes_daily.symbol to VARCHAR(128).
Revision ID: 0023
Revises: 0022
Create Date: 2026-05-27
"""
from typing import Sequence, Union
import sqlalchemy as sa
from alembic import op
revision: str = "0023"
down_revision: Union[str, None] = "0022"
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
op.create_index("ix_users_lang", "users", ["lang"])
with op.batch_alter_table("quotes_daily") as bop:
bop.alter_column(
"symbol",
existing_type=sa.String(length=64),
type_=sa.String(length=128),
existing_nullable=False,
)
def downgrade() -> None:
with op.batch_alter_table("quotes_daily") as bop:
bop.alter_column(
"symbol",
existing_type=sa.String(length=128),
type_=sa.String(length=64),
existing_nullable=False,
)
op.drop_index("ix_users_lang", table_name="users")

View file

@ -1,38 +0,0 @@
"""indicator_summary_translations table.
Revision ID: 0024
Revises: 0023
Create Date: 2026-05-27
"""
from typing import Sequence, Union
import sqlalchemy as sa
from alembic import op
revision: str = "0024"
down_revision: Union[str, None] = "0023"
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
op.create_table(
"indicator_summary_translations",
sa.Column("id", sa.BigInteger(), primary_key=True, autoincrement=True),
sa.Column("summary_id", sa.BigInteger(), nullable=False),
sa.Column("lang", sa.String(length=8), nullable=False),
sa.Column("content_md", sa.Text(), nullable=False),
sa.Column("generated_at", sa.DateTime(timezone=True), nullable=False),
sa.Column("llm_model", sa.String(length=64), nullable=True),
sa.Column("llm_cost_usd", sa.Float(), nullable=True),
sa.ForeignKeyConstraint(
["summary_id"], ["indicator_summaries.id"],
ondelete="CASCADE", name="fk_ist_summary",
),
sa.UniqueConstraint("summary_id", "lang", name="uq_ist_summary_lang"),
)
def downgrade() -> None:
op.drop_table("indicator_summary_translations")

View file

@ -1,79 +0,0 @@
"""align translation column naming + add token counts.
Revision ID: 0025
Revises: 0024
Create Date: 2026-05-27
"""
from typing import Sequence, Union
import sqlalchemy as sa
from alembic import op
revision: str = "0025"
down_revision: Union[str, None] = "0024"
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
# strategic_log_translations
with op.batch_alter_table("strategic_log_translations") as bop:
bop.alter_column("llm_model", new_column_name="model",
existing_type=sa.String(length=64), existing_nullable=True)
bop.alter_column("llm_cost_usd", new_column_name="cost_usd",
existing_type=sa.Float(), existing_nullable=True)
bop.alter_column("content_md", new_column_name="content",
existing_type=sa.Text(), existing_nullable=False)
bop.add_column(sa.Column("prompt_tokens", sa.Integer(), nullable=True))
bop.add_column(sa.Column("completion_tokens", sa.Integer(), nullable=True))
# indicator_summary_translations
with op.batch_alter_table("indicator_summary_translations") as bop:
bop.alter_column("llm_model", new_column_name="model",
existing_type=sa.String(length=64), existing_nullable=True)
bop.alter_column("llm_cost_usd", new_column_name="cost_usd",
existing_type=sa.Float(), existing_nullable=True)
bop.alter_column("content_md", new_column_name="content",
existing_type=sa.Text(), existing_nullable=False)
bop.add_column(sa.Column("prompt_tokens", sa.Integer(), nullable=True))
bop.add_column(sa.Column("completion_tokens", sa.Integer(), nullable=True))
# csv_format_templates
with op.batch_alter_table("csv_format_templates") as bop:
bop.alter_column("llm_model", new_column_name="model",
existing_type=sa.String(length=64), existing_nullable=True)
bop.alter_column("llm_cost_usd", new_column_name="cost_usd",
existing_type=sa.Float(), existing_nullable=True)
bop.add_column(sa.Column("prompt_tokens", sa.Integer(), nullable=True))
bop.add_column(sa.Column("completion_tokens", sa.Integer(), nullable=True))
def downgrade() -> None:
with op.batch_alter_table("csv_format_templates") as bop:
bop.drop_column("completion_tokens")
bop.drop_column("prompt_tokens")
bop.alter_column("cost_usd", new_column_name="llm_cost_usd",
existing_type=sa.Float(), existing_nullable=True)
bop.alter_column("model", new_column_name="llm_model",
existing_type=sa.String(length=64), existing_nullable=True)
with op.batch_alter_table("indicator_summary_translations") as bop:
bop.drop_column("completion_tokens")
bop.drop_column("prompt_tokens")
bop.alter_column("content", new_column_name="content_md",
existing_type=sa.Text(), existing_nullable=False)
bop.alter_column("cost_usd", new_column_name="llm_cost_usd",
existing_type=sa.Float(), existing_nullable=True)
bop.alter_column("model", new_column_name="llm_model",
existing_type=sa.String(length=64), existing_nullable=True)
with op.batch_alter_table("strategic_log_translations") as bop:
bop.drop_column("completion_tokens")
bop.drop_column("prompt_tokens")
bop.alter_column("content", new_column_name="content_md",
existing_type=sa.Text(), existing_nullable=False)
bop.alter_column("cost_usd", new_column_name="llm_cost_usd",
existing_type=sa.Float(), existing_nullable=True)
bop.alter_column("model", new_column_name="llm_model",
existing_type=sa.String(length=64), existing_nullable=True)

View file

@ -7,13 +7,13 @@ into user-visible chrome (page titles, email headers, OpenRouter referer)
must read `BRAND_NAME` from here; do not hard-code the string.
Internal identifiers (`cassandra_session` cookie, pyproject package name,
SQLAlchemy GET_LOCK keys, env var `CASSANDRA_TOKEN`) keep the legacy
name on purpose renaming them would invalidate live sessions /
advisory locks / configs for zero brand benefit.
SQLAlchemy GET_LOCK keys, file `cassandra.css`, env var `CASSANDRA_TOKEN`)
keep the legacy name on purpose renaming them would invalidate live
sessions / advisory locks / configs for zero brand benefit.
The colour palette below is hand-authored in CSS as well; a drift-
detection test (`tests/test_branding_consistency.py`) parses
`tokens.css` and asserts every variable matches. Update both or
`cassandra.css` and asserts every variable matches. Update both or
neither.
The light theme is the *default* everywhere dashboard `:root` block,

View file

@ -42,6 +42,7 @@ class Settings(BaseSettings):
# App
CASSANDRA_TOKEN: str = ""
CASSANDRA_PORT: int = 8000
# Signing key for session cookies. Generate with:
# python -c "import secrets; print(secrets.token_urlsafe(32))"
# Falls back to CASSANDRA_TOKEN if unset (acceptable for single-host dev).
@ -58,7 +59,9 @@ class Settings(BaseSettings):
SMTP_PASSWORD: str = ""
SMTP_USE_TLS: bool = True
SMTP_FROM: str = "" # Defaults to SMTP_USER if blank
CASSANDRA_BASE_CURRENCY: str = "GBP"
CASSANDRA_ANCHOR_DATE: str = ""
CASSANDRA_MOCK: bool = False
# Server-side pepper for the cloud-sync outer wrap. Generate with:
# python -c "import secrets; print(secrets.token_urlsafe(32))"
@ -94,6 +97,7 @@ class Settings(BaseSettings):
# env var. Empty = webhook endpoint refuses with 503 (so a misconfig
# is loud rather than silently accepting unsigned events).
POLAR_WEBHOOK_SECRET: str = ""
POLAR_API_KEY: str = ""
# Stripe (merchant-on-record for read.markets after Polar/Paddle
# both declined the financial-media category). Test-mode keys are

View file

@ -42,7 +42,6 @@ async def latest_quotes_by_group(session) -> dict[str, list[dict]]:
& (Quote.symbol == sub.c.symbol)
& (Quote.fetched_at == sub.c.mx),
)
.where(Quote.price.is_not(None))
.order_by(Quote.group_name, Quote.symbol)
)
rows = (await session.execute(stmt)).scalars().all()

View file

@ -17,91 +17,16 @@ from app.jobs._market_context import (
month_spend,
recent_headlines_by_bucket,
)
from app.models import AICall, JobRun, StrategicLog, StrategicLogTranslation, User
from app.models import AICall, JobRun, StrategicLog
from app.services.cadence import DEFAULT_POLICY
from app.services.i18n import ACTIVE_LANGUAGES
from app.services.llm_prompts import (
from app.services.openrouter import (
PROMPT_VERSION,
active_model,
build_system_prompt,
build_user_prompt,
)
from app.services.output_review import review_read
from app.services.openrouter import (
active_model,
call_llm,
llm_configured,
)
from app.services.translation import translate
async def translate_log_for_active_languages(session, log_id: int) -> None:
"""Fan out per-language translations for the strategic log identified
by ``log_id``.
Reads ``users.lang`` (deduplicated, restricted to ACTIVE_LANGUAGES
minus English), one translation call per language in parallel via
``asyncio.gather``, persists each successful result as a
``StrategicLogTranslation`` row. Each row is committed in its own
savepoint so a per-language LLM error or DB error doesn't roll back
the languages that already succeeded.
The job orchestrator calls this AFTER the English ``StrategicLog``
row is committed; pass the row's ``id`` in.
"""
target_langs = sorted({l for l in ACTIVE_LANGUAGES if l != "en"})
if not target_langs:
return
active_langs = (await session.execute(
select(User.lang).distinct().where(User.lang.in_(target_langs))
)).scalars().all()
if not active_langs:
return
log_row = await session.get(StrategicLog, log_id)
if log_row is None:
log.warning("log.translate.missing_log", log_id=log_id)
return
async with httpx.AsyncClient(follow_redirects=True, timeout=60) as client:
results = await asyncio.gather(*[
translate(client, log_row.content, lang)
for lang in active_langs
], return_exceptions=True)
succeeded = 0
failed = 0
for lang, result in zip(active_langs, results):
if isinstance(result, Exception):
log.warning("log.translate.failed", lang=lang, log_id=log_id,
error=str(result)[:200])
failed += 1
continue
translated_md, llm_result = result
try:
async with session.begin_nested():
session.add(StrategicLogTranslation(
log_id=log_id, lang=lang,
content=translated_md,
generated_at=utcnow(),
model=llm_result.model,
prompt_tokens=llm_result.prompt_tokens,
completion_tokens=llm_result.completion_tokens,
cost_usd=llm_result.cost_usd,
))
await session.commit()
succeeded += 1
except Exception as exc:
log.warning("log.translate.persist_failed",
lang=lang, log_id=log_id, error=str(exc)[:200])
failed += 1
if failed and succeeded == 0:
log.error("log.translate.all_failed",
log_id=log_id, attempted=len(active_langs))
else:
log.info("log.translate.done",
log_id=log_id, succeeded=succeeded, failed=failed)
async def run() -> None:
@ -201,28 +126,7 @@ async def run() -> None:
tone=tone, analysis=analysis, error=str(e)[:200])
continue
# Reviewer gate: catches chain-of-thought, truncation,
# and (regulatory-critical) any financial-advice phrasing
# that drifted past the generator's system prompt. Drop
# rejected variants; the API falls back to the previous
# clean StrategicLog row.
verdict = await review_read(client, result.content)
full_cost = (result.cost_usd or 0.0) + (verdict.cost_usd or 0.0)
if not verdict.clean:
session.add(AICall(
model=result.model,
prompt_tokens=result.prompt_tokens,
completion_tokens=result.completion_tokens,
cost_usd=full_cost, status="leaked",
))
await session.commit()
log.warning("ai_log.reviewer_rejected",
tone=tone, analysis=analysis,
reason=verdict.reason,
preview=result.content[:120])
continue
slog = StrategicLog(
session.add(StrategicLog(
generated_at=utcnow(),
model=result.model,
anchor_date=anchor,
@ -232,18 +136,16 @@ async def run() -> None:
content=result.content,
prompt_tokens=result.prompt_tokens,
completion_tokens=result.completion_tokens,
cost_usd=full_cost,
)
session.add(slog)
cost_usd=result.cost_usd,
))
session.add(AICall(
model=result.model,
prompt_tokens=result.prompt_tokens,
completion_tokens=result.completion_tokens,
cost_usd=full_cost,
cost_usd=result.cost_usd,
status="ok",
))
await session.commit()
await translate_log_for_active_languages(session, slog.id)
written += 1
log.info("ai_log.variant_done",
tone=tone, analysis=analysis,

View file

@ -29,20 +29,14 @@ from app.jobs._market_context import (
from app.models import EmailSend, User
from app.routers.email import sign_unsubscribe_token
from app.services.access import paid_status
from app.services.digest_email import render_digest_email
from app.services.email_service import send_email
from app.services.i18n import ACTIVE_LANGUAGES
from app.services.llm_prompts import (
from app.services.email_service import render_digest_email, send_email
from app.services.openrouter import (
PROMPT_VERSION,
build_daily_digest_prompt,
build_weekly_digest_prompt,
)
from app.services.openrouter import (
call_llm,
llm_configured,
)
from app.services.output_review import review_read
from app.services.translation import translate
def _now() -> datetime:
@ -94,31 +88,12 @@ async def _generate_variants(session, client, kind: str, ctx: dict) -> dict[str,
[{"role": "system", "content": sys_},
{"role": "user", "content": usr}],
)
# Reviewer gate. Digest emails land in inboxes — once
# delivered they're unrecallable, so a financial-advice slip
# has more reach than the dashboard. Drop rejected variants;
# users on that tone get no digest this cycle (better than
# delivering bad copy).
verdict = await review_read(client, result.content)
full_cost = (result.cost_usd or 0.0) + (verdict.cost_usd or 0.0)
if not verdict.clean:
session.add(AICall(
model=result.model,
prompt_tokens=result.prompt_tokens,
completion_tokens=result.completion_tokens,
cost_usd=full_cost, status="leaked",
error=f"reviewer: {verdict.reason}",
))
await session.commit()
log.warning("digest.reviewer_rejected", kind=kind, tone=tone,
reason=verdict.reason, preview=result.content[:120])
continue
out[tone] = result.content
session.add(AICall(
model=result.model,
prompt_tokens=result.prompt_tokens,
completion_tokens=result.completion_tokens,
cost_usd=full_cost,
cost_usd=result.cost_usd,
status="ok",
))
await session.commit()
@ -141,62 +116,6 @@ def _kind_for_today(today: datetime) -> str:
return "weekly" if today.weekday() == 6 else "daily"
async def _translate_variants_for_active_langs(
client,
english_variants: dict[str, str],
target_langs: list[str],
) -> dict[tuple[str, str], str]:
"""Build a {(tone, lang): content_md} table.
Starts with the English variants as the canonical cells. For each
(tone, target_lang) pair where target_lang != 'en', calls translate()
in parallel; on failure the cell falls back to the English variant
of the same tone so the digest still goes out, just untranslated.
"""
table: dict[tuple[str, str], str] = {
(tone, "en"): content for tone, content in english_variants.items()
}
pairs = [
(tone, lang)
for tone in english_variants
for lang in target_langs
if lang != "en"
]
if not pairs:
return table
results = await asyncio.gather(*[
translate(client, english_variants[tone], lang) for tone, lang in pairs
], return_exceptions=True)
for (tone, lang), result in zip(pairs, results):
if isinstance(result, Exception):
log.warning("digest.translate.failed",
tone=tone, lang=lang, error=str(result)[:200])
table[(tone, lang)] = english_variants[tone]
continue
translated_md, _llm_log = result
table[(tone, lang)] = translated_md
return table
def _pick_variant(
table: dict[tuple[str, str], str], tone: str, lang: str,
) -> str:
"""Return the digest content for a recipient.
Lookup order: exact (tone, lang) (tone, 'en') ('INTERMEDIATE',
'en') first table value. The last falls are defensive; the table
always contains at least one English entry when the job is sending.
"""
if (tone, lang) in table:
return table[(tone, lang)]
if (tone, "en") in table:
return table[(tone, "en")]
if ("INTERMEDIATE", "en") in table:
return table[("INTERMEDIATE", "en")]
return next(iter(table.values()))
async def _send_one(user: User, kind: str, content_html: str, date_str: str,
session) -> None:
settings_url = f"{branding.SITE_URL}/settings"
@ -281,21 +200,17 @@ async def run() -> None:
jr.error = "all variants failed"
return
# Build the per-language translation table once per job run.
active_non_en = sorted({l for l in ACTIVE_LANGUAGES if l != "en"})
async with httpx.AsyncClient(follow_redirects=True) as client:
variant_table = await _translate_variants_for_active_langs(
client, variants, active_non_en,
)
written = 0
for u in fresh:
tone = (u.digest_tone or "INTERMEDIATE").upper()
content = _pick_variant(
variant_table,
tone=tone,
lang=(u.lang or "en"),
)
# Fall back to INTERMEDIATE first (the more common tone) and then
# to whatever variant succeeded, so an asymmetric LLM failure
# doesn't silently skip the user.
content = (variants.get(tone)
or variants.get("INTERMEDIATE")
or next(iter(variants.values()), None))
if content is None:
continue
await _send_one(u, kind, content, date_str, session)
await asyncio.sleep(0.1)
written += 1

View file

@ -4,7 +4,8 @@ hourly stays comfortably under the monthly cap."""
from __future__ import annotations
import asyncio
import json
import re
from collections import defaultdict
import httpx
from sqlalchemy import desc, func, select
@ -12,146 +13,169 @@ from sqlalchemy import desc, func, select
from app.config import get_settings, load_groups
from app.db import utcnow
from app.jobs._helpers import job_lifecycle, log
from app.jobs._market_context import latest_quotes_by_group, month_spend
from app.models import (
AICall,
IndicatorSummary,
IndicatorSummaryTranslation,
JobRun,
User,
)
from app.models import AICall, IndicatorSummary, JobRun, Quote
from app.services.cadence import DEFAULT_POLICY
from app.services.i18n import ACTIVE_LANGUAGES
from app.services.llm_prompts import (
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,
)
from app.services.openrouter import (
active_model,
call_llm,
llm_configured,
month_start,
)
from app.services.output_review import review_read
from app.services.translation import translate
AGGREGATE_GROUP_NAME = "__all__"
async def translate_summary_for_active_languages(session, summary_id: int) -> None:
"""Fan out per-language translations for one IndicatorSummary row.
# Strip known meta-commentary openers the model sometimes leaks despite the
# prompt's hard constraints. Each pattern matches one leading sentence.
_LEAK_PATTERNS = [
re.compile(p, re.IGNORECASE | re.DOTALL)
for p in (
# First-person meta — "I need to / I'll / I have to / I'm going to ..."
r"^i\s+(?:need|have|must|should|am going|'ll|will|shall|can|am)[^.]*\.\s*",
# "We need / we're / we are asked / we will ..."
r"^we\s+(?:need|are|'re|will|shall|can|should|must|have)[^.]*\.\s*",
r"^let\s+(?:me|us|'?s)[^.]*\.\s*",
r"^here[']s[^.]*\.\s*",
r"^sure[,!]?\s[^.]*\.\s*",
r"^looking at[^.]*\.\s*",
r"^based on[^.]*\.\s*",
r"^to (?:address|answer|write|summarise|summarize)[^.]*\.\s*",
r"^first[,]?\s[^.]*\.\s*",
r"^the (?:user|data shows|reader|task|request|reader sees|instructions?)[^.]*\.\s*",
r"^summary[:.]\s*",
r"^key\s*[:\-—]\s*",
r"^must\s+(?:be|cite|explain|avoid|give|stay|provide)[^.]*\.\s*",
r"^should\s+(?:be|give|cite|explain|avoid|provide)[^.]*\.\s*",
r"^avoid[^.]*\.\s*",
r"^cite\s+at\s+most[^.]*\.\s*",
r"^be\s+(?:speculative|specific|concise|brief)[^.]*\.\s*",
r"^stay\s+on[^.]*\.\s*",
r"^okay[,]?\s+",
r"^alright[,]?\s+",
r"^thinking[^.]*\.\s*",
# Prompt-leak prefixes — the model echoes example framing or rule
# headers from the system prompt.
r"^(?:good|bad|positive|negative)\s+example\s*[:\-—]\s*",
r"^example\s+(?:good|bad)\s*[:\-—]\s*",
r"^example\s*[:\-—]\s*",
r"^reference\s+style\s*[:\-—]\s*",
# Prompt label echoes (markdown-style or plain-text)
r"^(?:hard\s+)?constraints?\s*[:\-—][^.\n]*[.\n]\s*",
r"^key\s+observations?\s*[:\-—]\s*",
r"^observations?\s*[:\-—]\s*",
r"^focus\s+on[^.]*\.\s*",
r"^output\s+the\s+read[^.]*\.\s*",
r"^plain\s+prose[^.]*\.\s*",
r"^the\s+indicators?[^.]*\.\s*", # "The indicators include..." / "The indicators are..."
r"^indicators?\s*[:\-—]\s*",
r"^data\s*[:\-—]\s*",
r"^analysis\s*[:\-—]\s*",
r"^interpretation\s*[:\-—]\s*",
r"^read\s*[:\-—]\s*",
r"^note\s*[:\-—]\s*",
# Sometimes the response gets wrapped in literal quotes
r"^[\"'`]+",
)
]
Mirrors ``ai_log_job.translate_log_for_active_languages``: reads the
distinct non-en ``users.lang`` set, translates the English content
once per active language in parallel via ``asyncio.gather``, and
persists each result as an ``IndicatorSummaryTranslation`` row in
its own savepoint so one bad row doesn't lose the rest.
"""
target_langs = sorted({l for l in ACTIVE_LANGUAGES if l != "en"})
if not target_langs:
return
active_langs = (await session.execute(
select(User.lang).distinct().where(User.lang.in_(target_langs))
_TRAILING_QUOTE = re.compile(r"[\"'`]+\s*$")
# Tell-tale phrases that mean the model regurgitated the prompt as its
# "answer" — we'd rather show nothing than show this.
_LEAKAGE_FLAGS = (
"≤60 words", "60 words", "must be under", "must cite", "must explain",
"no meta-commentary", "no buy/sell", "horizon. ", "1-day moves",
"the instructions are", "instructions:", "constraints:", "hard constraints",
"good example", "bad example", "reference style",
)
def looks_like_leakage(text: str) -> bool:
"""Heuristic: after cleaning, if these phrases still appear, the output
is contaminated prompt-regurgitation and shouldn't be shown."""
low = text.lower()
return any(flag in low for flag in _LEAKAGE_FLAGS)
def clean_summary(text: str) -> str:
"""Strip leading meta-commentary. If cleaning removes nearly everything
(suggesting the model emitted reasoning then ran out of tokens), fall
back to the last non-empty paragraph of the raw output that's usually
where the actual answer ended up."""
raw = text.strip()
out = raw
# Up to 6 passes: handles compound leakage like
# "Constraints: <...>. The indicators are: <...>. <actual answer>"
for _ in range(6):
before = out
for pat in _LEAK_PATTERNS:
out = pat.sub("", out, count=1).lstrip()
if out == before:
break
if len(out) < 60 and len(raw) > 120:
# Cleaning ate too much; take the last non-empty paragraph of raw.
paragraphs = [p.strip() for p in re.split(r"\n\s*\n", raw) if p.strip()]
if paragraphs:
out = paragraphs[-1]
# Re-strip leaders from the recovered paragraph too.
for _ in range(2):
before = out
for pat in _LEAK_PATTERNS:
out = pat.sub("", out, count=1).lstrip()
if out == before:
break
# Trim any orphan closing quote/backtick from the wrap-strip above.
out = _TRAILING_QUOTE.sub("", out).rstrip()
return out
async def _latest_quotes_by_group(session) -> dict[str, list[dict]]:
"""Latest non-null quote per (group, symbol). Drops error rows."""
sub = (
select(Quote.group_name, Quote.symbol,
func.max(Quote.fetched_at).label("mx"))
.group_by(Quote.group_name, Quote.symbol)
.subquery()
)
rows = (await session.execute(
select(Quote).join(
sub,
(Quote.group_name == sub.c.group_name)
& (Quote.symbol == sub.c.symbol)
& (Quote.fetched_at == sub.c.mx),
).where(Quote.price.is_not(None))
.order_by(Quote.group_name, Quote.symbol)
)).scalars().all()
if not active_langs:
return
summary_row = await session.get(IndicatorSummary, summary_id)
if summary_row is None:
log.warning("ind_summary.translate.missing_summary", summary_id=summary_id)
return
async with httpx.AsyncClient(follow_redirects=True, timeout=60) as client:
results = await asyncio.gather(*[
translate(client, summary_row.content, lang)
for lang in active_langs
], return_exceptions=True)
succeeded = 0
failed = 0
for lang, result in zip(active_langs, results):
if isinstance(result, Exception):
log.warning("ind_summary.translate.failed",
lang=lang, summary_id=summary_id,
error=str(result)[:200])
failed += 1
continue
translated_md, llm_result = result
try:
async with session.begin_nested():
session.add(IndicatorSummaryTranslation(
summary_id=summary_id, lang=lang,
content=translated_md,
generated_at=utcnow(),
model=llm_result.model,
prompt_tokens=llm_result.prompt_tokens,
completion_tokens=llm_result.completion_tokens,
cost_usd=llm_result.cost_usd,
))
await session.commit()
succeeded += 1
except Exception as exc:
log.warning("ind_summary.translate.persist_failed",
lang=lang, summary_id=summary_id, error=str(exc)[:200])
failed += 1
if failed and succeeded == 0:
log.error("ind_summary.translate.all_failed",
summary_id=summary_id, attempted=len(active_langs))
else:
log.info("ind_summary.translate.done",
summary_id=summary_id, succeeded=succeeded, failed=failed)
by_group: dict[str, list[dict]] = defaultdict(list)
for q in rows:
by_group[q.group_name].append({
"symbol": q.symbol, "label": q.label,
"price": q.price, "currency": q.currency,
"as_of": q.as_of, "changes": q.changes,
})
return by_group
# Defence-in-depth: read generation goes through JSON mode + a reviewer.
#
# 1. The system prompt instructs the model to emit {"read": "..."} only;
# response_format={"type":"json_object"} forces well-formed JSON at
# the API layer, so prose outside the field is impossible.
# 2. We extract `read`, then ask a second LLM call (services/output_review)
# whether the candidate text is publishable. Scratchpad INSIDE the
# field — "Let's see…", "X? Actually Y?" — is caught here.
# 3. Any failure at either stage (parse, missing field, reviewer veto,
# reviewer error) drops the candidate. The previous good
# IndicatorSummary stays visible.
#
# The old _LEAK_PATTERNS / clean_summary / looks_like_leakage regex
# scaffolding lived here previously. It produced false positives (e.g.
# chopping off a legitimate leading sentence like "The indicators are
# pricing…") and false negatives (it never caught the chain-of-thought
# patterns the model actually emits). The reviewer agent replaces it.
def _extract_read(raw: str) -> str | None:
"""Parse the model's JSON envelope and return the "read" field, or
None if the body isn't valid JSON / the field is missing / the field
isn't a string. Conservative: on any deviation from the schema we
drop the candidate rather than try to salvage it."""
try:
parsed = json.loads(raw)
except json.JSONDecodeError:
return None
if not isinstance(parsed, dict):
return None
read = parsed.get("read")
if not isinstance(read, str):
return None
read = read.strip()
return read or None
async def _month_spend(session) -> float:
total = (await session.execute(
select(func.coalesce(func.sum(AICall.cost_usd), 0.0))
.where(AICall.called_at >= month_start())
)).scalar()
return float(total or 0.0)
async def _generate_one(
session, client: httpx.AsyncClient, group: str, quotes: list[dict],
system_prompt: str, model: str, tone: str, analysis: str,
) -> IndicatorSummary | None:
"""Generate + persist one group's summary. Returns the new row on
success (so the caller can fan out localized translations after
the commit picks up its id) or None on failure.
) -> bool:
"""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)
@ -161,20 +185,19 @@ async def _generate_one(
[{"role": "system", "content": system_prompt},
{"role": "user", "content": user_prompt}],
max_tokens=800, # DeepSeek sometimes spends 300+ on internal reasoning
response_format={"type": "json_object"},
)
except Exception as e:
session.add(AICall(model=active_model(), status="error", error=str(e)[:500]))
log.warning("ind_summary.failed", group=group, error=str(e)[:120])
return None
return False
candidate = _extract_read(result.content)
if candidate is None or len(candidate) < 40:
# JSON envelope malformed, "read" field missing/wrong type, or
# the candidate is too short to be a real read. Don't persist;
# the last good summary stays visible.
log.warning("ind_summary.json_invalid",
group=group, preview=result.content[:160])
cleaned = clean_summary(result.content)
if looks_like_leakage(cleaned) or len(cleaned) < 40:
# Model regurgitated the prompt or produced nothing usable.
# Don't persist — keep the last good summary visible. Log it so
# we can see the rate of failures over time.
log.warning("ind_summary.leakage_detected",
group=group, preview=cleaned[:120])
session.add(AICall(
model=result.model,
prompt_tokens=result.prompt_tokens,
@ -182,48 +205,28 @@ async def _generate_one(
cost_usd=result.cost_usd,
status="leaked",
))
return None
return False
verdict = await review_read(client, candidate)
if not verdict.clean:
# Reviewer caught scratchpad / meta-commentary / partial text
# INSIDE the read field. Drop the candidate; the previous good
# summary continues to serve.
log.warning("ind_summary.reviewer_rejected",
group=group, reason=verdict.reason,
preview=candidate[:120])
session.add(AICall(
model=result.model,
prompt_tokens=result.prompt_tokens,
completion_tokens=result.completion_tokens,
cost_usd=(result.cost_usd or 0.0) + (verdict.cost_usd or 0.0),
status="leaked",
))
return None
summary = IndicatorSummary(
session.add(IndicatorSummary(
group_name=group,
generated_at=utcnow(),
model=result.model,
tone=tone,
analysis=analysis,
prompt_version=PROMPT_VERSION,
content=candidate,
content=cleaned,
prompt_tokens=result.prompt_tokens,
completion_tokens=result.completion_tokens,
# Include the reviewer's cost in the row's recorded spend so the
# monthly budget tracking covers the full pipeline cost.
cost_usd=(result.cost_usd or 0.0) + (verdict.cost_usd or 0.0),
)
session.add(summary)
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 or 0.0) + (verdict.cost_usd or 0.0),
cost_usd=result.cost_usd,
status="ok",
))
return summary
return True
async def run() -> None:
@ -251,13 +254,13 @@ async def run() -> None:
jr.error = reason
return
spent = await month_spend(session)
spent = await _month_spend(session)
if spent >= s.OPENROUTER_MONTHLY_CAP_USD:
jr.status = "skipped"
jr.error = f"monthly cap reached (${spent:.2f})"
return
groups = await latest_quotes_by_group(session)
groups = await _latest_quotes_by_group(session)
# Only summarise groups currently configured in TOML — drops stale
# group names (e.g. an old "pie" before T212 sourcing) that still have
# quotes in the table but no UI presence.
@ -280,71 +283,41 @@ async def run() -> None:
for tone in tones:
system_prompt = build_summary_system_prompt(tone, analysis)
for group, quotes in groups.items():
summary = await _generate_one(
ok = await _generate_one(
session, client, group, quotes,
system_prompt, active_model(), tone, analysis,
)
if summary is not None:
if ok:
written += 1
await session.commit() # partial progress survives mid-job error
if summary is not None:
await translate_summary_for_active_languages(session, summary.id)
# One aggregate read across all groups, stored under __all__.
# Same JSON-mode + reviewer-agent path as per-group reads.
agg_system = build_aggregate_summary_system_prompt(tone, analysis)
agg_user = build_aggregate_summary_user_prompt(groups)
agg_summary: IndicatorSummary | None = None
try:
result = await call_llm(
client,
[{"role": "system", "content": agg_system},
{"role": "user", "content": agg_user}],
max_tokens=1500,
response_format={"type": "json_object"},
max_tokens=1500, # room for reasoning + 80-word output
)
candidate = _extract_read(result.content)
if candidate is None or len(candidate) < 40:
log.warning("ind_summary.agg_json_invalid",
tone=tone, preview=result.content[:160])
session.add(AICall(
model=result.model,
prompt_tokens=result.prompt_tokens,
completion_tokens=result.completion_tokens,
cost_usd=result.cost_usd, status="leaked",
))
else:
verdict = await review_read(client, candidate)
full_cost = (result.cost_usd or 0.0) + (verdict.cost_usd or 0.0)
if not verdict.clean:
log.warning("ind_summary.agg_reviewer_rejected",
tone=tone, reason=verdict.reason,
preview=candidate[:120])
session.add(AICall(
model=result.model,
prompt_tokens=result.prompt_tokens,
completion_tokens=result.completion_tokens,
cost_usd=full_cost, status="leaked",
))
else:
agg_summary = IndicatorSummary(
session.add(IndicatorSummary(
group_name=AGGREGATE_GROUP_NAME,
generated_at=utcnow(),
model=result.model,
tone=tone,
analysis=analysis,
prompt_version=PROMPT_VERSION,
content=candidate,
content=clean_summary(result.content),
prompt_tokens=result.prompt_tokens,
completion_tokens=result.completion_tokens,
cost_usd=full_cost,
)
session.add(agg_summary)
cost_usd=result.cost_usd,
))
session.add(AICall(
model=result.model,
prompt_tokens=result.prompt_tokens,
completion_tokens=result.completion_tokens,
cost_usd=full_cost, status="ok",
cost_usd=result.cost_usd, status="ok",
))
written += 1
except Exception as e:
@ -355,8 +328,6 @@ async def run() -> None:
log.warning("ind_summary.agg_failed",
tone=tone, error=str(e)[:120])
await session.commit()
if agg_summary is not None:
await translate_summary_for_active_languages(session, agg_summary.id)
jr.items_written = written
log.info("ind_summary.done",

View file

@ -19,9 +19,7 @@ from app.db import get_session_factory
from app.logging import configure_logging, get_logger
from app.routers import api as api_router
from app.routers import auth as auth_router
from app.routers import chat as chat_router
from app.routers import email as email_router
from app.routers import ops as ops_router
from app.routers import pages as pages_router
from app.routers import polar_webhook as polar_webhook_router
from app.routers import public as public_router
@ -91,8 +89,6 @@ app.mount(
app.include_router(auth_router.router, tags=["auth"])
app.include_router(email_router.router, tags=["email"])
app.include_router(api_router.router, prefix="/api", tags=["api"])
app.include_router(chat_router.router, prefix="/api", tags=["chat"])
app.include_router(ops_router.router, prefix="/api", tags=["ops"])
app.include_router(universe_router.router, prefix="/api", tags=["universe"])
app.include_router(ticker_validate_router.router, prefix="/api", tags=["ticker-validate"])
app.include_router(sync_router.router, tags=["portfolio-sync"])

View file

@ -59,7 +59,7 @@ class Quote(Base):
class QuoteDaily(Base):
"""Daily rollup — sparkline source. PK on (symbol, date)."""
__tablename__ = "quotes_daily"
symbol: Mapped[str] = mapped_column(String(128), primary_key=True)
symbol: Mapped[str] = mapped_column(String(64), primary_key=True)
date: Mapped[date] = mapped_column(Date, primary_key=True)
close: Mapped[float | None] = mapped_column(Float)
high: Mapped[float | None] = mapped_column(Float)
@ -120,41 +120,6 @@ class StrategicLog(Base):
cost_usd: Mapped[float | None] = mapped_column(Float)
class StrategicLogTranslation(Base):
"""Cached translation of a single StrategicLog row.
Populated by ai_log_job after the English row is committed: one
row per (log_id, lang) combination. The /log endpoint serves the
matching row when available and falls back to the English source
when no row exists yet (e.g. translation failed or the language
was added after the log was generated).
No user attribution the cache is shared. Setting `lang` on a
user just selects which (already-translated) variant they see.
"""
__tablename__ = "strategic_log_translations"
id: Mapped[int] = mapped_column(_PK, primary_key=True, autoincrement=True)
log_id: Mapped[int] = mapped_column(
BigInteger().with_variant(Integer(), "sqlite"),
ForeignKey("strategic_logs.id", ondelete="CASCADE"),
nullable=False,
)
lang: Mapped[str] = mapped_column(String(8), nullable=False)
content: Mapped[str] = mapped_column(Text, nullable=False)
generated_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), nullable=False, default=utcnow,
)
model: Mapped[str | None] = mapped_column(String(64))
prompt_tokens: Mapped[int | None] = mapped_column(Integer)
completion_tokens: Mapped[int | None] = mapped_column(Integer)
cost_usd: Mapped[float | None] = mapped_column(Float)
__table_args__ = (
UniqueConstraint("log_id", "lang", name="uq_slt_log_lang"),
)
class IndicatorSummary(Base):
"""Short AI-generated read for one indicator group, regenerated hourly.
The latest row per group_name is what the dashboard renders."""
@ -174,39 +139,6 @@ class IndicatorSummary(Base):
__table_args__ = (Index("ix_indsumm_group_generated", "group_name", "generated_at"),)
class IndicatorSummaryTranslation(Base):
"""Cached translation of a single IndicatorSummary row.
Same pattern as StrategicLogTranslation: one row per
(summary_id, lang). Populated by indicator_summary_job after the
English row is committed. The dashboard / indicators endpoints
swap in the matching translation when a user with a non-en
lang preference loads them, falling back silently to the English
source when no row exists yet.
"""
__tablename__ = "indicator_summary_translations"
id: Mapped[int] = mapped_column(_PK, primary_key=True, autoincrement=True)
summary_id: Mapped[int] = mapped_column(
BigInteger().with_variant(Integer(), "sqlite"),
ForeignKey("indicator_summaries.id", ondelete="CASCADE"),
nullable=False,
)
lang: Mapped[str] = mapped_column(String(8), nullable=False)
content: Mapped[str] = mapped_column(Text, nullable=False)
generated_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), nullable=False, default=utcnow,
)
model: Mapped[str | None] = mapped_column(String(64))
prompt_tokens: Mapped[int | None] = mapped_column(Integer)
completion_tokens: Mapped[int | None] = mapped_column(Integer)
cost_usd: Mapped[float | None] = mapped_column(Float)
__table_args__ = (
UniqueConstraint("summary_id", "lang", name="uq_ist_summary_lang"),
)
class AICall(Base):
"""Cost ledger for OpenRouter calls. Feeds the monthly cap check."""
__tablename__ = "ai_calls"
@ -257,14 +189,6 @@ class User(Base):
# NULL = use INTERMEDIATE at render time. Server-side mirror of the
# dashboard tone, decoupled because the dashboard pref is localStorage.
digest_tone: Mapped[str | None] = mapped_column(String(16))
# Preferred language for AI-generated content (strategic log,
# digest emails, portfolio commentary). Default 'en'. The settings
# PATCH endpoint validates against ACTIVE_LANGUAGES in
# app/services/i18n.py before writing.
lang: Mapped[str] = mapped_column(
String(8), nullable=False, default="en", server_default="en",
index=True,
)
# Polar (MoR) linkage — populated by the polar_webhook handler the
# first time we see a subscription/order event for the user. The
# customer id is the stable join key; the subscription id is what
@ -539,7 +463,5 @@ class CsvFormatTemplate(Base):
last_used_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), nullable=False, default=utcnow,
)
model: Mapped[str | None] = mapped_column(String(64))
prompt_tokens: Mapped[int | None] = mapped_column(Integer)
completion_tokens: Mapped[int | None] = mapped_column(Integer)
cost_usd: Mapped[float | None] = mapped_column(Float)
llm_model: Mapped[str | None] = mapped_column(String(64))
llm_cost_usd: Mapped[float | None] = mapped_column(Float)

View file

@ -10,29 +10,39 @@ import re
from datetime import date, datetime, timedelta, timezone
from typing import Literal
from fastapi import APIRouter, Depends, HTTPException, Query, Request
from fastapi.responses import JSONResponse
from fastapi import APIRouter, Depends, File, Form, HTTPException, Query, Request, UploadFile
from fastapi.responses import HTMLResponse, JSONResponse
from sqlalchemy import desc, func, select
from sqlalchemy.ext.asyncio import AsyncSession
from pydantic import BaseModel
from collections import defaultdict
import httpx
from pydantic import BaseModel, Field
from app.auth import require_token, maybe_current_user, CurrentUser
from app.services.i18n import ACTIVE_LANGUAGES
from app.config import get_settings
from app.db import get_session, utcnow
from app.services.openrouter import (
PROMPT_VERSION,
build_chat_system_prompt,
call_openrouter,
month_start,
)
from app.templates_env import templates
from app.models import (
AICall,
Headline,
IndicatorSummary,
IndicatorSummaryTranslation,
JobRun,
Quote,
StrategicLog,
StrategicLogTranslation,
User,
)
from app.schemas import (
HealthOut,
HeadlineOut,
JobStatus,
QuoteOut,
StrategicLogOut,
)
@ -40,6 +50,11 @@ from app.schemas import (
router = APIRouter(dependencies=[Depends(require_token)])
JOB_NAMES = ("market_job", "news_job", "ai_log_job", "rollup_job",
"indicator_summary_job", "universe_flush_job",
"email_digest_job")
JOB_STALE_HOURS = 2.0 # job is "warn" if its last success was >2h ago
# Per-group expected freshness — bonds and intraday tape want daily data,
# macro/economy/valuation are monthly/quarterly by nature. Older than this
# many days from today → row gets a "stale" badge.
@ -120,7 +135,6 @@ async def indicators(
as_: str | None = Query(default=None, alias="as"),
tone: str | None = Query(default=None),
session: AsyncSession = Depends(get_session),
principal: CurrentUser | None = Depends(maybe_current_user),
):
sub = (
select(Quote.symbol, func.max(Quote.fetched_at).label("mx"))
@ -188,7 +202,6 @@ async def indicators(
if as_of_d and (today - as_of_d).days > threshold:
stale_symbols.add(r.symbol)
await _apply_localized_summary(session, summary, principal)
return templates.TemplateResponse(
request, "partials/indicators.html",
{"quotes": rows, "has_anchor": has_anchor,
@ -282,15 +295,11 @@ async def news_list(
# --- Strategic log -----------------------------------------------------------
def _log_partial_payload(
row: StrategicLog | None,
content_override: str | None = None,
) -> dict | None:
def _log_partial_payload(row: StrategicLog | None) -> dict | None:
if row is None:
return None
content = content_override if content_override is not None else row.content
return {
"content_html": _md_to_html(content),
"content_html": _md_to_html(row.content),
"generated_at": row.generated_at,
"model": row.model,
"tone": row.tone,
@ -302,52 +311,6 @@ def _log_partial_payload(
}
async def _localized_content(
session: AsyncSession,
row: StrategicLog | None,
principal: CurrentUser | None,
) -> str | None:
"""Return the translated content for ``row`` when the principal has
a non-English lang preference and a matching translation row exists.
Returns None to signal 'use row.content as-is' (the default English
path)."""
if row is None or principal is None or principal.user is None:
return None
lang = (principal.user.lang or "en")
if lang == "en":
return None
t = (await session.execute(
select(StrategicLogTranslation)
.where(StrategicLogTranslation.log_id == row.id)
.where(StrategicLogTranslation.lang == lang)
)).scalar_one_or_none()
return t.content if t is not None else None
async def _apply_localized_summary(
session: AsyncSession,
row: IndicatorSummary | None,
principal: CurrentUser | None,
) -> None:
"""If ``row`` has a matching translation for ``principal.user.lang``,
overwrite the in-memory ``content`` attribute so the template renders
the localized version. No DB write happens the mutation lives only
for the lifetime of this GET request.
"""
if row is None or principal is None or principal.user is None:
return
lang = (principal.user.lang or "en")
if lang == "en":
return
t = (await session.execute(
select(IndicatorSummaryTranslation)
.where(IndicatorSummaryTranslation.summary_id == row.id)
.where(IndicatorSummaryTranslation.lang == lang)
)).scalar_one_or_none()
if t is not None:
row.content = t.content
def _resolve_tone_param(tone: str | None) -> str:
"""Normalise a query-param tone to one of the two valid values.
PRO is silently mapped to INTERMEDIATE (see openrouter.PROMPT_VERSION 6)."""
@ -403,11 +366,10 @@ async def log_latest(
row = (await session.execute(fallback)).scalar_one_or_none()
if as_ == "html":
content_override = await _localized_content(session, row, principal)
return templates.TemplateResponse(
request, "partials/log.html",
{"log": _log_partial_payload(row, content_override=content_override),
"tone": wanted_tone, "paid": not free_only},
{"log": _log_partial_payload(row), "tone": wanted_tone,
"paid": not free_only},
)
if row is None:
@ -458,11 +420,10 @@ async def log_by_date(
row = (await session.execute(fallback)).scalar_one_or_none()
if as_ == "html":
content_override = await _localized_content(session, row, principal)
return templates.TemplateResponse(
request, "partials/log.html",
{"log": _log_partial_payload(row, content_override=content_override),
"tone": wanted_tone, "paid": not free_only},
{"log": _log_partial_payload(row), "tone": wanted_tone,
"paid": not free_only},
)
if row is None:
raise HTTPException(status_code=404, detail="No log on this date")
@ -544,6 +505,14 @@ async def log_days(
return templates.TemplateResponse(request, "partials/calendar.html", payload)
# Portfolio endpoints moved to app/routers/universe.py (Phase G). The
# server no longer persists per-user portfolio data; holdings live in
# the browser's localStorage and prices come from /api/universe.
# --- Health / ops footer -----------------------------------------------------
# --- Aggregate summary + market status (dashboard header) -------------------
@ -556,7 +525,6 @@ async def aggregate_summary(
session: AsyncSession = Depends(get_session),
as_: str | None = Query(default=None, alias="as"),
tone: str | None = Query(default=None),
principal: CurrentUser | None = Depends(maybe_current_user),
):
wanted_tone = _resolve_tone_param(tone)
row = (await session.execute(
@ -578,7 +546,6 @@ async def aggregate_summary(
statuses = all_statuses()
if as_ == "html":
await _apply_localized_summary(session, row, principal)
return templates.TemplateResponse(
request, "partials/dashboard_header.html",
{"summary": row, "markets": statuses, "tone": wanted_tone},
@ -596,6 +563,303 @@ async def aggregate_summary(
}
# Market → headline index mapping for the sticky bottom bar. Symbols must
# be present in config/default.toml so market_job populates `quotes`.
_MARKET_INDEX = {
"NYSE": ("^GSPC", "S&P 500"),
"LSE": ("^FTSE", "FTSE 100"),
# XETRA → Euro Stoxx 50 rather than ^GDAXI: Yahoo's DAX ticker is
# patchy via the chart endpoint, and ^STOXX50E is already tracked in
# config/default.toml's equity group.
"XETRA": ("^STOXX50E", "STOXX 50"),
"JPX": ("^N225", "Nikkei 225"),
"HKEX": ("^HSI", "Hang Seng"),
"SSE": ("000300.SS", "CSI 300"),
}
def _fmt_price(p: float | None) -> str:
if p is None:
return ""
if abs(p) >= 1000:
return f"{p:,.0f}"
if abs(p) >= 100:
return f"{p:,.1f}"
return f"{p:,.2f}"
@router.get("/markets-bar", response_class=HTMLResponse, include_in_schema=False)
async def markets_bar(
request: Request,
session: AsyncSession = Depends(get_session),
as_: str | None = Query(default=None, alias="as"),
):
"""The sticky bottom-bar payload: per-market open/close status with the
market's headline index price + 1d change. Refreshed by HTMX every 60s.
"""
from app.services.markets import all_statuses
statuses = all_statuses()
# Latest quote per headline-index symbol in one query.
wanted_syms = [sym for sym, _ in _MARKET_INDEX.values()]
sub = (
select(Quote.symbol, func.max(Quote.fetched_at).label("mx"))
.where(Quote.symbol.in_(wanted_syms))
.group_by(Quote.symbol)
.subquery()
)
rows = (await session.execute(
select(Quote).join(
sub,
(Quote.symbol == sub.c.symbol) & (Quote.fetched_at == sub.c.mx),
)
)).scalars().all()
by_sym = {q.symbol: q for q in rows}
markets: list[dict] = []
for st in statuses:
sym, label = _MARKET_INDEX.get(st["code"], (None, None))
q = by_sym.get(sym) if sym else None
idx = None
if q is not None and q.price is not None:
idx = {
"symbol": q.symbol,
"label": label,
"price_fmt": _fmt_price(q.price),
"change_1d_pct": (q.changes or {}).get("1d"),
}
markets.append({
"code": st["code"],
"label": st["label"],
"open": st["open"],
"until_iso": st["until"].isoformat(),
"until_hhmm": st["until"].strftime("%H:%M"),
"index": idx,
})
return templates.TemplateResponse(
request, "partials/markets_bar.html",
{"markets": markets},
)
@router.get("/health", response_class=HTMLResponse, include_in_schema=False)
async def health_html(
request: Request,
session: AsyncSession = Depends(get_session),
as_: str | None = Query(default=None, alias="as"),
):
"""Returns an HTML fragment by default (the ops footer); ?as=json returns the
structured object. The default is HTML because that's how the dashboard
consumes it; CLI/curl users will pass ?as=json."""
try:
await session.execute(select(func.now()))
db_ok = True
except Exception:
db_ok = False
now = utcnow()
jobs: list[dict] = []
structured: list[JobStatus] = []
for name in JOB_NAMES:
row = (await session.execute(
select(JobRun).where(JobRun.name == name)
.order_by(desc(JobRun.started_at)).limit(1)
)).scalar_one_or_none()
if row is None:
jobs.append({"name": name, "led": "idle", "age": "",
"last_finished": None})
structured.append(JobStatus(name=name))
continue
if row.status == "success":
secs = _age_seconds(now, row.finished_at or row.started_at) or 0
led = "ok" if secs < JOB_STALE_HOURS * 3600 else "warn"
elif row.status == "skipped":
led = "warn"
elif row.status == "running":
led = "warn"
else:
led = "err"
jobs.append({
"name": name, "led": led,
"age": _fmt_age(now, row.finished_at or row.started_at),
"last_finished": row.finished_at,
})
structured.append(JobStatus(
name=name, last_started=row.started_at,
last_finished=row.finished_at, status=row.status,
error=row.error, items_written=row.items_written,
))
if as_ == "json":
return JSONResponse(
HealthOut(db="ok" if db_ok else "down", jobs=structured).model_dump(mode="json")
)
return templates.TemplateResponse(
request, "partials/ops_footer.html",
{"db_ok": db_ok, "jobs": jobs},
)
# --- Chat -------------------------------------------------------------------
class ChatMessage(BaseModel):
role: str = Field(pattern="^(user|assistant)$")
content: str
class ChatRequest(BaseModel):
messages: list[ChatMessage]
CHAT_REFERENCE_LINE = (
"S&P 7,501 (ATH) · VIX 18.0 · US 10y 4.45% · HY OAS 279bps · "
"Brent $109/bbl · Gold $4,651/oz · CPI 3.8% YoY"
)
THESIS_KEYWORDS_FALLBACK = [
"hormuz", "iran", "opec", "brent", "wti", "crude", "oil",
"china", "taiwan", "yuan", "fed", "inflation", "cpi", "yield",
"gold", "dollar", "yen", "saudi", "russia", "ukraine", "israel",
"nato", "defence", "defense",
]
async def _latest_quotes_by_group_chat(session: AsyncSession) -> dict[str, list[dict]]:
sub = (
select(Quote.group_name, Quote.symbol,
func.max(Quote.fetched_at).label("mx"))
.group_by(Quote.group_name, Quote.symbol)
.subquery()
)
rows = (await session.execute(
select(Quote).join(
sub,
(Quote.group_name == sub.c.group_name)
& (Quote.symbol == sub.c.symbol)
& (Quote.fetched_at == sub.c.mx),
).order_by(Quote.group_name, Quote.symbol)
)).scalars().all()
by_group: dict[str, list[dict]] = defaultdict(list)
for q in rows:
by_group[q.group_name].append({
"symbol": q.symbol, "label": q.label,
"price": q.price, "currency": q.currency,
"as_of": q.as_of, "changes": q.changes,
})
return by_group
async def _thesis_headlines_for_chat(session: AsyncSession, limit: int = 50) -> list[dict]:
cutoff = utcnow() - timedelta(hours=24)
rows = (await session.execute(
select(Headline)
.where(Headline.published_at >= cutoff)
.order_by(desc(Headline.published_at))
.limit(300)
)).scalars().all()
out = []
for h in rows:
if any(kw in h.title.lower() for kw in THESIS_KEYWORDS_FALLBACK):
out.append({"source": h.source, "title": h.title})
if len(out) >= limit:
break
return out
async def _month_spend(session: AsyncSession) -> float:
total = (await session.execute(
select(func.coalesce(func.sum(AICall.cost_usd), 0.0))
.where(AICall.called_at >= month_start())
)).scalar()
return float(total or 0.0)
@router.post("/chat")
async def chat(
body: ChatRequest,
session: AsyncSession = Depends(get_session),
principal: CurrentUser | None = Depends(maybe_current_user),
):
"""Answer one user turn given the conversation so far. Grounded on the
latest strategic log + market data + thesis-filtered headlines.
Ephemeral the conversation lives entirely in the client; the endpoint
just records each call's cost in `ai_calls`."""
# Paid-only feature. Free users get the static log but not the
# interactive chat (see /pricing).
from app.services.access import is_paid_active
if not is_paid_active(principal):
raise HTTPException(
status_code=402,
detail={"code": "paid_required",
"message": "Follow-up chat is a paid-tier feature."},
)
s = get_settings()
if not s.OPENROUTER_API_KEY:
raise HTTPException(status_code=503, detail="OPENROUTER_API_KEY not set")
# Monthly cost cap — same one the log job respects.
spent = await _month_spend(session)
if spent >= s.OPENROUTER_MONTHLY_CAP_USD:
raise HTTPException(
status_code=429,
detail=f"Monthly OpenRouter cap reached (${spent:.2f})",
)
# Trim runaway conversations: keep last 20 turns.
history = body.messages[-20:]
if not history or history[-1].role != "user":
raise HTTPException(status_code=400, detail="Last message must be user")
# Gather grounding context.
log_row = (await session.execute(
select(StrategicLog).order_by(desc(StrategicLog.generated_at)).limit(1)
)).scalar_one_or_none()
quotes = await _latest_quotes_by_group_chat(session)
headlines = await _thesis_headlines_for_chat(session)
system_prompt = build_chat_system_prompt(
s.CASSANDRA_TONE, s.CASSANDRA_ANALYSIS,
log_content=log_row.content if log_row else None,
log_generated_at=log_row.generated_at if log_row else None,
quotes_by_group=quotes,
headlines=headlines,
reference_line=CHAT_REFERENCE_LINE,
)
msgs = [{"role": "system", "content": system_prompt}]
for m in history:
msgs.append({"role": m.role, "content": m.content})
try:
async with httpx.AsyncClient(follow_redirects=True) as client:
result = await call_openrouter(client, msgs, 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 HTTPException(status_code=502, detail=f"OpenRouter error: {e}")
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()
return {
"role": "assistant",
"content": result.content,
"content_html": _md_to_html(result.content),
"prompt_tokens": result.prompt_tokens,
"completion_tokens": result.completion_tokens,
}
# ---------------------------------------------------------------------------
# Settings — digest preferences
# ---------------------------------------------------------------------------
@ -631,38 +895,3 @@ async def patch_digest_prefs(
user.digest_tone = payload.tone
await session.commit()
return DigestPrefsOut(opt_in=payload.opt_in, tone=payload.tone)
# ---------------------------------------------------------------------------
# Settings — language preference
# ---------------------------------------------------------------------------
class LanguagePrefsIn(BaseModel):
lang: str
class LanguagePrefsOut(BaseModel):
lang: str
@router.patch("/settings/language", response_model=LanguagePrefsOut)
async def patch_language_prefs(
payload: LanguagePrefsIn,
principal: CurrentUser = Depends(require_token),
session: AsyncSession = Depends(get_session),
) -> LanguagePrefsOut:
if principal.user is None:
raise HTTPException(status_code=400, detail="no_user_context")
lang = (payload.lang or "").strip().lower()
if lang not in ACTIVE_LANGUAGES:
raise HTTPException(
status_code=400,
detail=f"unsupported language: {payload.lang!r}",
)
user = await session.get(User, principal.user.id)
if user is None:
raise HTTPException(status_code=404, detail="user_not_found")
user.lang = lang
await session.commit()
return LanguagePrefsOut(lang=lang)

View file

@ -1,239 +0,0 @@
"""Chat endpoint — POST /api/chat.
Grounded on the latest strategic log, current market quotes, and
thesis-filtered headlines. Ephemeral: the conversation lives in the
client; this endpoint just records each call's cost in `ai_calls`.
"""
from __future__ import annotations
from collections import defaultdict
from datetime import timedelta
import httpx
from fastapi import APIRouter, Depends, HTTPException
from pydantic import BaseModel, Field
from sqlalchemy import desc, func, select
from sqlalchemy.ext.asyncio import AsyncSession
from app.auth import require_token, maybe_current_user, CurrentUser
from app.config import get_settings
from app.db import get_session, utcnow
from app.jobs._market_context import REFERENCE_LINE
from app.models import AICall, Headline, Quote, StrategicLog
from app.routers.api import _md_to_html
from app.services.i18n import respond_in_clause
from app.services.llm_prompts import build_chat_system_prompt
from app.services.openrouter import call_llm, month_start
from app.services.output_review import review_read
from app.logging import get_logger
log = get_logger("chat")
router = APIRouter(dependencies=[Depends(require_token)])
# ---------------------------------------------------------------------------
# Pydantic models
# ---------------------------------------------------------------------------
class ChatMessage(BaseModel):
role: str = Field(pattern="^(user|assistant)$")
content: str
class ChatRequest(BaseModel):
messages: list[ChatMessage]
# ---------------------------------------------------------------------------
# Private helpers
# ---------------------------------------------------------------------------
THESIS_KEYWORDS_FALLBACK = [
"hormuz", "iran", "opec", "brent", "wti", "crude", "oil",
"china", "taiwan", "yuan", "fed", "inflation", "cpi", "yield",
"gold", "dollar", "yen", "saudi", "russia", "ukraine", "israel",
"nato", "defence", "defense",
]
async def _latest_quotes_by_group_chat(session: AsyncSession) -> dict[str, list[dict]]:
sub = (
select(Quote.group_name, Quote.symbol,
func.max(Quote.fetched_at).label("mx"))
.group_by(Quote.group_name, Quote.symbol)
.subquery()
)
rows = (await session.execute(
select(Quote).join(
sub,
(Quote.group_name == sub.c.group_name)
& (Quote.symbol == sub.c.symbol)
& (Quote.fetched_at == sub.c.mx),
).order_by(Quote.group_name, Quote.symbol)
)).scalars().all()
by_group: dict[str, list[dict]] = defaultdict(list)
for q in rows:
by_group[q.group_name].append({
"symbol": q.symbol, "label": q.label,
"price": q.price, "currency": q.currency,
"as_of": q.as_of, "changes": q.changes,
})
return by_group
async def _thesis_headlines_for_chat(session: AsyncSession, limit: int = 50) -> list[dict]:
cutoff = utcnow() - timedelta(hours=24)
rows = (await session.execute(
select(Headline)
.where(Headline.published_at >= cutoff)
.order_by(desc(Headline.published_at))
.limit(300)
)).scalars().all()
out = []
for h in rows:
if any(kw in h.title.lower() for kw in THESIS_KEYWORDS_FALLBACK):
out.append({"source": h.source, "title": h.title})
if len(out) >= limit:
break
return out
async def _month_spend(session: AsyncSession) -> float:
total = (await session.execute(
select(func.coalesce(func.sum(AICall.cost_usd), 0.0))
.where(AICall.called_at >= month_start())
)).scalar()
return float(total or 0.0)
# ---------------------------------------------------------------------------
# Route
# ---------------------------------------------------------------------------
@router.post("/chat")
async def chat(
body: ChatRequest,
session: AsyncSession = Depends(get_session),
principal: CurrentUser | None = Depends(maybe_current_user),
):
"""Answer one user turn given the conversation so far. Grounded on the
latest strategic log + market data + thesis-filtered headlines.
Ephemeral the conversation lives entirely in the client; the endpoint
just records each call's cost in `ai_calls`."""
# Paid-only feature. Free users get the static log but not the
# interactive chat (see /pricing).
from app.services.access import is_paid_active
if not is_paid_active(principal):
raise HTTPException(
status_code=402,
detail={"code": "paid_required",
"message": "Follow-up chat is a paid-tier feature."},
)
s = get_settings()
if not s.OPENROUTER_API_KEY:
raise HTTPException(status_code=503, detail="OPENROUTER_API_KEY not set")
# Monthly cost cap — same one the log job respects.
spent = await _month_spend(session)
if spent >= s.OPENROUTER_MONTHLY_CAP_USD:
raise HTTPException(
status_code=429,
detail=f"Monthly OpenRouter cap reached (${spent:.2f})",
)
# Trim runaway conversations: keep last 20 turns.
history = body.messages[-20:]
if not history or history[-1].role != "user":
raise HTTPException(status_code=400, detail="Last message must be user")
# Gather grounding context.
log_row = (await session.execute(
select(StrategicLog).order_by(desc(StrategicLog.generated_at)).limit(1)
)).scalar_one_or_none()
quotes = await _latest_quotes_by_group_chat(session)
headlines = await _thesis_headlines_for_chat(session)
system_prompt = build_chat_system_prompt(
s.CASSANDRA_TONE, s.CASSANDRA_ANALYSIS,
log_content=log_row.content if log_row else None,
log_generated_at=log_row.generated_at if log_row else None,
quotes_by_group=quotes,
headlines=headlines,
reference_line=REFERENCE_LINE,
)
# Respect the user's interface language preference: append a single
# localized "respond in" nudge so the assistant answers in IT when
# the user has lang=it. The prompt + history (which includes the
# user's own question, often in their language) are usually enough,
# but the nudge guarantees the first reply lands correctly.
user_lang = principal.user.lang if principal and principal.user else "en"
system_prompt = system_prompt + respond_in_clause(user_lang)
msgs = [{"role": "system", "content": system_prompt}]
for m in history:
msgs.append({"role": m.role, "content": m.content})
try:
async with httpx.AsyncClient(follow_redirects=True) as client:
result = await call_llm(client, msgs)
# Reviewer gate. The chat turn could solicit advice with a
# leading question; the generator's system prompt forbids it,
# but the reviewer is the enforcement layer. ~1-2 s extra
# latency per turn on top of the generation call.
verdict = await review_read(client, result.content)
except Exception as e:
session.add(AICall(
model=s.OPENROUTER_MODEL, status="error", error=str(e)[:500],
))
await session.commit()
raise HTTPException(status_code=502, detail=f"OpenRouter error: {e}")
full_cost = (result.cost_usd or 0.0) + (verdict.cost_usd or 0.0)
if not verdict.clean:
# Rejected reply. Record the cost and surface a generic refusal
# the user can retry, rather than letting potentially non-compliant
# text reach them.
session.add(AICall(
model=result.model,
prompt_tokens=result.prompt_tokens,
completion_tokens=result.completion_tokens,
cost_usd=full_cost, status="leaked",
error=f"reviewer: {verdict.reason}",
))
await session.commit()
log.warning("chat.reviewer_rejected", reason=verdict.reason,
preview=result.content[:120])
refusal = (
"I can't generate that reply — it would have crossed into "
"investment advice or specific recommendations, which I'm "
"not licensed to give. Try rephrasing as a question about "
"what the data means rather than what to do."
)
return {
"role": "assistant",
"content": refusal,
"content_html": _md_to_html(refusal),
"prompt_tokens": result.prompt_tokens,
"completion_tokens": result.completion_tokens,
}
session.add(AICall(
model=result.model,
prompt_tokens=result.prompt_tokens,
completion_tokens=result.completion_tokens,
cost_usd=full_cost,
status="ok",
))
await session.commit()
return {
"role": "assistant",
"content": result.content,
"content_html": _md_to_html(result.content),
"prompt_tokens": result.prompt_tokens,
"completion_tokens": result.completion_tokens,
}

View file

@ -63,9 +63,7 @@ _CONFIRM_PAGE = """\
<head>
<meta charset="utf-8">
<title>Unsubscribed {brand}</title>
<link rel="stylesheet" href="/static/css/tokens.css">
<link rel="stylesheet" href="/static/css/layout.css">
<link rel="stylesheet" href="/static/css/auth.css">
<link rel="stylesheet" href="/static/css/cassandra.css">
</head>
<body class="auth-shell">
<div class="auth-card" style="max-width:480px;">

View file

@ -1,162 +0,0 @@
"""HTML-only ops endpoints — /api/markets-bar and /api/health.
These are HTMX partials consumed by the dashboard. They return HTML by
default (not JSON) and are not included in the OpenAPI schema.
"""
from __future__ import annotations
from fastapi import APIRouter, Depends, Query, Request
from fastapi.responses import HTMLResponse, JSONResponse
from sqlalchemy import desc, func, select
from sqlalchemy.ext.asyncio import AsyncSession
from app.auth import require_token
from app.db import get_session, utcnow
from app.models import JobRun, Quote
from app.routers.api import _age_seconds, _fmt_age
from app.schemas import HealthOut, JobStatus
from app.templates_env import templates
router = APIRouter(dependencies=[Depends(require_token)])
JOB_NAMES = ("market_job", "news_job", "ai_log_job", "rollup_job",
"indicator_summary_job", "universe_flush_job",
"email_digest_job")
JOB_STALE_HOURS = 2.0 # job is "warn" if its last success was >2h ago
# Market → headline index mapping for the sticky bottom bar. Symbols must
# be present in config/default.toml so market_job populates `quotes`.
_MARKET_INDEX = {
"NYSE": ("^GSPC", "S&P 500"),
"LSE": ("^FTSE", "FTSE 100"),
# XETRA → Euro Stoxx 50 rather than ^GDAXI: Yahoo's DAX ticker is
# patchy via the chart endpoint, and ^STOXX50E is already tracked in
# config/default.toml's equity group.
"XETRA": ("^STOXX50E", "STOXX 50"),
"JPX": ("^N225", "Nikkei 225"),
"HKEX": ("^HSI", "Hang Seng"),
"SSE": ("000300.SS", "CSI 300"),
}
def _fmt_price(p: float | None) -> str:
if p is None:
return ""
if abs(p) >= 1000:
return f"{p:,.0f}"
if abs(p) >= 100:
return f"{p:,.1f}"
return f"{p:,.2f}"
@router.get("/markets-bar", response_class=HTMLResponse, include_in_schema=False)
async def markets_bar(
request: Request,
session: AsyncSession = Depends(get_session),
as_: str | None = Query(default=None, alias="as"),
):
"""The sticky bottom-bar payload: per-market open/close status with the
market's headline index price + 1d change. Refreshed by HTMX every 60s.
"""
from app.services.markets import all_statuses
statuses = all_statuses()
# Latest quote per headline-index symbol in one query.
wanted_syms = [sym for sym, _ in _MARKET_INDEX.values()]
sub = (
select(Quote.symbol, func.max(Quote.fetched_at).label("mx"))
.where(Quote.symbol.in_(wanted_syms))
.group_by(Quote.symbol)
.subquery()
)
rows = (await session.execute(
select(Quote).join(
sub,
(Quote.symbol == sub.c.symbol) & (Quote.fetched_at == sub.c.mx),
)
)).scalars().all()
by_sym = {q.symbol: q for q in rows}
markets: list[dict] = []
for st in statuses:
sym, label = _MARKET_INDEX.get(st["code"], (None, None))
q = by_sym.get(sym) if sym else None
idx = None
if q is not None and q.price is not None:
idx = {
"symbol": q.symbol,
"label": label,
"price_fmt": _fmt_price(q.price),
"change_1d_pct": (q.changes or {}).get("1d"),
}
markets.append({
"code": st["code"],
"label": st["label"],
"open": st["open"],
"until_iso": st["until"].isoformat(),
"until_hhmm": st["until"].strftime("%H:%M"),
"index": idx,
})
return templates.TemplateResponse(
request, "partials/markets_bar.html",
{"markets": markets},
)
@router.get("/health", response_class=HTMLResponse, include_in_schema=False)
async def health_html(
request: Request,
session: AsyncSession = Depends(get_session),
as_: str | None = Query(default=None, alias="as"),
):
"""Returns an HTML fragment by default (the ops footer); ?as=json returns the
structured object. The default is HTML because that's how the dashboard
consumes it; CLI/curl users will pass ?as=json."""
try:
await session.execute(select(func.now()))
db_ok = True
except Exception:
db_ok = False
now = utcnow()
jobs: list[dict] = []
structured: list[JobStatus] = []
for name in JOB_NAMES:
row = (await session.execute(
select(JobRun).where(JobRun.name == name)
.order_by(desc(JobRun.started_at)).limit(1)
)).scalar_one_or_none()
if row is None:
jobs.append({"name": name, "led": "idle", "age": "",
"last_finished": None})
structured.append(JobStatus(name=name))
continue
if row.status == "success":
secs = _age_seconds(now, row.finished_at or row.started_at) or 0
led = "ok" if secs < JOB_STALE_HOURS * 3600 else "warn"
elif row.status == "skipped":
led = "warn"
elif row.status == "running":
led = "warn"
else:
led = "err"
jobs.append({
"name": name, "led": led,
"age": _fmt_age(now, row.finished_at or row.started_at),
"last_finished": row.finished_at,
})
structured.append(JobStatus(
name=name, last_started=row.started_at,
last_finished=row.finished_at, status=row.status,
error=row.error, items_written=row.items_written,
))
if as_ == "json":
return JSONResponse(
HealthOut(db="ok" if db_ok else "down", jobs=structured).model_dump(mode="json")
)
return templates.TemplateResponse(
request, "partials/ops_footer.html",
{"db_ok": db_ok, "jobs": jobs},
)

View file

@ -75,13 +75,14 @@ async def _resolve_log_date(session: AsyncSession, day: str | None) -> date:
return datetime.now(timezone.utc).date()
def _log_page_context(target: date, paid: bool, user_lang: str = "en") -> dict:
def _log_page_context(target: date, paid: bool) -> dict:
s = get_settings()
return {
"selected_iso": target.isoformat(),
"selected_month": target.strftime("%Y-%m"),
"current_tone": s.CASSANDRA_TONE.upper(),
"current_analysis": s.CASSANDRA_ANALYSIS.upper(),
"paid": paid,
"user_lang": user_lang,
}
@ -92,9 +93,8 @@ async def log_page(
cu: CurrentUser = Depends(require_auth),
):
target = await _resolve_log_date(session, None)
user_lang = cu.user.lang if cu.user else "en"
return templates.TemplateResponse(
request, "log.html", _log_page_context(target, is_paid_active(cu), user_lang),
request, "log.html", _log_page_context(target, is_paid_active(cu)),
)
@ -106,9 +106,8 @@ async def log_page_day(
cu: CurrentUser = Depends(require_auth),
):
target = await _resolve_log_date(session, day)
user_lang = cu.user.lang if cu.user else "en"
return templates.TemplateResponse(
request, "log.html", _log_page_context(target, is_paid_active(cu), user_lang),
request, "log.html", _log_page_context(target, is_paid_active(cu)),
)

View file

@ -19,7 +19,7 @@ from __future__ import annotations
import asyncio
import json
from typing import Any, Literal, Optional
from typing import Any, Literal
import stripe
from fastapi import APIRouter, Body, Depends, HTTPException, Request
@ -69,53 +69,6 @@ def _price_for(cadence: str) -> str:
raise HTTPException(status_code=400, detail="cadence must be 'monthly' or 'annual'")
# Rough country → currency mapping. Covers the markets we have a stated
# rate for; everything else falls back to GBP (the home currency) and
# Stripe handles the FX at checkout. Configure the per-currency
# unit_amount on each Price's `currency_options` in the Stripe Dashboard
# — we just signal which option to use here.
_COUNTRY_CURRENCY: dict[str, str] = {
"US": "usd", "CA": "usd",
"GB": "gbp", "IM": "gbp", "JE": "gbp", "GG": "gbp",
**dict.fromkeys((
"DE", "FR", "IT", "ES", "PT", "NL", "BE", "IE", "AT", "FI",
"GR", "LU", "MT", "CY", "EE", "LV", "LT", "SI", "SK", "HR",
), "eur"),
}
# Accept-Language locale → currency, used when CF-IPCountry is absent.
# Ambiguous locales (e.g. plain "fr" without region) get EUR because
# that's the majority outcome.
_LOCALE_CURRENCY: dict[str, str] = {
"en-gb": "gbp", "en": "gbp",
"en-us": "usd", "en-ca": "usd",
"fr": "eur", "de": "eur", "it": "eur", "es": "eur",
"pt": "eur", "nl": "eur",
}
def _sniff_currency(request: Request) -> str:
"""Best-effort currency detection for new-customer checkouts.
Order: explicit Cloudflare country header, then Accept-Language
(exact match then language-only). GBP as the final fallback. Only
consulted when the user has no Stripe customer record yet Stripe
locks currency at customer creation, so an existing customer's
currency wins regardless of the request locale.
"""
cc = (request.headers.get("cf-ipcountry") or "").upper()
if cc in _COUNTRY_CURRENCY:
return _COUNTRY_CURRENCY[cc]
al = (request.headers.get("accept-language") or "").lower()
first = al.split(",", 1)[0].split(";", 1)[0].strip()
if first in _LOCALE_CURRENCY:
return _LOCALE_CURRENCY[first]
short = first.split("-", 1)[0]
if short in _LOCALE_CURRENCY:
return _LOCALE_CURRENCY[short]
return "gbp"
def _stripe_client() -> stripe.StripeClient:
"""Per-call client so we read the secret at request time (lets us
rotate the key by editing .env + reloading without rebuilding any
@ -130,10 +83,6 @@ def _stripe_client() -> stripe.StripeClient:
class CheckoutRequest(BaseModel):
cadence: Literal["monthly", "annual"]
# Optional override; when omitted we sniff from request headers.
# Honoured only for first-time checkouts (Stripe locks currency
# to the customer at creation).
currency: Optional[Literal["gbp", "usd", "eur"]] = None
class CheckoutResponse(BaseModel):
@ -143,7 +92,6 @@ class CheckoutResponse(BaseModel):
@router.post("/api/stripe/checkout", response_model=CheckoutResponse)
async def create_checkout(
body: CheckoutRequest,
request: Request,
session: AsyncSession = Depends(get_session),
cu: CurrentUser = Depends(require_auth),
) -> CheckoutResponse:
@ -172,13 +120,6 @@ async def create_checkout(
# referral redemption flow ships.
"allow_promotion_codes": True,
}
# Multi-currency: for first-time buyers (no stripe_customer_id yet)
# we pass the detected/requested currency. Stripe picks the matching
# `currency_options` rate configured on the Price in the Dashboard,
# then locks that currency to the new customer record. Existing
# customers keep their original currency regardless.
if not user.stripe_customer_id:
create_kwargs["currency"] = body.currency or _sniff_currency(request)
# Per-cadence cooling-off treatment:
#
# - Annual gets a 14-day free trial. No money moves during the

View file

@ -20,7 +20,10 @@ Four routes:
held in memory for one LLM
call, discarded on response.
All routes require authentication (session cookie OR bearer token).
All routes require authentication (session cookie OR bearer token). The
old endpoints in `app/routers/api.py` (`/api/portfolios/upload`,
`/api/portfolio/{name}/summary`) remain live until step 10 of the Phase G
plan, when they're removed alongside the table drops.
"""
from __future__ import annotations
@ -33,7 +36,7 @@ from fastapi.responses import JSONResponse
from sqlalchemy import and_, func, select
from sqlalchemy.ext.asyncio import AsyncSession
from app.auth import CurrentUser, require_auth
from app.auth import require_auth
from app.config import get_settings
from app.db import get_session, utcnow
from app.logging import get_logger
@ -338,11 +341,10 @@ async def parse_portfolio(
# ---------------------------------------------------------------------------
@router.post("/analyze")
@router.post("/analyze", dependencies=[Depends(require_paid)])
async def analyze_portfolio(
request: Request,
session: AsyncSession = Depends(get_session),
principal: CurrentUser = Depends(require_paid),
) -> dict:
"""Generate AI commentary for the supplied pie. The pie is held in
memory only for the duration of the LLM call; nothing about holdings
@ -362,11 +364,6 @@ async def analyze_portfolio(
except Exception:
raise HTTPException(status_code=400, detail="malformed JSON body")
user_lang = (
principal.user.lang if (principal.user and principal.user.lang) else "en"
)
payload["lang"] = user_lang
try:
req = portfolio_analysis.parse_request(payload)
except ValueError as e:

View file

@ -221,4 +221,7 @@ def parse_t212_csv(content: str | bytes) -> ParsedPie:
)
# persist_pie removed in Phase G — the parsed pie is returned to the
# browser by /api/portfolio/parse and lives in localStorage. The server
# now keeps only the anonymous ticker_universe (see
# app/services/ticker_universe.py).

View file

@ -1,116 +0,0 @@
"""Daily/weekly digest email rendering.
Pure prose HTML/text rendering. SMTP transport stays in
``email_service.send_email``; this module only assembles the message
body, subject, and a text-only fallback for clients without HTML
rendering.
Split from email_service.py during the Tier 2 cleanup pass the
SMTP/OTP/welcome surface and the digest renderer changed at very
different cadences and made the file noisy to navigate.
"""
from __future__ import annotations
import html as _html_lib
import re as _re
from app import branding
_DIGEST_HTML_TEMPLATE = """\
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<meta name="color-scheme" content="light dark">
<title>{brand} {label}</title>
<style>
@media (prefers-color-scheme: dark) {{
body {{ background:{D_bg} !important; }}
.card {{ background:{D_surface} !important; border-color:{D_border} !important; }}
.h1, p, li {{ color:{D_text} !important; }}
.muted {{ color:{D_muted} !important; }}
a {{ color:{D_accent} !important; }}
}}
</style>
</head>
<body style="margin:0; padding:24px 12px; background:{L_bg}; font-family:{FONT_MONO}; color:{L_text};">
<table role="presentation" cellpadding="0" cellspacing="0" border="0" align="center" width="100%" style="max-width:520px; margin:0 auto; border-collapse:separate;">
<tr><td class="card" style="background:{L_surface}; border:1px solid {L_border}; padding:32px 28px;">
<div class="muted" style="font-size:11px; letter-spacing:0.32em; color:{L_muted}; text-transform:uppercase;">
&#9648;&nbsp;{brand_upper} &middot; {label_upper}
</div>
<div style="height:20px; line-height:20px; font-size:0;">&nbsp;</div>
<div class="content" style="font-size:14px; line-height:1.65; color:{L_text};">
{content_html}
</div>
<div style="height:24px; line-height:24px; font-size:0;">&nbsp;</div>
<div style="border-top:1px solid {L_border};"></div>
<div style="height:14px; line-height:14px; font-size:0;">&nbsp;</div>
<div class="muted" style="font-size:11px; color:{L_muted};">
<a href="{unsubscribe_url}" style="color:{L_accent};">Unsubscribe in one click</a>
&middot; <a href="{settings_url}" style="color:{L_accent};">Manage preferences</a>
</div>
</td></tr>
</table>
</body>
</html>
"""
def _strip_html_to_text(html_body: str) -> str:
"""Best-effort HTML → plain text for the multipart fallback. We don't
need perfection just readable prose for clients that won't render
HTML."""
text = _re.sub(r"(?i)<(/(p|h[1-6]|li|ul|ol)|br\s*/?)>", "\n", html_body)
text = _re.sub(r"<[^>]+>", "", text)
text = _html_lib.unescape(text)
text = _re.sub(r"\n{3,}", "\n\n", text)
return text.strip()
def render_digest_email(
*,
kind: str,
date_str: str,
content_html: str,
unsubscribe_url: str,
settings_url: str,
) -> tuple[str, str, str]:
"""Returns (subject, text_body, html_body) for a digest email.
`kind` is "daily" or "weekly". Anything else raises ValueError."""
if kind == "daily":
label = "Daily"
subject = f"{branding.BRAND_NAME} · Daily — {date_str}"
elif kind == "weekly":
label = "Weekly recap"
subject = f"{branding.BRAND_NAME} · Weekly recap — {date_str}"
else:
raise ValueError(f"unknown digest kind: {kind!r}")
html_body = _DIGEST_HTML_TEMPLATE.format(
brand=branding.BRAND_NAME,
brand_upper=branding.BRAND_NAME.upper(),
label=label,
label_upper=label.upper(),
FONT_MONO=branding.FONT_MONO,
content_html=content_html,
unsubscribe_url=unsubscribe_url,
settings_url=settings_url,
**{f"L_{k.replace('-', '_')}": v for k, v in branding.LIGHT.items()},
**{f"D_{k.replace('-', '_')}": v for k, v in branding.DARK.items()},
)
text_lines = [
f"{branding.BRAND_NAME}{label}",
date_str,
"",
_strip_html_to_text(content_html),
"",
f"Unsubscribe: {unsubscribe_url}",
f"Manage preferences: {settings_url}",
]
text_body = "\n".join(text_lines)
return subject, text_body, html_body

View file

@ -18,6 +18,8 @@ convenient for local dev that doesn't want a mail server configured.
"""
from __future__ import annotations
import html as _html_lib
import re as _re
from email.message import EmailMessage
import aiosmtplib
@ -321,3 +323,106 @@ async def send_welcome_email(to: str) -> None:
subject, text, html = render_welcome_email()
await send_email(to, subject, text, html_body=html)
# ---------------------------------------------------------------------------
# Digest email rendering
# ---------------------------------------------------------------------------
_DIGEST_HTML_TEMPLATE = """\
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<meta name="color-scheme" content="light dark">
<title>{brand} {label}</title>
<style>
@media (prefers-color-scheme: dark) {{
body {{ background:{D_bg} !important; }}
.card {{ background:{D_surface} !important; border-color:{D_border} !important; }}
.h1, p, li {{ color:{D_text} !important; }}
.muted {{ color:{D_muted} !important; }}
a {{ color:{D_accent} !important; }}
}}
</style>
</head>
<body style="margin:0; padding:24px 12px; background:{L_bg}; font-family:{FONT_MONO}; color:{L_text};">
<table role="presentation" cellpadding="0" cellspacing="0" border="0" align="center" width="100%" style="max-width:520px; margin:0 auto; border-collapse:separate;">
<tr><td class="card" style="background:{L_surface}; border:1px solid {L_border}; padding:32px 28px;">
<div class="muted" style="font-size:11px; letter-spacing:0.32em; color:{L_muted}; text-transform:uppercase;">
&#9648;&nbsp;{brand_upper} &middot; {label_upper}
</div>
<div style="height:20px; line-height:20px; font-size:0;">&nbsp;</div>
<div class="content" style="font-size:14px; line-height:1.65; color:{L_text};">
{content_html}
</div>
<div style="height:24px; line-height:24px; font-size:0;">&nbsp;</div>
<div style="border-top:1px solid {L_border};"></div>
<div style="height:14px; line-height:14px; font-size:0;">&nbsp;</div>
<div class="muted" style="font-size:11px; color:{L_muted};">
<a href="{unsubscribe_url}" style="color:{L_accent};">Unsubscribe in one click</a>
&middot; <a href="{settings_url}" style="color:{L_accent};">Manage preferences</a>
</div>
</td></tr>
</table>
</body>
</html>
"""
def _strip_html_to_text(html_body: str) -> str:
"""Best-effort HTML → plain text for the multipart fallback. We don't
need perfection just readable prose for clients that won't render
HTML."""
text = _re.sub(r"(?i)<(/(p|h[1-6]|li|ul|ol)|br\s*/?)>", "\n", html_body)
text = _re.sub(r"<[^>]+>", "", text)
text = _html_lib.unescape(text)
text = _re.sub(r"\n{3,}", "\n\n", text)
return text.strip()
def render_digest_email(
*,
kind: str,
date_str: str,
content_html: str,
unsubscribe_url: str,
settings_url: str,
) -> tuple[str, str, str]:
"""Returns (subject, text_body, html_body) for a digest email.
`kind` is "daily" or "weekly". Anything else raises ValueError."""
if kind == "daily":
label = "Daily"
subject = f"{branding.BRAND_NAME} · Daily — {date_str}"
elif kind == "weekly":
label = "Weekly recap"
subject = f"{branding.BRAND_NAME} · Weekly recap — {date_str}"
else:
raise ValueError(f"unknown digest kind: {kind!r}")
html_body = _DIGEST_HTML_TEMPLATE.format(
brand=branding.BRAND_NAME,
brand_upper=branding.BRAND_NAME.upper(),
label=label,
label_upper=label.upper(),
FONT_MONO=branding.FONT_MONO,
content_html=content_html,
unsubscribe_url=unsubscribe_url,
settings_url=settings_url,
**{f"L_{k.replace('-', '_')}": v for k, v in branding.LIGHT.items()},
**{f"D_{k.replace('-', '_')}": v for k, v in branding.DARK.items()},
)
text_lines = [
f"{branding.BRAND_NAME}{label}",
date_str,
"",
_strip_html_to_text(content_html),
"",
f"Unsubscribe: {unsubscribe_url}",
f"Manage preferences: {settings_url}",
]
text_body = "\n".join(text_lines)
return subject, text_body, html_body

View file

@ -10,8 +10,8 @@ The wrap markup is:
<span class="glossary" data-def="..." title="..." tabindex="0">VIX</span>
`title` gives a native fallback on touch devices that don't fire :hover.
The CSS tooltip (see `.glossary` / `#glossary-tooltip` in dashboard.css)
uses `data-def` for richer formatting. Wrapping happens at most once per term
The CSS tooltip (see `.glossary:hover::after` in cassandra.css) uses
`data-def` for richer formatting. Wrapping happens at most once per term
per HTML fragment repeated occurrences stay plain.
"""
from __future__ import annotations

View file

@ -1,48 +0,0 @@
"""Language registry + prompt helpers for localized AI output.
Two surfaces consume this module:
- Per-user LLM call sites (portfolio analysis only at this stage) call
``respond_in_clause(user.lang)`` and append the result to their
system prompt.
- The settings dropdown + its PATCH endpoint consult ``ACTIVE_LANGUAGES``
to decide which options are selectable. The strategic-log and digest
translation fan-outs also consult it to decide which languages to
spend tokens on.
Adding Spanish/French/German support later is a one-line constant
change: extend ``ACTIVE_LANGUAGES`` to include the new code. No other
code change is required the rest of the system already treats them
as first-class via ``LANGUAGES``.
"""
from __future__ import annotations
# Display labels for every language the system knows about. ES/FR/DE
# are kept here so labels still render in the dropdown (as disabled
# options) without requiring code changes to enable them later.
LANGUAGES: dict[str, str] = {
"en": "English",
"it": "Italian",
"es": "Spanish",
"fr": "French",
"de": "German",
}
# Languages users can actually select. Settings POST validates against
# this; the strategic-log + digest translation fan-outs only consider
# these.
ACTIVE_LANGUAGES: set[str] = {"en", "it"}
def respond_in_clause(lang: str | None) -> str:
"""Suffix appended to per-user LLM system prompts.
Returns an empty string for ``en`` (no nudge needed), an unknown
code, or ``None``/empty input those callers want the default
English path. Otherwise returns ``"\\n\\nRespond in <Language>."``
keyed off ``LANGUAGES``.
"""
if not lang or lang == "en" or lang not in LANGUAGES:
return ""
return f"\n\nRespond in {LANGUAGES[lang]}."

View file

@ -424,10 +424,8 @@ async def parse_with_llm(raw: bytes, session: AsyncSession) -> ParsedPie:
first_seen_at=now,
last_used_at=now,
use_count=1,
model=llm_log.model,
prompt_tokens=llm_log.prompt_tokens,
completion_tokens=llm_log.completion_tokens,
cost_usd=llm_log.cost_usd,
llm_model=llm_log.model,
llm_cost_usd=llm_log.cost_usd,
))
await session.commit()
return pie

View file

@ -1,620 +0,0 @@
"""Prompt-engineering surface for AI surfaces.
This module assembles the system + user prompts the LLM ingests. It
has no I/O pure string-building from typed inputs. Pair with
``app.services.openrouter`` (the transport layer) which actually
calls the model.
The two halves of LLM work what to ask vs how to ask change at
very different cadences. Prompt-version bumps (see PROMPT_VERSION
below) happen ~weekly; transport changes are rare.
"""
from __future__ import annotations
import json
from datetime import datetime
# Bump when the composed prompt changes meaningfully. Stored on every
# StrategicLog row so historical logs can be linked to the prompt that produced
# them.
#
# 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.
# v7 (2026-05-18): Forbid "(Updated HH:MM UTC)" clauses in the date header —
# the model was hallucinating future times. The user prompt now carries the
# actual current UTC time so the model has accurate temporal context.
# v9 (2026-05-25): Adds daily + weekly digest prompt builders for email.
PROMPT_VERSION = 9
# --- Core: invariant across tone/analysis settings ----------------------------
_CORE = """You are Cassandra, writing a single daily strategic markets log \
for one specific investor. Synthesis, not exposition.
# Lens
- Geopolitics markets is the primary causal chain. For each sector move, \
ask: geopolitical, cyclical, or idiosyncratic. Label it.
- Divergences and contradictions are where the information is. Hunt for them.
- Absence of expected moves is signal. If the thesis predicted a reaction \
that didn't happen, that's more interesting than the reactions that did.
- Compare live readings against any reference snapshots provided.
# Multi-source news
- When state-aligned outlets (Xinhua, China Daily, RT) and Western outlets \
cover the same event, read the gap in framing that's the data.
- News matters only insofar as it changes a market read. Color without \
implications is filler.
# Structure
- One-line date header containing ONLY the date (e.g. `2026-05-18`) and \
optional anchor framing on the same line (e.g. "Week 11 since Hormuz"). \
**Never include a time-of-day clause like "(Updated 21:30 UTC)"** \
generation time is recorded as metadata elsewhere. Inventing a future or \
arbitrary time in the header confuses readers.
- Immediately after the date header with **nothing** in between write a \
TL;DR. Format it as:
## TL;DR
One concise paragraph of 2-3 sentences, **60 words total**, naming the \
single most important read or divergence of the day with concrete numbers. \
This is what a reader who only has 10 seconds sees. Don't waste it on the \
weather or generic context.
- Then 4-6 paragraphs, each anchored on a sleeve, sector, or theme. Concrete \
numbers in every paragraph. No section over ~150 words.
- One paragraph synthesising the news flow into a market read.
- End with a watch list: 3-5 specific items to track in the next week, \
each one sentence.
# Time-horizon discipline
- This is a STRATEGIC log, not a day-trader's read. Treat 1-day moves under \
2% as background noise; mention them only when they break or confirm a \
multi-week trend or are extreme outliers.
- Anchor every claim to multi-week (1m), multi-month (since-anchor), or \
multi-year (1y) changes not 1d. If the only thing happening is a 1d move, \
omit the paragraph.
- The watch list is for "structural tripwires over the next 1-3 months", not \
"things to watch tomorrow". Each watch item should name a level/threshold \
whose breach would change the regime, not a calendar-date event.
# Rational vs irrational framing (MANDATORY in every paragraph)
The reader's primary goal is to disconnect rational decisions from market \
irrationality. This is the single most important lens of the log it MUST \
appear in every sector or theme paragraph, not just where it feels natural. \
For each paragraph, before writing it, ask yourself the two questions and \
then make both answers visible in the prose:
- The RATIONAL drivers what the underlying factors justify: earnings, \
real-economy data, monetary policy, structural geopolitical shifts, \
valuation vs fundamentals.
- The IRRATIONAL drivers what the crowd is doing regardless of fundamentals: \
positioning, narrative momentum, sentiment extremes, concentration, \
flow-driven moves, options gamma, credit complacency.
Then state the GAP: is price moving with the rational read, ahead of it, \
or against it? If they agree, say so briefly and move on. If they diverge \
price moving on irrational drivers while fundamentals say otherwise, or \
vice versa name the divergence explicitly. Those gaps are where the next \
regime change starts and are the whole point of this log.
A paragraph that names only price action or only fundamentals, without \
both lenses, is incomplete and must be rewritten.
# Discipline
- No emojis, no marketing language, no "concerning" or "unprecedented" \
without a specific number behind it.
- Concrete > vague. "AMD +113% since the anchor" beats "AI stocks up sharply".
- Distinguish "the thesis predicted X and X happened" from "the thesis \
predicted X and X did not happen". Both are useful; conflating them is not.
- Don't repeat the same point in different words across paragraphs.
- 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:
System temperature: [cool|neutral|elevated|hot|extreme] [one clause naming the 2-3 specific divergences or readings that justify the label]
This is the line a reader who only sees the watch list scrolls down to. Make \
it earn its place: cite real signals (HY OAS, breadth, VIX, valuation, real \
yields), not vibes.
# Update mode (when an earlier log from today is provided)
If the user message includes a section labelled "Earlier log from today \
(generated HH:MM UTC)", treat that as YOUR OWN earlier draft. You are \
UPDATING it for the current data, not starting from scratch.
- Don't restate context that hasn't changed. Anchor on what's moved SINCE \
that timestamp: confirmations, refutations, new emergent patterns.
- The TL;DR should lead with the move since the earlier read when there \
was a meaningful intra-day change ("Since this morning's read, …") \
otherwise stay regime-level.
- The watch list should evolve: drop items that triggered or settled, add \
items that emerged. Keep items still load-bearing.
- Preserve any insights from the earlier draft that remain valid; sharpen \
or revise the ones that don't. Avoid contradicting yourself silently — if \
you change a stance, name it briefly ("Earlier I read X; with Y now, the \
read shifts to Z")."""
# --- Tone: audience-shaping block --------------------------------------------
_TONE: dict[str, str] = {
"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.
- **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, 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").
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] = {
"DRY": """# Analysis style: dry
Report what happened. Identify divergences and contradictions. Compare to \
references. Do not speculate on what comes next. Forward-looking statements \
are limited to "what would invalidate the read" never "we expect X to \
happen". The watch list contains items to monitor, not predictions.""",
"SPECULATIVE": """# Analysis style: speculative
Report what happened, then explicitly explore forward scenarios. For each \
significant sector or theme, sketch a 1-4 week scenario set: the base case \
(what the data suggests), a contrarian case (what would invalidate it), and \
what tape signal would tip you from one to the other. Be explicit about \
uncertainty say "the base case is" not "X will happen". The watch list is \
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[_resolve_tone(tone)]
analysis_block = _ANALYSIS.get(analysis.upper(), _ANALYSIS["SPECULATIVE"])
return "\n\n".join([_CORE, tone_block, analysis_block])
# Backwards-compat: a default-composed SYSTEM_PROMPT for tests / callers that
# don't yet pass tone/analysis. New callers should call build_system_prompt().
SYSTEM_PROMPT = build_system_prompt("INTERMEDIATE", "SPECULATIVE")
# --- Chat-mode overrides (sidebar on /log) -----------------------------------
_CHAT_OVERRIDES = """# Chat mode (overrides the log-structure rules above)
You are NOT writing a daily log right now. The user is asking a specific
question via the chat sidebar.
- Forget the date header, TL;DR, sectional structure, and watch list. Just answer.
- Typical response: 200-400 words. Longer only if the question genuinely
warrants it.
- Cite specific numbers and named headlines from the reference materials
below whenever relevant. If a number isn't in the context, don't invent it.
- If a question is outside the provided context (e.g. asking about a stock or
event not in the data), say so plainly rather than speculating from prior
knowledge.
- No buy/sell recommendations. If asked, redirect to thesis and scenarios.
- Keep the same audience and analysis discipline established above."""
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.
Output is JSON-mode: the model must emit a single object
{"read": "..."}. The wrapper makes scratchpad outside the field
physically impossible the API enforces well-formed JSON, and the
only schema slot is the publishable read. Scratchpad inside the
field is caught by the reviewer agent (services/output_review)."""
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.
# Output format (strict)
Return ONLY a single JSON object with exactly one field:
{{"read": "<your 2-3 sentence interpretation>"}}
Nothing outside that JSON object. No preamble. No markdown fences. \
No additional fields. The "read" string is what the user sees verbatim, \
so it must already be the finished, publishable text never your thinking.
# What this is for
The reader is looking at the table of numbers right next to your text. \
They can see the values. They CANNOT see the meaning. Your job is to \
**explain what the data means**, not to recite it. Each sentence should be \
a regime-level interpretation, a fundamental driver identification, or a \
cross-indicator implication not a description of moves.
# Rational vs irrational lens (required at this length too)
Even at 2-3 sentences, contrast what the underlying factors justify \
(rational: fundamentals, policy, valuation) with what the crowd is doing \
(irrational: positioning, narrative, flows) whenever the two diverge. If \
they don't diverge, say so in one clause. Never just describe the move \
without placing it on this axis.
# Hard constraints on the "read" string
- Plain prose, ONE paragraph. No markdown, no headers, no lists, no labels.
- Open IMMEDIATELY with substance. NEVER start with: "I need to", "I'll", \
"We need to", "We are asked", "Here's", "Let me", "Let's", "Sure", "Looking \
at", "Based on", "Summary:", "The data shows", "First", "To address". No \
meta-commentary at all.
- No rhetorical questions, no "X? Actually Y?" self-corrections, no \
parenthetical asides that question your own numbers. The text is the \
finished read, not the thinking.
- Cite at most 2-3 specific numbers and ONLY when they anchor an \
interpretation. Don't list moves; explain them.
- Multi-week / multi-month horizon. 1-day moves under 2% are noise skip.
- No buy/sell language. No predictions. No watch list. No TL;DR. No date \
header. No "system temperature" line that belongs to the full daily log.
{tone_block}
{analysis_block}
"""
def build_summary_user_prompt(group_name: str, quotes: list[dict]) -> str:
parts = [
f"# Group: {group_name}",
"Indicators (latest reading + 1d/1m/1y/since-anchor change):",
"```json",
json.dumps(quotes, indent=2, default=str)[:12000],
"```",
"\nWrite the 2-3 sentence read for this group now.",
]
return "\n".join(parts)
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.
Same JSON-mode contract as build_summary_system_prompt: output is
{"read": "..."} only; the field is the publishable text verbatim."""
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 \
give them the meaning of the whole tape, not a recap.
# Output format (strict)
Return ONLY a single JSON object with exactly one field:
{{"read": "<your 2-4 sentence cross-asset interpretation>"}}
Nothing outside that JSON object. No preamble. No markdown fences. \
No additional fields. The "read" string is what the user sees verbatim.
# What this is for
The reader can see every indicator on the dashboard below this paragraph. \
Your job is NOT to summarise the moves. It is to explain what the moves, \
**taken together as a system**, mean: which regime is being signalled, \
which divergences are load-bearing, what fundamental story the cross-asset \
behaviour tells.
# Rational vs irrational lens (required at this length too)
The cross-asset tape's value is in the gap between what the underlying \
factors justify (rational: fundamentals, policy, valuation) and what the \
crowd is actually doing (irrational: positioning, narrative momentum, \
flows). At least one of the 2-4 sentences must name this gap or, if the \
two cohere, explicitly say so.
# Hard constraints on the "read" string
- Plain prose, ONE paragraph. No markdown, headers, lists, or labels.
- Open IMMEDIATELY with substance. NEVER start with: "I need to", "I'll", \
"We need to", "Here's", "Let me", "Looking at", "Based on", "Sure", "Summary:", \
"The data shows", "Across the board". No meta-commentary.
- No rhetorical questions, no "X? Actually Y?" self-corrections, no \
parenthetical asides that question your own numbers.
- Identify the single most important **cross-asset implication**: e.g. \
"rates and credit disagree", "equities outrun fundamentals", "geopolitical \
risk premium is in commodities but not vol". Cite no more than 3 specific \
numbers, and only as anchors for the interpretation.
- Multi-week / multi-month horizon. 1-day moves under 2% are noise.
- No buy/sell language. No predictions of specific levels.
{tone_block}
{analysis_block}
"""
def build_aggregate_summary_user_prompt(quotes_by_group: dict[str, list[dict]]) -> str:
parts = [
"# All indicator groups (latest readings + change windows)",
"```json",
json.dumps(quotes_by_group, indent=2, default=str)[:20000],
"```",
"\nWrite the cross-asset aggregate read now.",
]
return "\n".join(parts)
def build_chat_system_prompt(
tone: str,
analysis: str,
*,
log_content: str | None,
log_generated_at: datetime | None,
quotes_by_group: dict[str, list[dict]],
headlines: list[dict],
reference_line: str | None = None,
) -> str:
"""Composed system prompt for the /log chat sidebar. Carries the user's
chosen tone + analysis style and inlines the latest log + market data +
headlines as reference material the model can cite from."""
parts = [build_system_prompt(tone, analysis), "", _CHAT_OVERRIDES, ""]
if reference_line:
parts.append(f"# Doc reference snapshot\n{reference_line}\n")
if log_content:
ts = log_generated_at.strftime("%Y-%m-%d %H:%M UTC") if log_generated_at else "n/a"
parts.append(f"# Latest strategic log (generated {ts})\n\n{log_content}\n")
parts.append("# Live market data")
parts.append(
"```json\n" + json.dumps(quotes_by_group, indent=2, default=str)[:25000] + "\n```"
)
parts.append("# Recent headlines (last 24h, thesis-filtered top 50)")
for h in headlines[:50]:
parts.append(f"- [{h['source']}] {h['title']}")
return "\n".join(parts)
def build_user_prompt(
*,
today: datetime,
anchor: str | None,
quotes_by_group: dict[str, list[dict]],
headlines_by_bucket: dict[str, list[dict]],
reference_line: str | None = None,
previous_log: object | None = None,
) -> str:
"""Assemble the user message from already-fetched-and-persisted data.
If `previous_log` is a StrategicLog from earlier today, it's included
as 'Update mode' context the model will revise rather than restart."""
parts = [
f"# Strategic log request — {today.strftime('%Y-%m-%d')}",
# Explicit current time so the model doesn't hallucinate one. The
# date header it writes MUST stay date-only (per system prompt).
f"Current time: {today.strftime('%Y-%m-%d %H:%M UTC')}",
]
if anchor:
parts.append(f"Anchor reference date: {anchor}")
if reference_line:
parts.append(
"\n## Reference snapshot (when the macro thesis was authored)"
f"\n{reference_line}\nCompare live readings against it."
)
if previous_log is not None:
gen = getattr(previous_log, "generated_at", None)
ts = gen.strftime("%H:%M UTC") if gen else "earlier today"
parts.append(
f"\n## Earlier log from today (generated {ts})\n"
"Treat this as YOUR OWN earlier draft for today. Update it for\n"
"the current data — don't restate unchanged context. See the\n"
"'Update mode' section of the system prompt for how to handle it.\n"
"```markdown\n"
f"{previous_log.content}\n"
"```"
)
parts.append("\n## Live market data (per group)")
parts.append("```json\n" + json.dumps(quotes_by_group, indent=2, default=str) + "\n```")
parts.append("\n## News flow (last 24h, filtered by bucket)")
for label, items in headlines_by_bucket.items():
if not items:
continue
parts.append(f"\n### {label.upper()}")
for h in items[:30]:
parts.append(f"- [{h['when'][:16].replace('T',' ')}] [{h['source']}] {h['title']}")
task_line = (
"\n## Task\nWrite the daily strategic log in ~800 words, following "
"the discipline in the system prompt. No preamble; begin directly "
"with the date header."
)
if previous_log is not None:
task_line = (
"\n## Task\nUpdate the earlier log above for the current data. "
"Keep the same structure (date header, TL;DR, sections, watch "
"list, system temperature) but anchor on what has CHANGED since "
"the earlier draft's timestamp. ~800 words. No preamble."
)
parts.append(task_line)
return "\n".join(parts)
def _digest_tone_clause(tone: str) -> str:
if tone.upper() == "NOVICE":
return "Use plain English. Define any jargon on first use."
return "Write for a reader who already speaks markets fluently."
def build_daily_digest_prompt(
*,
tone: str,
today,
quotes_by_group: dict,
headlines_by_bucket: dict,
reference_line: str,
) -> tuple[str, str]:
"""System + user prompt for the once-a-day editorial digest.
Different from the hourly log: the daily digest reflects on the past
24h and looks forward to the upcoming session. Longer, less
'live-blogging,' more contextual. Target ~600 words."""
system = (
"You write the daily editorial digest for Read the Markets. "
f"Audience tone: {tone.upper()}. {_digest_tone_clause(tone)} "
"Cover: (1) what mattered yesterday, (2) what to watch in today's "
"EU and US sessions, (3) one cross-asset thread connecting them. "
"No predictions of price level, no buy/sell language. Target ~600 "
"words. Output HTML using only <p>, <h3>, <ul>, <li>, <strong>, "
"<em> — no <html>, <head>, or <body> wrapper, no inline styles."
)
user = _digest_user_prompt(
today=today, quotes_by_group=quotes_by_group,
headlines_by_bucket=headlines_by_bucket, reference_line=reference_line,
)
return system, user
def build_weekly_digest_prompt(
*,
tone: str,
today,
quotes_by_group: dict,
headlines_by_bucket: dict,
reference_line: str,
) -> tuple[str, str]:
"""System + user prompt for the Sunday weekly recap + look-ahead.
Sent to ALL opt-in users (free and paid). Target ~900 words."""
system = (
"You write the Sunday weekly digest for Read the Markets. "
f"Audience tone: {tone.upper()}. {_digest_tone_clause(tone)} "
"Cover: (1) the week behind — what moved and why, "
"(2) the week ahead — releases, earnings, central-bank meetings, "
"(3) the cross-asset story to keep in mind. "
"No predictions of price level, no buy/sell language. Target ~900 "
"words. Output HTML using only <p>, <h3>, <ul>, <li>, <strong>, "
"<em> — no <html>, <head>, or <body> wrapper, no inline styles."
)
user = _digest_user_prompt(
today=today, quotes_by_group=quotes_by_group,
headlines_by_bucket=headlines_by_bucket, reference_line=reference_line,
)
return system, user
def _digest_user_prompt(
*,
today,
quotes_by_group: dict,
headlines_by_bucket: dict,
reference_line: str,
) -> str:
"""Shared user-message body used by both digest prompts. Same data
shape as the hourly user prompt; reformatted for the digest context."""
today_str = today.strftime("%A %d %B %Y") if hasattr(today, "strftime") else str(today)
lines = [f"TODAY (UTC): {today_str}", "", f"REFERENCE: {reference_line}", ""]
if headlines_by_bucket:
lines.append("HEADLINES BY CATEGORY")
for cat, items in headlines_by_bucket.items():
lines.append(f" [{cat}]")
for h in items[:30]:
when = h.get("when", "")
src = h.get("source", "")
title = h.get("title", "")
lines.append(f" {when} · {src} · {title}")
lines.append("")
if quotes_by_group:
lines.append("LATEST QUOTES BY GROUP")
for grp, items in quotes_by_group.items():
lines.append(f" [{grp}]")
for q in items[:30]:
sym = q.get("symbol", "")
price = q.get("price", "")
lbl = q.get("label", "")
ccy = q.get("currency", "")
lines.append(f" {sym} ({lbl}) — {price} {ccy}")
lines.append("")
return "\n".join(lines)

View file

@ -1,8 +1,8 @@
"""LLM transport layer — OpenRouter / DeepSeek API calls.
"""Strategic-log generator — DB-fed, OpenRouter-backed.
Handles provider selection, retry + fallback machinery, and the monthly
budget-cap helpers. Prompt engineering lives in ``app.services.llm_prompts``;
this module only cares about *how* to reach the model, not *what to ask*.
Ported from /home/gg/ownCloud/Family/Finances/Wealth/strategic_log.py. The
system prompt is preserved verbatim (the voice we converged on). The user
prompt is now built from DB rows, not from subprocess JSON dumps.
"""
from __future__ import annotations
@ -18,31 +18,420 @@ from app.config import get_settings
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.
#
# 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.
# v7 (2026-05-18): Forbid "(Updated HH:MM UTC)" clauses in the date header —
# the model was hallucinating future times. The user prompt now carries the
# actual current UTC time so the model has accurate temporal context.
# v9 (2026-05-25): Adds daily + weekly digest prompt builders for email.
PROMPT_VERSION = 9
# Per-model USD rates: (input_per_million, output_per_million).
# OpenRouter returns `usage.cost` directly; DeepSeek's native API does not.
# Used as a fallback when the upstream omits the cost field.
_MODEL_PRICING_USD_PER_MILLION: dict[str, tuple[float, float]] = {
"deepseek-v4-flash": (0.07, 0.28),
"deepseek/deepseek-v4-flash": (0.07, 0.28),
"deepseek-chat": (0.27, 1.10),
"deepseek-reasoner": (0.55, 2.19),
# --- Core: invariant across tone/analysis settings ----------------------------
_CORE = """You are Cassandra, writing a single daily strategic markets log \
for one specific investor. Synthesis, not exposition.
# Lens
- Geopolitics markets is the primary causal chain. For each sector move, \
ask: geopolitical, cyclical, or idiosyncratic. Label it.
- Divergences and contradictions are where the information is. Hunt for them.
- Absence of expected moves is signal. If the thesis predicted a reaction \
that didn't happen, that's more interesting than the reactions that did.
- Compare live readings against any reference snapshots provided.
# Multi-source news
- When state-aligned outlets (Xinhua, China Daily, RT) and Western outlets \
cover the same event, read the gap in framing that's the data.
- News matters only insofar as it changes a market read. Color without \
implications is filler.
# Structure
- One-line date header containing ONLY the date (e.g. `2026-05-18`) and \
optional anchor framing on the same line (e.g. "Week 11 since Hormuz"). \
**Never include a time-of-day clause like "(Updated 21:30 UTC)"** \
generation time is recorded as metadata elsewhere. Inventing a future or \
arbitrary time in the header confuses readers.
- Immediately after the date header with **nothing** in between write a \
TL;DR. Format it as:
## TL;DR
One concise paragraph of 2-3 sentences, **60 words total**, naming the \
single most important read or divergence of the day with concrete numbers. \
This is what a reader who only has 10 seconds sees. Don't waste it on the \
weather or generic context.
- Then 4-6 paragraphs, each anchored on a sleeve, sector, or theme. Concrete \
numbers in every paragraph. No section over ~150 words.
- One paragraph synthesising the news flow into a market read.
- End with a watch list: 3-5 specific items to track in the next week, \
each one sentence.
# Time-horizon discipline
- This is a STRATEGIC log, not a day-trader's read. Treat 1-day moves under \
2% as background noise; mention them only when they break or confirm a \
multi-week trend or are extreme outliers.
- Anchor every claim to multi-week (1m), multi-month (since-anchor), or \
multi-year (1y) changes not 1d. If the only thing happening is a 1d move, \
omit the paragraph.
- The watch list is for "structural tripwires over the next 1-3 months", not \
"things to watch tomorrow". Each watch item should name a level/threshold \
whose breach would change the regime, not a calendar-date event.
# Rational vs irrational framing (MANDATORY in every paragraph)
The reader's primary goal is to disconnect rational decisions from market \
irrationality. This is the single most important lens of the log it MUST \
appear in every sector or theme paragraph, not just where it feels natural. \
For each paragraph, before writing it, ask yourself the two questions and \
then make both answers visible in the prose:
- The RATIONAL drivers what the underlying factors justify: earnings, \
real-economy data, monetary policy, structural geopolitical shifts, \
valuation vs fundamentals.
- The IRRATIONAL drivers what the crowd is doing regardless of fundamentals: \
positioning, narrative momentum, sentiment extremes, concentration, \
flow-driven moves, options gamma, credit complacency.
Then state the GAP: is price moving with the rational read, ahead of it, \
or against it? If they agree, say so briefly and move on. If they diverge \
price moving on irrational drivers while fundamentals say otherwise, or \
vice versa name the divergence explicitly. Those gaps are where the next \
regime change starts and are the whole point of this log.
A paragraph that names only price action or only fundamentals, without \
both lenses, is incomplete and must be rewritten.
# Discipline
- No emojis, no marketing language, no "concerning" or "unprecedented" \
without a specific number behind it.
- Concrete > vague. "AMD +113% since the anchor" beats "AI stocks up sharply".
- Distinguish "the thesis predicted X and X happened" from "the thesis \
predicted X and X did not happen". Both are useful; conflating them is not.
- Don't repeat the same point in different words across paragraphs.
- 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:
System temperature: [cool|neutral|elevated|hot|extreme] [one clause naming the 2-3 specific divergences or readings that justify the label]
This is the line a reader who only sees the watch list scrolls down to. Make \
it earn its place: cite real signals (HY OAS, breadth, VIX, valuation, real \
yields), not vibes.
# Update mode (when an earlier log from today is provided)
If the user message includes a section labelled "Earlier log from today \
(generated HH:MM UTC)", treat that as YOUR OWN earlier draft. You are \
UPDATING it for the current data, not starting from scratch.
- Don't restate context that hasn't changed. Anchor on what's moved SINCE \
that timestamp: confirmations, refutations, new emergent patterns.
- The TL;DR should lead with the move since the earlier read when there \
was a meaningful intra-day change ("Since this morning's read, …") \
otherwise stay regime-level.
- The watch list should evolve: drop items that triggered or settled, add \
items that emerged. Keep items still load-bearing.
- Preserve any insights from the earlier draft that remain valid; sharpen \
or revise the ones that don't. Avoid contradicting yourself silently — if \
you change a stance, name it briefly ("Earlier I read X; with Y now, the \
read shifts to Z")."""
# --- Tone: audience-shaping block --------------------------------------------
_TONE: dict[str, str] = {
"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.
- **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, 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").
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.""",
}
def _estimate_cost_usd(model: str, prompt_tokens, completion_tokens) -> float | None:
"""Compute cost from token counts when the upstream didn't return one.
# 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",
}
Returns None if either token count is missing or the model isn't in
the pricing table caller falls back to whatever value the upstream
did (or didn't) return.
"""
rates = _MODEL_PRICING_USD_PER_MILLION.get(model)
if rates is None or prompt_tokens is None or completion_tokens is None:
return None
in_rate, out_rate = rates
return (prompt_tokens * in_rate + completion_tokens * out_rate) / 1_000_000.0
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] = {
"DRY": """# Analysis style: dry
Report what happened. Identify divergences and contradictions. Compare to \
references. Do not speculate on what comes next. Forward-looking statements \
are limited to "what would invalidate the read" never "we expect X to \
happen". The watch list contains items to monitor, not predictions.""",
"SPECULATIVE": """# Analysis style: speculative
Report what happened, then explicitly explore forward scenarios. For each \
significant sector or theme, sketch a 1-4 week scenario set: the base case \
(what the data suggests), a contrarian case (what would invalidate it), and \
what tape signal would tip you from one to the other. Be explicit about \
uncertainty say "the base case is" not "X will happen". The watch list is \
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[_resolve_tone(tone)]
analysis_block = _ANALYSIS.get(analysis.upper(), _ANALYSIS["SPECULATIVE"])
return "\n\n".join([_CORE, tone_block, analysis_block])
# Backwards-compat: a default-composed SYSTEM_PROMPT for tests / callers that
# don't yet pass tone/analysis. New callers should call build_system_prompt().
SYSTEM_PROMPT = build_system_prompt("INTERMEDIATE", "SPECULATIVE")
# --- Chat-mode overrides (sidebar on /log) -----------------------------------
_CHAT_OVERRIDES = """# Chat mode (overrides the log-structure rules above)
You are NOT writing a daily log right now. The user is asking a specific
question via the chat sidebar.
- Forget the date header, TL;DR, sectional structure, and watch list. Just answer.
- Typical response: 200-400 words. Longer only if the question genuinely
warrants it.
- Cite specific numbers and named headlines from the reference materials
below whenever relevant. If a number isn't in the context, don't invent it.
- If a question is outside the provided context (e.g. asking about a stock or
event not in the data), say so plainly rather than speculating from prior
knowledge.
- No buy/sell recommendations. If asked, redirect to thesis and scenarios.
- Keep the same audience and analysis discipline established above."""
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[_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.
# What this is for
The reader is looking at the table of numbers right next to your text. \
They can see the values. They CANNOT see the meaning. Your job is to \
**explain what the data means**, not to recite it. Each sentence should be \
a regime-level interpretation, a fundamental driver identification, or a \
cross-indicator implication not a description of moves.
# Rational vs irrational lens (required at this length too)
Even at 2-3 sentences, contrast what the underlying factors justify \
(rational: fundamentals, policy, valuation) with what the crowd is doing \
(irrational: positioning, narrative, flows) whenever the two diverge. If \
they don't diverge, say so in one clause. Never just describe the move \
without placing it on this axis.
# Hard constraints
- Plain prose, ONE paragraph. No markdown, no headers, no lists, no labels.
- Open IMMEDIATELY with substance. NEVER start with: "I need to", "I'll", \
"We need to", "We are asked", "Here's", "Let me", "Let's", "Sure", "Looking \
at", "Based on", "Summary:", "The data shows", "First", "To address". No \
meta-commentary at all.
- Cite at most 2-3 specific numbers and ONLY when they anchor an \
interpretation. Don't list moves; explain them.
- Multi-week / multi-month horizon. 1-day moves under 2% are noise skip.
- No buy/sell language. No predictions. No watch list. No TL;DR. No date \
header. No "system temperature" line that belongs to the full daily log.
- Output the read directly. Do NOT include phrases like "Example", "Good \
example", "Bad example", "Reference", or any meta-framing of your output.
{tone_block}
{analysis_block}
"""
def build_summary_user_prompt(group_name: str, quotes: list[dict]) -> str:
parts = [
f"# Group: {group_name}",
"Indicators (latest reading + 1d/1m/1y/since-anchor change):",
"```json",
json.dumps(quotes, indent=2, default=str)[:12000],
"```",
"\nWrite the 2-3 sentence read for this group now.",
]
return "\n".join(parts)
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[_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 \
give them the meaning of the whole tape, not a recap.
# What this is for
The reader can see every indicator on the dashboard below this paragraph. \
Your job is NOT to summarise the moves. It is to explain what the moves, \
**taken together as a system**, mean: which regime is being signalled, \
which divergences are load-bearing, what fundamental story the cross-asset \
behaviour tells.
# Rational vs irrational lens (required at this length too)
The cross-asset tape's value is in the gap between what the underlying \
factors justify (rational: fundamentals, policy, valuation) and what the \
crowd is actually doing (irrational: positioning, narrative momentum, \
flows). At least one of the 2-4 sentences must name this gap or, if the \
two cohere, explicitly say so.
# Hard constraints
- Plain prose, ONE paragraph. No markdown, headers, lists, or labels.
- Open IMMEDIATELY with substance. NEVER start with: "I need to", "I'll", \
"We need to", "Here's", "Let me", "Looking at", "Based on", "Sure", "Summary:", \
"The data shows", "Across the board". No meta-commentary.
- Identify the single most important **cross-asset implication**: e.g. \
"rates and credit disagree", "equities outrun fundamentals", "geopolitical \
risk premium is in commodities but not vol". Cite no more than 3 specific \
numbers, and only as anchors for the interpretation.
- Multi-week / multi-month horizon. 1-day moves under 2% are noise.
- No buy/sell language. No predictions of specific levels.
- Output the read directly. Do NOT include phrases like "Example", "Good \
example", "Bad example", "Reference", or any meta-framing of your output.
{tone_block}
{analysis_block}
"""
def build_aggregate_summary_user_prompt(quotes_by_group: dict[str, list[dict]]) -> str:
parts = [
"# All indicator groups (latest readings + change windows)",
"```json",
json.dumps(quotes_by_group, indent=2, default=str)[:20000],
"```",
"\nWrite the cross-asset aggregate read now.",
]
return "\n".join(parts)
def build_chat_system_prompt(
tone: str,
analysis: str,
*,
log_content: str | None,
log_generated_at: datetime | None,
quotes_by_group: dict[str, list[dict]],
headlines: list[dict],
reference_line: str | None = None,
) -> str:
"""Composed system prompt for the /log chat sidebar. Carries the user's
chosen tone + analysis style and inlines the latest log + market data +
headlines as reference material the model can cite from."""
parts = [build_system_prompt(tone, analysis), "", _CHAT_OVERRIDES, ""]
if reference_line:
parts.append(f"# Doc reference snapshot\n{reference_line}\n")
if log_content:
ts = log_generated_at.strftime("%Y-%m-%d %H:%M UTC") if log_generated_at else "n/a"
parts.append(f"# Latest strategic log (generated {ts})\n\n{log_content}\n")
parts.append("# Live market data")
parts.append(
"```json\n" + json.dumps(quotes_by_group, indent=2, default=str)[:25000] + "\n```"
)
parts.append("# Recent headlines (last 24h, thesis-filtered top 50)")
for h in headlines[:50]:
parts.append(f"- [{h['source']}] {h['title']}")
return "\n".join(parts)
@dataclass
@ -54,6 +443,172 @@ class LogResult:
cost_usd: float | None
def build_user_prompt(
*,
today: datetime,
anchor: str | None,
quotes_by_group: dict[str, list[dict]],
headlines_by_bucket: dict[str, list[dict]],
reference_line: str | None = None,
previous_log: object | None = None,
) -> str:
"""Assemble the user message from already-fetched-and-persisted data.
If `previous_log` is a StrategicLog from earlier today, it's included
as 'Update mode' context the model will revise rather than restart."""
parts = [
f"# Strategic log request — {today.strftime('%Y-%m-%d')}",
# Explicit current time so the model doesn't hallucinate one. The
# date header it writes MUST stay date-only (per system prompt).
f"Current time: {today.strftime('%Y-%m-%d %H:%M UTC')}",
]
if anchor:
parts.append(f"Anchor reference date: {anchor}")
if reference_line:
parts.append(
"\n## Reference snapshot (when the macro thesis was authored)"
f"\n{reference_line}\nCompare live readings against it."
)
if previous_log is not None:
gen = getattr(previous_log, "generated_at", None)
ts = gen.strftime("%H:%M UTC") if gen else "earlier today"
parts.append(
f"\n## Earlier log from today (generated {ts})\n"
"Treat this as YOUR OWN earlier draft for today. Update it for\n"
"the current data — don't restate unchanged context. See the\n"
"'Update mode' section of the system prompt for how to handle it.\n"
"```markdown\n"
f"{previous_log.content}\n"
"```"
)
parts.append("\n## Live market data (per group)")
parts.append("```json\n" + json.dumps(quotes_by_group, indent=2, default=str) + "\n```")
parts.append("\n## News flow (last 24h, filtered by bucket)")
for label, items in headlines_by_bucket.items():
if not items:
continue
parts.append(f"\n### {label.upper()}")
for h in items[:30]:
parts.append(f"- [{h['when'][:16].replace('T',' ')}] [{h['source']}] {h['title']}")
task_line = (
"\n## Task\nWrite the daily strategic log in ~800 words, following "
"the discipline in the system prompt. No preamble; begin directly "
"with the date header."
)
if previous_log is not None:
task_line = (
"\n## Task\nUpdate the earlier log above for the current data. "
"Keep the same structure (date header, TL;DR, sections, watch "
"list, system temperature) but anchor on what has CHANGED since "
"the earlier draft's timestamp. ~800 words. No preamble."
)
parts.append(task_line)
return "\n".join(parts)
def _digest_tone_clause(tone: str) -> str:
if tone.upper() == "NOVICE":
return "Use plain English. Define any jargon on first use."
return "Write for a reader who already speaks markets fluently."
def build_daily_digest_prompt(
*,
tone: str,
today,
quotes_by_group: dict,
headlines_by_bucket: dict,
reference_line: str,
) -> tuple[str, str]:
"""System + user prompt for the once-a-day editorial digest.
Different from the hourly log: the daily digest reflects on the past
24h and looks forward to the upcoming session. Longer, less
'live-blogging,' more contextual. Target ~600 words."""
system = (
"You write the daily editorial digest for Read the Markets. "
f"Audience tone: {tone.upper()}. {_digest_tone_clause(tone)} "
"Cover: (1) what mattered yesterday, (2) what to watch in today's "
"EU and US sessions, (3) one cross-asset thread connecting them. "
"No predictions of price level, no buy/sell language. Target ~600 "
"words. Output HTML using only <p>, <h3>, <ul>, <li>, <strong>, "
"<em> — no <html>, <head>, or <body> wrapper, no inline styles."
)
user = _digest_user_prompt(
today=today, quotes_by_group=quotes_by_group,
headlines_by_bucket=headlines_by_bucket, reference_line=reference_line,
)
return system, user
def build_weekly_digest_prompt(
*,
tone: str,
today,
quotes_by_group: dict,
headlines_by_bucket: dict,
reference_line: str,
) -> tuple[str, str]:
"""System + user prompt for the Sunday weekly recap + look-ahead.
Sent to ALL opt-in users (free and paid). Target ~900 words."""
system = (
"You write the Sunday weekly digest for Read the Markets. "
f"Audience tone: {tone.upper()}. {_digest_tone_clause(tone)} "
"Cover: (1) the week behind — what moved and why, "
"(2) the week ahead — releases, earnings, central-bank meetings, "
"(3) the cross-asset story to keep in mind. "
"No predictions of price level, no buy/sell language. Target ~900 "
"words. Output HTML using only <p>, <h3>, <ul>, <li>, <strong>, "
"<em> — no <html>, <head>, or <body> wrapper, no inline styles."
)
user = _digest_user_prompt(
today=today, quotes_by_group=quotes_by_group,
headlines_by_bucket=headlines_by_bucket, reference_line=reference_line,
)
return system, user
def _digest_user_prompt(
*,
today,
quotes_by_group: dict,
headlines_by_bucket: dict,
reference_line: str,
) -> str:
"""Shared user-message body used by both digest prompts. Same data
shape as the hourly user prompt; reformatted for the digest context."""
today_str = today.strftime("%A %d %B %Y") if hasattr(today, "strftime") else str(today)
lines = [f"TODAY (UTC): {today_str}", "", f"REFERENCE: {reference_line}", ""]
if headlines_by_bucket:
lines.append("HEADLINES BY CATEGORY")
for cat, items in headlines_by_bucket.items():
lines.append(f" [{cat}]")
for h in items[:30]:
when = h.get("when", "")
src = h.get("source", "")
title = h.get("title", "")
lines.append(f" {when} · {src} · {title}")
lines.append("")
if quotes_by_group:
lines.append("LATEST QUOTES BY GROUP")
for grp, items in quotes_by_group.items():
lines.append(f" [{grp}]")
for q in items[:30]:
sym = q.get("symbol", "")
price = q.get("price", "")
lbl = q.get("label", "")
ccy = q.get("currency", "")
lines.append(f" {sym} ({lbl}) — {price} {ccy}")
lines.append("")
return "\n".join(lines)
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)."""
@ -136,15 +691,10 @@ async def _call_provider(
messages: list[dict],
model: str | None,
max_tokens: int,
response_format: dict | None = None,
) -> LogResult:
"""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.
`response_format` is forwarded to the provider verbatim DeepSeek and
OpenRouter both accept the OpenAI-shaped {"type": "json_object"} for
JSON-mode generation. None means free-form text."""
not across the fallback chain."""
url, api_key, default_model, extra_headers = _endpoint_for(provider)
used_model = model or default_model
headers = {
@ -152,22 +702,18 @@ async def _call_provider(
"Content-Type": "application/json",
**extra_headers,
}
body: dict = {"model": used_model, "messages": messages, "max_tokens": max_tokens}
if response_format is not None:
body["response_format"] = response_format
r = await client.post(url, headers=headers, json=body, timeout=180)
r = await client.post(
url,
headers=headers,
json={"model": used_model, "messages": messages, "max_tokens": max_tokens},
timeout=180,
)
r.raise_for_status()
data = r.json()
msg = data["choices"][0]["message"]
# The `content` field is the model's user-facing answer. The optional
# `reasoning` field is the model's internal chain-of-thought — never
# safe to publish; it contains raw scratchpad ("Let's see…",
# mid-sentence question marks, planning notes). If `content` is empty
# (provider issue, finish_reason=length cutoff, or the model spent
# its budget on thinking), treat that as a generation failure and
# raise so the caller can retry or skip the row. Do NOT fall back to
# reasoning — see the 2026-05-29 valuation-read leak.
content = msg.get("content")
# Some providers return null content + populated `reasoning` for thinking
# models, or null content when finish_reason=length cut off the response.
content = msg.get("content") or msg.get("reasoning")
if not content:
finish = data["choices"][0].get("finish_reason")
raise RuntimeError(
@ -175,21 +721,13 @@ async def _call_provider(
f"provider={provider}, model={used_model}, max_tokens={max_tokens})"
)
usage = data.get("usage") or {}
prompt_tokens = usage.get("prompt_tokens")
completion_tokens = usage.get("completion_tokens")
# OpenRouter populates `usage.cost`; DeepSeek's native API doesn't —
# estimate from tokens × per-model rates so the cost ledger stays
# populated regardless of which provider answered.
cost_usd = usage.get("cost") or usage.get("total_cost")
if cost_usd is None:
cost_usd = _estimate_cost_usd(used_model, prompt_tokens, completion_tokens)
return LogResult(
content=content,
# Record provider+model so admin can see which path produced this row.
model=f"{provider}/{used_model}",
prompt_tokens=prompt_tokens,
completion_tokens=completion_tokens,
cost_usd=cost_usd,
prompt_tokens=usage.get("prompt_tokens"),
completion_tokens=usage.get("completion_tokens"),
cost_usd=usage.get("cost") or usage.get("total_cost"),
)
@ -198,8 +736,6 @@ async def call_llm(
messages: list[dict],
model: str | None = None,
max_tokens: int = 4000,
response_format: dict | None = None,
provider: str | None = None,
) -> LogResult:
"""Provider-aware chat completion with fallback. Tries primary
(LLM_PROVIDER) first; if it raises after retries, falls through to
@ -208,19 +744,7 @@ async def call_llm(
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.
Pass response_format={"type": "json_object"} to force JSON-mode
output (the model still needs to be instructed in the system prompt
to emit valid JSON this flag enforces, not asks).
Pass `provider` (e.g. "openrouter") to skip the configured chain
and pin the call to a specific provider. Used by the reviewer agent
to force routing through OpenRouter so it can address a non-DeepSeek
model that doesn't pre-think before emitting JSON."""
if provider is not None:
chain = [provider]
else:
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)")
@ -230,7 +754,6 @@ async def call_llm(
try:
result = await _call_provider(
client, provider, messages, model, max_tokens,
response_format=response_format,
)
if i > 0:
from app.logging import get_logger
@ -252,6 +775,10 @@ async def call_llm(
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)

View file

@ -1,162 +0,0 @@
"""Second-pass reviewer agent for AI-generated reads.
The per-group and aggregate indicator summaries are generated in JSON
mode and the publishable text comes out of a single "read" field, but a
misbehaving model can still slip chain-of-thought INSIDE the field
("Let's see…", "X? Actually Y?", multi-question parentheticals). This
module makes a small second LLM call that judges the candidate read as
clean / unclean. Cost is ~$0.0001 per check; latency ~1-2 s in the
hourly job. No user-facing latency.
The reviewer is deliberately a tiny, JSON-shaped classifier same
JSON-mode mechanism as the generator, so the verdict can't be lost in
prose. If parsing fails or the call errors, the row is rejected
(fail-safe: the previously cached good summary stays visible).
"""
from __future__ import annotations
import json
from dataclasses import dataclass
import httpx
from app.config import get_settings
from app.logging import get_logger
from app.services.openrouter import call_llm
log = get_logger("output_review")
# The reviewer runs through OpenRouter against a small, non-thinking
# model. DeepSeek-V4-flash (our generator default) emits internal
# chain-of-thought before its JSON output even when the prompt forbids
# it, which truncates the JSON at any reasonable max_tokens cap and
# breaks the parser. Anthropic's Haiku family answers structured-output
# tasks tersely and deterministically — no chain-of-thought tax. Cost
# is ~$0.0001-$0.0003 per review depending on candidate length.
DEFAULT_REVIEWER_MODEL = "anthropic/claude-haiku-4.5"
_SYSTEM_PROMPT = """\
You are a strict editor for a financial-markets dashboard. The author
was asked to produce editorial commentary on public market data for
human readers. You receive the proposed text it may be a one-line
read, a multi-paragraph daily log, a portfolio analysis, a chat
reply, or an email digest and decide if it is publishable as-is.
Mark CLEAN only if the text reads like finished editorial commentary
a reader could see on a public dashboard without confusion.
Mark UNCLEAN if the text contains ANY of:
- Chain-of-thought / scratchpad markers the author thinking on the
page rather than presenting finished commentary. Phrases like
"Let me", "Let's see", "we need to", "actually" (correcting itself),
"wait", "hmm", "or rather", "I should". Rhetorical questions used
as structure are fine; questions that the author then answers in
front of the reader (self-questioning) are not.
- Self-questioning parentheticals: "Q1 2026? Actually Q4 2025?",
"is it X or Y?", any place where the author appears to be working
out the answer in front of the reader.
- Meta-commentary about the task, output format, word limits, or
instructions e.g. "as required by the constraints", "the prompt
asks", "let me address each".
- Partial / truncated content. Starts mid-word, mid-number, mid-clause,
ends mid-thought.
- Visible internal numbers without clear meaning ("change 1y +5.9%?"),
raw column names ("as_of 2026-01-01"), or any debug-like fragments.
- FINANCIAL ADVICE or any phrasing that recommends an action the
reader should take. This service is editorial commentary on public
data, not investment advice; the operator is not licensed to give
it. Reject any of:
* Buy/sell/hold/accumulate/trim/exit/enter/rotate language.
* Allocation guidance ("overweight", "underweight",
"X% in bonds", "increase exposure to").
* Price targets or specific level predictions ("will reach $X",
"target Y", "expect Z by year-end").
* Personalised framing ("you should", "investors should",
"consider buying", "we recommend").
DESCRIPTIVE / INTERPRETIVE language about market state is fine
"valuations are stretched", "real yields are restrictive", "rates
and credit disagree". The test: does the text describe a STATE, or
does it suggest an ACTION? States are fine; actions are not.
- Anything else other than the finished, publishable commentary.
Return ONLY a JSON object with this exact shape:
{"clean": true | false, "reason": "<≤20 words, plain text>"}
No preamble, no markdown fences, no other fields.
"""
@dataclass(frozen=True)
class Verdict:
clean: bool
reason: str
cost_usd: float | None # cost of the review call itself, for the ledger
async def review_read(client: httpx.AsyncClient, candidate: str) -> Verdict:
"""Ask the LLM whether `candidate` is a publishable read.
Returns Verdict(clean, reason, cost). Any error provider failure,
JSON parse failure, missing field, wrong type yields a CONSERVATIVE
verdict (clean=False) so the caller drops the candidate. The
previously cached good summary stays visible on the dashboard."""
if not candidate or not candidate.strip():
return Verdict(clean=False, reason="empty candidate", cost_usd=0.0)
messages = [
{"role": "system", "content": _SYSTEM_PROMPT},
# Sent as a fenced user turn so the model can't confuse the
# candidate with instructions, even if the candidate happens to
# contain prompt-like prose.
{"role": "user", "content": f"Candidate read:\n```\n{candidate}\n```"},
]
settings = get_settings()
reviewer_model = getattr(settings, "REVIEWER_MODEL", None) or DEFAULT_REVIEWER_MODEL
try:
result = await call_llm(
client, messages,
# Pin to OpenRouter so a non-DeepSeek model like Haiku is
# actually reachable; the default provider chain would try
# DeepSeek native first and 404 on the Anthropic model name.
provider="openrouter",
model=reviewer_model,
# 300 tokens is well above the ~30-token JSON verdict.
# Haiku doesn't pad with hidden reasoning the way DeepSeek
# does, so we don't need the 800-token headroom required to
# absorb the generator's chain-of-thought.
max_tokens=300,
response_format={"type": "json_object"},
)
except Exception as e:
log.warning("review.call_failed", error=str(e)[:200])
return Verdict(clean=False, reason=f"reviewer error: {str(e)[:80]}",
cost_usd=None)
# Haiku (and several other models) occasionally wrap their JSON
# output in a markdown code fence even with response_format set —
# ```json\n{...}\n``` — so strip a single leading/trailing fence
# before parsing. We do this defensively for any model; it's a
# no-op for callers that already emit bare JSON.
raw = result.content.strip()
if raw.startswith("```"):
first_nl = raw.find("\n")
if first_nl != -1:
raw = raw[first_nl + 1:]
if raw.rstrip().endswith("```"):
raw = raw.rstrip()[:-3].rstrip()
raw = raw.strip()
try:
parsed = json.loads(raw)
except json.JSONDecodeError:
log.warning("review.parse_failed", preview=result.content[:200])
return Verdict(clean=False, reason="reviewer returned non-JSON",
cost_usd=result.cost_usd)
clean = parsed.get("clean")
reason = parsed.get("reason") or ""
if not isinstance(clean, bool):
return Verdict(clean=False, reason="reviewer omitted bool 'clean'",
cost_usd=result.cost_usd)
return Verdict(clean=clean, reason=str(reason)[:200], cost_usd=result.cost_usd)

View file

@ -31,12 +31,10 @@ from app.config import get_settings
from app.db import utcnow
from app.logging import get_logger
from app.models import AICall
from app.services.i18n import LANGUAGES, respond_in_clause
from app.services.llm_prompts import build_system_prompt
from app.services.output_review import review_read
from app.services.openrouter import (
LogResult,
active_model,
build_system_prompt,
call_llm,
)
@ -76,7 +74,6 @@ class AnalysisRequest:
anchor: str | None = None
tone: str = "INTERMEDIATE" # NOVICE | INTERMEDIATE | PRO
analysis: str = "SPECULATIVE" # DRY | SPECULATIVE
lang: str = "en"
@dataclass
@ -166,13 +163,10 @@ def parse_request(payload: dict) -> AnalysisRequest:
anchor = _sanitise_text(payload.get("anchor") or "", 32) or None
tone = _sanitise_text(payload.get("tone", "INTERMEDIATE"), 16) or "INTERMEDIATE"
analysis = _sanitise_text(payload.get("analysis", "SPECULATIVE"), 16) or "SPECULATIVE"
lang = (payload.get("lang") or "en").strip().lower()
if lang not in LANGUAGES:
lang = "en"
return AnalysisRequest(
positions=positions, prices=prices, base_currency=base_currency,
anchor=anchor, tone=tone, analysis=analysis, lang=lang,
anchor=anchor, tone=tone, analysis=analysis,
)
@ -282,7 +276,7 @@ def build_prompt(req: AnalysisRequest) -> tuple[str, str]:
head = enriched[:MAX_POSITIONS_INLINED]
tail_count = max(0, len(enriched) - MAX_POSITIONS_INLINED)
system = build_system_prompt(req.tone, req.analysis) + "\n\n" + _SYSTEM_OVERRIDES + respond_in_clause(req.lang)
system = build_system_prompt(req.tone, req.analysis) + "\n\n" + _SYSTEM_OVERRIDES
user_parts = [
f"# Portfolio commentary request — {utcnow().strftime('%Y-%m-%d')}",
@ -323,8 +317,6 @@ async def analyse(
s = get_settings()
system, user = build_prompt(req)
review_cost = 0.0
review_reason: str | None = None
async with httpx.AsyncClient() as client:
try:
llm: LogResult = await call_llm(
@ -343,31 +335,15 @@ async def analyse(
llm = None
log.error("portfolio_analysis.failed", error=error_msg)
# Reviewer gate. This is the highest-risk surface — the model is
# commenting on a real user's holdings, so any drift into
# buy/sell or allocation language is a regulatory hazard. Drop
# the response on a reject and surface a retry-able error to the
# caller; no analysis is ever persisted server-side anyway.
if llm is not None:
verdict = await review_read(client, llm.content)
review_cost = verdict.cost_usd or 0.0
if not verdict.clean:
status = "leaked"
error_msg = f"reviewer rejected: {verdict.reason}"
review_reason = verdict.reason
log.warning("portfolio_analysis.reviewer_rejected",
reason=verdict.reason, preview=llm.content[:120])
full_cost = ((llm.cost_usd or 0.0) + review_cost) if llm else None
# Ledger row — NO portfolio data, just metadata. Same row whether the
# call succeeded, failed, or was rejected by the reviewer, so
# cost-cap and rate-limit logic can observe the attempt.
# call succeeded or failed, so cost-cap and rate-limit logic can
# observe the attempt.
session.add(AICall(
called_at=utcnow(),
model=llm.model if llm else active_model(),
prompt_tokens=llm.prompt_tokens if llm else None,
completion_tokens=llm.completion_tokens if llm else None,
cost_usd=full_cost,
cost_usd=llm.cost_usd if llm else None,
status=status,
error=error_msg,
))
@ -375,26 +351,19 @@ async def analyse(
if llm is None:
raise RuntimeError(error_msg or "portfolio analysis failed")
if review_reason is not None:
# Reviewer rejected the candidate. Treat as a generation failure
# at the API layer so the user sees a retry-able error rather
# than potentially non-compliant advice.
raise RuntimeError(
"AI analysis couldn't be generated cleanly — please try again."
)
log.info(
"portfolio_analysis.ok",
n_positions=len(req.positions),
prompt_tokens=llm.prompt_tokens,
completion_tokens=llm.completion_tokens,
cost_usd=full_cost,
cost_usd=llm.cost_usd,
)
return AnalysisResult(
content=llm.content,
model=llm.model,
prompt_tokens=llm.prompt_tokens,
completion_tokens=llm.completion_tokens,
cost_usd=full_cost,
cost_usd=llm.cost_usd,
generated_at=datetime.now(timezone.utc),
)

View file

@ -1,88 +0,0 @@
"""Markdown translation via the existing LLM provider chain.
DeepSeek-4-flash at ~$0.28/M output tokens is cheap enough that we
don't bother with a separate translation-only model. ``call_llm``'s
provider chain (DeepSeek primary, OpenRouter fallback) handles this
path identically to any other LLM call.
The translator is content-aware in one important way: it instructs the
model to preserve markdown structure, ticker symbols, numbers, dates,
and percentages verbatim. This keeps generated artefacts (tables of
quotes, embedded percentages, dated references) intact across the
translation boundary.
"""
from __future__ import annotations
import httpx
from app.services.i18n import LANGUAGES
from app.services.openrouter import LogResult, call_llm
_SYSTEM_PROMPT_TMPL = """\
You are an expert translator working on financial-markets commentary.
Translate the following markdown text to {language}.
Strict rules:
- Preserve ALL markdown formatting (headings, lists, emphasis, links,
tables, code spans).
- Do NOT translate ticker symbols (AAPL, MSFT, VOD.L, ASML.AS, etc.),
company legal names, percentages, dates, ISO currency codes, or any
numbers.
- Do NOT add commentary, preambles, or apologies. Output ONLY the
translated markdown.
"""
async def translate(
client: httpx.AsyncClient,
text: str,
target_lang: str,
) -> tuple[str, LogResult]:
"""Translate markdown ``text`` to ``target_lang``.
Returns ``(translated_markdown, LogResult)``. Caller persists the
cost/model provenance from LogResult next to the cached row.
Short-circuits without calling the LLM when ``target_lang`` is
``'en'``, unknown, or empty returns the source unchanged with a
zero-cost stub LogResult. This lets fan-out callers iterate over
all languages without per-call gating.
Raises on provider failure (HTTP error, all chain providers down).
Callers in fan-out paths should catch and log per-language.
"""
if not target_lang or target_lang == "en" or target_lang not in LANGUAGES:
# No-op fast path. Returning a fake LogResult keeps the call
# signature stable for callers who unpack the tuple.
return text, LogResult(
content=text, model="noop",
prompt_tokens=0, completion_tokens=0, cost_usd=0.0,
)
system_prompt = _SYSTEM_PROMPT_TMPL.format(language=LANGUAGES[target_lang])
messages = [
{"role": "system", "content": system_prompt},
{"role": "user", "content": text},
]
# Italian / Spanish / French / German typically expand the token count
# 15-25 % over English (longer words, more sub-word splits). Our
# strategic-log generator runs up to its own 4000-token cap, so a 4000
# cap here would silently truncate any near-cap source. 8000 gives
# ample headroom for every language we currently support and costs
# nothing extra unless the model actually emits more tokens.
result = await call_llm(client, messages, max_tokens=8000)
content = (result.content or "").strip()
# Strip code fences if the model wrapped its output despite the system rule.
if content.startswith("```"):
# Drop the opening fence (with optional language tag).
first_nl = content.find("\n")
if first_nl != -1:
content = content[first_nl + 1:]
# Drop the closing fence.
if content.rstrip().endswith("```"):
content = content.rstrip()[:-3].rstrip()
content = content.strip()
return content, result

View file

@ -1,149 +0,0 @@
/* Cassandra — auth pages: login, sign-up, OTP verify (standalone, no app chrome). */
/* --- Auth pages (login / signup, standalone — no app chrome) -------- */
.auth-shell {
min-height: 100vh;
display: flex;
align-items: center;
justify-content: center;
background: var(--bg);
padding: 20px;
}
.auth-card {
width: 360px;
max-width: 100%;
background: var(--surface);
border: 1px solid var(--border);
padding: 28px 26px;
}
.auth-card__brand {
font-family: var(--font-mono);
color: var(--accent);
font-size: 18px;
letter-spacing: 0.12em;
text-transform: uppercase;
font-weight: 700;
}
.auth-card__brand::before { content: "▰ "; opacity: 0.6; }
.auth-card__hint {
font-family: var(--font-mono);
color: var(--muted);
font-size: 10px;
text-transform: uppercase;
letter-spacing: 0.08em;
margin: 2px 0 18px;
}
.auth-card form { display: flex; flex-direction: column; gap: 12px; }
.auth-card label {
display: flex;
flex-direction: column;
font-family: var(--font-mono);
color: var(--muted);
font-size: 10px;
text-transform: uppercase;
letter-spacing: 0.06em;
gap: 4px;
}
.auth-card input[type="email"],
.auth-card input[type="password"],
.auth-card input[type="text"] {
background: var(--bg);
border: 1px solid var(--border);
color: var(--text);
font-family: var(--font-mono);
font-size: 16px;
padding: 12px 14px;
outline: none;
border-radius: 3px;
}
/* The 6-digit OTP input wants to be visually loud it's the only
thing the user is doing on that page. Bigger, more spacing, taller. */
.auth-card input[name="code"] {
font-size: 24px;
padding: 16px 14px;
letter-spacing: 0.5em;
text-align: center;
}
.auth-card input:focus { border-color: var(--accent); }
.auth-card button {
margin-top: 8px;
background: transparent;
border: 1px solid var(--accent);
color: var(--accent);
font-family: var(--font-mono);
font-size: 11px;
padding: 9px 12px;
text-transform: uppercase;
letter-spacing: 0.1em;
cursor: pointer;
}
.auth-card button:hover { background: var(--accent); color: var(--bg); }
.auth-card__alt {
margin-top: 18px;
font-size: 12px;
color: var(--muted);
text-align: center;
}
.auth-error {
border-left: 3px solid var(--negative);
background: color-mix(in srgb, var(--negative) 6%, transparent);
color: var(--negative);
padding: 8px 10px;
font-size: 12px;
margin-bottom: 14px;
font-family: var(--font-mono);
}
.auth-info {
border-left: 3px solid var(--accent);
background: color-mix(in srgb, var(--accent) 6%, transparent);
color: var(--accent);
padding: 8px 10px;
font-size: 12px;
margin-bottom: 14px;
font-family: var(--font-mono);
}
.auth-info--invited {
/* Slightly warmer / friendlier shading for the referral banner. */
border-left-color: var(--positive);
background: color-mix(in srgb, var(--positive) 7%, transparent);
color: var(--text);
font-family: var(--font-sans);
font-size: 13px;
line-height: 1.5;
}
.auth-info--invited strong { color: var(--positive); font-weight: 600; }
.auth-card__lede {
font-size: 12.5px;
color: var(--muted);
margin: 0 0 16px;
line-height: 1.5;
}
.auth-card__lede strong { color: var(--text); font-weight: normal; }
.auth-card__resend {
background: transparent !important;
color: var(--muted) !important;
border: 1px dashed var(--border) !important;
font-size: 11px !important;
}
.auth-card__resend:hover {
color: var(--accent) !important;
border-color: var(--accent) !important;
}
/* --- Mobile (≤480px) -------------------------------------------------- */
@media (max-width: 480px) {
/* The card is already width:360px;max-width:100% so it fills the
screen just tighten internal padding to free up vertical space
for the keyboard on iOS Safari (which eats half the viewport). */
.auth-card { padding: 20px 18px; }
.auth-card__brand { font-size: 14px; }
.auth-card__lede { font-size: 12px; }
.auth-card input,
.auth-card button[type="submit"] {
font-size: 14px; /* avoids iOS Safari zoom-on-focus */
padding: 10px 12px;
}
}

2546
app/static/css/cassandra.css Normal file

File diff suppressed because it is too large Load diff

View file

@ -1,275 +0,0 @@
/* Cassandra dashboard-specific widgets: market chips, aggregate read
* header, indicator summary, glossary tooltips, group tabs, badges. */
/* --- Dashboard top header (markets + aggregate read) ----------------- */
.dash-header {
display: grid;
grid-template-columns: 1fr;
gap: 12px;
margin-bottom: 0;
}
.mkt {
background: var(--surface);
padding: 6px 10px;
font-family: var(--font-mono);
font-size: 11px;
display: grid;
grid-template-columns: auto 1fr auto;
grid-template-rows: auto auto;
align-items: center;
gap: 2px 6px;
}
.mkt__dot {
width: 8px; height: 8px; border-radius: 50%;
grid-row: 1 / span 2; grid-column: 1;
align-self: center;
}
.mkt--open .mkt__dot { background: var(--positive); box-shadow: 0 0 6px var(--positive); }
.mkt--closed .mkt__dot { background: var(--dim); }
.mkt__name {
grid-row: 1; grid-column: 2;
color: var(--text); font-weight: 700;
text-transform: uppercase; letter-spacing: 0.08em;
}
.mkt__state {
grid-row: 1; grid-column: 3;
font-size: 9.5px; letter-spacing: 0.08em;
text-transform: lowercase;
}
.mkt--open .mkt__state { color: var(--positive); }
.mkt--closed .mkt__state { color: var(--dim); }
.mkt__index {
grid-row: 2; grid-column: 2;
font-size: 10.5px;
font-variant-numeric: tabular-nums;
display: inline-flex;
align-items: baseline;
gap: 5px;
white-space: nowrap;
}
.mkt__index-label { color: var(--dim); }
.mkt__index-price { color: var(--text); }
.mkt__index-change.pos { color: var(--positive); }
.mkt__index-change.neg { color: var(--negative); }
.mkt__index-change.neu { color: var(--muted); }
.mkt__index--empty { color: var(--dim); font-size: 10px; }
.mkt__when {
grid-row: 2; grid-column: 3;
color: var(--muted); font-size: 10px;
font-variant-numeric: tabular-nums;
text-align: right;
}
.dash-header__read {
border: 1px solid var(--border);
border-left: 3px solid var(--accent);
background: color-mix(in srgb, var(--accent) 4%, transparent);
padding: 10px 14px;
}
.dash-header__read-meta {
display: flex;
justify-content: space-between;
align-items: baseline;
margin-bottom: 4px;
}
.dash-header__read-body {
margin: 0;
font-family: var(--font-sans);
font-size: 14px;
line-height: 1.55;
color: var(--text);
}
.dash-header__read--pending { color: var(--dim); font-style: italic; }
.dash-header__read--pending .dash-header__read-body { color: var(--dim); font-size: 12px; }
/* --- Indicator group summary (above the table) ----------------------- */
.ind-summary {
font-family: var(--font-sans);
padding: 10px 16px;
border-bottom: 1px solid var(--surface-2);
border-left: 3px solid var(--accent);
background: color-mix(in srgb, var(--accent) 4%, transparent);
}
.ind-summary__head {
display: flex;
align-items: baseline;
justify-content: space-between;
margin-bottom: 4px;
}
.ind-summary__label {
font-family: var(--font-mono);
font-size: 10px;
color: var(--accent);
text-transform: uppercase;
letter-spacing: 0.1em;
font-weight: 700;
}
.ind-summary__label::before { content: "▸ "; }
.ind-summary__when {
font-family: var(--font-mono);
font-size: 10px;
color: var(--dim);
font-variant-numeric: tabular-nums;
}
.ind-summary__body {
margin: 0;
font-size: 13.5px;
line-height: 1.55;
color: var(--text);
}
.ind-summary--pending { color: var(--dim); font-style: italic; }
.ind-summary--pending .ind-summary__body { color: var(--dim); font-size: 12px; }
/* --- Glossary tooltips (Novice mode) --------------------------------- */
/* The term gets a dotted underline. The actual tooltip is a single shared
element (#glossary-tooltip) positioned by JS so it can flip on viewport
edges and never clip behind sticky bars (which sit at z-index 50). */
.glossary {
border-bottom: 1px dotted var(--accent);
cursor: help;
/* Same colour as surrounding text only the underline signals "tooltip
available", keeping the paragraph visually quiet. */
}
.glossary:focus { outline: 1px dotted var(--accent); outline-offset: 2px; }
#glossary-tooltip {
position: fixed;
z-index: 200; /* Above sticky bars (z-index 50). */
max-width: 300px;
padding: 9px 12px;
background: var(--surface);
color: var(--text);
border: 1px solid var(--accent);
font-family: var(--font-sans);
font-size: 12.5px;
line-height: 1.5;
letter-spacing: 0;
text-transform: none;
font-weight: normal;
box-shadow: 0 6px 18px rgba(0,0,0,0.35);
pointer-events: none;
opacity: 0;
transition: opacity 90ms ease;
}
#glossary-tooltip[data-visible="1"] { opacity: 1; }
#glossary-tooltip[hidden] { display: none; }
/* --- Group tabs ------------------------------------------------------- */
.group-tabs {
display: flex;
border-bottom: 1px solid var(--border);
overflow-x: auto;
}
.group-tabs button {
background: transparent;
border: 0;
border-right: 1px solid var(--border);
color: var(--muted);
font-family: inherit;
font-size: 11px;
padding: 6px 12px;
text-transform: uppercase;
letter-spacing: 0.06em;
cursor: pointer;
}
.group-tabs button:hover { color: var(--text); }
.group-tabs button.active {
color: var(--accent);
background: var(--bg);
box-shadow: inset 0 -2px 0 var(--accent);
}
/* --- Badges (tone / analysis indicators) ------------------------------ */
.badge {
display: inline-block;
font-family: var(--font-mono);
font-size: 9.5px;
letter-spacing: 0.06em;
text-transform: uppercase;
padding: 1px 6px;
border: 1px solid currentColor;
margin-right: 4px;
background: transparent;
vertical-align: middle;
}
/* Tone axis — green→accent→amber as audience density rises */
.badge--tone-novice { color: var(--positive); }
.badge--tone-intermediate { color: var(--accent); }
.badge--tone-pro { color: var(--alert); }
/* Analysis axis — dry is muted, speculative is accent */
.badge--analysis-dry { color: var(--muted); }
.badge--analysis-speculative { color: var(--accent); }
.badge--ver { color: var(--dim); }
.badge--ok { color: var(--positive); border-color: var(--positive); }
.meta__hint { color: var(--dim); font-size: 10px; margin-right: 4px; }
/* BETA indicator pill in the app header — see app/templates/base.html. */
.beta-chip {
display: inline-block;
margin-left: 8px;
padding: 2px 7px;
font-size: 10px;
font-weight: 700;
letter-spacing: 0.14em;
font-family: var(--font-mono);
color: var(--bg);
background: var(--accent);
border-radius: 2px;
vertical-align: middle;
user-select: none;
}
/* --- Mobile (≤480px) -------------------------------------------------- */
@media (max-width: 480px) {
/* Hide secondary indicator-table columns: Label, Ccy, 1y, anchor,
as-of. The cells are tagged with .mobile-hide in indicators.html;
this rule keeps display intent in CSS while letting the template
handle the conditional anchor column. Symbol / Price / 1d / 1m
remain the four numbers a phone user actually wants. */
.dense .mobile-hide { display: none; }
/* Tighter cell padding so the four remaining columns fit
comfortably on a 360px viewport. */
.dense th, .dense td {
padding: 4px 6px;
font-size: 11px;
}
/* Symbol column gets a touch more breathing room it's the
identifying anchor. */
.dense td.label { font-weight: 600; }
/* Group tabs: wrap onto multiple rows instead of horizontal
scrolling so the user can see every group at a glance. The
border-bottom moves to each row so wrapped rows are still
visually delimited. */
.group-tabs {
flex-wrap: wrap;
overflow-x: visible;
border-bottom: 0;
}
.group-tabs button {
padding: 6px 10px;
font-size: 11px;
border-bottom: 1px solid var(--border);
white-space: nowrap;
}
/* Aggregate-read summary header tightens stack the label above
the timestamp to avoid wrapping at awkward points. */
.ind-summary__head {
flex-direction: column;
align-items: flex-start;
gap: 2px;
}
.ind-summary__body { font-size: 12px; }
}

View file

@ -1,488 +0,0 @@
/* Cassandra structural layout: html/body, app shell, header, main grid,
* sticky markets bar, scrollbar. */
html, body {
margin: 0;
padding: 0;
background: var(--bg);
color: var(--text);
font-family: var(--font-mono);
font-size: 13px;
line-height: 1.5;
font-variant-numeric: tabular-nums;
/* Prevents the off-screen fixed mobile drawer (translateX(100%))
from forcing horizontal scroll on Safari iOS, and provides a
safety net for any cell/grid that would otherwise overflow. */
overflow-x: hidden;
}
a { color: var(--accent); text-decoration: none; }
a:hover { text-decoration: underline; }
/* --- Layout ---------------------------------------------------------- */
.app {
display: grid;
grid-template-columns: 1fr;
grid-template-rows: auto 1fr auto;
min-height: 100vh;
/* Grid items default to min-content min-width which can blow past
the viewport when a descendant table or flex row is wide. min-width:0
lets the cell shrink below intrinsic min-content, and max-width:100vw
caps the whole shell against the viewport so we never need to rely on
overflow:hidden clipping. */
min-width: 0;
max-width: 100vw;
}
.app-header {
/* Three-column grid: brand+BETA pinned left, nav truly centered in
the middle column regardless of side widths, header-right pinned
right. The mobile-drawer wrapper is display:contents on desktop so
its children (nav, .header-right) become direct grid items and
land in columns 2 and 3 by source order. */
display: grid;
grid-template-columns: 1fr auto 1fr;
align-items: center;
gap: 14px;
border-bottom: 1px solid var(--border);
padding: 10px 18px;
background: var(--surface);
letter-spacing: 0.08em;
text-transform: uppercase;
position: sticky;
top: 0;
z-index: 50;
}
.app-header .header-left {
display: inline-flex;
align-items: center;
gap: 10px;
justify-self: start;
}
.app-header nav { justify-self: center; }
.app-header .brand {
color: var(--accent);
font-weight: 700;
text-decoration: none;
}
.app-header .brand:hover { color: var(--text); }
.app-header .brand::before { content: "▰ "; opacity: 0.6; }
.app-header nav a {
margin-left: 18px;
color: var(--muted);
}
.app-header nav a:first-child { margin-left: 0; }
.app-header nav a.active { color: var(--text); }
.app-header .meta { color: var(--muted); font-size: 11px; }
/* On desktop the mobile-drawer wrapper has no layout effect its
* children (nav, header-right) flow as if it weren't there. On mobile
* the @media block at the bottom converts it to a fixed slide-out. */
.mobile-drawer { display: contents; }
.app-header .header-right {
display: flex;
align-items: center;
gap: 14px;
justify-self: end;
}
/* Hamburger button only visible at 480px (rule in the mobile block).
* Three thin bars; uses the same border/muted treatment as the other
* header buttons so the visual rhythm matches. */
.drawer-toggle {
display: none;
background: transparent;
border: 1px solid var(--border);
cursor: pointer;
padding: 6px 8px;
width: 36px;
height: 32px;
flex-direction: column;
justify-content: space-between;
align-items: stretch;
}
.drawer-toggle:hover { border-color: var(--accent); }
.drawer-toggle__bar {
display: block;
height: 2px;
background: var(--muted);
width: 100%;
}
.drawer-toggle:hover .drawer-toggle__bar { background: var(--accent); }
.drawer-backdrop {
position: fixed;
inset: 0;
background: rgba(0, 0, 0, 0.4);
z-index: 90;
opacity: 0;
transition: opacity 120ms ease-out;
}
body.drawer-open .drawer-backdrop { opacity: 1; }
/* Segmented toggles tone (Novice | Intermediate), theme (Light | Dark)
* and language (EN | IT) share one visual rhythm so the three controls
* read as a single cluster in the header. By default only the currently
* active option is rendered; hover or keyboard focus reveals both so the
* user can pick the other. Touch devices (which can't hover) show both
* options at all times; the @media (hover: hover) gate handles that. */
.tone-toggle,
.theme-toggle,
.lang-toggle {
display: inline-flex;
border: 1px solid var(--border);
font-family: var(--font-mono);
font-size: 10.5px;
letter-spacing: 0.06em;
text-transform: uppercase;
}
.tone-toggle button,
.theme-toggle button,
.lang-toggle button {
background: transparent;
color: var(--muted);
border: 0;
padding: 4px 10px;
cursor: pointer;
font: inherit;
letter-spacing: inherit;
text-transform: inherit;
/* Fixed min-width so the active-only width matches the expanded width
of a single button prevents the layout jumping as the user
mouses over and the second option appears. */
min-width: 5.5em;
text-align: center;
}
.tone-toggle button + button,
.theme-toggle button + button,
.lang-toggle button + button { border-left: 1px solid var(--border); }
.tone-toggle button:hover,
.theme-toggle button:hover,
.lang-toggle button:hover { color: var(--accent); }
/* Active-option highlighting (data-* attribute on the container is
* authored by JS on load and on every change). */
.tone-toggle[data-tone="NOVICE"] button[data-value="NOVICE"],
.tone-toggle[data-tone="INTERMEDIATE"] button[data-value="INTERMEDIATE"],
.theme-toggle[data-theme="light"] button[data-value="light"],
.theme-toggle[data-theme="dark"] button[data-value="dark"],
.lang-toggle[data-lang="en"] button[data-value="en"],
.lang-toggle[data-lang="it"] button[data-value="it"] {
background: var(--accent);
color: var(--bg);
}
/* Collapse-when-idle behaviour: on hover-capable devices each toggle
* shows only its active option. Hover or keyboard focus reveals the
* other option STACKED ABSOLUTELY BELOW so the toggle's in-flow size
* never changes neighbouring controls don't shift when the user
* mouses over one of them. */
@media (hover: hover) {
.tone-toggle,
.theme-toggle,
.lang-toggle {
position: relative;
}
/* Hide every option by default. The active option's higher-specificity
rule below puts it back into the static flow. */
.tone-toggle button,
.theme-toggle button,
.lang-toggle button { display: none; }
/* Hover / focus: render every option as an absolutely-positioned
button immediately under the container. The active-button rule
immediately below wins on specificity and pins it back into the
static flow at the top only the non-active option(s) actually
end up absolutely-positioned, so the popup grows downward only. */
.tone-toggle:hover button,
.tone-toggle:focus-within button,
.theme-toggle:hover button,
.theme-toggle:focus-within button,
.lang-toggle:hover button,
.lang-toggle:focus-within button {
display: block;
position: absolute;
top: 100%;
left: 0;
right: 0;
margin-top: -1px; /* share the container's bottom border */
background: var(--surface);
border: 1px solid var(--border);
z-index: 60; /* above the markets bar (z-50) */
}
/* Active option stays in static flow at the top of the container
even while hovered. Two-attribute specificity (.X[data=Y] btn[data=Y])
beats the .X:hover button rule above. */
.tone-toggle[data-tone="NOVICE"] button[data-value="NOVICE"],
.tone-toggle[data-tone="INTERMEDIATE"] button[data-value="INTERMEDIATE"],
.theme-toggle[data-theme="light"] button[data-value="light"],
.theme-toggle[data-theme="dark"] button[data-value="dark"],
.lang-toggle[data-lang="en"] button[data-value="en"],
.lang-toggle[data-lang="it"] button[data-value="it"] {
display: block;
position: static;
margin-top: 0;
border: 0;
}
}
.app-main {
padding: 14px;
display: grid;
grid-template-columns: minmax(0, 2fr) minmax(0, 1fr);
grid-template-rows: auto auto auto auto;
grid-template-areas:
"header header"
"indicators log"
"portfolio log"
"news news";
gap: 14px;
}
@media (max-width: 1100px) {
.app-main {
grid-template-columns: 1fr;
grid-template-areas: "header" "indicators" "portfolio" "log" "news";
}
}
#dash-header-container { grid-area: header; }
#indicators-panel { grid-area: indicators; }
#portfolio-panel { grid-area: portfolio; }
#log-panel {
grid-area: log;
/* Stretch (default align-self) so the log panel's border reaches the
bottom of the portfolio next to it the two right-hand panels
align cleanly. The log body itself sits at the top of the panel;
any height beyond its content is empty padding inside the box. */
}
#news-panel { grid-area: news; }
/* Sticky bottom markets bar uses the same .mkt chip styling as the
old dashboard header, extended with each market's headline index. */
.markets-bar {
position: sticky;
bottom: 0;
z-index: 50;
background: var(--surface);
border-top: 1px solid var(--border);
}
.markets-bar__inner {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(220px, 1fr));
gap: 1px;
background: var(--border);
border: 0;
}
.markets-bar .mkt {
border: 0;
border-radius: 0;
}
/* --- Scrollbar -------------------------------------------------------- */
::-webkit-scrollbar { width: 8px; height: 8px; }
::-webkit-scrollbar-track { background: var(--bg); }
::-webkit-scrollbar-thumb { background: var(--dim); border-radius: 0; }
::-webkit-scrollbar-thumb:hover { background: var(--muted); }
/* --- Mobile (≤480px) -------------------------------------------------- */
@media (max-width: 480px) {
/* Revert to flex on mobile so the drawer-toggle can pin to the right
via margin-left:auto and the off-screen drawer doesn't try to claim
a grid column. */
.app-header {
display: flex;
padding: 8px 12px;
gap: 8px;
letter-spacing: 0.04em;
}
/* When the drawer is open the header (which contains the drawer)
needs to draw above the backdrop. The header is a sticky element
with its own stacking context at z-index 50, so the drawer's
local z-index 100 is clamped to z-50 in the root context the
backdrop at z-90 then sits OVER it. Raise the whole header above
the backdrop while the drawer is open. */
body.drawer-open .app-header { z-index: 110; }
.app-header .brand {
font-size: 12px;
/* Shrink the leading glyph but don't remove it — keeps brand identity. */
}
.beta-chip { display: none; }
/* Show the hamburger; the rest of the header widgets collapse into
the drawer (the .mobile-drawer block below). */
.drawer-toggle { display: flex; margin-left: auto; }
/* The drawer wrapper: full-height slide-out from the right. The
content inside (nav + header-right) becomes a vertical stack
with comfortable touch targets. */
.mobile-drawer {
display: flex;
flex-direction: column;
gap: 0;
position: fixed;
top: 0;
right: 0;
bottom: 0;
width: min(82vw, 320px);
background: var(--surface);
border-left: 1px solid var(--border);
box-shadow: -2px 0 12px rgba(0, 0, 0, 0.18);
transform: translateX(100%);
transition: transform 180ms ease-out;
z-index: 100;
overflow-y: auto;
padding: 56px 18px 24px;
text-transform: none;
letter-spacing: 0.02em;
}
body.drawer-open .mobile-drawer { transform: translateX(0); }
/* Vertical nav inside the drawer links become big-tap rows, no
leading margin like the desktop horizontal nav. */
.mobile-drawer nav { display: flex; flex-direction: column; }
.mobile-drawer nav a {
margin-left: 0;
padding: 12px 4px;
border-bottom: 1px solid var(--border);
font-size: 14px;
text-transform: uppercase;
letter-spacing: 0.06em;
}
.mobile-drawer nav a.active {
color: var(--accent);
border-left: 2px solid var(--accent);
padding-left: 10px;
}
/* header-right widgets vertically stacked inside the drawer. */
.mobile-drawer .header-right {
flex-direction: column;
align-items: stretch;
gap: 14px;
margin-top: 20px;
}
.mobile-drawer .tone-toggle,
.mobile-drawer .theme-toggle,
.mobile-drawer .lang-toggle {
display: inline-flex;
width: 100%;
justify-content: center;
}
/* Inside the drawer all options stay visible undoes the
hover-collapse from the @media (hover: hover) block above. Also
splits the row evenly and bumps the button padding for thumb taps. */
.mobile-drawer .tone-toggle button,
.mobile-drawer .theme-toggle button,
.mobile-drawer .lang-toggle button {
display: inline-block;
flex: 1;
padding: 10px;
font-size: 11.5px;
min-width: 0;
}
/* The user-menu's dropdown becomes redundant inside the drawer
surface its links flat as a list, and hide the chip button. */
.mobile-drawer .user-menu { width: 100%; }
.mobile-drawer .user-chip { display: none; }
.mobile-drawer .user-menu__panel {
display: block !important; /* override the hidden attribute */
position: static;
border: 0;
padding: 0;
margin-top: 4px;
}
.mobile-drawer .user-menu__panel[hidden] { display: block !important; }
.mobile-drawer .user-menu__item {
display: block;
padding: 10px 4px;
border-bottom: 1px solid var(--border);
font-size: 13px;
text-transform: uppercase;
letter-spacing: 0.06em;
}
.mobile-drawer .meta {
margin-top: auto;
padding-top: 18px;
text-align: center;
opacity: 0.7;
}
/* The drawer container itself sits above the topbar in z-stacking;
we still want the close button accessible while it's open, so push
a close target into the top-right corner of the drawer via a
repurposed pseudo-element. (Simpler than adding new markup.) */
.mobile-drawer::before {
content: "✕";
position: absolute;
top: 14px;
right: 18px;
font-size: 18px;
color: var(--muted);
cursor: pointer;
pointer-events: none; /* tap handled by the backdrop / hamburger */
}
/* Body-level layout: tighten main padding too saves another 16px
of horizontal real estate which the indicator table and chat
bubbles all benefit from. Also force min-width:0 on the grid
container and every grid item, otherwise a wide table inside
a panel forces the whole grid (and the page) wider than the
viewport. This is the single most important mobile fix. */
.app-main {
padding: 10px 8px;
gap: 10px;
min-width: 0;
max-width: 100vw;
}
.app-main > * { min-width: 0; }
/* Markets bar: compact each chip so the full set fits the viewport
without horizontal scrolling. We drop:
- state word ("open" / "closed") the dot already conveys that
- index label (e.g. "SPX") implied by the market code
- index price keep the change% which is the actionable number
- until-time too detailed for a glance
Remaining: dot + market code + change%. The grid keeps auto-fit
but the minimum drops from 220px to 0 so it always fits. */
.markets-bar__inner {
grid-template-columns: repeat(auto-fit, minmax(0, 1fr));
gap: 0;
}
.markets-bar .mkt {
grid-template-columns: auto 1fr auto;
grid-template-rows: auto;
padding: 5px 6px;
gap: 4px;
font-size: 10px;
}
/* Re-flow the chip's grid so it's a single row of three: dot,
code, change. The 2-row layout (which had state/when on row 2)
is dropped along with the elements that lived there. */
.markets-bar .mkt .mkt__dot {
grid-row: 1; grid-column: 1;
width: 6px; height: 6px;
}
.markets-bar .mkt .mkt__name {
grid-row: 1; grid-column: 2;
font-size: 10px;
letter-spacing: 0.04em;
}
.markets-bar .mkt .mkt__index {
grid-row: 1; grid-column: 3;
font-size: 10px;
}
/* Strip the now-redundant content. The elements still render but
occupy no space so the chip stays narrow. */
.markets-bar .mkt__state,
.markets-bar .mkt__when,
.markets-bar .mkt__index-label,
.markets-bar .mkt__index-price { display: none; }
}

View file

@ -1,330 +0,0 @@
/* Cassandra — log panel, log page layout, calendar widget, chat sidebar. */
/* --- Log panel -------------------------------------------------------- */
.log-content {
font-family: var(--font-sans);
padding: 28px clamp(20px, 4vw, 56px) 32px;
font-size: 15.5px;
line-height: 1.72;
color: var(--text);
max-width: 76ch;
margin: 0 auto;
/* No max-height cap here the dashboard's log panel now stretches in
the grid to match the left column's bottom (see #log-panel in
layout.css). A constrained max-height was producing an awkward
inner scrollbar AND leaving dead space below it inside the panel.
With the cap gone the content sits at the panel's top, the panel
grows or shrinks with the grid, and the regular page scroll
handles very long logs. */
}
.log-content p { margin: 0 0 1.1em; }
.log-content h1, .log-content h2, .log-content h3, .log-content h4 {
font-family: var(--font-mono);
color: var(--accent);
text-transform: uppercase;
letter-spacing: 0.08em;
font-size: 12px;
margin-top: 1.8em;
margin-bottom: 0.5em;
font-weight: 700;
}
.log-content h1:first-child,
.log-content h2:first-child,
.log-content h3:first-child { margin-top: 0; }
/* TL;DR callout model is instructed to put it first, so style the first
* heading + paragraph block as a callout. */
.log-content h3:first-of-type {
font-size: 11px;
color: var(--accent);
border-left: 3px solid var(--accent);
padding-left: 10px;
margin-bottom: 0;
}
.log-content h3:first-of-type + p {
font-size: 16.5px;
line-height: 1.6;
color: var(--text);
border-left: 3px solid var(--accent);
padding: 4px 14px 12px;
margin: 0 0 1.8em;
background: color-mix(in srgb, var(--accent) 5%, transparent);
font-weight: 500;
}
.log-content strong { color: var(--text); font-weight: 700; }
.log-content em { color: var(--muted); font-style: italic; }
.log-content ul, .log-content ol { padding-left: 1.4em; margin: 0 0 1.1em; }
.log-content li { margin-bottom: 0.4em; }
.log-content hr {
border: 0;
border-top: 1px solid var(--border);
margin: 1.6em 0;
}
/* --- Log page (calendar + log + chat sidebar) ------------------------- */
.log-page__body {
display: grid;
grid-template-columns: 220px 1fr 320px;
gap: 1px;
background: var(--border);
}
@media (max-width: 1100px) {
.log-page__body { grid-template-columns: 1fr; }
}
.log-page__cal, .log-page__content, .log-page__chat { background: var(--surface); }
.log-page__cal { padding: 10px; }
.log-page__content { min-height: 60vh; }
.log-page__chat { padding: 8px; min-height: 60vh; display: flex; flex-direction: column; }
.log-page__chat--locked { opacity: 0.92; }
.chat-locked {
flex: 1;
display: flex;
flex-direction: column;
justify-content: center;
align-items: center;
text-align: center;
gap: 16px;
padding: 24px 18px;
color: var(--muted);
font-size: 13px;
line-height: 1.55;
border: 1px dashed var(--border);
border-radius: 4px;
margin: 8px 4px;
}
.chat-locked p { margin: 0; max-width: 280px; }
.chat-locked strong { color: var(--text); display: block; margin-bottom: 6px; }
/* --- Calendar widget --------------------------------------------------- */
.cal__nav {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 8px;
font-size: 11px;
letter-spacing: 0.08em;
text-transform: uppercase;
}
.cal__title { color: var(--accent); font-weight: 700; }
.cal__btn {
background: transparent;
color: var(--muted);
border: 1px solid var(--border);
padding: 2px 8px;
cursor: pointer;
font-family: inherit;
font-size: 13px;
}
.cal__btn:hover { color: var(--accent); border-color: var(--accent); }
.cal__grid {
display: grid;
grid-template-columns: repeat(7, 1fr);
gap: 1px;
background: var(--border);
border: 1px solid var(--border);
}
.cal__h {
text-align: center;
font-size: 9px;
color: var(--dim);
background: var(--surface-2);
padding: 3px 0;
text-transform: uppercase;
}
.cal__d {
background: var(--surface);
border: 0;
color: var(--muted);
font-family: inherit;
font-size: 11px;
padding: 6px 0;
text-align: center;
cursor: not-allowed;
}
.cal__d--empty { background: var(--bg); cursor: default; }
.cal__d--has-log {
color: var(--text);
cursor: pointer;
position: relative;
}
.cal__d--has-log::after {
content: "";
position: absolute;
bottom: 3px;
left: 50%;
transform: translateX(-50%);
width: 3px; height: 3px;
border-radius: 50%;
background: var(--accent);
}
.cal__d--has-log:hover { background: color-mix(in srgb, var(--accent) 10%, transparent); }
.cal__d--today { color: var(--warning); }
.cal__d--selected {
background: var(--accent);
color: var(--bg);
font-weight: 700;
}
.cal__d--selected::after { background: var(--bg); }
/* --- Chat sidebar ----------------------------------------------------- */
.chat-header {
border-bottom: 1px solid var(--border);
padding: 6px 4px 8px;
margin-bottom: 6px;
display: flex;
flex-direction: column;
}
.chat-title {
color: var(--accent);
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.1em;
font-size: 11px;
}
.chat-title::before { content: "▸ "; }
.chat-hint { color: var(--dim); font-size: 10px; margin-top: 2px; }
.chat-thread {
flex: 1 1 auto;
overflow-y: auto;
padding: 4px 2px;
display: flex;
flex-direction: column;
gap: 8px;
min-height: 0;
}
.chat-msg {
font-family: var(--font-sans);
font-size: 13.5px;
padding: 9px 11px;
border: 1px solid var(--border);
line-height: 1.6;
word-wrap: break-word;
}
.chat-msg--system {
color: var(--muted);
font-size: 12px;
background: transparent;
border-style: dashed;
font-family: var(--font-mono);
}
.chat-msg--user {
background: var(--user-bubble-bg);
border-color: var(--accent);
color: var(--text);
align-self: flex-end;
max-width: 92%;
white-space: pre-wrap;
}
.chat-msg--user::before {
content: "you ";
font-family: var(--font-mono);
color: var(--accent);
opacity: 0.7;
font-size: 10px;
}
.chat-msg--assistant { background: var(--surface-2); color: var(--text); }
.chat-msg--assistant::before {
content: "cassandra ";
font-family: var(--font-mono);
color: var(--accent);
opacity: 0.7;
font-size: 10px;
}
.chat-msg--pending { color: var(--dim); font-style: italic; }
.chat-msg--error { color: var(--negative); border-color: var(--negative); }
.chat-msg p { margin: 0.4em 0; }
.chat-msg p:first-child { margin-top: 0; }
.chat-msg p:last-child { margin-bottom: 0; }
.chat-msg h2, .chat-msg h3, .chat-msg h4 {
font-family: var(--font-mono);
color: var(--accent);
font-size: 11px;
margin: 0.8em 0 0.3em;
text-transform: uppercase;
letter-spacing: 0.06em;
}
.chat-msg strong { color: var(--text); font-weight: 700; }
.chat-msg em { color: var(--muted); font-style: italic; }
.chat-form {
border-top: 1px solid var(--border);
padding-top: 6px;
display: flex;
gap: 6px;
align-items: flex-end;
}
.chat-form textarea {
flex: 1;
background: var(--bg);
border: 1px solid var(--border);
color: var(--text);
font-family: inherit;
font-size: 12px;
padding: 6px 8px;
resize: vertical;
min-height: 36px;
outline: none;
}
.chat-form textarea:focus { border-color: var(--accent); }
.chat-form button {
background: transparent;
border: 1px solid var(--accent);
color: var(--accent);
font-family: inherit;
font-size: 11px;
padding: 6px 12px;
text-transform: uppercase;
letter-spacing: 0.08em;
cursor: pointer;
}
.chat-form button:hover:not(:disabled) { background: var(--accent); color: var(--bg); }
.chat-form button:disabled { opacity: 0.4; cursor: not-allowed; }
/* --- Mobile (≤480px) -------------------------------------------------- */
@media (max-width: 480px) {
/* Trim horizontal padding so the markdown column uses the screen
width. The existing 1100px rule already capped the column at
76ch; we just shave the surrounding gutter. */
.log-content { padding: 0 4px; font-size: 13.5px; }
.log-content h2 { font-size: 16px; }
.log-content h3 { font-size: 14px; }
/* Chat bubbles edge-to-edge so the conversation reads like a
mobile messenger thread. */
.chat-msg {
max-width: 100%;
padding: 8px 10px;
font-size: 13px;
}
.chat-msg--user { margin-right: 0; }
.chat-msg--assistant { margin-left: 0; }
/* Chat input row stacks: textarea full-width, button below. */
.chat-form {
flex-direction: column;
gap: 6px;
padding: 8px;
}
.chat-form textarea {
width: 100%;
min-height: 56px;
font-size: 14px; /* avoids iOS Safari zoom-on-focus */
}
.chat-form button {
width: 100%;
padding: 10px;
font-size: 12px;
}
.chat-header { padding: 8px 10px; }
.chat-title { font-size: 12px; }
.chat-hint { font-size: 10px; }
}

View file

@ -1,123 +0,0 @@
/* Cassandra — news panel: rows, tag chips, filter pills. */
/* --- News ------------------------------------------------------------- */
.news-row {
padding: 4px 12px;
display: grid;
/* age | source | title | tags-on-right | utc-time */
grid-template-columns: 50px 130px minmax(0, 1fr) minmax(0, auto) 110px;
gap: 12px;
font-size: 12px;
border-bottom: 1px solid var(--surface-2);
align-items: center;
}
@media (max-width: 720px) {
.news-row { grid-template-columns: 50px 100px 1fr; }
.news-row .local,
.news-row__tags { display: none; }
}
.news-row:hover { background: color-mix(in srgb, var(--accent) 5%, transparent); }
.news-row .age { color: var(--dim); text-align: right; }
.news-row .source { color: var(--muted); font-size: 11px; }
.news-row .title { color: var(--text); }
.news-row .title:hover { color: var(--accent); }
.news-row .local {
color: var(--muted);
font-size: 11px;
text-align: right;
font-variant-numeric: tabular-nums;
white-space: nowrap;
}
/* News tag chips on each row + the top-bar pill toggles */
.news-row__tags {
display: inline-flex;
flex-wrap: nowrap;
gap: 3px;
justify-content: flex-end;
overflow: hidden;
max-width: 100%;
}
.tag-chip {
font-family: var(--font-mono);
font-size: 9px;
letter-spacing: 0.04em;
color: var(--muted);
background: var(--surface-2);
border: 1px solid var(--border);
padding: 0 4px;
white-space: nowrap;
text-transform: uppercase;
line-height: 1.5;
}
.news-tags {
display: flex;
flex-wrap: wrap;
gap: 4px;
padding: 8px 12px;
border-bottom: 1px solid var(--border);
background: var(--surface-2);
}
.news-tag {
font-family: var(--font-mono);
font-size: 10.5px;
letter-spacing: 0.04em;
text-transform: uppercase;
color: var(--muted);
background: transparent;
border: 1px solid var(--border);
padding: 3px 8px;
cursor: pointer;
}
.news-tag:hover { color: var(--accent); border-color: var(--accent); }
.news-tag[data-state="include"] {
background: var(--accent);
color: var(--bg);
border-color: var(--accent);
}
.news-tag[data-state="exclude"] {
color: var(--negative);
border-color: var(--negative);
text-decoration: line-through;
}
.news-tag--clear { color: var(--dim); border-style: dashed; }
.news-tag--clear:hover { color: var(--negative); border-color: var(--negative); }
/* --- Mobile (≤480px) -------------------------------------------------- */
@media (max-width: 480px) {
/* The 720px rule already collapsed to age | source | title and
hid the right-side tag chips. At 480 we drop the source column
too and let the title flow under the age, with source as a small
line below the title saves another ~100px of horizontal room. */
.news-row {
grid-template-columns: 50px minmax(0, 1fr);
gap: 8px;
padding: 6px 10px;
}
.news-row .source {
grid-column: 2;
grid-row: 2;
font-size: 10.5px;
}
.news-row .title {
grid-column: 2;
grid-row: 1;
font-size: 12.5px;
line-height: 1.35;
}
/* Tag filter strip wraps onto multiple rows on a phone. */
.news-tags {
flex-wrap: wrap;
gap: 6px;
padding: 6px 8px;
}
.news-tag {
padding: 4px 8px;
font-size: 11px;
}
}

View file

@ -1,131 +0,0 @@
/* Cassandra — panel chrome, tables, status LEDs, utility colour classes. */
/* --- Panels ----------------------------------------------------------- */
.panel {
background: var(--surface);
border: 1px solid var(--border);
position: relative;
}
.panel-header {
border-bottom: 1px solid var(--border);
padding: 8px 12px;
display: flex;
align-items: center;
justify-content: space-between;
text-transform: uppercase;
letter-spacing: 0.1em;
color: var(--muted);
font-size: 11px;
background: linear-gradient(180deg, var(--surface-2), var(--surface));
}
.panel-header .title { color: var(--text); font-weight: 700; }
.panel-header .title::before { content: "■ "; color: var(--accent); }
.panel-header .meta { color: var(--dim); }
.panel-body { padding: 6px 0; }
.panel-body--scroll { max-height: 70vh; overflow-y: auto; }
/* --- Tables ----------------------------------------------------------- */
table.dense {
width: 100%;
border-collapse: collapse;
}
table.dense th, table.dense td {
padding: 4px 12px;
font-size: 12px;
border-bottom: 1px solid var(--surface-2);
white-space: nowrap;
}
table.dense th {
text-align: left;
color: var(--muted);
font-weight: 400;
text-transform: uppercase;
letter-spacing: 0.06em;
font-size: 10px;
background: var(--surface-2);
}
table.dense th.num,
table.dense td.num { text-align: right; }
table.dense td.label { color: var(--text); }
table.dense td.label.has-tip,
table.dense td[title] {
cursor: help;
border-bottom: 1px dotted color-mix(in srgb, var(--accent) 40%, transparent);
border-bottom-width: 1px;
}
.pf-name.has-tip {
cursor: help;
border-bottom: 1px dotted color-mix(in srgb, var(--accent) 50%, transparent);
}
table.dense tr:hover td { background: color-mix(in srgb, var(--accent) 5%, transparent); }
.pos { color: var(--positive); }
.neg { color: var(--negative); }
.neu { color: var(--muted); }
/* --- Status LEDs ------------------------------------------------------ */
.led { display: inline-block; width: 8px; height: 8px; border-radius: 50%; margin-right: 4px; vertical-align: middle; }
.led.ok { background: var(--positive); box-shadow: 0 0 6px var(--positive); }
.led.warn { background: var(--warning); box-shadow: 0 0 6px var(--warning); }
.led.err { background: var(--negative); box-shadow: 0 0 6px var(--negative); }
.led.idle { background: var(--dim); }
/* --- Empty / loading state ------------------------------------------- */
.empty {
padding: 24px;
text-align: center;
color: var(--muted);
font-size: 11px;
letter-spacing: 0.08em;
text-transform: uppercase;
}
.htmx-indicator {
display: inline-block;
color: var(--dim);
opacity: 0;
transition: opacity 0.2s;
}
.htmx-request .htmx-indicator { opacity: 1; }
/* --- Mobile (≤480px) -------------------------------------------------- */
@media (max-width: 480px) {
/* Force panels and their bodies to honour the parent grid cell
width, even when descendants (tables, code blocks, long URLs)
have intrinsic widths that exceed the viewport. min-width:0 is
the magic that lets flex/grid items shrink past min-content;
max-width:100% caps the box itself. */
.panel { min-width: 0; max-width: 100%; }
.panel-body { min-width: 0; max-width: 100%; }
.panel-header {
padding: 8px 10px;
gap: 8px;
}
.panel-header .title { font-size: 12px; }
.panel-header .meta { font-size: 10px; }
.panel-body { padding: 4px 6px; }
/* Scroll panels lose some vertical room on small screens so the
stacked layout doesn't push log/news off the fold. */
.panel-body--scroll { max-height: 60vh; }
/* Tables: dropping white-space:nowrap lets long Symbol / Label cells
wrap to a second line instead of forcing the table wider than the
panel. Numeric cells stay nowrap since "12.34%" wrapping would be
unreadable. */
table.dense { table-layout: auto; }
table.dense th, table.dense td { white-space: normal; }
table.dense .num { white-space: nowrap; }
/* Final safety net: if a descendant still insists on being wider
than the panel (e.g. a wide pre/code block in the AI output),
scroll it horizontally inside the panel rather than blowing the
whole page out. */
.panel-body pre,
.panel-body code { max-width: 100%; overflow-x: auto; }
}

View file

@ -1,406 +0,0 @@
/* Cassandra portfolio panel styles: overall stats, actions, inline edit
* mode (add composer, delete rows), analysis accordion. */
/* --- Portfolio overall ----------------------------------------------- */
.pf-overall {
border-bottom: 1px solid var(--border);
padding: 10px 14px 12px;
background: linear-gradient(180deg, var(--surface-2), var(--surface));
}
.pf-overall__head {
display: flex;
justify-content: space-between;
align-items: baseline;
margin-bottom: 8px;
}
.pf-name {
color: var(--accent);
text-transform: uppercase;
letter-spacing: 0.1em;
font-weight: 700;
font-size: 11px;
}
.pf-name::before { content: "◆ "; opacity: 0.6; }
.pf-as-of { color: var(--dim); font-size: 11px; }
.pf-overall__grid {
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: 6px 24px;
}
@media (max-width: 640px) {
.pf-overall__grid { grid-template-columns: repeat(2, 1fr); }
}
.pf-stat-label {
font-size: 10px;
color: var(--muted);
text-transform: uppercase;
letter-spacing: 0.08em;
}
.pf-stat-value {
font-size: 16px;
color: var(--text);
font-variant-numeric: tabular-nums;
margin-top: 2px;
}
.pf-stat-value.pos { color: var(--positive); }
.pf-stat-value.neg { color: var(--negative); }
.pf-stat-value.neu { color: var(--muted); }
.pf-ccy { color: var(--dim); font-size: 11px; margin-left: 2px; }
.pf-pct { color: var(--dim); font-size: 11px; margin-left: 4px; }
.pf-pills { display: flex; flex-wrap: wrap; gap: 4px; margin-top: 4px; }
.pf-pill {
font-size: 10.5px;
font-family: var(--font-mono);
color: var(--muted);
background: var(--surface-2);
border: 1px solid var(--border);
padding: 2px 6px;
letter-spacing: 0.04em;
}
.pf-warn {
border-left: 3px solid var(--alert);
background: color-mix(in srgb, var(--alert) 6%, transparent);
color: var(--alert);
padding: 8px 10px;
font-size: 12px;
margin: 10px 0;
}
.pf-actions {
display: flex;
gap: 8px;
margin-top: 12px;
}
.pf-actions button {
font-family: var(--font-mono);
font-size: 11px;
letter-spacing: 0.06em;
text-transform: uppercase;
background: var(--surface-2);
color: var(--accent);
border: 1px solid var(--border);
padding: 7px 14px;
cursor: pointer;
}
.pf-actions button:hover { border-color: var(--accent); }
.pf-actions button:disabled { opacity: 0.5; cursor: not-allowed; }
.pf-secondary { color: var(--muted); }
.pf-secondary:hover { color: var(--negative); border-color: var(--negative); }
.pf-analysis {
margin-top: 14px;
background: var(--surface-2);
border: 1px solid var(--border);
}
.pf-analysis__details { padding: 0; }
.pf-analysis__head {
display: flex;
justify-content: space-between;
align-items: center;
font-size: 11px;
color: var(--muted);
letter-spacing: 0.06em;
text-transform: uppercase;
padding: 10px 16px;
cursor: pointer;
user-select: none;
list-style: none; /* hide native marker in Firefox */
}
.pf-analysis__head::-webkit-details-marker { display: none; }
.pf-analysis__head-left::before {
content: "▸ ";
display: inline-block;
width: 1em;
color: var(--accent);
transition: transform 120ms ease;
}
details[open] .pf-analysis__head-left::before { content: "▾ "; }
.pf-analysis__head:hover { color: var(--accent); }
.pf-analysis__head:hover .pf-analysis__head-left::before { color: var(--accent); }
.pf-analysis__details[open] .pf-analysis__head {
border-bottom: 1px solid var(--border);
}
.pf-analysis__body {
font-family: var(--font-sans);
font-size: 14px;
line-height: 1.65;
color: var(--text);
white-space: pre-wrap;
margin: 0;
padding: 14px 16px 16px;
}
/* ---------- Dashboard portfolio edit mode -----------------------------
*
* Inline composer that sits above the portfolio table. Aesthetic:
* terminal-style command line, no boxed-form chrome, ghost controls,
* tinted-neutral palette pulled from --border / --dim / --muted, accent
* is theme-aware (deep teal in light, electric cyan in dark).
*/
/* The portfolio panel header gains two extra children (the EDIT / Done
* pills). The global `.panel-header` uses `space-between`, which works
* for headers with only title+meta but collapses meta into title once
* any later child has `margin-left: auto`. Switch this header to a
* gap-based flow; meta now sits 12px from the title, edit pill at the
* far right via its own auto-margin. */
#portfolio-panel .panel-header {
justify-content: flex-start;
gap: 12px;
}
/* EDIT / Done toggle buttons in the panel header. */
.pf-edit-btn,
.pf-done-btn {
display: inline-flex;
align-items: center;
gap: 4px;
background: transparent;
border: 1px solid var(--border);
color: var(--dim);
padding: 2px 8px;
border-radius: 2px;
cursor: pointer;
font-family: var(--font-mono);
font-size: 11px;
letter-spacing: 0.04em;
text-transform: lowercase;
margin-left: auto;
transition: color 120ms ease-out, border-color 120ms ease-out;
}
.pf-edit-btn:hover, .pf-done-btn:hover {
color: var(--accent);
border-color: var(--accent);
}
/* The JS toggles these via the `hidden` attribute. `display: inline-flex`
* above otherwise wins over the UA's `[hidden] { display: none }`. */
.pf-edit-btn[hidden], .pf-done-btn[hidden] { display: none; }
/* × button per row — hidden by default, visible only in edit mode. */
.pf-row-del-cell { width: 20px; text-align: center; }
.pf-row-del {
display: none;
background: transparent;
border: none;
color: var(--dim);
cursor: pointer;
font-size: 14px;
padding: 0 4px;
font-family: inherit;
transition: color 120ms ease-out;
}
#portfolio-panel.pf-editing .pf-row-del { display: inline; }
#portfolio-panel.pf-editing .pf-row-del:hover { color: var(--negative); }
/* ---------- Inline add-position composer ----------------------------- */
.pf-add {
padding: 6px 12px 8px;
border-bottom: 1px dashed var(--border);
margin-bottom: 6px;
font-family: var(--font-mono);
font-size: 12px;
}
.pf-add__line {
display: flex;
align-items: center;
gap: 6px;
flex-wrap: wrap;
}
.pf-add__prompt {
color: var(--accent);
font-weight: 600;
user-select: none;
margin-right: 2px;
}
.pf-add__div {
width: 1px;
height: 14px;
background: var(--border);
margin: 0 2px;
}
.pf-add__at {
color: var(--dim);
font-size: 11px;
user-select: none;
}
.pf-add__line input[type="text"],
.pf-add__line input[type="number"],
.pf-add__line input[type="date"] {
background: transparent;
border: none;
border-bottom: 1px solid var(--border);
color: var(--text);
padding: 2px 4px;
font-family: inherit;
font-size: 12px;
line-height: 1.4;
font-variant-numeric: tabular-nums;
border-radius: 0;
}
.pf-add__line input:focus {
outline: none;
border-bottom-color: var(--accent);
background: color-mix(in srgb, var(--accent) 6%, transparent);
}
.pf-add__line input::placeholder {
color: var(--dim);
font-style: italic;
}
.pf-add__ticker {
width: 80px;
text-transform: uppercase;
letter-spacing: 0.05em;
font-weight: 600;
}
.pf-add__num--qty { width: 56px; text-align: right; }
.pf-add__num--cost { width: 76px; text-align: right; }
.pf-add__date { width: 128px; margin-left: 4px; }
/* Tiny pill that shows after a successful validate: "172.40 USD". */
.pf-add-currency {
color: var(--muted);
font-size: 11px;
min-width: 24px;
letter-spacing: 0.02em;
}
.pf-add-currency:empty { display: none; }
/* Calendar-icon button — ghost, square, terminal-feel. */
.pf-add__icon {
background: transparent;
border: 1px solid var(--border);
color: var(--dim);
width: 22px;
height: 22px;
padding: 0;
border-radius: 2px;
cursor: pointer;
display: inline-flex;
align-items: center;
justify-content: center;
transition: color 120ms ease-out, border-color 120ms ease-out;
}
.pf-add__icon:hover {
color: var(--accent);
border-color: var(--accent);
}
.pf-add__icon:focus-visible {
outline: none;
color: var(--accent);
border-color: var(--accent);
}
/* Submit button a square accent-bordered plus glyph. Visually
* heavier than the ghost calendar icon (larger size, accent border)
* so the primary action reads as primary. Lights up to solid accent
* on hover/focus when enabled. */
.pf-add__submit {
margin-left: auto;
background: transparent;
border: 1px solid var(--accent);
color: var(--accent);
width: 26px;
height: 26px;
padding: 0;
border-radius: 2px;
cursor: pointer;
font-family: inherit;
font-size: 18px;
font-weight: 600;
line-height: 1;
display: inline-flex;
align-items: center;
justify-content: center;
transition: background 120ms ease-out, color 120ms ease-out,
border-color 120ms ease-out, transform 120ms ease-out;
}
.pf-add__submit:hover:not(:disabled),
.pf-add__submit:focus-visible:not(:disabled) {
background: var(--accent);
color: var(--bg);
outline: none;
}
.pf-add__submit:active:not(:disabled) {
transform: scale(0.94);
}
.pf-add__submit:disabled {
border-color: var(--border);
color: var(--dim);
cursor: not-allowed;
}
/* Status indicators next to the ticker + below the row. */
.pf-add-status {
font-size: 11px;
color: var(--muted);
font-variant-numeric: tabular-nums;
}
.pf-add-status:empty { display: none; }
.pf-add-status--pending { color: var(--dim); font-style: italic; }
.pf-add-status--ok { color: var(--positive); }
.pf-add-status--err { color: var(--negative); }
/* Secondary line below the main row only takes space when a child has
* content. Holds the date-lookup status and the duplicate warning. */
.pf-add__notes {
display: flex;
gap: 14px;
margin-top: 4px;
font-size: 11px;
}
.pf-add__notes:has(:empty:only-child) { display: none; }
.pf-add-warning {
color: var(--warning);
}
.pf-add-warning:empty { display: none; }
/* Quietly explains the controls. Shown only when the form is visible,
* which is to say only in edit mode. */
.pf-add__hint {
margin: 6px 0 0;
font-size: 11px;
color: var(--dim);
line-height: 1.5;
font-style: italic;
}
.pf-add__hint kbd {
font-family: inherit;
font-style: normal;
font-size: 11px;
padding: 0 4px;
border: 1px solid var(--border);
border-radius: 2px;
color: var(--muted);
background: color-mix(in srgb, var(--accent) 4%, transparent);
}
/* --- Mobile (≤480px) -------------------------------------------------- */
@media (max-width: 480px) {
/* The existing 640px breakpoint already moves the overall grid to
2 cols. At 480 we keep 2 cols but tighten gap so the stat
values don't crowd the labels next to them. */
.pf-overall__grid { gap: 4px 12px; }
.pf-stat-value { font-size: 14px; }
/* Action buttons wrap to multiple rows instead of squishing onto
one. flex-wrap was already set above; ensure each button has a
comfortable tap target. */
.pf-actions { flex-wrap: wrap; gap: 6px; }
.pf-actions button {
flex: 1 1 auto;
padding: 8px 12px;
font-size: 11.5px;
}
/* Pill row stays wrapped; just give pills a small min-width so
two-character tags (USD, EUR) don't hug each other awkwardly. */
.pf-pill { padding: 3px 7px; }
/* The inline composer's input gets the full width — the desktop's
intrinsic-width sizing leaves it tiny on a phone. */
.pf-add__line { flex-wrap: wrap; gap: 6px; }
.pf-add__line input, .pf-add__line textarea { width: 100%; }
}

View file

@ -1,743 +0,0 @@
/* Cassandra public pages: landing, pricing, about, terms, privacy,
* disclaimer. Shared by all templates extending public_base.html.
* Visual language matches the app shell but without dashboard chrome. */
.public-page { background: var(--bg); }
.public-shell {
display: flex;
flex-direction: column;
min-height: 100vh;
max-width: 1080px;
margin: 0 auto;
padding: 0 24px;
}
.public-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 22px 0 16px;
border-bottom: 1px solid var(--border);
}
.public-header__brand {
color: var(--accent);
font-weight: 700;
text-decoration: none;
font-family: var(--font-mono);
font-size: 15px;
letter-spacing: 0.01em;
}
.public-header__brand::before { content: "▰ "; opacity: 0.6; }
.public-header__brand:hover { color: var(--text); }
.public-header__nav { display: flex; align-items: center; gap: 22px; }
.public-header__nav a {
color: var(--muted);
font-size: 13px;
text-decoration: none;
}
.public-header__nav a:hover,
.public-header__nav a.active { color: var(--text); }
.public-header__cta {
color: var(--accent) !important;
border: 1px solid var(--accent);
padding: 6px 14px;
border-radius: 3px;
}
.public-header__cta:hover { background: var(--accent); color: var(--bg) !important; }
.public-main {
flex: 1;
padding: 48px 0 64px;
}
.public-footer {
border-top: 1px solid var(--border);
padding: 28px 0 36px;
margin-top: 24px;
font-size: 12px;
color: var(--muted);
}
.public-footer__inner {
display: flex;
flex-direction: column;
gap: 14px;
}
.public-footer__brand strong { color: var(--text); margin-right: 10px; }
.public-footer__tagline { color: var(--muted); }
.public-footer__links { display: flex; flex-wrap: wrap; gap: 16px; }
.public-footer__links a { color: var(--muted); text-decoration: none; }
.public-footer__links a:hover { color: var(--accent); }
.public-footer__meta { color: var(--dim); font-size: 11px; }
/* --- Hero (landing) -------------------------------------------------- */
.hero {
padding: 32px 0 48px;
border-bottom: 1px solid var(--border);
margin-bottom: 48px;
}
.hero__brand {
color: var(--muted);
font-family: var(--font-mono);
font-size: 12px;
letter-spacing: 0.08em;
text-transform: uppercase;
}
.hero__headline {
font-size: clamp(28px, 5vw, 44px);
font-weight: 700;
line-height: 1.15;
color: var(--text);
margin: 12px 0 14px;
letter-spacing: -0.01em;
}
.hero__subhead {
font-size: 16px;
color: var(--muted);
max-width: 640px;
line-height: 1.55;
margin: 0 0 24px;
}
.hero__ctas { display: flex; flex-wrap: wrap; gap: 12px; }
/* Shared button shape was previously scoped to .hero__ctas, which made
the pricing-card CTAs render as bare anchors. */
.btn-primary,
.btn-secondary {
display: inline-block;
padding: 10px 22px;
border-radius: 3px;
font-size: 13.5px;
font-weight: 500;
line-height: 1.4;
text-decoration: none;
text-align: center;
cursor: pointer;
}
/* Block variant: full-width within parent, slightly taller used inside
tier cards so each CTA spans the card and reads as the obvious action. */
.btn-block { display: block; width: 100%; padding: 12px 22px; font-size: 14px; }
/* Qualify with `a` so we beat `a { color: var(--accent) }` and any
:link/:visited UA defaults. Without `a.btn-primary` the cascade can
resolve in favour of the visited-link color on some browsers and the
label disappears against the accent background. */
a.btn-primary,
a.btn-primary:link,
a.btn-primary:visited {
background: var(--accent);
color: var(--bg);
border: 1px solid var(--accent);
}
a.btn-primary:hover { background: transparent; color: var(--accent); }
a.btn-secondary,
a.btn-secondary:link,
a.btn-secondary:visited {
background: transparent;
color: var(--text);
border: 1px solid var(--border);
}
a.btn-secondary:hover { color: var(--accent); border-color: var(--accent); }
/* --- Feature blocks (landing) --------------------------------------- */
.feature-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(260px, 1fr));
gap: 24px;
margin: 8px 0 56px;
}
.feature-card {
border: 1px solid var(--border);
border-radius: 4px;
padding: 22px 22px 24px;
background: var(--surface);
/* Flex column so the screenshot thumbnail can dock to the bottom via
margin-top:auto that's what lines the three thumbnails up across
cards regardless of body-text length. */
display: flex;
flex-direction: column;
}
.feature-card__tag {
font-family: var(--font-mono);
font-size: 10.5px;
color: var(--accent);
letter-spacing: 0.08em;
text-transform: uppercase;
margin-bottom: 10px;
}
.feature-card__title {
font-size: 17px;
font-weight: 600;
color: var(--text);
margin: 0 0 10px;
}
.feature-card__body {
font-size: 13.5px;
line-height: 1.6;
color: var(--muted);
margin: 0;
/* Grow to fill the flex column so the thumbnail below docks to the
bottom of the card. With grid-stretched equal-height cards, this is
what aligns the thumbnails across the three cards. */
flex-grow: 1;
}
/* --- Section primitives reused across pricing/about/legal ---------- */
.public-section {
margin: 0 0 56px;
}
.public-section__head {
font-size: 20px;
font-weight: 600;
color: var(--text);
margin: 0 0 16px;
padding-bottom: 8px;
border-bottom: 1px solid var(--border);
}
.public-section h3 {
font-size: 15px;
font-weight: 600;
color: var(--text);
margin: 24px 0 8px;
}
.public-section p,
.public-section li {
font-size: 14px;
line-height: 1.65;
color: var(--text);
}
.public-section p { margin: 0 0 14px; }
.public-section ul {
margin: 0 0 16px;
padding-left: 22px;
}
.public-section li { margin-bottom: 6px; }
.public-section a { color: var(--accent); }
.public-section--callout {
border-left: 3px solid var(--accent);
padding: 16px 22px;
background: var(--surface);
border-radius: 0 4px 4px 0;
margin: 0 0 32px;
}
.public-section--warning {
border-left-color: var(--negative);
background: color-mix(in srgb, var(--negative) 6%, var(--bg));
}
.public-section--warning a { color: var(--text); }
/* --- "What this is not" strip on landing --------------------------- */
.not-strip {
border: 1px dashed var(--border);
padding: 18px 22px;
border-radius: 4px;
margin: 0 0 56px;
background: var(--surface);
}
.not-strip strong { color: var(--text); }
.not-strip ul { display: flex; flex-wrap: wrap; gap: 18px 28px; margin: 8px 0 0; padding: 0; list-style: none; }
.not-strip li { color: var(--muted); font-size: 13px; }
.not-strip li::before { content: "✕ "; color: var(--negative); font-weight: 700; margin-right: 4px; }
/* --- Pricing comparison -------------------------------------------- */
.tier-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(280px, 1fr));
gap: 20px;
margin: 8px 0 40px;
}
.tier-card {
position: relative;
border: 1px solid var(--border);
border-radius: 6px;
padding: 28px 26px 28px;
background: var(--surface);
display: flex;
flex-direction: column;
}
.tier-card--featured {
border-color: var(--accent);
box-shadow: 0 0 0 1px var(--accent) inset,
0 12px 32px rgba(15, 23, 42, 0.10);
}
[data-theme="dark"] .tier-card--featured {
box-shadow: 0 0 0 1px var(--accent) inset,
0 12px 32px rgba(0, 0, 0, 0.45);
}
.tier-card__badge {
position: absolute;
top: -11px;
left: 24px;
background: var(--accent);
color: var(--bg);
font-family: var(--font-mono);
font-size: 10px;
letter-spacing: 0.10em;
text-transform: uppercase;
font-weight: 600;
padding: 4px 10px;
border-radius: 3px;
}
/* Tier name the actual heading, not the small uppercase chip it used
to be. Pairs with .tier-card__tagline for a one-line value framing. */
.tier-card__name {
font-size: 26px;
font-weight: 700;
letter-spacing: -0.01em;
color: var(--text);
margin: 0 0 4px;
line-height: 1.1;
}
.tier-card__tagline {
font-size: 13px;
color: var(--muted);
line-height: 1.5;
margin-bottom: 22px;
}
.tier-card__price {
font-size: 40px;
font-weight: 700;
color: var(--text);
line-height: 1;
margin-bottom: 8px;
letter-spacing: -0.02em;
}
.tier-card__price-unit {
font-size: 15px;
color: var(--muted);
font-weight: 400;
letter-spacing: 0;
}
.tier-card__price-hint {
font-size: 12px;
color: var(--muted);
line-height: 1.55;
margin-bottom: 20px;
}
.tier-card__divider {
height: 1px;
background: var(--border);
margin: 0 0 18px;
}
.tier-card__list-head {
font-family: var(--font-mono);
font-size: 10.5px;
color: var(--muted);
letter-spacing: 0.10em;
text-transform: uppercase;
margin-bottom: 12px;
}
.tier-card ul {
list-style: none;
padding: 0;
margin: 0 0 24px;
flex: 1;
}
.tier-card li {
font-size: 13.5px;
color: var(--text);
line-height: 1.55;
padding: 8px 0 8px 22px;
position: relative;
border-bottom: 1px solid var(--border);
}
.tier-card li:last-child { border-bottom: 0; }
.tier-card li::before {
content: "✓";
position: absolute;
left: 0;
top: 8px;
color: var(--positive);
font-weight: 700;
}
.tier-card__cta { margin-top: 18px; }
/* Consent block above the Subscribe buttons (paid card, logged-in
free user). The Subscribe buttons render disabled; ticking the box
is what enables them. Wording covers ToS agreement (both cadences)
+ the Reg 36 CCR 2013 waiver (monthly only). */
.tier-card__consent {
display: flex;
gap: 10px;
align-items: flex-start;
margin-bottom: 14px;
padding: 12px 14px;
background: var(--surface-2);
border: 1px solid var(--border);
border-radius: 4px;
font-size: 12px;
line-height: 1.55;
color: var(--muted);
cursor: pointer;
}
.tier-card__consent input[type="checkbox"] {
flex-shrink: 0;
margin-top: 2px;
cursor: pointer;
}
.tier-card__consent a {
color: var(--accent);
text-decoration: underline;
}
.tier-card__consent strong { color: var(--text); }
.tier-card__more {
margin-top: 14px;
padding-top: 14px;
border-top: 1px dashed var(--border);
font-size: 12px;
color: var(--muted);
line-height: 1.55;
}
/* Side-by-side feature comparison table. Lives below the cards and
makes the deltas readable at a glance the cards sell, the table
confirms. */
.compare-table {
width: 100%;
border-collapse: collapse;
margin: 0 0 16px;
font-size: 13.5px;
}
.compare-table th,
.compare-table td {
text-align: left;
padding: 12px 14px;
border-bottom: 1px solid var(--border);
vertical-align: top;
line-height: 1.5;
}
.compare-table thead th {
font-family: var(--font-mono);
font-size: 10.5px;
letter-spacing: 0.10em;
text-transform: uppercase;
color: var(--muted);
font-weight: 600;
border-bottom: 1px solid var(--border);
}
.compare-table th[scope="row"] {
font-weight: 500;
color: var(--text);
width: 38%;
}
.compare-table td.compare-table__free { color: var(--muted); }
.compare-table td.compare-table__paid { color: var(--text); font-weight: 500; }
.compare-table td.compare-table__paid strong { color: var(--accent); font-weight: 600; }
.compare-table td.compare-table__none { color: var(--dim); }
@media (max-width: 520px) {
.compare-table th[scope="row"] { width: 50%; }
.compare-table th, .compare-table td { padding: 10px 8px; font-size: 13px; }
}
/* --- Landing-page screenshots: hero shot, thumbnails, gallery, lightbox --- */
/* All clickable screenshots are <button>s reset the default chrome so they
read as image cards, not form controls. */
.shot {
appearance: none;
-webkit-appearance: none;
background: var(--surface);
border: 1px solid var(--border);
padding: 0;
margin: 0;
display: block;
width: 100%;
cursor: zoom-in;
border-radius: 4px;
overflow: hidden;
transition: border-color 120ms ease, transform 120ms ease,
box-shadow 160ms ease;
position: relative;
box-shadow: 0 6px 22px rgba(15, 23, 42, 0.18),
0 2px 6px rgba(15, 23, 42, 0.10);
}
.shot:hover {
border-color: var(--accent);
transform: translateY(-1px);
box-shadow: 0 10px 28px rgba(15, 23, 42, 0.22),
0 4px 10px rgba(15, 23, 42, 0.14);
}
.shot:focus-visible {
outline: none;
border-color: var(--accent);
box-shadow: 0 0 0 2px var(--accent),
0 6px 22px rgba(15, 23, 42, 0.18);
}
/* Dark mode: the soft slate shadow disappears against the near-black bg.
Use a deeper, slightly accent-tinted glow so the cards still lift. */
[data-theme="dark"] .shot {
box-shadow: 0 8px 30px rgba(0, 0, 0, 0.55),
0 2px 8px rgba(0, 0, 0, 0.35);
}
[data-theme="dark"] .shot:hover {
box-shadow: 0 14px 36px rgba(0, 0, 0, 0.65),
0 0 0 1px rgba(0, 217, 255, 0.20);
}
.shot img {
display: block;
width: 100%;
height: auto;
}
/* Hero screenshot — sits just below the headline CTAs, full landing width. */
.shot-hero {
max-width: 960px;
margin: 0 auto 56px;
padding: 0 24px;
}
.shot--hero .shot__zoom {
position: absolute;
bottom: 10px;
right: 12px;
font-family: var(--font-mono);
font-size: 11px;
letter-spacing: 0.12em;
text-transform: uppercase;
color: var(--bg);
background: var(--accent);
padding: 4px 9px;
border-radius: 3px;
opacity: 0.85;
pointer-events: none;
}
/* Thumbnail at the bottom of each feature card. */
.feature-card__shot {
margin-top: 18px;
}
.feature-card__shot img {
max-height: 200px;
object-fit: cover;
object-position: top left;
}
/* "More views" strip — flex so we can drop in 2-3 extra shots later. */
.shots-section {
margin-top: 8px;
}
.shots-grid {
display: grid;
gap: 18px;
grid-template-columns: repeat(auto-fit, minmax(280px, 1fr));
margin-top: 12px;
}
.shot__caption {
padding: 10px 12px;
font-size: 12.5px;
line-height: 1.5;
color: var(--muted);
background: var(--surface);
border-top: 1px solid var(--border);
text-align: left;
}
.shot__caption strong {
display: block;
font-size: 13px;
color: var(--text);
margin-bottom: 2px;
font-family: var(--font-mono);
letter-spacing: 0.04em;
}
/* Lightbox. <dialog> handles the modal mechanics. */
.shot-modal {
border: 1px solid var(--border);
background: var(--surface);
color: var(--text);
max-width: min(96vw, 1400px);
max-height: 94vh;
padding: 0;
border-radius: 6px;
overflow: hidden;
}
.shot-modal::backdrop {
background: rgba(0, 0, 0, 0.78);
}
.shot-modal img {
display: block;
max-width: 100%;
max-height: 80vh;
width: auto;
height: auto;
margin: 0 auto;
}
.shot-modal p {
margin: 0;
padding: 14px 22px 18px;
font-size: 13.5px;
line-height: 1.6;
color: var(--muted);
border-top: 1px solid var(--border);
background: var(--surface-2);
}
.shot-modal__close {
position: absolute;
top: 6px;
right: 8px;
background: transparent;
border: 0;
color: var(--text);
font-size: 28px;
line-height: 1;
padding: 4px 10px;
cursor: pointer;
font-family: var(--font-mono);
}
.shot-modal__close:hover,
.shot-modal__close:focus-visible {
color: var(--accent);
outline: none;
}
/* --- Invite-a-friend callout (pricing) ----------------------------- */
.invite-callout {
display: flex;
align-items: center;
gap: 20px;
padding: 20px 24px;
margin: 0 0 40px;
background: linear-gradient(135deg,
color-mix(in srgb, var(--accent) 12%, var(--surface)),
var(--surface));
border: 1px solid color-mix(in srgb, var(--accent) 35%, var(--border));
border-left: 4px solid var(--accent);
border-radius: 6px;
}
.invite-callout__icon {
font-size: 32px;
line-height: 1;
flex-shrink: 0;
filter: saturate(1.1);
}
.invite-callout__body { flex: 1; min-width: 0; }
.invite-callout__eyebrow {
font-family: var(--font-mono);
font-size: 10.5px;
letter-spacing: 0.10em;
text-transform: uppercase;
color: var(--accent);
font-weight: 600;
margin-bottom: 4px;
}
.invite-callout__headline {
font-size: 19px;
font-weight: 600;
color: var(--text);
line-height: 1.3;
margin-bottom: 4px;
}
.invite-callout__headline strong { color: var(--accent); font-weight: 700; }
.invite-callout__sub {
font-size: 13px;
color: var(--muted);
line-height: 1.5;
}
.invite-callout .btn-secondary { flex-shrink: 0; }
@media (max-width: 560px) {
.invite-callout { flex-direction: column; align-items: flex-start; gap: 14px; }
.invite-callout .btn-secondary { width: 100%; }
}
/* Generic text-only modal — reuse for any "click for the details" pattern. */
.text-modal {
border: 1px solid var(--border);
background: var(--surface);
color: var(--text);
max-width: min(92vw, 560px);
max-height: 88vh;
padding: 28px 28px 24px;
border-radius: 6px;
overflow-y: auto;
position: relative;
line-height: 1.6;
}
.text-modal::backdrop { background: rgba(0, 0, 0, 0.65); }
.text-modal__title {
font-size: 20px;
font-weight: 700;
margin: 0 0 14px;
padding-right: 36px;
color: var(--text);
}
.text-modal__head {
font-family: var(--font-mono);
font-size: 10.5px;
letter-spacing: 0.10em;
text-transform: uppercase;
color: var(--muted);
font-weight: 600;
margin: 20px 0 8px;
}
.text-modal p {
font-size: 13.5px;
color: var(--text);
margin: 0 0 12px;
}
.text-modal__list {
margin: 0 0 8px;
padding-left: 22px;
}
.text-modal__list li {
font-size: 13.5px;
color: var(--text);
margin-bottom: 6px;
line-height: 1.55;
}
.text-modal code {
font-family: var(--font-mono);
font-size: 12px;
background: var(--surface-2);
padding: 1px 5px;
border-radius: 2px;
}
.text-modal__close {
position: absolute;
top: 8px;
right: 10px;
background: transparent;
border: 0;
color: var(--muted);
font-size: 26px;
line-height: 1;
padding: 4px 10px;
cursor: pointer;
font-family: var(--font-mono);
}
.text-modal__close:hover,
.text-modal__close:focus-visible {
color: var(--accent);
outline: none;
}
/* --- Mobile (≤480px) -------------------------------------------------- */
@media (max-width: 480px) {
/* Hero headline already uses clamp(); shrink its lower bound so a
two-line headline doesn't push the CTAs below the fold on a
360px screen. */
.hero__headline { font-size: clamp(22px, 6vw, 32px); }
.hero__subhead { font-size: 14px; }
/* CTAs stack full-width on phones — easier tap targets. */
.hero__ctas { flex-direction: column; align-items: stretch; }
.btn-primary, .btn-secondary {
width: 100%;
text-align: center;
padding: 12px 18px;
}
/* Tier cards (pricing page) stack on phones. */
.tier-grid { grid-template-columns: 1fr; gap: 16px; }
.tier-card { padding: 18px; }
/* Tighten public-page outer padding. */
.public-shell { padding: 16px 12px; }
}

View file

@ -1,416 +0,0 @@
/* Cassandra settings page: rows, selects, dropzone, invite block,
* user menu dropdown, import preview, action buttons. */
/* Settings-page action button same visual language as .pf-actions
button so buttons across /settings (Manage subscription, future
actions) read as one family. Standalone class (not nested under a
parent) so it can be dropped onto any button anywhere on the page. */
.settings-btn {
font-family: var(--font-mono);
font-size: 11px;
letter-spacing: 0.06em;
text-transform: uppercase;
background: var(--surface-2);
color: var(--accent);
border: 1px solid var(--border);
padding: 7px 14px;
cursor: pointer;
border-radius: 2px;
text-decoration: none;
display: inline-block;
}
.settings-btn:hover { border-color: var(--accent); }
.settings-btn:disabled { opacity: 0.5; cursor: not-allowed; }
/* Icon-button variant for inline row actions (e.g. Manage subscription
gear in the Tier row). Square hit area, accent on hover, tooltip via
title attribute. */
.settings-icon-btn {
background: transparent;
border: 1px solid transparent;
color: var(--muted);
width: 32px;
height: 32px;
padding: 0;
display: inline-flex;
align-items: center;
justify-content: center;
cursor: pointer;
border-radius: 3px;
flex-shrink: 0;
transition: color 80ms linear, border-color 80ms linear, background 80ms linear;
}
.settings-icon-btn:hover {
color: var(--accent);
border-color: var(--border);
background: var(--surface-2);
}
.settings-icon-btn:disabled { opacity: 0.5; cursor: not-allowed; }
.settings-icon-btn svg { display: block; }
/* --- Settings page --------------------------------------------------- */
.settings-row {
display: flex;
align-items: baseline;
gap: 14px;
padding: 8px 0;
border-bottom: 1px solid var(--surface-2);
font-size: 13px;
}
.settings-row__label {
width: 110px;
flex-shrink: 0;
color: var(--muted);
text-transform: uppercase;
letter-spacing: 0.06em;
font-size: 10.5px;
font-family: var(--font-mono);
}
.settings-row__value { color: var(--text); }
.settings-row__hint {
color: var(--dim);
font-size: 11px;
margin-left: 8px;
}
/* Terminal-aesthetic <select> used in the Settings page. Native
* browser chrome stripped; we render a small chevron via crossed
* linear-gradients so the control matches the rest of the panel. */
.settings-select {
appearance: none;
-webkit-appearance: none;
-moz-appearance: none;
background: transparent;
border: 1px solid var(--border);
color: var(--text);
padding: 4px 28px 4px 8px;
font-family: var(--font-mono);
font-size: 12px;
border-radius: 2px;
cursor: pointer;
background-image:
linear-gradient(45deg, transparent 50%, var(--dim) 50%),
linear-gradient(-45deg, transparent 50%, var(--dim) 50%);
background-position: calc(100% - 13px) 50%, calc(100% - 9px) 50%;
background-size: 5px 5px, 5px 5px;
background-repeat: no-repeat;
transition: border-color 120ms ease-out, color 120ms ease-out;
}
.settings-select:hover,
.settings-select:focus {
outline: none;
border-color: var(--accent);
color: var(--text);
}
.settings-select option { color: var(--text); background: var(--surface); }
.settings-select option:disabled { color: var(--dim); }
.settings-status {
font-family: var(--font-mono);
font-size: 11px;
color: var(--muted);
letter-spacing: 0.04em;
}
.settings-status:empty { display: none; }
/* Sections are <details> elements collapsed by default to keep the
settings page scannable. Click the summary to expand. */
.settings-section {
margin-top: 14px;
border-top: 1px solid var(--surface-2);
padding-top: 14px;
}
.settings-section__head {
font-family: var(--font-mono);
font-size: 11px;
letter-spacing: 0.08em;
text-transform: uppercase;
color: var(--accent);
margin-bottom: 6px;
cursor: pointer;
list-style: none;
user-select: none;
display: flex;
align-items: center;
gap: 8px;
padding: 4px 0;
}
/* Suppress the native disclosure marker (Webkit + Firefox). */
.settings-section__head::-webkit-details-marker { display: none; }
.settings-section__head::marker { content: ""; }
.settings-section__head::before {
content: "▸";
color: var(--accent);
display: inline-block;
transition: transform 120ms ease-out;
font-size: 10px;
}
.settings-section[open] > .settings-section__head::before {
transform: rotate(90deg);
}
.settings-section[open] > .settings-section__head { margin-bottom: 10px; }
.settings-section__head:hover { color: var(--text); }
.settings-section__head:hover::before { color: var(--text); }
.settings-section__lede {
color: var(--muted);
font-size: 12.5px;
line-height: 1.55;
margin: 0 0 14px;
}
.settings-section__lede strong { color: var(--positive); font-weight: 600; }
.invite-block {
background: var(--surface-2);
border: 1px solid var(--border);
padding: 14px 16px;
}
.invite-block__label {
display: block;
font-family: var(--font-mono);
font-size: 10px;
letter-spacing: 0.08em;
text-transform: uppercase;
color: var(--muted);
margin-bottom: 4px;
}
.invite-block__label:not(:first-child) { margin-top: 12px; }
.invite-block__code {
font-family: var(--font-mono);
font-size: 22px;
letter-spacing: 0.32em;
color: var(--accent);
background: var(--surface);
padding: 10px 14px;
border: 1px solid var(--accent);
text-align: center;
user-select: all;
}
.invite-block__link {
display: flex;
gap: 6px;
}
.invite-block__link input {
flex: 1;
background: var(--surface);
color: var(--text);
border: 1px solid var(--border);
padding: 7px 10px;
font-family: var(--font-mono);
font-size: 12px;
}
.invite-block__link button {
background: var(--accent);
color: var(--bg);
border: 0;
padding: 0 14px;
font-family: var(--font-mono);
font-size: 11px;
letter-spacing: 0.06em;
text-transform: uppercase;
cursor: pointer;
}
.invite-block__link button:hover { opacity: 0.85; }
.invite-stats {
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: 1px;
background: var(--border);
border: 1px solid var(--border);
margin-top: 16px;
}
.invite-stats > div {
background: var(--surface);
padding: 10px 14px;
}
.invite-stats__label {
font-family: var(--font-mono);
font-size: 10px;
letter-spacing: 0.08em;
text-transform: uppercase;
color: var(--muted);
}
.invite-stats__value {
font-family: var(--font-mono);
font-size: 18px;
color: var(--text);
font-variant-numeric: tabular-nums;
margin-top: 4px;
}
/* Import preview action row — two stacked buttons with an explainer. */
.import-actions {
display: flex;
flex-wrap: wrap;
gap: 12px;
margin-top: 14px;
}
.import-choice { flex: 1 1 240px; min-width: 220px; }
.import-choice button { width: 100%; }
.import-choice .settings-row__hint {
display: block;
margin-top: 6px;
line-height: 1.5;
}
/* User chip in header — now a button that toggles a dropdown menu. */
.user-menu { position: relative; margin-left: 8px; }
.user-chip {
font-family: var(--font-mono);
font-size: 10.5px;
color: var(--muted);
letter-spacing: 0.04em;
background: none;
border: 0;
padding: 0;
cursor: pointer;
}
.user-chip:hover { color: var(--accent); }
.user-menu__caret { margin-left: 4px; opacity: 0.6; }
.user-menu__panel {
position: absolute;
top: calc(100% + 6px);
right: 0;
min-width: 160px;
background: var(--surface);
border: 1px solid var(--border);
border-radius: 6px;
box-shadow: 0 6px 18px rgba(0, 0, 0, 0.18);
z-index: 200;
padding: 4px 0;
}
.user-menu__item {
display: block;
padding: 8px 14px;
color: var(--text);
text-decoration: none;
font-size: 12px;
}
.user-menu__item:hover { background: var(--surface-2); color: var(--accent); }
/* --- Upload / import drag-drop zone (settings page) ------------------ */
.dz {
border: 2px dashed var(--border);
background: var(--surface-2);
padding: 36px 20px;
text-align: center;
cursor: pointer;
transition: border-color 0.15s, background 0.15s;
}
.dz:hover, .dz--over {
border-color: var(--accent);
background: color-mix(in srgb, var(--accent) 6%, var(--surface-2));
}
.dz__icon {
font-family: var(--font-mono);
font-size: 28px;
color: var(--accent);
letter-spacing: -2px;
margin-bottom: 6px;
}
.dz__label {
font-family: var(--font-mono);
font-size: 13px;
color: var(--text);
text-transform: uppercase;
letter-spacing: 0.08em;
}
.dz__hint { color: var(--muted); font-size: 11.5px; margin-top: 4px; }
.dz__hint a { color: var(--accent); }
.dz__filename { margin-top: 10px; color: var(--accent); font-size: 12px; font-family: var(--font-mono); min-height: 1em; }
.result {
margin-top: 20px;
padding: 14px;
border: 1px solid var(--border);
border-left: 3px solid var(--accent);
background: color-mix(in srgb, var(--accent) 4%, transparent);
font-family: var(--font-sans);
font-size: 13px;
}
.result--err { border-left-color: var(--negative); background: color-mix(in srgb, var(--negative) 5%, transparent); }
.result__head {
font-family: var(--font-mono);
font-size: 11px;
text-transform: uppercase;
letter-spacing: 0.08em;
color: var(--accent);
margin-bottom: 10px;
}
.result--err .result__head { color: var(--negative); }
.result__grid {
display: grid;
grid-template-columns: repeat(4, 1fr);
gap: 10px 18px;
margin-bottom: 10px;
}
.result__grid .k {
font-family: var(--font-mono);
font-size: 9.5px;
color: var(--muted);
text-transform: uppercase;
letter-spacing: 0.08em;
}
.result__grid .v { font-size: 17px; color: var(--text); font-variant-numeric: tabular-nums; margin-top: 2px; }
.result__grid .v.pos { color: var(--positive); }
.result__grid .v.neg { color: var(--negative); }
.result__row { color: var(--muted); font-size: 12px; margin-top: 6px; }
.result__warn { color: var(--alert); font-size: 12px; margin-top: 4px; }
.result__warn code { background: rgba(0,0,0,0.15); padding: 1px 4px; font-family: var(--font-mono); }
/* --- Modal text inputs (cloud-sync PIN modal, etc.) ---------------- */
/* Same visual treatment as auth-card so prompts read as a coherent
family. Replaces the inline `style="padding:8px"` that left these
inputs feeling cramped. */
.modal-input {
width: 100%;
background: var(--bg);
border: 1px solid var(--border);
color: var(--text);
font-family: var(--font-mono);
font-size: 16px;
padding: 12px 14px;
margin-bottom: 12px;
outline: none;
border-radius: 3px;
box-sizing: border-box;
}
.modal-input:focus { border-color: var(--accent); }
/* --- Mobile (≤480px) -------------------------------------------------- */
@media (max-width: 480px) {
/* Form rows stack: label above value instead of side-by-side. The
desktop layout uses a fixed 110px label column that pinches the
value column unbearably on a phone. */
.settings-row {
flex-direction: column;
align-items: stretch;
gap: 4px;
padding: 10px 0;
}
.settings-row__label {
width: auto;
font-size: 11px;
text-transform: uppercase;
letter-spacing: 0.06em;
}
.settings-select {
width: 100%;
font-size: 14px; /* avoids iOS Safari zoom-on-focus */
}
/* The two-column import picker becomes single column. */
.import-choice {
flex: 1 1 100%;
min-width: 0;
}
/* Buttons get a full-width tap target. */
.settings-btn { width: 100%; padding: 10px; }
.settings-icon-btn { width: 100%; justify-content: center; }
}

View file

@ -1,44 +0,0 @@
/* Cassandra design tokens: palette, dark-theme overrides, font stacks.
* Must load first so all other files can var(--foo). */
:root {
/* Light theme (default) */
--bg: #f5f3ec; /* warm off-white, easier on the eyes than pure white */
--surface: #ffffff;
--surface-2: #efece3;
--border: #d6d3cb;
--text: #1c1f25;
--muted: #545b69;
--dim: #8a8f9a;
--accent: #0e7490; /* deep teal — still terminal-feel on light */
--positive: #166534;
--negative: #b91c1c;
--alert: #c2410c;
--warning: #a16207;
--user-bubble-bg: rgba(14, 116, 144, 0.07);
}
[data-theme="dark"] {
--bg: #0a0e14;
--surface: #11151c;
--surface-2: #161b25;
--border: #2a3142;
--text: #d4dae8; /* lifted from #c0caf5 for readability */
--muted: #8189a1; /* lifted from #565f89 — was unreadably dim */
--dim: #565f89;
--accent: #00d9ff;
--positive: #50fa7b;
--negative: #ff5b5b;
--alert: #ff8a4a;
--warning: #f1fa8c;
--user-bubble-bg: rgba(0, 217, 255, 0.08);
}
/* Font stacks. Mono for terminal feel; sans for reading. */
:root {
--font-mono: 'JetBrains Mono', 'IBM Plex Mono', 'Fira Code', ui-monospace, monospace;
--font-sans: -apple-system, BlinkMacSystemFont, 'Inter', 'Segoe UI', Roboto,
'Helvetica Neue', system-ui, sans-serif;
}
* { box-sizing: border-box; }

View file

@ -37,8 +37,7 @@
append('user', text);
input.value = '';
send.disabled = true;
const thinking = append('assistant', '…');
thinking.classList.add('chat-msg--pending');
const thinking = append('assistant pending', '…');
try {
const r = await fetch('/api/chat', {
method: 'POST',

View file

@ -220,7 +220,7 @@
? new Date(status.updated_at).toISOString().replace('T', ' ').slice(0, 16) + ' UTC'
: '—';
mount.innerHTML =
'<div style="padding:16px;">' +
'<div class="pf-restore" style="padding:16px;">' +
'<div class="result__head">▸ Restore from cloud</div>' +
'<div class="result__row" style="margin-bottom:12px;">' +
'A synced portfolio is available for this account (last synced ' +
@ -303,8 +303,8 @@
return '<tr>' +
'<td class="label">' + esc(p.yahoo_ticker) + '</td>' +
'<td>' + esc(p.name || '') + '</td>' +
'<td class="num mobile-hide">' + fmt(p.qty, { maximumFractionDigits: 6 }) + '</td>' +
'<td class="num neu mobile-hide">' + fmt(p.avg_cost) + '</td>' +
'<td class="num">' + fmt(p.qty, { maximumFractionDigits: 6 }) + '</td>' +
'<td class="num neu">' + fmt(p.avg_cost) + '</td>' +
'<td class="num">' + lastDisplay + fxBadge + '</td>' +
'<td class="num ' + cls(p._ppl) + '">' + signed(p._ppl) + '</td>' +
'<td class="num ' + cls(p._ppl_pct) + '">' + pct(p._ppl_pct) + '</td>' +
@ -365,7 +365,7 @@
'<table class="dense">' +
'<thead><tr>' +
'<th>Ticker</th><th>Name</th>' +
'<th class="num mobile-hide">Qty</th><th class="num mobile-hide">Avg</th>' +
'<th class="num">Qty</th><th class="num">Avg</th>' +
'<th class="num">Last</th><th class="num">P/L</th>' +
'<th class="num">%</th>' +
'<th></th>' +
@ -387,12 +387,10 @@
});
// Re-hydrate any cached AI analysis so the 60s auto-refresh doesn't
// wipe it. Rendered expanded so the user keeps seeing the body they
// just generated — collapsing it under their cursor every minute
// reads as "the analysis disappeared". They can still click the
// header to collapse manually within a single refresh window.
// wipe it. Collapsed by default on hydration so the panel stays
// compact — click the header to expand.
if (pie.analysis && pie.analysis.content) {
showAnalysis(pie.analysis, { open: true });
showAnalysis(pie.analysis, { open: false });
}
}

View file

@ -1,245 +0,0 @@
(function () {
'use strict';
// Server-side hint: did the user have paid privileges when the page
// rendered? Used to decide whether to offer the 'Import & sync' button.
// We still call CassandraSync.getStatus() at click time as the source
// of truth, but this lets us skip rendering a button we know is dead.
// Value is passed via data-paid attribute on #drop-zone.
function ready(fn) {
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', fn);
} else { fn(); }
}
ready(function () {
var P = window.CassandraPortfolio;
if (!P) return;
var esc = P.esc, fmt = P.fmt, signed = P.signed, cls = P.cls;
var dropZone = document.getElementById('drop-zone');
var fileInput = document.getElementById('file-input');
var browseLink = document.getElementById('browse-link');
var filenameEl = document.getElementById('dz-filename');
var previewEl = document.getElementById('import-preview');
var resultEl = document.getElementById('import-result');
if (!dropZone) return;
var IS_PAID = dropZone.dataset.paid === 'true';
var currentPie = null; // most recently parsed pie, awaiting commit
function showError(msg) {
previewEl.hidden = true;
resultEl.className = 'result result--err';
resultEl.innerHTML =
'<div class="result__head">✕ Import failed</div>' +
'<div class="result__row">' + esc(msg) + '</div>';
resultEl.hidden = false;
}
function showSuccess(headline, sub) {
previewEl.hidden = true;
resultEl.className = 'result result--ok';
resultEl.innerHTML =
'<div class="result__head">' + esc(headline) + '</div>' +
(sub ? '<div class="result__row">' + sub + '</div>' : '') +
'<div class="result__row" style="margin-top:14px;">' +
'<a href="/">Open dashboard →</a>' +
'</div>';
resultEl.hidden = false;
}
function renderPreview(pie) {
currentPie = pie;
resultEl.hidden = true;
var t = pie.totals || {};
var rows = (pie.positions || []).map(function (p) {
var invested = (p.avg_cost != null && p.qty != null) ? p.avg_cost * p.qty : null;
return '<tr>' +
'<td class="label">' + esc(p.yahoo_ticker || p.t212_slice || '') + '</td>' +
'<td>' + esc(p.name || '') + '</td>' +
'<td class="num">' + fmt(p.qty, { maximumFractionDigits: 6 }) + '</td>' +
'<td class="num neu">' + fmt(p.avg_cost) + '</td>' +
'<td class="num">' + fmt(invested) + '</td>' +
'</tr>';
}).join('');
var warnings = (pie.warnings || []).map(function (w) {
return '<div class="result__warn">' + esc(w) + '</div>';
}).join('');
var syncBtn = IS_PAID
? ('<div class="import-choice">' +
'<button type="button" id="commit-sync">Import &amp; sync to cloud</button>' +
'<div class="settings-row__hint">' +
'Also stores an <strong>encrypted</strong> copy on the server, ' +
'restorable on any device with your PIN. Only you can decrypt ' +
'it &mdash; losing the PIN means losing the backup.' +
'</div>' +
'</div>')
: ('<div class="import-choice">' +
'<button type="button" disabled>Import &amp; sync to cloud</button>' +
'<div class="settings-row__hint">' +
'Encrypted cloud backup is available on the paid tier.' +
'</div>' +
'</div>');
previewEl.innerHTML =
'<div class="result result--ok" style="margin:0;">' +
'<div class="result__head">' +
'▸ Preview: <strong>' + esc(pie.pie_name || 'pie') + '</strong>' +
'</div>' +
'<div class="result__grid">' +
'<div><div class="k">Positions</div><div class="v">' + (pie.positions || []).length + '</div></div>' +
'<div><div class="k">Invested</div><div class="v">' + fmt(t.invested) + '</div></div>' +
'<div><div class="k">Value</div><div class="v">' + fmt(t.value) + '</div></div>' +
'<div><div class="k">Result</div><div class="v ' + cls(t.result) + '">' + signed(t.result) + '</div></div>' +
'</div>' +
warnings +
(rows
? '<div style="max-height:280px;overflow:auto;margin-top:12px;">' +
'<table class="dense">' +
'<thead><tr>' +
'<th>Ticker</th><th>Name</th>' +
'<th class="num">Qty</th>' +
'<th class="num">Avg</th>' +
'<th class="num">Invested</th>' +
'</tr></thead>' +
'<tbody>' + rows + '</tbody>' +
'</table>' +
'</div>'
: ''
) +
'<div class="import-actions">' +
'<div class="import-choice">' +
'<button type="button" id="commit-local">Import to this browser</button>' +
'<div class="settings-row__hint">' +
'Saved to this browser only. No server-side copy of your holdings.' +
'</div>' +
'</div>' +
syncBtn +
'<div style="flex-basis:100%;">' +
'<button type="button" id="commit-cancel" class="pf-secondary">' +
'Cancel</button>' +
'</div>' +
'</div>' +
'</div>';
previewEl.hidden = false;
document.getElementById('commit-local').addEventListener('click', commitLocal);
document.getElementById('commit-cancel').addEventListener('click', resetUploader);
var syncEl = document.getElementById('commit-sync');
if (syncEl) syncEl.addEventListener('click', commitSync);
}
function commitLocal() {
if (!currentPie) return;
P.savePie(currentPie);
showSuccess('▸ Imported to this browser.',
'Pie kept locally; no server-side copy.');
currentPie = null;
}
async function commitSync() {
if (!currentPie) return;
// Save locally first so the cloud-sync flow uses the freshly-imported
// pie (the enable-PIN modal in this same page reads from localStorage).
P.savePie(currentPie);
var S = window.CassandraSync;
if (!S) { showError('Cloud sync module not loaded.'); return; }
var status;
try { status = await S.getStatus(); }
catch (e) { showError('Could not check sync status: ' + (e.message || e)); return; }
if (!status.paid) {
showError('Cloud sync requires the paid tier.');
return;
}
if (status.exists) {
// Already enabled — try a direct push using the cached session
// key. If no key is cached (fresh browser session), this throws,
// and we fall back to the enable-PIN modal so the user can
// re-enter their PIN.
try {
await S.pushSync(currentPie, null);
showSuccess('▸ Imported and synced.',
'Encrypted copy updated on the server.');
currentPie = null;
if (window.cassandraRefreshSyncStatus) window.cassandraRefreshSyncStatus();
return;
} catch (e) {
// Fall through to modal so the user can re-auth with their PIN.
console.warn('direct push failed, falling back to PIN modal', e);
}
}
// !status.exists OR cached-key push failed → use the modal.
if (window.cassandraOpenSyncModal) {
window.cassandraOpenSyncModal({
onSuccess: function () {
showSuccess('▸ Imported and synced.',
'Cloud sync is now enabled and the pie is stored encrypted.');
currentPie = null;
},
});
} else {
showError('Cloud sync UI unavailable on this page. ' +
'Use the Cloud sync section below to enable.');
}
}
function resetUploader() {
currentPie = null;
previewEl.hidden = true;
previewEl.innerHTML = '';
resultEl.hidden = true;
filenameEl.textContent = '';
fileInput.value = '';
}
async function parseFile(file) {
filenameEl.textContent = file.name + ' (' + Math.round(file.size / 1024) + ' KB) — parsing…';
previewEl.hidden = true;
resultEl.hidden = true;
try {
var pie = await P.parseCsv(file);
renderPreview(pie);
filenameEl.textContent = file.name + ' (' + Math.round(file.size / 1024) + ' KB)';
} catch (e) {
filenameEl.textContent = file.name + ' (failed)';
showError(e.message || 'Unknown error');
}
}
browseLink.addEventListener('click', function (e) { e.preventDefault(); fileInput.click(); });
fileInput.addEventListener('change', function () {
if (fileInput.files[0]) parseFile(fileInput.files[0]);
});
['dragenter', 'dragover'].forEach(function (ev) {
dropZone.addEventListener(ev, function (e) {
e.preventDefault(); e.stopPropagation();
dropZone.classList.add('dz--over');
});
});
['dragleave', 'drop'].forEach(function (ev) {
dropZone.addEventListener(ev, function (e) {
e.preventDefault(); e.stopPropagation();
dropZone.classList.remove('dz--over');
});
});
dropZone.addEventListener('drop', function (e) {
var f = e.dataTransfer.files && e.dataTransfer.files[0];
if (f) parseFile(f);
});
dropZone.addEventListener('click', function (e) {
if (e.target.tagName !== 'A') fileInput.click();
});
});
})();

View file

@ -1,154 +0,0 @@
(function () {
'use strict';
function $(id) { return document.getElementById(id); }
function esc(s) {
return String(s == null ? '' : s).replace(/[&<>"']/g, c => ({
'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#39;'
}[c]));
}
document.addEventListener('DOMContentLoaded', function () {
if (!window.CassandraSync) return;
const statusEl = $('sync-status');
const actionsEl = $('sync-actions');
const feedbackEl = $('sync-feedback');
const modal = $('sync-modal');
const pin1 = $('sync-pin1');
const pin2 = $('sync-pin2');
const ack = $('sync-ack');
const errEl = $('sync-modal-err');
function setFeedback(msg, ok) {
feedbackEl.style.color = ok ? 'var(--positive)' : '';
feedbackEl.textContent = msg || '';
}
// External callers (the Import section above) can pass a callback
// that fires after a successful enable-and-push.
let pendingOnSuccess = null;
function openModal(opts) {
pendingOnSuccess = (opts && opts.onSuccess) || null;
modal.style.display = 'flex';
// Focus PIN field after the layout flush so the caret lands.
setTimeout(() => pin1.focus(), 0);
}
function closeModal() {
modal.style.display = 'none';
pin1.value = ''; pin2.value = '';
ack.checked = false; errEl.hidden = true;
pendingOnSuccess = null;
}
$('sync-modal-cancel').addEventListener('click', closeModal);
// Backdrop click + Esc key dismiss the modal.
modal.addEventListener('click', function (e) {
if (e.target === modal) closeModal();
});
document.addEventListener('keydown', function (e) {
if (e.key === 'Escape' && modal.style.display !== 'none') closeModal();
});
$('sync-modal-form').addEventListener('submit', async function (e) {
e.preventDefault();
errEl.hidden = true;
if (pin1.value !== pin2.value) {
errEl.textContent = 'PINs do not match.';
errEl.hidden = false; return;
}
if (pin1.value.length < 4) {
errEl.textContent = 'PIN must be at least 4 characters.';
errEl.hidden = false; return;
}
const pie = JSON.parse(localStorage.getItem('cassandra.pie') || 'null');
if (!pie) {
errEl.textContent =
'No portfolio in this browser yet. Import a CSV first, then enable sync.';
errEl.hidden = false; return;
}
try {
await window.CassandraSync.pushSync(pie, pin1.value);
const cb = pendingOnSuccess;
closeModal(); // clears pendingOnSuccess
await refresh();
setFeedback('Cloud sync enabled. Your encrypted portfolio is stored.', true);
if (typeof cb === 'function') {
try { cb(); } catch (cbErr) { console.warn('sync onSuccess threw', cbErr); }
}
} catch (e2) {
errEl.textContent = e2.message || 'Failed to enable sync.';
errEl.hidden = false;
}
});
async function refresh() {
let status;
try { status = await window.CassandraSync.getStatus(); }
catch (e) {
statusEl.querySelector('.settings-row__value').innerHTML =
'<span class="pf-warn">' + esc(e.message || 'status check failed') + '</span>';
return;
}
const valueEl = statusEl.querySelector('.settings-row__value');
actionsEl.innerHTML = '';
if (status.exists && status.orphaned) {
// The stored blob can no longer be decrypted (server key rotated
// since it was written). The data is permanently unrecoverable,
// so silently clean up the dead row and re-render in the
// standard "off" state — leaving a soft one-liner so the user
// knows why they need to re-import.
try { await window.CassandraSync.disableSync(); }
catch (e) { console.warn('auto-clear stale sync failed', e); }
setFeedback('Your previous cloud backup couldnt be restored. Re-import your portfolio to enable cloud sync again.', true);
await refresh();
return;
} else if (status.exists) {
const when = status.updated_at
? new Date(status.updated_at).toISOString().replace('T', ' ').slice(0, 16) + ' UTC'
: '—';
valueEl.innerHTML =
'<span class="badge badge--ok">On</span> ' +
'<span class="settings-row__hint">last synced ' + esc(when) + '</span>';
const disable = document.createElement('button');
disable.type = 'button';
disable.className = 'pf-secondary';
disable.textContent = 'Disable sync';
disable.addEventListener('click', async function () {
if (!confirm('Remove your encrypted portfolio from the server? Your local copy is untouched.')) return;
try {
await window.CassandraSync.disableSync();
await refresh();
setFeedback('Cloud sync disabled. Server copy removed.', true);
} catch (e) { setFeedback(e.message || 'Disable failed.', false); }
});
actionsEl.appendChild(disable);
} else {
valueEl.innerHTML = '<span class="badge badge--ver">Off</span>';
// Only offer 'Enable' when there's actually a pie to encrypt;
// otherwise the user would hit a dead-end at the modal.
const hasPie = !!localStorage.getItem('cassandra.pie');
if (!hasPie) {
const hint = document.createElement('span');
hint.className = 'settings-row__hint';
hint.innerHTML =
'Nothing to sync yet &mdash; ' +
'<a href="#import">import a portfolio</a> first, then come back to enable cloud sync.';
actionsEl.appendChild(hint);
return;
}
const enable = document.createElement('button');
enable.type = 'button';
enable.textContent = 'Enable cloud sync';
enable.addEventListener('click', openModal);
actionsEl.appendChild(enable);
}
}
// Hooks for the Import section to drive this modal + status row.
window.cassandraOpenSyncModal = openModal;
window.cassandraRefreshSyncStatus = refresh;
refresh();
});
})();

View file

@ -36,16 +36,8 @@
} catch (e) { document.documentElement.dataset.theme = 'light'; }
})();
</script>
<link rel="stylesheet" href="{{ url_for('static', path='/css/tokens.css') }}?v={{ ASSET_VERSION }}" />
<link rel="stylesheet" href="{{ url_for('static', path='/css/layout.css') }}?v={{ ASSET_VERSION }}" />
<link rel="stylesheet" href="{{ url_for('static', path='/css/panels.css') }}?v={{ ASSET_VERSION }}" />
<link rel="stylesheet" href="{{ url_for('static', path='/css/dashboard.css') }}?v={{ ASSET_VERSION }}" />
<link rel="stylesheet" href="{{ url_for('static', path='/css/portfolio.css') }}?v={{ ASSET_VERSION }}" />
<link rel="stylesheet" href="{{ url_for('static', path='/css/log-chat.css') }}?v={{ ASSET_VERSION }}" />
<link rel="stylesheet" href="{{ url_for('static', path='/css/auth.css') }}?v={{ ASSET_VERSION }}" />
<link rel="stylesheet" href="{{ url_for('static', path='/css/settings.css') }}?v={{ ASSET_VERSION }}" />
<link rel="stylesheet" href="{{ url_for('static', path='/css/news.css') }}?v={{ ASSET_VERSION }}" />
<script src="{{ url_for('static', path='/js/htmx.min.js') }}?v={{ ASSET_VERSION }}" defer></script>
<link rel="stylesheet" href="{{ url_for('static', path='/css/cassandra.css') }}" />
<script src="{{ url_for('static', path='/js/htmx.min.js') }}" defer></script>
<script>
// Inject the user's preferred TONE (NOVICE | INTERMEDIATE) into every
// HTMX request so AI-generated panels resolve to the right cached
@ -126,34 +118,12 @@
// Reflect the saved value in the toggle on load.
var pill = document.getElementById('tone-toggle');
if (pill) pill.dataset.tone = currentTone();
// Same for the theme toggle — pull the current theme that the
// top-of-page inline script already wrote to <html data-theme>.
var themePill = document.getElementById('theme-toggle');
if (themePill) themePill.dataset.theme = document.documentElement.dataset.theme || 'light';
// Sync the /log page's tone badge to the saved tone — server-side
// first render defaults to "pro", but a returning NOVICE user
// should see "novice" before any toggle interaction.
window.cassandraSyncToneBadge(currentTone());
});
// Sync the optional #tone-badge (currently used on the /log page) to
// the supplied tone. NOVICE renders as "novice"; INTERMEDIATE renders
// as "pro" — matches the header toggle's display labels. Safe to call
// on pages that don't render the badge.
window.cassandraSyncToneBadge = function (tone) {
var badge = document.getElementById('tone-badge');
if (!badge) return;
var label = (tone === 'NOVICE') ? 'novice' : 'pro';
badge.className = 'badge badge--tone-' + label;
var span = badge.querySelector('[data-tone-label]');
if (span) span.textContent = label;
};
window.cassandraSetTone = function (newTone) {
try { localStorage.setItem('cassandra.tone', newTone); } catch (e) {}
var pill = document.getElementById('tone-toggle');
if (pill) pill.dataset.tone = newTone;
window.cassandraSyncToneBadge(newTone);
// Trigger a re-fetch of every AI-driven HTMX target on the page.
// Easiest: dispatch a custom event that the relevant elements
// listen to. Simpler still: fire htmx.trigger on the well-known
@ -164,78 +134,6 @@
if (el && window.htmx) window.htmx.trigger(el, 'tone-changed');
});
};
window.cassandraSetTheme = function (newTheme) {
document.documentElement.dataset.theme = newTheme;
var pill = document.getElementById('theme-toggle');
if (pill) pill.dataset.theme = newTheme;
try { localStorage.setItem('cassandra.theme', newTheme); } catch (e) {}
};
// Static-label i18n dictionary. AI-generated content is re-fetched via
// HTMX (server-side translation), but plain UI labels are baked into
// the HTML at render time. This dict + applyI18n() below let the
// language toggle swap labels live without a page refresh.
// Convention: data-i18n="key" sets textContent;
// data-i18n-placeholder="key" sets .placeholder.
// First-render correctness is handled by the template's user_lang
// conditional, so applyI18n only kicks in on subsequent toggles.
window.CASSANDRA_I18N = {
'chat.title': { en: 'Ask Cassandra',
it: 'Chiedi a Cassandra' },
'chat.hint': { en: 'grounded on the latest log + live data',
it: "basato sull'ultimo log + dati in tempo reale" },
'chat.lede': { en: "Ask about today's analysis. The model sees the latest strategic log, live market readings across all groups, and the last 24h of thesis-filtered headlines. Refresh wipes this conversation.",
it: "Fai domande sull'analisi di oggi. Il modello vede l'ultimo log strategico, le quotazioni di mercato in tempo reale per tutti i gruppi e le ultime 24h di titoli filtrati per tesi. Un refresh della pagina cancella questa conversazione." },
'chat.placeholder': { en: 'e.g. why is the defence sleeve flat through Hormuz?',
it: 'es. perché il comparto difesa è piatto nonostante Hormuz?' },
'chat.send': { en: 'Send',
it: 'Invia' },
};
window.cassandraApplyI18n = function (lang) {
document.querySelectorAll('[data-i18n]').forEach(function (el) {
var key = el.getAttribute('data-i18n');
var entry = window.CASSANDRA_I18N[key];
if (entry && entry[lang] != null) el.textContent = entry[lang];
});
document.querySelectorAll('[data-i18n-placeholder]').forEach(function (el) {
var key = el.getAttribute('data-i18n-placeholder');
var entry = window.CASSANDRA_I18N[key];
if (entry && entry[lang] != null) el.placeholder = entry[lang];
});
};
window.cassandraSetLang = async function (newLang) {
var pill = document.getElementById('lang-toggle');
if (!pill) return;
var prev = pill.dataset.lang;
if (prev === newLang) return;
// Optimistic update — flip the pill immediately so the click feels
// responsive. Revert on PATCH failure.
pill.dataset.lang = newLang;
try {
var r = await fetch('/api/settings/language', {
method: 'PATCH',
headers: {'Content-Type': 'application/json'},
credentials: 'same-origin',
body: JSON.stringify({lang: newLang}),
});
if (!r.ok) throw new Error('HTTP ' + r.status);
// Swap any static UI labels that have i18n bindings.
window.cassandraApplyI18n(newLang);
// Trigger HTMX-driven panels to re-fetch in the new language.
// Same shape as cassandraSetTone — every panel that listens to
// tone-changed also listens to lang-changed.
['#dash-header-container', '#log-panel .panel-body',
'#indicators-body', '#log-content'].forEach(function (sel) {
var el = document.querySelector(sel);
if (el && window.htmx) window.htmx.trigger(el, 'lang-changed');
});
} catch (e) {
pill.dataset.lang = prev;
console.warn('language switch failed:', e);
}
};
</script>
<script>
// Render any <time datetime="..."> in the browser's local timezone.
@ -262,61 +160,26 @@
<body>
<div class="app">
<header class="app-header">
{# Left group keeps brand + BETA chip pinned together as a single
layout cell so the chip can't drift away from the wordmark when
the header grows or shrinks. #}
<div class="header-left">
<a href="/" class="brand" aria-label="Dashboard">{{ BRAND_NAME }}</a>
{% if BETA_MODE %}<span class="beta-chip" title="Beta — feedback welcome at hello@read.markets">BETA</span>{% endif %}
</div>
{# Mobile hamburger — shown only at ≤480px via CSS. #}
<button type="button" id="drawer-toggle" class="drawer-toggle"
aria-label="Open menu" aria-controls="mobile-drawer" aria-expanded="false">
<span class="drawer-toggle__bar"></span>
<span class="drawer-toggle__bar"></span>
<span class="drawer-toggle__bar"></span>
</button>
{# Wrapper: display:contents on desktop (zero layout effect), fixed
slide-out panel on mobile. Holds nav + header-right widgets. #}
<div id="mobile-drawer" class="mobile-drawer">
<nav>
<a href="/" class="{% if request.url.path == '/' %}active{% endif %}">Dashboard</a>
<a href="/news" class="{% if request.url.path == '/news' %}active{% endif %}">News</a>
<a href="/log" class="{% if request.url.path.startswith('/log') %}active{% endif %}">Log</a>
</nav>
<div class="header-right">
{# The "Pro" label maps to the INTERMEDIATE tone server-side —
kept that way to avoid touching every stored user preference
and API contract. The mode itself (terse, no glossary
tooltips, assumes fluency) is unchanged; only the display
label changes. #}
<div id="tone-toggle" class="tone-toggle" data-tone="INTERMEDIATE"
role="group" aria-label="Explanation level">
<button type="button" data-value="NOVICE"
onclick="cassandraSetTone('NOVICE')">Novice</button>
<button type="button" data-value="INTERMEDIATE"
onclick="cassandraSetTone('INTERMEDIATE')">Pro</button>
</div>
<div id="theme-toggle" class="theme-toggle" data-theme="light"
role="group" aria-label="Theme">
<button type="button" data-value="light"
onclick="cassandraSetTheme('light')">Light</button>
<button type="button" data-value="dark"
onclick="cassandraSetTheme('dark')">Dark</button>
onclick="cassandraSetTone('INTERMEDIATE')">Intermediate</button>
</div>
<button class="theme-toggle" type="button" aria-label="Toggle theme"
onclick="(function(){var d=document.documentElement;var t=d.dataset.theme==='light'?'dark':'light';d.dataset.theme=t;try{localStorage.setItem('cassandra.theme',t);}catch(e){}})()">
<span class="theme-toggle__label"></span>
</button>
{% set cu = request.state.current_user if request.state and request.state.current_user is defined else None %}
{% if cu and cu.user %}
<div id="lang-toggle" class="lang-toggle" data-lang="{{ cu.user.lang or 'en' }}"
role="group" aria-label="AI output language"
title="Language the AI uses for the log, digest and portfolio commentary">
<button type="button" data-value="en"
onclick="cassandraSetLang('en')">EN</button>
<button type="button" data-value="it"
onclick="cassandraSetLang('it')">IT</button>
</div>
{% endif %}
{% if cu and (cu.user or cu.is_admin) %}
<div class="user-menu">
<button type="button" id="user-menu-toggle" class="user-chip"
@ -338,13 +201,8 @@
{% endif %}
<span class="meta">v0.1 · UTC</span>
</div>
</div>
</header>
{# Drawer backdrop. Hidden by default; CSS shows it when
body.drawer-open is set. Click closes the drawer. #}
<div id="drawer-backdrop" class="drawer-backdrop" hidden></div>
<script>
(function () {
var btn = document.getElementById('user-menu-toggle');
@ -365,56 +223,6 @@
})();
</script>
<script>
// Mobile drawer (hamburger → right-side slide-out panel). The CSS
// gates visibility of #drawer-toggle to ≤480px, so on desktop this
// wiring is harmless — the click handler is attached but nobody
// can fire it.
(function () {
var btn = document.getElementById('drawer-toggle');
var drawer = document.getElementById('mobile-drawer');
var backdrop = document.getElementById('drawer-backdrop');
if (!btn || !drawer || !backdrop) return;
function open() {
document.body.classList.add('drawer-open');
backdrop.hidden = false;
btn.setAttribute('aria-expanded', 'true');
}
function close() {
document.body.classList.remove('drawer-open');
backdrop.hidden = true;
btn.setAttribute('aria-expanded', 'false');
}
btn.addEventListener('click', function () {
if (document.body.classList.contains('drawer-open')) close(); else open();
});
backdrop.addEventListener('click', close);
document.addEventListener('keydown', function (e) {
if (e.key === 'Escape' && document.body.classList.contains('drawer-open')) close();
});
// Swipe-right inside the drawer closes — small native feel.
// Tracks pointer down then up; if the user moves >40px right
// and stays within 60° of horizontal, treat as a close gesture.
var startX = null, startY = null;
drawer.addEventListener('pointerdown', function (e) {
if (!document.body.classList.contains('drawer-open')) return;
startX = e.clientX; startY = e.clientY;
});
drawer.addEventListener('pointerup', function (e) {
if (startX === null) return;
var dx = e.clientX - startX;
var dy = Math.abs(e.clientY - startY);
startX = startY = null;
if (dx > 40 && dy < dx * 0.6) close();
});
// If a nav link inside the drawer is clicked, close after the
// navigation kicks off so the panel doesn't linger on the next page.
drawer.addEventListener('click', function (e) {
if (e.target.tagName === 'A') close();
});
})();
</script>
<main class="app-main">
{% block main %}{% endblock %}
</main>

View file

@ -5,7 +5,7 @@
<div id="dash-header-container"
style="grid-column: 1 / -1;"
hx-get="/api/summary/aggregate?as=html"
hx-trigger="load, every 300s, tone-changed, lang-changed"
hx-trigger="load, every 300s, tone-changed"
hx-swap="innerHTML">
<div class="empty">loading aggregate read…</div>
</div>
@ -29,7 +29,7 @@
<div id="indicators-body"
class="panel-body panel-body--scroll"
hx-get="/api/indicators/{{ groups[0] }}?as=html"
hx-trigger="load, tone-changed, lang-changed"
hx-trigger="load, tone-changed"
hx-swap="innerHTML">
<div class="empty">loading…</div>
</div>
@ -102,9 +102,9 @@
</div>
</div>
</section>
<script src="{{ url_for('static', path='/js/portfolio-sync.js') }}?v={{ ASSET_VERSION }}" defer></script>
<script src="{{ url_for('static', path='/js/portfolio.js') }}?v={{ ASSET_VERSION }}" defer></script>
<script src="{{ url_for('static', path='/js/portfolio_edit.js') }}?v={{ ASSET_VERSION }}" defer></script>
<script src="{{ url_for('static', path='/js/portfolio-sync.js') }}" defer></script>
<script src="{{ url_for('static', path='/js/portfolio.js') }}" defer></script>
<script src="{{ url_for('static', path='/js/portfolio_edit.js') }}" defer></script>
<section id="log-panel" class="panel">
<div class="panel-header">
@ -115,7 +115,7 @@
</div>
<div class="panel-body"
hx-get="/api/log/latest?as=html"
hx-trigger="load, every 300s, tone-changed, lang-changed"
hx-trigger="load, every 300s, tone-changed"
hx-swap="innerHTML">
<div class="empty">awaiting first log…</div>
</div>

View file

@ -27,10 +27,10 @@
<section class="shot-hero">
<button class="shot shot--hero"
data-full="{{ url_for('static', path='/images/dashboard.png') }}?v={{ ASSET_VERSION }}"
data-full="{{ url_for('static', path='/images/dashboard.png') }}"
data-alt="Read the Markets dashboard"
data-caption="The dashboard. An aggregate cross-asset read at the top, hand-picked indicator groups underneath. Reading level toggle (Novice / Intermediate) flips every AI-generated panel between plain-English and terse-pro framing.">
<img src="{{ url_for('static', path='/images/dashboard.png') }}?v={{ ASSET_VERSION }}"
<img src="{{ url_for('static', path='/images/dashboard.png') }}"
alt="Dashboard preview" loading="lazy">
<span class="shot__zoom" aria-hidden="true">Click to enlarge</span>
</button>
@ -48,10 +48,10 @@
off-hours stay quiet.
</p>
<button class="shot feature-card__shot"
data-full="{{ url_for('static', path='/images/news-feed.png') }}?v={{ ASSET_VERSION }}"
data-full="{{ url_for('static', path='/images/news-feed.png') }}"
data-alt="News feed with auto-tagged headlines"
data-caption="The news feed. Each headline carries one or more theme tags (rates, AI, energy, geopolitics, …) so you can keep the threads you care about and mute the ones you don't. Click a tag to include; shift-click to exclude.">
<img src="{{ url_for('static', path='/images/news-feed.png') }}?v={{ ASSET_VERSION }}"
<img src="{{ url_for('static', path='/images/news-feed.png') }}"
alt="News feed thumbnail" loading="lazy">
</button>
</div>
@ -66,10 +66,10 @@
in earnings, policy, valuation &mdash; not chart patterns.
</p>
<button class="shot feature-card__shot"
data-full="{{ url_for('static', path='/images/indicators-read.png') }}?v={{ ASSET_VERSION }}"
data-full="{{ url_for('static', path='/images/indicators-read.png') }}"
data-alt="Indicators panel with AI commentary"
data-caption="The indicators panel. Tabs across asset classes (equity, rates, commodities, FX, bonds, …); each tab carries a one-paragraph 'read' written by the model on top of the live prices. The numbers anchor the prose so the commentary is checkable, not floating.">
<img src="{{ url_for('static', path='/images/indicators-read.png') }}?v={{ ASSET_VERSION }}"
<img src="{{ url_for('static', path='/images/indicators-read.png') }}"
alt="Indicators panel thumbnail" loading="lazy">
</button>
</div>
@ -87,10 +87,10 @@
not a forecast and not advice on any investment decision.
</p>
<button class="shot feature-card__shot"
data-full="{{ url_for('static', path='/images/strategic-log.png') }}?v={{ ASSET_VERSION }}"
data-full="{{ url_for('static', path='/images/strategic-log.png') }}"
data-alt="Strategic log — the editorial AI read"
data-caption="The strategic log. The model writes a fresh interpretation through the trading day, taking the previous draft as context so it updates rather than starts over. Sections are typed: date header, TL;DR, what moved, what to watch, system temperature. Paid users get a refresh every hour; free users get one every six.">
<img src="{{ url_for('static', path='/images/strategic-log.png') }}?v={{ ASSET_VERSION }}"
<img src="{{ url_for('static', path='/images/strategic-log.png') }}"
alt="Strategic log thumbnail" loading="lazy">
</button>
</div>
@ -100,10 +100,10 @@
<h2 class="public-section__head">More views</h2>
<div class="shots-grid">
<button class="shot"
data-full="{{ url_for('static', path='/images/chat-with-log.png') }}?v={{ ASSET_VERSION }}"
data-full="{{ url_for('static', path='/images/chat-with-log.png') }}"
data-alt="Ask follow-up questions against any past log"
data-caption="Ask follow-up questions against any past log. The chat panel inherits the log's full context, so you can pull on a thread without re-pasting headlines or re-explaining the setup.">
<img src="{{ url_for('static', path='/images/chat-with-log.png') }}?v={{ ASSET_VERSION }}"
<img src="{{ url_for('static', path='/images/chat-with-log.png') }}"
alt="Chat-with-log thumbnail" loading="lazy">
<div class="shot__caption">
<strong>Ask anything about a log</strong>
@ -116,10 +116,10 @@
<section class="public-section">
<p style="font-size: 13.5px; color: var(--muted);">
Paid users can also drop a portfolio CSV from their broker
&mdash; Trading 212 natively, other formats auto-detected &mdash;
for an AI sense-check on concentration, regime fit, and currency
exposure. Holdings stay in your browser by default; opt in to
encrypted cloud sync to restore on another device.
(Trading 212 today, more brokers planned) for an AI sense-check on
concentration, regime fit, and currency exposure. Holdings stay in
your browser by default; opt in to encrypted cloud sync to restore
on another device.
</p>
</section>

View file

@ -8,11 +8,9 @@
<span class="meta">
selected {{ selected_iso }}
&nbsp;·&nbsp;
{# Tone badge mirrors the header toggle. base.html's DOMContentLoaded
hook and cassandraSetTone() both update this element so the label
stays in step with the user's choice — no need to re-render the
page when the toggle flips. #}
<span id="tone-badge" class="badge badge--tone-pro">tone <span data-tone-label>pro</span></span>
<span class="meta__hint">new logs use:</span>
<span class="badge badge--tone-{{ current_tone | lower }}">tone {{ current_tone | lower }}</span>
<span class="badge badge--analysis-{{ current_analysis | lower }}">analysis {{ current_analysis | lower }}</span>
</span>
</div>
@ -27,7 +25,7 @@
<article id="log-content"
class="log-page__content"
hx-get="/api/log/by-date/{{ selected_iso }}?as=html"
hx-trigger="load, tone-changed, lang-changed"
hx-trigger="load, tone-changed"
hx-swap="innerHTML">
<div class="empty">loading log…</div>
</article>
@ -35,18 +33,21 @@
{% if paid %}
<aside id="chat-sidebar" class="log-page__chat">
<div class="chat-header">
<span class="chat-title" data-i18n="chat.title">{% if user_lang == 'it' %}Chiedi a Cassandra{% else %}Ask Cassandra{% endif %}</span>
<span class="chat-hint" data-i18n="chat.hint">{% if user_lang == 'it' %}basato sull'ultimo log + dati in tempo reale{% else %}grounded on the latest log + live data{% endif %}</span>
<span class="chat-title">Ask Cassandra</span>
<span class="chat-hint">grounded on the latest log + live data</span>
</div>
<div id="chat-thread" class="chat-thread">
<div class="chat-msg chat-msg--system" data-i18n="chat.lede">{% if user_lang == 'it' %}Fai domande sull'analisi di oggi. Il modello vede l'ultimo log strategico, le quotazioni di mercato in tempo reale per tutti i gruppi e le ultime 24h di titoli filtrati per tesi. Un refresh della pagina cancella questa conversazione.{% else %}Ask about today's analysis. The model sees the latest strategic log, live market readings across all groups, and the last 24h of thesis-filtered headlines. Refresh wipes this conversation.{% endif %}</div>
<div class="chat-msg chat-msg--system">
Ask about today's analysis. The model sees the latest strategic log,
live market readings across all groups, and the last 24h of
thesis-filtered headlines. Refresh wipes this conversation.
</div>
</div>
<form id="chat-form" class="chat-form" autocomplete="off">
<textarea id="chat-input" rows="2"
data-i18n-placeholder="chat.placeholder"
placeholder="{% if user_lang == 'it' %}es. perché il comparto difesa è piatto nonostante Hormuz?{% else %}e.g. why is the defence sleeve flat through Hormuz?{% endif %}"
placeholder="e.g. why is the defence sleeve flat through Hormuz?"
required></textarea>
<button id="chat-send" type="submit" data-i18n="chat.send">{% if user_lang == 'it' %}Invia{% else %}Send{% endif %}</button>
<button id="chat-send" type="submit">Send</button>
</form>
</aside>
{% else %}
@ -68,5 +69,5 @@
{% endif %}
</div>
</section>
{% if paid %}<script src="{{ url_for('static', path='/js/chat.js') }}?v={{ ASSET_VERSION }}" defer></script>{% endif %}
{% if paid %}<script src="{{ url_for('static', path='/js/chat.js') }}" defer></script>{% endif %}
{% endblock %}

View file

@ -10,9 +10,7 @@
catch (e) { document.documentElement.dataset.theme = 'light'; }
})();
</script>
<link rel="stylesheet" href="{{ url_for('static', path='/css/tokens.css') }}?v={{ ASSET_VERSION }}" />
<link rel="stylesheet" href="{{ url_for('static', path='/css/layout.css') }}?v={{ ASSET_VERSION }}" />
<link rel="stylesheet" href="{{ url_for('static', path='/css/auth.css') }}?v={{ ASSET_VERSION }}" />
<link rel="stylesheet" href="{{ url_for('static', path='/css/cassandra.css') }}" />
</head>
<body>
<div class="auth-shell">

View file

@ -20,11 +20,11 @@
<table class="dense">
<thead>
<tr>
<th>Symbol</th><th class="mobile-hide">Label</th>
<th class="num">Price</th><th class="mobile-hide">Ccy</th>
<th class="num">1d</th><th class="num">1m</th><th class="num mobile-hide">1y</th>
{% if has_anchor %}<th class="num mobile-hide">anchor</th>{% endif %}
<th class="mobile-hide">as-of</th>
<th>Symbol</th><th>Label</th>
<th class="num">Price</th><th>Ccy</th>
<th class="num">1d</th><th class="num">1m</th><th class="num">1y</th>
{% if has_anchor %}<th class="num">anchor</th>{% endif %}
<th>as-of</th>
</tr>
</thead>
<tbody>
@ -46,22 +46,22 @@
<td class="label has-tip" title="{{ q.symbol }}{% if tip %} — {{ tip }}{% endif %}">
{{ short_sym }}
</td>
<td class="mobile-hide" {% if tip %}title="{{ tip }}"{% endif %}>{{ q.label or "" }}</td>
<td {% if tip %}title="{{ tip }}"{% endif %}>{{ q.label or "" }}</td>
<td class="num">{{ q.price | price }}</td>
<td class="neu mobile-hide">{{ q.currency or "" }}</td>
<td class="neu">{{ q.currency or "" }}</td>
{% for k in ["1d","1m","1y"] %}
{% set v = q.changes.get(k) if q.changes else None %}
<td class="num {% if k == '1y' %}mobile-hide {% endif %}{% if v is none %}neu{% elif v >= 0 %}pos{% else %}neg{% endif %}">
<td class="num {% if v is none %}neu{% elif v >= 0 %}pos{% else %}neg{% endif %}">
{% if v is none %}—{% else %}{{ "%+.2f"|format(v) }}%{% endif %}
</td>
{% endfor %}
{% if has_anchor %}
{% set va = q.changes.get('anchor') if q.changes else None %}
<td class="num mobile-hide {% if va is none %}neu{% elif va >= 0 %}pos{% else %}neg{% endif %}">
<td class="num {% if va is none %}neu{% elif va >= 0 %}pos{% else %}neg{% endif %}">
{% if va is none %}—{% else %}{{ "%+.2f"|format(va) }}%{% endif %}
</td>
{% endif %}
<td class="neu mobile-hide">{{ q.as_of or "" }}</td>
<td class="neu">{{ q.as_of or "" }}</td>
</tr>
{% endif %}
{% endfor %}

View file

@ -51,8 +51,8 @@
<tr>
<th>Ticker</th>
<th>Name</th>
<th class="num mobile-hide">Qty</th>
<th class="num mobile-hide">Avg</th>
<th class="num">Qty</th>
<th class="num">Avg</th>
<th class="num">Last</th>
<th class="num">P/L</th>
<th class="num">%</th>
@ -63,8 +63,8 @@
<tr>
<td class="label">{{ pos.ticker }}</td>
<td>{{ pos.name or "" }}</td>
<td class="num mobile-hide">{{ pos.quantity | price }}</td>
<td class="num neu mobile-hide">{{ pos.average_price | price }}</td>
<td class="num">{{ pos.quantity | price }}</td>
<td class="num neu">{{ pos.average_price | price }}</td>
<td class="num">{{ pos.current_price | price }}</td>
<td class="num {% if pos.ppl is none %}neu{% elif pos.ppl >= 0 %}pos{% else %}neg{% endif %}">
{{ pos.ppl | signed }}

View file

@ -62,7 +62,7 @@
<li><strong>Strategic log refreshed every hour</strong> instead of every six &mdash; track intraday moves as they unfold</li>
<li><strong>Follow-up chat on any past log</strong> &mdash; ask the model a question against the day&rsquo;s full context</li>
<li><strong>Daily email digest</strong> (Mon&ndash;Sat) &mdash; ~600-word read of the session ahead, on top of the Sunday recap</li>
<li><strong>Portfolio import</strong> from any broker CSV &mdash; Trading 212 natively, other formats auto-detected</li>
<li><strong>Portfolio import</strong> from a broker CSV (Trading 212 supported today; more brokers planned)</li>
<li><strong>AI portfolio read</strong> &mdash; diversification, sector and currency concentration, macro-regime fit on your holdings</li>
<li><strong>Optional encrypted cloud sync</strong> &mdash; PIN-derived encryption in your browser, second-layer wrap on the server, no plaintext holdings server-side</li>
</ul>

View file

@ -14,12 +14,7 @@
} catch (e) { document.documentElement.dataset.theme = 'light'; }
})();
</script>
<link rel="stylesheet" href="{{ url_for('static', path='/css/tokens.css') }}?v={{ ASSET_VERSION }}" />
<link rel="stylesheet" href="{{ url_for('static', path='/css/layout.css') }}?v={{ ASSET_VERSION }}" />
<link rel="stylesheet" href="{{ url_for('static', path='/css/panels.css') }}?v={{ ASSET_VERSION }}" />
<link rel="stylesheet" href="{{ url_for('static', path='/css/auth.css') }}?v={{ ASSET_VERSION }}" />
<link rel="stylesheet" href="{{ url_for('static', path='/css/settings.css') }}?v={{ ASSET_VERSION }}" />
<link rel="stylesheet" href="{{ url_for('static', path='/css/public.css') }}?v={{ ASSET_VERSION }}" />
<link rel="stylesheet" href="{{ url_for('static', path='/css/cassandra.css') }}" />
</head>
<body class="public-page">
<div class="public-shell">

View file

@ -106,7 +106,7 @@
<span class="neu">Investing &rarr; Your Pie &rarr; &middot;&middot;&middot; &rarr; Export</span>.</span>
</p>
<div id="drop-zone" class="dz" data-paid="{{ 'true' if paid and paid.active else 'false' }}">
<div id="drop-zone" class="dz">
<input type="file" id="file-input" name="file" accept=".csv,text/csv" hidden>
<div class="dz__icon"></div>
<div class="dz__label">Drop your broker's portfolio CSV here</div>
@ -187,7 +187,7 @@
<label><input type="radio" name="digest-tone" value="NOVICE"
{% if (user.digest_tone or 'INTERMEDIATE') == 'NOVICE' %}checked{% endif %}> Novice</label>
<label><input type="radio" name="digest-tone" value="INTERMEDIATE"
{% if (user.digest_tone or 'INTERMEDIATE') == 'INTERMEDIATE' %}checked{% endif %}> Pro</label>
{% if (user.digest_tone or 'INTERMEDIATE') == 'INTERMEDIATE' %}checked{% endif %}> Intermediate</label>
</div>
</div>
</div>
@ -224,47 +224,6 @@
})();
</script>
{# --- Language block ------------------------------------------------ #}
<details class="settings-section">
<summary class="settings-section__head">Language</summary>
<p class="settings-section__lede">
Language the AI uses for the strategic log, your daily digest, and
portfolio commentary. The interface itself stays in English for now.
</p>
<div class="settings-row">
<select id="lang-select" class="settings-select">
<option value="en" {% if (user.lang or 'en') == 'en' %}selected{% endif %}>English</option>
<option value="it" {% if (user.lang or 'en') == 'it' %}selected{% endif %}>Italiano</option>
<option value="es" disabled>Español &middot; coming soon</option>
<option value="fr" disabled>Français &middot; coming soon</option>
<option value="de" disabled>Deutsch &middot; coming soon</option>
</select>
<span id="lang-status" class="settings-status" aria-live="polite"></span>
</div>
<script>
(function () {
var sel = document.getElementById('lang-select');
var status = document.getElementById('lang-status');
if (!sel) return;
sel.addEventListener('change', async function () {
status.textContent = 'saving…';
try {
var r = await fetch('/api/settings/language', {
method: 'PATCH',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify({lang: sel.value}),
});
if (!r.ok) throw new Error('HTTP ' + r.status);
status.textContent = '✓ saved';
setTimeout(function () { status.textContent = ''; }, 1500);
} catch (e) {
status.textContent = '✗ failed';
}
});
})();
</script>
</details>
{# --- Cloud sync block --------------------------------------------- #}
<details class="settings-section">
<summary class="settings-section__head">Cloud sync (encrypted)</summary>
@ -301,7 +260,7 @@
<div id="sync-modal" class="modal"
style="position:fixed;inset:0;background:rgba(0,0,0,0.45);
display:none;align-items:center;justify-content:center;z-index:1000;">
<div style="background:var(--surface);color:var(--text);
<div style="background:var(--panel-bg,#fff);color:var(--text,#000);
padding:22px 26px;border-radius:8px;max-width:440px;width:90%;">
<div class="result__head" id="sync-modal-title" style="margin-bottom:8px;">
Enable cloud sync
@ -332,8 +291,161 @@
</div>
</div>
<script src="{{ url_for(static, path=/js/portfolio-sync.js) }}" defer></script>
<script src="{{ url_for(static, path=/js/settings-sync.js) }}" defer></script>
<script src="{{ url_for('static', path='/js/portfolio-sync.js') }}" defer></script>
<script>
(function () {
function $(id) { return document.getElementById(id); }
function esc(s) {
return String(s == null ? '' : s).replace(/[&<>"']/g, c => ({
'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#39;'
}[c]));
}
document.addEventListener('DOMContentLoaded', function () {
if (!window.CassandraSync) return;
const statusEl = $('sync-status');
const actionsEl = $('sync-actions');
const feedbackEl = $('sync-feedback');
const modal = $('sync-modal');
const pin1 = $('sync-pin1');
const pin2 = $('sync-pin2');
const ack = $('sync-ack');
const errEl = $('sync-modal-err');
function setFeedback(msg, ok) {
feedbackEl.style.color = ok ? 'var(--ok,#2a9d57)' : '';
feedbackEl.textContent = msg || '';
}
// External callers (the Import section above) can pass a callback
// that fires after a successful enable-and-push.
let pendingOnSuccess = null;
function openModal(opts) {
pendingOnSuccess = (opts && opts.onSuccess) || null;
modal.style.display = 'flex';
// Focus PIN field after the layout flush so the caret lands.
setTimeout(() => pin1.focus(), 0);
}
function closeModal() {
modal.style.display = 'none';
pin1.value = ''; pin2.value = '';
ack.checked = false; errEl.hidden = true;
pendingOnSuccess = null;
}
$('sync-modal-cancel').addEventListener('click', closeModal);
// Backdrop click + Esc key dismiss the modal.
modal.addEventListener('click', function (e) {
if (e.target === modal) closeModal();
});
document.addEventListener('keydown', function (e) {
if (e.key === 'Escape' && modal.style.display !== 'none') closeModal();
});
$('sync-modal-form').addEventListener('submit', async function (e) {
e.preventDefault();
errEl.hidden = true;
if (pin1.value !== pin2.value) {
errEl.textContent = 'PINs do not match.';
errEl.hidden = false; return;
}
if (pin1.value.length < 4) {
errEl.textContent = 'PIN must be at least 4 characters.';
errEl.hidden = false; return;
}
const pie = JSON.parse(localStorage.getItem('cassandra.pie') || 'null');
if (!pie) {
errEl.textContent =
'No portfolio in this browser yet. Import a CSV first, then enable sync.';
errEl.hidden = false; return;
}
try {
await window.CassandraSync.pushSync(pie, pin1.value);
const cb = pendingOnSuccess;
closeModal(); // clears pendingOnSuccess
await refresh();
setFeedback('Cloud sync enabled. Your encrypted portfolio is stored.', true);
if (typeof cb === 'function') {
try { cb(); } catch (cbErr) { console.warn('sync onSuccess threw', cbErr); }
}
} catch (e2) {
errEl.textContent = e2.message || 'Failed to enable sync.';
errEl.hidden = false;
}
});
async function refresh() {
let status;
try { status = await window.CassandraSync.getStatus(); }
catch (e) {
statusEl.querySelector('.settings-row__value').innerHTML =
'<span class="pf-warn">' + esc(e.message || 'status check failed') + '</span>';
return;
}
const valueEl = statusEl.querySelector('.settings-row__value');
actionsEl.innerHTML = '';
if (status.exists && status.orphaned) {
// The stored blob can no longer be decrypted (server key rotated
// since it was written). The data is permanently unrecoverable,
// so silently clean up the dead row and re-render in the
// standard "off" state — leaving a soft one-liner so the user
// knows why they need to re-import.
try { await window.CassandraSync.disableSync(); }
catch (e) { console.warn('auto-clear stale sync failed', e); }
setFeedback('Your previous cloud backup couldnt be restored. Re-import your portfolio to enable cloud sync again.', true);
await refresh();
return;
} else if (status.exists) {
const when = status.updated_at
? new Date(status.updated_at).toISOString().replace('T', ' ').slice(0, 16) + ' UTC'
: '—';
valueEl.innerHTML =
'<span class="badge badge--ok">On</span> ' +
'<span class="settings-row__hint">last synced ' + esc(when) + '</span>';
const disable = document.createElement('button');
disable.type = 'button';
disable.className = 'pf-secondary';
disable.textContent = 'Disable sync';
disable.addEventListener('click', async function () {
if (!confirm('Remove your encrypted portfolio from the server? Your local copy is untouched.')) return;
try {
await window.CassandraSync.disableSync();
await refresh();
setFeedback('Cloud sync disabled. Server copy removed.', true);
} catch (e) { setFeedback(e.message || 'Disable failed.', false); }
});
actionsEl.appendChild(disable);
} else {
valueEl.innerHTML = '<span class="badge badge--ver">Off</span>';
// Only offer 'Enable' when there's actually a pie to encrypt;
// otherwise the user would hit a dead-end at the modal.
const hasPie = !!localStorage.getItem('cassandra.pie');
if (!hasPie) {
const hint = document.createElement('span');
hint.className = 'settings-row__hint';
hint.innerHTML =
'Nothing to sync yet &mdash; ' +
'<a href="#import">import a portfolio</a> first, then come back to enable cloud sync.';
actionsEl.appendChild(hint);
return;
}
const enable = document.createElement('button');
enable.type = 'button';
enable.textContent = 'Enable cloud sync';
enable.addEventListener('click', openModal);
actionsEl.appendChild(enable);
}
}
// Hooks for the Import section to drive this modal + status row.
window.cassandraOpenSyncModal = openModal;
window.cassandraRefreshSyncStatus = refresh;
refresh();
});
})();
</script>
{% endif %}
<script>
@ -357,7 +469,249 @@
{% if user %}
{# Import widget wiring — auto-parse on drop, preview, then commit. #}
<script src="{{ url_for('static', path='/js/portfolio.js') }}?v={{ ASSET_VERSION }}" defer></script>
<script src="{{ url_for('static', path='/js/settings-import.js') }}?v={{ ASSET_VERSION }}" defer></script>
<script src="{{ url_for('static', path='/js/portfolio.js') }}" defer></script>
<script>
(function () {
// Server-side hint: did the user have paid privileges when the page
// rendered? Used to decide whether to offer the 'Import & sync' button.
// We still call CassandraSync.getStatus() at click time as the source
// of truth, but this lets us skip rendering a button we know is dead.
var IS_PAID = {{ 'true' if paid and paid.active else 'false' }};
function ready(fn) {
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', fn);
} else { fn(); }
}
ready(function () {
var P = window.CassandraPortfolio;
if (!P) return;
var esc = P.esc, fmt = P.fmt, signed = P.signed, cls = P.cls;
var dropZone = document.getElementById('drop-zone');
var fileInput = document.getElementById('file-input');
var browseLink = document.getElementById('browse-link');
var filenameEl = document.getElementById('dz-filename');
var previewEl = document.getElementById('import-preview');
var resultEl = document.getElementById('import-result');
if (!dropZone) return;
var currentPie = null; // most recently parsed pie, awaiting commit
function showError(msg) {
previewEl.hidden = true;
resultEl.className = 'result result--err';
resultEl.innerHTML =
'<div class="result__head">✕ Import failed</div>' +
'<div class="result__row">' + esc(msg) + '</div>';
resultEl.hidden = false;
}
function showSuccess(headline, sub) {
previewEl.hidden = true;
resultEl.className = 'result result--ok';
resultEl.innerHTML =
'<div class="result__head">' + esc(headline) + '</div>' +
(sub ? '<div class="result__row">' + sub + '</div>' : '') +
'<div class="result__row" style="margin-top:14px;">' +
'<a href="/">Open dashboard →</a>' +
'</div>';
resultEl.hidden = false;
}
function renderPreview(pie) {
currentPie = pie;
resultEl.hidden = true;
var t = pie.totals || {};
var rows = (pie.positions || []).map(function (p) {
var invested = (p.avg_cost != null && p.qty != null) ? p.avg_cost * p.qty : null;
return '<tr>' +
'<td class="label">' + esc(p.yahoo_ticker || p.t212_slice || '') + '</td>' +
'<td>' + esc(p.name || '') + '</td>' +
'<td class="num">' + fmt(p.qty, { maximumFractionDigits: 6 }) + '</td>' +
'<td class="num neu">' + fmt(p.avg_cost) + '</td>' +
'<td class="num">' + fmt(invested) + '</td>' +
'</tr>';
}).join('');
var warnings = (pie.warnings || []).map(function (w) {
return '<div class="result__warn">' + esc(w) + '</div>';
}).join('');
var syncBtn = IS_PAID
? ('<div class="import-choice">' +
'<button type="button" id="commit-sync">Import &amp; sync to cloud</button>' +
'<div class="settings-row__hint">' +
'Also stores an <strong>encrypted</strong> copy on the server, ' +
'restorable on any device with your PIN. Only you can decrypt ' +
'it &mdash; losing the PIN means losing the backup.' +
'</div>' +
'</div>')
: ('<div class="import-choice">' +
'<button type="button" disabled>Import &amp; sync to cloud</button>' +
'<div class="settings-row__hint">' +
'Encrypted cloud backup is available on the paid tier.' +
'</div>' +
'</div>');
previewEl.innerHTML =
'<div class="result result--ok" style="margin:0;">' +
'<div class="result__head">' +
'▸ Preview: <strong>' + esc(pie.pie_name || 'pie') + '</strong>' +
'</div>' +
'<div class="result__grid">' +
'<div><div class="k">Positions</div><div class="v">' + (pie.positions || []).length + '</div></div>' +
'<div><div class="k">Invested</div><div class="v">' + fmt(t.invested) + '</div></div>' +
'<div><div class="k">Value</div><div class="v">' + fmt(t.value) + '</div></div>' +
'<div><div class="k">Result</div><div class="v ' + cls(t.result) + '">' + signed(t.result) + '</div></div>' +
'</div>' +
warnings +
(rows
? '<div style="max-height:280px;overflow:auto;margin-top:12px;">' +
'<table class="dense">' +
'<thead><tr>' +
'<th>Ticker</th><th>Name</th>' +
'<th class="num">Qty</th>' +
'<th class="num">Avg</th>' +
'<th class="num">Invested</th>' +
'</tr></thead>' +
'<tbody>' + rows + '</tbody>' +
'</table>' +
'</div>'
: ''
) +
'<div class="import-actions">' +
'<div class="import-choice">' +
'<button type="button" id="commit-local">Import to this browser</button>' +
'<div class="settings-row__hint">' +
'Saved to this browser only. No server-side copy of your holdings.' +
'</div>' +
'</div>' +
syncBtn +
'<div style="flex-basis:100%;">' +
'<button type="button" id="commit-cancel" class="pf-secondary">' +
'Cancel</button>' +
'</div>' +
'</div>' +
'</div>';
previewEl.hidden = false;
document.getElementById('commit-local').addEventListener('click', commitLocal);
document.getElementById('commit-cancel').addEventListener('click', resetUploader);
var syncEl = document.getElementById('commit-sync');
if (syncEl) syncEl.addEventListener('click', commitSync);
}
function commitLocal() {
if (!currentPie) return;
P.savePie(currentPie);
showSuccess('▸ Imported to this browser.',
'Pie kept locally; no server-side copy.');
currentPie = null;
}
async function commitSync() {
if (!currentPie) return;
// Save locally first so the cloud-sync flow uses the freshly-imported
// pie (the enable-PIN modal in this same page reads from localStorage).
P.savePie(currentPie);
var S = window.CassandraSync;
if (!S) { showError('Cloud sync module not loaded.'); return; }
var status;
try { status = await S.getStatus(); }
catch (e) { showError('Could not check sync status: ' + (e.message || e)); return; }
if (!status.paid) {
showError('Cloud sync requires the paid tier.');
return;
}
if (status.exists) {
// Already enabled — try a direct push using the cached session
// key. If no key is cached (fresh browser session), this throws,
// and we fall back to the enable-PIN modal so the user can
// re-enter their PIN.
try {
await S.pushSync(currentPie, null);
showSuccess('▸ Imported and synced.',
'Encrypted copy updated on the server.');
currentPie = null;
if (window.cassandraRefreshSyncStatus) window.cassandraRefreshSyncStatus();
return;
} catch (e) {
// Fall through to modal so the user can re-auth with their PIN.
console.warn('direct push failed, falling back to PIN modal', e);
}
}
// !status.exists OR cached-key push failed → use the modal.
if (window.cassandraOpenSyncModal) {
window.cassandraOpenSyncModal({
onSuccess: function () {
showSuccess('▸ Imported and synced.',
'Cloud sync is now enabled and the pie is stored encrypted.');
currentPie = null;
},
});
} else {
showError('Cloud sync UI unavailable on this page. ' +
'Use the Cloud sync section below to enable.');
}
}
function resetUploader() {
currentPie = null;
previewEl.hidden = true;
previewEl.innerHTML = '';
resultEl.hidden = true;
filenameEl.textContent = '';
fileInput.value = '';
}
async function parseFile(file) {
filenameEl.textContent = file.name + ' (' + Math.round(file.size / 1024) + ' KB) — parsing…';
previewEl.hidden = true;
resultEl.hidden = true;
try {
var pie = await P.parseCsv(file);
renderPreview(pie);
filenameEl.textContent = file.name + ' (' + Math.round(file.size / 1024) + ' KB)';
} catch (e) {
filenameEl.textContent = file.name + ' (failed)';
showError(e.message || 'Unknown error');
}
}
browseLink.addEventListener('click', function (e) { e.preventDefault(); fileInput.click(); });
fileInput.addEventListener('change', function () {
if (fileInput.files[0]) parseFile(fileInput.files[0]);
});
['dragenter', 'dragover'].forEach(function (ev) {
dropZone.addEventListener(ev, function (e) {
e.preventDefault(); e.stopPropagation();
dropZone.classList.add('dz--over');
});
});
['dragleave', 'drop'].forEach(function (ev) {
dropZone.addEventListener(ev, function (e) {
e.preventDefault(); e.stopPropagation();
dropZone.classList.remove('dz--over');
});
});
dropZone.addEventListener('drop', function (e) {
var f = e.dataTransfer.files && e.dataTransfer.files[0];
if (f) parseFile(f);
});
dropZone.addEventListener('click', function (e) {
if (e.target.tagName !== 'A') fileInput.click();
});
});
})();
</script>
{% endif %}
{% endblock %}

View file

@ -77,16 +77,21 @@
<section class="public-section">
<h2 class="public-section__head">5. Paid plans</h2>
<p>
Paid plans are available at &pound;7/month or &pound;70/year (terms
and current prices on the <a href="/pricing">pricing page</a>). New
annual subscriptions begin with a 14-day free trial; monthly
subscriptions begin immediately on payment. Paid features remain
active for as long as the subscription is current or any
time-bounded credit granted to your account is still valid. You
can cancel a paid subscription at any time; cancellation takes
effect at the end of the current billing period unless otherwise
stated. Detailed refund and cancellation rights are set out in
section 6 below.
If and when paid plans become available, you will be told the
applicable fees at point of sale. Paid features remain active for as
long as the subscription is current or any time-bounded credit
granted to your account is still valid. You can cancel a paid
subscription at any time; cancellation takes effect at the end of
the current billing period unless otherwise stated.
</p>
<p>
Where the law gives you a 14-day right to cancel a subscription
(Consumer Contracts (Information, Cancellation and Additional
Charges) Regulations 2013, UK), that right applies. By starting to
use a paid feature immediately on purchase you agree we may begin
supplying the service within the cancellation period, and you
acknowledge that you lose the right to cancel in respect of any
digital content already delivered.
</p>
</section>

104
app/templates/upload.html Normal file
View file

@ -0,0 +1,104 @@
{% extends "base.html" %}
{% block title %}{{ BRAND_NAME }} · Import Portfolio{% endblock %}
{% block main %}
<section class="panel" style="grid-column: 1 / -1; max-width: 760px; margin: 0 auto;">
<div class="panel-header">
<span class="title">Import portfolio (Trading 212 CSV)</span>
<span class="meta">held locally · optional encrypted cloud sync (paid)</span>
</div>
<div class="panel-body" style="padding: 18px clamp(16px, 4vw, 32px) 24px;">
<p style="color: var(--muted); font-size: 12.5px; margin: 0 0 14px; line-height: 1.6;">
Export your pie from the T212 web app
(<span class="neu">Trading 212 → Investing → Your Pie → ⋯ → Export</span>)
and drop the CSV here. Each Slice is resolved to its Yahoo ticker;
the parsed pie is kept in <em>this browser's localStorage</em>.
The server learns only which tickers exist (anonymously) so it can
fetch their prices. If you have <a href="/settings">cloud sync</a>
enabled, an <strong>encrypted</strong> copy is also pushed to the
server &mdash; only your PIN can decrypt it.
</p>
<form id="upload-form" autocomplete="off">
<div id="drop-zone" class="dz">
<input type="file" id="file-input" name="file" accept=".csv,text/csv" hidden>
<div class="dz__icon"></div>
<div class="dz__label">Drop a T212 pie CSV here</div>
<div class="dz__hint">or <a href="#" id="browse-link">browse</a> · max 1 MB</div>
<div class="dz__filename" id="dz-filename"></div>
</div>
<button id="submit-btn" type="submit" disabled style="margin-top:18px;">Parse</button>
</form>
<div id="result" class="result" hidden></div>
</div>
</section>
<script src="{{ url_for('static', path='/js/portfolio-sync.js') }}" defer></script>
<script src="{{ url_for('static', path='/js/portfolio.js') }}" defer></script>
<script>
(function () {
function ready(fn) {
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', fn);
} else { fn(); }
}
ready(function () {
var dropZone = document.getElementById('drop-zone');
var fileInput = document.getElementById('file-input');
var browseLink = document.getElementById('browse-link');
var filenameEl = document.getElementById('dz-filename');
var submitBtn = document.getElementById('submit-btn');
var form = document.getElementById('upload-form');
var resultEl = document.getElementById('result');
function setFile(file) {
if (!file) return;
var dt = new DataTransfer();
dt.items.add(file);
fileInput.files = dt.files;
filenameEl.textContent = file.name + ' (' + Math.round(file.size / 1024) + ' KB)';
submitBtn.disabled = false;
}
browseLink.addEventListener('click', function (e) { e.preventDefault(); fileInput.click(); });
fileInput.addEventListener('change', function () {
if (fileInput.files[0]) setFile(fileInput.files[0]);
});
['dragenter', 'dragover'].forEach(function (ev) {
dropZone.addEventListener(ev, function (e) {
e.preventDefault(); e.stopPropagation();
dropZone.classList.add('dz--over');
});
});
['dragleave', 'drop'].forEach(function (ev) {
dropZone.addEventListener(ev, function (e) {
e.preventDefault(); e.stopPropagation();
dropZone.classList.remove('dz--over');
});
});
dropZone.addEventListener('drop', function (e) {
if (e.dataTransfer.files && e.dataTransfer.files[0]) setFile(e.dataTransfer.files[0]);
});
dropZone.addEventListener('click', function (e) {
if (e.target.tagName !== 'A') fileInput.click();
});
form.addEventListener('submit', async function (e) {
e.preventDefault();
if (!fileInput.files[0]) return;
submitBtn.disabled = true;
submitBtn.textContent = 'Parsing…';
// CassandraPortfolio is exposed by /static/js/portfolio.js.
var ok = await window.CassandraPortfolio.handleUpload(form, fileInput.files[0], resultEl);
submitBtn.textContent = ok ? 'Parsed' : 'Parse';
submitBtn.disabled = !ok;
});
});
})();
</script>
{% endblock %}

View file

@ -10,9 +10,7 @@
catch (e) { document.documentElement.dataset.theme = 'light'; }
})();
</script>
<link rel="stylesheet" href="{{ url_for('static', path='/css/tokens.css') }}?v={{ ASSET_VERSION }}" />
<link rel="stylesheet" href="{{ url_for('static', path='/css/layout.css') }}?v={{ ASSET_VERSION }}" />
<link rel="stylesheet" href="{{ url_for('static', path='/css/auth.css') }}?v={{ ASSET_VERSION }}" />
<link rel="stylesheet" href="{{ url_for('static', path='/css/cassandra.css') }}" />
</head>
<body>
<div class="auth-shell">

View file

@ -3,7 +3,6 @@ Imported by both routers/pages.py and routers/api.py so the filters are
registered exactly once."""
from __future__ import annotations
import time
from pathlib import Path
from fastapi.templating import Jinja2Templates
@ -14,13 +13,6 @@ from app.config import get_settings
from app.services.glossary import wrap_glossary
# Cache-busting token for static assets. Computed once at import time
# (i.e. process startup), so every container restart yields a fresh
# value and browsers refetch CSS/JS instead of serving stale cache.
# Templates append `?v={{ ASSET_VERSION }}` to every static URL.
ASSET_VERSION = str(int(time.time()))
TEMPLATE_DIR = Path(__file__).resolve().parent / "templates"
@ -85,4 +77,3 @@ templates.env.globals["LEGAL_OPERATOR"] = branding.LEGAL_OPERATOR
templates.env.globals["OPERATOR_EMAIL"] = branding.OPERATOR_EMAIL
templates.env.globals["OPERATOR_JURISDICTION"] = branding.OPERATOR_JURISDICTION
templates.env.globals["BETA_MODE"] = get_settings().BETA_MODE
templates.env.globals["ASSET_VERSION"] = ASSET_VERSION

File diff suppressed because it is too large Load diff

View file

@ -1,411 +0,0 @@
# Localization (Italian active, ES/FR/DE WIP) — Design Spec
**Date:** 2026-05-27
**Status:** Draft — pending implementation plan
## Context
All AI-generated content (strategic log, daily email digest, portfolio
analysis) is English-only today. The operator wants to add Italian
translation as the first localization, with Spanish, French, and
German listed as "coming soon" in the settings UI but not yet
functional. Italian must work end-to-end from settings dropdown to
rendered output; the other three exist as commitments and design
placeholders so adding them later is a flag flip.
This is foundational plumbing: it touches every LLM call site we ship
today and shapes how every future AI feature handles language. Doing it
first means later features (qty/cost edit narratives, P/L summaries,
alert text, etc.) inherit the i18n wiring for free instead of needing a
retrofit.
## Goals
- A user can pick `Italiano` from a settings dropdown and immediately
see every AI-generated surface in Italian.
- Adding `es`, `fr`, or `de` later is a one-line change to a constant
plus optionally validating the dropdown's enabled set.
- Translation cost stays in the "noise" range — we use the same
DeepSeek-4-flash model the rest of the system uses (~$0.28/M output
tokens). No separate "cheap translation" plumbing.
- Strategic-log reads stay instant for non-English users — no
read-time translation latency.
## Non-goals
- UI label translation. The dashboard buttons, settings labels,
headings, and other chrome remain English. Only the AI's own output
is localized.
- Translation of indicator summaries. The same pattern will apply when
those become user-facing prose, but they aren't surfaced today.
- Backfilling translations for historical strategic logs. Translation
only happens going forward, at the moment a new English log is written.
- Activation of Spanish/French/German. They appear in the dropdown as
"coming soon" with disabled options; the value-validation layer in
the settings POST refuses them.
## Two distinct translation paths
The system has two categories of AI-generated content, with different
generation patterns:
### Per-user content (portfolio analysis only)
Portfolio analysis is the only AI-generated surface whose *content* is
genuinely per-user — each call's input is the user's own pie. Here we
add the `"Respond in Italian."` clause to the system prompt when
`user.lang != 'en'`. One LLM call, no extra cost, no extra latency.
### Shared content (strategic log, email digest)
Strategic log and email digest are generated once per cycle (hourly,
daily) and consumed by many users. We do NOT generate them per-user
per-language. Instead:
- **Strategic log**: `ai_log_job` writes the English row as today,
then translates it to each active non-English language and persists
in `strategic_log_translations` (one row per `(log_id, lang)`).
`/log` serves the translation matching the user's `lang`, falling
back to English.
- **Email digest**: the digest job already generates one English
variant per tone (NOVICE / INTERMEDIATE / PRO). We extend the same
cycle so that for each tone variant, the job ALSO produces a
translation for each active non-English language. The translations
live alongside the English variants in memory for the duration of
the job run; the per-user send step selects the matching
`(tone, lang)` cell. No new persistence — variants exist only for
the lifetime of the job.
Why translate-after rather than generate-N-times: the shared content
involves expensive context assembly (live market data, headlines, log
history). Re-running the full generation in each language duplicates
that work; translating the rendered output preserves a single source
of truth and only spends LLM tokens on the actual prose conversion.
Why no per-user LLM call for the digest: 100 Italian users would
otherwise mean 100 translation calls per day. With the shared cycle
we make 3 translations per day (one per tone) regardless of how many
Italian users receive that variant.
## Architecture
```
┌─────────────────────────────────────────────────────────────────┐
│ User has user.lang preference │
│ Values: 'en' (default) | 'it' (active) | 'es'/'fr'/'de' (WIP) │
└─────────────────────────────────────────────────────────────────┘
├─ Per-user surface (portfolio analysis only)
│ └─ prompt assembly threads user.lang to
│ respond_in_clause() → appended to system prompt
│ when lang != 'en'. Single call_llm, no extra cost.
├─ Shared surface — strategic log
│ ├─ ai_log_job writes the English row as today
│ ├─ SELECTs distinct users.lang where lang != 'en'
│ │ (no tier gating)
│ ├─ asyncio.gather of one translate() call per language
│ └─ Each result → INSERT into strategic_log_translations
│ keyed by (log_id, lang) UNIQUE
└─ Shared surface — email digest
├─ Job builds one English variant per tone (existing
│ _generate_variants behaviour, unchanged)
├─ For each (variant, active non-en lang), translate
│ via asyncio.gather; results live in memory
└─ Per-user send loop looks up (user.digest_tone,
user.lang) in the in-memory dictionary; falls back
to the English variant of the same tone on miss
```
## Data model
### `users.lang` (new column)
```sql
ALTER TABLE users
ADD COLUMN lang VARCHAR(8) NOT NULL DEFAULT 'en';
```
Existing rows pick up the `en` default. Application-level validation
restricts writes to the `ACTIVE_LANGUAGES` set; the database column
accepts anything in `VARCHAR(8)` (no CHECK constraint — we want to
add new languages without a migration).
### `strategic_log_translations` (new table)
```sql
CREATE TABLE strategic_log_translations (
id BIGINT PRIMARY KEY AUTO_INCREMENT,
log_id BIGINT NOT NULL,
lang VARCHAR(8) NOT NULL,
content_md TEXT NOT NULL,
generated_at DATETIME(6) NOT NULL,
llm_model VARCHAR(64),
llm_cost_usd FLOAT,
CONSTRAINT fk_slt_log
FOREIGN KEY (log_id) REFERENCES strategic_logs(id) ON DELETE CASCADE,
CONSTRAINT uq_slt_log_lang UNIQUE (log_id, lang)
);
```
ON DELETE CASCADE means evicting an old strategic log row also drops
its translations. The UNIQUE constraint prevents duplicate translations
for the same log/lang combo.
## Components
### `app/services/i18n.py` (new)
```python
LANGUAGES = {
"en": "English",
"it": "Italian",
"es": "Spanish",
"fr": "French",
"de": "German",
}
# Set of language codes that users can actually pick from the settings
# dropdown. ES/FR/DE remain in LANGUAGES so their labels render, but
# the settings POST validator and the strategic-log translation fan-out
# both consult this set.
ACTIVE_LANGUAGES = {"en", "it"}
def respond_in_clause(lang: str) -> str:
"""Suffix appended to per-user LLM system prompts.
Returns an empty string for 'en' (the default everywhere already).
Otherwise returns "\n\nRespond in <Language>." so the model knows
to write its output in the user's language.
"""
if not lang or lang == "en":
return ""
name = LANGUAGES.get(lang, "English")
return f"\n\nRespond in {name}."
```
### `app/services/translation.py` (new)
```python
async def translate(
client: httpx.AsyncClient,
text: str,
target_lang: str,
) -> tuple[str, LogResult]:
"""Translate ``text`` (markdown) to ``target_lang``.
Uses the default ``call_llm`` provider chain — DeepSeek-4-flash via
the OG API is already cheap enough ($0.28/M output) that a separate
'translation model' setting would be over-engineering.
Returns ``(translated_markdown, LogResult)`` so the caller can
persist provenance (model + cost) alongside the translation.
Raises on provider failure; caller decides whether to surface or
swallow.
"""
```
System prompt: *"Translate the following markdown to {language}. Preserve all formatting (headings, lists, links, emphasis). Do NOT translate ticker symbols, company names, numbers, percentages, or dates. Output ONLY the translated markdown — no preamble, no commentary."*
### `app/models.py` (modified)
- `User`: add `lang: Mapped[str] = mapped_column(String(8), nullable=False, default="en", server_default="en")`
- New class `StrategicLogTranslation` matching the table above
### `app/jobs/ai_log_job.py` (modified)
After the existing English log row is persisted, add a translation
fan-out:
```python
# Select distinct active non-English languages.
async with session_factory() as session:
rows = (await session.execute(
select(User.lang).distinct()
.where(User.lang.in_(ACTIVE_LANGUAGES - {"en"}))
)).scalars().all()
active_langs = list(rows)
if active_langs:
async with httpx.AsyncClient(...) as client:
results = await asyncio.gather(*[
translate(client, log_row.content_md, lang)
for lang in active_langs
], return_exceptions=True)
for lang, result in zip(active_langs, results):
if isinstance(result, Exception):
log.warning("log.translate.failed", lang=lang, error=str(result)[:200])
continue
translated_md, llm_log = result
session.add(StrategicLogTranslation(
log_id=log_row.id, lang=lang,
content_md=translated_md,
generated_at=utcnow(),
llm_model=llm_log.model,
llm_cost_usd=llm_log.cost_usd,
))
await session.commit()
```
Errors in individual language translations are logged but do not fail
the job. Missing translations get rendered as the English fallback at
read time.
### `app/jobs/email_digest_job.py` (modified)
The job already builds one English variant per tone in
`_generate_variants(...)`. After that returns, the job translates each
variant into every active non-English language (parallel via
`asyncio.gather`), and exposes a `(tone, lang) -> content` lookup that
`_send_one(...)` consults using the recipient's `user.lang`.
- Variants live only in memory for the duration of the job run.
- A failed translation for `(tone, lang)` is logged and that cell
falls back to the English variant of the same tone. The send
proceeds — the user still gets a digest, just in English that day.
- The subject line is part of each variant's content, so it gets
translated as part of the same call.
### `app/services/portfolio_analysis.py` (modified)
- `AnalysisRequest` gains a `lang: str = "en"` field, populated by the
route from `principal.user.lang`
- `analyse(...)` appends `respond_in_clause(req.lang)` to its system prompt
### `app/routers/universe.py` (modified — the `/api/analyze` route)
Read the current user's `lang` and put it in the payload before calling
`analyse(...)`. (The current route gets the principal via Depends.)
### `app/routers/pages.py` / the `/log` resolution (modified)
When rendering `/log` (and the `/log/{day}` historical variant), look
up the user's `lang`. If `lang != 'en'`, attempt to fetch the matching
`StrategicLogTranslation`; if present, render that. If absent, fall
back to the English `StrategicLog.content_md`. No silent error — the
fallback is the intended graceful path.
### Settings UI (`app/templates/settings.html` modified)
New section under existing user preferences (alongside the digest-tone
toggle):
```html
<details class="settings-section">
<summary class="settings-section__head">Language</summary>
<p class="settings-section__lede">
The language the AI uses for the strategic log, your daily digest,
and portfolio commentary. UI labels stay in English for now.
</p>
<form method="post" action="/settings/language" class="settings-row">
<select name="lang" id="lang-select">
<option value="en" {% if user.lang == 'en' %}selected{% endif %}>English</option>
<option value="it" {% if user.lang == 'it' %}selected{% endif %}>Italiano</option>
<option value="es" disabled>Español (coming soon)</option>
<option value="fr" disabled>Français (coming soon)</option>
<option value="de" disabled>Deutsch (coming soon)</option>
</select>
<button type="submit" class="settings-btn">Save</button>
</form>
</details>
```
### Settings POST endpoint (new)
```python
@router.post("/settings/language")
async def set_language(
lang: str = Form(...),
cu: CurrentUser = Depends(require_auth),
session: AsyncSession = Depends(get_session),
):
if lang not in ACTIVE_LANGUAGES:
raise HTTPException(status_code=400, detail="unsupported language")
if cu.user is None:
raise HTTPException(status_code=403, detail="user required")
cu.user.lang = lang
await session.commit()
return RedirectResponse(url="/settings#language", status_code=303)
```
Server-side validation against `ACTIVE_LANGUAGES` is the gate that
keeps ES/FR/DE non-functional even if someone POSTs them by hand.
## Error handling
| Case | Behaviour |
|---|---|
| Translation provider down at ai_log_job time | English row still written. Translation row missing for that hour and language. Next hour retries. No retroactive backfill in v1. |
| Translation returns malformed markdown | Stored anyway (we trust DeepSeek output enough that this is rare). Operator can delete a bad row by hand. |
| User has `lang=it` but no IT translation for the latest log | Fall back to English silently. Better than an empty pane. |
| User saves an unsupported lang (`es`/`fr`/`de`/`xx`) via raw POST | 400 — validated against `ACTIVE_LANGUAGES`. |
| Migrating an existing user with no `lang` column | The `DEFAULT 'en'` clause on the migration handles it; no application code change needed. |
| User picks Italian, then logs change reaches them mid-hour | The next ai_log_job tick generates and translates a fresh log; users see the IT version on the next refresh. |
## Tests
Backend (`tests/test_i18n.py`, `tests/test_translation.py`,
`tests/test_localization_integration.py`):
- `respond_in_clause('en')` returns empty string
- `respond_in_clause('it')` includes the word "Italian"
- `respond_in_clause('xx')` falls back to "English" (defensive)
- `translate()` mocked happy path returns the translated text + LogResult
- `translate()` provider failure raises
- ai_log_job: with no non-en users, no translation calls happen (mock asserts call_count=0)
- ai_log_job: with one user at `lang='it'`, one translation row written with the right `lang` and `log_id`
- ai_log_job: translation failure on one lang doesn't fail the job; the other lang's row still writes
- `/log` serves IT row when `user.lang='it'` and an IT translation exists
- `/log` falls back to English when `user.lang='it'` but no IT translation exists
- `/settings/language` POST: accepts `en`/`it`, rejects `es`/`fr`/`de`/`xx` with 400
- `analyse()` system prompt contains `"Respond in Italian."` when `lang='it'` (assert on the messages list passed to call_llm)
- digest job system prompt likewise contains the clause when the user is Italian
## Verification
End-to-end manual check after deploy:
1. **Switch a paid test user to Italian via the settings dropdown.** Confirm `users.lang='it'` in the DB.
2. **Wait for the next hourly log generation** (or trigger manually via cron/admin). Confirm a new `strategic_log_translations` row exists with `lang='it'` and `content_md` clearly Italian.
3. **Open the dashboard as that user.** Strategic log renders in Italian.
4. **Trigger the daily digest send for that user** (CLI: `python -m app.cli send-test-digest user@x daily`). Confirm the received email is in Italian.
5. **Click "Analyse my portfolio"** on the dashboard. Confirm the AI commentary is in Italian.
6. **Switch the same user back to English.** Confirm the next dashboard refresh shows the English log. The IT translation row stays in the DB (other IT users still benefit).
7. **Inspect the dropdown.** Verify ES/FR/DE appear with "(coming soon)" suffix and the option is disabled.
8. **Attempt `curl -X POST /settings/language -d lang=es`** with a valid session cookie. Expect 400.
## Migration / rollout
- Alembic migration `0022_localization` adds `users.lang` and creates
`strategic_log_translations`. Existing rows pick up `en` default.
- App restart picks up the new code paths. Pre-existing English logs
stay as-is. The first ai_log_job tick after deploy generates the
first Italian translation for whatever active IT users exist (likely
zero on day one until someone opts in).
- Removing localization later (if needed) is harmless: setting any
user's `lang` back to `en` makes their experience identical to the
pre-localization state.
## Out-of-scope clarifications
- We do not translate UI labels. Italian users see English buttons,
headings, and tooltips. Future scope.
- We do not translate user-supplied input (e.g. portfolio names, any
free-text fields). Only AI-generated output is localized.
- The email subject line is part of each variant's content, so it
gets translated alongside the body in the same `translate()` call
per (tone, lang) cell — no separate subject-translation path.
- We do not surface translation cost in any user-visible UI. Strategic
log translation cost lands in `strategic_log_translations.llm_cost_usd`;
digest translation cost is captured in the existing `ai_calls` ledger
via the underlying `call_llm` calls.
- We do **not** gate strategic-log translation on user tier. Any user
with `lang='it'` triggers Italian translation for that hour's log,
regardless of whether they are paid, on credit, or free. Rationale:
Italian + UK are the first markets the operator is targeting, so
Italian availability is part of the public-facing experience — a
free-tier visitor needs to see the AI in Italian to convert. At
~$0.005/day total cost the gating overhead is not worth the savings.

View file

@ -1,88 +0,0 @@
# Mobile responsiveness — design
**Status:** approved 2026-05-28 (user opted to skip the implementation-plan
ceremony and iterate on the coded product instead).
**Scope:** all views, single ≤480px breakpoint, incremental media-query approach.
## Decisions captured
1. **Target device:** phones only. Single `@media (max-width: 480px)` breakpoint.
Tablets and small laptops keep the existing desktop layout.
2. **Scope:** every template (auth, public, app, dashboard, log, news, settings).
3. **Mobile topbar pattern:** hamburger drawer, **side-slide from the right**.
4. **Indicator table:** hide secondary columns on phones (`ccy`, `1y`, `anchor`,
`as_of`); keep symbol, price, 1d, 1m.
5. **CSS organisation:** per-file `@media` block at the bottom of each CSS file —
extends the pattern already in `layout.css`, `log-chat.css`, `news.css`,
`portfolio.css`, `public.css`. No central `mobile.css`.
## Architecture
```
tokens.css — no mobile rules
layout.css — drawer geometry + topbar mobile layout
panels.css — header padding tightens
dashboard.css — group tabs scroll, indicator table column-hiding
portfolio.css — overall grid 2-col, composer textarea full-width, action wrap
log-chat.css — body padding, bubble width
auth.css — card padding
settings.css — form rows stack
news.css — pill wrap, source under headline
public.css — tighten existing 520/560 rules, hero typography clamp
```
Two small additions to `base.html`:
- A hamburger button in `.app-header` (hidden on desktop via `display: none`,
shown at ≤480px).
- ~20 lines of vanilla JS to toggle `body.drawer-open` plus a backdrop element.
Tap-backdrop, ESC, and swipe-right-on-drawer all close.
## Hamburger drawer (right-side)
- `position: fixed; top: 0; right: 0; height: 100vh; width: min(82vw, 320px)`
- Transform animation: `translateX(100%) → translateX(0)`, `180ms ease-out`
- Backdrop: `rgba(0,0,0,0.4)`, fades in over `120ms`
- Existing nav + `.header-right` widgets get wrapped in `.mobile-drawer` which
is `display: contents` on desktop (zero layout effect) and the fixed slide-out
panel on mobile.
- The existing `.user-menu` dropdown chip hides on mobile; its links surface
flat inside the drawer.
## Per-view rules
**Dashboard.** Group-tabs `overflow-x: auto`, no-wrap. Indicator table hides
`Ccy / 1y / anchor / as_of` columns. Aggregate-read summary header tightens.
**Portfolio.** `.pf-overall__grid` collapses to 2 columns. Composer textarea
becomes full-width. `.pf-actions` buttons wrap to two rows instead of squishing.
**Log + chat.** Body padding `16px → 10px`. Chat bubbles `max-width: 100%`,
user bubble loses right margin so it reaches the screen edge.
**News.** Tag pills flex-wrap. Source + timestamp move under headline.
Shift-click hint hides (touch users get long-press equivalent).
**Settings.** Form rows stack — label above input. Two-column import picker
becomes single column. Digest preferences keep their layout.
**Auth.** Card padding `28px 26px → 20px 18px`. Width already fluid.
**Public pages.** Audit existing 520/560 breakpoints; tighten hero typography
with `clamp()` so it scales down for small phones.
## Testing
- No Python tests affected — this is pure CSS + a single template tweak.
Existing 336-pass suite stays green.
- Manual verification on the user's phone post-deploy. (User cannot reach
localhost on the dev host, so visual companion was abandoned mid-brainstorm
in favour of ASCII previews; same constraint means no local browser smoke
test from the assistant side either — user iterates on the deployed site.)
## Out of scope
- Tablet / small-laptop breakpoints. Single ≤480 only.
- Touch gestures beyond drawer swipe-right-to-close.
- Mobile-specific reordering of dashboard panels (existing collapse order
is preserved).
- Visual companion server work (host unreachable from user's browser).

View file

@ -1,60 +0,0 @@
aiomysql==0.3.2
aiosmtplib==5.1.0
aiosqlite==0.22.1
alembic==1.18.4
annotated-doc==0.0.4
annotated-types==0.7.0
anyio==4.13.0
APScheduler==3.11.2
argon2-cffi==25.1.0
argon2-cffi-bindings==25.1.0
certifi==2026.5.20
cffi==2.0.0
charset-normalizer==3.4.7
click==8.4.1
cryptography==48.0.0
dnspython==2.8.0
email-validator==2.3.0
fastapi==0.136.3
greenlet==3.5.1
h11==0.16.0
hiredis==3.3.1
httpcore==1.0.9
httptools==0.8.0
httpx==0.28.1
idna==3.16
iniconfig==2.3.0
itsdangerous==2.2.0
Jinja2==3.1.6
Mako==1.3.12
MarkupSafe==3.0.3
packaging==26.2
pluggy==1.6.0
pycparser==3.0
pydantic==2.13.4
pydantic-settings==2.14.1
pydantic_core==2.46.4
Pygments==2.20.0
PyMySQL==1.2.0
pytest==9.0.3
pytest-asyncio==1.4.0
pytest-httpx==0.36.2
python-dotenv==1.2.2
python-multipart==0.0.29
PyYAML==6.0.3
redis==7.4.0
requests==2.34.2
ruff==0.15.14
SQLAlchemy==2.0.50
starlette==1.1.0
stripe==15.1.0
structlog==25.5.0
tenacity==9.1.4
typing-inspection==0.4.2
typing_extensions==4.15.0
tzlocal==5.3.1
urllib3==2.7.0
uvicorn==0.48.0
uvloop==0.22.1
watchfiles==1.2.0
websockets==16.0

View file

@ -1,154 +0,0 @@
"""One-off backfill: re-translate StrategicLog rows whose Italian (or
other-language) translation was truncated by the old 4000-token cap in
services/translation.py.
Selection criteria for a "truncated" row:
- completion_tokens >= 3990 (right at or above the old cap), OR
- the translated content is shorter than half the English source
Usage inside the app container:
docker compose exec app python -m scripts.backfill_truncated_translations \
--date 2026-05-28 # restrict to one day, repeatable
docker compose exec app python -m scripts.backfill_truncated_translations \
--since 2026-04-01 # everything from a date onward
docker compose exec app python -m scripts.backfill_truncated_translations \
--all # entire history (slow / costs $$)
docker compose exec app python -m scripts.backfill_truncated_translations \
--date 2026-05-28 --dry-run # just print what would be touched
Idempotent: each affected row is deleted then re-inserted in its own
transaction, so a re-run only re-translates rows that are STILL flagged
truncated after the previous pass.
"""
from __future__ import annotations
import argparse
import asyncio
import sys
from datetime import date, datetime
import httpx
from sqlalchemy import and_, delete, func, or_, select
from app.db import get_session_factory
from app.logging import get_logger
from app.models import StrategicLog, StrategicLogTranslation
from app.services.translation import translate
log = get_logger("backfill.translations")
# Italian (and the other expansive Romance / Germanic targets we support)
# typically produce 15-25 % MORE characters than the English source, so
# a translation shorter than the source — let alone much shorter — is a
# truncation signal even if completion_tokens didn't land exactly at the
# old 4000-token cap. We tolerate down to 70 % of source length to avoid
# touching the occasional legitimately-compressed translation.
SHORTNESS_RATIO = 0.7
def _is_truncated(en_chars: int, tr_chars: int, tr_completion: int | None) -> bool:
if en_chars <= 0:
return False
return tr_chars < en_chars * SHORTNESS_RATIO
async def _find_targets(session, day: date | None, since: date | None, all_: bool):
q = (
select(
StrategicLog.id.label("log_id"),
StrategicLog.generated_at,
func.char_length(StrategicLog.content).label("en_chars"),
StrategicLogTranslation.id.label("tr_id"),
StrategicLogTranslation.lang,
StrategicLogTranslation.completion_tokens.label("tr_tok"),
func.char_length(StrategicLogTranslation.content).label("tr_chars"),
)
.join(StrategicLogTranslation,
StrategicLogTranslation.log_id == StrategicLog.id)
)
if day is not None:
q = q.where(func.date(StrategicLog.generated_at) == day)
elif since is not None:
q = q.where(StrategicLog.generated_at >= since)
# all_ → no date filter
q = q.order_by(StrategicLog.generated_at, StrategicLogTranslation.lang)
rows = (await session.execute(q)).all()
return [r for r in rows if _is_truncated(r.en_chars, r.tr_chars, r.tr_tok)]
async def _retranslate_one(session, client: httpx.AsyncClient, log_id: int, lang: str):
"""Delete the existing (log_id, lang) translation row and write a fresh
one via the (now uncapped) translation service. Each row commits
independently so a per-row failure doesn't roll back the rest."""
src_row = (await session.execute(
select(StrategicLog).where(StrategicLog.id == log_id)
)).scalar_one_or_none()
if src_row is None:
log.warning("backfill.missing_source", log_id=log_id)
return False
await session.execute(
delete(StrategicLogTranslation)
.where(StrategicLogTranslation.log_id == log_id)
.where(StrategicLogTranslation.lang == lang)
)
await session.commit()
try:
translated_md, llm_result = await translate(client, src_row.content, lang)
except Exception as exc:
log.warning("backfill.translate_failed",
log_id=log_id, lang=lang, error=str(exc)[:200])
return False
session.add(StrategicLogTranslation(
log_id=log_id,
lang=lang,
content=translated_md,
model=llm_result.model,
prompt_tokens=llm_result.prompt_tokens,
completion_tokens=llm_result.completion_tokens,
cost_usd=llm_result.cost_usd,
))
await session.commit()
return True
async def main(args):
day = datetime.strptime(args.date, "%Y-%m-%d").date() if args.date else None
since = datetime.strptime(args.since, "%Y-%m-%d").date() if args.since else None
if not (day or since or args.all):
print("Specify --date, --since, or --all", file=sys.stderr)
sys.exit(2)
session_factory = get_session_factory()
async with session_factory() as session:
targets = await _find_targets(session, day, since, args.all)
print(f"Found {len(targets)} truncated translation row(s):")
for r in targets:
print(f" log_id={r.log_id} lang={r.lang} "
f"en={r.en_chars}c tr={r.tr_chars}c "
f"tok={r.tr_tok} at {r.generated_at}")
if args.dry_run or not targets:
return
ok = 0
async with httpx.AsyncClient(follow_redirects=True) as client:
for r in targets:
print(f" re-translating log_id={r.log_id} lang={r.lang}", end=" ")
done = await _retranslate_one(session, client, r.log_id, r.lang)
print("OK" if done else "FAILED")
if done:
ok += 1
print(f"\nRe-translated {ok}/{len(targets)} row(s).")
if __name__ == "__main__":
p = argparse.ArgumentParser()
grp = p.add_mutually_exclusive_group()
grp.add_argument("--date", help="single day YYYY-MM-DD")
grp.add_argument("--since", help="from YYYY-MM-DD onward")
grp.add_argument("--all", action="store_true", help="entire history")
p.add_argument("--dry-run", action="store_true",
help="list affected rows without rewriting")
asyncio.run(main(p.parse_args()))

View file

@ -1,76 +0,0 @@
"""One-off purge: ask the reviewer agent to judge every IndicatorSummary
row already in the DB, delete the ones it flags as unclean.
Same reviewer the live pipeline uses (services/output_review.review_read),
so post-purge rows are exactly what would survive a fresh generation.
Per-row cost ~$0.0001; total run on ~3000 rows ~$0.30.
Usage inside the app container:
docker compose exec app python /tmp/purge.py --dry-run
docker compose exec app python /tmp/purge.py # actually delete
The script processes rows concurrently up to a small fan-out (default 8)
to keep wall-clock down without hammering the provider.
"""
from __future__ import annotations
import argparse
import asyncio
import httpx
from sqlalchemy import delete, select
from app.db import get_session_factory
from app.models import IndicatorSummary, IndicatorSummaryTranslation
from app.services.output_review import review_read
async def _judge(client, sem, row):
async with sem:
v = await review_read(client, row.content or "")
return row, v
async def main(args):
session_factory = get_session_factory()
async with session_factory() as session:
rows = (await session.execute(
select(IndicatorSummary).order_by(IndicatorSummary.id)
)).scalars().all()
print(f"Reviewing {len(rows)} IndicatorSummary rows…")
sem = asyncio.Semaphore(args.concurrency)
async with httpx.AsyncClient(follow_redirects=True) as client:
results = await asyncio.gather(*(_judge(client, sem, r) for r in rows))
unclean = [(r, v) for r, v in results if not v.clean]
print(f"\nFlagged {len(unclean)} of {len(rows)} as unclean.")
for r, v in unclean:
head = (r.content or "")[:100].replace("\n", " ")
print(f" id={r.id} group={r.group_name} tone={r.tone} "
f"at {r.generated_at} reason={v.reason!r}")
print(f" preview: {head!r}")
if args.dry_run or not unclean:
return
ids = [r.id for r, _ in unclean]
await session.execute(
delete(IndicatorSummaryTranslation)
.where(IndicatorSummaryTranslation.summary_id.in_(ids))
)
await session.execute(
delete(IndicatorSummary).where(IndicatorSummary.id.in_(ids))
)
await session.commit()
print(f"\nDeleted {len(ids)} unclean row(s). The dashboard's /api/indicators/"
"<group> endpoint will now fall back to the previous clean row "
"for each (group, tone).")
if __name__ == "__main__":
p = argparse.ArgumentParser()
p.add_argument("--dry-run", action="store_true")
p.add_argument("--concurrency", type=int, default=8,
help="Parallel reviewer calls (default 8)")
asyncio.run(main(p.parse_args()))

View file

@ -17,72 +17,3 @@ sys.path.insert(0, str(ROOT))
# Sentinel env so importing app.config doesn't try to read a missing .env.
os.environ.setdefault("DATABASE_URL", "sqlite+aiosqlite:///:memory:")
os.environ.setdefault("CASSANDRA_MOCK", "1")
import pytest
@pytest.fixture(autouse=True)
def stub_reviewer(monkeypatch):
"""Replace review_read with a clean-passing stub in every consumer
module. Tests that mock the generator's call_llm shouldn't also
have to mock the reviewer that runs after it the reviewer is a
safety gate, not behaviour under test.
Tests in test_output_review.py exercise review_read through its
own module and are unaffected. Tests that want to assert the
reviewer-rejected branch can override with their own
monkeypatch.setattr later wins.
"""
from app.services.output_review import Verdict
async def _clean(_client, _candidate):
return Verdict(clean=True, reason="stubbed-by-conftest", cost_usd=0.0)
for mod_path in (
"app.services.portfolio_analysis",
"app.routers.chat",
"app.jobs.ai_log_job",
"app.jobs.email_digest_job",
"app.jobs.indicator_summary_job",
):
try:
mod = __import__(mod_path, fromlist=["review_read"])
except ImportError:
continue
if hasattr(mod, "review_read"):
monkeypatch.setattr(mod, "review_read", _clean)
@pytest.fixture
async def db_factory(tmp_path):
"""Per-test sqlite engine + async session factory.
Creates a fresh sqlite database file under ``tmp_path``, applies
``Base.metadata.create_all``, and rebinds ``app.db._engine`` /
``app.db._session_factory`` so module-level helpers (which look
these up at call time) see the test engine.
Yields the ``async_sessionmaker``. Tests use it like:
async def test_foo(db_factory):
async with db_factory() as session:
...
"""
from sqlalchemy.ext.asyncio import async_sessionmaker, create_async_engine
from app import db as db_mod
from app.db import Base
import app.models # noqa: F401 — registers models on Base.metadata
engine = create_async_engine(f"sqlite+aiosqlite:///{tmp_path}/test.db")
factory = async_sessionmaker(engine, expire_on_commit=False)
db_mod._engine = engine
db_mod._session_factory = factory
async with engine.begin() as conn:
await conn.run_sync(Base.metadata.create_all)
yield factory
await engine.dispose()

View file

@ -1,81 +0,0 @@
"""Session cookie sign/verify — security-critical edges that the
existing test suite uses as a fixture (``sign_session(1)`` for cookies)
but doesn't actually probe.
Covers:
- Round-trip: sign(user_id) verify user_id
- Tampered cookie None (not raised)
- Expired cookie None (via itsdangerous max_age)
- Garbage / non-serializer-format input None
- Wrong-salt isolation: a pending cookie can't be unlocked by the
session verifier (and vice versa)
"""
from __future__ import annotations
from itsdangerous import URLSafeTimedSerializer
from app import auth
def test_session_signed_token_round_trips():
cookie = auth.sign_session(42)
assert auth.verify_session(cookie) == 42
def test_session_token_is_opaque_url_safe():
"""Sanity: the serializer produces a URL-safe string with at least
two dot-separated segments (payload.timestamp.signature). Not a
semantic test, but catches a future swap to an un-encoded format."""
cookie = auth.sign_session(7)
assert "." in cookie
assert " " not in cookie
def test_tampered_session_cookie_returns_none():
"""Flip a single character in the signature segment and verify
the cookie no longer authenticates without exceptions leaking."""
cookie = auth.sign_session(99)
# Flip the last character (signature segment).
tampered = cookie[:-1] + ("a" if cookie[-1] != "a" else "b")
assert auth.verify_session(tampered) is None
def test_garbage_session_cookie_returns_none():
assert auth.verify_session("not-a-real-cookie") is None
assert auth.verify_session("") is None
assert auth.verify_session("a.b.c") is None
def test_expired_session_cookie_returns_none(monkeypatch):
"""Forge a cookie with an ancient timestamp and confirm the TTL
check rejects it. We bypass sign_session() so the timestamp is
in our control rather than `now`."""
s = auth._serializer()
# itsdangerous stores the issued-at timestamp in a base62 segment.
# Easier than hand-building: monkeypatch the SESSION_TTL_SECONDS
# to a negative value so any freshly-signed cookie is "expired"
# the moment we verify it.
cookie = auth.sign_session(123)
monkeypatch.setattr(auth, "SESSION_TTL_SECONDS", -1)
assert auth.verify_session(cookie) is None
def test_session_serializer_isolated_from_pending_serializer():
"""A pending-verify cookie must not authenticate as a session
(different salts), and vice versa otherwise the half-finished
OTP flow becomes a free login."""
pending = auth.sign_pending("u@x", 5)
session = auth.sign_session(5)
assert auth.verify_session(pending) is None
assert auth.verify_pending(session) is None
def test_session_cookie_signed_with_different_secret_rejected(monkeypatch):
"""Defence-in-depth: signing with a different secret produces a
cookie that the live verifier (using the configured secret)
rejects. Confirms we're actually checking the HMAC, not just the
payload format."""
rogue = URLSafeTimedSerializer("totally-different-secret",
salt="cassandra-session-v1")
rogue_cookie = rogue.dumps({"uid": 1})
assert auth.verify_session(rogue_cookie) is None

View file

@ -1,6 +1,6 @@
"""Drift-detection: brand palette in `app/branding.py` must match the CSS.
Both the website (tokens.css) and the email templates use the same
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.
@ -15,7 +15,7 @@ import pytest
from app import branding
CSS_PATH = Path(__file__).resolve().parent.parent / "app" / "static" / "css" / "tokens.css"
CSS_PATH = Path(__file__).resolve().parent.parent / "app" / "static" / "css" / "cassandra.css"
def _extract_vars(css: str, selector: str) -> dict[str, str]:
@ -23,7 +23,7 @@ def _extract_vars(css: str, selector: str) -> dict[str, str]:
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 tokens.css).
# don't nest in cassandra.css).
pattern = re.escape(selector) + r"\s*\{([^}]*)\}"
m = re.search(pattern, css)
if not m:

View file

@ -1,163 +0,0 @@
"""Cadence policy — the gate that ai_log_job and indicator_summary_job
use to throttle OpenRouter spend outside active market hours.
Pure-function module, so tests just construct timestamps and assert on
the (should_run, reason) tuple. Uses the default policy (active window
07:00-21:00 UTC weekdays, no off-hours runs without 4+ hours since
last success, weekends 12+ hours).
"""
from __future__ import annotations
from datetime import datetime, timedelta, timezone
import pytest
from app.services.cadence import DEFAULT_POLICY, NEWS_POLICY, CadencePolicy
def _utc(year, month, day, hour, minute=0):
return datetime(year, month, day, hour, minute, tzinfo=timezone.utc)
# Pick reference timestamps used across tests. Wednesday 12:00 UTC is
# squarely inside the active window; Wednesday 03:00 is off-hours;
# Saturday 12:00 is weekend.
_WED_NOON = _utc(2026, 5, 27, 12) # Wednesday 12:00
_WED_PRE_DAWN = _utc(2026, 5, 27, 3) # Wednesday 03:00
_SAT_NOON = _utc(2026, 5, 30, 12) # Saturday 12:00
# ---------------------------------------------------------------------------
# is_active_window
# ---------------------------------------------------------------------------
def test_active_window_weekday_noon_is_active():
assert DEFAULT_POLICY.is_active_window(_WED_NOON) is True
def test_active_window_weekday_predawn_is_off_hours():
assert DEFAULT_POLICY.is_active_window(_WED_PRE_DAWN) is False
def test_active_window_weekend_always_off_hours():
"""Weekends bypass the hour check — even Saturday noon is throttled."""
assert DEFAULT_POLICY.is_active_window(_SAT_NOON) is False
def test_active_window_boundary_inclusive_start_exclusive_end():
"""07:00 UTC is the first active hour; 21:00 is the first off-hour.
Locks the half-open interval semantics in place."""
assert DEFAULT_POLICY.is_active_window(_utc(2026, 5, 27, 7)) is True
assert DEFAULT_POLICY.is_active_window(_utc(2026, 5, 27, 21)) is False
# ---------------------------------------------------------------------------
# min_gap_hours
# ---------------------------------------------------------------------------
def test_min_gap_uses_zero_during_active_window():
assert DEFAULT_POLICY.min_gap_hours(_WED_NOON) == 0.0
def test_min_gap_uses_off_hours_value_at_night():
assert DEFAULT_POLICY.min_gap_hours(_WED_PRE_DAWN) == 4.0
def test_min_gap_uses_weekend_value_on_saturday():
assert DEFAULT_POLICY.min_gap_hours(_SAT_NOON) == 12.0
# ---------------------------------------------------------------------------
# should_run — the function jobs call
# ---------------------------------------------------------------------------
def test_should_run_first_ever_call_always_proceeds():
ok, reason = DEFAULT_POLICY.should_run(None, now=_WED_NOON)
assert ok is True
assert "no prior" in reason.lower()
def test_should_run_during_active_window_always_proceeds():
"""Default policy has active_gap_h=0, so even a run from 1 minute ago
is allowed when we're in the active window."""
last = _WED_NOON - timedelta(minutes=1)
ok, reason = DEFAULT_POLICY.should_run(last, now=_WED_NOON)
assert ok is True
assert "active" in reason
def test_should_run_off_hours_too_soon_is_throttled():
"""Off-hours requires 4+ hours since last success. 1 hour ago → no."""
last = _WED_PRE_DAWN - timedelta(hours=1)
ok, reason = DEFAULT_POLICY.should_run(last, now=_WED_PRE_DAWN)
assert ok is False
assert "throttled" in reason
assert "off-hours" in reason
def test_should_run_off_hours_after_gap_proceeds():
last = _WED_PRE_DAWN - timedelta(hours=5)
ok, reason = DEFAULT_POLICY.should_run(last, now=_WED_PRE_DAWN)
assert ok is True
assert "off-hours" in reason
def test_should_run_weekend_requires_12h_gap():
"""Weekend gap is 12h. 6h is too soon; 13h is enough."""
ok6, _ = DEFAULT_POLICY.should_run(
_SAT_NOON - timedelta(hours=6), now=_SAT_NOON,
)
ok13, _ = DEFAULT_POLICY.should_run(
_SAT_NOON - timedelta(hours=13), now=_SAT_NOON,
)
assert ok6 is False
assert ok13 is True
def test_should_run_naive_datetime_treated_as_utc():
"""The DB column comes back as a naive datetime in some test paths;
the policy must coerce it to UTC rather than crash on tz subtraction."""
naive_last = _WED_PRE_DAWN.replace(tzinfo=None) - timedelta(hours=5)
ok, _ = DEFAULT_POLICY.should_run(naive_last, now=_WED_PRE_DAWN)
assert ok is True
# ---------------------------------------------------------------------------
# NEWS_POLICY — tighter gaps so 3 runs/hour during the active window.
# ---------------------------------------------------------------------------
def test_news_policy_active_gap_is_twenty_minutes():
# 20 minutes = 1/3 hour. Verify a 15-min-ago run is throttled but
# a 21-min-ago one is allowed.
last_15 = _WED_NOON - timedelta(minutes=15)
last_21 = _WED_NOON - timedelta(minutes=21)
assert NEWS_POLICY.should_run(last_15, now=_WED_NOON)[0] is False
assert NEWS_POLICY.should_run(last_21, now=_WED_NOON)[0] is True
def test_news_policy_off_hours_gap_is_three_hours():
last_2h = _WED_PRE_DAWN - timedelta(hours=2)
last_4h = _WED_PRE_DAWN - timedelta(hours=4)
assert NEWS_POLICY.should_run(last_2h, now=_WED_PRE_DAWN)[0] is False
assert NEWS_POLICY.should_run(last_4h, now=_WED_PRE_DAWN)[0] is True
# ---------------------------------------------------------------------------
# Bespoke policy — confirms the dataclass is reconfigurable for callers
# (the audit flagged this as risky to over-fit to defaults).
# ---------------------------------------------------------------------------
def test_custom_policy_with_active_gap_throttles_within_window():
"""active_gap_h=0.5 means even during the active window a run from
20 minutes ago is throttled verifies the gate isn't hardcoded to
'always run during active'."""
p = CadencePolicy(active_gap_h=0.5)
last = _WED_NOON - timedelta(minutes=20)
ok, reason = p.should_run(last, now=_WED_NOON)
assert ok is False
assert "throttled" in reason

View file

@ -23,7 +23,6 @@ def _build_app(tmp_path):
from app.db import Base
from app.models import StrategicLog, User
from app.routers import api as api_router
from app.routers import chat as chat_router
engine = create_async_engine(f"sqlite+aiosqlite:///{tmp_path}/gates.db")
factory = async_sessionmaker(engine, expire_on_commit=False)
@ -57,7 +56,6 @@ def _build_app(tmp_path):
app = FastAPI()
app.include_router(api_router.router, prefix="/api")
app.include_router(chat_router.router, prefix="/api")
client = TestClient(app)
return client, sign_session(1), sign_session(2)

View file

@ -3,7 +3,7 @@ from __future__ import annotations
from datetime import datetime, timezone
from app.services.llm_prompts import (
from app.services.openrouter import (
build_daily_digest_prompt,
build_weekly_digest_prompt,
)

View file

@ -1,7 +1,7 @@
"""Unit tests for render_digest_email."""
from __future__ import annotations
from app.services.digest_email import render_digest_email
from app.services.email_service import render_digest_email
def test_daily_subject_and_bodies():

View file

@ -1,115 +0,0 @@
"""Unit tests for app.services.i18n."""
from __future__ import annotations
import pytest
def test_languages_contains_all_four_plus_english():
from app.services.i18n import LANGUAGES
assert set(LANGUAGES.keys()) == {"en", "it", "es", "fr", "de"}
assert LANGUAGES["en"] == "English"
assert LANGUAGES["it"] == "Italian"
assert LANGUAGES["es"] == "Spanish"
assert LANGUAGES["fr"] == "French"
assert LANGUAGES["de"] == "German"
def test_active_languages_is_en_and_it_only():
from app.services.i18n import ACTIVE_LANGUAGES
assert ACTIVE_LANGUAGES == {"en", "it"}
def test_respond_in_clause_empty_for_english():
from app.services.i18n import respond_in_clause
assert respond_in_clause("en") == ""
def test_respond_in_clause_empty_for_none_or_empty():
from app.services.i18n import respond_in_clause
assert respond_in_clause("") == ""
assert respond_in_clause(None) == ""
def test_respond_in_clause_italian():
from app.services.i18n import respond_in_clause
result = respond_in_clause("it")
assert "Italian" in result
assert result.startswith("\n\n")
def test_respond_in_clause_unknown_lang_falls_back_to_english():
"""Defensive: a raw POST or stale lang code should not crash the
prompt assembly. Unknown codes map to no-suffix (English default)."""
from app.services.i18n import respond_in_clause
assert respond_in_clause("xx") == ""
async def test_translate_happy_path(monkeypatch):
from unittest.mock import AsyncMock, MagicMock
from app.services import translation as mod
from app.services.openrouter import LogResult
monkeypatch.setattr(mod, "call_llm", AsyncMock(return_value=LogResult(
content="# Apertura\n\nIl mercato è in calo dello 0,4%.",
model="deepseek/deepseek-v4-flash",
prompt_tokens=300, completion_tokens=80, cost_usd=0.00002,
)))
client = MagicMock()
translated, llm_log = await mod.translate(
client, "# Open\n\nThe market is down 0.4%.", "it",
)
assert "Apertura" in translated
assert llm_log.model == "deepseek/deepseek-v4-flash"
assert llm_log.cost_usd == pytest.approx(0.00002)
async def test_translate_strips_code_fences(monkeypatch):
"""If the LLM wraps the output in ```markdown ... ```, strip it."""
from unittest.mock import AsyncMock, MagicMock
from app.services import translation as mod
from app.services.openrouter import LogResult
fenced = "```markdown\n# Titolo\n\nCorpo.\n```"
monkeypatch.setattr(mod, "call_llm", AsyncMock(return_value=LogResult(
content=fenced, model="m", prompt_tokens=10, completion_tokens=20, cost_usd=0.0,
)))
client = MagicMock()
translated, _ = await mod.translate(client, "# Title\n\nBody.", "it")
assert "```" not in translated
assert translated.startswith("# Titolo")
async def test_translate_provider_failure_propagates(monkeypatch):
from unittest.mock import AsyncMock, MagicMock
from app.services import translation as mod
monkeypatch.setattr(mod, "call_llm", AsyncMock(side_effect=RuntimeError("upstream down")))
client = MagicMock()
with pytest.raises(RuntimeError, match="upstream down"):
await mod.translate(client, "# Title\n\nBody.", "it")
async def test_translate_unknown_lang_returns_source_unchanged(monkeypatch):
"""Defensive: an unknown lang code (or 'en') short-circuits without
calling the LLM. Callers shouldn't have to gate the call themselves."""
from unittest.mock import AsyncMock, MagicMock
from app.services import translation as mod
from app.services.openrouter import LogResult
call_mock = AsyncMock(return_value=LogResult(
content="should not be returned",
model="m", prompt_tokens=0, completion_tokens=0, cost_usd=0.0,
))
monkeypatch.setattr(mod, "call_llm", call_mock)
client = MagicMock()
out, _ = await mod.translate(client, "Hello world.", "en")
assert out == "Hello world."
call_mock.assert_not_awaited()

View file

@ -4,6 +4,26 @@ from __future__ import annotations
import pytest
def _build_session_factory(tmp_path):
"""Spin up a fresh in-memory schema and return (engine, factory).
Matches the pattern used in tests/test_referral_conversion.py."""
from sqlalchemy.ext.asyncio import async_sessionmaker, create_async_engine
from app import db as db_mod
from app.db import Base
import app.models # noqa: F401 — registers models on Base.metadata
engine = create_async_engine(f"sqlite+aiosqlite:///{tmp_path}/csv.db")
factory = async_sessionmaker(engine, expire_on_commit=False)
db_mod._engine = engine
db_mod._session_factory = factory
async def _setup():
async with engine.begin() as conn:
await conn.run_sync(Base.metadata.create_all)
return engine, factory, _setup
def test_csv_format_template_model_columns():
"""Model exposes every column the spec requires, with correct types."""
@ -22,10 +42,8 @@ def test_csv_format_template_model_columns():
assert "first_seen_at" in cols
assert "use_count" in cols
assert "last_used_at" in cols
assert "model" in cols
assert "cost_usd" in cols
assert "prompt_tokens" in cols
assert "completion_tokens" in cols
assert "llm_model" in cols
assert "llm_cost_usd" in cols
# Crucially, no user attribution.
assert "user_id" not in cols
assert "first_seen_user_id" not in cols
@ -225,6 +243,7 @@ def test_apply_mapping_skips_blank_and_unparseable_rows():
assert [p.slice for p in pie.positions] == ["AAPL", "NVDA"]
@pytest.mark.asyncio
async def test_extract_mapping_via_llm_parses_valid_json():
from unittest.mock import AsyncMock, MagicMock
from app.services.llm_csv_parser import _extract_mapping_via_llm
@ -256,6 +275,7 @@ async def test_extract_mapping_via_llm_parses_valid_json():
fake_call_llm.assert_awaited_once()
@pytest.mark.asyncio
async def test_extract_mapping_via_llm_malformed_json_raises():
from unittest.mock import AsyncMock, MagicMock
from app.services.llm_csv_parser import LLMParseError, _extract_mapping_via_llm
@ -278,6 +298,7 @@ async def test_extract_mapping_via_llm_malformed_json_raises():
await _extract_mapping_via_llm(fake_client, ["Symbol"], [["AAPL"]])
@pytest.mark.asyncio
async def test_extract_mapping_via_llm_provider_failure_wraps():
from unittest.mock import AsyncMock, MagicMock
from app.services.llm_csv_parser import LLMParseError, _extract_mapping_via_llm
@ -292,7 +313,8 @@ async def test_extract_mapping_via_llm_provider_failure_wraps():
await _extract_mapping_via_llm(fake_client, ["Symbol"], [["AAPL"]])
async def test_parse_with_llm_cache_miss_inserts_template(db_factory):
@pytest.mark.asyncio
async def test_parse_with_llm_cache_miss_inserts_template(tmp_path):
from unittest.mock import AsyncMock
from sqlalchemy import select
@ -300,7 +322,8 @@ async def test_parse_with_llm_cache_miss_inserts_template(db_factory):
from app.services.llm_csv_parser import parse_with_llm
from app.services.openrouter import LogResult
factory = db_factory
_, factory, setup = _build_session_factory(tmp_path)
await setup()
raw = (
b"Symbol,Quantity,Avg Price,Currency\n"
@ -332,12 +355,13 @@ async def test_parse_with_llm_cache_miss_inserts_template(db_factory):
assert tmpl.mapping["ticker_col"] == "Symbol"
assert tmpl.broker_label == "Generic broker"
assert tmpl.use_count == 1
assert tmpl.cost_usd == pytest.approx(0.0002)
assert tmpl.llm_cost_usd == pytest.approx(0.0002)
# The crucial PII guarantee:
assert not hasattr(tmpl, "user_id"), "sample row must not be linked to a user"
async def test_parse_with_llm_cache_hit_skips_llm(db_factory):
@pytest.mark.asyncio
async def test_parse_with_llm_cache_hit_skips_llm(tmp_path):
from unittest.mock import AsyncMock
from sqlalchemy import select
@ -345,7 +369,8 @@ async def test_parse_with_llm_cache_hit_skips_llm(db_factory):
from app.models import CsvFormatTemplate
from app.services.llm_csv_parser import _fingerprint, parse_with_llm
factory = db_factory
_, factory, setup = _build_session_factory(tmp_path)
await setup()
headers = ["Symbol", "Quantity", "Avg Price", "Currency"]
fp = _fingerprint(headers)
@ -367,8 +392,8 @@ async def test_parse_with_llm_cache_hit_skips_llm(db_factory):
first_seen_at=utcnow(),
last_used_at=utcnow(),
use_count=1,
model="seed",
cost_usd=0.0,
llm_model="seed",
llm_cost_usd=0.0,
))
await session.commit()
@ -391,7 +416,8 @@ async def test_parse_with_llm_cache_hit_skips_llm(db_factory):
assert rows[0].use_count == 2
async def test_parse_with_llm_stale_mapping_raises_but_does_not_evict(db_factory):
@pytest.mark.asyncio
async def test_parse_with_llm_stale_mapping_raises_but_does_not_evict(tmp_path):
from unittest.mock import AsyncMock
from sqlalchemy import select
@ -399,7 +425,8 @@ async def test_parse_with_llm_stale_mapping_raises_but_does_not_evict(db_factory
from app.models import CsvFormatTemplate
from app.services.llm_csv_parser import LLMParseError, _fingerprint, parse_with_llm
factory = db_factory
_, factory, setup = _build_session_factory(tmp_path)
await setup()
headers = ["Symbol", "Quantity"]
fp = _fingerprint(headers)
@ -412,7 +439,7 @@ async def test_parse_with_llm_stale_mapping_raises_but_does_not_evict(db_factory
mapping={"ticker_col": "Symbol", "qty_col": "Symbol"},
preamble_rows=0, delimiter=",", broker_label=None,
first_seen_at=utcnow(), last_used_at=utcnow(), use_count=1,
model="seed", cost_usd=0.0,
llm_model="seed", llm_cost_usd=0.0,
))
await session.commit()
@ -431,7 +458,8 @@ async def test_parse_with_llm_stale_mapping_raises_but_does_not_evict(db_factory
assert len(rows) == 1
async def test_parse_portfolio_route_falls_through_to_llm(db_factory, monkeypatch):
@pytest.mark.asyncio
async def test_parse_portfolio_route_falls_through_to_llm(tmp_path, monkeypatch):
"""End-to-end: T212 parser raises CSVImportError, LLM fallback runs,
response shape matches the existing JSON contract."""
from io import BytesIO
@ -440,7 +468,8 @@ async def test_parse_portfolio_route_falls_through_to_llm(db_factory, monkeypatc
from fastapi import UploadFile
factory = db_factory
_, factory, setup = _build_session_factory(tmp_path)
await setup()
import app.services.llm_csv_parser as mod
from app.services.openrouter import LogResult

View file

@ -1,361 +0,0 @@
"""Integration tests: model surface, ai_log_job translation fan-out,
route-level localized fetch, settings PATCH validation."""
from __future__ import annotations
import pytest
def test_user_has_lang_column_with_default_en():
from sqlalchemy import inspect
from app.models import User
cols = {c.name: c for c in inspect(User).columns}
assert "lang" in cols
assert cols["lang"].nullable is False
# SQLAlchemy default may be a callable or a literal — check both.
default = cols["lang"].default
assert default is not None
if hasattr(default, "arg"):
assert default.arg == "en"
def test_strategic_log_translation_model_columns():
from sqlalchemy import inspect
from app.models import StrategicLogTranslation
cols = {c.name: c for c in inspect(StrategicLogTranslation).columns}
assert "log_id" in cols
assert "lang" in cols
assert "content" in cols
assert "generated_at" in cols
assert "model" in cols
assert "cost_usd" in cols
assert cols["log_id"].nullable is False
assert cols["lang"].nullable is False
assert cols["content"].nullable is False
async def test_log_translation_fanout_no_active_non_en_users(db_factory, monkeypatch):
"""When no users have an active non-en lang, the fan-out makes no
translation calls and no rows are inserted."""
from unittest.mock import AsyncMock
from sqlalchemy import select
from app.db import utcnow
from app.models import StrategicLog, StrategicLogTranslation, User
from app.jobs import ai_log_job
factory = db_factory
fake_translate = AsyncMock()
monkeypatch.setattr(ai_log_job, "translate", fake_translate)
# Seed an English user (no non-en users).
async with factory() as session:
session.add(User(id=1, email="en@x", tier="paid", lang="en"))
slog = StrategicLog(
generated_at=utcnow(), content="# Open\n\nDown 0.4%.",
model="test-model",
tone="INTERMEDIATE", analysis="NORMAL",
)
session.add(slog)
await session.commit()
log_id = slog.id
async with factory() as session:
await ai_log_job.translate_log_for_active_languages(session, log_id)
fake_translate.assert_not_awaited()
async with factory() as session:
rows = (await session.execute(select(StrategicLogTranslation))).scalars().all()
assert rows == []
async def test_log_translation_fanout_italian_user(db_factory, monkeypatch):
"""One user at lang=it triggers one translation; the row lands with
the right lang and log_id."""
from sqlalchemy import select
from app.db import utcnow
from app.models import StrategicLog, StrategicLogTranslation, User
from app.services.openrouter import LogResult
from app.jobs import ai_log_job
factory = db_factory
async def _fake_translate(client, text, target_lang):
assert target_lang == "it"
return "# Apertura\n\nIn calo 0,4%.", LogResult(
content="# Apertura\n\nIn calo 0,4%.",
model="deepseek/deepseek-v4-flash",
prompt_tokens=300, completion_tokens=80, cost_usd=0.00002,
)
monkeypatch.setattr(ai_log_job, "translate", _fake_translate)
async with factory() as session:
session.add(User(id=2, email="it@x", tier="paid", lang="it"))
slog = StrategicLog(
generated_at=utcnow(), content="# Open\n\nDown 0.4%.",
model="test-model",
tone="INTERMEDIATE", analysis="NORMAL",
)
session.add(slog)
await session.commit()
log_id = slog.id
async with factory() as session:
await ai_log_job.translate_log_for_active_languages(session, log_id)
async with factory() as session:
rows = (await session.execute(select(StrategicLogTranslation))).scalars().all()
assert len(rows) == 1
row = rows[0]
assert row.log_id == log_id
assert row.lang == "it"
assert row.content.startswith("# Apertura")
assert row.model == "deepseek/deepseek-v4-flash"
assert row.cost_usd == pytest.approx(0.00002)
async def test_log_translation_fanout_per_language_failure_isolated(db_factory, monkeypatch):
"""If one language's translation fails, the others (if any) still land
and the job does not raise."""
from sqlalchemy import select
from app.db import utcnow
from app.models import StrategicLog, StrategicLogTranslation, User
from app.jobs import ai_log_job
factory = db_factory
async def _fake_translate(client, text, target_lang):
raise RuntimeError("upstream down")
monkeypatch.setattr(ai_log_job, "translate", _fake_translate)
async with factory() as session:
session.add(User(id=3, email="it@x", tier="paid", lang="it"))
slog = StrategicLog(
generated_at=utcnow(), content="# Open",
model="test-model",
tone="INTERMEDIATE", analysis="NORMAL",
)
session.add(slog)
await session.commit()
log_id = slog.id
# Must NOT raise.
async with factory() as session:
await ai_log_job.translate_log_for_active_languages(session, log_id)
async with factory() as session:
rows = (await session.execute(select(StrategicLogTranslation))).scalars().all()
assert rows == []
async def test_analyse_threads_lang_into_system_prompt(db_factory, monkeypatch):
"""When lang='it', the system prompt sent to call_llm contains
'Respond in Italian.' the LLM does the rest."""
from app.services import portfolio_analysis as pa
from app.services.openrouter import LogResult
captured = {}
async def _fake_call_llm(client, messages, **kw):
captured["messages"] = messages
return LogResult(
content="Analisi del portafoglio in italiano.",
model="m", prompt_tokens=400, completion_tokens=100, cost_usd=0.0001,
)
monkeypatch.setattr(pa, "call_llm", _fake_call_llm)
factory = db_factory
payload = {
"positions": [{"yahoo_ticker": "AAPL", "qty": 10, "avg_cost": 150.0,
"currency": "USD", "name": "Apple Inc"}],
"prices": {"AAPL": {"p": 172.4, "c": "USD"}},
"fx": {"USD": 1.0},
"base_currency": "USD",
"tone": "INTERMEDIATE",
"analysis": "NORMAL",
"lang": "it",
}
req = pa.parse_request(payload)
assert req.lang == "it"
async with factory() as session:
await pa.analyse(session, req)
system = next(m["content"] for m in captured["messages"] if m["role"] == "system")
assert "Respond in Italian" in system
async def test_analyse_no_clause_when_lang_is_en(db_factory, monkeypatch):
from app.services import portfolio_analysis as pa
from app.services.openrouter import LogResult
captured = {}
async def _fake_call_llm(client, messages, **kw):
captured["messages"] = messages
return LogResult(
content="Portfolio analysis in English.",
model="m", prompt_tokens=400, completion_tokens=100, cost_usd=0.0001,
)
monkeypatch.setattr(pa, "call_llm", _fake_call_llm)
factory = db_factory
payload = {
"positions": [{"yahoo_ticker": "AAPL", "qty": 10, "avg_cost": 150.0,
"currency": "USD", "name": "Apple Inc"}],
"prices": {"AAPL": {"p": 172.4, "c": "USD"}},
"fx": {"USD": 1.0},
"base_currency": "USD",
"tone": "INTERMEDIATE",
"analysis": "NORMAL",
"lang": "en",
}
req = pa.parse_request(payload)
async with factory() as session:
await pa.analyse(session, req)
system = next(m["content"] for m in captured["messages"] if m["role"] == "system")
assert "Respond in" not in system
async def test_digest_translates_variants_per_active_lang(monkeypatch):
"""After English variants are built, the job translates each to every
active non-en lang. The result is an in-memory mapping the send loop
consults."""
from unittest.mock import MagicMock
from app.jobs import email_digest_job as ed
from app.services.openrouter import LogResult
english_variants = {
"NOVICE": "**Today.** Markets calmer.",
"INTERMEDIATE": "**Today.** Indices slightly down.",
"PRO": "**Today.** Risk-off rotation, breadth weak.",
}
translate_calls: list[tuple[str, str]] = []
async def _fake_translate(client, text, target_lang):
translate_calls.append((text, target_lang))
return f"[IT] {text}", LogResult(
content=f"[IT] {text}", model="m",
prompt_tokens=10, completion_tokens=10, cost_usd=0.0,
)
monkeypatch.setattr(ed, "translate", _fake_translate)
client = MagicMock()
table = await ed._translate_variants_for_active_langs(
client, english_variants, ["it"],
)
# Three tones × one non-en lang = three translation calls.
assert len(translate_calls) == 3
assert {lang for _, lang in translate_calls} == {"it"}
# English entries are present unchanged.
assert table[("NOVICE", "en")] == english_variants["NOVICE"]
assert table[("PRO", "en")] == english_variants["PRO"]
# Italian entries are populated.
assert table[("INTERMEDIATE", "it")].startswith("[IT] ")
async def test_digest_translation_failure_falls_back_to_english(monkeypatch):
"""When translate() fails for a (tone, lang) cell, the table entry
for that cell is the English variant of the same tone the user
still gets a digest, just in English that day."""
from unittest.mock import MagicMock
from app.jobs import email_digest_job as ed
english_variants = {"INTERMEDIATE": "**Today.** Indices down."}
async def _fake_translate(client, text, target_lang):
raise RuntimeError("upstream down")
monkeypatch.setattr(ed, "translate", _fake_translate)
client = MagicMock()
table = await ed._translate_variants_for_active_langs(
client, english_variants, ["it"],
)
assert table[("INTERMEDIATE", "it")] == english_variants["INTERMEDIATE"]
def test_digest_pick_variant_uses_user_lang():
"""The variant-picker helper consults user.digest_tone + user.lang."""
from app.jobs import email_digest_job as ed
table = {
("NOVICE", "en"): "novice en",
("NOVICE", "it"): "novice it",
("INTERMEDIATE", "en"): "intermediate en",
("INTERMEDIATE", "it"): "intermediate it",
}
assert ed._pick_variant(table, tone="NOVICE", lang="it") == "novice it"
assert ed._pick_variant(table, tone="INTERMEDIATE", lang="en") == "intermediate en"
# Missing lang → fallback to English variant of the same tone.
assert ed._pick_variant(table, tone="NOVICE", lang="de") == "novice en"
# Missing tone → fallback to INTERMEDIATE/en (the safe default).
assert ed._pick_variant(table, tone="UNKNOWN", lang="en") == "intermediate en"
async def test_patch_language_accepts_active(db_factory):
"""PATCH /api/settings/language accepts 'en' and 'it' and persists."""
from app.models import User
from app.routers.api import patch_language_prefs, LanguagePrefsIn
factory = db_factory
async with factory() as session:
session.add(User(id=20, email="u@x", tier="paid", lang="en"))
await session.commit()
class _P:
is_admin = False
def __init__(self, u): self.user = u
async with factory() as session:
user = await session.get(User, 20)
result = await patch_language_prefs(
payload=LanguagePrefsIn(lang="it"),
principal=_P(user),
session=session,
)
assert result.lang == "it"
async with factory() as session:
user = await session.get(User, 20)
assert user.lang == "it"
async def test_patch_language_rejects_wip(db_factory):
"""PATCH rejects 'es'/'fr'/'de'/'xx' with 400 — ACTIVE_LANGUAGES gate."""
from fastapi import HTTPException
from app.models import User
from app.routers.api import patch_language_prefs, LanguagePrefsIn
factory = db_factory
async with factory() as session:
session.add(User(id=21, email="u2@x", tier="paid", lang="en"))
await session.commit()
class _P:
is_admin = False
def __init__(self, u): self.user = u
for bad in ("es", "fr", "de", "xx"):
async with factory() as session:
user = await session.get(User, 21)
with pytest.raises(HTTPException) as exc:
await patch_language_prefs(
payload=LanguagePrefsIn(lang=bad),
principal=_P(user),
session=session,
)
assert exc.value.status_code == 400

View file

@ -9,7 +9,7 @@ pytest.importorskip("pydantic_settings")
from datetime import datetime, timezone
from app.services.llm_prompts import SYSTEM_PROMPT, build_user_prompt
from app.services.openrouter import SYSTEM_PROMPT, build_user_prompt
def test_system_prompt_has_voice_anchors():
@ -35,7 +35,7 @@ def test_pro_tone_falls_back_to_intermediate():
"""PRO was removed in PROMPT_VERSION 6 (audience pivot to young
investors). Legacy callers that still pass PRO should get the
INTERMEDIATE prompt rather than a KeyError."""
from app.services.llm_prompts import build_system_prompt
from app.services.openrouter import build_system_prompt
pro = build_system_prompt("PRO", "SPECULATIVE")
inter = build_system_prompt("INTERMEDIATE", "SPECULATIVE")
assert pro == inter

View file

@ -1,258 +0,0 @@
"""Transport-layer tests for app.services.openrouter.
The companion file `test_openrouter_prompt.py` covers prompt building;
this one covers the HTTP plumbing: provider chain selection, endpoint
resolution, the per-call retry/parse path in `_call_provider`, and
fallback behaviour in `call_llm`. Network requests are intercepted with
``httpx.MockTransport`` so nothing hits the wire.
"""
from __future__ import annotations
import json
from unittest.mock import patch
import httpx
import pytest
from app.config import get_settings
from app.services import openrouter as ot
# ---------------------------------------------------------------------------
# _estimate_cost_usd
# ---------------------------------------------------------------------------
def test_estimate_cost_known_model_uses_table_rates():
# deepseek-v4-flash table: 0.07/M input, 0.28/M output.
# 1000 in + 2000 out = 0.000_07 + 0.000_56 = 0.000_63.
cost = ot._estimate_cost_usd("deepseek-v4-flash", 1000, 2000)
assert cost == pytest.approx(0.00063, rel=1e-9)
def test_estimate_cost_handles_provider_prefixed_model_name():
# OpenRouter-style model strings use the slash-prefixed form.
cost = ot._estimate_cost_usd("deepseek/deepseek-v4-flash", 1000, 2000)
assert cost == pytest.approx(0.00063, rel=1e-9)
def test_estimate_cost_unknown_model_returns_none():
assert ot._estimate_cost_usd("never-heard-of-this-model", 100, 200) is None
def test_estimate_cost_missing_tokens_returns_none():
assert ot._estimate_cost_usd("deepseek-v4-flash", None, 200) is None
assert ot._estimate_cost_usd("deepseek-v4-flash", 100, None) is None
assert ot._estimate_cost_usd("deepseek-v4-flash", None, None) is None
# ---------------------------------------------------------------------------
# _provider_chain / llm_configured / active_model
# ---------------------------------------------------------------------------
def _configure(monkeypatch, **overrides):
"""Apply a small bundle of LLM settings for one test."""
s = get_settings()
defaults = {
"LLM_PROVIDER": "deepseek",
"LLM_FALLBACK": "openrouter",
"DEEPSEEK_API_KEY": "",
"OPENROUTER_API_KEY": "",
"DEEPSEEK_MODEL": "deepseek-v4-flash",
"OPENROUTER_MODEL": "deepseek/deepseek-v4-flash",
"DEEPSEEK_URL": "https://api.deepseek.com/chat/completions",
}
defaults.update(overrides)
for k, v in defaults.items():
monkeypatch.setattr(s, k, v, raising=False)
def test_provider_chain_drops_providers_without_keys(monkeypatch):
_configure(monkeypatch, DEEPSEEK_API_KEY="sk-deepseek") # openrouter key missing
assert ot._provider_chain() == ["deepseek"]
assert ot.llm_configured() is True
def test_provider_chain_lists_primary_then_fallback(monkeypatch):
_configure(monkeypatch,
DEEPSEEK_API_KEY="sk-deepseek", OPENROUTER_API_KEY="sk-openrouter")
assert ot._provider_chain() == ["deepseek", "openrouter"]
def test_provider_chain_skips_duplicate_when_primary_equals_fallback(monkeypatch):
_configure(monkeypatch, LLM_FALLBACK="deepseek", DEEPSEEK_API_KEY="sk")
assert ot._provider_chain() == ["deepseek"]
def test_llm_configured_false_when_no_keys(monkeypatch):
_configure(monkeypatch) # both keys empty
assert ot.llm_configured() is False
assert ot._provider_chain() == []
assert ot.active_model() == "unknown"
def test_active_model_reflects_primary(monkeypatch):
_configure(monkeypatch,
LLM_PROVIDER="openrouter", OPENROUTER_API_KEY="sk-or",
DEEPSEEK_API_KEY="")
assert ot.active_model() == "deepseek/deepseek-v4-flash" # OPENROUTER_MODEL
# ---------------------------------------------------------------------------
# _endpoint_for
# ---------------------------------------------------------------------------
def test_endpoint_for_unknown_provider_raises(monkeypatch):
_configure(monkeypatch, DEEPSEEK_API_KEY="sk")
with pytest.raises(RuntimeError, match="Unknown LLM provider"):
ot._endpoint_for("anthropic")
def test_endpoint_for_provider_without_key_raises(monkeypatch):
_configure(monkeypatch) # both keys empty
with pytest.raises(RuntimeError, match="DEEPSEEK_API_KEY not set"):
ot._endpoint_for("deepseek")
with pytest.raises(RuntimeError, match="OPENROUTER_API_KEY not set"):
ot._endpoint_for("openrouter")
def test_endpoint_for_openrouter_includes_attribution_and_no_train_headers(monkeypatch):
_configure(monkeypatch, OPENROUTER_API_KEY="sk-or")
url, key, model, headers = ot._endpoint_for("openrouter")
assert url.endswith("/chat/completions")
assert key == "sk-or"
assert headers["X-OR-Allow-Training"] == "false"
assert "HTTP-Referer" in headers and "X-Title" in headers
# ---------------------------------------------------------------------------
# _call_provider (through call_llm so retry doesn't fire — happy paths only)
# ---------------------------------------------------------------------------
def _mock_post(callback):
"""Wrap a callback into an httpx.MockTransport. Callback receives the
request and returns either an httpx.Response or raises."""
return httpx.MockTransport(callback)
@pytest.mark.asyncio
async def test_call_llm_returns_parsed_log_result(monkeypatch):
_configure(monkeypatch, DEEPSEEK_API_KEY="sk-deepseek", LLM_FALLBACK="")
def handler(request: httpx.Request) -> httpx.Response:
body = json.loads(request.content.decode())
assert body["model"] == "deepseek-v4-flash"
return httpx.Response(200, json={
"choices": [{"message": {"content": "hello"}, "finish_reason": "stop"}],
"usage": {"prompt_tokens": 100, "completion_tokens": 200},
})
async with httpx.AsyncClient(transport=_mock_post(handler)) as client:
result = await ot.call_llm(client, [{"role": "user", "content": "hi"}])
assert result.content == "hello"
# Model is prefixed with the answering provider for ledger traceability.
assert result.model == "deepseek/deepseek-v4-flash"
assert result.prompt_tokens == 100
assert result.completion_tokens == 200
# DeepSeek doesn't return cost — estimated from tokens.
# 100 * 0.07 + 200 * 0.28 = 7 + 56 = 63 → 0.000063.
assert result.cost_usd == pytest.approx(0.000063, rel=1e-9)
@pytest.mark.asyncio
async def test_call_llm_uses_upstream_cost_when_provided(monkeypatch):
"""When the upstream supplies usage.cost (OpenRouter), we trust it
and skip the per-model table estimate."""
_configure(monkeypatch, LLM_PROVIDER="openrouter",
OPENROUTER_API_KEY="sk-or", LLM_FALLBACK="")
def handler(request: httpx.Request) -> httpx.Response:
return httpx.Response(200, json={
"choices": [{"message": {"content": "ok"}, "finish_reason": "stop"}],
"usage": {"prompt_tokens": 50, "completion_tokens": 50, "cost": 0.0042},
})
async with httpx.AsyncClient(transport=_mock_post(handler)) as client:
result = await ot.call_llm(client, [{"role": "user", "content": "hi"}])
assert result.cost_usd == 0.0042
@pytest.mark.asyncio
async def test_call_llm_does_not_publish_reasoning_when_content_null(monkeypatch):
"""The `reasoning` field is the model's internal chain-of-thought
(scratchpad: "Let's see…", planning notes, half-formed math). It is
never safe to surface as the user-facing answer see the
2026-05-29 valuation-read leak. If `content` is null we treat the
row as a generation failure and raise; the caller can retry or skip."""
_configure(monkeypatch, DEEPSEEK_API_KEY="sk-d", LLM_FALLBACK="")
def handler(request: httpx.Request) -> httpx.Response:
return httpx.Response(200, json={
"choices": [{
"message": {"content": None, "reasoning": "deep thought"},
"finish_reason": "stop",
}],
"usage": {"prompt_tokens": 10, "completion_tokens": 20},
})
async with httpx.AsyncClient(transport=_mock_post(handler)) as client:
with pytest.raises(RuntimeError, match="LLM returned empty content"):
await ot.call_llm(client, [{"role": "user", "content": "hi"}])
@pytest.mark.asyncio
async def test_call_llm_raises_when_no_provider_configured(monkeypatch):
_configure(monkeypatch) # both keys empty
async with httpx.AsyncClient() as client:
with pytest.raises(RuntimeError, match="No LLM provider configured"):
await ot.call_llm(client, [{"role": "user", "content": "hi"}])
# ---------------------------------------------------------------------------
# call_llm fallback chain — patch _call_provider to bypass the retry/sleep
# decorator and exercise the cross-provider failover logic directly.
# ---------------------------------------------------------------------------
@pytest.mark.asyncio
async def test_call_llm_falls_back_to_secondary_when_primary_raises(monkeypatch):
_configure(monkeypatch,
DEEPSEEK_API_KEY="sk-d", OPENROUTER_API_KEY="sk-or")
calls = []
success = ot.LogResult(
content="from-fallback", model="openrouter/deepseek/deepseek-v4-flash",
prompt_tokens=1, completion_tokens=2, cost_usd=0.0,
)
async def fake(_client, provider, _messages, _model, _max_tokens, response_format=None):
calls.append(provider)
if provider == "deepseek":
raise RuntimeError("primary down")
return success
with patch.object(ot, "_call_provider", fake):
async with httpx.AsyncClient() as client:
result = await ot.call_llm(client, [{"role": "user", "content": "hi"}])
assert calls == ["deepseek", "openrouter"]
assert result.content == "from-fallback"
@pytest.mark.asyncio
async def test_call_llm_raises_last_exception_when_chain_exhausted(monkeypatch):
_configure(monkeypatch,
DEEPSEEK_API_KEY="sk-d", OPENROUTER_API_KEY="sk-or")
async def fake(_client, provider, _messages, _model, _max_tokens, response_format=None):
raise RuntimeError(f"{provider} broken")
with patch.object(ot, "_call_provider", fake):
async with httpx.AsyncClient() as client:
with pytest.raises(RuntimeError, match="openrouter broken"):
await ot.call_llm(client, [{"role": "user", "content": "hi"}])

View file

@ -1,172 +0,0 @@
"""Tests for the JSON-envelope extractor and the reviewer agent.
The two together replaced the regex `clean_summary` + `looks_like_leakage`
scaffolding that used to live in indicator_summary_job. The extractor is
pure-function so it's covered exhaustively; the reviewer makes an LLM
call and is exercised via the httpx MockTransport that the other
openrouter tests use."""
from __future__ import annotations
import httpx
import pytest
from app.jobs.indicator_summary_job import _extract_read
from app.services import openrouter as ot
from app.services.output_review import review_read
# ---------------------------------------------------------------------------
# _extract_read — JSON envelope handling
# ---------------------------------------------------------------------------
def test_extract_read_returns_trimmed_field():
raw = '{"read": " The market is pricing growth. "}'
assert _extract_read(raw) == "The market is pricing growth."
def test_extract_read_returns_none_on_invalid_json():
assert _extract_read("not json") is None
assert _extract_read("{bad}") is None
assert _extract_read("") is None
def test_extract_read_returns_none_when_field_missing():
assert _extract_read('{"other": "x"}') is None
def test_extract_read_returns_none_when_field_not_string():
assert _extract_read('{"read": 42}') is None
assert _extract_read('{"read": null}') is None
assert _extract_read('{"read": ["a","b"]}') is None
def test_extract_read_returns_none_when_field_empty():
assert _extract_read('{"read": ""}') is None
assert _extract_read('{"read": " "}') is None
def test_extract_read_returns_none_when_envelope_not_object():
# A bare string or array is valid JSON but not the expected shape.
assert _extract_read('"just a string"') is None
assert _extract_read('["a", "b"]') is None
# ---------------------------------------------------------------------------
# review_read — judges candidate read via a second LLM call
# ---------------------------------------------------------------------------
def _mock_post(handler):
return httpx.MockTransport(handler)
def _configure(monkeypatch):
"""Minimal env so call_llm believes a provider is configured.
Both review_read (which pins to OpenRouter for a non-thinking model)
and the openrouter module itself read get_settings, so we patch
both module-level references."""
import app.services.output_review as orr
settings = type("S", (), {
"LLM_PROVIDER": "deepseek", "LLM_FALLBACK": "",
"DEEPSEEK_API_KEY": "sk-d", "OPENROUTER_API_KEY": "sk-or",
"DEEPSEEK_URL": "https://x/deepseek", "DEEPSEEK_MODEL": "deepseek-v4-flash",
"OPENROUTER_URL": "https://x/or", "OPENROUTER_MODEL": "deepseek/deepseek-v4-flash",
"REVIEWER_MODEL": "anthropic/claude-haiku-4.5",
})()
monkeypatch.setattr(ot, "get_settings", lambda: settings)
monkeypatch.setattr(orr, "get_settings", lambda: settings)
@pytest.mark.asyncio
async def test_review_clean_verdict(monkeypatch):
_configure(monkeypatch)
def handler(_req):
return httpx.Response(200, json={
"choices": [{"message": {"content": '{"clean": true, "reason": "ok"}'},
"finish_reason": "stop"}],
"usage": {"prompt_tokens": 50, "completion_tokens": 12, "cost": 0.00007},
})
async with httpx.AsyncClient(transport=_mock_post(handler)) as client:
v = await review_read(client, "Markets are pricing tighter policy.")
assert v.clean is True
assert v.cost_usd == 0.00007
@pytest.mark.asyncio
async def test_review_unclean_verdict(monkeypatch):
_configure(monkeypatch)
def handler(_req):
return httpx.Response(200, json={
"choices": [{"message": {"content":
'{"clean": false, "reason": "chain of thought"}'},
"finish_reason": "stop"}],
"usage": {"prompt_tokens": 50, "completion_tokens": 14, "cost": 0.00009},
})
async with httpx.AsyncClient(transport=_mock_post(handler)) as client:
v = await review_read(client, "Let's see, is it X? Actually Y?")
assert v.clean is False
assert "chain of thought" in v.reason
@pytest.mark.asyncio
async def test_review_strips_markdown_fence_around_json(monkeypatch):
"""Haiku (and friends) sometimes wrap JSON in ```json ... ``` even
when response_format is set. The parser needs to peel that off
before json.loads or it'll reject otherwise-valid verdicts."""
_configure(monkeypatch)
fenced = '```json\n{"clean": true, "reason": "polished read"}\n```'
def handler(_req):
return httpx.Response(200, json={
"choices": [{"message": {"content": fenced},
"finish_reason": "stop"}],
"usage": {"prompt_tokens": 50, "completion_tokens": 18, "cost": 0.0006},
})
async with httpx.AsyncClient(transport=_mock_post(handler)) as client:
v = await review_read(client, "Markets are pricing tighter policy.")
assert v.clean is True
assert v.reason == "polished read"
@pytest.mark.asyncio
async def test_review_failsafe_on_malformed_json(monkeypatch):
"""Reviewer returned prose instead of JSON → conservative reject."""
_configure(monkeypatch)
def handler(_req):
return httpx.Response(200, json={
"choices": [{"message": {"content": "yes it looks clean"},
"finish_reason": "stop"}],
"usage": {"prompt_tokens": 50, "completion_tokens": 6},
})
async with httpx.AsyncClient(transport=_mock_post(handler)) as client:
v = await review_read(client, "Some candidate.")
assert v.clean is False
assert "non-JSON" in v.reason
@pytest.mark.asyncio
async def test_review_failsafe_on_missing_clean_field(monkeypatch):
_configure(monkeypatch)
def handler(_req):
return httpx.Response(200, json={
"choices": [{"message": {"content": '{"reason": "no field"}'},
"finish_reason": "stop"}],
"usage": {"prompt_tokens": 50, "completion_tokens": 6},
})
async with httpx.AsyncClient(transport=_mock_post(handler)) as client:
v = await review_read(client, "Some candidate.")
assert v.clean is False
@pytest.mark.asyncio
async def test_review_failsafe_on_empty_candidate(monkeypatch):
"""No LLM call should fire if the candidate is empty."""
_configure(monkeypatch)
calls = []
def handler(_req):
calls.append(1)
return httpx.Response(500, json={"error": "should not be called"})
async with httpx.AsyncClient(transport=_mock_post(handler)) as client:
v = await review_read(client, " ")
assert v.clean is False
assert calls == []

View file

@ -23,6 +23,29 @@ import pytest
# ---------------------------------------------------------------------------
def _build_session_factory(tmp_path):
"""Spin up a fresh in-memory schema and return (engine, factory).
Mirrors test_stripe_billing._build_app's seeding strategy but
skips the FastAPI app most conversion tests only need the
session factory."""
from sqlalchemy.ext.asyncio import async_sessionmaker, create_async_engine
from app import db as db_mod
from app.db import Base
engine = create_async_engine(f"sqlite+aiosqlite:///{tmp_path}/conv.db")
factory = async_sessionmaker(engine, expire_on_commit=False)
db_mod._engine = engine
db_mod._session_factory = factory
async def _seed():
async with engine.begin() as conn:
await conn.run_sync(Base.metadata.create_all)
asyncio.run(_seed())
return factory
async def _add_pair(factory, *, referrer_id=1, referred_id=2):
"""Insert a referrer + referred user pair and a linking Referral row.
Returns nothing tests re-fetch via the factory."""
@ -45,7 +68,7 @@ async def _add_pair(factory, *, referrer_id=1, referred_id=2):
# ---------------------------------------------------------------------------
async def test_first_conversion_credits_both_parties(db_factory):
def test_first_conversion_credits_both_parties(tmp_path):
"""Calling convert_referral on a freshly-paid referred user should
extend credit_until by REFERRAL_CREDIT_DAYS for BOTH the buyer and
the referrer, and stamp converted_at + credited_at."""
@ -54,9 +77,10 @@ async def test_first_conversion_credits_both_parties(db_factory):
REFERRAL_CREDIT_DAYS, convert_referral,
)
factory = db_factory
await _add_pair(factory)
factory = _build_session_factory(tmp_path)
asyncio.run(_add_pair(factory))
async def _run():
async with factory() as s:
referred = await s.get(User, 2)
ref = await convert_referral(s, referred)
@ -81,17 +105,20 @@ async def test_first_conversion_credits_both_parties(db_factory):
delta_days = (cu - now).total_seconds() / 86400
assert REFERRAL_CREDIT_DAYS - 1 <= delta_days <= REFERRAL_CREDIT_DAYS + 1
asyncio.run(_run())
async def test_idempotent_on_repeat_call(db_factory):
def test_idempotent_on_repeat_call(tmp_path):
"""A second convert_referral call (e.g. from a duplicate webhook or
renewal event) must NOT extend credit a second time. The Referral
row is already stamped, so we should early-return unchanged."""
from app.models import User
from app.services.referral_service import convert_referral
factory = db_factory
await _add_pair(factory)
factory = _build_session_factory(tmp_path)
asyncio.run(_add_pair(factory))
async def _run():
async with factory() as s:
referred = await s.get(User, 2)
await convert_referral(s, referred)
@ -115,27 +142,35 @@ async def test_idempotent_on_repeat_call(db_factory):
assert referrer.credit_until == first_referrer_credit
assert referred.credit_until == first_referred_credit
asyncio.run(_run())
async def test_no_referral_row_returns_none(db_factory):
def test_no_referral_row_returns_none(tmp_path):
"""A user signing up directly (no inviter) has no Referral row.
convert_referral must return None and touch nothing."""
from app.models import User
from app.services.referral_service import convert_referral
factory = db_factory
factory = _build_session_factory(tmp_path)
async def _seed_orphan():
async with factory() as s:
s.add(User(id=9, email="lone@x", tier="free"))
await s.commit()
asyncio.run(_seed_orphan())
async def _run():
async with factory() as s:
user = await s.get(User, 9)
result = await convert_referral(s, user)
assert result is None
assert user.credit_until is None
asyncio.run(_run())
async def test_credit_stacks_from_existing_window(db_factory):
def test_credit_stacks_from_existing_window(tmp_path):
"""If the user already has a future credit_until (admin grant, prior
referral), the new credit should extend from THAT anchor not from
now. Mirrors cli.grant_credit's stacking semantics."""
@ -144,17 +179,21 @@ async def test_credit_stacks_from_existing_window(db_factory):
REFERRAL_CREDIT_DAYS, convert_referral,
)
factory = db_factory
await _add_pair(factory)
factory = _build_session_factory(tmp_path)
asyncio.run(_add_pair(factory))
# Pre-load 30 days of credit on the referred user.
existing = datetime.now(timezone.utc) + timedelta(days=30)
async def _preload():
async with factory() as s:
u = await s.get(User, 2)
u.credit_until = existing
await s.commit()
asyncio.run(_preload())
async def _run():
async with factory() as s:
referred = await s.get(User, 2)
await convert_referral(s, referred)
@ -172,16 +211,19 @@ async def test_credit_stacks_from_existing_window(db_factory):
f"got {cu}, expected ~{expected}"
)
asyncio.run(_run())
async def test_deleted_referrer_does_not_crash(db_factory):
def test_deleted_referrer_does_not_crash(tmp_path):
"""If the referrer's User row has been deleted, the referred user
should still be credited and the Referral still stamped we just
skip the missing referrer."""
from app.models import Referral, User
from app.services.referral_service import convert_referral
factory = db_factory
factory = _build_session_factory(tmp_path)
async def _seed():
from app.db import utcnow
async with factory() as s:
# Referrer with FK SET NULL — we don't delete the row, we
@ -193,6 +235,9 @@ async def test_deleted_referrer_does_not_crash(db_factory):
created_at=utcnow()))
await s.commit()
asyncio.run(_seed())
async def _run():
async with factory() as s:
referred = await s.get(User, 2)
ref = await convert_referral(s, referred)
@ -202,6 +247,8 @@ async def test_deleted_referrer_does_not_crash(db_factory):
# Referred still got their credit even though referrer is gone.
assert referred.credit_until is not None
asyncio.run(_run())
# ---------------------------------------------------------------------------
# Stripe-webhook integration

View file

@ -463,97 +463,3 @@ def test_checkout_endpoint_requires_login(tmp_path):
r = client.post("/api/stripe/checkout", json={"cadence": "monthly"})
# No session cookie → require_auth bounces with 401.
assert r.status_code == 401, r.text
def test_checkout_passes_sniffed_currency_for_new_customer(tmp_path):
"""First-time buyer (no stripe_customer_id yet) gets the currency
sniffed from the request. CF-IPCountry=US 'usd', and Stripe will
look up the USD currency_option on the Price."""
client, _, session_cookie = _build_app(tmp_path)
def asserter(params):
assert params["currency"] == "usd"
with patch("app.routers.stripe_billing._stripe_client",
return_value=_fake_checkout_client(asserter)):
r = client.post(
"/api/stripe/checkout",
json={"cadence": "monthly"},
cookies={"cassandra_session": session_cookie},
headers={"cf-ipcountry": "US"},
)
assert r.status_code == 200, r.text
def test_checkout_body_currency_overrides_sniff(tmp_path):
"""Explicit `currency` in the request body beats header sniffing —
lets a UK-based buyer choose EUR if they want to."""
client, _, session_cookie = _build_app(tmp_path)
def asserter(params):
assert params["currency"] == "eur"
with patch("app.routers.stripe_billing._stripe_client",
return_value=_fake_checkout_client(asserter)):
r = client.post(
"/api/stripe/checkout",
json={"cadence": "monthly", "currency": "eur"},
cookies={"cassandra_session": session_cookie},
headers={"cf-ipcountry": "GB"},
)
assert r.status_code == 200, r.text
def test_checkout_omits_currency_for_existing_customer(tmp_path):
"""Existing customer: Stripe locked their currency at first
checkout, so passing `currency` again would error. Verify we omit
it (and also use the existing `customer` ref instead of
customer_email)."""
import asyncio
from app.models import User
client, factory, session_cookie = _build_app(tmp_path)
async def _link():
async with factory() as s:
u = await s.get(User, 1)
u.stripe_customer_id = "cus_existing_xxxxxxxxxxxxxx"
await s.commit()
asyncio.run(_link())
def asserter(params):
assert "currency" not in params, (
"currency must not be passed once a customer exists — "
"Stripe rejects mismatches against the locked customer currency"
)
assert params["customer"] == "cus_existing_xxxxxxxxxxxxxx"
with patch("app.routers.stripe_billing._stripe_client",
return_value=_fake_checkout_client(asserter)):
r = client.post(
"/api/stripe/checkout",
json={"cadence": "monthly", "currency": "usd"},
cookies={"cassandra_session": session_cookie},
headers={"cf-ipcountry": "US"},
)
assert r.status_code == 200, r.text
def test_sniff_currency_fallback_chain():
"""Unit-test the header-sniffing helper: CF country wins, then
Accept-Language exact, then language-only, then GBP default."""
from types import SimpleNamespace
from app.routers.stripe_billing import _sniff_currency
def _req(headers):
return SimpleNamespace(headers=headers)
assert _sniff_currency(_req({"cf-ipcountry": "DE"})) == "eur"
assert _sniff_currency(_req({"cf-ipcountry": "us"})) == "usd" # case-insensitive
assert _sniff_currency(_req({"accept-language": "fr-FR,fr;q=0.9"})) == "eur"
assert _sniff_currency(_req({"accept-language": "en-US,en;q=0.5"})) == "usd"
assert _sniff_currency(_req({"accept-language": "ja,ja-JP;q=0.5"})) == "gbp"
assert _sniff_currency(_req({})) == "gbp"

View file

@ -9,13 +9,35 @@ from unittest.mock import AsyncMock
import pytest
def _build_session_factory(tmp_path):
"""Spin up a fresh in-memory schema and return (engine, factory, setup).
Mirrors tests/test_llm_csv_parser.py / tests/test_referral_conversion.py."""
from sqlalchemy.ext.asyncio import async_sessionmaker, create_async_engine
async def test_validate_happy_path(db_factory, monkeypatch):
from app import db as db_mod
from app.db import Base
import app.models # noqa: F401
engine = create_async_engine(f"sqlite+aiosqlite:///{tmp_path}/tv.db")
factory = async_sessionmaker(engine, expire_on_commit=False)
db_mod._engine = engine
db_mod._session_factory = factory
async def _setup():
async with engine.begin() as conn:
await conn.run_sync(Base.metadata.create_all)
return engine, factory, _setup
@pytest.mark.asyncio
async def test_validate_happy_path(tmp_path, monkeypatch):
from app.routers.ticker_validate import validate_ticker
from app.services.market import Quote
import app.routers.ticker_validate as mod
factory = db_factory
_, factory, setup = _build_session_factory(tmp_path)
await setup()
# Mock fetch_yahoo to return a successful quote.
async def _fake_yahoo(client, symbol, label, note, anchor=None):
@ -40,12 +62,14 @@ async def test_validate_happy_path(db_factory, monkeypatch):
assert result["as_of"] == "2026-05-27"
async def test_validate_unknown_symbol(db_factory, monkeypatch):
@pytest.mark.asyncio
async def test_validate_unknown_symbol(tmp_path, monkeypatch):
from app.routers.ticker_validate import validate_ticker
from app.services.market import Quote
import app.routers.ticker_validate as mod
factory = db_factory
_, factory, setup = _build_session_factory(tmp_path)
await setup()
# Mock fetch_yahoo to return a Quote with error and no price.
async def _fake_yahoo(client, symbol, label, note, anchor=None):
@ -61,6 +85,7 @@ async def test_validate_unknown_symbol(db_factory, monkeypatch):
assert "not recognised" in result["error"].lower()
@pytest.mark.asyncio
async def test_validate_empty_symbol_rejects():
from app.routers.ticker_validate import validate_ticker
@ -70,7 +95,8 @@ async def test_validate_empty_symbol_rejects():
assert "required" in result["error"].lower()
async def test_validate_seeds_universe_and_quote(db_factory, monkeypatch):
@pytest.mark.asyncio
async def test_validate_seeds_universe_and_quote(tmp_path, monkeypatch):
"""Side-effect check: on success, the symbol is upserted into the
universe and a Quote row is written."""
from sqlalchemy import select
@ -80,7 +106,8 @@ async def test_validate_seeds_universe_and_quote(db_factory, monkeypatch):
from app.services.market import Quote
import app.routers.ticker_validate as mod
factory = db_factory
_, factory, setup = _build_session_factory(tmp_path)
await setup()
upsert_calls: list[list[str]] = []
@ -110,6 +137,7 @@ async def test_validate_seeds_universe_and_quote(db_factory, monkeypatch):
assert rows[0].currency == "USD"
@pytest.mark.asyncio
async def test_historical_happy_path(monkeypatch):
from app.routers.ticker_validate import get_historical
import app.routers.ticker_validate as mod
@ -128,6 +156,7 @@ async def test_historical_happy_path(monkeypatch):
assert result["actual_date"] == "2024-01-12"
@pytest.mark.asyncio
async def test_historical_future_date_rejected():
from fastapi import HTTPException
from app.routers.ticker_validate import get_historical
@ -139,6 +168,7 @@ async def test_historical_future_date_rejected():
assert "future" in str(exc.value.detail).lower()
@pytest.mark.asyncio
async def test_historical_bad_date_format_rejected():
from fastapi import HTTPException
from app.routers.ticker_validate import get_historical
@ -148,6 +178,7 @@ async def test_historical_bad_date_format_rejected():
assert exc.value.status_code == 400
@pytest.mark.asyncio
async def test_historical_no_data(monkeypatch):
from app.routers.ticker_validate import get_historical
import app.routers.ticker_validate as mod
@ -161,6 +192,7 @@ async def test_historical_no_data(monkeypatch):
assert "no data" in result["error"].lower()
@pytest.mark.asyncio
async def test_historical_provider_failure(monkeypatch):
import httpx
@ -176,6 +208,7 @@ async def test_historical_provider_failure(monkeypatch):
assert "couldn" in result["error"].lower() or "fetch" in result["error"].lower()
@pytest.mark.asyncio
async def test_fetch_yahoo_historical_walks_back_to_preceding_trading_day(monkeypatch):
"""Unit test for the helper itself: feed a hand-crafted series with a
weekend gap, ask for the Saturday close, expect Friday's close."""