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.
'
' +
'';
@@ -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 %}
TermsPrivacyDisclaimer
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.
- 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/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 @@
- 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 @@
Best value
Paid
-
Full-day news feed, hourly strategic log, follow-up chat, and AI portfolio analysis.
+
Full-day news feed, hourly strategic log, follow-up chat, and the browser-only portfolio composition viewer.
£7 / month
Or £70 / year — two months free, and
@@ -62,16 +62,8 @@
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
-
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
+
Browser-only portfolio composition viewer — drop a broker CSV and see your sector, currency, and concentration breakdown, computed entirely in your browser
-
- 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.
-
- 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.
+ {% endif %}
- {% if paid and paid.active and paid.source != "credit" and user.stripe_customer_id %}
+ {% if SUBSCRIPTIONS_ENABLED and paid and paid.active and paid.source != "credit" and user.stripe_customer_id %}