From 01e7bf58f1ca7afa0fc943e8e15c6e072f5b77e5 Mon Sep 17 00:00:00 2001 From: Berack96 Date: Tue, 30 Sep 2025 22:13:12 +0200 Subject: [PATCH] Refactor news & social modules - update NewsAPIsTool - update SocialAPIsTool - add tests for NewsAPIsTool - added some missing docs --- src/__init__.py | 0 src/app/news/__init__.py | 11 +++- src/app/news/{gnews_api.py => googlenews.py} | 0 src/app/social/__init.py | 1 - src/app/social/__init__.py | 52 +++++++++++++++++++ src/app/social/base.py | 21 +++++--- tests/conftest.py | 1 + tests/tools/test_news_tool.py | 54 ++++++++++++++++++++ 8 files changed, 131 insertions(+), 9 deletions(-) delete mode 100644 src/__init__.py rename src/app/news/{gnews_api.py => googlenews.py} (100%) delete mode 100644 src/app/social/__init.py create mode 100644 src/app/social/__init__.py create mode 100644 tests/tools/test_news_tool.py diff --git a/src/__init__.py b/src/__init__.py deleted file mode 100644 index e69de29..0000000 diff --git a/src/app/news/__init__.py b/src/app/news/__init__.py index 3bbfe27..05e4309 100644 --- a/src/app/news/__init__.py +++ b/src/app/news/__init__.py @@ -2,7 +2,7 @@ from agno.tools import Toolkit from app.utils.wrapper_handler import WrapperHandler from .base import NewsWrapper, Article from .news_api import NewsApiWrapper -from .gnews_api import GoogleNewsWrapper +from .googlenews import GoogleNewsWrapper from .cryptopanic_api import CryptoPanicWrapper from .duckduckgo import DuckDuckGoWrapper @@ -24,6 +24,15 @@ class NewsAPIsTool(NewsWrapper, Toolkit): """ def __init__(self): + """ + Initialize the NewsAPIsTool with multiple news API wrappers. + The tool uses WrapperHandler to manage and invoke the different news API wrappers. + The following wrappers are included in this order: + - GoogleNewsWrapper. + - DuckDuckGoWrapper. + - NewsApiWrapper. + - CryptoPanicWrapper. + """ wrappers = [GoogleNewsWrapper, DuckDuckGoWrapper, NewsApiWrapper, CryptoPanicWrapper] self.wrapper_handler: WrapperHandler[NewsWrapper] = WrapperHandler.build_wrappers(wrappers) diff --git a/src/app/news/gnews_api.py b/src/app/news/googlenews.py similarity index 100% rename from src/app/news/gnews_api.py rename to src/app/news/googlenews.py diff --git a/src/app/social/__init.py b/src/app/social/__init.py deleted file mode 100644 index 0d46bc8..0000000 --- a/src/app/social/__init.py +++ /dev/null @@ -1 +0,0 @@ -from .base import SocialWrapper \ No newline at end of file diff --git a/src/app/social/__init__.py b/src/app/social/__init__.py new file mode 100644 index 0000000..29d17be --- /dev/null +++ b/src/app/social/__init__.py @@ -0,0 +1,52 @@ +from .base import SocialPost, SocialWrapper +from .reddit import RedditWrapper +from app.utils.wrapper_handler import WrapperHandler +from agno.tools import Toolkit + +__all__ = ["SocialAPIsTool", "SOCIAL_INSTRUCTIONS", "RedditWrapper"] + +class SocialAPIsTool(SocialWrapper, Toolkit): + """ + Aggregates multiple social media API wrappers and manages them using WrapperHandler. + This class supports retrieving top crypto-related posts by querying multiple sources: + - RedditWrapper + + By default, it returns results from the first successful wrapper. + Optionally, it can be configured to collect posts from all wrappers. + If no wrapper succeeds, an exception is raised. + """ + + def __init__(self): + """ + Initialize the SocialAPIsTool with multiple social media API wrappers. + The tool uses WrapperHandler to manage and invoke the different social media API wrappers. + The following wrappers are included in this order: + - RedditWrapper. + """ + + wrappers = [RedditWrapper] + self.wrapper_handler: WrapperHandler[SocialWrapper] = WrapperHandler(wrappers) + + Toolkit.__init__( + self, + name="Socials Toolkit", + tools=[ + self.get_top_crypto_posts, + ], + ) + + # TODO Pensare se ha senso restituire i post da TUTTI i wrapper o solo dal primo che funziona + # la modifica è banale, basta usare try_call_all invece di try_call + def get_top_crypto_posts(self, limit:int = 5) -> list[SocialPost]: + return self.wrapper_handler.try_call(lambda w: w.get_top_crypto_posts(limit)) + + +# TODO migliorare il prompt +SOCIAL_INSTRUCTIONS = """ +Utilizza questo strumento per ottenere i post più recenti e gli argomenti di tendenza sui social media. Puoi richiedere i post più recenti o gli argomenti di tendenza. + +Esempio di utilizzo: +- get_latest_news("crypto", limit=5) # ottieni le ultime 5 notizie su "crypto", la query può essere qualsiasi argomento di interesse +- get_top_headlines(limit=3) # ottieni i 3 titoli principali delle notizie globali + +""" \ No newline at end of file diff --git a/src/app/social/base.py b/src/app/social/base.py index 945cdd5..1b66c1d 100644 --- a/src/app/social/base.py +++ b/src/app/social/base.py @@ -7,16 +7,23 @@ class SocialPost(BaseModel): description: str = "" comments: list["SocialComment"] = [] - def __str__(self): - return f"Title: {self.title}\nDescription: {self.description}\nComments: {len(self.comments)}\n[{" | ".join(str(c) for c in self.comments)}]" - class SocialComment(BaseModel): time: str = "" description: str = "" - def __str__(self): - return f"Time: {self.time}\nDescription: {self.description}" -# TODO IMPLEMENTARLO SE SI USANO PIU' WRAPPER (E QUINDI PIU' SOCIAL) class SocialWrapper: - pass + """ + Base class for social media API wrappers. + All social media API wrappers should inherit from this class and implement the methods. + """ + def get_top_crypto_posts(self, limit: int = 5) -> list[SocialPost]: + """ + Get top cryptocurrency-related posts, optionally limited by total. + Args: + limit (int): The maximum number of posts to return. + Returns: + list[SocialPost]: A list of SocialPost objects. + """ + raise NotImplementedError("This method should be overridden by subclasses") + diff --git a/tests/conftest.py b/tests/conftest.py index e65e86f..c792e04 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -23,6 +23,7 @@ def pytest_configure(config:pytest.Config): ("social", "marks tests that use social media"), ("limited", "marks tests that have limited execution due to API constraints"), ("wrapper", "marks tests for wrapper handler"), + ("tools", "marks tests for tools"), ] for marker in markers: line = f"{marker[0]}: {marker[1]}" diff --git a/tests/tools/test_news_tool.py b/tests/tools/test_news_tool.py new file mode 100644 index 0000000..71dd51f --- /dev/null +++ b/tests/tools/test_news_tool.py @@ -0,0 +1,54 @@ +import pytest +from app.news import NewsAPIsTool + + +@pytest.mark.limited +@pytest.mark.tools +@pytest.mark.news +@pytest.mark.api +class TestNewsAPITool: + def test_news_api_tool(self): + tool = NewsAPIsTool() + assert tool is not None + + def test_news_api_tool_get_top(self): + tool = NewsAPIsTool() + result = tool.wrapper_handler.try_call(lambda w: w.get_top_headlines(total=2)) + assert isinstance(result, list) + assert len(result) > 0 + for article in result: + assert article.title is not None + assert article.source is not None + + def test_news_api_tool_get_latest(self): + tool = NewsAPIsTool() + result = tool.wrapper_handler.try_call(lambda w: w.get_latest_news(query="crypto", total=2)) + assert isinstance(result, list) + assert len(result) > 0 + for article in result: + assert article.title is not None + assert article.source is not None + + def test_news_api_tool_get_top__all_results(self): + tool = NewsAPIsTool() + result = tool.wrapper_handler.try_call_all(lambda w: w.get_top_headlines(total=2)) + assert isinstance(result, dict) + assert len(result.keys()) > 0 + print("Results from providers:", result.keys()) + for provider, articles in result.items(): + for article in articles: + print(provider, article.title) + assert article.title is not None + assert article.source is not None + + def test_news_api_tool_get_latest__all_results(self): + tool = NewsAPIsTool() + result = tool.wrapper_handler.try_call_all(lambda w: w.get_latest_news(query="crypto", total=2)) + assert isinstance(result, dict) + assert len(result.keys()) > 0 + print("Results from providers:", result.keys()) + for provider, articles in result.items(): + for article in articles: + print(provider, article.title) + assert article.title is not None + assert article.source is not None