diff --git a/README.md b/README.md index 0c11757..7b54d42 100644 --- a/README.md +++ b/README.md @@ -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 @@ -255,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 @@ -270,14 +270,14 @@ ArgumentAtlas/ - **Startseite:** Kategorien als Karten mit Behauptungsanzahl - **Kategorieseite:** Behauptungen filtern und durchsuchen -- **Behauptungsdetail:** Status, Kurzantwort, Nachweise (Bilder/PDFs), 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 @@ -305,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 diff --git a/claims/admin.py b/claims/admin.py index a331ae5..357a990 100644 --- a/claims/admin.py +++ b/claims/admin.py @@ -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) @@ -115,7 +115,7 @@ class ClaimAdmin(FaktenAdminMixin, admin.ModelAdmin): 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', { @@ -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) diff --git a/claims/admin_forms.py b/claims/admin_forms.py index 0f75bcf..99aa613 100644 --- a/claims/admin_forms.py +++ b/claims/admin_forms.py @@ -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'}), } diff --git a/claims/forms.py b/claims/forms.py index de34f4f..6e1834d 100644 --- a/claims/forms.py +++ b/claims/forms.py @@ -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}), ) @@ -95,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() @@ -119,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 diff --git a/claims/management/commands/load_sample_data.py b/claims/management/commands/load_sample_data.py index 8be8029..2eb2830 100644 --- a/claims/management/commands/load_sample_data.py +++ b/claims/management/commands/load_sample_data.py @@ -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): @@ -96,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( @@ -142,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( @@ -173,10 +171,9 @@ class Command(BaseCommand): detailed_explanation='Studien zeigen Szenarien mit nahezu 100 % erneuerbarem Strom, erfordern aber Infrastruktur und Sektorkopplung.', ) 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( @@ -255,8 +252,7 @@ class Command(BaseCommand): detailed_explanation='Peer Review filtert Fehler, garantiert aber keine Wahrheit. Metaanalysen fassen viele Studien zusammen.', ) 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.', ) diff --git a/claims/migrations/0007_possible_answers.py b/claims/migrations/0007_possible_answers.py new file mode 100644 index 0000000..8610f76 --- /dev/null +++ b/claims/migrations/0007_possible_answers.py @@ -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', + ), + ] diff --git a/claims/migrations/0008_alter_possibleanswer_answer_and_more.py b/claims/migrations/0008_alter_possibleanswer_answer_and_more.py new file mode 100644 index 0000000..1edda43 --- /dev/null +++ b/claims/migrations/0008_alter_possibleanswer_answer_and_more.py @@ -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'), + ), + ] diff --git a/claims/models.py b/claims/models.py index 926a92f..8b58149 100644 --- a/claims/models.py +++ b/claims/models.py @@ -205,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): @@ -286,7 +285,7 @@ class ClaimDraft(models.Model): detailed_explanation = models.TextField('Ausführliche Erklärung', 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) diff --git a/claims/services/ai_assistant.py b/claims/services/ai_assistant.py index 6017ed4..8d3b957 100644 --- a/claims/services/ai_assistant.py +++ b/claims/services/ai_assistant.py @@ -20,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: 2–4 kurze, diskussionsfertige Antworten auf die Behauptung formulieren. Antworte ausschließlich als gültiges JSON mit dieser Struktur: { @@ -46,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)" @@ -229,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 {} @@ -245,7 +257,9 @@ def apply_payload_to_draft(draft, payload): draft.detailed_explanation = claim.get('detailed_explanation', '') 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) diff --git a/claims/services/draft_publisher.py b/claims/services/draft_publisher.py index a402b74..6e0d727 100644 --- a/claims/services/draft_publisher.py +++ b/claims/services/draft_publisher.py @@ -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): @@ -72,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) diff --git a/claims/views.py b/claims/views.py index b1fe758..4cc2c97 100644 --- a/claims/views.py +++ b/claims/views.py @@ -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, ) diff --git a/static/css/custom.css b/static/css/custom.css index dbe73e0..a117a29 100644 --- a/static/css/custom.css +++ b/static/css/custom.css @@ -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); diff --git a/templates/admin/claims/claim/change_form.html b/templates/admin/claims/claim/change_form.html index 2cee33e..bb0dd7b 100644 --- a/templates/admin/claims/claim/change_form.html +++ b/templates/admin/claims/claim/change_form.html @@ -10,7 +10,7 @@ {% else %}