feat(multitenancy): Sprach-Whitelist + Translator-Override + Forum-Quellenklasse

Vorbereitung fuer jp_demo-Organisation: drei separate Sprach-Settings statt
einer einzigen output_language.

org_settings.py:
- get_source_language_whitelist: Liste erlaubter Quellsprachen als JSON-Array
  (z.B. ["ja"] beschraenkt RSS/Telegram auf japanische Quellen).
- get_research_language: Sprache fuer WebSearch-Prompts (Default: output_language).
- get_translator_enabled: Pro-Org-Override des globalen TRANSLATOR_ENABLED-Flags.
- LANGUAGE_DISPLAY_NAMES um ja/zh/ko/ru/ar/fa/he/fr/es erweitert.

source_rules.py:
- get_feeds_with_metadata filtert nach source_language_whitelist, wenn gesetzt.
- Feeds ohne primary_language fallen bei aktiver Whitelist raus (gewollt).
- SELECT um media_type erweitert, damit es im Feed-Dict ankommt.

orchestrator.py:
- Laedt research_language, source_language_whitelist, translator_enabled aus
  den Org-Settings.
- Wenn Whitelist gesetzt: international_sources-Flag wird ignoriert.
- research_language_iso wird an researcher.search() weitergegeben.
- translate_articles bekommt enabled-Parameter aus Org-Setting.
- Geoparsing ueberspringt media_type='forum' Artikel.
- SELECT * FROM articles wird zu JOIN sources, damit media_type beim Reload
  am Article-Dict haengt.

researcher.py:
- search() akzeptiert research_language_iso. Asymmetrische Sprach-Auswahl
  (Recherche != Output) erzeugt eigene Prompt-Anweisung "primaer in Quell-
  sprache, englische Region-Outlets erlaubt".

translator.py:
- translate_articles akzeptiert enabled-Parameter. Ueberschreibt die globale
  TRANSLATOR_ENABLED-Konstante pro Aufruf.

factchecker.py:
- _format_articles_text filtert Artikel mit media_type='forum' aus. Anonyme
  Foren-Posts gelten nicht als Faktenbeleg.

rss_parser.py:
- _fetch_feed traegt media_type aus feed_config ins Article-Dict ein,
  damit downstream Pipeline-Schritte Foren-Quellen erkennen.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Dieser Commit ist enthalten in:
2026-05-22 00:12:56 +02:00
Ursprung 75038939b4
Commit 379d14518c
7 geänderte Dateien mit 226 neuen und 20 gelöschten Zeilen

Datei anzeigen

@@ -744,14 +744,42 @@ class AgentOrchestrator:
description = incident["description"] or ""
incident_type = incident["type"] or "adhoc"
international = bool(incident["international_sources"]) if "international_sources" in incident.keys() else True
# Wenn die Org eine Sprach-Whitelist gesetzt hat, ist 'international' bedeutungslos —
# die Whitelist gewinnt. Wir setzen 'international' auf True, damit der nachgelagerte
# Code alle (durch Whitelist gefilterten) Feeds in Betracht zieht. Tatsaechliche
# Einschraenkung passiert in get_feeds_with_metadata.
# Hinweis: source_lang_whitelist wird weiter unten geladen.
include_telegram = bool(incident["include_telegram"]) if "include_telegram" in incident.keys() else False
visibility = incident["visibility"] if "visibility" in incident.keys() else "public"
created_by = incident["created_by"] if "created_by" in incident.keys() else None
tenant_id = incident["tenant_id"] if "tenant_id" in incident.keys() else None
# Org-Sprache fuer alle KI-Agenten (Lagebild, Faktencheck, Recherche)
from services.org_settings import get_org_language, language_display
from services.org_settings import (
get_org_language, language_display, get_research_language,
get_source_language_whitelist, get_translator_enabled,
)
output_language_iso = await get_org_language(db, tenant_id) if tenant_id else "de"
output_language = language_display(output_language_iso)
# research_language steuert nur den WebSearch-Prompt ("suche in Sprache X").
# Default = output_language_iso. Bei jp_demo wird das auf 'ja' gesetzt, waehrend
# output_language_iso 'de' bleibt (Lagebild auf Deutsch, Recherche auf Japanisch).
research_language_iso = await get_research_language(db, tenant_id) if tenant_id else output_language_iso
# source_language_whitelist schraenkt RSS-/Telegram-Quellenpool ein (z.B. ['ja']).
# Wenn gesetzt, wird das incident-level Flag international_sources ignoriert
# (Whitelist ist explizit, das Flag ist Default-Verhalten).
source_lang_whitelist = await get_source_language_whitelist(db, tenant_id) if tenant_id else None
# Pro-Org-Override des globalen TRANSLATOR_ENABLED-Flags.
translator_enabled = await get_translator_enabled(db, tenant_id)
# Whitelist gewinnt ueber das incident-Flag international_sources:
# wenn die Org eine Sprach-Whitelist hat, sind alle gewaehlten Feeds
# ohnehin "Wunsch-Sprache" — kein Splitting in primary/international noetig.
if source_lang_whitelist:
international = True
logger.info(
"Org %s hat source_language_whitelist=%s gesetzt; "
"incident.international_sources wird ignoriert",
tenant_id, source_lang_whitelist,
)
previous_summary = incident["summary"] or ""
previous_sources_json = incident["sources_json"] if "sources_json" in incident.keys() else None
previous_developments = incident["latest_developments"] if "latest_developments" in incident.keys() else None
@@ -936,6 +964,7 @@ class AgentOrchestrator:
preferred_sources=preferred_sources,
output_language=output_language,
output_language_iso=output_language_iso,
research_language_iso=research_language_iso,
)
logger.info(
f"Claude-Recherche: {len(results)} Ergebnisse"
@@ -1209,14 +1238,25 @@ class AgentOrchestrator:
await db.commit()
# Geoparsing: Orte aus neuen Artikeln extrahieren und speichern
if new_articles_for_analysis:
# Foren-Quellen (media_type='forum') ausschliessen: 5ch/Hatena/Note-Posts haben
# keinen eigenen, fuer das Lagebild interessanten geographischen Bezug; spart Haiku-Calls.
articles_for_geoparsing = [
a for a in new_articles_for_analysis
if (a.get("media_type") or "").lower() != "forum"
]
if new_articles_for_analysis and not articles_for_geoparsing:
logger.info(
"Geoparsing uebersprungen: alle %d neuen Artikel sind Forum-Quellen",
len(new_articles_for_analysis),
)
if articles_for_geoparsing:
# Pipeline-Schritt 5: Orte erkennen (Start)
await _pipe_start("geoparsing")
try:
from agents.geoparsing import geoparse_articles
incident_context = f"{title} - {description}"
logger.info(f"Geoparsing fuer {len(new_articles_for_analysis)} neue Artikel...")
geo_results, category_labels = await geoparse_articles(new_articles_for_analysis, incident_context)
logger.info(f"Geoparsing fuer {len(articles_for_geoparsing)} neue Artikel (Foren ausgeschlossen)...")
geo_results, category_labels = await geoparse_articles(articles_for_geoparsing, incident_context)
geo_count = 0
for art_id, locations in geo_results.items():
for loc in locations:
@@ -1294,7 +1334,12 @@ class AgentOrchestrator:
all_articles_preloaded = None
if not previous_summary or new_count == 0 or not existing_facts:
cursor = await db.execute(
"SELECT * FROM articles WHERE incident_id = ? ORDER BY collected_at DESC",
# JOIN auf sources, damit media_type pro Artikel verfuegbar ist
# (Faktencheck schliesst Foren-Quellen aus, das Stimmungs-Modul nimmt
# nur diese). Bei Quellen ohne Match in sources bleibt media_type NULL.
"SELECT a.*, s.media_type AS media_type FROM articles a "
"LEFT JOIN sources s ON s.name = a.source "
"WHERE a.incident_id = ? ORDER BY a.collected_at DESC",
(incident_id,),
)
all_articles_preloaded = [dict(row) for row in await cursor.fetchall()]
@@ -1582,8 +1627,9 @@ class AgentOrchestrator:
from services.post_refresh_qc import normalize_german_umlauts as _norm_de2
translations = await translate_articles(
pending_translations,
output_lang="de",
output_lang=output_language_iso,
usage_accumulator=usage_acc,
enabled=translator_enabled,
)
for t in translations:
hd = t.get("headline_de")