"""Deterministic regex/lexicon pre-check for the output reviewer. The LLM reviewer (``app.services.output_review.review_read``) is good at nuance but is a single model — it can be inconsistent, miss novel phrasings, or be nudged by candidate content. This module is the belt under the braces: a cheap, deterministic pass that hard-catches the *obvious* advice / forecast / level-trigger patterns with zero model variance. Calling pattern: ``output_review`` runs ``check(candidate)`` *first*. A hit short-circuits to ``Verdict(clean=False, reason="lexicon:", layer="deterministic")``. If the deterministic pass returns clean, the LLM reviewer runs as the second layer. Fail-closed on either layer. Keep this file editable in one place — add patterns here as new failure modes appear in the audit log. """ from __future__ import annotations import re from dataclasses import dataclass @dataclass(frozen=True) class LexiconHit: """A deterministic-layer rejection. ``rule`` names which class of pattern matched (used as the ``reason`` field), and ``snippet`` is the matched substring (≤80 chars) for the audit log.""" rule: str snippet: str # --- Single-word / short-phrase lexicons -------------------------------------- # # These are matched as case-insensitive word-boundary regex alternations. # Keep entries multi-word where possible — bare words like "should" or # "hold" produce far too many false positives (e.g. "Saudi price cuts", # "cargo hold", "investors should be aware that…"). Phrases are sharper. # Direct action language aimed at the reader. _ACTION_PHRASES = [ r"\bbuy (?:the |this |that )?(?:dip|stock|name|sector|equity|equities|bond|bonds)\b", r"\bis a buy\b", r"\bis a sell\b", r"\btake profit\b", r"\btake profits\b", r"\btrim (?:the |your |that )?(?:position|exposure|allocation|holding|holdings)\b", r"\badd to (?:the |your |that )?(?:position|exposure|allocation|holding|holdings)\b", r"\brotate (?:into|out of)\b", r"\brebalance (?:into|out of|towards|toward)\b", r"\bunderweight (?:the |this )?(?:sector|equities|bonds|tech|defensives|energy|commodities)\b", r"\boverweight (?:the |this )?(?:sector|equities|bonds|tech|defensives|energy|commodities)\b", r"\baccumulate (?:the |this |that )?(?:position|exposure|stock|name)\b", ] # Advice register: explicit recommendations. _ADVICE_PHRASES = [ r"\byou should\b", r"\binvestors should\b", r"\bwe recommend\b", r"\bour recommendation\b", r"\b(?:is |are )?recommended\b", r"\bconsider (?:buying|selling|trimming|adding|rotating|hedging|reducing)\b", r"\bworth buying\b", r"\bworth selling\b", ] # Forecast / level register: any price-level cast as a threshold or target. _FORECAST_PHRASES = [ r"\bprice target\b", r"\btarget of \$?\d", r"\bfair value of \$?\d", r"\btripwire\b", r"\bbreakout\b", r"\bbreakdown level\b", r"\bsupport at \$?\d", r"\bresistance at \$?\d", r"\bsupport near \$?\d", r"\bresistance near \$?\d", r"\bfloor at \$?\d", r"\bceiling at \$?\d", r"\bfloor near \$?\d", r"\bceiling near \$?\d", ] # --- Composed regex patterns -------------------------------------------------- # # Pattern matches need a small amount of context. Each entry pairs a rule # name with a compiled pattern. _PATTERNS = [ # "close above $93", "break below 4600", "move above 90" ("level_trigger", re.compile( r"\b(?:close|closes|closing|break|breaks|breaking|broke|" r"move|moves|moving|hold|holds|holding|push|pushes)\s+" r"(?:above|below|through|past|over|under)\s+\$?\d", re.IGNORECASE, )), # "target(s|ed|ing) ... $95" — the words 'target' near a number ("price_target", re.compile( r"\btarget(?:s|ed|ing)?\b[^.\n]{0,30}\$?\d", re.IGNORECASE, )), # "floor/ceiling/support/resistance at/near/of $X" ("level_as_noun", re.compile( r"\b(?:floor|ceiling|support|resistance)\b[^.\n]{0,15}" r"(?:at|near|of|around|just\s+(?:above|below))\s+\$?\d", re.IGNORECASE, )), # "watch for X to break/move/close above/below Y" ("watch_for_trigger", re.compile( r"\bwatch\s+for\s+[^.\n]{0,40}\s+to\s+" r"(?:break|move|close|hold|push)\s+(?:above|below|through|past|over|under)", re.IGNORECASE, )), ] def _compile_phrase_set(phrases: list[str]) -> re.Pattern: """Compile a list of regex fragments into one alternation pattern with case-insensitive matching. The fragments already carry their own word boundaries and group structure.""" return re.compile("|".join(f"(?:{p})" for p in phrases), re.IGNORECASE) _ACTION_RE = _compile_phrase_set(_ACTION_PHRASES) _ADVICE_RE = _compile_phrase_set(_ADVICE_PHRASES) _FORECAST_RE = _compile_phrase_set(_FORECAST_PHRASES) def _snippet(text: str, match: re.Match) -> str: """Window around a match for the audit log. Caps at 80 chars.""" start = max(0, match.start() - 20) end = min(len(text), match.end() + 20) s = text[start:end].replace("\n", " ").strip() if len(s) > 80: s = s[:77] + "..." return s def check(candidate: str) -> LexiconHit | None: """Run the deterministic layer. Returns the first matching ``LexiconHit`` or None if the text is clean by these rules. First-match-wins. Order is: action phrases, advice phrases, forecast phrases, then composed patterns. Bias toward catching the most direct violations first; the audit log records which rule fired.""" if not candidate or not candidate.strip(): return None text = candidate m = _ACTION_RE.search(text) if m: return LexiconHit(rule="action_phrase", snippet=_snippet(text, m)) m = _ADVICE_RE.search(text) if m: return LexiconHit(rule="advice_phrase", snippet=_snippet(text, m)) m = _FORECAST_RE.search(text) if m: return LexiconHit(rule="forecast_phrase", snippet=_snippet(text, m)) for rule, pat in _PATTERNS: m = pat.search(text) if m: return LexiconHit(rule=rule, snippet=_snippet(text, m)) return None