"""Attach a press-style narrative to a brief that was generated without one. The narrative is composed from the brief itself -- verified facts, computed quarter deltas, the reported trajectory -- and needs no filings, no vector store and no agent loop. So a stored brief can gain its article for the cost of one short model call instead of a full regeneration. Usage:: python -m scripts.backfill_narrative AAPL python -m scripts.backfill_narrative AAPL --dry-run python -m scripts.backfill_narrative --all ``--dry-run`` prints the article and the validation report without touching the database. """ from __future__ import annotations import argparse import json import sqlite3 import sys from dotenv import load_dotenv from agent.llm import default_config from agent.narrative import compose_narrative from storage import briefs_db load_dotenv() def _update_stored_brief(ticker: str, brief: dict) -> int: """Write *brief* back over the rows it came from. Returns rows touched. save_brief() cannot be used here: its snapshot insert is keyed on generated_at and would be ignored, leaving get_brief() -- which reads brief_snapshots first -- still serving the article-less payload. """ payload = json.dumps(brief) generated_at = brief.get("generated_at") or "" with sqlite3.connect(briefs_db.DB_PATH) as conn: touched = conn.execute( "UPDATE brief_snapshots SET brief_json = ? " "WHERE ticker = ? AND generated_at = ?", (payload, ticker.upper(), generated_at), ).rowcount touched += conn.execute( "UPDATE briefs SET brief_json = ? WHERE ticker = ?", (payload, ticker.upper()), ).rowcount # A stored translation predates the article and would render the # brief without it; drop it so it is rebuilt on next request. conn.execute("DELETE FROM brief_translations WHERE ticker = ?", (ticker.upper(),)) return touched def backfill(ticker: str, *, dry_run: bool = False, force: bool = False) -> int: brief = briefs_db.get_brief(ticker) if brief is None: print(f"{ticker}: no stored brief", file=sys.stderr) return 1 if brief.get("narrative") and not force: print(f"{ticker}: already has a narrative (use --force to replace)") return 0 try: from analytics.segments import digest_lines brief["segment_lines"] = digest_lines(ticker) except Exception as exc: print(f" · segment lines unavailable: {exc}") article, report, usage = compose_narrative( brief, default_config(), brief.get("language") or "English" ) for line in report: print(f" · {line}") if usage: print(f" · usage: {usage}") if article is None: print(f"{ticker}: no article survived validation") return 1 print(f"\n {article.get('headline') or '(headline dropped)'}") print(f" {article.get('dek') or ''}\n") for para in article["paragraphs"]: print(f" [{para['role']}] ({', '.join(para['fact_ids'])})") print(f" {para['text']}\n") if dry_run: print(f"{ticker}: dry run, nothing written") return 0 brief["narrative"] = article brief["narrative_usage"] = usage rows = _update_stored_brief(ticker, brief) print(f"{ticker}: narrative attached ({rows} row(s) updated)") return 0 def main() -> int: parser = argparse.ArgumentParser(description=__doc__) parser.add_argument("ticker", nargs="?", help="Ticker to backfill.") parser.add_argument("--all", action="store_true", help="Every ticker with a stored brief.") parser.add_argument("--dry-run", action="store_true", help="Print without writing.") parser.add_argument("--force", action="store_true", help="Replace an existing narrative.") args = parser.parse_args() if args.all: tickers = briefs_db.list_tickers() elif args.ticker: tickers = [args.ticker.upper()] else: parser.error("give a ticker or --all") failures = 0 for ticker in tickers: print(f"\n== {ticker} ==") failures += backfill(ticker, dry_run=args.dry_run, force=args.force) return 1 if failures else 0 if __name__ == "__main__": raise SystemExit(main())