antragstracker/backend/src/tracker/core/status.py
Dotty Dotter 17606ab237 feat: Initial commit — Antragstracker Hagen
Vollständige Pipeline zur Analyse kommunaler Vorlagen aus ALLRIS:
- OParl-Import: 20.149 Vorlagen
- PDF-Extraktion: 10.045 Volltexte (adaptives Throttling)
- KI-Zusammenfassungen: 10.026 via Qwen Plus (parallelisiert)
- Beratungsfolge-Scraper: Beschlusstexte + Wortprotokolle
- Abstimmungs-Analyse mit Koalitionsmatrix
- Georeferenzierung (Nominatim)

Stack: FastAPI + SvelteKit + SQLite
Deployment: Docker + Traefik auf VServer

Daten (DB, Logs) nicht im Repo — siehe Restic-Backup.
Repo-Setup: scripts/setup.sh für Neuaufbau aus OParl-API.
2026-03-30 16:37:58 +02:00

223 lines
8.2 KiB
Python

"""Status-Engine: computes chain status based on KONZEPT.md section 6."""
from __future__ import annotations
import sqlite3
from datetime import date, timedelta
# Threshold: a Vorlage is considered "old" after this many days without activity
VERSANDET_TAGE = 365
def compute_status(
conn: sqlite3.Connection,
ursprung_id: int,
chain_typ: str,
members: list[sqlite3.Row],
) -> dict:
"""Compute the overall status for a chain.
Returns dict with keys: status, status_seit, vertagungen_count
"""
if chain_typ == "anfrage":
return _status_anfrage(conn, ursprung_id, members)
elif chain_typ == "antrag":
return _status_antrag(conn, ursprung_id, members)
return {"status": "unbekannt", "status_seit": None, "vertagungen_count": 0}
def _status_anfrage(
conn: sqlite3.Connection,
ursprung_id: int,
members: list[sqlite3.Row],
) -> dict:
"""Status logic for Anfragen (KONZEPT.md 6.1).
angefragt: Keine Stellungnahme, <1 Jahr
beantwortet: Stellungnahme + KI-Match >=0.7 + Kenntnisnahme
offen: Stellungnahme da, aber keine Kenntnisnahme
abgewiegelt: Stellungnahme + KI-Match <0.5
versandet: Keine Antwort, >1 Jahr
zurückgezogen: Explizit zurückgezogen
"""
heute = date.today()
ursprung_datum = _parse_date(members[0]["datum_eingang"])
# Check for Stellungnahme in chain members
stellungnahmen = [m for m in members if m["typ"] == "stellungnahme"]
has_stellungnahme = len(stellungnahmen) > 0
# Check for Kenntnisnahme in Beratungen
beratungen = conn.execute("""
SELECT rolle, ergebnis, sitzung_datum
FROM beratungen
WHERE vorlage_id = ?
ORDER BY sitzung_datum DESC
""", (ursprung_id,)).fetchall()
has_kenntnisnahme = any(
b["rolle"] and "kenntnisnahme" in b["rolle"].lower()
for b in beratungen
)
# Check KI-Match score for Antwort
ki_score = _get_ki_score(conn, ursprung_id, "antwort_match")
# Check zurückgezogen
if _is_zurueckgezogen(beratungen):
return {"status": "zurückgezogen", "status_seit": _latest_date(beratungen), "vertagungen_count": 0}
if has_stellungnahme:
if ki_score is not None and ki_score < 0.5:
return {"status": "abgewiegelt", "status_seit": _vorlage_date(stellungnahmen[0]), "vertagungen_count": 0}
if has_kenntnisnahme and (ki_score is None or ki_score >= 0.7):
return {"status": "beantwortet", "status_seit": _vorlage_date(stellungnahmen[0]), "vertagungen_count": 0}
return {"status": "offen", "status_seit": _vorlage_date(stellungnahmen[0]), "vertagungen_count": 0}
# No Stellungnahme
if ursprung_datum and (heute - ursprung_datum).days > VERSANDET_TAGE:
return {"status": "versandet", "status_seit": ursprung_datum, "vertagungen_count": 0}
return {"status": "angefragt", "status_seit": ursprung_datum, "vertagungen_count": 0}
def _status_antrag(
conn: sqlite3.Connection,
ursprung_id: int,
members: list[sqlite3.Row],
) -> dict:
"""Status logic for Anträge (KONZEPT.md 6.2).
eingereicht: Neu, noch keine Beratung
in_beratung: Mindestens eine Beratung ohne Endbeschluss
vertagt: Letzte Beratung = vertagt
verwiesen: An anderen Ausschuss überwiesen
beschlossen: Angenommen, <1 Jahr, kein Umsetzungsbericht
umgesetzt: Umsetzungsbericht + KI-Match >=0.7
teilweise_umgesetzt: Umsetzungsbericht + KI-Match 0.4-0.7
abgelehnt: Beschluss = abgelehnt
abgewiegelt: Beschlossen + Bericht + KI-Match <0.4
versandet: Beschlossen, >1 Jahr, kein Bericht
zurückgezogen: Explizit zurückgezogen
"""
heute = date.today()
ursprung_datum = _parse_date(members[0]["datum_eingang"])
beratungen = conn.execute("""
SELECT rolle, ergebnis, sitzung_datum
FROM beratungen
WHERE vorlage_id = ?
ORDER BY sitzung_datum DESC NULLS LAST
""", (ursprung_id,)).fetchall()
# Count Vertagungen
vertagungen = sum(1 for b in beratungen if b["ergebnis"] and "vertagt" in b["ergebnis"].lower())
# Check zurückgezogen
if _is_zurueckgezogen(beratungen):
return {"status": "zurückgezogen", "status_seit": _latest_date(beratungen), "vertagungen_count": vertagungen}
# Check for Berichte in chain
berichte = [m for m in members if m["typ"] == "bericht"]
has_bericht = len(berichte) > 0
# Determine beschluss from beratungen
beschluss = _get_beschluss(beratungen)
if beschluss == "abgelehnt":
return {"status": "abgelehnt", "status_seit": _latest_date(beratungen), "vertagungen_count": vertagungen}
if beschluss == "angenommen":
beschluss_datum = _latest_date(beratungen)
if has_bericht:
ki_score = _get_ki_score(conn, ursprung_id, "umsetzung_match")
bericht_datum = _vorlage_date(berichte[-1])
if ki_score is not None:
if ki_score >= 0.7:
return {"status": "umgesetzt", "status_seit": bericht_datum, "vertagungen_count": vertagungen}
elif ki_score >= 0.4:
return {"status": "teilweise_umgesetzt", "status_seit": bericht_datum, "vertagungen_count": vertagungen}
else:
return {"status": "abgewiegelt", "status_seit": bericht_datum, "vertagungen_count": vertagungen}
return {"status": "umgesetzt", "status_seit": bericht_datum, "vertagungen_count": vertagungen}
# Angenommen but no Bericht
if beschluss_datum and (heute - beschluss_datum).days > VERSANDET_TAGE:
return {"status": "versandet", "status_seit": beschluss_datum, "vertagungen_count": vertagungen}
return {"status": "beschlossen", "status_seit": beschluss_datum, "vertagungen_count": vertagungen}
if beschluss == "verwiesen":
return {"status": "verwiesen", "status_seit": _latest_date(beratungen), "vertagungen_count": vertagungen}
# No final decision yet
if beratungen:
last = beratungen[0]
if last["ergebnis"] and "vertagt" in last["ergebnis"].lower():
return {"status": "vertagt", "status_seit": _latest_date(beratungen), "vertagungen_count": vertagungen}
return {"status": "in_beratung", "status_seit": _latest_date(beratungen), "vertagungen_count": vertagungen}
# No beratungen at all
return {"status": "eingereicht", "status_seit": ursprung_datum, "vertagungen_count": vertagungen}
# --- Helpers ---
def _parse_date(val: str | None) -> date | None:
if not val:
return None
try:
return date.fromisoformat(val)
except (ValueError, TypeError):
return None
def _vorlage_date(member: sqlite3.Row) -> date | None:
return _parse_date(member["datum_eingang"])
def _latest_date(beratungen: list[sqlite3.Row]) -> date | None:
dates = [_parse_date(b["sitzung_datum"]) for b in beratungen if b["sitzung_datum"]]
return max(dates) if dates else None
def _get_ki_score(conn: sqlite3.Connection, vorlage_id: int, typ: str) -> float | None:
row = conn.execute("""
SELECT score FROM ki_bewertungen
WHERE vorlage_id = ? AND typ = ?
ORDER BY erstellt_at DESC
LIMIT 1
""", (vorlage_id, typ)).fetchone()
return row["score"] if row else None
def _is_zurueckgezogen(beratungen: list[sqlite3.Row]) -> bool:
return any(
b["ergebnis"] and "zurückgezogen" in b["ergebnis"].lower()
for b in beratungen
)
def _get_beschluss(beratungen: list[sqlite3.Row]) -> str | None:
"""Determine the final decision from Beratungen.
Looks for Entscheidung-role beratungen with a result.
"""
for b in beratungen:
ergebnis = (b["ergebnis"] or "").lower()
rolle = (b["rolle"] or "").lower()
if "abgelehnt" in ergebnis:
return "abgelehnt"
if "verwiesen" in ergebnis:
return "verwiesen"
if any(kw in ergebnis for kw in ("angenommen", "empfohlen", "beschlossen", "zugestimmt")):
return "angenommen"
# If rolle is Entscheidung and there's any ergebnis, it's likely a decision
if "entscheidung" in rolle and ergebnis and "vertagt" not in ergebnis:
return "angenommen"
return None