diff --git a/src/agents/orchestrator.py b/src/agents/orchestrator.py index ebc1809..25c8022 100644 --- a/src/agents/orchestrator.py +++ b/src/agents/orchestrator.py @@ -332,7 +332,7 @@ class AgentOrchestrator: self._running = False logger.info("Agenten-Orchestrator gestoppt") - async def enqueue_refresh(self, incident_id: int, trigger_type: str = "manual") -> bool: + async def enqueue_refresh(self, incident_id: int, trigger_type: str = "manual", user_id: int = None) -> bool: """Refresh-Auftrag in die Queue stellen. Gibt False zurueck wenn bereits in Queue/aktiv.""" if incident_id in self._queued_ids or self._current_task == incident_id: logger.info(f"Refresh fuer Lage {incident_id} uebersprungen: bereits aktiv/in Queue") @@ -341,7 +341,7 @@ class AgentOrchestrator: visibility, created_by, tenant_id = await self._get_incident_visibility(incident_id) self._queued_ids.add(incident_id) - await self._queue.put((incident_id, trigger_type)) + await self._queue.put((incident_id, trigger_type, user_id)) queue_size = self._queue.qsize() logger.info(f"Refresh fuer Lage {incident_id} eingereiht (Queue: {queue_size}, Trigger: {trigger_type})") @@ -386,7 +386,11 @@ class AgentOrchestrator: except asyncio.TimeoutError: continue - incident_id, trigger_type = item + if len(item) == 3: + incident_id, trigger_type, user_id = item + else: + incident_id, trigger_type = item + user_id = None self._queued_ids.discard(incident_id) self._current_task = incident_id logger.info(f"Starte Refresh für Lage {incident_id} (Trigger: {trigger_type})") @@ -398,7 +402,7 @@ class AgentOrchestrator: try: for attempt in range(3): try: - await self._run_refresh(incident_id, trigger_type=trigger_type, retry_count=attempt) + await self._run_refresh(incident_id, trigger_type=trigger_type, retry_count=attempt, user_id=user_id) last_error = None break # Erfolg except asyncio.CancelledError: @@ -509,7 +513,7 @@ class AgentOrchestrator: await db.close() return visibility, created_by, tenant_id - async def _run_refresh(self, incident_id: int, trigger_type: str = "manual", retry_count: int = 0): + async def _run_refresh(self, incident_id: int, trigger_type: str = "manual", retry_count: int = 0, user_id: int = None): """Führt einen kompletten Refresh-Zyklus durch.""" import aiosqlite from database import get_db @@ -604,7 +608,7 @@ class AgentOrchestrator: keywords = feed_sel_keywords articles = await rss_parser.search_feeds_selective(title, selected_feeds, keywords=keywords) else: - articles = await rss_parser.search_feeds(title, international=international, tenant_id=tenant_id, keywords=keywords) + articles = await rss_parser.search_feeds(title, international=international, tenant_id=tenant_id, keywords=keywords, user_id=user_id) logger.info(f"RSS: {len(articles)} relevante Artikel gefunden (international={international})") return articles, feed_usage @@ -612,7 +616,7 @@ class AgentOrchestrator: async def _web_search_pipeline(): """Claude WebSearch-Recherche.""" researcher = ResearcherAgent() - results, usage = await researcher.search(title, description, incident_type, international=international) + results, usage = await researcher.search(title, description, incident_type, international=international, user_id=user_id) logger.info(f"Claude-Recherche: {len(results)} Ergebnisse") return results, usage diff --git a/src/agents/researcher.py b/src/agents/researcher.py index 9a28f79..7bb63d9 100644 --- a/src/agents/researcher.py +++ b/src/agents/researcher.py @@ -269,7 +269,7 @@ class ResearcherAgent: logger.warning(f"Keyword-Extraktion fehlgeschlagen: {e}") return None, None - async def search(self, title: str, description: str = "", incident_type: str = "adhoc", international: bool = True) -> tuple[list[dict], ClaudeUsage | None]: + async def search(self, title: str, description: str = "", incident_type: str = "adhoc", international: bool = True, user_id: int = None) -> tuple[list[dict], ClaudeUsage | None]: """Sucht nach Informationen zu einem Vorfall.""" from config import OUTPUT_LANGUAGE if incident_type == "research": @@ -290,7 +290,7 @@ class ResearcherAgent: articles = self._parse_response(result) # Ausgeschlossene Quellen dynamisch aus DB laden - excluded_sources = await self._get_excluded_sources() + excluded_sources = await self._get_excluded_sources(user_id=user_id) # Ausgeschlossene Quellen filtern filtered = [] @@ -317,14 +317,23 @@ class ResearcherAgent: logger.error(f"Recherche-Fehler: {e}") return [], None - async def _get_excluded_sources(self) -> list[str]: - """Lädt ausgeschlossene Quellen aus der Datenbank.""" + async def _get_excluded_sources(self, user_id: int = None) -> list[str]: + """Laedt ausgeschlossene Quellen (global + per-User).""" try: - from source_rules import get_source_rules + from source_rules import get_source_rules, get_user_excluded_domains rules = await get_source_rules() - return rules.get("excluded_domains", []) + excluded = list(rules.get("excluded_domains", [])) + + # User-spezifische Ausschluesse hinzufuegen + if user_id: + user_excluded = await get_user_excluded_domains(user_id) + for domain in user_excluded: + if domain not in excluded: + excluded.append(domain) + + return excluded except Exception as e: - logger.warning(f"Fallback auf config.py für Excluded Sources: {e}") + logger.warning(f"Fallback auf config.py fuer Excluded Sources: {e}") from config import EXCLUDED_SOURCES return list(EXCLUDED_SOURCES) diff --git a/src/database.py b/src/database.py index 2e95118..00e2935 100644 --- a/src/database.py +++ b/src/database.py @@ -183,6 +183,15 @@ CREATE TABLE IF NOT EXISTS article_locations ( ); CREATE INDEX IF NOT EXISTS idx_article_locations_incident ON article_locations(incident_id); CREATE INDEX IF NOT EXISTS idx_article_locations_article ON article_locations(article_id); + +CREATE TABLE IF NOT EXISTS user_excluded_domains ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, + domain TEXT NOT NULL, + notes TEXT, + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + UNIQUE(user_id, domain) +); """ diff --git a/src/feeds/rss_parser.py b/src/feeds/rss_parser.py index eaed414..e4aaefb 100644 --- a/src/feeds/rss_parser.py +++ b/src/feeds/rss_parser.py @@ -26,7 +26,7 @@ class RSSParser: cleaned = [w for w in words if not w.isdigit()] return cleaned if cleaned else words - async def search_feeds(self, search_term: str, international: bool = True, tenant_id: int = None, keywords: list[str] | None = None) -> list[dict]: + async def search_feeds(self, search_term: str, international: bool = True, tenant_id: int = None, keywords: list[str] | None = None, user_id: int = None) -> list[dict]: """Durchsucht RSS-Feeds nach einem Suchbegriff. Args: @@ -50,6 +50,19 @@ class RSSParser: rss_feeds = await self._get_rss_feeds(tenant_id=tenant_id) + # User-spezifische Ausschluesse anwenden + if user_id: + try: + from source_rules import get_user_excluded_domains + user_excluded = await get_user_excluded_domains(user_id) + if user_excluded: + for cat in rss_feeds: + rss_feeds[cat] = [f for f in rss_feeds[cat] + if not any(excl in (f.get("url", "") + f.get("name", "")).lower() + for excl in user_excluded)] + except Exception as e: + logger.warning(f"User-Ausschluesse konnten nicht geladen werden: {e}") + # Feed-Kategorien filtern if international: categories = rss_feeds.keys() diff --git a/src/models.py b/src/models.py index 726dad5..9c0b7db 100644 --- a/src/models.py +++ b/src/models.py @@ -124,6 +124,7 @@ class SourceResponse(BaseModel): article_count: int = 0 last_seen_at: Optional[str] = None created_at: str + is_global: bool = False # Source Discovery diff --git a/src/routers/incidents.py b/src/routers/incidents.py index 08bb628..2d3d050 100644 --- a/src/routers/incidents.py +++ b/src/routers/incidents.py @@ -550,7 +550,7 @@ async def trigger_refresh( await _check_incident_access(db, incident_id, current_user["id"], tenant_id) from agents.orchestrator import orchestrator - enqueued = await orchestrator.enqueue_refresh(incident_id) + enqueued = await orchestrator.enqueue_refresh(incident_id, user_id=current_user["id"]) if not enqueued: return {"status": "skipped", "incident_id": incident_id} diff --git a/src/routers/sources.py b/src/routers/sources.py index e3e5471..f7b9b33 100644 --- a/src/routers/sources.py +++ b/src/routers/sources.py @@ -57,7 +57,12 @@ async def list_sources( query += " ORDER BY source_type, category, name" cursor = await db.execute(query, params) rows = await cursor.fetchall() - return [dict(row) for row in rows] + results = [] + for row in rows: + d = dict(row) + d["is_global"] = d.get("tenant_id") is None + results.append(d) + return results @router.get("/stats") @@ -285,64 +290,54 @@ async def rediscover_existing_endpoint( raise HTTPException(status_code=500, detail="Rediscovery fehlgeschlagen") +@router.get("/my-exclusions") +async def get_my_exclusions( + current_user: dict = Depends(get_current_user), + db: aiosqlite.Connection = Depends(db_dependency), +): + """Gibt die vom aktuellen User ausgeschlossenen Domains zurück.""" + user_id = current_user["id"] + cursor = await db.execute( + "SELECT domain, notes, created_at FROM user_excluded_domains WHERE user_id = ? ORDER BY domain", + (user_id,), + ) + rows = await cursor.fetchall() + return [dict(row) for row in rows] + + @router.post("/block-domain") async def block_domain( data: DomainActionRequest, current_user: dict = Depends(get_current_user), db: aiosqlite.Connection = Depends(db_dependency), ): - """Domain ausschließen: Alle Feeds deaktivieren + excluded-Eintrag anlegen.""" - tenant_id = current_user.get("tenant_id") + """Domain fuer den aktuellen User ausschließen (per-User, nicht org-weit).""" + user_id = current_user["id"] domain = data.domain.lower().strip() - username = current_user["username"] + # Pruefen ob bereits ausgeschlossen cursor = await db.execute( - "SELECT added_by FROM sources WHERE LOWER(domain) = ? AND source_type != 'excluded' AND status = 'active' AND (tenant_id IS NULL OR tenant_id = ?)", - (domain, tenant_id), - ) - affected = await cursor.fetchall() - for row in affected: - ab = row["added_by"] or "" - if ab != "system" and ab != username and ab != "": - raise HTTPException( - status_code=status.HTTP_403_FORBIDDEN, - detail="Domain enthaelt Quellen anderer Nutzer", - ) - - cursor = await db.execute( - "UPDATE sources SET status = 'inactive' WHERE LOWER(domain) = ? AND source_type != 'excluded' AND tenant_id = ?", - (domain, tenant_id), - ) - feeds_deactivated = cursor.rowcount - - cursor = await db.execute( - "SELECT id FROM sources WHERE LOWER(domain) = ? AND source_type = 'excluded' AND (tenant_id IS NULL OR tenant_id = ?)", - (domain, tenant_id), + "SELECT id FROM user_excluded_domains WHERE user_id = ? AND domain = ?", + (user_id, domain), ) existing = await cursor.fetchone() if existing: - excluded_id = existing["id"] if data.notes: await db.execute( - "UPDATE sources SET notes = ? WHERE id = ?", - (data.notes, excluded_id), + "UPDATE user_excluded_domains SET notes = ? WHERE id = ?", + (data.notes, existing["id"]), ) - else: - cursor = await db.execute( - """INSERT INTO sources (name, url, domain, source_type, category, status, notes, added_by, tenant_id) - VALUES (?, NULL, ?, 'excluded', 'sonstige', 'active', ?, ?, ?)""", - (domain, domain, data.notes, current_user["username"], tenant_id), - ) - excluded_id = cursor.lastrowid + await db.commit() + return {"domain": domain, "status": "already_excluded"} + await db.execute( + "INSERT INTO user_excluded_domains (user_id, domain, notes) VALUES (?, ?, ?)", + (user_id, domain, data.notes), + ) await db.commit() - return { - "domain": domain, - "feeds_deactivated": feeds_deactivated, - "excluded_id": excluded_id, - } + return {"domain": domain, "status": "excluded"} @router.post("/unblock-domain") @@ -351,41 +346,18 @@ async def unblock_domain( current_user: dict = Depends(get_current_user), db: aiosqlite.Connection = Depends(db_dependency), ): - """Domain-Ausschluss aufheben: excluded-Eintrag loeschen + Feeds reaktivieren.""" - tenant_id = current_user.get("tenant_id") + """Domain-Ausschluss fuer den aktuellen User aufheben.""" + user_id = current_user["id"] domain = data.domain.lower().strip() cursor = await db.execute( - "SELECT COUNT(*) as cnt FROM sources WHERE LOWER(domain) = ? AND source_type != 'excluded' AND (tenant_id IS NULL OR tenant_id = ?)", - (domain, tenant_id), + "DELETE FROM user_excluded_domains WHERE user_id = ? AND domain = ?", + (user_id, domain), ) - row = await cursor.fetchone() - has_feeds = row["cnt"] > 0 - - if has_feeds: - await db.execute( - "DELETE FROM sources WHERE LOWER(domain) = ? AND source_type = 'excluded' AND tenant_id = ?", - (domain, tenant_id), - ) - cursor = await db.execute( - "UPDATE sources SET status = 'active' WHERE LOWER(domain) = ? AND source_type != 'excluded' AND tenant_id = ?", - (domain, tenant_id), - ) - feeds_reactivated = cursor.rowcount - else: - await db.execute( - """UPDATE sources SET source_type = 'web_source', status = 'active', notes = 'Ausschluss aufgehoben' - WHERE LOWER(domain) = ? AND source_type = 'excluded' AND (tenant_id IS NULL OR tenant_id = ?)""", - (domain, tenant_id), - ) - feeds_reactivated = 0 - + removed = cursor.rowcount await db.commit() - return { - "domain": domain, - "feeds_reactivated": feeds_reactivated, - } + return {"domain": domain, "removed": removed > 0} @router.delete("/domain/{domain}") diff --git a/src/source_rules.py b/src/source_rules.py index 05d4843..022d353 100644 --- a/src/source_rules.py +++ b/src/source_rules.py @@ -661,6 +661,24 @@ async def get_feeds_with_metadata(tenant_id: int = None) -> list[dict]: await db.close() +async def get_user_excluded_domains(user_id: int) -> list[str]: + """Laedt die vom User ausgeschlossenen Domains.""" + from database import get_db + + db = await get_db() + try: + cursor = await db.execute( + "SELECT domain FROM user_excluded_domains WHERE user_id = ?", + (user_id,), + ) + return [row[0] for row in await cursor.fetchall()] + except Exception as e: + logger.warning(f"Fehler beim Laden der User-Ausschluesse: {e}") + return [] + finally: + await db.close() + + async def get_source_rules(tenant_id: int = None) -> dict: """Liest Quellen-Konfiguration aus DB (global + org-spezifisch). diff --git a/src/static/css/style.css b/src/static/css/style.css index a022984..6ed98e7 100644 --- a/src/static/css/style.css +++ b/src/static/css/style.css @@ -3437,6 +3437,16 @@ a:hover { gap: var(--sp-xs); } +/* Grundquelle-Badge */ +.source-global-badge { + font-size: 10px; + padding: 2px 6px; + border-radius: 3px; + background: var(--bg-tertiary, #2a2a2a); + color: var(--text-secondary, #888); + white-space: nowrap; +} + /* Ausgeschlossene Domain */ .source-group-header.excluded { grid-template-columns: 1fr auto auto; diff --git a/src/static/dashboard.html b/src/static/dashboard.html index 2ecd5c1..1fd4e63 100644 --- a/src/static/dashboard.html +++ b/src/static/dashboard.html @@ -427,7 +427,7 @@ - +