308 lines
12 KiB
Python
308 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()
|