From db3558872e03a3b3480b63b848f4f4275e4a843b Mon Sep 17 00:00:00 2001 From: "Pirmin Hinderling (fedora)" Date: Tue, 7 Jul 2026 15:33:58 +0200 Subject: [PATCH] Faktenkompass: UI-Rebrand, Admin-Assistent und Produktions-Setup. MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- .env.example | 6 + README.md | 183 ++++++ claims/admin.py | 182 +++++- claims/admin_forms.py | 46 ++ claims/admin_views.py | 135 +++++ claims/forms.py | 159 ++++++ claims/migrations/0003_claimdraft.py | 52 ++ ...alter_claimdraft_uploaded_file_and_more.py | 25 + .../migrations/0005_allow_svg_draft_upload.py | 19 + claims/models.py | 125 ++++- claims/services/__init__.py | 0 claims/services/ai_assistant.py | 257 +++++++++ claims/services/article_fetcher.py | 203 +++++++ claims/services/draft_processor.py | 52 ++ claims/services/draft_publisher.py | 103 ++++ claims/templatetags/claims_extras.py | 10 + claims/tests.py | 78 ++- claims/views.py | 10 + faktenkompass/settings.py | 25 +- faktenkompass/urls.py | 4 +- requirements.txt | 7 + static/css/admin_custom.css | 197 +++++++ static/css/custom.css | 523 ++++++++++++++++-- static/js/admin_custom.js | 32 ++ static/js/image-lightbox.js | 44 ++ static/js/live-search.js | 63 +++ static/js/theme.js | 35 ++ templates/admin/base_site.html | 18 + templates/admin/claims/assistant.html | 97 ++++ templates/admin/claims/claim/change_form.html | 17 + .../admin/claims/claimdraft/change_list.html | 8 + templates/admin/claims/review_draft.html | 158 ++++++ templates/admin/index.html | 23 + templates/base.html | 44 +- templates/claims/category_detail.html | 28 +- templates/claims/claim_detail.html | 113 ++-- templates/claims/home.html | 38 +- templates/claims/partials/category_list.html | 27 + templates/claims/partials/claim_list.html | 20 + templates/claims/partials/evidence_badge.html | 4 + templates/claims/partials/search_results.html | 12 + templates/claims/partials/status_badge.html | 4 + templates/claims/search.html | 36 +- 43 files changed, 3014 insertions(+), 208 deletions(-) create mode 100644 .env.example create mode 100644 claims/admin_forms.py create mode 100644 claims/admin_views.py create mode 100644 claims/forms.py create mode 100644 claims/migrations/0003_claimdraft.py create mode 100644 claims/migrations/0004_alter_claimdraft_uploaded_file_and_more.py create mode 100644 claims/migrations/0005_allow_svg_draft_upload.py create mode 100644 claims/services/__init__.py create mode 100644 claims/services/ai_assistant.py create mode 100644 claims/services/article_fetcher.py create mode 100644 claims/services/draft_processor.py create mode 100644 claims/services/draft_publisher.py create mode 100644 static/css/admin_custom.css create mode 100644 static/js/admin_custom.js create mode 100644 static/js/image-lightbox.js create mode 100644 static/js/live-search.js create mode 100644 static/js/theme.js create mode 100644 templates/admin/base_site.html create mode 100644 templates/admin/claims/assistant.html create mode 100644 templates/admin/claims/claim/change_form.html create mode 100644 templates/admin/claims/claimdraft/change_list.html create mode 100644 templates/admin/claims/review_draft.html create mode 100644 templates/admin/index.html create mode 100644 templates/claims/partials/category_list.html create mode 100644 templates/claims/partials/claim_list.html create mode 100644 templates/claims/partials/evidence_badge.html create mode 100644 templates/claims/partials/search_results.html create mode 100644 templates/claims/partials/status_badge.html 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 = """ + + + +
+

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 = 'Vorschau'; + }; + 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:

+
    +
  1. Eingaben senden (Artikel-URL, Beschreibung, optional Bild/PDF)
  2. +
  3. KI analysiert den Kontext und schlägt Kategorie & Fakteneintrag vor
  4. +
  5. Sie prüfen, bearbeiten und veröffentlichen erst nach Freigabe
  6. +
+
+ +
+
+ {% csrf_token %} + {% if form.non_field_errors %} +
{{ form.non_field_errors }}
+ {% endif %} + +
+ + {{ form.article_url }} + {% if form.article_url.errors %}
{{ form.article_url.errors }}
{% endif %} +
+ +
+ + {{ form.user_notes }} + {% if form.user_notes.errors %}
{{ form.user_notes.errors }}
{% endif %} +
+ +
+ + {{ form.uploaded_file }} +
JPG, PNG, GIF, WebP, SVG oder PDF. SVG wird für die KI intern in PNG umgewandelt.
+
+ + +
+
+ + {% if recent_drafts %} +
+

Letzte Entwürfe

+ +
+ {% 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 %} + +
    +
    +
    + {% csrf_token %} + + +
    + + {{ form.category_choice }} +
    + +
    + + {{ form.new_category_name }} + {{ form.new_category_description }} + {{ form.new_category_icon }} +
    + +
    +
    + + {{ form.title }} +
    + +
    + + {{ form.claim_status }} +
    + +
    + + {{ form.evidence_level }} +
    + +
    + + {{ form.short_answer }} +
    + +
    + + {{ form.detailed_explanation }} +
    + +
    + + {{ form.tags_text }} +
    + +
    + + {{ form.sources_json }} +
    + +
    + + {{ form.counter_arguments_json }} +
    + +
    + + {% if draft.status != 'published' %} + + + {% 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' %} +
    + {% csrf_token %} + +

    Überarbeiten lassen

    + {{ refine_form.refinement }} + +
    + {% 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 %} + + + + + + - + -
    +
    {% block content %}{% endblock %}
    -