Faktenkompass: UI-Rebrand, Admin-Assistent und Produktions-Setup.

Modernisiert die öffentliche Oberfläche mit Inter, Dark Mode und Live-Suche, ergänzt KI-gestützte Entwürfe im Admin mit Nachweis-Uploads und dokumentiert Daphne/Nginx für den Serverbetrieb.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Pirmin Hinderling (fedora)
2026-07-07 15:33:58 +02:00
parent 2c7fb7d030
commit db3558872e
43 changed files with 3014 additions and 208 deletions
View File
+257
View File
@@ -0,0 +1,257 @@
import base64
import json
import mimetypes
import re
from django.conf import settings
from claims.models import Category, Claim
VALID_CLAIM_STATUSES = {choice[0] for choice in Claim.Status.choices}
VALID_EVIDENCE_LEVELS = {choice[0] for choice in Claim.EvidenceLevel.choices}
SYSTEM_PROMPT = """Du bist ein sachlicher Fakten-Assistent für die Plattform „Faktenkompass“.
Deine Aufgabe: Aus Artikeln, Bildern und Nutzereingaben einen überprüfbaren Fakteneintrag erstellen.
Regeln:
- Schreibe auf Deutsch, neutral und wissenschaftlich.
- Keine parteipolitische Sprache, keine Übertreibungen.
- Die Behauptung (title) formuliert eine häufig geäußerte Aussage, die überprüft wird.
- Die Kurzantwort ist in ~20 Sekunden lesbar.
- Quellen müssen echte, nachvollziehbare Referenzen sein (URL aus Eingabe bevorzugen).
- Wähle eine passende bestehende Kategorie (use_existing_slug) ODER schlage eine neue vor.
- Icons für neue Kategorien: Bootstrap Icons Klassen wie bi-thermometer-half, bi-virus, bi-lightning-charge.
Antworte ausschließlich als gültiges JSON mit dieser Struktur:
{
"category": {
"use_existing_slug": "slug-oder-null",
"new_category": {
"name": "Name",
"description": "Kurzbeschreibung",
"icon": "bi-icon-name"
}
},
"claim": {
"title": "Die zu prüfende Behauptung",
"status": "falsch|irrefuehrend|teilweise_richtig|unbelegt|wissenschaftlicher_konsens|offene_frage",
"short_answer": "Kurze Einordnung",
"detailed_explanation": "Ausführlichere Erklärung",
"evidence_level": "sehr_stark|stark|mittel|schwach"
},
"tags": ["Schlagwort1", "Schlagwort2"],
"sources": [
{
"title": "Titel",
"organization": "Autor/Organisation",
"url": "https://...",
"description": "Kurzbeschreibung"
}
],
"counter_arguments": [
{
"argument": "Häufiges Gegenargument",
"response": "Sachliche Antwort"
}
],
"assistant_summary": "Kurze Zusammenfassung für den Admin (1-2 Sätze)"
}
"""
class AIAssistantError(Exception):
pass
def _categories_context():
categories = Category.objects.all().values('name', 'slug', 'description')
if not categories:
return 'Keine Kategorien vorhanden neue Kategorie vorschlagen.'
lines = []
for cat in categories:
lines.append(f"- {cat['name']} (slug: {cat['slug']}): {cat['description'][:120]}")
return '\n'.join(lines)
def _build_user_prompt(*, article_url='', article_text='', user_notes='', refinement=''):
parts = [
'Bestehende Kategorien:',
_categories_context(),
'',
f'Artikel-URL: {article_url or "(keine)"}',
'',
'Artikeltext:',
article_text or '(nicht verfügbar)',
'',
'Zusätzliche Hinweise des Admins:',
user_notes or '(keine)',
]
if refinement:
parts.extend(['', 'Überarbeitungswunsch:', refinement])
return '\n'.join(parts)
def _svg_to_png_bytes(svg_data):
try:
import cairosvg
except ImportError:
return None
try:
return cairosvg.svg2png(bytestring=svg_data)
except Exception:
return None
def _svg_text_fallback(svg_data):
text = svg_data.decode('utf-8', errors='replace')
text = re.sub(r'<script[^>]*>.*?</script>', '', text, flags=re.IGNORECASE | re.DOTALL)
return text[:4000]
def _image_data_url(data, mime_type):
encoded = base64.b64encode(data).decode('ascii')
return {
'type': 'image_url',
'image_url': {'url': f'data:{mime_type};base64,{encoded}'},
}
def _image_message_part(uploaded_file):
uploaded_file.open('rb')
try:
data = uploaded_file.read()
finally:
uploaded_file.close()
mime_type = mimetypes.guess_type(uploaded_file.name)[0]
if mime_type == 'application/pdf' or uploaded_file.name.lower().endswith('.pdf'):
return {
'type': 'text',
'text': '[PDF-Datei hochgeladen nutze vor allem die Admin-Beschreibung und den Artikeltext.]',
}
if mime_type == 'image/svg+xml' or uploaded_file.name.lower().endswith('.svg'):
png_data = _svg_to_png_bytes(data)
if png_data:
return _image_data_url(png_data, 'image/png')
return {
'type': 'text',
'text': (
'[SVG-Grafik hochgeladen OpenAI unterstützt SVG nicht direkt. '
'Nutze diesen XML-Auszug und die Admin-Beschreibung:]\n'
+ _svg_text_fallback(data)
),
}
mime_type = mime_type or 'image/jpeg'
if mime_type not in {'image/png', 'image/jpeg', 'image/gif', 'image/webp'}:
mime_type = 'image/jpeg'
return _image_data_url(data, mime_type)
def _call_openai(messages, *, has_image=False):
api_key = getattr(settings, 'OPENAI_API_KEY', '')
if not api_key:
raise AIAssistantError(
'OPENAI_API_KEY ist nicht gesetzt. Bitte in der Umgebung oder .env konfigurieren.'
)
try:
from openai import OpenAI
except ImportError as exc:
raise AIAssistantError('OpenAI-Paket fehlt. Bitte pip install -r requirements.txt ausführen.') from exc
model = settings.OPENAI_MODEL_VISION if has_image else settings.OPENAI_MODEL
client = OpenAI(api_key=api_key, timeout=settings.OPENAI_TIMEOUT)
response = client.chat.completions.create(
model=model,
messages=messages,
response_format={'type': 'json_object'},
temperature=0.3,
)
content = response.choices[0].message.content
if not content:
raise AIAssistantError('Leere Antwort von der KI erhalten.')
return json.loads(content)
def _validate_payload(payload):
claim = payload.get('claim') or {}
status = claim.get('status', '')
evidence = claim.get('evidence_level', '')
if status and status not in VALID_CLAIM_STATUSES:
raise AIAssistantError(f'Ungültiger Status von der KI: {status}')
if evidence and evidence not in VALID_EVIDENCE_LEVELS:
raise AIAssistantError(f'Ungültiges Evidenzlevel von der KI: {evidence}')
return payload
def generate_draft_payload(
*,
article_url='',
article_text='',
user_notes='',
uploaded_file=None,
refinement='',
previous_payload=None,
):
user_prompt = _build_user_prompt(
article_url=article_url,
article_text=article_text,
user_notes=user_notes,
refinement=refinement,
)
if previous_payload:
user_prompt += '\n\nBisheriger Entwurf (JSON):\n' + json.dumps(previous_payload, ensure_ascii=False)
user_content = [{'type': 'text', 'text': user_prompt}]
has_image = False
extra_text = ''
if uploaded_file and uploaded_file.name:
part = _image_message_part(uploaded_file)
if part.get('type') == 'image_url':
user_content.append(part)
has_image = True
elif part.get('type') == 'text':
extra_text = part['text']
if extra_text:
user_prompt = f'{user_prompt}\n\n{extra_text}'
if has_image:
user_content[0] = {'type': 'text', 'text': user_prompt}
user_message = user_content
else:
user_message = user_prompt
messages = [
{'role': 'system', 'content': SYSTEM_PROMPT},
{'role': 'user', 'content': user_message},
]
payload = _call_openai(messages, has_image=has_image)
return _validate_payload(payload)
def apply_payload_to_draft(draft, payload):
category = payload.get('category') or {}
claim = payload.get('claim') or {}
draft.category_slug = category.get('use_existing_slug') or ''
new_category = category.get('new_category') or {}
draft.new_category_name = new_category.get('name', '')
draft.new_category_description = new_category.get('description', '')
draft.new_category_icon = new_category.get('icon', '')
draft.title = claim.get('title', '')
draft.claim_status = claim.get('status', Claim.Status.UNBELEGT)
draft.short_answer = claim.get('short_answer', '')
draft.detailed_explanation = claim.get('detailed_explanation', '')
draft.evidence_level = claim.get('evidence_level', Claim.EvidenceLevel.MITTEL)
draft.tags = payload.get('tags') or []
draft.sources = payload.get('sources') or []
draft.counter_arguments = payload.get('counter_arguments') or []
draft.ai_raw_response = payload
summary = payload.get('assistant_summary', 'Entwurf wurde erstellt.')
draft.add_message('assistant', summary)
+203
View File
@@ -0,0 +1,203 @@
import ipaddress
import re
import socket
from urllib.parse import urlparse
import requests
from bs4 import BeautifulSoup
from django.conf import settings
BLOCKED_HOSTNAMES = {'localhost', '127.0.0.1', '0.0.0.0', '::1'}
MAX_DOWNLOAD_BYTES = getattr(settings, 'ARTICLE_FETCH_MAX_BYTES', 2_000_000)
TARGET_TEXT_CHARS = getattr(settings, 'ARTICLE_FETCH_TARGET_CHARS', 8_000)
MAX_OUTPUT_CHARS = getattr(settings, 'ARTICLE_FETCH_OUTPUT_CHARS', 12_000)
PARTIAL_PARSE_BYTES = getattr(settings, 'ARTICLE_FETCH_PARTIAL_BYTES', 96_000)
FETCH_TIMEOUT = getattr(settings, 'ARTICLE_FETCH_TIMEOUT', 15)
USER_AGENT = 'FaktenkompassBot/1.0 (+https://faktenkompass.local)'
REMOVE_TAGS = {
'script', 'style', 'nav', 'footer', 'header', 'aside', 'noscript', 'iframe',
'svg', 'form', 'button', 'input', 'select', 'textarea', 'menu', 'dialog',
'figure', 'picture', 'video', 'audio', 'canvas', 'embed', 'object',
}
NOISE_PATTERN = re.compile(
r'comment|sidebar|related|newsletter|cookie|banner|social|share|widget|'
r'menu|breadcrumb|promo|advert|tracking|popup|modal|consent|paywall|'
r'recommend|trending|tag-cloud|author-box|meta-|sharing|outbrain|taboola',
re.IGNORECASE,
)
MIN_PARAGRAPH_LENGTH = 40
class ArticleFetchError(Exception):
pass
def _hostname_resolves_to_private_ip(hostname):
try:
infos = socket.getaddrinfo(hostname, None)
except socket.gaierror as exc:
raise ArticleFetchError(f'Domain konnte nicht aufgelöst werden: {hostname}') from exc
for info in infos:
ip = ipaddress.ip_address(info[4][0])
if (
ip.is_private
or ip.is_loopback
or ip.is_link_local
or ip.is_reserved
or ip.is_multicast
):
return True
return False
def validate_article_url(url):
parsed = urlparse(url.strip())
if parsed.scheme not in {'http', 'https'}:
raise ArticleFetchError('Nur http- und https-URLs sind erlaubt.')
if not parsed.netloc:
raise ArticleFetchError('Ungültige URL.')
hostname = parsed.hostname or ''
if hostname.lower() in BLOCKED_HOSTNAMES:
raise ArticleFetchError('Diese URL ist nicht erlaubt.')
if _hostname_resolves_to_private_ip(hostname):
raise ArticleFetchError('Interne oder private URLs sind aus Sicherheitsgründen blockiert.')
def _remove_noise_elements(soup):
for tag_name in REMOVE_TAGS:
for tag in soup.find_all(tag_name):
tag.decompose()
to_remove = []
for element in soup.find_all(True):
element_attrs = getattr(element, 'attrs', None) or {}
role = element_attrs.get('role')
if role in {'navigation', 'banner', 'complementary', 'contentinfo'}:
to_remove.append(element)
continue
class_val = element_attrs.get('class', [])
if isinstance(class_val, list):
class_str = ' '.join(class_val)
else:
class_str = str(class_val or '')
id_str = str(element_attrs.get('id', '') or '')
combined = f'{class_str} {id_str}'.strip()
if combined and NOISE_PATTERN.search(combined):
to_remove.append(element)
for element in to_remove:
element.decompose()
def _find_content_root(soup):
for selector in (
('article', {}),
('main', {}),
('div', {'role': 'main'}),
('div', {'class': re.compile(r'article|content|post|entry|story', re.I)}),
):
tag, attrs = selector
match = soup.find(tag, attrs=attrs) if attrs else soup.find(tag)
if match:
return match
return soup.body or soup
def _collect_paragraphs(root):
paragraphs = []
seen = set()
for element in root.find_all(['h1', 'h2', 'h3', 'p', 'li', 'blockquote']):
text = element.get_text(' ', strip=True)
text = re.sub(r'\s+', ' ', text)
if len(text) < MIN_PARAGRAPH_LENGTH:
continue
key = text.lower()
if key in seen:
continue
seen.add(key)
paragraphs.append(text)
return paragraphs
def _extract_readable_text(html):
soup = BeautifulSoup(html, 'html.parser')
_remove_noise_elements(soup)
root = _find_content_root(soup)
paragraphs = _collect_paragraphs(root)
if not paragraphs:
fallback = root.get_text('\n', strip=True)
paragraphs = []
for line in fallback.splitlines():
line = re.sub(r'\s+', ' ', line.strip())
if len(line) >= MIN_PARAGRAPH_LENGTH:
paragraphs.append(line)
content = '\n\n'.join(paragraphs)
content = re.sub(r'\n{3,}', '\n\n', content)
return content.strip()
def _finalize_text(text, *, partial=False):
if len(text) < 100:
raise ArticleFetchError('Aus dem Artikel konnte kein ausreichender Text extrahiert werden.')
trimmed = text[:MAX_OUTPUT_CHARS]
if partial and len(text) > MAX_OUTPUT_CHARS:
trimmed += '\n\n[… Text gekürzt …]'
return trimmed
def _decode_html(chunks, encoding):
return b''.join(chunks).decode(encoding or 'utf-8', errors='replace')
def fetch_article_text(url):
validate_article_url(url)
try:
response = requests.get(
url,
timeout=FETCH_TIMEOUT,
headers={'User-Agent': USER_AGENT},
allow_redirects=True,
stream=True,
)
except requests.RequestException as exc:
raise ArticleFetchError(f'Artikel konnte nicht geladen werden: {exc}') from exc
if response.status_code >= 400:
raise ArticleFetchError(f'Artikel nicht erreichbar (HTTP {response.status_code}).')
final_host = urlparse(response.url).hostname or ''
if _hostname_resolves_to_private_ip(final_host):
raise ArticleFetchError('Weiterleitung auf eine blockierte URL.')
chunks = []
size = 0
encoding = response.encoding
truncated_download = False
for chunk in response.iter_content(chunk_size=8192):
if not chunk:
continue
size += len(chunk)
if size > MAX_DOWNLOAD_BYTES:
truncated_download = True
break
chunks.append(chunk)
if size >= PARTIAL_PARSE_BYTES and size % PARTIAL_PARSE_BYTES < 8192:
text = _extract_readable_text(_decode_html(chunks, encoding))
if len(text) >= TARGET_TEXT_CHARS:
return _finalize_text(text, partial=True)
if not chunks:
raise ArticleFetchError('Artikel konnte nicht geladen werden.')
text = _extract_readable_text(_decode_html(chunks, encoding))
return _finalize_text(text, partial=truncated_download)
+52
View File
@@ -0,0 +1,52 @@
from claims.models import ClaimDraft
from claims.services.ai_assistant import (
AIAssistantError,
apply_payload_to_draft,
generate_draft_payload,
)
from claims.services.article_fetcher import ArticleFetchError, fetch_article_text
def process_claim_draft(draft, *, refinement=''):
draft.error_message = ''
draft.status = ClaimDraft.DraftStatus.PROCESSING
draft.save(update_fields=['error_message', 'status', 'updated_at'])
user_message_parts = []
if draft.article_url:
user_message_parts.append(f'Link: {draft.article_url}')
if draft.user_notes:
user_message_parts.append(draft.user_notes)
if draft.uploaded_file:
user_message_parts.append(f'Datei: {draft.uploaded_file.name}')
if refinement:
user_message_parts.append(f'Überarbeitung: {refinement}')
draft.add_message('user', '\n'.join(user_message_parts) or 'Neuer Entwurf')
draft.save(update_fields=['conversation', 'updated_at'])
try:
article_text = draft.article_text
if draft.article_url and not refinement:
article_text = fetch_article_text(draft.article_url)
draft.article_text = article_text
draft.save(update_fields=['article_text', 'updated_at'])
payload = generate_draft_payload(
article_url=draft.article_url,
article_text=article_text,
user_notes=draft.user_notes,
uploaded_file=draft.uploaded_file if draft.uploaded_file else None,
refinement=refinement,
previous_payload=draft.ai_raw_response if refinement else None,
)
apply_payload_to_draft(draft, payload)
draft.status = ClaimDraft.DraftStatus.REVIEW
draft.save()
return draft
except (ArticleFetchError, AIAssistantError) as exc:
draft.status = ClaimDraft.DraftStatus.FAILED
draft.error_message = str(exc)
draft.add_message('assistant', f'Fehler: {exc}')
draft.save()
raise
+103
View File
@@ -0,0 +1,103 @@
import os
import uuid
from django.db import transaction
from django.utils.text import slugify
from claims.models import Category, Claim, CounterArgument, EvidenceFile, Source, Tag
class DraftPublishError(Exception):
pass
def _resolve_category(draft):
if draft.category_slug:
category = Category.objects.filter(slug=draft.category_slug).first()
if category:
return category
if draft.new_category_name:
category, _ = Category.objects.get_or_create(
slug=slugify(draft.new_category_name),
defaults={
'name': draft.new_category_name,
'description': draft.new_category_description or draft.new_category_name,
'icon': draft.new_category_icon or 'bi-book',
},
)
return category
raise DraftPublishError('Bitte eine Kategorie auswählen oder eine neue angeben.')
def _get_or_create_tags(tag_names):
tags = []
for name in tag_names or []:
clean = str(name).strip()
if not clean:
continue
tag, _ = Tag.objects.get_or_create(name=clean, defaults={'slug': slugify(clean)})
tags.append(tag)
return tags
@transaction.atomic
def publish_draft(draft):
if draft.status == draft.DraftStatus.PUBLISHED and draft.published_claim_id:
return draft.published_claim
if not draft.title or not draft.short_answer:
raise DraftPublishError('Titel und Kurzantwort sind erforderlich.')
category = _resolve_category(draft)
claim = Claim.objects.create(
category=category,
title=draft.title,
status=draft.claim_status or Claim.Status.UNBELEGT,
short_answer=draft.short_answer,
detailed_explanation=draft.detailed_explanation,
evidence_level=draft.evidence_level or Claim.EvidenceLevel.MITTEL,
)
claim.tags.set(_get_or_create_tags(draft.tags))
for source in draft.sources or []:
url = (source.get('url') or '').strip()
if not url:
continue
Source.objects.create(
claim=claim,
title=source.get('title') or url,
organization=source.get('organization') or 'Unbekannt',
url=url,
description=source.get('description', ''),
)
for counter in draft.counter_arguments or []:
argument = (counter.get('argument') or '').strip()
response = (counter.get('response') or '').strip()
if argument and response:
CounterArgument.objects.create(claim=claim, argument=argument, response=response)
if draft.uploaded_file:
_attach_uploaded_file(draft, claim)
draft.published_claim = claim
draft.status = draft.DraftStatus.PUBLISHED
draft.save(update_fields=['published_claim', 'status', 'updated_at'])
return claim
def _attach_uploaded_file(draft, claim):
if not draft.uploaded_file:
return
original_name = os.path.basename(draft.uploaded_file.name)
ext = original_name.rsplit('.', 1)[-1].lower() if '.' in original_name else ''
if ext not in {'jpg', 'jpeg', 'png', 'gif', 'webp', 'svg', 'pdf'}:
return
evidence = EvidenceFile(claim=claim, title='')
short_name = f'{uuid.uuid4().hex[:12]}.{ext}'
with draft.uploaded_file.open('rb') as source_file:
evidence.file.save(short_name, source_file, save=True)