Files
politics-tracking/backend/politrack/enrich/corroborate.py
T
simonandClaude Opus 5.5 583ccba31c Politrack: track Austrian politicians from public sources
Initial import of Politrack, a self-hosted service that tracks Austrian
politicians from federal to municipal level - roles, votes, money flows,
statements, meetings, social media and legal proceedings - with every claim
linked to its sources.

- backend: FastAPI + Postgres/pgvector; adapter framework (fetch -> parse ->
  load -> enrich) with robots.txt/TDM-aware fetching, a raw-response archive
  for reparsing, and a claim/evidence model with corroboration
- sources: Parliament, Wikidata, EU Parliament/Commission, Council of
  Ministers, Rechnungshof party reports and donations, procurement, RTR media
  transparency, political ad libraries, WKStA, news feeds, Wikipedia edits,
  Bluesky, Mastodon, public Telegram channels, YouTube and X (official API)
- web: Next.js frontend in German and English with typed API client
- mcp: MCP server for AI agents and RAG
- docs: architecture, data model, sources, legal notes, Unraid deployment and
  contributor guides; Docker Compose setup for Unraid

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-28 20:02:01 +01:00

245 lines
9.9 KiB
Python

"""Corroboration: find claims from different sources that state the same fact and merge them.
Merging keeps the oldest claim, copies the duplicate's evidence onto it and marks
the duplicate with `merged_into`. Afterwards `refresh_claim_status` marks the
survivor "corroborated" when at least two *independent sources* support it.
Matchers:
* roles: same person, equivalent body/office, overlapping or near-identical periods
(e.g. Wikidata "Abgeordneter zum Nationalrat" == Parliament mandate)
* stories: news articles about the same event (similar headlines, shared people,
within 72 hours) are grouped; mentions of a person in one story are merged
* events: trips/meetings of the same person to the same place/counterpart within 3 days
"""
from collections import defaultdict
from datetime import timedelta
from psycopg import Connection
from rapidfuzz import fuzz
from politrack.core import Enricher, RunContext, register
from politrack.core.spec import DERIVED
from politrack.enrich.events import counterpart_anchor
from politrack.text import normalize
REFRESH_STATUS = """
UPDATE claim SET status = CASE
WHEN status = 'retracted' THEN status
WHEN EXISTS (SELECT 1 FROM claim_evidence e WHERE e.claim_id = claim.id AND e.stance = 'contradicts') THEN 'disputed'
WHEN (SELECT count(DISTINCT d.source_id) FROM claim_evidence e JOIN document d ON d.id = e.document_id
WHERE e.claim_id = claim.id AND e.stance IN ('supports', 'mentions')) >= 2 THEN 'corroborated'
ELSE 'reported' END
WHERE id = %s
"""
def merge_claims(conn: Connection, keep_id: int, dup_id: int) -> None:
if keep_id == dup_id:
return
conn.execute(
"""
INSERT INTO claim_evidence (claim_id, document_id, stance, quote, locator, extracted_by, confidence)
SELECT %s, document_id, stance, quote, locator, extracted_by, confidence
FROM claim_evidence WHERE claim_id = %s
ON CONFLICT (claim_id, document_id, locator) DO NOTHING
""",
(keep_id, dup_id),
)
conn.execute(
"""
INSERT INTO topic_assignment (topic_id, claim_id, confidence, method)
SELECT topic_id, %s, confidence, method FROM topic_assignment WHERE claim_id = %s
ON CONFLICT DO NOTHING
""",
(keep_id, dup_id),
)
conn.execute("UPDATE claim SET merged_into = %s, updated_at = now() WHERE id = %s", (keep_id, dup_id))
conn.execute("UPDATE claim SET merged_into = %s WHERE merged_into = %s", (keep_id, dup_id))
conn.execute(REFRESH_STATUS, (keep_id,))
# -- roles ---------------------------------------------------------------------
BODY_KEYWORDS = {
"nationalrat": ("nationalrat",),
"bundesrat": ("bundesrat", "bundesrates"),
}
def _same_office(a: dict, b: dict) -> bool:
"""a: authoritative role (Parliament), b: other source (Wikidata)."""
if a["role"] == "mp" and b["role"] == "mp":
return a["body_slug"] == b["body_slug"]
label = normalize(b["role"])
if a["role"] == "mp" and a["body_slug"] in BODY_KEYWORDS:
return any(k in label for k in BODY_KEYWORDS[a["body_slug"]]) and "prasident" not in label
return fuzz.token_set_ratio(normalize(a["role"]), label) >= 80
def _periods_match(a: dict, b: dict) -> bool:
a_start, b_start = a["start_date"], b["start_date"]
a_end, b_end = a["end_date"], b["end_date"]
if a_start and b_start and abs((a_start - b_start).days) <= 60:
return True
# Wikidata often only records one term for several consecutive mandates: accept containment.
if a_start and b_start and b_start <= a_start and (b_end is None or a_end is None or a_end <= b_end + timedelta(days=60)):
return True
return False
def merge_roles(conn: Connection) -> int:
rows = conn.execute(
"""
SELECT r.claim_id, r.person_id, r.role, r.start_date, r.end_date, c.created_by, o.slug AS body_slug
FROM role r JOIN claim c ON c.id = r.claim_id LEFT JOIN organization o ON o.id = r.organization_id
WHERE c.merged_into IS NULL AND r.level <> 'party'
AND (c.created_by LIKE 'parlament%%' OR c.created_by LIKE 'wikidata%%' OR c.created_by LIKE 'europarl%%')
ORDER BY r.person_id, r.start_date NULLS LAST
"""
).fetchall()
by_person: dict[int, dict[str, list[dict]]] = defaultdict(lambda: defaultdict(list))
for r in rows:
family = "wikidata" if r["created_by"].startswith("wikidata") else "parlament"
by_person[r["person_id"]][family].append(r)
merged = 0
for roles in by_person.values():
for other in roles.get("wikidata", []):
match = next((a for a in roles.get("parlament", []) if _same_office(a, other) and _periods_match(a, other)),
None)
if match:
merge_claims(conn, match["claim_id"], other["claim_id"])
merged += 1
conn.commit()
return merged
# -- news stories -------------------------------------------------------------
def cluster_stories(conn: Connection, days: int = 14) -> int:
"""Group recent news documents about the same thing into stories."""
docs = conn.execute(
"""
SELECT d.id, d.title, d.published_at, d.story_id, d.source_id, d.meta->'people' AS people
FROM document d JOIN source s ON s.id = d.source_id
WHERE (s.kind = 'media' OR s.slug = 'gdelt') AND d.published_at > now() - make_interval(days => %s)
AND d.title IS NOT NULL
ORDER BY d.published_at
""",
(days,),
).fetchall()
stories: list[dict] = [] # {id, docs: [...]}
for doc in docs:
title = normalize(doc["title"])
people = set(doc["people"] or [])
best, best_score = None, 0
for story in stories[-400:]:
lead = story["docs"][0]
if doc["published_at"] - lead["published_at"] > timedelta(hours=72):
continue
score = fuzz.token_set_ratio(title, lead["norm"])
shared = people & lead["people"]
if score >= 82 or (shared and score >= 60):
if score > best_score:
best, best_score = story, score
entry = {"id": doc["id"], "norm": title, "people": people, "published_at": doc["published_at"],
"source_id": doc["source_id"], "story_id": doc["story_id"], "title": doc["title"]}
if best:
best["docs"].append(entry)
else:
stories.append({"docs": [entry]})
assigned = 0
for story in stories:
if len(story["docs"]) < 2:
continue
existing = next((d["story_id"] for d in story["docs"] if d["story_id"]), None)
lead = story["docs"][0]
if existing is None:
existing = conn.execute(
"INSERT INTO story (title, first_seen) VALUES (%s, %s) RETURNING id", (lead["title"], lead["published_at"])
).fetchone()["id"]
ids = [d["id"] for d in story["docs"]]
conn.execute("UPDATE document SET story_id = %s WHERE id = ANY(%s)", (existing, ids))
conn.execute(
"""
UPDATE story SET last_seen = sub.last_seen, document_count = sub.n, source_count = sub.sources
FROM (SELECT max(published_at) AS last_seen, count(*) AS n, count(DISTINCT source_id) AS sources
FROM document WHERE story_id = %s) sub
WHERE story.id = %s
""",
(existing, existing),
)
assigned += len(ids)
conn.commit()
return assigned
def merge_story_mentions(conn: Connection) -> int:
"""One media_mention claim per (person or party, story); other outlets become additional evidence."""
rows = conn.execute(
"""
SELECT c.id, c.person_id, c.organization_id, d.story_id, c.occurred_at
FROM claim c
JOIN claim_evidence e ON e.claim_id = c.id
JOIN document d ON d.id = e.document_id
WHERE c.claim_type = 'media_mention' AND c.merged_into IS NULL AND d.story_id IS NOT NULL
ORDER BY c.occurred_at NULLS LAST, c.id
"""
).fetchall()
keep: dict[tuple, int] = {}
merged = 0
for r in rows:
key = (r["person_id"], r["organization_id"], r["story_id"])
if key in keep:
if keep[key] != r["id"]:
merge_claims(conn, keep[key], r["id"])
merged += 1
else:
keep[key] = r["id"]
conn.commit()
return merged
# -- events --------------------------------------------------------------------
def merge_events(conn: Connection) -> int:
rows = conn.execute(
"""
SELECT c.id, c.person_id, c.claim_type, c.occurred_at, c.data->>'country' AS country,
c.data->>'counterpart' AS counterpart
FROM claim c
WHERE c.claim_type IN ('travel', 'meeting') AND c.merged_into IS NULL
ORDER BY c.person_id, c.claim_type, c.occurred_at
"""
).fetchall()
merged = 0
previous: dict[tuple, dict] = {}
for r in rows:
anchor = r["country"] if r["claim_type"] == "travel" else counterpart_anchor(r["counterpart"] or "")
key = (r["person_id"], r["claim_type"], anchor)
prev = previous.get(key)
if prev and r["occurred_at"] and prev["occurred_at"] and r["occurred_at"] - prev["occurred_at"] <= timedelta(days=3):
merge_claims(conn, prev["id"], r["id"])
merged += 1
continue
previous[key] = r
conn.commit()
return merged
@register
class Corroborator(Enricher):
spec = DERIVED
name = "enrich.corroborate"
description = "Merge near-duplicate claims from different sources; cluster news stories"
schedule = "9-59/10 * * * *"
def run(self, ctx: RunContext) -> None:
conn = ctx.conn
ctx.stats["roles_merged"] = merge_roles(conn)
ctx.stats["story_documents"] = cluster_stories(conn)
ctx.stats["story_mentions_merged"] = merge_story_mentions(conn)
ctx.stats["events_merged"] = merge_events(conn)
return None