feat: Kontextabhängige Karten-Kategorien

4 feste Farbstufen (primary/secondary/tertiary/mentioned) mit
variablen Labels pro Lage, die von Haiku generiert werden.

- DB: category_labels Spalte in incidents, alte Kategorien migriert
  (target->primary, response/retaliation->secondary, actor->tertiary)
- Geoparsing: generate_category_labels() + neuer Prompt mit neuen Keys
- QC: Kategorieprüfung auf neue Keys umgestellt
- Orchestrator: Tuple-Rückgabe + Labels in DB speichern
- API: category_labels im Locations- und Lagebild-Response
- Frontend: Dynamische Legende aus API-Labels mit Fallback-Defaults
- Migrationsskript für bestehende Lagen

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Dieser Commit ist enthalten in:
Claude Dev
2026-03-15 15:04:02 +01:00
Ursprung 5fd65657c5
Commit 19da099583
9 geänderte Dateien mit 1315 neuen und 1012 gelöschten Zeilen

73
migrate_category_labels.py Normale Datei
Datei anzeigen

@@ -0,0 +1,73 @@
#!/usr/bin/env python3
"""Einmaliges Migrationsskript: Generiert Haiku-Labels fuer alle bestehenden Lagen.
Ausfuehrung auf dem Monitor-Server:
cd /home/claude-dev/AegisSight-Monitor
.venvs_run: /home/claude-dev/.venvs/osint/bin/python migrate_category_labels.py
"""
import asyncio
import json
import logging
import os
import sys
# Projektpfad setzen damit imports funktionieren
sys.path.insert(0, os.path.join(os.path.dirname(__file__), 'src'))
logging.basicConfig(
level=logging.INFO,
format='%(asctime)s [%(name)s] %(levelname)s: %(message)s',
)
logger = logging.getLogger("migrate_labels")
async def main():
from database import get_db
from agents.geoparsing import generate_category_labels
db = await get_db()
try:
# Alle Incidents ohne category_labels laden
cursor = await db.execute(
"SELECT id, title, description FROM incidents WHERE category_labels IS NULL"
)
incidents = [dict(row) for row in await cursor.fetchall()]
if not incidents:
logger.info("Keine Incidents ohne Labels gefunden. Nichts zu tun.")
return
logger.info(f"{len(incidents)} Incidents ohne Labels gefunden. Starte Generierung...")
success = 0
for inc in incidents:
incident_id = inc["id"]
context = f"{inc['title']} - {inc.get('description') or ''}"
logger.info(f"Generiere Labels fuer Incident {incident_id}: {inc['title'][:60]}...")
try:
labels = await generate_category_labels(context)
if labels:
await db.execute(
"UPDATE incidents SET category_labels = ? WHERE id = ?",
(json.dumps(labels, ensure_ascii=False), incident_id),
)
await db.commit()
success += 1
logger.info(f" -> Labels: {labels}")
else:
logger.warning(f" -> Keine Labels generiert")
except Exception as e:
logger.error(f" -> Fehler: {e}")
# Kurze Pause um Rate-Limits zu vermeiden
await asyncio.sleep(0.5)
logger.info(f"\nMigration abgeschlossen: {success}/{len(incidents)} Incidents mit Labels versehen.")
finally:
await db.close()
if __name__ == "__main__":
asyncio.run(main())

Datei anzeigen

@@ -209,6 +209,90 @@ def _geocode_location(name: str, country_code: str = "", haiku_coords: Optional[
return result return result
# Default-Labels (Fallback wenn Haiku keine generiert)
DEFAULT_CATEGORY_LABELS = {
"primary": "Hauptgeschehen",
"secondary": "Reaktionen",
"tertiary": "Beteiligte",
"mentioned": "Erwaehnt",
}
CATEGORY_LABELS_PROMPT = """Generiere kurze, praegnante Kategorie-Labels fuer Karten-Pins zu dieser Nachrichtenlage.
Lage: "{incident_context}"
Es gibt 4 Farbstufen fuer Orte auf der Karte:
1. primary (Rot): Wo das Hauptgeschehen stattfindet
2. secondary (Orange): Direkte Reaktionen/Gegenmassnahmen
3. tertiary (Blau): Entscheidungstraeger/Beteiligte
4. mentioned (Grau): Nur erwaehnt
Generiere fuer jede Stufe ein kurzes Label (1-3 Woerter), das zum Thema passt.
Wenn eine Stufe fuer dieses Thema nicht sinnvoll ist, setze null.
Beispiele:
- Militaerkonflikt Iran: {{"primary": "Kampfschauplätze", "secondary": "Vergeltungsschläge", "tertiary": "Strategische Akteure", "mentioned": "Erwähnt"}}
- Erdbeben Tuerkei: {{"primary": "Katastrophenzone", "secondary": "Hilfsoperationen", "tertiary": "Geberländer", "mentioned": "Erwähnt"}}
- Bundestagswahl: {{"primary": "Wahlkreise", "secondary": "Koalitionspartner", "tertiary": "Internationale Reaktionen", "mentioned": "Erwähnt"}}
Antworte NUR als JSON-Objekt:"""
async def generate_category_labels(incident_context: str) -> dict[str, str | None]:
"""Generiert kontextabhaengige Kategorie-Labels via Haiku.
Args:
incident_context: Lage-Titel + Beschreibung
Returns:
Dict mit Labels fuer primary/secondary/tertiary/mentioned (oder None wenn nicht passend)
"""
if not incident_context or not incident_context.strip():
return dict(DEFAULT_CATEGORY_LABELS)
prompt = CATEGORY_LABELS_PROMPT.format(incident_context=incident_context[:500])
try:
result_text, usage = await call_claude(prompt, tools=None, model=CLAUDE_MODEL_FAST)
parsed = None
try:
parsed = json.loads(result_text)
except json.JSONDecodeError:
match = re.search(r'\{.*\}', result_text, re.DOTALL)
if match:
try:
parsed = json.loads(match.group())
except json.JSONDecodeError:
pass
if not parsed or not isinstance(parsed, dict):
logger.warning("generate_category_labels: Kein gueltiges JSON erhalten")
return dict(DEFAULT_CATEGORY_LABELS)
# Validierung: Nur erlaubte Keys, Werte muessen str oder None sein
valid_keys = {"primary", "secondary", "tertiary", "mentioned"}
labels = {}
for key in valid_keys:
val = parsed.get(key)
if val is None or val == "null":
labels[key] = None
elif isinstance(val, str) and val.strip():
labels[key] = val.strip()
else:
labels[key] = DEFAULT_CATEGORY_LABELS.get(key)
# mentioned sollte immer einen Wert haben
if not labels.get("mentioned"):
labels["mentioned"] = "Erwaehnt"
logger.info(f"Kategorie-Labels generiert: {labels}")
return labels
except Exception as e:
logger.error(f"generate_category_labels fehlgeschlagen: {e}")
return dict(DEFAULT_CATEGORY_LABELS)
HAIKU_GEOPARSE_PROMPT = """Extrahiere alle geographischen Orte aus diesen Nachrichten-Headlines. HAIKU_GEOPARSE_PROMPT = """Extrahiere alle geographischen Orte aus diesen Nachrichten-Headlines.
Kontext der Lage: "{incident_context}" Kontext der Lage: "{incident_context}"
@@ -222,9 +306,9 @@ Regeln:
- Regionen wie "Middle East", "Gulf", "Naher Osten" NICHT extrahieren (kein einzelner Punkt auf der Karte) - Regionen wie "Middle East", "Gulf", "Naher Osten" NICHT extrahieren (kein einzelner Punkt auf der Karte)
Klassifiziere basierend auf dem Lage-Kontext: Klassifiziere basierend auf dem Lage-Kontext:
- "target": Wo das Ereignis passiert / Schaden entsteht - "primary": Wo das Hauptgeschehen stattfindet (z.B. Angriffsziele, Katastrophenzone, Wahlkreise)
- "response": Wo Reaktionen / Gegenmassnahmen stattfinden - "secondary": Direkte Reaktionen oder Gegenmassnahmen (z.B. Vergeltung, Hilfsoperationen)
- "actor": Wo Entscheidungen getroffen werden / Entscheider sitzen - "tertiary": Entscheidungstraeger, Beteiligte (z.B. wo Entscheidungen getroffen werden)
- "mentioned": Nur erwaehnt, kein direkter Bezug - "mentioned": Nur erwaehnt, kein direkter Bezug
Headlines: Headlines:
@@ -233,7 +317,7 @@ Headlines:
Antwort NUR als JSON-Array, kein anderer Text: Antwort NUR als JSON-Array, kein anderer Text:
[{{"headline_idx": 0, "locations": [ [{{"headline_idx": 0, "locations": [
{{"name": "Teheran", "normalized": "Tehran", "country_code": "IR", {{"name": "Teheran", "normalized": "Tehran", "country_code": "IR",
"type": "city", "category": "target", "type": "city", "category": "primary",
"lat": 35.69, "lon": 51.42}} "lat": 35.69, "lon": 51.42}}
]}}]""" ]}}]"""
@@ -314,12 +398,19 @@ async def _extract_locations_haiku(
if not name: if not name:
continue continue
raw_cat = loc.get("category", "mentioned")
# Alte Kategorien mappen (falls Haiku sie noch generiert)
cat_map = {"target": "primary", "response": "secondary", "retaliation": "secondary", "actor": "tertiary", "context": "tertiary"}
category = cat_map.get(raw_cat, raw_cat)
if category not in ("primary", "secondary", "tertiary", "mentioned"):
category = "mentioned"
article_locs.append({ article_locs.append({
"name": name, "name": name,
"normalized": loc.get("normalized", name), "normalized": loc.get("normalized", name),
"country_code": loc.get("country_code", ""), "country_code": loc.get("country_code", ""),
"type": loc_type, "type": loc_type,
"category": loc.get("category", "mentioned"), "category": category,
"lat": loc.get("lat"), "lat": loc.get("lat"),
"lon": loc.get("lon"), "lon": loc.get("lon"),
}) })
@@ -333,7 +424,7 @@ async def _extract_locations_haiku(
async def geoparse_articles( async def geoparse_articles(
articles: list[dict], articles: list[dict],
incident_context: str = "", incident_context: str = "",
) -> dict[int, list[dict]]: ) -> tuple[dict[int, list[dict]], dict[str, str | None] | None]:
"""Geoparsing fuer eine Liste von Artikeln via Haiku + geonamescache. """Geoparsing fuer eine Liste von Artikeln via Haiku + geonamescache.
Args: Args:
@@ -341,11 +432,15 @@ async def geoparse_articles(
incident_context: Lage-Kontext (Titel + Beschreibung) fuer kontextbewusste Klassifizierung incident_context: Lage-Kontext (Titel + Beschreibung) fuer kontextbewusste Klassifizierung
Returns: Returns:
dict[article_id -> list[{location_name, location_name_normalized, country_code, Tuple von (dict[article_id -> list[locations]], category_labels oder None)
lat, lon, confidence, source_text, category}]]
""" """
if not articles: if not articles:
return {} return {}, None
# Labels parallel zum Geoparsing generieren (nur wenn Kontext vorhanden)
labels_task = None
if incident_context:
labels_task = asyncio.create_task(generate_category_labels(incident_context))
# Headlines sammeln # Headlines sammeln
headlines = [] headlines = []
@@ -363,7 +458,13 @@ async def geoparse_articles(
headlines.append({"idx": article_id, "text": headline}) headlines.append({"idx": article_id, "text": headline})
if not headlines: if not headlines:
return {} category_labels = None
if labels_task:
try:
category_labels = await labels_task
except Exception:
pass
return {}, category_labels
# Batches bilden (max 50 Headlines pro Haiku-Call) # Batches bilden (max 50 Headlines pro Haiku-Call)
batch_size = 50 batch_size = 50
@@ -374,7 +475,13 @@ async def geoparse_articles(
all_haiku_results.update(batch_results) all_haiku_results.update(batch_results)
if not all_haiku_results: if not all_haiku_results:
return {} category_labels = None
if labels_task:
try:
category_labels = await labels_task
except Exception:
pass
return {}, category_labels
# Geocoding via geonamescache (mit Haiku-Koordinaten als Fallback) # Geocoding via geonamescache (mit Haiku-Koordinaten als Fallback)
result = {} result = {}
@@ -406,4 +513,12 @@ async def geoparse_articles(
if locations: if locations:
result[article_id] = locations result[article_id] = locations
return result # Category-Labels abwarten
category_labels = None
if labels_task:
try:
category_labels = await labels_task
except Exception as e:
logger.warning(f"Category-Labels konnten nicht generiert werden: {e}")
return result, category_labels

Datei anzeigen

@@ -782,7 +782,7 @@ class AgentOrchestrator:
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(new_articles_for_analysis)} neue Artikel...")
geo_results = await geoparse_articles(new_articles_for_analysis, incident_context) geo_results, category_labels = await geoparse_articles(new_articles_for_analysis, 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:
@@ -799,6 +799,15 @@ class AgentOrchestrator:
if geo_count > 0: if geo_count > 0:
await db.commit() await db.commit()
logger.info(f"Geoparsing: {geo_count} Orte aus {len(geo_results)} Artikeln gespeichert") logger.info(f"Geoparsing: {geo_count} Orte aus {len(geo_results)} Artikeln gespeichert")
# Category-Labels in Incident speichern (nur wenn neu generiert)
if category_labels:
import json as _json
await db.execute(
"UPDATE incidents SET category_labels = ? WHERE id = ? AND category_labels IS NULL",
(_json.dumps(category_labels, ensure_ascii=False), incident_id),
)
await db.commit()
logger.info(f"Category-Labels gespeichert fuer Incident {incident_id}: {category_labels}")
except Exception as e: except Exception as e:
logger.warning(f"Geoparsing fehlgeschlagen (Pipeline laeuft weiter): {e}") logger.warning(f"Geoparsing fehlgeschlagen (Pipeline laeuft weiter): {e}")

Datei anzeigen

@@ -73,6 +73,7 @@ CREATE TABLE IF NOT EXISTS incidents (
summary TEXT, summary TEXT,
sources_json TEXT, sources_json TEXT,
international_sources INTEGER DEFAULT 1, international_sources INTEGER DEFAULT 1,
category_labels TEXT,
tenant_id INTEGER REFERENCES organizations(id), tenant_id INTEGER REFERENCES organizations(id),
created_by INTEGER REFERENCES users(id), created_by INTEGER REFERENCES users(id),
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
@@ -269,6 +270,10 @@ async def init_db():
await db.commit() await db.commit()
logger.info("Migration: telegram_categories zu incidents hinzugefuegt") logger.info("Migration: telegram_categories zu incidents hinzugefuegt")
if "category_labels" not in columns:
await db.execute("ALTER TABLE incidents ADD COLUMN category_labels TEXT")
await db.commit()
logger.info("Migration: category_labels zu incidents hinzugefuegt")
if "tenant_id" not in columns: if "tenant_id" not in columns:
await db.execute("ALTER TABLE incidents ADD COLUMN tenant_id INTEGER REFERENCES organizations(id)") await db.execute("ALTER TABLE incidents ADD COLUMN tenant_id INTEGER REFERENCES organizations(id)")
@@ -415,6 +420,24 @@ async def init_db():
await db.commit() await db.commit()
logger.info("Migration: category zu article_locations hinzugefuegt") logger.info("Migration: category zu article_locations hinzugefuegt")
# Migration: Alte Kategorie-Werte auf neue Keys umbenennen
try:
await db.execute(
"UPDATE article_locations SET category = 'primary' WHERE category = 'target'"
)
await db.execute(
"UPDATE article_locations SET category = 'secondary' WHERE category IN ('response', 'retaliation')"
)
await db.execute(
"UPDATE article_locations SET category = 'tertiary' WHERE category IN ('actor', 'context')"
)
changed = db.total_changes
await db.commit()
if changed > 0:
logger.info("Migration: article_locations Kategorien umbenannt (target->primary, response/retaliation->secondary, actor->tertiary)")
except Exception:
pass # Bereits migriert oder keine Daten
# Migration: tenant_id fuer incident_snapshots # Migration: tenant_id fuer incident_snapshots
cursor = await db.execute("PRAGMA table_info(incident_snapshots)") cursor = await db.execute("PRAGMA table_info(incident_snapshots)")
snap_columns2 = [row[1] for row in await cursor.fetchall()] snap_columns2 = [row[1] for row in await cursor.fetchall()]

Datei anzeigen

@@ -338,8 +338,8 @@ async def get_locations(
"source_url": row["source_url"], "source_url": row["source_url"],
}) })
# Dominanteste Kategorie pro Ort bestimmen (Prioritaet: target > retaliation > actor > mentioned) # Dominanteste Kategorie pro Ort bestimmen (Prioritaet: primary > secondary > tertiary > mentioned)
priority = {"target": 4, "retaliation": 3, "actor": 2, "mentioned": 1} priority = {"primary": 4, "secondary": 3, "tertiary": 2, "mentioned": 1}
result = [] result = []
for loc in loc_map.values(): for loc in loc_map.values():
cats = loc.pop("categories") cats = loc.pop("categories")
@@ -349,7 +349,20 @@ async def get_locations(
best_cat = "mentioned" best_cat = "mentioned"
loc["category"] = best_cat loc["category"] = best_cat
result.append(loc) result.append(loc)
return result
# Category-Labels aus Incident laden
cursor = await db.execute(
"SELECT category_labels FROM incidents WHERE id = ?", (incident_id,)
)
inc_row = await cursor.fetchone()
category_labels = None
if inc_row and inc_row["category_labels"]:
try:
category_labels = json.loads(inc_row["category_labels"])
except (json.JSONDecodeError, TypeError):
pass
return {"category_labels": category_labels, "locations": result}
# Geoparse-Status pro Incident (in-memory) # Geoparse-Status pro Incident (in-memory)
@@ -395,8 +408,23 @@ async def _run_geoparse_background(incident_id: int, tenant_id: int | None):
processed = 0 processed = 0
for i in range(0, total, batch_size): for i in range(0, total, batch_size):
batch = articles[i:i + batch_size] batch = articles[i:i + batch_size]
geo_results = await geoparse_articles(batch, incident_context) geo_result = await geoparse_articles(batch, incident_context)
for art_id, locations in geo_results.items(): # Tuple-Rückgabe: (locations_dict, category_labels)
if isinstance(geo_result, tuple):
batch_geo_results, batch_labels = geo_result
# Labels beim ersten Batch speichern
if batch_labels and i == 0:
try:
await db.execute(
"UPDATE incidents SET category_labels = ? WHERE id = ? AND category_labels IS NULL",
(json.dumps(batch_labels, ensure_ascii=False), incident_id),
)
await db.commit()
except Exception:
pass
else:
batch_geo_results = geo_result
for art_id, locations in batch_geo_results.items():
for loc in locations: for loc in locations:
await db.execute( await db.execute(
"""INSERT INTO article_locations """INSERT INTO article_locations

Datei anzeigen

@@ -64,6 +64,14 @@ async def get_lagebild(db=Depends(db_dependency)):
raise HTTPException(status_code=404, detail="Incident not found") raise HTTPException(status_code=404, detail="Incident not found")
incident = dict(incident) incident = dict(incident)
# Category-Labels laden
category_labels = None
if incident.get("category_labels"):
try:
category_labels = json.loads(incident["category_labels"])
except (json.JSONDecodeError, TypeError):
pass
# Alle Artikel aus allen Iran-Incidents laden # Alle Artikel aus allen Iran-Incidents laden
cursor = await db.execute( cursor = await db.execute(
f"""SELECT id, headline, headline_de, source, source_url, language, f"""SELECT id, headline, headline_de, source, source_url, language,
@@ -148,6 +156,7 @@ async def get_lagebild(db=Depends(db_dependency)):
"fact_checks": fact_checks, "fact_checks": fact_checks,
"available_snapshots": available_snapshots, "available_snapshots": available_snapshots,
"locations": locations, "locations": locations,
"category_labels": category_labels,
} }

Datei anzeigen

@@ -218,29 +218,29 @@ Du bist ein Geopolitik-Experte fuer einen OSINT-Monitor.
LAGE: {incident_title} LAGE: {incident_title}
BESCHREIBUNG: {incident_desc} BESCHREIBUNG: {incident_desc}
{labels_context}
Unten stehen Orte, die auf der Karte als "target" (Angriffsziel) markiert sind. Unten stehen Orte, die auf der Karte als "primary" (Hauptgeschehen) markiert sind.
Pruefe fuer jeden Ort, ob die Kategorie "target" korrekt ist. Pruefe fuer jeden Ort, ob die Kategorie "primary" korrekt ist.
KATEGORIEN: KATEGORIEN:
- target: Ort wurde tatsaechlich militaerisch angegriffen oder bombardiert - primary: {label_primary} — Wo das Hauptgeschehen stattfindet
- actor: Ort gehoert zu einer Konfliktpartei (z.B. Hauptstadt des Angreifers) - secondary: {label_secondary} — Direkte Reaktionen/Gegenmassnahmen
- response: Ort reagiert auf den Konflikt (z.B. diplomatische Reaktion, Sanktionen) - tertiary: {label_tertiary} — Entscheidungstraeger/Beteiligte
- mentioned: Ort wird nur im Kontext erwaehnt (z.B. wirtschaftliche Auswirkungen) - mentioned: {label_mentioned} — Nur erwaehnt
REGELN: REGELN:
- Nur Orte die TATSAECHLICH physisch angegriffen/bombardiert wurden = "target" - Nur Orte die DIREKT vom Hauptgeschehen betroffen sind = "primary"
- Hauptstaedte von Angreiferlaendern (z.B. Washington DC) = "actor" - Orte mit Reaktionen/Gegenmassnahmen = "secondary"
- Laender die nur wirtschaftlich betroffen sind (z.B. steigende Oelpreise) = "mentioned" - Orte von Entscheidungstraegern (z.B. Hauptstaedte) = "tertiary"
- Laender die diplomatisch reagieren = "response" - Nur erwaehnte Orte = "mentioned"
- Im Zweifel: "mentioned" - Im Zweifel: "mentioned"
Antworte als JSON-Array mit Korrekturen. Nur Eintraege die GEAENDERT werden muessen: Antworte als JSON-Array mit Korrekturen. Nur Eintraege die GEAENDERT werden muessen:
[{{"id": 123, "category": "mentioned"}}, {{"id": 456, "category": "actor"}}] [{{"id": 123, "category": "mentioned"}}, {{"id": 456, "category": "tertiary"}}]
Wenn alle Kategorien korrekt sind: antworte mit [] Wenn alle Kategorien korrekt sind: antworte mit []
ORTE (aktuell alle als "target" markiert): ORTE (aktuell alle als "primary" markiert):
{locations_text}""" {locations_text}"""
@@ -253,7 +253,7 @@ async def check_location_categories(
""" """
cursor = await db.execute( cursor = await db.execute(
"SELECT id, location_name, latitude, longitude, category " "SELECT id, location_name, latitude, longitude, category "
"FROM article_locations WHERE incident_id = ? AND category = 'target'", "FROM article_locations WHERE incident_id = ? AND category = 'primary'",
(incident_id,), (incident_id,),
) )
targets = [dict(row) for row in await cursor.fetchall()] targets = [dict(row) for row in await cursor.fetchall()]
@@ -261,6 +261,27 @@ async def check_location_categories(
if not targets: if not targets:
return 0 return 0
# Category-Labels aus DB laden (fuer kontextabhaengige Prompt-Beschreibungen)
cursor = await db.execute(
"SELECT category_labels FROM incidents WHERE id = ?", (incident_id,)
)
inc_row = await cursor.fetchone()
labels = {}
if inc_row and inc_row["category_labels"]:
try:
labels = json.loads(inc_row["category_labels"])
except (json.JSONDecodeError, TypeError):
pass
label_primary = labels.get("primary") or "Hauptgeschehen"
label_secondary = labels.get("secondary") or "Reaktionen"
label_tertiary = labels.get("tertiary") or "Beteiligte"
label_mentioned = labels.get("mentioned") or "Erwaehnt"
labels_context = ""
if labels:
labels_context = f"KATEGORIE-LABELS: primary={label_primary}, secondary={label_secondary}, tertiary={label_tertiary}, mentioned={label_mentioned}\n"
# Dedupliziere nach location_name fuer den Prompt (spart Tokens) # Dedupliziere nach location_name fuer den Prompt (spart Tokens)
unique_names = {} unique_names = {}
ids_by_name = {} ids_by_name = {}
@@ -279,6 +300,11 @@ async def check_location_categories(
prompt = _LOCATION_PROMPT.format( prompt = _LOCATION_PROMPT.format(
incident_title=incident_title, incident_title=incident_title,
incident_desc=incident_desc[:500] if incident_desc else "(keine Beschreibung)", incident_desc=incident_desc[:500] if incident_desc else "(keine Beschreibung)",
labels_context=labels_context,
label_primary=label_primary,
label_secondary=label_secondary,
label_tertiary=label_tertiary,
label_mentioned=label_mentioned,
locations_text=locations_text, locations_text=locations_text,
) )
@@ -314,7 +340,7 @@ async def check_location_categories(
new_cat = fix.get("category") new_cat = fix.get("category")
if not fix_id or not new_cat: if not fix_id or not new_cat:
continue continue
if new_cat not in ("target", "actor", "response", "mentioned"): if new_cat not in ("primary", "secondary", "tertiary", "mentioned"):
continue continue
# Finde den location_name fuer diese ID # Finde den location_name fuer diese ID
@@ -327,12 +353,12 @@ async def check_location_categories(
placeholders = ",".join("?" * len(all_ids)) placeholders = ",".join("?" * len(all_ids))
await db.execute( await db.execute(
f"UPDATE article_locations SET category = ? " f"UPDATE article_locations SET category = ? "
f"WHERE id IN ({placeholders}) AND category = 'target'", f"WHERE id IN ({placeholders}) AND category = 'primary'",
[new_cat] + all_ids, [new_cat] + all_ids,
) )
total_fixed += len(all_ids) total_fixed += len(all_ids)
logger.info( logger.info(
"QC Location: '%s' (%d Eintraege): target -> %s", "QC Location: '%s' (%d Eintraege): primary -> %s",
loc_name, len(all_ids), new_cat, loc_name, len(all_ids), new_cat,
) )
@@ -346,7 +372,7 @@ async def check_location_categories(
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
# 3. Hauptfunktion # Hauptfunktion
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
async def run_post_refresh_qc(db, incident_id: int) -> dict: async def run_post_refresh_qc(db, incident_id: int) -> dict:

Datei anzeigen

@@ -698,7 +698,7 @@ const App = {
async loadIncidentDetail(id) { async loadIncidentDetail(id) {
try { try {
const [incident, articles, factchecks, snapshots, locations] = await Promise.all([ const [incident, articles, factchecks, snapshots, locationsResponse] = await Promise.all([
API.getIncident(id), API.getIncident(id),
API.getArticles(id), API.getArticles(id),
API.getFactChecks(id), API.getFactChecks(id),
@@ -706,14 +706,27 @@ const App = {
API.getLocations(id).catch(() => []), API.getLocations(id).catch(() => []),
]); ]);
this.renderIncidentDetail(incident, articles, factchecks, snapshots, locations); // Locations-API gibt jetzt {category_labels, locations} oder Array (Rückwärtskompatibel)
let locations, categoryLabels;
if (Array.isArray(locationsResponse)) {
locations = locationsResponse;
categoryLabels = null;
} else if (locationsResponse && locationsResponse.locations) {
locations = locationsResponse.locations;
categoryLabels = locationsResponse.category_labels || null;
} else {
locations = [];
categoryLabels = null;
}
this.renderIncidentDetail(incident, articles, factchecks, snapshots, locations, categoryLabels);
} catch (err) { } catch (err) {
console.error('loadIncidentDetail Fehler:', err); console.error('loadIncidentDetail Fehler:', err);
UI.showToast('Fehler beim Laden: ' + err.message, 'error'); UI.showToast('Fehler beim Laden: ' + err.message, 'error');
} }
}, },
renderIncidentDetail(incident, articles, factchecks, snapshots, locations) { renderIncidentDetail(incident, articles, factchecks, snapshots, locations, categoryLabels) {
// Header Strip // Header Strip
{ const _e = document.getElementById('incident-title'); if (_e) _e.textContent = incident.title; } { const _e = document.getElementById('incident-title'); if (_e) _e.textContent = incident.title; }
{ const _e = document.getElementById('incident-description'); if (_e) _e.textContent = incident.description || ''; } { const _e = document.getElementById('incident-description'); if (_e) _e.textContent = incident.description || ''; }
@@ -845,7 +858,7 @@ const App = {
this._resizeTimelineTile(); this._resizeTimelineTile();
// Karte rendern // Karte rendern
UI.renderMap(locations || []); UI.renderMap(locations || [], categoryLabels);
}, },
_collectEntries(filterType, searchTerm, range) { _collectEntries(filterType, searchTerm, range) {
@@ -1617,8 +1630,12 @@ const App = {
if (btn) { btn.disabled = false; btn.textContent = 'Orte erkennen'; } if (btn) { btn.disabled = false; btn.textContent = 'Orte erkennen'; }
if (st.status === 'done' && st.locations > 0) { if (st.status === 'done' && st.locations > 0) {
UI.showToast(`${st.locations} Orte aus ${st.processed} Artikeln erkannt`, 'success'); UI.showToast(`${st.locations} Orte aus ${st.processed} Artikeln erkannt`, 'success');
const locations = await API.getLocations(incidentId).catch(() => []); const locResp = await API.getLocations(incidentId).catch(() => []);
UI.renderMap(locations); let locs, catLabels;
if (Array.isArray(locResp)) { locs = locResp; catLabels = null; }
else if (locResp && locResp.locations) { locs = locResp.locations; catLabels = locResp.category_labels || null; }
else { locs = []; catLabels = null; }
UI.renderMap(locs, catLabels);
} else if (st.status === 'done') { } else if (st.status === 'done') {
UI.showToast('Keine neuen Orte gefunden', 'info'); UI.showToast('Keine neuen Orte gefunden', 'info');
} else if (st.status === 'error') { } else if (st.status === 'error') {

Datei anzeigen

@@ -639,30 +639,29 @@ const UI = {
_initMarkerIcons() { _initMarkerIcons() {
if (this._markerIcons || typeof L === 'undefined') return; if (this._markerIcons || typeof L === 'undefined') return;
this._markerIcons = { this._markerIcons = {
target: this._createSvgIcon('#dc3545', '#a71d2a'), primary: this._createSvgIcon('#dc3545', '#a71d2a'),
retaliation: this._createSvgIcon('#f39c12', '#c47d0a'), secondary: this._createSvgIcon('#f39c12', '#c47d0a'),
response: this._createSvgIcon('#f39c12', '#c47d0a'), tertiary: this._createSvgIcon('#2a81cb', '#1a5c8f'),
actor: this._createSvgIcon('#2a81cb', '#1a5c8f'),
mentioned: this._createSvgIcon('#7b7b7b', '#555555'), mentioned: this._createSvgIcon('#7b7b7b', '#555555'),
}; };
}, },
_categoryLabels: { _defaultCategoryLabels: {
target: 'Angegriffene Ziele', primary: 'Hauptgeschehen',
retaliation: 'Vergeltung / Eskalation', secondary: 'Reaktionen',
response: 'Reaktion / Gegenmassnahmen', tertiary: 'Beteiligte',
actor: 'Strategische Akteure',
mentioned: 'Erwaehnt', mentioned: 'Erwaehnt',
}, },
_categoryColors: { _categoryColors: {
target: '#cb2b3e', primary: '#cb2b3e',
retaliation: '#f39c12', secondary: '#f39c12',
response: '#f39c12', tertiary: '#2a81cb',
actor: '#2a81cb',
mentioned: '#7b7b7b', mentioned: '#7b7b7b',
}, },
renderMap(locations) { _activeCategoryLabels: null,
renderMap(locations, categoryLabels) {
const container = document.getElementById('map-container'); const container = document.getElementById('map-container');
const emptyEl = document.getElementById('map-empty'); const emptyEl = document.getElementById('map-empty');
const statsEl = document.getElementById('map-stats'); const statsEl = document.getElementById('map-stats');
@@ -741,6 +740,9 @@ const UI = {
// Marker hinzufuegen // Marker hinzufuegen
const bounds = []; const bounds = [];
this._initMarkerIcons(); this._initMarkerIcons();
// Dynamische Labels verwenden (API > Default)
const catLabels = categoryLabels || this._activeCategoryLabels || this._defaultCategoryLabels;
this._activeCategoryLabels = catLabels;
const usedCategories = new Set(); const usedCategories = new Set();
locations.forEach(loc => { locations.forEach(loc => {
@@ -751,7 +753,7 @@ const UI = {
const marker = L.marker([loc.lat, loc.lon], markerOpts); const marker = L.marker([loc.lat, loc.lon], markerOpts);
// Popup-Inhalt // Popup-Inhalt
const catLabel = this._categoryLabels[cat] || cat; const catLabel = catLabels[cat] || this._defaultCategoryLabels[cat] || cat;
const catColor = this._categoryColors[cat] || '#7b7b7b'; const catColor = this._categoryColors[cat] || '#7b7b7b';
let popupHtml = `<div class="map-popup">`; let popupHtml = `<div class="map-popup">`;
popupHtml += `<div class="map-popup-title">${this.escape(loc.location_name)}`; popupHtml += `<div class="map-popup-title">${this.escape(loc.location_name)}`;
@@ -798,12 +800,13 @@ const UI = {
const legend = L.control({ position: 'bottomright' }); const legend = L.control({ position: 'bottomright' });
const self2 = this; const self2 = this;
const legendLabels = catLabels;
legend.onAdd = function() { legend.onAdd = function() {
const div = L.DomUtil.create('div', 'map-legend-ctrl'); const div = L.DomUtil.create('div', 'map-legend-ctrl');
let html = '<strong style="display:block;margin-bottom:6px;">Legende</strong>'; let html = '<strong style="display:block;margin-bottom:6px;">Legende</strong>';
['target', 'retaliation', 'response', 'actor', 'mentioned'].forEach(cat => { ['primary', 'secondary', 'tertiary', 'mentioned'].forEach(cat => {
if (usedCategories.has(cat)) { if (usedCategories.has(cat) && legendLabels[cat]) {
html += `<div style="display:flex;align-items:center;gap:6px;margin:3px 0;"><span style="width:10px;height:10px;border-radius:50%;background:${self2._categoryColors[cat]};flex-shrink:0;"></span><span>${self2._categoryLabels[cat]}</span></div>`; html += `<div style="display:flex;align-items:center;gap:6px;margin:3px 0;"><span style="width:10px;height:10px;border-radius:50%;background:${self2._categoryColors[cat]};flex-shrink:0;"></span><span>${legendLabels[cat]}</span></div>`;
} }
}); });
div.innerHTML = html; div.innerHTML = html;
@@ -853,7 +856,7 @@ const UI = {
if (this._pendingLocations && typeof L !== 'undefined') { if (this._pendingLocations && typeof L !== 'undefined') {
const locs = this._pendingLocations; const locs = this._pendingLocations;
this._pendingLocations = null; this._pendingLocations = null;
this.renderMap(locs); this.renderMap(locs, this._activeCategoryLabels);
} }
}, },