Compare commits

..

3 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
22 changed files with 276 additions and 179 deletions
+19 -6
View File
@@ -1,6 +1,6 @@
# 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
@@ -42,7 +42,7 @@ python manage.py migrate
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
@@ -117,11 +117,24 @@ cd /pfad/zu/ArgumentAtlas
source .venv/bin/activate
pip install -r requirements.txt
set -a && source /etc/faktenkompass.env && set +a
python manage.py migrate
python manage.py collectstatic --noinput
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.
### 3. Daphne manuell testen
@@ -242,7 +255,7 @@ ArgumentAtlas/
│ ├── settings.py
│ └── urls.py
├── claims/ # Haupt-App
│ ├── models.py # Category, Claim, Source, EvidenceFile, CounterArgument, Tag
│ ├── models.py # Category, Claim, Source, EvidenceFile, PossibleAnswer, Tag
│ ├── views.py
│ ├── urls.py
│ ├── admin.py
@@ -257,14 +270,14 @@ ArgumentAtlas/
- **Startseite:** Kategorien als Karten mit Behauptungsanzahl
- **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
- **Admin:** Alle Inhalte über Django Admin pflegen
- **SEO:** Sprechende URLs (`/kategorie/klimawandel/`, `/behauptung/...`)
## 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
@@ -292,7 +305,7 @@ export OPENAI_API_KEY=sk-...
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
3. **Entwurf generieren** die KI schlägt Kategorie, Behauptung, Quellen und mögliche Antworten vor
4. Entwurf prüfen, bearbeiten, ggf. **KI erneut fragen**
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 (
ClaimAdminForm,
CounterArgumentInlineForm,
PossibleAnswerInlineForm,
EvidenceFileInlineForm,
SourceInlineForm,
)
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:
@@ -59,14 +59,14 @@ class SourceInline(FaktenAdminMixin, admin.StackedInline):
verbose_name_plural = 'Quellen'
class CounterArgumentInline(FaktenAdminMixin, admin.StackedInline):
model = CounterArgument
form = CounterArgumentInlineForm
class PossibleAnswerInline(FaktenAdminMixin, admin.StackedInline):
model = PossibleAnswer
form = PossibleAnswerInlineForm
extra = 1
fields = ('argument', 'response')
fields = ('answer',)
classes = ('collapse-open',)
verbose_name = 'Gegenargument'
verbose_name_plural = 'Gegenargumente'
verbose_name = 'Mögliche Antwort'
verbose_name_plural = 'Mögliche Antworten'
@admin.register(Tag)
@@ -106,20 +106,20 @@ class CategoryAdmin(FaktenAdminMixin, 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', 'updated_at')
list_display_links = ('title',)
list_filter = ('category', 'status', 'evidence_level', 'tags')
list_filter = ('category', 'status', 'tags')
search_fields = ('title', 'short_answer', 'detailed_explanation')
prepopulated_fields = {'slug': ('title',)}
filter_horizontal = ('tags',)
autocomplete_fields = ('category',)
save_on_top = True
date_hierarchy = 'updated_at'
inlines = [EvidenceFileInline, SourceInline, CounterArgumentInline]
inlines = [EvidenceFileInline, SourceInline, PossibleAnswerInline]
readonly_fields = ('public_link', 'created_at', 'updated_at')
fieldsets = (
('Einordnung', {
'fields': ('category', 'status', 'evidence_level', 'tags'),
'fields': ('category', 'status', 'tags'),
}),
('Behauptung', {
'fields': ('title', 'slug', 'public_link'),
@@ -175,16 +175,16 @@ class SourceAdmin(FaktenAdminMixin, admin.ModelAdmin):
search_fields = ('title', 'organization', 'description')
@admin.register(CounterArgument)
class CounterArgumentAdmin(FaktenAdminMixin, admin.ModelAdmin):
form = CounterArgumentInlineForm
list_display = ('argument_preview', 'claim')
@admin.register(PossibleAnswer)
class PossibleAnswerAdmin(FaktenAdminMixin, admin.ModelAdmin):
form = PossibleAnswerInlineForm
list_display = ('answer_preview', 'claim')
list_filter = ('claim__category',)
search_fields = ('argument', 'response')
search_fields = ('answer', 'claim__title')
@admin.display(description='Gegenargument')
def argument_preview(self, obj):
return obj.argument[:80]
@admin.display(description='Antwort')
def answer_preview(self, obj):
return obj.answer[:80]
@admin.register(ClaimDraft)
+4 -5
View File
@@ -1,6 +1,6 @@
from django import forms
from .models import Claim, CounterArgument, EvidenceFile, Source
from .models import Claim, PossibleAnswer, EvidenceFile, Source
class ClaimAdminForm(forms.ModelForm):
@@ -26,13 +26,12 @@ class SourceInlineForm(forms.ModelForm):
}
class CounterArgumentInlineForm(forms.ModelForm):
class PossibleAnswerInlineForm(forms.ModelForm):
class Meta:
model = CounterArgument
model = PossibleAnswer
fields = '__all__'
widgets = {
'argument': forms.Textarea(attrs={'rows': 3, 'class': 'fk-admin-textarea'}),
'response': forms.Textarea(attrs={'rows': 4, 'class': 'fk-admin-textarea'}),
'answer': forms.Textarea(attrs={'rows': 4, 'class': 'fk-admin-textarea'}),
}
+9 -11
View File
@@ -53,8 +53,8 @@ class ReviewDraftForm(forms.ModelForm):
required=False,
widget=forms.Textarea(attrs={'class': 'form-control font-monospace', 'rows': 6}),
)
counter_arguments_json = forms.CharField(
label='Gegenargumente (JSON)',
possible_answers_json = forms.CharField(
label='Mögliche Antworten (JSON)',
required=False,
widget=forms.Textarea(attrs={'class': 'form-control font-monospace', 'rows': 6}),
)
@@ -73,7 +73,6 @@ class ReviewDraftForm(forms.ModelForm):
'claim_status',
'short_answer',
'detailed_explanation',
'evidence_level',
'new_category_name',
'new_category_description',
'new_category_icon',
@@ -83,7 +82,6 @@ class ReviewDraftForm(forms.ModelForm):
'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'}),
@@ -97,8 +95,8 @@ class ReviewDraftForm(forms.ModelForm):
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
self.fields['possible_answers_json'].initial = json.dumps(
instance.possible_answers or [], ensure_ascii=False, indent=2
)
if instance.category_slug:
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.')
return data
def clean_counter_arguments_json(self):
raw = (self.cleaned_data.get('counter_arguments_json') or '').strip()
def clean_possible_answers_json(self):
raw = (self.cleaned_data.get('possible_answers_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
raise forms.ValidationError('Ungültiges JSON für mögliche Antworten.') from exc
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
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']
draft.possible_answers = self.cleaned_data['possible_answers_json']
category = self.cleaned_data.get('category_choice')
if category:
draft.category_slug = category.slug
+9 -28
View File
@@ -1,6 +1,6 @@
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):
@@ -87,7 +87,6 @@ class Command(BaseCommand):
'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.'
),
evidence_level=Claim.EvidenceLevel.SEHR_STARK,
)
c1.tags.add(tags['impfstoff'], tags['gesundheit'])
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',
description='Offizielle Informationen des RKI zu Wirkweise und Sicherheit der Impfstoffe.',
)
CounterArgument.objects.create(
PossibleAnswer.objects.create(
claim=c1,
argument='Die mRNA ist künstlich und kann deshalb die Zelle dauerhaft verändern.',
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.',
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.',
)
c2 = Claim.objects.create(
@@ -109,7 +107,6 @@ class Command(BaseCommand):
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.',
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'])
Source.objects.create(
@@ -126,7 +123,6 @@ class Command(BaseCommand):
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.',
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):
@@ -136,7 +132,6 @@ class Command(BaseCommand):
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.',
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'])
Source.objects.create(
@@ -146,10 +141,9 @@ class Command(BaseCommand):
url='https://www.ipcc.ch/report/ar6/syr/',
description='Aktueller Synthesebericht des Weltklimarats.',
)
CounterArgument.objects.create(
PossibleAnswer.objects.create(
claim=c1,
argument='Früher gab es auch warme und kalte Perioden ohne Industrie.',
response='Natürliche Schwankungen sind bekannt, aber die Geschwindigkeit der aktuellen Erwärmung passt zu Treibhausgasanstieg, nicht zu dominanten natürlichen Zyklen.',
answer='Natürliche Schwankungen sind bekannt, aber die Geschwindigkeit der aktuellen Erwärmung passt zu Treibhausgasanstieg, nicht zu dominanten natürlichen Zyklen.',
)
Claim.objects.create(
@@ -158,7 +152,6 @@ class Command(BaseCommand):
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.',
detailed_explanation='Große wissenschaftliche Gesellschaften weltweit befürworten diese Einschätzung.',
evidence_level=Claim.EvidenceLevel.SEHR_STARK,
)
Claim.objects.create(
@@ -167,7 +160,6 @@ class Command(BaseCommand):
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.',
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):
@@ -177,13 +169,11 @@ class Command(BaseCommand):
status=Claim.Status.TEILWEISE_RICHTIG,
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.',
evidence_level=Claim.EvidenceLevel.MITTEL,
)
c1.tags.add(tags['erneuerbare'])
CounterArgument.objects.create(
PossibleAnswer.objects.create(
claim=c1,
argument='Wenn die Sonne nicht scheint und der Wind nicht weht, fällt das Netz aus.',
response='Energiesysteme kombinieren Speicher, Netze, Importe und Reservekapazitäten statt auf eine einzige Quelle zu setzen.',
answer='Energiesysteme kombinieren Speicher, Netze, Importe und Reservekapazitäten statt auf eine einzige Quelle zu setzen.',
)
Claim.objects.create(
@@ -192,7 +182,6 @@ class Command(BaseCommand):
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.',
detailed_explanation='Lebenszyklusanalysen zeigen niedrige CO₂-Emissionen, aber Endlagerung und Unfallrisiken sind zentrale Gegenargumente.',
evidence_level=Claim.EvidenceLevel.STARK,
)
Claim.objects.create(
@@ -201,7 +190,6 @@ class Command(BaseCommand):
status=Claim.Status.IRREFUEHREND,
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.',
evidence_level=Claim.EvidenceLevel.MITTEL,
)
def _create_migration_claims(self, category, tags):
@@ -211,7 +199,6 @@ class Command(BaseCommand):
status=Claim.Status.IRREFUEHREND,
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.',
evidence_level=Claim.EvidenceLevel.STARK,
)
c1.tags.add(tags['migration'])
Source.objects.create(
@@ -228,7 +215,6 @@ class Command(BaseCommand):
status=Claim.Status.TEILWEISE_RICHTIG,
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.',
evidence_level=Claim.EvidenceLevel.STARK,
)
def _create_economy_claims(self, category, tags):
@@ -238,7 +224,6 @@ class Command(BaseCommand):
status=Claim.Status.IRREFUEHREND,
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.',
evidence_level=Claim.EvidenceLevel.STARK,
)
Claim.objects.create(
@@ -247,7 +232,6 @@ class Command(BaseCommand):
status=Claim.Status.FALSCH,
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.',
evidence_level=Claim.EvidenceLevel.SEHR_STARK,
)
def _create_politics_claims(self, category, tags):
@@ -257,7 +241,6 @@ class Command(BaseCommand):
status=Claim.Status.TEILWEISE_RICHTIG,
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.',
evidence_level=Claim.EvidenceLevel.STARK,
)
def _create_science_claims(self, category, tags):
@@ -267,11 +250,9 @@ class Command(BaseCommand):
status=Claim.Status.FALSCH,
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.',
evidence_level=Claim.EvidenceLevel.SEHR_STARK,
)
c1.tags.add(tags['forschung'])
CounterArgument.objects.create(
PossibleAnswer.objects.create(
claim=c1,
argument='Wenn eine Studie in einer renommierten Zeitschrift steht, ist sie bewiesen.',
response='Renommierte Journale publizieren auch widerlegte Ergebnisse. Die Qualität hängt von Replikation und Methodik ab.',
answer='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'
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,
on_delete=models.CASCADE,
@@ -94,12 +88,6 @@ class Claim(models.Model):
)
short_answer = models.TextField('Kurzantwort')
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')
created_at = models.DateTimeField('Erstellt am', auto_now_add=True)
updated_at = models.DateTimeField('Aktualisiert am', auto_now=True)
@@ -137,15 +125,6 @@ class Claim(models.Model):
}
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):
ext = os.path.splitext(filename)[1].lower().lstrip('.') or 'bin'
@@ -226,23 +205,22 @@ class Source(models.Model):
return self.title
class CounterArgument(models.Model):
class PossibleAnswer(models.Model):
claim = models.ForeignKey(
Claim,
on_delete=models.CASCADE,
related_name='counter_arguments',
related_name='possible_answers',
verbose_name='Behauptung',
)
argument = models.TextField('Gegenargument')
response = models.TextField('Antwort')
answer = models.TextField('Antwort')
class Meta:
verbose_name = 'Gegenargument'
verbose_name_plural = 'Gegenargumente'
verbose_name = 'Mögliche Antwort'
verbose_name_plural = 'Mögliche Antworten'
ordering = ['id']
def __str__(self):
return self.argument[:80]
return self.answer[:80]
def draft_upload_path(instance, filename):
@@ -305,15 +283,9 @@ class ClaimDraft(models.Model):
)
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)
possible_answers = models.JSONField('Mögliche Antworten', 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)
+19 -11
View File
@@ -8,7 +8,6 @@ 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.
@@ -21,6 +20,7 @@ Regeln:
- 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.
- Unter possible_answers: 24 kurze, diskussionsfertige Antworten auf die Behauptung formulieren.
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",
"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"
"detailed_explanation": "Ausführlichere Erklärung"
},
"tags": ["Schlagwort1", "Schlagwort2"],
"sources": [
@@ -48,10 +47,9 @@ Antworte ausschließlich als gültiges JSON mit dieser Struktur:
"description": "Kurzbeschreibung"
}
],
"counter_arguments": [
"possible_answers": [
{
"argument": "Häufiges Gegenargument",
"response": "Sachliche Antwort"
"answer": "Kurze, sachliche Antwort auf die Behauptung für die Diskussion"
}
],
"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):
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
@@ -234,6 +229,18 @@ def generate_draft_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):
category = payload.get('category') 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.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.possible_answers = _normalize_possible_answers(
payload.get('possible_answers', payload.get('counter_arguments'))
)
draft.ai_raw_response = payload
summary = payload.get('assistant_summary', 'Entwurf wurde erstellt.')
draft.add_message('assistant', summary)
+8 -7
View File
@@ -4,7 +4,7 @@ import uuid
from django.db import transaction
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):
@@ -57,7 +57,6 @@ def publish_draft(draft):
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))
@@ -73,11 +72,13 @@ def publish_draft(draft):
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)
for item in draft.possible_answers or []:
if isinstance(item, str):
answer = item.strip()
else:
answer = (item.get('answer') or item.get('response') or '').strip()
if answer:
PossibleAnswer.objects.create(claim=claim, answer=answer)
if draft.uploaded_file:
_attach_uploaded_file(draft, claim)
-11
View File
@@ -9,12 +9,6 @@ def status_label(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
def quoted(value):
if not value:
@@ -25,8 +19,3 @@ def quoted(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}
+1 -1
View File
@@ -59,7 +59,7 @@ def category_detail(request, slug):
def claim_detail(request, slug):
claim = get_object_or_404(
Claim.objects.select_related('category').prefetch_related(
'tags', 'sources', 'counter_arguments', 'evidence_files'
'tags', 'sources', 'possible_answers', 'evidence_files'
),
slug=slug,
)
+37 -2
View File
@@ -252,6 +252,42 @@ a:hover .claim-card {
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 {
background-color: var(--fk-accent);
border-color: var(--fk-accent);
@@ -306,8 +342,7 @@ a:hover .claim-card {
gap: 0.35rem;
}
.fk-status,
.fk-evidence {
.fk-status {
display: inline-flex;
align-items: center;
gap: 0.4rem;
@@ -10,7 +10,7 @@
{% 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.
Nachweise, Quellen und mögliche Antworten können danach ergänzt werden.
</div>
{% endif %}
{{ block.super }}
+2 -7
View File
@@ -81,11 +81,6 @@
{{ 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 }}
@@ -107,8 +102,8 @@
</div>
<div class="field-box">
<label for="{{ form.counter_arguments_json.id_for_label }}">{{ form.counter_arguments_json.label }}</label>
{{ form.counter_arguments_json }}
<label for="{{ form.possible_answers_json.id_for_label }}">{{ form.possible_answers_json.label }}</label>
{{ form.possible_answers_json }}
</div>
<div class="actions-bar">
+3 -3
View File
@@ -20,12 +20,12 @@
</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">
<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 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>
{% for value, label in status_choices %}
<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">
{% status_badge claim %}
{% evidence_badge claim %}
</div>
<div class="fk-card fk-answer-card">
@@ -90,24 +89,13 @@
</section>
{% endif %}
{% if claim.counter_arguments.all %}
{% if claim.possible_answers.all %}
<section class="fk-section">
<p class="fk-section-label">Gegenargumente</p>
<div class="accordion fk-accordion" id="counterArgs">
{% for ca in claim.counter_arguments.all %}
<div class="accordion-item">
<h2 class="accordion-header">
<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>
<p class="fk-section-label">Mögliche Antworten</p>
<div class="d-flex flex-column gap-2">
{% for item in claim.possible_answers.all %}
<div class="fk-card fk-section-body">
{{ item.answer|linebreaks }}
</div>
{% endfor %}
</div>
+1 -4
View File
@@ -6,10 +6,7 @@
<header class="mb-4">
<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">
<div class="input-group">
<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>
{% include "claims/partials/search_input.html" with value=query aria_label="Kategorien durchsuchen" %}
</form>
<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>
@@ -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 %}
<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">
<input type="search" id="q" name="q" class="form-control form-control-sm"
placeholder="Behauptung suchen…" value="{{ query }}" autofocus autocomplete="off">
{% include "claims/partials/search_input.html" with value=query placeholder="Behauptung suchen…" autofocus=True aria_label="Behauptung suchen" %}
</div>
<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>
{% for cat in categories %}
<option value="{{ cat.slug }}"{% if category_slug == cat.slug %} selected{% endif %}>{{ cat.name }}</option>
@@ -19,7 +18,7 @@
</select>
</div>
<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>
{% for value, label in status_choices %}
<option value="{{ value }}"{% if status == value %} selected{% endif %}>{{ label }}</option>