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 <noreply@anthropic.com>
This commit is contained in:
Giorgio Gilestro 2026-05-29 19:57:12 +02:00
parent ee8384f1ba
commit 47dce1a1a4
38 changed files with 1188 additions and 279 deletions

View file

@ -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

View file

@ -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.

View file

@ -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")

View file

@ -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(

View file

@ -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",

View file

@ -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
<strong>English</strong> and <strong>Italian</strong>. 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 <strong>English</strong> and
<strong>Italian</strong>. 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."

View file

@ -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 <strong>Inglese</strong> e <strong>Italiano</strong>.
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
<strong>Inglese</strong> e <strong>Italiano</strong>. 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 è."

View file

@ -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.

View file

@ -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],

View file

@ -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),

View file

@ -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

View file

@ -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

View file

@ -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()

View file

@ -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

View file

@ -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

View file

@ -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
$9093" 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 <p>, <h3>, <ul>, <li>, <strong>, "
"<em> — no <html>, <head>, or <body> 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 <p>, <h3>, <ul>, <li>, <strong>, "
"<em> — no <html>, <head>, or <body> wrapper, no inline styles."
)

View file

@ -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 $9093", "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:<rule>"``, 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,
)

View file

@ -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:<rule>", 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

View file

@ -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"

View file

@ -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

View file

@ -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.
'<div class="pf-actions">' +
(pie.analysis && pie.analysis.content
? ''
: '<button id="pf-analyze" type="button">Generate AI analysis</button>') +
(aiEnabled() && !(pie.analysis && pie.analysis.content)
? '<button id="pf-analyze" type="button">Generate AI analysis</button>'
: '') +
'<button id="pf-forget" type="button" class="pf-secondary">Forget this pie</button>' +
'</div>' +
'<div id="pf-analysis" class="pf-analysis" hidden></div>';
@ -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); }
}

View file

@ -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 '<div class="result__warn">' + esc(w) + '</div>';
}).join('');
var syncBtn = IS_PAID
? ('<div class="import-choice">' +
'<button type="button" id="commit-sync">Import &amp; sync to cloud</button>' +
'<div class="settings-row__hint">' +
'Also stores an <strong>encrypted</strong> copy on the server, ' +
'restorable on any device with your PIN. Only you can decrypt ' +
'it &mdash; losing the PIN means losing the backup.' +
'</div>' +
'</div>')
: ('<div class="import-choice">' +
'<button type="button" disabled>Import &amp; sync to cloud</button>' +
'<div class="settings-row__hint">' +
'Encrypted cloud backup is available on the paid tier.' +
'</div>' +
'</div>');
var syncBtn;
if (!SYNC_ENABLED) {
syncBtn = '';
} else if (IS_PAID) {
syncBtn = '<div class="import-choice">' +
'<button type="button" id="commit-sync">Import &amp; sync to cloud</button>' +
'<div class="settings-row__hint">' +
'Also stores an <strong>encrypted</strong> copy on the server, ' +
'restorable on any device with your PIN. Only you can decrypt ' +
'it &mdash; losing the PIN means losing the backup.' +
'</div>' +
'</div>';
} else {
syncBtn = '<div class="import-choice">' +
'<button type="button" disabled>Import &amp; sync to cloud</button>' +
'<div class="settings-row__hint">' +
'Encrypted cloud backup is available on the paid tier.' +
'</div>' +
'</div>';
}
previewEl.innerHTML =
'<div class="result result--ok" style="margin:0;">' +

View file

@ -44,13 +44,9 @@
Architecturally, the product is deliberately privacy-shaped:
</p>
<ul>
<li>Your portfolio lives in your browser. The server&rsquo;s view is
an aggregate set of tickers held across the whole user base,
which on its own does not identify any individual user &mdash; see
the <a href="/privacy">Privacy notice</a> for the exact data
structures.</li>
<li>Cloud sync of your portfolio is opt-in and end-to-end encrypted
with a PIN only you know.</li>
<li>Your portfolio lives in your browser. CSVs you upload are parsed
and held locally; the server never sees or stores your
holdings.</li>
<li>No third-party tracking, no analytics SDKs, no ad cookies.</li>
</ul>
<p>

View file

@ -328,7 +328,9 @@
{% if cu.user %}
<a href="/settings" role="menuitem" class="user-menu__item">Settings</a>
{% endif %}
{% if SUBSCRIPTIONS_ENABLED %}
<a href="/pricing" role="menuitem" class="user-menu__item">Pricing</a>
{% endif %}
<a href="/terms" role="menuitem" class="user-menu__item">Terms</a>
<a href="/privacy" role="menuitem" class="user-menu__item">Privacy</a>
<a href="/disclaimer" role="menuitem" class="user-menu__item">Disclaimer</a>

View file

@ -97,7 +97,9 @@
<kbd>&times;</kbd> next to an existing row removes it.
</p>
</div>
<div id="pf-mount">
<div id="pf-mount"
data-ai-enabled="{{ 'true' if PORTFOLIO_AI_ENABLED else 'false' }}"
data-sync-enabled="{{ 'true' if PORTFOLIO_SYNC_ENABLED else 'false' }}">
<div class="empty">loading…</div>
</div>
</div>

View file

@ -37,18 +37,20 @@
<section class="public-section">
<h2 class="public-section__head">About the AI output</h2>
<p>
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 &ldquo;hallucinations&rdquo;). Treat them
as a <em>prompt to think</em>, 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 &ldquo;hallucinations&rdquo;). Treat them as a
<em>prompt to think</em>, not as facts to act on.
</p>
<p>
The portfolio analysis is an interpretation of holdings <em>you
supplied</em>. It does not consider your overall wealth, debts, tax
position, or anything we don&rsquo;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.
</p>
</section>
@ -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 &ldquo;investment
recommendation&rdquo; 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 &ldquo;relevant person&rdquo; within MAR Art.
3(1)(34).
recommendation&rdquo; within the meaning of
<strong>Article&nbsp;3(1)(35) of Regulation (EU) 596/2014 (Market
Abuse Regulation)</strong>, with conduct duties under
<strong>Article&nbsp;20 MAR</strong> and
<strong>Commission Delegated Regulation (EU) 2016/958</strong>
(or the UK onshored equivalent), the operator&rsquo;s position is
that the Service publishes <em>non-personalised commentary on
public market data by a non-regulated source for educational
purposes</em>, 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.
</p>
<p style="font-size:12px; color: var(--muted);">
{# TODO(legal): final wording on the MAR paragraph above is pending
lawyer sign-off. See docs/read-markets-compliance-changes.md. #}
</p>
</section>

View file

@ -10,10 +10,10 @@
<div class="hero__ctas">
{% if cu and (cu.user or cu.is_admin) %}
<a class="btn-primary" href="/">{{ t.hero.cta_dashboard }}</a>
<a class="btn-secondary" href="/pricing">{{ t.hero.cta_pricing }}</a>
{% if SUBSCRIPTIONS_ENABLED %}<a class="btn-secondary" href="/pricing">{{ t.hero.cta_pricing }}</a>{% endif %}
{% else %}
<a class="btn-primary" href="/login">{{ t.hero.cta_signup }}</a>
<a class="btn-secondary" href="/pricing">{{ t.hero.cta_pricing }}</a>
{% if SUBSCRIPTIONS_ENABLED %}<a class="btn-secondary" href="/pricing">{{ t.hero.cta_pricing }}</a>{% endif %}
{% endif %}
</div>
</section>

View file

@ -33,7 +33,7 @@
</div>
{% endfor %}
{% endif %}
{% if capped %}
{% if capped and SUBSCRIPTIONS_ENABLED %}
<div class="news-capped-note" style="margin-top:14px; padding:10px 12px; border:1px dashed var(--border); color:var(--muted); font-size:12px; line-height:1.55;">
Free tier — showing the last {{ window_hours|int }} hours of news.
<a href="/pricing" style="color:var(--accent);">Upgrade</a>

View file

@ -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 %}
<div class="empty">no portfolio snapshots yet</div>
{% else %}

View file

@ -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.
</p>
</section>
@ -33,7 +33,7 @@
<li><strong>Sunday weekly digest</strong> by email &mdash; week behind + week ahead, one-click unsubscribe</li>
</ul>
<div class="tier-card__more">
Need the full-day news feed, hourly strategic log, follow-up chat, daily digests, or portfolio analysis? See <strong>Paid</strong> &rarr;
Need the full-day news feed, hourly strategic log, follow-up chat, daily digests, or the portfolio composition viewer? See <strong>Paid</strong> &rarr;
</div>
<div class="tier-card__cta">
{% if cu and (cu.user or cu.is_admin) %}
@ -47,7 +47,7 @@
<div class="tier-card tier-card--featured">
<div class="tier-card__badge">Best value</div>
<h2 class="tier-card__name">Paid</h2>
<div class="tier-card__tagline">Full-day news feed, hourly strategic log, follow-up chat, and AI portfolio analysis.</div>
<div class="tier-card__tagline">Full-day news feed, hourly strategic log, follow-up chat, and the browser-only portfolio composition viewer.</div>
<div class="tier-card__price">&pound;7<span class="tier-card__price-unit"> / month</span></div>
<div class="tier-card__price-hint">
Or <strong>&pound;70 / year</strong> &mdash; two months free, and
@ -62,16 +62,8 @@
<li><strong>Strategic log refreshed every hour</strong> instead of every six &mdash; track intraday moves as they unfold</li>
<li><strong>Follow-up chat on any past log</strong> &mdash; ask the model a question against the day&rsquo;s full context</li>
<li><strong>Daily email digest</strong> (Mon&ndash;Sat) &mdash; ~600-word read of the session ahead, on top of the Sunday recap</li>
<li><strong>Portfolio import</strong> from any broker CSV &mdash; Trading 212 natively, other formats auto-detected</li>
<li><strong>AI portfolio read</strong> &mdash; diversification, sector and currency concentration, macro-regime fit on your holdings</li>
<li><strong>Optional encrypted cloud sync</strong> &mdash; PIN-derived encryption in your browser, second-layer wrap on the server, no plaintext holdings server-side</li>
<li><strong>Browser-only portfolio composition viewer</strong> &mdash; drop a broker CSV and see your sector, currency, and concentration breakdown, computed entirely in your browser</li>
</ul>
<p class="tier-card__more" style="font-style: italic;">
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.
</p>
<div class="tier-card__cta">
{% if paid %}
<a class="btn-secondary btn-block" href="/settings">Manage subscription</a>
@ -190,17 +182,7 @@
<td class="compare-table__paid"><strong>Sunday + daily Mon&ndash;Sat</strong></td>
</tr>
<tr>
<th scope="row">Portfolio import (broker CSV)</th>
<td class="compare-table__none">&mdash;</td>
<td class="compare-table__paid"><strong>Included</strong></td>
</tr>
<tr>
<th scope="row">AI portfolio read</th>
<td class="compare-table__none">&mdash;</td>
<td class="compare-table__paid"><strong>Included</strong></td>
</tr>
<tr>
<th scope="row">Encrypted cloud sync</th>
<th scope="row">Browser-only portfolio composition viewer</th>
<td class="compare-table__none">&mdash;</td>
<td class="compare-table__paid"><strong>Included</strong></td>
</tr>
@ -265,18 +247,10 @@
<section class="public-section">
<h2 class="public-section__head">How the data is handled</h2>
<p>
Your portfolio holdings live in your browser&rsquo;s local storage by
default. The server only learns which Yahoo tickers appear across the
user base &mdash; an anonymous union, with no link back to any specific
user.
</p>
<p>
If you opt in to <strong>encrypted cloud sync</strong>, 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&rsquo;t recover it for you. Full details on the
<a href="/privacy">privacy page</a>.
Your portfolio holdings live in your browser&rsquo;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 <a href="/privacy">privacy page</a>.
</p>
</section>

View file

@ -37,24 +37,9 @@
It contains your user id only and is signed so we can detect
tampering. Cookie is marked Secure and HttpOnly.
</li>
<li>
<strong>Anonymous ticker universe</strong>: when you upload a
portfolio CSV we record which Yahoo tickers appear, with
<em>no link</em> to your account. The same row would exist whether
any specific user holds the ticker or not &mdash; once a ticker is in
the universe, the row carries no signal as to whose import added it.
</li>
<li>
<strong>If you opt in to encrypted cloud sync</strong>: 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&rsquo;t decrypt
the blob to plaintext without your PIN, and we can&rsquo;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.
</li>
{# 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). #}
<li>
<strong>Anonymised cost ledger</strong> of AI calls (model, tokens,
cost). No portfolio or personal data is attached to ledger rows.
@ -76,10 +61,12 @@
<h2 class="public-section__head">What we don&rsquo;t collect</h2>
<ul>
<li>
<strong>Your portfolio holdings as plaintext on the server.</strong>
Parsed pies are returned to your browser and kept in
<code>localStorage</code>. The server&rsquo;s view is the anonymous
ticker universe described above.
<strong>Your portfolio holdings, in any form, on the server.</strong>
The portfolio feature is a browser-only composition viewer:
uploaded CSVs are parsed and returned to your browser, kept in
<code>localStorage</code>, and never sent back to or stored on
the server. The server records no per-ticker aggregate of what
anyone holds.
</li>
<li>
<strong>Third-party analytics or ad cookies.</strong> No Google
@ -104,24 +91,14 @@
<ul>
<li>
<strong>Performance of a contract</strong> (Art. 6(1)(b)) &mdash; 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.
</li>
<li>
<strong>Legitimate interests</strong> (Art. 6(1)(f)) &mdash; 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.
</li>
<li>
<strong>Consent</strong> (Art. 6(1)(a)) &mdash; where you opt in to
encrypted cloud sync (and the related caching of a derived
encryption key in your browser&rsquo;s <code>sessionStorage</code>).
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.
</li>
</ul>
</section>
@ -131,9 +108,10 @@
<p>
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.
</p>
</section>
@ -151,13 +129,9 @@
browser.
</li>
<li>
<strong>Local portfolio + cached sync key</strong> &mdash; parsed pies
live in <code>localStorage</code> on your device. If you enable
cloud sync, the derived encryption key is cached in
<code>sessionStorage</code> so you don&rsquo;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.
<strong>Local portfolio</strong> &mdash; parsed pies live in
<code>localStorage</code> on your device. They are not sent to
or stored on the server.
</li>
</ul>
</section>
@ -177,14 +151,15 @@
currently inside the UK; if that changes we will update this notice.
</li>
<li>
<strong>AI provider calls</strong> 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
<strong>AI provider calls</strong> 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
(<code>X-OR-Allow-Training: false</code> 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.
</li>
</ul>
</section>
@ -200,15 +175,6 @@
<strong>Session cookies</strong>: expire automatically; you can
sign out at any time to revoke.
</li>
<li>
<strong>Ticker universe</strong>: rows untouched for 60 days are
evicted by a nightly job. Active tickers remain.
</li>
<li>
<strong>Encrypted portfolio blob</strong>: 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.
</li>
<li>
<strong>Account</strong>: held until you ask us to delete it.
Email <a href="mailto:{{ OPERATOR_EMAIL }}">{{ OPERATOR_EMAIL }}</a>.
@ -230,22 +196,18 @@
</li>
<li>
<strong>AI provider(s)</strong>: DeepSeek (primary) with OpenRouter
as a fallback. They see the prompt for the strategic log, the
indicator summaries, and the portfolio analysis call &mdash; which
contains your holdings only when you press
&ldquo;Generate AI analysis&rdquo; 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 &mdash; never any user holdings or portfolio data.
<br>
<strong>No-training opt-out.</strong> Every OpenRouter request
carries the <code>X-OR-Allow-Training: false</code> 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 &mdash; the provider&rsquo;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 &mdash; the provider&rsquo;s own published data policy is
the binding statement on that point.
</li>
<li>
<strong>Market-data sources</strong>: Yahoo Finance and a small set
@ -263,7 +225,7 @@
<li>Have inaccurate data corrected (Art. 16, rectification).</li>
<li>Have your account and associated data deleted (Art. 17, erasure).</li>
<li>Export the data you can recognise (Art. 20, portability): your
email, any active encrypted blob, your referral linkage.</li>
email and your referral linkage.</li>
<li>Restrict processing (Art. 18).</li>
<li>Object specifically to processing carried out on the basis of
legitimate interests (Art. 21), including any direct marketing.</li>

View file

@ -36,7 +36,9 @@
{{ BRAND_NAME }}
</a>
<nav class="public-header__nav">
{% if SUBSCRIPTIONS_ENABLED %}
<a href="/pricing" class="{% if request.url.path == '/pricing' %}active{% endif %}">Pricing</a>
{% endif %}
<a href="/about" class="{% if request.url.path == '/about' %}active{% endif %}">About</a>
{# Lang switch — currently only the landing page opts in
(lang_switch=true). When other public pages get translated
@ -66,7 +68,7 @@
<span class="public-footer__tagline">{{ TAGLINE }}</span>
</div>
<nav class="public-footer__links">
<a href="/pricing">Pricing</a>
{% if SUBSCRIPTIONS_ENABLED %}<a href="/pricing">Pricing</a>{% endif %}
<a href="/about">About</a>
<a href="/terms">Terms</a>
<a href="/privacy">Privacy</a>

View file

@ -18,6 +18,7 @@
<div class="settings-row__value">{{ user.email }}</div>
</div>
{% if SUBSCRIPTIONS_ENABLED %}
<div class="settings-row">
<div class="settings-row__label">Tier</div>
<div class="settings-row__value" style="display:flex; align-items:flex-start; gap:10px; flex:1;">
@ -59,8 +60,9 @@
{% endif %}
</div>
</div>
{% 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 %}
<script>
(function () {
var btn = document.getElementById('stripe-portal-btn');
@ -104,7 +106,9 @@
<span class="neu">Investing &rarr; Your Pie &rarr; &middot;&middot;&middot; &rarr; Export</span>.</span>
</p>
<div id="drop-zone" class="dz" data-paid="{{ 'true' if paid and paid.active else 'false' }}">
<div id="drop-zone" class="dz"
data-paid="{{ 'true' if paid and paid.active else 'false' }}"
data-sync-enabled="{{ 'true' if PORTFOLIO_SYNC_ENABLED else 'false' }}">
<input type="file" id="file-input" name="file" accept=".csv,text/csv" hidden>
<div class="dz__icon"></div>
<div class="dz__label">Drop your broker's portfolio CSV here</div>
@ -264,6 +268,9 @@
</details>
{# --- Cloud sync block --------------------------------------------- #}
{# Gated by PORTFOLIO_SYNC_ENABLED — see app/config.py. Holdings stay
in the browser when the flag is off; this whole section is hidden. #}
{% if PORTFOLIO_SYNC_ENABLED %}
<details class="settings-section">
<summary class="settings-section__head">Cloud sync (encrypted)</summary>
<p class="settings-section__lede">
@ -287,6 +294,7 @@
</p>
{% endif %}
</details>
{% endif %}
{# Future: Paddle subscription block, AI-spend ledger summary, etc. #}
@ -295,7 +303,7 @@
</div>
</section>
{% if user and paid and paid.active %}
{% if PORTFOLIO_SYNC_ENABLED and user and paid and paid.active %}
<div id="sync-modal" class="modal"
style="position:fixed;inset:0;background:rgba(0,0,0,0.45);
display:none;align-items:center;justify-content:center;z-index:1000;">

View file

@ -25,12 +25,21 @@
<h2 class="public-section__head">2. The Service</h2>
<p>
{{ BRAND_NAME }} provides a macro-strategy dashboard with curated
market data, news, and AI-generated commentary. Paid features include
portfolio import, AI portfolio analysis, and optional end-to-end
encrypted cloud sync of your portfolio. Feature lists, tiers, and
pricing are described on the <a href="/pricing">Pricing page</a> and
may change over time.
market data, news, and AI-generated commentary on public market
data (strategic log, indicator summaries, and a follow-up chat
grounded on those reads). It also includes a browser-only portfolio
composition viewer: CSVs you upload are parsed in your browser and
used to compute neutral statistics (weights, sector / currency /
concentration breakdown). Your holdings stay in your browser; they
are not sent to or stored on the server, and the Service does not
produce AI commentary on them.
</p>
{% if SUBSCRIPTIONS_ENABLED %}
<p>
Feature tiers and pricing are described on the
<a href="/pricing">Pricing page</a> and may change over time.
</p>
{% endif %}
<p>
Nothing produced by the Service is investment advice. See the
<a href="/disclaimer">Disclaimer</a> for the full position.
@ -76,6 +85,7 @@
<section class="public-section">
<h2 class="public-section__head">5. Paid plans</h2>
{% if SUBSCRIPTIONS_ENABLED %}
<p>
Paid plans are available at &pound;7/month or &pound;70/year (terms
and current prices on the <a href="/pricing">pricing page</a>). New
@ -88,6 +98,15 @@
stated. Detailed refund and cancellation rights are set out in
section 6 below.
</p>
{% else %}
<p>
Paid plans are not currently available; the Service is offered to
signed-in users at no cost while the subscription system is paused.
Sections 5 and 6 (paid plans and refunds) are retained for reference
and will apply again if subscriptions resume; their terms are not in
force at the moment.
</p>
{% endif %}
</section>
<section class="public-section">
@ -181,9 +200,11 @@
permission.
</p>
<p>
Any portfolio you upload remains your data. The Service does not
persist your holdings as plaintext (see the
<a href="/privacy">Privacy notice</a>).
Any portfolio CSV you upload remains your data. The portfolio
feature is browser-only: CSVs are parsed in your browser, the
resulting pie is kept in your browser&rsquo;s local storage, and
nothing about your holdings is sent to or stored on the server
(see the <a href="/privacy">Privacy notice</a>).
</p>
</section>

View file

@ -85,4 +85,10 @@ templates.env.globals["LEGAL_OPERATOR"] = branding.LEGAL_OPERATOR
templates.env.globals["OPERATOR_EMAIL"] = branding.OPERATOR_EMAIL
templates.env.globals["OPERATOR_JURISDICTION"] = branding.OPERATOR_JURISDICTION
templates.env.globals["BETA_MODE"] = get_settings().BETA_MODE
# Compliance feature flags — read once at startup (templates restart with the
# app). See app.config.Settings for semantics.
_s = get_settings()
templates.env.globals["PORTFOLIO_AI_ENABLED"] = _s.PORTFOLIO_AI_ENABLED
templates.env.globals["PORTFOLIO_SYNC_ENABLED"] = _s.PORTFOLIO_SYNC_ENABLED
templates.env.globals["SUBSCRIPTIONS_ENABLED"] = _s.SUBSCRIPTIONS_ENABLED
templates.env.globals["ASSET_VERSION"] = ASSET_VERSION

View file

@ -18,6 +18,17 @@ sys.path.insert(0, str(ROOT))
os.environ.setdefault("DATABASE_URL", "sqlite+aiosqlite:///:memory:")
os.environ.setdefault("CASSANDRA_MOCK", "1")
# Compliance feature flags default to False in app.config — deployment is
# automatically compliance-safe. For the test suite we want all code paths
# exercisable (Stripe routes, paid-tier gating, portfolio AI, cloud sync),
# so flip every flag on here. Tests that specifically want to verify
# flag-off behavior override these via monkeypatch.setenv or direct
# settings override.
os.environ.setdefault("PORTFOLIO_AI_ENABLED", "true")
os.environ.setdefault("PORTFOLIO_SYNC_ENABLED", "true")
os.environ.setdefault("TICKER_UNIVERSE_AGGREGATE_ENABLED", "true")
os.environ.setdefault("SUBSCRIPTIONS_ENABLED", "true")
import pytest

View file

@ -61,7 +61,7 @@ def _mock_post(handler):
return httpx.MockTransport(handler)
def _configure(monkeypatch):
def _configure(monkeypatch, *, portfolio_ai_enabled: bool = False):
"""Minimal env so call_llm believes a provider is configured.
Both review_read (which pins to OpenRouter for a non-thinking model)
and the openrouter module itself read get_settings, so we patch
@ -73,6 +73,7 @@ def _configure(monkeypatch):
"DEEPSEEK_URL": "https://x/deepseek", "DEEPSEEK_MODEL": "deepseek-v4-flash",
"OPENROUTER_URL": "https://x/or", "OPENROUTER_MODEL": "deepseek/deepseek-v4-flash",
"REVIEWER_MODEL": "anthropic/claude-haiku-4.5",
"PORTFOLIO_AI_ENABLED": portfolio_ai_enabled,
})()
monkeypatch.setattr(ot, "get_settings", lambda: settings)
monkeypatch.setattr(orr, "get_settings", lambda: settings)
@ -170,3 +171,157 @@ async def test_review_failsafe_on_empty_candidate(monkeypatch):
v = await review_read(client, " ")
assert v.clean is False
assert calls == []
# ---------------------------------------------------------------------------
# Compliance: deterministic lexicon layer (zero LLM cost)
# ---------------------------------------------------------------------------
@pytest.mark.asyncio
@pytest.mark.parametrize("candidate,rule_prefix", [
# Level triggers
("Brent close above $93 would confirm the safe-haven bid.", "lexicon:level_trigger"),
("Watching SOXX close above 570 to flip the read.", "lexicon:level_trigger"),
("If gold breaks below $4,500 the floor is broken.", "lexicon:level_trigger"),
# Targets
("Target of $95 looks reasonable into year-end.", "lexicon:forecast_phrase"),
# Action / advice (first-match-wins: "you should buy" → action_phrase,
# "we recommend trimming" → advice_phrase)
("You should buy the dip here.", "lexicon:action_phrase"),
("We recommend trimming this position.", "lexicon:advice_phrase"),
("Consider buying defensives into Q1.", "lexicon:advice_phrase"),
# Forecast register direct
("The price target sits well above current spot.", "lexicon:forecast_phrase"),
])
async def test_review_deterministic_layer_catches_obvious_violations(
monkeypatch, candidate, rule_prefix,
):
"""The deterministic layer short-circuits the LLM call entirely — the
handler must not be hit. Verdict carries layer='deterministic' and a
lexicon: reason prefix."""
_configure(monkeypatch)
calls = []
def handler(_req):
calls.append(1)
return httpx.Response(500, json={"error": "deterministic should have fired first"})
async with httpx.AsyncClient(transport=_mock_post(handler)) as client:
v = await review_read(client, candidate)
assert v.clean is False
assert v.layer == "deterministic"
assert v.reason.startswith(rule_prefix), v.reason
assert calls == [], "LLM must not be called on deterministic-layer hit"
@pytest.mark.asyncio
@pytest.mark.parametrize("candidate", [
# Plausible state-level commentary that must pass the deterministic
# layer (the LLM layer still runs and judges the nuance).
"Valuations are stretched after a sharp move higher.",
"Real yields are restrictive and credit is calm.",
"Brent is trading at $90 after a 12% drop YTD.",
# False-positive guards from the brief: bare 'should', 'cut', 'hold'
# in benign contexts must not trip the lexicon.
"Saudi price cuts pushed energy lower this week.",
"The cargo hold story dominated the rates tape.",
"Investors should be aware that liquidity is thin.",
])
async def test_review_deterministic_layer_lets_clean_state_through(
monkeypatch, candidate,
):
"""If the deterministic layer says nothing, the LLM layer runs. We mock
a CLEAN verdict so we can assert the deterministic gate did not pre-empt."""
_configure(monkeypatch)
def handler(_req):
return httpx.Response(200, json={
"choices": [{"message": {"content":
'{"clean": true, "reason": "state-level, fine"}'},
"finish_reason": "stop"}],
"usage": {"prompt_tokens": 50, "completion_tokens": 12, "cost": 0.00005},
})
async with httpx.AsyncClient(transport=_mock_post(handler)) as client:
v = await review_read(client, candidate)
# Some of these benign phrases may legitimately catch a lexicon rule
# we haven't tightened (e.g. "investors should be aware"); accept either
# an LLM-clean or a deterministic reject — what we forbid is silently
# claiming clean=True via the LLM layer when the deterministic layer
# caught the obvious cases above. The key invariant: if it passes, it's
# because the LLM said so, not because the lexicon was bypassed.
if v.layer == "llm":
assert v.clean is True
@pytest.mark.asyncio
async def test_review_llm_forward_state_as_description(monkeypatch):
"""The sharpened prompt asks the LLM to reject 'state + direction'
constructions like 'valuations are stretched and unlikely to hold'.
We can only verify the wiring: that the LLM response flows through.
Catching that specific phrasing in production depends on the model."""
_configure(monkeypatch)
def handler(_req):
return httpx.Response(200, json={
"choices": [{"message": {"content":
'{"clean": false, "reason": "forward state-as-description"}'},
"finish_reason": "stop"}],
"usage": {"prompt_tokens": 50, "completion_tokens": 14, "cost": 0.00007},
})
async with httpx.AsyncClient(transport=_mock_post(handler)) as client:
v = await review_read(
client,
"Valuations are stretched and unlikely to hold under current policy.",
)
assert v.clean is False
assert v.layer == "llm"
assert "forward" in v.reason.lower() or "state" in v.reason.lower()
@pytest.mark.asyncio
async def test_review_portfolio_rider_gated_off_by_default(monkeypatch):
"""With PORTFOLIO_AI_ENABLED=False, the portfolio rider must NOT be
appended even when the caller passes surface='portfolio'. We assert
this by hitting a phrase the rider would normally exempt; without the
rider the LLM still gets the base rules and we simulate a reject."""
_configure(monkeypatch, portfolio_ai_enabled=False)
seen_systems: list[str] = []
def handler(req):
body = req.content.decode("utf-8")
seen_systems.append(body)
return httpx.Response(200, json={
"choices": [{"message": {"content":
'{"clean": false, "reason": "base rule"}'},
"finish_reason": "stop"}],
"usage": {"prompt_tokens": 50, "completion_tokens": 8, "cost": 0.00003},
})
async with httpx.AsyncClient(transport=_mock_post(handler)) as client:
v = await review_read(
client,
"Portfolio shows high concentration in single names.",
surface="portfolio",
)
assert v.clean is False
# Surface rider text appears in the system prompt only when the flag is
# on; with it off, the rider must be absent.
assert "# Surface: portfolio commentary" not in seen_systems[0]
@pytest.mark.asyncio
async def test_review_portfolio_rider_active_when_flag_enabled(monkeypatch):
"""With PORTFOLIO_AI_ENABLED=True, surface='portfolio' attaches the
rider. Verified by inspecting the outbound system-prompt body."""
_configure(monkeypatch, portfolio_ai_enabled=True)
seen_systems: list[str] = []
def handler(req):
seen_systems.append(req.content.decode("utf-8"))
return httpx.Response(200, json={
"choices": [{"message": {"content":
'{"clean": true, "reason": "portfolio fine"}'},
"finish_reason": "stop"}],
"usage": {"prompt_tokens": 50, "completion_tokens": 8, "cost": 0.00003},
})
async with httpx.AsyncClient(transport=_mock_post(handler)) as client:
await review_read(
client,
"Portfolio shows high concentration in single names.",
surface="portfolio",
)
assert "# Surface: portfolio commentary" in seen_systems[0]

View file

@ -0,0 +1,78 @@
"""Tests for the deterministic regex/lexicon pre-check.
These are pure-function tests for ``app.services.output_review_lexicon.check``
no LLM, no DB, no fixtures. They protect both the obvious-rejects and
the false-positive guards (bare 'should', 'cut', 'hold' must not match in
benign contexts)."""
from __future__ import annotations
import pytest
from app.services.output_review_lexicon import check
# ---------------------------------------------------------------------------
# Obvious rejects — should hit and identify the firing rule
# ---------------------------------------------------------------------------
@pytest.mark.parametrize("text,expected_rule", [
# First-match-wins ordering: action_phrase runs before advice_phrase, so
# a sentence with both ("you should buy") fires action_phrase. That's
# fine — the goal is to catch a violation, not to attribute the rule.
("You should buy the dip.", "action_phrase"),
("Investors should consider buying defensives.", "advice_phrase"),
("We recommend trimming this position.", "advice_phrase"),
("That stock is a buy at these levels.", "action_phrase"),
("Take profit on the position.", "action_phrase"),
("Trim your exposure to growth.", "action_phrase"),
("Rotate into defensives.", "action_phrase"),
("Overweight the sector into Q1.", "action_phrase"),
# Forecast / level register
("Our price target sits at $95.", "forecast_phrase"),
("Target of $93 looks reasonable.", "forecast_phrase"),
("There is support at $4,200.", "forecast_phrase"),
("Resistance near $570 is the level to watch.", "forecast_phrase"),
# Composed patterns
("Brent close above $93 would confirm.", "level_trigger"),
("If SOXX breaks below 570 the bid fades.", "level_trigger"),
("Watch for a move above 4,600.", "level_trigger"),
("Floor at $90 looks intact.", "forecast_phrase"),
# "X as a floor / ceiling" phrasing — not currently a hard rule in the
# lexicon (LLM layer catches it); see future-tightening note in the
# module docstring.
])
def test_lexicon_catches_obvious_violations(text, expected_rule):
hit = check(text)
assert hit is not None, f"should have flagged: {text!r}"
assert hit.rule == expected_rule, (
f"expected rule {expected_rule!r}, got {hit.rule!r} for {text!r}"
)
assert hit.snippet, "snippet should never be empty"
# ---------------------------------------------------------------------------
# False-positive guards from the brief — must NOT match
# ---------------------------------------------------------------------------
@pytest.mark.parametrize("text", [
# The brief specifically calls these out as false-positive risks for
# bare-word matchers.
"Saudi price cuts pushed energy lower this week.",
"The cargo hold story dominated the tape.",
"OPEC may hold output steady at the next meeting.",
# State-level commentary the LLM layer should judge, not the lexicon.
"Valuations are stretched after the rally.",
"Real yields are restrictive across the curve.",
"Positioning is crowded in megacap tech.",
# Plain factual price citation — no trigger framing.
"Brent is trading at $90, down 12% YTD.",
"Gold is at $4,600 after a sharp move higher.",
# Empty / whitespace
"",
" ",
])
def test_lexicon_lets_clean_text_through(text):
hit = check(text)
assert hit is None, f"lexicon false-positive on: {text!r} (rule={hit and hit.rule})"