read.markets/app/services/output_review_lexicon.py
Giorgio Gilestro 47dce1a1a4 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>
2026-05-29 19:57:12 +02:00

161 lines
6 KiB
Python

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