Files
politics-tracking/backend/politrack/enrich/legal.py
T
Simon DiesenreiterandClaude Opus 5.5 9da5a1dc48 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 19:47:14 +01:00

92 lines
4.6 KiB
Python

"""Detect reports about legal proceedings in news headlines (and WKStA press releases).
Everything found here is a *proposal*: claims are created with review=True (hidden until a human approves
them via `politrack review`). Headline wording is ambiguous, the data is specially protected (Art. 10 GDPR)
and wrong attributions can be defamatory. Official parliament immunity data does not go through this path.
Several outlets reporting the same person + stage in the same month attach to one claim (corroboration).
"""
from __future__ import annotations
import re
from collections.abc import Iterator
from datetime import datetime
from psycopg.types.json import Jsonb
from politrack.core import Enricher, RunContext, register
from politrack.core.records import Claim, Evidence, Legal, PersonRef
from politrack.core.spec import DERIVED
# Most specific first.
STAGES = [
# "nicht rechtskräftig verurteilt" must never be read as a final conviction.
("convicted_final", re.compile(r"(?<!nicht )\brechtskräftig\s+(verurteilt|schuldig)", re.I)),
("convicted_not_final", re.compile(r"\b(nicht rechtskräftig\b.*\bverurteilt|verurteilt\b.*nicht rechtskräftig|"
r"schuldig gesprochen|Schuldspruch|\bverurteilt\b)", re.I)),
("acquitted", re.compile(r"\b(freigesprochen|Freispruch)\b", re.I)),
("diversion", re.compile(r"\bDiversion\b", re.I)),
("proceedings_dropped", re.compile(r"\b(Verfahren|Ermittlungen)\b.*\beingestellt\b|\beingestellt\b.*\bVerfahren\b", re.I)),
("trial", re.compile(r"\b(Prozess|Strafprozess|vor Gericht|Prozessauftakt|Hauptverhandlung)\b", re.I)),
("indicted", re.compile(r"\b(angeklagt|Anklage|Strafantrag)\b", re.I)),
("investigation", re.compile(r"\b(ermittelt|Ermittlungen|Ermittlungsverfahren|Beschuldigte[rn]?|Hausdurchsuchung|"
r"WKStA|Staatsanwaltschaft|Razzia)\b", re.I)),
]
STAGE_DE = {
"convicted_final": "rechtskräftige Verurteilung (laut Bericht)",
"convicted_not_final": "nicht rechtskräftige Verurteilung (laut Bericht)",
"acquitted": "Freispruch (laut Bericht)",
"diversion": "Diversion (laut Bericht)",
"proceedings_dropped": "Verfahren eingestellt (laut Bericht)",
"trial": "Gerichtsverfahren (laut Bericht)",
"indicted": "Anklage (laut Bericht)",
"investigation": "Ermittlungen (laut Bericht)",
}
def classify(headline: str) -> str | None:
for stage, pattern in STAGES:
if pattern.search(headline):
return stage
return None
@register
class LegalDetector(Enricher):
spec = DERIVED
name = "enrich.legal"
description = "Propose legal-proceeding claims from headlines (human review required)"
schedule = "6-59/10 * * * *"
def run(self, ctx: RunContext) -> Iterator[Claim]:
conn = ctx.conn
while True:
rows = conn.execute(
"""SELECT c.id, c.person_id, c.occurred_at, d.url, d.title, d.published_at, s.slug AS source
FROM claim c
JOIN claim_evidence e ON e.claim_id = c.id
JOIN document d ON d.id = e.document_id
JOIN source s ON s.id = d.source_id
WHERE c.claim_type = 'media_mention' AND c.person_id IS NOT NULL AND NOT (c.data ? 'legal_checked')
AND (s.kind IN ('media', 'aggregator') OR s.slug = 'wksta')
ORDER BY c.id LIMIT 1000"""
).fetchall()
if not rows:
return
for row in rows:
stage = classify(row["title"] or "")
if stage:
when = row["published_at"] or row["occurred_at"] or datetime.now()
yield Claim(
claim_type="legal_proceeding", person=PersonRef(ids={"politrack": str(row["person_id"])}),
key=("legal-news", row["person_id"], stage, when.strftime("%Y-%m")), occurred_at=when,
summary_de=STAGE_DE[stage], summary_en=f"Reported legal matter ({stage.replace('_', ' ')})",
data={"detected_from": "headline"}, sensitive=True, review=True,
evidence=[Evidence(url=row["url"], source=row["source"], title=row["title"], published_at=when,
quote=row["title"], confidence=0.5)],
topics=("Inneres und Recht",),
facet=Legal(stage=stage, stage_date=when.date()),
)
ctx.stats["proposed"] += 1
conn.execute("UPDATE claim SET data = data || %s WHERE id = %s", (Jsonb({"legal_checked": True}), row["id"]))