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

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