diff --git a/.gitignore b/.gitignore index 168165b..0c0c5ae 100644 --- a/.gitignore +++ b/.gitignore @@ -16,4 +16,3 @@ build/ dist/ .coverage .mypy_cache/ -.superpowers/ diff --git a/Dockerfile b/Dockerfile index 09c6443..1123177 100644 --- a/Dockerfile +++ b/Dockerfile @@ -6,17 +6,11 @@ ENV PIP_DISABLE_PIP_VERSION_CHECK=1 \ PYTHONDONTWRITEBYTECODE=1 WORKDIR /build -COPY pyproject.toml requirements.lock ./ +COPY pyproject.toml ./ COPY app ./app -# requirements.lock pins every transitive dependency to the known-good -# versions captured by `pip freeze` against a clean install. Install -# from it first, then add the project itself with --no-deps so the -# lockfile is the single source of truth and pyproject's range pins -# (>=) can't drift on rebuild. RUN python -m venv /opt/venv \ && /opt/venv/bin/pip install --upgrade pip \ - && /opt/venv/bin/pip install -r requirements.lock \ - && /opt/venv/bin/pip install --no-deps . + && /opt/venv/bin/pip install . FROM python:3.13-slim AS runtime @@ -55,7 +49,7 @@ ENV PYTHONUNBUFFERED=1 \ COPY --from=builder /opt/venv /opt/venv WORKDIR /app -COPY pyproject.toml requirements.lock ./ +COPY pyproject.toml ./ COPY app ./app COPY alembic ./alembic COPY alembic.ini ./ @@ -63,10 +57,6 @@ COPY alembic.ini ./ # a shipped image). docker-compose.test.yml bind-mounts ./tests:/app/tests # at run time, so the suite is always available without baking it in. -# The lockfile already contains the dev extras (pytest, ruff, aiosqlite, -# ...) because it was generated against a test-stage install. Same -# install pattern as the builder stage: lockfile first, project --no-deps. -RUN /opt/venv/bin/pip install -r requirements.lock \ - && /opt/venv/bin/pip install --no-deps . +RUN /opt/venv/bin/pip install ".[dev]" CMD ["pytest", "tests/", "-v"] diff --git a/alembic/env.py b/alembic/env.py index 0d40d2c..a652b05 100644 --- a/alembic/env.py +++ b/alembic/env.py @@ -44,17 +44,10 @@ def run_migrations_offline() -> None: def do_run_migrations(connection: Connection) -> None: - # render_as_batch is required for SQLite, which doesn't support - # most ALTER COLUMN / ADD CONSTRAINT operations natively. With - # batch mode enabled, Alembic emits a copy-and-rename dance under - # SQLite while still producing plain ALTER on MariaDB / Postgres, - # so prod migrations are unchanged. Detect via the dialect name. - render_as_batch = connection.dialect.name == "sqlite" context.configure( connection=connection, target_metadata=target_metadata, compare_type=True, - render_as_batch=render_as_batch, ) with context.begin_transaction(): context.run_migrations() diff --git a/alembic/versions/0005_widen_quote_symbol.py b/alembic/versions/0005_widen_quote_symbol.py index b7f8d33..22b2bce 100644 --- a/alembic/versions/0005_widen_quote_symbol.py +++ b/alembic/versions/0005_widen_quote_symbol.py @@ -17,25 +17,18 @@ depends_on: Union[str, Sequence[str], None] = None def upgrade() -> None: - # batch_alter_table wraps the ALTER in a copy-and-rename dance for - # SQLite (which doesn't support ALTER COLUMN TYPE) while remaining a - # plain ALTER on MariaDB / Postgres. Required for `alembic upgrade - # head` to work against a fresh SQLite database during local tooling - # or test bootstrap. - with op.batch_alter_table("quotes") as bop: - bop.alter_column( - "symbol", - existing_type=sa.String(64), - type_=sa.String(128), - existing_nullable=False, - ) + op.alter_column( + "quotes", "symbol", + existing_type=sa.String(64), + type_=sa.String(128), + existing_nullable=False, + ) def downgrade() -> None: - with op.batch_alter_table("quotes") as bop: - bop.alter_column( - "symbol", - existing_type=sa.String(128), - type_=sa.String(64), - existing_nullable=False, - ) + op.alter_column( + "quotes", "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 89b32f2..6eeae26 100644 --- a/alembic/versions/0013_referrals.py +++ b/alembic/versions/0013_referrals.py @@ -30,21 +30,23 @@ depends_on: Union[str, Sequence[str], None] = None def upgrade() -> None: - # batch_alter_table wraps ADD CONSTRAINT in a copy-and-rename for - # SQLite (no native ALTER constraints support); on MariaDB/Postgres - # it falls through to plain ALTER statements. - with op.batch_alter_table("users") as bop: - bop.add_column(sa.Column("referral_code", sa.String(16), nullable=True)) - bop.create_unique_constraint( - "uq_users_referral_code", ["referral_code"], - ) - bop.add_column(sa.Column("referred_by_user_id", sa.Integer, nullable=True)) - bop.create_foreign_key( - "fk_users_referred_by", - "users", - ["referred_by_user_id"], ["id"], - ondelete="SET NULL", - ) + 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", + ) op.create_table( "referrals", @@ -69,8 +71,7 @@ def upgrade() -> None: def downgrade() -> None: op.drop_index("ix_referrals_referrer", table_name="referrals") op.drop_table("referrals") - with op.batch_alter_table("users") as bop: - bop.drop_constraint("fk_users_referred_by", type_="foreignkey") - bop.drop_column("referred_by_user_id") - bop.drop_constraint("uq_users_referral_code", type_="unique") - bop.drop_column("referral_code") + op.drop_constraint("fk_users_referred_by", "users", type_="foreignkey") + op.drop_column("users", "referred_by_user_id") + op.drop_constraint("uq_users_referral_code", "users", type_="unique") + op.drop_column("users", "referral_code") diff --git a/alembic/versions/0018_polar_webhook.py b/alembic/versions/0018_polar_webhook.py index 5d3f31c..bc085a7 100644 --- a/alembic/versions/0018_polar_webhook.py +++ b/alembic/versions/0018_polar_webhook.py @@ -17,12 +17,17 @@ depends_on: Union[str, Sequence[str], None] = None def upgrade() -> None: - with op.batch_alter_table("users") as bop: - bop.add_column(sa.Column("polar_customer_id", sa.String(length=64), nullable=True)) - bop.add_column(sa.Column("polar_subscription_id", sa.String(length=64), nullable=True)) - bop.create_unique_constraint( - "uq_users_polar_customer", ["polar_customer_id"], - ) + op.add_column( + "users", + sa.Column("polar_customer_id", sa.String(length=64), nullable=True), + ) + op.add_column( + "users", + sa.Column("polar_subscription_id", sa.String(length=64), nullable=True), + ) + op.create_unique_constraint( + "uq_users_polar_customer", "users", ["polar_customer_id"], + ) op.create_table( "polar_events", @@ -45,7 +50,6 @@ def upgrade() -> None: def downgrade() -> None: op.drop_index("ix_polar_events_type_received", table_name="polar_events") op.drop_table("polar_events") - with op.batch_alter_table("users") as bop: - bop.drop_constraint("uq_users_polar_customer", type_="unique") - bop.drop_column("polar_subscription_id") + op.drop_constraint("uq_users_polar_customer", "users", type_="unique") + op.drop_column("users", "polar_subscription_id") op.drop_column("users", "polar_customer_id") diff --git a/alembic/versions/0019_stripe.py b/alembic/versions/0019_stripe.py index acd516d..3ea4018 100644 --- a/alembic/versions/0019_stripe.py +++ b/alembic/versions/0019_stripe.py @@ -18,12 +18,17 @@ depends_on: Union[str, Sequence[str], None] = None def upgrade() -> None: - with op.batch_alter_table("users") as bop: - bop.add_column(sa.Column("stripe_customer_id", sa.String(length=64), nullable=True)) - bop.add_column(sa.Column("stripe_subscription_id", sa.String(length=64), nullable=True)) - bop.create_unique_constraint( - "uq_users_stripe_customer", ["stripe_customer_id"], - ) + op.add_column( + "users", + sa.Column("stripe_customer_id", sa.String(length=64), nullable=True), + ) + op.add_column( + "users", + sa.Column("stripe_subscription_id", sa.String(length=64), nullable=True), + ) + op.create_unique_constraint( + "uq_users_stripe_customer", "users", ["stripe_customer_id"], + ) op.create_table( "stripe_events", @@ -46,7 +51,6 @@ def upgrade() -> None: def downgrade() -> None: op.drop_index("ix_stripe_events_type_received", table_name="stripe_events") op.drop_table("stripe_events") - with op.batch_alter_table("users") as bop: - bop.drop_constraint("uq_users_stripe_customer", type_="unique") - bop.drop_column("stripe_subscription_id") - bop.drop_column("stripe_customer_id") + op.drop_constraint("uq_users_stripe_customer", "users", type_="unique") + op.drop_column("users", "stripe_subscription_id") + op.drop_column("users", "stripe_customer_id") diff --git a/alembic/versions/0022_localization.py b/alembic/versions/0022_localization.py deleted file mode 100644 index 30f6814..0000000 --- a/alembic/versions/0022_localization.py +++ /dev/null @@ -1,46 +0,0 @@ -"""localization: users.lang + strategic_log_translations. - -Revision ID: 0022 -Revises: 0021 -Create Date: 2026-05-27 -""" -from typing import Sequence, Union - -import sqlalchemy as sa -from alembic import op - - -revision: str = "0022" -down_revision: Union[str, None] = "0021" -branch_labels: Union[str, Sequence[str], None] = None -depends_on: Union[str, Sequence[str], None] = None - - -def upgrade() -> None: - op.add_column( - "users", - sa.Column( - "lang", sa.String(length=8), nullable=False, - server_default="en", - ), - ) - op.create_table( - "strategic_log_translations", - sa.Column("id", sa.BigInteger(), primary_key=True, autoincrement=True), - sa.Column("log_id", sa.BigInteger(), nullable=False), - sa.Column("lang", sa.String(length=8), nullable=False), - sa.Column("content_md", sa.Text(), nullable=False), - sa.Column("generated_at", sa.DateTime(timezone=True), nullable=False), - sa.Column("llm_model", sa.String(length=64), nullable=True), - sa.Column("llm_cost_usd", sa.Float(), nullable=True), - sa.ForeignKeyConstraint( - ["log_id"], ["strategic_logs.id"], - ondelete="CASCADE", name="fk_slt_log", - ), - sa.UniqueConstraint("log_id", "lang", name="uq_slt_log_lang"), - ) - - -def downgrade() -> None: - op.drop_table("strategic_log_translations") - op.drop_column("users", "lang") diff --git a/alembic/versions/0023_lang_index_and_qd_symbol_widen.py b/alembic/versions/0023_lang_index_and_qd_symbol_widen.py deleted file mode 100644 index 31a6eeb..0000000 --- a/alembic/versions/0023_lang_index_and_qd_symbol_widen.py +++ /dev/null @@ -1,38 +0,0 @@ -"""users.lang index + widen quotes_daily.symbol to VARCHAR(128). - -Revision ID: 0023 -Revises: 0022 -Create Date: 2026-05-27 -""" -from typing import Sequence, Union - -import sqlalchemy as sa -from alembic import op - - -revision: str = "0023" -down_revision: Union[str, None] = "0022" -branch_labels: Union[str, Sequence[str], None] = None -depends_on: Union[str, Sequence[str], None] = None - - -def upgrade() -> None: - op.create_index("ix_users_lang", "users", ["lang"]) - with op.batch_alter_table("quotes_daily") as bop: - bop.alter_column( - "symbol", - existing_type=sa.String(length=64), - type_=sa.String(length=128), - existing_nullable=False, - ) - - -def downgrade() -> None: - with op.batch_alter_table("quotes_daily") as bop: - bop.alter_column( - "symbol", - existing_type=sa.String(length=128), - type_=sa.String(length=64), - existing_nullable=False, - ) - op.drop_index("ix_users_lang", table_name="users") diff --git a/alembic/versions/0024_ind_summary_translations.py b/alembic/versions/0024_ind_summary_translations.py deleted file mode 100644 index cde61bc..0000000 --- a/alembic/versions/0024_ind_summary_translations.py +++ /dev/null @@ -1,38 +0,0 @@ -"""indicator_summary_translations table. - -Revision ID: 0024 -Revises: 0023 -Create Date: 2026-05-27 -""" -from typing import Sequence, Union - -import sqlalchemy as sa -from alembic import op - - -revision: str = "0024" -down_revision: Union[str, None] = "0023" -branch_labels: Union[str, Sequence[str], None] = None -depends_on: Union[str, Sequence[str], None] = None - - -def upgrade() -> None: - op.create_table( - "indicator_summary_translations", - sa.Column("id", sa.BigInteger(), primary_key=True, autoincrement=True), - sa.Column("summary_id", sa.BigInteger(), nullable=False), - sa.Column("lang", sa.String(length=8), nullable=False), - sa.Column("content_md", sa.Text(), nullable=False), - sa.Column("generated_at", sa.DateTime(timezone=True), nullable=False), - sa.Column("llm_model", sa.String(length=64), nullable=True), - sa.Column("llm_cost_usd", sa.Float(), nullable=True), - sa.ForeignKeyConstraint( - ["summary_id"], ["indicator_summaries.id"], - ondelete="CASCADE", name="fk_ist_summary", - ), - sa.UniqueConstraint("summary_id", "lang", name="uq_ist_summary_lang"), - ) - - -def downgrade() -> None: - op.drop_table("indicator_summary_translations") diff --git a/alembic/versions/0025_align_translation_columns.py b/alembic/versions/0025_align_translation_columns.py deleted file mode 100644 index dbee1d7..0000000 --- a/alembic/versions/0025_align_translation_columns.py +++ /dev/null @@ -1,79 +0,0 @@ -"""align translation column naming + add token counts. - -Revision ID: 0025 -Revises: 0024 -Create Date: 2026-05-27 -""" -from typing import Sequence, Union - -import sqlalchemy as sa -from alembic import op - - -revision: str = "0025" -down_revision: Union[str, None] = "0024" -branch_labels: Union[str, Sequence[str], None] = None -depends_on: Union[str, Sequence[str], None] = None - - -def upgrade() -> None: - # strategic_log_translations - with op.batch_alter_table("strategic_log_translations") as bop: - bop.alter_column("llm_model", new_column_name="model", - existing_type=sa.String(length=64), existing_nullable=True) - bop.alter_column("llm_cost_usd", new_column_name="cost_usd", - existing_type=sa.Float(), existing_nullable=True) - bop.alter_column("content_md", new_column_name="content", - existing_type=sa.Text(), existing_nullable=False) - bop.add_column(sa.Column("prompt_tokens", sa.Integer(), nullable=True)) - bop.add_column(sa.Column("completion_tokens", sa.Integer(), nullable=True)) - - # indicator_summary_translations - with op.batch_alter_table("indicator_summary_translations") as bop: - bop.alter_column("llm_model", new_column_name="model", - existing_type=sa.String(length=64), existing_nullable=True) - bop.alter_column("llm_cost_usd", new_column_name="cost_usd", - existing_type=sa.Float(), existing_nullable=True) - bop.alter_column("content_md", new_column_name="content", - existing_type=sa.Text(), existing_nullable=False) - bop.add_column(sa.Column("prompt_tokens", sa.Integer(), nullable=True)) - bop.add_column(sa.Column("completion_tokens", sa.Integer(), nullable=True)) - - # csv_format_templates - with op.batch_alter_table("csv_format_templates") as bop: - bop.alter_column("llm_model", new_column_name="model", - existing_type=sa.String(length=64), existing_nullable=True) - bop.alter_column("llm_cost_usd", new_column_name="cost_usd", - existing_type=sa.Float(), existing_nullable=True) - bop.add_column(sa.Column("prompt_tokens", sa.Integer(), nullable=True)) - bop.add_column(sa.Column("completion_tokens", sa.Integer(), nullable=True)) - - -def downgrade() -> None: - with op.batch_alter_table("csv_format_templates") as bop: - bop.drop_column("completion_tokens") - bop.drop_column("prompt_tokens") - bop.alter_column("cost_usd", new_column_name="llm_cost_usd", - existing_type=sa.Float(), existing_nullable=True) - bop.alter_column("model", new_column_name="llm_model", - existing_type=sa.String(length=64), existing_nullable=True) - - with op.batch_alter_table("indicator_summary_translations") as bop: - bop.drop_column("completion_tokens") - bop.drop_column("prompt_tokens") - bop.alter_column("content", new_column_name="content_md", - existing_type=sa.Text(), existing_nullable=False) - bop.alter_column("cost_usd", new_column_name="llm_cost_usd", - existing_type=sa.Float(), existing_nullable=True) - bop.alter_column("model", new_column_name="llm_model", - existing_type=sa.String(length=64), existing_nullable=True) - - with op.batch_alter_table("strategic_log_translations") as bop: - bop.drop_column("completion_tokens") - bop.drop_column("prompt_tokens") - bop.alter_column("content", new_column_name="content_md", - existing_type=sa.Text(), existing_nullable=False) - bop.alter_column("cost_usd", new_column_name="llm_cost_usd", - existing_type=sa.Float(), existing_nullable=True) - bop.alter_column("model", new_column_name="llm_model", - existing_type=sa.String(length=64), existing_nullable=True) diff --git a/app/branding.py b/app/branding.py index dd7370c..1bd8f48 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, env var `CASSANDRA_TOKEN`) keep the legacy -name on purpose — renaming them would invalidate live sessions / -advisory locks / configs for zero brand benefit. +SQLAlchemy GET_LOCK keys, file `cassandra.css`, env var `CASSANDRA_TOKEN`) +keep the legacy name on purpose — renaming them would invalidate live +sessions / advisory locks / configs for zero brand benefit. The colour palette below is hand-authored in CSS as well; a drift- detection test (`tests/test_branding_consistency.py`) parses -`tokens.css` and asserts every variable matches. Update both or +`cassandra.css` and asserts every variable matches. Update both or neither. The light theme is the *default* everywhere — dashboard `:root` block, diff --git a/app/config.py b/app/config.py index 6c5b0ee..0aabb1d 100644 --- a/app/config.py +++ b/app/config.py @@ -42,6 +42,7 @@ class Settings(BaseSettings): # App CASSANDRA_TOKEN: str = "" + CASSANDRA_PORT: int = 8000 # Signing key for session cookies. Generate with: # python -c "import secrets; print(secrets.token_urlsafe(32))" # Falls back to CASSANDRA_TOKEN if unset (acceptable for single-host dev). @@ -58,7 +59,9 @@ class Settings(BaseSettings): SMTP_PASSWORD: str = "" SMTP_USE_TLS: bool = True SMTP_FROM: str = "" # Defaults to SMTP_USER if blank + CASSANDRA_BASE_CURRENCY: str = "GBP" CASSANDRA_ANCHOR_DATE: str = "" + CASSANDRA_MOCK: bool = False # Server-side pepper for the cloud-sync outer wrap. Generate with: # python -c "import secrets; print(secrets.token_urlsafe(32))" @@ -94,6 +97,7 @@ class Settings(BaseSettings): # env var. Empty = webhook endpoint refuses with 503 (so a misconfig # is loud rather than silently accepting unsigned events). POLAR_WEBHOOK_SECRET: str = "" + POLAR_API_KEY: str = "" # Stripe (merchant-on-record for read.markets after Polar/Paddle # both declined the financial-media category). Test-mode keys are diff --git a/app/jobs/_market_context.py b/app/jobs/_market_context.py index d55d695..5dd591f 100644 --- a/app/jobs/_market_context.py +++ b/app/jobs/_market_context.py @@ -42,7 +42,6 @@ async def latest_quotes_by_group(session) -> dict[str, list[dict]]: & (Quote.symbol == sub.c.symbol) & (Quote.fetched_at == sub.c.mx), ) - .where(Quote.price.is_not(None)) .order_by(Quote.group_name, Quote.symbol) ) rows = (await session.execute(stmt)).scalars().all() diff --git a/app/jobs/ai_log_job.py b/app/jobs/ai_log_job.py index 197faa5..bc8b488 100644 --- a/app/jobs/ai_log_job.py +++ b/app/jobs/ai_log_job.py @@ -17,91 +17,16 @@ from app.jobs._market_context import ( month_spend, recent_headlines_by_bucket, ) -from app.models import AICall, JobRun, StrategicLog, StrategicLogTranslation, User +from app.models import AICall, JobRun, StrategicLog from app.services.cadence import DEFAULT_POLICY -from app.services.i18n import ACTIVE_LANGUAGES -from app.services.llm_prompts import ( +from app.services.openrouter import ( PROMPT_VERSION, + active_model, build_system_prompt, build_user_prompt, -) -from app.services.output_review import review_read -from app.services.openrouter import ( - active_model, call_llm, llm_configured, ) -from app.services.translation import translate - - -async def translate_log_for_active_languages(session, log_id: int) -> None: - """Fan out per-language translations for the strategic log identified - by ``log_id``. - - Reads ``users.lang`` (deduplicated, restricted to ACTIVE_LANGUAGES - minus English), one translation call per language in parallel via - ``asyncio.gather``, persists each successful result as a - ``StrategicLogTranslation`` row. Each row is committed in its own - savepoint so a per-language LLM error or DB error doesn't roll back - the languages that already succeeded. - - The job orchestrator calls this AFTER the English ``StrategicLog`` - row is committed; pass the row's ``id`` in. - """ - target_langs = sorted({l for l in ACTIVE_LANGUAGES if l != "en"}) - if not target_langs: - return - - active_langs = (await session.execute( - select(User.lang).distinct().where(User.lang.in_(target_langs)) - )).scalars().all() - if not active_langs: - return - - log_row = await session.get(StrategicLog, log_id) - if log_row is None: - log.warning("log.translate.missing_log", log_id=log_id) - return - - async with httpx.AsyncClient(follow_redirects=True, timeout=60) as client: - results = await asyncio.gather(*[ - translate(client, log_row.content, lang) - for lang in active_langs - ], return_exceptions=True) - - succeeded = 0 - failed = 0 - for lang, result in zip(active_langs, results): - if isinstance(result, Exception): - log.warning("log.translate.failed", lang=lang, log_id=log_id, - error=str(result)[:200]) - failed += 1 - continue - translated_md, llm_result = result - try: - async with session.begin_nested(): - session.add(StrategicLogTranslation( - log_id=log_id, lang=lang, - content=translated_md, - generated_at=utcnow(), - model=llm_result.model, - prompt_tokens=llm_result.prompt_tokens, - completion_tokens=llm_result.completion_tokens, - cost_usd=llm_result.cost_usd, - )) - await session.commit() - succeeded += 1 - except Exception as exc: - log.warning("log.translate.persist_failed", - lang=lang, log_id=log_id, error=str(exc)[:200]) - failed += 1 - - if failed and succeeded == 0: - log.error("log.translate.all_failed", - log_id=log_id, attempted=len(active_langs)) - else: - log.info("log.translate.done", - log_id=log_id, succeeded=succeeded, failed=failed) async def run() -> None: @@ -201,28 +126,7 @@ async def run() -> None: tone=tone, analysis=analysis, error=str(e)[:200]) continue - # Reviewer gate: catches chain-of-thought, truncation, - # and (regulatory-critical) any financial-advice phrasing - # that drifted past the generator's system prompt. Drop - # rejected variants; the API falls back to the previous - # clean StrategicLog row. - verdict = await review_read(client, result.content) - full_cost = (result.cost_usd or 0.0) + (verdict.cost_usd or 0.0) - if not verdict.clean: - session.add(AICall( - model=result.model, - prompt_tokens=result.prompt_tokens, - completion_tokens=result.completion_tokens, - cost_usd=full_cost, status="leaked", - )) - await session.commit() - log.warning("ai_log.reviewer_rejected", - tone=tone, analysis=analysis, - reason=verdict.reason, - preview=result.content[:120]) - continue - - slog = StrategicLog( + session.add(StrategicLog( generated_at=utcnow(), model=result.model, anchor_date=anchor, @@ -232,18 +136,16 @@ async def run() -> None: content=result.content, prompt_tokens=result.prompt_tokens, completion_tokens=result.completion_tokens, - cost_usd=full_cost, - ) - session.add(slog) + cost_usd=result.cost_usd, + )) session.add(AICall( model=result.model, prompt_tokens=result.prompt_tokens, completion_tokens=result.completion_tokens, - cost_usd=full_cost, + cost_usd=result.cost_usd, status="ok", )) await session.commit() - await translate_log_for_active_languages(session, slog.id) written += 1 log.info("ai_log.variant_done", tone=tone, analysis=analysis, diff --git a/app/jobs/email_digest_job.py b/app/jobs/email_digest_job.py index 4cbd865..1f38777 100644 --- a/app/jobs/email_digest_job.py +++ b/app/jobs/email_digest_job.py @@ -29,20 +29,14 @@ from app.jobs._market_context import ( from app.models import EmailSend, User from app.routers.email import sign_unsubscribe_token from app.services.access import paid_status -from app.services.digest_email import render_digest_email -from app.services.email_service import send_email -from app.services.i18n import ACTIVE_LANGUAGES -from app.services.llm_prompts import ( +from app.services.email_service import render_digest_email, send_email +from app.services.openrouter import ( PROMPT_VERSION, build_daily_digest_prompt, build_weekly_digest_prompt, -) -from app.services.openrouter import ( call_llm, llm_configured, ) -from app.services.output_review import review_read -from app.services.translation import translate def _now() -> datetime: @@ -94,31 +88,12 @@ async def _generate_variants(session, client, kind: str, ctx: dict) -> dict[str, [{"role": "system", "content": sys_}, {"role": "user", "content": usr}], ) - # Reviewer gate. Digest emails land in inboxes — once - # delivered they're unrecallable, so a financial-advice slip - # has more reach than the dashboard. Drop rejected variants; - # users on that tone get no digest this cycle (better than - # delivering bad copy). - verdict = await review_read(client, result.content) - full_cost = (result.cost_usd or 0.0) + (verdict.cost_usd or 0.0) - if not verdict.clean: - session.add(AICall( - model=result.model, - prompt_tokens=result.prompt_tokens, - completion_tokens=result.completion_tokens, - cost_usd=full_cost, status="leaked", - error=f"reviewer: {verdict.reason}", - )) - await session.commit() - log.warning("digest.reviewer_rejected", kind=kind, tone=tone, - reason=verdict.reason, preview=result.content[:120]) - continue out[tone] = result.content session.add(AICall( model=result.model, prompt_tokens=result.prompt_tokens, completion_tokens=result.completion_tokens, - cost_usd=full_cost, + cost_usd=result.cost_usd, status="ok", )) await session.commit() @@ -141,62 +116,6 @@ def _kind_for_today(today: datetime) -> str: return "weekly" if today.weekday() == 6 else "daily" -async def _translate_variants_for_active_langs( - client, - english_variants: dict[str, str], - target_langs: list[str], -) -> dict[tuple[str, str], str]: - """Build a {(tone, lang): content_md} table. - - Starts with the English variants as the canonical cells. For each - (tone, target_lang) pair where target_lang != 'en', calls translate() - in parallel; on failure the cell falls back to the English variant - of the same tone so the digest still goes out, just untranslated. - """ - table: dict[tuple[str, str], str] = { - (tone, "en"): content for tone, content in english_variants.items() - } - pairs = [ - (tone, lang) - for tone in english_variants - for lang in target_langs - if lang != "en" - ] - if not pairs: - return table - - results = await asyncio.gather(*[ - translate(client, english_variants[tone], lang) for tone, lang in pairs - ], return_exceptions=True) - for (tone, lang), result in zip(pairs, results): - if isinstance(result, Exception): - log.warning("digest.translate.failed", - tone=tone, lang=lang, error=str(result)[:200]) - table[(tone, lang)] = english_variants[tone] - continue - translated_md, _llm_log = result - table[(tone, lang)] = translated_md - return table - - -def _pick_variant( - table: dict[tuple[str, str], str], tone: str, lang: str, -) -> str: - """Return the digest content for a recipient. - - Lookup order: exact (tone, lang) → (tone, 'en') → ('INTERMEDIATE', - 'en') → first table value. The last falls are defensive; the table - always contains at least one English entry when the job is sending. - """ - if (tone, lang) in table: - return table[(tone, lang)] - if (tone, "en") in table: - return table[(tone, "en")] - if ("INTERMEDIATE", "en") in table: - return table[("INTERMEDIATE", "en")] - return next(iter(table.values())) - - async def _send_one(user: User, kind: str, content_html: str, date_str: str, session) -> None: settings_url = f"{branding.SITE_URL}/settings" @@ -281,21 +200,17 @@ async def run() -> None: jr.error = "all variants failed" return - # Build the per-language translation table once per job run. - active_non_en = sorted({l for l in ACTIVE_LANGUAGES if l != "en"}) - async with httpx.AsyncClient(follow_redirects=True) as client: - variant_table = await _translate_variants_for_active_langs( - client, variants, active_non_en, - ) - written = 0 for u in fresh: tone = (u.digest_tone or "INTERMEDIATE").upper() - content = _pick_variant( - variant_table, - tone=tone, - lang=(u.lang or "en"), - ) + # Fall back to INTERMEDIATE first (the more common tone) and then + # to whatever variant succeeded, so an asymmetric LLM failure + # doesn't silently skip the user. + content = (variants.get(tone) + or variants.get("INTERMEDIATE") + or next(iter(variants.values()), None)) + if content is None: + continue await _send_one(u, kind, content, date_str, session) await asyncio.sleep(0.1) written += 1 diff --git a/app/jobs/indicator_summary_job.py b/app/jobs/indicator_summary_job.py index 422c49c..d96b309 100644 --- a/app/jobs/indicator_summary_job.py +++ b/app/jobs/indicator_summary_job.py @@ -4,7 +4,8 @@ hourly stays comfortably under the monthly cap.""" from __future__ import annotations import asyncio -import json +import re +from collections import defaultdict import httpx from sqlalchemy import desc, func, select @@ -12,146 +13,169 @@ from sqlalchemy import desc, func, select from app.config import get_settings, load_groups from app.db import utcnow from app.jobs._helpers import job_lifecycle, log -from app.jobs._market_context import latest_quotes_by_group, month_spend -from app.models import ( - AICall, - IndicatorSummary, - IndicatorSummaryTranslation, - JobRun, - User, -) +from app.models import AICall, IndicatorSummary, JobRun, Quote from app.services.cadence import DEFAULT_POLICY -from app.services.i18n import ACTIVE_LANGUAGES -from app.services.llm_prompts import ( +from app.services.openrouter import ( PROMPT_VERSION, + active_model, build_aggregate_summary_system_prompt, build_aggregate_summary_user_prompt, build_summary_system_prompt, build_summary_user_prompt, -) -from app.services.openrouter import ( - active_model, call_llm, llm_configured, month_start, ) -from app.services.output_review import review_read -from app.services.translation import translate AGGREGATE_GROUP_NAME = "__all__" -async def translate_summary_for_active_languages(session, summary_id: int) -> None: - """Fan out per-language translations for one IndicatorSummary row. +# Strip known meta-commentary openers the model sometimes leaks despite the +# prompt's hard constraints. Each pattern matches one leading sentence. +_LEAK_PATTERNS = [ + re.compile(p, re.IGNORECASE | re.DOTALL) + for p in ( + # First-person meta — "I need to / I'll / I have to / I'm going to ..." + r"^i\s+(?:need|have|must|should|am going|'ll|will|shall|can|am)[^.]*\.\s*", + # "We need / we're / we are asked / we will ..." + r"^we\s+(?:need|are|'re|will|shall|can|should|must|have)[^.]*\.\s*", + r"^let\s+(?:me|us|'?s)[^.]*\.\s*", + r"^here['’]s[^.]*\.\s*", + r"^sure[,!]?\s[^.]*\.\s*", + r"^looking at[^.]*\.\s*", + r"^based on[^.]*\.\s*", + r"^to (?:address|answer|write|summarise|summarize)[^.]*\.\s*", + r"^first[,]?\s[^.]*\.\s*", + r"^the (?:user|data shows|reader|task|request|reader sees|instructions?)[^.]*\.\s*", + r"^summary[:.]\s*", + r"^key\s*[:\-—]\s*", + r"^must\s+(?:be|cite|explain|avoid|give|stay|provide)[^.]*\.\s*", + r"^should\s+(?:be|give|cite|explain|avoid|provide)[^.]*\.\s*", + r"^avoid[^.]*\.\s*", + r"^cite\s+at\s+most[^.]*\.\s*", + r"^be\s+(?:speculative|specific|concise|brief)[^.]*\.\s*", + r"^stay\s+on[^.]*\.\s*", + r"^okay[,]?\s+", + r"^alright[,]?\s+", + r"^thinking[^.]*\.\s*", + # Prompt-leak prefixes — the model echoes example framing or rule + # headers from the system prompt. + r"^(?:good|bad|positive|negative)\s+example\s*[:\-—]\s*", + r"^example\s+(?:good|bad)\s*[:\-—]\s*", + r"^example\s*[:\-—]\s*", + r"^reference\s+style\s*[:\-—]\s*", + # Prompt label echoes (markdown-style or plain-text) + r"^(?:hard\s+)?constraints?\s*[:\-—][^.\n]*[.\n]\s*", + r"^key\s+observations?\s*[:\-—]\s*", + r"^observations?\s*[:\-—]\s*", + r"^focus\s+on[^.]*\.\s*", + r"^output\s+the\s+read[^.]*\.\s*", + r"^plain\s+prose[^.]*\.\s*", + r"^the\s+indicators?[^.]*\.\s*", # "The indicators include..." / "The indicators are..." + r"^indicators?\s*[:\-—]\s*", + r"^data\s*[:\-—]\s*", + r"^analysis\s*[:\-—]\s*", + r"^interpretation\s*[:\-—]\s*", + r"^read\s*[:\-—]\s*", + r"^note\s*[:\-—]\s*", + # Sometimes the response gets wrapped in literal quotes + r"^[\"“'`]+", + ) +] - Mirrors ``ai_log_job.translate_log_for_active_languages``: reads the - distinct non-en ``users.lang`` set, translates the English content - once per active language in parallel via ``asyncio.gather``, and - persists each result as an ``IndicatorSummaryTranslation`` row in - its own savepoint so one bad row doesn't lose the rest. - """ - target_langs = sorted({l for l in ACTIVE_LANGUAGES if l != "en"}) - if not target_langs: - return - active_langs = (await session.execute( - select(User.lang).distinct().where(User.lang.in_(target_langs)) + +_TRAILING_QUOTE = re.compile(r"[\"”'`]+\s*$") + +# Tell-tale phrases that mean the model regurgitated the prompt as its +# "answer" — we'd rather show nothing than show this. +_LEAKAGE_FLAGS = ( + "≤60 words", "60 words", "must be under", "must cite", "must explain", + "no meta-commentary", "no buy/sell", "horizon. ", "1-day moves", + "the instructions are", "instructions:", "constraints:", "hard constraints", + "good example", "bad example", "reference style", +) + + +def looks_like_leakage(text: str) -> bool: + """Heuristic: after cleaning, if these phrases still appear, the output + is contaminated prompt-regurgitation and shouldn't be shown.""" + low = text.lower() + return any(flag in low for flag in _LEAKAGE_FLAGS) + + +def clean_summary(text: str) -> str: + """Strip leading meta-commentary. If cleaning removes nearly everything + (suggesting the model emitted reasoning then ran out of tokens), fall + back to the last non-empty paragraph of the raw output — that's usually + where the actual answer ended up.""" + raw = text.strip() + out = raw + # Up to 6 passes: handles compound leakage like + # "Constraints: <...>. The indicators are: <...>. " + for _ in range(6): + before = out + for pat in _LEAK_PATTERNS: + out = pat.sub("", out, count=1).lstrip() + if out == before: + break + if len(out) < 60 and len(raw) > 120: + # Cleaning ate too much; take the last non-empty paragraph of raw. + paragraphs = [p.strip() for p in re.split(r"\n\s*\n", raw) if p.strip()] + if paragraphs: + out = paragraphs[-1] + # Re-strip leaders from the recovered paragraph too. + for _ in range(2): + before = out + for pat in _LEAK_PATTERNS: + out = pat.sub("", out, count=1).lstrip() + if out == before: + break + # Trim any orphan closing quote/backtick from the wrap-strip above. + out = _TRAILING_QUOTE.sub("", out).rstrip() + return out + + +async def _latest_quotes_by_group(session) -> dict[str, list[dict]]: + """Latest non-null quote per (group, symbol). Drops error rows.""" + sub = ( + select(Quote.group_name, Quote.symbol, + func.max(Quote.fetched_at).label("mx")) + .group_by(Quote.group_name, Quote.symbol) + .subquery() + ) + rows = (await session.execute( + select(Quote).join( + sub, + (Quote.group_name == sub.c.group_name) + & (Quote.symbol == sub.c.symbol) + & (Quote.fetched_at == sub.c.mx), + ).where(Quote.price.is_not(None)) + .order_by(Quote.group_name, Quote.symbol) )).scalars().all() - if not active_langs: - return - - summary_row = await session.get(IndicatorSummary, summary_id) - if summary_row is None: - log.warning("ind_summary.translate.missing_summary", summary_id=summary_id) - return - - async with httpx.AsyncClient(follow_redirects=True, timeout=60) as client: - results = await asyncio.gather(*[ - translate(client, summary_row.content, lang) - for lang in active_langs - ], return_exceptions=True) - - succeeded = 0 - failed = 0 - for lang, result in zip(active_langs, results): - if isinstance(result, Exception): - log.warning("ind_summary.translate.failed", - lang=lang, summary_id=summary_id, - error=str(result)[:200]) - failed += 1 - continue - translated_md, llm_result = result - try: - async with session.begin_nested(): - session.add(IndicatorSummaryTranslation( - summary_id=summary_id, lang=lang, - content=translated_md, - generated_at=utcnow(), - model=llm_result.model, - prompt_tokens=llm_result.prompt_tokens, - completion_tokens=llm_result.completion_tokens, - cost_usd=llm_result.cost_usd, - )) - await session.commit() - succeeded += 1 - except Exception as exc: - log.warning("ind_summary.translate.persist_failed", - lang=lang, summary_id=summary_id, error=str(exc)[:200]) - failed += 1 - - if failed and succeeded == 0: - log.error("ind_summary.translate.all_failed", - summary_id=summary_id, attempted=len(active_langs)) - else: - log.info("ind_summary.translate.done", - summary_id=summary_id, succeeded=succeeded, failed=failed) + by_group: dict[str, list[dict]] = defaultdict(list) + for q in rows: + by_group[q.group_name].append({ + "symbol": q.symbol, "label": q.label, + "price": q.price, "currency": q.currency, + "as_of": q.as_of, "changes": q.changes, + }) + return by_group -# Defence-in-depth: read generation goes through JSON mode + a reviewer. -# -# 1. The system prompt instructs the model to emit {"read": "..."} only; -# response_format={"type":"json_object"} forces well-formed JSON at -# the API layer, so prose outside the field is impossible. -# 2. We extract `read`, then ask a second LLM call (services/output_review) -# whether the candidate text is publishable. Scratchpad INSIDE the -# field — "Let's see…", "X? Actually Y?" — is caught here. -# 3. Any failure at either stage (parse, missing field, reviewer veto, -# reviewer error) drops the candidate. The previous good -# IndicatorSummary stays visible. -# -# The old _LEAK_PATTERNS / clean_summary / looks_like_leakage regex -# scaffolding lived here previously. It produced false positives (e.g. -# chopping off a legitimate leading sentence like "The indicators are -# pricing…") and false negatives (it never caught the chain-of-thought -# patterns the model actually emits). The reviewer agent replaces it. - - -def _extract_read(raw: str) -> str | None: - """Parse the model's JSON envelope and return the "read" field, or - None if the body isn't valid JSON / the field is missing / the field - isn't a string. Conservative: on any deviation from the schema we - drop the candidate rather than try to salvage it.""" - try: - parsed = json.loads(raw) - except json.JSONDecodeError: - return None - if not isinstance(parsed, dict): - return None - read = parsed.get("read") - if not isinstance(read, str): - return None - read = read.strip() - return read or None - +async def _month_spend(session) -> float: + total = (await session.execute( + select(func.coalesce(func.sum(AICall.cost_usd), 0.0)) + .where(AICall.called_at >= month_start()) + )).scalar() + return float(total or 0.0) async def _generate_one( session, client: httpx.AsyncClient, group: str, quotes: list[dict], system_prompt: str, model: str, tone: str, analysis: str, -) -> IndicatorSummary | None: - """Generate + persist one group's summary. Returns the new row on - success (so the caller can fan out localized translations after - the commit picks up its id) or None on failure. +) -> bool: + """Generate + persist one group's summary. Returns True on success. `model` is retained for ledger labelling but call_llm now picks the active-provider model itself.""" user_prompt = build_summary_user_prompt(group, quotes) @@ -161,20 +185,19 @@ async def _generate_one( [{"role": "system", "content": system_prompt}, {"role": "user", "content": user_prompt}], max_tokens=800, # DeepSeek sometimes spends 300+ on internal reasoning - response_format={"type": "json_object"}, ) except Exception as e: session.add(AICall(model=active_model(), status="error", error=str(e)[:500])) log.warning("ind_summary.failed", group=group, error=str(e)[:120]) - return None + return False - candidate = _extract_read(result.content) - if candidate is None or len(candidate) < 40: - # JSON envelope malformed, "read" field missing/wrong type, or - # the candidate is too short to be a real read. Don't persist; - # the last good summary stays visible. - log.warning("ind_summary.json_invalid", - group=group, preview=result.content[:160]) + cleaned = clean_summary(result.content) + if looks_like_leakage(cleaned) or len(cleaned) < 40: + # Model regurgitated the prompt or produced nothing usable. + # Don't persist — keep the last good summary visible. Log it so + # we can see the rate of failures over time. + log.warning("ind_summary.leakage_detected", + group=group, preview=cleaned[:120]) session.add(AICall( model=result.model, prompt_tokens=result.prompt_tokens, @@ -182,48 +205,28 @@ async def _generate_one( cost_usd=result.cost_usd, status="leaked", )) - return None + return False - verdict = await review_read(client, candidate) - if not verdict.clean: - # Reviewer caught scratchpad / meta-commentary / partial text - # INSIDE the read field. Drop the candidate; the previous good - # summary continues to serve. - log.warning("ind_summary.reviewer_rejected", - group=group, reason=verdict.reason, - preview=candidate[:120]) - session.add(AICall( - model=result.model, - prompt_tokens=result.prompt_tokens, - completion_tokens=result.completion_tokens, - cost_usd=(result.cost_usd or 0.0) + (verdict.cost_usd or 0.0), - status="leaked", - )) - return None - - summary = IndicatorSummary( + session.add(IndicatorSummary( group_name=group, generated_at=utcnow(), model=result.model, tone=tone, analysis=analysis, prompt_version=PROMPT_VERSION, - content=candidate, + content=cleaned, prompt_tokens=result.prompt_tokens, completion_tokens=result.completion_tokens, - # Include the reviewer's cost in the row's recorded spend so the - # monthly budget tracking covers the full pipeline cost. - cost_usd=(result.cost_usd or 0.0) + (verdict.cost_usd or 0.0), - ) - session.add(summary) + cost_usd=result.cost_usd, + )) session.add(AICall( model=result.model, prompt_tokens=result.prompt_tokens, completion_tokens=result.completion_tokens, - cost_usd=(result.cost_usd or 0.0) + (verdict.cost_usd or 0.0), + cost_usd=result.cost_usd, status="ok", )) - return summary + return True async def run() -> None: @@ -251,13 +254,13 @@ async def run() -> None: jr.error = reason return - spent = await month_spend(session) + spent = await _month_spend(session) if spent >= s.OPENROUTER_MONTHLY_CAP_USD: jr.status = "skipped" jr.error = f"monthly cap reached (${spent:.2f})" return - groups = await latest_quotes_by_group(session) + groups = await _latest_quotes_by_group(session) # Only summarise groups currently configured in TOML — drops stale # group names (e.g. an old "pie" before T212 sourcing) that still have # quotes in the table but no UI presence. @@ -280,73 +283,43 @@ async def run() -> None: for tone in tones: system_prompt = build_summary_system_prompt(tone, analysis) for group, quotes in groups.items(): - summary = await _generate_one( + ok = await _generate_one( session, client, group, quotes, system_prompt, active_model(), tone, analysis, ) - if summary is not None: + if ok: written += 1 await session.commit() # partial progress survives mid-job error - if summary is not None: - await translate_summary_for_active_languages(session, summary.id) # One aggregate read across all groups, stored under __all__. - # Same JSON-mode + reviewer-agent path as per-group reads. agg_system = build_aggregate_summary_system_prompt(tone, analysis) agg_user = build_aggregate_summary_user_prompt(groups) - agg_summary: IndicatorSummary | None = None try: result = await call_llm( client, [{"role": "system", "content": agg_system}, {"role": "user", "content": agg_user}], - max_tokens=1500, - response_format={"type": "json_object"}, + max_tokens=1500, # room for reasoning + 80-word output ) - candidate = _extract_read(result.content) - if candidate is None or len(candidate) < 40: - log.warning("ind_summary.agg_json_invalid", - tone=tone, preview=result.content[:160]) - session.add(AICall( - model=result.model, - prompt_tokens=result.prompt_tokens, - completion_tokens=result.completion_tokens, - cost_usd=result.cost_usd, status="leaked", - )) - else: - verdict = await review_read(client, candidate) - full_cost = (result.cost_usd or 0.0) + (verdict.cost_usd or 0.0) - if not verdict.clean: - log.warning("ind_summary.agg_reviewer_rejected", - tone=tone, reason=verdict.reason, - preview=candidate[:120]) - session.add(AICall( - model=result.model, - prompt_tokens=result.prompt_tokens, - completion_tokens=result.completion_tokens, - cost_usd=full_cost, status="leaked", - )) - else: - agg_summary = IndicatorSummary( - 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 + session.add(IndicatorSummary( + group_name=AGGREGATE_GROUP_NAME, + generated_at=utcnow(), + model=result.model, + tone=tone, + analysis=analysis, + prompt_version=PROMPT_VERSION, + content=clean_summary(result.content), + prompt_tokens=result.prompt_tokens, + completion_tokens=result.completion_tokens, + cost_usd=result.cost_usd, + )) + session.add(AICall( + model=result.model, + prompt_tokens=result.prompt_tokens, + completion_tokens=result.completion_tokens, + cost_usd=result.cost_usd, status="ok", + )) + written += 1 except Exception as e: session.add(AICall( model=active_model(), status="error", @@ -355,8 +328,6 @@ async def run() -> None: log.warning("ind_summary.agg_failed", tone=tone, error=str(e)[:120]) await session.commit() - if agg_summary is not None: - await translate_summary_for_active_languages(session, agg_summary.id) jr.items_written = written log.info("ind_summary.done", diff --git a/app/main.py b/app/main.py index 7f1729f..fe987f5 100644 --- a/app/main.py +++ b/app/main.py @@ -19,9 +19,7 @@ from app.db import get_session_factory from app.logging import configure_logging, get_logger from app.routers import api as api_router from app.routers import auth as auth_router -from app.routers import chat as chat_router from app.routers import email as email_router -from app.routers import ops as ops_router from app.routers import pages as pages_router from app.routers import polar_webhook as polar_webhook_router from app.routers import public as public_router @@ -91,8 +89,6 @@ app.mount( app.include_router(auth_router.router, tags=["auth"]) app.include_router(email_router.router, tags=["email"]) app.include_router(api_router.router, prefix="/api", tags=["api"]) -app.include_router(chat_router.router, prefix="/api", tags=["chat"]) -app.include_router(ops_router.router, prefix="/api", tags=["ops"]) app.include_router(universe_router.router, prefix="/api", tags=["universe"]) app.include_router(ticker_validate_router.router, prefix="/api", tags=["ticker-validate"]) app.include_router(sync_router.router, tags=["portfolio-sync"]) diff --git a/app/models.py b/app/models.py index 57c9f19..665a8cd 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(128), primary_key=True) + symbol: Mapped[str] = mapped_column(String(64), primary_key=True) date: Mapped[date] = mapped_column(Date, primary_key=True) close: Mapped[float | None] = mapped_column(Float) high: Mapped[float | None] = mapped_column(Float) @@ -120,41 +120,6 @@ class StrategicLog(Base): cost_usd: Mapped[float | None] = mapped_column(Float) -class StrategicLogTranslation(Base): - """Cached translation of a single StrategicLog row. - - Populated by ai_log_job after the English row is committed: one - row per (log_id, lang) combination. The /log endpoint serves the - matching row when available and falls back to the English source - when no row exists yet (e.g. translation failed or the language - was added after the log was generated). - - No user attribution — the cache is shared. Setting `lang` on a - user just selects which (already-translated) variant they see. - """ - __tablename__ = "strategic_log_translations" - - id: Mapped[int] = mapped_column(_PK, primary_key=True, autoincrement=True) - log_id: Mapped[int] = mapped_column( - BigInteger().with_variant(Integer(), "sqlite"), - ForeignKey("strategic_logs.id", ondelete="CASCADE"), - nullable=False, - ) - lang: Mapped[str] = mapped_column(String(8), nullable=False) - content: Mapped[str] = mapped_column(Text, nullable=False) - generated_at: Mapped[datetime] = mapped_column( - DateTime(timezone=True), nullable=False, default=utcnow, - ) - model: Mapped[str | None] = mapped_column(String(64)) - prompt_tokens: Mapped[int | None] = mapped_column(Integer) - completion_tokens: Mapped[int | None] = mapped_column(Integer) - cost_usd: Mapped[float | None] = mapped_column(Float) - - __table_args__ = ( - UniqueConstraint("log_id", "lang", name="uq_slt_log_lang"), - ) - - class IndicatorSummary(Base): """Short AI-generated read for one indicator group, regenerated hourly. The latest row per group_name is what the dashboard renders.""" @@ -174,39 +139,6 @@ class IndicatorSummary(Base): __table_args__ = (Index("ix_indsumm_group_generated", "group_name", "generated_at"),) -class IndicatorSummaryTranslation(Base): - """Cached translation of a single IndicatorSummary row. - - Same pattern as StrategicLogTranslation: one row per - (summary_id, lang). Populated by indicator_summary_job after the - English row is committed. The dashboard / indicators endpoints - swap in the matching translation when a user with a non-en - lang preference loads them, falling back silently to the English - source when no row exists yet. - """ - __tablename__ = "indicator_summary_translations" - - id: Mapped[int] = mapped_column(_PK, primary_key=True, autoincrement=True) - summary_id: Mapped[int] = mapped_column( - BigInteger().with_variant(Integer(), "sqlite"), - ForeignKey("indicator_summaries.id", ondelete="CASCADE"), - nullable=False, - ) - lang: Mapped[str] = mapped_column(String(8), nullable=False) - content: Mapped[str] = mapped_column(Text, nullable=False) - generated_at: Mapped[datetime] = mapped_column( - DateTime(timezone=True), nullable=False, default=utcnow, - ) - model: Mapped[str | None] = mapped_column(String(64)) - prompt_tokens: Mapped[int | None] = mapped_column(Integer) - completion_tokens: Mapped[int | None] = mapped_column(Integer) - cost_usd: Mapped[float | None] = mapped_column(Float) - - __table_args__ = ( - UniqueConstraint("summary_id", "lang", name="uq_ist_summary_lang"), - ) - - class AICall(Base): """Cost ledger for OpenRouter calls. Feeds the monthly cap check.""" __tablename__ = "ai_calls" @@ -257,14 +189,6 @@ class User(Base): # NULL = use INTERMEDIATE at render time. Server-side mirror of the # dashboard tone, decoupled because the dashboard pref is localStorage. digest_tone: Mapped[str | None] = mapped_column(String(16)) - # Preferred language for AI-generated content (strategic log, - # digest emails, portfolio commentary). Default 'en'. The settings - # PATCH endpoint validates against ACTIVE_LANGUAGES in - # app/services/i18n.py before writing. - lang: Mapped[str] = mapped_column( - String(8), nullable=False, default="en", server_default="en", - index=True, - ) # Polar (MoR) linkage — populated by the polar_webhook handler the # first time we see a subscription/order event for the user. The # customer id is the stable join key; the subscription id is what @@ -539,7 +463,5 @@ class CsvFormatTemplate(Base): last_used_at: Mapped[datetime] = mapped_column( DateTime(timezone=True), nullable=False, default=utcnow, ) - model: Mapped[str | None] = mapped_column(String(64)) - prompt_tokens: Mapped[int | None] = mapped_column(Integer) - completion_tokens: Mapped[int | None] = mapped_column(Integer) - cost_usd: Mapped[float | None] = mapped_column(Float) + llm_model: Mapped[str | None] = mapped_column(String(64)) + llm_cost_usd: Mapped[float | None] = mapped_column(Float) diff --git a/app/routers/api.py b/app/routers/api.py index 5075654..5e06090 100644 --- a/app/routers/api.py +++ b/app/routers/api.py @@ -10,29 +10,39 @@ import re from datetime import date, datetime, timedelta, timezone from typing import Literal -from fastapi import APIRouter, Depends, HTTPException, Query, Request -from fastapi.responses import JSONResponse +from fastapi import APIRouter, Depends, File, Form, HTTPException, Query, Request, UploadFile +from fastapi.responses import HTMLResponse, JSONResponse from sqlalchemy import desc, func, select from sqlalchemy.ext.asyncio import AsyncSession -from pydantic import BaseModel +from collections import defaultdict + +import httpx +from pydantic import BaseModel, Field from app.auth import require_token, maybe_current_user, CurrentUser -from app.services.i18n import ACTIVE_LANGUAGES from app.config import get_settings from app.db import get_session, utcnow +from app.services.openrouter import ( + PROMPT_VERSION, + build_chat_system_prompt, + call_openrouter, + month_start, +) from app.templates_env import templates from app.models import ( + AICall, Headline, IndicatorSummary, - IndicatorSummaryTranslation, + JobRun, Quote, StrategicLog, - StrategicLogTranslation, User, ) from app.schemas import ( + HealthOut, HeadlineOut, + JobStatus, QuoteOut, StrategicLogOut, ) @@ -40,6 +50,11 @@ from app.schemas import ( router = APIRouter(dependencies=[Depends(require_token)]) +JOB_NAMES = ("market_job", "news_job", "ai_log_job", "rollup_job", + "indicator_summary_job", "universe_flush_job", + "email_digest_job") +JOB_STALE_HOURS = 2.0 # job is "warn" if its last success was >2h ago + # Per-group expected freshness — bonds and intraday tape want daily data, # macro/economy/valuation are monthly/quarterly by nature. Older than this # many days from today → row gets a "stale" badge. @@ -120,7 +135,6 @@ async def indicators( as_: str | None = Query(default=None, alias="as"), tone: str | None = Query(default=None), session: AsyncSession = Depends(get_session), - principal: CurrentUser | None = Depends(maybe_current_user), ): sub = ( select(Quote.symbol, func.max(Quote.fetched_at).label("mx")) @@ -188,7 +202,6 @@ async def indicators( if as_of_d and (today - as_of_d).days > threshold: stale_symbols.add(r.symbol) - await _apply_localized_summary(session, summary, principal) return templates.TemplateResponse( request, "partials/indicators.html", {"quotes": rows, "has_anchor": has_anchor, @@ -282,15 +295,11 @@ async def news_list( # --- Strategic log ----------------------------------------------------------- -def _log_partial_payload( - row: StrategicLog | None, - content_override: str | None = None, -) -> dict | None: +def _log_partial_payload(row: StrategicLog | None) -> dict | None: if row is None: return None - content = content_override if content_override is not None else row.content return { - "content_html": _md_to_html(content), + "content_html": _md_to_html(row.content), "generated_at": row.generated_at, "model": row.model, "tone": row.tone, @@ -302,52 +311,6 @@ def _log_partial_payload( } -async def _localized_content( - session: AsyncSession, - row: StrategicLog | None, - principal: CurrentUser | None, -) -> str | None: - """Return the translated content for ``row`` when the principal has - a non-English lang preference and a matching translation row exists. - Returns None to signal 'use row.content as-is' (the default English - path).""" - if row is None or principal is None or principal.user is None: - return None - lang = (principal.user.lang or "en") - if lang == "en": - return None - t = (await session.execute( - select(StrategicLogTranslation) - .where(StrategicLogTranslation.log_id == row.id) - .where(StrategicLogTranslation.lang == lang) - )).scalar_one_or_none() - return t.content if t is not None else None - - -async def _apply_localized_summary( - session: AsyncSession, - row: IndicatorSummary | None, - principal: CurrentUser | None, -) -> None: - """If ``row`` has a matching translation for ``principal.user.lang``, - overwrite the in-memory ``content`` attribute so the template renders - the localized version. No DB write happens — the mutation lives only - for the lifetime of this GET request. - """ - if row is None or principal is None or principal.user is None: - return - lang = (principal.user.lang or "en") - if lang == "en": - return - t = (await session.execute( - select(IndicatorSummaryTranslation) - .where(IndicatorSummaryTranslation.summary_id == row.id) - .where(IndicatorSummaryTranslation.lang == lang) - )).scalar_one_or_none() - if t is not None: - row.content = t.content - - def _resolve_tone_param(tone: str | None) -> str: """Normalise a query-param tone to one of the two valid values. PRO is silently mapped to INTERMEDIATE (see openrouter.PROMPT_VERSION 6).""" @@ -403,11 +366,10 @@ async def log_latest( row = (await session.execute(fallback)).scalar_one_or_none() if as_ == "html": - content_override = await _localized_content(session, row, principal) return templates.TemplateResponse( request, "partials/log.html", - {"log": _log_partial_payload(row, content_override=content_override), - "tone": wanted_tone, "paid": not free_only}, + {"log": _log_partial_payload(row), "tone": wanted_tone, + "paid": not free_only}, ) if row is None: @@ -458,11 +420,10 @@ async def log_by_date( row = (await session.execute(fallback)).scalar_one_or_none() if as_ == "html": - content_override = await _localized_content(session, row, principal) return templates.TemplateResponse( request, "partials/log.html", - {"log": _log_partial_payload(row, content_override=content_override), - "tone": wanted_tone, "paid": not free_only}, + {"log": _log_partial_payload(row), "tone": wanted_tone, + "paid": not free_only}, ) if row is None: raise HTTPException(status_code=404, detail="No log on this date") @@ -544,6 +505,14 @@ async def log_days( return templates.TemplateResponse(request, "partials/calendar.html", payload) +# Portfolio endpoints moved to app/routers/universe.py (Phase G). The +# server no longer persists per-user portfolio data; holdings live in +# the browser's localStorage and prices come from /api/universe. + + +# --- Health / ops footer ----------------------------------------------------- + + # --- Aggregate summary + market status (dashboard header) ------------------- @@ -556,7 +525,6 @@ async def aggregate_summary( session: AsyncSession = Depends(get_session), as_: str | None = Query(default=None, alias="as"), tone: str | None = Query(default=None), - principal: CurrentUser | None = Depends(maybe_current_user), ): wanted_tone = _resolve_tone_param(tone) row = (await session.execute( @@ -578,7 +546,6 @@ async def aggregate_summary( statuses = all_statuses() if as_ == "html": - await _apply_localized_summary(session, row, principal) return templates.TemplateResponse( request, "partials/dashboard_header.html", {"summary": row, "markets": statuses, "tone": wanted_tone}, @@ -596,6 +563,303 @@ async def aggregate_summary( } +# Market → headline index mapping for the sticky bottom bar. Symbols must +# be present in config/default.toml so market_job populates `quotes`. +_MARKET_INDEX = { + "NYSE": ("^GSPC", "S&P 500"), + "LSE": ("^FTSE", "FTSE 100"), + # XETRA → Euro Stoxx 50 rather than ^GDAXI: Yahoo's DAX ticker is + # patchy via the chart endpoint, and ^STOXX50E is already tracked in + # config/default.toml's equity group. + "XETRA": ("^STOXX50E", "STOXX 50"), + "JPX": ("^N225", "Nikkei 225"), + "HKEX": ("^HSI", "Hang Seng"), + "SSE": ("000300.SS", "CSI 300"), +} + + +def _fmt_price(p: float | None) -> str: + if p is None: + return "—" + if abs(p) >= 1000: + return f"{p:,.0f}" + if abs(p) >= 100: + return f"{p:,.1f}" + return f"{p:,.2f}" + + +@router.get("/markets-bar", response_class=HTMLResponse, include_in_schema=False) +async def markets_bar( + request: Request, + session: AsyncSession = Depends(get_session), + as_: str | None = Query(default=None, alias="as"), +): + """The sticky bottom-bar payload: per-market open/close status with the + market's headline index price + 1d change. Refreshed by HTMX every 60s. + """ + from app.services.markets import all_statuses + + statuses = all_statuses() + # Latest quote per headline-index symbol in one query. + wanted_syms = [sym for sym, _ in _MARKET_INDEX.values()] + sub = ( + select(Quote.symbol, func.max(Quote.fetched_at).label("mx")) + .where(Quote.symbol.in_(wanted_syms)) + .group_by(Quote.symbol) + .subquery() + ) + rows = (await session.execute( + select(Quote).join( + sub, + (Quote.symbol == sub.c.symbol) & (Quote.fetched_at == sub.c.mx), + ) + )).scalars().all() + by_sym = {q.symbol: q for q in rows} + + markets: list[dict] = [] + for st in statuses: + sym, label = _MARKET_INDEX.get(st["code"], (None, None)) + q = by_sym.get(sym) if sym else None + idx = None + if q is not None and q.price is not None: + idx = { + "symbol": q.symbol, + "label": label, + "price_fmt": _fmt_price(q.price), + "change_1d_pct": (q.changes or {}).get("1d"), + } + markets.append({ + "code": st["code"], + "label": st["label"], + "open": st["open"], + "until_iso": st["until"].isoformat(), + "until_hhmm": st["until"].strftime("%H:%M"), + "index": idx, + }) + + return templates.TemplateResponse( + request, "partials/markets_bar.html", + {"markets": markets}, + ) + + +@router.get("/health", response_class=HTMLResponse, include_in_schema=False) +async def health_html( + request: Request, + session: AsyncSession = Depends(get_session), + as_: str | None = Query(default=None, alias="as"), +): + """Returns an HTML fragment by default (the ops footer); ?as=json returns the + structured object. The default is HTML because that's how the dashboard + consumes it; CLI/curl users will pass ?as=json.""" + try: + await session.execute(select(func.now())) + db_ok = True + except Exception: + db_ok = False + + now = utcnow() + jobs: list[dict] = [] + structured: list[JobStatus] = [] + for name in JOB_NAMES: + row = (await session.execute( + select(JobRun).where(JobRun.name == name) + .order_by(desc(JobRun.started_at)).limit(1) + )).scalar_one_or_none() + if row is None: + jobs.append({"name": name, "led": "idle", "age": "—", + "last_finished": None}) + structured.append(JobStatus(name=name)) + continue + if row.status == "success": + secs = _age_seconds(now, row.finished_at or row.started_at) or 0 + led = "ok" if secs < JOB_STALE_HOURS * 3600 else "warn" + elif row.status == "skipped": + led = "warn" + elif row.status == "running": + led = "warn" + else: + led = "err" + jobs.append({ + "name": name, "led": led, + "age": _fmt_age(now, row.finished_at or row.started_at), + "last_finished": row.finished_at, + }) + structured.append(JobStatus( + name=name, last_started=row.started_at, + last_finished=row.finished_at, status=row.status, + error=row.error, items_written=row.items_written, + )) + + if as_ == "json": + return JSONResponse( + HealthOut(db="ok" if db_ok else "down", jobs=structured).model_dump(mode="json") + ) + return templates.TemplateResponse( + request, "partials/ops_footer.html", + {"db_ok": db_ok, "jobs": jobs}, + ) + + +# --- Chat ------------------------------------------------------------------- + + +class ChatMessage(BaseModel): + role: str = Field(pattern="^(user|assistant)$") + content: str + + +class ChatRequest(BaseModel): + messages: list[ChatMessage] + + +CHAT_REFERENCE_LINE = ( + "S&P 7,501 (ATH) · VIX 18.0 · US 10y 4.45% · HY OAS 279bps · " + "Brent $109/bbl · Gold $4,651/oz · CPI 3.8% YoY" +) +THESIS_KEYWORDS_FALLBACK = [ + "hormuz", "iran", "opec", "brent", "wti", "crude", "oil", + "china", "taiwan", "yuan", "fed", "inflation", "cpi", "yield", + "gold", "dollar", "yen", "saudi", "russia", "ukraine", "israel", + "nato", "defence", "defense", +] + + +async def _latest_quotes_by_group_chat(session: AsyncSession) -> dict[str, list[dict]]: + sub = ( + select(Quote.group_name, Quote.symbol, + func.max(Quote.fetched_at).label("mx")) + .group_by(Quote.group_name, Quote.symbol) + .subquery() + ) + rows = (await session.execute( + select(Quote).join( + sub, + (Quote.group_name == sub.c.group_name) + & (Quote.symbol == sub.c.symbol) + & (Quote.fetched_at == sub.c.mx), + ).order_by(Quote.group_name, Quote.symbol) + )).scalars().all() + by_group: dict[str, list[dict]] = defaultdict(list) + for q in rows: + by_group[q.group_name].append({ + "symbol": q.symbol, "label": q.label, + "price": q.price, "currency": q.currency, + "as_of": q.as_of, "changes": q.changes, + }) + return by_group + + +async def _thesis_headlines_for_chat(session: AsyncSession, limit: int = 50) -> list[dict]: + cutoff = utcnow() - timedelta(hours=24) + rows = (await session.execute( + select(Headline) + .where(Headline.published_at >= cutoff) + .order_by(desc(Headline.published_at)) + .limit(300) + )).scalars().all() + out = [] + for h in rows: + if any(kw in h.title.lower() for kw in THESIS_KEYWORDS_FALLBACK): + out.append({"source": h.source, "title": h.title}) + if len(out) >= limit: + break + return out + + +async def _month_spend(session: AsyncSession) -> float: + total = (await session.execute( + select(func.coalesce(func.sum(AICall.cost_usd), 0.0)) + .where(AICall.called_at >= month_start()) + )).scalar() + return float(total or 0.0) + + +@router.post("/chat") +async def chat( + body: ChatRequest, + session: AsyncSession = Depends(get_session), + principal: CurrentUser | None = Depends(maybe_current_user), +): + """Answer one user turn given the conversation so far. Grounded on the + latest strategic log + market data + thesis-filtered headlines. + Ephemeral — the conversation lives entirely in the client; the endpoint + just records each call's cost in `ai_calls`.""" + # Paid-only feature. Free users get the static log but not the + # interactive chat (see /pricing). + from app.services.access import is_paid_active + if not is_paid_active(principal): + raise HTTPException( + status_code=402, + detail={"code": "paid_required", + "message": "Follow-up chat is a paid-tier feature."}, + ) + + s = get_settings() + if not s.OPENROUTER_API_KEY: + raise HTTPException(status_code=503, detail="OPENROUTER_API_KEY not set") + + # Monthly cost cap — same one the log job respects. + spent = await _month_spend(session) + if spent >= s.OPENROUTER_MONTHLY_CAP_USD: + raise HTTPException( + status_code=429, + detail=f"Monthly OpenRouter cap reached (${spent:.2f})", + ) + + # Trim runaway conversations: keep last 20 turns. + history = body.messages[-20:] + if not history or history[-1].role != "user": + raise HTTPException(status_code=400, detail="Last message must be user") + + # Gather grounding context. + log_row = (await session.execute( + select(StrategicLog).order_by(desc(StrategicLog.generated_at)).limit(1) + )).scalar_one_or_none() + quotes = await _latest_quotes_by_group_chat(session) + headlines = await _thesis_headlines_for_chat(session) + + system_prompt = build_chat_system_prompt( + s.CASSANDRA_TONE, s.CASSANDRA_ANALYSIS, + log_content=log_row.content if log_row else None, + log_generated_at=log_row.generated_at if log_row else None, + quotes_by_group=quotes, + headlines=headlines, + reference_line=CHAT_REFERENCE_LINE, + ) + + msgs = [{"role": "system", "content": system_prompt}] + for m in history: + msgs.append({"role": m.role, "content": m.content}) + + try: + async with httpx.AsyncClient(follow_redirects=True) as client: + result = await call_openrouter(client, msgs, model=s.OPENROUTER_MODEL) + except Exception as e: + session.add(AICall( + model=s.OPENROUTER_MODEL, status="error", error=str(e)[:500], + )) + await session.commit() + raise HTTPException(status_code=502, detail=f"OpenRouter error: {e}") + + session.add(AICall( + model=result.model, + prompt_tokens=result.prompt_tokens, + completion_tokens=result.completion_tokens, + cost_usd=result.cost_usd, + status="ok", + )) + await session.commit() + + return { + "role": "assistant", + "content": result.content, + "content_html": _md_to_html(result.content), + "prompt_tokens": result.prompt_tokens, + "completion_tokens": result.completion_tokens, + } + + # --------------------------------------------------------------------------- # Settings — digest preferences # --------------------------------------------------------------------------- @@ -631,38 +895,3 @@ async def patch_digest_prefs( user.digest_tone = payload.tone await session.commit() return DigestPrefsOut(opt_in=payload.opt_in, tone=payload.tone) - - -# --------------------------------------------------------------------------- -# Settings — language preference -# --------------------------------------------------------------------------- - - -class LanguagePrefsIn(BaseModel): - lang: str - - -class LanguagePrefsOut(BaseModel): - lang: str - - -@router.patch("/settings/language", response_model=LanguagePrefsOut) -async def patch_language_prefs( - payload: LanguagePrefsIn, - principal: CurrentUser = Depends(require_token), - session: AsyncSession = Depends(get_session), -) -> LanguagePrefsOut: - if principal.user is None: - raise HTTPException(status_code=400, detail="no_user_context") - lang = (payload.lang or "").strip().lower() - if lang not in ACTIVE_LANGUAGES: - raise HTTPException( - status_code=400, - detail=f"unsupported language: {payload.lang!r}", - ) - user = await session.get(User, principal.user.id) - if user is None: - raise HTTPException(status_code=404, detail="user_not_found") - user.lang = lang - await session.commit() - return LanguagePrefsOut(lang=lang) diff --git a/app/routers/chat.py b/app/routers/chat.py deleted file mode 100644 index 20f99e5..0000000 --- a/app/routers/chat.py +++ /dev/null @@ -1,239 +0,0 @@ -"""Chat endpoint — POST /api/chat. - -Grounded on the latest strategic log, current market quotes, and -thesis-filtered headlines. Ephemeral: the conversation lives in the -client; this endpoint just records each call's cost in `ai_calls`. -""" -from __future__ import annotations - -from collections import defaultdict -from datetime import timedelta - -import httpx -from fastapi import APIRouter, Depends, HTTPException -from pydantic import BaseModel, Field -from sqlalchemy import desc, func, select -from sqlalchemy.ext.asyncio import AsyncSession - -from app.auth import require_token, maybe_current_user, CurrentUser -from app.config import get_settings -from app.db import get_session, utcnow -from app.jobs._market_context import REFERENCE_LINE -from app.models import AICall, Headline, Quote, StrategicLog -from app.routers.api import _md_to_html -from app.services.i18n import respond_in_clause -from app.services.llm_prompts import build_chat_system_prompt -from app.services.openrouter import call_llm, month_start -from app.services.output_review import review_read - -from app.logging import get_logger -log = get_logger("chat") - -router = APIRouter(dependencies=[Depends(require_token)]) - - -# --------------------------------------------------------------------------- -# Pydantic models -# --------------------------------------------------------------------------- - - -class ChatMessage(BaseModel): - role: str = Field(pattern="^(user|assistant)$") - content: str - - -class ChatRequest(BaseModel): - messages: list[ChatMessage] - - -# --------------------------------------------------------------------------- -# Private helpers -# --------------------------------------------------------------------------- - -THESIS_KEYWORDS_FALLBACK = [ - "hormuz", "iran", "opec", "brent", "wti", "crude", "oil", - "china", "taiwan", "yuan", "fed", "inflation", "cpi", "yield", - "gold", "dollar", "yen", "saudi", "russia", "ukraine", "israel", - "nato", "defence", "defense", -] - - -async def _latest_quotes_by_group_chat(session: AsyncSession) -> dict[str, list[dict]]: - sub = ( - select(Quote.group_name, Quote.symbol, - func.max(Quote.fetched_at).label("mx")) - .group_by(Quote.group_name, Quote.symbol) - .subquery() - ) - rows = (await session.execute( - select(Quote).join( - sub, - (Quote.group_name == sub.c.group_name) - & (Quote.symbol == sub.c.symbol) - & (Quote.fetched_at == sub.c.mx), - ).order_by(Quote.group_name, Quote.symbol) - )).scalars().all() - by_group: dict[str, list[dict]] = defaultdict(list) - for q in rows: - by_group[q.group_name].append({ - "symbol": q.symbol, "label": q.label, - "price": q.price, "currency": q.currency, - "as_of": q.as_of, "changes": q.changes, - }) - return by_group - - -async def _thesis_headlines_for_chat(session: AsyncSession, limit: int = 50) -> list[dict]: - cutoff = utcnow() - timedelta(hours=24) - rows = (await session.execute( - select(Headline) - .where(Headline.published_at >= cutoff) - .order_by(desc(Headline.published_at)) - .limit(300) - )).scalars().all() - out = [] - for h in rows: - if any(kw in h.title.lower() for kw in THESIS_KEYWORDS_FALLBACK): - out.append({"source": h.source, "title": h.title}) - if len(out) >= limit: - break - return out - - -async def _month_spend(session: AsyncSession) -> float: - total = (await session.execute( - select(func.coalesce(func.sum(AICall.cost_usd), 0.0)) - .where(AICall.called_at >= month_start()) - )).scalar() - return float(total or 0.0) - - -# --------------------------------------------------------------------------- -# Route -# --------------------------------------------------------------------------- - - -@router.post("/chat") -async def chat( - body: ChatRequest, - session: AsyncSession = Depends(get_session), - principal: CurrentUser | None = Depends(maybe_current_user), -): - """Answer one user turn given the conversation so far. Grounded on the - latest strategic log + market data + thesis-filtered headlines. - Ephemeral — the conversation lives entirely in the client; the endpoint - just records each call's cost in `ai_calls`.""" - # Paid-only feature. Free users get the static log but not the - # interactive chat (see /pricing). - from app.services.access import is_paid_active - if not is_paid_active(principal): - raise HTTPException( - status_code=402, - detail={"code": "paid_required", - "message": "Follow-up chat is a paid-tier feature."}, - ) - - s = get_settings() - if not s.OPENROUTER_API_KEY: - raise HTTPException(status_code=503, detail="OPENROUTER_API_KEY not set") - - # Monthly cost cap — same one the log job respects. - spent = await _month_spend(session) - if spent >= s.OPENROUTER_MONTHLY_CAP_USD: - raise HTTPException( - status_code=429, - detail=f"Monthly OpenRouter cap reached (${spent:.2f})", - ) - - # Trim runaway conversations: keep last 20 turns. - history = body.messages[-20:] - if not history or history[-1].role != "user": - raise HTTPException(status_code=400, detail="Last message must be user") - - # Gather grounding context. - log_row = (await session.execute( - select(StrategicLog).order_by(desc(StrategicLog.generated_at)).limit(1) - )).scalar_one_or_none() - quotes = await _latest_quotes_by_group_chat(session) - headlines = await _thesis_headlines_for_chat(session) - - system_prompt = build_chat_system_prompt( - s.CASSANDRA_TONE, s.CASSANDRA_ANALYSIS, - log_content=log_row.content if log_row else None, - log_generated_at=log_row.generated_at if log_row else None, - quotes_by_group=quotes, - headlines=headlines, - reference_line=REFERENCE_LINE, - ) - # Respect the user's interface language preference: append a single - # localized "respond in" nudge so the assistant answers in IT when - # the user has lang=it. The prompt + history (which includes the - # user's own question, often in their language) are usually enough, - # but the nudge guarantees the first reply lands correctly. - user_lang = principal.user.lang if principal and principal.user else "en" - system_prompt = system_prompt + respond_in_clause(user_lang) - - msgs = [{"role": "system", "content": system_prompt}] - for m in history: - msgs.append({"role": m.role, "content": m.content}) - - try: - async with httpx.AsyncClient(follow_redirects=True) as client: - result = await call_llm(client, msgs) - # Reviewer gate. The chat turn could solicit advice with a - # leading question; the generator's system prompt forbids it, - # but the reviewer is the enforcement layer. ~1-2 s extra - # latency per turn on top of the generation call. - verdict = await review_read(client, result.content) - except Exception as e: - session.add(AICall( - model=s.OPENROUTER_MODEL, status="error", error=str(e)[:500], - )) - await session.commit() - raise HTTPException(status_code=502, detail=f"OpenRouter error: {e}") - - full_cost = (result.cost_usd or 0.0) + (verdict.cost_usd or 0.0) - if not verdict.clean: - # Rejected reply. Record the cost and surface a generic refusal - # the user can retry, rather than letting potentially non-compliant - # text reach them. - session.add(AICall( - model=result.model, - prompt_tokens=result.prompt_tokens, - completion_tokens=result.completion_tokens, - cost_usd=full_cost, status="leaked", - error=f"reviewer: {verdict.reason}", - )) - await session.commit() - log.warning("chat.reviewer_rejected", reason=verdict.reason, - preview=result.content[:120]) - refusal = ( - "I can't generate that reply — it would have crossed into " - "investment advice or specific recommendations, which I'm " - "not licensed to give. Try rephrasing as a question about " - "what the data means rather than what to do." - ) - return { - "role": "assistant", - "content": refusal, - "content_html": _md_to_html(refusal), - "prompt_tokens": result.prompt_tokens, - "completion_tokens": result.completion_tokens, - } - - session.add(AICall( - model=result.model, - prompt_tokens=result.prompt_tokens, - completion_tokens=result.completion_tokens, - cost_usd=full_cost, - status="ok", - )) - await session.commit() - - return { - "role": "assistant", - "content": result.content, - "content_html": _md_to_html(result.content), - "prompt_tokens": result.prompt_tokens, - "completion_tokens": result.completion_tokens, - } diff --git a/app/routers/email.py b/app/routers/email.py index b7df411..429101b 100644 --- a/app/routers/email.py +++ b/app/routers/email.py @@ -63,9 +63,7 @@ _CONFIRM_PAGE = """\ Unsubscribed — {brand} - - - +
diff --git a/app/routers/ops.py b/app/routers/ops.py deleted file mode 100644 index 289f803..0000000 --- a/app/routers/ops.py +++ /dev/null @@ -1,162 +0,0 @@ -"""HTML-only ops endpoints — /api/markets-bar and /api/health. - -These are HTMX partials consumed by the dashboard. They return HTML by -default (not JSON) and are not included in the OpenAPI schema. -""" -from __future__ import annotations - -from fastapi import APIRouter, Depends, Query, Request -from fastapi.responses import HTMLResponse, JSONResponse -from sqlalchemy import desc, func, select -from sqlalchemy.ext.asyncio import AsyncSession - -from app.auth import require_token -from app.db import get_session, utcnow -from app.models import JobRun, Quote -from app.routers.api import _age_seconds, _fmt_age -from app.schemas import HealthOut, JobStatus -from app.templates_env import templates - -router = APIRouter(dependencies=[Depends(require_token)]) - -JOB_NAMES = ("market_job", "news_job", "ai_log_job", "rollup_job", - "indicator_summary_job", "universe_flush_job", - "email_digest_job") -JOB_STALE_HOURS = 2.0 # job is "warn" if its last success was >2h ago - -# Market → headline index mapping for the sticky bottom bar. Symbols must -# be present in config/default.toml so market_job populates `quotes`. -_MARKET_INDEX = { - "NYSE": ("^GSPC", "S&P 500"), - "LSE": ("^FTSE", "FTSE 100"), - # XETRA → Euro Stoxx 50 rather than ^GDAXI: Yahoo's DAX ticker is - # patchy via the chart endpoint, and ^STOXX50E is already tracked in - # config/default.toml's equity group. - "XETRA": ("^STOXX50E", "STOXX 50"), - "JPX": ("^N225", "Nikkei 225"), - "HKEX": ("^HSI", "Hang Seng"), - "SSE": ("000300.SS", "CSI 300"), -} - - -def _fmt_price(p: float | None) -> str: - if p is None: - return "—" - if abs(p) >= 1000: - return f"{p:,.0f}" - if abs(p) >= 100: - return f"{p:,.1f}" - return f"{p:,.2f}" - - -@router.get("/markets-bar", response_class=HTMLResponse, include_in_schema=False) -async def markets_bar( - request: Request, - session: AsyncSession = Depends(get_session), - as_: str | None = Query(default=None, alias="as"), -): - """The sticky bottom-bar payload: per-market open/close status with the - market's headline index price + 1d change. Refreshed by HTMX every 60s. - """ - from app.services.markets import all_statuses - - statuses = all_statuses() - # Latest quote per headline-index symbol in one query. - wanted_syms = [sym for sym, _ in _MARKET_INDEX.values()] - sub = ( - select(Quote.symbol, func.max(Quote.fetched_at).label("mx")) - .where(Quote.symbol.in_(wanted_syms)) - .group_by(Quote.symbol) - .subquery() - ) - rows = (await session.execute( - select(Quote).join( - sub, - (Quote.symbol == sub.c.symbol) & (Quote.fetched_at == sub.c.mx), - ) - )).scalars().all() - by_sym = {q.symbol: q for q in rows} - - markets: list[dict] = [] - for st in statuses: - sym, label = _MARKET_INDEX.get(st["code"], (None, None)) - q = by_sym.get(sym) if sym else None - idx = None - if q is not None and q.price is not None: - idx = { - "symbol": q.symbol, - "label": label, - "price_fmt": _fmt_price(q.price), - "change_1d_pct": (q.changes or {}).get("1d"), - } - markets.append({ - "code": st["code"], - "label": st["label"], - "open": st["open"], - "until_iso": st["until"].isoformat(), - "until_hhmm": st["until"].strftime("%H:%M"), - "index": idx, - }) - - return templates.TemplateResponse( - request, "partials/markets_bar.html", - {"markets": markets}, - ) - - -@router.get("/health", response_class=HTMLResponse, include_in_schema=False) -async def health_html( - request: Request, - session: AsyncSession = Depends(get_session), - as_: str | None = Query(default=None, alias="as"), -): - """Returns an HTML fragment by default (the ops footer); ?as=json returns the - structured object. The default is HTML because that's how the dashboard - consumes it; CLI/curl users will pass ?as=json.""" - try: - await session.execute(select(func.now())) - db_ok = True - except Exception: - db_ok = False - - now = utcnow() - jobs: list[dict] = [] - structured: list[JobStatus] = [] - for name in JOB_NAMES: - row = (await session.execute( - select(JobRun).where(JobRun.name == name) - .order_by(desc(JobRun.started_at)).limit(1) - )).scalar_one_or_none() - if row is None: - jobs.append({"name": name, "led": "idle", "age": "—", - "last_finished": None}) - structured.append(JobStatus(name=name)) - continue - if row.status == "success": - secs = _age_seconds(now, row.finished_at or row.started_at) or 0 - led = "ok" if secs < JOB_STALE_HOURS * 3600 else "warn" - elif row.status == "skipped": - led = "warn" - elif row.status == "running": - led = "warn" - else: - led = "err" - jobs.append({ - "name": name, "led": led, - "age": _fmt_age(now, row.finished_at or row.started_at), - "last_finished": row.finished_at, - }) - structured.append(JobStatus( - name=name, last_started=row.started_at, - last_finished=row.finished_at, status=row.status, - error=row.error, items_written=row.items_written, - )) - - if as_ == "json": - return JSONResponse( - HealthOut(db="ok" if db_ok else "down", jobs=structured).model_dump(mode="json") - ) - return templates.TemplateResponse( - request, "partials/ops_footer.html", - {"db_ok": db_ok, "jobs": jobs}, - ) diff --git a/app/routers/pages.py b/app/routers/pages.py index 1801f93..f7ef42b 100644 --- a/app/routers/pages.py +++ b/app/routers/pages.py @@ -75,13 +75,14 @@ async def _resolve_log_date(session: AsyncSession, day: str | None) -> date: return datetime.now(timezone.utc).date() - -def _log_page_context(target: date, paid: bool, user_lang: str = "en") -> dict: +def _log_page_context(target: date, paid: bool) -> dict: + s = get_settings() return { "selected_iso": target.isoformat(), "selected_month": target.strftime("%Y-%m"), + "current_tone": s.CASSANDRA_TONE.upper(), + "current_analysis": s.CASSANDRA_ANALYSIS.upper(), "paid": paid, - "user_lang": user_lang, } @@ -92,9 +93,8 @@ async def log_page( cu: CurrentUser = Depends(require_auth), ): target = await _resolve_log_date(session, None) - user_lang = cu.user.lang if cu.user else "en" return templates.TemplateResponse( - request, "log.html", _log_page_context(target, is_paid_active(cu), user_lang), + request, "log.html", _log_page_context(target, is_paid_active(cu)), ) @@ -106,9 +106,8 @@ async def log_page_day( cu: CurrentUser = Depends(require_auth), ): target = await _resolve_log_date(session, day) - user_lang = cu.user.lang if cu.user else "en" return templates.TemplateResponse( - request, "log.html", _log_page_context(target, is_paid_active(cu), user_lang), + request, "log.html", _log_page_context(target, is_paid_active(cu)), ) diff --git a/app/routers/stripe_billing.py b/app/routers/stripe_billing.py index bfdeed0..60bc7f7 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, Optional +from typing import Any, Literal import stripe from fastapi import APIRouter, Body, Depends, HTTPException, Request @@ -69,53 +69,6 @@ def _price_for(cadence: str) -> str: raise HTTPException(status_code=400, detail="cadence must be 'monthly' or 'annual'") -# Rough country → currency mapping. Covers the markets we have a stated -# rate for; everything else falls back to GBP (the home currency) and -# Stripe handles the FX at checkout. Configure the per-currency -# unit_amount on each Price's `currency_options` in the Stripe Dashboard -# — we just signal which option to use here. -_COUNTRY_CURRENCY: dict[str, str] = { - "US": "usd", "CA": "usd", - "GB": "gbp", "IM": "gbp", "JE": "gbp", "GG": "gbp", - **dict.fromkeys(( - "DE", "FR", "IT", "ES", "PT", "NL", "BE", "IE", "AT", "FI", - "GR", "LU", "MT", "CY", "EE", "LV", "LT", "SI", "SK", "HR", - ), "eur"), -} - -# Accept-Language locale → currency, used when CF-IPCountry is absent. -# Ambiguous locales (e.g. plain "fr" without region) get EUR because -# that's the majority outcome. -_LOCALE_CURRENCY: dict[str, str] = { - "en-gb": "gbp", "en": "gbp", - "en-us": "usd", "en-ca": "usd", - "fr": "eur", "de": "eur", "it": "eur", "es": "eur", - "pt": "eur", "nl": "eur", -} - - -def _sniff_currency(request: Request) -> str: - """Best-effort currency detection for new-customer checkouts. - - Order: explicit Cloudflare country header, then Accept-Language - (exact match then language-only). GBP as the final fallback. Only - consulted when the user has no Stripe customer record yet — Stripe - locks currency at customer creation, so an existing customer's - currency wins regardless of the request locale. - """ - cc = (request.headers.get("cf-ipcountry") or "").upper() - if cc in _COUNTRY_CURRENCY: - return _COUNTRY_CURRENCY[cc] - al = (request.headers.get("accept-language") or "").lower() - first = al.split(",", 1)[0].split(";", 1)[0].strip() - if first in _LOCALE_CURRENCY: - return _LOCALE_CURRENCY[first] - short = first.split("-", 1)[0] - if short in _LOCALE_CURRENCY: - return _LOCALE_CURRENCY[short] - return "gbp" - - def _stripe_client() -> stripe.StripeClient: """Per-call client so we read the secret at request time (lets us rotate the key by editing .env + reloading without rebuilding any @@ -130,10 +83,6 @@ def _stripe_client() -> stripe.StripeClient: class CheckoutRequest(BaseModel): cadence: Literal["monthly", "annual"] - # Optional override; when omitted we sniff from request headers. - # Honoured only for first-time checkouts (Stripe locks currency - # to the customer at creation). - currency: Optional[Literal["gbp", "usd", "eur"]] = None class CheckoutResponse(BaseModel): @@ -143,7 +92,6 @@ class CheckoutResponse(BaseModel): @router.post("/api/stripe/checkout", response_model=CheckoutResponse) async def create_checkout( body: CheckoutRequest, - request: Request, session: AsyncSession = Depends(get_session), cu: CurrentUser = Depends(require_auth), ) -> CheckoutResponse: @@ -172,13 +120,6 @@ async def create_checkout( # referral redemption flow ships. "allow_promotion_codes": True, } - # Multi-currency: for first-time buyers (no stripe_customer_id yet) - # we pass the detected/requested currency. Stripe picks the matching - # `currency_options` rate configured on the Price in the Dashboard, - # then locks that currency to the new customer record. Existing - # customers keep their original currency regardless. - if not user.stripe_customer_id: - create_kwargs["currency"] = body.currency or _sniff_currency(request) # Per-cadence cooling-off treatment: # # - Annual gets a 14-day free trial. No money moves during the diff --git a/app/routers/universe.py b/app/routers/universe.py index d8d64ee..a77585f 100644 --- a/app/routers/universe.py +++ b/app/routers/universe.py @@ -20,7 +20,10 @@ Four routes: held in memory for one LLM call, discarded on response. -All routes require authentication (session cookie OR bearer token). +All routes require authentication (session cookie OR bearer token). The +old endpoints in `app/routers/api.py` (`/api/portfolios/upload`, +`/api/portfolio/{name}/summary`) remain live until step 10 of the Phase G +plan, when they're removed alongside the table drops. """ from __future__ import annotations @@ -33,7 +36,7 @@ from fastapi.responses import JSONResponse from sqlalchemy import and_, func, select from sqlalchemy.ext.asyncio import AsyncSession -from app.auth import CurrentUser, require_auth +from app.auth import require_auth from app.config import get_settings from app.db import get_session, utcnow from app.logging import get_logger @@ -338,11 +341,10 @@ async def parse_portfolio( # --------------------------------------------------------------------------- -@router.post("/analyze") +@router.post("/analyze", dependencies=[Depends(require_paid)]) async def analyze_portfolio( request: Request, session: AsyncSession = Depends(get_session), - principal: CurrentUser = Depends(require_paid), ) -> dict: """Generate AI commentary for the supplied pie. The pie is held in memory only for the duration of the LLM call; nothing about holdings @@ -362,11 +364,6 @@ async def analyze_portfolio( except Exception: raise HTTPException(status_code=400, detail="malformed JSON body") - user_lang = ( - principal.user.lang if (principal.user and principal.user.lang) else "en" - ) - payload["lang"] = user_lang - try: req = portfolio_analysis.parse_request(payload) except ValueError as e: diff --git a/app/services/csv_import.py b/app/services/csv_import.py index 41a6a99..cacd84d 100644 --- a/app/services/csv_import.py +++ b/app/services/csv_import.py @@ -221,4 +221,7 @@ def parse_t212_csv(content: str | bytes) -> ParsedPie: ) - +# persist_pie removed in Phase G — the parsed pie is returned to the +# browser by /api/portfolio/parse and lives in localStorage. The server +# now keeps only the anonymous ticker_universe (see +# app/services/ticker_universe.py). diff --git a/app/services/digest_email.py b/app/services/digest_email.py deleted file mode 100644 index 3d416f6..0000000 --- a/app/services/digest_email.py +++ /dev/null @@ -1,116 +0,0 @@ -"""Daily/weekly digest email rendering. - -Pure prose → HTML/text rendering. SMTP transport stays in -``email_service.send_email``; this module only assembles the message -body, subject, and a text-only fallback for clients without HTML -rendering. - -Split from email_service.py during the Tier 2 cleanup pass — the -SMTP/OTP/welcome surface and the digest renderer changed at very -different cadences and made the file noisy to navigate. -""" -from __future__ import annotations - -import html as _html_lib -import re as _re - -from app import branding - - -_DIGEST_HTML_TEMPLATE = """\ - - - - - - - {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 8180ca6..d3ed9f7 100644 --- a/app/services/email_service.py +++ b/app/services/email_service.py @@ -18,6 +18,8 @@ convenient for local dev that doesn't want a mail server configured. """ from __future__ import annotations +import html as _html_lib +import re as _re from email.message import EmailMessage import aiosmtplib @@ -321,3 +323,106 @@ async def send_welcome_email(to: str) -> None: subject, text, html = render_welcome_email() await send_email(to, subject, text, html_body=html) + +# --------------------------------------------------------------------------- +# Digest email rendering +# --------------------------------------------------------------------------- + + +_DIGEST_HTML_TEMPLATE = """\ + + + + + + + {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 40aa938..c994995 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` / `#glossary-tooltip` in dashboard.css) -uses `data-def` for richer formatting. Wrapping happens at most once per term +The CSS tooltip (see `.glossary:hover::after` in cassandra.css) uses +`data-def` for richer formatting. Wrapping happens at most once per term per HTML fragment — repeated occurrences stay plain. """ from __future__ import annotations diff --git a/app/services/i18n.py b/app/services/i18n.py deleted file mode 100644 index 742373d..0000000 --- a/app/services/i18n.py +++ /dev/null @@ -1,48 +0,0 @@ -"""Language registry + prompt helpers for localized AI output. - -Two surfaces consume this module: -- Per-user LLM call sites (portfolio analysis only at this stage) call - ``respond_in_clause(user.lang)`` and append the result to their - system prompt. -- The settings dropdown + its PATCH endpoint consult ``ACTIVE_LANGUAGES`` - to decide which options are selectable. The strategic-log and digest - translation fan-outs also consult it to decide which languages to - spend tokens on. - -Adding Spanish/French/German support later is a one-line constant -change: extend ``ACTIVE_LANGUAGES`` to include the new code. No other -code change is required — the rest of the system already treats them -as first-class via ``LANGUAGES``. -""" -from __future__ import annotations - - -# Display labels for every language the system knows about. ES/FR/DE -# are kept here so labels still render in the dropdown (as disabled -# options) without requiring code changes to enable them later. -LANGUAGES: dict[str, str] = { - "en": "English", - "it": "Italian", - "es": "Spanish", - "fr": "French", - "de": "German", -} - - -# Languages users can actually select. Settings POST validates against -# this; the strategic-log + digest translation fan-outs only consider -# these. -ACTIVE_LANGUAGES: set[str] = {"en", "it"} - - -def respond_in_clause(lang: str | None) -> str: - """Suffix appended to per-user LLM system prompts. - - Returns an empty string for ``en`` (no nudge needed), an unknown - code, or ``None``/empty input — those callers want the default - English path. Otherwise returns ``"\\n\\nRespond in ."`` - 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 7c7c7a5..7bb84af 100644 --- a/app/services/llm_csv_parser.py +++ b/app/services/llm_csv_parser.py @@ -424,10 +424,8 @@ async def parse_with_llm(raw: bytes, session: AsyncSession) -> ParsedPie: first_seen_at=now, last_used_at=now, use_count=1, - model=llm_log.model, - prompt_tokens=llm_log.prompt_tokens, - completion_tokens=llm_log.completion_tokens, - cost_usd=llm_log.cost_usd, + llm_model=llm_log.model, + llm_cost_usd=llm_log.cost_usd, )) await session.commit() return pie diff --git a/app/services/llm_prompts.py b/app/services/llm_prompts.py deleted file mode 100644 index 726b60a..0000000 --- a/app/services/llm_prompts.py +++ /dev/null @@ -1,620 +0,0 @@ -"""Prompt-engineering surface for AI surfaces. - -This module assembles the system + user prompts the LLM ingests. It -has no I/O — pure string-building from typed inputs. Pair with -``app.services.openrouter`` (the transport layer) which actually -calls the model. - -The two halves of LLM work — what to ask vs how to ask — change at -very different cadences. Prompt-version bumps (see PROMPT_VERSION -below) happen ~weekly; transport changes are rare. -""" -from __future__ import annotations - -import json -from datetime import datetime - - -# Bump when the composed prompt changes meaningfully. Stored on every -# StrategicLog row so historical logs can be linked to the prompt that produced -# them. -# -# v6 (2026-05-17): TONE shrinks to NOVICE | INTERMEDIATE (PRO dropped). New -# educational stance baked into _CORE — explicit anti-TA, anti-gambling-mindset -# framing aimed at young investors entering the trading world. NOVICE retuned -# to be pedagogical (defining terms, anti-pattern teach-backs); INTERMEDIATE -# kept terse but with light-touch educational nudges. See tasks/todo.md. -# v7 (2026-05-18): Forbid "(Updated HH:MM UTC)" clauses in the date header — -# the model was hallucinating future times. The user prompt now carries the -# actual current UTC time so the model has accurate temporal context. -# v9 (2026-05-25): Adds daily + weekly digest prompt builders for email. -PROMPT_VERSION = 9 - - -# --- Core: invariant across tone/analysis settings ---------------------------- - -_CORE = """You are Cassandra, writing a single daily strategic markets log \ -for one specific investor. Synthesis, not exposition. - -# Lens -- Geopolitics → markets is the primary causal chain. For each sector move, \ -ask: geopolitical, cyclical, or idiosyncratic. Label it. -- Divergences and contradictions are where the information is. Hunt for them. -- Absence of expected moves is signal. If the thesis predicted a reaction \ -that didn't happen, that's more interesting than the reactions that did. -- Compare live readings against any reference snapshots provided. - -# Multi-source news -- When state-aligned outlets (Xinhua, China Daily, RT) and Western outlets \ -cover the same event, read the gap in framing — that's the data. -- News matters only insofar as it changes a market read. Color without \ -implications is filler. - -# Structure -- One-line date header containing ONLY the date (e.g. `2026-05-18`) and \ -optional anchor framing on the same line (e.g. "Week 11 since Hormuz"). \ -**Never include a time-of-day clause like "(Updated 21:30 UTC)"** — \ -generation time is recorded as metadata elsewhere. Inventing a future or \ -arbitrary time in the header confuses readers. -- Immediately after the date header — with **nothing** in between — write a \ -TL;DR. Format it as: - - ## TL;DR - - One concise paragraph of 2-3 sentences, **≤60 words total**, naming the \ -single most important read or divergence of the day with concrete numbers. \ -This is what a reader who only has 10 seconds sees. Don't waste it on the \ -weather or generic context. - -- Then 4-6 paragraphs, each anchored on a sleeve, sector, or theme. Concrete \ -numbers in every paragraph. No section over ~150 words. -- One paragraph synthesising the news flow into a market read. -- End with a watch list: 3-5 specific items to track in the next week, \ -each one sentence. - -# Time-horizon discipline -- This is a STRATEGIC log, not a day-trader's read. Treat 1-day moves under \ -2% as background noise; mention them only when they break or confirm a \ -multi-week trend or are extreme outliers. -- Anchor every claim to multi-week (1m), multi-month (since-anchor), or \ -multi-year (1y) changes — not 1d. If the only thing happening is a 1d move, \ -omit the paragraph. -- The watch list is for "structural tripwires over the next 1-3 months", not \ -"things to watch tomorrow". Each watch item should name a level/threshold \ -whose breach would change the regime, not a calendar-date event. - -# Rational vs irrational framing (MANDATORY in every paragraph) -The reader's primary goal is to disconnect rational decisions from market \ -irrationality. This is the single most important lens of the log — it MUST \ -appear in every sector or theme paragraph, not just where it feels natural. \ -For each paragraph, before writing it, ask yourself the two questions and \ -then make both answers visible in the prose: -- The RATIONAL drivers — what the underlying factors justify: earnings, \ -real-economy data, monetary policy, structural geopolitical shifts, \ -valuation vs fundamentals. -- The IRRATIONAL drivers — what the crowd is doing regardless of fundamentals: \ -positioning, narrative momentum, sentiment extremes, concentration, \ -flow-driven moves, options gamma, credit complacency. -Then state the GAP: is price moving with the rational read, ahead of it, \ -or against it? If they agree, say so briefly and move on. If they diverge \ -— price moving on irrational drivers while fundamentals say otherwise, or \ -vice versa — name the divergence explicitly. Those gaps are where the next \ -regime change starts and are the whole point of this log. -A paragraph that names only price action or only fundamentals, without \ -both lenses, is incomplete and must be rewritten. - -# Discipline -- No emojis, no marketing language, no "concerning" or "unprecedented" \ -without a specific number behind it. -- Concrete > vague. "AMD +113% since the anchor" beats "AI stocks up sharply". -- Distinguish "the thesis predicted X and X happened" from "the thesis \ -predicted X and X did not happen". Both are useful; conflating them is not. -- Don't repeat the same point in different words across paragraphs. -- No buy/sell recommendations. Triggers are pre-set elsewhere; your job is \ -to report whether reality is confirming, modifying, or refuting the thesis. - -# Stance (educational, anti-TA, anti-gambling) -The target reader is most likely young, new to investing, and at risk of \ -treating markets like a horse race they need to "read" via chart patterns. \ -Cassandra is the corrective. -- **No technical analysis.** Head-and-shoulders, RSI thresholds, Fibonacci \ -levels, Elliott waves, "support/resistance" — these are descriptions of past \ -crowd behaviour, not predictions. Don't use them; don't legitimise them. If \ -you mention a price level, frame it as a positioning fact (e.g. "the level \ -where the latest tranche of buyers entered"), not a signal. -- **No gambling framing.** Markets are not a coin flip and not a horse race. \ -Never present a position as a single decisive moment, a "now or never", or a \ -bet to be won. Every read should follow the shape: *regime → implication → \ -what would change the regime*. -- **Macro causality, every time.** Price moves get explained through \ -fundamentals, geopolitics, monetary policy, and structural shifts — not \ -chart shapes. Even short paragraphs need the cause, not just the effect. - -# System temperature (closing line, mandatory) -Close the log with a single sentence on a line of its own, formatted exactly: - - System temperature: [cool|neutral|elevated|hot|extreme] — [one clause naming the 2-3 specific divergences or readings that justify the label] - -This is the line a reader who only sees the watch list scrolls down to. Make \ -it earn its place: cite real signals (HY OAS, breadth, VIX, valuation, real \ -yields), not vibes. - -# Update mode (when an earlier log from today is provided) -If the user message includes a section labelled "Earlier log from today \ -(generated HH:MM UTC)", treat that as YOUR OWN earlier draft. You are \ -UPDATING it for the current data, not starting from scratch. -- Don't restate context that hasn't changed. Anchor on what's moved SINCE \ -that timestamp: confirmations, refutations, new emergent patterns. -- The TL;DR should lead with the move since the earlier read when there \ -was a meaningful intra-day change ("Since this morning's read, …") — \ -otherwise stay regime-level. -- The watch list should evolve: drop items that triggered or settled, add \ -items that emerged. Keep items still load-bearing. -- Preserve any insights from the earlier draft that remain valid; sharpen \ -or revise the ones that don't. Avoid contradicting yourself silently — if \ -you change a stance, name it briefly ("Earlier I read X; with Y now, the \ -read shifts to Z").""" - - -# --- Tone: audience-shaping block -------------------------------------------- - -_TONE: dict[str, str] = { - "NOVICE": """# Audience: novice — likely a young investor new to markets -This reader probably arrived from social media, treats charts as predictions, \ -and is one bad week away from quitting. Your job is to **educate them out of \ -the gambling mindset** without ever being preachy. Calm, patient, slightly \ -teacherly. Never condescending. - -- **Define jargon the first time it appears.** A short clause in parentheses \ -is fine: "yield curve (the chart of borrowing costs across different \ -maturities)", "ERP (equity risk premium — the extra return investors demand \ -for owning stocks instead of safe bonds)", "basis point (one hundredth of a \ -percent — 25bp = 0.25%)". -- **Avoid ticker shorthand without context.** Use "Apple (AAPL)" on first \ -mention, then "Apple" or the ticker after. -- **Everyday phrasing over jargon** where the meaning survives: "the price \ -of US government debt fell, pushing yields up" rather than "the long end \ -backed up"; "investors are paying more for the same earnings" rather than \ -"multiple expansion". -- **One analogy per concept, used sparingly.** Use them to bridge to \ -something concrete the reader already understands — not to entertain. - -# Educational teach-backs (NOVICE-specific, when warranted) -When the day's data makes a common misconception concrete, drop in ONE \ -teach-back of one to two sentences. Don't force it. Don't moralise. Examples \ -of moments to do this: - -- Anyone treating chart patterns as predictions: \ -"Patterns like head-and-shoulders describe what crowds did, not what they \ -will do — they're stories told after the fact, not edges." -- Anyone fixated on day-to-day moves: \ -"A 1% one-day move in a stock is roughly what you'd expect by chance. The \ -multi-week trend is where the information lives." -- Anyone treating one ticker as a coin flip: \ -"A single name's monthly move is mostly noise. The regime — what bonds, the \ -dollar, and credit are doing together — tells you whether ANY stock is \ -likely to drift up or down." -- Anyone trying to "time the bottom" or "buy the dip": \ -"Catching the bottom is a different game from owning the next cycle. The \ -first needs you to be right within days; the second needs you to be roughly \ -right within years." - -Limit yourself to one teach-back per log. Skip them entirely if the day's \ -data doesn't naturally invite one. - -# Length -Target ~700 words. Slightly more than INTERMEDIATE because explanations \ -need breathing room.""", - - "INTERMEDIATE": """# Audience: intermediate — reads the news, learning to \ -connect macro to markets -Assume the reader knows market basics (yield curves, breakevens, HY OAS, \ -sector ETFs, the difference between cyclical and defensive, what a basis \ -point is). Use common terms without defining them, but stay clear of deep \ -institutional shorthand ("the belly", "duration trade", "carry pickup", \ -"the RV book", "off-the-run"). - -Light-touch educational nudges are welcome when the day's data warrants — \ -e.g. "with rates this volatile, technical levels in equities are mostly \ -distraction" — but keep them to a passing clause, not a paragraph. Don't \ -moralise. - -# Length -Target ~600 words. Lean and clear, no padding.""", -} - - -# Legacy values map to the closest current value. Logs a warning so we can -# notice if some caller's config didn't get updated. -_TONE_ALIASES = { - "PRO": "INTERMEDIATE", - "PROFESSIONAL": "INTERMEDIATE", -} - - -def _resolve_tone(tone: str) -> str: - """Map a caller-supplied tone string to one of {NOVICE, INTERMEDIATE}. - - Unknown tones fall back to INTERMEDIATE. The legacy PRO value is mapped - to INTERMEDIATE (audience pivot, see PROMPT_VERSION v6 notes).""" - upper = (tone or "").upper().strip() - if upper in _TONE: - return upper - if upper in _TONE_ALIASES: - return _TONE_ALIASES[upper] - return "INTERMEDIATE" - - -# --- Analysis: forward-vs-backward focus ------------------------------------- - -_ANALYSIS: dict[str, str] = { - "DRY": """# Analysis style: dry -Report what happened. Identify divergences and contradictions. Compare to \ -references. Do not speculate on what comes next. Forward-looking statements \ -are limited to "what would invalidate the read" — never "we expect X to \ -happen". The watch list contains items to monitor, not predictions.""", - - "SPECULATIVE": """# Analysis style: speculative -Report what happened, then explicitly explore forward scenarios. For each \ -significant sector or theme, sketch a 1-4 week scenario set: the base case \ -(what the data suggests), a contrarian case (what would invalidate it), and \ -what tape signal would tip you from one to the other. Be explicit about \ -uncertainty — say "the base case is" not "X will happen". The watch list is \ -the trip-wires that decide between scenarios.""", -} - - -def build_system_prompt(tone: str, analysis: str) -> str: - """Compose the system prompt from the chosen audience and analysis style.""" - tone_block = _TONE[_resolve_tone(tone)] - analysis_block = _ANALYSIS.get(analysis.upper(), _ANALYSIS["SPECULATIVE"]) - return "\n\n".join([_CORE, tone_block, analysis_block]) - - -# Backwards-compat: a default-composed SYSTEM_PROMPT for tests / callers that -# don't yet pass tone/analysis. New callers should call build_system_prompt(). -SYSTEM_PROMPT = build_system_prompt("INTERMEDIATE", "SPECULATIVE") - - -# --- Chat-mode overrides (sidebar on /log) ----------------------------------- - -_CHAT_OVERRIDES = """# Chat mode (overrides the log-structure rules above) -You are NOT writing a daily log right now. The user is asking a specific -question via the chat sidebar. -- Forget the date header, TL;DR, sectional structure, and watch list. Just answer. -- Typical response: 200-400 words. Longer only if the question genuinely - warrants it. -- Cite specific numbers and named headlines from the reference materials - below whenever relevant. If a number isn't in the context, don't invent it. -- If a question is outside the provided context (e.g. asking about a stock or - event not in the data), say so plainly rather than speculating from prior - knowledge. -- No buy/sell recommendations. If asked, redirect to thesis and scenarios. -- Keep the same audience and analysis discipline established above.""" - - -def build_summary_system_prompt(tone: str, analysis: str) -> str: - """A lean, focused system prompt for the per-indicator-group hourly - summary. INTERPRETATION not description — the reader has the table - next to this paragraph; they don't need numbers recited at them. - - Output is JSON-mode: the model must emit a single object - {"read": "..."}. The wrapper makes scratchpad outside the field - physically impossible — the API enforces well-formed JSON, and the - only schema slot is the publishable read. Scratchpad inside the - field is caught by the reviewer agent (services/output_review).""" - tone_block = _TONE[_resolve_tone(tone)] - analysis_block = _ANALYSIS.get(analysis.upper(), _ANALYSIS["SPECULATIVE"]) - return f"""You write a TINY interpretation (≤60 words, 2-3 sentences) \ -of ONE indicator group for a strategic markets dashboard. - -# Output format (strict) -Return ONLY a single JSON object with exactly one field: -{{"read": ""}} -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 50e7f7e..a542b98 100644 --- a/app/services/openrouter.py +++ b/app/services/openrouter.py @@ -1,8 +1,8 @@ -"""LLM transport layer — OpenRouter / DeepSeek API calls. +"""Strategic-log generator — DB-fed, OpenRouter-backed. -Handles provider selection, retry + fallback machinery, and the monthly -budget-cap helpers. Prompt engineering lives in ``app.services.llm_prompts``; -this module only cares about *how* to reach the model, not *what to ask*. +Ported from /home/gg/ownCloud/Family/Finances/Wealth/strategic_log.py. The +system prompt is preserved verbatim (the voice we converged on). The user +prompt is now built from DB rows, not from subprocess JSON dumps. """ from __future__ import annotations @@ -18,31 +18,420 @@ from app.config import get_settings OPENROUTER_URL = "https://openrouter.ai/api/v1/chat/completions" +# Bump when the composed prompt changes meaningfully. Stored on every +# StrategicLog row so historical logs can be linked to the prompt that produced +# them. +# +# v6 (2026-05-17): TONE shrinks to NOVICE | INTERMEDIATE (PRO dropped). New +# educational stance baked into _CORE — explicit anti-TA, anti-gambling-mindset +# framing aimed at young investors entering the trading world. NOVICE retuned +# to be pedagogical (defining terms, anti-pattern teach-backs); INTERMEDIATE +# kept terse but with light-touch educational nudges. See tasks/todo.md. +# v7 (2026-05-18): Forbid "(Updated HH:MM UTC)" clauses in the date header — +# the model was hallucinating future times. The user prompt now carries the +# actual current UTC time so the model has accurate temporal context. +# v9 (2026-05-25): Adds daily + weekly digest prompt builders for email. +PROMPT_VERSION = 9 -# Per-model USD rates: (input_per_million, output_per_million). -# OpenRouter returns `usage.cost` directly; DeepSeek's native API does not. -# Used as a fallback when the upstream omits the cost field. -_MODEL_PRICING_USD_PER_MILLION: dict[str, tuple[float, float]] = { - "deepseek-v4-flash": (0.07, 0.28), - "deepseek/deepseek-v4-flash": (0.07, 0.28), - "deepseek-chat": (0.27, 1.10), - "deepseek-reasoner": (0.55, 2.19), +# --- Core: invariant across tone/analysis settings ---------------------------- + +_CORE = """You are Cassandra, writing a single daily strategic markets log \ +for one specific investor. Synthesis, not exposition. + +# Lens +- Geopolitics → markets is the primary causal chain. For each sector move, \ +ask: geopolitical, cyclical, or idiosyncratic. Label it. +- Divergences and contradictions are where the information is. Hunt for them. +- Absence of expected moves is signal. If the thesis predicted a reaction \ +that didn't happen, that's more interesting than the reactions that did. +- Compare live readings against any reference snapshots provided. + +# Multi-source news +- When state-aligned outlets (Xinhua, China Daily, RT) and Western outlets \ +cover the same event, read the gap in framing — that's the data. +- News matters only insofar as it changes a market read. Color without \ +implications is filler. + +# Structure +- One-line date header containing ONLY the date (e.g. `2026-05-18`) and \ +optional anchor framing on the same line (e.g. "Week 11 since Hormuz"). \ +**Never include a time-of-day clause like "(Updated 21:30 UTC)"** — \ +generation time is recorded as metadata elsewhere. Inventing a future or \ +arbitrary time in the header confuses readers. +- Immediately after the date header — with **nothing** in between — write a \ +TL;DR. Format it as: + + ## TL;DR + + One concise paragraph of 2-3 sentences, **≤60 words total**, naming the \ +single most important read or divergence of the day with concrete numbers. \ +This is what a reader who only has 10 seconds sees. Don't waste it on the \ +weather or generic context. + +- Then 4-6 paragraphs, each anchored on a sleeve, sector, or theme. Concrete \ +numbers in every paragraph. No section over ~150 words. +- One paragraph synthesising the news flow into a market read. +- End with a watch list: 3-5 specific items to track in the next week, \ +each one sentence. + +# Time-horizon discipline +- This is a STRATEGIC log, not a day-trader's read. Treat 1-day moves under \ +2% as background noise; mention them only when they break or confirm a \ +multi-week trend or are extreme outliers. +- Anchor every claim to multi-week (1m), multi-month (since-anchor), or \ +multi-year (1y) changes — not 1d. If the only thing happening is a 1d move, \ +omit the paragraph. +- The watch list is for "structural tripwires over the next 1-3 months", not \ +"things to watch tomorrow". Each watch item should name a level/threshold \ +whose breach would change the regime, not a calendar-date event. + +# Rational vs irrational framing (MANDATORY in every paragraph) +The reader's primary goal is to disconnect rational decisions from market \ +irrationality. This is the single most important lens of the log — it MUST \ +appear in every sector or theme paragraph, not just where it feels natural. \ +For each paragraph, before writing it, ask yourself the two questions and \ +then make both answers visible in the prose: +- The RATIONAL drivers — what the underlying factors justify: earnings, \ +real-economy data, monetary policy, structural geopolitical shifts, \ +valuation vs fundamentals. +- The IRRATIONAL drivers — what the crowd is doing regardless of fundamentals: \ +positioning, narrative momentum, sentiment extremes, concentration, \ +flow-driven moves, options gamma, credit complacency. +Then state the GAP: is price moving with the rational read, ahead of it, \ +or against it? If they agree, say so briefly and move on. If they diverge \ +— price moving on irrational drivers while fundamentals say otherwise, or \ +vice versa — name the divergence explicitly. Those gaps are where the next \ +regime change starts and are the whole point of this log. +A paragraph that names only price action or only fundamentals, without \ +both lenses, is incomplete and must be rewritten. + +# Discipline +- No emojis, no marketing language, no "concerning" or "unprecedented" \ +without a specific number behind it. +- Concrete > vague. "AMD +113% since the anchor" beats "AI stocks up sharply". +- Distinguish "the thesis predicted X and X happened" from "the thesis \ +predicted X and X did not happen". Both are useful; conflating them is not. +- Don't repeat the same point in different words across paragraphs. +- No buy/sell recommendations. Triggers are pre-set elsewhere; your job is \ +to report whether reality is confirming, modifying, or refuting the thesis. + +# Stance (educational, anti-TA, anti-gambling) +The target reader is most likely young, new to investing, and at risk of \ +treating markets like a horse race they need to "read" via chart patterns. \ +Cassandra is the corrective. +- **No technical analysis.** Head-and-shoulders, RSI thresholds, Fibonacci \ +levels, Elliott waves, "support/resistance" — these are descriptions of past \ +crowd behaviour, not predictions. Don't use them; don't legitimise them. If \ +you mention a price level, frame it as a positioning fact (e.g. "the level \ +where the latest tranche of buyers entered"), not a signal. +- **No gambling framing.** Markets are not a coin flip and not a horse race. \ +Never present a position as a single decisive moment, a "now or never", or a \ +bet to be won. Every read should follow the shape: *regime → implication → \ +what would change the regime*. +- **Macro causality, every time.** Price moves get explained through \ +fundamentals, geopolitics, monetary policy, and structural shifts — not \ +chart shapes. Even short paragraphs need the cause, not just the effect. + +# System temperature (closing line, mandatory) +Close the log with a single sentence on a line of its own, formatted exactly: + + System temperature: [cool|neutral|elevated|hot|extreme] — [one clause naming the 2-3 specific divergences or readings that justify the label] + +This is the line a reader who only sees the watch list scrolls down to. Make \ +it earn its place: cite real signals (HY OAS, breadth, VIX, valuation, real \ +yields), not vibes. + +# Update mode (when an earlier log from today is provided) +If the user message includes a section labelled "Earlier log from today \ +(generated HH:MM UTC)", treat that as YOUR OWN earlier draft. You are \ +UPDATING it for the current data, not starting from scratch. +- Don't restate context that hasn't changed. Anchor on what's moved SINCE \ +that timestamp: confirmations, refutations, new emergent patterns. +- The TL;DR should lead with the move since the earlier read when there \ +was a meaningful intra-day change ("Since this morning's read, …") — \ +otherwise stay regime-level. +- The watch list should evolve: drop items that triggered or settled, add \ +items that emerged. Keep items still load-bearing. +- Preserve any insights from the earlier draft that remain valid; sharpen \ +or revise the ones that don't. Avoid contradicting yourself silently — if \ +you change a stance, name it briefly ("Earlier I read X; with Y now, the \ +read shifts to Z").""" + + +# --- Tone: audience-shaping block -------------------------------------------- + +_TONE: dict[str, str] = { + "NOVICE": """# Audience: novice — likely a young investor new to markets +This reader probably arrived from social media, treats charts as predictions, \ +and is one bad week away from quitting. Your job is to **educate them out of \ +the gambling mindset** without ever being preachy. Calm, patient, slightly \ +teacherly. Never condescending. + +- **Define jargon the first time it appears.** A short clause in parentheses \ +is fine: "yield curve (the chart of borrowing costs across different \ +maturities)", "ERP (equity risk premium — the extra return investors demand \ +for owning stocks instead of safe bonds)", "basis point (one hundredth of a \ +percent — 25bp = 0.25%)". +- **Avoid ticker shorthand without context.** Use "Apple (AAPL)" on first \ +mention, then "Apple" or the ticker after. +- **Everyday phrasing over jargon** where the meaning survives: "the price \ +of US government debt fell, pushing yields up" rather than "the long end \ +backed up"; "investors are paying more for the same earnings" rather than \ +"multiple expansion". +- **One analogy per concept, used sparingly.** Use them to bridge to \ +something concrete the reader already understands — not to entertain. + +# Educational teach-backs (NOVICE-specific, when warranted) +When the day's data makes a common misconception concrete, drop in ONE \ +teach-back of one to two sentences. Don't force it. Don't moralise. Examples \ +of moments to do this: + +- Anyone treating chart patterns as predictions: \ +"Patterns like head-and-shoulders describe what crowds did, not what they \ +will do — they're stories told after the fact, not edges." +- Anyone fixated on day-to-day moves: \ +"A 1% one-day move in a stock is roughly what you'd expect by chance. The \ +multi-week trend is where the information lives." +- Anyone treating one ticker as a coin flip: \ +"A single name's monthly move is mostly noise. The regime — what bonds, the \ +dollar, and credit are doing together — tells you whether ANY stock is \ +likely to drift up or down." +- Anyone trying to "time the bottom" or "buy the dip": \ +"Catching the bottom is a different game from owning the next cycle. The \ +first needs you to be right within days; the second needs you to be roughly \ +right within years." + +Limit yourself to one teach-back per log. Skip them entirely if the day's \ +data doesn't naturally invite one. + +# Length +Target ~700 words. Slightly more than INTERMEDIATE because explanations \ +need breathing room.""", + + "INTERMEDIATE": """# Audience: intermediate — reads the news, learning to \ +connect macro to markets +Assume the reader knows market basics (yield curves, breakevens, HY OAS, \ +sector ETFs, the difference between cyclical and defensive, what a basis \ +point is). Use common terms without defining them, but stay clear of deep \ +institutional shorthand ("the belly", "duration trade", "carry pickup", \ +"the RV book", "off-the-run"). + +Light-touch educational nudges are welcome when the day's data warrants — \ +e.g. "with rates this volatile, technical levels in equities are mostly \ +distraction" — but keep them to a passing clause, not a paragraph. Don't \ +moralise. + +# Length +Target ~600 words. Lean and clear, no padding.""", } -def _estimate_cost_usd(model: str, prompt_tokens, completion_tokens) -> float | None: - """Compute cost from token counts when the upstream didn't return one. +# Legacy values map to the closest current value. Logs a warning so we can +# notice if some caller's config didn't get updated. +_TONE_ALIASES = { + "PRO": "INTERMEDIATE", + "PROFESSIONAL": "INTERMEDIATE", +} - Returns None if either token count is missing or the model isn't in - the pricing table — caller falls back to whatever value the upstream - did (or didn't) return. - """ - rates = _MODEL_PRICING_USD_PER_MILLION.get(model) - if rates is None or prompt_tokens is None or completion_tokens is None: - return None - in_rate, out_rate = rates - return (prompt_tokens * in_rate + completion_tokens * out_rate) / 1_000_000.0 + +def _resolve_tone(tone: str) -> str: + """Map a caller-supplied tone string to one of {NOVICE, INTERMEDIATE}. + + Unknown tones fall back to INTERMEDIATE. The legacy PRO value is mapped + to INTERMEDIATE (audience pivot, see PROMPT_VERSION v6 notes).""" + upper = (tone or "").upper().strip() + if upper in _TONE: + return upper + if upper in _TONE_ALIASES: + return _TONE_ALIASES[upper] + return "INTERMEDIATE" + + +# --- Analysis: forward-vs-backward focus ------------------------------------- + +_ANALYSIS: dict[str, str] = { + "DRY": """# Analysis style: dry +Report what happened. Identify divergences and contradictions. Compare to \ +references. Do not speculate on what comes next. Forward-looking statements \ +are limited to "what would invalidate the read" — never "we expect X to \ +happen". The watch list contains items to monitor, not predictions.""", + + "SPECULATIVE": """# Analysis style: speculative +Report what happened, then explicitly explore forward scenarios. For each \ +significant sector or theme, sketch a 1-4 week scenario set: the base case \ +(what the data suggests), a contrarian case (what would invalidate it), and \ +what tape signal would tip you from one to the other. Be explicit about \ +uncertainty — say "the base case is" not "X will happen". The watch list is \ +the trip-wires that decide between scenarios.""", +} + + +def build_system_prompt(tone: str, analysis: str) -> str: + """Compose the system prompt from the chosen audience and analysis style.""" + tone_block = _TONE[_resolve_tone(tone)] + analysis_block = _ANALYSIS.get(analysis.upper(), _ANALYSIS["SPECULATIVE"]) + return "\n\n".join([_CORE, tone_block, analysis_block]) + + +# Backwards-compat: a default-composed SYSTEM_PROMPT for tests / callers that +# don't yet pass tone/analysis. New callers should call build_system_prompt(). +SYSTEM_PROMPT = build_system_prompt("INTERMEDIATE", "SPECULATIVE") + + +# --- Chat-mode overrides (sidebar on /log) ----------------------------------- + +_CHAT_OVERRIDES = """# Chat mode (overrides the log-structure rules above) +You are NOT writing a daily log right now. The user is asking a specific +question via the chat sidebar. +- Forget the date header, TL;DR, sectional structure, and watch list. Just answer. +- Typical response: 200-400 words. Longer only if the question genuinely + warrants it. +- Cite specific numbers and named headlines from the reference materials + below whenever relevant. If a number isn't in the context, don't invent it. +- If a question is outside the provided context (e.g. asking about a stock or + event not in the data), say so plainly rather than speculating from prior + knowledge. +- No buy/sell recommendations. If asked, redirect to thesis and scenarios. +- Keep the same audience and analysis discipline established above.""" + + +def build_summary_system_prompt(tone: str, analysis: str) -> str: + """A lean, focused system prompt for the per-indicator-group hourly + summary. INTERPRETATION not description — the reader has the table + next to this paragraph; they don't need numbers recited at them.""" + tone_block = _TONE[_resolve_tone(tone)] + analysis_block = _ANALYSIS.get(analysis.upper(), _ANALYSIS["SPECULATIVE"]) + return f"""You write a TINY interpretation (≤60 words, 2-3 sentences) \ +of ONE indicator group for a strategic markets dashboard. + +# What this is for +The reader is looking at the table of numbers right next to your text. \ +They can see the values. They CANNOT see the meaning. Your job is to \ +**explain what the data means**, not to recite it. Each sentence should be \ +a regime-level interpretation, a fundamental driver identification, or a \ +cross-indicator implication — not a description of moves. + +# Rational vs irrational lens (required at this length too) +Even at 2-3 sentences, contrast what the underlying factors justify \ +(rational: fundamentals, policy, valuation) with what the crowd is doing \ +(irrational: positioning, narrative, flows) whenever the two diverge. If \ +they don't diverge, say so in one clause. Never just describe the move \ +without placing it on this axis. + +# Hard constraints +- Plain prose, ONE paragraph. No markdown, no headers, no lists, no labels. +- Open IMMEDIATELY with substance. NEVER start with: "I need to", "I'll", \ +"We need to", "We are asked", "Here's", "Let me", "Let's", "Sure", "Looking \ +at", "Based on", "Summary:", "The data shows", "First", "To address". No \ +meta-commentary at all. +- Cite at most 2-3 specific numbers and ONLY when they anchor an \ +interpretation. Don't list moves; explain them. +- Multi-week / multi-month horizon. 1-day moves under 2% are noise — skip. +- No buy/sell language. No predictions. No watch list. No TL;DR. No date \ +header. No "system temperature" line — that belongs to the full daily log. +- Output the read directly. Do NOT include phrases like "Example", "Good \ +example", "Bad example", "Reference", or any meta-framing of your output. + +{tone_block} + +{analysis_block} +""" + + +def build_summary_user_prompt(group_name: str, quotes: list[dict]) -> str: + parts = [ + f"# Group: {group_name}", + "Indicators (latest reading + 1d/1m/1y/since-anchor change):", + "```json", + json.dumps(quotes, indent=2, default=str)[:12000], + "```", + "\nWrite the 2-3 sentence read for this group now.", + ] + return "\n".join(parts) + + +def build_aggregate_summary_system_prompt(tone: str, analysis: str) -> str: + """System prompt for the cross-group aggregate read shown on the dashboard. + Wider lens than a per-group summary — synthesise across all groups.""" + tone_block = _TONE[_resolve_tone(tone)] + analysis_block = _ANALYSIS.get(analysis.upper(), _ANALYSIS["SPECULATIVE"]) + return f"""You write a single SHORT cross-asset INTERPRETATION (≤80 \ +words, 2-4 sentences) for the dashboard header. The reader is glancing — \ +give them the meaning of the whole tape, not a recap. + +# What this is for +The reader can see every indicator on the dashboard below this paragraph. \ +Your job is NOT to summarise the moves. It is to explain what the moves, \ +**taken together as a system**, mean: which regime is being signalled, \ +which divergences are load-bearing, what fundamental story the cross-asset \ +behaviour tells. + +# Rational vs irrational lens (required at this length too) +The cross-asset tape's value is in the gap between what the underlying \ +factors justify (rational: fundamentals, policy, valuation) and what the \ +crowd is actually doing (irrational: positioning, narrative momentum, \ +flows). At least one of the 2-4 sentences must name this gap or, if the \ +two cohere, explicitly say so. + +# Hard constraints +- Plain prose, ONE paragraph. No markdown, headers, lists, or labels. +- Open IMMEDIATELY with substance. NEVER start with: "I need to", "I'll", \ +"We need to", "Here's", "Let me", "Looking at", "Based on", "Sure", "Summary:", \ +"The data shows", "Across the board". No meta-commentary. +- Identify the single most important **cross-asset implication**: e.g. \ +"rates and credit disagree", "equities outrun fundamentals", "geopolitical \ +risk premium is in commodities but not vol". Cite no more than 3 specific \ +numbers, and only as anchors for the interpretation. +- Multi-week / multi-month horizon. 1-day moves under 2% are noise. +- No buy/sell language. No predictions of specific levels. +- Output the read directly. Do NOT include phrases like "Example", "Good \ +example", "Bad example", "Reference", or any meta-framing of your output. + +{tone_block} + +{analysis_block} +""" + + +def build_aggregate_summary_user_prompt(quotes_by_group: dict[str, list[dict]]) -> str: + parts = [ + "# All indicator groups (latest readings + change windows)", + "```json", + json.dumps(quotes_by_group, indent=2, default=str)[:20000], + "```", + "\nWrite the cross-asset aggregate read now.", + ] + return "\n".join(parts) + + +def build_chat_system_prompt( + tone: str, + analysis: str, + *, + log_content: str | None, + log_generated_at: datetime | None, + quotes_by_group: dict[str, list[dict]], + headlines: list[dict], + reference_line: str | None = None, +) -> str: + """Composed system prompt for the /log chat sidebar. Carries the user's + chosen tone + analysis style and inlines the latest log + market data + + headlines as reference material the model can cite from.""" + parts = [build_system_prompt(tone, analysis), "", _CHAT_OVERRIDES, ""] + if reference_line: + parts.append(f"# Doc reference snapshot\n{reference_line}\n") + if log_content: + ts = log_generated_at.strftime("%Y-%m-%d %H:%M UTC") if log_generated_at else "n/a" + parts.append(f"# Latest strategic log (generated {ts})\n\n{log_content}\n") + parts.append("# Live market data") + parts.append( + "```json\n" + json.dumps(quotes_by_group, indent=2, default=str)[:25000] + "\n```" + ) + parts.append("# Recent headlines (last 24h, thesis-filtered top 50)") + for h in headlines[:50]: + parts.append(f"- [{h['source']}] {h['title']}") + return "\n".join(parts) @dataclass @@ -54,6 +443,172 @@ class LogResult: cost_usd: float | None +def build_user_prompt( + *, + today: datetime, + anchor: str | None, + quotes_by_group: dict[str, list[dict]], + headlines_by_bucket: dict[str, list[dict]], + reference_line: str | None = None, + previous_log: object | None = None, +) -> str: + """Assemble the user message from already-fetched-and-persisted data. + If `previous_log` is a StrategicLog from earlier today, it's included + as 'Update mode' context — the model will revise rather than restart.""" + parts = [ + f"# Strategic log request — {today.strftime('%Y-%m-%d')}", + # Explicit current time so the model doesn't hallucinate one. The + # date header it writes MUST stay date-only (per system prompt). + f"Current time: {today.strftime('%Y-%m-%d %H:%M UTC')}", + ] + if anchor: + parts.append(f"Anchor reference date: {anchor}") + if reference_line: + parts.append( + "\n## Reference snapshot (when the macro thesis was authored)" + f"\n{reference_line}\nCompare live readings against it." + ) + + if previous_log is not None: + gen = getattr(previous_log, "generated_at", None) + ts = gen.strftime("%H:%M UTC") if gen else "earlier today" + parts.append( + f"\n## Earlier log from today (generated {ts})\n" + "Treat this as YOUR OWN earlier draft for today. Update it for\n" + "the current data — don't restate unchanged context. See the\n" + "'Update mode' section of the system prompt for how to handle it.\n" + "```markdown\n" + f"{previous_log.content}\n" + "```" + ) + + parts.append("\n## Live market data (per group)") + parts.append("```json\n" + json.dumps(quotes_by_group, indent=2, default=str) + "\n```") + parts.append("\n## News flow (last 24h, filtered by bucket)") + for label, items in headlines_by_bucket.items(): + if not items: + continue + parts.append(f"\n### {label.upper()}") + for h in items[:30]: + parts.append(f"- [{h['when'][:16].replace('T',' ')}] [{h['source']}] {h['title']}") + + task_line = ( + "\n## Task\nWrite the daily strategic log in ~800 words, following " + "the discipline in the system prompt. No preamble; begin directly " + "with the date header." + ) + if previous_log is not None: + task_line = ( + "\n## Task\nUpdate the earlier log above for the current data. " + "Keep the same structure (date header, TL;DR, sections, watch " + "list, system temperature) but anchor on what has CHANGED since " + "the earlier draft's timestamp. ~800 words. No preamble." + ) + parts.append(task_line) + return "\n".join(parts) + + +def _digest_tone_clause(tone: str) -> str: + if tone.upper() == "NOVICE": + return "Use plain English. Define any jargon on first use." + return "Write for a reader who already speaks markets fluently." + + +def build_daily_digest_prompt( + *, + tone: str, + today, + quotes_by_group: dict, + headlines_by_bucket: dict, + reference_line: str, +) -> tuple[str, str]: + """System + user prompt for the once-a-day editorial digest. + + Different from the hourly log: the daily digest reflects on the past + 24h and looks forward to the upcoming session. Longer, less + 'live-blogging,' more contextual. Target ~600 words.""" + system = ( + "You write the daily editorial digest for Read the Markets. " + f"Audience tone: {tone.upper()}. {_digest_tone_clause(tone)} " + "Cover: (1) what mattered yesterday, (2) what to watch in today's " + "EU and US sessions, (3) one cross-asset thread connecting them. " + "No predictions of price level, no buy/sell language. Target ~600 " + "words. Output HTML using only

      ,

      ,
        ,
      • , , " + " — 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).""" @@ -136,15 +691,10 @@ async def _call_provider( messages: list[dict], model: str | None, max_tokens: int, - response_format: dict | None = None, ) -> LogResult: """One provider call with tenacity retries on transport/HTTP errors. Lives inside the retry decorator so retries happen within a provider, - not across the fallback chain. - - `response_format` is forwarded to the provider verbatim — DeepSeek and - OpenRouter both accept the OpenAI-shaped {"type": "json_object"} for - JSON-mode generation. None means free-form text.""" + not across the fallback chain.""" url, api_key, default_model, extra_headers = _endpoint_for(provider) used_model = model or default_model headers = { @@ -152,22 +702,18 @@ async def _call_provider( "Content-Type": "application/json", **extra_headers, } - body: dict = {"model": used_model, "messages": messages, "max_tokens": max_tokens} - if response_format is not None: - body["response_format"] = response_format - r = await client.post(url, headers=headers, json=body, timeout=180) + r = await client.post( + url, + headers=headers, + json={"model": used_model, "messages": messages, "max_tokens": max_tokens}, + timeout=180, + ) r.raise_for_status() data = r.json() msg = data["choices"][0]["message"] - # The `content` field is the model's user-facing answer. The optional - # `reasoning` field is the model's internal chain-of-thought — never - # safe to publish; it contains raw scratchpad ("Let's see…", - # mid-sentence question marks, planning notes). If `content` is empty - # (provider issue, finish_reason=length cutoff, or the model spent - # its budget on thinking), treat that as a generation failure and - # raise so the caller can retry or skip the row. Do NOT fall back to - # reasoning — see the 2026-05-29 valuation-read leak. - content = msg.get("content") + # Some providers return null content + populated `reasoning` for thinking + # models, or null content when finish_reason=length cut off the response. + content = msg.get("content") or msg.get("reasoning") if not content: finish = data["choices"][0].get("finish_reason") raise RuntimeError( @@ -175,21 +721,13 @@ async def _call_provider( f"provider={provider}, model={used_model}, max_tokens={max_tokens})" ) usage = data.get("usage") or {} - prompt_tokens = usage.get("prompt_tokens") - completion_tokens = usage.get("completion_tokens") - # OpenRouter populates `usage.cost`; DeepSeek's native API doesn't — - # estimate from tokens × per-model rates so the cost ledger stays - # populated regardless of which provider answered. - cost_usd = usage.get("cost") or usage.get("total_cost") - if cost_usd is None: - cost_usd = _estimate_cost_usd(used_model, prompt_tokens, completion_tokens) return LogResult( content=content, # Record provider+model so admin can see which path produced this row. model=f"{provider}/{used_model}", - prompt_tokens=prompt_tokens, - completion_tokens=completion_tokens, - cost_usd=cost_usd, + prompt_tokens=usage.get("prompt_tokens"), + completion_tokens=usage.get("completion_tokens"), + cost_usd=usage.get("cost") or usage.get("total_cost"), ) @@ -198,8 +736,6 @@ async def call_llm( messages: list[dict], model: str | None = None, max_tokens: int = 4000, - response_format: dict | None = None, - provider: str | None = None, ) -> LogResult: """Provider-aware chat completion with fallback. Tries primary (LLM_PROVIDER) first; if it raises after retries, falls through to @@ -208,20 +744,8 @@ async def call_llm( The returned LogResult.model is prefixed with the provider that actually answered (e.g. ``deepseek/deepseek-v4-flash`` or ``openrouter/deepseek/deepseek-v4-flash``) — useful admin metadata - even though we hide it from the user-facing UI. - - Pass response_format={"type": "json_object"} to force JSON-mode - output (the model still needs to be instructed in the system prompt - to emit valid JSON — this flag enforces, not asks). - - Pass `provider` (e.g. "openrouter") to skip the configured chain - and pin the call to a specific provider. Used by the reviewer agent - to force routing through OpenRouter so it can address a non-DeepSeek - model that doesn't pre-think before emitting JSON.""" - if provider is not None: - chain = [provider] - else: - chain = _provider_chain() + even though we hide it from the user-facing UI.""" + chain = _provider_chain() if not chain: raise RuntimeError("No LLM provider configured (no API key set)") @@ -230,7 +754,6 @@ async def call_llm( try: result = await _call_provider( client, provider, messages, model, max_tokens, - response_format=response_format, ) if i > 0: from app.logging import get_logger @@ -252,6 +775,10 @@ async def call_llm( raise last_exc +# Back-compat alias for any straggling import sites. +call_openrouter = call_llm + + def month_window() -> tuple[datetime, datetime]: """[start, now] in UTC for the current calendar month.""" now = datetime.now(timezone.utc) diff --git a/app/services/output_review.py b/app/services/output_review.py deleted file mode 100644 index 4fbb2fb..0000000 --- a/app/services/output_review.py +++ /dev/null @@ -1,162 +0,0 @@ -"""Second-pass reviewer agent for AI-generated reads. - -The per-group and aggregate indicator summaries are generated in JSON -mode and the publishable text comes out of a single "read" field, but a -misbehaving model can still slip chain-of-thought INSIDE the field -("Let's see…", "X? Actually Y?", multi-question parentheticals). This -module makes a small second LLM call that judges the candidate read as -clean / unclean. Cost is ~$0.0001 per check; latency ~1-2 s in the -hourly job. No user-facing latency. - -The reviewer is deliberately a tiny, JSON-shaped classifier — same -JSON-mode mechanism as the generator, so the verdict can't be lost in -prose. If parsing fails or the call errors, the row is rejected -(fail-safe: the previously cached good summary stays visible). -""" -from __future__ import annotations - -import json -from dataclasses import dataclass - -import httpx - -from app.config import get_settings -from app.logging import get_logger -from app.services.openrouter import call_llm - -log = get_logger("output_review") - - -# The reviewer runs through OpenRouter against a small, non-thinking -# model. DeepSeek-V4-flash (our generator default) emits internal -# chain-of-thought before its JSON output even when the prompt forbids -# it, which truncates the JSON at any reasonable max_tokens cap and -# breaks the parser. Anthropic's Haiku family answers structured-output -# tasks tersely and deterministically — no chain-of-thought tax. Cost -# is ~$0.0001-$0.0003 per review depending on candidate length. -DEFAULT_REVIEWER_MODEL = "anthropic/claude-haiku-4.5" - - -_SYSTEM_PROMPT = """\ -You are a strict editor for a financial-markets dashboard. The author -was asked to produce editorial commentary on public market data for -human readers. You receive the proposed text — it may be a one-line -read, a multi-paragraph daily log, a portfolio analysis, a chat -reply, or an email digest — and decide if it is publishable as-is. - -Mark CLEAN only if the text reads like finished editorial commentary -a reader could see on a public dashboard without confusion. - -Mark UNCLEAN if the text contains ANY of: -- Chain-of-thought / scratchpad markers — the author thinking on the - page rather than presenting finished commentary. Phrases like - "Let me", "Let's see", "we need to", "actually" (correcting itself), - "wait", "hmm", "or rather", "I should". Rhetorical questions used - as structure are fine; questions that the author then answers in - front of the reader (self-questioning) are not. -- Self-questioning parentheticals: "Q1 2026? Actually Q4 2025?", - "is it X or Y?", any place where the author appears to be working - out the answer in front of the reader. -- Meta-commentary about the task, output format, word limits, or - instructions — e.g. "as required by the constraints", "the prompt - asks", "let me address each". -- Partial / truncated content. Starts mid-word, mid-number, mid-clause, - ends mid-thought. -- Visible internal numbers without clear meaning ("change 1y +5.9%?"), - raw column names ("as_of 2026-01-01"), or any debug-like fragments. -- FINANCIAL ADVICE or any phrasing that recommends an action the - reader should take. This service is editorial commentary on public - data, not investment advice; the operator is not licensed to give - it. Reject any of: - * Buy/sell/hold/accumulate/trim/exit/enter/rotate language. - * Allocation guidance ("overweight", "underweight", - "X% in bonds", "increase exposure to"). - * Price targets or specific level predictions ("will reach $X", - "target Y", "expect Z by year-end"). - * Personalised framing ("you should", "investors should", - "consider buying", "we recommend"). - DESCRIPTIVE / INTERPRETIVE language about market state is fine — - "valuations are stretched", "real yields are restrictive", "rates - and credit disagree". The test: does the text describe a STATE, or - does it suggest an ACTION? States are fine; actions are not. -- Anything else other than the finished, publishable commentary. - -Return ONLY a JSON object with this exact shape: -{"clean": true | false, "reason": "<≤20 words, plain text>"} -No preamble, no markdown fences, no other fields. -""" - - -@dataclass(frozen=True) -class Verdict: - clean: bool - reason: str - cost_usd: float | None # cost of the review call itself, for the ledger - - -async def review_read(client: httpx.AsyncClient, candidate: str) -> Verdict: - """Ask the LLM whether `candidate` is a publishable read. - - Returns Verdict(clean, reason, cost). Any error — provider failure, - JSON parse failure, missing field, wrong type — yields a CONSERVATIVE - verdict (clean=False) so the caller drops the candidate. The - previously cached good summary stays visible on the dashboard.""" - if not candidate or not candidate.strip(): - return Verdict(clean=False, reason="empty candidate", cost_usd=0.0) - - messages = [ - {"role": "system", "content": _SYSTEM_PROMPT}, - # Sent as a fenced user turn so the model can't confuse the - # candidate with instructions, even if the candidate happens to - # contain prompt-like prose. - {"role": "user", "content": f"Candidate read:\n```\n{candidate}\n```"}, - ] - settings = get_settings() - reviewer_model = getattr(settings, "REVIEWER_MODEL", None) or DEFAULT_REVIEWER_MODEL - try: - result = await call_llm( - client, messages, - # Pin to OpenRouter so a non-DeepSeek model like Haiku is - # actually reachable; the default provider chain would try - # DeepSeek native first and 404 on the Anthropic model name. - provider="openrouter", - model=reviewer_model, - # 300 tokens is well above the ~30-token JSON verdict. - # Haiku doesn't pad with hidden reasoning the way DeepSeek - # does, so we don't need the 800-token headroom required to - # absorb the generator's chain-of-thought. - max_tokens=300, - response_format={"type": "json_object"}, - ) - except Exception as e: - log.warning("review.call_failed", error=str(e)[:200]) - return Verdict(clean=False, reason=f"reviewer error: {str(e)[:80]}", - cost_usd=None) - - # Haiku (and several other models) occasionally wrap their JSON - # output in a markdown code fence even with response_format set — - # ```json\n{...}\n``` — so strip a single leading/trailing fence - # before parsing. We do this defensively for any model; it's a - # no-op for callers that already emit bare JSON. - raw = result.content.strip() - if raw.startswith("```"): - first_nl = raw.find("\n") - if first_nl != -1: - raw = raw[first_nl + 1:] - if raw.rstrip().endswith("```"): - raw = raw.rstrip()[:-3].rstrip() - raw = raw.strip() - - try: - parsed = json.loads(raw) - except json.JSONDecodeError: - log.warning("review.parse_failed", preview=result.content[:200]) - return Verdict(clean=False, reason="reviewer returned non-JSON", - cost_usd=result.cost_usd) - - clean = parsed.get("clean") - reason = parsed.get("reason") or "" - if not isinstance(clean, bool): - return Verdict(clean=False, reason="reviewer omitted bool 'clean'", - cost_usd=result.cost_usd) - return Verdict(clean=clean, reason=str(reason)[:200], cost_usd=result.cost_usd) diff --git a/app/services/portfolio_analysis.py b/app/services/portfolio_analysis.py index 1f6bea7..eb8a349 100644 --- a/app/services/portfolio_analysis.py +++ b/app/services/portfolio_analysis.py @@ -31,12 +31,10 @@ from app.config import get_settings from app.db import utcnow from app.logging import get_logger from app.models import AICall -from app.services.i18n import LANGUAGES, respond_in_clause -from app.services.llm_prompts import build_system_prompt -from app.services.output_review import review_read from app.services.openrouter import ( LogResult, active_model, + build_system_prompt, call_llm, ) @@ -76,7 +74,6 @@ class AnalysisRequest: anchor: str | None = None tone: str = "INTERMEDIATE" # NOVICE | INTERMEDIATE | PRO analysis: str = "SPECULATIVE" # DRY | SPECULATIVE - lang: str = "en" @dataclass @@ -166,13 +163,10 @@ def parse_request(payload: dict) -> AnalysisRequest: anchor = _sanitise_text(payload.get("anchor") or "", 32) or None tone = _sanitise_text(payload.get("tone", "INTERMEDIATE"), 16) or "INTERMEDIATE" analysis = _sanitise_text(payload.get("analysis", "SPECULATIVE"), 16) or "SPECULATIVE" - lang = (payload.get("lang") or "en").strip().lower() - if lang not in LANGUAGES: - lang = "en" return AnalysisRequest( positions=positions, prices=prices, base_currency=base_currency, - anchor=anchor, tone=tone, analysis=analysis, lang=lang, + anchor=anchor, tone=tone, analysis=analysis, ) @@ -282,7 +276,7 @@ def build_prompt(req: AnalysisRequest) -> tuple[str, str]: head = enriched[:MAX_POSITIONS_INLINED] tail_count = max(0, len(enriched) - MAX_POSITIONS_INLINED) - system = build_system_prompt(req.tone, req.analysis) + "\n\n" + _SYSTEM_OVERRIDES + respond_in_clause(req.lang) + system = build_system_prompt(req.tone, req.analysis) + "\n\n" + _SYSTEM_OVERRIDES user_parts = [ f"# Portfolio commentary request — {utcnow().strftime('%Y-%m-%d')}", @@ -323,8 +317,6 @@ async def analyse( s = get_settings() system, user = build_prompt(req) - review_cost = 0.0 - review_reason: str | None = None async with httpx.AsyncClient() as client: try: llm: LogResult = await call_llm( @@ -343,31 +335,15 @@ async def analyse( llm = None log.error("portfolio_analysis.failed", error=error_msg) - # Reviewer gate. This is the highest-risk surface — the model is - # commenting on a real user's holdings, so any drift into - # buy/sell or allocation language is a regulatory hazard. Drop - # the response on a reject and surface a retry-able error to the - # caller; no analysis is ever persisted server-side anyway. - if llm is not None: - verdict = await review_read(client, llm.content) - review_cost = verdict.cost_usd or 0.0 - if not verdict.clean: - status = "leaked" - error_msg = f"reviewer rejected: {verdict.reason}" - review_reason = verdict.reason - log.warning("portfolio_analysis.reviewer_rejected", - reason=verdict.reason, preview=llm.content[:120]) - - full_cost = ((llm.cost_usd or 0.0) + review_cost) if llm else None # Ledger row — NO portfolio data, just metadata. Same row whether the - # call succeeded, failed, or was rejected by the reviewer, so - # cost-cap and rate-limit logic can observe the attempt. + # call succeeded or failed, so cost-cap and rate-limit logic can + # observe the attempt. session.add(AICall( called_at=utcnow(), model=llm.model if llm else active_model(), prompt_tokens=llm.prompt_tokens if llm else None, completion_tokens=llm.completion_tokens if llm else None, - cost_usd=full_cost, + cost_usd=llm.cost_usd if llm else None, status=status, error=error_msg, )) @@ -375,26 +351,19 @@ async def analyse( if llm is None: raise RuntimeError(error_msg or "portfolio analysis failed") - if review_reason is not None: - # Reviewer rejected the candidate. Treat as a generation failure - # at the API layer so the user sees a retry-able error rather - # than potentially non-compliant advice. - raise RuntimeError( - "AI analysis couldn't be generated cleanly — please try again." - ) log.info( "portfolio_analysis.ok", n_positions=len(req.positions), prompt_tokens=llm.prompt_tokens, completion_tokens=llm.completion_tokens, - cost_usd=full_cost, + cost_usd=llm.cost_usd, ) return AnalysisResult( content=llm.content, model=llm.model, prompt_tokens=llm.prompt_tokens, completion_tokens=llm.completion_tokens, - cost_usd=full_cost, + cost_usd=llm.cost_usd, generated_at=datetime.now(timezone.utc), ) diff --git a/app/services/translation.py b/app/services/translation.py deleted file mode 100644 index 96f99ed..0000000 --- a/app/services/translation.py +++ /dev/null @@ -1,88 +0,0 @@ -"""Markdown translation via the existing LLM provider chain. - -DeepSeek-4-flash at ~$0.28/M output tokens is cheap enough that we -don't bother with a separate translation-only model. ``call_llm``'s -provider chain (DeepSeek primary, OpenRouter fallback) handles this -path identically to any other LLM call. - -The translator is content-aware in one important way: it instructs the -model to preserve markdown structure, ticker symbols, numbers, dates, -and percentages verbatim. This keeps generated artefacts (tables of -quotes, embedded percentages, dated references) intact across the -translation boundary. -""" -from __future__ import annotations - -import httpx - -from app.services.i18n import LANGUAGES -from app.services.openrouter import LogResult, call_llm - - -_SYSTEM_PROMPT_TMPL = """\ -You are an expert translator working on financial-markets commentary. -Translate the following markdown text to {language}. - -Strict rules: -- Preserve ALL markdown formatting (headings, lists, emphasis, links, - tables, code spans). -- Do NOT translate ticker symbols (AAPL, MSFT, VOD.L, ASML.AS, etc.), - company legal names, percentages, dates, ISO currency codes, or any - numbers. -- Do NOT add commentary, preambles, or apologies. Output ONLY the - translated markdown. -""" - - -async def translate( - client: httpx.AsyncClient, - text: str, - target_lang: str, -) -> tuple[str, LogResult]: - """Translate markdown ``text`` to ``target_lang``. - - Returns ``(translated_markdown, LogResult)``. Caller persists the - cost/model provenance from LogResult next to the cached row. - - Short-circuits without calling the LLM when ``target_lang`` is - ``'en'``, unknown, or empty — returns the source unchanged with a - zero-cost stub LogResult. This lets fan-out callers iterate over - all languages without per-call gating. - - Raises on provider failure (HTTP error, all chain providers down). - Callers in fan-out paths should catch and log per-language. - """ - if not target_lang or target_lang == "en" or target_lang not in LANGUAGES: - # No-op fast path. Returning a fake LogResult keeps the call - # signature stable for callers who unpack the tuple. - return text, LogResult( - content=text, model="noop", - prompt_tokens=0, completion_tokens=0, cost_usd=0.0, - ) - - system_prompt = _SYSTEM_PROMPT_TMPL.format(language=LANGUAGES[target_lang]) - messages = [ - {"role": "system", "content": system_prompt}, - {"role": "user", "content": text}, - ] - # Italian / Spanish / French / German typically expand the token count - # 15-25 % over English (longer words, more sub-word splits). Our - # strategic-log generator runs up to its own 4000-token cap, so a 4000 - # cap here would silently truncate any near-cap source. 8000 gives - # ample headroom for every language we currently support and costs - # nothing extra unless the model actually emits more tokens. - result = await call_llm(client, messages, max_tokens=8000) - - content = (result.content or "").strip() - # Strip code fences if the model wrapped its output despite the system rule. - if content.startswith("```"): - # Drop the opening fence (with optional language tag). - first_nl = content.find("\n") - if first_nl != -1: - content = content[first_nl + 1:] - # Drop the closing fence. - if content.rstrip().endswith("```"): - content = content.rstrip()[:-3].rstrip() - content = content.strip() - - return content, result diff --git a/app/static/css/auth.css b/app/static/css/auth.css deleted file mode 100644 index 31081bb..0000000 --- a/app/static/css/auth.css +++ /dev/null @@ -1,149 +0,0 @@ -/* Cassandra — auth pages: login, sign-up, OTP verify (standalone, no app chrome). */ - -/* --- Auth pages (login / signup, standalone — no app chrome) -------- */ - -.auth-shell { - min-height: 100vh; - display: flex; - align-items: center; - justify-content: center; - background: var(--bg); - padding: 20px; -} -.auth-card { - width: 360px; - max-width: 100%; - background: var(--surface); - border: 1px solid var(--border); - padding: 28px 26px; -} -.auth-card__brand { - font-family: var(--font-mono); - color: var(--accent); - font-size: 18px; - letter-spacing: 0.12em; - text-transform: uppercase; - font-weight: 700; -} -.auth-card__brand::before { content: "▰ "; opacity: 0.6; } -.auth-card__hint { - font-family: var(--font-mono); - color: var(--muted); - font-size: 10px; - text-transform: uppercase; - letter-spacing: 0.08em; - margin: 2px 0 18px; -} -.auth-card form { display: flex; flex-direction: column; gap: 12px; } -.auth-card label { - display: flex; - flex-direction: column; - font-family: var(--font-mono); - color: var(--muted); - font-size: 10px; - text-transform: uppercase; - letter-spacing: 0.06em; - gap: 4px; -} -.auth-card input[type="email"], -.auth-card input[type="password"], -.auth-card input[type="text"] { - background: var(--bg); - border: 1px solid var(--border); - color: var(--text); - font-family: var(--font-mono); - font-size: 16px; - padding: 12px 14px; - outline: none; - border-radius: 3px; -} -/* The 6-digit OTP input wants to be visually loud — it's the only - thing the user is doing on that page. Bigger, more spacing, taller. */ -.auth-card input[name="code"] { - font-size: 24px; - padding: 16px 14px; - letter-spacing: 0.5em; - text-align: center; -} -.auth-card input:focus { border-color: var(--accent); } -.auth-card button { - margin-top: 8px; - background: transparent; - border: 1px solid var(--accent); - color: var(--accent); - font-family: var(--font-mono); - font-size: 11px; - padding: 9px 12px; - text-transform: uppercase; - letter-spacing: 0.1em; - cursor: pointer; -} -.auth-card button:hover { background: var(--accent); color: var(--bg); } -.auth-card__alt { - margin-top: 18px; - font-size: 12px; - color: var(--muted); - text-align: center; -} -.auth-error { - border-left: 3px solid var(--negative); - background: color-mix(in srgb, var(--negative) 6%, transparent); - color: var(--negative); - padding: 8px 10px; - font-size: 12px; - margin-bottom: 14px; - font-family: var(--font-mono); -} -.auth-info { - border-left: 3px solid var(--accent); - background: color-mix(in srgb, var(--accent) 6%, transparent); - color: var(--accent); - padding: 8px 10px; - font-size: 12px; - margin-bottom: 14px; - font-family: var(--font-mono); -} -.auth-info--invited { - /* Slightly warmer / friendlier shading for the referral banner. */ - border-left-color: var(--positive); - background: color-mix(in srgb, var(--positive) 7%, transparent); - color: var(--text); - font-family: var(--font-sans); - font-size: 13px; - line-height: 1.5; -} -.auth-info--invited strong { color: var(--positive); font-weight: 600; } -.auth-card__lede { - font-size: 12.5px; - color: var(--muted); - margin: 0 0 16px; - line-height: 1.5; -} -.auth-card__lede strong { color: var(--text); font-weight: normal; } -.auth-card__resend { - background: transparent !important; - color: var(--muted) !important; - border: 1px dashed var(--border) !important; - font-size: 11px !important; -} -.auth-card__resend:hover { - color: var(--accent) !important; - border-color: var(--accent) !important; -} - - -/* --- Mobile (≤480px) -------------------------------------------------- */ - -@media (max-width: 480px) { - /* The card is already width:360px;max-width:100% so it fills the - screen — just tighten internal padding to free up vertical space - for the keyboard on iOS Safari (which eats half the viewport). */ - .auth-card { padding: 20px 18px; } - .auth-card__brand { font-size: 14px; } - .auth-card__lede { font-size: 12px; } - .auth-card input, - .auth-card button[type="submit"] { - font-size: 14px; /* avoids iOS Safari zoom-on-focus */ - padding: 10px 12px; - } -} diff --git a/app/static/css/cassandra.css b/app/static/css/cassandra.css new file mode 100644 index 0000000..b4b6f6b --- /dev/null +++ b/app/static/css/cassandra.css @@ -0,0 +1,2546 @@ +/* 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 %} @@ -68,5 +69,5 @@ {% endif %}

-{% if paid %}{% endif %} +{% if paid %}{% endif %} {% endblock %} diff --git a/app/templates/login.html b/app/templates/login.html index 2cfc899..66816c1 100644 --- a/app/templates/login.html +++ b/app/templates/login.html @@ -10,9 +10,7 @@ catch (e) { document.documentElement.dataset.theme = 'light'; } })(); - - - +
diff --git a/app/templates/partials/indicators.html b/app/templates/partials/indicators.html index 70aaab0..0ae1e1f 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 99d4dc0..18061f9 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 c32fb26..93f1562 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 any broker CSV — Trading 212 natively, other formats auto-detected
  • +
  • Portfolio import from a broker CSV (Trading 212 supported today; more brokers planned)
  • 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 b1cef24..77e4186 100644 --- a/app/templates/public_base.html +++ b/app/templates/public_base.html @@ -14,12 +14,7 @@ } catch (e) { document.documentElement.dataset.theme = 'light'; } })(); - - - - - - +
    diff --git a/app/templates/settings.html b/app/templates/settings.html index ac0107d..20dfa57 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 %}> Intermediate
    @@ -224,47 +224,6 @@ })(); - {# --- 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) @@ -301,7 +260,7 @@