Commits vergleichen
11 Commits
e3f50e63fd
...
main
| Autor | SHA1 | Datum | |
|---|---|---|---|
|
|
6b4af4cf2a | ||
|
|
17088e588f | ||
|
|
97997724de | ||
|
|
acb3c6a6cb | ||
|
|
7bfa1d29cf | ||
|
|
4d6d022bee | ||
|
|
5e194d43e0 | ||
|
|
4b9ed6439a | ||
|
|
0b3fbb1efc | ||
|
|
474e2beca9 | ||
|
|
742f49467e |
234
regenerate_relations.py
Normale Datei
234
regenerate_relations.py
Normale Datei
@@ -0,0 +1,234 @@
|
|||||||
|
"""Regeneriert NUR die Beziehungen für eine bestehende Netzwerkanalyse.
|
||||||
|
Nutzt die vorhandenen Entitäten und führt Phase 2a + Phase 2 + Phase 2c + Phase 2d aus.
|
||||||
|
"""
|
||||||
|
import asyncio
|
||||||
|
import json
|
||||||
|
import sys
|
||||||
|
import os
|
||||||
|
|
||||||
|
sys.path.insert(0, "/home/claude-dev/AegisSight-Monitor/src")
|
||||||
|
|
||||||
|
from database import get_db
|
||||||
|
from agents.entity_extractor import (
|
||||||
|
_phase2a_deduplicate_entities,
|
||||||
|
_phase2_analyze_relationships,
|
||||||
|
_phase2c_semantic_dedup,
|
||||||
|
_phase2d_cleanup,
|
||||||
|
_build_entity_name_map,
|
||||||
|
_compute_data_hash,
|
||||||
|
_broadcast,
|
||||||
|
logger,
|
||||||
|
)
|
||||||
|
from agents.claude_client import UsageAccumulator
|
||||||
|
from config import TIMEZONE
|
||||||
|
from datetime import datetime
|
||||||
|
import logging
|
||||||
|
|
||||||
|
logging.basicConfig(
|
||||||
|
level=logging.INFO,
|
||||||
|
format="%(asctime)s [%(name)s] %(levelname)s: %(message)s",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
async def regenerate_relations_only(analysis_id: int):
|
||||||
|
"""Löscht alte Relations und führt Phase 2a + 2 + 2c + 2d neu aus."""
|
||||||
|
db = await get_db()
|
||||||
|
usage_acc = UsageAccumulator()
|
||||||
|
|
||||||
|
try:
|
||||||
|
# Analyse prüfen
|
||||||
|
cursor = await db.execute(
|
||||||
|
"SELECT id, name, tenant_id, entity_count FROM network_analyses WHERE id = ?",
|
||||||
|
(analysis_id,),
|
||||||
|
)
|
||||||
|
analysis = await cursor.fetchone()
|
||||||
|
if not analysis:
|
||||||
|
print(f"Analyse {analysis_id} nicht gefunden!")
|
||||||
|
return
|
||||||
|
|
||||||
|
tenant_id = analysis["tenant_id"]
|
||||||
|
print(f"\nAnalyse: {analysis['name']} (ID={analysis_id})")
|
||||||
|
print(f"Vorhandene Entitäten: {analysis['entity_count']}")
|
||||||
|
|
||||||
|
# Status auf generating setzen
|
||||||
|
await db.execute(
|
||||||
|
"UPDATE network_analyses SET status = 'generating' WHERE id = ?",
|
||||||
|
(analysis_id,),
|
||||||
|
)
|
||||||
|
await db.commit()
|
||||||
|
|
||||||
|
# Entitäten aus DB laden (mit db_id!)
|
||||||
|
cursor = await db.execute(
|
||||||
|
"""SELECT id, name, name_normalized, entity_type, description, aliases, mention_count
|
||||||
|
FROM network_entities WHERE network_analysis_id = ?""",
|
||||||
|
(analysis_id,),
|
||||||
|
)
|
||||||
|
entity_rows = await cursor.fetchall()
|
||||||
|
entities = []
|
||||||
|
for r in entity_rows:
|
||||||
|
aliases = []
|
||||||
|
try:
|
||||||
|
aliases = json.loads(r["aliases"]) if r["aliases"] else []
|
||||||
|
except (json.JSONDecodeError, TypeError):
|
||||||
|
pass
|
||||||
|
entities.append({
|
||||||
|
"name": r["name"],
|
||||||
|
"name_normalized": r["name_normalized"],
|
||||||
|
"type": r["entity_type"],
|
||||||
|
"description": r["description"] or "",
|
||||||
|
"aliases": aliases,
|
||||||
|
"mention_count": r["mention_count"] or 1,
|
||||||
|
"db_id": r["id"],
|
||||||
|
})
|
||||||
|
|
||||||
|
print(f"Geladene Entitäten: {len(entities)}")
|
||||||
|
|
||||||
|
# Phase 2a: Entity-Deduplication (vor Relation-Löschung)
|
||||||
|
print(f"\n--- Phase 2a: Entity-Deduplication ---\n")
|
||||||
|
await _phase2a_deduplicate_entities(db, analysis_id, entities)
|
||||||
|
print(f"Entitäten nach Dedup: {len(entities)}")
|
||||||
|
|
||||||
|
# Alte Relations löschen
|
||||||
|
cursor = await db.execute(
|
||||||
|
"SELECT COUNT(*) as cnt FROM network_relations WHERE network_analysis_id = ?",
|
||||||
|
(analysis_id,),
|
||||||
|
)
|
||||||
|
old_count = (await cursor.fetchone())["cnt"]
|
||||||
|
print(f"\nLösche {old_count} alte Relations...")
|
||||||
|
await db.execute(
|
||||||
|
"DELETE FROM network_relations WHERE network_analysis_id = ?",
|
||||||
|
(analysis_id,),
|
||||||
|
)
|
||||||
|
await db.commit()
|
||||||
|
|
||||||
|
# Incident-IDs laden
|
||||||
|
cursor = await db.execute(
|
||||||
|
"SELECT incident_id FROM network_analysis_incidents WHERE network_analysis_id = ?",
|
||||||
|
(analysis_id,),
|
||||||
|
)
|
||||||
|
incident_ids = [row["incident_id"] for row in await cursor.fetchall()]
|
||||||
|
print(f"Verknüpfte Lagen: {len(incident_ids)}")
|
||||||
|
|
||||||
|
# Artikel laden
|
||||||
|
placeholders = ",".join("?" * len(incident_ids))
|
||||||
|
cursor = await db.execute(
|
||||||
|
f"""SELECT id, incident_id, headline, headline_de, source, source_url,
|
||||||
|
content_original, content_de, collected_at
|
||||||
|
FROM articles WHERE incident_id IN ({placeholders})""",
|
||||||
|
incident_ids,
|
||||||
|
)
|
||||||
|
article_rows = await cursor.fetchall()
|
||||||
|
articles = []
|
||||||
|
article_ids = []
|
||||||
|
article_ts = []
|
||||||
|
for r in article_rows:
|
||||||
|
articles.append({
|
||||||
|
"id": r["id"], "incident_id": r["incident_id"],
|
||||||
|
"headline": r["headline"], "headline_de": r["headline_de"],
|
||||||
|
"source": r["source"], "source_url": r["source_url"],
|
||||||
|
"content_original": r["content_original"], "content_de": r["content_de"],
|
||||||
|
})
|
||||||
|
article_ids.append(r["id"])
|
||||||
|
article_ts.append(r["collected_at"] or "")
|
||||||
|
|
||||||
|
# Faktenchecks laden
|
||||||
|
cursor = await db.execute(
|
||||||
|
f"""SELECT id, incident_id, claim, status, evidence, checked_at
|
||||||
|
FROM fact_checks WHERE incident_id IN ({placeholders})""",
|
||||||
|
incident_ids,
|
||||||
|
)
|
||||||
|
fc_rows = await cursor.fetchall()
|
||||||
|
factchecks = []
|
||||||
|
factcheck_ids = []
|
||||||
|
factcheck_ts = []
|
||||||
|
for r in fc_rows:
|
||||||
|
factchecks.append({
|
||||||
|
"id": r["id"], "incident_id": r["incident_id"],
|
||||||
|
"claim": r["claim"], "status": r["status"], "evidence": r["evidence"],
|
||||||
|
})
|
||||||
|
factcheck_ids.append(r["id"])
|
||||||
|
factcheck_ts.append(r["checked_at"] or "")
|
||||||
|
|
||||||
|
print(f"Artikel: {len(articles)}, Faktenchecks: {len(factchecks)}")
|
||||||
|
|
||||||
|
# Phase 2: Beziehungsextraktion
|
||||||
|
print(f"\n--- Phase 2: Batched Beziehungsextraktion starten ---\n")
|
||||||
|
relations = await _phase2_analyze_relationships(
|
||||||
|
db, analysis_id, tenant_id, entities, articles, factchecks, usage_acc,
|
||||||
|
)
|
||||||
|
|
||||||
|
# Phase 2c: Semantische Deduplication
|
||||||
|
print(f"\n--- Phase 2c: Semantische Deduplication (Opus) ---\n")
|
||||||
|
await _phase2c_semantic_dedup(
|
||||||
|
db, analysis_id, tenant_id, entities, usage_acc,
|
||||||
|
)
|
||||||
|
|
||||||
|
# Phase 2d: Cleanup
|
||||||
|
print(f"\n--- Phase 2d: Cleanup ---\n")
|
||||||
|
await _phase2d_cleanup(db, analysis_id, entities)
|
||||||
|
|
||||||
|
# Finale Zähler aus DB
|
||||||
|
cursor = await db.execute(
|
||||||
|
"SELECT COUNT(*) as cnt FROM network_entities WHERE network_analysis_id = ?",
|
||||||
|
(analysis_id,),
|
||||||
|
)
|
||||||
|
row = await cursor.fetchone()
|
||||||
|
final_entity_count = row["cnt"] if row else len(entities)
|
||||||
|
|
||||||
|
cursor = await db.execute(
|
||||||
|
"SELECT COUNT(*) as cnt FROM network_relations WHERE network_analysis_id = ?",
|
||||||
|
(analysis_id,),
|
||||||
|
)
|
||||||
|
row = await cursor.fetchone()
|
||||||
|
final_relation_count = row["cnt"] if row else len(relations)
|
||||||
|
|
||||||
|
# Finalisierung
|
||||||
|
data_hash = _compute_data_hash(article_ids, factcheck_ids, article_ts, factcheck_ts)
|
||||||
|
now = datetime.now(TIMEZONE).strftime("%Y-%m-%d %H:%M:%S")
|
||||||
|
|
||||||
|
await db.execute(
|
||||||
|
"""UPDATE network_analyses
|
||||||
|
SET entity_count = ?, relation_count = ?, status = 'ready',
|
||||||
|
last_generated_at = ?, data_hash = ?
|
||||||
|
WHERE id = ?""",
|
||||||
|
(final_entity_count, final_relation_count, now, data_hash, analysis_id),
|
||||||
|
)
|
||||||
|
|
||||||
|
await db.execute(
|
||||||
|
"""INSERT INTO network_generation_log
|
||||||
|
(network_analysis_id, completed_at, status, input_tokens, output_tokens,
|
||||||
|
cache_creation_tokens, cache_read_tokens, total_cost_usd, api_calls,
|
||||||
|
entity_count, relation_count, tenant_id)
|
||||||
|
VALUES (?, ?, 'completed', ?, ?, ?, ?, ?, ?, ?, ?, ?)""",
|
||||||
|
(analysis_id, now, usage_acc.input_tokens, usage_acc.output_tokens,
|
||||||
|
usage_acc.cache_creation_tokens, usage_acc.cache_read_tokens,
|
||||||
|
usage_acc.total_cost_usd, usage_acc.call_count,
|
||||||
|
final_entity_count, final_relation_count, tenant_id),
|
||||||
|
)
|
||||||
|
|
||||||
|
await db.commit()
|
||||||
|
|
||||||
|
print(f"\n{'='*60}")
|
||||||
|
print(f"FERTIG!")
|
||||||
|
print(f"Entitäten: {final_entity_count}")
|
||||||
|
print(f"Beziehungen: {final_relation_count}")
|
||||||
|
print(f"API-Calls: {usage_acc.call_count}")
|
||||||
|
print(f"Kosten: ${usage_acc.total_cost_usd:.4f}")
|
||||||
|
print(f"{'='*60}")
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
print(f"FEHLER: {e}")
|
||||||
|
import traceback
|
||||||
|
traceback.print_exc()
|
||||||
|
try:
|
||||||
|
await db.execute("UPDATE network_analyses SET status = 'error' WHERE id = ?", (analysis_id,))
|
||||||
|
await db.commit()
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
finally:
|
||||||
|
await db.close()
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
analysis_id = int(sys.argv[1]) if len(sys.argv) > 1 else 1
|
||||||
|
asyncio.run(regenerate_relations_only(analysis_id))
|
||||||
Datei-Diff unterdrückt, da er zu groß ist
Diff laden
@@ -6,7 +6,9 @@ import re
|
|||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
from config import TIMEZONE
|
from config import TIMEZONE
|
||||||
from typing import Optional
|
from typing import Optional
|
||||||
from urllib.parse import urlparse, urlunparse
|
from urllib.parse import urlparse, urlunparse, quote_plus
|
||||||
|
|
||||||
|
import httpx
|
||||||
|
|
||||||
from agents.claude_client import UsageAccumulator
|
from agents.claude_client import UsageAccumulator
|
||||||
from agents.factchecker import find_matching_claim, deduplicate_new_facts, TWOPHASE_MIN_FACTS
|
from agents.factchecker import find_matching_claim, deduplicate_new_facts, TWOPHASE_MIN_FACTS
|
||||||
@@ -132,6 +134,80 @@ def _score_relevance(article: dict, search_words: list[str] = None) -> float:
|
|||||||
return min(1.0, score)
|
return min(1.0, score)
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
async def _verify_article_urls(
|
||||||
|
articles: list[dict],
|
||||||
|
concurrency: int = 10,
|
||||||
|
timeout: float = 8.0,
|
||||||
|
) -> list[dict]:
|
||||||
|
"""Prueft WebSearch-URLs auf Erreichbarkeit. Ersetzt unerreichbare URLs durch Suchlinks."""
|
||||||
|
if not articles:
|
||||||
|
return []
|
||||||
|
|
||||||
|
sem = asyncio.Semaphore(concurrency)
|
||||||
|
results: list[dict | None] = [None] * len(articles)
|
||||||
|
|
||||||
|
async def _check(idx: int, article: dict, client: httpx.AsyncClient):
|
||||||
|
url = article.get("source_url", "").strip()
|
||||||
|
if not url:
|
||||||
|
results[idx] = article # Kein URL -> behalten (wird eh nicht verlinkt)
|
||||||
|
return
|
||||||
|
async with sem:
|
||||||
|
try:
|
||||||
|
resp = await client.head(url)
|
||||||
|
if resp.status_code == 405:
|
||||||
|
# Manche Server unterstuetzen kein HEAD
|
||||||
|
resp = await client.get(url, headers={"Range": "bytes=0-0"})
|
||||||
|
if 200 <= resp.status_code < 400:
|
||||||
|
results[idx] = article
|
||||||
|
return
|
||||||
|
# 404 oder anderer Fehler -> Fallback-Suchlink
|
||||||
|
logger.info(f"URL-Verifizierung: {resp.status_code} fuer {url}")
|
||||||
|
except Exception as e:
|
||||||
|
logger.debug(f"URL-Verifizierung fehlgeschlagen fuer {url}: {e}")
|
||||||
|
|
||||||
|
# Fallback: Google-Suchlink aus Headline + Source-Domain
|
||||||
|
headline = article.get("headline", "")
|
||||||
|
source = article.get("source", "")
|
||||||
|
domain = ""
|
||||||
|
try:
|
||||||
|
from urllib.parse import urlparse as _urlparse
|
||||||
|
domain = _urlparse(url).netloc
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
if headline:
|
||||||
|
search_query = f"site:{domain} {headline}" if domain else f"{source} {headline}"
|
||||||
|
fallback_url = f"https://www.google.com/search?q={quote_plus(search_query)}"
|
||||||
|
article_copy = dict(article)
|
||||||
|
article_copy["source_url"] = fallback_url
|
||||||
|
article_copy["_url_repaired"] = True
|
||||||
|
results[idx] = article_copy
|
||||||
|
logger.info(f"URL-Fallback: {url} -> Google-Suche fuer \"{headline[:60]}...\"")
|
||||||
|
else:
|
||||||
|
results[idx] = article # Kein Headline -> Original behalten
|
||||||
|
|
||||||
|
async with httpx.AsyncClient(
|
||||||
|
timeout=timeout,
|
||||||
|
follow_redirects=True,
|
||||||
|
headers={"User-Agent": "Mozilla/5.0 (compatible; AegisSight-Monitor/1.0)"},
|
||||||
|
) as client:
|
||||||
|
await asyncio.gather(*[_check(i, a, client) for i, a in enumerate(articles)])
|
||||||
|
|
||||||
|
verified = [r for r in results if r is not None]
|
||||||
|
repaired = sum(1 for r in verified if r.get("_url_repaired"))
|
||||||
|
ok = len(verified) - repaired
|
||||||
|
|
||||||
|
if repaired > 0:
|
||||||
|
logger.warning(
|
||||||
|
f"URL-Verifizierung: {ok} OK, {repaired} durch Suchlinks ersetzt "
|
||||||
|
f"(von {len(articles)} WebSearch-Artikeln)"
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
logger.info(f"URL-Verifizierung: Alle {len(articles)} WebSearch-URLs erreichbar")
|
||||||
|
|
||||||
|
return verified
|
||||||
|
|
||||||
|
|
||||||
async def _background_discover_sources(articles: list[dict]):
|
async def _background_discover_sources(articles: list[dict]):
|
||||||
"""Background-Task: Registriert seriöse, unbekannte Quellen aus Recherche-Ergebnissen."""
|
"""Background-Task: Registriert seriöse, unbekannte Quellen aus Recherche-Ergebnissen."""
|
||||||
from database import get_db
|
from database import get_db
|
||||||
@@ -692,6 +768,10 @@ class AgentOrchestrator:
|
|||||||
(search_results, search_usage) = pipeline_results[1]
|
(search_results, search_usage) = pipeline_results[1]
|
||||||
telegram_articles = pipeline_results[2][0] if include_telegram else []
|
telegram_articles = pipeline_results[2][0] if include_telegram else []
|
||||||
|
|
||||||
|
# URL-Verifizierung nur fuer WebSearch-Ergebnisse (RSS-URLs sind bereits verifiziert)
|
||||||
|
if search_results:
|
||||||
|
search_results = await _verify_article_urls(search_results)
|
||||||
|
|
||||||
if rss_feed_usage:
|
if rss_feed_usage:
|
||||||
usage_acc.add(rss_feed_usage)
|
usage_acc.add(rss_feed_usage)
|
||||||
if search_usage:
|
if search_usage:
|
||||||
@@ -1236,6 +1316,41 @@ class AgentOrchestrator:
|
|||||||
f"${usage_acc.total_cost_usd:.4f} ({usage_acc.call_count} Calls)"
|
f"${usage_acc.total_cost_usd:.4f} ({usage_acc.call_count} Calls)"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
# Credits-Tracking: Monatliche Aggregation + Credits abziehen
|
||||||
|
if tenant_id and usage_acc.total_cost_usd > 0:
|
||||||
|
year_month = datetime.now(TIMEZONE).strftime('%Y-%m')
|
||||||
|
await db.execute("""
|
||||||
|
INSERT INTO token_usage_monthly
|
||||||
|
(organization_id, year_month, input_tokens, output_tokens,
|
||||||
|
cache_creation_tokens, cache_read_tokens, total_cost_usd, api_calls, refresh_count)
|
||||||
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?, 1)
|
||||||
|
ON CONFLICT(organization_id, year_month) DO UPDATE SET
|
||||||
|
input_tokens = input_tokens + excluded.input_tokens,
|
||||||
|
output_tokens = output_tokens + excluded.output_tokens,
|
||||||
|
cache_creation_tokens = cache_creation_tokens + excluded.cache_creation_tokens,
|
||||||
|
cache_read_tokens = cache_read_tokens + excluded.cache_read_tokens,
|
||||||
|
total_cost_usd = total_cost_usd + excluded.total_cost_usd,
|
||||||
|
api_calls = api_calls + excluded.api_calls,
|
||||||
|
refresh_count = refresh_count + 1,
|
||||||
|
updated_at = CURRENT_TIMESTAMP
|
||||||
|
""", (tenant_id, year_month,
|
||||||
|
usage_acc.input_tokens, usage_acc.output_tokens,
|
||||||
|
usage_acc.cache_creation_tokens, usage_acc.cache_read_tokens,
|
||||||
|
round(usage_acc.total_cost_usd, 7), usage_acc.call_count))
|
||||||
|
|
||||||
|
# Credits auf Lizenz abziehen
|
||||||
|
lic_cursor = await db.execute(
|
||||||
|
"SELECT cost_per_credit FROM licenses WHERE organization_id = ? AND status = 'active' ORDER BY id DESC LIMIT 1",
|
||||||
|
(tenant_id,))
|
||||||
|
lic = await lic_cursor.fetchone()
|
||||||
|
if lic and lic["cost_per_credit"] and lic["cost_per_credit"] > 0:
|
||||||
|
credits_consumed = usage_acc.total_cost_usd / lic["cost_per_credit"]
|
||||||
|
await db.execute(
|
||||||
|
"UPDATE licenses SET credits_used = COALESCE(credits_used, 0) + ? WHERE organization_id = ? AND status = 'active'",
|
||||||
|
(round(credits_consumed, 2), tenant_id))
|
||||||
|
await db.commit()
|
||||||
|
logger.info(f"Credits: {round(credits_consumed, 1) if lic and lic['cost_per_credit'] else 0} abgezogen für Tenant {tenant_id}")
|
||||||
|
|
||||||
# Quellen-Discovery im Background starten
|
# Quellen-Discovery im Background starten
|
||||||
if unique_results:
|
if unique_results:
|
||||||
asyncio.create_task(_background_discover_sources(unique_results))
|
asyncio.create_task(_background_discover_sources(unique_results))
|
||||||
|
|||||||
@@ -21,6 +21,7 @@ REGELN:
|
|||||||
- KEINE Boulevardmedien (Bild, Sun, Daily Mail etc.)
|
- KEINE Boulevardmedien (Bild, Sun, Daily Mail etc.)
|
||||||
{language_instruction}
|
{language_instruction}
|
||||||
- Faktenbasiert und neutral - keine Spekulationen
|
- Faktenbasiert und neutral - keine Spekulationen
|
||||||
|
- KRITISCH für source_url: Kopiere die EXAKTE URL aus den WebSearch-Ergebnissen. Erfinde oder konstruiere NIEMALS URLs aus Mustern oder Erinnerung. Wenn du die exakte URL eines Artikels nicht aus den Suchergebnissen hast, lass diesen Artikel komplett weg.
|
||||||
- Nutze removepaywalls.com für Paywall-geschützte Artikel (z.B. Spiegel+, Zeit+, SZ+): https://www.removepaywalls.com/search?url=ARTIKEL_URL
|
- Nutze removepaywalls.com für Paywall-geschützte Artikel (z.B. Spiegel+, Zeit+, SZ+): https://www.removepaywalls.com/search?url=ARTIKEL_URL
|
||||||
- Nutze WebFetch um die 3-5 wichtigsten Artikel vollständig abzurufen und zusammenzufassen
|
- Nutze WebFetch um die 3-5 wichtigsten Artikel vollständig abzurufen und zusammenzufassen
|
||||||
|
|
||||||
@@ -82,6 +83,7 @@ AUSSCHLUSS:
|
|||||||
- KEIN Social Media (Twitter/X, Facebook, Instagram, TikTok, Reddit)
|
- KEIN Social Media (Twitter/X, Facebook, Instagram, TikTok, Reddit)
|
||||||
- KEINE Boulevardmedien (Bild, Sun, Daily Mail etc.)
|
- KEINE Boulevardmedien (Bild, Sun, Daily Mail etc.)
|
||||||
- KEINE Meinungsblogs ohne Quellenbelege
|
- KEINE Meinungsblogs ohne Quellenbelege
|
||||||
|
- KEINE erfundenen oder konstruierten URLs — gib bei source_url NUR die EXAKTE URL zurueck, die WebSearch tatsaechlich angezeigt hat. Wenn du die URL nicht aus den Suchergebnissen kopieren kannst, lass den Artikel weg.
|
||||||
|
|
||||||
Gib die Ergebnisse AUSSCHLIESSLICH als JSON-Array zurück, ohne Erklärungen davor oder danach.
|
Gib die Ergebnisse AUSSCHLIESSLICH als JSON-Array zurück, ohne Erklärungen davor oder danach.
|
||||||
Jedes Element hat diese Felder:
|
Jedes Element hat diese Felder:
|
||||||
|
|||||||
@@ -464,6 +464,13 @@ async def init_db():
|
|||||||
await db.execute("ALTER TABLE users ADD COLUMN is_active INTEGER DEFAULT 1")
|
await db.execute("ALTER TABLE users ADD COLUMN is_active INTEGER DEFAULT 1")
|
||||||
await db.commit()
|
await db.commit()
|
||||||
|
|
||||||
|
# Migration: Tutorial-Fortschritt pro User
|
||||||
|
if "tutorial_step" not in user_columns:
|
||||||
|
await db.execute("ALTER TABLE users ADD COLUMN tutorial_step INTEGER DEFAULT NULL")
|
||||||
|
await db.execute("ALTER TABLE users ADD COLUMN tutorial_completed INTEGER DEFAULT 0")
|
||||||
|
await db.commit()
|
||||||
|
logger.info("Migration: tutorial_step + tutorial_completed zu users hinzugefuegt")
|
||||||
|
|
||||||
if "last_login_at" not in user_columns:
|
if "last_login_at" not in user_columns:
|
||||||
await db.execute("ALTER TABLE users ADD COLUMN last_login_at TIMESTAMP")
|
await db.execute("ALTER TABLE users ADD COLUMN last_login_at TIMESTAMP")
|
||||||
await db.commit()
|
await db.commit()
|
||||||
@@ -575,7 +582,42 @@ async def init_db():
|
|||||||
await db.commit()
|
await db.commit()
|
||||||
logger.info("Migration: article_locations-Tabelle erstellt")
|
logger.info("Migration: article_locations-Tabelle erstellt")
|
||||||
|
|
||||||
# Verwaiste running-Eintraege beim Start als error markieren (aelter als 15 Min)
|
|
||||||
|
# Migration: Credits-System fuer Lizenzen
|
||||||
|
cursor = await db.execute("PRAGMA table_info(licenses)")
|
||||||
|
columns = [row[1] for row in await cursor.fetchall()]
|
||||||
|
if "token_budget_usd" not in columns:
|
||||||
|
await db.execute("ALTER TABLE licenses ADD COLUMN token_budget_usd REAL")
|
||||||
|
await db.execute("ALTER TABLE licenses ADD COLUMN credits_total INTEGER")
|
||||||
|
await db.execute("ALTER TABLE licenses ADD COLUMN credits_used REAL DEFAULT 0")
|
||||||
|
await db.execute("ALTER TABLE licenses ADD COLUMN cost_per_credit REAL")
|
||||||
|
await db.execute("ALTER TABLE licenses ADD COLUMN budget_warning_percent INTEGER DEFAULT 80")
|
||||||
|
await db.commit()
|
||||||
|
logger.info("Migration: Credits-System zu Lizenzen hinzugefuegt")
|
||||||
|
|
||||||
|
# Migration: Token-Usage-Monatstabelle
|
||||||
|
cursor = await db.execute("SELECT name FROM sqlite_master WHERE type='table' AND name='token_usage_monthly'")
|
||||||
|
if not await cursor.fetchone():
|
||||||
|
await db.execute("""
|
||||||
|
CREATE TABLE token_usage_monthly (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
organization_id INTEGER REFERENCES organizations(id),
|
||||||
|
year_month TEXT NOT NULL,
|
||||||
|
input_tokens INTEGER DEFAULT 0,
|
||||||
|
output_tokens INTEGER DEFAULT 0,
|
||||||
|
cache_creation_tokens INTEGER DEFAULT 0,
|
||||||
|
cache_read_tokens INTEGER DEFAULT 0,
|
||||||
|
total_cost_usd REAL DEFAULT 0.0,
|
||||||
|
api_calls INTEGER DEFAULT 0,
|
||||||
|
refresh_count INTEGER DEFAULT 0,
|
||||||
|
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
UNIQUE(organization_id, year_month)
|
||||||
|
)
|
||||||
|
""")
|
||||||
|
await db.commit()
|
||||||
|
logger.info("Migration: token_usage_monthly Tabelle erstellt")
|
||||||
|
|
||||||
|
# Verwaiste running-Eintraege beim Start als error markieren (aelter als 15 Min)
|
||||||
await db.execute(
|
await db.execute(
|
||||||
"""UPDATE refresh_log SET status = 'error', error_message = 'Verwaist beim Neustart',
|
"""UPDATE refresh_log SET status = 'error', error_message = 'Verwaist beim Neustart',
|
||||||
completed_at = CURRENT_TIMESTAMP
|
completed_at = CURRENT_TIMESTAMP
|
||||||
|
|||||||
@@ -333,6 +333,7 @@ from routers.feedback import router as feedback_router
|
|||||||
from routers.public_api import router as public_api_router
|
from routers.public_api import router as public_api_router
|
||||||
from routers.chat import router as chat_router
|
from routers.chat import router as chat_router
|
||||||
from routers.network_analysis import router as network_analysis_router
|
from routers.network_analysis import router as network_analysis_router
|
||||||
|
from routers.tutorial import router as tutorial_router
|
||||||
|
|
||||||
app.include_router(auth_router)
|
app.include_router(auth_router)
|
||||||
app.include_router(incidents_router)
|
app.include_router(incidents_router)
|
||||||
@@ -342,6 +343,7 @@ app.include_router(feedback_router)
|
|||||||
app.include_router(public_api_router)
|
app.include_router(public_api_router)
|
||||||
app.include_router(chat_router, prefix="/api/chat")
|
app.include_router(chat_router, prefix="/api/chat")
|
||||||
app.include_router(network_analysis_router)
|
app.include_router(network_analysis_router)
|
||||||
|
app.include_router(tutorial_router)
|
||||||
|
|
||||||
|
|
||||||
@app.websocket("/api/ws")
|
@app.websocket("/api/ws")
|
||||||
|
|||||||
@@ -41,6 +41,9 @@ class UserMeResponse(BaseModel):
|
|||||||
license_status: str = "unknown"
|
license_status: str = "unknown"
|
||||||
license_type: str = ""
|
license_type: str = ""
|
||||||
read_only: bool = False
|
read_only: bool = False
|
||||||
|
credits_total: Optional[int] = None
|
||||||
|
credits_remaining: Optional[int] = None
|
||||||
|
credits_percent_used: Optional[float] = None
|
||||||
|
|
||||||
|
|
||||||
# Incidents (Lagen)
|
# Incidents (Lagen)
|
||||||
|
|||||||
@@ -261,10 +261,28 @@ async def get_me(
|
|||||||
from services.license_service import check_license
|
from services.license_service import check_license
|
||||||
license_info = await check_license(db, current_user["tenant_id"])
|
license_info = await check_license(db, current_user["tenant_id"])
|
||||||
|
|
||||||
|
# Credits-Daten laden
|
||||||
|
credits_total = None
|
||||||
|
credits_remaining = None
|
||||||
|
credits_percent_used = None
|
||||||
|
if current_user.get("tenant_id"):
|
||||||
|
lic_cursor = await db.execute(
|
||||||
|
"SELECT credits_total, credits_used, cost_per_credit FROM licenses WHERE organization_id = ? AND status = 'active' ORDER BY id DESC LIMIT 1",
|
||||||
|
(current_user["tenant_id"],))
|
||||||
|
lic_row = await lic_cursor.fetchone()
|
||||||
|
if lic_row and lic_row["credits_total"]:
|
||||||
|
credits_total = lic_row["credits_total"]
|
||||||
|
credits_used = lic_row["credits_used"] or 0
|
||||||
|
credits_remaining = max(0, int(credits_total - credits_used))
|
||||||
|
credits_percent_used = round(min(100, (credits_used / credits_total) * 100), 1) if credits_total > 0 else 0
|
||||||
|
|
||||||
return UserMeResponse(
|
return UserMeResponse(
|
||||||
id=current_user["id"],
|
id=current_user["id"],
|
||||||
username=current_user["username"],
|
username=current_user["username"],
|
||||||
email=current_user.get("email", ""),
|
email=current_user.get("email", ""),
|
||||||
|
credits_total=credits_total,
|
||||||
|
credits_remaining=credits_remaining,
|
||||||
|
credits_percent_used=credits_percent_used,
|
||||||
role=current_user["role"],
|
role=current_user["role"],
|
||||||
org_name=org_name,
|
org_name=org_name,
|
||||||
org_slug=current_user.get("org_slug", ""),
|
org_slug=current_user.get("org_slug", ""),
|
||||||
|
|||||||
@@ -415,12 +415,14 @@ async def create_source(
|
|||||||
"""Neue Quelle hinzufuegen (org-spezifisch)."""
|
"""Neue Quelle hinzufuegen (org-spezifisch)."""
|
||||||
tenant_id = current_user.get("tenant_id")
|
tenant_id = current_user.get("tenant_id")
|
||||||
|
|
||||||
# Domain normalisieren (Subdomain-Aliase auflösen)
|
# Domain normalisieren (Subdomain-Aliase auflösen, aus URL extrahieren)
|
||||||
domain = data.domain
|
domain = data.domain
|
||||||
|
if not domain and data.url:
|
||||||
|
domain = _extract_domain(data.url)
|
||||||
if domain:
|
if domain:
|
||||||
domain = _DOMAIN_ALIASES.get(domain.lower(), domain.lower())
|
domain = _DOMAIN_ALIASES.get(domain.lower(), domain.lower())
|
||||||
|
|
||||||
# Duplikat-Prüfung: gleiche URL bereits vorhanden?
|
# Duplikat-Prüfung 1: gleiche URL bereits vorhanden? (tenant-übergreifend)
|
||||||
if data.url:
|
if data.url:
|
||||||
cursor = await db.execute(
|
cursor = await db.execute(
|
||||||
"SELECT id, name FROM sources WHERE url = ? AND status = 'active'",
|
"SELECT id, name FROM sources WHERE url = ? AND status = 'active'",
|
||||||
@@ -433,6 +435,25 @@ async def create_source(
|
|||||||
detail=f"Feed-URL bereits vorhanden: {existing['name']} (ID {existing['id']})",
|
detail=f"Feed-URL bereits vorhanden: {existing['name']} (ID {existing['id']})",
|
||||||
)
|
)
|
||||||
|
|
||||||
|
# Duplikat-Prüfung 2: Domain bereits vorhanden? (tenant-übergreifend)
|
||||||
|
if domain:
|
||||||
|
cursor = await db.execute(
|
||||||
|
"SELECT id, name, source_type FROM sources WHERE LOWER(domain) = ? AND status = 'active' AND (tenant_id IS NULL OR tenant_id = ?) LIMIT 1",
|
||||||
|
(domain.lower(), tenant_id),
|
||||||
|
)
|
||||||
|
domain_existing = await cursor.fetchone()
|
||||||
|
if domain_existing:
|
||||||
|
if data.source_type == "web_source":
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_409_CONFLICT,
|
||||||
|
detail=f"Web-Quelle für '{domain}' bereits vorhanden: {domain_existing['name']}",
|
||||||
|
)
|
||||||
|
if not data.url:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_409_CONFLICT,
|
||||||
|
detail=f"Domain '{domain}' bereits als Quelle vorhanden: {domain_existing['name']}. Für einen neuen RSS-Feed bitte die Feed-URL angeben.",
|
||||||
|
)
|
||||||
|
|
||||||
cursor = await db.execute(
|
cursor = await db.execute(
|
||||||
"""INSERT INTO sources (name, url, domain, source_type, category, status, notes, added_by, tenant_id)
|
"""INSERT INTO sources (name, url, domain, source_type, category, status, notes, added_by, tenant_id)
|
||||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)""",
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)""",
|
||||||
|
|||||||
77
src/routers/tutorial.py
Normale Datei
77
src/routers/tutorial.py
Normale Datei
@@ -0,0 +1,77 @@
|
|||||||
|
"""Tutorial-Router: Fortschritt serverseitig pro User speichern."""
|
||||||
|
import logging
|
||||||
|
from fastapi import APIRouter, Depends
|
||||||
|
from auth import get_current_user
|
||||||
|
from database import db_dependency
|
||||||
|
import aiosqlite
|
||||||
|
|
||||||
|
logger = logging.getLogger("osint.tutorial")
|
||||||
|
|
||||||
|
router = APIRouter(prefix="/api/tutorial", tags=["tutorial"])
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/state")
|
||||||
|
async def get_tutorial_state(
|
||||||
|
current_user: dict = Depends(get_current_user),
|
||||||
|
db: aiosqlite.Connection = Depends(db_dependency),
|
||||||
|
):
|
||||||
|
"""Tutorial-Fortschritt des aktuellen Nutzers abrufen."""
|
||||||
|
cursor = await db.execute(
|
||||||
|
"SELECT tutorial_step, tutorial_completed FROM users WHERE id = ?",
|
||||||
|
(current_user["id"],),
|
||||||
|
)
|
||||||
|
row = await cursor.fetchone()
|
||||||
|
if not row:
|
||||||
|
return {"current_step": None, "completed": False}
|
||||||
|
return {
|
||||||
|
"current_step": row["tutorial_step"],
|
||||||
|
"completed": bool(row["tutorial_completed"]),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@router.put("/state")
|
||||||
|
async def save_tutorial_state(
|
||||||
|
body: dict,
|
||||||
|
current_user: dict = Depends(get_current_user),
|
||||||
|
db: aiosqlite.Connection = Depends(db_dependency),
|
||||||
|
):
|
||||||
|
"""Tutorial-Fortschritt speichern (current_step und/oder completed)."""
|
||||||
|
updates = []
|
||||||
|
params = []
|
||||||
|
|
||||||
|
if "current_step" in body:
|
||||||
|
step = body["current_step"]
|
||||||
|
if step is not None and (not isinstance(step, int) or step < 0 or step > 31):
|
||||||
|
from fastapi import HTTPException
|
||||||
|
raise HTTPException(status_code=422, detail="current_step muss 0-31 oder null sein")
|
||||||
|
updates.append("tutorial_step = ?")
|
||||||
|
params.append(step)
|
||||||
|
|
||||||
|
if "completed" in body:
|
||||||
|
updates.append("tutorial_completed = ?")
|
||||||
|
params.append(1 if body["completed"] else 0)
|
||||||
|
|
||||||
|
if not updates:
|
||||||
|
return {"ok": True}
|
||||||
|
|
||||||
|
params.append(current_user["id"])
|
||||||
|
await db.execute(
|
||||||
|
f"UPDATE users SET {', '.join(updates)} WHERE id = ?",
|
||||||
|
params,
|
||||||
|
)
|
||||||
|
await db.commit()
|
||||||
|
return {"ok": True}
|
||||||
|
|
||||||
|
|
||||||
|
@router.delete("/state")
|
||||||
|
async def reset_tutorial_state(
|
||||||
|
current_user: dict = Depends(get_current_user),
|
||||||
|
db: aiosqlite.Connection = Depends(db_dependency),
|
||||||
|
):
|
||||||
|
"""Tutorial-Fortschritt zuruecksetzen (fuer Neustart)."""
|
||||||
|
await db.execute(
|
||||||
|
"UPDATE users SET tutorial_step = NULL, tutorial_completed = 0 WHERE id = ?",
|
||||||
|
(current_user["id"],),
|
||||||
|
)
|
||||||
|
await db.commit()
|
||||||
|
return {"ok": True}
|
||||||
@@ -4231,6 +4231,33 @@ select:focus-visible, textarea:focus-visible,
|
|||||||
}
|
}
|
||||||
|
|
||||||
/* === Print Styles === */
|
/* === Print Styles === */
|
||||||
|
|
||||||
|
/* === PDF Export Dialog === */
|
||||||
|
.pdf-tile-option {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 10px;
|
||||||
|
padding: 8px 12px;
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: 6px;
|
||||||
|
cursor: pointer;
|
||||||
|
font-size: 14px;
|
||||||
|
color: var(--text-primary);
|
||||||
|
transition: background 0.15s, border-color 0.15s;
|
||||||
|
}
|
||||||
|
.pdf-tile-option:hover {
|
||||||
|
background: var(--bg-secondary);
|
||||||
|
}
|
||||||
|
.pdf-tile-option input[type="checkbox"] {
|
||||||
|
width: 16px;
|
||||||
|
height: 16px;
|
||||||
|
accent-color: var(--accent);
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
.pdf-tile-option input[type="checkbox"]:checked + span {
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
|
||||||
@media print {
|
@media print {
|
||||||
.sidebar,
|
.sidebar,
|
||||||
.header,
|
.header,
|
||||||
@@ -5308,3 +5335,69 @@ body.tutorial-active .tutorial-cursor {
|
|||||||
color: var(--text-primary);
|
color: var(--text-primary);
|
||||||
box-shadow: 0 0 0 2px rgba(150, 121, 26, 0.25);
|
box-shadow: 0 0 0 2px rgba(150, 121, 26, 0.25);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* ===== Credits-Anzeige im User-Dropdown ===== */
|
||||||
|
.credits-section {
|
||||||
|
padding: 0;
|
||||||
|
text-align: left;
|
||||||
|
}
|
||||||
|
|
||||||
|
.credits-divider {
|
||||||
|
height: 1px;
|
||||||
|
background: var(--border);
|
||||||
|
margin: 8px 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.credits-label {
|
||||||
|
font-size: 11px;
|
||||||
|
font-weight: 600;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.5px;
|
||||||
|
color: var(--text-tertiary);
|
||||||
|
margin-bottom: 8px;
|
||||||
|
text-align: left;
|
||||||
|
}
|
||||||
|
|
||||||
|
.credits-bar-container {
|
||||||
|
width: 100%;
|
||||||
|
height: 8px;
|
||||||
|
background: rgba(255,255,255,0.08);
|
||||||
|
border: 1px solid rgba(255,255,255,0.12);
|
||||||
|
border-radius: 4px;
|
||||||
|
overflow: hidden;
|
||||||
|
margin-bottom: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.credits-bar {
|
||||||
|
height: 100%;
|
||||||
|
border-radius: 4px;
|
||||||
|
background: var(--accent);
|
||||||
|
transition: width 0.6s ease, background-color 0.3s ease;
|
||||||
|
min-width: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.credits-bar.warning {
|
||||||
|
background: #e67e22;
|
||||||
|
}
|
||||||
|
|
||||||
|
.credits-bar.critical {
|
||||||
|
background: #e74c3c;
|
||||||
|
}
|
||||||
|
|
||||||
|
.credits-info {
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--text-tertiary);
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.credits-info span {
|
||||||
|
font-weight: 400;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.credits-percent {
|
||||||
|
font-size: 11px;
|
||||||
|
color: var(--text-tertiary);
|
||||||
|
}
|
||||||
|
|||||||
@@ -50,6 +50,17 @@
|
|||||||
<span class="header-dropdown-label">Lizenz</span>
|
<span class="header-dropdown-label">Lizenz</span>
|
||||||
<span class="header-dropdown-value" id="header-license-info">-</span>
|
<span class="header-dropdown-value" id="header-license-info">-</span>
|
||||||
</div>
|
</div>
|
||||||
|
<div id="credits-section" class="credits-section" style="display: none;">
|
||||||
|
<div class="credits-divider"></div>
|
||||||
|
<div class="credits-label">Credits</div>
|
||||||
|
<div class="credits-bar-container">
|
||||||
|
<div id="credits-bar" class="credits-bar"></div>
|
||||||
|
</div>
|
||||||
|
<div class="credits-info">
|
||||||
|
<span><span id="credits-remaining">0</span> von <span id="credits-total">0</span></span>
|
||||||
|
<span class="credits-percent" id="credits-percent"></span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="header-license-warning" id="header-license-warning"></div>
|
<div class="header-license-warning" id="header-license-warning"></div>
|
||||||
@@ -240,7 +251,7 @@
|
|||||||
<button class="export-dropdown-item" role="menuitem" onclick="App.exportIncident('md','full')">Vollexport (Markdown)</button>
|
<button class="export-dropdown-item" role="menuitem" onclick="App.exportIncident('md','full')">Vollexport (Markdown)</button>
|
||||||
<button class="export-dropdown-item" role="menuitem" onclick="App.exportIncident('json','full')">Vollexport (JSON)</button>
|
<button class="export-dropdown-item" role="menuitem" onclick="App.exportIncident('json','full')">Vollexport (JSON)</button>
|
||||||
<hr class="export-dropdown-divider" role="separator">
|
<hr class="export-dropdown-divider" role="separator">
|
||||||
<button class="export-dropdown-item" role="menuitem" onclick="App.printIncident()">Drucken / PDF</button>
|
<button class="export-dropdown-item" role="menuitem" onclick="App.openPdfExportDialog()">PDF exportieren...</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<button class="btn btn-secondary btn-small" id="archive-incident-btn">Archivieren</button>
|
<button class="btn btn-secondary btn-small" id="archive-incident-btn">Archivieren</button>
|
||||||
@@ -781,5 +792,29 @@
|
|||||||
</div>
|
</div>
|
||||||
<div class="map-fullscreen-container" id="map-fullscreen-container"></div>
|
<div class="map-fullscreen-container" id="map-fullscreen-container"></div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- PDF Export Dialog -->
|
||||||
|
<div class="modal-overlay" id="modal-pdf-export" role="dialog" aria-modal="true" aria-labelledby="pdf-export-title">
|
||||||
|
<div class="modal" style="max-width:420px;">
|
||||||
|
<div class="modal-header">
|
||||||
|
<h3 id="pdf-export-title">PDF exportieren</h3>
|
||||||
|
<button class="modal-close" onclick="closeModal('modal-pdf-export')" aria-label="Schliessen">×</button>
|
||||||
|
</div>
|
||||||
|
<div class="modal-body" style="padding:20px;">
|
||||||
|
<p style="margin:0 0 16px;font-size:13px;color:var(--text-secondary);">Kacheln fuer den Export auswaehlen:</p>
|
||||||
|
<div id="pdf-export-tiles" style="display:flex;flex-direction:column;gap:10px;">
|
||||||
|
<label class="pdf-tile-option"><input type="checkbox" value="lagebild" checked><span>Lagebild</span></label>
|
||||||
|
<label class="pdf-tile-option"><input type="checkbox" value="quellen" checked><span>Quellenübersicht</span></label>
|
||||||
|
<label class="pdf-tile-option"><input type="checkbox" value="faktencheck" checked><span>Faktencheck</span></label>
|
||||||
|
<label class="pdf-tile-option"><input type="checkbox" value="timeline"><span>Ereignis-Timeline</span></label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<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-pdf-export')">Abbrechen</button>
|
||||||
|
<button class="btn btn-primary" onclick="App.executePdfExport()">Exportieren</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
@@ -215,6 +215,19 @@ const API = {
|
|||||||
},
|
},
|
||||||
|
|
||||||
// Export
|
// Export
|
||||||
|
|
||||||
|
// Tutorial-Fortschritt
|
||||||
|
getTutorialState() {
|
||||||
|
return this._request('GET', '/tutorial/state');
|
||||||
|
},
|
||||||
|
|
||||||
|
saveTutorialState(data) {
|
||||||
|
return this._request('PUT', '/tutorial/state', data);
|
||||||
|
},
|
||||||
|
|
||||||
|
resetTutorialState() {
|
||||||
|
return this._request('DELETE', '/tutorial/state');
|
||||||
|
},
|
||||||
exportIncident(id, format, scope) {
|
exportIncident(id, format, scope) {
|
||||||
const token = localStorage.getItem('osint_token');
|
const token = localStorage.getItem('osint_token');
|
||||||
return fetch(`${this.baseUrl}/incidents/${id}/export?format=${format}&scope=${scope}`, {
|
return fetch(`${this.baseUrl}/incidents/${id}/export?format=${format}&scope=${scope}`, {
|
||||||
|
|||||||
@@ -466,6 +466,34 @@ const App = {
|
|||||||
licInfoEl.textContent = label;
|
licInfoEl.textContent = label;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Credits-Anzeige im Dropdown
|
||||||
|
const creditsSection = document.getElementById('credits-section');
|
||||||
|
if (creditsSection && user.credits_total) {
|
||||||
|
creditsSection.style.display = 'block';
|
||||||
|
const bar = document.getElementById('credits-bar');
|
||||||
|
const remainingEl = document.getElementById('credits-remaining');
|
||||||
|
const totalEl = document.getElementById('credits-total');
|
||||||
|
|
||||||
|
const remaining = user.credits_remaining || 0;
|
||||||
|
const total = user.credits_total || 1;
|
||||||
|
const percentUsed = user.credits_percent_used || 0;
|
||||||
|
const percentRemaining = Math.max(0, 100 - percentUsed);
|
||||||
|
|
||||||
|
remainingEl.textContent = remaining.toLocaleString('de-DE');
|
||||||
|
totalEl.textContent = total.toLocaleString('de-DE');
|
||||||
|
bar.style.width = percentRemaining + '%';
|
||||||
|
|
||||||
|
// Farbwechsel je nach Verbrauch
|
||||||
|
bar.classList.remove('warning', 'critical');
|
||||||
|
if (percentUsed > 80) {
|
||||||
|
bar.classList.add('critical');
|
||||||
|
} else if (percentUsed > 50) {
|
||||||
|
bar.classList.add('warning');
|
||||||
|
}
|
||||||
|
const percentEl = document.getElementById("credits-percent");
|
||||||
|
if (percentEl) percentEl.textContent = percentRemaining.toFixed(0) + "% verbleibend";
|
||||||
|
}
|
||||||
|
|
||||||
// Dropdown Toggle
|
// Dropdown Toggle
|
||||||
const userBtn = document.getElementById('header-user-btn');
|
const userBtn = document.getElementById('header-user-btn');
|
||||||
const userDropdown = document.getElementById('header-user-dropdown');
|
const userDropdown = document.getElementById('header-user-dropdown');
|
||||||
@@ -2132,6 +2160,222 @@ const App = {
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
|
openPdfExportDialog() {
|
||||||
|
this._closeExportDropdown();
|
||||||
|
if (!this.currentIncidentId) return;
|
||||||
|
openModal('modal-pdf-export');
|
||||||
|
},
|
||||||
|
|
||||||
|
executePdfExport() {
|
||||||
|
closeModal('modal-pdf-export');
|
||||||
|
const checked = [...document.querySelectorAll('#pdf-export-tiles input:checked')].map(c => c.value);
|
||||||
|
if (!checked.length) { UI.showToast('Keine Kacheln ausgewählt', 'warning'); return; }
|
||||||
|
this._generatePdf(checked);
|
||||||
|
},
|
||||||
|
|
||||||
|
_generatePdf(tiles) {
|
||||||
|
const title = document.getElementById('incident-title')?.textContent || 'Export';
|
||||||
|
const now = new Date().toLocaleString('de-DE', { day: '2-digit', month: '2-digit', year: 'numeric', hour: '2-digit', minute: '2-digit' });
|
||||||
|
|
||||||
|
let sections = '';
|
||||||
|
const esc = (s) => s ? s.replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>').replace(/"/g,'"') : '';
|
||||||
|
|
||||||
|
// === Lagebild ===
|
||||||
|
if (tiles.includes('lagebild')) {
|
||||||
|
const summaryEl = document.getElementById('summary-text');
|
||||||
|
const timestamp = document.getElementById('lagebild-timestamp')?.textContent || '';
|
||||||
|
if (summaryEl && summaryEl.innerHTML.trim()) {
|
||||||
|
// Clone innerHTML and make citation links clickable with full URL visible
|
||||||
|
let summaryHtml = summaryEl.innerHTML;
|
||||||
|
// Ensure citation links are styled for print (underlined, blue)
|
||||||
|
summaryHtml = summaryHtml.replace(/<a\s+href="([^"]*)"[^>]*class="citation"[^>]*>(\[[^\]]+\])<\/a>/g,
|
||||||
|
'<a href="$1" class="citation">$2</a>');
|
||||||
|
sections += '<div class="pdf-section">'
|
||||||
|
+ '<h2>Lagebild</h2>'
|
||||||
|
+ (timestamp ? '<p class="pdf-meta">' + esc(timestamp) + '</p>' : '')
|
||||||
|
+ '<div class="pdf-content">' + summaryHtml + '</div>'
|
||||||
|
+ '</div>';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// === Quellen ===
|
||||||
|
if (tiles.includes('quellen')) {
|
||||||
|
const articles = this._currentArticles || [];
|
||||||
|
if (articles.length) {
|
||||||
|
const sourceMap = {};
|
||||||
|
articles.forEach(a => {
|
||||||
|
const name = a.source || 'Unbekannt';
|
||||||
|
if (!sourceMap[name]) sourceMap[name] = [];
|
||||||
|
sourceMap[name].push(a);
|
||||||
|
});
|
||||||
|
const sources = Object.entries(sourceMap).sort((a,b) => b[1].length - a[1].length);
|
||||||
|
let s = '<p class="pdf-meta">' + articles.length + ' Artikel aus ' + sources.length + ' Quellen</p>';
|
||||||
|
s += '<table class="pdf-table"><thead><tr><th>Quelle</th><th>Artikel</th><th>Sprache</th></tr></thead><tbody>';
|
||||||
|
sources.forEach(([name, arts]) => {
|
||||||
|
const langs = [...new Set(arts.map(a => (a.language || 'de').toUpperCase()))].join(', ');
|
||||||
|
s += '<tr><td><strong>' + esc(name) + '</strong></td><td>' + arts.length + '</td><td>' + langs + '</td></tr>';
|
||||||
|
});
|
||||||
|
s += '</tbody></table>';
|
||||||
|
s += '<div class="pdf-article-list">';
|
||||||
|
sources.forEach(([name, arts]) => {
|
||||||
|
s += '<h4>' + esc(name) + ' (' + arts.length + ')</h4>';
|
||||||
|
arts.forEach(a => {
|
||||||
|
const hl = esc(a.headline_de || a.headline || 'Ohne Titel');
|
||||||
|
const url = a.source_url || '';
|
||||||
|
const dateStr = a.published_at ? new Date(a.published_at).toLocaleDateString('de-DE') : '';
|
||||||
|
s += '<div class="pdf-article-item">';
|
||||||
|
s += url ? '<a href="' + esc(url) + '">' + hl + '</a>' : '<span>' + hl + '</span>';
|
||||||
|
if (dateStr) s += ' <span class="pdf-date">(' + dateStr + ')</span>';
|
||||||
|
s += '</div>';
|
||||||
|
});
|
||||||
|
});
|
||||||
|
s += '</div>';
|
||||||
|
sections += '<div class="pdf-section"><h2>Quellenübersicht</h2>' + s + '</div>';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// === Faktencheck ===
|
||||||
|
if (tiles.includes('faktencheck')) {
|
||||||
|
const fcItems = document.querySelectorAll('#factcheck-list .factcheck-item');
|
||||||
|
if (fcItems.length) {
|
||||||
|
let s = '<div class="pdf-fc-list">';
|
||||||
|
fcItems.forEach(item => {
|
||||||
|
const status = item.dataset.fcStatus || '';
|
||||||
|
const statusEl = item.querySelector('.fc-status-text, .factcheck-status');
|
||||||
|
const claimEl = item.querySelector('.fc-claim-text, .factcheck-claim');
|
||||||
|
const evidenceEls = item.querySelectorAll('.fc-evidence-chip, .evidence-chip');
|
||||||
|
const statusText = statusEl ? statusEl.textContent.trim() : status;
|
||||||
|
const claim = claimEl ? claimEl.textContent.trim() : '';
|
||||||
|
const statusClass = (status.includes('confirmed') || status.includes('verified')) ? 'confirmed'
|
||||||
|
: (status.includes('refuted') || status.includes('disputed')) ? 'refuted'
|
||||||
|
: 'unverified';
|
||||||
|
s += '<div class="pdf-fc-item">'
|
||||||
|
+ '<span class="pdf-fc-badge pdf-fc-' + statusClass + '">' + esc(statusText) + '</span>'
|
||||||
|
+ '<div class="pdf-fc-claim">' + esc(claim) + '</div>';
|
||||||
|
if (evidenceEls.length) {
|
||||||
|
s += '<div class="pdf-fc-evidence">';
|
||||||
|
evidenceEls.forEach(ev => {
|
||||||
|
const link = ev.closest('a');
|
||||||
|
const href = link ? link.href : '';
|
||||||
|
const text = ev.textContent.trim();
|
||||||
|
s += href
|
||||||
|
? '<a href="' + esc(href) + '" class="pdf-fc-ev-link">' + esc(text) + '</a> '
|
||||||
|
: '<span class="pdf-fc-ev-tag">' + esc(text) + '</span> ';
|
||||||
|
});
|
||||||
|
s += '</div>';
|
||||||
|
}
|
||||||
|
s += '</div>';
|
||||||
|
});
|
||||||
|
s += '</div>';
|
||||||
|
sections += '<div class="pdf-section"><h2>Faktencheck</h2>' + s + '</div>';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// === Timeline ===
|
||||||
|
if (tiles.includes('timeline')) {
|
||||||
|
const buckets = document.querySelectorAll('#timeline .ht-bucket');
|
||||||
|
if (buckets.length) {
|
||||||
|
let s = '<div class="pdf-timeline">';
|
||||||
|
buckets.forEach(bucket => {
|
||||||
|
const label = bucket.querySelector('.ht-bucket-label');
|
||||||
|
const items = bucket.querySelectorAll('.ht-item');
|
||||||
|
if (label) s += '<h4>' + esc(label.textContent.trim()) + '</h4>';
|
||||||
|
items.forEach(item => {
|
||||||
|
const time = item.querySelector('.ht-item-time');
|
||||||
|
const ttl = item.querySelector('.ht-item-title');
|
||||||
|
const src = item.querySelector('.ht-item-source');
|
||||||
|
s += '<div class="pdf-tl-item">';
|
||||||
|
if (time) s += '<span class="pdf-tl-time">' + esc(time.textContent.trim()) + '</span> ';
|
||||||
|
if (ttl) s += '<span class="pdf-tl-title">' + esc(ttl.textContent.trim()) + '</span>';
|
||||||
|
if (src) s += ' <span class="pdf-tl-source">' + esc(src.textContent.trim()) + '</span>';
|
||||||
|
s += '</div>';
|
||||||
|
});
|
||||||
|
});
|
||||||
|
s += '</div>';
|
||||||
|
sections += '<div class="pdf-section"><h2>Ereignis-Timeline</h2>' + s + '</div>';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!sections) { UI.showToast('Keine Inhalte zum Exportieren', 'warning'); return; }
|
||||||
|
|
||||||
|
const css = `
|
||||||
|
@page { margin: 18mm 15mm 18mm 15mm; size: A4; }
|
||||||
|
* { box-sizing: border-box; margin: 0; padding: 0; }
|
||||||
|
body { font-family: -apple-system, 'Segoe UI', Roboto, Helvetica, Arial, sans-serif; font-size: 11pt; line-height: 1.55; color: #1a1a1a; background: #fff; padding: 0; }
|
||||||
|
a { color: #1a5276; }
|
||||||
|
|
||||||
|
/* Header: compact, inline with content */
|
||||||
|
.pdf-header { border-bottom: 2px solid #2c3e50; padding-bottom: 10px; margin-bottom: 16px; }
|
||||||
|
.pdf-header h1 { font-size: 18pt; font-weight: 700; color: #2c3e50; margin-bottom: 2px; }
|
||||||
|
.pdf-header .pdf-subtitle { font-size: 9pt; color: #666; }
|
||||||
|
|
||||||
|
/* Sections */
|
||||||
|
.pdf-section { margin-bottom: 22px; }
|
||||||
|
.pdf-section h2 { font-size: 13pt; font-weight: 600; color: #2c3e50; border-bottom: 1px solid #ccc; padding-bottom: 4px; margin-bottom: 10px; }
|
||||||
|
.pdf-section h4 { font-size: 10pt; font-weight: 600; color: #444; margin: 10px 0 3px; }
|
||||||
|
.pdf-meta { font-size: 9pt; color: #888; margin-bottom: 8px; }
|
||||||
|
|
||||||
|
/* Lagebild content */
|
||||||
|
.pdf-content { font-size: 10.5pt; line-height: 1.6; }
|
||||||
|
.pdf-content h3 { font-size: 11.5pt; font-weight: 600; color: #2c3e50; margin: 12px 0 5px; }
|
||||||
|
.pdf-content strong { font-weight: 600; }
|
||||||
|
.pdf-content ul { margin: 4px 0 4px 18px; }
|
||||||
|
.pdf-content li { margin-bottom: 2px; }
|
||||||
|
.pdf-content a, .pdf-content .citation { color: #1a5276; font-weight: 600; text-decoration: underline; cursor: pointer; }
|
||||||
|
|
||||||
|
/* Quellen table */
|
||||||
|
.pdf-table { width: 100%; border-collapse: collapse; font-size: 9.5pt; margin-bottom: 14px; }
|
||||||
|
.pdf-table th { background: #f0f0f0; text-align: left; padding: 5px 8px; border: 1px solid #ddd; font-weight: 600; font-size: 8.5pt; text-transform: uppercase; color: #555; }
|
||||||
|
.pdf-table td { padding: 4px 8px; border: 1px solid #ddd; }
|
||||||
|
.pdf-table tr:nth-child(even) { background: #fafafa; }
|
||||||
|
.pdf-article-list { font-size: 9.5pt; }
|
||||||
|
.pdf-article-item { padding: 1px 0; break-inside: avoid; }
|
||||||
|
.pdf-article-item a { color: #1a5276; text-decoration: none; }
|
||||||
|
.pdf-article-item a:hover { text-decoration: underline; }
|
||||||
|
.pdf-date { color: #888; font-size: 8.5pt; }
|
||||||
|
|
||||||
|
/* Faktencheck */
|
||||||
|
.pdf-fc-list { display: flex; flex-direction: column; gap: 10px; }
|
||||||
|
.pdf-fc-item { border: 1px solid #ddd; border-radius: 4px; padding: 8px 12px; break-inside: avoid; }
|
||||||
|
.pdf-fc-badge { display: inline-block; font-size: 7.5pt; font-weight: 700; text-transform: uppercase; letter-spacing: 0.4px; padding: 1px 7px; border-radius: 3px; margin-bottom: 3px; }
|
||||||
|
.pdf-fc-confirmed { background: #d4edda; color: #155724; }
|
||||||
|
.pdf-fc-refuted { background: #f8d7da; color: #721c24; }
|
||||||
|
.pdf-fc-unverified { background: #fff3cd; color: #856404; }
|
||||||
|
.pdf-fc-claim { font-size: 10.5pt; margin-top: 3px; }
|
||||||
|
.pdf-fc-evidence { margin-top: 5px; font-size: 8.5pt; }
|
||||||
|
.pdf-fc-ev-link { color: #1a5276; text-decoration: underline; margin-right: 5px; }
|
||||||
|
.pdf-fc-ev-tag { background: #eee; padding: 1px 5px; border-radius: 3px; margin-right: 3px; }
|
||||||
|
|
||||||
|
/* Timeline */
|
||||||
|
.pdf-timeline h4 { color: #2c3e50; border-bottom: 1px solid #eee; padding-bottom: 2px; margin-top: 8px; }
|
||||||
|
.pdf-tl-item { padding: 1px 0; font-size: 9.5pt; break-inside: avoid; }
|
||||||
|
.pdf-tl-time { color: #888; font-size: 8.5pt; min-width: 36px; display: inline-block; }
|
||||||
|
.pdf-tl-source { color: #888; font-size: 8.5pt; }
|
||||||
|
|
||||||
|
/* Footer */
|
||||||
|
.pdf-footer { margin-top: 24px; padding-top: 8px; border-top: 1px solid #ddd; font-size: 8pt; color: #999; text-align: center; }
|
||||||
|
`;
|
||||||
|
|
||||||
|
const printHtml = '<!DOCTYPE html>\n<html lang="de">\n<head>\n<meta charset="utf-8">\n'
|
||||||
|
+ '<title>' + esc(title) + ' \u2014 AegisSight Export</title>\n'
|
||||||
|
+ '<style>' + css + '</style>\n'
|
||||||
|
+ '</head>\n<body>\n'
|
||||||
|
+ '<div class="pdf-header">\n'
|
||||||
|
+ ' <h1>' + esc(title) + '</h1>\n'
|
||||||
|
+ ' <div class="pdf-subtitle">AegisSight Monitor \u2014 Exportiert am ' + esc(now) + '</div>\n'
|
||||||
|
+ '</div>\n'
|
||||||
|
+ sections + '\n'
|
||||||
|
+ '<div class="pdf-footer">Erstellt mit AegisSight Monitor \u2014 aegis-sight.de</div>\n'
|
||||||
|
+ '</body></html>';
|
||||||
|
|
||||||
|
const printWin = window.open('', '_blank', 'width=800,height=600');
|
||||||
|
if (!printWin) { UI.showToast('Popup blockiert \u2014 bitte Popup-Blocker deaktivieren', 'error'); return; }
|
||||||
|
printWin.document.write(printHtml);
|
||||||
|
printWin.document.close();
|
||||||
|
printWin.onload = function() { printWin.focus(); printWin.print(); };
|
||||||
|
setTimeout(function() { try { printWin.focus(); printWin.print(); } catch(e) {} }, 500);
|
||||||
|
},
|
||||||
|
|
||||||
async exportIncident(format, scope) {
|
async exportIncident(format, scope) {
|
||||||
this._closeExportDropdown();
|
this._closeExportDropdown();
|
||||||
if (!this.currentIncidentId) return;
|
if (!this.currentIncidentId) return;
|
||||||
@@ -2160,10 +2404,7 @@ const App = {
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
printIncident() {
|
|
||||||
this._closeExportDropdown();
|
|
||||||
window.print();
|
|
||||||
},
|
|
||||||
|
|
||||||
// === Sidebar-Stats ===
|
// === Sidebar-Stats ===
|
||||||
|
|
||||||
@@ -2851,7 +3092,7 @@ const App = {
|
|||||||
const discovered = this._discoveredData || {};
|
const discovered = this._discoveredData || {};
|
||||||
const data = {
|
const data = {
|
||||||
name,
|
name,
|
||||||
source_type: document.getElementById('src-type-select') ? document.getElementById('src-type-select').value : (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' ? (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,
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ const Chat = {
|
|||||||
_isOpen: false,
|
_isOpen: false,
|
||||||
_isLoading: false,
|
_isLoading: false,
|
||||||
_hasGreeted: false,
|
_hasGreeted: false,
|
||||||
|
_tutorialHintDismissed: false,
|
||||||
_isFullscreen: false,
|
_isFullscreen: false,
|
||||||
|
|
||||||
init() {
|
init() {
|
||||||
@@ -64,10 +65,13 @@ const Chat = {
|
|||||||
if (!this._hasGreeted) {
|
if (!this._hasGreeted) {
|
||||||
this._hasGreeted = true;
|
this._hasGreeted = true;
|
||||||
this.addMessage('assistant', 'Hallo! Ich bin der AegisSight Assistent. Stell mir gerne jede Frage rund um die Bedienung des Monitors, ich helfe dir weiter.');
|
this.addMessage('assistant', 'Hallo! Ich bin der AegisSight Assistent. Stell mir gerne jede Frage rund um die Bedienung des Monitors, ich helfe dir weiter.');
|
||||||
// Tutorial-Hinweis beim ersten Chat-Oeffnen der Session
|
}
|
||||||
if (typeof Tutorial !== 'undefined' && !sessionStorage.getItem('osint_tutorial_hint_dismissed')) {
|
|
||||||
this._showTutorialHint();
|
// Tutorial-Hinweis bei jedem Oeffnen aktualisieren (wenn nicht dismissed)
|
||||||
}
|
if (typeof Tutorial !== 'undefined' && !this._tutorialHintDismissed) {
|
||||||
|
var oldHint = document.getElementById('chat-tutorial-hint');
|
||||||
|
if (oldHint) oldHint.remove();
|
||||||
|
this._showTutorialHint();
|
||||||
}
|
}
|
||||||
|
|
||||||
// Focus auf Input
|
// Focus auf Input
|
||||||
@@ -288,29 +292,57 @@ const Chat = {
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
_showTutorialHint() {
|
async _showTutorialHint() {
|
||||||
var container = document.getElementById('chat-messages');
|
var container = document.getElementById('chat-messages');
|
||||||
if (!container) return;
|
if (!container) return;
|
||||||
|
|
||||||
|
// API-State laden (Fallback: Standard-Hint)
|
||||||
|
var state = null;
|
||||||
|
try { state = await API.getTutorialState(); } catch(e) {}
|
||||||
|
|
||||||
var hint = document.createElement('div');
|
var hint = document.createElement('div');
|
||||||
hint.className = 'chat-tutorial-hint';
|
hint.className = 'chat-tutorial-hint';
|
||||||
hint.id = 'chat-tutorial-hint';
|
hint.id = 'chat-tutorial-hint';
|
||||||
var textDiv = document.createElement('div');
|
var textDiv = document.createElement('div');
|
||||||
textDiv.className = 'chat-tutorial-hint-text';
|
textDiv.className = 'chat-tutorial-hint-text';
|
||||||
textDiv.innerHTML = '<strong>Tipp:</strong> Kennen Sie schon den interaktiven Rundgang? Er zeigt Ihnen Schritt f\u00fcr Schritt alle Funktionen des Monitors. Klicken Sie hier, um ihn zu starten.';
|
|
||||||
textDiv.style.cursor = 'pointer';
|
textDiv.style.cursor = 'pointer';
|
||||||
textDiv.addEventListener('click', function() {
|
|
||||||
Chat.close();
|
if (state && !state.completed && state.current_step !== null && state.current_step > 0) {
|
||||||
sessionStorage.setItem('osint_tutorial_hint_dismissed', '1');
|
// Mittendrin abgebrochen
|
||||||
if (typeof Tutorial !== 'undefined') Tutorial.start();
|
var totalSteps = (typeof Tutorial !== 'undefined') ? Tutorial._steps.length : 32;
|
||||||
});
|
textDiv.innerHTML = '<strong>Tipp:</strong> Sie haben den Rundgang bei Schritt ' + (state.current_step + 1) + '/' + totalSteps + ' unterbrochen. Klicken Sie hier, um fortzusetzen.';
|
||||||
|
textDiv.addEventListener('click', function() {
|
||||||
|
Chat.close();
|
||||||
|
Chat._tutorialHintDismissed = true;
|
||||||
|
if (typeof Tutorial !== 'undefined') Tutorial.start();
|
||||||
|
});
|
||||||
|
} else if (state && state.completed) {
|
||||||
|
// Bereits abgeschlossen
|
||||||
|
textDiv.innerHTML = '<strong>Tipp:</strong> Sie haben den Rundgang bereits abgeschlossen. <span style="text-decoration:underline;">Erneut starten?</span>';
|
||||||
|
textDiv.addEventListener('click', async function() {
|
||||||
|
Chat.close();
|
||||||
|
Chat._tutorialHintDismissed = true;
|
||||||
|
try { await API.resetTutorialState(); } catch(e) {}
|
||||||
|
if (typeof Tutorial !== 'undefined') Tutorial.start(true);
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
// Nie gestartet
|
||||||
|
textDiv.innerHTML = '<strong>Tipp:</strong> Kennen Sie schon den interaktiven Rundgang? Er zeigt Ihnen Schritt für Schritt alle Funktionen des Monitors. Klicken Sie hier, um ihn zu starten.';
|
||||||
|
textDiv.addEventListener('click', function() {
|
||||||
|
Chat.close();
|
||||||
|
Chat._tutorialHintDismissed = true;
|
||||||
|
if (typeof Tutorial !== 'undefined') Tutorial.start();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
var closeBtn = document.createElement('button');
|
var closeBtn = document.createElement('button');
|
||||||
closeBtn.className = 'chat-tutorial-hint-close';
|
closeBtn.className = 'chat-tutorial-hint-close';
|
||||||
closeBtn.title = 'Schlie\u00dfen';
|
closeBtn.title = 'Schließen';
|
||||||
closeBtn.innerHTML = '×';
|
closeBtn.innerHTML = '×';
|
||||||
closeBtn.addEventListener('click', function(e) {
|
closeBtn.addEventListener('click', function(e) {
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
hint.remove();
|
hint.remove();
|
||||||
sessionStorage.setItem('osint_tutorial_hint_dismissed', '1');
|
Chat._tutorialHintDismissed = true;
|
||||||
});
|
});
|
||||||
hint.appendChild(textDiv);
|
hint.appendChild(textDiv);
|
||||||
hint.appendChild(closeBtn);
|
hint.appendChild(closeBtn);
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ const Tutorial = {
|
|||||||
_resizeHandler: null,
|
_resizeHandler: null,
|
||||||
_demoRunning: false,
|
_demoRunning: false,
|
||||||
_lastExitedStep: -1,
|
_lastExitedStep: -1,
|
||||||
|
_highestStep: -1,
|
||||||
_stepTimers: [], // setTimeout-IDs fuer den aktuellen Step
|
_stepTimers: [], // setTimeout-IDs fuer den aktuellen Step
|
||||||
_savedState: null, // Dashboard-Zustand vor dem Tutorial
|
_savedState: null, // Dashboard-Zustand vor dem Tutorial
|
||||||
|
|
||||||
@@ -1152,14 +1153,52 @@ const Tutorial = {
|
|||||||
// -----------------------------------------------------------------------
|
// -----------------------------------------------------------------------
|
||||||
// Lifecycle
|
// Lifecycle
|
||||||
// -----------------------------------------------------------------------
|
// -----------------------------------------------------------------------
|
||||||
start() {
|
async start(forceRestart) {
|
||||||
if (this._isActive) return;
|
if (this._isActive) return;
|
||||||
this._isActive = true;
|
|
||||||
this._currentStep = -1;
|
|
||||||
|
|
||||||
// Chat schließen falls offen
|
// Chat schliessen falls offen
|
||||||
if (typeof Chat !== 'undefined' && Chat._isOpen) Chat.close();
|
if (typeof Chat !== 'undefined' && Chat._isOpen) Chat.close();
|
||||||
|
|
||||||
|
// Server-State laden (Fallback: direkt starten)
|
||||||
|
var state = null;
|
||||||
|
try { state = await API.getTutorialState(); } catch(e) {}
|
||||||
|
|
||||||
|
// Resume-Dialog wenn mittendrin abgebrochen
|
||||||
|
if (!forceRestart && state && !state.completed && state.current_step !== null && state.current_step > 0) {
|
||||||
|
this._showResumeDialog(state.current_step);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
this._startInternal(forceRestart ? 0 : null);
|
||||||
|
},
|
||||||
|
|
||||||
|
_showResumeDialog(step) {
|
||||||
|
var self = this;
|
||||||
|
var overlay = document.createElement('div');
|
||||||
|
overlay.className = 'tutorial-resume-overlay';
|
||||||
|
overlay.innerHTML = '<div class=tutorial-resume-dialog>'
|
||||||
|
+ '<p>Sie haben den Rundgang bei <strong>Schritt ' + (step + 1) + '/' + this._steps.length + '</strong> unterbrochen.</p>'
|
||||||
|
+ '<div class=tutorial-resume-actions>'
|
||||||
|
+ '<button class=tutorial-btn tutorial-btn-next id=tutorial-resume-btn>Fortsetzen</button>'
|
||||||
|
+ '<button class=tutorial-btn tutorial-btn-secondary id=tutorial-restart-btn>Neu starten</button>'
|
||||||
|
+ '</div></div>';
|
||||||
|
document.body.appendChild(overlay);
|
||||||
|
document.getElementById('tutorial-resume-btn').addEventListener('click', function() {
|
||||||
|
overlay.remove();
|
||||||
|
self._startInternal(step);
|
||||||
|
});
|
||||||
|
document.getElementById('tutorial-restart-btn').addEventListener('click', async function() {
|
||||||
|
overlay.remove();
|
||||||
|
try { await API.resetTutorialState(); } catch(e) {}
|
||||||
|
self._startInternal(0);
|
||||||
|
});
|
||||||
|
},
|
||||||
|
|
||||||
|
_startInternal(resumeStep) {
|
||||||
|
this._isActive = true;
|
||||||
|
this._highestStep = -1;
|
||||||
|
this._currentStep = -1;
|
||||||
|
|
||||||
// Overlay einblenden + Klicks blockieren
|
// Overlay einblenden + Klicks blockieren
|
||||||
this._els.overlay.classList.add('active');
|
this._els.overlay.classList.add('active');
|
||||||
document.body.classList.add('tutorial-active');
|
document.body.classList.add('tutorial-active');
|
||||||
@@ -1172,7 +1211,11 @@ const Tutorial = {
|
|||||||
this._resizeHandler = this._onResize.bind(this);
|
this._resizeHandler = this._onResize.bind(this);
|
||||||
window.addEventListener('resize', this._resizeHandler);
|
window.addEventListener('resize', this._resizeHandler);
|
||||||
|
|
||||||
this.next();
|
if (resumeStep && resumeStep > 0) {
|
||||||
|
this.goToStep(resumeStep);
|
||||||
|
} else {
|
||||||
|
this.next();
|
||||||
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
stop() {
|
stop() {
|
||||||
@@ -1230,7 +1273,14 @@ const Tutorial = {
|
|||||||
this._resizeHandler = null;
|
this._resizeHandler = null;
|
||||||
}
|
}
|
||||||
|
|
||||||
this._markSeen();
|
// Fortschritt serverseitig speichern
|
||||||
|
if (this._lastExitedStep >= 0 && this._lastExitedStep < this._steps.length - 1) {
|
||||||
|
// Mittendrin abgebrochen — Schritt speichern
|
||||||
|
API.saveTutorialState({ current_step: this._lastExitedStep }).catch(function() {});
|
||||||
|
} else {
|
||||||
|
// Komplett durchlaufen oder letzter Schritt
|
||||||
|
this._markSeen();
|
||||||
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
// -----------------------------------------------------------------------
|
// -----------------------------------------------------------------------
|
||||||
@@ -1258,6 +1308,7 @@ const Tutorial = {
|
|||||||
|
|
||||||
if (this._currentStep >= 0) this._exitStep(this._currentStep);
|
if (this._currentStep >= 0) this._exitStep(this._currentStep);
|
||||||
this._currentStep = index;
|
this._currentStep = index;
|
||||||
|
if (index > this._highestStep) this._highestStep = index;
|
||||||
this._enterStep(index);
|
this._enterStep(index);
|
||||||
},
|
},
|
||||||
|
|
||||||
@@ -2231,6 +2282,7 @@ const Tutorial = {
|
|||||||
// -----------------------------------------------------------------------
|
// -----------------------------------------------------------------------
|
||||||
_markSeen() {
|
_markSeen() {
|
||||||
try { localStorage.setItem('osint_tutorial_seen', '1'); } catch(e) {}
|
try { localStorage.setItem('osint_tutorial_seen', '1'); } catch(e) {}
|
||||||
|
API.saveTutorialState({ completed: true, current_step: null }).catch(function() {});
|
||||||
},
|
},
|
||||||
|
|
||||||
_hasSeen() {
|
_hasSeen() {
|
||||||
|
|||||||
In neuem Issue referenzieren
Einen Benutzer sperren