Commits vergleichen

8 Commits

29 geänderte Dateien mit 6385 neuen und 8593 gelöschten Zeilen

Datei anzeigen

@@ -1,13 +1,4 @@
[
{
"version": "2026-05-03T15:21Z",
"date": "2026-05-03",
"title": "Übersichtlichere Navigation in der Seitenleiste",
"items": [
"Schaltflächen in der Seitenleiste haben jetzt klarere Icons und kürzere Beschriftungen",
"Der Feedback-Button zeigt nun ein Brief-Symbol für bessere Erkennbarkeit"
]
},
{
"version": "2026-04-30T23:12Z",
"date": "2026-04-30",

Datei anzeigen

@@ -11,8 +11,4 @@ python-multipart
aiosmtplib
geonamescache>=2.0
telethon
# Bericht-Export (PDF via WeasyPrint + DOCX via python-docx)
Jinja2>=3.1
weasyprint>=68.0
python-docx>=1.2
pikepdf>=9.0

Datei anzeigen

@@ -16,7 +16,7 @@ WICHTIG: Verwende IMMER echte UTF-8-Umlaute (ä, ö, ü, ß) — NIEMALS Umschre
VORFALL: {title}
KONTEXT: {description}
{fact_context_block}VORHANDENE MELDUNGEN:
VORHANDENE MELDUNGEN:
{articles_text}
AUFTRAG:
@@ -47,6 +47,7 @@ Antworte AUSSCHLIESSLICH als JSON-Objekt mit diesen Feldern:
- "summary": Zusammenfassung auf {output_language} mit Quellenverweisen [1], [2] etc. im Text (Markdown-Überschriften ## erlaubt wenn sinnvoll, aber KEINE "## ZUSAMMENFASSUNG"/"## ÜBERBLICK"-Sektion)
- "sources": Array von Quellenobjekten, je: {{"nr": 1, "name": "Quellenname", "url": "https://..."}}
- "key_facts": Array von bestätigten Kernfakten (Strings, in Ausgabesprache)
- "translations": Array von Objekten mit "article_id", "headline_de", "content_de" (nur für fremdsprachige Artikel)
Antworte NUR mit dem JSON-Objekt. Keine Einleitung, keine Erklärung."""
@@ -59,7 +60,7 @@ WICHTIG: Verwende IMMER echte UTF-8-Umlaute (ä, ö, ü, ß) — NIEMALS Umschre
THEMA: {title}
KONTEXT: {description}
{fact_context_block}VORLIEGENDE QUELLEN:
VORLIEGENDE QUELLEN:
{articles_text}
AUFTRAG:
@@ -101,6 +102,7 @@ Antworte AUSSCHLIESSLICH als JSON-Objekt mit diesen Feldern:
- "summary": Das strukturierte Briefing als Markdown-Text mit Quellenverweisen [1], [2] etc.
- "sources": Array von Quellenobjekten, je: {{"nr": 1, "name": "Quellenname", "url": "https://..."}}
- "key_facts": Array von gesicherten Kernfakten (Strings, in Ausgabesprache)
- "translations": Array von Objekten mit "article_id", "headline_de", "content_de" (nur für fremdsprachige Artikel)
Antworte NUR mit dem JSON-Objekt. Keine Einleitung, keine Erklärung."""
@@ -118,7 +120,7 @@ BISHERIGES LAGEBILD:
BISHERIGE QUELLEN:
{previous_sources_text}
{fact_context_block}NEUE MELDUNGEN SEIT DEM LETZTEN UPDATE:
NEUE MELDUNGEN SEIT DEM LETZTEN UPDATE:
{new_articles_text}
AUFTRAG:
@@ -147,6 +149,7 @@ Antworte AUSSCHLIESSLICH als JSON-Objekt mit diesen Feldern:
- "summary": Aktualisierte Zusammenfassung mit Quellenverweisen [1], [2] etc.
- "sources": Array mit NUR den NEUEN Quellen aus den neuen Meldungen, je: {{"nr": <fortlaufende ganze Zahl, KEINE Buchstaben-Suffixe>, "name": "Quellenname", "url": "https://..."}}. Alte Quellen werden automatisch gemerged.
- "key_facts": Array aller aktuellen Kernfakten (in Ausgabesprache)
- "translations": Array von Objekten mit "article_id", "headline_de", "content_de" (nur für neue fremdsprachige Artikel)
Antworte NUR mit dem JSON-Objekt. Keine Einleitung, keine Erklärung."""
@@ -165,7 +168,7 @@ BISHERIGES BRIEFING:
BISHERIGE QUELLEN:
{previous_sources_text}
{fact_context_block}NEUE QUELLEN SEIT DEM LETZTEN UPDATE:
NEUE QUELLEN SEIT DEM LETZTEN UPDATE:
{new_articles_text}
AUFTRAG:
@@ -198,6 +201,7 @@ Antworte AUSSCHLIESSLICH als JSON-Objekt mit diesen Feldern:
- "summary": Das aktualisierte Briefing als Markdown-Text mit Quellenverweisen
- "sources": Array mit NUR den NEUEN Quellen aus den neuen Meldungen, je: {{"nr": <fortlaufende ganze Zahl, KEINE Buchstaben-Suffixe>, "name": "Quellenname", "url": "https://..."}}. Alte Quellen werden automatisch gemerged.
- "key_facts": Array aller gesicherten Kernfakten (in Ausgabesprache)
- "translations": Array von Objekten mit "article_id", "headline_de", "content_de" (nur für neue fremdsprachige Artikel)
Antworte NUR mit dem JSON-Objekt. Keine Einleitung, keine Erklärung."""
@@ -264,112 +268,6 @@ Antworte AUSSCHLIESSLICH als JSON-Objekt — KEINE Erklärung, KEINE Einleitung:
{{"relevant_ids": [1, 3, 7]}}"""
# Status-Gruppen fuer den Fakten-Kontext im Analyse-Prompt.
# adhoc nutzt confirmed/unconfirmed/contradicted/developing,
# research nutzt established/unverified/disputed/developing — beide Domaenen
# werden in dieselben vier Anzeige-Gruppen abgebildet.
_FACT_STATUS_GROUPS = [
("Bestätigt (mehrere unabhängige Quellen oder durch Faktencheck als gesichert eingestuft):",
{"confirmed", "established"}),
("Umstritten (Quellen widersprechen sich oder Faktencheck hat Widersprüche dokumentiert):",
{"contradicted", "disputed"}),
("Unbestätigt (nur eine einzelne Quelle, eine unabhängige Bestätigung steht aus):",
{"unconfirmed", "unverified"}),
("In Entwicklung (laufender Sachverhalt, Stand offen):",
{"developing"}),
]
_FACT_STATUS_PRIORITY = {
"confirmed": 5, "established": 5,
"contradicted": 4, "disputed": 4,
"unconfirmed": 3, "unverified": 3,
"developing": 1,
}
def build_fact_context_block(
existing_facts: list[dict] | None,
new_or_updated_facts: list[dict] | None,
incident_type: str,
max_total: int = 20,
) -> str:
"""Baut den 'GEPRUEFTE FAKTEN'-Block fuer den Analyse-Prompt.
Wird vom Orchestrator zwischen Faktencheck und Lagebild aufgerufen, damit
das Lagebild auf gepruefter Faktenbasis schreibt und Unklarheiten explizit
benennt. Bei leerer Faktenliste wird ein leerer String zurueckgegeben — der
Prompt laeuft dann ohne Fakten-Kontext (Fallback bei Faktencheck-Fail oder
bei Lagen ohne bisherige Fakten).
"""
existing_facts = existing_facts or []
new_or_updated_facts = new_or_updated_facts or []
if not existing_facts and not new_or_updated_facts:
return ""
seen_claims: set[str] = set()
merged: list[dict] = []
# Neue/aktualisierte Fakten zuerst (Status ist aktueller Stand).
for f in new_or_updated_facts:
c = (f.get("claim") or "").strip().lower()
if not c or c in seen_claims:
continue
seen_claims.add(c)
merged.append(f)
# Dann alte unveraenderte Fakten.
for f in existing_facts:
c = (f.get("claim") or "").strip().lower()
if not c or c in seen_claims:
continue
seen_claims.add(c)
merged.append(f)
if not merged:
return ""
merged.sort(key=lambda f: (
-_FACT_STATUS_PRIORITY.get((f.get("status") or "").lower(), 0),
-(f.get("sources_count") or 0),
))
merged = merged[:max_total]
grouped: dict[str, list[dict]] = {label: [] for label, _ in _FACT_STATUS_GROUPS}
for f in merged:
s = (f.get("status") or "").lower()
for label, codes in _FACT_STATUS_GROUPS:
if s in codes:
grouped[label].append(f)
break
if not any(grouped.values()):
return ""
lines: list[str] = []
lines.append("GEPRÜFTE FAKTEN (Stand nach dem Faktencheck dieses Refresh, max. {n} priorisiert):".format(n=max_total))
for label, _codes in _FACT_STATUS_GROUPS:
items = grouped[label]
if not items:
continue
lines.append("")
lines.append(label)
for f in items:
claim = (f.get("claim") or "").strip()
sc = f.get("sources_count") or 0
sc_text = f" ({sc} {'Quellen' if sc != 1 else 'Quelle'})" if sc else ""
lines.append(f"- {claim}{sc_text}")
lines.append("")
lines.append("AUSSAGE-DISZIPLIN für das Lagebild:")
lines.append("- Bestätigte Fakten als Grundgerüst nehmen, ohne Hedging.")
lines.append("- Umstrittene Punkte explizit als umstritten kennzeichnen, beide Seiten knapp benennen.")
lines.append("- Unbestätigtes klar einordnen ('Eine einzelne Quelle berichtet ...', 'Eine unabhängige Bestätigung steht aus.').")
lines.append("- Bei Aussagen, die durch keinen geprüften Fakt gedeckt sind und auch nicht direkt aus einer der vorliegenden Meldungen hervorgehen: NICHT spekulieren — entweder weglassen oder als unklar kennzeichnen.")
lines.append("- Triff KEINE Aussagen, die mit den oben gelisteten geprüften Fakten in Widerspruch stehen.")
lines.append("")
return "\n".join(lines)
class AnalyzerAgent:
"""Analysiert und übersetzt Meldungen über Claude CLI."""
@@ -396,7 +294,7 @@ class AnalyzerAgent:
articles_text += f"Inhalt: {content[:800]}\n"
return articles_text
async def analyze(self, title: str, description: str, articles: list[dict], incident_type: str = "adhoc", fact_context_block: str = "") -> tuple[dict | None, ClaudeUsage | None]:
async def analyze(self, title: str, description: str, articles: list[dict], incident_type: str = "adhoc") -> tuple[dict | None, ClaudeUsage | None]:
"""Erstanalyse: Analysiert alle Meldungen zu einem Vorfall (erster Refresh)."""
if not articles:
return None, None
@@ -412,7 +310,6 @@ class AnalyzerAgent:
articles_text=articles_text,
today=today,
output_language=OUTPUT_LANGUAGE,
fact_context_block=fact_context_block,
)
try:
@@ -434,7 +331,6 @@ class AnalyzerAgent:
previous_summary: str,
previous_sources_json: str | None,
incident_type: str = "adhoc",
fact_context_block: str = "",
) -> tuple[dict | None, ClaudeUsage | None]:
"""Inkrementelle Analyse: Aktualisiert das Lagebild mit nur den neuen Artikeln.
@@ -477,7 +373,6 @@ class AnalyzerAgent:
new_articles_text=new_articles_text,
today=today,
output_language=OUTPUT_LANGUAGE,
fact_context_block=fact_context_block,
)
try:
@@ -901,5 +796,5 @@ class AnalyzerAgent:
except json.JSONDecodeError:
pass
return {"summary": summary, "sources": sources, "key_facts": []}
return {"summary": summary, "sources": sources, "key_facts": [], "translations": []}

Datei anzeigen

@@ -21,21 +21,15 @@ from source_rules import (
logger = logging.getLogger("osint.orchestrator")
# Reputations-Score nach Quellenkategorie (fuer Relevanz-Scoring).
# Keys muessen mit den tatsaechlichen DB-Werten in sources.category uebereinstimmen
# (siehe DOMAIN_CATEGORY_MAP in source_rules.py).
# Reputations-Score nach Quellenkategorie (für Relevanz-Scoring)
CATEGORY_REPUTATION = {
"nachrichtenagentur": 1.0, # Reuters, AP, dpa, AFP — Primärquellen
"behoerde": 1.0, # BMI, BSI, Europol — offizielle Quellen
"oeffentlich-rechtlich": 0.95, # tagesschau, ZDF, ARD, BBC, ORF
"qualitaetszeitung": 0.85, # Spiegel, Zeit, FAZ, NZZ, Süddeutsche
"think-tank": 0.85, # SWP, IISS, Brookings, Chatham House
"fachmedien": 0.8, # heise, golem, netzpolitik, Handelsblatt
"international": 0.75, # CNN, Guardian, NYT, Al Jazeera, France24
"regional": 0.65, # regionale Tageszeitungen
"telegram": 0.5, # OSINT-Kanaele — gemischte Qualitaet
"sonstige": 0.4, # unkategorisiert
"boulevard": 0.3, # Bild, Sun etc.
"nachrichten_de": 0.9,
"nachrichten_int": 0.9,
"presseagenturen": 1.0,
"behoerden": 1.0,
"fachmedien": 0.8,
"international": 0.7,
"sonstige": 0.4,
}
# Research-Modus: Automatisch 3 Durchläufe für optimale Ergebnisse
@@ -489,9 +483,6 @@ class AgentOrchestrator:
logger.info(f"Lage {incident_id} aus Warteschlange entfernt (removed={removed})")
# refresh_log-Eintrag schreiben, damit Auto-Refresh nicht im naechsten Tick erneut einreiht
await self._log_queued_cancellation(incident_id)
# Send cancelled event
if self._ws_manager:
try:
@@ -627,59 +618,21 @@ class AgentOrchestrator:
self._queue.task_done()
async def _mark_refresh_cancelled(self, incident_id: int):
"""Markiert den laufenden Refresh-Log-Eintrag als cancelled und schliesst
alle noch aktiven Pipeline-Schritte. Ohne den zweiten Schritt blieb der
zuletzt aktive Step-Eintrag verwaist und das Frontend zeigte dauerhaft
'Schritt X laeuft', weil /api/incidents/<id>/pipeline aus
refresh_pipeline_steps liest."""
"""Markiert den laufenden Refresh-Log-Eintrag als cancelled."""
from database import get_db
from services.pipeline_tracker import cancel_active_steps
db = await get_db()
try:
now_str = datetime.now(TIMEZONE).strftime('%Y-%m-%d %H:%M:%S')
cur = await db.execute(
"SELECT id FROM refresh_log WHERE incident_id = ? AND status = 'running'",
(incident_id,),
)
row = await cur.fetchone()
refresh_log_id = row["id"] if row else None
await db.execute(
"""UPDATE refresh_log SET status = 'cancelled', error_message = 'Vom Nutzer abgebrochen',
completed_at = ? WHERE incident_id = ? AND status = 'running'""",
(now_str, incident_id),
(datetime.now(TIMEZONE).strftime('%Y-%m-%d %H:%M:%S'), incident_id),
)
await db.commit()
if refresh_log_id is not None:
await cancel_active_steps(db, refresh_log_id=refresh_log_id)
except Exception as e:
logger.warning(f"Konnte Refresh-Log nicht als abgebrochen markieren: {e}")
finally:
await db.close()
async def _log_queued_cancellation(self, incident_id: int):
"""Schreibt einen cancelled-Eintrag fuer einen Queue-Abbruch (Lage war noch nicht laufend).
Verhindert, dass der Auto-Refresh-Scheduler im naechsten Tick sofort wieder einreiht."""
from database import get_db
db = await get_db()
try:
cur = await db.execute("SELECT tenant_id FROM incidents WHERE id = ?", (incident_id,))
row = await cur.fetchone()
tid = row["tenant_id"] if row else None
now_str = datetime.now(TIMEZONE).strftime("%Y-%m-%d %H:%M:%S")
await db.execute(
"""INSERT INTO refresh_log (incident_id, started_at, completed_at, status,
trigger_type, error_message, tenant_id)
VALUES (?, ?, ?, 'cancelled', 'manual', 'Aus Warteschlange entfernt', ?)""",
(incident_id, now_str, now_str, tid),
)
await db.commit()
except Exception as e:
logger.warning(f"Konnte Queue-Cancel nicht in refresh_log loggen: {e}")
finally:
await db.close()
async def _mark_refresh_failed(self, incident_id: int, error: str):
"""Markiert den laufenden Refresh-Log-Eintrag als error."""
from database import get_db
@@ -891,7 +844,7 @@ class AgentOrchestrator:
return articles, feed_usage
async def _web_search_pipeline():
"""Claude WebSearch-Recherche mit Vorselektion eingetragener Web-Quellen."""
"""Claude WebSearch-Recherche."""
researcher = ResearcherAgent()
# Bestehende Artikel als Kontext mitgeben (Research + Adhoc)
existing_for_context = None
@@ -902,31 +855,13 @@ class AgentOrchestrator:
"source_url": row["source_url"]}
for row in existing_db_articles_full
]
# Web-Quellen vorselektieren (Haiku) — nur thematisch passende werden Claude im Prompt empfohlen
preferred_sources = []
try:
from source_rules import get_feeds_with_metadata
web_sources = await get_feeds_with_metadata(tenant_id=tenant_id, source_type="web_source")
if web_sources:
preferred_sources, web_sel_usage = await researcher.select_relevant_web_sources(
title, description, web_sources,
)
if web_sel_usage:
usage_acc.add(web_sel_usage)
except Exception as e:
logger.warning(f"Web-Source-Vorselektion fehlgeschlagen (Pipeline laeuft weiter): {e}")
preferred_sources = []
results, usage, parse_failed = await researcher.search(
title, description, incident_type,
international=international, user_id=user_id,
existing_articles=existing_for_context,
preferred_sources=preferred_sources,
)
logger.info(
f"Claude-Recherche: {len(results)} Ergebnisse"
+ (f" (mit {len(preferred_sources)} Web-Quellen-Hinweis)" if preferred_sources else "")
+ (" (Parser fehlgeschlagen)" if parse_failed else "")
)
return results, usage, parse_failed
@@ -1299,22 +1234,18 @@ class AgentOrchestrator:
except Exception as e:
logger.warning("Bias-Anreicherung fehlgeschlagen (Pipeline laeuft weiter): %s", e)
# --- Analyse-Task (wird nach _do_factcheck mit fact_context_block aufgerufen) ---
async def _do_analysis(fact_context_block: str = ""):
# --- Analyse-Task ---
async def _do_analysis():
analyzer = AnalyzerAgent()
if previous_summary and new_count > 0:
logger.info(f"Inkrementelle Analyse: {new_count} neue Artikel zum bestehenden Lagebild")
return await analyzer.analyze_incremental(
title, description, new_articles_for_analysis,
previous_summary, previous_sources_json, incident_type,
fact_context_block=fact_context_block,
)
else:
logger.info("Erstanalyse: Alle Artikel werden analysiert")
return await analyzer.analyze(
title, description, all_articles_preloaded, incident_type,
fact_context_block=fact_context_block,
)
return await analyzer.analyze(title, description, all_articles_preloaded, incident_type)
# --- Faktencheck-Task ---
async def _do_factcheck():
@@ -1348,61 +1279,20 @@ class AgentOrchestrator:
articles_for_check = [dict(row) for row in await cursor.fetchall()]
return await factchecker.check(title, articles_for_check, incident_type)
# Pipeline-Schritt 6: Faktencheck zuerst (sequenziell). Liefert den
# Faktenkontext fuer das Lagebild, damit dieses auf geprueftem Stand
# schreibt und Unklarheiten explizit benennt. Variante 1: bei
# Faktencheck-Fehler faellt das Lagebild auf den alten Pfad ohne
# Faktenkontext zurueck (Refresh bricht NICHT ab).
await _pipe_start("factcheck")
factcheck_result: tuple = ([], None)
fact_context_block = ""
factcheck_failed_reason: str | None = None
try:
factcheck_result = await _do_factcheck()
except Exception as fc_err:
factcheck_failed_reason = str(fc_err)
logger.warning(
"Faktencheck fehlgeschlagen, Lagebild laeuft ohne Faktenkontext: %s",
fc_err, exc_info=True,
)
fact_checks, fc_usage = factcheck_result if factcheck_result else ([], None)
# Pipeline-Schritt 6 done direkt nach dem Aufruf — die finale
# DB-Persistierung passiert weiter unten, aber fuer die UI ist
# der Faktencheck-Aufruf hier abgeschlossen. Der count_value
# ist eine Schaetzung (echte Zahl steht spaeter in der DB).
_fc_estimated_new = max(0, len(fact_checks or []) - len(existing_facts or []))
await _pipe_done(
"factcheck",
count_value=_fc_estimated_new,
count_secondary=len(fact_checks) if fact_checks else 0,
)
# Faktenkontext fuer das Lagebild bauen.
try:
from agents.analyzer import build_fact_context_block as _build_fc_ctx
fact_context_block = _build_fc_ctx(
existing_facts or [], fact_checks or [], incident_type,
)
if fact_context_block:
logger.info(
"Faktenkontext fuer Lagebild: %d Zeichen, basierend auf %d alten + %d neuen Fakten",
len(fact_context_block), len(existing_facts or []), len(fact_checks or []),
)
except Exception as ctx_err:
logger.warning("build_fact_context_block fehlgeschlagen: %s", ctx_err, exc_info=True)
fact_context_block = ""
# Pipeline-Schritt 7: Lagebild verfassen (jetzt mit Faktenkontext)
# Pipeline-Schritte 6+7: Lagebild verfassen + Fakten prüfen (Start, parallel)
await _pipe_start("summary")
logger.info(
"Starte Lagebild (sequenziell nach Faktencheck%s)",
" — OHNE Faktenkontext (Fallback)" if factcheck_failed_reason else "",
await _pipe_start("factcheck")
# Beide Tasks PARALLEL starten
logger.info("Starte Analyse und Faktencheck parallel...")
analysis_result, factcheck_result = await asyncio.gather(
_do_analysis(),
_do_factcheck(),
)
analysis_result = await _do_analysis(fact_context_block)
analysis, analysis_usage = analysis_result
fact_checks, fc_usage = factcheck_result
# Pipeline-Schritt 6: Lagebild verfassen (fertig, keine Zahl, nur Status)
await _pipe_done("summary", count_value=None, count_secondary=None)
# --- Analyse-Ergebnisse verarbeiten ---
@@ -1496,64 +1386,20 @@ class AgentOrchestrator:
snap_articles, snap_fcs, log_id, now, tenant_id),
)
# Translations werden vom dedizierten Translator-Agent unten
# erzeugt (frueher inline im Analyzer-Output, das war token-
# instabil und schaetzte regelmaessig content_de aus).
# Übersetzungen aktualisieren (nur für gültige DB-IDs)
for translation in analysis.get("translations", []):
article_id = translation.get("article_id")
if isinstance(article_id, int):
await db.execute(
"UPDATE articles SET headline_de = ?, content_de = ? WHERE id = ? AND incident_id = ?",
(translation.get("headline_de"), translation.get("content_de"), article_id, incident_id),
)
await db.commit()
# Cancel-Check nach paralleler Verarbeitung
self._check_cancelled(incident_id)
# --- Translator (Haiku) fuer fremdsprachige Artikel ohne DE-Texte ---
# Idempotent: nur Artikel ohne headline_de/content_de werden geholt.
# Lauft nach der Analyse (Lagebild ist schon committed) und vor QC
# (damit normalize_umlaut_articles auch die frischen DE-Texte fasst).
try:
tr_cursor = await db.execute(
"""SELECT id, headline, content_original, language
FROM articles
WHERE incident_id = ?
AND language IS NOT NULL AND LOWER(language) != 'de'
AND (headline_de IS NULL OR headline_de = ''
OR content_de IS NULL OR content_de = '')""",
(incident_id,),
)
pending_translations = [dict(r) for r in await tr_cursor.fetchall()]
if pending_translations:
logger.info(
"Translator fuer Incident %d: %d Artikel ohne DE-Uebersetzung",
incident_id, len(pending_translations),
)
from agents.translator import translate_articles
from services.post_refresh_qc import normalize_german_umlauts as _norm_de2
translations = await translate_articles(
pending_translations,
output_lang="de",
usage_accumulator=usage_acc,
)
for t in translations:
hd = t.get("headline_de")
cd = t.get("content_de")
if hd:
hd, _ = _norm_de2(hd)
if cd:
cd, _ = _norm_de2(cd)
if hd or cd:
await db.execute(
"UPDATE articles SET headline_de = COALESCE(?, headline_de), "
"content_de = COALESCE(?, content_de) WHERE id = ? AND incident_id = ?",
(hd, cd, t["id"], incident_id),
)
await db.commit()
logger.info(
"Translator fuer Incident %d: %d/%d Artikel uebersetzt",
incident_id, len(translations), len(pending_translations),
)
except Exception as e:
logger.error("Translator-Fehler fuer Incident %d: %s", incident_id, e, exc_info=True)
# Refresh trotz Translator-Fehler weiterlaufen lassen
# --- Neueste Entwicklungen (nur Live-Monitoring / adhoc) ---
# Basis ist jetzt das frisch generierte Lagebild (autoritativ, thematisch sauber).
# Zeitstempel und Quellen kommen aus den jüngsten belegenden Artikeln.
@@ -1701,10 +1547,9 @@ class AgentOrchestrator:
await db.commit()
# Pipeline-Schritt 7 (Fakten pruefen) wurde bereits frueher als done
# markiert (siehe weiter oben — direkt nach dem _do_factcheck-Aufruf,
# bevor das Lagebild generiert wurde). Hier nur noch die DB-
# Persistierung der Fakten, ohne den Step erneut zu schliessen.
# Pipeline-Schritt 7: Fakten prüfen (fertig)
_new_facts_count = max(0, len(fact_checks) - len(existing_facts))
await _pipe_done("factcheck", count_value=_new_facts_count, count_secondary=len(fact_checks) if fact_checks else 0)
# Pipeline-Schritt 8: Qualitätscheck (Start, ohne Zahlen)
await _pipe_start("qc")

Datei anzeigen

@@ -69,7 +69,7 @@ WICHTIG: Verwende IMMER echte UTF-8-Umlaute (ä, ö, ü, ß) — NIEMALS Umschre
AUFTRAG: Suche nach aktuellen Informationen zu folgendem Vorfall:
Titel: {title}
Kontext: {description}
{existing_context}{preferred_sources_block}
{existing_context}
REGELN:
- Suche nur bei seriösen Nachrichtenquellen (Nachrichtenagenturen, Qualitätszeitungen, öffentlich-rechtliche Medien, Behörden)
- KEIN Social Media (Twitter/X, Facebook, Instagram, TikTok, Reddit)
@@ -77,7 +77,7 @@ REGELN:
{language_instruction}
- Faktenbasiert und neutral - keine Spekulationen
- KRITISCH für source_url: Kopiere die EXAKTE URL aus den WebSearch-Ergebnissen. Erfinde oder konstruiere NIEMALS URLs aus Mustern oder Erinnerung. Wenn du die exakte URL eines Artikels nicht aus den Suchergebnissen hast, lass diesen Artikel komplett weg.
- Nutze removepaywall.com für Paywall-geschützte Artikel (z.B. Spiegel+, Zeit+, SZ+): https://www.removepaywall.com/search?url=ARTIKEL_URL
- Nutze removepaywalls.com für Paywall-geschützte Artikel (z.B. Spiegel+, Zeit+, SZ+): https://www.removepaywalls.com/search?url=ARTIKEL_URL
- Nutze WebFetch um die 3-5 wichtigsten Artikel vollständig abzurufen und zusammenzufassen
Gib die Ergebnisse AUSSCHLIESSLICH als JSON-Array zurück, ohne Erklärungen davor oder danach.
@@ -100,7 +100,7 @@ WICHTIG: Verwende IMMER echte UTF-8-Umlaute (ä, ö, ü, ß) — NIEMALS Umschre
AUFTRAG: Führe eine umfassende, mehrstufige Hintergrundrecherche durch zu:
Titel: {title}
Kontext: {description}
{existing_context}{preferred_sources_block}
{existing_context}
RECHERCHE IN 4 PHASEN — Führe ALLE Phasen nacheinander durch:
PHASE 1 — BREITE ERFASSUNG:
@@ -124,7 +124,7 @@ Nutze spezifische Suchbegriffe für institutionelle Quellen. Ziel: 6-10 weitere
PHASE 4 — VERIFIKATION UND VERTIEFUNG:
Nutze WebFetch um die 6-10 wichtigsten Artikel vollständig abzurufen und ausführlich zusammenzufassen.
Priorisiere dabei Primärquellen und investigative Berichte.
Nutze removepaywall.com für Paywall-geschützte Artikel (z.B. https://www.removepaywall.com/search?url=ARTIKEL_URL)
Nutze removepaywalls.com für Paywall-geschützte Artikel (z.B. https://www.removepaywalls.com/search?url=ARTIKEL_URL)
{language_instruction}
@@ -199,45 +199,19 @@ AKTUELLE HEADLINES (die letzten Meldungen zu diesem Thema):
AUFGABE:
Generiere 5 Begriffspaare (DE + EN), mit denen neue RSS-Artikel zu diesem Thema gefunden werden.
Ein Artikel gilt als relevant, wenn mindestens 2 dieser Begriffe im Titel oder der Beschreibung vorkommen
- bei spezifischen Begriffen (Eigennamen, lange Begriffe ab 7 Zeichen) reicht 1 Treffer.
Ein Artikel gilt als relevant, wenn mindestens 2 dieser Begriffe im Titel oder der Beschreibung vorkommen.
REGELN:
- ZWINGEND: Eigennamen oder spezifische Begriffe aus dem THEMA (z.B. Personennamen, Tiernamen,
Ortsnamen wie "timmy", "buckelwal", "merz", "dobrindt") MUESSEN als eigene Begriffspaare
enthalten sein. Solche Begriffe sind oft das einzige, was in kurzen Headlines vorkommt.
- Die ersten 2 Begriffspaare sind die zentralen Akteure/Laender/Themen (z.B. iran, israel,
buckelwal, timmy) — also die Begriffe, die in fast JEDEM Artikel zum Thema vorkommen.
- Die uebrigen 3 Begriffspaare sind aktuelle Entwicklungen aus den Headlines (Orte, Akteure,
Schluesselwoerter der aktuellen Phase).
- Wenn DE und EN identisch sind (Eigennamen), trotzdem das Paar einreichen.
- Begriffe muessen so gewaehlt sein, dass sie in kurzen RSS-Titeln matchen (einzelne Woerter,
keine Phrasen, keine Konjunktionen).
- Alle Begriffe in Kleinbuchstaben.
- Exakt 5 Begriffspaare.
- Die ersten 2 Begriffspaare MUESSEN die zentralen Akteure/Laender/Themen sein (z.B. iran, israel, usa) — also die Begriffe, die in fast JEDEM Artikel zum Thema vorkommen
- Die letzten 3 Begriffspaare sind aktuelle Entwicklungen aus den Headlines (Orte, Akteure, Schluesselwoerter der aktuellen Phase)
- Begriffe muessen so gewaehlt sein, dass sie in kurzen RSS-Titeln matchen (einzelne Woerter, keine Phrasen)
- Alle Begriffe in Kleinbuchstaben
- Exakt 5 Begriffspaare
Antwort NUR als JSON-Array:
[{{"de": "iran", "en": "iran"}}, {{"de": "israel", "en": "israel"}}, {{"de": "teheran", "en": "tehran"}}, {{"de": "luftangriff", "en": "airstrike"}}, {{"de": "trump", "en": "trump"}}]"""
WEB_SOURCE_SELECTION_PROMPT = """Du bist ein OSINT-Analyst. Pruefe diese eingetragenen Web-Quellen und waehle nur die thematisch passenden aus.
LAGE: {title}
KONTEXT: {description}
WEB-QUELLEN:
{source_list}
REGELN:
- Waehle nur Quellen, die thematisch tatsaechlich zur Lage passen
- Lieber leere Liste zurueckgeben als pauschal alle aufnehmen
- Behoerden- und institutionelle Quellen sind oft hochwertig, aber nur wenn das Thema passt
- Petitions-Plattformen z.B. nur bei Lagen zu Buergerinitiativen, Gesetzen, oeffentlichem Druck
- Bei reinen Kriegs-/Konflikt-/Tagesnachrichten meistens leere Liste
Antworte NUR mit einem JSON-Array der Quellen-Nummern, z.B. [1, 3] oder []."""
TELEGRAM_CHANNEL_SELECTION_PROMPT = """Du bist ein OSINT-Analyst. Waehle aus dieser Liste von Telegram-Kanaelen diejenigen aus, die fuer die Lage relevant sein koennten.
LAGE: {title}
@@ -373,17 +347,6 @@ class ResearcherAgent:
if en and en != de:
keywords.append(en)
# Bug-2-Fallback: Lagentitel-Wörter (>=4 Zeichen) zwingend in Keyword-Liste,
# falls Haiku sie weggelassen hat. Verhindert "Buckelwal timmy"-Bug, bei dem
# der Eigenname "timmy" fehlte und damit Headlines mit nur "Buckelwal" durchfielen.
STOPWORDS = {"der", "die", "das", "und", "oder", "von", "vom", "zum", "zur",
"the", "and", "for", "with", "ueber", "über", "von", "for"}
for word in (title or "").lower().split():
w = word.strip(".,;:!?\"\'()[]{}")
if len(w) >= 4 and w not in STOPWORDS and w not in keywords:
keywords.append(w)
logger.info(f"Lagentitel-Keyword '{w}' nachträglich injiziert")
if keywords:
logger.info(f"Dynamische Keywords ({len(keywords)}): {keywords}")
return keywords if keywords else None, usage
@@ -392,7 +355,7 @@ class ResearcherAgent:
logger.warning(f"Keyword-Extraktion fehlgeschlagen: {e}")
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) -> 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) -> tuple[list[dict], ClaudeUsage | None, bool]:
"""Sucht nach Informationen zu einem Vorfall.
Returns:
@@ -401,26 +364,6 @@ class ResearcherAgent:
"echt keine Treffer" und "kaputte Antwort" unterscheiden.
"""
from config import OUTPUT_LANGUAGE
# Bevorzugte Web-Quellen als Prompt-Block (optional)
preferred_sources_block = ""
if preferred_sources:
ps_lines = []
for s in preferred_sources:
domain = s.get("domain", "")
name = s.get("name", domain) or domain
if not domain:
continue
ps_lines.append(f"- {domain} ({name})")
if ps_lines:
preferred_sources_block = (
"\nEINGETRAGENE WEB-QUELLEN (vom Betreiber als seriös markiert):\n"
+ "\n".join(ps_lines) + "\n"
"EMPFEHLUNG: Wenn diese Domains thematisch zur Lage passen, suche dort gezielt "
"mit \"site:domain [Suchbegriff]\". Sie sind vertrauenswuerdig eingetragen, ersetzen "
"aber nicht deine sonstige Recherche.\n"
)
if incident_type == "research":
lang_instruction = LANG_DEEP_INTERNATIONAL if international else LANG_DEEP_GERMAN_ONLY
# Bestehende Artikel als Kontext für den Prompt aufbereiten
@@ -440,7 +383,6 @@ class ResearcherAgent:
prompt = DEEP_RESEARCH_PROMPT_TEMPLATE.format(
title=title, description=description, language_instruction=lang_instruction,
output_language=OUTPUT_LANGUAGE, existing_context=existing_context,
preferred_sources_block=preferred_sources_block,
)
else:
lang_instruction = LANG_INTERNATIONAL if international else LANG_GERMAN_ONLY
@@ -459,7 +401,6 @@ class ResearcherAgent:
prompt = RESEARCH_PROMPT_TEMPLATE.format(
title=title, description=description, language_instruction=lang_instruction,
output_language=OUTPUT_LANGUAGE, existing_context=existing_context,
preferred_sources_block=preferred_sources_block,
)
try:
@@ -573,67 +514,6 @@ class ResearcherAgent:
)
raise ResearcherParseError(f"Claude-Antwort enthielt kein verwertbares JSON (Laenge: {len(text)})")
async def select_relevant_web_sources(
self,
title: str,
description: str,
web_sources: list[dict],
) -> tuple[list[dict], ClaudeUsage | None]:
"""Laesst Claude die thematisch passenden Web-Quellen auswaehlen (Haiku).
Returns:
(ausgewaehlte Quellen, usage). Bei Fehler: ([], None).
Leere Auswahl ist explizit erlaubt — keine Quelle wird zwangsweise aufgenommen.
"""
if not web_sources:
return [], None
# Bei sehr wenigen Quellen lohnt der Selektions-Call kaum — alle weiterreichen.
if len(web_sources) <= 3:
logger.info("Web-Source-Selektion: Nur %d Quellen, alle uebernehmen", len(web_sources))
return list(web_sources), None
lines = []
for i, src in enumerate(web_sources, 1):
cat = src.get("category", "sonstige")
notes = (src.get("notes") or "")[:80]
domain = src.get("domain", "")
line = f"{i}. {src.get('name', domain)} ({domain}) [{cat}]"
if notes:
line += f" - {notes}"
lines.append(line)
prompt = WEB_SOURCE_SELECTION_PROMPT.format(
title=title,
description=description or "Keine weitere Beschreibung",
source_list="\n".join(lines),
)
try:
result, usage = await call_claude(prompt, tools=None, model=CLAUDE_MODEL_FAST)
indices = _extract_json_array(result)
if not isinstance(indices, list):
logger.warning(
"Web-Source-Selektion: Kein JSON in Antwort, ignoriere Quellen. Sample: %s",
_truncate_for_log(result),
)
return [], usage
selected = []
for idx in indices:
if isinstance(idx, int) and 1 <= idx <= len(web_sources):
selected.append(web_sources[idx - 1])
logger.info(
"Web-Source-Selektion: %d von %d ausgewaehlt%s",
len(selected), len(web_sources),
f" ({', '.join(s.get('domain', '') for s in selected)})" if selected else "",
)
return selected, usage
except Exception as e:
logger.warning("Web-Source-Selektion fehlgeschlagen (%s)", e)
return [], None
async def select_relevant_telegram_channels(
self,
title: str,

Datei anzeigen

@@ -1,254 +0,0 @@
"""Translator-Agent: uebersetzt fremdsprachige Artikel ins Deutsche.
Eigener Agent (separat vom Analyzer), damit Token-Limits nicht zwischen
Lagebild und Uebersetzung konkurrieren. Nutzt CLAUDE_MODEL_FAST (Haiku) in
Batches.
Aufgerufen vom Orchestrator nach analyzer.analyze() und vor post_refresh_qc.
Backfill-Skript nutzt dieselbe Funktion fuer rueckwirkendes Auffuellen.
"""
import json
import logging
import re
from agents.claude_client import call_claude, ClaudeUsage, UsageAccumulator
from config import CLAUDE_MODEL_FAST, TRANSLATOR_ENABLED
logger = logging.getLogger("osint.translator")
# Pro Batch nicht mehr als so viele Artikel an Claude geben.
# Bei Haiku ist das Output-Limit ca. 8k Tokens. Pro Artikel kommen leicht
# 400-600 Tokens raus (headline_de + content_de bis 1000 Zeichen). Bei 15
# wurde regelmaessig getrunkt (mid-JSON broken). 5 ist sicher mit Reserve.
DEFAULT_BATCH_SIZE = 5
# content_original wird ohnehin auf 1000 Zeichen gecappt (rss_parser).
# Fuer den Translator nochmal verkuerzen, falls vorhanden mehr.
CONTENT_INPUT_MAX = 1200
# content_de soll wie content_original auf 1000 Zeichen begrenzt sein.
CONTENT_OUTPUT_MAX = 1000
def _extract_complete_objects(text: str) -> list[dict]:
"""Extrahiert vollstaendige JSON-Objekte aus moeglicherweise abgeschnittenem Text.
Klammer-Counter-Ansatz: jedes balancierte {...} wird probiert.
"""
results = []
depth = 0
start = -1
in_string = False
escape = False
for i, ch in enumerate(text):
if escape:
escape = False
continue
if ch == "\\":
escape = True
continue
if ch == '"' and not escape:
in_string = not in_string
continue
if in_string:
continue
if ch == "{":
if depth == 0:
start = i
depth += 1
elif ch == "}":
depth -= 1
if depth == 0 and start >= 0:
obj_text = text[start:i + 1]
try:
obj = json.loads(obj_text)
if isinstance(obj, dict):
results.append(obj)
except json.JSONDecodeError:
pass
start = -1
return results
def _build_prompt(articles: list[dict], output_lang: str = "de") -> str:
"""Bauen den Translation-Prompt fuer eine Batch."""
lang_label = {"de": "Deutsch", "en": "Englisch"}.get(output_lang, output_lang)
items = []
for a in articles:
items.append({
"id": a["id"],
"headline": a.get("headline", "") or "",
"content": (a.get("content_original") or "")[:CONTENT_INPUT_MAX],
"source_lang": a.get("language", "en"),
})
return f"""Du bist ein praeziser Uebersetzer fuer Nachrichten-Artikel.
Uebersetze die folgenden Artikel nach {lang_label}.
WICHTIG:
- Verwende IMMER echte UTF-8-Umlaute (ä, ö, ü, ß) - NIEMALS Umschreibungen wie ae, oe, ue, ss.
Beispiele: "Gespraeche" -> "Gespräche", "Fuehrer" -> "Führer", "grosse" -> "große".
- Behalte Eigennamen (Personen, Orte, Organisationen) im Original.
- Headline kurz und buendig wie im Original.
- Content auf MAX {CONTENT_OUTPUT_MAX} Zeichen kuerzen, kein HTML, kein Markdown.
- Wenn der Artikel schon auf {lang_label} ist (z.B. source_lang="{output_lang}"),
kopiere headline und content unveraendert.
Antworte AUSSCHLIESSLICH mit einem flachen JSON-Array (kein Wrapper-Objekt!).
Format genau so:
[
{{"id": 1, "headline_de": "Titel auf Deutsch", "content_de": "Inhalt auf Deutsch"}},
{{"id": 2, "headline_de": "...", "content_de": "..."}}
]
NICHT erlaubt: {{"translations": [...]}} oder {{"items": [...]}} oder Markdown-Codefences.
Nur das Array, ohne Einleitung, ohne Erklaerung.
ARTIKEL:
{json.dumps(items, ensure_ascii=False, indent=2)}
"""
def _parse_response(text: str) -> list[dict]:
"""Robustes JSON-Array-Parsing.
Handhabt:
- reines JSON
- JSON in Markdown-Codefence ```json ... ```
- abgeschnittene Antworten (extrahiert vollstaendige Top-Level-Objekte)
"""
text = text.strip()
# Markdown-Codefence entfernen
if text.startswith("```"):
text = re.sub(r"^```(?:json)?\s*", "", text)
text = re.sub(r"\s*```\s*$", "", text)
text = text.strip()
try:
data = json.loads(text)
except json.JSONDecodeError:
# Erst Array versuchen
match = re.search(r"\[.*\]", text, re.DOTALL)
if match:
try:
data = json.loads(match.group(0))
except json.JSONDecodeError:
# Truncate-Fallback: einzelne Top-Level-Objekte extrahieren
data = _extract_complete_objects(text)
else:
data = _extract_complete_objects(text)
# Claude wraps das Array gelegentlich in {"translations": [...]} oder {"items": [...]}
if isinstance(data, dict):
for key in ("translations", "items", "results", "data"):
if isinstance(data.get(key), list):
data = data[key]
break
else:
# Einzelnes Objekt? Dann als Liste mit einem Element behandeln
if "id" in data:
data = [data]
else:
raise ValueError(f"Translator-Antwort: Dict ohne erwarteten Array-Key (keys={list(data.keys())[:5]})")
if not isinstance(data, list):
raise ValueError(f"Translator-Antwort ist kein Array: {type(data).__name__}")
cleaned = []
for item in data:
if not isinstance(item, dict):
continue
aid = item.get("id")
if not isinstance(aid, int):
try:
aid = int(aid)
except (TypeError, ValueError):
continue
cleaned.append({
"id": aid,
"headline_de": (item.get("headline_de") or "").strip() or None,
"content_de": (item.get("content_de") or "").strip() or None,
})
return cleaned
async def translate_articles_batch(
articles: list[dict],
output_lang: str = "de",
) -> tuple[list[dict], ClaudeUsage]:
"""Uebersetzt eine Batch von Artikeln.
Erwartet articles als Liste von Dicts mit den Feldern id, headline,
content_original, language.
Rueckgabe: (uebersetzte_artikel, usage)
Wenn der Call fehlschlaegt, wird ([], leere_usage) zurueckgegeben - der
Caller kann entscheiden, ob retry oder skip.
"""
if not articles:
return [], ClaudeUsage()
prompt = _build_prompt(articles, output_lang)
try:
result_text, usage = await call_claude(prompt, tools=None, model=CLAUDE_MODEL_FAST)
except Exception as e:
logger.error(f"Translator Claude-Call fehlgeschlagen: {e}")
return [], ClaudeUsage()
try:
translations = _parse_response(result_text)
except Exception as e:
logger.error(f"Translator JSON-Parsing fehlgeschlagen: {e}; raw: {result_text[:300]!r}")
return [], usage
# Validierung: nur Translations zurueckgeben, deren id wirklich
# in der angefragten Batch war
requested_ids = {a["id"] for a in articles}
valid = [t for t in translations if t["id"] in requested_ids]
if len(valid) != len(translations):
logger.warning(
"Translator: %d von %d Translations referenzieren unbekannte IDs",
len(translations) - len(valid), len(translations),
)
return valid, usage
async def translate_articles(
articles: list[dict],
output_lang: str = "de",
batch_size: int = DEFAULT_BATCH_SIZE,
usage_accumulator: UsageAccumulator | None = None,
) -> list[dict]:
"""Uebersetzt eine beliebige Anzahl Artikel in Batches.
Bringt die Batches durch Logik in `translate_articles_batch` und gibt
EINE flache Liste der Translations zurueck. Wenn ein Batch fehlschlaegt,
wird er uebersprungen (anderer Batches laufen weiter).
"""
if not articles:
return []
if not TRANSLATOR_ENABLED:
logger.info(
"Translator deaktiviert (TRANSLATOR_ENABLED=false), %d Artikel uebersprungen",
len(articles),
)
return []
all_translations = []
for i in range(0, len(articles), batch_size):
batch = articles[i : i + batch_size]
translations, usage = await translate_articles_batch(batch, output_lang)
if usage_accumulator is not None:
usage_accumulator.add(usage)
all_translations.extend(translations)
logger.info(
"Translator-Batch %d/%d: %d/%d uebersetzt (cost=$%.4f)",
(i // batch_size) + 1,
(len(articles) + batch_size - 1) // batch_size,
len(translations), len(batch),
usage.cost_usd,
)
return all_translations

Datei anzeigen

@@ -41,10 +41,6 @@ OUTPUT_LANGUAGE = "Deutsch"
# In Kundenversion auf False setzen oder Env-Variable entfernen
DEV_MODE = os.environ.get("DEV_MODE", "true").lower() == "true"
# Feature-Flag: Translator-Agent (Haiku) komplett deaktivieren.
# False = keine Uebersetzungen mehr, fremdsprachige Artikel bleiben unuebersetzt.
TRANSLATOR_ENABLED = os.environ.get("TRANSLATOR_ENABLED", "true").lower() == "true"
# RSS-Feeds (Fallback, primär aus DB geladen)
RSS_FEEDS = {
"deutsch": [
@@ -95,9 +91,3 @@ TELEGRAM_API_ID = int(os.environ.get("TELEGRAM_API_ID", "0"))
TELEGRAM_API_HASH = os.environ.get("TELEGRAM_API_HASH", "")
TELEGRAM_SESSION_PATH = os.environ.get("TELEGRAM_SESSION_PATH", "/home/claude-dev/.telegram/telegram_session")
# Health-Check (genutzt von services/source_health.py)
HEALTH_CHECK_USER_AGENT = os.environ.get(
"HEALTH_CHECK_USER_AGENT",
"Mozilla/5.0 (compatible; AegisSight-HealthCheck/1.0)",
)
HEALTH_CHECK_TIMEOUT_S = float(os.environ.get("HEALTH_CHECK_TIMEOUT_S", "15.0"))

Datei anzeigen

@@ -158,36 +158,7 @@ CREATE TABLE IF NOT EXISTS sources (
article_count INTEGER DEFAULT 0,
last_seen_at TIMESTAMP,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
tenant_id INTEGER REFERENCES organizations(id),
language TEXT,
bias TEXT,
political_orientation TEXT DEFAULT 'na',
media_type TEXT DEFAULT 'sonstige',
reliability TEXT DEFAULT 'na',
state_affiliated INTEGER DEFAULT 0,
country_code TEXT,
classification_source TEXT DEFAULT 'legacy',
classified_at TIMESTAMP,
proposed_political_orientation TEXT,
proposed_media_type TEXT,
proposed_reliability TEXT,
proposed_state_affiliated INTEGER,
proposed_country_code TEXT,
proposed_alignments_json TEXT,
proposed_confidence REAL,
proposed_reasoning TEXT,
proposed_at TIMESTAMP,
eu_disinfo_listed INTEGER DEFAULT 0,
eu_disinfo_case_count INTEGER DEFAULT 0,
eu_disinfo_last_seen TIMESTAMP,
ifcn_signatory INTEGER DEFAULT 0,
external_data_synced_at TIMESTAMP
);
CREATE TABLE IF NOT EXISTS source_alignments (
source_id INTEGER NOT NULL REFERENCES sources(id) ON DELETE CASCADE,
alignment TEXT NOT NULL,
PRIMARY KEY (source_id, alignment)
tenant_id INTEGER REFERENCES organizations(id)
);
CREATE TABLE IF NOT EXISTS notifications (
@@ -640,71 +611,6 @@ async def init_db():
await db.execute("ALTER TABLE sources ADD COLUMN tenant_id INTEGER REFERENCES organizations(id)")
await db.commit()
# Migration: language + bias (Freitext, schon laenger im Einsatz, Schema-Lueck schliessen)
if "language" not in src_columns:
await db.execute("ALTER TABLE sources ADD COLUMN language TEXT")
await db.commit()
if "bias" not in src_columns:
await db.execute("ALTER TABLE sources ADD COLUMN bias TEXT")
await db.commit()
# Migration: strukturierte Klassifikations-Spalten fuer sources
for col, ddl in [
("political_orientation", "ALTER TABLE sources ADD COLUMN political_orientation TEXT DEFAULT 'na'"),
("media_type", "ALTER TABLE sources ADD COLUMN media_type TEXT DEFAULT 'sonstige'"),
("reliability", "ALTER TABLE sources ADD COLUMN reliability TEXT DEFAULT 'na'"),
("state_affiliated", "ALTER TABLE sources ADD COLUMN state_affiliated INTEGER DEFAULT 0"),
("country_code", "ALTER TABLE sources ADD COLUMN country_code TEXT"),
("classification_source", "ALTER TABLE sources ADD COLUMN classification_source TEXT DEFAULT 'legacy'"),
("classified_at", "ALTER TABLE sources ADD COLUMN classified_at TIMESTAMP"),
("proposed_political_orientation", "ALTER TABLE sources ADD COLUMN proposed_political_orientation TEXT"),
("proposed_media_type", "ALTER TABLE sources ADD COLUMN proposed_media_type TEXT"),
("proposed_reliability", "ALTER TABLE sources ADD COLUMN proposed_reliability TEXT"),
("proposed_state_affiliated", "ALTER TABLE sources ADD COLUMN proposed_state_affiliated INTEGER"),
("proposed_country_code", "ALTER TABLE sources ADD COLUMN proposed_country_code TEXT"),
("proposed_alignments_json", "ALTER TABLE sources ADD COLUMN proposed_alignments_json TEXT"),
("proposed_confidence", "ALTER TABLE sources ADD COLUMN proposed_confidence REAL"),
("proposed_reasoning", "ALTER TABLE sources ADD COLUMN proposed_reasoning TEXT"),
("proposed_at", "ALTER TABLE sources ADD COLUMN proposed_at TIMESTAMP"),
]:
if col not in src_columns:
await db.execute(ddl)
await db.commit()
if any(c not in src_columns for c in ("political_orientation", "media_type", "reliability")):
logger.info("Migration: Klassifikations-Spalten zu sources hinzugefuegt")
# Migration: externe Reputations-Daten (EUvsDisinfo + IFCN)
for col, ddl in [
("eu_disinfo_listed", "ALTER TABLE sources ADD COLUMN eu_disinfo_listed INTEGER DEFAULT 0"),
("eu_disinfo_case_count", "ALTER TABLE sources ADD COLUMN eu_disinfo_case_count INTEGER DEFAULT 0"),
("eu_disinfo_last_seen", "ALTER TABLE sources ADD COLUMN eu_disinfo_last_seen TIMESTAMP"),
("ifcn_signatory", "ALTER TABLE sources ADD COLUMN ifcn_signatory INTEGER DEFAULT 0"),
("external_data_synced_at", "ALTER TABLE sources ADD COLUMN external_data_synced_at TIMESTAMP"),
]:
if col not in src_columns:
await db.execute(ddl)
await db.commit()
if any(c not in src_columns for c in ("eu_disinfo_listed", "ifcn_signatory")):
logger.info("Migration: externe Reputations-Spalten zu sources hinzugefuegt")
# Migration: source_alignments-Tabelle (Mehrfach-Tags fuer geopolitische Naehe)
cursor = await db.execute(
"SELECT name FROM sqlite_master WHERE type='table' AND name='source_alignments'"
)
if not await cursor.fetchone():
await db.executescript(
"""
CREATE TABLE source_alignments (
source_id INTEGER NOT NULL REFERENCES sources(id) ON DELETE CASCADE,
alignment TEXT NOT NULL,
PRIMARY KEY (source_id, alignment)
);
CREATE INDEX IF NOT EXISTS idx_source_alignments_alignment ON source_alignments(alignment);
"""
)
await db.commit()
logger.info("Migration: source_alignments-Tabelle erstellt")
# Migration: tenant_id fuer notifications
cursor = await db.execute("PRAGMA table_info(notifications)")
notif_columns = [row[1] for row in await cursor.fetchall()]

Datei anzeigen

@@ -6,8 +6,6 @@ import httpx
from datetime import datetime, timezone
from config import TIMEZONE, MAX_ARTICLES_PER_DOMAIN_RSS
from source_rules import _extract_domain
from feeds.transcript_extractors._common import html_to_text
from services.post_refresh_qc import normalize_german_umlauts
logger = logging.getLogger("osint.rss")
@@ -154,27 +152,11 @@ class RSSParser:
for entry in feed.entries[:50]:
title = entry.get("title", "")
# RSS-summary ist bei vielen Quellen HTML (Guardian, AP, SZ, ...).
# Vor weiterer Verwendung strippen, sonst landet HTML in DB
# und KI-Agenten und Sprach-Heuristik werden gestoert.
summary_raw = entry.get("summary", "")
summary = html_to_text(summary_raw) if summary_raw else ""
# ASCII-Umlaut-Normalisierung (z.B. dpa-AFX schreibt "Gespraeche").
# Dictionary-basiert, sicher gegen englische Woerter wie "Boeing".
title, _ = normalize_german_umlauts(title)
summary, _ = normalize_german_umlauts(summary)
summary = entry.get("summary", "")
text = f"{title} {summary}".lower()
# Adaptive Match-Schwelle:
# - Bei mindestens einem spezifischen Keyword (>=7 Zeichen) im Text reicht 1 Treffer.
# Verhindert, dass Headlines mit nur einem starken Keyword wie "buckelwal"
# rausfallen, wenn die Lage thematisch eng ist (Bug 1, vom User dokumentiert).
# - Sonst: alte Heuristik (mindestens halb der Wörter, max. 2).
specific_in_text = any(w in text for w in search_words if len(w) >= 7)
if specific_in_text:
min_matches = 1
else:
min_matches = min(2, max(1, (len(search_words) + 1) // 2))
# Flexibles Keyword-Matching: mindestens die Hälfte der Suchworte muss vorkommen (aufgerundet)
min_matches = min(2, max(1, (len(search_words) + 1) // 2))
match_count = sum(1 for word in search_words if word in text)
if match_count >= min_matches:

Datei anzeigen

@@ -124,7 +124,7 @@ async def check_auto_refresh():
# Letzten abgeschlossenen oder laufenden Refresh pruefen
cursor = await db.execute(
"SELECT started_at, status FROM refresh_log WHERE incident_id = ? AND status IN ('completed', 'running', 'cancelled', 'error') ORDER BY id DESC LIMIT 1",
"SELECT started_at, status FROM refresh_log WHERE incident_id = ? AND status IN ('completed', 'running') ORDER BY id DESC LIMIT 1",
(incident_id,),
)
last_refresh = await cursor.fetchone()

Datei anzeigen

@@ -40,25 +40,12 @@ async def require_writable_license(
) -> dict:
"""Dependency die sicherstellt, dass die Lizenz Schreibzugriff erlaubt.
Blockiert neue Lagen/Refreshes bei abgelaufener Lizenz, deaktivierter Org
oder aufgebrauchtem Token-Budget (Hard-Stop).
Blockiert neue Lagen/Refreshes bei abgelaufener Lizenz (Nur-Lesen-Modus).
"""
lic = current_user.get("license", {})
if lic.get("read_only"):
reason = lic.get("read_only_reason") or "expired"
if reason == "budget_exceeded":
detail = "Token-Budget aufgebraucht. Für Aufstockung oder Upgrade bitte info@aegis-sight.de kontaktieren."
elif reason == "expired":
detail = "Lizenz abgelaufen. Nur Lesezugriff moeglich."
elif reason == "no_license":
detail = "Keine aktive Lizenz. Bitte Verwaltung kontaktieren."
elif reason == "org_disabled":
detail = "Organisation deaktiviert. Bitte Support kontaktieren."
else:
detail = lic.get("message") or "Nur Lesezugriff moeglich."
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail=detail,
headers={"X-License-Status": reason},
detail="Lizenz abgelaufen oder widerrufen. Nur Lesezugriff moeglich.",
)
return current_user

Datei anzeigen

@@ -37,8 +37,6 @@ class UserMeResponse(BaseModel):
license_status: str = "unknown"
license_type: str = ""
read_only: bool = False
read_only_reason: Optional[str] = None
unlimited_budget: bool = False
credits_total: Optional[int] = None
credits_remaining: Optional[int] = None
credits_percent_used: Optional[float] = None
@@ -54,7 +52,7 @@ class IncidentCreate(BaseModel):
refresh_interval: int = Field(default=15, ge=10, le=10080)
refresh_start_time: Optional[str] = Field(default=None, pattern=r"^([01]\d|2[0-3]):[0-5]\d$")
retention_days: int = Field(default=0, ge=0, le=999)
international_sources: bool = False
international_sources: bool = True
include_telegram: bool = False
visibility: str = Field(default="public", pattern="^(public|private)$")
@@ -139,31 +137,24 @@ class IncidentListItem(BaseModel):
# Sources (Quellenverwaltung)
SOURCE_TYPE_PATTERN = "^(rss_feed|web_source|excluded|telegram_channel|podcast_feed)$"
SOURCE_CATEGORY_PATTERN = "^(nachrichtenagentur|oeffentlich-rechtlich|qualitaetszeitung|behoerde|fachmedien|think-tank|international|regional|boulevard|sonstige)$"
SOURCE_STATUS_PATTERN = "^(active|inactive)$"
class SourceCreate(BaseModel):
name: str = Field(min_length=1, max_length=200)
url: Optional[str] = None
domain: Optional[str] = None
source_type: str = Field(default="rss_feed", pattern=SOURCE_TYPE_PATTERN)
category: str = Field(default="sonstige", pattern=SOURCE_CATEGORY_PATTERN)
status: str = Field(default="active", pattern=SOURCE_STATUS_PATTERN)
source_type: str = Field(default="rss_feed", pattern="^(rss_feed|web_source|excluded|telegram_channel|podcast_feed)$")
category: str = Field(default="sonstige", pattern="^(nachrichtenagentur|oeffentlich-rechtlich|qualitaetszeitung|behoerde|fachmedien|think-tank|international|regional|boulevard|sonstige)$")
status: str = Field(default="active", pattern="^(active|inactive)$")
notes: Optional[str] = None
language: Optional[str] = None
bias: Optional[str] = None
class SourceUpdate(BaseModel):
name: Optional[str] = Field(default=None, max_length=200)
url: Optional[str] = None
domain: Optional[str] = None
source_type: Optional[str] = Field(default=None, pattern=SOURCE_TYPE_PATTERN)
category: Optional[str] = Field(default=None, pattern=SOURCE_CATEGORY_PATTERN)
status: Optional[str] = Field(default=None, pattern=SOURCE_STATUS_PATTERN)
source_type: Optional[str] = Field(default=None, pattern="^(rss_feed|web_source|excluded|telegram_channel|podcast_feed)$")
category: Optional[str] = Field(default=None, pattern="^(nachrichtenagentur|oeffentlich-rechtlich|qualitaetszeitung|behoerde|fachmedien|think-tank|international|regional|boulevard|sonstige)$")
status: Optional[str] = Field(default=None, pattern="^(active|inactive)$")
notes: Optional[str] = None
language: Optional[str] = None
bias: Optional[str] = None
class SourceResponse(BaseModel):
@@ -181,20 +172,7 @@ class SourceResponse(BaseModel):
created_at: str
language: Optional[str] = None
bias: Optional[str] = None
political_orientation: Optional[str] = None
media_type: Optional[str] = None
reliability: Optional[str] = None
state_affiliated: bool = False
country_code: Optional[str] = None
classification_source: Optional[str] = None
classified_at: Optional[str] = None
alignments: list[str] = []
is_global: bool = False
ifcn_signatory: bool = False
eu_disinfo_listed: bool = False
eu_disinfo_case_count: int = 0
eu_disinfo_last_seen: Optional[str] = None
external_data_synced_at: Optional[str] = None
# Source Discovery

Datei anzeigen

@@ -26,15 +26,10 @@ LOGO_PATH = Path(__file__).parent / "static" / "favicon.svg"
FC_STATUS_LABELS = {
# 1:1 vom Monitor-Frontend (components.js) — konsistent zum UI.
"confirmed": "Bestätigt",
"unconfirmed": "Unbestätigt",
"contradicted": "Widerlegt",
"developing": "Unklar",
"established": "Gesichert",
"disputed": "Umstritten",
"unverified": "Ungeprüft",
"false": "Falsch", # Legacy-Fallback
"confirmed": "Bestätigt",
"unconfirmed": "Unbestätigt",
"disputed": "Umstritten",
"false": "Falsch",
}
@@ -714,7 +709,7 @@ async def generate_pdf(
),
lagebild_timestamp=(incident.get("updated_at") or "")[:16].replace("T", " "),
sources=_prepare_sources(incident)[:30] if scope == "report" else _prepare_sources(incident),
fact_checks=_prepare_fact_checks(fact_checks),
fact_checks=_prepare_fact_checks(fact_checks[:20] if scope == "report" else fact_checks),
source_stats=_prepare_source_stats(articles)[:20] if scope == "report" else _prepare_source_stats(articles),
timeline=_prepare_timeline(articles) if scope == "full" else [],
articles=articles if scope == "full" else [],

Datei anzeigen

@@ -1,13 +1,7 @@
"""Auth-Router: Magic-Link-Login und Nutzerverwaltung."""
import logging
import os
from datetime import datetime, timedelta
from fastapi import APIRouter, Depends, HTTPException, Request, status
def _staging_mode() -> bool:
"""STAGING_MODE Env-Flag (vgl. services.license_service)."""
return os.environ.get("STAGING_MODE", "").lower() in ("1", "true", "yes")
from models import (
MagicLinkRequest,
MagicLinkResponse,
@@ -193,11 +187,10 @@ async def get_me(
from services.license_service import check_license
license_info = await check_license(db, current_user["tenant_id"])
# Credits-Daten laden (echte Prozente, nicht gekappt)
# Credits-Daten laden
credits_total = None
credits_remaining = None
credits_percent_used = None
unlimited_budget = bool(license_info.get("unlimited_budget", False))
if current_user.get("tenant_id"):
lic_cursor = await db.execute(
"SELECT credits_total, credits_used, cost_per_credit FROM licenses WHERE organization_id = ? AND status = 'active' ORDER BY id DESC LIMIT 1",
@@ -207,12 +200,7 @@ async def get_me(
credits_total = lic_row["credits_total"]
credits_used = lic_row["credits_used"] or 0
credits_remaining = max(0, int(credits_total - credits_used))
credits_percent_used = round((credits_used / credits_total) * 100, 1) if credits_total > 0 else 0
# STAGING_MODE: Org-Switcher im Frontend deaktivieren
is_global_admin_response = current_user.get("is_global_admin", False)
if _staging_mode():
is_global_admin_response = False
credits_percent_used = round(min(100, (credits_used / credits_total) * 100), 1) if credits_total > 0 else 0
return UserMeResponse(
id=current_user["id"],
@@ -228,9 +216,7 @@ async def get_me(
license_status=license_info.get("status", "unknown"),
license_type=license_info.get("license_type", ""),
read_only=license_info.get("read_only", False),
read_only_reason=license_info.get("read_only_reason"),
unlimited_budget=unlimited_budget,
is_global_admin=is_global_admin_response,
is_global_admin=current_user.get("is_global_admin", False),
)

Datei anzeigen

@@ -1165,18 +1165,8 @@ async def export_incident(
)
snapshots = [dict(r) for r in await cursor.fetchall()]
# Zusammenfassung fuer den Export:
# - Bei Adhoc-Lagen primaer "Neueste Entwicklungen" (latest_developments) als Markdown-Bullets,
# weil Live-Monitoring von Aktualitaet lebt.
# - Fallback (oder bei Research): Executive Summary (KI-generiert, gecacht).
is_adhoc = (incident.get("type") or "adhoc") != "research"
latest_dev = (incident.get("latest_developments") or "").strip()
exec_summary = None
if is_adhoc and latest_dev:
from report_generator import _markdown_to_html as _md_to_html
exec_summary = _md_to_html(latest_dev)
if not exec_summary:
exec_summary = incident.get("executive_summary")
# Executive Summary (KI-generiert, gecacht)
exec_summary = incident.get("executive_summary")
if not exec_summary:
summary_text = incident.get("summary") or ""
exec_summary = await generate_executive_summary(summary_text)

Datei anzeigen

@@ -1,5 +1,4 @@
"""Sources-Router: Quellenverwaltung (Multi-Tenant). Klassifikation: Read-Only — Pflege in der Verwaltung."""
import json
"""Sources-Router: Quellenverwaltung (Multi-Tenant)."""
import logging
from collections import defaultdict
from fastapi import APIRouter, Depends, HTTPException, status
@@ -13,25 +12,7 @@ logger = logging.getLogger("osint.sources")
router = APIRouter(prefix="/api/sources", tags=["sources"])
SOURCE_UPDATE_COLUMNS = {
"name", "url", "domain", "source_type", "category", "status", "notes",
"language", "bias",
}
async def _load_alignments_for(db: aiosqlite.Connection, source_ids: list[int]) -> dict[int, list[str]]:
"""Lädt alignments fuer mehrere Quellen — Read-Only fuer Anzeige (Pflege in Verwaltung)."""
if not source_ids:
return {}
placeholders = ",".join("?" for _ in source_ids)
cursor = await db.execute(
f"SELECT source_id, alignment FROM source_alignments WHERE source_id IN ({placeholders}) ORDER BY alignment",
source_ids,
)
out: dict[int, list[str]] = {sid: [] for sid in source_ids}
for row in await cursor.fetchall():
out.setdefault(row["source_id"], []).append(row["alignment"])
return out
SOURCE_UPDATE_COLUMNS = {"name", "url", "domain", "source_type", "category", "status", "notes"}
def _check_source_ownership(source: dict, username: str):
@@ -53,13 +34,6 @@ async def list_sources(
source_type: str = None,
category: str = None,
source_status: str = None,
political_orientation: str = None,
media_type: str = None,
reliability: str = None,
state_affiliated: bool = None,
alignment: str = None,
ifcn_signatory: bool = None,
eu_disinfo_listed: bool = None,
current_user: dict = Depends(get_current_user),
db: aiosqlite.Connection = Depends(db_dependency),
):
@@ -67,51 +41,27 @@ async def list_sources(
tenant_id = current_user.get("tenant_id")
# Global (tenant_id=NULL) + eigene Org
query = "SELECT s.* FROM sources s WHERE (s.tenant_id IS NULL OR s.tenant_id = ?)"
params: list = [tenant_id]
query = "SELECT * FROM sources WHERE (tenant_id IS NULL OR tenant_id = ?)"
params = [tenant_id]
if source_type:
query += " AND s.source_type = ?"
query += " AND source_type = ?"
params.append(source_type)
if category:
query += " AND s.category = ?"
query += " AND category = ?"
params.append(category)
if source_status:
query += " AND s.status = ?"
query += " AND status = ?"
params.append(source_status)
if political_orientation:
query += " AND s.political_orientation = ?"
params.append(political_orientation)
if media_type:
query += " AND s.media_type = ?"
params.append(media_type)
if reliability:
query += " AND s.reliability = ?"
params.append(reliability)
if state_affiliated is not None:
query += " AND s.state_affiliated = ?"
params.append(1 if state_affiliated else 0)
if alignment:
query += " AND EXISTS (SELECT 1 FROM source_alignments sa WHERE sa.source_id = s.id AND sa.alignment = ?)"
params.append(alignment.lower())
if ifcn_signatory is not None:
query += " AND s.ifcn_signatory = ?"
params.append(1 if ifcn_signatory else 0)
if eu_disinfo_listed is not None:
query += " AND s.eu_disinfo_listed = ?"
params.append(1 if eu_disinfo_listed else 0)
query += " ORDER BY s.source_type, s.category, s.name"
query += " ORDER BY source_type, category, name"
cursor = await db.execute(query, params)
rows = await cursor.fetchall()
results = [dict(row) for row in rows]
alignments_map = await _load_alignments_for(db, [r["id"] for r in results])
for d in results:
results = []
for row in rows:
d = dict(row)
d["is_global"] = d.get("tenant_id") is None
d["state_affiliated"] = bool(d.get("state_affiliated"))
d["ifcn_signatory"] = bool(d.get("ifcn_signatory"))
d["eu_disinfo_listed"] = bool(d.get("eu_disinfo_listed"))
d["alignments"] = alignments_map.get(d["id"], [])
results.append(d)
return results
@@ -504,40 +454,26 @@ async def create_source(
detail=f"Domain '{domain}' bereits als Quelle vorhanden: {domain_existing['name']}. Für einen neuen RSS-Feed bitte die Feed-URL angeben.",
)
payload = data.model_dump(exclude_unset=True)
cols = ["name", "url", "domain", "source_type", "category", "status", "notes",
"language", "bias", "added_by", "tenant_id"]
vals = [
data.name,
data.url,
domain,
data.source_type,
data.category,
data.status,
data.notes,
payload.get("language"),
payload.get("bias"),
current_user["username"],
tenant_id,
]
placeholders = ", ".join(["?"] * len(vals))
cursor = await db.execute(
f"INSERT INTO sources ({', '.join(cols)}) VALUES ({placeholders})",
vals,
"""INSERT INTO sources (name, url, domain, source_type, category, status, notes, added_by, tenant_id)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)""",
(
data.name,
data.url,
domain,
data.source_type,
data.category,
data.status,
data.notes,
current_user["username"],
tenant_id,
),
)
new_id = cursor.lastrowid
await db.commit()
cursor = await db.execute("SELECT * FROM sources WHERE id = ?", (new_id,))
cursor = await db.execute("SELECT * FROM sources WHERE id = ?", (cursor.lastrowid,))
row = await cursor.fetchone()
result = dict(row)
result["is_global"] = result.get("tenant_id") is None
result["state_affiliated"] = bool(result.get("state_affiliated"))
alignments_map = await _load_alignments_for(db, [new_id])
result["alignments"] = alignments_map.get(new_id, [])
return result
return dict(row)
@router.put("/{source_id}", response_model=SourceResponse)
@@ -558,30 +494,27 @@ async def update_source(
_check_source_ownership(dict(row), current_user["username"])
payload = data.model_dump(exclude_unset=True)
updates = {}
for field, value in payload.items():
for field, value in data.model_dump(exclude_none=True).items():
if field not in SOURCE_UPDATE_COLUMNS:
continue
# Domain normalisieren
if field == "domain" and value:
value = _DOMAIN_ALIASES.get(value.lower(), value.lower())
updates[field] = value
if updates:
set_clause = ", ".join(f"{k} = ?" for k in updates)
values = list(updates.values()) + [source_id]
await db.execute(f"UPDATE sources SET {set_clause} WHERE id = ?", values)
await db.commit()
if not updates:
return dict(row)
set_clause = ", ".join(f"{k} = ?" for k in updates)
values = list(updates.values()) + [source_id]
await db.execute(f"UPDATE sources SET {set_clause} WHERE id = ?", values)
await db.commit()
cursor = await db.execute("SELECT * FROM sources WHERE id = ?", (source_id,))
row = await cursor.fetchone()
result = dict(row)
result["is_global"] = result.get("tenant_id") is None
result["state_affiliated"] = bool(result.get("state_affiliated"))
alignments_map = await _load_alignments_for(db, [source_id])
result["alignments"] = alignments_map.get(source_id, [])
return result
return dict(row)
@router.delete("/{source_id}", status_code=status.HTTP_204_NO_CONTENT)
@@ -639,4 +572,3 @@ async def trigger_refresh_counts(
"""Artikelzaehler fuer alle Quellen neu berechnen."""
await refresh_source_counts(db)
return {"status": "ok"}

Datei anzeigen

@@ -1,6 +1,5 @@
"""Lizenz-Verwaltung und -Pruefung."""
import logging
import os
from datetime import datetime
from config import TIMEZONE
import aiosqlite
@@ -8,21 +7,11 @@ import aiosqlite
logger = logging.getLogger("osint.license")
def _staging_mode() -> bool:
"""Staging-Mode aktiv? Wenn ja, gilt: immer unlimited Budget, kein Hard-Stop.
Wird ueber ENV-Variable STAGING_MODE=1 (oder true) aktiviert.
Nur in Staging-.env gesetzt; Live-.env hat das Flag nicht.
"""
return os.environ.get("STAGING_MODE", "").lower() in ("1", "true", "yes")
async def check_license(db: aiosqlite.Connection, organization_id: int) -> dict:
"""Prueft den Lizenzstatus einer Organisation.
Returns:
dict mit: valid, status, license_type, max_users, current_users, read_only,
read_only_reason, message, unlimited_budget, credits_total, credits_used
dict mit: valid, status, license_type, max_users, current_users, read_only, message
"""
# Organisation pruefen
cursor = await db.execute(
@@ -31,14 +20,10 @@ async def check_license(db: aiosqlite.Connection, organization_id: int) -> dict:
)
org = await cursor.fetchone()
if not org:
return {"valid": False, "status": "not_found", "read_only": True,
"read_only_reason": "not_found",
"message": "Organisation nicht gefunden"}
return {"valid": False, "status": "not_found", "read_only": True, "message": "Organisation nicht gefunden"}
if not org["is_active"]:
return {"valid": False, "status": "org_disabled", "read_only": True,
"read_only_reason": "org_disabled",
"message": "Organisation deaktiviert"}
return {"valid": False, "status": "org_disabled", "read_only": True, "message": "Organisation deaktiviert"}
# Aktive Lizenz suchen
cursor = await db.execute(
@@ -50,19 +35,7 @@ async def check_license(db: aiosqlite.Connection, organization_id: int) -> dict:
license_row = await cursor.fetchone()
if not license_row:
return {"valid": False, "status": "no_license", "read_only": True,
"read_only_reason": "no_license",
"message": "Keine aktive Lizenz"}
# Felder zur weiteren Verwendung extrahieren
lic_dict = dict(license_row)
unlimited_budget = bool(lic_dict.get("unlimited_budget"))
credits_total = lic_dict.get("credits_total")
credits_used = lic_dict.get("credits_used") or 0
# STAGING_MODE: kein Token-Budget-Hard-Stop, immer unlimited
if _staging_mode():
unlimited_budget = True
return {"valid": False, "status": "no_license", "read_only": True, "message": "Keine aktive Lizenz"}
# Ablauf pruefen
now = datetime.now(TIMEZONE)
@@ -79,21 +52,11 @@ async def check_license(db: aiosqlite.Connection, organization_id: int) -> dict:
"status": "expired",
"license_type": license_row["license_type"],
"read_only": True,
"read_only_reason": "expired",
"message": "Lizenz abgelaufen",
"unlimited_budget": unlimited_budget,
"credits_total": credits_total,
"credits_used": credits_used,
}
except (ValueError, TypeError):
pass
# Budget-Check (Hard-Stop bei aufgebrauchten Credits, ausser unlimited)
budget_exceeded = False
if not unlimited_budget and credits_total and credits_total > 0:
if credits_used >= credits_total:
budget_exceeded = True
# Nutzerzahl pruefen
cursor = await db.execute(
"SELECT COUNT(*) as cnt FROM users WHERE organization_id = ? AND is_active = 1",
@@ -101,21 +64,6 @@ async def check_license(db: aiosqlite.Connection, organization_id: int) -> dict:
)
current_users = (await cursor.fetchone())["cnt"]
if budget_exceeded:
return {
"valid": True, # Lizenz ist gueltig, aber Budget aufgebraucht -> read-only
"status": "budget_exceeded",
"license_type": license_row["license_type"],
"max_users": license_row["max_users"],
"current_users": current_users,
"read_only": True,
"read_only_reason": "budget_exceeded",
"message": "Token-Budget aufgebraucht",
"unlimited_budget": False,
"credits_total": credits_total,
"credits_used": credits_used,
}
return {
"valid": True,
"status": license_row["status"],
@@ -123,11 +71,7 @@ async def check_license(db: aiosqlite.Connection, organization_id: int) -> dict:
"max_users": license_row["max_users"],
"current_users": current_users,
"read_only": False,
"read_only_reason": None,
"message": "Lizenz aktiv",
"unlimited_budget": unlimited_budget,
"credits_total": credits_total,
"credits_used": credits_used,
}

Datei anzeigen

@@ -50,18 +50,18 @@ PIPELINE_STEPS = [
"icon": "map-pin",
"tooltip": "Aus den Meldungen werden Ortsangaben erkannt und auf der Karte verortet.",
},
{
"key": "factcheck",
"label": "Fakten prüfen",
"icon": "shield",
"tooltip": "Behauptungen aus den Meldungen werden gegeneinander abgeglichen: Bestätigt? Umstritten? Noch unklar?",
},
{
"key": "summary",
"label": "Lagebild verfassen",
"icon": "file-text",
"tooltip": "Aus allen geprüften Meldungen wird ein zusammenhängendes Lagebild geschrieben, mit Quellenangaben am Text.",
},
{
"key": "factcheck",
"label": "Fakten prüfen",
"icon": "shield",
"tooltip": "Behauptungen aus den Meldungen werden gegeneinander abgeglichen: Bestätigt? Umstritten? Noch unklar?",
},
{
"key": "qc",
"label": "Qualitätscheck",
@@ -228,25 +228,3 @@ async def error_step(db, ws_manager, *, step_id: Optional[int], refresh_log_id:
"status": "error",
"pass_number": pass_number,
}, visibility, created_by, tenant_id)
async def cancel_active_steps(db, *, refresh_log_id: int) -> int:
"""Schliesst alle noch aktiven Pipeline-Schritte eines Refreshs als 'cancelled' ab.
Wird vom Orchestrator nach einem User-Cancel aufgerufen. Ohne diesen Schritt
bleibt der zuletzt aktive Step-Eintrag verwaist und der Pipeline-Endpoint
liefert dauerhaft 'Schritt X laeuft' an die UI.
"""
try:
cur = await db.execute(
"""UPDATE refresh_pipeline_steps
SET status = 'cancelled', completed_at = ?
WHERE refresh_log_id = ? AND status = 'active'""",
(_now_db(), refresh_log_id),
)
await db.commit()
return cur.rowcount or 0
except Exception as e:
logger.warning(f"Pipeline cancel_active_steps DB-Fehler: {e}")
return 0

Datei anzeigen

@@ -400,20 +400,18 @@ async def run_post_refresh_qc(db, incident_id: int) -> dict:
db, incident_id, incident_title, incident_desc
)
umlauts_fixed = await normalize_umlaut_fields(db, incident_id)
article_umlauts_fixed = await normalize_umlaut_articles(db, incident_id)
total_umlaut_changes = umlauts_fixed + article_umlauts_fixed
if facts_removed > 0 or locations_fixed > 0 or total_umlaut_changes > 0:
if facts_removed > 0 or locations_fixed > 0 or umlauts_fixed > 0:
await db.commit()
logger.info(
"Post-Refresh QC fuer Incident %d: %d Duplikate entfernt, %d Locations korrigiert, %d Umlaute normalisiert (davon %d in Articles)",
incident_id, facts_removed, locations_fixed, total_umlaut_changes, article_umlauts_fixed,
"Post-Refresh QC fuer Incident %d: %d Duplikate entfernt, %d Locations korrigiert, %d Umlaute normalisiert",
incident_id, facts_removed, locations_fixed, umlauts_fixed,
)
return {
"facts_removed": facts_removed,
"locations_fixed": locations_fixed,
"umlauts_fixed": total_umlaut_changes,
"umlauts_fixed": umlauts_fixed,
}
except Exception as e:
@@ -570,64 +568,3 @@ async def normalize_umlaut_fields(db, incident_id: int) -> int:
incident_id, count_summary, count_dev,
)
return total
async def normalize_umlaut_articles(db, incident_id: int) -> int:
"""Normalisiert Umlaute in allen Artikel-Texten des Incidents.
Felder die behandelt werden:
- headline_de und content_de bei allen Artikeln (LLM-Uebersetzung kann
ASCII-Umlaute liefern trotz Prompt-Anweisung)
- headline und content_original bei language='de' (manche Quellen wie
dpa-AFX, Telegram-Kanaele liefern selbst schon ASCII-Umlaute)
Idempotent: Wenn der Text schon korrekt ist, macht das Dict-Lookup
keine Aenderung und wir schreiben nicht zurueck.
Rueckgabe: Gesamtzahl der Wort-Ersetzungen ueber alle Artikel.
"""
cursor = await db.execute(
"""SELECT id, language, headline, headline_de, content_original, content_de
FROM articles WHERE incident_id = ?""",
(incident_id,),
)
rows = await cursor.fetchall()
if not rows:
return 0
total = 0
for row in rows:
is_de = (row["language"] or "").lower() == "de"
updates = {}
# Felder die immer behandelt werden (LLM-Uebersetzungen)
if row["headline_de"]:
new, n = normalize_german_umlauts(row["headline_de"])
if n > 0:
updates["headline_de"] = new
total += n
if row["content_de"]:
new, n = normalize_german_umlauts(row["content_de"])
if n > 0:
updates["content_de"] = new
total += n
# Originalfelder nur bei deutschen Quellen
if is_de:
if row["headline"]:
new, n = normalize_german_umlauts(row["headline"])
if n > 0:
updates["headline"] = new
total += n
if row["content_original"]:
new, n = normalize_german_umlauts(row["content_original"])
if n > 0:
updates["content_original"] = new
total += n
if updates:
set_clause = ", ".join(f"{k} = ?" for k in updates)
values = list(updates.values()) + [row["id"]]
await db.execute(f"UPDATE articles SET {set_clause} WHERE id = ?", values)
return total

Datei anzeigen

@@ -1,361 +1,282 @@
"""Quellen-Health-Check Engine - prüft Erreichbarkeit, Feed-Validität, Duplikate."""
import asyncio
import logging
import json
import uuid
from urllib.parse import urlparse
import httpx
import feedparser
import aiosqlite
try:
from config import HEALTH_CHECK_USER_AGENT, HEALTH_CHECK_TIMEOUT_S
except ImportError:
HEALTH_CHECK_USER_AGENT = "Mozilla/5.0 (compatible; AegisSight-HealthCheck/1.0)"
HEALTH_CHECK_TIMEOUT_S = 15.0
# Phase 18: alternative User-Agents fuer Bot-Block-Bypass
USER_AGENT_GOOGLEBOT = "Mozilla/5.0 (compatible; Googlebot/2.1; +http://www.google.com/bot.html)"
USER_AGENT_BROWSER = (
"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 "
"(KHTML, like Gecko) Chrome/120.0 Safari/537.36"
)
REMOVEPAYWALLS_PREFIX = "https://www.removepaywall.com/search?url="
# HTTP-Codes, die einen Retry mit anderem UA rechtfertigen
RETRY_ON_STATUS = {403, 406, 429}
logger = logging.getLogger("osint.source_health")
async def run_health_checks(db: aiosqlite.Connection) -> dict:
"""Führt Health-Checks für alle aktiven Quellen durch (global + Tenant)."""
logger.info("Starte Quellen-Health-Check...")
# Alle aktiven Quellen laden (global UND Tenant-spezifisch)
cursor = await db.execute(
"SELECT id, name, url, domain, source_type, article_count, last_seen_at, "
"COALESCE(fetch_strategy, 'default') AS fetch_strategy "
"FROM sources WHERE status = 'active' "
)
sources = [dict(row) for row in await cursor.fetchall()]
# Bisherigen Stand in History archivieren, dann frisch starten
run_id = uuid.uuid4().hex[:12]
await db.execute(
"INSERT INTO source_health_history "
"(run_id, source_id, check_type, status, message, details, checked_at) "
"SELECT ?, source_id, check_type, status, message, details, checked_at "
"FROM source_health_checks",
(run_id,),
)
await db.execute("DELETE FROM source_health_checks")
await db.commit()
logger.info(f"Health-Check Run {run_id}: vorigen Stand archiviert")
checks_done = 0
issues_found = 0
# 1. Erreichbarkeit + Feed-Validität (nur Quellen mit URL)
sources_with_url = [s for s in sources if s["url"]]
async with httpx.AsyncClient(
timeout=HEALTH_CHECK_TIMEOUT_S,
follow_redirects=True,
headers={"User-Agent": HEALTH_CHECK_USER_AGENT},
) as client:
for i in range(0, len(sources_with_url), 5):
batch = sources_with_url[i:i + 5]
tasks = [_check_source_reachability(client, s) for s in batch]
results = await asyncio.gather(*tasks, return_exceptions=True)
for source, result in zip(batch, results):
if isinstance(result, Exception):
await _save_check(
db, source["id"], "reachability", "error",
f"Prüfung fehlgeschlagen: {result}",
)
issues_found += 1
else:
for check in result:
await _save_check(
db, source["id"], check["type"], check["status"],
check["message"], check.get("details"),
)
if check["status"] != "ok":
issues_found += 1
checks_done += 1
# 2. Veraltete Quellen (kein Artikel seit >30 Tagen)
for source in sources:
if source["source_type"] in ("excluded", "web_source"):
continue
stale_check = _check_stale(source)
if stale_check:
await _save_check(
db, source["id"], stale_check["type"],
stale_check["status"], stale_check["message"],
)
if stale_check["status"] != "ok":
issues_found += 1
# 3. Duplikate erkennen
duplicates = _find_duplicates(sources)
for dup in duplicates:
await _save_check(
db, dup["source_id"], "duplicate", "warning",
dup["message"], json.dumps(dup.get("details", {})),
)
issues_found += 1
await db.commit()
logger.info(
f"Health-Check abgeschlossen: {checks_done} Quellen geprüft, "
f"{issues_found} Probleme gefunden"
)
return {"checked": checks_done, "issues": issues_found}
async def _check_source_reachability(
client: httpx.AsyncClient, source: dict,
) -> list[dict]:
"""Prüft Erreichbarkeit und Feed-Validität einer Quelle.
Phase 18: pro Quelle eine fetch_strategy ('default' | 'googlebot' | 'paywall' | 'skip').
Bei 'default' wird im Fehlerfall (403/406/429) ein Retry mit Googlebot-UA gemacht.
Bei 'paywall' wird auf removepaywall.com umgeleitet.
Bei 'skip' wird kein Check ausgeführt.
"""
checks = []
url = source["url"]
strategy = source.get("fetch_strategy") or "default"
# 'skip' -> kein Check (bekannte unerreichbare Quellen, z.B. Login-only)
if strategy == "skip":
checks.append({
"type": "reachability", "status": "ok",
"message": "Health-Check uebersprungen (fetch_strategy=skip)",
})
return checks
# URL-Schema sicherstellen
if url and not url.startswith(("http://", "https://")):
url = "https://" + url.lstrip("/")
# Initialen UA waehlen
initial_ua = HEALTH_CHECK_USER_AGENT
initial_url = url
if strategy == "googlebot":
initial_ua = USER_AGENT_GOOGLEBOT
elif strategy == "paywall":
# Paywall-Quellen: Feed-URL direkt laden, aber mit Browser-UA (versucht Bot-Detection zu umgehen).
# removepaywall.com ist fuer Article-URLs, NICHT fuer RSS-Feed-Validity-Checks
# (gibt HTML statt XML zurueck). Researcher-Pipeline nutzt removepaywall fuer Inhalte.
initial_ua = USER_AGENT_BROWSER
try:
resp = await client.get(initial_url, headers={"User-Agent": initial_ua})
# Paywall-Quellen: 4xx ist erwartbar (Bot-Detection), als warning markieren statt error
if strategy == "paywall" and resp.status_code in RETRY_ON_STATUS:
checks.append({
"type": "reachability", "status": "warning",
"message": f"Paywall-Quelle, Direkt-Zugang HTTP {resp.status_code} (Researcher-Pipeline nutzt removepaywall.com fuer Inhalte)",
})
return checks # Feed-Validity-Check skippen (Paywall liefert kein RSS)
# Bot-Block-Retry nur bei strategy='default'
if (
strategy == "default"
and resp.status_code in RETRY_ON_STATUS
):
retry = await client.get(url, headers={"User-Agent": USER_AGENT_GOOGLEBOT})
if retry.status_code < 400:
resp = retry # Retry hat geholfen
checks.append({
"type": "reachability", "status": "warning",
"message": f"Erreichbar nur mit Googlebot-UA (Standard-UA bekam HTTP {initial_url and 'unknown' or 'XXX'})",
})
if resp.status_code >= 400:
checks.append({
"type": "reachability",
"status": "error",
"message": f"HTTP {resp.status_code} - nicht erreichbar",
"details": json.dumps({"status_code": resp.status_code, "url": url}),
})
return checks
if resp.status_code >= 300:
checks.append({
"type": "reachability",
"status": "warning",
"message": f"HTTP {resp.status_code} - Weiterleitung",
"details": json.dumps({
"status_code": resp.status_code,
"final_url": str(resp.url),
}),
})
else:
checks.append({
"type": "reachability",
"status": "ok",
"message": "Erreichbar",
})
# Feed-Validität nur für RSS-Feeds
if source["source_type"] == "rss_feed":
text = resp.text[:20000]
if "<rss" not in text and "<feed" not in text and "<channel" not in text:
checks.append({
"type": "feed_validity",
"status": "error",
"message": "Kein gültiger RSS/Atom-Feed",
})
else:
feed = await asyncio.to_thread(feedparser.parse, text)
if feed.get("bozo") and not feed.entries:
checks.append({
"type": "feed_validity",
"status": "error",
"message": "Feed fehlerhaft (bozo)",
"details": json.dumps({
"bozo_exception": str(feed.get("bozo_exception", "")),
}),
})
elif not feed.entries:
checks.append({
"type": "feed_validity",
"status": "warning",
"message": "Feed erreichbar aber leer",
})
else:
checks.append({
"type": "feed_validity",
"status": "ok",
"message": f"Feed gültig ({len(feed.entries)} Einträge)",
})
except httpx.TimeoutException:
checks.append({
"type": "reachability",
"status": "error",
"message": "Timeout (15s)",
})
except httpx.ConnectError as e:
checks.append({
"type": "reachability",
"status": "error",
"message": f"Verbindung fehlgeschlagen: {e}",
})
except Exception as e:
checks.append({
"type": "reachability",
"status": "error",
"message": f"{type(e).__name__}: {e}",
})
return checks
def _check_stale(source: dict) -> dict | None:
"""Prüft ob eine Quelle veraltet ist (keine Artikel seit >30 Tagen)."""
if source["source_type"] == "excluded":
return None
article_count = source.get("article_count") or 0
last_seen = source.get("last_seen_at")
if article_count == 0:
return {
"type": "stale",
"status": "warning",
"message": "Noch nie Artikel geliefert",
}
if last_seen:
try:
from datetime import datetime
last_dt = datetime.fromisoformat(last_seen)
now = datetime.now()
age_days = (now - last_dt).days
if age_days > 30:
return {
"type": "stale",
"status": "warning",
"message": f"Letzter Artikel vor {age_days} Tagen",
}
except (ValueError, TypeError):
pass
return None
def _find_duplicates(sources: list[dict]) -> list[dict]:
"""Findet doppelte Quellen (gleiche URL)."""
duplicates = []
url_map = {}
for s in sources:
if not s["url"]:
continue
url_norm = s["url"].lower().rstrip("/")
if url_norm in url_map:
existing = url_map[url_norm]
duplicates.append({
"source_id": s["id"],
"message": f"Doppelte URL wie '{existing['name']}' (ID {existing['id']})",
"details": {"duplicate_of": existing["id"], "type": "url"},
})
else:
url_map[url_norm] = s
return duplicates
async def _save_check(
db: aiosqlite.Connection, source_id: int, check_type: str,
status: str, message: str, details: str = None,
):
"""Speichert ein Health-Check-Ergebnis."""
await db.execute(
"INSERT INTO source_health_checks "
"(source_id, check_type, status, message, details) "
"VALUES (?, ?, ?, ?, ?)",
(source_id, check_type, status, message, details),
)
async def get_health_summary(db: aiosqlite.Connection) -> dict:
"""Gibt eine Zusammenfassung der letzten Health-Check-Ergebnisse zurück."""
cursor = await db.execute("""
SELECT
h.id, h.source_id, s.name, s.domain, s.url, s.source_type,
h.check_type, h.status, h.message, h.details, h.checked_at
FROM source_health_checks h
JOIN sources s ON s.id = h.source_id
ORDER BY
CASE h.status WHEN 'error' THEN 0 WHEN 'warning' THEN 1 ELSE 2 END,
s.name
""")
checks = [dict(row) for row in await cursor.fetchall()]
error_count = sum(1 for c in checks if c["status"] == "error")
warning_count = sum(1 for c in checks if c["status"] == "warning")
ok_count = sum(1 for c in checks if c["status"] == "ok")
cursor = await db.execute(
"SELECT MAX(checked_at) as last_check FROM source_health_checks"
)
row = await cursor.fetchone()
last_check = row["last_check"] if row else None
return {
"last_check": last_check,
"total_checks": len(checks),
"errors": error_count,
"warnings": warning_count,
"ok": ok_count,
"checks": checks,
}
"""Quellen-Health-Check Engine - prüft Erreichbarkeit, Feed-Validität, Duplikate."""
import asyncio
import logging
import json
from urllib.parse import urlparse
import httpx
import feedparser
import aiosqlite
logger = logging.getLogger("osint.source_health")
async def run_health_checks(db: aiosqlite.Connection) -> dict:
"""Führt alle Health-Checks für aktive Grundquellen durch."""
logger.info("Starte Quellen-Health-Check...")
# Alle aktiven Grundquellen laden
cursor = await db.execute(
"SELECT id, name, url, domain, source_type, article_count, last_seen_at "
"FROM sources WHERE status = 'active' AND tenant_id IS NULL"
)
sources = [dict(row) for row in await cursor.fetchall()]
# Aktuelle Health-Check-Ergebnisse löschen (werden neu geschrieben)
await db.execute("DELETE FROM source_health_checks")
await db.commit()
checks_done = 0
issues_found = 0
# 1. Erreichbarkeit + Feed-Validität (nur Quellen mit URL)
sources_with_url = [s for s in sources if s["url"]]
async with httpx.AsyncClient(
timeout=15.0,
follow_redirects=True,
headers={"User-Agent": "Mozilla/5.0 (compatible; OSINT-Monitor/1.0)"},
) as client:
for i in range(0, len(sources_with_url), 5):
batch = sources_with_url[i:i + 5]
tasks = [_check_source_reachability(client, s) for s in batch]
results = await asyncio.gather(*tasks, return_exceptions=True)
for source, result in zip(batch, results):
if isinstance(result, Exception):
await _save_check(
db, source["id"], "reachability", "error",
f"Prüfung fehlgeschlagen: {result}",
)
issues_found += 1
else:
for check in result:
await _save_check(
db, source["id"], check["type"], check["status"],
check["message"], check.get("details"),
)
if check["status"] != "ok":
issues_found += 1
checks_done += 1
# 2. Veraltete Quellen (kein Artikel seit >30 Tagen)
for source in sources:
if source["source_type"] in ("excluded", "web_source"):
continue
stale_check = _check_stale(source)
if stale_check:
await _save_check(
db, source["id"], stale_check["type"],
stale_check["status"], stale_check["message"],
)
if stale_check["status"] != "ok":
issues_found += 1
# 3. Duplikate erkennen
duplicates = _find_duplicates(sources)
for dup in duplicates:
await _save_check(
db, dup["source_id"], "duplicate", "warning",
dup["message"], json.dumps(dup.get("details", {})),
)
issues_found += 1
await db.commit()
logger.info(
f"Health-Check abgeschlossen: {checks_done} Quellen geprüft, "
f"{issues_found} Probleme gefunden"
)
return {"checked": checks_done, "issues": issues_found}
async def _check_source_reachability(
client: httpx.AsyncClient, source: dict,
) -> list[dict]:
"""Prüft Erreichbarkeit und Feed-Validität einer Quelle."""
checks = []
url = source["url"]
try:
resp = await client.get(url)
if resp.status_code >= 400:
checks.append({
"type": "reachability",
"status": "error",
"message": f"HTTP {resp.status_code} - nicht erreichbar",
"details": json.dumps({"status_code": resp.status_code, "url": url}),
})
return checks
if resp.status_code >= 300:
checks.append({
"type": "reachability",
"status": "warning",
"message": f"HTTP {resp.status_code} - Weiterleitung",
"details": json.dumps({
"status_code": resp.status_code,
"final_url": str(resp.url),
}),
})
else:
checks.append({
"type": "reachability",
"status": "ok",
"message": "Erreichbar",
})
# Feed-Validität nur für RSS-Feeds
if source["source_type"] == "rss_feed":
text = resp.text[:20000]
if "<rss" not in text and "<feed" not in text and "<channel" not in text:
checks.append({
"type": "feed_validity",
"status": "error",
"message": "Kein gültiger RSS/Atom-Feed",
})
else:
feed = await asyncio.to_thread(feedparser.parse, text)
if feed.get("bozo") and not feed.entries:
checks.append({
"type": "feed_validity",
"status": "error",
"message": "Feed fehlerhaft (bozo)",
"details": json.dumps({
"bozo_exception": str(feed.get("bozo_exception", "")),
}),
})
elif not feed.entries:
checks.append({
"type": "feed_validity",
"status": "warning",
"message": "Feed erreichbar aber leer",
})
else:
checks.append({
"type": "feed_validity",
"status": "ok",
"message": f"Feed gültig ({len(feed.entries)} Einträge)",
})
except httpx.TimeoutException:
checks.append({
"type": "reachability",
"status": "error",
"message": "Timeout (15s)",
})
except httpx.ConnectError as e:
checks.append({
"type": "reachability",
"status": "error",
"message": f"Verbindung fehlgeschlagen: {e}",
})
except Exception as e:
checks.append({
"type": "reachability",
"status": "error",
"message": f"{type(e).__name__}: {e}",
})
return checks
def _check_stale(source: dict) -> dict | None:
"""Prüft ob eine Quelle veraltet ist (keine Artikel seit >30 Tagen)."""
if source["source_type"] == "excluded":
return None
article_count = source.get("article_count") or 0
last_seen = source.get("last_seen_at")
if article_count == 0:
return {
"type": "stale",
"status": "warning",
"message": "Noch nie Artikel geliefert",
}
if last_seen:
try:
from datetime import datetime
last_dt = datetime.fromisoformat(last_seen)
now = datetime.now()
age_days = (now - last_dt).days
if age_days > 30:
return {
"type": "stale",
"status": "warning",
"message": f"Letzter Artikel vor {age_days} Tagen",
}
except (ValueError, TypeError):
pass
return None
def _find_duplicates(sources: list[dict]) -> list[dict]:
"""Findet doppelte Quellen (gleiche URL)."""
duplicates = []
url_map = {}
for s in sources:
if not s["url"]:
continue
url_norm = s["url"].lower().rstrip("/")
if url_norm in url_map:
existing = url_map[url_norm]
duplicates.append({
"source_id": s["id"],
"message": f"Doppelte URL wie '{existing['name']}' (ID {existing['id']})",
"details": {"duplicate_of": existing["id"], "type": "url"},
})
else:
url_map[url_norm] = s
return duplicates
async def _save_check(
db: aiosqlite.Connection, source_id: int, check_type: str,
status: str, message: str, details: str = None,
):
"""Speichert ein Health-Check-Ergebnis."""
await db.execute(
"INSERT INTO source_health_checks "
"(source_id, check_type, status, message, details) "
"VALUES (?, ?, ?, ?, ?)",
(source_id, check_type, status, message, details),
)
async def get_health_summary(db: aiosqlite.Connection) -> dict:
"""Gibt eine Zusammenfassung der letzten Health-Check-Ergebnisse zurück."""
cursor = await db.execute("""
SELECT
h.id, h.source_id, s.name, s.domain, s.url, s.source_type,
h.check_type, h.status, h.message, h.details, h.checked_at
FROM source_health_checks h
JOIN sources s ON s.id = h.source_id
ORDER BY
CASE h.status WHEN 'error' THEN 0 WHEN 'warning' THEN 1 ELSE 2 END,
s.name
""")
checks = [dict(row) for row in await cursor.fetchall()]
error_count = sum(1 for c in checks if c["status"] == "error")
warning_count = sum(1 for c in checks if c["status"] == "warning")
ok_count = sum(1 for c in checks if c["status"] == "ok")
cursor = await db.execute(
"SELECT MAX(checked_at) as last_check FROM source_health_checks"
)
row = await cursor.fetchone()
last_check = row["last_check"] if row else None
return {
"last_check": last_check,
"total_checks": len(checks),
"errors": error_count,
"warnings": warning_count,
"ok": ok_count,
"checks": checks,
}

Datei anzeigen

@@ -1,4 +1,4 @@
"""KI-gestützte Quellen-Vorschläge via Haiku + deterministische Karteileichen-Heuristik."""
"""KI-gestützte Quellen-Vorschläge via Haiku."""
import json
import logging
import re
@@ -10,193 +10,10 @@ from config import CLAUDE_MODEL_FAST
logger = logging.getLogger("osint.source_suggester")
# Schwelle für "stumm seit": eine Quelle, die seit mehr als so vielen Tagen
# keinen Artikel mehr geliefert hat, gilt als Karteileichen-Kandidat.
STALE_DEACTIVATE_THRESHOLD_DAYS = 60
async def generate_stale_deactivation_suggestions(
db: aiosqlite.Connection,
days_threshold: int = STALE_DEACTIVATE_THRESHOLD_DAYS,
) -> int:
"""Erzeugt deactivate_source-Vorschläge für Karteileichen-Quellen.
Karteileiche = aktive Quelle, die entweder noch nie einen Artikel geliefert hat
(article_count = 0) oder seit mehr als days_threshold Tagen stumm ist
(last_seen_at älter als die Schwelle). Reine SQL-Heuristik, kein KI-Aufruf.
Doppel-Vermeidung: existiert bereits ein pending deactivate-Vorschlag für
dieselbe source_id, wird kein neuer erzeugt.
Returns: Anzahl neu erstellter Vorschläge.
"""
cursor = await db.execute(
f"""
SELECT id, name, url, domain, article_count, last_seen_at
FROM sources
WHERE status = 'active'
AND (
COALESCE(article_count, 0) = 0
OR (last_seen_at IS NOT NULL
AND last_seen_at < datetime('now', '-{int(days_threshold)} days'))
)
"""
)
candidates = [dict(row) for row in await cursor.fetchall()]
if not candidates:
return 0
cursor = await db.execute(
"SELECT DISTINCT source_id FROM source_suggestions "
"WHERE status = 'pending' AND suggestion_type = 'deactivate_source' "
"AND source_id IS NOT NULL"
)
already_pending = {row["source_id"] for row in await cursor.fetchall()}
created = 0
for c in candidates:
sid = c["id"]
if sid in already_pending:
continue
if (c["article_count"] or 0) == 0:
reason = "Hat seit Anlage noch nie einen Artikel geliefert."
else:
reason = (
f"Letzter Artikel vor mehr als {days_threshold} Tagen "
f"(last_seen_at={c['last_seen_at']})."
)
title = f"{c['name']} (ID {sid}) - Karteileiche, deaktivieren?"
description = (
f"Quelle: {c['name']} | URL: {c['url']} | Domain: {c['domain'] or '-'}\n"
f"Begründung: {reason}\n"
f"article_count={c['article_count'] or 0}, "
f"last_seen_at={c['last_seen_at'] or 'NULL'}\n"
"Hinweis: Quelle wurde automatisch als inaktiv erkannt. "
"Bitte vor Annahme prüfen, ob sie wirklich nicht mehr gebraucht wird."
)
suggested_data = json.dumps(
{"action": "deactivate", "source_id": sid}, ensure_ascii=False
)
await db.execute(
"INSERT INTO source_suggestions "
"(suggestion_type, title, description, source_id, suggested_data, "
" priority, status) VALUES "
"('deactivate_source', ?, ?, ?, ?, 'medium', 'pending')",
(title, description, sid, suggested_data),
)
created += 1
if created > 0:
await db.commit()
logger.info(
"Karteileichen-Heuristik: %d neue deactivate-Vorschläge erstellt "
"(%d Kandidaten, %d bereits pending)",
created, len(candidates), len(already_pending),
)
else:
logger.info(
"Karteileichen-Heuristik: keine neuen Vorschläge "
"(%d Kandidaten, alle bereits pending)",
len(candidates),
)
return created
async def generate_strategy_escalation_suggestions(db: aiosqlite.Connection) -> int:
"""Erzeugt deactivate_source-Vorschläge für Quellen, bei denen die fetch_strategy
bereits eskaliert wurde (googlebot oder paywall) und der Reachability-Check
trotzdem error meldet.
Beispiel: Rheinische Post hat fetch_strategy=googlebot, kriegt aber HTTP 403.
-> Strategie greift nicht, Quelle ist faktisch nicht abrufbar. Vorschlag: deaktivieren.
Doppel-Vermeidung wie in der Karteileichen-Heuristik: nur wenn noch kein pending
deactivate-Vorschlag für die source_id existiert.
Returns: Anzahl neu erstellter Vorschläge.
"""
cursor = await db.execute(
"""
SELECT s.id, s.name, s.url, s.domain, s.fetch_strategy, h.message
FROM sources s
JOIN source_health_checks h ON h.source_id = s.id
WHERE s.status = 'active'
AND s.fetch_strategy IN ('googlebot', 'paywall')
AND h.check_type = 'reachability'
AND h.status = 'error'
"""
)
candidates = [dict(row) for row in await cursor.fetchall()]
if not candidates:
return 0
cursor = await db.execute(
"SELECT DISTINCT source_id FROM source_suggestions "
"WHERE status = 'pending' AND suggestion_type = 'deactivate_source' "
"AND source_id IS NOT NULL"
)
already_pending = {row["source_id"] for row in await cursor.fetchall()}
created = 0
for c in candidates:
sid = c["id"]
if sid in already_pending:
continue
title = f"{c['name']} (ID {sid}) - Strategie greift nicht"
description = (
f"Quelle: {c['name']} | URL: {c['url']} | Domain: {c['domain'] or '-'}\n"
f"fetch_strategy='{c['fetch_strategy']}' wurde bereits zur Eskalation gesetzt, "
f"liefert beim Health-Check aber weiter einen Fehler:\n"
f" {c['message']}\n"
"Vorschlag: deaktivieren oder fetch_strategy='skip' setzen, damit die Quelle "
"den Health-Check nicht weiter verfälscht.\n"
"Hinweis: Quelle wurde automatisch erkannt. Bitte vor Annahme prüfen."
)
suggested_data = json.dumps(
{"action": "deactivate", "source_id": sid,
"reason": "fetch_strategy_failed", "current_strategy": c["fetch_strategy"]},
ensure_ascii=False,
)
await db.execute(
"INSERT INTO source_suggestions "
"(suggestion_type, title, description, source_id, suggested_data, "
" priority, status) VALUES "
"('deactivate_source', ?, ?, ?, ?, 'high', 'pending')",
(title, description, sid, suggested_data),
)
created += 1
if created > 0:
await db.commit()
logger.info(
"Strategie-Eskalations-Heuristik: %d neue deactivate-Vorschläge "
"(%d Kandidaten, %d bereits pending)",
created, len(candidates), len(already_pending),
)
return created
async def generate_suggestions(db: aiosqlite.Connection) -> int:
"""Generiert Quellen-Vorschläge basierend auf Health-Checks und Lückenanalyse.
Drei Stufen, in dieser Reihenfolge ausgeführt (spezifisch -> generisch -> KI):
1. Deterministisch: Strategie-Eskalations-Heuristik (fetch_strategy=googlebot
oder paywall, aber Reachability weiter error) erzeugt deactivate_source-
Vorschläge mit Priorität 'high'. Spezifischste Diagnose: "Workaround
greift nicht". Läuft ZUERST, damit diese Sources nicht von der
generischeren Karteileichen-Stufe weggefangen werden.
2. Deterministisch: Karteileichen-Heuristik (article_count=0 oder >60d stumm)
erzeugt sofort deactivate_source-Vorschläge für alle übrigen toten
Quellen ohne KI-Aufruf.
3. KI-basiert: Haiku schaut sich Quellensammlung + Health-Probleme an
und schlägt weitere Verbesserungen vor (add_source, deactivate_source,
fix_url, ...).
Rückgabe ist die Gesamtzahl neu erzeugter Vorschläge aller Stufen.
"""
strategy_count = await generate_strategy_escalation_suggestions(db)
stale_count = await generate_stale_deactivation_suggestions(db)
logger.info("Starte Quellen-Vorschläge via Haiku...")
"""Generiert Quellen-Vorschläge basierend auf Health-Checks und Lückenanalyse."""
logger.info("Starte Quellen-Vorschläge via Haiku...")
# 1. Aktuelle Quellen laden
cursor = await db.execute(
@@ -216,13 +33,13 @@ async def generate_suggestions(db: aiosqlite.Connection) -> int:
""")
issues = [dict(row) for row in await cursor.fetchall()]
# 3. Alte pending-Vorschläge entfernen (älter als 30 Tage)
# 3. Alte pending-Vorschläge entfernen (älter als 30 Tage)
await db.execute(
"DELETE FROM source_suggestions "
"WHERE status = 'pending' AND created_at < datetime('now', '-30 days')"
)
# 4. Quellen-Zusammenfassung für Haiku
# 4. Quellen-Zusammenfassung für Haiku
categories = {}
for s in sources:
cat = s["category"]
@@ -250,7 +67,7 @@ async def generate_suggestions(db: aiosqlite.Connection) -> int:
f"{issue['check_type']} = {issue['status']} - {issue['message']}\n"
)
prompt = f"""Du bist ein OSINT-Analyst und verwaltest die Quellensammlung eines Lagebildmonitors für Sicherheitsbehörden.
prompt = f"""Du bist ein OSINT-Analyst und verwaltest die Quellensammlung eines Lagebildmonitors für Sicherheitsbehörden.
Aktuelle Quellensammlung:{source_summary}{issues_summary}
@@ -261,13 +78,13 @@ Beachte:
2. Fehlende wichtige OSINT-Quellen: Schlage "add_source" mit konkreter RSS-Feed-URL vor
3. Fokus auf deutschsprachige + wichtige internationale Nachrichtenquellen
4. Nur Quellen vorschlagen, die NICHT bereits vorhanden sind
5. Maximal 5 Vorschläge
5. Maximal 5 Vorschläge
Antworte NUR mit einem JSON-Array. Jedes Element:
{{
"type": "add_source|deactivate_source|fix_url|remove_source",
"title": "Kurzer Titel",
"description": "Begründung",
"description": "Begründung",
"priority": "low|medium|high",
"source_id": null,
"data": {{
@@ -287,7 +104,7 @@ Nur das JSON-Array, kein anderer Text."""
json_match = re.search(r'\[.*\]', response, re.DOTALL)
if not json_match:
logger.warning("Keine Vorschläge von Haiku erhalten (kein JSON)")
logger.warning("Keine Vorschläge von Haiku erhalten (kein JSON)")
return 0
suggestions = json.loads(json_match.group(0))
@@ -347,16 +164,15 @@ Nur das JSON-Array, kein anderer Text."""
await db.commit()
logger.info(
f"Quellen-Vorschläge: {count} neue Vorschläge generiert via Haiku "
f"(+{stale_count} Karteileichen, +{strategy_count} Strategie-Eskalation) "
f"Quellen-Vorschläge: {count} neue Vorschläge generiert "
f"(Haiku: {usage.input_tokens} in / {usage.output_tokens} out / "
f"${usage.cost_usd:.4f})"
)
return count + stale_count + strategy_count
return count
except Exception as e:
logger.error(f"Fehler bei Quellen-Vorschlägen: {e}", exc_info=True)
return stale_count + strategy_count
logger.error(f"Fehler bei Quellen-Vorschlägen: {e}", exc_info=True)
return 0
async def apply_suggestion(
@@ -402,7 +218,7 @@ async def apply_suggestion(
(url,),
)
if await cursor.fetchone():
result["action"] = "übersprungen (URL bereits vorhanden)"
result["action"] = "übersprungen (URL bereits vorhanden)"
new_status = "rejected"
else:
await db.execute(
@@ -414,7 +230,7 @@ async def apply_suggestion(
)
result["action"] = f"Quelle '{name}' angelegt"
else:
result["action"] = "übersprungen (keine URL)"
result["action"] = "übersprungen (keine URL)"
new_status = "rejected"
elif stype == "deactivate_source":
@@ -426,7 +242,7 @@ async def apply_suggestion(
)
result["action"] = "Quelle deaktiviert"
else:
result["action"] = "übersprungen (keine source_id)"
result["action"] = "übersprungen (keine source_id)"
elif stype == "remove_source":
source_id = suggestion["source_id"]
@@ -434,9 +250,9 @@ async def apply_suggestion(
await db.execute(
"DELETE FROM sources WHERE id = ?", (source_id,),
)
result["action"] = "Quelle gelöscht"
result["action"] = "Quelle gelöscht"
else:
result["action"] = "übersprungen (keine source_id)"
result["action"] = "übersprungen (keine source_id)"
elif stype == "fix_url":
source_id = suggestion["source_id"]
@@ -448,7 +264,7 @@ async def apply_suggestion(
)
result["action"] = f"URL aktualisiert auf {new_url}"
else:
result["action"] = "übersprungen (keine source_id oder URL)"
result["action"] = "übersprungen (keine source_id oder URL)"
await db.execute(
"UPDATE source_suggestions SET status = ?, reviewed_at = CURRENT_TIMESTAMP "

Datei anzeigen

@@ -649,14 +649,14 @@ async def get_feeds_with_metadata(tenant_id: int = None, source_type: str = "rss
try:
if tenant_id:
cursor = await db.execute(
"SELECT name, url, domain, category, notes, COALESCE(article_count, 0) AS article_count FROM sources "
"SELECT name, url, domain, category, COALESCE(article_count, 0) AS article_count FROM sources "
"WHERE source_type = ? AND status = 'active' "
"AND (tenant_id IS NULL OR tenant_id = ?)",
(source_type, tenant_id),
)
else:
cursor = await db.execute(
"SELECT name, url, domain, category, notes, COALESCE(article_count, 0) AS article_count FROM sources "
"SELECT name, url, domain, category, COALESCE(article_count, 0) AS article_count FROM sources "
"WHERE source_type = ? AND status = 'active'",
(source_type,),
)

Datei-Diff unterdrückt, da er zu groß ist Diff laden

Datei anzeigen

@@ -13,7 +13,7 @@
<link rel="stylesheet" href="/static/vendor/leaflet.css">
<link rel="stylesheet" href="/static/vendor/MarkerCluster.css">
<link rel="stylesheet" href="/static/vendor/MarkerCluster.Default.css">
<link rel="stylesheet" href="/static/css/style.css?v=20260501h">
<link rel="stylesheet" href="/static/css/style.css?v=20260501e">
<style>
/* Export Modal Radio */
.export-radio { display:flex; align-items:center; gap:10px; padding:8px 12px; cursor:pointer; border-radius:var(--radius-sm); transition:background 0.15s; border:1px solid transparent; margin-bottom:4px; }
@@ -72,11 +72,6 @@
<span class="credits-percent" id="credits-percent"></span>
</div>
</div>
<div class="credits-divider"></div>
<button class="header-dropdown-action" type="button" onclick="AIDisclaimer && AIDisclaimer.show()">
<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" aria-hidden="true"><circle cx="12" cy="12" r="10"/><path d="M12 16v-4"/><path d="M12 8h.01"/></svg>
<span>Über KI-Inhalte</span>
</button>
</div>
</div>
<div class="header-license-warning" id="header-license-warning"></div>
@@ -123,14 +118,8 @@
<div id="archived-incidents" aria-live="polite" style="display:none;"></div>
</div>
<div class="sidebar-sources-link">
<button class="btn btn-secondary btn-full btn-small" onclick="App.openSourceManagement()" title="Quellen verwalten">
<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" aria-hidden="true"><ellipse cx="12" cy="5" rx="9" ry="3"/><path d="M3 5v14c0 1.66 4.03 3 9 3s9-1.34 9-3V5"/><path d="M3 12c0 1.66 4.03 3 9 3s9-1.34 9-3"/></svg>
<span>Quellen</span>
</button>
<button class="btn btn-secondary btn-full btn-small sidebar-feedback-btn" onclick="App.openFeedback()" title="Feedback senden">
<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" aria-hidden="true"><rect width="20" height="16" x="2" y="4" rx="2"/><path d="m22 7-10 5L2 7"/></svg>
<span>Feedback</span>
</button>
<button class="btn btn-secondary btn-full btn-small" onclick="App.openSourceManagement()">Quellen verwalten</button>
<button class="btn btn-secondary btn-full btn-small sidebar-feedback-btn" onclick="App.openFeedback()">Feedback senden</button>
<!-- Tutorial-Einstieg temporaer deaktiviert (Ueberarbeitung) - reaktivieren durch Entfernen der Kommentarzeichen:
<button class="btn btn-secondary btn-full btn-small" onclick="Tutorial.start()" title="Interaktiven Rundgang starten">Rundgang starten</button>
-->
@@ -362,9 +351,9 @@
<label>Quellen</label>
<div class="toggle-group">
<label class="toggle-label">
<input type="checkbox" id="inc-international">
<input type="checkbox" id="inc-international" checked>
<span class="toggle-switch"></span>
<span class="toggle-text">Internationale Quellen einbeziehen <span class="info-icon tooltip-below" data-tooltip="Aktiviert: Sucht auch in englischsprachigen und internationalen Medien.&#10;&#10;Deaktiviert (Standard): Nur deutschsprachige Quellen - empfohlen für DACH-Lagen."><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></span>
<span class="toggle-text">Internationale Quellen einbeziehen <span class="info-icon tooltip-below" data-tooltip="Aktiviert: Sucht auch in englischsprachigen und internationalen Medien.&#10;&#10;Deaktiviert: Nur deutschsprachige Quellen."><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></span>
</label>
</div>
<div class="toggle-group" style="margin-top: 8px;">
@@ -481,76 +470,6 @@
<option value="boulevard">Boulevard</option>
<option value="sonstige">Sonstige</option>
</select>
<label for="sources-filter-political" class="sr-only">Politische Ausrichtung filtern</label>
<select id="sources-filter-political" class="timeline-filter-select" onchange="App.filterSources()">
<option value="">Alle Ausrichtungen</option>
<option value="links_extrem">Links (extrem)</option>
<option value="links">Links</option>
<option value="mitte_links">Mitte-Links</option>
<option value="liberal">Liberal</option>
<option value="mitte">Mitte</option>
<option value="konservativ">Konservativ</option>
<option value="mitte_rechts">Mitte-Rechts</option>
<option value="rechts">Rechts</option>
<option value="rechts_extrem">Rechts (extrem)</option>
<option value="na">Nicht eingeordnet</option>
</select>
<label for="sources-filter-mediatype" class="sr-only">Medientyp filtern</label>
<select id="sources-filter-mediatype" class="timeline-filter-select" onchange="App.filterSources()">
<option value="">Alle Medientypen</option>
<option value="tageszeitung">Tageszeitung</option>
<option value="wochenzeitung">Wochenzeitung</option>
<option value="magazin">Magazin</option>
<option value="tv_sender">TV-Sender</option>
<option value="radio">Radio</option>
<option value="oeffentlich_rechtlich">Öffentlich-Rechtlich</option>
<option value="nachrichtenagentur">Nachrichtenagentur</option>
<option value="online_only">Online-only</option>
<option value="blog">Blog</option>
<option value="telegram_kanal">Telegram-Kanal</option>
<option value="telegram_bot">Telegram-Bot</option>
<option value="podcast">Podcast</option>
<option value="social_media">Social Media</option>
<option value="imageboard">Imageboard</option>
<option value="think_tank">Think Tank</option>
<option value="ngo">NGO</option>
<option value="behoerde">Behörde</option>
<option value="staatsmedium">Staatsmedium</option>
<option value="fachmedium">Fachmedium</option>
<option value="sonstige">Sonstige</option>
</select>
<label for="sources-filter-reliability" class="sr-only">Glaubwürdigkeit filtern</label>
<select id="sources-filter-reliability" class="timeline-filter-select" onchange="App.filterSources()">
<option value="">Alle Glaubwürdigkeiten</option>
<option value="sehr_hoch">Sehr hoch</option>
<option value="hoch">Hoch</option>
<option value="gemischt">Gemischt</option>
<option value="niedrig">Niedrig</option>
<option value="sehr_niedrig">Sehr niedrig</option>
<option value="na">Nicht eingeordnet</option>
</select>
<label for="sources-filter-extern" class="sr-only">Externe Reputation filtern</label>
<select id="sources-filter-extern" class="timeline-filter-select" onchange="App.filterSources()">
<option value="">Externe Reputation: alle</option>
<option value="ifcn">IFCN-Faktenchecker</option>
<option value="eu_disinfo">EU-Desinfo gelistet</option>
</select>
<label for="sources-filter-alignment" class="sr-only">Geopolitische Nähe filtern</label>
<select id="sources-filter-alignment" class="timeline-filter-select" onchange="App.filterSources()">
<option value="">Alle Nähen</option>
<option value="prorussisch">Prorussisch</option>
<option value="proiranisch">Proiranisch</option>
<option value="prowestlich">Prowestlich</option>
<option value="proukrainisch">Proukrainisch</option>
<option value="prochinesisch">Prochinesisch</option>
<option value="projapanisch">Projapanisch</option>
<option value="proisraelisch">Proisraelisch</option>
<option value="propalaestinensisch">Propalästinensisch</option>
<option value="protuerkisch">Protürkisch</option>
<option value="panarabisch">Panarabisch</option>
<option value="neutral">Neutral</option>
<option value="sonstige">Sonstige</option>
</select>
<label for="sources-search" class="sr-only">Quellen durchsuchen</label>
<input type="text" id="sources-search" class="timeline-filter-input sources-search-input" placeholder="Suche..." oninput="App.filterSources()">
</div>
@@ -727,8 +646,8 @@
<script src="/static/js/ws.js?v=20260316b"></script>
<script src="/static/js/components.js?v=20260427a"></script>
<script src="/static/js/layout.js?v=20260316b"></script>
<script src="/static/js/pipeline.js?v=20260501i"></script>
<script src="/static/js/app.js?v=20260512a"></script>
<script src="/static/js/pipeline.js?v=20260501a"></script>
<script src="/static/js/app.js?v=20260501e"></script>
<script src="/static/js/cluster-data.js?v=20260322f"></script>
<script src="/static/js/tutorial.js?v=20260316z"></script>
<script src="/static/js/chat.js?v=20260422a"></script>
@@ -819,6 +738,5 @@
</div>
<script src="/static/js/update-system.js"></script>
<script src="/static/js/ai-disclaimer.js"></script>
</body>
</html>

Datei anzeigen

@@ -1,195 +0,0 @@
/**
* AI-Hallucination-Disclaimer fuer den AegisSight Monitor.
*
* Zeigt:
* 1) Beim ersten Besuch (oder bei neuem v-Bump) ein Modal mit Hinweisen
* zur Fehlbarkeit von KI-Modellen.
* 2) Im Header-User-Dropdown immer einen Eintrag "Ueber KI-Inhalte",
* ueber den der User das Modal jederzeit erneut oeffnen kann.
*
* Persistenz:
* localStorage 'aegis_ai_disclaimer_seen' -> Versionsstring (z.B. "v1").
* Wenn die Version sich aendert (Wortlaut-Update), erscheint das Modal
* beim naechsten Login erneut.
*/
(function () {
'use strict';
const STORAGE_KEY = 'aegis_ai_disclaimer_seen';
const CURRENT_VERSION = 'v1';
// ---- DOM-Helpers (analog zu update-system.js) ----
function el(tag, attrs, ...children) {
const e = document.createElement(tag);
for (const k in (attrs || {})) {
if (k === 'class') e.className = attrs[k];
else if (k === 'html') e.innerHTML = attrs[k];
else if (k.startsWith('on')) e.addEventListener(k.slice(2), attrs[k]);
else e.setAttribute(k, attrs[k]);
}
for (const c of children) {
if (c == null) continue;
e.appendChild(typeof c === 'string' ? document.createTextNode(c) : c);
}
return e;
}
function injectStyles() {
if (document.getElementById('aegis-aidisc-styles')) return;
const css = `
#aegis-aidisc-overlay {
position: fixed; inset: 0; background: rgba(0,0,0,0.55); z-index: 99998;
backdrop-filter: blur(3px);
display: flex; align-items: center; justify-content: center; padding: 24px;
animation: aegis-aidisc-fade 0.25s ease;
}
@keyframes aegis-aidisc-fade { from { opacity: 0; } to { opacity: 1; } }
#aegis-aidisc-modal {
background: var(--bg-card);
color: var(--text-primary);
border-radius: 14px;
border: 1px solid var(--border);
box-shadow: 0 24px 80px rgba(0,0,0,0.4);
font-family: 'Inter', -apple-system, sans-serif;
max-width: 580px; width: 100%; max-height: 85vh; overflow: hidden;
display: flex; flex-direction: column;
}
#aegis-aidisc-modal header {
padding: 22px 28px 18px; border-bottom: 1px solid var(--border);
display: flex; align-items: center; gap: 12px;
}
#aegis-aidisc-modal header svg { color: var(--accent); flex-shrink: 0; }
#aegis-aidisc-modal h2 { margin: 0; color: var(--accent); font-size: 1.25rem; font-weight: 700; }
#aegis-aidisc-modal .body { padding: 18px 28px; overflow-y: auto; line-height: 1.55; }
#aegis-aidisc-modal .body p { margin: 0 0 12px; color: var(--text-primary); font-size: 0.94rem; }
#aegis-aidisc-modal .body strong { color: var(--accent); }
#aegis-aidisc-modal .body ul { margin: 8px 0 14px; padding-left: 22px; }
#aegis-aidisc-modal .body li { margin-bottom: 6px; color: var(--text-secondary); font-size: 0.92rem; }
#aegis-aidisc-modal .footnote {
margin-top: 10px; padding-top: 12px; border-top: 1px solid var(--border);
color: var(--text-tertiary); font-size: 0.82rem;
}
#aegis-aidisc-modal footer {
padding: 14px 28px 20px; border-top: 1px solid var(--border);
display: flex; justify-content: flex-end; gap: 10px;
}
#aegis-aidisc-modal footer button {
background: var(--accent); color: #fff; border: 0; padding: 10px 22px;
border-radius: 6px; font: inherit; font-size: 0.92rem; font-weight: 600;
cursor: pointer;
}
#aegis-aidisc-modal footer button:hover { background: var(--accent-hover); }
#aegis-aidisc-modal footer button.secondary {
background: transparent; color: var(--text-secondary); border: 1px solid var(--border);
}
#aegis-aidisc-modal footer button.secondary:hover {
background: var(--bg-hover, rgba(255,255,255,0.04)); color: var(--text-primary);
}`;
document.head.appendChild(el('style', { id: 'aegis-aidisc-styles', html: css }));
}
// ---- Modal-Aufbau ----
function buildModal(opts) {
const isFromUser = !!(opts && opts.fromUserAction);
// Lucide info-Icon (gleiches Pattern wie .info-icon im Repo)
const headerIcon = el('span', {
html: '<svg xmlns="http://www.w3.org/2000/svg" width="22" height="22" '
+ '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>'
});
const body = el('div', { class: 'body' });
body.appendChild(el('p', null,
'Der AegisSight Monitor nutzt Künstliche Intelligenz '
+ 'zur Analyse, Übersetzung und Zusammenfassung von Nachrichten.'));
const warn = el('p');
warn.innerHTML = '<strong>KI-Modelle können Fehler machen</strong> '
+ '(sogenannte „Halluzinationen"): erfundene Details, falsche Verbindungen oder '
+ 'ungenaue Zusammenfassungen sind möglich, auch wenn der Text plausibel klingt.';
body.appendChild(warn);
body.appendChild(el('p', null, 'Wir empfehlen daher:'));
body.appendChild(el('ul', null,
el('li', null, 'Wichtige Informationen mit den verlinkten Quellen verifizieren'),
el('li', null, 'Bei kritischen Entscheidungen die Originalartikel prüfen'),
el('li', null, 'Faktenchecks als Hinweis verstehen, nicht als endgültige Wahrheit')
));
body.appendChild(el('p', { class: 'footnote' },
'Diesen Hinweis findest du jederzeit wieder im Menü oben rechts unter „Über KI-Inhalte".'));
const closeAndStore = () => {
try { localStorage.setItem(STORAGE_KEY, CURRENT_VERSION); } catch (e) {}
overlay.remove();
document.removeEventListener('keydown', escHandler);
};
const closeOnly = () => {
overlay.remove();
document.removeEventListener('keydown', escHandler);
};
const footer = el('footer', null);
if (!isFromUser) {
footer.appendChild(el('button', { class: 'secondary', onclick: closeOnly }, 'Später nochmal'));
}
footer.appendChild(el('button', { onclick: closeAndStore }, 'Verstanden'));
const overlay = el('div', { id: 'aegis-aidisc-overlay' },
el('div', { id: 'aegis-aidisc-modal' },
el('header', null, headerIcon, el('h2', null, 'Hinweis zu KI-generierten Inhalten')),
body,
footer
)
);
function escHandler(ev) {
if (ev.key === 'Escape' && document.getElementById('aegis-aidisc-overlay')) {
// ESC = wie "Verstanden" beim erstmaligen Anzeigen, sonst nur schliessen
if (isFromUser) closeOnly(); else closeAndStore();
}
}
overlay.addEventListener('click', (ev) => {
if (ev.target === overlay) {
if (isFromUser) closeOnly(); else closeAndStore();
}
});
document.addEventListener('keydown', escHandler);
return overlay;
}
function show(opts) {
if (document.getElementById('aegis-aidisc-overlay')) return;
injectStyles();
document.body.appendChild(buildModal(opts));
}
function init() {
// Nur auf der Dashboard-Seite zeigen, nicht auf der Login-Seite
if (!document.body || document.body.classList.contains('login-page')) return;
injectStyles();
let seenVersion = '';
try { seenVersion = localStorage.getItem(STORAGE_KEY) || ''; } catch (e) {}
if (seenVersion !== CURRENT_VERSION) {
// Etwas verzoegern, damit Hauptdashboard sichtbar ist bevor Modal kommt
setTimeout(() => show({ fromUserAction: false }), 600);
}
}
// Globaler Zugriff zum manuellen Oeffnen aus dem Header-Dropdown
window.AIDisclaimer = {
show: () => show({ fromUserAction: true }),
VERSION: CURRENT_VERSION,
};
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', init);
} else {
init();
}
})();

Datei anzeigen

@@ -67,29 +67,6 @@ const API = {
} else if (typeof detail === 'object' && detail !== null) {
detail = JSON.stringify(detail);
}
// Lizenz-Status aus Header auslesen (vom Backend gesetzt bei 403)
const licStatus = response.headers.get('X-License-Status');
if (response.status === 403 && licStatus && typeof App !== 'undefined') {
if (!App.user) App.user = {};
App.user.read_only = true;
App.user.read_only_reason = licStatus;
const warningEl = document.getElementById('header-license-warning');
if (warningEl) {
let text = 'Nur Lesezugriff';
if (licStatus === 'budget_exceeded') text = 'Token-Budget aufgebraucht – nur Lesezugriff. Bitte Verwaltung kontaktieren.';
else if (licStatus === 'expired') text = 'Lizenz abgelaufen – nur Lesezugriff';
else if (licStatus === 'no_license') text = 'Keine aktive Lizenz – nur Lesezugriff';
else if (licStatus === 'org_disabled') text = 'Organisation deaktiviert – nur Lesezugriff';
warningEl.textContent = text;
warningEl.classList.add('visible');
}
if (typeof App._updateRefreshButton === 'function') App._updateRefreshButton(false);
if (typeof UI !== 'undefined' && UI.showToast) {
UI.showToast(detail || 'Lizenz-Beschränkung – nur Lesezugriff', 'error');
}
}
throw new ApiError(response.status, detail);
}
@@ -198,13 +175,6 @@ const API = {
if (params.source_type) query.set('source_type', params.source_type);
if (params.category) query.set('category', params.category);
if (params.source_status) query.set('source_status', params.source_status);
if (params.political_orientation) query.set('political_orientation', params.political_orientation);
if (params.media_type) query.set('media_type', params.media_type);
if (params.reliability) query.set('reliability', params.reliability);
if (params.alignment) query.set('alignment', params.alignment);
if (params.state_affiliated !== undefined && params.state_affiliated !== null) {
query.set('state_affiliated', String(params.state_affiliated));
}
const qs = query.toString();
return this._request('GET', `/sources${qs ? '?' + qs : ''}`);
},

Datei anzeigen

@@ -450,7 +450,6 @@ const App = {
try {
const user = await API.getMe();
this.user = user;
this._currentUsername = user.email;
document.getElementById('header-user').textContent = user.email;
@@ -516,27 +515,11 @@ const App = {
});
}
// Warnung bei Read-Only (Lizenz abgelaufen oder Token-Budget aufgebraucht)
// Warnung bei abgelaufener Lizenz
const warningEl = document.getElementById('header-license-warning');
if (warningEl) {
if (user.read_only) {
let text = 'Nur Lesezugriff';
const reason = user.read_only_reason;
if (reason === 'budget_exceeded') {
text = 'Token-Budget aufgebraucht – nur Lesezugriff. Für Aufstockung oder Upgrade bitte info@aegis-sight.de kontaktieren.';
} else if (reason === 'expired') {
text = 'Lizenz abgelaufen – nur Lesezugriff';
} else if (reason === 'no_license') {
text = 'Keine aktive Lizenz – nur Lesezugriff';
} else if (reason === 'org_disabled') {
text = 'Organisation deaktiviert – nur Lesezugriff';
}
warningEl.textContent = text;
warningEl.classList.add('visible');
} else {
warningEl.textContent = '';
warningEl.classList.remove('visible');
}
if (warningEl && user.read_only) {
warningEl.textContent = 'Lizenz abgelaufen – nur Lesezugriff';
warningEl.classList.add('visible');
}
// --- Global Admin: Org-Switcher (herausnehmbar) ---
@@ -618,10 +601,6 @@ const App = {
const inc = this.incidents.find(i => i.id === id);
const isFirst = inc && !inc.has_summary;
UI.showProgress('queued', { queue_position: idx + 1 }, id, isFirst);
// Pipeline-Reset auch nach F5: aktive Lage in Queue -> Icons grau
if (id === this.currentIncidentId && typeof Pipeline !== 'undefined' && Pipeline.beginQueue) {
Pipeline.beginQueue(id);
}
});
}
@@ -887,97 +866,6 @@ const App = {
}
},
/** Klick auf eine Quellen-Box: Liste der Artikel inline aufklappen (mutual-exclusive). */
toggleSourceOverviewDetail(el) {
if (!el) return;
const grid = el.parentElement;
if (!grid) return;
const sourceName = el.dataset.source || '';
const wasActive = el.classList.contains('active');
// Alle anderen schliessen + bestehendes Detail entfernen
grid.querySelectorAll('.source-overview-item.active').forEach(it => {
it.classList.remove('active');
it.setAttribute('aria-expanded', 'false');
});
const existingDetail = grid.querySelector('.source-overview-detail');
if (existingDetail) existingDetail.remove();
// Wenn das geklickte Item bereits aktiv war: nur schliessen
if (wasActive) return;
// Neues Detail einfuegen direkt nach dem geklickten Item
el.classList.add('active');
el.setAttribute('aria-expanded', 'true');
const type = this._currentIncidentType;
const getDate = (a) => (type === 'research' && a.published_at) ? a.published_at : (a.collected_at || a.published_at);
const articles = (this._currentArticles || [])
.filter(a => (a.source || 'Unbekannt') === sourceName)
.sort((a, b) => {
const ta = new Date(getDate(a) || 0).getTime();
const tb = new Date(getDate(b) || 0).getTime();
return tb - ta;
});
// Lagebild-Quellennummer pro Artikel ermitteln (matcht Artikel zu sources_json)
const normalize = (s) => (s || '').toLowerCase().replace(/^(der|die|das)\s+/, '').replace(/\s+/g, ' ').trim();
const sourcesList = this._currentSources || [];
const urlToNr = new Map();
sourcesList.forEach(s => {
if (s.url && s.nr != null) urlToNr.set(String(s.url).trim(), s.nr);
});
const findNr = (a) => {
// 1) Exakter URL-Match
if (a.source_url) {
const exact = urlToNr.get(String(a.source_url).trim());
if (exact != null) return exact;
}
// 2) Fallback: Match via Quellen-Namen (kann mehrfach treffen, nimm erstes)
if (a.source) {
const target = normalize(a.source);
const hit = sourcesList.find(s => s.nr != null && normalize(s.name) === target);
if (hit) return hit.nr;
}
return null;
};
const detail = document.createElement('div');
detail.className = 'source-overview-detail';
if (articles.length === 0) {
detail.innerHTML = '<div class="source-overview-detail-empty">Keine Artikel gefunden.</div>';
} else {
const fmtDate = (ts) => {
if (!ts) return '—';
try {
const d = new Date(ts);
if (isNaN(d.getTime())) return '—';
return d.toLocaleDateString('de-DE', { day: '2-digit', month: '2-digit', year: '2-digit', timeZone: TIMEZONE })
+ ' '
+ d.toLocaleTimeString('de-DE', { hour: '2-digit', minute: '2-digit', timeZone: TIMEZONE });
} catch (e) { return '—'; }
};
const items = articles.map(a => {
const nr = findNr(a);
const numHtml = nr != null
? `<span class="source-overview-detail-num">[${UI.escape(String(nr))}]</span>`
: `<span class="source-overview-detail-num source-overview-detail-num--none" title="Nicht im Lagebild zitiert">—</span>`;
const dateStr = fmtDate(getDate(a));
const headline = UI.escape(a.headline_de || a.headline || '(ohne Titel)');
const inner = a.source_url
? `<a href="${UI.escape(a.source_url)}" target="_blank" rel="noopener">${headline}</a>`
: headline;
return `<li>
${numHtml}
<span class="source-overview-detail-date">${UI.escape(dateStr)}</span>
<span class="source-overview-detail-headline">${inner}</span>
</li>`;
}).join('');
detail.innerHTML = `<ul class="source-overview-detail-list">${items}</ul>`;
}
el.insertAdjacentElement('afterend', detail);
},
/** Restliche Artikel seitenweise im Hintergrund nachladen und in _currentArticles mergen. */
async _loadRemainingArticlesInBackground(incidentId) {
const BATCH = 500;
@@ -1930,11 +1818,6 @@ async handleRefresh() {
this._updateRefreshButton(true);
// showProgress called via handleStatusUpdate
const result = await API.refreshIncident(this.currentIncidentId);
// Pipeline auf "pending" setzen, damit alte gruene Haekchen nicht
// faelschlich "schon fertig" suggerieren waehrend die Lage in der Queue steht
if (typeof Pipeline !== 'undefined' && Pipeline.beginQueue) {
Pipeline.beginQueue(this.currentIncidentId);
}
if (result && result.status === 'skipped') {
UI.showToast('Aktualisierung ist in der Warteschlange und wird ausgefuehrt, sobald die aktuelle Recherche abgeschlossen ist.', 'info');
} else {
@@ -2156,19 +2039,8 @@ async handleRefresh() {
_updateRefreshButton(disabled) {
const btn = document.getElementById('refresh-btn');
if (!btn) return;
// Hard-Stop: Lese-Modus (Budget aufgebraucht / Lizenz abgelaufen) -> immer disabled
if (this.user && this.user.read_only) {
btn.disabled = true;
const reason = this.user.read_only_reason;
btn.textContent = reason === 'budget_exceeded' ? 'Budget aufgebraucht' : 'Nur Lesezugriff';
btn.title = reason === 'budget_exceeded'
? 'Token-Budget aufgebraucht. Bitte Verwaltung kontaktieren.'
: 'Lizenz erlaubt keinen Schreibzugriff';
return;
}
btn.disabled = disabled;
btn.textContent = disabled ? 'Läuft...' : 'Aktualisieren';
btn.title = '';
},
async handleDelete() {
@@ -2210,7 +2082,7 @@ async handleRefresh() {
{ const _e = document.getElementById('inc-visibility'); if (_e) _e.checked = incident.visibility !== 'private'; }
updateVisibilityHint();
updateSourcesHint();
toggleTypeDefaults(true);
toggleTypeDefaults();
toggleRefreshInterval();
// Modal-Titel und Submit ändern
@@ -2750,11 +2622,6 @@ async handleRefresh() {
// Filter anwenden
const typeFilter = document.getElementById('sources-filter-type')?.value || '';
const catFilter = document.getElementById('sources-filter-category')?.value || '';
const politicalFilter = document.getElementById('sources-filter-political')?.value || '';
const mediaTypeFilter = document.getElementById('sources-filter-mediatype')?.value || '';
const reliabilityFilter = document.getElementById('sources-filter-reliability')?.value || '';
const alignmentFilter = document.getElementById('sources-filter-alignment')?.value || '';
const externFilter = document.getElementById('sources-filter-extern')?.value || '';
const search = (document.getElementById('sources-search')?.value || '').toLowerCase();
// Alle Quellen nach Domain gruppieren
@@ -2805,25 +2672,6 @@ async handleRefresh() {
if (!hasMatchingCat) continue;
}
// Klassifikations-Filter
if (politicalFilter) {
if (!feeds.some(f => (f.political_orientation || 'na') === politicalFilter)) continue;
}
if (mediaTypeFilter) {
if (!feeds.some(f => (f.media_type || 'sonstige') === mediaTypeFilter)) continue;
}
if (reliabilityFilter) {
if (!feeds.some(f => (f.reliability || 'na') === reliabilityFilter)) continue;
}
if (alignmentFilter) {
if (!feeds.some(f => Array.isArray(f.alignments) && f.alignments.includes(alignmentFilter))) continue;
}
if (externFilter === 'ifcn') {
if (!feeds.some(f => f.ifcn_signatory)) continue;
} else if (externFilter === 'eu_disinfo') {
if (!feeds.some(f => f.eu_disinfo_listed)) continue;
}
// Suche
if (search) {
const groupText = feeds.map(f =>
@@ -3666,18 +3514,15 @@ function updateSourcesHint() {
}
}
function toggleTypeDefaults(preserveMode = false) {
function toggleTypeDefaults() {
const type = document.getElementById('inc-type').value;
const hint = document.getElementById('type-hint');
const refreshMode = document.getElementById('inc-refresh-mode');
if (type === 'research') {
hint.textContent = 'Recherchiert in Tiefe: Nachrichtenarchive, Parlamentsdokumente, Fachmedien, Expertenquellen. Empfohlen: Manuell starten und bei Bedarf vertiefen.';
// Nur bei Typ-Wechsel/Neuanlage Modus zurückziehen, beim Edit bestehender Lagen DB-Wert respektieren
if (!preserveMode) {
refreshMode.value = 'manual';
toggleRefreshInterval();
}
refreshMode.value = 'manual';
toggleRefreshInterval();
} else {
hint.textContent = 'Durchsucht laufend hunderte Nachrichtenquellen nach neuen Meldungen. Empfohlen: Automatische Aktualisierung.';
}

Datei anzeigen

@@ -354,22 +354,9 @@ const UI = {
const minBtn = document.getElementById('progress-popup-minimize');
if (minBtn) minBtn.style.display = state.isFirst ? 'none' : '';
// Title - haengt von Status ab (queued = wartet, cancelling = bricht ab, sonst laeuft)
// Title
const titleEl = document.getElementById('progress-popup-title');
if (titleEl) {
let title;
if (status === 'queued') {
const pos = (state && state._queuePos) ? ' (#' + state._queuePos + ')' : '';
title = 'In Warteschlange' + pos;
} else if (status === 'cancelling') {
title = 'Wird abgebrochen\u2026';
} else if (state.isFirst) {
title = 'Erste Recherche l\u00e4uft';
} else {
title = 'Aktualisierung l\u00e4uft';
}
titleEl.textContent = title;
}
if (titleEl) titleEl.textContent = state.isFirst ? 'Erste Recherche l\u00e4uft' : 'Aktualisierung l\u00e4uft';
// Multi-pass info
const passEl = document.getElementById('progress-popup-pass');
@@ -984,9 +971,8 @@ const UI = {
html += '<div class="source-overview-grid">';
data.sources.forEach(s => {
const langs = (s.languages || ['de']).map(l => (l || 'de').toUpperCase()).join('/');
const sourceName = this.escape(s.source || 'Unbekannt');
html += `<div class="source-overview-item" data-source="${sourceName}" tabindex="0" role="button" aria-expanded="false" onclick="App.toggleSourceOverviewDetail(this)" onkeydown="if(event.key==='Enter'||event.key===' '){event.preventDefault();App.toggleSourceOverviewDetail(this);}">
<span class="source-overview-name">${sourceName}</span>
html += `<div class="source-overview-item">
<span class="source-overview-name">${this.escape(s.source || 'Unbekannt')}</span>
<span class="source-overview-lang">${langs}</span>
<span class="source-overview-count">${s.article_count}</span>
</div>`;
@@ -1062,98 +1048,6 @@ const UI = {
'sonstige': 'Sonstige',
},
_politicalLabels: {
links_extrem: { short: 'L+', full: 'Links (extrem)' },
links: { short: 'L', full: 'Links' },
mitte_links: { short: 'ML', full: 'Mitte-Links' },
liberal: { short: 'LIB', full: 'Liberal' },
mitte: { short: 'M', full: 'Mitte' },
konservativ: { short: 'KON', full: 'Konservativ' },
mitte_rechts: { short: 'MR', full: 'Mitte-Rechts' },
rechts: { short: 'R', full: 'Rechts' },
rechts_extrem: { short: 'R+', full: 'Rechts (extrem)' },
na: { short: '?', full: 'Nicht eingeordnet' },
},
_reliabilityLabels: {
sehr_hoch: 'Sehr hoch',
hoch: 'Hoch',
gemischt: 'Gemischt',
niedrig: 'Niedrig',
sehr_niedrig: 'Sehr niedrig',
na: 'Nicht eingeordnet',
},
_mediaTypeLabels: {
tageszeitung: 'Tageszeitung',
wochenzeitung: 'Wochenzeitung',
magazin: 'Magazin',
tv_sender: 'TV-Sender',
radio: 'Radio',
oeffentlich_rechtlich: 'Öffentlich-Rechtlich',
nachrichtenagentur: 'Nachrichtenagentur',
online_only: 'Online-only',
blog: 'Blog',
telegram_kanal: 'Telegram-Kanal',
telegram_bot: 'Telegram-Bot',
podcast: 'Podcast',
social_media: 'Social Media',
imageboard: 'Imageboard',
think_tank: 'Think Tank',
ngo: 'NGO',
behoerde: 'Behörde',
staatsmedium: 'Staatsmedium',
fachmedium: 'Fachmedium',
sonstige: 'Sonstige',
},
_alignmentLabels: {
prorussisch: 'prorussisch',
proiranisch: 'proiranisch',
prowestlich: 'prowestlich',
proukrainisch: 'proukrainisch',
prochinesisch: 'prochinesisch',
projapanisch: 'projapanisch',
proisraelisch: 'proisraelisch',
propalaestinensisch: 'propalästinensisch',
protuerkisch: 'protürkisch',
panarabisch: 'panarabisch',
neutral: 'neutral',
sonstige: 'sonstige',
},
_renderClassificationBadges(feed) {
const parts = [];
const pol = feed.political_orientation;
if (pol && pol !== 'na') {
const label = this._politicalLabels[pol] || { short: pol, full: pol };
parts.push(`<span class="source-political-badge pol-${this.escape(pol)}" title="${this.escape(label.full)}">${this.escape(label.short)}</span>`);
}
const rel = feed.reliability;
if (rel && rel !== 'na') {
const relLabel = this._reliabilityLabels[rel] || rel;
const relSource = feed.ifcn_signatory ? '(IFCN-Faktenchecker)'
: (feed.eu_disinfo_listed ? `(EU-Desinfo, ${feed.eu_disinfo_case_count || 0} Fälle)`
: '(LLM-Schätzung)');
const relTitle = `Glaubwürdigkeit: ${relLabel} ${relSource}`;
parts.push(`<span class="source-reliability-dot rel-${this.escape(rel)}" title="${this.escape(relTitle)}" aria-label="${this.escape(relTitle)}"></span>`);
}
if (feed.ifcn_signatory) {
parts.push(`<span class="source-ifcn-badge" title="IFCN-zertifizierter Faktenchecker" aria-label="IFCN-Faktenchecker">✓ IFCN</span>`);
}
if (feed.eu_disinfo_listed) {
const cnt = feed.eu_disinfo_case_count || 0;
const title = `EUvsDisinfo: ${cnt} dokumentierte Desinformations-Fälle`;
parts.push(`<span class="source-eu-disinfo-badge" title="${this.escape(title)}" aria-label="${this.escape(title)}">⚠ EU-Desinfo (${cnt})</span>`);
}
if (feed.state_affiliated) {
parts.push(`<span class="source-state-badge" title="Staatsnah/-kontrolliert" aria-label="Staatsnah">⚑</span>`);
}
const aligns = Array.isArray(feed.alignments) ? feed.alignments : [];
aligns.forEach(a => {
const label = this._alignmentLabels[a] || a;
parts.push(`<span class="source-alignment-chip-badge align-${this.escape(a)}">${this.escape(label)}</span>`);
});
return parts.join('');
},
/**
* Domain-Gruppe rendern (aufklappbar mit Feeds).
*/
@@ -1209,52 +1103,20 @@ const UI = {
? `<span class="source-feed-count">${feedCount} Feed${feedCount !== 1 ? 's' : ''}</span>`
: '';
// Info-Button mit Tooltip (Typ, Sprache, Ausrichtung, Klassifikation)
// Info-Button mit Tooltip (Typ, Sprache, Ausrichtung)
let infoButtonHtml = '';
const firstFeed = feeds[0] || {};
const hasInfo = firstFeed.language || firstFeed.bias
|| (firstFeed.political_orientation && firstFeed.political_orientation !== 'na')
|| (firstFeed.media_type && firstFeed.media_type !== 'sonstige')
|| (firstFeed.reliability && firstFeed.reliability !== 'na')
|| firstFeed.state_affiliated
|| firstFeed.country_code
|| (Array.isArray(firstFeed.alignments) && firstFeed.alignments.length > 0);
const hasInfo = firstFeed.language || firstFeed.bias;
if (hasInfo) {
const typeMap = { rss_feed: 'RSS-Feed', web_source: 'Web-Quelle', telegram_channel: 'Telegram-Kanal', podcast_feed: 'Podcast' };
const typeMap = { rss_feed: 'RSS-Feed', web_source: 'Web-Quelle', telegram_channel: 'Telegram-Kanal' };
const lines = [];
lines.push('Typ: ' + (typeMap[firstFeed.source_type] || firstFeed.source_type || 'Unbekannt'));
if (firstFeed.language) lines.push('Sprache: ' + firstFeed.language);
if (firstFeed.country_code) lines.push('Land: ' + firstFeed.country_code);
if (firstFeed.media_type && firstFeed.media_type !== 'sonstige') {
lines.push('Medientyp: ' + (this._mediaTypeLabels[firstFeed.media_type] || firstFeed.media_type));
}
if (firstFeed.political_orientation && firstFeed.political_orientation !== 'na') {
const pl = this._politicalLabels[firstFeed.political_orientation];
lines.push('Politisch: ' + (pl ? pl.full : firstFeed.political_orientation));
}
if (firstFeed.reliability && firstFeed.reliability !== 'na') {
const relLabel = this._reliabilityLabels[firstFeed.reliability] || firstFeed.reliability;
const relSrc = firstFeed.ifcn_signatory ? ' (IFCN-Faktenchecker)'
: (firstFeed.eu_disinfo_listed ? ` (EU-Desinfo, ${firstFeed.eu_disinfo_case_count || 0} Fälle)`
: ' (LLM-Schätzung)');
lines.push('Glaubwürdigkeit: ' + relLabel + relSrc);
}
if (firstFeed.ifcn_signatory) lines.push('IFCN-Faktenchecker: ja');
if (firstFeed.eu_disinfo_listed) {
lines.push(`EUvsDisinfo: ${firstFeed.eu_disinfo_case_count || 0} Fälle` + (firstFeed.eu_disinfo_last_seen ? ` (zuletzt ${firstFeed.eu_disinfo_last_seen})` : ''));
}
if (firstFeed.state_affiliated) lines.push('Staatsnah: ja');
if (Array.isArray(firstFeed.alignments) && firstFeed.alignments.length > 0) {
const labels = firstFeed.alignments.map(a => this._alignmentLabels[a] || a);
lines.push('Geopolitische Nähe: ' + labels.join(', '));
}
if (firstFeed.bias) lines.push('Notiz: ' + firstFeed.bias);
if (firstFeed.bias) lines.push('Ausrichtung: ' + firstFeed.bias);
const tooltipText = this.escape(lines.join('\n'));
infoButtonHtml = ` <span class="info-icon tooltip-below" data-tooltip="${tooltipText}"><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>`;
}
const classificationBadges = this._renderClassificationBadges(firstFeed);
return `<div class="source-group">
<div class="source-group-header" ${toggleAttr}>
${toggleIcon}
@@ -1262,7 +1124,6 @@ const UI = {
<span class="source-group-name">${this.escape(displayName)}</span>${infoButtonHtml}
</div>
<span class="source-category-badge cat-${feeds[0]?.category || 'sonstige'}">${catLabel}</span>
${classificationBadges ? `<span class="source-classification-badges">${classificationBadges}</span>` : ''}
${feedCountBadge}
<div class="source-group-actions" onclick="event.stopPropagation()">
${!isGlobal && !hasMultiple && feeds[0]?.id ? `<button class="source-edit-btn" onclick="App.editSource(${feeds[0].id})" title="Bearbeiten" aria-label="Bearbeiten">&#9998;</button>` : ''}

Datei anzeigen

@@ -19,7 +19,6 @@ const Pipeline = {
_incidentId: null,
_definition: null, // PIPELINE_STEPS vom Backend
_stateByKey: {}, // step_key -> {status, count_value, count_secondary, pass_number}
_snapshotState: null, // deep-copy von _stateByKey vor Refresh-Start (fuer Cancel-Restore)
_isResearch: false,
_passTotal: 1,
_lastRefreshHeader: null,
@@ -43,11 +42,10 @@ const Pipeline = {
if (this._wsBound) return;
if (typeof WS !== 'undefined' && WS.on) {
WS.on('pipeline_step', (msg) => this._onWsStep(msg));
// Erfolg: API-State neu laden (finaler Stand sichtbar)
WS.on('refresh_complete', (msg) => this._onRefreshDoneSuccess(msg));
// Cancel/Error: vor-Refresh-Snapshot zurueckspielen, damit Pipeline nicht im Mix-Zustand stehen bleibt
WS.on('refresh_cancelled', (msg) => this._onRefreshDoneCancel(msg));
WS.on('refresh_error', (msg) => this._onRefreshDoneError(msg));
// Bei Refresh-Complete den finalen Stand neu laden, damit Zahlen gefroren sichtbar bleiben
WS.on('refresh_complete', (msg) => this._onRefreshDone(msg));
WS.on('refresh_cancelled', (msg) => this._onRefreshDone(msg));
WS.on('refresh_error', (msg) => this._onRefreshDone(msg));
this._wsBound = true;
}
// Hover-Tooltip-Element vorbereiten
@@ -70,7 +68,6 @@ const Pipeline = {
async bindToIncident(incidentId) {
this._incidentId = incidentId;
this._stateByKey = {};
this._snapshotState = null; // Snapshot ist immer lagen-spezifisch
this._isResearch = false;
this._passTotal = 1;
this._lastRefreshHeader = null;
@@ -104,20 +101,6 @@ const Pipeline = {
this._render();
this._renderMini();
// Edge-Case: Lage ist gerade in Queue (z.B. via Lagen-Wechsel beim
// Klick in der Sidebar). API liefert den LETZTEN gespeicherten Stand
// (alles done = gruen), aber tatsaechlich wartet ein neuer Refresh.
// -> beginQueue() selbst ausloesen, damit Icons grau zeigen.
try {
if (typeof App !== 'undefined' && App._refreshingIncidents
&& App._refreshingIncidents.has(incidentId)
&& typeof UI !== 'undefined' && UI._progressState
&& UI._progressState[incidentId]
&& UI._progressState[incidentId].step === 'queued') {
this.beginQueue(incidentId);
}
} catch (e) { /* tolerant */ }
} catch (e) {
console.warn('Pipeline laden fehlgeschlagen:', e);
this._renderEmpty('Pipeline-Daten konnten nicht geladen werden.');
@@ -158,90 +141,30 @@ const Pipeline = {
}
}
// Wenn der ERSTE Schritt (sources_review) auf "active" geht, beginnt ein neuer
// Refresh oder ein neuer Multi-Pass-Durchlauf — alle nachfolgenden Schritte auf
// "pending" (grau) zuruecksetzen, damit der User sieht: das ist neu und
// noch nicht durchlaufen. Sonst stehen sie als "done" vom letzten Mal da.
let didReset = false;
// Wenn ein neuer Pass startet (pass_number > prev und status="active" beim ERSTEN step):
// alle Schritte zurück auf pending setzen, damit die Animation neu durchläuft.
if (d.status === 'active' && this._definition && this._definition.length
&& key === this._definition[0].key) {
&& key === this._definition[0].key && passNr > 1 && (!prev || prev.pass_number < passNr)) {
// Alle anderen Steps in "pending" zurueck (visuell), Werte behalten wir
this._definition.forEach(s => {
if (s.key !== key && this._stateByKey[s.key]) {
this._stateByKey[s.key].status = 'pending';
didReset = true;
}
});
}
if (didReset) {
// Beim Reset alle Bloecke neu zeichnen, nicht nur den aktuellen
this._render();
this._renderMini();
} else {
this._patchBlock(key);
this._patchMiniBlock(key);
}
this._patchBlock(key);
this._patchMiniBlock(key);
},
/**
* Wird vom Frontend gerufen, wenn ein Refresh angestossen wurde (queued).
* Macht einen Snapshot des aktuellen Pipeline-Stands (zur spaeteren Wiederherstellung
* bei Cancel/Error) und setzt dann alle Steps auf "pending" - damit der User sieht:
* "neuer Refresh laeuft an, alte gruene Haekchen sind nicht mehr aktuell".
*/
beginQueue(incidentId) {
if (this._incidentId !== incidentId) return; // andere Lage offen
if (!this._definition) return; // noch keine Pipeline-Definition geladen
// Aktuellen Stand sichern (deep-copy). Bei Mehrfach-Refresh ohne Cancel
// dazwischen wird der Snapshot bewusst ueberschrieben - er soll immer
// der "Stand kurz vor diesem Refresh" sein.
this._snapshotState = JSON.parse(JSON.stringify(this._stateByKey));
// Alle Steps auf pending setzen
this._definition.forEach(s => {
if (this._stateByKey[s.key]) {
this._stateByKey[s.key].status = 'pending';
} else {
this._stateByKey[s.key] = { status: 'pending', count_value: null, count_secondary: null, pass_number: 1 };
}
});
this._render();
this._renderMini();
},
/** Restauriert den letzten Snapshot. Rueckgabe: true bei Erfolg, false wenn keiner da war. */
_restoreSnapshot() {
if (!this._snapshotState) return false;
this._stateByKey = this._snapshotState;
this._snapshotState = null;
this._render();
this._renderMini();
return true;
},
_onRefreshDoneSuccess(msg) {
_onRefreshDone(msg) {
if (this._incidentId == null || (msg && msg.incident_id !== this._incidentId)) return;
this._snapshotState = null; // verworfen, neuer Stand wird vom API geladen
// Daten frisch nachladen, damit Header (Dauer) und finale Zahlen passen
setTimeout(() => {
if (this._incidentId != null) this.bindToIncident(this._incidentId);
}, 600);
},
_onRefreshDoneCancel(msg) {
if (this._incidentId == null || (msg && msg.incident_id !== this._incidentId)) return;
if (!this._restoreSnapshot()) {
// Kein Snapshot vorhanden (z.B. Page-Reload mitten im Refresh) -> wie bisher API-Reload
setTimeout(() => {
if (this._incidentId != null) this.bindToIncident(this._incidentId);
}, 600);
}
},
_onRefreshDoneError(msg) {
// Wie Cancel: vorheriger Stand zurueck (nicht im Mix-Zustand stehenbleiben)
this._onRefreshDoneCancel(msg);
},
/** Vollbild-Pipeline (Tab "Analysepipeline") als 3x3-Snake rendern. */
_render() {
const stage = document.getElementById('pipeline-stage');