2026-03-28 22:30:24 +01:00
<!DOCTYPE html>
< html lang = "de" >
< head >
< meta charset = "UTF-8" >
< meta name = "viewport" content = "width=device-width, initial-scale=1.0" >
< title > {{ app_name }}< / title >
< style >
:root {
--color-darkgray: #5a5a5a;
--color-green: #889e33;
--color-blue: #009da5;
--color-lightgray: #bfbfbf;
--color-bg: #f5f5f5;
}
* { 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);
2026-04-07 13:48:55 +02:00
min-height: 100vh;
min-height: 100dvh;
display: flex;
flex-direction: column;
2026-03-28 22:30:24 +01:00
}
/* Header */
.header {
background: white;
padding: 1rem 2rem;
border-bottom: 1px solid var(--color-lightgray);
display: flex;
align-items: center;
gap: 1rem;
flex-wrap: wrap;
}
.header h1 {
color: var(--color-blue);
font-size: 1.5rem;
}
.header .subtitle {
color: var(--color-darkgray);
font-size: 0.9rem;
}
.bundesland-select {
padding: 0.5rem 1rem;
border: 1px solid var(--color-lightgray);
border-radius: 4px;
font-size: 0.9rem;
background: white;
cursor: pointer;
}
.bundesland-select:focus {
outline: none;
border-color: var(--color-blue);
}
/* Main Layout */
.main-container {
display: flex;
2026-04-07 13:48:55 +02:00
flex: 1;
min-height: 0;
2026-03-28 22:30:24 +01:00
}
/* Left Panel - List */
.list-panel {
width: 400px;
min-width: 300px;
background: white;
border-right: 1px solid var(--color-lightgray);
display: flex;
flex-direction: column;
}
.list-header {
padding: 1rem;
border-bottom: 1px solid var(--color-lightgray);
}
.search-row {
display: flex;
gap: 0.5rem;
}
.search-box {
flex: 1;
padding: 0.75rem;
border: 1px solid var(--color-lightgray);
border-radius: 4px;
font-size: 0.9rem;
}
.btn-landtag {
padding: 0.5rem 0.75rem;
background: var(--color-green);
color: white;
border: none;
border-radius: 4px;
font-size: 0.8rem;
cursor: pointer;
white-space: nowrap;
}
.btn-landtag:hover {
background: #728a2b;
}
.btn-landtag:disabled {
background: #ccc;
cursor: wait;
}
.list-filters {
display: flex;
gap: 0.5rem;
margin-top: 0.5rem;
flex-wrap: wrap;
}
.filter-btn {
padding: 0.25rem 0.75rem;
border: 1px solid var(--color-lightgray);
border-radius: 20px;
background: white;
cursor: pointer;
font-size: 0.8rem;
}
.filter-btn.active {
background: var(--color-blue);
color: white;
border-color: var(--color-blue);
}
.list-content {
flex: 1;
overflow-y: auto;
}
.list-item {
padding: 1rem;
border-bottom: 1px solid var(--color-lightgray);
cursor: pointer;
transition: background 0.2s;
}
.list-item:hover {
background: var(--color-bg);
}
.list-item.active {
background: #e8f4f5;
border-left: 3px solid var(--color-blue);
}
.list-item-header {
display: flex;
justify-content: space-between;
align-items: flex-start;
margin-bottom: 0.25rem;
}
.list-item-id {
font-weight: bold;
color: var(--color-blue);
}
Bundesland filter & transparency: stringent split + visible source (#8)
Brings the Bundesland-Dropdown from a cosmetic header widget to a real
filter that propagates through every layer (Listing, internal search,
statistics, party/tag filters, upload mode), and at the same time makes
the source parliament visible in every place where assessments from
multiple bundesländer can be mixed.
Backend
- database.get_all_assessments(bundesland=None) — new optional filter,
"ALL" treated as None.
- database.search_assessments — bug fix: previous `if bundesland:`
branch incorrectly added a `WHERE bundesland='ALL'` clause; now
guarded with `bundesland and bundesland != "ALL"`.
- main.list_assessments — accepts ?bundesland= query param, includes the
bundesland field in the response so the frontend can render badges.
- main.get_single_assessment — also includes bundesland in the response
so the detail header can show the source parlament.
- main.search_landtag — early HTTP 400 when bundesland is missing or
"ALL"; the live Landtag adapter cannot serve a synthetic Bundesweit
request.
- main.index() and main.list_bundeslaender — synthetic "🌍 Bundesweit"
entry prepended to the bundesländer list (kept out of bundeslaender.py
on purpose — ALL is not a real state). Both endpoints additionally
expose a parlament_names map so the frontend can render the source
parliament without an extra round-trip.
Report (PDF + HTML)
- generate_html_report / generate_pdf_report — new optional bundesland
parameter. When set, the report header carries the parliament name
("Landtag von Sachsen-Anhalt", "Landtag Nordrhein-Westfalen", …)
beside the title. Three call sites updated: run_analysis,
run_drucksache_analysis, download_assessment_pdf.
Frontend (templates/index.html)
- Header dropdown gets the synthetic ALL entry as first option;
initial currentBundesland is now 'ALL' (was 'NRW').
- localStorage persistence: changeBundesland writes, DOMContentLoaded
reads and validates against the visible options.
- changeBundesland resets the score / party / tag filter state, syncs
the upload-mode bundesland select, disables the Landtag-Suche button
+ tooltip when ALL, and toggles a data-mode attribute on
.list-content (used by CSS to show/hide the per-item bundesland
badge).
- loadAssessments now sends ?bundesland=… so the API does the actual
filtering. updateStats renders an additional per-bundesland average
block (Ø NRW: x · Ø LSA: y) when in ALL mode and the loaded list
spans more than one bundesland.
- renderList prepends a small "bl-badge" beside the Drucksachen-Nummer.
Hidden in single-bundesland mode via CSS selector to avoid clutter.
- showDetail header now shows the parliament name as its own line
(.detail-parlament).
- searchLandtag has an early-out alert if currentBundesland === 'ALL',
saving a network round-trip.
- Upload-Mode bundesland select now starts with a "— Bundesland wählen
—" placeholder (no auto-default), and startAnalysis validates that a
concrete bundesland was chosen.
CSS
- .bl-badge plus the .list-content[data-mode="single"] hide rule.
- .detail-parlament for the detail header line.
- .header-parlament for the PDF report header line.
Resolves #8.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-07 23:00:39 +02:00
/* Bundesland-Badge: Im Listen-Item links neben der Drucksachen-Nummer.
Im Bundesland-spezifischen Modus per data-mode="single" am Container
ausgeblendet (redundant, da alle Einträge demselben Land zugehören). */
.bl-badge {
display: inline-block;
padding: 1px 6px;
margin-right: 0.4rem;
font-size: 0.7rem;
font-weight: 600;
letter-spacing: 0.02em;
color: var(--color-blue);
border: 1px solid var(--color-blue);
border-radius: 3px;
vertical-align: middle;
}
.list-content[data-mode="single"] .bl-badge { display: none; }
/* Detail-Header: Parlament-Name unter dem Titel, vor der Drucksache-Zeile */
.detail-parlament {
font-size: 0.85rem;
color: var(--color-blue);
font-weight: 600;
margin: 0.2rem 0 0.1rem;
}
2026-03-28 22:30:24 +01:00
.list-item-score {
font-size: 0.9rem;
font-weight: bold;
padding: 0.1rem 0.5rem;
border-radius: 4px;
}
/* Neue Skala -5 bis +5 */
.score-high { background: #155724; color: white; } /* +4 bis +5: dunkelgrün */
.score-mid { background: #889e33; color: white; } /* +2 bis +3: GWÖ-grün */
.score-neutral { background: #6c757d; color: white; } /* 0 bis +1: grau */
.score-low { background: #fd7e14; color: white; } /* -1 bis -2: orange */
.score-negative { background: #dc3545; color: white; } /* -3 bis -5: rot */
.score-none { background: #e2e3e5; color: #383d41; }
.status-unchecked {
background: #fff3cd;
color: #856404;
font-size: 0.75rem;
padding: 0.2rem 0.5rem;
border-radius: 4px;
}
.status-checked {
background: #d4edda;
color: #155724;
font-size: 0.75rem;
padding: 0.2rem 0.5rem;
border-radius: 4px;
}
.btn-check-now {
display: inline-block;
padding: 0.3rem 0.75rem;
background: var(--color-blue);
color: white;
border: none;
border-radius: 4px;
font-size: 0.8rem;
cursor: pointer;
margin-top: 0.5rem;
}
.btn-check-now:hover {
background: #007b82;
}
.btn-check-now:disabled {
background: #ccc;
cursor: wait;
}
.list-item-title {
font-size: 0.9rem;
color: var(--color-darkgray);
margin-bottom: 0.25rem;
}
.list-item-meta {
font-size: 0.8rem;
color: #888;
}
.list-item-tags {
display: flex;
gap: 0.25rem;
margin-top: 0.5rem;
flex-wrap: wrap;
}
.tag {
font-size: 0.7rem;
padding: 0.1rem 0.4rem;
background: var(--color-bg);
border-radius: 3px;
}
/* Right Panel - Detail */
.detail-panel {
flex: 1;
overflow-y: auto;
padding: 2rem;
}
.detail-placeholder {
text-align: center;
color: #888;
padding: 4rem;
}
.detail-card {
background: white;
border-radius: 8px;
padding: 2rem;
box-shadow: 0 2px 8px rgba(0,0,0,0.1);
margin-bottom: 1rem;
}
.detail-header {
display: flex;
justify-content: space-between;
align-items: flex-start;
margin-bottom: 1rem;
}
.detail-title {
font-size: 1.3rem;
color: var(--color-blue);
}
.detail-id {
font-size: 0.9rem;
color: #888;
}
.score-display {
text-align: center;
padding: 1rem;
}
.score-big {
font-size: 3rem;
font-weight: bold;
color: var(--color-blue);
}
.score-label {
font-size: 0.9rem;
color: #888;
}
.matrix-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(200px, 1fr));
gap: 0.5rem;
margin: 1rem 0;
}
.matrix-item {
padding: 0.75rem;
background: var(--color-bg);
border-radius: 4px;
display: flex;
justify-content: space-between;
align-items: center;
}
.matrix-label {
font-size: 0.85rem;
}
.matrix-rating {
font-weight: bold;
}
.rating-pos { color: var(--color-green); }
.rating-neg { color: #dc3545; }
.rating-neutral { color: #888; }
/* Matrix Table (5x5 Grid wie im PDF) */
.matrix-table {
width: 100%;
border-collapse: collapse;
margin: 0.5rem 0;
font-size: 0.85rem;
}
.matrix-table th, .matrix-table td {
border: 1px solid var(--color-lightgray);
padding: 0.4rem 0.5rem;
text-align: center;
}
.matrix-table thead th {
background: var(--color-blue);
color: white;
font-weight: normal;
}
.matrix-table tbody th {
background: var(--color-bg);
text-align: left;
font-weight: normal;
}
.matrix-table .positive {
background: var(--color-green);
color: white;
font-weight: bold;
}
.matrix-table .negative {
background: #dc3545;
color: white;
font-weight: bold;
}
.matrix-table .neutral {
background: #f0f0f0;
}
/* Themen Tags in Detail */
.themen-tags .tag {
display: inline-block;
margin-right: 0.5rem;
margin-bottom: 0.25rem;
padding: 0.25rem 0.5rem;
background: var(--color-blue);
color: white;
border-radius: 3px;
font-size: 0.8rem;
}
/* Kernpunkte Liste */
.kernpunkte-list {
margin-left: 1.5rem;
}
.kernpunkte-list li {
margin-bottom: 0.5rem;
}
.section-title {
font-size: 1.1rem;
color: var(--color-darkgray);
margin: 1.5rem 0 0.75rem;
padding-bottom: 0.5rem;
border-bottom: 1px solid var(--color-lightgray);
}
.text-block {
background: var(--color-bg);
padding: 1rem;
border-radius: 4px;
margin: 0.5rem 0;
}
.strength-list, .weakness-list {
list-style: none;
}
.strength-list li::before {
content: "✓ ";
color: var(--color-green);
}
.weakness-list li::before {
content: "✗ ";
color: #dc3545;
}
.btn-pdf {
display: inline-block;
padding: 0.75rem 1.5rem;
background: var(--color-blue);
color: white;
text-decoration: none;
border-radius: 4px;
margin-top: 1rem;
}
.btn-pdf:hover {
background: #007b82;
}
/* Upload Tab */
.upload-section {
background: white;
border-radius: 8px;
padding: 2rem;
max-width: 600px;
margin: 0 auto;
}
.tabs {
display: flex;
gap: 1rem;
margin-bottom: 1.5rem;
}
.tab-btn {
padding: 0.5rem 1rem;
border: none;
background: none;
cursor: pointer;
border-bottom: 2px solid transparent;
color: var(--color-darkgray);
}
.tab-btn.active {
border-bottom-color: var(--color-blue);
color: var(--color-blue);
}
textarea {
width: 100%;
min-height: 200px;
padding: 1rem;
border: 1px solid var(--color-lightgray);
border-radius: 4px;
font-family: inherit;
resize: vertical;
}
.file-drop {
border: 2px dashed var(--color-lightgray);
border-radius: 8px;
padding: 3rem;
text-align: center;
cursor: pointer;
}
.file-drop:hover {
border-color: var(--color-blue);
}
.btn-analyze {
display: block;
width: 100%;
padding: 1rem;
background: var(--color-green);
color: white;
border: none;
border-radius: 4px;
font-size: 1rem;
cursor: pointer;
margin-top: 1rem;
}
.btn-analyze:hover {
background: #728a2b;
}
.btn-analyze:disabled {
background: #ccc;
cursor: not-allowed;
}
/* Mode Toggle */
.mode-toggle {
display: flex;
background: var(--color-bg);
border-radius: 4px;
padding: 0.25rem;
margin-left: auto;
}
.mode-btn {
padding: 0.5rem 1rem;
border: none;
background: none;
cursor: pointer;
border-radius: 4px;
2026-04-10 22:25:52 +02:00
font-size: 0.9rem;
2026-03-28 22:30:24 +01:00
color: var(--color-darkgray);
}
.mode-btn.active {
background: white;
box-shadow: 0 1px 3px rgba(0,0,0,0.1);
}
2026-04-10 22:29:55 +02:00
.hamburger-dropdown {
display: none;
position: absolute;
right: 0;
top: 100%;
background: white;
border: 1px solid #ddd;
border-radius: 6px;
box-shadow: 0 4px 12px rgba(0,0,0,0.15);
min-width: 180px;
z-index: 100;
padding: 0.3rem 0;
}
.hamburger-dropdown.open { display: block; }
.hamburger-dropdown a,
.hamburger-dropdown button {
display: block;
width: 100%;
padding: 0.5rem 1rem;
text-align: left;
text-decoration: none;
color: var(--color-darkgray);
background: none;
border: none;
cursor: pointer;
font-size: 0.9rem;
}
.hamburger-dropdown a:hover,
.hamburger-dropdown button:hover {
background: #f5f5f5;
}
2026-03-28 22:30:24 +01:00
/* Loading */
.loading {
display: flex;
align-items: center;
gap: 1rem;
padding: 2rem;
}
.spinner {
width: 30px;
height: 30px;
border: 3px solid var(--color-lightgray);
border-top-color: var(--color-blue);
border-radius: 50%;
animation: spin 1s linear infinite;
}
@keyframes spin {
to { transform: rotate(360deg); }
}
2026-04-07 13:48:55 +02:00
/* Mobile Back-to-List Button (nur Mobile sichtbar) */
.btn-back-mobile {
display: none;
padding: 0.5rem 0.75rem;
background: var(--color-bg);
border: 1px solid var(--color-lightgray);
border-radius: 4px;
cursor: pointer;
margin-bottom: 1rem;
font-size: 0.85rem;
color: var(--color-darkgray);
}
2026-03-28 22:30:24 +01:00
/* Responsive */
@media (max-width: 900px) {
2026-04-07 13:48:55 +02:00
.header {
padding: 0.75rem 1rem;
gap: 0.5rem;
}
.header h1 {
font-size: 1.2rem;
}
.header .subtitle {
font-size: 0.8rem;
flex-basis: 100%;
order: 10;
}
.mode-toggle {
margin-left: auto;
flex-wrap: wrap;
}
.mode-btn {
padding: 0.4rem 0.6rem;
font-size: 0.85rem;
}
2026-03-28 22:30:24 +01:00
.main-container {
flex-direction: column;
2026-04-07 13:48:55 +02:00
flex: 1;
min-height: 0;
2026-03-28 22:30:24 +01:00
}
2026-04-07 13:48:55 +02:00
2026-03-28 22:30:24 +01:00
.list-panel {
width: 100%;
2026-04-07 13:48:55 +02:00
min-width: 0;
border-right: none;
border-bottom: 1px solid var(--color-lightgray);
}
.list-content {
2026-03-28 22:30:24 +01:00
max-height: 50vh;
}
2026-04-07 13:48:55 +02:00
2026-03-28 22:30:24 +01:00
.detail-panel {
padding: 1rem;
2026-04-07 13:48:55 +02:00
overflow: visible;
flex: none;
}
.btn-back-mobile {
display: inline-block;
2026-03-28 22:30:24 +01:00
}
}
2026-04-07 13:48:55 +02:00
@media (max-width: 600px) {
.header h1 { font-size: 1.1rem; }
.header .subtitle { display: none; }
.detail-card { padding: 1rem; }
.matrix-table { font-size: 0.7rem; }
.matrix-table th, .matrix-table td { padding: 0.25rem 0.3rem; }
.matrix-grid { grid-template-columns: 1fr; }
.upload-section { padding: 1rem; }
.file-drop { padding: 1.5rem; }
.score-big { font-size: 2.2rem; }
.detail-header { flex-wrap: wrap; gap: 0.5rem; }
}
2026-03-28 22:30:24 +01:00
/* Stats Bar */
.stats-bar {
display: flex;
gap: 2rem;
padding: 1rem;
background: var(--color-bg);
border-bottom: 1px solid var(--color-lightgray);
}
.stat {
text-align: center;
}
.stat-value {
font-size: 1.5rem;
font-weight: bold;
color: var(--color-blue);
}
.stat-label {
font-size: 0.8rem;
color: #888;
}
< / style >
< / head >
< body >
< header class = "header" >
< h1 > {{ app_name }}< / h1 >
< span class = "subtitle" > Gemeinwohl-Matrix 2.0 für Gemeinden< / span >
< select class = "bundesland-select" id = "bundesland-select" onchange = "changeBundesland(this.value)" >
{% for bl in bundeslaender %}
< option value = "{{ bl.code }}" { % if bl . active % } { % else % } disabled { % endif % } >
{{ bl.name }}{% if not bl.active %} (bald){% endif %}
< / option >
{% endfor %}
< / select >
< div class = "mode-toggle" >
< button class = "mode-btn active" onclick = "showMode('browse')" > 📋 Durchsuchen< / button >
2026-04-10 22:24:43 +02:00
< button class = "mode-btn" onclick = "showMode('bookmarks')" > ⭐ Merkliste< / button >
2026-03-28 22:30:24 +01:00
< button class = "mode-btn" onclick = "showMode('tags')" > 🏷️ Tags< / button >
< button class = "mode-btn" onclick = "showMode('upload')" > 📤 Prüfen< / button >
2026-04-10 22:29:55 +02:00
< div class = "hamburger-wrapper" style = "position:relative;display:inline-block;" >
< button class = "mode-btn" onclick = "document.getElementById('hamburger-menu').classList.toggle('open')" style = "font-size:1.2rem;padding:0.3rem 0.6rem;" > ☰< / button >
< div id = "hamburger-menu" class = "hamburger-dropdown" >
< a href = "/auswertungen" > 📈 Auswertungen< / a >
< a href = "/quellen" > 📚 Quellen< / a >
< a href = "/methodik" > 🔍 Methodik< / a >
< hr style = "margin:0.3rem 0;border:none;border-top:1px solid #eee;" >
< button id = "auth-btn" onclick = "event.stopPropagation();" > 🔑 Anmelden< / button >
< / div >
< / div >
2026-03-28 22:30:24 +01:00
< / div >
< / header >
< div class = "main-container" id = "browse-mode" >
<!-- Left: List -->
< aside class = "list-panel" >
< div class = "list-header" >
2026-04-09 11:27:29 +02:00
<!--
#16: zwei klar getrennte Suchfelder. Das erste filtert
in der DB der bereits geprüften Anträge (Live, debounced).
Das zweite triggert per Enter oder Button eine Live-
Anfrage gegen den Landtag-Adapter. Beide schreiben in
dieselbe Liste, unterscheiden sich aber visuell und
semantisch klar.
-->
< div class = "search-row" style = "flex-direction: column; gap: 0.4rem;" >
< div style = "display: flex; gap: 0.4rem; width: 100%;" >
< input type = "text" class = "search-box" id = "search-input"
placeholder="📊 Suche in geprüften Anträgen (DB)…"
oninput="debounceSearch(this.value)"
style="flex: 1;">
< / div >
< div style = "display: flex; gap: 0.4rem; width: 100%;" >
< input type = "text" class = "search-box" id = "landtag-search-input"
placeholder="🏛️ Im Landtag suchen (live)…"
onkeydown="if(event.key==='Enter')searchLandtag()"
style="flex: 1;">
< button class = "btn-landtag" id = "btn-landtag" onclick = "searchLandtag()" > 🔍 Suchen< / button >
< / div >
2026-03-28 22:30:24 +01:00
< / div >
< div class = "list-filters" >
< button class = "filter-btn active" data-filter = "all" onclick = "setScoreFilter('all', this)" > Alle< / button >
< button class = "filter-btn" data-filter = "high" onclick = "setScoreFilter('high', this)" > 8-10< / button >
< button class = "filter-btn" data-filter = "mid" onclick = "setScoreFilter('mid', this)" > 5-7< / button >
< button class = "filter-btn" data-filter = "low" onclick = "setScoreFilter('low', this)" > 0-4< / button >
< select id = "partei-filter" onchange = "setParteiFilter(this.value)" style = "padding: 0.25rem 0.5rem; border-radius: 20px; border: 1px solid var(--color-lightgray); font-size: 0.8rem; cursor: pointer;" >
< option value = "" > Alle Parteien< / option >
< / select >
< / div >
< / div >
< div class = "stats-bar" style = "padding: 0.5rem 1rem; gap: 1rem; flex-wrap: wrap; align-items: center;" >
< span style = "font-size: 0.8rem;" > < strong id = "stat-total" > 0< / strong > geprüft · < strong id = "stat-high" > 0< / strong > vorbildlich · Ø < strong id = "stat-avg" > 0< / strong > < / span >
Bundesland filter & transparency: stringent split + visible source (#8)
Brings the Bundesland-Dropdown from a cosmetic header widget to a real
filter that propagates through every layer (Listing, internal search,
statistics, party/tag filters, upload mode), and at the same time makes
the source parliament visible in every place where assessments from
multiple bundesländer can be mixed.
Backend
- database.get_all_assessments(bundesland=None) — new optional filter,
"ALL" treated as None.
- database.search_assessments — bug fix: previous `if bundesland:`
branch incorrectly added a `WHERE bundesland='ALL'` clause; now
guarded with `bundesland and bundesland != "ALL"`.
- main.list_assessments — accepts ?bundesland= query param, includes the
bundesland field in the response so the frontend can render badges.
- main.get_single_assessment — also includes bundesland in the response
so the detail header can show the source parlament.
- main.search_landtag — early HTTP 400 when bundesland is missing or
"ALL"; the live Landtag adapter cannot serve a synthetic Bundesweit
request.
- main.index() and main.list_bundeslaender — synthetic "🌍 Bundesweit"
entry prepended to the bundesländer list (kept out of bundeslaender.py
on purpose — ALL is not a real state). Both endpoints additionally
expose a parlament_names map so the frontend can render the source
parliament without an extra round-trip.
Report (PDF + HTML)
- generate_html_report / generate_pdf_report — new optional bundesland
parameter. When set, the report header carries the parliament name
("Landtag von Sachsen-Anhalt", "Landtag Nordrhein-Westfalen", …)
beside the title. Three call sites updated: run_analysis,
run_drucksache_analysis, download_assessment_pdf.
Frontend (templates/index.html)
- Header dropdown gets the synthetic ALL entry as first option;
initial currentBundesland is now 'ALL' (was 'NRW').
- localStorage persistence: changeBundesland writes, DOMContentLoaded
reads and validates against the visible options.
- changeBundesland resets the score / party / tag filter state, syncs
the upload-mode bundesland select, disables the Landtag-Suche button
+ tooltip when ALL, and toggles a data-mode attribute on
.list-content (used by CSS to show/hide the per-item bundesland
badge).
- loadAssessments now sends ?bundesland=… so the API does the actual
filtering. updateStats renders an additional per-bundesland average
block (Ø NRW: x · Ø LSA: y) when in ALL mode and the loaded list
spans more than one bundesland.
- renderList prepends a small "bl-badge" beside the Drucksachen-Nummer.
Hidden in single-bundesland mode via CSS selector to avoid clutter.
- showDetail header now shows the parliament name as its own line
(.detail-parlament).
- searchLandtag has an early-out alert if currentBundesland === 'ALL',
saving a network round-trip.
- Upload-Mode bundesland select now starts with a "— Bundesland wählen
—" placeholder (no auto-default), and startAnalysis validates that a
concrete bundesland was chosen.
CSS
- .bl-badge plus the .list-content[data-mode="single"] hide rule.
- .detail-parlament for the detail header line.
- .header-parlament for the PDF report header line.
Resolves #8.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-07 23:00:39 +02:00
< span id = "bundesland-stats" style = "font-size: 0.8rem; color: var(--color-darkgray); display: none; gap: 0.6rem; flex-wrap: wrap;" > < / span >
2026-03-28 22:30:24 +01:00
< span style = "color: var(--color-lightgray);" > |< / span >
< span id = "partei-stats" style = "font-size: 0.8rem; display: flex; gap: 0.75rem; flex-wrap: wrap;" > < / span >
< / div >
< div class = "list-content" id = "list-content" >
< div class = "loading" >
< div class = "spinner" > < / div >
< span > Lade Bewertungen...< / span >
< / div >
< / div >
< / aside >
<!-- Right: Detail -->
< main class = "detail-panel" id = "detail-panel" >
< div class = "detail-placeholder" >
< p > 👈 Wähle einen Antrag aus der Liste< / p >
< / div >
< / main >
< / div >
2026-04-10 22:24:43 +02:00
<!-- Bookmarks / Merkliste Mode -->
< div class = "main-container" id = "bookmarks-mode" style = "display: none;" >
< div class = "list-panel" style = "max-width: 100%;" >
< h2 style = "color: var(--color-blue); margin-bottom: 1rem;" > ⭐ Merkliste< / h2 >
< div id = "bookmarks-content" >
< p style = "color: #888;" > Lade Merkliste...< / p >
< / div >
< / div >
< / div >
2026-03-28 22:30:24 +01:00
<!-- Tags Mode -->
< div class = "main-container" id = "tags-mode" style = "display: none;" >
< aside class = "list-panel" style = "width: 100%; max-width: 400px;" >
< div class = "list-header" >
< h3 style = "margin-bottom: 0.5rem;" > 🏷️ Filter nach Tags< / h3 >
< p style = "font-size: 0.85rem; color: #666; margin-bottom: 1rem;" > Klicke auf Tags um zu filtern. Mehrfachauswahl zeigt Schnittmenge.< / p >
< input type = "text" class = "search-box" id = "tag-search-input" placeholder = "Tags durchsuchen..." oninput = "filterTagCloud(this.value)" >
< div id = "active-tags" style = "margin-top: 0.75rem; display: flex; flex-wrap: wrap; gap: 0.5rem;" > < / div >
< button onclick = "clearTagFilters()" style = "margin-top: 0.75rem; padding: 0.5rem 1rem; background: #dc3545; color: white; border: none; border-radius: 4px; cursor: pointer; display: none;" id = "clear-tags-btn" > ✕ Filter zurücksetzen< / button >
< / div >
< div class = "list-content" id = "tag-cloud" style = "padding: 1rem;" >
< div class = "loading" >
< div class = "spinner" > < / div >
< span > Lade Tags...< / span >
< / div >
< / div >
< / aside >
< main class = "detail-panel" id = "tag-results-panel" >
< div class = "detail-card" >
< h3 style = "margin-bottom: 1rem;" > 📋 Gefilterte Anträge< / h3 >
< div id = "tag-results-list" >
< p style = "color: #888;" > Wähle Tags aus der Wolke um Anträge zu filtern.< / p >
< / div >
< / div >
< / main >
< / div >
<!-- Upload Mode -->
< div class = "main-container" id = "upload-mode" style = "display: none;" >
< div class = "detail-panel" >
< div class = "upload-section" >
< h2 style = "margin-bottom: 1rem; color: var(--color-blue);" > Neuen Antrag prüfen< / h2 >
< div class = "tabs" >
< button class = "tab-btn active" onclick = "showTab('text')" > Text eingeben< / button >
< button class = "tab-btn" onclick = "showTab('file')" > PDF hochladen< / button >
< / div >
< div id = "tab-text" >
< textarea id = "antrag-text" placeholder = "Antragstext hier einfügen..." > < / textarea >
< / div >
< div id = "tab-file" style = "display: none;" >
< div class = "file-drop" onclick = "document.getElementById('file-input').click()" >
< p > 📄 PDF hier ablegen oder klicken< / p >
< input type = "file" id = "file-input" accept = ".pdf" style = "display: none" onchange = "handleFile(this)" >
< p id = "file-name" style = "margin-top: 0.5rem; color: var(--color-blue);" > < / p >
< / div >
< / div >
< div style = "margin-top: 1rem;" >
< label > Bundesland:< / label >
< select id = "bundesland" style = "padding: 0.5rem; margin-left: 0.5rem;" >
Bundesland filter & transparency: stringent split + visible source (#8)
Brings the Bundesland-Dropdown from a cosmetic header widget to a real
filter that propagates through every layer (Listing, internal search,
statistics, party/tag filters, upload mode), and at the same time makes
the source parliament visible in every place where assessments from
multiple bundesländer can be mixed.
Backend
- database.get_all_assessments(bundesland=None) — new optional filter,
"ALL" treated as None.
- database.search_assessments — bug fix: previous `if bundesland:`
branch incorrectly added a `WHERE bundesland='ALL'` clause; now
guarded with `bundesland and bundesland != "ALL"`.
- main.list_assessments — accepts ?bundesland= query param, includes the
bundesland field in the response so the frontend can render badges.
- main.get_single_assessment — also includes bundesland in the response
so the detail header can show the source parlament.
- main.search_landtag — early HTTP 400 when bundesland is missing or
"ALL"; the live Landtag adapter cannot serve a synthetic Bundesweit
request.
- main.index() and main.list_bundeslaender — synthetic "🌍 Bundesweit"
entry prepended to the bundesländer list (kept out of bundeslaender.py
on purpose — ALL is not a real state). Both endpoints additionally
expose a parlament_names map so the frontend can render the source
parliament without an extra round-trip.
Report (PDF + HTML)
- generate_html_report / generate_pdf_report — new optional bundesland
parameter. When set, the report header carries the parliament name
("Landtag von Sachsen-Anhalt", "Landtag Nordrhein-Westfalen", …)
beside the title. Three call sites updated: run_analysis,
run_drucksache_analysis, download_assessment_pdf.
Frontend (templates/index.html)
- Header dropdown gets the synthetic ALL entry as first option;
initial currentBundesland is now 'ALL' (was 'NRW').
- localStorage persistence: changeBundesland writes, DOMContentLoaded
reads and validates against the visible options.
- changeBundesland resets the score / party / tag filter state, syncs
the upload-mode bundesland select, disables the Landtag-Suche button
+ tooltip when ALL, and toggles a data-mode attribute on
.list-content (used by CSS to show/hide the per-item bundesland
badge).
- loadAssessments now sends ?bundesland=… so the API does the actual
filtering. updateStats renders an additional per-bundesland average
block (Ø NRW: x · Ø LSA: y) when in ALL mode and the loaded list
spans more than one bundesland.
- renderList prepends a small "bl-badge" beside the Drucksachen-Nummer.
Hidden in single-bundesland mode via CSS selector to avoid clutter.
- showDetail header now shows the parliament name as its own line
(.detail-parlament).
- searchLandtag has an early-out alert if currentBundesland === 'ALL',
saving a network round-trip.
- Upload-Mode bundesland select now starts with a "— Bundesland wählen
—" placeholder (no auto-default), and startAnalysis validates that a
concrete bundesland was chosen.
CSS
- .bl-badge plus the .list-content[data-mode="single"] hide rule.
- .detail-parlament for the detail header line.
- .header-parlament for the PDF report header line.
Resolves #8.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-07 23:00:39 +02:00
< option value = "" disabled selected > — Bundesland wählen —< / option >
{% for bl in bundeslaender if bl.code != 'ALL' %}
< option value = "{{ bl.code }}" { % if not bl . active % } disabled { % endif % } >
2026-03-28 22:30:24 +01:00
{{ bl.name }}{% if not bl.active %} (bald){% endif %}
< / option >
{% endfor %}
< / select >
< / div >
< button class = "btn-analyze" id = "analyze-btn" onclick = "startAnalysis()" >
🔍 GWÖ-Analyse starten
< / button >
< div id = "analysis-status" style = "display: none;" >
< div class = "loading" >
< div class = "spinner" > < / div >
< span id = "status-text" > Analyse läuft...< / span >
< / div >
< / div >
< div id = "analysis-result" style = "display: none; margin-top: 1rem;" > < / div >
< / div >
< / div >
< / div >
< script >
let allAssessments = [];
let currentScoreFilter = 'all';
let currentParteiFilter = '';
Bundesland filter & transparency: stringent split + visible source (#8)
Brings the Bundesland-Dropdown from a cosmetic header widget to a real
filter that propagates through every layer (Listing, internal search,
statistics, party/tag filters, upload mode), and at the same time makes
the source parliament visible in every place where assessments from
multiple bundesländer can be mixed.
Backend
- database.get_all_assessments(bundesland=None) — new optional filter,
"ALL" treated as None.
- database.search_assessments — bug fix: previous `if bundesland:`
branch incorrectly added a `WHERE bundesland='ALL'` clause; now
guarded with `bundesland and bundesland != "ALL"`.
- main.list_assessments — accepts ?bundesland= query param, includes the
bundesland field in the response so the frontend can render badges.
- main.get_single_assessment — also includes bundesland in the response
so the detail header can show the source parlament.
- main.search_landtag — early HTTP 400 when bundesland is missing or
"ALL"; the live Landtag adapter cannot serve a synthetic Bundesweit
request.
- main.index() and main.list_bundeslaender — synthetic "🌍 Bundesweit"
entry prepended to the bundesländer list (kept out of bundeslaender.py
on purpose — ALL is not a real state). Both endpoints additionally
expose a parlament_names map so the frontend can render the source
parliament without an extra round-trip.
Report (PDF + HTML)
- generate_html_report / generate_pdf_report — new optional bundesland
parameter. When set, the report header carries the parliament name
("Landtag von Sachsen-Anhalt", "Landtag Nordrhein-Westfalen", …)
beside the title. Three call sites updated: run_analysis,
run_drucksache_analysis, download_assessment_pdf.
Frontend (templates/index.html)
- Header dropdown gets the synthetic ALL entry as first option;
initial currentBundesland is now 'ALL' (was 'NRW').
- localStorage persistence: changeBundesland writes, DOMContentLoaded
reads and validates against the visible options.
- changeBundesland resets the score / party / tag filter state, syncs
the upload-mode bundesland select, disables the Landtag-Suche button
+ tooltip when ALL, and toggles a data-mode attribute on
.list-content (used by CSS to show/hide the per-item bundesland
badge).
- loadAssessments now sends ?bundesland=… so the API does the actual
filtering. updateStats renders an additional per-bundesland average
block (Ø NRW: x · Ø LSA: y) when in ALL mode and the loaded list
spans more than one bundesland.
- renderList prepends a small "bl-badge" beside the Drucksachen-Nummer.
Hidden in single-bundesland mode via CSS selector to avoid clutter.
- showDetail header now shows the parliament name as its own line
(.detail-parlament).
- searchLandtag has an early-out alert if currentBundesland === 'ALL',
saving a network round-trip.
- Upload-Mode bundesland select now starts with a "— Bundesland wählen
—" placeholder (no auto-default), and startAnalysis validates that a
concrete bundesland was chosen.
CSS
- .bl-badge plus the .list-content[data-mode="single"] hide rule.
- .detail-parlament for the detail header line.
- .header-parlament for the PDF report header line.
Resolves #8.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-07 23:00:39 +02:00
let currentBundesland = 'ALL';
2026-03-28 22:30:24 +01:00
let searchTimeout = null;
let isSearching = false;
let selectedTags = new Set();
let allTags = {};
#43 Keycloak SSO: JWT-Middleware + UI-Guiding
Auth-Schicht vorbereitet — Dev-Modus (KEYCLOAK_URL leer) lässt alles
durch, Prod-Modus (ENV gesetzt) validiert JWT gegen Keycloak-JWKS.
Backend (app/auth.py):
- JWKS-Cache mit 1h TTL (async httpx fetch)
- get_current_user: Optional, gibt User-Dict oder None
- require_auth: Pflicht, gibt User-Dict oder HTTP 401
- keycloak_login_url: Baut die OIDC-Login-URL
- _is_auth_enabled: prüft ob alle 3 ENV-Vars gesetzt sind
Abgesicherte POST-Endpoints:
- POST /analyze → Depends(require_auth)
- POST /api/analyze-drucksache → Depends(require_auth)
- POST /api/programme/index → Depends(require_auth)
Neue Endpoints:
- GET /api/auth/me → {authenticated, sub, email, name, roles} oder {authenticated: false}
- GET /api/auth/login-url → {enabled, url} für Keycloak-Redirect
Frontend (index.html):
- initAuth() beim DOMContentLoaded → prüft /api/auth/me
- "Anmelden"-Button im Header (neben "Quellen")
- "Jetzt prüfen"-Button: disabled + Tooltip "Nur nach Anmeldung
verfügbar" wenn nicht eingeloggt; aktiv wenn eingeloggt
- currentUser-State steuert Button-Zustände
Dev-Modus: Solange KEYCLOAK_URL nicht gesetzt ist (lokale Dev, aktueller
Prod-Stand), sind alle Endpoints offen wie bisher. Kein Breaking Change.
Dependency: python-jose[cryptography]>=3.3.0 in requirements.txt.
Tests: 194/194 grün (auth.py hat keine Seiteneffekte im Import).
Refs: #43
2026-04-10 14:28:57 +02:00
let currentUser = null; // #43: Auth-State
// #43: Auth prüfen beim Load. Steuert ob "Jetzt prüfen" aktiv ist.
async function initAuth() {
try {
const resp = await fetch('/api/auth/me');
const data = await resp.json();
currentUser = data.authenticated ? data : null;
} catch { currentUser = null; }
updateAuthUI();
}
function updateAuthUI() {
const authBtn = document.getElementById('auth-btn');
if (!authBtn) return;
if (currentUser) {
2026-04-10 21:24:07 +02:00
authBtn.textContent = '✓ Angemeldet (Logout)';
#43 Keycloak SSO: JWT-Middleware + UI-Guiding
Auth-Schicht vorbereitet — Dev-Modus (KEYCLOAK_URL leer) lässt alles
durch, Prod-Modus (ENV gesetzt) validiert JWT gegen Keycloak-JWKS.
Backend (app/auth.py):
- JWKS-Cache mit 1h TTL (async httpx fetch)
- get_current_user: Optional, gibt User-Dict oder None
- require_auth: Pflicht, gibt User-Dict oder HTTP 401
- keycloak_login_url: Baut die OIDC-Login-URL
- _is_auth_enabled: prüft ob alle 3 ENV-Vars gesetzt sind
Abgesicherte POST-Endpoints:
- POST /analyze → Depends(require_auth)
- POST /api/analyze-drucksache → Depends(require_auth)
- POST /api/programme/index → Depends(require_auth)
Neue Endpoints:
- GET /api/auth/me → {authenticated, sub, email, name, roles} oder {authenticated: false}
- GET /api/auth/login-url → {enabled, url} für Keycloak-Redirect
Frontend (index.html):
- initAuth() beim DOMContentLoaded → prüft /api/auth/me
- "Anmelden"-Button im Header (neben "Quellen")
- "Jetzt prüfen"-Button: disabled + Tooltip "Nur nach Anmeldung
verfügbar" wenn nicht eingeloggt; aktiv wenn eingeloggt
- currentUser-State steuert Button-Zustände
Dev-Modus: Solange KEYCLOAK_URL nicht gesetzt ist (lokale Dev, aktueller
Prod-Stand), sind alle Endpoints offen wie bisher. Kein Breaking Change.
Dependency: python-jose[cryptography]>=3.3.0 in requirements.txt.
Tests: 194/194 grün (auth.py hat keine Seiteneffekte im Import).
Refs: #43
2026-04-10 14:28:57 +02:00
authBtn.classList.add('logged-in');
2026-04-10 21:24:07 +02:00
authBtn.style.color = '#889e33';
authBtn.onclick = () => {
// Cookie löschen + Keycloak-Logout
document.cookie = 'access_token=; Max-Age=0; path=/; secure; samesite=lax';
currentUser = null;
updateAuthUI();
loadAssessments(); // Liste neu rendern (Buttons deaktivieren)
};
// Bestehende Liste neu rendern damit Buttons aktiv werden
if (allAssessments.length > 0) renderList(allAssessments);
#43 Keycloak SSO: JWT-Middleware + UI-Guiding
Auth-Schicht vorbereitet — Dev-Modus (KEYCLOAK_URL leer) lässt alles
durch, Prod-Modus (ENV gesetzt) validiert JWT gegen Keycloak-JWKS.
Backend (app/auth.py):
- JWKS-Cache mit 1h TTL (async httpx fetch)
- get_current_user: Optional, gibt User-Dict oder None
- require_auth: Pflicht, gibt User-Dict oder HTTP 401
- keycloak_login_url: Baut die OIDC-Login-URL
- _is_auth_enabled: prüft ob alle 3 ENV-Vars gesetzt sind
Abgesicherte POST-Endpoints:
- POST /analyze → Depends(require_auth)
- POST /api/analyze-drucksache → Depends(require_auth)
- POST /api/programme/index → Depends(require_auth)
Neue Endpoints:
- GET /api/auth/me → {authenticated, sub, email, name, roles} oder {authenticated: false}
- GET /api/auth/login-url → {enabled, url} für Keycloak-Redirect
Frontend (index.html):
- initAuth() beim DOMContentLoaded → prüft /api/auth/me
- "Anmelden"-Button im Header (neben "Quellen")
- "Jetzt prüfen"-Button: disabled + Tooltip "Nur nach Anmeldung
verfügbar" wenn nicht eingeloggt; aktiv wenn eingeloggt
- currentUser-State steuert Button-Zustände
Dev-Modus: Solange KEYCLOAK_URL nicht gesetzt ist (lokale Dev, aktueller
Prod-Stand), sind alle Endpoints offen wie bisher. Kein Breaking Change.
Dependency: python-jose[cryptography]>=3.3.0 in requirements.txt.
Tests: 194/194 grün (auth.py hat keine Seiteneffekte im Import).
Refs: #43
2026-04-10 14:28:57 +02:00
} else {
authBtn.textContent = '🔑 Anmelden';
2026-04-10 21:24:07 +02:00
authBtn.style.color = '';
#43 Keycloak SSO: JWT-Middleware + UI-Guiding
Auth-Schicht vorbereitet — Dev-Modus (KEYCLOAK_URL leer) lässt alles
durch, Prod-Modus (ENV gesetzt) validiert JWT gegen Keycloak-JWKS.
Backend (app/auth.py):
- JWKS-Cache mit 1h TTL (async httpx fetch)
- get_current_user: Optional, gibt User-Dict oder None
- require_auth: Pflicht, gibt User-Dict oder HTTP 401
- keycloak_login_url: Baut die OIDC-Login-URL
- _is_auth_enabled: prüft ob alle 3 ENV-Vars gesetzt sind
Abgesicherte POST-Endpoints:
- POST /analyze → Depends(require_auth)
- POST /api/analyze-drucksache → Depends(require_auth)
- POST /api/programme/index → Depends(require_auth)
Neue Endpoints:
- GET /api/auth/me → {authenticated, sub, email, name, roles} oder {authenticated: false}
- GET /api/auth/login-url → {enabled, url} für Keycloak-Redirect
Frontend (index.html):
- initAuth() beim DOMContentLoaded → prüft /api/auth/me
- "Anmelden"-Button im Header (neben "Quellen")
- "Jetzt prüfen"-Button: disabled + Tooltip "Nur nach Anmeldung
verfügbar" wenn nicht eingeloggt; aktiv wenn eingeloggt
- currentUser-State steuert Button-Zustände
Dev-Modus: Solange KEYCLOAK_URL nicht gesetzt ist (lokale Dev, aktueller
Prod-Stand), sind alle Endpoints offen wie bisher. Kein Breaking Change.
Dependency: python-jose[cryptography]>=3.3.0 in requirements.txt.
Tests: 194/194 grün (auth.py hat keine Seiteneffekte im Import).
Refs: #43
2026-04-10 14:28:57 +02:00
authBtn.classList.remove('logged-in');
authBtn.onclick = async () => {
const resp = await fetch(`/api/auth/login-url?redirect=${encodeURIComponent(window.location.pathname)}`);
const data = await resp.json();
if (data.url) window.location.href = data.url;
};
}
}
Bundesland filter & transparency: stringent split + visible source (#8)
Brings the Bundesland-Dropdown from a cosmetic header widget to a real
filter that propagates through every layer (Listing, internal search,
statistics, party/tag filters, upload mode), and at the same time makes
the source parliament visible in every place where assessments from
multiple bundesländer can be mixed.
Backend
- database.get_all_assessments(bundesland=None) — new optional filter,
"ALL" treated as None.
- database.search_assessments — bug fix: previous `if bundesland:`
branch incorrectly added a `WHERE bundesland='ALL'` clause; now
guarded with `bundesland and bundesland != "ALL"`.
- main.list_assessments — accepts ?bundesland= query param, includes the
bundesland field in the response so the frontend can render badges.
- main.get_single_assessment — also includes bundesland in the response
so the detail header can show the source parlament.
- main.search_landtag — early HTTP 400 when bundesland is missing or
"ALL"; the live Landtag adapter cannot serve a synthetic Bundesweit
request.
- main.index() and main.list_bundeslaender — synthetic "🌍 Bundesweit"
entry prepended to the bundesländer list (kept out of bundeslaender.py
on purpose — ALL is not a real state). Both endpoints additionally
expose a parlament_names map so the frontend can render the source
parliament without an extra round-trip.
Report (PDF + HTML)
- generate_html_report / generate_pdf_report — new optional bundesland
parameter. When set, the report header carries the parliament name
("Landtag von Sachsen-Anhalt", "Landtag Nordrhein-Westfalen", …)
beside the title. Three call sites updated: run_analysis,
run_drucksache_analysis, download_assessment_pdf.
Frontend (templates/index.html)
- Header dropdown gets the synthetic ALL entry as first option;
initial currentBundesland is now 'ALL' (was 'NRW').
- localStorage persistence: changeBundesland writes, DOMContentLoaded
reads and validates against the visible options.
- changeBundesland resets the score / party / tag filter state, syncs
the upload-mode bundesland select, disables the Landtag-Suche button
+ tooltip when ALL, and toggles a data-mode attribute on
.list-content (used by CSS to show/hide the per-item bundesland
badge).
- loadAssessments now sends ?bundesland=… so the API does the actual
filtering. updateStats renders an additional per-bundesland average
block (Ø NRW: x · Ø LSA: y) when in ALL mode and the loaded list
spans more than one bundesland.
- renderList prepends a small "bl-badge" beside the Drucksachen-Nummer.
Hidden in single-bundesland mode via CSS selector to avoid clutter.
- showDetail header now shows the parliament name as its own line
(.detail-parlament).
- searchLandtag has an early-out alert if currentBundesland === 'ALL',
saving a network round-trip.
- Upload-Mode bundesland select now starts with a "— Bundesland wählen
—" placeholder (no auto-default), and startAnalysis validates that a
concrete bundesland was chosen.
CSS
- .bl-badge plus the .list-content[data-mode="single"] hide rule.
- .detail-parlament for the detail header line.
- .header-parlament for the PDF report header line.
Resolves #8.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-07 23:00:39 +02:00
2026-04-10 22:29:55 +02:00
// Hamburger-Menü schließen bei Klick außerhalb
document.addEventListener('click', (e) => {
const menu = document.getElementById('hamburger-menu');
if (menu & & !e.target.closest('.hamburger-wrapper')) {
menu.classList.remove('open');
}
});
2026-04-10 22:15:13 +02:00
// Partei-Normalisierung (global, für Stats + Labels)
function normalizePartei(f) {
const u = f.toUpperCase();
if (u === 'AFD') return 'AfD';
if (u === 'GRÜNE' || u === 'GRUENE' || u === 'BÜNDNIS 90/DIE GRÜNEN') return 'GRÜNE';
if (u === 'DIE LINKE') return 'LINKE';
return f;
}
Bundesland filter & transparency: stringent split + visible source (#8)
Brings the Bundesland-Dropdown from a cosmetic header widget to a real
filter that propagates through every layer (Listing, internal search,
statistics, party/tag filters, upload mode), and at the same time makes
the source parliament visible in every place where assessments from
multiple bundesländer can be mixed.
Backend
- database.get_all_assessments(bundesland=None) — new optional filter,
"ALL" treated as None.
- database.search_assessments — bug fix: previous `if bundesland:`
branch incorrectly added a `WHERE bundesland='ALL'` clause; now
guarded with `bundesland and bundesland != "ALL"`.
- main.list_assessments — accepts ?bundesland= query param, includes the
bundesland field in the response so the frontend can render badges.
- main.get_single_assessment — also includes bundesland in the response
so the detail header can show the source parlament.
- main.search_landtag — early HTTP 400 when bundesland is missing or
"ALL"; the live Landtag adapter cannot serve a synthetic Bundesweit
request.
- main.index() and main.list_bundeslaender — synthetic "🌍 Bundesweit"
entry prepended to the bundesländer list (kept out of bundeslaender.py
on purpose — ALL is not a real state). Both endpoints additionally
expose a parlament_names map so the frontend can render the source
parliament without an extra round-trip.
Report (PDF + HTML)
- generate_html_report / generate_pdf_report — new optional bundesland
parameter. When set, the report header carries the parliament name
("Landtag von Sachsen-Anhalt", "Landtag Nordrhein-Westfalen", …)
beside the title. Three call sites updated: run_analysis,
run_drucksache_analysis, download_assessment_pdf.
Frontend (templates/index.html)
- Header dropdown gets the synthetic ALL entry as first option;
initial currentBundesland is now 'ALL' (was 'NRW').
- localStorage persistence: changeBundesland writes, DOMContentLoaded
reads and validates against the visible options.
- changeBundesland resets the score / party / tag filter state, syncs
the upload-mode bundesland select, disables the Landtag-Suche button
+ tooltip when ALL, and toggles a data-mode attribute on
.list-content (used by CSS to show/hide the per-item bundesland
badge).
- loadAssessments now sends ?bundesland=… so the API does the actual
filtering. updateStats renders an additional per-bundesland average
block (Ø NRW: x · Ø LSA: y) when in ALL mode and the loaded list
spans more than one bundesland.
- renderList prepends a small "bl-badge" beside the Drucksachen-Nummer.
Hidden in single-bundesland mode via CSS selector to avoid clutter.
- showDetail header now shows the parliament name as its own line
(.detail-parlament).
- searchLandtag has an early-out alert if currentBundesland === 'ALL',
saving a network round-trip.
- Upload-Mode bundesland select now starts with a "— Bundesland wählen
—" placeholder (no auto-default), and startAnalysis validates that a
concrete bundesland was chosen.
CSS
- .bl-badge plus the .list-content[data-mode="single"] hide rule.
- .detail-parlament for the detail header line.
- .header-parlament for the PDF report header line.
Resolves #8.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-07 23:00:39 +02:00
// Map code → parlament_name, vom Backend mit dem Initial-Render geliefert.
// Wird im Detail-Header und im Listen-Item-Badge-Tooltip verwendet.
const PARLAMENT_NAMES = {{ parlament_names | tojson }};
// Load assessments on page load — localStorage-Auswahl wiederherstellen
2026-03-28 22:30:24 +01:00
document.addEventListener('DOMContentLoaded', () => {
#43 Keycloak SSO: JWT-Middleware + UI-Guiding
Auth-Schicht vorbereitet — Dev-Modus (KEYCLOAK_URL leer) lässt alles
durch, Prod-Modus (ENV gesetzt) validiert JWT gegen Keycloak-JWKS.
Backend (app/auth.py):
- JWKS-Cache mit 1h TTL (async httpx fetch)
- get_current_user: Optional, gibt User-Dict oder None
- require_auth: Pflicht, gibt User-Dict oder HTTP 401
- keycloak_login_url: Baut die OIDC-Login-URL
- _is_auth_enabled: prüft ob alle 3 ENV-Vars gesetzt sind
Abgesicherte POST-Endpoints:
- POST /analyze → Depends(require_auth)
- POST /api/analyze-drucksache → Depends(require_auth)
- POST /api/programme/index → Depends(require_auth)
Neue Endpoints:
- GET /api/auth/me → {authenticated, sub, email, name, roles} oder {authenticated: false}
- GET /api/auth/login-url → {enabled, url} für Keycloak-Redirect
Frontend (index.html):
- initAuth() beim DOMContentLoaded → prüft /api/auth/me
- "Anmelden"-Button im Header (neben "Quellen")
- "Jetzt prüfen"-Button: disabled + Tooltip "Nur nach Anmeldung
verfügbar" wenn nicht eingeloggt; aktiv wenn eingeloggt
- currentUser-State steuert Button-Zustände
Dev-Modus: Solange KEYCLOAK_URL nicht gesetzt ist (lokale Dev, aktueller
Prod-Stand), sind alle Endpoints offen wie bisher. Kein Breaking Change.
Dependency: python-jose[cryptography]>=3.3.0 in requirements.txt.
Tests: 194/194 grün (auth.py hat keine Seiteneffekte im Import).
Refs: #43
2026-04-10 14:28:57 +02:00
initAuth(); // #43: Auth-State prüfen
Bundesland filter & transparency: stringent split + visible source (#8)
Brings the Bundesland-Dropdown from a cosmetic header widget to a real
filter that propagates through every layer (Listing, internal search,
statistics, party/tag filters, upload mode), and at the same time makes
the source parliament visible in every place where assessments from
multiple bundesländer can be mixed.
Backend
- database.get_all_assessments(bundesland=None) — new optional filter,
"ALL" treated as None.
- database.search_assessments — bug fix: previous `if bundesland:`
branch incorrectly added a `WHERE bundesland='ALL'` clause; now
guarded with `bundesland and bundesland != "ALL"`.
- main.list_assessments — accepts ?bundesland= query param, includes the
bundesland field in the response so the frontend can render badges.
- main.get_single_assessment — also includes bundesland in the response
so the detail header can show the source parlament.
- main.search_landtag — early HTTP 400 when bundesland is missing or
"ALL"; the live Landtag adapter cannot serve a synthetic Bundesweit
request.
- main.index() and main.list_bundeslaender — synthetic "🌍 Bundesweit"
entry prepended to the bundesländer list (kept out of bundeslaender.py
on purpose — ALL is not a real state). Both endpoints additionally
expose a parlament_names map so the frontend can render the source
parliament without an extra round-trip.
Report (PDF + HTML)
- generate_html_report / generate_pdf_report — new optional bundesland
parameter. When set, the report header carries the parliament name
("Landtag von Sachsen-Anhalt", "Landtag Nordrhein-Westfalen", …)
beside the title. Three call sites updated: run_analysis,
run_drucksache_analysis, download_assessment_pdf.
Frontend (templates/index.html)
- Header dropdown gets the synthetic ALL entry as first option;
initial currentBundesland is now 'ALL' (was 'NRW').
- localStorage persistence: changeBundesland writes, DOMContentLoaded
reads and validates against the visible options.
- changeBundesland resets the score / party / tag filter state, syncs
the upload-mode bundesland select, disables the Landtag-Suche button
+ tooltip when ALL, and toggles a data-mode attribute on
.list-content (used by CSS to show/hide the per-item bundesland
badge).
- loadAssessments now sends ?bundesland=… so the API does the actual
filtering. updateStats renders an additional per-bundesland average
block (Ø NRW: x · Ø LSA: y) when in ALL mode and the loaded list
spans more than one bundesland.
- renderList prepends a small "bl-badge" beside the Drucksachen-Nummer.
Hidden in single-bundesland mode via CSS selector to avoid clutter.
- showDetail header now shows the parliament name as its own line
(.detail-parlament).
- searchLandtag has an early-out alert if currentBundesland === 'ALL',
saving a network round-trip.
- Upload-Mode bundesland select now starts with a "— Bundesland wählen
—" placeholder (no auto-default), and startAnalysis validates that a
concrete bundesland was chosen.
CSS
- .bl-badge plus the .list-content[data-mode="single"] hide rule.
- .detail-parlament for the detail header line.
- .header-parlament for the PDF report header line.
Resolves #8.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-07 23:00:39 +02:00
const saved = localStorage.getItem('selectedBundesland');
const select = document.getElementById('bundesland-select');
if (saved) {
// Validieren: existiert die Option?
const exists = Array.from(select.options).some(
o => o.value === saved & & !o.disabled
);
if (exists) {
currentBundesland = saved;
select.value = saved;
}
}
// Modus-Klasse für CSS (Badges aus/an)
document.getElementById('list-content').dataset.mode =
(currentBundesland === 'ALL') ? 'all' : 'single';
// Landtag-Button-State für Initial-Auswahl
const btnLandtag = document.getElementById('btn-landtag');
if (currentBundesland === 'ALL') {
btnLandtag.disabled = true;
btnLandtag.title = 'Landtag-Suche nur mit konkretem Bundesland möglich';
}
2026-03-28 22:30:24 +01:00
loadAssessments();
});
function buildParteienFilter() {
// Alle Fraktionen sammeln
const parteien = new Set();
allAssessments.forEach(a => {
(a.fraktionen || []).forEach(f => parteien.add(f));
});
const select = document.getElementById('partei-filter');
select.innerHTML = '< option value = "" > Alle Parteien< / option > ';
Array.from(parteien).sort().forEach(p => {
select.innerHTML += `< option value = "${p}" > ${p}< / option > `;
});
}
function buildTagCloud() {
allTags = {};
allAssessments.forEach(a => {
(a.themen || []).forEach(tag => {
allTags[tag] = (allTags[tag] || 0) + 1;
});
});
renderTagCloud();
}
function renderTagCloud(filter = '') {
const container = document.getElementById('tag-cloud');
const filterLower = filter.toLowerCase();
// Sortiert nach Häufigkeit
const sorted = Object.entries(allTags)
.filter(([tag]) => tag.toLowerCase().includes(filterLower))
.sort((a, b) => b[1] - a[1]);
if (sorted.length === 0) {
container.innerHTML = '< p style = "color: #888;" > Keine Tags gefunden< / p > ';
return;
}
const maxCount = Math.max(...sorted.map(([, c]) => c));
container.innerHTML = '< div style = "display: flex; flex-wrap: wrap; gap: 0.5rem;" > ' +
sorted.map(([tag, count]) => {
const size = 0.75 + (count / maxCount) * 0.75;
const isActive = selectedTags.has(tag);
return `< button
onclick="toggleTag('${tag.replace(/'/g, "\\'")}')"
style="
font-size: ${size}rem;
padding: 0.3rem 0.6rem;
border-radius: 20px;
border: 1px solid ${isActive ? 'var(--color-blue)' : 'var(--color-lightgray)'};
background: ${isActive ? 'var(--color-blue)' : 'white'};
color: ${isActive ? 'white' : 'var(--color-darkgray)'};
cursor: pointer;
"
title="${count} Anträge"
>${tag} < span style = "font-size: 0.7rem; opacity: 0.7;" > (${count})< / span > < / button > `;
}).join('') +
'< / div > ';
}
function filterTagCloud(query) {
renderTagCloud(query);
}
function toggleTag(tag) {
if (selectedTags.has(tag)) {
selectedTags.delete(tag);
} else {
selectedTags.add(tag);
}
updateTagFilterUI();
filterByTags();
}
function updateTagFilterUI() {
const container = document.getElementById('active-tags');
const clearBtn = document.getElementById('clear-tags-btn');
if (selectedTags.size === 0) {
container.innerHTML = '';
clearBtn.style.display = 'none';
} else {
container.innerHTML = Array.from(selectedTags).map(tag =>
`< span style = "padding: 0.25rem 0.5rem; background: var(--color-blue); color: white; border-radius: 4px; font-size: 0.85rem;" >
${tag} < span style = "cursor: pointer; margin-left: 0.25rem;" onclick = "toggleTag('${tag.replace(/'/g, " \ \ ' " ) } ' ) " > × < / span >
< / span > `
).join('');
clearBtn.style.display = 'block';
}
renderTagCloud(document.getElementById('tag-search-input')?.value || '');
}
function clearTagFilters() {
selectedTags.clear();
updateTagFilterUI();
filterByTags();
}
function filterByTags() {
const resultsContainer = document.getElementById('tag-results-list');
if (selectedTags.size === 0) {
resultsContainer.innerHTML = '< p style = "color: #888;" > Wähle Tags aus der Wolke um Anträge zu filtern.< / p > ';
return;
}
// Schnittmenge: Anträge müssen ALLE ausgewählten Tags haben
const filtered = allAssessments.filter(a => {
const tags = new Set(a.themen || []);
return Array.from(selectedTags).every(t => tags.has(t));
});
if (filtered.length === 0) {
resultsContainer.innerHTML = '< p style = "color: #888;" > Keine Anträge mit dieser Tag-Kombination gefunden.< / p > ';
return;
}
resultsContainer.innerHTML = `
< p style = "margin-bottom: 1rem; color: #666;" > ${filtered.length} Anträge gefunden< / p >
${filtered.map(item => `
< div style = "padding: 0.75rem; border-bottom: 1px solid var(--color-lightgray); cursor: pointer;" onclick = "showMode('browse'); setTimeout(() => showDetail('${item.drucksache}'), 100);" >
< div style = "display: flex; justify-content: space-between; align-items: center;" >
< span style = "font-weight: bold; color: var(--color-blue);" > ${item.drucksache}< / span >
< span style = "padding: 0.1rem 0.5rem; border-radius: 4px; font-size: 0.85rem; ${item.gwoeScore >= 8 ? 'background: #155724; color: white;' : item.gwoeScore >= 5 ? 'background: #889e33; color: white;' : 'background: #dc3545; color: white;'}" > ${item.gwoeScore}/10< / span >
< / div >
< div style = "font-size: 0.9rem; margin-top: 0.25rem;" > ${item.title || 'Ohne Titel'}< / div >
< div style = "font-size: 0.8rem; color: #888;" > ${(item.fraktionen || []).join(', ')}< / div >
< / div >
`).join('')}
`;
}
async function loadAssessments() {
try {
Bundesland filter & transparency: stringent split + visible source (#8)
Brings the Bundesland-Dropdown from a cosmetic header widget to a real
filter that propagates through every layer (Listing, internal search,
statistics, party/tag filters, upload mode), and at the same time makes
the source parliament visible in every place where assessments from
multiple bundesländer can be mixed.
Backend
- database.get_all_assessments(bundesland=None) — new optional filter,
"ALL" treated as None.
- database.search_assessments — bug fix: previous `if bundesland:`
branch incorrectly added a `WHERE bundesland='ALL'` clause; now
guarded with `bundesland and bundesland != "ALL"`.
- main.list_assessments — accepts ?bundesland= query param, includes the
bundesland field in the response so the frontend can render badges.
- main.get_single_assessment — also includes bundesland in the response
so the detail header can show the source parlament.
- main.search_landtag — early HTTP 400 when bundesland is missing or
"ALL"; the live Landtag adapter cannot serve a synthetic Bundesweit
request.
- main.index() and main.list_bundeslaender — synthetic "🌍 Bundesweit"
entry prepended to the bundesländer list (kept out of bundeslaender.py
on purpose — ALL is not a real state). Both endpoints additionally
expose a parlament_names map so the frontend can render the source
parliament without an extra round-trip.
Report (PDF + HTML)
- generate_html_report / generate_pdf_report — new optional bundesland
parameter. When set, the report header carries the parliament name
("Landtag von Sachsen-Anhalt", "Landtag Nordrhein-Westfalen", …)
beside the title. Three call sites updated: run_analysis,
run_drucksache_analysis, download_assessment_pdf.
Frontend (templates/index.html)
- Header dropdown gets the synthetic ALL entry as first option;
initial currentBundesland is now 'ALL' (was 'NRW').
- localStorage persistence: changeBundesland writes, DOMContentLoaded
reads and validates against the visible options.
- changeBundesland resets the score / party / tag filter state, syncs
the upload-mode bundesland select, disables the Landtag-Suche button
+ tooltip when ALL, and toggles a data-mode attribute on
.list-content (used by CSS to show/hide the per-item bundesland
badge).
- loadAssessments now sends ?bundesland=… so the API does the actual
filtering. updateStats renders an additional per-bundesland average
block (Ø NRW: x · Ø LSA: y) when in ALL mode and the loaded list
spans more than one bundesland.
- renderList prepends a small "bl-badge" beside the Drucksachen-Nummer.
Hidden in single-bundesland mode via CSS selector to avoid clutter.
- showDetail header now shows the parliament name as its own line
(.detail-parlament).
- searchLandtag has an early-out alert if currentBundesland === 'ALL',
saving a network round-trip.
- Upload-Mode bundesland select now starts with a "— Bundesland wählen
—" placeholder (no auto-default), and startAnalysis validates that a
concrete bundesland was chosen.
CSS
- .bl-badge plus the .list-content[data-mode="single"] hide rule.
- .detail-parlament for the detail header line.
- .header-parlament for the PDF report header line.
Resolves #8.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-07 23:00:39 +02:00
const url = `/api/assessments?bundesland=${encodeURIComponent(currentBundesland)}`;
const resp = await fetch(url);
2026-03-28 22:30:24 +01:00
allAssessments = await resp.json();
updateStats();
renderList(allAssessments);
buildParteienFilter();
buildTagCloud();
} catch (e) {
2026-04-10 19:55:08 +02:00
console.error('loadAssessments error:', e);
Bundesland filter & transparency: stringent split + visible source (#8)
Brings the Bundesland-Dropdown from a cosmetic header widget to a real
filter that propagates through every layer (Listing, internal search,
statistics, party/tag filters, upload mode), and at the same time makes
the source parliament visible in every place where assessments from
multiple bundesländer can be mixed.
Backend
- database.get_all_assessments(bundesland=None) — new optional filter,
"ALL" treated as None.
- database.search_assessments — bug fix: previous `if bundesland:`
branch incorrectly added a `WHERE bundesland='ALL'` clause; now
guarded with `bundesland and bundesland != "ALL"`.
- main.list_assessments — accepts ?bundesland= query param, includes the
bundesland field in the response so the frontend can render badges.
- main.get_single_assessment — also includes bundesland in the response
so the detail header can show the source parlament.
- main.search_landtag — early HTTP 400 when bundesland is missing or
"ALL"; the live Landtag adapter cannot serve a synthetic Bundesweit
request.
- main.index() and main.list_bundeslaender — synthetic "🌍 Bundesweit"
entry prepended to the bundesländer list (kept out of bundeslaender.py
on purpose — ALL is not a real state). Both endpoints additionally
expose a parlament_names map so the frontend can render the source
parliament without an extra round-trip.
Report (PDF + HTML)
- generate_html_report / generate_pdf_report — new optional bundesland
parameter. When set, the report header carries the parliament name
("Landtag von Sachsen-Anhalt", "Landtag Nordrhein-Westfalen", …)
beside the title. Three call sites updated: run_analysis,
run_drucksache_analysis, download_assessment_pdf.
Frontend (templates/index.html)
- Header dropdown gets the synthetic ALL entry as first option;
initial currentBundesland is now 'ALL' (was 'NRW').
- localStorage persistence: changeBundesland writes, DOMContentLoaded
reads and validates against the visible options.
- changeBundesland resets the score / party / tag filter state, syncs
the upload-mode bundesland select, disables the Landtag-Suche button
+ tooltip when ALL, and toggles a data-mode attribute on
.list-content (used by CSS to show/hide the per-item bundesland
badge).
- loadAssessments now sends ?bundesland=… so the API does the actual
filtering. updateStats renders an additional per-bundesland average
block (Ø NRW: x · Ø LSA: y) when in ALL mode and the loaded list
spans more than one bundesland.
- renderList prepends a small "bl-badge" beside the Drucksachen-Nummer.
Hidden in single-bundesland mode via CSS selector to avoid clutter.
- showDetail header now shows the parliament name as its own line
(.detail-parlament).
- searchLandtag has an early-out alert if currentBundesland === 'ALL',
saving a network round-trip.
- Upload-Mode bundesland select now starts with a "— Bundesland wählen
—" placeholder (no auto-default), and startAnalysis validates that a
concrete bundesland was chosen.
CSS
- .bl-badge plus the .list-content[data-mode="single"] hide rule.
- .detail-parlament for the detail header line.
- .header-parlament for the PDF report header line.
Resolves #8.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-07 23:00:39 +02:00
document.getElementById('list-content').innerHTML =
2026-04-10 19:55:08 +02:00
`< p style = "padding: 1rem; color: #c00;" > Fehler beim Laden: ${e.message}< / p > `;
2026-03-28 22:30:24 +01:00
}
}
function updateStats() {
const checked = allAssessments.filter(a => a.status !== 'unchecked').length;
const high = allAssessments.filter(a => a.gwoeScore >= 8).length;
const avg = checked > 0 ? (allAssessments.filter(a => a.gwoeScore != null).reduce((s, a) => s + (a.gwoeScore || 0), 0) / checked).toFixed(1) : 0;
Bundesland filter & transparency: stringent split + visible source (#8)
Brings the Bundesland-Dropdown from a cosmetic header widget to a real
filter that propagates through every layer (Listing, internal search,
statistics, party/tag filters, upload mode), and at the same time makes
the source parliament visible in every place where assessments from
multiple bundesländer can be mixed.
Backend
- database.get_all_assessments(bundesland=None) — new optional filter,
"ALL" treated as None.
- database.search_assessments — bug fix: previous `if bundesland:`
branch incorrectly added a `WHERE bundesland='ALL'` clause; now
guarded with `bundesland and bundesland != "ALL"`.
- main.list_assessments — accepts ?bundesland= query param, includes the
bundesland field in the response so the frontend can render badges.
- main.get_single_assessment — also includes bundesland in the response
so the detail header can show the source parlament.
- main.search_landtag — early HTTP 400 when bundesland is missing or
"ALL"; the live Landtag adapter cannot serve a synthetic Bundesweit
request.
- main.index() and main.list_bundeslaender — synthetic "🌍 Bundesweit"
entry prepended to the bundesländer list (kept out of bundeslaender.py
on purpose — ALL is not a real state). Both endpoints additionally
expose a parlament_names map so the frontend can render the source
parliament without an extra round-trip.
Report (PDF + HTML)
- generate_html_report / generate_pdf_report — new optional bundesland
parameter. When set, the report header carries the parliament name
("Landtag von Sachsen-Anhalt", "Landtag Nordrhein-Westfalen", …)
beside the title. Three call sites updated: run_analysis,
run_drucksache_analysis, download_assessment_pdf.
Frontend (templates/index.html)
- Header dropdown gets the synthetic ALL entry as first option;
initial currentBundesland is now 'ALL' (was 'NRW').
- localStorage persistence: changeBundesland writes, DOMContentLoaded
reads and validates against the visible options.
- changeBundesland resets the score / party / tag filter state, syncs
the upload-mode bundesland select, disables the Landtag-Suche button
+ tooltip when ALL, and toggles a data-mode attribute on
.list-content (used by CSS to show/hide the per-item bundesland
badge).
- loadAssessments now sends ?bundesland=… so the API does the actual
filtering. updateStats renders an additional per-bundesland average
block (Ø NRW: x · Ø LSA: y) when in ALL mode and the loaded list
spans more than one bundesland.
- renderList prepends a small "bl-badge" beside the Drucksachen-Nummer.
Hidden in single-bundesland mode via CSS selector to avoid clutter.
- showDetail header now shows the parliament name as its own line
(.detail-parlament).
- searchLandtag has an early-out alert if currentBundesland === 'ALL',
saving a network round-trip.
- Upload-Mode bundesland select now starts with a "— Bundesland wählen
—" placeholder (no auto-default), and startAnalysis validates that a
concrete bundesland was chosen.
CSS
- .bl-badge plus the .list-content[data-mode="single"] hide rule.
- .detail-parlament for the detail header line.
- .header-parlament for the PDF report header line.
Resolves #8.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-07 23:00:39 +02:00
2026-03-28 22:30:24 +01:00
document.getElementById('stat-total').textContent = checked;
document.getElementById('stat-high').textContent = high;
document.getElementById('stat-avg').textContent = avg;
Bundesland filter & transparency: stringent split + visible source (#8)
Brings the Bundesland-Dropdown from a cosmetic header widget to a real
filter that propagates through every layer (Listing, internal search,
statistics, party/tag filters, upload mode), and at the same time makes
the source parliament visible in every place where assessments from
multiple bundesländer can be mixed.
Backend
- database.get_all_assessments(bundesland=None) — new optional filter,
"ALL" treated as None.
- database.search_assessments — bug fix: previous `if bundesland:`
branch incorrectly added a `WHERE bundesland='ALL'` clause; now
guarded with `bundesland and bundesland != "ALL"`.
- main.list_assessments — accepts ?bundesland= query param, includes the
bundesland field in the response so the frontend can render badges.
- main.get_single_assessment — also includes bundesland in the response
so the detail header can show the source parlament.
- main.search_landtag — early HTTP 400 when bundesland is missing or
"ALL"; the live Landtag adapter cannot serve a synthetic Bundesweit
request.
- main.index() and main.list_bundeslaender — synthetic "🌍 Bundesweit"
entry prepended to the bundesländer list (kept out of bundeslaender.py
on purpose — ALL is not a real state). Both endpoints additionally
expose a parlament_names map so the frontend can render the source
parliament without an extra round-trip.
Report (PDF + HTML)
- generate_html_report / generate_pdf_report — new optional bundesland
parameter. When set, the report header carries the parliament name
("Landtag von Sachsen-Anhalt", "Landtag Nordrhein-Westfalen", …)
beside the title. Three call sites updated: run_analysis,
run_drucksache_analysis, download_assessment_pdf.
Frontend (templates/index.html)
- Header dropdown gets the synthetic ALL entry as first option;
initial currentBundesland is now 'ALL' (was 'NRW').
- localStorage persistence: changeBundesland writes, DOMContentLoaded
reads and validates against the visible options.
- changeBundesland resets the score / party / tag filter state, syncs
the upload-mode bundesland select, disables the Landtag-Suche button
+ tooltip when ALL, and toggles a data-mode attribute on
.list-content (used by CSS to show/hide the per-item bundesland
badge).
- loadAssessments now sends ?bundesland=… so the API does the actual
filtering. updateStats renders an additional per-bundesland average
block (Ø NRW: x · Ø LSA: y) when in ALL mode and the loaded list
spans more than one bundesland.
- renderList prepends a small "bl-badge" beside the Drucksachen-Nummer.
Hidden in single-bundesland mode via CSS selector to avoid clutter.
- showDetail header now shows the parliament name as its own line
(.detail-parlament).
- searchLandtag has an early-out alert if currentBundesland === 'ALL',
saving a network round-trip.
- Upload-Mode bundesland select now starts with a "— Bundesland wählen
—" placeholder (no auto-default), and startAnalysis validates that a
concrete bundesland was chosen.
CSS
- .bl-badge plus the .list-content[data-mode="single"] hide rule.
- .detail-parlament for the detail header line.
- .header-parlament for the PDF report header line.
Resolves #8.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-07 23:00:39 +02:00
// Pro-Bundesland-Aufschlüsselung — nur im Bundesweit-Modus, und nur
// wenn tatsächlich mehr als ein Bundesland in der Liste vorkommt.
const blContainer = document.getElementById('bundesland-stats');
if (currentBundesland === 'ALL') {
const blStats = {};
allAssessments.forEach(a => {
if (a.gwoeScore == null || !a.bundesland) return;
if (!blStats[a.bundesland]) blStats[a.bundesland] = { sum: 0, count: 0 };
blStats[a.bundesland].sum += a.gwoeScore;
blStats[a.bundesland].count += 1;
});
const codes = Object.keys(blStats);
if (codes.length > 1) {
const sortedBl = codes
.map(c => ({ code: c, avg: blStats[c].sum / blStats[c].count, count: blStats[c].count }))
.sort((a, b) => b.avg - a.avg);
blContainer.innerHTML = sortedBl.map(b =>
`< span title = "${PARLAMENT_NAMES[b.code] || b.code}" > Ø < strong > ${b.code}< / strong > ${b.avg.toFixed(1)} < span style = "color:#888" > (n=${b.count})< / span > < / span > `
).join(' · ');
blContainer.style.display = 'inline-flex';
} else {
blContainer.style.display = 'none';
blContainer.innerHTML = '';
}
} else {
blContainer.style.display = 'none';
blContainer.innerHTML = '';
}
2026-04-10 22:15:13 +02:00
// Partei-Durchschnitte berechnen (Normalisierung via globaler normalizePartei)
2026-03-28 22:30:24 +01:00
const parteiStats = {};
allAssessments.forEach(a => {
if (a.gwoeScore == null) return;
(a.fraktionen || []).forEach(f => {
2026-04-10 22:13:30 +02:00
const norm = normalizePartei(f);
if (!parteiStats[norm]) parteiStats[norm] = { sum: 0, count: 0 };
parteiStats[norm].sum += a.gwoeScore;
parteiStats[norm].count += 1;
2026-03-28 22:30:24 +01:00
});
});
Bundesland filter & transparency: stringent split + visible source (#8)
Brings the Bundesland-Dropdown from a cosmetic header widget to a real
filter that propagates through every layer (Listing, internal search,
statistics, party/tag filters, upload mode), and at the same time makes
the source parliament visible in every place where assessments from
multiple bundesländer can be mixed.
Backend
- database.get_all_assessments(bundesland=None) — new optional filter,
"ALL" treated as None.
- database.search_assessments — bug fix: previous `if bundesland:`
branch incorrectly added a `WHERE bundesland='ALL'` clause; now
guarded with `bundesland and bundesland != "ALL"`.
- main.list_assessments — accepts ?bundesland= query param, includes the
bundesland field in the response so the frontend can render badges.
- main.get_single_assessment — also includes bundesland in the response
so the detail header can show the source parlament.
- main.search_landtag — early HTTP 400 when bundesland is missing or
"ALL"; the live Landtag adapter cannot serve a synthetic Bundesweit
request.
- main.index() and main.list_bundeslaender — synthetic "🌍 Bundesweit"
entry prepended to the bundesländer list (kept out of bundeslaender.py
on purpose — ALL is not a real state). Both endpoints additionally
expose a parlament_names map so the frontend can render the source
parliament without an extra round-trip.
Report (PDF + HTML)
- generate_html_report / generate_pdf_report — new optional bundesland
parameter. When set, the report header carries the parliament name
("Landtag von Sachsen-Anhalt", "Landtag Nordrhein-Westfalen", …)
beside the title. Three call sites updated: run_analysis,
run_drucksache_analysis, download_assessment_pdf.
Frontend (templates/index.html)
- Header dropdown gets the synthetic ALL entry as first option;
initial currentBundesland is now 'ALL' (was 'NRW').
- localStorage persistence: changeBundesland writes, DOMContentLoaded
reads and validates against the visible options.
- changeBundesland resets the score / party / tag filter state, syncs
the upload-mode bundesland select, disables the Landtag-Suche button
+ tooltip when ALL, and toggles a data-mode attribute on
.list-content (used by CSS to show/hide the per-item bundesland
badge).
- loadAssessments now sends ?bundesland=… so the API does the actual
filtering. updateStats renders an additional per-bundesland average
block (Ø NRW: x · Ø LSA: y) when in ALL mode and the loaded list
spans more than one bundesland.
- renderList prepends a small "bl-badge" beside the Drucksachen-Nummer.
Hidden in single-bundesland mode via CSS selector to avoid clutter.
- showDetail header now shows the parliament name as its own line
(.detail-parlament).
- searchLandtag has an early-out alert if currentBundesland === 'ALL',
saving a network round-trip.
- Upload-Mode bundesland select now starts with a "— Bundesland wählen
—" placeholder (no auto-default), and startAnalysis validates that a
concrete bundesland was chosen.
CSS
- .bl-badge plus the .list-content[data-mode="single"] hide rule.
- .detail-parlament for the detail header line.
- .header-parlament for the PDF report header line.
Resolves #8.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-07 23:00:39 +02:00
2026-03-28 22:30:24 +01:00
// Sortiert nach Durchschnitt (absteigend)
const sorted = Object.entries(parteiStats)
.map(([partei, data]) => ({ partei, avg: data.sum / data.count, count: data.count }))
.sort((a, b) => b.avg - a.avg);
Bundesland filter & transparency: stringent split + visible source (#8)
Brings the Bundesland-Dropdown from a cosmetic header widget to a real
filter that propagates through every layer (Listing, internal search,
statistics, party/tag filters, upload mode), and at the same time makes
the source parliament visible in every place where assessments from
multiple bundesländer can be mixed.
Backend
- database.get_all_assessments(bundesland=None) — new optional filter,
"ALL" treated as None.
- database.search_assessments — bug fix: previous `if bundesland:`
branch incorrectly added a `WHERE bundesland='ALL'` clause; now
guarded with `bundesland and bundesland != "ALL"`.
- main.list_assessments — accepts ?bundesland= query param, includes the
bundesland field in the response so the frontend can render badges.
- main.get_single_assessment — also includes bundesland in the response
so the detail header can show the source parlament.
- main.search_landtag — early HTTP 400 when bundesland is missing or
"ALL"; the live Landtag adapter cannot serve a synthetic Bundesweit
request.
- main.index() and main.list_bundeslaender — synthetic "🌍 Bundesweit"
entry prepended to the bundesländer list (kept out of bundeslaender.py
on purpose — ALL is not a real state). Both endpoints additionally
expose a parlament_names map so the frontend can render the source
parliament without an extra round-trip.
Report (PDF + HTML)
- generate_html_report / generate_pdf_report — new optional bundesland
parameter. When set, the report header carries the parliament name
("Landtag von Sachsen-Anhalt", "Landtag Nordrhein-Westfalen", …)
beside the title. Three call sites updated: run_analysis,
run_drucksache_analysis, download_assessment_pdf.
Frontend (templates/index.html)
- Header dropdown gets the synthetic ALL entry as first option;
initial currentBundesland is now 'ALL' (was 'NRW').
- localStorage persistence: changeBundesland writes, DOMContentLoaded
reads and validates against the visible options.
- changeBundesland resets the score / party / tag filter state, syncs
the upload-mode bundesland select, disables the Landtag-Suche button
+ tooltip when ALL, and toggles a data-mode attribute on
.list-content (used by CSS to show/hide the per-item bundesland
badge).
- loadAssessments now sends ?bundesland=… so the API does the actual
filtering. updateStats renders an additional per-bundesland average
block (Ø NRW: x · Ø LSA: y) when in ALL mode and the loaded list
spans more than one bundesland.
- renderList prepends a small "bl-badge" beside the Drucksachen-Nummer.
Hidden in single-bundesland mode via CSS selector to avoid clutter.
- showDetail header now shows the parliament name as its own line
(.detail-parlament).
- searchLandtag has an early-out alert if currentBundesland === 'ALL',
saving a network round-trip.
- Upload-Mode bundesland select now starts with a "— Bundesland wählen
—" placeholder (no auto-default), and startAnalysis validates that a
concrete bundesland was chosen.
CSS
- .bl-badge plus the .list-content[data-mode="single"] hide rule.
- .detail-parlament for the detail header line.
- .header-parlament for the PDF report header line.
Resolves #8.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-07 23:00:39 +02:00
2026-03-28 22:30:24 +01:00
const container = document.getElementById('partei-stats');
container.innerHTML = sorted.map(p => {
const color = p.avg >= 7 ? '#889e33' : p.avg >= 4 ? '#fd7e14' : '#dc3545';
return `< span style = "color: ${color};" > < strong > ${p.partei}< / strong > ${p.avg.toFixed(1)}< / span > `;
}).join('');
}
function renderList(items) {
const container = document.getElementById('list-content');
if (items.length === 0) {
container.innerHTML = '< p style = "padding: 1rem; color: #888;" > Keine Ergebnisse< / p > ';
return;
}
container.innerHTML = items.map(item => {
const isUnchecked = item.status === 'unchecked';
// Skala 0-10
const scoreClass = isUnchecked ? 'status-unchecked' :
item.gwoeScore >= 8 ? 'score-high' :
item.gwoeScore >= 5 ? 'score-mid' :
item.gwoeScore >= 3 ? 'score-low' : 'score-negative';
const fraktionen = (item.fraktionen || []).join(', ') || 'k.A.';
const themen = (item.themen || []).slice(0, 3);
const scoreText = isUnchecked ? '⏳' : `${item.gwoeScore}/10`;
Bundesland filter & transparency: stringent split + visible source (#8)
Brings the Bundesland-Dropdown from a cosmetic header widget to a real
filter that propagates through every layer (Listing, internal search,
statistics, party/tag filters, upload mode), and at the same time makes
the source parliament visible in every place where assessments from
multiple bundesländer can be mixed.
Backend
- database.get_all_assessments(bundesland=None) — new optional filter,
"ALL" treated as None.
- database.search_assessments — bug fix: previous `if bundesland:`
branch incorrectly added a `WHERE bundesland='ALL'` clause; now
guarded with `bundesland and bundesland != "ALL"`.
- main.list_assessments — accepts ?bundesland= query param, includes the
bundesland field in the response so the frontend can render badges.
- main.get_single_assessment — also includes bundesland in the response
so the detail header can show the source parlament.
- main.search_landtag — early HTTP 400 when bundesland is missing or
"ALL"; the live Landtag adapter cannot serve a synthetic Bundesweit
request.
- main.index() and main.list_bundeslaender — synthetic "🌍 Bundesweit"
entry prepended to the bundesländer list (kept out of bundeslaender.py
on purpose — ALL is not a real state). Both endpoints additionally
expose a parlament_names map so the frontend can render the source
parliament without an extra round-trip.
Report (PDF + HTML)
- generate_html_report / generate_pdf_report — new optional bundesland
parameter. When set, the report header carries the parliament name
("Landtag von Sachsen-Anhalt", "Landtag Nordrhein-Westfalen", …)
beside the title. Three call sites updated: run_analysis,
run_drucksache_analysis, download_assessment_pdf.
Frontend (templates/index.html)
- Header dropdown gets the synthetic ALL entry as first option;
initial currentBundesland is now 'ALL' (was 'NRW').
- localStorage persistence: changeBundesland writes, DOMContentLoaded
reads and validates against the visible options.
- changeBundesland resets the score / party / tag filter state, syncs
the upload-mode bundesland select, disables the Landtag-Suche button
+ tooltip when ALL, and toggles a data-mode attribute on
.list-content (used by CSS to show/hide the per-item bundesland
badge).
- loadAssessments now sends ?bundesland=… so the API does the actual
filtering. updateStats renders an additional per-bundesland average
block (Ø NRW: x · Ø LSA: y) when in ALL mode and the loaded list
spans more than one bundesland.
- renderList prepends a small "bl-badge" beside the Drucksachen-Nummer.
Hidden in single-bundesland mode via CSS selector to avoid clutter.
- showDetail header now shows the parliament name as its own line
(.detail-parlament).
- searchLandtag has an early-out alert if currentBundesland === 'ALL',
saving a network round-trip.
- Upload-Mode bundesland select now starts with a "— Bundesland wählen
—" placeholder (no auto-default), and startAnalysis validates that a
concrete bundesland was chosen.
CSS
- .bl-badge plus the .list-content[data-mode="single"] hide rule.
- .detail-parlament for the detail header line.
- .header-parlament for the PDF report header line.
Resolves #8.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-07 23:00:39 +02:00
const blBadge = item.bundesland
? `< span class = "bl-badge" title = "${PARLAMENT_NAMES[item.bundesland] || item.bundesland}" > ${item.bundesland}< / span > `
: '';
2026-03-28 22:30:24 +01:00
return `
< div class = "list-item ${isUnchecked ? 'unchecked' : ''}" data-drucksache = "${item.drucksache}" onclick = "${isUnchecked ? '' : `showDetail('${item.drucksache}')`}" >
< div class = "list-item-header" >
Bundesland filter & transparency: stringent split + visible source (#8)
Brings the Bundesland-Dropdown from a cosmetic header widget to a real
filter that propagates through every layer (Listing, internal search,
statistics, party/tag filters, upload mode), and at the same time makes
the source parliament visible in every place where assessments from
multiple bundesländer can be mixed.
Backend
- database.get_all_assessments(bundesland=None) — new optional filter,
"ALL" treated as None.
- database.search_assessments — bug fix: previous `if bundesland:`
branch incorrectly added a `WHERE bundesland='ALL'` clause; now
guarded with `bundesland and bundesland != "ALL"`.
- main.list_assessments — accepts ?bundesland= query param, includes the
bundesland field in the response so the frontend can render badges.
- main.get_single_assessment — also includes bundesland in the response
so the detail header can show the source parlament.
- main.search_landtag — early HTTP 400 when bundesland is missing or
"ALL"; the live Landtag adapter cannot serve a synthetic Bundesweit
request.
- main.index() and main.list_bundeslaender — synthetic "🌍 Bundesweit"
entry prepended to the bundesländer list (kept out of bundeslaender.py
on purpose — ALL is not a real state). Both endpoints additionally
expose a parlament_names map so the frontend can render the source
parliament without an extra round-trip.
Report (PDF + HTML)
- generate_html_report / generate_pdf_report — new optional bundesland
parameter. When set, the report header carries the parliament name
("Landtag von Sachsen-Anhalt", "Landtag Nordrhein-Westfalen", …)
beside the title. Three call sites updated: run_analysis,
run_drucksache_analysis, download_assessment_pdf.
Frontend (templates/index.html)
- Header dropdown gets the synthetic ALL entry as first option;
initial currentBundesland is now 'ALL' (was 'NRW').
- localStorage persistence: changeBundesland writes, DOMContentLoaded
reads and validates against the visible options.
- changeBundesland resets the score / party / tag filter state, syncs
the upload-mode bundesland select, disables the Landtag-Suche button
+ tooltip when ALL, and toggles a data-mode attribute on
.list-content (used by CSS to show/hide the per-item bundesland
badge).
- loadAssessments now sends ?bundesland=… so the API does the actual
filtering. updateStats renders an additional per-bundesland average
block (Ø NRW: x · Ø LSA: y) when in ALL mode and the loaded list
spans more than one bundesland.
- renderList prepends a small "bl-badge" beside the Drucksachen-Nummer.
Hidden in single-bundesland mode via CSS selector to avoid clutter.
- showDetail header now shows the parliament name as its own line
(.detail-parlament).
- searchLandtag has an early-out alert if currentBundesland === 'ALL',
saving a network round-trip.
- Upload-Mode bundesland select now starts with a "— Bundesland wählen
—" placeholder (no auto-default), and startAnalysis validates that a
concrete bundesland was chosen.
CSS
- .bl-badge plus the .list-content[data-mode="single"] hide rule.
- .detail-parlament for the detail header line.
- .header-parlament for the PDF report header line.
Resolves #8.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-07 23:00:39 +02:00
< span class = "list-item-id" > ${blBadge}${item.drucksache}< / span >
2026-03-28 22:30:24 +01:00
< span class = "list-item-score ${scoreClass}" > ${scoreText}< / span >
< / div >
2026-04-10 22:13:30 +02:00
< div class = "list-item-title" > ${(item.title || 'Ohne Titel').length > 80 ? (item.title.substring(0, 80) + '…') : (item.title || 'Ohne Titel')}< / div >
2026-03-28 22:30:24 +01:00
< div class = "list-item-meta" > ${fraktionen} · ${item.datum || ''}< / div >
${isUnchecked ? `
#43 Keycloak SSO: JWT-Middleware + UI-Guiding
Auth-Schicht vorbereitet — Dev-Modus (KEYCLOAK_URL leer) lässt alles
durch, Prod-Modus (ENV gesetzt) validiert JWT gegen Keycloak-JWKS.
Backend (app/auth.py):
- JWKS-Cache mit 1h TTL (async httpx fetch)
- get_current_user: Optional, gibt User-Dict oder None
- require_auth: Pflicht, gibt User-Dict oder HTTP 401
- keycloak_login_url: Baut die OIDC-Login-URL
- _is_auth_enabled: prüft ob alle 3 ENV-Vars gesetzt sind
Abgesicherte POST-Endpoints:
- POST /analyze → Depends(require_auth)
- POST /api/analyze-drucksache → Depends(require_auth)
- POST /api/programme/index → Depends(require_auth)
Neue Endpoints:
- GET /api/auth/me → {authenticated, sub, email, name, roles} oder {authenticated: false}
- GET /api/auth/login-url → {enabled, url} für Keycloak-Redirect
Frontend (index.html):
- initAuth() beim DOMContentLoaded → prüft /api/auth/me
- "Anmelden"-Button im Header (neben "Quellen")
- "Jetzt prüfen"-Button: disabled + Tooltip "Nur nach Anmeldung
verfügbar" wenn nicht eingeloggt; aktiv wenn eingeloggt
- currentUser-State steuert Button-Zustände
Dev-Modus: Solange KEYCLOAK_URL nicht gesetzt ist (lokale Dev, aktueller
Prod-Stand), sind alle Endpoints offen wie bisher. Kein Breaking Change.
Dependency: python-jose[cryptography]>=3.3.0 in requirements.txt.
Tests: 194/194 grün (auth.py hat keine Seiteneffekte im Import).
Refs: #43
2026-04-10 14:28:57 +02:00
< button class = "btn-check-now"
${currentUser ? '' : 'disabled title="Nur nach Anmeldung verfügbar" style="opacity:0.5;cursor:not-allowed;"'}
onclick="event.stopPropagation(); checkNow('${item.drucksache}', this)">
2026-03-28 22:30:24 +01:00
🔍 Jetzt prüfen
< / button >
` : `
< div class = "list-item-tags" >
${themen.map(t => `< span class = "tag" > ${t}< / span > `).join('')}
< / div >
`}
< / div >
`;
}).join('');
}
function debounceSearch(query) {
clearTimeout(searchTimeout);
searchTimeout = setTimeout(() => performSearch(query), 300);
}
async function performSearch(query) {
if (query.length < 2 ) {
// Show all from DB with current filters
applyAllFilters();
return;
}
isSearching = true;
document.getElementById('list-content').innerHTML = `
< div class = "loading" >
< div class = "spinner" > < / div >
< span > Suche...< / span >
< / div >
`;
try {
const resp = await fetch(`/api/search?q=${encodeURIComponent(query)}&bundesland=${currentBundesland}`);
let results = await resp.json();
// Score-Filter anwenden
if (currentScoreFilter !== 'all') {
results = applyScoreFilter(results, currentScoreFilter);
}
// Partei-Filter anwenden
if (currentParteiFilter) {
results = results.filter(a =>
(a.fraktionen || []).includes(currentParteiFilter)
);
}
renderList(results);
} catch (e) {
document.getElementById('list-content').innerHTML =
'< p style = "padding: 1rem; color: #888;" > Suchfehler< / p > ';
}
isSearching = false;
}
async function checkNow(drucksache, btn) {
btn.disabled = true;
btn.textContent = '⏳ Prüfe...';
try {
const resp = await fetch('/api/analyze-drucksache', {
method: 'POST',
headers: {'Content-Type': 'application/x-www-form-urlencoded'},
body: `drucksache=${encodeURIComponent(drucksache)}& bundesland=${currentBundesland}`
});
const data = await resp.json();
if (data.status === 'queued') {
btn.textContent = '⏳ Analysiere...';
pollAnalysis(data.job_id, drucksache, btn);
} else if (data.status === 'already_checked') {
btn.textContent = '✓ Bereits geprüft';
setTimeout(() => loadAssessments(), 500);
} else {
btn.textContent = '✗ Fehler';
btn.disabled = false;
}
} catch (e) {
btn.textContent = '✗ Fehler';
btn.disabled = false;
}
}
2026-04-10 22:24:43 +02:00
// ─── Merkliste (#94) ────────────────────────────────────────
async function loadBookmarksList() {
const container = document.getElementById('bookmarks-content');
if (!currentUser) {
container.innerHTML = '< p style = "color:#888;" > Bitte anmelden um die Merkliste zu sehen.< / p > ';
return;
}
container.innerHTML = '< p style = "color:#888;" > Lade...< / p > ';
try {
const bookmarkIds = await fetch('/api/bookmarks').then(r => r.json());
if (bookmarkIds.length === 0) {
container.innerHTML = '< p style = "color:#888;" > Keine gemerkten Anträge. Klicke auf "🔖 Merken" bei einem Antrag.< / p > ';
return;
}
// Finde die Assessment-Daten für die gebookmarkten Drucksachen
const bookmarked = allAssessments.filter(a => bookmarkIds.includes(a.drucksache));
if (bookmarked.length === 0) {
container.innerHTML = '< p style = "color:#888;" > Gemerkte Anträge nicht in der aktuellen Auswahl gefunden. Wechsle zu "Bundesweit".< / p > ';
return;
}
container.innerHTML = bookmarked.map(item => {
const scoreClass = item.gwoeScore >= 8 ? 'score-high' : item.gwoeScore >= 5 ? 'score-mid' : item.gwoeScore >= 3 ? 'score-low' : 'score-negative';
const blBadge = item.bundesland ? `< span class = "bl-badge" > ${item.bundesland}< / span > ` : '';
return `
< div class = "list-item" onclick = "showMode('browse'); setTimeout(() => showDetail('${item.drucksache}'), 100);" style = "cursor:pointer;" >
< div class = "list-item-header" >
< span class = "list-item-id" > ${blBadge}${item.drucksache}< / span >
< span class = "list-item-score ${scoreClass}" > ${item.gwoeScore}/10< / span >
< / div >
< div class = "list-item-title" > ${(item.title || '').substring(0, 80)}${(item.title || '').length > 80 ? '…' : ''}< / div >
< div class = "list-item-meta" > ${(item.fraktionen || []).join(', ')} · ${item.datum || ''}< / div >
< / div > `;
}).join('');
} catch (e) {
container.innerHTML = '< p style = "color:#c00;" > Fehler beim Laden der Merkliste.< / p > ';
}
}
#94 Bookmarks + Kommentare: DB-Schema, API, UI
DB (database.py):
- bookmarks-Tabelle (user_id + drucksache, toggle)
- comments-Tabelle (user_id, user_name, drucksache, text, visibility)
- Functions: toggle_bookmark, get_bookmarks, add_comment, get_comments, delete_comment
API (main.py):
- POST /api/bookmark (toggle, Auth-pflichtig)
- GET /api/bookmarks (User-Bookmarks)
- POST /api/comment (Auth-pflichtig, max 2000 Zeichen)
- GET /api/comments?drucksache= (öffentlich)
- DELETE /api/comment/{id} (nur eigene, Auth-pflichtig)
UI (index.html):
- Bookmark-Button ("🔖 Merken" / "⭐ Gemerkt") im Detail-Footer
- Kommentar-Bereich: Liste + Eingabefeld + Senden-Button
- Kommentare laden automatisch beim Detail-Öffnen
- Eigene Kommentare löschbar (✕ Button)
- Ohne Login: "Anmelden um zu kommentieren"
Gruppen-Sichtbarkeit (visibility) ist vorbereitet aber noch nicht
im UI exponiert — kommt als separater Schritt wenn Keycloak-Gruppen
konfiguriert sind.
Tests: 206 passed.
Refs: #94
2026-04-10 22:19:46 +02:00
// ─── Bookmarks + Comments (#94) ─────────────────────────────
async function toggleBookmark(drucksache, btn) {
const resp = await fetch('/api/bookmark', {
method: 'POST',
headers: {'Content-Type': 'application/x-www-form-urlencoded'},
body: `drucksache=${encodeURIComponent(drucksache)}`
});
if (resp.ok) {
const data = await resp.json();
btn.textContent = data.bookmarked ? '⭐ Gemerkt' : '🔖 Merken';
btn.style.background = data.bookmarked ? '#fff3cd' : 'none';
}
}
async function loadBookmarkState(drucksache) {
if (!currentUser) return;
const bookmarks = await fetch('/api/bookmarks').then(r => r.json());
const btn = document.getElementById('bookmark-btn-' + drucksache.replace('/', '-'));
if (btn & & bookmarks.includes(drucksache)) {
btn.textContent = '⭐ Gemerkt';
btn.style.background = '#fff3cd';
}
}
async function loadComments(drucksache) {
const container = document.getElementById('comments-' + drucksache.replace('/', '-'));
if (!container) return;
try {
const comments = await fetch(`/api/comments?drucksache=${encodeURIComponent(drucksache)}`).then(r => r.json());
2026-04-10 22:40:27 +02:00
// Client-seitig filtern nach Sichtbarkeit
const visible = comments.filter(c => {
if (c.visibility === 'all') return true;
if (!currentUser) return false;
if (c.visibility === 'authenticated') return true;
if (c.visibility === 'private') return c.user_id === currentUser.sub;
// group:XYZ — TODO: gegen Keycloak-Gruppen prüfen
if (c.visibility?.startsWith('group:')) return true; // Platzhalter
return false;
});
if (visible.length === 0) {
#94 Bookmarks + Kommentare: DB-Schema, API, UI
DB (database.py):
- bookmarks-Tabelle (user_id + drucksache, toggle)
- comments-Tabelle (user_id, user_name, drucksache, text, visibility)
- Functions: toggle_bookmark, get_bookmarks, add_comment, get_comments, delete_comment
API (main.py):
- POST /api/bookmark (toggle, Auth-pflichtig)
- GET /api/bookmarks (User-Bookmarks)
- POST /api/comment (Auth-pflichtig, max 2000 Zeichen)
- GET /api/comments?drucksache= (öffentlich)
- DELETE /api/comment/{id} (nur eigene, Auth-pflichtig)
UI (index.html):
- Bookmark-Button ("🔖 Merken" / "⭐ Gemerkt") im Detail-Footer
- Kommentar-Bereich: Liste + Eingabefeld + Senden-Button
- Kommentare laden automatisch beim Detail-Öffnen
- Eigene Kommentare löschbar (✕ Button)
- Ohne Login: "Anmelden um zu kommentieren"
Gruppen-Sichtbarkeit (visibility) ist vorbereitet aber noch nicht
im UI exponiert — kommt als separater Schritt wenn Keycloak-Gruppen
konfiguriert sind.
Tests: 206 passed.
Refs: #94
2026-04-10 22:19:46 +02:00
container.innerHTML = '< span style = "color:#aaa;font-size:0.85rem;" > Noch keine Kommentare.< / span > ';
return;
}
2026-04-10 22:40:27 +02:00
const visBadge = (v) => {
if (v === 'private') return '< span style = "font-size:0.7rem;background:#f0f0f0;padding:0.1rem 0.3rem;border-radius:2px;" title = "Nur für dich sichtbar" > 👤< / span > ';
if (v === 'authenticated') return '< span style = "font-size:0.7rem;background:#e8f4f8;padding:0.1rem 0.3rem;border-radius:2px;" title = "Nur für angemeldete Nutzer" > 🔒< / span > ';
if (v?.startsWith('group:')) return `< span style = "font-size:0.7rem;background:#f0e6ff;padding:0.1rem 0.3rem;border-radius:2px;" title = "Gruppe: ${v.substring(6)}" > 👥 ${v.substring(6)}< / span > `;
return '< span style = "font-size:0.7rem;background:#e8ffe8;padding:0.1rem 0.3rem;border-radius:2px;" title = "Öffentlich sichtbar" > 🌐< / span > ';
};
container.innerHTML = visible.map(c => `
#94 Bookmarks + Kommentare: DB-Schema, API, UI
DB (database.py):
- bookmarks-Tabelle (user_id + drucksache, toggle)
- comments-Tabelle (user_id, user_name, drucksache, text, visibility)
- Functions: toggle_bookmark, get_bookmarks, add_comment, get_comments, delete_comment
API (main.py):
- POST /api/bookmark (toggle, Auth-pflichtig)
- GET /api/bookmarks (User-Bookmarks)
- POST /api/comment (Auth-pflichtig, max 2000 Zeichen)
- GET /api/comments?drucksache= (öffentlich)
- DELETE /api/comment/{id} (nur eigene, Auth-pflichtig)
UI (index.html):
- Bookmark-Button ("🔖 Merken" / "⭐ Gemerkt") im Detail-Footer
- Kommentar-Bereich: Liste + Eingabefeld + Senden-Button
- Kommentare laden automatisch beim Detail-Öffnen
- Eigene Kommentare löschbar (✕ Button)
- Ohne Login: "Anmelden um zu kommentieren"
Gruppen-Sichtbarkeit (visibility) ist vorbereitet aber noch nicht
im UI exponiert — kommt als separater Schritt wenn Keycloak-Gruppen
konfiguriert sind.
Tests: 206 passed.
Refs: #94
2026-04-10 22:19:46 +02:00
< div style = "padding:0.4rem 0;border-bottom:1px solid #f0f0f0;font-size:0.85rem;" >
< strong > ${c.user_name || 'Anonym'}< / strong >
2026-04-10 22:40:27 +02:00
${visBadge(c.visibility)}
< span style = "color:#aaa;font-size:0.75rem;margin-left:0.3rem;" > ${new Date(c.created_at).toLocaleString('de-DE')}< / span >
#94 Bookmarks + Kommentare: DB-Schema, API, UI
DB (database.py):
- bookmarks-Tabelle (user_id + drucksache, toggle)
- comments-Tabelle (user_id, user_name, drucksache, text, visibility)
- Functions: toggle_bookmark, get_bookmarks, add_comment, get_comments, delete_comment
API (main.py):
- POST /api/bookmark (toggle, Auth-pflichtig)
- GET /api/bookmarks (User-Bookmarks)
- POST /api/comment (Auth-pflichtig, max 2000 Zeichen)
- GET /api/comments?drucksache= (öffentlich)
- DELETE /api/comment/{id} (nur eigene, Auth-pflichtig)
UI (index.html):
- Bookmark-Button ("🔖 Merken" / "⭐ Gemerkt") im Detail-Footer
- Kommentar-Bereich: Liste + Eingabefeld + Senden-Button
- Kommentare laden automatisch beim Detail-Öffnen
- Eigene Kommentare löschbar (✕ Button)
- Ohne Login: "Anmelden um zu kommentieren"
Gruppen-Sichtbarkeit (visibility) ist vorbereitet aber noch nicht
im UI exponiert — kommt als separater Schritt wenn Keycloak-Gruppen
konfiguriert sind.
Tests: 206 passed.
Refs: #94
2026-04-10 22:19:46 +02:00
${currentUser & & currentUser.sub === c.user_id ? `< button onclick = "deleteCommentUI(${c.id},'${drucksache}')" style = "float:right;background:none;border:none;color:#dc3545;cursor:pointer;font-size:0.75rem;" > ✕< / button > ` : ''}
< div style = "margin-top:0.2rem;" > ${c.text}< / div >
< / div >
`).join('');
} catch { container.innerHTML = ''; }
}
async function addCommentUI(drucksache) {
2026-04-10 22:40:27 +02:00
const safeDrs = drucksache.replace('/', '-');
const input = document.getElementById('comment-input-' + safeDrs);
const visSelect = document.getElementById('comment-visibility-' + safeDrs);
#94 Bookmarks + Kommentare: DB-Schema, API, UI
DB (database.py):
- bookmarks-Tabelle (user_id + drucksache, toggle)
- comments-Tabelle (user_id, user_name, drucksache, text, visibility)
- Functions: toggle_bookmark, get_bookmarks, add_comment, get_comments, delete_comment
API (main.py):
- POST /api/bookmark (toggle, Auth-pflichtig)
- GET /api/bookmarks (User-Bookmarks)
- POST /api/comment (Auth-pflichtig, max 2000 Zeichen)
- GET /api/comments?drucksache= (öffentlich)
- DELETE /api/comment/{id} (nur eigene, Auth-pflichtig)
UI (index.html):
- Bookmark-Button ("🔖 Merken" / "⭐ Gemerkt") im Detail-Footer
- Kommentar-Bereich: Liste + Eingabefeld + Senden-Button
- Kommentare laden automatisch beim Detail-Öffnen
- Eigene Kommentare löschbar (✕ Button)
- Ohne Login: "Anmelden um zu kommentieren"
Gruppen-Sichtbarkeit (visibility) ist vorbereitet aber noch nicht
im UI exponiert — kommt als separater Schritt wenn Keycloak-Gruppen
konfiguriert sind.
Tests: 206 passed.
Refs: #94
2026-04-10 22:19:46 +02:00
if (!input || !input.value.trim()) return;
2026-04-10 22:40:27 +02:00
const visibility = visSelect ? visSelect.value : 'all';
#94 Bookmarks + Kommentare: DB-Schema, API, UI
DB (database.py):
- bookmarks-Tabelle (user_id + drucksache, toggle)
- comments-Tabelle (user_id, user_name, drucksache, text, visibility)
- Functions: toggle_bookmark, get_bookmarks, add_comment, get_comments, delete_comment
API (main.py):
- POST /api/bookmark (toggle, Auth-pflichtig)
- GET /api/bookmarks (User-Bookmarks)
- POST /api/comment (Auth-pflichtig, max 2000 Zeichen)
- GET /api/comments?drucksache= (öffentlich)
- DELETE /api/comment/{id} (nur eigene, Auth-pflichtig)
UI (index.html):
- Bookmark-Button ("🔖 Merken" / "⭐ Gemerkt") im Detail-Footer
- Kommentar-Bereich: Liste + Eingabefeld + Senden-Button
- Kommentare laden automatisch beim Detail-Öffnen
- Eigene Kommentare löschbar (✕ Button)
- Ohne Login: "Anmelden um zu kommentieren"
Gruppen-Sichtbarkeit (visibility) ist vorbereitet aber noch nicht
im UI exponiert — kommt als separater Schritt wenn Keycloak-Gruppen
konfiguriert sind.
Tests: 206 passed.
Refs: #94
2026-04-10 22:19:46 +02:00
await fetch('/api/comment', {
method: 'POST',
headers: {'Content-Type': 'application/x-www-form-urlencoded'},
2026-04-10 22:40:27 +02:00
body: `drucksache=${encodeURIComponent(drucksache)}& text=${encodeURIComponent(input.value)}& visibility=${visibility}`
#94 Bookmarks + Kommentare: DB-Schema, API, UI
DB (database.py):
- bookmarks-Tabelle (user_id + drucksache, toggle)
- comments-Tabelle (user_id, user_name, drucksache, text, visibility)
- Functions: toggle_bookmark, get_bookmarks, add_comment, get_comments, delete_comment
API (main.py):
- POST /api/bookmark (toggle, Auth-pflichtig)
- GET /api/bookmarks (User-Bookmarks)
- POST /api/comment (Auth-pflichtig, max 2000 Zeichen)
- GET /api/comments?drucksache= (öffentlich)
- DELETE /api/comment/{id} (nur eigene, Auth-pflichtig)
UI (index.html):
- Bookmark-Button ("🔖 Merken" / "⭐ Gemerkt") im Detail-Footer
- Kommentar-Bereich: Liste + Eingabefeld + Senden-Button
- Kommentare laden automatisch beim Detail-Öffnen
- Eigene Kommentare löschbar (✕ Button)
- Ohne Login: "Anmelden um zu kommentieren"
Gruppen-Sichtbarkeit (visibility) ist vorbereitet aber noch nicht
im UI exponiert — kommt als separater Schritt wenn Keycloak-Gruppen
konfiguriert sind.
Tests: 206 passed.
Refs: #94
2026-04-10 22:19:46 +02:00
});
input.value = '';
loadComments(drucksache);
}
async function deleteCommentUI(commentId, drucksache) {
await fetch(`/api/comment/${commentId}`, {method: 'DELETE'});
loadComments(drucksache);
}
2026-04-10 21:10:33 +02:00
async function reAnalyze(drucksache, bundesland, btn) {
2026-04-10 21:24:07 +02:00
if (!currentUser) {
alert('Bitte zuerst anmelden.');
return;
}
2026-04-10 21:10:33 +02:00
btn.disabled = true;
2026-04-10 21:24:07 +02:00
btn.style.background = '#ffc107';
btn.textContent = '⏳ Lösche alte Bewertung...';
2026-04-10 21:10:33 +02:00
try {
// Altes Assessment löschen
2026-04-10 21:24:07 +02:00
const delResp = await fetch(`/api/assessment/delete?drucksache=${encodeURIComponent(drucksache)}`, {method: 'DELETE'});
if (delResp.status === 401) {
btn.textContent = '🔒 Nicht angemeldet';
btn.style.background = '#dc3545';
setTimeout(() => { btn.textContent = '🔄 Neu bewerten'; btn.style.background = '#6c757d'; btn.disabled = false; }, 3000);
return;
}
btn.textContent = '⏳ Analyse gestartet...';
2026-04-10 21:10:33 +02:00
// Neue Analyse enqueuen
const resp = await fetch('/api/analyze-drucksache', {
method: 'POST',
headers: {'Content-Type': 'application/x-www-form-urlencoded'},
body: `drucksache=${encodeURIComponent(drucksache)}& bundesland=${encodeURIComponent(bundesland)}`
});
2026-04-10 21:24:07 +02:00
if (resp.status === 401) {
btn.textContent = '🔒 Nicht angemeldet';
btn.style.background = '#dc3545';
setTimeout(() => { btn.textContent = '🔄 Neu bewerten'; btn.style.background = '#6c757d'; btn.disabled = false; }, 3000);
return;
}
2026-04-10 21:10:33 +02:00
const data = await resp.json();
if (data.status === 'queued') {
2026-04-10 21:24:07 +02:00
btn.textContent = '⏳ Wird analysiert...';
btn.style.background = '#009da5';
2026-04-10 21:10:33 +02:00
pollAnalysis(data.job_id, drucksache, btn);
} else {
2026-04-10 21:24:07 +02:00
btn.textContent = '❌ ' + (data.detail || 'Fehler');
btn.style.background = '#dc3545';
setTimeout(() => { btn.textContent = '🔄 Neu bewerten'; btn.style.background = '#6c757d'; btn.disabled = false; }, 3000);
2026-04-10 21:10:33 +02:00
}
} catch (e) {
2026-04-10 21:24:07 +02:00
btn.textContent = '❌ ' + e.message;
btn.style.background = '#dc3545';
setTimeout(() => { btn.textContent = '🔄 Neu bewerten'; btn.style.background = '#6c757d'; btn.disabled = false; }, 3000);
2026-04-10 21:10:33 +02:00
}
}
2026-03-28 22:30:24 +01:00
async function pollAnalysis(jobId, drucksache, btn) {
try {
const resp = await fetch(`/status/${jobId}`);
const data = await resp.json();
if (data.status === 'completed') {
btn.textContent = '✓ Geprüft';
btn.style.background = '#889e33'; // Green
// Update this item in current list to show as checked
const listItem = btn.closest('.list-item');
if (listItem) {
listItem.classList.remove('unchecked');
listItem.onclick = () => showDetail(drucksache);
}
// Reload assessments in background (for internal list)
loadAssessments();
// Show detail for this item
setTimeout(() => showDetail(drucksache), 500);
} else if (data.status === 'failed') {
btn.textContent = '✗ Fehlgeschlagen';
btn.disabled = false;
} else {
setTimeout(() => pollAnalysis(jobId, drucksache, btn), 2000);
}
} catch (e) {
btn.textContent = '✗ Fehler';
btn.disabled = false;
}
}
function changeBundesland(code) {
currentBundesland = code;
Bundesland filter & transparency: stringent split + visible source (#8)
Brings the Bundesland-Dropdown from a cosmetic header widget to a real
filter that propagates through every layer (Listing, internal search,
statistics, party/tag filters, upload mode), and at the same time makes
the source parliament visible in every place where assessments from
multiple bundesländer can be mixed.
Backend
- database.get_all_assessments(bundesland=None) — new optional filter,
"ALL" treated as None.
- database.search_assessments — bug fix: previous `if bundesland:`
branch incorrectly added a `WHERE bundesland='ALL'` clause; now
guarded with `bundesland and bundesland != "ALL"`.
- main.list_assessments — accepts ?bundesland= query param, includes the
bundesland field in the response so the frontend can render badges.
- main.get_single_assessment — also includes bundesland in the response
so the detail header can show the source parlament.
- main.search_landtag — early HTTP 400 when bundesland is missing or
"ALL"; the live Landtag adapter cannot serve a synthetic Bundesweit
request.
- main.index() and main.list_bundeslaender — synthetic "🌍 Bundesweit"
entry prepended to the bundesländer list (kept out of bundeslaender.py
on purpose — ALL is not a real state). Both endpoints additionally
expose a parlament_names map so the frontend can render the source
parliament without an extra round-trip.
Report (PDF + HTML)
- generate_html_report / generate_pdf_report — new optional bundesland
parameter. When set, the report header carries the parliament name
("Landtag von Sachsen-Anhalt", "Landtag Nordrhein-Westfalen", …)
beside the title. Three call sites updated: run_analysis,
run_drucksache_analysis, download_assessment_pdf.
Frontend (templates/index.html)
- Header dropdown gets the synthetic ALL entry as first option;
initial currentBundesland is now 'ALL' (was 'NRW').
- localStorage persistence: changeBundesland writes, DOMContentLoaded
reads and validates against the visible options.
- changeBundesland resets the score / party / tag filter state, syncs
the upload-mode bundesland select, disables the Landtag-Suche button
+ tooltip when ALL, and toggles a data-mode attribute on
.list-content (used by CSS to show/hide the per-item bundesland
badge).
- loadAssessments now sends ?bundesland=… so the API does the actual
filtering. updateStats renders an additional per-bundesland average
block (Ø NRW: x · Ø LSA: y) when in ALL mode and the loaded list
spans more than one bundesland.
- renderList prepends a small "bl-badge" beside the Drucksachen-Nummer.
Hidden in single-bundesland mode via CSS selector to avoid clutter.
- showDetail header now shows the parliament name as its own line
(.detail-parlament).
- searchLandtag has an early-out alert if currentBundesland === 'ALL',
saving a network round-trip.
- Upload-Mode bundesland select now starts with a "— Bundesland wählen
—" placeholder (no auto-default), and startAnalysis validates that a
concrete bundesland was chosen.
CSS
- .bl-badge plus the .list-content[data-mode="single"] hide rule.
- .detail-parlament for the detail header line.
- .header-parlament for the PDF report header line.
Resolves #8.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-07 23:00:39 +02:00
localStorage.setItem('selectedBundesland', code);
// Filter zurücksetzen — Parteien & Tags pro Bundesland unterschiedlich,
// ein "LINKE"-Filter aus LSA würde in NRW eine leere Liste zeigen.
currentScoreFilter = 'all';
currentParteiFilter = '';
selectedTags.clear();
2026-03-28 22:30:24 +01:00
document.getElementById('search-input').value = '';
2026-04-09 11:27:29 +02:00
const landtagInput = document.getElementById('landtag-search-input');
if (landtagInput) landtagInput.value = '';
Bundesland filter & transparency: stringent split + visible source (#8)
Brings the Bundesland-Dropdown from a cosmetic header widget to a real
filter that propagates through every layer (Listing, internal search,
statistics, party/tag filters, upload mode), and at the same time makes
the source parliament visible in every place where assessments from
multiple bundesländer can be mixed.
Backend
- database.get_all_assessments(bundesland=None) — new optional filter,
"ALL" treated as None.
- database.search_assessments — bug fix: previous `if bundesland:`
branch incorrectly added a `WHERE bundesland='ALL'` clause; now
guarded with `bundesland and bundesland != "ALL"`.
- main.list_assessments — accepts ?bundesland= query param, includes the
bundesland field in the response so the frontend can render badges.
- main.get_single_assessment — also includes bundesland in the response
so the detail header can show the source parlament.
- main.search_landtag — early HTTP 400 when bundesland is missing or
"ALL"; the live Landtag adapter cannot serve a synthetic Bundesweit
request.
- main.index() and main.list_bundeslaender — synthetic "🌍 Bundesweit"
entry prepended to the bundesländer list (kept out of bundeslaender.py
on purpose — ALL is not a real state). Both endpoints additionally
expose a parlament_names map so the frontend can render the source
parliament without an extra round-trip.
Report (PDF + HTML)
- generate_html_report / generate_pdf_report — new optional bundesland
parameter. When set, the report header carries the parliament name
("Landtag von Sachsen-Anhalt", "Landtag Nordrhein-Westfalen", …)
beside the title. Three call sites updated: run_analysis,
run_drucksache_analysis, download_assessment_pdf.
Frontend (templates/index.html)
- Header dropdown gets the synthetic ALL entry as first option;
initial currentBundesland is now 'ALL' (was 'NRW').
- localStorage persistence: changeBundesland writes, DOMContentLoaded
reads and validates against the visible options.
- changeBundesland resets the score / party / tag filter state, syncs
the upload-mode bundesland select, disables the Landtag-Suche button
+ tooltip when ALL, and toggles a data-mode attribute on
.list-content (used by CSS to show/hide the per-item bundesland
badge).
- loadAssessments now sends ?bundesland=… so the API does the actual
filtering. updateStats renders an additional per-bundesland average
block (Ø NRW: x · Ø LSA: y) when in ALL mode and the loaded list
spans more than one bundesland.
- renderList prepends a small "bl-badge" beside the Drucksachen-Nummer.
Hidden in single-bundesland mode via CSS selector to avoid clutter.
- showDetail header now shows the parliament name as its own line
(.detail-parlament).
- searchLandtag has an early-out alert if currentBundesland === 'ALL',
saving a network round-trip.
- Upload-Mode bundesland select now starts with a "— Bundesland wählen
—" placeholder (no auto-default), and startAnalysis validates that a
concrete bundesland was chosen.
CSS
- .bl-badge plus the .list-content[data-mode="single"] hide rule.
- .detail-parlament for the detail header line.
- .header-parlament for the PDF report header line.
Resolves #8.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-07 23:00:39 +02:00
document.querySelectorAll('.filter-btn').forEach(b => {
b.classList.toggle('active', b.dataset.filter === 'all');
});
const parteiSelect = document.getElementById('partei-filter');
if (parteiSelect) parteiSelect.value = '';
// Upload-Mode-Dropdown synchronisieren. Bei "ALL" KEIN automatischer
// Default — der User soll im Upload-Form bewusst ein Bundesland wählen.
const uploadDropdown = document.getElementById('bundesland');
if (uploadDropdown) {
if (code === 'ALL') {
uploadDropdown.value = '';
} else {
uploadDropdown.value = code;
}
}
// Landtag-Suche-Button im Bundesweit-Modus deaktivieren
const btnLandtag = document.getElementById('btn-landtag');
if (code === 'ALL') {
btnLandtag.disabled = true;
btnLandtag.title = 'Landtag-Suche nur mit konkretem Bundesland möglich';
} else {
btnLandtag.disabled = false;
btnLandtag.title = '';
}
// Modus-Klasse für CSS (Badges aus/an im Single-Modus)
document.getElementById('list-content').dataset.mode =
(code === 'ALL') ? 'all' : 'single';
2026-03-28 22:30:24 +01:00
loadAssessments();
}
async function searchLandtag() {
Bundesland filter & transparency: stringent split + visible source (#8)
Brings the Bundesland-Dropdown from a cosmetic header widget to a real
filter that propagates through every layer (Listing, internal search,
statistics, party/tag filters, upload mode), and at the same time makes
the source parliament visible in every place where assessments from
multiple bundesländer can be mixed.
Backend
- database.get_all_assessments(bundesland=None) — new optional filter,
"ALL" treated as None.
- database.search_assessments — bug fix: previous `if bundesland:`
branch incorrectly added a `WHERE bundesland='ALL'` clause; now
guarded with `bundesland and bundesland != "ALL"`.
- main.list_assessments — accepts ?bundesland= query param, includes the
bundesland field in the response so the frontend can render badges.
- main.get_single_assessment — also includes bundesland in the response
so the detail header can show the source parlament.
- main.search_landtag — early HTTP 400 when bundesland is missing or
"ALL"; the live Landtag adapter cannot serve a synthetic Bundesweit
request.
- main.index() and main.list_bundeslaender — synthetic "🌍 Bundesweit"
entry prepended to the bundesländer list (kept out of bundeslaender.py
on purpose — ALL is not a real state). Both endpoints additionally
expose a parlament_names map so the frontend can render the source
parliament without an extra round-trip.
Report (PDF + HTML)
- generate_html_report / generate_pdf_report — new optional bundesland
parameter. When set, the report header carries the parliament name
("Landtag von Sachsen-Anhalt", "Landtag Nordrhein-Westfalen", …)
beside the title. Three call sites updated: run_analysis,
run_drucksache_analysis, download_assessment_pdf.
Frontend (templates/index.html)
- Header dropdown gets the synthetic ALL entry as first option;
initial currentBundesland is now 'ALL' (was 'NRW').
- localStorage persistence: changeBundesland writes, DOMContentLoaded
reads and validates against the visible options.
- changeBundesland resets the score / party / tag filter state, syncs
the upload-mode bundesland select, disables the Landtag-Suche button
+ tooltip when ALL, and toggles a data-mode attribute on
.list-content (used by CSS to show/hide the per-item bundesland
badge).
- loadAssessments now sends ?bundesland=… so the API does the actual
filtering. updateStats renders an additional per-bundesland average
block (Ø NRW: x · Ø LSA: y) when in ALL mode and the loaded list
spans more than one bundesland.
- renderList prepends a small "bl-badge" beside the Drucksachen-Nummer.
Hidden in single-bundesland mode via CSS selector to avoid clutter.
- showDetail header now shows the parliament name as its own line
(.detail-parlament).
- searchLandtag has an early-out alert if currentBundesland === 'ALL',
saving a network round-trip.
- Upload-Mode bundesland select now starts with a "— Bundesland wählen
—" placeholder (no auto-default), and startAnalysis validates that a
concrete bundesland was chosen.
CSS
- .bl-badge plus the .list-content[data-mode="single"] hide rule.
- .detail-parlament for the detail header line.
- .header-parlament for the PDF report header line.
Resolves #8.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-07 23:00:39 +02:00
if (currentBundesland === 'ALL') {
alert('Landtag-Suche ist nur mit Auswahl eines konkreten Bundeslands möglich.\nBitte oben ein Bundesland auswählen.');
return;
}
2026-04-09 11:27:29 +02:00
// #16: Landtag-Suche zieht aus dem dedizierten Landtag-Input,
// nicht mehr aus dem DB-Suchfeld.
const query = document.getElementById('landtag-search-input').value.trim();
2026-03-28 22:30:24 +01:00
if (query.length < 2 ) {
2026-04-09 11:27:29 +02:00
alert('Bitte mindestens 2 Zeichen ins Landtag-Suchfeld eingeben');
2026-03-28 22:30:24 +01:00
return;
}
const btn = document.getElementById('btn-landtag');
btn.disabled = true;
btn.textContent = '⏳ Suche...';
document.getElementById('list-content').innerHTML = `
< div class = "loading" >
< div class = "spinner" > < / div >
< span > Suche im Landtag ${currentBundesland}...< / span >
< / div >
`;
try {
const resp = await fetch(`/api/search-landtag?q=${encodeURIComponent(query)}&bundesland=${currentBundesland}`);
const results = await resp.json();
if (results.error) {
document.getElementById('list-content').innerHTML = `
< p style = "padding: 1rem; color: #888;" > ${results.error}< / p >
`;
} else if (results.length === 0) {
document.getElementById('list-content').innerHTML = `
< p style = "padding: 1rem; color: #888;" > Keine Treffer im Landtag für "${query}"< / p >
`;
} else {
// Merge with checked status from DB
const checkedIds = new Set(allAssessments.map(a => a.drucksache));
const merged = results.map(r => ({
...r,
gwoeScore: checkedIds.has(r.drucksache)
? allAssessments.find(a => a.drucksache === r.drucksache)?.gwoeScore
: null,
status: checkedIds.has(r.drucksache) ? 'checked' : 'unchecked'
}));
renderList(merged);
}
} catch (e) {
document.getElementById('list-content').innerHTML = `
< p style = "padding: 1rem; color: #888;" > Suchfehler: ${e.message}< / p >
`;
}
btn.disabled = false;
btn.textContent = '🔍 Im Landtag';
}
function filterList(query) {
const q = query.toLowerCase();
let filtered = allAssessments.filter(a =>
(a.title || '').toLowerCase().includes(q) ||
(a.drucksache || '').toLowerCase().includes(q) ||
(a.fraktionen || []).join(' ').toLowerCase().includes(q) ||
(a.themen || []).join(' ').toLowerCase().includes(q)
);
// Score-Filter anwenden
if (currentScoreFilter !== 'all') {
filtered = applyScoreFilter(filtered, currentScoreFilter);
}
// Partei-Filter anwenden
if (currentParteiFilter) {
filtered = filtered.filter(a =>
(a.fraktionen || []).includes(currentParteiFilter)
);
}
renderList(filtered);
}
function setScoreFilter(filter, btn) {
currentScoreFilter = filter;
document.querySelectorAll('.filter-btn').forEach(b => b.classList.remove('active'));
btn.classList.add('active');
applyAllFilters();
}
function setParteiFilter(partei) {
currentParteiFilter = partei;
applyAllFilters();
}
function applyAllFilters() {
let filtered = allAssessments;
// Score-Filter
if (currentScoreFilter !== 'all') {
filtered = applyScoreFilter(filtered, currentScoreFilter);
}
// Partei-Filter
if (currentParteiFilter) {
filtered = filtered.filter(a =>
(a.fraktionen || []).includes(currentParteiFilter)
);
}
renderList(filtered);
}
function applyScoreFilter(items, filter) {
switch (filter) {
case 'high': return items.filter(a => a.gwoeScore >= 8);
case 'mid': return items.filter(a => a.gwoeScore >= 5 & & a.gwoeScore < 8 ) ;
case 'low': return items.filter(a => a.gwoeScore < 5 ) ;
default: return items;
}
}
function showDetail(drucksache) {
const item = allAssessments.find(a => a.drucksache === drucksache);
if (!item) return;
// Highlight active item
document.querySelectorAll('.list-item').forEach(el => el.classList.remove('active'));
// Find and highlight the list item by drucksache
const listItem = document.querySelector(`.list-item[data-drucksache="${drucksache}"]`);
if (listItem) listItem.classList.add('active');
// Skala 0-10
const scoreClass = item.gwoeScore >= 8 ? 'score-high' :
item.gwoeScore >= 5 ? 'score-mid' :
item.gwoeScore >= 3 ? 'score-low' : 'score-negative';
// Matrix als 5x5-Tabelle wie im PDF
const matrixData = {};
(item.gwoeMatrix || []).forEach(m => { matrixData[m.field] = m; });
const rowLabels = {
'A': 'Lieferant:innen',
'B': 'Finanzen',
'C': 'Führung/Verwaltung',
'D': 'Bürger:innen',
'E': 'Gesellschaft/Natur'
};
// Spaltenüberschriften der GWÖ-Matrix (5 Werte)
const colLabels = {
1: 'Menschen-würde',
2: 'Solidarität',
3: 'Ökol. Nachh.',
4: 'Soz. Gerecht.',
5: 'Transparenz'
};
const colFull = {
1: 'Menschenwürde',
2: 'Solidarität',
3: 'Ökologische Nachhaltigkeit',
4: 'Soziale Gerechtigkeit',
5: 'Transparenz & Mitbestimmung'
};
let matrixTableHtml = '< table class = "matrix-table" > < thead > < tr > < th > < / th > ';
for (let col = 1; col < = 5; col++) matrixTableHtml += `< th title = "${colFull[col]}" > ${colLabels[col]}< / th > `;
matrixTableHtml += '< / tr > < / thead > < tbody > ';
['A', 'B', 'C', 'D', 'E'].forEach(row => {
matrixTableHtml += `< tr > < th > ${row}: ${rowLabels[row]}< / th > `;
for (let col = 1; col < = 5; col++) {
const field = `${row}${col}`;
const entry = matrixData[field];
if (entry) {
const cssClass = entry.rating > 0 ? 'positive' : (entry.rating < 0 ? ' negative ' : ' neutral ' ) ;
matrixTableHtml += `< td class = "${cssClass}" title = "${entry.aspect || entry.label}" > ${entry.symbol}< / td > `;
} else {
matrixTableHtml += '< td > < / td > ';
}
}
matrixTableHtml += '< / tr > ';
});
matrixTableHtml += '< / tbody > < / table > ';
// Zusätzlich die Detail-Liste der bewerteten Felder
const matrixDetailHtml = (item.gwoeMatrix || []).map(m => `
< div class = "matrix-item" >
< span class = "matrix-label" > ${m.field}: ${m.label}< / span >
< span class = "matrix-rating ${m.rating > 0 ? 'rating-pos' : m.rating < 0 ? 'rating-neg' : 'rating-neutral'}" > ${m.symbol}< / span >
< / div >
`).join('');
const stärkenHtml = (item.stärken || []).map(s => `< li > ${s}< / li > `).join('');
const schwächenHtml = (item.schwächen || []).map(s => `< li > ${s}< / li > `).join('');
// Verbesserungsvorschläge formatieren
const verbesserungenHtml = (item.verbesserungen || []).map(v => {
// Redline-Format: **fett** = grün/neu, ~~durchgestrichen~~ = rot/gelöscht
let vorschlag = v.vorschlag || '';
vorschlag = vorschlag.replace(/\*\*([^*]+)\*\*/g, '< span style = "color: #889e33; font-weight: bold;" > $1< / span > ');
vorschlag = vorschlag.replace(/~~([^~]+)~~/g, '< span style = "color: #d00000; text-decoration: line-through;" > $1< / span > ');
return `
< div style = "margin: 0.75rem 0; padding: 0.75rem; border: 1px solid var(--color-lightgray); border-radius: 4px;" >
< div style = "background: #f5f5f5; padding: 0.5rem; margin-bottom: 0.5rem; font-size: 0.9rem;" >
< strong > Original:< / strong > < br > ${v.original || '-'}
< / div >
< div style = "background: rgba(136, 158, 51, 0.1); border-left: 3px solid #889e33; padding: 0.5rem; font-size: 0.9rem;" >
< strong > Vorschlag:< / strong > < br > ${vorschlag}
< / div >
< div style = "font-size: 0.85rem; color: #666; margin-top: 0.5rem; font-style: italic;" >
${v.begruendung || ''}
< / div >
< / div >
`}).join('');
2026-04-10 09:57:58 +02:00
// Issue #47: Zitat-URLs zu Cite-Endpoint umschreiben für gelbes
// Highlighting. Funktioniert retroaktiv für Pre-#47-Assessments
// (statische /static/referenzen/X.pdf#page=N) und nativ für
// Post-#47 (die schon /api/wahlprogramm-cite enthalten).
#47: Auto-Re-Analyse bei nicht-verifizierbaren Zitaten
Statt eine Nachricht "Textstelle nicht auffindbar" zu zeigen (was User
zurecht als Quatsch bezeichnet hat), erkennt der Cite-Endpoint jetzt
halluzinierte Zitate und triggert automatisch eine Re-Analyse:
Flow:
1. User klickt auf Zitat-Link
2. render_highlighted_page gibt (pdf, page, highlighted=False) zurück
3. Endpoint prüft: ds+bl Parameter vorhanden? Assessment in DB?
4. → Löscht altes Assessment, startet Re-Analyse als Background-Task
5. → Zeigt HTML-Warte-Seite mit Spinner und "Wird neu analysiert..."
6. → Auto-Redirect nach 15s zurück zum Assessment
Das neue Assessment hat durch reconstruct_zitate verifizierte Zitate,
die dann beim nächsten Klick korrekt gehighlighted werden.
Änderungen:
- embeddings.render_highlighted_page: Return-Typ (bytes, int, bool) —
drittes Element ist True wenn Highlight gesetzt wurde
- database.delete_assessment: neue Funktion für die Re-Analyse
- main.py cite-Endpoint: akzeptiert ds= und bl= als optionale Params,
triggert Re-Analyse bei highlighted=False + ds vorhanden
- Frontend: makeCiteUrl reicht ds+bl aus dem Assessment-Kontext mit
durch in die Cite-URL
- Cache-Control auf 1h reduziert (war 24h, zu aggressiv für
Assessments die sich durch Re-Analyse ändern)
Tests: 194/194 grün.
Refs: #47, #60
2026-04-10 10:35:01 +02:00
// makeCiteUrl baut die Highlight-URL und hängt ds+bl an,
// damit der Server bei nicht-auffindbaren Zitaten automatisch
// eine Re-Analyse triggern kann (#47 + #60).
function makeCiteUrl(z, ds, bl) {
2026-04-10 09:57:58 +02:00
if (!z || !z.url) return '#';
#47: Auto-Re-Analyse bei nicht-verifizierbaren Zitaten
Statt eine Nachricht "Textstelle nicht auffindbar" zu zeigen (was User
zurecht als Quatsch bezeichnet hat), erkennt der Cite-Endpoint jetzt
halluzinierte Zitate und triggert automatisch eine Re-Analyse:
Flow:
1. User klickt auf Zitat-Link
2. render_highlighted_page gibt (pdf, page, highlighted=False) zurück
3. Endpoint prüft: ds+bl Parameter vorhanden? Assessment in DB?
4. → Löscht altes Assessment, startet Re-Analyse als Background-Task
5. → Zeigt HTML-Warte-Seite mit Spinner und "Wird neu analysiert..."
6. → Auto-Redirect nach 15s zurück zum Assessment
Das neue Assessment hat durch reconstruct_zitate verifizierte Zitate,
die dann beim nächsten Klick korrekt gehighlighted werden.
Änderungen:
- embeddings.render_highlighted_page: Return-Typ (bytes, int, bool) —
drittes Element ist True wenn Highlight gesetzt wurde
- database.delete_assessment: neue Funktion für die Re-Analyse
- main.py cite-Endpoint: akzeptiert ds= und bl= als optionale Params,
triggert Re-Analyse bei highlighted=False + ds vorhanden
- Frontend: makeCiteUrl reicht ds+bl aus dem Assessment-Kontext mit
durch in die Cite-URL
- Cache-Control auf 1h reduziert (war 24h, zu aggressiv für
Assessments die sich durch Re-Analyse ändern)
Tests: 194/194 grün.
Refs: #47, #60
2026-04-10 10:35:01 +02:00
const extra = (ds & & bl) ? `& ds=${encodeURIComponent(ds)}& bl=${encodeURIComponent(bl)}` : '';
// Schon eine Cite-URL? ds/bl anhängen + #page=N.
2026-04-10 10:16:00 +02:00
if (z.url.includes('/api/wahlprogramm-cite')) {
const m = z.url.match(/seite=(\d+)/);
const page = m ? m[1] : '';
#47: Auto-Re-Analyse bei nicht-verifizierbaren Zitaten
Statt eine Nachricht "Textstelle nicht auffindbar" zu zeigen (was User
zurecht als Quatsch bezeichnet hat), erkennt der Cite-Endpoint jetzt
halluzinierte Zitate und triggert automatisch eine Re-Analyse:
Flow:
1. User klickt auf Zitat-Link
2. render_highlighted_page gibt (pdf, page, highlighted=False) zurück
3. Endpoint prüft: ds+bl Parameter vorhanden? Assessment in DB?
4. → Löscht altes Assessment, startet Re-Analyse als Background-Task
5. → Zeigt HTML-Warte-Seite mit Spinner und "Wird neu analysiert..."
6. → Auto-Redirect nach 15s zurück zum Assessment
Das neue Assessment hat durch reconstruct_zitate verifizierte Zitate,
die dann beim nächsten Klick korrekt gehighlighted werden.
Änderungen:
- embeddings.render_highlighted_page: Return-Typ (bytes, int, bool) —
drittes Element ist True wenn Highlight gesetzt wurde
- database.delete_assessment: neue Funktion für die Re-Analyse
- main.py cite-Endpoint: akzeptiert ds= und bl= als optionale Params,
triggert Re-Analyse bei highlighted=False + ds vorhanden
- Frontend: makeCiteUrl reicht ds+bl aus dem Assessment-Kontext mit
durch in die Cite-URL
- Cache-Control auf 1h reduziert (war 24h, zu aggressiv für
Assessments die sich durch Re-Analyse ändern)
Tests: 194/194 grün.
Refs: #47, #60
2026-04-10 10:35:01 +02:00
const base = z.url.split('#')[0];
return base + extra + (page ? '#page=' + page : '');
2026-04-10 10:16:00 +02:00
}
#47: Auto-Re-Analyse bei nicht-verifizierbaren Zitaten
Statt eine Nachricht "Textstelle nicht auffindbar" zu zeigen (was User
zurecht als Quatsch bezeichnet hat), erkennt der Cite-Endpoint jetzt
halluzinierte Zitate und triggert automatisch eine Re-Analyse:
Flow:
1. User klickt auf Zitat-Link
2. render_highlighted_page gibt (pdf, page, highlighted=False) zurück
3. Endpoint prüft: ds+bl Parameter vorhanden? Assessment in DB?
4. → Löscht altes Assessment, startet Re-Analyse als Background-Task
5. → Zeigt HTML-Warte-Seite mit Spinner und "Wird neu analysiert..."
6. → Auto-Redirect nach 15s zurück zum Assessment
Das neue Assessment hat durch reconstruct_zitate verifizierte Zitate,
die dann beim nächsten Klick korrekt gehighlighted werden.
Änderungen:
- embeddings.render_highlighted_page: Return-Typ (bytes, int, bool) —
drittes Element ist True wenn Highlight gesetzt wurde
- database.delete_assessment: neue Funktion für die Re-Analyse
- main.py cite-Endpoint: akzeptiert ds= und bl= als optionale Params,
triggert Re-Analyse bei highlighted=False + ds vorhanden
- Frontend: makeCiteUrl reicht ds+bl aus dem Assessment-Kontext mit
durch in die Cite-URL
- Cache-Control auf 1h reduziert (war 24h, zu aggressiv für
Assessments die sich durch Re-Analyse ändern)
Tests: 194/194 grün.
Refs: #47, #60
2026-04-10 10:35:01 +02:00
// Statische URL umschreiben
2026-04-10 09:57:58 +02:00
const m = z.url.match(/\/static\/referenzen\/(.+\.pdf)#page=(\d+)/);
if (m & & z.text) {
const pdf = m[1];
const page = m[2];
const q = encodeURIComponent((z.text || '').substring(0, 200));
#47: Auto-Re-Analyse bei nicht-verifizierbaren Zitaten
Statt eine Nachricht "Textstelle nicht auffindbar" zu zeigen (was User
zurecht als Quatsch bezeichnet hat), erkennt der Cite-Endpoint jetzt
halluzinierte Zitate und triggert automatisch eine Re-Analyse:
Flow:
1. User klickt auf Zitat-Link
2. render_highlighted_page gibt (pdf, page, highlighted=False) zurück
3. Endpoint prüft: ds+bl Parameter vorhanden? Assessment in DB?
4. → Löscht altes Assessment, startet Re-Analyse als Background-Task
5. → Zeigt HTML-Warte-Seite mit Spinner und "Wird neu analysiert..."
6. → Auto-Redirect nach 15s zurück zum Assessment
Das neue Assessment hat durch reconstruct_zitate verifizierte Zitate,
die dann beim nächsten Klick korrekt gehighlighted werden.
Änderungen:
- embeddings.render_highlighted_page: Return-Typ (bytes, int, bool) —
drittes Element ist True wenn Highlight gesetzt wurde
- database.delete_assessment: neue Funktion für die Re-Analyse
- main.py cite-Endpoint: akzeptiert ds= und bl= als optionale Params,
triggert Re-Analyse bei highlighted=False + ds vorhanden
- Frontend: makeCiteUrl reicht ds+bl aus dem Assessment-Kontext mit
durch in die Cite-URL
- Cache-Control auf 1h reduziert (war 24h, zu aggressiv für
Assessments die sich durch Re-Analyse ändern)
Tests: 194/194 grün.
Refs: #47, #60
2026-04-10 10:35:01 +02:00
return `/api/wahlprogramm-cite?pdf=${encodeURIComponent(pdf)}&seite=${page}&q=${q}${extra}#page=${page}`;
2026-04-10 09:57:58 +02:00
}
return z.url;
}
2026-03-28 22:30:24 +01:00
const wahlprogrammHtml = (item.wahlprogrammScores || []).map(wp => {
2026-04-10 09:57:58 +02:00
// Zitate formatieren mit klickbaren Links + Highlighting
2026-04-10 21:45:36 +02:00
const zitateHtml = (wp.wahlprogramm?.zitate || []).map(z => {
const isVerified = z.verified !== false;
const borderColor = isVerified ? '#889e33' : '#ffc107';
const bgColor = isVerified ? '#f8f9fa' : '#fffbf0';
const badge = isVerified
? '< span style = "font-size:0.7rem;color:#889e33;" > ✓ verifiziert< / span > '
: '< span style = "font-size:0.7rem;color:#b8860b;" > ~ paraphrasiert (nicht wörtlich im Programm)< / span > ';
return `
< div style = "margin: 0.5rem 0; padding: 0.5rem; background: ${bgColor}; border-left: 3px ${isVerified ? 'solid' : 'dashed'} ${borderColor}; font-size: 0.85rem;" >
2026-03-28 22:30:24 +01:00
< em > "${z.text}"< / em > < br >
2026-04-10 21:45:36 +02:00
${z.quelle ? `< a href = "${makeCiteUrl(z, item.drucksache, item.bundesland)}" target = "_blank" style = "color: #009da5; font-size: 0.8rem;" >
2026-03-28 22:30:24 +01:00
📄 ${z.quelle}
2026-04-10 21:45:36 +02:00
< / a > ` : ''}
${badge}
< / div > `;
}).join('');
#63 B+C: Force-Honesty + UI-Warning bei Score ohne Zitate
Problem: BUND 21/3660 zeigt Score 10/10 für Linke und Grüne, aber null
Zitate — der Report sieht aus als sei die Bewertung fundiert, obwohl das
LLM mangels indexierter Quellen (linke-grundsatz fehlt) aus
Trainingswissen geraten hat. User-Feedback: "Da muss stehen warum."
Fix C — Force-Honesty im Prompt:
- format_quotes_for_prompt akzeptiert neuen Parameter searched_parties.
Parteien, für die kein Chunk retrievt wurde, werden explizit als
"KEINE QUELLEN VORHANDEN" markiert, mit der Anweisung "score: 0,
zitate: [], Begründung: keine Quellen im Index".
- Neue ZITATEREGEL Punkt 5: "Wenn KEINE QUELLEN VORHANDEN → score 0."
Das ist die strukturelle Lösung — das LLM darf nicht mehr raten.
- analyzer.py: fraktionen-Liste wird an format_quotes_for_prompt als
searched_parties durchgereicht.
Fix B — UI-Transparenz:
- index.html: gelbe Warn-Box (amber, border-left #ffc107) wenn
wp.wahlprogramm.score > 0 aber wp.wahlprogramm.zitate.length === 0:
"Keine belegbaren Quellen im Index gefunden — Score basiert auf
LLM-Einschätzung, nicht auf verifizierten Programm-Stellen."
- Wird für bestehende Assessments sofort sichtbar (JS-seitig berechnet),
keine DB-Migration nötig. Neue Assessments nach Force-Honesty sollten
idealerweise Score=0 haben, aber die Warning ist ein Fallback für
den Fall dass das LLM die Prompt-Regel nicht immer 100% befolgt.
Fix A (Linke/AfD-Grundsatzprogramme) folgt als separater Commit —
sind öffentlich downloadbar, brauchen manuellen Sichtbarkeitscheck.
Tests: 194/194 grün (keine Schema-Änderung, nur Prompt + Template).
Refs: #63, ADR 0001
2026-04-10 09:32:31 +02:00
// Issue #63: Transparenz-Warnung bei Score > 0 ohne Zitate.
// Differenziert zwischen "Score 0 = keine Quellen" (LLM hat
// Force-Honesty befolgt) und "Score > 0 aber 0 Zitate" (LLM
// hat trotzdem geratet oder Zitate wurden von reconstruct_zitate
// verworfen). Nur bei Scores > 0 warnen, weil Score 0 schon
// selbsterklärend ist.
const wpScore = wp.wahlprogramm?.score ?? 0;
const wpZitateCount = (wp.wahlprogramm?.zitate || []).length;
const noQuotesWarning = (wpScore > 0 & & wpZitateCount === 0) ? `
< div style = "margin: 0.5rem 0; padding: 0.5rem; background: #fff3cd; border-left: 3px solid #ffc107; font-size: 0.8rem; color: #856404;" >
2026-04-10 21:41:15 +02:00
⚠ Zu diesem Themenkomplex konnten keine konkreten Formulierungen im Wahlprogramm gefunden werden — Score basiert auf der allgemeinen Programmatik der Partei.
#63 B+C: Force-Honesty + UI-Warning bei Score ohne Zitate
Problem: BUND 21/3660 zeigt Score 10/10 für Linke und Grüne, aber null
Zitate — der Report sieht aus als sei die Bewertung fundiert, obwohl das
LLM mangels indexierter Quellen (linke-grundsatz fehlt) aus
Trainingswissen geraten hat. User-Feedback: "Da muss stehen warum."
Fix C — Force-Honesty im Prompt:
- format_quotes_for_prompt akzeptiert neuen Parameter searched_parties.
Parteien, für die kein Chunk retrievt wurde, werden explizit als
"KEINE QUELLEN VORHANDEN" markiert, mit der Anweisung "score: 0,
zitate: [], Begründung: keine Quellen im Index".
- Neue ZITATEREGEL Punkt 5: "Wenn KEINE QUELLEN VORHANDEN → score 0."
Das ist die strukturelle Lösung — das LLM darf nicht mehr raten.
- analyzer.py: fraktionen-Liste wird an format_quotes_for_prompt als
searched_parties durchgereicht.
Fix B — UI-Transparenz:
- index.html: gelbe Warn-Box (amber, border-left #ffc107) wenn
wp.wahlprogramm.score > 0 aber wp.wahlprogramm.zitate.length === 0:
"Keine belegbaren Quellen im Index gefunden — Score basiert auf
LLM-Einschätzung, nicht auf verifizierten Programm-Stellen."
- Wird für bestehende Assessments sofort sichtbar (JS-seitig berechnet),
keine DB-Migration nötig. Neue Assessments nach Force-Honesty sollten
idealerweise Score=0 haben, aber die Warning ist ein Fallback für
den Fall dass das LLM die Prompt-Regel nicht immer 100% befolgt.
Fix A (Linke/AfD-Grundsatzprogramme) folgt als separater Commit —
sind öffentlich downloadbar, brauchen manuellen Sichtbarkeitscheck.
Tests: 194/194 grün (keine Schema-Änderung, nur Prompt + Template).
Refs: #63, ADR 0001
2026-04-10 09:32:31 +02:00
< / div >
` : '';
2026-04-10 22:13:30 +02:00
// Labels: Antragsteller:in (aus item.fraktionen) und Landesregierung
// istAntragsteller/istRegierung aus dem LLM ist oft null — ableiten.
const isAntragsteller = (item.fraktionen || []).some(f => normalizePartei(f) === normalizePartei(wp.fraktion));
2026-04-10 21:41:15 +02:00
const roleLabels = [];
2026-04-10 22:13:30 +02:00
if (wp.istAntragsteller || isAntragsteller) roleLabels.push('< span style = "background:#889e33;color:white;padding:0.1rem 0.4rem;border-radius:3px;font-size:0.75rem;" > Antragsteller:in< / span > ');
2026-04-10 21:41:15 +02:00
if (wp.istRegierung) roleLabels.push('< span style = "background:#009da5;color:white;padding:0.1rem 0.4rem;border-radius:3px;font-size:0.75rem;" > Landesregierung< / span > ');
2026-03-28 22:30:24 +01:00
return `
< div style = "margin: 0.75rem 0; padding: 0.75rem; background: var(--color-bg); border-radius: 4px;" >
2026-04-10 21:41:15 +02:00
< strong > ${wp.fraktion}< / strong > ${roleLabels.join(' ')}< br >
2026-03-28 22:30:24 +01:00
< div style = "margin: 0.5rem 0;" >
#63 B+C: Force-Honesty + UI-Warning bei Score ohne Zitate
Problem: BUND 21/3660 zeigt Score 10/10 für Linke und Grüne, aber null
Zitate — der Report sieht aus als sei die Bewertung fundiert, obwohl das
LLM mangels indexierter Quellen (linke-grundsatz fehlt) aus
Trainingswissen geraten hat. User-Feedback: "Da muss stehen warum."
Fix C — Force-Honesty im Prompt:
- format_quotes_for_prompt akzeptiert neuen Parameter searched_parties.
Parteien, für die kein Chunk retrievt wurde, werden explizit als
"KEINE QUELLEN VORHANDEN" markiert, mit der Anweisung "score: 0,
zitate: [], Begründung: keine Quellen im Index".
- Neue ZITATEREGEL Punkt 5: "Wenn KEINE QUELLEN VORHANDEN → score 0."
Das ist die strukturelle Lösung — das LLM darf nicht mehr raten.
- analyzer.py: fraktionen-Liste wird an format_quotes_for_prompt als
searched_parties durchgereicht.
Fix B — UI-Transparenz:
- index.html: gelbe Warn-Box (amber, border-left #ffc107) wenn
wp.wahlprogramm.score > 0 aber wp.wahlprogramm.zitate.length === 0:
"Keine belegbaren Quellen im Index gefunden — Score basiert auf
LLM-Einschätzung, nicht auf verifizierten Programm-Stellen."
- Wird für bestehende Assessments sofort sichtbar (JS-seitig berechnet),
keine DB-Migration nötig. Neue Assessments nach Force-Honesty sollten
idealerweise Score=0 haben, aber die Warning ist ein Fallback für
den Fall dass das LLM die Prompt-Regel nicht immer 100% befolgt.
Fix A (Linke/AfD-Grundsatzprogramme) folgt als separater Commit —
sind öffentlich downloadbar, brauchen manuellen Sichtbarkeitscheck.
Tests: 194/194 grün (keine Schema-Änderung, nur Prompt + Template).
Refs: #63, ADR 0001
2026-04-10 09:32:31 +02:00
Wahlprogramm: < strong > ${wp.wahlprogramm?.score || '-'}/10< / strong > ·
2026-03-28 22:30:24 +01:00
Parteiprogramm: < strong > ${wp.parteiprogramm?.score || '-'}/10< / strong >
< / div >
${wp.wahlprogramm?.begründung ? `< div style = "font-size: 0.9rem; color: #555; margin-bottom: 0.5rem;" > ${wp.wahlprogramm.begründung}< / div > ` : ''}
#63 B+C: Force-Honesty + UI-Warning bei Score ohne Zitate
Problem: BUND 21/3660 zeigt Score 10/10 für Linke und Grüne, aber null
Zitate — der Report sieht aus als sei die Bewertung fundiert, obwohl das
LLM mangels indexierter Quellen (linke-grundsatz fehlt) aus
Trainingswissen geraten hat. User-Feedback: "Da muss stehen warum."
Fix C — Force-Honesty im Prompt:
- format_quotes_for_prompt akzeptiert neuen Parameter searched_parties.
Parteien, für die kein Chunk retrievt wurde, werden explizit als
"KEINE QUELLEN VORHANDEN" markiert, mit der Anweisung "score: 0,
zitate: [], Begründung: keine Quellen im Index".
- Neue ZITATEREGEL Punkt 5: "Wenn KEINE QUELLEN VORHANDEN → score 0."
Das ist die strukturelle Lösung — das LLM darf nicht mehr raten.
- analyzer.py: fraktionen-Liste wird an format_quotes_for_prompt als
searched_parties durchgereicht.
Fix B — UI-Transparenz:
- index.html: gelbe Warn-Box (amber, border-left #ffc107) wenn
wp.wahlprogramm.score > 0 aber wp.wahlprogramm.zitate.length === 0:
"Keine belegbaren Quellen im Index gefunden — Score basiert auf
LLM-Einschätzung, nicht auf verifizierten Programm-Stellen."
- Wird für bestehende Assessments sofort sichtbar (JS-seitig berechnet),
keine DB-Migration nötig. Neue Assessments nach Force-Honesty sollten
idealerweise Score=0 haben, aber die Warning ist ein Fallback für
den Fall dass das LLM die Prompt-Regel nicht immer 100% befolgt.
Fix A (Linke/AfD-Grundsatzprogramme) folgt als separater Commit —
sind öffentlich downloadbar, brauchen manuellen Sichtbarkeitscheck.
Tests: 194/194 grün (keine Schema-Änderung, nur Prompt + Template).
Refs: #63, ADR 0001
2026-04-10 09:32:31 +02:00
${noQuotesWarning}
2026-03-28 22:30:24 +01:00
${zitateHtml}
< / div >
`}).join('');
document.getElementById('detail-panel').innerHTML = `
< div class = "detail-card" >
2026-04-07 13:48:55 +02:00
< button class = "btn-back-mobile" onclick = "document.querySelector('.list-panel').scrollIntoView({behavior:'smooth', block:'start'})" > ← Zur Liste< / button >
2026-03-28 22:30:24 +01:00
< div class = "detail-header" >
< div >
< div class = "detail-title" > ${item.title || 'Ohne Titel'}< / div >
Bundesland filter & transparency: stringent split + visible source (#8)
Brings the Bundesland-Dropdown from a cosmetic header widget to a real
filter that propagates through every layer (Listing, internal search,
statistics, party/tag filters, upload mode), and at the same time makes
the source parliament visible in every place where assessments from
multiple bundesländer can be mixed.
Backend
- database.get_all_assessments(bundesland=None) — new optional filter,
"ALL" treated as None.
- database.search_assessments — bug fix: previous `if bundesland:`
branch incorrectly added a `WHERE bundesland='ALL'` clause; now
guarded with `bundesland and bundesland != "ALL"`.
- main.list_assessments — accepts ?bundesland= query param, includes the
bundesland field in the response so the frontend can render badges.
- main.get_single_assessment — also includes bundesland in the response
so the detail header can show the source parlament.
- main.search_landtag — early HTTP 400 when bundesland is missing or
"ALL"; the live Landtag adapter cannot serve a synthetic Bundesweit
request.
- main.index() and main.list_bundeslaender — synthetic "🌍 Bundesweit"
entry prepended to the bundesländer list (kept out of bundeslaender.py
on purpose — ALL is not a real state). Both endpoints additionally
expose a parlament_names map so the frontend can render the source
parliament without an extra round-trip.
Report (PDF + HTML)
- generate_html_report / generate_pdf_report — new optional bundesland
parameter. When set, the report header carries the parliament name
("Landtag von Sachsen-Anhalt", "Landtag Nordrhein-Westfalen", …)
beside the title. Three call sites updated: run_analysis,
run_drucksache_analysis, download_assessment_pdf.
Frontend (templates/index.html)
- Header dropdown gets the synthetic ALL entry as first option;
initial currentBundesland is now 'ALL' (was 'NRW').
- localStorage persistence: changeBundesland writes, DOMContentLoaded
reads and validates against the visible options.
- changeBundesland resets the score / party / tag filter state, syncs
the upload-mode bundesland select, disables the Landtag-Suche button
+ tooltip when ALL, and toggles a data-mode attribute on
.list-content (used by CSS to show/hide the per-item bundesland
badge).
- loadAssessments now sends ?bundesland=… so the API does the actual
filtering. updateStats renders an additional per-bundesland average
block (Ø NRW: x · Ø LSA: y) when in ALL mode and the loaded list
spans more than one bundesland.
- renderList prepends a small "bl-badge" beside the Drucksachen-Nummer.
Hidden in single-bundesland mode via CSS selector to avoid clutter.
- showDetail header now shows the parliament name as its own line
(.detail-parlament).
- searchLandtag has an early-out alert if currentBundesland === 'ALL',
saving a network round-trip.
- Upload-Mode bundesland select now starts with a "— Bundesland wählen
—" placeholder (no auto-default), and startAnalysis validates that a
concrete bundesland was chosen.
CSS
- .bl-badge plus the .list-content[data-mode="single"] hide rule.
- .detail-parlament for the detail header line.
- .header-parlament for the PDF report header line.
Resolves #8.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-07 23:00:39 +02:00
${item.bundesland & & PARLAMENT_NAMES[item.bundesland] ? `< div class = "detail-parlament" > ${PARLAMENT_NAMES[item.bundesland]}< / div > ` : ''}
2026-04-10 22:13:30 +02:00
< div class = "detail-id" > ${item.drucksache} · ${(item.fraktionen || []).join(', ')} · ${item.datum || ''}${item.updatedAt ? ` · Bewertet ${new Date(item.updatedAt).toLocaleDateString('de-DE')}` : ''}< / div >
2026-03-28 22:30:24 +01:00
< / div >
< div class = "score-display" >
< div class = "score-big ${scoreClass}" > ${item.gwoeScore}< / div >
< div class = "score-label" > GWÖ-Score< / div >
< / div >
< / div >
${item.themen & & item.themen.length > 0 ? `
< div class = "themen-tags" style = "margin-bottom: 1rem;" >
${item.themen.map(t => `< span class = "tag" > ${t}< / span > `).join(' ')}
< / div >
` : ''}
< h3 class = "section-title" > Zusammenfassung< / h3 >
< div class = "text-block" > ${item.antragZusammenfassung || '-'}< / div >
${item.antragKernpunkte & & item.antragKernpunkte.length > 0 ? `
< h3 class = "section-title" > Kernpunkte< / h3 >
< ul class = "kernpunkte-list" > ${item.antragKernpunkte.map(k => `< li > ${k}< / li > `).join('')}< / ul >
` : ''}
< h3 class = "section-title" > GWÖ-Begründung< / h3 >
< div class = "text-block" > ${item.gwoeBegründung || '-'}< / div >
${item.gwoeSchwerpunkt ? `
< h3 class = "section-title" > GWÖ-Schwerpunkt< / h3 >
< div class = "text-block" > ${item.gwoeSchwerpunkt}< / div >
` : ''}
${item.gwoeMatrix & & item.gwoeMatrix.length > 0 ? `
< h3 class = "section-title" > GWÖ-Matrix< / h3 >
${matrixTableHtml}
< div class = "matrix-grid" style = "margin-top: 0.75rem;" > ${matrixDetailHtml}< / div >
` : ''}
${wahlprogrammHtml ? `
< h3 class = "section-title" > Programmtreue< / h3 >
${wahlprogrammHtml}
` : ''}
${stärkenHtml ? `
< h3 class = "section-title" > Stärken< / h3 >
< ul class = "strength-list" > ${stärkenHtml}< / ul >
` : ''}
${schwächenHtml ? `
< h3 class = "section-title" > Schwächen< / h3 >
< ul class = "weakness-list" > ${schwächenHtml}< / ul >
` : ''}
${verbesserungenHtml ? `
< h3 class = "section-title" > Verbesserungsvorschläge< / h3 >
${verbesserungenHtml}
` : ''}
${item.verbesserungspotenzial ? `
< h3 class = "section-title" > Verbesserungspotenzial< / h3 >
< div class = "text-block" > ${item.verbesserungspotenzial}< / div >
` : ''}
< h3 class = "section-title" > Empfehlung< / h3 >
< div class = "text-block" >
< strong > ${item.empfehlungSymbol || ''} ${item.empfehlung || '-'}< / strong >
< / div >
2026-04-10 21:10:33 +02:00
< div style = "display: flex; gap: 0.5rem; flex-wrap: wrap; margin-top: 1rem;" >
< a href = "${item.link}" target = "_blank" class = "btn-pdf" > 📄 Original-PDF< / a >
< a href = "/api/assessment/pdf?drucksache=${encodeURIComponent(item.drucksache)}" class = "btn-pdf" style = "background: var(--color-green);" > 📥 GWÖ-Report< / a >
< button class = "btn-pdf" style = "background: #6c757d; border: none; cursor: pointer;"
${currentUser ? '' : 'disabled title="Nur nach Anmeldung verfügbar" style="background:#6c757d;opacity:0.5;cursor:not-allowed;"'}
onclick="reAnalyze('${item.drucksache}', '${item.bundesland}', this)">
🔄 Neu bewerten
< / button >
< / div >
< div style = "margin-top: 0.75rem; font-size: 0.8rem; color: #999; border-top: 1px solid #eee; padding-top: 0.5rem;" >
2026-04-10 21:24:07 +02:00
Bewertet am ${item.updatedAt ? new Date(item.updatedAt).toLocaleDateString('de-DE') + ', ' + new Date(item.updatedAt).toLocaleTimeString('de-DE', {hour:'2-digit', minute:'2-digit'}) + ' Uhr' : '– '}
2026-04-10 21:10:33 +02:00
${item.source ? ` · Quelle: ${item.source}` : ''}
${item.model ? ` · Modell: ${item.model}` : ''}
< / div >
#94 Bookmarks + Kommentare: DB-Schema, API, UI
DB (database.py):
- bookmarks-Tabelle (user_id + drucksache, toggle)
- comments-Tabelle (user_id, user_name, drucksache, text, visibility)
- Functions: toggle_bookmark, get_bookmarks, add_comment, get_comments, delete_comment
API (main.py):
- POST /api/bookmark (toggle, Auth-pflichtig)
- GET /api/bookmarks (User-Bookmarks)
- POST /api/comment (Auth-pflichtig, max 2000 Zeichen)
- GET /api/comments?drucksache= (öffentlich)
- DELETE /api/comment/{id} (nur eigene, Auth-pflichtig)
UI (index.html):
- Bookmark-Button ("🔖 Merken" / "⭐ Gemerkt") im Detail-Footer
- Kommentar-Bereich: Liste + Eingabefeld + Senden-Button
- Kommentare laden automatisch beim Detail-Öffnen
- Eigene Kommentare löschbar (✕ Button)
- Ohne Login: "Anmelden um zu kommentieren"
Gruppen-Sichtbarkeit (visibility) ist vorbereitet aber noch nicht
im UI exponiert — kommt als separater Schritt wenn Keycloak-Gruppen
konfiguriert sind.
Tests: 206 passed.
Refs: #94
2026-04-10 22:19:46 +02:00
<!-- Bookmark + Kommentare (#94) -->
< div style = "margin-top: 1rem; border-top: 2px solid var(--color-lightgray); padding-top: 1rem;" >
< div style = "display: flex; align-items: center; gap: 1rem; margin-bottom: 0.75rem;" >
< button id = "bookmark-btn-${item.drucksache.replace('/','-')}"
style="background:none;border:1px solid #ddd;border-radius:4px;padding:0.3rem 0.8rem;cursor:pointer;font-size:0.9rem;"
onclick="toggleBookmark('${item.drucksache}', this)"
${currentUser ? '' : 'disabled title="Nur nach Anmeldung"'}>
🔖 Merken
< / button >
< span style = "font-size: 0.85rem; color: #888;" > Kommentare:< / span >
< / div >
< div id = "comments-${item.drucksache.replace('/','-')}" style = "margin-bottom: 0.75rem;" >
< span style = "color:#aaa;font-size:0.85rem;" > Lade Kommentare...< / span >
< / div >
${currentUser ? `
2026-04-10 22:40:27 +02:00
< div style = "display:flex;gap:0.5rem;flex-wrap:wrap;align-items:center;" >
#94 Bookmarks + Kommentare: DB-Schema, API, UI
DB (database.py):
- bookmarks-Tabelle (user_id + drucksache, toggle)
- comments-Tabelle (user_id, user_name, drucksache, text, visibility)
- Functions: toggle_bookmark, get_bookmarks, add_comment, get_comments, delete_comment
API (main.py):
- POST /api/bookmark (toggle, Auth-pflichtig)
- GET /api/bookmarks (User-Bookmarks)
- POST /api/comment (Auth-pflichtig, max 2000 Zeichen)
- GET /api/comments?drucksache= (öffentlich)
- DELETE /api/comment/{id} (nur eigene, Auth-pflichtig)
UI (index.html):
- Bookmark-Button ("🔖 Merken" / "⭐ Gemerkt") im Detail-Footer
- Kommentar-Bereich: Liste + Eingabefeld + Senden-Button
- Kommentare laden automatisch beim Detail-Öffnen
- Eigene Kommentare löschbar (✕ Button)
- Ohne Login: "Anmelden um zu kommentieren"
Gruppen-Sichtbarkeit (visibility) ist vorbereitet aber noch nicht
im UI exponiert — kommt als separater Schritt wenn Keycloak-Gruppen
konfiguriert sind.
Tests: 206 passed.
Refs: #94
2026-04-10 22:19:46 +02:00
< input id = "comment-input-${item.drucksache.replace('/','-')}" type = "text"
placeholder="Kommentar schreiben..."
2026-04-10 22:40:27 +02:00
style="flex:1;min-width:150px;padding:0.4rem;border:1px solid #ddd;border-radius:4px;font-size:0.85rem;"
#94 Bookmarks + Kommentare: DB-Schema, API, UI
DB (database.py):
- bookmarks-Tabelle (user_id + drucksache, toggle)
- comments-Tabelle (user_id, user_name, drucksache, text, visibility)
- Functions: toggle_bookmark, get_bookmarks, add_comment, get_comments, delete_comment
API (main.py):
- POST /api/bookmark (toggle, Auth-pflichtig)
- GET /api/bookmarks (User-Bookmarks)
- POST /api/comment (Auth-pflichtig, max 2000 Zeichen)
- GET /api/comments?drucksache= (öffentlich)
- DELETE /api/comment/{id} (nur eigene, Auth-pflichtig)
UI (index.html):
- Bookmark-Button ("🔖 Merken" / "⭐ Gemerkt") im Detail-Footer
- Kommentar-Bereich: Liste + Eingabefeld + Senden-Button
- Kommentare laden automatisch beim Detail-Öffnen
- Eigene Kommentare löschbar (✕ Button)
- Ohne Login: "Anmelden um zu kommentieren"
Gruppen-Sichtbarkeit (visibility) ist vorbereitet aber noch nicht
im UI exponiert — kommt als separater Schritt wenn Keycloak-Gruppen
konfiguriert sind.
Tests: 206 passed.
Refs: #94
2026-04-10 22:19:46 +02:00
onkeydown="if(event.key==='Enter')addCommentUI('${item.drucksache}')">
2026-04-10 22:40:27 +02:00
< select id = "comment-visibility-${item.drucksache.replace('/','-')}"
style="padding:0.4rem;border:1px solid #ddd;border-radius:4px;font-size:0.8rem;color:#666;">
< option value = "all" > 🌐 Öffentlich< / option >
< option value = "authenticated" > 🔒 Angemeldete< / option >
< option value = "private" > 👤 Nur ich< / option >
< / select >
#94 Bookmarks + Kommentare: DB-Schema, API, UI
DB (database.py):
- bookmarks-Tabelle (user_id + drucksache, toggle)
- comments-Tabelle (user_id, user_name, drucksache, text, visibility)
- Functions: toggle_bookmark, get_bookmarks, add_comment, get_comments, delete_comment
API (main.py):
- POST /api/bookmark (toggle, Auth-pflichtig)
- GET /api/bookmarks (User-Bookmarks)
- POST /api/comment (Auth-pflichtig, max 2000 Zeichen)
- GET /api/comments?drucksache= (öffentlich)
- DELETE /api/comment/{id} (nur eigene, Auth-pflichtig)
UI (index.html):
- Bookmark-Button ("🔖 Merken" / "⭐ Gemerkt") im Detail-Footer
- Kommentar-Bereich: Liste + Eingabefeld + Senden-Button
- Kommentare laden automatisch beim Detail-Öffnen
- Eigene Kommentare löschbar (✕ Button)
- Ohne Login: "Anmelden um zu kommentieren"
Gruppen-Sichtbarkeit (visibility) ist vorbereitet aber noch nicht
im UI exponiert — kommt als separater Schritt wenn Keycloak-Gruppen
konfiguriert sind.
Tests: 206 passed.
Refs: #94
2026-04-10 22:19:46 +02:00
< button onclick = "addCommentUI('${item.drucksache}')"
style="padding:0.4rem 0.8rem;background:var(--color-blue);color:white;border:none;border-radius:4px;cursor:pointer;font-size:0.85rem;">
Senden
< / button >
< / div >
` : '< span style = "font-size:0.8rem;color:#aaa;" > Anmelden um zu kommentieren< / span > '}
< / div >
2026-03-28 22:30:24 +01:00
< / div >
`;
2026-04-07 13:48:55 +02:00
#94 Bookmarks + Kommentare: DB-Schema, API, UI
DB (database.py):
- bookmarks-Tabelle (user_id + drucksache, toggle)
- comments-Tabelle (user_id, user_name, drucksache, text, visibility)
- Functions: toggle_bookmark, get_bookmarks, add_comment, get_comments, delete_comment
API (main.py):
- POST /api/bookmark (toggle, Auth-pflichtig)
- GET /api/bookmarks (User-Bookmarks)
- POST /api/comment (Auth-pflichtig, max 2000 Zeichen)
- GET /api/comments?drucksache= (öffentlich)
- DELETE /api/comment/{id} (nur eigene, Auth-pflichtig)
UI (index.html):
- Bookmark-Button ("🔖 Merken" / "⭐ Gemerkt") im Detail-Footer
- Kommentar-Bereich: Liste + Eingabefeld + Senden-Button
- Kommentare laden automatisch beim Detail-Öffnen
- Eigene Kommentare löschbar (✕ Button)
- Ohne Login: "Anmelden um zu kommentieren"
Gruppen-Sichtbarkeit (visibility) ist vorbereitet aber noch nicht
im UI exponiert — kommt als separater Schritt wenn Keycloak-Gruppen
konfiguriert sind.
Tests: 206 passed.
Refs: #94
2026-04-10 22:19:46 +02:00
// Kommentare + Bookmark-Status laden
loadComments('${item.drucksache}');
loadBookmarkState('${item.drucksache}');
2026-04-07 13:48:55 +02:00
// Auf Mobile: zum Detail-Panel scrollen, damit der gewählte Antrag sichtbar wird
if (window.matchMedia('(max-width: 900px)').matches) {
document.getElementById('detail-panel').scrollIntoView({ behavior: 'smooth', block: 'start' });
}
2026-03-28 22:30:24 +01:00
}
// Mode Toggle
function showMode(mode) {
document.querySelectorAll('.mode-btn').forEach(b => b.classList.remove('active'));
if (event & & event.currentTarget) {
event.currentTarget.classList.add('active');
} else {
// Find and activate the right button
document.querySelectorAll('.mode-btn').forEach(b => {
if (b.textContent.includes(mode === 'browse' ? 'Durchsuchen' : mode === 'tags' ? 'Tags' : 'Prüfen')) {
b.classList.add('active');
}
});
}
document.getElementById('browse-mode').style.display = mode === 'browse' ? 'flex' : 'none';
2026-04-10 22:24:43 +02:00
document.getElementById('bookmarks-mode').style.display = mode === 'bookmarks' ? 'flex' : 'none';
2026-03-28 22:30:24 +01:00
document.getElementById('tags-mode').style.display = mode === 'tags' ? 'flex' : 'none';
document.getElementById('upload-mode').style.display = mode === 'upload' ? 'flex' : 'none';
2026-04-10 22:24:43 +02:00
if (mode === 'bookmarks') loadBookmarksList();
2026-03-28 22:30:24 +01:00
}
// Upload Tab Toggle
function showTab(tab) {
document.querySelectorAll('.tab-btn').forEach(b => b.classList.remove('active'));
event.currentTarget.classList.add('active');
document.getElementById('tab-text').style.display = tab === 'text' ? 'block' : 'none';
document.getElementById('tab-file').style.display = tab === 'file' ? 'block' : 'none';
}
function handleFile(input) {
if (input.files[0]) {
document.getElementById('file-name').textContent = input.files[0].name;
}
}
async function startAnalysis() {
const btn = document.getElementById('analyze-btn');
const statusDiv = document.getElementById('analysis-status');
const resultDiv = document.getElementById('analysis-result');
const text = document.getElementById('antrag-text').value;
const file = document.getElementById('file-input').files[0];
const bundesland = document.getElementById('bundesland').value;
Bundesland filter & transparency: stringent split + visible source (#8)
Brings the Bundesland-Dropdown from a cosmetic header widget to a real
filter that propagates through every layer (Listing, internal search,
statistics, party/tag filters, upload mode), and at the same time makes
the source parliament visible in every place where assessments from
multiple bundesländer can be mixed.
Backend
- database.get_all_assessments(bundesland=None) — new optional filter,
"ALL" treated as None.
- database.search_assessments — bug fix: previous `if bundesland:`
branch incorrectly added a `WHERE bundesland='ALL'` clause; now
guarded with `bundesland and bundesland != "ALL"`.
- main.list_assessments — accepts ?bundesland= query param, includes the
bundesland field in the response so the frontend can render badges.
- main.get_single_assessment — also includes bundesland in the response
so the detail header can show the source parlament.
- main.search_landtag — early HTTP 400 when bundesland is missing or
"ALL"; the live Landtag adapter cannot serve a synthetic Bundesweit
request.
- main.index() and main.list_bundeslaender — synthetic "🌍 Bundesweit"
entry prepended to the bundesländer list (kept out of bundeslaender.py
on purpose — ALL is not a real state). Both endpoints additionally
expose a parlament_names map so the frontend can render the source
parliament without an extra round-trip.
Report (PDF + HTML)
- generate_html_report / generate_pdf_report — new optional bundesland
parameter. When set, the report header carries the parliament name
("Landtag von Sachsen-Anhalt", "Landtag Nordrhein-Westfalen", …)
beside the title. Three call sites updated: run_analysis,
run_drucksache_analysis, download_assessment_pdf.
Frontend (templates/index.html)
- Header dropdown gets the synthetic ALL entry as first option;
initial currentBundesland is now 'ALL' (was 'NRW').
- localStorage persistence: changeBundesland writes, DOMContentLoaded
reads and validates against the visible options.
- changeBundesland resets the score / party / tag filter state, syncs
the upload-mode bundesland select, disables the Landtag-Suche button
+ tooltip when ALL, and toggles a data-mode attribute on
.list-content (used by CSS to show/hide the per-item bundesland
badge).
- loadAssessments now sends ?bundesland=… so the API does the actual
filtering. updateStats renders an additional per-bundesland average
block (Ø NRW: x · Ø LSA: y) when in ALL mode and the loaded list
spans more than one bundesland.
- renderList prepends a small "bl-badge" beside the Drucksachen-Nummer.
Hidden in single-bundesland mode via CSS selector to avoid clutter.
- showDetail header now shows the parliament name as its own line
(.detail-parlament).
- searchLandtag has an early-out alert if currentBundesland === 'ALL',
saving a network round-trip.
- Upload-Mode bundesland select now starts with a "— Bundesland wählen
—" placeholder (no auto-default), and startAnalysis validates that a
concrete bundesland was chosen.
CSS
- .bl-badge plus the .list-content[data-mode="single"] hide rule.
- .detail-parlament for the detail header line.
- .header-parlament for the PDF report header line.
Resolves #8.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-07 23:00:39 +02:00
2026-03-28 22:30:24 +01:00
if (!text & & !file) {
alert('Bitte Text eingeben oder PDF hochladen');
return;
}
Bundesland filter & transparency: stringent split + visible source (#8)
Brings the Bundesland-Dropdown from a cosmetic header widget to a real
filter that propagates through every layer (Listing, internal search,
statistics, party/tag filters, upload mode), and at the same time makes
the source parliament visible in every place where assessments from
multiple bundesländer can be mixed.
Backend
- database.get_all_assessments(bundesland=None) — new optional filter,
"ALL" treated as None.
- database.search_assessments — bug fix: previous `if bundesland:`
branch incorrectly added a `WHERE bundesland='ALL'` clause; now
guarded with `bundesland and bundesland != "ALL"`.
- main.list_assessments — accepts ?bundesland= query param, includes the
bundesland field in the response so the frontend can render badges.
- main.get_single_assessment — also includes bundesland in the response
so the detail header can show the source parlament.
- main.search_landtag — early HTTP 400 when bundesland is missing or
"ALL"; the live Landtag adapter cannot serve a synthetic Bundesweit
request.
- main.index() and main.list_bundeslaender — synthetic "🌍 Bundesweit"
entry prepended to the bundesländer list (kept out of bundeslaender.py
on purpose — ALL is not a real state). Both endpoints additionally
expose a parlament_names map so the frontend can render the source
parliament without an extra round-trip.
Report (PDF + HTML)
- generate_html_report / generate_pdf_report — new optional bundesland
parameter. When set, the report header carries the parliament name
("Landtag von Sachsen-Anhalt", "Landtag Nordrhein-Westfalen", …)
beside the title. Three call sites updated: run_analysis,
run_drucksache_analysis, download_assessment_pdf.
Frontend (templates/index.html)
- Header dropdown gets the synthetic ALL entry as first option;
initial currentBundesland is now 'ALL' (was 'NRW').
- localStorage persistence: changeBundesland writes, DOMContentLoaded
reads and validates against the visible options.
- changeBundesland resets the score / party / tag filter state, syncs
the upload-mode bundesland select, disables the Landtag-Suche button
+ tooltip when ALL, and toggles a data-mode attribute on
.list-content (used by CSS to show/hide the per-item bundesland
badge).
- loadAssessments now sends ?bundesland=… so the API does the actual
filtering. updateStats renders an additional per-bundesland average
block (Ø NRW: x · Ø LSA: y) when in ALL mode and the loaded list
spans more than one bundesland.
- renderList prepends a small "bl-badge" beside the Drucksachen-Nummer.
Hidden in single-bundesland mode via CSS selector to avoid clutter.
- showDetail header now shows the parliament name as its own line
(.detail-parlament).
- searchLandtag has an early-out alert if currentBundesland === 'ALL',
saving a network round-trip.
- Upload-Mode bundesland select now starts with a "— Bundesland wählen
—" placeholder (no auto-default), and startAnalysis validates that a
concrete bundesland was chosen.
CSS
- .bl-badge plus the .list-content[data-mode="single"] hide rule.
- .detail-parlament for the detail header line.
- .header-parlament for the PDF report header line.
Resolves #8.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-07 23:00:39 +02:00
if (!bundesland) {
alert('Bitte ein Bundesland wählen.');
return;
}
2026-03-28 22:30:24 +01:00
btn.disabled = true;
statusDiv.style.display = 'block';
resultDiv.style.display = 'none';
try {
const formData = new FormData();
if (text) formData.append('text', text);
if (file) formData.append('file', file);
formData.append('bundesland', bundesland);
formData.append('model', 'qwen-plus');
const resp = await fetch('/analyze', { method: 'POST', body: formData });
const data = await resp.json();
if (data.job_id) {
pollStatus(data.job_id);
} else {
throw new Error(data.detail || 'Fehler');
}
} catch (e) {
statusDiv.innerHTML = `< p style = "color: #dc3545;" > ✗ Fehler: ${e.message}< / p > `;
btn.disabled = false;
}
}
async function pollStatus(jobId) {
const statusText = document.getElementById('status-text');
const statusDiv = document.getElementById('analysis-status');
const resultDiv = document.getElementById('analysis-result');
const btn = document.getElementById('analyze-btn');
try {
const resp = await fetch(`/status/${jobId}`);
const data = await resp.json();
if (data.status === 'completed') {
statusDiv.style.display = 'none';
resultDiv.innerHTML = `
< p style = "color: var(--color-green);" > ✓ Analyse abgeschlossen!< / p >
< a href = "/result/${jobId}" class = "btn-pdf" style = "background: var(--color-green);" > 📊 Ergebnis ansehen< / a >
< a href = "/result/${jobId}/pdf" class = "btn-pdf" > 📄 PDF herunterladen< / a >
`;
resultDiv.style.display = 'block';
btn.disabled = false;
} else if (data.status === 'failed') {
statusDiv.innerHTML = `< p style = "color: #dc3545;" > ✗ Fehler: Analyse fehlgeschlagen. Bitte erneut versuchen.< / p > `;
btn.disabled = false;
} else {
statusText.textContent = `Analysiere... (${data.status})`;
setTimeout(() => pollStatus(jobId), 2000);
}
} catch (e) {
statusDiv.innerHTML = `< p style = "color: #dc3545;" > ✗ Fehler: ${e.message}< / p > `;
btn.disabled = false;
}
}
< / script >
< / body >
< / html >