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>
92 lines
4.6 KiB
Python
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"]))
|