From 47dce1a1a42db9d4923c93186b37dcd9ab907713 Mon Sep 17 00:00:00 2001 From: Giorgio Gilestro Date: Fri, 29 May 2026 19:57:12 +0200 Subject: [PATCH] compliance: flag-gate AI portfolio + cloud sync + Stripe; de-risk prompts; harden reviewer Implements docs/read-markets-compliance-changes.md as flag-gated changes (no deletions) so paused features stay in the tree for future re-enable. All four flags default False so a fresh deploy is compliance-safe. - New env flags: PORTFOLIO_AI_ENABLED, PORTFOLIO_SYNC_ENABLED, TICKER_UNIVERSE_AGGREGATE_ENABLED, SUBSCRIPTIONS_ENABLED. - Gates: /api/analyze, /api/portfolio/sync*, /api/stripe/*, /pricing, ticker_universe writes, portfolio_analysis.analyse(). is_paid_active() returns True for any auth'd user when subscriptions are paused. - Prompts (PROMPT_VERSION 10): universal _COMPLIANCE_RIDER prepended to every system prompt; watch list removed; price-target / close-above-below / trigger / forward-state-as-description rules added; SPECULATIVE pivoted to regime-only scenarios; daily + weekly digests tightened. - Reviewer: deterministic regex/lexicon pre-check fail-closed under the Haiku call; portfolio rider gated by PORTFOLIO_AI_ENABLED; base prompt sharpened for forward-state and MAR forward-opinion patterns; ReviewerVerdict audit table; generate_with_review retry helper. - Migration 0026: purge portfolio_sync + ticker_universe; create reviewer_verdicts. - Copy: MAR cite fixed to Art 3(1)(35) + Art 20 + Del Reg 2016/958; portfolio reframed as browser-only viewer in disclaimer / privacy / terms / about / pricing / landing (en + it). TODO(legal) marker for lawyer sign-off on disclaimer. - Tests: 13 lexicon + 6 reviewer compliance regressions; conftest enables all flags so existing 402 tests still cover their code paths. Co-Authored-By: Claude Opus 4.7 --- .env.example | 8 + .../0026_compliance_purge_and_audit.py | 75 +++++ app/config.py | 19 ++ app/jobs/ai_log_job.py | 3 +- app/jobs/indicator_summary_job.py | 8 +- app/locales/en.yaml | 19 +- app/locales/it.yaml | 18 +- app/models.py | 26 ++ app/routers/chat.py | 3 +- app/routers/public.py | 7 +- app/routers/stripe_billing.py | 8 +- app/routers/sync.py | 8 +- app/routers/universe.py | 10 +- app/services/access.py | 12 +- app/services/feature_flags.py | 41 +++ app/services/llm_prompts.py | 163 ++++++++--- app/services/output_review.py | 262 +++++++++++++++--- app/services/output_review_lexicon.py | 161 +++++++++++ app/services/portfolio_analysis.py | 7 +- app/services/ticker_universe.py | 9 + app/static/js/portfolio.js | 24 +- app/static/js/settings-import.js | 39 ++- app/templates/about.html | 10 +- app/templates/base.html | 2 + app/templates/dashboard.html | 4 +- app/templates/disclaimer.html | 45 +-- app/templates/landing.html | 4 +- app/templates/partials/news.html | 2 +- app/templates/partials/portfolio.html | 3 + app/templates/pricing.html | 48 +--- app/templates/privacy.html | 112 +++----- app/templates/public_base.html | 4 +- app/templates/settings.html | 14 +- app/templates/terms.html | 37 ++- app/templates_env.py | 6 + tests/conftest.py | 11 + tests/test_output_review.py | 157 ++++++++++- tests/test_output_review_lexicon.py | 78 ++++++ 38 files changed, 1188 insertions(+), 279 deletions(-) create mode 100644 alembic/versions/0026_compliance_purge_and_audit.py create mode 100644 app/services/feature_flags.py create mode 100644 app/services/output_review_lexicon.py create mode 100644 tests/test_output_review_lexicon.py diff --git a/.env.example b/.env.example index 889bd4d..83f4f24 100644 --- a/.env.example +++ b/.env.example @@ -25,3 +25,11 @@ OPENROUTER_MODEL=deepseek/deepseek-v4-flash # cheap & fast; swap to anthropic OPENROUTER_MONTHLY_CAP_USD=20 CASSANDRA_TONE=INTERMEDIATE # NOVICE | INTERMEDIATE | PRO CASSANDRA_ANALYSIS=SPECULATIVE # DRY | SPECULATIVE + +# --- Compliance feature flags (default false = compliance-safe) --- +# See docs/read-markets-compliance-changes.md. Code paths stay in the tree; +# flip a flag to reactivate. +PORTFOLIO_AI_ENABLED=false # /api/analyze + dashboard AI read + reviewer portfolio rider +PORTFOLIO_SYNC_ENABLED=false # /api/portfolio/sync* + cloud-sync UI + PortfolioSync writes +TICKER_UNIVERSE_AGGREGATE_ENABLED=false # ticker_universe buffer/flush/upsert writes (server-learns-nothing) +SUBSCRIPTIONS_ENABLED=false # Stripe checkout/webhook/portal + /pricing + paid-tier gating diff --git a/alembic/versions/0026_compliance_purge_and_audit.py b/alembic/versions/0026_compliance_purge_and_audit.py new file mode 100644 index 0000000..1738fcf --- /dev/null +++ b/alembic/versions/0026_compliance_purge_and_audit.py @@ -0,0 +1,75 @@ +"""compliance purge (portfolio_sync, ticker_universe) + reviewer_verdicts audit table. + +Revision ID: 0026 +Revises: 0025 +Create Date: 2026-05-29 + +See docs/read-markets-compliance-changes.md. + +Two unrelated changes bundled into one migration because they ship together: + +1. Purge — the server learns nothing about anyone's holdings while the + compliance flags are off. Empties ``portfolio_sync`` (per-user encrypted + ciphertext blobs) and ``ticker_universe`` (the anonymous aggregate set of + tickers ever uploaded). Tables stay; data goes. If a flag is later + re-enabled, the tables refill from scratch. + +2. Audit — adds ``reviewer_verdicts`` so every output-reviewer decision + (deterministic + LLM, pass and fail) is persisted with surface, candidate + text, reason, layer, and model. This is the regulator-facing evidence that + automated review runs on every published item. + +Downgrade restores neither the purged data nor the historical verdicts — +both are destructive; downgrade just drops the audit table. +""" +from typing import Sequence, Union + +import sqlalchemy as sa +from alembic import op + + +revision: str = "0026" +down_revision: Union[str, None] = "0025" +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + # 1. Purge — server holds no portfolio data. + op.execute("DELETE FROM portfolio_sync") + op.execute("DELETE FROM ticker_universe") + + # 2. Audit trail for the two-layer output reviewer. Append-only. + op.create_table( + "reviewer_verdicts", + sa.Column("id", sa.BigInteger().with_variant(sa.Integer(), "sqlite"), + primary_key=True, autoincrement=True), + sa.Column("created_at", sa.DateTime(timezone=True), nullable=False, + server_default=sa.text("CURRENT_TIMESTAMP")), + sa.Column("surface", sa.String(length=32), nullable=True), + sa.Column("candidate_text", sa.Text(), nullable=False), + sa.Column("clean", sa.Boolean(), nullable=False), + sa.Column("reason", sa.String(length=255), nullable=True), + sa.Column("layer", sa.String(length=16), nullable=False), + sa.Column("model", sa.String(length=64), nullable=True), + ) + op.create_index( + "ix_reviewer_verdicts_created_at", + "reviewer_verdicts", ["created_at"], + ) + op.create_index( + "ix_reviewer_verdicts_surface", + "reviewer_verdicts", ["surface"], + ) + op.create_index( + "ix_reviewer_verdicts_clean", + "reviewer_verdicts", ["clean"], + ) + + +def downgrade() -> None: + op.drop_index("ix_reviewer_verdicts_clean", table_name="reviewer_verdicts") + op.drop_index("ix_reviewer_verdicts_surface", table_name="reviewer_verdicts") + op.drop_index("ix_reviewer_verdicts_created_at", table_name="reviewer_verdicts") + op.drop_table("reviewer_verdicts") + # Purges are not restored on downgrade — the data is gone. diff --git a/app/config.py b/app/config.py index 6c5b0ee..975f165 100644 --- a/app/config.py +++ b/app/config.py @@ -105,6 +105,25 @@ class Settings(BaseSettings): STRIPE_PRICE_MONTHLY: str = "" # price_xxx for £7/month subscription STRIPE_PRICE_ANNUAL: str = "" # price_xxx for £70/year subscription + # Compliance feature flags. All default False so a fresh deploy is on the + # compliance-safe side. Code paths stay in the tree — flip a flag to + # reactivate. See docs/read-markets-compliance-changes.md. + # + # PORTFOLIO_AI_ENABLED — gates /api/analyze, portfolio_analysis.analyse(), + # the AI-read UI on the dashboard, and the output-reviewer portfolio rider. + # PORTFOLIO_SYNC_ENABLED — gates /api/portfolio/sync* routes, portfolio_sync + # service, the cloud-sync UI/JS, and PortfolioSync writes. + # TICKER_UNIVERSE_AGGREGATE_ENABLED — gates server-side per-ticker aggregate + # union writes (ticker_universe.buffer/flush/upsert). When off, the server + # learns nothing about what anyone holds. + # SUBSCRIPTIONS_ENABLED — gates Stripe checkout/webhook/portal, the /pricing + # page, and is_paid_active(). When off, is_paid_active() returns True for + # every logged-in user (free-for-all while subscriptions are paused). + PORTFOLIO_AI_ENABLED: bool = False + PORTFOLIO_SYNC_ENABLED: bool = False + TICKER_UNIVERSE_AGGREGATE_ENABLED: bool = False + SUBSCRIPTIONS_ENABLED: bool = False + # Config file locations (overridable for tests) BASELINE_TOML: Path = Field(default_factory=lambda: CONFIG_DIR / "default.toml") PORTFOLIO_TOML: Path = Field(default_factory=lambda: CONFIG_DIR / "portfolio.toml") diff --git a/app/jobs/ai_log_job.py b/app/jobs/ai_log_job.py index 197faa5..fdf2a23 100644 --- a/app/jobs/ai_log_job.py +++ b/app/jobs/ai_log_job.py @@ -206,7 +206,8 @@ async def run() -> None: # 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) + verdict = await review_read(client, result.content, + surface="log", session=session) full_cost = (result.cost_usd or 0.0) + (verdict.cost_usd or 0.0) if not verdict.clean: session.add(AICall( diff --git a/app/jobs/indicator_summary_job.py b/app/jobs/indicator_summary_job.py index 422c49c..4a81aaf 100644 --- a/app/jobs/indicator_summary_job.py +++ b/app/jobs/indicator_summary_job.py @@ -184,7 +184,8 @@ async def _generate_one( )) return None - verdict = await review_read(client, candidate) + verdict = await review_read(client, candidate, + surface="indicator", session=session) if not verdict.clean: # Reviewer caught scratchpad / meta-commentary / partial text # INSIDE the read field. Drop the candidate; the previous good @@ -314,7 +315,10 @@ async def run() -> None: cost_usd=result.cost_usd, status="leaked", )) else: - verdict = await review_read(client, candidate) + verdict = await review_read( + client, candidate, + surface="indicator_aggregate", session=session, + ) 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", diff --git a/app/locales/en.yaml b/app/locales/en.yaml index a36e314..d8c9bb4 100644 --- a/app/locales/en.yaml +++ b/app/locales/en.yaml @@ -77,12 +77,11 @@ features: every hour; free users get one every six. multilang_callout: >- - Every AI-generated surface — strategic log, indicator reads, - portfolio analysis, chat, daily digest — is available in - English and Italian. Toggle the - language pill in the header and the panels refresh in place; - ticker symbols, currency codes and numbers stay verbatim across - languages. + Every AI-generated surface — strategic log, indicator reads, chat, + daily digest — is available in English and + Italian. Toggle the language pill in the header + and the panels refresh in place; ticker symbols, currency codes + and numbers stay verbatim across languages. more_views: head: "More views" @@ -96,10 +95,10 @@ more_views: caption_span: "Conversational follow-ups with the day's context loaded." portfolio_blurb: >- - Paid users can also drop a portfolio CSV from their broker for an - AI sense-check on concentration, regime fit, and currency - exposure. Holdings stay in your browser by default; opt in to - encrypted cloud sync to restore on another device. + Drop a portfolio CSV from your broker to see your sector, currency + and concentration breakdown — computed entirely in your browser. + Holdings stay in your browser; nothing about them is sent to or + stored on the server. not_strip: head: "What this isn't." diff --git a/app/locales/it.yaml b/app/locales/it.yaml index b0188aa..51a8417 100644 --- a/app/locales/it.yaml +++ b/app/locales/it.yaml @@ -82,10 +82,10 @@ features: multilang_callout: >- Ogni superficie generata dall'IA — log strategico, letture degli - indicatori, analisi di portafoglio, chat, digest giornaliero — è - disponibile in Inglese e Italiano. - Usa il selettore di lingua nell'header e i pannelli si aggiornano - in tempo reale; simboli ticker, codici valuta e numeri restano + indicatori, chat, digest giornaliero — è disponibile in + Inglese e Italiano. Usa il + selettore di lingua nell'header e i pannelli si aggiornano in + tempo reale; simboli ticker, codici valuta e numeri restano invariati tra le lingue. more_views: @@ -101,12 +101,10 @@ more_views: caption_span: "Domande conversazionali con il contesto della giornata già caricato." portfolio_blurb: >- - Gli utenti paganti possono anche caricare un CSV di portafoglio - dal loro broker per un sense-check IA su concentrazione, - coerenza con il regime di mercato ed esposizione valutaria. Le - posizioni restano nel browser per impostazione predefinita; - l'attivazione opzionale del cloud sync (crittografato) - permette di ritrovarle su un altro dispositivo. + Carica un CSV di portafoglio dal tuo broker per vedere la + ripartizione per settore, valuta e concentrazione — calcolata + interamente nel tuo browser. Le posizioni restano nel browser; + nulla sui tuoi titoli viene inviato o conservato sul server. not_strip: head: "Cosa questo NON è." diff --git a/app/models.py b/app/models.py index 57c9f19..b454f76 100644 --- a/app/models.py +++ b/app/models.py @@ -220,6 +220,32 @@ class AICall(Base): error: Mapped[str | None] = mapped_column(String(512)) +class ReviewerVerdict(Base): + """Append-only audit log of every output-reviewer verdict. + + Both layers (deterministic lexicon + LLM) write here, pass and fail. + 'Here is a complete log showing fail-closed automated review on every + published item' is the regulator-facing answer; this table is what backs + that claim. See app/services/output_review.py.""" + __tablename__ = "reviewer_verdicts" + id: Mapped[int] = mapped_column(_PK, primary_key=True, autoincrement=True) + created_at: Mapped[datetime] = mapped_column( + DateTime(timezone=True), default=utcnow, index=True, + ) + # Caller-supplied tag — "log", "indicator", "indicator_aggregate", "chat", + # "portfolio", "digest", or None for unattributed. + surface: Mapped[str | None] = mapped_column(String(32), index=True) + # The candidate text being reviewed. Truncated upstream to avoid pathological + # writes; Text type accepts whatever fits. + candidate_text: Mapped[str] = mapped_column(Text, nullable=False) + clean: Mapped[bool] = mapped_column(Boolean, nullable=False, index=True) + reason: Mapped[str | None] = mapped_column(String(255)) + # Which layer fired: "deterministic" | "llm" | "error". + layer: Mapped[str] = mapped_column(String(16), nullable=False) + # LLM-layer model id, nullable for deterministic / error rows. + model: Mapped[str | None] = mapped_column(String(64)) + + # Portfolio / PortfolioSnapshot / Position removed in Phase G — # holdings live in the browser, the server stores only the anonymous # ticker universe + public market data. diff --git a/app/routers/chat.py b/app/routers/chat.py index 6e12a8e..4c921b0 100644 --- a/app/routers/chat.py +++ b/app/routers/chat.py @@ -188,7 +188,8 @@ async def chat( # 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) + verdict = await review_read(client, result.content, + surface="chat", session=session) except Exception as e: session.add(AICall( model=s.OPENROUTER_MODEL, status="error", error=str(e)[:500], diff --git a/app/routers/public.py b/app/routers/public.py index 33bd245..661fadf 100644 --- a/app/routers/public.py +++ b/app/routers/public.py @@ -16,6 +16,7 @@ from fastapi.responses import HTMLResponse from app.auth import CurrentUser, maybe_current_user from app.services.access import is_paid_active +from app.services.feature_flags import require_flag from app.templates_env import templates @@ -29,7 +30,11 @@ def _ctx(request: Request, cu: CurrentUser | None) -> dict: return {"cu": cu} -@router.get("/pricing", response_class=HTMLResponse) +@router.get( + "/pricing", + response_class=HTMLResponse, + dependencies=[Depends(require_flag("SUBSCRIPTIONS_ENABLED"))], +) async def pricing_page( request: Request, cu: CurrentUser | None = Depends(maybe_current_user), diff --git a/app/routers/stripe_billing.py b/app/routers/stripe_billing.py index bfdeed0..169e5a1 100644 --- a/app/routers/stripe_billing.py +++ b/app/routers/stripe_billing.py @@ -34,10 +34,16 @@ from app.config import get_settings from app.db import get_session, utcnow from app.logging import get_logger from app.models import StripeEvent, User +from app.services.feature_flags import require_flag log = get_logger("stripe_billing") -router = APIRouter() +# Whole router gated by SUBSCRIPTIONS_ENABLED: checkout, portal, and webhook +# all 404 when the subscription system is paused. The webhook gate keeps us +# from accidentally processing a late delivery while the surface is "off". +router = APIRouter( + dependencies=[Depends(require_flag("SUBSCRIPTIONS_ENABLED"))], +) # Cap stored payload at 16 KiB so a hostile (or buggy) sender can't diff --git a/app/routers/sync.py b/app/routers/sync.py index 0fa1174..7b4ecf1 100644 --- a/app/routers/sync.py +++ b/app/routers/sync.py @@ -20,11 +20,17 @@ from app.db import get_session from app.logging import get_logger from app.services import portfolio_sync as svc from app.services.access import require_paid +from app.services.feature_flags import require_flag log = get_logger("portfolio_sync_router") -router = APIRouter(prefix="/api/portfolio/sync") +# Whole router gated by PORTFOLIO_SYNC_ENABLED: when the flag is off, every +# endpoint here 404s — indistinguishable from a non-existent surface. +router = APIRouter( + prefix="/api/portfolio/sync", + dependencies=[Depends(require_flag("PORTFOLIO_SYNC_ENABLED"))], +) # A 256 KB cap is ~200× a typical pie's serialized size — generous diff --git a/app/routers/universe.py b/app/routers/universe.py index ea1d633..aa54ddf 100644 --- a/app/routers/universe.py +++ b/app/routers/universe.py @@ -41,6 +41,7 @@ from app.models import Quote, QuoteDaily from app.services import fx, portfolio_analysis, ticker_universe from app.services.access import require_paid from app.services.csv_import import CSVImportError, parse_t212_csv +from app.services.feature_flags import require_flag from app.services.instrument_map import resolve_slice from app.services.market import fetch as market_fetch @@ -338,7 +339,10 @@ async def parse_portfolio( # --------------------------------------------------------------------------- -@router.post("/analyze") +@router.post( + "/analyze", + dependencies=[Depends(require_flag("PORTFOLIO_AI_ENABLED"))], +) async def analyze_portfolio( request: Request, session: AsyncSession = Depends(get_session), @@ -349,8 +353,8 @@ async def analyze_portfolio( is persisted. The ai_calls ledger row records tokens + cost, never holdings. - Gated behind ``require_paid``: free-tier users get 402. - Admin bearer-token bypasses the gate for testing.""" + Gated behind ``PORTFOLIO_AI_ENABLED`` (404 when off) and ``require_paid`` + (402 for free tier when subscriptions are active).""" # Read JSON body manually so we can enforce a hard size cap. FastAPI's # default body limit is generous; we want tighter control here. body = await request.body() diff --git a/app/services/access.py b/app/services/access.py index 2f91f7a..10e04c9 100644 --- a/app/services/access.py +++ b/app/services/access.py @@ -21,6 +21,7 @@ from datetime import datetime, timezone from fastapi import Depends, HTTPException, status from app.auth import CurrentUser, require_auth +from app.config import get_settings from app.models import User # How many hours of news the free tier sees. Paid sees whatever the @@ -76,13 +77,22 @@ def paid_status(user: User | None) -> PaidStatus: def is_paid_active(principal: CurrentUser | User | None) -> bool: """True if the principal has paid-tier access right now. Admin - bearer-token (``CurrentUser.is_admin=True``) always passes.""" + bearer-token (``CurrentUser.is_admin=True``) always passes. + + When ``SUBSCRIPTIONS_ENABLED=False`` the subscription system is paused: + any authenticated principal is treated as paid (free-for-all). Anonymous + callers still return False so ``require_paid`` continues to enforce auth. + """ if principal is None: return False if isinstance(principal, CurrentUser): if principal.is_admin: return True + if not get_settings().SUBSCRIPTIONS_ENABLED: + return principal.user is not None return paid_status(principal.user).active + if not get_settings().SUBSCRIPTIONS_ENABLED: + return True return paid_status(principal).active diff --git a/app/services/feature_flags.py b/app/services/feature_flags.py new file mode 100644 index 0000000..7cd7f83 --- /dev/null +++ b/app/services/feature_flags.py @@ -0,0 +1,41 @@ +"""Feature-flag gate helpers. + +The four compliance flags in ``app.config.Settings`` (``PORTFOLIO_AI_ENABLED``, +``PORTFOLIO_SYNC_ENABLED``, ``TICKER_UNIVERSE_AGGREGATE_ENABLED``, +``SUBSCRIPTIONS_ENABLED``) gate code paths that stay in the tree but are +inactive by default. Routes / dependencies use ``require_flag()`` to 404 a +whole endpoint when its flag is off — making the surface indistinguishable +from a non-existent route. +""" +from __future__ import annotations + +from fastapi import HTTPException, status + +from app.config import get_settings + + +def flag_enabled(flag_name: str) -> bool: + """Read a boolean flag from Settings. Unknown flags raise — typos here + would silently disable features otherwise.""" + settings = get_settings() + if not hasattr(settings, flag_name): + raise AttributeError(f"unknown feature flag: {flag_name}") + return bool(getattr(settings, flag_name)) + + +def require_flag(flag_name: str): + """FastAPI dependency factory: 404 the route if the flag is off. + + Usage:: + + @router.post("/analyze", dependencies=[Depends(require_flag("PORTFOLIO_AI_ENABLED"))]) + + 404 (not 503) is deliberate: a paused feature should be indistinguishable + from a missing route to clients and crawlers.""" + async def _gate() -> None: + if not flag_enabled(flag_name): + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail="not found", + ) + return _gate diff --git a/app/services/llm_prompts.py b/app/services/llm_prompts.py index 726b60a..7806754 100644 --- a/app/services/llm_prompts.py +++ b/app/services/llm_prompts.py @@ -28,7 +28,47 @@ from datetime import datetime # 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 +# v10 (2026-05-29): Compliance pass. Drops the watch-list section, removes +# price-level / "close above/below" / floor-ceiling / tripwire framing across +# log + indicator reads + chat + digests, adds a universal _COMPLIANCE_RIDER +# prepended to every system prompt. See docs/read-markets-compliance-changes.md. +PROMPT_VERSION = 10 + + +# --- Universal compliance rider ---------------------------------------------- + +# Prepended to every system prompt below (log, per-group indicator read, +# aggregate read, chat, daily + weekly digests). The inline edits in _CORE, +# _CHAT_OVERRIDES, and the summary prompt builders remove the worst patterns +# directly; this rider is belt-and-braces so a future tone/style tweak can't +# silently regress past it. See docs/read-markets-compliance-changes.md TASK 3. +_COMPLIANCE_RIDER = """# Editorial perimeter (overrides everything below) +You are an editorial market explainer, not an adviser or forecaster. +- Explain what moved and WHY (fundamentals, policy, valuation, positioning). +- Separate rational drivers from irrational/positioning drivers. +- DO NOT predict future prices or give price targets, levels, floors, ceilings, + or "close above/below X" triggers for any instrument. +- DO NOT recommend or imply any action: no buy/sell/hold, no add/trim/rebalance, + no overweight/underweight, no "you should", no "watch for X to do Y". +- DO NOT use technical-analysis or chart-pattern framing + (head-and-shoulders, support/resistance, breakouts, RSI, Fibonacci, etc.). +- Refer to instruments to explain the present, never to advise on the future. +- Forward-looking opinions on a named instrument's price or value are out of + scope even without a numeric target. "Brent is likely to consolidate near + $90–93" or "the base case is a move to X" both cross the line. +Write as commentary on public data for a general audience. + +Positive calibration example: +- AVOID: "Gold $4,600 — a close above would confirm the safe-haven bid is + returning; failure at that level would mean the peace script is dominant." +- PREFER: "Gold is rising alongside equities while oil slides — a decoupling + from the usual 'peace = gold weak' narrative. The most natural read is + that safe-haven/hedging demand is re-emerging even as risk assets hold up; + that tension is the day's most notable signal about how fully the peace + scenario is actually priced." +The 'prefer' version keeps the analytical insight; it drops the price level, +the confirm/fail conditional, and the implied action. +""" # --- Core: invariant across tone/analysis settings ---------------------------- @@ -69,8 +109,10 @@ 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. +- Close with the synthesis paragraph (and the System temperature line below). \ +Do NOT add a "watch list", "what to monitor", "tripwires", or any equivalent \ +section. A list of conditional price predictions is a forecast framework — \ +which this log is not. # Time-horizon discipline - This is a STRATEGIC log, not a day-trader's read. Treat 1-day moves under \ @@ -79,9 +121,6 @@ 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 \ @@ -110,8 +149,23 @@ without a specific number behind it. - 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. +- No buy/sell recommendations. No add/trim/rebalance, no overweight/underweight, \ +no "you should", no "investors should", no "we recommend". +- No price targets. No "close above/below $X" or "break above/below X" framing. \ +No floors, ceilings, support, resistance, or any level cast as a trigger or \ +tripwire whose breach would mean something. Citing where a price is right \ +now is fine ("Brent at $90"); casting a price as a threshold ("$93 is the \ +ceiling") is not. +- No forward conditional calls on a named instrument's price. "Would confirm \ +X if Y", "base case is consolidation near $X", "watch for Brent to break Y" \ +are all out. Forward language belongs to the *regime* and *fundamentals* — \ +"the policy mix is tightening", "real yields stay restrictive", "positioning \ +is crowded" — not to a number on a chart. +- Forward predictions wrapped in present-tense state language are still \ +predictions. "Valuations are stretched and unlikely to hold", "the path of \ +least resistance is lower", "risk is skewed to the downside" all smuggle a \ +forecast into description. State the state ("valuations are stretched"); \ +don't tack a direction onto it. # Stance (educational, anti-TA, anti-gambling) The target reader is most likely young, new to investing, and at risk of \ @@ -119,9 +173,10 @@ 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. +crowd behaviour, not predictions. Don't use them; don't legitimise them. \ +Don't cast specific price levels as load-bearing for the read; \ +spot prices and percent changes are fine ("Brent at $90", "+12% YTD") but \ +"$93 is the level to watch" is not. - **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 → \ @@ -135,9 +190,9 @@ 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. +This is the line a glancing reader scrolls to first. Make it earn its place: \ +cite real signals (HY OAS, breadth, VIX, valuation, real yields), not vibes. \ +The label is a description of the current regime, not a forecast. # Update mode (when an earlier log from today is provided) If the user message includes a section labelled "Earlier log from today \ @@ -148,8 +203,6 @@ 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 \ @@ -250,17 +303,18 @@ def _resolve_tone(tone: str) -> str: _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.""", +references. Do not speculate on what comes next.""", "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.""", +Report what happened, then explore forward *regimes* — never forward prices. \ +For each significant sector or theme, you may sketch what the underlying \ +fundamentals and positioning suggest about the prevailing macro regime \ +(e.g. "the policy mix is still tightening", "real yields remain restrictive", \ +"crowded positioning leaves little fuel for further upside in this style"). \ +What you must NOT do is forecast the price or value of any specific named \ +instrument, even hedged with "base case is X" or "likely to consolidate near \ +$X" — those are MAR investment recommendations and outside scope. \ +Stay at the regime / fundamentals level.""", } @@ -268,7 +322,7 @@ 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]) + return "\n\n".join([_COMPLIANCE_RIDER, _CORE, tone_block, analysis_block]) # Backwards-compat: a default-composed SYSTEM_PROMPT for tests / callers that @@ -281,7 +335,7 @@ SYSTEM_PROMPT = build_system_prompt("INTERMEDIATE", "SPECULATIVE") _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. +- Forget the date header, TL;DR, and sectional structure. 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 @@ -289,7 +343,15 @@ question via the chat sidebar. - 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. +- No buy/sell recommendations and no instrument-specific advice, even if the + user asks directly ("should I buy X?", "what about TICKER?"). Redirect to + the regime and the fundamentals. +- No forward price calls, no targets, no triggers, no "close above/below", + no floors, no ceilings. The compliance rider above is in force in chat too. +- The chat receives the latest log, live quotes, and headlines — it does NOT + receive any portfolio or holdings context. If the user mentions their own + positions, do not engage with them at the per-position level; answer the + underlying macro question instead. - Keep the same audience and analysis discipline established above.""" @@ -305,7 +367,9 @@ def build_summary_system_prompt(tone: str, analysis: str) -> str: 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) \ + return f"""{_COMPLIANCE_RIDER} + +You write a TINY interpretation (≤60 words, 2-3 sentences) \ of ONE indicator group for a strategic markets dashboard. # Output format (strict) @@ -341,8 +405,10 @@ 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. +- No buy/sell language. No price targets, no "close above/below", no \ +floors/ceilings/support/resistance, no triggers. No forward price calls on \ +named instruments. No watch list. No TL;DR. No date header. No "system \ +temperature" line — that belongs to the full daily log. {tone_block} @@ -370,7 +436,9 @@ def build_aggregate_summary_system_prompt(tone: str, analysis: str) -> str: {"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 \ + return f"""{_COMPLIANCE_RIDER} + +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. @@ -406,7 +474,9 @@ parenthetical asides that question your own numbers. 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. +- No buy/sell language. No forward price calls on named instruments. \ +No targets, floors, ceilings, support/resistance, "close above/below", or \ +trigger framing of any kind. {tone_block} @@ -437,7 +507,13 @@ def build_chat_system_prompt( ) -> 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.""" + headlines as reference material the model can cite from. + + Compliance contract: no holdings / portfolio / per-user position data may + be passed to this builder. The signature deliberately exposes only log, + quotes, and headlines — adding a holdings parameter would re-open the + advice surface that Task 1 closed. If a future caller needs portfolio + context, the right answer is to redesign the chat, not to bolt it on.""" parts = [build_system_prompt(tone, analysis), "", _CHAT_OVERRIDES, ""] if reference_line: parts.append(f"# Doc reference snapshot\n{reference_line}\n") @@ -539,11 +615,16 @@ def build_daily_digest_prompt( 24h and looks forward to the upcoming session. Longer, less 'live-blogging,' more contextual. Target ~600 words.""" system = ( + f"{_COMPLIANCE_RIDER}\n\n" "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 " + "Cover: (1) what mattered yesterday, (2) what releases or events are " + "scheduled in today's EU and US sessions, (3) one cross-asset thread " + "connecting them. Frame (2) as scheduled events to be aware of, NOT " + "as a price-watch list. " + "No predictions of price level, no buy/sell language, no targets, " + "no 'close above/below', no floors/ceilings/support/resistance, " + "no trigger framing on named instruments. Target ~600 " "words. Output HTML using only

,

,
    ,
  • , , " " — no , , or wrapper, no inline styles." ) @@ -566,12 +647,16 @@ def build_weekly_digest_prompt( Sent to ALL opt-in users (free and paid). Target ~900 words.""" system = ( + f"{_COMPLIANCE_RIDER}\n\n" "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, " + "(2) the week ahead — releases, earnings, central-bank meetings as " + "scheduled events, NOT as a list of price levels to watch, " "(3) the cross-asset story to keep in mind. " - "No predictions of price level, no buy/sell language. Target ~900 " + "No predictions of price level, no buy/sell language, no targets, " + "no 'close above/below', no floors/ceilings/support/resistance, " + "no trigger framing on named instruments. Target ~900 " "words. Output HTML using only

    ,

    ,
      ,
    • , , " " — no , , or wrapper, no inline styles." ) diff --git a/app/services/output_review.py b/app/services/output_review.py index af96ffa..a63f4e9 100644 --- a/app/services/output_review.py +++ b/app/services/output_review.py @@ -1,17 +1,20 @@ -"""Second-pass reviewer agent for AI-generated reads. +"""Two-layer reviewer 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. +Architecture (both layers fail-closed): + 1. Deterministic lexicon/regex pre-check (cheap, zero-variance) — see + ``app.services.output_review_lexicon``. Hard-catches the obvious + advice / forecast / level-trigger patterns. + 2. LLM nuance check (Haiku via OpenRouter) — catches the subtle cases + the lexicon misses (chain-of-thought leakage, forward-state-as- + description, MAR forward price opinions on named instruments). + +Either layer can reject. A reject drops the candidate; the previously +cached good version stays visible. Every verdict (pass and fail) is +persisted to ``reviewer_verdicts`` — the regulator-facing audit trail. 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). +prose. Cost is ~$0.0001 per LLM check; latency ~1-2 s in the hourly job. """ from __future__ import annotations @@ -19,10 +22,13 @@ import json from dataclasses import dataclass import httpx +from sqlalchemy.ext.asyncio import AsyncSession from app.config import get_settings from app.logging import get_logger +from app.models import ReviewerVerdict from app.services.openrouter import call_llm +from app.services.output_review_lexicon import check as lexicon_check log = get_logger("output_review") @@ -93,6 +99,28 @@ Mark UNCLEAN if the text contains ANY of: "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. +- FORWARD PREDICTION DRESSED AS PRESENT-STATE DESCRIPTION. A claim + about direction smuggled into state-shaped language is still a + forecast, and an action-focused rule misses it. + * Allowed (pure state): "valuations are stretched", "real yields + are restrictive", "positioning is crowded". + * Reject (state + direction): "valuations are stretched and unlikely + to hold", "the path of least resistance is lower", "risk is + skewed to the downside", "the setup favours further weakness". + If the sentence implies which way prices go next, flag it. +- MAR INVESTMENT-RECOMMENDATION — a forward-looking opinion on the + price or value of a specific NAMED INSTRUMENT, even without an + explicit numeric target and even without a "you should" verb. + * Reject: "Brent is likely to consolidate near $90–93", "the + base case is a move to X for gold", "TSLA appears poised to + drift lower", "EUR/USD looks set to test new highs". + * Allowed: regime-level forward observation that does NOT name a + specific instrument's price trajectory ("the policy mix is still + tightening", "real yields are likely to stay restrictive while + the labour market holds up"). + This rule applies to single tickers, single commodities, and single + FX pairs equally. A cross-asset *regime* claim is fine; a price + claim on a *named* instrument is not. - Anything else other than the finished, publishable commentary. Return ONLY a JSON object with this exact shape: @@ -102,8 +130,11 @@ No preamble, no markdown fences, no other fields. # Surface-specific rider appended to the system prompt when the caller -# passes a known `surface` to review_read(). Lets us relax or tighten -# rules per editorial context without rewriting the whole prompt. +# passes a known `surface` to review_read(). The portfolio rider — the only +# entry here — only fires when PORTFOLIO_AI_ENABLED is on AND the caller +# explicitly tags surface="portfolio". With the flag off (default) the +# strict base rules govern every surface; future re-enable requires both +# flipping the flag and a deliberate code review of this loosened block. _SURFACE_RIDERS = { "portfolio": """\ @@ -115,8 +146,6 @@ as financial advice. The following ARE fine: exposure", "currency risk is unhedged", "FX exposure", "elevated risk", "stretched valuations", "concentration is manageable", "low diversification". -- Stating what would invalidate the posture: "this view fails if - rates retrace", "the thesis depends on X holding". - Impersonal observation about a position's behaviour or sensitivity: "the position warrants monitoring", "carries vulnerability to a policy shock", "is sensitive to rate moves". @@ -129,6 +158,9 @@ aimed at the reader: - Specific allocation prescriptions: "go 20% bonds", "overweight tech", "underweight defensives". - Price-target predictions: "will reach $X by year-end". +- Forward conditional judgements on a held position ("the thesis + fails if rates retrace", "this view depends on X holding"): these + read as "you should be watching for X" and are out of scope. """, } @@ -138,30 +170,101 @@ class Verdict: clean: bool reason: str cost_usd: float | None # cost of the review call itself, for the ledger + layer: str = "llm" # "deterministic" | "llm" | "error" + + +# Truncation cap for the audit log's candidate_text column. Generous enough +# to keep useful context, bounded to keep pathological inputs from blowing +# the row size. +_AUDIT_CANDIDATE_MAX = 16_000 + + +async def _record_verdict( + session: AsyncSession | None, + *, + surface: str | None, + candidate: str, + verdict: Verdict, + model: str | None, +) -> None: + """Best-effort persist the verdict to ``reviewer_verdicts``. Errors here + must NEVER mask the caller's verdict — wrap and swallow.""" + if session is None: + return + try: + row = ReviewerVerdict( + surface=surface, + candidate_text=candidate[:_AUDIT_CANDIDATE_MAX], + clean=verdict.clean, + reason=verdict.reason[:240] if verdict.reason else None, + layer=verdict.layer, + model=model, + ) + session.add(row) + await session.flush() + except Exception as e: + log.warning("review.audit_write_failed", error=str(e)[:200]) async def review_read( client: httpx.AsyncClient, candidate: str, surface: str | None = None, + *, + session: AsyncSession | None = None, ) -> Verdict: - """Ask the LLM whether `candidate` is a publishable read. + """Run the two-layer reviewer on `candidate`. - 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. + Layer 1: deterministic lexicon/regex pre-check. On hit, returns + ``clean=False, reason="lexicon:"``, layer="deterministic" — no + LLM call is made. + + Layer 2: LLM nuance check (Haiku via OpenRouter). On JSON-mode + success, returns whatever the model decided. 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. + + `surface` selects a surface-specific rider — see _SURFACE_RIDERS. The + only entry, "portfolio", is additionally gated behind + PORTFOLIO_AI_ENABLED so it cannot loosen review when the feature is + paused. + + When ``session`` is provided, every verdict (pass and fail, every + layer) is persisted to ``reviewer_verdicts`` for the audit trail. + Persistence errors are swallowed.""" + settings = get_settings() - `surface` selects a surface-specific rider that's appended to the - base system prompt — see _SURFACE_RIDERS. Currently only the - "portfolio" surface uses one (descriptive risk language is the - whole point there and shouldn't be flagged as advice). Unknown - or None surfaces fall back to the generic rules.""" if not candidate or not candidate.strip(): - return Verdict(clean=False, reason="empty candidate", cost_usd=0.0) + verdict = Verdict(clean=False, reason="empty candidate", cost_usd=0.0, + layer="deterministic") + await _record_verdict(session, surface=surface, candidate=candidate or "", + verdict=verdict, model=None) + return verdict + # Layer 1 — deterministic lexicon pre-check. Cheap, zero-variance. + hit = lexicon_check(candidate) + if hit is not None: + verdict = Verdict( + clean=False, + reason=f"lexicon:{hit.rule}: {hit.snippet}", + cost_usd=0.0, + layer="deterministic", + ) + log.info("review.deterministic_reject", + rule=hit.rule, snippet=hit.snippet, surface=surface) + await _record_verdict(session, surface=surface, candidate=candidate, + verdict=verdict, model=None) + return verdict + + # Layer 2 — LLM nuance check. The portfolio rider is doubly gated: by + # PORTFOLIO_AI_ENABLED here, and by the only caller's own flag-gate. system_prompt = _SYSTEM_PROMPT - if surface and surface in _SURFACE_RIDERS: + if ( + surface + and surface in _SURFACE_RIDERS + and (surface != "portfolio" or settings.PORTFOLIO_AI_ENABLED) + ): system_prompt = system_prompt + _SURFACE_RIDERS[surface] messages = [ @@ -171,7 +274,6 @@ async def review_read( # 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( @@ -190,8 +292,11 @@ async def review_read( ) 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) + verdict = Verdict(clean=False, reason=f"reviewer error: {str(e)[:80]}", + cost_usd=None, layer="error") + await _record_verdict(session, surface=surface, candidate=candidate, + verdict=verdict, model=reviewer_model) + return verdict # Haiku (and several other models) occasionally wrap their JSON # output in a markdown code fence even with response_format set — @@ -211,12 +316,101 @@ async def review_read( 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) + verdict = Verdict(clean=False, reason="reviewer returned non-JSON", + cost_usd=result.cost_usd, layer="error") + await _record_verdict(session, surface=surface, candidate=candidate, + verdict=verdict, model=reviewer_model) + return verdict 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) + verdict = Verdict(clean=False, reason="reviewer omitted bool 'clean'", + cost_usd=result.cost_usd, layer="error") + await _record_verdict(session, surface=surface, candidate=candidate, + verdict=verdict, model=reviewer_model) + return verdict + + verdict = Verdict(clean=clean, reason=str(reason)[:200], + cost_usd=result.cost_usd, layer="llm") + await _record_verdict(session, surface=surface, candidate=candidate, + verdict=verdict, model=reviewer_model) + return verdict + + +# --- Regenerate-on-fail helper ----------------------------------------------- + + +# Maximum regenerate attempts before falling back to stale-cache. Two retries +# is the sweet spot: cheap enough on cost ($0.0003-$0.001 extra per rejected +# draft), enough to absorb the occasional model drift, and capped so a +# genuinely bad prompt template doesn't loop forever. +MAX_REGENERATE_ATTEMPTS = 2 + + +@dataclass(frozen=True) +class ReviewedGeneration: + """Result of generate_with_review. ``content`` is the accepted text (or + None if all attempts were rejected — caller falls back to stale cache). + ``verdict`` is the final reviewer verdict (which may be a reject when + content is None). ``attempts`` counts how many generator calls ran.""" + content: str | None + verdict: Verdict + attempts: int + + +async def generate_with_review( + *, + client: httpx.AsyncClient, + generate, + surface: str | None = None, + session: AsyncSession | None = None, + max_attempts: int = MAX_REGENERATE_ATTEMPTS + 1, # 1 initial + N retries +) -> ReviewedGeneration: + """Run a generator, review it, and on reject feed the reviewer's reason + back into the generator for up to ``max_attempts - 1`` retries before + falling back. Returns ``ReviewedGeneration(content=None, ...)`` when + every attempt was rejected — caller decides how to fall back (typically + keep the stale cached version). + + ``generate`` is an async callable ``(reject_reason: str | None) -> str`` + that produces a candidate. The reject_reason is None on the first call + and the prior verdict's reason on subsequent calls — the generator is + expected to thread it into its system prompt as guidance. + + Every attempt's verdict is audited via the session — see review_read.""" + last_verdict: Verdict | None = None + for attempt in range(1, max_attempts + 1): + reject_reason = last_verdict.reason if last_verdict else None + try: + candidate = await generate(reject_reason) + except Exception as e: + log.warning("review.generator_failed", + attempt=attempt, error=str(e)[:200]) + return ReviewedGeneration( + content=None, + verdict=Verdict(clean=False, + reason=f"generator error: {str(e)[:80]}", + cost_usd=None, layer="error"), + attempts=attempt, + ) + + verdict = await review_read(client, candidate, surface, session=session) + if verdict.clean: + return ReviewedGeneration( + content=candidate, verdict=verdict, attempts=attempt, + ) + + log.info("review.regenerate_attempt", + attempt=attempt, max=max_attempts, + reason=verdict.reason[:80] if verdict.reason else None, + layer=verdict.layer) + last_verdict = verdict + + # Exhausted — every attempt was rejected. + return ReviewedGeneration( + content=None, + verdict=last_verdict or Verdict(clean=False, reason="no attempts", + cost_usd=None, layer="error"), + attempts=max_attempts, + ) diff --git a/app/services/output_review_lexicon.py b/app/services/output_review_lexicon.py new file mode 100644 index 0000000..94638cd --- /dev/null +++ b/app/services/output_review_lexicon.py @@ -0,0 +1,161 @@ +"""Deterministic regex/lexicon pre-check for the output reviewer. + +The LLM reviewer (``app.services.output_review.review_read``) is good at +nuance but is a single model — it can be inconsistent, miss novel phrasings, +or be nudged by candidate content. This module is the belt under the +braces: a cheap, deterministic pass that hard-catches the *obvious* advice / +forecast / level-trigger patterns with zero model variance. + +Calling pattern: ``output_review`` runs ``check(candidate)`` *first*. A hit +short-circuits to ``Verdict(clean=False, reason="lexicon:", layer="deterministic")``. +If the deterministic pass returns clean, the LLM reviewer runs as the +second layer. Fail-closed on either layer. + +Keep this file editable in one place — add patterns here as new failure +modes appear in the audit log. +""" +from __future__ import annotations + +import re +from dataclasses import dataclass + + +@dataclass(frozen=True) +class LexiconHit: + """A deterministic-layer rejection. ``rule`` names which class of pattern + matched (used as the ``reason`` field), and ``snippet`` is the matched + substring (≤80 chars) for the audit log.""" + rule: str + snippet: str + + +# --- Single-word / short-phrase lexicons -------------------------------------- +# +# These are matched as case-insensitive word-boundary regex alternations. +# Keep entries multi-word where possible — bare words like "should" or +# "hold" produce far too many false positives (e.g. "Saudi price cuts", +# "cargo hold", "investors should be aware that…"). Phrases are sharper. + +# Direct action language aimed at the reader. +_ACTION_PHRASES = [ + r"\bbuy (?:the |this |that )?(?:dip|stock|name|sector|equity|equities|bond|bonds)\b", + r"\bis a buy\b", r"\bis a sell\b", + r"\btake profit\b", r"\btake profits\b", + r"\btrim (?:the |your |that )?(?:position|exposure|allocation|holding|holdings)\b", + r"\badd to (?:the |your |that )?(?:position|exposure|allocation|holding|holdings)\b", + r"\brotate (?:into|out of)\b", + r"\brebalance (?:into|out of|towards|toward)\b", + r"\bunderweight (?:the |this )?(?:sector|equities|bonds|tech|defensives|energy|commodities)\b", + r"\boverweight (?:the |this )?(?:sector|equities|bonds|tech|defensives|energy|commodities)\b", + r"\baccumulate (?:the |this |that )?(?:position|exposure|stock|name)\b", +] + +# Advice register: explicit recommendations. +_ADVICE_PHRASES = [ + r"\byou should\b", + r"\binvestors should\b", + r"\bwe recommend\b", + r"\bour recommendation\b", + r"\b(?:is |are )?recommended\b", + r"\bconsider (?:buying|selling|trimming|adding|rotating|hedging|reducing)\b", + r"\bworth buying\b", r"\bworth selling\b", +] + +# Forecast / level register: any price-level cast as a threshold or target. +_FORECAST_PHRASES = [ + r"\bprice target\b", + r"\btarget of \$?\d", + r"\bfair value of \$?\d", + r"\btripwire\b", + r"\bbreakout\b", r"\bbreakdown level\b", + r"\bsupport at \$?\d", r"\bresistance at \$?\d", + r"\bsupport near \$?\d", r"\bresistance near \$?\d", + r"\bfloor at \$?\d", r"\bceiling at \$?\d", + r"\bfloor near \$?\d", r"\bceiling near \$?\d", +] + + +# --- Composed regex patterns -------------------------------------------------- +# +# Pattern matches need a small amount of context. Each entry pairs a rule +# name with a compiled pattern. + +_PATTERNS = [ + # "close above $93", "break below 4600", "move above 90" + ("level_trigger", re.compile( + r"\b(?:close|closes|closing|break|breaks|breaking|broke|" + r"move|moves|moving|hold|holds|holding|push|pushes)\s+" + r"(?:above|below|through|past|over|under)\s+\$?\d", + re.IGNORECASE, + )), + # "target(s|ed|ing) ... $95" — the words 'target' near a number + ("price_target", re.compile( + r"\btarget(?:s|ed|ing)?\b[^.\n]{0,30}\$?\d", + re.IGNORECASE, + )), + # "floor/ceiling/support/resistance at/near/of $X" + ("level_as_noun", re.compile( + r"\b(?:floor|ceiling|support|resistance)\b[^.\n]{0,15}" + r"(?:at|near|of|around|just\s+(?:above|below))\s+\$?\d", + re.IGNORECASE, + )), + # "watch for X to break/move/close above/below Y" + ("watch_for_trigger", re.compile( + r"\bwatch\s+for\s+[^.\n]{0,40}\s+to\s+" + r"(?:break|move|close|hold|push)\s+(?:above|below|through|past|over|under)", + re.IGNORECASE, + )), +] + + +def _compile_phrase_set(phrases: list[str]) -> re.Pattern: + """Compile a list of regex fragments into one alternation pattern with + case-insensitive matching. The fragments already carry their own word + boundaries and group structure.""" + return re.compile("|".join(f"(?:{p})" for p in phrases), re.IGNORECASE) + + +_ACTION_RE = _compile_phrase_set(_ACTION_PHRASES) +_ADVICE_RE = _compile_phrase_set(_ADVICE_PHRASES) +_FORECAST_RE = _compile_phrase_set(_FORECAST_PHRASES) + + +def _snippet(text: str, match: re.Match) -> str: + """Window around a match for the audit log. Caps at 80 chars.""" + start = max(0, match.start() - 20) + end = min(len(text), match.end() + 20) + s = text[start:end].replace("\n", " ").strip() + if len(s) > 80: + s = s[:77] + "..." + return s + + +def check(candidate: str) -> LexiconHit | None: + """Run the deterministic layer. Returns the first matching ``LexiconHit`` + or None if the text is clean by these rules. + + First-match-wins. Order is: action phrases, advice phrases, forecast + phrases, then composed patterns. Bias toward catching the most direct + violations first; the audit log records which rule fired.""" + if not candidate or not candidate.strip(): + return None + text = candidate + + m = _ACTION_RE.search(text) + if m: + return LexiconHit(rule="action_phrase", snippet=_snippet(text, m)) + + m = _ADVICE_RE.search(text) + if m: + return LexiconHit(rule="advice_phrase", snippet=_snippet(text, m)) + + m = _FORECAST_RE.search(text) + if m: + return LexiconHit(rule="forecast_phrase", snippet=_snippet(text, m)) + + for rule, pat in _PATTERNS: + m = pat.search(text) + if m: + return LexiconHit(rule=rule, snippet=_snippet(text, m)) + + return None diff --git a/app/services/portfolio_analysis.py b/app/services/portfolio_analysis.py index bd51f89..413b41a 100644 --- a/app/services/portfolio_analysis.py +++ b/app/services/portfolio_analysis.py @@ -328,6 +328,10 @@ async def analyse( object is a function-local — when this function returns, the pie is garbage-collected. No DB writes mention positions.""" s = get_settings() + # Defense-in-depth: the route is already gated by require_flag, but a + # direct service call must not bypass the compliance pause. + if not s.PORTFOLIO_AI_ENABLED: + raise RuntimeError("portfolio AI commentary is disabled") system, user = build_prompt(req) review_cost = 0.0 @@ -372,7 +376,8 @@ async def analyse( # purpose of this surface, while keeping explicit # buy/sell/allocation directives forbidden. if llm is not None: - verdict = await review_read(client, llm.content, surface="portfolio") + verdict = await review_read(client, llm.content, + surface="portfolio", session=session) review_cost = verdict.cost_usd or 0.0 if not verdict.clean: status = "leaked" diff --git a/app/services/ticker_universe.py b/app/services/ticker_universe.py index 9745dcc..4c7f0d2 100644 --- a/app/services/ticker_universe.py +++ b/app/services/ticker_universe.py @@ -33,6 +33,7 @@ from sqlalchemy import delete, insert, select, update from sqlalchemy.dialects.mysql import insert as mysql_insert from sqlalchemy.ext.asyncio import AsyncSession +from app.config import get_settings from app.db import utcnow from app.logging import get_logger from app.models import TickerUniverse @@ -81,6 +82,8 @@ async def buffer_tickers(tickers: Iterable[str]) -> int: Already-known tickers are still buffered — the flush job will collapse them via INSERT IGNORE. Cheap and avoids a synchronous DB read here.""" + if not get_settings().TICKER_UNIVERSE_AGGREGATE_ENABLED: + return 0 items = [_normalise(t) for t in tickers if t and t.strip()] if not items: return 0 @@ -98,6 +101,8 @@ async def refresh_references( """Bump last_referenced_at for tickers already in the universe. Returns rows updated. Tickers not yet in the universe are silently ignored — they'll land via the buffered flush path.""" + if not get_settings().TICKER_UNIVERSE_AGGREGATE_ENABLED: + return 0 items = list({_normalise(t) for t in tickers if t and t.strip()}) if not items: return 0 @@ -117,6 +122,8 @@ async def flush_buffer(session: AsyncSession) -> dict[str, int]: Idempotent: re-running on the same bucket is a no-op because the bucket is deleted on success.""" + if not get_settings().TICKER_UNIVERSE_AGGREGATE_ENABLED: + return {"buffered": 0, "inserted": 0} r = get_redis() key = _previous_bucket_key() tickers = await r.smembers(key) @@ -177,6 +184,8 @@ async def upsert_tickers(session: AsyncSession, tickers: Iterable[str]) -> int: mitigation has no statistical effect anyway, so bypassing it is free. When we hit ≥10 users this path will be deprecated in favour of the buffered path, per the Phase G plan.""" + if not get_settings().TICKER_UNIVERSE_AGGREGATE_ENABLED: + return 0 items = list({_normalise(t) for t in tickers if t and t.strip()}) if not items: return 0 diff --git a/app/static/js/portfolio.js b/app/static/js/portfolio.js index 7ab75f5..b291173 100644 --- a/app/static/js/portfolio.js +++ b/app/static/js/portfolio.js @@ -19,6 +19,16 @@ const STORAGE_KEY = 'cassandra.pie'; const UNIVERSE_REFRESH_MS = 60_000; + // Compliance feature flags — server emits these as data attributes on + // #pf-mount (see app/templates/dashboard.html). When false the related + // UI is suppressed and the corresponding API call is skipped. + function flagEnabled(name) { + const m = document.getElementById('pf-mount'); + return !!(m && m.dataset && m.dataset[name] === 'true'); + } + function aiEnabled() { return flagEnabled('aiEnabled'); } + function syncEnabled() { return flagEnabled('syncEnabled'); } + // --- localStorage ------------------------------------------------------ function loadPie() { @@ -379,9 +389,9 @@ // edit-mode only — CSS in portfolio.css hides it when the // portfolio panel isn't carrying the .pf-editing class. '
      ' + - (pie.analysis && pie.analysis.content - ? '' - : '') + + (aiEnabled() && !(pie.analysis && pie.analysis.content) + ? '' + : '') + '' + '
      ' + ''; @@ -405,7 +415,7 @@ // regenerate callback closes over the current pie/enriched so a // click rebuilds the analysis with the same context that drove // the initial render. - if (pie.analysis && pie.analysis.content) { + if (aiEnabled() && pie.analysis && pie.analysis.content) { showAnalysis(pie.analysis, { open: true }, () => runAnalysis(pie, enriched)); } } @@ -527,9 +537,11 @@ // Before falling back to "no portfolio", check whether the account // has a synced blob this device could restore from. Status is // 402 for free-tier users — getStatus() returns paid:false there - // and we fall through to the standard empty state. + // and we fall through to the standard empty state. When + // PORTFOLIO_SYNC_ENABLED=false the sync route 404s, so skip the + // status check entirely and render the standard empty state. let status = null; - if (window.CassandraSync) { + if (syncEnabled() && window.CassandraSync) { try { status = await window.CassandraSync.getStatus(); } catch (e) { console.warn('sync status check failed', e); } } diff --git a/app/static/js/settings-import.js b/app/static/js/settings-import.js index 6ec4692..b8c2b1e 100644 --- a/app/static/js/settings-import.js +++ b/app/static/js/settings-import.js @@ -27,6 +27,10 @@ if (!dropZone) return; var IS_PAID = dropZone.dataset.paid === 'true'; + // PORTFOLIO_SYNC_ENABLED gate — when off, the sync route 404s and the + // 'Import & sync to cloud' option must be hidden entirely (a button + // that errors on click is worse than no button). + var SYNC_ENABLED = dropZone.dataset.syncEnabled === 'true'; var currentPie = null; // most recently parsed pie, awaiting commit @@ -71,21 +75,26 @@ return '
      ' + esc(w) + '
      '; }).join(''); - var syncBtn = IS_PAID - ? ('
      ' + - '' + - '
      ' + - 'Also stores an encrypted copy on the server, ' + - 'restorable on any device with your PIN. Only you can decrypt ' + - 'it — losing the PIN means losing the backup.' + - '
      ' + - '
      ') - : ('
      ' + - '' + - '
      ' + - 'Encrypted cloud backup is available on the paid tier.' + - '
      ' + - '
      '); + var syncBtn; + if (!SYNC_ENABLED) { + syncBtn = ''; + } else if (IS_PAID) { + syncBtn = '
      ' + + '' + + '
      ' + + 'Also stores an encrypted copy on the server, ' + + 'restorable on any device with your PIN. Only you can decrypt ' + + 'it — losing the PIN means losing the backup.' + + '
      ' + + '
      '; + } else { + syncBtn = '
      ' + + '' + + '
      ' + + 'Encrypted cloud backup is available on the paid tier.' + + '
      ' + + '
      '; + } previewEl.innerHTML = '
      ' + diff --git a/app/templates/about.html b/app/templates/about.html index 0f0d989..c586319 100644 --- a/app/templates/about.html +++ b/app/templates/about.html @@ -44,13 +44,9 @@ Architecturally, the product is deliberately privacy-shaped:

        -
      • Your portfolio lives in your browser. The server’s view is - an aggregate set of tickers held across the whole user base, - which on its own does not identify any individual user — see - the Privacy notice for the exact data - structures.
      • -
      • Cloud sync of your portfolio is opt-in and end-to-end encrypted - with a PIN only you know.
      • +
      • Your portfolio lives in your browser. CSVs you upload are parsed + and held locally; the server never sees or stores your + holdings.
      • No third-party tracking, no analytics SDKs, no ad cookies.

      diff --git a/app/templates/base.html b/app/templates/base.html index 850fbca..9cccfac 100644 --- a/app/templates/base.html +++ b/app/templates/base.html @@ -328,7 +328,9 @@ {% if cu.user %} Settings {% endif %} + {% if SUBSCRIPTIONS_ENABLED %} Pricing + {% endif %} Terms Privacy Disclaimer diff --git a/app/templates/dashboard.html b/app/templates/dashboard.html index 8db96c2..7505d71 100644 --- a/app/templates/dashboard.html +++ b/app/templates/dashboard.html @@ -97,7 +97,9 @@ × next to an existing row removes it.

      -
      +
      loading…
      diff --git a/app/templates/disclaimer.html b/app/templates/disclaimer.html index cea2695..d736de3 100644 --- a/app/templates/disclaimer.html +++ b/app/templates/disclaimer.html @@ -37,18 +37,20 @@

      About the AI output

      - The strategic log, indicator summaries, and portfolio analysis are - generated by large language models from publicly available market - data and news. They can be wrong, incomplete, or out of date. Numbers - can be misread. Models occasionally generate inaccurate or invented - information (often called “hallucinations”). Treat them - as a prompt to think, not as facts to act on. + The strategic log and indicator summaries are generated by large + language models from publicly available market data and news. They + can be wrong, incomplete, or out of date. Numbers can be misread. + Models occasionally generate inaccurate or invented information + (often called “hallucinations”). Treat them as a + prompt to think, not as facts to act on.

      - The portfolio analysis is an interpretation of holdings you - supplied. It does not consider your overall wealth, debts, tax - position, or anything we don’t see. It is not personalised - advice. + The portfolio feature is a browser-only composition viewer: it + parses a CSV you supply, computes neutral statistics (weights, + sector / currency / concentration breakdown), and shows them to + you. Your holdings stay in your browser; they are never sent to + or stored on the server. There is no AI commentary on your + portfolio.

      @@ -79,13 +81,22 @@ EU/EEA member state, nor in any jurisdiction where its provision would require local licensing or registration. Where any output of the Service could be construed as an “investment - recommendation” under Regulation (EU) 596/2014 (Market Abuse - Regulation) or its UK equivalent, it is non-personalised, produced - by a non-regulated source for educational purposes only, and the - operator (a) has no position in, or remuneration linked to, the - specific instruments mentioned in any individual piece of commentary, - and (b) is not a “relevant person” within MAR Art. - 3(1)(34). + recommendation” within the meaning of + Article 3(1)(35) of Regulation (EU) 596/2014 (Market + Abuse Regulation), with conduct duties under + Article 20 MAR and + Commission Delegated Regulation (EU) 2016/958 + (or the UK onshored equivalent), the operator’s position is + that the Service publishes non-personalised commentary on + public market data by a non-regulated source for educational + purposes, and does not directly propose a particular + investment decision in any specific instrument. The operator has + no position in, and no remuneration linked to, the specific + instruments mentioned in any individual piece of commentary. +

      +

      + {# TODO(legal): final wording on the MAR paragraph above is pending + lawyer sign-off. See docs/read-markets-compliance-changes.md. #}

      diff --git a/app/templates/landing.html b/app/templates/landing.html index 62af9f8..99fea13 100644 --- a/app/templates/landing.html +++ b/app/templates/landing.html @@ -10,10 +10,10 @@
      {% if cu and (cu.user or cu.is_admin) %} {{ t.hero.cta_dashboard }} - {{ t.hero.cta_pricing }} + {% if SUBSCRIPTIONS_ENABLED %}{{ t.hero.cta_pricing }}{% endif %} {% else %} {{ t.hero.cta_signup }} - {{ t.hero.cta_pricing }} + {% if SUBSCRIPTIONS_ENABLED %}{{ t.hero.cta_pricing }}{% endif %} {% endif %}
      diff --git a/app/templates/partials/news.html b/app/templates/partials/news.html index 5f19f4d..4689f34 100644 --- a/app/templates/partials/news.html +++ b/app/templates/partials/news.html @@ -33,7 +33,7 @@ {% endfor %} {% endif %} -{% if capped %} +{% if capped and SUBSCRIPTIONS_ENABLED %}
      Free tier — showing the last {{ window_hours|int }} hours of news. Upgrade diff --git a/app/templates/partials/portfolio.html b/app/templates/partials/portfolio.html index 99d4dc0..d9e0a32 100644 --- a/app/templates/partials/portfolio.html +++ b/app/templates/partials/portfolio.html @@ -1,3 +1,6 @@ +{# Compliance: portfolio is a neutral composition viewer — report numbers, + never append a verdict. No "over-concentrated", no "consider X", no colour- + coded warning badges. See docs/read-markets-compliance-changes.md TASK 1. #} {% if not portfolios %}
      no portfolio snapshots yet
      {% else %} diff --git a/app/templates/pricing.html b/app/templates/pricing.html index c32fb26..875a67a 100644 --- a/app/templates/pricing.html +++ b/app/templates/pricing.html @@ -10,9 +10,9 @@ 6-hour news feed, the cross-asset indicator panels, and a strategic log refreshed every six hours. Paid stretches the news feed to a full 24 hours, runs the strategic log hourly, unlocks the follow-up - chat against past logs, adds portfolio import with AI analysis, and - turns on the daily email digest on top of the Sunday recap everyone - gets. + chat against past logs, adds a browser-only portfolio composition + viewer, and turns on the daily email digest on top of the Sunday + recap everyone gets.

      @@ -33,7 +33,7 @@
    • Sunday weekly digest by email — week behind + week ahead, one-click unsubscribe
    - Need the full-day news feed, hourly strategic log, follow-up chat, daily digests, or portfolio analysis? See Paid → + Need the full-day news feed, hourly strategic log, follow-up chat, daily digests, or the portfolio composition viewer? See Paid
    {% if cu and (cu.user or cu.is_admin) %} @@ -47,7 +47,7 @@

-

- The portfolio feature does not produce buy, sell or hold - recommendations and does not consider your wider finances, debts, - tax position or objectives. It is not regulated investment advice - or a personal recommendation under FSMA / FCA COBS. -

{% if paid %} Manage subscription @@ -190,17 +182,7 @@ Sunday + daily Mon–Sat - Portfolio import (broker CSV) - — - Included - - - AI portfolio read - — - Included - - - Encrypted cloud sync + Browser-only portfolio composition viewer — Included @@ -265,18 +247,10 @@

How the data is handled

- Your portfolio holdings live in your browser’s local storage by - default. The server only learns which Yahoo tickers appear across the - user base — an anonymous union, with no link back to any specific - user. -

-

- If you opt in to encrypted cloud sync, your pie is - encrypted in your browser with a PIN you choose, then sent to the - server. We add a second layer of encryption with a key only the - server holds. We never see your holdings as plaintext, and forgetting - the PIN means we can’t recover it for you. Full details on the - privacy page. + Your portfolio holdings live in your browser’s local storage. + The CSV is parsed in your browser, the resulting pie is kept there, + and nothing about your holdings is sent to or stored on the server. + Full details on the privacy page.

diff --git a/app/templates/privacy.html b/app/templates/privacy.html index 736af55..dbd6ec1 100644 --- a/app/templates/privacy.html +++ b/app/templates/privacy.html @@ -37,24 +37,9 @@ It contains your user id only and is signed so we can detect tampering. Cookie is marked Secure and HttpOnly. -
  • - Anonymous ticker universe: when you upload a - portfolio CSV we record which Yahoo tickers appear, with - no link to your account. The same row would exist whether - any specific user holds the ticker or not — once a ticker is in - the universe, the row carries no signal as to whose import added it. -
  • -
  • - If you opt in to encrypted cloud sync: an opaque - blob of bytes per user. The blob is your portfolio, encrypted in - your browser with a PIN you choose, then wrapped a second time on - the server with a key only the server holds. We can’t decrypt - the blob to plaintext without your PIN, and we can’t recover - your PIN if you forget it. By enabling cloud sync you give your - consent (UK-GDPR Art. 6(1)(a)) to this processing; you can - withdraw consent at any time by disabling sync in Settings, which - also removes the server-side blob. -
  • + {# Cloud sync + server-side per-ticker aggregate union are flag-gated off. + See docs/read-markets-compliance-changes.md and app/config.py + (PORTFOLIO_SYNC_ENABLED, TICKER_UNIVERSE_AGGREGATE_ENABLED). #}
  • Anonymised cost ledger of AI calls (model, tokens, cost). No portfolio or personal data is attached to ledger rows. @@ -76,10 +61,12 @@

    What we don’t collect

    • - Your portfolio holdings as plaintext on the server. - Parsed pies are returned to your browser and kept in - localStorage. The server’s view is the anonymous - ticker universe described above. + Your portfolio holdings, in any form, on the server. + The portfolio feature is a browser-only composition viewer: + uploaded CSVs are parsed and returned to your browser, kept in + localStorage, and never sent back to or stored on + the server. The server records no per-ticker aggregate of what + anyone holds.
    • Third-party analytics or ad cookies. No Google @@ -104,24 +91,14 @@
      • Performance of a contract (Art. 6(1)(b)) — for - operating your account, the sign-in flow, paid features, and the - mechanics of encrypted cloud sync. + operating your account, the sign-in flow, and any paid features.
      • Legitimate interests (Art. 6(1)(f)) — for the - anonymous ticker universe, the anonymised cost ledger, job-run - telemetry, and reverse-proxy access logs. Our interest is the - secure, abuse-resistant, cost-controlled operation of a free - public service, balanced against the minimal and de-identified - nature of the data. -
      • -
      • - Consent (Art. 6(1)(a)) — where you opt in to - encrypted cloud sync (and the related caching of a derived - encryption key in your browser’s sessionStorage). - You can withdraw consent at any time by disabling sync in - Settings; the cached key is cleared and the server-side blob is - removed. + anonymised cost ledger, job-run telemetry, and reverse-proxy access + logs. Our interest is the secure, abuse-resistant, cost-controlled + operation of a free public service, balanced against the minimal + and de-identified nature of the data.
      @@ -131,9 +108,10 @@

      The Service does not make decisions about you that produce legal or similarly significant effects in an automated way (UK-GDPR Art. 22). - The AI portfolio analysis is editorial commentary on the holdings - you upload; it does not approve, reject or rank you, and you remain - the sole decision-maker about anything in your account. + The strategic log and indicator summaries are general editorial + commentary on public market data, not personalised assessments of + you, and you remain the sole decision-maker about anything in your + account.

      @@ -151,13 +129,9 @@ browser.
    • - Local portfolio + cached sync key — parsed pies - live in localStorage on your device. If you enable - cloud sync, the derived encryption key is cached in - sessionStorage so you don’t have to re-enter - your PIN on every navigation. This caching is performed only with - your consent (given when you enable sync); it is cleared when you - close the tab or disable sync. + Local portfolio — parsed pies live in + localStorage on your device. They are not sent to + or stored on the server.
    @@ -177,14 +151,15 @@ currently inside the UK; if that changes we will update this notice.
  • - AI provider calls for the strategic log, indicator - summaries, and (paid) portfolio analysis. Where the provider sits - outside the UK, we rely on the UK International Data Transfer - Agreement (IDTA) / the UK Addendum to the EU Standard Contractual - Clauses where no adequacy decision applies. Each outbound request - carries an explicit no-training opt-out header + AI provider calls for the strategic log and + indicator summaries. Where the provider sits outside the UK, we + rely on the UK International Data Transfer Agreement (IDTA) / the + UK Addendum to the EU Standard Contractual Clauses where no + adequacy decision applies. Each outbound request carries an + explicit no-training opt-out header (X-OR-Allow-Training: false on OpenRouter); see the - Third parties section below for the caveats. + Third parties section below for the caveats. None of these + outbound requests contain user holdings or other portfolio data.
  • @@ -200,15 +175,6 @@ Session cookies: expire automatically; you can sign out at any time to revoke. -
  • - Ticker universe: rows untouched for 60 days are - evicted by a nightly job. Active tickers remain. -
  • -
  • - Encrypted portfolio blob: kept until you disable - cloud sync (one click in Settings) or delete your account. We hold - one row per user; new uploads overwrite the previous blob. -
  • Account: held until you ask us to delete it. Email {{ OPERATOR_EMAIL }}. @@ -230,22 +196,18 @@
  • AI provider(s): DeepSeek (primary) with OpenRouter - as a fallback. They see the prompt for the strategic log, the - indicator summaries, and the portfolio analysis call — which - contains your holdings only when you press - “Generate AI analysis” on a paid plan, and only for the - duration of that single call. The portfolio analysis output is not - persisted on the server. + as a fallback. They see the prompt for the strategic log and the + indicator summaries. These prompts contain public market data and + headlines — never any user holdings or portfolio data.
    No-training opt-out. Every OpenRouter request carries the X-OR-Allow-Training: false header, which signals to OpenRouter and any compatible upstream that the prompt must not be used to train or improve models. DeepSeek does not - currently expose a per-request opt-out; if you do not want your - holdings to leave our server at all, do not use the AI portfolio - analysis feature. We do not control retention or training policies - on the provider side beyond the headers we set — the provider’s - own published data policy is the binding statement on that point. + currently expose a per-request opt-out. We do not control + retention or training policies on the provider side beyond the + headers we set — the provider’s own published data policy is + the binding statement on that point.
  • Market-data sources: Yahoo Finance and a small set @@ -263,7 +225,7 @@
  • Have inaccurate data corrected (Art. 16, rectification).
  • Have your account and associated data deleted (Art. 17, erasure).
  • Export the data you can recognise (Art. 20, portability): your - email, any active encrypted blob, your referral linkage.
  • + email and your referral linkage.
  • Restrict processing (Art. 18).
  • Object specifically to processing carried out on the basis of legitimate interests (Art. 21), including any direct marketing.
  • diff --git a/app/templates/public_base.html b/app/templates/public_base.html index 47dd096..9ec66d9 100644 --- a/app/templates/public_base.html +++ b/app/templates/public_base.html @@ -36,7 +36,9 @@ {{ BRAND_NAME }}