From 8ae984060115951343fdc9b8fbb172150fc80159 Mon Sep 17 00:00:00 2001 From: Jonas Heinrich Date: Thu, 25 Dec 2025 22:27:34 +0100 Subject: [PATCH] generate antrag --- flake.lock | 8 +- flake.nix | 13 +- meinantrag.py | 486 +++++++++++++++++++++++++++++++++++++------ module.nix | 15 +- templates/index.html | 346 +++++++++++++++++++++++------- 5 files changed, 719 insertions(+), 149 deletions(-) diff --git a/flake.lock b/flake.lock index eda6165..3884789 100644 --- a/flake.lock +++ b/flake.lock @@ -2,16 +2,16 @@ "nodes": { "nixpkgs": { "locked": { - "lastModified": 1763622513, - "narHash": "sha256-1jQnuyu82FpiSxowrF/iFK6Toh9BYprfDqfs4BB+19M=", + "lastModified": 1766473571, + "narHash": "sha256-5G1NDO2PulBx1RoaA6U1YoUDX0qZslpPxv+n5GX6Qto=", "owner": "NixOS", "repo": "nixpkgs", - "rev": "c58bc7f5459328e4afac201c5c4feb7c818d604b", + "rev": "76701a179d3a98b07653e2b0409847499b2a07d3", "type": "github" }, "original": { "id": "nixpkgs", - "ref": "nixos-25.05", + "ref": "nixos-25.11", "type": "indirect" } }, diff --git a/flake.nix b/flake.nix index 5434c1c..f466aef 100644 --- a/flake.nix +++ b/flake.nix @@ -1,7 +1,7 @@ { description = "meinantrag package and service"; - inputs.nixpkgs.url = "nixpkgs/nixos-25.05"; + inputs.nixpkgs.url = "nixpkgs/nixos-25.11"; outputs = { self, nixpkgs }: let @@ -18,14 +18,21 @@ overlay = final: prev: { meinantrag = with final; python3Packages.buildPythonApplication rec { pname = "meinantrag"; - version = "0.0.1"; + version = "0.0.2"; format = "other"; src = self; dontBuild = true; - dependencies = with python3Packages; [ falcon requests jinja2 ]; + dependencies = with python3Packages; [ + falcon + requests + jinja2 + google-generativeai # Dependency for Gemini API + reportlab # Dependency for PDF generation + python-docx # Dependency for Word document generation + ]; installPhase = '' install -Dm755 ${./meinantrag.py} $out/bin/meinantrag diff --git a/meinantrag.py b/meinantrag.py index 019ea7f..b8d8da6 100644 --- a/meinantrag.py +++ b/meinantrag.py @@ -6,10 +6,25 @@ MeinAntrag - A web application to generate prefilled government requests import falcon import json import requests -from urllib.parse import urlencode +from urllib.parse import urlencode, parse_qs import os import sys from jinja2 import Environment, FileSystemLoader +import google.generativeai as genai +import re +from io import BytesIO +from reportlab.lib.pagesizes import A4 +from reportlab.lib.units import cm +from reportlab.lib.styles import getSampleStyleSheet, ParagraphStyle +from reportlab.platypus import SimpleDocTemplate, Paragraph, Spacer +from reportlab.lib.enums import TA_LEFT, TA_JUSTIFY +try: + from docx import Document + from docx.shared import Pt, Inches + from docx.enum.text import WD_ALIGN_PARAGRAPH + DOCX_AVAILABLE = True +except ImportError: + DOCX_AVAILABLE = False SITE_BASE_URL = os.environ.get('MEINANTRAG_BASE_URL', 'http://localhost:8000') @@ -59,7 +74,6 @@ class BaseTemplateResource: class MeinAntragApp(BaseTemplateResource): def __init__(self): - self.fragdenstaat_api = "https://fragdenstaat.de/api/v1" # Setup Jinja2 template environment template_dir = self._get_template_dir() print(f"Using template directory: {template_dir}") @@ -74,42 +88,6 @@ class MeinAntragApp(BaseTemplateResource): meta_description='Erstelle vorausgefüllte Anfragelinks für FragDenStaat.de, suche Behörden, füge Betreff und Text hinzu und teile den Link.', canonical_url=f"{SITE_BASE_URL}/" ) - - def on_post(self, req, resp): - """Handle form submission and generate link""" - try: - # Parse form data - use get_param for form fields - publicbody_id = req.get_param('publicbody_id', default='') - subject = req.get_param('subject', default='') - body = req.get_param('body', default='') - - # Generate FragDenStaat.de link - base_url = "https://fragdenstaat.de/anfrage-stellen/" - if publicbody_id: - base_url += f"an/{publicbody_id}/" - - params = {} - if subject: - params['subject'] = subject - if body: - params['body'] = body - - if params: - base_url += "?" + urlencode(params) - - resp.content_type = 'application/json' - resp.text = json.dumps({ - 'success': True, - 'link': base_url - }) - - except Exception as e: - resp.status = falcon.HTTP_500 - resp.content_type = 'application/json' - resp.text = json.dumps({ - 'success': False, - 'error': str(e) - }) class ImpressumResource(BaseTemplateResource): def __init__(self): @@ -143,42 +121,422 @@ class DatenschutzResource(BaseTemplateResource): noindex=True ) -class PublicBodiesResource: +class GenerateAntragResource: def __init__(self): - self.fragdenstaat_api = "https://fragdenstaat.de/api/v1" + # Initialize Gemini API + api_key = os.environ.get('GOOGLE_GEMINI_API_KEY') + if api_key: + genai.configure(api_key=api_key) + self.model = genai.GenerativeModel('gemini-flash-latest') + else: + self.model = None - def on_get(self, req, resp): - """API endpoint to search public bodies""" + def _remove_markdown(self, text): + """Remove markdown formatting from text""" + if not text: + return text + + # Remove bold/italic markdown: **text** or *text* or __text__ or _text_ + text = re.sub(r'\*\*(.+?)\*\*', r'\1', text) + text = re.sub(r'\*(.+?)\*', r'\1', text) + text = re.sub(r'__(.+?)__', r'\1', text) + text = re.sub(r'_(.+?)_', r'\1', text) + + # Remove heading markdown: /Heading or # Heading + text = re.sub(r'^/\s*', '', text, flags=re.MULTILINE) + text = re.sub(r'^#+\s*', '', text, flags=re.MULTILINE) + + # Remove other markdown elements + text = re.sub(r'`(.+?)`', r'\1', text) # Code + text = re.sub(r'\[(.+?)\]\(.+?\)', r'\1', text) # Links + + return text.strip() + + def _parse_gemini_response(self, text): + """Parse the response from Gemini into title, demand, and justification""" + # Remove markdown formatting first + text = self._remove_markdown(text) + + # Split by "Begründung/Sachverhalt" or similar patterns + parts = re.split(r'(begründung|sachverhalt|begründung/sachverhalt)', text, maxsplit=1, flags=re.IGNORECASE) + + if len(parts) >= 3: + # We have a split at "Begründung/Sachverhalt" + before_justification = parts[0].strip() + justification = parts[2].strip() if len(parts) > 2 else "" + + # Remove "Begründung/Sachverhalt" or "Sachverhalt" from the beginning of justification + justification = re.sub(r'^(begründung\s*/?\s*sachverhalt|sachverhalt|begründung)\s*:?\s*\n?', '', justification, flags=re.IGNORECASE).strip() + else: + # Try to split by paragraphs + paragraphs = [p.strip() for p in text.split('\n\n') if p.strip()] + if len(paragraphs) >= 3: + before_justification = '\n\n'.join(paragraphs[:-1]) + justification = paragraphs[-1] + # Remove "Begründung/Sachverhalt" or "Sachverhalt" from the beginning + justification = re.sub(r'^(begründung\s*/?\s*sachverhalt|sachverhalt|begründung)\s*:?\s*\n?', '', justification, flags=re.IGNORECASE).strip() + else: + before_justification = text + justification = "" + + # Extract title (first line or first paragraph) + title_match = re.match(r'^(.+?)(?:\n\n|\n|$)', before_justification) + if title_match: + title = title_match.group(1).strip() + demand = before_justification[len(title):].strip() + else: + lines = before_justification.split('\n', 1) + title = lines[0].strip() + demand = lines[1].strip() if len(lines) > 1 else "" + + # Remove title from demand if it's duplicated + if demand.startswith(title): + demand = demand[len(title):].strip() + + # Remove markdown from each part + title = self._remove_markdown(title) + demand = self._remove_markdown(demand) + justification = self._remove_markdown(justification) + + # Final cleanup: remove any remaining "Sachverhalt" or "Begründung/Sachverhalt" at the start + justification = re.sub(r'^(begründung\s*/?\s*sachverhalt|sachverhalt|begründung)\s*:?\s*\n?\s*', '', justification, flags=re.IGNORECASE).strip() + + return { + 'title': title, + 'demand': demand, + 'justification': justification + } + + def on_post(self, req, resp): + """Generate text from user input using Gemini API""" try: - search = req.get_param('search', default='') - page = req.get_param('page', default=1) + if not self.model: + resp.status = falcon.HTTP_500 + resp.content_type = 'application/json' + resp.text = json.dumps({ + 'success': False, + 'error': 'Gemini API key not configured' + }) + return - # Build API URL - url = f"{self.fragdenstaat_api}/publicbody/" - params = { - 'limit': 20, - 'offset': (int(page) - 1) * 20 - } + # Get form data - try multiple methods for Falcon compatibility + anliegen = '' + party_id = '' - if search: - params['q'] = search + # Method 1: Try get_param (works for URL-encoded form data) + anliegen = req.get_param('anliegen', default='') or '' + party_id = req.get_param('party_id', default='') or '' - # Make request to FragDenStaat API - response = requests.get(url, params=params, timeout=10) - response.raise_for_status() + # Method 2: If empty, try to read from stream and parse manually + if not anliegen: + try: + # Read the raw body - use bounded_stream if available, otherwise stream + stream = getattr(req, 'bounded_stream', req.stream) + raw_body = stream.read().decode('utf-8') + # Parse URL-encoded data manually + parsed = parse_qs(raw_body) + anliegen = parsed.get('anliegen', [''])[0] + party_id = parsed.get('party_id', [''])[0] + except Exception as e: + # Log the exception for debugging + print(f"Error parsing form data: {e}") + pass - data = response.json() + # Remove any whitespace and check if actually empty + anliegen = anliegen.strip() if anliegen else '' + party_id = party_id.strip() if party_id else '' + if not anliegen: + resp.status = falcon.HTTP_400 + resp.content_type = 'application/json' + resp.text = json.dumps({ + 'success': False, + 'error': 'Anliegen-Feld ist erforderlich' + }) + return + + # Create prompt for Gemini + prompt = """Erzeuge aus dem folgenden Anliegen-Text je nach Anliegen eine Anfrage oder einen Antrag an die Karlsruher Stadtverwaltung im Namen einer Stadtratsfraktion. + +Der Antrag soll im sachlichen, offiziellen Ton einer Fraktion verfasst sein - KEINE persönliche Anrede, KEINE "ich" oder "wir" Formulierungen. Verwende die dritte Person oder Passiv-Formulierungen. + +Struktur: +- Die erste Zeile ist der Antragstitel. Der Titel soll PRÄGNANT, EINFACH und EINPRÄGSAM sein - maximal 8-10 Wörter. Vermeide komplizierte Formulierungen, technische Fachbegriffe oder zu lange Titel. Der Titel soll eine gute Außenwirkung haben und das Anliegen klar und verständlich kommunizieren. Beispiele für gute Titel: "Nachtabsenkung der öffentlichen Straßenbeleuchtung", "Vielfalt in Bewegung – Kulturelle Begleitmaßnahmen World Games 2029", "Prüfung digitaler Zahlungsdienstleister und WERO-Alternative" +- Der zweite Absatz ist der Forderungsteil ("Der Gemeinderat möge beschließen:"). Hier können nach einem kurzen Satz auch Stichpunkte verwendet werden, wenn dies sinnvoll ist. +- Der letzte Teil ist Begründung/Sachverhalt (ohne diesen Titel im Text) + +WICHTIG: +- Verwende KEINE Markdown-Formatierung. Keine **fett**, keine *kursiv*, keine /Überschriften, keine # Hashtags, keine Links oder andere Formatierung. +- Schreibe nur reinen Text ohne jegliche Markdown-Syntax. +- Sachlicher, offizieller Ton einer Fraktion, keine persönlichen Formulierungen. +- Der Antragstitel muss prägnant, einfach verständlich und einprägsam sein - keine komplizierten Formulierungen! + +""" + prompt += anliegen + + # Call Gemini API + response = self.model.generate_content(prompt) + generated_text = response.text + + # Parse the response + parsed = self._parse_gemini_response(generated_text) + + # Return JSON with the generated text parts resp.content_type = 'application/json' - resp.text = json.dumps(data) + resp.text = json.dumps({ + 'success': True, + 'title': parsed['title'], + 'demand': parsed['demand'], + 'justification': parsed['justification'], + 'party_name': party_id if party_id else "" + }) except Exception as e: + import traceback + traceback.print_exc() resp.status = falcon.HTTP_500 resp.content_type = 'application/json' resp.text = json.dumps({ - 'error': str(e), - 'results': [], - 'next': None + 'success': False, + 'error': str(e) + }) + +class GeneratePDFResource: + def _generate_pdf(self, title, demand, justification, party_name=""): + """Generate a PDF that looks like a city council proposal""" + buffer = BytesIO() + doc = SimpleDocTemplate(buffer, pagesize=A4, + rightMargin=2.5*cm, leftMargin=2.5*cm, + topMargin=2.5*cm, bottomMargin=2.5*cm) + + # Container for the 'Flowable' objects + story = [] + + # Define styles + styles = getSampleStyleSheet() + + # Custom styles for the document + title_style = ParagraphStyle( + 'CustomTitle', + parent=styles['Heading1'], + fontSize=16, + textColor='black', + spaceAfter=30, + alignment=TA_LEFT, + fontName='Helvetica-Bold' + ) + + heading_style = ParagraphStyle( + 'CustomHeading', + parent=styles['Heading2'], + fontSize=12, + textColor='black', + spaceAfter=12, + spaceBefore=20, + alignment=TA_LEFT, + fontName='Helvetica-Bold' + ) + + body_style = ParagraphStyle( + 'CustomBody', + parent=styles['Normal'], + fontSize=11, + textColor='black', + spaceAfter=12, + alignment=TA_JUSTIFY, + fontName='Helvetica' + ) + + # Header with party name if provided + if party_name: + party_para = Paragraph(f"Antrag der {party_name}", body_style) + story.append(party_para) + story.append(Spacer(1, 0.5*cm)) + + # Title + if title: + title_para = Paragraph(f"{title}", title_style) + story.append(title_para) + + # Demand section + if demand: + story.append(Spacer(1, 0.3*cm)) + demand_heading = Paragraph("Der Gemeinderat möge beschließen:", heading_style) + story.append(demand_heading) + + # Process demand text - replace newlines with proper breaks + demand_lines = demand.split('\n') + for line in demand_lines: + if line.strip(): + demand_para = Paragraph(line.strip(), body_style) + story.append(demand_para) + + # Justification section + if justification: + story.append(Spacer(1, 0.5*cm)) + justification_heading = Paragraph("Begründung/Sachverhalt", heading_style) + story.append(justification_heading) + + # Process justification text + justification_lines = justification.split('\n') + for line in justification_lines: + if line.strip(): + justification_para = Paragraph(line.strip(), body_style) + story.append(justification_para) + + # Build PDF + doc.build(story) + buffer.seek(0) + return buffer + + def on_post(self, req, resp): + """Generate PDF from form data""" + try: + # Get form data + title = req.get_param('title', default='') or '' + demand = req.get_param('demand', default='') or '' + justification = req.get_param('justification', default='') or '' + party_name = req.get_param('party_name', default='') or '' + + # If empty, try to read from stream + if not title: + try: + stream = getattr(req, 'bounded_stream', req.stream) + raw_body = stream.read().decode('utf-8') + parsed = parse_qs(raw_body) + title = parsed.get('title', [''])[0] + demand = parsed.get('demand', [''])[0] + justification = parsed.get('justification', [''])[0] + party_name = parsed.get('party_name', [''])[0] + except Exception: + pass + + # Generate PDF + pdf_buffer = self._generate_pdf(title, demand, justification, party_name) + + # Return PDF + resp.content_type = 'application/pdf' + resp.set_header('Content-Disposition', 'inline; filename="antrag.pdf"') + resp.data = pdf_buffer.read() + + except Exception as e: + import traceback + traceback.print_exc() + resp.status = falcon.HTTP_500 + resp.content_type = 'application/json' + resp.text = json.dumps({ + 'success': False, + 'error': str(e) + }) + +class GenerateWordResource: + def _generate_word(self, title, demand, justification, party_name=""): + """Generate a Word document that looks like a city council proposal""" + doc = Document() + + # Set default font + style = doc.styles['Normal'] + font = style.font + font.name = 'Arial' + font.size = Pt(11) + + # Header with party name if provided + if party_name: + party_para = doc.add_paragraph(f"Antrag der {party_name}") + party_para.runs[0].bold = True + party_para.runs[0].font.size = Pt(11) + doc.add_paragraph() + + # Title + if title: + title_para = doc.add_paragraph(title) + title_para.runs[0].bold = True + title_para.runs[0].font.size = Pt(16) + title_para.paragraph_format.space_after = Pt(30) + + # Demand section + if demand: + doc.add_paragraph() + demand_heading = doc.add_paragraph("Der Gemeinderat möge beschließen:") + demand_heading.runs[0].bold = True + demand_heading.runs[0].font.size = Pt(12) + demand_heading.paragraph_format.space_before = Pt(20) + demand_heading.paragraph_format.space_after = Pt(12) + + # Process demand text + demand_lines = demand.split('\n') + for line in demand_lines: + if line.strip(): + doc.add_paragraph(line.strip()) + + # Justification section + if justification: + doc.add_paragraph() + justification_heading = doc.add_paragraph("Begründung/Sachverhalt") + justification_heading.runs[0].bold = True + justification_heading.runs[0].font.size = Pt(12) + justification_heading.paragraph_format.space_before = Pt(20) + justification_heading.paragraph_format.space_after = Pt(12) + + # Process justification text + justification_lines = justification.split('\n') + for line in justification_lines: + if line.strip(): + doc.add_paragraph(line.strip()) + + # Save to buffer + buffer = BytesIO() + doc.save(buffer) + buffer.seek(0) + return buffer + + def on_post(self, req, resp): + """Generate Word document from form data""" + try: + if not DOCX_AVAILABLE: + resp.status = falcon.HTTP_500 + resp.content_type = 'application/json' + resp.text = json.dumps({ + 'success': False, + 'error': 'python-docx not installed' + }) + return + + # Get form data + title = req.get_param('title', default='') or '' + demand = req.get_param('demand', default='') or '' + justification = req.get_param('justification', default='') or '' + party_name = req.get_param('party_name', default='') or '' + + # If empty, try to read from stream + if not title: + try: + stream = getattr(req, 'bounded_stream', req.stream) + raw_body = stream.read().decode('utf-8') + parsed = parse_qs(raw_body) + title = parsed.get('title', [''])[0] + demand = parsed.get('demand', [''])[0] + justification = parsed.get('justification', [''])[0] + party_name = parsed.get('party_name', [''])[0] + except Exception: + pass + + # Generate Word document + word_buffer = self._generate_word(title, demand, justification, party_name) + + # Return Word document + resp.content_type = 'application/vnd.openxmlformats-officedocument.wordprocessingml.document' + resp.set_header('Content-Disposition', 'attachment; filename="antrag.docx"') + resp.data = word_buffer.read() + + except Exception as e: + import traceback + traceback.print_exc() + resp.status = falcon.HTTP_500 + resp.content_type = 'application/json' + resp.text = json.dumps({ + 'success': False, + 'error': str(e) }) class RobotsResource: @@ -224,14 +582,18 @@ if not STATIC_DIR: meinantrag = MeinAntragApp() impressum = ImpressumResource() datenschutz = DatenschutzResource() -publicbodies = PublicBodiesResource() +generate_antrag = GenerateAntragResource() +generate_pdf = GeneratePDFResource() +generate_word = GenerateWordResource() robots = RobotsResource() sitemap = SitemapResource() app.add_route('/', meinantrag) app.add_route('/impressum', impressum) app.add_route('/datenschutz', datenschutz) -app.add_route('/api/publicbodies', publicbodies) +app.add_route('/api/generate-antrag', generate_antrag) +app.add_route('/api/generate-pdf', generate_pdf) +app.add_route('/api/generate-word', generate_word) app.add_route('/robots.txt', robots) app.add_route('/sitemap.xml', sitemap) diff --git a/module.nix b/module.nix index 1475f31..7232251 100644 --- a/module.nix +++ b/module.nix @@ -16,6 +16,19 @@ in enable = lib.mkEnableOption "MeinAntrag web app"; + environment = lib.mkOption { + type = lib.types.attrsOf lib.types.str; + default = { }; + example = { + GOOGLE_GEMINI_API_KEY = "your-api-key-here"; + MEINANTRAG_BASE_URL = "https://example.com"; + }; + description = '' + Additional environment variables to pass to the MeinAntrag service. + For example, set GOOGLE_GEMINI_API_KEY for Gemini API integration. + ''; + }; + }; }; @@ -51,7 +64,7 @@ in "PYTHONPATH=${pkgs.meinantrag}/share/meinantrag:${pkgs.meinantrag.pythonPath}" "MEINANTRAG_TEMPLATES_DIR=${pkgs.meinantrag}/share/meinantrag/templates" "MEINANTRAG_STATIC_DIR=${pkgs.meinantrag}/share/meinantrag/assets" - ]; + ] ++ (lib.mapAttrsToList (name: value: "${name}=${value}") cfg.environment); settings = { "static-map" = "/static=${pkgs.meinantrag}/share/meinantrag/assets"; diff --git a/templates/index.html b/templates/index.html index 1f9c658..510ab24 100644 --- a/templates/index.html +++ b/templates/index.html @@ -11,107 +11,295 @@

-
- - +
+
+ + +
+ +
+ + +
+ +
+ +
-
- - -
- -
- + - -
{% endblock %} {% block extra_js %}