Commits vergleichen
57 Commits
3f97aa63e9
...
main
| Autor | SHA1 | Datum | |
|---|---|---|---|
| 7d9bca12ee | |||
|
|
3e64539aa3 | ||
|
|
1647a6f50a | ||
|
|
c53e260c6c | ||
| c3a0ee4538 | |||
|
|
e20b3de0fa | ||
| aa36a9a38f | |||
|
|
d570e13dc6 | ||
|
|
7777b77abd | ||
| b02578e48b | |||
|
|
952df87afa | ||
| 38ce26f0be | |||
| 7f7b30c1d6 | |||
|
|
d986d611cf | ||
| 7954a78964 | |||
|
|
453c505a7e | ||
| 0b335263c9 | |||
|
|
279df0f56b | ||
| 889044cc3b | |||
|
|
0c34f67194 | ||
| 64f9841240 | |||
|
|
1b8961ca12 | ||
| 773715a38e | |||
|
|
f69fa1b95e | ||
| f1a395bb94 | |||
|
|
a0f4572a01 | ||
| 9598063728 | |||
|
|
cc1f9af273 | ||
| a61e45f752 | |||
| 3f45ae66df | |||
|
|
9c50439785 | ||
| f1200743e6 | |||
| 86b12a156e | |||
| 002584bdb1 | |||
| 309c97f40a | |||
| 51276af97a | |||
| 4e9d9f92f1 | |||
| 14b98b59e0 | |||
| 0e4c78d50a | |||
| f7fc09c864 | |||
| 16d1133442 | |||
| d65f0180d9 | |||
| 379d14518c | |||
| 7fe62df529 | |||
|
|
75038939b4 | ||
| 23a709f3d5 | |||
| 3196424ec9 | |||
|
|
a41c8ae529 | ||
| dd6a7d66a4 | |||
| 4b193d5784 | |||
| 74f50c3b6e | |||
| b4898614c4 | |||
| 10606dba95 | |||
| 3345743aa5 | |||
| 2cfc14b264 | |||
|
|
168fbc3987 | ||
|
|
e68386f6bb |
@@ -1,4 +1,29 @@
|
|||||||
[
|
[
|
||||||
|
{
|
||||||
|
"version": "2026-05-22T19:10Z",
|
||||||
|
"date": "2026-05-22",
|
||||||
|
"title": "Exportdialog: Ersteller manuell eintragbar",
|
||||||
|
"items": [
|
||||||
|
"Im Export-Dialog kann der Ersteller jetzt manuell eingegeben werden."
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"version": "2026-05-22T07:41Z",
|
||||||
|
"date": "2026-05-22",
|
||||||
|
"title": "X (Twitter) als neue Informationsquelle verfügbar",
|
||||||
|
"items": [
|
||||||
|
"Nachrichten und Beiträge von X (Twitter) können jetzt als Quelle für Lageberichte genutzt werden."
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"version": "2026-05-21T17:10Z",
|
||||||
|
"date": "2026-05-21",
|
||||||
|
"title": "Sprachunterstützung für Artikel-Überschriften verbessert",
|
||||||
|
"items": [
|
||||||
|
"Englische Überschriften werden jetzt korrekt gespeichert und angezeigt.",
|
||||||
|
"Die Sprache eines Artikels wird automatisch aus der jeweiligen Quelle übernommen."
|
||||||
|
]
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"version": "2026-05-13T22:38Z",
|
"version": "2026-05-13T22:38Z",
|
||||||
"date": "2026-05-13",
|
"date": "2026-05-13",
|
||||||
|
|||||||
@@ -11,8 +11,15 @@ python-multipart
|
|||||||
aiosmtplib
|
aiosmtplib
|
||||||
geonamescache>=2.0
|
geonamescache>=2.0
|
||||||
telethon
|
telethon
|
||||||
|
# X/Twitter-Scraper (feeds/x_parser.py)
|
||||||
|
twscrape @ git+https://github.com/vladkens/twscrape.git@206f0942fe41149da28530399f7c772ec00be17a
|
||||||
# Bericht-Export (PDF via WeasyPrint + DOCX via python-docx)
|
# Bericht-Export (PDF via WeasyPrint + DOCX via python-docx)
|
||||||
Jinja2>=3.1
|
Jinja2>=3.1
|
||||||
weasyprint>=68.0
|
weasyprint>=68.0
|
||||||
python-docx>=1.2
|
python-docx>=1.2
|
||||||
pikepdf>=9.0
|
pikepdf>=9.0
|
||||||
|
# PDF-Quellen (Ingestion)
|
||||||
|
pdfplumber>=0.11
|
||||||
|
pytesseract>=0.3
|
||||||
|
pdf2image>=1.17
|
||||||
|
Pillow>=10.0
|
||||||
|
|||||||
34
scripts/migrate_pdf_source.py
Normale Datei
34
scripts/migrate_pdf_source.py
Normale Datei
@@ -0,0 +1,34 @@
|
|||||||
|
"""Idempotente Migration: Quellen-Typ pdf_document + EN-Spalten in articles.
|
||||||
|
|
||||||
|
Beim Live-Promote anwenden:
|
||||||
|
python3 scripts/migrate_pdf_source.py /home/claude-dev/osint-data/osint.db
|
||||||
|
"""
|
||||||
|
import sqlite3
|
||||||
|
import sys
|
||||||
|
|
||||||
|
|
||||||
|
def add_col(db, table, col_def):
|
||||||
|
name = col_def.split()[0]
|
||||||
|
cols = {r[1] for r in db.execute(f"PRAGMA table_info({table})").fetchall()}
|
||||||
|
if name in cols:
|
||||||
|
return False
|
||||||
|
db.execute(f"ALTER TABLE {table} ADD COLUMN {col_def}")
|
||||||
|
return True
|
||||||
|
|
||||||
|
|
||||||
|
def main(path):
|
||||||
|
with sqlite3.connect(path) as db:
|
||||||
|
for col in ("pdf_path TEXT", "pdf_sha256 TEXT", "processed_at TIMESTAMP"):
|
||||||
|
print(f"sources.{col.split()[0]}:", "added" if add_col(db, "sources", col) else "exists")
|
||||||
|
for col in ("headline_en TEXT", "content_en TEXT"):
|
||||||
|
print(f"articles.{col.split()[0]}:", "added" if add_col(db, "articles", col) else "exists")
|
||||||
|
db.execute("CREATE INDEX IF NOT EXISTS idx_sources_pdf_sha256 ON sources(pdf_sha256)")
|
||||||
|
db.commit()
|
||||||
|
print("DONE")
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
if len(sys.argv) != 2:
|
||||||
|
print("Usage: migrate_pdf_source.py /path/to/osint.db")
|
||||||
|
sys.exit(1)
|
||||||
|
main(sys.argv[1])
|
||||||
@@ -124,7 +124,7 @@ BISHERIGE QUELLEN:
|
|||||||
AUFTRAG:
|
AUFTRAG:
|
||||||
1. Aktualisiere das Lagebild basierend auf den neuen Meldungen. Das Lagebild soll so ausführlich wie nötig sein, um alle wesentlichen Themenstränge abzudecken
|
1. Aktualisiere das Lagebild basierend auf den neuen Meldungen. Das Lagebild soll so ausführlich wie nötig sein, um alle wesentlichen Themenstränge abzudecken
|
||||||
2. Behalte bestätigte Fakten aus dem bisherigen Lagebild bei
|
2. Behalte bestätigte Fakten aus dem bisherigen Lagebild bei
|
||||||
3. Ergänze neue Erkenntnisse und markiere wichtige neue Entwicklungen
|
3. Arbeite neue Erkenntnisse direkt in den thematisch passenden Abschnitt ein. Erzeuge KEINE datierten Verlaufsblöcke wie "Neu am DD.MM." oder "Neu seit ...". Das Lagebild ist eine zusammenhängende thematische Darstellung des AKTUELLEN Stands, kein chronologisches Änderungsprotokoll. Die zeitliche Abfolge der jüngsten Ereignisse wird separat in der Kachel "Neueste Entwicklungen" gepflegt und darf hier NICHT als Datums-Changelog dupliziert werden
|
||||||
4. Aktualisiere die Quellenverweise — neue Quellen bekommen fortlaufende Nummern nach den bisherigen
|
4. Aktualisiere die Quellenverweise — neue Quellen bekommen fortlaufende Nummern nach den bisherigen
|
||||||
5. Entferne nur nachweislich widerlegte Informationen. Behalte alle thematischen Abschnitte bei, auch wenn sie nicht durch neue Meldungen aktualisiert werden
|
5. Entferne nur nachweislich widerlegte Informationen. Behalte alle thematischen Abschnitte bei, auch wenn sie nicht durch neue Meldungen aktualisiert werden
|
||||||
|
|
||||||
@@ -133,6 +133,8 @@ STRUKTUR:
|
|||||||
- Wenn sich Daten strukturiert vergleichen lassen (z.B. Produkte, Unternehmen, Kennzahlen, Modelle), verwende eine Markdown-Tabelle (| Spalte1 | Spalte2 | ... mit Trennzeile |---|---|)
|
- Wenn sich Daten strukturiert vergleichen lassen (z.B. Produkte, Unternehmen, Kennzahlen, Modelle), verwende eine Markdown-Tabelle (| Spalte1 | Spalte2 | ... mit Trennzeile |---|---|)
|
||||||
- KEIN Fettdruck (**) verwenden
|
- KEIN Fettdruck (**) verwenden
|
||||||
- ERZEUGE KEINE Sektion "## ZUSAMMENFASSUNG", "## ÜBERBLICK" oder "## KERNPUNKTE". Falls das BISHERIGE LAGEBILD eine solche Sektion enthält, ENTFERNE sie vollständig beim Aktualisieren. Die neuesten Entwicklungen werden separat als eigene Kachel gepflegt und dürfen im Lagebild NICHT dupliziert werden.
|
- ERZEUGE KEINE Sektion "## ZUSAMMENFASSUNG", "## ÜBERBLICK" oder "## KERNPUNKTE". Falls das BISHERIGE LAGEBILD eine solche Sektion enthält, ENTFERNE sie vollständig beim Aktualisieren. Die neuesten Entwicklungen werden separat als eigene Kachel gepflegt und dürfen im Lagebild NICHT dupliziert werden.
|
||||||
|
- KEINE datierten Verlaufsmarker im Lagebild. Einleitungen wie "Neu am 31.05./01.06.:", "Neu seit gestern:" oder vergleichbare Datums-Changelog-Phrasen sind nicht erlaubt. Falls das BISHERIGE LAGEBILD solche Blöcke enthält, LÖSE SIE AUF: integriere ihren Inhalt in den thematisch passenden Abschnitt und ENTFERNE die "Neu am"-Einleitung samt reiner Datumsgruppierung restlos. Innerhalb eines Abschnitts steht der aktuelle Stand vorne, ältere Belege werden im Fließtext zeitlich eingeordnet (z.B. "Ende Mai berichtete ...").
|
||||||
|
- KEINE stichwortartigen Fragmente und KEINE blanken Quellennummern-Sammlungen. Verboten sind Telegramm-Verkürzungen wie "Teheran-Bluff-Vorwurf [2897]. NYT-Abraham-Accords [2890]." sowie Auffangblöcke ohne Aussage wie "Frühere Belege [2806][2807]...". Jede Quellennummer muss an einem vollständigen, eigenständigen Satz hängen. Falls das BISHERIGE LAGEBILD solche Fragment- oder Sammelblöcke enthält, formuliere sie zu vollständigen Sätzen aus oder lass die betreffende Quellennummer weg. Am Ende eines Abschnitts oder des Lagebildes darf KEINE reine Aufzählung von Quellennummern stehen.
|
||||||
|
|
||||||
REGELN:
|
REGELN:
|
||||||
- Neutral und sachlich - keine Wertungen oder Spekulationen
|
- Neutral und sachlich - keine Wertungen oder Spekulationen
|
||||||
@@ -258,7 +260,9 @@ REGELN:
|
|||||||
- Breit gefasste Lagen (z.B. "Iran-Israel-Krieg", "Ukrainekrieg – aktuelle Lage") akzeptieren alle Meldungen, die einen der direkt beteiligten Akteure oder Kriegsschauplätze behandeln.
|
- Breit gefasste Lagen (z.B. "Iran-Israel-Krieg", "Ukrainekrieg – aktuelle Lage") akzeptieren alle Meldungen, die einen der direkt beteiligten Akteure oder Kriegsschauplätze behandeln.
|
||||||
- Eng gefasste Lagen (z.B. "Russische Militärblogger", "Ausfall bei Cloudflare", "Cybervorfall Stadtwerke X") akzeptieren NUR Meldungen zum Spezifikum. Peripheres, auch wenn im selben Großkontext, wird abgelehnt.
|
- Eng gefasste Lagen (z.B. "Russische Militärblogger", "Ausfall bei Cloudflare", "Cybervorfall Stadtwerke X") akzeptieren NUR Meldungen zum Spezifikum. Peripheres, auch wenn im selben Großkontext, wird abgelehnt.
|
||||||
- Eine Meldung gilt auch dann als relevant, wenn sie das Thema aus einer gegnerischen/kritischen Perspektive behandelt — es geht um thematische Zugehörigkeit, nicht um Ausrichtung.
|
- Eine Meldung gilt auch dann als relevant, wenn sie das Thema aus einer gegnerischen/kritischen Perspektive behandelt — es geht um thematische Zugehörigkeit, nicht um Ausrichtung.
|
||||||
- Im Zweifel: NICHT relevant. Ein zu schmaler Filter ist besser als ein Schwall off-topic-Treffer.
|
- FREMDSPRACHIGE QUELLEN (CJK, Arabisch, Hebräisch, Kyrillisch): Wo verfügbar steht eine "Übersetzung:"-Zeile unter der Originalüberschrift. NUTZE die Übersetzung für deine Bewertung. Verwirf einen fremdsprachigen Artikel NICHT pauschal aus Sicherheit, wenn die Übersetzung das Lagethema sichtbar berührt — wende dieselben Maßstäbe an wie auf englische Artikel.
|
||||||
|
- Im Zweifel bei lateinisch geschriebenen Quellen: NICHT relevant. Im Zweifel bei nicht-lateinischen Quellen mit übersetzter, thematisch passender Überschrift: relevant.
|
||||||
|
- FOREN-QUELLEN ([FORUM]-Tag hinter dem Quellennamen, z.B. 5ch, Hatena, Note): WEICHER bewerten. Sie liefern keine Faktenlage, sondern Stimmungsmaterial fuer eine separate Kachel. Wenn das Lage-Keyword im Thread-Titel oder in der ersten Zeile des Inhalts vorkommt UND der Beitrag nicht offensichtlich off-topic ist (Hobby, Sport ohne Bezug, reine Werbung), DURCHLASSEN. Im Zweifel bei Foren-Quellen: relevant.
|
||||||
|
|
||||||
Antworte AUSSCHLIESSLICH als JSON-Objekt — KEINE Erklärung, KEINE Einleitung:
|
Antworte AUSSCHLIESSLICH als JSON-Objekt — KEINE Erklärung, KEINE Einleitung:
|
||||||
{{"relevant_ids": [1, 3, 7]}}"""
|
{{"relevant_ids": [1, 3, 7]}}"""
|
||||||
@@ -526,10 +530,21 @@ class AnalyzerAgent:
|
|||||||
headline = article.get("headline_de") or article.get("headline", "")
|
headline = article.get("headline_de") or article.get("headline", "")
|
||||||
source = article.get("source", "Unbekannt")
|
source = article.get("source", "Unbekannt")
|
||||||
content = article.get("content_de") or article.get("content_original") or ""
|
content = article.get("content_de") or article.get("content_original") or ""
|
||||||
lines.append(f"[{i}] Quelle: {source}")
|
# Pre-Topic-Translation für fremdsprachige Headlines (gesetzt vom Orchestrator)
|
||||||
|
headline_en = article.get("headline_en_for_topic")
|
||||||
|
content_en = article.get("content_en_for_topic")
|
||||||
|
# Foren-Quellen explizit markieren, damit Haiku sie weicher bewertet
|
||||||
|
# (Stimmungs-Material, nicht Faktenlage — eigener Filter-Modus im Prompt)
|
||||||
|
is_forum = (article.get("media_type") or "").lower() == "forum"
|
||||||
|
source_label = f"{source} [FORUM]" if is_forum else source
|
||||||
|
lines.append(f"[{i}] Quelle: {source_label}")
|
||||||
lines.append(f" Überschrift: {headline}")
|
lines.append(f" Überschrift: {headline}")
|
||||||
|
if headline_en and headline_en.strip().lower() != (headline or "").strip().lower():
|
||||||
|
lines.append(f" Übersetzung: {headline_en}")
|
||||||
if content:
|
if content:
|
||||||
lines.append(f" Inhalt: {content[:400]}")
|
lines.append(f" Inhalt: {content[:400]}")
|
||||||
|
if content_en and content_en.strip().lower() != (content or "")[:len(content_en)].strip().lower():
|
||||||
|
lines.append(f" Inhalt (EN): {content_en[:400]}")
|
||||||
articles_text = "\n".join(lines)
|
articles_text = "\n".join(lines)
|
||||||
|
|
||||||
prompt = TOPIC_FILTER_PROMPT_TEMPLATE.format(
|
prompt = TOPIC_FILTER_PROMPT_TEMPLATE.format(
|
||||||
@@ -558,7 +573,10 @@ class AnalyzerAgent:
|
|||||||
}
|
}
|
||||||
filtered = [a for i, a in enumerate(articles, 1) if i in relevant_set]
|
filtered = [a for i, a in enumerate(articles, 1) if i in relevant_set]
|
||||||
|
|
||||||
rejected = len(articles) - len(filtered)
|
rejected_articles = [
|
||||||
|
(idx, a) for idx, a in enumerate(articles, 1) if idx not in relevant_set
|
||||||
|
]
|
||||||
|
rejected = len(rejected_articles)
|
||||||
if not filtered and articles:
|
if not filtered and articles:
|
||||||
logger.warning(
|
logger.warning(
|
||||||
f"Topic-Filter hat ALLE {len(articles)} Artikel verworfen — "
|
f"Topic-Filter hat ALLE {len(articles)} Artikel verworfen — "
|
||||||
@@ -570,6 +588,14 @@ class AnalyzerAgent:
|
|||||||
f"Topic-Filter: {len(filtered)}/{len(articles)} Artikel thematisch relevant "
|
f"Topic-Filter: {len(filtered)}/{len(articles)} Artikel thematisch relevant "
|
||||||
f"({rejected} verworfen)"
|
f"({rejected} verworfen)"
|
||||||
)
|
)
|
||||||
|
for idx, a in rejected_articles:
|
||||||
|
src = a.get("source", "Unbekannt")
|
||||||
|
hl = (a.get("headline_de") or a.get("headline") or "").strip()
|
||||||
|
hl_en = (a.get("headline_en_for_topic") or "").strip()
|
||||||
|
if hl_en and hl_en.lower() != hl.lower():
|
||||||
|
logger.info("Topic-Filter REJECT [%d] %s | %s | EN: %s", idx, src, hl[:120], hl_en[:120])
|
||||||
|
else:
|
||||||
|
logger.info("Topic-Filter REJECT [%d] %s | %s", idx, src, hl[:120])
|
||||||
return filtered, usage
|
return filtered, usage
|
||||||
|
|
||||||
async def generate_latest_developments(
|
async def generate_latest_developments(
|
||||||
@@ -648,6 +674,246 @@ class AnalyzerAgent:
|
|||||||
logger.info(f"Latest-Developments: {len(bullets)} Bullets aus Lagebild generiert")
|
logger.info(f"Latest-Developments: {len(bullets)} Bullets aus Lagebild generiert")
|
||||||
return output, usage
|
return output, usage
|
||||||
|
|
||||||
|
async def moderate_forum_articles(
|
||||||
|
self,
|
||||||
|
forum_articles: list[dict],
|
||||||
|
) -> tuple[list[dict], ClaudeUsage | None]:
|
||||||
|
"""Vorab-Moderation fuer Foren-Beitraege (5ch, Hatena, Note ...).
|
||||||
|
|
||||||
|
Schickt eine Batch von bis zu 25 Foren-Beitraegen an Haiku, der pro
|
||||||
|
Beitrag entscheidet:
|
||||||
|
- "publishable" -> Beitrag wird unveraendert in die Stimmungs-Kachel uebernommen.
|
||||||
|
- "redact" -> der Beitrag bleibt, aber sein Content wird auf eine kurze,
|
||||||
|
entschaerfte Version reduziert (Klarnamen, persoenliche Daten, persoenliche
|
||||||
|
Beleidigungen entfernt). Die Headline darf bleiben, wenn sie selbst clean ist.
|
||||||
|
- "discard" -> Beitrag wird aus der Liste entfernt (Hassrede gegen Gruppen,
|
||||||
|
NSFW, glaubhafte Drohungen, doxxing).
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
(gefilterte_liste, usage) — die Liste enthaelt publishable + redacted
|
||||||
|
Artikel (in Original-Reihenfolge). Discarded werden weggeworfen. Bei
|
||||||
|
API-/Parse-Fehler wird die Originalliste unveraendert zurueckgegeben
|
||||||
|
(Fail-Open, damit die Pipeline nicht hartfaellt — Haiku im Prompt
|
||||||
|
erinnert nochmal an Moderation).
|
||||||
|
"""
|
||||||
|
if not forum_articles:
|
||||||
|
return forum_articles, None
|
||||||
|
|
||||||
|
from config import CLAUDE_MODEL_FAST
|
||||||
|
|
||||||
|
# Pro Aufruf nicht mehr als 25 Beitraege (Token-Budget)
|
||||||
|
if len(forum_articles) > 25:
|
||||||
|
# In Batches verarbeiten, akkumulieren
|
||||||
|
kept: list[dict] = []
|
||||||
|
total_usage: ClaudeUsage | None = None
|
||||||
|
for i in range(0, len(forum_articles), 25):
|
||||||
|
batch = forum_articles[i:i + 25]
|
||||||
|
batch_kept, batch_usage = await self.moderate_forum_articles(batch)
|
||||||
|
kept.extend(batch_kept)
|
||||||
|
if batch_usage:
|
||||||
|
if total_usage is None:
|
||||||
|
total_usage = batch_usage
|
||||||
|
else:
|
||||||
|
try:
|
||||||
|
total_usage.add(batch_usage) # type: ignore[attr-defined]
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
return kept, total_usage
|
||||||
|
|
||||||
|
items = []
|
||||||
|
for i, a in enumerate(forum_articles):
|
||||||
|
headline = (a.get("headline_de") or a.get("headline_en_for_topic") or a.get("headline") or "").strip()
|
||||||
|
content = (a.get("content_de") or a.get("content_en_for_topic") or a.get("content_original") or "").strip()
|
||||||
|
items.append({
|
||||||
|
"i": i,
|
||||||
|
"source": (a.get("source") or "Forum").strip(),
|
||||||
|
"headline": headline[:200],
|
||||||
|
"content": content[:600],
|
||||||
|
})
|
||||||
|
|
||||||
|
prompt = f"""Du bist ein Moderations-Agent fuer ANONYME FOREN-/COMMUNITY-BEITRAEGE (5ch, Hatena, Note).
|
||||||
|
Diese Beitraege gehen in eine Stimmungs-Kachel eines OSINT-Lagemonitorings ein, das auch von Behoerden gelesen werden kann.
|
||||||
|
|
||||||
|
Pro Beitrag entscheide:
|
||||||
|
- "publishable": Beitrag ist sachlich-bezogen, ohne Hassrede gegen Gruppen, ohne Klarnamen Dritter, ohne sexuelle Inhalte, ohne Drohungen. Keine Aenderung noetig.
|
||||||
|
- "redact": Beitrag ist im Kern thematisch wertvoll, enthaelt aber persoenliche Daten, persoenliche Beleidigungen oder Klarnamen Dritter. Gib eine bereinigte Kurzfassung des Inhalts (1-3 Saetze) zurueck, die das thematische Argument behaelt aber alle PII/Beleidigungen entfernt.
|
||||||
|
- "discard": Beitrag ist Hassrede gegen ethnische/religioese/sexuelle Gruppen, NSFW, glaubhafte Drohung, oder reines Trolling ohne Themenbezug.
|
||||||
|
|
||||||
|
EINGABE:
|
||||||
|
{json.dumps(items, ensure_ascii=False)}
|
||||||
|
|
||||||
|
Antworte AUSSCHLIESSLICH mit einem JSON-Array. Pro Beitrag genau ein Objekt:
|
||||||
|
[
|
||||||
|
{{"i": 0, "decision": "publishable"}},
|
||||||
|
{{"i": 1, "decision": "redact", "clean_content": "Kurzfassung ohne PII."}},
|
||||||
|
{{"i": 2, "decision": "discard"}}
|
||||||
|
]
|
||||||
|
|
||||||
|
Keine Erklaerung, keine Einleitung, kein Markdown, nur das Array."""
|
||||||
|
|
||||||
|
try:
|
||||||
|
result, usage = await call_claude(prompt, tools=None, model=CLAUDE_MODEL_FAST)
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning("Forum-Moderation Claude-Call fehlgeschlagen, fail-open: %s", e)
|
||||||
|
return forum_articles, None
|
||||||
|
|
||||||
|
# Robustes JSON-Parsing
|
||||||
|
text = (result or "").strip()
|
||||||
|
if text.startswith("```"):
|
||||||
|
text = re.sub(r"^```(?:json)?\s*", "", text)
|
||||||
|
text = re.sub(r"\s*```\s*$", "", text)
|
||||||
|
text = text.strip()
|
||||||
|
try:
|
||||||
|
decisions = json.loads(text)
|
||||||
|
except json.JSONDecodeError:
|
||||||
|
m = re.search(r"\[.*\]", text, re.DOTALL)
|
||||||
|
if m:
|
||||||
|
try:
|
||||||
|
decisions = json.loads(m.group(0))
|
||||||
|
except json.JSONDecodeError:
|
||||||
|
decisions = None
|
||||||
|
else:
|
||||||
|
decisions = None
|
||||||
|
if not isinstance(decisions, list):
|
||||||
|
logger.warning("Forum-Moderation: kein JSON-Array, fail-open. Sample: %r", text[:200])
|
||||||
|
return forum_articles, usage
|
||||||
|
|
||||||
|
decision_map: dict[int, dict] = {}
|
||||||
|
for d in decisions:
|
||||||
|
if isinstance(d, dict) and isinstance(d.get("i"), int):
|
||||||
|
decision_map[d["i"]] = d
|
||||||
|
|
||||||
|
kept: list[dict] = []
|
||||||
|
stats = {"publishable": 0, "redact": 0, "discard": 0, "unknown": 0}
|
||||||
|
for i, art in enumerate(forum_articles):
|
||||||
|
d = decision_map.get(i)
|
||||||
|
if not d:
|
||||||
|
# Keine Entscheidung fuer diesen Beitrag -> als publishable behandeln (fail-open)
|
||||||
|
kept.append(art)
|
||||||
|
stats["unknown"] += 1
|
||||||
|
continue
|
||||||
|
decision = (d.get("decision") or "").strip().lower()
|
||||||
|
if decision == "discard":
|
||||||
|
stats["discard"] += 1
|
||||||
|
continue
|
||||||
|
if decision == "redact":
|
||||||
|
clean = (d.get("clean_content") or "").strip()
|
||||||
|
if clean:
|
||||||
|
new_art = dict(art)
|
||||||
|
new_art["content_original"] = clean
|
||||||
|
new_art["content_de"] = clean if (art.get("content_de") or "") else None
|
||||||
|
new_art["_moderation"] = "redacted"
|
||||||
|
kept.append(new_art)
|
||||||
|
stats["redact"] += 1
|
||||||
|
continue
|
||||||
|
# Redact ohne clean_content -> sicherheitshalber discard
|
||||||
|
stats["discard"] += 1
|
||||||
|
continue
|
||||||
|
# Default / "publishable"
|
||||||
|
kept.append(art)
|
||||||
|
stats["publishable"] += 1
|
||||||
|
|
||||||
|
logger.info(
|
||||||
|
"Forum-Moderation: %d publishable, %d redacted, %d discarded, %d ohne Entscheidung",
|
||||||
|
stats["publishable"], stats["redact"], stats["discard"], stats["unknown"],
|
||||||
|
)
|
||||||
|
return kept, usage
|
||||||
|
|
||||||
|
async def generate_public_mood(
|
||||||
|
self,
|
||||||
|
title: str,
|
||||||
|
description: str,
|
||||||
|
forum_articles: list[dict],
|
||||||
|
output_language: str = "Deutsch",
|
||||||
|
) -> tuple[str | None, ClaudeUsage | None]:
|
||||||
|
"""Generiert die Kachel 'Öffentliche Stimmung' aus Foren-Quellen.
|
||||||
|
|
||||||
|
Eingabe: Artikel mit media_type='forum' (5ch-Threads, Hatena-Bookmarks,
|
||||||
|
Note-Trending-Posts etc.). Ausgabe: 3-6 Markdown-Bullets, jeder Bullet
|
||||||
|
fasst ein dominantes Thema/eine Bruchlinie der Diskussion zusammen und
|
||||||
|
nennt explizit die Quellen-Herkunft (z.B. "Auf 5ch /seiji/ ueberwiegen
|
||||||
|
ablehnende Stimmen ...").
|
||||||
|
|
||||||
|
WICHTIG: Das ist Stimmungsmaterial, NICHT Faktenlage. Der Prompt weist
|
||||||
|
Claude explizit an, Eigenaussagen aus Foren nicht als Fakt zu zitieren.
|
||||||
|
|
||||||
|
Returns: (markdown_text, usage) oder (None, usage) bei leerer/kaputter
|
||||||
|
Antwort. Bei keinen Foren-Artikeln: (None, None).
|
||||||
|
"""
|
||||||
|
if not forum_articles:
|
||||||
|
return None, None
|
||||||
|
|
||||||
|
from config import CLAUDE_MODEL_FAST
|
||||||
|
|
||||||
|
# Pro Quelle gruppieren, damit Claude die Herkunft kennt
|
||||||
|
by_source: dict[str, list[dict]] = {}
|
||||||
|
for a in forum_articles:
|
||||||
|
src = (a.get("source") or "Forum (unbekannt)").strip()
|
||||||
|
by_source.setdefault(src, []).append(a)
|
||||||
|
|
||||||
|
# Artikel-Block bauen, kompakt aber mit Herkunft
|
||||||
|
lines: list[str] = []
|
||||||
|
for src, items in by_source.items():
|
||||||
|
lines.append(f"\n=== Quelle: {src} ({len(items)} Beitrag/-e) ===")
|
||||||
|
for it in items[:15]: # max 15 pro Quelle, sonst sprengt das den Prompt
|
||||||
|
headline = it.get("headline_de") or it.get("headline_en_for_topic") or it.get("headline", "")
|
||||||
|
content = (
|
||||||
|
it.get("content_de")
|
||||||
|
or it.get("content_en_for_topic")
|
||||||
|
or it.get("content_original")
|
||||||
|
or ""
|
||||||
|
)
|
||||||
|
lines.append(f"- {headline[:200]}")
|
||||||
|
if content:
|
||||||
|
lines.append(f" {content[:300]}")
|
||||||
|
articles_block = "\n".join(lines)
|
||||||
|
|
||||||
|
prompt = f"""Du bist ein OSINT-Analyst. Aus den folgenden ANONYMEN FOREN-/COMMUNITY-BEITRAEGEN sollst du das Stimmungsbild der oeffentlichen Online-Diskussion fuer eine Lage extrahieren.
|
||||||
|
|
||||||
|
LAGE: {title}
|
||||||
|
KONTEXT: {description}
|
||||||
|
|
||||||
|
FOREN-BEITRAEGE (gruppiert nach Quelle):
|
||||||
|
{articles_block}
|
||||||
|
|
||||||
|
AUFGABE:
|
||||||
|
Erstelle eine kompakte Themen-Zusammenfassung in {output_language}: 3-6 Markdown-Bullet-Points, jeder Bullet fasst ein dominantes Thema, eine Forderung oder eine Bruchlinie der Diskussion zusammen. Pro Bullet 1-3 Saetze.
|
||||||
|
|
||||||
|
REGELN:
|
||||||
|
- DIES IST KEINE FAKTENLAGE. Du fasst zusammen, wie online diskutiert wird, nicht was wahr ist.
|
||||||
|
- Quellen-Herkunft je Bullet EXPLIZIT nennen ("auf 5ch /seiji/ ueberwiegen ablehnende Reaktionen...", "Hatena-Kommentare betonen ueberwiegend ...", "Note-Autoren schreiben ueberwiegend ...").
|
||||||
|
- KEINE Eigenaussagen aus Forenposts als Faktenbehauptung uebernehmen.
|
||||||
|
- KEINE Klarnamen, persoenliche Daten oder Beleidigungen Dritter zitieren.
|
||||||
|
- Bei klaren Pro-/Contra-Lagern beide Seiten beschreiben.
|
||||||
|
- Wenn das Material zu duenn oder off-topic ist, gib explizit "Material zu duenn fuer Stimmungsbild" zurueck statt zu spekulieren.
|
||||||
|
- Markdown: nur "- " Bullets, keine Ueberschriften, kein Fettdruck, keine Inline-Quellenverweise [1].
|
||||||
|
- KEINE Gedankenstriche (—, –) verwenden — stattdessen Kommas, Doppelpunkte oder neue Saetze.
|
||||||
|
- Verwende IMMER echte UTF-8-Umlaute (ä, ö, ü, ß) — NIEMALS Umschreibungen (ae, oe, ue, ss).
|
||||||
|
|
||||||
|
Antworte AUSSCHLIESSLICH mit dem Markdown-Text der Bullets, ohne Einleitung, ohne Erklaerung."""
|
||||||
|
|
||||||
|
try:
|
||||||
|
result, usage = await call_claude(prompt, tools=None, model=CLAUDE_MODEL_FAST)
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning(f"Public-Mood Claude-Call fehlgeschlagen: {e}")
|
||||||
|
return None, None
|
||||||
|
|
||||||
|
text = (result or "").strip()
|
||||||
|
if not text or "zu duenn" in text.lower() or "too thin" in text.lower():
|
||||||
|
logger.info("Public-Mood: Material zu duenn, kein Stimmungsbild generiert")
|
||||||
|
return None, usage
|
||||||
|
|
||||||
|
# Sanity-Check: mindestens 1 Bullet (- am Zeilenanfang)
|
||||||
|
if not any(line.lstrip().startswith("-") for line in text.split("\n")):
|
||||||
|
logger.warning("Public-Mood: Claude-Antwort enthaelt keine Bullets, Sample: %r", text[:200])
|
||||||
|
return None, usage
|
||||||
|
|
||||||
|
logger.info(
|
||||||
|
"Public-Mood: %d Forum-Beitraege aus %d Quellen zu Stimmungsbild zusammengefasst",
|
||||||
|
len(forum_articles), len(by_source),
|
||||||
|
)
|
||||||
|
return text, usage
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def _parse_latest_developments(text: str, new_articles: list[dict] | None = None) -> list[str]:
|
def _parse_latest_developments(text: str, new_articles: list[dict] | None = None) -> list[str]:
|
||||||
"""Extrahiert '- [DD.MM. HH:MM] ...'-Zeilen aus der Claude-Antwort.
|
"""Extrahiert '- [DD.MM. HH:MM] ...'-Zeilen aus der Claude-Antwort.
|
||||||
|
|||||||
@@ -431,9 +431,27 @@ class FactCheckerAgent:
|
|||||||
"""Prüft Fakten über Claude CLI gegen unabhängige Quellen."""
|
"""Prüft Fakten über Claude CLI gegen unabhängige Quellen."""
|
||||||
|
|
||||||
def _format_articles_text(self, articles: list[dict], max_articles: int = 20) -> str:
|
def _format_articles_text(self, articles: list[dict], max_articles: int = 20) -> str:
|
||||||
"""Formatiert Artikel als Text für den Prompt."""
|
"""Formatiert Artikel als Text für den Prompt.
|
||||||
|
|
||||||
|
Foren-Quellen (media_type='forum', z.B. 5ch/Hatena/Note) werden hier
|
||||||
|
ausgeschlossen — sie sind Stimmungsmaterial, kein Faktenbeleg. Ein
|
||||||
|
anonymer Forenpost darf nicht als "Quelle bestaetigt Behauptung X"
|
||||||
|
gelten.
|
||||||
|
"""
|
||||||
|
# Falls media_type am Dict vorhanden ist, Foren-Quellen ausfiltern.
|
||||||
|
# Bei Article-Dicts aus dem RSS-/Pre-Topic-Pfad ist das Feld gesetzt;
|
||||||
|
# bei Reload aus der DB muss der Orchestrator das per JOIN annotieren.
|
||||||
|
non_forum = [a for a in articles if (a.get("media_type") or "").lower() != "forum"]
|
||||||
|
skipped = len(articles) - len(non_forum)
|
||||||
|
if skipped > 0:
|
||||||
|
logger.info(
|
||||||
|
"Faktencheck: %d Foren-Quellen (media_type='forum') ausgeschlossen, "
|
||||||
|
"%d Artikel als Faktenbeleg-Kandidaten",
|
||||||
|
skipped, len(non_forum),
|
||||||
|
)
|
||||||
|
|
||||||
articles_text = ""
|
articles_text = ""
|
||||||
for i, article in enumerate(articles[:max_articles]):
|
for i, article in enumerate(non_forum[:max_articles]):
|
||||||
articles_text += f"\n--- Meldung {i+1} ---\n"
|
articles_text += f"\n--- Meldung {i+1} ---\n"
|
||||||
articles_text += f"Quelle: {article.get('source', 'Unbekannt')}\n"
|
articles_text += f"Quelle: {article.get('source', 'Unbekannt')}\n"
|
||||||
source_url = article.get('source_url', '')
|
source_url = article.get('source_url', '')
|
||||||
|
|||||||
@@ -31,6 +31,28 @@ def _get_geonamescache():
|
|||||||
return _gc
|
return _gc
|
||||||
|
|
||||||
|
|
||||||
|
# Geografische Zentren (Centroids) der Laender, keyed nach ISO-2-Code.
|
||||||
|
# Wird genutzt, wenn ein Artikel ein LAND nennt (kein konkreter Ort). Vorher
|
||||||
|
# wurde dem Land die Hauptstadt zugewiesen — das stapelte z.B. alle "Japan"-
|
||||||
|
# Marker exakt auf Tokyo und suggerierte faelschlich ein Ereignis in der
|
||||||
|
# Hauptstadt. Das Centroid liegt in der Landesmitte und ist neutral.
|
||||||
|
# Laender, die hier fehlen, fallen auf die Hauptstadt zurueck (alte Logik).
|
||||||
|
_COUNTRY_CENTROIDS = {
|
||||||
|
"AF": (33.94, 67.71), "AT": (47.52, 14.55), "AZ": (40.14, 47.58),
|
||||||
|
"CH": (46.82, 8.23), "CN": (35.86, 104.20), "CY": (35.13, 33.43),
|
||||||
|
"DE": (51.17, 10.45), "EG": (26.82, 30.80), "ES": (40.46, -3.75),
|
||||||
|
"FR": (46.23, 2.21), "GB": (54.70, -3.28), "GR": (39.07, 21.82),
|
||||||
|
"IL": (31.05, 34.85), "IN": (20.59, 78.96), "IQ": (33.22, 43.68),
|
||||||
|
"IR": (32.43, 53.69), "IT": (41.87, 12.57), "JO": (30.59, 36.24),
|
||||||
|
"JP": (36.20, 138.25), "KP": (40.34, 127.51), "KR": (35.91, 127.77),
|
||||||
|
"KW": (29.31, 47.48), "LB": (33.85, 35.86), "NL": (52.13, 5.29),
|
||||||
|
"OM": (21.47, 55.98), "PK": (30.38, 69.35), "PS": (31.95, 35.23),
|
||||||
|
"QA": (25.32, 51.18), "RU": (61.52, 105.32), "SA": (23.89, 45.08),
|
||||||
|
"SY": (34.80, 38.997), "TR": (38.96, 35.24), "UA": (48.38, 31.17),
|
||||||
|
"US": (39.83, -98.58), "YE": (15.55, 48.52), "TW": (23.80, 121.00),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
# Bekannte Laendernamen (deutsch/englisch/alternativ -> ISO-2 Code + Hauptstadt-Koordinaten)
|
# Bekannte Laendernamen (deutsch/englisch/alternativ -> ISO-2 Code + Hauptstadt-Koordinaten)
|
||||||
_COUNTRY_ALIASES = {
|
_COUNTRY_ALIASES = {
|
||||||
"libanon": {"code": "LB", "name": "Lebanon", "lat": 33.8938, "lon": 35.5018},
|
"libanon": {"code": "LB", "name": "Lebanon", "lat": 33.8938, "lon": 35.5018},
|
||||||
@@ -106,9 +128,12 @@ def _geocode_offline(name: str, country_code: str = "") -> Optional[dict]:
|
|||||||
# 1. Bekannte Laender-Aliase (schnellster + sicherster Pfad)
|
# 1. Bekannte Laender-Aliase (schnellster + sicherster Pfad)
|
||||||
alias = _COUNTRY_ALIASES.get(name_lower)
|
alias = _COUNTRY_ALIASES.get(name_lower)
|
||||||
if alias:
|
if alias:
|
||||||
|
# Land -> geografisches Zentrum (Centroid) statt Hauptstadt, wo bekannt.
|
||||||
|
centroid = _COUNTRY_CENTROIDS.get(alias["code"])
|
||||||
|
lat, lon = centroid if centroid else (alias["lat"], alias["lon"])
|
||||||
return {
|
return {
|
||||||
"lat": alias["lat"],
|
"lat": lat,
|
||||||
"lon": alias["lon"],
|
"lon": lon,
|
||||||
"country_code": alias["code"],
|
"country_code": alias["code"],
|
||||||
"normalized_name": alias["name"],
|
"normalized_name": alias["name"],
|
||||||
"confidence": 0.95,
|
"confidence": 0.95,
|
||||||
@@ -118,9 +143,20 @@ def _geocode_offline(name: str, country_code: str = "") -> Optional[dict]:
|
|||||||
countries = gc.get_countries()
|
countries = gc.get_countries()
|
||||||
for code, country in countries.items():
|
for code, country in countries.items():
|
||||||
if country.get("name", "").lower() == name_lower:
|
if country.get("name", "").lower() == name_lower:
|
||||||
|
# Land -> Centroid (Landesmitte), wo bekannt. Das verhindert, dass
|
||||||
|
# alle "Japan"-Marker exakt auf Tokyo gestapelt werden.
|
||||||
|
centroid = _COUNTRY_CENTROIDS.get(code)
|
||||||
|
if centroid:
|
||||||
|
return {
|
||||||
|
"lat": centroid[0],
|
||||||
|
"lon": centroid[1],
|
||||||
|
"country_code": code,
|
||||||
|
"normalized_name": country["name"],
|
||||||
|
"confidence": 0.9,
|
||||||
|
}
|
||||||
|
# Kein Centroid hinterlegt -> Fallback auf die Hauptstadt.
|
||||||
capital = country.get("capital", "")
|
capital = country.get("capital", "")
|
||||||
if capital:
|
if capital:
|
||||||
# Hauptstadt geocoden, aber als Land benennen
|
|
||||||
cap_alias = _COUNTRY_ALIASES.get(capital.lower())
|
cap_alias = _COUNTRY_ALIASES.get(capital.lower())
|
||||||
if cap_alias:
|
if cap_alias:
|
||||||
return {
|
return {
|
||||||
|
|||||||
@@ -34,6 +34,7 @@ CATEGORY_REPUTATION = {
|
|||||||
"international": 0.75, # CNN, Guardian, NYT, Al Jazeera, France24
|
"international": 0.75, # CNN, Guardian, NYT, Al Jazeera, France24
|
||||||
"regional": 0.65, # regionale Tageszeitungen
|
"regional": 0.65, # regionale Tageszeitungen
|
||||||
"telegram": 0.5, # OSINT-Kanaele — gemischte Qualitaet
|
"telegram": 0.5, # OSINT-Kanaele — gemischte Qualitaet
|
||||||
|
"x": 0.4, # X/Twitter-Accounts, hohes Rauschen
|
||||||
"sonstige": 0.4, # unkategorisiert
|
"sonstige": 0.4, # unkategorisiert
|
||||||
"boulevard": 0.3, # Bild, Sun etc.
|
"boulevard": 0.3, # Bild, Sun etc.
|
||||||
}
|
}
|
||||||
@@ -744,14 +745,43 @@ class AgentOrchestrator:
|
|||||||
description = incident["description"] or ""
|
description = incident["description"] or ""
|
||||||
incident_type = incident["type"] or "adhoc"
|
incident_type = incident["type"] or "adhoc"
|
||||||
international = bool(incident["international_sources"]) if "international_sources" in incident.keys() else True
|
international = bool(incident["international_sources"]) if "international_sources" in incident.keys() else True
|
||||||
|
# Wenn die Org eine Sprach-Whitelist gesetzt hat, ist 'international' bedeutungslos —
|
||||||
|
# die Whitelist gewinnt. Wir setzen 'international' auf True, damit der nachgelagerte
|
||||||
|
# Code alle (durch Whitelist gefilterten) Feeds in Betracht zieht. Tatsaechliche
|
||||||
|
# Einschraenkung passiert in get_feeds_with_metadata.
|
||||||
|
# Hinweis: source_lang_whitelist wird weiter unten geladen.
|
||||||
include_telegram = bool(incident["include_telegram"]) if "include_telegram" in incident.keys() else False
|
include_telegram = bool(incident["include_telegram"]) if "include_telegram" in incident.keys() else False
|
||||||
|
include_x = bool(incident["include_x"]) if "include_x" in incident.keys() else False
|
||||||
visibility = incident["visibility"] if "visibility" in incident.keys() else "public"
|
visibility = incident["visibility"] if "visibility" in incident.keys() else "public"
|
||||||
created_by = incident["created_by"] if "created_by" in incident.keys() else None
|
created_by = incident["created_by"] if "created_by" in incident.keys() else None
|
||||||
tenant_id = incident["tenant_id"] if "tenant_id" in incident.keys() else None
|
tenant_id = incident["tenant_id"] if "tenant_id" in incident.keys() else None
|
||||||
# Org-Sprache fuer alle KI-Agenten (Lagebild, Faktencheck, Recherche)
|
# Org-Sprache fuer alle KI-Agenten (Lagebild, Faktencheck, Recherche)
|
||||||
from services.org_settings import get_org_language, language_display
|
from services.org_settings import (
|
||||||
|
get_org_language, language_display, get_research_language,
|
||||||
|
get_source_language_whitelist, get_translator_enabled,
|
||||||
|
)
|
||||||
output_language_iso = await get_org_language(db, tenant_id) if tenant_id else "de"
|
output_language_iso = await get_org_language(db, tenant_id) if tenant_id else "de"
|
||||||
output_language = language_display(output_language_iso)
|
output_language = language_display(output_language_iso)
|
||||||
|
# research_language steuert nur den WebSearch-Prompt ("suche in Sprache X").
|
||||||
|
# Default = output_language_iso. Bei jp_demo wird das auf 'ja' gesetzt, waehrend
|
||||||
|
# output_language_iso 'de' bleibt (Lagebild auf Deutsch, Recherche auf Japanisch).
|
||||||
|
research_language_iso = await get_research_language(db, tenant_id) if tenant_id else output_language_iso
|
||||||
|
# source_language_whitelist schraenkt RSS-/Telegram-Quellenpool ein (z.B. ['ja']).
|
||||||
|
# Wenn gesetzt, wird das incident-level Flag international_sources ignoriert
|
||||||
|
# (Whitelist ist explizit, das Flag ist Default-Verhalten).
|
||||||
|
source_lang_whitelist = await get_source_language_whitelist(db, tenant_id) if tenant_id else None
|
||||||
|
# Pro-Org-Override des globalen TRANSLATOR_ENABLED-Flags.
|
||||||
|
translator_enabled = await get_translator_enabled(db, tenant_id)
|
||||||
|
# Whitelist gewinnt ueber das incident-Flag international_sources:
|
||||||
|
# wenn die Org eine Sprach-Whitelist hat, sind alle gewaehlten Feeds
|
||||||
|
# ohnehin "Wunsch-Sprache" — kein Splitting in primary/international noetig.
|
||||||
|
if source_lang_whitelist:
|
||||||
|
international = True
|
||||||
|
logger.info(
|
||||||
|
"Org %s hat source_language_whitelist=%s gesetzt; "
|
||||||
|
"incident.international_sources wird ignoriert",
|
||||||
|
tenant_id, source_lang_whitelist,
|
||||||
|
)
|
||||||
previous_summary = incident["summary"] or ""
|
previous_summary = incident["summary"] or ""
|
||||||
previous_sources_json = incident["sources_json"] if "sources_json" in incident.keys() else None
|
previous_sources_json = incident["sources_json"] if "sources_json" in incident.keys() else None
|
||||||
previous_developments = incident["latest_developments"] if "latest_developments" in incident.keys() else None
|
previous_developments = incident["latest_developments"] if "latest_developments" in incident.keys() else None
|
||||||
@@ -844,7 +874,9 @@ class AgentOrchestrator:
|
|||||||
try:
|
try:
|
||||||
if incident_type == "adhoc":
|
if incident_type == "adhoc":
|
||||||
_src_cursor = await db.execute(
|
_src_cursor = await db.execute(
|
||||||
"SELECT COUNT(*) AS cnt FROM sources WHERE tenant_id = ? AND status = 'active'",
|
"SELECT COUNT(*) AS cnt FROM sources "
|
||||||
|
"WHERE status = 'active' "
|
||||||
|
"AND (tenant_id IS NULL OR tenant_id = ?)",
|
||||||
(tenant_id,),
|
(tenant_id,),
|
||||||
)
|
)
|
||||||
_src_row = await _src_cursor.fetchone()
|
_src_row = await _src_cursor.fetchone()
|
||||||
@@ -892,7 +924,32 @@ class AgentOrchestrator:
|
|||||||
# Feed-Selektion-Keywords nur als Fallback wenn dynamische fehlen
|
# Feed-Selektion-Keywords nur als Fallback wenn dynamische fehlen
|
||||||
if not keywords:
|
if not keywords:
|
||||||
keywords = feed_sel_keywords
|
keywords = feed_sel_keywords
|
||||||
articles = await rss_parser.search_feeds_selective(title, selected_feeds, keywords=keywords)
|
# --- Recall-Boost: dynamische Google-News-Volltext-Suchfeeds ---
|
||||||
|
# Statt nur feste site:-Feeds zu durchsuchen, baut die Pipeline
|
||||||
|
# pro Sprache einen Google-News-Suchfeed aus den Keywords. Damit
|
||||||
|
# erreichen wir Quellen, die in keinem festen Feed stehen
|
||||||
|
# (Vendor-Blogs, Fachportale, Regionalmedien).
|
||||||
|
from agents.researcher import build_news_search_feeds
|
||||||
|
if source_lang_whitelist:
|
||||||
|
_gnews_langs = list(source_lang_whitelist)
|
||||||
|
else:
|
||||||
|
_gnews_langs = list({output_language_iso, research_language_iso})
|
||||||
|
# Zwei Sets: ein Kontext-Feed (alle Zeiten) + ein Frische-Feed
|
||||||
|
# (when:14d). Der Frische-Feed garantiert, dass das aktuelle
|
||||||
|
# Bild eingefangen wird, auch wenn aeltere Artikel relevanter
|
||||||
|
# ranken. Beide laufen durch dieselbe Pipeline; Dedup entfernt
|
||||||
|
# Ueberschneidungen.
|
||||||
|
_gnews_feeds = build_news_search_feeds(keywords, _gnews_langs)
|
||||||
|
_gnews_recent = build_news_search_feeds(keywords, _gnews_langs, recency_days=14)
|
||||||
|
_all_gnews = _gnews_feeds + _gnews_recent
|
||||||
|
if _all_gnews:
|
||||||
|
logger.info(
|
||||||
|
f"Google-News-Suchfeeds ergaenzt: {len(_gnews_feeds)} Kontext "
|
||||||
|
f"+ {len(_gnews_recent)} Frische (when:14d)"
|
||||||
|
)
|
||||||
|
articles = await rss_parser.search_feeds_selective(
|
||||||
|
title, selected_feeds + _all_gnews, keywords=keywords,
|
||||||
|
)
|
||||||
else:
|
else:
|
||||||
articles = await rss_parser.search_feeds(title, international=international, tenant_id=tenant_id, keywords=keywords, user_id=user_id)
|
articles = await rss_parser.search_feeds(title, international=international, tenant_id=tenant_id, keywords=keywords, user_id=user_id)
|
||||||
|
|
||||||
@@ -934,6 +991,7 @@ class AgentOrchestrator:
|
|||||||
preferred_sources=preferred_sources,
|
preferred_sources=preferred_sources,
|
||||||
output_language=output_language,
|
output_language=output_language,
|
||||||
output_language_iso=output_language_iso,
|
output_language_iso=output_language_iso,
|
||||||
|
research_language_iso=research_language_iso,
|
||||||
)
|
)
|
||||||
logger.info(
|
logger.info(
|
||||||
f"Claude-Recherche: {len(results)} Ergebnisse"
|
f"Claude-Recherche: {len(results)} Ergebnisse"
|
||||||
@@ -971,7 +1029,11 @@ class AgentOrchestrator:
|
|||||||
if pd_kw_usage:
|
if pd_kw_usage:
|
||||||
usage_acc.add(pd_kw_usage)
|
usage_acc.add(pd_kw_usage)
|
||||||
|
|
||||||
articles = await pd_parser.search_feeds_selective(title, podcast_feeds, keywords=pd_keywords)
|
# Podcast-Parser erwartet (noch) eine flache Liste – Podcasts sind
|
||||||
|
# primaer deutschsprachig, daher reicht das gemeinsame Flatten.
|
||||||
|
from agents.researcher import flatten_keywords
|
||||||
|
pd_keywords_flat = flatten_keywords(pd_keywords)
|
||||||
|
articles = await pd_parser.search_feeds_selective(title, podcast_feeds, keywords=pd_keywords_flat or None)
|
||||||
logger.info(f"Podcast-Pipeline: {len(articles)} Episoden gefunden")
|
logger.info(f"Podcast-Pipeline: {len(articles)} Episoden gefunden")
|
||||||
return articles, None
|
return articles, None
|
||||||
|
|
||||||
@@ -1009,26 +1071,76 @@ class AgentOrchestrator:
|
|||||||
tg_keywords, tg_kw_usage = await tg_researcher.extract_dynamic_keywords(title, tg_headlines)
|
tg_keywords, tg_kw_usage = await tg_researcher.extract_dynamic_keywords(title, tg_headlines)
|
||||||
if tg_kw_usage:
|
if tg_kw_usage:
|
||||||
usage_acc.add(tg_kw_usage)
|
usage_acc.add(tg_kw_usage)
|
||||||
logger.info(f"Telegram-Keywords: {tg_keywords}")
|
if isinstance(tg_keywords, dict):
|
||||||
|
logger.info(f"Telegram-Keywords (Sprachen): { {k: len(v) for k, v in tg_keywords.items()} }")
|
||||||
|
else:
|
||||||
|
logger.info(f"Telegram-Keywords: {tg_keywords}")
|
||||||
|
|
||||||
articles = await tg_parser.search_channels(title, tenant_id=tenant_id, keywords=tg_keywords, channel_ids=selected_ids)
|
articles = await tg_parser.search_channels(title, tenant_id=tenant_id, keywords=tg_keywords, channel_ids=selected_ids)
|
||||||
logger.info(f"Telegram-Pipeline: {len(articles)} Nachrichten")
|
logger.info(f"Telegram-Pipeline: {len(articles)} Nachrichten")
|
||||||
return articles, None
|
return articles, None
|
||||||
|
|
||||||
|
async def _x_pipeline():
|
||||||
|
"""X-Account-Suche (Twitter) mit KI-basierter Account-Selektion."""
|
||||||
|
from feeds.x_parser import XParser
|
||||||
|
x_parser = XParser()
|
||||||
|
|
||||||
|
# Alle X-Accounts laden
|
||||||
|
all_accounts = await x_parser._get_x_accounts(tenant_id=tenant_id)
|
||||||
|
if not all_accounts:
|
||||||
|
logger.info("Keine X-Accounts konfiguriert")
|
||||||
|
return [], None
|
||||||
|
|
||||||
|
# KI waehlt relevante Accounts aus
|
||||||
|
x_researcher = ResearcherAgent()
|
||||||
|
selected_accounts, x_sel_usage = await x_researcher.select_relevant_x_accounts(
|
||||||
|
title, description, all_accounts
|
||||||
|
)
|
||||||
|
if x_sel_usage:
|
||||||
|
usage_acc.add(x_sel_usage)
|
||||||
|
|
||||||
|
selected_ids = [acc["id"] for acc in selected_accounts]
|
||||||
|
logger.info(f"X-Selektion: {len(selected_ids)} von {len(all_accounts)} Accounts")
|
||||||
|
|
||||||
|
# Dynamische Keywords fuer X (eigener Aufruf, da parallel zu RSS)
|
||||||
|
cursor_x_hl = await db.execute(
|
||||||
|
"""SELECT COALESCE(headline_de, headline) as hl
|
||||||
|
FROM articles WHERE incident_id = ?
|
||||||
|
AND COALESCE(headline_de, headline) IS NOT NULL
|
||||||
|
ORDER BY collected_at DESC LIMIT 30""",
|
||||||
|
(incident_id,),
|
||||||
|
)
|
||||||
|
x_headlines = [row["hl"] for row in await cursor_x_hl.fetchall() if row["hl"]]
|
||||||
|
x_keywords, x_kw_usage = await x_researcher.extract_dynamic_keywords(title, x_headlines)
|
||||||
|
if x_kw_usage:
|
||||||
|
usage_acc.add(x_kw_usage)
|
||||||
|
|
||||||
|
articles = await x_parser.search_accounts(
|
||||||
|
title, tenant_id=tenant_id, keywords=x_keywords, account_ids=selected_ids
|
||||||
|
)
|
||||||
|
logger.info(f"X-Pipeline: {len(articles)} Posts")
|
||||||
|
return articles, None
|
||||||
|
|
||||||
# Pipeline-Schritt 2: Nachrichten sammeln (Start)
|
# Pipeline-Schritt 2: Nachrichten sammeln (Start)
|
||||||
await _pipe_start("collect")
|
await _pipe_start("collect")
|
||||||
|
|
||||||
# Pipelines parallel starten (RSS + WebSearch + Podcasts + optional Telegram)
|
# Pipelines parallel starten (RSS + WebSearch + Podcasts + optional Telegram/X)
|
||||||
pipelines = [_rss_pipeline(), _web_search_pipeline(), _podcast_pipeline()]
|
pipelines = [_rss_pipeline(), _web_search_pipeline(), _podcast_pipeline()]
|
||||||
|
telegram_idx = x_idx = None
|
||||||
if include_telegram:
|
if include_telegram:
|
||||||
|
telegram_idx = len(pipelines)
|
||||||
pipelines.append(_telegram_pipeline())
|
pipelines.append(_telegram_pipeline())
|
||||||
|
if include_x:
|
||||||
|
x_idx = len(pipelines)
|
||||||
|
pipelines.append(_x_pipeline())
|
||||||
|
|
||||||
pipeline_results = await asyncio.gather(*pipelines)
|
pipeline_results = await asyncio.gather(*pipelines)
|
||||||
|
|
||||||
(rss_articles, rss_feed_usage) = pipeline_results[0]
|
(rss_articles, rss_feed_usage) = pipeline_results[0]
|
||||||
(search_results, search_usage, search_parse_failed) = pipeline_results[1]
|
(search_results, search_usage, search_parse_failed) = pipeline_results[1]
|
||||||
(podcast_articles, _podcast_usage) = pipeline_results[2]
|
(podcast_articles, _podcast_usage) = pipeline_results[2]
|
||||||
telegram_articles = pipeline_results[3][0] if include_telegram else []
|
telegram_articles = pipeline_results[telegram_idx][0] if telegram_idx is not None else []
|
||||||
|
x_articles = pipeline_results[x_idx][0] if x_idx is not None else []
|
||||||
|
|
||||||
# Podcast-Artikel in die RSS-Liste einfuegen (gleicher Downstream-Pfad)
|
# Podcast-Artikel in die RSS-Liste einfuegen (gleicher Downstream-Pfad)
|
||||||
if podcast_articles:
|
if podcast_articles:
|
||||||
@@ -1047,7 +1159,7 @@ class AgentOrchestrator:
|
|||||||
self._check_cancelled(incident_id)
|
self._check_cancelled(incident_id)
|
||||||
|
|
||||||
# Alle Ergebnisse zusammenführen
|
# Alle Ergebnisse zusammenführen
|
||||||
all_results = rss_articles + search_results + telegram_articles
|
all_results = rss_articles + search_results + telegram_articles + x_articles
|
||||||
# Pipeline-Schritt 2: Nachrichten sammeln (fertig)
|
# Pipeline-Schritt 2: Nachrichten sammeln (fertig)
|
||||||
try:
|
try:
|
||||||
_delivering_sources = len({a.get("source", "") for a in all_results if a.get("source")})
|
_delivering_sources = len({a.get("source", "") for a in all_results if a.get("source")})
|
||||||
@@ -1130,6 +1242,25 @@ class AgentOrchestrator:
|
|||||||
await _pipe_start("relevance")
|
await _pipe_start("relevance")
|
||||||
_candidates_before_topic = len(new_candidates)
|
_candidates_before_topic = len(new_candidates)
|
||||||
|
|
||||||
|
# --- Pre-Topic-Übersetzung: fremdsprachige Headlines ins Englische ---
|
||||||
|
# Damit der nachgelagerte Topic-Filter (Haiku) auch CJK/Arabisch/
|
||||||
|
# Hebräisch/Kyrillisch-Headlines fair beurteilen kann statt sie aus
|
||||||
|
# Sicherheit zu verwerfen.
|
||||||
|
if new_candidates:
|
||||||
|
try:
|
||||||
|
from agents.translator import translate_headlines_for_topic_filter
|
||||||
|
_pt_count, _pt_usage = await translate_headlines_for_topic_filter(new_candidates)
|
||||||
|
if _pt_usage:
|
||||||
|
usage_acc.add(_pt_usage)
|
||||||
|
if _pt_count:
|
||||||
|
logger.info(
|
||||||
|
f"Pre-Topic-Translate: {_pt_count} fremdsprachige Headlines übersetzt"
|
||||||
|
)
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning(
|
||||||
|
f"Pre-Topic-Translate fehlgeschlagen (Pipeline laeuft weiter): {e}"
|
||||||
|
)
|
||||||
|
|
||||||
# --- Semantischer Topic-Filter (Haiku) ---
|
# --- Semantischer Topic-Filter (Haiku) ---
|
||||||
# Wirft Artikel raus, die zwar Keyword-Treffer hatten, aber das Kernthema
|
# Wirft Artikel raus, die zwar Keyword-Treffer hatten, aber das Kernthema
|
||||||
# der Lage nicht inhaltlich behandeln. Bei Fehler Fallback auf alle Kandidaten.
|
# der Lage nicht inhaltlich behandeln. Bei Fehler Fallback auf alle Kandidaten.
|
||||||
@@ -1146,18 +1277,28 @@ class AgentOrchestrator:
|
|||||||
new_count = 0
|
new_count = 0
|
||||||
new_articles_for_analysis = []
|
new_articles_for_analysis = []
|
||||||
for article in new_candidates:
|
for article in new_candidates:
|
||||||
|
# headline_en / content_en: zuerst die vollwertige Übersetzung
|
||||||
|
# vom Translator (wenn TRANSLATOR_ENABLED), sonst die für den
|
||||||
|
# Topic-Filter angefertigte Mini-Übersetzung wiederverwenden.
|
||||||
|
# Ohne diesen Fallback würden fremdsprachige Artikel zwar
|
||||||
|
# gefiltert, aber ohne englische Headline in der DB landen und
|
||||||
|
# später im Frontend bzw. im Summary-LLM unlesbar bleiben.
|
||||||
|
headline_en = article.get("headline_en") or article.get("headline_en_for_topic")
|
||||||
|
content_en = article.get("content_en") or article.get("content_en_for_topic")
|
||||||
cursor = await db.execute(
|
cursor = await db.execute(
|
||||||
"""INSERT INTO articles (incident_id, headline, headline_de, source,
|
"""INSERT INTO articles (incident_id, headline, headline_de, headline_en, source,
|
||||||
source_url, content_original, content_de, language, published_at, tenant_id)
|
source_url, content_original, content_de, content_en, language, published_at, tenant_id)
|
||||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)""",
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)""",
|
||||||
(
|
(
|
||||||
incident_id,
|
incident_id,
|
||||||
article.get("headline", ""),
|
article.get("headline", ""),
|
||||||
article.get("headline_de"),
|
article.get("headline_de"),
|
||||||
|
headline_en,
|
||||||
article.get("source", "Unbekannt"),
|
article.get("source", "Unbekannt"),
|
||||||
article.get("source_url"),
|
article.get("source_url"),
|
||||||
article.get("content_original"),
|
article.get("content_original"),
|
||||||
article.get("content_de"),
|
article.get("content_de"),
|
||||||
|
content_en,
|
||||||
article.get("language", "de"),
|
article.get("language", "de"),
|
||||||
article.get("published_at"),
|
article.get("published_at"),
|
||||||
tenant_id,
|
tenant_id,
|
||||||
@@ -1171,14 +1312,25 @@ class AgentOrchestrator:
|
|||||||
await db.commit()
|
await db.commit()
|
||||||
|
|
||||||
# Geoparsing: Orte aus neuen Artikeln extrahieren und speichern
|
# Geoparsing: Orte aus neuen Artikeln extrahieren und speichern
|
||||||
if new_articles_for_analysis:
|
# Foren-Quellen (media_type='forum') ausschliessen: 5ch/Hatena/Note-Posts haben
|
||||||
|
# keinen eigenen, fuer das Lagebild interessanten geographischen Bezug; spart Haiku-Calls.
|
||||||
|
articles_for_geoparsing = [
|
||||||
|
a for a in new_articles_for_analysis
|
||||||
|
if (a.get("media_type") or "").lower() != "forum"
|
||||||
|
]
|
||||||
|
if new_articles_for_analysis and not articles_for_geoparsing:
|
||||||
|
logger.info(
|
||||||
|
"Geoparsing uebersprungen: alle %d neuen Artikel sind Forum-Quellen",
|
||||||
|
len(new_articles_for_analysis),
|
||||||
|
)
|
||||||
|
if articles_for_geoparsing:
|
||||||
# Pipeline-Schritt 5: Orte erkennen (Start)
|
# Pipeline-Schritt 5: Orte erkennen (Start)
|
||||||
await _pipe_start("geoparsing")
|
await _pipe_start("geoparsing")
|
||||||
try:
|
try:
|
||||||
from agents.geoparsing import geoparse_articles
|
from agents.geoparsing import geoparse_articles
|
||||||
incident_context = f"{title} - {description}"
|
incident_context = f"{title} - {description}"
|
||||||
logger.info(f"Geoparsing fuer {len(new_articles_for_analysis)} neue Artikel...")
|
logger.info(f"Geoparsing fuer {len(articles_for_geoparsing)} neue Artikel (Foren ausgeschlossen)...")
|
||||||
geo_results, category_labels = await geoparse_articles(new_articles_for_analysis, incident_context)
|
geo_results, category_labels = await geoparse_articles(articles_for_geoparsing, incident_context)
|
||||||
geo_count = 0
|
geo_count = 0
|
||||||
for art_id, locations in geo_results.items():
|
for art_id, locations in geo_results.items():
|
||||||
for loc in locations:
|
for loc in locations:
|
||||||
@@ -1256,7 +1408,12 @@ class AgentOrchestrator:
|
|||||||
all_articles_preloaded = None
|
all_articles_preloaded = None
|
||||||
if not previous_summary or new_count == 0 or not existing_facts:
|
if not previous_summary or new_count == 0 or not existing_facts:
|
||||||
cursor = await db.execute(
|
cursor = await db.execute(
|
||||||
"SELECT * FROM articles WHERE incident_id = ? ORDER BY collected_at DESC",
|
# JOIN auf sources, damit media_type pro Artikel verfuegbar ist
|
||||||
|
# (Faktencheck schliesst Foren-Quellen aus, das Stimmungs-Modul nimmt
|
||||||
|
# nur diese). Bei Quellen ohne Match in sources bleibt media_type NULL.
|
||||||
|
"SELECT a.*, s.media_type AS media_type FROM articles a "
|
||||||
|
"LEFT JOIN sources s ON s.name = a.source "
|
||||||
|
"WHERE a.incident_id = ? ORDER BY a.collected_at DESC",
|
||||||
(incident_id,),
|
(incident_id,),
|
||||||
)
|
)
|
||||||
all_articles_preloaded = [dict(row) for row in await cursor.fetchall()]
|
all_articles_preloaded = [dict(row) for row in await cursor.fetchall()]
|
||||||
@@ -1409,6 +1566,78 @@ class AgentOrchestrator:
|
|||||||
logger.warning("build_fact_context_block fehlgeschlagen: %s", ctx_err, exc_info=True)
|
logger.warning("build_fact_context_block fehlgeschlagen: %s", ctx_err, exc_info=True)
|
||||||
fact_context_block = ""
|
fact_context_block = ""
|
||||||
|
|
||||||
|
# Pipeline-Schritt 6b: Öffentliche Stimmung aus Foren-Quellen
|
||||||
|
# (nur Artikel mit media_type='forum'). Eigene Kachel, kein Faktencheck.
|
||||||
|
# Wird vor dem Lagebild-Schritt ausgefuehrt, damit das Lagebild bei
|
||||||
|
# Bedarf darauf verweisen kann (z.B. Demo-Lagen mit Bezug zur Stimmung).
|
||||||
|
try:
|
||||||
|
# Bestand aller Foren-Artikel der Lage laden (inkl. media_type via JOIN)
|
||||||
|
cursor_fm = await db.execute(
|
||||||
|
"SELECT a.*, s.media_type AS media_type FROM articles a "
|
||||||
|
"LEFT JOIN sources s ON s.name = a.source "
|
||||||
|
"WHERE a.incident_id = ?",
|
||||||
|
(incident_id,),
|
||||||
|
)
|
||||||
|
all_articles_with_mt = [dict(r) for r in await cursor_fm.fetchall()]
|
||||||
|
forum_articles_in_db = [
|
||||||
|
a for a in all_articles_with_mt
|
||||||
|
if (a.get("media_type") or "").lower() == "forum"
|
||||||
|
]
|
||||||
|
# Aus dem aktuellen Refresh-Lauf zusaetzliche Foren-Artikel ergaenzen
|
||||||
|
# (haben media_type aus feed_config, sind aber evtl. noch nicht in DB,
|
||||||
|
# wenn die Persistierung anders laeuft — Robustheit).
|
||||||
|
for art in new_articles_for_analysis:
|
||||||
|
if (art.get("media_type") or "").lower() != "forum":
|
||||||
|
continue
|
||||||
|
# Duplikate vermeiden ueber source_url
|
||||||
|
if any(a.get("source_url") == art.get("source_url") for a in forum_articles_in_db):
|
||||||
|
continue
|
||||||
|
forum_articles_in_db.append(art)
|
||||||
|
|
||||||
|
if forum_articles_in_db:
|
||||||
|
await _pipe_start("public_mood")
|
||||||
|
try:
|
||||||
|
mood_agent = AnalyzerAgent()
|
||||||
|
# 1. Moderationspass: Hassrede/PII/NSFW vorab filtern.
|
||||||
|
moderated_articles, mod_usage = await mood_agent.moderate_forum_articles(
|
||||||
|
forum_articles_in_db,
|
||||||
|
)
|
||||||
|
if mod_usage:
|
||||||
|
usage_acc.add(mod_usage)
|
||||||
|
# 2. Stimmungs-Zusammenfassung aus gefilterten Beitraegen.
|
||||||
|
mood_text, mood_usage = await mood_agent.generate_public_mood(
|
||||||
|
title, description, moderated_articles,
|
||||||
|
output_language=output_language,
|
||||||
|
)
|
||||||
|
if mood_usage:
|
||||||
|
usage_acc.add(mood_usage)
|
||||||
|
if mood_text:
|
||||||
|
await db.execute(
|
||||||
|
"UPDATE incidents SET public_mood = ?, public_mood_updated_at = ? WHERE id = ?",
|
||||||
|
(mood_text, now, incident_id),
|
||||||
|
)
|
||||||
|
await db.commit()
|
||||||
|
logger.info(
|
||||||
|
"Public-Mood gespeichert fuer Incident %d (%d -> %d Foren-Artikel nach Moderation)",
|
||||||
|
incident_id, len(forum_articles_in_db), len(moderated_articles),
|
||||||
|
)
|
||||||
|
await _pipe_done(
|
||||||
|
"public_mood",
|
||||||
|
count_value=len(moderated_articles),
|
||||||
|
count_secondary=(1 if mood_text else 0),
|
||||||
|
)
|
||||||
|
except Exception as mood_err:
|
||||||
|
logger.warning("Public-Mood fehlgeschlagen: %s", mood_err, exc_info=True)
|
||||||
|
await _pipe_done("public_mood", count_value=0, count_secondary=0)
|
||||||
|
else:
|
||||||
|
await _pipe_skip("public_mood")
|
||||||
|
except Exception as mood_outer_err:
|
||||||
|
logger.warning("Public-Mood-Block uebersprungen: %s", mood_outer_err)
|
||||||
|
try:
|
||||||
|
await _pipe_skip("public_mood")
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
# Pipeline-Schritt 7: Lagebild verfassen (jetzt mit Faktenkontext)
|
# Pipeline-Schritt 7: Lagebild verfassen (jetzt mit Faktenkontext)
|
||||||
await _pipe_start("summary")
|
await _pipe_start("summary")
|
||||||
logger.info(
|
logger.info(
|
||||||
@@ -1524,6 +1753,7 @@ class AgentOrchestrator:
|
|||||||
# Idempotent: nur Artikel ohne headline_de/content_de werden geholt.
|
# Idempotent: nur Artikel ohne headline_de/content_de werden geholt.
|
||||||
# Lauft nach der Analyse (Lagebild ist schon committed) und vor QC
|
# Lauft nach der Analyse (Lagebild ist schon committed) und vor QC
|
||||||
# (damit normalize_umlaut_articles auch die frischen DE-Texte fasst).
|
# (damit normalize_umlaut_articles auch die frischen DE-Texte fasst).
|
||||||
|
_translate_step_started = False
|
||||||
try:
|
try:
|
||||||
tr_cursor = await db.execute(
|
tr_cursor = await db.execute(
|
||||||
"""SELECT id, headline, content_original, language
|
"""SELECT id, headline, content_original, language
|
||||||
@@ -1535,7 +1765,10 @@ class AgentOrchestrator:
|
|||||||
(incident_id,),
|
(incident_id,),
|
||||||
)
|
)
|
||||||
pending_translations = [dict(r) for r in await tr_cursor.fetchall()]
|
pending_translations = [dict(r) for r in await tr_cursor.fetchall()]
|
||||||
if pending_translations:
|
if pending_translations and translator_enabled:
|
||||||
|
# Pipeline-Schritt 9: Artikel uebersetzen (nur sichtbar wenn was zu uebersetzen)
|
||||||
|
await _pipe_start("translate")
|
||||||
|
_translate_step_started = True
|
||||||
logger.info(
|
logger.info(
|
||||||
"Translator fuer Incident %d: %d Artikel ohne DE-Uebersetzung",
|
"Translator fuer Incident %d: %d Artikel ohne DE-Uebersetzung",
|
||||||
incident_id, len(pending_translations),
|
incident_id, len(pending_translations),
|
||||||
@@ -1544,8 +1777,9 @@ class AgentOrchestrator:
|
|||||||
from services.post_refresh_qc import normalize_german_umlauts as _norm_de2
|
from services.post_refresh_qc import normalize_german_umlauts as _norm_de2
|
||||||
translations = await translate_articles(
|
translations = await translate_articles(
|
||||||
pending_translations,
|
pending_translations,
|
||||||
output_lang="de",
|
output_lang=output_language_iso,
|
||||||
usage_accumulator=usage_acc,
|
usage_accumulator=usage_acc,
|
||||||
|
enabled=translator_enabled,
|
||||||
)
|
)
|
||||||
for t in translations:
|
for t in translations:
|
||||||
hd = t.get("headline_de")
|
hd = t.get("headline_de")
|
||||||
@@ -1565,8 +1799,11 @@ class AgentOrchestrator:
|
|||||||
"Translator fuer Incident %d: %d/%d Artikel uebersetzt",
|
"Translator fuer Incident %d: %d/%d Artikel uebersetzt",
|
||||||
incident_id, len(translations), len(pending_translations),
|
incident_id, len(translations), len(pending_translations),
|
||||||
)
|
)
|
||||||
|
await _pipe_done("translate", count_value=len(translations), count_secondary=len(pending_translations))
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error("Translator-Fehler fuer Incident %d: %s", incident_id, e, exc_info=True)
|
logger.error("Translator-Fehler fuer Incident %d: %s", incident_id, e, exc_info=True)
|
||||||
|
if _translate_step_started:
|
||||||
|
await _pipe_done("translate", count_value=0, count_secondary=0)
|
||||||
# Refresh trotz Translator-Fehler weiterlaufen lassen
|
# Refresh trotz Translator-Fehler weiterlaufen lassen
|
||||||
|
|
||||||
# --- Neueste Entwicklungen (nur Live-Monitoring / adhoc) ---
|
# --- Neueste Entwicklungen (nur Live-Monitoring / adhoc) ---
|
||||||
|
|||||||
@@ -2,12 +2,131 @@
|
|||||||
import json
|
import json
|
||||||
import logging
|
import logging
|
||||||
import re
|
import re
|
||||||
|
import urllib.parse
|
||||||
from agents.claude_client import call_claude, ClaudeUsage
|
from agents.claude_client import call_claude, ClaudeUsage
|
||||||
from config import CLAUDE_MODEL_FAST
|
from config import CLAUDE_MODEL_FAST
|
||||||
|
|
||||||
logger = logging.getLogger("osint.researcher")
|
logger = logging.getLogger("osint.researcher")
|
||||||
|
|
||||||
|
|
||||||
|
# Google-News-Locale pro ISO-Sprachcode: (hl, gl). ceid wird daraus gebaut.
|
||||||
|
_GNEWS_LOCALE = {
|
||||||
|
"ja": ("ja", "JP"),
|
||||||
|
"de": ("de", "DE"),
|
||||||
|
"en": ("en-US", "US"),
|
||||||
|
"ru": ("ru", "RU"),
|
||||||
|
"ko": ("ko", "KR"),
|
||||||
|
"zh": ("zh-CN", "CN"),
|
||||||
|
"fr": ("fr", "FR"),
|
||||||
|
"es": ("es", "ES"),
|
||||||
|
"it": ("it", "IT"),
|
||||||
|
"ar": ("ar", "EG"),
|
||||||
|
"he": ("iw", "IL"),
|
||||||
|
"fa": ("fa", "IR"),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def build_news_search_feeds(
|
||||||
|
keywords_by_lang: dict | list | None,
|
||||||
|
languages: list[str],
|
||||||
|
max_keywords: int = 4,
|
||||||
|
recency_days: int | None = None,
|
||||||
|
) -> list[dict]:
|
||||||
|
"""Baut dynamische Google-News-Volltext-Such-Feeds pro Sprache.
|
||||||
|
|
||||||
|
Statt nur feste site:-RSS-Feeds zu durchsuchen, erzeugt diese Funktion pro
|
||||||
|
Sprache einen Google-News-Suchfeed (news.google.com/rss/search?q=...). Damit
|
||||||
|
erreicht die Pipeline auch Quellen, die in keinem festen Feed stehen
|
||||||
|
(Security-Vendor-Blogs, Fachportale, Regionalmedien). Der Recall steigt
|
||||||
|
massiv; die Precision bleibt, weil der nachgelagerte Topic-Filter unveraendert
|
||||||
|
greift.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
keywords_by_lang: Sprach-Dict {iso: [keyword,...]} aus der Keyword-Extraktion.
|
||||||
|
languages: ISO-Codes, fuer die ein Suchfeed gebaut werden soll.
|
||||||
|
max_keywords: wie viele (spezifischste) Keywords in die Such-Query gehen.
|
||||||
|
recency_days: wenn gesetzt, wird der Google-News-Operator "when:Nd" an die
|
||||||
|
Query gehaengt — der Feed liefert dann nur Artikel der letzten N Tage.
|
||||||
|
Fuer "Frische-Suchfeeds", die das aktuelle Bild garantiert einfangen.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Liste von Feed-Config-Dicts (kompatibel mit RSSParser._fetch_feed).
|
||||||
|
"""
|
||||||
|
if not keywords_by_lang or not isinstance(keywords_by_lang, dict):
|
||||||
|
return []
|
||||||
|
|
||||||
|
feeds: list[dict] = []
|
||||||
|
seen_queries: set[str] = set()
|
||||||
|
for lang in languages:
|
||||||
|
lang_key = (lang or "").lower().strip()
|
||||||
|
locale = _GNEWS_LOCALE.get(lang_key)
|
||||||
|
if not locale:
|
||||||
|
continue
|
||||||
|
lang_kws = [str(k).strip() for k in (keywords_by_lang.get(lang_key) or []) if str(k).strip()]
|
||||||
|
en_kws = [str(k).strip() for k in (keywords_by_lang.get("en") or []) if str(k).strip()]
|
||||||
|
|
||||||
|
if lang_key == "en":
|
||||||
|
query_terms = en_kws[:max_keywords]
|
||||||
|
else:
|
||||||
|
# Fuer nicht-englische Sprachen: die ersten 2 englischen Keywords
|
||||||
|
# voranstellen. Haiku ordnet Eigennamen/Akronyme (z.B. "Qilin",
|
||||||
|
# "Asahi") nach vorne — und die kommen auch in fremdsprachigen
|
||||||
|
# Artikeln lateinisch vor. Ohne das fehlt beim ersten Refresh (noch
|
||||||
|
# keine Headlines-Historie) der entscheidende Eigenname in der Query.
|
||||||
|
# Danach 3 sprach-spezifische Keywords.
|
||||||
|
query_terms = en_kws[:2] + lang_kws[:3]
|
||||||
|
# Wenn fuer die Sprache gar keine Keywords da sind: ganz auf en.
|
||||||
|
if not lang_kws:
|
||||||
|
query_terms = en_kws[:max_keywords]
|
||||||
|
|
||||||
|
# Dedup, Reihenfolge erhalten
|
||||||
|
seen_terms: set[str] = set()
|
||||||
|
deduped: list[str] = []
|
||||||
|
for t in query_terms:
|
||||||
|
tl = t.lower()
|
||||||
|
if tl in seen_terms:
|
||||||
|
continue
|
||||||
|
seen_terms.add(tl)
|
||||||
|
deduped.append(t)
|
||||||
|
|
||||||
|
if not deduped:
|
||||||
|
continue
|
||||||
|
query = " ".join(deduped)
|
||||||
|
# when:Nd-Operator anhaengen (Google-News-Zeitfilter)
|
||||||
|
effective_query = query
|
||||||
|
if recency_days and recency_days > 0:
|
||||||
|
effective_query = f"{query} when:{recency_days}d"
|
||||||
|
if not effective_query or effective_query in seen_queries:
|
||||||
|
continue
|
||||||
|
seen_queries.add(effective_query)
|
||||||
|
|
||||||
|
hl, gl = locale
|
||||||
|
ceid_lang = hl.split("-")[0]
|
||||||
|
url = (
|
||||||
|
"https://news.google.com/rss/search?q="
|
||||||
|
+ urllib.parse.quote(effective_query)
|
||||||
|
+ f"&hl={hl}&gl={gl}&ceid={gl}:{ceid_lang}"
|
||||||
|
)
|
||||||
|
if recency_days and recency_days > 0:
|
||||||
|
name = f"Google News Suche ({lang_key}, letzte {recency_days}d): {query}"
|
||||||
|
domain = f"google-news-search-{lang_key}-recent"
|
||||||
|
else:
|
||||||
|
name = f"Google News Suche ({lang_key}): {query}"
|
||||||
|
domain = f"google-news-search-{lang_key}"
|
||||||
|
feeds.append({
|
||||||
|
"name": name,
|
||||||
|
"url": url,
|
||||||
|
# Eigene Domain-Gruppe, damit der Domain-Cap die Such-Feeds NICHT mit
|
||||||
|
# den site:-Google-News-Feeds in einen Topf wirft.
|
||||||
|
"domain": domain,
|
||||||
|
"primary_language": lang_key,
|
||||||
|
"category": "international",
|
||||||
|
"media_type": "",
|
||||||
|
})
|
||||||
|
logger.info("Google-News-Suchfeed (%s): q=%r", lang_key, effective_query)
|
||||||
|
return feeds
|
||||||
|
|
||||||
|
|
||||||
class ResearcherParseError(Exception):
|
class ResearcherParseError(Exception):
|
||||||
"""Claude hat eine nicht-leere Antwort geliefert, aus der kein JSON extrahiert werden konnte."""
|
"""Claude hat eine nicht-leere Antwort geliefert, aus der kein JSON extrahiert werden konnte."""
|
||||||
|
|
||||||
@@ -61,6 +180,87 @@ def _extract_json_object(text: str):
|
|||||||
return obj
|
return obj
|
||||||
idx = brace + 1
|
idx = brace + 1
|
||||||
|
|
||||||
|
|
||||||
|
def _normalize_keywords_dict(raw: dict) -> dict | None:
|
||||||
|
"""Normalisiert ein {iso_lang: [keywords]}-Dict aus Haiku-Output.
|
||||||
|
|
||||||
|
Wir wenden .lower() global an (Python case-folding lässt CJK unverändert und
|
||||||
|
lowercased kyrillisch/arabisch/hebräisch sinnvoll), damit der Match später
|
||||||
|
konsistent gegen den ebenfalls lowercased Headline-Text läuft.
|
||||||
|
|
||||||
|
Entfernt leere Strings und Duplikate. Gibt None zurück, wenn das Ergebnis leer ist.
|
||||||
|
"""
|
||||||
|
out: dict[str, list[str]] = {}
|
||||||
|
for lang, kws in raw.items():
|
||||||
|
if not isinstance(lang, str) or not isinstance(kws, list):
|
||||||
|
continue
|
||||||
|
lang_key = lang.lower().strip()
|
||||||
|
clean: list[str] = []
|
||||||
|
seen: set[str] = set()
|
||||||
|
for k in kws:
|
||||||
|
s = str(k).strip().lower()
|
||||||
|
if not s or s in seen:
|
||||||
|
continue
|
||||||
|
seen.add(s)
|
||||||
|
clean.append(s)
|
||||||
|
if clean:
|
||||||
|
out[lang_key] = clean
|
||||||
|
return out or None
|
||||||
|
|
||||||
|
|
||||||
|
def flatten_keywords(keywords_by_lang: dict | list | None) -> list[str]:
|
||||||
|
"""Bequeme Flachsicht aller Keywords (für Logging, Web-Source-Selektion etc.).
|
||||||
|
|
||||||
|
Akzeptiert auch die alte flache Liste, damit Aufrufer schrittweise migrieren können.
|
||||||
|
"""
|
||||||
|
if not keywords_by_lang:
|
||||||
|
return []
|
||||||
|
if isinstance(keywords_by_lang, list):
|
||||||
|
return [str(k).strip() for k in keywords_by_lang if str(k).strip()]
|
||||||
|
flat: list[str] = []
|
||||||
|
seen: set[str] = set()
|
||||||
|
for kws in keywords_by_lang.values():
|
||||||
|
if not isinstance(kws, list):
|
||||||
|
continue
|
||||||
|
for k in kws:
|
||||||
|
s = str(k).strip()
|
||||||
|
if not s or s in seen:
|
||||||
|
continue
|
||||||
|
seen.add(s)
|
||||||
|
flat.append(s)
|
||||||
|
return flat
|
||||||
|
|
||||||
|
|
||||||
|
def keywords_for_language(keywords_by_lang: dict | list | None, lang: str | None) -> list[str]:
|
||||||
|
"""Liefert die für eine konkrete Feed-/Channel-Sprache anwendbaren Keywords.
|
||||||
|
|
||||||
|
- Universelle "en"-Keywords (lateinische Eigennamen) immer mitgeben.
|
||||||
|
- Plus die Keywords der Feed-Sprache, falls vorhanden.
|
||||||
|
- Für unbekannte/None-Sprachen: alle Keywords (flach), damit kein Feed leer ausgeht.
|
||||||
|
- Akzeptiert auch alte flache Liste -> wird unverändert zurückgegeben.
|
||||||
|
"""
|
||||||
|
if not keywords_by_lang:
|
||||||
|
return []
|
||||||
|
if isinstance(keywords_by_lang, list):
|
||||||
|
return [str(k).strip() for k in keywords_by_lang if str(k).strip()]
|
||||||
|
if not lang:
|
||||||
|
return flatten_keywords(keywords_by_lang)
|
||||||
|
lang_key = lang.lower().strip()
|
||||||
|
out: list[str] = []
|
||||||
|
seen: set[str] = set()
|
||||||
|
for k_lang in ("en", lang_key):
|
||||||
|
for k in keywords_by_lang.get(k_lang, []) or []:
|
||||||
|
s = str(k).strip()
|
||||||
|
if not s or s in seen:
|
||||||
|
continue
|
||||||
|
seen.add(s)
|
||||||
|
out.append(s)
|
||||||
|
# Wenn weder "en" noch lang_key Treffer haben (z.B. Haiku-Schema-Mismatch):
|
||||||
|
# auf die universelle Flachsicht zurückfallen, damit der Feed nicht leer matched.
|
||||||
|
if not out:
|
||||||
|
return flatten_keywords(keywords_by_lang)
|
||||||
|
return out
|
||||||
|
|
||||||
RESEARCH_PROMPT_TEMPLATE = """Du bist ein OSINT-Recherche-Agent für ein Lagemonitoring-System.
|
RESEARCH_PROMPT_TEMPLATE = """Du bist ein OSINT-Recherche-Agent für ein Lagemonitoring-System.
|
||||||
AUSGABESPRACHE: {output_language}
|
AUSGABESPRACHE: {output_language}
|
||||||
- KEINE Gedankenstriche (— oder –) verwenden, stattdessen Kommas, Doppelpunkte oder neue Saetze.
|
- KEINE Gedankenstriche (— oder –) verwenden, stattdessen Kommas, Doppelpunkte oder neue Saetze.
|
||||||
@@ -192,7 +392,7 @@ LAGE: {title}
|
|||||||
KONTEXT: {description}
|
KONTEXT: {description}
|
||||||
INTERNATIONALE QUELLEN: {international}
|
INTERNATIONALE QUELLEN: {international}
|
||||||
|
|
||||||
FEEDS:
|
FEEDS (Format: Nr. Name (Domain, Sprache) [Kategorie]):
|
||||||
{feed_list}
|
{feed_list}
|
||||||
|
|
||||||
REGELN:
|
REGELN:
|
||||||
@@ -203,16 +403,23 @@ REGELN:
|
|||||||
- QUELLENVIELFALT: Wähle pro Domain maximal 2-3 Feeds. Bevorzuge eine breite Mischung aus verschiedenen Quellen statt vieler Feeds derselben Domain.
|
- QUELLENVIELFALT: Wähle pro Domain maximal 2-3 Feeds. Bevorzuge eine breite Mischung aus verschiedenen Quellen statt vieler Feeds derselben Domain.
|
||||||
|
|
||||||
KEYWORDS-REGELN:
|
KEYWORDS-REGELN:
|
||||||
- Generiere 5-10 thematisch relevante Suchbegriffe für das RSS-Matching
|
- Keywords werden nach Sprache GRUPPIERT zurückgegeben (siehe Format unten).
|
||||||
|
- "en" enthält universelle Begriffe (Eigennamen, Akronyme, lateinisch geschriebene Marken/Personen),
|
||||||
|
die in JEDER Sprache vorkommen (z.B. "iran", "trump", "takaichi", "sdf").
|
||||||
|
- Für JEDE Sprache, in der ausgewählte Feeds publizieren (z.B. "ja", "ru", "ar", "zh", "ko", "fa",
|
||||||
|
"he", "de"), MUSS zusätzlich eine Liste mit 3-8 Suchbegriffen in der jeweiligen ORIGINALSCHRIFT
|
||||||
|
generiert werden. Beispiel Japan: "ja": ["自衛隊", "憲法改正", "改憲", "9条", "防衛省"].
|
||||||
|
Beispiel Russland: "ru": ["украина", "путин", "москва", "санкции"].
|
||||||
|
- Wenn die Lage rein deutsch oder englisch ist und keine fremdsprachigen Feeds gewählt werden,
|
||||||
|
reichen "de" und/oder "en".
|
||||||
- Nur inhaltlich relevante Begriffe (Personen, Orte, Themen, Organisationen)
|
- Nur inhaltlich relevante Begriffe (Personen, Orte, Themen, Organisationen)
|
||||||
- KEINE Jahreszahlen (2024, 2025, 2026 etc.)
|
- KEINE Jahreszahlen (2024, 2025, 2026 etc.)
|
||||||
- KEINE Monatsnamen (Januar, Februar, März etc.)
|
- KEINE Monatsnamen (Januar, Februar, März etc.)
|
||||||
- KEINE generischen Wörter (aktuell, news, update etc.)
|
- KEINE generischen Wörter (aktuell, news, update etc.)
|
||||||
- Begriffe in Kleinbuchstaben
|
- Lateinische Begriffe in Kleinbuchstaben. CJK/Arabisch/Hebräisch/Kyrillisch wie üblich.
|
||||||
- Sowohl deutsche als auch englische Begriffe wo sinnvoll
|
|
||||||
|
|
||||||
Antworte NUR mit einem JSON-Objekt in diesem Format:
|
Antworte NUR mit einem JSON-Objekt in genau diesem Format:
|
||||||
{{"feeds": [1, 2, 5, 12], "keywords": ["begriff1", "begriff2", "begriff3"]}}"""
|
{{"feeds": [1, 2, 5, 12], "keywords": {{"de": ["..."], "en": ["..."], "ja": ["..."]}}}}"""
|
||||||
|
|
||||||
|
|
||||||
KEYWORD_EXTRACTION_PROMPT = """Analysiere diese aktuellen Nachrichten-Headlines und extrahiere die wichtigsten Suchbegriffe fuer RSS-Feed-Filterung.
|
KEYWORD_EXTRACTION_PROMPT = """Analysiere diese aktuellen Nachrichten-Headlines und extrahiere die wichtigsten Suchbegriffe fuer RSS-Feed-Filterung.
|
||||||
@@ -227,6 +434,11 @@ Generiere 5 Begriffspaare (DE + EN), mit denen neue RSS-Artikel zu diesem Thema
|
|||||||
Ein Artikel gilt als relevant, wenn mindestens 2 dieser Begriffe im Titel oder der Beschreibung vorkommen
|
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.
|
- bei spezifischen Begriffen (Eigennamen, lange Begriffe ab 7 Zeichen) reicht 1 Treffer.
|
||||||
|
|
||||||
|
Wenn das Thema einen klaren Länderbezug zu einem nicht-lateinischen Sprachraum hat (z.B. Japan,
|
||||||
|
China, Korea, Russland, Iran, Israel, arabische Welt), GIB ZUSAETZLICH ein Feld "extra" mit
|
||||||
|
schrift-spezifischen Keywords pro Sprache zurück (siehe Format unten). Diese matchen dann die
|
||||||
|
Original-Headlines in den jeweiligen Feeds.
|
||||||
|
|
||||||
REGELN:
|
REGELN:
|
||||||
- ZWINGEND: Eigennamen oder spezifische Begriffe aus dem THEMA (z.B. Personennamen, Tiernamen,
|
- ZWINGEND: Eigennamen oder spezifische Begriffe aus dem THEMA (z.B. Personennamen, Tiernamen,
|
||||||
Ortsnamen wie "timmy", "buckelwal", "merz", "dobrindt") MUESSEN als eigene Begriffspaare
|
Ortsnamen wie "timmy", "buckelwal", "merz", "dobrindt") MUESSEN als eigene Begriffspaare
|
||||||
@@ -238,11 +450,13 @@ REGELN:
|
|||||||
- Wenn DE und EN identisch sind (Eigennamen), trotzdem das Paar einreichen.
|
- 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,
|
- Begriffe muessen so gewaehlt sein, dass sie in kurzen RSS-Titeln matchen (einzelne Woerter,
|
||||||
keine Phrasen, keine Konjunktionen).
|
keine Phrasen, keine Konjunktionen).
|
||||||
- Alle Begriffe in Kleinbuchstaben.
|
- Lateinische Begriffe in Kleinbuchstaben. CJK/Arabisch/Hebräisch/Kyrillisch wie üblich.
|
||||||
- Exakt 5 Begriffspaare.
|
- Exakt 5 Begriffspaare im "pairs"-Array.
|
||||||
|
|
||||||
Antwort NUR als JSON-Array:
|
Antwort NUR als JSON-Objekt, z.B.:
|
||||||
[{{"de": "iran", "en": "iran"}}, {{"de": "israel", "en": "israel"}}, {{"de": "teheran", "en": "tehran"}}, {{"de": "luftangriff", "en": "airstrike"}}, {{"de": "trump", "en": "trump"}}]"""
|
{{"pairs": [{{"de": "japan", "en": "japan"}}, {{"de": "verfassung", "en": "constitution"}}, {{"de": "takaichi", "en": "takaichi"}}, {{"de": "selbstverteidigung", "en": "sdf"}}, {{"de": "pazifismus", "en": "pacifism"}}], "extra": {{"ja": ["自衛隊", "憲法改正", "改憲", "9条", "高市"]}}}}
|
||||||
|
|
||||||
|
Wenn kein nicht-lateinischer Sprachraum betroffen ist, lass "extra" weg oder gib `{{}}` zurück."""
|
||||||
|
|
||||||
|
|
||||||
WEB_SOURCE_SELECTION_PROMPT = """Du bist ein OSINT-Analyst. Pruefe diese eingetragenen Web-Quellen und waehle nur die thematisch passenden aus.
|
WEB_SOURCE_SELECTION_PROMPT = """Du bist ein OSINT-Analyst. Pruefe diese eingetragenen Web-Quellen und waehle nur die thematisch passenden aus.
|
||||||
@@ -282,6 +496,24 @@ REGELN:
|
|||||||
Antworte NUR mit einem JSON-Array der Kanal-Nummern, z.B.: [1, 3, 5, 12]"""
|
Antworte NUR mit einem JSON-Array der Kanal-Nummern, z.B.: [1, 3, 5, 12]"""
|
||||||
|
|
||||||
|
|
||||||
|
X_ACCOUNT_SELECTION_PROMPT = """Du bist ein OSINT-Analyst. Waehle aus dieser Liste von X-Accounts (Twitter) diejenigen aus, die fuer die Lage relevant sein koennten.
|
||||||
|
|
||||||
|
LAGE: {title}
|
||||||
|
KONTEXT: {description}
|
||||||
|
|
||||||
|
X-ACCOUNTS:
|
||||||
|
{account_list}
|
||||||
|
|
||||||
|
REGELN:
|
||||||
|
- Waehle alle Accounts die thematisch relevant sein koennten
|
||||||
|
- Lieber einen Account zu viel als zu wenig auswaehlen
|
||||||
|
- Beachte die Kategorie und Beschreibung jedes Accounts
|
||||||
|
- Allgemeine OSINT-Accounts sind oft relevant
|
||||||
|
- Bei geopolitischen Themen: Relevante Laender-/Regions-Accounts waehlen
|
||||||
|
|
||||||
|
Antworte NUR mit einem JSON-Array der Account-Nummern, z.B.: [1, 3, 5, 12]"""
|
||||||
|
|
||||||
|
|
||||||
class ResearcherAgent:
|
class ResearcherAgent:
|
||||||
"""Führt OSINT-Recherchen über Claude CLI WebSearch durch."""
|
"""Führt OSINT-Recherchen über Claude CLI WebSearch durch."""
|
||||||
|
|
||||||
@@ -291,19 +523,24 @@ class ResearcherAgent:
|
|||||||
description: str,
|
description: str,
|
||||||
international: bool,
|
international: bool,
|
||||||
feeds_metadata: list[dict],
|
feeds_metadata: list[dict],
|
||||||
) -> tuple[list[dict], list[str] | None, ClaudeUsage | None]:
|
) -> tuple[list[dict], dict | None, ClaudeUsage | None]:
|
||||||
"""Lässt Claude die relevanten Feeds für eine Lage vorauswählen.
|
"""Lässt Claude die relevanten Feeds für eine Lage vorauswählen.
|
||||||
|
|
||||||
Nutzt Haiku (CLAUDE_MODEL_FAST) für diese einfache Aufgabe.
|
Nutzt Haiku (CLAUDE_MODEL_FAST) für diese einfache Aufgabe.
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
(ausgewählte Feeds, keywords, usage) — Bei Fehler: (alle Feeds, None, None)
|
(ausgewählte Feeds, keywords_by_lang, usage)
|
||||||
|
keywords_by_lang ist ein Dict {iso_lang: [keyword, ...]} mit mindestens
|
||||||
|
den Schlüsseln, für die ausgewählte Feeds publizieren ("en" enthält
|
||||||
|
universelle/lateinische Begriffe, die in jedem Feed matchen).
|
||||||
|
Bei Fehler: (alle Feeds, None, usage_or_None).
|
||||||
"""
|
"""
|
||||||
# Feed-Liste als nummerierte Übersicht formatieren
|
# Feed-Liste als nummerierte Übersicht formatieren (mit Sprache)
|
||||||
feed_lines = []
|
feed_lines = []
|
||||||
for i, feed in enumerate(feeds_metadata, 1):
|
for i, feed in enumerate(feeds_metadata, 1):
|
||||||
|
lang = feed.get("primary_language") or "?"
|
||||||
feed_lines.append(
|
feed_lines.append(
|
||||||
f"{i}. {feed['name']} ({feed['domain']}) [{feed['category']}]"
|
f"{i}. {feed['name']} ({feed['domain']}, {lang}) [{feed['category']}]"
|
||||||
)
|
)
|
||||||
|
|
||||||
prompt = FEED_SELECTION_PROMPT_TEMPLATE.format(
|
prompt = FEED_SELECTION_PROMPT_TEMPLATE.format(
|
||||||
@@ -316,17 +553,25 @@ class ResearcherAgent:
|
|||||||
try:
|
try:
|
||||||
result, usage = await call_claude(prompt, tools=None, model=CLAUDE_MODEL_FAST)
|
result, usage = await call_claude(prompt, tools=None, model=CLAUDE_MODEL_FAST)
|
||||||
|
|
||||||
keywords = None
|
keywords_by_lang: dict | None = None
|
||||||
indices = None
|
indices = None
|
||||||
|
|
||||||
# Neues Format: {"feeds": [...], "keywords": [...]}
|
|
||||||
obj = _extract_json_object(result)
|
obj = _extract_json_object(result)
|
||||||
if isinstance(obj, dict) and isinstance(obj.get("feeds"), list):
|
if isinstance(obj, dict) and isinstance(obj.get("feeds"), list):
|
||||||
indices = obj["feeds"]
|
indices = obj["feeds"]
|
||||||
raw_keywords = obj.get("keywords", [])
|
raw_keywords = obj.get("keywords")
|
||||||
if isinstance(raw_keywords, list) and raw_keywords:
|
|
||||||
keywords = [str(k).lower().strip() for k in raw_keywords if k]
|
# Neues Format: {"de": [...], "en": [...], "ja": [...]}
|
||||||
logger.info(f"Feed-Selektion Keywords: {keywords}")
|
if isinstance(raw_keywords, dict):
|
||||||
|
keywords_by_lang = _normalize_keywords_dict(raw_keywords)
|
||||||
|
# Backward-Format: flache Liste -> als "en" speichern (universell behandelt)
|
||||||
|
elif isinstance(raw_keywords, list) and raw_keywords:
|
||||||
|
flat = [str(k).strip() for k in raw_keywords if str(k).strip()]
|
||||||
|
if flat:
|
||||||
|
keywords_by_lang = {"en": [w.lower() for w in flat]}
|
||||||
|
|
||||||
|
if keywords_by_lang:
|
||||||
|
logger.info(f"Feed-Selektion Keywords (Sprachen): {keywords_by_lang}")
|
||||||
|
|
||||||
# Fallback: nacktes Array
|
# Fallback: nacktes Array
|
||||||
if indices is None:
|
if indices is None:
|
||||||
@@ -346,12 +591,12 @@ class ResearcherAgent:
|
|||||||
|
|
||||||
if not selected:
|
if not selected:
|
||||||
logger.warning("Feed-Selektion: Keine gültigen Indizes, nutze alle Feeds")
|
logger.warning("Feed-Selektion: Keine gültigen Indizes, nutze alle Feeds")
|
||||||
return feeds_metadata, keywords, usage
|
return feeds_metadata, keywords_by_lang, usage
|
||||||
|
|
||||||
logger.info(
|
logger.info(
|
||||||
f"Feed-Selektion: {len(selected)} von {len(feeds_metadata)} Feeds ausgewählt"
|
f"Feed-Selektion: {len(selected)} von {len(feeds_metadata)} Feeds ausgewählt"
|
||||||
)
|
)
|
||||||
return selected, keywords, usage
|
return selected, keywords_by_lang, usage
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.warning(f"Feed-Selektion fehlgeschlagen ({e}), nutze alle Feeds")
|
logger.warning(f"Feed-Selektion fehlgeschlagen ({e}), nutze alle Feeds")
|
||||||
@@ -360,11 +605,14 @@ class ResearcherAgent:
|
|||||||
|
|
||||||
async def extract_dynamic_keywords(
|
async def extract_dynamic_keywords(
|
||||||
self, title: str, recent_headlines: list[str]
|
self, title: str, recent_headlines: list[str]
|
||||||
) -> tuple[list[str] | None, ClaudeUsage | None]:
|
) -> tuple[dict | None, ClaudeUsage | None]:
|
||||||
"""Extrahiert aktuelle Suchbegriffe aus den letzten Headlines via Haiku.
|
"""Extrahiert aktuelle Suchbegriffe aus den letzten Headlines via Haiku.
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
(flache Keyword-Liste DE+EN, usage) oder (None, None) bei Fehler
|
(keywords_by_lang, usage) oder (None, None) bei Fehler.
|
||||||
|
keywords_by_lang ist ein Dict {iso_lang: [keyword,...]}, mit mindestens
|
||||||
|
"de" und "en" gefüllt, optional zusätzlich "ja"/"zh"/"ko"/"ar"/"he"/"fa"/"ru"
|
||||||
|
bei nicht-lateinischen Sprachräumen.
|
||||||
"""
|
"""
|
||||||
if not recent_headlines:
|
if not recent_headlines:
|
||||||
return None, None
|
return None, None
|
||||||
@@ -378,25 +626,38 @@ class ResearcherAgent:
|
|||||||
try:
|
try:
|
||||||
result, usage = await call_claude(prompt, tools=None, model=CLAUDE_MODEL_FAST)
|
result, usage = await call_claude(prompt, tools=None, model=CLAUDE_MODEL_FAST)
|
||||||
|
|
||||||
parsed = _extract_json_array(result)
|
# Neues Format: {"pairs": [...], "extra": {"ja": [...]}}
|
||||||
if not isinstance(parsed, list):
|
obj = _extract_json_object(result)
|
||||||
logger.warning(
|
pairs_raw = None
|
||||||
"Keyword-Extraktion: Kein gueltiges JSON erhalten. Sample: %s",
|
extra_raw: dict = {}
|
||||||
_truncate_for_log(result),
|
if isinstance(obj, dict) and isinstance(obj.get("pairs"), list):
|
||||||
)
|
pairs_raw = obj["pairs"]
|
||||||
return None, usage
|
extra = obj.get("extra")
|
||||||
|
if isinstance(extra, dict):
|
||||||
|
extra_raw = extra
|
||||||
|
else:
|
||||||
|
# Backward: nacktes Array von {de,en}-Paaren
|
||||||
|
arr = _extract_json_array(result)
|
||||||
|
if isinstance(arr, list):
|
||||||
|
pairs_raw = arr
|
||||||
|
else:
|
||||||
|
logger.warning(
|
||||||
|
"Keyword-Extraktion: Kein gueltiges JSON erhalten. Sample: %s",
|
||||||
|
_truncate_for_log(result),
|
||||||
|
)
|
||||||
|
return None, usage
|
||||||
|
|
||||||
# Flache Liste: alle DE + EN Begriffe
|
de_list: list[str] = []
|
||||||
keywords = []
|
en_list: list[str] = []
|
||||||
for entry in parsed:
|
for entry in pairs_raw or []:
|
||||||
if not isinstance(entry, dict):
|
if not isinstance(entry, dict):
|
||||||
continue
|
continue
|
||||||
de = entry.get("de", "").lower().strip()
|
de = str(entry.get("de", "")).lower().strip()
|
||||||
en = entry.get("en", "").lower().strip()
|
en = str(entry.get("en", "")).lower().strip()
|
||||||
if de:
|
if de and de not in de_list:
|
||||||
keywords.append(de)
|
de_list.append(de)
|
||||||
if en and en != de:
|
if en and en not in en_list:
|
||||||
keywords.append(en)
|
en_list.append(en)
|
||||||
|
|
||||||
# Bug-2-Fallback: Lagentitel-Wörter (>=4 Zeichen) zwingend in Keyword-Liste,
|
# Bug-2-Fallback: Lagentitel-Wörter (>=4 Zeichen) zwingend in Keyword-Liste,
|
||||||
# falls Haiku sie weggelassen hat. Verhindert "Buckelwal timmy"-Bug, bei dem
|
# falls Haiku sie weggelassen hat. Verhindert "Buckelwal timmy"-Bug, bei dem
|
||||||
@@ -405,26 +666,60 @@ class ResearcherAgent:
|
|||||||
"the", "and", "for", "with", "ueber", "über", "von", "for"}
|
"the", "and", "for", "with", "ueber", "über", "von", "for"}
|
||||||
for word in (title or "").lower().split():
|
for word in (title or "").lower().split():
|
||||||
w = word.strip(".,;:!?\"\'()[]{}")
|
w = word.strip(".,;:!?\"\'()[]{}")
|
||||||
if len(w) >= 4 and w not in STOPWORDS and w not in keywords:
|
if len(w) >= 4 and w not in STOPWORDS:
|
||||||
keywords.append(w)
|
if w not in en_list:
|
||||||
logger.info(f"Lagentitel-Keyword '{w}' nachträglich injiziert")
|
en_list.append(w)
|
||||||
|
logger.info(f"Lagentitel-Keyword '{w}' nachträglich injiziert")
|
||||||
|
|
||||||
if keywords:
|
keywords_by_lang: dict[str, list[str]] = {}
|
||||||
logger.info(f"Dynamische Keywords ({len(keywords)}): {keywords}")
|
if de_list:
|
||||||
return keywords if keywords else None, usage
|
keywords_by_lang["de"] = de_list
|
||||||
|
if en_list:
|
||||||
|
keywords_by_lang["en"] = en_list
|
||||||
|
|
||||||
|
# Extra-Sprachen mit übernehmen
|
||||||
|
extra_norm = _normalize_keywords_dict(extra_raw) if extra_raw else None
|
||||||
|
if extra_norm:
|
||||||
|
for lang, kws in extra_norm.items():
|
||||||
|
keywords_by_lang.setdefault(lang, [])
|
||||||
|
for k in kws:
|
||||||
|
if k not in keywords_by_lang[lang]:
|
||||||
|
keywords_by_lang[lang].append(k)
|
||||||
|
|
||||||
|
if not keywords_by_lang:
|
||||||
|
return None, usage
|
||||||
|
|
||||||
|
logger.info(
|
||||||
|
"Dynamische Keywords (Sprachen): %s",
|
||||||
|
{k: len(v) for k, v in keywords_by_lang.items()},
|
||||||
|
)
|
||||||
|
return keywords_by_lang, usage
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.warning(f"Keyword-Extraktion fehlgeschlagen: {e}")
|
logger.warning(f"Keyword-Extraktion fehlgeschlagen: {e}")
|
||||||
return None, None
|
return None, None
|
||||||
|
|
||||||
async def search(self, title: str, description: str = "", incident_type: str = "adhoc", international: bool = True, user_id: int = None, existing_articles: list[dict] = None, preferred_sources: list[dict] = None, output_language: str = "Deutsch", output_language_iso: str = "de") -> tuple[list[dict], ClaudeUsage | None, bool]:
|
async def search(self, title: str, description: str = "", incident_type: str = "adhoc", international: bool = True, user_id: int = None, existing_articles: list[dict] = None, preferred_sources: list[dict] = None, output_language: str = "Deutsch", output_language_iso: str = "de", research_language_iso: str | None = None) -> tuple[list[dict], ClaudeUsage | None, bool]:
|
||||||
"""Sucht nach Informationen zu einem Vorfall.
|
"""Sucht nach Informationen zu einem Vorfall.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
output_language / output_language_iso: Ausgabesprache (Lagebild-Sprache).
|
||||||
|
research_language_iso: optionaler Override fuer die Sprache, in der gesucht
|
||||||
|
werden soll. Default = output_language_iso. Bei jp_demo z.B. 'ja',
|
||||||
|
waehrend output_language_iso 'de' bleibt (Lagebild deutsch, Recherche japanisch).
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
(artikel, usage, parse_failed) — parse_failed ist True, wenn Claude geantwortet hat,
|
(artikel, usage, parse_failed) — parse_failed ist True, wenn Claude geantwortet hat,
|
||||||
das JSON aber nicht extrahierbar war. So kann der Orchestrator zwischen
|
das JSON aber nicht extrahierbar war. So kann der Orchestrator zwischen
|
||||||
"echt keine Treffer" und "kaputte Antwort" unterscheiden.
|
"echt keine Treffer" und "kaputte Antwort" unterscheiden.
|
||||||
"""
|
"""
|
||||||
|
# research_language defaultet auf output_language. Wenn das aber abweicht
|
||||||
|
# (z.B. jp_demo: research='ja', output='de'), ueberschreiben wir die
|
||||||
|
# Sprach-Anweisung im Prompt mit einer eigenen, dual-sprachigen Variante.
|
||||||
|
research_language_iso = (research_language_iso or output_language_iso or "de").lower()
|
||||||
|
# Display-Name der Recherche-Sprache fuer Prompts ("Japanese", "Russian", ...)
|
||||||
|
from services.org_settings import language_display as _lang_display
|
||||||
|
research_language_display = _lang_display(research_language_iso)
|
||||||
# Bevorzugte Web-Quellen als Prompt-Block (optional)
|
# Bevorzugte Web-Quellen als Prompt-Block (optional)
|
||||||
preferred_sources_block = ""
|
preferred_sources_block = ""
|
||||||
if preferred_sources:
|
if preferred_sources:
|
||||||
@@ -444,8 +739,31 @@ class ResearcherAgent:
|
|||||||
"aber nicht deine sonstige Recherche.\n"
|
"aber nicht deine sonstige Recherche.\n"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
# Asymmetrische Sprach-Auswahl: research_language weicht von output_language ab
|
||||||
|
# -> eigene Anweisung "primaer in research-language, englische Quellen aus der
|
||||||
|
# Region auch erlaubt". Sonst die bisherige Logik (primary_only vs international).
|
||||||
|
asymmetric_lang = research_language_iso != output_language_iso
|
||||||
|
|
||||||
|
def _build_lang_instruction(deep: bool) -> str:
|
||||||
|
if asymmetric_lang:
|
||||||
|
# jp_demo & Co.: Recherche in Quellsprache + lokale Englisch-Outlets.
|
||||||
|
return (
|
||||||
|
f"- Fokus liegt auf {research_language_display}-sprachigen Quellen "
|
||||||
|
f"(Behoerden, Qualitaetszeitungen, oeffentlich-rechtliche Medien dieser Sprache).\n"
|
||||||
|
f"- Englischsprachige Outlets mit Fokus auf demselben Sprachraum/Region sind "
|
||||||
|
f"ebenfalls willkommen (z.B. Japan Times, Nikkei Asia, Kyodo English fuer Japan; "
|
||||||
|
f"Moscow Times English fuer Russland).\n"
|
||||||
|
f"- Quellen ausserhalb des Sprachraums NUR, wenn sie exklusive Informationen "
|
||||||
|
f"ueber die Region liefern (z.B. Reuters/AFP/AP-Berichte aus der Region).\n"
|
||||||
|
f"- Antworte in der Ausgabesprache {output_language} (das Lagebild wird in "
|
||||||
|
f"{output_language} angezeigt), aber zitiere die Original-Headlines/Quellen unveraendert."
|
||||||
|
)
|
||||||
|
if deep:
|
||||||
|
return lang_deep_international(output_language) if international else lang_deep_primary_only(output_language)
|
||||||
|
return lang_international(output_language) if international else lang_primary_only(output_language)
|
||||||
|
|
||||||
if incident_type == "research":
|
if incident_type == "research":
|
||||||
lang_instruction = lang_deep_international(output_language) if international else lang_deep_primary_only(output_language)
|
lang_instruction = _build_lang_instruction(deep=True)
|
||||||
# Bestehende Artikel als Kontext für den Prompt aufbereiten
|
# Bestehende Artikel als Kontext für den Prompt aufbereiten
|
||||||
existing_context = ""
|
existing_context = ""
|
||||||
if existing_articles:
|
if existing_articles:
|
||||||
@@ -466,7 +784,7 @@ class ResearcherAgent:
|
|||||||
preferred_sources_block=preferred_sources_block,
|
preferred_sources_block=preferred_sources_block,
|
||||||
)
|
)
|
||||||
else:
|
else:
|
||||||
lang_instruction = lang_international(output_language) if international else lang_primary_only(output_language)
|
lang_instruction = _build_lang_instruction(deep=False)
|
||||||
# Bestehende Artikel als Kontext: bei Folge-Refreshes findet Claude andere Quellen
|
# Bestehende Artikel als Kontext: bei Folge-Refreshes findet Claude andere Quellen
|
||||||
existing_context = ""
|
existing_context = ""
|
||||||
if existing_articles:
|
if existing_articles:
|
||||||
@@ -716,3 +1034,62 @@ class ResearcherAgent:
|
|||||||
logger.warning("Telegram-Selektion fehlgeschlagen (%s), nutze alle Kanaele", e)
|
logger.warning("Telegram-Selektion fehlgeschlagen (%s), nutze alle Kanaele", e)
|
||||||
return channels_metadata, None
|
return channels_metadata, None
|
||||||
|
|
||||||
|
async def select_relevant_x_accounts(
|
||||||
|
self,
|
||||||
|
title: str,
|
||||||
|
description: str,
|
||||||
|
accounts_metadata: list[dict],
|
||||||
|
) -> tuple[list[dict], ClaudeUsage | None]:
|
||||||
|
"""Laesst Claude die relevanten X-Accounts fuer eine Lage vorauswaehlen.
|
||||||
|
|
||||||
|
Nutzt Haiku (CLAUDE_MODEL_FAST) fuer diese einfache Aufgabe.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
(ausgewaehlte Accounts, usage) -- Bei Fehler: (alle Accounts, None)
|
||||||
|
"""
|
||||||
|
if len(accounts_metadata) <= 10:
|
||||||
|
logger.info("X-Selektion: Nur %d Accounts, nutze alle", len(accounts_metadata))
|
||||||
|
return accounts_metadata, None
|
||||||
|
|
||||||
|
account_lines = []
|
||||||
|
for i, acc in enumerate(accounts_metadata, 1):
|
||||||
|
cat = acc.get("category", "sonstige")
|
||||||
|
notes = (acc.get("notes") or "")[:100]
|
||||||
|
account_lines.append(f"{i}. {acc['name']} [{cat}] - {notes}")
|
||||||
|
|
||||||
|
prompt = X_ACCOUNT_SELECTION_PROMPT.format(
|
||||||
|
title=title,
|
||||||
|
description=description or "Keine weitere Beschreibung",
|
||||||
|
account_list="\n".join(account_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(
|
||||||
|
"X-Selektion: Kein JSON in Antwort, nutze alle Accounts. Sample: %s",
|
||||||
|
_truncate_for_log(result),
|
||||||
|
)
|
||||||
|
return accounts_metadata, usage
|
||||||
|
|
||||||
|
selected = []
|
||||||
|
for idx in indices:
|
||||||
|
if isinstance(idx, int) and 1 <= idx <= len(accounts_metadata):
|
||||||
|
selected.append(accounts_metadata[idx - 1])
|
||||||
|
|
||||||
|
if not selected:
|
||||||
|
logger.warning("X-Selektion: Keine gueltigen Indizes, nutze alle Accounts")
|
||||||
|
return accounts_metadata, usage
|
||||||
|
|
||||||
|
logger.info(
|
||||||
|
"X-Selektion: %d von %d Accounts ausgewaehlt",
|
||||||
|
len(selected), len(accounts_metadata)
|
||||||
|
)
|
||||||
|
return selected, usage
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning("X-Selektion fehlgeschlagen (%s), nutze alle Accounts", e)
|
||||||
|
return accounts_metadata, None
|
||||||
|
|
||||||
|
|||||||
@@ -215,25 +215,185 @@ async def translate_articles_batch(
|
|||||||
return valid, usage
|
return valid, usage
|
||||||
|
|
||||||
|
|
||||||
|
# --- Pre-Topic-Filter: schmale Headline-Übersetzung -----------------------------
|
||||||
|
#
|
||||||
|
# Der Topic-Filter (analyzer.filter_relevant_articles) ist ein Haiku-Call, der pro
|
||||||
|
# Artikel beurteilt, ob er thematisch zur Lage passt. Bei fremdsprachigen Headlines
|
||||||
|
# (CJK/Arabisch/Hebräisch/Kyrillisch) bewertet Haiku konservativ und verwirft sie
|
||||||
|
# häufig, weil er sie nur halb versteht. Damit landeten z.B. die japanischen
|
||||||
|
# Ministeriums-Feeds (MOD, NHK, Asahi) in Lagen mit Japan-Bezug nie in der finalen
|
||||||
|
# Auswahl, obwohl der RSS-Match korrekt griff.
|
||||||
|
#
|
||||||
|
# Diese Funktion übersetzt einen einzelnen Batch-Call alle nicht-lateinischen
|
||||||
|
# Headlines + erste Content-Sätze ins Englische und hängt das Ergebnis als
|
||||||
|
# article["headline_en_for_topic"] / article["content_en_for_topic"] an. Der
|
||||||
|
# Topic-Filter zeigt das dem LLM zusätzlich zum Original.
|
||||||
|
#
|
||||||
|
# WICHTIG: Diese Mini-Übersetzung ist UNABHÄNGIG vom TRANSLATOR_ENABLED-Flag —
|
||||||
|
# sie wird auch dann gemacht, wenn der nachgelagerte Volltext-Translator
|
||||||
|
# deaktiviert ist (Pflicht für korrektes Topic-Filtering, sehr kleine Kosten).
|
||||||
|
|
||||||
|
_TOPIC_TRANSLATE_CONTENT_MAX = 500
|
||||||
|
|
||||||
|
|
||||||
|
def _needs_pretopic_translate(article: dict) -> bool:
|
||||||
|
"""Erkennt fremdsprachige Headlines, die für den Topic-Filter übersetzt
|
||||||
|
werden sollten.
|
||||||
|
|
||||||
|
Heuristik: Headline enthält Non-ASCII-Zeichen, die NICHT in den typischen
|
||||||
|
deutsch/franz./span./port./skand. Latin-1-Erweiterungen liegen.
|
||||||
|
Das sind v.a. CJK (Kanji/Kana/Hangul), Arabisch, Hebräisch, Kyrillisch,
|
||||||
|
Thai, Devanagari etc.
|
||||||
|
"""
|
||||||
|
headline = (article.get("headline_de") or article.get("headline") or "").strip()
|
||||||
|
if not headline:
|
||||||
|
return False
|
||||||
|
for ch in headline:
|
||||||
|
cp = ord(ch)
|
||||||
|
# Bereiche ausschließen, die in Latin-Schrift normal sind:
|
||||||
|
# ASCII (0-127), Latin-1 Supplement (128-255), Latin Extended-A/B (256-591)
|
||||||
|
if cp <= 591:
|
||||||
|
continue
|
||||||
|
# Alles darüber sind fremde Schriftsysteme → übersetzen
|
||||||
|
return True
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
async def translate_headlines_for_topic_filter(
|
||||||
|
articles: list[dict],
|
||||||
|
target_lang: str = "en",
|
||||||
|
) -> tuple[int, ClaudeUsage]:
|
||||||
|
"""Übersetzt die Headlines fremdsprachiger Artikel ins Englische, damit der
|
||||||
|
nachgelagerte Topic-Filter (Haiku) sie zuverlässig beurteilen kann.
|
||||||
|
|
||||||
|
Setzt direkt auf den Artikel-Dicts:
|
||||||
|
article["headline_en_for_topic"]: str | None
|
||||||
|
article["content_en_for_topic"]: str | None
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
(anzahl_übersetzt, ClaudeUsage)
|
||||||
|
"""
|
||||||
|
if not articles:
|
||||||
|
return 0, ClaudeUsage()
|
||||||
|
|
||||||
|
candidates = [a for a in articles if _needs_pretopic_translate(a)]
|
||||||
|
if not candidates:
|
||||||
|
return 0, ClaudeUsage()
|
||||||
|
|
||||||
|
# Eindeutige Indizes (auch wenn article kein "id"-Feld hat, weil noch nicht
|
||||||
|
# in der DB): wir nutzen die Position in der gesamten articles-Liste.
|
||||||
|
idx_by_obj = {id(a): i for i, a in enumerate(articles)}
|
||||||
|
|
||||||
|
items = []
|
||||||
|
for a in candidates:
|
||||||
|
idx = idx_by_obj.get(id(a))
|
||||||
|
if idx is None:
|
||||||
|
continue
|
||||||
|
headline = (a.get("headline_de") or a.get("headline") or "").strip()
|
||||||
|
content_src = (a.get("content_de") or a.get("content_original") or "")
|
||||||
|
items.append({
|
||||||
|
"i": idx,
|
||||||
|
"h": headline[:200],
|
||||||
|
"c": content_src[:_TOPIC_TRANSLATE_CONTENT_MAX],
|
||||||
|
})
|
||||||
|
|
||||||
|
if not items:
|
||||||
|
return 0, ClaudeUsage()
|
||||||
|
|
||||||
|
lang_label = {"en": "English", "de": "German"}.get(target_lang, target_lang)
|
||||||
|
prompt = f"""Translate these news headlines and short content snippets to {lang_label}.
|
||||||
|
Keep proper names (people, organizations, places) untouched. Keep it concise; the goal
|
||||||
|
is to let another model judge topical relevance, not to publish.
|
||||||
|
|
||||||
|
Return ONLY a JSON array. Each item: {{"i": <index>, "h": <headline in {lang_label}>, "c": <content snippet in {lang_label}>}}.
|
||||||
|
Keep the same "i" values. No prose, no markdown fences.
|
||||||
|
|
||||||
|
INPUT:
|
||||||
|
{json.dumps(items, ensure_ascii=False)}
|
||||||
|
"""
|
||||||
|
|
||||||
|
try:
|
||||||
|
result_text, usage = await call_claude(prompt, tools=None, model=CLAUDE_MODEL_FAST)
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning(f"Pre-Topic-Translate Claude-Call fehlgeschlagen: {e}")
|
||||||
|
return 0, ClaudeUsage()
|
||||||
|
|
||||||
|
# Robustes Parsing (Markdown-Codefence + nacktes Array)
|
||||||
|
text = result_text.strip()
|
||||||
|
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:
|
||||||
|
m = re.search(r"\[.*\]", text, re.DOTALL)
|
||||||
|
if not m:
|
||||||
|
logger.warning(
|
||||||
|
f"Pre-Topic-Translate: kein JSON-Array in Antwort. Sample: {text[:200]!r}"
|
||||||
|
)
|
||||||
|
return 0, usage
|
||||||
|
try:
|
||||||
|
data = json.loads(m.group(0))
|
||||||
|
except json.JSONDecodeError:
|
||||||
|
data = _extract_complete_objects(text)
|
||||||
|
|
||||||
|
if not isinstance(data, list):
|
||||||
|
logger.warning(
|
||||||
|
f"Pre-Topic-Translate: Antwort ist kein Array ({type(data).__name__})"
|
||||||
|
)
|
||||||
|
return 0, usage
|
||||||
|
|
||||||
|
applied = 0
|
||||||
|
for entry in data:
|
||||||
|
if not isinstance(entry, dict):
|
||||||
|
continue
|
||||||
|
idx = entry.get("i")
|
||||||
|
if not isinstance(idx, int) or not (0 <= idx < len(articles)):
|
||||||
|
try:
|
||||||
|
idx = int(idx)
|
||||||
|
if not (0 <= idx < len(articles)):
|
||||||
|
continue
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
continue
|
||||||
|
h = (entry.get("h") or "").strip() or None
|
||||||
|
c = (entry.get("c") or "").strip() or None
|
||||||
|
if h:
|
||||||
|
articles[idx]["headline_en_for_topic"] = h
|
||||||
|
if c:
|
||||||
|
articles[idx]["content_en_for_topic"] = c
|
||||||
|
if h or c:
|
||||||
|
applied += 1
|
||||||
|
|
||||||
|
return applied, usage
|
||||||
|
|
||||||
|
|
||||||
async def translate_articles(
|
async def translate_articles(
|
||||||
articles: list[dict],
|
articles: list[dict],
|
||||||
output_lang: str = "de",
|
output_lang: str = "de",
|
||||||
batch_size: int = DEFAULT_BATCH_SIZE,
|
batch_size: int = DEFAULT_BATCH_SIZE,
|
||||||
usage_accumulator: UsageAccumulator | None = None,
|
usage_accumulator: UsageAccumulator | None = None,
|
||||||
|
enabled: bool | None = None,
|
||||||
) -> list[dict]:
|
) -> list[dict]:
|
||||||
"""Uebersetzt eine beliebige Anzahl Artikel in Batches.
|
"""Uebersetzt eine beliebige Anzahl Artikel in Batches.
|
||||||
|
|
||||||
Bringt die Batches durch Logik in `translate_articles_batch` und gibt
|
Bringt die Batches durch Logik in `translate_articles_batch` und gibt
|
||||||
EINE flache Liste der Translations zurueck. Wenn ein Batch fehlschlaegt,
|
EINE flache Liste der Translations zurueck. Wenn ein Batch fehlschlaegt,
|
||||||
wird er uebersprungen (anderer Batches laufen weiter).
|
wird er uebersprungen (anderer Batches laufen weiter).
|
||||||
|
|
||||||
|
enabled: Pro-Aufruf-Override des globalen TRANSLATOR_ENABLED-Flags. Wenn None,
|
||||||
|
greift das Modul-Default (config.TRANSLATOR_ENABLED, abgeleitet aus .env).
|
||||||
|
Der Orchestrator setzt das aus dem Org-Setting 'translator_enabled', damit
|
||||||
|
jp_demo (Translator zwingend an) trotz global deaktiviertem Flag funktioniert.
|
||||||
"""
|
"""
|
||||||
if not articles:
|
if not articles:
|
||||||
return []
|
return []
|
||||||
|
|
||||||
if not TRANSLATOR_ENABLED:
|
is_enabled = TRANSLATOR_ENABLED if enabled is None else bool(enabled)
|
||||||
|
if not is_enabled:
|
||||||
logger.info(
|
logger.info(
|
||||||
"Translator deaktiviert (TRANSLATOR_ENABLED=false), %d Artikel uebersprungen",
|
"Translator deaktiviert (enabled=%s, global TRANSLATOR_ENABLED=%s), %d Artikel uebersprungen",
|
||||||
len(articles),
|
enabled, TRANSLATOR_ENABLED, len(articles),
|
||||||
)
|
)
|
||||||
return []
|
return []
|
||||||
|
|
||||||
|
|||||||
@@ -97,6 +97,19 @@ TELEGRAM_API_ID = int(os.environ.get("TELEGRAM_API_ID", "0"))
|
|||||||
TELEGRAM_API_HASH = os.environ.get("TELEGRAM_API_HASH", "")
|
TELEGRAM_API_HASH = os.environ.get("TELEGRAM_API_HASH", "")
|
||||||
TELEGRAM_SESSION_PATH = os.environ.get("TELEGRAM_SESSION_PATH", "/home/claude-dev/.telegram/telegram_session")
|
TELEGRAM_SESSION_PATH = os.environ.get("TELEGRAM_SESSION_PATH", "/home/claude-dev/.telegram/telegram_session")
|
||||||
|
|
||||||
|
# X / Twitter (twscrape) -- siehe feeds/x_parser.py
|
||||||
|
# Scraper liest Account-Timelines konfigurierter X-Quellen (source_type='x_account').
|
||||||
|
X_SCRAPER_ENABLED = os.environ.get("X_SCRAPER_ENABLED", "true").lower() == "true"
|
||||||
|
# twscrape-Account-Store (SQLite). Liegt ausserhalb des Repos.
|
||||||
|
X_ACCOUNTS_DB_PATH = os.environ.get("X_ACCOUNTS_DB_PATH", "/home/claude-dev/.x-scraper/accounts.db")
|
||||||
|
# HTTP-Proxy fuer den X-Egress (tinyproxy am RUTX11 ueber WireGuard).
|
||||||
|
# Leer = direkter Abruf ueber die Server-IP. Bei gesetztem Wert prueft der
|
||||||
|
# Parser den Proxy vor jedem Lauf und faellt bei Ausfall auf direkt zurueck.
|
||||||
|
X_PROXY_URL = os.environ.get("X_PROXY_URL", "")
|
||||||
|
# Max. Posts pro Account-Timeline und Recency-Fenster in Tagen.
|
||||||
|
X_POST_CAP_PER_ACCOUNT = int(os.environ.get("X_POST_CAP_PER_ACCOUNT", "40"))
|
||||||
|
X_RECENCY_DAYS = int(os.environ.get("X_RECENCY_DAYS", "14"))
|
||||||
|
|
||||||
# Health-Check (genutzt von services/source_health.py)
|
# Health-Check (genutzt von services/source_health.py)
|
||||||
HEALTH_CHECK_USER_AGENT = os.environ.get(
|
HEALTH_CHECK_USER_AGENT = os.environ.get(
|
||||||
"HEALTH_CHECK_USER_AGENT",
|
"HEALTH_CHECK_USER_AGENT",
|
||||||
|
|||||||
@@ -403,6 +403,11 @@ async def init_db():
|
|||||||
await db.commit()
|
await db.commit()
|
||||||
logger.info("Migration: include_telegram zu incidents hinzugefuegt")
|
logger.info("Migration: include_telegram zu incidents hinzugefuegt")
|
||||||
|
|
||||||
|
if "include_x" not in columns:
|
||||||
|
await db.execute("ALTER TABLE incidents ADD COLUMN include_x INTEGER DEFAULT 0")
|
||||||
|
await db.commit()
|
||||||
|
logger.info("Migration: include_x zu incidents hinzugefuegt")
|
||||||
|
|
||||||
if "telegram_categories" not in columns:
|
if "telegram_categories" not in columns:
|
||||||
await db.execute("ALTER TABLE incidents ADD COLUMN telegram_categories TEXT DEFAULT NULL")
|
await db.execute("ALTER TABLE incidents ADD COLUMN telegram_categories TEXT DEFAULT NULL")
|
||||||
await db.commit()
|
await db.commit()
|
||||||
@@ -429,6 +434,16 @@ async def init_db():
|
|||||||
await db.commit()
|
await db.commit()
|
||||||
logger.info("Migration: latest_developments zu incidents hinzugefuegt")
|
logger.info("Migration: latest_developments zu incidents hinzugefuegt")
|
||||||
|
|
||||||
|
if "public_mood" not in columns:
|
||||||
|
await db.execute("ALTER TABLE incidents ADD COLUMN public_mood TEXT")
|
||||||
|
await db.commit()
|
||||||
|
logger.info("Migration: public_mood zu incidents hinzugefuegt")
|
||||||
|
|
||||||
|
if "public_mood_updated_at" not in columns:
|
||||||
|
await db.execute("ALTER TABLE incidents ADD COLUMN public_mood_updated_at TIMESTAMP")
|
||||||
|
await db.commit()
|
||||||
|
logger.info("Migration: public_mood_updated_at zu incidents hinzugefuegt")
|
||||||
|
|
||||||
# Migration: Tabelle podcast_transcripts (URL-Cache fuer Transkripte)
|
# Migration: Tabelle podcast_transcripts (URL-Cache fuer Transkripte)
|
||||||
cursor = await db.execute(
|
cursor = await db.execute(
|
||||||
"SELECT name FROM sqlite_master WHERE type='table' AND name='podcast_transcripts'"
|
"SELECT name FROM sqlite_master WHERE type='table' AND name='podcast_transcripts'"
|
||||||
|
|||||||
@@ -6,12 +6,32 @@ import httpx
|
|||||||
from datetime import datetime, timezone
|
from datetime import datetime, timezone
|
||||||
from config import TIMEZONE, MAX_ARTICLES_PER_DOMAIN_RSS
|
from config import TIMEZONE, MAX_ARTICLES_PER_DOMAIN_RSS
|
||||||
from source_rules import _extract_domain
|
from source_rules import _extract_domain
|
||||||
|
|
||||||
|
# Cap fuer dynamische Google-News-Suchfeeds — hoeher als der normale Domain-Cap,
|
||||||
|
# weil ein Suchfeed gezielt fuer breiten Recall gebaut wird. Topic-Filter
|
||||||
|
# entscheidet danach ueber die Precision.
|
||||||
|
MAX_ARTICLES_PER_DOMAIN_RSS_SEARCH = 25
|
||||||
from feeds.transcript_extractors._common import html_to_text
|
from feeds.transcript_extractors._common import html_to_text
|
||||||
from services.post_refresh_qc import normalize_german_umlauts
|
from services.post_refresh_qc import normalize_german_umlauts
|
||||||
|
from agents.researcher import keywords_for_language, flatten_keywords
|
||||||
|
|
||||||
logger = logging.getLogger("osint.rss")
|
logger = logging.getLogger("osint.rss")
|
||||||
|
|
||||||
|
|
||||||
|
def _is_specific_word(w: str) -> bool:
|
||||||
|
"""Spezifisches Keyword = 1-Treffer reicht für Match.
|
||||||
|
|
||||||
|
- Lateinisch: ab 7 Zeichen (alte Heuristik).
|
||||||
|
- Nicht-ASCII (CJK, Arabisch, Hebräisch, Kyrillisch etc.): ab 3 Zeichen.
|
||||||
|
Beispiel: '自衛隊' (3 Kanji) oder 'путин' (5 Kyrillisch) sind spezifisch genug.
|
||||||
|
"""
|
||||||
|
if not w:
|
||||||
|
return False
|
||||||
|
if any(ord(c) > 127 for c in w):
|
||||||
|
return len(w) >= 3
|
||||||
|
return len(w) >= 7
|
||||||
|
|
||||||
|
|
||||||
class RSSParser:
|
class RSSParser:
|
||||||
"""Durchsucht RSS-Feeds nach relevanten Artikeln."""
|
"""Durchsucht RSS-Feeds nach relevanten Artikeln."""
|
||||||
|
|
||||||
@@ -28,27 +48,31 @@ class RSSParser:
|
|||||||
cleaned = [w for w in words if not w.isdigit()]
|
cleaned = [w for w in words if not w.isdigit()]
|
||||||
return cleaned if cleaned else words
|
return cleaned if cleaned else words
|
||||||
|
|
||||||
async def search_feeds(self, search_term: str, international: bool = True, tenant_id: int = None, keywords: list[str] | None = None, user_id: int = None) -> list[dict]:
|
def _fallback_search_words(self, search_term: str) -> list[str]:
|
||||||
|
words = [
|
||||||
|
w for w in search_term.lower().split()
|
||||||
|
if w not in self.STOP_WORDS and len(w) >= 3
|
||||||
|
]
|
||||||
|
if not words:
|
||||||
|
words = search_term.lower().split()[:2]
|
||||||
|
return self._clean_search_words(words)
|
||||||
|
|
||||||
|
async def search_feeds(self, search_term: str, international: bool = True, tenant_id: int = None, keywords: dict | list | None = None, user_id: int = None) -> list[dict]:
|
||||||
"""Durchsucht RSS-Feeds nach einem Suchbegriff.
|
"""Durchsucht RSS-Feeds nach einem Suchbegriff.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
search_term: Suchbegriff
|
search_term: Suchbegriff
|
||||||
international: Wenn False, nur Feeds in der Org-Sprache + Behoerden (keine internationalen)
|
international: Wenn False, nur Feeds in der Org-Sprache + Behoerden (keine internationalen)
|
||||||
tenant_id: Optionale Org-ID fuer tenant-spezifische Quellen
|
tenant_id: Optionale Org-ID fuer tenant-spezifische Quellen
|
||||||
keywords: Optionale Claude-generierte Keywords (bevorzugt gegenüber Title-Split)
|
keywords: Sprach-Dict {iso_lang: [keyword, ...]} oder flache Liste (Backward).
|
||||||
"""
|
"""
|
||||||
all_articles = []
|
all_articles = []
|
||||||
if keywords:
|
if keywords:
|
||||||
search_words = [w.lower().strip() for w in keywords if w.strip()]
|
logger.info(f"RSS-Suche mit Claude-Keywords (Sprachen): "
|
||||||
logger.info(f"RSS-Suche mit Claude-Keywords: {search_words}")
|
f"{ {k: len(v) for k, v in keywords.items()} if isinstance(keywords, dict) else len(keywords) }")
|
||||||
|
fallback_words = None
|
||||||
else:
|
else:
|
||||||
search_words = [
|
fallback_words = self._fallback_search_words(search_term)
|
||||||
w for w in search_term.lower().split()
|
|
||||||
if w not in self.STOP_WORDS and len(w) >= 3
|
|
||||||
]
|
|
||||||
if not search_words:
|
|
||||||
search_words = search_term.lower().split()[:2]
|
|
||||||
search_words = self._clean_search_words(search_words)
|
|
||||||
|
|
||||||
rss_feeds = await self._get_rss_feeds(tenant_id=tenant_id)
|
rss_feeds = await self._get_rss_feeds(tenant_id=tenant_id)
|
||||||
|
|
||||||
@@ -74,7 +98,13 @@ class RSSParser:
|
|||||||
tasks = []
|
tasks = []
|
||||||
for category in categories:
|
for category in categories:
|
||||||
for feed_config in rss_feeds.get(category, []):
|
for feed_config in rss_feeds.get(category, []):
|
||||||
tasks.append(self._fetch_feed(feed_config, search_words))
|
feed_lang = feed_config.get("primary_language")
|
||||||
|
if keywords:
|
||||||
|
words = keywords_for_language(keywords, feed_lang)
|
||||||
|
words = [w.lower() for w in words]
|
||||||
|
else:
|
||||||
|
words = fallback_words
|
||||||
|
tasks.append(self._fetch_feed(feed_config, words))
|
||||||
|
|
||||||
results = await asyncio.gather(*tasks, return_exceptions=True)
|
results = await asyncio.gather(*tasks, return_exceptions=True)
|
||||||
|
|
||||||
@@ -89,30 +119,34 @@ class RSSParser:
|
|||||||
all_articles = self._apply_domain_cap(all_articles)
|
all_articles = self._apply_domain_cap(all_articles)
|
||||||
return all_articles
|
return all_articles
|
||||||
|
|
||||||
async def search_feeds_selective(self, search_term: str, selected_feeds: list[dict], keywords: list[str] | None = None) -> list[dict]:
|
async def search_feeds_selective(self, search_term: str, selected_feeds: list[dict], keywords: dict | list | None = None) -> list[dict]:
|
||||||
"""Durchsucht nur die übergebenen Feeds (vorselektiert durch Claude).
|
"""Durchsucht nur die übergebenen Feeds (vorselektiert durch Claude).
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
search_term: Suchbegriff
|
search_term: Suchbegriff
|
||||||
selected_feeds: Liste von Feed-Dicts mit mindestens {"name", "url"}
|
selected_feeds: Liste von Feed-Dicts mit mindestens {"name", "url"} und idealerweise "primary_language"
|
||||||
keywords: Optionale Claude-generierte Keywords (bevorzugt gegenüber Title-Split)
|
keywords: Sprach-Dict {iso_lang: [keyword, ...]} oder flache Liste (Backward).
|
||||||
"""
|
"""
|
||||||
all_articles = []
|
all_articles = []
|
||||||
if keywords:
|
if keywords:
|
||||||
search_words = [w.lower().strip() for w in keywords if w.strip()]
|
if isinstance(keywords, dict):
|
||||||
logger.info(f"RSS-Selektiv mit Claude-Keywords: {search_words}")
|
logger.info(f"RSS-Selektiv mit Claude-Keywords (Sprachen): "
|
||||||
|
f"{ {k: len(v) for k, v in keywords.items()} }")
|
||||||
|
else:
|
||||||
|
logger.info(f"RSS-Selektiv mit Claude-Keywords (flach): {keywords}")
|
||||||
|
fallback_words = None
|
||||||
else:
|
else:
|
||||||
search_words = [
|
fallback_words = self._fallback_search_words(search_term)
|
||||||
w for w in search_term.lower().split()
|
|
||||||
if w not in self.STOP_WORDS and len(w) >= 3
|
|
||||||
]
|
|
||||||
if not search_words:
|
|
||||||
search_words = search_term.lower().split()[:2]
|
|
||||||
search_words = self._clean_search_words(search_words)
|
|
||||||
|
|
||||||
tasks = []
|
tasks = []
|
||||||
for feed_config in selected_feeds:
|
for feed_config in selected_feeds:
|
||||||
tasks.append(self._fetch_feed(feed_config, search_words))
|
feed_lang = feed_config.get("primary_language")
|
||||||
|
if keywords:
|
||||||
|
words = keywords_for_language(keywords, feed_lang)
|
||||||
|
words = [w.lower() for w in words]
|
||||||
|
else:
|
||||||
|
words = fallback_words
|
||||||
|
tasks.append(self._fetch_feed(feed_config, words))
|
||||||
|
|
||||||
results = await asyncio.gather(*tasks, return_exceptions=True)
|
results = await asyncio.gather(*tasks, return_exceptions=True)
|
||||||
|
|
||||||
@@ -142,6 +176,11 @@ class RSSParser:
|
|||||||
name = feed_config["name"]
|
name = feed_config["name"]
|
||||||
url = feed_config["url"]
|
url = feed_config["url"]
|
||||||
articles = []
|
articles = []
|
||||||
|
# Google-News-Feeds (Site-Search ODER Volltext-Suche) buendeln Artikel
|
||||||
|
# vieler echter Publisher. Pro Item steht der echte Publisher im
|
||||||
|
# <source>-Tag — den nutzen wir als source-Name, sonst zaehlt der
|
||||||
|
# Faktencheck 25 Artikel als "eine Quelle".
|
||||||
|
_is_google_news = "news.google.com" in (url or "")
|
||||||
|
|
||||||
try:
|
try:
|
||||||
async with httpx.AsyncClient(timeout=15.0, follow_redirects=True) as client:
|
async with httpx.AsyncClient(timeout=15.0, follow_redirects=True) as client:
|
||||||
@@ -166,11 +205,11 @@ class RSSParser:
|
|||||||
text = f"{title} {summary}".lower()
|
text = f"{title} {summary}".lower()
|
||||||
|
|
||||||
# Adaptive Match-Schwelle:
|
# Adaptive Match-Schwelle:
|
||||||
# - Bei mindestens einem spezifischen Keyword (>=7 Zeichen) im Text reicht 1 Treffer.
|
# - Bei mindestens einem spezifischen Keyword (Latin ≥7 Zeichen oder
|
||||||
# Verhindert, dass Headlines mit nur einem starken Keyword wie "buckelwal"
|
# CJK/Arabisch/Hebräisch/Kyrillisch ≥3 Zeichen) im Text reicht 1 Treffer.
|
||||||
# rausfallen, wenn die Lage thematisch eng ist (Bug 1, vom User dokumentiert).
|
# Damit matched z.B. "自衛隊" (3 Kanji) wie "buckelwal" (9 Zeichen).
|
||||||
# - Sonst: alte Heuristik (mindestens halb der Wörter, max. 2).
|
# - 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)
|
specific_in_text = any(w in text for w in search_words if _is_specific_word(w))
|
||||||
if specific_in_text:
|
if specific_in_text:
|
||||||
min_matches = 1
|
min_matches = 1
|
||||||
else:
|
else:
|
||||||
@@ -179,23 +218,73 @@ class RSSParser:
|
|||||||
|
|
||||||
if match_count >= min_matches:
|
if match_count >= min_matches:
|
||||||
published = None
|
published = None
|
||||||
|
published_dt = None
|
||||||
if hasattr(entry, "published_parsed") and entry.published_parsed:
|
if hasattr(entry, "published_parsed") and entry.published_parsed:
|
||||||
try:
|
try:
|
||||||
published = datetime(*entry.published_parsed[:6], tzinfo=timezone.utc).astimezone(TIMEZONE).isoformat()
|
published_dt = datetime(*entry.published_parsed[:6], tzinfo=timezone.utc)
|
||||||
|
published = published_dt.astimezone(TIMEZONE).isoformat()
|
||||||
except (TypeError, ValueError):
|
except (TypeError, ValueError):
|
||||||
pass
|
pass
|
||||||
|
|
||||||
# Relevanz-Score: Anteil der gematchten Suchworte (0.0-1.0)
|
# Relevanz-Score: Anteil der gematchten Suchworte (0.0-1.0)
|
||||||
relevance_score = match_count / len(search_words) if search_words else 0.0
|
relevance_score = match_count / len(search_words) if search_words else 0.0
|
||||||
|
# Aktualitaets-Bonus/Malus: frische Artikel sollen den
|
||||||
|
# Domain-Cap (sortiert nach relevance_score) ueberleben und
|
||||||
|
# nicht von Monate alten verdraengt werden. Damit faengt die
|
||||||
|
# Pipeline das aktuelle Bild ein. Nur adhoc-Pfad — research
|
||||||
|
# nutzt diesen Code nicht.
|
||||||
|
if published_dt is not None:
|
||||||
|
age_days = (datetime.now(timezone.utc) - published_dt).days
|
||||||
|
if age_days <= 3:
|
||||||
|
relevance_score += 0.35
|
||||||
|
elif age_days <= 14:
|
||||||
|
relevance_score += 0.20
|
||||||
|
elif age_days <= 60:
|
||||||
|
relevance_score += 0.05
|
||||||
|
elif age_days > 365:
|
||||||
|
relevance_score -= 0.30
|
||||||
|
elif age_days > 180:
|
||||||
|
relevance_score -= 0.15
|
||||||
|
|
||||||
|
# Bei Google-News-Feeds: echten Publisher aus <source>-Tag holen
|
||||||
|
article_source = name
|
||||||
|
if _is_google_news:
|
||||||
|
src_obj = entry.get("source")
|
||||||
|
src_title = ""
|
||||||
|
if isinstance(src_obj, dict):
|
||||||
|
src_title = (src_obj.get("title") or "").strip()
|
||||||
|
elif src_obj:
|
||||||
|
src_title = str(getattr(src_obj, "title", "") or "").strip()
|
||||||
|
if src_title:
|
||||||
|
article_source = src_title
|
||||||
|
else:
|
||||||
|
# Google-News-Titel enden oft mit " - Publishername"
|
||||||
|
if " - " in title:
|
||||||
|
article_source = title.rsplit(" - ", 1)[-1].strip() or name
|
||||||
|
|
||||||
articles.append({
|
articles.append({
|
||||||
"headline": title,
|
"headline": title,
|
||||||
"headline_de": title if self._is_german(title) else None,
|
"headline_de": title if self._is_german(title) else None,
|
||||||
"source": name,
|
"source": article_source,
|
||||||
"source_url": entry.get("link", ""),
|
"source_url": entry.get("link", ""),
|
||||||
|
# Die Quell-Domain aus der DB (z.B. "mod.go.jp"), nicht aus
|
||||||
|
# der URL — relevant für Google-News-RSS-Quellen, deren URLs
|
||||||
|
# alle "news.google.com" sind, obwohl sie für 14 verschiedene
|
||||||
|
# Behörden/Zeitungen stehen. Wird vom Domain-Cap genutzt.
|
||||||
|
"source_domain": feed_config.get("domain") or "",
|
||||||
|
# media_type aus dem Feed-Eintrag (z.B. "forum" fuer 5ch/Hatena/Note)
|
||||||
|
# damit downstream Pipeline-Schritte (Faktencheck, Geoparsing,
|
||||||
|
# Topic-Filter, Stimmungs-Kachel) Foren-Quellen erkennen koennen.
|
||||||
|
"media_type": feed_config.get("media_type") or "",
|
||||||
"content_original": summary[:1000] if summary else None,
|
"content_original": summary[:1000] if summary else None,
|
||||||
"content_de": summary[:1000] if summary and self._is_german(summary) else None,
|
"content_de": summary[:1000] if summary and self._is_german(summary) else None,
|
||||||
"language": "de" if self._is_german(title) else "en",
|
# Sprache primär aus der Quell-Konfiguration übernehmen
|
||||||
|
# (z.B. "ja" für Asahi Shimbun, "ru" für TASS). Nur wenn
|
||||||
|
# die Quelle kein primary_language gesetzt hat, auf die
|
||||||
|
# alte de/en-Heuristik zurückfallen. Sonst landen
|
||||||
|
# CJK/kyrillische Headlines fälschlich als language="en"
|
||||||
|
# und verlieren Pre-Topic-Übersetzung + Translator-Pfad.
|
||||||
|
"language": feed_config.get("primary_language") or ("de" if self._is_german(title) else "en"),
|
||||||
"published_at": published,
|
"published_at": published,
|
||||||
"relevance_score": relevance_score,
|
"relevance_score": relevance_score,
|
||||||
})
|
})
|
||||||
@@ -214,10 +303,16 @@ class RSSParser:
|
|||||||
if not articles:
|
if not articles:
|
||||||
return articles
|
return articles
|
||||||
|
|
||||||
# Nach Domain gruppieren
|
# Nach Domain gruppieren. Bevorzugt source_domain (aus dem Feed-Eintrag,
|
||||||
|
# z.B. "mod.go.jp" bei einer Google-News-Site-Search-RSS-Quelle), fällt
|
||||||
|
# erst dann auf die URL-Domain zurück. Sonst landen alle Google-News-
|
||||||
|
# Feeds (14 ja-Quellen) im selben "news.google.com"-Topf und werden
|
||||||
|
# vom Cap auf 10 begrenzt.
|
||||||
by_domain: dict[str, list[dict]] = {}
|
by_domain: dict[str, list[dict]] = {}
|
||||||
for article in articles:
|
for article in articles:
|
||||||
domain = _extract_domain(article.get("source_url", ""))
|
domain = (article.get("source_domain") or "").strip().lower()
|
||||||
|
if not domain:
|
||||||
|
domain = _extract_domain(article.get("source_url", ""))
|
||||||
if not domain:
|
if not domain:
|
||||||
domain = "__unknown__"
|
domain = "__unknown__"
|
||||||
by_domain.setdefault(domain, []).append(article)
|
by_domain.setdefault(domain, []).append(article)
|
||||||
@@ -226,10 +321,15 @@ class RSSParser:
|
|||||||
for domain, domain_articles in by_domain.items():
|
for domain, domain_articles in by_domain.items():
|
||||||
# Nach Relevanz sortieren (beste zuerst)
|
# Nach Relevanz sortieren (beste zuerst)
|
||||||
domain_articles.sort(key=lambda a: a.get("relevance_score", 0), reverse=True)
|
domain_articles.sort(key=lambda a: a.get("relevance_score", 0), reverse=True)
|
||||||
kept = domain_articles[:MAX_ARTICLES_PER_DOMAIN_RSS]
|
# Dynamische Google-News-Suchfeeds ("google-news-search-<lang>") sind
|
||||||
if len(domain_articles) > MAX_ARTICLES_PER_DOMAIN_RSS:
|
# der Recall-Treiber und bekommen einen hoeheren Cap als feste Feeds.
|
||||||
|
cap = (MAX_ARTICLES_PER_DOMAIN_RSS_SEARCH
|
||||||
|
if domain.startswith("google-news-search-")
|
||||||
|
else MAX_ARTICLES_PER_DOMAIN_RSS)
|
||||||
|
kept = domain_articles[:cap]
|
||||||
|
if len(domain_articles) > cap:
|
||||||
logger.info(
|
logger.info(
|
||||||
f"Domain-Cap: {domain} von {len(domain_articles)} auf {MAX_ARTICLES_PER_DOMAIN_RSS} Artikel begrenzt"
|
f"Domain-Cap: {domain} von {len(domain_articles)} auf {cap} Artikel begrenzt"
|
||||||
)
|
)
|
||||||
capped.extend(kept)
|
capped.extend(kept)
|
||||||
|
|
||||||
|
|||||||
@@ -61,38 +61,50 @@ class TelegramParser:
|
|||||||
return None
|
return None
|
||||||
|
|
||||||
async def search_channels(self, search_term: str, tenant_id: int = None,
|
async def search_channels(self, search_term: str, tenant_id: int = None,
|
||||||
keywords: list[str] = None, channel_ids: list[int] = None) -> list[dict]:
|
keywords: dict | list = None, channel_ids: list[int] = None) -> list[dict]:
|
||||||
"""Liest Nachrichten aus konfigurierten Telegram-Kanaelen.
|
"""Liest Nachrichten aus konfigurierten Telegram-Kanaelen.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
keywords: Sprach-Dict {iso_lang: [keyword,...]} oder flache Liste (Backward).
|
||||||
|
Match nutzt pro Kanal die "en"-Universalbegriffe + die Keywords der
|
||||||
|
Kanalsprache (primary_language aus sources-Tabelle).
|
||||||
|
|
||||||
Gibt Artikel-Dicts zurueck (kompatibel mit RSS-Parser-Format).
|
Gibt Artikel-Dicts zurueck (kompatibel mit RSS-Parser-Format).
|
||||||
"""
|
"""
|
||||||
|
from agents.researcher import keywords_for_language
|
||||||
|
|
||||||
client = await self._get_client()
|
client = await self._get_client()
|
||||||
if not client:
|
if not client:
|
||||||
logger.warning("Telegram-Client nicht verfuegbar, ueberspringe Telegram-Pipeline")
|
logger.warning("Telegram-Client nicht verfuegbar, ueberspringe Telegram-Pipeline")
|
||||||
return []
|
return []
|
||||||
|
|
||||||
# Telegram-Kanaele aus DB laden
|
# Telegram-Kanaele aus DB laden (inkl. primary_language)
|
||||||
channels = await self._get_telegram_channels(tenant_id, channel_ids=channel_ids)
|
channels = await self._get_telegram_channels(tenant_id, channel_ids=channel_ids)
|
||||||
if not channels:
|
if not channels:
|
||||||
logger.info("Keine Telegram-Kanaele konfiguriert")
|
logger.info("Keine Telegram-Kanaele konfiguriert")
|
||||||
return []
|
return []
|
||||||
|
|
||||||
# Suchwoerter vorbereiten
|
# Fallback-Suchwoerter wenn keine Keywords da sind
|
||||||
if keywords:
|
fallback_words: list[str] | None = None
|
||||||
search_words = [w.lower().strip() for w in keywords if w.strip()]
|
if not keywords:
|
||||||
else:
|
fallback_words = [
|
||||||
search_words = [
|
|
||||||
w for w in search_term.lower().split()
|
w for w in search_term.lower().split()
|
||||||
if w not in STOP_WORDS and len(w) >= 3
|
if w not in STOP_WORDS and len(w) >= 3
|
||||||
]
|
]
|
||||||
if not search_words:
|
if not fallback_words:
|
||||||
search_words = search_term.lower().split()[:2]
|
fallback_words = search_term.lower().split()[:2]
|
||||||
|
|
||||||
# Kanaele parallel abrufen
|
# Kanaele parallel abrufen
|
||||||
tasks = []
|
tasks = []
|
||||||
for ch in channels:
|
for ch in channels:
|
||||||
channel_id = ch["url"] or ch["name"]
|
channel_id = ch["url"] or ch["name"]
|
||||||
tasks.append(self._fetch_channel(client, channel_id, search_words))
|
channel_lang = ch.get("primary_language")
|
||||||
|
if keywords:
|
||||||
|
search_words = keywords_for_language(keywords, channel_lang)
|
||||||
|
search_words = [w.lower() for w in search_words]
|
||||||
|
else:
|
||||||
|
search_words = fallback_words or []
|
||||||
|
tasks.append(self._fetch_channel(client, channel_id, search_words, channel_lang=channel_lang))
|
||||||
|
|
||||||
results = await asyncio.gather(*tasks, return_exceptions=True)
|
results = await asyncio.gather(*tasks, return_exceptions=True)
|
||||||
|
|
||||||
@@ -115,7 +127,7 @@ class TelegramParser:
|
|||||||
if channel_ids and len(channel_ids) > 0:
|
if channel_ids and len(channel_ids) > 0:
|
||||||
placeholders = ",".join("?" for _ in channel_ids)
|
placeholders = ",".join("?" for _ in channel_ids)
|
||||||
cursor = await db.execute(
|
cursor = await db.execute(
|
||||||
f"""SELECT id, name, url, category, notes FROM sources
|
f"""SELECT id, name, url, category, notes, primary_language FROM sources
|
||||||
WHERE source_type = 'telegram_channel'
|
WHERE source_type = 'telegram_channel'
|
||||||
AND status = 'active'
|
AND status = 'active'
|
||||||
AND id IN ({placeholders})""",
|
AND id IN ({placeholders})""",
|
||||||
@@ -123,7 +135,7 @@ class TelegramParser:
|
|||||||
)
|
)
|
||||||
else:
|
else:
|
||||||
cursor = await db.execute(
|
cursor = await db.execute(
|
||||||
"""SELECT id, name, url, category, notes FROM sources
|
"""SELECT id, name, url, category, notes, primary_language FROM sources
|
||||||
WHERE source_type = 'telegram_channel'
|
WHERE source_type = 'telegram_channel'
|
||||||
AND status = 'active'
|
AND status = 'active'
|
||||||
AND (tenant_id IS NULL OR tenant_id = ?)""",
|
AND (tenant_id IS NULL OR tenant_id = ?)""",
|
||||||
@@ -138,7 +150,7 @@ class TelegramParser:
|
|||||||
return []
|
return []
|
||||||
|
|
||||||
async def _fetch_channel(self, client, channel_id: str, search_words: list[str],
|
async def _fetch_channel(self, client, channel_id: str, search_words: list[str],
|
||||||
limit: int = 50) -> list[dict]:
|
limit: int = 50, channel_lang: str | None = None) -> list[dict]:
|
||||||
"""Letzte N Nachrichten eines Kanals abrufen und nach Keywords filtern."""
|
"""Letzte N Nachrichten eines Kanals abrufen und nach Keywords filtern."""
|
||||||
articles = []
|
articles = []
|
||||||
try:
|
try:
|
||||||
@@ -205,7 +217,10 @@ class TelegramParser:
|
|||||||
"source_url": source_url,
|
"source_url": source_url,
|
||||||
"content_original": content[:2000],
|
"content_original": content[:2000],
|
||||||
"content_de": content[:2000] if self._is_german(content) else None,
|
"content_de": content[:2000] if self._is_german(content) else None,
|
||||||
"language": "de" if self._is_german(content) else "en",
|
# Sprache primär aus der Kanal-Konfiguration übernehmen
|
||||||
|
# (z.B. "ru" für russische Kanäle). Sonst Fallback auf die
|
||||||
|
# de/en-Heuristik. Symmetrisch zur RSS-Pfad-Logik.
|
||||||
|
"language": channel_lang or ("de" if self._is_german(content) else "en"),
|
||||||
"published_at": published,
|
"published_at": published,
|
||||||
"relevance_score": relevance_score,
|
"relevance_score": relevance_score,
|
||||||
})
|
})
|
||||||
|
|||||||
320
src/feeds/x_parser.py
Normale Datei
320
src/feeds/x_parser.py
Normale Datei
@@ -0,0 +1,320 @@
|
|||||||
|
"""X (Twitter) Parser: Liest Posts aus konfigurierten X-Accounts via twscrape.
|
||||||
|
|
||||||
|
Egress laeuft -- wenn X_PROXY_URL gesetzt -- ueber den HTTP-Proxy am RUTX11
|
||||||
|
(Mobilfunk-IP). Faellt der Proxy aus, wird direkt ueber die Server-IP
|
||||||
|
abgerufen (Fallback). Gibt Artikel-Dicts im RSS-/Telegram-kompatiblen Format
|
||||||
|
zurueck.
|
||||||
|
"""
|
||||||
|
import asyncio
|
||||||
|
import logging
|
||||||
|
import os
|
||||||
|
from datetime import datetime, timezone, timedelta
|
||||||
|
|
||||||
|
import httpx
|
||||||
|
|
||||||
|
from config import (
|
||||||
|
TIMEZONE, X_ACCOUNTS_DB_PATH, X_PROXY_URL,
|
||||||
|
X_POST_CAP_PER_ACCOUNT, X_RECENCY_DAYS, X_SCRAPER_ENABLED,
|
||||||
|
)
|
||||||
|
|
||||||
|
logger = logging.getLogger("osint.x")
|
||||||
|
|
||||||
|
# Stoppwoerter (gleich wie RSS-/Telegram-Parser)
|
||||||
|
STOP_WORDS = {
|
||||||
|
"und", "oder", "der", "die", "das", "ein", "eine", "in", "im", "am", "an",
|
||||||
|
"auf", "fuer", "mit", "von", "zu", "zum", "zur", "bei", "nach", "vor",
|
||||||
|
"ueber", "unter", "ist", "sind", "hat", "the", "and", "for", "with", "from",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _normalize_handle(raw: str) -> str:
|
||||||
|
"""X-Handle aus URL-/@-Form auf den nackten Benutzernamen normalisieren."""
|
||||||
|
h = (raw or "").strip()
|
||||||
|
for prefix in ("https://", "http://"):
|
||||||
|
if h.startswith(prefix):
|
||||||
|
h = h[len(prefix):]
|
||||||
|
for prefix in ("www.", "x.com/", "twitter.com/", "nitter.net/"):
|
||||||
|
if h.startswith(prefix):
|
||||||
|
h = h[len(prefix):]
|
||||||
|
h = h.lstrip("@").strip("/")
|
||||||
|
# Pfad-/Query-Reste abschneiden (z.B. handle/status/123 oder handle?lang=de)
|
||||||
|
for sep in ("/", "?"):
|
||||||
|
if sep in h:
|
||||||
|
h = h.split(sep)[0]
|
||||||
|
return h
|
||||||
|
|
||||||
|
|
||||||
|
class XParser:
|
||||||
|
"""Durchsucht konfigurierte X-Accounts nach relevanten Posts."""
|
||||||
|
|
||||||
|
async def _resolve_proxy(self) -> tuple[str | None, str | None]:
|
||||||
|
"""Proxy-Strategie aufloesen.
|
||||||
|
|
||||||
|
Returns (proxy_url, egress_ip):
|
||||||
|
- X_PROXY_URL leer -> (None, None): direkter Abruf ueber Server-IP.
|
||||||
|
- X_PROXY_URL gesetzt und erreichbar -> (proxy, egress_ip).
|
||||||
|
- X_PROXY_URL gesetzt aber tot -> (None, None): Fallback direkt + Warnung.
|
||||||
|
"""
|
||||||
|
if not X_PROXY_URL:
|
||||||
|
return None, None
|
||||||
|
try:
|
||||||
|
async with httpx.AsyncClient(proxy=X_PROXY_URL, timeout=8.0) as client:
|
||||||
|
resp = await client.get("https://api.ipify.org")
|
||||||
|
resp.raise_for_status()
|
||||||
|
egress_ip = resp.text.strip()
|
||||||
|
logger.info("X-Egress ueber Proxy %s aktiv (IP: %s)", X_PROXY_URL, egress_ip)
|
||||||
|
return X_PROXY_URL, egress_ip
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning(
|
||||||
|
"X-Proxy %s nicht erreichbar (%s) -- Fallback auf direkte Server-IP",
|
||||||
|
X_PROXY_URL, e,
|
||||||
|
)
|
||||||
|
return None, None
|
||||||
|
|
||||||
|
async def _get_api(self, proxy: str | None):
|
||||||
|
"""twscrape-API-Objekt erstellen.
|
||||||
|
|
||||||
|
Gibt None zurueck wenn der Account-Store fehlt oder keine
|
||||||
|
nutzbaren Accounts vorhanden sind.
|
||||||
|
"""
|
||||||
|
if not os.path.exists(X_ACCOUNTS_DB_PATH):
|
||||||
|
logger.error("X-Account-Store nicht gefunden: %s", X_ACCOUNTS_DB_PATH)
|
||||||
|
return None
|
||||||
|
try:
|
||||||
|
from twscrape import API
|
||||||
|
except ImportError:
|
||||||
|
logger.error("twscrape nicht installiert: pip install twscrape")
|
||||||
|
return None
|
||||||
|
try:
|
||||||
|
api = API(X_ACCOUNTS_DB_PATH, proxy=proxy)
|
||||||
|
# Account-Pool pruefen -- ohne aktive Accounts liefert twscrape nichts
|
||||||
|
try:
|
||||||
|
accounts = await api.pool.get_all()
|
||||||
|
active = [a for a in accounts if getattr(a, "active", True)]
|
||||||
|
if not accounts:
|
||||||
|
logger.error("X-Account-Pool leer -- keine Accounts konfiguriert")
|
||||||
|
return None
|
||||||
|
if not active:
|
||||||
|
logger.error(
|
||||||
|
"X-Account-Pool: alle %d Accounts inaktiv/gesperrt", len(accounts)
|
||||||
|
)
|
||||||
|
return None
|
||||||
|
logger.info("X-Account-Pool: %d/%d Accounts aktiv", len(active), len(accounts))
|
||||||
|
except Exception as e:
|
||||||
|
# Pool-Status nicht ermittelbar -- trotzdem weiterversuchen
|
||||||
|
logger.debug("X-Account-Pool-Status nicht ermittelbar: %s", e)
|
||||||
|
return api
|
||||||
|
except Exception as e:
|
||||||
|
logger.error("X-API-Initialisierung fehlgeschlagen: %s", e)
|
||||||
|
return None
|
||||||
|
|
||||||
|
async def search_accounts(self, search_term: str, tenant_id: int = None,
|
||||||
|
keywords: dict | list = None,
|
||||||
|
account_ids: list[int] = None) -> list[dict]:
|
||||||
|
"""Liest Posts aus konfigurierten X-Accounts.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
keywords: Sprach-Dict {iso_lang: [keyword,...]} oder flache Liste.
|
||||||
|
Match nutzt pro Account die "en"-Universalbegriffe + die
|
||||||
|
Keywords der Account-Sprache (primary_language aus sources).
|
||||||
|
|
||||||
|
Gibt Artikel-Dicts zurueck (kompatibel mit RSS-/Telegram-Format).
|
||||||
|
"""
|
||||||
|
if not X_SCRAPER_ENABLED:
|
||||||
|
logger.info("X-Scraper deaktiviert (X_SCRAPER_ENABLED=false)")
|
||||||
|
return []
|
||||||
|
|
||||||
|
from agents.researcher import keywords_for_language
|
||||||
|
|
||||||
|
accounts = await self._get_x_accounts(tenant_id, account_ids=account_ids)
|
||||||
|
if not accounts:
|
||||||
|
logger.info("Keine X-Accounts konfiguriert")
|
||||||
|
return []
|
||||||
|
|
||||||
|
proxy, _egress_ip = await self._resolve_proxy()
|
||||||
|
api = await self._get_api(proxy)
|
||||||
|
if not api:
|
||||||
|
logger.warning("X-API nicht verfuegbar, ueberspringe X-Pipeline")
|
||||||
|
return []
|
||||||
|
|
||||||
|
# Fallback-Suchwoerter wenn keine Keywords da sind
|
||||||
|
fallback_words: list[str] | None = None
|
||||||
|
if not keywords:
|
||||||
|
fallback_words = [
|
||||||
|
w for w in search_term.lower().split()
|
||||||
|
if w not in STOP_WORDS and len(w) >= 3
|
||||||
|
]
|
||||||
|
if not fallback_words:
|
||||||
|
fallback_words = search_term.lower().split()[:2]
|
||||||
|
|
||||||
|
cutoff = datetime.now(timezone.utc) - timedelta(days=X_RECENCY_DAYS)
|
||||||
|
|
||||||
|
# Accounts parallel abrufen
|
||||||
|
tasks = []
|
||||||
|
for acc in accounts:
|
||||||
|
handle = _normalize_handle(acc["url"] or acc["name"])
|
||||||
|
acc_lang = acc.get("primary_language")
|
||||||
|
if keywords:
|
||||||
|
search_words = [w.lower() for w in keywords_for_language(keywords, acc_lang)]
|
||||||
|
else:
|
||||||
|
search_words = fallback_words or []
|
||||||
|
tasks.append(self._fetch_account(api, handle, search_words, cutoff, acc_lang))
|
||||||
|
|
||||||
|
results = await asyncio.gather(*tasks, return_exceptions=True)
|
||||||
|
|
||||||
|
all_articles = []
|
||||||
|
for i, result in enumerate(results):
|
||||||
|
if isinstance(result, Exception):
|
||||||
|
logger.warning("X-Account %s: %s", accounts[i]["name"], result)
|
||||||
|
continue
|
||||||
|
all_articles.extend(result)
|
||||||
|
|
||||||
|
logger.info("X: %d relevante Posts aus %d Accounts", len(all_articles), len(accounts))
|
||||||
|
return all_articles
|
||||||
|
|
||||||
|
async def _get_x_accounts(self, tenant_id: int = None,
|
||||||
|
account_ids: list[int] = None) -> list[dict]:
|
||||||
|
"""Laedt X-Accounts aus der sources-Tabelle."""
|
||||||
|
try:
|
||||||
|
from database import get_db
|
||||||
|
db = await get_db()
|
||||||
|
try:
|
||||||
|
if account_ids and len(account_ids) > 0:
|
||||||
|
placeholders = ",".join("?" for _ in account_ids)
|
||||||
|
cursor = await db.execute(
|
||||||
|
f"""SELECT id, name, url, category, notes, primary_language FROM sources
|
||||||
|
WHERE source_type = 'x_account'
|
||||||
|
AND status = 'active'
|
||||||
|
AND id IN ({placeholders})""",
|
||||||
|
tuple(account_ids),
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
cursor = await db.execute(
|
||||||
|
"""SELECT id, name, url, category, notes, primary_language FROM sources
|
||||||
|
WHERE source_type = 'x_account'
|
||||||
|
AND status = 'active'
|
||||||
|
AND (tenant_id IS NULL OR tenant_id = ?)""",
|
||||||
|
(tenant_id,),
|
||||||
|
)
|
||||||
|
rows = await cursor.fetchall()
|
||||||
|
return [dict(row) for row in rows]
|
||||||
|
finally:
|
||||||
|
await db.close()
|
||||||
|
except Exception as e:
|
||||||
|
logger.error("Fehler beim Laden der X-Accounts: %s", e)
|
||||||
|
return []
|
||||||
|
|
||||||
|
async def _fetch_account(self, api, handle: str, search_words: list[str],
|
||||||
|
cutoff: datetime, account_lang: str | None = None) -> list[dict]:
|
||||||
|
"""Letzte Posts eines X-Accounts abrufen und nach Keywords filtern."""
|
||||||
|
from twscrape import gather
|
||||||
|
|
||||||
|
articles: list[dict] = []
|
||||||
|
if not handle:
|
||||||
|
return articles
|
||||||
|
try:
|
||||||
|
user = await api.user_by_login(handle)
|
||||||
|
if not user:
|
||||||
|
logger.warning("X-Account @%s nicht gefunden", handle)
|
||||||
|
return articles
|
||||||
|
|
||||||
|
tweets = await gather(api.user_tweets(user.id, limit=X_POST_CAP_PER_ACCOUNT))
|
||||||
|
|
||||||
|
for tw in tweets:
|
||||||
|
# Reine Retweets ueberspringen (Original wird ohnehin erfasst)
|
||||||
|
if getattr(tw, "retweetedTweet", None) is not None:
|
||||||
|
continue
|
||||||
|
|
||||||
|
text = getattr(tw, "rawContent", None) or ""
|
||||||
|
# Quote-Tweet: zitierten Text anhaengen, damit Kontext erhalten bleibt
|
||||||
|
quoted = getattr(tw, "quotedTweet", None)
|
||||||
|
if quoted is not None:
|
||||||
|
q_text = getattr(quoted, "rawContent", "") or ""
|
||||||
|
if q_text:
|
||||||
|
text = "%s\n\n[Zitiert] %s" % (text, q_text)
|
||||||
|
if not text.strip():
|
||||||
|
continue
|
||||||
|
|
||||||
|
# Recency-Fenster
|
||||||
|
tw_date = getattr(tw, "date", None)
|
||||||
|
if tw_date is not None:
|
||||||
|
try:
|
||||||
|
if tw_date < cutoff:
|
||||||
|
continue
|
||||||
|
except TypeError:
|
||||||
|
pass
|
||||||
|
|
||||||
|
# Keyword-Matching (lockerer als RSS: 1 Match reicht,
|
||||||
|
# da Accounts bereits thematisch vorselektiert sind)
|
||||||
|
text_lower = text.lower()
|
||||||
|
match_count = sum(1 for w in search_words if w in text_lower)
|
||||||
|
if search_words and match_count < 1:
|
||||||
|
continue
|
||||||
|
|
||||||
|
lines = text.strip().split("\n")
|
||||||
|
headline = (lines[0][:200] if lines else text[:200]).strip()
|
||||||
|
|
||||||
|
published = None
|
||||||
|
if tw_date is not None:
|
||||||
|
try:
|
||||||
|
published = tw_date.astimezone(TIMEZONE).isoformat()
|
||||||
|
except Exception:
|
||||||
|
published = tw_date.isoformat()
|
||||||
|
|
||||||
|
source_url = getattr(tw, "url", None) or \
|
||||||
|
"https://x.com/%s/status/%s" % (handle, getattr(tw, "id", ""))
|
||||||
|
tw_lang = getattr(tw, "lang", None)
|
||||||
|
language = account_lang \
|
||||||
|
or (tw_lang if tw_lang and tw_lang != "und" else None) \
|
||||||
|
or ("de" if self._is_german(text) else "en")
|
||||||
|
relevance_score = (match_count / len(search_words)) if search_words else 0.0
|
||||||
|
|
||||||
|
articles.append({
|
||||||
|
"headline": headline,
|
||||||
|
"headline_de": headline if self._is_german(headline) else None,
|
||||||
|
"source": "X: @%s" % handle,
|
||||||
|
"source_url": source_url,
|
||||||
|
"content_original": text[:2000],
|
||||||
|
"content_de": text[:2000] if self._is_german(text) else None,
|
||||||
|
"language": language,
|
||||||
|
"published_at": published,
|
||||||
|
"relevance_score": relevance_score,
|
||||||
|
})
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning("X-Account @%s: %s", handle, e)
|
||||||
|
|
||||||
|
return articles
|
||||||
|
|
||||||
|
async def validate_account(self, handle: str) -> dict | None:
|
||||||
|
"""Prueft ob ein X-Account erreichbar ist und gibt Account-Info zurueck."""
|
||||||
|
handle = _normalize_handle(handle)
|
||||||
|
if not handle:
|
||||||
|
return None
|
||||||
|
proxy, _ = await self._resolve_proxy()
|
||||||
|
api = await self._get_api(proxy)
|
||||||
|
if not api:
|
||||||
|
return None
|
||||||
|
try:
|
||||||
|
user = await api.user_by_login(handle)
|
||||||
|
if not user:
|
||||||
|
return None
|
||||||
|
return {
|
||||||
|
"valid": True,
|
||||||
|
"name": getattr(user, "displayname", None) or handle,
|
||||||
|
"username": getattr(user, "username", handle),
|
||||||
|
"description": getattr(user, "rawDescription", "") or "",
|
||||||
|
"subscribers": getattr(user, "followersCount", None),
|
||||||
|
}
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning("X-Account-Validierung fehlgeschlagen fuer @%s: %s", handle, e)
|
||||||
|
return None
|
||||||
|
|
||||||
|
def _is_german(self, text: str) -> bool:
|
||||||
|
"""Einfache Heuristik ob ein Text deutsch ist."""
|
||||||
|
german_words = {"der", "die", "das", "und", "ist", "von", "mit", "fuer", "auf", "ein",
|
||||||
|
"eine", "den", "dem", "des", "sich", "wird", "nach", "bei", "auch",
|
||||||
|
"ueber", "wie", "aus", "hat", "zum", "zur", "als", "noch", "mehr",
|
||||||
|
"nicht", "aber", "oder", "sind", "vor", "einem", "einer", "wurde"}
|
||||||
|
words = set(text.lower().split())
|
||||||
|
return len(words & german_words) >= 2
|
||||||
51
src/main.py
51
src/main.py
@@ -246,7 +246,14 @@ async def cleanup_expired():
|
|||||||
)
|
)
|
||||||
logger.info(f"Lage {incident['id']} archiviert (Aufbewahrung abgelaufen)")
|
logger.info(f"Lage {incident['id']} archiviert (Aufbewahrung abgelaufen)")
|
||||||
|
|
||||||
# Verwaiste running-Einträge bereinigen (> 15 Minuten ohne Abschluss)
|
# Verwaiste running-Einträge bereinigen.
|
||||||
|
# Pruefen auf Pipeline-Fortschritt: legitime Long-Runner (z.B. Translator
|
||||||
|
# nach summary fuer jp_demo mit 200+ Artikeln ~20 Min) duerfen nicht
|
||||||
|
# vorzeitig gekillt werden. Ein Refresh gilt als verwaist, wenn entweder
|
||||||
|
# (a) seit ORPHAN_IDLE_LIMIT Min kein Pipeline-Step Fortschritt zeigte,
|
||||||
|
# oder (b) das harte Limit ORPHAN_HARD_LIMIT Min ueberschritten wurde.
|
||||||
|
ORPHAN_IDLE_LIMIT = 60
|
||||||
|
ORPHAN_HARD_LIMIT = 120
|
||||||
cursor = await db.execute(
|
cursor = await db.execute(
|
||||||
"SELECT id, incident_id, started_at FROM refresh_log WHERE status = 'running'"
|
"SELECT id, incident_id, started_at FROM refresh_log WHERE status = 'running'"
|
||||||
)
|
)
|
||||||
@@ -258,12 +265,46 @@ async def cleanup_expired():
|
|||||||
else:
|
else:
|
||||||
started = started.astimezone(TIMEZONE)
|
started = started.astimezone(TIMEZONE)
|
||||||
age_minutes = (now - started).total_seconds() / 60
|
age_minutes = (now - started).total_seconds() / 60
|
||||||
if age_minutes >= 15:
|
if age_minutes < ORPHAN_IDLE_LIMIT:
|
||||||
|
continue
|
||||||
|
|
||||||
|
# Letzter Pipeline-Step-Fortschritt (Start ODER Ende)
|
||||||
|
prog_cursor = await db.execute(
|
||||||
|
"""SELECT MAX(COALESCE(completed_at, started_at)) AS last_activity
|
||||||
|
FROM refresh_pipeline_steps WHERE refresh_log_id = ?""",
|
||||||
|
(orphan["id"],),
|
||||||
|
)
|
||||||
|
prog_row = await prog_cursor.fetchone()
|
||||||
|
last_activity_str = prog_row["last_activity"] if prog_row else None
|
||||||
|
|
||||||
|
is_orphan = False
|
||||||
|
reason = None
|
||||||
|
if age_minutes >= ORPHAN_HARD_LIMIT:
|
||||||
|
is_orphan = True
|
||||||
|
reason = f"Verwaist (>{int(age_minutes)} Min, hartes Limit {ORPHAN_HARD_LIMIT} Min)"
|
||||||
|
elif last_activity_str:
|
||||||
|
last_activity = datetime.fromisoformat(last_activity_str)
|
||||||
|
if last_activity.tzinfo is None:
|
||||||
|
last_activity = last_activity.replace(tzinfo=TIMEZONE)
|
||||||
|
else:
|
||||||
|
last_activity = last_activity.astimezone(TIMEZONE)
|
||||||
|
idle_minutes = (now - last_activity).total_seconds() / 60
|
||||||
|
if idle_minutes >= ORPHAN_IDLE_LIMIT:
|
||||||
|
is_orphan = True
|
||||||
|
reason = (
|
||||||
|
f"Verwaist (kein Pipeline-Fortschritt seit {int(idle_minutes)} Min, "
|
||||||
|
f"gesamt {int(age_minutes)} Min)"
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
is_orphan = True
|
||||||
|
reason = f"Verwaist (keine Pipeline-Schritte nach {int(age_minutes)} Min)"
|
||||||
|
|
||||||
|
if is_orphan:
|
||||||
await db.execute(
|
await db.execute(
|
||||||
"UPDATE refresh_log SET status = 'error', completed_at = ?, error_message = ? WHERE id = ?",
|
"UPDATE refresh_log SET status = 'error', completed_at = ?, error_message = ? WHERE id = ?",
|
||||||
(now.strftime('%Y-%m-%d %H:%M:%S'), f"Verwaist (>{int(age_minutes)} Min ohne Abschluss, automatisch bereinigt)", orphan["id"]),
|
(now.strftime('%Y-%m-%d %H:%M:%S'), reason, orphan["id"]),
|
||||||
)
|
)
|
||||||
logger.warning(f"Verwaisten Refresh #{orphan['id']} für Lage {orphan['incident_id']} bereinigt ({int(age_minutes)} Min)")
|
logger.warning(f"Verwaisten Refresh #{orphan['id']} fuer Lage {orphan['incident_id']} bereinigt: {reason}")
|
||||||
|
|
||||||
# Alte Notifications bereinigen (> 7 Tage)
|
# Alte Notifications bereinigen (> 7 Tage)
|
||||||
await db.execute("DELETE FROM notifications WHERE created_at < datetime('now', '-7 days')")
|
await db.execute("DELETE FROM notifications WHERE created_at < datetime('now', '-7 days')")
|
||||||
@@ -298,6 +339,8 @@ async def lifespan(app: FastAPI):
|
|||||||
orchestrator.set_ws_manager(ws_manager)
|
orchestrator.set_ws_manager(ws_manager)
|
||||||
await orchestrator.start()
|
await orchestrator.start()
|
||||||
|
|
||||||
|
from services import pdf_ingest as _pdf_ingest
|
||||||
|
scheduler.add_job(_pdf_ingest.run_once, "interval", minutes=1, id="pdf_ingest", max_instances=1, coalesce=True)
|
||||||
scheduler.add_job(check_auto_refresh, "interval", minutes=1, id="auto_refresh")
|
scheduler.add_job(check_auto_refresh, "interval", minutes=1, id="auto_refresh")
|
||||||
scheduler.add_job(cleanup_expired, "interval", hours=1, id="cleanup")
|
scheduler.add_job(cleanup_expired, "interval", hours=1, id="cleanup")
|
||||||
scheduler.add_job(daily_source_health_check, "cron", hour=4, minute=0, id="source_health")
|
scheduler.add_job(daily_source_health_check, "cron", hour=4, minute=0, id="source_health")
|
||||||
|
|||||||
@@ -57,6 +57,7 @@ class IncidentCreate(BaseModel):
|
|||||||
retention_days: int = Field(default=0, ge=0, le=999)
|
retention_days: int = Field(default=0, ge=0, le=999)
|
||||||
international_sources: bool = False
|
international_sources: bool = False
|
||||||
include_telegram: bool = False
|
include_telegram: bool = False
|
||||||
|
include_x: bool = False
|
||||||
visibility: str = Field(default="public", pattern="^(public|private)$")
|
visibility: str = Field(default="public", pattern="^(public|private)$")
|
||||||
|
|
||||||
|
|
||||||
@@ -71,6 +72,7 @@ class IncidentUpdate(BaseModel):
|
|||||||
retention_days: Optional[int] = Field(default=None, ge=0, le=999)
|
retention_days: Optional[int] = Field(default=None, ge=0, le=999)
|
||||||
international_sources: Optional[bool] = None
|
international_sources: Optional[bool] = None
|
||||||
include_telegram: Optional[bool] = None
|
include_telegram: Optional[bool] = None
|
||||||
|
include_x: Optional[bool] = None
|
||||||
visibility: Optional[str] = Field(default=None, pattern="^(public|private)$")
|
visibility: Optional[str] = Field(default=None, pattern="^(public|private)$")
|
||||||
|
|
||||||
|
|
||||||
@@ -98,8 +100,11 @@ class IncidentResponse(BaseModel):
|
|||||||
visibility: str = "public"
|
visibility: str = "public"
|
||||||
summary: Optional[str]
|
summary: Optional[str]
|
||||||
latest_developments: Optional[str] = None
|
latest_developments: Optional[str] = None
|
||||||
|
public_mood: Optional[str] = None
|
||||||
|
public_mood_updated_at: Optional[str] = None
|
||||||
international_sources: bool = True
|
international_sources: bool = True
|
||||||
include_telegram: bool = False
|
include_telegram: bool = False
|
||||||
|
include_x: bool = False
|
||||||
created_by: int
|
created_by: int
|
||||||
created_by_username: str = ""
|
created_by_username: str = ""
|
||||||
created_at: str
|
created_at: str
|
||||||
@@ -128,6 +133,7 @@ class IncidentListItem(BaseModel):
|
|||||||
visibility: str = "public"
|
visibility: str = "public"
|
||||||
international_sources: bool = True
|
international_sources: bool = True
|
||||||
include_telegram: bool = False
|
include_telegram: bool = False
|
||||||
|
include_x: bool = False
|
||||||
created_by: int
|
created_by: int
|
||||||
created_by_username: str = ""
|
created_by_username: str = ""
|
||||||
created_at: str
|
created_at: str
|
||||||
@@ -140,8 +146,8 @@ class IncidentListItem(BaseModel):
|
|||||||
|
|
||||||
|
|
||||||
# Sources (Quellenverwaltung)
|
# Sources (Quellenverwaltung)
|
||||||
SOURCE_TYPE_PATTERN = "^(rss_feed|web_source|excluded|telegram_channel|podcast_feed)$"
|
SOURCE_TYPE_PATTERN = "^(rss_feed|web_source|excluded|telegram_channel|podcast_feed|pdf_document|x_account)$"
|
||||||
SOURCE_CATEGORY_PATTERN = "^(nachrichtenagentur|oeffentlich-rechtlich|qualitaetszeitung|behoerde|fachmedien|think-tank|international|regional|boulevard|sonstige)$"
|
SOURCE_CATEGORY_PATTERN = "^(nachrichtenagentur|oeffentlich-rechtlich|qualitaetszeitung|behoerde|fachmedien|think-tank|international|regional|boulevard|sonstige|x)$"
|
||||||
SOURCE_STATUS_PATTERN = "^(active|inactive)$"
|
SOURCE_STATUS_PATTERN = "^(active|inactive)$"
|
||||||
class SourceCreate(BaseModel):
|
class SourceCreate(BaseModel):
|
||||||
name: str = Field(min_length=1, max_length=200)
|
name: str = Field(min_length=1, max_length=200)
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import re
|
|||||||
import uuid
|
import uuid
|
||||||
from collections import defaultdict
|
from collections import defaultdict
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
|
from html import escape as _html_escape
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
import pikepdf
|
import pikepdf
|
||||||
@@ -153,6 +154,66 @@ def _markdown_to_html(text: str) -> str:
|
|||||||
return '\n'.join(result)
|
return '\n'.join(result)
|
||||||
|
|
||||||
|
|
||||||
|
def _parse_developments_for_export(text: str) -> list[tuple[str, str]]:
|
||||||
|
"""Parst die 'Neuesten Entwicklungen' (latest_developments) fuer den Export.
|
||||||
|
|
||||||
|
Eingabeformat je Eintrag: '- [DD.MM. HH:MM] Text {Quelle|URL, ...}'.
|
||||||
|
Liefert (datum_label, body) je Eintrag in gespeicherter Reihenfolge.
|
||||||
|
Quellen-Klammern und [N]-Zitate werden entfernt — der Export zeigt bewusst
|
||||||
|
KEINE Links. Das gespeicherte Format enthaelt kein Jahr; fehlt es, wird das
|
||||||
|
aktuelle Jahr ergaenzt (Live-Monitoring-Berichte sind tagesaktuell).
|
||||||
|
"""
|
||||||
|
if not text:
|
||||||
|
return []
|
||||||
|
year2 = datetime.now(TIMEZONE).strftime("%y")
|
||||||
|
bullet_re = re.compile(
|
||||||
|
r"^\s*(?:[-*•]\s*)?\[\s*(\d{1,2})\.(\d{1,2})\.?(?:(\d{2,4}))?\s+(\d{1,2}:\d{2})\s*\]\s*(.+?)\s*$"
|
||||||
|
)
|
||||||
|
trailing_braces = re.compile(r"\s*\{[^{}]*\}\s*\.?\s*$")
|
||||||
|
citation_re = re.compile(r"\s*\[\d{1,5}[a-z]?\]")
|
||||||
|
result: list[tuple[str, str]] = []
|
||||||
|
for raw in text.splitlines():
|
||||||
|
line = raw.strip()
|
||||||
|
if not line:
|
||||||
|
continue
|
||||||
|
m = bullet_re.match(line)
|
||||||
|
if not m:
|
||||||
|
continue
|
||||||
|
day, month, year, time = m.group(1), m.group(2), m.group(3), m.group(4)
|
||||||
|
body = m.group(5).strip()
|
||||||
|
# Quellen-Klammer am Ende und Inline-[N]-Zitate entfernen (keine Links)
|
||||||
|
body = trailing_braces.sub("", body).strip()
|
||||||
|
body = citation_re.sub("", body).strip()
|
||||||
|
if not body:
|
||||||
|
continue
|
||||||
|
yy = year[-2:] if year else year2
|
||||||
|
label = f"{int(day):02d}.{int(month):02d}.{yy}, {time} Uhr"
|
||||||
|
result.append((label, body))
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
def _format_latest_developments_html(text: str) -> str:
|
||||||
|
"""Rendert die 'Neuesten Entwicklungen' als HTML-Block fuer den PDF-Export.
|
||||||
|
|
||||||
|
Pro Eintrag: Datum/Uhrzeit-Zeile, darunter (eigener Absatz) der Meldungstext.
|
||||||
|
Keine Quellen-Links. Faellt bei nicht-parsebarem Text auf _markdown_to_html zurueck.
|
||||||
|
"""
|
||||||
|
pairs = _parse_developments_for_export(text)
|
||||||
|
if not pairs:
|
||||||
|
return _markdown_to_html(text)
|
||||||
|
blocks = []
|
||||||
|
for label, body in pairs:
|
||||||
|
body_html = _html_escape(body)
|
||||||
|
body_html = re.sub(r'\*\*(.+?)\*\*', r'<strong>\1</strong>', body_html)
|
||||||
|
blocks.append(
|
||||||
|
'<div class="dev-entry">'
|
||||||
|
f'<div class="dev-entry-date">{_html_escape(label)}</div>'
|
||||||
|
f'<div class="dev-entry-body">{body_html}</div>'
|
||||||
|
'</div>'
|
||||||
|
)
|
||||||
|
return "\n".join(blocks)
|
||||||
|
|
||||||
|
|
||||||
def _truncate_lagebild(summary_text: str, max_chars: int = 4000) -> str:
|
def _truncate_lagebild(summary_text: str, max_chars: int = 4000) -> str:
|
||||||
"""Lagebild für den Lagebericht auf die Zusammenfassung kürzen.
|
"""Lagebild für den Lagebericht auf die Zusammenfassung kürzen.
|
||||||
|
|
||||||
@@ -462,8 +523,12 @@ def _build_export_metadata(
|
|||||||
organization_name: str | None,
|
organization_name: str | None,
|
||||||
top_locations: list[str] | None,
|
top_locations: list[str] | None,
|
||||||
snapshot_count: int = 0,
|
snapshot_count: int = 0,
|
||||||
|
include_branding: bool = True,
|
||||||
) -> dict:
|
) -> dict:
|
||||||
"""Einheitlicher Metadaten-Dict fuer PDF (HTML-Meta-Tags) und DOCX (core_properties)."""
|
"""Einheitlicher Metadaten-Dict fuer PDF (HTML-Meta-Tags) und DOCX (core_properties).
|
||||||
|
|
||||||
|
include_branding=False neutralisiert alle AegisSight-Firmenbezeichnungen (White-Label-Export).
|
||||||
|
"""
|
||||||
is_research = incident.get("type") == "research"
|
is_research = incident.get("type") == "research"
|
||||||
type_label = "Hintergrundrecherche" if is_research else "Live-Monitoring"
|
type_label = "Hintergrundrecherche" if is_research else "Live-Monitoring"
|
||||||
category = "OSINT-Hintergrundrecherche" if is_research else "OSINT-Lagebericht"
|
category = "OSINT-Hintergrundrecherche" if is_research else "OSINT-Lagebericht"
|
||||||
@@ -475,6 +540,10 @@ def _build_export_metadata(
|
|||||||
subject = (incident.get("description") or "").strip()
|
subject = (incident.get("description") or "").strip()
|
||||||
if not subject:
|
if not subject:
|
||||||
subject = f"{type_label} zu: {title_raw}"
|
subject = f"{type_label} zu: {title_raw}"
|
||||||
|
# DOCX-Core-Property "subject" erzwingt ein 255-Zeichen-Limit; laengere
|
||||||
|
# Beschreibungen wuerden den Word-Export sonst mit ValueError abbrechen.
|
||||||
|
if len(subject) > 255:
|
||||||
|
subject = subject[:255]
|
||||||
|
|
||||||
# Keywords sammeln (Reihenfolge relevant für Anzeige, Dedup mit dict.fromkeys)
|
# Keywords sammeln (Reihenfolge relevant für Anzeige, Dedup mit dict.fromkeys)
|
||||||
keywords: list[str] = ["OSINT", type_label]
|
keywords: list[str] = ["OSINT", type_label]
|
||||||
@@ -546,23 +615,37 @@ def _build_export_metadata(
|
|||||||
comments_lines.append("Orte: " + ", ".join(top_locations[:5]))
|
comments_lines.append("Orte: " + ", ".join(top_locations[:5]))
|
||||||
comments = "\n".join(comments_lines)
|
comments = "\n".join(comments_lines)
|
||||||
|
|
||||||
publisher = organization_name or "AegisSight"
|
# Branding-abhaengige Felder: bei include_branding=False neutralisiert (White-Label-Export)
|
||||||
identifier = f"urn:aegissight:incident:{incident.get('id', '0')}:{now.strftime('%Y%m%dT%H%M%S')}"
|
if include_branding:
|
||||||
rights = (
|
publisher = organization_name or "AegisSight"
|
||||||
"Vertrauliche Lageanalyse — AegisSight Monitor. "
|
author = creator or "AegisSight Monitor"
|
||||||
"Weitergabe nur an autorisierte Empfänger."
|
creator_app = "AegisSight Monitor"
|
||||||
)
|
producer = "WeasyPrint + AegisSight Monitor"
|
||||||
|
urn_ns = "aegissight"
|
||||||
|
rights = (
|
||||||
|
"Vertrauliche Lageanalyse — AegisSight Monitor. "
|
||||||
|
"Weitergabe nur an autorisierte Empfänger."
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
publisher = organization_name or ""
|
||||||
|
author = creator or "Unbekannt"
|
||||||
|
creator_app = ""
|
||||||
|
producer = "WeasyPrint"
|
||||||
|
urn_ns = "report"
|
||||||
|
rights = "Vertrauliche Lageanalyse. Weitergabe nur an autorisierte Empfänger."
|
||||||
|
identifier = f"urn:{urn_ns}:incident:{incident.get('id', '0')}:{now.strftime('%Y%m%dT%H%M%S')}"
|
||||||
|
|
||||||
return {
|
return {
|
||||||
"title": title,
|
"title": title,
|
||||||
"author": creator or "AegisSight Monitor",
|
"author": author,
|
||||||
"subject": subject,
|
"subject": subject,
|
||||||
"keywords": unique_keywords,
|
"keywords": unique_keywords,
|
||||||
"keywords_comma": ", ".join(unique_keywords),
|
"keywords_comma": ", ".join(unique_keywords),
|
||||||
"keywords_semicolon": "; ".join(unique_keywords),
|
"keywords_semicolon": "; ".join(unique_keywords),
|
||||||
"category": category,
|
"category": category,
|
||||||
"comments": comments,
|
"comments": comments,
|
||||||
"creator_app": "AegisSight Monitor",
|
"creator_app": creator_app,
|
||||||
|
"producer": producer,
|
||||||
"language": "de-DE",
|
"language": "de-DE",
|
||||||
"created": created,
|
"created": created,
|
||||||
"modified": modified,
|
"modified": modified,
|
||||||
@@ -634,7 +717,7 @@ def _enrich_pdf_metadata(pdf_bytes: bytes, meta: dict) -> bytes:
|
|||||||
|
|
||||||
# PDF Namespace
|
# PDF Namespace
|
||||||
xmp["pdf:Keywords"] = meta.get("keywords_comma", "")
|
xmp["pdf:Keywords"] = meta.get("keywords_comma", "")
|
||||||
xmp["pdf:Producer"] = "WeasyPrint + AegisSight Monitor"
|
xmp["pdf:Producer"] = meta.get("producer", "WeasyPrint + AegisSight Monitor")
|
||||||
|
|
||||||
# XMP Namespace
|
# XMP Namespace
|
||||||
xmp["xmp:CreatorTool"] = meta.get("creator_app", "AegisSight Monitor")
|
xmp["xmp:CreatorTool"] = meta.get("creator_app", "AegisSight Monitor")
|
||||||
@@ -681,6 +764,7 @@ async def generate_pdf(
|
|||||||
organization_name: str | None = None,
|
organization_name: str | None = None,
|
||||||
top_locations: list[str] | None = None,
|
top_locations: list[str] | None = None,
|
||||||
snapshot_count: int = 0,
|
snapshot_count: int = 0,
|
||||||
|
include_branding: bool = True,
|
||||||
) -> bytes:
|
) -> bytes:
|
||||||
"""PDF-Report via WeasyPrint generieren."""
|
"""PDF-Report via WeasyPrint generieren."""
|
||||||
# Sections aus scope ableiten wenn nicht explizit angegeben
|
# Sections aus scope ableiten wenn nicht explizit angegeben
|
||||||
@@ -692,27 +776,39 @@ async def generate_pdf(
|
|||||||
else: # full
|
else: # full
|
||||||
sections = {"zusammenfassung", "bericht", "faktencheck", "quellen", "timeline"}
|
sections = {"zusammenfassung", "bericht", "faktencheck", "quellen", "timeline"}
|
||||||
|
|
||||||
# Fuer Research-Lagen: Zusammenfassung aus dem Bericht extrahieren
|
# Zusammenfassungs-Quelle bestimmen:
|
||||||
|
# - Research: ZUSAMMENFASSUNG/UEBERBLICK aus dem Bericht extrahieren.
|
||||||
|
# - Live-Monitoring (adhoc): "Neueste Entwicklungen" aus latest_developments,
|
||||||
|
# ohne Quellen-Links, Datum/Uhrzeit als eigene Zeile.
|
||||||
|
# - sonst: KI-Executive-Summary (executive_summary_html).
|
||||||
is_research = incident.get("type") == "research"
|
is_research = incident.get("type") == "research"
|
||||||
all_sources = _prepare_sources(incident)
|
all_sources = _prepare_sources(incident)
|
||||||
|
latest_dev = (incident.get("latest_developments") or "").strip()
|
||||||
zusammenfassung_html = executive_summary_html
|
zusammenfassung_html = executive_summary_html
|
||||||
bericht_summary = incident.get("summary", "")
|
bericht_summary = incident.get("summary", "")
|
||||||
zusammenfassung_title = "Zusammenfassung"
|
zusammenfassung_title = "Zusammenfassung"
|
||||||
|
summary_has_links = True
|
||||||
|
|
||||||
if is_research and bericht_summary:
|
if is_research and bericht_summary:
|
||||||
extracted_html, remaining = _extract_zusammenfassung(bericht_summary, all_sources)
|
extracted_html, remaining = _extract_zusammenfassung(bericht_summary, all_sources)
|
||||||
if extracted_html:
|
if extracted_html:
|
||||||
zusammenfassung_html = extracted_html
|
zusammenfassung_html = extracted_html
|
||||||
zusammenfassung_title = "Zusammenfassung"
|
|
||||||
bericht_summary = remaining
|
bericht_summary = remaining
|
||||||
|
elif not is_research and latest_dev:
|
||||||
|
dev_html = _format_latest_developments_html(latest_dev)
|
||||||
|
if dev_html:
|
||||||
|
zusammenfassung_html = dev_html
|
||||||
|
zusammenfassung_title = "Neueste Entwicklungen"
|
||||||
|
summary_has_links = False # Quellen bewusst entfernt
|
||||||
|
|
||||||
# Auch das (nicht-research) Executive Summary linkifizieren — ggf. enthaelt es Zitate
|
# KI-/Research-Zusammenfassung linkifizieren; Developments bleiben linkfrei
|
||||||
if not is_research and zusammenfassung_html:
|
if not is_research and summary_has_links and zusammenfassung_html:
|
||||||
zusammenfassung_html = _linkify_citations_html(zusammenfassung_html, all_sources)
|
zusammenfassung_html = _linkify_citations_html(zusammenfassung_html, all_sources)
|
||||||
|
|
||||||
meta = _build_export_metadata(
|
meta = _build_export_metadata(
|
||||||
incident, articles, fact_checks, all_sources, creator, scope, sections,
|
incident, articles, fact_checks, all_sources, creator, scope, sections,
|
||||||
organization_name, top_locations, snapshot_count=snapshot_count,
|
organization_name, top_locations, snapshot_count=snapshot_count,
|
||||||
|
include_branding=include_branding,
|
||||||
)
|
)
|
||||||
|
|
||||||
env = Environment(loader=FileSystemLoader(str(TEMPLATE_DIR)))
|
env = Environment(loader=FileSystemLoader(str(TEMPLATE_DIR)))
|
||||||
@@ -741,6 +837,7 @@ async def generate_pdf(
|
|||||||
timeline=_prepare_timeline(articles) if scope == "full" else [],
|
timeline=_prepare_timeline(articles) if scope == "full" else [],
|
||||||
articles=articles if scope == "full" else [],
|
articles=articles if scope == "full" else [],
|
||||||
meta=meta,
|
meta=meta,
|
||||||
|
include_branding=include_branding,
|
||||||
)
|
)
|
||||||
|
|
||||||
# Artikel pub_date aufbereiten
|
# Artikel pub_date aufbereiten
|
||||||
@@ -764,6 +861,7 @@ async def generate_docx(
|
|||||||
organization_name: str | None = None,
|
organization_name: str | None = None,
|
||||||
top_locations: list[str] | None = None,
|
top_locations: list[str] | None = None,
|
||||||
snapshot_count: int = 0,
|
snapshot_count: int = 0,
|
||||||
|
include_branding: bool = True,
|
||||||
) -> bytes:
|
) -> bytes:
|
||||||
"""Word-Report via python-docx generieren."""
|
"""Word-Report via python-docx generieren."""
|
||||||
doc = Document()
|
doc = Document()
|
||||||
@@ -777,24 +875,32 @@ async def generate_docx(
|
|||||||
else: # full
|
else: # full
|
||||||
sections = {"zusammenfassung", "bericht", "faktencheck", "quellen", "timeline"}
|
sections = {"zusammenfassung", "bericht", "faktencheck", "quellen", "timeline"}
|
||||||
|
|
||||||
# Fuer Research-Lagen: Zusammenfassung aus dem Bericht extrahieren
|
# Zusammenfassungs-Quelle bestimmen (analog generate_pdf):
|
||||||
|
# Research -> Bericht-Extrakt, Live-Monitoring -> "Neueste Entwicklungen", sonst KI.
|
||||||
is_research = incident.get("type") == "research"
|
is_research = incident.get("type") == "research"
|
||||||
all_sources = _prepare_sources(incident)
|
all_sources = _prepare_sources(incident)
|
||||||
|
latest_dev = (incident.get("latest_developments") or "").strip()
|
||||||
zusammenfassung_text = executive_summary_text
|
zusammenfassung_text = executive_summary_text
|
||||||
bericht_summary = incident.get("summary") or "Keine Zusammenfassung verfügbar."
|
bericht_summary = incident.get("summary") or "Keine Zusammenfassung verfügbar."
|
||||||
zusammenfassung_title = "Zusammenfassung"
|
zusammenfassung_title = "Zusammenfassung"
|
||||||
zusammenfassung_lines: list[str] = []
|
zusammenfassung_lines: list[str] = []
|
||||||
|
zusammenfassung_developments: list[tuple[str, str]] = []
|
||||||
|
|
||||||
if is_research and bericht_summary:
|
if is_research and bericht_summary:
|
||||||
extracted_lines, remaining = _extract_zusammenfassung_lines(bericht_summary)
|
extracted_lines, remaining = _extract_zusammenfassung_lines(bericht_summary)
|
||||||
if extracted_lines:
|
if extracted_lines:
|
||||||
zusammenfassung_lines = extracted_lines
|
zusammenfassung_lines = extracted_lines
|
||||||
zusammenfassung_title = "Zusammenfassung"
|
|
||||||
bericht_summary = remaining
|
bericht_summary = remaining
|
||||||
|
elif not is_research and latest_dev:
|
||||||
|
dev_pairs = _parse_developments_for_export(latest_dev)
|
||||||
|
if dev_pairs:
|
||||||
|
zusammenfassung_developments = dev_pairs
|
||||||
|
zusammenfassung_title = "Neueste Entwicklungen"
|
||||||
|
|
||||||
meta = _build_export_metadata(
|
meta = _build_export_metadata(
|
||||||
incident, articles, fact_checks, all_sources, creator, scope, sections,
|
incident, articles, fact_checks, all_sources, creator, scope, sections,
|
||||||
organization_name, top_locations, snapshot_count=snapshot_count,
|
organization_name, top_locations, snapshot_count=snapshot_count,
|
||||||
|
include_branding=include_branding,
|
||||||
)
|
)
|
||||||
|
|
||||||
# Dateimetadaten setzen (sichtbar in Explorer/Finder, DMS-Systemen)
|
# Dateimetadaten setzen (sichtbar in Explorer/Finder, DMS-Systemen)
|
||||||
@@ -823,13 +929,15 @@ async def generate_docx(
|
|||||||
for _ in range(6):
|
for _ in range(6):
|
||||||
doc.add_paragraph()
|
doc.add_paragraph()
|
||||||
|
|
||||||
title_para = doc.add_paragraph()
|
# Firmenname-Zeile nur im gebrandeten Export
|
||||||
title_para.alignment = WD_ALIGN_PARAGRAPH.CENTER
|
if include_branding:
|
||||||
run = title_para.add_run("AegisSight Monitor")
|
title_para = doc.add_paragraph()
|
||||||
run.font.size = Pt(12)
|
title_para.alignment = WD_ALIGN_PARAGRAPH.CENTER
|
||||||
run.font.color.rgb = RGBColor(0x0a, 0x18, 0x32)
|
run = title_para.add_run("AegisSight Monitor")
|
||||||
|
run.font.size = Pt(12)
|
||||||
|
run.font.color.rgb = RGBColor(0x0a, 0x18, 0x32)
|
||||||
|
|
||||||
doc.add_paragraph()
|
doc.add_paragraph()
|
||||||
|
|
||||||
type_label = "Hintergrundrecherche" if incident.get("type") == "research" else "Live-Monitoring"
|
type_label = "Hintergrundrecherche" if incident.get("type") == "research" else "Live-Monitoring"
|
||||||
type_para = doc.add_paragraph()
|
type_para = doc.add_paragraph()
|
||||||
@@ -865,11 +973,23 @@ async def generate_docx(
|
|||||||
|
|
||||||
doc.add_page_break()
|
doc.add_page_break()
|
||||||
|
|
||||||
# --- Zusammenfassung / Executive Summary ---
|
# --- Zusammenfassung / Neueste Entwicklungen ---
|
||||||
if "zusammenfassung" in sections:
|
if "zusammenfassung" in sections:
|
||||||
doc.add_heading(zusammenfassung_title, level=1)
|
doc.add_heading(zusammenfassung_title, level=1)
|
||||||
|
|
||||||
if zusammenfassung_lines:
|
if zusammenfassung_developments:
|
||||||
|
# Live-Monitoring: pro Eintrag Datum/Uhrzeit-Zeile + Absatz mit Text, ohne Links
|
||||||
|
for label, body in zusammenfassung_developments:
|
||||||
|
date_para = doc.add_paragraph()
|
||||||
|
date_para.paragraph_format.space_after = Pt(1)
|
||||||
|
run = date_para.add_run(label)
|
||||||
|
run.bold = True
|
||||||
|
run.font.size = Pt(9)
|
||||||
|
run.font.color.rgb = RGBColor(0x0a, 0x18, 0x32)
|
||||||
|
body_para = doc.add_paragraph()
|
||||||
|
body_para.paragraph_format.space_after = Pt(8)
|
||||||
|
body_para.add_run(re.sub(r'\*\*(.+?)\*\*', r'\1', body))
|
||||||
|
elif zusammenfassung_lines:
|
||||||
for line in zusammenfassung_lines:
|
for line in zusammenfassung_lines:
|
||||||
_add_docx_paragraph_with_citations(doc, line, all_sources, style='List Bullet')
|
_add_docx_paragraph_with_citations(doc, line, all_sources, style='List Bullet')
|
||||||
else:
|
else:
|
||||||
@@ -978,7 +1098,11 @@ async def generate_docx(
|
|||||||
doc.add_paragraph()
|
doc.add_paragraph()
|
||||||
footer = doc.add_paragraph()
|
footer = doc.add_paragraph()
|
||||||
footer.alignment = WD_ALIGN_PARAGRAPH.CENTER
|
footer.alignment = WD_ALIGN_PARAGRAPH.CENTER
|
||||||
run = footer.add_run(f"Erstellt mit AegisSight Monitor — aegis-sight.de — {now.strftime('%d.%m.%Y')}")
|
if include_branding:
|
||||||
|
footer_text = f"Erstellt mit AegisSight Monitor — aegis-sight.de — {now.strftime('%d.%m.%Y')}"
|
||||||
|
else:
|
||||||
|
footer_text = f"Stand: {now.strftime('%d.%m.%Y')}"
|
||||||
|
run = footer.add_run(footer_text)
|
||||||
run.font.size = Pt(8)
|
run.font.size = Pt(8)
|
||||||
run.font.color.rgb = RGBColor(0x0a, 0x18, 0x32)
|
run.font.color.rgb = RGBColor(0x0a, 0x18, 0x32)
|
||||||
|
|
||||||
|
|||||||
@@ -47,6 +47,12 @@ body { font-family: -apple-system, 'Segoe UI', Roboto, Helvetica, Arial, sans-se
|
|||||||
.exec-summary ul { margin: 8px 0 0 18px; }
|
.exec-summary ul { margin: 8px 0 0 18px; }
|
||||||
.exec-summary li { margin-bottom: 6px; line-height: 1.6; }
|
.exec-summary li { margin-bottom: 6px; line-height: 1.6; }
|
||||||
|
|
||||||
|
/* Neueste Entwicklungen (Live-Monitoring) */
|
||||||
|
.dev-entry { margin-bottom: 12px; }
|
||||||
|
.dev-entry:last-child { margin-bottom: 0; }
|
||||||
|
.dev-entry-date { font-size: 9pt; font-weight: 600; color: #0a1832; margin-bottom: 2px; }
|
||||||
|
.dev-entry-body { font-size: 10.5pt; line-height: 1.5; }
|
||||||
|
|
||||||
/* Lagebild */
|
/* Lagebild */
|
||||||
.lagebild-content { line-height: 1.7; }
|
.lagebild-content { line-height: 1.7; }
|
||||||
.lagebild-content p { margin-bottom: 8px; }
|
.lagebild-content p { margin-bottom: 8px; }
|
||||||
@@ -84,7 +90,7 @@ tr:nth-child(even) { background: #f8f9fa; }
|
|||||||
<body>
|
<body>
|
||||||
<!-- Deckblatt -->
|
<!-- Deckblatt -->
|
||||||
<div class="cover">
|
<div class="cover">
|
||||||
<img src="data:image/svg+xml;base64,{{ logo_base64 }}" class="cover-logo" alt="AegisSight">
|
{% if include_branding %}<img src="data:image/svg+xml;base64,{{ logo_base64 }}" class="cover-logo" alt="AegisSight">{% endif %}
|
||||||
<div class="cover-type">{{ incident_type_label }}</div>
|
<div class="cover-type">{{ incident_type_label }}</div>
|
||||||
<div class="cover-title">{{ incident.title }}</div>
|
<div class="cover-title">{{ incident.title }}</div>
|
||||||
<div class="cover-meta">
|
<div class="cover-meta">
|
||||||
@@ -92,14 +98,14 @@ tr:nth-child(even) { background: #f8f9fa; }
|
|||||||
<div>Erstellt von: {{ creator }}</div>
|
<div>Erstellt von: {{ creator }}</div>
|
||||||
{% if incident.organization_name %}<div>Organisation: {{ incident.organization_name }}</div>{% endif %}
|
{% if incident.organization_name %}<div>Organisation: {{ incident.organization_name }}</div>{% endif %}
|
||||||
</div>
|
</div>
|
||||||
<div class="cover-brand">AegisSight Monitor</div>
|
{% if include_branding %}<div class="cover-brand">AegisSight Monitor</div>{% endif %}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Inhaltsverzeichnis -->
|
<!-- Inhaltsverzeichnis -->
|
||||||
<div class="toc">
|
<div class="toc">
|
||||||
<h2>Inhaltsverzeichnis</h2>
|
<h2>Inhaltsverzeichnis</h2>
|
||||||
<ul class="toc-list">
|
<ul class="toc-list">
|
||||||
{% if 'zusammenfassung' in sections %}<li><a href="#sec-zusammenfassung">Zusammenfassung</a></li>{% endif %}
|
{% if 'zusammenfassung' in sections %}<li><a href="#sec-zusammenfassung">{{ zusammenfassung_title }}</a></li>{% endif %}
|
||||||
{% if 'bericht' in sections %}<li><a href="#sec-bericht">{% if incident.type == "research" %}Recherchebericht{% else %}Lagebild{% endif %}</a></li>{% endif %}
|
{% if 'bericht' in sections %}<li><a href="#sec-bericht">{% if incident.type == "research" %}Recherchebericht{% else %}Lagebild{% endif %}</a></li>{% endif %}
|
||||||
{% if 'faktencheck' in sections and fact_checks %}<li><a href="#sec-faktencheck">Faktencheck</a></li>{% endif %}
|
{% if 'faktencheck' in sections and fact_checks %}<li><a href="#sec-faktencheck">Faktencheck</a></li>{% endif %}
|
||||||
{% if 'quellen' in sections and sources %}<li><a href="#sec-quellen">Quellenverzeichnis</a></li>{% endif %}
|
{% if 'quellen' in sections and sources %}<li><a href="#sec-quellen">Quellenverzeichnis</a></li>{% endif %}
|
||||||
@@ -208,7 +214,7 @@ tr:nth-child(even) { background: #f8f9fa; }
|
|||||||
{% endif %}
|
{% endif %}
|
||||||
|
|
||||||
<div class="report-footer">
|
<div class="report-footer">
|
||||||
Erstellt mit AegisSight Monitor — aegis-sight.de — {{ report_date }}
|
{% if include_branding %}Erstellt mit AegisSight Monitor — aegis-sight.de — {{ report_date }}{% else %}Stand: {{ report_date }}{% endif %}
|
||||||
</div>
|
</div>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
@@ -21,7 +21,7 @@ router = APIRouter(prefix="/api/incidents", tags=["incidents"])
|
|||||||
|
|
||||||
INCIDENT_UPDATE_COLUMNS = {
|
INCIDENT_UPDATE_COLUMNS = {
|
||||||
"title", "description", "type", "status", "refresh_mode",
|
"title", "description", "type", "status", "refresh_mode",
|
||||||
"refresh_interval", "refresh_start_time", "retention_days", "international_sources", "include_telegram", "visibility",
|
"refresh_interval", "refresh_start_time", "retention_days", "international_sources", "include_telegram", "include_x", "visibility",
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
@@ -89,7 +89,7 @@ async def list_incidents(
|
|||||||
query = (
|
query = (
|
||||||
"SELECT id, title, description, type, status, refresh_mode, refresh_interval, "
|
"SELECT id, title, description, type, status, refresh_mode, refresh_interval, "
|
||||||
"refresh_start_time, retention_days, visibility, "
|
"refresh_start_time, retention_days, visibility, "
|
||||||
"international_sources, include_telegram, created_by, created_at, updated_at, "
|
"international_sources, include_telegram, include_x, created_by, created_at, updated_at, "
|
||||||
"CASE WHEN summary IS NOT NULL AND summary != '' THEN 1 ELSE 0 END AS has_summary "
|
"CASE WHEN summary IS NOT NULL AND summary != '' THEN 1 ELSE 0 END AS has_summary "
|
||||||
"FROM incidents WHERE tenant_id = ? AND (visibility = 'public' OR created_by = ?)"
|
"FROM incidents WHERE tenant_id = ? AND (visibility = 'public' OR created_by = ?)"
|
||||||
)
|
)
|
||||||
@@ -120,9 +120,9 @@ async def create_incident(
|
|||||||
now = datetime.now(TIMEZONE).strftime('%Y-%m-%d %H:%M:%S')
|
now = datetime.now(TIMEZONE).strftime('%Y-%m-%d %H:%M:%S')
|
||||||
cursor = await db.execute(
|
cursor = await db.execute(
|
||||||
"""INSERT INTO incidents (title, description, type, refresh_mode, refresh_interval,
|
"""INSERT INTO incidents (title, description, type, refresh_mode, refresh_interval,
|
||||||
refresh_start_time, retention_days, international_sources, include_telegram, visibility,
|
refresh_start_time, retention_days, international_sources, include_telegram, include_x, visibility,
|
||||||
tenant_id, created_by, created_at, updated_at)
|
tenant_id, created_by, created_at, updated_at)
|
||||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)""",
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)""",
|
||||||
(
|
(
|
||||||
data.title,
|
data.title,
|
||||||
data.description,
|
data.description,
|
||||||
@@ -133,6 +133,7 @@ async def create_incident(
|
|||||||
data.retention_days,
|
data.retention_days,
|
||||||
1 if data.international_sources else 0,
|
1 if data.international_sources else 0,
|
||||||
1 if data.include_telegram else 0,
|
1 if data.include_telegram else 0,
|
||||||
|
1 if data.include_x else 0,
|
||||||
data.visibility,
|
data.visibility,
|
||||||
tenant_id,
|
tenant_id,
|
||||||
current_user["id"],
|
current_user["id"],
|
||||||
@@ -385,7 +386,7 @@ async def update_incident(
|
|||||||
for field, value in data.model_dump(exclude_none=True).items():
|
for field, value in data.model_dump(exclude_none=True).items():
|
||||||
if field not in INCIDENT_UPDATE_COLUMNS:
|
if field not in INCIDENT_UPDATE_COLUMNS:
|
||||||
continue
|
continue
|
||||||
if field in ("international_sources", "include_telegram"):
|
if field in ("international_sources", "include_telegram", "include_x"):
|
||||||
updates[field] = 1 if value else 0
|
updates[field] = 1 if value else 0
|
||||||
else:
|
else:
|
||||||
updates[field] = value
|
updates[field] = value
|
||||||
@@ -506,6 +507,14 @@ async def get_articles_sources_summary(
|
|||||||
d = dict(r)
|
d = dict(r)
|
||||||
langs = (d.pop("languages") or "de").split(",")
|
langs = (d.pop("languages") or "de").split(",")
|
||||||
d["languages"] = sorted({(l or "de").strip() for l in langs if l is not None})
|
d["languages"] = sorted({(l or "de").strip() for l in langs if l is not None})
|
||||||
|
# Quellentyp aus dem source-Praefix ableiten (fuer den Typ-Filter der Quellenuebersicht)
|
||||||
|
src = d.get("source") or ""
|
||||||
|
if src.startswith("X: "):
|
||||||
|
d["source_type"] = "x"
|
||||||
|
elif src.startswith("Telegram: "):
|
||||||
|
d["source_type"] = "telegram"
|
||||||
|
else:
|
||||||
|
d["source_type"] = "web"
|
||||||
sources.append(d)
|
sources.append(d)
|
||||||
# Sprach-Verteilung gesamt
|
# Sprach-Verteilung gesamt
|
||||||
cursor = await db.execute(
|
cursor = await db.execute(
|
||||||
@@ -1143,6 +1152,8 @@ async def export_incident(
|
|||||||
format: str = Query("pdf", pattern="^(pdf|docx)$"),
|
format: str = Query("pdf", pattern="^(pdf|docx)$"),
|
||||||
scope: str = Query("report", pattern="^(summary|report|full)$"),
|
scope: str = Query("report", pattern="^(summary|report|full)$"),
|
||||||
sections: str = Query(None),
|
sections: str = Query(None),
|
||||||
|
branding: str = Query("on", pattern="^(on|off)$"),
|
||||||
|
creator: str = Query(None, max_length=120),
|
||||||
current_user: dict = Depends(get_current_user),
|
current_user: dict = Depends(get_current_user),
|
||||||
db: aiosqlite.Connection = Depends(db_dependency),
|
db: aiosqlite.Connection = Depends(db_dependency),
|
||||||
):
|
):
|
||||||
@@ -1161,10 +1172,13 @@ async def export_incident(
|
|||||||
row = await _check_incident_access(db, incident_id, current_user["id"], tenant_id)
|
row = await _check_incident_access(db, incident_id, current_user["id"], tenant_id)
|
||||||
incident = dict(row)
|
incident = dict(row)
|
||||||
|
|
||||||
# Ersteller-Name
|
# Ersteller-Name: manuell uebergebener Wert hat Vorrang, sonst E-Mail des Lage-Erstellers
|
||||||
cursor = await db.execute("SELECT email FROM users WHERE id = ?", (incident["created_by"],))
|
if creator and creator.strip():
|
||||||
user_row = await cursor.fetchone()
|
creator = creator.strip()
|
||||||
creator = user_row["email"] if user_row else "Unbekannt"
|
else:
|
||||||
|
cursor = await db.execute("SELECT email FROM users WHERE id = ?", (incident["created_by"],))
|
||||||
|
user_row = await cursor.fetchone()
|
||||||
|
creator = user_row["email"] if user_row else "Unbekannt"
|
||||||
|
|
||||||
# Organisation (fuer Dateimetadaten)
|
# Organisation (fuer Dateimetadaten)
|
||||||
organization_name = None
|
organization_name = None
|
||||||
@@ -1218,18 +1232,14 @@ async def export_incident(
|
|||||||
snapshots = [dict(r) for r in await cursor.fetchall()]
|
snapshots = [dict(r) for r in await cursor.fetchall()]
|
||||||
|
|
||||||
# Zusammenfassung fuer den Export:
|
# Zusammenfassung fuer den Export:
|
||||||
# - Bei Adhoc-Lagen primaer "Neueste Entwicklungen" (latest_developments) als Markdown-Bullets,
|
# - Live-Monitoring (adhoc) zeigt primaer "Neueste Entwicklungen" (latest_developments).
|
||||||
# weil Live-Monitoring von Aktualitaet lebt.
|
# Das Rendering (Datum/Uhrzeit als eigene Zeile, ohne Links) uebernimmt der
|
||||||
# - Fallback (oder bei Research): Executive Summary (KI-generiert, gecacht).
|
# Report-Generator direkt aus incident["latest_developments"].
|
||||||
|
# - Executive Summary (KI, gecacht) dient nur als Fallback (oder bei Research-Lagen).
|
||||||
is_adhoc = (incident.get("type") or "adhoc") != "research"
|
is_adhoc = (incident.get("type") or "adhoc") != "research"
|
||||||
latest_dev = (incident.get("latest_developments") or "").strip()
|
latest_dev = (incident.get("latest_developments") or "").strip()
|
||||||
exec_summary = None
|
exec_summary = incident.get("executive_summary")
|
||||||
if is_adhoc and latest_dev:
|
if not exec_summary and not (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")
|
|
||||||
if not exec_summary:
|
|
||||||
summary_text = incident.get("summary") or ""
|
summary_text = incident.get("summary") or ""
|
||||||
exec_summary = await generate_executive_summary(summary_text)
|
exec_summary = await generate_executive_summary(summary_text)
|
||||||
await db.execute(
|
await db.execute(
|
||||||
@@ -1237,6 +1247,7 @@ async def export_incident(
|
|||||||
(exec_summary, incident_id),
|
(exec_summary, incident_id),
|
||||||
)
|
)
|
||||||
await db.commit()
|
await db.commit()
|
||||||
|
exec_summary = exec_summary or ""
|
||||||
|
|
||||||
date_str = datetime.now(TIMEZONE).strftime("%Y%m%d")
|
date_str = datetime.now(TIMEZONE).strftime("%Y%m%d")
|
||||||
slug = _slugify(incident["title"])
|
slug = _slugify(incident["title"])
|
||||||
@@ -1259,6 +1270,7 @@ async def export_incident(
|
|||||||
organization_name=organization_name,
|
organization_name=organization_name,
|
||||||
top_locations=top_locations,
|
top_locations=top_locations,
|
||||||
snapshot_count=snapshot_count,
|
snapshot_count=snapshot_count,
|
||||||
|
include_branding=(branding == "on"),
|
||||||
)
|
)
|
||||||
filename = f"{slug}_{scope_labels_key}_{date_str}.pdf"
|
filename = f"{slug}_{scope_labels_key}_{date_str}.pdf"
|
||||||
return StreamingResponse(
|
return StreamingResponse(
|
||||||
@@ -1273,6 +1285,7 @@ async def export_incident(
|
|||||||
organization_name=organization_name,
|
organization_name=organization_name,
|
||||||
top_locations=top_locations,
|
top_locations=top_locations,
|
||||||
snapshot_count=snapshot_count,
|
snapshot_count=snapshot_count,
|
||||||
|
include_branding=(branding == "on"),
|
||||||
)
|
)
|
||||||
filename = f"{slug}_{scope_labels_key}_{date_str}.docx"
|
filename = f"{slug}_{scope_labels_key}_{date_str}.docx"
|
||||||
return StreamingResponse(
|
return StreamingResponse(
|
||||||
|
|||||||
@@ -1,13 +1,19 @@
|
|||||||
"""Sources-Router: Quellenverwaltung (Multi-Tenant). Klassifikation: Read-Only — Pflege in der Verwaltung."""
|
"""Sources-Router: Quellenverwaltung (Multi-Tenant). Klassifikation: Read-Only — Pflege in der Verwaltung."""
|
||||||
import json
|
import json
|
||||||
import logging
|
import logging
|
||||||
|
import uuid
|
||||||
|
import re
|
||||||
|
import os
|
||||||
|
import hashlib
|
||||||
from collections import defaultdict
|
from collections import defaultdict
|
||||||
from fastapi import APIRouter, Depends, HTTPException, status
|
from fastapi import APIRouter, Depends, File, Form, HTTPException, UploadFile, status
|
||||||
from models import SourceCreate, SourceUpdate, SourceResponse, DiscoverRequest, DiscoverResponse, DiscoverMultiResponse, DomainActionRequest
|
from models import SourceCreate, SourceUpdate, SourceResponse, DiscoverRequest, DiscoverResponse, DiscoverMultiResponse, DomainActionRequest
|
||||||
from auth import get_current_user
|
from auth import get_current_user
|
||||||
from database import db_dependency, refresh_source_counts
|
from database import db_dependency, refresh_source_counts
|
||||||
from source_rules import discover_source, discover_all_feeds, evaluate_feeds_with_claude, _extract_domain, _detect_category, domain_to_display_name, _DOMAIN_ALIASES
|
from source_rules import discover_source, discover_all_feeds, evaluate_feeds_with_claude, _extract_domain, _detect_category, domain_to_display_name, _DOMAIN_ALIASES
|
||||||
import aiosqlite
|
import aiosqlite
|
||||||
|
from config import DB_PATH
|
||||||
|
from typing import Optional
|
||||||
|
|
||||||
logger = logging.getLogger("osint.sources")
|
logger = logging.getLogger("osint.sources")
|
||||||
|
|
||||||
@@ -138,6 +144,7 @@ async def get_source_stats(
|
|||||||
"rss_feed": {"count": 0, "articles": 0},
|
"rss_feed": {"count": 0, "articles": 0},
|
||||||
"web_source": {"count": 0, "articles": 0},
|
"web_source": {"count": 0, "articles": 0},
|
||||||
"telegram_channel": {"count": 0, "articles": 0},
|
"telegram_channel": {"count": 0, "articles": 0},
|
||||||
|
"x_account": {"count": 0, "articles": 0},
|
||||||
"excluded": {"count": 0, "articles": 0},
|
"excluded": {"count": 0, "articles": 0},
|
||||||
}
|
}
|
||||||
for row in rows:
|
for row in rows:
|
||||||
@@ -631,6 +638,30 @@ async def validate_telegram_channel(
|
|||||||
raise HTTPException(status_code=500, detail="Telegram-Validierung fehlgeschlagen")
|
raise HTTPException(status_code=500, detail="Telegram-Validierung fehlgeschlagen")
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/x/validate")
|
||||||
|
async def validate_x_account(
|
||||||
|
data: dict,
|
||||||
|
current_user: dict = Depends(get_current_user),
|
||||||
|
):
|
||||||
|
"""Prueft ob ein X-Account (Twitter) erreichbar ist und gibt Account-Info zurueck."""
|
||||||
|
handle = data.get("handle", "").strip()
|
||||||
|
if not handle:
|
||||||
|
raise HTTPException(status_code=400, detail="handle ist erforderlich")
|
||||||
|
|
||||||
|
try:
|
||||||
|
from feeds.x_parser import XParser
|
||||||
|
parser = XParser()
|
||||||
|
result = await parser.validate_account(handle)
|
||||||
|
if result:
|
||||||
|
return result
|
||||||
|
raise HTTPException(status_code=404, detail="X-Account nicht erreichbar oder nicht gefunden")
|
||||||
|
except HTTPException:
|
||||||
|
raise
|
||||||
|
except Exception as e:
|
||||||
|
logger.error("X-Validierung fehlgeschlagen: %s", e, exc_info=True)
|
||||||
|
raise HTTPException(status_code=500, detail="X-Validierung fehlgeschlagen")
|
||||||
|
|
||||||
|
|
||||||
@router.post("/refresh-counts")
|
@router.post("/refresh-counts")
|
||||||
async def trigger_refresh_counts(
|
async def trigger_refresh_counts(
|
||||||
current_user: dict = Depends(get_current_user),
|
current_user: dict = Depends(get_current_user),
|
||||||
@@ -640,3 +671,110 @@ async def trigger_refresh_counts(
|
|||||||
await refresh_source_counts(db)
|
await refresh_source_counts(db)
|
||||||
return {"status": "ok"}
|
return {"status": "ok"}
|
||||||
|
|
||||||
|
|
||||||
|
# --- PDF-Upload (Kundenquelle vom Typ pdf_document) ---
|
||||||
|
# Analog zum Verwaltungs-Upload, aber tenant-spezifisch.
|
||||||
|
# Datei landet unter <dirname(DB_PATH)>/pdfs/{sha256}.pdf.
|
||||||
|
# Der Worker (services.pdf_ingest) verarbeitet sie asynchron im Minutentakt.
|
||||||
|
|
||||||
|
MAX_PDF_SIZE_BYTES = 50 * 1024 * 1024 # 50 MB
|
||||||
|
PDF_DIR = os.path.join(os.path.dirname(os.path.abspath(DB_PATH)), "pdfs")
|
||||||
|
|
||||||
|
|
||||||
|
def _pdf_dir() -> str:
|
||||||
|
os.makedirs(PDF_DIR, exist_ok=True)
|
||||||
|
return PDF_DIR
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/upload-pdf", status_code=status.HTTP_201_CREATED)
|
||||||
|
async def upload_pdf_source(
|
||||||
|
current_user: dict = Depends(get_current_user),
|
||||||
|
db: aiosqlite.Connection = Depends(db_dependency),
|
||||||
|
file: UploadFile = File(...),
|
||||||
|
name: Optional[str] = Form(None),
|
||||||
|
category: str = Form("sonstige"),
|
||||||
|
language: Optional[str] = Form(None),
|
||||||
|
notes: Optional[str] = Form(None),
|
||||||
|
):
|
||||||
|
"""PDF hochladen + als Kundenquelle (source_type=pdf_document) registrieren.
|
||||||
|
|
||||||
|
Idempotent ueber SHA256 innerhalb des Tenants: doppelter Upload erzeugt 409.
|
||||||
|
"""
|
||||||
|
head = await file.read(8)
|
||||||
|
if not head.startswith(b"%PDF-"):
|
||||||
|
raise HTTPException(status_code=415, detail="Datei ist kein gueltiges PDF")
|
||||||
|
|
||||||
|
tenant_id = current_user.get("tenant_id")
|
||||||
|
sha = hashlib.sha256()
|
||||||
|
sha.update(head)
|
||||||
|
total = len(head)
|
||||||
|
tmp_path = os.path.join(_pdf_dir(), f".upload-{uuid.uuid4().hex}.tmp")
|
||||||
|
try:
|
||||||
|
with open(tmp_path, "wb") as out:
|
||||||
|
out.write(head)
|
||||||
|
while True:
|
||||||
|
chunk = await file.read(1024 * 1024)
|
||||||
|
if not chunk:
|
||||||
|
break
|
||||||
|
total += len(chunk)
|
||||||
|
if total > MAX_PDF_SIZE_BYTES:
|
||||||
|
raise HTTPException(status_code=413, detail=f"PDF ueberschreitet {MAX_PDF_SIZE_BYTES // 1024 // 1024} MB")
|
||||||
|
sha.update(chunk)
|
||||||
|
out.write(chunk)
|
||||||
|
sha_hex = sha.hexdigest()
|
||||||
|
final_path = os.path.join(_pdf_dir(), f"{sha_hex}.pdf")
|
||||||
|
rel_path = os.path.join("pdfs", f"{sha_hex}.pdf")
|
||||||
|
|
||||||
|
# Duplikat-Pruefung innerhalb des Tenants (oder global, falls eine
|
||||||
|
# gleiche PDF bereits als Grundquelle existiert -> dann sichtbar fuer alle).
|
||||||
|
cursor = await db.execute(
|
||||||
|
"SELECT id, name, tenant_id FROM sources WHERE pdf_sha256 = ? "
|
||||||
|
"AND (tenant_id IS NULL OR tenant_id = ?)",
|
||||||
|
(sha_hex, tenant_id),
|
||||||
|
)
|
||||||
|
existing = await cursor.fetchone()
|
||||||
|
if existing:
|
||||||
|
os.unlink(tmp_path)
|
||||||
|
scope = "global" if existing["tenant_id"] is None else "Ihrer Organisation"
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=409,
|
||||||
|
detail=f"PDF bereits in {scope} vorhanden als Quelle '{existing['name']}' (id={existing['id']})",
|
||||||
|
)
|
||||||
|
|
||||||
|
if not os.path.exists(final_path):
|
||||||
|
os.replace(tmp_path, final_path)
|
||||||
|
else:
|
||||||
|
os.unlink(tmp_path)
|
||||||
|
except HTTPException:
|
||||||
|
if os.path.exists(tmp_path):
|
||||||
|
try: os.unlink(tmp_path)
|
||||||
|
except OSError: pass
|
||||||
|
raise
|
||||||
|
except Exception as e:
|
||||||
|
if os.path.exists(tmp_path):
|
||||||
|
try: os.unlink(tmp_path)
|
||||||
|
except OSError: pass
|
||||||
|
logger.exception("PDF-Upload (tenant) fehlgeschlagen")
|
||||||
|
raise HTTPException(status_code=500, detail=f"PDF-Upload fehlgeschlagen: {e}")
|
||||||
|
|
||||||
|
display_name = (name or "").strip() or re.sub(r"\.pdf$", "", file.filename or "PDF", flags=re.I)
|
||||||
|
display_name = display_name[:200]
|
||||||
|
|
||||||
|
cursor = await db.execute(
|
||||||
|
"""INSERT INTO sources
|
||||||
|
(name, url, domain, source_type, category, status, notes, language,
|
||||||
|
pdf_path, pdf_sha256, added_by, tenant_id)
|
||||||
|
VALUES (?, NULL, NULL, 'pdf_document', ?, 'active', ?, ?, ?, ?, ?, ?)""",
|
||||||
|
(display_name, category, notes, language, rel_path, sha_hex,
|
||||||
|
current_user["username"], tenant_id),
|
||||||
|
)
|
||||||
|
src_id = cursor.lastrowid
|
||||||
|
await db.commit()
|
||||||
|
|
||||||
|
cursor = await db.execute("SELECT * FROM sources WHERE id = ?", (src_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"))
|
||||||
|
result["alignments"] = []
|
||||||
|
return result
|
||||||
|
|||||||
@@ -1,12 +1,17 @@
|
|||||||
"""Organization-Settings-Helper.
|
"""Organization-Settings-Helper.
|
||||||
|
|
||||||
KV-Store pro Organisation. Aktuell genutzt fuer output_language ('de'|'en').
|
KV-Store pro Organisation. Aktuell genutzt fuer:
|
||||||
Spaeter erweiterbar (Default-Modell, Telegram-Toggle, Theme, ...).
|
- output_language ('de'|'en'|...) - Anzeige-/Lagebild-Sprache
|
||||||
|
- source_language_whitelist (JSON-Liste, z.B. ["ja"]) - schraenkt RSS/Telegram-Quellen ein
|
||||||
|
- research_language (ISO-Code) - steuert WebSearch-Prompts (default = output_language)
|
||||||
|
- translator_enabled ('true'|'false') - override fuer das globale TRANSLATOR_ENABLED-Flag
|
||||||
|
|
||||||
Cache: TTL 60s in-memory pro (tenant_id, key). Wird bei set_org_setting()
|
Cache: TTL 60s in-memory pro (tenant_id, key). Wird bei set_org_setting()
|
||||||
invalidiert.
|
invalidiert.
|
||||||
"""
|
"""
|
||||||
|
import json
|
||||||
import logging
|
import logging
|
||||||
|
import os
|
||||||
import time
|
import time
|
||||||
from typing import Optional
|
from typing import Optional
|
||||||
|
|
||||||
@@ -84,6 +89,15 @@ async def set_org_setting(
|
|||||||
LANGUAGE_DISPLAY_NAMES = {
|
LANGUAGE_DISPLAY_NAMES = {
|
||||||
"de": "Deutsch",
|
"de": "Deutsch",
|
||||||
"en": "English",
|
"en": "English",
|
||||||
|
"ja": "Japanese",
|
||||||
|
"zh": "Chinese",
|
||||||
|
"ko": "Korean",
|
||||||
|
"ru": "Russian",
|
||||||
|
"ar": "Arabic",
|
||||||
|
"fa": "Persian",
|
||||||
|
"he": "Hebrew",
|
||||||
|
"fr": "French",
|
||||||
|
"es": "Spanish",
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
@@ -91,7 +105,10 @@ async def get_org_language(
|
|||||||
db: aiosqlite.Connection,
|
db: aiosqlite.Connection,
|
||||||
tenant_id: int,
|
tenant_id: int,
|
||||||
) -> str:
|
) -> str:
|
||||||
"""Liefert ISO-2-Sprachcode der Org (default 'de')."""
|
"""Liefert ISO-2-Sprachcode der Org (default 'de').
|
||||||
|
|
||||||
|
Steuert die Lagebild-/Anzeige-Sprache.
|
||||||
|
"""
|
||||||
value = await get_org_setting(db, tenant_id, "output_language", default="de")
|
value = await get_org_setting(db, tenant_id, "output_language", default="de")
|
||||||
if value not in LANGUAGE_DISPLAY_NAMES:
|
if value not in LANGUAGE_DISPLAY_NAMES:
|
||||||
logger.warning("Unbekannte output_language '%s' fuer Org %s -- fallback 'de'", value, tenant_id)
|
logger.warning("Unbekannte output_language '%s' fuer Org %s -- fallback 'de'", value, tenant_id)
|
||||||
@@ -99,6 +116,65 @@ async def get_org_language(
|
|||||||
return value
|
return value
|
||||||
|
|
||||||
|
|
||||||
|
async def get_source_language_whitelist(
|
||||||
|
db: aiosqlite.Connection,
|
||||||
|
tenant_id: int,
|
||||||
|
) -> Optional[list[str]]:
|
||||||
|
"""Liefert Liste erlaubter Quellsprachen oder None (= keine Einschränkung).
|
||||||
|
|
||||||
|
Gespeichert als JSON-Array unter dem Key 'source_language_whitelist'.
|
||||||
|
Beispiel-Wert: '["ja"]' -> nur japanischsprachige Quellen.
|
||||||
|
"""
|
||||||
|
raw = await get_org_setting(db, tenant_id, "source_language_whitelist", default=None)
|
||||||
|
if not raw:
|
||||||
|
return None
|
||||||
|
try:
|
||||||
|
parsed = json.loads(raw)
|
||||||
|
except (json.JSONDecodeError, TypeError) as e:
|
||||||
|
logger.warning(
|
||||||
|
"source_language_whitelist fuer Org %s ist kein JSON ('%s'): %s",
|
||||||
|
tenant_id, raw, e,
|
||||||
|
)
|
||||||
|
return None
|
||||||
|
if not isinstance(parsed, list):
|
||||||
|
logger.warning("source_language_whitelist fuer Org %s ist keine Liste: %r", tenant_id, parsed)
|
||||||
|
return None
|
||||||
|
cleaned = [str(x).strip().lower() for x in parsed if str(x).strip()]
|
||||||
|
return cleaned or None
|
||||||
|
|
||||||
|
|
||||||
|
async def get_research_language(
|
||||||
|
db: aiosqlite.Connection,
|
||||||
|
tenant_id: int,
|
||||||
|
) -> str:
|
||||||
|
"""Liefert die Sprache, in der der WebSearch-Researcher primär sucht.
|
||||||
|
|
||||||
|
Default = output_language. Bei jp_demo z.B. 'ja', während output_language='de' bleibt.
|
||||||
|
"""
|
||||||
|
value = await get_org_setting(db, tenant_id, "research_language", default=None)
|
||||||
|
if value and value in LANGUAGE_DISPLAY_NAMES:
|
||||||
|
return value
|
||||||
|
return await get_org_language(db, tenant_id)
|
||||||
|
|
||||||
|
|
||||||
|
async def get_translator_enabled(
|
||||||
|
db: aiosqlite.Connection,
|
||||||
|
tenant_id: Optional[int],
|
||||||
|
) -> bool:
|
||||||
|
"""Liefert true wenn der (volle) Translator-Schritt fuer diese Org laufen soll.
|
||||||
|
|
||||||
|
Hierarchie:
|
||||||
|
1. Org-Setting 'translator_enabled' ('true'/'false') gewinnt, wenn gesetzt.
|
||||||
|
2. Sonst: globales ENV-Flag TRANSLATOR_ENABLED (Default true im config.py).
|
||||||
|
"""
|
||||||
|
if tenant_id is not None:
|
||||||
|
raw = await get_org_setting(db, tenant_id, "translator_enabled", default=None)
|
||||||
|
if raw is not None:
|
||||||
|
return str(raw).strip().lower() in ("true", "1", "yes", "on")
|
||||||
|
env_value = os.environ.get("TRANSLATOR_ENABLED", "true").strip().lower()
|
||||||
|
return env_value in ("true", "1", "yes", "on")
|
||||||
|
|
||||||
|
|
||||||
def language_display(lang_iso: str) -> str:
|
def language_display(lang_iso: str) -> str:
|
||||||
"""ISO-Code -> Anzeigename fuer Prompts ('de' -> 'Deutsch')."""
|
"""ISO-Code -> Anzeigename fuer Prompts ('de' -> 'Deutsch')."""
|
||||||
return LANGUAGE_DISPLAY_NAMES.get(lang_iso, lang_iso)
|
return LANGUAGE_DISPLAY_NAMES.get(lang_iso, lang_iso)
|
||||||
|
|||||||
237
src/services/pdf_ingest.py
Normale Datei
237
src/services/pdf_ingest.py
Normale Datei
@@ -0,0 +1,237 @@
|
|||||||
|
"""PDF-Ingest: liest hochgeladene PDFs ein und legt sie als Pool-Artikel ab.
|
||||||
|
|
||||||
|
Quellen vom Typ `pdf_document` werden in der Verwaltung angelegt
|
||||||
|
(`processed_at IS NULL`). Dieser Service pollt sie, extrahiert den Text,
|
||||||
|
uebersetzt nach DE+EN und schreibt EINEN Artikel (incident_id=NULL) in
|
||||||
|
`articles`. Idempotent ueber `processed_at`.
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
import json
|
||||||
|
import logging
|
||||||
|
import os
|
||||||
|
import re
|
||||||
|
from typing import Optional
|
||||||
|
|
||||||
|
import aiosqlite
|
||||||
|
|
||||||
|
from config import DB_PATH, CLAUDE_MODEL_FAST
|
||||||
|
from agents.claude_client import call_claude
|
||||||
|
|
||||||
|
logger = logging.getLogger("osint.pdf_ingest")
|
||||||
|
|
||||||
|
MAX_CHARS_PER_PDF = 200_000 # harte Obergrenze, schuetzt vor riesigen Dumps
|
||||||
|
TRANSLATE_INPUT_MAX = 12_000 # was wir dem LLM zum Uebersetzen geben (Cost-Control)
|
||||||
|
|
||||||
|
|
||||||
|
def _extract_text_pdfplumber(path: str) -> str:
|
||||||
|
import pdfplumber
|
||||||
|
parts: list[str] = []
|
||||||
|
with pdfplumber.open(path) as pdf:
|
||||||
|
for page in pdf.pages:
|
||||||
|
t = page.extract_text() or ""
|
||||||
|
if t:
|
||||||
|
parts.append(t)
|
||||||
|
return "\n\n".join(parts).strip()
|
||||||
|
|
||||||
|
|
||||||
|
def _extract_text_ocr(path: str) -> str:
|
||||||
|
"""Tesseract-Fallback ueber pdf2image -> Pillow -> pytesseract."""
|
||||||
|
from pdf2image import convert_from_path
|
||||||
|
import pytesseract
|
||||||
|
images = convert_from_path(path, dpi=200)
|
||||||
|
parts = []
|
||||||
|
for img in images:
|
||||||
|
# deu+eng zusammen, damit mehrsprachige PDFs gehen
|
||||||
|
t = pytesseract.image_to_string(img, lang="deu+eng")
|
||||||
|
if t and t.strip():
|
||||||
|
parts.append(t.strip())
|
||||||
|
return "\n\n".join(parts).strip()
|
||||||
|
|
||||||
|
|
||||||
|
def _extract_text(path: str) -> tuple[str, str]:
|
||||||
|
"""Gibt (text, method) zurueck. method: 'pdfplumber' oder 'ocr'."""
|
||||||
|
try:
|
||||||
|
text = _extract_text_pdfplumber(path)
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning("pdfplumber-Extraktion fehlgeschlagen fuer %s: %s", path, e)
|
||||||
|
text = ""
|
||||||
|
if len(text) >= 50:
|
||||||
|
return text[:MAX_CHARS_PER_PDF], "pdfplumber"
|
||||||
|
logger.info("PDF hat keinen Text-Layer (oder <50 Zeichen), versuche OCR: %s", path)
|
||||||
|
text = _extract_text_ocr(path)
|
||||||
|
return text[:MAX_CHARS_PER_PDF], "ocr"
|
||||||
|
|
||||||
|
|
||||||
|
def _derive_headline(text: str, fallback: str) -> str:
|
||||||
|
"""Erste sinnvolle Zeile als Headline; sonst Fallback (Dateiname)."""
|
||||||
|
for raw in text.splitlines():
|
||||||
|
line = raw.strip()
|
||||||
|
if 5 <= len(line) <= 200:
|
||||||
|
return line
|
||||||
|
return fallback.strip() or "Untitled PDF"
|
||||||
|
|
||||||
|
|
||||||
|
async def _translate(text: str, headline: str, target_lang: str) -> tuple[str, str]:
|
||||||
|
"""Uebersetzt Headline + Content nach target_lang ('de' oder 'en').
|
||||||
|
|
||||||
|
Eigene mini-Funktion (statt agents.translator), weil wir je PDF nur EIN
|
||||||
|
Item haben und Headline+Content getrennt brauchen. Returnt (headline_t, content_t).
|
||||||
|
Bei Fehler oder leerem Text: ('', '').
|
||||||
|
"""
|
||||||
|
if not text and not headline:
|
||||||
|
return "", ""
|
||||||
|
lang_label = {"de": "Deutsch", "en": "Englisch"}.get(target_lang, target_lang)
|
||||||
|
content_in = (text or "")[:TRANSLATE_INPUT_MAX]
|
||||||
|
prompt = f"""Du bist ein praeziser Uebersetzer fuer Sachtexte.
|
||||||
|
Uebersetze Headline und Inhalt nach {lang_label}.
|
||||||
|
|
||||||
|
WICHTIG:
|
||||||
|
- Verwende IMMER echte UTF-8-Umlaute (ae->ä, oe->ö, ue->ü, ss->ß) bei Deutsch.
|
||||||
|
- Behalte Eigennamen im Original.
|
||||||
|
- Wenn der Text schon auf {lang_label} ist, gib ihn (nahezu) unveraendert zurueck.
|
||||||
|
- Behalte die wichtigsten Inhalte; kuerze stark auf MAX 3000 Zeichen Content.
|
||||||
|
|
||||||
|
Antworte AUSSCHLIESSLICH mit einem JSON-Objekt im Format:
|
||||||
|
{{"headline": "...", "content": "..."}}
|
||||||
|
|
||||||
|
Keine Markdown-Codefence, keine Einleitung.
|
||||||
|
|
||||||
|
HEADLINE: {headline}
|
||||||
|
INHALT:
|
||||||
|
{content_in}
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
result_text, _usage = await call_claude(prompt, tools=None, model=CLAUDE_MODEL_FAST)
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning("PDF-Translator (%s) Claude-Call fehlgeschlagen: %s", target_lang, e)
|
||||||
|
return "", ""
|
||||||
|
|
||||||
|
raw = result_text.strip()
|
||||||
|
if raw.startswith("```"):
|
||||||
|
raw = re.sub(r"^```(?:json)?\s*", "", raw)
|
||||||
|
raw = re.sub(r"\s*```\s*$", "", raw).strip()
|
||||||
|
try:
|
||||||
|
data = json.loads(raw)
|
||||||
|
except json.JSONDecodeError:
|
||||||
|
m = re.search(r"\{.*\}", raw, re.DOTALL)
|
||||||
|
if not m:
|
||||||
|
logger.warning("PDF-Translator (%s) JSON nicht parsbar: %r", target_lang, raw[:200])
|
||||||
|
return "", ""
|
||||||
|
try:
|
||||||
|
data = json.loads(m.group(0))
|
||||||
|
except json.JSONDecodeError:
|
||||||
|
return "", ""
|
||||||
|
if not isinstance(data, dict):
|
||||||
|
return "", ""
|
||||||
|
return (data.get("headline") or "").strip(), (data.get("content") or "").strip()
|
||||||
|
|
||||||
|
|
||||||
|
async def _process_one(db: aiosqlite.Connection, src: dict) -> None:
|
||||||
|
sid = src["id"]
|
||||||
|
name = src["name"] or "PDF"
|
||||||
|
rel_path = src["pdf_path"]
|
||||||
|
if not rel_path:
|
||||||
|
logger.warning("PDF-Source #%d ohne pdf_path, ueberspringe", sid)
|
||||||
|
return
|
||||||
|
|
||||||
|
abs_path = rel_path if os.path.isabs(rel_path) else os.path.join(
|
||||||
|
os.path.dirname(DB_PATH), rel_path
|
||||||
|
)
|
||||||
|
if not os.path.exists(abs_path):
|
||||||
|
logger.error("PDF-Datei fehlt fuer Source #%d: %s", sid, abs_path)
|
||||||
|
# auf processed_at setzen aber Notiz hinterlegen, damit kein Endlos-Retry
|
||||||
|
await db.execute(
|
||||||
|
"UPDATE sources SET processed_at = CURRENT_TIMESTAMP, "
|
||||||
|
"notes = COALESCE(notes,'') || ' [PDF-Datei nicht gefunden]' WHERE id = ?",
|
||||||
|
(sid,),
|
||||||
|
)
|
||||||
|
await db.commit()
|
||||||
|
return
|
||||||
|
|
||||||
|
logger.info("PDF-Ingest start: source #%d (%s)", sid, abs_path)
|
||||||
|
|
||||||
|
try:
|
||||||
|
text, method = await asyncio.to_thread(_extract_text, abs_path)
|
||||||
|
except Exception as e:
|
||||||
|
logger.exception("PDF-Extraktion fehlgeschlagen fuer #%d: %s", sid, e)
|
||||||
|
await db.execute(
|
||||||
|
"UPDATE sources SET processed_at = CURRENT_TIMESTAMP, "
|
||||||
|
"notes = COALESCE(notes,'') || ' [PDF-Extraktion fehlgeschlagen]' WHERE id = ?",
|
||||||
|
(sid,),
|
||||||
|
)
|
||||||
|
await db.commit()
|
||||||
|
return
|
||||||
|
|
||||||
|
if not text:
|
||||||
|
logger.warning("PDF #%d ergab keinen Text (auch OCR leer)", sid)
|
||||||
|
await db.execute(
|
||||||
|
"UPDATE sources SET processed_at = CURRENT_TIMESTAMP, "
|
||||||
|
"notes = COALESCE(notes,'') || ' [PDF leer/nicht lesbar]' WHERE id = ?",
|
||||||
|
(sid,),
|
||||||
|
)
|
||||||
|
await db.commit()
|
||||||
|
return
|
||||||
|
|
||||||
|
fallback_name = re.sub(r"\.pdf$", "", os.path.basename(abs_path), flags=re.I)
|
||||||
|
headline = _derive_headline(text, fallback_name)
|
||||||
|
# Hochgeladene PDFs sind meist deutsch oder englisch; LLM kann das im Prompt erkennen
|
||||||
|
src_lang = (src.get("language") or "").lower() or "auto"
|
||||||
|
|
||||||
|
# Wir senden parallel DE + EN
|
||||||
|
(de_h, de_c), (en_h, en_c) = await asyncio.gather(
|
||||||
|
_translate(text, headline, "de"),
|
||||||
|
_translate(text, headline, "en"),
|
||||||
|
)
|
||||||
|
|
||||||
|
# Originaltext kappen, damit articles-Tabelle handhabbar bleibt
|
||||||
|
content_original = text[:5000]
|
||||||
|
|
||||||
|
await db.execute(
|
||||||
|
"""INSERT INTO articles (incident_id, headline, headline_de, headline_en,
|
||||||
|
source, source_url, content_original, content_de, content_en, language,
|
||||||
|
published_at, tenant_id, verification_status)
|
||||||
|
VALUES (NULL, ?, ?, ?, ?, ?, ?, ?, ?, ?, NULL, ?, 'unverified')""",
|
||||||
|
(
|
||||||
|
headline,
|
||||||
|
de_h or None,
|
||||||
|
en_h or None,
|
||||||
|
name,
|
||||||
|
f"pdf://{src.get('pdf_sha256') or sid}",
|
||||||
|
content_original,
|
||||||
|
de_c or None,
|
||||||
|
en_c or None,
|
||||||
|
src_lang if src_lang != "auto" else None,
|
||||||
|
src.get("tenant_id"),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
await db.execute(
|
||||||
|
"UPDATE sources SET processed_at = CURRENT_TIMESTAMP, article_count = article_count + 1, "
|
||||||
|
"last_seen_at = CURRENT_TIMESTAMP WHERE id = ?",
|
||||||
|
(sid,),
|
||||||
|
)
|
||||||
|
await db.commit()
|
||||||
|
logger.info("PDF-Ingest fertig: source #%d (%s, %d Zeichen)", sid, method, len(text))
|
||||||
|
|
||||||
|
|
||||||
|
async def run_once() -> int:
|
||||||
|
"""Verarbeitet alle pdf_document-Sources ohne processed_at. Returnt Anzahl.
|
||||||
|
|
||||||
|
Wird vom APScheduler als interval-Job aufgerufen. Pro Tick max 5 PDFs,
|
||||||
|
damit ein hochgeladener Stapel nicht einen einzelnen Lauf monopolisiert.
|
||||||
|
"""
|
||||||
|
async with aiosqlite.connect(DB_PATH) as db:
|
||||||
|
db.row_factory = aiosqlite.Row
|
||||||
|
cursor = await db.execute(
|
||||||
|
"SELECT id, name, pdf_path, pdf_sha256, language, tenant_id "
|
||||||
|
"FROM sources WHERE source_type = 'pdf_document' AND processed_at IS NULL "
|
||||||
|
"ORDER BY created_at ASC LIMIT 5"
|
||||||
|
)
|
||||||
|
rows = [dict(r) for r in await cursor.fetchall()]
|
||||||
|
for src in rows:
|
||||||
|
try:
|
||||||
|
await _process_one(db, src)
|
||||||
|
except Exception:
|
||||||
|
logger.exception("PDF-Ingest unerwarteter Fehler bei source #%d", src["id"])
|
||||||
|
return len(rows)
|
||||||
@@ -32,8 +32,12 @@ _PIPELINE_STEPS_DE = [
|
|||||||
"tooltip": "Aus den Meldungen werden Ortsangaben erkannt und auf der Karte verortet."},
|
"tooltip": "Aus den Meldungen werden Ortsangaben erkannt und auf der Karte verortet."},
|
||||||
{"key": "factcheck", "label": "Fakten prüfen", "icon": "shield",
|
{"key": "factcheck", "label": "Fakten prüfen", "icon": "shield",
|
||||||
"tooltip": "Behauptungen aus den Meldungen werden gegeneinander abgeglichen: Bestätigt? Umstritten? Noch unklar?"},
|
"tooltip": "Behauptungen aus den Meldungen werden gegeneinander abgeglichen: Bestätigt? Umstritten? Noch unklar?"},
|
||||||
|
{"key": "public_mood", "label": "Stimmung erfassen", "icon": "message-circle",
|
||||||
|
"tooltip": "Aus Foren-Quellen (z.B. 5ch, Hatena, Note) wird ein Stimmungsbild der öffentlichen Diskussion extrahiert. Keine Faktenlage, sondern dominante Themen und Bruchlinien."},
|
||||||
{"key": "summary", "label": "Lagebild verfassen", "icon": "file-text",
|
{"key": "summary", "label": "Lagebild verfassen", "icon": "file-text",
|
||||||
"tooltip": "Aus allen geprüften Meldungen wird ein zusammenhängendes Lagebild geschrieben, mit Quellenangaben am Text."},
|
"tooltip": "Aus allen geprüften Meldungen wird ein zusammenhängendes Lagebild geschrieben, mit Quellenangaben am Text."},
|
||||||
|
{"key": "translate", "label": "Artikel uebersetzen", "icon": "languages",
|
||||||
|
"tooltip": "Fremdsprachige Meldungen (z.B. japanisch) werden ins Lagebild-Output uebersetzt. Laeuft nur fuer Quellen-Pools mit nicht-deutschen Sprachen und kann bei vielen neuen Artikeln einige Minuten dauern."},
|
||||||
{"key": "qc", "label": "Qualitätscheck", "icon": "check-circle",
|
{"key": "qc", "label": "Qualitätscheck", "icon": "check-circle",
|
||||||
"tooltip": "Eine letzte Kontrollprüfung am Ergebnis: Doppelte Fakten zusammenführen, Karten-Verortung prüfen, bevor du benachrichtigt wirst."},
|
"tooltip": "Eine letzte Kontrollprüfung am Ergebnis: Doppelte Fakten zusammenführen, Karten-Verortung prüfen, bevor du benachrichtigt wirst."},
|
||||||
{"key": "notify", "label": "Benachrichtigen", "icon": "bell",
|
{"key": "notify", "label": "Benachrichtigen", "icon": "bell",
|
||||||
@@ -53,8 +57,12 @@ _PIPELINE_STEPS_EN = [
|
|||||||
"tooltip": "Locations are extracted from the articles and placed on the map."},
|
"tooltip": "Locations are extracted from the articles and placed on the map."},
|
||||||
{"key": "factcheck", "label": "Checking facts", "icon": "shield",
|
{"key": "factcheck", "label": "Checking facts", "icon": "shield",
|
||||||
"tooltip": "Claims from the articles are cross-checked: Confirmed? Disputed? Still unclear?"},
|
"tooltip": "Claims from the articles are cross-checked: Confirmed? Disputed? Still unclear?"},
|
||||||
|
{"key": "public_mood", "label": "Reading the mood", "icon": "message-circle",
|
||||||
|
"tooltip": "Forum sources (5ch, Hatena, Note, etc.) are summarised into a public-mood overview. Not factual, but dominant themes and fault lines."},
|
||||||
{"key": "summary", "label": "Writing the briefing", "icon": "file-text",
|
{"key": "summary", "label": "Writing the briefing", "icon": "file-text",
|
||||||
"tooltip": "All verified articles are combined into a coherent briefing with inline citations."},
|
"tooltip": "All verified articles are combined into a coherent briefing with inline citations."},
|
||||||
|
{"key": "translate", "label": "Translating articles", "icon": "languages",
|
||||||
|
"tooltip": "Foreign-language articles (e.g. Japanese) are translated into the briefing output language. Runs only when the source pool contains non-target-language items and can take several minutes for large incoming batches."},
|
||||||
{"key": "qc", "label": "Quality check", "icon": "check-circle",
|
{"key": "qc", "label": "Quality check", "icon": "check-circle",
|
||||||
"tooltip": "A final review: consolidate duplicate facts, verify map locations, before you get notified."},
|
"tooltip": "A final review: consolidate duplicate facts, verify map locations, before you get notified."},
|
||||||
{"key": "notify", "label": "Notifying", "icon": "bell",
|
{"key": "notify", "label": "Notifying", "icon": "bell",
|
||||||
|
|||||||
@@ -86,6 +86,9 @@ DOMAIN_CATEGORY_MAP = {
|
|||||||
"merkur.de": "regional",
|
"merkur.de": "regional",
|
||||||
# Telegram
|
# Telegram
|
||||||
"t.me": "telegram",
|
"t.me": "telegram",
|
||||||
|
# X / Twitter
|
||||||
|
"x.com": "x",
|
||||||
|
"twitter.com": "x",
|
||||||
}
|
}
|
||||||
|
|
||||||
# Bekannte Feed-Pfade zum Durchprobieren
|
# Bekannte Feed-Pfade zum Durchprobieren
|
||||||
@@ -642,25 +645,46 @@ async def get_feeds_with_metadata(tenant_id: int = None, source_type: str = "rss
|
|||||||
|
|
||||||
source_type: "rss_feed" (Default) oder "podcast_feed" — trennt RSS- und Podcast-Quellen
|
source_type: "rss_feed" (Default) oder "podcast_feed" — trennt RSS- und Podcast-Quellen
|
||||||
in getrennten Pipelines, damit der RSS-Heisspfad unveraendert bleibt.
|
in getrennten Pipelines, damit der RSS-Heisspfad unveraendert bleibt.
|
||||||
|
|
||||||
|
Wenn die Org eine source_language_whitelist gesetzt hat (z.B. jp_demo: ['ja']),
|
||||||
|
werden nur Feeds geliefert, deren primary_language darauf passt. Feeds ohne
|
||||||
|
gesetztes primary_language fallen in dem Fall raus — das ist gewollt, weil
|
||||||
|
eine Whitelist gerade die strenge Beschraenkung ist.
|
||||||
"""
|
"""
|
||||||
from database import get_db
|
from database import get_db
|
||||||
|
from services.org_settings import get_source_language_whitelist
|
||||||
|
|
||||||
db = await get_db()
|
db = await get_db()
|
||||||
try:
|
try:
|
||||||
if tenant_id:
|
if tenant_id:
|
||||||
cursor = await db.execute(
|
cursor = await db.execute(
|
||||||
"SELECT name, url, domain, category, notes, COALESCE(article_count, 0) AS article_count FROM sources "
|
"SELECT name, url, domain, category, notes, primary_language, media_type, "
|
||||||
|
"COALESCE(article_count, 0) AS article_count FROM sources "
|
||||||
"WHERE source_type = ? AND status = 'active' "
|
"WHERE source_type = ? AND status = 'active' "
|
||||||
"AND (tenant_id IS NULL OR tenant_id = ?)",
|
"AND (tenant_id IS NULL OR tenant_id = ?)",
|
||||||
(source_type, tenant_id),
|
(source_type, tenant_id),
|
||||||
)
|
)
|
||||||
else:
|
else:
|
||||||
cursor = await db.execute(
|
cursor = await db.execute(
|
||||||
"SELECT name, url, domain, category, notes, COALESCE(article_count, 0) AS article_count FROM sources "
|
"SELECT name, url, domain, category, notes, primary_language, media_type, "
|
||||||
|
"COALESCE(article_count, 0) AS article_count FROM sources "
|
||||||
"WHERE source_type = ? AND status = 'active'",
|
"WHERE source_type = ? AND status = 'active'",
|
||||||
(source_type,),
|
(source_type,),
|
||||||
)
|
)
|
||||||
return [dict(row) for row in await cursor.fetchall()]
|
feeds = [dict(row) for row in await cursor.fetchall()]
|
||||||
|
|
||||||
|
# Whitelist-Filter (nur wenn die Org eine gesetzt hat)
|
||||||
|
if tenant_id:
|
||||||
|
whitelist = await get_source_language_whitelist(db, tenant_id)
|
||||||
|
if whitelist:
|
||||||
|
before = len(feeds)
|
||||||
|
feeds = [f for f in feeds if (f.get("primary_language") or "").lower() in whitelist]
|
||||||
|
logger.info(
|
||||||
|
"source_language_whitelist=%s fuer Org %s: %d/%d Feeds passieren",
|
||||||
|
whitelist, tenant_id, len(feeds), before,
|
||||||
|
)
|
||||||
|
|
||||||
|
return feeds
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"Fehler beim Laden der Feed-Metadaten ({source_type}): {e}")
|
logger.error(f"Fehler beim Laden der Feed-Metadaten ({source_type}): {e}")
|
||||||
return []
|
return []
|
||||||
|
|||||||
@@ -1715,6 +1715,39 @@ a.dev-source-pill:hover {
|
|||||||
color: var(--text-primary);
|
color: var(--text-primary);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.source-type-filter-chips {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: var(--sp-xs);
|
||||||
|
margin: var(--sp-sm) 0 var(--sp-xs);
|
||||||
|
}
|
||||||
|
|
||||||
|
.source-type-filter-chip {
|
||||||
|
font: inherit;
|
||||||
|
font-size: 11px;
|
||||||
|
padding: 3px 10px;
|
||||||
|
border-radius: var(--radius);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
background: var(--bg-secondary);
|
||||||
|
color: var(--text-secondary);
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.source-type-filter-chip:hover {
|
||||||
|
border-color: var(--accent);
|
||||||
|
color: var(--text-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.source-type-filter-chip.active {
|
||||||
|
background: var(--accent);
|
||||||
|
border-color: var(--accent);
|
||||||
|
color: #fff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.source-type-filter-chip.active strong {
|
||||||
|
color: #fff;
|
||||||
|
}
|
||||||
|
|
||||||
.source-overview-grid {
|
.source-overview-grid {
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: repeat(auto-fill, minmax(180px, 1fr));
|
grid-template-columns: repeat(auto-fill, minmax(180px, 1fr));
|
||||||
|
|||||||
@@ -13,7 +13,7 @@
|
|||||||
<link rel="stylesheet" href="/static/vendor/leaflet.css">
|
<link rel="stylesheet" href="/static/vendor/leaflet.css">
|
||||||
<link rel="stylesheet" href="/static/vendor/MarkerCluster.css">
|
<link rel="stylesheet" href="/static/vendor/MarkerCluster.css">
|
||||||
<link rel="stylesheet" href="/static/vendor/MarkerCluster.Default.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=20260522c">
|
||||||
<style>
|
<style>
|
||||||
/* Export Modal Radio */
|
/* 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; }
|
.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; }
|
||||||
@@ -209,6 +209,7 @@
|
|||||||
<button class="tab-btn" data-tab="timeline" data-i18n="tab.timeline">Ereignis-Timeline</button>
|
<button class="tab-btn" data-tab="timeline" data-i18n="tab.timeline">Ereignis-Timeline</button>
|
||||||
<button class="tab-btn" data-tab="karte" data-i18n="tab.map">Geografische Verteilung</button>
|
<button class="tab-btn" data-tab="karte" data-i18n="tab.map">Geografische Verteilung</button>
|
||||||
<button class="tab-btn" data-tab="faktencheck" data-i18n="tab.factcheck">Faktencheck</button>
|
<button class="tab-btn" data-tab="faktencheck" data-i18n="tab.factcheck">Faktencheck</button>
|
||||||
|
<button class="tab-btn" data-tab="stimmung" data-i18n="tab.public_mood" id="tab-btn-stimmung" style="display:none;">Öffentliche Stimmung</button>
|
||||||
<button class="tab-btn" data-tab="pipeline" data-i18n="tab.pipeline">Analysepipeline</button>
|
<button class="tab-btn" data-tab="pipeline" data-i18n="tab.pipeline">Analysepipeline</button>
|
||||||
<button class="tab-btn" data-tab="quellen" data-i18n="tab.sources_overview">Quellenübersicht</button>
|
<button class="tab-btn" data-tab="quellen" data-i18n="tab.sources_overview">Quellenübersicht</button>
|
||||||
</div>
|
</div>
|
||||||
@@ -293,6 +294,24 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div class="tab-panel" id="panel-stimmung">
|
||||||
|
<div class="card incident-analysis-stimmung" id="stimmung-card">
|
||||||
|
<div class="card-header">
|
||||||
|
<div class="card-title">
|
||||||
|
<span data-i18n="card.public_mood">Öffentliche Stimmung</span>
|
||||||
|
<span class="info-icon" data-tooltip="Themen und Bruchlinien aus Foren-Quellen (z.B. 5ch, Hatena, Note). KEINE Faktenlage - reines Stimmungsmaterial. Beitraege sind anonym und koennen Trolling enthalten."><svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="10"/><path d="M12 16v-4"/><path d="M12 8h.01"/></svg></span>
|
||||||
|
</div>
|
||||||
|
<span class="stimmung-timestamp" id="stimmung-timestamp"></span>
|
||||||
|
</div>
|
||||||
|
<div id="stimmung-content">
|
||||||
|
<div id="stimmung-text" class="summary-text" style="padding:8px 16px;"></div>
|
||||||
|
<div style="padding:0 16px 16px; font-size:11px; color:var(--text-disabled); border-top:1px solid var(--border); margin-top:8px; padding-top:8px;">
|
||||||
|
Hinweis: Forenbeiträge sind anonyme Online-Stimmungen, keine Faktenlage. Sie fließen nicht in den Faktencheck ein.
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div class="tab-panel" id="panel-pipeline">
|
<div class="tab-panel" id="panel-pipeline">
|
||||||
<div class="card pipeline-card" id="pipeline-card">
|
<div class="card pipeline-card" id="pipeline-card">
|
||||||
<div class="card-header">
|
<div class="card-header">
|
||||||
@@ -333,6 +352,16 @@
|
|||||||
</div>
|
</div>
|
||||||
<form id="new-incident-form">
|
<form id="new-incident-form">
|
||||||
<div class="modal-body">
|
<div class="modal-body">
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="inc-type" data-i18n="modal.field.type">Art der Lage</label>
|
||||||
|
<select id="inc-type" onchange="toggleTypeDefaults()">
|
||||||
|
<option value="adhoc" data-i18n="modal.option.type_adhoc">Live-Monitoring : Ereignis beobachten</option>
|
||||||
|
<option value="research" data-i18n="modal.option.type_research">Recherche : Thema analysieren</option>
|
||||||
|
</select>
|
||||||
|
<div class="form-hint" id="type-hint" data-i18n="modal.hint.type_adhoc">
|
||||||
|
Durchsucht laufend hunderte Nachrichtenquellen nach neuen Meldungen. Empfohlen: Automatische Aktualisierung.
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
<div class="form-group">
|
<div class="form-group">
|
||||||
<label for="inc-title" data-i18n="modal.new_incident.title_field">Titel des Vorfalls</label>
|
<label for="inc-title" data-i18n="modal.new_incident.title_field">Titel des Vorfalls</label>
|
||||||
<input type="text" id="inc-title" required aria-required="true" placeholder="z.B. Explosion in Madrid" data-i18n-attr="placeholder:modal.placeholder.title">
|
<input type="text" id="inc-title" required aria-required="true" placeholder="z.B. Explosion in Madrid" data-i18n-attr="placeholder:modal.placeholder.title">
|
||||||
@@ -348,16 +377,6 @@
|
|||||||
<textarea id="inc-description" placeholder="Weitere Details zum Vorfall (optional)" data-i18n-attr="placeholder:modal.placeholder.description"></textarea>
|
<textarea id="inc-description" placeholder="Weitere Details zum Vorfall (optional)" data-i18n-attr="placeholder:modal.placeholder.description"></textarea>
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
<div class="form-group">
|
|
||||||
<label for="inc-type" data-i18n="modal.field.type">Art der Lage</label>
|
|
||||||
<select id="inc-type" onchange="toggleTypeDefaults()">
|
|
||||||
<option value="adhoc" data-i18n="modal.option.type_adhoc">Live-Monitoring : Ereignis beobachten</option>
|
|
||||||
<option value="research" data-i18n="modal.option.type_research">Recherche : Thema analysieren</option>
|
|
||||||
</select>
|
|
||||||
<div class="form-hint" id="type-hint" data-i18n="modal.hint.type_adhoc">
|
|
||||||
Durchsucht laufend hunderte Nachrichtenquellen nach neuen Meldungen. Empfohlen: Automatische Aktualisierung.
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="form-group">
|
<div class="form-group">
|
||||||
<label data-i18n="modal.field.sources">Quellen</label>
|
<label data-i18n="modal.field.sources">Quellen</label>
|
||||||
<div class="toggle-group">
|
<div class="toggle-group">
|
||||||
@@ -373,6 +392,13 @@
|
|||||||
<span class="toggle-switch"></span>
|
<span class="toggle-switch"></span>
|
||||||
<span class="toggle-text"><span data-i18n="modal.toggle.telegram">Telegram-Kanäle einbeziehen</span> <span class="info-icon tooltip-below" data-tooltip="Bezieht OSINT-relevante Telegram-Kanäle als zusätzliche Quelle ein. Kann die Aktualität erhöhen, aber auch unbestätigte Informationen liefern."><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"><span data-i18n="modal.toggle.telegram">Telegram-Kanäle einbeziehen</span> <span class="info-icon tooltip-below" data-tooltip="Bezieht OSINT-relevante Telegram-Kanäle als zusätzliche Quelle ein. Kann die Aktualität erhöhen, aber auch unbestätigte Informationen liefern."><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>
|
</label>
|
||||||
|
</div>
|
||||||
|
<div class="toggle-group" style="margin-top: 8px;">
|
||||||
|
<label class="toggle-label">
|
||||||
|
<input type="checkbox" id="inc-x">
|
||||||
|
<span class="toggle-switch"></span>
|
||||||
|
<span class="toggle-text"><span data-i18n="modal.toggle.x">X (Twitter) einbeziehen</span> <span class="info-icon tooltip-below" data-tooltip="Bezieht Posts konfigurierter X-Accounts (Twitter) als zusätzliche Quelle ein. Kann die Aktualität erhöhen, aber auch unbestätigte Informationen liefern."><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>
|
</div> </div>
|
||||||
<div class="form-group">
|
<div class="form-group">
|
||||||
<label><span data-i18n="modal.new_incident.visibility">Sichtbarkeit</span> <span class="info-icon tooltip-below" data-tooltip="Öffentlich: Alle Nutzer der Organisation sehen diese Lage. Privat: Nur für dich sichtbar."><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></label>
|
<label><span data-i18n="modal.new_incident.visibility">Sichtbarkeit</span> <span class="info-icon tooltip-below" data-tooltip="Öffentlich: Alle Nutzer der Organisation sehen diese Lage. Privat: Nur für dich sichtbar."><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></label>
|
||||||
@@ -394,7 +420,7 @@
|
|||||||
<div class="form-group conditional-field" id="refresh-interval-field">
|
<div class="form-group conditional-field" id="refresh-interval-field">
|
||||||
<label for="inc-refresh-value" data-i18n="modal.field.interval">Intervall</label>
|
<label for="inc-refresh-value" data-i18n="modal.field.interval">Intervall</label>
|
||||||
<div class="interval-input-group">
|
<div class="interval-input-group">
|
||||||
<input type="number" id="inc-refresh-value" min="10" value="15">
|
<input type="number" id="inc-refresh-value" min="30" value="30">
|
||||||
<select id="inc-refresh-unit" onchange="updateIntervalMin()">
|
<select id="inc-refresh-unit" onchange="updateIntervalMin()">
|
||||||
<option value="1" selected data-i18n="modal.unit.minutes">Minuten</option>
|
<option value="1" selected data-i18n="modal.unit.minutes">Minuten</option>
|
||||||
<option value="60" data-i18n="modal.unit.hours">Stunden</option>
|
<option value="60" data-i18n="modal.unit.hours">Stunden</option>
|
||||||
@@ -402,6 +428,7 @@
|
|||||||
<option value="10080" data-i18n="modal.unit.weeks">Wochen</option>
|
<option value="10080" data-i18n="modal.unit.weeks">Wochen</option>
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
|
<div class="form-hint" id="interval-min-hint" style="display:none;"></div>
|
||||||
</div>
|
</div>
|
||||||
<div class="form-group conditional-field" id="refresh-starttime-field">
|
<div class="form-group conditional-field" id="refresh-starttime-field">
|
||||||
<label for="inc-refresh-starttime"><span data-i18n="modal.field.start_time">Erste Aktualisierung um</span> <span class="info-icon tooltip-below" data-tooltip="Legt den Startzeitpunkt fest. Danach wird im eingestellten Intervall aktualisiert."><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></label>
|
<label for="inc-refresh-starttime"><span data-i18n="modal.field.start_time">Erste Aktualisierung um</span> <span class="info-icon tooltip-below" data-tooltip="Legt den Startzeitpunkt fest. Danach wird im eingestellten Intervall aktualisiert."><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></label>
|
||||||
@@ -465,6 +492,8 @@
|
|||||||
<option value="rss_feed">RSS-Feed</option>
|
<option value="rss_feed">RSS-Feed</option>
|
||||||
<option value="web_source">Web-Quelle</option>
|
<option value="web_source">Web-Quelle</option>
|
||||||
<option value="telegram_channel">Telegram</option>
|
<option value="telegram_channel">Telegram</option>
|
||||||
|
<option value="x_account">X (Twitter)</option>
|
||||||
|
<option value="podcast_feed">Podcast</option>
|
||||||
<option value="excluded">Von mir ausgeschlossen</option>
|
<option value="excluded">Von mir ausgeschlossen</option>
|
||||||
</select>
|
</select>
|
||||||
<label for="sources-filter-category" class="sr-only" data-i18n="sources_modal.filter.category">Kategorie filtern</label>
|
<label for="sources-filter-category" class="sr-only" data-i18n="sources_modal.filter.category">Kategorie filtern</label>
|
||||||
@@ -555,6 +584,7 @@
|
|||||||
<input type="text" id="sources-search" class="timeline-filter-input sources-search-input" placeholder="Suche..." oninput="App.filterSources()" data-i18n-attr="placeholder:sources_modal.search_placeholder">
|
<input type="text" id="sources-search" class="timeline-filter-input sources-search-input" placeholder="Suche..." oninput="App.filterSources()" data-i18n-attr="placeholder:sources_modal.search_placeholder">
|
||||||
</div>
|
</div>
|
||||||
<div class="sources-toolbar-actions">
|
<div class="sources-toolbar-actions">
|
||||||
|
<button class="btn btn-secondary btn-small" onclick="App.openPdfUpload()" style="margin-right:8px;">+ PDF hochladen</button>
|
||||||
<button class="btn btn-primary btn-small" onclick="App.toggleSourceForm()" data-i18n="sources_modal.add_source">+ Quelle</button>
|
<button class="btn btn-primary btn-small" onclick="App.toggleSourceForm()" data-i18n="sources_modal.add_source">+ Quelle</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -603,6 +633,7 @@
|
|||||||
<option value="rss_feed">RSS-Feed</option>
|
<option value="rss_feed">RSS-Feed</option>
|
||||||
<option value="web_source">Web-Quelle</option>
|
<option value="web_source">Web-Quelle</option>
|
||||||
<option value="telegram_channel">Telegram-Kanal</option>
|
<option value="telegram_channel">Telegram-Kanal</option>
|
||||||
|
<option value="x_account">X-Account</option>
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
<div class="form-group" id="src-rss-url-group">
|
<div class="form-group" id="src-rss-url-group">
|
||||||
@@ -633,6 +664,57 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- Modal: PDF als Quelle hochladen -->
|
||||||
|
<div class="modal-overlay" id="modal-pdf-upload" role="dialog" aria-modal="true" aria-labelledby="modal-pdf-upload-title">
|
||||||
|
<div class="modal">
|
||||||
|
<div class="modal-header">
|
||||||
|
<div class="modal-title" id="modal-pdf-upload-title">PDF als Quelle hochladen</div>
|
||||||
|
<button class="modal-close" onclick="closeModal('modal-pdf-upload')" aria-label="Schliessen">×</button>
|
||||||
|
</div>
|
||||||
|
<form id="pdf-upload-form" enctype="multipart/form-data">
|
||||||
|
<div class="modal-body">
|
||||||
|
<p class="text-secondary" style="margin-top:0;">
|
||||||
|
Die PDF wird gespeichert und im Hintergrund verarbeitet: Text wird extrahiert (OCR-Fallback fuer gescannte Dokumente) und nach Deutsch und Englisch uebersetzt. Sie erscheint danach in Ihrer Quellenliste.
|
||||||
|
</p>
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="pdf-upload-file">PDF-Datei (max. 50 MB)</label>
|
||||||
|
<input type="file" id="pdf-upload-file" accept="application/pdf,.pdf" required>
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="pdf-upload-name">Anzeige-Name (optional)</label>
|
||||||
|
<input type="text" id="pdf-upload-name" maxlength="200" placeholder="leer = Dateiname">
|
||||||
|
</div>
|
||||||
|
<div style="display:grid;grid-template-columns:1fr 1fr;gap:12px;">
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="pdf-upload-category">Kategorie</label>
|
||||||
|
<select id="pdf-upload-category">
|
||||||
|
<option value="sonstige" selected>Sonstige</option>
|
||||||
|
<option value="behoerde">Behoerde</option>
|
||||||
|
<option value="think-tank">Think-Tank</option>
|
||||||
|
<option value="fachmedien">Fachmedien</option>
|
||||||
|
<option value="international">International</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="pdf-upload-language">Sprache (optional)</label>
|
||||||
|
<input type="text" id="pdf-upload-language" placeholder="z.B. Deutsch, Englisch">
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="pdf-upload-notes">Notizen</label>
|
||||||
|
<input type="text" id="pdf-upload-notes" placeholder="Optional">
|
||||||
|
</div>
|
||||||
|
<div id="pdf-upload-error" class="error-msg" style="display:none"></div>
|
||||||
|
<div id="pdf-upload-progress" class="text-secondary" style="display:none;margin-top:8px;">Laedt hoch …</div>
|
||||||
|
</div>
|
||||||
|
<div class="modal-footer">
|
||||||
|
<button type="button" class="btn btn-secondary" onclick="closeModal('modal-pdf-upload')">Abbrechen</button>
|
||||||
|
<button type="submit" class="btn btn-primary" id="pdf-upload-submit">Hochladen</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<!-- Modal: Content-Viewer (wiederverwendbar für Lagebild, Faktencheck, Quellenübersicht, Timeline) -->
|
<!-- Modal: Content-Viewer (wiederverwendbar für Lagebild, Faktencheck, Quellenübersicht, Timeline) -->
|
||||||
<div class="modal-overlay" id="modal-content-viewer" role="dialog" aria-modal="true" aria-labelledby="content-viewer-title">
|
<div class="modal-overlay" id="modal-content-viewer" role="dialog" aria-modal="true" aria-labelledby="content-viewer-title">
|
||||||
<div class="modal modal-content-viewer">
|
<div class="modal modal-content-viewer">
|
||||||
@@ -724,12 +806,12 @@
|
|||||||
<script src="/static/vendor/leaflet.js"></script>
|
<script src="/static/vendor/leaflet.js"></script>
|
||||||
<script src="/static/vendor/leaflet.markercluster.js"></script>
|
<script src="/static/vendor/leaflet.markercluster.js"></script>
|
||||||
<script src="/static/js/i18n.js?v=20260513a"></script>
|
<script src="/static/js/i18n.js?v=20260513a"></script>
|
||||||
<script src="/static/js/api.js?v=20260423a"></script>
|
<script src="/static/js/api.js?v=20260522f"></script>
|
||||||
<script src="/static/js/ws.js?v=20260316b"></script>
|
<script src="/static/js/ws.js?v=20260316b"></script>
|
||||||
<script src="/static/js/components.js?v=20260514e"></script>
|
<script src="/static/js/components.js?v=20260522d"></script>
|
||||||
<script src="/static/js/layout.js?v=20260513f"></script>
|
<script src="/static/js/layout.js?v=20260513f"></script>
|
||||||
<script src="/static/js/pipeline.js?v=20260513d"></script>
|
<script src="/static/js/pipeline.js?v=20260513d"></script>
|
||||||
<script src="/static/js/app.js?v=20260514e"></script>
|
<script src="/static/js/app.js?v=20260522f"></script>
|
||||||
<script src="/static/js/cluster-data.js?v=20260322f"></script>
|
<script src="/static/js/cluster-data.js?v=20260322f"></script>
|
||||||
<script src="/static/js/tutorial.js?v=20260316z"></script>
|
<script src="/static/js/tutorial.js?v=20260316z"></script>
|
||||||
<script src="/static/js/chat.js?v=20260514e"></script>
|
<script src="/static/js/chat.js?v=20260514e"></script>
|
||||||
@@ -769,6 +851,16 @@
|
|||||||
<label class="export-radio"><input type="radio" name="export-format" value="pdf" checked><span>PDF</span></label>
|
<label class="export-radio"><input type="radio" name="export-format" value="pdf" checked><span>PDF</span></label>
|
||||||
<label class="export-radio"><input type="radio" name="export-format" value="docx"><span data-i18n="export.format.docx">Word (DOCX)</span></label>
|
<label class="export-radio"><input type="radio" name="export-format" value="docx"><span data-i18n="export.format.docx">Word (DOCX)</span></label>
|
||||||
</div>
|
</div>
|
||||||
|
<div style="margin-bottom:16px;">
|
||||||
|
<label style="font-size:11px;text-transform:uppercase;letter-spacing:1px;color:var(--text-secondary);display:block;margin-bottom:8px;" data-i18n="export.branding">Branding</label>
|
||||||
|
<label class="export-radio"><input type="radio" name="export-branding" value="on" checked><span data-i18n="export.branding.on">Mit AegisSight-Branding</span></label>
|
||||||
|
<label class="export-radio"><input type="radio" name="export-branding" value="off"><span data-i18n="export.branding.off">Ohne Firmen-Branding</span></label>
|
||||||
|
</div>
|
||||||
|
<div style="margin-bottom:0;">
|
||||||
|
<label for="export-ersteller" style="font-size:11px;text-transform:uppercase;letter-spacing:1px;color:var(--text-secondary);display:block;margin-bottom:8px;">Ersteller</label>
|
||||||
|
<input type="text" id="export-ersteller" maxlength="120" placeholder="Name des Erstellers (optional)" style="width:100%;box-sizing:border-box;">
|
||||||
|
<div style="font-size:11px;color:var(--text-secondary);margin-top:6px;">Leer lassen, dann wird automatisch der Lage-Ersteller verwendet.</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="modal-footer" style="padding:12px 20px;display:flex;justify-content:flex-end;gap:8px;border-top:1px solid var(--border);">
|
<div class="modal-footer" style="padding:12px 20px;display:flex;justify-content:flex-end;gap:8px;border-top:1px solid var(--border);">
|
||||||
<button class="btn btn-secondary" onclick="closeModal('modal-export')" data-i18n="common.cancel">Abbrechen</button>
|
<button class="btn btn-secondary" onclick="closeModal('modal-export')" data-i18n="common.cancel">Abbrechen</button>
|
||||||
|
|||||||
@@ -210,6 +210,9 @@
|
|||||||
"export.format": "Format",
|
"export.format": "Format",
|
||||||
"export.format.pdf": "PDF",
|
"export.format.pdf": "PDF",
|
||||||
"export.format.docx": "Word (DOCX)",
|
"export.format.docx": "Word (DOCX)",
|
||||||
|
"export.branding": "Branding",
|
||||||
|
"export.branding.on": "Mit AegisSight-Branding",
|
||||||
|
"export.branding.off": "Ohne Firmen-Branding",
|
||||||
"export.submit": "Exportieren",
|
"export.submit": "Exportieren",
|
||||||
"sources_modal.title": "Quellenverwaltung",
|
"sources_modal.title": "Quellenverwaltung",
|
||||||
"sources_modal.stats.rss": "RSS-Feeds",
|
"sources_modal.stats.rss": "RSS-Feeds",
|
||||||
|
|||||||
@@ -210,6 +210,9 @@
|
|||||||
"export.format": "Format",
|
"export.format": "Format",
|
||||||
"export.format.pdf": "PDF",
|
"export.format.pdf": "PDF",
|
||||||
"export.format.docx": "Word (DOCX)",
|
"export.format.docx": "Word (DOCX)",
|
||||||
|
"export.branding": "Branding",
|
||||||
|
"export.branding.on": "With AegisSight branding",
|
||||||
|
"export.branding.off": "Without company branding",
|
||||||
"export.submit": "Export",
|
"export.submit": "Export",
|
||||||
"sources_modal.title": "Source management",
|
"sources_modal.title": "Source management",
|
||||||
"sources_modal.stats.rss": "RSS feeds",
|
"sources_modal.stats.rss": "RSS feeds",
|
||||||
|
|||||||
@@ -22,6 +22,31 @@ const API = {
|
|||||||
};
|
};
|
||||||
},
|
},
|
||||||
|
|
||||||
|
async upload(path, formData) {
|
||||||
|
const token = localStorage.getItem("osint_token");
|
||||||
|
const headers = {};
|
||||||
|
if (token) headers["Authorization"] = `Bearer ${token}`;
|
||||||
|
const response = await fetch(`${this.baseUrl}${path}`, {
|
||||||
|
method: "POST",
|
||||||
|
headers,
|
||||||
|
body: formData,
|
||||||
|
});
|
||||||
|
if (response.status === 401) {
|
||||||
|
localStorage.removeItem("osint_token");
|
||||||
|
localStorage.removeItem("osint_username");
|
||||||
|
window.location.href = "/";
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!response.ok) {
|
||||||
|
const data = await response.json().catch(() => ({}));
|
||||||
|
let d = data.detail;
|
||||||
|
if (Array.isArray(d)) d = d.map(e => e.msg || JSON.stringify(e)).join("; ");
|
||||||
|
else if (typeof d === "object" && d !== null) d = JSON.stringify(d);
|
||||||
|
throw new Error(d || `Fehler ${response.status}`);
|
||||||
|
}
|
||||||
|
return response.json();
|
||||||
|
},
|
||||||
|
|
||||||
async _request(method, path, body = null, externalSignal = null) {
|
async _request(method, path, body = null, externalSignal = null) {
|
||||||
const controller = new AbortController();
|
const controller = new AbortController();
|
||||||
const timeout = setTimeout(() => controller.abort(), 30000);
|
const timeout = setTimeout(() => controller.abort(), 30000);
|
||||||
@@ -305,7 +330,7 @@ const API = {
|
|||||||
resetTutorialState() {
|
resetTutorialState() {
|
||||||
return this._request('DELETE', '/tutorial/state');
|
return this._request('DELETE', '/tutorial/state');
|
||||||
},
|
},
|
||||||
exportReport(id, format, scope, sections) {
|
exportReport(id, format, scope, sections, includeBranding, creator) {
|
||||||
const token = localStorage.getItem('osint_token');
|
const token = localStorage.getItem('osint_token');
|
||||||
let url = `${this.baseUrl}/incidents/${id}/export?format=${format}`;
|
let url = `${this.baseUrl}/incidents/${id}/export?format=${format}`;
|
||||||
if (sections && sections.length > 0) {
|
if (sections && sections.length > 0) {
|
||||||
@@ -313,6 +338,12 @@ const API = {
|
|||||||
} else if (scope) {
|
} else if (scope) {
|
||||||
url += `&scope=${scope}`;
|
url += `&scope=${scope}`;
|
||||||
}
|
}
|
||||||
|
if (includeBranding === false) {
|
||||||
|
url += `&branding=off`;
|
||||||
|
}
|
||||||
|
if (creator) {
|
||||||
|
url += `&creator=${encodeURIComponent(creator)}`;
|
||||||
|
}
|
||||||
return fetch(url, {
|
return fetch(url, {
|
||||||
headers: { 'Authorization': `Bearer ${token}` },
|
headers: { 'Authorization': `Bearer ${token}` },
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -578,8 +578,16 @@ const App = {
|
|||||||
// Telegram-Kategorien Toggle
|
// Telegram-Kategorien Toggle
|
||||||
const tgCheckbox = document.getElementById('inc-telegram');
|
const tgCheckbox = document.getElementById('inc-telegram');
|
||||||
if (tgCheckbox) {
|
if (tgCheckbox) {
|
||||||
|
tgCheckbox.addEventListener('change', () => updateIntervalMin());
|
||||||
}
|
}
|
||||||
|
{ const xCheckbox = document.getElementById('inc-x');
|
||||||
|
if (xCheckbox) xCheckbox.addEventListener('change', () => updateIntervalMin()); }
|
||||||
|
{ const ivInput = document.getElementById('inc-refresh-value');
|
||||||
|
if (ivInput) ivInput.addEventListener('change', () => {
|
||||||
|
const u = parseInt(document.getElementById('inc-refresh-unit').value);
|
||||||
|
const m = (u === 1) ? _getMinIntervalMinutes() : 1;
|
||||||
|
if (isNaN(parseInt(ivInput.value)) || parseInt(ivInput.value) < m) ivInput.value = m;
|
||||||
|
}); }
|
||||||
|
|
||||||
|
|
||||||
// Feedback
|
// Feedback
|
||||||
@@ -909,6 +917,26 @@ const App = {
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
|
/** Quellenuebersicht der Lage nach Quellentyp filtern (Web/Telegram/X). */
|
||||||
|
filterSourceOverview(type, chipEl) {
|
||||||
|
const content = document.getElementById('source-overview-content');
|
||||||
|
if (!content) return;
|
||||||
|
content.querySelectorAll('.source-type-filter-chip').forEach(c => c.classList.remove('active'));
|
||||||
|
if (chipEl) chipEl.classList.add('active');
|
||||||
|
// ein offenes Detail-Panel schliessen
|
||||||
|
const det = content.querySelector('.source-overview-detail');
|
||||||
|
if (det) det.remove();
|
||||||
|
content.querySelectorAll('.source-overview-item.active').forEach(it => {
|
||||||
|
it.classList.remove('active');
|
||||||
|
it.setAttribute('aria-expanded', 'false');
|
||||||
|
});
|
||||||
|
// Quellen-Boxen nach Typ ein-/ausblenden
|
||||||
|
content.querySelectorAll('.source-overview-item').forEach(it => {
|
||||||
|
const t = it.dataset.type || 'web';
|
||||||
|
it.style.display = (!type || t === type) ? '' : 'none';
|
||||||
|
});
|
||||||
|
},
|
||||||
|
|
||||||
/** Klick auf eine Quellen-Box: Liste der Artikel inline aufklappen (mutual-exclusive). */
|
/** Klick auf eine Quellen-Box: Liste der Artikel inline aufklappen (mutual-exclusive). */
|
||||||
toggleSourceOverviewDetail(el) {
|
toggleSourceOverviewDetail(el) {
|
||||||
if (!el) return;
|
if (!el) return;
|
||||||
@@ -1131,6 +1159,26 @@ const App = {
|
|||||||
: '';
|
: '';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Öffentliche Stimmung (Foren-Kachel): Tab + Inhalt nur einblenden,
|
||||||
|
// wenn fuer diese Lage tatsaechlich Stimmungs-Text vorhanden ist.
|
||||||
|
const stimmungTabBtn = document.getElementById('tab-btn-stimmung');
|
||||||
|
const stimmungText = document.getElementById('stimmung-text');
|
||||||
|
const stimmungTs = document.getElementById('stimmung-timestamp');
|
||||||
|
const moodText = (incident.public_mood || '').trim();
|
||||||
|
if (moodText && stimmungTabBtn) {
|
||||||
|
stimmungTabBtn.style.display = '';
|
||||||
|
if (stimmungText) stimmungText.innerHTML = UI.renderPublicMood(moodText);
|
||||||
|
if (stimmungTs && incident.public_mood_updated_at) {
|
||||||
|
const mUpd = parseUTC(incident.public_mood_updated_at);
|
||||||
|
if (mUpd) {
|
||||||
|
stimmungTs.textContent = `Stand: ${mUpd.toLocaleDateString('de-DE', { day: '2-digit', month: '2-digit', year: 'numeric', timeZone: TIMEZONE })} ${mUpd.toLocaleTimeString('de-DE', { hour: '2-digit', minute: '2-digit', timeZone: TIMEZONE })} Uhr`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else if (stimmungTabBtn) {
|
||||||
|
stimmungTabBtn.style.display = 'none';
|
||||||
|
if (stimmungText) stimmungText.innerHTML = '';
|
||||||
|
}
|
||||||
|
|
||||||
{ const _e = document.getElementById('meta-refresh-mode'); if (_e) {
|
{ const _e = document.getElementById('meta-refresh-mode'); if (_e) {
|
||||||
if (incident.refresh_mode === 'auto' && incident.refresh_start_time) {
|
if (incident.refresh_mode === 'auto' && incident.refresh_start_time) {
|
||||||
const intervalText = App._formatInterval(incident.refresh_interval);
|
const intervalText = App._formatInterval(incident.refresh_interval);
|
||||||
@@ -1796,9 +1844,9 @@ const App = {
|
|||||||
// === Event Handlers ===
|
// === Event Handlers ===
|
||||||
|
|
||||||
_getFormData() {
|
_getFormData() {
|
||||||
const value = parseInt(document.getElementById('inc-refresh-value').value) || 15;
|
const value = parseInt(document.getElementById('inc-refresh-value').value) || 30;
|
||||||
const unit = parseInt(document.getElementById('inc-refresh-unit').value) || 1;
|
const unit = parseInt(document.getElementById('inc-refresh-unit').value) || 1;
|
||||||
const interval = Math.max(10, Math.min(10080, value * unit));
|
const interval = Math.max(_getMinIntervalMinutes(), Math.min(10080, value * unit));
|
||||||
return {
|
return {
|
||||||
title: document.getElementById('inc-title').value.trim(),
|
title: document.getElementById('inc-title').value.trim(),
|
||||||
description: document.getElementById('inc-description').value.trim() || null,
|
description: document.getElementById('inc-description').value.trim() || null,
|
||||||
@@ -1811,6 +1859,7 @@ const App = {
|
|||||||
retention_days: parseInt(document.getElementById('inc-retention').value) || 0,
|
retention_days: parseInt(document.getElementById('inc-retention').value) || 0,
|
||||||
international_sources: document.getElementById('inc-international').checked,
|
international_sources: document.getElementById('inc-international').checked,
|
||||||
include_telegram: document.getElementById('inc-telegram').checked,
|
include_telegram: document.getElementById('inc-telegram').checked,
|
||||||
|
include_x: document.getElementById('inc-x').checked,
|
||||||
visibility: document.getElementById('inc-visibility').checked ? 'public' : 'private',
|
visibility: document.getElementById('inc-visibility').checked ? 'public' : 'private',
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
@@ -2246,12 +2295,14 @@ async handleRefresh() {
|
|||||||
{ const _e = document.getElementById('inc-retention'); if (_e) _e.value = incident.retention_days; }
|
{ const _e = document.getElementById('inc-retention'); if (_e) _e.value = incident.retention_days; }
|
||||||
{ const _e = document.getElementById('inc-international'); if (_e) _e.checked = incident.international_sources !== false && incident.international_sources !== 0; }
|
{ const _e = document.getElementById('inc-international'); if (_e) _e.checked = incident.international_sources !== false && incident.international_sources !== 0; }
|
||||||
{ const _e = document.getElementById('inc-telegram'); if (_e) _e.checked = !!incident.include_telegram; }
|
{ const _e = document.getElementById('inc-telegram'); if (_e) _e.checked = !!incident.include_telegram; }
|
||||||
|
{ const _e = document.getElementById('inc-x'); if (_e) _e.checked = !!incident.include_x; }
|
||||||
|
|
||||||
{ const _e = document.getElementById('inc-visibility'); if (_e) _e.checked = incident.visibility !== 'private'; }
|
{ const _e = document.getElementById('inc-visibility'); if (_e) _e.checked = incident.visibility !== 'private'; }
|
||||||
updateVisibilityHint();
|
updateVisibilityHint();
|
||||||
updateSourcesHint();
|
updateSourcesHint();
|
||||||
toggleTypeDefaults(true);
|
toggleTypeDefaults(true);
|
||||||
toggleRefreshInterval();
|
toggleRefreshInterval();
|
||||||
|
updateIntervalMin();
|
||||||
|
|
||||||
// Modal-Titel und Submit ändern
|
// Modal-Titel und Submit ändern
|
||||||
{ const _e = document.getElementById('modal-new-title'); if (_e) _e.textContent = (typeof T === 'function') ? T('modal.new_incident.edit_title', 'Lage bearbeiten') : 'Lage bearbeiten'; }
|
{ const _e = document.getElementById('modal-new-title'); if (_e) _e.textContent = (typeof T === 'function') ? T('modal.new_incident.edit_title', 'Lage bearbeiten') : 'Lage bearbeiten'; }
|
||||||
@@ -2595,6 +2646,9 @@ async handleRefresh() {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const format = document.querySelector('input[name="export-format"]:checked').value;
|
const format = document.querySelector('input[name="export-format"]:checked').value;
|
||||||
|
const brandingEl = document.querySelector('input[name="export-branding"]:checked');
|
||||||
|
const includeBranding = !brandingEl || brandingEl.value === 'on';
|
||||||
|
const ersteller = (document.getElementById('export-ersteller')?.value || '').trim();
|
||||||
|
|
||||||
const btn = document.getElementById('export-submit-btn');
|
const btn = document.getElementById('export-submit-btn');
|
||||||
const origText = btn.textContent;
|
const origText = btn.textContent;
|
||||||
@@ -2602,7 +2656,7 @@ async handleRefresh() {
|
|||||||
btn.textContent = (typeof T === 'function' ? T('action.creating', 'Wird erstellt...') : 'Wird erstellt...');
|
btn.textContent = (typeof T === 'function' ? T('action.creating', 'Wird erstellt...') : 'Wird erstellt...');
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const response = await API.exportReport(this.currentIncidentId, format, null, sections);
|
const response = await API.exportReport(this.currentIncidentId, format, null, sections, includeBranding, ersteller);
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
const err = await response.json().catch(() => ({}));
|
const err = await response.json().catch(() => ({}));
|
||||||
throw new Error(err.detail || 'Fehler ' + response.status);
|
throw new Error(err.detail || 'Fehler ' + response.status);
|
||||||
@@ -2775,12 +2829,14 @@ async handleRefresh() {
|
|||||||
const rss = stats.by_type.rss_feed || { count: 0, articles: 0 };
|
const rss = stats.by_type.rss_feed || { count: 0, articles: 0 };
|
||||||
const web = stats.by_type.web_source || { count: 0, articles: 0 };
|
const web = stats.by_type.web_source || { count: 0, articles: 0 };
|
||||||
const tg = stats.by_type.telegram_channel || { count: 0, articles: 0 };
|
const tg = stats.by_type.telegram_channel || { count: 0, articles: 0 };
|
||||||
|
const x = stats.by_type.x_account || { count: 0, articles: 0 };
|
||||||
const excluded = this._myExclusions.length;
|
const excluded = this._myExclusions.length;
|
||||||
|
|
||||||
bar.innerHTML = `
|
bar.innerHTML = `
|
||||||
<span class="sources-stat-item"><span class="sources-stat-value">${rss.count}</span> ${(typeof T === 'function' ? T('sources_modal.stats.rss', 'RSS-Feeds') : 'RSS-Feeds')}</span>
|
<span class="sources-stat-item"><span class="sources-stat-value">${rss.count}</span> ${(typeof T === 'function' ? T('sources_modal.stats.rss', 'RSS-Feeds') : 'RSS-Feeds')}</span>
|
||||||
<span class="sources-stat-item"><span class="sources-stat-value">${web.count}</span> ${(typeof T === 'function' ? T('sources_modal.stats.web', 'Web-Quellen') : 'Web-Quellen')}</span>
|
<span class="sources-stat-item"><span class="sources-stat-value">${web.count}</span> ${(typeof T === 'function' ? T('sources_modal.stats.web', 'Web-Quellen') : 'Web-Quellen')}</span>
|
||||||
<span class="sources-stat-item"><span class="sources-stat-value">${tg.count}</span> Telegram</span>
|
<span class="sources-stat-item"><span class="sources-stat-value">${tg.count}</span> Telegram</span>
|
||||||
|
<span class="sources-stat-item"><span class="sources-stat-value">${x.count}</span> X</span>
|
||||||
<span class="sources-stat-item"><span class="sources-stat-value">${excluded}</span> ${(typeof T === 'function' ? T('sources_modal.stats.excluded', 'Ausgeschlossen') : 'Ausgeschlossen')}</span>
|
<span class="sources-stat-item"><span class="sources-stat-value">${excluded}</span> ${(typeof T === 'function' ? T('sources_modal.stats.excluded', 'Ausgeschlossen') : 'Ausgeschlossen')}</span>
|
||||||
<span class="sources-stat-item"><span class="sources-stat-value">${stats.total_articles}</span> Artikel gesamt</span>
|
<span class="sources-stat-item"><span class="sources-stat-value">${stats.total_articles}</span> Artikel gesamt</span>
|
||||||
`;
|
`;
|
||||||
@@ -3106,6 +3162,70 @@ async handleRefresh() {
|
|||||||
|
|
||||||
_discoveredData: null,
|
_discoveredData: null,
|
||||||
|
|
||||||
|
openPdfUpload() {
|
||||||
|
const form = document.getElementById("pdf-upload-form");
|
||||||
|
if (form) form.reset();
|
||||||
|
const err = document.getElementById("pdf-upload-error");
|
||||||
|
if (err) { err.style.display = "none"; err.textContent = ""; }
|
||||||
|
const prog = document.getElementById("pdf-upload-progress");
|
||||||
|
if (prog) prog.style.display = "none";
|
||||||
|
openModal("modal-pdf-upload");
|
||||||
|
this._bindPdfUploadFormOnce();
|
||||||
|
},
|
||||||
|
|
||||||
|
_bindPdfUploadFormOnce() {
|
||||||
|
const form = document.getElementById("pdf-upload-form");
|
||||||
|
if (!form || form.dataset.bound === "1") return;
|
||||||
|
form.dataset.bound = "1";
|
||||||
|
form.addEventListener("submit", async (e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
const errEl = document.getElementById("pdf-upload-error");
|
||||||
|
const progEl = document.getElementById("pdf-upload-progress");
|
||||||
|
const submitBtn = document.getElementById("pdf-upload-submit");
|
||||||
|
errEl.style.display = "none";
|
||||||
|
|
||||||
|
const fileInput = document.getElementById("pdf-upload-file");
|
||||||
|
const f = fileInput && fileInput.files && fileInput.files[0];
|
||||||
|
if (!f) {
|
||||||
|
errEl.textContent = "Bitte eine PDF-Datei auswaehlen.";
|
||||||
|
errEl.style.display = "block";
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (f.size > 50 * 1024 * 1024) {
|
||||||
|
errEl.textContent = "Datei ueberschreitet 50 MB.";
|
||||||
|
errEl.style.display = "block";
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const fd = new FormData();
|
||||||
|
fd.append("file", f);
|
||||||
|
const nm = (document.getElementById("pdf-upload-name").value || "").trim();
|
||||||
|
if (nm) fd.append("name", nm);
|
||||||
|
fd.append("category", document.getElementById("pdf-upload-category").value || "sonstige");
|
||||||
|
const lng = (document.getElementById("pdf-upload-language").value || "").trim();
|
||||||
|
if (lng) fd.append("language", lng);
|
||||||
|
const nt = (document.getElementById("pdf-upload-notes").value || "").trim();
|
||||||
|
if (nt) fd.append("notes", nt);
|
||||||
|
|
||||||
|
submitBtn.disabled = true;
|
||||||
|
progEl.style.display = "block";
|
||||||
|
try {
|
||||||
|
await API.upload("/sources/upload-pdf", fd);
|
||||||
|
closeModal("modal-pdf-upload");
|
||||||
|
if (typeof UI !== "undefined" && UI.showToast) {
|
||||||
|
UI.showToast("PDF hochgeladen -- Verarbeitung laeuft im Hintergrund", "success");
|
||||||
|
}
|
||||||
|
await App.loadSources();
|
||||||
|
} catch (err) {
|
||||||
|
errEl.textContent = err && err.message ? err.message : "Upload fehlgeschlagen";
|
||||||
|
errEl.style.display = "block";
|
||||||
|
} finally {
|
||||||
|
submitBtn.disabled = false;
|
||||||
|
progEl.style.display = "none";
|
||||||
|
}
|
||||||
|
});
|
||||||
|
},
|
||||||
|
|
||||||
toggleSourceForm(show) {
|
toggleSourceForm(show) {
|
||||||
const form = document.getElementById('sources-add-form');
|
const form = document.getElementById('sources-add-form');
|
||||||
if (!form) return;
|
if (!form) return;
|
||||||
@@ -3162,6 +3282,31 @@ async handleRefresh() {
|
|||||||
if (saveBtn) { saveBtn.disabled = false; saveBtn.textContent = 'Speichern'; }
|
if (saveBtn) { saveBtn.disabled = false; saveBtn.textContent = 'Speichern'; }
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// X (Twitter)-URLs direkt behandeln (kein Discovery noetig)
|
||||||
|
if (urlVal.match(/^(https?:\/\/)?(x\.com|twitter\.com)\//i)) {
|
||||||
|
const handle = urlVal
|
||||||
|
.replace(/^(https?:\/\/)?(x\.com|twitter\.com)\//i, '')
|
||||||
|
.replace(/\/$/, '')
|
||||||
|
.split(/[/?]/)[0]
|
||||||
|
.replace(/^@/, '');
|
||||||
|
const xUrl = 'x.com/' + handle;
|
||||||
|
this._discoveredData = {
|
||||||
|
name: '@' + handle,
|
||||||
|
domain: xUrl,
|
||||||
|
source_type: 'x_account',
|
||||||
|
rss_url: null,
|
||||||
|
};
|
||||||
|
document.getElementById('src-name').value = '@' + handle;
|
||||||
|
document.getElementById('src-type-select').value = 'x_account';
|
||||||
|
document.getElementById('src-type-display').value = 'X (Twitter)';
|
||||||
|
document.getElementById('src-domain').value = xUrl;
|
||||||
|
document.getElementById('src-rss-url-group').style.display = 'none';
|
||||||
|
document.getElementById('src-discovery-result').style.display = 'block';
|
||||||
|
const saveBtnX = document.querySelector('#src-discovery-result .sources-discovery-actions .btn-primary');
|
||||||
|
if (saveBtnX) { saveBtnX.disabled = false; saveBtnX.textContent = 'Speichern'; }
|
||||||
|
return;
|
||||||
|
}
|
||||||
const url = urlInput.value.trim();
|
const url = urlInput.value.trim();
|
||||||
if (!url) {
|
if (!url) {
|
||||||
UI.showToast('Bitte URL oder Domain eingeben.', 'warning');
|
UI.showToast('Bitte URL oder Domain eingeben.', 'warning');
|
||||||
@@ -3281,7 +3426,7 @@ async handleRefresh() {
|
|||||||
document.getElementById('src-notes').value = source.notes || '';
|
document.getElementById('src-notes').value = source.notes || '';
|
||||||
document.getElementById('src-domain').value = source.domain || '';
|
document.getElementById('src-domain').value = source.domain || '';
|
||||||
|
|
||||||
const typeLabel = source.source_type === 'rss_feed' ? 'RSS-Feed' : source.source_type === 'telegram_channel' ? 'Telegram' : 'Web-Quelle';
|
const typeLabel = source.source_type === 'rss_feed' ? 'RSS-Feed' : source.source_type === 'telegram_channel' ? 'Telegram' : source.source_type === 'x_account' ? 'X (Twitter)' : 'Web-Quelle';
|
||||||
const typeSelect = document.getElementById('src-type-select');
|
const typeSelect = document.getElementById('src-type-select');
|
||||||
if (typeSelect) typeSelect.value = source.source_type || 'web_source';
|
if (typeSelect) typeSelect.value = source.source_type || 'web_source';
|
||||||
document.getElementById('src-type-display').value = typeLabel;
|
document.getElementById('src-type-display').value = typeLabel;
|
||||||
@@ -3325,7 +3470,7 @@ async handleRefresh() {
|
|||||||
name,
|
name,
|
||||||
source_type: discovered.source_type || 'web_source',
|
source_type: discovered.source_type || 'web_source',
|
||||||
category: document.getElementById('src-category').value,
|
category: document.getElementById('src-category').value,
|
||||||
url: discovered.rss_url || (discovered.source_type === 'telegram_channel' ? (document.getElementById('src-domain').value || null) : null),
|
url: discovered.rss_url || ((discovered.source_type === 'telegram_channel' || discovered.source_type === 'x_account') ? (document.getElementById('src-domain').value || null) : null),
|
||||||
domain: document.getElementById('src-domain').value.trim() || discovered.domain || null,
|
domain: document.getElementById('src-domain').value.trim() || discovered.domain || null,
|
||||||
notes: document.getElementById('src-notes').value.trim() || null,
|
notes: document.getElementById('src-notes').value.trim() || null,
|
||||||
};
|
};
|
||||||
@@ -3497,6 +3642,7 @@ function openModal(id) {
|
|||||||
document.getElementById('inc-notify-status-change').checked = false;
|
document.getElementById('inc-notify-status-change').checked = false;
|
||||||
toggleTypeDefaults();
|
toggleTypeDefaults();
|
||||||
toggleRefreshInterval();
|
toggleRefreshInterval();
|
||||||
|
updateIntervalMin();
|
||||||
}
|
}
|
||||||
const modal = document.getElementById(id);
|
const modal = document.getElementById(id);
|
||||||
modal._previousFocus = document.activeElement;
|
modal._previousFocus = document.activeElement;
|
||||||
@@ -3678,17 +3824,38 @@ function toggleRefreshInterval() {
|
|||||||
if (startField) startField.classList.toggle('visible', mode === 'auto');
|
if (startField) startField.classList.toggle('visible', mode === 'auto');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function _getMinIntervalMinutes() {
|
||||||
|
// Mindest-Intervall (Minuten) je nach Quellen: 30 Basis, 45 bei X oder Telegram, 60 bei beiden. International zaehlt nicht.
|
||||||
|
const tg = document.getElementById('inc-telegram');
|
||||||
|
const x = document.getElementById('inc-x');
|
||||||
|
const tgOn = !!(tg && tg.checked);
|
||||||
|
const xOn = !!(x && x.checked);
|
||||||
|
if (tgOn && xOn) return 60;
|
||||||
|
if (tgOn || xOn) return 45;
|
||||||
|
return 30;
|
||||||
|
}
|
||||||
|
|
||||||
function updateIntervalMin() {
|
function updateIntervalMin() {
|
||||||
const unit = parseInt(document.getElementById('inc-refresh-unit').value);
|
const unit = parseInt(document.getElementById('inc-refresh-unit').value);
|
||||||
const input = document.getElementById('inc-refresh-value');
|
const input = document.getElementById('inc-refresh-value');
|
||||||
|
const minMinutes = _getMinIntervalMinutes();
|
||||||
|
const hint = document.getElementById('interval-min-hint');
|
||||||
if (unit === 1) {
|
if (unit === 1) {
|
||||||
// Minuten: Minimum 10
|
// Minuten: dynamisches Minimum (30 / 45 bei X oder Telegram / 60 bei beiden)
|
||||||
input.min = 10;
|
input.min = minMinutes;
|
||||||
if (parseInt(input.value) < 10) input.value = 10;
|
if (isNaN(parseInt(input.value)) || parseInt(input.value) < minMinutes) input.value = minMinutes;
|
||||||
|
if (hint) {
|
||||||
|
let zusatz = '';
|
||||||
|
if (minMinutes === 45) zusatz = ' (X oder Telegram aktiv)';
|
||||||
|
else if (minMinutes === 60) zusatz = ' (X und Telegram aktiv)';
|
||||||
|
hint.textContent = 'Mindestens ' + minMinutes + ' Minuten' + zusatz;
|
||||||
|
hint.style.display = '';
|
||||||
|
}
|
||||||
} else {
|
} else {
|
||||||
// Stunden/Tage/Wochen: Minimum 1
|
// Stunden/Tage/Wochen: eine Einheit liegt ueber jedem Minuten-Minimum
|
||||||
input.min = 1;
|
input.min = 1;
|
||||||
if (parseInt(input.value) < 1) input.value = 1;
|
if (isNaN(parseInt(input.value)) || parseInt(input.value) < 1) input.value = 1;
|
||||||
|
if (hint) hint.style.display = 'none';
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -813,6 +813,26 @@ const UI = {
|
|||||||
return html;
|
return html;
|
||||||
},
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Rendert die "Öffentliche Stimmung"-Kachel.
|
||||||
|
* Eingabe ist Markdown mit "- "-Bullets (vom AnalyzerAgent.generate_public_mood).
|
||||||
|
* Quellen-Pills brauchen wir hier nicht — die Bullet-Texte nennen die Foren-Herkunft
|
||||||
|
* explizit ("auf 5ch /seiji/ ...", "Hatena-Kommentare betonen ...").
|
||||||
|
*/
|
||||||
|
renderPublicMood(text) {
|
||||||
|
if (!text) return '<span style="color:var(--text-disabled);">Noch kein Stimmungsbild erfasst.</span>';
|
||||||
|
const bulletLines = text.split("\n").map(l => l.trim()).filter(l => l.startsWith("- "));
|
||||||
|
if (bulletLines.length === 0) {
|
||||||
|
// Fliesstext-Fallback: HTML-escapen + Zeilenumbrueche
|
||||||
|
return this.escape(text).replace(/\n/g, '<br>');
|
||||||
|
}
|
||||||
|
const items = bulletLines.map(l => {
|
||||||
|
const body = l.replace(/^-\s+/, '');
|
||||||
|
return `<li>${this.escape(body)}</li>`;
|
||||||
|
}).join('');
|
||||||
|
return `<ul style="margin:4px 0 4px 18px;line-height:1.7;">${items}</ul>`;
|
||||||
|
},
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Rendert "Neueste Entwicklungen" für Live-Monitoring (adhoc).
|
* Rendert "Neueste Entwicklungen" für Live-Monitoring (adhoc).
|
||||||
* Erwartet Bullets im Format "- [DD.MM. HH:MM] Text {Quelle1, Quelle2}".
|
* Erwartet Bullets im Format "- [DD.MM. HH:MM] Text {Quelle1, Quelle2}".
|
||||||
@@ -1014,11 +1034,31 @@ const UI = {
|
|||||||
html += `<div class="source-lang-chips">${langChips}</div>`;
|
html += `<div class="source-lang-chips">${langChips}</div>`;
|
||||||
html += `</div>`;
|
html += `</div>`;
|
||||||
|
|
||||||
|
// Typ-Filter-Chips: immer zeigen, sobald Quellen vorhanden sind. Die Leiste
|
||||||
|
// zeigt zugleich auf einen Blick, welche Quellentypen der Fall enthaelt.
|
||||||
|
const typeCounts = { web: 0, telegram: 0, x: 0 };
|
||||||
|
data.sources.forEach(s => {
|
||||||
|
const t = s.source_type || 'web';
|
||||||
|
typeCounts[t] = (typeCounts[t] || 0) + 1;
|
||||||
|
});
|
||||||
|
const typeMeta = [
|
||||||
|
{ key: '', label: 'Alle', count: data.sources.length },
|
||||||
|
{ key: 'web', label: 'Web', count: typeCounts.web },
|
||||||
|
{ key: 'telegram', label: 'Telegram', count: typeCounts.telegram },
|
||||||
|
{ key: 'x', label: 'X', count: typeCounts.x },
|
||||||
|
];
|
||||||
|
const chips = typeMeta
|
||||||
|
.filter(t => t.key === '' || t.count > 0)
|
||||||
|
.map(t => `<button type="button" class="source-type-filter-chip${t.key === '' ? ' active' : ''}" data-type="${t.key}" onclick="App.filterSourceOverview('${t.key}', this)">${t.label} <strong>${t.count}</strong></button>`)
|
||||||
|
.join('');
|
||||||
|
html += `<div class="source-type-filter-chips">${chips}</div>`;
|
||||||
|
|
||||||
html += '<div class="source-overview-grid">';
|
html += '<div class="source-overview-grid">';
|
||||||
data.sources.forEach(s => {
|
data.sources.forEach(s => {
|
||||||
const langs = (s.languages || ['de']).map(l => (l || 'de').toUpperCase()).join('/');
|
const langs = (s.languages || ['de']).map(l => (l || 'de').toUpperCase()).join('/');
|
||||||
const sourceName = this.escape(s.source || 'Unbekannt');
|
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);}">
|
const sType = s.source_type || 'web';
|
||||||
|
html += `<div class="source-overview-item" data-source="${sourceName}" data-type="${sType}" 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>
|
<span class="source-overview-name">${sourceName}</span>
|
||||||
<span class="source-overview-lang">${langs}</span>
|
<span class="source-overview-lang">${langs}</span>
|
||||||
<span class="source-overview-count">${s.article_count}</span>
|
<span class="source-overview-count">${s.article_count}</span>
|
||||||
@@ -1190,6 +1230,10 @@ const UI = {
|
|||||||
/**
|
/**
|
||||||
* Domain-Gruppe rendern (aufklappbar mit Feeds).
|
* Domain-Gruppe rendern (aufklappbar mit Feeds).
|
||||||
*/
|
*/
|
||||||
|
_sourceTypeLabel(type) {
|
||||||
|
return ({ rss_feed: 'RSS', web_source: 'Web', telegram_channel: 'Telegram', x_account: 'X', podcast_feed: 'Podcast', excluded: 'Ausgeschlossen' })[type] || 'Web';
|
||||||
|
},
|
||||||
|
|
||||||
renderSourceGroup(domain, feeds, isExcluded, excludedNotes, isGlobal) {
|
renderSourceGroup(domain, feeds, isExcluded, excludedNotes, isGlobal) {
|
||||||
const catLabel = this._categoryLabels[feeds[0]?.category] || feeds[0]?.category || '';
|
const catLabel = this._categoryLabels[feeds[0]?.category] || feeds[0]?.category || '';
|
||||||
const feedCount = feeds.filter(f => f.source_type !== 'excluded').length;
|
const feedCount = feeds.filter(f => f.source_type !== 'excluded').length;
|
||||||
@@ -1224,7 +1268,7 @@ const UI = {
|
|||||||
realFeeds.forEach((feed, i) => {
|
realFeeds.forEach((feed, i) => {
|
||||||
const isLast = i === realFeeds.length - 1;
|
const isLast = i === realFeeds.length - 1;
|
||||||
const connector = isLast ? '\u2514\u2500' : '\u251C\u2500';
|
const connector = isLast ? '\u2514\u2500' : '\u251C\u2500';
|
||||||
const typeLabel = feed.source_type === 'rss_feed' ? 'RSS' : 'Web';
|
const typeLabel = this._sourceTypeLabel(feed.source_type);
|
||||||
const urlDisplay = feed.url ? this._shortenUrl(feed.url) : '';
|
const urlDisplay = feed.url ? this._shortenUrl(feed.url) : '';
|
||||||
feedRows += `<div class="source-feed-row">
|
feedRows += `<div class="source-feed-row">
|
||||||
<span class="source-feed-connector">${connector}</span>
|
<span class="source-feed-connector">${connector}</span>
|
||||||
@@ -1253,7 +1297,7 @@ const UI = {
|
|||||||
|| firstFeed.country_code
|
|| firstFeed.country_code
|
||||||
|| (Array.isArray(firstFeed.alignments) && firstFeed.alignments.length > 0);
|
|| (Array.isArray(firstFeed.alignments) && firstFeed.alignments.length > 0);
|
||||||
if (hasInfo) {
|
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', x_account: 'X (Twitter)', podcast_feed: 'Podcast' };
|
||||||
const lines = [];
|
const lines = [];
|
||||||
lines.push('Typ: ' + (typeMap[firstFeed.source_type] || firstFeed.source_type || 'Unbekannt'));
|
lines.push('Typ: ' + (typeMap[firstFeed.source_type] || firstFeed.source_type || 'Unbekannt'));
|
||||||
if (firstFeed.language) lines.push('Sprache: ' + firstFeed.language);
|
if (firstFeed.language) lines.push('Sprache: ' + firstFeed.language);
|
||||||
@@ -1294,6 +1338,7 @@ const UI = {
|
|||||||
<div class="source-group-info">
|
<div class="source-group-info">
|
||||||
<span class="source-group-name">${this.escape(displayName)}</span>${infoButtonHtml}
|
<span class="source-group-name">${this.escape(displayName)}</span>${infoButtonHtml}
|
||||||
</div>
|
</div>
|
||||||
|
${!hasMultiple ? `<span class="source-type-badge type-${feeds[0]?.source_type || ''}">${this._sourceTypeLabel(feeds[0]?.source_type)}</span>` : ''}
|
||||||
<span class="source-category-badge cat-${feeds[0]?.category || 'sonstige'}">${catLabel}</span>
|
<span class="source-category-badge cat-${feeds[0]?.category || 'sonstige'}">${catLabel}</span>
|
||||||
${classificationBadges ? `<span class="source-classification-badges">${classificationBadges}</span>` : ''}
|
${classificationBadges ? `<span class="source-classification-badges">${classificationBadges}</span>` : ''}
|
||||||
${feedCountBadge}
|
${feedCountBadge}
|
||||||
|
|||||||
In neuem Issue referenzieren
Einen Benutzer sperren