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.
223 lines
8.2 KiB
Python
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
|