From 801944a7ea53c85720d2e30dfbd4b043478a8750 Mon Sep 17 00:00:00 2001 From: claude-dev Date: Thu, 5 Mar 2026 20:04:18 +0100 Subject: [PATCH] feat: Discovery-Funktion in Grundquellen-Verwaltung integriert MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - POST /api/sources/discover: URL analysieren, RSS-Feeds erkennen, Duplikate prüfen - POST /api/sources/discover/add: Erkannte Feeds als Grundquellen anlegen (inkl. Web-Source) - Erkennen-Button und Modal im Dashboard mit Feed-Auswahl per Checkbox - Duplikat-Erkennung zeigt bereits vorhandene Grundquellen an - source_rules aus Monitor importiert für Feed-Discovery und Claude-Bewertung - config.py um Discovery-Konfiguration erweitert Co-Authored-By: Claude Opus 4.6 --- src/config.py | 5 + src/routers/sources.py | 481 ++++++++++++++++++++----------- src/static/dashboard.html | 28 ++ src/static/js/sources.js | 584 ++++++++++++++++++++++---------------- 4 files changed, 689 insertions(+), 409 deletions(-) diff --git a/src/config.py b/src/config.py index baa3ca7..ba379d7 100644 --- a/src/config.py +++ b/src/config.py @@ -30,3 +30,8 @@ SMTP_USE_TLS = os.environ.get("SMTP_USE_TLS", "true").lower() == "true" # Magic Link Base URL (fuer OSINT-Monitor Einladungen) MAGIC_LINK_BASE_URL = os.environ.get("MAGIC_LINK_BASE_URL", "https://osint.intelsight.de") MAGIC_LINK_EXPIRE_MINUTES = 10 + +# Source Discovery (geteilte Config mit OSINT-Monitor) +CLAUDE_PATH = os.environ.get("CLAUDE_PATH", "/home/claude-dev/.claude/local/claude") +CLAUDE_TIMEOUT = 300 +MAX_FEEDS_PER_DOMAIN = 3 diff --git a/src/routers/sources.py b/src/routers/sources.py index 047dc2b..3a5a7bd 100644 --- a/src/routers/sources.py +++ b/src/routers/sources.py @@ -1,167 +1,314 @@ -"""Grundquellen-Verwaltung und Kundenquellen-Übersicht.""" -from fastapi import APIRouter, Depends, HTTPException, status -from pydantic import BaseModel, Field -from typing import Optional -from auth import get_current_admin -from database import db_dependency -import aiosqlite - -router = APIRouter(prefix="/api/sources", tags=["sources"]) - - -class GlobalSourceCreate(BaseModel): - name: str = Field(min_length=1, max_length=200) - url: Optional[str] = None - domain: Optional[str] = None - source_type: str = Field(default="rss_feed", pattern="^(rss_feed|web_source|excluded)$") - category: str = Field(default="sonstige") - status: str = Field(default="active", pattern="^(active|inactive)$") - notes: Optional[str] = None - - -class GlobalSourceUpdate(BaseModel): - name: Optional[str] = Field(default=None, max_length=200) - url: Optional[str] = None - domain: Optional[str] = None - source_type: Optional[str] = Field(default=None, pattern="^(rss_feed|web_source|excluded)$") - category: Optional[str] = None - status: Optional[str] = Field(default=None, pattern="^(active|inactive)$") - notes: Optional[str] = None - - -@router.get("/global") -async def list_global_sources( - admin: dict = Depends(get_current_admin), - db: aiosqlite.Connection = Depends(db_dependency), -): - """Alle Grundquellen auflisten (tenant_id IS NULL).""" - cursor = await db.execute( - "SELECT * FROM sources WHERE tenant_id IS NULL ORDER BY category, source_type, name" - ) - return [dict(row) for row in await cursor.fetchall()] - - -@router.post("/global", status_code=201) -async def create_global_source( - data: GlobalSourceCreate, - admin: dict = Depends(get_current_admin), - db: aiosqlite.Connection = Depends(db_dependency), -): - """Neue Grundquelle anlegen.""" - if data.url: - cursor = await db.execute( - "SELECT id, name FROM sources WHERE url = ? AND tenant_id IS NULL", - (data.url,), - ) - existing = await cursor.fetchone() - if existing: - raise HTTPException( - status_code=409, - detail=f"URL bereits vorhanden: {existing['name']}", - ) - - cursor = await db.execute( - """INSERT INTO sources (name, url, domain, source_type, category, status, notes, added_by, tenant_id) - VALUES (?, ?, ?, ?, ?, ?, ?, 'system', NULL)""", - (data.name, data.url, data.domain, data.source_type, data.category, data.status, data.notes), - ) - await db.commit() - - cursor = await db.execute("SELECT * FROM sources WHERE id = ?", (cursor.lastrowid,)) - return dict(await cursor.fetchone()) - - -@router.put("/global/{source_id}") -async def update_global_source( - source_id: int, - data: GlobalSourceUpdate, - admin: dict = Depends(get_current_admin), - db: aiosqlite.Connection = Depends(db_dependency), -): - """Grundquelle bearbeiten.""" - cursor = await db.execute( - "SELECT * FROM sources WHERE id = ? AND tenant_id IS NULL", (source_id,) - ) - row = await cursor.fetchone() - if not row: - raise HTTPException(status_code=404, detail="Grundquelle nicht gefunden") - - updates = {} - for field, value in data.model_dump(exclude_none=True).items(): - updates[field] = value - - if not updates: - return dict(row) - - set_clause = ", ".join(f"{k} = ?" for k in updates) - values = list(updates.values()) + [source_id] - await db.execute(f"UPDATE sources SET {set_clause} WHERE id = ?", values) - await db.commit() - - cursor = await db.execute("SELECT * FROM sources WHERE id = ?", (source_id,)) - return dict(await cursor.fetchone()) - - -@router.delete("/global/{source_id}", status_code=204) -async def delete_global_source( - source_id: int, - admin: dict = Depends(get_current_admin), - db: aiosqlite.Connection = Depends(db_dependency), -): - """Grundquelle loeschen.""" - cursor = await db.execute( - "SELECT id FROM sources WHERE id = ? AND tenant_id IS NULL", (source_id,) - ) - if not await cursor.fetchone(): - raise HTTPException(status_code=404, detail="Grundquelle nicht gefunden") - - await db.execute("DELETE FROM sources WHERE id = ?", (source_id,)) - await db.commit() - - -@router.get("/tenant") -async def list_tenant_sources( - admin: dict = Depends(get_current_admin), - db: aiosqlite.Connection = Depends(db_dependency), -): - """Alle tenant-spezifischen Quellen mit Org-Name auflisten.""" - cursor = await db.execute(""" - SELECT s.*, o.name as org_name - FROM sources s - LEFT JOIN organizations o ON o.id = s.tenant_id - WHERE s.tenant_id IS NOT NULL - ORDER BY o.name, s.category, s.name - """) - return [dict(row) for row in await cursor.fetchall()] - - -@router.post("/tenant/{source_id}/promote") -async def promote_to_global( - source_id: int, - admin: dict = Depends(get_current_admin), - db: aiosqlite.Connection = Depends(db_dependency), -): - """Tenant-Quelle zur Grundquelle befoerdern.""" - cursor = await db.execute("SELECT * FROM sources WHERE id = ?", (source_id,)) - row = await cursor.fetchone() - if not row: - raise HTTPException(status_code=404, detail="Quelle nicht gefunden") - if row["tenant_id"] is None: - raise HTTPException(status_code=400, detail="Bereits eine Grundquelle") - - if row["url"]: - cursor = await db.execute( - "SELECT id FROM sources WHERE url = ? AND tenant_id IS NULL", - (row["url"],), - ) - if await cursor.fetchone(): - raise HTTPException(status_code=409, detail="URL bereits als Grundquelle vorhanden") - - await db.execute( - "UPDATE sources SET tenant_id = NULL, added_by = 'system' WHERE id = ?", - (source_id,), - ) - await db.commit() - - cursor = await db.execute("SELECT * FROM sources WHERE id = ?", (source_id,)) - return dict(await cursor.fetchone()) +"""Grundquellen-Verwaltung und Kundenquellen-Übersicht.""" +import sys +import logging + +# Monitor-Source-Rules verfügbar machen +sys.path.insert(0, "/home/claude-dev/AegisSight-Monitor/src") + +from fastapi import APIRouter, Depends, HTTPException, status +from pydantic import BaseModel, Field +from typing import Optional +from auth import get_current_admin +from database import db_dependency +import aiosqlite + +from source_rules import ( + discover_source, + discover_all_feeds, + evaluate_feeds_with_claude, + _extract_domain, + _detect_category, + domain_to_display_name, +) + +logger = logging.getLogger("verwaltung.sources") + +router = APIRouter(prefix="/api/sources", tags=["sources"]) + + +class GlobalSourceCreate(BaseModel): + name: str = Field(min_length=1, max_length=200) + url: Optional[str] = None + domain: Optional[str] = None + source_type: str = Field(default="rss_feed", pattern="^(rss_feed|web_source|excluded)$") + category: str = Field(default="sonstige") + status: str = Field(default="active", pattern="^(active|inactive)$") + notes: Optional[str] = None + + +class GlobalSourceUpdate(BaseModel): + name: Optional[str] = Field(default=None, max_length=200) + url: Optional[str] = None + domain: Optional[str] = None + source_type: Optional[str] = Field(default=None, pattern="^(rss_feed|web_source|excluded)$") + category: Optional[str] = None + status: Optional[str] = Field(default=None, pattern="^(active|inactive)$") + notes: Optional[str] = None + + +@router.get("/global") +async def list_global_sources( + admin: dict = Depends(get_current_admin), + db: aiosqlite.Connection = Depends(db_dependency), +): + """Alle Grundquellen auflisten (tenant_id IS NULL).""" + cursor = await db.execute( + "SELECT * FROM sources WHERE tenant_id IS NULL ORDER BY category, source_type, name" + ) + return [dict(row) for row in await cursor.fetchall()] + + +@router.post("/global", status_code=201) +async def create_global_source( + data: GlobalSourceCreate, + admin: dict = Depends(get_current_admin), + db: aiosqlite.Connection = Depends(db_dependency), +): + """Neue Grundquelle anlegen.""" + if data.url: + cursor = await db.execute( + "SELECT id, name FROM sources WHERE url = ? AND tenant_id IS NULL", + (data.url,), + ) + existing = await cursor.fetchone() + if existing: + raise HTTPException( + status_code=409, + detail=f"URL bereits vorhanden: {existing['name']}", + ) + + cursor = await db.execute( + """INSERT INTO sources (name, url, domain, source_type, category, status, notes, added_by, tenant_id) + VALUES (?, ?, ?, ?, ?, ?, ?, 'system', NULL)""", + (data.name, data.url, data.domain, data.source_type, data.category, data.status, data.notes), + ) + await db.commit() + + cursor = await db.execute("SELECT * FROM sources WHERE id = ?", (cursor.lastrowid,)) + return dict(await cursor.fetchone()) + + +@router.put("/global/{source_id}") +async def update_global_source( + source_id: int, + data: GlobalSourceUpdate, + admin: dict = Depends(get_current_admin), + db: aiosqlite.Connection = Depends(db_dependency), +): + """Grundquelle bearbeiten.""" + cursor = await db.execute( + "SELECT * FROM sources WHERE id = ? AND tenant_id IS NULL", (source_id,) + ) + row = await cursor.fetchone() + if not row: + raise HTTPException(status_code=404, detail="Grundquelle nicht gefunden") + + updates = {} + for field, value in data.model_dump(exclude_none=True).items(): + updates[field] = value + + if not updates: + return dict(row) + + set_clause = ", ".join(f"{k} = ?" for k in updates) + values = list(updates.values()) + [source_id] + await db.execute(f"UPDATE sources SET {set_clause} WHERE id = ?", values) + await db.commit() + + cursor = await db.execute("SELECT * FROM sources WHERE id = ?", (source_id,)) + return dict(await cursor.fetchone()) + + +@router.delete("/global/{source_id}", status_code=204) +async def delete_global_source( + source_id: int, + admin: dict = Depends(get_current_admin), + db: aiosqlite.Connection = Depends(db_dependency), +): + """Grundquelle loeschen.""" + cursor = await db.execute( + "SELECT id FROM sources WHERE id = ? AND tenant_id IS NULL", (source_id,) + ) + if not await cursor.fetchone(): + raise HTTPException(status_code=404, detail="Grundquelle nicht gefunden") + + await db.execute("DELETE FROM sources WHERE id = ?", (source_id,)) + await db.commit() + + +@router.get("/tenant") +async def list_tenant_sources( + admin: dict = Depends(get_current_admin), + db: aiosqlite.Connection = Depends(db_dependency), +): + """Alle tenant-spezifischen Quellen mit Org-Name auflisten.""" + cursor = await db.execute(""" + SELECT s.*, o.name as org_name + FROM sources s + LEFT JOIN organizations o ON o.id = s.tenant_id + WHERE s.tenant_id IS NOT NULL + ORDER BY o.name, s.category, s.name + """) + return [dict(row) for row in await cursor.fetchall()] + + +@router.post("/tenant/{source_id}/promote") +async def promote_to_global( + source_id: int, + admin: dict = Depends(get_current_admin), + db: aiosqlite.Connection = Depends(db_dependency), +): + """Tenant-Quelle zur Grundquelle befoerdern.""" + cursor = await db.execute("SELECT * FROM sources WHERE id = ?", (source_id,)) + row = await cursor.fetchone() + if not row: + raise HTTPException(status_code=404, detail="Quelle nicht gefunden") + if row["tenant_id"] is None: + raise HTTPException(status_code=400, detail="Bereits eine Grundquelle") + + if row["url"]: + cursor = await db.execute( + "SELECT id FROM sources WHERE url = ? AND tenant_id IS NULL", + (row["url"],), + ) + if await cursor.fetchone(): + raise HTTPException(status_code=409, detail="URL bereits als Grundquelle vorhanden") + + await db.execute( + "UPDATE sources SET tenant_id = NULL, added_by = 'system' WHERE id = ?", + (source_id,), + ) + await db.commit() + + cursor = await db.execute("SELECT * FROM sources WHERE id = ?", (source_id,)) + return dict(await cursor.fetchone()) + + + +@router.post("/discover") +async def discover_source_endpoint( + url: str, + admin: dict = Depends(get_current_admin), + db: aiosqlite.Connection = Depends(db_dependency), +): + """URL analysieren: Domain, Kategorie und RSS-Feeds automatisch erkennen. + + Findet alle Feeds einer Domain, bewertet sie mit Claude und gibt + die relevanten zurueck. Prueft auf bereits vorhandene Grundquellen. + """ + try: + multi = await discover_all_feeds(url) + except Exception as e: + logger.error(f"Discovery fehlgeschlagen: {e}", exc_info=True) + raise HTTPException(status_code=500, detail="Discovery fehlgeschlagen") + + domain = multi["domain"] + category = multi["category"] + feeds = multi.get("feeds", []) + + # Fallback auf Einzel-Discovery wenn keine Feeds gefunden + if not feeds: + try: + single = await discover_source(url) + if single.get("rss_url"): + feeds = [{"name": single["name"], "url": single["rss_url"]}] + domain = single.get("domain", domain) + category = single.get("category", category) + except Exception: + pass + + if not feeds: + return { + "domain": domain, + "category": category, + "feeds": [], + "existing": [], + "message": "Keine RSS-Feeds gefunden", + } + + # Mit Claude bewerten + try: + relevant_feeds = await evaluate_feeds_with_claude(domain, feeds) + except Exception: + relevant_feeds = feeds[:3] + + # Bereits vorhandene Grundquellen pruefen + cursor = await db.execute( + "SELECT url FROM sources WHERE tenant_id IS NULL AND url IS NOT NULL" + ) + existing_urls = {row["url"] for row in await cursor.fetchall()} + + result_feeds = [] + existing = [] + for feed in relevant_feeds: + info = { + "name": feed.get("name", domain_to_display_name(domain)), + "url": feed["url"], + "domain": domain, + "category": category, + } + if feed["url"] in existing_urls: + existing.append(info) + else: + result_feeds.append(info) + + return { + "domain": domain, + "category": category, + "feeds": result_feeds, + "existing": existing, + } + + +@router.post("/discover/add") +async def add_discovered_sources( + feeds: list[dict], + admin: dict = Depends(get_current_admin), + db: aiosqlite.Connection = Depends(db_dependency), +): + """Erkannte Feeds als Grundquellen anlegen. + + Erwartet eine Liste von {name, url, domain, category}. + Ueberspringt bereits vorhandene URLs. + """ + cursor = await db.execute( + "SELECT url FROM sources WHERE tenant_id IS NULL AND url IS NOT NULL" + ) + existing_urls = {row["url"] for row in await cursor.fetchall()} + + added = 0 + skipped = 0 + for feed in feeds: + if not feed.get("url"): + continue + if feed["url"] in existing_urls: + skipped += 1 + continue + + domain = feed.get("domain", "") + await db.execute( + """INSERT INTO sources (name, url, domain, source_type, category, status, added_by, tenant_id) + VALUES (?, ?, ?, 'rss_feed', ?, 'active', 'system', NULL)""", + (feed["name"], feed["url"], domain, feed.get("category", "sonstige")), + ) + existing_urls.add(feed["url"]) + added += 1 + + # Web-Source für die Domain anlegen wenn noch nicht vorhanden + if feeds and feeds[0].get("domain"): + domain = feeds[0]["domain"] + cursor = await db.execute( + "SELECT id FROM sources WHERE LOWER(domain) = ? AND source_type = 'web_source' AND tenant_id IS NULL", + (domain.lower(),), + ) + if not await cursor.fetchone(): + await db.execute( + """INSERT INTO sources (name, url, domain, source_type, category, status, added_by, tenant_id) + VALUES (?, ?, ?, 'web_source', ?, 'active', 'system', NULL)""", + (domain_to_display_name(domain), f"https://{domain}", domain, + feeds[0].get("category", "sonstige")), + ) + added += 1 + + await db.commit() + return {"added": added, "skipped": skipped} diff --git a/src/static/dashboard.html b/src/static/dashboard.html index a792cc0..79d255d 100644 --- a/src/static/dashboard.html +++ b/src/static/dashboard.html @@ -209,6 +209,7 @@ +
@@ -421,6 +422,33 @@
+ + +