2026-08-24 17:28:46 -04:00
#!/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 ─────────────────────────────────────────────────────────────
2026-08-24 17:42:06 -04:00
def ensure_tables ( ) - > None :
""" Idempotently create the news tables if missing.
Normally created by alembic 003 _news when the app container starts , but
this summarizer may boot before the app has run migrations ( compose only
guarantees ` db ` is up , not that alembic has run ) . Mirrors the scraper
pipeline ' s own CREATE TABLE IF NOT EXISTS so either start order is safe.
"""
ddl = """
CREATE TABLE IF NOT EXISTS articles (
id SERIAL PRIMARY KEY ,
title TEXT ,
url TEXT UNIQUE ,
content TEXT ,
domain TEXT ,
timestamp TIMESTAMPTZ
) ;
CREATE TABLE IF NOT EXISTS article_summaries (
id SERIAL PRIMARY KEY ,
summary_text TEXT NOT NULL ,
batch_timestamp TIMESTAMPTZ NOT NULL DEFAULT NOW ( )
) ;
"""
try :
conn = psycopg2 . connect ( * * DB_CONFIG )
cur = conn . cursor ( )
cur . execute ( ddl )
conn . commit ( )
cur . close ( )
conn . close ( )
except Exception as exc : # noqa: BLE001
logger . error ( " Error ensuring news tables: %s " , exc )
2026-08-24 17:28:46 -04:00
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 ' ] } \n Source: { a [ ' domain ' ] } \n URL: { a [ ' url ' ] } \n Content: { 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. """
2026-08-24 17:42:06 -04:00
ensure_tables ( )
2026-08-24 17:28:46 -04:00
articles = get_recent_news ( )
if not articles :
logger . info ( " No new articles found in the last %s h. " , 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 ( )