read.markets/app/jobs/email_digest_job.py
Giorgio Gilestro 8946dee2e0 feedback: thumb up/down on logs + reviewer self-score 0-10
Two unrelated features bundled because they ship together and share
migration 0028.

Strategic-log feedback (thumb up/down):
- New strategic_log_feedback table with UNIQUE(log_id, user_id) so each
  user has one vote per log, flippable in place (up -> down -> clear).
  UI shows aggregate counts only.
- app/services/log_feedback.py: set_vote, get_counts, sign/verify
  feedback tokens (same itsdangerous pattern as auth.sign_pending,
  30-day TTL for email links).
- POST /api/log/{id}/feedback: web vote, auth required, returns counts
  + the requesting user's own vote.
- GET /feedback?token=...&vote=...: email-link target, no auth, signed
  token encodes (user, log, vote), renders feedback_thanks.html.
- partials/log.html: thumbs row below content, JS-driven swap via the
  POST endpoint. Dashboard latest-log card and /log page both render
  this partial via htmx, so the buttons appear in all three surfaces.
- digest emails: a "How was today's read?" row above the unsub footer,
  with signed-token URLs against the latest StrategicLog at send time.
  Plain-text fallback included.

Reviewer self-score (0-10):
- _SYSTEM_PROMPT asks for an integer score with anchors (10 exemplary,
  5 borderline, 0 unfit). Verdict gains score: int | None.
- Deterministic-layer hits get score=0 (hard rule, no nuance);
  error rows get None; LLM rows get the model's score clamped 0..10.
- ReviewerVerdict.score, StrategicLog.reviewer_score, and
  IndicatorSummary.reviewer_score all new SMALLINT NULL columns.
- ai_log_job + indicator_summary_job persist verdict.score onto their
  content rows when committing the row alongside content.

Tests:
- tests/test_strategic_log_feedback.py: vote, flip, clear, aggregate
  across users, invalid vote, token round-trip + tamper + garbage +
  'clear' not signable for email path.
- tests/test_output_review.py: score parsing, clamping (>10, <0),
  missing/non-numeric -> None, deterministic-layer score=0.

Full suite: 427 passed (was 412), 5 skipped, no regressions.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-29 21:28:03 +02:00

337 lines
12 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"""Daily/weekly editorial email digest.
Runs once a day at 06:30 UTC via the scheduler. On Sundays sends the
weekly recap to every opt-in user (free + paid). On other days sends
the daily digest to opt-in paid users only.
Generates LLM content once per tone (NOVICE + INTERMEDIATE), then fans
out by SMTP. EmailSend audit rows guard against double-delivery if the
job is re-run within the same UTC day.
"""
from __future__ import annotations
import asyncio
from datetime import datetime, timedelta
import httpx
from sqlalchemy import select
from app import branding
from app.config import get_settings
from app.db import utcnow
from app.jobs._helpers import job_lifecycle, log
from app.jobs._market_context import (
REFERENCE_LINE,
latest_quotes_by_group,
month_spend,
recent_headlines_by_bucket,
)
from app.models import EmailSend, User
from app.routers.email import sign_unsubscribe_token
from app.services.access import paid_status
from app.services.digest_email import render_digest_email
from app.services.email_service import send_email
from app.services.i18n import ACTIVE_LANGUAGES
from app.services.llm_prompts import (
PROMPT_VERSION,
build_daily_digest_prompt,
build_weekly_digest_prompt,
)
from app.services.openrouter import (
call_llm,
llm_configured,
)
from app.services.output_review import review_read
from app.services.translation import translate
def _now() -> datetime:
"""Indirection so tests can monkeypatch the "current time" without
touching the system clock."""
return utcnow()
async def _opt_in_recipients(session, *, paid_only: bool) -> list[User]:
stmt = select(User).where(User.email_digest_opt_in.is_(True))
rows = (await session.execute(stmt)).scalars().all()
if paid_only:
rows = [u for u in rows if paid_status(u).active]
return rows
async def _already_sent_today(session, user_id: int, kind: str, today: datetime) -> bool:
"""True if an EmailSend row exists for this user+kind on the same UTC
day, with status in ('sent','error'). 'error' counts because we don't
want to keep retrying a bad address inside the same daily slot."""
day_start = today.replace(hour=0, minute=0, second=0, microsecond=0)
day_end = day_start + timedelta(days=1)
stmt = select(EmailSend.id).where(
EmailSend.user_id == user_id,
EmailSend.kind == kind,
EmailSend.sent_at >= day_start,
EmailSend.sent_at < day_end,
EmailSend.status.in_(("sent", "error")),
)
return (await session.execute(stmt)).first() is not None
async def _generate_variants(session, client, kind: str, ctx: dict) -> dict[str, str]:
"""Returns {tone: html_content}. Missing tone means generation failed
for that variant — skip recipients on that tone.
Persists an AICall row per attempt so digest LLM spend counts toward
the monthly cost cap on subsequent runs."""
from app.models import AICall
from app.services.openrouter import active_model
builder = build_weekly_digest_prompt if kind == "weekly" else build_daily_digest_prompt
out: dict[str, str] = {}
for tone in ("NOVICE", "INTERMEDIATE"):
sys_, usr = builder(tone=tone, **ctx)
try:
result = await call_llm(
client,
[{"role": "system", "content": sys_},
{"role": "user", "content": usr}],
)
# Reviewer gate. Digest emails land in inboxes — once
# delivered they're unrecallable, so a financial-advice slip
# has more reach than the dashboard. Drop rejected variants;
# users on that tone get no digest this cycle (better than
# delivering bad copy).
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(
model=result.model,
prompt_tokens=result.prompt_tokens,
completion_tokens=result.completion_tokens,
cost_usd=full_cost, status="leaked",
error=f"reviewer: {verdict.reason}",
))
await session.commit()
log.warning("digest.reviewer_rejected", kind=kind, tone=tone,
reason=verdict.reason, preview=result.content[:120])
continue
out[tone] = result.content
session.add(AICall(
model=result.model,
prompt_tokens=result.prompt_tokens,
completion_tokens=result.completion_tokens,
cost_usd=full_cost,
status="ok",
))
await session.commit()
log.info("digest.variant_ok", kind=kind, tone=tone,
prompt_tokens=result.prompt_tokens,
completion_tokens=result.completion_tokens)
except Exception as e:
session.add(AICall(
model=active_model(), status="error",
error=f"{kind}/{tone}: {str(e)[:480]}",
))
await session.commit()
log.error("digest.variant_failed", kind=kind, tone=tone,
error=str(e)[:200])
return out
def _kind_for_today(today: datetime) -> str:
"""Sunday → weekly. MonSat → daily."""
return "weekly" if today.weekday() == 6 else "daily"
async def _translate_variants_for_active_langs(
client,
english_variants: dict[str, str],
target_langs: list[str],
) -> dict[tuple[str, str], str]:
"""Build a {(tone, lang): content_md} table.
Starts with the English variants as the canonical cells. For each
(tone, target_lang) pair where target_lang != 'en', calls translate()
in parallel; on failure the cell falls back to the English variant
of the same tone so the digest still goes out, just untranslated.
"""
table: dict[tuple[str, str], str] = {
(tone, "en"): content for tone, content in english_variants.items()
}
pairs = [
(tone, lang)
for tone in english_variants
for lang in target_langs
if lang != "en"
]
if not pairs:
return table
results = await asyncio.gather(*[
translate(client, english_variants[tone], lang) for tone, lang in pairs
], return_exceptions=True)
for (tone, lang), result in zip(pairs, results):
if isinstance(result, Exception):
log.warning("digest.translate.failed",
tone=tone, lang=lang, error=str(result)[:200])
table[(tone, lang)] = english_variants[tone]
continue
translated_md, _llm_log = result
table[(tone, lang)] = translated_md
return table
def _pick_variant(
table: dict[tuple[str, str], str], tone: str, lang: str,
) -> str:
"""Return the digest content for a recipient.
Lookup order: exact (tone, lang) → (tone, 'en') → ('INTERMEDIATE',
'en') → first table value. The last falls are defensive; the table
always contains at least one English entry when the job is sending.
"""
if (tone, lang) in table:
return table[(tone, lang)]
if (tone, "en") in table:
return table[(tone, "en")]
if ("INTERMEDIATE", "en") in table:
return table[("INTERMEDIATE", "en")]
return next(iter(table.values()))
async def _send_one(user: User, kind: str, content_html: str, date_str: str,
session, *, latest_log_id: int | None = None) -> None:
settings_url = f"{branding.SITE_URL}/settings"
unsubscribe_url = (
f"{branding.SITE_URL}/email/unsubscribe"
f"?token={sign_unsubscribe_token(user.id)}"
)
# Build signed feedback URLs against the latest strategic log at send
# time. The token encodes (user, log, vote) so the recipient can
# click without being logged in; the receiving /feedback endpoint
# verifies the signature and applies the vote.
feedback_up_url = feedback_down_url = None
if latest_log_id is not None:
from app.services.log_feedback import sign_feedback_token
up_tok = sign_feedback_token(user.id, latest_log_id, "up")
down_tok = sign_feedback_token(user.id, latest_log_id, "down")
feedback_up_url = f"{branding.SITE_URL}/feedback?token={up_tok}&vote=up"
feedback_down_url = f"{branding.SITE_URL}/feedback?token={down_tok}&vote=down"
subject, text_body, html_body = render_digest_email(
kind=kind, date_str=date_str,
content_html=content_html,
unsubscribe_url=unsubscribe_url,
settings_url=settings_url,
feedback_up_url=feedback_up_url,
feedback_down_url=feedback_down_url,
)
try:
await send_email(to=user.email, subject=subject,
text_body=text_body, html_body=html_body)
status_ = "sent"
err = None
except Exception as e:
status_ = "error"
err = str(e)[:255]
log.error("digest.send_failed", user_id=user.id, error=err)
session.add(EmailSend(
user_id=user.id, kind=kind, sent_at=_now(),
status=status_, error=err,
))
await session.commit()
async def run() -> None:
async with job_lifecycle("email_digest_job") as (session, jr):
if jr.status == "skipped":
return
s = get_settings()
if not llm_configured():
log.warning("digest.skipped_no_key", provider=s.LLM_PROVIDER)
jr.status = "skipped"
return
today = _now()
kind = _kind_for_today(today)
date_str = today.strftime("%Y-%m-%d")
recipients = await _opt_in_recipients(
session, paid_only=(kind == "daily"),
)
fresh: list[User] = []
for u in recipients:
if not await _already_sent_today(session, u.id, kind, today):
fresh.append(u)
if not fresh:
log.info("digest.no_fresh_recipients", kind=kind,
total=len(recipients))
jr.status = "skipped"
return
spent = await month_spend(session)
if spent >= s.OPENROUTER_MONTHLY_CAP_USD:
log.warning("digest.cap_reached", spent=spent,
cap=s.OPENROUTER_MONTHLY_CAP_USD)
jr.status = "skipped"
jr.error = f"monthly cost cap reached (${spent:.2f})"
return
quotes = await latest_quotes_by_group(session)
news = await recent_headlines_by_bucket(
session, hours=(168 if kind == "weekly" else 24),
)
ctx = dict(
today=today,
quotes_by_group=quotes,
headlines_by_bucket=news,
reference_line=REFERENCE_LINE,
)
async with httpx.AsyncClient(follow_redirects=True) as client:
variants = await _generate_variants(session, client, kind, ctx)
if not variants:
log.warning("digest.all_variants_failed", kind=kind)
jr.status = "failed"
jr.error = "all variants failed"
return
# Build the per-language translation table once per job run.
active_non_en = sorted({l for l in ACTIVE_LANGUAGES if l != "en"})
async with httpx.AsyncClient(follow_redirects=True) as client:
variant_table = await _translate_variants_for_active_langs(
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()
content = _pick_variant(
variant_table,
tone=tone,
lang=(u.lang or "en"),
)
await _send_one(u, kind, content, date_str, session,
latest_log_id=latest_log_id)
await asyncio.sleep(0.1)
written += 1
jr.items_written = written
log.info("digest.done", kind=kind, written=written,
prompt_version=PROMPT_VERSION)
if __name__ == "__main__":
asyncio.run(run())