diff --git a/.env.example b/.env.example
new file mode 100644
index 0000000..3dd0ea9
--- /dev/null
+++ b/.env.example
@@ -0,0 +1,6 @@
+# OpenAI API-Schlüssel für den Fakten-Assistenten im Admin
+OPENAI_API_KEY=sk-...
+
+# Optional: Modelle anpassen
+# OPENAI_MODEL=gpt-4o-mini
+# OPENAI_MODEL_VISION=gpt-4o
diff --git a/README.md b/README.md
index d1736f2..2bceb98 100644
--- a/README.md
+++ b/README.md
@@ -80,6 +80,163 @@ export DB_PORT=5432
Installieren Sie zusätzlich `psycopg2-binary` und führen Sie `migrate` erneut aus.
+## Produktion (Daphne + Nginx)
+
+Für den Serverbetrieb wird [Daphne](https://github.com/django/daphne) als ASGI-Server empfohlen. Nginx dient als Reverse Proxy und liefert statische Dateien sowie Uploads aus.
+
+### 1. Umgebungsvariablen
+
+Legen Sie z. B. `/etc/faktenkompass.env` an:
+
+```bash
+DJANGO_SETTINGS_MODULE=faktenkompass.settings
+DJANGO_DEBUG=False
+DJANGO_SECRET_KEY=lange-zufaellige-zeichenkette
+ALLOWED_HOSTS=faktenkompass.example.com,www.faktenkompass.example.com
+
+DB_ENGINE=django.db.backends.postgresql
+DB_NAME=faktenkompass
+DB_USER=faktenkompass
+DB_PASSWORD=geheim
+DB_HOST=127.0.0.1
+DB_PORT=5432
+
+OPENAI_API_KEY=sk-...
+```
+
+Optional in `faktenkompass/settings.py` für HTTPS hinter Nginx ergänzen:
+
+```python
+SECURE_PROXY_SSL_HEADER = ('HTTP_X_FORWARDED_PROTO', 'https')
+CSRF_TRUSTED_ORIGINS = ['https://faktenkompass.example.com']
+```
+
+`SECRET_KEY`, `DEBUG` und `ALLOWED_HOSTS` sollten in der Produktion aus Umgebungsvariablen gelesen werden.
+
+### 2. Deployment vorbereiten
+
+```bash
+cd /pfad/zu/ArgumentAtlas
+source .venv/bin/activate
+pip install -r requirements.txt
+
+python manage.py migrate
+python manage.py collectstatic --noinput
+python manage.py createsuperuser # falls noch nicht vorhanden
+```
+
+Stellen Sie sicher, dass der Prozessnutzer Schreibrechte für `media/` und die Datenbank hat.
+
+### 3. Daphne manuell testen
+
+```bash
+set -a && source /etc/faktenkompass.env && set +a
+daphne -b 127.0.0.1 -p 8001 faktenkompass.asgi:application
+```
+
+Die App lauscht dann nur lokal auf Port `8001` – der öffentliche Zugriff erfolgt über Nginx.
+
+### 4. systemd-Dienst
+
+Datei `/etc/systemd/system/faktenkompass.service`:
+
+```ini
+[Unit]
+Description=Faktenkompass (Daphne ASGI)
+After=network.target postgresql.service
+
+[Service]
+Type=simple
+User=www-data
+Group=www-data
+WorkingDirectory=/pfad/zu/ArgumentAtlas
+EnvironmentFile=/etc/faktenkompass.env
+ExecStart=/pfad/zu/ArgumentAtlas/.venv/bin/daphne \
+ -b 127.0.0.1 \
+ -p 8001 \
+ --access-log /var/log/faktenkompass/access.log \
+ --proxy-headers \
+ faktenkompass.asgi:application
+Restart=always
+RestartSec=5
+
+[Install]
+WantedBy=multi-user.target
+```
+
+Log-Verzeichnis anlegen und Dienst aktivieren:
+
+```bash
+sudo mkdir -p /var/log/faktenkompass
+sudo chown www-data:www-data /var/log/faktenkompass
+sudo systemctl daemon-reload
+sudo systemctl enable --now faktenkompass
+sudo systemctl status faktenkompass
+```
+
+`--proxy-headers` setzt voraus, dass Nginx die Header `X-Forwarded-For`, `X-Forwarded-Proto` und `Host` korrekt weitergibt.
+
+### 5. Nginx-Konfiguration
+
+Datei `/etc/nginx/sites-available/faktenkompass`:
+
+```nginx
+upstream faktenkompass {
+ server 127.0.0.1:8001;
+}
+
+server {
+ listen 80;
+ server_name faktenkompass.example.com;
+
+ client_max_body_size 25M;
+
+ location /static/ {
+ alias /pfad/zu/ArgumentAtlas/staticfiles/;
+ expires 30d;
+ access_log off;
+ }
+
+ location /media/ {
+ alias /pfad/zu/ArgumentAtlas/media/;
+ expires 7d;
+ access_log off;
+ }
+
+ location / {
+ proxy_pass http://faktenkompass;
+ proxy_http_version 1.1;
+ proxy_set_header Host $host;
+ proxy_set_header X-Real-IP $remote_addr;
+ proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
+ proxy_set_header X-Forwarded-Proto $scheme;
+ proxy_redirect off;
+ }
+}
+```
+
+Aktivieren und testen:
+
+```bash
+sudo ln -s /etc/nginx/sites-available/faktenkompass /etc/nginx/sites-enabled/
+sudo nginx -t
+sudo systemctl reload nginx
+```
+
+Für HTTPS: Certbot/Let's Encrypt verwenden (`certbot --nginx`).
+
+### 6. Updates einspielen
+
+```bash
+cd /pfad/zu/ArgumentAtlas
+source .venv/bin/activate
+git pull
+pip install -r requirements.txt
+python manage.py migrate
+python manage.py collectstatic --noinput
+sudo systemctl restart faktenkompass
+```
+
## Projektstruktur
```
@@ -123,6 +280,32 @@ Optional: Titel, Bildunterschrift und Reihenfolge. Hochgeladene Dateien erschein
Dateien werden unter `media/evidence/` gespeichert. In der Produktion muss der Webserver Medien-Dateien ausliefern (z. B. über Nginx).
+### Fakten-Assistent (KI im Admin)
+
+Der Assistent hilft beim Anlegen neuer Behauptungen per ChatGPT. **Nichts wird automatisch veröffentlicht** – jeder Entwurf muss zuerst geprüft werden.
+
+**Einrichtung:**
+
+```bash
+export OPENAI_API_KEY=sk-...
+# optional: OPENAI_MODEL=gpt-4o-mini, OPENAI_MODEL_VISION=gpt-4o
+```
+
+**Nutzung:**
+
+1. Admin öffnen → **Fakten-Assistent** (Startseite oder unter Fakten-Entwürfe)
+2. Artikel-URL, Beschreibung und/oder Bild/PDF eingeben
+3. **Entwurf generieren** – die KI schlägt Kategorie, Behauptung, Quellen und Gegenargumente vor
+4. Entwurf prüfen, bearbeiten, ggf. **KI erneut fragen**
+5. Erst mit **Veröffentlichen** wird der Eintrag live geschaltet
+
+**Sicherheit:**
+
+- Nur für eingeloggte Staff-Nutzer
+- Artikel-URLs: SSRF-Schutz (keine internen/privaten Adressen)
+- API-Schlüssel nur serverseitig (Umgebungsvariable), nie im Frontend
+- Hochgeladene Dateien werden erst nach Freigabe als Nachweis verknüpft
+
## Lizenz
Projekt für den internen Gebrauch / Bildungszwecke.
diff --git a/claims/admin.py b/claims/admin.py
index 82569b6..1c5f1e1 100644
--- a/claims/admin.py
+++ b/claims/admin.py
@@ -1,36 +1,101 @@
from django.contrib import admin
+from django.urls import path
+from django.utils.html import format_html
-from .models import Category, Claim, CounterArgument, EvidenceFile, Source, Tag
+from .admin_forms import (
+ ClaimAdminForm,
+ CounterArgumentInlineForm,
+ EvidenceFileInlineForm,
+ SourceInlineForm,
+)
+from .admin_views import AssistantView, ReviewDraftView
+from .models import Category, Claim, ClaimDraft, CounterArgument, EvidenceFile, Source, Tag
-class EvidenceFileInline(admin.TabularInline):
+class FaktenAdminMixin:
+ class Media:
+ css = {'all': ['css/admin_custom.css']}
+ js = ['js/admin_custom.js']
+
+
+class EvidenceFileInline(FaktenAdminMixin, admin.StackedInline):
model = EvidenceFile
+ form = EvidenceFileInlineForm
extra = 1
- fields = ('title', 'file', 'caption', 'order')
+ fields = ('preview', 'file', 'title', 'caption', 'order')
+ readonly_fields = ('preview',)
+ classes = ('collapse-open',)
+ verbose_name = 'Nachweis'
+ verbose_name_plural = 'Nachweise (Grafiken / PDFs)'
+
+ @admin.display(description='Vorschau')
+ def preview(self, obj):
+ if not obj or not obj.pk or not obj.file:
+ return format_html('Nach dem Speichern sichtbar ')
+ if obj.is_image:
+ return format_html(
+ '
',
+ obj.file.url,
+ obj.file.url,
+ obj.image_alt,
+ )
+ if obj.is_pdf:
+ return format_html(
+ '',
+ obj.file.url,
+ )
+ return '—'
-class SourceInline(admin.TabularInline):
+class SourceInline(FaktenAdminMixin, admin.StackedInline):
model = Source
+ form = SourceInlineForm
extra = 1
+ fields = ('title', 'organization', 'url', 'description')
+ classes = ('collapse-open',)
+ verbose_name = 'Quelle'
+ verbose_name_plural = 'Quellen'
-class CounterArgumentInline(admin.StackedInline):
+class CounterArgumentInline(FaktenAdminMixin, admin.StackedInline):
model = CounterArgument
+ form = CounterArgumentInlineForm
extra = 1
+ fields = ('argument', 'response')
+ classes = ('collapse-open',)
+ verbose_name = 'Gegenargument'
+ verbose_name_plural = 'Gegenargumente'
@admin.register(Tag)
-class TagAdmin(admin.ModelAdmin):
+class TagAdmin(FaktenAdminMixin, admin.ModelAdmin):
list_display = ('name', 'slug')
prepopulated_fields = {'slug': ('name',)}
search_fields = ('name',)
@admin.register(Category)
-class CategoryAdmin(admin.ModelAdmin):
+class CategoryAdmin(FaktenAdminMixin, admin.ModelAdmin):
list_display = ('name', 'slug', 'claim_count', 'created_at')
prepopulated_fields = {'slug': ('name',)}
search_fields = ('name', 'description')
+ fieldsets = (
+ (None, {
+ 'fields': ('name', 'slug', 'icon'),
+ }),
+ ('Beschreibung', {
+ 'fields': ('description',),
+ 'classes': ('wide',),
+ }),
+ )
+
+ def formfield_for_dbfield(self, db_field, request, **kwargs):
+ formfield = super().formfield_for_dbfield(db_field, request, **kwargs)
+ if db_field.name == 'description':
+ formfield.widget.attrs.update({'rows': 4, 'class': 'fk-admin-textarea'})
+ return formfield
@admin.display(description='Anzahl Behauptungen')
def claim_count(self, obj):
@@ -38,43 +103,81 @@ class CategoryAdmin(admin.ModelAdmin):
@admin.register(Claim)
-class ClaimAdmin(admin.ModelAdmin):
+class ClaimAdmin(FaktenAdminMixin, admin.ModelAdmin):
+ form = ClaimAdminForm
+ change_form_template = 'admin/claims/claim/change_form.html'
list_display = ('title', 'category', 'status', 'evidence_level', 'updated_at')
+ list_display_links = ('title',)
list_filter = ('category', 'status', 'evidence_level', 'tags')
search_fields = ('title', 'short_answer', 'detailed_explanation')
prepopulated_fields = {'slug': ('title',)}
filter_horizontal = ('tags',)
+ autocomplete_fields = ('category',)
+ save_on_top = True
+ date_hierarchy = 'updated_at'
inlines = [EvidenceFileInline, SourceInline, CounterArgumentInline]
+ readonly_fields = ('public_link', 'created_at', 'updated_at')
fieldsets = (
- (None, {
- 'fields': ('category', 'title', 'slug', 'status', 'evidence_level', 'tags'),
+ ('Einordnung', {
+ 'fields': ('category', 'status', 'evidence_level', 'tags'),
}),
- ('Inhalt', {
+ ('Behauptung', {
+ 'fields': ('title', 'slug', 'public_link'),
+ }),
+ ('Texte', {
'fields': ('short_answer', 'detailed_explanation'),
+ 'classes': ('wide',),
+ 'description': 'Kurzantwort: in ~20 Sekunden lesbar. Ausführliche Erklärung optional.',
+ }),
+ ('Zeitstempel', {
+ 'fields': ('created_at', 'updated_at'),
+ 'classes': ('collapse',),
}),
)
+ @admin.display(description='Website')
+ def public_link(self, obj):
+ if not obj.pk:
+ return '—'
+ return format_html(
+ 'Behauptung auf der Website öffnen ',
+ obj.get_absolute_url(),
+ )
+
@admin.register(EvidenceFile)
-class EvidenceFileAdmin(admin.ModelAdmin):
- list_display = ('display_title', 'claim', 'file_extension_display', 'order', 'created_at')
+class EvidenceFileAdmin(FaktenAdminMixin, admin.ModelAdmin):
+ list_display = ('preview_thumb', 'display_title', 'claim', 'order', 'created_at')
list_filter = ('claim__category',)
search_fields = ('title', 'caption', 'claim__title')
+ readonly_fields = ('preview',)
+ fields = ('claim', 'preview', 'file', 'title', 'caption', 'order')
- @admin.display(description='Typ')
- def file_extension_display(self, obj):
+ @admin.display(description='')
+ def preview_thumb(self, obj):
+ if obj.is_image and obj.file:
+ return format_html(
+ ' ',
+ obj.file.url,
+ )
return obj.file_extension.upper()
+ @admin.display(description='Vorschau')
+ def preview(self, obj):
+ return EvidenceFileInline.preview(self, obj)
+
@admin.register(Source)
-class SourceAdmin(admin.ModelAdmin):
+class SourceAdmin(FaktenAdminMixin, admin.ModelAdmin):
+ form = SourceInlineForm
list_display = ('title', 'organization', 'claim')
list_filter = ('claim__category',)
search_fields = ('title', 'organization', 'description')
@admin.register(CounterArgument)
-class CounterArgumentAdmin(admin.ModelAdmin):
+class CounterArgumentAdmin(FaktenAdminMixin, admin.ModelAdmin):
+ form = CounterArgumentInlineForm
list_display = ('argument_preview', 'claim')
list_filter = ('claim__category',)
search_fields = ('argument', 'response')
@@ -82,3 +185,48 @@ class CounterArgumentAdmin(admin.ModelAdmin):
@admin.display(description='Gegenargument')
def argument_preview(self, obj):
return obj.argument[:80]
+
+
+@admin.register(ClaimDraft)
+class ClaimDraftAdmin(FaktenAdminMixin, admin.ModelAdmin):
+ change_list_template = 'admin/claims/claimdraft/change_list.html'
+ list_display = ('title', 'status', 'created_by', 'created_at', 'published_claim')
+ list_filter = ('status', 'created_at')
+ search_fields = ('title', 'user_notes', 'article_url')
+ readonly_fields = (
+ 'created_by', 'status', 'article_url', 'user_notes', 'uploaded_file',
+ 'article_text', 'conversation', 'ai_raw_response', 'error_message',
+ 'published_claim', 'created_at', 'updated_at',
+ )
+
+ def get_queryset(self, request):
+ qs = super().get_queryset(request)
+ if request.user.is_superuser:
+ return qs
+ return qs.filter(created_by=request.user)
+
+ def has_change_permission(self, request, obj=None):
+ if obj and not request.user.is_superuser and obj.created_by_id != request.user.id:
+ return False
+ return super().has_change_permission(request, obj)
+
+ def has_delete_permission(self, request, obj=None):
+ if obj and not request.user.is_superuser and obj.created_by_id != request.user.id:
+ return False
+ return super().has_delete_permission(request, obj)
+
+ def get_urls(self):
+ urls = super().get_urls()
+ custom_urls = [
+ path(
+ 'assistant/',
+ self.admin_site.admin_view(AssistantView.as_view()),
+ name='claims_claimdraft_assistant',
+ ),
+ path(
+ '/review/',
+ self.admin_site.admin_view(ReviewDraftView.as_view()),
+ name='claims_claimdraft_review',
+ ),
+ ]
+ return custom_urls + urls
diff --git a/claims/admin_forms.py b/claims/admin_forms.py
new file mode 100644
index 0000000..0f75bcf
--- /dev/null
+++ b/claims/admin_forms.py
@@ -0,0 +1,46 @@
+from django import forms
+
+from .models import Claim, CounterArgument, EvidenceFile, Source
+
+
+class ClaimAdminForm(forms.ModelForm):
+ class Meta:
+ model = Claim
+ fields = '__all__'
+ widgets = {
+ 'title': forms.TextInput(attrs={'class': 'fk-admin-wide'}),
+ 'short_answer': forms.Textarea(attrs={'rows': 5, 'class': 'fk-admin-textarea'}),
+ 'detailed_explanation': forms.Textarea(attrs={'rows': 12, 'class': 'fk-admin-textarea'}),
+ }
+
+
+class SourceInlineForm(forms.ModelForm):
+ class Meta:
+ model = Source
+ fields = '__all__'
+ widgets = {
+ 'title': forms.TextInput(attrs={'class': 'fk-admin-wide'}),
+ 'organization': forms.TextInput(attrs={'class': 'fk-admin-wide'}),
+ 'url': forms.URLInput(attrs={'class': 'fk-admin-wide'}),
+ 'description': forms.Textarea(attrs={'rows': 3, 'class': 'fk-admin-textarea'}),
+ }
+
+
+class CounterArgumentInlineForm(forms.ModelForm):
+ class Meta:
+ model = CounterArgument
+ fields = '__all__'
+ widgets = {
+ 'argument': forms.Textarea(attrs={'rows': 3, 'class': 'fk-admin-textarea'}),
+ 'response': forms.Textarea(attrs={'rows': 4, 'class': 'fk-admin-textarea'}),
+ }
+
+
+class EvidenceFileInlineForm(forms.ModelForm):
+ class Meta:
+ model = EvidenceFile
+ fields = '__all__'
+ widgets = {
+ 'title': forms.TextInput(attrs={'class': 'fk-admin-wide'}),
+ 'caption': forms.Textarea(attrs={'rows': 2, 'class': 'fk-admin-textarea'}),
+ }
diff --git a/claims/admin_views.py b/claims/admin_views.py
new file mode 100644
index 0000000..7719554
--- /dev/null
+++ b/claims/admin_views.py
@@ -0,0 +1,135 @@
+from django.contrib import admin, messages
+from django.contrib.admin.views.decorators import staff_member_required
+from django.core.exceptions import SuspiciousFileOperation
+from django.shortcuts import get_object_or_404, redirect, render
+from django.urls import path, reverse
+from django.utils.decorators import method_decorator
+from django.views import View
+
+from claims.forms import AssistantInputForm, RefineDraftForm, ReviewDraftForm
+from claims.models import ClaimDraft
+from claims.services.ai_assistant import AIAssistantError
+from claims.services.article_fetcher import ArticleFetchError
+from claims.services.draft_processor import process_claim_draft
+from claims.services.draft_publisher import DraftPublishError, publish_draft
+
+
+def _get_user_draft(request, draft_id):
+ if request.user.is_superuser:
+ return get_object_or_404(ClaimDraft, pk=draft_id)
+ return get_object_or_404(ClaimDraft, pk=draft_id, created_by=request.user)
+
+
+@method_decorator(staff_member_required, name='dispatch')
+class AssistantView(View):
+ template_name = 'admin/claims/assistant.html'
+
+ def get(self, request):
+ form = AssistantInputForm()
+ drafts = ClaimDraft.objects.filter(created_by=request.user).order_by('-created_at')[:10]
+ return render(request, self.template_name, {
+ **admin.site.each_context(request),
+ 'form': form,
+ 'recent_drafts': drafts,
+ 'title': 'Fakten-Assistent',
+ 'opts': ClaimDraft._meta,
+ })
+
+ def post(self, request):
+ form = AssistantInputForm(request.POST, request.FILES)
+ if not form.is_valid():
+ return render(request, self.template_name, {
+ **admin.site.each_context(request),
+ 'form': form,
+ 'recent_drafts': ClaimDraft.objects.filter(created_by=request.user)[:10],
+ 'title': 'Fakten-Assistent',
+ 'opts': ClaimDraft._meta,
+ })
+
+ draft = ClaimDraft.objects.create(
+ created_by=request.user,
+ article_url=form.cleaned_data.get('article_url') or '',
+ user_notes=form.cleaned_data.get('user_notes') or '',
+ uploaded_file=form.cleaned_data.get('uploaded_file'),
+ )
+
+ try:
+ process_claim_draft(draft)
+ except (ArticleFetchError, AIAssistantError):
+ return redirect('admin:claims_claimdraft_review', draft.pk)
+
+ messages.success(request, 'Entwurf wurde erstellt. Bitte vor der Veröffentlichung prüfen.')
+ return redirect('admin:claims_claimdraft_review', draft.pk)
+
+
+@method_decorator(staff_member_required, name='dispatch')
+class ReviewDraftView(View):
+ template_name = 'admin/claims/review_draft.html'
+
+ def get(self, request, draft_id):
+ draft = _get_user_draft(request, draft_id)
+ return render(request, self.template_name, {
+ **admin.site.each_context(request),
+ 'draft': draft,
+ 'form': ReviewDraftForm(instance=draft),
+ 'refine_form': RefineDraftForm(),
+ 'title': f'Entwurf prüfen: {draft.title or draft.pk}',
+ 'opts': ClaimDraft._meta,
+ })
+
+ def post(self, request, draft_id):
+ draft = _get_user_draft(request, draft_id)
+ action = request.POST.get('action')
+
+ if action == 'refine':
+ refine_form = RefineDraftForm(request.POST)
+ if refine_form.is_valid():
+ try:
+ process_claim_draft(draft, refinement=refine_form.cleaned_data['refinement'])
+ messages.success(request, 'Entwurf wurde überarbeitet.')
+ except (ArticleFetchError, AIAssistantError) as exc:
+ messages.error(request, str(exc))
+ return redirect('admin:claims_claimdraft_review', draft.pk)
+
+ if action == 'reject':
+ draft.status = ClaimDraft.DraftStatus.REJECTED
+ draft.save(update_fields=['status', 'updated_at'])
+ messages.info(request, 'Entwurf wurde verworfen.')
+ return redirect('admin:claims_claimdraft_assistant')
+
+ form = ReviewDraftForm(request.POST, instance=draft)
+ if not form.is_valid():
+ return render(request, self.template_name, {
+ **admin.site.each_context(request),
+ 'draft': draft,
+ 'form': form,
+ 'refine_form': RefineDraftForm(),
+ 'title': f'Entwurf prüfen: {draft.title or draft.pk}',
+ 'opts': ClaimDraft._meta,
+ })
+
+ draft = form.save()
+
+ if action == 'save':
+ draft.status = ClaimDraft.DraftStatus.REVIEW
+ draft.save(update_fields=['status', 'updated_at'])
+ messages.success(request, 'Änderungen gespeichert.')
+ return redirect('admin:claims_claimdraft_review', draft.pk)
+
+ if action == 'publish':
+ try:
+ claim = publish_draft(draft)
+ except DraftPublishError as exc:
+ messages.error(request, str(exc))
+ return redirect('admin:claims_claimdraft_review', draft.pk)
+ except SuspiciousFileOperation as exc:
+ messages.error(request, f'Datei konnte nicht gespeichert werden: {exc}')
+ return redirect('admin:claims_claimdraft_review', draft.pk)
+
+ messages.success(
+ request,
+ f'Behauptung „{claim.title}" wurde veröffentlicht.',
+ )
+ return redirect(reverse('admin:claims_claim_change', args=[claim.pk]))
+
+ return redirect('admin:claims_claimdraft_review', draft.pk)
diff --git a/claims/forms.py b/claims/forms.py
new file mode 100644
index 0000000..adfa09a
--- /dev/null
+++ b/claims/forms.py
@@ -0,0 +1,159 @@
+import json
+
+from django import forms
+
+from claims.models import Category, Claim, ClaimDraft
+
+
+class AssistantInputForm(forms.Form):
+ article_url = forms.URLField(
+ label='Artikel-Link',
+ required=False,
+ widget=forms.URLInput(attrs={
+ 'class': 'form-control',
+ 'placeholder': 'https://beispiel.de/artikel',
+ }),
+ )
+ user_notes = forms.CharField(
+ label='Beschreibung / Behauptung',
+ required=False,
+ widget=forms.Textarea(attrs={
+ 'class': 'form-control',
+ 'rows': 4,
+ 'placeholder': 'Welche Aussage soll überprüft werden? Kontext, Stichpunkte …',
+ }),
+ )
+ uploaded_file = forms.FileField(
+ label='Bild oder PDF',
+ required=False,
+ widget=forms.ClearableFileInput(attrs={'class': 'form-control', 'accept': '.jpg,.jpeg,.png,.gif,.webp,.svg,.pdf'}),
+ )
+
+ def clean(self):
+ cleaned = super().clean()
+ url = cleaned.get('article_url')
+ notes = (cleaned.get('user_notes') or '').strip()
+ uploaded = cleaned.get('uploaded_file')
+ if not url and not notes and not uploaded:
+ raise forms.ValidationError(
+ 'Bitte mindestens einen Artikel-Link, eine Beschreibung oder eine Datei angeben.'
+ )
+ return cleaned
+
+
+class ReviewDraftForm(forms.ModelForm):
+ tags_text = forms.CharField(
+ label='Schlagwörter',
+ required=False,
+ help_text='Kommagetrennt',
+ widget=forms.TextInput(attrs={'class': 'form-control'}),
+ )
+ sources_json = forms.CharField(
+ label='Quellen (JSON)',
+ required=False,
+ widget=forms.Textarea(attrs={'class': 'form-control font-monospace', 'rows': 6}),
+ )
+ counter_arguments_json = forms.CharField(
+ label='Gegenargumente (JSON)',
+ required=False,
+ widget=forms.Textarea(attrs={'class': 'form-control font-monospace', 'rows': 6}),
+ )
+ category_choice = forms.ModelChoiceField(
+ label='Bestehende Kategorie',
+ queryset=Category.objects.all(),
+ required=False,
+ empty_label='— Neue Kategorie verwenden —',
+ widget=forms.Select(attrs={'class': 'form-select'}),
+ )
+
+ class Meta:
+ model = ClaimDraft
+ fields = [
+ 'title',
+ 'claim_status',
+ 'short_answer',
+ 'detailed_explanation',
+ 'evidence_level',
+ 'new_category_name',
+ 'new_category_description',
+ 'new_category_icon',
+ ]
+ widgets = {
+ 'title': forms.TextInput(attrs={'class': 'form-control'}),
+ 'claim_status': forms.Select(attrs={'class': 'form-select'}),
+ 'short_answer': forms.Textarea(attrs={'class': 'form-control', 'rows': 4}),
+ 'detailed_explanation': forms.Textarea(attrs={'class': 'form-control', 'rows': 6}),
+ 'evidence_level': forms.Select(attrs={'class': 'form-select'}),
+ 'new_category_name': forms.TextInput(attrs={'class': 'form-control'}),
+ 'new_category_description': forms.Textarea(attrs={'class': 'form-control', 'rows': 2}),
+ 'new_category_icon': forms.TextInput(attrs={'class': 'form-control', 'placeholder': 'bi-book'}),
+ }
+
+ def __init__(self, *args, **kwargs):
+ super().__init__(*args, **kwargs)
+ instance = kwargs.get('instance') or getattr(self, 'instance', None)
+ if instance and instance.pk:
+ self.fields['tags_text'].initial = ', '.join(instance.tags or [])
+ self.fields['sources_json'].initial = json.dumps(
+ instance.sources or [], ensure_ascii=False, indent=2
+ )
+ self.fields['counter_arguments_json'].initial = json.dumps(
+ instance.counter_arguments or [], ensure_ascii=False, indent=2
+ )
+ if instance.category_slug:
+ category = Category.objects.filter(slug=instance.category_slug).first()
+ if category:
+ self.fields['category_choice'].initial = category
+
+ def clean_tags_text(self):
+ raw = self.cleaned_data.get('tags_text', '')
+ return [part.strip() for part in raw.split(',') if part.strip()]
+
+ def clean_sources_json(self):
+ raw = (self.cleaned_data.get('sources_json') or '').strip()
+ if not raw:
+ return []
+ try:
+ data = json.loads(raw)
+ except json.JSONDecodeError as exc:
+ raise forms.ValidationError('Ungültiges JSON für Quellen.') from exc
+ if not isinstance(data, list):
+ raise forms.ValidationError('Quellen müssen eine JSON-Liste sein.')
+ return data
+
+ def clean_counter_arguments_json(self):
+ raw = (self.cleaned_data.get('counter_arguments_json') or '').strip()
+ if not raw:
+ return []
+ try:
+ data = json.loads(raw)
+ except json.JSONDecodeError as exc:
+ raise forms.ValidationError('Ungültiges JSON für Gegenargumente.') from exc
+ if not isinstance(data, list):
+ raise forms.ValidationError('Gegenargumente müssen eine JSON-Liste sein.')
+ return data
+
+ def save(self, commit=True):
+ draft = super().save(commit=False)
+ draft.tags = self.cleaned_data['tags_text']
+ draft.sources = self.cleaned_data['sources_json']
+ draft.counter_arguments = self.cleaned_data['counter_arguments_json']
+ category = self.cleaned_data.get('category_choice')
+ if category:
+ draft.category_slug = category.slug
+ else:
+ draft.category_slug = ''
+ if commit:
+ draft.save()
+ return draft
+
+
+class RefineDraftForm(forms.Form):
+ refinement = forms.CharField(
+ label='Überarbeitungswunsch',
+ widget=forms.Textarea(attrs={
+ 'class': 'form-control',
+ 'rows': 3,
+ 'placeholder': 'z. B. „Kurzantwort kürzer formulieren“ oder „mehr Quellen hinzufügen“',
+ }),
+ )
diff --git a/claims/migrations/0003_claimdraft.py b/claims/migrations/0003_claimdraft.py
new file mode 100644
index 0000000..7e48b89
--- /dev/null
+++ b/claims/migrations/0003_claimdraft.py
@@ -0,0 +1,52 @@
+# Generated by Django 5.2.15 on 2026-07-07 11:04
+
+import django.core.validators
+import django.db.models.deletion
+from django.conf import settings
+from django.db import migrations, models
+
+
+class Migration(migrations.Migration):
+
+ dependencies = [
+ ('claims', '0002_evidencefile'),
+ migrations.swappable_dependency(settings.AUTH_USER_MODEL),
+ ]
+
+ operations = [
+ migrations.CreateModel(
+ name='ClaimDraft',
+ fields=[
+ ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
+ ('status', models.CharField(choices=[('processing', 'Wird verarbeitet'), ('review', 'Zur Prüfung'), ('published', 'Veröffentlicht'), ('failed', 'Fehlgeschlagen'), ('rejected', 'Abgelehnt')], default='processing', max_length=20, verbose_name='Status')),
+ ('article_url', models.URLField(blank=True, max_length=500, verbose_name='Artikel-URL')),
+ ('user_notes', models.TextField(blank=True, verbose_name='Eingabe / Beschreibung')),
+ ('uploaded_file', models.FileField(blank=True, help_text='Optional: Bild oder PDF als Kontext', upload_to='drafts/uploads/%Y/%m/', validators=[django.core.validators.FileExtensionValidator(allowed_extensions=['jpg', 'jpeg', 'png', 'gif', 'webp', 'pdf'])], verbose_name='Hochgeladene Datei')),
+ ('article_text', models.TextField(blank=True, verbose_name='Extrahierter Artikeltext')),
+ ('category_slug', models.SlugField(blank=True, help_text='Slug einer vorhandenen Kategorie, falls passend', max_length=220, verbose_name='Bestehende Kategorie (Slug)')),
+ ('new_category_name', models.CharField(blank=True, max_length=200, verbose_name='Neue Kategorie')),
+ ('new_category_description', models.TextField(blank=True, verbose_name='Beschreibung neue Kategorie')),
+ ('new_category_icon', models.CharField(blank=True, help_text='Bootstrap-Icon-Klasse, z. B. bi-thermometer-half', max_length=50, verbose_name='Icon neue Kategorie')),
+ ('title', models.CharField(blank=True, max_length=300, verbose_name='Behauptung')),
+ ('claim_status', models.CharField(blank=True, choices=[('falsch', 'Falsch'), ('irrefuehrend', 'Irreführend'), ('teilweise_richtig', 'Teilweise richtig'), ('unbelegt', 'Unbelegt'), ('wissenschaftlicher_konsens', 'Wissenschaftlicher Konsens'), ('offene_frage', 'Offene Frage')], max_length=40, verbose_name='Einordnung')),
+ ('short_answer', models.TextField(blank=True, verbose_name='Kurzantwort')),
+ ('detailed_explanation', models.TextField(blank=True, verbose_name='Ausführliche Erklärung')),
+ ('evidence_level', models.CharField(blank=True, choices=[('sehr_stark', 'Sehr stark'), ('stark', 'Stark'), ('mittel', 'Mittel'), ('schwach', 'Schwach')], max_length=20, verbose_name='Evidenzlevel')),
+ ('tags', models.JSONField(blank=True, default=list, verbose_name='Schlagwörter')),
+ ('sources', models.JSONField(blank=True, default=list, verbose_name='Quellen')),
+ ('counter_arguments', models.JSONField(blank=True, default=list, verbose_name='Gegenargumente')),
+ ('conversation', models.JSONField(blank=True, default=list, verbose_name='Konversation')),
+ ('ai_raw_response', models.JSONField(blank=True, null=True, verbose_name='KI-Rohantwort')),
+ ('error_message', models.TextField(blank=True, verbose_name='Fehlermeldung')),
+ ('created_at', models.DateTimeField(auto_now_add=True, verbose_name='Erstellt am')),
+ ('updated_at', models.DateTimeField(auto_now=True, verbose_name='Aktualisiert am')),
+ ('created_by', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='claim_drafts', to=settings.AUTH_USER_MODEL, verbose_name='Erstellt von')),
+ ('published_claim', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='origin_drafts', to='claims.claim', verbose_name='Veröffentlichte Behauptung')),
+ ],
+ options={
+ 'verbose_name': 'Fakten-Entwurf',
+ 'verbose_name_plural': 'Fakten-Entwürfe',
+ 'ordering': ['-created_at'],
+ },
+ ),
+ ]
diff --git a/claims/migrations/0004_alter_claimdraft_uploaded_file_and_more.py b/claims/migrations/0004_alter_claimdraft_uploaded_file_and_more.py
new file mode 100644
index 0000000..c424bc4
--- /dev/null
+++ b/claims/migrations/0004_alter_claimdraft_uploaded_file_and_more.py
@@ -0,0 +1,25 @@
+# Generated by Django 5.2.15 on 2026-07-07 11:13
+
+import claims.models
+import django.core.validators
+from django.db import migrations, models
+
+
+class Migration(migrations.Migration):
+
+ dependencies = [
+ ('claims', '0003_claimdraft'),
+ ]
+
+ operations = [
+ migrations.AlterField(
+ model_name='claimdraft',
+ name='uploaded_file',
+ field=models.FileField(blank=True, help_text='Optional: Bild oder PDF als Kontext', max_length=500, upload_to='drafts/uploads/%Y/%m/', validators=[django.core.validators.FileExtensionValidator(allowed_extensions=['jpg', 'jpeg', 'png', 'gif', 'webp', 'pdf'])], verbose_name='Hochgeladene Datei'),
+ ),
+ migrations.AlterField(
+ model_name='evidencefile',
+ name='file',
+ field=models.FileField(help_text='Bilder (JPG, PNG, GIF, WebP, SVG) oder PDF', max_length=500, upload_to=claims.models.evidence_upload_path, validators=[django.core.validators.FileExtensionValidator(allowed_extensions=['jpg', 'jpeg', 'png', 'gif', 'webp', 'svg', 'pdf'])], verbose_name='Datei'),
+ ),
+ ]
diff --git a/claims/migrations/0005_allow_svg_draft_upload.py b/claims/migrations/0005_allow_svg_draft_upload.py
new file mode 100644
index 0000000..217ae79
--- /dev/null
+++ b/claims/migrations/0005_allow_svg_draft_upload.py
@@ -0,0 +1,19 @@
+# Generated by Django 5.2.15 on 2026-07-07 12:05
+
+import django.core.validators
+from django.db import migrations, models
+
+
+class Migration(migrations.Migration):
+
+ dependencies = [
+ ('claims', '0004_alter_claimdraft_uploaded_file_and_more'),
+ ]
+
+ operations = [
+ migrations.AlterField(
+ model_name='claimdraft',
+ name='uploaded_file',
+ field=models.FileField(blank=True, help_text='Optional: Bild oder PDF als Kontext', max_length=500, upload_to='drafts/uploads/%Y/%m/', validators=[django.core.validators.FileExtensionValidator(allowed_extensions=['jpg', 'jpeg', 'png', 'gif', 'webp', 'svg', 'pdf'])], verbose_name='Hochgeladene Datei'),
+ ),
+ ]
diff --git a/claims/models.py b/claims/models.py
index 62b754a..de14bb3 100644
--- a/claims/models.py
+++ b/claims/models.py
@@ -1,3 +1,7 @@
+import os
+import uuid
+
+from django.contrib.auth.models import User
from django.core.validators import FileExtensionValidator
from django.db import models
from django.urls import reverse
@@ -5,6 +9,7 @@ from django.utils.text import slugify
EVIDENCE_FILE_EXTENSIONS = ['jpg', 'jpeg', 'png', 'gif', 'webp', 'svg', 'pdf']
IMAGE_EXTENSIONS = {'jpg', 'jpeg', 'png', 'gif', 'webp', 'svg'}
+DRAFT_UPLOAD_EXTENSIONS = ['jpg', 'jpeg', 'png', 'gif', 'webp', 'svg', 'pdf']
class Tag(models.Model):
@@ -143,7 +148,11 @@ class Claim(models.Model):
def evidence_upload_path(instance, filename):
- return f'evidence/{instance.claim.slug}/{filename}'
+ ext = os.path.splitext(filename)[1].lower().lstrip('.') or 'bin'
+ if ext not in EVIDENCE_FILE_EXTENSIONS:
+ ext = 'bin'
+ claim_id = instance.claim_id or 'new'
+ return f'evidence/{claim_id}/{uuid.uuid4().hex[:12]}.{ext}'
class EvidenceFile(models.Model):
@@ -157,6 +166,7 @@ class EvidenceFile(models.Model):
file = models.FileField(
'Datei',
upload_to=evidence_upload_path,
+ max_length=500,
validators=[FileExtensionValidator(allowed_extensions=EVIDENCE_FILE_EXTENSIONS)],
help_text='Bilder (JPG, PNG, GIF, WebP, SVG) oder PDF',
)
@@ -190,6 +200,10 @@ class EvidenceFile(models.Model):
def is_pdf(self):
return self.file_extension == 'pdf'
+ @property
+ def image_alt(self):
+ return self.caption or 'Nachweis-Grafik'
+
class Source(models.Model):
claim = models.ForeignKey(
@@ -229,3 +243,112 @@ class CounterArgument(models.Model):
def __str__(self):
return self.argument[:80]
+
+
+def draft_upload_path(instance, filename):
+ return f'drafts/{instance.pk or "new"}/{filename}'
+
+
+class ClaimDraft(models.Model):
+ class DraftStatus(models.TextChoices):
+ PROCESSING = 'processing', 'Wird verarbeitet'
+ REVIEW = 'review', 'Zur Prüfung'
+ PUBLISHED = 'published', 'Veröffentlicht'
+ FAILED = 'failed', 'Fehlgeschlagen'
+ REJECTED = 'rejected', 'Abgelehnt'
+
+ created_by = models.ForeignKey(
+ User,
+ on_delete=models.CASCADE,
+ related_name='claim_drafts',
+ verbose_name='Erstellt von',
+ )
+ status = models.CharField(
+ 'Status',
+ max_length=20,
+ choices=DraftStatus.choices,
+ default=DraftStatus.PROCESSING,
+ )
+ article_url = models.URLField('Artikel-URL', max_length=500, blank=True)
+ user_notes = models.TextField('Eingabe / Beschreibung', blank=True)
+ uploaded_file = models.FileField(
+ 'Hochgeladene Datei',
+ upload_to='drafts/uploads/%Y/%m/',
+ max_length=500,
+ blank=True,
+ validators=[FileExtensionValidator(allowed_extensions=DRAFT_UPLOAD_EXTENSIONS)],
+ help_text='Optional: Bild oder PDF als Kontext',
+ )
+ article_text = models.TextField('Extrahierter Artikeltext', blank=True)
+
+ category_slug = models.SlugField(
+ 'Bestehende Kategorie (Slug)',
+ max_length=220,
+ blank=True,
+ help_text='Slug einer vorhandenen Kategorie, falls passend',
+ )
+ new_category_name = models.CharField('Neue Kategorie', max_length=200, blank=True)
+ new_category_description = models.TextField('Beschreibung neue Kategorie', blank=True)
+ new_category_icon = models.CharField(
+ 'Icon neue Kategorie',
+ max_length=50,
+ blank=True,
+ help_text='Bootstrap-Icon-Klasse, z. B. bi-thermometer-half',
+ )
+
+ title = models.CharField('Behauptung', max_length=300, blank=True)
+ claim_status = models.CharField(
+ 'Einordnung',
+ max_length=40,
+ choices=Claim.Status.choices,
+ blank=True,
+ )
+ short_answer = models.TextField('Kurzantwort', blank=True)
+ detailed_explanation = models.TextField('Ausführliche Erklärung', blank=True)
+ evidence_level = models.CharField(
+ 'Evidenzlevel',
+ max_length=20,
+ choices=Claim.EvidenceLevel.choices,
+ blank=True,
+ )
+ tags = models.JSONField('Schlagwörter', default=list, blank=True)
+ sources = models.JSONField('Quellen', default=list, blank=True)
+ counter_arguments = models.JSONField('Gegenargumente', default=list, blank=True)
+ conversation = models.JSONField('Konversation', default=list, blank=True)
+ ai_raw_response = models.JSONField('KI-Rohantwort', blank=True, null=True)
+ error_message = models.TextField('Fehlermeldung', blank=True)
+ published_claim = models.ForeignKey(
+ Claim,
+ on_delete=models.SET_NULL,
+ null=True,
+ blank=True,
+ related_name='origin_drafts',
+ verbose_name='Veröffentlichte Behauptung',
+ )
+ created_at = models.DateTimeField('Erstellt am', auto_now_add=True)
+ updated_at = models.DateTimeField('Aktualisiert am', auto_now=True)
+
+ class Meta:
+ verbose_name = 'Fakten-Entwurf'
+ verbose_name_plural = 'Fakten-Entwürfe'
+ ordering = ['-created_at']
+
+ def __str__(self):
+ return self.title or f'Entwurf #{self.pk}'
+
+ def get_admin_review_url(self):
+ return reverse('admin:claims_claimdraft_review', args=[self.pk])
+
+ def get_admin_assistant_url(self):
+ return reverse('admin:claims_claimdraft_assistant')
+
+ def add_message(self, role, content):
+ from django.utils import timezone
+
+ messages = list(self.conversation or [])
+ messages.append({
+ 'role': role,
+ 'content': content,
+ 'at': timezone.now().isoformat(),
+ })
+ self.conversation = messages
diff --git a/claims/services/__init__.py b/claims/services/__init__.py
new file mode 100644
index 0000000..e69de29
diff --git a/claims/services/ai_assistant.py b/claims/services/ai_assistant.py
new file mode 100644
index 0000000..5fefc63
--- /dev/null
+++ b/claims/services/ai_assistant.py
@@ -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'', '', 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)
diff --git a/claims/services/article_fetcher.py b/claims/services/article_fetcher.py
new file mode 100644
index 0000000..783a02b
--- /dev/null
+++ b/claims/services/article_fetcher.py
@@ -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)
diff --git a/claims/services/draft_processor.py b/claims/services/draft_processor.py
new file mode 100644
index 0000000..17fe4db
--- /dev/null
+++ b/claims/services/draft_processor.py
@@ -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
diff --git a/claims/services/draft_publisher.py b/claims/services/draft_publisher.py
new file mode 100644
index 0000000..7a52ca5
--- /dev/null
+++ b/claims/services/draft_publisher.py
@@ -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)
diff --git a/claims/templatetags/claims_extras.py b/claims/templatetags/claims_extras.py
index f81d014..118c35f 100644
--- a/claims/templatetags/claims_extras.py
+++ b/claims/templatetags/claims_extras.py
@@ -13,3 +13,13 @@ def status_label(value):
def evidence_label(value):
from claims.models import Claim
return dict(Claim.EvidenceLevel.choices).get(value, value)
+
+
+@register.inclusion_tag('claims/partials/status_badge.html')
+def status_badge(claim):
+ return {'claim': claim}
+
+
+@register.inclusion_tag('claims/partials/evidence_badge.html')
+def evidence_badge(claim):
+ return {'claim': claim}
diff --git a/claims/tests.py b/claims/tests.py
index 7ce503c..ae74ddf 100644
--- a/claims/tests.py
+++ b/claims/tests.py
@@ -1,3 +1,77 @@
-from django.test import TestCase
+from django.test import SimpleTestCase
-# Create your tests here.
+from claims.services.ai_assistant import _image_message_part, _svg_to_png_bytes
+from claims.services.article_fetcher import (
+ ArticleFetchError,
+ _extract_readable_text,
+ validate_article_url,
+)
+
+
+class ArticleFetcherSecurityTests(SimpleTestCase):
+ def test_blocks_file_scheme(self):
+ with self.assertRaises(ArticleFetchError):
+ validate_article_url('file:///etc/passwd')
+
+ def test_blocks_localhost(self):
+ with self.assertRaises(ArticleFetchError):
+ validate_article_url('http://localhost/article')
+
+ def test_allows_https_url(self):
+ validate_article_url('https://example.com/artikel')
+
+
+class ArticleFetcherExtractionTests(SimpleTestCase):
+ def test_strips_navigation_and_ads(self):
+ html = """
+
+ Navigation mit vielen Links die ignoriert werden sollen
+
+
+ Echter Artikeltitel über Klimawandel und Energiepolitik
+ Dies ist der eigentliche Artikeltext mit genügend Inhalt für die Extraktion.
+ Ein zweiter Absatz mit relevanten Fakten zur Erderwärmung und Treibhausgasen.
+
+ Impressum und Datenschutz und Cookie-Hinweise überall
+
+ """
+ text = _extract_readable_text(html)
+ self.assertIn('Klimawandel', text)
+ self.assertNotIn('Navigation mit vielen', text)
+ self.assertNotIn('Werbung für ein tolles', text)
+ self.assertNotIn('Impressum', text)
+
+ def test_handles_tags_without_attrs(self):
+ html = """
+
+
+ Überschrift mit genügend Zeichen für den Parser hier
+ Absatz mit ausreichend Inhalt damit die Extraktion erfolgreich klappt.
+
+
+ """
+ text = _extract_readable_text(html)
+ self.assertIn('Überschrift', text)
+
+
+class AIAssistantUploadTests(SimpleTestCase):
+ def test_svg_converts_to_png_for_openai(self):
+ svg = b'''
+
+
+ Test
+ '''
+ png = _svg_to_png_bytes(svg)
+ self.assertIsNotNone(png)
+ self.assertTrue(png.startswith(b'\x89PNG'))
+
+ def test_svg_message_part_uses_png_data_url(self):
+ from django.core.files.uploadedfile import SimpleUploadedFile
+
+ svg = b'Faktencheck '
+ upload = SimpleUploadedFile('chart.svg', svg, content_type='image/svg+xml')
+ part = _image_message_part(upload)
+ if part['type'] == 'image_url':
+ self.assertIn('image/png', part['image_url']['url'])
+ else:
+ self.assertIn('SVG-Grafik', part['text'])
diff --git a/claims/views.py b/claims/views.py
index 830588d..b1fe758 100644
--- a/claims/views.py
+++ b/claims/views.py
@@ -4,6 +4,10 @@ from django.shortcuts import get_object_or_404, render
from .models import Category, Claim
+def _is_htmx_partial(request):
+ return request.headers.get('X-Requested-With') == 'XMLHttpRequest'
+
+
def home(request):
query = request.GET.get('q', '').strip()
categories = Category.objects.all()
@@ -18,6 +22,8 @@ def home(request):
'query': query,
'total_claims': Claim.objects.count(),
}
+ if _is_htmx_partial(request):
+ return render(request, 'claims/partials/category_list.html', context)
return render(request, 'claims/home.html', context)
@@ -45,6 +51,8 @@ def category_detail(request, slug):
'status': status,
'status_choices': Claim.Status.choices,
}
+ if _is_htmx_partial(request):
+ return render(request, 'claims/partials/claim_list.html', context)
return render(request, 'claims/category_detail.html', context)
@@ -99,4 +107,6 @@ def search(request):
'status_choices': Claim.Status.choices,
'result_count': claims.count() if query or category_slug or status else 0,
}
+ if _is_htmx_partial(request):
+ return render(request, 'claims/partials/search_results.html', context)
return render(request, 'claims/search.html', context)
diff --git a/faktenkompass/settings.py b/faktenkompass/settings.py
index e9a0fb9..7565ffc 100644
--- a/faktenkompass/settings.py
+++ b/faktenkompass/settings.py
@@ -21,12 +21,19 @@ BASE_DIR = Path(__file__).resolve().parent.parent
# See https://docs.djangoproject.com/en/5.2/howto/deployment/checklist/
# SECURITY WARNING: keep the secret key used in production secret!
-SECRET_KEY = 'django-insecure-^&jd*%g0yb!yj#do6!pt!xqftz%m9zuzc6g@o0i&dji5cy7!qw'
+SECRET_KEY = os.environ.get(
+ 'DJANGO_SECRET_KEY',
+ 'django-insecure-^&jd*%g0yb!yj#do6!pt!xqftz%m9zuzc6g@o0i&dji5cy7!qw',
+)
# SECURITY WARNING: don't run with debug turned on in production!
-DEBUG = True
+DEBUG = os.environ.get('DJANGO_DEBUG', 'True').lower() in ('true', '1', 'yes')
-ALLOWED_HOSTS = []
+ALLOWED_HOSTS = [
+ host.strip()
+ for host in os.environ.get('ALLOWED_HOSTS', '').split(',')
+ if host.strip()
+]
# Application definition
@@ -122,10 +129,22 @@ USE_TZ = True
STATIC_URL = 'static/'
STATICFILES_DIRS = [BASE_DIR / 'static']
+STATIC_ROOT = BASE_DIR / 'staticfiles'
MEDIA_URL = 'media/'
MEDIA_ROOT = BASE_DIR / 'media'
+# OpenAI / Fakten-Assistent
+OPENAI_API_KEY = os.environ.get('OPENAI_API_KEY', '')
+OPENAI_MODEL = os.environ.get('OPENAI_MODEL', 'gpt-4o-mini')
+OPENAI_MODEL_VISION = os.environ.get('OPENAI_MODEL_VISION', 'gpt-4o')
+OPENAI_TIMEOUT = int(os.environ.get('OPENAI_TIMEOUT', '60'))
+ARTICLE_FETCH_TIMEOUT = int(os.environ.get('ARTICLE_FETCH_TIMEOUT', '15'))
+ARTICLE_FETCH_MAX_BYTES = int(os.environ.get('ARTICLE_FETCH_MAX_BYTES', '2000000'))
+ARTICLE_FETCH_TARGET_CHARS = int(os.environ.get('ARTICLE_FETCH_TARGET_CHARS', '8000'))
+ARTICLE_FETCH_OUTPUT_CHARS = int(os.environ.get('ARTICLE_FETCH_OUTPUT_CHARS', '12000'))
+ARTICLE_FETCH_PARTIAL_BYTES = int(os.environ.get('ARTICLE_FETCH_PARTIAL_BYTES', '96000'))
+
# Default primary key field type
# https://docs.djangoproject.com/en/5.2/ref/settings/#default-auto-field
diff --git a/faktenkompass/urls.py b/faktenkompass/urls.py
index 753166a..3e7a49f 100644
--- a/faktenkompass/urls.py
+++ b/faktenkompass/urls.py
@@ -19,8 +19,8 @@ from django.conf.urls.static import static
from django.contrib import admin
from django.urls import include, path
-admin.site.site_header = 'Faktenkompass Verwaltung'
-admin.site.site_title = 'Faktenkompass Admin'
+admin.site.site_header = 'Faktenkompass Redaktion'
+admin.site.site_title = 'Redaktion'
admin.site.index_title = 'Inhalte verwalten'
urlpatterns = [
diff --git a/requirements.txt b/requirements.txt
index af9b7e1..5fa7cda 100644
--- a/requirements.txt
+++ b/requirements.txt
@@ -1 +1,8 @@
Django>=5.0,<6.0
+daphne>=4.0.0
+openai>=1.40.0
+requests>=2.31.0
+beautifulsoup4>=4.12.0
+cairosvg>=2.7.0
+Pillow>=10.0.0
+psycopg2-binary>=2.9.0
diff --git a/static/css/admin_custom.css b/static/css/admin_custom.css
new file mode 100644
index 0000000..ccc3179
--- /dev/null
+++ b/static/css/admin_custom.css
@@ -0,0 +1,197 @@
+/* Faktenkompass – Redaktions-Admin */
+
+.fk-admin-dashboard {
+ display: grid;
+ grid-template-columns: repeat(auto-fit, minmax(220px, 1fr));
+ gap: 1rem;
+ margin-bottom: 1.5rem;
+}
+
+.fk-admin-card {
+ display: block;
+ padding: 1rem 1.1rem;
+ border: 1px solid #d1d5db;
+ border-radius: 10px;
+ background: #fff;
+ text-decoration: none;
+ color: #111827;
+ transition: border-color 0.15s, box-shadow 0.15s;
+}
+
+.fk-admin-card:hover {
+ border-color: #0d9488;
+ box-shadow: 0 2px 8px rgba(13, 148, 136, 0.12);
+ color: #111827;
+}
+
+.fk-admin-card strong {
+ display: block;
+ margin-bottom: 0.35rem;
+ font-size: 1rem;
+}
+
+.fk-admin-card span {
+ color: #6b7280;
+ font-size: 0.85rem;
+ line-height: 1.4;
+}
+
+.change-form .fk-admin-wide,
+.change-form .fk-admin-textarea,
+.change-form textarea.vLargeTextField,
+.change-form textarea.large {
+ width: 100% !important;
+ max-width: 100% !important;
+ box-sizing: border-box;
+ font-size: 0.95rem !important;
+ line-height: 1.5 !important;
+}
+
+.change-form .fk-admin-textarea,
+.change-form textarea.vLargeTextField,
+.change-form textarea.large {
+ min-height: 6rem;
+ padding: 0.65rem 0.75rem !important;
+}
+
+.change-form .fk-admin-wide {
+ max-width: 100%;
+}
+
+.module h2,
+.inline-group h2 {
+ font-size: 0.95rem;
+}
+
+.inline-group .tabular tr.has_original td {
+ padding-top: 0.75rem;
+ padding-bottom: 0.75rem;
+}
+
+/* Behauptung bearbeiten */
+.fk-claim-help {
+ margin: 0 0 1.25rem;
+ padding: 0.85rem 1rem;
+ border-left: 4px solid #0d9488;
+ background: #f0fdfa;
+ border-radius: 0 8px 8px 0;
+ font-size: 0.9rem;
+ color: #374151;
+}
+
+.fk-claim-meta-links {
+ margin-bottom: 1rem;
+}
+
+.fk-claim-meta-links a {
+ margin-right: 1rem;
+}
+
+.fk-evidence-preview {
+ margin: 0.5rem 0 0;
+}
+
+.fk-evidence-preview img {
+ display: block;
+ max-width: min(100%, 360px);
+ max-height: 240px;
+ object-fit: contain;
+ border: 1px solid #e5e7eb;
+ border-radius: 8px;
+ background: #f9fafb;
+ padding: 0.35rem;
+}
+
+.fk-evidence-preview a {
+ font-weight: 500;
+}
+
+.fk-evidence-preview-live {
+ margin-top: 0.5rem;
+}
+
+.fk-evidence-preview-live img {
+ max-width: min(100%, 280px);
+ max-height: 200px;
+ border: 1px dashed #9ca3af;
+ border-radius: 8px;
+}
+
+.inline-related fieldset.module {
+ margin-bottom: 1.25rem;
+}
+
+.inline-group .stacked .inline-related {
+ padding: 1rem;
+ margin-bottom: 0.75rem;
+ border: 1px solid #e5e7eb;
+ border-radius: 10px;
+ background: #fafafa;
+}
+
+.inline-group .stacked .inline-related h3 {
+ margin-top: 0;
+ padding: 0.35rem 0 0.75rem;
+ border-bottom: 1px solid #e5e7eb;
+}
+
+.field-public_link readonly {
+ font-size: 0.95rem;
+}
+
+@media (min-width: 1024px) {
+ .change-form .form-row .field-short_answer textarea,
+ .change-form .form-row .field-detailed_explanation textarea {
+ min-width: 100%;
+ }
+}
+
+/* Changelist: Suche über der Tabelle, Filter rechts */
+#changelist {
+ display: flex;
+ align-items: flex-start;
+ justify-content: space-between;
+ flex-direction: row;
+}
+
+#changelist .changelist-form-container {
+ display: block !important;
+ flex: 1 1 auto;
+ min-width: 0;
+ width: 100%;
+}
+
+#changelist #toolbar,
+#changelist .toplinks,
+#changelist #changelist-form,
+#changelist .results,
+#changelist .paginator {
+ display: block;
+ width: 100%;
+ max-width: 100%;
+ box-sizing: border-box;
+}
+
+#changelist #changelist-search {
+ display: block;
+ width: 100%;
+}
+
+#changelist-filter {
+ flex: 0 0 240px;
+ order: 1;
+ margin: 0 0 0 30px;
+}
+
+@media (max-width: 1024px) {
+ #changelist {
+ flex-direction: column !important;
+ }
+
+ #changelist-filter {
+ flex-basis: auto;
+ width: 100%;
+ margin: 1.5rem 0 0 !important;
+ order: 2;
+ }
+}
diff --git a/static/css/custom.css b/static/css/custom.css
index efb18fc..434d296 100644
--- a/static/css/custom.css
+++ b/static/css/custom.css
@@ -1,39 +1,172 @@
:root {
- --fk-primary: #1e4d7b;
- --fk-primary-dark: #163a5f;
- --fk-bg: #f4f6f8;
+ --fk-accent: #0d9488;
+ --fk-accent-hover: #0f766e;
+ --fk-bg: #f5f5f7;
+ --fk-surface: #ffffff;
+ --fk-border: #e5e7eb;
+ --fk-text: #111827;
+ --fk-muted: #6b7280;
+ --fk-nav-bg: rgba(255, 255, 255, 0.85);
+ --fk-radius: 12px;
+ --fk-font: 'Inter', system-ui, -apple-system, sans-serif;
+}
+
+[data-theme="dark"] {
+ --fk-accent: #2dd4bf;
+ --fk-accent-hover: #5eead4;
+ --fk-bg: #0f1117;
+ --fk-surface: #1a1d27;
+ --fk-border: #2d3348;
+ --fk-text: #f3f4f6;
+ --fk-muted: #9ca3af;
+ --fk-nav-bg: rgba(15, 17, 23, 0.9);
+}
+
+*,
+*::before,
+*::after {
+ box-sizing: border-box;
}
body {
+ font-family: var(--fk-font);
background-color: var(--fk-bg);
+ color: var(--fk-text);
+ -webkit-font-smoothing: antialiased;
}
-.navbar.bg-primary {
- background-color: var(--fk-primary) !important;
+/* Navigation */
+.fk-nav {
+ position: sticky;
+ top: 0;
+ z-index: 1030;
+ background: var(--fk-nav-bg);
+ backdrop-filter: blur(12px);
+ -webkit-backdrop-filter: blur(12px);
+ border-bottom: 1px solid var(--fk-border);
}
-.btn-primary {
- background-color: var(--fk-primary);
- border-color: var(--fk-primary);
+.fk-nav-inner {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ padding: 0.75rem 0;
}
-.btn-primary:hover,
-.btn-primary:focus {
- background-color: var(--fk-primary-dark);
- border-color: var(--fk-primary-dark);
-}
-
-.text-primary {
- color: var(--fk-primary) !important;
-}
-
-.page-header h1 {
+.fk-brand {
+ display: inline-flex;
+ align-items: center;
+ gap: 0.5rem;
font-weight: 600;
+ font-size: 1.05rem;
+ color: var(--fk-text);
+ text-decoration: none;
+ letter-spacing: -0.02em;
+}
+
+.fk-brand:hover {
+ color: var(--fk-accent);
+}
+
+.fk-brand-icon {
+ display: inline-flex;
+ align-items: center;
+ justify-content: center;
+ width: 1.75rem;
+ height: 1.75rem;
+ color: var(--fk-accent);
+ font-size: 1.15rem;
+}
+
+.fk-nav-links {
+ display: flex;
+ align-items: center;
+ gap: 1.25rem;
+}
+
+.fk-nav-link {
+ font-size: 0.9rem;
+ font-weight: 500;
+ color: var(--fk-muted);
+ text-decoration: none;
+ padding-bottom: 2px;
+ border-bottom: 2px solid transparent;
+ transition: color 0.15s, border-color 0.15s;
+}
+
+.fk-nav-link:hover {
+ color: var(--fk-text);
+}
+
+.fk-nav-link.active {
+ color: var(--fk-accent);
+ border-bottom-color: var(--fk-accent);
+}
+
+.fk-theme-btn {
+ display: inline-flex;
+ align-items: center;
+ justify-content: center;
+ width: 2rem;
+ height: 2rem;
+ padding: 0;
+ border: 1px solid var(--fk-border);
+ border-radius: 8px;
+ background: var(--fk-surface);
+ color: var(--fk-muted);
+ cursor: pointer;
+ transition: color 0.15s, border-color 0.15s;
+}
+
+.fk-theme-btn:hover {
+ color: var(--fk-text);
+ border-color: var(--fk-muted);
+}
+
+/* Layout */
+.fk-main {
+ padding: 1.25rem 0 2rem;
+}
+
+.fk-page-title {
+ font-size: 1.25rem;
+ font-weight: 600;
+ letter-spacing: -0.02em;
+ margin-bottom: 1rem;
+}
+
+.fk-footer {
+ border-top: 1px solid var(--fk-border);
+ padding: 1rem 0;
+ margin-top: auto;
+}
+
+.fk-footer p {
+ color: var(--fk-muted);
+ font-size: 0.8rem;
+ text-align: center;
+ margin: 0;
+}
+
+/* Cards */
+.fk-card,
+.category-tile,
+.claim-card,
+.card {
+ background: var(--fk-surface);
+ border: 1px solid var(--fk-border);
+ border-radius: var(--fk-radius);
+ box-shadow: none;
}
.category-tile,
.claim-card {
- border: 1px solid rgba(0, 0, 0, 0.06);
+ transition: border-color 0.15s;
+}
+
+a:hover .category-tile,
+a:hover .claim-card {
+ border-color: var(--fk-accent);
}
.category-tile {
@@ -41,48 +174,360 @@ body {
}
.category-tile-icon {
- width: 2.75rem;
- height: 2.75rem;
+ width: 2.5rem;
+ height: 2.5rem;
display: flex;
align-items: center;
justify-content: center;
- background: rgba(30, 77, 123, 0.08);
- color: var(--fk-primary);
- border-radius: 50%;
- font-size: 1.25rem;
+ color: var(--fk-accent);
+ font-size: 1.2rem;
}
.category-tile .card-title {
- line-height: 1.25;
+ font-size: 0.9rem;
+ font-weight: 600;
+ color: var(--fk-text);
+ line-height: 1.3;
}
.claim-card .card-body {
padding: 0.875rem 1rem;
}
+.claim-card .claim-title {
+ color: var(--fk-text);
+ font-size: 0.925rem;
+ line-height: 1.45;
+}
+
+/* Forms */
+.form-control,
+.form-select {
+ background-color: var(--fk-surface);
+ border-color: var(--fk-border);
+ color: var(--fk-text);
+ border-radius: 10px;
+ font-size: 0.9rem;
+}
+
+.form-control:focus,
+.form-select:focus {
+ background-color: var(--fk-surface);
+ border-color: var(--fk-accent);
+ color: var(--fk-text);
+ box-shadow: 0 0 0 3px color-mix(in srgb, var(--fk-accent) 18%, transparent);
+}
+
+.input-group-text {
+ background: var(--fk-surface);
+ border-color: var(--fk-border);
+ color: var(--fk-muted);
+ border-radius: 10px 0 0 10px;
+}
+
+.btn-primary {
+ background-color: var(--fk-accent);
+ border-color: var(--fk-accent);
+ border-radius: 10px;
+ font-weight: 500;
+ font-size: 0.9rem;
+}
+
+.btn-primary:hover,
+.btn-primary:focus {
+ background-color: var(--fk-accent-hover);
+ border-color: var(--fk-accent-hover);
+}
+
+.text-primary {
+ color: var(--fk-accent) !important;
+}
+
+.text-muted {
+ color: var(--fk-muted) !important;
+}
+
+.text-dark {
+ color: var(--fk-text) !important;
+}
+
+/* Breadcrumb */
+.breadcrumb {
+ font-size: 0.8rem;
+ margin-bottom: 1rem;
+ --bs-breadcrumb-divider-color: var(--fk-muted);
+}
+
+.breadcrumb-item a {
+ color: var(--fk-muted);
+ text-decoration: none;
+}
+
+.breadcrumb-item a:hover {
+ color: var(--fk-accent);
+}
+
+.breadcrumb-item.active {
+ color: var(--fk-text);
+}
+
+/* Status & meta */
+.fk-meta-stack {
+ display: flex;
+ flex-direction: column;
+ align-items: flex-start;
+ gap: 0.35rem;
+}
+
+.fk-status,
+.fk-evidence {
+ display: inline-flex;
+ align-items: center;
+ gap: 0.4rem;
+ font-size: 0.72rem;
+ font-weight: 500;
+ color: var(--fk-muted);
+ letter-spacing: 0.01em;
+}
+
+.fk-status-dot {
+ width: 6px;
+ height: 6px;
+ border-radius: 50%;
+ flex-shrink: 0;
+}
+
+.fk-status--falsch .fk-status-dot { background: #ef4444; }
+.fk-status--irrefuehrend .fk-status-dot { background: #f59e0b; }
+.fk-status--teilweise_richtig .fk-status-dot { background: #3b82f6; }
+.fk-status--unbelegt .fk-status-dot { background: #9ca3af; }
+.fk-status--wissenschaftlicher_konsens .fk-status-dot { background: #10b981; }
+.fk-status--offene_frage .fk-status-dot { background: #8b5cf6; }
+
+.fk-topic {
+ font-size: 0.72rem;
+ color: var(--fk-muted);
+ font-weight: 500;
+}
+
+/* Detail sections */
+.fk-section {
+ margin-bottom: 1.5rem;
+}
+
+.fk-section-label {
+ display: block;
+ font-size: 0.7rem;
+ font-weight: 600;
+ text-transform: uppercase;
+ letter-spacing: 0.06em;
+ color: var(--fk-muted);
+ margin: 0 0 0.625rem;
+}
+
+.fk-section-body {
+ padding: 1rem 1.125rem;
+ line-height: 1.6;
+ font-size: 0.9rem;
+ color: var(--fk-muted);
+}
+
+.fk-section-body p:last-child {
+ margin-bottom: 0;
+}
+
+.fk-answer-card {
+ border-left: 3px solid var(--fk-accent);
+ padding: 1rem 1.125rem;
+ margin-bottom: 1.5rem;
+}
+
+.fk-answer-card p:last-child {
+ margin-bottom: 0;
+ line-height: 1.6;
+ font-size: 0.95rem;
+}
+
+.fk-claim-title {
+ font-size: 1.15rem;
+ font-weight: 600;
+ letter-spacing: -0.02em;
+ line-height: 1.35;
+ margin-bottom: 0.75rem;
+}
+
+/* Evidence */
+.fk-img-open {
+ display: block;
+ width: 100%;
+ padding: 0;
+ border: 0;
+ background: transparent;
+ cursor: zoom-in;
+}
+
.evidence-img {
display: block;
max-height: 20rem;
object-fit: contain;
- background: #fff;
+ background: var(--fk-bg);
}
.evidence-card {
overflow: hidden;
}
-.breadcrumb {
+.fk-lightbox {
+ position: fixed;
+ inset: 0;
+ z-index: 2000;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ padding: 1rem;
+}
+
+.fk-lightbox[hidden] {
+ display: none !important;
+}
+
+.fk-lightbox-backdrop {
+ position: absolute;
+ inset: 0;
+ border: 0;
+ background: rgba(0, 0, 0, 0.88);
+ cursor: zoom-out;
+}
+
+.fk-lightbox-close {
+ position: absolute;
+ top: 1rem;
+ right: 1rem;
+ z-index: 2;
+ width: 2.5rem;
+ height: 2.5rem;
+ border: 0;
+ border-radius: 999px;
+ background: rgba(255, 255, 255, 0.12);
+ color: #fff;
+ font-size: 1.1rem;
+ cursor: pointer;
+}
+
+.fk-lightbox-close:hover {
+ background: rgba(255, 255, 255, 0.22);
+}
+
+.fk-lightbox-img {
+ position: relative;
+ z-index: 1;
+ max-width: min(100%, 1200px);
+ max-height: calc(100vh - 4rem);
+ object-fit: contain;
+ border-radius: 8px;
+}
+
+.fk-lightbox-caption {
+ position: absolute;
+ bottom: 1rem;
+ left: 50%;
+ z-index: 2;
+ transform: translateX(-50%);
+ max-width: min(90%, 40rem);
+ margin: 0;
+ padding: 0.5rem 0.75rem;
+ border-radius: 8px;
+ background: rgba(0, 0, 0, 0.55);
+ color: #fff;
font-size: 0.85rem;
- margin-bottom: 0.75rem;
+ text-align: center;
+}
+
+body.fk-lightbox-open {
+ overflow: hidden;
+}
+
+/* Accordion */
+.fk-section .accordion {
+ margin-top: 0;
+}
+
+.fk-accordion {
+ display: flex;
+ flex-direction: column;
+ gap: 0.5rem;
+}
+
+.fk-accordion .accordion-item {
+ background: var(--fk-surface);
+ border: 1px solid var(--fk-border);
+ border-radius: var(--fk-radius);
+ overflow: hidden;
+}
+
+.fk-accordion .accordion-button {
+ background: var(--fk-surface);
+ color: var(--fk-text);
+ font-size: 0.875rem;
+ box-shadow: none !important;
+ border-radius: var(--fk-radius);
+}
+
+.fk-accordion .accordion-button:not(.collapsed) {
+ background: color-mix(in srgb, var(--fk-accent) 8%, var(--fk-surface));
+ color: var(--fk-text);
+ border-radius: var(--fk-radius) var(--fk-radius) 0 0;
+}
+
+.fk-accordion .accordion-body {
+ color: var(--fk-muted);
+ font-size: 0.875rem;
+ background: var(--fk-surface);
+}
+
+.accordion-item {
+ background: var(--fk-surface);
+ border-color: var(--fk-border) !important;
+}
+
+.accordion-button {
+ background: var(--fk-surface);
+ color: var(--fk-text);
+ font-size: 0.875rem;
+ box-shadow: none !important;
}
.accordion-button:not(.collapsed) {
- background-color: rgba(30, 77, 123, 0.06);
- color: var(--fk-primary);
+ background: color-mix(in srgb, var(--fk-accent) 8%, var(--fk-surface));
+ color: var(--fk-text);
}
-.accordion-button:focus {
- box-shadow: 0 0 0 0.2rem rgba(30, 77, 123, 0.15);
+.accordion-body {
+ color: var(--fk-muted);
+ font-size: 0.875rem;
+}
+
+/* Tags */
+.fk-tag {
+ display: inline-block;
+ font-size: 0.72rem;
+ color: var(--fk-muted);
+ border: 1px solid var(--fk-border);
+ border-radius: 999px;
+ padding: 0.15rem 0.55rem;
+ background: var(--fk-surface);
+}
+
+/* Source links */
+.fk-source-link {
+ color: var(--fk-text);
+ text-decoration: none;
+ font-weight: 500;
+ font-size: 0.875rem;
+}
+
+.fk-source-link:hover {
+ color: var(--fk-accent);
}
@media (max-width: 576px) {
@@ -91,12 +536,16 @@ body {
}
.category-tile-icon {
- width: 2.25rem;
- height: 2.25rem;
- font-size: 1.1rem;
+ width: 2rem;
+ height: 2rem;
+ font-size: 1rem;
}
.category-tile .card-title {
+ font-size: 0.8rem;
+ }
+
+ .fk-nav-link {
font-size: 0.85rem;
}
}
diff --git a/static/js/admin_custom.js b/static/js/admin_custom.js
new file mode 100644
index 0000000..8be3937
--- /dev/null
+++ b/static/js/admin_custom.js
@@ -0,0 +1,32 @@
+(function () {
+ function showFilePreview(input) {
+ var file = input.files && input.files[0];
+ if (!file || !file.type.startsWith('image/')) {
+ return;
+ }
+
+ var row = input.closest('.inline-related') || input.closest('.form-row');
+ if (!row) {
+ return;
+ }
+
+ var box = row.querySelector('.fk-evidence-preview-live');
+ if (!box) {
+ box = document.createElement('div');
+ box.className = 'fk-evidence-preview-live';
+ input.parentNode.appendChild(box);
+ }
+
+ var reader = new FileReader();
+ reader.onload = function (event) {
+ box.innerHTML = ' ';
+ };
+ reader.readAsDataURL(file);
+ }
+
+ document.addEventListener('change', function (event) {
+ if (event.target.type === 'file' && event.target.name && event.target.name.indexOf('evidence_files') !== -1) {
+ showFilePreview(event.target);
+ }
+ });
+})();
diff --git a/static/js/image-lightbox.js b/static/js/image-lightbox.js
new file mode 100644
index 0000000..8de37db
--- /dev/null
+++ b/static/js/image-lightbox.js
@@ -0,0 +1,44 @@
+(function () {
+ document.addEventListener('DOMContentLoaded', function () {
+ var lightbox = document.getElementById('fk-lightbox');
+ if (!lightbox) {
+ return;
+ }
+
+ var img = lightbox.querySelector('.fk-lightbox-img');
+ var caption = lightbox.querySelector('.fk-lightbox-caption');
+ var closeBtn = lightbox.querySelector('.fk-lightbox-close');
+ var backdrop = lightbox.querySelector('.fk-lightbox-backdrop');
+
+ function openLightbox(src, alt) {
+ img.src = src;
+ img.alt = alt || '';
+ caption.textContent = alt || '';
+ caption.hidden = !alt;
+ lightbox.hidden = false;
+ document.body.classList.add('fk-lightbox-open');
+ closeBtn.focus();
+ }
+
+ function closeLightbox() {
+ lightbox.hidden = true;
+ img.src = '';
+ document.body.classList.remove('fk-lightbox-open');
+ }
+
+ document.querySelectorAll('.fk-img-open').forEach(function (button) {
+ button.addEventListener('click', function () {
+ openLightbox(button.dataset.src, button.dataset.caption);
+ });
+ });
+
+ closeBtn.addEventListener('click', closeLightbox);
+ backdrop.addEventListener('click', closeLightbox);
+
+ document.addEventListener('keydown', function (event) {
+ if (event.key === 'Escape' && !lightbox.hidden) {
+ closeLightbox();
+ }
+ });
+ });
+})();
diff --git a/static/js/live-search.js b/static/js/live-search.js
new file mode 100644
index 0000000..1898fd2
--- /dev/null
+++ b/static/js/live-search.js
@@ -0,0 +1,63 @@
+(function () {
+ const DEBOUNCE_MS = 300;
+
+ function debounce(fn, delay) {
+ let timer;
+ return function (...args) {
+ clearTimeout(timer);
+ timer = setTimeout(() => fn.apply(this, args), delay);
+ };
+ }
+
+ function buildUrl(form) {
+ const params = new URLSearchParams();
+ new FormData(form).forEach((value, key) => {
+ if (value) {
+ params.append(key, value);
+ }
+ });
+ const action = form.getAttribute('action') || window.location.pathname;
+ const query = params.toString();
+ return query ? action + '?' + query : action;
+ }
+
+ function runSearch(form) {
+ const target = document.querySelector(form.dataset.liveTarget);
+ if (!target) {
+ return;
+ }
+ const url = buildUrl(form);
+ fetch(url, {
+ headers: { 'X-Requested-With': 'XMLHttpRequest' },
+ })
+ .then((response) => {
+ if (!response.ok) {
+ throw new Error('search failed');
+ }
+ return response.text();
+ })
+ .then((html) => {
+ target.innerHTML = html;
+ history.replaceState(null, '', url);
+ })
+ .catch(() => {});
+ }
+
+ document.addEventListener('DOMContentLoaded', function () {
+ document.querySelectorAll('form[data-live-search]').forEach(function (form) {
+ const search = debounce(function () {
+ runSearch(form);
+ }, DEBOUNCE_MS);
+
+ form.querySelectorAll('input, select').forEach(function (field) {
+ field.addEventListener('input', search);
+ field.addEventListener('change', search);
+ });
+
+ form.addEventListener('submit', function (event) {
+ event.preventDefault();
+ runSearch(form);
+ });
+ });
+ });
+})();
diff --git a/static/js/theme.js b/static/js/theme.js
new file mode 100644
index 0000000..a496634
--- /dev/null
+++ b/static/js/theme.js
@@ -0,0 +1,35 @@
+(function () {
+ const storageKey = 'fk-theme';
+
+ function applyTheme(theme) {
+ document.documentElement.setAttribute('data-theme', theme);
+ const sun = document.querySelector('.fk-icon-sun');
+ const moon = document.querySelector('.fk-icon-moon');
+ if (sun && moon) {
+ sun.hidden = theme === 'dark';
+ moon.hidden = theme !== 'dark';
+ }
+ }
+
+ function preferredTheme() {
+ const stored = localStorage.getItem(storageKey);
+ if (stored === 'light' || stored === 'dark') {
+ return stored;
+ }
+ return window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light';
+ }
+
+ applyTheme(preferredTheme());
+
+ document.addEventListener('DOMContentLoaded', function () {
+ const toggle = document.getElementById('fk-theme-toggle');
+ if (!toggle) {
+ return;
+ }
+ toggle.addEventListener('click', function () {
+ const next = document.documentElement.getAttribute('data-theme') === 'dark' ? 'light' : 'dark';
+ localStorage.setItem(storageKey, next);
+ applyTheme(next);
+ });
+ });
+})();
diff --git a/templates/admin/base_site.html b/templates/admin/base_site.html
new file mode 100644
index 0000000..ef8368e
--- /dev/null
+++ b/templates/admin/base_site.html
@@ -0,0 +1,18 @@
+{% extends "admin/base.html" %}
+{% load static %}
+
+{% block title %}{% if subtitle %}{{ subtitle }} | {% endif %}{{ title }} | Faktenkompass Redaktion{% endblock %}
+
+{% block extrastyle %}
+{{ block.super }}
+
+{% endblock %}
+
+{% block extrahead %}
+{{ block.super }}
+
+{% endblock %}
+
+{% block branding %}
+
+{% endblock %}
diff --git a/templates/admin/claims/assistant.html b/templates/admin/claims/assistant.html
new file mode 100644
index 0000000..d30b5f0
--- /dev/null
+++ b/templates/admin/claims/assistant.html
@@ -0,0 +1,97 @@
+{% extends "admin/base_site.html" %}
+{% load i18n %}
+
+{% block extrastyle %}
+{{ block.super }}
+
+{% endblock %}
+
+{% block breadcrumbs %}
+
+{% endblock %}
+
+{% block content %}
+
+
Fakten-Assistent
+
+ Link, Bild und/oder Beschreibung eingeben. Die KI erstellt einen Entwurf zur Prüfung –
+ nichts wird automatisch veröffentlicht.
+
+
+
+
So funktioniert es:
+
+ Eingaben senden (Artikel-URL, Beschreibung, optional Bild/PDF)
+ KI analysiert den Kontext und schlägt Kategorie & Fakteneintrag vor
+ Sie prüfen, bearbeiten und veröffentlichen erst nach Freigabe
+
+
+
+
+
+ {% if recent_drafts %}
+
+ {% endif %}
+
+{% endblock %}
diff --git a/templates/admin/claims/claim/change_form.html b/templates/admin/claims/claim/change_form.html
new file mode 100644
index 0000000..2cee33e
--- /dev/null
+++ b/templates/admin/claims/claim/change_form.html
@@ -0,0 +1,17 @@
+{% extends "admin/change_form.html" %}
+{% load i18n %}
+
+{% block content %}
+{% if original and original.pk %}
+
+{% else %}
+
+ Neue Behauptung anlegen: zuerst Titel und Kurzantwort ausfüllen, dann speichern.
+ Nachweise, Quellen und Gegenargumente können danach ergänzt werden.
+
+{% endif %}
+{{ block.super }}
+{% endblock %}
diff --git a/templates/admin/claims/claimdraft/change_list.html b/templates/admin/claims/claimdraft/change_list.html
new file mode 100644
index 0000000..21940a9
--- /dev/null
+++ b/templates/admin/claims/claimdraft/change_list.html
@@ -0,0 +1,8 @@
+{% extends "admin/change_list.html" %}
+
+{% block object-tools-items %}
+
+ Fakten-Assistent
+
+{{ block.super }}
+{% endblock %}
diff --git a/templates/admin/claims/review_draft.html b/templates/admin/claims/review_draft.html
new file mode 100644
index 0000000..eb940da
--- /dev/null
+++ b/templates/admin/claims/review_draft.html
@@ -0,0 +1,158 @@
+{% extends "admin/base_site.html" %}
+{% load i18n %}
+
+{% block extrastyle %}
+{{ block.super }}
+
+{% endblock %}
+
+{% block breadcrumbs %}
+
+{% endblock %}
+
+{% block content %}
+Entwurf prüfen
+
+
+ Status: {{ draft.get_status_display }}
+ {% if draft.published_claim %}
+ · Veröffentlichte Behauptung öffnen
+ {% endif %}
+
+
+{% if draft.error_message %}
+{{ draft.error_message }}
+{% endif %}
+
+
+
+
+
+
+
Konversation
+ {% for msg in draft.conversation %}
+
+ {% if msg.role == 'user' %}Sie{% else %}Assistent{% endif %}:
+ {{ msg.content|linebreaksbr }}
+
+ {% empty %}
+
Noch keine Nachrichten.
+ {% endfor %}
+
+
+ {% if draft.uploaded_file %}
+
Datei: {{ draft.uploaded_file.name }}
+ {% endif %}
+ {% if draft.article_url %}
+
Artikel öffnen
+ {% endif %}
+
+ {% if draft.status != 'published' %}
+
+ {% endif %}
+
+
+{% endblock %}
diff --git a/templates/admin/index.html b/templates/admin/index.html
new file mode 100644
index 0000000..20e47f9
--- /dev/null
+++ b/templates/admin/index.html
@@ -0,0 +1,23 @@
+{% extends "admin/index.html" %}
+
+{% block content %}
+
+{{ block.super }}
+{% endblock %}
diff --git a/templates/base.html b/templates/base.html
index 2f6ac40..1866b45 100644
--- a/templates/base.html
+++ b/templates/base.html
@@ -6,35 +6,55 @@
{% block title %}Faktenkompass{% endblock %}
+
+
+
+
+
+
-
-