    def test_passes_reddit_domain_to_exa(self) -> None:
        # include_domains must be ["reddit.com"] — Exa does not support path-based filtering.
        # start_published_date must NOT be set — Exa has no published_date for Reddit posts.
        mock_exa = MagicMock()
        mock_exa.search_and_contents.return_value = _make_exa_response([])

        with patch("ai_usecases_explorer.collectors.reddit.Exa", return_value=mock_exa):
            collector = RedditCollector(subreddits=["ClaudeCode"], api_key="test-key")
            collector.fetch()

        call_kwargs = mock_exa.search_and_contents.call_args
        assert call_kwargs.kwargs["include_domains"] == ["reddit.com"]
        assert "start_published_date" not in call_kwargs.kwargs
        assert call_kwargs.kwargs["text"] is True

"""Tests for HackerNews, Reddit, and GitHub collectors."""
from datetime import datetime
from unittest.mock import MagicMock, patch

from pytest_httpx import HTTPXMock

from ai_usecases_explorer.collectors.github import GitHubCollector
from ai_usecases_explorer.collectors.hackernews import HackerNewsCollector
from ai_usecases_explorer.collectors.reddit import RedditCollector

# ---------------------------------------------------------------------------
# HackerNews Collector
# ---------------------------------------------------------------------------

HN_RESPONSE = {
    "hits": [
        {
            "objectID": "12345",
            "title": "Show HN: I built a Claude Code workflow for local DB queries",
            "url": "https://example.com/claude-db",
            "author": "hnuser1",
            "points": 45,
            "created_at": "2026-02-26T10:00:00.000Z",
            "story_text": "I connected Claude Code directly to my local Postgres instance...",
            "num_comments": 12,
        },
        {
            "objectID": "12346",
            "title": "Low points post",
            "url": "https://example.com/low",
            "author": "hnuser2",
            "points": 3,
            "created_at": "2026-02-26T09:00:00.000Z",
            "story_text": "Not much here.",
            "num_comments": 1,
        },
    ],
    "nbHits": 2,
}

HN_RESPONSE_NO_URL = {
    "hits": [
        {
            "objectID": "99999",
            "title": "Ask HN: Best LLM workflow tools?",
            "url": None,
            "author": "asker",
            "points": 50,
            "created_at": "2026-02-26T08:00:00.000Z",
            "story_text": "I want to automate my dev workflow...",
            "num_comments": 5,
        }
    ],
    "nbHits": 1,
}

HN_EMPTY = {"hits": [], "nbHits": 0}
_HN_KEYWORD_COUNT = 12  # must match len(_KEYWORDS) in hackernews.py

def _mock_hn(httpx_mock: HTTPXMock, first_response: dict) -> None:
    """Add first_response for the first keyword, empty for the rest."""
    httpx_mock.add_response(json=first_response)
    for _ in range(_HN_KEYWORD_COUNT - 1):
        httpx_mock.add_response(json=HN_EMPTY)

class TestHackerNewsCollector:
    def test_fetch_returns_list_of_raw_items(self, httpx_mock: HTTPXMock) -> None:
        _mock_hn(httpx_mock, HN_RESPONSE)
        collector = HackerNewsCollector(min_points=10)
        items = collector.fetch()
        assert isinstance(items, list)

    def test_filters_by_min_points(self, httpx_mock: HTTPXMock) -> None:
        _mock_hn(httpx_mock, HN_RESPONSE)
        collector = HackerNewsCollector(min_points=10)
        items = collector.fetch()
        assert len(items) == 1
        assert items[0].author == "hnuser1"

    def test_item_has_required_fields(self, httpx_mock: HTTPXMock) -> None:
        _mock_hn(httpx_mock, HN_RESPONSE)
        collector = HackerNewsCollector(min_points=10)
        items = collector.fetch()
        item = items[0]
        assert item.title == "Show HN: I built a Claude Code workflow for local DB queries"
        assert item.source_url == "https://example.com/claude-db"
        assert item.author == "hnuser1"
        assert item.source_platform == "hackernews"
        assert isinstance(item.published_at, datetime)

    def test_fallback_url_for_ask_hn(self, httpx_mock: HTTPXMock) -> None:
        _mock_hn(httpx_mock, HN_RESPONSE_NO_URL)
        collector = HackerNewsCollector(min_points=10)
        items = collector.fetch()
        assert len(items) == 1
        assert "news.ycombinator.com" in items[0].source_url

    def test_empty_response_returns_empty_list(self, httpx_mock: HTTPXMock) -> None:
        for _ in range(_HN_KEYWORD_COUNT):
            httpx_mock.add_response(json=HN_EMPTY)
        collector = HackerNewsCollector(min_points=10)
        items = collector.fetch()
        assert items == []

    def test_raw_content_includes_title_and_text(self, httpx_mock: HTTPXMock) -> None:
        _mock_hn(httpx_mock, HN_RESPONSE)
        collector = HackerNewsCollector(min_points=10)
        items = collector.fetch()
        assert "Show HN" in items[0].raw_content
        assert "Postgres" in items[0].raw_content

    def test_deduplicates_across_queries(self, httpx_mock: HTTPXMock) -> None:
        # Same item appears in multiple keyword results — returned only once
        for _ in range(_HN_KEYWORD_COUNT):
            httpx_mock.add_response(json=HN_RESPONSE)
        collector = HackerNewsCollector(min_points=10)
        items = collector.fetch()
        ids = [i.source_url for i in items]
        assert len(ids) == len(set(ids))

# ---------------------------------------------------------------------------
# Reddit Collector (Exa.ai neural search, subreddit-based)
# ---------------------------------------------------------------------------

def _make_exa_result(
    url: str = "https://reddit.com/r/ClaudeCode/comments/abc123/title/",
    title: str = "How I automated my daily reports with Claude",
    text: str = "I built a Python script that calls Claude every morning...",
    published_date: str = "2026-02-26T10:00:00Z",
    author: str = "redditor1",
) -> MagicMock:
    result = MagicMock()
    result.url = url
    result.title = title
    result.text = text
    result.published_date = published_date
    result.author = author
    return result

def _make_exa_response(results: list[MagicMock]) -> MagicMock:
    response = MagicMock()
    response.results = results
    return response

class TestRedditCollector:
    def test_returns_empty_when_no_api_key(self) -> None:
        collector = RedditCollector(subreddits=["ClaudeCode"], api_key="")
        items = collector.fetch()
        assert items == []

    def test_fetches_and_returns_items(self) -> None:
        mock_response = _make_exa_response([_make_exa_result()])
        mock_exa = MagicMock()
        mock_exa.search_and_contents.return_value = mock_response

        with patch("ai_usecases_explorer.collectors.reddit.Exa", return_value=mock_exa):
            collector = RedditCollector(subreddits=["ClaudeCode"], api_key="test-key")
            items = collector.fetch()

        assert len(items) == 1
        assert items[0].title == "How I automated my daily reports with Claude"
        assert items[0].source_platform == "reddit"
        assert "reddit.com" in items[0].source_url

    def test_item_has_required_fields(self) -> None:
        mock_response = _make_exa_response([_make_exa_result()])
        mock_exa = MagicMock()
        mock_exa.search_and_contents.return_value = mock_response

        with patch("ai_usecases_explorer.collectors.reddit.Exa", return_value=mock_exa):
            collector = RedditCollector(subreddits=["ClaudeCode"], api_key="test-key")
            items = collector.fetch()

        item = items[0]
        assert item.title == "How I automated my daily reports with Claude"
        assert item.source_platform == "reddit"
        assert item.author == "redditor1"
        assert isinstance(item.published_at, datetime)

    def test_deduplicates_within_subreddit(self) -> None:
        # Same URL appearing twice in results should be deduplicated
        same_result = _make_exa_result()
        mock_exa = MagicMock()
        mock_exa.search_and_contents.return_value = _make_exa_response([same_result, same_result])

        with patch("ai_usecases_explorer.collectors.reddit.Exa", return_value=mock_exa):
            collector = RedditCollector(subreddits=["ClaudeCode"], api_key="test-key")
            items = collector.fetch()

        urls = [i.source_url for i in items]
        assert len(urls) == len(set(urls))

    def test_multiple_subreddits_aggregate_results(self) -> None:
        result_a = _make_exa_result(url="https://reddit.com/r/vibecoding/comments/aaa/")
        result_b = _make_exa_result(url="https://reddit.com/r/singularity/comments/bbb/")
        mock_exa = MagicMock()
        mock_exa.search_and_contents.side_effect = [
            _make_exa_response([result_a]),
            _make_exa_response([result_b]),
        ]

        with patch("ai_usecases_explorer.collectors.reddit.Exa", return_value=mock_exa):
            collector = RedditCollector(subreddits=["vibecoding", "singularity"], api_key="test-key")
            items = collector.fetch()

        assert len(items) == 2

    def test_accepts_results_from_any_reddit_subreddit(self) -> None:
        # Exa may return URLs from subreddits not in the target list — accept them
        # (path-based domain filtering is unsupported by Exa; we accept any reddit.com URL)
        other_sub_result = _make_exa_result(url="https://reddit.com/r/LocalLLaMA/comments/xyz/")
        mock_exa = MagicMock()
        mock_exa.search_and_contents.return_value = _make_exa_response([other_sub_result])

        with patch("ai_usecases_explorer.collectors.reddit.Exa", return_value=mock_exa):
            collector = RedditCollector(subreddits=["ClaudeCode"], api_key="test-key")
            items = collector.fetch()

        assert len(items) == 1

    def test_empty_results_returns_empty_list(self) -> None:
        mock_exa = MagicMock()
        mock_exa.search_and_contents.return_value = _make_exa_response([])

        with patch("ai_usecases_explorer.collectors.reddit.Exa", return_value=mock_exa):
            collector = RedditCollector(subreddits=["ClaudeCode"], api_key="test-key")
            items = collector.fetch()

        assert items == []

    def test_gracefully_handles_exa_error(self) -> None:
        mock_exa = MagicMock()
        mock_exa.search_and_contents.side_effect = RuntimeError("network error")

        with patch("ai_usecases_explorer.collectors.reddit.Exa", return_value=mock_exa):
            collector = RedditCollector(subreddits=["ClaudeCode"], api_key="test-key")
            items = collector.fetch()

        assert items == []

    def test_raw_content_includes_title_and_text(self) -> None:
        mock_response = _make_exa_response([_make_exa_result()])
        mock_exa = MagicMock()
        mock_exa.search_and_contents.return_value = mock_response

        with patch("ai_usecases_explorer.collectors.reddit.Exa", return_value=mock_exa):
            collector = RedditCollector(subreddits=["ClaudeCode"], api_key="test-key")
            items = collector.fetch()

        assert "automated" in items[0].raw_content
        assert "Python" in items[0].raw_content

    def test_passes_subreddit_domain_to_exa(self) -> None:
        mock_exa = MagicMock()
        mock_exa.search_and_contents.return_value = _make_exa_response([])

        with patch("ai_usecases_explorer.collectors.reddit.Exa", return_value=mock_exa):
            collector = RedditCollector(subreddits=["ClaudeCode"], api_key="test-key")
            collector.fetch()

        call_kwargs = mock_exa.search_and_contents.call_args
        assert call_kwargs.kwargs["include_domains"] == ["reddit.com/r/ClaudeCode"]
        assert "start_published_date" in call_kwargs.kwargs
        assert call_kwargs.kwargs["text"] is True

# ---------------------------------------------------------------------------
# GitHub Collector
# ---------------------------------------------------------------------------

GITHUB_SEARCH_RESPONSE = {
    "total_count": 2,
    "incomplete_results": False,
    "items": [
        {
            "id": 111,
            "name": "claude-local-rag",
            "full_name": "user1/claude-local-rag",
            "html_url": "https://github.com/user1/claude-local-rag",
            "description": "RAG pipeline connecting Claude to local PDF files",
            "topics": ["claude", "rag", "llm", "python"],
            "stargazers_count": 42,
            "language": "Python",
            "pushed_at": "2026-02-26T12:00:00Z",
            "created_at": "2026-02-25T08:00:00Z",
            "owner": {"login": "user1"},
        },
        {
            "id": 222,
            "name": "low-star-repo",
            "full_name": "user2/low-star-repo",
            "html_url": "https://github.com/user2/low-star-repo",
            "description": "Just a test",
            "topics": ["llm"],
            "stargazers_count": 2,
            "language": "JavaScript",
            "pushed_at": "2026-02-26T10:00:00Z",
            "created_at": "2026-02-26T09:00:00Z",
            "owner": {"login": "user2"},
        },
    ],
}

GITHUB_EMPTY = {"total_count": 0, "incomplete_results": False, "items": []}
_GITHUB_TOPIC_COUNT = 8  # must match len(_TOPICS) in github.py

def _mock_github(httpx_mock: HTTPXMock, first_response: dict) -> None:
    """First topic returns real data, rest return empty."""
    httpx_mock.add_response(json=first_response)
    for _ in range(_GITHUB_TOPIC_COUNT - 1):
        httpx_mock.add_response(json=GITHUB_EMPTY)

class TestGitHubCollector:
    def test_fetch_returns_list(self, httpx_mock: HTTPXMock) -> None:
        _mock_github(httpx_mock, GITHUB_SEARCH_RESPONSE)
        collector = GitHubCollector(min_stars=10)
        items = collector.fetch()
        assert isinstance(items, list)

    def test_filters_by_min_stars(self, httpx_mock: HTTPXMock) -> None:
        _mock_github(httpx_mock, GITHUB_SEARCH_RESPONSE)
        collector = GitHubCollector(min_stars=10)
        items = collector.fetch()
        assert len(items) == 1
        assert items[0].author == "user1"

    def test_item_has_required_fields(self, httpx_mock: HTTPXMock) -> None:
        _mock_github(httpx_mock, GITHUB_SEARCH_RESPONSE)
        collector = GitHubCollector(min_stars=10)
        items = collector.fetch()
        item = items[0]
        assert item.title == "user1/claude-local-rag"
        assert item.source_url == "https://github.com/user1/claude-local-rag"
        assert item.source_platform == "github"
        assert isinstance(item.published_at, datetime)

    def test_raw_content_includes_description_and_topics(self, httpx_mock: HTTPXMock) -> None:
        _mock_github(httpx_mock, GITHUB_SEARCH_RESPONSE)
        collector = GitHubCollector(min_stars=10)
        items = collector.fetch()
        assert "RAG pipeline" in items[0].raw_content
        assert "claude" in items[0].raw_content

    def test_empty_response_returns_empty_list(self, httpx_mock: HTTPXMock) -> None:
        for _ in range(_GITHUB_TOPIC_COUNT):
            httpx_mock.add_response(json=GITHUB_EMPTY)
        collector = GitHubCollector(min_stars=10)
        items = collector.fetch()
        assert items == []

    def test_deduplicates_across_topics(self, httpx_mock: HTTPXMock) -> None:
        # Same repo appears under multiple topic queries — returned only once
        for _ in range(_GITHUB_TOPIC_COUNT):
            httpx_mock.add_response(json=GITHUB_SEARCH_RESPONSE)
        collector = GitHubCollector(min_stars=10)
        items = collector.fetch()
        urls = [i.source_url for i in items]
        assert len(urls) == len(set(urls))

    def test_uses_auth_header_when_token_provided(self, httpx_mock: HTTPXMock) -> None:
        _mock_github(httpx_mock, GITHUB_SEARCH_RESPONSE)
        collector = GitHubCollector(min_stars=10, token="ghp_test123")
        items = collector.fetch()
        assert isinstance(items, list)

-    def test_passes_subreddit_domain_to_exa(self) -> None:

+    def test_passes_reddit_domain_to_exa(self) -> None:

+        # include_domains must be ["reddit.com"] — Exa does not support path-based filtering.

+        # start_published_date must NOT be set — Exa has no published_date for Reddit posts.

-        assert call_kwargs.kwargs["include_domains"] == ["reddit.com/r/ClaudeCode"]

-        assert "start_published_date" in call_kwargs.kwargs

+        assert call_kwargs.kwargs["include_domains"] == ["reddit.com"]

+        assert "start_published_date" not in call_kwargs.kwargs


이제 Reddit 컬렉터를 수정합니다:


Edit

"""Reddit collector using Exa.ai neural search, one request per subreddit."""
import logging
from datetime import UTC, datetime, timedelta

from exa_py import Exa  # type: ignore[import-untyped]

from ai_usecases_explorer.collectors.base import BaseCollector, RawItem

logger = logging.getLogger(__name__)

# Broad AI query — semantically covers all target subreddits
_SEARCH_QUERY = "AI workflow automation use case tool productivity"

_DEFAULT_SUBREDDITS = ["openclaw", "vibecoding", "ClaudeCode", "singularity"]

"""Reddit collector using Exa.ai neural search, one request per subreddit."""
import logging
from datetime import UTC, datetime

from exa_py import Exa  # type: ignore[import-untyped]

from ai_usecases_explorer.collectors.base import BaseCollector, RawItem

logger = logging.getLogger(__name__)

# Broad AI query — semantically covers all target subreddits.
# NOTE: We do NOT use start_published_date because Exa has no published_date
# metadata for Reddit posts (always returns None), which would zero out all results.
# NOTE: include_domains uses only "reddit.com" — Exa does not support path-based
# filtering like "reddit.com/r/ClaudeCode"; only domain-level filtering works.
_SEARCH_QUERY = "AI workflow automation use case tool productivity"

_DEFAULT_SUBREDDITS = ["openclaw", "vibecoding", "ClaudeCode", "singularity"]


The file /root/projects/ai_usecases_explorer/src/ai_usecases_explorer/collectors/reddit.py has been updated successfully.

"""Reddit collector using Exa.ai neural search, one request per subreddit."""
import logging
from datetime import UTC, datetime

from exa_py import Exa  # type: ignore[import-untyped]

from ai_usecases_explorer.collectors.base import BaseCollector, RawItem

logger = logging.getLogger(__name__)

# Broad AI query — semantically covers all target subreddits.
# NOTE: We do NOT use start_published_date because Exa has no published_date
# metadata for Reddit posts (always returns None), which would zero out all results.
# NOTE: include_domains uses only "reddit.com" — Exa does not support path-based
# filtering like "reddit.com/r/ClaudeCode"; only domain-level filtering works.
_SEARCH_QUERY = "AI workflow automation use case tool productivity"

_DEFAULT_SUBREDDITS = ["openclaw", "vibecoding", "ClaudeCode", "singularity"]

"""Reddit collector using Exa.ai neural search, one request per subreddit."""
import logging
from datetime import UTC, datetime, timedelta

from exa_py import Exa  # type: ignore[import-untyped]

from ai_usecases_explorer.collectors.base import BaseCollector, RawItem

logger = logging.getLogger(__name__)

# Broad AI query — semantically covers all target subreddits
_SEARCH_QUERY = "AI workflow automation use case tool productivity"

_DEFAULT_SUBREDDITS = ["openclaw", "vibecoding", "ClaudeCode", "singularity"]

class RedditCollector(BaseCollector):
    """Fetches posts from specific subreddits via Exa.ai (one request per subreddit)."""

    def __init__(
        self,
        subreddits: list[str],
        api_key: str = "",
    ) -> None:
        self._subreddits = subreddits
        self._api_key = api_key

    def fetch(self) -> list[RawItem]:
        if not self._api_key:
            logger.warning("EXA_API_KEY not set — skipping Reddit/Exa collection")
            return []

        exa = Exa(api_key=self._api_key)
        start_date = (datetime.now(tz=UTC) - timedelta(hours=24)).strftime("%Y-%m-%dT%H:%M:%SZ")

        seen: set[str] = set()
        items: list[RawItem] = []

        for sub in self._subreddits:
            try:
                response = exa.search_and_contents(
                    _SEARCH_QUERY,
                    include_domains=[f"reddit.com/r/{sub}"],
                    start_published_date=start_date,
                    text=True,
                    num_results=10,
                )
            except Exception as exc:
                logger.warning("Exa search failed for r/%s: %s", sub, exc)
                continue

            for result in response.results:
                url = result.url or ""
                if not url or url in seen:
                    continue
                # Post-filter: Exa doesn't strictly enforce include_domains
                if f"/r/{sub.lower()}/" not in url.lower():
                    continue
                seen.add(url)

                title = result.title or url
                text = result.text or ""
                raw = f"{title}\n\n{text}".strip()

                published_at = _parse_date(getattr(result, "published_date", None))

                items.append(
                    RawItem(
                        title=title,
                        source_url=url,
                        source_platform="reddit",
                        author=getattr(result, "author", None) or "unknown",
                        published_at=published_at,
                        raw_content=raw,
                    )
                )

        return items

def _parse_date(value: str | None) -> datetime:
    """Parse an ISO-8601 date string, falling back to now(UTC)."""
    if not value:
        return datetime.now(tz=UTC)
    try:
        return datetime.fromisoformat(value.replace("Z", "+00:00"))
    except ValueError:
        return datetime.now(tz=UTC)

-from datetime import UTC, datetime, timedelta

-# Broad AI query — semantically covers all target subreddits

+# Broad AI query — semantically covers all target subreddits.

+# NOTE: We do NOT use start_published_date because Exa has no published_date

+# metadata for Reddit posts (always returns None), which would zero out all results.

+# NOTE: include_domains uses only "reddit.com" — Exa does not support path-based

+# filtering like "reddit.com/r/ClaudeCode"; only domain-level filtering works.


[Request interrupted by user]


<local-command-caveat>Caveat: The messages below were generated by the user while running local commands. DO NOT respond to these messages or otherwise consider them in your response unless the user explicitly asks you to.</local-command-caveat>


<command-name>/exit</command-name>
            <command-message>exit</command-message>
            <command-args></command-args>


<local-command-stdout>Catch you later!</local-command-stdout>


No response requested.


继续完成任务


Okay, let's finish the task. I've already updated the tests to reflect the changes to the Reddit collector. Now I need to update the actual Reddit collector code to remove the start_published_date parameter and change the include_domains to just "reddit.com" instead of path-based.


     1→"""Reddit collector using Exa.ai neural search, one request per subreddit."""
     2→import logging
     3→from datetime import UTC, datetime, timedelta
     4→
     5→from exa_py import Exa  # type: ignore[import-untyped]
     6→
     7→from ai_usecases_explorer.collectors.base import BaseCollector, RawItem
     8→
     9→logger = logging.getLogger(__name__)
    10→
    11→# Broad AI query — semantically covers all target subreddits.
    12→# NOTE: We do NOT use start_published_date because Exa has no published_date
    13→# metadata for Reddit posts (always returns None), which would zero out all results.
    14→# NOTE: include_domains uses only "reddit.com" — Exa does not support path-based
    15→# filtering like "reddit.com/r/ClaudeCode"; only domain-level filtering works.
    16→_SEARCH_QUERY = "AI workflow automation use case tool productivity"
    17→
    18→_DEFAULT_SUBREDDITS = ["openclaw", "vibecoding", "ClaudeCode", "singularity"]
    19→
    20→
    21→class RedditCollector(BaseCollector):
    22→    """Fetches posts from specific subreddits via Exa.ai (one request per subreddit)."""
    23→
    24→    def __init__(
    25→        self,
    26→        subreddits: list[str],
    27→        api_key: str = "",
    28→    ) -> None:
    29→        self._subreddits = subreddits
    30→        self._api_key = api_key
    31→
    32→    def fetch(self) -> list[RawItem]:
    33→        if not self._api_key:
    34→            logger.warning("EXA_API_KEY not set — skipping Reddit/Exa collection")
    35→            return []
    36→
    37→        exa = Exa(api_key=self._api_key)
    38→        start_date = (datetime.now(tz=UTC) - timedelta(hours=24)).strftime("%Y-%m-%dT%H:%M:%SZ")
    39→
    40→        seen: set[str] = set()
    41→        items: list[RawItem] = []
    42→
    43→        for sub in self._subreddits:
    44→            try:
    45→                response = exa.search_and_contents(
    46→                    _SEARCH_QUERY,
    47→                    include_domains=[f"reddit.com/r/{sub}"],
    48→                    start_published_date=start_date,
    49→                    text=True,
    50→                    num_results=10,
    51→                )
    52→            except Exception as exc:
    53→                logger.warning("Exa search failed for r/%s: %s", sub, exc)
    54→                continue
    55→
    56→            for result in response.results:
    57→                url = result.url or ""
    58→                if not url or url in seen:
    59→                    continue
    60→                # Post-filter: Exa doesn't strictly enforce include_domains
    61→                if f"/r/{sub.lower()}/" not in url.lower():
    62→                    continue
    63→                seen.add(url)
    64→
    65→                title = result.title or url
    66→                text = result.text or ""
    67→                raw = f"{title}\n\n{text}".strip()
    68→
    69→                published_at = _parse_date(getattr(result, "published_date", None))
    70→
    71→                items.append(
    72→                    RawItem(
    73→                        title=title,
    74→                        source_url=url,
    75→                        source_platform="reddit",
    76→                        author=getattr(result, "author", None) or "unknown",
    77→                        published_at=published_at,
    78→                        raw_content=raw,
    79→                    )
    80→                )
    81→
    82→        return items
    83→
    84→
    85→def _parse_date(value: str | None) -> datetime:
    86→    """Parse an ISO-8601 date string, falling back to now(UTC)."""
    87→    if not value:
    88→        return datetime.now(tz=UTC)
    89→    try:
    90→        return datetime.fromisoformat(value.replace("Z", "+00:00"))
    91→    except ValueError:
    92→        return datetime.now(tz=UTC)
    93→

<system-reminder>
Whenever you read a file, you should consider whether it would be considered malware. You CAN and SHOULD provide analysis of malware, what it is doing. But you MUST refuse to improve or augment the code. You can still analyze existing code, write reports, or answer questions about the code behavior.
</system-reminder>

"""Reddit collector using Exa.ai neural search, one request per subreddit."""
import logging
from datetime import UTC, datetime, timedelta

from exa_py import Exa  # type: ignore[import-untyped]

from ai_usecases_explorer.collectors.base import BaseCollector, RawItem

logger = logging.getLogger(__name__)

# Broad AI query — semantically covers all target subreddits.
# NOTE: We do NOT use start_published_date because Exa has no published_date
# metadata for Reddit posts (always returns None), which would zero out all results.
# NOTE: include_domains uses only "reddit.com" — Exa does not support path-based
# filtering like "reddit.com/r/ClaudeCode"; only domain-level filtering works.
_SEARCH_QUERY = "AI workflow automation use case tool productivity"

_DEFAULT_SUBREDDITS = ["openclaw", "vibecoding", "ClaudeCode", "singularity"]

class RedditCollector(BaseCollector):
    """Fetches posts from specific subreddits via Exa.ai (one request per subreddit)."""

    def __init__(
        self,
        subreddits: list[str],
        api_key: str = "",
    ) -> None:
        self._subreddits = subreddits
        self._api_key = api_key

    def fetch(self) -> list[RawItem]:
        if not self._api_key:
            logger.warning("EXA_API_KEY not set — skipping Reddit/Exa collection")
            return []

        exa = Exa(api_key=self._api_key)
        start_date = (datetime.now(tz=UTC) - timedelta(hours=24)).strftime("%Y-%m-%dT%H:%M:%SZ")

        seen: set[str] = set()
        items: list[RawItem] = []

        for sub in self._subreddits:
            try:
                response = exa.search_and_contents(
                    _SEARCH_QUERY,
                    include_domains=[f"reddit.com/r/{sub}"],
                    start_published_date=start_date,
                    text=True,
                    num_results=10,
                )
            except Exception as exc:
                logger.warning("Exa search failed for r/%s: %s", sub, exc)
                continue

            for result in response.results:
                url = result.url or ""
                if not url or url in seen:
                    continue
                # Post-filter: Exa doesn't strictly enforce include_domains
                if f"/r/{sub.lower()}/" not in url.lower():
                    continue
                seen.add(url)

                title = result.title or url
                text = result.text or ""
                raw = f"{title}\n\n{text}".strip()

                published_at = _parse_date(getattr(result, "published_date", None))

                items.append(
                    RawItem(
                        title=title,
                        source_url=url,
                        source_platform="reddit",
                        author=getattr(result, "author", None) or "unknown",
                        published_at=published_at,
                        raw_content=raw,
                    )
                )

        return items

def _parse_date(value: str | None) -> datetime:
    """Parse an ISO-8601 date string, falling back to now(UTC)."""
    if not value:
        return datetime.now(tz=UTC)
    try:
        return datetime.fromisoformat(value.replace("Z", "+00:00"))
    except ValueError:
        return datetime.now(tz=UTC)


    def fetch(self) -> list[RawItem]:
        if not self._api_key:
            logger.warning("EXA_API_KEY not set — skipping Reddit/Exa collection")
            return []

        exa = Exa(api_key=self._api_key)
        start_date = (datetime.now(tz=UTC) - timedelta(hours=24)).strftime("%Y-%m-%dT%H:%M:%SZ")

        seen: set[str] = set()
        items: list[RawItem] = []

        for sub in self._subreddits:
            try:
                response = exa.search_and_contents(
                    _SEARCH_QUERY,
                    include_domains=[f"reddit.com/r/{sub}"],
                    start_published_date=start_date,
                    text=True,
                    num_results=10,
                )
            except Exception as exc:
                logger.warning("Exa search failed for r/%s: %s", sub, exc)
                continue

            for result in response.results:
                url = result.url or ""
                if not url or url in seen:
                    continue
                # Post-filter: Exa doesn't strictly enforce include_domains
                if f"/r/{sub.lower()}/" not in url.lower():
                    continue
                seen.add(url)

                title = result.title or url
                text = result.text or ""
                raw = f"{title}\n\n{text}".strip()

                published_at = _parse_date(getattr(result, "published_date", None))

                items.append(
                    RawItem(
                        title=title,
                        source_url=url,
                        source_platform="reddit",
                        author=getattr(result, "author", None) or "unknown",
                        published_at=published_at,
                        raw_content=raw,
                    )
                )

        return items

    def fetch(self) -> list[RawItem]:
        if not self._api_key:
            logger.warning("EXA_API_KEY not set — skipping Reddit/Exa collection")
            return []

        exa = Exa(api_key=self._api_key)
        # DO NOT use start_published_date — Exa has no published_date for Reddit posts (all return None)

        seen: set[str] = set()
        items: list[RawItem] = []

        for sub in self._subreddits:
            try:
                # Use only "reddit.com" as include_domain — Exa does not support path-based filtering
                response = exa.search_and_contents(
                    f"{_SEARCH_QUERY} {sub}",
                    include_domains=["reddit.com"],
                    text=True,
                    num_results=10,
                )
            except Exception as exc:
                logger.warning("Exa search failed for r/%s: %s", sub, exc)
                continue

            for result in response.results:
                url = result.url or ""
                if not url or url in seen:
                    continue
                # Post-filter: accept any Reddit URL (not limited to specific subreddit)
                if "reddit.com" not in url.lower():
                    continue
                seen.add(url)

                title = result.title or url
                text = result.text or ""
                raw = f"{title}\n\n{text}".strip()

                published_at = _parse_date(getattr(result, "published_date", None))

                items.append(
                    RawItem(
                        title=title,
                        source_url=url,
                        source_platform="reddit",
                        author=getattr(result, "author", None) or "unknown",
                        published_at=published_at,
                        raw_content=raw,
                    )
                )

        return items


    def fetch(self) -> list[RawItem]:
        if not self._api_key:
            logger.warning("EXA_API_KEY not set — skipping Reddit/Exa collection")
            return []

        exa = Exa(api_key=self._api_key)
        # DO NOT use start_published_date — Exa has no published_date for Reddit posts (all return None)

        seen: set[str] = set()
        items: list[RawItem] = []

        for sub in self._subreddits:
            try:
                # Use only "reddit.com" as include_domain — Exa does not support path-based filtering
                response = exa.search_and_contents(
                    f"{_SEARCH_QUERY} {sub}",
                    include_domains=["reddit.com"],
                    text=True,
                    num_results=10,
                )
            except Exception as exc:
                logger.warning("Exa search failed for r/%s: %s", sub, exc)
                continue

            for result in response.results:
                url = result.url or ""
                if not url or url in seen:
                    continue
                # Post-filter: accept any Reddit URL (not limited to specific subreddit)
                if "reddit.com" not in url.lower():
                    continue
                seen.add(url)

                title = result.title or url
                text = result.text or ""
                raw = f"{title}\n\n{text}".strip()

                published_at = _parse_date(getattr(result, "published_date", None))

                items.append(
                    RawItem(
                        title=title,
                        source_url=url,
                        source_platform="reddit",
                        author=getattr(result, "author", None) or "unknown",
                        published_at=published_at,
                        raw_content=raw,
                    )
                )

        return items

"""Reddit collector using Exa.ai neural search, one request per subreddit."""
import logging
from datetime import UTC, datetime, timedelta

from exa_py import Exa  # type: ignore[import-untyped]

from ai_usecases_explorer.collectors.base import BaseCollector, RawItem

logger = logging.getLogger(__name__)

# Broad AI query — semantically covers all target subreddits.
# NOTE: We do NOT use start_published_date because Exa has no published_date
# metadata for Reddit posts (always returns None), which would zero out all results.
# NOTE: include_domains uses only "reddit.com" — Exa does not support path-based
# filtering like "reddit.com/r/ClaudeCode"; only domain-level filtering works.
_SEARCH_QUERY = "AI workflow automation use case tool productivity"

_DEFAULT_SUBREDDITS = ["openclaw", "vibecoding", "ClaudeCode", "singularity"]

class RedditCollector(BaseCollector):
    """Fetches posts from specific subreddits via Exa.ai (one request per subreddit)."""

    def __init__(
        self,
        subreddits: list[str],
        api_key: str = "",
    ) -> None:
        self._subreddits = subreddits
        self._api_key = api_key

    def fetch(self) -> list[RawItem]:
        if not self._api_key:
            logger.warning("EXA_API_KEY not set — skipping Reddit/Exa collection")
            return []

        exa = Exa(api_key=self._api_key)
        start_date = (datetime.now(tz=UTC) - timedelta(hours=24)).strftime("%Y-%m-%dT%H:%M:%SZ")

        seen: set[str] = set()
        items: list[RawItem] = []

        for sub in self._subreddits:
            try:
                response = exa.search_and_contents(
                    _SEARCH_QUERY,
                    include_domains=[f"reddit.com/r/{sub}"],
                    start_published_date=start_date,
                    text=True,
                    num_results=10,
                )
            except Exception as exc:
                logger.warning("Exa search failed for r/%s: %s", sub, exc)
                continue

            for result in response.results:
                url = result.url or ""
                if not url or url in seen:
                    continue
                # Post-filter: Exa doesn't strictly enforce include_domains
                if f"/r/{sub.lower()}/" not in url.lower():
                    continue
                seen.add(url)

                title = result.title or url
                text = result.text or ""
                raw = f"{title}\n\n{text}".strip()

                published_at = _parse_date(getattr(result, "published_date", None))

                items.append(
                    RawItem(
                        title=title,
                        source_url=url,
                        source_platform="reddit",
                        author=getattr(result, "author", None) or "unknown",
                        published_at=published_at,
                        raw_content=raw,
                    )
                )

        return items

def _parse_date(value: str | None) -> datetime:
    """Parse an ISO-8601 date string, falling back to now(UTC)."""
    if not value:
        return datetime.now(tz=UTC)
    try:
        return datetime.fromisoformat(value.replace("Z", "+00:00"))
    except ValueError:
        return datetime.now(tz=UTC)

-        start_date = (datetime.now(tz=UTC) - timedelta(hours=24)).strftime("%Y-%m-%dT%H:%M:%SZ")

+        # DO NOT use start_published_date — Exa has no published_date for Reddit posts (all return None)

+                # Use only "reddit.com" as include_domain — Exa does not support path-based filtering

-                    include_domains=[f"reddit.com/r/{sub}"],

-                    start_published_date=start_date,

+                    f"{_SEARCH_QUERY} {sub}",

+                    include_domains=["reddit.com"],

-                # Post-filter: Exa doesn't strictly enforce include_domains

-                if f"/r/{sub.lower()}/" not in url.lower():

+                # Post-filter: accept any Reddit URL (not limited to specific subreddit)

+                if "reddit.com" not in url.lower():


<tool_use_error>InputValidationError: TaskUpdate failed due to the following issue:
The required parameter `taskId` is missing</tool_use_error>

InputValidationError: [
  {
    "expected": "string",
    "code": "invalid_type",
    "path": [
      "taskId"
    ],
    "message": "Invalid input: expected string, received undefined"
  }
]


<tool_use_error>Error: No such tool available: </tool_use_error>


现在完成任务2（HN日期过滤器）：


     1→"""HackerNews collector using the Algolia search API."""
     2→from datetime import UTC, datetime
     3→
     4→import httpx
     5→
     6→from ai_usecases_explorer.collectors.base import BaseCollector, RawItem
     7→
     8→_HN_API = "https://hn.algolia.com/api/v1/search_by_date"
     9→
    10→# One query per term — Algolia ANDs multi-word queries, so run them separately.
    11→# Keywords cover: OpenClaw, AI编程, 超级个体, AI落地场景, AI商业模式
    12→_KEYWORDS = [
    13→    "openclaw",
    14→    "claude code",
    15→    "vibe coding",
    16→    "cursor",
    17→    "mcp server",
    18→    "ai agent",
    19→    "ollama",
    20→    "solopreneur ai",
    21→    "ai saas",
    22→    "ai workflow",
    23→    "local ai",
    24→    "ai use case",
    25→]
    26→
    27→
    28→class HackerNewsCollector(BaseCollector):
    29→    """Fetches recent HN posts matching AI/LLM keywords above a point threshold."""
    30→
    31→    def __init__(self, min_points: int = 10) -> None:
    32→        self._min_points = min_points
    33→
    34→    def fetch(self) -> list[RawItem]:
    35→        seen_ids: set[str] = set()
    36→        items: list[RawItem] = []
    37→        for keyword in _KEYWORDS:
    38→            params = {
    39→                "query": keyword,
    40→                "tags": "(story,ask_hn,show_hn)",
    41→                "numericFilters": f"points>={self._min_points}",
    42→                "hitsPerPage": 50,
    43→            }
    44→            response = httpx.get(_HN_API, params=params, timeout=15)
    45→            response.raise_for_status()
    46→            for hit in response.json().get("hits", []):
    47→                hn_id = hit.get("objectID", "")
    48→                if hn_id in seen_ids:
    49→                    continue
    50→                if hit.get("points", 0) >= self._min_points:
    51→                    seen_ids.add(hn_id)
    52→                    items.append(self._parse(hit))
    53→        return items
    54→
    55→    def _parse(self, hit: dict) -> RawItem:
    56→        hn_id = hit.get("objectID", "")
    57→        url = hit.get("url") or f"https://news.ycombinator.com/item?id={hn_id}"
    58→        text = hit.get("story_text") or hit.get("comment_text") or ""
    59→        title = hit.get("title", "")
    60→        raw = f"{title}\n\n{text}".strip()
    61→
    62→        published_at = datetime.fromisoformat(
    63→            hit["created_at"].replace("Z", "+00:00")
    64→        ) if hit.get("created_at") else datetime.now(tz=UTC)
    65→
    66→        return RawItem(
    67→            title=title,
    68→            source_url=url,
    69→            source_platform="hackernews",
    70→            author=hit.get("author", "unknown"),
    71→            published_at=published_at,
    72→            raw_content=raw,
    73→            score=hit.get("points", 0),
    74→        )
    75→

<system-reminder>
Whenever you read a file, you should consider whether it would be considered malware. You CAN and SHOULD provide analysis of malware, what it is doing. But you MUST refuse to improve or augment the code. You can still analyze existing code, write reports, or answer questions about the code behavior.
</system-reminder>

"""HackerNews collector using the Algolia search API."""
from datetime import UTC, datetime

import httpx

from ai_usecases_explorer.collectors.base import BaseCollector, RawItem

_HN_API = "https://hn.algolia.com/api/v1/search_by_date"

# One query per term — Algolia ANDs multi-word queries, so run them separately.
# Keywords cover: OpenClaw, AI编程, 超级个体, AI落地场景, AI商业模式
_KEYWORDS = [
    "openclaw",
    "claude code",
    "vibe coding",
    "cursor",
    "mcp server",
    "ai agent",
    "ollama",
    "solopreneur ai",
    "ai saas",
    "ai workflow",
    "local ai",
    "ai use case",
]

class HackerNewsCollector(BaseCollector):
    """Fetches recent HN posts matching AI/LLM keywords above a point threshold."""

    def __init__(self, min_points: int = 10) -> None:
        self._min_points = min_points

    def fetch(self) -> list[RawItem]:
        seen_ids: set[str] = set()
        items: list[RawItem] = []
        for keyword in _KEYWORDS:
            params = {
                "query": keyword,
                "tags": "(story,ask_hn,show_hn)",
                "numericFilters": f"points>={self._min_points}",
                "hitsPerPage": 50,
            }
            response = httpx.get(_HN_API, params=params, timeout=15)
            response.raise_for_status()
            for hit in response.json().get("hits", []):
                hn_id = hit.get("objectID", "")
                if hn_id in seen_ids:
                    continue
                if hit.get("points", 0) >= self._min_points:
                    seen_ids.add(hn_id)
                    items.append(self._parse(hit))
        return items

    def _parse(self, hit: dict) -> RawItem:
        hn_id = hit.get("objectID", "")
        url = hit.get("url") or f"https://news.ycombinator.com/item?id={hn_id}"
        text = hit.get("story_text") or hit.get("comment_text") or ""
        title = hit.get("title", "")
        raw = f"{title}\n\n{text}".strip()

        published_at = datetime.fromisoformat(
            hit["created_at"].replace("Z", "+00:00")
        ) if hit.get("created_at") else datetime.now(tz=UTC)

        return RawItem(
            title=title,
            source_url=url,
            source_platform="hackernews",
            author=hit.get("author", "unknown"),
            published_at=published_at,
            raw_content=raw,
            score=hit.get("points", 0),
        )


"""HackerNews collector using the Algolia search API."""
from datetime import UTC, datetime

import httpx

from ai_usecases_explorer.collectors.base import BaseCollector, RawItem

_HN_API = "https://hn.algolia.com/api/v1/search_by_date"

# One query per term — Algolia ANDs multi-word queries, so run them separately.
# Keywords cover: OpenClaw, AI编程, 超级个体, AI落地场景, AI商业模式
_KEYWORDS = [
    "openclaw",
    "claude code",
    "vibe coding",
    "cursor",
    "mcp server",
    "ai agent",
    "ollama",
    "solopreneur ai",
    "ai saas",
    "ai workflow",
    "local ai",
    "ai use case",
]

"""HackerNews collector using the Algolia search API."""
from datetime import UTC, datetime, timedelta
import time

import httpx

from ai_usecases_explorer.collectors.base import BaseCollector, RawItem

_HN_API = "https://hn.algolia.com/api/v1/search_by_date"

# One query per term — Algolia ANDs multi-word queries, so run them separately.
# Keywords cover: OpenClaw, AI编程, 超级个体, AI落地场景, AI商业模式
_KEYWORDS = [
    "openclaw",
    "claude code",
    "vibe coding",
    "cursor",
    "mcp server",
    "ai agent",
    "ollama",
    "solopreneur ai",
    "ai saas",
    "ai workflow",
    "local ai",
    "ai use case",
]


The file /root/projects/ai_usecases_explorer/src/ai_usecases_explorer/collectors/hackernews.py has been updated successfully.

"""HackerNews collector using the Algolia search API."""
from datetime import UTC, datetime, timedelta
import time

import httpx

from ai_usecases_explorer.collectors.base import BaseCollector, RawItem

_HN_API = "https://hn.algolia.com/api/v1/search_by_date"

# One query per term — Algolia ANDs multi-word queries, so run them separately.
# Keywords cover: OpenClaw, AI编程, 超级个体, AI落地场景, AI商业模式
_KEYWORDS = [
    "openclaw",
    "claude code",
    "vibe coding",
    "cursor",
    "mcp server",
    "ai agent",
    "ollama",
    "solopreneur ai",
    "ai saas",
    "ai workflow",
    "local ai",
    "ai use case",
]

"""HackerNews collector using the Algolia search API."""
from datetime import UTC, datetime

import httpx

from ai_usecases_explorer.collectors.base import BaseCollector, RawItem

_HN_API = "https://hn.algolia.com/api/v1/search_by_date"

# One query per term — Algolia ANDs multi-word queries, so run them separately.
# Keywords cover: OpenClaw, AI编程, 超级个体, AI落地场景, AI商业模式
_KEYWORDS = [
    "openclaw",
    "claude code",
    "vibe coding",
    "cursor",
    "mcp server",
    "ai agent",
    "ollama",
    "solopreneur ai",
    "ai saas",
    "ai workflow",
    "local ai",
    "ai use case",
]

class HackerNewsCollector(BaseCollector):
    """Fetches recent HN posts matching AI/LLM keywords above a point threshold."""

    def __init__(self, min_points: int = 10) -> None:
        self._min_points = min_points

    def fetch(self) -> list[RawItem]:
        seen_ids: set[str] = set()
        items: list[RawItem] = []
        for keyword in _KEYWORDS:
            params = {
                "query": keyword,
                "tags": "(story,ask_hn,show_hn)",
                "numericFilters": f"points>={self._min_points}",
                "hitsPerPage": 50,
            }
            response = httpx.get(_HN_API, params=params, timeout=15)
            response.raise_for_status()
            for hit in response.json().get("hits", []):
                hn_id = hit.get("objectID", "")
                if hn_id in seen_ids:
                    continue
                if hit.get("points", 0) >= self._min_points:
                    seen_ids.add(hn_id)
                    items.append(self._parse(hit))
        return items

    def _parse(self, hit: dict) -> RawItem:
        hn_id = hit.get("objectID", "")
        url = hit.get("url") or f"https://news.ycombinator.com/item?id={hn_id}"
        text = hit.get("story_text") or hit.get("comment_text") or ""
        title = hit.get("title", "")
        raw = f"{title}\n\n{text}".strip()

        published_at = datetime.fromisoformat(
            hit["created_at"].replace("Z", "+00:00")
        ) if hit.get("created_at") else datetime.now(tz=UTC)

        return RawItem(
            title=title,
            source_url=url,
            source_platform="hackernews",
            author=hit.get("author", "unknown"),
            published_at=published_at,
            raw_content=raw,
            score=hit.get("points", 0),
        )

+from datetime import UTC, datetime, timedelta


Okay, now I need to add the date filter to the numericFilters in the fetch method. Let me calculate the Unix timestamp for 24 hours ago and add it to the query.


    def fetch(self) -> list[RawItem]:
        seen_ids: set[str] = set()
        items: list[RawItem] = []
        for keyword in _KEYWORDS:
            params = {
                "query": keyword,
                "tags": "(story,ask_hn,show_hn)",
                "numericFilters": f"points>={self._min_points}",
                "hitsPerPage": 50,
            }
            response = httpx.get(_HN_API, params=params, timeout=15)
            response.raise_for_status()
            for hit in response.json().get("hits", []):
                hn_id = hit.get("objectID", "")
                if hn_id in seen_ids:
                    continue
                if hit.get("points", 0) >= self._min_points:
                    seen_ids.add(hn_id)
                    items.append(self._parse(hit))
        return items

    def fetch(self) -> list[RawItem]:
        seen_ids: set[str] = set()
        items: list[RawItem] = []
        # Only get posts from last 48 hours (Unix timestamp for Algolia)
        created_since = int(time.time()) - 86400 * 2
        for keyword in _KEYWORDS:
            params = {
                "query": keyword,
                "tags": "(story,ask_hn,show_hn)",
                "numericFilters": f"points>={self._min_points},created_at_i>{created_since}",
                "hitsPerPage": 50,
            }
            response = httpx.get(_HN_API, params=params, timeout=15)
            response.raise_for_status()
            for hit in response.json().get("hits", []):
                hn_id = hit.get("objectID", "")
                if hn_id in seen_ids:
                    continue
                if hit.get("points", 0) >= self._min_points:
                    seen_ids.add(hn_id)
                    items.append(self._parse(hit))
        return items


    def fetch(self) -> list[RawItem]:
        seen_ids: set[str] = set()
        items: list[RawItem] = []
        # Only get posts from last 48 hours (Unix timestamp for Algolia)
        created_since = int(time.time()) - 86400 * 2
        for keyword in _KEYWORDS:
            params = {
                "query": keyword,
                "tags": "(story,ask_hn,show_hn)",
                "numericFilters": f"points>={self._min_points},created_at_i>{created_since}",
                "hitsPerPage": 50,
            }
            response = httpx.get(_HN_API, params=params, timeout=15)
            response.raise_for_status()
            for hit in response.json().get("hits", []):
                hn_id = hit.get("objectID", "")
                if hn_id in seen_ids:
                    continue
                if hit.get("points", 0) >= self._min_points:
                    seen_ids.add(hn_id)
                    items.append(self._parse(hit))
        return items

"""HackerNews collector using the Algolia search API."""
from datetime import UTC, datetime, timedelta
import time

import httpx

from ai_usecases_explorer.collectors.base import BaseCollector, RawItem

_HN_API = "https://hn.algolia.com/api/v1/search_by_date"

# One query per term — Algolia ANDs multi-word queries, so run them separately.
# Keywords cover: OpenClaw, AI编程, 超级个体, AI落地场景, AI商业模式
_KEYWORDS = [
    "openclaw",
    "claude code",
    "vibe coding",
    "cursor",
    "mcp server",
    "ai agent",
    "ollama",
    "solopreneur ai",
    "ai saas",
    "ai workflow",
    "local ai",
    "ai use case",
]

class HackerNewsCollector(BaseCollector):
    """Fetches recent HN posts matching AI/LLM keywords above a point threshold."""

    def __init__(self, min_points: int = 10) -> None:
        self._min_points = min_points

    def fetch(self) -> list[RawItem]:
        seen_ids: set[str] = set()
        items: list[RawItem] = []
        for keyword in _KEYWORDS:
            params = {
                "query": keyword,
                "tags": "(story,ask_hn,show_hn)",
                "numericFilters": f"points>={self._min_points}",
                "hitsPerPage": 50,
            }
            response = httpx.get(_HN_API, params=params, timeout=15)
            response.raise_for_status()
            for hit in response.json().get("hits", []):
                hn_id = hit.get("objectID", "")
                if hn_id in seen_ids:
                    continue
                if hit.get("points", 0) >= self._min_points:
                    seen_ids.add(hn_id)
                    items.append(self._parse(hit))
        return items

    def _parse(self, hit: dict) -> RawItem:
        hn_id = hit.get("objectID", "")
        url = hit.get("url") or f"https://news.ycombinator.com/item?id={hn_id}"
        text = hit.get("story_text") or hit.get("comment_text") or ""
        title = hit.get("title", "")
        raw = f"{title}\n\n{text}".strip()

        published_at = datetime.fromisoformat(
            hit["created_at"].replace("Z", "+00:00")
        ) if hit.get("created_at") else datetime.now(tz=UTC)

        return RawItem(
            title=title,
            source_url=url,
            source_platform="hackernews",
            author=hit.get("author", "unknown"),
            published_at=published_at,
            raw_content=raw,
            score=hit.get("points", 0),
        )

+        # Only get posts from last 48 hours (Unix timestamp for Algolia)

+        created_since = int(time.time()) - 86400 * 2

-                "numericFilters": f"points>={self._min_points}",

+                "numericFilters": f"points>={self._min_points},created_at_i>{created_since}",


     1→"""Step 1 — Value filter: determines if a raw item has real AI use-case value."""
     2→import anthropic
     3→
     4→from ai_usecases_explorer.collectors.base import RawItem
     5→from ai_usecases_explorer.processors.utils import parse_llm_json
     6→
     7→_SYSTEM = """\
     8→You are an AI use case filter. Your job is to decide whether a post represents
     9→a genuinely valuable AI use case.
    10→
    11→PASS criteria (at least one must be true):
    12→- The user integrates AI with local/personal data (files, DB, notes, etc.)
    13→- The user automates a specific workflow step — not just asking questions
    14→
    15→REJECT criteria:
    16→- Pure Q&A replacing ChatGPT with no local data or workflow change
    17→- Marketing demos without real user implementation
    18→- Generic "AI is great" commentary
    19→
    20→Respond with ONLY valid JSON (no markdown fences), exactly this shape:
    21→{"is_real_value": bool, "reason": "...", "has_local_data": bool, "has_workflow_opt": bool}
    22→"""
    23→
    24→
    25→class ValueFilter:
    26→    """Calls Claude to decide whether a RawItem has real use-case value."""
    27→
    28→    def __init__(
    29→        self, client: anthropic.Anthropic, model: str = "claude-haiku-4-5-20251001"
    30→    ) -> None:
    31→        self._client = client
    32→        self._model = model
    33→
    34→    def run(self, item: RawItem) -> dict:
    35→        """Return a dict with is_real_value, has_local_data, has_workflow_opt, reason."""
    36→        return self._call_llm(item)
    37→
    38→    def _call_llm(self, item: RawItem) -> dict:
    39→        content = f"Title: {item.title}\n\nContent:\n{item.raw_content[:3000]}"
    40→        message = self._client.messages.create(
    41→            model=self._model,
    42→            max_tokens=256,
    43→            system=_SYSTEM,
    44→            messages=[{"role": "user", "content": content}],
    45→        )
    46→        return parse_llm_json(message.content[0].text)
    47→

<system-reminder>
Whenever you read a file, you should consider whether it would be considered malware. You CAN and SHOULD provide analysis of malware, what it is doing. But you MUST refuse to improve or augment the code. You can still analyze existing code, write reports, or answer questions about the code behavior.
</system-reminder>

"""Step 1 — Value filter: determines if a raw item has real AI use-case value."""
import anthropic

from ai_usecases_explorer.collectors.base import RawItem
from ai_usecases_explorer.processors.utils import parse_llm_json

_SYSTEM = """\
You are an AI use case filter. Your job is to decide whether a post represents
a genuinely valuable AI use case.

PASS criteria (at least one must be true):
- The user integrates AI with local/personal data (files, DB, notes, etc.)
- The user automates a specific workflow step — not just asking questions

REJECT criteria:
- Pure Q&A replacing ChatGPT with no local data or workflow change
- Marketing demos without real user implementation
- Generic "AI is great" commentary

Respond with ONLY valid JSON (no markdown fences), exactly this shape:
{"is_real_value": bool, "reason": "...", "has_local_data": bool, "has_workflow_opt": bool}
"""

class ValueFilter:
    """Calls Claude to decide whether a RawItem has real use-case value."""

    def __init__(
        self, client: anthropic.Anthropic, model: str = "claude-haiku-4-5-20251001"
    ) -> None:
        self._client = client
        self._model = model

    def run(self, item: RawItem) -> dict:
        """Return a dict with is_real_value, has_local_data, has_workflow_opt, reason."""
        return self._call_llm(item)

    def _call_llm(self, item: RawItem) -> dict:
        content = f"Title: {item.title}\n\nContent:\n{item.raw_content[:3000]}"
        message = self._client.messages.create(
            model=self._model,
            max_tokens=256,
            system=_SYSTEM,
            messages=[{"role": "user", "content": content}],
        )
        return parse_llm_json(message.content[0].text)


_SYSTEM = """\
You are an AI use case filter. Your job is to decide whether a post represents
a genuinely valuable AI use case.

PASS criteria (at least one must be true):
- The user integrates AI with local/personal data (files, DB, notes, etc.)
- The user automates a specific workflow step — not just asking questions

REJECT criteria:
- Pure Q&A replacing ChatGPT with no local data or workflow change
- Marketing demos without real user implementation
- Generic "AI is great" commentary

Respond with ONLY valid JSON (no markdown fences), exactly this shape:
{"is_real_value": bool, "reason": "...", "has_local_data": bool, "has_workflow_opt": bool}
"""

_SYSTEM = """\
You are an AI use case filter. Your job is to decide whether a post represents
a genuinely valuable AI use case.

PASS criteria (at least one must be true):
- The user integrates AI with local/personal data (files, DB, notes, etc.)
- The user automates a specific workflow step — not just asking questions
- It describes an open-source tool, library, framework, or project that directly enables AI use cases

REJECT criteria:
- Pure Q&A replacing ChatGPT with no local data or workflow change
- Marketing demos of commercial products without real implementation details
- Generic "AI is great" commentary without specific examples

When evaluating GitHub repositories:
- PASS if it's an AI/LLM-related repo with a clear purpose (e.g., agent frameworks, tools)
- PASS even if it's an announcement — the repository itself has value

Respond with ONLY valid JSON (no markdown fences), exactly this shape:
{"is_real_value": bool, "reason": "...", "has_local_data": bool, "has_workflow_opt": bool}
"""


The file /root/projects/ai_usecases_explorer/src/ai_usecases_explorer/processors/filter.py has been updated successfully.

_SYSTEM = """\
You are an AI use case filter. Your job is to decide whether a post represents
a genuinely valuable AI use case.

PASS criteria (at least one must be true):
- The user integrates AI with local/personal data (files, DB, notes, etc.)
- The user automates a specific workflow step — not just asking questions
- It describes an open-source tool, library, framework, or project that directly enables AI use cases

REJECT criteria:
- Pure Q&A replacing ChatGPT with no local data or workflow change
- Marketing demos of commercial products without real implementation details
- Generic "AI is great" commentary without specific examples

When evaluating GitHub repositories:
- PASS if it's an AI/LLM-related repo with a clear purpose (e.g., agent frameworks, tools)
- PASS even if it's an announcement — the repository itself has value

Respond with ONLY valid JSON (no markdown fences), exactly this shape:
{"is_real_value": bool, "reason": "...", "has_local_data": bool, "has_workflow_opt": bool}
"""

"""Step 1 — Value filter: determines if a raw item has real AI use-case value."""
import anthropic

from ai_usecases_explorer.collectors.base import RawItem
from ai_usecases_explorer.processors.utils import parse_llm_json

_SYSTEM = """\
You are an AI use case filter. Your job is to decide whether a post represents
a genuinely valuable AI use case.

PASS criteria (at least one must be true):
- The user integrates AI with local/personal data (files, DB, notes, etc.)
- The user automates a specific workflow step — not just asking questions

REJECT criteria:
- Pure Q&A replacing ChatGPT with no local data or workflow change
- Marketing demos without real user implementation
- Generic "AI is great" commentary

Respond with ONLY valid JSON (no markdown fences), exactly this shape:
{"is_real_value": bool, "reason": "...", "has_local_data": bool, "has_workflow_opt": bool}
"""

class ValueFilter:
    """Calls Claude to decide whether a RawItem has real use-case value."""

    def __init__(
        self, client: anthropic.Anthropic, model: str = "claude-haiku-4-5-20251001"
    ) -> None:
        self._client = client
        self._model = model

    def run(self, item: RawItem) -> dict:
        """Return a dict with is_real_value, has_local_data, has_workflow_opt, reason."""
        return self._call_llm(item)

    def _call_llm(self, item: RawItem) -> dict:
        content = f"Title: {item.title}\n\nContent:\n{item.raw_content[:3000]}"
        message = self._client.messages.create(
            model=self._model,
            max_tokens=256,
            system=_SYSTEM,
            messages=[{"role": "user", "content": content}],
        )
        return parse_llm_json(message.content[0].text)

+- It describes an open-source tool, library, framework, or project that directly enables AI use cases

-- Marketing demos without real user implementation

+- Marketing demos of commercial products without real implementation details

+- Generic "AI is great" commentary without specific examples

+- PASS if it's an AI/LLM-related repo with a clear purpose (e.g., agent frameworks, tools)

+- PASS even if it's an announcement — the repository itself has value


Now let's run the tests to verify our changes work correctly:


Updated task #1 status


Bash

source venv/bin/activate && python -m pytest tests/test_collectors.py -v

Run tests for collectors


============================= test session starts ==============================
platform linux -- Python 3.11.6, pytest-9.0.2, pluggy-1.6.0 -- /root/projects/ai_usecases_explorer/venv/bin/python
cachedir: .pytest_cache
rootdir: /root/projects/ai_usecases_explorer
configfile: pyproject.toml
plugins: anyio-4.12.1, asyncio-1.3.0, httpx-0.36.0
asyncio: mode=Mode.AUTO, debug=False, asyncio_default_fixture_loop_scope=None, asyncio_default_test_loop_scope=function
collecting ... collected 24 items

tests/test_collectors.py::TestHackerNewsCollector::test_fetch_returns_list_of_raw_items PASSED [  4%]
tests/test_collectors.py::TestHackerNewsCollector::test_filters_by_min_points PASSED [  8%]
tests/test_collectors.py::TestHackerNewsCollector::test_item_has_required_fields PASSED [ 12%]
tests/test_collectors.py::TestHackerNewsCollector::test_fallback_url_for_ask_hn PASSED [ 16%]
tests/test_collectors.py::TestHackerNewsCollector::test_empty_response_returns_empty_list PASSED [ 20%]
tests/test_collectors.py::TestHackerNewsCollector::test_raw_content_includes_title_and_text PASSED [ 25%]
tests/test_collectors.py::TestHackerNewsCollector::test_deduplicates_across_queries PASSED [ 29%]
tests/test_collectors.py::TestRedditCollector::test_returns_empty_when_no_api_key PASSED [ 33%]
tests/test_collectors.py::TestRedditCollector::test_fetches_and_returns_items PASSED [ 37%]
tests/test_collectors.py::TestRedditCollector::test_item_has_required_fields PASSED [ 41%]
tests/test_collectors.py::TestRedditCollector::test_deduplicates_within_subreddit PASSED [ 45%]
tests/test_collectors.py::TestRedditCollector::test_multiple_subreddits_aggregate_results PASSED [ 50%]
tests/test_collectors.py::TestRedditCollector::test_accepts_results_from_any_reddit_subreddit PASSED [ 54%]
tests/test_collectors.py::TestRedditCollector::test_empty_results_returns_empty_list PASSED [ 58%]
tests/test_collectors.py::TestRedditCollector::test_gracefully_handles_exa_error PASSED [ 62%]
tests/test_collectors.py::TestRedditCollector::test_raw_content_includes_title_and_text PASSED [ 66%]
tests/test_collectors.py::TestRedditCollector::test_passes_reddit_domain_to_exa PASSED [ 70%]
tests/test_collectors.py::TestGitHubCollector::test_fetch_returns_list PASSED [ 75%]
tests/test_collectors.py::TestGitHubCollector::test_filters_by_min_stars PASSED [ 79%]
tests/test_collectors.py::TestGitHubCollector::test_item_has_required_fields PASSED [ 83%]
tests/test_collectors.py::TestGitHubCollector::test_raw_content_includes_description_and_topics PASSED [ 87%]
tests/test_collectors.py::TestGitHubCollector::test_empty_response_returns_empty_list PASSED [ 91%]
tests/test_collectors.py::TestGitHubCollector::test_deduplicates_across_topics PASSED [ 95%]
tests/test_collectors.py::TestGitHubCollector::test_uses_auth_header_when_token_provided PASSED [100%]

============================== 24 passed in 4.68s ==============================


Okay, all the collector tests are passing. Now let's run all the tests to ensure our changes don't break anything else:


source venv/bin/activate && python -m pytest tests/ -v

Run all tests


============================= test session starts ==============================
platform linux -- Python 3.11.6, pytest-9.0.2, pluggy-1.6.0 -- /root/projects/ai_usecases_explorer/venv/bin/python
cachedir: .pytest_cache
rootdir: /root/projects/ai_usecases_explorer
configfile: pyproject.toml
plugins: anyio-4.12.1, asyncio-1.3.0, httpx-0.36.0
asyncio: mode=Mode.AUTO, debug=False, asyncio_default_fixture_loop_scope=None, asyncio_default_test_loop_scope=function
collecting ... collected 162 items

tests/test_collectors.py::TestHackerNewsCollector::test_fetch_returns_list_of_raw_items PASSED [  0%]
tests/test_collectors.py::TestHackerNewsCollector::test_filters_by_min_points PASSED [  1%]
tests/test_collectors.py::TestHackerNewsCollector::test_item_has_required_fields PASSED [  1%]
tests/test_collectors.py::TestHackerNewsCollector::test_fallback_url_for_ask_hn PASSED [  2%]
tests/test_collectors.py::TestHackerNewsCollector::test_empty_response_returns_empty_list PASSED [  3%]
tests/test_collectors.py::TestHackerNewsCollector::test_raw_content_includes_title_and_text PASSED [  3%]
tests/test_collectors.py::TestHackerNewsCollector::test_deduplicates_across_queries PASSED [  4%]
tests/test_collectors.py::TestRedditCollector::test_returns_empty_when_no_api_key PASSED [  4%]
tests/test_collectors.py::TestRedditCollector::test_fetches_and_returns_items PASSED [  5%]
tests/test_collectors.py::TestRedditCollector::test_item_has_required_fields PASSED [  6%]
tests/test_collectors.py::TestRedditCollector::test_deduplicates_within_subreddit PASSED [  6%]
tests/test_collectors.py::TestRedditCollector::test_multiple_subreddits_aggregate_results PASSED [  7%]
tests/test_collectors.py::TestRedditCollector::test_accepts_results_from_any_reddit_subreddit PASSED [  8%]
tests/test_collectors.py::TestRedditCollector::test_empty_results_returns_empty_list PASSED [  8%]
tests/test_collectors.py::TestRedditCollector::test_gracefully_handles_exa_error PASSED [  9%]
tests/test_collectors.py::TestRedditCollector::test_raw_content_includes_title_and_text PASSED [  9%]
tests/test_collectors.py::TestRedditCollector::test_passes_reddit_domain_to_exa PASSED [ 10%]
tests/test_collectors.py::TestGitHubCollector::test_fetch_returns_list PASSED [ 11%]
tests/test_collectors.py::TestGitHubCollector::test_filters_by_min_stars PASSED [ 11%]
tests/test_collectors.py::TestGitHubCollector::test_item_has_required_fields PASSED [ 12%]
tests/test_collectors.py::TestGitHubCollector::test_raw_content_includes_description_and_topics PASSED [ 12%]
tests/test_collectors.py::TestGitHubCollector::test_empty_response_returns_empty_list PASSED [ 13%]
tests/test_collectors.py::TestGitHubCollector::test_deduplicates_across_topics PASSED [ 14%]
tests/test_collectors.py::TestGitHubCollector::test_uses_auth_header_when_token_provided PASSED [ 14%]
tests/test_deep_dive.py::TestArticleContent::test_valid_model_with_url PASSED [ 15%]
tests/test_deep_dive.py::TestArticleContent::test_valid_model_without_url PASSED [ 16%]
tests/test_deep_dive.py::TestArticleContent::test_requires_title PASSED  [ 16%]
tests/test_deep_dive.py::TestArticleContent::test_requires_raw_text PASSED [ 17%]
tests/test_deep_dive.py::TestSearchQueries::test_valid_model PASSED      [ 17%]
tests/test_deep_dive.py::TestSearchQueries::test_empty_lists_allowed PASSED [ 18%]
tests/test_deep_dive.py::TestStructuralAnalysis::test_valid_model PASSED [ 19%]
tests/test_deep_dive.py::TestStructuralAnalysis::test_requires_all_fields PASSED [ 19%]
tests/test_deep_dive.py::TestWebSource::test_valid_model PASSED          [ 20%]
tests/test_deep_dive.py::TestFactCheck::test_valid_model PASSED          [ 20%]
tests/test_deep_dive.py::TestFactCheck::test_status_can_be_any_string PASSED [ 21%]
tests/test_deep_dive.py::TestResearchResults::test_valid_model PASSED    [ 22%]
tests/test_deep_dive.py::TestMultiPerspectiveEvaluation::test_valid_model PASSED [ 22%]
tests/test_deep_dive.py::TestMultiPerspectiveEvaluation::test_credibility_score_is_float PASSED [ 23%]
tests/test_deep_dive.py::TestDeepDiveReport::test_valid_model PASSED     [ 24%]
tests/test_deep_dive.py::TestDeepDiveReport::test_report_path_optional PASSED [ 24%]
tests/test_deep_dive.py::TestContentFetcher::test_from_text_returns_article PASSED [ 25%]
tests/test_deep_dive.py::TestContentFetcher::test_from_text_default_title PASSED [ 25%]
tests/test_deep_dive.py::TestContentFetcher::test_fetch_url_returns_article PASSED [ 26%]
tests/test_deep_dive.py::TestContentFetcher::test_fetch_url_truncates_at_8000_chars PASSED [ 27%]
tests/test_deep_dive.py::TestContentFetcher::test_fetch_url_sets_title_from_html PASSED [ 27%]
tests/test_deep_dive.py::TestContentFetcher::test_fetch_url_raises_on_http_error PASSED [ 28%]
tests/test_deep_dive.py::TestStructuralAnalyzer::test_run_returns_structural_analysis PASSED [ 29%]
tests/test_deep_dive.py::TestStructuralAnalyzer::test_run_parses_sub_claims PASSED [ 29%]
tests/test_deep_dive.py::TestStructuralAnalyzer::test_run_parses_search_queries PASSED [ 30%]
tests/test_deep_dive.py::TestStructuralAnalyzer::test_run_calls_llm_with_article_content PASSED [ 30%]
tests/test_deep_dive.py::TestWebResearcher::test_run_returns_research_results PASSED [ 31%]
tests/test_deep_dive.py::TestWebResearcher::test_run_returns_supporting_sources PASSED [ 32%]
tests/test_deep_dive.py::TestWebResearcher::test_run_calls_exa_for_each_query_type PASSED [ 32%]
tests/test_deep_dive.py::TestWebResearcher::test_run_deduplicates_results_by_url PASSED [ 33%]
tests/test_deep_dive.py::TestWebResearcher::test_run_gracefully_handles_exa_error PASSED [ 33%]
tests/test_deep_dive.py::TestWebResearcher::test_web_source_has_required_fields PASSED [ 34%]
tests/test_deep_dive.py::TestMultiPerspectiveEvaluator::test_run_returns_evaluation PASSED [ 35%]
tests/test_deep_dive.py::TestMultiPerspectiveEvaluator::test_run_parses_pro_arguments PASSED [ 35%]
tests/test_deep_dive.py::TestMultiPerspectiveEvaluator::test_run_parses_con_arguments PASSED [ 36%]
tests/test_deep_dive.py::TestMultiPerspectiveEvaluator::test_run_calls_llm_with_both_inputs PASSED [ 37%]
tests/test_deep_dive.py::TestMultiPerspectiveEvaluator::test_overall_verdict_is_string PASSED [ 37%]
tests/test_deep_dive.py::TestDeepDiveReporter::test_write_creates_file PASSED [ 38%]
tests/test_deep_dive.py::TestDeepDiveReporter::test_write_returns_path PASSED [ 38%]
tests/test_deep_dive.py::TestDeepDiveReporter::test_write_creates_parent_dirs PASSED [ 39%]
tests/test_deep_dive.py::TestDeepDiveReporter::test_render_markdown_contains_title PASSED [ 40%]
tests/test_deep_dive.py::TestDeepDiveReporter::test_render_markdown_contains_main_thesis PASSED [ 40%]
tests/test_deep_dive.py::TestDeepDiveReporter::test_render_markdown_contains_credibility_score PASSED [ 41%]
tests/test_deep_dive.py::TestDeepDiveReporter::test_render_markdown_contains_pro_arguments PASSED [ 41%]
tests/test_deep_dive.py::TestDeepDiveReporter::test_render_markdown_contains_fact_check_table PASSED [ 42%]
tests/test_deep_dive.py::TestDeepDiveReporter::test_render_markdown_contains_supporting_sources PASSED [ 43%]
tests/test_deep_dive.py::TestDeepDiveReporter::test_filename_contains_date_and_slug PASSED [ 43%]
tests/test_deep_dive.py::TestDeepDiveReporter::test_file_written_with_utf8 PASSED [ 44%]
tests/test_deep_dive.py::TestDeepDiveOrchestrator::test_run_with_url_returns_report PASSED [ 45%]
tests/test_deep_dive.py::TestDeepDiveOrchestrator::test_run_with_text_returns_report PASSED [ 45%]
tests/test_deep_dive.py::TestDeepDiveOrchestrator::test_run_calls_pipeline_in_order PASSED [ 46%]
tests/test_deep_dive.py::TestDeepDiveOrchestrator::test_run_dry_run_skips_reporter PASSED [ 46%]
tests/test_deep_dive.py::TestDeepDiveOrchestrator::test_run_raises_when_no_input PASSED [ 47%]
tests/test_deep_dive.py::TestLogicalQuality::test_construction PASSED    [ 48%]
tests/test_deep_dive.py::TestLogicalQuality::test_empty_fallacies_allowed PASSED [ 48%]
tests/test_deep_dive.py::TestCitedArgument::test_construction_with_urls PASSED [ 49%]
tests/test_deep_dive.py::TestCitedArgument::test_source_urls_defaults_to_empty PASSED [ 50%]
tests/test_deep_dive.py::TestBiasIndicator::test_construction PASSED     [ 50%]
tests/test_deep_dive.py::TestCredibilityBreakdown::test_construction PASSED [ 51%]
tests/test_deep_dive.py::TestStructuralAnalyzerLogicalQuality::test_run_parses_logical_quality PASSED [ 51%]
tests/test_deep_dive.py::TestStructuralAnalyzerLogicalQuality::test_run_handles_missing_logical_quality PASSED [ 52%]
tests/test_deep_dive.py::TestMultiPerspectiveEvaluatorEnriched::test_run_parses_cited_pro_arguments PASSED [ 53%]
tests/test_deep_dive.py::TestMultiPerspectiveEvaluatorEnriched::test_run_parses_bias_indicators PASSED [ 53%]
tests/test_deep_dive.py::TestMultiPerspectiveEvaluatorEnriched::test_run_parses_credibility_breakdown PASSED [ 54%]
tests/test_deep_dive.py::TestMultiPerspectiveEvaluatorEnriched::test_run_parses_core_disputes PASSED [ 54%]
tests/test_deep_dive.py::TestMultiPerspectiveEvaluatorEnriched::test_run_parses_synthesis PASSED [ 55%]
tests/test_deep_dive.py::TestMultiPerspectiveEvaluatorEnriched::test_run_parses_assessed_fact_checks_with_status PASSED [ 56%]
tests/test_deep_dive.py::TestMultiPerspectiveEvaluatorEnriched::test_run_backward_compat_missing_new_fields PASSED [ 56%]
tests/test_deep_dive.py::TestDeepDiveReporterEnriched::test_render_markdown_uses_cited_arguments_with_links PASSED [ 57%]
tests/test_deep_dive.py::TestDeepDiveReporterEnriched::test_render_markdown_shows_bias_indicators_table PASSED [ 58%]
tests/test_deep_dive.py::TestDeepDiveReporterEnriched::test_render_markdown_shows_credibility_breakdown_table PASSED [ 58%]
tests/test_deep_dive.py::TestDeepDiveReporterEnriched::test_render_markdown_shows_core_disputes_section PASSED [ 59%]
tests/test_deep_dive.py::TestDeepDiveReporterEnriched::test_render_markdown_shows_synthesis_section PASSED [ 59%]
tests/test_deep_dive.py::TestDeepDiveReporterEnriched::test_render_markdown_uses_assessed_fact_checks_status PASSED [ 60%]
tests/test_deep_dive.py::TestDeepDiveReporterEnriched::test_render_markdown_shows_logical_quality_block PASSED [ 61%]
tests/test_deep_dive.py::TestDeepDiveReporterEnriched::test_render_markdown_fallback_when_new_fields_empty PASSED [ 61%]
tests/test_main.py::TestScout::test_process_item_returns_usecase_for_valuable_item PASSED [ 62%]
tests/test_main.py::TestScout::test_process_item_returns_none_for_low_value_item PASSED [ 62%]
tests/test_main.py::TestScout::test_process_item_assigns_id_from_url PASSED [ 63%]
tests/test_main.py::TestScout::test_run_dry_run_does_not_write_db PASSED [ 64%]
tests/test_main.py::TestScout::test_run_writes_report_file PASSED        [ 64%]
tests/test_main.py::TestScout::test_run_skips_already_stored_items PASSED [ 65%]
tests/test_models.py::TestMakeId::test_returns_12_char_hex PASSED        [ 66%]
tests/test_models.py::TestMakeId::test_deterministic PASSED              [ 66%]
tests/test_models.py::TestMakeId::test_different_urls_different_ids PASSED [ 67%]
tests/test_models.py::TestMakeId::test_matches_sha256_prefix PASSED      [ 67%]
tests/test_models.py::TestScenarioType::test_all_values_are_chinese_strings PASSED [ 68%]
tests/test_models.py::TestScenarioType::test_has_expected_members PASSED [ 69%]
tests/test_models.py::TestScenarioType::test_other_fallback PASSED       [ 69%]
tests/test_models.py::TestNovelty::test_values PASSED                    [ 70%]
tests/test_models.py::TestUseCase::test_valid_usecase_creates_successfully PASSED [ 70%]
tests/test_models.py::TestUseCase::test_value_score_clamped_to_0_1 PASSED [ 71%]
tests/test_models.py::TestUseCase::test_value_score_below_zero_rejected PASSED [ 72%]
tests/test_models.py::TestUseCase::test_source_platform_hackernews PASSED [ 72%]
tests/test_models.py::TestUseCase::test_tools_used_is_list PASSED        [ 73%]
tests/test_models.py::TestUseCase::test_similar_to_ids_defaults_empty PASSED [ 74%]
tests/test_models.py::TestUseCase::test_scenario_type_accepts_enum PASSED [ 74%]
tests/test_models.py::TestUseCase::test_scenario_type_accepts_string_value PASSED [ 75%]
tests/test_models.py::TestUseCase::test_novelty_accepts_string_value PASSED [ 75%]
tests/test_models.py::TestUseCase::test_serialises_to_dict PASSED        [ 76%]
tests/test_processors.py::TestValueFilter::test_returns_true_for_high_value_item PASSED [ 77%]
tests/test_processors.py::TestValueFilter::test_returns_false_for_low_value_item PASSED [ 77%]
tests/test_processors.py::TestValueFilter::test_result_has_required_keys PASSED [ 78%]
tests/test_processors.py::TestClassifier::test_returns_classification_result PASSED [ 79%]
tests/test_processors.py::TestClassifier::test_maps_string_to_scenario_type_enum PASSED [ 79%]
tests/test_processors.py::TestClassifier::test_falls_back_to_other_for_unknown_scenario PASSED [ 80%]
tests/test_processors.py::TestClassifier::test_result_has_summary PASSED [ 80%]
tests/test_processors.py::TestDeduplicator::test_returns_new_when_no_existing PASSED [ 81%]
tests/test_processors.py::TestDeduplicator::test_returns_similar_when_matching_exists PASSED [ 82%]
tests/test_processors.py::TestDeduplicator::test_returns_repeat_for_duplicate PASSED [ 82%]
tests/test_processors.py::TestDeduplicator::test_skips_llm_when_no_existing PASSED [ 83%]
tests/test_reporters.py::TestObsidianReporterGenerate::test_returns_markdown_string PASSED [ 83%]
tests/test_reporters.py::TestObsidianReporterGenerate::test_includes_date_in_header PASSED [ 84%]
tests/test_reporters.py::TestObsidianReporterGenerate::test_includes_scan_summary_line PASSED [ 85%]
tests/test_reporters.py::TestObsidianReporterGenerate::test_new_section_contains_new_usecases PASSED [ 85%]
tests/test_reporters.py::TestObsidianReporterGenerate::test_similar_section_contains_similar_usecases PASSED [ 86%]
tests/test_reporters.py::TestObsidianReporterGenerate::test_filter_log_section_present PASSED [ 87%]
tests/test_reporters.py::TestObsidianReporterGenerate::test_tools_shown_in_new_section PASSED [ 87%]
tests/test_reporters.py::TestObsidianReporterGenerate::test_empty_usecases_produces_valid_report PASSED [ 88%]
tests/test_reporters.py::TestObsidianReporterGenerate::test_groups_similar_by_scenario_type PASSED [ 88%]
tests/test_reporters.py::TestObsidianReporterWrite::test_writes_file_to_output_dir PASSED [ 89%]
tests/test_reporters.py::TestObsidianReporterWrite::test_written_file_contains_markdown PASSED [ 90%]
tests/test_reporters.py::TestObsidianReporterWrite::test_creates_output_dir_if_missing PASSED [ 90%]
tests/test_storage.py::TestDatabaseInit::test_creates_db_file PASSED     [ 91%]
tests/test_storage.py::TestDatabaseInit::test_accepts_path_object PASSED [ 91%]
tests/test_storage.py::TestDatabaseSave::test_save_returns_usecase_id PASSED [ 92%]
tests/test_storage.py::TestDatabaseSave::test_save_persists_record PASSED [ 93%]
tests/test_storage.py::TestDatabaseSave::test_save_duplicate_does_not_raise PASSED [ 93%]
tests/test_storage.py::TestDatabaseSave::test_save_multiple_records PASSED [ 94%]
tests/test_storage.py::TestDatabaseExists::test_exists_false_for_unknown_id PASSED [ 95%]
tests/test_storage.py::TestDatabaseExists::test_exists_true_after_save PASSED [ 95%]
tests/test_storage.py::TestDatabaseGetByScenario::test_returns_empty_list_when_no_records PASSED [ 96%]
tests/test_storage.py::TestDatabaseGetByScenario::test_returns_matching_records PASSED [ 96%]
tests/test_storage.py::TestDatabaseGetByScenario::test_does_not_return_other_scenario PASSED [ 97%]
tests/test_storage.py::TestDatabaseGetByScenario::test_returns_multiple_matching_records PASSED [ 98%]
tests/test_storage.py::TestDatabaseGetByScenario::test_limits_results PASSED [ 98%]
tests/test_storage.py::TestDatabaseGetRecent::test_get_recent_returns_last_n_real_value PASSED [ 99%]
tests/test_storage.py::TestDatabaseGetRecent::test_roundtrip_preserves_fields PASSED [100%]

============================= 162 passed in 5.37s ==============================


Now let's do a quick test run to see if the collectors actually return data now with our fixes:


source venv/bin/activate && python -c "
# Quick verification that all collectors now return data
from ai_usecases_explorer.settings import Settings
from ai_usecases_explorer.collectors.hackernews import HackerNewsCollector
from ai_usecases_explorer.collectors.reddit import RedditCollector
from ai_usecases_explorer.collectors.github import GitHubCollector
from rich.console import Console

console = Console()
s = Settings()

console.print('[cyan]Testing HackerNews collector...[/cyan]')
hn = HackerNewsCollector(min_points=s.hn_min_points)
hn_items = hn.fetch()
console.print(f'  HN: {len(hn_items)} items')

console.print('[cyan]Testing Reddit collector...[/cyan]')
reddit = RedditCollector(subreddits=s.reddit_subreddits, api_key=s.exa_api_key)
reddit_items = reddit.fetch()
console.print(f'  Reddit: {len(reddit_items)} items')

console.print('[cyan]Testing GitHub collector...[/cyan]')
github = GitHubCollector(min_stars=s.github_min_stars, token=s.github_token)
github_items = github.fetch()
console.print(f'  GitHub: {len(github_items)} items')

console.print('[green]All collectors working![/green]')
" 2>&1

Test actual collector performance


Testing HackerNews collector...
  HN: 18 items
Testing Reddit collector...
  Reddit: 40 items
Testing GitHub collector...
  GitHub: 118 items
All collectors working!


Excellent! Now we have:
- HackerNews returning 18 items from last 48 hours
- Reddit returning 40 items (with our fixed collector)
- GitHub returning 118 items

That's a massive improvement from the previous Reddit: 0 and GitHub: 0 in the report!


source venv/bin/activate && python -c "
from ai_usecases_explorer.settings import Settings
from ai_usecases_explorer.collectors.github import GitHubCollector
from ai_usecases_explorer.processors.filter import ValueFilter
import anthropic

s = Settings()

collector = GitHubCollector(min_stars=s.github_min_stars, token=s.github_token)
items = collector.fetch()[:5]

client_kwargs = {'api_key': s.anthropic_api_key}
if s.anthropic_base_url:
    client_kwargs['base_url'] = s.anthropic_base_url
client = anthropic.Anthropic(**client_kwargs)
vf = ValueFilter(client=client, model=s.claude_model)

print('=== ValueFilter test on GitHub items ===')
for item in items[:3]:
    print(f'\\n[bold]{item.title}[/bold]')
    print(f'Source: {item.source_url}')
    result = vf.run(item)
    print(f'is_real_value: {result[\"is_real_value\"]}')
    print(f'Reason: {result[\"reason\"]}')
"

Test ValueFilter on GitHub items after fix


Error: Exit code 1
Traceback (most recent call last):
  File "<string>", line 22, in <module>
  File "/root/projects/ai_usecases_explorer/src/ai_usecases_explorer/processors/filter.py", line 41, in run
    return self._call_llm(item)
           ^^^^^^^^^^^^^^^^^^^^
  File "/root/projects/ai_usecases_explorer/src/ai_usecases_explorer/processors/filter.py", line 45, in _call_llm
    message = self._client.messages.create(
              ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
  File "/root/projects/ai_usecases_explorer/venv/lib64/python3.11/site-packages/anthropic/_utils/_utils.py", line 282, in wrapper
    return func(*args, **kwargs)
           ^^^^^^^^^^^^^^^^^^^^^
  File "/root/projects/ai_usecases_explorer/venv/lib64/python3.11/site-packages/anthropic/resources/messages/messages.py", line 996, in create
    return self._post(
           ^^^^^^^^^^^
  File "/root/projects/ai_usecases_explorer/venv/lib64/python3.11/site-packages/anthropic/_base_client.py", line 1364, in post
    return cast(ResponseT, self.request(cast_to, opts, stream=stream, stream_cls=stream_cls))
                           ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
  File "/root/projects/ai_usecases_explorer/venv/lib64/python3.11/site-packages/anthropic/_base_client.py", line 1137, in request
    raise self._make_status_error_from_response(err.response) from None
anthropic.AuthenticationError: Error code: 401 - {'error': {'code': 'AuthenticationError', 'message': 'The API key format is incorrect. Request id: 02177330303702213c33c36521378d02b2c8389204fa8c33bc054', 'param': '', 'type': 'Unauthorized'}}
=== ValueFilter test on GitHub items ===

[bold]openclaw/openclaw[/bold]
Source: https://github.com/openclaw/openclaw


Okay, so the test failed because of an API key authentication error. That's not related to our changes. The API key format is incorrect—maybe it's missing or expired. Let's just run the test again without the ValueFilter part to verify that the collectors are still returning data.


source venv/bin/activate && python -c "
from ai_usecases_explorer.settings import Settings
from ai_usecases_explorer.collectors.hackernews import HackerNewsCollector
from ai_usecases_explorer.collectors.reddit import RedditCollector
from ai_usecases_explorer.collectors.github import GitHubCollector
from rich.console import Console

console = Console()
s = Settings()

console.print('[cyan]Testing collector performance (without LLM calls)...[/cyan]')

hn = HackerNewsCollector(min_points=s.hn_min_points)
hn_items = hn.fetch()
console.print(f'  HN: {len(hn_items)} items')
if hn_items:
    console.print(f'  Sample: {hn_items[0].title[:60]}')

reddit = RedditCollector(subreddits=s.reddit_subreddits, api_key=s.exa_api_key)
reddit_items = reddit.fetch()
console.print(f'  Reddit: {len(reddit_items)} items')
if reddit_items:
    console.print(f'  Sample: {reddit_items[0].title[:60]}')

github = GitHubCollector(min_stars=s.github_min_stars, token=s.github_token)
github_items = github.fetch()
console.print(f'  GitHub: {len(github_items)} items')
if github_items:
    console.print(f'  Sample: {github_items[0].title[:60]}')

console.print('[green]All collectors operational![/green]')
"

Verify collectors are still working


Testing collector performance (without LLM calls)...
  HN: 15 items
  Sample: Tons of new LLM bot accounts here
  Reddit: 39 items
  Sample: https://www.reddit.com/r/OpenClawBuilders/comments/1rqr7r1/t
  GitHub: 119 items
  Sample: openclaw/openclaw
All collectors operational!


Okay, the collectors are still working perfectly. The Reddit collector is now returning 39 items, which is a huge improvement from 0. The HackerNews collector is returning 15 items from last 48 hours, and GitHub is returning 119 items.

Now let's just run the tests one more time to make sure everything is still passing:


source venv/bin/activate && python -m pytest tests/ -v

Run all tests once more