diff --git a/.env.example b/.env.example index f2a593b..889bd4d 100644 --- a/.env.example +++ b/.env.example @@ -16,13 +16,6 @@ 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 @@ -32,11 +25,3 @@ 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 e9eb31d..168165b 100644 --- a/.gitignore +++ b/.gitignore @@ -9,10 +9,8 @@ __pycache__/ .ruff_cache/ .venv/ venv/ -# 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/ +backup/*.sql +backup/*.sql.gz *.egg-info/ build/ dist/ diff --git a/Dockerfile b/Dockerfile index 94b8042..09c6443 100644 --- a/Dockerfile +++ b/Dockerfile @@ -32,12 +32,10 @@ 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; the scheduler and admin-console containers -# override via `command:` (see docker-compose.yml). +# Default command is the web app; scheduler container overrides via `command:`. EXPOSE 8000 CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000", "--workers", "1"] @@ -59,7 +57,6 @@ COPY --from=builder /opt/venv /opt/venv WORKDIR /app 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 deleted file mode 100644 index 3f1213c..0000000 --- a/admin/README.md +++ /dev/null @@ -1,54 +0,0 @@ -# Superadmin console - -An **independent, internal-only** web service for the operator: user list with -per-user history + payment status, and database usage stats. Runs in its own -container (`admin` service) off the same image as the main app, reusing -`app.db` + `app.models`, but it is a separate FastAPI app (`admin.main:app`) -that **never** runs migrations or the scheduler and only ever issues `SELECT`s. - -## Access model - -- **Dev:** bound to **`127.0.0.1:8091`** on the host (loopback only, from - `docker-compose.override.yml`). Open . -- **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 deleted file mode 100644 index 5f9b1c7..0000000 --- a/admin/__init__.py +++ /dev/null @@ -1,9 +0,0 @@ -"""Superadmin console — an independent, internal-only web service. - -Runs in its own container (see the `admin` service in docker-compose.yml), -bound to 127.0.0.1 on the VPS and reached over an SSH tunnel. It reuses the -main app's ORM models and DB engine (`app.db`, `app.models`) but is a wholly -separate FastAPI app: it never imports `app.main`, so it does NOT run Alembic -migrations or the app's lifespan. Every DB access is a plain SELECT — the -console never writes. -""" diff --git a/admin/auth.py b/admin/auth.py deleted file mode 100644 index a85067b..0000000 --- a/admin/auth.py +++ /dev/null @@ -1,71 +0,0 @@ -"""Password gate for the superadmin console. - -A single shared password (``ADMIN_CONSOLE_PASSWORD``) is exchanged at -``/login`` for a signed, time-limited session cookie. There is no user -identity here — the console has exactly one principal, "the operator". - -The cookie is signed with itsdangerous using ``ADMIN_CONSOLE_SESSION_SECRET`` -(falling back to the main app's session secret / token), mirroring the scheme -in ``app.auth`` but under a distinct salt so an app session cookie can never -be replayed against the console and vice-versa. -""" -from __future__ import annotations - -import secrets - -from fastapi import HTTPException, Request, status -from itsdangerous import BadSignature, SignatureExpired, URLSafeTimedSerializer - -from app.config import get_settings - -SESSION_COOKIE_NAME = "admin_console_session" -SESSION_TTL_SECONDS = 12 * 60 * 60 # 12 hours — re-auth daily-ish. -_SALT = "admin-console-session-v1" - - -def _serializer() -> URLSafeTimedSerializer: - s = get_settings() - secret = ( - s.ADMIN_CONSOLE_SESSION_SECRET - or s.CASSANDRA_SESSION_SECRET - or s.CASSANDRA_TOKEN - or "dev-insecure-secret" - ) - return URLSafeTimedSerializer(secret, salt=_SALT) - - -def check_password(provided: str) -> bool: - """Constant-time compare against ``ADMIN_CONSOLE_PASSWORD``. Returns - False when the password is unset — the console is closed by default so a - fresh deploy can't be logged into without an explicit password.""" - expected = get_settings().ADMIN_CONSOLE_PASSWORD - if not expected: - return False - return secrets.compare_digest(provided.encode(), expected.encode()) - - -def sign_session() -> str: - """Signed value proving a successful password login. Carries no identity - beyond a version marker — the console has a single principal.""" - return _serializer().dumps({"v": 1}) - - -def verify_session(cookie: str) -> bool: - try: - data = _serializer().loads(cookie, max_age=SESSION_TTL_SECONDS) - return data.get("v") == 1 - except (BadSignature, SignatureExpired, KeyError, TypeError, ValueError): - return False - - -def require_admin(request: Request) -> None: - """FastAPI dependency guarding every console page. Valid cookie → passes; - otherwise 303 → /login (browser) so the operator lands on the form.""" - cookie = request.cookies.get(SESSION_COOKIE_NAME) - if cookie and verify_session(cookie): - return - raise HTTPException( - status_code=status.HTTP_303_SEE_OTHER, - detail="Login required", - headers={"Location": "/login"}, - ) diff --git a/admin/main.py b/admin/main.py deleted file mode 100644 index 324dd72..0000000 --- a/admin/main.py +++ /dev/null @@ -1,140 +0,0 @@ -"""Superadmin console FastAPI app. - -Independent of ``app.main``: it wires only its own routes and reuses -``app.db``'s session factory for read-only queries. No lifespan, no -migrations, no scheduler. - -Run: uvicorn admin.main:app --host 0.0.0.0 --port 8000 -""" -from __future__ import annotations - -from datetime import datetime, timezone -from pathlib import Path - -from fastapi import Depends, FastAPI, Form, Query, Request -from fastapi.responses import HTMLResponse, PlainTextResponse, RedirectResponse -from fastapi.templating import Jinja2Templates -from sqlalchemy.ext.asyncio import AsyncSession - -from admin import auth, queries -from app.db import get_session - -TEMPLATE_DIR = Path(__file__).resolve().parent / "templates" -templates = Jinja2Templates(directory=str(TEMPLATE_DIR)) - - -def _fmt_bytes(n: int | None) -> str: - if not n: - return "0 B" - units = ["B", "KB", "MB", "GB", "TB"] - size = float(n) - for u in units: - if size < 1024 or u == units[-1]: - return f"{size:,.0f} {u}" if u == "B" else f"{size:,.1f} {u}" - size /= 1024 - return f"{n} B" - - -def _fmt_dt(dt: datetime | None) -> str: - if dt is None: - return "—" - if dt.tzinfo is None: - dt = dt.replace(tzinfo=timezone.utc) - return dt.strftime("%Y-%m-%d %H:%M UTC") - - -templates.env.filters["bytes"] = _fmt_bytes -templates.env.filters["dt"] = _fmt_dt - -app = FastAPI(title="read.markets superadmin console", docs_url=None, redoc_url=None) - - -@app.get("/healthz", response_class=PlainTextResponse, include_in_schema=False) -async def healthz() -> str: - return "ok" - - -@app.get("/login", response_class=HTMLResponse, include_in_schema=False) -async def login_form(request: Request): - # Already signed in? Skip the form. - cookie = request.cookies.get(auth.SESSION_COOKIE_NAME) - if cookie and auth.verify_session(cookie): - return RedirectResponse("/", status_code=303) - return templates.TemplateResponse(request, "login.html", {"error": None}) - - -@app.post("/login", response_class=HTMLResponse, include_in_schema=False) -async def login_submit(request: Request, password: str = Form(...)): - if not auth.check_password(password): - return templates.TemplateResponse( - request, "login.html", {"error": "Incorrect password."}, - status_code=401, - ) - resp = RedirectResponse("/", status_code=303) - resp.set_cookie( - auth.SESSION_COOKIE_NAME, - auth.sign_session(), - max_age=auth.SESSION_TTL_SECONDS, - httponly=True, - samesite="lax", - # secure=False: the console is served over http on a localhost SSH - # tunnel, never public TLS. A secure cookie would never be sent. - secure=False, - ) - return resp - - -@app.get("/logout", include_in_schema=False) -async def logout(): - resp = RedirectResponse("/login", status_code=303) - resp.delete_cookie(auth.SESSION_COOKIE_NAME) - return resp - - -@app.get("/", response_class=HTMLResponse, include_in_schema=False, - dependencies=[Depends(auth.require_admin)]) -async def overview(request: Request, session: AsyncSession = Depends(get_session)): - stats = await queries.overview_stats(session) - recent, _ = await queries.list_users(session, limit=10) - return templates.TemplateResponse( - request, "overview.html", {"stats": stats, "recent": recent}, - ) - - -@app.get("/users", response_class=HTMLResponse, include_in_schema=False, - dependencies=[Depends(auth.require_admin)]) -async def users_list( - request: Request, - session: AsyncSession = Depends(get_session), - q: str | None = Query(default=None), - page: int = Query(default=1, ge=1), -): - per_page = 200 - offset = (page - 1) * per_page - rows, total = await queries.list_users(session, q=q, limit=per_page, offset=offset) - return templates.TemplateResponse( - request, "users.html", - {"rows": rows, "total": total, "q": q or "", - "page": page, "per_page": per_page}, - ) - - -@app.get("/users/{user_id}", response_class=HTMLResponse, include_in_schema=False, - dependencies=[Depends(auth.require_admin)]) -async def user_detail( - request: Request, user_id: int, - session: AsyncSession = Depends(get_session), -): - detail = await queries.user_detail(session, user_id) - if detail is None: - return templates.TemplateResponse( - request, "not_found.html", {"user_id": user_id}, status_code=404, - ) - return templates.TemplateResponse(request, "user_detail.html", {"d": detail}) - - -@app.get("/db", response_class=HTMLResponse, include_in_schema=False, - dependencies=[Depends(auth.require_admin)]) -async def db_page(request: Request, session: AsyncSession = Depends(get_session)): - stats = await queries.db_stats(session) - return templates.TemplateResponse(request, "db.html", {"stats": stats}) diff --git a/admin/queries.py b/admin/queries.py deleted file mode 100644 index d2c9fec..0000000 --- a/admin/queries.py +++ /dev/null @@ -1,266 +0,0 @@ -"""Read-only queries backing the console. Every function issues SELECTs -only — the console never mutates the database. - -Payment status is computed with the app's own ``paid_status`` so the console -never diverges from what the app considers "paid". -""" -from __future__ import annotations - -from datetime import timedelta - -from sqlalchemy import func, or_, select, text -from sqlalchemy.ext.asyncio import AsyncSession - -from app.db import utcnow -from app.models import ( - EmailSend, - PortfolioSync, - Referral, - StrategicLogFeedback, - User, - UserAcknowledgement, -) -from app.services.access import paid_status - - -async def overview_stats(session: AsyncSession) -> dict: - """Headline counts for the landing page: totals, tier split, growth, - paid-active, engagement.""" - now = utcnow() - d7 = now - timedelta(days=7) - d30 = now - timedelta(days=30) - - total = await session.scalar(select(func.count(User.id))) or 0 - - tier_rows = (await session.execute( - select(User.tier, func.count(User.id)).group_by(User.tier) - )).all() - tiers = {tier: n for tier, n in tier_rows} - - signups_7d = await session.scalar( - select(func.count(User.id)).where(User.created_at >= d7) - ) or 0 - signups_30d = await session.scalar( - select(func.count(User.id)).where(User.created_at >= d30) - ) or 0 - active_30d = await session.scalar( - select(func.count(User.id)).where(User.last_login_at >= d30) - ) or 0 - - # Paid-active = subscribed tier OR a live credit window. The two are - # counted disjointly (a credit user whose tier is already paid is only - # counted once, under tier) so the sum can't double-count. - paid_by_tier = await session.scalar( - select(func.count(User.id)).where(User.tier.in_(("paid", "enterprise"))) - ) or 0 - credit_active = await session.scalar( - select(func.count(User.id)).where( - User.credit_until.is_not(None), - User.credit_until > now, - User.tier.not_in(("paid", "enterprise")), - ) - ) or 0 - - sync_enabled = await session.scalar( - select(func.count(PortfolioSync.user_id)) - ) or 0 - - referrals_total = await session.scalar(select(func.count(Referral.id))) or 0 - referrals_converted = await session.scalar( - select(func.count(Referral.id)).where(Referral.converted_at.is_not(None)) - ) or 0 - - return { - "total_users": total, - "tiers": tiers, - "free": tiers.get("free", 0), - "paid": tiers.get("paid", 0), - "enterprise": tiers.get("enterprise", 0), - "paid_active": paid_by_tier + credit_active, - "credit_active": credit_active, - "signups_7d": signups_7d, - "signups_30d": signups_30d, - "active_30d": active_30d, - "sync_enabled": sync_enabled, - "referrals_total": referrals_total, - "referrals_converted": referrals_converted, - } - - -async def list_users( - session: AsyncSession, - q: str | None = None, - limit: int = 200, - offset: int = 0, -) -> tuple[list[dict], int]: - """User rows for the table view, newest first. Optional ``q`` filters by - email substring. Returns (rows, total_matching).""" - stmt = select(User) - count_stmt = select(func.count(User.id)) - if q: - like = f"%{q.strip()}%" - stmt = stmt.where(User.email.like(like)) - count_stmt = count_stmt.where(User.email.like(like)) - - total = await session.scalar(count_stmt) or 0 - users = (await session.execute( - stmt.order_by(User.created_at.desc()).limit(limit).offset(offset) - )).scalars().all() - - if not users: - return [], total - - ids = [u.id for u in users] - - # One grouped query each for the per-user badges, merged in Python — - # avoids an N+1 without a wide join fan-out. - sync_ids = set((await session.execute( - select(PortfolioSync.user_id).where(PortfolioSync.user_id.in_(ids)) - )).scalars().all()) - - ref_counts = dict((await session.execute( - select(Referral.referrer_user_id, func.count(Referral.id)) - .where(Referral.referrer_user_id.in_(ids)) - .group_by(Referral.referrer_user_id) - )).all()) - - rows: list[dict] = [] - for u in users: - ps = paid_status(u) - rows.append({ - "id": u.id, - "email": u.email, - "tier": u.tier, - "paid_active": ps.active, - "paid_source": ps.source, - "credit_days": ps.days_remaining, - "created_at": u.created_at, - "last_login_at": u.last_login_at, - "lang": u.lang, - "has_sync": u.id in sync_ids, - "referrals": ref_counts.get(u.id, 0), - "on_stripe": bool(u.stripe_customer_id), - "on_polar": bool(u.polar_customer_id), - "trialing": u.stripe_trial_end_at is not None, - }) - return rows, total - - -async def user_detail(session: AsyncSession, user_id: int) -> dict | None: - """Everything the console shows for one user: the row itself, computed - payment status, billing linkage, and joined activity history.""" - user = await session.get(User, user_id) - if user is None: - return None - - ps = paid_status(user) - - referred_by = None - if user.referred_by_user_id: - ref = await session.get(User, user.referred_by_user_id) - referred_by = {"id": ref.id, "email": ref.email} if ref else None - - # Referrals this user sent (with conversion state). - sent = (await session.execute( - select(Referral, User.email) - .join(User, User.id == Referral.referred_user_id) - .where(Referral.referrer_user_id == user_id) - .order_by(Referral.created_at.desc()) - )).all() - referrals_sent = [{ - "referred_email": email, - "created_at": r.created_at, - "converted_at": r.converted_at, - } for r, email in sent] - - # Digest email delivery log (most recent first, capped). - email_rows = (await session.execute( - select(EmailSend).where(EmailSend.user_id == user_id) - .order_by(EmailSend.sent_at.desc()).limit(25) - )).scalars().all() - emails = [{ - "kind": e.kind, "sent_at": e.sent_at, - "status": e.status, "error": e.error, - } for e in email_rows] - - # Legal acknowledgements (evidentiary audit trail). - ack_rows = (await session.execute( - select(UserAcknowledgement).where(UserAcknowledgement.user_id == user_id) - .order_by(UserAcknowledgement.accepted_at.desc()) - )).scalars().all() - acks = [{ - "version": a.version, "lang": a.lang, "accepted_at": a.accepted_at, - } for a in ack_rows] - - # Feedback votes cast on strategic logs. - fb_rows = (await session.execute( - select(StrategicLogFeedback.vote, func.count(StrategicLogFeedback.id)) - .where(StrategicLogFeedback.user_id == user_id) - .group_by(StrategicLogFeedback.vote) - )).all() - feedback = {vote: n for vote, n in fb_rows} - - sync = await session.get(PortfolioSync, user_id) - - return { - "user": user, - "paid": ps, - "referred_by": referred_by, - "referrals_sent": referrals_sent, - "emails": emails, - "acks": acks, - "feedback": feedback, - "sync": { - "enabled": sync is not None, - "updated_at": sync.updated_at if sync else None, - "version": sync.version if sync else None, - }, - } - - -async def db_stats(session: AsyncSession) -> dict: - """Per-table size and row estimates for the current schema. - - On MariaDB this reads ``information_schema.tables`` (row counts are the - engine's estimate for InnoDB, sizes are exact). On sqlite (the test - backend) there is no such view, so it falls back to real ``COUNT(*)`` - per table with sizes reported as 0. - """ - dialect = session.bind.dialect.name - - if dialect == "sqlite": - from app.db import Base - tables = [] - for tbl in Base.metadata.sorted_tables: - n = await session.scalar(select(func.count()).select_from(tbl)) or 0 - tables.append({ - "name": tbl.name, "rows": n, - "data_bytes": 0, "index_bytes": 0, "total_bytes": 0, "pct": 0.0, - }) - tables.sort(key=lambda t: t["rows"], reverse=True) - return {"tables": tables, "total_bytes": 0, "total_rows": sum(t["rows"] for t in tables)} - - rows = (await session.execute(text( - "SELECT table_name, table_rows, data_length, index_length " - "FROM information_schema.tables " - "WHERE table_schema = DATABASE() " - "ORDER BY (data_length + index_length) DESC" - ))).all() - - tables = [] - total_bytes = 0 - total_rows = 0 - for name, tr, data_len, idx_len in rows: - data_len = int(data_len or 0) - idx_len = int(idx_len or 0) - tot = data_len + idx_len - total_bytes += tot - total_rows += int(tr or 0) - tables.append({ - "name": name, "rows": int(tr or 0), - "data_bytes": data_len, "index_bytes": idx_len, "total_bytes": tot, - }) - for t in tables: - t["pct"] = round(100.0 * t["total_bytes"] / total_bytes, 1) if total_bytes else 0.0 - - return {"tables": tables, "total_bytes": total_bytes, "total_rows": total_rows} diff --git a/admin/templates/base.html b/admin/templates/base.html deleted file mode 100644 index 003e163..0000000 --- a/admin/templates/base.html +++ /dev/null @@ -1,80 +0,0 @@ - - - - - - - {% 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 deleted file mode 100644 index 7ccde4c..0000000 --- a/admin/templates/db.html +++ /dev/null @@ -1,31 +0,0 @@ -{% 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 deleted file mode 100644 index aff4c50..0000000 --- a/admin/templates/login.html +++ /dev/null @@ -1,14 +0,0 @@ -{% 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 deleted file mode 100644 index 6e41006..0000000 --- a/admin/templates/not_found.html +++ /dev/null @@ -1,6 +0,0 @@ -{% 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 deleted file mode 100644 index 06f3a0a..0000000 --- a/admin/templates/overview.html +++ /dev/null @@ -1,22 +0,0 @@ -{% 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 deleted file mode 100644 index 5525464..0000000 --- a/admin/templates/partials_users_table.html +++ /dev/null @@ -1,39 +0,0 @@ -{# 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 deleted file mode 100644 index 715c56f..0000000 --- a/admin/templates/user_detail.html +++ /dev/null @@ -1,73 +0,0 @@ -{% 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 deleted file mode 100644 index 72bde22..0000000 --- a/admin/templates/users.html +++ /dev/null @@ -1,20 +0,0 @@ -{% 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 deleted file mode 100644 index 1738fcf..0000000 --- a/alembic/versions/0026_compliance_purge_and_audit.py +++ /dev/null @@ -1,75 +0,0 @@ -"""compliance purge (portfolio_sync, ticker_universe) + reviewer_verdicts audit table. - -Revision ID: 0026 -Revises: 0025 -Create Date: 2026-05-29 - -See docs/read-markets-compliance-changes.md. - -Two unrelated changes bundled into one migration because they ship together: - -1. Purge — the server learns nothing about anyone's holdings while the - compliance flags are off. Empties ``portfolio_sync`` (per-user encrypted - ciphertext blobs) and ``ticker_universe`` (the anonymous aggregate set of - tickers ever uploaded). Tables stay; data goes. If a flag is later - re-enabled, the tables refill from scratch. - -2. Audit — adds ``reviewer_verdicts`` so every output-reviewer decision - (deterministic + LLM, pass and fail) is persisted with surface, candidate - text, reason, layer, and model. This is the regulator-facing evidence that - automated review runs on every published item. - -Downgrade restores neither the purged data nor the historical verdicts — -both are destructive; downgrade just drops the audit table. -""" -from typing import Sequence, Union - -import sqlalchemy as sa -from alembic import op - - -revision: str = "0026" -down_revision: Union[str, None] = "0025" -branch_labels: Union[str, Sequence[str], None] = None -depends_on: Union[str, Sequence[str], None] = None - - -def upgrade() -> None: - # 1. Purge — server holds no portfolio data. - op.execute("DELETE FROM portfolio_sync") - op.execute("DELETE FROM ticker_universe") - - # 2. Audit trail for the two-layer output reviewer. Append-only. - op.create_table( - "reviewer_verdicts", - sa.Column("id", sa.BigInteger().with_variant(sa.Integer(), "sqlite"), - primary_key=True, autoincrement=True), - sa.Column("created_at", sa.DateTime(timezone=True), nullable=False, - server_default=sa.text("CURRENT_TIMESTAMP")), - sa.Column("surface", sa.String(length=32), nullable=True), - sa.Column("candidate_text", sa.Text(), nullable=False), - sa.Column("clean", sa.Boolean(), nullable=False), - sa.Column("reason", sa.String(length=255), nullable=True), - sa.Column("layer", sa.String(length=16), nullable=False), - sa.Column("model", sa.String(length=64), nullable=True), - ) - op.create_index( - "ix_reviewer_verdicts_created_at", - "reviewer_verdicts", ["created_at"], - ) - op.create_index( - "ix_reviewer_verdicts_surface", - "reviewer_verdicts", ["surface"], - ) - op.create_index( - "ix_reviewer_verdicts_clean", - "reviewer_verdicts", ["clean"], - ) - - -def downgrade() -> None: - op.drop_index("ix_reviewer_verdicts_clean", table_name="reviewer_verdicts") - op.drop_index("ix_reviewer_verdicts_surface", table_name="reviewer_verdicts") - op.drop_index("ix_reviewer_verdicts_created_at", table_name="reviewer_verdicts") - op.drop_table("reviewer_verdicts") - # Purges are not restored on downgrade — the data is gone. diff --git a/alembic/versions/0027_user_acknowledgements.py b/alembic/versions/0027_user_acknowledgements.py deleted file mode 100644 index 7332ee2..0000000 --- a/alembic/versions/0027_user_acknowledgements.py +++ /dev/null @@ -1,61 +0,0 @@ -"""user_acknowledgements: affirmative sign-up acknowledgement audit table. - -Revision ID: 0027 -Revises: 0026 -Create Date: 2026-05-29 - -One row per (user, version) acceptance of the /login acknowledgement. -See app/models.py::UserAcknowledgement and app/legal.py for context. -""" -from typing import Sequence, Union - -import sqlalchemy as sa -from alembic import op - - -revision: str = "0027" -down_revision: Union[str, None] = "0026" -branch_labels: Union[str, Sequence[str], None] = None -depends_on: Union[str, Sequence[str], None] = None - - -def upgrade() -> None: - op.create_table( - "user_acknowledgements", - sa.Column( - "id", - sa.BigInteger().with_variant(sa.Integer(), "sqlite"), - primary_key=True, autoincrement=True, - ), - sa.Column( - "user_id", sa.Integer(), - sa.ForeignKey("users.id", ondelete="CASCADE"), - nullable=False, - ), - sa.Column("version", sa.SmallInteger(), nullable=False), - sa.Column("lang", sa.String(length=8), nullable=False), - sa.Column( - "accepted_at", sa.DateTime(timezone=True), nullable=False, - server_default=sa.text("CURRENT_TIMESTAMP"), - ), - ) - op.create_index( - "ix_user_acknowledgements_user_version", - "user_acknowledgements", ["user_id", "version"], - ) - op.create_index( - "ix_user_acknowledgements_accepted_at", - "user_acknowledgements", ["accepted_at"], - ) - - -def downgrade() -> None: - op.drop_index( - "ix_user_acknowledgements_accepted_at", - table_name="user_acknowledgements", - ) - op.drop_index( - "ix_user_acknowledgements_user_version", - table_name="user_acknowledgements", - ) - op.drop_table("user_acknowledgements") diff --git a/alembic/versions/0028_log_feedback_and_reviewer_score.py b/alembic/versions/0028_log_feedback_and_reviewer_score.py deleted file mode 100644 index ac4cd7f..0000000 --- a/alembic/versions/0028_log_feedback_and_reviewer_score.py +++ /dev/null @@ -1,93 +0,0 @@ -"""strategic_log_feedback + reviewer_score columns. - -Revision ID: 0028 -Revises: 0027 -Create Date: 2026-05-29 - -Two unrelated features bundled because they ship together: - -1. **strategic_log_feedback** — thumb up/down votes per (log, user). - UNIQUE on (log_id, user_id) enforces one vote per user per log, - flippable in place. The UI shows aggregate counts only. - -2. **reviewer_score** — the output reviewer now self-rates each - verdict 0-10 (10 = exemplary editorial, 0 = unfit). Stored on - strategic_logs, indicator_summaries, and every reviewer_verdicts - audit row, as nullable SMALLINT so existing rows aren't backfilled. -""" -from typing import Sequence, Union - -import sqlalchemy as sa -from alembic import op - - -revision: str = "0028" -down_revision: Union[str, None] = "0027" -branch_labels: Union[str, Sequence[str], None] = None -depends_on: Union[str, Sequence[str], None] = None - - -def upgrade() -> None: - # --- reviewer_score columns (nullable SMALLINT) ----------------------- - op.add_column( - "strategic_logs", - sa.Column("reviewer_score", sa.SmallInteger(), nullable=True), - ) - op.add_column( - "indicator_summaries", - sa.Column("reviewer_score", sa.SmallInteger(), nullable=True), - ) - op.add_column( - "reviewer_verdicts", - sa.Column("score", sa.SmallInteger(), nullable=True), - ) - - # --- strategic_log_feedback table ------------------------------------- - op.create_table( - "strategic_log_feedback", - sa.Column( - "id", - sa.BigInteger().with_variant(sa.Integer(), "sqlite"), - primary_key=True, autoincrement=True, - ), - sa.Column( - "log_id", - sa.BigInteger().with_variant(sa.Integer(), "sqlite"), - sa.ForeignKey("strategic_logs.id", ondelete="CASCADE"), - nullable=False, - ), - sa.Column( - "user_id", sa.Integer(), - sa.ForeignKey("users.id", ondelete="CASCADE"), - nullable=False, - ), - # 'up' or 'down'. Storing as varchar keeps the column readable in - # the DB shell; the enum-ness is enforced at the service layer. - sa.Column("vote", sa.String(length=8), nullable=False), - sa.Column( - "created_at", sa.DateTime(timezone=True), nullable=False, - server_default=sa.text("CURRENT_TIMESTAMP"), - ), - sa.Column( - "updated_at", sa.DateTime(timezone=True), nullable=False, - server_default=sa.text("CURRENT_TIMESTAMP"), - ), - sa.UniqueConstraint( - "log_id", "user_id", name="uq_slf_log_user", - ), - ) - op.create_index( - "ix_strategic_log_feedback_log", - "strategic_log_feedback", ["log_id"], - ) - - -def downgrade() -> None: - op.drop_index( - "ix_strategic_log_feedback_log", - table_name="strategic_log_feedback", - ) - op.drop_table("strategic_log_feedback") - op.drop_column("reviewer_verdicts", "score") - op.drop_column("indicator_summaries", "reviewer_score") - op.drop_column("strategic_logs", "reviewer_score") diff --git a/app/cli.py b/app/cli.py index 8e546e7..c780f0b 100644 --- a/app/cli.py +++ b/app/cli.py @@ -146,90 +146,6 @@ 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) @@ -249,15 +165,6 @@ 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 @@ -274,9 +181,6 @@ 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 07052d5..6c5b0ee 100644 --- a/app/config.py +++ b/app/config.py @@ -50,17 +50,6 @@ 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 = "" @@ -116,25 +105,6 @@ 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 ed1b5e0..197faa5 100644 --- a/app/jobs/ai_log_job.py +++ b/app/jobs/ai_log_job.py @@ -206,8 +206,7 @@ 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, - surface="log", session=session) + verdict = await review_read(client, result.content) full_cost = (result.cost_usd or 0.0) + (verdict.cost_usd or 0.0) if not verdict.clean: session.add(AICall( @@ -234,7 +233,6 @@ 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 ea20688..4cbd865 100644 --- a/app/jobs/email_digest_job.py +++ b/app/jobs/email_digest_job.py @@ -198,32 +198,17 @@ def _pick_variant( async def _send_one(user: User, kind: str, content_html: str, date_str: str, - session, *, latest_log_id: int | None = None) -> None: + session) -> None: settings_url = f"{branding.SITE_URL}/settings" 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, @@ -303,18 +288,6 @@ 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() @@ -323,8 +296,7 @@ async def run() -> None: tone=tone, lang=(u.lang or "en"), ) - await _send_one(u, kind, content, date_str, session, - latest_log_id=latest_log_id) + await _send_one(u, kind, content, date_str, session) await asyncio.sleep(0.1) written += 1 diff --git a/app/jobs/indicator_summary_job.py b/app/jobs/indicator_summary_job.py index 3d401d5..422c49c 100644 --- a/app/jobs/indicator_summary_job.py +++ b/app/jobs/indicator_summary_job.py @@ -184,8 +184,7 @@ async def _generate_one( )) return None - verdict = await review_read(client, candidate, - surface="indicator", session=session) + verdict = await review_read(client, candidate) if not verdict.clean: # Reviewer caught scratchpad / meta-commentary / partial text # INSIDE the read field. Drop the candidate; the previous good @@ -215,7 +214,6 @@ async def _generate_one( # Include the reviewer's cost in the row's recorded spend so the # 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( @@ -316,10 +314,7 @@ async def run() -> None: cost_usd=result.cost_usd, status="leaked", )) else: - verdict = await review_read( - client, candidate, - surface="indicator_aggregate", session=session, - ) + verdict = await review_read(client, candidate) 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", @@ -343,7 +338,6 @@ 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 deleted file mode 100644 index 94f27f3..0000000 --- a/app/legal.py +++ /dev/null @@ -1,19 +0,0 @@ -"""Legal-surface constants. - -Currently a one-liner: the version of the sign-up acknowledgement text -the user actively accepts at ``/login``. - -Bumping this constant re-prompts every existing user on their next -sign-in, because the new value won't match their stored -``user_acknowledgements.version`` rows. The recorded row is what gives -the acknowledgement its evidentiary weight; the constant is the pointer -that says "the current canonical text is at version N". The text itself -lives in ``app/locales/{en,it}.yaml`` under ``auth.ack``. - -When the solicitor finalises wording, edit the YAML files **and** bump -this number in the same change so the version pointer stays accurate. -""" -from __future__ import annotations - - -ACKNOWLEDGEMENT_VERSION: int = 1 diff --git a/app/locales/en.yaml b/app/locales/en.yaml deleted file mode 100644 index caef603..0000000 --- a/app/locales/en.yaml +++ /dev/null @@ -1,134 +0,0 @@ -# English copy for the public landing page. -# -# Strings that contain inline HTML are rendered with the `safe` filter -# in the template. Keep markup minimal — , , 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 deleted file mode 100644 index 34f9098..0000000 --- a/app/locales/it.yaml +++ /dev/null @@ -1,140 +0,0 @@ -# Italian copy for the public landing page. -# -# Note: brand names ("Read the Markets") and proper nouns -# (tickers, currency codes) stay verbatim — they aren't translated. - -hero: - tagline: "Capisci i mercati. Non scommetterci sopra." - subhead: >- - Pensato per investitori che vogliono 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 148f5dc..7f1729f 100644 --- a/app/main.py +++ b/app/main.py @@ -66,12 +66,6 @@ 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 d8d3c25..57c9f19 100644 --- a/app/models.py +++ b/app/models.py @@ -118,10 +118,6 @@ 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): @@ -174,8 +170,6 @@ 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"),) @@ -226,100 +220,6 @@ 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 bf78eb0..5075654 100644 --- a/app/routers/api.py +++ b/app/routers/api.py @@ -285,13 +285,11 @@ 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, @@ -301,7 +299,6 @@ def _log_partial_payload( "cost_usd": row.cost_usd, "prompt_tokens": row.prompt_tokens, "completion_tokens": row.completion_tokens, - "feedback": feedback, } @@ -407,12 +404,9 @@ 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, feedback=feedback, - ), + {"log": _log_partial_payload(row, content_override=content_override), "tone": wanted_tone, "paid": not free_only}, ) @@ -421,21 +415,6 @@ 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, @@ -480,12 +459,9 @@ 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, feedback=feedback, - ), + {"log": _log_partial_payload(row, content_override=content_override), "tone": wanted_tone, "paid": not free_only}, ) if row is None: @@ -493,53 +469,6 @@ 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 aa7b036..28a7d4d 100644 --- a/app/routers/auth.py +++ b/app/routers/auth.py @@ -34,38 +34,13 @@ 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, - has_acknowledged_current, - record_acknowledgement, -) +from app.services.auth_service import AuthError, get_or_create_user, get_user 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"]) @@ -136,7 +111,6 @@ 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" @@ -147,27 +121,15 @@ async def login_page( await referral_service.lookup_referrer(session, ref_norm) if ref_norm else None ) - resolved_lang = _resolve_login_lang(request, lang) - response = templates.TemplateResponse( + return 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") @@ -176,9 +138,6 @@ 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() @@ -191,27 +150,6 @@ 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 @@ -226,9 +164,7 @@ 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, - "lang": resolved_lang, "t": t, - "ack_version": ACKNOWLEDGEMENT_VERSION}, + "referrer_present": referrer is not None}, status_code=400, ) @@ -239,15 +175,6 @@ 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 4c921b0..6e12a8e 100644 --- a/app/routers/chat.py +++ b/app/routers/chat.py @@ -188,8 +188,7 @@ 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, - surface="chat", session=session) + verdict = await review_read(client, result.content) 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 4be5276..1801f93 100644 --- a/app/routers/pages.py +++ b/app/routers/pages.py @@ -13,44 +13,9 @@ 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. @@ -62,19 +27,11 @@ async def root_page( request: Request, cu: CurrentUser | None = Depends(maybe_current_user), ): - """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.""" + """Dual-purpose root: dashboard when authenticated, landing otherwise.""" if cu is 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 templates.TemplateResponse( + request, "landing.html", {"cu": None}, ) - return RedirectResponse(url=f"/{lang}/", status_code=302) s = get_settings() groups = load_groups(s.BASELINE_TOML, s.PORTFOLIO_TOML) return templates.TemplateResponse( @@ -85,27 +42,6 @@ async def root_page( ) -@router.get("/en/", response_class=HTMLResponse) -async def landing_en( - request: Request, - cu: CurrentUser | None = Depends(maybe_current_user), -): - """English landing. For logged-in users with a non-en `user.lang` - we still serve EN content here because the URL is an explicit - request — same shape as a manual toggle click. The cookie gets - set to en so subsequent /-visits keep them in English.""" - return _render_landing(request, cu, lang="en") - - -@router.get("/it/", response_class=HTMLResponse) -async def landing_it( - request: Request, - cu: CurrentUser | None = Depends(maybe_current_user), -): - """Italian landing. Same explicit-URL contract as landing_en.""" - return _render_landing(request, cu, lang="it") - - @router.get( "/news", response_class=HTMLResponse, @@ -176,60 +112,6 @@ async def log_page_day( ) -@router.get("/feedback", response_class=HTMLResponse) -async def log_feedback_via_token( - request: Request, - token: str, - vote: str | None = None, - session: AsyncSession = Depends(get_session), -): - """Email-link target for thumb up/down votes on a strategic log. - - The signed token encodes (user_id, log_id, intended_vote). The query - param ``vote`` is informational (lets the URL be self-describing in - the inbox); the canonical vote is what's in the token. If the two - disagree the token wins. - - Renders a small thank-you confirmation. No auth required — the token - is the auth-equivalent for this single side-effecting action.""" - from app.services.log_feedback import ( - FeedbackError, set_vote, verify_feedback_token, - ) - - payload = verify_feedback_token(token) - if payload is None: - return templates.TemplateResponse( - request, "feedback_thanks.html", - {"ok": False, "message": "This link has expired or is invalid.", - "log_id": None, "vote": None}, - status_code=400, - ) - - try: - counts = await set_vote( - session, - log_id=payload["log_id"], - user_id=payload["user_id"], - vote=payload["vote"], - ) - except FeedbackError as e: - return templates.TemplateResponse( - request, "feedback_thanks.html", - {"ok": False, "message": str(e), "log_id": payload["log_id"], - "vote": payload["vote"]}, - status_code=400, - ) - - return templates.TemplateResponse( - request, "feedback_thanks.html", - {"ok": True, - "vote": payload["vote"], - "log_id": payload["log_id"], - "counts": counts, - "message": None}, - ) - - @router.get("/settings", response_class=HTMLResponse) async def settings_page( request: Request, diff --git a/app/routers/public.py b/app/routers/public.py index 661fadf..33bd245 100644 --- a/app/routers/public.py +++ b/app/routers/public.py @@ -16,7 +16,6 @@ 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 @@ -30,11 +29,7 @@ def _ctx(request: Request, cu: CurrentUser | None) -> dict: return {"cu": cu} -@router.get( - "/pricing", - response_class=HTMLResponse, - dependencies=[Depends(require_flag("SUBSCRIPTIONS_ENABLED"))], -) +@router.get("/pricing", response_class=HTMLResponse) 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 30ab079..bfdeed0 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 +from typing import Any, Literal, Optional import stripe from fastapi import APIRouter, Body, Depends, HTTPException, Request @@ -34,16 +34,10 @@ from app.config import get_settings from app.db import get_session, utcnow from app.logging import get_logger from app.models import StripeEvent, User -from app.services.feature_flags import require_flag log = get_logger("stripe_billing") -# 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"))], -) +router = APIRouter() # Cap stored payload at 16 KiB so a hostile (or buggy) sender can't @@ -75,21 +69,51 @@ def _price_for(cadence: str) -> str: raise HTTPException(status_code=400, detail="cadence must be 'monthly' or 'annual'") -# 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. +# 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" def _stripe_client() -> stripe.StripeClient: @@ -106,6 +130,10 @@ 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): @@ -115,6 +143,7 @@ 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: @@ -142,19 +171,14 @@ 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", } - # 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. - # + # 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) # Per-cadence cooling-off treatment: # # - Annual gets a 14-day free trial. No money moves during the @@ -173,12 +197,6 @@ async def create_checkout( create_kwargs["subscription_data"] = {"trial_period_days": 14} 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 @@ -308,15 +326,11 @@ async def _grant_paid( await convert_referral(session, user) -async def _revoke_paid(user: User, *, keep_subscription: bool = False) -> None: +async def _revoke_paid(user: User) -> None: user.tier = "free" - if not keep_subscription: - user.stripe_subscription_id = None + 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( @@ -354,16 +368,6 @@ 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 @@ -390,22 +394,6 @@ 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: @@ -420,8 +408,6 @@ _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 7b4ecf1..0fa1174 100644 --- a/app/routers/sync.py +++ b/app/routers/sync.py @@ -20,17 +20,11 @@ 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") -# 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"))], -) +router = APIRouter(prefix="/api/portfolio/sync") # 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 aa54ddf..ea1d633 100644 --- a/app/routers/universe.py +++ b/app/routers/universe.py @@ -41,7 +41,6 @@ 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 @@ -339,10 +338,7 @@ async def parse_portfolio( # --------------------------------------------------------------------------- -@router.post( - "/analyze", - dependencies=[Depends(require_flag("PORTFOLIO_AI_ENABLED"))], -) +@router.post("/analyze") async def analyze_portfolio( request: Request, session: AsyncSession = Depends(get_session), @@ -353,8 +349,8 @@ async def analyze_portfolio( is persisted. The ai_calls ledger row records tokens + cost, never holdings. - Gated behind ``PORTFOLIO_AI_ENABLED`` (404 when off) and ``require_paid`` - (402 for free tier when subscriptions are active).""" + Gated behind ``require_paid``: free-tier users get 402. + Admin bearer-token bypasses the gate for testing.""" # 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 10e04c9..2f91f7a 100644 --- a/app/services/access.py +++ b/app/services/access.py @@ -21,7 +21,6 @@ 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 @@ -77,22 +76,13 @@ 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. - - 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. - """ + bearer-token (``CurrentUser.is_admin=True``) always passes.""" 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 558b4d2..4791ee8 100644 --- a/app/services/auth_service.py +++ b/app/services/auth_service.py @@ -17,8 +17,7 @@ from sqlalchemy import select from sqlalchemy.ext.asyncio import AsyncSession from app.db import utcnow -from app.legal import ACKNOWLEDGEMENT_VERSION -from app.models import User, UserAcknowledgement +from app.models import User class AuthError(Exception): @@ -70,35 +69,3 @@ 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 760a004..3d416f6 100644 --- a/app/services/digest_email.py +++ b/app/services/digest_email.py @@ -47,7 +47,6 @@ _DIGEST_HTML_TEMPLATE = """\
 
- {feedback_row}
 
Unsubscribe in one click @@ -71,32 +70,6 @@ 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, @@ -104,17 +77,10 @@ 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. - - 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``).""" + `kind` is "daily" or "weekly". Anything else raises ValueError.""" if kind == "daily": label = "Daily" subject = f"{branding.BRAND_NAME} · Daily — {date_str}" @@ -124,12 +90,6 @@ 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(), @@ -139,7 +99,6 @@ 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()}, ) @@ -150,16 +109,8 @@ 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 deleted file mode 100644 index 7cd7f83..0000000 --- a/app/services/feature_flags.py +++ /dev/null @@ -1,41 +0,0 @@ -"""Feature-flag gate helpers. - -The four compliance flags in ``app.config.Settings`` (``PORTFOLIO_AI_ENABLED``, -``PORTFOLIO_SYNC_ENABLED``, ``TICKER_UNIVERSE_AGGREGATE_ENABLED``, -``SUBSCRIPTIONS_ENABLED``) gate code paths that stay in the tree but are -inactive by default. Routes / dependencies use ``require_flag()`` to 404 a -whole endpoint when its flag is off — making the surface indistinguishable -from a non-existent route. -""" -from __future__ import annotations - -from fastapi import HTTPException, status - -from app.config import get_settings - - -def flag_enabled(flag_name: str) -> bool: - """Read a boolean flag from Settings. Unknown flags raise — typos here - would silently disable features otherwise.""" - settings = get_settings() - if not hasattr(settings, flag_name): - raise AttributeError(f"unknown feature flag: {flag_name}") - return bool(getattr(settings, flag_name)) - - -def require_flag(flag_name: str): - """FastAPI dependency factory: 404 the route if the flag is off. - - Usage:: - - @router.post("/analyze", dependencies=[Depends(require_flag("PORTFOLIO_AI_ENABLED"))]) - - 404 (not 503) is deliberate: a paused feature should be indistinguishable - from a missing route to clients and crawlers.""" - async def _gate() -> None: - if not flag_enabled(flag_name): - raise HTTPException( - status_code=status.HTTP_404_NOT_FOUND, - detail="not found", - ) - return _gate diff --git a/app/services/llm_prompts.py b/app/services/llm_prompts.py index 7806754..726b60a 100644 --- a/app/services/llm_prompts.py +++ b/app/services/llm_prompts.py @@ -28,47 +28,7 @@ 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. -# 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. -""" +PROMPT_VERSION = 9 # --- Core: invariant across tone/analysis settings ---------------------------- @@ -109,10 +69,8 @@ weather or generic context. - Then 4-6 paragraphs, each anchored on a sleeve, sector, or theme. Concrete \ numbers in every paragraph. No section over ~150 words. - One paragraph synthesising the news flow into a market read. -- 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. +- End with a watch list: 3-5 specific items to track in the next week, \ +each one sentence. # Time-horizon discipline - This is a STRATEGIC log, not a day-trader's read. Treat 1-day moves under \ @@ -121,6 +79,9 @@ multi-week trend or are extreme outliers. - Anchor every claim to multi-week (1m), multi-month (since-anchor), or \ 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 \ @@ -149,23 +110,8 @@ 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. 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. +- No buy/sell recommendations. Triggers are pre-set elsewhere; your job is \ +to report whether reality is confirming, modifying, or refuting the thesis. # Stance (educational, anti-TA, anti-gambling) The target reader is most likely young, new to investing, and at risk of \ @@ -173,10 +119,9 @@ treating markets like a horse race they need to "read" via chart patterns. \ Cassandra is the corrective. - **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. \ -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. +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. - **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 → \ @@ -190,9 +135,9 @@ Close the log with a single sentence on a line of its own, formatted exactly: System temperature: [cool|neutral|elevated|hot|extreme] — [one clause naming the 2-3 specific divergences or readings that justify the label] -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. +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. # Update mode (when an earlier log from today is provided) If the user message includes a section labelled "Earlier log from today \ @@ -203,6 +148,8 @@ that timestamp: confirmations, refutations, new emergent patterns. - The TL;DR should lead with the move since the earlier read when there \ 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 \ @@ -303,18 +250,17 @@ 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.""", +references. Do not speculate on what comes next. Forward-looking statements \ +are limited to "what would invalidate the read" — never "we expect X to \ +happen". The watch list contains items to monitor, not predictions.""", "SPECULATIVE": """# Analysis style: speculative -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.""", +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.""", } @@ -322,7 +268,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([_COMPLIANCE_RIDER, _CORE, tone_block, analysis_block]) + return "\n\n".join([_CORE, tone_block, analysis_block]) # Backwards-compat: a default-composed SYSTEM_PROMPT for tests / callers that @@ -335,7 +281,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, and sectional structure. Just answer. +- Forget the date header, TL;DR, sectional structure, and watch list. Just answer. - Typical response: 200-400 words. Longer only if the question genuinely warrants it. - Cite specific numbers and named headlines from the reference materials @@ -343,15 +289,7 @@ question via the chat sidebar. - If a question is outside the provided context (e.g. asking about a stock or event not in the data), say so plainly rather than speculating from prior knowledge. -- 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. +- No buy/sell recommendations. If asked, redirect to thesis and scenarios. - Keep the same audience and analysis discipline established above.""" @@ -367,9 +305,7 @@ def build_summary_system_prompt(tone: str, analysis: str) -> str: field is caught by the reviewer agent (services/output_review).""" tone_block = _TONE[_resolve_tone(tone)] analysis_block = _ANALYSIS.get(analysis.upper(), _ANALYSIS["SPECULATIVE"]) - return f"""{_COMPLIANCE_RIDER} - -You write a TINY interpretation (≤60 words, 2-3 sentences) \ + return f"""You write a TINY interpretation (≤60 words, 2-3 sentences) \ of ONE indicator group for a strategic markets dashboard. # Output format (strict) @@ -405,10 +341,8 @@ finished read, not the thinking. - Cite at most 2-3 specific numbers and ONLY when they anchor an \ 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 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. +- 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. {tone_block} @@ -436,9 +370,7 @@ def build_aggregate_summary_system_prompt(tone: str, analysis: str) -> str: {"read": "..."} only; the field is the publishable text verbatim.""" tone_block = _TONE[_resolve_tone(tone)] analysis_block = _ANALYSIS.get(analysis.upper(), _ANALYSIS["SPECULATIVE"]) - return f"""{_COMPLIANCE_RIDER} - -You write a single SHORT cross-asset INTERPRETATION (≤80 \ + return f"""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. @@ -474,9 +406,7 @@ parenthetical asides that question your own numbers. risk premium is in commodities but not vol". Cite no more than 3 specific \ 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 forward price calls on named instruments. \ -No targets, floors, ceilings, support/resistance, "close above/below", or \ -trigger framing of any kind. +- No buy/sell language. No predictions of specific levels. {tone_block} @@ -507,13 +437,7 @@ 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. - - 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.""" + headlines as reference material the model can cite from.""" parts = [build_system_prompt(tone, analysis), "", _CHAT_OVERRIDES, ""] if reference_line: parts.append(f"# Doc reference snapshot\n{reference_line}\n") @@ -615,16 +539,11 @@ def build_daily_digest_prompt( 24h and looks forward to the upcoming session. Longer, less '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 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 " + "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 " "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 4689f34..5f19f4d 100644 --- a/app/templates/partials/news.html +++ b/app/templates/partials/news.html @@ -33,7 +33,7 @@ {% endfor %} {% endif %} -{% if capped and SUBSCRIPTIONS_ENABLED %} +{% if capped %}
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 d9e0a32..99d4dc0 100644 --- a/app/templates/partials/portfolio.html +++ b/app/templates/partials/portfolio.html @@ -1,6 +1,3 @@ -{# Compliance: portfolio is a neutral composition viewer — report numbers, - never append a verdict. No "over-concentrated", no "consider X", no colour- - coded warning badges. See docs/read-markets-compliance-changes.md TASK 1. #} {% if not portfolios %}
no portfolio snapshots yet
{% else %} diff --git a/app/templates/pricing.html b/app/templates/pricing.html index 875a67a..c32fb26 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 a browser-only portfolio composition - viewer, and turns on the daily email digest on top of the Sunday - recap everyone gets. + 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.

@@ -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 the portfolio composition viewer? See Paid → + Need the full-day news feed, hourly strategic log, follow-up chat, daily digests, or portfolio analysis? See Paid
    {% if cu and (cu.user or cu.is_admin) %} @@ -47,7 +47,7 @@