Przeglądaj źródła

feat: per-feed enable/disable toggle via MCP tools and dashboard REST API

- Add 'enabled' column to feed_state table (DB migration: ALTER TABLE)
- SQLiteClusterStore: feed_ensure_seeded(), get_enabled_feed_urls(), set_feed_enabled()
- poller: filter enabled feeds before fetching via store
- MCP tools: get_feeds (list status), toggle_feed (enable/disable)
- REST endpoints: GET /api/v1/feeds, POST /api/v1/feeds/toggle
- Update test mocks to match new interface
- All 32 tests pass
Lukas Goldschmidt 2 tygodni temu
rodzic
commit
d861305e2e

+ 2 - 2
news_mcp/dashboard/dashboard_store.py

@@ -54,8 +54,8 @@ class DashboardStore:
 
         feeds = {}
         with self._store._conn() as conn:
-            for row in conn.execute("SELECT feed_key, last_hash, last_item_count, updated_at FROM feed_state ORDER BY updated_at DESC"):
-                feeds[row[0]] = {"last_hash": row[1], "last_item_count": row[2], "updated_at": row[3]}
+            for row in conn.execute("SELECT feed_key, last_hash, last_item_count, enabled, updated_at FROM feed_state ORDER BY updated_at DESC"):
+                feeds[row[0]] = {"last_hash": row[1], "last_item_count": row[2], "enabled": bool(row[3]), "updated_at": row[4]}
 
         return {
             "total_clusters": total_clusters,

+ 14 - 1
news_mcp/jobs/poller.py

@@ -30,6 +30,14 @@ from news_mcp.storage.sqlite_store import SQLiteClusterStore
 from news_mcp.trends_resolution import resolve_entity_via_trends
 
 
+def _load_feed_urls() -> list[str]:
+    """Return the configured feed URLs from environment (unsorted)."""
+    urls = [u.strip() for u in NEWS_FEED_URLS.split(",") if u.strip()]
+    if not urls:
+        urls = [NEWS_FEED_URL]
+    return urls
+
+
 MAX_ENRICHMENT_RETRIES = 3  # per-cluster retries before giving up for this cycle
 
 async def _enrich_single_cluster(
@@ -145,8 +153,13 @@ async def refresh_clusters(topic: str | None = None, limit: int = 80) -> None:
 
     logger.info("refresh start topic=%s limit=%s", topic, limit)
 
+    # Get enabled feed URLs from store (seeds new ones as enabled by default).
+    configured_urls = _load_feed_urls()
+    enabled_urls = store.get_enabled_feed_urls(configured_urls)
+    logger.info("refresh enabled feeds=%d / configured=%d", len(enabled_urls), len(configured_urls))
+
     # fetch_news_articles is now fully async (concurrent RSS fetching)
-    articles = await fetch_news_articles(limit)
+    articles = await fetch_news_articles(limit, url_list=enabled_urls)
     logger.info("refresh fetched articles=%s", len(articles))
 
     # Drop legacy aggregate feed-state rows so the dashboard only reflects

+ 82 - 0
news_mcp/mcp_server_fastmcp.py

@@ -13,6 +13,8 @@ from mcp.server.transport_security import TransportSecuritySettings
 
 from news_mcp.config import DEFAULT_LOOKBACK_HOURS, DEFAULT_TOPICS, DB_PATH
 from news_mcp.config import (
+    NEWS_FEED_URL,
+    NEWS_FEED_URLS,
     NEWS_PRUNE_INTERVAL_HOURS,
     NEWS_PRUNING_ENABLED,
     NEWS_REFRESH_INTERVAL_SECONDS,
@@ -102,6 +104,23 @@ def _tool_card(name: str, description: str, inputs: list[dict], outputs: list[st
 
 
 NEWS_TOOL_CARDS = [
+    _tool_card(
+        "get_feeds",
+        "List all configured RSS feeds with their enabled/disabled status.",
+        [],
+        ["feeds[]: {feed_key, enabled, last_hash, last_item_count, updated_at}"],
+        ["Use this to see which feeds are currently active or disabled."],
+    ),
+    _tool_card(
+        "toggle_feed",
+        "Enable or disable a specific RSS feed by URL.",
+        [
+            {"name": "feed_url", "type": "string", "meaning": "the feed URL to toggle"},
+            {"name": "enabled", "type": "boolean", "meaning": "true to enable, false to disable"},
+        ],
+        ["ok", "feed_key", "enabled"],
+        ["Changes take effect on the next refresh cycle."],
+    ),
     _tool_card(
         "get_latest_events",
         "Get the newest deduplicated clusters for a topic or resolved entity-like query.",
@@ -249,6 +268,34 @@ NEWS_EXAMPLE_CHAINS = [
 ]
 
 
+def _configured_feed_urls() -> list[str]:
+    """Return the configured feed URLs from environment variables."""
+    urls = [u.strip() for u in NEWS_FEED_URLS.split(",") if u.strip()]
+    if not urls:
+        urls = [NEWS_FEED_URL]
+    return urls
+
+
+@mcp.tool(description="List all configured RSS feeds with their current enabled/disabled status.")
+async def get_feeds() -> list[dict]:
+    """Return each feed URL with its enabled flag, last fetch stats, and timestamps."""
+    store = SQLiteClusterStore(DB_PATH)
+    return store.get_feed_state_list()
+
+
+@mcp.tool(description="Enable or disable a specific RSS feed by URL.")
+async def toggle_feed(feed_url: str, enabled: bool) -> dict:
+    """Toggle a feed's active/inactive state.
+
+    Changes take effect on the next background refresh cycle.
+    Returns the updated feed state.
+    """
+    store = SQLiteClusterStore(DB_PATH)
+    store.set_feed_enabled(feed_url.strip(), enabled)
+    updated = store.get_feed_state(feed_url.strip())
+    return {"ok": True, "feed_key": feed_url.strip(), "enabled": enabled, "details": updated}
+
+
 @mcp.tool(description="Investigate a topic and return the newest deduplicated news clusters, sorted by recency.")
 async def get_latest_events(topic: str | None = None, limit: int = 5, include_articles: bool = False):
     limit = max(1, min(int(limit), 20))
@@ -863,6 +910,41 @@ def api_cluster_detail(cluster_id: str):
         return _api_err(e, f"detail({cluster_id})")
 
 
+# ------------------------------------------------------------------
+# Feed management endpoints (toggle on/off from dashboard)
+# ------------------------------------------------------------------
+
+@app.get("/api/v1/feeds")
+def api_feeds():
+    """List all configured feeds with enabled/disabled status."""
+    try:
+        store = SQLiteClusterStore(DB_PATH)
+        feed_list = store.get_feed_state_list()
+        configured = _configured_feed_urls()
+        return {
+            "feeds": feed_list,
+            "configured_urls": configured,
+        }
+    except Exception as e:
+        return _api_err(e, "feeds")
+
+
+@app.post("/api/v1/feeds/toggle")
+def api_feed_toggle(feed_url: str, enabled: bool):
+    """Toggle a feed's enabled state."""
+    try:
+        store = SQLiteClusterStore(DB_PATH)
+        ok = store.set_feed_enabled(feed_url.strip(), enabled)
+        if not ok:
+            return JSONResponse(
+                status_code=404,
+                content={"error": f"Feed not found: {feed_url}"},
+            )
+        return {"ok": True, "feed_url": feed_url.strip(), "enabled": enabled}
+    except Exception as e:
+        return _api_err(e, f"toggle({feed_url})")
+
+
 @app.get("/health")
 def health():
     return {

+ 12 - 3
news_mcp/sources/news_feeds.py

@@ -108,9 +108,18 @@ def _extract_articles_from_feed(
     return articles
 
 
-async def fetch_news_articles(limit: int = NEWS_FEED_ITEMS_PER_POLL) -> List[Dict[str, Any]]:
-    """Fetch all RSS feeds concurrently, parse, and return articles."""
-    feed_urls = _feed_urls()
+async def fetch_news_articles(
+    limit: int = NEWS_FEED_ITEMS_PER_POLL,
+    url_list: list[str] | None = None,
+) -> list[dict[str, Any]]:
+    """Fetch all RSS feeds concurrently, parse, and return articles.
+
+    Args:
+        limit: Maximum number of articles per feed.
+        url_list: Optional explicit list of feed URLs to fetch.
+                  If None, the configured NEWS_FEED_URLS / NEWS_FEED_URL is used.
+    """
+    feed_urls = url_list if url_list is not None else _feed_urls()
     per_feed_limit = max(1, int(limit))
 
     logger.info(

+ 51 - 3
news_mcp/storage/sqlite_store.py

@@ -169,12 +169,13 @@ class SQLiteClusterStore:
                   feed_key TEXT PRIMARY KEY,
                   last_hash TEXT NOT NULL,
                   last_item_count INTEGER,
+                  enabled INTEGER DEFAULT 1,
                   updated_at TEXT NOT NULL
                 )
                 """
             )
             try:
-                conn.execute("ALTER TABLE feed_state ADD COLUMN last_item_count INTEGER")
+                conn.execute("ALTER TABLE feed_state ADD COLUMN enabled INTEGER DEFAULT 1")
             except sqlite3.OperationalError:
                 pass
 
@@ -366,13 +367,60 @@ class SQLiteClusterStore:
         """All feed_state rows."""
         with self._conn() as conn:
             cur = conn.execute(
-                "SELECT feed_key, last_hash, last_item_count, updated_at FROM feed_state ORDER BY updated_at DESC"
+                "SELECT feed_key, last_hash, last_item_count, enabled, updated_at FROM feed_state ORDER BY updated_at DESC"
             )
             return [
-                {"feed_key": row[0], "last_hash": row[1], "last_item_count": row[2], "updated_at": row[3]}
+                {
+                    "feed_key": row[0],
+                    "last_hash": row[1],
+                    "last_item_count": row[2],
+                    "enabled": bool(row[3]),
+                    "updated_at": row[4],
+                }
                 for row in cur.fetchall()
             ]
 
+    def feed_ensure_seeded(self, feed_urls: list[str]) -> None:
+        """Insert any feed URLs not yet present in feed_state (enabled by default)."""
+        if not feed_urls:
+            return
+        with self._conn() as conn:
+            for url in feed_urls:
+                conn.execute(
+                    "INSERT OR IGNORE INTO feed_state(feed_key, last_hash, last_item_count, enabled, updated_at) VALUES(?, '', 0, 1, '')",
+                    (url,),
+                )
+
+    def get_feed_state_list(self) -> list[dict[str, Any]]:
+        """Return all feeds with enabled/disabled status for the dashboard."""
+        return self.get_all_feed_states()
+
+    def set_feed_enabled(self, feed_url: str, enabled: bool) -> bool:
+        """Toggle a feed's enabled state. Returns True if the feed existed and was updated."""
+        with self._conn() as conn:
+            cur = conn.execute(
+                "UPDATE feed_state SET enabled = ? WHERE feed_key = ?",
+                (1 if enabled else 0, feed_url),
+            )
+            return cur.rowcount > 0
+
+    def get_enabled_feed_urls(self, feed_urls: list[str]) -> list[str]:
+        """From a list of configured feed URLs, return only those that are enabled in feed_state.
+
+        URLs not yet present in feed_state are seeded as enabled.
+        """
+        self.feed_ensure_seeded(feed_urls)
+        with self._conn() as conn:
+            placeholders = ",".join("?" for _ in feed_urls) if feed_urls else ""
+            if placeholders:
+                cur = conn.execute(
+                    f"SELECT feed_key FROM feed_state WHERE feed_key IN ({placeholders}) AND enabled = 1",
+                    feed_urls,
+                )
+            else:
+                cur = conn.execute("SELECT feed_key FROM feed_state WHERE enabled = 1")
+            return [row[0] for row in cur.fetchall()]
+
     def get_meta(self, key: str) -> str | None:
         with self._conn() as conn:
             cur = conn.execute("SELECT value FROM meta WHERE key=?", (key,))

+ 14 - 8
test_news_mcp.py

@@ -372,6 +372,9 @@ def test_refresh_skips_reprocessing_when_feed_hash_is_unchanged(monkeypatch):
         def set_feed_state(self, feed_key, last_hash, item_count):
             self.feed_hash = last_hash
 
+        def get_enabled_feed_urls(self, feed_urls):
+            return feed_urls
+
         def get_cluster_by_id(self, cluster_id):
             return None
 
@@ -387,9 +390,9 @@ def test_refresh_skips_reprocessing_when_feed_hash_is_unchanged(monkeypatch):
 
     monkeypatch.setattr(poller, "SQLiteClusterStore", DummyStore)
 
-    async def _mock_fetch(limit):
-        calls["fetch"] += 1
-        return [{"title": "Bitcoin rallies", "url": "https://example.com/a", "timestamp": "Wed, 01 Apr 2026 12:00:00 GMT"}]
+    async def _mock_fetch(limit, url_list=None):
+            calls["fetch"] += 1
+            return [{"title": "Bitcoin rallies", "url": "https://example.com/a", "timestamp": "Wed, 01 Apr 2026 12:00:00 GMT"}]
     monkeypatch.setattr(poller, "fetch_news_articles", _mock_fetch)
     monkeypatch.setattr(poller.asyncio, "to_thread", fake_to_thread)
     monkeypatch.setattr(poller, "dedup_and_cluster_articles", fake_cluster)
@@ -617,6 +620,9 @@ def test_poller_persists_clusters_under_post_enrichment_topic(monkeypatch):
         def set_feed_state(self, feed_key, last_hash, item_count):
             pass
 
+        def get_enabled_feed_urls(self, feed_urls):
+            return feed_urls
+
         def get_cluster_by_id(self, cluster_id):
             return None
 
@@ -674,11 +680,11 @@ def test_poller_persists_clusters_under_post_enrichment_topic(monkeypatch):
 
     monkeypatch.setattr(poller, "SQLiteClusterStore", DummyStore)
 
-    async def _mock_fetch2(limit):
-        return [
-            {"title": "SEC fines firm", "url": "https://example.com/a", "source": "S",
-             "timestamp": "Wed, 01 Apr 2026 12:00:00 GMT", "summary": "..."},
-        ]
+    async def _mock_fetch2(limit, url_list=None):
+            return [
+                {"title": "SEC fines firm", "url": "https://example.com/a", "source": "S",
+                 "timestamp": "Wed, 01 Apr 2026 12:00:00 GMT", "summary": "..."},
+            ]
     monkeypatch.setattr(poller, "fetch_news_articles", _mock_fetch2)
     monkeypatch.setattr(poller, "dedup_and_cluster_articles", fake_cluster)
     monkeypatch.setattr(poller, "enrich_cluster", fake_enrich)