Commits vergleichen

...

11 Commits

Autor SHA1 Nachricht Datum
Claude Dev
6b4af4cf2a fix: justify-content: center überall wiederhergestellt + Quellen-Duplikatprüfung
- CSS: 24x fälschliches flex-start zurück auf center (Login, Buttons, Modals, Badges, Map etc.)
- Sources: Domain-Duplikatprüfung bei manuellem Hinzufügen (web_source 1x pro Domain, Domain aus URL extrahieren)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-18 00:13:36 +01:00
Claude Dev
17088e588f fix: Credits-Dropdown linksbündig, Balken-Track sichtbar, Prozentzahl rechts, kein Fettdruck, mehr Abstand 2026-03-18 00:08:20 +01:00
Claude Dev
97997724de fix: Credits-Anzeige linksbündig, Balken-Hintergrund sichtbar 2026-03-18 00:03:18 +01:00
Claude Dev
acb3c6a6cb feat: Netzwerkanalyse Qualitätsverbesserung — 3 neue Cleanup-Stufen
Phase 1: Entity-Map Key nur noch name_normalized (statt name+type), Typ-Priorität bei Konflikten
Phase 2a (neu): Code-basierte Dedup nach name_normalized, merged Typ-Duplikate
Phase 2c (neu): Semantische Dedup via Opus — erkennt Synonyme, Abkürzungen, Sprachvarianten
Phase 2d (neu): Cleanup — Self-Loops, Richtungsnormalisierung, Duplikat-Relations, verwaiste Entities
Gemeinsamer _merge_entity_in_db Helper für konsistente Entity-Zusammenführung
Phase 2b (Opus-Korrekturpass) entfernt, ersetzt durch präzisere Phase 2c

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-17 23:53:28 +01:00
Claude Dev
7bfa1d29cf feat: Credits-System mit Verbrauchsanzeige im User-Dropdown
- DB-Migration: credits_total/credits_used/cost_per_credit auf licenses, token_usage_monthly Tabelle
- Orchestrator: Monatliche Token-Aggregation + Credits-Abzug nach Refresh
- Auth: Credits-Daten im /me Endpoint + Bugfix fehlende Klammer in get()
- Frontend: Credits-Balken im User-Dropdown mit Farbwechsel

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-17 23:53:19 +01:00
Claude Dev
4d6d022bee refactor: Netzwerkanalyse Phase 2 auf batched Extraktion umgestellt
- Statt einem Mega-Opus-Call: Haiku extrahiert Beziehungen pro Artikel-Batch
- Stufe A: Per-Batch Extraktion mit nur den relevanten Entitäten (~20-50 statt 3.463)
- Stufe B: Globaler Merge + Deduplizierung (Richtungsnormalisierung, Gewichts-Boost)
- Phase 2b: Separater Opus-Korrekturpass (name_fix, merge, add) in 500er-Batches
- Löst das Problem: 0 Relations bei großen Analysen (3.463 Entitäten -> 1.791 Relations)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-17 22:51:17 +01:00
Claude Dev
5e194d43e0 feat: Tutorial-Fortschritt serverseitig persistieren (Resume/Restart)
- Neuer Router /api/tutorial mit GET/PUT/DELETE für Fortschritt pro User
- DB-Migration: tutorial_step + tutorial_completed in users-Tabelle
- Resume-Dialog bei abgebrochenem Tutorial (Fortsetzen/Neu starten)
- Chat-Hinweis passt sich dem Tutorial-Status dynamisch an
- API-Methoden: getTutorialState, saveTutorialState, resetTutorialState

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-17 22:51:06 +01:00
Claude Dev
4b9ed6439a fix: PDF-Export verbessert - Seitenraender, kein Abschneiden, klickbare Links
- @page margins 18mm/15mm fuer korrekte Seitenraender
- Header kompakt: Titel + Lagebild direkt auf Seite 1 (keine leere Seite)
- Citations/Quellenverweise als klickbare unterstrichene Links im PDF
- break-inside:avoid statt page-break-inside fuer korrekte Seitenumbrueche
- Kartenexport entfernt (nicht sinnvoll als PDF)
- Schriftgroessen leicht reduziert fuer bessere Platznutzung
2026-03-17 10:34:38 +01:00
Claude Dev
0b3fbb1efc feat: PDF-Export mit Kachel-Auswahl und hellem Drucklayout
- Neuer PDF-Export-Dialog mit Checkboxen: Lagebild, Quellen, Faktencheck, Karte, Timeline
- Helles, schlichtes Drucklayout (weiss, Serifenlos, A4-optimiert)
- Oeffnet neues Fenster mit sauberem HTML fuer Drucken/PDF-Speichern
- Ersetzt alte window.print() Funktion die das dunkle Theme exportierte
- Quellenübersicht als Tabelle + Artikelliste mit Links
- Faktencheck mit farbcodierten Status-Badges
2026-03-17 10:29:01 +01:00
Claude Dev
474e2beca9 fix: URL-Verifizierung fuer WebSearch-Ergebnisse
- Prompt-Verbesserung: Claude muss exakte URLs aus WebSearch kopieren, keine konstruierten URLs
- Neue _verify_article_urls() Funktion im Orchestrator
- HEAD-Request auf jede WebSearch-URL, GET-Fallback bei 405
- Bei 404/unerreichbar: Ersetzung durch Google-Suchlink (site:domain headline)
- Nur WebSearch-URLs werden geprueft, RSS-URLs sind bereits verifiziert
2026-03-17 10:22:01 +01:00
Claude Dev
742f49467e fix: saveSource() liest source_type wieder aus _discoveredData statt Select
Stellt das Original-Verhalten wieder her: Der Typ wird aus den
Auto-Discovery-Daten gelesen, nicht aus dem versteckten Select-Element.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-16 23:55:42 +01:00
16 geänderte Dateien mit 2153 neuen und 775 gelöschten Zeilen

234
regenerate_relations.py Normale Datei
Datei anzeigen

@@ -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

Datei anzeigen

@@ -6,7 +6,9 @@ import re
from datetime import datetime
from config import TIMEZONE
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.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)
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]):
"""Background-Task: Registriert seriöse, unbekannte Quellen aus Recherche-Ergebnissen."""
from database import get_db
@@ -692,6 +768,10 @@ class AgentOrchestrator:
(search_results, search_usage) = pipeline_results[1]
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:
usage_acc.add(rss_feed_usage)
if search_usage:
@@ -1236,6 +1316,41 @@ class AgentOrchestrator:
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
if unique_results:
asyncio.create_task(_background_discover_sources(unique_results))

Datei anzeigen

@@ -21,6 +21,7 @@ REGELN:
- KEINE Boulevardmedien (Bild, Sun, Daily Mail etc.)
{language_instruction}
- Faktenbasiert und neutral - keine Spekulationen
- KRITISCH für source_url: Kopiere die EXAKTE URL aus den WebSearch-Ergebnissen. Erfinde oder konstruiere NIEMALS URLs aus Mustern oder Erinnerung. Wenn du die exakte URL eines Artikels nicht aus den Suchergebnissen hast, lass diesen Artikel komplett weg.
- Nutze 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
@@ -82,6 +83,7 @@ AUSSCHLUSS:
- KEIN Social Media (Twitter/X, Facebook, Instagram, TikTok, Reddit)
- KEINE Boulevardmedien (Bild, Sun, Daily Mail etc.)
- 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.
Jedes Element hat diese Felder:

Datei anzeigen

@@ -464,6 +464,13 @@ async def init_db():
await db.execute("ALTER TABLE users ADD COLUMN is_active INTEGER DEFAULT 1")
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:
await db.execute("ALTER TABLE users ADD COLUMN last_login_at TIMESTAMP")
await db.commit()
@@ -575,7 +582,42 @@ async def init_db():
await db.commit()
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(
"""UPDATE refresh_log SET status = 'error', error_message = 'Verwaist beim Neustart',
completed_at = CURRENT_TIMESTAMP

Datei anzeigen

@@ -333,6 +333,7 @@ from routers.feedback import router as feedback_router
from routers.public_api import router as public_api_router
from routers.chat import router as chat_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(incidents_router)
@@ -342,6 +343,7 @@ app.include_router(feedback_router)
app.include_router(public_api_router)
app.include_router(chat_router, prefix="/api/chat")
app.include_router(network_analysis_router)
app.include_router(tutorial_router)
@app.websocket("/api/ws")

Datei anzeigen

@@ -41,6 +41,9 @@ class UserMeResponse(BaseModel):
license_status: str = "unknown"
license_type: str = ""
read_only: bool = False
credits_total: Optional[int] = None
credits_remaining: Optional[int] = None
credits_percent_used: Optional[float] = None
# Incidents (Lagen)

Datei anzeigen

@@ -261,10 +261,28 @@ async def get_me(
from services.license_service import check_license
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(
id=current_user["id"],
username=current_user["username"],
email=current_user.get("email", ""),
credits_total=credits_total,
credits_remaining=credits_remaining,
credits_percent_used=credits_percent_used,
role=current_user["role"],
org_name=org_name,
org_slug=current_user.get("org_slug", ""),

Datei anzeigen

@@ -415,12 +415,14 @@ async def create_source(
"""Neue Quelle hinzufuegen (org-spezifisch)."""
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
if not domain and data.url:
domain = _extract_domain(data.url)
if domain:
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:
cursor = await db.execute(
"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']})",
)
# 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(
"""INSERT INTO sources (name, url, domain, source_type, category, status, notes, added_by, tenant_id)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)""",

77
src/routers/tutorial.py Normale Datei
Datei anzeigen

@@ -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}

Datei anzeigen

@@ -4231,6 +4231,33 @@ select:focus-visible, textarea:focus-visible,
}
/* === 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 {
.sidebar,
.header,
@@ -5308,3 +5335,69 @@ body.tutorial-active .tutorial-cursor {
color: var(--text-primary);
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);
}

Datei anzeigen

@@ -50,6 +50,17 @@
<span class="header-dropdown-label">Lizenz</span>
<span class="header-dropdown-value" id="header-license-info">-</span>
</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 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('json','full')">Vollexport (JSON)</button>
<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>
<button class="btn btn-secondary btn-small" id="archive-incident-btn">Archivieren</button>
@@ -781,5 +792,29 @@
</div>
<div class="map-fullscreen-container" id="map-fullscreen-container"></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">&times;</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>
</html>

Datei anzeigen

@@ -215,6 +215,19 @@ const API = {
},
// 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) {
const token = localStorage.getItem('osint_token');
return fetch(`${this.baseUrl}/incidents/${id}/export?format=${format}&scope=${scope}`, {

Datei anzeigen

@@ -466,6 +466,34 @@ const App = {
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
const userBtn = document.getElementById('header-user-btn');
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,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;').replace(/"/g,'&quot;') : '';
// === 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) {
this._closeExportDropdown();
if (!this.currentIncidentId) return;
@@ -2160,10 +2404,7 @@ const App = {
}
},
printIncident() {
this._closeExportDropdown();
window.print();
},
// === Sidebar-Stats ===
@@ -2851,7 +3092,7 @@ const App = {
const discovered = this._discoveredData || {};
const data = {
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,
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,

Datei anzeigen

@@ -6,6 +6,7 @@ const Chat = {
_isOpen: false,
_isLoading: false,
_hasGreeted: false,
_tutorialHintDismissed: false,
_isFullscreen: false,
init() {
@@ -64,10 +65,13 @@ const Chat = {
if (!this._hasGreeted) {
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.');
// 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
@@ -288,29 +292,57 @@ const Chat = {
}
},
_showTutorialHint() {
async _showTutorialHint() {
var container = document.getElementById('chat-messages');
if (!container) return;
// API-State laden (Fallback: Standard-Hint)
var state = null;
try { state = await API.getTutorialState(); } catch(e) {}
var hint = document.createElement('div');
hint.className = 'chat-tutorial-hint';
hint.id = 'chat-tutorial-hint';
var textDiv = document.createElement('div');
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.addEventListener('click', function() {
Chat.close();
sessionStorage.setItem('osint_tutorial_hint_dismissed', '1');
if (typeof Tutorial !== 'undefined') Tutorial.start();
});
if (state && !state.completed && state.current_step !== null && state.current_step > 0) {
// Mittendrin abgebrochen
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');
closeBtn.className = 'chat-tutorial-hint-close';
closeBtn.title = 'Schlie\u00dfen';
closeBtn.title = 'Schließen';
closeBtn.innerHTML = '&times;';
closeBtn.addEventListener('click', function(e) {
e.stopPropagation();
hint.remove();
sessionStorage.setItem('osint_tutorial_hint_dismissed', '1');
Chat._tutorialHintDismissed = true;
});
hint.appendChild(textDiv);
hint.appendChild(closeBtn);

Datei anzeigen

@@ -12,6 +12,7 @@ const Tutorial = {
_resizeHandler: null,
_demoRunning: false,
_lastExitedStep: -1,
_highestStep: -1,
_stepTimers: [], // setTimeout-IDs fuer den aktuellen Step
_savedState: null, // Dashboard-Zustand vor dem Tutorial
@@ -1152,14 +1153,52 @@ const Tutorial = {
// -----------------------------------------------------------------------
// Lifecycle
// -----------------------------------------------------------------------
start() {
async start(forceRestart) {
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();
// 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
this._els.overlay.classList.add('active');
document.body.classList.add('tutorial-active');
@@ -1172,7 +1211,11 @@ const Tutorial = {
this._resizeHandler = this._onResize.bind(this);
window.addEventListener('resize', this._resizeHandler);
this.next();
if (resumeStep && resumeStep > 0) {
this.goToStep(resumeStep);
} else {
this.next();
}
},
stop() {
@@ -1230,7 +1273,14 @@ const Tutorial = {
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);
this._currentStep = index;
if (index > this._highestStep) this._highestStep = index;
this._enterStep(index);
},
@@ -2231,6 +2282,7 @@ const Tutorial = {
// -----------------------------------------------------------------------
_markSeen() {
try { localStorage.setItem('osint_tutorial_seen', '1'); } catch(e) {}
API.saveTutorialState({ completed: true, current_step: null }).catch(function() {});
},
_hasSeen() {