From fcbb312d081e68cd2b4b23fa03c837a4053561eb Mon Sep 17 00:00:00 2001 From: trojanhorse47 Date: Tue, 30 Sep 2025 12:28:44 +0200 Subject: [PATCH] - Refactor struttura progetto: divisione tra agent e toolkit --- src/__init__.py | 0 src/app.py | 18 ++-- src/app/agents/market_agent.py | 92 ++++++++++++++++++ src/app/agents/news_agent.py | 32 +++++- src/app/agents/social_agent.py | 33 ++++++- src/app/markets/__init__.py | 3 +- src/app/markets/coinbase.py | 3 + src/app/markets/cryptocompare.py | 2 + src/app/models.py | 7 +- src/app/pipeline.py | 84 ++++++++++++++++ src/app/{agents => }/predictor.py | 5 +- src/app/tool.py | 97 ------------------- src/app/toolkits/__init__.py | 0 .../market.py => toolkits/market_toolkit.py} | 2 + tests/agents/test_market.py | 2 +- 15 files changed, 266 insertions(+), 114 deletions(-) create mode 100644 src/__init__.py create mode 100644 src/app/agents/market_agent.py create mode 100644 src/app/pipeline.py rename src/app/{agents => }/predictor.py (99%) delete mode 100644 src/app/tool.py create mode 100644 src/app/toolkits/__init__.py rename src/app/{agents/market.py => toolkits/market_toolkit.py} (99%) diff --git a/src/__init__.py b/src/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/src/app.py b/src/app.py index 983779e..8e9e787 100644 --- a/src/app.py +++ b/src/app.py @@ -1,8 +1,8 @@ import gradio as gr - -from dotenv import load_dotenv -from app.tool import ToolAgent from agno.utils.log import log_info +from dotenv import load_dotenv + +from app.pipeline import Pipeline ######################################## # MAIN APP & GRADIO INTERFACE @@ -16,31 +16,31 @@ if __name__ == "__main__": load_dotenv() ###################################### - tool_agent = ToolAgent() + pipeline = Pipeline() with gr.Blocks() as demo: gr.Markdown("# 🤖 Agente di Analisi e Consulenza Crypto") with gr.Row(): provider = gr.Dropdown( - choices=tool_agent.list_providers(), + choices=pipeline.list_providers(), type="index", label="Modello da usare" ) - provider.change(fn=tool_agent.choose_provider, inputs=provider, outputs=None) + provider.change(fn=pipeline.choose_provider, inputs=provider, outputs=None) style = gr.Dropdown( - choices=tool_agent.list_styles(), + choices=pipeline.list_styles(), type="index", label="Stile di investimento" ) - style.change(fn=tool_agent.choose_style, inputs=style, outputs=None) + style.change(fn=pipeline.choose_style, inputs=style, outputs=None) user_input = gr.Textbox(label="Richiesta utente") output = gr.Textbox(label="Risultato analisi", lines=12) analyze_btn = gr.Button("🔎 Analizza") - analyze_btn.click(fn=tool_agent.interact, inputs=[user_input], outputs=output) + analyze_btn.click(fn=pipeline.interact, inputs=[user_input], outputs=output) server, port = ("0.0.0.0", 8000) log_info(f"Starting UPO AppAI on http://{server}:{port}") diff --git a/src/app/agents/market_agent.py b/src/app/agents/market_agent.py new file mode 100644 index 0000000..94d01d5 --- /dev/null +++ b/src/app/agents/market_agent.py @@ -0,0 +1,92 @@ +from typing import Union, List, Dict, Optional, Any, Iterator, Sequence +from agno.agent import Agent +from agno.models.message import Message +from agno.run.agent import RunOutput, RunOutputEvent +from pydantic import BaseModel + +from src.app.toolkits.market_toolkit import MarketToolkit +from src.app.markets.base import ProductInfo # modello dati già definito nel tuo progetto + + +class MarketAgent(Agent): + """ + Wrapper che trasforma MarketToolkit in un Agent compatibile con Team. + Produce sia output leggibile (content) che dati strutturati (metadata). + """ + + def __init__(self, currency: str = "USD"): + super().__init__() + self.toolkit = MarketToolkit() + self.currency = currency + self.name = "MarketAgent" + + def run( + self, + input: Union[str, List, Dict, Message, BaseModel, List[Message]], + *, + stream: Optional[bool] = None, + stream_intermediate_steps: Optional[bool] = None, + user_id: Optional[str] = None, + session_id: Optional[str] = None, + session_state: Optional[Dict[str, Any]] = None, + audio: Optional[Sequence[Any]] = None, + images: Optional[Sequence[Any]] = None, + videos: Optional[Sequence[Any]] = None, + files: Optional[Sequence[Any]] = None, + retries: Optional[int] = None, + knowledge_filters: Optional[Dict[str, Any]] = None, + add_history_to_context: Optional[bool] = None, + add_dependencies_to_context: Optional[bool] = None, + add_session_state_to_context: Optional[bool] = None, + dependencies: Optional[Dict[str, Any]] = None, + metadata: Optional[Dict[str, Any]] = None, + yield_run_response: bool = False, + debug_mode: Optional[bool] = None, + **kwargs: Any, + ) -> Union[RunOutput, Iterator[Union[RunOutputEvent, RunOutput]]]: + # 1. Estraggo la query dal parametro "input" + if isinstance(input, str): + query = input + elif isinstance(input, dict) and "query" in input: + query = input["query"] + elif isinstance(input, Message): + query = input.content + elif isinstance(input, BaseModel): + query = str(input) + elif isinstance(input, list) and input and isinstance(input[0], Message): + query = input[0].content + else: + query = str(input) + + # 2. Individuo i simboli da analizzare + symbols = [] + for token in query.upper().split(): + if token in ("BTC", "ETH", "XRP", "LTC", "BCH"): # TODO: estendere dinamicamente + symbols.append(token) + + if not symbols: + symbols = ["BTC", "ETH"] # default + + # 3. Recupero i dati dal toolkit + results = [] + products: List[ProductInfo] = [] + + for sym in symbols: + try: + product = self.toolkit.get_current_price(sym) # supponiamo ritorni un ProductInfo o simile + if isinstance(product, list): + products.extend(product) + else: + products.append(product) + + results.append(f"{sym}: {product.price if hasattr(product, 'price') else product}") + except Exception as e: + results.append(f"{sym}: errore ({e})") + + # 4. Preparo output leggibile + metadati strutturati + output_text = "📊 Dati di mercato:\n" + "\n".join(results) + + return RunOutput( + content=output_text, + metadata={"products": products} + ) diff --git a/src/app/agents/news_agent.py b/src/app/agents/news_agent.py index 831d5b3..d6de5e5 100644 --- a/src/app/agents/news_agent.py +++ b/src/app/agents/news_agent.py @@ -1,4 +1,34 @@ -class NewsAgent: +from agno.agent import Agent + +class NewsAgent(Agent): + """ + Gli agenti devono esporre un metodo run con questa firma. + + def run( + self, + input: Union[str, List, Dict, Message, BaseModel, List[Message]], + *, + stream: Optional[bool] = None, + stream_intermediate_steps: Optional[bool] = None, + user_id: Optional[str] = None, + session_id: Optional[str] = None, + session_state: Optional[Dict[str, Any]] = None, + audio: Optional[Sequence[Any]] = None, + images: Optional[Sequence[Any]] = None, + videos: Optional[Sequence[Any]] = None, + files: Optional[Sequence[Any]] = None, + retries: Optional[int] = None, + knowledge_filters: Optional[Dict[str, Any]] = None, + add_history_to_context: Optional[bool] = None, + add_dependencies_to_context: Optional[bool] = None, + add_session_state_to_context: Optional[bool] = None, + dependencies: Optional[Dict[str, Any]] = None, + metadata: Optional[Dict[str, Any]] = None, + yield_run_response: bool = False, + debug_mode: Optional[bool] = None, + **kwargs: Any, + ) -> Union[RunOutput, Iterator[Union[RunOutputEvent, RunOutput]]]: + """ @staticmethod def analyze(query: str) -> str: # Mock analisi news diff --git a/src/app/agents/social_agent.py b/src/app/agents/social_agent.py index 1ec2fb5..cefa7ef 100644 --- a/src/app/agents/social_agent.py +++ b/src/app/agents/social_agent.py @@ -1,4 +1,35 @@ -class SocialAgent: +from agno.agent import Agent + + +class SocialAgent(Agent): + """ + Gli agenti devono esporre un metodo run con questa firma. + + def run( + self, + input: Union[str, List, Dict, Message, BaseModel, List[Message]], + *, + stream: Optional[bool] = None, + stream_intermediate_steps: Optional[bool] = None, + user_id: Optional[str] = None, + session_id: Optional[str] = None, + session_state: Optional[Dict[str, Any]] = None, + audio: Optional[Sequence[Any]] = None, + images: Optional[Sequence[Any]] = None, + videos: Optional[Sequence[Any]] = None, + files: Optional[Sequence[Any]] = None, + retries: Optional[int] = None, + knowledge_filters: Optional[Dict[str, Any]] = None, + add_history_to_context: Optional[bool] = None, + add_dependencies_to_context: Optional[bool] = None, + add_session_state_to_context: Optional[bool] = None, + dependencies: Optional[Dict[str, Any]] = None, + metadata: Optional[Dict[str, Any]] = None, + yield_run_response: bool = False, + debug_mode: Optional[bool] = None, + **kwargs: Any, + ) -> Union[RunOutput, Iterator[Union[RunOutputEvent, RunOutput]]]: + """ @staticmethod def analyze(query: str) -> str: # Mock analisi social diff --git a/src/app/markets/__init__.py b/src/app/markets/__init__.py index cb27b23..b965637 100644 --- a/src/app/markets/__init__.py +++ b/src/app/markets/__init__.py @@ -1,8 +1,9 @@ +from agno.utils.log import log_warning + from src.app.markets.base import BaseWrapper from src.app.markets.coinbase import CoinBaseWrapper from src.app.markets.cryptocompare import CryptoCompareWrapper -from agno.utils.log import log_warning class MarketAPIs(BaseWrapper): """ diff --git a/src/app/markets/coinbase.py b/src/app/markets/coinbase.py index 2e0e779..9d2e4be 100644 --- a/src/app/markets/coinbase.py +++ b/src/app/markets/coinbase.py @@ -1,7 +1,10 @@ import os + from coinbase.rest import RESTClient + from src.app.markets.base import ProductInfo, BaseWrapper, Price + class CoinBaseWrapper(BaseWrapper): """ Wrapper per le API di Coinbase. diff --git a/src/app/markets/cryptocompare.py b/src/app/markets/cryptocompare.py index a6e50a0..5ba44b4 100644 --- a/src/app/markets/cryptocompare.py +++ b/src/app/markets/cryptocompare.py @@ -1,5 +1,7 @@ import os + import requests + from src.app.markets.base import ProductInfo, BaseWrapper, Price BASE_URL = "https://min-api.cryptocompare.com" diff --git a/src/app/models.py b/src/app/models.py index 94f7da2..f7ec9b8 100644 --- a/src/app/models.py +++ b/src/app/models.py @@ -1,13 +1,14 @@ import os -import requests from enum import Enum -from pydantic import BaseModel + +import requests from agno.agent import Agent from agno.models.base import Model from agno.models.google import Gemini from agno.models.ollama import Ollama - from agno.utils.log import log_warning +from pydantic import BaseModel + class AppModels(Enum): """ diff --git a/src/app/pipeline.py b/src/app/pipeline.py new file mode 100644 index 0000000..9e0e2bd --- /dev/null +++ b/src/app/pipeline.py @@ -0,0 +1,84 @@ +from typing import List + +from agno.team import Team +from agno.utils.log import log_info + +from app.agents.market_agent import MarketAgent +from src.app.agents.news_agent import NewsAgent +from src.app.agents.social_agent import SocialAgent +from src.app.markets import MarketAPIs +from src.app.models import AppModels +from src.app.predictor import PredictorStyle, PredictorInput, PredictorOutput, PREDICTOR_INSTRUCTIONS + + +class Pipeline: + """ + Pipeline coordinata: esegue tutti gli agenti del Team, aggrega i risultati e invoca il Predictor. + """ + + def __init__(self): + # Inizializza gli agenti + self.market_agent = MarketAgent() + self.news_agent = NewsAgent() + self.social_agent = SocialAgent() + + # Crea il Team + self.team = Team(name="CryptoAnalysisTeam", members=[self.market_agent, self.news_agent, self.social_agent]) + + # Modelli disponibili e Predictor + self.available_models = AppModels.availables() + self.predictor_model = self.available_models[0] + self.predictor = self.predictor_model.get_agent(PREDICTOR_INSTRUCTIONS, output=PredictorOutput) # type: ignore[arg-type] + + # Stili + self.styles = list(PredictorStyle) + self.style = self.styles[0] + + def choose_provider(self, index: int): + self.predictor_model = self.available_models[index] + self.predictor = self.predictor_model.get_agent(PREDICTOR_INSTRUCTIONS, output=PredictorOutput) # type: ignore[arg-type] + + def choose_style(self, index: int): + self.style = self.styles[index] + + def interact(self, query: str) -> str: + """ + Esegue il Team (Market + News + Social), aggrega i risultati e invoca il Predictor. + """ + # Step 1: raccogli output del Team + team_results = self.team.run(query) + if isinstance(team_results, dict): # alcuni Team possono restituire dict + pieces = [str(v) for v in team_results.values()] + elif isinstance(team_results, list): + pieces = [str(r) for r in team_results] + else: + pieces = [str(team_results)] + aggregated_text = "\n\n".join(pieces) + + # Step 2: prepara input per Predictor + predictor_input = PredictorInput( + data=[], # TODO: mappare meglio i dati di mercato in ProductInfo + style=self.style, + sentiment=aggregated_text + ) + + # Step 3: chiama Predictor + result = self.predictor.run(predictor_input) + prediction: PredictorOutput = result.content + + # Step 4: formatta output finale + portfolio_lines = "\n".join( + [f"{item.asset} ({item.percentage}%): {item.motivation}" for item in prediction.portfolio] + ) + output = ( + f"📊 Strategia ({self.style.value}): {prediction.strategy}\n\n" + f"💼 Portafoglio consigliato:\n{portfolio_lines}" + ) + + return output + + def list_providers(self) -> List[str]: + return [m.name for m in self.available_models] + + def list_styles(self) -> List[str]: + return [s.value for s in self.styles] diff --git a/src/app/agents/predictor.py b/src/app/predictor.py similarity index 99% rename from src/app/agents/predictor.py rename to src/app/predictor.py index 09d7a26..101b4ac 100644 --- a/src/app/agents/predictor.py +++ b/src/app/predictor.py @@ -1,7 +1,10 @@ from enum import Enum -from src.app.markets.base import ProductInfo + from pydantic import BaseModel, Field +from src.app.markets.base import ProductInfo + + class PredictorStyle(Enum): CONSERVATIVE = "Conservativo" AGGRESSIVE = "Aggressivo" diff --git a/src/app/tool.py b/src/app/tool.py deleted file mode 100644 index f1ca560..0000000 --- a/src/app/tool.py +++ /dev/null @@ -1,97 +0,0 @@ -from src.app.agents.news_agent import NewsAgent -from src.app.agents.social_agent import SocialAgent -from src.app.agents.predictor import PredictorStyle, PredictorInput, PredictorOutput, PREDICTOR_INSTRUCTIONS -from src.app.markets import MarketAPIs -from src.app.models import AppModels -from agno.utils.log import log_info - -class ToolAgent: - """ - Classe principale che coordina gli agenti per rispondere alle richieste dell'utente. - """ - - def __init__(self): - """ - Inizializza l'agente con i modelli disponibili, gli stili e l'API di mercato. - """ - self.social_agent = None - self.news_agent = None - self.predictor = None - self.chosen_model = None - self.available_models = AppModels.availables() - self.all_styles = list(PredictorStyle) - self.style = self.all_styles[0] # Default to the first style - - self.market = MarketAPIs(currency="USD") - self.choose_provider(0) # Default to the first model - - def choose_provider(self, index: int): - """ - Sceglie il modello LLM da utilizzare in base all'indice fornito. - - Args: - index: indice del modello nella lista available_models. - """ - # TODO Utilizzare AGNO per gestire i modelli... è molto più semplice e permette di cambiare modello facilmente - # TODO https://docs.agno.com/introduction - # Inoltre permette di creare dei team e workflow di agenti più facilmente - self.chosen_model = self.available_models[index] - self.predictor = self.chosen_model.get_agent(PREDICTOR_INSTRUCTIONS, output=PredictorOutput) - self.news_agent = NewsAgent() - self.social_agent = SocialAgent() - - def choose_style(self, index: int): - """ - Sceglie lo stile di previsione da utilizzare in base all'indice fornito. - - Args: - index: indice dello stile nella lista all_styles. - """ - self.style = self.all_styles[index] - - def interact(self, query: str) -> str: - """ - Funzione principale che coordina gli agenti per rispondere alla richiesta dell'utente. - - Args: - query: richiesta dell'utente (es. "Qual è la previsione per Bitcoin?") - """ - - log_info(f"[model={self.chosen_model.name}] [style={self.style.name}] [query=\"{query.replace('"', "'")}\"]") - # TODO Step 0: ricerca e analisi della richiesta (es. estrazione di criptovalute specifiche) - # Prendere la query dell'utente e fare un'analisi preliminare con una agente o con un team di agenti (social e news) - - # Step 1: raccolta analisi - cryptos = ["BTC", "ETH", "XRP", "LTC", "BCH"] # TODO rendere dinamico in futuro - market_data = self.market.get_products(cryptos) - news_sentiment = self.news_agent.analyze(query) - social_sentiment = self.social_agent.analyze(query) - log_info(f"End of data collection") - - # Step 2: aggrega sentiment - sentiment = f"{news_sentiment}\n{social_sentiment}" - - # Step 3: previsione - inputs = PredictorInput(data=market_data, style=self.style, sentiment=sentiment) - result = self.predictor.run(inputs) - prediction: PredictorOutput = result.content - log_info(f"End of prediction") - - market_data = "\n".join([f"{product.symbol}: {product.price}" for product in market_data]) - output = f"[{prediction.strategy}]\nPortafoglio:\n" + "\n".join( - [f"{item.asset} ({item.percentage}%): {item.motivation}" for item in prediction.portfolio] - ) - - return f"INPUT:\n{market_data}\n{sentiment}\n\n\nOUTPUT:\n{output}" - - def list_providers(self) -> list[str]: - """ - Restituisce la lista dei nomi dei modelli disponibili. - """ - return [model.name for model in self.available_models] - - def list_styles(self) -> list[str]: - """ - Restituisce la lista degli stili di previsione disponibili. - """ - return [style.value for style in self.all_styles] diff --git a/src/app/toolkits/__init__.py b/src/app/toolkits/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/src/app/agents/market.py b/src/app/toolkits/market_toolkit.py similarity index 99% rename from src/app/agents/market.py rename to src/app/toolkits/market_toolkit.py index a561cb6..f0aef93 100644 --- a/src/app/agents/market.py +++ b/src/app/toolkits/market_toolkit.py @@ -1,6 +1,8 @@ from agno.tools import Toolkit + from src.app.markets import MarketAPIs + # TODO (?) in futuro fare in modo che la LLM faccia da sé per il mercato # Non so se può essere utile, per ora lo lascio qui # per ora mettiamo tutto statico e poi, se abbiamo API-Key senza limiti diff --git a/tests/agents/test_market.py b/tests/agents/test_market.py index cb6c390..ef0b8f7 100644 --- a/tests/agents/test_market.py +++ b/tests/agents/test_market.py @@ -1,6 +1,6 @@ import os import pytest -from src.app.agents.market import MarketToolkit +from src.app.agents.market_toolkit import MarketToolkit from src.app.markets.base import BaseWrapper from src.app.markets.coinbase import CoinBaseWrapper from src.app.markets.cryptocompare import CryptoCompareWrapper