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 @@ Alle Typen RSS-Feed Web-Quelle - Ausgeschlossen + Von mir ausgeschlossen Kategorie filtern diff --git a/src/static/js/api.js b/src/static/js/api.js index 2994cee..1e8c28f 100644 --- a/src/static/js/api.js +++ b/src/static/js/api.js @@ -152,6 +152,10 @@ const API = { return this._request('POST', '/sources/discover-multi', { url }); }, + getMyExclusions() { + return this._request('GET', '/sources/my-exclusions'); + }, + blockDomain(domain, notes) { return this._request('POST', '/sources/block-domain', { domain, notes }); }, diff --git a/src/static/js/app.js b/src/static/js/app.js index 9b117eb..7370a60 100644 --- a/src/static/js/app.js +++ b/src/static/js/app.js @@ -425,7 +425,7 @@ const App = { _currentUsername: '', _allSources: [], _sourcesOnly: [], - _blacklistOnly: [], + _myExclusions: [], // [{domain, notes, created_at}] _expandedGroups: new Set(), _editingSourceId: null, _timelineFilter: 'all', @@ -2173,13 +2173,14 @@ const App = { async loadSources() { try { - const [sources, stats] = await Promise.all([ + const [sources, stats, myExclusions] = await Promise.all([ API.listSources(), API.getSourceStats(), + API.getMyExclusions(), ]); this._allSources = sources; this._sourcesOnly = sources.filter(s => s.source_type !== 'excluded'); - this._blacklistOnly = sources.filter(s => s.source_type === 'excluded'); + this._myExclusions = myExclusions || []; this.renderSourceStats(stats); this.renderSourceList(); @@ -2194,7 +2195,7 @@ const App = { const rss = stats.by_type.rss_feed || { count: 0, articles: 0 }; const web = stats.by_type.web_source || { count: 0, articles: 0 }; - const excluded = this._blacklistOnly.length; + const excluded = this._myExclusions.length; bar.innerHTML = ` ${rss.count} RSS-Feeds @@ -2221,12 +2222,12 @@ const App = { const excludedDomains = new Set(); const excludedNotes = {}; - // Blacklist-Domains sammeln - this._blacklistOnly.forEach(s => { - const domain = (s.domain || s.name || '').toLowerCase(); + // User-Ausschlüsse sammeln + this._myExclusions.forEach(e => { + const domain = (e.domain || '').toLowerCase(); if (domain) { excludedDomains.add(domain); - excludedNotes[domain] = s.notes || ''; + excludedNotes[domain] = e.notes || ''; } }); @@ -2238,10 +2239,10 @@ const App = { }); // Ausgeschlossene Domains die keine Feeds haben auch als Gruppe - this._blacklistOnly.forEach(s => { - const domain = (s.domain || s.name || '').toLowerCase(); + this._myExclusions.forEach(e => { + const domain = (e.domain || '').toLowerCase(); if (domain && !groups.has(domain)) { - groups.set(domain, [s]); + groups.set(domain, []); } }); @@ -2249,6 +2250,7 @@ const App = { let filteredGroups = []; for (const [domain, feeds] of groups) { const isExcluded = excludedDomains.has(domain); + const isGlobal = feeds.some(f => f.is_global); // Typ-Filter if (typeFilter === 'excluded' && !isExcluded) continue; @@ -2271,7 +2273,7 @@ const App = { if (!groupText.includes(search)) continue; } - filteredGroups.push({ domain, feeds, isExcluded }); + filteredGroups.push({ domain, feeds, isExcluded, isGlobal }); } if (filteredGroups.length === 0) { @@ -2286,7 +2288,7 @@ const App = { }); list.innerHTML = filteredGroups.map(g => - UI.renderSourceGroup(g.domain, g.feeds, g.isExcluded, excludedNotes[g.domain] || '') + UI.renderSourceGroup(g.domain, g.feeds, g.isExcluded, excludedNotes[g.domain] || '', g.isGlobal) ).join(''); // Erweiterte Gruppen wiederherstellen @@ -2440,11 +2442,11 @@ const App = { * Domain direkt ausschließen (aus der Gruppenliste). */ async blockDomainDirect(domain) { - if (!await confirmDialog(`"${domain}" wirklich ausschließen? Alle Feeds dieser Domain werden deaktiviert.`)) return; + if (!await confirmDialog(`"${domain}" wirklich ausschließen? Artikel dieser Domain werden bei deinen Recherchen ignoriert.`)) return; try { await API.blockDomain(domain); - UI.showToast(`${domain} gesperrt.`, 'success'); + UI.showToast(`${domain} ausgeschlossen.`, 'success'); await this.loadSources(); this.updateSidebarStats(); } catch (err) { @@ -2560,7 +2562,7 @@ const App = { // Prüfen ob Domain ausgeschlossen ist const inputDomain = url.replace(/^https?:\/\//, '').replace(/^www\./, '').split('/')[0].toLowerCase(); - const isBlocked = inputDomain && this._blacklistOnly.some(s => (s.domain || '').toLowerCase() === inputDomain); + const isBlocked = inputDomain && this._myExclusions.some(e => (e.domain || '').toLowerCase() === inputDomain); if (isBlocked) { if (!await confirmDialog(`"${inputDomain}" ist ausgeschlossen. Trotzdem hinzufügen? Der Ausschluss wird dabei aufgehoben.`)) return; diff --git a/src/static/js/components.js b/src/static/js/components.js index e2b970a..f6b2efe 100644 --- a/src/static/js/components.js +++ b/src/static/js/components.js @@ -529,7 +529,7 @@ const UI = { /** * Domain-Gruppe rendern (aufklappbar mit Feeds). */ - renderSourceGroup(domain, feeds, isExcluded, excludedNotes) { + renderSourceGroup(domain, feeds, isExcluded, excludedNotes, isGlobal) { const catLabel = this._categoryLabels[feeds[0]?.category] || feeds[0]?.category || ''; const feedCount = feeds.filter(f => f.source_type !== 'excluded').length; const hasMultiple = feedCount > 1; @@ -547,7 +547,6 @@ const UI = { Ausgeschlossen Ausschluss aufheben - × `; @@ -571,8 +570,8 @@ const UI = { ${this.escape(feed.name)} ${typeLabel} ${this.escape(urlDisplay)} - ✎ - × + ${!feed.is_global ? `✎ + ×` : 'Grundquelle'} `; }); feedRows += ''; @@ -591,9 +590,9 @@ const UI = { ${catLabel} ${feedCountBadge} - ${!hasMultiple && feeds[0]?.id ? `✎` : ''} + ${!isGlobal && !hasMultiple && feeds[0]?.id ? `✎` : ''} Ausschließen - × + ${!isGlobal ? `×` : ''} ${feedRows}