Compare commits
5 Commits
5ea507b771
...
0870e8a910
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
0870e8a910 | ||
|
|
65d7dfeb88 | ||
|
|
f0f1c39911 | ||
|
|
8bd311dbc8 | ||
|
|
07507de24a |
27
app/main.py
27
app/main.py
@ -633,6 +633,33 @@ async def list_bundeslaender():
|
|||||||
|
|
||||||
# === Quellen / Programme ===
|
# === Quellen / Programme ===
|
||||||
|
|
||||||
|
@app.get("/methodik", response_class=HTMLResponse)
|
||||||
|
async def methodik_page(request: Request):
|
||||||
|
"""Transparenz-/Methodik-Seite (#96)."""
|
||||||
|
from .bundeslaender import aktive_bundeslaender, BUNDESLAENDER
|
||||||
|
from .embeddings import get_indexing_status
|
||||||
|
|
||||||
|
bl_list = []
|
||||||
|
for bl in aktive_bundeslaender():
|
||||||
|
bl_list.append({
|
||||||
|
"code": bl.code,
|
||||||
|
"name": bl.name,
|
||||||
|
"doku_system": bl.doku_system,
|
||||||
|
})
|
||||||
|
|
||||||
|
status = get_indexing_status()
|
||||||
|
|
||||||
|
return templates.TemplateResponse("methodik.html", {
|
||||||
|
"request": request,
|
||||||
|
"app_name": settings.app_name,
|
||||||
|
"adapter_count": len(ADAPTERS),
|
||||||
|
"model_name": settings.llm_model_default,
|
||||||
|
"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"]),
|
||||||
|
})
|
||||||
|
|
||||||
|
|
||||||
@app.get("/quellen", response_class=HTMLResponse)
|
@app.get("/quellen", response_class=HTMLResponse)
|
||||||
async def quellen_page(request: Request):
|
async def quellen_page(request: Request):
|
||||||
"""Quellen-Seite mit allen Wahl- und Parteiprogrammen."""
|
"""Quellen-Seite mit allen Wahl- und Parteiprogrammen."""
|
||||||
|
|||||||
@ -700,6 +700,7 @@
|
|||||||
<button class="mode-btn" onclick="showMode('tags')">🏷️ Tags</button>
|
<button class="mode-btn" onclick="showMode('tags')">🏷️ Tags</button>
|
||||||
<button class="mode-btn" onclick="showMode('upload')">📤 Prüfen</button>
|
<button class="mode-btn" onclick="showMode('upload')">📤 Prüfen</button>
|
||||||
<a href="/quellen" class="mode-btn" style="text-decoration: none;">📚 Quellen</a>
|
<a href="/quellen" class="mode-btn" style="text-decoration: none;">📚 Quellen</a>
|
||||||
|
<a href="/methodik" class="mode-btn" style="text-decoration: none;">🔍 Methodik</a>
|
||||||
<button id="auth-btn" class="mode-btn" style="border:none;cursor:pointer;font-size:0.85rem;">🔑 Anmelden</button>
|
<button id="auth-btn" class="mode-btn" style="border:none;cursor:pointer;font-size:0.85rem;">🔑 Anmelden</button>
|
||||||
<a href="/auswertungen" class="mode-btn" style="text-decoration: none;">📈 Auswertungen</a>
|
<a href="/auswertungen" class="mode-btn" style="text-decoration: none;">📈 Auswertungen</a>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
409
app/templates/methodik.html
Normal file
409
app/templates/methodik.html
Normal file
@ -0,0 +1,409 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="de">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<title>Methodik — {{ app_name }}</title>
|
||||||
|
<style>
|
||||||
|
:root {
|
||||||
|
--color-darkgray: #5a5a5a;
|
||||||
|
--color-green: #889e33;
|
||||||
|
--color-blue: #009da5;
|
||||||
|
--color-lightgray: #bfbfbf;
|
||||||
|
--color-bg: #f5f5f5;
|
||||||
|
--color-amber: #ffc107;
|
||||||
|
}
|
||||||
|
* { box-sizing: border-box; margin: 0; padding: 0; }
|
||||||
|
body {
|
||||||
|
font-family: 'Avenir', 'Segoe UI', sans-serif;
|
||||||
|
color: var(--color-darkgray);
|
||||||
|
line-height: 1.6;
|
||||||
|
background: var(--color-bg);
|
||||||
|
}
|
||||||
|
.header {
|
||||||
|
background: white;
|
||||||
|
padding: 1rem 2rem;
|
||||||
|
border-bottom: 1px solid var(--color-lightgray);
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 1rem;
|
||||||
|
}
|
||||||
|
.header h1 { color: var(--color-blue); font-size: 1.5rem; }
|
||||||
|
.header a { color: var(--color-blue); text-decoration: none; }
|
||||||
|
.container { max-width: 900px; margin: 2rem auto; padding: 0 2rem; }
|
||||||
|
h2 {
|
||||||
|
color: var(--color-blue);
|
||||||
|
margin: 2rem 0 1rem;
|
||||||
|
padding-bottom: 0.5rem;
|
||||||
|
border-bottom: 2px solid var(--color-blue);
|
||||||
|
}
|
||||||
|
h3 { color: var(--color-green); margin: 1.5rem 0 0.5rem; }
|
||||||
|
.card {
|
||||||
|
background: white;
|
||||||
|
padding: 1.5rem;
|
||||||
|
border-radius: 8px;
|
||||||
|
margin-bottom: 1.5rem;
|
||||||
|
box-shadow: 0 1px 3px rgba(0,0,0,.08);
|
||||||
|
}
|
||||||
|
.matrix-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: auto repeat(5, 1fr);
|
||||||
|
gap: 2px;
|
||||||
|
font-size: 0.8rem;
|
||||||
|
margin: 1rem 0;
|
||||||
|
}
|
||||||
|
.matrix-grid .cell {
|
||||||
|
padding: 0.4rem;
|
||||||
|
text-align: center;
|
||||||
|
background: #f8f9fa;
|
||||||
|
border: 1px solid #e0e0e0;
|
||||||
|
}
|
||||||
|
.matrix-grid .header-cell {
|
||||||
|
background: var(--color-blue);
|
||||||
|
color: white;
|
||||||
|
font-weight: bold;
|
||||||
|
}
|
||||||
|
.matrix-grid .row-header {
|
||||||
|
background: var(--color-green);
|
||||||
|
color: white;
|
||||||
|
font-weight: bold;
|
||||||
|
text-align: left;
|
||||||
|
}
|
||||||
|
details { margin: 0.5rem 0; }
|
||||||
|
details summary {
|
||||||
|
cursor: pointer;
|
||||||
|
color: var(--color-blue);
|
||||||
|
font-weight: 600;
|
||||||
|
padding: 0.3rem 0;
|
||||||
|
}
|
||||||
|
details summary:hover { text-decoration: underline; }
|
||||||
|
.pipeline-step {
|
||||||
|
display: flex;
|
||||||
|
align-items: flex-start;
|
||||||
|
gap: 1rem;
|
||||||
|
margin: 0.75rem 0;
|
||||||
|
padding: 0.75rem;
|
||||||
|
background: #f8f9fa;
|
||||||
|
border-radius: 6px;
|
||||||
|
border-left: 3px solid var(--color-blue);
|
||||||
|
}
|
||||||
|
.step-num {
|
||||||
|
background: var(--color-blue);
|
||||||
|
color: white;
|
||||||
|
width: 28px; height: 28px;
|
||||||
|
border-radius: 50%;
|
||||||
|
display: flex; align-items: center; justify-content: center;
|
||||||
|
font-weight: bold;
|
||||||
|
font-size: 0.85rem;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
.note {
|
||||||
|
background: #fff3cd;
|
||||||
|
border-left: 3px solid var(--color-amber);
|
||||||
|
padding: 0.75rem 1rem;
|
||||||
|
margin: 1rem 0;
|
||||||
|
border-radius: 4px;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
}
|
||||||
|
table { border-collapse: collapse; width: 100%; margin: 0.5rem 0; }
|
||||||
|
th, td { padding: 0.5rem; border: 1px solid #e0e0e0; text-align: left; font-size: 0.9rem; }
|
||||||
|
th { background: #f0f0f0; }
|
||||||
|
a { color: var(--color-blue); }
|
||||||
|
.footer { text-align: center; padding: 2rem; color: #999; font-size: 0.85rem; }
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div class="header">
|
||||||
|
<h1>{{ app_name }}</h1>
|
||||||
|
<a href="/">Bewertungen</a>
|
||||||
|
<a href="/auswertungen">Auswertungen</a>
|
||||||
|
<a href="/quellen">Quellen</a>
|
||||||
|
<strong>Methodik</strong>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="container">
|
||||||
|
<h2>Wie funktioniert der GWÖ-Antragsprüfer?</h2>
|
||||||
|
|
||||||
|
<div class="card">
|
||||||
|
<p>
|
||||||
|
Der GWÖ-Antragsprüfer bewertet Parlamentsanträge automatisch nach der
|
||||||
|
<strong>Gemeinwohl-Ökonomie Matrix 2.0 für Gemeinden</strong>. Jede Bewertung
|
||||||
|
analysiert drei Dimensionen: GWÖ-Treue, Übereinstimmung mit Wahlprogrammen
|
||||||
|
und Übereinstimmung mit Grundsatzprogrammen der Parteien.
|
||||||
|
</p>
|
||||||
|
<p style="margin-top: 0.5rem;">
|
||||||
|
Alle Bewertungen werden durch ein KI-Sprachmodell erzeugt und anschließend
|
||||||
|
<strong>automatisch verifiziert</strong> — Zitate werden gegen die Originaltexte
|
||||||
|
der Wahlprogramme geprüft, nicht-verifizierbare Zitate werden verworfen.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<h2>Die GWÖ-Matrix 2.0</h2>
|
||||||
|
|
||||||
|
<div class="card">
|
||||||
|
<p>Die Matrix besteht aus <strong>5 Berührungsgruppen</strong> (Zeilen) und
|
||||||
|
<strong>5 Werten</strong> (Spalten) = 25 Themenfelder:</p>
|
||||||
|
|
||||||
|
<div class="matrix-grid">
|
||||||
|
<div class="cell"></div>
|
||||||
|
<div class="header-cell">Menschen­würde</div>
|
||||||
|
<div class="header-cell">Solidarität</div>
|
||||||
|
<div class="header-cell">Ökologische Nachhaltig­keit</div>
|
||||||
|
<div class="header-cell">Soziale Gerechtig­keit</div>
|
||||||
|
<div class="header-cell">Transparenz & Demokratie</div>
|
||||||
|
|
||||||
|
<div class="row-header">A · Lieferanten</div>
|
||||||
|
<div class="cell">A1</div><div class="cell">A2</div><div class="cell">A3</div><div class="cell">A4</div><div class="cell">A5</div>
|
||||||
|
|
||||||
|
<div class="row-header">B · Finanzen</div>
|
||||||
|
<div class="cell">B1</div><div class="cell">B2</div><div class="cell">B3</div><div class="cell">B4</div><div class="cell">B5</div>
|
||||||
|
|
||||||
|
<div class="row-header">C · Verwaltung</div>
|
||||||
|
<div class="cell">C1</div><div class="cell">C2</div><div class="cell">C3</div><div class="cell">C4</div><div class="cell">C5</div>
|
||||||
|
|
||||||
|
<div class="row-header">D · Bürger</div>
|
||||||
|
<div class="cell">D1</div><div class="cell">D2</div><div class="cell">D3</div><div class="cell">D4</div><div class="cell">D5</div>
|
||||||
|
|
||||||
|
<div class="row-header">E · Gesellschaft</div>
|
||||||
|
<div class="cell">E1</div><div class="cell">E2</div><div class="cell">E3</div><div class="cell">E4</div><div class="cell">E5</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p>Jedes Feld wird auf einer Skala von <strong>-5</strong> (fundamental widersprechend)
|
||||||
|
bis <strong>+5</strong> (stark fördernd) bewertet. Der Gesamtscore (0-10) gewichtet
|
||||||
|
die Matrix-Bewertungen und berücksichtigt Ausschlusskriterien:</p>
|
||||||
|
|
||||||
|
<table>
|
||||||
|
<tr><th>Symbol</th><th>Rating</th><th>Bedeutung</th></tr>
|
||||||
|
<tr><td>++</td><td>+4 bis +5</td><td>Stark fördernd, vorbildlich</td></tr>
|
||||||
|
<tr><td>+</td><td>+1 bis +3</td><td>Fördernd</td></tr>
|
||||||
|
<tr><td>○</td><td>0</td><td>Neutral / nicht berührt</td></tr>
|
||||||
|
<tr><td>−</td><td>-1 bis -3</td><td>Widersprechend</td></tr>
|
||||||
|
<tr><td>−−</td><td>-4 bis -5</td><td>Stark widersprechend</td></tr>
|
||||||
|
</table>
|
||||||
|
|
||||||
|
<details>
|
||||||
|
<summary>Mehr zur GWÖ-Matrix</summary>
|
||||||
|
<p style="margin-top: 0.5rem;">
|
||||||
|
Die Matrix basiert auf dem
|
||||||
|
<a href="https://econgood.org" target="_blank">Arbeitsbuch der Gemeinwohl-Ökonomie</a>.
|
||||||
|
Die Adaption für Gemeinden fokussiert auf kommunale Handlungsfelder:
|
||||||
|
Beschaffung, Haushalt, Verwaltung, Daseinsvorsorge und überregionale Wirkung.
|
||||||
|
</p>
|
||||||
|
</details>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<h2>Analyse-Pipeline</h2>
|
||||||
|
|
||||||
|
<div class="card">
|
||||||
|
<p>Jede Bewertung durchläuft fünf Schritte:</p>
|
||||||
|
|
||||||
|
<div class="pipeline-step">
|
||||||
|
<div class="step-num">1</div>
|
||||||
|
<div>
|
||||||
|
<strong>Antrags-Text herunterladen</strong><br>
|
||||||
|
Der Volltext wird automatisch aus dem jeweiligen Landtags-Portal geholt
|
||||||
|
({{ adapter_count }} Parlamente angebunden). Der PDF-Text wird via PyMuPDF extrahiert.
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="pipeline-step">
|
||||||
|
<div class="step-num">2</div>
|
||||||
|
<div>
|
||||||
|
<strong>Relevante Wahlprogramm-Passagen suchen</strong><br>
|
||||||
|
Für <strong>alle Fraktionen der Wahlperiode</strong> werden per Embedding-Suche
|
||||||
|
(Qwen text-embedding-v3) die thematisch relevantesten Passagen aus Wahl- und
|
||||||
|
Grundsatzprogrammen gesucht (Top-5 pro Partei, Cosinus-Ähnlichkeit ≥ 0.45).
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="pipeline-step">
|
||||||
|
<div class="step-num">3</div>
|
||||||
|
<div>
|
||||||
|
<strong>KI-Bewertung</strong><br>
|
||||||
|
Ein Sprachmodell ({{ model_name }}) bewertet den Antrag anhand der GWÖ-Matrix
|
||||||
|
und vergleicht ihn mit den gefundenen Programm-Passagen. Der Prompt enthält
|
||||||
|
strikte Regeln für die Quellenangabe (nur wörtliche Zitate aus den vorgelegten Passagen).
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="pipeline-step">
|
||||||
|
<div class="step-num">4</div>
|
||||||
|
<div>
|
||||||
|
<strong>Zitat-Verifikation</strong><br>
|
||||||
|
Jedes vom Modell genannte Zitat wird <strong>server-seitig verifiziert</strong>:
|
||||||
|
Der zitierte Text muss als Substring (oder 5-Wort-Sequenz) in einem der
|
||||||
|
vorgelegten Chunks auffindbar sein. Nicht-verifizierbare Zitate werden
|
||||||
|
verworfen — Quellenangabe und Seitenzahl werden aus dem echten Treffer
|
||||||
|
rekonstruiert, nicht aus der Modell-Ausgabe übernommen.
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="pipeline-step">
|
||||||
|
<div class="step-num">5</div>
|
||||||
|
<div>
|
||||||
|
<strong>Persistierung & Darstellung</strong><br>
|
||||||
|
Die verifizierte Bewertung wird gespeichert. Klick auf ein Zitat öffnet
|
||||||
|
das Original-Wahlprogramm-PDF mit <strong>gelb markierter Fundstelle</strong>.
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<details>
|
||||||
|
<summary>Technische Details zum Sprachmodell</summary>
|
||||||
|
<div style="margin-top: 0.5rem;">
|
||||||
|
<table>
|
||||||
|
<tr><th>Eigenschaft</th><th>Wert</th></tr>
|
||||||
|
<tr><td>Modell</td><td>{{ model_name }}</td></tr>
|
||||||
|
<tr><td>Anbieter</td><td>DashScope (Alibaba Cloud)</td></tr>
|
||||||
|
<tr><td>Retry bei Parse-Fehlern</td><td>3 Versuche mit steigender Temperatur</td></tr>
|
||||||
|
<tr><td>Embedding-Modell</td><td>text-embedding-v3 (1024 Dimensionen)</td></tr>
|
||||||
|
<tr><td>Chunk-Größe</td><td>400 Wörter, 50 Wörter Overlap</td></tr>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</details>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<h2>Beispiel einer Bewertung</h2>
|
||||||
|
|
||||||
|
<div class="card">
|
||||||
|
<p>Am Beispiel eines fiktiven Antrags "Kostenfreies Schulessen in allen Grundschulen":</p>
|
||||||
|
|
||||||
|
<div class="pipeline-step">
|
||||||
|
<div class="step-num">1</div>
|
||||||
|
<div>
|
||||||
|
<strong>Antragstext wird geladen</strong><br>
|
||||||
|
"Der Landtag wolle beschließen: Die Landesregierung wird aufgefordert,
|
||||||
|
ein Programm für kostenfreies Mittagessen in allen Grundschulen …"
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="pipeline-step">
|
||||||
|
<div class="step-num">2</div>
|
||||||
|
<div>
|
||||||
|
<strong>Embedding-Suche findet relevante Passagen</strong><br>
|
||||||
|
Für jede Fraktion (z.B. SPD, CDU, GRÜNE, FDP, AfD) werden die thematisch
|
||||||
|
nächsten Abschnitte aus den Wahlprogrammen gesucht. Beispiel:
|
||||||
|
<em>"Wir setzen uns für gesunde Ernährung in Kitas und Schulen ein"</em>
|
||||||
|
(GRÜNE Wahlprogramm, S. 47).
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="pipeline-step">
|
||||||
|
<div class="step-num">3</div>
|
||||||
|
<div>
|
||||||
|
<strong>KI bewertet den Antrag</strong><br>
|
||||||
|
GWÖ-Score: <strong>7/10</strong> — berührt D4 (Soziale öffentliche Leistung, ++),
|
||||||
|
E3 (Ökologische Auswirkungen, +), B2 (Gemeinnutz im Finanzgebaren, +).
|
||||||
|
Wahlprogramm-Passung GRÜNE: 9/10 mit Zitat aus S. 47.
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="pipeline-step">
|
||||||
|
<div class="step-num">4</div>
|
||||||
|
<div>
|
||||||
|
<strong>Zitat wird verifiziert</strong><br>
|
||||||
|
Der Server prüft: steht "gesunde Ernährung in Kitas und Schulen"
|
||||||
|
wirklich auf S. 47 des GRÜNE-Wahlprogramms? ✓ Ja → Zitat wird übernommen.
|
||||||
|
Quellenangabe wird aus dem echten Treffer konstruiert.
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<h2>Wahlprogramm-Vergleich</h2>
|
||||||
|
|
||||||
|
<div class="card">
|
||||||
|
<p>
|
||||||
|
Für jede Fraktion der aktuellen Wahlperiode wird die <strong>Passung</strong>
|
||||||
|
des Antrags zu zwei Programmen bewertet:
|
||||||
|
</p>
|
||||||
|
<ul style="margin: 0.5rem 0 0.5rem 1.5rem;">
|
||||||
|
<li><strong>Wahlprogramm</strong> — das Landtags-Wahlprogramm der jeweiligen Legislaturperiode</li>
|
||||||
|
<li><strong>Grundsatzprogramm</strong> — das aktuelle Bundespartei-Grundsatzprogramm</li>
|
||||||
|
</ul>
|
||||||
|
<p>
|
||||||
|
Aktuell sind <strong>{{ programme_count }} Programme</strong> indexiert
|
||||||
|
({{ chunk_count }} Textabschnitte). Die vollständige Liste ist auf der
|
||||||
|
<a href="/quellen">Quellen-Seite</a> einsehbar.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<div class="note">
|
||||||
|
<strong>Wichtig:</strong> Wenn für eine Fraktion kein Programm im Index vorhanden ist,
|
||||||
|
wird kein Score vergeben — stattdessen erscheint ein Hinweis. Bewertungen
|
||||||
|
basieren ausschließlich auf verifizierbaren Quellen, nicht auf dem Trainingswissen
|
||||||
|
des Sprachmodells.
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<h2>Qualitätssicherung</h2>
|
||||||
|
|
||||||
|
<div class="card">
|
||||||
|
<h3>Zitat-Verifikation (Sub-D)</h3>
|
||||||
|
<p>
|
||||||
|
Ein automatisierter Property-Test prüft für jedes in der Datenbank gespeicherte
|
||||||
|
Zitat, ob der zitierte Text tatsächlich auf der angegebenen Seite des
|
||||||
|
Wahlprogramm-PDFs vorkommt (Substring- oder 5-Wort-Anker-Match). Dieses
|
||||||
|
Verfahren hat im April 2026 drei halluzinierte Zitate aufgedeckt und zur
|
||||||
|
Implementierung der server-seitigen Verifikation geführt.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<h3>Server-seitige Quellen-Rekonstruktion</h3>
|
||||||
|
<p>
|
||||||
|
Das Sprachmodell darf keine Quellenangaben (Programmname, Seitenzahl) frei
|
||||||
|
erfinden. Nach jeder Analyse wird jedes Zitat gegen die tatsächlich vorgelegten
|
||||||
|
Textabschnitte abgeglichen. Quellenangabe und URL werden aus dem gefundenen
|
||||||
|
Treffer <strong>server-seitig konstruiert</strong> — die Modell-Ausgabe für
|
||||||
|
diese Felder wird verworfen.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<h3>Automatische Neu-Analyse</h3>
|
||||||
|
<p>
|
||||||
|
Wenn ein Nutzer auf ein Zitat klickt und die Textstelle im PDF nicht auffindbar
|
||||||
|
ist (z.B. bei älteren Bewertungen vor der Verifikations-Einführung), wird der
|
||||||
|
Antrag automatisch mit der aktuellen Pipeline neu analysiert.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<h2>Einschränkungen</h2>
|
||||||
|
|
||||||
|
<div class="card">
|
||||||
|
<ul style="margin-left: 1.5rem;">
|
||||||
|
<li><strong>Keine juristische Bewertung</strong> — die GWÖ-Analyse ist eine
|
||||||
|
wertebasierte Einordnung, keine Rechtsprüfung.</li>
|
||||||
|
<li><strong>KI-Bias</strong> — Sprachmodelle können systematische Verzerrungen
|
||||||
|
aufweisen. Die Bewertungen sollten als Orientierung verstanden werden,
|
||||||
|
nicht als objektive Wahrheit.</li>
|
||||||
|
<li><strong>Nur indexierte Programme</strong> — Parteien ohne hinterlegtes
|
||||||
|
Programm können nicht zuverlässig bewertet werden.</li>
|
||||||
|
<li><strong>Keine Analyse des Abstimmungsverhaltens</strong> — bewertet wird
|
||||||
|
der Antragstext, nicht ob oder wie darüber abgestimmt wurde.</li>
|
||||||
|
<li><strong>Aktualität</strong> — Wahlprogramme werden einmalig zur Wahl
|
||||||
|
indexiert und nicht automatisch aktualisiert.</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<h2>Datenquellen</h2>
|
||||||
|
|
||||||
|
<div class="card">
|
||||||
|
<p><strong>{{ adapter_count }} Parlamente</strong> sind angebunden:</p>
|
||||||
|
<table>
|
||||||
|
<tr><th>Parlament</th><th>Doku-System</th></tr>
|
||||||
|
{% for bl in bundeslaender %}
|
||||||
|
<tr>
|
||||||
|
<td>{{ bl.name }} ({{ bl.code }})</td>
|
||||||
|
<td>{{ bl.doku_system }}</td>
|
||||||
|
</tr>
|
||||||
|
{% endfor %}
|
||||||
|
</table>
|
||||||
|
<p style="margin-top: 1rem;">
|
||||||
|
<a href="/quellen">Vollständige Programm-Liste</a> ·
|
||||||
|
<a href="https://docs.toppyr.de/gwoe-antragspruefer/reference/adapter-capabilities/" target="_blank">Technische Adapter-Vergleichsmatrix</a> ·
|
||||||
|
<a href="https://docs.toppyr.de/gwoe-antragspruefer/adr/" target="_blank">Architektur-Entscheidungen (ADRs)</a>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="footer">
|
||||||
|
{{ app_name }} · <a href="https://econgood.org" target="_blank">Gemeinwohl-Ökonomie</a> ·
|
||||||
|
<a href="https://repo.toppyr.de/tobias/gwoe-antragspruefer" target="_blank">Quellcode</a>
|
||||||
|
</div>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
80
docs/adr/0005-keycloak-sso-with-dev-bypass.md
Normal file
80
docs/adr/0005-keycloak-sso-with-dev-bypass.md
Normal file
@ -0,0 +1,80 @@
|
|||||||
|
# 0005 — Keycloak SSO mit Dev-Bypass für Read/Write-Trennung
|
||||||
|
|
||||||
|
| | |
|
||||||
|
|---|---|
|
||||||
|
| **Status** | accepted |
|
||||||
|
| **Datum** | 2026-04-10 |
|
||||||
|
| **Refs** | Issue #43, Commit 7159240 + 303b30f, app/auth.py |
|
||||||
|
|
||||||
|
## Kontext
|
||||||
|
|
||||||
|
Die Webapp soll öffentlich durchsuchbar sein (Assessments lesen, PDFs
|
||||||
|
ansehen, Auswertungen), aber Analyse-Aktionen (Antrag bewerten, Programme
|
||||||
|
indexieren) nur authentifizierten Nutzern erlauben. Der User betreibt
|
||||||
|
bereits einen Keycloak-Server unter `sso.toppyr.de` mit Realm `collaboration`.
|
||||||
|
|
||||||
|
## Optionen
|
||||||
|
|
||||||
|
### Option A — Keycloak-only mit hartem 401
|
||||||
|
|
||||||
|
Alle POST-Endpoints erfordern JWT. Ohne Keycloak-Server läuft nichts.
|
||||||
|
|
||||||
|
**Vorteile:** Klar, sicher.
|
||||||
|
**Nachteile:** Lokale Entwicklung blockiert ohne Keycloak-Instanz.
|
||||||
|
|
||||||
|
### Option B — Dev-Bypass: Auth nur wenn ENV gesetzt
|
||||||
|
|
||||||
|
Wenn `KEYCLOAK_URL` leer ist → ALLE Endpoints offen (Dev-Modus).
|
||||||
|
Wenn gesetzt → POST-Endpoints erfordern JWT, GET bleibt offen.
|
||||||
|
|
||||||
|
**Vorteile:** Lokale Dev ohne Keycloak. Prod sofort sicher per ENV.
|
||||||
|
**Nachteile:** Versehentlich Prod ohne ENV = alles offen.
|
||||||
|
|
||||||
|
### Option C — API-Key statt Keycloak
|
||||||
|
|
||||||
|
Einfacher API-Key-Header für POST-Endpoints.
|
||||||
|
|
||||||
|
**Vorteile:** Zero-Dependency.
|
||||||
|
**Nachteile:** Kein SSO, kein User-Identity, keine Gruppen/Rollen.
|
||||||
|
|
||||||
|
## Entscheidung
|
||||||
|
|
||||||
|
**Option B.** Dev-Bypass ermöglicht reibungslose lokale Entwicklung und
|
||||||
|
Batch-Skripte (die keinen JWT haben). Prod-Absicherung über die drei
|
||||||
|
ENV-Vars in docker-compose.yml. Die Keycloak-Client-Registrierung in
|
||||||
|
`sso.toppyr.de` ist ein einmaliger manueller Schritt.
|
||||||
|
|
||||||
|
## Implementation
|
||||||
|
|
||||||
|
- `app/auth.py`: JWKS-Cache (1h TTL), `get_current_user` (optional),
|
||||||
|
`require_auth` (pflicht), `keycloak_login_url` (für Browser-Redirect)
|
||||||
|
- POST-Endpoints (`/analyze`, `/api/analyze-drucksache`,
|
||||||
|
`/api/programme/index`): `user: dict = Depends(require_auth)`
|
||||||
|
- GET-Endpoints: unverändert offen
|
||||||
|
- Frontend: `initAuth()` prüft `/api/auth/me`, steuert
|
||||||
|
"Jetzt prüfen"-Button (disabled + Tooltip wenn nicht eingeloggt)
|
||||||
|
und "Anmelden"-Button im Header
|
||||||
|
|
||||||
|
## Konsequenzen
|
||||||
|
|
||||||
|
### Positiv
|
||||||
|
|
||||||
|
- Read-Only für alle, Write nur mit Login — klare Trennung
|
||||||
|
- Batch-Skripte und auto-Re-Analyse im Container laufen im Dev-Modus
|
||||||
|
(KEYCLOAK_URL nicht gesetzt), keine Auth-Hürde für Maintenance
|
||||||
|
- Keycloak-Rollen über `realm_access.roles` im JWT verfügbar für
|
||||||
|
künftige Gruppen-Features (#94 Bookmarks/Kommentare)
|
||||||
|
|
||||||
|
### Negativ
|
||||||
|
|
||||||
|
- Dev-Modus ist unsicher — wenn jemand KEYCLOAK_URL in Prod vergisst
|
||||||
|
zu setzen, ist alles offen. Mitigation: Health-Endpoint oder
|
||||||
|
Startup-Warning wenn ENV fehlt.
|
||||||
|
- JWKS-Cache (1h) bedeutet: nach Key-Rotation dauert es bis zu 1h bis
|
||||||
|
alte Tokens abgelehnt werden. Für dieses Projekt akzeptabel.
|
||||||
|
|
||||||
|
### Folgen für andere ADRs
|
||||||
|
|
||||||
|
- #94 Bookmarks/Kommentare baut auf der User-Identity aus dem JWT auf
|
||||||
|
(`sub`, `email`, `realm_access.roles`).
|
||||||
|
- #95 Queuing könnte Auth-User priorisieren.
|
||||||
@ -21,6 +21,7 @@ und Konsequenzen. Format inspiriert von [Michael Nygard](https://cognitect.com/b
|
|||||||
| [0002](0002-adapter-architecture.md) | Adapter-Pattern mit ParlamentAdapter-Basisklasse + Registry | accepted | 2026-04-10 |
|
| [0002](0002-adapter-architecture.md) | Adapter-Pattern mit ParlamentAdapter-Basisklasse + Registry | accepted | 2026-04-10 |
|
||||||
| [0003](0003-citation-property-tests.md) | Sub-D Property-Verification: Zitate als Substring der zitierten PDF-Seite | accepted | 2026-04-10 |
|
| [0003](0003-citation-property-tests.md) | Sub-D Property-Verification: Zitate als Substring der zitierten PDF-Seite | accepted | 2026-04-10 |
|
||||||
| [0004](0004-deployment-workflow.md) | Docker Compose Deploy mit DB-/Reports-Volume und SN-XML-Sonderpfad | accepted | 2026-04-10 |
|
| [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 für Read/Write-Trennung | accepted | 2026-04-10 |
|
||||||
|
|
||||||
## Wann ADR, wann nicht
|
## Wann ADR, wann nicht
|
||||||
|
|
||||||
|
|||||||
89
docs/how-to/keycloak-setup.md
Normal file
89
docs/how-to/keycloak-setup.md
Normal file
@ -0,0 +1,89 @@
|
|||||||
|
# Keycloak-Client einrichten
|
||||||
|
|
||||||
|
Schritt-für-Schritt-Anleitung zum Aktivieren der Authentifizierung
|
||||||
|
auf `gwoe.toppyr.de`. Voraussetzung: Admin-Zugang zu
|
||||||
|
`sso.toppyr.de` (Keycloak).
|
||||||
|
|
||||||
|
## 1. Client anlegen
|
||||||
|
|
||||||
|
1. Öffne `https://sso.toppyr.de/admin/master/console/`
|
||||||
|
2. Wechsle zum Realm **collaboration** (Dropdown oben links)
|
||||||
|
3. Navigiere zu **Clients** → **Create client**
|
||||||
|
4. Einstellungen:
|
||||||
|
|
||||||
|
| Feld | Wert |
|
||||||
|
|---|---|
|
||||||
|
| Client type | OpenID Connect |
|
||||||
|
| Client ID | `gwoe-antragspruefer` |
|
||||||
|
| Name | GWÖ-Antragsprüfer |
|
||||||
|
| Always display in UI | Off |
|
||||||
|
|
||||||
|
5. **Next** → Capability config:
|
||||||
|
|
||||||
|
| Feld | Wert |
|
||||||
|
|---|---|
|
||||||
|
| Client authentication | **Off** (public client für Browser-Flow) |
|
||||||
|
| Authorization | Off |
|
||||||
|
| Standard flow | **On** |
|
||||||
|
| Direct access grants | Off |
|
||||||
|
|
||||||
|
6. **Next** → Login settings:
|
||||||
|
|
||||||
|
| Feld | Wert |
|
||||||
|
|---|---|
|
||||||
|
| Root URL | `https://gwoe.toppyr.de` |
|
||||||
|
| Home URL | `https://gwoe.toppyr.de` |
|
||||||
|
| Valid redirect URIs | `https://gwoe.toppyr.de/*` |
|
||||||
|
| Valid post logout redirect URIs | `https://gwoe.toppyr.de/*` |
|
||||||
|
| Web origins | `https://gwoe.toppyr.de` |
|
||||||
|
|
||||||
|
7. **Save**
|
||||||
|
|
||||||
|
## 2. Verifizieren
|
||||||
|
|
||||||
|
Nach dem Speichern sollte der "Anmelden"-Button auf `gwoe.toppyr.de`
|
||||||
|
zum Keycloak-Login führen (statt "client not found").
|
||||||
|
|
||||||
|
Schnelltest via curl:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
curl -s "https://sso.toppyr.de/realms/collaboration/protocol/openid-connect/auth?client_id=gwoe-antragspruefer&redirect_uri=https://gwoe.toppyr.de/&response_type=code&scope=openid" -o /dev/null -w "%{http_code}"
|
||||||
|
# Erwartet: 200 (Login-Seite), nicht 400 (client not found)
|
||||||
|
```
|
||||||
|
|
||||||
|
## 3. Optional: Gruppen/Rollen konfigurieren
|
||||||
|
|
||||||
|
Für die künftigen Features Bookmarks/Kommentare (#94) werden
|
||||||
|
Keycloak-Gruppen benötigt. Diese können später angelegt werden:
|
||||||
|
|
||||||
|
1. **Realm roles** → Create role → z.B. `gwoe-editor`, `gwoe-admin`
|
||||||
|
2. **Groups** → Create group → z.B. "Fraktion SPD Hagen", "AG Klima"
|
||||||
|
3. Nutzer den Gruppen zuweisen
|
||||||
|
4. In den Client-Scopes sicherstellen, dass `realm_access.roles` im
|
||||||
|
JWT-Token enthalten ist (Default bei Keycloak ≥ 18)
|
||||||
|
|
||||||
|
Die `auth.py`-Middleware liest `realm_access.roles` aus dem JWT und
|
||||||
|
stellt sie über `user["roles"]` bereit.
|
||||||
|
|
||||||
|
## 4. ENV-Variablen (bereits konfiguriert)
|
||||||
|
|
||||||
|
Die docker-compose.yml enthält bereits:
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
environment:
|
||||||
|
- KEYCLOAK_URL=https://sso.toppyr.de
|
||||||
|
- KEYCLOAK_REALM=collaboration
|
||||||
|
- KEYCLOAK_CLIENT_ID=gwoe-antragspruefer
|
||||||
|
```
|
||||||
|
|
||||||
|
Solange diese gesetzt sind, ist Auth aktiv. Zum Deaktivieren
|
||||||
|
(Dev-Modus): `KEYCLOAK_URL` auf leer setzen.
|
||||||
|
|
||||||
|
## Troubleshooting
|
||||||
|
|
||||||
|
| Problem | Lösung |
|
||||||
|
|---|---|
|
||||||
|
| "client not found" | Client-ID Tippfehler oder Client nicht im richtigen Realm |
|
||||||
|
| 401 bei POST obwohl eingeloggt | Token abgelaufen → neu einloggen; oder JWKS-Cache (1h) hat den neuen Key noch nicht |
|
||||||
|
| "Anmelden" tut nichts | Browser-Konsole prüfen; `/api/auth/login-url` muss `enabled: true` zurückgeben |
|
||||||
|
| Alles offen trotz Keycloak | `KEYCLOAK_URL` in `.env` auf dem Server prüfen — wenn leer, ist Dev-Modus aktiv |
|
||||||
@ -55,6 +55,146 @@ Satz von Treffern nach WP/Datum/Typ und filtert dann im Python-Code per
|
|||||||
| **HE** | Perl-Data::Dumper in HTML-Comments, Hex-Escape-Decoding (`\x{e9}` → `é`) |
|
| **HE** | Perl-Data::Dumper in HTML-Comments, Hex-Escape-Decoding (`\x{e9}` → `é`) |
|
||||||
| **NI** | ❌ **Nicht implementiert** — NILAS-StarWeb braucht Session-Cookie, HAR-Capture nötig |
|
| **NI** | ❌ **Nicht implementiert** — NILAS-StarWeb braucht Session-Cookie, HAR-Capture nötig |
|
||||||
|
|
||||||
|
## Feld-Mapping: Woher kommen die Daten?
|
||||||
|
|
||||||
|
Pro Adapter: welches API-Feld wird zu welchem Drucksache-Feld, mit Beispiel.
|
||||||
|
|
||||||
|
### NRW (NRWAdapter — OPAL HTML)
|
||||||
|
|
||||||
|
| Feld | Quelle | Beispiel |
|
||||||
|
|---|---|---|
|
||||||
|
| title | HTML `.e-document-result-item__title` `<a>` Text | "Kostenloses Parken für E-Fahrzeuge aufheben" |
|
||||||
|
| datum | `<time>` Element, DD.MM.YYYY → ISO | "07.04.2026" → "2026-04-07" |
|
||||||
|
| fraktionen | `<p>` mit "Urheber:" → `extract_fraktionen()` | "Urheber: SPD, GRÜNE" → `["SPD", "GRÜNE"]` |
|
||||||
|
| drucksache | Regex aus href `MMD18-12345.pdf` → "18/12345" | "18/18085" |
|
||||||
|
| link | `https://www.landtag.nrw.de{href}` | `https://www.landtag.nrw.de/.../MMD18-18085.pdf` |
|
||||||
|
| typ | HTML `.e-document-result-item__category` | "Antrag", "Kleine Anfrage" |
|
||||||
|
|
||||||
|
### LSA / BB / RP (PortalaAdapter — eUI Perl-Dump)
|
||||||
|
|
||||||
|
| Feld | Quelle (Perl-Dump-Feld) | Beispiel |
|
||||||
|
|---|---|---|
|
||||||
|
| title | `WEV06[0].main` | "Energiewende und Klimaschutz" |
|
||||||
|
| datum | `WEV32[0].main` → Regex, DD.MM.YYYY → ISO | "01.10.2024" → "2024-10-01" |
|
||||||
|
| fraktionen | `WEV32[0].main` Urheber-Text → `extract_fraktionen()` | "CDU, SPD" → `["CDU", "SPD"]` |
|
||||||
|
| drucksache | `WEV32[0].main` → Regex | "8/6645" |
|
||||||
|
| link | `{base_url}{pdf_url_prefix}{WEV32[0].5}` | `https://padoka.landtag.sachsen-anhalt.de/files/drs/8/6645.pdf` |
|
||||||
|
| typ | Fest: "Antrag" (server-side gefiltert via `ETYPF`) | "Antrag" |
|
||||||
|
|
||||||
|
### BE (PortalaAdapter — eUI HTML-Cards)
|
||||||
|
|
||||||
|
| Feld | Quelle (HTML-Element) | Beispiel |
|
||||||
|
|---|---|---|
|
||||||
|
| title | `<h3 class="h5">` Text | "Kinder- und Familienförderung" |
|
||||||
|
| datum | `<h6>` Metadata-Zeile → Regex DD.MM.YYYY → ISO | "31.03.2026" → "2026-03-31" |
|
||||||
|
| fraktionen | `<h6>` Doctype-Zeile → `extract_fraktionen()` | "Antrag CDU, SPD" → `["CDU", "SPD"]` |
|
||||||
|
| drucksache | `<h6>` → Regex | "19/3104" |
|
||||||
|
| link | `<a href="…pdf">` (http→https, relativ→absolut) | `https://pardok.parlament-berlin.de/...` |
|
||||||
|
| typ | `<h6>` vor Fraktionsnamen | "Antrag", "Vorlage" |
|
||||||
|
|
||||||
|
### MV / HH / TH (ParLDokAdapter — JSON-API)
|
||||||
|
|
||||||
|
| Feld | Quelle (JSON-Feld) | Beispiel |
|
||||||
|
|---|---|---|
|
||||||
|
| title | `title` | "Weiterbildungsförderung" |
|
||||||
|
| datum | `date`, DD.MM.YYYY → ISO | "15.03.2026" → "2026-03-15" |
|
||||||
|
| fraktionen | `authorhtml` → `extract_fraktionen()` | "Klaus Meyer (CDU)" → `["CDU"]` |
|
||||||
|
| drucksache | `f"{lp}/{number}"` | lp=8, number=1594 → "8/1594" |
|
||||||
|
| link | `{base_url}{prefix}{link}` (Fragment `#navpanes=0` gestrippt) | `https://dokumentation.landtag-mv.de/parldok/dokument/8` |
|
||||||
|
| typ | `type` (TH: Substring-Match auf "Antrag") | "Antrag", "Antrag gemäß § 79 GO" |
|
||||||
|
|
||||||
|
### SH (StarFinderCGIAdapter — Legacy CGI)
|
||||||
|
|
||||||
|
| Feld | Quelle (HTML-Regex) | Beispiel |
|
||||||
|
|---|---|---|
|
||||||
|
| title | `<b>…</b>` in Tabellenzeile | "Energiewende vorantreiben" |
|
||||||
|
| datum | Nach `</b>`, DD.MM.YYYY → ISO | "07.04.2026" → "2026-04-07" |
|
||||||
|
| fraktionen | Urheber-Text → `extract_fraktionen()` | "Christian Dirschauer (SSW)" → `["SSW"]` |
|
||||||
|
| drucksache | `<a>` Text | "20/5136" |
|
||||||
|
| link | `<a href="…">` direkt | `http://lissh.lvn.parlanet.de/.../20-5136.pdf` |
|
||||||
|
| typ | Fest: "Antrag" (server-side `dtyp=antrag`) | "Antrag" |
|
||||||
|
|
||||||
|
### HE (StarWebHEAdapter — eUI Perl-Dump)
|
||||||
|
|
||||||
|
| Feld | Quelle (Perl-Dump-Feld) | Beispiel |
|
||||||
|
|---|---|---|
|
||||||
|
| title | `WEV01[0].main` (Hex-Escape-Decoding `\x{e9}` → `é`) | "Schulinfrastruktur modernisieren" |
|
||||||
|
| datum | `WEV02[0].main`, DD.MM.YYYY → ISO | "29.02.2026" → "2026-02-29" |
|
||||||
|
| fraktionen | `WEV12[0].main` → `extract_fraktionen()` | "Klaus Dieter (GRÜNE)" → `["GRÜNE"]` |
|
||||||
|
| drucksache | `WEV08[0].main` | "21/8532" |
|
||||||
|
| link | `WEV07[0].main` (http→https) | `https://starweb.hessen.de/.../21_8532.pdf` |
|
||||||
|
| typ | `WEV03[0].main` | "Antrag", "Antrag mit Änderung" |
|
||||||
|
|
||||||
|
### HB (PARiSHBAdapter — Java-Servlet HTML)
|
||||||
|
|
||||||
|
| Feld | Quelle (HTML-Regex) | Beispiel |
|
||||||
|
|---|---|---|
|
||||||
|
| title | `<h2><a>…</a></h2>` | "Bremenpass für Kultur" |
|
||||||
|
| datum | Nach Drucksache, DD.MM.YYYY → ISO | "23.02.2026" → "2026-02-23" |
|
||||||
|
| fraktionen | Nach Datum → `extract_fraktionen()` | "SPD, BÜNDNIS 90/DIE GRÜNEN, Die Linke" → `["SPD", "GRÜNE", "LINKE"]` |
|
||||||
|
| drucksache | `Drs <b>21/730</b>` + optionaler Suffix (S/L) | "21/730", "21/730S" |
|
||||||
|
| link | `<a href="…pdf" target="new">` | `https://www.bremische-buergerschaft.de/.../21/730.pdf` |
|
||||||
|
| typ | Regex vor Datum | "Antrag", "Änderungsantrag" |
|
||||||
|
|
||||||
|
### BW (PARLISAdapter — eUI async Polling, JSON-in-HTML)
|
||||||
|
|
||||||
|
| Feld | Quelle (Perl/JSON-Feld) | Beispiel |
|
||||||
|
|---|---|---|
|
||||||
|
| title | `WMV33` (Schlagworte, `<i>` gestrippt) / Fallback `EWBV23` | "Energiewirtschaft; Stromversorgung" |
|
||||||
|
| datum | `EWBV23` → Regex DD.MM.YYYY → ISO | "16.03.2026" → "2026-03-16" |
|
||||||
|
| fraktionen | `WMV30` (Kurz-Urheber) → `extract_fraktionen()` | "Felix Herkens (GRÜNE) u. a." → `["GRÜNE"]` |
|
||||||
|
| drucksache | `EWBV22` oder `EWBD01` → Regex | "17/10323" |
|
||||||
|
| link | `EWBD05[0].main` (direkte PDF-URL) | `https://parlis.landtag-bw.de/.../17_10323.pdf` |
|
||||||
|
| typ | Fest: `document_typ="Antrag"` (in `lines.l4`) | "Antrag" |
|
||||||
|
|
||||||
|
### BY (BayernAdapter — TYPO3-Solr HTML)
|
||||||
|
|
||||||
|
| Feld | Quelle (HTML-Regex) | Beispiel |
|
||||||
|
|---|---|---|
|
||||||
|
| title | `<h5><strong>…</strong></h5>` | "Kostenloses Parken für E-Fahrzeuge aufheben" |
|
||||||
|
| datum | `<h4>` "Drucksache Nr. 19/11407 vom 08.04.2026" DD.MM.YYYY → ISO | "08.04.2026" → "2026-04-08" |
|
||||||
|
| fraktionen | `<p>` "Antrag AfD" → `extract_fraktionen()` | "Antrag CSU, FREIE WÄHLER" → `["CSU", "FW-BAYERN"]` |
|
||||||
|
| drucksache | `<h4>` Regex | "19/11407" |
|
||||||
|
| link | `<a href="…pdf">` (absolute URL) | `https://www.bayern.landtag.de/.../0000009107.pdf` |
|
||||||
|
| typ | Erstes Wort aus `<p>` | "Antrag" |
|
||||||
|
|
||||||
|
### SL (SaarlandAdapter — Umbraco JSON)
|
||||||
|
|
||||||
|
| Feld | Quelle (JSON-Feld) | Beispiel |
|
||||||
|
|---|---|---|
|
||||||
|
| title | `Title` | "Schule als Lern- und Bildungsort weiter stärken" |
|
||||||
|
| datum | `PublicDate` ISO-Format, erste 10 Zeichen | "2022-05-12T00:00:00" → "2022-05-12" |
|
||||||
|
| fraktionen | `Publisher` + `DocumentAuthor` → `extract_fraktionen()` | Publisher "CDU" → `["CDU"]` |
|
||||||
|
| drucksache | `DocumentNumber` | "17/11" |
|
||||||
|
| link | `FilePath` mit `/file.ashx` → `/Downloadfile.ashx` Rewrite | `https://www.landtag-saar.de/Downloadfile.ashx?FileId=14230&FileName=Ag17_0011.pdf` |
|
||||||
|
| typ | `DocumentType` | "Antrag", "Anfrage", "Gesetzentwurf" |
|
||||||
|
|
||||||
|
### SN (SNEdasXmlAdapter — Manueller XML-Export)
|
||||||
|
|
||||||
|
| Feld | Quelle (XML-Element) | Beispiel |
|
||||||
|
|---|---|---|
|
||||||
|
| title | `<Titel>` (CDATA) | "Geschäftsordnung des Sächsischen Landtags" |
|
||||||
|
| datum | `<Fundstelle>` → Regex "datum"-Gruppe, DD.MM.YYYY → ISO | "01.10.2024" → "2024-10-01" |
|
||||||
|
| fraktionen | `<Fundstelle>` → Regex "urheber"-Gruppe → `extract_fraktionen()` | "CDU, BSW, SPD" → `["CDU", "BSW", "SPD"]` |
|
||||||
|
| drucksache | `f"{Wahlperiode}/{Dokumentennummer}"` | "8/2" |
|
||||||
|
| link | Konstruiert: `.../viewer.aspx?dok_nr={nr}&dok_art=Drs&leg_per={wp}` (PDF wird on-demand aus Iframe gelöst) | `https://edas.landtag.sachsen.de/.../viewer.aspx?dok_nr=2&dok_art=Drs&leg_per=8` |
|
||||||
|
| typ | Fest: "Antrag" | "Antrag" |
|
||||||
|
|
||||||
|
### BUND (BundestagAdapter — DIP REST-API)
|
||||||
|
|
||||||
|
| Feld | Quelle (JSON-Feld) | Beispiel |
|
||||||
|
|---|---|---|
|
||||||
|
| title | `titel` | "Förderung von Genossenschaften im sozialen Bereich" |
|
||||||
|
| datum | `datum` (bereits ISO YYYY-MM-DD) | "2026-04-08" |
|
||||||
|
| fraktionen | `urheber[*].titel` → `extract_fraktionen()` | "Fraktion der AfD" → `["AfD"]` |
|
||||||
|
| drucksache | `dokumentnummer` | "21/5136" |
|
||||||
|
| link | `fundstelle.pdf_url` | `https://dip.bundestag.de/documents/btd/21/051/2105136.pdf` |
|
||||||
|
| typ | `drucksachetyp` (server-side gefiltert) | "Antrag" |
|
||||||
|
|
||||||
|
## Gemeinsames Pattern
|
||||||
|
|
||||||
|
Alle Adapter normalisieren Fraktionen über den zentralen `parteien.extract_fraktionen(text, bundesland=...)` Mapper (#55). Alle konvertieren DE-Datumsformat (DD.MM.YYYY) nach ISO (YYYY-MM-DD), außer BUND (schon ISO) und SL (schon ISO mit T-Suffix).
|
||||||
|
|
||||||
## Historien-Tiefe (ältere WPs)
|
## Historien-Tiefe (ältere WPs)
|
||||||
|
|
||||||
Alle Adapter sind aktuell auf **eine feste Wahlperiode** konfiguriert.
|
Alle Adapter sind aktuell auf **eine feste Wahlperiode** konfiguriert.
|
||||||
|
|||||||
@ -27,6 +27,8 @@ nav:
|
|||||||
- Adapter-Capabilities: reference/adapter-capabilities.md
|
- Adapter-Capabilities: reference/adapter-capabilities.md
|
||||||
- Datenmodelle: reference/models.md
|
- Datenmodelle: reference/models.md
|
||||||
- Embeddings & Citations: reference/embeddings.md
|
- Embeddings & Citations: reference/embeddings.md
|
||||||
|
- How-to:
|
||||||
|
- Keycloak einrichten: how-to/keycloak-setup.md
|
||||||
- Archiv:
|
- Archiv:
|
||||||
- Übersicht: archive/index.md
|
- Übersicht: archive/index.md
|
||||||
|
|
||||||
|
|||||||
107
tests/test_auth.py
Normal file
107
tests/test_auth.py
Normal file
@ -0,0 +1,107 @@
|
|||||||
|
"""Tests for app/auth.py — Keycloak JWT authentication (#43).
|
||||||
|
|
||||||
|
These tests cover the auth module WITHOUT a running Keycloak server:
|
||||||
|
- Token extraction from headers/cookies
|
||||||
|
- Auth-disabled detection (Dev-Modus)
|
||||||
|
- _pick_best_title helper (in main.py, tested here for convenience)
|
||||||
|
"""
|
||||||
|
import sys
|
||||||
|
import types
|
||||||
|
|
||||||
|
# Stub jose if not installed locally
|
||||||
|
if "jose" not in sys.modules:
|
||||||
|
jose_stub = types.ModuleType("jose")
|
||||||
|
jose_jwt = types.ModuleType("jose.jwt")
|
||||||
|
jose_stub.jwt = jose_jwt
|
||||||
|
jose_stub.JWTError = Exception
|
||||||
|
jose_stub.ExpiredSignatureError = Exception
|
||||||
|
jose_jwt.get_unverified_header = lambda t: {"kid": "test"}
|
||||||
|
jose_jwt.decode = lambda *a, **kw: {"sub": "test", "email": "t@t.de"}
|
||||||
|
sys.modules["jose"] = jose_stub
|
||||||
|
sys.modules["jose.jwt"] = jose_jwt
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
from unittest.mock import MagicMock
|
||||||
|
from app.auth import _extract_token, _is_auth_enabled
|
||||||
|
|
||||||
|
|
||||||
|
class TestExtractToken:
|
||||||
|
def test_bearer_header(self):
|
||||||
|
req = MagicMock()
|
||||||
|
req.headers = {"authorization": "Bearer abc123"}
|
||||||
|
req.cookies = {}
|
||||||
|
assert _extract_token(req) == "abc123"
|
||||||
|
|
||||||
|
def test_cookie_fallback(self):
|
||||||
|
req = MagicMock()
|
||||||
|
req.headers = {}
|
||||||
|
req.cookies = {"access_token": "cookie_token"}
|
||||||
|
assert _extract_token(req) == "cookie_token"
|
||||||
|
|
||||||
|
def test_no_token(self):
|
||||||
|
req = MagicMock()
|
||||||
|
req.headers = {}
|
||||||
|
req.cookies = {}
|
||||||
|
assert _extract_token(req) is None
|
||||||
|
|
||||||
|
def test_non_bearer_header_ignored(self):
|
||||||
|
req = MagicMock()
|
||||||
|
req.headers = {"authorization": "Basic dXNlcjpwYXNz"}
|
||||||
|
req.cookies = {}
|
||||||
|
assert _extract_token(req) is None
|
||||||
|
|
||||||
|
|
||||||
|
class TestIsAuthEnabled:
|
||||||
|
def test_disabled_when_url_empty(self, monkeypatch):
|
||||||
|
from app import config
|
||||||
|
monkeypatch.setattr(config.settings, "keycloak_url", "")
|
||||||
|
monkeypatch.setattr(config.settings, "keycloak_realm", "test")
|
||||||
|
monkeypatch.setattr(config.settings, "keycloak_client_id", "test")
|
||||||
|
assert _is_auth_enabled() is False
|
||||||
|
|
||||||
|
def test_disabled_when_realm_empty(self, monkeypatch):
|
||||||
|
from app import config
|
||||||
|
monkeypatch.setattr(config.settings, "keycloak_url", "https://sso.test")
|
||||||
|
monkeypatch.setattr(config.settings, "keycloak_realm", "")
|
||||||
|
monkeypatch.setattr(config.settings, "keycloak_client_id", "test")
|
||||||
|
assert _is_auth_enabled() is False
|
||||||
|
|
||||||
|
def test_enabled_when_all_set(self, monkeypatch):
|
||||||
|
from app import config
|
||||||
|
monkeypatch.setattr(config.settings, "keycloak_url", "https://sso.test")
|
||||||
|
monkeypatch.setattr(config.settings, "keycloak_realm", "realm")
|
||||||
|
monkeypatch.setattr(config.settings, "keycloak_client_id", "client")
|
||||||
|
assert _is_auth_enabled() is True
|
||||||
|
|
||||||
|
|
||||||
|
try:
|
||||||
|
from app.main import _pick_best_title
|
||||||
|
_HAS_MAIN = True
|
||||||
|
except ImportError:
|
||||||
|
_HAS_MAIN = False
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.skipif(not _HAS_MAIN, reason="app.main not importable (missing slowapi/etc)")
|
||||||
|
class TestPickBestTitle:
|
||||||
|
"""Test _pick_best_title from main.py."""
|
||||||
|
|
||||||
|
def test_prefer_real_doc_title(self):
|
||||||
|
assert _pick_best_title(
|
||||||
|
"LLM-Titel", "Echte Antrag-Bezeichnung aus OPAL", "18/123"
|
||||||
|
) == "Echte Antrag-Bezeichnung aus OPAL"
|
||||||
|
|
||||||
|
def test_reject_generic_doc_title(self):
|
||||||
|
from app.main import _pick_best_title
|
||||||
|
result = _pick_best_title(
|
||||||
|
"Lehrkräfte stärken", "Drucksache 18/18085", "18/18085"
|
||||||
|
)
|
||||||
|
assert result == "Lehrkräfte stärken"
|
||||||
|
|
||||||
|
def test_fallback_to_llm_title(self):
|
||||||
|
assert _pick_best_title("LLM-Titel", None, "18/123") == "LLM-Titel"
|
||||||
|
|
||||||
|
def test_fallback_to_generic(self):
|
||||||
|
assert _pick_best_title("", None, "18/123") == "Drucksache 18/123"
|
||||||
|
|
||||||
|
def test_empty_doc_title_uses_llm(self):
|
||||||
|
assert _pick_best_title("Guter LLM-Titel", "", "18/123") == "Guter LLM-Titel"
|
||||||
Loading…
Reference in New Issue
Block a user