gwoe-antragspruefer/app/templates/index.html

1835 lines
74 KiB
HTML
Raw Normal View History

<!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);
min-height: 100vh;
min-height: 100dvh;
display: flex;
flex-direction: column;
}
/* 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;
flex: 1;
min-height: 0;
}
/* 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;
}
.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;
color: var(--color-darkgray);
}
.mode-btn.active {
background: white;
box-shadow: 0 1px 3px rgba(0,0,0,0.1);
}
/* 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); }
}
/* 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);
}
/* Responsive */
@media (max-width: 900px) {
.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;
}
.main-container {
flex-direction: column;
flex: 1;
min-height: 0;
}
.list-panel {
width: 100%;
min-width: 0;
border-right: none;
border-bottom: 1px solid var(--color-lightgray);
}
.list-content {
max-height: 50vh;
}
.detail-panel {
padding: 1rem;
overflow: visible;
flex: none;
}
.btn-back-mobile {
display: inline-block;
}
}
@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; }
}
/* 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>
<button class="mode-btn" onclick="showMode('tags')">🏷️ Tags</button>
<button class="mode-btn" onclick="showMode('upload')">📤 Prüfen</button>
<a href="/auswertungen" class="mode-btn" style="text-decoration: none;">📈 Auswertungen</a>
<a href="/quellen" class="mode-btn" style="text-decoration: none;">📚 Quellen</a>
<a href="/methodik" class="mode-btn" style="text-decoration: none;">🔍 Methodik</a>
<button id="auth-btn" class="mode-btn" style="border:none;cursor:pointer;">🔑 Anmelden</button>
</div>
</header>
<div class="main-container" id="browse-mode">
<!-- Left: List -->
<aside class="list-panel">
<div class="list-header">
<!--
#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>
</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>
<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>
<!-- 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 %}>
{{ 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';
let searchTimeout = null;
let isSearching = false;
let selectedTags = new Set();
let allTags = {};
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) {
authBtn.textContent = currentUser.name || currentUser.email || 'Angemeldet';
authBtn.classList.add('logged-in');
authBtn.onclick = () => { /* TODO: Logout */ };
} else {
authBtn.textContent = '🔑 Anmelden';
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
// 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
document.addEventListener('DOMContentLoaded', () => {
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';
}
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, "\\'")}')">&times;</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);
allAssessments = await resp.json();
updateStats();
renderList(allAssessments);
buildParteienFilter();
buildTagCloud();
} catch (e) {
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 =
`<p style="padding: 1rem; color: #c00;">Fehler beim Laden: ${e.message}</p>`;
}
}
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
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 = '';
}
// Partei-Durchschnitte berechnen
const parteiStats = {};
allAssessments.forEach(a => {
if (a.gwoeScore == null) return;
(a.fraktionen || []).forEach(f => {
if (!parteiStats[f]) parteiStats[f] = { sum: 0, count: 0 };
parteiStats[f].sum += a.gwoeScore;
parteiStats[f].count += 1;
});
});
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
// 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
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>`
: '';
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>
<span class="list-item-score ${scoreClass}">${scoreText}</span>
</div>
<div class="list-item-title">${item.title || 'Ohne Titel'}</div>
<div class="list-item-meta">${fraktionen} · ${item.datum || ''}</div>
${isUnchecked ? `
<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)">
🔍 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;
}
}
async function reAnalyze(drucksache, bundesland, btn) {
btn.disabled = true;
btn.textContent = '⏳ Wird neu bewertet...';
try {
// Altes Assessment löschen
await fetch(`/api/assessment/delete?drucksache=${encodeURIComponent(drucksache)}`, {method: 'DELETE'});
// 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)}`
});
const data = await resp.json();
if (data.status === 'queued') {
pollAnalysis(data.job_id, drucksache, btn);
} else {
btn.textContent = '❌ Fehler';
setTimeout(() => { btn.textContent = '🔄 Neu bewerten'; btn.disabled = false; }, 3000);
}
} catch (e) {
btn.textContent = '❌ Fehler';
setTimeout(() => { btn.textContent = '🔄 Neu bewerten'; btn.disabled = false; }, 3000);
}
}
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();
document.getElementById('search-input').value = '';
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';
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;
}
// #16: Landtag-Suche zieht aus dem dedizierten Landtag-Input,
// nicht mehr aus dem DB-Suchfeld.
const query = document.getElementById('landtag-search-input').value.trim();
if (query.length < 2) {
alert('Bitte mindestens 2 Zeichen ins Landtag-Suchfeld eingeben');
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('');
// 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).
// 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) {
if (!z || !z.url) return '#';
const extra = (ds && bl) ? `&ds=${encodeURIComponent(ds)}&bl=${encodeURIComponent(bl)}` : '';
// Schon eine Cite-URL? ds/bl anhängen + #page=N.
if (z.url.includes('/api/wahlprogramm-cite')) {
const m = z.url.match(/seite=(\d+)/);
const page = m ? m[1] : '';
const base = z.url.split('#')[0];
return base + extra + (page ? '#page=' + page : '');
}
// Statische URL umschreiben
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));
return `/api/wahlprogramm-cite?pdf=${encodeURIComponent(pdf)}&seite=${page}&q=${q}${extra}#page=${page}`;
}
return z.url;
}
const wahlprogrammHtml = (item.wahlprogrammScores || []).map(wp => {
// Zitate formatieren mit klickbaren Links + Highlighting
const zitateHtml = (wp.wahlprogramm?.zitate || []).map(z => `
<div style="margin: 0.5rem 0; padding: 0.5rem; background: #f8f9fa; border-left: 3px solid #889e33; font-size: 0.85rem;">
<em>"${z.text}"</em><br>
<a href="${makeCiteUrl(z, item.drucksache, item.bundesland)}" target="_blank" style="color: #009da5; font-size: 0.8rem;">
📄 ${z.quelle}
</a>
</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;">
&#9888; Keine belegbaren Quellen im Index gefunden — Score basiert auf LLM-Einschätzung, nicht auf verifizierten Programm-Stellen.
</div>
` : '';
return `
<div style="margin: 0.75rem 0; padding: 0.75rem; background: var(--color-bg); border-radius: 4px;">
<strong>${wp.fraktion}</strong>
${wp.istAntragsteller ? ' <span style="color:#889e33">(Antragsteller)</span>' : ''}
${wp.istRegierung ? ' <span style="color:#009da5">(Regierung)</span>' : ''}<br>
<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> ·
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}
${zitateHtml}
</div>
`}).join('');
document.getElementById('detail-panel').innerHTML = `
<div class="detail-card">
<button class="btn-back-mobile" onclick="document.querySelector('.list-panel').scrollIntoView({behavior:'smooth', block:'start'})">← Zur Liste</button>
<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>` : ''}
<div class="detail-id">${item.drucksache} · ${(item.fraktionen || []).join(', ')} · ${item.datum || ''}</div>
</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>
<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;">
Bewertet am ${item.updatedAt ? new Date(item.updatedAt).toLocaleDateString('de-DE') : ''}
${item.source ? ` · Quelle: ${item.source}` : ''}
${item.model ? ` · Modell: ${item.model}` : ''}
</div>
</div>
`;
// 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' });
}
}
// 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';
document.getElementById('tags-mode').style.display = mode === 'tags' ? 'flex' : 'none';
document.getElementById('upload-mode').style.display = mode === 'upload' ? 'flex' : 'none';
}
// 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
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;
}
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>