Compare commits

..

No commits in common. "feat/superadmin-console" and "main" have entirely different histories.

82 changed files with 475 additions and 5422 deletions

View file

@ -16,13 +16,6 @@ OPENROUTER_API_KEY= # OpenRouter (AI log generation)
# --- App --- # --- App ---
CASSANDRA_TOKEN= # Bearer token required if set; LAN-only no-auth if empty CASSANDRA_TOKEN= # Bearer token required if set; LAN-only no-auth if empty
CASSANDRA_PORT=8000 CASSANDRA_PORT=8000
# --- Superadmin console (internal-only, separate `admin` container) ---
# Password for the read-only operator dashboard at 127.0.0.1:8091 (reach it
# via `ssh -L 8091:localhost:8091 <vps>`). Empty = console refuses every
# login (closed by default). Set a strong value in prod.
ADMIN_CONSOLE_PASSWORD=
ADMIN_CONSOLE_SESSION_SECRET= # cookie signing key; falls back to CASSANDRA_SESSION_SECRET/TOKEN if empty
CASSANDRA_BASE_CURRENCY=GBP CASSANDRA_BASE_CURRENCY=GBP
CASSANDRA_ANCHOR_DATE=2026-03-04 # YYYY-MM-DD; used by market_pulse anchor column CASSANDRA_ANCHOR_DATE=2026-03-04 # YYYY-MM-DD; used by market_pulse anchor column
CASSANDRA_MOCK=0 # 1 = serve canned fixtures, skip live APIs CASSANDRA_MOCK=0 # 1 = serve canned fixtures, skip live APIs
@ -32,11 +25,3 @@ OPENROUTER_MODEL=deepseek/deepseek-v4-flash # cheap & fast; swap to anthropic
OPENROUTER_MONTHLY_CAP_USD=20 OPENROUTER_MONTHLY_CAP_USD=20
CASSANDRA_TONE=INTERMEDIATE # NOVICE | INTERMEDIATE | PRO CASSANDRA_TONE=INTERMEDIATE # NOVICE | INTERMEDIATE | PRO
CASSANDRA_ANALYSIS=SPECULATIVE # DRY | SPECULATIVE 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

6
.gitignore vendored
View file

@ -9,10 +9,8 @@ __pycache__/
.ruff_cache/ .ruff_cache/
.venv/ .venv/
venv/ venv/
# Everything under backup/ is operational data, never source: DB dumps and backup/*.sql
# pre-change .env copies (which hold live Stripe/SMTP secrets). The earlier backup/*.sql.gz
# backup/*.sql* patterns missed the .env copies — ignore the whole directory.
backup/
*.egg-info/ *.egg-info/
build/ build/
dist/ dist/

View file

@ -32,12 +32,10 @@ RUN apt-get update \
COPY --from=builder /opt/venv /opt/venv COPY --from=builder /opt/venv /opt/venv
WORKDIR /app WORKDIR /app
COPY app ./app COPY app ./app
COPY admin ./admin
COPY alembic ./alembic COPY alembic ./alembic
COPY alembic.ini ./ COPY alembic.ini ./
# Default command is the web app; the scheduler and admin-console containers # Default command is the web app; scheduler container overrides via `command:`.
# override via `command:` (see docker-compose.yml).
EXPOSE 8000 EXPOSE 8000
CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000", "--workers", "1"] CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000", "--workers", "1"]
@ -59,7 +57,6 @@ COPY --from=builder /opt/venv /opt/venv
WORKDIR /app WORKDIR /app
COPY pyproject.toml requirements.lock ./ COPY pyproject.toml requirements.lock ./
COPY app ./app COPY app ./app
COPY admin ./admin
COPY alembic ./alembic COPY alembic ./alembic
COPY alembic.ini ./ COPY alembic.ini ./
# tests/ is excluded by .dockerignore (prod-correct: never bake tests into # tests/ is excluded by .dockerignore (prod-correct: never bake tests into

View file

@ -1,54 +0,0 @@
# Superadmin console
An **independent, internal-only** web service for the operator: user list with
per-user history + payment status, and database usage stats. Runs in its own
container (`admin` service) off the same image as the main app, reusing
`app.db` + `app.models`, but it is a separate FastAPI app (`admin.main:app`)
that **never** runs migrations or the scheduler and only ever issues `SELECT`s.
## Access model
- **Dev:** bound to **`127.0.0.1:8091`** on the host (loopback only, from
`docker-compose.override.yml`). Open <http://localhost:8091>.
- **Prod:** no host port. The container joins the `intranet` network and
listens on port 80, so **Nginx Proxy Manager** fronts it like the main app
(upstream `readmarkets-admin-1:80`). Point an NPM proxy host at it and,
ideally, add an NPM access list / basic-auth as a second layer.
- Gated by a single shared password, `ADMIN_CONSOLE_PASSWORD` (in `.env`).
Empty password ⇒ every login is refused (closed by default). The login sets
a 12-hour signed cookie (`admin_console_session`).
> The console is now reachable on whatever public hostname NPM maps to it —
> it is no longer air-gapped behind an SSH tunnel. Keep `ADMIN_CONSOLE_PASSWORD`
> strong and prefer adding an NPM access rule in front of it.
## Pages
- `/` — overview: totals, tier split, paid-active, signups 7/30d, sync count,
referral conversions, newest users.
- `/users` — full user table with email search + paging.
- `/users/{id}` — one user: account, computed paid status, Stripe/Polar
linkage, cloud-sync state, referrals sent, feedback votes, digest-email log,
legal acknowledgements.
- `/db` — per-table row estimates and on-disk size (from
`information_schema.tables`), with each table's share of total.
## Deploy (this host runs prod)
Adding a **new** service means `up -d` (a plain `restart` won't create it).
Do NOT run a bare `docker compose up` on this host — always pass the prod
overlay:
```sh
# 1. set ADMIN_CONSOLE_PASSWORD in .env
# 2. build + create just the admin container (leaves app/scheduler/db running)
docker compose -f docker-compose.yml -f docker-compose.prod.yml up -d --build admin
# 3. in Nginx Proxy Manager: add a proxy host → forward to readmarkets-admin-1
# port 80 (both containers are on the `intranet` network).
```
## Tests
```sh
docker compose -f docker-compose.test.yml run --rm test pytest tests/test_admin_console.py -v
```

View file

@ -1,9 +0,0 @@
"""Superadmin console — an independent, internal-only web service.
Runs in its own container (see the `admin` service in docker-compose.yml),
bound to 127.0.0.1 on the VPS and reached over an SSH tunnel. It reuses the
main app's ORM models and DB engine (`app.db`, `app.models`) but is a wholly
separate FastAPI app: it never imports `app.main`, so it does NOT run Alembic
migrations or the app's lifespan. Every DB access is a plain SELECT — the
console never writes.
"""

View file

@ -1,71 +0,0 @@
"""Password gate for the superadmin console.
A single shared password (``ADMIN_CONSOLE_PASSWORD``) is exchanged at
``/login`` for a signed, time-limited session cookie. There is no user
identity here the console has exactly one principal, "the operator".
The cookie is signed with itsdangerous using ``ADMIN_CONSOLE_SESSION_SECRET``
(falling back to the main app's session secret / token), mirroring the scheme
in ``app.auth`` but under a distinct salt so an app session cookie can never
be replayed against the console and vice-versa.
"""
from __future__ import annotations
import secrets
from fastapi import HTTPException, Request, status
from itsdangerous import BadSignature, SignatureExpired, URLSafeTimedSerializer
from app.config import get_settings
SESSION_COOKIE_NAME = "admin_console_session"
SESSION_TTL_SECONDS = 12 * 60 * 60 # 12 hours — re-auth daily-ish.
_SALT = "admin-console-session-v1"
def _serializer() -> URLSafeTimedSerializer:
s = get_settings()
secret = (
s.ADMIN_CONSOLE_SESSION_SECRET
or s.CASSANDRA_SESSION_SECRET
or s.CASSANDRA_TOKEN
or "dev-insecure-secret"
)
return URLSafeTimedSerializer(secret, salt=_SALT)
def check_password(provided: str) -> bool:
"""Constant-time compare against ``ADMIN_CONSOLE_PASSWORD``. Returns
False when the password is unset the console is closed by default so a
fresh deploy can't be logged into without an explicit password."""
expected = get_settings().ADMIN_CONSOLE_PASSWORD
if not expected:
return False
return secrets.compare_digest(provided.encode(), expected.encode())
def sign_session() -> str:
"""Signed value proving a successful password login. Carries no identity
beyond a version marker the console has a single principal."""
return _serializer().dumps({"v": 1})
def verify_session(cookie: str) -> bool:
try:
data = _serializer().loads(cookie, max_age=SESSION_TTL_SECONDS)
return data.get("v") == 1
except (BadSignature, SignatureExpired, KeyError, TypeError, ValueError):
return False
def require_admin(request: Request) -> None:
"""FastAPI dependency guarding every console page. Valid cookie → passes;
otherwise 303 /login (browser) so the operator lands on the form."""
cookie = request.cookies.get(SESSION_COOKIE_NAME)
if cookie and verify_session(cookie):
return
raise HTTPException(
status_code=status.HTTP_303_SEE_OTHER,
detail="Login required",
headers={"Location": "/login"},
)

View file

@ -1,140 +0,0 @@
"""Superadmin console FastAPI app.
Independent of ``app.main``: it wires only its own routes and reuses
``app.db``'s session factory for read-only queries. No lifespan, no
migrations, no scheduler.
Run: uvicorn admin.main:app --host 0.0.0.0 --port 8000
"""
from __future__ import annotations
from datetime import datetime, timezone
from pathlib import Path
from fastapi import Depends, FastAPI, Form, Query, Request
from fastapi.responses import HTMLResponse, PlainTextResponse, RedirectResponse
from fastapi.templating import Jinja2Templates
from sqlalchemy.ext.asyncio import AsyncSession
from admin import auth, queries
from app.db import get_session
TEMPLATE_DIR = Path(__file__).resolve().parent / "templates"
templates = Jinja2Templates(directory=str(TEMPLATE_DIR))
def _fmt_bytes(n: int | None) -> str:
if not n:
return "0 B"
units = ["B", "KB", "MB", "GB", "TB"]
size = float(n)
for u in units:
if size < 1024 or u == units[-1]:
return f"{size:,.0f} {u}" if u == "B" else f"{size:,.1f} {u}"
size /= 1024
return f"{n} B"
def _fmt_dt(dt: datetime | None) -> str:
if dt is None:
return ""
if dt.tzinfo is None:
dt = dt.replace(tzinfo=timezone.utc)
return dt.strftime("%Y-%m-%d %H:%M UTC")
templates.env.filters["bytes"] = _fmt_bytes
templates.env.filters["dt"] = _fmt_dt
app = FastAPI(title="read.markets superadmin console", docs_url=None, redoc_url=None)
@app.get("/healthz", response_class=PlainTextResponse, include_in_schema=False)
async def healthz() -> str:
return "ok"
@app.get("/login", response_class=HTMLResponse, include_in_schema=False)
async def login_form(request: Request):
# Already signed in? Skip the form.
cookie = request.cookies.get(auth.SESSION_COOKIE_NAME)
if cookie and auth.verify_session(cookie):
return RedirectResponse("/", status_code=303)
return templates.TemplateResponse(request, "login.html", {"error": None})
@app.post("/login", response_class=HTMLResponse, include_in_schema=False)
async def login_submit(request: Request, password: str = Form(...)):
if not auth.check_password(password):
return templates.TemplateResponse(
request, "login.html", {"error": "Incorrect password."},
status_code=401,
)
resp = RedirectResponse("/", status_code=303)
resp.set_cookie(
auth.SESSION_COOKIE_NAME,
auth.sign_session(),
max_age=auth.SESSION_TTL_SECONDS,
httponly=True,
samesite="lax",
# secure=False: the console is served over http on a localhost SSH
# tunnel, never public TLS. A secure cookie would never be sent.
secure=False,
)
return resp
@app.get("/logout", include_in_schema=False)
async def logout():
resp = RedirectResponse("/login", status_code=303)
resp.delete_cookie(auth.SESSION_COOKIE_NAME)
return resp
@app.get("/", response_class=HTMLResponse, include_in_schema=False,
dependencies=[Depends(auth.require_admin)])
async def overview(request: Request, session: AsyncSession = Depends(get_session)):
stats = await queries.overview_stats(session)
recent, _ = await queries.list_users(session, limit=10)
return templates.TemplateResponse(
request, "overview.html", {"stats": stats, "recent": recent},
)
@app.get("/users", response_class=HTMLResponse, include_in_schema=False,
dependencies=[Depends(auth.require_admin)])
async def users_list(
request: Request,
session: AsyncSession = Depends(get_session),
q: str | None = Query(default=None),
page: int = Query(default=1, ge=1),
):
per_page = 200
offset = (page - 1) * per_page
rows, total = await queries.list_users(session, q=q, limit=per_page, offset=offset)
return templates.TemplateResponse(
request, "users.html",
{"rows": rows, "total": total, "q": q or "",
"page": page, "per_page": per_page},
)
@app.get("/users/{user_id}", response_class=HTMLResponse, include_in_schema=False,
dependencies=[Depends(auth.require_admin)])
async def user_detail(
request: Request, user_id: int,
session: AsyncSession = Depends(get_session),
):
detail = await queries.user_detail(session, user_id)
if detail is None:
return templates.TemplateResponse(
request, "not_found.html", {"user_id": user_id}, status_code=404,
)
return templates.TemplateResponse(request, "user_detail.html", {"d": detail})
@app.get("/db", response_class=HTMLResponse, include_in_schema=False,
dependencies=[Depends(auth.require_admin)])
async def db_page(request: Request, session: AsyncSession = Depends(get_session)):
stats = await queries.db_stats(session)
return templates.TemplateResponse(request, "db.html", {"stats": stats})

View file

@ -1,266 +0,0 @@
"""Read-only queries backing the console. Every function issues SELECTs
only the console never mutates the database.
Payment status is computed with the app's own ``paid_status`` so the console
never diverges from what the app considers "paid".
"""
from __future__ import annotations
from datetime import timedelta
from sqlalchemy import func, or_, select, text
from sqlalchemy.ext.asyncio import AsyncSession
from app.db import utcnow
from app.models import (
EmailSend,
PortfolioSync,
Referral,
StrategicLogFeedback,
User,
UserAcknowledgement,
)
from app.services.access import paid_status
async def overview_stats(session: AsyncSession) -> dict:
"""Headline counts for the landing page: totals, tier split, growth,
paid-active, engagement."""
now = utcnow()
d7 = now - timedelta(days=7)
d30 = now - timedelta(days=30)
total = await session.scalar(select(func.count(User.id))) or 0
tier_rows = (await session.execute(
select(User.tier, func.count(User.id)).group_by(User.tier)
)).all()
tiers = {tier: n for tier, n in tier_rows}
signups_7d = await session.scalar(
select(func.count(User.id)).where(User.created_at >= d7)
) or 0
signups_30d = await session.scalar(
select(func.count(User.id)).where(User.created_at >= d30)
) or 0
active_30d = await session.scalar(
select(func.count(User.id)).where(User.last_login_at >= d30)
) or 0
# Paid-active = subscribed tier OR a live credit window. The two are
# counted disjointly (a credit user whose tier is already paid is only
# counted once, under tier) so the sum can't double-count.
paid_by_tier = await session.scalar(
select(func.count(User.id)).where(User.tier.in_(("paid", "enterprise")))
) or 0
credit_active = await session.scalar(
select(func.count(User.id)).where(
User.credit_until.is_not(None),
User.credit_until > now,
User.tier.not_in(("paid", "enterprise")),
)
) or 0
sync_enabled = await session.scalar(
select(func.count(PortfolioSync.user_id))
) or 0
referrals_total = await session.scalar(select(func.count(Referral.id))) or 0
referrals_converted = await session.scalar(
select(func.count(Referral.id)).where(Referral.converted_at.is_not(None))
) or 0
return {
"total_users": total,
"tiers": tiers,
"free": tiers.get("free", 0),
"paid": tiers.get("paid", 0),
"enterprise": tiers.get("enterprise", 0),
"paid_active": paid_by_tier + credit_active,
"credit_active": credit_active,
"signups_7d": signups_7d,
"signups_30d": signups_30d,
"active_30d": active_30d,
"sync_enabled": sync_enabled,
"referrals_total": referrals_total,
"referrals_converted": referrals_converted,
}
async def list_users(
session: AsyncSession,
q: str | None = None,
limit: int = 200,
offset: int = 0,
) -> tuple[list[dict], int]:
"""User rows for the table view, newest first. Optional ``q`` filters by
email substring. Returns (rows, total_matching)."""
stmt = select(User)
count_stmt = select(func.count(User.id))
if q:
like = f"%{q.strip()}%"
stmt = stmt.where(User.email.like(like))
count_stmt = count_stmt.where(User.email.like(like))
total = await session.scalar(count_stmt) or 0
users = (await session.execute(
stmt.order_by(User.created_at.desc()).limit(limit).offset(offset)
)).scalars().all()
if not users:
return [], total
ids = [u.id for u in users]
# One grouped query each for the per-user badges, merged in Python —
# avoids an N+1 without a wide join fan-out.
sync_ids = set((await session.execute(
select(PortfolioSync.user_id).where(PortfolioSync.user_id.in_(ids))
)).scalars().all())
ref_counts = dict((await session.execute(
select(Referral.referrer_user_id, func.count(Referral.id))
.where(Referral.referrer_user_id.in_(ids))
.group_by(Referral.referrer_user_id)
)).all())
rows: list[dict] = []
for u in users:
ps = paid_status(u)
rows.append({
"id": u.id,
"email": u.email,
"tier": u.tier,
"paid_active": ps.active,
"paid_source": ps.source,
"credit_days": ps.days_remaining,
"created_at": u.created_at,
"last_login_at": u.last_login_at,
"lang": u.lang,
"has_sync": u.id in sync_ids,
"referrals": ref_counts.get(u.id, 0),
"on_stripe": bool(u.stripe_customer_id),
"on_polar": bool(u.polar_customer_id),
"trialing": u.stripe_trial_end_at is not None,
})
return rows, total
async def user_detail(session: AsyncSession, user_id: int) -> dict | None:
"""Everything the console shows for one user: the row itself, computed
payment status, billing linkage, and joined activity history."""
user = await session.get(User, user_id)
if user is None:
return None
ps = paid_status(user)
referred_by = None
if user.referred_by_user_id:
ref = await session.get(User, user.referred_by_user_id)
referred_by = {"id": ref.id, "email": ref.email} if ref else None
# Referrals this user sent (with conversion state).
sent = (await session.execute(
select(Referral, User.email)
.join(User, User.id == Referral.referred_user_id)
.where(Referral.referrer_user_id == user_id)
.order_by(Referral.created_at.desc())
)).all()
referrals_sent = [{
"referred_email": email,
"created_at": r.created_at,
"converted_at": r.converted_at,
} for r, email in sent]
# Digest email delivery log (most recent first, capped).
email_rows = (await session.execute(
select(EmailSend).where(EmailSend.user_id == user_id)
.order_by(EmailSend.sent_at.desc()).limit(25)
)).scalars().all()
emails = [{
"kind": e.kind, "sent_at": e.sent_at,
"status": e.status, "error": e.error,
} for e in email_rows]
# Legal acknowledgements (evidentiary audit trail).
ack_rows = (await session.execute(
select(UserAcknowledgement).where(UserAcknowledgement.user_id == user_id)
.order_by(UserAcknowledgement.accepted_at.desc())
)).scalars().all()
acks = [{
"version": a.version, "lang": a.lang, "accepted_at": a.accepted_at,
} for a in ack_rows]
# Feedback votes cast on strategic logs.
fb_rows = (await session.execute(
select(StrategicLogFeedback.vote, func.count(StrategicLogFeedback.id))
.where(StrategicLogFeedback.user_id == user_id)
.group_by(StrategicLogFeedback.vote)
)).all()
feedback = {vote: n for vote, n in fb_rows}
sync = await session.get(PortfolioSync, user_id)
return {
"user": user,
"paid": ps,
"referred_by": referred_by,
"referrals_sent": referrals_sent,
"emails": emails,
"acks": acks,
"feedback": feedback,
"sync": {
"enabled": sync is not None,
"updated_at": sync.updated_at if sync else None,
"version": sync.version if sync else None,
},
}
async def db_stats(session: AsyncSession) -> dict:
"""Per-table size and row estimates for the current schema.
On MariaDB this reads ``information_schema.tables`` (row counts are the
engine's estimate for InnoDB, sizes are exact). On sqlite (the test
backend) there is no such view, so it falls back to real ``COUNT(*)``
per table with sizes reported as 0.
"""
dialect = session.bind.dialect.name
if dialect == "sqlite":
from app.db import Base
tables = []
for tbl in Base.metadata.sorted_tables:
n = await session.scalar(select(func.count()).select_from(tbl)) or 0
tables.append({
"name": tbl.name, "rows": n,
"data_bytes": 0, "index_bytes": 0, "total_bytes": 0, "pct": 0.0,
})
tables.sort(key=lambda t: t["rows"], reverse=True)
return {"tables": tables, "total_bytes": 0, "total_rows": sum(t["rows"] for t in tables)}
rows = (await session.execute(text(
"SELECT table_name, table_rows, data_length, index_length "
"FROM information_schema.tables "
"WHERE table_schema = DATABASE() "
"ORDER BY (data_length + index_length) DESC"
))).all()
tables = []
total_bytes = 0
total_rows = 0
for name, tr, data_len, idx_len in rows:
data_len = int(data_len or 0)
idx_len = int(idx_len or 0)
tot = data_len + idx_len
total_bytes += tot
total_rows += int(tr or 0)
tables.append({
"name": name, "rows": int(tr or 0),
"data_bytes": data_len, "index_bytes": idx_len, "total_bytes": tot,
})
for t in tables:
t["pct"] = round(100.0 * t["total_bytes"] / total_bytes, 1) if total_bytes else 0.0
return {"tables": tables, "total_bytes": total_bytes, "total_rows": total_rows}

View file

@ -1,80 +0,0 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<meta name="robots" content="noindex, nofollow">
<title>{% block title %}superadmin{% endblock %} · read.markets</title>
<style>
:root {
--bg:#0d1117; --panel:#161b22; --border:#30363d; --fg:#c9d1d9;
--muted:#8b949e; --accent:#58a6ff; --good:#3fb950; --warn:#d29922;
--bad:#f85149; --mono:ui-monospace,SFMono-Regular,Menlo,Consolas,monospace;
}
* { box-sizing:border-box; }
body { margin:0; background:var(--bg); color:var(--fg);
font:14px/1.5 var(--mono); }
a { color:var(--accent); text-decoration:none; }
a:hover { text-decoration:underline; }
header { display:flex; align-items:center; gap:1.5rem; padding:.75rem 1.25rem;
background:var(--panel); border-bottom:1px solid var(--border); }
header .brand { font-weight:700; color:#fff; }
header nav { display:flex; gap:1rem; }
header .spacer { flex:1; }
main { padding:1.25rem; max-width:1200px; margin:0 auto; }
h1 { font-size:1.25rem; margin:0 0 1rem; }
h2 { font-size:1rem; color:var(--muted); text-transform:uppercase;
letter-spacing:.05em; margin:1.5rem 0 .5rem; }
.cards { display:grid; grid-template-columns:repeat(auto-fill,minmax(160px,1fr));
gap:.75rem; }
.card { background:var(--panel); border:1px solid var(--border);
border-radius:6px; padding:.75rem 1rem; }
.card .n { font-size:1.6rem; font-weight:700; color:#fff; }
.card .l { color:var(--muted); font-size:.8rem; }
table { width:100%; border-collapse:collapse; margin-top:.5rem; }
th,td { text-align:left; padding:.4rem .6rem; border-bottom:1px solid var(--border);
white-space:nowrap; }
th { color:var(--muted); font-weight:600; font-size:.8rem;
text-transform:uppercase; letter-spacing:.04em; }
tr:hover td { background:#1c2230; }
.pill { display:inline-block; padding:.05rem .5rem; border-radius:999px;
font-size:.75rem; border:1px solid var(--border); }
.pill.good { color:var(--good); border-color:#238636; }
.pill.warn { color:var(--warn); border-color:#9e6a03; }
.pill.bad { color:var(--bad); border-color:#a1281f; }
.pill.mute { color:var(--muted); }
.muted { color:var(--muted); }
.bar { height:8px; background:#21262d; border-radius:4px; overflow:hidden;
min-width:80px; }
.bar > span { display:block; height:100%; background:var(--accent); }
form.search { margin:.5rem 0; }
input[type=text],input[type=password] {
background:#0d1117; border:1px solid var(--border); color:var(--fg);
padding:.4rem .6rem; border-radius:6px; font:inherit; }
button { background:#238636; color:#fff; border:0; padding:.45rem .9rem;
border-radius:6px; font:inherit; cursor:pointer; }
button:hover { background:#2ea043; }
.kv { display:grid; grid-template-columns:180px 1fr; gap:.25rem 1rem; }
.kv dt { color:var(--muted); }
.kv dd { margin:0; }
.login { max-width:340px; margin:12vh auto; background:var(--panel);
border:1px solid var(--border); border-radius:8px; padding:1.5rem; }
.err { color:var(--bad); margin:.5rem 0; }
</style>
</head>
<body>
{% if show_nav|default(true) %}
<header>
<span class="brand">read.markets · superadmin</span>
<nav>
<a href="/">Overview</a>
<a href="/users">Users</a>
<a href="/db">Database</a>
</nav>
<span class="spacer"></span>
<a href="/logout">Log out</a>
</header>
{% endif %}
<main>{% block body %}{% endblock %}</main>
</body>
</html>

View file

@ -1,31 +0,0 @@
{% extends "base.html" %}
{% block title %}database{% endblock %}
{% block body %}
<h1>Database usage</h1>
<div class="cards">
<div class="card"><div class="n">{{ stats.total_bytes|bytes }}</div><div class="l">Total size</div></div>
<div class="card"><div class="n">{{ "{:,}".format(stats.total_rows) }}</div><div class="l">Total rows (est.)</div></div>
<div class="card"><div class="n">{{ stats.tables|length }}</div><div class="l">Tables</div></div>
</div>
<h2>Per table</h2>
<table>
<thead><tr><th>Table</th><th>Rows (est.)</th><th>Data</th><th>Index</th><th>Total</th><th style="width:160px">Share</th></tr></thead>
<tbody>
{% for t in stats.tables %}
<tr>
<td>{{ t.name }}</td>
<td>{{ "{:,}".format(t.rows) }}</td>
<td class="muted">{{ t.data_bytes|bytes }}</td>
<td class="muted">{{ t.index_bytes|bytes }}</td>
<td>{{ t.total_bytes|bytes }}</td>
<td>
<div class="bar"><span style="width:{{ t.pct }}%"></span></div>
<span class="muted">{{ t.pct }}%</span>
</td>
</tr>
{% endfor %}
</tbody>
</table>
<p class="muted">Row counts are the storage engine's estimate for InnoDB; sizes are exact on-disk bytes.</p>
{% endblock %}

View file

@ -1,14 +0,0 @@
{% extends "base.html" %}
{% set show_nav = false %}
{% block title %}login{% endblock %}
{% block body %}
<div class="login">
<h1>Superadmin</h1>
{% if error %}<div class="err">{{ error }}</div>{% endif %}
<form method="post" action="/login">
<p><input type="password" name="password" placeholder="Password"
autofocus autocomplete="current-password" style="width:100%"></p>
<button type="submit">Sign in</button>
</form>
</div>
{% endblock %}

View file

@ -1,6 +0,0 @@
{% extends "base.html" %}
{% block title %}not found{% endblock %}
{% block body %}
<h1>User #{{ user_id }} not found</h1>
<p><a href="/users">← back to users</a></p>
{% endblock %}

View file

@ -1,22 +0,0 @@
{% extends "base.html" %}
{% block title %}overview{% endblock %}
{% block body %}
<h1>Overview</h1>
<div class="cards">
<div class="card"><div class="n">{{ stats.total_users }}</div><div class="l">Total users</div></div>
<div class="card"><div class="n">{{ stats.paid_active }}</div><div class="l">Paid-active</div></div>
<div class="card"><div class="n">{{ stats.free }}</div><div class="l">Free tier</div></div>
<div class="card"><div class="n">{{ stats.paid }}</div><div class="l">Paid tier</div></div>
<div class="card"><div class="n">{{ stats.enterprise }}</div><div class="l">Enterprise</div></div>
<div class="card"><div class="n">{{ stats.credit_active }}</div><div class="l">On credit</div></div>
<div class="card"><div class="n">{{ stats.signups_7d }}</div><div class="l">Signups · 7d</div></div>
<div class="card"><div class="n">{{ stats.signups_30d }}</div><div class="l">Signups · 30d</div></div>
<div class="card"><div class="n">{{ stats.active_30d }}</div><div class="l">Active · 30d</div></div>
<div class="card"><div class="n">{{ stats.sync_enabled }}</div><div class="l">Cloud-sync on</div></div>
<div class="card"><div class="n">{{ stats.referrals_converted }}/{{ stats.referrals_total }}</div><div class="l">Referrals conv.</div></div>
</div>
<h2>Newest users</h2>
{% include "partials_users_table.html" %}
<p><a href="/users">All users →</a></p>
{% endblock %}

View file

@ -1,39 +0,0 @@
{# expects `recent` (overview) or `rows` (users list) — normalise to `rows` #}
{% set rows = rows if rows is defined else recent %}
<table>
<thead>
<tr>
<th>ID</th><th>Email</th><th>Tier</th><th>Paid</th><th>Created</th>
<th>Last login</th><th>Lang</th><th>Sync</th><th>Refs</th><th>Billing</th>
</tr>
</thead>
<tbody>
{% for u in rows %}
<tr>
<td>{{ u.id }}</td>
<td><a href="/users/{{ u.id }}">{{ u.email }}</a></td>
<td>{{ u.tier }}</td>
<td>
{% if u.paid_active %}
<span class="pill good">yes{% if u.paid_source == 'credit' %} · {{ u.credit_days }}d{% endif %}</span>
{% else %}
<span class="pill mute">no</span>
{% endif %}
{% if u.trialing %}<span class="pill warn">trial</span>{% endif %}
</td>
<td class="muted">{{ u.created_at|dt }}</td>
<td class="muted">{{ u.last_login_at|dt }}</td>
<td>{{ u.lang }}</td>
<td>{% if u.has_sync %}<span class="pill good">on</span>{% else %}<span class="muted"></span>{% endif %}</td>
<td>{{ u.referrals }}</td>
<td>
{% if u.on_stripe %}<span class="pill">stripe</span>{% endif %}
{% if u.on_polar %}<span class="pill">polar</span>{% endif %}
{% if not u.on_stripe and not u.on_polar %}<span class="muted"></span>{% endif %}
</td>
</tr>
{% else %}
<tr><td colspan="10" class="muted">No users.</td></tr>
{% endfor %}
</tbody>
</table>

View file

@ -1,73 +0,0 @@
{% extends "base.html" %}
{% set u = d.user %}
{% block title %}{{ u.email }}{% endblock %}
{% block body %}
<p class="muted"><a href="/users">← users</a></p>
<h1>{{ u.email }} <span class="muted">#{{ u.id }}</span></h1>
<h2>Account</h2>
<dl class="kv">
<dt>Tier</dt><dd>{{ u.tier }}</dd>
<dt>Paid status</dt><dd>
{% if d.paid.active %}
<span class="pill good">active</span> via {{ d.paid.source }}
{% if d.paid.source == 'credit' %}· expires {{ d.paid.expires_at|dt }} ({{ d.paid.days_remaining }}d){% endif %}
{% else %}<span class="pill mute">inactive</span>{% endif %}
</dd>
<dt>Credit until</dt><dd>{{ u.credit_until|dt }}</dd>
<dt>Created</dt><dd>{{ u.created_at|dt }}</dd>
<dt>Last login</dt><dd>{{ u.last_login_at|dt }}</dd>
<dt>Language</dt><dd>{{ u.lang }}</dd>
<dt>Digest opt-in</dt><dd>{{ 'yes' if u.email_digest_opt_in else 'no' }}{% if u.digest_tone %} · {{ u.digest_tone }}{% endif %}</dd>
<dt>Referral code</dt><dd>{{ u.referral_code or '—' }}</dd>
<dt>Referred by</dt><dd>{% if d.referred_by %}<a href="/users/{{ d.referred_by.id }}">{{ d.referred_by.email }}</a>{% else %}—{% endif %}</dd>
</dl>
<h2>Billing linkage</h2>
<dl class="kv">
<dt>Stripe customer</dt><dd>{{ u.stripe_customer_id or '—' }}</dd>
<dt>Stripe subscription</dt><dd>{{ u.stripe_subscription_id or '—' }}</dd>
<dt>Stripe trial ends</dt><dd>{{ u.stripe_trial_end_at|dt }}</dd>
<dt>Polar customer</dt><dd>{{ u.polar_customer_id or '—' }}</dd>
<dt>Polar subscription</dt><dd>{{ u.polar_subscription_id or '—' }}</dd>
</dl>
<h2>Cloud sync</h2>
{% if d.sync.enabled %}
<p><span class="pill good">enabled</span> · v{{ d.sync.version }} · updated {{ d.sync.updated_at|dt }}
<span class="muted">(contents are end-to-end encrypted — not readable here)</span></p>
{% else %}<p class="muted">Not enabled.</p>{% endif %}
<h2>Referrals sent ({{ d.referrals_sent|length }})</h2>
{% if d.referrals_sent %}
<table><thead><tr><th>Referred</th><th>Sent</th><th>Converted</th></tr></thead><tbody>
{% for r in d.referrals_sent %}
<tr><td>{{ r.referred_email }}</td><td class="muted">{{ r.created_at|dt }}</td>
<td>{% if r.converted_at %}<span class="pill good">{{ r.converted_at|dt }}</span>{% else %}<span class="muted">pending</span>{% endif %}</td></tr>
{% endfor %}
</tbody></table>
{% else %}<p class="muted">None.</p>{% endif %}
<h2>Feedback votes</h2>
<p>👍 {{ d.feedback.get('up', 0) }} · 👎 {{ d.feedback.get('down', 0) }}</p>
<h2>Digest emails (last 25)</h2>
{% if d.emails %}
<table><thead><tr><th>Kind</th><th>Sent</th><th>Status</th><th>Error</th></tr></thead><tbody>
{% for e in d.emails %}
<tr><td>{{ e.kind }}</td><td class="muted">{{ e.sent_at|dt }}</td>
<td>{% if e.status == 'sent' %}<span class="pill good">sent</span>{% elif e.status == 'error' %}<span class="pill bad">error</span>{% else %}<span class="pill warn">{{ e.status }}</span>{% endif %}</td>
<td class="muted">{{ e.error or '' }}</td></tr>
{% endfor %}
</tbody></table>
{% else %}<p class="muted">None.</p>{% endif %}
<h2>Legal acknowledgements</h2>
{% if d.acks %}
<table><thead><tr><th>Version</th><th>Lang</th><th>Accepted</th></tr></thead><tbody>
{% for a in d.acks %}
<tr><td>v{{ a.version }}</td><td>{{ a.lang }}</td><td class="muted">{{ a.accepted_at|dt }}</td></tr>
{% endfor %}
</tbody></table>
{% else %}<p class="muted">None recorded.</p>{% endif %}
{% endblock %}

View file

@ -1,20 +0,0 @@
{% extends "base.html" %}
{% block title %}users{% endblock %}
{% block body %}
<h1>Users <span class="muted">({{ total }})</span></h1>
<form class="search" method="get" action="/users">
<input type="text" name="q" value="{{ q }}" placeholder="filter by email…">
<button type="submit">Search</button>
{% if q %}<a href="/users" style="margin-left:.5rem">clear</a>{% endif %}
</form>
{% include "partials_users_table.html" %}
{% set pages = (total // per_page) + (1 if total % per_page else 0) %}
{% if pages > 1 %}
<p class="muted">
Page {{ page }} / {{ pages }}
{% if page > 1 %}· <a href="/users?page={{ page-1 }}{% if q %}&q={{ q }}{% endif %}">prev</a>{% endif %}
{% if page < pages %}· <a href="/users?page={{ page+1 }}{% if q %}&q={{ q }}{% endif %}">next</a>{% endif %}
</p>
{% endif %}
{% endblock %}

View file

@ -1,75 +0,0 @@
"""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.

View file

@ -1,61 +0,0 @@
"""user_acknowledgements: affirmative sign-up acknowledgement audit table.
Revision ID: 0027
Revises: 0026
Create Date: 2026-05-29
One row per (user, version) acceptance of the /login acknowledgement.
See app/models.py::UserAcknowledgement and app/legal.py for context.
"""
from typing import Sequence, Union
import sqlalchemy as sa
from alembic import op
revision: str = "0027"
down_revision: Union[str, None] = "0026"
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
op.create_table(
"user_acknowledgements",
sa.Column(
"id",
sa.BigInteger().with_variant(sa.Integer(), "sqlite"),
primary_key=True, autoincrement=True,
),
sa.Column(
"user_id", sa.Integer(),
sa.ForeignKey("users.id", ondelete="CASCADE"),
nullable=False,
),
sa.Column("version", sa.SmallInteger(), nullable=False),
sa.Column("lang", sa.String(length=8), nullable=False),
sa.Column(
"accepted_at", sa.DateTime(timezone=True), nullable=False,
server_default=sa.text("CURRENT_TIMESTAMP"),
),
)
op.create_index(
"ix_user_acknowledgements_user_version",
"user_acknowledgements", ["user_id", "version"],
)
op.create_index(
"ix_user_acknowledgements_accepted_at",
"user_acknowledgements", ["accepted_at"],
)
def downgrade() -> None:
op.drop_index(
"ix_user_acknowledgements_accepted_at",
table_name="user_acknowledgements",
)
op.drop_index(
"ix_user_acknowledgements_user_version",
table_name="user_acknowledgements",
)
op.drop_table("user_acknowledgements")

View file

@ -1,93 +0,0 @@
"""strategic_log_feedback + reviewer_score columns.
Revision ID: 0028
Revises: 0027
Create Date: 2026-05-29
Two unrelated features bundled because they ship together:
1. **strategic_log_feedback** thumb up/down votes per (log, user).
UNIQUE on (log_id, user_id) enforces one vote per user per log,
flippable in place. The UI shows aggregate counts only.
2. **reviewer_score** the output reviewer now self-rates each
verdict 0-10 (10 = exemplary editorial, 0 = unfit). Stored on
strategic_logs, indicator_summaries, and every reviewer_verdicts
audit row, as nullable SMALLINT so existing rows aren't backfilled.
"""
from typing import Sequence, Union
import sqlalchemy as sa
from alembic import op
revision: str = "0028"
down_revision: Union[str, None] = "0027"
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
# --- reviewer_score columns (nullable SMALLINT) -----------------------
op.add_column(
"strategic_logs",
sa.Column("reviewer_score", sa.SmallInteger(), nullable=True),
)
op.add_column(
"indicator_summaries",
sa.Column("reviewer_score", sa.SmallInteger(), nullable=True),
)
op.add_column(
"reviewer_verdicts",
sa.Column("score", sa.SmallInteger(), nullable=True),
)
# --- strategic_log_feedback table -------------------------------------
op.create_table(
"strategic_log_feedback",
sa.Column(
"id",
sa.BigInteger().with_variant(sa.Integer(), "sqlite"),
primary_key=True, autoincrement=True,
),
sa.Column(
"log_id",
sa.BigInteger().with_variant(sa.Integer(), "sqlite"),
sa.ForeignKey("strategic_logs.id", ondelete="CASCADE"),
nullable=False,
),
sa.Column(
"user_id", sa.Integer(),
sa.ForeignKey("users.id", ondelete="CASCADE"),
nullable=False,
),
# 'up' or 'down'. Storing as varchar keeps the column readable in
# the DB shell; the enum-ness is enforced at the service layer.
sa.Column("vote", sa.String(length=8), nullable=False),
sa.Column(
"created_at", sa.DateTime(timezone=True), nullable=False,
server_default=sa.text("CURRENT_TIMESTAMP"),
),
sa.Column(
"updated_at", sa.DateTime(timezone=True), nullable=False,
server_default=sa.text("CURRENT_TIMESTAMP"),
),
sa.UniqueConstraint(
"log_id", "user_id", name="uq_slf_log_user",
),
)
op.create_index(
"ix_strategic_log_feedback_log",
"strategic_log_feedback", ["log_id"],
)
def downgrade() -> None:
op.drop_index(
"ix_strategic_log_feedback_log",
table_name="strategic_log_feedback",
)
op.drop_table("strategic_log_feedback")
op.drop_column("reviewer_verdicts", "score")
op.drop_column("indicator_summaries", "reviewer_score")
op.drop_column("strategic_logs", "reviewer_score")

View file

@ -146,90 +146,6 @@ async def send_test_digest(email: str, kind: str) -> int:
return 0 return 0
# The genuinely-real accounts. Everything else in the users table is a
# leftover from smoke/E2E signup tests that were pointed at the live site
# (OTP delivered to the @gilest.ro catch-all). Kept here so the purge is an
# explicit allow-list — deny by default — rather than pattern-matching on
# test prefixes that a future test run might not follow.
DEFAULT_KEEP_EMAILS = (
"giorgio@gilest.ro",
"giorgio.gilestro@gmail.com",
"ilariodamato@hotmail.com",
)
async def purge_test_users(keep: list[str], commit: bool) -> int:
"""Delete every user whose email is not in `keep`, cascading to child
rows. Dry-run by default: prints exactly what would go and changes
nothing unless `commit` is True.
Deletes child rows explicitly (rather than leaning on DB-level ON DELETE
CASCADE) so behaviour is identical on MariaDB and the sqlite test DB.
"""
from sqlalchemy import delete, or_, update
from app.models import (
EmailOTP, EmailSend, PortfolioSync, Referral,
StrategicLogFeedback, User, UserAcknowledgement,
)
keep_set = {e.strip().lower() for e in keep if e.strip()}
factory = get_session_factory()
async with factory() as session:
rows = (await session.execute(
select(User.id, User.email, User.tier, User.created_at)
.order_by(User.id)
)).all()
victims = [r for r in rows if (r.email or "").lower() not in keep_set]
kept = [r for r in rows if (r.email or "").lower() in keep_set]
print(f"users total: {len(rows)} keep: {len(kept)} "
f"to delete: {len(victims)}")
print("\nKEEP:")
for r in kept:
print(f" [{r.id}] {r.email} ({r.tier})")
print("\nDELETE:" if victims else "\nDELETE: (none)")
for r in victims:
print(f" [{r.id}] {r.email} ({r.tier}) {r.created_at}")
if not victims:
print("\nnothing to do.")
return 0
if not commit:
print(f"\nDRY RUN — nothing deleted. "
f"Re-run with --commit to remove these {len(victims)} "
f"account(s).")
return 0
ids = [r.id for r in victims]
emails = [r.email for r in victims if r.email]
# Child rows first (explicit; order matters without cascade).
await session.execute(delete(StrategicLogFeedback)
.where(StrategicLogFeedback.user_id.in_(ids)))
await session.execute(delete(EmailSend)
.where(EmailSend.user_id.in_(ids)))
await session.execute(delete(PortfolioSync)
.where(PortfolioSync.user_id.in_(ids)))
await session.execute(delete(UserAcknowledgement)
.where(UserAcknowledgement.user_id.in_(ids)))
await session.execute(delete(Referral).where(or_(
Referral.referrer_user_id.in_(ids),
Referral.referred_user_id.in_(ids),
)))
# Self-referential FK on survivors that pointed at a victim.
await session.execute(update(User)
.where(User.referred_by_user_id.in_(ids))
.values(referred_by_user_id=None))
# OTPs are keyed by email, not user_id — no FK to cascade.
if emails:
await session.execute(delete(EmailOTP)
.where(EmailOTP.email.in_(emails)))
result = await session.execute(delete(User).where(User.id.in_(ids)))
await session.commit()
print(f"\ndeleted {result.rowcount} user(s) and their child rows.")
return 0
def build_parser() -> argparse.ArgumentParser: def build_parser() -> argparse.ArgumentParser:
p = argparse.ArgumentParser(prog="app.cli", description="Cassandra admin CLI") p = argparse.ArgumentParser(prog="app.cli", description="Cassandra admin CLI")
sub = p.add_subparsers(dest="cmd", required=True) sub = p.add_subparsers(dest="cmd", required=True)
@ -249,15 +165,6 @@ def build_parser() -> argparse.ArgumentParser:
t.add_argument("email") t.add_argument("email")
t.add_argument("kind", choices=("daily", "weekly")) t.add_argument("kind", choices=("daily", "weekly"))
pu = sub.add_parser(
"purge-test-users",
help="Delete all users except an allow-list (dry-run unless --commit)")
pu.add_argument(
"--keep", action="append", metavar="EMAIL", default=None,
help="Email to keep; repeatable. Defaults to the known real accounts.")
pu.add_argument("--commit", action="store_true",
help="Actually delete. Without this it is a dry run.")
return p return p
@ -274,9 +181,6 @@ async def _dispatch(args) -> int:
return await show_status(args.email) return await show_status(args.email)
if args.cmd == "send-test-digest": if args.cmd == "send-test-digest":
return await send_test_digest(args.email, args.kind) return await send_test_digest(args.email, args.kind)
if args.cmd == "purge-test-users":
keep = args.keep if args.keep else list(DEFAULT_KEEP_EMAILS)
return await purge_test_users(keep, args.commit)
return 2 return 2
finally: finally:
await get_engine().dispose() await get_engine().dispose()

View file

@ -50,17 +50,6 @@ class Settings(BaseSettings):
# created. Phase A leaves this open so the operator can self-onboard. # created. Phase A leaves this open so the operator can self-onboard.
CASSANDRA_SIGNUP_ENABLED: bool = True CASSANDRA_SIGNUP_ENABLED: bool = True
# Superadmin console (independent container, internal-only — bound to
# 127.0.0.1 on the VPS, reached via SSH tunnel). Read-only operator
# dashboard: user list, per-user history/payment status, DB usage stats.
# A single shared password gates it; empty ADMIN_CONSOLE_PASSWORD makes
# every login attempt fail (the console is closed rather than open by
# default, so a fresh deploy can't accidentally expose it).
ADMIN_CONSOLE_PASSWORD: str = ""
# Signing secret for the console's session cookie. Falls back to
# CASSANDRA_SESSION_SECRET / CASSANDRA_TOKEN like the main app's cookie.
ADMIN_CONSOLE_SESSION_SECRET: str = ""
# SMTP for email OTP verification. If SMTP_SERVER is empty, OTP codes # SMTP for email OTP verification. If SMTP_SERVER is empty, OTP codes
# are written to stdout instead of sent — convenient for local dev. # are written to stdout instead of sent — convenient for local dev.
SMTP_SERVER: str = "" SMTP_SERVER: str = ""
@ -116,25 +105,6 @@ class Settings(BaseSettings):
STRIPE_PRICE_MONTHLY: str = "" # price_xxx for £7/month subscription STRIPE_PRICE_MONTHLY: str = "" # price_xxx for £7/month subscription
STRIPE_PRICE_ANNUAL: str = "" # price_xxx for £70/year 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) # Config file locations (overridable for tests)
BASELINE_TOML: Path = Field(default_factory=lambda: CONFIG_DIR / "default.toml") BASELINE_TOML: Path = Field(default_factory=lambda: CONFIG_DIR / "default.toml")
PORTFOLIO_TOML: Path = Field(default_factory=lambda: CONFIG_DIR / "portfolio.toml") PORTFOLIO_TOML: Path = Field(default_factory=lambda: CONFIG_DIR / "portfolio.toml")

View file

@ -206,8 +206,7 @@ async def run() -> None:
# that drifted past the generator's system prompt. Drop # that drifted past the generator's system prompt. Drop
# rejected variants; the API falls back to the previous # rejected variants; the API falls back to the previous
# clean StrategicLog row. # 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) full_cost = (result.cost_usd or 0.0) + (verdict.cost_usd or 0.0)
if not verdict.clean: if not verdict.clean:
session.add(AICall( session.add(AICall(
@ -234,7 +233,6 @@ async def run() -> None:
prompt_tokens=result.prompt_tokens, prompt_tokens=result.prompt_tokens,
completion_tokens=result.completion_tokens, completion_tokens=result.completion_tokens,
cost_usd=full_cost, cost_usd=full_cost,
reviewer_score=verdict.score,
) )
session.add(slog) session.add(slog)
session.add(AICall( session.add(AICall(

View file

@ -198,32 +198,17 @@ def _pick_variant(
async def _send_one(user: User, kind: str, content_html: str, date_str: str, async def _send_one(user: User, kind: str, content_html: str, date_str: str,
session, *, latest_log_id: int | None = None) -> None: session) -> None:
settings_url = f"{branding.SITE_URL}/settings" settings_url = f"{branding.SITE_URL}/settings"
unsubscribe_url = ( unsubscribe_url = (
f"{branding.SITE_URL}/email/unsubscribe" f"{branding.SITE_URL}/email/unsubscribe"
f"?token={sign_unsubscribe_token(user.id)}" f"?token={sign_unsubscribe_token(user.id)}"
) )
# Build signed feedback URLs against the latest strategic log at send
# time. The token encodes (user, log, vote) so the recipient can
# click without being logged in; the receiving /feedback endpoint
# verifies the signature and applies the vote.
feedback_up_url = feedback_down_url = None
if latest_log_id is not None:
from app.services.log_feedback import sign_feedback_token
up_tok = sign_feedback_token(user.id, latest_log_id, "up")
down_tok = sign_feedback_token(user.id, latest_log_id, "down")
feedback_up_url = f"{branding.SITE_URL}/feedback?token={up_tok}&vote=up"
feedback_down_url = f"{branding.SITE_URL}/feedback?token={down_tok}&vote=down"
subject, text_body, html_body = render_digest_email( subject, text_body, html_body = render_digest_email(
kind=kind, date_str=date_str, kind=kind, date_str=date_str,
content_html=content_html, content_html=content_html,
unsubscribe_url=unsubscribe_url, unsubscribe_url=unsubscribe_url,
settings_url=settings_url, settings_url=settings_url,
feedback_up_url=feedback_up_url,
feedback_down_url=feedback_down_url,
) )
try: try:
await send_email(to=user.email, subject=subject, await send_email(to=user.email, subject=subject,
@ -303,18 +288,6 @@ async def run() -> None:
client, variants, active_non_en, client, variants, active_non_en,
) )
# Resolve the latest strategic log once per job — used as the
# target of the email's thumb up/down feedback links. None if
# nothing has been generated yet (shouldn't happen at this point
# in the flow, but defensible).
from sqlalchemy import desc, select
from app.models import StrategicLog
latest_log_id = (await session.execute(
select(StrategicLog.id)
.order_by(desc(StrategicLog.generated_at))
.limit(1)
)).scalar_one_or_none()
written = 0 written = 0
for u in fresh: for u in fresh:
tone = (u.digest_tone or "INTERMEDIATE").upper() tone = (u.digest_tone or "INTERMEDIATE").upper()
@ -323,8 +296,7 @@ async def run() -> None:
tone=tone, tone=tone,
lang=(u.lang or "en"), lang=(u.lang or "en"),
) )
await _send_one(u, kind, content, date_str, session, await _send_one(u, kind, content, date_str, session)
latest_log_id=latest_log_id)
await asyncio.sleep(0.1) await asyncio.sleep(0.1)
written += 1 written += 1

View file

@ -184,8 +184,7 @@ async def _generate_one(
)) ))
return None return None
verdict = await review_read(client, candidate, verdict = await review_read(client, candidate)
surface="indicator", session=session)
if not verdict.clean: if not verdict.clean:
# Reviewer caught scratchpad / meta-commentary / partial text # Reviewer caught scratchpad / meta-commentary / partial text
# INSIDE the read field. Drop the candidate; the previous good # INSIDE the read field. Drop the candidate; the previous good
@ -215,7 +214,6 @@ async def _generate_one(
# Include the reviewer's cost in the row's recorded spend so the # Include the reviewer's cost in the row's recorded spend so the
# monthly budget tracking covers the full pipeline cost. # monthly budget tracking covers the full pipeline cost.
cost_usd=(result.cost_usd or 0.0) + (verdict.cost_usd or 0.0), cost_usd=(result.cost_usd or 0.0) + (verdict.cost_usd or 0.0),
reviewer_score=verdict.score,
) )
session.add(summary) session.add(summary)
session.add(AICall( session.add(AICall(
@ -316,10 +314,7 @@ async def run() -> None:
cost_usd=result.cost_usd, status="leaked", cost_usd=result.cost_usd, status="leaked",
)) ))
else: else:
verdict = await review_read( verdict = await review_read(client, candidate)
client, candidate,
surface="indicator_aggregate", session=session,
)
full_cost = (result.cost_usd or 0.0) + (verdict.cost_usd or 0.0) full_cost = (result.cost_usd or 0.0) + (verdict.cost_usd or 0.0)
if not verdict.clean: if not verdict.clean:
log.warning("ind_summary.agg_reviewer_rejected", log.warning("ind_summary.agg_reviewer_rejected",
@ -343,7 +338,6 @@ async def run() -> None:
prompt_tokens=result.prompt_tokens, prompt_tokens=result.prompt_tokens,
completion_tokens=result.completion_tokens, completion_tokens=result.completion_tokens,
cost_usd=full_cost, cost_usd=full_cost,
reviewer_score=verdict.score,
) )
session.add(agg_summary) session.add(agg_summary)
session.add(AICall( session.add(AICall(

View file

@ -1,19 +0,0 @@
"""Legal-surface constants.
Currently a one-liner: the version of the sign-up acknowledgement text
the user actively accepts at ``/login``.
Bumping this constant re-prompts every existing user on their next
sign-in, because the new value won't match their stored
``user_acknowledgements.version`` rows. The recorded row is what gives
the acknowledgement its evidentiary weight; the constant is the pointer
that says "the current canonical text is at version N". The text itself
lives in ``app/locales/{en,it}.yaml`` under ``auth.ack``.
When the solicitor finalises wording, edit the YAML files **and** bump
this number in the same change so the version pointer stays accurate.
"""
from __future__ import annotations
ACKNOWLEDGEMENT_VERSION: int = 1

View file

@ -1,134 +0,0 @@
# English copy for the public landing page.
#
# Strings that contain inline HTML are rendered with the `safe` filter
# in the template. Keep markup minimal — <strong>, <em>, and the
# brand placeholders only.
hero:
tagline: "Understand markets. Don't gamble on them."
subhead: >-
Built for investors who want to <strong>act rationally</strong> and
tune out the high-frequency noise that comes from treating markets
like a casino. We aggregate cross-asset news and macro signals,
then write a plain-English read of what the underlying fundamentals
justify versus what the crowd is doing. Refreshed through the
trading day. A media service, not a financial one.
cta_dashboard: "Open dashboard"
cta_pricing: "See pricing"
cta_signup: "Sign up free"
shot_dashboard:
alt: "Dashboard preview"
zoom_hint: "Click to enlarge"
caption: >-
The dashboard. An aggregate cross-asset read at the top,
hand-picked indicator groups underneath. Reading level toggle
(Novice / Pro) flips every AI-generated panel between
plain-English and terse-pro framing.
features:
news:
tag: "News, aggregated"
title: "Headlines from across the macro universe"
body: >-
RSS and per-ticker feeds covering equities, rates, credit, FX,
commodities, and geopolitics. Every headline is auto-tagged by
theme so the noise stays as noise and the fundamentals-relevant
stuff is easy to find. Ingestion follows the trading calendar —
off-hours stay quiet.
shot_alt: "News feed with auto-tagged headlines"
shot_caption: >-
The news feed. Each headline carries one or more theme tags
(rates, AI, energy, geopolitics, …) so you can keep the threads
you care about and mute the ones you don't. Click a tag to
include; shift-click to exclude.
indicators:
tag: "Macro signals"
title: "A curated cross-asset tape"
body: >-
A hand-picked set of indicators across every asset class,
refreshed hourly during market hours. Each group gets a short
read that explains what the move <em>means</em>, not what it
was. Anchored in earnings, policy, valuation — not chart
patterns.
shot_alt: "Indicators panel with AI commentary"
shot_caption: >-
The indicators panel. Tabs across asset classes (equity, rates,
commodities, FX, bonds, …); each tab carries a one-paragraph
'read' written by the model on top of the live prices. The
numbers anchor the prose so the commentary is checkable, not
floating.
strategic:
tag: "The strategic read"
title: "Rational vs irrational, every paragraph"
body: >-
We tie the day's headlines and the cross-asset signals into a
single short interpretation. Each paragraph separates
<strong>rational drivers</strong> (earnings, policy, valuation)
from <strong>irrational ones</strong> (positioning, narrative,
flows) and names the gap. Two reading levels: Novice and Pro.
This is editorial commentary on public data — not a forecast
and not advice on any investment decision.
shot_alt: "Strategic log — the editorial AI read"
shot_caption: >-
The strategic log. The model writes a fresh interpretation
through the trading day, taking the previous draft as context
so it updates rather than starts over. Paid users get a refresh
every hour; free users get one every six.
multilang_callout: >-
Every AI-generated surface — strategic log, indicator reads, chat,
daily digest — is available in <strong>English</strong> and
<strong>Italian</strong>. 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"
chat:
alt: "Ask follow-up questions against any past log"
caption: >-
Ask follow-up questions against any past log. The chat panel
inherits the log's full context, so you can pull on a thread
without re-pasting headlines or re-explaining the setup.
caption_strong: "Ask anything about a log"
caption_span: "Conversational follow-ups with the day's context loaded."
portfolio_blurb: >-
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."
items:
- "Not investment advice."
- "Not trading signals."
- "Not a day-trading tool."
- "No buy/sell calls, ever."
- "No chart-pattern predictions."
- "Not a regulated service."
footer:
legal: >-
By signing up you agree to our <a href="/terms">Terms</a> and
<a href="/privacy">Privacy notice</a>, and confirm you've read
the <a href="/disclaimer">financial disclaimer</a>.
meta:
description: "Understand markets. Don't gamble on them."
# TODO(legal): final wording pending solicitor sign-off. Bumping
# ACKNOWLEDGEMENT_VERSION (app/legal.py) re-prompts every existing user
# on next sign-in and writes a fresh row at the new version.
auth:
ack:
heading: "Before you create an account, please confirm:"
items:
- "This service is editorial and educational commentary on public market data. It is not financial, investment, or tax advice and is not a personal recommendation."
- "The operator is not authorised or regulated to provide investment advice. Nothing here is a recommendation to buy, sell, or hold any investment."
- "I make my own investment decisions and am solely responsible for them. For personal advice I will consult a regulated adviser."
checkbox_label: "I have read and accept these statements."
error_required: "Please tick the box to confirm the acknowledgement before continuing."
lang_switch_aria: "Language"

View file

@ -1,140 +0,0 @@
# Italian copy for the public landing page.
#
# Note: brand names ("Read the Markets") and proper nouns
# (tickers, currency codes) stay verbatim — they aren't translated.
hero:
tagline: "Capisci i mercati. Non scommetterci sopra."
subhead: >-
Pensato per investitori che vogliono <strong>ragionare con
lucidità</strong> e tagliare fuori il rumore ad alta frequenza
che nasce dal trattare i mercati come un casinò. Aggreghiamo
notizie cross-asset e segnali macro, e ne scriviamo una lettura
in prosa chiara: cosa giustificano i fondamentali sottostanti,
rispetto a cosa sta facendo la folla. Aggiornato durante la
giornata di trading. Un servizio editoriale, non finanziario.
cta_dashboard: "Apri dashboard"
cta_pricing: "Vedi i piani"
cta_signup: "Iscriviti gratis"
shot_dashboard:
alt: "Anteprima dashboard"
zoom_hint: "Clicca per ingrandire"
caption: >-
La dashboard. In alto una lettura cross-asset aggregata, sotto
gruppi di indicatori selezionati a mano. Il toggle del livello
di lettura (Novice / Pro) cambia il taglio di ogni pannello
generato dall'IA, da prosa accessibile a registro tecnico
sintetico.
features:
news:
tag: "Notizie, aggregate"
title: "Titoli dall'intero universo macro"
body: >-
Feed RSS e per-ticker che coprono azioni, tassi, credito, FX,
materie prime e geopolitica. Ogni titolo è etichettato
automaticamente per tema, così il rumore resta rumore e il
materiale rilevante per i fondamentali è facile da trovare.
L'ingestione segue il calendario di trading — fuori orario
tutto resta in pausa.
shot_alt: "Feed notizie con tag automatici"
shot_caption: >-
Il feed delle notizie. Ogni titolo porta uno o più tag tematici
(tassi, AI, energia, geopolitica, …) per tenere i fili che ti
interessano e silenziare gli altri. Click su un tag per
includerlo; shift-click per escluderlo.
indicators:
tag: "Segnali macro"
title: "Una tape cross-asset curata"
body: >-
Un set di indicatori selezionato a mano su ogni classe di
attivo, aggiornato ogni ora durante le sessioni di mercato.
Ogni gruppo ha una lettura breve che spiega cosa il movimento
<em>significa</em>, non cosa è stato. Ancorata a utili,
politica monetaria, valutazione — non a pattern grafici.
shot_alt: "Pannello indicatori con commento IA"
shot_caption: >-
Il pannello indicatori. Tab per classe di attivo (azionario,
tassi, materie prime, FX, obbligazioni, …); ogni tab porta una
'lettura' di un paragrafo scritta dal modello sopra i prezzi
live. I numeri ancorano la prosa, così il commento è
verificabile, non sospeso nel vuoto.
strategic:
tag: "Lettura strategica"
title: "Razionale vs irrazionale, paragrafo per paragrafo"
body: >-
Leghiamo i titoli della giornata e i segnali cross-asset in
un'unica interpretazione breve. Ogni paragrafo distingue i
<strong>driver razionali</strong> (utili, politica, valutazione)
dagli <strong>irrazionali</strong> (posizionamento, narrativa,
flussi) e dà un nome al divario. Due livelli di lettura: Novice
e Pro. È commento editoriale su dati pubblici — non una
previsione e non un consiglio su nessuna decisione di
investimento.
shot_alt: "Log strategico — la lettura editoriale IA"
shot_caption: >-
Il log strategico. Il modello scrive un'interpretazione
aggiornata durante la giornata, partendo dalla versione
precedente come contesto, in modo da aggiornare invece di
ricominciare. Gli utenti paganti ricevono un refresh ogni ora;
gli utenti gratuiti uno ogni sei.
multilang_callout: >-
Ogni superficie generata dall'IA — log strategico, letture degli
indicatori, chat, digest giornaliero — è disponibile in
<strong>Inglese</strong> e <strong>Italiano</strong>. 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:
head: "Altre viste"
chat:
alt: "Fai domande di approfondimento su qualunque log passato"
caption: >-
Fai domande di approfondimento su qualunque log passato. Il
pannello chat eredita tutto il contesto del log, così puoi
tirare un filo senza re-incollare titoli o re-spiegare la
situazione.
caption_strong: "Chiedi qualsiasi cosa su un log"
caption_span: "Domande conversazionali con il contesto della giornata già caricato."
portfolio_blurb: >-
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 è."
items:
- "Non è consulenza finanziaria."
- "Non sono segnali di trading."
- "Non è uno strumento per day-trading."
- "Nessuna chiamata buy/sell, mai."
- "Nessuna previsione basata su pattern grafici."
- "Non è un servizio regolamentato."
footer:
legal: >-
Iscrivendoti accetti i nostri <a href="/terms">Termini</a> e
l'<a href="/privacy">Informativa privacy</a>, e confermi di aver
letto il <a href="/disclaimer">disclaimer finanziario</a>.
meta:
description: "Capisci i mercati. Non scommetterci sopra."
# TODO(legal): final wording pending solicitor sign-off. Bumping
# ACKNOWLEDGEMENT_VERSION (app/legal.py) re-prompts every existing user
# on next sign-in and writes a fresh row at the new version.
auth:
ack:
heading: "Prima di creare un account, conferma:"
items:
- "Questo servizio è commento editoriale ed educativo su dati di mercato pubblici. Non è consulenza finanziaria, di investimento o fiscale e non è una raccomandazione personalizzata."
- "L'operatore non è autorizzato né regolamentato a fornire consulenza in materia di investimenti. Nulla qui è una raccomandazione di acquisto, vendita o detenzione di alcuno strumento finanziario."
- "Prendo da solo le mie decisioni di investimento e ne sono unico responsabile. Per una consulenza personalizzata mi rivolgerò a un consulente regolamentato."
checkbox_label: "Ho letto e accetto queste dichiarazioni."
error_required: "Spunta la casella per confermare prima di continuare."
lang_switch_aria: "Lingua"

View file

@ -66,12 +66,6 @@ async def lifespan(app: FastAPI):
async with get_session_factory()() as session: async with get_session_factory()() as session:
inserted = await bootstrap_feeds(session) inserted = await bootstrap_feeds(session)
log.info("cassandra.feeds.bootstrap", inserted=inserted) log.info("cassandra.feeds.bootstrap", inserted=inserted)
# Load public-page translation YAMLs into memory once at startup.
# Lazy-loaded otherwise, but pre-loading lets startup fail loudly
# if a YAML file is corrupted instead of failing on the first
# landing-page request.
from app.services.locales import load_locales
load_locales()
yield yield
log.info("cassandra.shutdown") log.info("cassandra.shutdown")

View file

@ -118,10 +118,6 @@ class StrategicLog(Base):
prompt_tokens: Mapped[int | None] = mapped_column(Integer) prompt_tokens: Mapped[int | None] = mapped_column(Integer)
completion_tokens: Mapped[int | None] = mapped_column(Integer) completion_tokens: Mapped[int | None] = mapped_column(Integer)
cost_usd: Mapped[float | None] = mapped_column(Float) cost_usd: Mapped[float | None] = mapped_column(Float)
# Reviewer self-rating 0-10 (10 = exemplary, 0 = unfit). Nullable for
# rows generated before the score field existed; new rows always
# carry the value the reviewer returned alongside its clean verdict.
reviewer_score: Mapped[int | None] = mapped_column(SmallInteger)
class StrategicLogTranslation(Base): class StrategicLogTranslation(Base):
@ -174,8 +170,6 @@ class IndicatorSummary(Base):
prompt_tokens: Mapped[int | None] = mapped_column(Integer) prompt_tokens: Mapped[int | None] = mapped_column(Integer)
completion_tokens: Mapped[int | None] = mapped_column(Integer) completion_tokens: Mapped[int | None] = mapped_column(Integer)
cost_usd: Mapped[float | None] = mapped_column(Float) cost_usd: Mapped[float | None] = mapped_column(Float)
# Reviewer self-rating 0-10. See StrategicLog.reviewer_score.
reviewer_score: Mapped[int | None] = mapped_column(SmallInteger)
__table_args__ = (Index("ix_indsumm_group_generated", "group_name", "generated_at"),) __table_args__ = (Index("ix_indsumm_group_generated", "group_name", "generated_at"),)
@ -226,100 +220,6 @@ class AICall(Base):
error: Mapped[str | None] = mapped_column(String(512)) 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))
# Reviewer self-rating 0-10. Deterministic-layer hits get 0 (hard
# reject by rule), error rows get NULL, LLM rows get the model's score.
score: Mapped[int | None] = mapped_column(SmallInteger)
class StrategicLogFeedback(Base):
"""Anonymous-in-UI thumb up/down votes on strategic-log rows.
One row per (log_id, user_id) flippable: a user can change their
vote (up down) by overwriting, or clear it by deleting. The UI
surfaces only aggregate counts; user attribution is server-side
only and exists purely so we can dedup and let the voter see/flip
their own vote. See app/services/log_feedback.py."""
__tablename__ = "strategic_log_feedback"
id: Mapped[int] = mapped_column(_PK, primary_key=True, autoincrement=True)
log_id: Mapped[int] = mapped_column(
BigInteger().with_variant(Integer(), "sqlite"),
ForeignKey("strategic_logs.id", ondelete="CASCADE"),
nullable=False,
)
user_id: Mapped[int] = mapped_column(
Integer,
ForeignKey("users.id", ondelete="CASCADE"),
nullable=False,
)
# 'up' or 'down'. Service layer enforces the enum.
vote: Mapped[str] = mapped_column(String(8), nullable=False)
created_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), nullable=False, default=utcnow,
)
updated_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), nullable=False, default=utcnow,
onupdate=utcnow,
)
__table_args__ = (
UniqueConstraint("log_id", "user_id", name="uq_slf_log_user"),
Index("ix_strategic_log_feedback_log", "log_id"),
)
class UserAcknowledgement(Base):
"""Append-only record of a user actively accepting the sign-up
acknowledgement at ``/login``. See app/legal.py for the version
constant and app/locales/{en,it}.yaml::auth.ack for the canonical text.
One row per (user, version) acceptance. The value of this table is
evidentiary: 'user X accepted the version-N text on date D, in the
language they were shown'. A future wording revision bumps
ACKNOWLEDGEMENT_VERSION; existing users see the prompt again on next
sign-in and a new row is written at the new version."""
__tablename__ = "user_acknowledgements"
id: Mapped[int] = mapped_column(_PK, primary_key=True, autoincrement=True)
user_id: Mapped[int] = mapped_column(
Integer,
ForeignKey("users.id", ondelete="CASCADE"),
nullable=False,
)
version: Mapped[int] = mapped_column(SmallInteger, nullable=False)
# Language the user actually saw — what they accepted is keyed to it.
lang: Mapped[str] = mapped_column(String(8), nullable=False)
accepted_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), default=utcnow, nullable=False, index=True,
)
__table_args__ = (
Index("ix_user_acknowledgements_user_version", "user_id", "version"),
)
# Portfolio / PortfolioSnapshot / Position removed in Phase G — # Portfolio / PortfolioSnapshot / Position removed in Phase G —
# holdings live in the browser, the server stores only the anonymous # holdings live in the browser, the server stores only the anonymous
# ticker universe + public market data. # ticker universe + public market data.

View file

@ -285,13 +285,11 @@ async def news_list(
def _log_partial_payload( def _log_partial_payload(
row: StrategicLog | None, row: StrategicLog | None,
content_override: str | None = None, content_override: str | None = None,
feedback: object | None = None,
) -> dict | None: ) -> dict | None:
if row is None: if row is None:
return None return None
content = content_override if content_override is not None else row.content content = content_override if content_override is not None else row.content
return { return {
"id": row.id,
"content_html": _md_to_html(content), "content_html": _md_to_html(content),
"generated_at": row.generated_at, "generated_at": row.generated_at,
"model": row.model, "model": row.model,
@ -301,7 +299,6 @@ def _log_partial_payload(
"cost_usd": row.cost_usd, "cost_usd": row.cost_usd,
"prompt_tokens": row.prompt_tokens, "prompt_tokens": row.prompt_tokens,
"completion_tokens": row.completion_tokens, "completion_tokens": row.completion_tokens,
"feedback": feedback,
} }
@ -407,12 +404,9 @@ async def log_latest(
if as_ == "html": if as_ == "html":
content_override = await _localized_content(session, row, principal) content_override = await _localized_content(session, row, principal)
feedback = await _feedback_for(session, row, principal)
return templates.TemplateResponse( return templates.TemplateResponse(
request, "partials/log.html", request, "partials/log.html",
{"log": _log_partial_payload( {"log": _log_partial_payload(row, content_override=content_override),
row, content_override=content_override, feedback=feedback,
),
"tone": wanted_tone, "paid": not free_only}, "tone": wanted_tone, "paid": not free_only},
) )
@ -421,21 +415,6 @@ async def log_latest(
return StrategicLogOut.model_validate(row, from_attributes=True) return StrategicLogOut.model_validate(row, from_attributes=True)
async def _feedback_for(
session: AsyncSession,
row: StrategicLog | None,
principal: CurrentUser | None,
):
"""Aggregate up/down counts + the principal's own vote, or None when
there's no log to fetch feedback for. Always safe to await; runs two
indexed queries."""
if row is None:
return None
from app.services.log_feedback import get_counts
user_id = principal.user.id if (principal and principal.user) else None
return await get_counts(session, log_id=row.id, user_id=user_id)
@router.get("/log/by-date/{day}") @router.get("/log/by-date/{day}")
async def log_by_date( async def log_by_date(
request: Request, request: Request,
@ -480,12 +459,9 @@ async def log_by_date(
if as_ == "html": if as_ == "html":
content_override = await _localized_content(session, row, principal) content_override = await _localized_content(session, row, principal)
feedback = await _feedback_for(session, row, principal)
return templates.TemplateResponse( return templates.TemplateResponse(
request, "partials/log.html", request, "partials/log.html",
{"log": _log_partial_payload( {"log": _log_partial_payload(row, content_override=content_override),
row, content_override=content_override, feedback=feedback,
),
"tone": wanted_tone, "paid": not free_only}, "tone": wanted_tone, "paid": not free_only},
) )
if row is None: if row is None:
@ -493,53 +469,6 @@ async def log_by_date(
return StrategicLogOut.model_validate(row, from_attributes=True) return StrategicLogOut.model_validate(row, from_attributes=True)
# --- Log feedback (thumb up/down) --------------------------------------------
class FeedbackIn(BaseModel):
vote: Literal["up", "down", "clear"]
class FeedbackOut(BaseModel):
up: int
down: int
user_vote: str | None
@router.post("/log/{log_id}/feedback", response_model=FeedbackOut)
async def post_log_feedback(
log_id: int,
body: FeedbackIn,
session: AsyncSession = Depends(get_session),
principal: CurrentUser = Depends(require_token),
) -> FeedbackOut:
"""Record (or flip / clear) the authenticated user's thumb on a log.
Anonymous-in-UI: the response carries only aggregate counts plus the
*requesting* user's own vote (so the UI can highlight it). Other
users' votes are never exposed."""
if principal.user is None:
raise HTTPException(status_code=400, detail="admin token cannot vote")
# Guard against votes on non-existent logs (don't want orphan FKs).
exists = (await session.execute(
select(StrategicLog.id).where(StrategicLog.id == log_id).limit(1)
)).scalar_one_or_none()
if exists is None:
raise HTTPException(status_code=404, detail="log not found")
from app.services.log_feedback import FeedbackError, set_vote
try:
counts = await set_vote(
session, log_id=log_id, user_id=principal.user.id, vote=body.vote,
)
except FeedbackError as e:
raise HTTPException(status_code=400, detail=str(e))
return FeedbackOut(
up=counts.up, down=counts.down, user_vote=counts.user_vote,
)
# --- Calendar archive -------------------------------------------------------- # --- Calendar archive --------------------------------------------------------

View file

@ -34,38 +34,13 @@ from app.auth import (
) )
from app.config import get_settings from app.config import get_settings
from app.db import get_session, utcnow from app.db import get_session, utcnow
from app.legal import ACKNOWLEDGEMENT_VERSION
from app.logging import get_logger from app.logging import get_logger
from app.services.auth_service import ( from app.services.auth_service import AuthError, get_or_create_user, get_user
AuthError,
get_or_create_user,
get_user,
has_acknowledged_current,
record_acknowledgement,
)
from app.services import otp_service, referral_service from app.services import otp_service, referral_service
from app.services.email_service import EmailSendError, send_otp, send_welcome_email from app.services.email_service import EmailSendError, send_otp, send_welcome_email
from app.services.locales import ACTIVE_PUBLIC_LANGS, detect_public_lang, get_locale
from app.templates_env import templates from app.templates_env import templates
_LANG_COOKIE = "rtm.lang"
def _resolve_login_lang(request: Request, explicit: str | None) -> str:
"""Resolve the language to render /login in. An explicit ?lang= wins
(used by the on-page EN/IT pill); otherwise the same detection
landing.py uses cookie Accept-Language cf-ipcountry en."""
if explicit and explicit in ACTIVE_PUBLIC_LANGS:
return explicit
return detect_public_lang(
cookie_lang=request.cookies.get(_LANG_COOKIE),
accept_language=request.headers.get("accept-language"),
cf_country=request.headers.get("cf-ipcountry"),
user_lang=None,
)
log = get_logger("auth_router") log = get_logger("auth_router")
router = APIRouter(tags=["auth"]) router = APIRouter(tags=["auth"])
@ -136,7 +111,6 @@ async def login_page(
next: str | None = None, next: str | None = None,
error: str | None = None, error: str | None = None,
ref: str | None = None, ref: str | None = None,
lang: str | None = None,
session: AsyncSession = Depends(get_session), session: AsyncSession = Depends(get_session),
): ):
# If a valid referral code is supplied, surface a small "invited" # If a valid referral code is supplied, surface a small "invited"
@ -147,27 +121,15 @@ async def login_page(
await referral_service.lookup_referrer(session, ref_norm) await referral_service.lookup_referrer(session, ref_norm)
if ref_norm else None if ref_norm else None
) )
resolved_lang = _resolve_login_lang(request, lang) return templates.TemplateResponse(
response = templates.TemplateResponse(
request, "login.html", request, "login.html",
{ {
"next_path": _safe_next(next), "next_path": _safe_next(next),
"error": error, "error": error,
"ref": ref_norm if referrer else None, "ref": ref_norm if referrer else None,
"referrer_present": referrer is not None, "referrer_present": referrer is not None,
"lang": resolved_lang,
"t": get_locale(resolved_lang),
"ack_version": ACKNOWLEDGEMENT_VERSION,
}, },
) )
# Stamp the language cookie if the user used the on-page pill so
# subsequent visits land on the same translation without re-detecting.
if lang and lang in ACTIVE_PUBLIC_LANGS:
response.set_cookie(
_LANG_COOKIE, lang,
max_age=60 * 60 * 24 * 365, samesite="lax", httponly=False,
)
return response
@router.post("/login") @router.post("/login")
@ -176,9 +138,6 @@ async def login_submit(
email: str = Form(...), email: str = Form(...),
next: str | None = Form(default=None), next: str | None = Form(default=None),
ref: str | None = Form(default=None), ref: str | None = Form(default=None),
lang: str | None = Form(default=None),
acknowledged: str | None = Form(default=None),
ack_version: int | None = Form(default=None),
session: AsyncSession = Depends(get_session), session: AsyncSession = Depends(get_session),
): ):
s = get_settings() s = get_settings()
@ -191,27 +150,6 @@ async def login_submit(
if ref_norm else None if ref_norm else None
) )
# Resolve language from the form (what the user actually saw) or
# fall back to detection. This is the language we'll record on the
# acknowledgement row if one gets written.
resolved_lang = _resolve_login_lang(request, lang)
t = get_locale(resolved_lang)
# Affirmative-acknowledgement check. Browsers only POST the checkbox
# name when ticked, so any truthy value here means accepted.
if not acknowledged:
return templates.TemplateResponse(
request, "login.html",
{"next_path": _safe_next(next),
"error": str(t.auth.ack.error_required),
"email": email,
"ref": ref_norm if referrer else None,
"referrer_present": referrer is not None,
"lang": resolved_lang, "t": t,
"ack_version": ACKNOWLEDGEMENT_VERSION},
status_code=400,
)
# Track whether THIS request creates the user row (i.e. a referral # Track whether THIS request creates the user row (i.e. a referral
# capture window). Cleanest way: probe for existence first. # capture window). Cleanest way: probe for existence first.
from app.services.auth_service import get_user_by_email from app.services.auth_service import get_user_by_email
@ -226,9 +164,7 @@ async def login_submit(
request, "login.html", request, "login.html",
{"next_path": _safe_next(next), "error": str(e), "email": email, {"next_path": _safe_next(next), "error": str(e), "email": email,
"ref": ref_norm if referrer else None, "ref": ref_norm if referrer else None,
"referrer_present": referrer is not None, "referrer_present": referrer is not None},
"lang": resolved_lang, "t": t,
"ack_version": ACKNOWLEDGEMENT_VERSION},
status_code=400, status_code=400,
) )
@ -239,15 +175,6 @@ async def login_submit(
if was_new and referrer is not None: if was_new and referrer is not None:
await referral_service.link_new_user(session, user, referrer) await referral_service.link_new_user(session, user, referrer)
# Record the acknowledgement at the current version, idempotently.
# If the user has already accepted this version we skip — every
# sign-in needn't write a row, only every version bump does.
if not await has_acknowledged_current(session, user):
await record_acknowledgement(session, user, resolved_lang)
log.info("auth.acknowledgement_recorded",
user_id=user.id, version=ACKNOWLEDGEMENT_VERSION,
lang=resolved_lang)
# Issue OTP only if cooldown allows; if a fresh one was sent in the # Issue OTP only if cooldown allows; if a fresh one was sent in the
# last 60s we just reuse the existing one (silently) to avoid # last 60s we just reuse the existing one (silently) to avoid
# spamming the user's inbox on a refreshed form submit. # spamming the user's inbox on a refreshed form submit.

View file

@ -188,8 +188,7 @@ async def chat(
# leading question; the generator's system prompt forbids it, # leading question; the generator's system prompt forbids it,
# but the reviewer is the enforcement layer. ~1-2 s extra # but the reviewer is the enforcement layer. ~1-2 s extra
# latency per turn on top of the generation call. # 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: except Exception as e:
session.add(AICall( session.add(AICall(
model=s.OPENROUTER_MODEL, status="error", error=str(e)[:500], model=s.OPENROUTER_MODEL, status="error", error=str(e)[:500],

View file

@ -13,44 +13,9 @@ from app.config import get_settings, load_groups
from app.db import get_session from app.db import get_session
from app.models import EmailSend, Referral, StrategicLog, User from app.models import EmailSend, Referral, StrategicLog, User
from app.services.access import is_paid_active, paid_status from app.services.access import is_paid_active, paid_status
from app.services.locales import (
ACTIVE_PUBLIC_LANGS,
DEFAULT_LANG,
detect_public_lang,
get_locale,
)
from app.services.referral_service import assign_code_if_missing from app.services.referral_service import assign_code_if_missing
from app.templates_env import templates from app.templates_env import templates
# Cookie used to remember an explicit language toggle on public pages.
# Distinct from the in-app user.lang preference (which lives in the
# DB for authenticated users).
_LANG_COOKIE = "rtm.lang"
_LANG_COOKIE_MAX_AGE = 60 * 60 * 24 * 365 # 1 year
def _render_landing(
request: Request, cu: CurrentUser | None, lang: str,
) -> HTMLResponse:
"""Render the localised landing page and stamp the language
cookie so a return visitor lands on the same translation without
another detection pass."""
t = get_locale(lang)
response = templates.TemplateResponse(
request,
"landing.html",
{"cu": cu, "t": t, "lang": lang, "lang_switch": True},
)
# `secure` would block the cookie in local-dev HTTP; rely on the
# reverse proxy to upgrade everything to HTTPS in prod. samesite=Lax
# is the cookie we want for first-party navigation.
response.set_cookie(
_LANG_COOKIE, lang,
max_age=_LANG_COOKIE_MAX_AGE, samesite="lax",
httponly=False,
)
return response
# Router-level auth removed in favour of per-route deps so that `/` can be # Router-level auth removed in favour of per-route deps so that `/` can be
# dual-purpose: logged-in users see the dashboard, logged-out visitors see # dual-purpose: logged-in users see the dashboard, logged-out visitors see
# the landing page. # the landing page.
@ -62,19 +27,11 @@ async def root_page(
request: Request, request: Request,
cu: CurrentUser | None = Depends(maybe_current_user), cu: CurrentUser | None = Depends(maybe_current_user),
): ):
"""Dual-purpose root: dashboard when authenticated, otherwise """Dual-purpose root: dashboard when authenticated, landing otherwise."""
detect the visitor's language and redirect to the localised
landing URL. Detection considers (in order) the rtm.lang cookie,
the Accept-Language header, the cf-ipcountry geolocation header,
and finally DEFAULT_LANG."""
if cu is None: if cu is None:
lang = detect_public_lang( return templates.TemplateResponse(
cookie_lang=request.cookies.get(_LANG_COOKIE), request, "landing.html", {"cu": None},
accept_language=request.headers.get("accept-language"),
cf_country=request.headers.get("cf-ipcountry"),
user_lang=None,
) )
return RedirectResponse(url=f"/{lang}/", status_code=302)
s = get_settings() s = get_settings()
groups = load_groups(s.BASELINE_TOML, s.PORTFOLIO_TOML) groups = load_groups(s.BASELINE_TOML, s.PORTFOLIO_TOML)
return templates.TemplateResponse( return templates.TemplateResponse(
@ -85,27 +42,6 @@ async def root_page(
) )
@router.get("/en/", response_class=HTMLResponse)
async def landing_en(
request: Request,
cu: CurrentUser | None = Depends(maybe_current_user),
):
"""English landing. For logged-in users with a non-en `user.lang`
we still serve EN content here because the URL is an explicit
request same shape as a manual toggle click. The cookie gets
set to en so subsequent /-visits keep them in English."""
return _render_landing(request, cu, lang="en")
@router.get("/it/", response_class=HTMLResponse)
async def landing_it(
request: Request,
cu: CurrentUser | None = Depends(maybe_current_user),
):
"""Italian landing. Same explicit-URL contract as landing_en."""
return _render_landing(request, cu, lang="it")
@router.get( @router.get(
"/news", "/news",
response_class=HTMLResponse, response_class=HTMLResponse,
@ -176,60 +112,6 @@ async def log_page_day(
) )
@router.get("/feedback", response_class=HTMLResponse)
async def log_feedback_via_token(
request: Request,
token: str,
vote: str | None = None,
session: AsyncSession = Depends(get_session),
):
"""Email-link target for thumb up/down votes on a strategic log.
The signed token encodes (user_id, log_id, intended_vote). The query
param ``vote`` is informational (lets the URL be self-describing in
the inbox); the canonical vote is what's in the token. If the two
disagree the token wins.
Renders a small thank-you confirmation. No auth required the token
is the auth-equivalent for this single side-effecting action."""
from app.services.log_feedback import (
FeedbackError, set_vote, verify_feedback_token,
)
payload = verify_feedback_token(token)
if payload is None:
return templates.TemplateResponse(
request, "feedback_thanks.html",
{"ok": False, "message": "This link has expired or is invalid.",
"log_id": None, "vote": None},
status_code=400,
)
try:
counts = await set_vote(
session,
log_id=payload["log_id"],
user_id=payload["user_id"],
vote=payload["vote"],
)
except FeedbackError as e:
return templates.TemplateResponse(
request, "feedback_thanks.html",
{"ok": False, "message": str(e), "log_id": payload["log_id"],
"vote": payload["vote"]},
status_code=400,
)
return templates.TemplateResponse(
request, "feedback_thanks.html",
{"ok": True,
"vote": payload["vote"],
"log_id": payload["log_id"],
"counts": counts,
"message": None},
)
@router.get("/settings", response_class=HTMLResponse) @router.get("/settings", response_class=HTMLResponse)
async def settings_page( async def settings_page(
request: Request, request: Request,

View file

@ -16,7 +16,6 @@ from fastapi.responses import HTMLResponse
from app.auth import CurrentUser, maybe_current_user from app.auth import CurrentUser, maybe_current_user
from app.services.access import is_paid_active from app.services.access import is_paid_active
from app.services.feature_flags import require_flag
from app.templates_env import templates from app.templates_env import templates
@ -30,11 +29,7 @@ def _ctx(request: Request, cu: CurrentUser | None) -> dict:
return {"cu": cu} return {"cu": cu}
@router.get( @router.get("/pricing", response_class=HTMLResponse)
"/pricing",
response_class=HTMLResponse,
dependencies=[Depends(require_flag("SUBSCRIPTIONS_ENABLED"))],
)
async def pricing_page( async def pricing_page(
request: Request, request: Request,
cu: CurrentUser | None = Depends(maybe_current_user), cu: CurrentUser | None = Depends(maybe_current_user),

View file

@ -19,7 +19,7 @@ from __future__ import annotations
import asyncio import asyncio
import json import json
from typing import Any, Literal from typing import Any, Literal, Optional
import stripe import stripe
from fastapi import APIRouter, Body, Depends, HTTPException, Request from fastapi import APIRouter, Body, Depends, HTTPException, Request
@ -34,16 +34,10 @@ from app.config import get_settings
from app.db import get_session, utcnow from app.db import get_session, utcnow
from app.logging import get_logger from app.logging import get_logger
from app.models import StripeEvent, User from app.models import StripeEvent, User
from app.services.feature_flags import require_flag
log = get_logger("stripe_billing") log = get_logger("stripe_billing")
# Whole router gated by SUBSCRIPTIONS_ENABLED: checkout, portal, and webhook router = APIRouter()
# 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 # Cap stored payload at 16 KiB so a hostile (or buggy) sender can't
@ -75,21 +69,51 @@ def _price_for(cadence: str) -> str:
raise HTTPException(status_code=400, detail="cadence must be 'monthly' or 'annual'") raise HTTPException(status_code=400, detail="cadence must be 'monthly' or 'annual'")
# NOTE: we deliberately never pass `currency` to Stripe, so every # Rough country → currency mapping. Covers the markets we have a stated
# checkout bills the Price's base currency — GBP. An earlier version # rate for; everything else falls back to GBP (the home currency) and
# sniffed CF-IPCountry / Accept-Language and selected a matching # Stripe handles the FX at checkout. Configure the per-currency
# `currency_options` entry, but /pricing renders £7 and £70 as static # unit_amount on each Price's `currency_options` in the Stripe Dashboard
# copy: a US visitor was shown £7 and charged $9.99. Showing one price # — we just signal which option to use here.
# and billing another is exactly what the UK CPRs and the EU _COUNTRY_CURRENCY: dict[str, str] = {
# price-indication rules prohibit, so the sniffing was removed rather "US": "usd", "CA": "usd",
# than the disclosure patched. The `currency_options` still configured "GB": "gbp", "IM": "gbp", "JE": "gbp", "GG": "gbp",
# on the Prices in the Dashboard are simply unused. **dict.fromkeys((
# "DE", "FR", "IT", "ES", "PT", "NL", "BE", "IE", "AT", "FI",
# To reinstate geo-pricing, /pricing must render the matching currency "GR", "LU", "MT", "CY", "EE", "LV", "LT", "SI", "SK", "HR",
# in its copy, its buttons AND its annual-saving claim first (the claim ), "eur"),
# is currency-specific: "two months free" is true at £70/£84 and }
# $94.99/$119.88, but not at €80/€84). See git history for the removed
# _sniff_currency helper and its country/locale tables. # Accept-Language locale → currency, used when CF-IPCountry is absent.
# Ambiguous locales (e.g. plain "fr" without region) get EUR because
# that's the majority outcome.
_LOCALE_CURRENCY: dict[str, str] = {
"en-gb": "gbp", "en": "gbp",
"en-us": "usd", "en-ca": "usd",
"fr": "eur", "de": "eur", "it": "eur", "es": "eur",
"pt": "eur", "nl": "eur",
}
def _sniff_currency(request: Request) -> str:
"""Best-effort currency detection for new-customer checkouts.
Order: explicit Cloudflare country header, then Accept-Language
(exact match then language-only). GBP as the final fallback. Only
consulted when the user has no Stripe customer record yet Stripe
locks currency at customer creation, so an existing customer's
currency wins regardless of the request locale.
"""
cc = (request.headers.get("cf-ipcountry") or "").upper()
if cc in _COUNTRY_CURRENCY:
return _COUNTRY_CURRENCY[cc]
al = (request.headers.get("accept-language") or "").lower()
first = al.split(",", 1)[0].split(";", 1)[0].strip()
if first in _LOCALE_CURRENCY:
return _LOCALE_CURRENCY[first]
short = first.split("-", 1)[0]
if short in _LOCALE_CURRENCY:
return _LOCALE_CURRENCY[short]
return "gbp"
def _stripe_client() -> stripe.StripeClient: def _stripe_client() -> stripe.StripeClient:
@ -106,6 +130,10 @@ def _stripe_client() -> stripe.StripeClient:
class CheckoutRequest(BaseModel): class CheckoutRequest(BaseModel):
cadence: Literal["monthly", "annual"] cadence: Literal["monthly", "annual"]
# Optional override; when omitted we sniff from request headers.
# Honoured only for first-time checkouts (Stripe locks currency
# to the customer at creation).
currency: Optional[Literal["gbp", "usd", "eur"]] = None
class CheckoutResponse(BaseModel): class CheckoutResponse(BaseModel):
@ -115,6 +143,7 @@ class CheckoutResponse(BaseModel):
@router.post("/api/stripe/checkout", response_model=CheckoutResponse) @router.post("/api/stripe/checkout", response_model=CheckoutResponse)
async def create_checkout( async def create_checkout(
body: CheckoutRequest, body: CheckoutRequest,
request: Request,
session: AsyncSession = Depends(get_session), session: AsyncSession = Depends(get_session),
cu: CurrentUser = Depends(require_auth), cu: CurrentUser = Depends(require_auth),
) -> CheckoutResponse: ) -> CheckoutResponse:
@ -142,19 +171,14 @@ async def create_checkout(
# Lets us paste in a referral coupon at checkout once the # Lets us paste in a referral coupon at checkout once the
# referral redemption flow ships. # referral redemption flow ships.
"allow_promotion_codes": True, "allow_promotion_codes": True,
# Collect a billing address on every checkout so each Stripe
# Customer carries a country. Two reasons: card-fraud checks get
# materially better with AVS data, and EU B2C digital-services
# VAT is charged at the *consumer's* place of supply — we can't
# answer the OSS question at all without knowing where buyers
# are. Note this is the address on the card, not an IP guess,
# which is the evidence a tax authority actually accepts.
"billing_address_collection": "required",
} }
# No `currency` kwarg — every checkout bills the Price's base # Multi-currency: for first-time buyers (no stripe_customer_id yet)
# currency (GBP), matching the static £7 / £70 copy on /pricing. # we pass the detected/requested currency. Stripe picks the matching
# See the note above _stripe_client() before reintroducing one. # `currency_options` rate configured on the Price in the Dashboard,
# # then locks that currency to the new customer record. Existing
# customers keep their original currency regardless.
if not user.stripe_customer_id:
create_kwargs["currency"] = body.currency or _sniff_currency(request)
# Per-cadence cooling-off treatment: # Per-cadence cooling-off treatment:
# #
# - Annual gets a 14-day free trial. No money moves during the # - Annual gets a 14-day free trial. No money moves during the
@ -173,12 +197,6 @@ async def create_checkout(
create_kwargs["subscription_data"] = {"trial_period_days": 14} create_kwargs["subscription_data"] = {"trial_period_days": 14}
if user.stripe_customer_id: if user.stripe_customer_id:
create_kwargs["customer"] = user.stripe_customer_id create_kwargs["customer"] = user.stripe_customer_id
# Required for billing_address_collection to actually persist:
# when `customer` is supplied, Stripe collects the address for
# the payment but leaves the Customer record untouched unless
# customer_update.address is "auto". Without this the country
# lands on the PaymentIntent and nowhere durable.
create_kwargs["customer_update"] = {"address": "auto"}
else: else:
create_kwargs["customer_email"] = user.email create_kwargs["customer_email"] = user.email
@ -308,15 +326,11 @@ async def _grant_paid(
await convert_referral(session, user) await convert_referral(session, user)
async def _revoke_paid(user: User, *, keep_subscription: bool = False) -> None: async def _revoke_paid(user: User) -> None:
user.tier = "free" user.tier = "free"
if not keep_subscription: user.stripe_subscription_id = None
user.stripe_subscription_id = None
user.stripe_trial_end_at = None user.stripe_trial_end_at = None
# Keep stripe_customer_id so a re-subscription matches this row. # Keep stripe_customer_id so a re-subscription matches this row.
# `keep_subscription` is for a pause: the subscription still exists
# at Stripe and will resume under the same id, so nulling our copy
# would lose the link while access is merely suspended.
async def _handle_checkout_completed( async def _handle_checkout_completed(
@ -354,16 +368,6 @@ async def _handle_subscription_event(
customer_id=obj.get("customer")) customer_id=obj.get("customer"))
return return
status = obj.get("status") status = obj.get("status")
# `pause_collection` is a *different* mechanism from status="paused":
# the subscription stays `active` while Stripe simply stops invoicing.
# Unhandled, that leaves the customer on paid features indefinitely
# without paying, so treat any live pause as not-paid regardless of
# status. Pause is disabled in our live portal configuration, so in
# practice this only fires if someone re-enables it there or pauses
# from the Dashboard — which is exactly when we'd want it to work.
if obj.get("pause_collection"):
await _revoke_paid(user, keep_subscription=True)
return
# Stripe statuses: trialing, active, past_due, canceled, unpaid, # Stripe statuses: trialing, active, past_due, canceled, unpaid,
# incomplete, incomplete_expired, paused. Treat trialing/active as # incomplete, incomplete_expired, paused. Treat trialing/active as
# paid; everything else holds tier the same until we get an explicit # paid; everything else holds tier the same until we get an explicit
@ -390,22 +394,6 @@ async def _handle_subscription_deleted(
await _revoke_paid(user) await _revoke_paid(user)
async def _handle_subscription_paused(
session: AsyncSession, event_type: str, obj: dict[str, Any],
) -> None:
"""customer.subscription.paused — status flips to `paused` when a
trial ends with no usable payment method (trial_settings.end_behavior
.missing_payment_method = pause). No money is being collected, so
paid features come off. `.resumed` routes to the normal subscription
handler, which grants again on active/trialing."""
user = await _find_user(session, customer_id=obj.get("customer"))
if user is None:
log.warning("stripe.user_not_found", event_type=event_type,
customer_id=obj.get("customer"))
return
await _revoke_paid(user, keep_subscription=True)
async def _handle_audit_only( async def _handle_audit_only(
session: AsyncSession, event_type: str, obj: dict[str, Any], session: AsyncSession, event_type: str, obj: dict[str, Any],
) -> None: ) -> None:
@ -420,8 +408,6 @@ _HANDLERS = {
"customer.subscription.created": _handle_subscription_event, "customer.subscription.created": _handle_subscription_event,
"customer.subscription.updated": _handle_subscription_event, "customer.subscription.updated": _handle_subscription_event,
"customer.subscription.deleted": _handle_subscription_deleted, "customer.subscription.deleted": _handle_subscription_deleted,
"customer.subscription.paused": _handle_subscription_paused,
"customer.subscription.resumed": _handle_subscription_event,
"invoice.paid": _handle_audit_only, "invoice.paid": _handle_audit_only,
"invoice.payment_failed": _handle_audit_only, "invoice.payment_failed": _handle_audit_only,
"charge.refunded": _handle_audit_only, "charge.refunded": _handle_audit_only,

View file

@ -20,17 +20,11 @@ from app.db import get_session
from app.logging import get_logger from app.logging import get_logger
from app.services import portfolio_sync as svc from app.services import portfolio_sync as svc
from app.services.access import require_paid from app.services.access import require_paid
from app.services.feature_flags import require_flag
log = get_logger("portfolio_sync_router") log = get_logger("portfolio_sync_router")
# Whole router gated by PORTFOLIO_SYNC_ENABLED: when the flag is off, every router = APIRouter(prefix="/api/portfolio/sync")
# 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 # A 256 KB cap is ~200× a typical pie's serialized size — generous

View file

@ -41,7 +41,6 @@ from app.models import Quote, QuoteDaily
from app.services import fx, portfolio_analysis, ticker_universe from app.services import fx, portfolio_analysis, ticker_universe
from app.services.access import require_paid from app.services.access import require_paid
from app.services.csv_import import CSVImportError, parse_t212_csv 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.instrument_map import resolve_slice
from app.services.market import fetch as market_fetch from app.services.market import fetch as market_fetch
@ -339,10 +338,7 @@ async def parse_portfolio(
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
@router.post( @router.post("/analyze")
"/analyze",
dependencies=[Depends(require_flag("PORTFOLIO_AI_ENABLED"))],
)
async def analyze_portfolio( async def analyze_portfolio(
request: Request, request: Request,
session: AsyncSession = Depends(get_session), session: AsyncSession = Depends(get_session),
@ -353,8 +349,8 @@ async def analyze_portfolio(
is persisted. The ai_calls ledger row records tokens + cost, never is persisted. The ai_calls ledger row records tokens + cost, never
holdings. holdings.
Gated behind ``PORTFOLIO_AI_ENABLED`` (404 when off) and ``require_paid`` Gated behind ``require_paid``: free-tier users get 402.
(402 for free tier when subscriptions are active).""" Admin bearer-token bypasses the gate for testing."""
# Read JSON body manually so we can enforce a hard size cap. FastAPI's # Read JSON body manually so we can enforce a hard size cap. FastAPI's
# default body limit is generous; we want tighter control here. # default body limit is generous; we want tighter control here.
body = await request.body() body = await request.body()

View file

@ -21,7 +21,6 @@ from datetime import datetime, timezone
from fastapi import Depends, HTTPException, status from fastapi import Depends, HTTPException, status
from app.auth import CurrentUser, require_auth from app.auth import CurrentUser, require_auth
from app.config import get_settings
from app.models import User from app.models import User
# How many hours of news the free tier sees. Paid sees whatever the # How many hours of news the free tier sees. Paid sees whatever the
@ -77,22 +76,13 @@ def paid_status(user: User | None) -> PaidStatus:
def is_paid_active(principal: CurrentUser | User | None) -> bool: def is_paid_active(principal: CurrentUser | User | None) -> bool:
"""True if the principal has paid-tier access right now. Admin """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: if principal is None:
return False return False
if isinstance(principal, CurrentUser): if isinstance(principal, CurrentUser):
if principal.is_admin: if principal.is_admin:
return True return True
if not get_settings().SUBSCRIPTIONS_ENABLED:
return principal.user is not None
return paid_status(principal.user).active return paid_status(principal.user).active
if not get_settings().SUBSCRIPTIONS_ENABLED:
return True
return paid_status(principal).active return paid_status(principal).active

View file

@ -17,8 +17,7 @@ from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
from app.db import utcnow from app.db import utcnow
from app.legal import ACKNOWLEDGEMENT_VERSION from app.models import User
from app.models import User, UserAcknowledgement
class AuthError(Exception): class AuthError(Exception):
@ -70,35 +69,3 @@ async def get_or_create_user(
await session.commit() await session.commit()
await session.refresh(user) await session.refresh(user)
return user return user
async def has_acknowledged_current(
session: AsyncSession, user: User,
) -> bool:
"""True iff ``user`` has a ``user_acknowledgements`` row at the
currently-canonical ``ACKNOWLEDGEMENT_VERSION``. Composite-index hit
on (user_id, version); fast enough to run on every sign-in."""
row = (await session.execute(
select(UserAcknowledgement.id)
.where(UserAcknowledgement.user_id == user.id)
.where(UserAcknowledgement.version == ACKNOWLEDGEMENT_VERSION)
.limit(1)
)).scalar_one_or_none()
return row is not None
async def record_acknowledgement(
session: AsyncSession, user: User, lang: str,
) -> None:
"""Insert one ``user_acknowledgements`` row at the current version,
recording the language the user actually saw. The caller is
expected to gate on ``has_acknowledged_current`` first this helper
does not de-duplicate so the audit trail can carry repeated
acceptances if a future caller wants them."""
session.add(UserAcknowledgement(
user_id=user.id,
version=ACKNOWLEDGEMENT_VERSION,
lang=lang,
accepted_at=utcnow(),
))
await session.commit()

View file

@ -47,7 +47,6 @@ _DIGEST_HTML_TEMPLATE = """\
</div> </div>
<div style="height:24px; line-height:24px; font-size:0;">&nbsp;</div> <div style="height:24px; line-height:24px; font-size:0;">&nbsp;</div>
<div style="border-top:1px solid {L_border};"></div> <div style="border-top:1px solid {L_border};"></div>
{feedback_row}
<div style="height:14px; line-height:14px; font-size:0;">&nbsp;</div> <div style="height:14px; line-height:14px; font-size:0;">&nbsp;</div>
<div class="muted" style="font-size:11px; color:{L_muted};"> <div class="muted" style="font-size:11px; color:{L_muted};">
<a href="{unsubscribe_url}" style="color:{L_accent};">Unsubscribe in one click</a> <a href="{unsubscribe_url}" style="color:{L_accent};">Unsubscribe in one click</a>
@ -71,32 +70,6 @@ def _strip_html_to_text(html_body: str) -> str:
return text.strip() return text.strip()
def _feedback_row_html(
feedback_up_url: str | None,
feedback_down_url: str | None,
light_accent: str,
light_muted: str,
) -> str:
"""Build the optional 'How was today's read?' row that sits between
the digest content and the unsubscribe footer. Empty string when no
feedback URLs were supplied (e.g. there's no latest log to vote on)."""
if not feedback_up_url or not feedback_down_url:
return ""
return (
'<div style="height:14px; line-height:14px; font-size:0;">&nbsp;</div>'
f'<div class="muted" style="font-size:12px; color:{light_muted};">'
"How was today&rsquo;s read? "
f'<a href="{feedback_up_url}" '
f'style="color:{light_accent}; text-decoration:none;">'
"&#x1F44D; Helpful</a>"
" &middot; "
f'<a href="{feedback_down_url}" '
f'style="color:{light_accent}; text-decoration:none;">'
"&#x1F44E; Not useful</a>"
"</div>"
)
def render_digest_email( def render_digest_email(
*, *,
kind: str, kind: str,
@ -104,17 +77,10 @@ def render_digest_email(
content_html: str, content_html: str,
unsubscribe_url: str, unsubscribe_url: str,
settings_url: str, settings_url: str,
feedback_up_url: str | None = None,
feedback_down_url: str | None = None,
) -> tuple[str, str, str]: ) -> tuple[str, str, str]:
"""Returns (subject, text_body, html_body) for a digest email. """Returns (subject, text_body, html_body) for a digest email.
`kind` is "daily" or "weekly". Anything else raises ValueError. `kind` is "daily" or "weekly". Anything else raises ValueError."""
When ``feedback_up_url`` and ``feedback_down_url`` are both supplied,
a small thumb up/down row is rendered above the unsubscribe footer.
Both must be signed-token URLs pointing at /feedback (see
``app.services.log_feedback.sign_feedback_token``)."""
if kind == "daily": if kind == "daily":
label = "Daily" label = "Daily"
subject = f"{branding.BRAND_NAME} · Daily — {date_str}" subject = f"{branding.BRAND_NAME} · Daily — {date_str}"
@ -124,12 +90,6 @@ def render_digest_email(
else: else:
raise ValueError(f"unknown digest kind: {kind!r}") raise ValueError(f"unknown digest kind: {kind!r}")
feedback_row = _feedback_row_html(
feedback_up_url, feedback_down_url,
light_accent=branding.LIGHT["accent"],
light_muted=branding.LIGHT["muted"],
)
html_body = _DIGEST_HTML_TEMPLATE.format( html_body = _DIGEST_HTML_TEMPLATE.format(
brand=branding.BRAND_NAME, brand=branding.BRAND_NAME,
brand_upper=branding.BRAND_NAME.upper(), brand_upper=branding.BRAND_NAME.upper(),
@ -139,7 +99,6 @@ def render_digest_email(
content_html=content_html, content_html=content_html,
unsubscribe_url=unsubscribe_url, unsubscribe_url=unsubscribe_url,
settings_url=settings_url, settings_url=settings_url,
feedback_row=feedback_row,
**{f"L_{k.replace('-', '_')}": v for k, v in branding.LIGHT.items()}, **{f"L_{k.replace('-', '_')}": v for k, v in branding.LIGHT.items()},
**{f"D_{k.replace('-', '_')}": v for k, v in branding.DARK.items()}, **{f"D_{k.replace('-', '_')}": v for k, v in branding.DARK.items()},
) )
@ -150,16 +109,8 @@ def render_digest_email(
"", "",
_strip_html_to_text(content_html), _strip_html_to_text(content_html),
"", "",
]
if feedback_up_url and feedback_down_url:
text_lines.extend([
f"Was this read useful? Helpful: {feedback_up_url}",
f" Not useful: {feedback_down_url}",
"",
])
text_lines.extend([
f"Unsubscribe: {unsubscribe_url}", f"Unsubscribe: {unsubscribe_url}",
f"Manage preferences: {settings_url}", f"Manage preferences: {settings_url}",
]) ]
text_body = "\n".join(text_lines) text_body = "\n".join(text_lines)
return subject, text_body, html_body return subject, text_body, html_body

View file

@ -1,41 +0,0 @@
"""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

View file

@ -28,47 +28,7 @@ from datetime import datetime
# the model was hallucinating future times. The user prompt now carries the # the model was hallucinating future times. The user prompt now carries the
# actual current UTC time so the model has accurate temporal context. # actual current UTC time so the model has accurate temporal context.
# v9 (2026-05-25): Adds daily + weekly digest prompt builders for email. # v9 (2026-05-25): Adds daily + weekly digest prompt builders for email.
# v10 (2026-05-29): Compliance pass. Drops the watch-list section, removes PROMPT_VERSION = 9
# 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
$9093" 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 ---------------------------- # --- Core: invariant across tone/analysis settings ----------------------------
@ -109,10 +69,8 @@ weather or generic context.
- Then 4-6 paragraphs, each anchored on a sleeve, sector, or theme. Concrete \ - Then 4-6 paragraphs, each anchored on a sleeve, sector, or theme. Concrete \
numbers in every paragraph. No section over ~150 words. numbers in every paragraph. No section over ~150 words.
- One paragraph synthesising the news flow into a market read. - One paragraph synthesising the news flow into a market read.
- Close with the synthesis paragraph (and the System temperature line below). \ - End with a watch list: 3-5 specific items to track in the next week, \
Do NOT add a "watch list", "what to monitor", "tripwires", or any equivalent \ each one sentence.
section. A list of conditional price predictions is a forecast framework \
which this log is not.
# Time-horizon discipline # Time-horizon discipline
- This is a STRATEGIC log, not a day-trader's read. Treat 1-day moves under \ - This is a STRATEGIC log, not a day-trader's read. Treat 1-day moves under \
@ -121,6 +79,9 @@ multi-week trend or are extreme outliers.
- Anchor every claim to multi-week (1m), multi-month (since-anchor), or \ - 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, \ multi-year (1y) changes not 1d. If the only thing happening is a 1d move, \
omit the paragraph. 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) # Rational vs irrational framing (MANDATORY in every paragraph)
The reader's primary goal is to disconnect rational decisions from market \ The reader's primary goal is to disconnect rational decisions from market \
@ -149,23 +110,8 @@ without a specific number behind it.
- Distinguish "the thesis predicted X and X happened" from "the thesis \ - 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. 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. - Don't repeat the same point in different words across paragraphs.
- No buy/sell recommendations. No add/trim/rebalance, no overweight/underweight, \ - No buy/sell recommendations. Triggers are pre-set elsewhere; your job is \
no "you should", no "investors should", no "we recommend". to report whether reality is confirming, modifying, or refuting the thesis.
- 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) # Stance (educational, anti-TA, anti-gambling)
The target reader is most likely young, new to investing, and at risk of \ The target reader is most likely young, new to investing, and at risk of \
@ -173,10 +119,9 @@ treating markets like a horse race they need to "read" via chart patterns. \
Cassandra is the corrective. Cassandra is the corrective.
- **No technical analysis.** Head-and-shoulders, RSI thresholds, Fibonacci \ - **No technical analysis.** Head-and-shoulders, RSI thresholds, Fibonacci \
levels, Elliott waves, "support/resistance" these are descriptions of past \ levels, Elliott waves, "support/resistance" these are descriptions of past \
crowd behaviour, not predictions. Don't use them; don't legitimise them. \ crowd behaviour, not predictions. Don't use them; don't legitimise them. If \
Don't cast specific price levels as load-bearing for the read; \ you mention a price level, frame it as a positioning fact (e.g. "the level \
spot prices and percent changes are fine ("Brent at $90", "+12% YTD") but \ where the latest tranche of buyers entered"), not a signal.
"$93 is the level to watch" is not.
- **No gambling framing.** Markets are not a coin flip and not a horse race. \ - **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 \ 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 \ bet to be won. Every read should follow the shape: *regime implication \
@ -190,9 +135,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] 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 glancing reader scrolls to first. Make it earn its place: \ This is the line a reader who only sees the watch list scrolls down to. Make \
cite real signals (HY OAS, breadth, VIX, valuation, real yields), not vibes. \ it earn its place: cite real signals (HY OAS, breadth, VIX, valuation, real \
The label is a description of the current regime, not a forecast. yields), not vibes.
# Update mode (when an earlier log from today is provided) # Update mode (when an earlier log from today is provided)
If the user message includes a section labelled "Earlier log from today \ If the user message includes a section labelled "Earlier log from today \
@ -203,6 +148,8 @@ that timestamp: confirmations, refutations, new emergent patterns.
- The TL;DR should lead with the move since the earlier read when there \ - 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, …") \ was a meaningful intra-day change ("Since this morning's read, …") \
otherwise stay regime-level. 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 \ - Preserve any insights from the earlier draft that remain valid; sharpen \
or revise the ones that don't. Avoid contradicting yourself silently — if \ 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 \ you change a stance, name it briefly ("Earlier I read X; with Y now, the \
@ -303,18 +250,17 @@ def _resolve_tone(tone: str) -> str:
_ANALYSIS: dict[str, str] = { _ANALYSIS: dict[str, str] = {
"DRY": """# Analysis style: dry "DRY": """# Analysis style: dry
Report what happened. Identify divergences and contradictions. Compare to \ Report what happened. Identify divergences and contradictions. Compare to \
references. Do not speculate on what comes next.""", 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.""",
"SPECULATIVE": """# Analysis style: speculative "SPECULATIVE": """# Analysis style: speculative
Report what happened, then explore forward *regimes* never forward prices. \ Report what happened, then explicitly explore forward scenarios. For each \
For each significant sector or theme, you may sketch what the underlying \ significant sector or theme, sketch a 1-4 week scenario set: the base case \
fundamentals and positioning suggest about the prevailing macro regime \ (what the data suggests), a contrarian case (what would invalidate it), and \
(e.g. "the policy mix is still tightening", "real yields remain restrictive", \ what tape signal would tip you from one to the other. Be explicit about \
"crowded positioning leaves little fuel for further upside in this style"). \ uncertainty say "the base case is" not "X will happen". The watch list is \
What you must NOT do is forecast the price or value of any specific named \ the trip-wires that decide between scenarios.""",
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.""",
} }
@ -322,7 +268,7 @@ def build_system_prompt(tone: str, analysis: str) -> str:
"""Compose the system prompt from the chosen audience and analysis style.""" """Compose the system prompt from the chosen audience and analysis style."""
tone_block = _TONE[_resolve_tone(tone)] tone_block = _TONE[_resolve_tone(tone)]
analysis_block = _ANALYSIS.get(analysis.upper(), _ANALYSIS["SPECULATIVE"]) analysis_block = _ANALYSIS.get(analysis.upper(), _ANALYSIS["SPECULATIVE"])
return "\n\n".join([_COMPLIANCE_RIDER, _CORE, tone_block, analysis_block]) return "\n\n".join([_CORE, tone_block, analysis_block])
# Backwards-compat: a default-composed SYSTEM_PROMPT for tests / callers that # Backwards-compat: a default-composed SYSTEM_PROMPT for tests / callers that
@ -335,7 +281,7 @@ SYSTEM_PROMPT = build_system_prompt("INTERMEDIATE", "SPECULATIVE")
_CHAT_OVERRIDES = """# Chat mode (overrides the log-structure rules above) _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 You are NOT writing a daily log right now. The user is asking a specific
question via the chat sidebar. question via the chat sidebar.
- Forget the date header, TL;DR, and sectional structure. Just answer. - Forget the date header, TL;DR, sectional structure, and watch list. Just answer.
- Typical response: 200-400 words. Longer only if the question genuinely - Typical response: 200-400 words. Longer only if the question genuinely
warrants it. warrants it.
- Cite specific numbers and named headlines from the reference materials - Cite specific numbers and named headlines from the reference materials
@ -343,15 +289,7 @@ question via the chat sidebar.
- If a question is outside the provided context (e.g. asking about a stock or - 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 event not in the data), say so plainly rather than speculating from prior
knowledge. knowledge.
- No buy/sell recommendations and no instrument-specific advice, even if the - No buy/sell recommendations. If asked, redirect to thesis and scenarios.
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.""" - Keep the same audience and analysis discipline established above."""
@ -367,9 +305,7 @@ def build_summary_system_prompt(tone: str, analysis: str) -> str:
field is caught by the reviewer agent (services/output_review).""" field is caught by the reviewer agent (services/output_review)."""
tone_block = _TONE[_resolve_tone(tone)] tone_block = _TONE[_resolve_tone(tone)]
analysis_block = _ANALYSIS.get(analysis.upper(), _ANALYSIS["SPECULATIVE"]) analysis_block = _ANALYSIS.get(analysis.upper(), _ANALYSIS["SPECULATIVE"])
return f"""{_COMPLIANCE_RIDER} return f"""You write a TINY interpretation (≤60 words, 2-3 sentences) \
You write a TINY interpretation (60 words, 2-3 sentences) \
of ONE indicator group for a strategic markets dashboard. of ONE indicator group for a strategic markets dashboard.
# Output format (strict) # Output format (strict)
@ -405,10 +341,8 @@ finished read, not the thinking.
- Cite at most 2-3 specific numbers and ONLY when they anchor an \ - Cite at most 2-3 specific numbers and ONLY when they anchor an \
interpretation. Don't list moves; explain them. interpretation. Don't list moves; explain them.
- Multi-week / multi-month horizon. 1-day moves under 2% are noise skip. - Multi-week / multi-month horizon. 1-day moves under 2% are noise skip.
- No buy/sell language. No price targets, no "close above/below", no \ - No buy/sell language. No predictions. No watch list. No TL;DR. No date \
floors/ceilings/support/resistance, no triggers. No forward price calls on \ header. No "system temperature" line that belongs to the full daily log.
named instruments. No watch list. No TL;DR. No date header. No "system \
temperature" line — that belongs to the full daily log.
{tone_block} {tone_block}
@ -436,9 +370,7 @@ def build_aggregate_summary_system_prompt(tone: str, analysis: str) -> str:
{"read": "..."} only; the field is the publishable text verbatim.""" {"read": "..."} only; the field is the publishable text verbatim."""
tone_block = _TONE[_resolve_tone(tone)] tone_block = _TONE[_resolve_tone(tone)]
analysis_block = _ANALYSIS.get(analysis.upper(), _ANALYSIS["SPECULATIVE"]) analysis_block = _ANALYSIS.get(analysis.upper(), _ANALYSIS["SPECULATIVE"])
return f"""{_COMPLIANCE_RIDER} return f"""You write a single SHORT cross-asset INTERPRETATION (≤80 \
You write a single SHORT cross-asset INTERPRETATION (80 \
words, 2-4 sentences) for the dashboard header. The reader is glancing \ words, 2-4 sentences) for the dashboard header. The reader is glancing \
give them the meaning of the whole tape, not a recap. give them the meaning of the whole tape, not a recap.
@ -474,9 +406,7 @@ parenthetical asides that question your own numbers.
risk premium is in commodities but not vol". Cite no more than 3 specific \ risk premium is in commodities but not vol". Cite no more than 3 specific \
numbers, and only as anchors for the interpretation. numbers, and only as anchors for the interpretation.
- Multi-week / multi-month horizon. 1-day moves under 2% are noise. - Multi-week / multi-month horizon. 1-day moves under 2% are noise.
- No buy/sell language. No forward price calls on named instruments. \ - No buy/sell language. No predictions of specific levels.
No targets, floors, ceilings, support/resistance, "close above/below", or \
trigger framing of any kind.
{tone_block} {tone_block}
@ -507,13 +437,7 @@ def build_chat_system_prompt(
) -> str: ) -> str:
"""Composed system prompt for the /log chat sidebar. Carries the user's """Composed system prompt for the /log chat sidebar. Carries the user's
chosen tone + analysis style and inlines the latest log + market data + 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, ""] parts = [build_system_prompt(tone, analysis), "", _CHAT_OVERRIDES, ""]
if reference_line: if reference_line:
parts.append(f"# Doc reference snapshot\n{reference_line}\n") parts.append(f"# Doc reference snapshot\n{reference_line}\n")
@ -615,16 +539,11 @@ def build_daily_digest_prompt(
24h and looks forward to the upcoming session. Longer, less 24h and looks forward to the upcoming session. Longer, less
'live-blogging,' more contextual. Target ~600 words.""" 'live-blogging,' more contextual. Target ~600 words."""
system = ( system = (
f"{_COMPLIANCE_RIDER}\n\n"
"You write the daily editorial digest for Read the Markets. " "You write the daily editorial digest for Read the Markets. "
f"Audience tone: {tone.upper()}. {_digest_tone_clause(tone)} " f"Audience tone: {tone.upper()}. {_digest_tone_clause(tone)} "
"Cover: (1) what mattered yesterday, (2) what releases or events are " "Cover: (1) what mattered yesterday, (2) what to watch in today's "
"scheduled in today's EU and US sessions, (3) one cross-asset thread " "EU and US sessions, (3) one cross-asset thread connecting them. "
"connecting them. Frame (2) as scheduled events to be aware of, NOT " "No predictions of price level, no buy/sell language. Target ~600 "
"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>, " "words. Output HTML using only <p>, <h3>, <ul>, <li>, <strong>, "
"<em> — no <html>, <head>, or <body> wrapper, no inline styles." "<em> — no <html>, <head>, or <body> wrapper, no inline styles."
) )
@ -647,16 +566,12 @@ def build_weekly_digest_prompt(
Sent to ALL opt-in users (free and paid). Target ~900 words.""" Sent to ALL opt-in users (free and paid). Target ~900 words."""
system = ( system = (
f"{_COMPLIANCE_RIDER}\n\n"
"You write the Sunday weekly digest for Read the Markets. " "You write the Sunday weekly digest for Read the Markets. "
f"Audience tone: {tone.upper()}. {_digest_tone_clause(tone)} " f"Audience tone: {tone.upper()}. {_digest_tone_clause(tone)} "
"Cover: (1) the week behind — what moved and why, " "Cover: (1) the week behind — what moved and why, "
"(2) the week ahead — releases, earnings, central-bank meetings as " "(2) the week ahead — releases, earnings, central-bank meetings, "
"scheduled events, NOT as a list of price levels to watch, "
"(3) the cross-asset story to keep in mind. " "(3) the cross-asset story to keep in mind. "
"No predictions of price level, no buy/sell language, no targets, " "No predictions of price level, no buy/sell language. Target ~900 "
"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>, " "words. Output HTML using only <p>, <h3>, <ul>, <li>, <strong>, "
"<em> — no <html>, <head>, or <body> wrapper, no inline styles." "<em> — no <html>, <head>, or <body> wrapper, no inline styles."
) )

View file

@ -1,176 +0,0 @@
"""Locale loader for the public landing page (and, eventually, other
public surfaces). Loads YAML translation files from ``app/locales/``
at process startup into in-memory dicts; templates access them via the
``t`` context variable.
Adding a new language: drop ``<code>.yaml`` into app/locales/ and add
the code to ``ACTIVE_PUBLIC_LANGS`` here. No code changes elsewhere
should be required.
Format: arbitrary nested dict. Values that contain inline HTML are
rendered with the Jinja2 ``safe`` filter in the template keep
markup minimal (``<strong>``, ``<em>``, ``<a>``) and don't put user
input through here. The dicts are wrapped in a small dotted-access
helper so templates can write ``{{ t.hero.subhead }}`` instead of
``{{ t['hero']['subhead'] }}``.
"""
from __future__ import annotations
from pathlib import Path
from typing import Any
import yaml
from app.logging import get_logger
log = get_logger("locales")
LOCALES_DIR = Path(__file__).resolve().parent.parent / "locales"
# Public-surface languages. Mirrors ``services.i18n.ACTIVE_LANGUAGES``
# but is kept separate because the public surface may roll a new
# language out independently of the in-app translations.
ACTIVE_PUBLIC_LANGS = ("en", "it")
DEFAULT_LANG = "en"
class _Dotted:
"""Read-only dotted-access view over a YAML-loaded dict tree.
Templates write ``{{ t.hero.tagline }}`` instead of
``{{ t['hero']['tagline'] }}``. The wrapper deliberately does NOT
subclass ``dict`` dict's built-in methods (``items``, ``keys``,
``values``, ``copy``, ``update``, ``pop`` ) would shadow YAML
keys with the same name. With this wrapper, a YAML key called
``items`` (which the landing copy actually has: ``not_strip.items``)
resolves through ``__getattr__`` like any other key.
Mutability is intentionally not supported: locale data is loaded
once at startup and read-only afterwards.
"""
__slots__ = ("_data",)
def __init__(self, data: dict):
self._data = data
def __getattr__(self, key: str) -> Any:
if key.startswith("_"):
raise AttributeError(key)
if key in self._data:
return _wrap(self._data[key])
raise AttributeError(key)
def __getitem__(self, key: str) -> Any:
return _wrap(self._data[key])
def __contains__(self, key: object) -> bool:
return key in self._data
def __iter__(self):
return iter(self._data)
def __len__(self) -> int:
return len(self._data)
def __bool__(self) -> bool:
return bool(self._data)
def __eq__(self, other: object) -> bool:
if isinstance(other, _Dotted):
return self._data == other._data
return self._data == other
def __repr__(self) -> str:
return f"_Dotted({self._data!r})"
def _wrap(value: Any) -> Any:
if isinstance(value, dict):
return _Dotted(value)
if isinstance(value, list):
return [_wrap(v) for v in value]
return value
_LOADED: dict[str, _Dotted] = {}
def _load_one(lang: str) -> _Dotted:
path = LOCALES_DIR / f"{lang}.yaml"
if not path.exists():
log.warning("locale.missing_file", lang=lang, path=str(path))
return _Dotted({})
raw = yaml.safe_load(path.read_text(encoding="utf-8")) or {}
return _Dotted(raw)
def load_locales() -> None:
"""Populate the module-level cache. Called once at app startup;
subsequent calls re-read from disk (useful for tests)."""
_LOADED.clear()
for lang in ACTIVE_PUBLIC_LANGS:
_LOADED[lang] = _load_one(lang)
log.info("locales.loaded", languages=list(_LOADED.keys()))
def get_locale(lang: str) -> _Dotted:
"""Return the loaded translations for ``lang``, falling back to
``DEFAULT_LANG`` for an unknown code. Lazy-loads on first call
so importers don't have to remember to call ``load_locales()``
explicitly."""
if not _LOADED:
load_locales()
if lang in _LOADED:
return _LOADED[lang]
return _LOADED.get(DEFAULT_LANG, _Dotted({}))
# ----- request-time language detection ---------------------------------------
# Country codes whose primary language is Italian. cf-ipcountry uses
# ISO-3166 alpha-2.
_GEO_TO_LANG = {
"IT": "it", "SM": "it", "VA": "it",
# Italian-speaking Swiss canton: we can't detect canton from country
# so a Swiss visitor defaults to en here. The Accept-Language path
# above catches the actual Italian-speakers in CH.
}
def detect_public_lang(
cookie_lang: str | None,
accept_language: str | None,
cf_country: str | None,
user_lang: str | None,
) -> str:
"""Resolve the language for a public-page request.
Priority (highest first):
1. user_lang a logged-in user's stored preference. Consistent
with their dashboard, never overridden by detection.
2. cookie_lang sticky from a previous explicit toggle.
3. accept_language browser locale. First language tag only,
stripped to its base subtag ("en-US" -> "en").
4. cf_country Cloudflare's IP-derived country code, mapped to
a primary language via ``_GEO_TO_LANG``.
5. ``DEFAULT_LANG`` ("en").
Returns a value guaranteed to be in ``ACTIVE_PUBLIC_LANGS``.
"""
if user_lang and user_lang in ACTIVE_PUBLIC_LANGS:
return user_lang
if cookie_lang and cookie_lang in ACTIVE_PUBLIC_LANGS:
return cookie_lang
if accept_language:
first = accept_language.split(",", 1)[0].split(";", 1)[0]
base = first.split("-", 1)[0].strip().lower()
if base in ACTIVE_PUBLIC_LANGS:
return base
if cf_country:
cc = cf_country.strip().upper()
if cc in _GEO_TO_LANG:
mapped = _GEO_TO_LANG[cc]
if mapped in ACTIVE_PUBLIC_LANGS:
return mapped
return DEFAULT_LANG

View file

@ -1,177 +0,0 @@
"""Thumb up/down votes on strategic-log rows.
The model is one row per (log_id, user_id) see
``app/models.py::StrategicLogFeedback``. The UI shows aggregate counts
only; user attribution is server-side state, not surface state. A user
can flip up down (or vice versa) by re-submitting; "clear" deletes
the row.
For email-digest feedback links we sign a short payload with the same
itsdangerous serialiser pattern used elsewhere (``app/auth.py``). The
link is single-purpose: it identifies the (user, log, intended vote)
combination, lets the recipient click without being logged in, and
expires after EMAIL_FEEDBACK_TTL_SECONDS.
"""
from __future__ import annotations
from dataclasses import dataclass
from itsdangerous import BadSignature, SignatureExpired, URLSafeTimedSerializer
from sqlalchemy import delete, func, select
from sqlalchemy.ext.asyncio import AsyncSession
from app.config import get_settings
from app.db import utcnow
from app.models import StrategicLogFeedback
# Email-link tokens are valid for 30 days so a user can click the thumb
# in last week's Sunday digest. Long enough for slow readers; short
# enough that the signing-secret rotation can shake stale tokens off.
EMAIL_FEEDBACK_TTL_SECONDS = 30 * 24 * 60 * 60
# Discriminated enum of acceptable votes. "clear" is a sentinel that
# means "remove your existing vote, if any".
VALID_VOTES = ("up", "down", "clear")
class FeedbackError(ValueError):
"""Raised on bad input. Message is safe to surface to the user."""
@dataclass(frozen=True)
class FeedbackCounts:
up: int
down: int
user_vote: str | None # 'up' | 'down' | None
# ---------------------------------------------------------------------------
# Service
# ---------------------------------------------------------------------------
def _validate_vote(vote: str) -> str:
v = (vote or "").strip().lower()
if v not in VALID_VOTES:
raise FeedbackError(
f"vote must be one of {VALID_VOTES!r}; got {vote!r}"
)
return v
async def set_vote(
session: AsyncSession,
*,
log_id: int,
user_id: int,
vote: str,
) -> FeedbackCounts:
"""Insert / update / clear the user's vote on ``log_id``. Returns the
resulting aggregate counts plus the user's new vote state.
"clear" removes the row entirely; "up"/"down" upserts. The unique
index on (log_id, user_id) guarantees there's never more than one
row per pair."""
v = _validate_vote(vote)
if v == "clear":
await session.execute(
delete(StrategicLogFeedback)
.where(StrategicLogFeedback.log_id == log_id)
.where(StrategicLogFeedback.user_id == user_id)
)
await session.commit()
return await get_counts(session, log_id=log_id, user_id=user_id)
# Upsert by hand — SQLAlchemy's portable dialect doesn't expose
# ON DUPLICATE KEY UPDATE across MySQL+SQLite reliably for our test
# path, so SELECT then INSERT-or-UPDATE is the simplest correct shape.
existing = (await session.execute(
select(StrategicLogFeedback)
.where(StrategicLogFeedback.log_id == log_id)
.where(StrategicLogFeedback.user_id == user_id)
)).scalar_one_or_none()
if existing is None:
session.add(StrategicLogFeedback(
log_id=log_id, user_id=user_id, vote=v,
created_at=utcnow(), updated_at=utcnow(),
))
else:
existing.vote = v
existing.updated_at = utcnow()
await session.commit()
return await get_counts(session, log_id=log_id, user_id=user_id)
async def get_counts(
session: AsyncSession,
*,
log_id: int,
user_id: int | None = None,
) -> FeedbackCounts:
"""Aggregate up/down counts for one log, plus the requesting user's
own vote (if ``user_id`` is supplied). One indexed query for the
counts; a second indexed lookup for the personal vote."""
rows = (await session.execute(
select(
StrategicLogFeedback.vote,
func.count(StrategicLogFeedback.id),
)
.where(StrategicLogFeedback.log_id == log_id)
.group_by(StrategicLogFeedback.vote)
)).all()
counts = {vote: int(n) for vote, n in rows}
user_vote: str | None = None
if user_id is not None:
user_vote = (await session.execute(
select(StrategicLogFeedback.vote)
.where(StrategicLogFeedback.log_id == log_id)
.where(StrategicLogFeedback.user_id == user_id)
)).scalar_one_or_none()
return FeedbackCounts(
up=counts.get("up", 0),
down=counts.get("down", 0),
user_vote=user_vote,
)
# ---------------------------------------------------------------------------
# Email-link token helpers (parallel to app.auth.sign_pending)
# ---------------------------------------------------------------------------
def _feedback_serializer() -> URLSafeTimedSerializer:
s = get_settings()
secret = s.CASSANDRA_SESSION_SECRET or s.CASSANDRA_TOKEN or "dev-insecure-secret"
return URLSafeTimedSerializer(secret, salt="cassandra-log-feedback-v1")
def sign_feedback_token(user_id: int, log_id: int, vote: str) -> str:
"""Signed token for an email-digest thumb link. Encodes the intended
(user, log, vote) tuple. The recipient clicks the link without being
logged in; the receiving endpoint verifies the signature, applies the
vote, and shows a thank-you page."""
v = _validate_vote(vote)
if v == "clear":
raise FeedbackError("clear is not a valid email-link vote")
return _feedback_serializer().dumps({
"uid": int(user_id), "lid": int(log_id), "v": v,
})
def verify_feedback_token(token: str) -> dict | None:
"""Returns {"user_id": int, "log_id": int, "vote": "up"|"down"} on
valid + un-expired tokens, or None on bad signature / expired / bad
payload. The TTL is ``EMAIL_FEEDBACK_TTL_SECONDS``."""
try:
data = _feedback_serializer().loads(
token, max_age=EMAIL_FEEDBACK_TTL_SECONDS,
)
return {
"user_id": int(data["uid"]),
"log_id": int(data["lid"]),
"vote": str(data["v"]),
}
except (BadSignature, SignatureExpired, KeyError, TypeError, ValueError):
return None

View file

@ -1,20 +1,17 @@
"""Two-layer reviewer for AI-generated reads. """Second-pass reviewer agent for AI-generated reads.
Architecture (both layers fail-closed): The per-group and aggregate indicator summaries are generated in JSON
1. Deterministic lexicon/regex pre-check (cheap, zero-variance) see mode and the publishable text comes out of a single "read" field, but a
``app.services.output_review_lexicon``. Hard-catches the obvious misbehaving model can still slip chain-of-thought INSIDE the field
advice / forecast / level-trigger patterns. ("Let's see…", "X? Actually Y?", multi-question parentheticals). This
2. LLM nuance check (Haiku via OpenRouter) catches the subtle cases module makes a small second LLM call that judges the candidate read as
the lexicon misses (chain-of-thought leakage, forward-state-as- clean / unclean. Cost is ~$0.0001 per check; latency ~1-2 s in the
description, MAR forward price opinions on named instruments). hourly job. No user-facing latency.
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 The reviewer is deliberately a tiny, JSON-shaped classifier same
JSON-mode mechanism as the generator, so the verdict can't be lost in JSON-mode mechanism as the generator, so the verdict can't be lost in
prose. Cost is ~$0.0001 per LLM check; latency ~1-2 s in the hourly job. prose. If parsing fails or the call errors, the row is rejected
(fail-safe: the previously cached good summary stays visible).
""" """
from __future__ import annotations from __future__ import annotations
@ -22,13 +19,10 @@ import json
from dataclasses import dataclass from dataclasses import dataclass
import httpx import httpx
from sqlalchemy.ext.asyncio import AsyncSession
from app.config import get_settings from app.config import get_settings
from app.logging import get_logger from app.logging import get_logger
from app.models import ReviewerVerdict
from app.services.openrouter import call_llm from app.services.openrouter import call_llm
from app.services.output_review_lexicon import check as lexicon_check
log = get_logger("output_review") log = get_logger("output_review")
@ -99,53 +93,17 @@ Mark UNCLEAN if the text contains ANY of:
"valuations are stretched", "real yields are restrictive", "rates "valuations are stretched", "real yields are restrictive", "rates
and credit disagree". The test: does the text describe a STATE, or and credit disagree". The test: does the text describe a STATE, or
does it suggest an ACTION? States are fine; actions are not. 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 $9093", "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. - Anything else other than the finished, publishable commentary.
Also assign a SCORE 0-10 to the candidate:
- 10 = exemplary editorial: sharp, well-grounded, no perimeter risk, clean prose.
- 7-9 = publishable as-is, varying degrees of polish.
- 4-6 = borderline: scratchpad leakage, mild perimeter drift, or weak structure,
but not yet outright unfit. (Anything 4 should usually be clean=false.)
- 1-3 = unfit: clear chain-of-thought, partial / truncated, or financial-advice drift.
- 0 = unfit by hard rule (deterministic catch territory).
Clean=true implies a score of ~7+; clean=false implies ~4 or lower. Use the
score to communicate confidence within the verdict.
Return ONLY a JSON object with this exact shape: Return ONLY a JSON object with this exact shape:
{"clean": true | false, "reason": "<≤20 words, plain text>", "score": 0-10} {"clean": true | false, "reason": "<≤20 words, plain text>"}
No preamble, no markdown fences, no other fields. No preamble, no markdown fences, no other fields.
""" """
# Surface-specific rider appended to the system prompt when the caller # Surface-specific rider appended to the system prompt when the caller
# passes a known `surface` to review_read(). The portfolio rider — the only # passes a known `surface` to review_read(). Lets us relax or tighten
# entry here — only fires when PORTFOLIO_AI_ENABLED is on AND the caller # rules per editorial context without rewriting the whole prompt.
# 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 = { _SURFACE_RIDERS = {
"portfolio": """\ "portfolio": """\
@ -157,6 +115,8 @@ as financial advice. The following ARE fine:
exposure", "currency risk is unhedged", "FX exposure", "elevated exposure", "currency risk is unhedged", "FX exposure", "elevated
risk", "stretched valuations", "concentration is manageable", "low risk", "stretched valuations", "concentration is manageable", "low
diversification". 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: - Impersonal observation about a position's behaviour or sensitivity:
"the position warrants monitoring", "carries vulnerability to a "the position warrants monitoring", "carries vulnerability to a
policy shock", "is sensitive to rate moves". policy shock", "is sensitive to rate moves".
@ -169,9 +129,6 @@ aimed at the reader:
- Specific allocation prescriptions: "go 20% bonds", "overweight - Specific allocation prescriptions: "go 20% bonds", "overweight
tech", "underweight defensives". tech", "underweight defensives".
- Price-target predictions: "will reach $X by year-end". - 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.
""", """,
} }
@ -181,109 +138,30 @@ class Verdict:
clean: bool clean: bool
reason: str reason: str
cost_usd: float | None # cost of the review call itself, for the ledger cost_usd: float | None # cost of the review call itself, for the ledger
layer: str = "llm" # "deterministic" | "llm" | "error"
# Integer 0-10. None for error rows; 0 for deterministic-layer hits
# (rejected by hard rule, no nuance to score); 0-10 from the model
# on LLM-layer verdicts. Stored alongside the content row for
# future analysis — see StrategicLog.reviewer_score and
# IndicatorSummary.reviewer_score.
score: int | None = None
# 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,
score=verdict.score,
)
session.add(row)
await session.flush()
except Exception as e:
log.warning("review.audit_write_failed", error=str(e)[:200])
async def review_read( async def review_read(
client: httpx.AsyncClient, client: httpx.AsyncClient,
candidate: str, candidate: str,
surface: str | None = None, surface: str | None = None,
*,
session: AsyncSession | None = None,
) -> Verdict: ) -> Verdict:
"""Run the two-layer reviewer on `candidate`. """Ask the LLM whether `candidate` is a publishable read.
Layer 1: deterministic lexicon/regex pre-check. On hit, returns Returns Verdict(clean, reason, cost). Any error provider failure,
``clean=False, reason="lexicon:<rule>"``, layer="deterministic" no JSON parse failure, missing field, wrong type yields a CONSERVATIVE
LLM call is made. verdict (clean=False) so the caller drops the candidate. The
previously cached good summary stays visible on the dashboard.
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(): if not candidate or not candidate.strip():
verdict = Verdict(clean=False, reason="empty candidate", cost_usd=0.0, return Verdict(clean=False, reason="empty candidate", cost_usd=0.0)
layer="deterministic", score=0)
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",
score=0,
)
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 system_prompt = _SYSTEM_PROMPT
if ( if surface and surface in _SURFACE_RIDERS:
surface
and surface in _SURFACE_RIDERS
and (surface != "portfolio" or settings.PORTFOLIO_AI_ENABLED)
):
system_prompt = system_prompt + _SURFACE_RIDERS[surface] system_prompt = system_prompt + _SURFACE_RIDERS[surface]
messages = [ messages = [
@ -293,6 +171,7 @@ async def review_read(
# contain prompt-like prose. # contain prompt-like prose.
{"role": "user", "content": f"Candidate read:\n```\n{candidate}\n```"}, {"role": "user", "content": f"Candidate read:\n```\n{candidate}\n```"},
] ]
settings = get_settings()
reviewer_model = getattr(settings, "REVIEWER_MODEL", None) or DEFAULT_REVIEWER_MODEL reviewer_model = getattr(settings, "REVIEWER_MODEL", None) or DEFAULT_REVIEWER_MODEL
try: try:
result = await call_llm( result = await call_llm(
@ -311,11 +190,8 @@ async def review_read(
) )
except Exception as e: except Exception as e:
log.warning("review.call_failed", error=str(e)[:200]) log.warning("review.call_failed", error=str(e)[:200])
verdict = Verdict(clean=False, reason=f"reviewer error: {str(e)[:80]}", return Verdict(clean=False, reason=f"reviewer error: {str(e)[:80]}",
cost_usd=None, layer="error", score=None) cost_usd=None)
await _record_verdict(session, surface=surface, candidate=candidate,
verdict=verdict, model=reviewer_model)
return verdict
# Haiku (and several other models) occasionally wrap their JSON # Haiku (and several other models) occasionally wrap their JSON
# output in a markdown code fence even with response_format set — # output in a markdown code fence even with response_format set —
@ -335,114 +211,12 @@ async def review_read(
parsed = json.loads(raw) parsed = json.loads(raw)
except json.JSONDecodeError: except json.JSONDecodeError:
log.warning("review.parse_failed", preview=result.content[:200]) log.warning("review.parse_failed", preview=result.content[:200])
verdict = Verdict(clean=False, reason="reviewer returned non-JSON", return Verdict(clean=False, reason="reviewer returned non-JSON",
cost_usd=result.cost_usd, layer="error", score=None) cost_usd=result.cost_usd)
await _record_verdict(session, surface=surface, candidate=candidate,
verdict=verdict, model=reviewer_model)
return verdict
clean = parsed.get("clean") clean = parsed.get("clean")
reason = parsed.get("reason") or "" reason = parsed.get("reason") or ""
if not isinstance(clean, bool): if not isinstance(clean, bool):
verdict = Verdict(clean=False, reason="reviewer omitted bool 'clean'", return Verdict(clean=False, reason="reviewer omitted bool 'clean'",
cost_usd=result.cost_usd, layer="error", score=None) cost_usd=result.cost_usd)
await _record_verdict(session, surface=surface, candidate=candidate, return Verdict(clean=clean, reason=str(reason)[:200], cost_usd=result.cost_usd)
verdict=verdict, model=reviewer_model)
return verdict
# Score is optional and bounded; the verdict is still valid without it.
raw_score = parsed.get("score")
score: int | None
if isinstance(raw_score, bool):
# bool is a subclass of int — exclude it explicitly to avoid
# silently treating True/False as 1/0.
score = None
elif isinstance(raw_score, (int, float)):
score = max(0, min(10, int(raw_score)))
else:
score = None
verdict = Verdict(clean=clean, reason=str(reason)[:200],
cost_usd=result.cost_usd, layer="llm", score=score)
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", score=None),
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",
score=None),
attempts=max_attempts,
)

View file

@ -1,161 +0,0 @@
"""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

View file

@ -328,10 +328,6 @@ async def analyse(
object is a function-local when this function returns, the pie is object is a function-local when this function returns, the pie is
garbage-collected. No DB writes mention positions.""" garbage-collected. No DB writes mention positions."""
s = get_settings() 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) system, user = build_prompt(req)
review_cost = 0.0 review_cost = 0.0
@ -376,8 +372,7 @@ async def analyse(
# purpose of this surface, while keeping explicit # purpose of this surface, while keeping explicit
# buy/sell/allocation directives forbidden. # buy/sell/allocation directives forbidden.
if llm is not None: if llm is not None:
verdict = await review_read(client, llm.content, verdict = await review_read(client, llm.content, surface="portfolio")
surface="portfolio", session=session)
review_cost = verdict.cost_usd or 0.0 review_cost = verdict.cost_usd or 0.0
if not verdict.clean: if not verdict.clean:
status = "leaked" status = "leaked"

View file

@ -33,7 +33,6 @@ from sqlalchemy import delete, insert, select, update
from sqlalchemy.dialects.mysql import insert as mysql_insert from sqlalchemy.dialects.mysql import insert as mysql_insert
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
from app.config import get_settings
from app.db import utcnow from app.db import utcnow
from app.logging import get_logger from app.logging import get_logger
from app.models import TickerUniverse from app.models import TickerUniverse
@ -82,8 +81,6 @@ async def buffer_tickers(tickers: Iterable[str]) -> int:
Already-known tickers are still buffered the flush job will collapse Already-known tickers are still buffered the flush job will collapse
them via INSERT IGNORE. Cheap and avoids a synchronous DB read here.""" 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()] items = [_normalise(t) for t in tickers if t and t.strip()]
if not items: if not items:
return 0 return 0
@ -101,8 +98,6 @@ async def refresh_references(
"""Bump last_referenced_at for tickers already in the universe. """Bump last_referenced_at for tickers already in the universe.
Returns rows updated. Tickers not yet in the universe are silently Returns rows updated. Tickers not yet in the universe are silently
ignored they'll land via the buffered flush path.""" 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()}) items = list({_normalise(t) for t in tickers if t and t.strip()})
if not items: if not items:
return 0 return 0
@ -122,8 +117,6 @@ async def flush_buffer(session: AsyncSession) -> dict[str, int]:
Idempotent: re-running on the same bucket is a no-op because the bucket Idempotent: re-running on the same bucket is a no-op because the bucket
is deleted on success.""" is deleted on success."""
if not get_settings().TICKER_UNIVERSE_AGGREGATE_ENABLED:
return {"buffered": 0, "inserted": 0}
r = get_redis() r = get_redis()
key = _previous_bucket_key() key = _previous_bucket_key()
tickers = await r.smembers(key) tickers = await r.smembers(key)
@ -184,8 +177,6 @@ async def upsert_tickers(session: AsyncSession, tickers: Iterable[str]) -> int:
mitigation has no statistical effect anyway, so bypassing it is free. 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 When we hit 10 users this path will be deprecated in favour of the
buffered path, per the Phase G plan.""" 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()}) items = list({_normalise(t) for t in tickers if t and t.strip()})
if not items: if not items:
return 0 return 0

View file

@ -46,30 +46,6 @@
} }
.public-header__cta:hover { background: var(--accent); color: var(--bg) !important; } .public-header__cta:hover { background: var(--accent); color: var(--bg) !important; }
/* Tiny EN | IT link group sitting in the public header next to the
nav links. Rendered only on pages that opt in via lang_switch=true.
Visually low-key the language pair is informational, not a
primary CTA. */
.public-header__lang-switch {
display: inline-flex;
align-items: center;
gap: 4px;
font-size: 11px;
letter-spacing: 0.08em;
text-transform: uppercase;
}
.public-header__lang-switch a.public-header__lang {
color: var(--muted);
padding: 2px 6px;
border: 1px solid transparent;
border-radius: 2px;
}
.public-header__lang-switch a.public-header__lang:hover { color: var(--accent); }
.public-header__lang-switch a.public-header__lang.active {
color: var(--text);
border-color: var(--border);
}
.public-main { .public-main {
flex: 1; flex: 1;
padding: 48px 0 64px; padding: 48px 0 64px;

View file

@ -19,16 +19,6 @@
const STORAGE_KEY = 'cassandra.pie'; const STORAGE_KEY = 'cassandra.pie';
const UNIVERSE_REFRESH_MS = 60_000; const UNIVERSE_REFRESH_MS = 60_000;
// Compliance feature flags — server emits these as data attributes on
// #pf-mount (see app/templates/dashboard.html). When false the related
// UI is suppressed and the corresponding API call is skipped.
function flagEnabled(name) {
const m = document.getElementById('pf-mount');
return !!(m && m.dataset && m.dataset[name] === 'true');
}
function aiEnabled() { return flagEnabled('aiEnabled'); }
function syncEnabled() { return flagEnabled('syncEnabled'); }
// --- localStorage ------------------------------------------------------ // --- localStorage ------------------------------------------------------
function loadPie() { function loadPie() {
@ -389,9 +379,9 @@
// edit-mode only — CSS in portfolio.css hides it when the // edit-mode only — CSS in portfolio.css hides it when the
// portfolio panel isn't carrying the .pf-editing class. // portfolio panel isn't carrying the .pf-editing class.
'<div class="pf-actions">' + '<div class="pf-actions">' +
(aiEnabled() && !(pie.analysis && pie.analysis.content) (pie.analysis && pie.analysis.content
? '<button id="pf-analyze" type="button">Generate AI analysis</button>' ? ''
: '') + : '<button id="pf-analyze" type="button">Generate AI analysis</button>') +
'<button id="pf-forget" type="button" class="pf-secondary">Forget this pie</button>' + '<button id="pf-forget" type="button" class="pf-secondary">Forget this pie</button>' +
'</div>' + '</div>' +
'<div id="pf-analysis" class="pf-analysis" hidden></div>'; '<div id="pf-analysis" class="pf-analysis" hidden></div>';
@ -415,7 +405,7 @@
// regenerate callback closes over the current pie/enriched so a // regenerate callback closes over the current pie/enriched so a
// click rebuilds the analysis with the same context that drove // click rebuilds the analysis with the same context that drove
// the initial render. // the initial render.
if (aiEnabled() && pie.analysis && pie.analysis.content) { if (pie.analysis && pie.analysis.content) {
showAnalysis(pie.analysis, { open: true }, () => runAnalysis(pie, enriched)); showAnalysis(pie.analysis, { open: true }, () => runAnalysis(pie, enriched));
} }
} }
@ -537,11 +527,9 @@
// Before falling back to "no portfolio", check whether the account // Before falling back to "no portfolio", check whether the account
// has a synced blob this device could restore from. Status is // has a synced blob this device could restore from. Status is
// 402 for free-tier users — getStatus() returns paid:false there // 402 for free-tier users — getStatus() returns paid:false there
// and we fall through to the standard empty state. When // and we fall through to the standard empty state.
// PORTFOLIO_SYNC_ENABLED=false the sync route 404s, so skip the
// status check entirely and render the standard empty state.
let status = null; let status = null;
if (syncEnabled() && window.CassandraSync) { if (window.CassandraSync) {
try { status = await window.CassandraSync.getStatus(); } try { status = await window.CassandraSync.getStatus(); }
catch (e) { console.warn('sync status check failed', e); } catch (e) { console.warn('sync status check failed', e); }
} }

View file

@ -27,10 +27,6 @@
if (!dropZone) return; if (!dropZone) return;
var IS_PAID = dropZone.dataset.paid === 'true'; var IS_PAID = dropZone.dataset.paid === 'true';
// PORTFOLIO_SYNC_ENABLED gate — when off, the sync route 404s and the
// 'Import & sync to cloud' option must be hidden entirely (a button
// that errors on click is worse than no button).
var SYNC_ENABLED = dropZone.dataset.syncEnabled === 'true';
var currentPie = null; // most recently parsed pie, awaiting commit var currentPie = null; // most recently parsed pie, awaiting commit
@ -75,26 +71,21 @@
return '<div class="result__warn">' + esc(w) + '</div>'; return '<div class="result__warn">' + esc(w) + '</div>';
}).join(''); }).join('');
var syncBtn; var syncBtn = IS_PAID
if (!SYNC_ENABLED) { ? ('<div class="import-choice">' +
syncBtn = ''; '<button type="button" id="commit-sync">Import &amp; sync to cloud</button>' +
} else if (IS_PAID) { '<div class="settings-row__hint">' +
syncBtn = '<div class="import-choice">' + 'Also stores an <strong>encrypted</strong> copy on the server, ' +
'<button type="button" id="commit-sync">Import &amp; sync to cloud</button>' + 'restorable on any device with your PIN. Only you can decrypt ' +
'<div class="settings-row__hint">' + 'it &mdash; losing the PIN means losing the backup.' +
'Also stores an <strong>encrypted</strong> copy on the server, ' + '</div>' +
'restorable on any device with your PIN. Only you can decrypt ' + '</div>')
'it &mdash; losing the PIN means losing the backup.' + : ('<div class="import-choice">' +
'</div>' + '<button type="button" disabled>Import &amp; sync to cloud</button>' +
'</div>'; '<div class="settings-row__hint">' +
} else { 'Encrypted cloud backup is available on the paid tier.' +
syncBtn = '<div class="import-choice">' + '</div>' +
'<button type="button" disabled>Import &amp; sync to cloud</button>' + '</div>');
'<div class="settings-row__hint">' +
'Encrypted cloud backup is available on the paid tier.' +
'</div>' +
'</div>';
}
previewEl.innerHTML = previewEl.innerHTML =
'<div class="result result--ok" style="margin:0;">' + '<div class="result result--ok" style="margin:0;">' +

View file

@ -44,9 +44,13 @@
Architecturally, the product is deliberately privacy-shaped: Architecturally, the product is deliberately privacy-shaped:
</p> </p>
<ul> <ul>
<li>Your portfolio lives in your browser. CSVs you upload are parsed <li>Your portfolio lives in your browser. The server&rsquo;s view is
and held locally; the server never sees or stores your an aggregate set of tickers held across the whole user base,
holdings.</li> which on its own does not identify any individual user &mdash; see
the <a href="/privacy">Privacy notice</a> for the exact data
structures.</li>
<li>Cloud sync of your portfolio is opt-in and end-to-end encrypted
with a PIN only you know.</li>
<li>No third-party tracking, no analytics SDKs, no ad cookies.</li> <li>No third-party tracking, no analytics SDKs, no ad cookies.</li>
</ul> </ul>
<p> <p>

View file

@ -328,9 +328,7 @@
{% if cu.user %} {% if cu.user %}
<a href="/settings" role="menuitem" class="user-menu__item">Settings</a> <a href="/settings" role="menuitem" class="user-menu__item">Settings</a>
{% endif %} {% endif %}
{% if SUBSCRIPTIONS_ENABLED %}
<a href="/pricing" role="menuitem" class="user-menu__item">Pricing</a> <a href="/pricing" role="menuitem" class="user-menu__item">Pricing</a>
{% endif %}
<a href="/terms" role="menuitem" class="user-menu__item">Terms</a> <a href="/terms" role="menuitem" class="user-menu__item">Terms</a>
<a href="/privacy" role="menuitem" class="user-menu__item">Privacy</a> <a href="/privacy" role="menuitem" class="user-menu__item">Privacy</a>
<a href="/disclaimer" role="menuitem" class="user-menu__item">Disclaimer</a> <a href="/disclaimer" role="menuitem" class="user-menu__item">Disclaimer</a>

View file

@ -97,9 +97,7 @@
<kbd>&times;</kbd> next to an existing row removes it. <kbd>&times;</kbd> next to an existing row removes it.
</p> </p>
</div> </div>
<div id="pf-mount" <div id="pf-mount">
data-ai-enabled="{{ 'true' if PORTFOLIO_AI_ENABLED else 'false' }}"
data-sync-enabled="{{ 'true' if PORTFOLIO_SYNC_ENABLED else 'false' }}">
<div class="empty">loading…</div> <div class="empty">loading…</div>
</div> </div>
</div> </div>

View file

@ -37,20 +37,18 @@
<section class="public-section"> <section class="public-section">
<h2 class="public-section__head">About the AI output</h2> <h2 class="public-section__head">About the AI output</h2>
<p> <p>
The strategic log and indicator summaries are generated by large The strategic log, indicator summaries, and portfolio analysis are
language models from publicly available market data and news. They generated by large language models from publicly available market
can be wrong, incomplete, or out of date. Numbers can be misread. data and news. They can be wrong, incomplete, or out of date. Numbers
Models occasionally generate inaccurate or invented information can be misread. Models occasionally generate inaccurate or invented
(often called &ldquo;hallucinations&rdquo;). Treat them as a information (often called &ldquo;hallucinations&rdquo;). Treat them
<em>prompt to think</em>, not as facts to act on. as a <em>prompt to think</em>, not as facts to act on.
</p> </p>
<p> <p>
The portfolio feature is a browser-only composition viewer: it The portfolio analysis is an interpretation of holdings <em>you
parses a CSV you supply, computes neutral statistics (weights, supplied</em>. It does not consider your overall wealth, debts, tax
sector / currency / concentration breakdown), and shows them to position, or anything we don&rsquo;t see. It is not personalised
you. Your holdings stay in your browser; they are never sent to advice.
or stored on the server. There is no AI commentary on your
portfolio.
</p> </p>
</section> </section>
@ -81,22 +79,13 @@
EU/EEA member state, nor in any jurisdiction where its provision EU/EEA member state, nor in any jurisdiction where its provision
would require local licensing or registration. Where any output of would require local licensing or registration. Where any output of
the Service could be construed as an &ldquo;investment the Service could be construed as an &ldquo;investment
recommendation&rdquo; within the meaning of recommendation&rdquo; under Regulation (EU) 596/2014 (Market Abuse
<strong>Article&nbsp;3(1)(35) of Regulation (EU) 596/2014 (Market Regulation) or its UK equivalent, it is non-personalised, produced
Abuse Regulation)</strong>, with conduct duties under by a non-regulated source for educational purposes only, and the
<strong>Article&nbsp;20 MAR</strong> and operator (a) has no position in, or remuneration linked to, the
<strong>Commission Delegated Regulation (EU) 2016/958</strong> specific instruments mentioned in any individual piece of commentary,
(or the UK onshored equivalent), the operator&rsquo;s position is and (b) is not a &ldquo;relevant person&rdquo; within MAR Art.
that the Service publishes <em>non-personalised commentary on 3(1)(34).
public market data by a non-regulated source for educational
purposes</em>, and does not directly propose a particular
investment decision in any specific instrument. The operator has
no position in, and no remuneration linked to, the specific
instruments mentioned in any individual piece of commentary.
</p>
<p style="font-size:12px; color: var(--muted);">
{# TODO(legal): final wording on the MAR paragraph above is pending
lawyer sign-off. See docs/read-markets-compliance-changes.md. #}
</p> </p>
</section> </section>

View file

@ -1,30 +0,0 @@
{% extends "public_base.html" %}
{% block title %}{{ BRAND_NAME }} &middot; Feedback{% endblock %}
{% block main %}
<section class="public-section" style="max-width:520px; margin:60px auto; text-align:center;">
{% if ok %}
<h1 class="public-section__head" style="margin-bottom:14px;">
{% if vote == 'up' %}Thanks for the thumbs up.{% else %}Thanks for the thumbs down.{% endif %}
</h1>
<p style="color:var(--muted); font-size:14px; line-height:1.55;">
Your vote on this strategic log is recorded.
{% if counts %}
Current tally: <strong>{{ counts.up }}</strong> &#x1F44D; ·
<strong>{{ counts.down }}</strong> &#x1F44E;.
{% endif %}
</p>
<p style="margin-top:24px;">
<a href="/" class="btn-primary">Open the dashboard</a>
</p>
{% else %}
<h1 class="public-section__head" style="margin-bottom:14px;">Couldn&rsquo;t record that vote</h1>
<p style="color:var(--muted); font-size:14px; line-height:1.55;">
{{ message or "Something went wrong; please try again from the dashboard." }}
</p>
<p style="margin-top:24px;">
<a href="/" class="btn-primary">Open the dashboard</a>
</p>
{% endif %}
</section>
{% endblock %}

View file

@ -1,19 +1,26 @@
{% extends "public_base.html" %} {% extends "public_base.html" %}
{% block title %}{{ BRAND_NAME }} &middot; {{ t.hero.tagline }}{% endblock %} {% block title %}{{ BRAND_NAME }} &middot; {{ TAGLINE }}{% endblock %}
{% block main %} {% block main %}
<section class="hero"> <section class="hero">
<div class="hero__brand">{{ BRAND_NAME }}</div> <div class="hero__brand">{{ BRAND_NAME }}</div>
<h1 class="hero__headline">{{ t.hero.tagline }}</h1> <h1 class="hero__headline">{{ TAGLINE }}</h1>
<p class="hero__subhead">{{ t.hero.subhead | safe }}</p> <p class="hero__subhead">
Built for investors who want to <strong>act rationally</strong> and
tune out the high-frequency noise that comes from treating markets
like a casino. We aggregate cross-asset news and macro signals,
then write a plain-English read of what the underlying fundamentals
justify versus what the crowd is doing. Refreshed through the
trading day. A media service, not a financial one.
</p>
<div class="hero__ctas"> <div class="hero__ctas">
{% if cu and (cu.user or cu.is_admin) %} {% if cu and (cu.user or cu.is_admin) %}
<a class="btn-primary" href="/">{{ t.hero.cta_dashboard }}</a> <a class="btn-primary" href="/">Open dashboard</a>
{% if SUBSCRIPTIONS_ENABLED %}<a class="btn-secondary" href="/pricing">{{ t.hero.cta_pricing }}</a>{% endif %} <a class="btn-secondary" href="/pricing">See pricing</a>
{% else %} {% else %}
<a class="btn-primary" href="/login">{{ t.hero.cta_signup }}</a> <a class="btn-primary" href="/login">Sign up free</a>
{% if SUBSCRIPTIONS_ENABLED %}<a class="btn-secondary" href="/pricing">{{ t.hero.cta_pricing }}</a>{% endif %} <a class="btn-secondary" href="/pricing">See pricing</a>
{% endif %} {% endif %}
</div> </div>
</section> </section>
@ -21,96 +28,124 @@
<section class="shot-hero"> <section class="shot-hero">
<button class="shot shot--hero" <button class="shot shot--hero"
data-full="{{ url_for('static', path='/images/dashboard.png') }}?v={{ ASSET_VERSION }}" data-full="{{ url_for('static', path='/images/dashboard.png') }}?v={{ ASSET_VERSION }}"
data-alt="{{ t.shot_dashboard.alt }}" data-alt="Read the Markets dashboard"
data-caption="{{ t.shot_dashboard.caption }}"> data-caption="The dashboard. An aggregate cross-asset read at the top, hand-picked indicator groups underneath. Reading level toggle (Novice / Intermediate) flips every AI-generated panel between plain-English and terse-pro framing.">
<img src="{{ url_for('static', path='/images/dashboard.png') }}?v={{ ASSET_VERSION }}" <img src="{{ url_for('static', path='/images/dashboard.png') }}?v={{ ASSET_VERSION }}"
alt="{{ t.shot_dashboard.alt }}" loading="lazy"> alt="Dashboard preview" loading="lazy">
<span class="shot__zoom" aria-hidden="true">{{ t.shot_dashboard.zoom_hint }}</span> <span class="shot__zoom" aria-hidden="true">Click to enlarge</span>
</button> </button>
</section> </section>
<section class="feature-grid"> <section class="feature-grid">
<div class="feature-card"> <div class="feature-card">
<div class="feature-card__tag">{{ t.features.news.tag }}</div> <div class="feature-card__tag">News, aggregated</div>
<h3 class="feature-card__title">{{ t.features.news.title }}</h3> <h3 class="feature-card__title">Headlines from across the macro universe</h3>
<p class="feature-card__body">{{ t.features.news.body | safe }}</p> <p class="feature-card__body">
RSS and per-ticker feeds covering equities, rates, credit, FX,
commodities, and geopolitics. Every headline is auto-tagged by
theme so the noise stays as noise and the fundamentals-relevant
stuff is easy to find. Ingestion follows the trading calendar &mdash;
off-hours stay quiet.
</p>
<button class="shot feature-card__shot" <button class="shot feature-card__shot"
data-full="{{ url_for('static', path='/images/news-feed.png') }}?v={{ ASSET_VERSION }}" data-full="{{ url_for('static', path='/images/news-feed.png') }}?v={{ ASSET_VERSION }}"
data-alt="{{ t.features.news.shot_alt }}" data-alt="News feed with auto-tagged headlines"
data-caption="{{ t.features.news.shot_caption }}"> data-caption="The news feed. Each headline carries one or more theme tags (rates, AI, energy, geopolitics, …) so you can keep the threads you care about and mute the ones you don't. Click a tag to include; shift-click to exclude.">
<img src="{{ url_for('static', path='/images/news-feed.png') }}?v={{ ASSET_VERSION }}" <img src="{{ url_for('static', path='/images/news-feed.png') }}?v={{ ASSET_VERSION }}"
alt="{{ t.features.news.shot_alt }}" loading="lazy"> alt="News feed thumbnail" loading="lazy">
</button> </button>
</div> </div>
<div class="feature-card"> <div class="feature-card">
<div class="feature-card__tag">{{ t.features.indicators.tag }}</div> <div class="feature-card__tag">Macro signals</div>
<h3 class="feature-card__title">{{ t.features.indicators.title }}</h3> <h3 class="feature-card__title">A curated cross-asset tape</h3>
<p class="feature-card__body">{{ t.features.indicators.body | safe }}</p> <p class="feature-card__body">
A hand-picked set of indicators across every asset class, refreshed
hourly during market hours. Each group gets a short read that
explains what the move <em>means</em>, not what it was. Anchored
in earnings, policy, valuation &mdash; not chart patterns.
</p>
<button class="shot feature-card__shot" <button class="shot feature-card__shot"
data-full="{{ url_for('static', path='/images/indicators-read.png') }}?v={{ ASSET_VERSION }}" data-full="{{ url_for('static', path='/images/indicators-read.png') }}?v={{ ASSET_VERSION }}"
data-alt="{{ t.features.indicators.shot_alt }}" data-alt="Indicators panel with AI commentary"
data-caption="{{ t.features.indicators.shot_caption }}"> data-caption="The indicators panel. Tabs across asset classes (equity, rates, commodities, FX, bonds, …); each tab carries a one-paragraph 'read' written by the model on top of the live prices. The numbers anchor the prose so the commentary is checkable, not floating.">
<img src="{{ url_for('static', path='/images/indicators-read.png') }}?v={{ ASSET_VERSION }}" <img src="{{ url_for('static', path='/images/indicators-read.png') }}?v={{ ASSET_VERSION }}"
alt="{{ t.features.indicators.shot_alt }}" loading="lazy"> alt="Indicators panel thumbnail" loading="lazy">
</button> </button>
</div> </div>
<div class="feature-card"> <div class="feature-card">
<div class="feature-card__tag">{{ t.features.strategic.tag }}</div> <div class="feature-card__tag">The strategic read</div>
<h3 class="feature-card__title">{{ t.features.strategic.title }}</h3> <h3 class="feature-card__title">Rational vs irrational, every paragraph</h3>
<p class="feature-card__body">{{ t.features.strategic.body | safe }}</p> <p class="feature-card__body">
We tie the day&rsquo;s headlines and the cross-asset signals into
a single short interpretation. Each paragraph separates
<strong>rational drivers</strong> (earnings, policy, valuation)
from <strong>irrational ones</strong> (positioning, narrative,
flows) and names the gap. Two reading levels: novice and
intermediate. This is editorial commentary on public data &mdash;
not a forecast and not advice on any investment decision.
</p>
<button class="shot feature-card__shot" <button class="shot feature-card__shot"
data-full="{{ url_for('static', path='/images/strategic-log.png') }}?v={{ ASSET_VERSION }}" data-full="{{ url_for('static', path='/images/strategic-log.png') }}?v={{ ASSET_VERSION }}"
data-alt="{{ t.features.strategic.shot_alt }}" data-alt="Strategic log — the editorial AI read"
data-caption="{{ t.features.strategic.shot_caption }}"> data-caption="The strategic log. The model writes a fresh interpretation through the trading day, taking the previous draft as context so it updates rather than starts over. Sections are typed: date header, TL;DR, what moved, what to watch, system temperature. Paid users get a refresh every hour; free users get one every six.">
<img src="{{ url_for('static', path='/images/strategic-log.png') }}?v={{ ASSET_VERSION }}" <img src="{{ url_for('static', path='/images/strategic-log.png') }}?v={{ ASSET_VERSION }}"
alt="{{ t.features.strategic.shot_alt }}" loading="lazy"> alt="Strategic log thumbnail" loading="lazy">
</button> </button>
</div> </div>
</section> </section>
<section class="public-section" style="text-align:center;">
<p style="font-size: 14px; color: var(--muted); max-width: 60ch; margin: 0 auto;">
{{ t.multilang_callout | safe }}
</p>
</section>
<section class="public-section shots-section"> <section class="public-section shots-section">
<h2 class="public-section__head">{{ t.more_views.head }}</h2> <h2 class="public-section__head">More views</h2>
<div class="shots-grid"> <div class="shots-grid">
<button class="shot" <button class="shot"
data-full="{{ url_for('static', path='/images/chat-with-log.png') }}?v={{ ASSET_VERSION }}" data-full="{{ url_for('static', path='/images/chat-with-log.png') }}?v={{ ASSET_VERSION }}"
data-alt="{{ t.more_views.chat.alt }}" data-alt="Ask follow-up questions against any past log"
data-caption="{{ t.more_views.chat.caption }}"> data-caption="Ask follow-up questions against any past log. The chat panel inherits the log's full context, so you can pull on a thread without re-pasting headlines or re-explaining the setup.">
<img src="{{ url_for('static', path='/images/chat-with-log.png') }}?v={{ ASSET_VERSION }}" <img src="{{ url_for('static', path='/images/chat-with-log.png') }}?v={{ ASSET_VERSION }}"
alt="{{ t.more_views.chat.alt }}" loading="lazy"> alt="Chat-with-log thumbnail" loading="lazy">
<div class="shot__caption"> <div class="shot__caption">
<strong>{{ t.more_views.chat.caption_strong }}</strong> <strong>Ask anything about a log</strong>
<span>{{ t.more_views.chat.caption_span }}</span> <span>Conversational follow-ups with the day's context loaded.</span>
</div> </div>
</button> </button>
</div> </div>
</section> </section>
<section class="public-section"> <section class="public-section">
<p style="font-size: 13.5px; color: var(--muted);">{{ t.portfolio_blurb | safe }}</p> <p style="font-size: 13.5px; color: var(--muted);">
Paid users can also drop a portfolio CSV from their broker
&mdash; Trading 212 natively, other formats auto-detected &mdash;
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.
</p>
</section> </section>
<section class="not-strip"> <section class="not-strip">
<strong>{{ t.not_strip.head }}</strong> <strong>What this isn&rsquo;t.</strong>
<ul> <ul>
{% for item in t.not_strip.items %}<li>{{ item }}</li>{% endfor %} <li>Not investment advice.</li>
<li>Not trading signals.</li>
<li>Not a day-trading tool.</li>
<li>No buy/sell calls, ever.</li>
<li>No chart-pattern predictions.</li>
<li>Not a regulated service.</li>
</ul> </ul>
</section> </section>
<section class="public-section"> <section class="public-section">
<p style="font-size: 13px; color: var(--muted);">{{ t.footer.legal | safe }}</p> <p style="font-size: 13px; color: var(--muted);">
By signing up you agree to our <a href="/terms">Terms</a> and
<a href="/privacy">Privacy notice</a>, and confirm you&rsquo;ve read
the <a href="/disclaimer">financial disclaimer</a>.
</p>
<div class="hero__ctas" style="margin-top:8px;"> <div class="hero__ctas" style="margin-top:8px;">
{% if cu and (cu.user or cu.is_admin) %} {% if cu and (cu.user or cu.is_admin) %}
<a class="btn-primary" href="/">{{ t.hero.cta_dashboard }}</a> <a class="btn-primary" href="/">Open dashboard</a>
{% else %} {% else %}
<a class="btn-primary" href="/login">{{ t.hero.cta_signup }}</a> <a class="btn-primary" href="/login">Sign up free</a>
{% endif %} {% endif %}
</div> </div>
</section> </section>

View file

@ -1,5 +1,5 @@
<!DOCTYPE html> <!DOCTYPE html>
<html lang="{{ lang or 'en' }}"> <html lang="en">
<head> <head>
<meta charset="UTF-8" /> <meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" /> <meta name="viewport" content="width=device-width, initial-scale=1.0" />
@ -17,110 +17,40 @@
<body> <body>
<div class="auth-shell"> <div class="auth-shell">
<div class="auth-card"> <div class="auth-card">
<div class="auth-card__brand" style="display:flex; justify-content:space-between; align-items:center;"> <div class="auth-card__brand">{{ BRAND_NAME }}</div>
<span>{{ BRAND_NAME }}</span> <div class="auth-card__hint">sign in with email</div>
<span class="auth-card__lang-switch" role="group"
aria-label="{{ (t and t.auth.ack.lang_switch_aria) or 'Language' }}"
style="font-size:11px; letter-spacing:0.04em;">
<a href="/login?lang=en{% if next_path and next_path != '/' %}&next={{ next_path }}{% endif %}{% if ref %}&ref={{ ref }}{% endif %}"
style="text-decoration:none; opacity:{{ '1' if lang == 'en' else '0.55' }};">EN</a>
<span style="opacity:0.4;">·</span>
<a href="/login?lang=it{% if next_path and next_path != '/' %}&next={{ next_path }}{% endif %}{% if ref %}&ref={{ ref }}{% endif %}"
style="text-decoration:none; opacity:{{ '1' if lang == 'it' else '0.55' }};">IT</a>
</span>
</div>
<div class="auth-card__hint">
{% if lang == 'it' %}accedi via email{% else %}sign in with email{% endif %}
</div>
{% if referrer_present %} {% if referrer_present %}
<div class="auth-info auth-info--invited"> <div class="auth-info auth-info--invited">
<strong>{% if lang == 'it' %}Sei stato invitato.{% else %}You've been invited.{% endif %}</strong> <strong>You've been invited.</strong>
{% if lang == 'it' %}
Quando ti abboni, tu e il tuo amico ricevete entrambi
<strong>50% di sconto per 3 mesi</strong>. Iscriviti qui sotto per attivarlo.
{% else %}
When you subscribe, you and your friend both get When you subscribe, you and your friend both get
<strong>50% off for 3 months</strong>. Sign up below to lock it in. <strong>50% off for 3 months</strong>. Sign up below to lock it in.
{% endif %}
</div> </div>
{% endif %} {% endif %}
<p class="auth-card__lede"> <p class="auth-card__lede">
{% if lang == 'it' %}
Inserisci la tua email e ti invieremo un codice di 6 cifre. Niente password.
I nuovi visitatori creano un account; chi torna fa l'accesso.
{% else %}
Enter your email and we'll send you a 6-digit code. No password. Enter your email and we'll send you a 6-digit code. No password.
First-time visitors get an account; returning visitors get a sign-in. First-time visitors get an account; returning visitors get a sign-in.
{% endif %}
</p> </p>
{% if error %}<div class="auth-error">{{ error }}</div>{% endif %} {% if error %}<div class="auth-error">{{ error }}</div>{% endif %}
<form method="post" action="/login" autocomplete="on"> <form method="post" action="/login" autocomplete="on">
<input type="hidden" name="next" value="{{ next_path or '/' }}"> <input type="hidden" name="next" value="{{ next_path or '/' }}">
<input type="hidden" name="lang" value="{{ lang or 'en' }}">
<input type="hidden" name="ack_version" value="{{ ack_version }}">
{% if ref %}<input type="hidden" name="ref" value="{{ ref }}">{% endif %} {% if ref %}<input type="hidden" name="ref" value="{{ ref }}">{% endif %}
<label>Email <label>Email
<input type="email" name="email" value="{{ email or '' }}" required autofocus> <input type="email" name="email" value="{{ email or '' }}" required autofocus>
</label> </label>
<button type="submit">Send code</button>
{# Affirmative un-pre-ticked acknowledgement. The substance is
locale-driven (auth.ack.*); the checkbox is required client-side
AND server-side. Replaces a passive "by continuing you agree…"
paragraph because an active acceptance carries more weight. #}
{% if t %}
<div class="auth-ack" style="margin-top:18px; padding:14px 16px;
border:1px solid var(--border, #ddd); border-radius:6px;
font-size:12.5px; line-height:1.55;">
<div style="font-weight:600; margin-bottom:8px;">{{ t.auth.ack.heading }}</div>
<ul style="margin:0 0 12px 18px; padding:0;">
{% for item in t.auth.ack.items %}
<li style="margin-bottom:6px;">{{ item }}</li>
{% endfor %}
</ul>
<label style="display:flex; gap:8px; align-items:flex-start; cursor:pointer;">
<input type="checkbox" name="acknowledged" id="ack-box"
value="on" required style="margin-top:3px;">
<span>{{ t.auth.ack.checkbox_label }}</span>
</label>
</div>
{% endif %}
<button type="submit" id="ack-submit" disabled
style="margin-top:14px;">
{% if lang == 'it' %}Invia codice{% else %}Send code{% endif %}
</button>
</form> </form>
<p class="auth-card__legal" style="margin-top:18px; font-size:11px; color: var(--muted); line-height:1.6;"> <p class="auth-card__legal" style="margin-top:18px; font-size:11px; color: var(--muted); line-height:1.6;">
{% if lang == 'it' %} By signing in you agree to our
Vedi i nostri <a href="/terms">Terms</a> and
<a href="/terms">Termini</a>, l&rsquo;<a href="/privacy">Informativa privacy</a> <a href="/privacy">Privacy notice</a>, and confirm you&rsquo;ve read
e il <a href="/disclaimer">disclaimer finanziario</a>. the <a href="/disclaimer">financial disclaimer</a>.
{% else %}
See our
<a href="/terms">Terms</a>,
<a href="/privacy">Privacy notice</a>, and
<a href="/disclaimer">financial disclaimer</a>.
{% endif %}
</p> </p>
</div> </div>
</div> </div>
<script>
// Tiny UX polish: disable the submit until the box is ticked. The
// server-side validation is what protects us — this is just so the
// user can't burn a click and immediately see an error.
(function () {
var box = document.getElementById('ack-box');
var btn = document.getElementById('ack-submit');
if (!box || !btn) return;
function sync() { btn.disabled = !box.checked; }
box.addEventListener('change', sync);
sync();
})();
</script>
</body> </body>
</html> </html>

View file

@ -9,78 +9,4 @@
title="Last generated {{ log.generated_at.strftime('%Y-%m-%d %H:%M UTC') }}"> title="Last generated {{ log.generated_at.strftime('%Y-%m-%d %H:%M UTC') }}">
{{ log.content_html | safe | glossary(tone) }} {{ log.content_html | safe | glossary(tone) }}
</div> </div>
{% if log.feedback %}
{# Anonymous-in-UI thumb up/down. Server stores (user, log, vote) for dedup
so a vote can be flipped; UI shows aggregate counts only.
POST clicks JSON-fetch and swap this partial back in place — no full
reload, the log content stays put. #}
<div class="log-feedback" id="log-feedback-{{ log.id }}"
data-log-id="{{ log.id }}"
style="display:flex; align-items:center; gap:14px; margin-top:18px;
padding-top:14px; border-top:1px solid var(--border);
font-size:13px; color:var(--muted);">
<span>Was this useful?</span>
<button type="button" class="log-feedback__btn log-feedback__btn--up"
data-vote="up"
aria-pressed="{{ 'true' if log.feedback.user_vote == 'up' else 'false' }}"
title="Helpful"
style="background:none; border:1px solid var(--border);
padding:4px 10px; border-radius:4px; cursor:pointer;
{% if log.feedback.user_vote == 'up' %}background:var(--accent-bg, #eef);
border-color:var(--accent);{% endif %}">
👍 <span class="log-feedback__count">{{ log.feedback.up }}</span>
</button>
<button type="button" class="log-feedback__btn log-feedback__btn--down"
data-vote="down"
aria-pressed="{{ 'true' if log.feedback.user_vote == 'down' else 'false' }}"
title="Not useful"
style="background:none; border:1px solid var(--border);
padding:4px 10px; border-radius:4px; cursor:pointer;
{% if log.feedback.user_vote == 'down' %}background:var(--accent-bg, #fee);
border-color:var(--accent);{% endif %}">
👎 <span class="log-feedback__count">{{ log.feedback.down }}</span>
</button>
<span class="log-feedback__status" aria-live="polite"
style="font-size:12px; opacity:0.7;"></span>
</div>
<script>
(function () {
var root = document.getElementById('log-feedback-{{ log.id }}');
if (!root || root.dataset.wired) return;
root.dataset.wired = '1';
var logId = root.dataset.logId;
var statusEl = root.querySelector('.log-feedback__status');
root.querySelectorAll('.log-feedback__btn').forEach(function (btn) {
btn.addEventListener('click', async function () {
var currentlyPressed = btn.getAttribute('aria-pressed') === 'true';
// Clicking the already-pressed vote clears it (toggle off).
var vote = currentlyPressed ? 'clear' : btn.dataset.vote;
statusEl.textContent = '…';
try {
var r = await fetch('/api/log/' + logId + '/feedback', {
method: 'POST',
headers: {'content-type': 'application/json'},
body: JSON.stringify({vote: vote}),
credentials: 'same-origin',
});
if (!r.ok) throw new Error('Vote failed: ' + r.status);
var data = await r.json();
// Update counts + pressed state in place. Server is source of truth.
var upBtn = root.querySelector('.log-feedback__btn--up');
var downBtn = root.querySelector('.log-feedback__btn--down');
upBtn.querySelector('.log-feedback__count').textContent = data.up;
downBtn.querySelector('.log-feedback__count').textContent = data.down;
upBtn.setAttribute('aria-pressed', data.user_vote === 'up' ? 'true' : 'false');
downBtn.setAttribute('aria-pressed', data.user_vote === 'down' ? 'true' : 'false');
statusEl.textContent = 'thanks';
setTimeout(function () { statusEl.textContent = ''; }, 1800);
} catch (e) {
statusEl.textContent = 'could not save';
}
});
});
})();
</script>
{% endif %}
{% endif %} {% endif %}

View file

@ -33,7 +33,7 @@
</div> </div>
{% endfor %} {% endfor %}
{% endif %} {% endif %}
{% if capped and SUBSCRIPTIONS_ENABLED %} {% if capped %}
<div class="news-capped-note" style="margin-top:14px; padding:10px 12px; border:1px dashed var(--border); color:var(--muted); font-size:12px; line-height:1.55;"> <div class="news-capped-note" style="margin-top:14px; padding:10px 12px; border:1px dashed var(--border); color:var(--muted); font-size:12px; line-height:1.55;">
Free tier — showing the last {{ window_hours|int }} hours of news. Free tier — showing the last {{ window_hours|int }} hours of news.
<a href="/pricing" style="color:var(--accent);">Upgrade</a> <a href="/pricing" style="color:var(--accent);">Upgrade</a>

View file

@ -1,6 +1,3 @@
{# Compliance: portfolio is a neutral composition viewer — report numbers,
never append a verdict. No "over-concentrated", no "consider X", no colour-
coded warning badges. See docs/read-markets-compliance-changes.md TASK 1. #}
{% if not portfolios %} {% if not portfolios %}
<div class="empty">no portfolio snapshots yet</div> <div class="empty">no portfolio snapshots yet</div>
{% else %} {% else %}

View file

@ -10,9 +10,9 @@
6-hour news feed, the cross-asset indicator panels, and a strategic 6-hour news feed, the cross-asset indicator panels, and a strategic
log refreshed every six hours. Paid stretches the news feed to a log refreshed every six hours. Paid stretches the news feed to a
full 24 hours, runs the strategic log hourly, unlocks the follow-up full 24 hours, runs the strategic log hourly, unlocks the follow-up
chat against past logs, adds a browser-only portfolio composition chat against past logs, adds portfolio import with AI analysis, and
viewer, and turns on the daily email digest on top of the Sunday turns on the daily email digest on top of the Sunday recap everyone
recap everyone gets. gets.
</p> </p>
</section> </section>
@ -33,7 +33,7 @@
<li><strong>Sunday weekly digest</strong> by email &mdash; week behind + week ahead, one-click unsubscribe</li> <li><strong>Sunday weekly digest</strong> by email &mdash; week behind + week ahead, one-click unsubscribe</li>
</ul> </ul>
<div class="tier-card__more"> <div class="tier-card__more">
Need the full-day news feed, hourly strategic log, follow-up chat, daily digests, or the portfolio composition viewer? See <strong>Paid</strong> &rarr; Need the full-day news feed, hourly strategic log, follow-up chat, daily digests, or portfolio analysis? See <strong>Paid</strong> &rarr;
</div> </div>
<div class="tier-card__cta"> <div class="tier-card__cta">
{% if cu and (cu.user or cu.is_admin) %} {% if cu and (cu.user or cu.is_admin) %}
@ -47,7 +47,7 @@
<div class="tier-card tier-card--featured"> <div class="tier-card tier-card--featured">
<div class="tier-card__badge">Best value</div> <div class="tier-card__badge">Best value</div>
<h2 class="tier-card__name">Paid</h2> <h2 class="tier-card__name">Paid</h2>
<div class="tier-card__tagline">Full-day news feed, hourly strategic log, follow-up chat, and the browser-only portfolio composition viewer.</div> <div class="tier-card__tagline">Full-day news feed, hourly strategic log, follow-up chat, and AI portfolio analysis.</div>
<div class="tier-card__price">&pound;7<span class="tier-card__price-unit"> / month</span></div> <div class="tier-card__price">&pound;7<span class="tier-card__price-unit"> / month</span></div>
<div class="tier-card__price-hint"> <div class="tier-card__price-hint">
Or <strong>&pound;70 / year</strong> &mdash; two months free, and Or <strong>&pound;70 / year</strong> &mdash; two months free, and
@ -62,8 +62,16 @@
<li><strong>Strategic log refreshed every hour</strong> instead of every six &mdash; track intraday moves as they unfold</li> <li><strong>Strategic log refreshed every hour</strong> instead of every six &mdash; track intraday moves as they unfold</li>
<li><strong>Follow-up chat on any past log</strong> &mdash; ask the model a question against the day&rsquo;s full context</li> <li><strong>Follow-up chat on any past log</strong> &mdash; ask the model a question against the day&rsquo;s full context</li>
<li><strong>Daily email digest</strong> (Mon&ndash;Sat) &mdash; ~600-word read of the session ahead, on top of the Sunday recap</li> <li><strong>Daily email digest</strong> (Mon&ndash;Sat) &mdash; ~600-word read of the session ahead, on top of the Sunday recap</li>
<li><strong>Browser-only portfolio composition viewer</strong> &mdash; drop a broker CSV and see your sector, currency, and concentration breakdown, computed entirely in your browser</li> <li><strong>Portfolio import</strong> from any broker CSV &mdash; Trading 212 natively, other formats auto-detected</li>
<li><strong>AI portfolio read</strong> &mdash; diversification, sector and currency concentration, macro-regime fit on your holdings</li>
<li><strong>Optional encrypted cloud sync</strong> &mdash; PIN-derived encryption in your browser, second-layer wrap on the server, no plaintext holdings server-side</li>
</ul> </ul>
<p class="tier-card__more" style="font-style: italic;">
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.
</p>
<div class="tier-card__cta"> <div class="tier-card__cta">
{% if paid %} {% if paid %}
<a class="btn-secondary btn-block" href="/settings">Manage subscription</a> <a class="btn-secondary btn-block" href="/settings">Manage subscription</a>
@ -182,7 +190,17 @@
<td class="compare-table__paid"><strong>Sunday + daily Mon&ndash;Sat</strong></td> <td class="compare-table__paid"><strong>Sunday + daily Mon&ndash;Sat</strong></td>
</tr> </tr>
<tr> <tr>
<th scope="row">Browser-only portfolio composition viewer</th> <th scope="row">Portfolio import (broker CSV)</th>
<td class="compare-table__none">&mdash;</td>
<td class="compare-table__paid"><strong>Included</strong></td>
</tr>
<tr>
<th scope="row">AI portfolio read</th>
<td class="compare-table__none">&mdash;</td>
<td class="compare-table__paid"><strong>Included</strong></td>
</tr>
<tr>
<th scope="row">Encrypted cloud sync</th>
<td class="compare-table__none">&mdash;</td> <td class="compare-table__none">&mdash;</td>
<td class="compare-table__paid"><strong>Included</strong></td> <td class="compare-table__paid"><strong>Included</strong></td>
</tr> </tr>
@ -247,10 +265,18 @@
<section class="public-section"> <section class="public-section">
<h2 class="public-section__head">How the data is handled</h2> <h2 class="public-section__head">How the data is handled</h2>
<p> <p>
Your portfolio holdings live in your browser&rsquo;s local storage. Your portfolio holdings live in your browser&rsquo;s local storage by
The CSV is parsed in your browser, the resulting pie is kept there, default. The server only learns which Yahoo tickers appear across the
and nothing about your holdings is sent to or stored on the server. user base &mdash; an anonymous union, with no link back to any specific
Full details on the <a href="/privacy">privacy page</a>. user.
</p>
<p>
If you opt in to <strong>encrypted cloud sync</strong>, 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&rsquo;t recover it for you. Full details on the
<a href="/privacy">privacy page</a>.
</p> </p>
</section> </section>

View file

@ -37,9 +37,24 @@
It contains your user id only and is signed so we can detect It contains your user id only and is signed so we can detect
tampering. Cookie is marked Secure and HttpOnly. tampering. Cookie is marked Secure and HttpOnly.
</li> </li>
{# Cloud sync + server-side per-ticker aggregate union are flag-gated off. <li>
See docs/read-markets-compliance-changes.md and app/config.py <strong>Anonymous ticker universe</strong>: when you upload a
(PORTFOLIO_SYNC_ENABLED, TICKER_UNIVERSE_AGGREGATE_ENABLED). #} portfolio CSV we record which Yahoo tickers appear, with
<em>no link</em> to your account. The same row would exist whether
any specific user holds the ticker or not &mdash; once a ticker is in
the universe, the row carries no signal as to whose import added it.
</li>
<li>
<strong>If you opt in to encrypted cloud sync</strong>: 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&rsquo;t decrypt
the blob to plaintext without your PIN, and we can&rsquo;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.
</li>
<li> <li>
<strong>Anonymised cost ledger</strong> of AI calls (model, tokens, <strong>Anonymised cost ledger</strong> of AI calls (model, tokens,
cost). No portfolio or personal data is attached to ledger rows. cost). No portfolio or personal data is attached to ledger rows.
@ -61,12 +76,10 @@
<h2 class="public-section__head">What we don&rsquo;t collect</h2> <h2 class="public-section__head">What we don&rsquo;t collect</h2>
<ul> <ul>
<li> <li>
<strong>Your portfolio holdings, in any form, on the server.</strong> <strong>Your portfolio holdings as plaintext on the server.</strong>
The portfolio feature is a browser-only composition viewer: Parsed pies are returned to your browser and kept in
uploaded CSVs are parsed and returned to your browser, kept in <code>localStorage</code>. The server&rsquo;s view is the anonymous
<code>localStorage</code>, and never sent back to or stored on ticker universe described above.
the server. The server records no per-ticker aggregate of what
anyone holds.
</li> </li>
<li> <li>
<strong>Third-party analytics or ad cookies.</strong> No Google <strong>Third-party analytics or ad cookies.</strong> No Google
@ -91,14 +104,24 @@
<ul> <ul>
<li> <li>
<strong>Performance of a contract</strong> (Art. 6(1)(b)) &mdash; for <strong>Performance of a contract</strong> (Art. 6(1)(b)) &mdash; for
operating your account, the sign-in flow, and any paid features. operating your account, the sign-in flow, paid features, and the
mechanics of encrypted cloud sync.
</li> </li>
<li> <li>
<strong>Legitimate interests</strong> (Art. 6(1)(f)) &mdash; for the <strong>Legitimate interests</strong> (Art. 6(1)(f)) &mdash; for the
anonymised cost ledger, job-run telemetry, and reverse-proxy access anonymous ticker universe, the anonymised cost ledger, job-run
logs. Our interest is the secure, abuse-resistant, cost-controlled telemetry, and reverse-proxy access logs. Our interest is the
operation of a free public service, balanced against the minimal secure, abuse-resistant, cost-controlled operation of a free
and de-identified nature of the data. public service, balanced against the minimal and de-identified
nature of the data.
</li>
<li>
<strong>Consent</strong> (Art. 6(1)(a)) &mdash; where you opt in to
encrypted cloud sync (and the related caching of a derived
encryption key in your browser&rsquo;s <code>sessionStorage</code>).
You can withdraw consent at any time by disabling sync in
Settings; the cached key is cleared and the server-side blob is
removed.
</li> </li>
</ul> </ul>
</section> </section>
@ -108,10 +131,9 @@
<p> <p>
The Service does not make decisions about you that produce legal or The Service does not make decisions about you that produce legal or
similarly significant effects in an automated way (UK-GDPR Art. 22). similarly significant effects in an automated way (UK-GDPR Art. 22).
The strategic log and indicator summaries are general editorial The AI portfolio analysis is editorial commentary on the holdings
commentary on public market data, not personalised assessments of you upload; it does not approve, reject or rank you, and you remain
you, and you remain the sole decision-maker about anything in your the sole decision-maker about anything in your account.
account.
</p> </p>
</section> </section>
@ -129,9 +151,13 @@
browser. browser.
</li> </li>
<li> <li>
<strong>Local portfolio</strong> &mdash; parsed pies live in <strong>Local portfolio + cached sync key</strong> &mdash; parsed pies
<code>localStorage</code> on your device. They are not sent to live in <code>localStorage</code> on your device. If you enable
or stored on the server. cloud sync, the derived encryption key is cached in
<code>sessionStorage</code> so you don&rsquo;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.
</li> </li>
</ul> </ul>
</section> </section>
@ -151,15 +177,14 @@
currently inside the UK; if that changes we will update this notice. currently inside the UK; if that changes we will update this notice.
</li> </li>
<li> <li>
<strong>AI provider calls</strong> for the strategic log and <strong>AI provider calls</strong> for the strategic log, indicator
indicator summaries. Where the provider sits outside the UK, we summaries, and (paid) portfolio analysis. Where the provider sits
rely on the UK International Data Transfer Agreement (IDTA) / the outside the UK, we rely on the UK International Data Transfer
UK Addendum to the EU Standard Contractual Clauses where no Agreement (IDTA) / the UK Addendum to the EU Standard Contractual
adequacy decision applies. Each outbound request carries an Clauses where no adequacy decision applies. Each outbound request
explicit no-training opt-out header carries an explicit no-training opt-out header
(<code>X-OR-Allow-Training: false</code> on OpenRouter); see the (<code>X-OR-Allow-Training: false</code> on OpenRouter); see the
Third parties section below for the caveats. None of these Third parties section below for the caveats.
outbound requests contain user holdings or other portfolio data.
</li> </li>
</ul> </ul>
</section> </section>
@ -175,6 +200,15 @@
<strong>Session cookies</strong>: expire automatically; you can <strong>Session cookies</strong>: expire automatically; you can
sign out at any time to revoke. sign out at any time to revoke.
</li> </li>
<li>
<strong>Ticker universe</strong>: rows untouched for 60 days are
evicted by a nightly job. Active tickers remain.
</li>
<li>
<strong>Encrypted portfolio blob</strong>: 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.
</li>
<li> <li>
<strong>Account</strong>: held until you ask us to delete it. <strong>Account</strong>: held until you ask us to delete it.
Email <a href="mailto:{{ OPERATOR_EMAIL }}">{{ OPERATOR_EMAIL }}</a>. Email <a href="mailto:{{ OPERATOR_EMAIL }}">{{ OPERATOR_EMAIL }}</a>.
@ -196,18 +230,22 @@
</li> </li>
<li> <li>
<strong>AI provider(s)</strong>: DeepSeek (primary) with OpenRouter <strong>AI provider(s)</strong>: DeepSeek (primary) with OpenRouter
as a fallback. They see the prompt for the strategic log and the as a fallback. They see the prompt for the strategic log, the
indicator summaries. These prompts contain public market data and indicator summaries, and the portfolio analysis call &mdash; which
headlines &mdash; never any user holdings or portfolio data. contains your holdings only when you press
&ldquo;Generate AI analysis&rdquo; on a paid plan, and only for the
duration of that single call. The portfolio analysis output is not
persisted on the server.
<br> <br>
<strong>No-training opt-out.</strong> Every OpenRouter request <strong>No-training opt-out.</strong> Every OpenRouter request
carries the <code>X-OR-Allow-Training: false</code> header, which carries the <code>X-OR-Allow-Training: false</code> header, which
signals to OpenRouter and any compatible upstream that the prompt signals to OpenRouter and any compatible upstream that the prompt
must not be used to train or improve models. DeepSeek does not must not be used to train or improve models. DeepSeek does not
currently expose a per-request opt-out. We do not control currently expose a per-request opt-out; if you do not want your
retention or training policies on the provider side beyond the holdings to leave our server at all, do not use the AI portfolio
headers we set &mdash; the provider&rsquo;s own published data policy is analysis feature. We do not control retention or training policies
the binding statement on that point. on the provider side beyond the headers we set &mdash; the provider&rsquo;s
own published data policy is the binding statement on that point.
</li> </li>
<li> <li>
<strong>Market-data sources</strong>: Yahoo Finance and a small set <strong>Market-data sources</strong>: Yahoo Finance and a small set
@ -225,7 +263,7 @@
<li>Have inaccurate data corrected (Art. 16, rectification).</li> <li>Have inaccurate data corrected (Art. 16, rectification).</li>
<li>Have your account and associated data deleted (Art. 17, erasure).</li> <li>Have your account and associated data deleted (Art. 17, erasure).</li>
<li>Export the data you can recognise (Art. 20, portability): your <li>Export the data you can recognise (Art. 20, portability): your
email and your referral linkage.</li> email, any active encrypted blob, your referral linkage.</li>
<li>Restrict processing (Art. 18).</li> <li>Restrict processing (Art. 18).</li>
<li>Object specifically to processing carried out on the basis of <li>Object specifically to processing carried out on the basis of
legitimate interests (Art. 21), including any direct marketing.</li> legitimate interests (Art. 21), including any direct marketing.</li>

View file

@ -1,18 +1,10 @@
<!DOCTYPE html> <!DOCTYPE html>
<html lang="{{ lang|default('en') }}"> <html lang="en">
<head> <head>
<meta charset="UTF-8" /> <meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" /> <meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>{% block title %}{{ BRAND_NAME }}{% endblock %}</title> <title>{% block title %}{{ BRAND_NAME }}{% endblock %}</title>
<meta name="description" content="{{ (t.meta.description if t is defined and t else None) or TAGLINE }}"> <meta name="description" content="{{ TAGLINE }}">
{# Hreflang signals for search engines: tells crawlers that the
same page exists in another language at a different URL. Only
rendered when the route explicitly opts in via lang_switch=true. #}
{% if lang_switch %}
<link rel="alternate" hreflang="en" href="/en/">
<link rel="alternate" hreflang="it" href="/it/">
<link rel="alternate" hreflang="x-default" href="/">
{% endif %}
{# Same flash-prevention theme bootstrap as the app shell. #} {# Same flash-prevention theme bootstrap as the app shell. #}
<script> <script>
(function() { (function() {
@ -36,19 +28,8 @@
{{ BRAND_NAME }} {{ BRAND_NAME }}
</a> </a>
<nav class="public-header__nav"> <nav class="public-header__nav">
{% if SUBSCRIPTIONS_ENABLED %}
<a href="/pricing" class="{% if request.url.path == '/pricing' %}active{% endif %}">Pricing</a> <a href="/pricing" class="{% if request.url.path == '/pricing' %}active{% endif %}">Pricing</a>
{% endif %}
<a href="/about" class="{% if request.url.path == '/about' %}active{% endif %}">About</a> <a href="/about" class="{% if request.url.path == '/about' %}active{% endif %}">About</a>
{# Lang switch — currently only the landing page opts in
(lang_switch=true). When other public pages get translated
this widget will surface there too automatically. #}
{% if lang_switch %}
<span class="public-header__lang-switch" role="group" aria-label="Language">
<a href="/en/" class="public-header__lang {% if lang == 'en' %}active{% endif %}">EN</a>
<a href="/it/" class="public-header__lang {% if lang == 'it' %}active{% endif %}">IT</a>
</span>
{% endif %}
{% if cu and (cu.user or cu.is_admin) %} {% if cu and (cu.user or cu.is_admin) %}
<a href="/" class="public-header__cta">Dashboard</a> <a href="/" class="public-header__cta">Dashboard</a>
{% else %} {% else %}
@ -68,7 +49,7 @@
<span class="public-footer__tagline">{{ TAGLINE }}</span> <span class="public-footer__tagline">{{ TAGLINE }}</span>
</div> </div>
<nav class="public-footer__links"> <nav class="public-footer__links">
{% if SUBSCRIPTIONS_ENABLED %}<a href="/pricing">Pricing</a>{% endif %} <a href="/pricing">Pricing</a>
<a href="/about">About</a> <a href="/about">About</a>
<a href="/terms">Terms</a> <a href="/terms">Terms</a>
<a href="/privacy">Privacy</a> <a href="/privacy">Privacy</a>

View file

@ -18,7 +18,6 @@
<div class="settings-row__value">{{ user.email }}</div> <div class="settings-row__value">{{ user.email }}</div>
</div> </div>
{% if SUBSCRIPTIONS_ENABLED %}
<div class="settings-row"> <div class="settings-row">
<div class="settings-row__label">Tier</div> <div class="settings-row__label">Tier</div>
<div class="settings-row__value" style="display:flex; align-items:flex-start; gap:10px; flex:1;"> <div class="settings-row__value" style="display:flex; align-items:flex-start; gap:10px; flex:1;">
@ -60,9 +59,8 @@
{% endif %} {% endif %}
</div> </div>
</div> </div>
{% endif %}
{% if SUBSCRIPTIONS_ENABLED and paid and paid.active and paid.source != "credit" and user.stripe_customer_id %} {% if paid and paid.active and paid.source != "credit" and user.stripe_customer_id %}
<script> <script>
(function () { (function () {
var btn = document.getElementById('stripe-portal-btn'); var btn = document.getElementById('stripe-portal-btn');
@ -106,9 +104,7 @@
<span class="neu">Investing &rarr; Your Pie &rarr; &middot;&middot;&middot; &rarr; Export</span>.</span> <span class="neu">Investing &rarr; Your Pie &rarr; &middot;&middot;&middot; &rarr; Export</span>.</span>
</p> </p>
<div id="drop-zone" class="dz" <div id="drop-zone" class="dz" data-paid="{{ 'true' if paid and paid.active else 'false' }}">
data-paid="{{ 'true' if paid and paid.active else 'false' }}"
data-sync-enabled="{{ 'true' if PORTFOLIO_SYNC_ENABLED else 'false' }}">
<input type="file" id="file-input" name="file" accept=".csv,text/csv" hidden> <input type="file" id="file-input" name="file" accept=".csv,text/csv" hidden>
<div class="dz__icon"></div> <div class="dz__icon"></div>
<div class="dz__label">Drop your broker's portfolio CSV here</div> <div class="dz__label">Drop your broker's portfolio CSV here</div>
@ -268,9 +264,6 @@
</details> </details>
{# --- Cloud sync block --------------------------------------------- #} {# --- Cloud sync block --------------------------------------------- #}
{# Gated by PORTFOLIO_SYNC_ENABLED — see app/config.py. Holdings stay
in the browser when the flag is off; this whole section is hidden. #}
{% if PORTFOLIO_SYNC_ENABLED %}
<details class="settings-section"> <details class="settings-section">
<summary class="settings-section__head">Cloud sync (encrypted)</summary> <summary class="settings-section__head">Cloud sync (encrypted)</summary>
<p class="settings-section__lede"> <p class="settings-section__lede">
@ -294,7 +287,6 @@
</p> </p>
{% endif %} {% endif %}
</details> </details>
{% endif %}
{# Future: Paddle subscription block, AI-spend ledger summary, etc. #} {# Future: Paddle subscription block, AI-spend ledger summary, etc. #}
@ -303,7 +295,7 @@
</div> </div>
</section> </section>
{% if PORTFOLIO_SYNC_ENABLED and user and paid and paid.active %} {% if user and paid and paid.active %}
<div id="sync-modal" class="modal" <div id="sync-modal" class="modal"
style="position:fixed;inset:0;background:rgba(0,0,0,0.45); style="position:fixed;inset:0;background:rgba(0,0,0,0.45);
display:none;align-items:center;justify-content:center;z-index:1000;"> display:none;align-items:center;justify-content:center;z-index:1000;">

View file

@ -25,21 +25,12 @@
<h2 class="public-section__head">2. The Service</h2> <h2 class="public-section__head">2. The Service</h2>
<p> <p>
{{ BRAND_NAME }} provides a macro-strategy dashboard with curated {{ BRAND_NAME }} provides a macro-strategy dashboard with curated
market data, news, and AI-generated commentary on public market market data, news, and AI-generated commentary. Paid features include
data (strategic log, indicator summaries, and a follow-up chat portfolio import, AI portfolio analysis, and optional end-to-end
grounded on those reads). It also includes a browser-only portfolio encrypted cloud sync of your portfolio. Feature lists, tiers, and
composition viewer: CSVs you upload are parsed in your browser and pricing are described on the <a href="/pricing">Pricing page</a> and
used to compute neutral statistics (weights, sector / currency / may change over time.
concentration breakdown). Your holdings stay in your browser; they
are not sent to or stored on the server, and the Service does not
produce AI commentary on them.
</p> </p>
{% if SUBSCRIPTIONS_ENABLED %}
<p>
Feature tiers and pricing are described on the
<a href="/pricing">Pricing page</a> and may change over time.
</p>
{% endif %}
<p> <p>
Nothing produced by the Service is investment advice. See the Nothing produced by the Service is investment advice. See the
<a href="/disclaimer">Disclaimer</a> for the full position. <a href="/disclaimer">Disclaimer</a> for the full position.
@ -85,7 +76,6 @@
<section class="public-section"> <section class="public-section">
<h2 class="public-section__head">5. Paid plans</h2> <h2 class="public-section__head">5. Paid plans</h2>
{% if SUBSCRIPTIONS_ENABLED %}
<p> <p>
Paid plans are available at &pound;7/month or &pound;70/year (terms Paid plans are available at &pound;7/month or &pound;70/year (terms
and current prices on the <a href="/pricing">pricing page</a>). New and current prices on the <a href="/pricing">pricing page</a>). New
@ -98,15 +88,6 @@
stated. Detailed refund and cancellation rights are set out in stated. Detailed refund and cancellation rights are set out in
section 6 below. section 6 below.
</p> </p>
{% else %}
<p>
Paid plans are not currently available; the Service is offered to
signed-in users at no cost while the subscription system is paused.
Sections 5 and 6 (paid plans and refunds) are retained for reference
and will apply again if subscriptions resume; their terms are not in
force at the moment.
</p>
{% endif %}
</section> </section>
<section class="public-section"> <section class="public-section">
@ -200,11 +181,9 @@
permission. permission.
</p> </p>
<p> <p>
Any portfolio CSV you upload remains your data. The portfolio Any portfolio you upload remains your data. The Service does not
feature is browser-only: CSVs are parsed in your browser, the persist your holdings as plaintext (see the
resulting pie is kept in your browser&rsquo;s local storage, and <a href="/privacy">Privacy notice</a>).
nothing about your holdings is sent to or stored on the server
(see the <a href="/privacy">Privacy notice</a>).
</p> </p>
</section> </section>

View file

@ -85,10 +85,4 @@ templates.env.globals["LEGAL_OPERATOR"] = branding.LEGAL_OPERATOR
templates.env.globals["OPERATOR_EMAIL"] = branding.OPERATOR_EMAIL templates.env.globals["OPERATOR_EMAIL"] = branding.OPERATOR_EMAIL
templates.env.globals["OPERATOR_JURISDICTION"] = branding.OPERATOR_JURISDICTION templates.env.globals["OPERATOR_JURISDICTION"] = branding.OPERATOR_JURISDICTION
templates.env.globals["BETA_MODE"] = get_settings().BETA_MODE templates.env.globals["BETA_MODE"] = get_settings().BETA_MODE
# Compliance feature flags — read once at startup (templates restart with the
# app). See app.config.Settings for semantics.
_s = get_settings()
templates.env.globals["PORTFOLIO_AI_ENABLED"] = _s.PORTFOLIO_AI_ENABLED
templates.env.globals["PORTFOLIO_SYNC_ENABLED"] = _s.PORTFOLIO_SYNC_ENABLED
templates.env.globals["SUBSCRIPTIONS_ENABLED"] = _s.SUBSCRIPTIONS_ENABLED
templates.env.globals["ASSET_VERSION"] = ASSET_VERSION templates.env.globals["ASSET_VERSION"] = ASSET_VERSION

View file

@ -15,9 +15,3 @@ services:
- ./app:/app/app - ./app:/app/app
ports: ports:
- "${CASSANDRA_PORT:-8000}:8000" - "${CASSANDRA_PORT:-8000}:8000"
admin:
# Dev: reach the console at http://localhost:8091 (loopback only). Prod
# drops this and fronts it with NPM instead (docker-compose.prod.yml).
ports:
- "127.0.0.1:${CASSANDRA_ADMIN_PORT:-8091}:8000"

View file

@ -44,23 +44,6 @@ services:
DATABASE_URL: mysql+aiomysql://${MARIADB_USER:-cassandra}:${MARIADB_PASSWORD:-changeme}@readmarkets-db-1:3306/${MARIADB_DATABASE:-cassandra} DATABASE_URL: mysql+aiomysql://${MARIADB_USER:-cassandra}:${MARIADB_PASSWORD:-changeme}@readmarkets-db-1:3306/${MARIADB_DATABASE:-cassandra}
REDIS_URL: redis://readmarkets-redis-1:6379/0 REDIS_URL: redis://readmarkets-redis-1:6379/0
admin:
# Fronted by NPM like `app`: listen on 80 and join the `intranet` network
# so the proxy can reach it as `readmarkets-admin-1:80`. No host port.
# --proxy-headers so redirect/asset URLs honour X-Forwarded-Proto from NPM.
# Still gated by ADMIN_CONSOLE_PASSWORD; add an NPM access rule in front for
# a second layer. Project-prefixed DB name avoids the shared-network `db`
# DNS collision (same reason as app/scheduler).
command: ["uvicorn", "admin.main:app", "--host", "0.0.0.0", "--port", "80",
"--workers", "1", "--proxy-headers", "--forwarded-allow-ips=*"]
expose:
- "80"
networks:
- default
- intranet
environment:
DATABASE_URL: mysql+aiomysql://${MARIADB_USER:-cassandra}:${MARIADB_PASSWORD:-changeme}@readmarkets-db-1:3306/${MARIADB_DATABASE:-cassandra}
networks: networks:
intranet: intranet:
external: true external: true

View file

@ -33,7 +33,6 @@ services:
# on the next `run` without rebuilding the image. # on the next `run` without rebuilding the image.
volumes: volumes:
- ./app:/app/app - ./app:/app/app
- ./admin:/app/admin
- ./tests:/app/tests - ./tests:/app/tests
- ./alembic:/app/alembic - ./alembic:/app/alembic
- ./alembic.ini:/app/alembic.ini:ro - ./alembic.ini:/app/alembic.ini:ro

View file

@ -77,27 +77,6 @@ services:
redis: redis:
condition: service_healthy condition: service_healthy
# Superadmin console — independent read-only operator dashboard. Same
# image (reuses app.db/app.models) but runs admin.main:app instead of the
# public app, and NEVER runs migrations. No host port here, mirroring `app`:
# dev adds a loopback port (docker-compose.override.yml) and prod joins the
# `intranet` network so NPM can proxy it (docker-compose.prod.yml). Still
# gated by ADMIN_CONSOLE_PASSWORD.
admin:
build: .
restart: unless-stopped
command: ["uvicorn", "admin.main:app", "--host", "0.0.0.0", "--port", "8000", "--workers", "1"]
env_file: .env
environment:
DATABASE_URL: mysql+aiomysql://${MARIADB_USER:-cassandra}:${MARIADB_PASSWORD:-changeme}@db:3306/${MARIADB_DATABASE:-cassandra}
volumes:
- ./config:/app/config:ro
- ./app:/app/app
- ./admin:/app/admin
depends_on:
db:
condition: service_healthy
backup: backup:
image: mariadb:11 image: mariadb:11
restart: unless-stopped restart: unless-stopped

View file

@ -1,222 +0,0 @@
# Currency-localised pricing — Design Spec
**Date:** 2026-07-29
**Status:** Draft — pending implementation plan
## Context
`/pricing` hardcodes `£7` and `£70` in its copy and its buttons. Until
commit `4169a67`, checkout sniffed `CF-IPCountry` / `Accept-Language`
and passed a matching `currency` to Stripe, which then selected a
`currency_options` rate off the Price. A US visitor was shown £7 and
billed $9.99; a German visitor was billed €7. The page's "Prices in GBP"
line was untrue for two of the three currencies.
`4169a67` fixed that by forcing GBP for everyone — correct, but it
gives up genuine multi-currency pricing that is already configured and
paid for on the Stripe side. This spec restores it properly: the page
displays the currency the customer will actually be charged.
Live Prices today (both `livemode: true`, base currency GBP):
| Price | Interval | GBP | EUR | USD |
|---|---|---|---|---|
| `price_1TbNshDLpLwvRJpKnuWjdU1x` | month | 700 | 700 | 999 |
| `price_1TbNtWDLpLwvRJpKze87qOJ4` | year | 7000 | 7000 | 9499 |
The EUR annual was corrected from 8000 to 7000 on 2026-07-29, so the
"two months free" claim now holds in GBP and EUR (16.7%) and understates
USD (20.8%).
## Goals
- A visitor sees prices in the currency they will be charged, in the
page copy, the buttons, and the saving claim.
- The visitor can override the detected currency, and the choice sticks.
- The displayed amounts are structurally incapable of disagreeing with
what Stripe charges.
- The monthly cooling-off waiver is effective for non-UK customers.
- `/it/pricing` renders in Italian, matching how the landing page
already works.
## Non-goals
- Adding currencies beyond GBP/EUR/USD. Each would need
`currency_options` on both live Prices first.
- VAT calculation or Stripe Tax. `automatic_tax` is currently `false`;
see Open Questions.
- Changing the monthly/annual plan structure. Annual keeps its 14-day
trial, monthly keeps immediate billing with a waiver.
- Localising any public page other than `/pricing`.
## Design
### Two axes, both user-switchable
| Axis | Values | Detection order | Cookie |
|---|---|---|---|
| Language | `en`, `it` | existing `detect_public_lang` | `rtm.lang` |
| Currency | `gbp`, `eur`, `usd` | cookie → country → Accept-Language → `gbp` | `rtm.ccy` |
An earlier draft added a third, non-switchable `jurisdiction` axis to
select between UK Reg-36 and Italian art. 59 consent wording. It was
dropped: see "Consent wording" below. Nothing legally operative is
derived from IP geolocation.
### Data flow
```
GET /pricing (or /it/pricing)
├─ lang = detect_public_lang(cookie, accept-language, cf-country, user)
├─ currency = detect_currency(cookie, cf-country, accept-language)
│ overridden by users.stripe_currency when set
└─ amounts = pricing_catalog.get(currency)
render symbol + amounts + computed saving %, in `lang`
POST /api/stripe/checkout {cadence, currency}
currency honoured only when the user has no stripe_customer_id
```
### Components
**`app/services/pricing_catalog.py`** (new)
Reads both Prices with `expand[]=currency_options`, caches the result in
memory for 1 hour, and exposes:
```python
get(currency: str) -> PriceSet # monthly, annual, symbol, saving_pct
available() -> list[str] # currencies present on BOTH prices
```
`saving_pct` is computed as `1 - annual / (12 * monthly)` and rounded
down to a whole percent. It is never written by hand — this is what
structurally prevents a repeat of the €80-vs-€84 drift.
Knows nothing about HTTP, requests, or templates. Takes a Stripe client
as a constructor argument so tests inject a fake.
**Currency detection** — added to `app/services/locales.py` next to
`detect_public_lang`, reusing its country tables rather than starting a
parallel module. Pure function, no I/O:
```python
detect_currency(cookie_ccy, cf_country, accept_language, allowed) -> str
```
Priority: an explicit cookie beats everything; then `CF-IPCountry`;
then the first `Accept-Language` tag; then `gbp`. `allowed` is passed in
by the caller from `pricing_catalog.available()` — the function stays
pure and does no I/O of its own; anything not in `allowed` falls through
to the next rule.
The country table is carried over unchanged from the removed version,
including `CA -> usd`. No CAD price exists, so every choice for Canada is
a proxy; USD is the closest familiar one. Adding a real CAD
`currency_options` entry would be the actual fix, and is out of scope.
**`/pricing` route** (`app/routers/public.py`) gains the currency in its
context and a sibling `/it/pricing` route. Copy moves into the existing
`app/locales/{en,it}.yaml` under a `pricing.` key, matching the landing
page. A `?ccy=` query parameter sets the cookie and redirects, so the
switcher works without JavaScript.
**`/api/stripe/checkout`** restores the `currency` field on
`CheckoutRequest`, validated against `pricing_catalog.available()`, and
passes it only when `user.stripe_customer_id` is unset. This reverts the
mechanical part of `4169a67` while keeping its guarantee: the page and
the charge always agree, because both now read the same catalog.
### Consent wording
The monthly waiver currently cites *Regulation 36 of the Consumer
Contracts Regulations 2013*. That is UK law; for a customer resident
elsewhere the citation does not apply, and an ineffective waiver means a
monthly subscriber retains the 14-day refund right the checkbox was
meant to remove.
UK Reg 36 and Italian `Codice del Consumo` art. 59 both implement
Directive 2011/83/EU art. 16(m). The waiver takes effect from its
substance — an express request for immediate performance plus an
acknowledgement that the cancellation right is lost — not from the
citation. Wording that states the substance and cites no statute is
therefore effective under both regimes, whereas citing the wrong one is
worse than citing none.
New wording, in place of the current sentence:
> I request that the service starts immediately, and I understand that
> once it has started I lose my right to cancel and get a refund.
The Terms-of-Service agreement in the same checkbox is unchanged. The
Italian rendering of this sentence is a translation of substance, not of
a statutory reference, so it carries the same weight as the existing
`auth.ack` translations.
This wording is subject to the legal sign-off already tracked on the
launch blocker list. It is not a lawyer-authored sentence.
### Locked currency
Stripe locks currency to the Customer at creation. A returning customer
whose subscription lapsed could otherwise be shown €7 and billed £7.
Add `users.stripe_currency` (`String(3)`, nullable), populated in
`_grant_paid` from the subscription object. When set, `/pricing` renders
that currency and disables the switcher with a one-line explanation.
Requires a small Alembic migration.
The simpler alternative — disable the switcher for anyone with a
`stripe_customer_id`, without storing the currency — is rejected because
it still shows a possibly-wrong currency; it only stops the user
changing it.
### Failure modes
| Condition | Behaviour |
|---|---|
| Stripe unreachable, warm cache | Serve stale cache indefinitely; log a warning |
| Stripe unreachable, cold cache | Static GBP amounts, switcher hidden — i.e. exactly today's page |
| Requested currency absent from a Price | Excluded from `available()`, so unreachable |
| `?ccy=` with an unknown value | Ignored, cookie untouched |
The page never returns an error because of a pricing lookup.
## Testing
- `detect_currency` — table-driven unit tests over the priority chain,
including values outside `allowed` falling through to the next rule.
- `pricing_catalog` — fake Stripe client: happy path, `saving_pct`
arithmetic, currency missing from one Price but not the other, cold-cache
failure, stale-cache-on-failure.
- Route tests — `/pricing` and `/it/pricing` render expected symbols and
amounts per cookie/header combination; `?ccy=` sets the cookie.
- **Cross-check test:** for each currency, assert the amount rendered in
the page equals the amount Stripe would charge for the currency
checkout sends. This is the regression guard for the original bug and
is the most important test in the set.
- Locked-currency test — a user with `stripe_currency` set sees that
currency regardless of headers or cookie.
## Open questions
1. **EU VAT.** `automatic_tax` is `false`, so no VAT is charged. B2C
digital services sold into the EU have no VAT threshold — VAT is due
in the customer's member state from the first sale, normally via a
non-Union OSS registration. Displaying EUR does not create this
obligation, but selling to EU consumers does. Resolve before taking
EUR money. Registration decision, not a code change.
2. **`billing_address_collection`.** Currently unset, so Stripe defaults
to `auto` and may capture only a postal code. Setting it to
`required` puts a country on every Customer record — useful for the
VAT question above and for knowing where customers are. Recommended,
independent of this feature.
## Out of scope / follow-ups
- `customer.subscription.paused` and `.resumed` are subscribed at Stripe
but absent from `_HANDLERS`. Harmless while pause is disabled in the
portal configuration, but a live trap if it is ever enabled.
- Localising `/terms` and `/privacy`, which the Italian pricing page
will link to in English.

View file

@ -24,7 +24,6 @@ dependencies = [
"aiosmtplib>=3.0", "aiosmtplib>=3.0",
"redis[hiredis]>=5.2", "redis[hiredis]>=5.2",
"stripe>=11.0", "stripe>=11.0",
"pyyaml>=6.0",
] ]
[project.optional-dependencies] [project.optional-dependencies]

View file

@ -18,17 +18,6 @@ sys.path.insert(0, str(ROOT))
os.environ.setdefault("DATABASE_URL", "sqlite+aiosqlite:///:memory:") os.environ.setdefault("DATABASE_URL", "sqlite+aiosqlite:///:memory:")
os.environ.setdefault("CASSANDRA_MOCK", "1") os.environ.setdefault("CASSANDRA_MOCK", "1")
# Compliance feature flags default to False in app.config — deployment is
# automatically compliance-safe. For the test suite we want all code paths
# exercisable (Stripe routes, paid-tier gating, portfolio AI, cloud sync),
# so flip every flag on here. Tests that specifically want to verify
# flag-off behavior override these via monkeypatch.setenv or direct
# settings override.
os.environ.setdefault("PORTFOLIO_AI_ENABLED", "true")
os.environ.setdefault("PORTFOLIO_SYNC_ENABLED", "true")
os.environ.setdefault("TICKER_UNIVERSE_AGGREGATE_ENABLED", "true")
os.environ.setdefault("SUBSCRIPTIONS_ENABLED", "true")
import pytest import pytest

View file

@ -1,264 +0,0 @@
"""Superadmin console — auth gate, read-only queries, and page wiring.
Runs against an in-memory-ish sqlite file (same pattern as the other API
tests): seed a handful of users + related rows, rebind app.db to the test
engine, then drive both the query layer directly and the FastAPI app via
TestClient.
"""
from __future__ import annotations
import asyncio
from datetime import timedelta
def _seed(tmp_path):
"""Create schema + fixture rows; rebind app.db. Returns the factory."""
from sqlalchemy.ext.asyncio import async_sessionmaker, create_async_engine
from app import db as db_mod
from app.db import Base, utcnow
from app.models import (
EmailSend, PortfolioSync, Referral, StrategicLogFeedback,
User, UserAcknowledgement,
)
engine = create_async_engine(f"sqlite+aiosqlite:///{tmp_path}/admin.db")
factory = async_sessionmaker(engine, expire_on_commit=False)
db_mod._engine = engine
db_mod._session_factory = factory
now = utcnow()
async def _go():
async with engine.begin() as conn:
await conn.run_sync(Base.metadata.create_all)
async with factory() as s:
# 1: free, referrer
s.add(User(id=1, email="alice@x", tier="free", created_at=now,
last_login_at=now, referral_code="ALICE"))
# 2: paid subscriber via Stripe
s.add(User(id=2, email="bob@x", tier="paid", created_at=now,
last_login_at=now, stripe_customer_id="cus_1",
stripe_subscription_id="sub_1"))
# 3: free but credit-active, referred by alice, has sync/history
s.add(User(id=3, email="carol@x", tier="free", created_at=now,
credit_until=now + timedelta(days=30),
referred_by_user_id=1))
await s.flush()
s.add(PortfolioSync(user_id=3, outer_ciphertext=b"x",
outer_nonce=b"y", version=1,
created_at=now, updated_at=now))
s.add(EmailSend(user_id=3, kind="daily", sent_at=now, status="sent"))
s.add(UserAcknowledgement(user_id=3, version=2, lang="en",
accepted_at=now))
s.add(StrategicLogFeedback(log_id=1, user_id=3, vote="up",
created_at=now, updated_at=now))
s.add(Referral(referrer_user_id=1, referred_user_id=3,
created_at=now, converted_at=now))
await s.commit()
asyncio.run(_go())
return factory
# --- auth ------------------------------------------------------------------
def test_check_password(monkeypatch):
from app.config import get_settings
from admin import auth
monkeypatch.setenv("ADMIN_CONSOLE_PASSWORD", "s3cret")
get_settings.cache_clear()
assert auth.check_password("s3cret") is True
assert auth.check_password("wrong") is False
get_settings.cache_clear()
def test_check_password_empty_denies(monkeypatch):
from app.config import get_settings
from admin import auth
monkeypatch.setenv("ADMIN_CONSOLE_PASSWORD", "")
get_settings.cache_clear()
# Empty password must reject everything — console closed by default.
assert auth.check_password("") is False
assert auth.check_password("anything") is False
get_settings.cache_clear()
def test_session_round_trip(monkeypatch):
from app.config import get_settings
from admin import auth
monkeypatch.setenv("ADMIN_CONSOLE_SESSION_SECRET", "unit-test-secret")
get_settings.cache_clear()
token = auth.sign_session()
assert auth.verify_session(token) is True
assert auth.verify_session("garbage") is False
get_settings.cache_clear()
# --- queries ---------------------------------------------------------------
def test_overview_stats(tmp_path):
factory = _seed(tmp_path)
from admin import queries
async def _go():
async with factory() as s:
return await queries.overview_stats(s)
stats = asyncio.run(_go())
assert stats["total_users"] == 3
assert stats["free"] == 2
assert stats["paid"] == 1
# bob (tier paid) + carol (credit) both count as paid-active.
assert stats["paid_active"] == 2
assert stats["credit_active"] == 1
assert stats["sync_enabled"] == 1
assert stats["referrals_total"] == 1
assert stats["referrals_converted"] == 1
def test_list_users(tmp_path):
factory = _seed(tmp_path)
from admin import queries
async def _go():
async with factory() as s:
return await queries.list_users(s)
rows, total = asyncio.run(_go())
assert total == 3
by_id = {r["id"]: r for r in rows}
assert by_id[2]["paid_active"] and by_id[2]["paid_source"] == "tier"
assert by_id[2]["on_stripe"] is True
assert by_id[3]["paid_active"] and by_id[3]["paid_source"] == "credit"
assert by_id[3]["has_sync"] is True
assert by_id[1]["referrals"] == 1
assert by_id[1]["paid_active"] is False
def test_list_users_search(tmp_path):
factory = _seed(tmp_path)
from admin import queries
async def _go():
async with factory() as s:
return await queries.list_users(s, q="bob")
rows, total = asyncio.run(_go())
assert total == 1 and rows[0]["email"] == "bob@x"
def test_user_detail(tmp_path):
factory = _seed(tmp_path)
from admin import queries
async def _go():
async with factory() as s:
return await queries.user_detail(s, 3)
d = asyncio.run(_go())
assert d is not None
assert d["user"].email == "carol@x"
assert d["paid"].active and d["paid"].source == "credit"
assert d["referred_by"]["email"] == "alice@x"
assert d["sync"]["enabled"] is True
assert d["feedback"].get("up") == 1
assert len(d["emails"]) == 1
assert len(d["acks"]) == 1
def test_user_detail_missing(tmp_path):
factory = _seed(tmp_path)
from admin import queries
async def _go():
async with factory() as s:
return await queries.user_detail(s, 999)
assert asyncio.run(_go()) is None
def test_db_stats_sqlite_fallback(tmp_path):
factory = _seed(tmp_path)
from admin import queries
async def _go():
async with factory() as s:
return await queries.db_stats(s)
stats = asyncio.run(_go())
names = {t["name"] for t in stats["tables"]}
assert "users" in names
users_row = next(t for t in stats["tables"] if t["name"] == "users")
assert users_row["rows"] == 3
# --- app wiring ------------------------------------------------------------
def _client(tmp_path, monkeypatch):
from fastapi.testclient import TestClient
from app.config import get_settings
_seed(tmp_path)
monkeypatch.setenv("ADMIN_CONSOLE_PASSWORD", "letmein")
get_settings.cache_clear()
from admin.main import app
return TestClient(app, follow_redirects=False)
def test_pages_require_login(tmp_path, monkeypatch):
client = _client(tmp_path, monkeypatch)
for path in ("/", "/users", "/users/1", "/db"):
r = client.get(path)
assert r.status_code == 303, path
assert r.headers["location"] == "/login"
get_settings_clear()
def test_login_flow(tmp_path, monkeypatch):
client = _client(tmp_path, monkeypatch)
# Wrong password → 401, no cookie.
r = client.post("/login", data={"password": "nope"})
assert r.status_code == 401
assert "admin_console_session" not in r.cookies
# Right password → 303 to / with a session cookie.
r = client.post("/login", data={"password": "letmein"})
assert r.status_code == 303
assert r.headers["location"] == "/"
cookie = r.cookies.get("admin_console_session")
assert cookie
# Authenticated pages now load.
r = client.get("/", cookies={"admin_console_session": cookie})
assert r.status_code == 200
assert "Overview" in r.text
r = client.get("/users", cookies={"admin_console_session": cookie})
assert r.status_code == 200
assert "carol@x" in r.text
r = client.get("/users/3", cookies={"admin_console_session": cookie})
assert r.status_code == 200
assert "carol@x" in r.text and "alice@x" in r.text
r = client.get("/db", cookies={"admin_console_session": cookie})
assert r.status_code == 200
assert "users" in r.text
get_settings_clear()
def test_healthz(tmp_path, monkeypatch):
client = _client(tmp_path, monkeypatch)
r = client.get("/healthz")
assert r.status_code == 200 and r.text == "ok"
get_settings_clear()
def get_settings_clear():
from app.config import get_settings
get_settings.cache_clear()

View file

@ -6,8 +6,6 @@ container. The parser-level tests are enough to catch the common
shapes: bad args, missing args, unknown sub-command.""" shapes: bad args, missing args, unknown sub-command."""
from __future__ import annotations from __future__ import annotations
import asyncio
import pytest import pytest
from app.cli import build_parser from app.cli import build_parser
@ -49,116 +47,3 @@ def test_unknown_command_rejected():
def test_no_command_rejected(): def test_no_command_rejected():
with pytest.raises(SystemExit): with pytest.raises(SystemExit):
build_parser().parse_args([]) build_parser().parse_args([])
# --- purge-test-users ------------------------------------------------------
def test_purge_parses_dry_run_default():
args = build_parser().parse_args(["purge-test-users"])
assert args.cmd == "purge-test-users"
assert args.commit is False
assert args.keep is None
def test_purge_parses_keep_and_commit():
args = build_parser().parse_args(
["purge-test-users", "--keep", "a@x", "--keep", "b@x", "--commit"])
assert args.keep == ["a@x", "b@x"]
assert args.commit is True
def _seed_users(tmp_path):
"""Two keepers + one throwaway with child rows across every table."""
from sqlalchemy.ext.asyncio import async_sessionmaker, create_async_engine
from app import db as db_mod
from app.db import Base, utcnow
from app.models import (
EmailOTP, EmailSend, PortfolioSync, Referral,
StrategicLogFeedback, User, UserAcknowledgement,
)
engine = create_async_engine(f"sqlite+aiosqlite:///{tmp_path}/cli.db")
factory = async_sessionmaker(engine, expire_on_commit=False)
db_mod._engine = engine
db_mod._session_factory = factory
now = utcnow()
async def _go():
async with engine.begin() as conn:
await conn.run_sync(Base.metadata.create_all)
async with factory() as s:
s.add(User(id=1, email="keep@real.com", tier="paid", created_at=now))
s.add(User(id=2, email="also@real.com", tier="free", created_at=now,
referred_by_user_id=3)) # referred by a victim
s.add(User(id=3, email="junk-123@gilest.ro", tier="free",
created_at=now))
await s.flush()
s.add(EmailSend(user_id=3, kind="daily", sent_at=now, status="sent"))
s.add(PortfolioSync(user_id=3, outer_ciphertext=b"x",
outer_nonce=b"y", version=1,
created_at=now, updated_at=now))
s.add(UserAcknowledgement(user_id=3, version=1, lang="en",
accepted_at=now))
s.add(StrategicLogFeedback(log_id=1, user_id=3, vote="up",
created_at=now, updated_at=now))
s.add(Referral(referrer_user_id=1, referred_user_id=3,
created_at=now))
s.add(EmailOTP(email="junk-123@gilest.ro", code_hash="h",
created_at=now, expires_at=now))
await s.commit()
asyncio.run(_go())
return factory
def _count(factory, model):
from sqlalchemy import func, select
async def _go():
async with factory() as s:
return (await s.execute(select(func.count()).select_from(model))).scalar()
return asyncio.run(_go())
def test_purge_dry_run_deletes_nothing(tmp_path):
from app.cli import purge_test_users
from app.models import User
factory = _seed_users(tmp_path)
rc = asyncio.run(purge_test_users(["keep@real.com", "also@real.com"],
commit=False))
assert rc == 0
assert _count(factory, User) == 3 # untouched
def test_purge_commit_removes_victim_and_children(tmp_path):
from sqlalchemy import select
from app.cli import purge_test_users
from app.models import (
EmailOTP, EmailSend, PortfolioSync, Referral,
StrategicLogFeedback, User, UserAcknowledgement,
)
factory = _seed_users(tmp_path)
rc = asyncio.run(purge_test_users(["keep@real.com", "also@real.com"],
commit=True))
assert rc == 0
# Only the two keepers remain.
async def _remaining():
async with factory() as s:
emails = (await s.execute(select(User.email).order_by(User.id))).scalars().all()
survivor = (await s.execute(
select(User.referred_by_user_id).where(User.id == 2))).scalar()
return emails, survivor
emails, survivor = asyncio.run(_remaining())
assert emails == ["keep@real.com", "also@real.com"]
# Self-ref FK to the deleted user was nulled, not left dangling.
assert survivor is None
# Every child table row for the victim is gone.
for model in (EmailSend, PortfolioSync, UserAcknowledgement,
StrategicLogFeedback, Referral, EmailOTP):
assert _count(factory, model) == 0, model.__name__

View file

@ -1,137 +0,0 @@
"""Tests for the public-landing locale loader and language detection."""
from __future__ import annotations
import pytest
from app.services.locales import (
ACTIVE_PUBLIC_LANGS,
DEFAULT_LANG,
detect_public_lang,
get_locale,
load_locales,
)
# ---------------------------------------------------------------------------
# YAML loading
# ---------------------------------------------------------------------------
def test_locales_load_all_active_languages():
"""Every code in ACTIVE_PUBLIC_LANGS must have a YAML file and
return a usable dict keeps deploys honest when a new language
is added to the list but the YAML is missing."""
load_locales()
for lang in ACTIVE_PUBLIC_LANGS:
t = get_locale(lang)
assert t, f"no copy loaded for {lang}"
def test_locale_dotted_access():
"""Templates use {{ t.hero.subhead }} syntax — the wrapper must
expose nested dotted access through the whole tree."""
t = get_locale("en")
assert isinstance(t.hero.tagline, str)
assert isinstance(t.features.news.title, str)
assert isinstance(t.not_strip.items, list)
assert t.not_strip.items[0] # non-empty
def test_unknown_locale_falls_back_to_default():
t = get_locale("zz")
# Same shape as the default — the lookup should hand back the
# default locale, not raise.
assert hasattr(t, "hero") or t == {}
def test_en_and_it_have_matching_top_level_keys():
"""Translation parity check — both languages must define the
same top-level sections. Lets a missing IT section show up in
CI rather than as a 500 on the live page."""
en_keys = set(iter(get_locale("en")))
it_keys = set(iter(get_locale("it")))
assert en_keys == it_keys
# ---------------------------------------------------------------------------
# detect_public_lang precedence chain
# ---------------------------------------------------------------------------
def test_detect_user_lang_wins_over_everything():
"""A logged-in user's stored preference is the highest-priority
signal never overridden by detection on a public surface."""
lang = detect_public_lang(
cookie_lang="it",
accept_language="fr,fr-FR;q=0.9",
cf_country="DE",
user_lang="en",
)
assert lang == "en"
def test_detect_cookie_wins_over_header_and_geo():
lang = detect_public_lang(
cookie_lang="it",
accept_language="en-US",
cf_country="DE",
user_lang=None,
)
assert lang == "it"
def test_detect_accept_language_first_subtag():
"""Accept-Language is parsed to its base subtag — 'en-GB' resolves
to 'en'. Falls through cookie (None) to header."""
lang = detect_public_lang(
cookie_lang=None,
accept_language="en-GB,en;q=0.9,it;q=0.5",
cf_country=None,
user_lang=None,
)
assert lang == "en"
def test_detect_geolocation_when_no_other_signal():
lang = detect_public_lang(
cookie_lang=None,
accept_language=None,
cf_country="IT",
user_lang=None,
)
assert lang == "it"
def test_detect_default_when_nothing_matches():
"""Falls through to DEFAULT_LANG when no signal returns a code
in ACTIVE_PUBLIC_LANGS. e.g. a French browser hitting from a
German IP neither lang is in our active set, default wins."""
lang = detect_public_lang(
cookie_lang=None,
accept_language="fr-FR,fr;q=0.9",
cf_country="DE",
user_lang=None,
)
assert lang == DEFAULT_LANG
def test_detect_invalid_cookie_ignored():
"""A cookie pointing at a language we don't support shouldn't
derail detection fall through to the next signal."""
lang = detect_public_lang(
cookie_lang="zz",
accept_language="it",
cf_country=None,
user_lang=None,
)
assert lang == "it"
def test_detect_empty_inputs_default():
lang = detect_public_lang(
cookie_lang=None,
accept_language=None,
cf_country=None,
user_lang=None,
)
assert lang == DEFAULT_LANG

View file

@ -61,7 +61,7 @@ def _mock_post(handler):
return httpx.MockTransport(handler) return httpx.MockTransport(handler)
def _configure(monkeypatch, *, portfolio_ai_enabled: bool = False): def _configure(monkeypatch):
"""Minimal env so call_llm believes a provider is configured. """Minimal env so call_llm believes a provider is configured.
Both review_read (which pins to OpenRouter for a non-thinking model) Both review_read (which pins to OpenRouter for a non-thinking model)
and the openrouter module itself read get_settings, so we patch and the openrouter module itself read get_settings, so we patch
@ -73,7 +73,6 @@ def _configure(monkeypatch, *, portfolio_ai_enabled: bool = False):
"DEEPSEEK_URL": "https://x/deepseek", "DEEPSEEK_MODEL": "deepseek-v4-flash", "DEEPSEEK_URL": "https://x/deepseek", "DEEPSEEK_MODEL": "deepseek-v4-flash",
"OPENROUTER_URL": "https://x/or", "OPENROUTER_MODEL": "deepseek/deepseek-v4-flash", "OPENROUTER_URL": "https://x/or", "OPENROUTER_MODEL": "deepseek/deepseek-v4-flash",
"REVIEWER_MODEL": "anthropic/claude-haiku-4.5", "REVIEWER_MODEL": "anthropic/claude-haiku-4.5",
"PORTFOLIO_AI_ENABLED": portfolio_ai_enabled,
})() })()
monkeypatch.setattr(ot, "get_settings", lambda: settings) monkeypatch.setattr(ot, "get_settings", lambda: settings)
monkeypatch.setattr(orr, "get_settings", lambda: settings) monkeypatch.setattr(orr, "get_settings", lambda: settings)
@ -171,263 +170,3 @@ async def test_review_failsafe_on_empty_candidate(monkeypatch):
v = await review_read(client, " ") v = await review_read(client, " ")
assert v.clean is False assert v.clean is False
assert calls == [] assert calls == []
# ---------------------------------------------------------------------------
# Compliance: deterministic lexicon layer (zero LLM cost)
# ---------------------------------------------------------------------------
@pytest.mark.asyncio
@pytest.mark.parametrize("candidate,rule_prefix", [
# Level triggers
("Brent close above $93 would confirm the safe-haven bid.", "lexicon:level_trigger"),
("Watching SOXX close above 570 to flip the read.", "lexicon:level_trigger"),
("If gold breaks below $4,500 the floor is broken.", "lexicon:level_trigger"),
# Targets
("Target of $95 looks reasonable into year-end.", "lexicon:forecast_phrase"),
# Action / advice (first-match-wins: "you should buy" → action_phrase,
# "we recommend trimming" → advice_phrase)
("You should buy the dip here.", "lexicon:action_phrase"),
("We recommend trimming this position.", "lexicon:advice_phrase"),
("Consider buying defensives into Q1.", "lexicon:advice_phrase"),
# Forecast register direct
("The price target sits well above current spot.", "lexicon:forecast_phrase"),
])
async def test_review_deterministic_layer_catches_obvious_violations(
monkeypatch, candidate, rule_prefix,
):
"""The deterministic layer short-circuits the LLM call entirely — the
handler must not be hit. Verdict carries layer='deterministic' and a
lexicon: reason prefix."""
_configure(monkeypatch)
calls = []
def handler(_req):
calls.append(1)
return httpx.Response(500, json={"error": "deterministic should have fired first"})
async with httpx.AsyncClient(transport=_mock_post(handler)) as client:
v = await review_read(client, candidate)
assert v.clean is False
assert v.layer == "deterministic"
assert v.reason.startswith(rule_prefix), v.reason
assert calls == [], "LLM must not be called on deterministic-layer hit"
@pytest.mark.asyncio
@pytest.mark.parametrize("candidate", [
# Plausible state-level commentary that must pass the deterministic
# layer (the LLM layer still runs and judges the nuance).
"Valuations are stretched after a sharp move higher.",
"Real yields are restrictive and credit is calm.",
"Brent is trading at $90 after a 12% drop YTD.",
# False-positive guards from the brief: bare 'should', 'cut', 'hold'
# in benign contexts must not trip the lexicon.
"Saudi price cuts pushed energy lower this week.",
"The cargo hold story dominated the rates tape.",
"Investors should be aware that liquidity is thin.",
])
async def test_review_deterministic_layer_lets_clean_state_through(
monkeypatch, candidate,
):
"""If the deterministic layer says nothing, the LLM layer runs. We mock
a CLEAN verdict so we can assert the deterministic gate did not pre-empt."""
_configure(monkeypatch)
def handler(_req):
return httpx.Response(200, json={
"choices": [{"message": {"content":
'{"clean": true, "reason": "state-level, fine"}'},
"finish_reason": "stop"}],
"usage": {"prompt_tokens": 50, "completion_tokens": 12, "cost": 0.00005},
})
async with httpx.AsyncClient(transport=_mock_post(handler)) as client:
v = await review_read(client, candidate)
# Some of these benign phrases may legitimately catch a lexicon rule
# we haven't tightened (e.g. "investors should be aware"); accept either
# an LLM-clean or a deterministic reject — what we forbid is silently
# claiming clean=True via the LLM layer when the deterministic layer
# caught the obvious cases above. The key invariant: if it passes, it's
# because the LLM said so, not because the lexicon was bypassed.
if v.layer == "llm":
assert v.clean is True
@pytest.mark.asyncio
async def test_review_llm_forward_state_as_description(monkeypatch):
"""The sharpened prompt asks the LLM to reject 'state + direction'
constructions like 'valuations are stretched and unlikely to hold'.
We can only verify the wiring: that the LLM response flows through.
Catching that specific phrasing in production depends on the model."""
_configure(monkeypatch)
def handler(_req):
return httpx.Response(200, json={
"choices": [{"message": {"content":
'{"clean": false, "reason": "forward state-as-description"}'},
"finish_reason": "stop"}],
"usage": {"prompt_tokens": 50, "completion_tokens": 14, "cost": 0.00007},
})
async with httpx.AsyncClient(transport=_mock_post(handler)) as client:
v = await review_read(
client,
"Valuations are stretched and unlikely to hold under current policy.",
)
assert v.clean is False
assert v.layer == "llm"
assert "forward" in v.reason.lower() or "state" in v.reason.lower()
@pytest.mark.asyncio
async def test_review_portfolio_rider_gated_off_by_default(monkeypatch):
"""With PORTFOLIO_AI_ENABLED=False, the portfolio rider must NOT be
appended even when the caller passes surface='portfolio'. We assert
this by hitting a phrase the rider would normally exempt; without the
rider the LLM still gets the base rules and we simulate a reject."""
_configure(monkeypatch, portfolio_ai_enabled=False)
seen_systems: list[str] = []
def handler(req):
body = req.content.decode("utf-8")
seen_systems.append(body)
return httpx.Response(200, json={
"choices": [{"message": {"content":
'{"clean": false, "reason": "base rule"}'},
"finish_reason": "stop"}],
"usage": {"prompt_tokens": 50, "completion_tokens": 8, "cost": 0.00003},
})
async with httpx.AsyncClient(transport=_mock_post(handler)) as client:
v = await review_read(
client,
"Portfolio shows high concentration in single names.",
surface="portfolio",
)
assert v.clean is False
# Surface rider text appears in the system prompt only when the flag is
# on; with it off, the rider must be absent.
assert "# Surface: portfolio commentary" not in seen_systems[0]
@pytest.mark.asyncio
async def test_review_portfolio_rider_active_when_flag_enabled(monkeypatch):
"""With PORTFOLIO_AI_ENABLED=True, surface='portfolio' attaches the
rider. Verified by inspecting the outbound system-prompt body."""
_configure(monkeypatch, portfolio_ai_enabled=True)
seen_systems: list[str] = []
def handler(req):
seen_systems.append(req.content.decode("utf-8"))
return httpx.Response(200, json={
"choices": [{"message": {"content":
'{"clean": true, "reason": "portfolio fine"}'},
"finish_reason": "stop"}],
"usage": {"prompt_tokens": 50, "completion_tokens": 8, "cost": 0.00003},
})
async with httpx.AsyncClient(transport=_mock_post(handler)) as client:
await review_read(
client,
"Portfolio shows high concentration in single names.",
surface="portfolio",
)
assert "# Surface: portfolio commentary" in seen_systems[0]
# ---------------------------------------------------------------------------
# Reviewer self-score (0-10)
# ---------------------------------------------------------------------------
@pytest.mark.asyncio
async def test_review_parses_score_from_llm_json(monkeypatch):
_configure(monkeypatch)
def handler(_req):
return httpx.Response(200, json={
"choices": [{"message": {"content":
'{"clean": true, "reason": "exemplary", "score": 9}'},
"finish_reason": "stop"}],
"usage": {"prompt_tokens": 50, "completion_tokens": 12, "cost": 0.00007},
})
async with httpx.AsyncClient(transport=_mock_post(handler)) as client:
v = await review_read(client, "Markets pricing tighter policy.")
assert v.clean is True
assert v.score == 9
assert v.layer == "llm"
@pytest.mark.asyncio
async def test_review_score_clamped_to_0_10(monkeypatch):
"""A model returning 17 or -3 is buggy but must not blow up — clamp."""
_configure(monkeypatch)
def handler(_req):
return httpx.Response(200, json={
"choices": [{"message": {"content":
'{"clean": false, "reason": "x", "score": 17}'},
"finish_reason": "stop"}],
"usage": {"prompt_tokens": 50, "completion_tokens": 8, "cost": 0.00003},
})
async with httpx.AsyncClient(transport=_mock_post(handler)) as client:
v = await review_read(client, "Some content.")
assert v.score == 10
@pytest.mark.asyncio
async def test_review_score_negative_clamped_to_0(monkeypatch):
_configure(monkeypatch)
def handler(_req):
return httpx.Response(200, json={
"choices": [{"message": {"content":
'{"clean": false, "reason": "x", "score": -3}'},
"finish_reason": "stop"}],
"usage": {"prompt_tokens": 50, "completion_tokens": 8, "cost": 0.00003},
})
async with httpx.AsyncClient(transport=_mock_post(handler)) as client:
v = await review_read(client, "Some content.")
assert v.score == 0
@pytest.mark.asyncio
async def test_review_missing_score_yields_none(monkeypatch):
"""Older mocked responses don't carry score; verdict still valid,
score is None."""
_configure(monkeypatch)
def handler(_req):
return httpx.Response(200, json={
"choices": [{"message": {"content":
'{"clean": true, "reason": "ok"}'},
"finish_reason": "stop"}],
"usage": {"prompt_tokens": 50, "completion_tokens": 6, "cost": 0.00002},
})
async with httpx.AsyncClient(transport=_mock_post(handler)) as client:
v = await review_read(client, "Plain state-level prose.")
assert v.clean is True
assert v.score is None
@pytest.mark.asyncio
async def test_review_score_non_numeric_yields_none(monkeypatch):
"""Defensive: a string or null in the score field doesn't poison the
verdict; score becomes None."""
_configure(monkeypatch)
def handler(_req):
return httpx.Response(200, json={
"choices": [{"message": {"content":
'{"clean": true, "reason": "ok", "score": "high"}'},
"finish_reason": "stop"}],
"usage": {"prompt_tokens": 50, "completion_tokens": 6, "cost": 0.00002},
})
async with httpx.AsyncClient(transport=_mock_post(handler)) as client:
v = await review_read(client, "Plain state-level prose.")
assert v.clean is True
assert v.score is None
@pytest.mark.asyncio
async def test_review_deterministic_layer_score_is_zero(monkeypatch):
"""A deterministic-layer hit is a hard reject by rule; the audit row
carries score=0 (no nuance to score)."""
_configure(monkeypatch)
calls = []
def handler(_req):
calls.append(1)
return httpx.Response(500, json={"error": "should not fire"})
async with httpx.AsyncClient(transport=_mock_post(handler)) as client:
v = await review_read(client, "You should buy the dip.")
assert v.clean is False
assert v.layer == "deterministic"
assert v.score == 0
assert calls == []

View file

@ -1,78 +0,0 @@
"""Tests for the deterministic regex/lexicon pre-check.
These are pure-function tests for ``app.services.output_review_lexicon.check``
no LLM, no DB, no fixtures. They protect both the obvious-rejects and
the false-positive guards (bare 'should', 'cut', 'hold' must not match in
benign contexts)."""
from __future__ import annotations
import pytest
from app.services.output_review_lexicon import check
# ---------------------------------------------------------------------------
# Obvious rejects — should hit and identify the firing rule
# ---------------------------------------------------------------------------
@pytest.mark.parametrize("text,expected_rule", [
# First-match-wins ordering: action_phrase runs before advice_phrase, so
# a sentence with both ("you should buy") fires action_phrase. That's
# fine — the goal is to catch a violation, not to attribute the rule.
("You should buy the dip.", "action_phrase"),
("Investors should consider buying defensives.", "advice_phrase"),
("We recommend trimming this position.", "advice_phrase"),
("That stock is a buy at these levels.", "action_phrase"),
("Take profit on the position.", "action_phrase"),
("Trim your exposure to growth.", "action_phrase"),
("Rotate into defensives.", "action_phrase"),
("Overweight the sector into Q1.", "action_phrase"),
# Forecast / level register
("Our price target sits at $95.", "forecast_phrase"),
("Target of $93 looks reasonable.", "forecast_phrase"),
("There is support at $4,200.", "forecast_phrase"),
("Resistance near $570 is the level to watch.", "forecast_phrase"),
# Composed patterns
("Brent close above $93 would confirm.", "level_trigger"),
("If SOXX breaks below 570 the bid fades.", "level_trigger"),
("Watch for a move above 4,600.", "level_trigger"),
("Floor at $90 looks intact.", "forecast_phrase"),
# "X as a floor / ceiling" phrasing — not currently a hard rule in the
# lexicon (LLM layer catches it); see future-tightening note in the
# module docstring.
])
def test_lexicon_catches_obvious_violations(text, expected_rule):
hit = check(text)
assert hit is not None, f"should have flagged: {text!r}"
assert hit.rule == expected_rule, (
f"expected rule {expected_rule!r}, got {hit.rule!r} for {text!r}"
)
assert hit.snippet, "snippet should never be empty"
# ---------------------------------------------------------------------------
# False-positive guards from the brief — must NOT match
# ---------------------------------------------------------------------------
@pytest.mark.parametrize("text", [
# The brief specifically calls these out as false-positive risks for
# bare-word matchers.
"Saudi price cuts pushed energy lower this week.",
"The cargo hold story dominated the tape.",
"OPEC may hold output steady at the next meeting.",
# State-level commentary the LLM layer should judge, not the lexicon.
"Valuations are stretched after the rally.",
"Real yields are restrictive across the curve.",
"Positioning is crowded in megacap tech.",
# Plain factual price citation — no trigger framing.
"Brent is trading at $90, down 12% YTD.",
"Gold is at $4,600 after a sharp move higher.",
# Empty / whitespace
"",
" ",
])
def test_lexicon_lets_clean_text_through(text):
hit = check(text)
assert hit is None, f"lexicon false-positive on: {text!r} (rule={hit and hit.rule})"

View file

@ -1,388 +0,0 @@
"""Sign-up acknowledgement: the affirmative checkbox at /login.
Covers:
- POST /login without the box ticked 400, form re-rendered with the error.
- New email with box ticked User row + UserAcknowledgement row at the
current version, in the language the user actually saw.
- Existing user with a current-version ack row POST succeeds, no duplicate.
- Existing user with only an older-version ack row new row at current.
- has_acknowledged_current() unit tests.
"""
from __future__ import annotations
import asyncio
def _build(tmp_path):
"""Spin up a fresh app + sqlite DB + tables. Returns (TestClient, factory).
Patches otp_service and email send into no-ops so POST /login can complete
without hitting SMTP. The acknowledgement is captured during POST /login
(before OTP), so /verify never needs to be exercised here. Static files
are mounted because the rejection path re-renders login.html which
references ``url_for('static', ...)``."""
from pathlib import Path
from fastapi import FastAPI
from fastapi.staticfiles import StaticFiles
from fastapi.testclient import TestClient
from sqlalchemy.ext.asyncio import async_sessionmaker, create_async_engine
from app import db as db_mod
from app.db import Base
import app.models # noqa: F401 — registers tables
from app.routers import auth as auth_router
engine = create_async_engine(f"sqlite+aiosqlite:///{tmp_path}/ack.db")
factory = async_sessionmaker(engine, expire_on_commit=False)
db_mod._engine = engine
db_mod._session_factory = factory
async def _create_all():
async with engine.begin() as conn:
await conn.run_sync(Base.metadata.create_all)
asyncio.run(_create_all())
app = FastAPI()
app.include_router(auth_router.router)
static_dir = Path(__file__).resolve().parent.parent / "app" / "static"
app.mount("/static", StaticFiles(directory=str(static_dir)), name="static")
return TestClient(app), factory
def _patch_otp_email(monkeypatch):
"""Stub OTP issuance and email send so POST /login can run end-to-end
without a Redis-backed OTP service or real SMTP."""
from app.services import otp_service
from app.routers import auth as auth_router
async def _allowed(*_a, **_kw):
return (True, 0)
async def _issue(*_a, **_kw):
return "123456"
async def _send_ok(*_a, **_kw):
return True
monkeypatch.setattr(otp_service, "can_request_new", _allowed)
monkeypatch.setattr(otp_service, "issue", _issue)
# _issue_and_send_otp lives on the router and wraps the email send;
# easier to stub the whole helper than to thread through email_service.
monkeypatch.setattr(auth_router, "_issue_and_send_otp", _send_ok)
async def _count_acks(factory, user_id: int, version: int | None = None) -> int:
from sqlalchemy import select, func
from app.models import UserAcknowledgement
async with factory() as s:
q = select(func.count()).select_from(UserAcknowledgement).where(
UserAcknowledgement.user_id == user_id,
)
if version is not None:
q = q.where(UserAcknowledgement.version == version)
return (await s.execute(q)).scalar() or 0
# ---------------------------------------------------------------------------
# POST /login validation: missing-checkbox path
# ---------------------------------------------------------------------------
def test_post_login_rejects_when_acknowledged_unchecked(tmp_path, monkeypatch):
_patch_otp_email(monkeypatch)
client, _ = _build(tmp_path)
r = client.post(
"/login",
data={"email": "alice@example.com", "next": "/", "lang": "en"},
follow_redirects=False,
)
assert r.status_code == 400
# The localised error message goes back in the rendered template.
assert "tick the box" in r.text.lower() or "confirm" in r.text.lower()
def test_post_login_rejection_preserves_email(tmp_path, monkeypatch):
_patch_otp_email(monkeypatch)
client, _ = _build(tmp_path)
r = client.post(
"/login",
data={"email": "alice@example.com", "next": "/", "lang": "en"},
follow_redirects=False,
)
assert r.status_code == 400
assert "alice@example.com" in r.text
def test_post_login_localises_error_in_italian(tmp_path, monkeypatch):
_patch_otp_email(monkeypatch)
client, _ = _build(tmp_path)
r = client.post(
"/login",
data={"email": "anna@example.com", "next": "/", "lang": "it"},
follow_redirects=False,
)
assert r.status_code == 400
# IT error: "Spunta la casella per confermare prima di continuare."
assert "spunta la casella" in r.text.lower() or "confermare" in r.text.lower()
# ---------------------------------------------------------------------------
# Successful POST /login: writes User + UserAcknowledgement
# ---------------------------------------------------------------------------
def test_new_signup_writes_acknowledgement_row(tmp_path, monkeypatch):
_patch_otp_email(monkeypatch)
client, factory = _build(tmp_path)
r = client.post(
"/login",
data={
"email": "alice@example.com",
"next": "/",
"lang": "en",
"acknowledged": "on",
"ack_version": "1",
},
follow_redirects=False,
)
# 303 → /verify
assert r.status_code == 303
assert r.headers["location"].startswith("/verify")
# Find the new user and assert exactly one acknowledgement row at v1.
from app.models import User, UserAcknowledgement
from sqlalchemy import select
async def _check():
async with factory() as s:
user = (await s.execute(
select(User).where(User.email == "alice@example.com")
)).scalar_one()
rows = (await s.execute(
select(UserAcknowledgement).where(
UserAcknowledgement.user_id == user.id,
)
)).scalars().all()
return user, rows
user, rows = asyncio.run(_check())
assert len(rows) == 1
ack = rows[0]
assert ack.version == 1
assert ack.lang == "en"
assert ack.accepted_at is not None
def test_acknowledgement_records_displayed_language(tmp_path, monkeypatch):
_patch_otp_email(monkeypatch)
client, factory = _build(tmp_path)
r = client.post(
"/login",
data={
"email": "anna@example.it",
"next": "/",
"lang": "it",
"acknowledged": "on",
"ack_version": "1",
},
follow_redirects=False,
)
assert r.status_code == 303
from app.models import User, UserAcknowledgement
from sqlalchemy import select
async def _check():
async with factory() as s:
user = (await s.execute(
select(User).where(User.email == "anna@example.it")
)).scalar_one()
ack = (await s.execute(
select(UserAcknowledgement).where(
UserAcknowledgement.user_id == user.id,
)
)).scalar_one()
return ack.lang
assert asyncio.run(_check()) == "it"
# ---------------------------------------------------------------------------
# Idempotency: existing user already at current version → no dup row
# ---------------------------------------------------------------------------
def test_existing_user_current_version_no_duplicate(tmp_path, monkeypatch):
_patch_otp_email(monkeypatch)
client, factory = _build(tmp_path)
# Pre-seed: User + one acknowledgement at the current version.
async def _seed():
from app.models import User, UserAcknowledgement
from app.legal import ACKNOWLEDGEMENT_VERSION
from app.db import utcnow
async with factory() as s:
u = User(email="repeat@example.com", tier="free",
settings_json={}, created_at=utcnow())
s.add(u)
await s.commit()
await s.refresh(u)
s.add(UserAcknowledgement(
user_id=u.id,
version=ACKNOWLEDGEMENT_VERSION,
lang="en",
accepted_at=utcnow(),
))
await s.commit()
return u.id
user_id = asyncio.run(_seed())
before = asyncio.run(_count_acks(factory, user_id))
assert before == 1
r = client.post(
"/login",
data={
"email": "repeat@example.com",
"next": "/",
"lang": "en",
"acknowledged": "on",
"ack_version": "1",
},
follow_redirects=False,
)
assert r.status_code == 303
after = asyncio.run(_count_acks(factory, user_id))
assert after == 1, "must not write a duplicate row when user already at current version"
# ---------------------------------------------------------------------------
# Version bump: existing user only at older version → new current-version row
# ---------------------------------------------------------------------------
def test_existing_user_older_version_writes_new_current_row(tmp_path, monkeypatch):
_patch_otp_email(monkeypatch)
client, factory = _build(tmp_path)
# Pre-seed a user with an OLD-version acknowledgement (version=0).
# The current version constant is 1 → this user is "stale" and should
# be prompted again.
async def _seed():
from app.models import User, UserAcknowledgement
from app.db import utcnow
async with factory() as s:
u = User(email="bump@example.com", tier="free",
settings_json={}, created_at=utcnow())
s.add(u)
await s.commit()
await s.refresh(u)
s.add(UserAcknowledgement(
user_id=u.id, version=0, lang="en", accepted_at=utcnow(),
))
await s.commit()
return u.id
user_id = asyncio.run(_seed())
r = client.post(
"/login",
data={
"email": "bump@example.com",
"next": "/",
"lang": "en",
"acknowledged": "on",
"ack_version": "1",
},
follow_redirects=False,
)
assert r.status_code == 303
# Total rows: 1 old + 1 new = 2. Current-version rows: exactly 1.
total = asyncio.run(_count_acks(factory, user_id))
current = asyncio.run(_count_acks(factory, user_id, version=1))
assert total == 2
assert current == 1
# ---------------------------------------------------------------------------
# has_acknowledged_current() — unit-ish, no HTTP
# ---------------------------------------------------------------------------
def test_has_acknowledged_current_no_row(tmp_path):
_, factory = _build(tmp_path)
async def _go():
from app.models import User
from app.services.auth_service import has_acknowledged_current
from app.db import utcnow
async with factory() as s:
u = User(email="empty@example.com", tier="free",
settings_json={}, created_at=utcnow())
s.add(u)
await s.commit()
await s.refresh(u)
return await has_acknowledged_current(s, u)
assert asyncio.run(_go()) is False
def test_has_acknowledged_current_only_old(tmp_path):
_, factory = _build(tmp_path)
async def _go():
from app.models import User, UserAcknowledgement
from app.services.auth_service import has_acknowledged_current
from app.db import utcnow
async with factory() as s:
u = User(email="oldonly@example.com", tier="free",
settings_json={}, created_at=utcnow())
s.add(u)
await s.commit()
await s.refresh(u)
s.add(UserAcknowledgement(
user_id=u.id, version=0, lang="en", accepted_at=utcnow(),
))
await s.commit()
return await has_acknowledged_current(s, u)
assert asyncio.run(_go()) is False
def test_has_acknowledged_current_at_current(tmp_path):
_, factory = _build(tmp_path)
async def _go():
from app.models import User, UserAcknowledgement
from app.services.auth_service import has_acknowledged_current
from app.legal import ACKNOWLEDGEMENT_VERSION
from app.db import utcnow
async with factory() as s:
u = User(email="atcurrent@example.com", tier="free",
settings_json={}, created_at=utcnow())
s.add(u)
await s.commit()
await s.refresh(u)
s.add(UserAcknowledgement(
user_id=u.id,
version=ACKNOWLEDGEMENT_VERSION,
lang="en",
accepted_at=utcnow(),
))
await s.commit()
return await has_acknowledged_current(s, u)
assert asyncio.run(_go()) is True

View file

@ -1,186 +0,0 @@
"""Strategic-log feedback service + token helpers.
Covers the pure service path (set_vote, get_counts, clear) plus the
token sign/verify round-trip. The web endpoint POST /api/log/{id}/feedback
is covered in tests/test_api_feedback.py (separate file because it
needs the full FastAPI + auth stack)."""
from __future__ import annotations
import asyncio
def _build_db(tmp_path):
from sqlalchemy.ext.asyncio import async_sessionmaker, create_async_engine
from app import db as db_mod
from app.db import Base
import app.models # noqa: F401
engine = create_async_engine(f"sqlite+aiosqlite:///{tmp_path}/fb.db")
factory = async_sessionmaker(engine, expire_on_commit=False)
db_mod._engine = engine
db_mod._session_factory = factory
async def _seed():
from app.models import StrategicLog, User
from app.db import utcnow
async with engine.begin() as conn:
await conn.run_sync(Base.metadata.create_all)
async with factory() as s:
s.add(User(id=1, email="alice@example.com", tier="free",
settings_json={}, created_at=utcnow()))
s.add(User(id=2, email="bob@example.com", tier="free",
settings_json={}, created_at=utcnow()))
s.add(StrategicLog(
id=10, generated_at=utcnow(),
model="m", anchor_date=None, prompt_version=1,
tone="INTERMEDIATE", analysis="DRY",
content="x", prompt_tokens=1, completion_tokens=1,
cost_usd=0.0,
))
await s.commit()
asyncio.run(_seed())
return factory
# ---------------------------------------------------------------------------
# Service: set_vote / get_counts
# ---------------------------------------------------------------------------
def test_set_vote_up_writes_one_row(tmp_path):
factory = _build_db(tmp_path)
async def _go():
from app.services.log_feedback import set_vote
async with factory() as s:
counts = await set_vote(s, log_id=10, user_id=1, vote="up")
return counts
counts = asyncio.run(_go())
assert counts.up == 1
assert counts.down == 0
assert counts.user_vote == "up"
def test_set_vote_flip_replaces_existing(tmp_path):
"""Voting again with a different value flips the existing row;
no duplicate is written, the up/down counts swap."""
factory = _build_db(tmp_path)
async def _go():
from app.services.log_feedback import set_vote
async with factory() as s:
await set_vote(s, log_id=10, user_id=1, vote="up")
return await set_vote(s, log_id=10, user_id=1, vote="down")
counts = asyncio.run(_go())
assert counts.up == 0
assert counts.down == 1
assert counts.user_vote == "down"
def test_set_vote_clear_removes_row(tmp_path):
factory = _build_db(tmp_path)
async def _go():
from app.services.log_feedback import set_vote, get_counts
async with factory() as s:
await set_vote(s, log_id=10, user_id=1, vote="up")
await set_vote(s, log_id=10, user_id=1, vote="clear")
return await get_counts(s, log_id=10, user_id=1)
counts = asyncio.run(_go())
assert counts.up == 0
assert counts.down == 0
assert counts.user_vote is None
def test_aggregate_counts_across_users(tmp_path):
"""Two distinct users vote — counts aggregate; each user's own_vote
field reflects only their own row."""
factory = _build_db(tmp_path)
async def _go():
from app.services.log_feedback import set_vote, get_counts
async with factory() as s:
await set_vote(s, log_id=10, user_id=1, vote="up")
await set_vote(s, log_id=10, user_id=2, vote="down")
alice_view = await get_counts(s, log_id=10, user_id=1)
bob_view = await get_counts(s, log_id=10, user_id=2)
return alice_view, bob_view
alice, bob = asyncio.run(_go())
assert alice.up == 1 and alice.down == 1 and alice.user_vote == "up"
assert bob.up == 1 and bob.down == 1 and bob.user_vote == "down"
def test_invalid_vote_rejected(tmp_path):
factory = _build_db(tmp_path)
async def _go():
from app.services.log_feedback import FeedbackError, set_vote
async with factory() as s:
try:
await set_vote(s, log_id=10, user_id=1, vote="meh")
except FeedbackError as e:
return str(e)
return "no error"
msg = asyncio.run(_go())
assert "up" in msg.lower() and "down" in msg.lower()
# ---------------------------------------------------------------------------
# Token sign / verify
# ---------------------------------------------------------------------------
def test_feedback_token_round_trips(monkeypatch):
"""A signed token decodes back to the same (user, log, vote) tuple."""
monkeypatch.setenv("CASSANDRA_SESSION_SECRET", "test-secret-32-chars-long-okay")
from app.config import get_settings
get_settings.cache_clear()
from app.services.log_feedback import sign_feedback_token, verify_feedback_token
tok = sign_feedback_token(user_id=42, log_id=99, vote="up")
payload = verify_feedback_token(tok)
assert payload == {"user_id": 42, "log_id": 99, "vote": "up"}
def test_feedback_token_tampered_returns_none(monkeypatch):
monkeypatch.setenv("CASSANDRA_SESSION_SECRET", "test-secret-32-chars-long-okay")
from app.config import get_settings
get_settings.cache_clear()
from app.services.log_feedback import sign_feedback_token, verify_feedback_token
tok = sign_feedback_token(user_id=42, log_id=99, vote="up")
tampered = tok[:-1] + ("a" if tok[-1] != "a" else "b")
assert verify_feedback_token(tampered) is None
def test_feedback_token_garbage_returns_none(monkeypatch):
monkeypatch.setenv("CASSANDRA_SESSION_SECRET", "test-secret-32-chars-long-okay")
from app.config import get_settings
get_settings.cache_clear()
from app.services.log_feedback import verify_feedback_token
assert verify_feedback_token("not.a.real.token") is None
assert verify_feedback_token("") is None
def test_feedback_token_clear_is_not_a_valid_email_link_vote(monkeypatch):
"""The 'clear' sentinel is a web-only path; the email link can only
apply a positive vote (up or down). Trying to sign 'clear' raises."""
monkeypatch.setenv("CASSANDRA_SESSION_SECRET", "test-secret-32-chars-long-okay")
from app.config import get_settings
get_settings.cache_clear()
from app.services.log_feedback import FeedbackError, sign_feedback_token
try:
sign_feedback_token(user_id=1, log_id=10, vote="clear")
except FeedbackError:
return
raise AssertionError("expected FeedbackError")

View file

@ -341,131 +341,6 @@ def test_subscription_active_grants_paid(tmp_path):
assert asyncio.run(_check_tier()) == "paid" assert asyncio.run(_check_tier()) == "paid"
# --- pause / resume --------------------------------------------------------
def _activate(client, *, customer="cus_p", subscription="sub_p", evt="evt_a"):
"""Link user 1 to a Stripe customer and put them on paid."""
return _post_webhook(client, body={
"id": evt,
"type": "checkout.session.completed",
"data": {"object": {
"client_reference_id": "1",
"customer": customer,
"subscription": subscription,
}},
})
def _tier_and_sub(factory):
async def _check():
from sqlalchemy import select
from app.models import User
async with factory() as session:
u = (await session.execute(
select(User).where(User.id == 1)
)).scalar_one()
return u.tier, u.stripe_subscription_id
return asyncio.run(_check())
def test_subscription_paused_drops_tier(tmp_path):
"""status=paused means Stripe has stopped collecting (trial ended
with no usable card). Paid features must come off otherwise the
customer keeps everything for free."""
client, factory, _ = _build_app(tmp_path)
_activate(client)
assert _tier_and_sub(factory)[0] == "paid"
r = _post_webhook(client, body={
"id": "evt_paused",
"type": "customer.subscription.paused",
"data": {"object": {
"id": "sub_p", "customer": "cus_p", "status": "paused",
}},
})
assert r.status_code == 200, r.text
assert r.json()["status"] == "ok", "must not fall through to 'ignored'"
tier, sub = _tier_and_sub(factory)
assert tier == "free"
# The subscription still exists at Stripe and resumes under the same
# id, so we keep our link to it.
assert sub == "sub_p"
def test_subscription_resumed_regrants_tier(tmp_path):
client, factory, _ = _build_app(tmp_path)
_activate(client)
_post_webhook(client, body={
"id": "evt_paused2",
"type": "customer.subscription.paused",
"data": {"object": {
"id": "sub_p", "customer": "cus_p", "status": "paused",
}},
})
assert _tier_and_sub(factory)[0] == "free"
r = _post_webhook(client, body={
"id": "evt_resumed",
"type": "customer.subscription.resumed",
"data": {"object": {
"id": "sub_p", "customer": "cus_p", "status": "active",
}},
})
assert r.status_code == 200, r.text
assert r.json()["status"] == "ok"
assert _tier_and_sub(factory) == ("paid", "sub_p")
def test_pause_collection_drops_tier_despite_active_status(tmp_path):
"""The portal's pause uses `pause_collection` and leaves status as
`active`, so the status check alone would keep the user on paid while
Stripe bills them nothing."""
client, factory, _ = _build_app(tmp_path)
_activate(client)
assert _tier_and_sub(factory)[0] == "paid"
r = _post_webhook(client, body={
"id": "evt_pause_coll",
"type": "customer.subscription.updated",
"data": {"object": {
"id": "sub_p",
"customer": "cus_p",
"status": "active",
"pause_collection": {"behavior": "void"},
}},
})
assert r.status_code == 200, r.text
assert _tier_and_sub(factory) == ("free", "sub_p")
def test_unpause_collection_regrants_tier(tmp_path):
"""Resuming collection sends subscription.updated with
pause_collection cleared to null that must grant paid back."""
client, factory, _ = _build_app(tmp_path)
_activate(client)
_post_webhook(client, body={
"id": "evt_pc_on",
"type": "customer.subscription.updated",
"data": {"object": {
"id": "sub_p", "customer": "cus_p", "status": "active",
"pause_collection": {"behavior": "void"},
}},
})
assert _tier_and_sub(factory)[0] == "free"
_post_webhook(client, body={
"id": "evt_pc_off",
"type": "customer.subscription.updated",
"data": {"object": {
"id": "sub_p", "customer": "cus_p", "status": "active",
"pause_collection": None,
}},
})
assert _tier_and_sub(factory) == ("paid", "sub_p")
# --- idempotency + unknown ------------------------------------------------ # --- idempotency + unknown ------------------------------------------------
@ -590,93 +465,50 @@ def test_checkout_endpoint_requires_login(tmp_path):
assert r.status_code == 401, r.text assert r.status_code == 401, r.text
def test_checkout_never_passes_currency(tmp_path): def test_checkout_passes_sniffed_currency_for_new_customer(tmp_path):
"""Every checkout bills the Price's base currency (GBP), whatever the """First-time buyer (no stripe_customer_id yet) gets the currency
visitor's geo headers say. sniffed from the request. CF-IPCountry=US 'usd', and Stripe will
look up the USD currency_option on the Price."""
/pricing renders "£7" and "£70" as static copy, so selecting a
`currency_options` rate would show one price and charge another
a US visitor saw £7 and was billed $9.99. Regression guard: if
geo-pricing is ever reinstated, the pricing page must become
currency-aware in the same change.
"""
client, _, session_cookie = _build_app(tmp_path)
seen = []
def asserter(params):
seen.append(params)
assert "currency" not in params, (
"no currency may be sent — /pricing advertises GBP only"
)
for headers in (
{"cf-ipcountry": "US"},
{"cf-ipcountry": "DE"},
{"accept-language": "en-US,en;q=0.5"},
{},
):
with patch("app.routers.stripe_billing._stripe_client",
return_value=_fake_checkout_client(asserter)):
r = client.post(
"/api/stripe/checkout",
json={"cadence": "monthly"},
cookies={"cassandra_session": session_cookie},
headers=headers,
)
assert r.status_code == 200, r.text
assert len(seen) == 4
def test_checkout_rejects_currency_in_body(tmp_path):
"""The `currency` field is gone from CheckoutRequest. A client that
still sends one must not silently get GBP under a USD label the
extra key is simply ignored by pydantic, so assert the call is still
currency-free rather than trusting the caller."""
client, _, session_cookie = _build_app(tmp_path) client, _, session_cookie = _build_app(tmp_path)
def asserter(params): def asserter(params):
assert "currency" not in params assert params["currency"] == "usd"
with patch("app.routers.stripe_billing._stripe_client", with patch("app.routers.stripe_billing._stripe_client",
return_value=_fake_checkout_client(asserter)): return_value=_fake_checkout_client(asserter)):
r = client.post( r = client.post(
"/api/stripe/checkout", "/api/stripe/checkout",
json={"cadence": "monthly", "currency": "usd"}, json={"cadence": "monthly"},
cookies={"cassandra_session": session_cookie}, cookies={"cassandra_session": session_cookie},
headers={"cf-ipcountry": "US"},
) )
assert r.status_code == 200, r.text assert r.status_code == 200, r.text
def test_checkout_requires_billing_address(tmp_path): def test_checkout_body_currency_overrides_sniff(tmp_path):
"""Every checkout must collect a billing address, so each Stripe """Explicit `currency` in the request body beats header sniffing —
Customer ends up with a country. EU B2C digital-services VAT is due lets a UK-based buyer choose EUR if they want to."""
at the consumer's place of supply, and the card's billing country is
the evidence for that an IP guess is not."""
client, _, session_cookie = _build_app(tmp_path) client, _, session_cookie = _build_app(tmp_path)
def asserter(params): def asserter(params):
assert params["billing_address_collection"] == "required" assert params["currency"] == "eur"
# New customer (no stored customer id): customer_update is only
# valid alongside `customer`, so it must be absent here.
assert "customer_update" not in params
for cadence in ("monthly", "annual"): with patch("app.routers.stripe_billing._stripe_client",
with patch("app.routers.stripe_billing._stripe_client", return_value=_fake_checkout_client(asserter)):
return_value=_fake_checkout_client(asserter)): r = client.post(
r = client.post( "/api/stripe/checkout",
"/api/stripe/checkout", json={"cadence": "monthly", "currency": "eur"},
json={"cadence": cadence}, cookies={"cassandra_session": session_cookie},
cookies={"cassandra_session": session_cookie}, headers={"cf-ipcountry": "GB"},
) )
assert r.status_code == 200, r.text assert r.status_code == 200, r.text
def test_checkout_uses_existing_customer_ref(tmp_path): def test_checkout_omits_currency_for_existing_customer(tmp_path):
"""Existing customer: use the stored `customer` ref rather than """Existing customer: Stripe locked their currency at first
`customer_email`, so repeat checkouts don't mint duplicate Stripe checkout, so passing `currency` again would error. Verify we omit
customers.""" it (and also use the existing `customer` ref instead of
customer_email)."""
import asyncio import asyncio
from app.models import User from app.models import User
@ -692,20 +524,36 @@ def test_checkout_uses_existing_customer_ref(tmp_path):
asyncio.run(_link()) asyncio.run(_link())
def asserter(params): def asserter(params):
assert "currency" not in params assert "currency" not in params, (
"currency must not be passed once a customer exists — "
"Stripe rejects mismatches against the locked customer currency"
)
assert params["customer"] == "cus_existing_xxxxxxxxxxxxxx" assert params["customer"] == "cus_existing_xxxxxxxxxxxxxx"
assert "customer_email" not in params
# Without customer_update.address the collected address is
# attached to the payment only and the Customer record keeps a
# null address — i.e. still no country for the VAT question.
assert params["customer_update"] == {"address": "auto"}
with patch("app.routers.stripe_billing._stripe_client", with patch("app.routers.stripe_billing._stripe_client",
return_value=_fake_checkout_client(asserter)): return_value=_fake_checkout_client(asserter)):
r = client.post( r = client.post(
"/api/stripe/checkout", "/api/stripe/checkout",
json={"cadence": "monthly"}, json={"cadence": "monthly", "currency": "usd"},
cookies={"cassandra_session": session_cookie}, cookies={"cassandra_session": session_cookie},
headers={"cf-ipcountry": "US"}, headers={"cf-ipcountry": "US"},
) )
assert r.status_code == 200, r.text assert r.status_code == 200, r.text
def test_sniff_currency_fallback_chain():
"""Unit-test the header-sniffing helper: CF country wins, then
Accept-Language exact, then language-only, then GBP default."""
from types import SimpleNamespace
from app.routers.stripe_billing import _sniff_currency
def _req(headers):
return SimpleNamespace(headers=headers)
assert _sniff_currency(_req({"cf-ipcountry": "DE"})) == "eur"
assert _sniff_currency(_req({"cf-ipcountry": "us"})) == "usd" # case-insensitive
assert _sniff_currency(_req({"accept-language": "fr-FR,fr;q=0.9"})) == "eur"
assert _sniff_currency(_req({"accept-language": "en-US,en;q=0.5"})) == "usd"
assert _sniff_currency(_req({"accept-language": "ja,ja-JP;q=0.5"})) == "gbp"
assert _sniff_currency(_req({})) == "gbp"