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>
161 lines
6 KiB
Python
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
|