diff --git a/.gitignore b/.gitignore index 0c0c5ae..168165b 100644 --- a/.gitignore +++ b/.gitignore @@ -16,3 +16,4 @@ build/ dist/ .coverage .mypy_cache/ +.superpowers/ diff --git a/Dockerfile b/Dockerfile index 1123177..09c6443 100644 --- a/Dockerfile +++ b/Dockerfile @@ -6,11 +6,17 @@ ENV PIP_DISABLE_PIP_VERSION_CHECK=1 \ PYTHONDONTWRITEBYTECODE=1 WORKDIR /build -COPY pyproject.toml ./ +COPY pyproject.toml requirements.lock ./ 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 . + && /opt/venv/bin/pip install -r requirements.lock \ + && /opt/venv/bin/pip install --no-deps . FROM python:3.13-slim AS runtime @@ -49,7 +55,7 @@ ENV PYTHONUNBUFFERED=1 \ COPY --from=builder /opt/venv /opt/venv WORKDIR /app -COPY pyproject.toml ./ +COPY pyproject.toml requirements.lock ./ COPY app ./app COPY alembic ./alembic COPY alembic.ini ./ @@ -57,6 +63,10 @@ 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. -RUN /opt/venv/bin/pip install ".[dev]" +# 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 . CMD ["pytest", "tests/", "-v"] diff --git a/alembic/env.py b/alembic/env.py index a652b05..0d40d2c 100644 --- a/alembic/env.py +++ b/alembic/env.py @@ -44,10 +44,17 @@ 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() diff --git a/alembic/versions/0005_widen_quote_symbol.py b/alembic/versions/0005_widen_quote_symbol.py index 22b2bce..b7f8d33 100644 --- a/alembic/versions/0005_widen_quote_symbol.py +++ b/alembic/versions/0005_widen_quote_symbol.py @@ -17,18 +17,25 @@ depends_on: Union[str, Sequence[str], None] = None def upgrade() -> None: - op.alter_column( - "quotes", "symbol", - existing_type=sa.String(64), - type_=sa.String(128), - existing_nullable=False, - ) + # 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", + existing_type=sa.String(64), + type_=sa.String(128), + existing_nullable=False, + ) def downgrade() -> None: - op.alter_column( - "quotes", "symbol", - existing_type=sa.String(128), - type_=sa.String(64), - existing_nullable=False, - ) + with op.batch_alter_table("quotes") as bop: + bop.alter_column( + "symbol", + existing_type=sa.String(128), + type_=sa.String(64), + existing_nullable=False, + ) diff --git a/alembic/versions/0013_referrals.py b/alembic/versions/0013_referrals.py index 6eeae26..89b32f2 100644 --- a/alembic/versions/0013_referrals.py +++ b/alembic/versions/0013_referrals.py @@ -30,23 +30,21 @@ depends_on: Union[str, Sequence[str], None] = None def upgrade() -> None: - 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", - ) + # 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", + "users", + ["referred_by_user_id"], ["id"], + ondelete="SET NULL", + ) op.create_table( "referrals", @@ -71,7 +69,8 @@ def upgrade() -> None: def downgrade() -> None: op.drop_index("ix_referrals_referrer", table_name="referrals") op.drop_table("referrals") - 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") + 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") diff --git a/alembic/versions/0018_polar_webhook.py b/alembic/versions/0018_polar_webhook.py index bc085a7..5d3f31c 100644 --- a/alembic/versions/0018_polar_webhook.py +++ b/alembic/versions/0018_polar_webhook.py @@ -17,17 +17,12 @@ depends_on: Union[str, Sequence[str], None] = None def upgrade() -> None: - 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"], - ) + 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.create_table( "polar_events", @@ -50,6 +45,7 @@ def upgrade() -> None: def downgrade() -> None: op.drop_index("ix_polar_events_type_received", table_name="polar_events") op.drop_table("polar_events") - op.drop_constraint("uq_users_polar_customer", "users", type_="unique") - op.drop_column("users", "polar_subscription_id") + 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_column("users", "polar_customer_id") diff --git a/alembic/versions/0019_stripe.py b/alembic/versions/0019_stripe.py index 3ea4018..acd516d 100644 --- a/alembic/versions/0019_stripe.py +++ b/alembic/versions/0019_stripe.py @@ -18,17 +18,12 @@ depends_on: Union[str, Sequence[str], None] = None def upgrade() -> None: - 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"], - ) + 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.create_table( "stripe_events", @@ -51,6 +46,7 @@ def upgrade() -> None: def downgrade() -> None: op.drop_index("ix_stripe_events_type_received", table_name="stripe_events") op.drop_table("stripe_events") - op.drop_constraint("uq_users_stripe_customer", "users", type_="unique") - op.drop_column("users", "stripe_subscription_id") - op.drop_column("users", "stripe_customer_id") + 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") diff --git a/alembic/versions/0022_localization.py b/alembic/versions/0022_localization.py new file mode 100644 index 0000000..30f6814 --- /dev/null +++ b/alembic/versions/0022_localization.py @@ -0,0 +1,46 @@ +"""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") diff --git a/alembic/versions/0023_lang_index_and_qd_symbol_widen.py b/alembic/versions/0023_lang_index_and_qd_symbol_widen.py new file mode 100644 index 0000000..31a6eeb --- /dev/null +++ b/alembic/versions/0023_lang_index_and_qd_symbol_widen.py @@ -0,0 +1,38 @@ +"""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") diff --git a/alembic/versions/0024_ind_summary_translations.py b/alembic/versions/0024_ind_summary_translations.py new file mode 100644 index 0000000..cde61bc --- /dev/null +++ b/alembic/versions/0024_ind_summary_translations.py @@ -0,0 +1,38 @@ +"""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") diff --git a/alembic/versions/0025_align_translation_columns.py b/alembic/versions/0025_align_translation_columns.py new file mode 100644 index 0000000..dbee1d7 --- /dev/null +++ b/alembic/versions/0025_align_translation_columns.py @@ -0,0 +1,79 @@ +"""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) diff --git a/app/branding.py b/app/branding.py index 1bd8f48..dd7370c 100644 --- a/app/branding.py +++ b/app/branding.py @@ -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, 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. +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. The colour palette below is hand-authored in CSS as well; a drift- detection test (`tests/test_branding_consistency.py`) parses -`cassandra.css` and asserts every variable matches. Update both or +`tokens.css` and asserts every variable matches. Update both or neither. The light theme is the *default* everywhere — dashboard `:root` block, diff --git a/app/config.py b/app/config.py index 0aabb1d..6c5b0ee 100644 --- a/app/config.py +++ b/app/config.py @@ -42,7 +42,6 @@ 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). @@ -59,9 +58,7 @@ 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))" @@ -97,7 +94,6 @@ 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 diff --git a/app/jobs/_market_context.py b/app/jobs/_market_context.py index 5dd591f..d55d695 100644 --- a/app/jobs/_market_context.py +++ b/app/jobs/_market_context.py @@ -42,6 +42,7 @@ 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() diff --git a/app/jobs/ai_log_job.py b/app/jobs/ai_log_job.py index bc8b488..197faa5 100644 --- a/app/jobs/ai_log_job.py +++ b/app/jobs/ai_log_job.py @@ -17,16 +17,91 @@ from app.jobs._market_context import ( month_spend, recent_headlines_by_bucket, ) -from app.models import AICall, JobRun, StrategicLog +from app.models import AICall, JobRun, StrategicLog, StrategicLogTranslation, User from app.services.cadence import DEFAULT_POLICY -from app.services.openrouter import ( +from app.services.i18n import ACTIVE_LANGUAGES +from app.services.llm_prompts 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: @@ -126,7 +201,28 @@ async def run() -> None: tone=tone, analysis=analysis, error=str(e)[:200]) continue - session.add(StrategicLog( + # 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( generated_at=utcnow(), model=result.model, anchor_date=anchor, @@ -136,16 +232,18 @@ async def run() -> None: content=result.content, prompt_tokens=result.prompt_tokens, completion_tokens=result.completion_tokens, - cost_usd=result.cost_usd, - )) + cost_usd=full_cost, + ) + session.add(slog) session.add(AICall( model=result.model, prompt_tokens=result.prompt_tokens, completion_tokens=result.completion_tokens, - cost_usd=result.cost_usd, + cost_usd=full_cost, 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, diff --git a/app/jobs/email_digest_job.py b/app/jobs/email_digest_job.py index 1f38777..4cbd865 100644 --- a/app/jobs/email_digest_job.py +++ b/app/jobs/email_digest_job.py @@ -29,14 +29,20 @@ 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.email_service import render_digest_email, send_email -from app.services.openrouter import ( +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 ( 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: @@ -88,12 +94,31 @@ 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=result.cost_usd, + cost_usd=full_cost, status="ok", )) await session.commit() @@ -116,6 +141,62 @@ 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" @@ -200,17 +281,21 @@ 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() - # 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 + content = _pick_variant( + variant_table, + tone=tone, + lang=(u.lang or "en"), + ) await _send_one(u, kind, content, date_str, session) await asyncio.sleep(0.1) written += 1 diff --git a/app/jobs/indicator_summary_job.py b/app/jobs/indicator_summary_job.py index d96b309..422c49c 100644 --- a/app/jobs/indicator_summary_job.py +++ b/app/jobs/indicator_summary_job.py @@ -4,8 +4,7 @@ hourly stays comfortably under the monthly cap.""" from __future__ import annotations import asyncio -import re -from collections import defaultdict +import json import httpx from sqlalchemy import desc, func, select @@ -13,169 +12,146 @@ 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.models import AICall, IndicatorSummary, JobRun, Quote +from app.jobs._market_context import latest_quotes_by_group, month_spend +from app.models import ( + AICall, + IndicatorSummary, + IndicatorSummaryTranslation, + JobRun, + User, +) from app.services.cadence import DEFAULT_POLICY -from app.services.openrouter import ( +from app.services.i18n import ACTIVE_LANGUAGES +from app.services.llm_prompts 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__" -# 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"^[\"“'`]+", - ) -] +async def translate_summary_for_active_languages(session, summary_id: int) -> None: + """Fan out per-language translations for one IndicatorSummary row. - -_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: <...>. " - 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) + 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)) )).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 + 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) -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) +# 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 _generate_one( session, client: httpx.AsyncClient, group: str, quotes: list[dict], system_prompt: str, model: str, tone: str, analysis: str, -) -> bool: - """Generate + persist one group's summary. Returns True on success. +) -> 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. `model` is retained for ledger labelling but call_llm now picks the active-provider model itself.""" user_prompt = build_summary_user_prompt(group, quotes) @@ -185,19 +161,20 @@ 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 False + return None - 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]) + 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]) session.add(AICall( model=result.model, prompt_tokens=result.prompt_tokens, @@ -205,28 +182,48 @@ async def _generate_one( cost_usd=result.cost_usd, status="leaked", )) - return False + return None - session.add(IndicatorSummary( + 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( group_name=group, generated_at=utcnow(), model=result.model, tone=tone, analysis=analysis, prompt_version=PROMPT_VERSION, - content=cleaned, + content=candidate, prompt_tokens=result.prompt_tokens, completion_tokens=result.completion_tokens, - cost_usd=result.cost_usd, - )) + # 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) session.add(AICall( model=result.model, prompt_tokens=result.prompt_tokens, completion_tokens=result.completion_tokens, - cost_usd=result.cost_usd, + cost_usd=(result.cost_usd or 0.0) + (verdict.cost_usd or 0.0), status="ok", )) - return True + return summary async def run() -> None: @@ -254,13 +251,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. @@ -283,43 +280,73 @@ async def run() -> None: for tone in tones: system_prompt = build_summary_system_prompt(tone, analysis) for group, quotes in groups.items(): - ok = await _generate_one( + summary = await _generate_one( session, client, group, quotes, system_prompt, active_model(), tone, analysis, ) - if ok: + if summary is not None: 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, # room for reasoning + 80-word output + max_tokens=1500, + response_format={"type": "json_object"}, ) - session.add(IndicatorSummary( - group_name=AGGREGATE_GROUP_NAME, - generated_at=utcnow(), - model=result.model, - tone=tone, - analysis=analysis, - prompt_version=PROMPT_VERSION, - content=clean_summary(result.content), - prompt_tokens=result.prompt_tokens, - completion_tokens=result.completion_tokens, - cost_usd=result.cost_usd, - )) - session.add(AICall( - model=result.model, - prompt_tokens=result.prompt_tokens, - completion_tokens=result.completion_tokens, - cost_usd=result.cost_usd, status="ok", - )) - written += 1 + 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( + group_name=AGGREGATE_GROUP_NAME, + generated_at=utcnow(), + model=result.model, + tone=tone, + analysis=analysis, + prompt_version=PROMPT_VERSION, + content=candidate, + prompt_tokens=result.prompt_tokens, + completion_tokens=result.completion_tokens, + cost_usd=full_cost, + ) + session.add(agg_summary) + session.add(AICall( + model=result.model, + prompt_tokens=result.prompt_tokens, + completion_tokens=result.completion_tokens, + cost_usd=full_cost, status="ok", + )) + written += 1 except Exception as e: session.add(AICall( model=active_model(), status="error", @@ -328,6 +355,8 @@ 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", diff --git a/app/main.py b/app/main.py index fe987f5..7f1729f 100644 --- a/app/main.py +++ b/app/main.py @@ -19,7 +19,9 @@ 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 @@ -89,6 +91,8 @@ 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"]) diff --git a/app/models.py b/app/models.py index 665a8cd..57c9f19 100644 --- a/app/models.py +++ b/app/models.py @@ -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(64), primary_key=True) + symbol: Mapped[str] = mapped_column(String(128), 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,6 +120,41 @@ 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.""" @@ -139,6 +174,39 @@ 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" @@ -189,6 +257,14 @@ 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 @@ -463,5 +539,7 @@ class CsvFormatTemplate(Base): last_used_at: Mapped[datetime] = mapped_column( DateTime(timezone=True), nullable=False, default=utcnow, ) - llm_model: Mapped[str | None] = mapped_column(String(64)) - llm_cost_usd: Mapped[float | None] = mapped_column(Float) + 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) diff --git a/app/routers/api.py b/app/routers/api.py index 5e06090..5075654 100644 --- a/app/routers/api.py +++ b/app/routers/api.py @@ -10,39 +10,29 @@ import re from datetime import date, datetime, timedelta, timezone from typing import Literal -from fastapi import APIRouter, Depends, File, Form, HTTPException, Query, Request, UploadFile -from fastapi.responses import HTMLResponse, JSONResponse +from fastapi import APIRouter, Depends, HTTPException, Query, Request +from fastapi.responses import JSONResponse from sqlalchemy import desc, func, select from sqlalchemy.ext.asyncio import AsyncSession -from collections import defaultdict - -import httpx -from pydantic import BaseModel, Field +from pydantic import BaseModel 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, - JobRun, + IndicatorSummaryTranslation, Quote, StrategicLog, + StrategicLogTranslation, User, ) from app.schemas import ( - HealthOut, HeadlineOut, - JobStatus, QuoteOut, StrategicLogOut, ) @@ -50,11 +40,6 @@ 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. @@ -135,6 +120,7 @@ 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")) @@ -202,6 +188,7 @@ 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, @@ -295,11 +282,15 @@ async def news_list( # --- Strategic log ----------------------------------------------------------- -def _log_partial_payload(row: StrategicLog | None) -> dict | None: +def _log_partial_payload( + row: StrategicLog | None, + content_override: str | None = 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(row.content), + "content_html": _md_to_html(content), "generated_at": row.generated_at, "model": row.model, "tone": row.tone, @@ -311,6 +302,52 @@ def _log_partial_payload(row: StrategicLog | None) -> dict | None: } +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).""" @@ -366,10 +403,11 @@ 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), "tone": wanted_tone, - "paid": not free_only}, + {"log": _log_partial_payload(row, content_override=content_override), + "tone": wanted_tone, "paid": not free_only}, ) if row is None: @@ -420,10 +458,11 @@ 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), "tone": wanted_tone, - "paid": not free_only}, + {"log": _log_partial_payload(row, content_override=content_override), + "tone": wanted_tone, "paid": not free_only}, ) if row is None: raise HTTPException(status_code=404, detail="No log on this date") @@ -505,14 +544,6 @@ 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) ------------------- @@ -525,6 +556,7 @@ 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( @@ -546,6 +578,7 @@ 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}, @@ -563,303 +596,6 @@ 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 # --------------------------------------------------------------------------- @@ -895,3 +631,38 @@ 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) diff --git a/app/routers/chat.py b/app/routers/chat.py new file mode 100644 index 0000000..20f99e5 --- /dev/null +++ b/app/routers/chat.py @@ -0,0 +1,239 @@ +"""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, + } diff --git a/app/routers/email.py b/app/routers/email.py index 429101b..b7df411 100644 --- a/app/routers/email.py +++ b/app/routers/email.py @@ -63,7 +63,9 @@ _CONFIRM_PAGE = """\ Unsubscribed — {brand} - + + +
diff --git a/app/routers/ops.py b/app/routers/ops.py new file mode 100644 index 0000000..289f803 --- /dev/null +++ b/app/routers/ops.py @@ -0,0 +1,162 @@ +"""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}, + ) diff --git a/app/routers/pages.py b/app/routers/pages.py index f7ef42b..1801f93 100644 --- a/app/routers/pages.py +++ b/app/routers/pages.py @@ -75,14 +75,13 @@ 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) -> dict: - s = get_settings() + +def _log_page_context(target: date, paid: bool, user_lang: str = "en") -> dict: 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, } @@ -93,8 +92,9 @@ 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)), + request, "log.html", _log_page_context(target, is_paid_active(cu), user_lang), ) @@ -106,8 +106,9 @@ 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)), + request, "log.html", _log_page_context(target, is_paid_active(cu), user_lang), ) diff --git a/app/routers/stripe_billing.py b/app/routers/stripe_billing.py index 60bc7f7..bfdeed0 100644 --- a/app/routers/stripe_billing.py +++ b/app/routers/stripe_billing.py @@ -19,7 +19,7 @@ from __future__ import annotations import asyncio import json -from typing import Any, Literal +from typing import Any, Literal, Optional import stripe from fastapi import APIRouter, Body, Depends, HTTPException, Request @@ -69,6 +69,53 @@ 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 @@ -83,6 +130,10 @@ 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): @@ -92,6 +143,7 @@ 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: @@ -120,6 +172,13 @@ 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 diff --git a/app/routers/universe.py b/app/routers/universe.py index a77585f..d8d64ee 100644 --- a/app/routers/universe.py +++ b/app/routers/universe.py @@ -20,10 +20,7 @@ Four routes: held in memory for one LLM call, discarded on response. -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. +All routes require authentication (session cookie OR bearer token). """ from __future__ import annotations @@ -36,7 +33,7 @@ from fastapi.responses import JSONResponse from sqlalchemy import and_, func, select from sqlalchemy.ext.asyncio import AsyncSession -from app.auth import require_auth +from app.auth import CurrentUser, require_auth from app.config import get_settings from app.db import get_session, utcnow from app.logging import get_logger @@ -341,10 +338,11 @@ async def parse_portfolio( # --------------------------------------------------------------------------- -@router.post("/analyze", dependencies=[Depends(require_paid)]) +@router.post("/analyze") 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 @@ -364,6 +362,11 @@ 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: diff --git a/app/services/csv_import.py b/app/services/csv_import.py index cacd84d..41a6a99 100644 --- a/app/services/csv_import.py +++ b/app/services/csv_import.py @@ -221,7 +221,4 @@ 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). + diff --git a/app/services/digest_email.py b/app/services/digest_email.py new file mode 100644 index 0000000..3d416f6 --- /dev/null +++ b/app/services/digest_email.py @@ -0,0 +1,116 @@ +"""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 = """\ + + + + + + + {brand} — {label} + + + + + +
+
+ ▰ {brand_upper} · {label_upper} +
+
 
+
+ {content_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 diff --git a/app/services/email_service.py b/app/services/email_service.py index d3ed9f7..8180ca6 100644 --- a/app/services/email_service.py +++ b/app/services/email_service.py @@ -18,8 +18,6 @@ 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 @@ -323,106 +321,3 @@ 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 = """\ - - - - - - - {brand} — {label} - - - - - -
-
- ▰ {brand_upper} · {label_upper} -
-
 
-
- {content_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 diff --git a/app/services/glossary.py b/app/services/glossary.py index c994995..40aa938 100644 --- a/app/services/glossary.py +++ b/app/services/glossary.py @@ -10,8 +10,8 @@ The wrap markup is: VIX `title` gives a native fallback on touch devices that don't fire :hover. -The CSS tooltip (see `.glossary:hover::after` in cassandra.css) uses -`data-def` for richer formatting. Wrapping happens at most once per term +The CSS tooltip (see `.glossary` / `#glossary-tooltip` in dashboard.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 diff --git a/app/services/i18n.py b/app/services/i18n.py new file mode 100644 index 0000000..742373d --- /dev/null +++ b/app/services/i18n.py @@ -0,0 +1,48 @@ +"""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 ."`` + keyed off ``LANGUAGES``. + """ + if not lang or lang == "en" or lang not in LANGUAGES: + return "" + return f"\n\nRespond in {LANGUAGES[lang]}." diff --git a/app/services/llm_csv_parser.py b/app/services/llm_csv_parser.py index 7bb84af..7c7c7a5 100644 --- a/app/services/llm_csv_parser.py +++ b/app/services/llm_csv_parser.py @@ -424,8 +424,10 @@ async def parse_with_llm(raw: bytes, session: AsyncSession) -> ParsedPie: first_seen_at=now, last_used_at=now, use_count=1, - llm_model=llm_log.model, - llm_cost_usd=llm_log.cost_usd, + model=llm_log.model, + prompt_tokens=llm_log.prompt_tokens, + completion_tokens=llm_log.completion_tokens, + cost_usd=llm_log.cost_usd, )) await session.commit() return pie diff --git a/app/services/llm_prompts.py b/app/services/llm_prompts.py new file mode 100644 index 0000000..726b60a --- /dev/null +++ b/app/services/llm_prompts.py @@ -0,0 +1,620 @@ +"""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": ""}} +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": ""}} +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

,

,
    ,
  • , , " + " — no , , or 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

    ,

    ,
      ,
    • , , " + " — no , , or 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) diff --git a/app/services/openrouter.py b/app/services/openrouter.py index a542b98..50e7f7e 100644 --- a/app/services/openrouter.py +++ b/app/services/openrouter.py @@ -1,8 +1,8 @@ -"""Strategic-log generator — DB-fed, OpenRouter-backed. +"""LLM transport layer — OpenRouter / DeepSeek API calls. -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. +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*. """ from __future__ import annotations @@ -18,420 +18,31 @@ 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 -# --- 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.""", +# 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), } -# 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 _estimate_cost_usd(model: str, prompt_tokens, completion_tokens) -> float | None: + """Compute cost from token counts when the upstream didn't return one. - -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) + 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 @dataclass @@ -443,172 +54,6 @@ 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

      ,

      ,
        ,
      • , , " - " — no , , or 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

        ,

        ,
          ,
        • , , " - " — no , , or 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).""" @@ -691,10 +136,15 @@ 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.""" + 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.""" url, api_key, default_model, extra_headers = _endpoint_for(provider) used_model = model or default_model headers = { @@ -702,18 +152,22 @@ async def _call_provider( "Content-Type": "application/json", **extra_headers, } - r = await client.post( - url, - headers=headers, - json={"model": used_model, "messages": messages, "max_tokens": max_tokens}, - timeout=180, - ) + 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.raise_for_status() data = r.json() msg = data["choices"][0]["message"] - # 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") + # 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") if not content: finish = data["choices"][0].get("finish_reason") raise RuntimeError( @@ -721,13 +175,21 @@ 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=usage.get("prompt_tokens"), - completion_tokens=usage.get("completion_tokens"), - cost_usd=usage.get("cost") or usage.get("total_cost"), + prompt_tokens=prompt_tokens, + completion_tokens=completion_tokens, + cost_usd=cost_usd, ) @@ -736,6 +198,8 @@ 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 @@ -744,8 +208,20 @@ 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.""" - chain = _provider_chain() + 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: + chain = _provider_chain() if not chain: raise RuntimeError("No LLM provider configured (no API key set)") @@ -754,6 +230,7 @@ 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 @@ -775,10 +252,6 @@ 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) diff --git a/app/services/output_review.py b/app/services/output_review.py new file mode 100644 index 0000000..4fbb2fb --- /dev/null +++ b/app/services/output_review.py @@ -0,0 +1,162 @@ +"""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) diff --git a/app/services/portfolio_analysis.py b/app/services/portfolio_analysis.py index eb8a349..1f6bea7 100644 --- a/app/services/portfolio_analysis.py +++ b/app/services/portfolio_analysis.py @@ -31,10 +31,12 @@ 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, ) @@ -74,6 +76,7 @@ class AnalysisRequest: anchor: str | None = None tone: str = "INTERMEDIATE" # NOVICE | INTERMEDIATE | PRO analysis: str = "SPECULATIVE" # DRY | SPECULATIVE + lang: str = "en" @dataclass @@ -163,10 +166,13 @@ 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, + anchor=anchor, tone=tone, analysis=analysis, lang=lang, ) @@ -276,7 +282,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 + system = build_system_prompt(req.tone, req.analysis) + "\n\n" + _SYSTEM_OVERRIDES + respond_in_clause(req.lang) user_parts = [ f"# Portfolio commentary request — {utcnow().strftime('%Y-%m-%d')}", @@ -317,6 +323,8 @@ 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( @@ -335,15 +343,31 @@ 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 or failed, so cost-cap and rate-limit logic can - # observe the attempt. + # call succeeded, failed, or was rejected by the reviewer, 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=llm.cost_usd if llm else None, + cost_usd=full_cost, status=status, error=error_msg, )) @@ -351,19 +375,26 @@ 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=llm.cost_usd, + cost_usd=full_cost, ) return AnalysisResult( content=llm.content, model=llm.model, prompt_tokens=llm.prompt_tokens, completion_tokens=llm.completion_tokens, - cost_usd=llm.cost_usd, + cost_usd=full_cost, generated_at=datetime.now(timezone.utc), ) diff --git a/app/services/translation.py b/app/services/translation.py new file mode 100644 index 0000000..96f99ed --- /dev/null +++ b/app/services/translation.py @@ -0,0 +1,88 @@ +"""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 diff --git a/app/static/css/auth.css b/app/static/css/auth.css new file mode 100644 index 0000000..31081bb --- /dev/null +++ b/app/static/css/auth.css @@ -0,0 +1,149 @@ +/* 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; + } +} diff --git a/app/static/css/cassandra.css b/app/static/css/cassandra.css deleted file mode 100644 index b4b6f6b..0000000 --- a/app/static/css/cassandra.css +++ /dev/null @@ -1,2546 +0,0 @@ -/* Cassandra — geopolitical-terminal aesthetic with two themes. - * Mono for data, headers, terminal feel; sans for prose surfaces (log + chat). */ - -: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; } - -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; -} - -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; -} - -.app-header { - display: flex; - align-items: center; - justify-content: space-between; - 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 .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.active { color: var(--text); } -.app-header .meta { color: var(--muted); font-size: 11px; } - -.app-header .header-right { display: flex; align-items: center; gap: 14px; } -.theme-toggle { - background: transparent; - border: 1px solid var(--border); - color: var(--muted); - padding: 3px 8px; - font-family: var(--font-mono); - font-size: 10px; - letter-spacing: 0.08em; - cursor: pointer; - text-transform: lowercase; -} -.theme-toggle:hover { color: var(--accent); border-color: var(--accent); } -.theme-toggle__label::before { content: "◐ light"; } -[data-theme="dark"] .theme-toggle__label::before { content: "◐ dark"; } - -/* Tone toggle (segmented control: Novice | Intermediate) */ -.tone-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 { - background: transparent; - color: var(--muted); - border: 0; - padding: 4px 10px; - cursor: pointer; - font: inherit; - letter-spacing: inherit; - text-transform: inherit; -} -.tone-toggle button + button { border-left: 1px solid var(--border); } -.tone-toggle button:hover { color: var(--accent); } -.tone-toggle[data-tone="NOVICE"] button[data-value="NOVICE"], -.tone-toggle[data-tone="INTERMEDIATE"] button[data-value="INTERMEDIATE"] { - background: var(--accent); - color: var(--bg); -} - -.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; - /* Don't stretch to fill both grid rows; if the log is shorter than - the portfolio next to it, the surplus below would render as a big - empty white box. Aligning to the start makes the panel shrink to - its content and the dashboard background fills any gap. */ - align-self: start; -} -#news-panel { grid-area: news; } - -/* Legacy footer rules — kept for the /api/health page which still uses - the old class via the standalone HTML template. */ -.app-footer { - border-top: 1px solid var(--border); - padding: 8px 18px; - background: var(--surface); - font-size: 11px; - color: var(--muted); - display: flex; - gap: 16px; - flex-wrap: wrap; -} - -/* 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; -} - -/* --- 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); } -.note { color: var(--dim); font-size: 11px; } - -/* Stale indicator rows — last observation > 90 days old */ -table.dense tr.row-stale td { color: var(--dim); } -.stale-tag { - display: inline-block; - font-size: 8.5px; - letter-spacing: 0.08em; - color: var(--alert); - border: 1px solid var(--alert); - padding: 0 4px; - margin-left: 4px; - vertical-align: middle; - text-transform: uppercase; - cursor: help; -} - -/* --- 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); } - -/* --- Dashboard top header (markets + aggregate read) ----------------- */ - -.dash-header { - display: grid; - grid-template-columns: 1fr; - gap: 12px; - margin-bottom: 0; -} -.dash-header__markets { - display: grid; - grid-template-columns: repeat(auto-fit, minmax(150px, 1fr)); - gap: 1px; - background: var(--border); - border: 1px solid var(--border); -} -.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; -} -.mkt__when-label { color: var(--dim); } - -.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); -} - -/* --- 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-actions .pf-secondary { color: var(--muted); } -.pf-actions .pf-secondary:hover { color: var(--negative); border-color: var(--negative); } - -/* 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; } -.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; -} - -/* --- 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; - max-height: calc(100vh - 240px); - overflow-y: auto; -} -.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); } - -/* --- 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; } - -/* --- Log metadata footer ---------------------------------------------- */ - -.log-meta { - padding: 4px clamp(20px, 4vw, 56px) 6px; - max-width: 76ch; - margin: 0 auto; - border-top: 1px dashed var(--border); - color: var(--dim); - font-size: 10.5px; - font-family: var(--font-mono); - letter-spacing: 0.04em; -} - -/* --- 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); } - -/* --- 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); } -.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; } - -/* --- 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; -} - -/* Sections are
          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; -} -.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; -} - -/* 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-1, var(--surface-2)); - 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 page (drag-drop CSV) ------------------------------------- */ - -.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; } - -.form-row { display: grid; grid-template-columns: 180px 1fr; align-items: center; gap: 12px; padding: 6px 0; } -.form-row label { color: var(--muted); font-size: 11px; text-transform: uppercase; letter-spacing: 0.08em; } -.form-row input[type="text"], .form-row select { - background: var(--bg); - border: 1px solid var(--border); - color: var(--text); - font-family: var(--font-mono); - font-size: 12px; - padding: 6px 8px; - outline: none; -} -.form-row input[type="text"]:focus, .form-row select:focus { border-color: var(--accent); } - -#submit-btn { - margin-top: 14px; - background: transparent; - border: 1px solid var(--accent); - color: var(--accent); - font-family: var(--font-mono); - font-size: 11px; - padding: 8px 18px; - text-transform: uppercase; - letter-spacing: 0.1em; - cursor: pointer; -} -#submit-btn:hover:not(:disabled) { background: var(--accent); color: var(--bg); } -#submit-btn:disabled { opacity: 0.4; cursor: not-allowed; } - -.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__tag { - display: inline-block; - margin-left: 6px; - font-size: 9px; - padding: 1px 5px; - border: 1px solid var(--accent); - color: var(--accent); -} -.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); } - -/* --- 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; } - -/* --- 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); } - -/* --- 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; } - -/* --- 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); } - -/* ============================================================ - * Public pages — landing, pricing, about, terms, privacy, disclaimer. - * Shared by all templates extending public_base.html. Visual language - * matches the app shell (same palette, monospace brand, restrained - * typography) 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; } -} - -/* 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; -} - - -/* ----------------------------------------------------------------------------- - Landing-page screenshots: hero shot, thumbnails inside feature cards, gallery - strip, and a -based lightbox. See app/templates/landing.html. */ - -/* All clickable screenshots are + {% else %} @@ -69,5 +68,5 @@ {% endif %}

-{% if paid %}{% endif %} +{% if paid %}{% endif %} {% endblock %} diff --git a/app/templates/login.html b/app/templates/login.html index 66816c1..2cfc899 100644 --- a/app/templates/login.html +++ b/app/templates/login.html @@ -10,7 +10,9 @@ catch (e) { document.documentElement.dataset.theme = 'light'; } })(); - + + +
diff --git a/app/templates/partials/indicators.html b/app/templates/partials/indicators.html index 0ae1e1f..70aaab0 100644 --- a/app/templates/partials/indicators.html +++ b/app/templates/partials/indicators.html @@ -20,11 +20,11 @@ - - - - {% if has_anchor %}{% endif %} - + + + + {% if has_anchor %}{% endif %} + @@ -46,22 +46,22 @@ - + - + {% for k in ["1d","1m","1y"] %} {% set v = q.changes.get(k) if q.changes else None %} - {% endfor %} {% if has_anchor %} {% set va = q.changes.get('anchor') if q.changes else None %} - {% endif %} - + {% endif %} {% endfor %} diff --git a/app/templates/partials/portfolio.html b/app/templates/partials/portfolio.html index 18061f9..99d4dc0 100644 --- a/app/templates/partials/portfolio.html +++ b/app/templates/partials/portfolio.html @@ -51,8 +51,8 @@ - - + + @@ -63,8 +63,8 @@ - - + +
SymbolLabelPriceCcy1d1m1yanchoras-ofSymbolLabelPriceCcy1d1m1yanchoras-of
{{ short_sym }} {{ q.label or "" }}{{ q.label or "" }} {{ q.price | price }}{{ q.currency or "" }}{{ q.currency or "" }} + {% if v is none %}—{% else %}{{ "%+.2f"|format(v) }}%{% endif %} + {% if va is none %}—{% else %}{{ "%+.2f"|format(va) }}%{% endif %} {{ q.as_of or "" }}{{ q.as_of or "" }}
Ticker NameQtyAvgQtyAvg Last P/L %
{{ pos.ticker }} {{ pos.name or "" }}{{ pos.quantity | price }}{{ pos.average_price | price }}{{ pos.quantity | price }}{{ pos.average_price | price }} {{ pos.current_price | price }} {{ pos.ppl | signed }} diff --git a/app/templates/pricing.html b/app/templates/pricing.html index 93f1562..c32fb26 100644 --- a/app/templates/pricing.html +++ b/app/templates/pricing.html @@ -62,7 +62,7 @@
  • Strategic log refreshed every hour instead of every six — track intraday moves as they unfold
  • Follow-up chat on any past log — ask the model a question against the day’s full context
  • Daily email digest (Mon–Sat) — ~600-word read of the session ahead, on top of the Sunday recap
  • -
  • Portfolio import from a broker CSV (Trading 212 supported today; more brokers planned)
  • +
  • Portfolio import from any broker CSV — Trading 212 natively, other formats auto-detected
  • AI portfolio read — diversification, sector and currency concentration, macro-regime fit on your holdings
  • Optional encrypted cloud sync — PIN-derived encryption in your browser, second-layer wrap on the server, no plaintext holdings server-side
  • diff --git a/app/templates/public_base.html b/app/templates/public_base.html index 77e4186..b1cef24 100644 --- a/app/templates/public_base.html +++ b/app/templates/public_base.html @@ -14,7 +14,12 @@ } catch (e) { document.documentElement.dataset.theme = 'light'; } })(); - + + + + + +
    diff --git a/app/templates/settings.html b/app/templates/settings.html index 20dfa57..ac0107d 100644 --- a/app/templates/settings.html +++ b/app/templates/settings.html @@ -106,7 +106,7 @@ Investing → Your Pie → ··· → Export.

    -
    +
    Drop your broker's portfolio CSV here
    @@ -187,7 +187,7 @@ + {% if (user.digest_tone or 'INTERMEDIATE') == 'INTERMEDIATE' %}checked{% endif %}> Pro
    @@ -224,6 +224,47 @@ })(); + {# --- Language block ------------------------------------------------ #} +
    + Language +

    + Language the AI uses for the strategic log, your daily digest, and + portfolio commentary. The interface itself stays in English for now. +

    +
    + + +
    + +
    + {# --- Cloud sync block --------------------------------------------- #}
    Cloud sync (encrypted) @@ -260,7 +301,7 @@