diff --git a/.env.example b/.env.example index 889bd4d..f2a593b 100644 --- a/.env.example +++ b/.env.example @@ -16,6 +16,13 @@ OPENROUTER_API_KEY= # OpenRouter (AI log generation) # --- App --- CASSANDRA_TOKEN= # Bearer token required if set; LAN-only no-auth if empty 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 `). 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_ANCHOR_DATE=2026-03-04 # YYYY-MM-DD; used by market_pulse anchor column CASSANDRA_MOCK=0 # 1 = serve canned fixtures, skip live APIs @@ -25,3 +32,11 @@ OPENROUTER_MODEL=deepseek/deepseek-v4-flash # cheap & fast; swap to anthropic OPENROUTER_MONTHLY_CAP_USD=20 CASSANDRA_TONE=INTERMEDIATE # NOVICE | INTERMEDIATE | PRO CASSANDRA_ANALYSIS=SPECULATIVE # DRY | SPECULATIVE + +# --- Compliance feature flags (default false = compliance-safe) --- +# See docs/read-markets-compliance-changes.md. Code paths stay in the tree; +# flip a flag to reactivate. +PORTFOLIO_AI_ENABLED=false # /api/analyze + dashboard AI read + reviewer portfolio rider +PORTFOLIO_SYNC_ENABLED=false # /api/portfolio/sync* + cloud-sync UI + PortfolioSync writes +TICKER_UNIVERSE_AGGREGATE_ENABLED=false # ticker_universe buffer/flush/upsert writes (server-learns-nothing) +SUBSCRIPTIONS_ENABLED=false # Stripe checkout/webhook/portal + /pricing + paid-tier gating diff --git a/.gitignore b/.gitignore index 168165b..e9eb31d 100644 --- a/.gitignore +++ b/.gitignore @@ -9,8 +9,10 @@ __pycache__/ .ruff_cache/ .venv/ venv/ -backup/*.sql -backup/*.sql.gz +# Everything under backup/ is operational data, never source: DB dumps and +# pre-change .env copies (which hold live Stripe/SMTP secrets). The earlier +# backup/*.sql* patterns missed the .env copies — ignore the whole directory. +backup/ *.egg-info/ build/ dist/ diff --git a/Dockerfile b/Dockerfile index 09c6443..94b8042 100644 --- a/Dockerfile +++ b/Dockerfile @@ -32,10 +32,12 @@ RUN apt-get update \ COPY --from=builder /opt/venv /opt/venv WORKDIR /app COPY app ./app +COPY admin ./admin COPY alembic ./alembic COPY alembic.ini ./ -# Default command is the web app; scheduler container overrides via `command:`. +# Default command is the web app; the scheduler and admin-console containers +# override via `command:` (see docker-compose.yml). EXPOSE 8000 CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000", "--workers", "1"] @@ -57,6 +59,7 @@ COPY --from=builder /opt/venv /opt/venv WORKDIR /app COPY pyproject.toml requirements.lock ./ COPY app ./app +COPY admin ./admin COPY alembic ./alembic COPY alembic.ini ./ # tests/ is excluded by .dockerignore (prod-correct: never bake tests into diff --git a/admin/README.md b/admin/README.md new file mode 100644 index 0000000..3f1213c --- /dev/null +++ b/admin/README.md @@ -0,0 +1,54 @@ +# 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 . +- **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 +``` diff --git a/admin/__init__.py b/admin/__init__.py new file mode 100644 index 0000000..5f9b1c7 --- /dev/null +++ b/admin/__init__.py @@ -0,0 +1,9 @@ +"""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. +""" diff --git a/admin/auth.py b/admin/auth.py new file mode 100644 index 0000000..a85067b --- /dev/null +++ b/admin/auth.py @@ -0,0 +1,71 @@ +"""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"}, + ) diff --git a/admin/main.py b/admin/main.py new file mode 100644 index 0000000..324dd72 --- /dev/null +++ b/admin/main.py @@ -0,0 +1,140 @@ +"""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}) diff --git a/admin/queries.py b/admin/queries.py new file mode 100644 index 0000000..d2c9fec --- /dev/null +++ b/admin/queries.py @@ -0,0 +1,266 @@ +"""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} diff --git a/admin/templates/base.html b/admin/templates/base.html new file mode 100644 index 0000000..003e163 --- /dev/null +++ b/admin/templates/base.html @@ -0,0 +1,80 @@ + + + + + + + {% block title %}superadmin{% endblock %} · read.markets + + + + {% if show_nav|default(true) %} +
+ read.markets · superadmin + + + Log out +
+ {% endif %} +
{% block body %}{% endblock %}
+ + diff --git a/admin/templates/db.html b/admin/templates/db.html new file mode 100644 index 0000000..7ccde4c --- /dev/null +++ b/admin/templates/db.html @@ -0,0 +1,31 @@ +{% extends "base.html" %} +{% block title %}database{% endblock %} +{% block body %} +

Database usage

+
+
{{ stats.total_bytes|bytes }}
Total size
+
{{ "{:,}".format(stats.total_rows) }}
Total rows (est.)
+
{{ stats.tables|length }}
Tables
+
+ +

Per table

+ + + + {% for t in stats.tables %} + + + + + + + + + {% endfor %} + +
TableRows (est.)DataIndexTotalShare
{{ t.name }}{{ "{:,}".format(t.rows) }}{{ t.data_bytes|bytes }}{{ t.index_bytes|bytes }}{{ t.total_bytes|bytes }} +
+ {{ t.pct }}% +
+

Row counts are the storage engine's estimate for InnoDB; sizes are exact on-disk bytes.

+{% endblock %} diff --git a/admin/templates/login.html b/admin/templates/login.html new file mode 100644 index 0000000..aff4c50 --- /dev/null +++ b/admin/templates/login.html @@ -0,0 +1,14 @@ +{% extends "base.html" %} +{% set show_nav = false %} +{% block title %}login{% endblock %} +{% block body %} + +{% endblock %} diff --git a/admin/templates/not_found.html b/admin/templates/not_found.html new file mode 100644 index 0000000..6e41006 --- /dev/null +++ b/admin/templates/not_found.html @@ -0,0 +1,6 @@ +{% extends "base.html" %} +{% block title %}not found{% endblock %} +{% block body %} +

User #{{ user_id }} not found

+

← back to users

+{% endblock %} diff --git a/admin/templates/overview.html b/admin/templates/overview.html new file mode 100644 index 0000000..06f3a0a --- /dev/null +++ b/admin/templates/overview.html @@ -0,0 +1,22 @@ +{% extends "base.html" %} +{% block title %}overview{% endblock %} +{% block body %} +

Overview

+
+
{{ stats.total_users }}
Total users
+
{{ stats.paid_active }}
Paid-active
+
{{ stats.free }}
Free tier
+
{{ stats.paid }}
Paid tier
+
{{ stats.enterprise }}
Enterprise
+
{{ stats.credit_active }}
On credit
+
{{ stats.signups_7d }}
Signups · 7d
+
{{ stats.signups_30d }}
Signups · 30d
+
{{ stats.active_30d }}
Active · 30d
+
{{ stats.sync_enabled }}
Cloud-sync on
+
{{ stats.referrals_converted }}/{{ stats.referrals_total }}
Referrals conv.
+
+ +

Newest users

+{% include "partials_users_table.html" %} +

All users →

+{% endblock %} diff --git a/admin/templates/partials_users_table.html b/admin/templates/partials_users_table.html new file mode 100644 index 0000000..5525464 --- /dev/null +++ b/admin/templates/partials_users_table.html @@ -0,0 +1,39 @@ +{# expects `recent` (overview) or `rows` (users list) — normalise to `rows` #} +{% set rows = rows if rows is defined else recent %} + + + + + + + + + {% for u in rows %} + + + + + + + + + + + + + {% else %} + + {% endfor %} + +
IDEmailTierPaidCreatedLast loginLangSyncRefsBilling
{{ u.id }}{{ u.email }}{{ u.tier }} + {% if u.paid_active %} + yes{% if u.paid_source == 'credit' %} · {{ u.credit_days }}d{% endif %} + {% else %} + no + {% endif %} + {% if u.trialing %}trial{% endif %} + {{ u.created_at|dt }}{{ u.last_login_at|dt }}{{ u.lang }}{% if u.has_sync %}on{% else %}{% endif %}{{ u.referrals }} + {% if u.on_stripe %}stripe{% endif %} + {% if u.on_polar %}polar{% endif %} + {% if not u.on_stripe and not u.on_polar %}{% endif %} +
No users.
diff --git a/admin/templates/user_detail.html b/admin/templates/user_detail.html new file mode 100644 index 0000000..715c56f --- /dev/null +++ b/admin/templates/user_detail.html @@ -0,0 +1,73 @@ +{% extends "base.html" %} +{% set u = d.user %} +{% block title %}{{ u.email }}{% endblock %} +{% block body %} +

← users

+

{{ u.email }} #{{ u.id }}

+ +

Account

+
+
Tier
{{ u.tier }}
+
Paid status
+ {% if d.paid.active %} + active via {{ d.paid.source }} + {% if d.paid.source == 'credit' %}· expires {{ d.paid.expires_at|dt }} ({{ d.paid.days_remaining }}d){% endif %} + {% else %}inactive{% endif %} +
+
Credit until
{{ u.credit_until|dt }}
+
Created
{{ u.created_at|dt }}
+
Last login
{{ u.last_login_at|dt }}
+
Language
{{ u.lang }}
+
Digest opt-in
{{ 'yes' if u.email_digest_opt_in else 'no' }}{% if u.digest_tone %} · {{ u.digest_tone }}{% endif %}
+
Referral code
{{ u.referral_code or '—' }}
+
Referred by
{% if d.referred_by %}{{ d.referred_by.email }}{% else %}—{% endif %}
+
+ +

Billing linkage

+
+
Stripe customer
{{ u.stripe_customer_id or '—' }}
+
Stripe subscription
{{ u.stripe_subscription_id or '—' }}
+
Stripe trial ends
{{ u.stripe_trial_end_at|dt }}
+
Polar customer
{{ u.polar_customer_id or '—' }}
+
Polar subscription
{{ u.polar_subscription_id or '—' }}
+
+ +

Cloud sync

+{% if d.sync.enabled %} +

enabled · v{{ d.sync.version }} · updated {{ d.sync.updated_at|dt }} + (contents are end-to-end encrypted — not readable here)

+{% else %}

Not enabled.

{% endif %} + +

Referrals sent ({{ d.referrals_sent|length }})

+{% if d.referrals_sent %} + + {% for r in d.referrals_sent %} + + + {% endfor %} +
ReferredSentConverted
{{ r.referred_email }}{{ r.created_at|dt }}{% if r.converted_at %}{{ r.converted_at|dt }}{% else %}pending{% endif %}
+{% else %}

None.

{% endif %} + +

Feedback votes

+

👍 {{ d.feedback.get('up', 0) }} · 👎 {{ d.feedback.get('down', 0) }}

+ +

Digest emails (last 25)

+{% if d.emails %} + + {% for e in d.emails %} + + + + {% endfor %} +
KindSentStatusError
{{ e.kind }}{{ e.sent_at|dt }}{% if e.status == 'sent' %}sent{% elif e.status == 'error' %}error{% else %}{{ e.status }}{% endif %}{{ e.error or '' }}
+{% else %}

None.

{% endif %} + +

Legal acknowledgements

+{% if d.acks %} + + {% for a in d.acks %} + + {% endfor %} +
VersionLangAccepted
v{{ a.version }}{{ a.lang }}{{ a.accepted_at|dt }}
+{% else %}

None recorded.

{% endif %} +{% endblock %} diff --git a/admin/templates/users.html b/admin/templates/users.html new file mode 100644 index 0000000..72bde22 --- /dev/null +++ b/admin/templates/users.html @@ -0,0 +1,20 @@ +{% extends "base.html" %} +{% block title %}users{% endblock %} +{% block body %} +

Users ({{ total }})

+ +{% include "partials_users_table.html" %} + +{% set pages = (total // per_page) + (1 if total % per_page else 0) %} +{% if pages > 1 %} +

+ Page {{ page }} / {{ pages }} + {% if page > 1 %}· prev{% endif %} + {% if page < pages %}· next{% endif %} +

+{% endif %} +{% endblock %} diff --git a/alembic/versions/0026_compliance_purge_and_audit.py b/alembic/versions/0026_compliance_purge_and_audit.py new file mode 100644 index 0000000..1738fcf --- /dev/null +++ b/alembic/versions/0026_compliance_purge_and_audit.py @@ -0,0 +1,75 @@ +"""compliance purge (portfolio_sync, ticker_universe) + reviewer_verdicts audit table. + +Revision ID: 0026 +Revises: 0025 +Create Date: 2026-05-29 + +See docs/read-markets-compliance-changes.md. + +Two unrelated changes bundled into one migration because they ship together: + +1. Purge — the server learns nothing about anyone's holdings while the + compliance flags are off. Empties ``portfolio_sync`` (per-user encrypted + ciphertext blobs) and ``ticker_universe`` (the anonymous aggregate set of + tickers ever uploaded). Tables stay; data goes. If a flag is later + re-enabled, the tables refill from scratch. + +2. Audit — adds ``reviewer_verdicts`` so every output-reviewer decision + (deterministic + LLM, pass and fail) is persisted with surface, candidate + text, reason, layer, and model. This is the regulator-facing evidence that + automated review runs on every published item. + +Downgrade restores neither the purged data nor the historical verdicts — +both are destructive; downgrade just drops the audit table. +""" +from typing import Sequence, Union + +import sqlalchemy as sa +from alembic import op + + +revision: str = "0026" +down_revision: Union[str, None] = "0025" +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + # 1. Purge — server holds no portfolio data. + op.execute("DELETE FROM portfolio_sync") + op.execute("DELETE FROM ticker_universe") + + # 2. Audit trail for the two-layer output reviewer. Append-only. + op.create_table( + "reviewer_verdicts", + sa.Column("id", sa.BigInteger().with_variant(sa.Integer(), "sqlite"), + primary_key=True, autoincrement=True), + sa.Column("created_at", sa.DateTime(timezone=True), nullable=False, + server_default=sa.text("CURRENT_TIMESTAMP")), + sa.Column("surface", sa.String(length=32), nullable=True), + sa.Column("candidate_text", sa.Text(), nullable=False), + sa.Column("clean", sa.Boolean(), nullable=False), + sa.Column("reason", sa.String(length=255), nullable=True), + sa.Column("layer", sa.String(length=16), nullable=False), + sa.Column("model", sa.String(length=64), nullable=True), + ) + op.create_index( + "ix_reviewer_verdicts_created_at", + "reviewer_verdicts", ["created_at"], + ) + op.create_index( + "ix_reviewer_verdicts_surface", + "reviewer_verdicts", ["surface"], + ) + op.create_index( + "ix_reviewer_verdicts_clean", + "reviewer_verdicts", ["clean"], + ) + + +def downgrade() -> None: + op.drop_index("ix_reviewer_verdicts_clean", table_name="reviewer_verdicts") + op.drop_index("ix_reviewer_verdicts_surface", table_name="reviewer_verdicts") + op.drop_index("ix_reviewer_verdicts_created_at", table_name="reviewer_verdicts") + op.drop_table("reviewer_verdicts") + # Purges are not restored on downgrade — the data is gone. diff --git a/alembic/versions/0027_user_acknowledgements.py b/alembic/versions/0027_user_acknowledgements.py new file mode 100644 index 0000000..7332ee2 --- /dev/null +++ b/alembic/versions/0027_user_acknowledgements.py @@ -0,0 +1,61 @@ +"""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") diff --git a/alembic/versions/0028_log_feedback_and_reviewer_score.py b/alembic/versions/0028_log_feedback_and_reviewer_score.py new file mode 100644 index 0000000..ac4cd7f --- /dev/null +++ b/alembic/versions/0028_log_feedback_and_reviewer_score.py @@ -0,0 +1,93 @@ +"""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") diff --git a/app/cli.py b/app/cli.py index c780f0b..8e546e7 100644 --- a/app/cli.py +++ b/app/cli.py @@ -146,6 +146,90 @@ async def send_test_digest(email: str, kind: str) -> int: 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: p = argparse.ArgumentParser(prog="app.cli", description="Cassandra admin CLI") sub = p.add_subparsers(dest="cmd", required=True) @@ -165,6 +249,15 @@ def build_parser() -> argparse.ArgumentParser: t.add_argument("email") 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 @@ -181,6 +274,9 @@ async def _dispatch(args) -> int: return await show_status(args.email) if args.cmd == "send-test-digest": 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 finally: await get_engine().dispose() diff --git a/app/config.py b/app/config.py index 6c5b0ee..07052d5 100644 --- a/app/config.py +++ b/app/config.py @@ -50,6 +50,17 @@ class Settings(BaseSettings): # created. Phase A leaves this open so the operator can self-onboard. 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 # are written to stdout instead of sent — convenient for local dev. SMTP_SERVER: str = "" @@ -105,6 +116,25 @@ class Settings(BaseSettings): STRIPE_PRICE_MONTHLY: str = "" # price_xxx for £7/month subscription STRIPE_PRICE_ANNUAL: str = "" # price_xxx for £70/year subscription + # Compliance feature flags. All default False so a fresh deploy is on the + # compliance-safe side. Code paths stay in the tree — flip a flag to + # reactivate. See docs/read-markets-compliance-changes.md. + # + # PORTFOLIO_AI_ENABLED — gates /api/analyze, portfolio_analysis.analyse(), + # the AI-read UI on the dashboard, and the output-reviewer portfolio rider. + # PORTFOLIO_SYNC_ENABLED — gates /api/portfolio/sync* routes, portfolio_sync + # service, the cloud-sync UI/JS, and PortfolioSync writes. + # TICKER_UNIVERSE_AGGREGATE_ENABLED — gates server-side per-ticker aggregate + # union writes (ticker_universe.buffer/flush/upsert). When off, the server + # learns nothing about what anyone holds. + # SUBSCRIPTIONS_ENABLED — gates Stripe checkout/webhook/portal, the /pricing + # page, and is_paid_active(). When off, is_paid_active() returns True for + # every logged-in user (free-for-all while subscriptions are paused). + PORTFOLIO_AI_ENABLED: bool = False + PORTFOLIO_SYNC_ENABLED: bool = False + TICKER_UNIVERSE_AGGREGATE_ENABLED: bool = False + SUBSCRIPTIONS_ENABLED: bool = False + # Config file locations (overridable for tests) BASELINE_TOML: Path = Field(default_factory=lambda: CONFIG_DIR / "default.toml") PORTFOLIO_TOML: Path = Field(default_factory=lambda: CONFIG_DIR / "portfolio.toml") diff --git a/app/jobs/ai_log_job.py b/app/jobs/ai_log_job.py index 197faa5..ed1b5e0 100644 --- a/app/jobs/ai_log_job.py +++ b/app/jobs/ai_log_job.py @@ -206,7 +206,8 @@ async def run() -> None: # that drifted past the generator's system prompt. Drop # rejected variants; the API falls back to the previous # clean StrategicLog row. - verdict = await review_read(client, result.content) + verdict = await review_read(client, result.content, + surface="log", session=session) full_cost = (result.cost_usd or 0.0) + (verdict.cost_usd or 0.0) if not verdict.clean: session.add(AICall( @@ -233,6 +234,7 @@ async def run() -> None: prompt_tokens=result.prompt_tokens, completion_tokens=result.completion_tokens, cost_usd=full_cost, + reviewer_score=verdict.score, ) session.add(slog) session.add(AICall( diff --git a/app/jobs/email_digest_job.py b/app/jobs/email_digest_job.py index 4cbd865..ea20688 100644 --- a/app/jobs/email_digest_job.py +++ b/app/jobs/email_digest_job.py @@ -198,17 +198,32 @@ def _pick_variant( async def _send_one(user: User, kind: str, content_html: str, date_str: str, - session) -> None: + session, *, latest_log_id: int | None = None) -> None: settings_url = f"{branding.SITE_URL}/settings" unsubscribe_url = ( f"{branding.SITE_URL}/email/unsubscribe" 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( kind=kind, date_str=date_str, content_html=content_html, unsubscribe_url=unsubscribe_url, settings_url=settings_url, + feedback_up_url=feedback_up_url, + feedback_down_url=feedback_down_url, ) try: await send_email(to=user.email, subject=subject, @@ -288,6 +303,18 @@ async def run() -> None: 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 for u in fresh: tone = (u.digest_tone or "INTERMEDIATE").upper() @@ -296,7 +323,8 @@ async def run() -> None: tone=tone, 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) written += 1 diff --git a/app/jobs/indicator_summary_job.py b/app/jobs/indicator_summary_job.py index 422c49c..3d401d5 100644 --- a/app/jobs/indicator_summary_job.py +++ b/app/jobs/indicator_summary_job.py @@ -184,7 +184,8 @@ async def _generate_one( )) return None - verdict = await review_read(client, candidate) + verdict = await review_read(client, candidate, + surface="indicator", session=session) if not verdict.clean: # Reviewer caught scratchpad / meta-commentary / partial text # INSIDE the read field. Drop the candidate; the previous good @@ -214,6 +215,7 @@ async def _generate_one( # Include the reviewer's cost in the row's recorded spend so the # monthly budget tracking covers the full pipeline cost. cost_usd=(result.cost_usd or 0.0) + (verdict.cost_usd or 0.0), + reviewer_score=verdict.score, ) session.add(summary) session.add(AICall( @@ -314,7 +316,10 @@ async def run() -> None: cost_usd=result.cost_usd, status="leaked", )) else: - verdict = await review_read(client, candidate) + verdict = await review_read( + client, candidate, + surface="indicator_aggregate", session=session, + ) full_cost = (result.cost_usd or 0.0) + (verdict.cost_usd or 0.0) if not verdict.clean: log.warning("ind_summary.agg_reviewer_rejected", @@ -338,6 +343,7 @@ async def run() -> None: prompt_tokens=result.prompt_tokens, completion_tokens=result.completion_tokens, cost_usd=full_cost, + reviewer_score=verdict.score, ) session.add(agg_summary) session.add(AICall( diff --git a/app/legal.py b/app/legal.py new file mode 100644 index 0000000..94f27f3 --- /dev/null +++ b/app/legal.py @@ -0,0 +1,19 @@ +"""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 diff --git a/app/locales/en.yaml b/app/locales/en.yaml new file mode 100644 index 0000000..caef603 --- /dev/null +++ b/app/locales/en.yaml @@ -0,0 +1,134 @@ +# English copy for the public landing page. +# +# Strings that contain inline HTML are rendered with the `safe` filter +# in the template. Keep markup minimal — , , and the +# brand placeholders only. + +hero: + tagline: "Understand markets. Don't gamble on them." + subhead: >- + Built for investors who want to act rationally 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 means, 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 + rational drivers (earnings, policy, valuation) + from irrational ones (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 English and + Italian. Toggle the language pill in the header + and the panels refresh in place; ticker symbols, currency codes + and numbers stay verbatim across languages. + +more_views: + head: "More views" + 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 Terms and + Privacy notice, and confirm you've read + the financial disclaimer. + +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" diff --git a/app/locales/it.yaml b/app/locales/it.yaml new file mode 100644 index 0000000..34f9098 --- /dev/null +++ b/app/locales/it.yaml @@ -0,0 +1,140 @@ +# 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 ragionare con + lucidità 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 + significa, 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 + driver razionali (utili, politica, valutazione) + dagli irrazionali (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 + Inglese e Italiano. Usa il + selettore di lingua nell'header e i pannelli si aggiornano in + tempo reale; simboli ticker, codici valuta e numeri restano + invariati tra le lingue. + +more_views: + 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 Termini e + l'Informativa privacy, e confermi di aver + letto il disclaimer finanziario. + +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" diff --git a/app/main.py b/app/main.py index 7f1729f..148f5dc 100644 --- a/app/main.py +++ b/app/main.py @@ -66,6 +66,12 @@ async def lifespan(app: FastAPI): async with get_session_factory()() as session: inserted = await bootstrap_feeds(session) 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 log.info("cassandra.shutdown") diff --git a/app/models.py b/app/models.py index 57c9f19..d8d3c25 100644 --- a/app/models.py +++ b/app/models.py @@ -118,6 +118,10 @@ class StrategicLog(Base): prompt_tokens: Mapped[int | None] = mapped_column(Integer) completion_tokens: Mapped[int | None] = mapped_column(Integer) 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): @@ -170,6 +174,8 @@ class IndicatorSummary(Base): prompt_tokens: Mapped[int | None] = mapped_column(Integer) completion_tokens: Mapped[int | None] = mapped_column(Integer) 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"),) @@ -220,6 +226,100 @@ class AICall(Base): error: Mapped[str | None] = mapped_column(String(512)) +class ReviewerVerdict(Base): + """Append-only audit log of every output-reviewer verdict. + + Both layers (deterministic lexicon + LLM) write here, pass and fail. + 'Here is a complete log showing fail-closed automated review on every + published item' is the regulator-facing answer; this table is what backs + that claim. See app/services/output_review.py.""" + __tablename__ = "reviewer_verdicts" + id: Mapped[int] = mapped_column(_PK, primary_key=True, autoincrement=True) + created_at: Mapped[datetime] = mapped_column( + DateTime(timezone=True), default=utcnow, index=True, + ) + # Caller-supplied tag — "log", "indicator", "indicator_aggregate", "chat", + # "portfolio", "digest", or None for unattributed. + surface: Mapped[str | None] = mapped_column(String(32), index=True) + # The candidate text being reviewed. Truncated upstream to avoid pathological + # writes; Text type accepts whatever fits. + candidate_text: Mapped[str] = mapped_column(Text, nullable=False) + clean: Mapped[bool] = mapped_column(Boolean, nullable=False, index=True) + reason: Mapped[str | None] = mapped_column(String(255)) + # Which layer fired: "deterministic" | "llm" | "error". + layer: Mapped[str] = mapped_column(String(16), nullable=False) + # LLM-layer model id, nullable for deterministic / error rows. + model: Mapped[str | None] = mapped_column(String(64)) + # 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 — # holdings live in the browser, the server stores only the anonymous # ticker universe + public market data. diff --git a/app/routers/api.py b/app/routers/api.py index 5075654..bf78eb0 100644 --- a/app/routers/api.py +++ b/app/routers/api.py @@ -285,11 +285,13 @@ async def news_list( def _log_partial_payload( row: StrategicLog | None, content_override: str | None = None, + feedback: object | None = None, ) -> dict | None: if row is None: return None content = content_override if content_override is not None else row.content return { + "id": row.id, "content_html": _md_to_html(content), "generated_at": row.generated_at, "model": row.model, @@ -299,6 +301,7 @@ def _log_partial_payload( "cost_usd": row.cost_usd, "prompt_tokens": row.prompt_tokens, "completion_tokens": row.completion_tokens, + "feedback": feedback, } @@ -404,9 +407,12 @@ async def log_latest( if as_ == "html": content_override = await _localized_content(session, row, principal) + feedback = await _feedback_for(session, row, principal) return templates.TemplateResponse( request, "partials/log.html", - {"log": _log_partial_payload(row, content_override=content_override), + {"log": _log_partial_payload( + row, content_override=content_override, feedback=feedback, + ), "tone": wanted_tone, "paid": not free_only}, ) @@ -415,6 +421,21 @@ async def log_latest( 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}") async def log_by_date( request: Request, @@ -459,9 +480,12 @@ async def log_by_date( if as_ == "html": content_override = await _localized_content(session, row, principal) + feedback = await _feedback_for(session, row, principal) return templates.TemplateResponse( request, "partials/log.html", - {"log": _log_partial_payload(row, content_override=content_override), + {"log": _log_partial_payload( + row, content_override=content_override, feedback=feedback, + ), "tone": wanted_tone, "paid": not free_only}, ) if row is None: @@ -469,6 +493,53 @@ async def log_by_date( 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 -------------------------------------------------------- diff --git a/app/routers/auth.py b/app/routers/auth.py index 28a7d4d..aa7b036 100644 --- a/app/routers/auth.py +++ b/app/routers/auth.py @@ -34,13 +34,38 @@ from app.auth import ( ) from app.config import get_settings from app.db import get_session, utcnow +from app.legal import ACKNOWLEDGEMENT_VERSION from app.logging import get_logger -from app.services.auth_service import AuthError, get_or_create_user, get_user +from app.services.auth_service import ( + AuthError, + get_or_create_user, + get_user, + has_acknowledged_current, + record_acknowledgement, +) from app.services import otp_service, referral_service 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 +_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") router = APIRouter(tags=["auth"]) @@ -111,6 +136,7 @@ async def login_page( next: str | None = None, error: str | None = None, ref: str | None = None, + lang: str | None = None, session: AsyncSession = Depends(get_session), ): # If a valid referral code is supplied, surface a small "invited" @@ -121,15 +147,27 @@ async def login_page( await referral_service.lookup_referrer(session, ref_norm) if ref_norm else None ) - return templates.TemplateResponse( + resolved_lang = _resolve_login_lang(request, lang) + response = templates.TemplateResponse( request, "login.html", { "next_path": _safe_next(next), "error": error, "ref": ref_norm if referrer else 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") @@ -138,6 +176,9 @@ async def login_submit( email: str = Form(...), next: 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), ): s = get_settings() @@ -150,6 +191,27 @@ async def login_submit( 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 # capture window). Cleanest way: probe for existence first. from app.services.auth_service import get_user_by_email @@ -164,7 +226,9 @@ async def login_submit( request, "login.html", {"next_path": _safe_next(next), "error": str(e), "email": email, "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, ) @@ -175,6 +239,15 @@ async def login_submit( if was_new and referrer is not None: 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 # last 60s we just reuse the existing one (silently) to avoid # spamming the user's inbox on a refreshed form submit. diff --git a/app/routers/chat.py b/app/routers/chat.py index 6e12a8e..4c921b0 100644 --- a/app/routers/chat.py +++ b/app/routers/chat.py @@ -188,7 +188,8 @@ async def chat( # leading question; the generator's system prompt forbids it, # but the reviewer is the enforcement layer. ~1-2 s extra # latency per turn on top of the generation call. - verdict = await review_read(client, result.content) + verdict = await review_read(client, result.content, + surface="chat", session=session) except Exception as e: session.add(AICall( model=s.OPENROUTER_MODEL, status="error", error=str(e)[:500], diff --git a/app/routers/pages.py b/app/routers/pages.py index 1801f93..4be5276 100644 --- a/app/routers/pages.py +++ b/app/routers/pages.py @@ -13,9 +13,44 @@ from app.config import get_settings, load_groups from app.db import get_session from app.models import EmailSend, Referral, StrategicLog, User 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.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 # dual-purpose: logged-in users see the dashboard, logged-out visitors see # the landing page. @@ -27,11 +62,19 @@ async def root_page( request: Request, cu: CurrentUser | None = Depends(maybe_current_user), ): - """Dual-purpose root: dashboard when authenticated, landing otherwise.""" + """Dual-purpose root: dashboard when authenticated, 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: - return templates.TemplateResponse( - request, "landing.html", {"cu": None}, + lang = 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, ) + return RedirectResponse(url=f"/{lang}/", status_code=302) s = get_settings() groups = load_groups(s.BASELINE_TOML, s.PORTFOLIO_TOML) return templates.TemplateResponse( @@ -42,6 +85,27 @@ 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( "/news", response_class=HTMLResponse, @@ -112,6 +176,60 @@ 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) async def settings_page( request: Request, diff --git a/app/routers/public.py b/app/routers/public.py index 33bd245..661fadf 100644 --- a/app/routers/public.py +++ b/app/routers/public.py @@ -16,6 +16,7 @@ from fastapi.responses import HTMLResponse from app.auth import CurrentUser, maybe_current_user from app.services.access import is_paid_active +from app.services.feature_flags import require_flag from app.templates_env import templates @@ -29,7 +30,11 @@ def _ctx(request: Request, cu: CurrentUser | None) -> dict: return {"cu": cu} -@router.get("/pricing", response_class=HTMLResponse) +@router.get( + "/pricing", + response_class=HTMLResponse, + dependencies=[Depends(require_flag("SUBSCRIPTIONS_ENABLED"))], +) async def pricing_page( request: Request, cu: CurrentUser | None = Depends(maybe_current_user), diff --git a/app/routers/stripe_billing.py b/app/routers/stripe_billing.py index bfdeed0..30ab079 100644 --- a/app/routers/stripe_billing.py +++ b/app/routers/stripe_billing.py @@ -19,7 +19,7 @@ from __future__ import annotations import asyncio import json -from typing import Any, Literal, Optional +from typing import Any, Literal import stripe from fastapi import APIRouter, Body, Depends, HTTPException, Request @@ -34,10 +34,16 @@ from app.config import get_settings from app.db import get_session, utcnow from app.logging import get_logger from app.models import StripeEvent, User +from app.services.feature_flags import require_flag log = get_logger("stripe_billing") -router = APIRouter() +# Whole router gated by SUBSCRIPTIONS_ENABLED: checkout, portal, and webhook +# all 404 when the subscription system is paused. The webhook gate keeps us +# from accidentally processing a late delivery while the surface is "off". +router = APIRouter( + dependencies=[Depends(require_flag("SUBSCRIPTIONS_ENABLED"))], +) # Cap stored payload at 16 KiB so a hostile (or buggy) sender can't @@ -69,51 +75,21 @@ def _price_for(cadence: str) -> str: raise HTTPException(status_code=400, detail="cadence must be 'monthly' or 'annual'") -# Rough country → currency mapping. Covers the markets we have a stated -# rate for; everything else falls back to GBP (the home currency) and -# Stripe handles the FX at checkout. Configure the per-currency -# unit_amount on each Price's `currency_options` in the Stripe Dashboard -# — we just signal which option to use here. -_COUNTRY_CURRENCY: dict[str, str] = { - "US": "usd", "CA": "usd", - "GB": "gbp", "IM": "gbp", "JE": "gbp", "GG": "gbp", - **dict.fromkeys(( - "DE", "FR", "IT", "ES", "PT", "NL", "BE", "IE", "AT", "FI", - "GR", "LU", "MT", "CY", "EE", "LV", "LT", "SI", "SK", "HR", - ), "eur"), -} - -# 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" +# NOTE: we deliberately never pass `currency` to Stripe, so every +# checkout bills the Price's base currency — GBP. An earlier version +# sniffed CF-IPCountry / Accept-Language and selected a matching +# `currency_options` entry, but /pricing renders £7 and £70 as static +# copy: a US visitor was shown £7 and charged $9.99. Showing one price +# and billing another is exactly what the UK CPRs and the EU +# price-indication rules prohibit, so the sniffing was removed rather +# than the disclosure patched. The `currency_options` still configured +# on the Prices in the Dashboard are simply unused. +# +# To reinstate geo-pricing, /pricing must render the matching currency +# in its copy, its buttons AND its annual-saving claim first (the claim +# 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. def _stripe_client() -> stripe.StripeClient: @@ -130,10 +106,6 @@ def _stripe_client() -> stripe.StripeClient: class CheckoutRequest(BaseModel): 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): @@ -143,7 +115,6 @@ class CheckoutResponse(BaseModel): @router.post("/api/stripe/checkout", response_model=CheckoutResponse) async def create_checkout( body: CheckoutRequest, - request: Request, session: AsyncSession = Depends(get_session), cu: CurrentUser = Depends(require_auth), ) -> CheckoutResponse: @@ -171,14 +142,19 @@ async def create_checkout( # Lets us paste in a referral coupon at checkout once the # referral redemption flow ships. "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", } - # Multi-currency: for first-time buyers (no stripe_customer_id yet) - # we pass the detected/requested currency. Stripe picks the matching - # `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) + # No `currency` kwarg — every checkout bills the Price's base + # currency (GBP), matching the static £7 / £70 copy on /pricing. + # See the note above _stripe_client() before reintroducing one. + # # Per-cadence cooling-off treatment: # # - Annual gets a 14-day free trial. No money moves during the @@ -197,6 +173,12 @@ async def create_checkout( create_kwargs["subscription_data"] = {"trial_period_days": 14} if 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: create_kwargs["customer_email"] = user.email @@ -326,11 +308,15 @@ async def _grant_paid( await convert_referral(session, user) -async def _revoke_paid(user: User) -> None: +async def _revoke_paid(user: User, *, keep_subscription: bool = False) -> None: user.tier = "free" - user.stripe_subscription_id = None + if not keep_subscription: + user.stripe_subscription_id = None user.stripe_trial_end_at = None # 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( @@ -368,6 +354,16 @@ async def _handle_subscription_event( customer_id=obj.get("customer")) return 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, # incomplete, incomplete_expired, paused. Treat trialing/active as # paid; everything else holds tier the same until we get an explicit @@ -394,6 +390,22 @@ async def _handle_subscription_deleted( 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( session: AsyncSession, event_type: str, obj: dict[str, Any], ) -> None: @@ -408,6 +420,8 @@ _HANDLERS = { "customer.subscription.created": _handle_subscription_event, "customer.subscription.updated": _handle_subscription_event, "customer.subscription.deleted": _handle_subscription_deleted, + "customer.subscription.paused": _handle_subscription_paused, + "customer.subscription.resumed": _handle_subscription_event, "invoice.paid": _handle_audit_only, "invoice.payment_failed": _handle_audit_only, "charge.refunded": _handle_audit_only, diff --git a/app/routers/sync.py b/app/routers/sync.py index 0fa1174..7b4ecf1 100644 --- a/app/routers/sync.py +++ b/app/routers/sync.py @@ -20,11 +20,17 @@ from app.db import get_session from app.logging import get_logger from app.services import portfolio_sync as svc from app.services.access import require_paid +from app.services.feature_flags import require_flag log = get_logger("portfolio_sync_router") -router = APIRouter(prefix="/api/portfolio/sync") +# Whole router gated by PORTFOLIO_SYNC_ENABLED: when the flag is off, every +# endpoint here 404s — indistinguishable from a non-existent surface. +router = APIRouter( + prefix="/api/portfolio/sync", + dependencies=[Depends(require_flag("PORTFOLIO_SYNC_ENABLED"))], +) # A 256 KB cap is ~200× a typical pie's serialized size — generous diff --git a/app/routers/universe.py b/app/routers/universe.py index ea1d633..aa54ddf 100644 --- a/app/routers/universe.py +++ b/app/routers/universe.py @@ -41,6 +41,7 @@ from app.models import Quote, QuoteDaily from app.services import fx, portfolio_analysis, ticker_universe from app.services.access import require_paid from app.services.csv_import import CSVImportError, parse_t212_csv +from app.services.feature_flags import require_flag from app.services.instrument_map import resolve_slice from app.services.market import fetch as market_fetch @@ -338,7 +339,10 @@ async def parse_portfolio( # --------------------------------------------------------------------------- -@router.post("/analyze") +@router.post( + "/analyze", + dependencies=[Depends(require_flag("PORTFOLIO_AI_ENABLED"))], +) async def analyze_portfolio( request: Request, session: AsyncSession = Depends(get_session), @@ -349,8 +353,8 @@ async def analyze_portfolio( is persisted. The ai_calls ledger row records tokens + cost, never holdings. - Gated behind ``require_paid``: free-tier users get 402. - Admin bearer-token bypasses the gate for testing.""" + Gated behind ``PORTFOLIO_AI_ENABLED`` (404 when off) and ``require_paid`` + (402 for free tier when subscriptions are active).""" # Read JSON body manually so we can enforce a hard size cap. FastAPI's # default body limit is generous; we want tighter control here. body = await request.body() diff --git a/app/services/access.py b/app/services/access.py index 2f91f7a..10e04c9 100644 --- a/app/services/access.py +++ b/app/services/access.py @@ -21,6 +21,7 @@ from datetime import datetime, timezone from fastapi import Depends, HTTPException, status from app.auth import CurrentUser, require_auth +from app.config import get_settings from app.models import User # How many hours of news the free tier sees. Paid sees whatever the @@ -76,13 +77,22 @@ def paid_status(user: User | None) -> PaidStatus: def is_paid_active(principal: CurrentUser | User | None) -> bool: """True if the principal has paid-tier access right now. Admin - bearer-token (``CurrentUser.is_admin=True``) always passes.""" + bearer-token (``CurrentUser.is_admin=True``) always passes. + + When ``SUBSCRIPTIONS_ENABLED=False`` the subscription system is paused: + any authenticated principal is treated as paid (free-for-all). Anonymous + callers still return False so ``require_paid`` continues to enforce auth. + """ if principal is None: return False if isinstance(principal, CurrentUser): if principal.is_admin: return True + if not get_settings().SUBSCRIPTIONS_ENABLED: + return principal.user is not None return paid_status(principal.user).active + if not get_settings().SUBSCRIPTIONS_ENABLED: + return True return paid_status(principal).active diff --git a/app/services/auth_service.py b/app/services/auth_service.py index 4791ee8..558b4d2 100644 --- a/app/services/auth_service.py +++ b/app/services/auth_service.py @@ -17,7 +17,8 @@ from sqlalchemy import select from sqlalchemy.ext.asyncio import AsyncSession from app.db import utcnow -from app.models import User +from app.legal import ACKNOWLEDGEMENT_VERSION +from app.models import User, UserAcknowledgement class AuthError(Exception): @@ -69,3 +70,35 @@ async def get_or_create_user( await session.commit() await session.refresh(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() diff --git a/app/services/digest_email.py b/app/services/digest_email.py index 3d416f6..760a004 100644 --- a/app/services/digest_email.py +++ b/app/services/digest_email.py @@ -47,6 +47,7 @@ _DIGEST_HTML_TEMPLATE = """\
 
+ {feedback_row}
 
Unsubscribe in one click @@ -70,6 +71,32 @@ def _strip_html_to_text(html_body: str) -> str: 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 ( + '
 
' + f'
' + "How was today’s read? " + f'' + "👍 Helpful" + " · " + f'' + "👎 Not useful" + "
" + ) + + def render_digest_email( *, kind: str, @@ -77,10 +104,17 @@ def render_digest_email( content_html: str, unsubscribe_url: str, settings_url: str, + feedback_up_url: str | None = None, + feedback_down_url: str | None = None, ) -> tuple[str, str, str]: """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": label = "Daily" subject = f"{branding.BRAND_NAME} · Daily — {date_str}" @@ -90,6 +124,12 @@ def render_digest_email( else: 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( brand=branding.BRAND_NAME, brand_upper=branding.BRAND_NAME.upper(), @@ -99,6 +139,7 @@ def render_digest_email( content_html=content_html, unsubscribe_url=unsubscribe_url, settings_url=settings_url, + feedback_row=feedback_row, **{f"L_{k.replace('-', '_')}": v for k, v in branding.LIGHT.items()}, **{f"D_{k.replace('-', '_')}": v for k, v in branding.DARK.items()}, ) @@ -109,8 +150,16 @@ def render_digest_email( "", _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"Manage preferences: {settings_url}", - ] + ]) text_body = "\n".join(text_lines) return subject, text_body, html_body diff --git a/app/services/feature_flags.py b/app/services/feature_flags.py new file mode 100644 index 0000000..7cd7f83 --- /dev/null +++ b/app/services/feature_flags.py @@ -0,0 +1,41 @@ +"""Feature-flag gate helpers. + +The four compliance flags in ``app.config.Settings`` (``PORTFOLIO_AI_ENABLED``, +``PORTFOLIO_SYNC_ENABLED``, ``TICKER_UNIVERSE_AGGREGATE_ENABLED``, +``SUBSCRIPTIONS_ENABLED``) gate code paths that stay in the tree but are +inactive by default. Routes / dependencies use ``require_flag()`` to 404 a +whole endpoint when its flag is off — making the surface indistinguishable +from a non-existent route. +""" +from __future__ import annotations + +from fastapi import HTTPException, status + +from app.config import get_settings + + +def flag_enabled(flag_name: str) -> bool: + """Read a boolean flag from Settings. Unknown flags raise — typos here + would silently disable features otherwise.""" + settings = get_settings() + if not hasattr(settings, flag_name): + raise AttributeError(f"unknown feature flag: {flag_name}") + return bool(getattr(settings, flag_name)) + + +def require_flag(flag_name: str): + """FastAPI dependency factory: 404 the route if the flag is off. + + Usage:: + + @router.post("/analyze", dependencies=[Depends(require_flag("PORTFOLIO_AI_ENABLED"))]) + + 404 (not 503) is deliberate: a paused feature should be indistinguishable + from a missing route to clients and crawlers.""" + async def _gate() -> None: + if not flag_enabled(flag_name): + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail="not found", + ) + return _gate diff --git a/app/services/llm_prompts.py b/app/services/llm_prompts.py index 726b60a..7806754 100644 --- a/app/services/llm_prompts.py +++ b/app/services/llm_prompts.py @@ -28,7 +28,47 @@ from datetime import datetime # the model was hallucinating future times. The user prompt now carries the # actual current UTC time so the model has accurate temporal context. # v9 (2026-05-25): Adds daily + weekly digest prompt builders for email. -PROMPT_VERSION = 9 +# v10 (2026-05-29): Compliance pass. Drops the watch-list section, removes +# price-level / "close above/below" / floor-ceiling / tripwire framing across +# log + indicator reads + chat + digests, adds a universal _COMPLIANCE_RIDER +# prepended to every system prompt. See docs/read-markets-compliance-changes.md. +PROMPT_VERSION = 10 + + +# --- Universal compliance rider ---------------------------------------------- + +# Prepended to every system prompt below (log, per-group indicator read, +# aggregate read, chat, daily + weekly digests). The inline edits in _CORE, +# _CHAT_OVERRIDES, and the summary prompt builders remove the worst patterns +# directly; this rider is belt-and-braces so a future tone/style tweak can't +# silently regress past it. See docs/read-markets-compliance-changes.md TASK 3. +_COMPLIANCE_RIDER = """# Editorial perimeter (overrides everything below) +You are an editorial market explainer, not an adviser or forecaster. +- Explain what moved and WHY (fundamentals, policy, valuation, positioning). +- Separate rational drivers from irrational/positioning drivers. +- DO NOT predict future prices or give price targets, levels, floors, ceilings, + or "close above/below X" triggers for any instrument. +- DO NOT recommend or imply any action: no buy/sell/hold, no add/trim/rebalance, + no overweight/underweight, no "you should", no "watch for X to do Y". +- DO NOT use technical-analysis or chart-pattern framing + (head-and-shoulders, support/resistance, breakouts, RSI, Fibonacci, etc.). +- Refer to instruments to explain the present, never to advise on the future. +- Forward-looking opinions on a named instrument's price or value are out of + scope even without a numeric target. "Brent is likely to consolidate near + $90–93" or "the base case is a move to X" both cross the line. +Write as commentary on public data for a general audience. + +Positive calibration example: +- AVOID: "Gold $4,600 — a close above would confirm the safe-haven bid is + returning; failure at that level would mean the peace script is dominant." +- PREFER: "Gold is rising alongside equities while oil slides — a decoupling + from the usual 'peace = gold weak' narrative. The most natural read is + that safe-haven/hedging demand is re-emerging even as risk assets hold up; + that tension is the day's most notable signal about how fully the peace + scenario is actually priced." +The 'prefer' version keeps the analytical insight; it drops the price level, +the confirm/fail conditional, and the implied action. +""" # --- Core: invariant across tone/analysis settings ---------------------------- @@ -69,8 +109,10 @@ weather or generic context. - Then 4-6 paragraphs, each anchored on a sleeve, sector, or theme. Concrete \ numbers in every paragraph. No section over ~150 words. - One paragraph synthesising the news flow into a market read. -- End with a watch list: 3-5 specific items to track in the next week, \ -each one sentence. +- Close with the synthesis paragraph (and the System temperature line below). \ +Do NOT add a "watch list", "what to monitor", "tripwires", or any equivalent \ +section. A list of conditional price predictions is a forecast framework — \ +which this log is not. # Time-horizon discipline - This is a STRATEGIC log, not a day-trader's read. Treat 1-day moves under \ @@ -79,9 +121,6 @@ multi-week trend or are extreme outliers. - Anchor every claim to multi-week (1m), multi-month (since-anchor), or \ multi-year (1y) changes — not 1d. If the only thing happening is a 1d move, \ omit the paragraph. -- The watch list is for "structural tripwires over the next 1-3 months", not \ -"things to watch tomorrow". Each watch item should name a level/threshold \ -whose breach would change the regime, not a calendar-date event. # Rational vs irrational framing (MANDATORY in every paragraph) The reader's primary goal is to disconnect rational decisions from market \ @@ -110,8 +149,23 @@ without a specific number behind it. - Distinguish "the thesis predicted X and X happened" from "the thesis \ predicted X and X did not happen". Both are useful; conflating them is not. - Don't repeat the same point in different words across paragraphs. -- No buy/sell recommendations. Triggers are pre-set elsewhere; your job is \ -to report whether reality is confirming, modifying, or refuting the thesis. +- No buy/sell recommendations. No add/trim/rebalance, no overweight/underweight, \ +no "you should", no "investors should", no "we recommend". +- No price targets. No "close above/below $X" or "break above/below X" framing. \ +No floors, ceilings, support, resistance, or any level cast as a trigger or \ +tripwire whose breach would mean something. Citing where a price is right \ +now is fine ("Brent at $90"); casting a price as a threshold ("$93 is the \ +ceiling") is not. +- No forward conditional calls on a named instrument's price. "Would confirm \ +X if Y", "base case is consolidation near $X", "watch for Brent to break Y" \ +are all out. Forward language belongs to the *regime* and *fundamentals* — \ +"the policy mix is tightening", "real yields stay restrictive", "positioning \ +is crowded" — not to a number on a chart. +- Forward predictions wrapped in present-tense state language are still \ +predictions. "Valuations are stretched and unlikely to hold", "the path of \ +least resistance is lower", "risk is skewed to the downside" all smuggle a \ +forecast into description. State the state ("valuations are stretched"); \ +don't tack a direction onto it. # Stance (educational, anti-TA, anti-gambling) The target reader is most likely young, new to investing, and at risk of \ @@ -119,9 +173,10 @@ treating markets like a horse race they need to "read" via chart patterns. \ Cassandra is the corrective. - **No technical analysis.** Head-and-shoulders, RSI thresholds, Fibonacci \ levels, Elliott waves, "support/resistance" — these are descriptions of past \ -crowd behaviour, not predictions. Don't use them; don't legitimise them. If \ -you mention a price level, frame it as a positioning fact (e.g. "the level \ -where the latest tranche of buyers entered"), not a signal. +crowd behaviour, not predictions. Don't use them; don't legitimise them. \ +Don't cast specific price levels as load-bearing for the read; \ +spot prices and percent changes are fine ("Brent at $90", "+12% YTD") but \ +"$93 is the level to watch" is not. - **No gambling framing.** Markets are not a coin flip and not a horse race. \ Never present a position as a single decisive moment, a "now or never", or a \ bet to be won. Every read should follow the shape: *regime → implication → \ @@ -135,9 +190,9 @@ Close the log with a single sentence on a line of its own, formatted exactly: System temperature: [cool|neutral|elevated|hot|extreme] — [one clause naming the 2-3 specific divergences or readings that justify the label] -This is the line a reader who only sees the watch list scrolls down to. Make \ -it earn its place: cite real signals (HY OAS, breadth, VIX, valuation, real \ -yields), not vibes. +This is the line a glancing reader scrolls to first. Make it earn its place: \ +cite real signals (HY OAS, breadth, VIX, valuation, real yields), not vibes. \ +The label is a description of the current regime, not a forecast. # Update mode (when an earlier log from today is provided) If the user message includes a section labelled "Earlier log from today \ @@ -148,8 +203,6 @@ that timestamp: confirmations, refutations, new emergent patterns. - The TL;DR should lead with the move since the earlier read when there \ was a meaningful intra-day change ("Since this morning's read, …") — \ otherwise stay regime-level. -- The watch list should evolve: drop items that triggered or settled, add \ -items that emerged. Keep items still load-bearing. - Preserve any insights from the earlier draft that remain valid; sharpen \ or revise the ones that don't. Avoid contradicting yourself silently — if \ you change a stance, name it briefly ("Earlier I read X; with Y now, the \ @@ -250,17 +303,18 @@ def _resolve_tone(tone: str) -> str: _ANALYSIS: dict[str, str] = { "DRY": """# Analysis style: dry Report what happened. Identify divergences and contradictions. Compare to \ -references. Do not speculate on what comes next. Forward-looking statements \ -are limited to "what would invalidate the read" — never "we expect X to \ -happen". The watch list contains items to monitor, not predictions.""", +references. Do not speculate on what comes next.""", "SPECULATIVE": """# Analysis style: speculative -Report what happened, then explicitly explore forward scenarios. For each \ -significant sector or theme, sketch a 1-4 week scenario set: the base case \ -(what the data suggests), a contrarian case (what would invalidate it), and \ -what tape signal would tip you from one to the other. Be explicit about \ -uncertainty — say "the base case is" not "X will happen". The watch list is \ -the trip-wires that decide between scenarios.""", +Report what happened, then explore forward *regimes* — never forward prices. \ +For each significant sector or theme, you may sketch what the underlying \ +fundamentals and positioning suggest about the prevailing macro regime \ +(e.g. "the policy mix is still tightening", "real yields remain restrictive", \ +"crowded positioning leaves little fuel for further upside in this style"). \ +What you must NOT do is forecast the price or value of any specific named \ +instrument, even hedged with "base case is X" or "likely to consolidate near \ +$X" — those are MAR investment recommendations and outside scope. \ +Stay at the regime / fundamentals level.""", } @@ -268,7 +322,7 @@ def build_system_prompt(tone: str, analysis: str) -> str: """Compose the system prompt from the chosen audience and analysis style.""" tone_block = _TONE[_resolve_tone(tone)] analysis_block = _ANALYSIS.get(analysis.upper(), _ANALYSIS["SPECULATIVE"]) - return "\n\n".join([_CORE, tone_block, analysis_block]) + return "\n\n".join([_COMPLIANCE_RIDER, _CORE, tone_block, analysis_block]) # Backwards-compat: a default-composed SYSTEM_PROMPT for tests / callers that @@ -281,7 +335,7 @@ SYSTEM_PROMPT = build_system_prompt("INTERMEDIATE", "SPECULATIVE") _CHAT_OVERRIDES = """# Chat mode (overrides the log-structure rules above) You are NOT writing a daily log right now. The user is asking a specific question via the chat sidebar. -- Forget the date header, TL;DR, sectional structure, and watch list. Just answer. +- Forget the date header, TL;DR, and sectional structure. Just answer. - Typical response: 200-400 words. Longer only if the question genuinely warrants it. - Cite specific numbers and named headlines from the reference materials @@ -289,7 +343,15 @@ question via the chat sidebar. - If a question is outside the provided context (e.g. asking about a stock or event not in the data), say so plainly rather than speculating from prior knowledge. -- No buy/sell recommendations. If asked, redirect to thesis and scenarios. +- No buy/sell recommendations and no instrument-specific advice, even if the + user asks directly ("should I buy X?", "what about TICKER?"). Redirect to + the regime and the fundamentals. +- No forward price calls, no targets, no triggers, no "close above/below", + no floors, no ceilings. The compliance rider above is in force in chat too. +- The chat receives the latest log, live quotes, and headlines — it does NOT + receive any portfolio or holdings context. If the user mentions their own + positions, do not engage with them at the per-position level; answer the + underlying macro question instead. - Keep the same audience and analysis discipline established above.""" @@ -305,7 +367,9 @@ def build_summary_system_prompt(tone: str, analysis: str) -> str: field is caught by the reviewer agent (services/output_review).""" tone_block = _TONE[_resolve_tone(tone)] analysis_block = _ANALYSIS.get(analysis.upper(), _ANALYSIS["SPECULATIVE"]) - return f"""You write a TINY interpretation (≤60 words, 2-3 sentences) \ + return f"""{_COMPLIANCE_RIDER} + +You write a TINY interpretation (≤60 words, 2-3 sentences) \ of ONE indicator group for a strategic markets dashboard. # Output format (strict) @@ -341,8 +405,10 @@ finished read, not the thinking. - Cite at most 2-3 specific numbers and ONLY when they anchor an \ interpretation. Don't list moves; explain them. - Multi-week / multi-month horizon. 1-day moves under 2% are noise — skip. -- No buy/sell language. No predictions. No watch list. No TL;DR. No date \ -header. No "system temperature" line — that belongs to the full daily log. +- No buy/sell language. No price targets, no "close above/below", no \ +floors/ceilings/support/resistance, no triggers. No forward price calls on \ +named instruments. No watch list. No TL;DR. No date header. No "system \ +temperature" line — that belongs to the full daily log. {tone_block} @@ -370,7 +436,9 @@ def build_aggregate_summary_system_prompt(tone: str, analysis: str) -> str: {"read": "..."} only; the field is the publishable text verbatim.""" tone_block = _TONE[_resolve_tone(tone)] analysis_block = _ANALYSIS.get(analysis.upper(), _ANALYSIS["SPECULATIVE"]) - return f"""You write a single SHORT cross-asset INTERPRETATION (≤80 \ + return f"""{_COMPLIANCE_RIDER} + +You write a single SHORT cross-asset INTERPRETATION (≤80 \ words, 2-4 sentences) for the dashboard header. The reader is glancing — \ give them the meaning of the whole tape, not a recap. @@ -406,7 +474,9 @@ parenthetical asides that question your own numbers. risk premium is in commodities but not vol". Cite no more than 3 specific \ numbers, and only as anchors for the interpretation. - Multi-week / multi-month horizon. 1-day moves under 2% are noise. -- No buy/sell language. No predictions of specific levels. +- No buy/sell language. No forward price calls on named instruments. \ +No targets, floors, ceilings, support/resistance, "close above/below", or \ +trigger framing of any kind. {tone_block} @@ -437,7 +507,13 @@ def build_chat_system_prompt( ) -> str: """Composed system prompt for the /log chat sidebar. Carries the user's chosen tone + analysis style and inlines the latest log + market data + - headlines as reference material the model can cite from.""" + headlines as reference material the model can cite from. + + Compliance contract: no holdings / portfolio / per-user position data may + be passed to this builder. The signature deliberately exposes only log, + quotes, and headlines — adding a holdings parameter would re-open the + advice surface that Task 1 closed. If a future caller needs portfolio + context, the right answer is to redesign the chat, not to bolt it on.""" parts = [build_system_prompt(tone, analysis), "", _CHAT_OVERRIDES, ""] if reference_line: parts.append(f"# Doc reference snapshot\n{reference_line}\n") @@ -539,11 +615,16 @@ def build_daily_digest_prompt( 24h and looks forward to the upcoming session. Longer, less 'live-blogging,' more contextual. Target ~600 words.""" system = ( + f"{_COMPLIANCE_RIDER}\n\n" "You write the daily editorial digest for Read the Markets. " f"Audience tone: {tone.upper()}. {_digest_tone_clause(tone)} " - "Cover: (1) what mattered yesterday, (2) what to watch in today's " - "EU and US sessions, (3) one cross-asset thread connecting them. " - "No predictions of price level, no buy/sell language. Target ~600 " + "Cover: (1) what mattered yesterday, (2) what releases or events are " + "scheduled in today's EU and US sessions, (3) one cross-asset thread " + "connecting them. Frame (2) as scheduled events to be aware of, NOT " + "as a price-watch list. " + "No predictions of price level, no buy/sell language, no targets, " + "no 'close above/below', no floors/ceilings/support/resistance, " + "no trigger framing on named instruments. Target ~600 " "words. Output HTML using only

,

,

+ +{% 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. #} +
+ Was this useful? + + + +
+ +{% endif %} {% endif %} diff --git a/app/templates/partials/news.html b/app/templates/partials/news.html index 5f19f4d..4689f34 100644 --- a/app/templates/partials/news.html +++ b/app/templates/partials/news.html @@ -33,7 +33,7 @@ {% endfor %} {% endif %} -{% if capped %} +{% if capped and SUBSCRIPTIONS_ENABLED %}
Free tier — showing the last {{ window_hours|int }} hours of news. Upgrade diff --git a/app/templates/partials/portfolio.html b/app/templates/partials/portfolio.html index 99d4dc0..d9e0a32 100644 --- a/app/templates/partials/portfolio.html +++ b/app/templates/partials/portfolio.html @@ -1,3 +1,6 @@ +{# 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 %}
no portfolio snapshots yet
{% else %} diff --git a/app/templates/pricing.html b/app/templates/pricing.html index c32fb26..875a67a 100644 --- a/app/templates/pricing.html +++ b/app/templates/pricing.html @@ -10,9 +10,9 @@ 6-hour news feed, the cross-asset indicator panels, and a strategic log refreshed every six hours. Paid stretches the news feed to a full 24 hours, runs the strategic log hourly, unlocks the follow-up - chat against past logs, adds portfolio import with AI analysis, and - turns on the daily email digest on top of the Sunday recap everyone - gets. + chat against past logs, adds a browser-only portfolio composition + viewer, and turns on the daily email digest on top of the Sunday + recap everyone gets.

@@ -33,7 +33,7 @@
  • Sunday weekly digest by email — week behind + week ahead, one-click unsubscribe
  • - Need the full-day news feed, hourly strategic log, follow-up chat, daily digests, or portfolio analysis? See Paid → + Need the full-day news feed, hourly strategic log, follow-up chat, daily digests, or the portfolio composition viewer? See Paid
    {% if cu and (cu.user or cu.is_admin) %} @@ -47,7 +47,7 @@