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:
73
migrate_category_labels.py
Normale Datei
73
migrate_category_labels.py
Normale Datei
@@ -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())
|
||||
@@ -209,6 +209,90 @@ def _geocode_location(name: str, country_code: str = "", haiku_coords: Optional[
|
||||
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.
|
||||
|
||||
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)
|
||||
|
||||
Klassifiziere basierend auf dem Lage-Kontext:
|
||||
- "target": Wo das Ereignis passiert / Schaden entsteht
|
||||
- "response": Wo Reaktionen / Gegenmassnahmen stattfinden
|
||||
- "actor": Wo Entscheidungen getroffen werden / Entscheider sitzen
|
||||
- "primary": Wo das Hauptgeschehen stattfindet (z.B. Angriffsziele, Katastrophenzone, Wahlkreise)
|
||||
- "secondary": Direkte Reaktionen oder Gegenmassnahmen (z.B. Vergeltung, Hilfsoperationen)
|
||||
- "tertiary": Entscheidungstraeger, Beteiligte (z.B. wo Entscheidungen getroffen werden)
|
||||
- "mentioned": Nur erwaehnt, kein direkter Bezug
|
||||
|
||||
Headlines:
|
||||
@@ -233,7 +317,7 @@ Headlines:
|
||||
Antwort NUR als JSON-Array, kein anderer Text:
|
||||
[{{"headline_idx": 0, "locations": [
|
||||
{{"name": "Teheran", "normalized": "Tehran", "country_code": "IR",
|
||||
"type": "city", "category": "target",
|
||||
"type": "city", "category": "primary",
|
||||
"lat": 35.69, "lon": 51.42}}
|
||||
]}}]"""
|
||||
|
||||
@@ -314,12 +398,19 @@ async def _extract_locations_haiku(
|
||||
if not name:
|
||||
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({
|
||||
"name": name,
|
||||
"normalized": loc.get("normalized", name),
|
||||
"country_code": loc.get("country_code", ""),
|
||||
"type": loc_type,
|
||||
"category": loc.get("category", "mentioned"),
|
||||
"category": category,
|
||||
"lat": loc.get("lat"),
|
||||
"lon": loc.get("lon"),
|
||||
})
|
||||
@@ -333,7 +424,7 @@ async def _extract_locations_haiku(
|
||||
async def geoparse_articles(
|
||||
articles: list[dict],
|
||||
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.
|
||||
|
||||
Args:
|
||||
@@ -341,11 +432,15 @@ async def geoparse_articles(
|
||||
incident_context: Lage-Kontext (Titel + Beschreibung) fuer kontextbewusste Klassifizierung
|
||||
|
||||
Returns:
|
||||
dict[article_id -> list[{location_name, location_name_normalized, country_code,
|
||||
lat, lon, confidence, source_text, category}]]
|
||||
Tuple von (dict[article_id -> list[locations]], category_labels oder None)
|
||||
"""
|
||||
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 = []
|
||||
@@ -363,7 +458,13 @@ async def geoparse_articles(
|
||||
headlines.append({"idx": article_id, "text": headline})
|
||||
|
||||
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)
|
||||
batch_size = 50
|
||||
@@ -374,7 +475,13 @@ async def geoparse_articles(
|
||||
all_haiku_results.update(batch_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)
|
||||
result = {}
|
||||
@@ -406,4 +513,12 @@ async def geoparse_articles(
|
||||
if 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
|
||||
|
||||
@@ -782,7 +782,7 @@ class AgentOrchestrator:
|
||||
from agents.geoparsing import geoparse_articles
|
||||
incident_context = f"{title} - {description}"
|
||||
logger.info(f"Geoparsing fuer {len(new_articles_for_analysis)} neue Artikel...")
|
||||
geo_results = 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
|
||||
for art_id, locations in geo_results.items():
|
||||
for loc in locations:
|
||||
@@ -799,6 +799,15 @@ class AgentOrchestrator:
|
||||
if geo_count > 0:
|
||||
await db.commit()
|
||||
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:
|
||||
logger.warning(f"Geoparsing fehlgeschlagen (Pipeline laeuft weiter): {e}")
|
||||
|
||||
|
||||
@@ -73,6 +73,7 @@ CREATE TABLE IF NOT EXISTS incidents (
|
||||
summary TEXT,
|
||||
sources_json TEXT,
|
||||
international_sources INTEGER DEFAULT 1,
|
||||
category_labels TEXT,
|
||||
tenant_id INTEGER REFERENCES organizations(id),
|
||||
created_by INTEGER REFERENCES users(id),
|
||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
@@ -269,6 +270,10 @@ async def init_db():
|
||||
await db.commit()
|
||||
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:
|
||||
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()
|
||||
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
|
||||
cursor = await db.execute("PRAGMA table_info(incident_snapshots)")
|
||||
snap_columns2 = [row[1] for row in await cursor.fetchall()]
|
||||
|
||||
@@ -338,8 +338,8 @@ async def get_locations(
|
||||
"source_url": row["source_url"],
|
||||
})
|
||||
|
||||
# Dominanteste Kategorie pro Ort bestimmen (Prioritaet: target > retaliation > actor > mentioned)
|
||||
priority = {"target": 4, "retaliation": 3, "actor": 2, "mentioned": 1}
|
||||
# Dominanteste Kategorie pro Ort bestimmen (Prioritaet: primary > secondary > tertiary > mentioned)
|
||||
priority = {"primary": 4, "secondary": 3, "tertiary": 2, "mentioned": 1}
|
||||
result = []
|
||||
for loc in loc_map.values():
|
||||
cats = loc.pop("categories")
|
||||
@@ -349,7 +349,20 @@ async def get_locations(
|
||||
best_cat = "mentioned"
|
||||
loc["category"] = best_cat
|
||||
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)
|
||||
@@ -395,8 +408,23 @@ async def _run_geoparse_background(incident_id: int, tenant_id: int | None):
|
||||
processed = 0
|
||||
for i in range(0, total, batch_size):
|
||||
batch = articles[i:i + batch_size]
|
||||
geo_results = await geoparse_articles(batch, incident_context)
|
||||
for art_id, locations in geo_results.items():
|
||||
geo_result = await geoparse_articles(batch, incident_context)
|
||||
# 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:
|
||||
await db.execute(
|
||||
"""INSERT INTO article_locations
|
||||
|
||||
@@ -64,6 +64,14 @@ async def get_lagebild(db=Depends(db_dependency)):
|
||||
raise HTTPException(status_code=404, detail="Incident not found")
|
||||
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
|
||||
cursor = await db.execute(
|
||||
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,
|
||||
"available_snapshots": available_snapshots,
|
||||
"locations": locations,
|
||||
"category_labels": category_labels,
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -218,29 +218,29 @@ Du bist ein Geopolitik-Experte fuer einen OSINT-Monitor.
|
||||
|
||||
LAGE: {incident_title}
|
||||
BESCHREIBUNG: {incident_desc}
|
||||
|
||||
Unten stehen Orte, die auf der Karte als "target" (Angriffsziel) markiert sind.
|
||||
Pruefe fuer jeden Ort, ob die Kategorie "target" korrekt ist.
|
||||
{labels_context}
|
||||
Unten stehen Orte, die auf der Karte als "primary" (Hauptgeschehen) markiert sind.
|
||||
Pruefe fuer jeden Ort, ob die Kategorie "primary" korrekt ist.
|
||||
|
||||
KATEGORIEN:
|
||||
- target: Ort wurde tatsaechlich militaerisch angegriffen oder bombardiert
|
||||
- actor: Ort gehoert zu einer Konfliktpartei (z.B. Hauptstadt des Angreifers)
|
||||
- response: Ort reagiert auf den Konflikt (z.B. diplomatische Reaktion, Sanktionen)
|
||||
- mentioned: Ort wird nur im Kontext erwaehnt (z.B. wirtschaftliche Auswirkungen)
|
||||
- primary: {label_primary} — Wo das Hauptgeschehen stattfindet
|
||||
- secondary: {label_secondary} — Direkte Reaktionen/Gegenmassnahmen
|
||||
- tertiary: {label_tertiary} — Entscheidungstraeger/Beteiligte
|
||||
- mentioned: {label_mentioned} — Nur erwaehnt
|
||||
|
||||
REGELN:
|
||||
- Nur Orte die TATSAECHLICH physisch angegriffen/bombardiert wurden = "target"
|
||||
- Hauptstaedte von Angreiferlaendern (z.B. Washington DC) = "actor"
|
||||
- Laender die nur wirtschaftlich betroffen sind (z.B. steigende Oelpreise) = "mentioned"
|
||||
- Laender die diplomatisch reagieren = "response"
|
||||
- Nur Orte die DIREKT vom Hauptgeschehen betroffen sind = "primary"
|
||||
- Orte mit Reaktionen/Gegenmassnahmen = "secondary"
|
||||
- Orte von Entscheidungstraegern (z.B. Hauptstaedte) = "tertiary"
|
||||
- Nur erwaehnte Orte = "mentioned"
|
||||
- Im Zweifel: "mentioned"
|
||||
|
||||
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 []
|
||||
|
||||
ORTE (aktuell alle als "target" markiert):
|
||||
ORTE (aktuell alle als "primary" markiert):
|
||||
{locations_text}"""
|
||||
|
||||
|
||||
@@ -253,7 +253,7 @@ async def check_location_categories(
|
||||
"""
|
||||
cursor = await db.execute(
|
||||
"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,),
|
||||
)
|
||||
targets = [dict(row) for row in await cursor.fetchall()]
|
||||
@@ -261,6 +261,27 @@ async def check_location_categories(
|
||||
if not targets:
|
||||
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)
|
||||
unique_names = {}
|
||||
ids_by_name = {}
|
||||
@@ -279,6 +300,11 @@ async def check_location_categories(
|
||||
prompt = _LOCATION_PROMPT.format(
|
||||
incident_title=incident_title,
|
||||
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,
|
||||
)
|
||||
|
||||
@@ -314,7 +340,7 @@ async def check_location_categories(
|
||||
new_cat = fix.get("category")
|
||||
if not fix_id or not new_cat:
|
||||
continue
|
||||
if new_cat not in ("target", "actor", "response", "mentioned"):
|
||||
if new_cat not in ("primary", "secondary", "tertiary", "mentioned"):
|
||||
continue
|
||||
|
||||
# Finde den location_name fuer diese ID
|
||||
@@ -327,12 +353,12 @@ async def check_location_categories(
|
||||
placeholders = ",".join("?" * len(all_ids))
|
||||
await db.execute(
|
||||
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,
|
||||
)
|
||||
total_fixed += len(all_ids)
|
||||
logger.info(
|
||||
"QC Location: '%s' (%d Eintraege): target -> %s",
|
||||
"QC Location: '%s' (%d Eintraege): primary -> %s",
|
||||
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:
|
||||
|
||||
@@ -698,7 +698,7 @@ const App = {
|
||||
|
||||
async loadIncidentDetail(id) {
|
||||
try {
|
||||
const [incident, articles, factchecks, snapshots, locations] = await Promise.all([
|
||||
const [incident, articles, factchecks, snapshots, locationsResponse] = await Promise.all([
|
||||
API.getIncident(id),
|
||||
API.getArticles(id),
|
||||
API.getFactChecks(id),
|
||||
@@ -706,14 +706,27 @@ const App = {
|
||||
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) {
|
||||
console.error('loadIncidentDetail Fehler:', err);
|
||||
UI.showToast('Fehler beim Laden: ' + err.message, 'error');
|
||||
}
|
||||
},
|
||||
|
||||
renderIncidentDetail(incident, articles, factchecks, snapshots, locations) {
|
||||
renderIncidentDetail(incident, articles, factchecks, snapshots, locations, categoryLabels) {
|
||||
// Header Strip
|
||||
{ const _e = document.getElementById('incident-title'); if (_e) _e.textContent = incident.title; }
|
||||
{ const _e = document.getElementById('incident-description'); if (_e) _e.textContent = incident.description || ''; }
|
||||
@@ -845,7 +858,7 @@ const App = {
|
||||
this._resizeTimelineTile();
|
||||
|
||||
// Karte rendern
|
||||
UI.renderMap(locations || []);
|
||||
UI.renderMap(locations || [], categoryLabels);
|
||||
},
|
||||
|
||||
_collectEntries(filterType, searchTerm, range) {
|
||||
@@ -1617,8 +1630,12 @@ const App = {
|
||||
if (btn) { btn.disabled = false; btn.textContent = 'Orte erkennen'; }
|
||||
if (st.status === 'done' && st.locations > 0) {
|
||||
UI.showToast(`${st.locations} Orte aus ${st.processed} Artikeln erkannt`, 'success');
|
||||
const locations = await API.getLocations(incidentId).catch(() => []);
|
||||
UI.renderMap(locations);
|
||||
const locResp = await API.getLocations(incidentId).catch(() => []);
|
||||
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') {
|
||||
UI.showToast('Keine neuen Orte gefunden', 'info');
|
||||
} else if (st.status === 'error') {
|
||||
|
||||
@@ -639,30 +639,29 @@ const UI = {
|
||||
_initMarkerIcons() {
|
||||
if (this._markerIcons || typeof L === 'undefined') return;
|
||||
this._markerIcons = {
|
||||
target: this._createSvgIcon('#dc3545', '#a71d2a'),
|
||||
retaliation: this._createSvgIcon('#f39c12', '#c47d0a'),
|
||||
response: this._createSvgIcon('#f39c12', '#c47d0a'),
|
||||
actor: this._createSvgIcon('#2a81cb', '#1a5c8f'),
|
||||
primary: this._createSvgIcon('#dc3545', '#a71d2a'),
|
||||
secondary: this._createSvgIcon('#f39c12', '#c47d0a'),
|
||||
tertiary: this._createSvgIcon('#2a81cb', '#1a5c8f'),
|
||||
mentioned: this._createSvgIcon('#7b7b7b', '#555555'),
|
||||
};
|
||||
},
|
||||
|
||||
_categoryLabels: {
|
||||
target: 'Angegriffene Ziele',
|
||||
retaliation: 'Vergeltung / Eskalation',
|
||||
response: 'Reaktion / Gegenmassnahmen',
|
||||
actor: 'Strategische Akteure',
|
||||
_defaultCategoryLabels: {
|
||||
primary: 'Hauptgeschehen',
|
||||
secondary: 'Reaktionen',
|
||||
tertiary: 'Beteiligte',
|
||||
mentioned: 'Erwaehnt',
|
||||
},
|
||||
_categoryColors: {
|
||||
target: '#cb2b3e',
|
||||
retaliation: '#f39c12',
|
||||
response: '#f39c12',
|
||||
actor: '#2a81cb',
|
||||
primary: '#cb2b3e',
|
||||
secondary: '#f39c12',
|
||||
tertiary: '#2a81cb',
|
||||
mentioned: '#7b7b7b',
|
||||
},
|
||||
|
||||
renderMap(locations) {
|
||||
_activeCategoryLabels: null,
|
||||
|
||||
renderMap(locations, categoryLabels) {
|
||||
const container = document.getElementById('map-container');
|
||||
const emptyEl = document.getElementById('map-empty');
|
||||
const statsEl = document.getElementById('map-stats');
|
||||
@@ -741,6 +740,9 @@ const UI = {
|
||||
// Marker hinzufuegen
|
||||
const bounds = [];
|
||||
this._initMarkerIcons();
|
||||
// Dynamische Labels verwenden (API > Default)
|
||||
const catLabels = categoryLabels || this._activeCategoryLabels || this._defaultCategoryLabels;
|
||||
this._activeCategoryLabels = catLabels;
|
||||
const usedCategories = new Set();
|
||||
|
||||
locations.forEach(loc => {
|
||||
@@ -751,7 +753,7 @@ const UI = {
|
||||
const marker = L.marker([loc.lat, loc.lon], markerOpts);
|
||||
|
||||
// Popup-Inhalt
|
||||
const catLabel = this._categoryLabels[cat] || cat;
|
||||
const catLabel = catLabels[cat] || this._defaultCategoryLabels[cat] || cat;
|
||||
const catColor = this._categoryColors[cat] || '#7b7b7b';
|
||||
let popupHtml = `<div class="map-popup">`;
|
||||
popupHtml += `<div class="map-popup-title">${this.escape(loc.location_name)}`;
|
||||
@@ -798,12 +800,13 @@ const UI = {
|
||||
|
||||
const legend = L.control({ position: 'bottomright' });
|
||||
const self2 = this;
|
||||
const legendLabels = catLabels;
|
||||
legend.onAdd = function() {
|
||||
const div = L.DomUtil.create('div', 'map-legend-ctrl');
|
||||
let html = '<strong style="display:block;margin-bottom:6px;">Legende</strong>';
|
||||
['target', 'retaliation', 'response', 'actor', 'mentioned'].forEach(cat => {
|
||||
if (usedCategories.has(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>`;
|
||||
['primary', 'secondary', 'tertiary', 'mentioned'].forEach(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>${legendLabels[cat]}</span></div>`;
|
||||
}
|
||||
});
|
||||
div.innerHTML = html;
|
||||
@@ -853,7 +856,7 @@ const UI = {
|
||||
if (this._pendingLocations && typeof L !== 'undefined') {
|
||||
const locs = this._pendingLocations;
|
||||
this._pendingLocations = null;
|
||||
this.renderMap(locs);
|
||||
this.renderMap(locs, this._activeCategoryLabels);
|
||||
}
|
||||
},
|
||||
|
||||
|
||||
In neuem Issue referenzieren
Einen Benutzer sperren