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 @@