diff --git a/.env.example b/.env.example index 889bd4d..83f4f24 100644 --- a/.env.example +++ b/.env.example @@ -25,3 +25,11 @@ OPENROUTER_MODEL=deepseek/deepseek-v4-flash # cheap & fast; swap to anthropic OPENROUTER_MONTHLY_CAP_USD=20 CASSANDRA_TONE=INTERMEDIATE # NOVICE | INTERMEDIATE | PRO CASSANDRA_ANALYSIS=SPECULATIVE # DRY | SPECULATIVE + +# --- Compliance feature flags (default false = compliance-safe) --- +# See docs/read-markets-compliance-changes.md. Code paths stay in the tree; +# flip a flag to reactivate. +PORTFOLIO_AI_ENABLED=false # /api/analyze + dashboard AI read + reviewer portfolio rider +PORTFOLIO_SYNC_ENABLED=false # /api/portfolio/sync* + cloud-sync UI + PortfolioSync writes +TICKER_UNIVERSE_AGGREGATE_ENABLED=false # ticker_universe buffer/flush/upsert writes (server-learns-nothing) +SUBSCRIPTIONS_ENABLED=false # Stripe checkout/webhook/portal + /pricing + paid-tier gating diff --git a/alembic/versions/0026_compliance_purge_and_audit.py b/alembic/versions/0026_compliance_purge_and_audit.py new file mode 100644 index 0000000..1738fcf --- /dev/null +++ b/alembic/versions/0026_compliance_purge_and_audit.py @@ -0,0 +1,75 @@ +"""compliance purge (portfolio_sync, ticker_universe) + reviewer_verdicts audit table. + +Revision ID: 0026 +Revises: 0025 +Create Date: 2026-05-29 + +See docs/read-markets-compliance-changes.md. + +Two unrelated changes bundled into one migration because they ship together: + +1. Purge — the server learns nothing about anyone's holdings while the + compliance flags are off. Empties ``portfolio_sync`` (per-user encrypted + ciphertext blobs) and ``ticker_universe`` (the anonymous aggregate set of + tickers ever uploaded). Tables stay; data goes. If a flag is later + re-enabled, the tables refill from scratch. + +2. Audit — adds ``reviewer_verdicts`` so every output-reviewer decision + (deterministic + LLM, pass and fail) is persisted with surface, candidate + text, reason, layer, and model. This is the regulator-facing evidence that + automated review runs on every published item. + +Downgrade restores neither the purged data nor the historical verdicts — +both are destructive; downgrade just drops the audit table. +""" +from typing import Sequence, Union + +import sqlalchemy as sa +from alembic import op + + +revision: str = "0026" +down_revision: Union[str, None] = "0025" +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + # 1. Purge — server holds no portfolio data. + op.execute("DELETE FROM portfolio_sync") + op.execute("DELETE FROM ticker_universe") + + # 2. Audit trail for the two-layer output reviewer. Append-only. + op.create_table( + "reviewer_verdicts", + sa.Column("id", sa.BigInteger().with_variant(sa.Integer(), "sqlite"), + primary_key=True, autoincrement=True), + sa.Column("created_at", sa.DateTime(timezone=True), nullable=False, + server_default=sa.text("CURRENT_TIMESTAMP")), + sa.Column("surface", sa.String(length=32), nullable=True), + sa.Column("candidate_text", sa.Text(), nullable=False), + sa.Column("clean", sa.Boolean(), nullable=False), + sa.Column("reason", sa.String(length=255), nullable=True), + sa.Column("layer", sa.String(length=16), nullable=False), + sa.Column("model", sa.String(length=64), nullable=True), + ) + op.create_index( + "ix_reviewer_verdicts_created_at", + "reviewer_verdicts", ["created_at"], + ) + op.create_index( + "ix_reviewer_verdicts_surface", + "reviewer_verdicts", ["surface"], + ) + op.create_index( + "ix_reviewer_verdicts_clean", + "reviewer_verdicts", ["clean"], + ) + + +def downgrade() -> None: + op.drop_index("ix_reviewer_verdicts_clean", table_name="reviewer_verdicts") + op.drop_index("ix_reviewer_verdicts_surface", table_name="reviewer_verdicts") + op.drop_index("ix_reviewer_verdicts_created_at", table_name="reviewer_verdicts") + op.drop_table("reviewer_verdicts") + # Purges are not restored on downgrade — the data is gone. diff --git a/app/config.py b/app/config.py index 6c5b0ee..975f165 100644 --- a/app/config.py +++ b/app/config.py @@ -105,6 +105,25 @@ class Settings(BaseSettings): STRIPE_PRICE_MONTHLY: str = "" # price_xxx for £7/month subscription STRIPE_PRICE_ANNUAL: str = "" # price_xxx for £70/year subscription + # Compliance feature flags. All default False so a fresh deploy is on the + # compliance-safe side. Code paths stay in the tree — flip a flag to + # reactivate. See docs/read-markets-compliance-changes.md. + # + # PORTFOLIO_AI_ENABLED — gates /api/analyze, portfolio_analysis.analyse(), + # the AI-read UI on the dashboard, and the output-reviewer portfolio rider. + # PORTFOLIO_SYNC_ENABLED — gates /api/portfolio/sync* routes, portfolio_sync + # service, the cloud-sync UI/JS, and PortfolioSync writes. + # TICKER_UNIVERSE_AGGREGATE_ENABLED — gates server-side per-ticker aggregate + # union writes (ticker_universe.buffer/flush/upsert). When off, the server + # learns nothing about what anyone holds. + # SUBSCRIPTIONS_ENABLED — gates Stripe checkout/webhook/portal, the /pricing + # page, and is_paid_active(). When off, is_paid_active() returns True for + # every logged-in user (free-for-all while subscriptions are paused). + PORTFOLIO_AI_ENABLED: bool = False + PORTFOLIO_SYNC_ENABLED: bool = False + TICKER_UNIVERSE_AGGREGATE_ENABLED: bool = False + SUBSCRIPTIONS_ENABLED: bool = False + # Config file locations (overridable for tests) BASELINE_TOML: Path = Field(default_factory=lambda: CONFIG_DIR / "default.toml") PORTFOLIO_TOML: Path = Field(default_factory=lambda: CONFIG_DIR / "portfolio.toml") diff --git a/app/jobs/ai_log_job.py b/app/jobs/ai_log_job.py index 197faa5..fdf2a23 100644 --- a/app/jobs/ai_log_job.py +++ b/app/jobs/ai_log_job.py @@ -206,7 +206,8 @@ async def run() -> None: # that drifted past the generator's system prompt. Drop # rejected variants; the API falls back to the previous # clean StrategicLog row. - verdict = await review_read(client, result.content) + verdict = await review_read(client, result.content, + surface="log", session=session) full_cost = (result.cost_usd or 0.0) + (verdict.cost_usd or 0.0) if not verdict.clean: session.add(AICall( diff --git a/app/jobs/indicator_summary_job.py b/app/jobs/indicator_summary_job.py index 422c49c..4a81aaf 100644 --- a/app/jobs/indicator_summary_job.py +++ b/app/jobs/indicator_summary_job.py @@ -184,7 +184,8 @@ async def _generate_one( )) return None - verdict = await review_read(client, candidate) + verdict = await review_read(client, candidate, + surface="indicator", session=session) if not verdict.clean: # Reviewer caught scratchpad / meta-commentary / partial text # INSIDE the read field. Drop the candidate; the previous good @@ -314,7 +315,10 @@ async def run() -> None: cost_usd=result.cost_usd, status="leaked", )) else: - verdict = await review_read(client, candidate) + verdict = await review_read( + client, candidate, + surface="indicator_aggregate", session=session, + ) full_cost = (result.cost_usd or 0.0) + (verdict.cost_usd or 0.0) if not verdict.clean: log.warning("ind_summary.agg_reviewer_rejected", diff --git a/app/locales/en.yaml b/app/locales/en.yaml index a36e314..d8c9bb4 100644 --- a/app/locales/en.yaml +++ b/app/locales/en.yaml @@ -77,12 +77,11 @@ features: every hour; free users get one every six. multilang_callout: >- - Every AI-generated surface — strategic log, indicator reads, - portfolio analysis, chat, daily digest — is available in - English and Italian. Toggle the - language pill in the header and the panels refresh in place; - ticker symbols, currency codes and numbers stay verbatim across - languages. + Every AI-generated surface — strategic log, indicator reads, chat, + daily digest — is available in English and + Italian. Toggle the language pill in the header + and the panels refresh in place; ticker symbols, currency codes + and numbers stay verbatim across languages. more_views: head: "More views" @@ -96,10 +95,10 @@ more_views: caption_span: "Conversational follow-ups with the day's context loaded." portfolio_blurb: >- - Paid users can also drop a portfolio CSV from their broker for an - AI sense-check on concentration, regime fit, and currency - exposure. Holdings stay in your browser by default; opt in to - encrypted cloud sync to restore on another device. + Drop a portfolio CSV from your broker to see your sector, currency + and concentration breakdown — computed entirely in your browser. + Holdings stay in your browser; nothing about them is sent to or + stored on the server. not_strip: head: "What this isn't." diff --git a/app/locales/it.yaml b/app/locales/it.yaml index b0188aa..51a8417 100644 --- a/app/locales/it.yaml +++ b/app/locales/it.yaml @@ -82,10 +82,10 @@ features: multilang_callout: >- Ogni superficie generata dall'IA — log strategico, letture degli - indicatori, analisi di portafoglio, chat, digest giornaliero — è - disponibile in Inglese e Italiano. - Usa il selettore di lingua nell'header e i pannelli si aggiornano - in tempo reale; simboli ticker, codici valuta e numeri restano + indicatori, chat, digest giornaliero — è disponibile in + Inglese e Italiano. Usa il + selettore di lingua nell'header e i pannelli si aggiornano in + tempo reale; simboli ticker, codici valuta e numeri restano invariati tra le lingue. more_views: @@ -101,12 +101,10 @@ more_views: caption_span: "Domande conversazionali con il contesto della giornata già caricato." portfolio_blurb: >- - Gli utenti paganti possono anche caricare un CSV di portafoglio - dal loro broker per un sense-check IA su concentrazione, - coerenza con il regime di mercato ed esposizione valutaria. Le - posizioni restano nel browser per impostazione predefinita; - l'attivazione opzionale del cloud sync (crittografato) - permette di ritrovarle su un altro dispositivo. + Carica un CSV di portafoglio dal tuo broker per vedere la + ripartizione per settore, valuta e concentrazione — calcolata + interamente nel tuo browser. Le posizioni restano nel browser; + nulla sui tuoi titoli viene inviato o conservato sul server. not_strip: head: "Cosa questo NON è." diff --git a/app/models.py b/app/models.py index 57c9f19..b454f76 100644 --- a/app/models.py +++ b/app/models.py @@ -220,6 +220,32 @@ class AICall(Base): error: Mapped[str | None] = mapped_column(String(512)) +class ReviewerVerdict(Base): + """Append-only audit log of every output-reviewer verdict. + + Both layers (deterministic lexicon + LLM) write here, pass and fail. + 'Here is a complete log showing fail-closed automated review on every + published item' is the regulator-facing answer; this table is what backs + that claim. See app/services/output_review.py.""" + __tablename__ = "reviewer_verdicts" + id: Mapped[int] = mapped_column(_PK, primary_key=True, autoincrement=True) + created_at: Mapped[datetime] = mapped_column( + DateTime(timezone=True), default=utcnow, index=True, + ) + # Caller-supplied tag — "log", "indicator", "indicator_aggregate", "chat", + # "portfolio", "digest", or None for unattributed. + surface: Mapped[str | None] = mapped_column(String(32), index=True) + # The candidate text being reviewed. Truncated upstream to avoid pathological + # writes; Text type accepts whatever fits. + candidate_text: Mapped[str] = mapped_column(Text, nullable=False) + clean: Mapped[bool] = mapped_column(Boolean, nullable=False, index=True) + reason: Mapped[str | None] = mapped_column(String(255)) + # Which layer fired: "deterministic" | "llm" | "error". + layer: Mapped[str] = mapped_column(String(16), nullable=False) + # LLM-layer model id, nullable for deterministic / error rows. + model: Mapped[str | None] = mapped_column(String(64)) + + # Portfolio / PortfolioSnapshot / Position removed in Phase G — # holdings live in the browser, the server stores only the anonymous # ticker universe + public market data. diff --git a/app/routers/chat.py b/app/routers/chat.py index 6e12a8e..4c921b0 100644 --- a/app/routers/chat.py +++ b/app/routers/chat.py @@ -188,7 +188,8 @@ async def chat( # leading question; the generator's system prompt forbids it, # but the reviewer is the enforcement layer. ~1-2 s extra # latency per turn on top of the generation call. - verdict = await review_read(client, result.content) + verdict = await review_read(client, result.content, + surface="chat", session=session) except Exception as e: session.add(AICall( model=s.OPENROUTER_MODEL, status="error", error=str(e)[:500], diff --git a/app/routers/public.py b/app/routers/public.py index 33bd245..661fadf 100644 --- a/app/routers/public.py +++ b/app/routers/public.py @@ -16,6 +16,7 @@ from fastapi.responses import HTMLResponse from app.auth import CurrentUser, maybe_current_user from app.services.access import is_paid_active +from app.services.feature_flags import require_flag from app.templates_env import templates @@ -29,7 +30,11 @@ def _ctx(request: Request, cu: CurrentUser | None) -> dict: return {"cu": cu} -@router.get("/pricing", response_class=HTMLResponse) +@router.get( + "/pricing", + response_class=HTMLResponse, + dependencies=[Depends(require_flag("SUBSCRIPTIONS_ENABLED"))], +) async def pricing_page( request: Request, cu: CurrentUser | None = Depends(maybe_current_user), diff --git a/app/routers/stripe_billing.py b/app/routers/stripe_billing.py index bfdeed0..169e5a1 100644 --- a/app/routers/stripe_billing.py +++ b/app/routers/stripe_billing.py @@ -34,10 +34,16 @@ from app.config import get_settings from app.db import get_session, utcnow from app.logging import get_logger from app.models import StripeEvent, User +from app.services.feature_flags import require_flag log = get_logger("stripe_billing") -router = APIRouter() +# Whole router gated by SUBSCRIPTIONS_ENABLED: checkout, portal, and webhook +# all 404 when the subscription system is paused. The webhook gate keeps us +# from accidentally processing a late delivery while the surface is "off". +router = APIRouter( + dependencies=[Depends(require_flag("SUBSCRIPTIONS_ENABLED"))], +) # Cap stored payload at 16 KiB so a hostile (or buggy) sender can't diff --git a/app/routers/sync.py b/app/routers/sync.py index 0fa1174..7b4ecf1 100644 --- a/app/routers/sync.py +++ b/app/routers/sync.py @@ -20,11 +20,17 @@ from app.db import get_session from app.logging import get_logger from app.services import portfolio_sync as svc from app.services.access import require_paid +from app.services.feature_flags import require_flag log = get_logger("portfolio_sync_router") -router = APIRouter(prefix="/api/portfolio/sync") +# Whole router gated by PORTFOLIO_SYNC_ENABLED: when the flag is off, every +# endpoint here 404s — indistinguishable from a non-existent surface. +router = APIRouter( + prefix="/api/portfolio/sync", + dependencies=[Depends(require_flag("PORTFOLIO_SYNC_ENABLED"))], +) # A 256 KB cap is ~200× a typical pie's serialized size — generous diff --git a/app/routers/universe.py b/app/routers/universe.py index ea1d633..aa54ddf 100644 --- a/app/routers/universe.py +++ b/app/routers/universe.py @@ -41,6 +41,7 @@ from app.models import Quote, QuoteDaily from app.services import fx, portfolio_analysis, ticker_universe from app.services.access import require_paid from app.services.csv_import import CSVImportError, parse_t212_csv +from app.services.feature_flags import require_flag from app.services.instrument_map import resolve_slice from app.services.market import fetch as market_fetch @@ -338,7 +339,10 @@ async def parse_portfolio( # --------------------------------------------------------------------------- -@router.post("/analyze") +@router.post( + "/analyze", + dependencies=[Depends(require_flag("PORTFOLIO_AI_ENABLED"))], +) async def analyze_portfolio( request: Request, session: AsyncSession = Depends(get_session), @@ -349,8 +353,8 @@ async def analyze_portfolio( is persisted. The ai_calls ledger row records tokens + cost, never holdings. - Gated behind ``require_paid``: free-tier users get 402. - Admin bearer-token bypasses the gate for testing.""" + Gated behind ``PORTFOLIO_AI_ENABLED`` (404 when off) and ``require_paid`` + (402 for free tier when subscriptions are active).""" # Read JSON body manually so we can enforce a hard size cap. FastAPI's # default body limit is generous; we want tighter control here. body = await request.body() diff --git a/app/services/access.py b/app/services/access.py index 2f91f7a..10e04c9 100644 --- a/app/services/access.py +++ b/app/services/access.py @@ -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 diff --git a/app/services/feature_flags.py b/app/services/feature_flags.py new file mode 100644 index 0000000..7cd7f83 --- /dev/null +++ b/app/services/feature_flags.py @@ -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 diff --git a/app/services/llm_prompts.py b/app/services/llm_prompts.py index 726b60a..7806754 100644 --- a/app/services/llm_prompts.py +++ b/app/services/llm_prompts.py @@ -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

,

, -

- The portfolio feature does not produce buy, sell or hold - recommendations and does not consider your wider finances, debts, - tax position or objectives. It is not regulated investment advice - or a personal recommendation under FSMA / FCA COBS. -

{% if paid %} Manage subscription @@ -190,17 +182,7 @@ Sunday + daily Mon–Sat - Portfolio import (broker CSV) - — - Included - - - AI portfolio read - — - Included - - - Encrypted cloud sync + Browser-only portfolio composition viewer — Included @@ -265,18 +247,10 @@

How the data is handled

- Your portfolio holdings live in your browser’s local storage by - default. The server only learns which Yahoo tickers appear across the - user base — an anonymous union, with no link back to any specific - user. -

-

- If you opt in to encrypted cloud sync, your pie is - encrypted in your browser with a PIN you choose, then sent to the - server. We add a second layer of encryption with a key only the - server holds. We never see your holdings as plaintext, and forgetting - the PIN means we can’t recover it for you. Full details on the - privacy page. + Your portfolio holdings live in your browser’s local storage. + The CSV is parsed in your browser, the resulting pie is kept there, + and nothing about your holdings is sent to or stored on the server. + Full details on the privacy page.

diff --git a/app/templates/privacy.html b/app/templates/privacy.html index 736af55..dbd6ec1 100644 --- a/app/templates/privacy.html +++ b/app/templates/privacy.html @@ -37,24 +37,9 @@ It contains your user id only and is signed so we can detect tampering. Cookie is marked Secure and HttpOnly. -
  • - Anonymous ticker universe: when you upload a - portfolio CSV we record which Yahoo tickers appear, with - no link to your account. The same row would exist whether - any specific user holds the ticker or not — once a ticker is in - the universe, the row carries no signal as to whose import added it. -
  • -
  • - If you opt in to encrypted cloud sync: an opaque - blob of bytes per user. The blob is your portfolio, encrypted in - your browser with a PIN you choose, then wrapped a second time on - the server with a key only the server holds. We can’t decrypt - the blob to plaintext without your PIN, and we can’t recover - your PIN if you forget it. By enabling cloud sync you give your - consent (UK-GDPR Art. 6(1)(a)) to this processing; you can - withdraw consent at any time by disabling sync in Settings, which - also removes the server-side blob. -
  • + {# Cloud sync + server-side per-ticker aggregate union are flag-gated off. + See docs/read-markets-compliance-changes.md and app/config.py + (PORTFOLIO_SYNC_ENABLED, TICKER_UNIVERSE_AGGREGATE_ENABLED). #}
  • Anonymised cost ledger of AI calls (model, tokens, cost). No portfolio or personal data is attached to ledger rows. @@ -76,10 +61,12 @@

    What we don’t collect

    • - Your portfolio holdings as plaintext on the server. - Parsed pies are returned to your browser and kept in - localStorage. The server’s view is the anonymous - ticker universe described above. + Your portfolio holdings, in any form, on the server. + The portfolio feature is a browser-only composition viewer: + uploaded CSVs are parsed and returned to your browser, kept in + localStorage, and never sent back to or stored on + the server. The server records no per-ticker aggregate of what + anyone holds.
    • Third-party analytics or ad cookies. No Google @@ -104,24 +91,14 @@
      • Performance of a contract (Art. 6(1)(b)) — for - operating your account, the sign-in flow, paid features, and the - mechanics of encrypted cloud sync. + operating your account, the sign-in flow, and any paid features.
      • Legitimate interests (Art. 6(1)(f)) — for the - anonymous ticker universe, the anonymised cost ledger, job-run - telemetry, and reverse-proxy access logs. Our interest is the - secure, abuse-resistant, cost-controlled operation of a free - public service, balanced against the minimal and de-identified - nature of the data. -
      • -
      • - Consent (Art. 6(1)(a)) — where you opt in to - encrypted cloud sync (and the related caching of a derived - encryption key in your browser’s sessionStorage). - You can withdraw consent at any time by disabling sync in - Settings; the cached key is cleared and the server-side blob is - removed. + anonymised cost ledger, job-run telemetry, and reverse-proxy access + logs. Our interest is the secure, abuse-resistant, cost-controlled + operation of a free public service, balanced against the minimal + and de-identified nature of the data.
      @@ -131,9 +108,10 @@

      The Service does not make decisions about you that produce legal or similarly significant effects in an automated way (UK-GDPR Art. 22). - The AI portfolio analysis is editorial commentary on the holdings - you upload; it does not approve, reject or rank you, and you remain - the sole decision-maker about anything in your account. + The strategic log and indicator summaries are general editorial + commentary on public market data, not personalised assessments of + you, and you remain the sole decision-maker about anything in your + account.

      @@ -151,13 +129,9 @@ browser.
    • - Local portfolio + cached sync key — parsed pies - live in localStorage on your device. If you enable - cloud sync, the derived encryption key is cached in - sessionStorage so you don’t have to re-enter - your PIN on every navigation. This caching is performed only with - your consent (given when you enable sync); it is cleared when you - close the tab or disable sync. + Local portfolio — parsed pies live in + localStorage on your device. They are not sent to + or stored on the server.
    @@ -177,14 +151,15 @@ currently inside the UK; if that changes we will update this notice.
  • - AI provider calls for the strategic log, indicator - summaries, and (paid) portfolio analysis. Where the provider sits - outside the UK, we rely on the UK International Data Transfer - Agreement (IDTA) / the UK Addendum to the EU Standard Contractual - Clauses where no adequacy decision applies. Each outbound request - carries an explicit no-training opt-out header + AI provider calls for the strategic log and + indicator summaries. Where the provider sits outside the UK, we + rely on the UK International Data Transfer Agreement (IDTA) / the + UK Addendum to the EU Standard Contractual Clauses where no + adequacy decision applies. Each outbound request carries an + explicit no-training opt-out header (X-OR-Allow-Training: false on OpenRouter); see the - Third parties section below for the caveats. + Third parties section below for the caveats. None of these + outbound requests contain user holdings or other portfolio data.
  • @@ -200,15 +175,6 @@ Session cookies: expire automatically; you can sign out at any time to revoke. -
  • - Ticker universe: rows untouched for 60 days are - evicted by a nightly job. Active tickers remain. -
  • -
  • - Encrypted portfolio blob: kept until you disable - cloud sync (one click in Settings) or delete your account. We hold - one row per user; new uploads overwrite the previous blob. -
  • Account: held until you ask us to delete it. Email {{ OPERATOR_EMAIL }}. @@ -230,22 +196,18 @@
  • AI provider(s): DeepSeek (primary) with OpenRouter - as a fallback. They see the prompt for the strategic log, the - indicator summaries, and the portfolio analysis call — which - contains your holdings only when you press - “Generate AI analysis” on a paid plan, and only for the - duration of that single call. The portfolio analysis output is not - persisted on the server. + as a fallback. They see the prompt for the strategic log and the + indicator summaries. These prompts contain public market data and + headlines — never any user holdings or portfolio data.
    No-training opt-out. Every OpenRouter request carries the X-OR-Allow-Training: false header, which signals to OpenRouter and any compatible upstream that the prompt must not be used to train or improve models. DeepSeek does not - currently expose a per-request opt-out; if you do not want your - holdings to leave our server at all, do not use the AI portfolio - analysis feature. We do not control retention or training policies - on the provider side beyond the headers we set — the provider’s - own published data policy is the binding statement on that point. + currently expose a per-request opt-out. We do not control + retention or training policies on the provider side beyond the + headers we set — the provider’s own published data policy is + the binding statement on that point.
  • Market-data sources: Yahoo Finance and a small set @@ -263,7 +225,7 @@
  • Have inaccurate data corrected (Art. 16, rectification).
  • Have your account and associated data deleted (Art. 17, erasure).
  • Export the data you can recognise (Art. 20, portability): your - email, any active encrypted blob, your referral linkage.
  • + email and your referral linkage.
  • Restrict processing (Art. 18).
  • Object specifically to processing carried out on the basis of legitimate interests (Art. 21), including any direct marketing.
  • diff --git a/app/templates/public_base.html b/app/templates/public_base.html index 47dd096..9ec66d9 100644 --- a/app/templates/public_base.html +++ b/app/templates/public_base.html @@ -36,7 +36,9 @@ {{ BRAND_NAME }}