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:
parent
ee8384f1ba
commit
47dce1a1a4
38 changed files with 1188 additions and 279 deletions
|
|
@ -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
|
||||
|
||||
|
||||
|
|
|
|||
41
app/services/feature_flags.py
Normal file
41
app/services/feature_flags.py
Normal 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
|
||||
|
|
@ -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
|
||||
$90–93" 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."
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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 $90–93", "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,
|
||||
)
|
||||
|
|
|
|||
161
app/services/output_review_lexicon.py
Normal file
161
app/services/output_review_lexicon.py
Normal 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
|
||||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue