osint-dashboard/news/summerizer/summarizer.py
Sirius DevOps 91f436390b
Some checks failed
build-and-deploy / build (push) Failing after 4s
Add news pipeline: hourly scraper + Gemini summarizer in compose
Vendor the newsPipeline scraper + summarizer into the repo and wire them into
docker-compose against the EXISTING osint-db (no second Postgres), replacing
the upstream k8s CronJobs with in-compose wall-clock loops (:00 scrape, :05
summarize).

- news/scraper: vendored Scrapy project (257 RSS feeds) + hourly loop
  scheduler (run_news_scraper.py)
- news/summerizer: vendored Gemini map-reduce summarizer, cleaned:
  * fix broken google-genai response handling (_extract_text, defensive)
  * fix malformed INSERT/GRANT query in save_summary_to_db
  * OSINT-neutral default MAP_PROMPT; futures/markets language gated behind
    INCLUDE_FUTURES=0 (yfinance lazy-imported)
  * env-configurable model, batch size, lookback window
  + hourly loop scheduler (run_news_summarizer.py, :05)
- alembic 003_news: idempotent articles + article_summaries tables
- API: GET /api/news and GET /api/news/summaries (+ models, schemas)
- tests/test_api_news.py: 5 DB-backed contract tests (all pass vs real PG)
- docs/news.md + .env.example updates

Both services run under the `ingest` compose profile (matching the
ingester/camera-scraper pattern) and build arm64 on the Pi via the existing
Forgejo CI workflow. telebot left out of scope (reserved env only).
2026-08-24 17:28:46 -04:00

307 lines
12 KiB
Python

#!/usr/bin/env python3
"""News summarizer — LLM (Gemini) map-reduce summarization of scraped articles.
Reads articles scraped within the last hour from the shared `articles` table,
summarizes them with Gemini (map phase per batch, reduce phase into one master
summary), and stores the result in `article_summaries` — both tables live in
the EXISTING osint-db (created by alembic migration 003_news, idempotent).
Everything is env-driven (12-factor):
DB_HOST / DB_NAME / DB_USER / DB_PASSWORD / DB_PORT PostgreSQL (osint-db)
GEMINI_API_KEY Google AI Studio key (required to actually run)
SUMMARY_MODEL Gemini model id (default gemini-2.0-flash)
BATCH_SIZE articles per map-phase batch (default 50)
SUMMARY_WINDOW_HOURS look-back window in hours (default 1)
MAP_PROMPT override map-phase prompt (uses {batch_text})
SUMMARY_PROMPT override reduce-phase prompt (uses {final_input})
INCLUDE_FUTURES "1" to prepend live futures prices (default 0)
The futures/markets coupling from the original pipeline is gated behind
INCLUDE_FUTURES and OFF by default — it is irrelevant to the OSINT dashboard
and pulled yfinance into the image. Re-enable by installing yfinance and
setting INCLUDE_FUTURES=1.
"""
from __future__ import annotations
import logging
import os
from datetime import datetime
import psycopg2
logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s %(message)s")
logger = logging.getLogger("news.summarizer")
# ── Configuration (12-factor, container-friendly defaults) ─────────────────
DB_CONFIG = {
"host": os.getenv("DB_HOST", "db").strip(),
"database": os.getenv("DB_NAME", "osint_data").strip(),
"user": os.getenv("DB_USER", "osint").strip(),
"password": os.getenv("DB_PASSWORD", "").strip(),
"port": int(os.getenv("DB_PORT", "5432")),
}
GEMINI_API_KEY = os.getenv("GEMINI_API_KEY", "").strip()
MODEL_NAME = os.getenv("SUMMARY_MODEL", "gemini-2.0-flash").strip()
BATCH_SIZE = int(os.getenv("BATCH_SIZE", "50"))
SUMMARY_WINDOW_HOURS = int(os.getenv("SUMMARY_WINDOW_HOURS", "1"))
INCLUDE_FUTURES = os.getenv("INCLUDE_FUTURES", "0").lower() in ("1", "true", "yes")
# Only touched when INCLUDE_FUTURES=1 (legacy markets coupling, OSINT-off).
FUTURES_TICKERS = {
"Equity Indices": ["ES=F", "NQ=F", "YM=F", "RTY=F"],
"Energy": ["CL=F", "NG=F", "HO=F", "RB=F"],
"Metals": ["GC=F", "SI=F", "HG=F"],
"Agriculture": ["ZC=F", "ZS=F", "ZW=F", "ZL=F", "KE=F"],
"Currencies": ["6E=F", "6J=F", "6B=F"],
}
# ── OSINT-neutral default prompts (env-overridable via MAP_PROMPT/SUMMARY_PROMPT) ──
MAP_PROMPT_DEFAULT = """\
You are a precise, factual OSINT news processor. Your ONLY source of information is the articles provided below. Do NOT add external knowledge, assumptions, training data, or invented facts.
For EACH article in the batch:
1. Extract 2-4 key factual bullet points (who, what, when, where, numbers, quotes — stay very close to the text).
2. Location: name the country / city / region mentioned if determinable from the text, else "Unknown".
3. Entities: list the key people, organizations, or governments mentioned (comma-separated, only names present in the text), else "None".
4. Category: pick one — politics, military/conflict, economy, technology, environment/disaster, health, crime, society, sport, other.
5. OSINT signal: if the article describes an event with geopolitical, security, military, economic, or disaster significance, say so in one short sentence. Otherwise write: "No notable OSINT signal."
If several articles cover the same story, add one short batch-level note at the end: "Batch theme: [one sentence]".
Output format — strictly one block per article:
Article 1:
- Fact bullet 1
- Fact bullet 2
- Location: ...
- Entities: ...
- Category: ...
- OSINT signal: ...
Article 2:
...
Articles in this batch:
{batch_text}
"""
SUMMARY_PROMPT_DEFAULT = """\
CRITICAL INSTRUCTION - REPEAT 3 TIMES: YOU MUST USE ONLY THE DATA PROVIDED BELOW. DO NOT INVENT, RECALL, OR ADD ANY EVENTS, NAMES, DATES, IMPLICATIONS, PROJECTS, OR DETAILS NOT EXPLICITLY PRESENT IN THE DATA. IF THE DATA HAS NO MAJOR GEOPOLITICAL/TECH/MILITARY/ECONOMIC/IMPACTFUL EVENTS OR UNUSUAL STORIES, OUTPUT ONLY: "No qualifying impactful or unusual events in the recent hourly news data." AND STOP. NO EXTERNAL KNOWLEDGE FROM TRAINING.
Write a concise executive summary of the most impactful items as a short markdown list, one line per story, using only the data.
DATA:
{final_input}
"""
# ── LLM helpers ────────────────────────────────────────────────────────────
_client = None
def _get_client():
"""Lazily build the Gemini client (avoids import/init when key unset)."""
global _client
if _client is None:
from google import genai
_client = genai.Client(api_key=GEMINI_API_KEY)
return _client
def _extract_text(resp) -> str:
"""Defensively pull text out of the google-genai GenerateContentResponse.
The modern SDK returns the response directly (``resp.text``); some older
wrappers exposed it as ``resp.response``. Handle both plus a candidates
fallback so a provider/SDK change degrades to "" instead of crashing.
"""
if not resp:
return ""
if hasattr(resp, "text") and resp.text:
return resp.text
inner = getattr(resp, "response", None)
if inner is not None and hasattr(inner, "text") and inner.text:
return inner.text
try:
parts = []
for cand in getattr(resp, "candidates", None) or []:
content = getattr(cand, "content", None)
for part in getattr(content, "parts", None) or []:
if getattr(part, "text", None):
parts.append(part.text)
return "\n".join(parts)
except Exception: # noqa: BLE001
return str(resp)
def call_llm(prompt: str) -> str:
"""Send a prompt to Gemini and return the text ("" on any failure)."""
if not GEMINI_API_KEY:
logger.warning("GEMINI_API_KEY not set — skipping LLM call")
return ""
try:
resp = _get_client().models.generate_content(model=MODEL_NAME, contents=prompt)
return _extract_text(resp)
except Exception as exc: # noqa: BLE001
logger.error("Gemini API error: %s", exc)
return ""
# ── Futures (legacy, gated) ────────────────────────────────────────────────
def fetch_current_futures_prices() -> dict:
"""Live futures prices. Only meaningful when INCLUDE_FUTURES=1."""
if not INCLUDE_FUTURES:
return {}
try:
import yfinance as yf # noqa: PLC0415
except ImportError:
logger.warning(
"INCLUDE_FUTURES=1 but yfinance is not installed — install it to enable futures prices"
)
return {}
prices: dict = {}
for category, tickers in FUTURES_TICKERS.items():
for ticker in tickers:
try:
data = yf.Ticker(ticker).history(period="1d", interval="1m")
if not data.empty:
last_price = data["Close"].iloc[-1]
prices[ticker] = {
"price": round(last_price, 2),
"change_pct": round(
(last_price - data["Open"].iloc[0]) / data["Open"].iloc[0] * 100, 2
) if len(data) > 1 else 0,
"timestamp": datetime.utcnow().strftime("%Y-%m-%d %H:%M UTC"),
"category": category,
}
else:
prices[ticker] = {"price": None, "error": "No data"}
except Exception as exc: # noqa: BLE001
prices[ticker] = {"price": None, "error": str(exc)}
return prices
def build_futures_context() -> str:
ctx = f"CURRENT FUTURES PRICES (as of {datetime.now().strftime('%Y-%m-%d %H:%M UTC')}):\n"
for ticker, info in fetch_current_futures_prices().items():
if info.get("price") is not None:
ctx += (
f"- {ticker} ({info['category']}): ${info['price']:.2f} "
f"({info['change_pct']:+.2f}% today)\n"
)
else:
ctx += f"- {ticker}: unavailable ({info.get('error', 'unknown error')})\n"
return ctx
# ── DB helpers ─────────────────────────────────────────────────────────────
def get_recent_news() -> list[dict]:
"""Fetch articles from the last SUMMARY_WINDOW_HOURS (content > 100 chars)."""
query = """
SELECT title, content, url, domain
FROM articles
WHERE timestamp > NOW() - make_interval(hours => %s)
AND content IS NOT NULL AND length(content) > 100
ORDER BY timestamp DESC;
"""
try:
conn = psycopg2.connect(**DB_CONFIG)
cur = conn.cursor()
cur.execute(query, (SUMMARY_WINDOW_HOURS,))
rows = cur.fetchall()
cur.close()
conn.close()
return [
{"title": r[0], "content": r[1], "url": r[2], "domain": r[3]}
for r in rows
]
except Exception as exc: # noqa: BLE001
logger.error("Database error reading articles: %s", exc)
return []
def save_summary_to_db(summary_text: str) -> None:
"""Insert one master summary row (table created by alembic 003_news)."""
if not summary_text or len(summary_text.strip()) < 10:
logger.info("Summary too short or empty. Skipping save.")
return
try:
conn = psycopg2.connect(**DB_CONFIG)
cur = conn.cursor()
cur.execute(
"INSERT INTO article_summaries (summary_text) VALUES (%s)",
(summary_text.strip(),),
)
conn.commit()
logger.info("Master summary saved to database successfully.")
cur.close()
conn.close()
except Exception as exc: # noqa: BLE001
logger.error("Error saving summary to DB: %s", exc)
# ── Orchestration ──────────────────────────────────────────────────────────
def build_map_prompt(batch: list[dict]) -> str:
batch_text = "\n\n".join(
f"Title: {a['title']}\nSource: {a['domain']}\nURL: {a['url']}\nContent: {a['content'][:1500]}"
for a in batch
)
template = os.getenv("MAP_PROMPT", MAP_PROMPT_DEFAULT)
prefix = build_futures_context() + "\n" if INCLUDE_FUTURES else ""
try:
return prefix + template.format(batch_text=batch_text)
except KeyError:
return prefix + template
def build_master_prompt(final_input: str) -> str:
template = os.getenv("SUMMARY_PROMPT", SUMMARY_PROMPT_DEFAULT)
prefix = build_futures_context() + "\n" if INCLUDE_FUTURES else ""
try:
return prefix + template.format(final_input=final_input)
except KeyError:
return prefix + template
def summarize_news() -> None:
"""Map-reduce summarize recent articles and store the master summary."""
articles = get_recent_news()
if not articles:
logger.info("No new articles found in the last %sh.", SUMMARY_WINDOW_HOURS)
return
logger.info(
"Processing %d articles with %s (batch_size=%d, futures=%s)...",
len(articles), MODEL_NAME, BATCH_SIZE, INCLUDE_FUTURES,
)
partial_summaries: list[str] = []
for i in range(0, len(articles), BATCH_SIZE):
batch = articles[i : i + BATCH_SIZE]
logger.info("map batch %d/%d (%d articles)", i // BATCH_SIZE + 1, -(-len(articles) // BATCH_SIZE), len(batch))
summary = call_llm(build_map_prompt(batch))
if summary:
partial_summaries.append(summary)
final_input = "\n\n".join(partial_summaries)
if not final_input.strip():
logger.warning("No partial summaries produced — nothing to reduce.")
return
logger.info("reduce phase over %d partial summaries", len(partial_summaries))
master_summary = call_llm(build_master_prompt(final_input))
if master_summary:
save_summary_to_db(master_summary)
if __name__ == "__main__":
summarize_news()