@@ -260,6 +260,7 @@ REGELN:
|
|||||||
- Eine Meldung gilt auch dann als relevant, wenn sie das Thema aus einer gegnerischen/kritischen Perspektive behandelt — es geht um thematische Zugehörigkeit, nicht um Ausrichtung.
|
- Eine Meldung gilt auch dann als relevant, wenn sie das Thema aus einer gegnerischen/kritischen Perspektive behandelt — es geht um thematische Zugehörigkeit, nicht um Ausrichtung.
|
||||||
- FREMDSPRACHIGE QUELLEN (CJK, Arabisch, Hebräisch, Kyrillisch): Wo verfügbar steht eine "Übersetzung:"-Zeile unter der Originalüberschrift. NUTZE die Übersetzung für deine Bewertung. Verwirf einen fremdsprachigen Artikel NICHT pauschal aus Sicherheit, wenn die Übersetzung das Lagethema sichtbar berührt — wende dieselben Maßstäbe an wie auf englische Artikel.
|
- FREMDSPRACHIGE QUELLEN (CJK, Arabisch, Hebräisch, Kyrillisch): Wo verfügbar steht eine "Übersetzung:"-Zeile unter der Originalüberschrift. NUTZE die Übersetzung für deine Bewertung. Verwirf einen fremdsprachigen Artikel NICHT pauschal aus Sicherheit, wenn die Übersetzung das Lagethema sichtbar berührt — wende dieselben Maßstäbe an wie auf englische Artikel.
|
||||||
- Im Zweifel bei lateinisch geschriebenen Quellen: NICHT relevant. Im Zweifel bei nicht-lateinischen Quellen mit übersetzter, thematisch passender Überschrift: relevant.
|
- Im Zweifel bei lateinisch geschriebenen Quellen: NICHT relevant. Im Zweifel bei nicht-lateinischen Quellen mit übersetzter, thematisch passender Überschrift: relevant.
|
||||||
|
- FOREN-QUELLEN ([FORUM]-Tag hinter dem Quellennamen, z.B. 5ch, Hatena, Note): WEICHER bewerten. Sie liefern keine Faktenlage, sondern Stimmungsmaterial fuer eine separate Kachel. Wenn das Lage-Keyword im Thread-Titel oder in der ersten Zeile des Inhalts vorkommt UND der Beitrag nicht offensichtlich off-topic ist (Hobby, Sport ohne Bezug, reine Werbung), DURCHLASSEN. Im Zweifel bei Foren-Quellen: relevant.
|
||||||
|
|
||||||
Antworte AUSSCHLIESSLICH als JSON-Objekt — KEINE Erklärung, KEINE Einleitung:
|
Antworte AUSSCHLIESSLICH als JSON-Objekt — KEINE Erklärung, KEINE Einleitung:
|
||||||
{{"relevant_ids": [1, 3, 7]}}"""
|
{{"relevant_ids": [1, 3, 7]}}"""
|
||||||
@@ -530,7 +531,11 @@ class AnalyzerAgent:
|
|||||||
# Pre-Topic-Translation für fremdsprachige Headlines (gesetzt vom Orchestrator)
|
# Pre-Topic-Translation für fremdsprachige Headlines (gesetzt vom Orchestrator)
|
||||||
headline_en = article.get("headline_en_for_topic")
|
headline_en = article.get("headline_en_for_topic")
|
||||||
content_en = article.get("content_en_for_topic")
|
content_en = article.get("content_en_for_topic")
|
||||||
lines.append(f"[{i}] Quelle: {source}")
|
# Foren-Quellen explizit markieren, damit Haiku sie weicher bewertet
|
||||||
|
# (Stimmungs-Material, nicht Faktenlage — eigener Filter-Modus im Prompt)
|
||||||
|
is_forum = (article.get("media_type") or "").lower() == "forum"
|
||||||
|
source_label = f"{source} [FORUM]" if is_forum else source
|
||||||
|
lines.append(f"[{i}] Quelle: {source_label}")
|
||||||
lines.append(f" Überschrift: {headline}")
|
lines.append(f" Überschrift: {headline}")
|
||||||
if headline_en and headline_en.strip().lower() != (headline or "").strip().lower():
|
if headline_en and headline_en.strip().lower() != (headline or "").strip().lower():
|
||||||
lines.append(f" Übersetzung: {headline_en}")
|
lines.append(f" Übersetzung: {headline_en}")
|
||||||
@@ -667,6 +672,246 @@ class AnalyzerAgent:
|
|||||||
logger.info(f"Latest-Developments: {len(bullets)} Bullets aus Lagebild generiert")
|
logger.info(f"Latest-Developments: {len(bullets)} Bullets aus Lagebild generiert")
|
||||||
return output, usage
|
return output, usage
|
||||||
|
|
||||||
|
async def moderate_forum_articles(
|
||||||
|
self,
|
||||||
|
forum_articles: list[dict],
|
||||||
|
) -> tuple[list[dict], ClaudeUsage | None]:
|
||||||
|
"""Vorab-Moderation fuer Foren-Beitraege (5ch, Hatena, Note ...).
|
||||||
|
|
||||||
|
Schickt eine Batch von bis zu 25 Foren-Beitraegen an Haiku, der pro
|
||||||
|
Beitrag entscheidet:
|
||||||
|
- "publishable" -> Beitrag wird unveraendert in die Stimmungs-Kachel uebernommen.
|
||||||
|
- "redact" -> der Beitrag bleibt, aber sein Content wird auf eine kurze,
|
||||||
|
entschaerfte Version reduziert (Klarnamen, persoenliche Daten, persoenliche
|
||||||
|
Beleidigungen entfernt). Die Headline darf bleiben, wenn sie selbst clean ist.
|
||||||
|
- "discard" -> Beitrag wird aus der Liste entfernt (Hassrede gegen Gruppen,
|
||||||
|
NSFW, glaubhafte Drohungen, doxxing).
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
(gefilterte_liste, usage) — die Liste enthaelt publishable + redacted
|
||||||
|
Artikel (in Original-Reihenfolge). Discarded werden weggeworfen. Bei
|
||||||
|
API-/Parse-Fehler wird die Originalliste unveraendert zurueckgegeben
|
||||||
|
(Fail-Open, damit die Pipeline nicht hartfaellt — Haiku im Prompt
|
||||||
|
erinnert nochmal an Moderation).
|
||||||
|
"""
|
||||||
|
if not forum_articles:
|
||||||
|
return forum_articles, None
|
||||||
|
|
||||||
|
from config import CLAUDE_MODEL_FAST
|
||||||
|
|
||||||
|
# Pro Aufruf nicht mehr als 25 Beitraege (Token-Budget)
|
||||||
|
if len(forum_articles) > 25:
|
||||||
|
# In Batches verarbeiten, akkumulieren
|
||||||
|
kept: list[dict] = []
|
||||||
|
total_usage: ClaudeUsage | None = None
|
||||||
|
for i in range(0, len(forum_articles), 25):
|
||||||
|
batch = forum_articles[i:i + 25]
|
||||||
|
batch_kept, batch_usage = await self.moderate_forum_articles(batch)
|
||||||
|
kept.extend(batch_kept)
|
||||||
|
if batch_usage:
|
||||||
|
if total_usage is None:
|
||||||
|
total_usage = batch_usage
|
||||||
|
else:
|
||||||
|
try:
|
||||||
|
total_usage.add(batch_usage) # type: ignore[attr-defined]
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
return kept, total_usage
|
||||||
|
|
||||||
|
items = []
|
||||||
|
for i, a in enumerate(forum_articles):
|
||||||
|
headline = (a.get("headline_de") or a.get("headline_en_for_topic") or a.get("headline") or "").strip()
|
||||||
|
content = (a.get("content_de") or a.get("content_en_for_topic") or a.get("content_original") or "").strip()
|
||||||
|
items.append({
|
||||||
|
"i": i,
|
||||||
|
"source": (a.get("source") or "Forum").strip(),
|
||||||
|
"headline": headline[:200],
|
||||||
|
"content": content[:600],
|
||||||
|
})
|
||||||
|
|
||||||
|
prompt = f"""Du bist ein Moderations-Agent fuer ANONYME FOREN-/COMMUNITY-BEITRAEGE (5ch, Hatena, Note).
|
||||||
|
Diese Beitraege gehen in eine Stimmungs-Kachel eines OSINT-Lagemonitorings ein, das auch von Behoerden gelesen werden kann.
|
||||||
|
|
||||||
|
Pro Beitrag entscheide:
|
||||||
|
- "publishable": Beitrag ist sachlich-bezogen, ohne Hassrede gegen Gruppen, ohne Klarnamen Dritter, ohne sexuelle Inhalte, ohne Drohungen. Keine Aenderung noetig.
|
||||||
|
- "redact": Beitrag ist im Kern thematisch wertvoll, enthaelt aber persoenliche Daten, persoenliche Beleidigungen oder Klarnamen Dritter. Gib eine bereinigte Kurzfassung des Inhalts (1-3 Saetze) zurueck, die das thematische Argument behaelt aber alle PII/Beleidigungen entfernt.
|
||||||
|
- "discard": Beitrag ist Hassrede gegen ethnische/religioese/sexuelle Gruppen, NSFW, glaubhafte Drohung, oder reines Trolling ohne Themenbezug.
|
||||||
|
|
||||||
|
EINGABE:
|
||||||
|
{json.dumps(items, ensure_ascii=False)}
|
||||||
|
|
||||||
|
Antworte AUSSCHLIESSLICH mit einem JSON-Array. Pro Beitrag genau ein Objekt:
|
||||||
|
[
|
||||||
|
{{"i": 0, "decision": "publishable"}},
|
||||||
|
{{"i": 1, "decision": "redact", "clean_content": "Kurzfassung ohne PII."}},
|
||||||
|
{{"i": 2, "decision": "discard"}}
|
||||||
|
]
|
||||||
|
|
||||||
|
Keine Erklaerung, keine Einleitung, kein Markdown, nur das Array."""
|
||||||
|
|
||||||
|
try:
|
||||||
|
result, usage = await call_claude(prompt, tools=None, model=CLAUDE_MODEL_FAST)
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning("Forum-Moderation Claude-Call fehlgeschlagen, fail-open: %s", e)
|
||||||
|
return forum_articles, None
|
||||||
|
|
||||||
|
# Robustes JSON-Parsing
|
||||||
|
text = (result or "").strip()
|
||||||
|
if text.startswith("```"):
|
||||||
|
text = re.sub(r"^```(?:json)?\s*", "", text)
|
||||||
|
text = re.sub(r"\s*```\s*$", "", text)
|
||||||
|
text = text.strip()
|
||||||
|
try:
|
||||||
|
decisions = json.loads(text)
|
||||||
|
except json.JSONDecodeError:
|
||||||
|
m = re.search(r"\[.*\]", text, re.DOTALL)
|
||||||
|
if m:
|
||||||
|
try:
|
||||||
|
decisions = json.loads(m.group(0))
|
||||||
|
except json.JSONDecodeError:
|
||||||
|
decisions = None
|
||||||
|
else:
|
||||||
|
decisions = None
|
||||||
|
if not isinstance(decisions, list):
|
||||||
|
logger.warning("Forum-Moderation: kein JSON-Array, fail-open. Sample: %r", text[:200])
|
||||||
|
return forum_articles, usage
|
||||||
|
|
||||||
|
decision_map: dict[int, dict] = {}
|
||||||
|
for d in decisions:
|
||||||
|
if isinstance(d, dict) and isinstance(d.get("i"), int):
|
||||||
|
decision_map[d["i"]] = d
|
||||||
|
|
||||||
|
kept: list[dict] = []
|
||||||
|
stats = {"publishable": 0, "redact": 0, "discard": 0, "unknown": 0}
|
||||||
|
for i, art in enumerate(forum_articles):
|
||||||
|
d = decision_map.get(i)
|
||||||
|
if not d:
|
||||||
|
# Keine Entscheidung fuer diesen Beitrag -> als publishable behandeln (fail-open)
|
||||||
|
kept.append(art)
|
||||||
|
stats["unknown"] += 1
|
||||||
|
continue
|
||||||
|
decision = (d.get("decision") or "").strip().lower()
|
||||||
|
if decision == "discard":
|
||||||
|
stats["discard"] += 1
|
||||||
|
continue
|
||||||
|
if decision == "redact":
|
||||||
|
clean = (d.get("clean_content") or "").strip()
|
||||||
|
if clean:
|
||||||
|
new_art = dict(art)
|
||||||
|
new_art["content_original"] = clean
|
||||||
|
new_art["content_de"] = clean if (art.get("content_de") or "") else None
|
||||||
|
new_art["_moderation"] = "redacted"
|
||||||
|
kept.append(new_art)
|
||||||
|
stats["redact"] += 1
|
||||||
|
continue
|
||||||
|
# Redact ohne clean_content -> sicherheitshalber discard
|
||||||
|
stats["discard"] += 1
|
||||||
|
continue
|
||||||
|
# Default / "publishable"
|
||||||
|
kept.append(art)
|
||||||
|
stats["publishable"] += 1
|
||||||
|
|
||||||
|
logger.info(
|
||||||
|
"Forum-Moderation: %d publishable, %d redacted, %d discarded, %d ohne Entscheidung",
|
||||||
|
stats["publishable"], stats["redact"], stats["discard"], stats["unknown"],
|
||||||
|
)
|
||||||
|
return kept, usage
|
||||||
|
|
||||||
|
async def generate_public_mood(
|
||||||
|
self,
|
||||||
|
title: str,
|
||||||
|
description: str,
|
||||||
|
forum_articles: list[dict],
|
||||||
|
output_language: str = "Deutsch",
|
||||||
|
) -> tuple[str | None, ClaudeUsage | None]:
|
||||||
|
"""Generiert die Kachel 'Öffentliche Stimmung' aus Foren-Quellen.
|
||||||
|
|
||||||
|
Eingabe: Artikel mit media_type='forum' (5ch-Threads, Hatena-Bookmarks,
|
||||||
|
Note-Trending-Posts etc.). Ausgabe: 3-6 Markdown-Bullets, jeder Bullet
|
||||||
|
fasst ein dominantes Thema/eine Bruchlinie der Diskussion zusammen und
|
||||||
|
nennt explizit die Quellen-Herkunft (z.B. "Auf 5ch /seiji/ ueberwiegen
|
||||||
|
ablehnende Stimmen ...").
|
||||||
|
|
||||||
|
WICHTIG: Das ist Stimmungsmaterial, NICHT Faktenlage. Der Prompt weist
|
||||||
|
Claude explizit an, Eigenaussagen aus Foren nicht als Fakt zu zitieren.
|
||||||
|
|
||||||
|
Returns: (markdown_text, usage) oder (None, usage) bei leerer/kaputter
|
||||||
|
Antwort. Bei keinen Foren-Artikeln: (None, None).
|
||||||
|
"""
|
||||||
|
if not forum_articles:
|
||||||
|
return None, None
|
||||||
|
|
||||||
|
from config import CLAUDE_MODEL_FAST
|
||||||
|
|
||||||
|
# Pro Quelle gruppieren, damit Claude die Herkunft kennt
|
||||||
|
by_source: dict[str, list[dict]] = {}
|
||||||
|
for a in forum_articles:
|
||||||
|
src = (a.get("source") or "Forum (unbekannt)").strip()
|
||||||
|
by_source.setdefault(src, []).append(a)
|
||||||
|
|
||||||
|
# Artikel-Block bauen, kompakt aber mit Herkunft
|
||||||
|
lines: list[str] = []
|
||||||
|
for src, items in by_source.items():
|
||||||
|
lines.append(f"\n=== Quelle: {src} ({len(items)} Beitrag/-e) ===")
|
||||||
|
for it in items[:15]: # max 15 pro Quelle, sonst sprengt das den Prompt
|
||||||
|
headline = it.get("headline_de") or it.get("headline_en_for_topic") or it.get("headline", "")
|
||||||
|
content = (
|
||||||
|
it.get("content_de")
|
||||||
|
or it.get("content_en_for_topic")
|
||||||
|
or it.get("content_original")
|
||||||
|
or ""
|
||||||
|
)
|
||||||
|
lines.append(f"- {headline[:200]}")
|
||||||
|
if content:
|
||||||
|
lines.append(f" {content[:300]}")
|
||||||
|
articles_block = "\n".join(lines)
|
||||||
|
|
||||||
|
prompt = f"""Du bist ein OSINT-Analyst. Aus den folgenden ANONYMEN FOREN-/COMMUNITY-BEITRAEGEN sollst du das Stimmungsbild der oeffentlichen Online-Diskussion fuer eine Lage extrahieren.
|
||||||
|
|
||||||
|
LAGE: {title}
|
||||||
|
KONTEXT: {description}
|
||||||
|
|
||||||
|
FOREN-BEITRAEGE (gruppiert nach Quelle):
|
||||||
|
{articles_block}
|
||||||
|
|
||||||
|
AUFGABE:
|
||||||
|
Erstelle eine kompakte Themen-Zusammenfassung in {output_language}: 3-6 Markdown-Bullet-Points, jeder Bullet fasst ein dominantes Thema, eine Forderung oder eine Bruchlinie der Diskussion zusammen. Pro Bullet 1-3 Saetze.
|
||||||
|
|
||||||
|
REGELN:
|
||||||
|
- DIES IST KEINE FAKTENLAGE. Du fasst zusammen, wie online diskutiert wird, nicht was wahr ist.
|
||||||
|
- Quellen-Herkunft je Bullet EXPLIZIT nennen ("auf 5ch /seiji/ ueberwiegen ablehnende Reaktionen...", "Hatena-Kommentare betonen ueberwiegend ...", "Note-Autoren schreiben ueberwiegend ...").
|
||||||
|
- KEINE Eigenaussagen aus Forenposts als Faktenbehauptung uebernehmen.
|
||||||
|
- KEINE Klarnamen, persoenliche Daten oder Beleidigungen Dritter zitieren.
|
||||||
|
- Bei klaren Pro-/Contra-Lagern beide Seiten beschreiben.
|
||||||
|
- Wenn das Material zu duenn oder off-topic ist, gib explizit "Material zu duenn fuer Stimmungsbild" zurueck statt zu spekulieren.
|
||||||
|
- Markdown: nur "- " Bullets, keine Ueberschriften, kein Fettdruck, keine Inline-Quellenverweise [1].
|
||||||
|
- KEINE Gedankenstriche (—, –) verwenden — stattdessen Kommas, Doppelpunkte oder neue Saetze.
|
||||||
|
- Verwende IMMER echte UTF-8-Umlaute (ä, ö, ü, ß) — NIEMALS Umschreibungen (ae, oe, ue, ss).
|
||||||
|
|
||||||
|
Antworte AUSSCHLIESSLICH mit dem Markdown-Text der Bullets, ohne Einleitung, ohne Erklaerung."""
|
||||||
|
|
||||||
|
try:
|
||||||
|
result, usage = await call_claude(prompt, tools=None, model=CLAUDE_MODEL_FAST)
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning(f"Public-Mood Claude-Call fehlgeschlagen: {e}")
|
||||||
|
return None, None
|
||||||
|
|
||||||
|
text = (result or "").strip()
|
||||||
|
if not text or "zu duenn" in text.lower() or "too thin" in text.lower():
|
||||||
|
logger.info("Public-Mood: Material zu duenn, kein Stimmungsbild generiert")
|
||||||
|
return None, usage
|
||||||
|
|
||||||
|
# Sanity-Check: mindestens 1 Bullet (- am Zeilenanfang)
|
||||||
|
if not any(line.lstrip().startswith("-") for line in text.split("\n")):
|
||||||
|
logger.warning("Public-Mood: Claude-Antwort enthaelt keine Bullets, Sample: %r", text[:200])
|
||||||
|
return None, usage
|
||||||
|
|
||||||
|
logger.info(
|
||||||
|
"Public-Mood: %d Forum-Beitraege aus %d Quellen zu Stimmungsbild zusammengefasst",
|
||||||
|
len(forum_articles), len(by_source),
|
||||||
|
)
|
||||||
|
return text, usage
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def _parse_latest_developments(text: str, new_articles: list[dict] | None = None) -> list[str]:
|
def _parse_latest_developments(text: str, new_articles: list[dict] | None = None) -> list[str]:
|
||||||
"""Extrahiert '- [DD.MM. HH:MM] ...'-Zeilen aus der Claude-Antwort.
|
"""Extrahiert '- [DD.MM. HH:MM] ...'-Zeilen aus der Claude-Antwort.
|
||||||
|
|||||||
@@ -431,9 +431,27 @@ class FactCheckerAgent:
|
|||||||
"""Prüft Fakten über Claude CLI gegen unabhängige Quellen."""
|
"""Prüft Fakten über Claude CLI gegen unabhängige Quellen."""
|
||||||
|
|
||||||
def _format_articles_text(self, articles: list[dict], max_articles: int = 20) -> str:
|
def _format_articles_text(self, articles: list[dict], max_articles: int = 20) -> str:
|
||||||
"""Formatiert Artikel als Text für den Prompt."""
|
"""Formatiert Artikel als Text für den Prompt.
|
||||||
|
|
||||||
|
Foren-Quellen (media_type='forum', z.B. 5ch/Hatena/Note) werden hier
|
||||||
|
ausgeschlossen — sie sind Stimmungsmaterial, kein Faktenbeleg. Ein
|
||||||
|
anonymer Forenpost darf nicht als "Quelle bestaetigt Behauptung X"
|
||||||
|
gelten.
|
||||||
|
"""
|
||||||
|
# Falls media_type am Dict vorhanden ist, Foren-Quellen ausfiltern.
|
||||||
|
# Bei Article-Dicts aus dem RSS-/Pre-Topic-Pfad ist das Feld gesetzt;
|
||||||
|
# bei Reload aus der DB muss der Orchestrator das per JOIN annotieren.
|
||||||
|
non_forum = [a for a in articles if (a.get("media_type") or "").lower() != "forum"]
|
||||||
|
skipped = len(articles) - len(non_forum)
|
||||||
|
if skipped > 0:
|
||||||
|
logger.info(
|
||||||
|
"Faktencheck: %d Foren-Quellen (media_type='forum') ausgeschlossen, "
|
||||||
|
"%d Artikel als Faktenbeleg-Kandidaten",
|
||||||
|
skipped, len(non_forum),
|
||||||
|
)
|
||||||
|
|
||||||
articles_text = ""
|
articles_text = ""
|
||||||
for i, article in enumerate(articles[:max_articles]):
|
for i, article in enumerate(non_forum[:max_articles]):
|
||||||
articles_text += f"\n--- Meldung {i+1} ---\n"
|
articles_text += f"\n--- Meldung {i+1} ---\n"
|
||||||
articles_text += f"Quelle: {article.get('source', 'Unbekannt')}\n"
|
articles_text += f"Quelle: {article.get('source', 'Unbekannt')}\n"
|
||||||
source_url = article.get('source_url', '')
|
source_url = article.get('source_url', '')
|
||||||
|
|||||||
@@ -744,14 +744,42 @@ class AgentOrchestrator:
|
|||||||
description = incident["description"] or ""
|
description = incident["description"] or ""
|
||||||
incident_type = incident["type"] or "adhoc"
|
incident_type = incident["type"] or "adhoc"
|
||||||
international = bool(incident["international_sources"]) if "international_sources" in incident.keys() else True
|
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
|
include_telegram = bool(incident["include_telegram"]) if "include_telegram" in incident.keys() else False
|
||||||
visibility = incident["visibility"] if "visibility" in incident.keys() else "public"
|
visibility = incident["visibility"] if "visibility" in incident.keys() else "public"
|
||||||
created_by = incident["created_by"] if "created_by" in incident.keys() else None
|
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
|
tenant_id = incident["tenant_id"] if "tenant_id" in incident.keys() else None
|
||||||
# Org-Sprache fuer alle KI-Agenten (Lagebild, Faktencheck, Recherche)
|
# 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_iso = await get_org_language(db, tenant_id) if tenant_id else "de"
|
||||||
output_language = language_display(output_language_iso)
|
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_summary = incident["summary"] or ""
|
||||||
previous_sources_json = incident["sources_json"] if "sources_json" in incident.keys() else None
|
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
|
previous_developments = incident["latest_developments"] if "latest_developments" in incident.keys() else None
|
||||||
@@ -936,6 +964,7 @@ class AgentOrchestrator:
|
|||||||
preferred_sources=preferred_sources,
|
preferred_sources=preferred_sources,
|
||||||
output_language=output_language,
|
output_language=output_language,
|
||||||
output_language_iso=output_language_iso,
|
output_language_iso=output_language_iso,
|
||||||
|
research_language_iso=research_language_iso,
|
||||||
)
|
)
|
||||||
logger.info(
|
logger.info(
|
||||||
f"Claude-Recherche: {len(results)} Ergebnisse"
|
f"Claude-Recherche: {len(results)} Ergebnisse"
|
||||||
@@ -1209,14 +1238,25 @@ class AgentOrchestrator:
|
|||||||
await db.commit()
|
await db.commit()
|
||||||
|
|
||||||
# Geoparsing: Orte aus neuen Artikeln extrahieren und speichern
|
# 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)
|
# Pipeline-Schritt 5: Orte erkennen (Start)
|
||||||
await _pipe_start("geoparsing")
|
await _pipe_start("geoparsing")
|
||||||
try:
|
try:
|
||||||
from agents.geoparsing import geoparse_articles
|
from agents.geoparsing import geoparse_articles
|
||||||
incident_context = f"{title} - {description}"
|
incident_context = f"{title} - {description}"
|
||||||
logger.info(f"Geoparsing fuer {len(new_articles_for_analysis)} neue Artikel...")
|
logger.info(f"Geoparsing fuer {len(articles_for_geoparsing)} neue Artikel (Foren ausgeschlossen)...")
|
||||||
geo_results, category_labels = await geoparse_articles(new_articles_for_analysis, incident_context)
|
geo_results, category_labels = await geoparse_articles(articles_for_geoparsing, incident_context)
|
||||||
geo_count = 0
|
geo_count = 0
|
||||||
for art_id, locations in geo_results.items():
|
for art_id, locations in geo_results.items():
|
||||||
for loc in locations:
|
for loc in locations:
|
||||||
@@ -1294,7 +1334,12 @@ class AgentOrchestrator:
|
|||||||
all_articles_preloaded = None
|
all_articles_preloaded = None
|
||||||
if not previous_summary or new_count == 0 or not existing_facts:
|
if not previous_summary or new_count == 0 or not existing_facts:
|
||||||
cursor = await db.execute(
|
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,),
|
(incident_id,),
|
||||||
)
|
)
|
||||||
all_articles_preloaded = [dict(row) for row in await cursor.fetchall()]
|
all_articles_preloaded = [dict(row) for row in await cursor.fetchall()]
|
||||||
@@ -1447,6 +1492,78 @@ class AgentOrchestrator:
|
|||||||
logger.warning("build_fact_context_block fehlgeschlagen: %s", ctx_err, exc_info=True)
|
logger.warning("build_fact_context_block fehlgeschlagen: %s", ctx_err, exc_info=True)
|
||||||
fact_context_block = ""
|
fact_context_block = ""
|
||||||
|
|
||||||
|
# Pipeline-Schritt 6b: Öffentliche Stimmung aus Foren-Quellen
|
||||||
|
# (nur Artikel mit media_type='forum'). Eigene Kachel, kein Faktencheck.
|
||||||
|
# Wird vor dem Lagebild-Schritt ausgefuehrt, damit das Lagebild bei
|
||||||
|
# Bedarf darauf verweisen kann (z.B. Demo-Lagen mit Bezug zur Stimmung).
|
||||||
|
try:
|
||||||
|
# Bestand aller Foren-Artikel der Lage laden (inkl. media_type via JOIN)
|
||||||
|
cursor_fm = await db.execute(
|
||||||
|
"SELECT a.*, s.media_type AS media_type FROM articles a "
|
||||||
|
"LEFT JOIN sources s ON s.name = a.source "
|
||||||
|
"WHERE a.incident_id = ?",
|
||||||
|
(incident_id,),
|
||||||
|
)
|
||||||
|
all_articles_with_mt = [dict(r) for r in await cursor_fm.fetchall()]
|
||||||
|
forum_articles_in_db = [
|
||||||
|
a for a in all_articles_with_mt
|
||||||
|
if (a.get("media_type") or "").lower() == "forum"
|
||||||
|
]
|
||||||
|
# Aus dem aktuellen Refresh-Lauf zusaetzliche Foren-Artikel ergaenzen
|
||||||
|
# (haben media_type aus feed_config, sind aber evtl. noch nicht in DB,
|
||||||
|
# wenn die Persistierung anders laeuft — Robustheit).
|
||||||
|
for art in new_articles_for_analysis:
|
||||||
|
if (art.get("media_type") or "").lower() != "forum":
|
||||||
|
continue
|
||||||
|
# Duplikate vermeiden ueber source_url
|
||||||
|
if any(a.get("source_url") == art.get("source_url") for a in forum_articles_in_db):
|
||||||
|
continue
|
||||||
|
forum_articles_in_db.append(art)
|
||||||
|
|
||||||
|
if forum_articles_in_db:
|
||||||
|
await _pipe_start("public_mood")
|
||||||
|
try:
|
||||||
|
mood_agent = AnalyzerAgent()
|
||||||
|
# 1. Moderationspass: Hassrede/PII/NSFW vorab filtern.
|
||||||
|
moderated_articles, mod_usage = await mood_agent.moderate_forum_articles(
|
||||||
|
forum_articles_in_db,
|
||||||
|
)
|
||||||
|
if mod_usage:
|
||||||
|
usage_acc.add(mod_usage)
|
||||||
|
# 2. Stimmungs-Zusammenfassung aus gefilterten Beitraegen.
|
||||||
|
mood_text, mood_usage = await mood_agent.generate_public_mood(
|
||||||
|
title, description, moderated_articles,
|
||||||
|
output_language=output_language,
|
||||||
|
)
|
||||||
|
if mood_usage:
|
||||||
|
usage_acc.add(mood_usage)
|
||||||
|
if mood_text:
|
||||||
|
await db.execute(
|
||||||
|
"UPDATE incidents SET public_mood = ?, public_mood_updated_at = ? WHERE id = ?",
|
||||||
|
(mood_text, now, incident_id),
|
||||||
|
)
|
||||||
|
await db.commit()
|
||||||
|
logger.info(
|
||||||
|
"Public-Mood gespeichert fuer Incident %d (%d -> %d Foren-Artikel nach Moderation)",
|
||||||
|
incident_id, len(forum_articles_in_db), len(moderated_articles),
|
||||||
|
)
|
||||||
|
await _pipe_done(
|
||||||
|
"public_mood",
|
||||||
|
count_value=len(moderated_articles),
|
||||||
|
count_secondary=(1 if mood_text else 0),
|
||||||
|
)
|
||||||
|
except Exception as mood_err:
|
||||||
|
logger.warning("Public-Mood fehlgeschlagen: %s", mood_err, exc_info=True)
|
||||||
|
await _pipe_done("public_mood", count_value=0, count_secondary=0)
|
||||||
|
else:
|
||||||
|
await _pipe_skip("public_mood")
|
||||||
|
except Exception as mood_outer_err:
|
||||||
|
logger.warning("Public-Mood-Block uebersprungen: %s", mood_outer_err)
|
||||||
|
try:
|
||||||
|
await _pipe_skip("public_mood")
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
# Pipeline-Schritt 7: Lagebild verfassen (jetzt mit Faktenkontext)
|
# Pipeline-Schritt 7: Lagebild verfassen (jetzt mit Faktenkontext)
|
||||||
await _pipe_start("summary")
|
await _pipe_start("summary")
|
||||||
logger.info(
|
logger.info(
|
||||||
@@ -1582,8 +1699,9 @@ class AgentOrchestrator:
|
|||||||
from services.post_refresh_qc import normalize_german_umlauts as _norm_de2
|
from services.post_refresh_qc import normalize_german_umlauts as _norm_de2
|
||||||
translations = await translate_articles(
|
translations = await translate_articles(
|
||||||
pending_translations,
|
pending_translations,
|
||||||
output_lang="de",
|
output_lang=output_language_iso,
|
||||||
usage_accumulator=usage_acc,
|
usage_accumulator=usage_acc,
|
||||||
|
enabled=translator_enabled,
|
||||||
)
|
)
|
||||||
for t in translations:
|
for t in translations:
|
||||||
hd = t.get("headline_de")
|
hd = t.get("headline_de")
|
||||||
|
|||||||
@@ -562,14 +562,27 @@ class ResearcherAgent:
|
|||||||
logger.warning(f"Keyword-Extraktion fehlgeschlagen: {e}")
|
logger.warning(f"Keyword-Extraktion fehlgeschlagen: {e}")
|
||||||
return None, None
|
return None, None
|
||||||
|
|
||||||
async def search(self, title: str, description: str = "", incident_type: str = "adhoc", international: bool = True, user_id: int = None, existing_articles: list[dict] = None, preferred_sources: list[dict] = None, output_language: str = "Deutsch", output_language_iso: str = "de") -> tuple[list[dict], ClaudeUsage | None, bool]:
|
async def search(self, title: str, description: str = "", incident_type: str = "adhoc", international: bool = True, user_id: int = None, existing_articles: list[dict] = None, preferred_sources: list[dict] = None, output_language: str = "Deutsch", output_language_iso: str = "de", research_language_iso: str | None = None) -> tuple[list[dict], ClaudeUsage | None, bool]:
|
||||||
"""Sucht nach Informationen zu einem Vorfall.
|
"""Sucht nach Informationen zu einem Vorfall.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
output_language / output_language_iso: Ausgabesprache (Lagebild-Sprache).
|
||||||
|
research_language_iso: optionaler Override fuer die Sprache, in der gesucht
|
||||||
|
werden soll. Default = output_language_iso. Bei jp_demo z.B. 'ja',
|
||||||
|
waehrend output_language_iso 'de' bleibt (Lagebild deutsch, Recherche japanisch).
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
(artikel, usage, parse_failed) — parse_failed ist True, wenn Claude geantwortet hat,
|
(artikel, usage, parse_failed) — parse_failed ist True, wenn Claude geantwortet hat,
|
||||||
das JSON aber nicht extrahierbar war. So kann der Orchestrator zwischen
|
das JSON aber nicht extrahierbar war. So kann der Orchestrator zwischen
|
||||||
"echt keine Treffer" und "kaputte Antwort" unterscheiden.
|
"echt keine Treffer" und "kaputte Antwort" unterscheiden.
|
||||||
"""
|
"""
|
||||||
|
# research_language defaultet auf output_language. Wenn das aber abweicht
|
||||||
|
# (z.B. jp_demo: research='ja', output='de'), ueberschreiben wir die
|
||||||
|
# Sprach-Anweisung im Prompt mit einer eigenen, dual-sprachigen Variante.
|
||||||
|
research_language_iso = (research_language_iso or output_language_iso or "de").lower()
|
||||||
|
# Display-Name der Recherche-Sprache fuer Prompts ("Japanese", "Russian", ...)
|
||||||
|
from services.org_settings import language_display as _lang_display
|
||||||
|
research_language_display = _lang_display(research_language_iso)
|
||||||
# Bevorzugte Web-Quellen als Prompt-Block (optional)
|
# Bevorzugte Web-Quellen als Prompt-Block (optional)
|
||||||
preferred_sources_block = ""
|
preferred_sources_block = ""
|
||||||
if preferred_sources:
|
if preferred_sources:
|
||||||
@@ -589,8 +602,31 @@ class ResearcherAgent:
|
|||||||
"aber nicht deine sonstige Recherche.\n"
|
"aber nicht deine sonstige Recherche.\n"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
# Asymmetrische Sprach-Auswahl: research_language weicht von output_language ab
|
||||||
|
# -> eigene Anweisung "primaer in research-language, englische Quellen aus der
|
||||||
|
# Region auch erlaubt". Sonst die bisherige Logik (primary_only vs international).
|
||||||
|
asymmetric_lang = research_language_iso != output_language_iso
|
||||||
|
|
||||||
|
def _build_lang_instruction(deep: bool) -> str:
|
||||||
|
if asymmetric_lang:
|
||||||
|
# jp_demo & Co.: Recherche in Quellsprache + lokale Englisch-Outlets.
|
||||||
|
return (
|
||||||
|
f"- Fokus liegt auf {research_language_display}-sprachigen Quellen "
|
||||||
|
f"(Behoerden, Qualitaetszeitungen, oeffentlich-rechtliche Medien dieser Sprache).\n"
|
||||||
|
f"- Englischsprachige Outlets mit Fokus auf demselben Sprachraum/Region sind "
|
||||||
|
f"ebenfalls willkommen (z.B. Japan Times, Nikkei Asia, Kyodo English fuer Japan; "
|
||||||
|
f"Moscow Times English fuer Russland).\n"
|
||||||
|
f"- Quellen ausserhalb des Sprachraums NUR, wenn sie exklusive Informationen "
|
||||||
|
f"ueber die Region liefern (z.B. Reuters/AFP/AP-Berichte aus der Region).\n"
|
||||||
|
f"- Antworte in der Ausgabesprache {output_language} (das Lagebild wird in "
|
||||||
|
f"{output_language} angezeigt), aber zitiere die Original-Headlines/Quellen unveraendert."
|
||||||
|
)
|
||||||
|
if deep:
|
||||||
|
return lang_deep_international(output_language) if international else lang_deep_primary_only(output_language)
|
||||||
|
return lang_international(output_language) if international else lang_primary_only(output_language)
|
||||||
|
|
||||||
if incident_type == "research":
|
if incident_type == "research":
|
||||||
lang_instruction = lang_deep_international(output_language) if international else lang_deep_primary_only(output_language)
|
lang_instruction = _build_lang_instruction(deep=True)
|
||||||
# Bestehende Artikel als Kontext für den Prompt aufbereiten
|
# Bestehende Artikel als Kontext für den Prompt aufbereiten
|
||||||
existing_context = ""
|
existing_context = ""
|
||||||
if existing_articles:
|
if existing_articles:
|
||||||
@@ -611,7 +647,7 @@ class ResearcherAgent:
|
|||||||
preferred_sources_block=preferred_sources_block,
|
preferred_sources_block=preferred_sources_block,
|
||||||
)
|
)
|
||||||
else:
|
else:
|
||||||
lang_instruction = lang_international(output_language) if international else lang_primary_only(output_language)
|
lang_instruction = _build_lang_instruction(deep=False)
|
||||||
# Bestehende Artikel als Kontext: bei Folge-Refreshes findet Claude andere Quellen
|
# Bestehende Artikel als Kontext: bei Folge-Refreshes findet Claude andere Quellen
|
||||||
existing_context = ""
|
existing_context = ""
|
||||||
if existing_articles:
|
if existing_articles:
|
||||||
|
|||||||
@@ -373,20 +373,27 @@ async def translate_articles(
|
|||||||
output_lang: str = "de",
|
output_lang: str = "de",
|
||||||
batch_size: int = DEFAULT_BATCH_SIZE,
|
batch_size: int = DEFAULT_BATCH_SIZE,
|
||||||
usage_accumulator: UsageAccumulator | None = None,
|
usage_accumulator: UsageAccumulator | None = None,
|
||||||
|
enabled: bool | None = None,
|
||||||
) -> list[dict]:
|
) -> list[dict]:
|
||||||
"""Uebersetzt eine beliebige Anzahl Artikel in Batches.
|
"""Uebersetzt eine beliebige Anzahl Artikel in Batches.
|
||||||
|
|
||||||
Bringt die Batches durch Logik in `translate_articles_batch` und gibt
|
Bringt die Batches durch Logik in `translate_articles_batch` und gibt
|
||||||
EINE flache Liste der Translations zurueck. Wenn ein Batch fehlschlaegt,
|
EINE flache Liste der Translations zurueck. Wenn ein Batch fehlschlaegt,
|
||||||
wird er uebersprungen (anderer Batches laufen weiter).
|
wird er uebersprungen (anderer Batches laufen weiter).
|
||||||
|
|
||||||
|
enabled: Pro-Aufruf-Override des globalen TRANSLATOR_ENABLED-Flags. Wenn None,
|
||||||
|
greift das Modul-Default (config.TRANSLATOR_ENABLED, abgeleitet aus .env).
|
||||||
|
Der Orchestrator setzt das aus dem Org-Setting 'translator_enabled', damit
|
||||||
|
jp_demo (Translator zwingend an) trotz global deaktiviertem Flag funktioniert.
|
||||||
"""
|
"""
|
||||||
if not articles:
|
if not articles:
|
||||||
return []
|
return []
|
||||||
|
|
||||||
if not TRANSLATOR_ENABLED:
|
is_enabled = TRANSLATOR_ENABLED if enabled is None else bool(enabled)
|
||||||
|
if not is_enabled:
|
||||||
logger.info(
|
logger.info(
|
||||||
"Translator deaktiviert (TRANSLATOR_ENABLED=false), %d Artikel uebersprungen",
|
"Translator deaktiviert (enabled=%s, global TRANSLATOR_ENABLED=%s), %d Artikel uebersprungen",
|
||||||
len(articles),
|
enabled, TRANSLATOR_ENABLED, len(articles),
|
||||||
)
|
)
|
||||||
return []
|
return []
|
||||||
|
|
||||||
|
|||||||
@@ -429,6 +429,16 @@ async def init_db():
|
|||||||
await db.commit()
|
await db.commit()
|
||||||
logger.info("Migration: latest_developments zu incidents hinzugefuegt")
|
logger.info("Migration: latest_developments zu incidents hinzugefuegt")
|
||||||
|
|
||||||
|
if "public_mood" not in columns:
|
||||||
|
await db.execute("ALTER TABLE incidents ADD COLUMN public_mood TEXT")
|
||||||
|
await db.commit()
|
||||||
|
logger.info("Migration: public_mood zu incidents hinzugefuegt")
|
||||||
|
|
||||||
|
if "public_mood_updated_at" not in columns:
|
||||||
|
await db.execute("ALTER TABLE incidents ADD COLUMN public_mood_updated_at TIMESTAMP")
|
||||||
|
await db.commit()
|
||||||
|
logger.info("Migration: public_mood_updated_at zu incidents hinzugefuegt")
|
||||||
|
|
||||||
# Migration: Tabelle podcast_transcripts (URL-Cache fuer Transkripte)
|
# Migration: Tabelle podcast_transcripts (URL-Cache fuer Transkripte)
|
||||||
cursor = await db.execute(
|
cursor = await db.execute(
|
||||||
"SELECT name FROM sqlite_master WHERE type='table' AND name='podcast_transcripts'"
|
"SELECT name FROM sqlite_master WHERE type='table' AND name='podcast_transcripts'"
|
||||||
|
|||||||
@@ -227,6 +227,10 @@ class RSSParser:
|
|||||||
# alle "news.google.com" sind, obwohl sie für 14 verschiedene
|
# alle "news.google.com" sind, obwohl sie für 14 verschiedene
|
||||||
# Behörden/Zeitungen stehen. Wird vom Domain-Cap genutzt.
|
# Behörden/Zeitungen stehen. Wird vom Domain-Cap genutzt.
|
||||||
"source_domain": feed_config.get("domain") or "",
|
"source_domain": feed_config.get("domain") or "",
|
||||||
|
# media_type aus dem Feed-Eintrag (z.B. "forum" fuer 5ch/Hatena/Note)
|
||||||
|
# damit downstream Pipeline-Schritte (Faktencheck, Geoparsing,
|
||||||
|
# Topic-Filter, Stimmungs-Kachel) Foren-Quellen erkennen koennen.
|
||||||
|
"media_type": feed_config.get("media_type") or "",
|
||||||
"content_original": summary[:1000] if summary else None,
|
"content_original": summary[:1000] if summary else None,
|
||||||
"content_de": summary[:1000] if summary and self._is_german(summary) else None,
|
"content_de": summary[:1000] if summary and self._is_german(summary) else None,
|
||||||
# Sprache primär aus der Quell-Konfiguration übernehmen
|
# Sprache primär aus der Quell-Konfiguration übernehmen
|
||||||
|
|||||||
@@ -98,6 +98,8 @@ class IncidentResponse(BaseModel):
|
|||||||
visibility: str = "public"
|
visibility: str = "public"
|
||||||
summary: Optional[str]
|
summary: Optional[str]
|
||||||
latest_developments: Optional[str] = None
|
latest_developments: Optional[str] = None
|
||||||
|
public_mood: Optional[str] = None
|
||||||
|
public_mood_updated_at: Optional[str] = None
|
||||||
international_sources: bool = True
|
international_sources: bool = True
|
||||||
include_telegram: bool = False
|
include_telegram: bool = False
|
||||||
created_by: int
|
created_by: int
|
||||||
|
|||||||
@@ -1,12 +1,17 @@
|
|||||||
"""Organization-Settings-Helper.
|
"""Organization-Settings-Helper.
|
||||||
|
|
||||||
KV-Store pro Organisation. Aktuell genutzt fuer output_language ('de'|'en').
|
KV-Store pro Organisation. Aktuell genutzt fuer:
|
||||||
Spaeter erweiterbar (Default-Modell, Telegram-Toggle, Theme, ...).
|
- output_language ('de'|'en'|...) - Anzeige-/Lagebild-Sprache
|
||||||
|
- source_language_whitelist (JSON-Liste, z.B. ["ja"]) - schraenkt RSS/Telegram-Quellen ein
|
||||||
|
- research_language (ISO-Code) - steuert WebSearch-Prompts (default = output_language)
|
||||||
|
- translator_enabled ('true'|'false') - override fuer das globale TRANSLATOR_ENABLED-Flag
|
||||||
|
|
||||||
Cache: TTL 60s in-memory pro (tenant_id, key). Wird bei set_org_setting()
|
Cache: TTL 60s in-memory pro (tenant_id, key). Wird bei set_org_setting()
|
||||||
invalidiert.
|
invalidiert.
|
||||||
"""
|
"""
|
||||||
|
import json
|
||||||
import logging
|
import logging
|
||||||
|
import os
|
||||||
import time
|
import time
|
||||||
from typing import Optional
|
from typing import Optional
|
||||||
|
|
||||||
@@ -84,6 +89,15 @@ async def set_org_setting(
|
|||||||
LANGUAGE_DISPLAY_NAMES = {
|
LANGUAGE_DISPLAY_NAMES = {
|
||||||
"de": "Deutsch",
|
"de": "Deutsch",
|
||||||
"en": "English",
|
"en": "English",
|
||||||
|
"ja": "Japanese",
|
||||||
|
"zh": "Chinese",
|
||||||
|
"ko": "Korean",
|
||||||
|
"ru": "Russian",
|
||||||
|
"ar": "Arabic",
|
||||||
|
"fa": "Persian",
|
||||||
|
"he": "Hebrew",
|
||||||
|
"fr": "French",
|
||||||
|
"es": "Spanish",
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
@@ -91,7 +105,10 @@ async def get_org_language(
|
|||||||
db: aiosqlite.Connection,
|
db: aiosqlite.Connection,
|
||||||
tenant_id: int,
|
tenant_id: int,
|
||||||
) -> str:
|
) -> str:
|
||||||
"""Liefert ISO-2-Sprachcode der Org (default 'de')."""
|
"""Liefert ISO-2-Sprachcode der Org (default 'de').
|
||||||
|
|
||||||
|
Steuert die Lagebild-/Anzeige-Sprache.
|
||||||
|
"""
|
||||||
value = await get_org_setting(db, tenant_id, "output_language", default="de")
|
value = await get_org_setting(db, tenant_id, "output_language", default="de")
|
||||||
if value not in LANGUAGE_DISPLAY_NAMES:
|
if value not in LANGUAGE_DISPLAY_NAMES:
|
||||||
logger.warning("Unbekannte output_language '%s' fuer Org %s -- fallback 'de'", value, tenant_id)
|
logger.warning("Unbekannte output_language '%s' fuer Org %s -- fallback 'de'", value, tenant_id)
|
||||||
@@ -99,6 +116,65 @@ async def get_org_language(
|
|||||||
return value
|
return value
|
||||||
|
|
||||||
|
|
||||||
|
async def get_source_language_whitelist(
|
||||||
|
db: aiosqlite.Connection,
|
||||||
|
tenant_id: int,
|
||||||
|
) -> Optional[list[str]]:
|
||||||
|
"""Liefert Liste erlaubter Quellsprachen oder None (= keine Einschränkung).
|
||||||
|
|
||||||
|
Gespeichert als JSON-Array unter dem Key 'source_language_whitelist'.
|
||||||
|
Beispiel-Wert: '["ja"]' -> nur japanischsprachige Quellen.
|
||||||
|
"""
|
||||||
|
raw = await get_org_setting(db, tenant_id, "source_language_whitelist", default=None)
|
||||||
|
if not raw:
|
||||||
|
return None
|
||||||
|
try:
|
||||||
|
parsed = json.loads(raw)
|
||||||
|
except (json.JSONDecodeError, TypeError) as e:
|
||||||
|
logger.warning(
|
||||||
|
"source_language_whitelist fuer Org %s ist kein JSON ('%s'): %s",
|
||||||
|
tenant_id, raw, e,
|
||||||
|
)
|
||||||
|
return None
|
||||||
|
if not isinstance(parsed, list):
|
||||||
|
logger.warning("source_language_whitelist fuer Org %s ist keine Liste: %r", tenant_id, parsed)
|
||||||
|
return None
|
||||||
|
cleaned = [str(x).strip().lower() for x in parsed if str(x).strip()]
|
||||||
|
return cleaned or None
|
||||||
|
|
||||||
|
|
||||||
|
async def get_research_language(
|
||||||
|
db: aiosqlite.Connection,
|
||||||
|
tenant_id: int,
|
||||||
|
) -> str:
|
||||||
|
"""Liefert die Sprache, in der der WebSearch-Researcher primär sucht.
|
||||||
|
|
||||||
|
Default = output_language. Bei jp_demo z.B. 'ja', während output_language='de' bleibt.
|
||||||
|
"""
|
||||||
|
value = await get_org_setting(db, tenant_id, "research_language", default=None)
|
||||||
|
if value and value in LANGUAGE_DISPLAY_NAMES:
|
||||||
|
return value
|
||||||
|
return await get_org_language(db, tenant_id)
|
||||||
|
|
||||||
|
|
||||||
|
async def get_translator_enabled(
|
||||||
|
db: aiosqlite.Connection,
|
||||||
|
tenant_id: Optional[int],
|
||||||
|
) -> bool:
|
||||||
|
"""Liefert true wenn der (volle) Translator-Schritt fuer diese Org laufen soll.
|
||||||
|
|
||||||
|
Hierarchie:
|
||||||
|
1. Org-Setting 'translator_enabled' ('true'/'false') gewinnt, wenn gesetzt.
|
||||||
|
2. Sonst: globales ENV-Flag TRANSLATOR_ENABLED (Default true im config.py).
|
||||||
|
"""
|
||||||
|
if tenant_id is not None:
|
||||||
|
raw = await get_org_setting(db, tenant_id, "translator_enabled", default=None)
|
||||||
|
if raw is not None:
|
||||||
|
return str(raw).strip().lower() in ("true", "1", "yes", "on")
|
||||||
|
env_value = os.environ.get("TRANSLATOR_ENABLED", "true").strip().lower()
|
||||||
|
return env_value in ("true", "1", "yes", "on")
|
||||||
|
|
||||||
|
|
||||||
def language_display(lang_iso: str) -> str:
|
def language_display(lang_iso: str) -> str:
|
||||||
"""ISO-Code -> Anzeigename fuer Prompts ('de' -> 'Deutsch')."""
|
"""ISO-Code -> Anzeigename fuer Prompts ('de' -> 'Deutsch')."""
|
||||||
return LANGUAGE_DISPLAY_NAMES.get(lang_iso, lang_iso)
|
return LANGUAGE_DISPLAY_NAMES.get(lang_iso, lang_iso)
|
||||||
|
|||||||
@@ -32,6 +32,8 @@ _PIPELINE_STEPS_DE = [
|
|||||||
"tooltip": "Aus den Meldungen werden Ortsangaben erkannt und auf der Karte verortet."},
|
"tooltip": "Aus den Meldungen werden Ortsangaben erkannt und auf der Karte verortet."},
|
||||||
{"key": "factcheck", "label": "Fakten prüfen", "icon": "shield",
|
{"key": "factcheck", "label": "Fakten prüfen", "icon": "shield",
|
||||||
"tooltip": "Behauptungen aus den Meldungen werden gegeneinander abgeglichen: Bestätigt? Umstritten? Noch unklar?"},
|
"tooltip": "Behauptungen aus den Meldungen werden gegeneinander abgeglichen: Bestätigt? Umstritten? Noch unklar?"},
|
||||||
|
{"key": "public_mood", "label": "Stimmung erfassen", "icon": "message-circle",
|
||||||
|
"tooltip": "Aus Foren-Quellen (z.B. 5ch, Hatena, Note) wird ein Stimmungsbild der öffentlichen Diskussion extrahiert. Keine Faktenlage, sondern dominante Themen und Bruchlinien."},
|
||||||
{"key": "summary", "label": "Lagebild verfassen", "icon": "file-text",
|
{"key": "summary", "label": "Lagebild verfassen", "icon": "file-text",
|
||||||
"tooltip": "Aus allen geprüften Meldungen wird ein zusammenhängendes Lagebild geschrieben, mit Quellenangaben am Text."},
|
"tooltip": "Aus allen geprüften Meldungen wird ein zusammenhängendes Lagebild geschrieben, mit Quellenangaben am Text."},
|
||||||
{"key": "qc", "label": "Qualitätscheck", "icon": "check-circle",
|
{"key": "qc", "label": "Qualitätscheck", "icon": "check-circle",
|
||||||
@@ -53,6 +55,8 @@ _PIPELINE_STEPS_EN = [
|
|||||||
"tooltip": "Locations are extracted from the articles and placed on the map."},
|
"tooltip": "Locations are extracted from the articles and placed on the map."},
|
||||||
{"key": "factcheck", "label": "Checking facts", "icon": "shield",
|
{"key": "factcheck", "label": "Checking facts", "icon": "shield",
|
||||||
"tooltip": "Claims from the articles are cross-checked: Confirmed? Disputed? Still unclear?"},
|
"tooltip": "Claims from the articles are cross-checked: Confirmed? Disputed? Still unclear?"},
|
||||||
|
{"key": "public_mood", "label": "Reading the mood", "icon": "message-circle",
|
||||||
|
"tooltip": "Forum sources (5ch, Hatena, Note, etc.) are summarised into a public-mood overview. Not factual, but dominant themes and fault lines."},
|
||||||
{"key": "summary", "label": "Writing the briefing", "icon": "file-text",
|
{"key": "summary", "label": "Writing the briefing", "icon": "file-text",
|
||||||
"tooltip": "All verified articles are combined into a coherent briefing with inline citations."},
|
"tooltip": "All verified articles are combined into a coherent briefing with inline citations."},
|
||||||
{"key": "qc", "label": "Quality check", "icon": "check-circle",
|
{"key": "qc", "label": "Quality check", "icon": "check-circle",
|
||||||
|
|||||||
@@ -642,14 +642,20 @@ async def get_feeds_with_metadata(tenant_id: int = None, source_type: str = "rss
|
|||||||
|
|
||||||
source_type: "rss_feed" (Default) oder "podcast_feed" — trennt RSS- und Podcast-Quellen
|
source_type: "rss_feed" (Default) oder "podcast_feed" — trennt RSS- und Podcast-Quellen
|
||||||
in getrennten Pipelines, damit der RSS-Heisspfad unveraendert bleibt.
|
in getrennten Pipelines, damit der RSS-Heisspfad unveraendert bleibt.
|
||||||
|
|
||||||
|
Wenn die Org eine source_language_whitelist gesetzt hat (z.B. jp_demo: ['ja']),
|
||||||
|
werden nur Feeds geliefert, deren primary_language darauf passt. Feeds ohne
|
||||||
|
gesetztes primary_language fallen in dem Fall raus — das ist gewollt, weil
|
||||||
|
eine Whitelist gerade die strenge Beschraenkung ist.
|
||||||
"""
|
"""
|
||||||
from database import get_db
|
from database import get_db
|
||||||
|
from services.org_settings import get_source_language_whitelist
|
||||||
|
|
||||||
db = await get_db()
|
db = await get_db()
|
||||||
try:
|
try:
|
||||||
if tenant_id:
|
if tenant_id:
|
||||||
cursor = await db.execute(
|
cursor = await db.execute(
|
||||||
"SELECT name, url, domain, category, notes, primary_language, "
|
"SELECT name, url, domain, category, notes, primary_language, media_type, "
|
||||||
"COALESCE(article_count, 0) AS article_count FROM sources "
|
"COALESCE(article_count, 0) AS article_count FROM sources "
|
||||||
"WHERE source_type = ? AND status = 'active' "
|
"WHERE source_type = ? AND status = 'active' "
|
||||||
"AND (tenant_id IS NULL OR tenant_id = ?)",
|
"AND (tenant_id IS NULL OR tenant_id = ?)",
|
||||||
@@ -657,12 +663,25 @@ async def get_feeds_with_metadata(tenant_id: int = None, source_type: str = "rss
|
|||||||
)
|
)
|
||||||
else:
|
else:
|
||||||
cursor = await db.execute(
|
cursor = await db.execute(
|
||||||
"SELECT name, url, domain, category, notes, primary_language, "
|
"SELECT name, url, domain, category, notes, primary_language, media_type, "
|
||||||
"COALESCE(article_count, 0) AS article_count FROM sources "
|
"COALESCE(article_count, 0) AS article_count FROM sources "
|
||||||
"WHERE source_type = ? AND status = 'active'",
|
"WHERE source_type = ? AND status = 'active'",
|
||||||
(source_type,),
|
(source_type,),
|
||||||
)
|
)
|
||||||
return [dict(row) for row in await cursor.fetchall()]
|
feeds = [dict(row) for row in await cursor.fetchall()]
|
||||||
|
|
||||||
|
# Whitelist-Filter (nur wenn die Org eine gesetzt hat)
|
||||||
|
if tenant_id:
|
||||||
|
whitelist = await get_source_language_whitelist(db, tenant_id)
|
||||||
|
if whitelist:
|
||||||
|
before = len(feeds)
|
||||||
|
feeds = [f for f in feeds if (f.get("primary_language") or "").lower() in whitelist]
|
||||||
|
logger.info(
|
||||||
|
"source_language_whitelist=%s fuer Org %s: %d/%d Feeds passieren",
|
||||||
|
whitelist, tenant_id, len(feeds), before,
|
||||||
|
)
|
||||||
|
|
||||||
|
return feeds
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"Fehler beim Laden der Feed-Metadaten ({source_type}): {e}")
|
logger.error(f"Fehler beim Laden der Feed-Metadaten ({source_type}): {e}")
|
||||||
return []
|
return []
|
||||||
|
|||||||
@@ -209,6 +209,7 @@
|
|||||||
<button class="tab-btn" data-tab="timeline" data-i18n="tab.timeline">Ereignis-Timeline</button>
|
<button class="tab-btn" data-tab="timeline" data-i18n="tab.timeline">Ereignis-Timeline</button>
|
||||||
<button class="tab-btn" data-tab="karte" data-i18n="tab.map">Geografische Verteilung</button>
|
<button class="tab-btn" data-tab="karte" data-i18n="tab.map">Geografische Verteilung</button>
|
||||||
<button class="tab-btn" data-tab="faktencheck" data-i18n="tab.factcheck">Faktencheck</button>
|
<button class="tab-btn" data-tab="faktencheck" data-i18n="tab.factcheck">Faktencheck</button>
|
||||||
|
<button class="tab-btn" data-tab="stimmung" data-i18n="tab.public_mood" id="tab-btn-stimmung" style="display:none;">Öffentliche Stimmung</button>
|
||||||
<button class="tab-btn" data-tab="pipeline" data-i18n="tab.pipeline">Analysepipeline</button>
|
<button class="tab-btn" data-tab="pipeline" data-i18n="tab.pipeline">Analysepipeline</button>
|
||||||
<button class="tab-btn" data-tab="quellen" data-i18n="tab.sources_overview">Quellenübersicht</button>
|
<button class="tab-btn" data-tab="quellen" data-i18n="tab.sources_overview">Quellenübersicht</button>
|
||||||
</div>
|
</div>
|
||||||
@@ -293,6 +294,24 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div class="tab-panel" id="panel-stimmung">
|
||||||
|
<div class="card incident-analysis-stimmung" id="stimmung-card">
|
||||||
|
<div class="card-header">
|
||||||
|
<div class="card-title">
|
||||||
|
<span data-i18n="card.public_mood">Öffentliche Stimmung</span>
|
||||||
|
<span class="info-icon" data-tooltip="Themen und Bruchlinien aus Foren-Quellen (z.B. 5ch, Hatena, Note). KEINE Faktenlage - reines Stimmungsmaterial. Beitraege sind anonym und koennen Trolling enthalten."><svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="10"/><path d="M12 16v-4"/><path d="M12 8h.01"/></svg></span>
|
||||||
|
</div>
|
||||||
|
<span class="stimmung-timestamp" id="stimmung-timestamp"></span>
|
||||||
|
</div>
|
||||||
|
<div id="stimmung-content">
|
||||||
|
<div id="stimmung-text" class="summary-text" style="padding:8px 16px;"></div>
|
||||||
|
<div style="padding:0 16px 16px; font-size:11px; color:var(--text-disabled); border-top:1px solid var(--border); margin-top:8px; padding-top:8px;">
|
||||||
|
Hinweis: Forenbeiträge sind anonyme Online-Stimmungen, keine Faktenlage. Sie fließen nicht in den Faktencheck ein.
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div class="tab-panel" id="panel-pipeline">
|
<div class="tab-panel" id="panel-pipeline">
|
||||||
<div class="card pipeline-card" id="pipeline-card">
|
<div class="card pipeline-card" id="pipeline-card">
|
||||||
<div class="card-header">
|
<div class="card-header">
|
||||||
@@ -778,10 +797,10 @@
|
|||||||
<script src="/static/js/i18n.js?v=20260513a"></script>
|
<script src="/static/js/i18n.js?v=20260513a"></script>
|
||||||
<script src="/static/js/api.js?v=20260423a"></script>
|
<script src="/static/js/api.js?v=20260423a"></script>
|
||||||
<script src="/static/js/ws.js?v=20260316b"></script>
|
<script src="/static/js/ws.js?v=20260316b"></script>
|
||||||
<script src="/static/js/components.js?v=20260514e"></script>
|
<script src="/static/js/components.js?v=20260522a"></script>
|
||||||
<script src="/static/js/layout.js?v=20260513f"></script>
|
<script src="/static/js/layout.js?v=20260513f"></script>
|
||||||
<script src="/static/js/pipeline.js?v=20260513d"></script>
|
<script src="/static/js/pipeline.js?v=20260513d"></script>
|
||||||
<script src="/static/js/app.js?v=20260514e"></script>
|
<script src="/static/js/app.js?v=20260522a"></script>
|
||||||
<script src="/static/js/cluster-data.js?v=20260322f"></script>
|
<script src="/static/js/cluster-data.js?v=20260322f"></script>
|
||||||
<script src="/static/js/tutorial.js?v=20260316z"></script>
|
<script src="/static/js/tutorial.js?v=20260316z"></script>
|
||||||
<script src="/static/js/chat.js?v=20260514e"></script>
|
<script src="/static/js/chat.js?v=20260514e"></script>
|
||||||
|
|||||||
@@ -1131,6 +1131,26 @@ const App = {
|
|||||||
: '';
|
: '';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Öffentliche Stimmung (Foren-Kachel): Tab + Inhalt nur einblenden,
|
||||||
|
// wenn fuer diese Lage tatsaechlich Stimmungs-Text vorhanden ist.
|
||||||
|
const stimmungTabBtn = document.getElementById('tab-btn-stimmung');
|
||||||
|
const stimmungText = document.getElementById('stimmung-text');
|
||||||
|
const stimmungTs = document.getElementById('stimmung-timestamp');
|
||||||
|
const moodText = (incident.public_mood || '').trim();
|
||||||
|
if (moodText && stimmungTabBtn) {
|
||||||
|
stimmungTabBtn.style.display = '';
|
||||||
|
if (stimmungText) stimmungText.innerHTML = UI.renderPublicMood(moodText);
|
||||||
|
if (stimmungTs && incident.public_mood_updated_at) {
|
||||||
|
const mUpd = parseUTC(incident.public_mood_updated_at);
|
||||||
|
if (mUpd) {
|
||||||
|
stimmungTs.textContent = `Stand: ${mUpd.toLocaleDateString('de-DE', { day: '2-digit', month: '2-digit', year: 'numeric', timeZone: TIMEZONE })} ${mUpd.toLocaleTimeString('de-DE', { hour: '2-digit', minute: '2-digit', timeZone: TIMEZONE })} Uhr`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else if (stimmungTabBtn) {
|
||||||
|
stimmungTabBtn.style.display = 'none';
|
||||||
|
if (stimmungText) stimmungText.innerHTML = '';
|
||||||
|
}
|
||||||
|
|
||||||
{ const _e = document.getElementById('meta-refresh-mode'); if (_e) {
|
{ const _e = document.getElementById('meta-refresh-mode'); if (_e) {
|
||||||
if (incident.refresh_mode === 'auto' && incident.refresh_start_time) {
|
if (incident.refresh_mode === 'auto' && incident.refresh_start_time) {
|
||||||
const intervalText = App._formatInterval(incident.refresh_interval);
|
const intervalText = App._formatInterval(incident.refresh_interval);
|
||||||
|
|||||||
@@ -813,6 +813,26 @@ const UI = {
|
|||||||
return html;
|
return html;
|
||||||
},
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Rendert die "Öffentliche Stimmung"-Kachel.
|
||||||
|
* Eingabe ist Markdown mit "- "-Bullets (vom AnalyzerAgent.generate_public_mood).
|
||||||
|
* Quellen-Pills brauchen wir hier nicht — die Bullet-Texte nennen die Foren-Herkunft
|
||||||
|
* explizit ("auf 5ch /seiji/ ...", "Hatena-Kommentare betonen ...").
|
||||||
|
*/
|
||||||
|
renderPublicMood(text) {
|
||||||
|
if (!text) return '<span style="color:var(--text-disabled);">Noch kein Stimmungsbild erfasst.</span>';
|
||||||
|
const bulletLines = text.split("\n").map(l => l.trim()).filter(l => l.startsWith("- "));
|
||||||
|
if (bulletLines.length === 0) {
|
||||||
|
// Fliesstext-Fallback: HTML-escapen + Zeilenumbrueche
|
||||||
|
return this.escape(text).replace(/\n/g, '<br>');
|
||||||
|
}
|
||||||
|
const items = bulletLines.map(l => {
|
||||||
|
const body = l.replace(/^-\s+/, '');
|
||||||
|
return `<li>${this.escape(body)}</li>`;
|
||||||
|
}).join('');
|
||||||
|
return `<ul style="margin:4px 0 4px 18px;line-height:1.7;">${items}</ul>`;
|
||||||
|
},
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Rendert "Neueste Entwicklungen" für Live-Monitoring (adhoc).
|
* Rendert "Neueste Entwicklungen" für Live-Monitoring (adhoc).
|
||||||
* Erwartet Bullets im Format "- [DD.MM. HH:MM] Text {Quelle1, Quelle2}".
|
* Erwartet Bullets im Format "- [DD.MM. HH:MM] Text {Quelle1, Quelle2}".
|
||||||
|
|||||||
In neuem Issue referenzieren
Einen Benutzer sperren