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

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

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Pirmin Hinderling (fedora)
2026-07-07 15:33:58 +02:00
parent 2c7fb7d030
commit db3558872e
43 changed files with 3014 additions and 208 deletions
+6
View File
@@ -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
+183
View File
@@ -80,6 +80,163 @@ export DB_PORT=5432
Installieren Sie zusätzlich `psycopg2-binary` und führen Sie `migrate` erneut aus. 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 ## 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). 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 ## Lizenz
Projekt für den internen Gebrauch / Bildungszwecke. Projekt für den internen Gebrauch / Bildungszwecke.
+165 -17
View File
@@ -1,36 +1,101 @@
from django.contrib import admin 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 model = EvidenceFile
form = EvidenceFileInlineForm
extra = 1 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('<span class="quiet">Nach dem Speichern sichtbar</span>')
if obj.is_image:
return format_html(
'<div class="fk-evidence-preview"><a href="{}" target="_blank" rel="noopener">'
'<img src="{}" alt="{}"></a></div>',
obj.file.url,
obj.file.url,
obj.image_alt,
)
if obj.is_pdf:
return format_html(
'<div class="fk-evidence-preview"><a href="{}" target="_blank" rel="noopener">'
'📄 PDF öffnen</a></div>',
obj.file.url,
)
return ''
class SourceInline(admin.TabularInline): class SourceInline(FaktenAdminMixin, admin.StackedInline):
model = Source model = Source
form = SourceInlineForm
extra = 1 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 model = CounterArgument
form = CounterArgumentInlineForm
extra = 1 extra = 1
fields = ('argument', 'response')
classes = ('collapse-open',)
verbose_name = 'Gegenargument'
verbose_name_plural = 'Gegenargumente'
@admin.register(Tag) @admin.register(Tag)
class TagAdmin(admin.ModelAdmin): class TagAdmin(FaktenAdminMixin, admin.ModelAdmin):
list_display = ('name', 'slug') list_display = ('name', 'slug')
prepopulated_fields = {'slug': ('name',)} prepopulated_fields = {'slug': ('name',)}
search_fields = ('name',) search_fields = ('name',)
@admin.register(Category) @admin.register(Category)
class CategoryAdmin(admin.ModelAdmin): class CategoryAdmin(FaktenAdminMixin, admin.ModelAdmin):
list_display = ('name', 'slug', 'claim_count', 'created_at') list_display = ('name', 'slug', 'claim_count', 'created_at')
prepopulated_fields = {'slug': ('name',)} prepopulated_fields = {'slug': ('name',)}
search_fields = ('name', 'description') 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') @admin.display(description='Anzahl Behauptungen')
def claim_count(self, obj): def claim_count(self, obj):
@@ -38,43 +103,81 @@ class CategoryAdmin(admin.ModelAdmin):
@admin.register(Claim) @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 = ('title', 'category', 'status', 'evidence_level', 'updated_at')
list_display_links = ('title',)
list_filter = ('category', 'status', 'evidence_level', 'tags') list_filter = ('category', 'status', 'evidence_level', 'tags')
search_fields = ('title', 'short_answer', 'detailed_explanation') search_fields = ('title', 'short_answer', 'detailed_explanation')
prepopulated_fields = {'slug': ('title',)} prepopulated_fields = {'slug': ('title',)}
filter_horizontal = ('tags',) filter_horizontal = ('tags',)
autocomplete_fields = ('category',)
save_on_top = True
date_hierarchy = 'updated_at'
inlines = [EvidenceFileInline, SourceInline, CounterArgumentInline] inlines = [EvidenceFileInline, SourceInline, CounterArgumentInline]
readonly_fields = ('public_link', 'created_at', 'updated_at')
fieldsets = ( fieldsets = (
(None, { ('Einordnung', {
'fields': ('category', 'title', 'slug', 'status', 'evidence_level', 'tags'), 'fields': ('category', 'status', 'evidence_level', 'tags'),
}), }),
('Inhalt', { ('Behauptung', {
'fields': ('title', 'slug', 'public_link'),
}),
('Texte', {
'fields': ('short_answer', 'detailed_explanation'), '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(
'<a href="{}" target="_blank" rel="noopener">Behauptung auf der Website öffnen</a>',
obj.get_absolute_url(),
)
@admin.register(EvidenceFile) @admin.register(EvidenceFile)
class EvidenceFileAdmin(admin.ModelAdmin): class EvidenceFileAdmin(FaktenAdminMixin, admin.ModelAdmin):
list_display = ('display_title', 'claim', 'file_extension_display', 'order', 'created_at') list_display = ('preview_thumb', 'display_title', 'claim', 'order', 'created_at')
list_filter = ('claim__category',) list_filter = ('claim__category',)
search_fields = ('title', 'caption', 'claim__title') search_fields = ('title', 'caption', 'claim__title')
readonly_fields = ('preview',)
fields = ('claim', 'preview', 'file', 'title', 'caption', 'order')
@admin.display(description='Typ') @admin.display(description='')
def file_extension_display(self, obj): def preview_thumb(self, obj):
if obj.is_image and obj.file:
return format_html(
'<img src="{}" style="max-width:48px;max-height:48px;object-fit:contain">',
obj.file.url,
)
return obj.file_extension.upper() return obj.file_extension.upper()
@admin.display(description='Vorschau')
def preview(self, obj):
return EvidenceFileInline.preview(self, obj)
@admin.register(Source) @admin.register(Source)
class SourceAdmin(admin.ModelAdmin): class SourceAdmin(FaktenAdminMixin, admin.ModelAdmin):
form = SourceInlineForm
list_display = ('title', 'organization', 'claim') list_display = ('title', 'organization', 'claim')
list_filter = ('claim__category',) list_filter = ('claim__category',)
search_fields = ('title', 'organization', 'description') search_fields = ('title', 'organization', 'description')
@admin.register(CounterArgument) @admin.register(CounterArgument)
class CounterArgumentAdmin(admin.ModelAdmin): class CounterArgumentAdmin(FaktenAdminMixin, admin.ModelAdmin):
form = CounterArgumentInlineForm
list_display = ('argument_preview', 'claim') list_display = ('argument_preview', 'claim')
list_filter = ('claim__category',) list_filter = ('claim__category',)
search_fields = ('argument', 'response') search_fields = ('argument', 'response')
@@ -82,3 +185,48 @@ class CounterArgumentAdmin(admin.ModelAdmin):
@admin.display(description='Gegenargument') @admin.display(description='Gegenargument')
def argument_preview(self, obj): def argument_preview(self, obj):
return obj.argument[:80] 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(
'<int:draft_id>/review/',
self.admin_site.admin_view(ReviewDraftView.as_view()),
name='claims_claimdraft_review',
),
]
return custom_urls + urls
+46
View File
@@ -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'}),
}
+135
View File
@@ -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)
+159
View File
@@ -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“',
}),
)
+52
View File
@@ -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'],
},
),
]
@@ -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'),
),
]
@@ -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'),
),
]
+124 -1
View File
@@ -1,3 +1,7 @@
import os
import uuid
from django.contrib.auth.models import User
from django.core.validators import FileExtensionValidator from django.core.validators import FileExtensionValidator
from django.db import models from django.db import models
from django.urls import reverse 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'] EVIDENCE_FILE_EXTENSIONS = ['jpg', 'jpeg', 'png', 'gif', 'webp', 'svg', 'pdf']
IMAGE_EXTENSIONS = {'jpg', 'jpeg', 'png', 'gif', 'webp', 'svg'} IMAGE_EXTENSIONS = {'jpg', 'jpeg', 'png', 'gif', 'webp', 'svg'}
DRAFT_UPLOAD_EXTENSIONS = ['jpg', 'jpeg', 'png', 'gif', 'webp', 'svg', 'pdf']
class Tag(models.Model): class Tag(models.Model):
@@ -143,7 +148,11 @@ class Claim(models.Model):
def evidence_upload_path(instance, filename): 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): class EvidenceFile(models.Model):
@@ -157,6 +166,7 @@ class EvidenceFile(models.Model):
file = models.FileField( file = models.FileField(
'Datei', 'Datei',
upload_to=evidence_upload_path, upload_to=evidence_upload_path,
max_length=500,
validators=[FileExtensionValidator(allowed_extensions=EVIDENCE_FILE_EXTENSIONS)], validators=[FileExtensionValidator(allowed_extensions=EVIDENCE_FILE_EXTENSIONS)],
help_text='Bilder (JPG, PNG, GIF, WebP, SVG) oder PDF', help_text='Bilder (JPG, PNG, GIF, WebP, SVG) oder PDF',
) )
@@ -190,6 +200,10 @@ class EvidenceFile(models.Model):
def is_pdf(self): def is_pdf(self):
return self.file_extension == 'pdf' return self.file_extension == 'pdf'
@property
def image_alt(self):
return self.caption or 'Nachweis-Grafik'
class Source(models.Model): class Source(models.Model):
claim = models.ForeignKey( claim = models.ForeignKey(
@@ -229,3 +243,112 @@ class CounterArgument(models.Model):
def __str__(self): def __str__(self):
return self.argument[:80] 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
View File
+257
View File
@@ -0,0 +1,257 @@
import base64
import json
import mimetypes
import re
from django.conf import settings
from claims.models import Category, Claim
VALID_CLAIM_STATUSES = {choice[0] for choice in Claim.Status.choices}
VALID_EVIDENCE_LEVELS = {choice[0] for choice in Claim.EvidenceLevel.choices}
SYSTEM_PROMPT = """Du bist ein sachlicher Fakten-Assistent für die Plattform „Faktenkompass“.
Deine Aufgabe: Aus Artikeln, Bildern und Nutzereingaben einen überprüfbaren Fakteneintrag erstellen.
Regeln:
- Schreibe auf Deutsch, neutral und wissenschaftlich.
- Keine parteipolitische Sprache, keine Übertreibungen.
- Die Behauptung (title) formuliert eine häufig geäußerte Aussage, die überprüft wird.
- Die Kurzantwort ist in ~20 Sekunden lesbar.
- Quellen müssen echte, nachvollziehbare Referenzen sein (URL aus Eingabe bevorzugen).
- Wähle eine passende bestehende Kategorie (use_existing_slug) ODER schlage eine neue vor.
- Icons für neue Kategorien: Bootstrap Icons Klassen wie bi-thermometer-half, bi-virus, bi-lightning-charge.
Antworte ausschließlich als gültiges JSON mit dieser Struktur:
{
"category": {
"use_existing_slug": "slug-oder-null",
"new_category": {
"name": "Name",
"description": "Kurzbeschreibung",
"icon": "bi-icon-name"
}
},
"claim": {
"title": "Die zu prüfende Behauptung",
"status": "falsch|irrefuehrend|teilweise_richtig|unbelegt|wissenschaftlicher_konsens|offene_frage",
"short_answer": "Kurze Einordnung",
"detailed_explanation": "Ausführlichere Erklärung",
"evidence_level": "sehr_stark|stark|mittel|schwach"
},
"tags": ["Schlagwort1", "Schlagwort2"],
"sources": [
{
"title": "Titel",
"organization": "Autor/Organisation",
"url": "https://...",
"description": "Kurzbeschreibung"
}
],
"counter_arguments": [
{
"argument": "Häufiges Gegenargument",
"response": "Sachliche Antwort"
}
],
"assistant_summary": "Kurze Zusammenfassung für den Admin (1-2 Sätze)"
}
"""
class AIAssistantError(Exception):
pass
def _categories_context():
categories = Category.objects.all().values('name', 'slug', 'description')
if not categories:
return 'Keine Kategorien vorhanden neue Kategorie vorschlagen.'
lines = []
for cat in categories:
lines.append(f"- {cat['name']} (slug: {cat['slug']}): {cat['description'][:120]}")
return '\n'.join(lines)
def _build_user_prompt(*, article_url='', article_text='', user_notes='', refinement=''):
parts = [
'Bestehende Kategorien:',
_categories_context(),
'',
f'Artikel-URL: {article_url or "(keine)"}',
'',
'Artikeltext:',
article_text or '(nicht verfügbar)',
'',
'Zusätzliche Hinweise des Admins:',
user_notes or '(keine)',
]
if refinement:
parts.extend(['', 'Überarbeitungswunsch:', refinement])
return '\n'.join(parts)
def _svg_to_png_bytes(svg_data):
try:
import cairosvg
except ImportError:
return None
try:
return cairosvg.svg2png(bytestring=svg_data)
except Exception:
return None
def _svg_text_fallback(svg_data):
text = svg_data.decode('utf-8', errors='replace')
text = re.sub(r'<script[^>]*>.*?</script>', '', text, flags=re.IGNORECASE | re.DOTALL)
return text[:4000]
def _image_data_url(data, mime_type):
encoded = base64.b64encode(data).decode('ascii')
return {
'type': 'image_url',
'image_url': {'url': f'data:{mime_type};base64,{encoded}'},
}
def _image_message_part(uploaded_file):
uploaded_file.open('rb')
try:
data = uploaded_file.read()
finally:
uploaded_file.close()
mime_type = mimetypes.guess_type(uploaded_file.name)[0]
if mime_type == 'application/pdf' or uploaded_file.name.lower().endswith('.pdf'):
return {
'type': 'text',
'text': '[PDF-Datei hochgeladen nutze vor allem die Admin-Beschreibung und den Artikeltext.]',
}
if mime_type == 'image/svg+xml' or uploaded_file.name.lower().endswith('.svg'):
png_data = _svg_to_png_bytes(data)
if png_data:
return _image_data_url(png_data, 'image/png')
return {
'type': 'text',
'text': (
'[SVG-Grafik hochgeladen OpenAI unterstützt SVG nicht direkt. '
'Nutze diesen XML-Auszug und die Admin-Beschreibung:]\n'
+ _svg_text_fallback(data)
),
}
mime_type = mime_type or 'image/jpeg'
if mime_type not in {'image/png', 'image/jpeg', 'image/gif', 'image/webp'}:
mime_type = 'image/jpeg'
return _image_data_url(data, mime_type)
def _call_openai(messages, *, has_image=False):
api_key = getattr(settings, 'OPENAI_API_KEY', '')
if not api_key:
raise AIAssistantError(
'OPENAI_API_KEY ist nicht gesetzt. Bitte in der Umgebung oder .env konfigurieren.'
)
try:
from openai import OpenAI
except ImportError as exc:
raise AIAssistantError('OpenAI-Paket fehlt. Bitte pip install -r requirements.txt ausführen.') from exc
model = settings.OPENAI_MODEL_VISION if has_image else settings.OPENAI_MODEL
client = OpenAI(api_key=api_key, timeout=settings.OPENAI_TIMEOUT)
response = client.chat.completions.create(
model=model,
messages=messages,
response_format={'type': 'json_object'},
temperature=0.3,
)
content = response.choices[0].message.content
if not content:
raise AIAssistantError('Leere Antwort von der KI erhalten.')
return json.loads(content)
def _validate_payload(payload):
claim = payload.get('claim') or {}
status = claim.get('status', '')
evidence = claim.get('evidence_level', '')
if status and status not in VALID_CLAIM_STATUSES:
raise AIAssistantError(f'Ungültiger Status von der KI: {status}')
if evidence and evidence not in VALID_EVIDENCE_LEVELS:
raise AIAssistantError(f'Ungültiges Evidenzlevel von der KI: {evidence}')
return payload
def generate_draft_payload(
*,
article_url='',
article_text='',
user_notes='',
uploaded_file=None,
refinement='',
previous_payload=None,
):
user_prompt = _build_user_prompt(
article_url=article_url,
article_text=article_text,
user_notes=user_notes,
refinement=refinement,
)
if previous_payload:
user_prompt += '\n\nBisheriger Entwurf (JSON):\n' + json.dumps(previous_payload, ensure_ascii=False)
user_content = [{'type': 'text', 'text': user_prompt}]
has_image = False
extra_text = ''
if uploaded_file and uploaded_file.name:
part = _image_message_part(uploaded_file)
if part.get('type') == 'image_url':
user_content.append(part)
has_image = True
elif part.get('type') == 'text':
extra_text = part['text']
if extra_text:
user_prompt = f'{user_prompt}\n\n{extra_text}'
if has_image:
user_content[0] = {'type': 'text', 'text': user_prompt}
user_message = user_content
else:
user_message = user_prompt
messages = [
{'role': 'system', 'content': SYSTEM_PROMPT},
{'role': 'user', 'content': user_message},
]
payload = _call_openai(messages, has_image=has_image)
return _validate_payload(payload)
def apply_payload_to_draft(draft, payload):
category = payload.get('category') or {}
claim = payload.get('claim') or {}
draft.category_slug = category.get('use_existing_slug') or ''
new_category = category.get('new_category') or {}
draft.new_category_name = new_category.get('name', '')
draft.new_category_description = new_category.get('description', '')
draft.new_category_icon = new_category.get('icon', '')
draft.title = claim.get('title', '')
draft.claim_status = claim.get('status', Claim.Status.UNBELEGT)
draft.short_answer = claim.get('short_answer', '')
draft.detailed_explanation = claim.get('detailed_explanation', '')
draft.evidence_level = claim.get('evidence_level', Claim.EvidenceLevel.MITTEL)
draft.tags = payload.get('tags') or []
draft.sources = payload.get('sources') or []
draft.counter_arguments = payload.get('counter_arguments') or []
draft.ai_raw_response = payload
summary = payload.get('assistant_summary', 'Entwurf wurde erstellt.')
draft.add_message('assistant', summary)
+203
View File
@@ -0,0 +1,203 @@
import ipaddress
import re
import socket
from urllib.parse import urlparse
import requests
from bs4 import BeautifulSoup
from django.conf import settings
BLOCKED_HOSTNAMES = {'localhost', '127.0.0.1', '0.0.0.0', '::1'}
MAX_DOWNLOAD_BYTES = getattr(settings, 'ARTICLE_FETCH_MAX_BYTES', 2_000_000)
TARGET_TEXT_CHARS = getattr(settings, 'ARTICLE_FETCH_TARGET_CHARS', 8_000)
MAX_OUTPUT_CHARS = getattr(settings, 'ARTICLE_FETCH_OUTPUT_CHARS', 12_000)
PARTIAL_PARSE_BYTES = getattr(settings, 'ARTICLE_FETCH_PARTIAL_BYTES', 96_000)
FETCH_TIMEOUT = getattr(settings, 'ARTICLE_FETCH_TIMEOUT', 15)
USER_AGENT = 'FaktenkompassBot/1.0 (+https://faktenkompass.local)'
REMOVE_TAGS = {
'script', 'style', 'nav', 'footer', 'header', 'aside', 'noscript', 'iframe',
'svg', 'form', 'button', 'input', 'select', 'textarea', 'menu', 'dialog',
'figure', 'picture', 'video', 'audio', 'canvas', 'embed', 'object',
}
NOISE_PATTERN = re.compile(
r'comment|sidebar|related|newsletter|cookie|banner|social|share|widget|'
r'menu|breadcrumb|promo|advert|tracking|popup|modal|consent|paywall|'
r'recommend|trending|tag-cloud|author-box|meta-|sharing|outbrain|taboola',
re.IGNORECASE,
)
MIN_PARAGRAPH_LENGTH = 40
class ArticleFetchError(Exception):
pass
def _hostname_resolves_to_private_ip(hostname):
try:
infos = socket.getaddrinfo(hostname, None)
except socket.gaierror as exc:
raise ArticleFetchError(f'Domain konnte nicht aufgelöst werden: {hostname}') from exc
for info in infos:
ip = ipaddress.ip_address(info[4][0])
if (
ip.is_private
or ip.is_loopback
or ip.is_link_local
or ip.is_reserved
or ip.is_multicast
):
return True
return False
def validate_article_url(url):
parsed = urlparse(url.strip())
if parsed.scheme not in {'http', 'https'}:
raise ArticleFetchError('Nur http- und https-URLs sind erlaubt.')
if not parsed.netloc:
raise ArticleFetchError('Ungültige URL.')
hostname = parsed.hostname or ''
if hostname.lower() in BLOCKED_HOSTNAMES:
raise ArticleFetchError('Diese URL ist nicht erlaubt.')
if _hostname_resolves_to_private_ip(hostname):
raise ArticleFetchError('Interne oder private URLs sind aus Sicherheitsgründen blockiert.')
def _remove_noise_elements(soup):
for tag_name in REMOVE_TAGS:
for tag in soup.find_all(tag_name):
tag.decompose()
to_remove = []
for element in soup.find_all(True):
element_attrs = getattr(element, 'attrs', None) or {}
role = element_attrs.get('role')
if role in {'navigation', 'banner', 'complementary', 'contentinfo'}:
to_remove.append(element)
continue
class_val = element_attrs.get('class', [])
if isinstance(class_val, list):
class_str = ' '.join(class_val)
else:
class_str = str(class_val or '')
id_str = str(element_attrs.get('id', '') or '')
combined = f'{class_str} {id_str}'.strip()
if combined and NOISE_PATTERN.search(combined):
to_remove.append(element)
for element in to_remove:
element.decompose()
def _find_content_root(soup):
for selector in (
('article', {}),
('main', {}),
('div', {'role': 'main'}),
('div', {'class': re.compile(r'article|content|post|entry|story', re.I)}),
):
tag, attrs = selector
match = soup.find(tag, attrs=attrs) if attrs else soup.find(tag)
if match:
return match
return soup.body or soup
def _collect_paragraphs(root):
paragraphs = []
seen = set()
for element in root.find_all(['h1', 'h2', 'h3', 'p', 'li', 'blockquote']):
text = element.get_text(' ', strip=True)
text = re.sub(r'\s+', ' ', text)
if len(text) < MIN_PARAGRAPH_LENGTH:
continue
key = text.lower()
if key in seen:
continue
seen.add(key)
paragraphs.append(text)
return paragraphs
def _extract_readable_text(html):
soup = BeautifulSoup(html, 'html.parser')
_remove_noise_elements(soup)
root = _find_content_root(soup)
paragraphs = _collect_paragraphs(root)
if not paragraphs:
fallback = root.get_text('\n', strip=True)
paragraphs = []
for line in fallback.splitlines():
line = re.sub(r'\s+', ' ', line.strip())
if len(line) >= MIN_PARAGRAPH_LENGTH:
paragraphs.append(line)
content = '\n\n'.join(paragraphs)
content = re.sub(r'\n{3,}', '\n\n', content)
return content.strip()
def _finalize_text(text, *, partial=False):
if len(text) < 100:
raise ArticleFetchError('Aus dem Artikel konnte kein ausreichender Text extrahiert werden.')
trimmed = text[:MAX_OUTPUT_CHARS]
if partial and len(text) > MAX_OUTPUT_CHARS:
trimmed += '\n\n[… Text gekürzt …]'
return trimmed
def _decode_html(chunks, encoding):
return b''.join(chunks).decode(encoding or 'utf-8', errors='replace')
def fetch_article_text(url):
validate_article_url(url)
try:
response = requests.get(
url,
timeout=FETCH_TIMEOUT,
headers={'User-Agent': USER_AGENT},
allow_redirects=True,
stream=True,
)
except requests.RequestException as exc:
raise ArticleFetchError(f'Artikel konnte nicht geladen werden: {exc}') from exc
if response.status_code >= 400:
raise ArticleFetchError(f'Artikel nicht erreichbar (HTTP {response.status_code}).')
final_host = urlparse(response.url).hostname or ''
if _hostname_resolves_to_private_ip(final_host):
raise ArticleFetchError('Weiterleitung auf eine blockierte URL.')
chunks = []
size = 0
encoding = response.encoding
truncated_download = False
for chunk in response.iter_content(chunk_size=8192):
if not chunk:
continue
size += len(chunk)
if size > MAX_DOWNLOAD_BYTES:
truncated_download = True
break
chunks.append(chunk)
if size >= PARTIAL_PARSE_BYTES and size % PARTIAL_PARSE_BYTES < 8192:
text = _extract_readable_text(_decode_html(chunks, encoding))
if len(text) >= TARGET_TEXT_CHARS:
return _finalize_text(text, partial=True)
if not chunks:
raise ArticleFetchError('Artikel konnte nicht geladen werden.')
text = _extract_readable_text(_decode_html(chunks, encoding))
return _finalize_text(text, partial=truncated_download)
+52
View File
@@ -0,0 +1,52 @@
from claims.models import ClaimDraft
from claims.services.ai_assistant import (
AIAssistantError,
apply_payload_to_draft,
generate_draft_payload,
)
from claims.services.article_fetcher import ArticleFetchError, fetch_article_text
def process_claim_draft(draft, *, refinement=''):
draft.error_message = ''
draft.status = ClaimDraft.DraftStatus.PROCESSING
draft.save(update_fields=['error_message', 'status', 'updated_at'])
user_message_parts = []
if draft.article_url:
user_message_parts.append(f'Link: {draft.article_url}')
if draft.user_notes:
user_message_parts.append(draft.user_notes)
if draft.uploaded_file:
user_message_parts.append(f'Datei: {draft.uploaded_file.name}')
if refinement:
user_message_parts.append(f'Überarbeitung: {refinement}')
draft.add_message('user', '\n'.join(user_message_parts) or 'Neuer Entwurf')
draft.save(update_fields=['conversation', 'updated_at'])
try:
article_text = draft.article_text
if draft.article_url and not refinement:
article_text = fetch_article_text(draft.article_url)
draft.article_text = article_text
draft.save(update_fields=['article_text', 'updated_at'])
payload = generate_draft_payload(
article_url=draft.article_url,
article_text=article_text,
user_notes=draft.user_notes,
uploaded_file=draft.uploaded_file if draft.uploaded_file else None,
refinement=refinement,
previous_payload=draft.ai_raw_response if refinement else None,
)
apply_payload_to_draft(draft, payload)
draft.status = ClaimDraft.DraftStatus.REVIEW
draft.save()
return draft
except (ArticleFetchError, AIAssistantError) as exc:
draft.status = ClaimDraft.DraftStatus.FAILED
draft.error_message = str(exc)
draft.add_message('assistant', f'Fehler: {exc}')
draft.save()
raise
+103
View File
@@ -0,0 +1,103 @@
import os
import uuid
from django.db import transaction
from django.utils.text import slugify
from claims.models import Category, Claim, CounterArgument, EvidenceFile, Source, Tag
class DraftPublishError(Exception):
pass
def _resolve_category(draft):
if draft.category_slug:
category = Category.objects.filter(slug=draft.category_slug).first()
if category:
return category
if draft.new_category_name:
category, _ = Category.objects.get_or_create(
slug=slugify(draft.new_category_name),
defaults={
'name': draft.new_category_name,
'description': draft.new_category_description or draft.new_category_name,
'icon': draft.new_category_icon or 'bi-book',
},
)
return category
raise DraftPublishError('Bitte eine Kategorie auswählen oder eine neue angeben.')
def _get_or_create_tags(tag_names):
tags = []
for name in tag_names or []:
clean = str(name).strip()
if not clean:
continue
tag, _ = Tag.objects.get_or_create(name=clean, defaults={'slug': slugify(clean)})
tags.append(tag)
return tags
@transaction.atomic
def publish_draft(draft):
if draft.status == draft.DraftStatus.PUBLISHED and draft.published_claim_id:
return draft.published_claim
if not draft.title or not draft.short_answer:
raise DraftPublishError('Titel und Kurzantwort sind erforderlich.')
category = _resolve_category(draft)
claim = Claim.objects.create(
category=category,
title=draft.title,
status=draft.claim_status or Claim.Status.UNBELEGT,
short_answer=draft.short_answer,
detailed_explanation=draft.detailed_explanation,
evidence_level=draft.evidence_level or Claim.EvidenceLevel.MITTEL,
)
claim.tags.set(_get_or_create_tags(draft.tags))
for source in draft.sources or []:
url = (source.get('url') or '').strip()
if not url:
continue
Source.objects.create(
claim=claim,
title=source.get('title') or url,
organization=source.get('organization') or 'Unbekannt',
url=url,
description=source.get('description', ''),
)
for counter in draft.counter_arguments or []:
argument = (counter.get('argument') or '').strip()
response = (counter.get('response') or '').strip()
if argument and response:
CounterArgument.objects.create(claim=claim, argument=argument, response=response)
if draft.uploaded_file:
_attach_uploaded_file(draft, claim)
draft.published_claim = claim
draft.status = draft.DraftStatus.PUBLISHED
draft.save(update_fields=['published_claim', 'status', 'updated_at'])
return claim
def _attach_uploaded_file(draft, claim):
if not draft.uploaded_file:
return
original_name = os.path.basename(draft.uploaded_file.name)
ext = original_name.rsplit('.', 1)[-1].lower() if '.' in original_name else ''
if ext not in {'jpg', 'jpeg', 'png', 'gif', 'webp', 'svg', 'pdf'}:
return
evidence = EvidenceFile(claim=claim, title='')
short_name = f'{uuid.uuid4().hex[:12]}.{ext}'
with draft.uploaded_file.open('rb') as source_file:
evidence.file.save(short_name, source_file, save=True)
+10
View File
@@ -13,3 +13,13 @@ def status_label(value):
def evidence_label(value): def evidence_label(value):
from claims.models import Claim from claims.models import Claim
return dict(Claim.EvidenceLevel.choices).get(value, value) 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}
+76 -2
View File
@@ -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 = """
<html><body>
<nav>Navigation mit vielen Links die ignoriert werden sollen</nav>
<div class="sidebar-promo">Werbung für ein tolles Angebot heute nur hier</div>
<article>
<h1>Echter Artikeltitel über Klimawandel und Energiepolitik</h1>
<p>Dies ist der eigentliche Artikeltext mit genügend Inhalt für die Extraktion.</p>
<p>Ein zweiter Absatz mit relevanten Fakten zur Erderwärmung und Treibhausgasen.</p>
</article>
<footer>Impressum und Datenschutz und Cookie-Hinweise überall</footer>
</body></html>
"""
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 = """
<html><body>
<article>
<h1>Überschrift mit genügend Zeichen für den Parser hier</h1>
<p>Absatz mit ausreichend Inhalt damit die Extraktion erfolgreich klappt.</p>
</article>
</body></html>
"""
text = _extract_readable_text(html)
self.assertIn('Überschrift', text)
class AIAssistantUploadTests(SimpleTestCase):
def test_svg_converts_to_png_for_openai(self):
svg = b'''<?xml version="1.0" encoding="UTF-8"?>
<svg xmlns="http://www.w3.org/2000/svg" width="100" height="100">
<rect width="100" height="100" fill="teal"/>
<text x="10" y="50">Test</text>
</svg>'''
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'<svg xmlns="http://www.w3.org/2000/svg"><text y="20">Faktencheck</text></svg>'
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'])
+10
View File
@@ -4,6 +4,10 @@ from django.shortcuts import get_object_or_404, render
from .models import Category, Claim from .models import Category, Claim
def _is_htmx_partial(request):
return request.headers.get('X-Requested-With') == 'XMLHttpRequest'
def home(request): def home(request):
query = request.GET.get('q', '').strip() query = request.GET.get('q', '').strip()
categories = Category.objects.all() categories = Category.objects.all()
@@ -18,6 +22,8 @@ def home(request):
'query': query, 'query': query,
'total_claims': Claim.objects.count(), '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) return render(request, 'claims/home.html', context)
@@ -45,6 +51,8 @@ def category_detail(request, slug):
'status': status, 'status': status,
'status_choices': Claim.Status.choices, '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) return render(request, 'claims/category_detail.html', context)
@@ -99,4 +107,6 @@ def search(request):
'status_choices': Claim.Status.choices, 'status_choices': Claim.Status.choices,
'result_count': claims.count() if query or category_slug or status else 0, '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) return render(request, 'claims/search.html', context)
+22 -3
View File
@@ -21,12 +21,19 @@ BASE_DIR = Path(__file__).resolve().parent.parent
# See https://docs.djangoproject.com/en/5.2/howto/deployment/checklist/ # See https://docs.djangoproject.com/en/5.2/howto/deployment/checklist/
# SECURITY WARNING: keep the secret key used in production secret! # 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! # 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 # Application definition
@@ -122,10 +129,22 @@ USE_TZ = True
STATIC_URL = 'static/' STATIC_URL = 'static/'
STATICFILES_DIRS = [BASE_DIR / 'static'] STATICFILES_DIRS = [BASE_DIR / 'static']
STATIC_ROOT = BASE_DIR / 'staticfiles'
MEDIA_URL = 'media/' MEDIA_URL = 'media/'
MEDIA_ROOT = BASE_DIR / '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 # Default primary key field type
# https://docs.djangoproject.com/en/5.2/ref/settings/#default-auto-field # https://docs.djangoproject.com/en/5.2/ref/settings/#default-auto-field
+2 -2
View File
@@ -19,8 +19,8 @@ from django.conf.urls.static import static
from django.contrib import admin from django.contrib import admin
from django.urls import include, path from django.urls import include, path
admin.site.site_header = 'Faktenkompass Verwaltung' admin.site.site_header = 'Faktenkompass Redaktion'
admin.site.site_title = 'Faktenkompass Admin' admin.site.site_title = 'Redaktion'
admin.site.index_title = 'Inhalte verwalten' admin.site.index_title = 'Inhalte verwalten'
urlpatterns = [ urlpatterns = [
+7
View File
@@ -1 +1,8 @@
Django>=5.0,<6.0 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
+197
View File
@@ -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;
}
}
+486 -37
View File
@@ -1,39 +1,172 @@
:root { :root {
--fk-primary: #1e4d7b; --fk-accent: #0d9488;
--fk-primary-dark: #163a5f; --fk-accent-hover: #0f766e;
--fk-bg: #f4f6f8; --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 { body {
font-family: var(--fk-font);
background-color: var(--fk-bg); background-color: var(--fk-bg);
color: var(--fk-text);
-webkit-font-smoothing: antialiased;
} }
.navbar.bg-primary { /* Navigation */
background-color: var(--fk-primary) !important; .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 { .fk-nav-inner {
background-color: var(--fk-primary); display: flex;
border-color: var(--fk-primary); align-items: center;
justify-content: space-between;
padding: 0.75rem 0;
} }
.btn-primary:hover, .fk-brand {
.btn-primary:focus { display: inline-flex;
background-color: var(--fk-primary-dark); align-items: center;
border-color: var(--fk-primary-dark); gap: 0.5rem;
}
.text-primary {
color: var(--fk-primary) !important;
}
.page-header h1 {
font-weight: 600; 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, .category-tile,
.claim-card { .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 { .category-tile {
@@ -41,48 +174,360 @@ body {
} }
.category-tile-icon { .category-tile-icon {
width: 2.75rem; width: 2.5rem;
height: 2.75rem; height: 2.5rem;
display: flex; display: flex;
align-items: center; align-items: center;
justify-content: center; justify-content: center;
background: rgba(30, 77, 123, 0.08); color: var(--fk-accent);
color: var(--fk-primary); font-size: 1.2rem;
border-radius: 50%;
font-size: 1.25rem;
} }
.category-tile .card-title { .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 { .claim-card .card-body {
padding: 0.875rem 1rem; 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 { .evidence-img {
display: block; display: block;
max-height: 20rem; max-height: 20rem;
object-fit: contain; object-fit: contain;
background: #fff; background: var(--fk-bg);
} }
.evidence-card { .evidence-card {
overflow: hidden; 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; 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) { .accordion-button:not(.collapsed) {
background-color: rgba(30, 77, 123, 0.06); background: color-mix(in srgb, var(--fk-accent) 8%, var(--fk-surface));
color: var(--fk-primary); color: var(--fk-text);
} }
.accordion-button:focus { .accordion-body {
box-shadow: 0 0 0 0.2rem rgba(30, 77, 123, 0.15); 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) { @media (max-width: 576px) {
@@ -91,12 +536,16 @@ body {
} }
.category-tile-icon { .category-tile-icon {
width: 2.25rem; width: 2rem;
height: 2.25rem; height: 2rem;
font-size: 1.1rem; font-size: 1rem;
} }
.category-tile .card-title { .category-tile .card-title {
font-size: 0.8rem;
}
.fk-nav-link {
font-size: 0.85rem; font-size: 0.85rem;
} }
} }
+32
View File
@@ -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 = '<img src="' + event.target.result + '" alt="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);
}
});
})();
+44
View File
@@ -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();
}
});
});
})();
+63
View File
@@ -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);
});
});
});
})();
+35
View File
@@ -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);
});
});
})();
+18
View File
@@ -0,0 +1,18 @@
{% extends "admin/base.html" %}
{% load static %}
{% block title %}{% if subtitle %}{{ subtitle }} | {% endif %}{{ title }} | Faktenkompass Redaktion{% endblock %}
{% block extrastyle %}
{{ block.super }}
<link rel="stylesheet" href="{% static 'css/admin_custom.css' %}">
{% endblock %}
{% block extrahead %}
{{ block.super }}
<script src="{% static 'js/admin_custom.js' %}" defer></script>
{% endblock %}
{% block branding %}
<div id="site-name"><a href="{% url 'admin:index' %}">Faktenkompass Redaktion</a></div>
{% endblock %}
+97
View File
@@ -0,0 +1,97 @@
{% extends "admin/base_site.html" %}
{% load i18n %}
{% block extrastyle %}
{{ block.super }}
<style>
.assistant-wrap { max-width: 820px; }
.chat-box {
background: #f8f9fa;
border: 1px solid #dee2e6;
border-radius: 8px;
padding: 1rem;
min-height: 120px;
margin-bottom: 1.5rem;
}
.chat-hint { color: #6c757d; font-size: 0.9rem; }
.assistant-card {
border: 1px solid #dee2e6;
border-radius: 8px;
padding: 1.25rem;
background: #fff;
}
</style>
{% endblock %}
{% block breadcrumbs %}
<div class="breadcrumbs">
<a href="{% url 'admin:index' %}">Start</a>
&rsaquo; <a href="{% url 'admin:claims_claimdraft_changelist' %}">Fakten-Entwürfe</a>
&rsaquo; Assistent
</div>
{% endblock %}
{% block content %}
<div class="assistant-wrap">
<h1>Fakten-Assistent</h1>
<p class="chat-hint">
Link, Bild und/oder Beschreibung eingeben. Die KI erstellt einen <strong>Entwurf zur Prüfung</strong>
nichts wird automatisch veröffentlicht.
</p>
<div class="chat-box">
<p class="mb-1"><strong>So funktioniert es:</strong></p>
<ol class="mb-0 ps-3">
<li>Eingaben senden (Artikel-URL, Beschreibung, optional Bild/PDF)</li>
<li>KI analysiert den Kontext und schlägt Kategorie &amp; Fakteneintrag vor</li>
<li>Sie prüfen, bearbeiten und veröffentlichen erst nach Freigabe</li>
</ol>
</div>
<div class="assistant-card">
<form method="post" enctype="multipart/form-data" novalidate>
{% csrf_token %}
{% if form.non_field_errors %}
<div class="alert alert-danger">{{ form.non_field_errors }}</div>
{% endif %}
<div class="mb-3">
<label class="form-label" for="{{ form.article_url.id_for_label }}">{{ form.article_url.label }}</label>
{{ form.article_url }}
{% if form.article_url.errors %}<div class="text-danger small">{{ form.article_url.errors }}</div>{% endif %}
</div>
<div class="mb-3">
<label class="form-label" for="{{ form.user_notes.id_for_label }}">{{ form.user_notes.label }}</label>
{{ form.user_notes }}
{% if form.user_notes.errors %}<div class="text-danger small">{{ form.user_notes.errors }}</div>{% endif %}
</div>
<div class="mb-3">
<label class="form-label" for="{{ form.uploaded_file.id_for_label }}">{{ form.uploaded_file.label }}</label>
{{ form.uploaded_file }}
<div class="form-text">JPG, PNG, GIF, WebP, SVG oder PDF. SVG wird für die KI intern in PNG umgewandelt.</div>
</div>
<button type="submit" class="button default">Entwurf generieren</button>
</form>
</div>
{% if recent_drafts %}
<div class="mt-4">
<h2 class="h6 text-muted">Letzte Entwürfe</h2>
<ul>
{% for draft in recent_drafts %}
<li>
<a href="{% url 'admin:claims_claimdraft_review' draft.pk %}">
{{ draft.title|default:"(ohne Titel)" }}
</a>
{{ draft.get_status_display }}
<span class="text-muted">({{ draft.created_at|date:"d.m.Y H:i" }})</span>
</li>
{% endfor %}
</ul>
</div>
{% endif %}
</div>
{% endblock %}
@@ -0,0 +1,17 @@
{% extends "admin/change_form.html" %}
{% load i18n %}
{% block content %}
{% if original and original.pk %}
<div class="fk-claim-meta-links">
<a href="{{ original.get_absolute_url }}" target="_blank" rel="noopener">↗ Auf Website ansehen</a>
<a href="{% url 'admin:claims_claimdraft_assistant' %}"> Fakten-Assistent</a>
</div>
{% else %}
<div class="fk-claim-help">
Neue Behauptung anlegen: zuerst Titel und Kurzantwort ausfüllen, dann speichern.
Nachweise, Quellen und Gegenargumente können danach ergänzt werden.
</div>
{% endif %}
{{ block.super }}
{% endblock %}
@@ -0,0 +1,8 @@
{% extends "admin/change_list.html" %}
{% block object-tools-items %}
<li>
<a href="{% url 'admin:claims_claimdraft_assistant' %}" class="addlink">Fakten-Assistent</a>
</li>
{{ block.super }}
{% endblock %}
+158
View File
@@ -0,0 +1,158 @@
{% extends "admin/base_site.html" %}
{% load i18n %}
{% block extrastyle %}
{{ block.super }}
<style>
.review-grid { display: grid; grid-template-columns: 1fr 320px; gap: 1.5rem; }
@media (max-width: 960px) { .review-grid { grid-template-columns: 1fr; } }
.preview-card, .chat-panel {
border: 1px solid #dee2e6;
border-radius: 8px;
background: #fff;
padding: 1rem;
}
.chat-panel { background: #f8f9fa; max-height: 420px; overflow-y: auto; }
.chat-msg { margin-bottom: 0.75rem; font-size: 0.92rem; }
.chat-msg.user { color: #0d3d66; }
.chat-msg.assistant { color: #333; }
.status-badge {
display: inline-block;
padding: 0.2rem 0.5rem;
border-radius: 4px;
font-size: 0.8rem;
background: #e9ecef;
}
.field-box { margin-bottom: 1rem; }
.field-box label { display: block; font-weight: 600; margin-bottom: 0.25rem; }
.actions-bar { display: flex; flex-wrap: wrap; gap: 0.5rem; margin-top: 1rem; }
</style>
{% endblock %}
{% block breadcrumbs %}
<div class="breadcrumbs">
<a href="{% url 'admin:index' %}">Start</a>
&rsaquo; <a href="{% url 'admin:claims_claimdraft_changelist' %}">Fakten-Entwürfe</a>
&rsaquo; <a href="{% url 'admin:claims_claimdraft_assistant' %}">Assistent</a>
&rsaquo; Prüfen
</div>
{% endblock %}
{% block content %}
<h1>Entwurf prüfen</h1>
<p>
Status: <span class="status-badge">{{ draft.get_status_display }}</span>
{% if draft.published_claim %}
· <a href="{% url 'admin:claims_claim_change' draft.published_claim.pk %}">Veröffentlichte Behauptung öffnen</a>
{% endif %}
</p>
{% if draft.error_message %}
<div class="alert alert-danger">{{ draft.error_message }}</div>
{% endif %}
<div class="review-grid">
<div>
<form method="post" class="preview-card">
{% csrf_token %}
<input type="hidden" name="action" value="save" id="form-action">
<div class="field-box">
<label for="{{ form.category_choice.id_for_label }}">{{ form.category_choice.label }}</label>
{{ form.category_choice }}
</div>
<div class="field-box">
<label for="{{ form.new_category_name.id_for_label }}">Neue Kategorie (falls leer oben)</label>
{{ form.new_category_name }}
{{ form.new_category_description }}
{{ form.new_category_icon }}
</div>
<hr>
<div class="field-box">
<label for="{{ form.title.id_for_label }}">{{ form.title.label }}</label>
{{ form.title }}
</div>
<div class="field-box">
<label for="{{ form.claim_status.id_for_label }}">{{ form.claim_status.label }}</label>
{{ form.claim_status }}
</div>
<div class="field-box">
<label for="{{ form.evidence_level.id_for_label }}">{{ form.evidence_level.label }}</label>
{{ form.evidence_level }}
</div>
<div class="field-box">
<label for="{{ form.short_answer.id_for_label }}">{{ form.short_answer.label }}</label>
{{ form.short_answer }}
</div>
<div class="field-box">
<label for="{{ form.detailed_explanation.id_for_label }}">{{ form.detailed_explanation.label }}</label>
{{ form.detailed_explanation }}
</div>
<div class="field-box">
<label for="{{ form.tags_text.id_for_label }}">{{ form.tags_text.label }}</label>
{{ form.tags_text }}
</div>
<div class="field-box">
<label for="{{ form.sources_json.id_for_label }}">{{ form.sources_json.label }}</label>
{{ form.sources_json }}
</div>
<div class="field-box">
<label for="{{ form.counter_arguments_json.id_for_label }}">{{ form.counter_arguments_json.label }}</label>
{{ form.counter_arguments_json }}
</div>
<div class="actions-bar">
<button type="submit" class="button" onclick="document.getElementById('form-action').value='save'">Speichern</button>
{% if draft.status != 'published' %}
<button type="submit" class="button default" onclick="document.getElementById('form-action').value='publish'">
Veröffentlichen
</button>
<button type="submit" class="button" formaction="." formmethod="post" name="action" value="reject"
onclick="return confirm('Entwurf wirklich verwerfen?')">Verwerfen</button>
{% endif %}
</div>
</form>
</div>
<div>
<div class="chat-panel mb-3">
<h2 class="h6">Konversation</h2>
{% for msg in draft.conversation %}
<div class="chat-msg {{ msg.role }}">
<strong>{% if msg.role == 'user' %}Sie{% else %}Assistent{% endif %}:</strong>
{{ msg.content|linebreaksbr }}
</div>
{% empty %}
<p class="text-muted mb-0">Noch keine Nachrichten.</p>
{% endfor %}
</div>
{% if draft.uploaded_file %}
<p class="small text-muted mb-2">Datei: {{ draft.uploaded_file.name }}</p>
{% endif %}
{% if draft.article_url %}
<p class="small mb-3"><a href="{{ draft.article_url }}" target="_blank" rel="noopener">Artikel öffnen</a></p>
{% endif %}
{% if draft.status != 'published' %}
<form method="post" class="preview-card">
{% csrf_token %}
<input type="hidden" name="action" value="refine">
<h2 class="h6">Überarbeiten lassen</h2>
{{ refine_form.refinement }}
<button type="submit" class="button mt-2">KI erneut fragen</button>
</form>
{% endif %}
</div>
</div>
{% endblock %}
+23
View File
@@ -0,0 +1,23 @@
{% extends "admin/index.html" %}
{% block content %}
<div class="fk-admin-dashboard">
<a class="fk-admin-card" href="{% url 'admin:claims_claim_add' %}">
<strong> Neue Behauptung</strong>
<span>Manuell anlegen und Texte, Quellen sowie Nachweise einpflegen.</span>
</a>
<a class="fk-admin-card" href="{% url 'admin:claims_claim_changelist' %}">
<strong>Behauptungen bearbeiten</strong>
<span>Bestehende Einträge suchen, aktualisieren und veröffentlichen.</span>
</a>
<a class="fk-admin-card" href="{% url 'admin:claims_claimdraft_assistant' %}">
<strong>Fakten-Assistent (KI)</strong>
<span>Aus Artikel oder Grafik einen Entwurf erstellen und prüfen.</span>
</a>
<a class="fk-admin-card" href="{% url 'admin:claims_category_changelist' %}">
<strong>Themen / Kategorien</strong>
<span>Kategorien verwalten, Icons und Beschreibungen anpassen.</span>
</a>
</div>
{{ block.super }}
{% endblock %}
+32 -12
View File
@@ -6,35 +6,55 @@
<meta name="viewport" content="width=device-width, initial-scale=1.0"> <meta name="viewport" content="width=device-width, initial-scale=1.0">
<meta name="description" content="{% block meta_description %}Faktenkompass Überprüfbare Fakten und Quellen.{% endblock %}"> <meta name="description" content="{% block meta_description %}Faktenkompass Überprüfbare Fakten und Quellen.{% endblock %}">
<title>{% block title %}Faktenkompass{% endblock %}</title> <title>{% block title %}Faktenkompass{% endblock %}</title>
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600&display=swap" rel="stylesheet">
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/css/bootstrap.min.css" rel="stylesheet"> <link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/css/bootstrap.min.css" rel="stylesheet">
<link href="https://cdn.jsdelivr.net/npm/bootstrap-icons@1.11.3/font/bootstrap-icons.min.css" rel="stylesheet"> <link href="https://cdn.jsdelivr.net/npm/bootstrap-icons@1.11.3/font/bootstrap-icons.min.css" rel="stylesheet">
<link href="{% static 'css/custom.css' %}" rel="stylesheet"> <link href="{% static 'css/custom.css' %}" rel="stylesheet">
<script>
(function () {
var t = localStorage.getItem('fk-theme');
if (!t) {
t = window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light';
}
document.documentElement.setAttribute('data-theme', t);
})();
</script>
<script src="{% static 'js/theme.js' %}" defer></script>
<script src="{% static 'js/live-search.js' %}" defer></script>
</head> </head>
<body class="d-flex flex-column min-vh-100"> <body class="d-flex flex-column min-vh-100">
<nav class="navbar navbar-dark bg-primary"> <header class="fk-nav">
<div class="container"> <div class="container fk-nav-inner">
<a class="navbar-brand" href="{% url 'claims:home' %}"> <a class="fk-brand" href="{% url 'claims:home' %}">
<i class="bi bi-compass me-1"></i>Faktenkompass <span class="fk-brand-icon"><i class="bi bi-compass"></i></span>
Faktenkompass
</a> </a>
<div class="d-flex gap-2"> <nav class="fk-nav-links" aria-label="Hauptnavigation">
<a class="btn btn-sm btn-outline-light{% if request.resolver_match.url_name == 'home' %} active{% endif %}" href="{% url 'claims:home' %}">Themen</a> <a class="fk-nav-link{% if request.resolver_match.url_name == 'home' %} active{% endif %}" href="{% url 'claims:home' %}">Themen</a>
<a class="btn btn-sm btn-outline-light{% if request.resolver_match.url_name == 'search' %} active{% endif %}" href="{% url 'claims:search' %}">Suche</a> <a class="fk-nav-link{% if request.resolver_match.url_name == 'search' %} active{% endif %}" href="{% url 'claims:search' %}">Suche</a>
</div> <button type="button" class="fk-theme-btn" id="fk-theme-toggle" aria-label="Farbschema umschalten">
<i class="bi bi-sun fk-icon-sun" aria-hidden="true"></i>
<i class="bi bi-moon fk-icon-moon" hidden aria-hidden="true"></i>
</button>
</nav>
</div> </div>
</nav> </header>
<main class="flex-grow-1 py-3"> <main class="flex-grow-1 fk-main">
<div class="container"> <div class="container">
{% block content %}{% endblock %} {% block content %}{% endblock %}
</div> </div>
</main> </main>
<footer class="border-top py-2 mt-auto"> <footer class="fk-footer">
<div class="container"> <div class="container">
<p class="text-muted text-center mb-0 small">&copy; {% now "Y" %} Faktenkompass</p> <p>&copy; {% now "Y" %} Faktenkompass</p>
</div> </div>
</footer> </footer>
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/js/bootstrap.bundle.min.js"></script> <script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/js/bootstrap.bundle.min.js"></script>
{% block extra_js %}{% endblock %}
</body> </body>
</html> </html>
+7 -21
View File
@@ -10,21 +10,21 @@
</ol> </ol>
</nav> </nav>
<div class="d-flex align-items-center gap-2 mb-3"> <div class="d-flex align-items-center gap-2 mb-4">
<div class="category-tile-icon flex-shrink-0"> <div class="category-tile-icon flex-shrink-0">
<i class="bi {{ category.icon|default:'bi-book' }}"></i> <i class="bi {{ category.icon|default:'bi-book' }}"></i>
</div> </div>
<div class="flex-grow-1 overflow-hidden"> <div class="flex-grow-1 overflow-hidden">
<h1 class="h5 mb-0 text-truncate">{{ category.name }}</h1> <h1 class="fk-page-title mb-0 text-truncate">{{ category.name }}</h1>
<span class="small text-muted">{{ claims|length }} Behauptung{% if claims|length != 1 %}en{% endif %}</span> <span class="small text-muted">{{ claims|length }} Behauptung{% if claims|length != 1 %}en{% endif %}</span>
</div> </div>
</div> </div>
<form method="get" class="row g-2 mb-3"> <form method="get" class="row g-2 mb-4" data-live-search data-live-target="#category-results">
<div class="col-12 col-sm"> <div class="col-12 col-sm">
<input type="search" id="q" name="q" class="form-control form-control-sm" placeholder="Suchen…" value="{{ query }}"> <input type="search" id="q" name="q" class="form-control form-control-sm" placeholder="Suchen…" value="{{ query }}" autocomplete="off">
</div> </div>
<div class="col-7 col-sm-auto"> <div class="col-12 col-sm-auto">
<select id="status" name="status" class="form-select form-select-sm"> <select id="status" name="status" class="form-select form-select-sm">
<option value="">Alle Status</option> <option value="">Alle Status</option>
{% for value, label in status_choices %} {% for value, label in status_choices %}
@@ -32,23 +32,9 @@
{% endfor %} {% endfor %}
</select> </select>
</div> </div>
<div class="col-5 col-sm-auto">
<button type="submit" class="btn btn-primary btn-sm w-100">Filtern</button>
</div>
</form> </form>
<div class="d-flex flex-column gap-2"> <div id="category-results">
{% for claim in claims %} {% include "claims/partials/claim_list.html" %}
<a href="{{ claim.get_absolute_url }}" class="text-decoration-none">
<div class="card claim-card">
<div class="card-body d-flex align-items-start gap-2">
<span class="badge text-bg-{{ claim.get_status_badge_class }} flex-shrink-0 mt-1">{{ claim.get_status_display }}</span>
<span class="text-dark">{{ claim.title }}</span>
</div>
</div>
</a>
{% empty %}
<p class="text-muted text-center py-4 mb-0">Keine Behauptungen gefunden.</p>
{% endfor %}
</div> </div>
{% endblock %} {% endblock %}
+62 -51
View File
@@ -1,4 +1,5 @@
{% extends "base.html" %} {% extends "base.html" %}
{% load static claims_extras %}
{% block title %}{{ claim.title }} Faktenkompass{% endblock %} {% block title %}{{ claim.title }} Faktenkompass{% endblock %}
{% block meta_description %}{{ claim.short_answer|truncatewords:30 }}{% endblock %} {% block meta_description %}{{ claim.short_answer|truncatewords:30 }}{% endblock %}
@@ -13,43 +14,42 @@
</nav> </nav>
<article> <article>
<div class="d-flex flex-wrap gap-1 mb-2"> <h1 class="fk-claim-title">{{ claim.title }}</h1>
<span class="badge text-bg-{{ claim.get_status_badge_class }}">{{ claim.get_status_display }}</span>
<span class="badge text-bg-light border text-dark">Evidenz: {{ claim.get_evidence_level_display }}</span> <div class="fk-meta-stack mb-3">
{% status_badge claim %}
{% evidence_badge claim %}
</div> </div>
<h1 class="h5 fw-semibold mb-3">{{ claim.title }}</h1> <div class="fk-card fk-answer-card">
<p class="fk-section-label mb-2">Kurzantwort</p>
<div class="card mb-3 border-start border-3 border-primary"> <p>{{ claim.short_answer }}</p>
<div class="card-body py-3">
<p class="small text-muted text-uppercase fw-semibold mb-1">Kurzantwort</p>
<p class="mb-0">{{ claim.short_answer }}</p>
</div>
</div> </div>
{% if claim.evidence_files.all %} {% if claim.evidence_files.all %}
<section class="mb-3"> <section class="fk-section">
<p class="small text-muted text-uppercase fw-semibold mb-2">Nachweise</p> <p class="fk-section-label">Nachweise</p>
<div class="d-flex flex-column gap-2"> <div class="d-flex flex-column gap-2">
{% for evidence in claim.evidence_files.all %} {% for evidence in claim.evidence_files.all %}
{% if evidence.is_image %} {% if evidence.is_image %}
<figure class="card mb-0 evidence-card"> <figure class="fk-card evidence-card mb-0">
<img src="{{ evidence.file.url }}" alt="{{ evidence.caption|default:evidence.display_title }}" <button type="button" class="fk-img-open" data-src="{{ evidence.file.url }}"
class="evidence-img w-100" loading="lazy"> data-caption="{{ evidence.caption|default:evidence.image_alt }}"
{% if evidence.title or evidence.caption %} aria-label="Grafik im Vollbild anzeigen">
<figcaption class="card-body py-2 px-3 small"> <img src="{{ evidence.file.url }}" alt="{{ evidence.image_alt }}"
{% if evidence.title %}<strong class="d-block">{{ evidence.title }}</strong>{% endif %} class="evidence-img w-100" loading="lazy">
{% if evidence.caption %}<span class="text-muted">{{ evidence.caption }}</span>{% endif %} </button>
</figcaption> {% if evidence.caption %}
<figcaption class="card-body py-2 px-3 small text-muted">{{ evidence.caption }}</figcaption>
{% endif %} {% endif %}
</figure> </figure>
{% elif evidence.is_pdf %} {% elif evidence.is_pdf %}
<a href="{{ evidence.file.url }}" target="_blank" rel="noopener noreferrer" class="text-decoration-none"> <a href="{{ evidence.file.url }}" target="_blank" rel="noopener noreferrer" class="text-decoration-none">
<div class="card evidence-card"> <div class="fk-card evidence-card">
<div class="card-body py-2 px-3 d-flex align-items-center gap-3"> <div class="card-body py-2 px-3 d-flex align-items-center gap-3">
<i class="bi bi-file-earmark-pdf text-danger fs-3 flex-shrink-0"></i> <i class="bi bi-file-earmark-pdf fs-3 flex-shrink-0" style="color:#ef4444"></i>
<div class="min-w-0"> <div class="min-w-0">
<span class="small fw-medium text-dark d-block">{{ evidence.display_title }}</span> <span class="small fw-medium d-block" style="color:var(--fk-text)">{{ evidence.display_title }}</span>
{% if evidence.caption %}<span class="small text-muted">{{ evidence.caption }}</span>{% endif %} {% if evidence.caption %}<span class="small text-muted">{{ evidence.caption }}</span>{% endif %}
<span class="small text-primary">PDF öffnen</span> <span class="small text-primary">PDF öffnen</span>
</div> </div>
@@ -63,29 +63,27 @@
{% endif %} {% endif %}
{% if claim.detailed_explanation %} {% if claim.detailed_explanation %}
<section class="card mb-3"> <section class="fk-section">
<div class="card-body py-3"> <p class="fk-section-label">Erklärung</p>
<p class="small text-muted text-uppercase fw-semibold mb-1">Erklärung</p> <div class="fk-card fk-section-body">
<div class="small">{{ claim.detailed_explanation|linebreaks }}</div> {{ claim.detailed_explanation|linebreaks }}
</div> </div>
</section> </section>
{% endif %} {% endif %}
{% if claim.sources.all %} {% if claim.sources.all %}
<section class="mb-3"> <section class="fk-section">
<p class="small text-muted text-uppercase fw-semibold mb-2">Quellen</p> <p class="fk-section-label">Quellen</p>
<div class="d-flex flex-column gap-2"> <div class="d-flex flex-column gap-2">
{% for source in claim.sources.all %} {% for source in claim.sources.all %}
<div class="card"> <div class="fk-card fk-section-body">
<div class="card-body py-2 px-3"> <a href="{{ source.url }}" target="_blank" rel="noopener noreferrer" class="fk-source-link">
<a href="{{ source.url }}" target="_blank" rel="noopener noreferrer" class="text-decoration-none fw-medium small"> {{ source.title }} <i class="bi bi-box-arrow-up-right"></i>
{{ source.title }} <i class="bi bi-box-arrow-up-right"></i> </a>
</a> <p class="text-muted small mb-0">{{ source.organization }}</p>
<p class="text-muted small mb-0">{{ source.organization }}</p> {% if source.description %}
{% if source.description %} <p class="small mb-0 mt-1">{{ source.description }}</p>
<p class="small mb-0 mt-1">{{ source.description }}</p> {% endif %}
{% endif %}
</div>
</div> </div>
{% endfor %} {% endfor %}
</div> </div>
@@ -93,11 +91,11 @@
{% endif %} {% endif %}
{% if claim.counter_arguments.all %} {% if claim.counter_arguments.all %}
<section class="mb-3"> <section class="fk-section">
<p class="small text-muted text-uppercase fw-semibold mb-2">Gegenargumente</p> <p class="fk-section-label">Gegenargumente</p>
<div class="accordion accordion-flush" id="counterArgs"> <div class="accordion fk-accordion" id="counterArgs">
{% for ca in claim.counter_arguments.all %} {% for ca in claim.counter_arguments.all %}
<div class="accordion-item border mb-1 rounded"> <div class="accordion-item">
<h2 class="accordion-header"> <h2 class="accordion-header">
<button class="accordion-button collapsed py-2 small" type="button" <button class="accordion-button collapsed py-2 small" type="button"
data-bs-toggle="collapse" data-bs-target="#collapse{{ forloop.counter }}"> data-bs-toggle="collapse" data-bs-target="#collapse{{ forloop.counter }}">
@@ -106,7 +104,7 @@
</h2> </h2>
<div id="collapse{{ forloop.counter }}" class="accordion-collapse collapse" <div id="collapse{{ forloop.counter }}" class="accordion-collapse collapse"
data-bs-parent="#counterArgs"> data-bs-parent="#counterArgs">
<div class="accordion-body py-2 small text-muted"> <div class="accordion-body py-2">
{{ ca.response|linebreaks }} {{ ca.response|linebreaks }}
</div> </div>
</div> </div>
@@ -119,22 +117,22 @@
{% if claim.tags.all %} {% if claim.tags.all %}
<div class="d-flex flex-wrap gap-1"> <div class="d-flex flex-wrap gap-1">
{% for tag in claim.tags.all %} {% for tag in claim.tags.all %}
<span class="badge text-bg-light border text-secondary">#{{ tag.name }}</span> <span class="fk-tag">#{{ tag.name }}</span>
{% endfor %} {% endfor %}
</div> </div>
{% endif %} {% endif %}
</article> </article>
{% if related_claims %} {% if related_claims %}
<aside class="mt-4 pt-3 border-top"> <aside class="mt-4 pt-4" style="border-top:1px solid var(--fk-border)">
<p class="small text-muted text-uppercase fw-semibold mb-2">Mehr zu {{ claim.category.name }}</p> <p class="fk-section-label">Mehr zu {{ claim.category.name }}</p>
<div class="d-flex flex-column gap-2"> <div class="d-flex flex-column gap-2">
{% for related in related_claims %} {% for related in related_claims %}
<a href="{{ related.get_absolute_url }}" class="text-decoration-none"> <a href="{{ related.get_absolute_url }}" class="text-decoration-none">
<div class="card claim-card"> <div class="claim-card">
<div class="card-body py-2 d-flex align-items-start gap-2"> <div class="card-body py-2">
<span class="badge text-bg-{{ related.get_status_badge_class }} flex-shrink-0">{{ related.get_status_display }}</span> <div class="mb-1">{% status_badge related %}</div>
<span class="small text-dark">{{ related.title }}</span> <span class="claim-title small d-block">{{ related.title }}</span>
</div> </div>
</div> </div>
</a> </a>
@@ -142,4 +140,17 @@
</div> </div>
</aside> </aside>
{% endif %} {% endif %}
<div id="fk-lightbox" class="fk-lightbox" hidden role="dialog" aria-modal="true" aria-label="Grafik Vollbild">
<button type="button" class="fk-lightbox-backdrop" aria-label="Schließen"></button>
<button type="button" class="fk-lightbox-close" aria-label="Schließen">
<i class="bi bi-x-lg"></i>
</button>
<img class="fk-lightbox-img" src="" alt="">
<p class="fk-lightbox-caption"></p>
</div>
{% endblock %}
{% block extra_js %}
<script src="{% static 'js/image-lightbox.js' %}" defer></script>
{% endblock %} {% endblock %}
+7 -31
View File
@@ -3,12 +3,12 @@
{% block title %}Faktenkompass{% endblock %} {% block title %}Faktenkompass{% endblock %}
{% block content %} {% block content %}
<header class="page-header mb-3"> <header class="mb-4">
<h1 class="h4 mb-3">Themen</h1> <h1 class="fk-page-title">Themen</h1>
<form method="get" action="{% url 'claims:home' %}" class="mb-2"> <form method="get" action="{% url 'claims:home' %}" class="mb-2" data-live-search data-live-target="#home-results">
<div class="input-group"> <div class="input-group">
<span class="input-group-text bg-white border-end-0"><i class="bi bi-search text-muted"></i></span> <span class="input-group-text border-end-0"><i class="bi bi-search"></i></span>
<input type="search" name="q" class="form-control border-start-0 ps-0" placeholder="Suchen…" value="{{ query }}" aria-label="Kategorien durchsuchen"> <input type="search" name="q" class="form-control border-start-0 ps-0" placeholder="Suchen…" value="{{ query }}" aria-label="Kategorien durchsuchen" autocomplete="off">
</div> </div>
</form> </form>
<div class="d-flex justify-content-between align-items-center"> <div class="d-flex justify-content-between align-items-center">
@@ -17,31 +17,7 @@
</div> </div>
</header> </header>
{% if query and not categories %} <div id="home-results">
<p class="text-muted small mb-3">Keine Kategorien für „{{ query }}".</p> {% include "claims/partials/category_list.html" %}
{% endif %}
<div class="row g-2 g-sm-3">
{% for category in categories %}
<div class="col-6 col-md-4 col-lg-3">
<a href="{{ category.get_absolute_url }}" class="text-decoration-none d-block h-100">
<div class="card category-tile h-100 text-center">
<div class="card-body d-flex flex-column align-items-center justify-content-center py-3 py-sm-4">
<div class="category-tile-icon mb-2">
<i class="bi {{ category.icon|default:'bi-book' }}"></i>
</div>
<h2 class="h6 card-title text-dark mb-0">{{ category.name }}</h2>
<span class="small text-muted mt-1">{{ category.claim_count }}</span>
</div>
</div>
</a>
</div>
{% empty %}
{% if not query %}
<div class="col-12">
<p class="text-muted text-center py-5 mb-0">Noch keine Kategorien vorhanden.</p>
</div>
{% endif %}
{% endfor %}
</div> </div>
{% endblock %} {% endblock %}
@@ -0,0 +1,27 @@
{% if query and not categories %}
<p class="text-muted small mb-3">Keine Kategorien für „{{ query }}".</p>
{% endif %}
<div class="row g-2 g-sm-3">
{% for category in categories %}
<div class="col-6 col-md-4 col-lg-3">
<a href="{{ category.get_absolute_url }}" class="text-decoration-none d-block h-100">
<div class="category-tile h-100 text-center">
<div class="card-body d-flex flex-column align-items-center justify-content-center py-3 py-sm-4">
<div class="category-tile-icon mb-2">
<i class="bi {{ category.icon|default:'bi-book' }}"></i>
</div>
<h2 class="h6 card-title mb-0">{{ category.name }}</h2>
<span class="small text-muted mt-1">{{ category.claim_count }}</span>
</div>
</div>
</a>
</div>
{% empty %}
{% if not query %}
<div class="col-12">
<p class="text-muted text-center py-5 mb-0">Noch keine Kategorien vorhanden.</p>
</div>
{% endif %}
{% endfor %}
</div>
+20
View File
@@ -0,0 +1,20 @@
{% load claims_extras %}
<div class="d-flex flex-column gap-2">
{% for claim in claims %}
<a href="{{ claim.get_absolute_url }}" class="text-decoration-none">
<div class="claim-card">
<div class="card-body">
{% if show_category %}
<div class="mb-1">
<span class="fk-topic">{{ claim.category.name }}</span>
</div>
{% endif %}
<div class="mb-1">{% status_badge claim %}</div>
<span class="claim-title d-block">{{ claim.title }}</span>
</div>
</div>
</a>
{% empty %}
<p class="text-muted text-center py-4 mb-0">{{ empty_message|default:"Keine Behauptungen gefunden." }}</p>
{% endfor %}
</div>
@@ -0,0 +1,4 @@
<span class="fk-evidence">
<i class="bi bi-bar-chart" aria-hidden="true"></i>
Evidenz: {{ claim.get_evidence_level_display }}
</span>
@@ -0,0 +1,12 @@
{% if claims is not None %}
<div class="d-flex justify-content-between align-items-center mb-2">
<span class="small text-muted">{{ result_count }} Ergebnis{% if result_count != 1 %}se{% endif %}</span>
{% if query or category_slug or status %}
<a href="{% url 'claims:search' %}" class="small text-muted text-decoration-none">Zurücksetzen</a>
{% endif %}
</div>
{% include "claims/partials/claim_list.html" with claims=claims show_category=True empty_message="Keine Ergebnisse." %}
{% else %}
<p class="text-muted text-center py-4 mb-0 small">Suchbegriff oder Filter eingeben…</p>
{% endif %}
@@ -0,0 +1,4 @@
<span class="fk-status fk-status--{{ claim.status }}">
<span class="fk-status-dot" aria-hidden="true"></span>
{{ claim.get_status_display }}
</span>
+5 -31
View File
@@ -3,12 +3,12 @@
{% block title %}Suche Faktenkompass{% endblock %} {% block title %}Suche Faktenkompass{% endblock %}
{% block content %} {% block content %}
<h1 class="h5 mb-3">Suche</h1> <h1 class="fk-page-title">Suche</h1>
<form method="get" class="row g-2 mb-3"> <form method="get" action="{% url 'claims:search' %}" class="row g-2 mb-4" data-live-search data-live-target="#search-results">
<div class="col-12"> <div class="col-12">
<input type="search" id="q" name="q" class="form-control form-control-sm" <input type="search" id="q" name="q" class="form-control form-control-sm"
placeholder="Behauptung suchen…" value="{{ query }}" autofocus> placeholder="Behauptung suchen…" value="{{ query }}" autofocus autocomplete="off">
</div> </div>
<div class="col-6"> <div class="col-6">
<select id="kategorie" name="kategorie" class="form-select form-select-sm"> <select id="kategorie" name="kategorie" class="form-select form-select-sm">
@@ -26,35 +26,9 @@
{% endfor %} {% endfor %}
</select> </select>
</div> </div>
<div class="col-12">
<button type="submit" class="btn btn-primary btn-sm w-100">Suchen</button>
</div>
</form> </form>
{% if claims is not None %} <div id="search-results">
<div class="d-flex justify-content-between align-items-center mb-2"> {% include "claims/partials/search_results.html" %}
<span class="small text-muted">{{ result_count }} Ergebnis{% if result_count != 1 %}se{% endif %}</span>
{% if query or category_slug or status %}
<a href="{% url 'claims:search' %}" class="small text-muted">Zurücksetzen</a>
{% endif %}
</div> </div>
<div class="d-flex flex-column gap-2">
{% for claim in claims %}
<a href="{{ claim.get_absolute_url }}" class="text-decoration-none">
<div class="card claim-card">
<div class="card-body">
<div class="d-flex flex-wrap gap-1 mb-1">
<span class="badge text-bg-secondary">{{ claim.category.name }}</span>
<span class="badge text-bg-{{ claim.get_status_badge_class }}">{{ claim.get_status_display }}</span>
</div>
<span class="text-dark">{{ claim.title }}</span>
</div>
</div>
</a>
{% empty %}
<p class="text-muted text-center py-4 mb-0">Keine Ergebnisse.</p>
{% endfor %}
</div>
{% endif %}
{% endblock %} {% endblock %}