news: auto-tag headlines + market-aware cadence + filter UI

- Move news_job from hourly to 3x/hour (cron 10,30,50), with a CadencePolicy
  gate that throttles to active hours (07-21 UTC weekdays at 20 min), off-hours
  (3 h), weekends (6 h). Keeps the daytime feed fresh without spamming RSS
  sources overnight.
- Tag each headline on ingestion via DeepSeek (BATCH_SIZE=25, max_tokens=4000,
  json.JSONDecoder().raw_decode + per-row regex recovery for resilient parsing).
  Vocabulary: 16 tags including new EU / USA / AI / Conflict. NULL tags are
  picked up automatically on the next news_job run, so back-tagging is implicit
  rather than a separate migration step.
- Tag UI: pill bar above the feed with off → include → exclude cycle on click;
  shift-click jumps straight to exclude. State persists in localStorage and is
  injected into /api/news requests via htmx:configRequest. Per-row chips sit to
  the right of the headline (new 5-column grid: age | source | title | tags |
  UTC) so vertical density stays high.
- Strategic log header bug: model was hallucinating "(Updated 21:30 UTC)" in
  future tense. Bumped PROMPT_VERSION 6→7, added explicit ban on time-of-day
  clauses, and supply the actual current UTC time in the user prompt so the
  model has no need to invent one.

Migration 0012 adds headlines.tags (JSON, nullable). Tests cover vocabulary
integrity, validation/normalisation, and the JSON-recovery parser (17 tests).
This commit is contained in:
Giorgio Gilestro 2026-05-21 23:25:03 +01:00
parent 6e7f57c6b2
commit 2013bfa8cc
15 changed files with 745 additions and 25 deletions

View file

@ -0,0 +1,29 @@
"""headlines.tags — AI-assigned content tags per headline
Adds a JSON column to `headlines` for semantic tags (markets, geopolitics,
tech, etc.) assigned at ingest time by `app/services/news_tagging.py`.
NULL means "not yet tagged" picked up automatically by the next
news_job run.
Revision ID: 0012
Revises: 0011
Create Date: 2026-05-18
"""
from typing import Sequence, Union
import sqlalchemy as sa
from alembic import op
revision: str = "0012"
down_revision: Union[str, None] = "0011"
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
op.add_column("headlines", sa.Column("tags", sa.JSON, nullable=True))
def downgrade() -> None:
op.drop_column("headlines", "tags")

View file

@ -1,21 +1,36 @@
"""Hourly news ingestion. Reads enabled feeds from the DB (not TOML — DB has """News ingestion + AI tagging.
the authoritative enabled/failure state). Per-ticker Yahoo news pulled for
each symbol in the default portfolio group ('pie').""" Cron fires every 20 minutes. NEWS_POLICY gates the actual work:
- Active window (07-21 UTC weekdays): always run (20-min gap)
- Off-hours weekday: skip until 3h since last success
- Weekend: skip until 6h since last success
Each run does (a) fresh fetch of all enabled feeds + per-ticker Yahoo
news, (b) bulk INSERT IGNORE into headlines, (c) batch-tags any rows
still NULL via news_tagging. Untagged rows survive run failures and are
retried automatically next cycle.
"""
from __future__ import annotations from __future__ import annotations
import asyncio import asyncio
import httpx import httpx
from sqlalchemy import desc, select from sqlalchemy import desc, func, select, update
from sqlalchemy.dialects.mysql import insert as mysql_insert from sqlalchemy.dialects.mysql import insert as mysql_insert
from app.db import utcnow from app.db import utcnow
from app.jobs._helpers import job_lifecycle, log from app.jobs._helpers import job_lifecycle, log
from app.models import Feed, Headline, InstrumentMap, TickerUniverse from app.models import Feed, Headline, InstrumentMap, JobRun, TickerUniverse
from app.services.cadence import NEWS_POLICY
from app.services.news import dedupe, fetch_feed, fetch_yahoo_news from app.services.news import dedupe, fetch_feed, fetch_yahoo_news
from app.services.news_tagging import ToTag, tag_titles
AUTO_DISABLE_AT = 5 AUTO_DISABLE_AT = 5
# Cap on how many untagged headlines a single run will tag. Stops a
# backlog from blowing the cost ledger if the tagger has been failing
# for a while.
TAG_PER_RUN_LIMIT = 200
async def _process_feed(client: httpx.AsyncClient, feed: Feed) -> tuple[Feed, list]: async def _process_feed(client: httpx.AsyncClient, feed: Feed) -> tuple[Feed, list]:
@ -38,6 +53,21 @@ async def run() -> None:
if run.status == "skipped": if run.status == "skipped":
return return
# Market-aware cadence: skip this fire if too soon (off-hours /
# weekend). Active window still runs every 20 min.
last_success = (await session.execute(
select(func.max(JobRun.finished_at)).where(
JobRun.name == "news_job",
JobRun.status == "success",
)
)).scalar()
should_run, reason = NEWS_POLICY.should_run(last_success)
if not should_run:
log.info("news_job.cadence_skip", reason=reason)
run.status = "skipped"
run.error = reason
return
feeds = ( feeds = (
await session.execute(select(Feed).where(Feed.enabled == True)) await session.execute(select(Feed).where(Feed.enabled == True))
).scalars().all() ).scalars().all()
@ -91,8 +121,35 @@ async def run() -> None:
await session.execute(stmt) await session.execute(stmt)
await session.commit() await session.commit()
# Tag any headlines still NULL — fresh inserts from this run plus
# any that failed to tag on previous runs. Bounded by
# TAG_PER_RUN_LIMIT so a long outage doesn't blow the cost ledger.
untagged_rows = (await session.execute(
select(Headline.id, Headline.title)
.where(Headline.tags.is_(None))
.order_by(desc(Headline.published_at))
.limit(TAG_PER_RUN_LIMIT)
)).all()
tagged_count = 0
if untagged_rows:
items = [ToTag(id=int(r.id), title=r.title) for r in untagged_rows]
tags_by_id = await tag_titles(items)
for hid, tags in tags_by_id.items():
await session.execute(
update(Headline)
.where(Headline.id == hid)
.values(tags=tags)
)
tagged_count = len(tags_by_id)
await session.commit()
run.items_written = len(headlines) run.items_written = len(headlines)
log.info("news_job.done", fetched=len(all_headlines), kept=len(headlines)) log.info(
"news_job.done",
fetched=len(all_headlines), kept=len(headlines),
untagged_seen=len(untagged_rows), tagged=tagged_count,
)
if __name__ == "__main__": if __name__ == "__main__":

View file

@ -67,6 +67,10 @@ class Headline(Base):
published_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False) published_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False)
fetched_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=utcnow) fetched_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=utcnow)
fingerprint: Mapped[str] = mapped_column(String(40), nullable=False) # sha1 of normalised title fingerprint: Mapped[str] = mapped_column(String(40), nullable=False) # sha1 of normalised title
# Semantic content tags from app.services.news_tagging. NULL = not yet
# tagged; the next news_job run picks it up. Each entry is one of the
# values in news_tagging.TAG_VOCABULARY.
tags: Mapped[list[str] | None] = mapped_column(JSON, nullable=True)
__table_args__ = ( __table_args__ = (
UniqueConstraint("fingerprint", name="uq_headlines_fingerprint"), UniqueConstraint("fingerprint", name="uq_headlines_fingerprint"),

View file

@ -212,6 +212,13 @@ async def indicators(
# --- News -------------------------------------------------------------------- # --- News --------------------------------------------------------------------
def _split_tag_param(s: str | None) -> set[str]:
"""Parse a comma-separated tags query param, lowercase + trim."""
if not s:
return set()
return {t.strip().lower() for t in s.split(",") if t.strip()}
@router.get("/news") @router.get("/news")
async def news_list( async def news_list(
request: Request, request: Request,
@ -219,19 +226,39 @@ async def news_list(
category: str | None = Query(None), category: str | None = Query(None),
since_hours: float = Query(24.0, ge=0.1, le=720.0), since_hours: float = Query(24.0, ge=0.1, le=720.0),
limit: int = Query(50, ge=1, le=500), limit: int = Query(50, ge=1, le=500),
tags: str | None = Query(None, description="comma-separated include list"),
exclude_tags: str | None = Query(None, description="comma-separated exclude list"),
as_: str | None = Query(default=None, alias="as"), as_: str | None = Query(default=None, alias="as"),
): ):
from app.services.news_tagging import TAG_LABELS, TAG_VOCABULARY
cutoff = utcnow() - timedelta(hours=since_hours) cutoff = utcnow() - timedelta(hours=since_hours)
stmt = select(Headline).where(Headline.published_at >= cutoff) stmt = select(Headline).where(Headline.published_at >= cutoff)
if category: if category:
stmt = stmt.where(Headline.category == category) stmt = stmt.where(Headline.category == category)
stmt = stmt.order_by(desc(Headline.published_at)).limit(limit) # Fetch a wider window than `limit` because we tag-filter client-of-DB.
# JSON column filters in MariaDB are doable but messy; in-Python is
# simple at our scale.
stmt = stmt.order_by(desc(Headline.published_at)).limit(max(limit * 3, 200))
rows = (await session.execute(stmt)).scalars().all() rows = (await session.execute(stmt)).scalars().all()
include = _split_tag_param(tags)
exclude = _split_tag_param(exclude_tags)
def _keep(h: Headline) -> bool:
ts = set(h.tags or [])
if include and not (ts & include):
return False
if exclude and (ts & exclude):
return False
return True
filtered = [h for h in rows if _keep(h)][:limit]
if as_ == "html": if as_ == "html":
now = utcnow() now = utcnow()
items = [] items = []
for h in rows: for h in filtered:
when = _as_utc(h.published_at) if h.published_at else None when = _as_utc(h.published_at) if h.published_at else None
items.append({ items.append({
"age": _fmt_age(now, h.published_at), "age": _fmt_age(now, h.published_at),
@ -240,11 +267,17 @@ async def news_list(
"url": h.url, "url": h.url,
"iso": when.isoformat() if when else None, "iso": when.isoformat() if when else None,
"utc_short": when.strftime("%d %b %H:%M") + "Z" if when else "", "utc_short": when.strftime("%d %b %H:%M") + "Z" if when else "",
"tags": h.tags or [],
}) })
return templates.TemplateResponse( return templates.TemplateResponse(
request, "partials/news.html", {"headlines": items}, request, "partials/news.html",
{"headlines": items,
"tag_vocabulary": TAG_VOCABULARY,
"tag_labels": TAG_LABELS,
"active_include": sorted(include),
"active_exclude": sorted(exclude)},
) )
return [HeadlineOut.model_validate(r, from_attributes=True) for r in rows] return [HeadlineOut.model_validate(r, from_attributes=True) for r in filtered]
# --- Strategic log ----------------------------------------------------------- # --- Strategic log -----------------------------------------------------------

View file

@ -40,7 +40,11 @@ async def main() -> None:
sched = AsyncIOScheduler(timezone="UTC") sched = AsyncIOScheduler(timezone="UTC")
sched.add_job(market_job.run, CronTrigger(minute=5), name="market_job", id="market_job") sched.add_job(market_job.run, CronTrigger(minute=5), name="market_job", id="market_job")
sched.add_job(news_job.run, CronTrigger(minute=10), name="news_job", id="news_job") # 3x/hour: cron fires at xx:10, xx:30, xx:50. NEWS_POLICY inside the
# job throttles off-hours / weekends so most fires no-op when the
# markets are closed.
sched.add_job(news_job.run, CronTrigger(minute="10,30,50"),
name="news_job", id="news_job")
# portfolio_job removed in Phase G — server no longer holds holdings. # portfolio_job removed in Phase G — server no longer holds holdings.
sched.add_job(indicator_summary_job.run, CronTrigger(minute=7), name="indicator_summary_job", id="indicator_summary_job") sched.add_job(indicator_summary_job.run, CronTrigger(minute=7), name="indicator_summary_job", id="indicator_summary_job")
sched.add_job(ai_log_job.run, CronTrigger(minute=20), name="ai_log_job", id="ai_log_job") sched.add_job(ai_log_job.run, CronTrigger(minute=20), name="ai_log_job", id="ai_log_job")

View file

@ -25,6 +25,7 @@ class HeadlineOut(BaseModel):
title: str title: str
url: str url: str
published_at: datetime published_at: datetime
tags: list[str] | None = None # populated by news_tagging; null = pending
class JobStatus(BaseModel): class JobStatus(BaseModel):

View file

@ -29,6 +29,11 @@ class CadencePolicy:
(7, 21), # EU/US (LSE open through NYSE close) (7, 21), # EU/US (LSE open through NYSE close)
# (0, 8), # Asia (Tokyo + HK/Shanghai) — uncomment to add # (0, 8), # Asia (Tokyo + HK/Shanghai) — uncomment to add
) )
# Minimum gap between successful runs DURING the active window. The
# cron may fire more frequently than this — we just skip until enough
# time has passed since the last success. Default 0 means "run on
# every cron fire" (the original AI-job behaviour).
active_gap_h: float = 0.0
# Minimum gap between successful runs outside the active window. # Minimum gap between successful runs outside the active window.
off_hours_gap_h: float = 4.0 off_hours_gap_h: float = 4.0
weekend_gap_h: float = 12.0 weekend_gap_h: float = 12.0
@ -44,7 +49,7 @@ class CadencePolicy:
if now.weekday() >= 5: if now.weekday() >= 5:
return self.weekend_gap_h return self.weekend_gap_h
if self.is_active_window(now): if self.is_active_window(now):
return 0.0 # always run during the active window return self.active_gap_h
return self.off_hours_gap_h return self.off_hours_gap_h
def should_run( def should_run(
@ -55,8 +60,6 @@ class CadencePolicy:
"""Returns (should_run, reason). The reason is human-readable for logs """Returns (should_run, reason). The reason is human-readable for logs
and the job_runs.error column when a run is skipped.""" and the job_runs.error column when a run is skipped."""
now = now or datetime.now(timezone.utc) now = now or datetime.now(timezone.utc)
if self.is_active_window(now):
return True, "active window"
min_gap = self.min_gap_hours(now) min_gap = self.min_gap_hours(now)
if last_success_at is None: if last_success_at is None:
return True, "no prior successful run" return True, "no prior successful run"
@ -64,9 +67,27 @@ class CadencePolicy:
if last_success_at.tzinfo is None: if last_success_at.tzinfo is None:
last_success_at = last_success_at.replace(tzinfo=timezone.utc) last_success_at = last_success_at.replace(tzinfo=timezone.utc)
age_h = (now - last_success_at).total_seconds() / 3600.0 age_h = (now - last_success_at).total_seconds() / 3600.0
if min_gap <= 0 and self.is_active_window(now):
return True, "active window"
if age_h >= min_gap: if age_h >= min_gap:
return True, f"off-hours but last run {age_h:.1f}h ago (≥ {min_gap}h)" band = "active" if self.is_active_window(now) else (
return False, f"off-hours throttled — last run {age_h:.1f}h ago (< {min_gap}h)" "weekend" if now.weekday() >= 5 else "off-hours"
)
return True, f"{band}: last run {age_h:.2f}h ago (≥ {min_gap:.2f}h)"
band = "active" if self.is_active_window(now) else (
"weekend" if now.weekday() >= 5 else "off-hours"
)
return False, f"{band} throttled — last run {age_h:.2f}h ago (< {min_gap:.2f}h)"
# AI jobs: run hot during the active window, throttle off-hours.
DEFAULT_POLICY = CadencePolicy() DEFAULT_POLICY = CadencePolicy()
# News + tagging: 3 runs/hour during the active window (20-min gap),
# every 3h off-hours, every 6h on weekends. Cron fires every 20 min;
# the policy gates whether each fire actually does work.
NEWS_POLICY = CadencePolicy(
active_gap_h=1.0 / 3.0, # 20 minutes
off_hours_gap_h=3.0,
weekend_gap_h=6.0,
)

View file

@ -0,0 +1,290 @@
"""AI-driven content tagging for headlines.
Each headline gets 1-3 tags from a fixed vocabulary (markets, geopolitics,
tech, etc.). Tagging happens at ingest time inside `news_job` only
rows whose `tags` column is still NULL are processed, so re-runs are
idempotent and recover from prior failures naturally.
Implementation notes:
- Titles only (not body) they're informative enough and keep the
prompt + cost small.
- Batched: ~50 titles per LLM call. Returns JSON with one entry per
input id. Unknown / hallucinated tags are dropped against the
vocabulary; an empty tag list falls back to ["other"] so we can tell
"tagged but bland" from "not yet tagged" (NULL).
- Uses the existing call_llm dispatcher DeepSeek-direct primary,
OpenRouter fallback, per Phase G provider config.
"""
from __future__ import annotations
import json
import re
from dataclasses import dataclass
import httpx
from app.logging import get_logger
from app.services.openrouter import call_llm
log = get_logger("news_tagging")
# Frozen vocabulary. Keep ASCII-lowercase, hyphenated. If you add or
# remove a tag, also update the system prompt below and the test fixture.
TAG_VOCABULARY: tuple[str, ...] = (
"markets",
"monetary-policy",
"economy",
"geopolitics",
"conflict", # wars, military actions, armed escalation
"energy",
"commodities",
"tech",
"ai", # AI-specific: model releases, capex, regulation
"crypto",
"corporate",
"regulation",
# Geographic emphasis tags — overlap freely with thematic ones.
"usa",
"eu",
"china",
"other",
)
# Display labels for the toggle UI (Title Case + readable). Keys must
# match TAG_VOCABULARY exactly.
TAG_LABELS: dict[str, str] = {
"markets": "Markets",
"monetary-policy": "Monetary policy",
"economy": "Economy",
"geopolitics": "Geopolitics",
"conflict": "Conflict",
"energy": "Energy",
"commodities": "Commodities",
"tech": "Tech",
"ai": "AI",
"crypto": "Crypto",
"corporate": "Corporate",
"regulation": "Regulation",
"usa": "USA",
"eu": "EU",
"china": "China",
"other": "Other",
}
_VOCAB_SET = frozenset(TAG_VOCABULARY)
# Batch size for one LLM call. Small enough that one batch of output
# (50 items × ~30 tokens each = ~1500 tokens) fits well under any
# reasonable max_tokens, AND so a single batch failure only loses a
# small number of rows to next-cycle retry.
BATCH_SIZE = 25
# Max tags per headline. Stories often touch multiple themes; we cap at
# three so the UI chips don't blow up.
MAX_TAGS_PER_HEADLINE = 3
_SYSTEM_PROMPT = """\
You tag financial / business news headlines with ONE to THREE content tags \
from a fixed vocabulary. You receive a JSON array of headlines, each with \
an `id` and a `title`. Return a JSON array of objects: `{"id": ..., \
"tags": ["...", "..."]}`. Output nothing else no prose, no markdown, no \
preamble. The first character of your response must be `[`.
# Vocabulary (use ONLY these values, lowercase, hyphens not spaces)
## Thematic tags
- markets direct market moves: stocks, bonds, FX, indices
- monetary-policy central banks, rate decisions, QE/QT, Fed/ECB/BOJ
- economy macro data: CPI, GDP, jobs, PMI, retail sales
- geopolitics sanctions, diplomacy, chokepoints, elections, trade
- conflict active wars, military strikes, armed escalation
(use ALONGSIDE geopolitics, not instead of)
- energy oil, gas, OPEC, energy transition, utilities
- commodities gold, copper, agri, industrial metals (non-energy)
- tech Big Tech, chips, semiconductors, software, social media
- ai AI-specific: model releases, AI capex, AI regulation
(overlap with tech freely)
- crypto bitcoin, ethereum, stablecoins, crypto regulation
- corporate earnings, M&A, layoffs, single-company news without
a clear sector fit above
- regulation antitrust, securities regs, EU/SEC rulings, trade rules
## Geographic tags (overlap freely with thematic ones)
- usa US-specific news, US policy, US-driven stories
- eu EU / Eurozone / individual EU member states
- china China-specific news
## Fallback
- other last resort: entertainment, sport, weather, off-topic
# Tagging discipline
- 1 to 3 tags per headline. Prefer 1-2; use 3 only when the story \
genuinely spans multiple themes.
- Tags can OVERLAP. "China bans US chips" ["china", "tech", "geopolitics"].
- For armed conflict, combine: "Israel strikes Lebanon" ["conflict", "geopolitics"].
- For AI stories, prefer "ai" over generic "tech" if the headline is AI-centric.
- Geographic tags are additive: a US-focused tech story ["tech", "usa"].
- "other" is a last resort. If a headline is entertainment, sport, weather, \
or otherwise off-topic for a macro dashboard, tag it "other".
- Order tags by relevance: most specific first.
"""
@dataclass(frozen=True)
class _ToTag:
id: int
title: str
def _validate_tags(raw: list) -> list[str]:
"""Filter a model-returned tag list down to known vocabulary + cap."""
if not isinstance(raw, list):
return []
cleaned: list[str] = []
seen: set[str] = set()
for t in raw:
if not isinstance(t, str):
continue
# Normalise: lowercase, replace spaces with hyphens (common drift).
norm = t.strip().lower().replace(" ", "-")
if norm in _VOCAB_SET and norm not in seen:
cleaned.append(norm)
seen.add(norm)
if len(cleaned) >= MAX_TAGS_PER_HEADLINE:
break
return cleaned
def _parse_batch_response(content: str, expected_ids: set[int]) -> dict[int, list[str]]:
"""Parse the model's JSON output into {id: tags}.
Robust to leading prose / code fences / trailing notes uses
``json.JSONDecoder.raw_decode`` to parse the first complete JSON
value starting at the first ``[``. Anything after that array is
ignored. If the first parse fails, we fall back to extracting
well-formed ``{"id": ..., "tags": [...]}`` objects via regex so a
single corrupt item doesn't lose the whole batch.
"""
out: dict[int, list[str]] = {}
if not content:
return out
# Trim common preambles + code fences.
stripped = content.strip()
# First-`[` to last-position parse via raw_decode.
start = stripped.find("[")
if start == -1:
log.warning("news_tagging.unparseable", preview=content[:120])
return out
try:
data, _end = json.JSONDecoder().raw_decode(stripped[start:])
if isinstance(data, list):
for item in data:
_absorb(item, expected_ids, out)
return out
except json.JSONDecodeError:
pass # fall through to per-item recovery
# Recovery path: scrape individual objects. Looks for shapes like
# `{"id": 123, "tags": ["a", "b"]}` and tolerates any garbage between.
matched = 0
for m in re.finditer(
r'\{\s*"id"\s*:\s*"?(\d+)"?\s*,\s*"tags"\s*:\s*(\[[^\]]*\])\s*\}',
stripped,
):
try:
item = {"id": int(m.group(1)), "tags": json.loads(m.group(2))}
except (ValueError, json.JSONDecodeError):
continue
if _absorb(item, expected_ids, out):
matched += 1
if not out:
log.warning(
"news_tagging.json_error_unrecoverable",
preview=content[:200],
)
elif matched < len(expected_ids):
log.info(
"news_tagging.json_partial_recovery",
recovered=matched, expected=len(expected_ids),
)
return out
def _absorb(item, expected_ids: set[int], out: dict[int, list[str]]) -> bool:
"""Place one well-formed item into the output dict if it matches an
expected id. Returns True if it landed."""
if not isinstance(item, dict):
return False
try:
iid = int(item.get("id"))
except (TypeError, ValueError):
return False
if iid not in expected_ids or iid in out:
return False
tags = _validate_tags(item.get("tags"))
# Empty post-validation = model picked nothing in vocabulary. Fall
# back to "other" so the row is marked tagged (distinguishes
# "tagged poorly" from "not yet tagged").
out[iid] = tags or ["other"]
return True
async def tag_batch(
client: httpx.AsyncClient,
items: list[_ToTag],
) -> dict[int, list[str]]:
"""Tag one batch of (id, title) pairs. Returns {id: tags}. Items not
in the result remain untagged (NULL in the DB) and are retried on the
next news_job run."""
if not items:
return {}
user_msg = (
"# Headlines to tag\n```json\n"
+ json.dumps(
[{"id": it.id, "title": it.title} for it in items],
ensure_ascii=False,
)
+ "\n```"
)
try:
result = await call_llm(
client,
messages=[
{"role": "system", "content": _SYSTEM_PROMPT},
{"role": "user", "content": user_msg},
],
# Generous ceiling: ~30 tokens/item × 25 items + reasoning
# overhead for thinking models. Hitting the cap returns empty
# content (finish_reason=length) and triggers the fallback.
max_tokens=4000,
)
except Exception as e:
log.warning("news_tagging.llm_failed", n=len(items), error=str(e)[:200])
return {}
return _parse_batch_response(result.content, {it.id for it in items})
async def tag_titles(items: list[_ToTag]) -> dict[int, list[str]]:
"""Tag a list of titles, splitting into BATCH_SIZE chunks. Returns
{id: tags}. Failed batches contribute nothing their items stay
untagged for next time."""
if not items:
return {}
out: dict[int, list[str]] = {}
async with httpx.AsyncClient(follow_redirects=True, timeout=60) as client:
for i in range(0, len(items), BATCH_SIZE):
chunk = items[i:i + BATCH_SIZE]
batch_out = await tag_batch(client, chunk)
out.update(batch_out)
log.info(
"news_tagging.batch_complete",
requested=len(items), tagged=len(out),
)
return out
# Public re-export for the news_job hook + callers that want to assemble
# their own (id, title) tuples without importing the private dataclass.
ToTag = _ToTag

View file

@ -26,7 +26,10 @@ OPENROUTER_URL = "https://openrouter.ai/api/v1/chat/completions"
# framing aimed at young investors entering the trading world. NOVICE retuned # framing aimed at young investors entering the trading world. NOVICE retuned
# to be pedagogical (defining terms, anti-pattern teach-backs); INTERMEDIATE # to be pedagogical (defining terms, anti-pattern teach-backs); INTERMEDIATE
# kept terse but with light-touch educational nudges. See tasks/todo.md. # kept terse but with light-touch educational nudges. See tasks/todo.md.
PROMPT_VERSION = 6 # v7 (2026-05-18): Forbid "(Updated HH:MM UTC)" clauses in the date header —
# the model was hallucinating future times. The user prompt now carries the
# actual current UTC time so the model has accurate temporal context.
PROMPT_VERSION = 7
# --- Core: invariant across tone/analysis settings ---------------------------- # --- Core: invariant across tone/analysis settings ----------------------------
@ -49,7 +52,11 @@ cover the same event, read the gap in framing — that's the data.
implications is filler. implications is filler.
# Structure # Structure
- One-line date header + any anchor framing (e.g. "Week 11 since Hormuz"). - One-line date header containing ONLY the date (e.g. `2026-05-18`) and \
optional anchor framing on the same line (e.g. "Week 11 since Hormuz"). \
**Never include a time-of-day clause like "(Updated 21:30 UTC)"** \
generation time is recorded as metadata elsewhere. Inventing a future or \
arbitrary time in the header confuses readers.
- Immediately after the date header with **nothing** in between write a \ - Immediately after the date header with **nothing** in between write a \
TL;DR. Format it as: TL;DR. Format it as:
@ -423,7 +430,12 @@ def build_user_prompt(
"""Assemble the user message from already-fetched-and-persisted data. """Assemble the user message from already-fetched-and-persisted data.
If `previous_log` is a StrategicLog from earlier today, it's included If `previous_log` is a StrategicLog from earlier today, it's included
as 'Update mode' context the model will revise rather than restart.""" as 'Update mode' context the model will revise rather than restart."""
parts = [f"# Strategic log request — {today.strftime('%Y-%m-%d')}"] parts = [
f"# Strategic log request — {today.strftime('%Y-%m-%d')}",
# Explicit current time so the model doesn't hallucinate one. The
# date header it writes MUST stay date-only (per system prompt).
f"Current time: {today.strftime('%Y-%m-%d %H:%M UTC')}",
]
if anchor: if anchor:
parts.append(f"Anchor reference date: {anchor}") parts.append(f"Anchor reference date: {anchor}")
if reference_line: if reference_line:

View file

@ -1143,15 +1143,17 @@ details[open] .pf-analysis__head-left::before { content: "▾ "; }
.news-row { .news-row {
padding: 4px 12px; padding: 4px 12px;
display: grid; display: grid;
grid-template-columns: 50px 130px 1fr 110px; /* age | source | title | tags-on-right | utc-time */
grid-template-columns: 50px 130px minmax(0, 1fr) minmax(0, auto) 110px;
gap: 12px; gap: 12px;
font-size: 12px; font-size: 12px;
border-bottom: 1px solid var(--surface-2); border-bottom: 1px solid var(--surface-2);
align-items: baseline; align-items: center;
} }
@media (max-width: 720px) { @media (max-width: 720px) {
.news-row { grid-template-columns: 50px 100px 1fr; } .news-row { grid-template-columns: 50px 100px 1fr; }
.news-row .local { display: none; } .news-row .local,
.news-row__tags { display: none; }
} }
.news-row:hover { background: color-mix(in srgb, var(--accent) 5%, transparent); } .news-row:hover { background: color-mix(in srgb, var(--accent) 5%, transparent); }
.news-row .age { color: var(--dim); text-align: right; } .news-row .age { color: var(--dim); text-align: right; }
@ -1166,6 +1168,61 @@ details[open] .pf-analysis__head-left::before { content: "▾ "; }
white-space: nowrap; white-space: nowrap;
} }
/* News tag chips on each row + the top-bar pill toggles */
.news-row__tags {
display: inline-flex;
flex-wrap: nowrap;
gap: 3px;
justify-content: flex-end;
overflow: hidden;
max-width: 100%;
}
.tag-chip {
font-family: var(--font-mono);
font-size: 9px;
letter-spacing: 0.04em;
color: var(--muted);
background: var(--surface-2);
border: 1px solid var(--border);
padding: 0 4px;
white-space: nowrap;
text-transform: uppercase;
line-height: 1.5;
}
.news-tags {
display: flex;
flex-wrap: wrap;
gap: 4px;
padding: 8px 12px;
border-bottom: 1px solid var(--border);
background: var(--surface-2);
}
.news-tag {
font-family: var(--font-mono);
font-size: 10.5px;
letter-spacing: 0.04em;
text-transform: uppercase;
color: var(--muted);
background: transparent;
border: 1px solid var(--border);
padding: 3px 8px;
cursor: pointer;
}
.news-tag:hover { color: var(--accent); border-color: var(--accent); }
.news-tag[data-state="include"] {
background: var(--accent);
color: var(--bg);
border-color: var(--accent);
}
.news-tag[data-state="exclude"] {
color: var(--negative);
border-color: var(--negative);
text-decoration: line-through;
}
.news-tag--clear { color: var(--dim); border-style: dashed; }
.news-tag--clear:hover { color: var(--negative); border-color: var(--negative); }
/* --- Empty / loading state ------------------------------------------- */ /* --- Empty / loading state ------------------------------------------- */
.empty { .empty {

View file

@ -28,6 +28,69 @@
} }
document.body.addEventListener('htmx:configRequest', function (evt) { document.body.addEventListener('htmx:configRequest', function (evt) {
evt.detail.parameters.tone = currentTone(); evt.detail.parameters.tone = currentTone();
// News tag filters — only attach to /api/news requests.
if ((evt.detail.path || '').indexOf('/api/news') === 0) {
var inc = newsTags('include');
var exc = newsTags('exclude');
if (inc.length) evt.detail.parameters.tags = inc.join(',');
if (exc.length) evt.detail.parameters.exclude_tags = exc.join(',');
}
});
// News tag preference: include / exclude sets persisted in
// localStorage. Click cycles include → exclude → off;
// shift-click goes straight to exclude.
function newsTags(kind) {
try {
var raw = localStorage.getItem('cassandra.news.' + kind);
var arr = raw ? JSON.parse(raw) : [];
return Array.isArray(arr) ? arr : [];
} catch (e) { return []; }
}
function setNewsTags(kind, arr) {
try { localStorage.setItem('cassandra.news.' + kind, JSON.stringify(arr)); }
catch (e) {}
}
function refreshNewsPanels() {
document.querySelectorAll('[hx-get*="/api/news"]').forEach(function (el) {
if (window.htmx) window.htmx.trigger(el, 'tags-changed');
});
}
// Event delegation so HTMX-swapped pills work without rebinding.
document.addEventListener('click', function (e) {
var el = e.target.closest && e.target.closest('.news-tag');
if (!el) return;
e.preventDefault();
var tag = el.getAttribute('data-tag') || '';
if (el.classList.contains('news-tag--clear')) {
setNewsTags('include', []);
setNewsTags('exclude', []);
refreshNewsPanels();
return;
}
var inc = newsTags('include');
var exc = newsTags('exclude');
var inInc = inc.indexOf(tag);
var inExc = exc.indexOf(tag);
if (e.shiftKey) {
// Shift-click → toggle exclude membership; remove from include.
if (inInc >= 0) inc.splice(inInc, 1);
if (inExc >= 0) exc.splice(inExc, 1);
else exc.push(tag);
} else {
// Plain click → cycle: off → include → exclude → off.
if (inInc >= 0) {
inc.splice(inInc, 1);
exc.push(tag);
} else if (inExc >= 0) {
exc.splice(inExc, 1);
} else {
inc.push(tag);
}
}
setNewsTags('include', inc);
setNewsTags('exclude', exc);
refreshNewsPanels();
}); });
// Reflect the saved value in the toggle on load. // Reflect the saved value in the toggle on load.
var pill = document.getElementById('tone-toggle'); var pill = document.getElementById('tone-toggle');

View file

@ -77,7 +77,7 @@
</div> </div>
<div class="panel-body panel-body--scroll" <div class="panel-body panel-body--scroll"
hx-get="/api/news?as=html&limit=40" hx-get="/api/news?as=html&limit=40"
hx-trigger="load, every 60s" hx-trigger="load, every 60s, tags-changed"
hx-swap="innerHTML"> hx-swap="innerHTML">
<div class="empty">loading…</div> <div class="empty">loading…</div>
</div> </div>

View file

@ -9,7 +9,7 @@
</div> </div>
<div class="panel-body panel-body--scroll" <div class="panel-body panel-body--scroll"
hx-get="/api/news?as=html&limit=200" hx-get="/api/news?as=html&limit=200"
hx-trigger="load, every 60s" hx-trigger="load, every 60s, tags-changed"
hx-swap="innerHTML"> hx-swap="innerHTML">
<div class="empty">loading…</div> <div class="empty">loading…</div>
</div> </div>

View file

@ -1,11 +1,30 @@
{% if tag_vocabulary %}
<div class="news-tags" data-include="{{ active_include|join(',') }}" data-exclude="{{ active_exclude|join(',') }}">
{% for tag in tag_vocabulary %}
<button type="button" class="news-tag"
data-tag="{{ tag }}"
{% if tag in active_include %}data-state="include"{% elif tag in active_exclude %}data-state="exclude"{% endif %}
title="{{ tag_labels.get(tag, tag) }} — click to include only, shift-click to exclude">
{{ tag_labels.get(tag, tag) }}
</button>
{% endfor %}
{% if active_include or active_exclude %}
<button type="button" class="news-tag news-tag--clear" data-tag="" title="Clear all filters">clear</button>
{% endif %}
</div>
{% endif %}
{% if not headlines %} {% if not headlines %}
<div class="empty">no headlines in window</div> <div class="empty">no headlines in window{% if active_include or active_exclude %} (after tag filter){% endif %}</div>
{% else %} {% else %}
{% for h in headlines %} {% for h in headlines %}
<div class="news-row"> <div class="news-row">
<span class="age">{{ h.age }}</span> <span class="age">{{ h.age }}</span>
<span class="source">{{ h.source }}</span> <span class="source">{{ h.source }}</span>
<a class="title" href="{{ h.url }}" target="_blank" rel="noopener">{{ h.title }}</a> <a class="title" href="{{ h.url }}" target="_blank" rel="noopener">{{ h.title }}</a>
<span class="news-row__tags">
{% for t in h.tags or [] %}<span class="tag-chip" data-tag="{{ t }}">{{ tag_labels.get(t, t) }}</span>{% endfor %}
</span>
{% if h.iso %} {% if h.iso %}
<time class="local" datetime="{{ h.iso }}" title="{{ h.iso }}">{{ h.utc_short }}</time> <time class="local" datetime="{{ h.iso }}" title="{{ h.iso }}">{{ h.utc_short }}</time>
{% else %} {% else %}

130
tests/test_news_tagging.py Normal file
View file

@ -0,0 +1,130 @@
"""Tests for the deterministic half of news_tagging: vocabulary filtering
and JSON-response parsing. The LLM call itself isn't exercised."""
from __future__ import annotations
import pytest
from app.services.news_tagging import (
MAX_TAGS_PER_HEADLINE,
TAG_LABELS,
TAG_VOCABULARY,
_parse_batch_response,
_validate_tags,
)
# ---------------------------------------------------------------------------
# Vocabulary integrity
# ---------------------------------------------------------------------------
def test_every_vocab_tag_has_a_label():
"""Display labels must cover every tag — missing keys would render
the raw machine-name in the UI."""
for t in TAG_VOCABULARY:
assert t in TAG_LABELS, f"missing label for {t}"
def test_other_is_the_fallback_tag():
assert "other" in TAG_VOCABULARY
# ---------------------------------------------------------------------------
# _validate_tags
# ---------------------------------------------------------------------------
def test_validate_drops_unknown_tags():
out = _validate_tags(["markets", "wibble", "tech"])
assert out == ["markets", "tech"]
def test_validate_normalises_spaces_to_hyphens():
"""Common drift: model returns 'monetary policy' instead of
'monetary-policy'. We normalise."""
out = _validate_tags(["monetary policy"])
assert out == ["monetary-policy"]
def test_validate_normalises_case():
out = _validate_tags(["MARKETS", "Geopolitics"])
assert out == ["markets", "geopolitics"]
def test_validate_caps_at_max_tags():
out = _validate_tags(["markets", "tech", "china", "economy", "energy"])
assert len(out) == MAX_TAGS_PER_HEADLINE
def test_validate_dedupes():
out = _validate_tags(["markets", "markets", "tech"])
assert out == ["markets", "tech"]
def test_validate_rejects_non_list():
assert _validate_tags(None) == []
assert _validate_tags("markets") == []
assert _validate_tags({"tag": "markets"}) == []
def test_validate_skips_non_string_entries():
out = _validate_tags(["markets", 42, None, "tech"])
assert out == ["markets", "tech"]
# ---------------------------------------------------------------------------
# _parse_batch_response
# ---------------------------------------------------------------------------
def test_parse_basic_json_array():
raw = '[{"id": 1, "tags": ["markets", "tech"]}, {"id": 2, "tags": ["china"]}]'
out = _parse_batch_response(raw, {1, 2})
assert out == {1: ["markets", "tech"], 2: ["china"]}
def test_parse_strips_leading_prose():
"""Models occasionally prepend 'Here is the output:' before the JSON."""
raw = 'Sure! Here are the tags:\n[{"id": 1, "tags": ["markets"]}]'
out = _parse_batch_response(raw, {1})
assert out == {1: ["markets"]}
def test_parse_strips_markdown_fences():
raw = "```json\n[{\"id\": 1, \"tags\": [\"tech\"]}]\n```"
out = _parse_batch_response(raw, {1})
assert out == {1: ["tech"]}
def test_parse_drops_unexpected_ids():
raw = '[{"id": 99, "tags": ["markets"]}, {"id": 1, "tags": ["tech"]}]'
out = _parse_batch_response(raw, {1, 2})
assert out == {1: ["tech"]}
def test_parse_empty_tags_falls_back_to_other():
"""An item whose tags list ends up empty after validation gets
tagged 'other' so the row is marked tagged, not left NULL."""
raw = '[{"id": 1, "tags": ["nonsense"]}]'
out = _parse_batch_response(raw, {1})
assert out == {1: ["other"]}
def test_parse_unparseable_returns_empty():
"""Garbage in → empty out. The caller leaves those rows untagged
so they get retried on the next run."""
assert _parse_batch_response("nope, no JSON here", {1}) == {}
assert _parse_batch_response("[invalid json", {1}) == {}
def test_parse_ignores_non_dict_items():
raw = '[{"id": 1, "tags": ["markets"]}, "lol", null, {"id": 2, "tags": ["tech"]}]'
out = _parse_batch_response(raw, {1, 2})
assert out == {1: ["markets"], 2: ["tech"]}
def test_parse_handles_string_id_coercion():
"""Some models render the id as a string. We coerce."""
raw = '[{"id": "1", "tags": ["markets"]}]'
out = _parse_batch_response(raw, {1})
assert out == {1: ["markets"]}