Compare commits
42 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
145ad1e8d4 | ||
|
|
eb0669d6ac | ||
|
|
722b073bbd | ||
|
|
8f3a811a83 | ||
|
|
50442f203a | ||
|
|
7de4df1fef | ||
|
|
a9f0b61c75 | ||
|
|
7e0f0117e6 | ||
|
|
e26607854f | ||
|
|
ae3f48be41 | ||
|
|
d640734641 | ||
|
|
3262f17458 | ||
|
|
7e20f910fe | ||
|
|
3a8c03db6c | ||
|
|
d2fc11f21b | ||
|
|
5559f42c92 | ||
|
|
d0d941444d | ||
|
|
0d26cad549 | ||
|
|
5f6bcac282 | ||
|
|
09c29cac69 | ||
|
|
3921cb91a4 | ||
|
|
6d587c1f3a | ||
|
|
4b03448e29 | ||
|
|
07bb832c35 | ||
|
|
a8d7b72702 | ||
|
|
fab1bddd3c | ||
|
|
98787c8684 | ||
|
|
b1ad2bd45d | ||
|
|
7a64335e64 | ||
|
|
c1926ada4f | ||
|
|
6581acd28e | ||
|
|
7cbd46f88d | ||
|
|
7f070b5e6c | ||
|
|
fa5a5b6026 | ||
|
|
85a10b7fc3 | ||
|
|
997d59a9a5 | ||
|
|
273d45ea36 | ||
|
|
88f9c7db6c | ||
|
|
489a1915f8 | ||
|
|
50c026e3a0 | ||
|
|
553e99d14e | ||
|
|
38bffb23fa |
41
.coveragerc
Normal file
41
.coveragerc
Normal file
@ -0,0 +1,41 @@
|
||||
[run]
|
||||
source = app
|
||||
omit =
|
||||
# Hilfs-Skripte und Migrations-Tools — nicht produktiver Code
|
||||
app/reindex_embeddings.py
|
||||
app/sync_abgeordnetenwatch.py
|
||||
# Generated / Auto-Discovery
|
||||
app/__init__.py
|
||||
|
||||
[report]
|
||||
# Faustregel ADR 0007: keine 100%-Jagd, aber kritische Pfade abdecken.
|
||||
# show_missing-Flag macht Luecken im CI-Output sofort sichtbar.
|
||||
#
|
||||
# fail_under=45 ist die aktuelle Baseline (Stand 2026-04-28), nicht das
|
||||
# Ziel. Die niedrige Total-Coverage kommt aus drei Bereichen, die mit
|
||||
# Unit-Tests schwer abzudecken sind und stattdessen via integration/e2e
|
||||
# laufen sollten:
|
||||
# - app/main.py (FastAPI-Endpoints, ~900 LOC) — TestClient-Smoke-Tests
|
||||
# sind lokal geskippt mangels App-Imports; laufen in der Docker-Suite.
|
||||
# - app/parlamente.py (16 Adapter, ~3400 LOC) — Live-HTTP gegen Landtage,
|
||||
# tests/integration/ deckt das ab.
|
||||
# - app/queue.py, app/report.py — Async-Worker und PDF-Renderer, eigene
|
||||
# Test-Runden noch ausstehend.
|
||||
# Schwelle hochsetzen, sobald genannte Bereiche eigene Tests haben.
|
||||
show_missing = true
|
||||
skip_covered = false
|
||||
precision = 1
|
||||
fail_under = 45
|
||||
|
||||
# Zeilen, die nicht gezaehlt werden sollen — typische Boilerplate ohne
|
||||
# eigentliche Testbarkeit.
|
||||
exclude_lines =
|
||||
pragma: no cover
|
||||
def __repr__
|
||||
raise NotImplementedError
|
||||
if __name__ == .__main__.:
|
||||
if TYPE_CHECKING:
|
||||
\.\.\.
|
||||
|
||||
[html]
|
||||
directory = htmlcov
|
||||
4
.gitignore
vendored
4
.gitignore
vendored
@ -18,4 +18,8 @@ reports/
|
||||
.DS_Store
|
||||
Thumbs.db
|
||||
site/
|
||||
|
||||
# Coverage reports (Phase 3 von #134, ADR 0007)
|
||||
.coverage
|
||||
.coverage.*
|
||||
htmlcov/
|
||||
|
||||
@ -71,6 +71,52 @@ def load_context_file(name: str) -> str:
|
||||
return ""
|
||||
|
||||
|
||||
USER_PROMPT_TEMPLATE = """Analysiere den folgenden Antrag:
|
||||
|
||||
<kontext>
|
||||
{bundesland_context}
|
||||
</kontext>
|
||||
|
||||
<wahlprogramm_zitate>
|
||||
{quotes_context}
|
||||
</wahlprogramm_zitate>
|
||||
|
||||
<antrag>
|
||||
{text}
|
||||
</antrag>
|
||||
|
||||
**PFLICHT-FRAKTIONEN:** Du MUSST ALLE folgenden Fraktionen der aktuellen Wahlperiode in `wahlprogrammScores` bewerten — keine auslassen:
|
||||
{pflicht_fraktionen}
|
||||
|
||||
Bewerte nach GWÖ-Matrix 2.0 für Gemeinden:
|
||||
1. GWÖ-Treue (0-10) mit Matrix-Zuordnung und Symbolen (++/+/○/−/−−)
|
||||
2. Wahlprogrammtreue JEDER der oben genannten Pflicht-Fraktionen (0-10)
|
||||
3. Parteiprogrammtreue JEDER der oben genannten Pflicht-Fraktionen (0-10)
|
||||
4. Bis zu 3 Verbesserungsvorschläge in Redline-Syntax
|
||||
5. Themen-Tags für Kategorisierung
|
||||
|
||||
**ZITATEREGEL — STRIKT:** In jedem ``wahlprogrammScores[].wahlprogramm.zitate[].quelle``
|
||||
und ``parteiprogrammScores[].parteiprogramm.zitate[].quelle`` musst du **wortgleich**
|
||||
einen der oben in ``<wahlprogramm_zitate>`` aufgelisteten Quellen-Labels (Programm-Name +
|
||||
Seite) übernehmen — z.B. ``"CDU Mecklenburg-Vorpommern Wahlprogramm 2021, S. 33"``.
|
||||
Erfinde keine Quellen aus deinem Trainingswissen. Nimm keine Quelle aus einem anderen
|
||||
Bundesland (z.B. NRW 2022) als die hier aufgelisteten — selbst wenn dir die dortigen
|
||||
Programme bekannter sind. Findest du oben für eine Partei keinen passenden Chunk, lass
|
||||
``zitate`` leer (``[]``) und vermerke das in der ``begruendung``.
|
||||
|
||||
Ausgabe als reines JSON ohne Markdown-Codeblöcke."""
|
||||
|
||||
|
||||
def get_user_prompt_template() -> str:
|
||||
"""Public Template-String fuer Transparenz-Seite (#145).
|
||||
|
||||
Enthaelt die Platzhalter ``{bundesland_context}``, ``{quotes_context}``,
|
||||
``{text}`` und ``{pflicht_fraktionen}`` — gerendert wird in
|
||||
``analyze_text`` direkt via ``.format(...)``.
|
||||
"""
|
||||
return USER_PROMPT_TEMPLATE
|
||||
|
||||
|
||||
def get_system_prompt() -> str:
|
||||
"""Build the system prompt with GWÖ matrix context."""
|
||||
return """Du bist ein Experte für Gemeinwohl-Ökonomie (GWÖ) und parlamentarische Analyse. Du bewertest Anträge aus Landesparlamenten systematisch nach drei Dimensionen:
|
||||
@ -316,40 +362,12 @@ async def analyze_antrag(
|
||||
quotes = find_relevant_quotes(text, fraktionen, bundesland=bundesland)
|
||||
quotes_context = format_quote_for_prompt(quotes)
|
||||
|
||||
user_prompt = f"""Analysiere den folgenden Antrag:
|
||||
|
||||
<kontext>
|
||||
{bundesland_context}
|
||||
</kontext>
|
||||
|
||||
<wahlprogramm_zitate>
|
||||
{quotes_context if quotes_context else "Keine relevanten Zitate gefunden."}
|
||||
</wahlprogramm_zitate>
|
||||
|
||||
<antrag>
|
||||
{text}
|
||||
</antrag>
|
||||
|
||||
**PFLICHT-FRAKTIONEN:** Du MUSST ALLE folgenden Fraktionen der aktuellen Wahlperiode in `wahlprogrammScores` bewerten — keine auslassen:
|
||||
{', '.join(BUNDESLAENDER[bundesland].landtagsfraktionen)}
|
||||
|
||||
Bewerte nach GWÖ-Matrix 2.0 für Gemeinden:
|
||||
1. GWÖ-Treue (0-10) mit Matrix-Zuordnung und Symbolen (++/+/○/−/−−)
|
||||
2. Wahlprogrammtreue JEDER der oben genannten Pflicht-Fraktionen (0-10)
|
||||
3. Parteiprogrammtreue JEDER der oben genannten Pflicht-Fraktionen (0-10)
|
||||
4. Bis zu 3 Verbesserungsvorschläge in Redline-Syntax
|
||||
5. Themen-Tags für Kategorisierung
|
||||
|
||||
**ZITATEREGEL — STRIKT:** In jedem ``wahlprogrammScores[].wahlprogramm.zitate[].quelle``
|
||||
und ``parteiprogrammScores[].parteiprogramm.zitate[].quelle`` musst du **wortgleich**
|
||||
einen der oben in ``<wahlprogramm_zitate>`` aufgelisteten Quellen-Labels (Programm-Name +
|
||||
Seite) übernehmen — z.B. ``"CDU Mecklenburg-Vorpommern Wahlprogramm 2021, S. 33"``.
|
||||
Erfinde keine Quellen aus deinem Trainingswissen. Nimm keine Quelle aus einem anderen
|
||||
Bundesland (z.B. NRW 2022) als die hier aufgelisteten — selbst wenn dir die dortigen
|
||||
Programme bekannter sind. Findest du oben für eine Partei keinen passenden Chunk, lass
|
||||
``zitate`` leer (``[]``) und vermerke das in der ``begruendung``.
|
||||
|
||||
Ausgabe als reines JSON ohne Markdown-Codeblöcke."""
|
||||
user_prompt = USER_PROMPT_TEMPLATE.format(
|
||||
bundesland_context=bundesland_context,
|
||||
quotes_context=quotes_context if quotes_context else "Keine relevanten Zitate gefunden.",
|
||||
text=text,
|
||||
pflicht_fraktionen=", ".join(BUNDESLAENDER[bundesland].landtagsfraktionen),
|
||||
)
|
||||
|
||||
# LLM-Call über den Port. Retry-Loop + Markdown-Stripping wohnen im
|
||||
# Adapter (``QwenBewerter``). Bei exhausted retries wirft er
|
||||
|
||||
@ -4,7 +4,7 @@ from pathlib import Path
|
||||
|
||||
class Settings(BaseSettings):
|
||||
app_name: str = "GWÖ-Antragsprüfer"
|
||||
app_version: str = "1.0.0"
|
||||
app_version: str = "1.0.2"
|
||||
prompt_version: str = "v4.1"
|
||||
|
||||
# Paths
|
||||
@ -54,6 +54,17 @@ class Settings(BaseSettings):
|
||||
# Token für Unsubscribe-Links (HMAC-Secret)
|
||||
unsubscribe_secret: str = "change-me-in-prod"
|
||||
|
||||
# Gitea-API-Token für Feedback-Issues (Issue #feedback-widget)
|
||||
# Wert in .env: GITEA_TOKEN=<token>
|
||||
# Token-Quelle: cat ~/.claude/.gitea-token
|
||||
gitea_token: str = ""
|
||||
gitea_api_url: str = "https://repo.toppyr.de/api/v1"
|
||||
gitea_repo_owner: str = "tobias"
|
||||
gitea_repo_name: str = "gwoe-antragspruefer"
|
||||
# Komma-getrennte Liste zusätzlicher Labels, die Feedback-Issues bekommen.
|
||||
# Auf Dev: "feedback,dev" — damit Issues aus gwoe-dev.toppyr.de unterscheidbar sind.
|
||||
gitea_feedback_labels: str = "feedback"
|
||||
|
||||
model_config = {"env_file": ".env", "env_file_encoding": "utf-8"}
|
||||
|
||||
|
||||
|
||||
111
app/database.py
111
app/database.py
@ -259,6 +259,32 @@ async def init_db():
|
||||
)
|
||||
""")
|
||||
|
||||
# Fraktions-aggregierte Abstimmungsergebnisse aus Plenarprotokollen (#106).
|
||||
# Granularitaet: "GRUENE und SPD haben zugestimmt", nicht pro MP — das
|
||||
# ist der Datentyp, der aus deterministischen Parsern wie
|
||||
# app/protokoll_parsers/ rauskommt.
|
||||
# Compound-PK ueber quelle_protokoll, weil eine Drucksache mehrfach
|
||||
# abgestimmt werden kann (Ausschuss-Empfehlung + Plenum-Beschluss).
|
||||
await db.execute("""
|
||||
CREATE TABLE IF NOT EXISTS plenum_vote_results (
|
||||
bundesland TEXT NOT NULL,
|
||||
drucksache TEXT NOT NULL,
|
||||
ergebnis TEXT NOT NULL,
|
||||
einstimmig INTEGER NOT NULL DEFAULT 0,
|
||||
fraktionen_ja TEXT NOT NULL DEFAULT '[]',
|
||||
fraktionen_nein TEXT NOT NULL DEFAULT '[]',
|
||||
fraktionen_enthaltung TEXT NOT NULL DEFAULT '[]',
|
||||
quelle_protokoll TEXT NOT NULL,
|
||||
quelle_url TEXT,
|
||||
parsed_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||
PRIMARY KEY (bundesland, drucksache, quelle_protokoll)
|
||||
)
|
||||
""")
|
||||
await db.execute(
|
||||
"CREATE INDEX IF NOT EXISTS idx_pvr_bl_ds "
|
||||
"ON plenum_vote_results(bundesland, drucksache)"
|
||||
)
|
||||
|
||||
await db.commit()
|
||||
|
||||
|
||||
@ -1181,3 +1207,88 @@ async def get_monitoring_new_today(scan_date: str) -> list[dict]:
|
||||
pass
|
||||
result.append(d)
|
||||
return result
|
||||
|
||||
|
||||
# ─── Plenum-Vote-Results (#106) ─────────────────────────────────────────────
|
||||
# Fraktions-aggregierte Abstimmungsergebnisse aus Plenarprotokollen.
|
||||
# Quelle: app/protokoll_parsers/ (NRW). BL-uebergreifender Parser ist #126.
|
||||
|
||||
async def upsert_plenum_vote(
|
||||
*,
|
||||
bundesland: str,
|
||||
drucksache: str,
|
||||
ergebnis: str,
|
||||
einstimmig: bool,
|
||||
fraktionen_ja: list[str],
|
||||
fraktionen_nein: list[str],
|
||||
fraktionen_enthaltung: list[str],
|
||||
quelle_protokoll: str,
|
||||
quelle_url: Optional[str] = None,
|
||||
) -> None:
|
||||
"""Schreibt ein Abstimmungsergebnis aus einem Plenarprotokoll.
|
||||
|
||||
Idempotent ueber den Compound-PK (bundesland, drucksache, quelle_protokoll):
|
||||
derselbe Eintrag aus demselben Protokoll wird upgesertet, mehrfach-Voten
|
||||
derselben Drucksache aus verschiedenen Protokollen behalten beide Eintraege.
|
||||
"""
|
||||
import json as _json
|
||||
async with aiosqlite.connect(settings.db_path) as db:
|
||||
await db.execute(
|
||||
"""
|
||||
INSERT INTO plenum_vote_results
|
||||
(bundesland, drucksache, ergebnis, einstimmig,
|
||||
fraktionen_ja, fraktionen_nein, fraktionen_enthaltung,
|
||||
quelle_protokoll, quelle_url)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
ON CONFLICT(bundesland, drucksache, quelle_protokoll) DO UPDATE SET
|
||||
ergebnis = excluded.ergebnis,
|
||||
einstimmig = excluded.einstimmig,
|
||||
fraktionen_ja = excluded.fraktionen_ja,
|
||||
fraktionen_nein = excluded.fraktionen_nein,
|
||||
fraktionen_enthaltung = excluded.fraktionen_enthaltung,
|
||||
quelle_url = excluded.quelle_url,
|
||||
parsed_at = datetime('now')
|
||||
""",
|
||||
(
|
||||
bundesland,
|
||||
drucksache,
|
||||
ergebnis,
|
||||
1 if einstimmig else 0,
|
||||
_json.dumps(fraktionen_ja, ensure_ascii=False),
|
||||
_json.dumps(fraktionen_nein, ensure_ascii=False),
|
||||
_json.dumps(fraktionen_enthaltung, ensure_ascii=False),
|
||||
quelle_protokoll,
|
||||
quelle_url,
|
||||
),
|
||||
)
|
||||
await db.commit()
|
||||
|
||||
|
||||
async def get_plenum_votes(bundesland: str, drucksache: str) -> list[dict]:
|
||||
"""Alle Plenarprotokoll-Abstimmungen fuer eine Drucksache, neueste zuerst.
|
||||
|
||||
Eine Drucksache kann mehrfach abgestimmt werden (z.B. Ueberweisung +
|
||||
finale Beschlussfassung), deshalb Liste statt Single.
|
||||
"""
|
||||
import json as _json
|
||||
async with aiosqlite.connect(settings.db_path) as db:
|
||||
db.row_factory = aiosqlite.Row
|
||||
rows = await db.execute(
|
||||
"""
|
||||
SELECT * FROM plenum_vote_results
|
||||
WHERE bundesland = ? AND drucksache = ?
|
||||
ORDER BY parsed_at DESC
|
||||
""",
|
||||
(bundesland, drucksache),
|
||||
)
|
||||
out = []
|
||||
for r in await rows.fetchall():
|
||||
d = dict(r)
|
||||
d["einstimmig"] = bool(d.get("einstimmig"))
|
||||
for key in ("fraktionen_ja", "fraktionen_nein", "fraktionen_enthaltung"):
|
||||
try:
|
||||
d[key] = _json.loads(d.get(key) or "[]")
|
||||
except Exception:
|
||||
d[key] = []
|
||||
out.append(d)
|
||||
return out
|
||||
|
||||
@ -86,3 +86,45 @@ def ist_abstimmbar(typ_normiert: str) -> bool:
|
||||
def ist_abstimmbar_original(original: str) -> bool:
|
||||
"""Convenience: prüft direkt am Original-Typ-String."""
|
||||
return ist_abstimmbar(normalize_typ(original))
|
||||
|
||||
|
||||
# Frage-Präfixe die typisch für Kleine Anfragen sind. Wird genutzt wenn der
|
||||
# Adapter (z.B. NRW) den Typ nur als "Drucksache" liefert — wir versuchen
|
||||
# anhand des Titels eine bessere Klassifikation, damit Search-Ergebnisse
|
||||
# nicht voll mit nicht-abstimmbaren Anfragen sind.
|
||||
_FRAGE_PRAEFIXE = (
|
||||
"welche ", "wie viele ", "wieviel", "wie viel ", "wie hoch ", "wie ",
|
||||
"wann ", "warum ", "weshalb ", "wo ", "wer ", "wie steht ", "wie weit ",
|
||||
"ist es ", "ist der ", "ist die ", "ist das ", "sind ",
|
||||
"trifft es ", "kann ", "wird ", "wieso ", "was ",
|
||||
"hat ", "hat der ", "hat die ", "hat das ",
|
||||
"haben ", "war ", "waren ",
|
||||
)
|
||||
|
||||
|
||||
def likely_kleine_anfrage_titel(title: str) -> bool:
|
||||
"""Heuristik: erkennt Kleine Anfragen am Titel-Format.
|
||||
|
||||
Wenn der Titel mit einem typischen Frage-Präfix beginnt oder mit "?" endet,
|
||||
behandeln wir die Drucksache als Kleine Anfrage. NRW-OPAL klassifiziert
|
||||
alle Drucksachen als "Drucksache" → ohne diese Heuristik landen Anfragen
|
||||
in den Search-Ergebnissen, was den User verwirrt (#149 Folge).
|
||||
|
||||
Args:
|
||||
title: Drucksachen-Titel inkl. evtl. Nummer-Präfix wie "1Welche...".
|
||||
|
||||
Returns:
|
||||
True wenn der Titel wie eine Kleine Anfrage aussieht.
|
||||
"""
|
||||
if not title:
|
||||
return False
|
||||
t = title.strip()
|
||||
# Manche Adapter prefixen mit Nummerierung wie "1Welche..." — strippen
|
||||
while t and (t[0].isdigit() or t[0] in " .-"):
|
||||
t = t[1:]
|
||||
t_low = t.lower()
|
||||
if t_low.startswith(_FRAGE_PRAEFIXE):
|
||||
return True
|
||||
if t.rstrip().endswith("?"):
|
||||
return True
|
||||
return False
|
||||
|
||||
@ -714,7 +714,7 @@ def _chunk_pdf_url(chunk: dict) -> Optional[str]:
|
||||
# die URL bleibt bounded (sonst würden 500-Zeichen-Snippets in jeder
|
||||
# Zitat-URL stehen und das HTML-Report-JSON aufblähen).
|
||||
q = urllib.parse.quote_plus(text[:200])
|
||||
return f"/api/wahlprogramm-cite?pid={prog_id}&seite={seite}&q={q}"
|
||||
return f"/api/wahlprogramm-cite?pid={prog_id}&seite={seite}&q={q}#page={seite}"
|
||||
|
||||
if seite:
|
||||
return f"/static/referenzen/{pdf}#page={seite}"
|
||||
@ -777,9 +777,14 @@ def render_highlighted_page(programm_id: str, seite: int, query: str) -> Optiona
|
||||
rects = []
|
||||
if needle:
|
||||
clean = needle.replace("\u00ad", "")
|
||||
# LLMs ziehen h\u00e4ufig die Seitenzahl-Header (\u201e44 Gute Bildung \u2026")
|
||||
# mit ins Zitat. Wenn die ersten Tokens reine Ziffern sind,
|
||||
# strippen wir sie f\u00fcr die Suche \u2014 sonst matched search_for nicht.
|
||||
import re as _re
|
||||
clean = _re.sub(r"^\s*\d+\s+", "", clean).strip()
|
||||
words = clean.split()
|
||||
anchor = " ".join(words[:5]) if len(words) >= 5 else clean
|
||||
# Versuch 1: angegebene Seite, Volltext
|
||||
# Versuch 1: angegebene Seite, Volltext (gestrippt)
|
||||
rects = src[target_page_idx].search_for(clean)
|
||||
# Versuch 2: angegebene Seite, 5-Wort-Anker
|
||||
if not rects:
|
||||
@ -792,8 +797,7 @@ def render_highlighted_page(programm_id: str, seite: int, query: str) -> Optiona
|
||||
target_page_idx = i
|
||||
break
|
||||
|
||||
# Volles PDF mit Highlight-Annotation. Der Browser öffnet das
|
||||
# vollständige Wahlprogramm; das Frontend hängt #page=N an die URL.
|
||||
# Volles PDF mit Highlight-Annotation.
|
||||
page = src[target_page_idx]
|
||||
if needle and rects:
|
||||
for rect in rects:
|
||||
@ -802,6 +806,16 @@ def render_highlighted_page(programm_id: str, seite: int, query: str) -> Optiona
|
||||
annot.set_colors(stroke=(1.0, 0.93, 0.0)) # gelb
|
||||
annot.update()
|
||||
|
||||
# PDF-OpenAction setzen, damit der Reader direkt auf der richtigen
|
||||
# Seite startet (statt Seite 1) — sonst sieht der User „PDF öffnet,
|
||||
# aber falsche Seite". /Fit = passt-zur-Größe.
|
||||
try:
|
||||
page_xref = page.xref
|
||||
catalog_xref = src.pdf_catalog()
|
||||
src.xref_set_key(catalog_xref, "OpenAction", f"[{page_xref} 0 R /Fit]")
|
||||
except Exception:
|
||||
logger.exception("render_highlighted_page: OpenAction-Setzen fehlgeschlagen")
|
||||
|
||||
highlighted = bool(needle and rects)
|
||||
try:
|
||||
return src.tobytes(), target_page_idx + 1, highlighted
|
||||
|
||||
170
app/ingest_votes.py
Normal file
170
app/ingest_votes.py
Normal file
@ -0,0 +1,170 @@
|
||||
"""BL-uebergreifende Ingest-CLI fuer Plenarprotokolle (#106 / #126).
|
||||
|
||||
Pipeline:
|
||||
1. PDF laden (Pfad oder URL)
|
||||
2. ``protokoll_parsers.parse_protocol(bundesland, pdf_path)`` waehlt den
|
||||
BL-spezifischen Parser aus der Registry
|
||||
3. ``upsert_plenum_vote()`` schreibt jede Abstimmung in die DB
|
||||
|
||||
CLI:
|
||||
python -m app.ingest_votes --pdf MMP18-119.pdf
|
||||
python -m app.ingest_votes --url https://landtag.nrw.de/.../MMP18-119.pdf
|
||||
python -m app.ingest_votes --pdf x.pdf --bundesland NRW --protokoll-id MMP18-119
|
||||
python -m app.ingest_votes --supported # Liste der BL mit Parser
|
||||
|
||||
Aktuell registriert: NRW. Folge-BL via app/protokoll_parsers/<bl>.py + Eintrag
|
||||
in PROTOKOLL_PARSERS — siehe ADR 0009.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import asyncio
|
||||
import logging
|
||||
import sys
|
||||
import tempfile
|
||||
import urllib.request
|
||||
from pathlib import Path
|
||||
from typing import Optional
|
||||
|
||||
from .protokoll_parsers import parse_protocol, supported_bundeslaender
|
||||
from .database import upsert_plenum_vote
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def _derive_protokoll_id(pdf_path: Path) -> str:
|
||||
"""Ermittle Protokoll-ID aus dem Datei-Stem (z.B. 'MMP18-119.pdf' → 'MMP18-119')."""
|
||||
return pdf_path.stem
|
||||
|
||||
|
||||
def _download_pdf(url: str, dest: Path) -> Path:
|
||||
"""Lade ein PDF von einer URL in einen Pfad. Wirft bei HTTP-Fehlern."""
|
||||
req = urllib.request.Request(
|
||||
url,
|
||||
headers={"User-Agent": "GWOeAntragspruefer/1.0 (+https://gwoe.toppyr.de)"},
|
||||
)
|
||||
with urllib.request.urlopen(req, timeout=60) as resp:
|
||||
dest.write_bytes(resp.read())
|
||||
return dest
|
||||
|
||||
|
||||
async def ingest_pdf(
|
||||
pdf_path: Path,
|
||||
*,
|
||||
bundesland: str = "NRW",
|
||||
protokoll_id: Optional[str] = None,
|
||||
quelle_url: Optional[str] = None,
|
||||
) -> dict:
|
||||
"""Parse das PDF mit dem BL-Parser und schreibe alle Abstimmungen in die DB.
|
||||
|
||||
Returns:
|
||||
Statistik-Dict ``{parsed, written, skipped_no_drucksache, errors,
|
||||
protokoll_id, bundesland}``.
|
||||
|
||||
Raises:
|
||||
NotImplementedError: wenn fuer ``bundesland`` kein Parser registriert ist.
|
||||
"""
|
||||
pid = protokoll_id or _derive_protokoll_id(pdf_path)
|
||||
parsed = parse_protocol(bundesland, str(pdf_path))
|
||||
|
||||
written = 0
|
||||
skipped_no_ds = 0
|
||||
errors: list[str] = []
|
||||
|
||||
for entry in parsed:
|
||||
ds = entry.get("drucksache")
|
||||
if not ds:
|
||||
skipped_no_ds += 1
|
||||
continue
|
||||
try:
|
||||
await upsert_plenum_vote(
|
||||
bundesland=bundesland,
|
||||
drucksache=ds,
|
||||
ergebnis=entry["ergebnis"],
|
||||
einstimmig=bool(entry.get("einstimmig", False)),
|
||||
fraktionen_ja=entry.get("votes", {}).get("ja", []),
|
||||
fraktionen_nein=entry.get("votes", {}).get("nein", []),
|
||||
fraktionen_enthaltung=entry.get("votes", {}).get("enthaltung", []),
|
||||
quelle_protokoll=pid,
|
||||
quelle_url=quelle_url,
|
||||
)
|
||||
written += 1
|
||||
except Exception as exc:
|
||||
logger.exception("Upsert fehlgeschlagen fuer %s", ds)
|
||||
errors.append(f"{ds}: {exc}")
|
||||
|
||||
return {
|
||||
"parsed": len(parsed),
|
||||
"written": written,
|
||||
"skipped_no_drucksache": skipped_no_ds,
|
||||
"errors": errors,
|
||||
"protokoll_id": pid,
|
||||
"bundesland": bundesland,
|
||||
}
|
||||
|
||||
|
||||
def _cli() -> None:
|
||||
logging.basicConfig(level=logging.INFO, format="%(levelname)s %(message)s")
|
||||
|
||||
parser = argparse.ArgumentParser(
|
||||
description="Plenarprotokoll → plenum_vote_results (#106 / #126)",
|
||||
)
|
||||
src = parser.add_mutually_exclusive_group(required=False)
|
||||
src.add_argument("--pdf", help="Pfad zu lokalem PDF")
|
||||
src.add_argument("--url", help="HTTP(S)-URL zum PDF")
|
||||
parser.add_argument("--bundesland", default="NRW",
|
||||
help="Bundesland-Code (default: NRW)")
|
||||
parser.add_argument("--protokoll-id",
|
||||
help="Protokoll-ID (default: aus Datei-Stem)")
|
||||
parser.add_argument("--supported", action="store_true",
|
||||
help="Liste alle BL-Codes mit registriertem Parser")
|
||||
args = parser.parse_args()
|
||||
|
||||
if args.supported:
|
||||
for bl in supported_bundeslaender():
|
||||
print(bl)
|
||||
sys.exit(0)
|
||||
|
||||
if not args.pdf and not args.url:
|
||||
parser.error("--pdf oder --url ist erforderlich")
|
||||
|
||||
if args.url:
|
||||
# Download in tmp und nach dem Run wieder loeschen
|
||||
with tempfile.NamedTemporaryFile(suffix=".pdf", delete=False) as tmp:
|
||||
tmp_path = Path(tmp.name)
|
||||
try:
|
||||
print(f"Lade {args.url} → {tmp_path} …")
|
||||
_download_pdf(args.url, tmp_path)
|
||||
pid = args.protokoll_id or args.url.rsplit("/", 1)[-1].rsplit(".", 1)[0]
|
||||
stats = asyncio.run(ingest_pdf(
|
||||
tmp_path, bundesland=args.bundesland,
|
||||
protokoll_id=pid, quelle_url=args.url,
|
||||
))
|
||||
finally:
|
||||
tmp_path.unlink(missing_ok=True)
|
||||
else:
|
||||
pdf_path = Path(args.pdf)
|
||||
if not pdf_path.exists():
|
||||
print(f"FEHLER: PDF nicht gefunden: {pdf_path}", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
stats = asyncio.run(ingest_pdf(
|
||||
pdf_path, bundesland=args.bundesland,
|
||||
protokoll_id=args.protokoll_id,
|
||||
))
|
||||
|
||||
print()
|
||||
print(f"Protokoll {stats['protokoll_id']} ({stats['bundesland']})")
|
||||
print(f" parsed: {stats['parsed']}")
|
||||
print(f" written: {stats['written']}")
|
||||
if stats["skipped_no_drucksache"]:
|
||||
print(f" ohne DS: {stats['skipped_no_drucksache']}")
|
||||
if stats["errors"]:
|
||||
print(f" errors: {len(stats['errors'])}")
|
||||
for e in stats["errors"][:5]:
|
||||
print(f" {e}")
|
||||
if stats["written"] == 0 and not stats["errors"]:
|
||||
sys.exit(2)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
_cli()
|
||||
430
app/main.py
430
app/main.py
@ -91,6 +91,25 @@ app.state.limiter = limiter
|
||||
app.add_exception_handler(RateLimitExceeded, _rate_limit_exceeded_handler)
|
||||
|
||||
|
||||
# Browser-friendly Auth-Redirect — 401/403 von HTML-Routen werden als
|
||||
# 302-Redirect zu /?login=1 ausgeliefert (Login-Modal öffnet sich automatisch).
|
||||
# API-Calls (Accept: application/json) bleiben bei 401/403-JSON.
|
||||
@app.exception_handler(HTTPException)
|
||||
async def _auth_redirect_handler(request: Request, exc: HTTPException):
|
||||
if exc.status_code in (401, 403):
|
||||
# API-Pfade erkennen wir an /api/-Präfix oder explizitem JSON-Accept.
|
||||
accept = request.headers.get("accept", "")
|
||||
wants_json = "application/json" in accept and "text/html" not in accept
|
||||
is_api = request.url.path.startswith("/api/")
|
||||
is_browser = not is_api and not wants_json
|
||||
if is_browser:
|
||||
from fastapi.responses import RedirectResponse
|
||||
target = f"/?login=1&next={request.url.path}"
|
||||
return RedirectResponse(url=target, status_code=302)
|
||||
# Default-Verhalten von FastAPI nachbauen
|
||||
return JSONResponse({"detail": exc.detail}, status_code=exc.status_code, headers=exc.headers or None)
|
||||
|
||||
|
||||
# Security Headers Middleware
|
||||
class SecurityHeadersMiddleware(BaseHTTPMiddleware):
|
||||
async def dispatch(self, request: Request, call_next):
|
||||
@ -127,6 +146,28 @@ app.mount("/static", StaticFiles(directory=static_dir), name="static")
|
||||
templates = Jinja2Templates(directory=str(templates_dir))
|
||||
|
||||
|
||||
# ─── Auth-Fehler bei HTML-Seiten: Redirect statt JSON-401/403 ─────────────────
|
||||
|
||||
@app.exception_handler(401)
|
||||
async def auth_required_redirect(request: Request, exc: HTTPException):
|
||||
"""Bei 401 auf HTML-Routes: Redirect zu /?login=1 (öffnet Auth-Modal)."""
|
||||
accept = request.headers.get("accept", "")
|
||||
if "text/html" in accept:
|
||||
from fastapi.responses import RedirectResponse
|
||||
return RedirectResponse("/?login=1", status_code=302)
|
||||
return JSONResponse({"detail": exc.detail}, status_code=401)
|
||||
|
||||
|
||||
@app.exception_handler(403)
|
||||
async def admin_required_redirect(request: Request, exc: HTTPException):
|
||||
"""Bei 403 auf HTML-Routes: Redirect zu /?login=1 (öffnet Auth-Modal)."""
|
||||
accept = request.headers.get("accept", "")
|
||||
if "text/html" in accept:
|
||||
from fastapi.responses import RedirectResponse
|
||||
return RedirectResponse("/?login=1", status_code=302)
|
||||
return JSONResponse({"detail": exc.detail}, status_code=403)
|
||||
|
||||
|
||||
@app.on_event("startup")
|
||||
async def startup():
|
||||
import asyncio
|
||||
@ -209,7 +250,7 @@ async def classic_index(request: Request):
|
||||
# ─── Default: / → v2 (Default-Flip #139 Phase 2) ────────────────────────────
|
||||
|
||||
@app.get("/", response_class=HTMLResponse)
|
||||
async def index(request: Request):
|
||||
async def index(request: Request, current_user: Optional[dict] = Depends(get_current_user)):
|
||||
"""Startseite — rendert v2-Listenansicht (Default-Flip Phase 2).
|
||||
|
||||
Alte /?drucksache=XX-YYYY Deep-Links werden per 301-Redirect auf
|
||||
@ -230,11 +271,12 @@ async def index(request: Request):
|
||||
"assessments": assessments,
|
||||
"bl_codes": bl_codes,
|
||||
"assessment_count": len(assessments),
|
||||
**_v2_template_context(current_user),
|
||||
})
|
||||
|
||||
|
||||
@app.get("/antrag/{drucksache:path}", response_class=HTMLResponse)
|
||||
async def antrag_detail(request: Request, drucksache: str):
|
||||
async def antrag_detail(request: Request, drucksache: str, current_user: Optional[dict] = Depends(get_current_user)):
|
||||
"""v2-Antragsdetail-Route — Server-Side-Rendering mit echten DB-Daten."""
|
||||
try:
|
||||
drucksache = validate_drucksache(drucksache)
|
||||
@ -243,6 +285,7 @@ async def antrag_detail(request: Request, drucksache: str):
|
||||
"request": request,
|
||||
"v2_active_nav": "durchsuchen",
|
||||
"error": f"Ungültige Drucksachen-ID: {drucksache}",
|
||||
**_v2_template_context(current_user),
|
||||
}, status_code=400)
|
||||
|
||||
row = await get_assessment(drucksache)
|
||||
@ -251,6 +294,7 @@ async def antrag_detail(request: Request, drucksache: str):
|
||||
"request": request,
|
||||
"v2_active_nav": "durchsuchen",
|
||||
"error": f"Antrag {drucksache} wurde nicht gefunden.",
|
||||
**_v2_template_context(current_user),
|
||||
}, status_code=404)
|
||||
|
||||
antrag = _row_to_detail(row)
|
||||
@ -260,6 +304,13 @@ async def antrag_detail(request: Request, drucksache: str):
|
||||
except Exception:
|
||||
logger.exception("Fehler beim Laden von Abstimmungsverhalten für %s", drucksache)
|
||||
antrag["abstimmungsverhalten"] = None
|
||||
# #106 Phase 2: fraktions-aggregierte Plenum-Abstimmungen aus Plenarprotokollen
|
||||
try:
|
||||
from .database import get_plenum_votes as _gpv
|
||||
antrag["plenum_votes"] = await _gpv(antrag.get("bundesland") or "NRW", drucksache)
|
||||
except Exception:
|
||||
logger.exception("Fehler beim Laden plenum_vote_results für %s", drucksache)
|
||||
antrag["plenum_votes"] = []
|
||||
from .models import MATRIX_LABELS
|
||||
return templates.TemplateResponse("v2/screens/antrag_detail.html", {
|
||||
"request": request,
|
||||
@ -294,9 +345,34 @@ async def antrag_detail(request: Request, drucksache: str):
|
||||
"E5": "Setzt sich Ihre Kommune für mehr Demokratie ein — auch auf Landes- und Bundesebene? Werden internationale Abkommen unterstützt?",
|
||||
},
|
||||
"matrix_labels": MATRIX_LABELS,
|
||||
**_v2_template_context(current_user),
|
||||
})
|
||||
|
||||
|
||||
def _v2_template_context(current_user=None) -> dict:
|
||||
"""Gemeinsame v2-Template-Variablen: is_admin, is_authenticated, v2_bundeslaender.
|
||||
|
||||
Wird in jeder v2-Route aufgerufen und per **-Spread in den Template-Context gemischt.
|
||||
"""
|
||||
is_authenticated = bool(current_user and current_user.get("authenticated", False))
|
||||
# require_auth liefert keinen "authenticated"-Key, aber ein sub-Feld — beides prüfen
|
||||
if current_user and current_user.get("sub"):
|
||||
is_authenticated = True
|
||||
roles = (current_user or {}).get("roles", [])
|
||||
is_admin = "admin" in roles or "gwoe-admin" in roles
|
||||
v2_bls = [
|
||||
{"code": bl.code, "name": bl.name}
|
||||
for bl in alle_bundeslaender()
|
||||
if bl.aktiv
|
||||
]
|
||||
return {
|
||||
"is_authenticated": is_authenticated,
|
||||
"is_admin": is_admin,
|
||||
"v2_bundeslaender": v2_bls,
|
||||
"app_version": settings.app_version,
|
||||
}
|
||||
|
||||
|
||||
def _rows_to_list(rows):
|
||||
"""Konvertiert DB-Rows in das flache Dict-Format für die v2-Listenansicht."""
|
||||
result = []
|
||||
@ -648,6 +724,25 @@ async def auth_login_url(request: Request, redirect: str = "/"):
|
||||
return {"enabled": True, "url": url}
|
||||
|
||||
|
||||
@app.get("/api/auth/forgot-password")
|
||||
async def auth_forgot_password(request: Request):
|
||||
"""Redirect zur Keycloak-Passwort-Reset-Seite (#143-Folge).
|
||||
|
||||
Keycloak bietet bei `resetPasswordAllowed=True` eine eigene Reset-Page,
|
||||
die per Mail einen Link zum Passwort-Setzen schickt. Wir leiten direkt
|
||||
dahin um statt eine eigene UI zu bauen.
|
||||
"""
|
||||
from fastapi.responses import RedirectResponse
|
||||
base = str(request.base_url).rstrip("/").replace("http://", "https://")
|
||||
issuer = f"{settings.keycloak_url}/realms/{settings.keycloak_realm}"
|
||||
target = (
|
||||
f"{issuer}/login-actions/reset-credentials"
|
||||
f"?client_id={settings.keycloak_client_id}"
|
||||
f"&redirect_uri={base}/"
|
||||
)
|
||||
return RedirectResponse(url=target, status_code=302)
|
||||
|
||||
|
||||
@app.post("/api/auth/login")
|
||||
async def auth_direct_login(
|
||||
username: str = Form(...),
|
||||
@ -1015,7 +1110,37 @@ async def auth_register(
|
||||
if create_resp.status_code != 201:
|
||||
raise HTTPException(status_code=500, detail="Registrierung fehlgeschlagen")
|
||||
|
||||
return {"status": "pending_approval", "message": "Registrierung eingegangen. Nach Freischaltung erhalten Sie eine E-Mail zum Passwort setzen."}
|
||||
# #143: Bestätigungsmail an User direkt nach Anmeldung
|
||||
try:
|
||||
from .mail import send_mail
|
||||
anrede = f"{firstName} {lastName}".strip() or username
|
||||
text_body = (
|
||||
f"Hallo {anrede},\n\n"
|
||||
f"deine Registrierung am GWÖ-Antragsprüfer ist eingegangen.\n\n"
|
||||
f"Was passiert jetzt?\n"
|
||||
f" 1. Ein Admin schaltet deinen Account manuell frei.\n"
|
||||
f" 2. Du erhältst dann eine separate Mail mit einem Link zum Passwort-Setzen.\n"
|
||||
f" 3. Anschließend kannst du dich auf https://gwoe.toppyr.de/ anmelden.\n\n"
|
||||
f"Falls du nach 1-2 Werktagen nichts hörst, melde dich gerne unter mail@tobiasroedel.de.\n\n"
|
||||
f"Schöne Grüße\nGWÖ-Antragsprüfer"
|
||||
)
|
||||
html_body = (
|
||||
f"<p>Hallo <strong>{anrede}</strong>,</p>"
|
||||
f"<p>deine Registrierung am <a href=\"https://gwoe.toppyr.de/\">GWÖ-Antragsprüfer</a> ist eingegangen.</p>"
|
||||
f"<p><strong>Was passiert jetzt?</strong></p>"
|
||||
f"<ol>"
|
||||
f"<li>Ein Admin schaltet deinen Account manuell frei.</li>"
|
||||
f"<li>Du erhältst dann eine separate Mail mit einem Link zum Passwort-Setzen.</li>"
|
||||
f"<li>Anschließend kannst du dich auf <a href=\"https://gwoe.toppyr.de/\">gwoe.toppyr.de</a> anmelden.</li>"
|
||||
f"</ol>"
|
||||
f"<p>Falls du nach 1-2 Werktagen nichts hörst, melde dich gerne unter <a href=\"mailto:mail@tobiasroedel.de\">mail@tobiasroedel.de</a>.</p>"
|
||||
f"<p style=\"color:#666;font-size:0.9em\">Schöne Grüße<br>GWÖ-Antragsprüfer</p>"
|
||||
)
|
||||
await send_mail(email, "GWÖ-Antragsprüfer — Registrierung eingegangen", text_body, html_body)
|
||||
except Exception:
|
||||
logger.exception("Bestätigungsmail an %s fehlgeschlagen — User-Anlage war aber erfolgreich", email)
|
||||
|
||||
return {"status": "pending_approval", "message": "Registrierung eingegangen. Wir haben dir eine Bestätigung per E-Mail geschickt."}
|
||||
|
||||
|
||||
@app.get("/api/auth/pending-users")
|
||||
@ -1265,8 +1390,13 @@ async def search_landtag(
|
||||
|
||||
try:
|
||||
external = adapter._filter_abstimmbar(await adapter.search(q, limit))
|
||||
# Zusätzliche Title-Heuristik: bei Adaptern die Typ='Drucksache' liefern
|
||||
# (NRW), Kleine-Anfrage-Frage-Pattern erkennen und ausfiltern.
|
||||
from .drucksache_typen import likely_kleine_anfrage_titel, KLEINE_ANFRAGE
|
||||
results = []
|
||||
for doc in external:
|
||||
if doc.typ_normiert == "sonstige" and likely_kleine_anfrage_titel(doc.title):
|
||||
continue # höchstwahrscheinlich Kleine Anfrage
|
||||
results.append({
|
||||
"drucksache": doc.drucksache,
|
||||
"title": doc.title,
|
||||
@ -1588,28 +1718,31 @@ async def list_bundeslaender():
|
||||
# === Impressum / Datenschutz ===
|
||||
|
||||
@app.get("/impressum", response_class=HTMLResponse)
|
||||
async def impressum_page(request: Request):
|
||||
async def impressum_page(request: Request, current_user: Optional[dict] = Depends(get_current_user)):
|
||||
return templates.TemplateResponse("v2/screens/legal.html", {
|
||||
"request": request, "app_name": settings.app_name,
|
||||
"title": "Impressum", "section": "impressum",
|
||||
**_v2_template_context(current_user),
|
||||
})
|
||||
|
||||
|
||||
@app.get("/datenschutz", response_class=HTMLResponse)
|
||||
async def datenschutz_page(request: Request):
|
||||
async def datenschutz_page(request: Request, current_user: Optional[dict] = Depends(get_current_user)):
|
||||
return templates.TemplateResponse("v2/screens/legal.html", {
|
||||
"request": request, "app_name": settings.app_name,
|
||||
"title": "Datenschutzerklärung", "section": "datenschutz",
|
||||
**_v2_template_context(current_user),
|
||||
})
|
||||
|
||||
|
||||
# === Quellen / Programme ===
|
||||
|
||||
@app.get("/methodik", response_class=HTMLResponse)
|
||||
async def methodik_page(request: Request):
|
||||
async def methodik_page(request: Request, current_user: Optional[dict] = Depends(get_current_user)):
|
||||
"""Transparenz-/Methodik-Seite (#96)."""
|
||||
from .bundeslaender import aktive_bundeslaender, BUNDESLAENDER
|
||||
from .embeddings import get_indexing_status
|
||||
from .analyzer import get_system_prompt, get_user_prompt_template
|
||||
|
||||
bl_list = []
|
||||
for bl in aktive_bundeslaender():
|
||||
@ -1630,11 +1763,14 @@ async def methodik_page(request: Request):
|
||||
"programme_count": status.get("total", 0),
|
||||
"chunk_count": sum(p.get("chunks", 0) for p in status.get("programmes", [])),
|
||||
"bundeslaender": sorted(bl_list, key=lambda x: x["name"]),
|
||||
"system_prompt": get_system_prompt(),
|
||||
"user_prompt_template": get_user_prompt_template(),
|
||||
**_v2_template_context(current_user),
|
||||
})
|
||||
|
||||
|
||||
@app.get("/quellen", response_class=HTMLResponse)
|
||||
async def quellen_page(request: Request):
|
||||
async def quellen_page(request: Request, current_user: Optional[dict] = Depends(get_current_user)):
|
||||
"""Quellen-Seite mit allen Wahl- und Parteiprogrammen, nach BL gruppiert."""
|
||||
from .bundeslaender import BUNDESLAENDER
|
||||
programmes = get_programme_info()
|
||||
@ -1661,6 +1797,7 @@ async def quellen_page(request: Request):
|
||||
"wahlprogramme_grouped": wahlprogramme_grouped,
|
||||
"grundsatzprogramme": grundsatz,
|
||||
"status": status,
|
||||
**_v2_template_context(current_user),
|
||||
})
|
||||
|
||||
|
||||
@ -1850,8 +1987,8 @@ async def index_programme(
|
||||
|
||||
|
||||
@app.get("/auswertungen", response_class=HTMLResponse)
|
||||
async def auswertungen_page(request: Request):
|
||||
"""Auswertungs-Dashboard in v2 (Phase 3 Migration aus Classic)."""
|
||||
async def auswertungen_page(request: Request, current_user: dict = Depends(require_auth)):
|
||||
"""Auswertungs-Dashboard in v2 (Phase 3 Migration aus Classic). Auth-only."""
|
||||
from .auswertungen import get_wahlperioden
|
||||
from .bundeslaender import alle_bundeslaender
|
||||
|
||||
@ -1864,6 +2001,7 @@ async def auswertungen_page(request: Request):
|
||||
"v2_active_nav": "auswertungen",
|
||||
"wahlperioden": wahlperioden,
|
||||
"bl_codes": bl_codes,
|
||||
**_v2_template_context(current_user),
|
||||
})
|
||||
|
||||
|
||||
@ -2171,26 +2309,55 @@ async def v2_antrag_redirect(request: Request, drucksache: str):
|
||||
|
||||
|
||||
@app.get("/v2/merkliste", response_class=HTMLResponse)
|
||||
async def v2_merkliste(request: Request):
|
||||
"""Merkliste (Bookmarks) — lädt Daten via /api/bookmarks client-seitig."""
|
||||
async def v2_merkliste(request: Request, current_user: dict = Depends(require_auth)):
|
||||
"""Merkliste (Bookmarks) — nur für eingeloggte User; lädt Daten via /api/bookmarks client-seitig."""
|
||||
return templates.TemplateResponse("v2/screens/merkliste.html", {
|
||||
"request": request,
|
||||
"v2_active_nav": "merkliste",
|
||||
**_v2_template_context(current_user),
|
||||
})
|
||||
|
||||
|
||||
@app.get("/v2/tags", response_class=HTMLResponse)
|
||||
async def v2_tags(request: Request):
|
||||
async def v2_tags(request: Request, current_user: Optional[dict] = Depends(get_current_user)):
|
||||
"""Tag-Cloud-Seite — Themen-Filter über alle Assessments."""
|
||||
return templates.TemplateResponse("v2/screens/tags.html", {
|
||||
"request": request,
|
||||
"v2_active_nav": "tags",
|
||||
**_v2_template_context(current_user),
|
||||
})
|
||||
|
||||
|
||||
@app.get("/v2/abos", response_class=HTMLResponse)
|
||||
async def v2_abos(request: Request, current_user: dict = Depends(require_auth)):
|
||||
"""Eigene E-Mail-Abos verwalten — auth-only."""
|
||||
from .parteien import all_canonical_keys
|
||||
# Landesregierung als Filter unsinnig — ausblenden
|
||||
parteien = [p for p in all_canonical_keys() if p != "Landesregierung"]
|
||||
return templates.TemplateResponse("v2/screens/abos.html", {
|
||||
"request": request,
|
||||
"v2_active_nav": "abos",
|
||||
"parteien": parteien,
|
||||
**_v2_template_context(current_user),
|
||||
})
|
||||
|
||||
|
||||
@app.get("/v2/feed", response_class=HTMLResponse)
|
||||
async def v2_feed(request: Request, current_user: dict = Depends(require_auth)):
|
||||
"""Atom-Feed-Konfigurations-Seite — auth-only."""
|
||||
from .parteien import all_canonical_keys
|
||||
parteien = [p for p in all_canonical_keys() if p != "Landesregierung"]
|
||||
return templates.TemplateResponse("v2/screens/feed.html", {
|
||||
"request": request,
|
||||
"v2_active_nav": "feed",
|
||||
"parteien": parteien,
|
||||
**_v2_template_context(current_user),
|
||||
})
|
||||
|
||||
|
||||
@app.get("/v2/cluster", response_class=HTMLResponse)
|
||||
async def v2_cluster(request: Request):
|
||||
"""Cluster-Liste — Top-10 Cluster als redaktionelle Liste."""
|
||||
async def v2_cluster(request: Request, current_user: dict = Depends(require_admin)):
|
||||
"""Cluster-Liste — nur für Admins."""
|
||||
rows = await get_all_assessments(None)
|
||||
assessments = _rows_to_list(rows)
|
||||
bl_codes = sorted({a["bundesland"] for a in assessments if a.get("bundesland")})
|
||||
@ -2198,12 +2365,13 @@ async def v2_cluster(request: Request):
|
||||
"request": request,
|
||||
"v2_active_nav": "cluster",
|
||||
"bl_codes": bl_codes,
|
||||
**_v2_template_context(current_user),
|
||||
})
|
||||
|
||||
|
||||
@app.get("/v2/neu", response_class=HTMLResponse)
|
||||
async def v2_neu(request: Request):
|
||||
"""Neuer-Antrag-Form — startet Analyse via /api/analyze-drucksache."""
|
||||
async def v2_neu(request: Request, current_user: dict = Depends(require_auth)):
|
||||
"""Neuer-Antrag-Form — nur für eingeloggte User; startet Analyse via /api/analyze-drucksache."""
|
||||
from .bundeslaender import alle_bundeslaender
|
||||
bl_list = [
|
||||
{"code": bl.code, "name": bl.name}
|
||||
@ -2215,12 +2383,13 @@ async def v2_neu(request: Request):
|
||||
"v2_active_nav": "neu",
|
||||
"bundeslaender": sorted(bl_list, key=lambda x: x["name"]),
|
||||
"default_model": settings.llm_model_default,
|
||||
**_v2_template_context(current_user),
|
||||
})
|
||||
|
||||
|
||||
@app.get("/v2/landtag-suche", response_class=HTMLResponse)
|
||||
async def v2_landtag_suche(request: Request):
|
||||
"""Landtag-Suche — sucht Drucksachen live im Landtags-Portal (nicht nur DB)."""
|
||||
async def v2_landtag_suche(request: Request, current_user: dict = Depends(require_auth)):
|
||||
"""Landtag-Suche — nur für eingeloggte User; sucht Drucksachen live im Landtags-Portal."""
|
||||
from .bundeslaender import alle_bundeslaender
|
||||
bl_list = [
|
||||
{"code": bl.code, "name": bl.name}
|
||||
@ -2231,12 +2400,13 @@ async def v2_landtag_suche(request: Request):
|
||||
"request": request,
|
||||
"v2_active_nav": "landtag_suche",
|
||||
"bundeslaender": sorted(bl_list, key=lambda x: x["name"]),
|
||||
**_v2_template_context(current_user),
|
||||
})
|
||||
|
||||
|
||||
@app.get("/v2/batch", response_class=HTMLResponse)
|
||||
async def v2_batch(request: Request):
|
||||
"""Batch-Analyse-Form (Admin) — enqueued ungeprüfte Drucksachen eines BL."""
|
||||
async def v2_batch(request: Request, current_user: dict = Depends(require_admin)):
|
||||
"""Batch-Analyse-Form — nur für Admins; enqueued ungeprüfte Drucksachen eines BL."""
|
||||
from .bundeslaender import alle_bundeslaender
|
||||
bl_list = [
|
||||
{"code": bl.code, "name": bl.name}
|
||||
@ -2247,6 +2417,7 @@ async def v2_batch(request: Request):
|
||||
"request": request,
|
||||
"v2_active_nav": "batch",
|
||||
"bundeslaender": sorted(bl_list, key=lambda x: x["name"]),
|
||||
**_v2_template_context(current_user),
|
||||
})
|
||||
|
||||
|
||||
@ -2258,7 +2429,7 @@ async def v2_admin_freischaltungen(request: Request, user: dict = Depends(requir
|
||||
return templates.TemplateResponse("v2/screens/admin_freischaltungen.html", {
|
||||
"request": request,
|
||||
"v2_active_nav": "admin_freischaltungen",
|
||||
"is_admin": True,
|
||||
**_v2_template_context(user),
|
||||
})
|
||||
|
||||
|
||||
@ -2268,7 +2439,7 @@ async def v2_admin_queue(request: Request, user: dict = Depends(require_admin)):
|
||||
return templates.TemplateResponse("v2/screens/admin_queue.html", {
|
||||
"request": request,
|
||||
"v2_active_nav": "admin_queue",
|
||||
"is_admin": True,
|
||||
**_v2_template_context(user),
|
||||
})
|
||||
|
||||
|
||||
@ -2278,7 +2449,7 @@ async def v2_admin_abos(request: Request, user: dict = Depends(require_admin)):
|
||||
return templates.TemplateResponse("v2/screens/admin_abos.html", {
|
||||
"request": request,
|
||||
"v2_active_nav": "admin_abos",
|
||||
"is_admin": True,
|
||||
**_v2_template_context(user),
|
||||
})
|
||||
|
||||
|
||||
@ -2413,6 +2584,219 @@ async def api_admin_wahlprogramm_fetch(
|
||||
})
|
||||
|
||||
|
||||
# ─── Feedback / Bug-Report — Gitea-Issue-Anbindung ───────────────────────────
|
||||
|
||||
def _strip_html(text: str, max_len: int) -> str:
|
||||
"""Minimale HTML-Tag-Entfernung + Längenbegrenzung für Nutzerinput."""
|
||||
import re
|
||||
cleaned = re.sub(r'<[^>]+>', '', text)
|
||||
return cleaned[:max_len]
|
||||
|
||||
|
||||
async def _gitea_ensure_label(session, base_url: str, owner: str, repo: str,
|
||||
token: str, label_name: str, color: str = "#e11d48") -> int | None:
|
||||
"""Gibt die ID des Labels zurück; legt es idempotent an, falls es fehlt."""
|
||||
headers = {"Authorization": f"token {token}", "Content-Type": "application/json"}
|
||||
url = f"{base_url}/repos/{owner}/{repo}/labels"
|
||||
try:
|
||||
r = await session.get(url, headers=headers)
|
||||
if r.status_code == 200:
|
||||
for lbl in r.json():
|
||||
if lbl.get("name") == label_name:
|
||||
return lbl["id"]
|
||||
# Label fehlt → anlegen
|
||||
r2 = await session.post(url, headers=headers,
|
||||
json={"name": label_name, "color": color})
|
||||
if r2.status_code in (200, 201):
|
||||
return r2.json().get("id")
|
||||
except Exception as exc:
|
||||
logger.exception("Gitea-Label-Lookup fehlgeschlagen: %s", exc)
|
||||
return None
|
||||
|
||||
|
||||
async def _gitea_upload_screenshot(session, base_url: str, owner: str, repo: str,
|
||||
token: str, issue_index: int,
|
||||
data_uri: str) -> str | None:
|
||||
"""Lädt einen Screenshot als Issue-Asset hoch. Gibt Attachment-URL zurück oder None."""
|
||||
import base64, re as _re
|
||||
m = _re.match(r'data:(image/[a-z]+);base64,(.+)', data_uri, _re.DOTALL)
|
||||
if not m:
|
||||
return None
|
||||
mime, b64data = m.group(1), m.group(2)
|
||||
try:
|
||||
raw = base64.b64decode(b64data)
|
||||
except Exception:
|
||||
return None
|
||||
ext = mime.split('/')[-1]
|
||||
upload_url = f"{base_url}/repos/{owner}/{repo}/issues/{issue_index}/assets"
|
||||
headers = {"Authorization": f"token {token}"}
|
||||
files = {"attachment": (f"screenshot.{ext}", raw, mime)}
|
||||
try:
|
||||
r = await session.post(upload_url, headers=headers, files=files)
|
||||
if r.status_code in (200, 201):
|
||||
return r.json().get("browser_download_url") or r.json().get("download_url")
|
||||
except Exception as exc:
|
||||
logger.exception("Screenshot-Upload fehlgeschlagen: %s", exc)
|
||||
return None
|
||||
|
||||
|
||||
@app.post("/api/feedback")
|
||||
@limiter.limit("5/hour")
|
||||
async def submit_feedback(
|
||||
request: Request,
|
||||
titel: str = Form(...),
|
||||
beschreibung: str = Form(...),
|
||||
url: str = Form(""),
|
||||
user_agent: str = Form(""),
|
||||
viewport: str = Form(""),
|
||||
drucksache: str = Form(""),
|
||||
klicks_json: str = Form("[]"),
|
||||
errors_json: str = Form("[]"),
|
||||
screenshot: Optional[str] = Form(None),
|
||||
screenshot_error: Optional[str] = Form(None),
|
||||
current_user: Optional[dict] = Depends(get_current_user),
|
||||
):
|
||||
"""Erstellt ein Gitea-Issue mit Label 'feedback'.
|
||||
|
||||
Audit-Trail (Klicks, Errors, URL etc.) wird im Issue-Body als
|
||||
Markdown-Code-Block angefügt. Screenshot wird als Issue-Asset
|
||||
hochgeladen, falls vorhanden.
|
||||
"""
|
||||
import json as _json
|
||||
import httpx
|
||||
|
||||
# Validierung
|
||||
titel_clean = _strip_html(titel, 200).strip()
|
||||
beschreibung_clean = _strip_html(beschreibung, 5000).strip()
|
||||
if not titel_clean:
|
||||
raise HTTPException(status_code=400, detail="Titel darf nicht leer sein")
|
||||
if not beschreibung_clean:
|
||||
raise HTTPException(status_code=400, detail="Beschreibung darf nicht leer sein")
|
||||
|
||||
# Audit-Trail parsen
|
||||
try:
|
||||
klicks = _json.loads(klicks_json)[:15]
|
||||
except Exception:
|
||||
klicks = []
|
||||
try:
|
||||
errors = _json.loads(errors_json)[:10]
|
||||
except Exception:
|
||||
errors = []
|
||||
|
||||
# User-Identität (wenn eingeloggt)
|
||||
user_email = ""
|
||||
user_name = ""
|
||||
if current_user:
|
||||
user_email = current_user.get("email", "")
|
||||
user_name = current_user.get("preferred_username", current_user.get("name", ""))
|
||||
|
||||
# Issue-Body zusammenbauen
|
||||
body_parts = [beschreibung_clean, ""]
|
||||
|
||||
body_parts.append("## Kontext")
|
||||
body_parts.append(f"- **URL:** `{url[:300]}`")
|
||||
if drucksache:
|
||||
body_parts.append(f"- **Drucksache:** `{drucksache[:100]}`")
|
||||
body_parts.append(f"- **Viewport:** {viewport}")
|
||||
body_parts.append(f"- **User-Agent:** `{user_agent[:200]}`")
|
||||
if user_name:
|
||||
body_parts.append(f"- **Gemeldet von:** {user_name} ({user_email})")
|
||||
else:
|
||||
body_parts.append("- **Gemeldet von:** anonym")
|
||||
body_parts.append("")
|
||||
|
||||
if klicks:
|
||||
body_parts.append("## Letzte Klicks (Audit-Trail)")
|
||||
body_parts.append("```")
|
||||
for c in klicks:
|
||||
txt_part = f' "{c["txt"]}"' if c.get("txt") else ""
|
||||
body_parts.append(f'{c.get("t","")[-8:]} {c.get("el","")}{txt_part}')
|
||||
body_parts.append("```")
|
||||
body_parts.append("")
|
||||
|
||||
if errors:
|
||||
body_parts.append("## Console-Errors")
|
||||
body_parts.append("```")
|
||||
for err in errors:
|
||||
body_parts.append(f'{err.get("t","")[-8:]} {err.get("msg","")} @ {err.get("src","")}')
|
||||
body_parts.append("```")
|
||||
body_parts.append("")
|
||||
|
||||
if screenshot_error:
|
||||
body_parts.append(f"_Screenshot angefordert, aber fehlgeschlagen: `{screenshot_error[:200]}`_")
|
||||
body_parts.append("")
|
||||
|
||||
issue_body = "\n".join(body_parts)
|
||||
|
||||
if not settings.gitea_token:
|
||||
logger.warning("GITEA_TOKEN nicht gesetzt — Feedback-Issue kann nicht angelegt werden")
|
||||
raise HTTPException(
|
||||
status_code=503,
|
||||
detail="Feedback-Funktion ist derzeit nicht konfiguriert (kein Gitea-Token)."
|
||||
)
|
||||
|
||||
base_url = settings.gitea_api_url
|
||||
owner = settings.gitea_repo_owner
|
||||
repo = settings.gitea_repo_name
|
||||
token = settings.gitea_token
|
||||
headers = {"Authorization": f"token {token}", "Content-Type": "application/json"}
|
||||
|
||||
async with httpx.AsyncClient(timeout=15.0) as session:
|
||||
# Konfigurierbare Label-Liste (Default "feedback"; Dev setzt "feedback,dev")
|
||||
label_names = [s.strip() for s in (settings.gitea_feedback_labels or "feedback").split(",") if s.strip()]
|
||||
label_color_map = {"feedback": "#e11d48", "dev": "#f59e0b"}
|
||||
label_ids: list[int] = []
|
||||
for name in label_names:
|
||||
color = label_color_map.get(name, "#6b7280")
|
||||
lid = await _gitea_ensure_label(session, base_url, owner, repo, token, name, color)
|
||||
if lid:
|
||||
label_ids.append(lid)
|
||||
|
||||
# Issue anlegen
|
||||
payload = {
|
||||
"title": titel_clean,
|
||||
"body": issue_body,
|
||||
"label_ids": label_ids,
|
||||
}
|
||||
try:
|
||||
r = await session.post(
|
||||
f"{base_url}/repos/{owner}/{repo}/issues",
|
||||
headers=headers,
|
||||
json=payload,
|
||||
)
|
||||
except httpx.RequestError as exc:
|
||||
logger.exception("Gitea-Request fehlgeschlagen: %s", exc)
|
||||
raise HTTPException(status_code=502, detail="Gitea nicht erreichbar")
|
||||
|
||||
if r.status_code not in (200, 201):
|
||||
logger.error("Gitea-Issue-Anlage fehlgeschlagen: %s %s", r.status_code, r.text[:500])
|
||||
raise HTTPException(status_code=502, detail=f"Gitea: {r.status_code}")
|
||||
|
||||
issue = r.json()
|
||||
issue_index = issue.get("number") or issue.get("id")
|
||||
issue_url = issue.get("html_url", "")
|
||||
|
||||
# Screenshot hochladen (optional)
|
||||
if screenshot and issue_index:
|
||||
att_url = await _gitea_upload_screenshot(
|
||||
session, base_url, owner, repo, token, issue_index, screenshot
|
||||
)
|
||||
if att_url:
|
||||
# Screenshot-Link als Kommentar anhängen
|
||||
comment_headers = {"Authorization": f"token {token}", "Content-Type": "application/json"}
|
||||
try:
|
||||
await session.post(
|
||||
f"{base_url}/repos/{owner}/{repo}/issues/{issue_index}/comments",
|
||||
headers=comment_headers,
|
||||
json={"body": f"**Screenshot:**\n\n"},
|
||||
)
|
||||
except Exception as exc:
|
||||
logger.exception("Screenshot-Kommentar fehlgeschlagen: %s", exc)
|
||||
|
||||
logger.info("Feedback-Issue #%s angelegt: %s", issue_index, issue_url)
|
||||
return JSONResponse({"issue_id": issue_index, "issue_url": issue_url})
|
||||
|
||||
|
||||
# Health check
|
||||
@app.get("/health")
|
||||
async def health():
|
||||
|
||||
@ -3206,7 +3206,10 @@ class SaarlandAdapter(ParlamentAdapter):
|
||||
)
|
||||
if resp.status_code != 200:
|
||||
logger.error("SL HTTP %s: %s", resp.status_code, resp.text[:200])
|
||||
return []
|
||||
raise httpx.HTTPStatusError(
|
||||
f"SL HTTP {resp.status_code}",
|
||||
request=resp.request, response=resp,
|
||||
)
|
||||
data = resp.json()
|
||||
return data.get("FilteredResult", []) or []
|
||||
except Exception:
|
||||
|
||||
69
app/protokoll_parsers/__init__.py
Normal file
69
app/protokoll_parsers/__init__.py
Normal file
@ -0,0 +1,69 @@
|
||||
"""BL-uebergreifende Plenarprotokoll-Abstimmungsparser (#126).
|
||||
|
||||
Architektur (vgl. ADR 0009): pro Bundesland eine Modul-Datei
|
||||
``app/protokoll_parsers/<bl-code>.py``, die mindestens eine Funktion
|
||||
``parse_protocol(pdf_path: str) -> list[dict]`` exportiert. Die Registry
|
||||
``PROTOKOLL_PARSERS`` mappt BL-Code → Parser-Funktion.
|
||||
|
||||
Erwartetes Result-Schema pro Eintrag in der Liste::
|
||||
|
||||
{
|
||||
"drucksache": str | None, # z.B. "18/1234"; None bei nicht aufloesbar
|
||||
"ergebnis": str, # angenommen | abgelehnt | ueberwiesen | ...
|
||||
"einstimmig": bool, # explizit als einstimmig markiert
|
||||
"kind": str, # parser-intern, fuer Debug
|
||||
"votes": { # fraktions-Listen pro Vote-Kategorie
|
||||
"ja": list[str],
|
||||
"nein": list[str],
|
||||
"enthaltung": list[str],
|
||||
},
|
||||
}
|
||||
|
||||
NRW ist die Referenz-Implementierung. Folge-BL (HE/BB/MV/BE/...) bekommen
|
||||
eigene Module mit demselben Funktions-Vertrag — neue Eintraege in der
|
||||
Registry sind reine Tippelarbeit, das Reverse-Engineering pro Landtag
|
||||
ist die eigentliche Arbeit.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Callable
|
||||
|
||||
from .nrw import parse_protocol as _parse_nrw
|
||||
|
||||
# Typ-Alias fuer Lesbarkeit; Parser-Signatur ist bewusst minimal.
|
||||
ProtokollParser = Callable[[str], list[dict]]
|
||||
|
||||
PROTOKOLL_PARSERS: dict[str, ProtokollParser] = {
|
||||
"NRW": _parse_nrw,
|
||||
}
|
||||
|
||||
|
||||
def parse_protocol(bundesland: str, pdf_path: str) -> list[dict]:
|
||||
"""BL-uebergreifender Einstieg. Sucht den Parser in der Registry.
|
||||
|
||||
Raises:
|
||||
NotImplementedError: wenn fuer das Bundesland (noch) kein Parser
|
||||
registriert ist. Folge-Issue: BL-Adapter ergaenzen mit einem
|
||||
eigenen Modul plus Eintrag hier.
|
||||
"""
|
||||
parser = PROTOKOLL_PARSERS.get(bundesland)
|
||||
if parser is None:
|
||||
supported = ", ".join(sorted(PROTOKOLL_PARSERS)) or "(keine)"
|
||||
raise NotImplementedError(
|
||||
f"Kein Plenarprotokoll-Parser fuer {bundesland!r}. "
|
||||
f"Unterstuetzt: {supported}. Siehe #126."
|
||||
)
|
||||
return parser(pdf_path)
|
||||
|
||||
|
||||
def supported_bundeslaender() -> list[str]:
|
||||
"""Liste der BL-Codes mit registrierten Parsern."""
|
||||
return sorted(PROTOKOLL_PARSERS)
|
||||
|
||||
|
||||
__all__ = [
|
||||
"ProtokollParser",
|
||||
"PROTOKOLL_PARSERS",
|
||||
"parse_protocol",
|
||||
"supported_bundeslaender",
|
||||
]
|
||||
348
app/protokoll_parsers/nrw.py
Normal file
348
app/protokoll_parsers/nrw.py
Normal file
@ -0,0 +1,348 @@
|
||||
"""NRW-Plenarprotokoll Abstimmungs-Parser v5 (deterministisch, anchor-basiert).
|
||||
|
||||
Neue Architektur: Statt pro Drucksache zu suchen, findet der Parser zuerst
|
||||
alle **Result-Anchors** im Volltext ("Damit ist ... angenommen/abgelehnt/...")
|
||||
und extrahiert pro Anchor rückwärts:
|
||||
1. die zugehörige Drucksache (nächste 18/XXXXX davor, innerhalb ~500 chars)
|
||||
2. den Vote-Block (letztes "Wer stimmt ... zu?" vor dem Anchor)
|
||||
|
||||
Fixture-basierte Tests. Ziel: 18/19 (17824 ist bewusst nicht_gesondert).
|
||||
|
||||
Migriert nach app/ aus dem POC-Skript parser_v5_iteration15.py
|
||||
(2026-04-28, #134/#106). Fitz-Import ist optional — pure-string-Funktionen
|
||||
laufen ohne, parse_protocol() braucht das echte fitz.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
import json
|
||||
import sys
|
||||
|
||||
try: # fitz ist optional — pure-string-Funktionen laufen ohne
|
||||
import fitz
|
||||
except ImportError:
|
||||
fitz = None
|
||||
|
||||
FRAKTIONEN_MAP = [
|
||||
("Bündnis 90/Die Grünen", "GRÜNE"),
|
||||
("Bündnis 90", "GRÜNE"),
|
||||
("Grünen", "GRÜNE"),
|
||||
("GRÜNE", "GRÜNE"),
|
||||
("F.D.P.", "FDP"),
|
||||
("FDP", "FDP"),
|
||||
("CDU", "CDU"),
|
||||
("SPD", "SPD"),
|
||||
("AfD", "AfD"),
|
||||
("LINKE", "LINKE"),
|
||||
("BSW", "BSW"),
|
||||
("Landesregierung", "Landesregierung"),
|
||||
]
|
||||
|
||||
ALLE_FRAKTIONEN_NRW = ["CDU", "SPD", "GRÜNE", "FDP", "AfD"]
|
||||
|
||||
|
||||
def normalize_fraktionen(txt):
|
||||
"""Extrahiere Fraktions-Tokens aus einem Text-Abschnitt."""
|
||||
found = set()
|
||||
# Reihenfolge: längere zuerst (damit "Bündnis 90/Die Grünen" vor "Grünen" matcht)
|
||||
remaining = txt
|
||||
for key, val in FRAKTIONEN_MAP:
|
||||
if key in remaining:
|
||||
found.add(val)
|
||||
remaining = remaining.replace(key, "") # Doppel-Match vermeiden
|
||||
return sorted(found)
|
||||
|
||||
|
||||
def _is_empty_phrase(txt):
|
||||
"""Prüft ob der Text eine Negation ausdrückt (niemand, nicht, keine)."""
|
||||
neg = ["niemand", "Niemand", "Keine", "keine", "nicht der Fall",
|
||||
"Auch nicht", "ist nicht", "ist auch nicht", "nicht vor"]
|
||||
return any(n in txt for n in neg)
|
||||
|
||||
|
||||
def _parse_vote_block(block: str) -> dict:
|
||||
"""Extrahiere ja/nein/enthaltung aus dem Text-Block vor einem Result-Anchor.
|
||||
|
||||
Vereinfachter Ansatz: matche bis zum nächsten '?' oder 200 chars.
|
||||
"""
|
||||
votes = {"ja": [], "nein": [], "enthaltung": []}
|
||||
|
||||
# JA — letztes Match gewinnt (bei Re-Votes)
|
||||
ja_matches = list(re.finditer(
|
||||
r"Wer stimmt(?! dagegen)[^?]{0,80}zu\?\s*[–-]?\s*([^?]{1,250})",
|
||||
block
|
||||
))
|
||||
if ja_matches:
|
||||
g = ja_matches[-1].group(1)
|
||||
if not _is_empty_phrase(g):
|
||||
votes["ja"] = normalize_fraktionen(g)
|
||||
|
||||
# NEIN
|
||||
nein_patterns = [
|
||||
r"Wer stimmt dagegen\?\s*[–-]?\s*([^?]{1,200})",
|
||||
r"Wer lehnt[^?]{0,30}ab\?\s*[–-]?\s*([^?]{1,200})",
|
||||
r"Stimmt jemand dagegen\?\s*[–-]?\s*([^?]{1,120})",
|
||||
r"Ist jemand dagegen\?\s*[–-]?\s*([^?]{1,120})",
|
||||
]
|
||||
for pat in nein_patterns:
|
||||
matches = list(re.finditer(pat, block))
|
||||
if matches:
|
||||
g = matches[-1].group(1)
|
||||
votes["nein"] = [] if _is_empty_phrase(g) else normalize_fraktionen(g)
|
||||
break
|
||||
|
||||
# ENTHALTUNG
|
||||
enth_patterns = [
|
||||
r"Wer enthält sich\?\s*[–-]?\s*([^?]{1,200})",
|
||||
r"Gibt es Enthaltungen\?\s*[–-]?\s*([^?]{1,200})",
|
||||
r"Enthält sich jemand\?\s*[–-]?\s*([^?]{1,120})",
|
||||
r"Möchte sich jemand enthalten\?\s*[–-]?\s*([^?]{1,120})",
|
||||
]
|
||||
for pat in enth_patterns:
|
||||
matches = list(re.finditer(pat, block))
|
||||
if matches:
|
||||
g = matches[-1].group(1)
|
||||
votes["enthaltung"] = [] if _is_empty_phrase(g) else normalize_fraktionen(g)
|
||||
break
|
||||
|
||||
# Implizite leere Enthaltungen: "Enthaltungen gibt es damit nicht"
|
||||
if not votes["enthaltung"] and re.search(r"Enthaltungen\s+gibt\s+es\s+damit\s+nicht", block):
|
||||
votes["enthaltung"] = []
|
||||
|
||||
return votes
|
||||
|
||||
|
||||
# Result-Anchors: Pattern → (ergebnis, is_ueberweisung)
|
||||
# v6: Broad-Anchor-Matches für alle direkten Varianten.
|
||||
# Type 'direct_broad': matcht "Damit/Somit ist der/dieser/die Antrag/Gesetzentwurf/...
|
||||
# ... angenommen/abgelehnt/überwiesen/verabschiedet" — Drucksache wird
|
||||
# separat aus dem Match-Span extrahiert (oder aus dem vorangehenden Segment).
|
||||
RESULT_ANCHORS = [
|
||||
# Broad direct-result pattern (deckt fast alle Varianten ab).
|
||||
# "beschlossen" = bei direkter Abstimmung eines Antrags = angenommen
|
||||
(r"(?:Damit|Somit) ist (?:der|dieser|die|diese) (?:Antrag|Gesetzentwurf|Änderungsantrag|Wahlvorschlag|Entschließungsantrag|Beschlussempfehlung)[^.]{0,200}?(angenommen|abgelehnt|überwiesen|zurückgezogen|verabschiedet|beschlossen)", "direct_broad"),
|
||||
# Variante ohne führendes "Damit/Somit ist": "Dieser Antrag Drucksache X ist somit ... abgelehnt"
|
||||
(r"Dieser (?:Antrag|Gesetzentwurf|Änderungsantrag|Wahlvorschlag)[^.]{0,200}?(angenommen|abgelehnt|überwiesen|zurückgezogen|verabschiedet|beschlossen)", "direct_broad"),
|
||||
# Überweisungs-Anchor (Drucksache muss rückwärts gesucht werden)
|
||||
(r"(?:Damit|Somit) ist (?:diese|die)\s+Überweisungsempfehlung\s+(einstimmig\s+|ebenso\s+)?(angenommen)", "ueber"),
|
||||
(r"Somit ist das so beschlossen()()", "ueber"),
|
||||
(r"Damit ist das so beschlossen()()", "ueber"),
|
||||
# "Damit schließt sich der Landtag der Empfehlung des Rechtsausschusses an" — Empfehlung-Beitritt
|
||||
(r"Damit schließt sich der Landtag der Empfehlung[^.]{0,100}?an()()", "ueber"),
|
||||
# Petitionsausschuss-Sammel-Abstimmung
|
||||
(r"Damit sind die Beschlüsse des Petitionsausschusses[^.]{0,100}?bestätigt()()", "petition"),
|
||||
# Übersicht-Bestätigung (§ 82 Abs. 2 GO)
|
||||
(r"Damit sind die in Drucksache (\d+/\d+(?:\(neu\))?) enthaltenen[^.]{0,150}?bestätigt()", "uebersicht"),
|
||||
]
|
||||
|
||||
|
||||
def find_results(text: str) -> list[dict]:
|
||||
"""Finde alle Result-Anchors im Text.
|
||||
|
||||
Returns: Liste von {drucksache, ergebnis, anchor_start, anchor_end, kind, einstimmig}.
|
||||
"""
|
||||
results = []
|
||||
for pat, kind in RESULT_ANCHORS:
|
||||
for m in re.finditer(pat, text):
|
||||
groups = m.groups()
|
||||
ds = None
|
||||
einstimmig = False
|
||||
span_text = text[m.start():m.end()]
|
||||
|
||||
# Für "direct" kind: erste DS-artige Group ist die Drucksache
|
||||
if kind == "direct":
|
||||
for g in groups:
|
||||
if g and re.match(r"^\d+/\d+(?:\(neu\))?$", g):
|
||||
ds = g
|
||||
break
|
||||
# Für "direct_broad": Drucksache innerhalb des Match-Spans suchen
|
||||
elif kind == "direct_broad":
|
||||
ds_match = re.search(r"Drucksache\s+(\d+/\d+(?:\(neu\))?)", span_text)
|
||||
if ds_match:
|
||||
ds = ds_match.group(1)
|
||||
# Ergebnis: suche bekanntes Wort in allen Groups
|
||||
ergebnis = None
|
||||
for g in groups:
|
||||
if g and g.strip() == "einstimmig":
|
||||
einstimmig = True
|
||||
if g and g.strip() in ("angenommen", "abgelehnt", "überwiesen", "zurückgezogen", "verabschiedet", "beschlossen"):
|
||||
ergebnis = g.strip()
|
||||
# "verabschiedet" = angenommen und verabschiedet (Gesetzentwurf)
|
||||
# "beschlossen" (bei direkter Abstimmung) = angenommen
|
||||
if ergebnis in ("verabschiedet", "beschlossen"):
|
||||
ergebnis = "angenommen"
|
||||
if kind == "ueber":
|
||||
ergebnis = "überwiesen"
|
||||
if "einstimmig" in text[m.start():m.end() + 5]:
|
||||
einstimmig = True
|
||||
# "Damit ist das so beschlossen" / "Somit ist das so beschlossen" = implizit einstimmig
|
||||
if "so beschlossen" in text[m.start():m.end() + 5]:
|
||||
einstimmig = True
|
||||
if kind == "petition":
|
||||
ergebnis = "sammel"
|
||||
einstimmig = True
|
||||
if kind == "uebersicht":
|
||||
ergebnis = "bestätigt"
|
||||
einstimmig = True
|
||||
# Drucksache ist in Group[0] des Patterns
|
||||
for g in groups:
|
||||
if g and re.match(r"^\d+/\d+(?:\(neu\))?$", g):
|
||||
ds = g
|
||||
break
|
||||
if not ergebnis:
|
||||
continue
|
||||
results.append({
|
||||
"drucksache": ds,
|
||||
"ergebnis": ergebnis,
|
||||
"kind": kind,
|
||||
"einstimmig": einstimmig,
|
||||
"anchor_start": m.start(),
|
||||
"anchor_end": m.end(),
|
||||
})
|
||||
results.sort(key=lambda r: r["anchor_start"])
|
||||
dedup = []
|
||||
seen_positions = set()
|
||||
for r in results:
|
||||
if r["anchor_start"] in seen_positions:
|
||||
continue
|
||||
seen_positions.add(r["anchor_start"])
|
||||
dedup.append(r)
|
||||
return dedup
|
||||
|
||||
|
||||
def resolve_drucksache_for_ueber(text: str, anchor_start: int) -> str | None:
|
||||
"""Für Überweisungs-Anchors: rückwärts die nächste Drucksache-Nr suchen."""
|
||||
# Schaue bis 2000 chars zurück
|
||||
window_start = max(0, anchor_start - 2000)
|
||||
window = text[window_start:anchor_start]
|
||||
# Letzte Drucksache vor dem Anchor
|
||||
matches = list(re.finditer(r"Drucksache\s+(\d+/\d+(?:\(neu\))?)", window))
|
||||
if not matches:
|
||||
return None
|
||||
return matches[-1].group(1)
|
||||
|
||||
|
||||
def normalize_text(text: str) -> str:
|
||||
"""Normalisiere PDF-Text: Worttrennungen (-\n) auflösen, Zeilenumbrüche zu Spaces."""
|
||||
# Worttrennung am Zeilenende: "Überweisungs-\nempfehlung" → "Überweisungsempfehlung"
|
||||
text = re.sub(r"-\s*\n\s*", "", text)
|
||||
# Alle restlichen Zeilenumbrüche zu Spaces
|
||||
text = re.sub(r"\s+", " ", text)
|
||||
return text
|
||||
|
||||
|
||||
def parse_protocol(pdf_path: str) -> list[dict]:
|
||||
doc = fitz.open(pdf_path)
|
||||
full = "".join(page.get_text() for page in doc)
|
||||
doc.close()
|
||||
full = normalize_text(full)
|
||||
|
||||
anchors = find_results(full)
|
||||
parsed = []
|
||||
|
||||
# Segment-Boundaries: jede Abstimmung beginnt mit einer dieser Phrasen
|
||||
segment_starts = [m.start() for m in re.finditer(
|
||||
r"(?:(?:Damit|Somit) kommen wir (?:zur|somit zur) Abstimmung|Wir kommen (?:somit )?zur Abstimmung|Wir stimmen(?!\s+zu\?)|(?:Somit|Damit) kommen wir (?:direkt )?zu den Abstimmungen|Wir stimmen zweitens|gehen (?:wir )?zur Abstimmung über|Somit kommen wir sofort zur Abstimmung)",
|
||||
full
|
||||
)]
|
||||
|
||||
def segment_start_for(anchor_pos: int) -> int:
|
||||
"""Letzte Segment-Grenze vor dem Anchor."""
|
||||
candidates = [s for s in segment_starts if s < anchor_pos]
|
||||
return candidates[-1] if candidates else max(0, anchor_pos - 1500)
|
||||
|
||||
for a in anchors:
|
||||
ds = a["drucksache"]
|
||||
if not ds:
|
||||
ds = resolve_drucksache_for_ueber(full, a["anchor_start"])
|
||||
if not ds:
|
||||
continue
|
||||
|
||||
# Vote-Block: vom letzten Segment-Start bis zum Anchor
|
||||
block_start = segment_start_for(a["anchor_start"])
|
||||
block = full[block_start:a["anchor_end"]]
|
||||
|
||||
# Einstimmig: immer alle ja, unabhängig davon was das Fenster sagt
|
||||
if a["einstimmig"]:
|
||||
votes = {"ja": list(ALLE_FRAKTIONEN_NRW), "nein": [], "enthaltung": []}
|
||||
else:
|
||||
votes = _parse_vote_block(block)
|
||||
# Fallback-Einstimmig: wenn ein Überweisungs-Anchor keinen eigenen
|
||||
# "Wer stimmt ... zu?"-Block hat (stattdessen nur inverse Form
|
||||
# "Wer stimmt gegen ...?"), ist das in der Praxis einstimmig.
|
||||
if a["kind"] == "ueber" and not votes["ja"] and not votes["nein"] and not votes["enthaltung"]:
|
||||
votes = {"ja": list(ALLE_FRAKTIONEN_NRW), "nein": [], "enthaltung": []}
|
||||
|
||||
parsed.append({
|
||||
"drucksache": ds,
|
||||
"ergebnis": a["ergebnis"],
|
||||
"votes": votes,
|
||||
"anchor_pos": a["anchor_start"],
|
||||
})
|
||||
|
||||
return parsed
|
||||
|
||||
|
||||
def compare_to_fixture(parsed: list[dict], fixture: dict) -> tuple[int, list]:
|
||||
"""Vergleiche Parser-Output gegen Ground-Truth-Fixture."""
|
||||
parsed_map = {}
|
||||
for p in parsed:
|
||||
parsed_map.setdefault(p["drucksache"], []).append(p)
|
||||
|
||||
errors = []
|
||||
matches = 0
|
||||
for gt in fixture["drucksachen"]:
|
||||
ds = gt["drucksache"]
|
||||
gt_erg = gt["ergebnis"]
|
||||
if ds not in parsed_map:
|
||||
if gt_erg == "nicht_gesondert_abgestimmt":
|
||||
# Korrekt NICHT gefunden
|
||||
matches += 1
|
||||
continue
|
||||
errors.append(f"{ds}: NOT FOUND")
|
||||
continue
|
||||
if gt_erg == "nicht_gesondert_abgestimmt":
|
||||
errors.append(f"{ds}: expected nicht_gesondert, but parser found it")
|
||||
continue
|
||||
# Pick the one closest to expected — if multiple, take the first
|
||||
candidates = parsed_map[ds]
|
||||
p = candidates[0]
|
||||
|
||||
gt_erg = gt["ergebnis"]
|
||||
if gt_erg == "nicht_gesondert_abgestimmt":
|
||||
# Erwartetes Verhalten: Parser sollte es NICHT finden
|
||||
continue
|
||||
|
||||
ok = True
|
||||
if p["ergebnis"] != gt_erg:
|
||||
errors.append(f"{ds}: ergebnis {p['ergebnis']} != {gt_erg}")
|
||||
ok = False
|
||||
if sorted(p["votes"]["ja"]) != sorted(gt["ja"]):
|
||||
errors.append(f"{ds}: ja {p['votes']['ja']} != {gt['ja']}")
|
||||
ok = False
|
||||
if sorted(p["votes"]["nein"]) != sorted(gt["nein"]):
|
||||
errors.append(f"{ds}: nein {p['votes']['nein']} != {gt['nein']}")
|
||||
ok = False
|
||||
if sorted(p["votes"]["enthaltung"]) != sorted(gt["enthaltung"]):
|
||||
errors.append(f"{ds}: enth {p['votes']['enthaltung']} != {gt['enthaltung']}")
|
||||
ok = False
|
||||
if ok:
|
||||
matches += 1
|
||||
return matches, errors
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
pdf = "/tmp/mmp18-119.pdf"
|
||||
fixture_path = "/tmp/nrw_fixture.json"
|
||||
fixture = json.load(open(fixture_path))
|
||||
|
||||
parsed = parse_protocol(pdf)
|
||||
print(f"Parsed {len(parsed)} Abstimmungen gesamt")
|
||||
|
||||
matches, errors = compare_to_fixture(parsed, fixture)
|
||||
print(f"Match gegen Fixture: {matches}/{len(fixture['drucksachen']) - 1} (ohne nicht_gesondert)")
|
||||
print()
|
||||
if errors:
|
||||
print("Fehler:")
|
||||
for e in errors:
|
||||
print(f" {e}")
|
||||
@ -85,4 +85,6 @@ def build_pdf_href(zitat: dict, bundesland: str = "") -> str:
|
||||
|
||||
text = zitat.get("text", "")
|
||||
q = " ".join(text.split()[:5])
|
||||
return f"/api/wahlprogramm-cite?pid={pid}&seite={seite}&q={quote_plus(q)}"
|
||||
# #page=N als URL-Hash, damit der Browser-PDF-Viewer direkt zur Seite
|
||||
# springt — OpenAction im PDF wird von Chrome/Firefox ignoriert.
|
||||
return f"/api/wahlprogramm-cite?pid={pid}&seite={seite}&q={quote_plus(q)}#page={seite}"
|
||||
|
||||
1
app/static/v2/icons/phosphor/bug.svg
Normal file
1
app/static/v2/icons/phosphor/bug.svg
Normal file
@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 256 256" fill="currentColor"><path d="M240,96a8,8,0,0,0-8-8H213.06A88.18,88.18,0,0,0,192,61.34V48a16,16,0,0,0-16-16H80A16,16,0,0,0,64,48V61.34A88.18,88.18,0,0,0,42.94,88H24a8,8,0,0,0,0,16H40.21A88.35,88.35,0,0,0,40,112v8H24a8,8,0,0,0,0,16H40v8a88.35,88.35,0,0,0,.21,8H24a8,8,0,0,0,0,16H42.94A88,88,0,0,0,120,209.72V232a8,8,0,0,0,16,0V209.72A88,88,0,0,0,213.06,168H232a8,8,0,0,0,0-16H215.79A88.35,88.35,0,0,0,216,144v-8h16a8,8,0,0,0,0-16H216v-8a88.35,88.35,0,0,0-.21-8H232A8,8,0,0,0,240,96ZM80,48H176V55.12A88.25,88.25,0,0,0,128,40a88.25,88.25,0,0,0-48,15.12ZM128,192a72,72,0,1,1,72-72A72.08,72.08,0,0,1,128,192Z"/></svg>
|
||||
|
After Width: | Height: | Size: 674 B |
20
app/static/v2/lib/html2canvas.min.js
vendored
Normal file
20
app/static/v2/lib/html2canvas.min.js
vendored
Normal file
File diff suppressed because one or more lines are too long
@ -66,14 +66,32 @@ body.v2 :focus-visible {
|
||||
grid-area: topbar;
|
||||
background: var(--paper);
|
||||
border-bottom: 1px solid var(--hairline);
|
||||
padding: 10px 24px;
|
||||
padding: 0 24px;
|
||||
height: 32px; /* harte Höhe statt min-height */
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-4);
|
||||
font-family: var(--font-mono);
|
||||
font-size: 11px;
|
||||
line-height: 1;
|
||||
color: var(--ecg-dark);
|
||||
}
|
||||
.v2-topbar > * {
|
||||
height: auto;
|
||||
max-height: 24px; /* nichts darin höher als 24 px */
|
||||
}
|
||||
.v2-topbar select,
|
||||
.v2-topbar button,
|
||||
.v2-topbar a {
|
||||
padding: 2px 6px;
|
||||
margin: 0;
|
||||
line-height: 1;
|
||||
height: 22px;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
}
|
||||
.v2-topbar .v2-icon { width: 12px !important; height: 12px !important; }
|
||||
.v2-topbar .v2-icon svg { width: 12px; height: 12px; }
|
||||
|
||||
.v2-topbar-spacer {
|
||||
flex: 1;
|
||||
@ -524,7 +542,7 @@ body.v2 strong, body.v2 b {
|
||||
/* ── Matrix Mini (5×5) ──────────────────────────────────────────── */
|
||||
.v2-matrix-mini {
|
||||
display: grid;
|
||||
grid-template-columns: 92px repeat(5, 1fr);
|
||||
grid-template-columns: 130px repeat(5, 1fr);
|
||||
gap: 0;
|
||||
border: 1px solid var(--hairline);
|
||||
font-size: 11px;
|
||||
@ -537,7 +555,7 @@ body.v2 strong, body.v2 b {
|
||||
border-bottom: 1px solid var(--hairline);
|
||||
font-family: var(--font-mono);
|
||||
text-align: center;
|
||||
min-height: 30px;
|
||||
min-height: 36px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
@ -547,8 +565,8 @@ body.v2 strong, body.v2 b {
|
||||
.v2-matrix-mini > div:nth-child(6n) { border-right: 0; }
|
||||
.v2-matrix-mini > div:nth-last-child(-n+6) { border-bottom: 0; }
|
||||
|
||||
.v2-matrix-mini .hdr { background: var(--ecg-blue); color: #fff; font-size: 10px; letter-spacing: 0.04em; font-weight: 700; }
|
||||
.v2-matrix-mini .rhdr { background: var(--surface); text-align: left; justify-content: flex-start; padding-left: 10px; color: var(--ecg-dark); font-weight: 700; font-family: var(--font-sans); text-transform: uppercase; font-size: 10px; letter-spacing: 0.05em; }
|
||||
.v2-matrix-mini .hdr { background: var(--ecg-blue); color: #fff; font-size: 10px; letter-spacing: 0.03em; font-weight: 700; line-height: 1.25; cursor: help; padding: 4px 4px; }
|
||||
.v2-matrix-mini .rhdr { background: var(--surface); text-align: left; justify-content: flex-start; padding-left: 10px; color: var(--ecg-dark); font-weight: 700; font-family: var(--font-sans); font-size: 10px; letter-spacing: 0.02em; line-height: 1.25; cursor: help; }
|
||||
|
||||
.v2-matrix-mini .m-pp { background: var(--ecg-green); color: #fff; font-weight: 700; }
|
||||
.v2-matrix-mini .m-p { background: var(--redline-ins-bg); color: var(--ecg-dark); }
|
||||
@ -881,8 +899,11 @@ body.v2 ul.v2-manual ul li::before {
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
/* ── Menu Toggle Button ─────────────────────────────────────────── */
|
||||
.v2-menu-toggle {
|
||||
/* ── Menu Toggle Button — nur auf Mobile sichtbar (< 900 px) ───────
|
||||
!important nötig wegen .v2-topbar button { display: inline-flex }
|
||||
(die generische Reset-Regel hat 2 Klassen Specificity vs hier 1). */
|
||||
.v2-topbar .v2-menu-toggle {
|
||||
display: none !important;
|
||||
padding: 6px 10px;
|
||||
background: none;
|
||||
border: 1px solid var(--hairline);
|
||||
@ -891,6 +912,9 @@ body.v2 ul.v2-manual ul li::before {
|
||||
cursor: pointer;
|
||||
line-height: 1;
|
||||
}
|
||||
@media (max-width: 900px) {
|
||||
.v2-topbar .v2-menu-toggle { display: inline-flex !important; }
|
||||
}
|
||||
|
||||
/* ── Fraktions-Score-Tabelle (Fix 2+3) ─────────────────────────── */
|
||||
.v2-fraktions-scores {
|
||||
@ -964,8 +988,13 @@ body.v2 ul.v2-manual ul li::before {
|
||||
font-size: 10px;
|
||||
font-weight: 700;
|
||||
line-height: 1;
|
||||
cursor: help; /* Browser zeigt Hilfe-Cursor — Affordanz fuer Tooltip */
|
||||
}
|
||||
|
||||
/* Score-Chips bekommen die gleiche cursor-Affordanz, sodass User merken,
|
||||
dass WP/PP nicht nur Labels sind sondern Tooltips haben (#147). */
|
||||
.v2-score-chip[title] { cursor: help; }
|
||||
|
||||
.v2-badge-antragsteller {
|
||||
background: var(--ecg-blue);
|
||||
color: #fff;
|
||||
|
||||
@ -8,9 +8,9 @@
|
||||
<link rel="alternate" type="application/atom+xml" title="GWÖ-Antragsprüfer — Neue Bewertungen" href="/api/feed.xml">
|
||||
|
||||
{# Design-System: Tokens zuerst, dann Fonts, dann Base-Styles #}
|
||||
<link rel="stylesheet" href="/static/v2/tokens.css">
|
||||
<link rel="stylesheet" href="/static/v2/fonts.css">
|
||||
<link rel="stylesheet" href="/static/v2/v2.css">
|
||||
<link rel="stylesheet" href="/static/v2/tokens.css?v={{ app_version|default('1') }}">
|
||||
<link rel="stylesheet" href="/static/v2/fonts.css?v={{ app_version|default('1') }}">
|
||||
<link rel="stylesheet" href="/static/v2/v2.css?v={{ app_version|default('1') }}">
|
||||
|
||||
{% block head_extra %}{% endblock %}
|
||||
</head>
|
||||
@ -40,26 +40,29 @@
|
||||
<span class="v2-nav-count">{{ assessment_count }}</span>
|
||||
{% endif %}
|
||||
</a>
|
||||
<a href="/v2/merkliste" class="v2-nav-item {% if v2_active_nav == 'merkliste' %}active{% endif %}">{{ icon("bookmark-simple", 14) }} Merkliste</a>
|
||||
<a href="/v2/tags" class="v2-nav-item {% if v2_active_nav == 'tags' %}active{% endif %}">{{ icon("tag", 14) }} Tags</a>
|
||||
<a href="/v2/cluster" class="v2-nav-item {% if v2_active_nav == 'cluster' %}active{% endif %}">{{ icon("graph", 14) }} Cluster</a>
|
||||
<a href="/v2/landtag-suche" class="v2-nav-item {% if v2_active_nav == 'landtag_suche' %}active{% endif %}">{{ icon("magnifying-glass-plus", 14) }} Landtag-Suche</a>
|
||||
{% if is_authenticated %}<a href="/v2/merkliste" class="v2-nav-item {% if v2_active_nav == 'merkliste' %}active{% endif %}">{{ icon("bookmark-simple", 14) }} Merkliste</a>{% endif %}
|
||||
<a href="/v2/tags" class="v2-nav-item {% if v2_active_nav == 'tags' %}active{% endif %}">{{ icon("tag", 14) }} Tags</a>
|
||||
{% if is_admin %}<a href="/v2/cluster" class="v2-nav-item {% if v2_active_nav == 'cluster' %}active{% endif %}">{{ icon("graph", 14) }} Cluster</a>{% endif %}
|
||||
{% if is_authenticated %}<a href="/v2/landtag-suche" class="v2-nav-item {% if v2_active_nav == 'landtag_suche' %}active{% endif %}">{{ icon("magnifying-glass-plus", 14) }} Landtag-Suche</a>{% endif %}
|
||||
</div>
|
||||
|
||||
{% if is_authenticated %}
|
||||
<div class="v2-nav-group">
|
||||
<div class="v2-nav-label">— Prüfen</div>
|
||||
<a href="/v2/neu" class="v2-nav-item {% if v2_active_nav == 'neu' %}active{% endif %}">{{ icon("file-plus", 14) }} Neuer Antrag</a>
|
||||
<a href="/v2/batch" class="v2-nav-item {% if v2_active_nav == 'batch' %}active{% endif %}">{{ icon("stack", 14) }} Batch-Analyse</a>
|
||||
<a href="/v2/neu" class="v2-nav-item {% if v2_active_nav == 'neu' %}active{% endif %}">{{ icon("file-plus", 14) }} Neuer Antrag</a>
|
||||
{% if is_admin %}<a href="/v2/batch" class="v2-nav-item {% if v2_active_nav == 'batch' %}active{% endif %}">{{ icon("stack", 14) }} Batch-Analyse</a>{% endif %}
|
||||
</div>
|
||||
|
||||
<div class="v2-nav-group">
|
||||
<div class="v2-nav-label">— Daten</div>
|
||||
<a href="/auswertungen" class="v2-nav-item {% if v2_active_nav == 'auswertungen' %}active{% endif %}">{{ icon("chart-bar", 14) }} Auswertungen</a>
|
||||
<a href="/api/auswertungen/export.csv" class="v2-nav-item">{{ icon("file-csv", 14) }} Export · API</a>
|
||||
<a href="/api/feed.xml" class="v2-nav-item">{{ icon("rss", 14) }} Atom-Feed</a>
|
||||
<a href="/auswertungen" class="v2-nav-item {% if v2_active_nav == 'auswertungen' %}active{% endif %}">{{ icon("chart-bar", 14) }} Auswertungen</a>
|
||||
<a href="/api/auswertungen/export.csv" class="v2-nav-item">{{ icon("file-csv", 14) }} Export · API</a>
|
||||
<a href="/v2/feed" class="v2-nav-item {% if v2_active_nav == 'feed' %}active{% endif %}">{{ icon("rss", 14) }} Atom-Feed</a>
|
||||
<a href="/v2/abos" class="v2-nav-item {% if v2_active_nav == 'abos' %}active{% endif %}">{{ icon("envelope-simple", 14) }} Meine Abos</a>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
{% if is_admin is defined and is_admin %}
|
||||
{% if is_admin %}
|
||||
<div class="v2-nav-group">
|
||||
<div class="v2-nav-label">— Administration</div>
|
||||
<a href="/v2/admin/freischaltungen" class="v2-nav-item">{{ icon("user-check", 14) }} Freischaltungen</a>
|
||||
@ -77,6 +80,15 @@
|
||||
<a href="/methodik">{{ icon("info", 13) }} Methodik</a>
|
||||
<a href="/quellen">{{ icon("book-open", 13) }} Quellen</a>
|
||||
|
||||
{# ── Globaler Bundesland-Selector ─────────────────────────────────── #}
|
||||
<select id="v2-global-bl"
|
||||
onchange="v2SetGlobalBl(this.value)"
|
||||
aria-label="Bundesland wählen"
|
||||
style="font-family:var(--font-mono);font-size:11px;padding:3px 6px;border:1px solid var(--ecg-light, var(--ecg-border));background:var(--ecg-card-bg);color:var(--ecg-dark);text-transform:uppercase;border-radius:3px;cursor:pointer;">
|
||||
<option value="ALL">Bundesweit</option>
|
||||
{% for bl in v2_bundeslaender %}<option value="{{ bl.code }}">{{ bl.code }}</option>{% endfor %}
|
||||
</select>
|
||||
|
||||
{# ── Auth-Control — wird per JS nach /api/auth/me-Aufruf umgeschaltet ── #}
|
||||
<div id="v2-auth-control" style="display:inline-flex;align-items:center;">
|
||||
{# Platzhalter, bis initV2Auth() den Zustand kennt — unsichtbar #}
|
||||
@ -204,6 +216,27 @@
|
||||
|
||||
{% block body_scripts %}{% endblock %}
|
||||
|
||||
{# ── Globaler BL-Selector — Persistenz + Event ───────────────────────────── #}
|
||||
<script>
|
||||
(function () {
|
||||
var BL_KEY = 'gwoe.bl';
|
||||
|
||||
window.v2SetGlobalBl = function (code) {
|
||||
try { localStorage.setItem(BL_KEY, code); } catch (_) {}
|
||||
window.dispatchEvent(new CustomEvent('v2-bl-changed', { detail: { bl: code } }));
|
||||
};
|
||||
|
||||
window.v2GetGlobalBl = function () {
|
||||
try { return localStorage.getItem(BL_KEY) || 'ALL'; } catch (_) { return 'ALL'; }
|
||||
};
|
||||
|
||||
document.addEventListener('DOMContentLoaded', function () {
|
||||
var sel = document.getElementById('v2-global-bl');
|
||||
if (sel) sel.value = window.v2GetGlobalBl();
|
||||
});
|
||||
})();
|
||||
</script>
|
||||
|
||||
{# ── Auth Modal (global, einmal pro Seite) ────────────────────────────── #}
|
||||
{% include "v2/components/auth_modal.html" %}
|
||||
|
||||
@ -278,5 +311,11 @@
|
||||
|
||||
})();
|
||||
</script>
|
||||
|
||||
{# Feedback/Bug-Report-Widget — öffnet Gitea-Issues direkt aus dem Browser #}
|
||||
{% include "v2/components/feedback_widget.html" %}
|
||||
|
||||
{# Queue-Statusbar mit Hover-Tooltip — analog zu classic-UI (#149) #}
|
||||
{% include "v2/components/queue_widget.html" %}
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@ -49,6 +49,10 @@
|
||||
style="padding:var(--space-3);background:var(--ecg-blue);color:#fff;border:none;border-radius:4px;cursor:pointer;font-family:var(--font-sans);font-size:0.95rem;font-weight:700;letter-spacing:0.04em;">
|
||||
Anmelden
|
||||
</button>
|
||||
<a href="/api/auth/forgot-password" target="_blank" rel="noopener"
|
||||
style="font-family:var(--font-mono);font-size:0.78rem;color:var(--ecg-blue);text-align:right;text-decoration:none;border-bottom:1px solid rgba(0,157,165,0.35);align-self:flex-end;">
|
||||
Passwort vergessen?
|
||||
</a>
|
||||
</form>
|
||||
|
||||
<!-- Register Form -->
|
||||
|
||||
386
app/templates/v2/components/feedback_widget.html
Normal file
386
app/templates/v2/components/feedback_widget.html
Normal file
@ -0,0 +1,386 @@
|
||||
{#
|
||||
feedback_widget.html — Feedback/Bug-Report-Widget mit Audit-Trail und Gitea-Anbindung.
|
||||
|
||||
Position: bottom:4rem, left:1rem — über dem Queue-Widget.
|
||||
Self-contained: Button + Modal + Audit-Trail-Sammler + Submit-Logic.
|
||||
Wird via {% include %} in base.html eingebunden.
|
||||
#}
|
||||
|
||||
{# ── Feedback-Button ──────────────────────────────────────────────────────── #}
|
||||
<button id="v2-feedback-btn"
|
||||
onclick="v2FeedbackOpen()"
|
||||
aria-label="Feedback oder Bug melden"
|
||||
title="Feedback / Bug melden"
|
||||
style="position:fixed;bottom:4rem;left:1rem;
|
||||
background:var(--ecg-card-bg);border:1px solid var(--ecg-light);
|
||||
border-radius:6px;padding:0.4rem 0.8rem;
|
||||
font-family:var(--font-mono);font-size:11px;color:var(--ecg-dark);
|
||||
box-shadow:0 2px 8px rgba(0,0,0,0.1);z-index:100;cursor:pointer;
|
||||
display:inline-flex;align-items:center;gap:5px;
|
||||
transition:all 0.2s;white-space:nowrap;">
|
||||
<span style="display:inline-flex;align-items:center;width:14px;height:14px;">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 256 256" fill="currentColor" width="14" height="14"><path d="M240,96a8,8,0,0,0-8-8H213.06A88.18,88.18,0,0,0,192,61.34V48a16,16,0,0,0-16-16H80A16,16,0,0,0,64,48V61.34A88.18,88.18,0,0,0,42.94,88H24a8,8,0,0,0,0,16H40.21A88.35,88.35,0,0,0,40,112v8H24a8,8,0,0,0,0,16H40v8a88.35,88.35,0,0,0,.21,8H24a8,8,0,0,0,0,16H42.94A88,88,0,0,0,120,209.72V232a8,8,0,0,0,16,0V209.72A88,88,0,0,0,213.06,168H232a8,8,0,0,0,0-16H215.79A88.35,88.35,0,0,0,216,144v-8h16a8,8,0,0,0,0-16H216v-8a88.35,88.35,0,0,0-.21-8H232A8,8,0,0,0,240,96ZM80,48H176V55.12A88.25,88.25,0,0,0,128,40a88.25,88.25,0,0,0-48,15.12ZM128,192a72,72,0,1,1,72-72A72.08,72.08,0,0,1,128,192Z"/></svg>
|
||||
</span>
|
||||
Feedback
|
||||
</button>
|
||||
|
||||
{# ── Feedback-Modal ───────────────────────────────────────────────────────── #}
|
||||
<div id="v2-feedback-modal"
|
||||
role="dialog" aria-modal="true" aria-labelledby="v2-feedback-modal-title"
|
||||
style="display:none;position:fixed;inset:0;z-index:10000;
|
||||
background:rgba(0,0,0,0.45);
|
||||
align-items:center;justify-content:center;">
|
||||
|
||||
<div style="background:var(--ecg-card-bg);border:1px solid var(--ecg-light);
|
||||
border-radius:8px;padding:1.5rem;
|
||||
width:min(680px,96vw);max-height:90vh;overflow-y:auto;
|
||||
box-shadow:0 8px 32px rgba(0,0,0,0.25);
|
||||
font-family:var(--font-sans);font-size:13px;color:var(--ecg-dark);
|
||||
position:relative;">
|
||||
|
||||
<button onclick="v2FeedbackClose()"
|
||||
aria-label="Schließen"
|
||||
style="position:absolute;top:0.75rem;right:0.75rem;
|
||||
background:none;border:none;cursor:pointer;
|
||||
font-size:16px;color:var(--ecg-text-muted);line-height:1;">✕</button>
|
||||
|
||||
<h2 id="v2-feedback-modal-title"
|
||||
style="margin:0 0 1rem;font-size:14px;font-weight:900;
|
||||
letter-spacing:0.04em;text-transform:uppercase;
|
||||
color:var(--ecg-blue);">Feedback / Bug melden</h2>
|
||||
|
||||
<form id="v2-feedback-form" onsubmit="v2FeedbackSubmit(event)">
|
||||
|
||||
{# ── User-Eingaben ────────────────────────────────────────────── #}
|
||||
<div style="margin-bottom:0.75rem;">
|
||||
<label for="v2-fb-titel"
|
||||
style="display:block;margin-bottom:0.25rem;font-size:11px;
|
||||
font-family:var(--font-mono);text-transform:uppercase;
|
||||
letter-spacing:0.06em;color:var(--ecg-text-muted);">
|
||||
Titel <span style="color:var(--ecg-green);">*</span>
|
||||
</label>
|
||||
<input id="v2-fb-titel" type="text" required maxlength="200"
|
||||
placeholder="Kurze Zusammenfassung des Problems"
|
||||
style="width:100%;box-sizing:border-box;
|
||||
border:1px solid var(--ecg-light);border-radius:4px;
|
||||
padding:0.5rem 0.6rem;font-family:var(--font-sans);
|
||||
font-size:13px;background:var(--ecg-card-bg);
|
||||
color:var(--ecg-dark);">
|
||||
</div>
|
||||
|
||||
<div style="margin-bottom:0.75rem;">
|
||||
<label for="v2-fb-beschreibung"
|
||||
style="display:block;margin-bottom:0.25rem;font-size:11px;
|
||||
font-family:var(--font-mono);text-transform:uppercase;
|
||||
letter-spacing:0.06em;color:var(--ecg-text-muted);">
|
||||
Beschreibung <span style="color:var(--ecg-green);">*</span>
|
||||
</label>
|
||||
<textarea id="v2-fb-beschreibung" required maxlength="5000" rows="5"
|
||||
placeholder="Was ist passiert? Was hast du erwartet?"
|
||||
style="width:100%;box-sizing:border-box;
|
||||
border:1px solid var(--ecg-light);border-radius:4px;
|
||||
padding:0.5rem 0.6rem;font-family:var(--font-sans);
|
||||
font-size:13px;background:var(--ecg-card-bg);
|
||||
color:var(--ecg-dark);resize:vertical;"></textarea>
|
||||
</div>
|
||||
|
||||
{# ── Screenshot-Checkbox ──────────────────────────────────────── #}
|
||||
<div style="margin-bottom:1rem;display:flex;align-items:center;gap:0.5rem;">
|
||||
<input id="v2-fb-screenshot" type="checkbox">
|
||||
<label for="v2-fb-screenshot" style="font-size:12px;cursor:pointer;">
|
||||
Screenshot anhängen (aktueller Seitenausschnitt)
|
||||
</label>
|
||||
</div>
|
||||
|
||||
{# ── Audit-Trail-Vorschau ─────────────────────────────────────── #}
|
||||
<details style="margin-bottom:1rem;">
|
||||
<summary style="cursor:pointer;font-size:11px;font-family:var(--font-mono);
|
||||
text-transform:uppercase;letter-spacing:0.06em;
|
||||
color:var(--ecg-text-muted);">
|
||||
Mitgesendeter Kontext (Audit-Trail) ▾
|
||||
</summary>
|
||||
<div id="v2-fb-audit-preview"
|
||||
style="margin-top:0.5rem;padding:0.75rem;
|
||||
background:var(--ecg-bg, #f8f8f5);
|
||||
border:1px solid var(--ecg-light);border-radius:4px;
|
||||
font-family:var(--font-mono);font-size:10px;
|
||||
color:var(--ecg-text-muted);
|
||||
white-space:pre-wrap;max-height:200px;overflow-y:auto;
|
||||
word-break:break-all;"></div>
|
||||
</details>
|
||||
|
||||
{# ── Status-Anzeige ───────────────────────────────────────────── #}
|
||||
<div id="v2-fb-status" style="display:none;margin-bottom:0.75rem;
|
||||
padding:0.5rem 0.75rem;border-radius:4px;
|
||||
font-size:12px;"></div>
|
||||
|
||||
{# ── Buttons ──────────────────────────────────────────────────── #}
|
||||
<div style="display:flex;gap:0.75rem;justify-content:flex-end;">
|
||||
<button type="button" onclick="v2FeedbackClose()"
|
||||
style="background:none;border:1px solid var(--ecg-light);
|
||||
border-radius:4px;padding:0.5rem 1rem;cursor:pointer;
|
||||
font-family:var(--font-sans);font-size:13px;
|
||||
color:var(--ecg-dark);">Abbrechen</button>
|
||||
<button type="submit" id="v2-fb-submit-btn"
|
||||
style="background:var(--ecg-blue,#1a6fa8);border:none;
|
||||
border-radius:4px;padding:0.5rem 1.25rem;cursor:pointer;
|
||||
font-family:var(--font-sans);font-size:13px;
|
||||
color:#fff;font-weight:600;">Absenden</button>
|
||||
</div>
|
||||
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{# ── Audit-Trail-Sammler + Modal-Logik ────────────────────────────────────── #}
|
||||
<script>
|
||||
(function () {
|
||||
'use strict';
|
||||
|
||||
/* ── Ringbuffer-Helper ─────────────────────────────────────── */
|
||||
var AUDIT_KEY = 'gwoe.audit';
|
||||
var ERRORS_KEY = 'gwoe.errors';
|
||||
var MAX_CLICKS = 30;
|
||||
var MAX_ERRORS = 10;
|
||||
|
||||
function ringPush(key, item, max) {
|
||||
var arr = [];
|
||||
try { arr = JSON.parse(localStorage.getItem(key) || '[]'); } catch (_) {}
|
||||
arr.push(item);
|
||||
if (arr.length > max) arr = arr.slice(arr.length - max);
|
||||
try { localStorage.setItem(key, JSON.stringify(arr)); } catch (_) {}
|
||||
}
|
||||
|
||||
function ringRead(key) {
|
||||
try { return JSON.parse(localStorage.getItem(key) || '[]'); } catch (_) { return []; }
|
||||
}
|
||||
|
||||
/* ── CSS-Pfad (kurz) ────────────────────────────────────────── */
|
||||
function cssPath(el) {
|
||||
if (!el || el === document.body) return 'body';
|
||||
var path = [];
|
||||
var cur = el;
|
||||
while (cur && cur !== document.body && path.length < 4) {
|
||||
var tag = cur.tagName ? cur.tagName.toLowerCase() : '';
|
||||
var id = cur.id ? '#' + cur.id : '';
|
||||
var cls = cur.className && typeof cur.className === 'string'
|
||||
? ('.' + cur.className.trim().split(/\s+/).slice(0,2).join('.'))
|
||||
: '';
|
||||
path.unshift(tag + id + cls);
|
||||
cur = cur.parentElement;
|
||||
}
|
||||
return path.join(' > ');
|
||||
}
|
||||
|
||||
/* ── Click-Listener ─────────────────────────────────────────── */
|
||||
document.addEventListener('click', function (e) {
|
||||
var target = e.target;
|
||||
if (!target) return;
|
||||
// Feedback-Modal-Klicks nicht tracken
|
||||
if (target.closest && target.closest('#v2-feedback-modal')) return;
|
||||
var text = (target.textContent || target.value || target.alt || '')
|
||||
.trim().slice(0, 60).replace(/\s+/g, ' ');
|
||||
ringPush(AUDIT_KEY, {
|
||||
t: new Date().toISOString(),
|
||||
el: cssPath(target),
|
||||
txt: text || null
|
||||
}, MAX_CLICKS);
|
||||
}, true);
|
||||
|
||||
/* ── Error-Listener ─────────────────────────────────────────── */
|
||||
window.addEventListener('error', function (e) {
|
||||
ringPush(ERRORS_KEY, {
|
||||
t: new Date().toISOString(),
|
||||
msg: e.message || String(e),
|
||||
src: (e.filename || '').replace(window.location.origin, '') + ':' + e.lineno
|
||||
}, MAX_ERRORS);
|
||||
});
|
||||
|
||||
/* ── Modal öffnen/schließen ─────────────────────────────────── */
|
||||
window.v2FeedbackOpen = function () {
|
||||
var modal = document.getElementById('v2-feedback-modal');
|
||||
if (!modal) return;
|
||||
|
||||
// Audit-Vorschau befüllen
|
||||
var preview = document.getElementById('v2-fb-audit-preview');
|
||||
if (preview) {
|
||||
var clicks = ringRead(AUDIT_KEY).slice(-15);
|
||||
var errors = ringRead(ERRORS_KEY).slice(-10);
|
||||
var lines = [];
|
||||
lines.push('URL: ' + window.location.href);
|
||||
lines.push('User-Agent: ' + navigator.userAgent.slice(0, 120));
|
||||
lines.push('Viewport: ' + window.innerWidth + 'x' + window.innerHeight);
|
||||
// Drucksache aus URL extrahieren
|
||||
var dsMatch = window.location.pathname.match(/\/antrag\/([^/?#]+)/);
|
||||
if (dsMatch) lines.push('Drucksache: ' + decodeURIComponent(dsMatch[1]));
|
||||
lines.push('');
|
||||
if (clicks.length) {
|
||||
lines.push('Letzte Klicks:');
|
||||
clicks.forEach(function (c) {
|
||||
lines.push(' ' + c.t.slice(11,19) + ' ' + c.el + (c.txt ? ' "' + c.txt + '"' : ''));
|
||||
});
|
||||
}
|
||||
if (errors.length) {
|
||||
lines.push('');
|
||||
lines.push('Console-Errors:');
|
||||
errors.forEach(function (e) {
|
||||
lines.push(' ' + e.t.slice(11,19) + ' ' + e.msg + ' @ ' + e.src);
|
||||
});
|
||||
}
|
||||
preview.textContent = lines.join('\n');
|
||||
}
|
||||
|
||||
// Status zurücksetzen
|
||||
var status = document.getElementById('v2-fb-status');
|
||||
if (status) { status.style.display = 'none'; status.textContent = ''; }
|
||||
var submitBtn = document.getElementById('v2-fb-submit-btn');
|
||||
if (submitBtn) { submitBtn.disabled = false; submitBtn.textContent = 'Absenden'; }
|
||||
|
||||
modal.style.display = 'flex';
|
||||
var titel = document.getElementById('v2-fb-titel');
|
||||
if (titel) setTimeout(function () { titel.focus(); }, 50);
|
||||
};
|
||||
|
||||
window.v2FeedbackClose = function () {
|
||||
var modal = document.getElementById('v2-feedback-modal');
|
||||
if (modal) modal.style.display = 'none';
|
||||
};
|
||||
|
||||
// Schließen bei Klick auf Backdrop
|
||||
document.getElementById('v2-feedback-modal').addEventListener('click', function (e) {
|
||||
if (e.target === this) window.v2FeedbackClose();
|
||||
});
|
||||
|
||||
// Escape-Taste
|
||||
document.addEventListener('keydown', function (e) {
|
||||
if (e.key === 'Escape') {
|
||||
var modal = document.getElementById('v2-feedback-modal');
|
||||
if (modal && modal.style.display === 'flex') window.v2FeedbackClose();
|
||||
}
|
||||
});
|
||||
|
||||
/* ── Submit ─────────────────────────────────────────────────── */
|
||||
window.v2FeedbackSubmit = async function (e) {
|
||||
e.preventDefault();
|
||||
|
||||
var titel = document.getElementById('v2-fb-titel').value.trim();
|
||||
var beschreibung = document.getElementById('v2-fb-beschreibung').value.trim();
|
||||
var screenshot = document.getElementById('v2-fb-screenshot').checked;
|
||||
var submitBtn = document.getElementById('v2-fb-submit-btn');
|
||||
var statusEl = document.getElementById('v2-fb-status');
|
||||
|
||||
function setStatus(msg, ok) {
|
||||
statusEl.textContent = msg;
|
||||
statusEl.style.display = 'block';
|
||||
statusEl.style.background = ok ? 'rgba(0,128,64,0.1)' : 'rgba(200,0,0,0.08)';
|
||||
statusEl.style.border = '1px solid ' + (ok ? 'rgba(0,128,64,0.3)' : 'rgba(200,0,0,0.2)');
|
||||
statusEl.style.color = ok ? 'var(--ecg-green)' : '#c00';
|
||||
}
|
||||
|
||||
submitBtn.disabled = true;
|
||||
submitBtn.textContent = 'Wird gesendet…';
|
||||
|
||||
// Audit-Daten sammeln
|
||||
var clicks = ringRead(AUDIT_KEY).slice(-15);
|
||||
var errors = ringRead(ERRORS_KEY).slice(-10);
|
||||
var dsMatch = window.location.pathname.match(/\/antrag\/([^/?#]+)/);
|
||||
|
||||
var fd = new FormData();
|
||||
fd.append('titel', titel);
|
||||
fd.append('beschreibung', beschreibung);
|
||||
fd.append('url', window.location.href);
|
||||
fd.append('user_agent', navigator.userAgent);
|
||||
fd.append('viewport', window.innerWidth + 'x' + window.innerHeight);
|
||||
fd.append('drucksache', dsMatch ? decodeURIComponent(dsMatch[1]) : '');
|
||||
fd.append('klicks_json', JSON.stringify(clicks));
|
||||
fd.append('errors_json', JSON.stringify(errors));
|
||||
|
||||
// Screenshot (optional, via html2canvas)
|
||||
if (screenshot && window.html2canvas) {
|
||||
submitBtn.textContent = 'Screenshot wird erstellt…';
|
||||
// Modal + Overlay verstecken, damit der Screenshot die Seite ohne
|
||||
// Feedback-UI zeigt. Nach dem Capture wieder einblenden.
|
||||
var modal = document.getElementById('v2-feedback-modal');
|
||||
var overlay = document.getElementById('v2-feedback-overlay');
|
||||
var fbBtn = document.getElementById('v2-feedback-btn');
|
||||
var prev = {
|
||||
modalDisp: modal ? modal.style.display : null,
|
||||
overlayDisp: overlay ? overlay.style.display : null,
|
||||
btnDisp: fbBtn ? fbBtn.style.display : null,
|
||||
};
|
||||
if (modal) modal.style.display = 'none';
|
||||
if (overlay) overlay.style.display = 'none';
|
||||
if (fbBtn) fbBtn.style.display = 'none';
|
||||
// ein Frame warten, damit die Browser den Reflow rendert
|
||||
await new Promise(function (r) { requestAnimationFrame(function(){ requestAnimationFrame(r); }); });
|
||||
try {
|
||||
var canvas = await window.html2canvas(document.body, {
|
||||
scale: window.devicePixelRatio || 2, // Hi-DPI: scharfes Bild
|
||||
useCORS: true,
|
||||
allowTaint: false,
|
||||
logging: false,
|
||||
backgroundColor: getComputedStyle(document.body).backgroundColor || '#fff',
|
||||
// Sichtbares Viewport, nicht das ganze Dokument
|
||||
width: document.documentElement.clientWidth,
|
||||
height: document.documentElement.clientHeight,
|
||||
x: window.scrollX,
|
||||
y: window.scrollY,
|
||||
windowWidth: document.documentElement.clientWidth,
|
||||
windowHeight: document.documentElement.clientHeight,
|
||||
});
|
||||
// Breite begrenzen — bei Hi-DPI Display kann canvas.width 4000+ sein.
|
||||
// Cap bei 1600 logischen px (à la Retina-friendly), JPEG quality 0.85.
|
||||
var MAX_W = 1600;
|
||||
var finalCanvas = canvas;
|
||||
if (canvas.width > MAX_W) {
|
||||
var ratio = MAX_W / canvas.width;
|
||||
var sc = document.createElement('canvas');
|
||||
sc.width = MAX_W;
|
||||
sc.height = Math.round(canvas.height * ratio);
|
||||
var ctx = sc.getContext('2d');
|
||||
ctx.imageSmoothingEnabled = true;
|
||||
ctx.imageSmoothingQuality = 'high';
|
||||
ctx.drawImage(canvas, 0, 0, sc.width, sc.height);
|
||||
finalCanvas = sc;
|
||||
}
|
||||
var dataUrl = finalCanvas.toDataURL('image/jpeg', 0.85);
|
||||
fd.append('screenshot', dataUrl);
|
||||
} catch (err) {
|
||||
fd.append('screenshot_error', String(err));
|
||||
} finally {
|
||||
// UI zurückbringen
|
||||
if (modal) modal.style.display = prev.modalDisp || '';
|
||||
if (overlay) overlay.style.display = prev.overlayDisp || '';
|
||||
if (fbBtn) fbBtn.style.display = prev.btnDisp || '';
|
||||
}
|
||||
}
|
||||
|
||||
submitBtn.textContent = 'Wird gesendet…';
|
||||
|
||||
try {
|
||||
var resp = await fetch('/api/feedback', { method: 'POST', body: fd });
|
||||
var data = await resp.json();
|
||||
if (resp.ok && data.issue_url) {
|
||||
setStatus('Danke! Issue angelegt: ' + data.issue_url, true);
|
||||
submitBtn.textContent = 'Abgeschlossen';
|
||||
// Felder leeren
|
||||
document.getElementById('v2-fb-titel').value = '';
|
||||
document.getElementById('v2-fb-beschreibung').value = '';
|
||||
document.getElementById('v2-fb-screenshot').checked = false;
|
||||
setTimeout(window.v2FeedbackClose, 3000);
|
||||
} else {
|
||||
setStatus('Fehler: ' + (data.detail || JSON.stringify(data)), false);
|
||||
submitBtn.disabled = false;
|
||||
submitBtn.textContent = 'Absenden';
|
||||
}
|
||||
} catch (err) {
|
||||
setStatus('Netzwerkfehler: ' + err.message, false);
|
||||
submitBtn.disabled = false;
|
||||
submitBtn.textContent = 'Absenden';
|
||||
}
|
||||
};
|
||||
})();
|
||||
</script>
|
||||
|
||||
{# html2canvas — self-hosted, kein CDN-Call #}
|
||||
<script src="/static/v2/lib/html2canvas.min.js"></script>
|
||||
@ -21,8 +21,34 @@
|
||||
{% macro matrix_mini(matrix) %}
|
||||
{% set rows = ["A", "B", "C", "D", "E"] %}
|
||||
{% set cols = ["1", "2", "3", "4", "5"] %}
|
||||
{% set row_labels = {"A": "A · Liefer.", "B": "B · Finanzen", "C": "C · Verwalt.", "D": "D · Bürger", "E": "E · Gesell."} %}
|
||||
{% set col_labels = {"1": "Würde", "2": "Solid.", "3": "Ökol.", "4": "Soz.", "5": "Trans."} %}
|
||||
{% set row_labels = {
|
||||
"A": "A · Lieferant:innen",
|
||||
"B": "B · Finanzen",
|
||||
"C": "C · Verwaltung",
|
||||
"D": "D · Bürger:innen",
|
||||
"E": "E · Gesellschaft & Natur"
|
||||
} %}
|
||||
{% set row_titles = {
|
||||
"A": "Berührungsgruppe A — Lieferant:innen, ausgelagerte Betriebe, Dienstleister:innen. Externe Beschaffung und Lieferketten der Kommune.",
|
||||
"B": "Berührungsgruppe B — Finanzpartner:innen, Geldgeber:innen, Steuerzahler:innen. Umgang mit öffentlichen Mitteln und Haushalt.",
|
||||
"C": "Berührungsgruppe C — Politische Führung, Verwaltung, Ehrenamtliche. Mandatsträger:innen und Mitarbeitende der Kommune.",
|
||||
"D": "Berührungsgruppe D — Bürger:innen und Wirtschaft. Wirkung innerhalb der Gemeindegrenzen, Daseinsvorsorge.",
|
||||
"E": "Berührungsgruppe E — Staat, Gesellschaft und Natur. Wirkung über die Gemeindegrenzen hinaus, Zukunft."
|
||||
} %}
|
||||
{% set col_labels = {
|
||||
"1": "Menschenwürde",
|
||||
"2": "Solidarität",
|
||||
"3": "Ökol. Nachhaltigkeit",
|
||||
"4": "Soz. Gerechtigkeit",
|
||||
"5": "Transparenz"
|
||||
} %}
|
||||
{% set col_titles = {
|
||||
"1": "Wert 1 — Menschenwürde (Rechtsstaatsprinzip): Werden Grundrechte geschützt? Rechtliche Gleichstellung, Schutz vor Diskriminierung.",
|
||||
"2": "Wert 2 — Solidarität (Gemeinnutz): Wird das Gemeinwohl gefördert? Mehrwert für die Gemeinschaft, Kooperation statt Konkurrenz.",
|
||||
"3": "Wert 3 — Ökologische Nachhaltigkeit (Umwelt-Verantwortung): Klimaschutz, Ressourcenschonung, Biodiversität, Kreislaufwirtschaft.",
|
||||
"4": "Wert 4 — Soziale Gerechtigkeit (Sozialstaatsprinzip): Gerechte Verteilung, Daseinsvorsorge, soziale Absicherung, Chancengleichheit.",
|
||||
"5": "Wert 5 — Transparenz & Mitbestimmung (Demokratie): Bürgerbeteiligung, Offenlegung, demokratische Prozesse, Rechenschaftspflicht."
|
||||
} %}
|
||||
|
||||
{% macro rating_class(r) %}
|
||||
{% if r == 2 %}m-pp
|
||||
@ -36,12 +62,12 @@
|
||||
{# Header-Zeile #}
|
||||
<div class="hdr" role="columnheader"></div>
|
||||
{% for c in cols %}
|
||||
<div class="hdr" role="columnheader">{{ col_labels[c] }}</div>
|
||||
<div class="hdr" role="columnheader" title="{{ col_titles[c] }}">{{ col_labels[c] }}</div>
|
||||
{% endfor %}
|
||||
|
||||
{# Daten-Zeilen #}
|
||||
{% for r in rows %}
|
||||
<div class="rhdr" role="rowheader">{{ row_labels[r] }}</div>
|
||||
<div class="rhdr" role="rowheader" title="{{ row_titles[r] }}">{{ row_labels[r] }}</div>
|
||||
{% for c in cols %}
|
||||
{% set key = r ~ c %}
|
||||
{% set cell = matrix[key] if matrix is defined and key in matrix else {} %}
|
||||
|
||||
93
app/templates/v2/components/queue_widget.html
Normal file
93
app/templates/v2/components/queue_widget.html
Normal file
@ -0,0 +1,93 @@
|
||||
{#
|
||||
queue_widget.html — Queue-Statusbar mit Hover-Tooltip (#149).
|
||||
|
||||
Wird am Ende von base.html eingebunden via {% include %}. Self-contained:
|
||||
Eigenes <div id="v2-queue-statusbar"> + <div id="v2-queue-tooltip"> +
|
||||
Polling-Script. Pollt alle 5 s `/api/queue/status` und blendet sich aus,
|
||||
wenn keine Jobs aktiv/fertig/fehlgeschlagen sind.
|
||||
|
||||
Portiert aus classic-UI (#99). Nutzt v2-Tokens statt classic-Variablen.
|
||||
#}
|
||||
|
||||
<div id="v2-queue-statusbar"
|
||||
style="position:fixed;bottom:1rem;left:1rem;
|
||||
background:var(--ecg-card-bg);border:1px solid var(--ecg-light);
|
||||
border-radius:6px;padding:0.4rem 0.8rem;
|
||||
font-family:var(--font-mono);font-size:11px;color:var(--ecg-dark);
|
||||
box-shadow:0 2px 8px rgba(0,0,0,0.1);z-index:100;cursor:default;
|
||||
transition:all 0.2s;"
|
||||
onmouseenter="document.getElementById('v2-queue-tooltip').style.display='block'"
|
||||
onmouseleave="document.getElementById('v2-queue-tooltip').style.display='none'"
|
||||
aria-label="Analyse-Queue Status">
|
||||
<span id="v2-queue-status-text"></span>
|
||||
</div>
|
||||
|
||||
<div id="v2-queue-tooltip"
|
||||
style="display:none;position:fixed;bottom:3.5rem;left:1rem;
|
||||
background:var(--ecg-card-bg);border:1px solid var(--ecg-light);
|
||||
border-radius:6px;padding:0.8rem 1rem;
|
||||
font-family:var(--font-sans);font-size:12px;color:var(--ecg-dark);
|
||||
box-shadow:0 4px 16px rgba(0,0,0,0.15);z-index:101;
|
||||
max-width:420px;max-height:320px;overflow-y:auto;">
|
||||
</div>
|
||||
|
||||
<script>
|
||||
(function () {
|
||||
function poll() {
|
||||
fetch('/api/queue/status')
|
||||
.then(function (r) { return r.json(); })
|
||||
.then(function (qs) {
|
||||
var allJobs = qs.jobs || [];
|
||||
var jobs = allJobs.filter(function (j) { return j.status !== 'stale'; });
|
||||
var processing = jobs.filter(function (j) { return j.status === 'processing'; }).length;
|
||||
var queued = jobs.filter(function (j) { return j.status === 'queued' || j.status === 'pending'; }).length;
|
||||
var completed = jobs.filter(function (j) { return j.status === 'completed'; }).length;
|
||||
var failed = jobs.filter(function (j) { return j.status === 'failed'; }).length;
|
||||
|
||||
var bar = document.getElementById('v2-queue-statusbar');
|
||||
var text = document.getElementById('v2-queue-status-text');
|
||||
if (!bar || !text) return;
|
||||
|
||||
var workers = qs.workers_running != null ? qs.workers_running : '?';
|
||||
var parts = [];
|
||||
if (processing > 0) parts.push('⏳ ' + processing + ' in Bearbeitung');
|
||||
if (queued > 0) parts.push('⏸ ' + queued + ' wartend');
|
||||
if (completed > 0) parts.push('✓ ' + completed + ' fertig');
|
||||
if (failed > 0) parts.push('✗ ' + failed + ' fehlgeschlagen');
|
||||
if (parts.length === 0) {
|
||||
parts.push('Queue leer · ' + workers + ' Worker bereit');
|
||||
}
|
||||
text.textContent = parts.join(' · ');
|
||||
|
||||
var tip = document.getElementById('v2-queue-tooltip');
|
||||
if (!tip) return;
|
||||
// Tooltip zeigt bevorzugt aktive Jobs, Stale als „letzter Lauf"-Block.
|
||||
var displayJobs = jobs.length ? jobs : allJobs;
|
||||
var rows = displayJobs.slice(0, 20).map(function (j) {
|
||||
var icon = j.status === 'completed' ? '✓'
|
||||
: j.status === 'processing' ? '⏳'
|
||||
: j.status === 'failed' ? '✗'
|
||||
: '⏸';
|
||||
var dur = j.duration ? (' · ' + j.duration + 's') : '';
|
||||
var bl = j.bundesland ? (' · ' + j.bundesland) : '';
|
||||
var ds = j.drucksache || '?';
|
||||
var dsLink = j.status === 'completed' && j.drucksache
|
||||
? '<a href="/antrag/' + encodeURIComponent(j.drucksache) + '" style="color:var(--ecg-blue);">' + ds + '</a>'
|
||||
: ds;
|
||||
return '<div style="padding:0.25rem 0;border-bottom:1px solid var(--ecg-light);">'
|
||||
+ '<span style="font-family:var(--font-mono);">' + icon + '</span> '
|
||||
+ dsLink
|
||||
+ '<span style="font-family:var(--font-mono);color:var(--ecg-text-muted);font-size:0.85em;">' + bl + dur + '</span>'
|
||||
+ '</div>';
|
||||
}).join('');
|
||||
tip.innerHTML = '<div style="margin-bottom:0.5rem;font-weight:900;font-size:11px;letter-spacing:0.04em;text-transform:uppercase;color:var(--ecg-blue);">Queue · '
|
||||
+ workers + ' Worker</div>'
|
||||
+ (rows || '<div style="color:var(--ecg-text-muted);">leer</div>');
|
||||
})
|
||||
.catch(function () { /* still */ });
|
||||
}
|
||||
// erster Aufruf direkt + danach alle 5 s
|
||||
poll();
|
||||
setInterval(poll, 5000);
|
||||
})();
|
||||
</script>
|
||||
@ -27,7 +27,9 @@
|
||||
{% endif %}
|
||||
{{ source }}
|
||||
{% if pdf_href %}
|
||||
· <a href="{{ pdf_href }}" target="_blank" rel="noopener" style="color:var(--ecg-blue);border-bottom:1px solid rgba(0,157,165,0.35);">PDF öffnen</a>
|
||||
{# Falls pdf_href noch keinen #page=…-Anker hat, aus seite= im Query-String einen anhaengen — Browser-PDF-Viewer ignorieren PDF-OpenAction, der Hash-Anker funktioniert zuverlaessig. #}
|
||||
{% set _href = pdf_href if '#page=' in pdf_href else (pdf_href ~ ('#page=' ~ (pdf_href.split('seite=')[1].split('&')[0] if 'seite=' in pdf_href else '1'))) %}
|
||||
· <a href="{{ _href }}" target="_blank" rel="noopener" style="color:var(--ecg-blue);border-bottom:1px solid rgba(0,157,165,0.35);">PDF öffnen</a>
|
||||
{% endif %}
|
||||
</cite>
|
||||
</div>
|
||||
|
||||
@ -20,7 +20,9 @@
|
||||
{% set s = score | float %}
|
||||
{% if s < 5 %}{% set modifier = "low" %}{% else %}{% set modifier = "" %}{% endif %}
|
||||
|
||||
<div class="v2-score-hero {{ modifier }}" role="region" aria-label="GWÖ-Score {{ '%.1f'|format(s) }} von 10">
|
||||
<div class="v2-score-hero {{ modifier }}" role="region" aria-label="GWÖ-Score {{ '%.1f'|format(s) }} von 10"
|
||||
title="GWÖ-Score (0–10): Gesamt-Bewertung des Antrags nach der Gemeinwohl-Matrix 2.0 für Gemeinden — gewichteter Durchschnitt der 25 Matrix-Felder. Höher = stärkerer Beitrag zum Gemeinwohl. Details unter /methodik."
|
||||
style="cursor:help;">
|
||||
<div class="big-num" aria-hidden="true">
|
||||
{{ "%.1f" | format(s) }}<span class="slash">/10</span>
|
||||
</div>
|
||||
|
||||
166
app/templates/v2/screens/abos.html
Normal file
166
app/templates/v2/screens/abos.html
Normal file
@ -0,0 +1,166 @@
|
||||
{% extends "v2/base.html" %}
|
||||
|
||||
{% block title %}Meine Abos — GWÖ-Antragsprüfer{% endblock %}
|
||||
|
||||
{% set v2_active_nav = "abos" %}
|
||||
|
||||
{% block head_extra %}
|
||||
<style>
|
||||
.abo-form {
|
||||
display: flex; gap: 8px; flex-wrap: wrap; align-items: flex-end;
|
||||
margin-bottom: 24px; max-width: 760px;
|
||||
padding: 12px 14px; border: 1px solid var(--ecg-border);
|
||||
border-radius: 4px; background: var(--ecg-bg-subtle);
|
||||
}
|
||||
.abo-form label {
|
||||
display: block; font-family: var(--font-mono); font-size: 11px;
|
||||
text-transform: uppercase; letter-spacing: 0.06em; opacity: 0.7;
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
.abo-form select, .abo-form input[type="text"] {
|
||||
font-family: var(--font-mono); font-size: 13px;
|
||||
padding: 6px 8px; border: 1px solid var(--ecg-border);
|
||||
border-radius: 4px; background: var(--ecg-card-bg); color: var(--ecg-dark);
|
||||
}
|
||||
.abo-form .submit {
|
||||
font-family: var(--font-display); font-size: 12px; font-weight: 700;
|
||||
padding: 7px 14px; background: var(--ecg-teal); color: #fff;
|
||||
border: none; border-radius: 4px; cursor: pointer; letter-spacing: 0.04em;
|
||||
}
|
||||
.abo-row {
|
||||
display: flex; align-items: center; gap: 14px;
|
||||
padding: 10px 0; border-bottom: 1px solid var(--ecg-border);
|
||||
font-size: 13px;
|
||||
}
|
||||
.abo-row:last-child { border-bottom: none; }
|
||||
.abo-tag {
|
||||
font-family: var(--font-mono); font-size: 11px;
|
||||
padding: 3px 7px; border: 1px solid var(--ecg-border); border-radius: 3px;
|
||||
background: var(--ecg-card-bg);
|
||||
}
|
||||
.abo-del {
|
||||
font-family: var(--font-mono); font-size: 11px;
|
||||
padding: 4px 10px; background: none; border: 1px solid var(--ecg-border);
|
||||
border-radius: 3px; cursor: pointer; color: var(--redline-contra, #c00);
|
||||
}
|
||||
.abo-del:hover { background: var(--redline-contra-bg, #f9e6e6); }
|
||||
</style>
|
||||
{% endblock %}
|
||||
|
||||
{% block main %}
|
||||
<div style="padding:0 0 1rem;">
|
||||
<h1 style="font-family:var(--font-display);font-size:22px;color:var(--ecg-teal);margin:0 0 4px;">Meine E-Mail-Abos</h1>
|
||||
<p style="font-size:12px;font-family:var(--font-mono);color:var(--ecg-dark);opacity:0.6;">
|
||||
Tägliche Zusammenfassung neuer Bewertungen — gefiltert nach Bundesland und/oder Partei.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<form class="abo-form" onsubmit="aboCreate(event)">
|
||||
<div>
|
||||
<label for="abo-bl">Bundesland</label>
|
||||
<select id="abo-bl">
|
||||
<option value="">— alle —</option>
|
||||
{% for bl in v2_bundeslaender %}<option value="{{ bl.code }}">{{ bl.code }} — {{ bl.name }}</option>{% endfor %}
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label for="abo-partei">Partei</label>
|
||||
<select id="abo-partei">
|
||||
<option value="">— alle —</option>
|
||||
{% for p in parteien %}<option value="{{ p }}">{{ p }}</option>{% endfor %}
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label for="abo-freq">Frequenz</label>
|
||||
<select id="abo-freq">
|
||||
<option value="daily">täglich</option>
|
||||
<option value="weekly">wöchentlich</option>
|
||||
</select>
|
||||
</div>
|
||||
<button type="submit" class="submit">Abo anlegen</button>
|
||||
</form>
|
||||
|
||||
<div id="abo-status" style="margin-bottom:8px;font-family:var(--font-mono);font-size:12px;opacity:0.7;"></div>
|
||||
<div id="abo-list">Lade …</div>
|
||||
|
||||
{% endblock %}
|
||||
|
||||
{% block body_scripts %}
|
||||
<script>
|
||||
async function aboLoad() {
|
||||
var listEl = document.getElementById('abo-list');
|
||||
try {
|
||||
var r = await fetch('/api/subscriptions');
|
||||
if (r.status === 401) {
|
||||
listEl.innerHTML = '<p style="color:var(--ecg-dark);opacity:0.7;">Bitte erst anmelden.</p>';
|
||||
if (window.v2AuthModalOpen) window.v2AuthModalOpen();
|
||||
return;
|
||||
}
|
||||
var subs = await r.json();
|
||||
if (!subs || !subs.length) {
|
||||
listEl.innerHTML = '<p style="opacity:0.6;font-style:italic;">Du hast noch keine Abos. Lege oben eines an.</p>';
|
||||
return;
|
||||
}
|
||||
listEl.innerHTML = subs.map(function(s) {
|
||||
var bl = s.bundesland || '—';
|
||||
var p = s.partei || '—';
|
||||
var f = s.frequency || 'daily';
|
||||
var ls = s.last_sent ? ('zuletzt: ' + s.last_sent.substring(0,10)) : 'noch nie versandt';
|
||||
return '<div class="abo-row">'
|
||||
+ '<span class="abo-tag">BL ' + escHtml(bl) + '</span>'
|
||||
+ '<span class="abo-tag">Partei ' + escHtml(p) + '</span>'
|
||||
+ '<span class="abo-tag">' + escHtml(f) + '</span>'
|
||||
+ '<span style="flex:1;opacity:0.6;font-family:var(--font-mono);font-size:11px;">' + escHtml(ls) + '</span>'
|
||||
+ '<button class="abo-del" onclick="aboDelete(' + s.id + ')">✕ Löschen</button>'
|
||||
+ '</div>';
|
||||
}).join('');
|
||||
} catch (e) {
|
||||
listEl.innerHTML = '<p style="color:#c00;">Fehler: ' + escHtml(e.message) + '</p>';
|
||||
}
|
||||
}
|
||||
|
||||
async function aboCreate(e) {
|
||||
e.preventDefault();
|
||||
var bl = document.getElementById('abo-bl').value;
|
||||
var part = document.getElementById('abo-partei').value.trim();
|
||||
var freq = document.getElementById('abo-freq').value;
|
||||
var fd = new FormData();
|
||||
if (bl) fd.append('bundesland', bl);
|
||||
if (part) fd.append('partei', part);
|
||||
fd.append('frequency', freq);
|
||||
var r = await fetch('/api/subscriptions', { method: 'POST', body: fd });
|
||||
if (r.status === 401) {
|
||||
if (window.v2AuthModalOpen) window.v2AuthModalOpen();
|
||||
return;
|
||||
}
|
||||
if (!r.ok) {
|
||||
var err = await r.json().catch(()=>({detail:'Fehler'}));
|
||||
setStatus('Fehler: ' + (err.detail || r.status), true);
|
||||
return;
|
||||
}
|
||||
setStatus('Abo angelegt.');
|
||||
document.getElementById('abo-partei').value = '';
|
||||
aboLoad();
|
||||
}
|
||||
|
||||
async function aboDelete(id) {
|
||||
if (!confirm('Abo wirklich löschen?')) return;
|
||||
var r = await fetch('/api/subscriptions/' + id, { method: 'DELETE' });
|
||||
if (r.ok) { setStatus('Abo gelöscht.'); aboLoad(); }
|
||||
else { setStatus('Löschen fehlgeschlagen.', true); }
|
||||
}
|
||||
|
||||
function setStatus(msg, isErr) {
|
||||
var el = document.getElementById('abo-status');
|
||||
el.textContent = msg;
|
||||
el.style.color = isErr ? '#c00' : 'var(--ecg-teal)';
|
||||
setTimeout(function(){ el.textContent=''; }, 4000);
|
||||
}
|
||||
|
||||
function escHtml(s) {
|
||||
return String(s).replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>').replace(/"/g,'"');
|
||||
}
|
||||
|
||||
document.addEventListener('DOMContentLoaded', aboLoad);
|
||||
</script>
|
||||
{% endblock %}
|
||||
@ -264,6 +264,53 @@
|
||||
{% endfor %}
|
||||
{% endif %}{# abstimmungsverhalten #}
|
||||
|
||||
{# ── Fraktions-aggregierte Plenum-Abstimmung aus Plenarprotokoll (#106) ── #}
|
||||
{% if antrag.plenum_votes %}
|
||||
<h3 class="v2-h3" style="margin-top:24px;">Abstimmungsergebnis</h3>
|
||||
{% set ergebnis_color = {
|
||||
"angenommen": "#2da44e",
|
||||
"abgelehnt": "#cf222e",
|
||||
"überwiesen": "#0969da",
|
||||
"zurückgezogen": "#8250df",
|
||||
"bestätigt": "#2da44e",
|
||||
"sammel": "#0969da",
|
||||
} %}
|
||||
{% for v in antrag.plenum_votes %}
|
||||
<div style="border:1px solid var(--hairline);border-radius:6px;padding:12px 14px;margin-bottom:10px;background:var(--paper);">
|
||||
<div style="display:flex;justify-content:space-between;align-items:baseline;margin-bottom:6px;">
|
||||
<span style="font-family:var(--font-display);font-size:14px;font-weight:700;color:{{ ergebnis_color.get(v.ergebnis, '#6e7781') }};">
|
||||
{{ v.ergebnis | capitalize }}{% if v.einstimmig %} · einstimmig{% endif %}
|
||||
</span>
|
||||
<span style="font-family:var(--font-mono);font-size:10px;opacity:0.6;" title="{% if v.quelle_url %}{{ v.quelle_url }}{% endif %}">
|
||||
{{ v.quelle_protokoll }}{% if v.quelle_url %} ↗{% endif %}
|
||||
</span>
|
||||
</div>
|
||||
{% if v.fraktionen_ja or v.fraktionen_nein or v.fraktionen_enthaltung %}
|
||||
<div style="display:flex;flex-wrap:wrap;gap:12px;font-family:var(--font-mono);font-size:11px;">
|
||||
{% if v.fraktionen_ja %}
|
||||
<div><span style="color:#2da44e;font-weight:700;">Ja:</span>
|
||||
{% for f in v.fraktionen_ja %}<span style="display:inline-block;padding:1px 6px;background:color-mix(in srgb,#2da44e 15%,transparent);color:#1a7f37;border-radius:3px;margin-right:3px;">{{ f }}</span>{% endfor %}
|
||||
</div>
|
||||
{% endif %}
|
||||
{% if v.fraktionen_nein %}
|
||||
<div><span style="color:#cf222e;font-weight:700;">Nein:</span>
|
||||
{% for f in v.fraktionen_nein %}<span style="display:inline-block;padding:1px 6px;background:color-mix(in srgb,#cf222e 15%,transparent);color:#a40e26;border-radius:3px;margin-right:3px;">{{ f }}</span>{% endfor %}
|
||||
</div>
|
||||
{% endif %}
|
||||
{% if v.fraktionen_enthaltung %}
|
||||
<div><span style="color:#6e7781;font-weight:700;cursor:help;border-bottom:1px dotted currentColor;" title="Enth. — Enthaltung: weder Zustimmung noch Ablehnung.">Enth.:</span>
|
||||
{% for f in v.fraktionen_enthaltung %}<span style="display:inline-block;padding:1px 6px;background:color-mix(in srgb,#6e7781 15%,transparent);color:#57606a;border-radius:3px;margin-right:3px;">{{ f }}</span>{% endfor %}
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% endfor %}
|
||||
<div style="font-family:var(--font-mono);font-size:10px;opacity:0.5;margin-top:-4px;margin-bottom:8px;">
|
||||
Quelle: Plenarprotokoll · automatisch extrahiert
|
||||
</div>
|
||||
{% endif %}{# plenum_votes #}
|
||||
|
||||
{% if antrag.matrix %}
|
||||
<h3 class="v2-h3">Matrix 2.0 · 25 Felder</h3>
|
||||
{{ matrix_mini(antrag.matrix) }}
|
||||
@ -277,18 +324,18 @@
|
||||
<div class="v2-fraktion-row">
|
||||
<div class="v2-fraktion-label">
|
||||
{{ fs.fraktion }}
|
||||
{% if fs.ist_antragsteller %}<span class="v2-badge-antragsteller" title="Antragstellende Fraktion">A</span>{% endif %}
|
||||
{% if fs.ist_regierung %}<span class="v2-badge-regierung" title="Regierungsfraktion">R</span>{% endif %}
|
||||
{% if fs.ist_antragsteller %}<span class="v2-badge-antragsteller" title="A — Antragstellende Fraktion: hat den Antrag eingereicht.">A</span>{% endif %}
|
||||
{% if fs.ist_regierung %}<span class="v2-badge-regierung" title="R — Regierungsfraktion: trägt die aktuelle Mehrheit im Landtag.">R</span>{% endif %}
|
||||
</div>
|
||||
<div class="v2-fraktion-scores">
|
||||
{% set wp_score = fs.wahlprogramm.score | float %}
|
||||
{% set pp_score = fs.parteiprogramm.score | float %}
|
||||
<span class="v2-score-chip {% if wp_score >= 7 %}chip-green{% elif wp_score >= 4 %}chip-mid{% else %}chip-red{% endif %}"
|
||||
title="Wahlprogramm-Treue: {{ fs.wahlprogramm.begruendung }}">
|
||||
title="WP — Wahlprogramm-Treue (0–10): wie gut passt der Antrag zum aktuellen Wahlprogramm dieser Fraktion? {{ fs.wahlprogramm.begruendung }}">
|
||||
WP {{ "%.0f"|format(wp_score) }}/10
|
||||
</span>
|
||||
<span class="v2-score-chip {% if pp_score >= 7 %}chip-green{% elif pp_score >= 4 %}chip-mid{% else %}chip-red{% endif %}"
|
||||
title="Parteiprogramm-Treue: {{ fs.parteiprogramm.begruendung }}">
|
||||
title="PP — Parteiprogramm-Treue (0–10): wie gut passt der Antrag zum Grundsatzprogramm dieser Partei? {{ fs.parteiprogramm.begruendung }}">
|
||||
PP {{ "%.0f"|format(pp_score) }}/10
|
||||
</span>
|
||||
</div>
|
||||
@ -342,22 +389,38 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{# ── Share-Block ──────────────────────────────────────────────── #}
|
||||
{# ── Share-Block (analog v1) ───────────────────────────────────── #}
|
||||
<div style="margin-top:20px;">
|
||||
<div style="font-family:var(--font-mono);font-size:10px;text-transform:uppercase;letter-spacing:0.07em;color:var(--ecg-dark);opacity:0.6;margin-bottom:8px;">Teilen</div>
|
||||
<div style="display:flex;gap:8px;flex-wrap:wrap;">
|
||||
<button onclick="v2DetailShareCopy()"
|
||||
style="padding:5px 10px;border:1px solid var(--hairline);border-radius:4px;background:none;cursor:pointer;font-family:var(--font-mono);font-size:11px;color:var(--ecg-dark);">
|
||||
📋 Kopieren
|
||||
</button>
|
||||
<button onclick="v2DetailShare('threads')"
|
||||
style="padding:5px 10px;border:1px solid var(--hairline);border-radius:4px;background:none;cursor:pointer;font-family:var(--font-mono);font-size:11px;color:var(--ecg-dark);">
|
||||
Threads
|
||||
</button>
|
||||
<button onclick="v2DetailShare('twitter')"
|
||||
style="padding:5px 10px;border:1px solid var(--hairline);border-radius:4px;background:none;cursor:pointer;font-family:var(--font-mono);font-size:11px;color:var(--ecg-dark);">
|
||||
X
|
||||
𝕏
|
||||
</button>
|
||||
<button onclick="v2DetailShareMastodon()"
|
||||
style="padding:5px 10px;border:1px solid var(--hairline);border-radius:4px;background:none;cursor:pointer;font-family:var(--font-mono);font-size:11px;color:var(--ecg-dark);">
|
||||
Mastodon
|
||||
</button>
|
||||
<button onclick="v2DetailShare('linkedin')"
|
||||
style="padding:5px 10px;border:1px solid var(--hairline);border-radius:4px;background:none;cursor:pointer;font-family:var(--font-mono);font-size:11px;color:var(--ecg-dark);">
|
||||
LinkedIn
|
||||
</button>
|
||||
<button onclick="v2DetailShareEmail()"
|
||||
style="padding:5px 10px;border:1px solid var(--hairline);border-radius:4px;background:none;cursor:pointer;font-family:var(--font-mono);font-size:11px;color:var(--ecg-dark);">
|
||||
📧 E-Mail
|
||||
</button>
|
||||
<button onclick="v2DetailShareImage()"
|
||||
style="padding:5px 10px;border:1px solid var(--hairline);border-radius:4px;background:none;cursor:pointer;font-family:var(--font-mono);font-size:11px;color:var(--ecg-dark);">
|
||||
🖼 Bild
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@ -490,6 +553,7 @@ window.v2ShowMatrixFieldInfo = function(field) {
|
||||
var SHARE_MAS = {{ (antrag.share_mastodon or '') | tojson }};
|
||||
var TITLE = {{ antrag.title | tojson }};
|
||||
var SCORE = {{ antrag.score | tojson }};
|
||||
window.ANTRAG_TOPICS = {{ (antrag.themen or []) | tojson }};
|
||||
var PERMALINK = 'https://gwoe.toppyr.de/antrag/' + encodeURIComponent(DRS);
|
||||
|
||||
var currentUser = null;
|
||||
@ -632,12 +696,42 @@ window.v2ShowMatrixFieldInfo = function(field) {
|
||||
window.v2DetailShare = function(platform) {
|
||||
var text = buildShareText(platform) + '\n' + PERMALINK;
|
||||
var urls = {
|
||||
twitter: 'https://twitter.com/intent/tweet?text=' + encodeURIComponent(text),
|
||||
threads: 'https://www.threads.net/intent/post?text=' + encodeURIComponent(text)
|
||||
twitter: 'https://twitter.com/intent/tweet?text=' + encodeURIComponent(text),
|
||||
threads: 'https://www.threads.net/intent/post?text=' + encodeURIComponent(text),
|
||||
linkedin: 'https://www.linkedin.com/sharing/share-offsite/?url=' + encodeURIComponent(PERMALINK)
|
||||
};
|
||||
if (urls[platform]) window.open(urls[platform], '_blank', 'noopener');
|
||||
};
|
||||
|
||||
window.v2DetailShareCopy = function() {
|
||||
var text = buildShareText('twitter') + '\n' + PERMALINK;
|
||||
if (navigator.clipboard && navigator.clipboard.writeText) {
|
||||
navigator.clipboard.writeText(text).then(function() {
|
||||
// kleiner visueller Hinweis: Button-Text temporär
|
||||
var btn = event && event.currentTarget;
|
||||
if (btn) {
|
||||
var orig = btn.textContent;
|
||||
btn.textContent = '✓ kopiert';
|
||||
setTimeout(function(){ btn.textContent = orig; }, 1500);
|
||||
}
|
||||
});
|
||||
} else {
|
||||
prompt('Zum Kopieren markieren und Cmd/Strg-C drücken:', text);
|
||||
}
|
||||
};
|
||||
|
||||
window.v2DetailShareEmail = function() {
|
||||
var subject = 'GWÖ-Bewertung: ' + (TITLE.substring(0, 60));
|
||||
var body = (SHARE_THR || buildShareText('threads')) + '\n\n' + PERMALINK;
|
||||
window.location.href = 'mailto:?subject=' + encodeURIComponent(subject) + '&body=' + encodeURIComponent(body);
|
||||
};
|
||||
|
||||
window.v2DetailShareImage = function() {
|
||||
var topics = (window.ANTRAG_TOPICS || []).slice(0, 2).join(' ');
|
||||
var query = (topics || TITLE.substring(0, 40)) + ' Politik';
|
||||
window.open('https://www.freepik.com/search?format=search&query=' + encodeURIComponent(query), '_blank', 'noopener');
|
||||
};
|
||||
|
||||
window.v2DetailShareMastodon = function() {
|
||||
var text = buildShareText('mastodon') + '\n' + PERMALINK;
|
||||
var instance = localStorage.getItem('mastodon_instance');
|
||||
|
||||
@ -181,13 +181,6 @@ table.modal-table th { background: var(--ecg-bg-subtle); font-weight: 700; }
|
||||
<option value="{{ wp }}">{{ wp }}</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
<label for="bl-filter">Bundesland:</label>
|
||||
<select id="bl-filter">
|
||||
<option value="">Alle</option>
|
||||
{% for code in bl_codes %}
|
||||
<option value="{{ code }}">{{ code }}</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
<button class="primary" onclick="loadBlMatrix()">Laden</button>
|
||||
<button onclick="window.location.href='/api/auswertungen/export.csv'">CSV-Export</button>
|
||||
</div>
|
||||
@ -200,14 +193,6 @@ table.modal-table th { background: var(--ecg-bg-subtle); font-weight: 700; }
|
||||
<!-- Panel 2: Thema × Fraktion -->
|
||||
<div class="auswert-panel" id="panel-themen">
|
||||
<div class="controls-bar">
|
||||
<label for="themen-bl-filter">Bundesland:</label>
|
||||
<select id="themen-bl-filter" onchange="loadThemenMatrix()">
|
||||
<option value="">Alle</option>
|
||||
{% for code in bl_codes %}
|
||||
<option value="{{ code }}">{{ code }}</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
</div>
|
||||
<div id="themen-matrix-wrap" class="matrix-wrap">
|
||||
<div style="font-family:var(--font-mono);font-size:12px;opacity:0.5;">Wählen Sie den Tab „Thema × Fraktion".</div>
|
||||
</div>
|
||||
@ -258,6 +243,16 @@ function switchTab(id, btn) {
|
||||
}
|
||||
}
|
||||
|
||||
// Bei BL-Wechsel aktive Panels neu laden
|
||||
window.addEventListener('v2-bl-changed', function () {
|
||||
var activePanel = document.querySelector('.auswert-panel.active');
|
||||
if (!activePanel) return;
|
||||
if (activePanel.id === 'panel-bl-partei') loadBlMatrix();
|
||||
if (activePanel.id === 'panel-themen') loadThemenMatrix();
|
||||
});
|
||||
|
||||
document.addEventListener('DOMContentLoaded', function () { loadBlMatrix(); });
|
||||
|
||||
function scoreClass(avg) {
|
||||
if (avg == null) return '';
|
||||
if (avg >= 6) return 's-high';
|
||||
@ -269,7 +264,8 @@ async function loadBlMatrix() {
|
||||
const wrap = document.getElementById('bl-matrix-wrap');
|
||||
const metaEl = document.getElementById('bl-matrix-meta');
|
||||
const wp = document.getElementById('wp-filter').value;
|
||||
const bl = document.getElementById('bl-filter').value;
|
||||
const blRaw = (window.v2GetGlobalBl && window.v2GetGlobalBl()) || 'ALL';
|
||||
const bl = (blRaw === 'ALL') ? '' : blRaw;
|
||||
|
||||
wrap.innerHTML = '<div style="font-family:var(--font-mono);font-size:12px;opacity:0.5;">Lade Matrix …</div>';
|
||||
metaEl.textContent = '';
|
||||
@ -316,8 +312,9 @@ async function loadBlMatrix() {
|
||||
}
|
||||
|
||||
async function loadThemenMatrix() {
|
||||
const wrap = document.getElementById('themen-matrix-wrap');
|
||||
const bl = document.getElementById('themen-bl-filter').value;
|
||||
const wrap = document.getElementById('themen-matrix-wrap');
|
||||
const blRaw = (window.v2GetGlobalBl && window.v2GetGlobalBl()) || 'ALL';
|
||||
const bl = (blRaw === 'ALL') ? '' : blRaw;
|
||||
wrap.innerHTML = '<div style="font-family:var(--font-mono);font-size:12px;opacity:0.5;">Lade Themen-Matrix …</div>';
|
||||
|
||||
let url = '/api/auswertungen/themen-matrix';
|
||||
|
||||
@ -9,13 +9,9 @@
|
||||
|
||||
{% block main %}
|
||||
|
||||
{# ── Toolbar: Bundesland-Filter + Suche ─────────────────────────── #}
|
||||
{# ── Toolbar: Suche ──────────────────────────────────────────────── #}
|
||||
{# BL-Filter läuft jetzt über den globalen Selector in der Topbar. #}
|
||||
<div class="v2-toolbar" id="v2-toolbar" role="toolbar" aria-label="Filter und Suche">
|
||||
<button class="v2-chip active" data-bl="ALL" onclick="v2SetBl(this,'ALL')">Bundesweit</button>
|
||||
{% for code in bl_codes %}
|
||||
<button class="v2-chip" data-bl="{{ code }}" onclick="v2SetBl(this,'{{ code }}')">{{ code }}</button>
|
||||
{% endfor %}
|
||||
<span class="v2-toolbar-sep"></span>
|
||||
<input class="v2-search"
|
||||
type="search"
|
||||
placeholder="Anträge durchsuchen …"
|
||||
@ -122,13 +118,11 @@
|
||||
if (empty) empty.style.display = (visible === 0) ? 'block' : 'none';
|
||||
}
|
||||
|
||||
window.v2SetBl = function (btn, code) {
|
||||
activeBl = code;
|
||||
document.querySelectorAll('[data-bl]').forEach(function (b) {
|
||||
b.classList.toggle('active', b.dataset.bl === code);
|
||||
});
|
||||
/* BL-Filter: globaler Selector in der Topbar */
|
||||
window.addEventListener('v2-bl-changed', function (e) {
|
||||
activeBl = (e.detail && e.detail.bl) ? e.detail.bl : 'ALL';
|
||||
applyFilters();
|
||||
};
|
||||
});
|
||||
|
||||
window.v2SetBand = function (btn, band) {
|
||||
activeBand = band;
|
||||
@ -140,13 +134,19 @@
|
||||
|
||||
window.v2ResetFilters = function () {
|
||||
document.getElementById('v2-search-input').value = '';
|
||||
v2SetBl(null, 'ALL');
|
||||
// BL auf ALL zurücksetzen: globalen Selector aktualisieren
|
||||
var sel = document.getElementById('v2-global-bl');
|
||||
if (sel) sel.value = 'ALL';
|
||||
window.v2SetGlobalBl && window.v2SetGlobalBl('ALL');
|
||||
v2SetBand(null, 'ALL');
|
||||
};
|
||||
|
||||
document.addEventListener('DOMContentLoaded', function () {
|
||||
var input = document.getElementById('v2-search-input');
|
||||
if (input) input.addEventListener('input', applyFilters);
|
||||
// Gespeicherten BL-Wert beim Laden anwenden
|
||||
activeBl = (window.v2GetGlobalBl && window.v2GetGlobalBl()) || 'ALL';
|
||||
applyFilters();
|
||||
});
|
||||
})();
|
||||
|
||||
|
||||
125
app/templates/v2/screens/feed.html
Normal file
125
app/templates/v2/screens/feed.html
Normal file
@ -0,0 +1,125 @@
|
||||
{% extends "v2/base.html" %}
|
||||
|
||||
{% block title %}Atom-Feed — GWÖ-Antragsprüfer{% endblock %}
|
||||
|
||||
{% set v2_active_nav = "feed" %}
|
||||
|
||||
{% block head_extra %}
|
||||
<style>
|
||||
.feed-form {
|
||||
display: grid; grid-template-columns: max-content 1fr; gap: 8px 14px;
|
||||
align-items: center; margin-bottom: 24px; max-width: 560px;
|
||||
padding: 14px; border: 1px solid var(--ecg-border); border-radius: 4px;
|
||||
background: var(--ecg-bg-subtle);
|
||||
}
|
||||
.feed-form label { font-family: var(--font-mono); font-size: 11px;
|
||||
text-transform: uppercase; letter-spacing: 0.06em; opacity: 0.7; }
|
||||
.feed-form select, .feed-form input {
|
||||
font-family: var(--font-mono); font-size: 13px;
|
||||
padding: 6px 8px; border: 1px solid var(--ecg-border);
|
||||
border-radius: 4px; background: var(--ecg-card-bg); color: var(--ecg-dark);
|
||||
}
|
||||
.feed-url-box {
|
||||
margin-top: 16px; padding: 14px; border: 1px solid var(--ecg-border);
|
||||
border-radius: 4px; background: var(--ecg-card-bg);
|
||||
}
|
||||
.feed-url {
|
||||
font-family: var(--font-mono); font-size: 12px; padding: 8px 10px;
|
||||
border: 1px solid var(--ecg-border); border-radius: 3px; word-break: break-all;
|
||||
background: var(--paper); color: var(--ecg-dark); display: block;
|
||||
margin: 8px 0;
|
||||
}
|
||||
.feed-actions { display: flex; gap: 10px; flex-wrap: wrap; }
|
||||
.feed-btn {
|
||||
font-family: var(--font-mono); font-size: 12px; padding: 6px 14px;
|
||||
background: var(--ecg-teal); color: #fff; border: none; border-radius: 3px;
|
||||
cursor: pointer; text-decoration: none; display: inline-flex;
|
||||
align-items: center; gap: 6px;
|
||||
}
|
||||
.feed-btn.secondary { background: none; color: var(--ecg-dark); border: 1px solid var(--ecg-border); }
|
||||
</style>
|
||||
{% endblock %}
|
||||
|
||||
{% block main %}
|
||||
<div style="padding:0 0 1rem;">
|
||||
<h1 style="font-family:var(--font-display);font-size:22px;color:var(--ecg-teal);margin:0 0 4px;">Atom-Feed</h1>
|
||||
<p style="font-size:12px;font-family:var(--font-mono);color:var(--ecg-dark);opacity:0.6;">
|
||||
Konfigurierbarer Feed der neuesten Bewertungen — abonnierbar mit jedem RSS/Atom-Reader.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<form class="feed-form" onsubmit="event.preventDefault();feedUpdate();">
|
||||
<label for="feed-bl">Bundesland</label>
|
||||
<select id="feed-bl" onchange="feedUpdate()">
|
||||
<option value="">— alle —</option>
|
||||
{% for bl in v2_bundeslaender %}<option value="{{ bl.code }}">{{ bl.code }} — {{ bl.name }}</option>{% endfor %}
|
||||
</select>
|
||||
|
||||
<label for="feed-partei">Partei</label>
|
||||
<select id="feed-partei" onchange="feedUpdate()">
|
||||
<option value="">— alle —</option>
|
||||
{% for p in parteien %}<option value="{{ p }}">{{ p }}</option>{% endfor %}
|
||||
</select>
|
||||
|
||||
<label for="feed-limit">Anzahl</label>
|
||||
<input type="number" id="feed-limit" min="1" max="200" value="50" oninput="feedUpdate()">
|
||||
</form>
|
||||
|
||||
<div class="feed-url-box">
|
||||
<div style="font-family:var(--font-mono);font-size:11px;text-transform:uppercase;letter-spacing:0.07em;opacity:0.7;">Feed-URL</div>
|
||||
<code class="feed-url" id="feed-url">/api/feed.xml</code>
|
||||
<div class="feed-actions">
|
||||
<a id="feed-open" href="/api/feed.xml" class="feed-btn" target="_blank" rel="noopener">📰 Öffnen</a>
|
||||
<button class="feed-btn secondary" onclick="feedCopy()">📋 URL kopieren</button>
|
||||
<a id="feed-reader" href="" class="feed-btn secondary" target="_blank" rel="noopener" title="In Feedly öffnen">In Feedly</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div style="margin-top:24px;font-size:12px;color:var(--ecg-dark);opacity:0.7;line-height:1.6;max-width:600px;">
|
||||
<p><strong>Hinweis:</strong> Du kannst die Feed-URL in jedem RSS-Reader (z.B. Feedly, NewsBlur, Inoreader, NetNewsWire, Thunderbird) abonnieren. Der Feed ist Atom 1.0 und liefert die letzten Bewertungen mit Score, Empfehlung und Kurzbegründung.</p>
|
||||
<p>Wenn du regelmäßige Mails statt Pull-Feed willst, lege ein <a href="/v2/abos" style="color:var(--ecg-teal);">E-Mail-Abo</a> an.</p>
|
||||
</div>
|
||||
|
||||
{% endblock %}
|
||||
|
||||
{% block body_scripts %}
|
||||
<script>
|
||||
function feedUpdate() {
|
||||
var bl = document.getElementById('feed-bl').value;
|
||||
var part = document.getElementById('feed-partei').value.trim();
|
||||
var limit = document.getElementById('feed-limit').value;
|
||||
var qs = [];
|
||||
if (bl) qs.push('bundesland=' + encodeURIComponent(bl));
|
||||
if (part) qs.push('partei=' + encodeURIComponent(part));
|
||||
if (limit && limit !== '50') qs.push('limit=' + encodeURIComponent(limit));
|
||||
var path = '/api/feed.xml' + (qs.length ? ('?' + qs.join('&')) : '');
|
||||
var full = location.origin + path;
|
||||
document.getElementById('feed-url').textContent = full;
|
||||
document.getElementById('feed-open').href = path;
|
||||
document.getElementById('feed-reader').href = 'https://feedly.com/i/subscription/feed%2F' + encodeURIComponent(full);
|
||||
}
|
||||
|
||||
async function feedCopy() {
|
||||
var url = document.getElementById('feed-url').textContent;
|
||||
if (navigator.clipboard) await navigator.clipboard.writeText(url);
|
||||
else { prompt('Kopieren:', url); }
|
||||
}
|
||||
|
||||
// Bundesland aus globaler Auswahl als Default übernehmen
|
||||
document.addEventListener('DOMContentLoaded', function() {
|
||||
var globalBl = (window.v2GetGlobalBl && window.v2GetGlobalBl()) || 'ALL';
|
||||
if (globalBl && globalBl !== 'ALL') {
|
||||
var sel = document.getElementById('feed-bl');
|
||||
if (sel) sel.value = globalBl;
|
||||
}
|
||||
feedUpdate();
|
||||
});
|
||||
window.addEventListener('v2-bl-changed', function(e) {
|
||||
var sel = document.getElementById('feed-bl');
|
||||
if (sel) {
|
||||
sel.value = (e.detail && e.detail.bl !== 'ALL') ? e.detail.bl : '';
|
||||
feedUpdate();
|
||||
}
|
||||
});
|
||||
</script>
|
||||
{% endblock %}
|
||||
@ -78,12 +78,18 @@
|
||||
min-width: 100px;
|
||||
padding-top: 2px;
|
||||
}
|
||||
.ls-main {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
min-width: 0;
|
||||
}
|
||||
.ls-title {
|
||||
font-family: var(--font-display);
|
||||
font-size: 14px;
|
||||
font-weight: 700;
|
||||
color: var(--ecg-dark);
|
||||
flex: 1;
|
||||
line-height: 1.35;
|
||||
}
|
||||
.ls-title a {
|
||||
@ -91,6 +97,24 @@
|
||||
text-decoration: none;
|
||||
}
|
||||
.ls-title a:hover { text-decoration: underline; }
|
||||
.ls-fraktionen {
|
||||
display: flex;
|
||||
gap: 6px;
|
||||
flex-wrap: wrap;
|
||||
margin-top: 2px;
|
||||
}
|
||||
.ls-fraktion {
|
||||
display: inline-block;
|
||||
padding: 1px 7px;
|
||||
background: color-mix(in srgb, var(--ecg-teal) 10%, transparent);
|
||||
color: var(--ecg-teal);
|
||||
font-family: var(--font-mono);
|
||||
font-size: 10px;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.04em;
|
||||
border-radius: 3px;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
.ls-actions { flex-shrink: 0; }
|
||||
.ls-btn-analyse {
|
||||
font-family: var(--font-mono);
|
||||
@ -147,6 +171,9 @@
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div id="ls-bl-hint" style="display:none;margin-bottom:12px;padding:8px 12px;background:color-mix(in srgb,var(--ecg-teal) 10%,transparent);border:1px solid var(--ecg-teal);border-radius:4px;font-family:var(--font-mono);font-size:11px;color:var(--ecg-teal);">
|
||||
Bitte zuerst ein Bundesland im Header wählen.
|
||||
</div>
|
||||
<form class="ls-form" onsubmit="lsSearch(event)">
|
||||
<div class="ls-q">
|
||||
<label for="ls-q-input">Suchbegriff</label>
|
||||
@ -158,14 +185,6 @@
|
||||
required
|
||||
onkeydown="if(event.key==='Enter'){event.preventDefault();lsSearch(event);}">
|
||||
</div>
|
||||
<div class="ls-bl">
|
||||
<label for="ls-bl-select">Bundesland</label>
|
||||
<select id="ls-bl-select" name="bundesland">
|
||||
{% for bl in bundeslaender %}
|
||||
<option value="{{ bl.code }}"{% if bl.code == 'NRW' %} selected{% endif %}>{{ bl.name }} ({{ bl.code }})</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
</div>
|
||||
<button type="submit" class="ls-submit" id="ls-btn">
|
||||
{{ icon("magnifying-glass-plus", 14) }} Suchen
|
||||
</button>
|
||||
@ -202,7 +221,14 @@ async function lsSearch(e) {
|
||||
if (e && e.preventDefault) e.preventDefault();
|
||||
|
||||
var q = (document.getElementById('ls-q-input').value || '').trim();
|
||||
var bl = document.getElementById('ls-bl-select').value;
|
||||
var bl = (window.v2GetGlobalBl && window.v2GetGlobalBl()) || 'ALL';
|
||||
|
||||
if (bl === 'ALL') {
|
||||
document.getElementById('ls-bl-hint').style.display = '';
|
||||
document.getElementById('ls-status').textContent = '';
|
||||
return;
|
||||
}
|
||||
document.getElementById('ls-bl-hint').style.display = 'none';
|
||||
|
||||
if (q.length < 2) {
|
||||
document.getElementById('ls-status').textContent = 'Bitte mindestens 2 Zeichen eingeben.';
|
||||
@ -244,13 +270,22 @@ async function lsSearch(e) {
|
||||
function renderRow(item, bl) {
|
||||
var ds = item.drucksache || '';
|
||||
var title = escHtml(item.title || item.titel || ds);
|
||||
var url = item.url || '';
|
||||
var url = item.url || item.link || '';
|
||||
var done = lsCheckedIds.has(ds);
|
||||
var fraktionen = Array.isArray(item.fraktionen) ? item.fraktionen : [];
|
||||
|
||||
var titleHtml = url
|
||||
? '<a href="' + escHtml(url) + '" target="_blank" rel="noopener">' + title + '</a>'
|
||||
: title;
|
||||
|
||||
var fraktionenHtml = fraktionen.length
|
||||
? '<div class="ls-fraktionen">'
|
||||
+ fraktionen.map(function (f) {
|
||||
return '<span class="ls-fraktion">' + escHtml(f) + '</span>';
|
||||
}).join('')
|
||||
+ '</div>'
|
||||
: '';
|
||||
|
||||
var actionHtml;
|
||||
if (done) {
|
||||
actionHtml = '<span class="ls-badge-done">Bewertet → <a href="/antrag/' + encodeURIComponent(ds) + '" style="color:inherit;">Ansehen</a></span>';
|
||||
@ -262,7 +297,10 @@ function renderRow(item, bl) {
|
||||
|
||||
return '<div class="ls-row">'
|
||||
+ '<div class="ls-drucksache">' + escHtml(ds) + '</div>'
|
||||
+ '<div class="ls-title">' + titleHtml + '</div>'
|
||||
+ '<div class="ls-main">'
|
||||
+ '<div class="ls-title">' + titleHtml + '</div>'
|
||||
+ fraktionenHtml
|
||||
+ '</div>'
|
||||
+ '<div class="ls-actions">' + actionHtml + '</div>'
|
||||
+ '</div>';
|
||||
}
|
||||
@ -292,9 +330,50 @@ async function lsAnalyse(btn, drucksache, bundesland) {
|
||||
}
|
||||
var data = await resp.json();
|
||||
var ds = data.drucksache || drucksache;
|
||||
// Backend gibt {job_id, drucksache} zurück (Queue) — nicht direkt redirecten,
|
||||
// sondern auf /antrag/{ds} gehen, dort wird dann ggf. der Polling-Status sichtbar
|
||||
window.location.href = '/antrag/' + encodeURIComponent(ds);
|
||||
|
||||
// Falls bereits bewertet oder skipped: direkt redirecten
|
||||
if (data.status === 'already_checked') {
|
||||
window.location.href = '/antrag/' + encodeURIComponent(ds);
|
||||
return;
|
||||
}
|
||||
if (data.status === 'skipped') {
|
||||
btn.textContent = 'Nicht abstimmbar';
|
||||
btn.title = (data.reason || ('Typ „' + (data.typ || 'unbekannt') + '" ist nicht abstimmbar — keine GWÖ-Bewertung sinnvoll'));
|
||||
btn.style.opacity = '0.55';
|
||||
btn.style.cursor = 'not-allowed';
|
||||
// Begründung sichtbar in der Zeile anzeigen
|
||||
var row = btn.closest('.ls-row');
|
||||
if (row) {
|
||||
var hint = document.createElement('div');
|
||||
hint.style.cssText = 'flex-basis:100%;font-family:var(--font-mono);font-size:11px;color:var(--ecg-dark);opacity:0.7;margin-top:4px;font-style:italic;';
|
||||
hint.textContent = data.reason || ('Typ „' + (data.typ || 'unbekannt') + '" — keine Abstimmung, keine GWÖ-Bewertung');
|
||||
row.appendChild(hint);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// Sonst Job-Polling bis fertig, dann redirect
|
||||
btn.textContent = 'Analysiere…';
|
||||
var jobId = data.job_id;
|
||||
if (!jobId) { window.location.href = '/antrag/' + encodeURIComponent(ds); return; }
|
||||
|
||||
var attempts = 0;
|
||||
var maxAttempts = 90; // 90 × 2s = 3 min
|
||||
while (attempts < maxAttempts) {
|
||||
await new Promise(function (r) { setTimeout(r, 2000); });
|
||||
attempts++;
|
||||
var st = await fetch('/status/' + jobId).then(function (r) { return r.json(); }).catch(function () { return null; });
|
||||
if (!st) continue;
|
||||
if (st.status === 'completed') {
|
||||
window.location.href = '/antrag/' + encodeURIComponent(ds);
|
||||
return;
|
||||
}
|
||||
if (st.status === 'failed' || st.status === 'rejected') {
|
||||
throw new Error('Analyse fehlgeschlagen: ' + (st.error || 'unbekannt'));
|
||||
}
|
||||
btn.textContent = 'Analysiere… (' + (st.status || '...') + ')';
|
||||
}
|
||||
throw new Error('Analyse-Timeout (>3 min)');
|
||||
} catch (err) {
|
||||
btn.disabled = false;
|
||||
btn.textContent = 'Fehler';
|
||||
@ -313,5 +392,15 @@ function escHtml(s) {
|
||||
function escAttr(s) {
|
||||
return String(s).replace(/'/g, "\\'");
|
||||
}
|
||||
|
||||
document.addEventListener('DOMContentLoaded', function () {
|
||||
var hint = document.getElementById('ls-bl-hint');
|
||||
function updateHint() {
|
||||
var bl = (window.v2GetGlobalBl && window.v2GetGlobalBl()) || 'ALL';
|
||||
if (hint) hint.style.display = (bl === 'ALL') ? '' : 'none';
|
||||
}
|
||||
updateHint();
|
||||
window.addEventListener('v2-bl-changed', updateHint);
|
||||
});
|
||||
</script>
|
||||
{% endblock %}
|
||||
|
||||
@ -79,14 +79,14 @@
|
||||
/* Interactive matrix grid */
|
||||
.gwoe-matrix-grid {
|
||||
display: grid;
|
||||
grid-template-columns: 110px repeat(5, 1fr);
|
||||
grid-template-columns: 150px repeat(5, 1fr);
|
||||
gap: 2px;
|
||||
font-size: 11px;
|
||||
margin: 1rem 0;
|
||||
}
|
||||
.gwoe-matrix-grid .gc { padding: 5px 4px; text-align: center; background: var(--ecg-bg-subtle); border: 1px solid var(--ecg-border); }
|
||||
.gwoe-matrix-grid .gh { background: var(--ecg-teal); color: #fff; font-weight: 700; }
|
||||
.gwoe-matrix-grid .gr { background: var(--ecg-green); color: #fff; font-weight: 700; text-align: left; padding-left: 6px; }
|
||||
.gwoe-matrix-grid .gc { padding: 5px 4px; text-align: center; background: var(--ecg-bg-subtle); border: 1px solid var(--ecg-border); display: flex; align-items: center; justify-content: center; line-height: 1.25; min-height: 36px; }
|
||||
.gwoe-matrix-grid .gh { background: var(--ecg-teal); color: #fff; font-weight: 700; cursor: help; }
|
||||
.gwoe-matrix-grid .gr { background: var(--ecg-green); color: #fff; font-weight: 700; justify-content: flex-start; padding-left: 6px; text-align: left; cursor: help; }
|
||||
.gwoe-matrix-grid .gc.clickable { cursor: pointer; transition: background 0.1s; }
|
||||
.gwoe-matrix-grid .gc.clickable:hover { background: rgba(0,157,165,0.12); }
|
||||
#field-explain {
|
||||
@ -138,6 +138,7 @@
|
||||
<a href="#was-macht">Was macht der Prüfer?</a>
|
||||
<a href="#matrix">Die Matrix 2.0</a>
|
||||
<a href="#pipeline">Analyse-Pipeline</a>
|
||||
<a href="#prompts">LLM-Prompts</a>
|
||||
<a href="#qualitaet">Qualitätssicherung</a>
|
||||
<a href="#einschraenkungen">Einschränkungen</a>
|
||||
<a href="#datenquellen">Datenquellen</a>
|
||||
@ -254,46 +255,46 @@
|
||||
|
||||
<div class="gwoe-matrix-grid">
|
||||
<div class="gc"></div>
|
||||
<div class="gc gh">Menschen­würde</div>
|
||||
<div class="gc gh">Solidarität</div>
|
||||
<div class="gc gh">Ökol. Nachh.</div>
|
||||
<div class="gc gh">Soz. Gerecht.</div>
|
||||
<div class="gc gh">Transparenz</div>
|
||||
<div class="gc gh" title="Wert 1 — Menschenwürde (Rechtsstaatsprinzip): Werden Grundrechte geschützt? Rechtliche Gleichstellung, Schutz vor Diskriminierung.">Menschenwürde</div>
|
||||
<div class="gc gh" title="Wert 2 — Solidarität (Gemeinnutz): Wird das Gemeinwohl gefördert? Mehrwert für die Gemeinschaft, Kooperation statt Konkurrenz.">Solidarität</div>
|
||||
<div class="gc gh" title="Wert 3 — Ökologische Nachhaltigkeit (Umwelt-Verantwortung): Klimaschutz, Ressourcenschonung, Biodiversität, Kreislaufwirtschaft.">Ökologische Nachhaltigkeit</div>
|
||||
<div class="gc gh" title="Wert 4 — Soziale Gerechtigkeit (Sozialstaatsprinzip): Gerechte Verteilung, Daseinsvorsorge, soziale Absicherung, Chancengleichheit.">Soziale Gerechtigkeit</div>
|
||||
<div class="gc gh" title="Wert 5 — Transparenz & Mitbestimmung (Demokratie): Bürgerbeteiligung, Offenlegung, demokratische Prozesse, Rechenschaftspflicht.">Transparenz & Mitbestimmung</div>
|
||||
|
||||
<div class="gc gr">A · Lieferant:innen</div>
|
||||
<div class="gc clickable" onclick="showField('A1')"><strong>A1</strong><br><small>Grundrechte Lieferkette</small></div>
|
||||
<div class="gc clickable" onclick="showField('A2')"><strong>A2</strong><br><small>Nutzen Gemeinde</small></div>
|
||||
<div class="gc clickable" onclick="showField('A3')"><strong>A3</strong><br><small>Ökol. Verantwortung</small></div>
|
||||
<div class="gc clickable" onclick="showField('A4')"><strong>A4</strong><br><small>Soziale Verantwortung</small></div>
|
||||
<div class="gc clickable" onclick="showField('A5')"><strong>A5</strong><br><small>Rechenschaft</small></div>
|
||||
<div class="gc gr" title="Berührungsgruppe A — Lieferant:innen, ausgelagerte Betriebe, Dienstleister:innen. Externe Beschaffung und Lieferketten der Kommune.">A · Lieferant:innen</div>
|
||||
<div class="gc clickable" onclick="showField('A1')" title="A1 — Grundrechtsschutz in der Lieferkette"><strong>A1</strong><br><small>Grundrechte Lieferkette</small></div>
|
||||
<div class="gc clickable" onclick="showField('A2')" title="A2 — Nutzen für die Gemeinde"><strong>A2</strong><br><small>Nutzen Gemeinde</small></div>
|
||||
<div class="gc clickable" onclick="showField('A3')" title="A3 — Ökologische Verantwortung in der Lieferkette"><strong>A3</strong><br><small>Ökol. Verantwortung</small></div>
|
||||
<div class="gc clickable" onclick="showField('A4')" title="A4 — Soziale Verantwortung in der Lieferkette"><strong>A4</strong><br><small>Soziale Verantwortung</small></div>
|
||||
<div class="gc clickable" onclick="showField('A5')" title="A5 — Rechenschaft und Mitsprache bei Beschaffung"><strong>A5</strong><br><small>Rechenschaft</small></div>
|
||||
|
||||
<div class="gc gr">B · Finanzen</div>
|
||||
<div class="gc clickable" onclick="showField('B1')"><strong>B1</strong><br><small>Eth. Finanzgebaren</small></div>
|
||||
<div class="gc clickable" onclick="showField('B2')"><strong>B2</strong><br><small>Gemeinnutz</small></div>
|
||||
<div class="gc clickable" onclick="showField('B3')"><strong>B3</strong><br><small>Ökol. Finanzpolitik</small></div>
|
||||
<div class="gc clickable" onclick="showField('B4')"><strong>B4</strong><br><small>Soz. Finanzpolitik</small></div>
|
||||
<div class="gc clickable" onclick="showField('B5')"><strong>B5</strong><br><small>Partizipation</small></div>
|
||||
<div class="gc gr" title="Berührungsgruppe B — Finanzpartner:innen, Geldgeber:innen, Steuerzahler:innen. Umgang mit öffentlichen Mitteln und Haushalt.">B · Finanzen</div>
|
||||
<div class="gc clickable" onclick="showField('B1')" title="B1 — Ethisches Finanzgebaren"><strong>B1</strong><br><small>Eth. Finanzgebaren</small></div>
|
||||
<div class="gc clickable" onclick="showField('B2')" title="B2 — Gemeinnutz im Finanzgebaren"><strong>B2</strong><br><small>Gemeinnutz</small></div>
|
||||
<div class="gc clickable" onclick="showField('B3')" title="B3 — Ökologische Verantwortung der Finanzpolitik"><strong>B3</strong><br><small>Ökol. Finanzpolitik</small></div>
|
||||
<div class="gc clickable" onclick="showField('B4')" title="B4 — Soziale Verantwortung der Finanzpolitik"><strong>B4</strong><br><small>Soz. Finanzpolitik</small></div>
|
||||
<div class="gc clickable" onclick="showField('B5')" title="B5 — Partizipation in der Finanzpolitik"><strong>B5</strong><br><small>Partizipation</small></div>
|
||||
|
||||
<div class="gc gr">C · Verwaltung</div>
|
||||
<div class="gc clickable" onclick="showField('C1')"><strong>C1</strong><br><small>Gleichstellung</small></div>
|
||||
<div class="gc clickable" onclick="showField('C2')"><strong>C2</strong><br><small>Gemeinsame Ziele</small></div>
|
||||
<div class="gc clickable" onclick="showField('C3')"><strong>C3</strong><br><small>Ökol. Verhalten</small></div>
|
||||
<div class="gc clickable" onclick="showField('C4')"><strong>C4</strong><br><small>Gerechte Arbeit</small></div>
|
||||
<div class="gc clickable" onclick="showField('C5')"><strong>C5</strong><br><small>Transparenz intern</small></div>
|
||||
<div class="gc gr" title="Berührungsgruppe C — Politische Führung, Verwaltung, Ehrenamtliche. Mandatsträger:innen und Mitarbeitende der Kommune.">C · Verwaltung</div>
|
||||
<div class="gc clickable" onclick="showField('C1')" title="C1 — Individuelle Rechts- und Gleichstellung"><strong>C1</strong><br><small>Gleichstellung</small></div>
|
||||
<div class="gc clickable" onclick="showField('C2')" title="C2 — Gemeinsame Zielvereinbarung für das Gemeinwohl"><strong>C2</strong><br><small>Gemeinsame Ziele</small></div>
|
||||
<div class="gc clickable" onclick="showField('C3')" title="C3 — Förderung ökologischen Verhaltens intern"><strong>C3</strong><br><small>Ökol. Verhalten</small></div>
|
||||
<div class="gc clickable" onclick="showField('C4')" title="C4 — Gerechte Verteilung von Arbeit"><strong>C4</strong><br><small>Gerechte Arbeit</small></div>
|
||||
<div class="gc clickable" onclick="showField('C5')" title="C5 — Transparente Kommunikation intern"><strong>C5</strong><br><small>Transparenz intern</small></div>
|
||||
|
||||
<div class="gc gr">D · Bürger:innen</div>
|
||||
<div class="gc clickable" onclick="showField('D1')"><strong>D1</strong><br><small>Rechtsgleichheit</small></div>
|
||||
<div class="gc clickable" onclick="showField('D2')"><strong>D2</strong><br><small>Gesamtwohl</small></div>
|
||||
<div class="gc clickable" onclick="showField('D3')"><strong>D3</strong><br><small>Ökol. Leistung</small></div>
|
||||
<div class="gc clickable" onclick="showField('D4')"><strong>D4</strong><br><small>Soz. Leistung</small></div>
|
||||
<div class="gc clickable" onclick="showField('D5')"><strong>D5</strong><br><small>Demokratie</small></div>
|
||||
<div class="gc gr" title="Berührungsgruppe D — Bürger:innen und Wirtschaft. Wirkung innerhalb der Gemeindegrenzen, Daseinsvorsorge.">D · Bürger:innen</div>
|
||||
<div class="gc clickable" onclick="showField('D1')" title="D1 — Schutz des Individuums, Rechtsgleichheit"><strong>D1</strong><br><small>Rechtsgleichheit</small></div>
|
||||
<div class="gc clickable" onclick="showField('D2')" title="D2 — Gesamtwohl in der Gemeinde"><strong>D2</strong><br><small>Gesamtwohl</small></div>
|
||||
<div class="gc clickable" onclick="showField('D3')" title="D3 — Ökologische Gestaltung der öffentlichen Leistung"><strong>D3</strong><br><small>Ökol. Leistung</small></div>
|
||||
<div class="gc clickable" onclick="showField('D4')" title="D4 — Soziale Gestaltung der öffentlichen Leistung"><strong>D4</strong><br><small>Soz. Leistung</small></div>
|
||||
<div class="gc clickable" onclick="showField('D5')" title="D5 — Transparente Kommunikation und demokratische Einbindung"><strong>D5</strong><br><small>Demokratie</small></div>
|
||||
|
||||
<div class="gc gr">E · Gesellschaft</div>
|
||||
<div class="gc clickable" onclick="showField('E1')"><strong>E1</strong><br><small>Zukunft</small></div>
|
||||
<div class="gc clickable" onclick="showField('E2')"><strong>E2</strong><br><small>Beitrag Gesamtwohl</small></div>
|
||||
<div class="gc clickable" onclick="showField('E3')"><strong>E3</strong><br><small>Ökol. Auswirkungen</small></div>
|
||||
<div class="gc clickable" onclick="showField('E4')"><strong>E4</strong><br><small>Sozialer Ausgleich</small></div>
|
||||
<div class="gc clickable" onclick="showField('E5')"><strong>E5</strong><br><small>Demokratie global</small></div>
|
||||
<div class="gc gr" title="Berührungsgruppe E — Staat, Gesellschaft und Natur. Wirkung über die Gemeindegrenzen hinaus, Zukunft.">E · Gesellschaft & Natur</div>
|
||||
<div class="gc clickable" onclick="showField('E1')" title="E1 — Menschenwürdiges Leben für zukünftige Generationen"><strong>E1</strong><br><small>Zukunft</small></div>
|
||||
<div class="gc clickable" onclick="showField('E2')" title="E2 — Beitrag zum Gesamtwohl über die Gemeindegrenzen hinaus"><strong>E2</strong><br><small>Beitrag Gesamtwohl</small></div>
|
||||
<div class="gc clickable" onclick="showField('E3')" title="E3 — Verantwortung für ökologische Auswirkungen jenseits der Gemeinde"><strong>E3</strong><br><small>Ökol. Auswirkungen</small></div>
|
||||
<div class="gc clickable" onclick="showField('E4')" title="E4 — Beitrag zum sozialen Ausgleich"><strong>E4</strong><br><small>Sozialer Ausgleich</small></div>
|
||||
<div class="gc clickable" onclick="showField('E5')" title="E5 — Transparente und demokratische Mitbestimmung auf übergeordneter Ebene"><strong>E5</strong><br><small>Demokratie global</small></div>
|
||||
</div>
|
||||
|
||||
<details style="font-size:12px;margin-top:8px;">
|
||||
@ -365,6 +366,77 @@
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section id="prompts">
|
||||
<h2>LLM-Prompts</h2>
|
||||
<div class="v2-kasten outline-blue">
|
||||
<p>
|
||||
Volle Transparenz: hier liegen die exakten Anweisungen, mit denen das
|
||||
Sprachmodell ({{ model_name }}) jeden Antrag bewertet.
|
||||
</p>
|
||||
|
||||
<h3 style="margin-top:0.75rem;">Wie System- und User-Prompt zusammenwirken</h3>
|
||||
<p>
|
||||
Beide Prompts werden in <strong>einem einzigen API-Call</strong>
|
||||
gesendet — nicht getrennt ausgeführt. Sie fließen gemeinsam ins
|
||||
Modell-Kontextfenster und werden zusammen bewertet.
|
||||
</p>
|
||||
<table style="margin-top:0.5rem;">
|
||||
<tr>
|
||||
<th style="width:30%;">System-Prompt (statisch, ~5 KB)</th>
|
||||
<th>User-Prompt (dynamisch, pro Antrag)</th>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><strong>Wer und wie</strong> bewertet wird: GWÖ-Matrix-Definition, 25 Felder, Bewertungs-Skala, Empfehlungs-Kategorien, Ausgabe-JSON-Schema, strenge Regeln (max. 3 Verbesserungsvorschläge, wörtliche Zitate, …).</td>
|
||||
<td><strong>Was</strong> bewertet wird: BL-Spezifika, semantisch gefundene Wahlprogramm-Chunks, der Antragstext selbst, Pflicht-Fraktionen-Liste.</td>
|
||||
</tr>
|
||||
</table>
|
||||
<p>
|
||||
Das Modell wendet die Matrix-Definition aus dem System-Prompt auf
|
||||
den Antragstext aus dem User-Prompt an. Ohne System-Prompt wüsste
|
||||
es nicht <em>wonach</em> es bewerten soll; ohne User-Prompt
|
||||
hätte es nichts zu bewerten.
|
||||
</p>
|
||||
<p style="font-size:12px;opacity:0.85;">
|
||||
<strong>Warum die Trennung?</strong>
|
||||
</p>
|
||||
<ul style="font-size:12px;opacity:0.85;">
|
||||
<li><strong>Caching:</strong> Der API-Anbieter cached den System-Prompt — pro neuem Antrag werden nur die User-Tokens verrechnet.</li>
|
||||
<li><strong>Modell-Compliance:</strong> Sprachmodelle behandeln System-Anweisungen mit höherem Vertrauen, was robuster gegen Prompt-Injection aus dem Antragstext ist.</li>
|
||||
<li><strong>Wartbarkeit:</strong> statische Bewertungs-Regeln getrennt vom dynamischen Inhalt — leichter zu pflegen, leichter zu auditieren.</li>
|
||||
</ul>
|
||||
<p style="font-size:11px;opacity:0.7;">
|
||||
Quelle: <a href="https://repo.toppyr.de/tobias/gwoe-antragspruefer/src/branch/main/app/analyzer.py" target="_blank"><code>app/analyzer.py</code></a>
|
||||
(<code>get_system_prompt()</code> und <code>get_user_prompt_template()</code>);
|
||||
API-Aufruf in <a href="https://repo.toppyr.de/tobias/gwoe-antragspruefer/src/branch/main/app/adapters/qwen_bewerter.py" target="_blank"><code>app/adapters/qwen_bewerter.py</code></a>
|
||||
(Zeilen 83–85, <code>messages=[{"role":"system",…}, {"role":"user",…}]</code>).
|
||||
</p>
|
||||
|
||||
<h3 style="margin-top:1rem;">Die Prompts im Wortlaut</h3>
|
||||
<p style="font-size:12px;">
|
||||
Der User-Prompt unten ist als <em>Template</em> abgebildet — die
|
||||
Platzhalter <code>{kontext}</code>, <code>{wahlprogramm_zitate}</code>,
|
||||
<code>{antrag}</code> und <code>{pflicht_fraktionen}</code> werden
|
||||
pro Antrag mit den konkreten Inhalten gefüllt.
|
||||
</p>
|
||||
|
||||
<details style="margin-top:1rem;">
|
||||
<summary style="cursor:pointer;color:var(--ecg-teal);font-weight:700;padding:6px 0;font-family:var(--font-display);">
|
||||
System-Prompt anzeigen
|
||||
<span style="font-family:var(--font-mono);font-size:11px;opacity:0.6;font-weight:400;">({{ system_prompt|length }} Zeichen)</span>
|
||||
</summary>
|
||||
<pre style="background:var(--ecg-bg-subtle);border:1px solid var(--ecg-border);border-radius:4px;padding:14px 16px;margin-top:8px;font-family:var(--font-mono);font-size:11px;line-height:1.5;white-space:pre-wrap;word-break:break-word;color:var(--ecg-dark);overflow-x:auto;">{{ system_prompt }}</pre>
|
||||
</details>
|
||||
|
||||
<details style="margin-top:0.75rem;">
|
||||
<summary style="cursor:pointer;color:var(--ecg-teal);font-weight:700;padding:6px 0;font-family:var(--font-display);">
|
||||
User-Prompt-Template anzeigen
|
||||
<span style="font-family:var(--font-mono);font-size:11px;opacity:0.6;font-weight:400;">({{ user_prompt_template|length }} Zeichen)</span>
|
||||
</summary>
|
||||
<pre style="background:var(--ecg-bg-subtle);border:1px solid var(--ecg-border);border-radius:4px;padding:14px 16px;margin-top:8px;font-family:var(--font-mono);font-size:11px;line-height:1.5;white-space:pre-wrap;word-break:break-word;color:var(--ecg-dark);overflow-x:auto;">{{ user_prompt_template }}</pre>
|
||||
</details>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section id="qualitaet">
|
||||
<h2>Qualitätssicherung</h2>
|
||||
<div class="v2-kasten outline-green">
|
||||
|
||||
@ -92,18 +92,15 @@
|
||||
|
||||
<form class="neu-form" onsubmit="startAnalyse(event)">
|
||||
|
||||
<div id="neu-bl-hint" style="display:none;margin-bottom:14px;padding:8px 12px;background:color-mix(in srgb,var(--ecg-teal) 10%,transparent);border:1px solid var(--ecg-teal);border-radius:4px;font-family:var(--font-mono);font-size:11px;color:var(--ecg-teal);">
|
||||
Bitte zuerst ein Bundesland im Header wählen.
|
||||
</div>
|
||||
|
||||
<label for="neu-drucksache">Drucksachen-Nummer</label>
|
||||
<input type="text" id="neu-drucksache" name="drucksache"
|
||||
placeholder="z. B. 18/12345 oder NRW-18/12345"
|
||||
required autocomplete="off">
|
||||
|
||||
<label for="neu-bl">Bundesland</label>
|
||||
<select id="neu-bl" name="bundesland">
|
||||
{% for bl in bundeslaender %}
|
||||
<option value="{{ bl.code }}"{% if bl.code == 'NRW' %} selected{% endif %}>{{ bl.name }} ({{ bl.code }})</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
|
||||
<label for="neu-model">Modell</label>
|
||||
<select id="neu-model" name="model">
|
||||
<option value="">Standard ({{ default_model }})</option>
|
||||
@ -136,11 +133,17 @@ async function startAnalyse(e) {
|
||||
const errEl = document.getElementById('neu-error');
|
||||
|
||||
const drucksache = document.getElementById('neu-drucksache').value.trim();
|
||||
const bundesland = document.getElementById('neu-bl').value;
|
||||
const bundesland = (window.v2GetGlobalBl && window.v2GetGlobalBl()) || 'ALL';
|
||||
const model = document.getElementById('neu-model').value;
|
||||
|
||||
if (!drucksache) return;
|
||||
|
||||
if (bundesland === 'ALL') {
|
||||
errEl.style.display = '';
|
||||
errEl.textContent = 'Bitte zuerst ein Bundesland im Header wählen.';
|
||||
return;
|
||||
}
|
||||
|
||||
btn.disabled = true;
|
||||
statusEl.style.display = '';
|
||||
progEl.style.display = '';
|
||||
@ -154,16 +157,56 @@ async function startAnalyse(e) {
|
||||
if (model) fd.append('model', model);
|
||||
|
||||
const resp = await fetch('/api/analyze-drucksache', { method: 'POST', body: fd });
|
||||
if (resp.status === 401 || resp.status === 403) {
|
||||
progEl.style.display = 'none';
|
||||
errEl.style.display = '';
|
||||
errEl.textContent = 'Sitzung abgelaufen — bitte erneut anmelden.';
|
||||
btn.disabled = false;
|
||||
if (typeof window.v2AuthModalOpen === 'function') window.v2AuthModalOpen();
|
||||
return;
|
||||
}
|
||||
if (!resp.ok) {
|
||||
const err = await resp.json().catch(() => ({ detail: resp.statusText }));
|
||||
throw new Error(err.detail || ('HTTP ' + resp.status));
|
||||
}
|
||||
|
||||
const data = await resp.json();
|
||||
// Redirect to result page
|
||||
const ds = data.drucksache || drucksache;
|
||||
progText.textContent = 'Analyse abgeschlossen. Weiterleitung …';
|
||||
setTimeout(() => { window.location.href = '/antrag/' + encodeURIComponent(ds); }, 600);
|
||||
|
||||
if (data.status === 'already_checked') {
|
||||
progText.textContent = 'Bereits bewertet. Weiterleitung …';
|
||||
setTimeout(() => { window.location.href = '/antrag/' + encodeURIComponent(ds); }, 400);
|
||||
return;
|
||||
}
|
||||
if (data.status === 'skipped') {
|
||||
progEl.style.display = 'none';
|
||||
errEl.style.display = '';
|
||||
errEl.textContent = 'Antrag-Typ "' + (data.typ || 'unbekannt') + '" ist nicht abstimmbar — keine GWÖ-Bewertung sinnvoll.';
|
||||
btn.disabled = false;
|
||||
return;
|
||||
}
|
||||
|
||||
// Job-Polling bis Abschluss, dann redirect
|
||||
const jobId = data.job_id;
|
||||
if (!jobId) { window.location.href = '/antrag/' + encodeURIComponent(ds); return; }
|
||||
let attempts = 0;
|
||||
const maxAttempts = 90;
|
||||
while (attempts < maxAttempts) {
|
||||
await new Promise(r => setTimeout(r, 2000));
|
||||
attempts++;
|
||||
const st = await fetch('/status/' + jobId).then(r => r.json()).catch(() => null);
|
||||
if (!st) continue;
|
||||
progText.textContent = 'Analyse läuft … (' + (st.status || '?') + ', ~' + (attempts * 2) + 's)';
|
||||
if (st.status === 'completed') {
|
||||
progText.textContent = 'Fertig. Weiterleitung …';
|
||||
setTimeout(() => { window.location.href = '/antrag/' + encodeURIComponent(ds); }, 400);
|
||||
return;
|
||||
}
|
||||
if (st.status === 'failed' || st.status === 'rejected') {
|
||||
throw new Error('Analyse fehlgeschlagen: ' + (st.error || 'unbekannt'));
|
||||
}
|
||||
}
|
||||
throw new Error('Analyse-Timeout (>3 min)');
|
||||
|
||||
} catch (err) {
|
||||
progEl.style.display = 'none';
|
||||
@ -172,5 +215,15 @@ async function startAnalyse(e) {
|
||||
btn.disabled = false;
|
||||
}
|
||||
}
|
||||
|
||||
document.addEventListener('DOMContentLoaded', function () {
|
||||
var hint = document.getElementById('neu-bl-hint');
|
||||
function updateHint() {
|
||||
var bl = (window.v2GetGlobalBl && window.v2GetGlobalBl()) || 'ALL';
|
||||
if (hint) hint.style.display = (bl === 'ALL') ? '' : 'none';
|
||||
}
|
||||
updateHint();
|
||||
window.addEventListener('v2-bl-changed', updateHint);
|
||||
});
|
||||
</script>
|
||||
{% endblock %}
|
||||
|
||||
55
app/wahlprogramm-shas.lock.json
Normal file
55
app/wahlprogramm-shas.lock.json
Normal file
@ -0,0 +1,55 @@
|
||||
{
|
||||
"afd-bb-2024.pdf": "da5cd04cc66128b2f0df35b47775fce850ed2f4145ee15d74ec8bf501ce043f1",
|
||||
"afd-be-2023.pdf": "d2b5997b1bc0d3fb590cc354d8ed1ac879e8de4a74518f4089436a2fa12615f1",
|
||||
"afd-bw-2021.pdf": "a438e09279c6c5766171a213715ed0a9d60248ff86f648227e8bb6ec59a591c7",
|
||||
"afd-hh-2025.pdf": "6aae3ad00cd07824bcd99473e130d1b894e2174a89fcafece51865c51fdcd4c8",
|
||||
"afd-lsa-2021.pdf": "dd2651af2a9423039b1c5a39760be2332025d569a878453f09e0302e252edc23",
|
||||
"afd-mv-2021.pdf": "953c39941a1f997233daaf0cec01bc82b1e86ba895b43e8d34b015cc72799648",
|
||||
"afd-nrw-2022.pdf": "36c4bc55c3239e3f7e69568e19d7f074ce2f1cf018653d493767ec09df637282",
|
||||
"afd-rp-2021.pdf": "3ec39eb08a073244813a51f260e18fe52aab791bea26bf8079546b6e189ec2b3",
|
||||
"afd-th-2024.pdf": "26e61fdc3456e7ce18f7a3d2ea1eada303f93cad0b9698797f83a671574eaf51",
|
||||
"bsw-bb-2024.pdf": "548c9bda01af176586606fae708c9f3b3ba98e1e128f1e2ff39e482289faab42",
|
||||
"bsw-th-2024.pdf": "5ace33912083048a759ee2af9288248447363dafa21f569c5c056df22751ba69",
|
||||
"cdu-bb-2024.pdf": "460b1463483429f9e8b84e4ae6ef9cf878dd228e108411bed3c153169a0001e8",
|
||||
"cdu-be-2023.pdf": "813d0d08ac8ce7381e9a7b9472e0616aaf684b1632c9d4a7f4e940a33455f29a",
|
||||
"cdu-bw-2021.pdf": "a92c104c456ce06d8bad6649071551e0ec0d525a1bc0bc31e9fa6a0566da4db0",
|
||||
"cdu-hh-2025.pdf": "8d29e514b8bce5c2f3f497dc5b97f6f8ab95a7bdbf619abf258e9582d57f2dbd",
|
||||
"cdu-lsa-2021.pdf": "63b6cf42ce97834d5d105fb7b8cc7fb7a2aa96928d4153bd3a5858c196ee0797",
|
||||
"cdu-mv-2021.pdf": "605a2211bef8666c2103771ebffd97a088e7cdb1545401087ef125155e7e4db2",
|
||||
"cdu-nrw-2022.pdf": "49d97a6f30fbacad3a0b770c182ed0527bc5d347dc4cacd65f85e7e4e9644566",
|
||||
"cdu-rp-2021.pdf": "54c50d88bdf5c5f7dee5abcc981ffb4d1cfd5c86fbf2a29f4f2f4a8a3dd4797a",
|
||||
"cdu-sh-2022.pdf": "39b79a22e904b300cf1bbc25752b618195683c90c31e6b10c3bc0e8408aa6a1a",
|
||||
"cdu-th-2024.pdf": "cde8d2222bd8ce04aee24883a38dab8a30f5d60cda115b8bb2f43ceffa08b730",
|
||||
"fdp-bw-2021.pdf": "bdcbb1b2e5748922c8347bd69ea6f81c954fd02cd220d448400f9a5a86ce914b",
|
||||
"fdp-lsa-2021.pdf": "3d4275e36e29c0b191dcc4a29061a1072920f868cc52bee954bf81491ad15224",
|
||||
"fdp-mv-2021.pdf": "8dc341dd017f1d82c51608a26e1fd6c3d8acd1281dc37409e375389999b37b55",
|
||||
"fdp-nrw-2022.pdf": "576b42a26c29ca5d8b7469d417ae709c8d0699aed5195d4ca16dd696dcff8bea",
|
||||
"fdp-rp-2021.pdf": "fba792d8d43842f33ae8f0aa94b0d4e50838908c217402b4c5cb4707f958e1ae",
|
||||
"fdp-sh-2022.pdf": "4c49da411bb3c8e008f4b57dd20dc005104515b56056ff746cf5403529728d09",
|
||||
"fw-rp-2021.pdf": "c7f26d553f24c9d9fcf1c2edb1dbe558edc1ca65af68b289a1541e77f7bbeea8",
|
||||
"gruene-be-2023.pdf": "2b14a319cdcd2ca022399254ea285714f872eddd166f3f537861eeb2dc5ade80",
|
||||
"gruene-bw-2021.pdf": "9af526705cb10b91be0690b26c9c033668a8082eeefca482dc4e7a46f2d671f9",
|
||||
"gruene-hh-2025.pdf": "4428d1cdc16b4e74588f0bd51145ab7371f9e0871a2fc9d25a1f94e4f5aeb662",
|
||||
"gruene-lsa-2021.pdf": "7b5cea92cd600283d7edf18dc0d358c0b7d78d7269589d9ef05de7d5f8b35998",
|
||||
"gruene-mv-2021.pdf": "40f0070743ef9ae7808cab319234b4c83faa53a8a098ba8a82f28023bee4d9f6",
|
||||
"gruene-nrw-2022.pdf": "2d7eaf2f4b73e0b7cdccf8641208b86d306b654ead5706d72c446965f82e5769",
|
||||
"gruene-rp-2021.pdf": "4fd68629d1560c28d61b2b913fd20ce6ad9a76b22823fd8496e51bfaf70dc19c",
|
||||
"gruene-sh-2022.pdf": "62870c948c9e05663125b051d3a6401d63952ea6a64e4140dcece7bd1b1aea52",
|
||||
"linke-be-2023.pdf": "7d6a9166f6a1d87ba26cc1a2818ae2b844ee9df6ed6668673f329dd5186fd956",
|
||||
"linke-hh-2025.pdf": "15e68efe3818758a7cefc0a3e3095a5a5fb191111c00a1202c563cee43ce6e40",
|
||||
"linke-lsa-2021.pdf": "f269c014416b213785badf7bea5928fdb847fc902e09f52ec66a140a37e03d75",
|
||||
"linke-mv-2021.pdf": "160dad56ab4de8f641c21f51cbf3c33953f2f3d91b4de792c4e725f3975fdfbe",
|
||||
"linke-th-2024.pdf": "2d8ca99ef60cbe1b59cf33b1e37320d66a057e5136c2f49aa8cde77e4a19533a",
|
||||
"spd-bb-2024.pdf": "4131f63fbb9d67cd8948ca7a54f1c140b47968c77454a3dabe6bcdc4384f63d3",
|
||||
"spd-be-2023.pdf": "4ee84e969e97894742673f940ec030883216ce852b729507327f8bced637d03b",
|
||||
"spd-bw-2021.pdf": "d888ae92bb62a61aaa4d6ac8dc22c2c98d1a2227b6ba223b6422770672825072",
|
||||
"spd-hh-2025.pdf": "5e8c57969cb3b159b9299c173831f7863ab81bd206c2a87ae232ba96f23156ee",
|
||||
"spd-lsa-2021.pdf": "59140aa1921ab0ee85142d74e1d72b1af7254da3f7870a30460abd605d280333",
|
||||
"spd-mv-2021.pdf": "c8c671c2e60f1a4f8048bd74e379eb8edc69ab2daeb09581fe83f25f6c87d529",
|
||||
"spd-nrw-2022.pdf": "6f1375add74a532cb084dee10c3e5a6215e7d4118ddd26ef0d27bf39765d19a6",
|
||||
"spd-rp-2021.pdf": "13966815b8870b30e3480673437634fb90882bf5410c652694a6579492e32707",
|
||||
"spd-sh-2022.pdf": "3acd3ed6c42a0e0a8f49abd76610b536c7d5fdf13fcc4499e391bc9b1a3d0f0f",
|
||||
"spd-th-2024.pdf": "dbd96a51134c8c13dabe18807fe233e9a43f45c2fefeead2ea500ecc3d63de6b",
|
||||
"ssw-sh-2022.pdf": "3020762a1c33a09bc51f7fa49ede1c2d5dd7574ea74ef262076e59d5e3a9a41b",
|
||||
"test.pdf": "71630b3ce93b3fd91aefa095908c8070d07e0eca8ad3071c60ae7375da2e7e17"
|
||||
}
|
||||
@ -16,6 +16,7 @@ CLI:
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
import json
|
||||
import logging
|
||||
import urllib.request
|
||||
from pathlib import Path
|
||||
@ -26,9 +27,39 @@ import yaml
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
_LINKS_FILE = Path(__file__).parent / "wahlprogramm-links.yaml"
|
||||
_LOCK_FILE = Path(__file__).parent / "wahlprogramm-shas.lock.json"
|
||||
_REFERENZEN_DIR = Path(__file__).parent / "static" / "referenzen"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# SHA-Lock — schuetzt vor stillem PDF-Austausch unter gleicher URL.
|
||||
# Hintergrund: abgeordnetenwatch hat die CDU-BE-2023-Datei intern gegen den
|
||||
# 2026-Berlin-Plan ersetzt, ohne den Slug zu aendern. Nach dem ersten
|
||||
# erfolgreichen Download wird der SHA-256 hier gepinnt; spaetere fetches
|
||||
# vergleichen gegen den Lock und brechen bei Abweichung ab.
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def _load_lock() -> dict[str, str]:
|
||||
if not _LOCK_FILE.exists():
|
||||
return {}
|
||||
try:
|
||||
return json.loads(_LOCK_FILE.read_text(encoding="utf-8"))
|
||||
except (json.JSONDecodeError, OSError) as exc:
|
||||
logger.error("Lock-File %s ist kaputt: %s — leerer Lock genutzt", _LOCK_FILE, exc)
|
||||
return {}
|
||||
|
||||
|
||||
def _save_lock(lock: dict[str, str]) -> None:
|
||||
_LOCK_FILE.write_text(
|
||||
json.dumps(lock, indent=2, sort_keys=True, ensure_ascii=False) + "\n",
|
||||
encoding="utf-8",
|
||||
)
|
||||
|
||||
|
||||
def _lock_key(dateiname: str) -> str:
|
||||
return dateiname
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# YAML-Quelle laden
|
||||
# ---------------------------------------------------------------------------
|
||||
@ -78,32 +109,45 @@ def fetch_and_verify(
|
||||
url: str,
|
||||
dest_path: Path,
|
||||
expected_sha: Optional[str] = None,
|
||||
*,
|
||||
accept_new_sha: bool = False,
|
||||
) -> dict:
|
||||
"""Lädt eine Datei herunter und prüft optional den SHA-256-Hash.
|
||||
"""Lädt eine Datei herunter und prüft den SHA-256-Hash gegen den Lock.
|
||||
|
||||
SHA-Gate-Logik:
|
||||
- Existiert ``dest_path`` bereits, wird der bisherige Hash gespeichert.
|
||||
- Nach dem Download wird der neue Hash verglichen.
|
||||
- Bei Abweichung wird die temporäre Datei gelöscht und ein Fehler zurückgegeben
|
||||
(niemals stillschweigend überschreiben).
|
||||
SHA-Gate-Logik (Pferdetausch-Schutz):
|
||||
- Beim ersten erfolgreichen Download wird der SHA in
|
||||
``wahlprogramm-shas.lock.json`` gepinnt.
|
||||
- Spätere fetches vergleichen gegen diesen gepinnten SHA. Abweichung →
|
||||
Abbruch, ausser ``accept_new_sha=True`` ist gesetzt (dann wird der Lock
|
||||
explizit aktualisiert).
|
||||
- ``expected_sha`` (z.B. aus YAML) ueberschreibt den Lock fuer diesen Call.
|
||||
|
||||
Args:
|
||||
url: Download-URL der PDF-Datei.
|
||||
dest_path: Ziel-Pfad (typischerweise in app/static/referenzen/).
|
||||
expected_sha: Wenn angegeben, muss der Download-Hash übereinstimmen.
|
||||
expected_sha: Wenn angegeben, muss der Download-Hash übereinstimmen
|
||||
(haerter als der Lock-Vergleich).
|
||||
accept_new_sha: Wenn True, wird der Lock auf den neuen SHA aktualisiert
|
||||
statt bei Abweichung abzubrechen. NICHT default — Maintainer-Override.
|
||||
|
||||
Returns:
|
||||
Dict mit den Schlüsseln:
|
||||
- ``ok`` (bool): True bei Erfolg.
|
||||
- ``sha256`` (str): SHA-256 der heruntergeladenen Datei.
|
||||
- ``prev_sha256`` (str|None): SHA-256 der bisherigen Datei, falls vorhanden.
|
||||
- ``locked_sha256`` (str|None): SHA aus dem Lock-File (vor diesem Call).
|
||||
- ``error`` (str|None): Fehlermeldung bei Misserfolg.
|
||||
- ``changed`` (bool): True, wenn sich die Datei gegenüber der bisherigen Version geändert hat.
|
||||
- ``changed`` (bool): True, wenn sich die Datei geaendert hat.
|
||||
- ``lock_updated`` (bool): True, wenn der Lock-Eintrag neu/ersetzt wurde.
|
||||
"""
|
||||
prev_sha: Optional[str] = None
|
||||
if dest_path.exists():
|
||||
prev_sha = sha256_of_file(dest_path)
|
||||
|
||||
lock = _load_lock()
|
||||
lock_key = _lock_key(dest_path.name)
|
||||
locked_sha = lock.get(lock_key)
|
||||
|
||||
tmp_path = dest_path.with_suffix(".tmp")
|
||||
try:
|
||||
logger.info("Lade %s → %s", url, tmp_path)
|
||||
@ -119,39 +163,71 @@ def fetch_and_verify(
|
||||
|
||||
new_sha = sha256_of_file(tmp_path)
|
||||
|
||||
# SHA-Gate gegen expected_sha
|
||||
# SHA-Gate gegen expected_sha (haerter, aus YAML kuratiert)
|
||||
if expected_sha and new_sha != expected_sha:
|
||||
tmp_path.unlink(missing_ok=True)
|
||||
return {
|
||||
"ok": False,
|
||||
"sha256": new_sha,
|
||||
"prev_sha256": prev_sha,
|
||||
"locked_sha256": locked_sha,
|
||||
"changed": False,
|
||||
"lock_updated": False,
|
||||
"error": (
|
||||
f"SHA-Prüfung fehlgeschlagen: erwartet {expected_sha[:12]}…, "
|
||||
f"erhalten {new_sha[:12]}…"
|
||||
f"SHA-Pruefung gegen erwarteten Hash fehlgeschlagen: "
|
||||
f"erwartet {expected_sha[:12]}…, erhalten {new_sha[:12]}…"
|
||||
),
|
||||
}
|
||||
|
||||
# SHA-Gate gegen bisherige Datei
|
||||
# SHA-Gate gegen Lock-File (Pferdetausch-Schutz)
|
||||
if locked_sha and new_sha != locked_sha and not accept_new_sha:
|
||||
tmp_path.unlink(missing_ok=True)
|
||||
return {
|
||||
"ok": False,
|
||||
"sha256": new_sha,
|
||||
"prev_sha256": prev_sha,
|
||||
"locked_sha256": locked_sha,
|
||||
"changed": False,
|
||||
"lock_updated": False,
|
||||
"error": (
|
||||
f"Lock-Pruefung fehlgeschlagen: gepinnt {locked_sha[:12]}…, "
|
||||
f"jetzt {new_sha[:12]}…. Pferdetausch-Verdacht — Inhalt manuell "
|
||||
f"pruefen, dann mit --accept-new-sha bestaetigen."
|
||||
),
|
||||
}
|
||||
|
||||
# SHA-Gate gegen bisherige Datei (no-op)
|
||||
if prev_sha and new_sha == prev_sha:
|
||||
tmp_path.unlink(missing_ok=True)
|
||||
logger.info("Datei unverändert (SHA %s…), kein Überschreiben.", new_sha[:12])
|
||||
lock_updated = False
|
||||
if locked_sha != new_sha:
|
||||
# Datei war schon korrekt, Lock fehlte — initialer Pin.
|
||||
lock[lock_key] = new_sha
|
||||
_save_lock(lock)
|
||||
lock_updated = True
|
||||
logger.info("Datei unveraendert (SHA %s…), kein Ueberschreiben.", new_sha[:12])
|
||||
return {
|
||||
"ok": True,
|
||||
"sha256": new_sha,
|
||||
"prev_sha256": prev_sha,
|
||||
"locked_sha256": locked_sha,
|
||||
"changed": False,
|
||||
"lock_updated": lock_updated,
|
||||
"error": None,
|
||||
}
|
||||
|
||||
tmp_path.rename(dest_path)
|
||||
# Lock aktualisieren — initialer Pin oder bewusstes Update via accept_new_sha
|
||||
lock[lock_key] = new_sha
|
||||
_save_lock(lock)
|
||||
logger.info("Gespeichert: %s (SHA %s…)", dest_path.name, new_sha[:12])
|
||||
return {
|
||||
"ok": True,
|
||||
"sha256": new_sha,
|
||||
"prev_sha256": prev_sha,
|
||||
"locked_sha256": locked_sha,
|
||||
"changed": True,
|
||||
"lock_updated": True,
|
||||
"error": None,
|
||||
}
|
||||
|
||||
@ -162,7 +238,9 @@ def fetch_and_verify(
|
||||
"ok": False,
|
||||
"sha256": "",
|
||||
"prev_sha256": prev_sha,
|
||||
"locked_sha256": locked_sha,
|
||||
"changed": False,
|
||||
"lock_updated": False,
|
||||
"error": str(exc),
|
||||
}
|
||||
|
||||
@ -225,8 +303,39 @@ def _cli() -> None:
|
||||
parser.add_argument("--url", help="URL überschreiben (statt erster Kandidat aus YAML)")
|
||||
parser.add_argument("--yes", action="store_true",
|
||||
help="Nicht interaktiv bestätigen (gefährlich)")
|
||||
parser.add_argument("--accept-new-sha", action="store_true",
|
||||
help="Bei Lock-Mismatch: neuen SHA in den Lock uebernehmen (Pferdetausch-Override)")
|
||||
parser.add_argument("--pin-existing", action="store_true",
|
||||
help="Alle bereits vorhandenen PDFs in static/referenzen/ in den Lock pinnen "
|
||||
"(einmalig nach Einfuehrung des Lock-Files)")
|
||||
args = parser.parse_args()
|
||||
|
||||
if args.pin_existing:
|
||||
from .wahlprogramme import WAHLPROGRAMME
|
||||
|
||||
lock = _load_lock()
|
||||
added = 0
|
||||
for bl, parteien in WAHLPROGRAMME.items():
|
||||
for partei, info in parteien.items():
|
||||
dateiname = info.get("file") if isinstance(info, dict) else None
|
||||
if not dateiname:
|
||||
continue
|
||||
pdf_path = _REFERENZEN_DIR / dateiname
|
||||
if not pdf_path.exists():
|
||||
continue
|
||||
key = _lock_key(dateiname)
|
||||
if key in lock:
|
||||
continue
|
||||
lock[key] = sha256_of_file(pdf_path)
|
||||
added += 1
|
||||
print(f" pinned {bl}/{partei}: {dateiname} → {lock[key][:12]}…")
|
||||
if added:
|
||||
_save_lock(lock)
|
||||
print(f"\n{added} neue Eintraege in {_LOCK_FILE.name}.")
|
||||
else:
|
||||
print("Keine neuen Eintraege — alle vorhandenen PDFs sind bereits gepinnt.")
|
||||
sys.exit(0)
|
||||
|
||||
if args.check:
|
||||
missing = get_missing_programmes(args.bl)
|
||||
if not missing:
|
||||
@ -272,12 +381,14 @@ def _cli() -> None:
|
||||
print("Abgebrochen.")
|
||||
sys.exit(0)
|
||||
|
||||
result = fetch_and_verify(url, dest)
|
||||
result = fetch_and_verify(url, dest, accept_new_sha=args.accept_new_sha)
|
||||
if result["ok"]:
|
||||
change_note = "geändert" if result["changed"] else "unverändert"
|
||||
change_note = "geaendert" if result["changed"] else "unveraendert"
|
||||
print(f"OK ({change_note}) — SHA-256: {result['sha256'][:16]}…")
|
||||
if result["lock_updated"]:
|
||||
print(f"Lock aktualisiert in {_LOCK_FILE.name}.")
|
||||
if result["changed"]:
|
||||
print("Hinweis: Embeddings müssen neu indexiert werden (python -m app.reindex_embeddings).")
|
||||
print("Hinweis: Embeddings muessen neu indexiert werden (python -m app.reindex_embeddings).")
|
||||
else:
|
||||
print(f"FEHLER: {result['error']}")
|
||||
sys.exit(1)
|
||||
|
||||
44
docker-compose.dev.yml
Normal file
44
docker-compose.dev.yml
Normal file
@ -0,0 +1,44 @@
|
||||
# Dev-Compose fuer gwoe-dev.toppyr.de.
|
||||
# Auto-Deploy via Cron: docker compose -f docker-compose.dev.yml up -d --build
|
||||
# Datenbank, Wahlprogramme, Reports: separate Volumes (am Server: /opt/gwoe-antragspruefer-dev/{data,reports})
|
||||
# Mail: bewusst nicht aktiv (kein SMTP-Block)
|
||||
# Keycloak: eigener Public-Client gwoe-antragspruefer-dev
|
||||
services:
|
||||
gwoe-antragspruefer-dev:
|
||||
build: .
|
||||
container_name: gwoe-antragspruefer-dev
|
||||
restart: unless-stopped
|
||||
stop_grace_period: 15m
|
||||
environment:
|
||||
- DASHSCOPE_API_KEY=${DASHSCOPE_API_KEY}
|
||||
- KEYCLOAK_URL=https://sso.toppyr.de
|
||||
- KEYCLOAK_REALM=collaboration
|
||||
- KEYCLOAK_CLIENT_ID=${KEYCLOAK_CLIENT_ID:-gwoe-antragspruefer-dev}
|
||||
- KEYCLOAK_CLIENT_SECRET=${KEYCLOAK_CLIENT_SECRET}
|
||||
- KEYCLOAK_ADMIN_USER=${KEYCLOAK_ADMIN_USER}
|
||||
- KEYCLOAK_ADMIN_PASSWORD=${KEYCLOAK_ADMIN_PASSWORD}
|
||||
- EMBEDDING_MODEL_WRITE=${EMBEDDING_MODEL_WRITE:-text-embedding-v4}
|
||||
- EMBEDDING_MODEL_READ=${EMBEDDING_MODEL_READ:-text-embedding-v3}
|
||||
- BASE_URL=${BASE_URL:-https://gwoe-dev.toppyr.de}
|
||||
- GITEA_TOKEN=${GITEA_TOKEN}
|
||||
- GITEA_API_URL=${GITEA_API_URL:-https://repo.toppyr.de/api/v1}
|
||||
- GITEA_REPO_OWNER=${GITEA_REPO_OWNER:-tobias}
|
||||
- GITEA_REPO_NAME=${GITEA_REPO_NAME:-gwoe-antragspruefer}
|
||||
- GITEA_FEEDBACK_LABELS=${GITEA_FEEDBACK_LABELS:-feedback,dev}
|
||||
- APP_ENV=dev
|
||||
volumes:
|
||||
- ./data:/app/data
|
||||
- ./reports:/app/reports
|
||||
labels:
|
||||
- "traefik.enable=true"
|
||||
- "traefik.http.routers.gwoe-dev.rule=Host(`gwoe-dev.toppyr.de`)"
|
||||
- "traefik.http.routers.gwoe-dev.entrypoints=websecure"
|
||||
- "traefik.http.routers.gwoe-dev.tls=true"
|
||||
- "traefik.http.routers.gwoe-dev.tls.certresolver=letsencrypt"
|
||||
- "traefik.http.services.gwoe-dev.loadbalancer.server.port=8000"
|
||||
networks:
|
||||
- collaboration_collaboration
|
||||
|
||||
networks:
|
||||
collaboration_collaboration:
|
||||
external: true
|
||||
@ -23,6 +23,11 @@ services:
|
||||
- SMTP_FROM_NAME=${SMTP_FROM_NAME:-GWÖ-Antragsprüfer}
|
||||
- UNSUBSCRIBE_SECRET=${UNSUBSCRIBE_SECRET}
|
||||
- BASE_URL=${BASE_URL:-https://gwoe.toppyr.de}
|
||||
# Gitea-Anbindung fuer Feedback-Widget (#149-Folge)
|
||||
- GITEA_TOKEN=${GITEA_TOKEN}
|
||||
- GITEA_API_URL=${GITEA_API_URL:-https://repo.toppyr.de/api/v1}
|
||||
- GITEA_REPO_OWNER=${GITEA_REPO_OWNER:-tobias}
|
||||
- GITEA_REPO_NAME=${GITEA_REPO_NAME:-gwoe-antragspruefer}
|
||||
volumes:
|
||||
- ./data:/app/data
|
||||
- ./reports:/app/reports
|
||||
|
||||
120
docs/adr/0007-test-taxonomy.md
Normal file
120
docs/adr/0007-test-taxonomy.md
Normal file
@ -0,0 +1,120 @@
|
||||
# 0007 — Test-Taxonomie (Unit / Integration / E2E / Property / Smoke)
|
||||
|
||||
| | |
|
||||
|---|---|
|
||||
| **Status** | accepted |
|
||||
| **Datum** | 2026-04-28 |
|
||||
| **Refs** | #50 (Umbrella E2E Functional Acceptance), #51-54 (Sub-A-D), #134 (Phase 3 Audit), ADR 0003 |
|
||||
|
||||
## Kontext
|
||||
|
||||
Die Test-Suite ist organisch ueber drei Epochen gewachsen:
|
||||
|
||||
1. **Original Unit-Suite** (#46, #91) — `tests/conftest.py` stubbt
|
||||
`fitz`/`bs4`/`openai`/`pydantic_settings`. Lokal in Sekunden lauffaehig,
|
||||
keine externen Calls, keine Live-Daten.
|
||||
2. **E2E Functional Acceptance** (#50 Umbrella) — `tests/integration/`
|
||||
mit eigenem `conftest.py`, das die Stubs *nicht* setzt. HTTP gegen
|
||||
echte Landtags-Portale, PDF-Parsing mit echtem `fitz`, DB-Lookups
|
||||
gegen `embeddings.db`. Marker `integration`.
|
||||
3. **Playwright UI-Tests** (#120) — `tests/e2e/test_ui.py`, headless
|
||||
Chromium gegen die laufende App. Marker `e2e`.
|
||||
|
||||
Mit dem Backfill aus #134 kamen zusaetzlich:
|
||||
|
||||
- **Property-/Substring-Tests** (ADR 0003) fuer LLM-Zitate gegen PDF-Seiten
|
||||
- **Smoke-Tests** (`test_endpoints_smoke.py`) — Endpoints nur auf
|
||||
Antwortcode + Format pruefen, kein Geschaeftslogik-Detail
|
||||
|
||||
Ohne klare Taxonomie weiss niemand, wo ein neuer Test hingehoert. Folge:
|
||||
ad-hoc Tests werden in `tests/` abgelegt, Marker werden vergessen, und
|
||||
beim CI-Lauf brennen langsame Tests die schnellen mit ab.
|
||||
|
||||
## Optionen
|
||||
|
||||
### Option A — Flacher Test-Ordner ohne formale Kategorien
|
||||
|
||||
Status quo bis zu #50: alle Tests unter `tests/`, Marker frei waehlbar.
|
||||
**Vorteile:** keine Migrationskosten, niedrige kognitive Last.
|
||||
**Nachteile:** Kategorien implizit, Stub-Setup kollidiert mit echten
|
||||
Imports, Lauf-Dauer schwankt unvorhersehbar.
|
||||
|
||||
### Option B — Drei harte Verzeichnisse (`tests/unit/`, `tests/integration/`, `tests/e2e/`)
|
||||
|
||||
Strenge raeumliche Trennung mit jeweils eigenem `conftest.py`.
|
||||
**Vorteile:** Stub-Konflikte ausgeschlossen, einfaches `pytest tests/unit/`.
|
||||
**Nachteile:** grosse Migration; viele bestehende Test-Files muessten
|
||||
in `unit/` umziehen; verschachtelte Pfade werden vom Test-Runner und
|
||||
von Reports etwas sperriger.
|
||||
|
||||
### Option C — Flacher Ordner + verbindliche Marker (gewaehlt)
|
||||
|
||||
`tests/` flach, aber **jede** Datei traegt einen klaren Kategorie-Marker
|
||||
(`integration`, `e2e`, `slow`) oder ist Default-Unit. Neue
|
||||
Sub-Verzeichnisse nur wenn sie strukturell notwendig sind (z.B.
|
||||
`tests/integration/` weil dort ein anderer `conftest.py` lebt — keine
|
||||
Stubs).
|
||||
|
||||
**Vorteile:** wenig Migrationsschmerz, Default-Run laeuft schnell, aber
|
||||
opt-in zu langsamen Suiten ist explizit (`pytest -m integration`).
|
||||
**Nachteile:** Disziplin noetig, Marker mssen gepflegt werden.
|
||||
|
||||
## Entscheidung
|
||||
|
||||
**Option C** mit folgender expliziter Taxonomie:
|
||||
|
||||
| Typ | Marker | Verzeichnis | Latenz | Was ist erlaubt |
|
||||
|---|---|---|---|---|
|
||||
| **Unit** | (none, default) | `tests/*.py` | < 100 ms / Test | Reines Python, alle externen Dependencies gestubbed in `tests/conftest.py`. Domain-Logik, Validatoren, Pure Functions, Datenstrukturen. |
|
||||
| **Smoke** | (none, default) | `tests/test_*_smoke.py` | < 200 ms / Test | TestClient gegen `app.main`, nur Status-Code + Pflicht-Felder pruefen. Skipped wenn `app.main` nicht importierbar. |
|
||||
| **Property** | (none, default) | `tests/test_citations_substring.py` u.a. | < 500 ms / Test | Invarianten-Checks gegen Fixture-Corpus. Substrings, Strukturmuster. PDF-Parsing erlaubt, aber nur gegen Fixtures im Repo. |
|
||||
| **Integration** | `integration` | `tests/integration/` | < 5 s / Test, gesamt < 5 min | Echtes HTTP gegen Landtags-Portale, echtes `fitz` gegen reale PDFs, DB-Lookups gegen `embeddings.db`. Eigenes `conftest.py` ohne Stubs. Opt-in via `pytest -m integration`. |
|
||||
| **E2E** | `e2e` | `tests/e2e/` | < 30 s / Test | Headless-Chromium gegen lokal laufende App oder Prod-URL. Tests koennen flaky sein — werden NICHT von Default-Run getriggert. |
|
||||
| **Slow** | `slow` | (queruerend) | beliebig | Marker-Suffix zu jedem Typ. Ausschliessbar via `pytest -m "not slow"`. Beispiel: ein Integration-Test, der pro BL einen Wahlprogramm-PDF herunterlaedt. |
|
||||
|
||||
**Lauf-Konvention** (verbindlich, in `pytest.ini` definiert):
|
||||
|
||||
```bash
|
||||
pytest # Default — Unit + Smoke + Property, ~1s
|
||||
pytest -m integration # nur E2E-Functional-Acceptance, ~5 min
|
||||
pytest -m "integration and not slow" # E2E ohne PDF-Downloads
|
||||
pytest -m e2e # nur Playwright-UI-Tests
|
||||
pytest -m "" tests/ # ALLES (auch lokal selten gebraucht)
|
||||
```
|
||||
|
||||
**Naming-Konvention:**
|
||||
- `test_<modul>.py` — Unit-Tests fuer ein Modul
|
||||
- `test_<feature>_smoke.py` — Smoke-Tests
|
||||
- `test_<feature>_substring.py` / `_substring_*` — Property-Tests
|
||||
- Integration- und E2E-Tests heissen wie das Feature, das sie testen
|
||||
(z.B. `test_adapters_live.py`, `test_ui.py`).
|
||||
|
||||
## Konsequenzen
|
||||
|
||||
### Positiv
|
||||
|
||||
- Default-Run bleibt schnell (< 2s) — niemand wartet bei jedem Save.
|
||||
- Klar, wo neue Tests landen: jeder neue Test im Default-Ordner ist
|
||||
ein **Unit-Test** mit Stubs; alles, was Live-HTTP/PDF/LLM braucht,
|
||||
geht zwingend nach `tests/integration/`.
|
||||
- CI kann Default-Suite als Pre-Commit-Gate nutzen, Integration-Suite
|
||||
nightly oder pre-deploy.
|
||||
|
||||
### Negativ
|
||||
|
||||
- Disziplin noetig: Marker vergessen → langsame Tests im Default-Run
|
||||
oder unbemerkte Lueckentest. Code-Review muss darauf achten.
|
||||
- Smoke-Tests sind technisch keine Unit-Tests (importieren `app.main`),
|
||||
aber wir behandeln sie wegen geringer Latenz als Default. Ausnahme
|
||||
bewusst akzeptiert.
|
||||
|
||||
### Folgen fuer andere ADRs
|
||||
|
||||
- **ADR 0003** (Sub-D Citation-Property-Tests) bleibt gueltig; Property-Tests
|
||||
sind hier explizit als eigene Kategorie verortet.
|
||||
- Folge-Issue: Coverage-Baseline (`.coveragerc` mit `fail_under` pro
|
||||
Modul) — nicht im Skopus dieses ADRs, sondern eigenstaendig in
|
||||
Phase 3 von #134.
|
||||
- Folge-Arbeit: einzelne bestehende Test-Files umtaggen, falls sie
|
||||
faktisch Integration sind aber als Unit liefen (Audit ergab: keine
|
||||
bekannten Faelle, alle Live-Calls liegen in `tests/integration/`).
|
||||
127
docs/adr/0009-protokoll-parser-registry.md
Normal file
127
docs/adr/0009-protokoll-parser-registry.md
Normal file
@ -0,0 +1,127 @@
|
||||
# 0009 — Plenarprotokoll-Parser-Registry pro Bundesland
|
||||
|
||||
| | |
|
||||
|---|---|
|
||||
| **Status** | accepted |
|
||||
| **Datum** | 2026-04-28 |
|
||||
| **Refs** | #106, #126, ADR 0002 (Adapter-Pattern) |
|
||||
|
||||
## Kontext
|
||||
|
||||
Der NRW-Plenarprotokoll-Parser (#106) ist deterministisch, anchor-basiert
|
||||
und erreicht 19/19 auf der MMP18-119-Fixture. Damit war die Architektur-Frage
|
||||
gelöst — aber nur fuer NRW. Andere Bundeslaender publizieren ihre
|
||||
Plenarprotokolle in fundamental anderen Formaten:
|
||||
|
||||
- Hessen: HTML mit semantischen Tags pro Beschluss
|
||||
- Brandenburg: PDF mit Tabellen-Layout fuer Vote-Counts
|
||||
- Mecklenburg-Vorpommern: ParLDok-XML-Export
|
||||
- Berlin: PDF mit eigenem Formularkasten-Schema
|
||||
- ...
|
||||
|
||||
Ein einziger Parser fuer alle BL ist nicht baubar. Die Reverse-Engineering-
|
||||
Arbeit pro Landtag ist substantiell und passiert phasenweise: zuerst NRW
|
||||
wegen der hohen Antragsdichte, danach BL fuer BL nach Bedarf.
|
||||
|
||||
Das Adapter-Pattern aus ADR 0002 (`ParlamentAdapter`) hat dieses Problem
|
||||
fuer die Antrags-Suche bereits geloest. Plenarprotokoll-Parser ist die
|
||||
naechste Familie mit derselben Form: pro BL eine eigene Implementierung,
|
||||
ein gemeinsamer Aufruf-Vertrag, ein Registry-Lookup.
|
||||
|
||||
## Optionen
|
||||
|
||||
### Option A — Eine grosse Datei mit If-Else-Dispatch
|
||||
|
||||
Eine einzige `app/protokoll_parser.py`-Datei mit einem `parse_protocol(bl, pdf)`,
|
||||
das je nach BL andere Funktionen ruft. **Vorteile:** flach, einfach.
|
||||
**Nachteile:** waechst zur 2000-LOC-Datei, BL-spezifische Reverse-Engineering-
|
||||
Notizen und Helper-Functions vermischen sich, schlechte Test-Isolation.
|
||||
|
||||
### Option B — OOP-Hierarchie mit `ProtokollParserBase` als ABC
|
||||
|
||||
Abstrakte Basisklasse mit `parse(pdf_path) -> list[VoteResult]`,
|
||||
konkrete Subklassen pro BL. **Vorteile:** typisierter Vertrag.
|
||||
**Nachteile:** Boilerplate fuer Klassen-Definitionen ohne Mehrwert,
|
||||
weil der NRW-Parser keine Instanz-State hat (alles `def`-Funktionen,
|
||||
keine `self.x`).
|
||||
|
||||
### Option C — Sub-Package mit Funktions-Registry (gewaehlt)
|
||||
|
||||
`app/protokoll_parsers/` als Sub-Package, pro BL eine eigene Datei
|
||||
(`nrw.py`, `mv.py`, `he.py`, ...) die mindestens
|
||||
`parse_protocol(pdf_path: str) -> list[dict]` exportiert. Eine
|
||||
`PROTOKOLL_PARSERS`-Dict in `__init__.py` mappt BL-Code → Funktion.
|
||||
Das BL-uebergreifende `parse_protocol(bl, pdf_path)` macht den Lookup.
|
||||
|
||||
**Vorteile:**
|
||||
- Konsistent mit dem `ADAPTERS`-Dict in `parlamente.py` (ADR 0002)
|
||||
- BL-Code lebt in eigener Datei mit eigenen Helpern und Notizen
|
||||
- Neue BL = neue Datei + ein Eintrag in `__init__.py`, kein Refactoring
|
||||
- Tests pro BL in eigener Test-Datei (`tests/test_protokoll_parsers_<bl>.py`)
|
||||
- Parser-Funktionen bleiben simpel, kein OOP-Overhead
|
||||
|
||||
**Nachteile:**
|
||||
- Vertrag ist nur per Convention dokumentiert (nicht via Type-System
|
||||
erzwingbar) — dafuer ein Schema-Test in `test_protokoll_parsers.py`
|
||||
als Sicherheitsnetz.
|
||||
|
||||
## Entscheidung
|
||||
|
||||
**Option C.** Konkret:
|
||||
|
||||
```
|
||||
app/protokoll_parsers/
|
||||
├── __init__.py # Registry + parse_protocol(bl, pdf) + supported_bundeslaender()
|
||||
├── nrw.py # NRW v5 (vorher app/protokoll_parser_nrw.py)
|
||||
└── <bl>.py # je BL eine Datei, sobald implementiert
|
||||
```
|
||||
|
||||
**Vertrag fuer jeden Parser** (verbindlich):
|
||||
|
||||
```python
|
||||
def parse_protocol(pdf_path: str) -> list[dict]:
|
||||
"""Returns: [
|
||||
{
|
||||
"drucksache": str | None,
|
||||
"ergebnis": str, # angenommen/abgelehnt/ueberwiesen/...
|
||||
"einstimmig": bool,
|
||||
"kind": str, # parser-intern, fuer Debug
|
||||
"votes": {
|
||||
"ja": list[str], # Fraktions-Codes (CDU, SPD, GRUENE, ...)
|
||||
"nein": list[str],
|
||||
"enthaltung": list[str],
|
||||
},
|
||||
},
|
||||
...
|
||||
]"""
|
||||
```
|
||||
|
||||
**Naming:** Datei-Stem = lowercase BL-Code (`nrw.py`, `mv.py`, ...).
|
||||
Registry-Key = uppercase BL-Code (`"NRW"`, `"MV"`).
|
||||
|
||||
**Konsumenten** rufen `parse_protocol(bundesland, pdf_path)` aus dem
|
||||
Sub-Package, nicht direkt eine BL-Datei.
|
||||
|
||||
## Konsequenzen
|
||||
|
||||
### Positiv
|
||||
|
||||
- Folge-BL-Implementierungen ohne Refactoring der Bestands-Logik.
|
||||
- Reverse-Engineering-Notizen leben pro BL in einer Datei statt verteilt
|
||||
ueber eine Mega-Datei.
|
||||
- Der `supported_bundeslaender()`-Helper macht in CLI und UI sofort
|
||||
sichtbar, wo Daten verfuegbar sind und wo nicht.
|
||||
- Neue Adapter-Test-Files folgen demselben Schema (`test_protokoll_parsers_<bl>.py`).
|
||||
|
||||
### Negativ
|
||||
|
||||
- Schema-Vertrag nur per Convention (kein TypedDict). Dafuer ein
|
||||
Smoke-Test in `tests/test_protokoll_parsers.py`, der pro registriertem
|
||||
Parser die Result-Keys pruefen wird, sobald >1 Implementation existiert.
|
||||
|
||||
### Folgen fuer andere ADRs
|
||||
|
||||
- ADR 0002 (Adapter-Pattern) bleibt gueltig; dieses ADR ueberbruckt es
|
||||
nicht, sondern wendet das gleiche Muster auf eine zweite Adapter-Familie an.
|
||||
- Folge-Issues (HE/BB/MV/BE/...) sind reine Implementation-Tickets ohne
|
||||
Architektur-Diskussion — der Vertrag ist hier festgelegt.
|
||||
@ -23,7 +23,9 @@ und Konsequenzen. Format inspiriert von [Michael Nygard](https://cognitect.com/b
|
||||
| [0004](0004-deployment-workflow.md) | Docker Compose Deploy mit DB-/Reports-Volume und SN-XML-Sonderpfad | accepted | 2026-04-10 |
|
||||
| [0005](0005-keycloak-sso-with-dev-bypass.md) | Keycloak SSO mit Dev-Bypass-Fallback | accepted | 2026-04-10 |
|
||||
| [0006](0006-embedding-model-migration-v3-to-v4.md) | Embedding-Modell-Migration text-embedding-v3 → v4 | accepted | 2026-04-11 |
|
||||
| [0007](0007-test-taxonomy.md) | Test-Taxonomie (Unit / Integration / E2E / Property / Smoke) | accepted | 2026-04-28 |
|
||||
| [0008](0008-ddd-lightweight-migration.md) | DDD-Lightweight-Migration (Repository, LLM-Port, Domain-Verhalten) | accepted | 2026-04-20 |
|
||||
| [0009](0009-protokoll-parser-registry.md) | Plenarprotokoll-Parser-Registry pro Bundesland | accepted | 2026-04-28 |
|
||||
|
||||
## Wann ADR, wann nicht
|
||||
|
||||
|
||||
@ -11,3 +11,4 @@
|
||||
|
||||
pytest>=8.0.0
|
||||
pytest-asyncio>=0.24.0
|
||||
pytest-cov>=5.0.0
|
||||
|
||||
@ -24,6 +24,28 @@ fi
|
||||
|
||||
cd "$PROJECT_DIR"
|
||||
|
||||
# Branch-Guard: Prod (gwoe.toppyr.de) ist auf release/1.0 festgelegt.
|
||||
# 1.x-Entwicklung laeuft auf gwoe-dev.toppyr.de via Cron-Auto-Deploy aus main.
|
||||
CURRENT_BRANCH=$(git rev-parse --abbrev-ref HEAD 2>/dev/null || echo "")
|
||||
EXPECTED_BRANCH="release/1.0"
|
||||
if [ "${1:-}" = "--force" ]; then
|
||||
shift
|
||||
echo "⚠ --force aktiv: Branch-Guard übersprungen ($CURRENT_BRANCH)"
|
||||
elif [ "$CURRENT_BRANCH" != "$EXPECTED_BRANCH" ]; then
|
||||
cat <<EOF
|
||||
✗ Prod-Deploy abgebrochen: lokal aktiv ist '$CURRENT_BRANCH', erwartet '$EXPECTED_BRANCH'.
|
||||
|
||||
Prod (gwoe.toppyr.de) ist auf release/1.0 festgelegt. Vor einem Deploy:
|
||||
git checkout release/1.0
|
||||
|
||||
Fuer Dev (gwoe-dev.toppyr.de) braucht es kein deploy.sh — der Server zieht
|
||||
main per Cron alle 5 Minuten.
|
||||
|
||||
Mit --force kann der Guard ueberbruckt werden (nur in Notfaellen).
|
||||
EOF
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "=== GWÖ-Antragsprüfer Deploy ==="
|
||||
|
||||
# 1. Uptime Kuma auf Wartung setzen
|
||||
|
||||
@ -42,23 +42,26 @@ echo " $(date -Iseconds)"
|
||||
echo "================================================================"
|
||||
|
||||
echo
|
||||
echo "[1] Hauptseiten erreichbar (alle 200)"
|
||||
echo "[1] Public-Seiten (200 ohne Auth)"
|
||||
check "v2 Default /" "200" "/"
|
||||
check "v2 Detail (echte DS)" "200" "/antrag/21/754S"
|
||||
check "Classic /classic" "200" "/classic"
|
||||
check "/auswertungen" "200" "/auswertungen"
|
||||
check "/methodik" "200" "/methodik"
|
||||
check "/quellen" "200" "/quellen"
|
||||
check "/impressum" "200" "/impressum"
|
||||
check "/datenschutz" "200" "/datenschutz"
|
||||
check "/v2/merkliste" "200" "/v2/merkliste"
|
||||
check "/v2/tags" "200" "/v2/tags"
|
||||
check "/v2/cluster" "200" "/v2/cluster"
|
||||
check "/v2/landtag-suche" "200" "/v2/landtag-suche"
|
||||
check "/v2/neu" "200" "/v2/neu"
|
||||
check "/v2/batch" "200" "/v2/batch"
|
||||
check "/health" "200" "/health"
|
||||
|
||||
echo
|
||||
echo "[1b] Auth-Routen (302/401 ohne Auth — Redirect zu Login)"
|
||||
check "/auswertungen (auth)" "401" "/auswertungen"
|
||||
check "/v2/merkliste (auth)" "401" "/v2/merkliste"
|
||||
check "/v2/landtag-suche (auth)" "401" "/v2/landtag-suche"
|
||||
check "/v2/neu (auth)" "401" "/v2/neu"
|
||||
check "/v2/cluster (admin)" "401" "/v2/cluster"
|
||||
check "/v2/batch (admin)" "401" "/v2/batch"
|
||||
|
||||
echo
|
||||
echo "[2] API-Endpoints (öffentlich)"
|
||||
check "/api/assessments" "200" "/api/assessments"
|
||||
|
||||
@ -552,3 +552,110 @@ class TestMerkliste:
|
||||
assert count == 1
|
||||
listed = run(database.merkliste_list("user1"))
|
||||
assert len([e for e in listed if e["antrag_id"] == "18/9001"]) == 1
|
||||
|
||||
|
||||
# ─── Plenum-Vote-Results (#106) ──────────────────────────────────────────────
|
||||
|
||||
class TestPlenumVoteResults:
|
||||
def test_creates_table(self, db_path):
|
||||
import aiosqlite
|
||||
from app import database
|
||||
run(database.init_db())
|
||||
|
||||
async def check():
|
||||
async with aiosqlite.connect(db_path) as db:
|
||||
cur = await db.execute(
|
||||
"SELECT name FROM sqlite_master WHERE type='table' "
|
||||
"AND name='plenum_vote_results'"
|
||||
)
|
||||
return await cur.fetchone()
|
||||
|
||||
assert run(check()) is not None
|
||||
|
||||
def test_upsert_and_get_roundtrip(self, initialized_db):
|
||||
from app import database
|
||||
run(database.upsert_plenum_vote(
|
||||
bundesland="NRW",
|
||||
drucksache="18/1234",
|
||||
ergebnis="angenommen",
|
||||
einstimmig=False,
|
||||
fraktionen_ja=["CDU", "GRÜNE"],
|
||||
fraktionen_nein=["SPD", "AfD"],
|
||||
fraktionen_enthaltung=[],
|
||||
quelle_protokoll="MMP18-119",
|
||||
quelle_url="https://landtag.nrw.de/MMP18-119.pdf",
|
||||
))
|
||||
result = run(database.get_plenum_votes("NRW", "18/1234"))
|
||||
assert len(result) == 1
|
||||
r = result[0]
|
||||
assert r["ergebnis"] == "angenommen"
|
||||
assert r["einstimmig"] is False
|
||||
assert r["fraktionen_ja"] == ["CDU", "GRÜNE"]
|
||||
assert r["fraktionen_nein"] == ["SPD", "AfD"]
|
||||
assert r["fraktionen_enthaltung"] == []
|
||||
assert r["quelle_protokoll"] == "MMP18-119"
|
||||
|
||||
def test_einstimmig_flag_persisted(self, initialized_db):
|
||||
from app import database
|
||||
run(database.upsert_plenum_vote(
|
||||
bundesland="NRW", drucksache="18/100", ergebnis="überwiesen",
|
||||
einstimmig=True, fraktionen_ja=[], fraktionen_nein=[],
|
||||
fraktionen_enthaltung=[], quelle_protokoll="MMP18-100",
|
||||
))
|
||||
result = run(database.get_plenum_votes("NRW", "18/100"))
|
||||
assert result[0]["einstimmig"] is True
|
||||
|
||||
def test_idempotent_upsert_same_protokoll(self, initialized_db):
|
||||
"""Zweiter Upsert mit demselben Protokoll → ein Eintrag, neue Werte."""
|
||||
from app import database
|
||||
run(database.upsert_plenum_vote(
|
||||
bundesland="NRW", drucksache="18/200", ergebnis="abgelehnt",
|
||||
einstimmig=False, fraktionen_ja=["AfD"], fraktionen_nein=["CDU", "SPD"],
|
||||
fraktionen_enthaltung=[], quelle_protokoll="MMP18-50",
|
||||
))
|
||||
# Re-Parse mit aktualisiertem Ergebnis
|
||||
run(database.upsert_plenum_vote(
|
||||
bundesland="NRW", drucksache="18/200", ergebnis="zurückgezogen",
|
||||
einstimmig=False, fraktionen_ja=[], fraktionen_nein=[],
|
||||
fraktionen_enthaltung=[], quelle_protokoll="MMP18-50",
|
||||
))
|
||||
result = run(database.get_plenum_votes("NRW", "18/200"))
|
||||
assert len(result) == 1
|
||||
assert result[0]["ergebnis"] == "zurückgezogen"
|
||||
|
||||
def test_multiple_protokolle_keep_separate_records(self, initialized_db):
|
||||
"""Eine Drucksache, zwei Protokolle (Ueberweisung + finale Abstimmung)
|
||||
muessen beide erhalten bleiben."""
|
||||
from app import database
|
||||
run(database.upsert_plenum_vote(
|
||||
bundesland="NRW", drucksache="18/300", ergebnis="überwiesen",
|
||||
einstimmig=True, fraktionen_ja=[], fraktionen_nein=[],
|
||||
fraktionen_enthaltung=[], quelle_protokoll="MMP18-50",
|
||||
))
|
||||
run(database.upsert_plenum_vote(
|
||||
bundesland="NRW", drucksache="18/300", ergebnis="angenommen",
|
||||
einstimmig=False, fraktionen_ja=["CDU", "SPD"], fraktionen_nein=["AfD"],
|
||||
fraktionen_enthaltung=["GRÜNE"], quelle_protokoll="MMP18-119",
|
||||
))
|
||||
result = run(database.get_plenum_votes("NRW", "18/300"))
|
||||
assert len(result) == 2
|
||||
protokolle = {r["quelle_protokoll"] for r in result}
|
||||
assert protokolle == {"MMP18-50", "MMP18-119"}
|
||||
|
||||
def test_empty_query_returns_empty_list(self, initialized_db):
|
||||
from app import database
|
||||
result = run(database.get_plenum_votes("NRW", "99/9999"))
|
||||
assert result == []
|
||||
|
||||
def test_unicode_in_fraktionen_persisted(self, initialized_db):
|
||||
"""GRÜNE mit Umlaut darf nicht ASCII-kodiert werden."""
|
||||
from app import database
|
||||
run(database.upsert_plenum_vote(
|
||||
bundesland="NRW", drucksache="18/400", ergebnis="angenommen",
|
||||
einstimmig=False, fraktionen_ja=["GRÜNE", "BÜNDNIS"],
|
||||
fraktionen_nein=[], fraktionen_enthaltung=[],
|
||||
quelle_protokoll="MMP18-1",
|
||||
))
|
||||
result = run(database.get_plenum_votes("NRW", "18/400"))
|
||||
assert "GRÜNE" in result[0]["fraktionen_ja"]
|
||||
assert "BÜNDNIS" in result[0]["fraktionen_ja"]
|
||||
|
||||
159
tests/test_feedback_endpoint.py
Normal file
159
tests/test_feedback_endpoint.py
Normal file
@ -0,0 +1,159 @@
|
||||
"""Unit-Tests für /api/feedback — gemockter Gitea-Call.
|
||||
|
||||
Prüft:
|
||||
- Issue-Body wird korrekt aus Eingaben + Audit-Trail zusammengebaut
|
||||
- Endpoint antwortet mit issue_id und issue_url
|
||||
- Rate-Limit-Decorator ist deklariert
|
||||
- Kein Token → 503
|
||||
"""
|
||||
import json
|
||||
import pytest
|
||||
from unittest.mock import AsyncMock, MagicMock, patch
|
||||
|
||||
|
||||
# Skip falls die App-Abhängigkeiten nicht importierbar sind
|
||||
try:
|
||||
from fastapi.testclient import TestClient
|
||||
from app.main import app, _strip_html, _gitea_ensure_label
|
||||
_HAS_APP = True
|
||||
except ImportError:
|
||||
_HAS_APP = False
|
||||
|
||||
pytestmark = pytest.mark.skipif(not _HAS_APP, reason="app.main not importable")
|
||||
|
||||
|
||||
# ── _strip_html ──────────────────────────────────────────────────────────────
|
||||
|
||||
class TestStripHtml:
|
||||
def test_removes_tags(self):
|
||||
assert _strip_html("<b>hallo</b>", 200) == "hallo"
|
||||
|
||||
def test_max_len(self):
|
||||
assert len(_strip_html("a" * 300, 100)) == 100
|
||||
|
||||
def test_empty(self):
|
||||
assert _strip_html("", 200) == ""
|
||||
|
||||
def test_no_tags(self):
|
||||
assert _strip_html("plain text", 200) == "plain text"
|
||||
|
||||
|
||||
# ── /api/feedback Endpoint ───────────────────────────────────────────────────
|
||||
|
||||
class TestFeedbackEndpoint:
|
||||
"""Smoke-Tests mit gemocktem httpx-Client + gemocktem gitea_token."""
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def _mock_gitea(self):
|
||||
"""Patches settings.gitea_token und httpx.AsyncClient."""
|
||||
# settings.gitea_token setzen
|
||||
with patch("app.main.settings") as mock_settings:
|
||||
mock_settings.gitea_token = "fake-token-123"
|
||||
mock_settings.gitea_api_url = "https://repo.example.com/api/v1"
|
||||
mock_settings.gitea_repo_owner = "testowner"
|
||||
mock_settings.gitea_repo_name = "testrepo"
|
||||
|
||||
# httpx.AsyncClient mocken
|
||||
mock_resp_labels = MagicMock()
|
||||
mock_resp_labels.status_code = 200
|
||||
mock_resp_labels.json.return_value = [{"id": 7, "name": "feedback"}]
|
||||
|
||||
mock_resp_issue = MagicMock()
|
||||
mock_resp_issue.status_code = 201
|
||||
mock_resp_issue.json.return_value = {
|
||||
"number": 42,
|
||||
"html_url": "https://repo.example.com/testowner/testrepo/issues/42",
|
||||
}
|
||||
|
||||
async_client = AsyncMock()
|
||||
async_client.get.return_value = mock_resp_labels
|
||||
async_client.post.return_value = mock_resp_issue
|
||||
async_client.__aenter__ = AsyncMock(return_value=async_client)
|
||||
async_client.__aexit__ = AsyncMock(return_value=False)
|
||||
|
||||
with patch("httpx.AsyncClient", return_value=async_client):
|
||||
self._async_client = async_client
|
||||
yield
|
||||
|
||||
def test_happy_path_returns_issue_url(self):
|
||||
client = TestClient(app)
|
||||
resp = client.post("/api/feedback", data={
|
||||
"titel": "Test-Bug",
|
||||
"beschreibung": "Etwas ist kaputt",
|
||||
"url": "https://gwoe.toppyr.de/antrag/NRW-18/1234",
|
||||
"drucksache": "NRW-18/1234",
|
||||
"viewport": "1440x900",
|
||||
"user_agent": "TestAgent/1.0",
|
||||
"klicks_json": json.dumps([{"t": "2026-04-25T10:00:00Z", "el": "button.v2-nav-item", "txt": "Durchsuchen"}]),
|
||||
"errors_json": json.dumps([]),
|
||||
})
|
||||
assert resp.status_code == 200
|
||||
data = resp.json()
|
||||
assert data["issue_id"] == 42
|
||||
assert "issues/42" in data["issue_url"]
|
||||
|
||||
def test_issue_body_contains_drucksache(self):
|
||||
"""Stellt sicher, dass die Drucksachen-Nummer im POST-Payload auftaucht."""
|
||||
client = TestClient(app)
|
||||
client.post("/api/feedback", data={
|
||||
"titel": "Body-Check",
|
||||
"beschreibung": "Details",
|
||||
"drucksache": "BY-18/9999",
|
||||
"klicks_json": "[]",
|
||||
"errors_json": "[]",
|
||||
})
|
||||
# Zweiter Post-Call ist der Issue-Create-Call
|
||||
calls = self._async_client.post.call_args_list
|
||||
issue_call = next((c for c in calls if "/issues" in str(c)), None)
|
||||
assert issue_call is not None
|
||||
body_arg = issue_call.kwargs.get("json", {}).get("body", "")
|
||||
assert "BY-18/9999" in body_arg
|
||||
|
||||
def test_missing_titel_returns_422(self):
|
||||
client = TestClient(app)
|
||||
resp = client.post("/api/feedback", data={
|
||||
"beschreibung": "Ohne Titel",
|
||||
"klicks_json": "[]",
|
||||
"errors_json": "[]",
|
||||
})
|
||||
assert resp.status_code == 422
|
||||
|
||||
def test_missing_beschreibung_returns_422(self):
|
||||
client = TestClient(app)
|
||||
resp = client.post("/api/feedback", data={
|
||||
"titel": "Ohne Beschreibung",
|
||||
"klicks_json": "[]",
|
||||
"errors_json": "[]",
|
||||
})
|
||||
assert resp.status_code == 422
|
||||
|
||||
def test_html_stripped_from_titel(self):
|
||||
"""XSS im Titel wird entfernt."""
|
||||
client = TestClient(app)
|
||||
client.post("/api/feedback", data={
|
||||
"titel": "<script>alert(1)</script>Bug",
|
||||
"beschreibung": "XSS-Test",
|
||||
"klicks_json": "[]",
|
||||
"errors_json": "[]",
|
||||
})
|
||||
calls = self._async_client.post.call_args_list
|
||||
issue_call = next((c for c in calls if "/issues" in str(c)), None)
|
||||
if issue_call:
|
||||
title_arg = issue_call.kwargs.get("json", {}).get("title", "")
|
||||
assert "<script>" not in title_arg
|
||||
|
||||
def test_no_token_returns_503(self):
|
||||
"""Ohne konfiguriertes Token gibt es 503."""
|
||||
with patch("app.main.settings") as s:
|
||||
s.gitea_token = ""
|
||||
s.gitea_api_url = "https://repo.example.com/api/v1"
|
||||
s.gitea_repo_owner = "testowner"
|
||||
s.gitea_repo_name = "testrepo"
|
||||
client = TestClient(app, raise_server_exceptions=False)
|
||||
resp = client.post("/api/feedback", data={
|
||||
"titel": "Test",
|
||||
"beschreibung": "Kein Token",
|
||||
"klicks_json": "[]",
|
||||
"errors_json": "[]",
|
||||
})
|
||||
assert resp.status_code == 503
|
||||
160
tests/test_ingest_votes.py
Normal file
160
tests/test_ingest_votes.py
Normal file
@ -0,0 +1,160 @@
|
||||
"""Tests fuer app/ingest_votes.py — PDF → plenum_vote_results Pipeline (#106 / #126)."""
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import sys
|
||||
from pathlib import Path
|
||||
from unittest.mock import patch
|
||||
|
||||
import pytest
|
||||
|
||||
# Gleiches aiosqlite-Setup-Problem wie in test_database.py — dort fix
|
||||
# importieren, damit hier nichts gestubbed ist.
|
||||
_aio = sys.modules.get("aiosqlite")
|
||||
if _aio is not None and not hasattr(_aio, "connect"):
|
||||
del sys.modules["aiosqlite"]
|
||||
|
||||
import aiosqlite # noqa: E402
|
||||
import importlib # noqa: E402
|
||||
|
||||
if "app.database" in sys.modules:
|
||||
if not hasattr(getattr(sys.modules["app.database"], "aiosqlite", None), "connect"):
|
||||
del sys.modules["app.database"]
|
||||
importlib.import_module("app.database")
|
||||
else:
|
||||
importlib.import_module("app.database")
|
||||
|
||||
|
||||
def run(coro):
|
||||
return asyncio.get_event_loop().run_until_complete(coro)
|
||||
|
||||
|
||||
@pytest.fixture()
|
||||
def db_path(tmp_path, monkeypatch):
|
||||
path = tmp_path / "test.db"
|
||||
from app.config import settings
|
||||
monkeypatch.setattr(settings, "db_path", str(path))
|
||||
return str(path)
|
||||
|
||||
|
||||
@pytest.fixture()
|
||||
def initialized_db(db_path):
|
||||
from app import database
|
||||
run(database.init_db())
|
||||
return db_path
|
||||
|
||||
|
||||
def _fake_parse_result(drucksache: str, ergebnis: str = "angenommen",
|
||||
einstimmig: bool = False,
|
||||
ja: list[str] = None, nein: list[str] = None,
|
||||
enth: list[str] = None) -> dict:
|
||||
return {
|
||||
"drucksache": drucksache,
|
||||
"ergebnis": ergebnis,
|
||||
"einstimmig": einstimmig,
|
||||
"votes": {
|
||||
"ja": ja or [],
|
||||
"nein": nein or [],
|
||||
"enthaltung": enth or [],
|
||||
},
|
||||
"kind": "direct",
|
||||
}
|
||||
|
||||
|
||||
class TestIngestPdf:
|
||||
def test_writes_each_parsed_vote(self, initialized_db, tmp_path):
|
||||
from app import ingest_votes, database
|
||||
fake_pdf = tmp_path / "MMP18-119.pdf"
|
||||
fake_pdf.write_bytes(b"%PDF-1.4 fake")
|
||||
|
||||
parser_results = [
|
||||
_fake_parse_result("18/100", "angenommen", ja=["CDU", "SPD"], nein=["AfD"]),
|
||||
_fake_parse_result("18/200", "abgelehnt", ja=["AfD"], nein=["CDU", "SPD"]),
|
||||
]
|
||||
|
||||
with patch("app.ingest_votes.parse_protocol", return_value=parser_results):
|
||||
stats = run(ingest_votes.ingest_pdf(fake_pdf))
|
||||
|
||||
assert stats["parsed"] == 2
|
||||
assert stats["written"] == 2
|
||||
|
||||
votes_100 = run(database.get_plenum_votes("NRW", "18/100"))
|
||||
assert len(votes_100) == 1
|
||||
assert votes_100[0]["fraktionen_ja"] == ["CDU", "SPD"]
|
||||
assert votes_100[0]["quelle_protokoll"] == "MMP18-119"
|
||||
|
||||
def test_skips_entries_without_drucksache(self, initialized_db, tmp_path):
|
||||
"""Anchors ohne aufloesbare Drucksache werden gezaehlt aber nicht
|
||||
geschrieben (sonst muellt der Import die DB voll)."""
|
||||
from app import ingest_votes
|
||||
fake_pdf = tmp_path / "MMP18-50.pdf"
|
||||
fake_pdf.write_bytes(b"%PDF")
|
||||
|
||||
parser_results = [
|
||||
_fake_parse_result("18/300", "angenommen"),
|
||||
{"drucksache": None, "ergebnis": "angenommen", "votes": {"ja": [], "nein": [], "enthaltung": []}},
|
||||
]
|
||||
with patch("app.ingest_votes.parse_protocol", return_value=parser_results):
|
||||
stats = run(ingest_votes.ingest_pdf(fake_pdf))
|
||||
|
||||
assert stats["parsed"] == 2
|
||||
assert stats["written"] == 1
|
||||
assert stats["skipped_no_drucksache"] == 1
|
||||
|
||||
def test_protokoll_id_default_from_stem(self, initialized_db, tmp_path):
|
||||
from app import ingest_votes, database
|
||||
fake_pdf = tmp_path / "MMP18-77.pdf"
|
||||
fake_pdf.write_bytes(b"%PDF")
|
||||
with patch("app.ingest_votes.parse_protocol",
|
||||
return_value=[_fake_parse_result("18/500")]):
|
||||
stats = run(ingest_votes.ingest_pdf(fake_pdf))
|
||||
assert stats["protokoll_id"] == "MMP18-77"
|
||||
votes = run(database.get_plenum_votes("NRW", "18/500"))
|
||||
assert votes[0]["quelle_protokoll"] == "MMP18-77"
|
||||
|
||||
def test_protokoll_id_override(self, initialized_db, tmp_path):
|
||||
from app import ingest_votes, database
|
||||
fake_pdf = tmp_path / "scan.pdf"
|
||||
fake_pdf.write_bytes(b"%PDF")
|
||||
with patch("app.ingest_votes.parse_protocol",
|
||||
return_value=[_fake_parse_result("18/600")]):
|
||||
run(ingest_votes.ingest_pdf(
|
||||
fake_pdf, protokoll_id="MMP18-99", quelle_url="https://example.com/x.pdf",
|
||||
))
|
||||
votes = run(database.get_plenum_votes("NRW", "18/600"))
|
||||
assert votes[0]["quelle_protokoll"] == "MMP18-99"
|
||||
assert votes[0]["quelle_url"] == "https://example.com/x.pdf"
|
||||
|
||||
def test_bundesland_override(self, initialized_db, tmp_path):
|
||||
"""Adapter fuer andere BL koennten denselben Ingest-Helper nutzen."""
|
||||
from app import ingest_votes, database
|
||||
fake_pdf = tmp_path / "MV-MP1.pdf"
|
||||
fake_pdf.write_bytes(b"%PDF")
|
||||
with patch("app.ingest_votes.parse_protocol",
|
||||
return_value=[_fake_parse_result("8/100")]):
|
||||
run(ingest_votes.ingest_pdf(fake_pdf, bundesland="MV"))
|
||||
# Lookup unter dem richtigen BL
|
||||
votes_mv = run(database.get_plenum_votes("MV", "8/100"))
|
||||
assert len(votes_mv) == 1
|
||||
votes_nrw = run(database.get_plenum_votes("NRW", "8/100"))
|
||||
assert votes_nrw == []
|
||||
|
||||
def test_re_ingest_overwrites_same_protokoll(self, initialized_db, tmp_path):
|
||||
"""Erneuter Ingest desselben Protokolls aktualisiert die Eintraege
|
||||
(idempotent), kein Duplikat."""
|
||||
from app import ingest_votes, database
|
||||
fake_pdf = tmp_path / "MMP18-1.pdf"
|
||||
fake_pdf.write_bytes(b"%PDF")
|
||||
|
||||
with patch("app.ingest_votes.parse_protocol",
|
||||
return_value=[_fake_parse_result("18/700", "angenommen", ja=["CDU"])]):
|
||||
run(ingest_votes.ingest_pdf(fake_pdf))
|
||||
# Re-Ingest mit korrigiertem Ergebnis (z.B. Parser-Fix)
|
||||
with patch("app.ingest_votes.parse_protocol",
|
||||
return_value=[_fake_parse_result("18/700", "abgelehnt", ja=[], nein=["CDU"])]):
|
||||
run(ingest_votes.ingest_pdf(fake_pdf))
|
||||
|
||||
votes = run(database.get_plenum_votes("NRW", "18/700"))
|
||||
assert len(votes) == 1
|
||||
assert votes[0]["ergebnis"] == "abgelehnt"
|
||||
assert votes[0]["fraktionen_nein"] == ["CDU"]
|
||||
168
tests/test_og_card.py
Normal file
168
tests/test_og_card.py
Normal file
@ -0,0 +1,168 @@
|
||||
"""Tests fuer app/og_card.py — render_og_card mit Cache + Playwright (#134, #141).
|
||||
|
||||
Tests fuer cache_key + get_cached lebten vorher in test_wahlprogramm_fetch.py;
|
||||
hier kommt der Render-Pfad mit gemocktem Playwright dazu, sodass die volle
|
||||
Coverage von render_og_card lokal lauft.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import sys
|
||||
import types
|
||||
from pathlib import Path
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
import pytest
|
||||
|
||||
from app.og_card import (
|
||||
cache_key,
|
||||
get_cached,
|
||||
render_og_card,
|
||||
)
|
||||
|
||||
|
||||
class TestCacheKey:
|
||||
def test_deterministic(self):
|
||||
a = cache_key("NRW-18/1", "2026-04-01T00:00:00")
|
||||
b = cache_key("NRW-18/1", "2026-04-01T00:00:00")
|
||||
assert a == b
|
||||
|
||||
def test_changes_with_updated_at(self):
|
||||
a = cache_key("NRW-18/1", "2026-04-01T00:00:00")
|
||||
b = cache_key("NRW-18/1", "2026-04-02T00:00:00")
|
||||
assert a != b
|
||||
|
||||
def test_length_16(self):
|
||||
assert len(cache_key("NRW-18/1", "x")) == 16
|
||||
|
||||
|
||||
class TestGetCached:
|
||||
def test_returns_none_when_missing(self, tmp_path):
|
||||
assert get_cached("NRW-18/1", "2026-04-01T00:00:00", cache_dir=tmp_path) is None
|
||||
|
||||
def test_returns_path_when_exists(self, tmp_path):
|
||||
ds = "NRW-18/1"
|
||||
upd = "2026-04-01T00:00:00"
|
||||
key = cache_key(ds, upd)
|
||||
safe = ds.replace("/", "_")
|
||||
target = tmp_path / f"{safe}_{key}.png"
|
||||
target.write_bytes(b"\x89PNG dummy")
|
||||
result = get_cached(ds, upd, cache_dir=tmp_path)
|
||||
assert result == target
|
||||
|
||||
|
||||
class TestRenderOgCard:
|
||||
"""Tests fuer den Render-Pfad. Playwright wird ueber sys.modules-Stub
|
||||
eingehaengt — sync_playwright() liefert einen ContextManager, der
|
||||
einen gemockten Browser/Page-Stack zurueckgibt."""
|
||||
|
||||
def _make_playwright_stub(self, png_bytes: bytes = b"\x89PNG fake"):
|
||||
"""Erstellt ein Stub-Modul 'playwright.sync_api' mit
|
||||
``sync_playwright`` als ContextManager, dessen __enter__ einen Mock
|
||||
liefert, der die Chain pw.chromium.launch().new_page().screenshot()
|
||||
liefert."""
|
||||
mod = types.ModuleType("playwright")
|
||||
sub = types.ModuleType("playwright.sync_api")
|
||||
|
||||
page_mock = MagicMock()
|
||||
page_mock.screenshot.return_value = png_bytes
|
||||
page_mock.goto.return_value = None
|
||||
|
||||
browser_mock = MagicMock()
|
||||
browser_mock.new_page.return_value = page_mock
|
||||
browser_mock.close.return_value = None
|
||||
|
||||
pw_mock = MagicMock()
|
||||
pw_mock.chromium.launch.return_value = browser_mock
|
||||
|
||||
ctx_mgr = MagicMock()
|
||||
ctx_mgr.__enter__.return_value = pw_mock
|
||||
ctx_mgr.__exit__.return_value = False
|
||||
|
||||
sub.sync_playwright = MagicMock(return_value=ctx_mgr)
|
||||
mod.sync_api = sub
|
||||
return mod, sub, page_mock
|
||||
|
||||
def test_cache_hit_skips_playwright(self, tmp_path):
|
||||
"""Existierender Cache → Playwright wird gar nicht angerufen."""
|
||||
ds = "NRW-18/1"
|
||||
upd = "2026-04-01T00:00:00"
|
||||
key = cache_key(ds, upd)
|
||||
safe = ds.replace("/", "_")
|
||||
cache_file = tmp_path / f"{safe}_{key}.png"
|
||||
cache_file.write_bytes(b"\x89CACHED")
|
||||
|
||||
# Wenn der Cache hit ist, sollte playwright NICHT importiert werden.
|
||||
# Dafuer setzen wir einen Stub, der bei Aufruf einen Test-Fehler triggert.
|
||||
sys.modules.pop("playwright", None)
|
||||
sys.modules.pop("playwright.sync_api", None)
|
||||
|
||||
with patch.dict(sys.modules, {}, clear=False):
|
||||
result = render_og_card(ds, upd, cache_dir=tmp_path)
|
||||
assert result == b"\x89CACHED"
|
||||
|
||||
def test_cache_miss_renders_via_playwright(self, tmp_path):
|
||||
ds = "NRW-18/2"
|
||||
upd = "2026-04-02T00:00:00"
|
||||
png = b"\x89PNG rendered"
|
||||
|
||||
mod, sub, page_mock = self._make_playwright_stub(png)
|
||||
with patch.dict(sys.modules, {"playwright": mod, "playwright.sync_api": sub}):
|
||||
result = render_og_card(ds, upd, cache_dir=tmp_path,
|
||||
base_url="http://test.example")
|
||||
|
||||
assert result == png
|
||||
# Cache-Datei muss geschrieben sein
|
||||
key = cache_key(ds, upd)
|
||||
safe = ds.replace("/", "_")
|
||||
cache_file = tmp_path / f"{safe}_{key}.png"
|
||||
assert cache_file.exists()
|
||||
assert cache_file.read_bytes() == png
|
||||
|
||||
def test_cache_miss_passes_drucksache_to_playwright_url(self, tmp_path):
|
||||
"""URL-Kodierung des Drucksachen-Namens muss ans og-template gehen."""
|
||||
ds = "NRW-18/123 (neu)" # Sonderzeichen
|
||||
upd = "2026-04-03T00:00:00"
|
||||
mod, sub, page_mock = self._make_playwright_stub()
|
||||
with patch.dict(sys.modules, {"playwright": mod, "playwright.sync_api": sub}):
|
||||
render_og_card(ds, upd, cache_dir=tmp_path,
|
||||
base_url="http://internal:8000")
|
||||
# page.goto wurde aufgerufen — URL-Argument analysieren
|
||||
call = page_mock.goto.call_args
|
||||
url = call.args[0]
|
||||
assert url.startswith("http://internal:8000/v2/og-template?drucksache=")
|
||||
# / und Klammern muessen URL-encoded sein
|
||||
assert "%2F" in url
|
||||
assert "(" not in url # encoded as %28
|
||||
|
||||
def test_playwright_exception_returns_none(self, tmp_path):
|
||||
"""Renderer-Fehler darf den Caller nicht crashen."""
|
||||
ds = "NRW-18/3"
|
||||
upd = "2026-04-04T00:00:00"
|
||||
|
||||
mod = types.ModuleType("playwright")
|
||||
sub = types.ModuleType("playwright.sync_api")
|
||||
|
||||
def _broken(*a, **kw):
|
||||
raise RuntimeError("Browser launch failed")
|
||||
sub.sync_playwright = _broken
|
||||
mod.sync_api = sub
|
||||
|
||||
with patch.dict(sys.modules, {"playwright": mod, "playwright.sync_api": sub}):
|
||||
result = render_og_card(ds, upd, cache_dir=tmp_path)
|
||||
assert result is None
|
||||
# Cache-Datei darf NICHT existieren
|
||||
key = cache_key(ds, upd)
|
||||
safe = ds.replace("/", "_")
|
||||
cache_file = tmp_path / f"{safe}_{key}.png"
|
||||
assert not cache_file.exists()
|
||||
|
||||
def test_cache_dir_created_if_missing(self, tmp_path):
|
||||
"""render_og_card muss das cache_dir auch anlegen, wenn es fehlt."""
|
||||
sub_dir = tmp_path / "deep" / "nested" / "cache"
|
||||
# Existiert noch nicht
|
||||
assert not sub_dir.exists()
|
||||
|
||||
mod, sub, page_mock = self._make_playwright_stub()
|
||||
with patch.dict(sys.modules, {"playwright": mod, "playwright.sync_api": sub}):
|
||||
render_og_card("NRW-18/4", "2026-04-05T00:00:00", cache_dir=sub_dir)
|
||||
assert sub_dir.exists()
|
||||
@ -576,3 +576,27 @@ class TestSaarlandSearchPropagatesErrors:
|
||||
|
||||
with pytest.raises(httpx.ConnectError):
|
||||
asyncio.run(_run())
|
||||
|
||||
def test_search_propagates_http_500(self):
|
||||
"""HTTP 5xx response must NOT be silently turned into empty results
|
||||
(regression #142): a 500 from the Umbraco backend used to log+return
|
||||
[], hiding it from the monitoring summary."""
|
||||
import httpx
|
||||
from app.parlamente import SaarlandAdapter
|
||||
|
||||
adapter = SaarlandAdapter()
|
||||
|
||||
async def _run():
|
||||
mock_client = AsyncMock()
|
||||
mock_client.__aenter__ = AsyncMock(return_value=mock_client)
|
||||
mock_client.__aexit__ = AsyncMock(return_value=False)
|
||||
mock_resp = MagicMock()
|
||||
mock_resp.status_code = 500
|
||||
mock_resp.text = "Server Error"
|
||||
mock_resp.request = MagicMock()
|
||||
mock_client.post = AsyncMock(return_value=mock_resp)
|
||||
with patch.object(adapter, "_make_client", return_value=mock_client):
|
||||
await adapter.search("Schule")
|
||||
|
||||
with pytest.raises(httpx.HTTPStatusError):
|
||||
asyncio.run(_run())
|
||||
|
||||
74
tests/test_protokoll_parsers.py
Normal file
74
tests/test_protokoll_parsers.py
Normal file
@ -0,0 +1,74 @@
|
||||
"""Tests fuer app/protokoll_parsers/__init__.py — Registry + Dispatch (#126)."""
|
||||
from __future__ import annotations
|
||||
|
||||
import pytest
|
||||
|
||||
from app.protokoll_parsers import (
|
||||
PROTOKOLL_PARSERS,
|
||||
parse_protocol,
|
||||
supported_bundeslaender,
|
||||
)
|
||||
|
||||
|
||||
class TestRegistry:
|
||||
def test_nrw_registered(self):
|
||||
"""NRW ist die Referenz-Implementierung — muss da sein."""
|
||||
assert "NRW" in PROTOKOLL_PARSERS
|
||||
|
||||
def test_supported_includes_nrw(self):
|
||||
assert "NRW" in supported_bundeslaender()
|
||||
|
||||
def test_supported_returns_sorted(self):
|
||||
codes = supported_bundeslaender()
|
||||
assert codes == sorted(codes)
|
||||
|
||||
def test_registry_values_are_callable(self):
|
||||
for code, parser in PROTOKOLL_PARSERS.items():
|
||||
assert callable(parser), f"Parser fuer {code} ist nicht callable"
|
||||
|
||||
|
||||
class TestDispatch:
|
||||
def test_unknown_bl_raises_not_implemented(self):
|
||||
with pytest.raises(NotImplementedError) as exc:
|
||||
parse_protocol("XX", "/dev/null")
|
||||
msg = str(exc.value)
|
||||
assert "XX" in msg
|
||||
# Liste der unterstuetzten BL muss in der Message stehen
|
||||
assert "NRW" in msg
|
||||
# Issue-Referenz fuer Folge-Arbeit
|
||||
assert "#126" in msg
|
||||
|
||||
def test_known_bl_delegates_to_registered_parser(self, tmp_path, monkeypatch):
|
||||
"""parse_protocol delegiert an den BL-Parser aus der Registry."""
|
||||
called_with: list[str] = []
|
||||
|
||||
def fake_parser(pdf_path: str) -> list[dict]:
|
||||
called_with.append(pdf_path)
|
||||
return [{"drucksache": "18/1", "ergebnis": "angenommen", "votes": {"ja": [], "nein": [], "enthaltung": []}}]
|
||||
|
||||
# Temporaer einen TEST-Parser registrieren, dann wieder entfernen
|
||||
monkeypatch.setitem(PROTOKOLL_PARSERS, "TEST", fake_parser)
|
||||
|
||||
result = parse_protocol("TEST", str(tmp_path / "x.pdf"))
|
||||
|
||||
assert called_with == [str(tmp_path / "x.pdf")]
|
||||
assert len(result) == 1
|
||||
assert result[0]["drucksache"] == "18/1"
|
||||
|
||||
|
||||
class TestParserSchema:
|
||||
"""Vertrag: jeder registrierte Parser muss Result-Dicts mit minimalem
|
||||
Schema liefern — drucksache (str|None), ergebnis (str), votes (dict)."""
|
||||
|
||||
def test_nrw_result_dict_has_expected_keys(self):
|
||||
"""Smoke-Test mit handgemachtem Plenarprotokoll-Snippet — pruefen,
|
||||
dass das Schema des Output-Dicts die in __init__.py dokumentierten
|
||||
Keys enthaelt."""
|
||||
from app.protokoll_parsers.nrw import find_results
|
||||
|
||||
text = "Damit ist der Antrag Drucksache 18/100 angenommen."
|
||||
results = find_results(text)
|
||||
assert results, "find_results sollte mindestens einen Treffer liefern"
|
||||
for r in results:
|
||||
for key in ("drucksache", "ergebnis", "kind", "einstimmig"):
|
||||
assert key in r, f"Key '{key}' fehlt im Result"
|
||||
206
tests/test_protokoll_parsers_nrw.py
Normal file
206
tests/test_protokoll_parsers_nrw.py
Normal file
@ -0,0 +1,206 @@
|
||||
"""Tests fuer app/protokoll_parsers/nrw.py — NRW-Plenarprotokoll-Parser v5.
|
||||
|
||||
Backfill aus #134, BL-Refactor aus #126.
|
||||
|
||||
Der Parser ist deterministisch und anchor-basiert; jede Aenderung an den
|
||||
RESULT_ANCHORS oder den Vote-Block-Regexes muss sofort durch diese Tests
|
||||
fallen. Die echte 19/19-Garantie auf MMP18-119 laeuft separat als
|
||||
Integration-Test (braucht das PDF). Hier: pure-string-Tests fuer alle
|
||||
Reverse-Engineering-Findings, die bei der iterativen Entwicklung 1-15
|
||||
dokumentiert wurden.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import sys
|
||||
import types
|
||||
|
||||
# fitz ist via tests/conftest.py gestubbed — Pure-String-Funktionen kommen ohne aus.
|
||||
|
||||
from app.protokoll_parsers.nrw import (
|
||||
normalize_fraktionen,
|
||||
find_results,
|
||||
resolve_drucksache_for_ueber,
|
||||
normalize_text,
|
||||
_is_empty_phrase,
|
||||
_parse_vote_block,
|
||||
ALLE_FRAKTIONEN_NRW,
|
||||
)
|
||||
|
||||
|
||||
class TestNormalizeFraktionen:
|
||||
def test_simple_cdu(self):
|
||||
assert normalize_fraktionen("Wer stimmt zu? – CDU") == ["CDU"]
|
||||
|
||||
def test_multiple_fraktionen(self):
|
||||
result = normalize_fraktionen("CDU, SPD und GRÜNE")
|
||||
assert result == sorted(["CDU", "SPD", "GRÜNE"])
|
||||
|
||||
def test_buendnis_90_normalizes_to_gruene(self):
|
||||
assert normalize_fraktionen("Bündnis 90/Die Grünen") == ["GRÜNE"]
|
||||
|
||||
def test_fdp_with_dots_normalizes(self):
|
||||
"""F.D.P. (mit Punkten) muss als FDP erkannt werden."""
|
||||
assert normalize_fraktionen("F.D.P.") == ["FDP"]
|
||||
|
||||
def test_no_double_match_for_overlapping_keys(self):
|
||||
"""'GRÜNE' darf nicht zusaetzlich als 'Grünen' wieder gematcht werden."""
|
||||
result = normalize_fraktionen("GRÜNE und Grünen")
|
||||
# Beide Tokens sind dieselbe Fraktion → nur einmal in der Liste
|
||||
assert result.count("GRÜNE") == 1
|
||||
|
||||
def test_landesregierung_recognized(self):
|
||||
assert "Landesregierung" in normalize_fraktionen("Landesregierung")
|
||||
|
||||
def test_empty_text_returns_empty(self):
|
||||
assert normalize_fraktionen("") == []
|
||||
|
||||
def test_no_known_partei(self):
|
||||
assert normalize_fraktionen("Some random text") == []
|
||||
|
||||
|
||||
class TestIsEmptyPhrase:
|
||||
def test_niemand_is_empty(self):
|
||||
assert _is_empty_phrase("Stimmt jemand dagegen? – Niemand") is True
|
||||
|
||||
def test_keine_is_empty(self):
|
||||
assert _is_empty_phrase("Enthaltungen? – Keine") is True
|
||||
|
||||
def test_nicht_der_fall(self):
|
||||
assert _is_empty_phrase("Das ist nicht der Fall.") is True
|
||||
|
||||
def test_actual_fraktion_is_not_empty(self):
|
||||
assert _is_empty_phrase("CDU und SPD") is False
|
||||
|
||||
|
||||
class TestParseVoteBlock:
|
||||
def test_simple_ja_extraction(self):
|
||||
block = "Wer stimmt zu? – CDU und SPD."
|
||||
votes = _parse_vote_block(block)
|
||||
assert "CDU" in votes["ja"] and "SPD" in votes["ja"]
|
||||
|
||||
def test_ja_with_negation_returns_empty(self):
|
||||
"""'Wer stimmt zu? – Niemand.' → ja-Liste muss leer sein."""
|
||||
block = "Wer stimmt zu? – Niemand."
|
||||
votes = _parse_vote_block(block)
|
||||
assert votes["ja"] == []
|
||||
|
||||
def test_nein_extraction(self):
|
||||
block = "Wer stimmt dagegen? – AfD."
|
||||
votes = _parse_vote_block(block)
|
||||
assert "AfD" in votes["nein"]
|
||||
|
||||
def test_dagegen_negation(self):
|
||||
block = "Wer stimmt dagegen? – Das ist nicht der Fall."
|
||||
votes = _parse_vote_block(block)
|
||||
assert votes["nein"] == []
|
||||
|
||||
|
||||
class TestFindResults:
|
||||
def test_direct_angenommen(self):
|
||||
text = (
|
||||
"Damit ist der Antrag Drucksache 18/123 mit den Stimmen "
|
||||
"der CDU und der SPD angenommen."
|
||||
)
|
||||
results = find_results(text)
|
||||
assert len(results) == 1
|
||||
r = results[0]
|
||||
assert r["drucksache"] == "18/123"
|
||||
assert r["ergebnis"] == "angenommen"
|
||||
|
||||
def test_direct_abgelehnt(self):
|
||||
text = (
|
||||
"Damit ist der Antrag Drucksache 18/9999 mit den Stimmen "
|
||||
"der CDU gegen die Stimmen der SPD abgelehnt."
|
||||
)
|
||||
results = find_results(text)
|
||||
assert any(r["drucksache"] == "18/9999" and r["ergebnis"] == "abgelehnt" for r in results)
|
||||
|
||||
def test_einstimmig_flag_only_for_ueber_kind(self):
|
||||
"""v5-Verhalten dokumentiert: 'einstimmig' wird in direct-kind-Anchors
|
||||
NICHT gesetzt, nur in ueber/petition/uebersicht. Dieser Test pinnt
|
||||
das aktuelle Verhalten — wenn v6 einstimmig auch fuer direct erkennt,
|
||||
muss der Test angepasst werden."""
|
||||
text = "Damit ist der Antrag Drucksache 18/100 einstimmig angenommen."
|
||||
results = find_results(text)
|
||||
assert results[0]["kind"] == "direct_broad"
|
||||
# einstimmig wird hier (noch) nicht gesetzt — Reverse-Engineering-Befund
|
||||
assert results[0]["einstimmig"] is False
|
||||
|
||||
def test_einstimmig_flag_for_ueberweisung(self):
|
||||
"""Bei Ueberweisungs-Anchors mit 'einstimmig' im naechsten Token-Bereich
|
||||
wird das Flag gesetzt."""
|
||||
text = "Drucksache 18/100 ... Damit ist diese Überweisungsempfehlung einstimmig angenommen."
|
||||
results = find_results(text)
|
||||
ueber_results = [r for r in results if r["kind"] == "ueber"]
|
||||
assert ueber_results, "kein ueber-Result im Test-Text gefunden"
|
||||
assert ueber_results[0]["einstimmig"] is True
|
||||
|
||||
def test_ueberweisung_so_beschlossen_implies_einstimmig(self):
|
||||
"""'Damit ist das so beschlossen' = implizit einstimmige Ueberweisung."""
|
||||
text = "Drucksache 18/200 ... Damit ist das so beschlossen."
|
||||
results = find_results(text)
|
||||
assert any(r["kind"] == "ueber" and r["einstimmig"] for r in results)
|
||||
|
||||
def test_neu_suffix_in_drucksachenummer(self):
|
||||
"""Drucksache-Nummern mit (neu)-Suffix muessen matchen."""
|
||||
text = "Damit ist der Antrag Drucksache 18/4567(neu) angenommen."
|
||||
results = find_results(text)
|
||||
# Match irgendwo in den Results
|
||||
assert any(r["drucksache"] == "18/4567(neu)" for r in results)
|
||||
|
||||
def test_results_sorted_by_position(self):
|
||||
"""Mehrere Anchors muessen nach anchor_start aufsteigend sortiert sein."""
|
||||
text = (
|
||||
"Damit ist der Antrag Drucksache 18/100 angenommen. "
|
||||
"Spaeter im Text. Damit ist der Antrag Drucksache 18/200 abgelehnt."
|
||||
)
|
||||
results = find_results(text)
|
||||
positions = [r["anchor_start"] for r in results]
|
||||
assert positions == sorted(positions)
|
||||
|
||||
def test_dedup_same_position(self):
|
||||
"""Wenn zwei Patterns am selben anchor_start matchen, nur einer im Output."""
|
||||
text = "Damit ist der Antrag Drucksache 18/300 angenommen."
|
||||
results = find_results(text)
|
||||
positions = [r["anchor_start"] for r in results]
|
||||
assert len(positions) == len(set(positions))
|
||||
|
||||
|
||||
class TestResolveDrucksacheForUeber:
|
||||
def test_finds_nearest_ds_before_anchor(self):
|
||||
text = "Drucksache 18/100 ... irgendein Text ... Damit ist das so beschlossen."
|
||||
anchor_start = text.find("Damit")
|
||||
ds = resolve_drucksache_for_ueber(text, anchor_start)
|
||||
assert ds == "18/100"
|
||||
|
||||
def test_picks_closest_when_multiple(self):
|
||||
"""Bei mehreren DS-Nrn vor dem Anchor wird die naechste gewaehlt."""
|
||||
text = "Drucksache 18/100 ... Drucksache 18/200 ... Damit ist das so beschlossen."
|
||||
anchor_start = text.find("Damit")
|
||||
ds = resolve_drucksache_for_ueber(text, anchor_start)
|
||||
assert ds == "18/200"
|
||||
|
||||
def test_returns_none_when_no_ds_before(self):
|
||||
text = "Damit ist das so beschlossen. Drucksache 18/100 spaeter."
|
||||
anchor_start = 0
|
||||
ds = resolve_drucksache_for_ueber(text, anchor_start)
|
||||
assert ds is None
|
||||
|
||||
|
||||
class TestNormalizeText:
|
||||
def test_collapses_whitespace(self):
|
||||
"""Mehrfach-Whitespace wird zu einzelnem Leerzeichen kollabiert."""
|
||||
result = normalize_text("Damit ist\nder\tAntrag")
|
||||
assert " " not in result
|
||||
|
||||
def test_preserves_drucksache_format(self):
|
||||
"""Drucksache-Schreibweise mit Slash muss erhalten bleiben."""
|
||||
result = normalize_text("Drucksache 18/123")
|
||||
assert "18/123" in result
|
||||
|
||||
|
||||
class TestKnownFraktionsList:
|
||||
def test_alle_fraktionen_nrw_complete(self):
|
||||
"""ALLE_FRAKTIONEN_NRW deckt die WP18-Fraktionen ab (CDU, SPD, GRÜNE, FDP, AfD)."""
|
||||
for f in ("CDU", "SPD", "GRÜNE", "FDP", "AfD"):
|
||||
assert f in ALLE_FRAKTIONEN_NRW
|
||||
@ -143,3 +143,83 @@ class TestEdgeCases:
|
||||
assert "muss" in ins_texts
|
||||
assert "31.12.2026" in del_texts
|
||||
assert "30.06.2025" in ins_texts
|
||||
|
||||
|
||||
# ─── build_pdf_href Tests (#134 Coverage-Backfill) ───────────────────────────
|
||||
|
||||
class TestBuildPdfHref:
|
||||
"""Tests fuer build_pdf_href: rekonstruiert PDF-URLs aus Zitat-Metadaten,
|
||||
bevorzugt die explizite url, faellt auf WAHLPROGRAMME-Lookup zurueck."""
|
||||
|
||||
def test_explicit_url_passed_through(self):
|
||||
from app.redline_utils import build_pdf_href
|
||||
zitat = {"url": "/api/wahlprogramm-cite?pid=cdu-nrw-2022&seite=15"}
|
||||
assert build_pdf_href(zitat) == "/api/wahlprogramm-cite?pid=cdu-nrw-2022&seite=15"
|
||||
|
||||
def test_empty_url_falls_back_to_quelle_lookup(self):
|
||||
"""Ohne url muss die quelle reconstruiert werden via WAHLPROGRAMME."""
|
||||
from app.redline_utils import build_pdf_href
|
||||
# Ein in WAHLPROGRAMME hinterlegter Titel
|
||||
from app.wahlprogramme import WAHLPROGRAMME
|
||||
# Pick the first programme from the registry
|
||||
bl, parteien = next(iter(WAHLPROGRAMME.items()))
|
||||
partei, info = next(iter(parteien.items()))
|
||||
titel = info.get("titel", "")
|
||||
if not titel:
|
||||
pytest.skip("Kein WAHLPROGRAMME-Eintrag mit titel verfuegbar")
|
||||
zitat = {
|
||||
"quelle": f"{titel} · S. 42",
|
||||
"text": "Wir wollen die Energiewende",
|
||||
"url": "",
|
||||
}
|
||||
href = build_pdf_href(zitat)
|
||||
assert "/api/wahlprogramm-cite" in href
|
||||
assert "seite=42" in href
|
||||
assert "#page=42" in href # URL-Hash fuer Browser-PDF-Viewer
|
||||
|
||||
def test_no_seitenzahl_returns_empty(self):
|
||||
from app.redline_utils import build_pdf_href
|
||||
zitat = {"quelle": "Irgendein Programm ohne Seite", "text": "x", "url": ""}
|
||||
assert build_pdf_href(zitat) == ""
|
||||
|
||||
def test_unmatched_quelle_returns_empty(self):
|
||||
from app.redline_utils import build_pdf_href
|
||||
zitat = {
|
||||
"quelle": "Erfundenes Programm 1995, S. 1",
|
||||
"text": "x",
|
||||
"url": "",
|
||||
}
|
||||
assert build_pdf_href(zitat) == ""
|
||||
|
||||
def test_query_uses_first_5_words_of_text(self):
|
||||
from app.redline_utils import build_pdf_href
|
||||
from app.wahlprogramme import WAHLPROGRAMME
|
||||
bl, parteien = next(iter(WAHLPROGRAMME.items()))
|
||||
partei, info = next(iter(parteien.items()))
|
||||
titel = info.get("titel", "")
|
||||
if not titel:
|
||||
pytest.skip("Kein WAHLPROGRAMME-Eintrag mit titel verfuegbar")
|
||||
zitat = {
|
||||
"quelle": f"{titel} · S. 5",
|
||||
"text": "Eins zwei drei vier fünf sechs sieben",
|
||||
"url": "",
|
||||
}
|
||||
href = build_pdf_href(zitat)
|
||||
# max. 5 Worte → "sechs sieben" muessen im Query fehlen
|
||||
assert "sechs" not in href
|
||||
assert "sieben" not in href
|
||||
# erste fuenf Wortteile sollten kodiert in q= auftauchen
|
||||
assert "Eins" in href or "Eins" in href.replace("+", " ")
|
||||
|
||||
def test_handles_seite_with_comma_separator(self):
|
||||
"""Quelle 'Titel, S. 42' (Komma) muss genauso parsen wie '· S. 42'."""
|
||||
from app.redline_utils import build_pdf_href
|
||||
from app.wahlprogramme import WAHLPROGRAMME
|
||||
bl, parteien = next(iter(WAHLPROGRAMME.items()))
|
||||
partei, info = next(iter(parteien.items()))
|
||||
titel = info.get("titel", "")
|
||||
if not titel:
|
||||
pytest.skip("Kein WAHLPROGRAMME-Eintrag mit titel verfuegbar")
|
||||
zitat = {"quelle": f"{titel}, S. 17", "text": "x", "url": ""}
|
||||
href = build_pdf_href(zitat)
|
||||
assert "seite=17" in href
|
||||
|
||||
189
tests/test_rss.py
Normal file
189
tests/test_rss.py
Normal file
@ -0,0 +1,189 @@
|
||||
"""Tests fuer den Atom-Feed-Endpoint /api/feed.xml (#125).
|
||||
|
||||
Backfill aus #134: vorher nur indirekt im Smoke-Test abgedeckt. Hier:
|
||||
- Atom-1.0-Validitaet (XML well-formed, Pflicht-Elemente)
|
||||
- Filter-Parameter wirken (bundesland, partei)
|
||||
- ETag-Header + 304-Verhalten
|
||||
- Limit-Clamping
|
||||
- HTML-Escaping fuer Sonderzeichen in Titeln/Drucksachen
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import pytest
|
||||
from unittest.mock import patch
|
||||
from xml.etree import ElementTree as ET
|
||||
|
||||
try:
|
||||
from fastapi.testclient import TestClient
|
||||
from app.main import app
|
||||
client = TestClient(app)
|
||||
_HAS_APP = True
|
||||
except ImportError:
|
||||
_HAS_APP = False
|
||||
client = None
|
||||
|
||||
|
||||
pytestmark = pytest.mark.skipif(not _HAS_APP, reason="app.main not importable")
|
||||
|
||||
ATOM_NS = "{http://www.w3.org/2005/Atom}"
|
||||
|
||||
|
||||
def _fake_assessments() -> list[dict]:
|
||||
"""Drei Fixture-Assessments mit allen Feldern, die der Feed nutzt."""
|
||||
return [
|
||||
{
|
||||
"drucksache": "21/1234",
|
||||
"title": "Antrag zu Erneuerbaren Energien",
|
||||
"bundesland": "NRW",
|
||||
"fraktionen": ["GRÜNE", "SPD"],
|
||||
"gwoe_score": 7.5,
|
||||
"empfehlung": "Unterstützen mit Änderungen",
|
||||
"antrag_zusammenfassung": "Solarpflicht für Neubauten",
|
||||
"updated_at": "2026-04-25T10:00:00",
|
||||
},
|
||||
{
|
||||
"drucksache": "8/4242",
|
||||
"title": "Anti-Terror-Paket & Überwachung", # Sonderzeichen
|
||||
"bundesland": "MV",
|
||||
"fraktionen": ["CDU"],
|
||||
"gwoe_score": 2.1,
|
||||
"empfehlung": "Ablehnen",
|
||||
"antrag_zusammenfassung": None,
|
||||
"updated_at": "2026-04-24T08:30:00",
|
||||
},
|
||||
{
|
||||
"drucksache": "19/9999",
|
||||
"title": "Bürger:innen-Beteiligung stärken",
|
||||
"bundesland": "BE",
|
||||
"fraktionen": ["LINKE", "GRÜNE"],
|
||||
"gwoe_score": 9.0,
|
||||
"empfehlung": "Uneingeschränkt unterstützen",
|
||||
"antrag_zusammenfassung": "Bürgerräte etablieren",
|
||||
"updated_at": "2026-04-26T12:15:00",
|
||||
},
|
||||
]
|
||||
|
||||
|
||||
class TestFeedXml:
|
||||
def test_returns_atom_xml(self):
|
||||
with patch("app.main.get_all_assessments", return_value=_fake_assessments()):
|
||||
resp = client.get("/api/feed.xml")
|
||||
assert resp.status_code == 200
|
||||
assert "atom+xml" in resp.headers["content-type"]
|
||||
# XML well-formed
|
||||
root = ET.fromstring(resp.content)
|
||||
assert root.tag == f"{ATOM_NS}feed"
|
||||
|
||||
def test_required_atom_elements_present(self):
|
||||
with patch("app.main.get_all_assessments", return_value=_fake_assessments()):
|
||||
resp = client.get("/api/feed.xml")
|
||||
root = ET.fromstring(resp.content)
|
||||
# Pflicht-Top-Level-Elemente nach RFC 4287
|
||||
for tag in ("id", "title", "updated"):
|
||||
assert root.find(f"{ATOM_NS}{tag}") is not None, f"missing <{tag}>"
|
||||
# mind. ein self-Link
|
||||
self_links = [
|
||||
l for l in root.findall(f"{ATOM_NS}link")
|
||||
if l.get("rel") == "self"
|
||||
]
|
||||
assert len(self_links) == 1
|
||||
|
||||
def test_entry_count_matches_input(self):
|
||||
with patch("app.main.get_all_assessments", return_value=_fake_assessments()):
|
||||
resp = client.get("/api/feed.xml")
|
||||
root = ET.fromstring(resp.content)
|
||||
entries = root.findall(f"{ATOM_NS}entry")
|
||||
assert len(entries) == 3
|
||||
|
||||
def test_entries_sorted_by_updated_desc(self):
|
||||
with patch("app.main.get_all_assessments", return_value=_fake_assessments()):
|
||||
resp = client.get("/api/feed.xml")
|
||||
root = ET.fromstring(resp.content)
|
||||
updateds = [
|
||||
e.find(f"{ATOM_NS}updated").text
|
||||
for e in root.findall(f"{ATOM_NS}entry")
|
||||
]
|
||||
# Strip Z-suffix fuer Vergleich
|
||||
bare = [u.rstrip("Z") for u in updateds]
|
||||
assert bare == sorted(bare, reverse=True), updateds
|
||||
|
||||
def test_html_escaping_in_titles(self):
|
||||
"""Anti-Terror-Paket & Überwachung — & muss als & im XML stehen."""
|
||||
with patch("app.main.get_all_assessments", return_value=_fake_assessments()):
|
||||
resp = client.get("/api/feed.xml")
|
||||
# Roh-XML pruefen, nicht den geparsten Inhalt
|
||||
body = resp.text
|
||||
# Das Ampersand muss als & codiert sein
|
||||
assert "Anti-Terror-Paket &" in body or "Anti-Terror-Paket &#" in body
|
||||
# Der Roh-String darf kein nacktes & vor Whitespace haben
|
||||
assert "Paket & Überw" not in body
|
||||
|
||||
def test_partei_filter_narrows_results(self):
|
||||
with patch("app.main.get_all_assessments", return_value=_fake_assessments()):
|
||||
resp_all = client.get("/api/feed.xml")
|
||||
resp_cdu = client.get("/api/feed.xml?partei=CDU")
|
||||
all_count = len(ET.fromstring(resp_all.content).findall(f"{ATOM_NS}entry"))
|
||||
cdu_count = len(ET.fromstring(resp_cdu.content).findall(f"{ATOM_NS}entry"))
|
||||
assert cdu_count == 1
|
||||
assert cdu_count < all_count
|
||||
|
||||
def test_bundesland_filter_passed_to_query(self):
|
||||
"""Der bundesland-Parameter wird an get_all_assessments durchgereicht."""
|
||||
with patch("app.main.get_all_assessments", return_value=_fake_assessments()) as m:
|
||||
client.get("/api/feed.xml?bundesland=NRW")
|
||||
m.assert_called_once_with("NRW")
|
||||
|
||||
def test_etag_header_set(self):
|
||||
with patch("app.main.get_all_assessments", return_value=_fake_assessments()):
|
||||
resp = client.get("/api/feed.xml")
|
||||
assert "etag" in {k.lower() for k in resp.headers}
|
||||
etag = resp.headers["etag"]
|
||||
assert etag.startswith('"') and etag.endswith('"')
|
||||
|
||||
def test_etag_304_not_modified(self):
|
||||
with patch("app.main.get_all_assessments", return_value=_fake_assessments()):
|
||||
resp1 = client.get("/api/feed.xml")
|
||||
etag = resp1.headers["etag"]
|
||||
resp2 = client.get("/api/feed.xml", headers={"If-None-Match": etag})
|
||||
assert resp2.status_code == 304
|
||||
|
||||
def test_limit_clamped_to_200(self):
|
||||
big_input = _fake_assessments() * 100 # 300 Eintraege
|
||||
with patch("app.main.get_all_assessments", return_value=big_input):
|
||||
resp = client.get("/api/feed.xml?limit=500")
|
||||
root = ET.fromstring(resp.content)
|
||||
entries = root.findall(f"{ATOM_NS}entry")
|
||||
assert len(entries) == 200
|
||||
|
||||
def test_limit_clamped_to_min_1(self):
|
||||
with patch("app.main.get_all_assessments", return_value=_fake_assessments()):
|
||||
resp = client.get("/api/feed.xml?limit=0")
|
||||
root = ET.fromstring(resp.content)
|
||||
entries = root.findall(f"{ATOM_NS}entry")
|
||||
assert len(entries) >= 1
|
||||
|
||||
def test_empty_db_returns_valid_feed(self):
|
||||
with patch("app.main.get_all_assessments", return_value=[]):
|
||||
resp = client.get("/api/feed.xml")
|
||||
assert resp.status_code == 200
|
||||
root = ET.fromstring(resp.content)
|
||||
# Pflicht-Elemente trotzdem da
|
||||
assert root.find(f"{ATOM_NS}id") is not None
|
||||
assert root.find(f"{ATOM_NS}title") is not None
|
||||
# Aber keine Entries
|
||||
assert root.findall(f"{ATOM_NS}entry") == []
|
||||
|
||||
def test_cors_header_present(self):
|
||||
with patch("app.main.get_all_assessments", return_value=[]):
|
||||
resp = client.get("/api/feed.xml")
|
||||
assert resp.headers.get("access-control-allow-origin") == "*"
|
||||
|
||||
def test_self_url_includes_filter_params(self):
|
||||
with patch("app.main.get_all_assessments", return_value=_fake_assessments()):
|
||||
resp = client.get("/api/feed.xml?bundesland=NRW&partei=GRÜNE")
|
||||
root = ET.fromstring(resp.content)
|
||||
self_link = [l for l in root.findall(f"{ATOM_NS}link") if l.get("rel") == "self"][0]
|
||||
href = self_link.get("href")
|
||||
assert "bundesland=NRW" in href
|
||||
# partei kann URL-codiert sein
|
||||
assert "partei=" in href
|
||||
87
tests/test_wahlperioden.py
Normal file
87
tests/test_wahlperioden.py
Normal file
@ -0,0 +1,87 @@
|
||||
"""Tests fuer app/wahlperioden.py — Datum→WP-Mapping fuer Aggregations-Sicht (#58).
|
||||
|
||||
Backfill aus #134.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import pytest
|
||||
|
||||
from app.wahlperioden import wahlperiode_for, all_wahlperioden
|
||||
from app.bundeslaender import BUNDESLAENDER
|
||||
|
||||
|
||||
class TestWahlperiodeFor:
|
||||
def test_returns_current_wp_for_date_after_start(self):
|
||||
bl = BUNDESLAENDER["NRW"]
|
||||
# ein Tag nach Wahlperiode-Start → aktuelle WP
|
||||
# (lexikographische ISO-Vergleich-Grenze)
|
||||
date_after = bl.wahlperiode_start
|
||||
assert wahlperiode_for(date_after, "NRW") == f"NRW-WP{bl.wahlperiode}"
|
||||
|
||||
def test_returns_previous_wp_for_date_before_start(self):
|
||||
bl = BUNDESLAENDER["NRW"]
|
||||
# ein Datum klar vor dem WP-Start
|
||||
assert wahlperiode_for("2010-01-01", "NRW") == f"NRW-WP{bl.wahlperiode - 1}"
|
||||
|
||||
def test_returns_none_for_unknown_bundesland(self):
|
||||
assert wahlperiode_for("2026-03-18", "XX") is None
|
||||
|
||||
def test_empty_datum_falls_back_to_current_wp(self):
|
||||
bl = BUNDESLAENDER["NRW"]
|
||||
assert wahlperiode_for("", "NRW") == f"NRW-WP{bl.wahlperiode}"
|
||||
|
||||
def test_none_datum_falls_back_to_current_wp(self):
|
||||
bl = BUNDESLAENDER["NRW"]
|
||||
# Aufrufer schickt None; der Code prueft `if not datum`
|
||||
assert wahlperiode_for(None, "NRW") == f"NRW-WP{bl.wahlperiode}"
|
||||
|
||||
def test_boundary_date_equals_wp_start(self):
|
||||
"""An der WP-Start-Grenze gehoert der Tag zur neuen WP (>=)."""
|
||||
bl = BUNDESLAENDER["MV"]
|
||||
assert wahlperiode_for(bl.wahlperiode_start, "MV") == f"MV-WP{bl.wahlperiode}"
|
||||
|
||||
def test_doctest_examples(self):
|
||||
"""Die Docstring-Examples muessen halten."""
|
||||
# 2026-03-18 ist nach MV WP8-Start (2021-09-26)
|
||||
assert wahlperiode_for("2026-03-18", "MV") == "MV-WP8"
|
||||
# 2020-01-01 ist davor → WP7
|
||||
assert wahlperiode_for("2020-01-01", "MV") == "MV-WP7"
|
||||
|
||||
def test_lexicographic_iso_date_works(self):
|
||||
"""ISO-Format YYYY-MM-DD vergleicht lexikographisch korrekt."""
|
||||
bl = BUNDESLAENDER["NRW"]
|
||||
start = bl.wahlperiode_start # z.B. "2022-06-01"
|
||||
# Ein Tag davor (gleiches Jahr) gehoert zur Vorgaenger-WP
|
||||
if start[5:7] != "01" or start[8:10] != "01":
|
||||
# nicht 1. Januar — Day-1 Test einfach moeglich
|
||||
year, month, day = int(start[:4]), int(start[5:7]), int(start[8:10])
|
||||
if day > 1:
|
||||
day_before = f"{year:04d}-{month:02d}-{day-1:02d}"
|
||||
else:
|
||||
day_before = f"{year:04d}-{month-1:02d}-28"
|
||||
assert wahlperiode_for(day_before, "NRW") == f"NRW-WP{bl.wahlperiode - 1}"
|
||||
|
||||
|
||||
class TestAllWahlperioden:
|
||||
def test_includes_each_bundesland(self):
|
||||
all_wp = all_wahlperioden()
|
||||
# pro BL zwei Eintraege (current + previous)
|
||||
assert len(all_wp) == len(BUNDESLAENDER) * 2
|
||||
|
||||
def test_format_is_BL_WPn(self):
|
||||
for entry in all_wahlperioden():
|
||||
parts = entry.split("-WP")
|
||||
assert len(parts) == 2, entry
|
||||
bl_code, wp_num = parts
|
||||
assert bl_code in BUNDESLAENDER, bl_code
|
||||
assert wp_num.isdigit(), wp_num
|
||||
|
||||
def test_no_duplicates(self):
|
||||
all_wp = all_wahlperioden()
|
||||
assert len(all_wp) == len(set(all_wp))
|
||||
|
||||
def test_contains_known_examples(self):
|
||||
all_wp = all_wahlperioden()
|
||||
# NRW WP18 + 17 muessen drin sein
|
||||
assert "NRW-WP18" in all_wp
|
||||
assert "NRW-WP17" in all_wp
|
||||
@ -177,6 +177,211 @@ class TestFetchAndVerify:
|
||||
assert result["changed"] is True
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Test 4: SHA-Lock-File — Pferdetausch-Schutz (#138)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestShaLock:
|
||||
"""Regression: abgeordnetenwatch hat das CDU-BE-2023-PDF unter dem alten
|
||||
Slug-Namen gegen das CDU-BE-2026-PDF ersetzt. Der Lock-File-Mechanismus
|
||||
muss solche stillen Tausch-Aktionen abfangen."""
|
||||
|
||||
def _patch_lock_file(self, tmp_path):
|
||||
"""Setzt den Lock-File-Pfad auf einen tmp-Pfad fuer den Test."""
|
||||
return patch("app.wahlprogramm_fetch._LOCK_FILE", tmp_path / "lock.json")
|
||||
|
||||
def _urlopen_with(self, content: bytes):
|
||||
def _u(url_or_req, timeout=None):
|
||||
class _R:
|
||||
def read(self_inner):
|
||||
return content
|
||||
def __enter__(self_inner):
|
||||
return self_inner
|
||||
def __exit__(self_inner, *a):
|
||||
pass
|
||||
return _R()
|
||||
return _u
|
||||
|
||||
def test_first_download_pins_sha(self, tmp_path):
|
||||
"""Erster Download → Lock-File wird angelegt mit dem neuen SHA."""
|
||||
dest = tmp_path / "cdu-be.pdf"
|
||||
content = b"%PDF original CDU BE 2021"
|
||||
|
||||
with self._patch_lock_file(tmp_path), \
|
||||
patch("urllib.request.urlopen", self._urlopen_with(content)):
|
||||
result = fetch_and_verify("https://example.com/cdu-be.pdf", dest)
|
||||
|
||||
assert result["ok"] is True
|
||||
assert result["lock_updated"] is True
|
||||
lock_path = tmp_path / "lock.json"
|
||||
assert lock_path.exists()
|
||||
import json
|
||||
lock = json.loads(lock_path.read_text())
|
||||
assert lock["cdu-be.pdf"] == _sha(content)
|
||||
|
||||
def test_second_download_with_same_content_passes(self, tmp_path):
|
||||
"""Zweiter Download mit gleichem Inhalt → ok, changed=False."""
|
||||
dest = tmp_path / "cdu-be.pdf"
|
||||
content = b"%PDF original CDU BE 2021"
|
||||
dest.write_bytes(content)
|
||||
# Lock vorbereiten
|
||||
import json
|
||||
(tmp_path / "lock.json").write_text(json.dumps({"cdu-be.pdf": _sha(content)}))
|
||||
|
||||
with self._patch_lock_file(tmp_path), \
|
||||
patch("urllib.request.urlopen", self._urlopen_with(content)):
|
||||
result = fetch_and_verify("https://example.com/cdu-be.pdf", dest)
|
||||
|
||||
assert result["ok"] is True
|
||||
assert result["changed"] is False
|
||||
|
||||
def test_pferdetausch_blocks_silent_replacement(self, tmp_path):
|
||||
"""KRITISCH: lokal liegt 'CDU BE 2021', Server liefert 'CDU BE 2026'.
|
||||
Lock zeigt SHA von 2021 → fetch muss ABBRECHEN, nicht ueberschreiben."""
|
||||
dest = tmp_path / "cdu-be-2023.pdf"
|
||||
original_content = b"%PDF CDU Berlin 2021-2026 Wahlprogramm"
|
||||
replaced_content = b"%PDF CDU Berlin-Plan 2026 (replaced!)"
|
||||
dest.write_bytes(original_content)
|
||||
# Lock pinnt den Original-SHA
|
||||
import json
|
||||
(tmp_path / "lock.json").write_text(
|
||||
json.dumps({"cdu-be-2023.pdf": _sha(original_content)})
|
||||
)
|
||||
|
||||
with self._patch_lock_file(tmp_path), \
|
||||
patch("urllib.request.urlopen", self._urlopen_with(replaced_content)):
|
||||
result = fetch_and_verify("https://example.com/cdu-be-2023.pdf", dest)
|
||||
|
||||
assert result["ok"] is False
|
||||
assert "Lock-Pruefung" in result["error"]
|
||||
# Datei darf NICHT ueberschrieben sein
|
||||
assert dest.read_bytes() == original_content
|
||||
|
||||
def test_accept_new_sha_overrides_lock(self, tmp_path):
|
||||
"""Mit accept_new_sha=True wird der Lock bewusst aktualisiert."""
|
||||
dest = tmp_path / "linke-bb.pdf"
|
||||
original_content = b"%PDF v1"
|
||||
new_content = b"%PDF v2 - intentional update"
|
||||
dest.write_bytes(original_content)
|
||||
import json
|
||||
(tmp_path / "lock.json").write_text(
|
||||
json.dumps({"linke-bb.pdf": _sha(original_content)})
|
||||
)
|
||||
|
||||
with self._patch_lock_file(tmp_path), \
|
||||
patch("urllib.request.urlopen", self._urlopen_with(new_content)):
|
||||
result = fetch_and_verify(
|
||||
"https://example.com/linke-bb.pdf", dest,
|
||||
accept_new_sha=True,
|
||||
)
|
||||
|
||||
assert result["ok"] is True
|
||||
assert result["changed"] is True
|
||||
# Lock muss neuen SHA haben
|
||||
lock = json.loads((tmp_path / "lock.json").read_text())
|
||||
assert lock["linke-bb.pdf"] == _sha(new_content)
|
||||
|
||||
def test_existing_file_without_lock_pins_silently(self, tmp_path):
|
||||
"""File ist da aber Lock fehlt (Migration-Szenario): bei naechstem
|
||||
identischen fetch wird der SHA gepinnt, kein Block."""
|
||||
dest = tmp_path / "spd-mv.pdf"
|
||||
content = b"%PDF SPD MV 2021"
|
||||
dest.write_bytes(content)
|
||||
# Kein Lock-Eintrag
|
||||
|
||||
with self._patch_lock_file(tmp_path), \
|
||||
patch("urllib.request.urlopen", self._urlopen_with(content)):
|
||||
result = fetch_and_verify("https://example.com/spd-mv.pdf", dest)
|
||||
|
||||
assert result["ok"] is True
|
||||
assert result["lock_updated"] is True
|
||||
import json
|
||||
lock = json.loads((tmp_path / "lock.json").read_text())
|
||||
assert lock["spd-mv.pdf"] == _sha(content)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Test 5: Lock-File und YAML-Robustheit (#134 Coverage-Backfill)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestLockFileRobustness:
|
||||
def test_corrupt_lock_file_returns_empty_dict(self, tmp_path):
|
||||
"""Kaputtes JSON darf den Caller nicht crashen — leeren Lock liefern."""
|
||||
from app.wahlprogramm_fetch import _load_lock
|
||||
bad = tmp_path / "broken-lock.json"
|
||||
bad.write_text("{ this is not json ;)")
|
||||
with patch("app.wahlprogramm_fetch._LOCK_FILE", bad):
|
||||
result = _load_lock()
|
||||
assert result == {}
|
||||
|
||||
def test_missing_lock_file_returns_empty_dict(self, tmp_path):
|
||||
from app.wahlprogramm_fetch import _load_lock
|
||||
missing = tmp_path / "no-such-file.json"
|
||||
with patch("app.wahlprogramm_fetch._LOCK_FILE", missing):
|
||||
assert _load_lock() == {}
|
||||
|
||||
def test_save_lock_writes_valid_json(self, tmp_path):
|
||||
from app.wahlprogramm_fetch import _save_lock
|
||||
target = tmp_path / "lock.json"
|
||||
with patch("app.wahlprogramm_fetch._LOCK_FILE", target):
|
||||
_save_lock({"x.pdf": "abc123", "y.pdf": "def456"})
|
||||
import json
|
||||
loaded = json.loads(target.read_text())
|
||||
assert loaded == {"x.pdf": "abc123", "y.pdf": "def456"}
|
||||
|
||||
|
||||
class TestLoadLinks:
|
||||
def test_missing_yaml_returns_empty(self, tmp_path):
|
||||
from app.wahlprogramm_fetch import _load_links
|
||||
with patch("app.wahlprogramm_fetch._LINKS_FILE", tmp_path / "missing.yaml"):
|
||||
assert _load_links() == {}
|
||||
|
||||
def test_empty_yaml_returns_empty(self, tmp_path):
|
||||
from app.wahlprogramm_fetch import _load_links
|
||||
target = tmp_path / "empty.yaml"
|
||||
target.write_text("")
|
||||
with patch("app.wahlprogramm_fetch._LINKS_FILE", target):
|
||||
assert _load_links() == {}
|
||||
|
||||
# Hinweis: yaml ist im Unit-Setup gestubbed (siehe Top-of-File), deshalb
|
||||
# testen wir _load_links nur mit existing-vs-missing-File. Die echte
|
||||
# YAML-Parsing-Logik wird in der integration-Suite gegen die echte
|
||||
# links.yaml validiert.
|
||||
|
||||
|
||||
class TestGetMissingProgrammes:
|
||||
"""Tests fuer get_missing_programmes — listet BL/Partei-Kombinationen mit
|
||||
Kandidaten-URL aber fehlender lokaler Datei. yaml ist gestubbed; Tests
|
||||
patchen daher _load_links direkt."""
|
||||
|
||||
def test_no_yaml_returns_empty(self):
|
||||
from app.wahlprogramm_fetch import get_missing_programmes
|
||||
with patch("app.wahlprogramm_fetch._load_links", return_value={}):
|
||||
assert get_missing_programmes() == []
|
||||
|
||||
def test_lists_entries_when_file_missing(self, tmp_path):
|
||||
"""Eintrag in YAML, registriertes WAHLPROGRAMME-File fehlt → listed."""
|
||||
from app.wahlprogramm_fetch import get_missing_programmes
|
||||
fake_links = {"BX": {"XYZ": [{"url": "https://example.com/x.pdf"}]}}
|
||||
with patch("app.wahlprogramm_fetch._load_links", return_value=fake_links):
|
||||
with patch("app.wahlprogramm_fetch._REFERENZEN_DIR", tmp_path / "ref"):
|
||||
missing = get_missing_programmes()
|
||||
codes = [m["bl"] for m in missing]
|
||||
assert "BX" in codes
|
||||
|
||||
def test_bundesland_filter(self, tmp_path):
|
||||
from app.wahlprogramm_fetch import get_missing_programmes
|
||||
fake_links = {
|
||||
"BX": {"XYZ": [{"url": "https://example.com/x.pdf"}]},
|
||||
"BY": {"ABC": [{"url": "https://example.com/y.pdf"}]},
|
||||
}
|
||||
with patch("app.wahlprogramm_fetch._load_links", return_value=fake_links):
|
||||
with patch("app.wahlprogramm_fetch._REFERENZEN_DIR", tmp_path / "ref"):
|
||||
missing = get_missing_programmes(bundesland="BX")
|
||||
codes = {m["bl"] for m in missing}
|
||||
assert codes == {"BX"}
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Test 4: og_card — cache_key Determinismus und Cache-Miss/Hit
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
Loading…
Reference in New Issue
Block a user