Compare commits

...

4 Commits

Author SHA1 Message Date
Pirmin Hinderling (fedora) 16428f2c16 Replace counter-arguments with possible answers and unify search inputs.
Models, AI assistant, and UI now show diskussionsfertige Antworten without titles; shared search partial fixes aria-label rendering.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-07 22:50:53 +02:00
Pirmin Hinderling (fedora) 28aab3b16a Remove ambiguous evidence level field from claims and the AI assistant.
Drops Evidenzlevel from models, admin, public UI, and draft workflow while keeping Nachweise file uploads unchanged.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-07 22:11:47 +02:00
Pirmin Hinderling (fedora) 3a5849660a Document collectstatic requirements for hashed static files in production.
Clarifies that collectstatic must run with production env vars and --clear after static updates.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-07 21:54:52 +02:00
Pirmin Hinderling (fedora) fce11689e1 Hash static filenames in production via ManifestStaticFilesStorage.
Enables cache-busted asset URLs after collectstatic when DEBUG is disabled.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-07 21:45:23 +02:00
23 changed files with 289 additions and 179 deletions
+19 -6
View File
@@ -1,6 +1,6 @@
# Faktenkompass Wissensdatenbank für überprüfbare Behauptungen # Faktenkompass Wissensdatenbank für überprüfbare Behauptungen
Eine Django-Webplattform zum schnellen Nachschlagen von Fakten, Quellen und Gegenargumenten zu häufigen Aussagen in Diskussionen. Eine Django-Webplattform zum schnellen Nachschlagen von Fakten, Quellen und Antworten zu häufigen Aussagen in Diskussionen.
## Voraussetzungen ## Voraussetzungen
@@ -42,7 +42,7 @@ python manage.py migrate
python manage.py load_sample_data python manage.py load_sample_data
``` ```
Dies erstellt 7 Kategorien mit insgesamt 15 Behauptungen, Quellen und Gegenargumenten. Dies erstellt 7 Kategorien mit insgesamt 15 Behauptungen, Quellen und möglichen Antworten.
### 5. Admin-Zugang einrichten ### 5. Admin-Zugang einrichten
@@ -117,11 +117,24 @@ cd /pfad/zu/ArgumentAtlas
source .venv/bin/activate source .venv/bin/activate
pip install -r requirements.txt pip install -r requirements.txt
set -a && source /etc/faktenkompass.env && set +a
python manage.py migrate python manage.py migrate
python manage.py collectstatic --noinput python manage.py collectstatic --noinput
python manage.py createsuperuser # falls noch nicht vorhanden python manage.py createsuperuser # falls noch nicht vorhanden
``` ```
**Wichtig:** `collectstatic` immer mit denselben Umgebungsvariablen ausführen wie der Daphne-Dienst (`DJANGO_DEBUG=False`). Sonst fehlen gehashte Dateinamen bzw. `staticfiles.json` und die Seite antwortet mit 500.
Nach jedem `git pull` mit geänderten Static-Dateien:
```bash
set -a && source /etc/faktenkompass.env && set +a
python manage.py collectstatic --clear --noinput
sudo systemctl restart faktenkompass
```
Erfolg prüfen: `staticfiles/staticfiles.json` existiert, CSS heißt z.B. `custom.<hash>.css`.
Stellen Sie sicher, dass der Prozessnutzer Schreibrechte für `media/` und die Datenbank hat. Stellen Sie sicher, dass der Prozessnutzer Schreibrechte für `media/` und die Datenbank hat.
### 3. Daphne manuell testen ### 3. Daphne manuell testen
@@ -242,7 +255,7 @@ ArgumentAtlas/
│ ├── settings.py │ ├── settings.py
│ └── urls.py │ └── urls.py
├── claims/ # Haupt-App ├── claims/ # Haupt-App
│ ├── models.py # Category, Claim, Source, EvidenceFile, CounterArgument, Tag │ ├── models.py # Category, Claim, Source, EvidenceFile, PossibleAnswer, Tag
│ ├── views.py │ ├── views.py
│ ├── urls.py │ ├── urls.py
│ ├── admin.py │ ├── admin.py
@@ -257,14 +270,14 @@ ArgumentAtlas/
- **Startseite:** Kategorien als Karten mit Behauptungsanzahl - **Startseite:** Kategorien als Karten mit Behauptungsanzahl
- **Kategorieseite:** Behauptungen filtern und durchsuchen - **Kategorieseite:** Behauptungen filtern und durchsuchen
- **Behauptungsdetail:** Status, Kurzantwort, Nachweise (Bilder/PDFs), Evidenzlevel, Quellen, Gegenargumente, Tags - **Behauptungsdetail:** Status, Kurzantwort, Nachweise (Bilder/PDFs), Quellen, mögliche Antworten, Tags
- **Globale Suche:** Behauptungen über alle Kategorien hinweg - **Globale Suche:** Behauptungen über alle Kategorien hinweg
- **Admin:** Alle Inhalte über Django Admin pflegen - **Admin:** Alle Inhalte über Django Admin pflegen
- **SEO:** Sprechende URLs (`/kategorie/klimawandel/`, `/behauptung/...`) - **SEO:** Sprechende URLs (`/kategorie/klimawandel/`, `/behauptung/...`)
## Inhalte verwalten ## Inhalte verwalten
Melden Sie sich im Django-Admin an, um Kategorien, Behauptungen, Quellen, Gegenargumente und Schlagwörter anzulegen oder zu bearbeiten. Slugs werden automatisch aus Titeln generiert. Melden Sie sich im Django-Admin an, um Kategorien, Behauptungen, Quellen, mögliche Antworten und Schlagwörter anzulegen oder zu bearbeiten. Slugs werden automatisch aus Titeln generiert.
### Nachweise hochladen ### Nachweise hochladen
@@ -292,7 +305,7 @@ export OPENAI_API_KEY=sk-...
1. Admin öffnen → **Fakten-Assistent** (Startseite oder unter Fakten-Entwürfe) 1. Admin öffnen → **Fakten-Assistent** (Startseite oder unter Fakten-Entwürfe)
2. Artikel-URL, Beschreibung und/oder Bild/PDF eingeben 2. Artikel-URL, Beschreibung und/oder Bild/PDF eingeben
3. **Entwurf generieren** die KI schlägt Kategorie, Behauptung, Quellen und Gegenargumente vor 3. **Entwurf generieren** die KI schlägt Kategorie, Behauptung, Quellen und mögliche Antworten vor
4. Entwurf prüfen, bearbeiten, ggf. **KI erneut fragen** 4. Entwurf prüfen, bearbeiten, ggf. **KI erneut fragen**
5. Erst mit **Veröffentlichen** wird der Eintrag live geschaltet 5. Erst mit **Veröffentlichen** wird der Eintrag live geschaltet
+20 -20
View File
@@ -4,12 +4,12 @@ from django.utils.html import format_html
from .admin_forms import ( from .admin_forms import (
ClaimAdminForm, ClaimAdminForm,
CounterArgumentInlineForm, PossibleAnswerInlineForm,
EvidenceFileInlineForm, EvidenceFileInlineForm,
SourceInlineForm, SourceInlineForm,
) )
from .admin_views import AssistantView, ReviewDraftView from .admin_views import AssistantView, ReviewDraftView
from .models import Category, Claim, ClaimDraft, CounterArgument, EvidenceFile, Source, Tag from .models import Category, Claim, ClaimDraft, PossibleAnswer, EvidenceFile, Source, Tag
class FaktenAdminMixin: class FaktenAdminMixin:
@@ -59,14 +59,14 @@ class SourceInline(FaktenAdminMixin, admin.StackedInline):
verbose_name_plural = 'Quellen' verbose_name_plural = 'Quellen'
class CounterArgumentInline(FaktenAdminMixin, admin.StackedInline): class PossibleAnswerInline(FaktenAdminMixin, admin.StackedInline):
model = CounterArgument model = PossibleAnswer
form = CounterArgumentInlineForm form = PossibleAnswerInlineForm
extra = 1 extra = 1
fields = ('argument', 'response') fields = ('answer',)
classes = ('collapse-open',) classes = ('collapse-open',)
verbose_name = 'Gegenargument' verbose_name = 'Mögliche Antwort'
verbose_name_plural = 'Gegenargumente' verbose_name_plural = 'Mögliche Antworten'
@admin.register(Tag) @admin.register(Tag)
@@ -106,20 +106,20 @@ class CategoryAdmin(FaktenAdminMixin, admin.ModelAdmin):
class ClaimAdmin(FaktenAdminMixin, admin.ModelAdmin): class ClaimAdmin(FaktenAdminMixin, admin.ModelAdmin):
form = ClaimAdminForm form = ClaimAdminForm
change_form_template = 'admin/claims/claim/change_form.html' change_form_template = 'admin/claims/claim/change_form.html'
list_display = ('title', 'category', 'status', 'evidence_level', 'updated_at') list_display = ('title', 'category', 'status', 'updated_at')
list_display_links = ('title',) list_display_links = ('title',)
list_filter = ('category', 'status', 'evidence_level', 'tags') list_filter = ('category', 'status', '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',) autocomplete_fields = ('category',)
save_on_top = True save_on_top = True
date_hierarchy = 'updated_at' date_hierarchy = 'updated_at'
inlines = [EvidenceFileInline, SourceInline, CounterArgumentInline] inlines = [EvidenceFileInline, SourceInline, PossibleAnswerInline]
readonly_fields = ('public_link', 'created_at', 'updated_at') readonly_fields = ('public_link', 'created_at', 'updated_at')
fieldsets = ( fieldsets = (
('Einordnung', { ('Einordnung', {
'fields': ('category', 'status', 'evidence_level', 'tags'), 'fields': ('category', 'status', 'tags'),
}), }),
('Behauptung', { ('Behauptung', {
'fields': ('title', 'slug', 'public_link'), 'fields': ('title', 'slug', 'public_link'),
@@ -175,16 +175,16 @@ class SourceAdmin(FaktenAdminMixin, admin.ModelAdmin):
search_fields = ('title', 'organization', 'description') search_fields = ('title', 'organization', 'description')
@admin.register(CounterArgument) @admin.register(PossibleAnswer)
class CounterArgumentAdmin(FaktenAdminMixin, admin.ModelAdmin): class PossibleAnswerAdmin(FaktenAdminMixin, admin.ModelAdmin):
form = CounterArgumentInlineForm form = PossibleAnswerInlineForm
list_display = ('argument_preview', 'claim') list_display = ('answer_preview', 'claim')
list_filter = ('claim__category',) list_filter = ('claim__category',)
search_fields = ('argument', 'response') search_fields = ('answer', 'claim__title')
@admin.display(description='Gegenargument') @admin.display(description='Antwort')
def argument_preview(self, obj): def answer_preview(self, obj):
return obj.argument[:80] return obj.answer[:80]
@admin.register(ClaimDraft) @admin.register(ClaimDraft)
+4 -5
View File
@@ -1,6 +1,6 @@
from django import forms from django import forms
from .models import Claim, CounterArgument, EvidenceFile, Source from .models import Claim, PossibleAnswer, EvidenceFile, Source
class ClaimAdminForm(forms.ModelForm): class ClaimAdminForm(forms.ModelForm):
@@ -26,13 +26,12 @@ class SourceInlineForm(forms.ModelForm):
} }
class CounterArgumentInlineForm(forms.ModelForm): class PossibleAnswerInlineForm(forms.ModelForm):
class Meta: class Meta:
model = CounterArgument model = PossibleAnswer
fields = '__all__' fields = '__all__'
widgets = { widgets = {
'argument': forms.Textarea(attrs={'rows': 3, 'class': 'fk-admin-textarea'}), 'answer': forms.Textarea(attrs={'rows': 4, 'class': 'fk-admin-textarea'}),
'response': forms.Textarea(attrs={'rows': 4, 'class': 'fk-admin-textarea'}),
} }
+9 -11
View File
@@ -53,8 +53,8 @@ class ReviewDraftForm(forms.ModelForm):
required=False, required=False,
widget=forms.Textarea(attrs={'class': 'form-control font-monospace', 'rows': 6}), widget=forms.Textarea(attrs={'class': 'form-control font-monospace', 'rows': 6}),
) )
counter_arguments_json = forms.CharField( possible_answers_json = forms.CharField(
label='Gegenargumente (JSON)', label='Mögliche Antworten (JSON)',
required=False, required=False,
widget=forms.Textarea(attrs={'class': 'form-control font-monospace', 'rows': 6}), widget=forms.Textarea(attrs={'class': 'form-control font-monospace', 'rows': 6}),
) )
@@ -73,7 +73,6 @@ class ReviewDraftForm(forms.ModelForm):
'claim_status', 'claim_status',
'short_answer', 'short_answer',
'detailed_explanation', 'detailed_explanation',
'evidence_level',
'new_category_name', 'new_category_name',
'new_category_description', 'new_category_description',
'new_category_icon', 'new_category_icon',
@@ -83,7 +82,6 @@ class ReviewDraftForm(forms.ModelForm):
'claim_status': forms.Select(attrs={'class': 'form-select'}), 'claim_status': forms.Select(attrs={'class': 'form-select'}),
'short_answer': forms.Textarea(attrs={'class': 'form-control', 'rows': 4}), 'short_answer': forms.Textarea(attrs={'class': 'form-control', 'rows': 4}),
'detailed_explanation': forms.Textarea(attrs={'class': 'form-control', 'rows': 6}), '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_name': forms.TextInput(attrs={'class': 'form-control'}),
'new_category_description': forms.Textarea(attrs={'class': 'form-control', 'rows': 2}), 'new_category_description': forms.Textarea(attrs={'class': 'form-control', 'rows': 2}),
'new_category_icon': forms.TextInput(attrs={'class': 'form-control', 'placeholder': 'bi-book'}), 'new_category_icon': forms.TextInput(attrs={'class': 'form-control', 'placeholder': 'bi-book'}),
@@ -97,8 +95,8 @@ class ReviewDraftForm(forms.ModelForm):
self.fields['sources_json'].initial = json.dumps( self.fields['sources_json'].initial = json.dumps(
instance.sources or [], ensure_ascii=False, indent=2 instance.sources or [], ensure_ascii=False, indent=2
) )
self.fields['counter_arguments_json'].initial = json.dumps( self.fields['possible_answers_json'].initial = json.dumps(
instance.counter_arguments or [], ensure_ascii=False, indent=2 instance.possible_answers or [], ensure_ascii=False, indent=2
) )
if instance.category_slug: if instance.category_slug:
category = Category.objects.filter(slug=instance.category_slug).first() category = Category.objects.filter(slug=instance.category_slug).first()
@@ -121,23 +119,23 @@ class ReviewDraftForm(forms.ModelForm):
raise forms.ValidationError('Quellen müssen eine JSON-Liste sein.') raise forms.ValidationError('Quellen müssen eine JSON-Liste sein.')
return data return data
def clean_counter_arguments_json(self): def clean_possible_answers_json(self):
raw = (self.cleaned_data.get('counter_arguments_json') or '').strip() raw = (self.cleaned_data.get('possible_answers_json') or '').strip()
if not raw: if not raw:
return [] return []
try: try:
data = json.loads(raw) data = json.loads(raw)
except json.JSONDecodeError as exc: except json.JSONDecodeError as exc:
raise forms.ValidationError('Ungültiges JSON für Gegenargumente.') from exc raise forms.ValidationError('Ungültiges JSON für mögliche Antworten.') from exc
if not isinstance(data, list): if not isinstance(data, list):
raise forms.ValidationError('Gegenargumente müssen eine JSON-Liste sein.') raise forms.ValidationError('Mögliche Antworten müssen eine JSON-Liste sein.')
return data return data
def save(self, commit=True): def save(self, commit=True):
draft = super().save(commit=False) draft = super().save(commit=False)
draft.tags = self.cleaned_data['tags_text'] draft.tags = self.cleaned_data['tags_text']
draft.sources = self.cleaned_data['sources_json'] draft.sources = self.cleaned_data['sources_json']
draft.counter_arguments = self.cleaned_data['counter_arguments_json'] draft.possible_answers = self.cleaned_data['possible_answers_json']
category = self.cleaned_data.get('category_choice') category = self.cleaned_data.get('category_choice')
if category: if category:
draft.category_slug = category.slug draft.category_slug = category.slug
+9 -28
View File
@@ -1,6 +1,6 @@
from django.core.management.base import BaseCommand from django.core.management.base import BaseCommand
from claims.models import Category, Claim, CounterArgument, Source, Tag from claims.models import Category, Claim, PossibleAnswer, Source, Tag
class Command(BaseCommand): class Command(BaseCommand):
@@ -87,7 +87,6 @@ class Command(BaseCommand):
'Sie bleibt im Zytoplasma und wird nicht in den Zellkern transportiert, wo die DNA liegt. ' 'Sie bleibt im Zytoplasma und wird nicht in den Zellkern transportiert, wo die DNA liegt. '
'Integration in das Erbgut wäre ohne spezielle Hilfsmechanismen, die Impfstoffe nicht enthalten, praktisch nicht möglich.' 'Integration in das Erbgut wäre ohne spezielle Hilfsmechanismen, die Impfstoffe nicht enthalten, praktisch nicht möglich.'
), ),
evidence_level=Claim.EvidenceLevel.SEHR_STARK,
) )
c1.tags.add(tags['impfstoff'], tags['gesundheit']) c1.tags.add(tags['impfstoff'], tags['gesundheit'])
Source.objects.create( Source.objects.create(
@@ -97,10 +96,9 @@ class Command(BaseCommand):
url='https://www.rki.de/DE/Content/InfAZ/N/Neuartiges_Coronavirus/Impfen/Impfen_node.html', url='https://www.rki.de/DE/Content/InfAZ/N/Neuartiges_Coronavirus/Impfen/Impfen_node.html',
description='Offizielle Informationen des RKI zu Wirkweise und Sicherheit der Impfstoffe.', description='Offizielle Informationen des RKI zu Wirkweise und Sicherheit der Impfstoffe.',
) )
CounterArgument.objects.create( PossibleAnswer.objects.create(
claim=c1, claim=c1,
argument='Die mRNA ist künstlich und kann deshalb die Zelle dauerhaft verändern.', answer='Künstliche mRNA unterscheidet sich in der Wirkung nicht grundlegend von natürlicher mRNA im Körper. Beide werden abgebaut. Es gibt keinen Mechanismus, durch den Impf-mRNA in die DNA integriert wird.',
response='Künstliche mRNA unterscheidet sich in der Wirkung nicht grundlegend von natürlicher mRNA im Körper. Beide werden abgebaut. Es gibt keinen Mechanismus, durch den Impf-mRNA in die DNA integriert wird.',
) )
c2 = Claim.objects.create( c2 = Claim.objects.create(
@@ -109,7 +107,6 @@ class Command(BaseCommand):
status=Claim.Status.FALSCH, status=Claim.Status.FALSCH,
short_answer='In den ersten Pandemiejahren war COVID-19 deutlich tödlicher als saisonale Influenza und führte weltweit zu Millionen überzähliger Todesfälle.', short_answer='In den ersten Pandemiejahren war COVID-19 deutlich tödlicher als saisonale Influenza und führte weltweit zu Millionen überzähliger Todesfälle.',
detailed_explanation='Vergleiche mit der Grippe vereinfachen oft: COVID-19 verursachte höhere Belastungen für Intensivstationen und wies in vulnerablen Gruppen eine höhere Letalität auf.', detailed_explanation='Vergleiche mit der Grippe vereinfachen oft: COVID-19 verursachte höhere Belastungen für Intensivstationen und wies in vulnerablen Gruppen eine höhere Letalität auf.',
evidence_level=Claim.EvidenceLevel.STARK,
) )
c2.tags.add(tags['gesundheit']) c2.tags.add(tags['gesundheit'])
Source.objects.create( Source.objects.create(
@@ -126,7 +123,6 @@ class Command(BaseCommand):
status=Claim.Status.WISSENSCHAFTLICHER_KONSENS, status=Claim.Status.WISSENSCHAFTLICHER_KONSENS,
short_answer='Gut sitzende medizinische Masken und FFP2-Masken reduzieren nachweislich die Übertragung von Tröpfchen und Aerosolen, besonders in Innenräumen.', short_answer='Gut sitzende medizinische Masken und FFP2-Masken reduzieren nachweislich die Übertragung von Tröpfchen und Aerosolen, besonders in Innenräumen.',
detailed_explanation='Metaanalysen zeigen, dass Masken die Emission und Aufnahme infektiöser Partikel verringern. Der Schutz hängt von Maskentyp, Passform und Nutzung ab.', detailed_explanation='Metaanalysen zeigen, dass Masken die Emission und Aufnahme infektiöser Partikel verringern. Der Schutz hängt von Maskentyp, Passform und Nutzung ab.',
evidence_level=Claim.EvidenceLevel.STARK,
) )
def _create_climate_claims(self, category, tags): def _create_climate_claims(self, category, tags):
@@ -136,7 +132,6 @@ class Command(BaseCommand):
status=Claim.Status.FALSCH, status=Claim.Status.FALSCH,
short_answer='Die aktuelle Erwärmung lässt sich nicht durch natürliche Zyklen allein erklären. Treibhausgasemissionen sind laut IPCC die dominierende Ursache.', short_answer='Die aktuelle Erwärmung lässt sich nicht durch natürliche Zyklen allein erklären. Treibhausgasemissionen sind laut IPCC die dominierende Ursache.',
detailed_explanation='Klimamodelle mit nur natürlichen Faktoren reproduzieren die gemessene Temperaturanomalie nicht. Mit anthropogenen Emissionen stimmen Beobachtungen und Modelle überein.', detailed_explanation='Klimamodelle mit nur natürlichen Faktoren reproduzieren die gemessene Temperaturanomalie nicht. Mit anthropogenen Emissionen stimmen Beobachtungen und Modelle überein.',
evidence_level=Claim.EvidenceLevel.SEHR_STARK,
) )
c1.tags.add(tags['klima'], tags['co2']) c1.tags.add(tags['klima'], tags['co2'])
Source.objects.create( Source.objects.create(
@@ -146,10 +141,9 @@ class Command(BaseCommand):
url='https://www.ipcc.ch/report/ar6/syr/', url='https://www.ipcc.ch/report/ar6/syr/',
description='Aktueller Synthesebericht des Weltklimarats.', description='Aktueller Synthesebericht des Weltklimarats.',
) )
CounterArgument.objects.create( PossibleAnswer.objects.create(
claim=c1, claim=c1,
argument='Früher gab es auch warme und kalte Perioden ohne Industrie.', answer='Natürliche Schwankungen sind bekannt, aber die Geschwindigkeit der aktuellen Erwärmung passt zu Treibhausgasanstieg, nicht zu dominanten natürlichen Zyklen.',
response='Natürliche Schwankungen sind bekannt, aber die Geschwindigkeit der aktuellen Erwärmung passt zu Treibhausgasanstieg, nicht zu dominanten natürlichen Zyklen.',
) )
Claim.objects.create( Claim.objects.create(
@@ -158,7 +152,6 @@ class Command(BaseCommand):
status=Claim.Status.FALSCH, status=Claim.Status.FALSCH,
short_answer='Über 97 % der klimabezogenen Fachpublikationen stützen den Konsens, dass der Mensch die Hauptursache der aktuellen Erwärmung ist.', short_answer='Über 97 % der klimabezogenen Fachpublikationen stützen den Konsens, dass der Mensch die Hauptursache der aktuellen Erwärmung ist.',
detailed_explanation='Große wissenschaftliche Gesellschaften weltweit befürworten diese Einschätzung.', detailed_explanation='Große wissenschaftliche Gesellschaften weltweit befürworten diese Einschätzung.',
evidence_level=Claim.EvidenceLevel.SEHR_STARK,
) )
Claim.objects.create( Claim.objects.create(
@@ -167,7 +160,6 @@ class Command(BaseCommand):
status=Claim.Status.TEILWEISE_RICHTIG, status=Claim.Status.TEILWEISE_RICHTIG,
short_answer='Viele Extremwetterarten zeigen einen Zusammenhang mit dem Klimawandel, aber nicht jedes Ereignis ist direkt darauf zurückzuführen.', short_answer='Viele Extremwetterarten zeigen einen Zusammenhang mit dem Klimawandel, aber nicht jedes Ereignis ist direkt darauf zurückzuführen.',
detailed_explanation='Hitzewellen und starke Niederschläge werden in vielen Regionen häufiger. Attributionsforschung berechnet Wahrscheinlichkeitsveränderungen für Einzelereignisse.', detailed_explanation='Hitzewellen und starke Niederschläge werden in vielen Regionen häufiger. Attributionsforschung berechnet Wahrscheinlichkeitsveränderungen für Einzelereignisse.',
evidence_level=Claim.EvidenceLevel.STARK,
) )
def _create_energy_claims(self, category, tags): def _create_energy_claims(self, category, tags):
@@ -177,13 +169,11 @@ class Command(BaseCommand):
status=Claim.Status.TEILWEISE_RICHTIG, status=Claim.Status.TEILWEISE_RICHTIG,
short_answer='Langfristig ist ein sehr hoher Anteil erneuerbarer Energien möglich, erfordert aber Speicher, Netzausbau und Flexibilität.', short_answer='Langfristig ist ein sehr hoher Anteil erneuerbarer Energien möglich, erfordert aber Speicher, Netzausbau und Flexibilität.',
detailed_explanation='Studien zeigen Szenarien mit nahezu 100 % erneuerbarem Strom, erfordern aber Infrastruktur und Sektorkopplung.', detailed_explanation='Studien zeigen Szenarien mit nahezu 100 % erneuerbarem Strom, erfordern aber Infrastruktur und Sektorkopplung.',
evidence_level=Claim.EvidenceLevel.MITTEL,
) )
c1.tags.add(tags['erneuerbare']) c1.tags.add(tags['erneuerbare'])
CounterArgument.objects.create( PossibleAnswer.objects.create(
claim=c1, claim=c1,
argument='Wenn die Sonne nicht scheint und der Wind nicht weht, fällt das Netz aus.', answer='Energiesysteme kombinieren Speicher, Netze, Importe und Reservekapazitäten statt auf eine einzige Quelle zu setzen.',
response='Energiesysteme kombinieren Speicher, Netze, Importe und Reservekapazitäten statt auf eine einzige Quelle zu setzen.',
) )
Claim.objects.create( Claim.objects.create(
@@ -192,7 +182,6 @@ class Command(BaseCommand):
status=Claim.Status.IRREFUEHREND, status=Claim.Status.IRREFUEHREND,
short_answer='Atomkraft erzeugt wenig CO₂ im Betrieb, verursacht aber andere Risiken und hohe Folgekosten. Sie ist nicht die einzige klimafreundliche Option.', short_answer='Atomkraft erzeugt wenig CO₂ im Betrieb, verursacht aber andere Risiken und hohe Folgekosten. Sie ist nicht die einzige klimafreundliche Option.',
detailed_explanation='Lebenszyklusanalysen zeigen niedrige CO₂-Emissionen, aber Endlagerung und Unfallrisiken sind zentrale Gegenargumente.', detailed_explanation='Lebenszyklusanalysen zeigen niedrige CO₂-Emissionen, aber Endlagerung und Unfallrisiken sind zentrale Gegenargumente.',
evidence_level=Claim.EvidenceLevel.STARK,
) )
Claim.objects.create( Claim.objects.create(
@@ -201,7 +190,6 @@ class Command(BaseCommand):
status=Claim.Status.IRREFUEHREND, status=Claim.Status.IRREFUEHREND,
short_answer='Strompreise werden von Brennstoffkosten, Netzentgelten und Abgaben beeinflusst. Erneuerbare senken Grenzkosten, verursachen aber Netzausbaukosten.', short_answer='Strompreise werden von Brennstoffkosten, Netzentgelten und Abgaben beeinflusst. Erneuerbare senken Grenzkosten, verursachen aber Netzausbaukosten.',
detailed_explanation='In Phasen hoher Gaspreise stiegen Börsenstrompreise stark. Langfristig sind Wind und Solar kostengünstige Neubau-Technologien.', detailed_explanation='In Phasen hoher Gaspreise stiegen Börsenstrompreise stark. Langfristig sind Wind und Solar kostengünstige Neubau-Technologien.',
evidence_level=Claim.EvidenceLevel.MITTEL,
) )
def _create_migration_claims(self, category, tags): def _create_migration_claims(self, category, tags):
@@ -211,7 +199,6 @@ class Command(BaseCommand):
status=Claim.Status.IRREFUEHREND, status=Claim.Status.IRREFUEHREND,
short_answer='Die Erwerbstätigenquote variiert stark nach Herkunft und Aufenthaltsdauer. Pauschale Aussagen sind nicht haltbar.', short_answer='Die Erwerbstätigenquote variiert stark nach Herkunft und Aufenthaltsdauer. Pauschale Aussagen sind nicht haltbar.',
detailed_explanation='Viele Zuwanderer arbeiten nach mehreren Jahren ähnlich häufig wie die Gesamtbevölkerung. Neuankömmlinge weisen anfangs häufiger Arbeitslosigkeit auf.', detailed_explanation='Viele Zuwanderer arbeiten nach mehreren Jahren ähnlich häufig wie die Gesamtbevölkerung. Neuankömmlinge weisen anfangs häufiger Arbeitslosigkeit auf.',
evidence_level=Claim.EvidenceLevel.STARK,
) )
c1.tags.add(tags['migration']) c1.tags.add(tags['migration'])
Source.objects.create( Source.objects.create(
@@ -228,7 +215,6 @@ class Command(BaseCommand):
status=Claim.Status.TEILWEISE_RICHTIG, status=Claim.Status.TEILWEISE_RICHTIG,
short_answer='Deutschland nimmt absolut viele Zuwanderer auf, liegt pro Kopf aber nicht immer an der Spitze in Europa.', short_answer='Deutschland nimmt absolut viele Zuwanderer auf, liegt pro Kopf aber nicht immer an der Spitze in Europa.',
detailed_explanation='Eurostat-Daten zeigen Deutschland unter den Ländern mit höchsten absoluten Zahlen. Relativ zur Bevölkerung liegen andere Länder teils höher.', detailed_explanation='Eurostat-Daten zeigen Deutschland unter den Ländern mit höchsten absoluten Zahlen. Relativ zur Bevölkerung liegen andere Länder teils höher.',
evidence_level=Claim.EvidenceLevel.STARK,
) )
def _create_economy_claims(self, category, tags): def _create_economy_claims(self, category, tags):
@@ -238,7 +224,6 @@ class Command(BaseCommand):
status=Claim.Status.IRREFUEHREND, status=Claim.Status.IRREFUEHREND,
short_answer='Lohnkosten können Preise mitverursachen, aber Energiepreise, Lieferketten und Geldpolitik spielen ebenfalls eine zentrale Rolle.', short_answer='Lohnkosten können Preise mitverursachen, aber Energiepreise, Lieferketten und Geldpolitik spielen ebenfalls eine zentrale Rolle.',
detailed_explanation='Die Inflation 2022/2023 wurde maßgeblich durch Energie- und Rohstoffpreisschocks getrieben.', detailed_explanation='Die Inflation 2022/2023 wurde maßgeblich durch Energie- und Rohstoffpreisschocks getrieben.',
evidence_level=Claim.EvidenceLevel.STARK,
) )
Claim.objects.create( Claim.objects.create(
@@ -247,7 +232,6 @@ class Command(BaseCommand):
status=Claim.Status.FALSCH, status=Claim.Status.FALSCH,
short_answer='Deutschland ist ein führender Exporteur, lag in den letzten Jahren aber hinter China.', short_answer='Deutschland ist ein führender Exporteur, lag in den letzten Jahren aber hinter China.',
detailed_explanation='Nach WTO-Daten exportierte China mehr Waren als Deutschland. Deutschland bleibt stark im Maschinen- und Fahrzeugbau.', detailed_explanation='Nach WTO-Daten exportierte China mehr Waren als Deutschland. Deutschland bleibt stark im Maschinen- und Fahrzeugbau.',
evidence_level=Claim.EvidenceLevel.SEHR_STARK,
) )
def _create_politics_claims(self, category, tags): def _create_politics_claims(self, category, tags):
@@ -257,7 +241,6 @@ class Command(BaseCommand):
status=Claim.Status.TEILWEISE_RICHTIG, status=Claim.Status.TEILWEISE_RICHTIG,
short_answer='Bei Bundestagswahlen schwankt die Beteiligung; langfristig gibt es einen leichten Rückgang, aber keine einheitliche Abwärtsspirale.', short_answer='Bei Bundestagswahlen schwankt die Beteiligung; langfristig gibt es einen leichten Rückgang, aber keine einheitliche Abwärtsspirale.',
detailed_explanation='Historische Daten zeigen Schwankungen zwischen etwa 70 % und 90 % bei Bundestagswahlen.', detailed_explanation='Historische Daten zeigen Schwankungen zwischen etwa 70 % und 90 % bei Bundestagswahlen.',
evidence_level=Claim.EvidenceLevel.STARK,
) )
def _create_science_claims(self, category, tags): def _create_science_claims(self, category, tags):
@@ -267,11 +250,9 @@ class Command(BaseCommand):
status=Claim.Status.FALSCH, status=Claim.Status.FALSCH,
short_answer='Einzelstudien sind Ausgangspunkte, kein abschließender Beweis. Konsens entsteht durch Replikation und Synthese vieler Arbeiten.', short_answer='Einzelstudien sind Ausgangspunkte, kein abschließender Beweis. Konsens entsteht durch Replikation und Synthese vieler Arbeiten.',
detailed_explanation='Peer Review filtert Fehler, garantiert aber keine Wahrheit. Metaanalysen fassen viele Studien zusammen.', detailed_explanation='Peer Review filtert Fehler, garantiert aber keine Wahrheit. Metaanalysen fassen viele Studien zusammen.',
evidence_level=Claim.EvidenceLevel.SEHR_STARK,
) )
c1.tags.add(tags['forschung']) c1.tags.add(tags['forschung'])
CounterArgument.objects.create( PossibleAnswer.objects.create(
claim=c1, claim=c1,
argument='Wenn eine Studie in einer renommierten Zeitschrift steht, ist sie bewiesen.', answer='Renommierte Journale publizieren auch widerlegte Ergebnisse. Die Qualität hängt von Replikation und Methodik ab.',
response='Renommierte Journale publizieren auch widerlegte Ergebnisse. Die Qualität hängt von Replikation und Methodik ab.',
) )
@@ -0,0 +1,21 @@
# Generated by Django 5.2.15 on 2026-07-07 20:10
from django.db import migrations
class Migration(migrations.Migration):
dependencies = [
('claims', '0005_allow_svg_draft_upload'),
]
operations = [
migrations.RemoveField(
model_name='claim',
name='evidence_level',
),
migrations.RemoveField(
model_name='claimdraft',
name='evidence_level',
),
]
@@ -0,0 +1,69 @@
from django.db import migrations, models
def migrate_possible_answer_text(apps, schema_editor):
PossibleAnswer = apps.get_model('claims', 'PossibleAnswer')
for obj in PossibleAnswer.objects.all():
obj.answer = (obj.response or obj.argument or '').strip()
obj.save(update_fields=['answer'])
def migrate_draft_json(apps, schema_editor):
ClaimDraft = apps.get_model('claims', 'ClaimDraft')
for draft in ClaimDraft.objects.all():
answers = []
for item in draft.counter_arguments or []:
if isinstance(item, str):
text = item.strip()
else:
text = (item.get('answer') or item.get('response') or '').strip()
if text:
answers.append({'answer': text})
draft.possible_answers = answers
draft.save(update_fields=['possible_answers'])
class Migration(migrations.Migration):
dependencies = [
('claims', '0006_remove_claim_evidence_level_and_more'),
]
operations = [
migrations.RenameModel(
old_name='CounterArgument',
new_name='PossibleAnswer',
),
migrations.AddField(
model_name='possibleanswer',
name='answer',
field=models.TextField(blank=True, default='', verbose_name='Antwort'),
),
migrations.RunPython(migrate_possible_answer_text, migrations.RunPython.noop),
migrations.RemoveField(
model_name='possibleanswer',
name='argument',
),
migrations.RemoveField(
model_name='possibleanswer',
name='response',
),
migrations.AlterModelOptions(
name='possibleanswer',
options={
'ordering': ['id'],
'verbose_name': 'Mögliche Antwort',
'verbose_name_plural': 'Mögliche Antworten',
},
),
migrations.AddField(
model_name='claimdraft',
name='possible_answers',
field=models.JSONField(blank=True, default=list, verbose_name='Mögliche Antworten'),
),
migrations.RunPython(migrate_draft_json, migrations.RunPython.noop),
migrations.RemoveField(
model_name='claimdraft',
name='counter_arguments',
),
]
@@ -0,0 +1,24 @@
# Generated by Django 5.2.15 on 2026-07-07 20:47
import django.db.models.deletion
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('claims', '0007_possible_answers'),
]
operations = [
migrations.AlterField(
model_name='possibleanswer',
name='answer',
field=models.TextField(verbose_name='Antwort'),
),
migrations.AlterField(
model_name='possibleanswer',
name='claim',
field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='possible_answers', to='claims.claim', verbose_name='Behauptung'),
),
]
+7 -35
View File
@@ -72,12 +72,6 @@ class Claim(models.Model):
WISSENSCHAFTLICHER_KONSENS = 'wissenschaftlicher_konsens', 'Wissenschaftlicher Konsens' WISSENSCHAFTLICHER_KONSENS = 'wissenschaftlicher_konsens', 'Wissenschaftlicher Konsens'
OFFENE_FRAGE = 'offene_frage', 'Offene Frage' OFFENE_FRAGE = 'offene_frage', 'Offene Frage'
class EvidenceLevel(models.TextChoices):
SEHR_STARK = 'sehr_stark', 'Sehr stark'
STARK = 'stark', 'Stark'
MITTEL = 'mittel', 'Mittel'
SCHWACH = 'schwach', 'Schwach'
category = models.ForeignKey( category = models.ForeignKey(
Category, Category,
on_delete=models.CASCADE, on_delete=models.CASCADE,
@@ -94,12 +88,6 @@ class Claim(models.Model):
) )
short_answer = models.TextField('Kurzantwort') short_answer = models.TextField('Kurzantwort')
detailed_explanation = models.TextField('Ausführliche Erklärung', blank=True) detailed_explanation = models.TextField('Ausführliche Erklärung', blank=True)
evidence_level = models.CharField(
'Evidenzlevel',
max_length=20,
choices=EvidenceLevel.choices,
default=EvidenceLevel.MITTEL,
)
tags = models.ManyToManyField(Tag, blank=True, verbose_name='Schlagwörter') tags = models.ManyToManyField(Tag, blank=True, verbose_name='Schlagwörter')
created_at = models.DateTimeField('Erstellt am', auto_now_add=True) created_at = models.DateTimeField('Erstellt am', auto_now_add=True)
updated_at = models.DateTimeField('Aktualisiert am', auto_now=True) updated_at = models.DateTimeField('Aktualisiert am', auto_now=True)
@@ -137,15 +125,6 @@ class Claim(models.Model):
} }
return mapping.get(self.status, 'secondary') return mapping.get(self.status, 'secondary')
def get_evidence_badge_class(self):
mapping = {
self.EvidenceLevel.SEHR_STARK: 'success',
self.EvidenceLevel.STARK: 'primary',
self.EvidenceLevel.MITTEL: 'info',
self.EvidenceLevel.SCHWACH: 'warning',
}
return mapping.get(self.evidence_level, 'secondary')
def evidence_upload_path(instance, filename): def evidence_upload_path(instance, filename):
ext = os.path.splitext(filename)[1].lower().lstrip('.') or 'bin' ext = os.path.splitext(filename)[1].lower().lstrip('.') or 'bin'
@@ -226,23 +205,22 @@ class Source(models.Model):
return self.title return self.title
class CounterArgument(models.Model): class PossibleAnswer(models.Model):
claim = models.ForeignKey( claim = models.ForeignKey(
Claim, Claim,
on_delete=models.CASCADE, on_delete=models.CASCADE,
related_name='counter_arguments', related_name='possible_answers',
verbose_name='Behauptung', verbose_name='Behauptung',
) )
argument = models.TextField('Gegenargument') answer = models.TextField('Antwort')
response = models.TextField('Antwort')
class Meta: class Meta:
verbose_name = 'Gegenargument' verbose_name = 'Mögliche Antwort'
verbose_name_plural = 'Gegenargumente' verbose_name_plural = 'Mögliche Antworten'
ordering = ['id'] ordering = ['id']
def __str__(self): def __str__(self):
return self.argument[:80] return self.answer[:80]
def draft_upload_path(instance, filename): def draft_upload_path(instance, filename):
@@ -305,15 +283,9 @@ class ClaimDraft(models.Model):
) )
short_answer = models.TextField('Kurzantwort', blank=True) short_answer = models.TextField('Kurzantwort', blank=True)
detailed_explanation = models.TextField('Ausführliche Erklärung', 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) tags = models.JSONField('Schlagwörter', default=list, blank=True)
sources = models.JSONField('Quellen', default=list, blank=True) sources = models.JSONField('Quellen', default=list, blank=True)
counter_arguments = models.JSONField('Gegenargumente', default=list, blank=True) possible_answers = models.JSONField('Mögliche Antworten', default=list, blank=True)
conversation = models.JSONField('Konversation', default=list, blank=True) conversation = models.JSONField('Konversation', default=list, blank=True)
ai_raw_response = models.JSONField('KI-Rohantwort', blank=True, null=True) ai_raw_response = models.JSONField('KI-Rohantwort', blank=True, null=True)
error_message = models.TextField('Fehlermeldung', blank=True) error_message = models.TextField('Fehlermeldung', blank=True)
+19 -11
View File
@@ -8,7 +8,6 @@ from django.conf import settings
from claims.models import Category, Claim from claims.models import Category, Claim
VALID_CLAIM_STATUSES = {choice[0] for choice in Claim.Status.choices} 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“. 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. Deine Aufgabe: Aus Artikeln, Bildern und Nutzereingaben einen überprüfbaren Fakteneintrag erstellen.
@@ -21,6 +20,7 @@ Regeln:
- Quellen müssen echte, nachvollziehbare Referenzen sein (URL aus Eingabe bevorzugen). - 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. - 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. - Icons für neue Kategorien: Bootstrap Icons Klassen wie bi-thermometer-half, bi-virus, bi-lightning-charge.
- Unter possible_answers: 24 kurze, diskussionsfertige Antworten auf die Behauptung formulieren.
Antworte ausschließlich als gültiges JSON mit dieser Struktur: Antworte ausschließlich als gültiges JSON mit dieser Struktur:
{ {
@@ -36,8 +36,7 @@ Antworte ausschließlich als gültiges JSON mit dieser Struktur:
"title": "Die zu prüfende Behauptung", "title": "Die zu prüfende Behauptung",
"status": "falsch|irrefuehrend|teilweise_richtig|unbelegt|wissenschaftlicher_konsens|offene_frage", "status": "falsch|irrefuehrend|teilweise_richtig|unbelegt|wissenschaftlicher_konsens|offene_frage",
"short_answer": "Kurze Einordnung", "short_answer": "Kurze Einordnung",
"detailed_explanation": "Ausführlichere Erklärung", "detailed_explanation": "Ausführlichere Erklärung"
"evidence_level": "sehr_stark|stark|mittel|schwach"
}, },
"tags": ["Schlagwort1", "Schlagwort2"], "tags": ["Schlagwort1", "Schlagwort2"],
"sources": [ "sources": [
@@ -48,10 +47,9 @@ Antworte ausschließlich als gültiges JSON mit dieser Struktur:
"description": "Kurzbeschreibung" "description": "Kurzbeschreibung"
} }
], ],
"counter_arguments": [ "possible_answers": [
{ {
"argument": "Häufiges Gegenargument", "answer": "Kurze, sachliche Antwort auf die Behauptung für die Diskussion"
"response": "Sachliche Antwort"
} }
], ],
"assistant_summary": "Kurze Zusammenfassung für den Admin (1-2 Sätze)" "assistant_summary": "Kurze Zusammenfassung für den Admin (1-2 Sätze)"
@@ -180,11 +178,8 @@ def _call_openai(messages, *, has_image=False):
def _validate_payload(payload): def _validate_payload(payload):
claim = payload.get('claim') or {} claim = payload.get('claim') or {}
status = claim.get('status', '') status = claim.get('status', '')
evidence = claim.get('evidence_level', '')
if status and status not in VALID_CLAIM_STATUSES: if status and status not in VALID_CLAIM_STATUSES:
raise AIAssistantError(f'Ungültiger Status von der KI: {status}') 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 return payload
@@ -234,6 +229,18 @@ def generate_draft_payload(
return _validate_payload(payload) return _validate_payload(payload)
def _normalize_possible_answers(items):
answers = []
for item in items or []:
if isinstance(item, str):
text = item.strip()
else:
text = (item.get('answer') or item.get('response') or '').strip()
if text:
answers.append({'answer': text})
return answers
def apply_payload_to_draft(draft, payload): def apply_payload_to_draft(draft, payload):
category = payload.get('category') or {} category = payload.get('category') or {}
claim = payload.get('claim') or {} claim = payload.get('claim') or {}
@@ -248,10 +255,11 @@ def apply_payload_to_draft(draft, payload):
draft.claim_status = claim.get('status', Claim.Status.UNBELEGT) draft.claim_status = claim.get('status', Claim.Status.UNBELEGT)
draft.short_answer = claim.get('short_answer', '') draft.short_answer = claim.get('short_answer', '')
draft.detailed_explanation = claim.get('detailed_explanation', '') draft.detailed_explanation = claim.get('detailed_explanation', '')
draft.evidence_level = claim.get('evidence_level', Claim.EvidenceLevel.MITTEL)
draft.tags = payload.get('tags') or [] draft.tags = payload.get('tags') or []
draft.sources = payload.get('sources') or [] draft.sources = payload.get('sources') or []
draft.counter_arguments = payload.get('counter_arguments') or [] draft.possible_answers = _normalize_possible_answers(
payload.get('possible_answers', payload.get('counter_arguments'))
)
draft.ai_raw_response = payload draft.ai_raw_response = payload
summary = payload.get('assistant_summary', 'Entwurf wurde erstellt.') summary = payload.get('assistant_summary', 'Entwurf wurde erstellt.')
draft.add_message('assistant', summary) draft.add_message('assistant', summary)
+8 -7
View File
@@ -4,7 +4,7 @@ import uuid
from django.db import transaction from django.db import transaction
from django.utils.text import slugify from django.utils.text import slugify
from claims.models import Category, Claim, CounterArgument, EvidenceFile, Source, Tag from claims.models import Category, Claim, EvidenceFile, PossibleAnswer, Source, Tag
class DraftPublishError(Exception): class DraftPublishError(Exception):
@@ -57,7 +57,6 @@ def publish_draft(draft):
status=draft.claim_status or Claim.Status.UNBELEGT, status=draft.claim_status or Claim.Status.UNBELEGT,
short_answer=draft.short_answer, short_answer=draft.short_answer,
detailed_explanation=draft.detailed_explanation, detailed_explanation=draft.detailed_explanation,
evidence_level=draft.evidence_level or Claim.EvidenceLevel.MITTEL,
) )
claim.tags.set(_get_or_create_tags(draft.tags)) claim.tags.set(_get_or_create_tags(draft.tags))
@@ -73,11 +72,13 @@ def publish_draft(draft):
description=source.get('description', ''), description=source.get('description', ''),
) )
for counter in draft.counter_arguments or []: for item in draft.possible_answers or []:
argument = (counter.get('argument') or '').strip() if isinstance(item, str):
response = (counter.get('response') or '').strip() answer = item.strip()
if argument and response: else:
CounterArgument.objects.create(claim=claim, argument=argument, response=response) answer = (item.get('answer') or item.get('response') or '').strip()
if answer:
PossibleAnswer.objects.create(claim=claim, answer=answer)
if draft.uploaded_file: if draft.uploaded_file:
_attach_uploaded_file(draft, claim) _attach_uploaded_file(draft, claim)
-11
View File
@@ -9,12 +9,6 @@ def status_label(value):
return dict(Claim.Status.choices).get(value, value) return dict(Claim.Status.choices).get(value, value)
@register.filter
def evidence_label(value):
from claims.models import Claim
return dict(Claim.EvidenceLevel.choices).get(value, value)
@register.filter @register.filter
def quoted(value): def quoted(value):
if not value: if not value:
@@ -25,8 +19,3 @@ def quoted(value):
@register.inclusion_tag('claims/partials/status_badge.html') @register.inclusion_tag('claims/partials/status_badge.html')
def status_badge(claim): def status_badge(claim):
return {'claim': claim} return {'claim': claim}
@register.inclusion_tag('claims/partials/evidence_badge.html')
def evidence_badge(claim):
return {'claim': claim}
+1 -1
View File
@@ -59,7 +59,7 @@ def category_detail(request, slug):
def claim_detail(request, slug): def claim_detail(request, slug):
claim = get_object_or_404( claim = get_object_or_404(
Claim.objects.select_related('category').prefetch_related( Claim.objects.select_related('category').prefetch_related(
'tags', 'sources', 'counter_arguments', 'evidence_files' 'tags', 'sources', 'possible_answers', 'evidence_files'
), ),
slug=slug, slug=slug,
) )
+13
View File
@@ -143,6 +143,19 @@ STATIC_URL = 'static/'
STATICFILES_DIRS = [BASE_DIR / 'static'] STATICFILES_DIRS = [BASE_DIR / 'static']
STATIC_ROOT = BASE_DIR / 'staticfiles' STATIC_ROOT = BASE_DIR / 'staticfiles'
STORAGES = {
'default': {
'BACKEND': 'django.core.files.storage.FileSystemStorage',
},
'staticfiles': {
'BACKEND': (
'django.contrib.staticfiles.storage.ManifestStaticFilesStorage'
if not DEBUG
else 'django.contrib.staticfiles.storage.StaticFilesStorage'
),
},
}
MEDIA_URL = 'media/' MEDIA_URL = 'media/'
MEDIA_ROOT = BASE_DIR / 'media' MEDIA_ROOT = BASE_DIR / 'media'
+37 -2
View File
@@ -252,6 +252,42 @@ a:hover .claim-card {
border-radius: 10px 0 0 10px; border-radius: 10px 0 0 10px;
} }
.fk-search .input-group-text,
.fk-search .fk-search-input,
.fk-search-filter {
min-height: 2.625rem;
font-size: 0.9rem;
}
.fk-search .input-group-text,
.fk-search .fk-search-input {
padding-top: 0.5rem;
padding-bottom: 0.5rem;
}
.fk-search .fk-search-input {
border-radius: 0 10px 10px 0;
}
.fk-search:focus-within .input-group-text,
.fk-search:focus-within .fk-search-input {
border-color: var(--fk-accent);
}
.fk-search:focus-within {
box-shadow: 0 0 0 3px color-mix(in srgb, var(--fk-accent) 18%, transparent);
border-radius: 10px;
}
.fk-search-filter {
padding-top: 0.5rem;
padding-bottom: 0.5rem;
}
.fk-search-form .col-sm-auto {
min-width: 11rem;
}
.btn-primary { .btn-primary {
background-color: var(--fk-accent); background-color: var(--fk-accent);
border-color: var(--fk-accent); border-color: var(--fk-accent);
@@ -306,8 +342,7 @@ a:hover .claim-card {
gap: 0.35rem; gap: 0.35rem;
} }
.fk-status, .fk-status {
.fk-evidence {
display: inline-flex; display: inline-flex;
align-items: center; align-items: center;
gap: 0.4rem; gap: 0.4rem;
@@ -10,7 +10,7 @@
{% else %} {% else %}
<div class="fk-claim-help"> <div class="fk-claim-help">
Neue Behauptung anlegen: zuerst Titel und Kurzantwort ausfüllen, dann speichern. Neue Behauptung anlegen: zuerst Titel und Kurzantwort ausfüllen, dann speichern.
Nachweise, Quellen und Gegenargumente können danach ergänzt werden. Nachweise, Quellen und mögliche Antworten können danach ergänzt werden.
</div> </div>
{% endif %} {% endif %}
{{ block.super }} {{ block.super }}
+2 -7
View File
@@ -81,11 +81,6 @@
{{ form.claim_status }} {{ form.claim_status }}
</div> </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"> <div class="field-box">
<label for="{{ form.short_answer.id_for_label }}">{{ form.short_answer.label }}</label> <label for="{{ form.short_answer.id_for_label }}">{{ form.short_answer.label }}</label>
{{ form.short_answer }} {{ form.short_answer }}
@@ -107,8 +102,8 @@
</div> </div>
<div class="field-box"> <div class="field-box">
<label for="{{ form.counter_arguments_json.id_for_label }}">{{ form.counter_arguments_json.label }}</label> <label for="{{ form.possible_answers_json.id_for_label }}">{{ form.possible_answers_json.label }}</label>
{{ form.counter_arguments_json }} {{ form.possible_answers_json }}
</div> </div>
<div class="actions-bar"> <div class="actions-bar">
+3 -3
View File
@@ -20,12 +20,12 @@
</div> </div>
</div> </div>
<form method="get" class="row g-2 mb-4" data-live-search data-live-target="#category-results"> <form method="get" class="row g-2 mb-4 fk-search-form" 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 }}" autocomplete="off"> {% include "claims/partials/search_input.html" with value=query aria_label="Behauptungen durchsuchen" %}
</div> </div>
<div class="col-12 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 fk-search-filter">
<option value="">Alle Status</option> <option value="">Alle Status</option>
{% for value, label in status_choices %} {% for value, label in status_choices %}
<option value="{{ value }}"{% if status == value %} selected{% endif %}>{{ label }}</option> <option value="{{ value }}"{% if status == value %} selected{% endif %}>{{ label }}</option>
+6 -18
View File
@@ -18,7 +18,6 @@
<div class="fk-meta-stack mb-3"> <div class="fk-meta-stack mb-3">
{% status_badge claim %} {% status_badge claim %}
{% evidence_badge claim %}
</div> </div>
<div class="fk-card fk-answer-card"> <div class="fk-card fk-answer-card">
@@ -90,24 +89,13 @@
</section> </section>
{% endif %} {% endif %}
{% if claim.counter_arguments.all %} {% if claim.possible_answers.all %}
<section class="fk-section"> <section class="fk-section">
<p class="fk-section-label">Gegenargumente</p> <p class="fk-section-label">Mögliche Antworten</p>
<div class="accordion fk-accordion" id="counterArgs"> <div class="d-flex flex-column gap-2">
{% for ca in claim.counter_arguments.all %} {% for item in claim.possible_answers.all %}
<div class="accordion-item"> <div class="fk-card fk-section-body">
<h2 class="accordion-header"> {{ item.answer|linebreaks }}
<button class="accordion-button collapsed py-2 small" type="button"
data-bs-toggle="collapse" data-bs-target="#collapse{{ forloop.counter }}">
{{ ca.argument }}
</button>
</h2>
<div id="collapse{{ forloop.counter }}" class="accordion-collapse collapse"
data-bs-parent="#counterArgs">
<div class="accordion-body py-2">
{{ ca.response|linebreaks }}
</div>
</div>
</div> </div>
{% endfor %} {% endfor %}
</div> </div>
+1 -4
View File
@@ -6,10 +6,7 @@
<header class="mb-4"> <header class="mb-4">
<h1 class="fk-page-title">Themen</h1> <h1 class="fk-page-title">Themen</h1>
<form method="get" action="{% url 'claims:home' %}" class="mb-2" data-live-search data-live-target="#home-results"> <form method="get" action="{% url 'claims:home' %}" class="mb-2" data-live-search data-live-target="#home-results">
<div class="input-group"> {% include "claims/partials/search_input.html" with value=query aria_label="Kategorien durchsuchen" %}
<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" autocomplete="off">
</div>
</form> </form>
<div class="d-flex justify-content-between align-items-center"> <div class="d-flex justify-content-between align-items-center">
<a href="{% url 'claims:search' %}" class="small text-muted text-decoration-none">Alle Behauptungen</a> <a href="{% url 'claims:search' %}" class="small text-muted text-decoration-none">Alle Behauptungen</a>
@@ -1,4 +0,0 @@
<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 @@
<div class="input-group fk-search">
<span class="input-group-text border-end-0"><i class="bi bi-search" aria-hidden="true"></i></span>
<input type="search"
name="{{ name|default:'q' }}"
{% if id %}id="{{ id }}"{% else %}id="q"{% endif %}
class="form-control fk-search-input border-start-0 ps-0"
placeholder="{{ placeholder|default:'Suchen…' }}"
value="{{ value|default:'' }}"
{% if autofocus %}autofocus{% endif %}
autocomplete="off"
aria-label="{% firstof aria_label placeholder 'Suchen' %}">
</div>
+4 -5
View File
@@ -5,13 +5,12 @@
{% block content %} {% block content %}
<h1 class="fk-page-title">Suche</h1> <h1 class="fk-page-title">Suche</h1>
<form method="get" action="{% url 'claims:search' %}" class="row g-2 mb-4" data-live-search data-live-target="#search-results"> <form method="get" action="{% url 'claims:search' %}" class="row g-2 mb-4 fk-search-form" 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" {% include "claims/partials/search_input.html" with value=query placeholder="Behauptung suchen…" autofocus=True aria_label="Behauptung suchen" %}
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 fk-search-filter">
<option value="">Alle Themen</option> <option value="">Alle Themen</option>
{% for cat in categories %} {% for cat in categories %}
<option value="{{ cat.slug }}"{% if category_slug == cat.slug %} selected{% endif %}>{{ cat.name }}</option> <option value="{{ cat.slug }}"{% if category_slug == cat.slug %} selected{% endif %}>{{ cat.name }}</option>
@@ -19,7 +18,7 @@
</select> </select>
</div> </div>
<div class="col-6"> <div class="col-6">
<select id="status" name="status" class="form-select form-select-sm"> <select id="status" name="status" class="form-select fk-search-filter">
<option value="">Alle Status</option> <option value="">Alle Status</option>
{% for value, label in status_choices %} {% for value, label in status_choices %}
<option value="{{ value }}"{% if status == value %} selected{% endif %}>{{ label }}</option> <option value="{{ value }}"{% if status == value %} selected{% endif %}>{{ label }}</option>