generate antrag
This commit is contained in:
parent
6c46660d0c
commit
8ae9840601
5 changed files with 719 additions and 149 deletions
486
meinantrag.py
486
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"<b>Antrag der {party_name}</b>", body_style)
|
||||
story.append(party_para)
|
||||
story.append(Spacer(1, 0.5*cm))
|
||||
|
||||
# Title
|
||||
if title:
|
||||
title_para = Paragraph(f"<b>{title}</b>", title_style)
|
||||
story.append(title_para)
|
||||
|
||||
# Demand section
|
||||
if demand:
|
||||
story.append(Spacer(1, 0.3*cm))
|
||||
demand_heading = Paragraph("<b>Der Gemeinderat möge beschließen:</b>", 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("<b>Begründung/Sachverhalt</b>", 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)
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue