From 517842c834cc288d9d5d31f88e79725c34e7bb67 Mon Sep 17 00:00:00 2001 From: Giacomo Bertolazzi <31776951+Berack96@users.noreply.github.com> Date: Wed, 8 Oct 2025 16:21:09 +0200 Subject: [PATCH 1/7] Refactor and update structure (#20) * Aggiorna gli agenti e il modello del team per utilizzare OLLAMA_QWEN_1B * Riorganizza e rinomina funzioni di estrazione in moduli di mercato e notizie; migliora la gestione delle importazioni * Spostato main nel corretto file __main__ e aggiornato il README.md * Aggiunta cartella per i modelli, agenti e team * Aggiornata la posizione delle istruzioni * Rimossi TODO e Aggiunto documentazione per metodi aggregated * Aggiornate le istruzioni del coordinatore del team * utils type checks * Rinominato BaseWrapper in MarketWrapper e fix type check markets * fix type checks di notizie e social. * Aggiunti type hints finali * Riorganizzati gli import * Refactoring architetturale e spostamento classi base - Eliminazione del file __init__.py obsoleto che importava ChatManager e Pipeline - Spostamento della classe Pipeline in agents/pipeline.py - Spostamento della classe ChatManager in utils/chat_manager.py - Aggiornamento di __main__.py per importare da app.utils e app.agents, e modifica della logica per utilizzare Pipeline invece di chat per la selezione di provider e stile - Creazione della cartella base con classi base comuni: markets.py (ProductInfo, Price, MarketWrapper), news.py (Article, NewsWrapper), social.py (SocialPost, SocialComment, SocialWrapper) - Aggiornamento di tutti gli import nel progetto (markets/, news/, social/, utils/, tests/) per utilizzare la nuova struttura base/ * Aggiornato Readme * Corretto il valore predefinito della valuta in BinanceWrapper da "USDT" a "USD" * fix type in tests * fix type per models * Rinominato 'quote_currency' in 'currency' e aggiornato il trattamento del timestamp in Price * fix errors found by Copilot * WrapperHandler: semplificata la logica di chiamata delle funzioni sui wrapper * fix docs * fix demos, semplificata logica lista ollama --- Dockerfile | 33 +++--- README.md | 43 ++++--- demos/example.py | 2 +- demos/market_providers_api_demo.py | 112 +++++++++---------- demos/news_api.py | 2 + src/{app.py => app/__main__.py} | 42 ++++--- src/app/agents/__init__.py | 6 + src/app/{ => agents}/models.py | 28 ++--- src/app/{ => agents}/pipeline.py | 93 +++++---------- src/app/{ => agents}/predictor.py | 3 +- src/app/agents/team.py | 109 ++++++++++++++++++ src/app/{ => base}/__init__.py | 0 src/app/{markets/base.py => base/markets.py} | 98 +++++++++------- src/app/{news/base.py => base/news.py} | 1 + src/app/{social/base.py => base/social.py} | 0 src/app/markets/__init__.py | 52 +++------ src/app/markets/binance.py | 58 +++++----- src/app/markets/coinbase.py | 32 +++--- src/app/markets/cryptocompare.py | 24 ++-- src/app/markets/yfinance.py | 24 ++-- src/app/news/__init__.py | 48 +++----- src/app/news/cryptopanic_api.py | 12 +- src/app/news/duckduckgo.py | 10 +- src/app/news/googlenews.py | 20 ++-- src/app/news/news_api.py | 23 ++-- src/app/social/__init__.py | 46 ++++---- src/app/social/reddit.py | 21 ++-- src/app/utils/__init__.py | 5 + src/app/{ => utils}/chat_manager.py | 42 ++----- src/app/utils/market_aggregation.py | 48 ++------ src/app/utils/wrapper_handler.py | 108 +++++++++--------- tests/agents/test_predictor.py | 30 +++-- tests/api/test_binance.py | 4 +- tests/api/test_coinbase.py | 4 +- tests/api/test_cryptocompare.py | 4 +- tests/api/test_reddit.py | 3 +- tests/api/test_yfinance.py | 4 +- tests/conftest.py | 2 +- tests/tools/test_market_tool.py | 12 +- tests/tools/test_news_tool.py | 4 +- tests/utils/test_market_aggregator.py | 66 ++++++----- tests/utils/test_wrapper_handler.py | 62 +++++----- 42 files changed, 696 insertions(+), 644 deletions(-) rename src/{app.py => app/__main__.py} (67%) create mode 100644 src/app/agents/__init__.py rename src/app/{ => agents}/models.py (82%) rename src/app/{ => agents}/pipeline.py (50%) rename src/app/{ => agents}/predictor.py (98%) create mode 100644 src/app/agents/team.py rename src/app/{ => base}/__init__.py (100%) rename src/app/{markets/base.py => base/markets.py} (61%) rename src/app/{news/base.py => base/news.py} (99%) rename src/app/{social/base.py => base/social.py} (100%) create mode 100644 src/app/utils/__init__.py rename src/app/{ => utils}/chat_manager.py (54%) diff --git a/Dockerfile b/Dockerfile index 16868ac..f4d7e97 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,23 +1,26 @@ -# Vogliamo usare una versione di linux leggera con già uv installato -# Infatti scegliamo l'immagine ufficiale di uv che ha già tutto configurato -FROM ghcr.io/astral-sh/uv:python3.12-alpine +# Utilizziamo Debian slim invece di Alpine per migliore compatibilità +FROM debian:bookworm-slim +RUN apt-get update && apt-get install -y curl && rm -rf /var/lib/apt/lists/* -# Dopo aver definito la workdir mi trovo già in essa -WORKDIR /app +# Installiamo uv +RUN curl -LsSf https://astral.sh/uv/install.sh | sh +ENV PATH="/root/.local/bin:$PATH" -# Settiamo variabili d'ambiente per usare python del sistema invece che venv -ENV UV_PROJECT_ENVIRONMENT=/usr/local +# Configuriamo UV per usare copy mode ed evitare problemi di linking ENV UV_LINK_MODE=copy -# Copiamo prima i file di configurazione delle dipendenze e installiamo le dipendenze +# Impostiamo la directory di lavoro +WORKDIR /app + +# Copiamo i file del progetto COPY pyproject.toml ./ COPY uv.lock ./ -RUN uv sync --frozen --no-cache +COPY LICENSE ./ +COPY src/ ./src/ -# Copiamo i file sorgente dopo aver installato le dipendenze per sfruttare la cache di Docker -COPY LICENSE . -COPY src ./src +# Creiamo l'ambiente virtuale con tutto già presente +RUN uv sync +ENV PYTHONPATH="/app/src" -# Comando di default all'avvio dell'applicazione -CMD ["echo", "Benvenuto in UPO AppAI!"] -CMD ["uv", "run", "src/app.py"] +# Comando di avvio dell'applicazione +CMD ["uv", "run", "src/app"] diff --git a/README.md b/README.md index a545c92..aae9a60 100644 --- a/README.md +++ b/README.md @@ -9,13 +9,13 @@ L'obiettivo è quello di creare un sistema di consulenza finanziaria basato su L # **Indice** - [Installazione](#installazione) - - [1. Variabili d'Ambiente](#1-variabili-dambiente) - - [2. Ollama](#2-ollama) - - [3. Docker](#3-docker) - - [4. UV (solo per sviluppo locale)](#4-uv-solo-per-sviluppo-locale) + - [1. Variabili d'Ambiente](#1-variabili-dambiente) + - [2. Ollama](#2-ollama) + - [3. Docker](#3-docker) + - [4. UV (solo per sviluppo locale)](#4-uv-solo-per-sviluppo-locale) - [Applicazione](#applicazione) - - [Ultimo Aggiornamento](#ultimo-aggiornamento) - - [Tests](#tests) + - [Struttura del codice del Progetto](#struttura-del-codice-del-progetto) + - [Tests](#tests) # **Installazione** @@ -31,9 +31,10 @@ L'installazione di questo progetto richiede 3 passaggi totali (+1 se si vuole sv ### **1. Variabili d'Ambiente** -Copia il file `.env.example` in `.env` e modificalo con le tue API keys: +Copia il file `.env.example` in `.env` e successivamente modificalo con le tue API keys: ```sh cp .env.example .env +nano .env # esempio di modifica del file ``` Le API Keys devono essere inserite nelle variabili opportune dopo l'uguale e ***senza*** spazi. Esse si possono ottenere tramite i loro providers (alcune sono gratuite, altre a pagamento).\ @@ -58,11 +59,6 @@ I modelli usati dall'applicazione sono visibili in [src/app/models.py](src/app/m Se si vuole solamente avviare il progetto, si consiglia di utilizzare [Docker](https://www.docker.com), dato che sono stati creati i files [Dockerfile](Dockerfile) e [docker-compose.yaml](docker-compose.yaml) per creare il container con tutti i file necessari e già in esecuzione. ```sh -# Configura le variabili d'ambiente -cp .env.example .env -nano .env # Modifica il file - -# Avvia il container docker compose up --build -d ``` @@ -80,16 +76,17 @@ powershell -ExecutionPolicy ByPass -c "irm https://astral.sh/uv/install.ps1 | ie curl -LsSf https://astral.sh/uv/install.sh | sh ``` -UV installerà python e creerà automaticamente l'ambiente virtuale con le dipendenze corrette (nota che questo passaggio è opzionale, dato che uv, ogni volta che si esegue un comando, controlla se l'ambiente è attivo e se le dipendenze sono installate): +Dopodiché bisogna creare un ambiente virtuale per lo sviluppo locale e impostare PYTHONPATH. Questo passaggio è necessario per far sì che Python riesca a trovare tutti i moduli del progetto e ad installare tutte le dipendenze. Fortunatamente uv semplifica molto questo processo: ```sh -uv sync --frozen --no-cache +uv venv +uv pip install -e . ``` -A questo punto si può far partire il progetto tramite il comando: +A questo punto si può già modificare il codice e, quando necessario, far partire il progetto tramite il comando: ```sh -uv run python src/app.py +uv run python src/app ``` # **Applicazione** @@ -102,6 +99,20 @@ Usando la libreria ``gradio`` è stata creata un'interfaccia web semplice per in - **Social Agent**: Analizza i sentimenti sui social media riguardo alle criptovalute. - **Predictor Agent**: Utilizza i dati raccolti dagli altri agenti per fare previsioni. +## Struttura del codice del Progetto + +``` +src +└── app + ├── __main__.py + ├── agents <-- Agenti, modelli, prompts e simili + ├── base <-- Classi base per le API + ├── markets <-- Market data provider (Es. Binance) + ├── news <-- News data provider (Es. NewsAPI) + ├── social <-- Social data provider (Es. Reddit) + └── utils <-- Codice di utilità generale +``` + ## Tests Per eseguire i test, assicurati di aver configurato correttamente le variabili d'ambiente nel file `.env` come descritto sopra. Poi esegui il comando: diff --git a/demos/example.py b/demos/example.py index c1fa08c..35acf59 100644 --- a/demos/example.py +++ b/demos/example.py @@ -14,7 +14,7 @@ try: instructions="Use tables to display data.", markdown=True, ) - result = reasoning_agent.run("Scrivi una poesia su un gatto. Sii breve.") + result = reasoning_agent.run("Scrivi una poesia su un gatto. Sii breve.") # type: ignore print(result.content) except Exception as e: print(f"Si è verificato un errore: {e}") diff --git a/demos/market_providers_api_demo.py b/demos/market_providers_api_demo.py index fc05c26..caba571 100644 --- a/demos/market_providers_api_demo.py +++ b/demos/market_providers_api_demo.py @@ -32,7 +32,7 @@ from app.markets import ( CryptoCompareWrapper, BinanceWrapper, YFinanceWrapper, - BaseWrapper + MarketWrapper ) # Carica variabili d'ambiente @@ -40,21 +40,21 @@ load_dotenv() class DemoFormatter: """Classe per formattare l'output del demo in modo strutturato.""" - + @staticmethod def print_header(title: str, char: str = "=", width: int = 80): """Stampa un'intestazione formattata.""" print(f"\n{char * width}") print(f"{title:^{width}}") print(f"{char * width}") - + @staticmethod def print_subheader(title: str, char: str = "-", width: int = 60): """Stampa una sotto-intestazione formattata.""" print(f"\n{char * width}") print(f" {title}") print(f"{char * width}") - + @staticmethod def print_request_info(provider_name: str, method: str, timestamp: datetime, status: str, error: Optional[str] = None): @@ -66,83 +66,83 @@ class DemoFormatter: if error: print(f"❌ Error: {error}") print() - + @staticmethod def print_product_table(products: List[Any], title: str = "Products"): """Stampa una tabella di prodotti.""" if not products: print(f"📋 {title}: Nessun prodotto trovato") return - + print(f"📋 {title} ({len(products)} items):") print(f"{'Symbol':<15} {'ID':<20} {'Price':<12} {'Quote':<10} {'Status':<10}") print("-" * 67) - + for product in products[:10]: # Mostra solo i primi 10 symbol = getattr(product, 'symbol', 'N/A') product_id = getattr(product, 'id', 'N/A') price = getattr(product, 'price', 0.0) quote = getattr(product, 'quote_currency', 'N/A') status = getattr(product, 'status', 'N/A') - + # Tronca l'ID se troppo lungo if len(product_id) > 18: product_id = product_id[:15] + "..." - + price_str = f"${price:.2f}" if price > 0 else "N/A" - + print(f"{symbol:<15} {product_id:<20} {price_str:<12} {quote:<10} {status:<10}") - + if len(products) > 10: print(f"... e altri {len(products) - 10} prodotti") print() - + @staticmethod def print_prices_table(prices: List[Any], title: str = "Historical Prices"): """Stampa una tabella di prezzi storici.""" if not prices: print(f"💰 {title}: Nessun prezzo trovato") return - + print(f"💰 {title} ({len(prices)} entries):") print(f"{'Time':<12} {'Open':<12} {'High':<12} {'Low':<12} {'Close':<12} {'Volume':<15}") print("-" * 75) - + for price in prices[:5]: # Mostra solo i primi 5 time_str = getattr(price, 'time', 'N/A') # Il time è già una stringa, non serve strftime if len(time_str) > 10: time_str = time_str[:10] # Tronca se troppo lungo - + open_price = f"${getattr(price, 'open', 0):.2f}" high_price = f"${getattr(price, 'high', 0):.2f}" low_price = f"${getattr(price, 'low', 0):.2f}" close_price = f"${getattr(price, 'close', 0):.2f}" volume = f"{getattr(price, 'volume', 0):,.0f}" - + print(f"{time_str:<12} {open_price:<12} {high_price:<12} {low_price:<12} {close_price:<12} {volume:<15}") - + if len(prices) > 5: print(f"... e altri {len(prices) - 5} prezzi") print() class ProviderTester: """Classe per testare i provider di market data.""" - + def __init__(self): self.formatter = DemoFormatter() self.test_symbols = ["BTC", "ETH", "ADA"] - - def test_provider(self, wrapper: BaseWrapper, provider_name: str) -> Dict[str, Any]: + + def test_provider(self, wrapper: MarketWrapper, provider_name: str) -> Dict[str, Any]: """Testa un provider specifico con tutti i metodi disponibili.""" - results = { + results: Dict[str, Any] = { "provider_name": provider_name, "tests": {}, "overall_status": "SUCCESS" } - + self.formatter.print_subheader(f"🔍 Testing {provider_name}") - + # Test get_product for symbol in self.test_symbols: timestamp = datetime.now() @@ -153,13 +153,13 @@ class ProviderTester: ) if product: print(f"📦 Product: {product.symbol} (ID: {product.id})") - print(f" Price: ${product.price:.2f}, Quote: {product.quote_currency}") + print(f" Price: ${product.price:.2f}, Quote: {product.currency}") print(f" Volume 24h: {product.volume_24h:,.2f}") else: print(f"📦 Product: Nessun prodotto trovato per {symbol}") - + results["tests"][f"get_product_{symbol}"] = "SUCCESS" - + except Exception as e: error_msg = str(e) self.formatter.print_request_info( @@ -167,7 +167,7 @@ class ProviderTester: ) results["tests"][f"get_product_{symbol}"] = f"ERROR: {error_msg}" results["overall_status"] = "PARTIAL" - + # Test get_products timestamp = datetime.now() try: @@ -177,7 +177,7 @@ class ProviderTester: ) self.formatter.print_product_table(products, f"{provider_name} Products") results["tests"]["get_products"] = "SUCCESS" - + except Exception as e: error_msg = str(e) self.formatter.print_request_info( @@ -185,7 +185,7 @@ class ProviderTester: ) results["tests"]["get_products"] = f"ERROR: {error_msg}" results["overall_status"] = "PARTIAL" - + # Test get_historical_prices timestamp = datetime.now() try: @@ -195,7 +195,7 @@ class ProviderTester: ) self.formatter.print_prices_table(prices, f"{provider_name} BTC Historical Prices") results["tests"]["get_historical_prices"] = "SUCCESS" - + except Exception as e: error_msg = str(e) self.formatter.print_request_info( @@ -203,7 +203,7 @@ class ProviderTester: ) results["tests"]["get_historical_prices"] = f"ERROR: {error_msg}" results["overall_status"] = "PARTIAL" - + return results def check_environment_variables() -> Dict[str, bool]: @@ -217,11 +217,11 @@ def check_environment_variables() -> Dict[str, bool]: } return env_vars -def initialize_providers() -> Dict[str, BaseWrapper]: +def initialize_providers() -> Dict[str, MarketWrapper]: """Inizializza tutti i provider disponibili.""" - providers = {} + providers: Dict[str, MarketWrapper] = {} env_vars = check_environment_variables() - + # CryptoCompareWrapper if env_vars["CRYPTOCOMPARE_API_KEY"]: try: @@ -231,7 +231,7 @@ def initialize_providers() -> Dict[str, BaseWrapper]: print(f"❌ Errore nell'inizializzazione di CryptoCompareWrapper: {e}") else: print("⚠️ CryptoCompareWrapper saltato: CRYPTOCOMPARE_API_KEY non trovata") - + # CoinBaseWrapper if env_vars["COINBASE_API_KEY"] and env_vars["COINBASE_API_SECRET"]: try: @@ -241,14 +241,14 @@ def initialize_providers() -> Dict[str, BaseWrapper]: print(f"❌ Errore nell'inizializzazione di CoinBaseWrapper: {e}") else: print("⚠️ CoinBaseWrapper saltato: credenziali Coinbase non complete") - + # BinanceWrapper try: providers["Binance"] = BinanceWrapper() print("✅ BinanceWrapper inizializzato con successo") except Exception as e: print(f"❌ Errore nell'inizializzazione di BinanceWrapper: {e}") - + # YFinanceWrapper (sempre disponibile - dati azionari e crypto gratuiti) try: providers["YFinance"] = YFinanceWrapper() @@ -261,22 +261,22 @@ def print_summary(results: List[Dict[str, Any]]): """Stampa un riassunto finale dei risultati.""" formatter = DemoFormatter() formatter.print_header("📊 RIASSUNTO FINALE", "=", 80) - + total_providers = len(results) successful_providers = sum(1 for r in results if r["overall_status"] == "SUCCESS") partial_providers = sum(1 for r in results if r["overall_status"] == "PARTIAL") - + print(f"🔢 Provider testati: {total_providers}") print(f"✅ Provider completamente funzionanti: {successful_providers}") print(f"⚠️ Provider parzialmente funzionanti: {partial_providers}") print(f"❌ Provider non funzionanti: {total_providers - successful_providers - partial_providers}") - + print("\n📋 Dettaglio per provider:") for result in results: provider_name = result["provider_name"] status = result["overall_status"] status_icon = "✅" if status == "SUCCESS" else "⚠️" if status == "PARTIAL" else "❌" - + print(f"\n{status_icon} {provider_name}:") for test_name, test_result in result["tests"].items(): test_icon = "✅" if test_result == "SUCCESS" else "❌" @@ -285,39 +285,39 @@ def print_summary(results: List[Dict[str, Any]]): def main(): """Funzione principale del demo.""" formatter = DemoFormatter() - + # Intestazione principale formatter.print_header("🚀 DEMO COMPLETO MARKET DATA PROVIDERS", "=", 80) - + print(f"🕒 Avvio demo: {datetime.now().strftime('%Y-%m-%d %H:%M:%S')}") print("📝 Questo demo testa tutti i wrapper BaseWrapper disponibili") print("🔍 Ogni test include timestamp, stato della richiesta e dati formattati") - + # Verifica variabili d'ambiente formatter.print_subheader("🔐 Verifica Configurazione") env_vars = check_environment_variables() - + print("Variabili d'ambiente:") for var_name, is_present in env_vars.items(): status = "✅ Presente" if is_present else "❌ Mancante" print(f" {var_name}: {status}") - + # Inizializza provider formatter.print_subheader("🏗️ Inizializzazione Provider") providers = initialize_providers() - + if not providers: print("❌ Nessun provider disponibile. Verifica la configurazione.") return - + print(f"\n🎯 Provider disponibili per il test: {list(providers.keys())}") - + # Testa ogni provider formatter.print_header("🧪 ESECUZIONE TEST PROVIDER", "=", 80) - + tester = ProviderTester() - all_results = [] - + all_results: List[Dict[str, Any]] = [] + for provider_name, wrapper in providers.items(): try: result = tester.test_provider(wrapper, provider_name) @@ -331,22 +331,22 @@ def main(): "overall_status": "CRITICAL_ERROR", "error": str(e) }) - + # Stampa riassunto finale print_summary(all_results) - + # Informazioni aggiuntive formatter.print_header("ℹ️ INFORMAZIONI AGGIUNTIVE", "=", 80) print("📚 Documentazione:") print(" - BaseWrapper: src/app/markets/base.py") print(" - Test completi: tests/agents/test_market.py") print(" - Configurazione: .env") - + print("\n🔧 Per abilitare tutti i provider:") print(" 1. Configura le credenziali nel file .env") print(" 2. Segui la documentazione di ogni provider") print(" 3. Riavvia il demo") - + print(f"\n🏁 Demo completato: {datetime.now().strftime('%Y-%m-%d %H:%M:%S')}") if __name__ == "__main__": diff --git a/demos/news_api.py b/demos/news_api.py index 26dab24..1497a15 100644 --- a/demos/news_api.py +++ b/demos/news_api.py @@ -9,6 +9,8 @@ from app.news import NewsApiWrapper def main(): api = NewsApiWrapper() + articles = api.get_latest_news(query="bitcoin", limit=5) + assert len(articles) > 0 print("ok") if __name__ == "__main__": diff --git a/src/app.py b/src/app/__main__.py similarity index 67% rename from src/app.py rename to src/app/__main__.py index 65c22cc..578ef35 100644 --- a/src/app.py +++ b/src/app/__main__.py @@ -1,40 +1,39 @@ import gradio as gr -from agno.utils.log import log_info from dotenv import load_dotenv +from agno.utils.log import log_info #type: ignore +from app.utils import ChatManager +from app.agents import Pipeline -from app.chat_manager import ChatManager -######################################## -# MAIN APP & GRADIO CHAT INTERFACE -######################################## if __name__ == "__main__": - # Carica variabili d’ambiente (.env) + # Inizializzazioni load_dotenv() - - # Inizializza ChatManager + pipeline = Pipeline() chat = ChatManager() ######################################## # Funzioni Gradio ######################################## - def respond(message, history): - response = chat.send_message(message) + def respond(message: str, history: list[dict[str, str]]) -> tuple[list[dict[str, str]], list[dict[str, str]], str]: + chat.send_message(message) + response = pipeline.interact(message) + chat.receive_message(response) history.append({"role": "user", "content": message}) history.append({"role": "assistant", "content": response}) return history, history, "" - def save_current_chat(): + def save_current_chat() -> str: chat.save_chat("chat.json") return "💾 Chat salvata in chat.json" - def load_previous_chat(): + def load_previous_chat() -> tuple[list[dict[str, str]], list[dict[str, str]]]: chat.load_chat("chat.json") - history = [] + history: list[dict[str, str]] = [] for m in chat.get_history(): history.append({"role": m["role"], "content": m["content"]}) return history, history - def reset_chat(): + def reset_chat() -> tuple[list[dict[str, str]], list[dict[str, str]]]: chat.reset_chat() return [], [] @@ -47,18 +46,18 @@ if __name__ == "__main__": # Dropdown provider e stile with gr.Row(): provider = gr.Dropdown( - choices=chat.list_providers(), + choices=pipeline.list_providers(), type="index", label="Modello da usare" ) - provider.change(fn=chat.choose_provider, inputs=provider, outputs=None) + provider.change(fn=pipeline.choose_predictor, inputs=provider, outputs=None) style = gr.Dropdown( - choices=chat.list_styles(), + choices=pipeline.list_styles(), type="index", label="Stile di investimento" ) - style.change(fn=chat.choose_style, inputs=style, outputs=None) + style.change(fn=pipeline.choose_style, inputs=style, outputs=None) chatbot = gr.Chatbot(label="Conversazione", height=500, type="messages") msg = gr.Textbox(label="Scrivi la tua richiesta", placeholder="Es: Quali sono le crypto interessanti oggi?") @@ -68,16 +67,13 @@ if __name__ == "__main__": save_btn = gr.Button("💾 Salva Chat") load_btn = gr.Button("📂 Carica Chat") - # Invio messaggio + # Eventi e interazioni msg.submit(respond, inputs=[msg, chatbot], outputs=[chatbot, chatbot, msg]) - # Reset clear_btn.click(reset_chat, inputs=None, outputs=[chatbot, chatbot]) - # Salvataggio save_btn.click(save_current_chat, inputs=None, outputs=None) - # Caricamento load_btn.click(load_previous_chat, inputs=None, outputs=[chatbot, chatbot]) - server, port = ("0.0.0.0", 8000) + server, port = ("0.0.0.0", 8000) # 0.0.0.0 per accesso esterno (Docker) server_log = "localhost" if server == "0.0.0.0" else server log_info(f"Starting UPO AppAI Chat on http://{server_log}:{port}") # noqa demo.launch(server_name=server, server_port=port, quiet=True) diff --git a/src/app/agents/__init__.py b/src/app/agents/__init__.py new file mode 100644 index 0000000..a9ec99e --- /dev/null +++ b/src/app/agents/__init__.py @@ -0,0 +1,6 @@ +from app.agents.models import AppModels +from app.agents.predictor import PredictorInput, PredictorOutput, PredictorStyle, PREDICTOR_INSTRUCTIONS +from app.agents.team import create_team_with +from app.agents.pipeline import Pipeline + +__all__ = ["AppModels", "PredictorInput", "PredictorOutput", "PredictorStyle", "PREDICTOR_INSTRUCTIONS", "create_team_with", "Pipeline"] diff --git a/src/app/models.py b/src/app/agents/models.py similarity index 82% rename from src/app/models.py rename to src/app/agents/models.py index 4cc591d..79d4a26 100644 --- a/src/app/models.py +++ b/src/app/agents/models.py @@ -1,12 +1,12 @@ import os -import requests +import ollama from enum import Enum 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 agno.tools import Toolkit +from agno.utils.log import log_warning #type: ignore from pydantic import BaseModel @@ -30,19 +30,15 @@ class AppModels(Enum): Controlla quali provider di modelli LLM locali sono disponibili. Ritorna una lista di provider disponibili. """ - ollama_host = os.getenv("OLLAMA_HOST", "http://localhost:11434") - result = requests.get(f"{ollama_host}/api/tags") - if result.status_code != 200: - log_warning(f"Ollama is not running or not reachable {result}") + try: + models_list = ollama.list() + availables = [model['model'] for model in models_list['models']] + app_models = [model for model in AppModels if model.name.startswith("OLLAMA")] + return [model for model in app_models if model.value in availables] + except Exception as e: + log_warning(f"Ollama is not running or not reachable: {e}") return [] - availables = [] - result = result.text - for model in [model for model in AppModels if model.name.startswith("OLLAMA")]: - if model.value in result: - availables.append(model) - return availables - @staticmethod def availables_online() -> list['AppModels']: """ @@ -90,13 +86,14 @@ class AppModels(Enum): raise ValueError(f"Modello non supportato: {self}") - def get_agent(self, instructions: str, name: str = "", output: BaseModel | None = None, tools: list[Toolkit] = []) -> Agent: + def get_agent(self, instructions: str, name: str = "", output_schema: type[BaseModel] | None = None, tools: list[Toolkit] | None = None) -> Agent: """ Costruisce un agente con il modello e le istruzioni specificate. Args: instructions: istruzioni da passare al modello (system prompt) name: nome dell'agente (opzionale) output: schema di output opzionale (Pydantic BaseModel) + tools: lista opzionale di strumenti (tools) da fornire all'agente Returns: Un'istanza di Agent. """ @@ -106,6 +103,5 @@ class AppModels(Enum): retries=2, tools=tools, delay_between_retries=5, # seconds - output_schema=output # se si usa uno schema di output, lo si passa qui - # TODO Eventuali altri parametri da mettere all'agente anche se si possono comunque assegnare dopo la creazione + output_schema=output_schema ) diff --git a/src/app/pipeline.py b/src/app/agents/pipeline.py similarity index 50% rename from src/app/pipeline.py rename to src/app/agents/pipeline.py index a7ae9d4..a7d1001 100644 --- a/src/app/pipeline.py +++ b/src/app/agents/pipeline.py @@ -1,11 +1,8 @@ from agno.run.agent import RunOutput -from agno.team import Team - -from app.news import NewsAPIsTool, NEWS_INSTRUCTIONS -from app.social import SocialAPIsTool, SOCIAL_INSTRUCTIONS -from app.markets import MarketAPIsTool, MARKET_INSTRUCTIONS -from app.models import AppModels -from app.predictor import PredictorStyle, PredictorInput, PredictorOutput, PREDICTOR_INSTRUCTIONS +from app.agents.models import AppModels +from app.agents.team import create_team_with +from app.agents.predictor import PREDICTOR_INSTRUCTIONS, PredictorInput, PredictorOutput, PredictorStyle +from app.base.markets import ProductInfo class Pipeline: @@ -14,56 +11,27 @@ class Pipeline: Il Team è orchestrato da qwen3:latest (Ollama), mentre il Predictor è dinamico e scelto dall'utente tramite i dropdown dell'interfaccia grafica. """ + def __init__(self): - # Inizializza gli agenti - self.market_agent = AppModels.OLLAMA_QWEN.get_agent( - instructions=MARKET_INSTRUCTIONS, - name="MarketAgent", - tools=[MarketAPIsTool()] - ) - self.news_agent = AppModels.OLLAMA_QWEN.get_agent( - instructions=NEWS_INSTRUCTIONS, - name="NewsAgent", - tools=[NewsAPIsTool()] - ) - self.social_agent = AppModels.OLLAMA_QWEN.get_agent( - instructions=SOCIAL_INSTRUCTIONS, - name="SocialAgent", - tools=[SocialAPIsTool()] - ) - - # === Modello di orchestrazione del Team === - team_model = AppModels.OLLAMA_QWEN.get_model( - # TODO: migliorare le istruzioni del team - "Agisci come coordinatore: smista le richieste tra MarketAgent, NewsAgent e SocialAgent." - ) - - # === Team === - self.team = Team( - name="CryptoAnalysisTeam", - members=[self.market_agent, self.news_agent, self.social_agent], - model=team_model - ) - - # === Predictor === self.available_models = AppModels.availables() self.all_styles = list(PredictorStyle) - # Scelte di default - self.chosen_model = self.available_models[0] if self.available_models else None - self.style = self.all_styles[0] if self.all_styles else None - - self._init_predictor() # Inizializza il predictor con il modello di default + self.style = self.all_styles[0] + self.team = create_team_with(AppModels.OLLAMA_QWEN_1B) + self.choose_predictor(0) # Modello di default # ====================== # Dropdown handlers # ====================== - def choose_provider(self, index: int): + def choose_predictor(self, index: int): """ Sceglie il modello LLM da usare per il Predictor. """ - self.chosen_model = self.available_models[index] - self._init_predictor() + model = self.available_models[index] + self.predictor = model.get_agent( + PREDICTOR_INSTRUCTIONS, + output_schema=PredictorOutput, + ) def choose_style(self, index: int): """ @@ -74,17 +42,6 @@ class Pipeline: # ====================== # Helpers # ====================== - def _init_predictor(self): - """ - Inizializza (o reinizializza) il Predictor in base al modello scelto. - """ - if not self.chosen_model: - return - self.predictor = self.chosen_model.get_agent( - PREDICTOR_INSTRUCTIONS, - output=PredictorOutput, # type: ignore - ) - def list_providers(self) -> list[str]: """ Restituisce la lista dei nomi dei modelli disponibili. @@ -107,23 +64,21 @@ class Pipeline: 3. Invoca Predictor 4. Restituisce la strategia finale """ - if not self.predictor or not self.style: - return "⚠️ Devi prima selezionare un modello e una strategia validi dagli appositi menu." - # Step 1: raccolta output dai membri del Team - team_outputs = self.team.run(query) + team_outputs = self.team.run(query) # type: ignore # Step 2: aggregazione output strutturati - all_products = [] - sentiments = [] + all_products: list[ProductInfo] = [] + sentiments: list[str] = [] for agent_output in team_outputs.member_responses: - if isinstance(agent_output, RunOutput): - if "products" in agent_output.metadata: + if isinstance(agent_output, RunOutput) and agent_output.metadata is not None: + keys = agent_output.metadata.keys() + if "products" in keys: all_products.extend(agent_output.metadata["products"]) - if "sentiment_news" in agent_output.metadata: + if "sentiment_news" in keys: sentiments.append(agent_output.metadata["sentiment_news"]) - if "sentiment_social" in agent_output.metadata: + if "sentiment_social" in keys: sentiments.append(agent_output.metadata["sentiment_social"]) aggregated_sentiment = "\n".join(sentiments) @@ -135,7 +90,9 @@ class Pipeline: sentiment=aggregated_sentiment ) - result = self.predictor.run(predictor_input) + result = self.predictor.run(predictor_input) # type: ignore + if not isinstance(result.content, PredictorOutput): + return "❌ Errore: il modello non ha restituito un output valido." prediction: PredictorOutput = result.content # Step 4: restituzione strategia finale diff --git a/src/app/predictor.py b/src/app/agents/predictor.py similarity index 98% rename from src/app/predictor.py rename to src/app/agents/predictor.py index 38780de..69a92af 100644 --- a/src/app/predictor.py +++ b/src/app/agents/predictor.py @@ -1,6 +1,6 @@ from enum import Enum from pydantic import BaseModel, Field -from app.markets.base import ProductInfo +from app.base.markets import ProductInfo class PredictorStyle(Enum): @@ -21,6 +21,7 @@ class PredictorOutput(BaseModel): strategy: str = Field(..., description="Concise operational strategy in Italian") portfolio: list[ItemPortfolio] = Field(..., description="List of portfolio items with allocations") + PREDICTOR_INSTRUCTIONS = """ You are an **Allocation Algorithm (Crypto-Algo)** specialized in analyzing market data and sentiment to generate an investment strategy and a target portfolio. diff --git a/src/app/agents/team.py b/src/app/agents/team.py new file mode 100644 index 0000000..27b9cae --- /dev/null +++ b/src/app/agents/team.py @@ -0,0 +1,109 @@ +from agno.team import Team +from app.agents import AppModels +from app.markets import MarketAPIsTool +from app.news import NewsAPIsTool +from app.social import SocialAPIsTool + + +def create_team_with(models: AppModels, coordinator: AppModels | None = None) -> Team: + market_agent = models.get_agent( + instructions=MARKET_INSTRUCTIONS, + name="MarketAgent", + tools=[MarketAPIsTool()] + ) + news_agent = models.get_agent( + instructions=NEWS_INSTRUCTIONS, + name="NewsAgent", + tools=[NewsAPIsTool()] + ) + social_agent = models.get_agent( + instructions=SOCIAL_INSTRUCTIONS, + name="SocialAgent", + tools=[SocialAPIsTool()] + ) + + coordinator = coordinator or models + return Team( + model=coordinator.get_model(COORDINATOR_INSTRUCTIONS), + name="CryptoAnalysisTeam", + members=[market_agent, news_agent, social_agent], + ) + +COORDINATOR_INSTRUCTIONS = """ +You are the expert coordinator of a financial analysis team specializing in cryptocurrencies. + +Your team consists of three agents: +- **MarketAgent**: Provides quantitative market data, price analysis, and technical indicators. +- **NewsAgent**: Scans and analyzes the latest news, articles, and official announcements. +- **SocialAgent**: Gauges public sentiment, trends, and discussions on social media. + +Your primary objective is to answer the user's query by orchestrating the work of your team members. + +Your workflow is as follows: +1. **Deconstruct the user's query** to identify the required information. +2. **Delegate specific tasks** to the most appropriate agent(s) to gather the necessary data and initial analysis. +3. **Analyze the information** returned by the agents. +4. If the initial data is insufficient or the query is complex, **iteratively re-engage the agents** with follow-up questions to build a comprehensive picture. +5. **Synthesize all the gathered information** into a final, coherent, and complete analysis that fills all the required output fields. +""" + +MARKET_INSTRUCTIONS = """ +**TASK:** You are a specialized **Crypto Price Data Retrieval Agent**. Your primary goal is to fetch the most recent and/or historical price data for requested cryptocurrency assets (e.g., 'BTC', 'ETH', 'SOL'). You must provide the data in a clear and structured format. + +**AVAILABLE TOOLS:** +1. `get_products(asset_ids: list[str])`: Get **current** product/price info for a list of assets. **(PREFERITA: usa questa per i prezzi live)** +2. `get_historical_prices(asset_id: str, limit: int)`: Get historical price data for one asset. Default limit is 100. **(PREFERITA: usa questa per i dati storici)** +3. `get_products_aggregated(asset_ids: list[str])`: Get **aggregated current** product/price info for a list of assets. **(USA SOLO SE richiesto 'aggregato' o se `get_products` fallisce)** +4. `get_historical_prices_aggregated(asset_id: str, limit: int)`: Get **aggregated historical** price data for one asset. **(USA SOLO SE richiesto 'aggregato' o se `get_historical_prices` fallisce)** + +**USAGE GUIDELINE:** +* **Asset ID:** Always convert common names (e.g., 'Bitcoin', 'Ethereum') into their official ticker/ID (e.g., 'BTC', 'ETH'). +* **Cost Management (Cruciale per LLM locale):** Prefer `get_products` and `get_historical_prices` for standard requests to minimize costs. +* **Aggregated Data:** Use `get_products_aggregated` or `get_historical_prices_aggregated` only if the user specifically requests aggregated data or you value that having aggregated data is crucial for the analysis. +* **Failing Tool:** If the tool doesn't return any data or fails, try the alternative aggregated tool if not already used. + +**REPORTING REQUIREMENT:** +1. **Format:** Output the results in a clear, easy-to-read list or table. +2. **Live Price Request:** If an asset's *current price* is requested, report the **Asset ID**, **Latest Price**, and **Time/Date of the price**. +3. **Historical Price Request:** If *historical data* is requested, report the **Asset ID**, the **Limit** of points returned, and the **First** and **Last** entries from the list of historical prices (Date, Price). +4. **Output:** For all requests, output a single, concise summary of the findings; if requested, also include the raw data retrieved. +""" + +NEWS_INSTRUCTIONS = """ +**TASK:** You are a specialized **Crypto News Analyst**. Your goal is to fetch the latest news or top headlines related to cryptocurrencies, and then **analyze the sentiment** of the content to provide a concise report to the team leader. Prioritize 'crypto' or specific cryptocurrency names (e.g., 'Bitcoin', 'Ethereum') in your searches. + +**AVAILABLE TOOLS:** +1. `get_latest_news(query: str, limit: int)`: Get the 'limit' most recent news articles for a specific 'query'. +2. `get_top_headlines(limit: int)`: Get the 'limit' top global news headlines. +3. `get_latest_news_aggregated(query: str, limit: int)`: Get aggregated latest news articles for a specific 'query'. +4. `get_top_headlines_aggregated(limit: int)`: Get aggregated top global news headlines. + +**USAGE GUIDELINE:** +* Always use `get_latest_news` with a relevant crypto-related query first. +* The default limit for news items should be 5 unless specified otherwise. +* If the tool doesn't return any articles, respond with "No relevant news articles found." + +**REPORTING REQUIREMENT:** +1. **Analyze** the tone and key themes of the retrieved articles. +2. **Summarize** the overall **market sentiment** (e.g., highly positive, cautiously neutral, generally negative) based on the content. +3. **Identify** the top 2-3 **main topics** discussed (e.g., new regulation, price surge, institutional adoption). +4. **Output** a single, brief report summarizing these findings. Do not output the raw articles. +""" + +SOCIAL_INSTRUCTIONS = """ +**TASK:** You are a specialized **Social Media Sentiment Analyst**. Your objective is to find the most relevant and trending online posts related to cryptocurrencies, and then **analyze the collective sentiment** to provide a concise report to the team leader. + +**AVAILABLE TOOLS:** +1. `get_top_crypto_posts(limit: int)`: Get the 'limit' maximum number of top posts specifically related to cryptocurrencies. + +**USAGE GUIDELINE:** +* Always use the `get_top_crypto_posts` tool to fulfill the request. +* The default limit for posts should be 5 unless specified otherwise. +* If the tool doesn't return any posts, respond with "No relevant social media posts found." + +**REPORTING REQUIREMENT:** +1. **Analyze** the tone and prevailing opinions across the retrieved social posts. +2. **Summarize** the overall **community sentiment** (e.g., high enthusiasm/FOMO, uncertainty, FUD/fear) based on the content. +3. **Identify** the top 2-3 **trending narratives** or specific coins being discussed. +4. **Output** a single, brief report summarizing these findings. Do not output the raw posts. +""" diff --git a/src/app/__init__.py b/src/app/base/__init__.py similarity index 100% rename from src/app/__init__.py rename to src/app/base/__init__.py diff --git a/src/app/markets/base.py b/src/app/base/markets.py similarity index 61% rename from src/app/markets/base.py rename to src/app/base/markets.py index 1ef247b..cd00879 100644 --- a/src/app/markets/base.py +++ b/src/app/base/markets.py @@ -1,41 +1,6 @@ +from datetime import datetime from pydantic import BaseModel -class BaseWrapper: - """ - Base class for market API wrappers. - All market API wrappers should inherit from this class and implement the methods. - """ - - def get_product(self, asset_id: str) -> 'ProductInfo': - """ - Get product information for a specific asset ID. - Args: - asset_id (str): The asset ID to retrieve information for. - Returns: - ProductInfo: An object containing product information. - """ - raise NotImplementedError("This method should be overridden by subclasses") - - def get_products(self, asset_ids: list[str]) -> list['ProductInfo']: - """ - Get product information for multiple asset IDs. - Args: - asset_ids (list[str]): The list of asset IDs to retrieve information for. - Returns: - list[ProductInfo]: A list of objects containing product information. - """ - raise NotImplementedError("This method should be overridden by subclasses") - - def get_historical_prices(self, asset_id: str = "BTC", limit: int = 100) -> list['Price']: - """ - Get historical price data for a specific asset ID. - Args: - asset_id (str): The asset ID to retrieve price data for. - limit (int): The maximum number of price data points to return. - Returns: - list[Price]: A list of Price objects. - """ - raise NotImplementedError("This method should be overridden by subclasses") class ProductInfo(BaseModel): """ @@ -46,7 +11,7 @@ class ProductInfo(BaseModel): symbol: str = "" price: float = 0.0 volume_24h: float = 0.0 - quote_currency: str = "" + currency: str = "" class Price(BaseModel): """ @@ -58,4 +23,61 @@ class Price(BaseModel): open: float = 0.0 close: float = 0.0 volume: float = 0.0 - timestamp_ms: int = 0 # Timestamp in milliseconds + timestamp: str = "" + """Timestamp con formato YYYY-MM-DD HH:MM""" + + def set_timestamp(self, timestamp_ms: int | None = None, timestamp_s: int | None = None) -> None: + """ + Imposta il timestamp a partire da millisecondi o secondi. + IL timestamp viene salvato come stringa formattata 'YYYY-MM-DD HH:MM'. + Args: + timestamp_ms: Timestamp in millisecondi. + timestamp_s: Timestamp in secondi. + Raises: + """ + if timestamp_ms is not None: + timestamp = timestamp_ms // 1000 + elif timestamp_s is not None: + timestamp = timestamp_s + else: + raise ValueError("Either timestamp_ms or timestamp_s must be provided") + assert timestamp > 0, "Invalid timestamp data received" + + self.timestamp = datetime.fromtimestamp(timestamp).strftime('%Y-%m-%d %H:%M') + +class MarketWrapper: + """ + Base class for market API wrappers. + All market API wrappers should inherit from this class and implement the methods. + """ + + def get_product(self, asset_id: str) -> ProductInfo: + """ + Get product information for a specific asset ID. + Args: + asset_id (str): The asset ID to retrieve information for. + Returns: + ProductInfo: An object containing product information. + """ + raise NotImplementedError("This method should be overridden by subclasses") + + def get_products(self, asset_ids: list[str]) -> list[ProductInfo]: + """ + Get product information for multiple asset IDs. + Args: + asset_ids (list[str]): The list of asset IDs to retrieve information for. + Returns: + list[ProductInfo]: A list of objects containing product information. + """ + raise NotImplementedError("This method should be overridden by subclasses") + + def get_historical_prices(self, asset_id: str, limit: int = 100) -> list[Price]: + """ + Get historical price data for a specific asset ID. + Args: + asset_id (str): The asset ID to retrieve price data for. + limit (int): The maximum number of price data points to return. + Returns: + list[Price]: A list of Price objects. + """ + raise NotImplementedError("This method should be overridden by subclasses") diff --git a/src/app/news/base.py b/src/app/base/news.py similarity index 99% rename from src/app/news/base.py rename to src/app/base/news.py index 55a35ee..8a0d51e 100644 --- a/src/app/news/base.py +++ b/src/app/base/news.py @@ -1,5 +1,6 @@ from pydantic import BaseModel + class Article(BaseModel): source: str = "" time: str = "" diff --git a/src/app/social/base.py b/src/app/base/social.py similarity index 100% rename from src/app/social/base.py rename to src/app/base/social.py diff --git a/src/app/markets/__init__.py b/src/app/markets/__init__.py index ef73f68..bf2d344 100644 --- a/src/app/markets/__init__.py +++ b/src/app/markets/__init__.py @@ -1,16 +1,15 @@ from agno.tools import Toolkit -from app.utils.wrapper_handler import WrapperHandler -from app.utils.market_aggregation import aggregate_product_info, aggregate_history_prices -from .base import BaseWrapper, ProductInfo, Price -from .coinbase import CoinBaseWrapper -from .binance import BinanceWrapper -from .cryptocompare import CryptoCompareWrapper -from .yfinance import YFinanceWrapper +from app.base.markets import MarketWrapper, Price, ProductInfo +from app.markets.binance import BinanceWrapper +from app.markets.coinbase import CoinBaseWrapper +from app.markets.cryptocompare import CryptoCompareWrapper +from app.markets.yfinance import YFinanceWrapper +from app.utils import aggregate_history_prices, aggregate_product_info, WrapperHandler -__all__ = [ "MarketAPIsTool", "BinanceWrapper", "CoinBaseWrapper", "CryptoCompareWrapper", "YFinanceWrapper", "MARKET_INSTRUCTIONS" ] +__all__ = [ "MarketAPIsTool", "BinanceWrapper", "CoinBaseWrapper", "CryptoCompareWrapper", "YFinanceWrapper", "ProductInfo", "Price" ] -class MarketAPIsTool(BaseWrapper, Toolkit): +class MarketAPIsTool(MarketWrapper, Toolkit): """ Class that aggregates multiple market API wrappers and manages them using WrapperHandler. This class supports retrieving product information and historical prices. @@ -34,10 +33,10 @@ class MarketAPIsTool(BaseWrapper, Toolkit): currency (str): Valuta in cui restituire i prezzi. Default è "USD". """ kwargs = {"currency": currency or "USD"} - wrappers = [ BinanceWrapper, YFinanceWrapper, CoinBaseWrapper, CryptoCompareWrapper ] - self.wrappers: WrapperHandler[BaseWrapper] = WrapperHandler.build_wrappers(wrappers, kwargs=kwargs) + wrappers: list[type[MarketWrapper]] = [BinanceWrapper, YFinanceWrapper, CoinBaseWrapper, CryptoCompareWrapper] + self.wrappers = WrapperHandler.build_wrappers(wrappers, kwargs=kwargs) - Toolkit.__init__( + Toolkit.__init__( # type: ignore self, name="Market APIs Toolkit", tools=[ @@ -53,7 +52,7 @@ class MarketAPIsTool(BaseWrapper, Toolkit): return self.wrappers.try_call(lambda w: w.get_product(asset_id)) def get_products(self, asset_ids: list[str]) -> list[ProductInfo]: return self.wrappers.try_call(lambda w: w.get_products(asset_ids)) - def get_historical_prices(self, asset_id: str = "BTC", limit: int = 100) -> list[Price]: + def get_historical_prices(self, asset_id: str, limit: int = 100) -> list[Price]: return self.wrappers.try_call(lambda w: w.get_historical_prices(asset_id, limit)) @@ -65,6 +64,8 @@ class MarketAPIsTool(BaseWrapper, Toolkit): asset_ids (list[str]): Lista di asset_id da cercare. Returns: list[ProductInfo]: Lista di ProductInfo aggregati. + Raises: + Exception: If all wrappers fail to provide results. """ all_products = self.wrappers.try_call_all(lambda w: w.get_products(asset_ids)) return aggregate_product_info(all_products) @@ -78,29 +79,8 @@ class MarketAPIsTool(BaseWrapper, Toolkit): limit (int): Numero massimo di dati storici da restituire. Returns: list[Price]: Lista di Price aggregati. + Raises: + Exception: If all wrappers fail to provide results. """ all_prices = self.wrappers.try_call_all(lambda w: w.get_historical_prices(asset_id, limit)) return aggregate_history_prices(all_prices) - -MARKET_INSTRUCTIONS = """ -**TASK:** You are a specialized **Crypto Price Data Retrieval Agent**. Your primary goal is to fetch the most recent and/or historical price data for requested cryptocurrency assets (e.g., 'BTC', 'ETH', 'SOL'). You must provide the data in a clear and structured format. - -**AVAILABLE TOOLS:** -1. `get_products(asset_ids: list[str])`: Get **current** product/price info for a list of assets. **(PREFERITA: usa questa per i prezzi live)** -2. `get_historical_prices(asset_id: str, limit: int)`: Get historical price data for one asset. Default limit is 100. **(PREFERITA: usa questa per i dati storici)** -3. `get_products_aggregated(asset_ids: list[str])`: Get **aggregated current** product/price info for a list of assets. **(USA SOLO SE richiesto 'aggregato' o se `get_products` fallisce)** -4. `get_historical_prices_aggregated(asset_id: str, limit: int)`: Get **aggregated historical** price data for one asset. **(USA SOLO SE richiesto 'aggregato' o se `get_historical_prices` fallisce)** - -**USAGE GUIDELINE:** -* **Asset ID:** Always convert common names (e.g., 'Bitcoin', 'Ethereum') into their official ticker/ID (e.g., 'BTC', 'ETH'). -* **Cost Management (Cruciale per LLM locale):** - * **Priorità Bassa per Aggregazione:** **Non** usare i metodi `*aggregated` a meno che l'utente non lo richieda esplicitamente o se i metodi non-aggregati falliscono. - * **Limitazione Storica:** Il limite predefinito per i dati storici deve essere **20** punti dati, a meno che l'utente non specifichi un limite diverso. -* **Fallimento Tool:** Se lo strumento non restituisce dati per un asset specifico, rispondi per quell'asset con: "Dati di prezzo non trovati per [Asset ID]." - -**REPORTING REQUIREMENT:** -1. **Format:** Output the results in a clear, easy-to-read list or table. -2. **Live Price Request:** If an asset's *current price* is requested, report the **Asset ID**, **Latest Price**, and **Time/Date of the price**. -3. **Historical Price Request:** If *historical data* is requested, report the **Asset ID**, the **Limit** of points returned, and the **First** and **Last** entries from the list of historical prices (Date, Price). Non stampare l'intera lista di dati storici. -4. **Output:** For all requests, fornire un **unico e conciso riepilogo** dei dati reperiti. -""" \ No newline at end of file diff --git a/src/app/markets/binance.py b/src/app/markets/binance.py index 8e941c8..ffd31bb 100644 --- a/src/app/markets/binance.py +++ b/src/app/markets/binance.py @@ -1,28 +1,31 @@ import os -from datetime import datetime -from binance.client import Client -from .base import ProductInfo, BaseWrapper, Price +from typing import Any +from binance.client import Client # type: ignore +from app.base.markets import ProductInfo, MarketWrapper, Price -def get_product(currency: str, ticker_data: dict[str, str]) -> ProductInfo: + +def extract_product(currency: str, ticker_data: dict[str, Any]) -> ProductInfo: product = ProductInfo() - product.id = ticker_data.get('symbol') + product.id = ticker_data.get('symbol', '') product.symbol = ticker_data.get('symbol', '').replace(currency, '') product.price = float(ticker_data.get('price', 0)) product.volume_24h = float(ticker_data.get('volume', 0)) - product.quote_currency = currency + product.currency = currency return product -def get_price(kline_data: list) -> Price: +def extract_price(kline_data: list[Any]) -> Price: + timestamp = kline_data[0] + price = Price() price.open = float(kline_data[1]) price.high = float(kline_data[2]) price.low = float(kline_data[3]) price.close = float(kline_data[4]) price.volume = float(kline_data[5]) - price.timestamp_ms = kline_data[0] + price.set_timestamp(timestamp_ms=timestamp) return price -class BinanceWrapper(BaseWrapper): +class BinanceWrapper(MarketWrapper): """ Wrapper per le API autenticate di Binance.\n Implementa l'interfaccia BaseWrapper per fornire accesso unificato @@ -30,11 +33,19 @@ class BinanceWrapper(BaseWrapper): https://binance-docs.github.io/apidocs/spot/en/ """ - def __init__(self, currency: str = "USDT"): + def __init__(self, currency: str = "USD"): + """ + Inizializza il wrapper di Binance con le credenziali API e la valuta di riferimento. + Se viene fornita una valuta fiat come "USD", questa viene automaticamente convertita in una stablecoin Tether ("USDT") per compatibilità con Binance, + poiché Binance non supporta direttamente le valute fiat per il trading di criptovalute. + Tutti i prezzi e volumi restituiti saranno quindi denominati nella stablecoin (ad esempio, "USDT") e non nella valuta fiat originale. + Args: + currency (str): Valuta in cui restituire i prezzi. Se "USD" viene fornito, verrà utilizzato "USDT". Default è "USD". + """ api_key = os.getenv("BINANCE_API_KEY") api_secret = os.getenv("BINANCE_API_SECRET") - self.currency = currency + self.currency = f"{currency}T" self.client = Client(api_key=api_key, api_secret=api_secret) def __format_symbol(self, asset_id: str) -> str: @@ -46,31 +57,22 @@ class BinanceWrapper(BaseWrapper): def get_product(self, asset_id: str) -> ProductInfo: symbol = self.__format_symbol(asset_id) - ticker = self.client.get_symbol_ticker(symbol=symbol) - ticker_24h = self.client.get_ticker(symbol=symbol) - ticker['volume'] = ticker_24h.get('volume', 0) # Aggiunge volume 24h ai dati del ticker + ticker: dict[str, Any] = self.client.get_symbol_ticker(symbol=symbol) # type: ignore + ticker_24h: dict[str, Any] = self.client.get_ticker(symbol=symbol) # type: ignore + ticker['volume'] = ticker_24h.get('volume', 0) - return get_product(self.currency, ticker) + return extract_product(self.currency, ticker) def get_products(self, asset_ids: list[str]) -> list[ProductInfo]: - symbols = [self.__format_symbol(asset_id) for asset_id in asset_ids] - symbols_str = f"[\"{'","'.join(symbols)}\"]" + return [ self.get_product(asset_id) for asset_id in asset_ids ] - tickers = self.client.get_symbol_ticker(symbols=symbols_str) - tickers_24h = self.client.get_ticker(symbols=symbols_str) # un po brutale, ma va bene così - for t, t24 in zip(tickers, tickers_24h): - t['volume'] = t24.get('volume', 0) - - return [get_product(self.currency, ticker) for ticker in tickers] - - def get_historical_prices(self, asset_id: str = "BTC", limit: int = 100) -> list[Price]: + def get_historical_prices(self, asset_id: str, limit: int = 100) -> list[Price]: symbol = self.__format_symbol(asset_id) # Ottiene candele orarie degli ultimi 30 giorni - klines = self.client.get_historical_klines( + klines: list[list[Any]] = self.client.get_historical_klines( # type: ignore symbol=symbol, interval=Client.KLINE_INTERVAL_1HOUR, limit=limit, ) - return [get_price(kline) for kline in klines] - + return [extract_price(kline) for kline in klines] diff --git a/src/app/markets/coinbase.py b/src/app/markets/coinbase.py index 54409c1..c59382b 100644 --- a/src/app/markets/coinbase.py +++ b/src/app/markets/coinbase.py @@ -1,12 +1,12 @@ import os from enum import Enum from datetime import datetime, timedelta -from coinbase.rest import RESTClient -from coinbase.rest.types.product_types import Candle, GetProductResponse, Product -from .base import ProductInfo, BaseWrapper, Price +from coinbase.rest import RESTClient # type: ignore +from coinbase.rest.types.product_types import Candle, GetProductResponse, Product # type: ignore +from app.base.markets import ProductInfo, MarketWrapper, Price -def get_product(product_data: GetProductResponse | Product) -> ProductInfo: +def extract_product(product_data: GetProductResponse | Product) -> ProductInfo: product = ProductInfo() product.id = product_data.product_id or "" product.symbol = product_data.base_currency_id or "" @@ -14,14 +14,16 @@ def get_product(product_data: GetProductResponse | Product) -> ProductInfo: product.volume_24h = float(product_data.volume_24h) if product_data.volume_24h else 0.0 return product -def get_price(candle_data: Candle) -> Price: +def extract_price(candle_data: Candle) -> Price: + timestamp = int(candle_data.start) if candle_data.start else 0 + price = Price() price.high = float(candle_data.high) if candle_data.high else 0.0 price.low = float(candle_data.low) if candle_data.low else 0.0 price.open = float(candle_data.open) if candle_data.open else 0.0 price.close = float(candle_data.close) if candle_data.close else 0.0 price.volume = float(candle_data.volume) if candle_data.volume else 0.0 - price.timestamp_ms = int(candle_data.start) * 1000 if candle_data.start else 0 + price.set_timestamp(timestamp_s=timestamp) return price @@ -37,7 +39,7 @@ class Granularity(Enum): SIX_HOUR = 21600 ONE_DAY = 86400 -class CoinBaseWrapper(BaseWrapper): +class CoinBaseWrapper(MarketWrapper): """ Wrapper per le API di Coinbase Advanced Trade.\n Implementa l'interfaccia BaseWrapper per fornire accesso unificato @@ -63,24 +65,26 @@ class CoinBaseWrapper(BaseWrapper): def get_product(self, asset_id: str) -> ProductInfo: asset_id = self.__format(asset_id) - asset = self.client.get_product(asset_id) - return get_product(asset) + asset = self.client.get_product(asset_id) # type: ignore + return extract_product(asset) def get_products(self, asset_ids: list[str]) -> list[ProductInfo]: all_asset_ids = [self.__format(asset_id) for asset_id in asset_ids] - assets = self.client.get_products(product_ids=all_asset_ids) - return [get_product(asset) for asset in assets.products] + assets = self.client.get_products(product_ids=all_asset_ids) # type: ignore + assert assets.products is not None, "No products data received from Coinbase" + return [extract_product(asset) for asset in assets.products] - def get_historical_prices(self, asset_id: str = "BTC", limit: int = 100) -> list[Price]: + def get_historical_prices(self, asset_id: str, limit: int = 100) -> list[Price]: asset_id = self.__format(asset_id) end_time = datetime.now() start_time = end_time - timedelta(days=14) - data = self.client.get_candles( + data = self.client.get_candles( # type: ignore product_id=asset_id, granularity=Granularity.ONE_HOUR.name, start=str(int(start_time.timestamp())), end=str(int(end_time.timestamp())), limit=limit ) - return [get_price(candle) for candle in data.candles] + assert data.candles is not None, "No candles data received from Coinbase" + return [extract_price(candle) for candle in data.candles] diff --git a/src/app/markets/cryptocompare.py b/src/app/markets/cryptocompare.py index f4b96e9..5431267 100644 --- a/src/app/markets/cryptocompare.py +++ b/src/app/markets/cryptocompare.py @@ -1,9 +1,10 @@ import os +from typing import Any import requests -from .base import ProductInfo, BaseWrapper, Price +from app.base.markets import ProductInfo, MarketWrapper, Price -def get_product(asset_data: dict) -> ProductInfo: +def extract_product(asset_data: dict[str, Any]) -> ProductInfo: product = ProductInfo() product.id = asset_data.get('FROMSYMBOL', '') + '-' + asset_data.get('TOSYMBOL', '') product.symbol = asset_data.get('FROMSYMBOL', '') @@ -12,21 +13,22 @@ def get_product(asset_data: dict) -> ProductInfo: assert product.price > 0, "Invalid price data received from CryptoCompare" return product -def get_price(price_data: dict) -> Price: +def extract_price(price_data: dict[str, Any]) -> Price: + timestamp = price_data.get('time', 0) + price = Price() price.high = float(price_data.get('high', 0)) price.low = float(price_data.get('low', 0)) price.open = float(price_data.get('open', 0)) price.close = float(price_data.get('close', 0)) price.volume = float(price_data.get('volumeto', 0)) - price.timestamp_ms = price_data.get('time', 0) * 1000 - assert price.timestamp_ms > 0, "Invalid timestamp data received from CryptoCompare" + price.set_timestamp(timestamp_s=timestamp) return price BASE_URL = "https://min-api.cryptocompare.com" -class CryptoCompareWrapper(BaseWrapper): +class CryptoCompareWrapper(MarketWrapper): """ Wrapper per le API pubbliche di CryptoCompare. La documentazione delle API è disponibile qui: https://developers.coindesk.com/documentation/legacy/Price/SingleSymbolPriceEndpoint @@ -39,7 +41,7 @@ class CryptoCompareWrapper(BaseWrapper): self.api_key = api_key self.currency = currency - def __request(self, endpoint: str, params: dict[str, str] | None = None) -> dict[str, str]: + def __request(self, endpoint: str, params: dict[str, Any] | None = None) -> dict[str, Any]: if params is None: params = {} params['api_key'] = self.api_key @@ -53,18 +55,18 @@ class CryptoCompareWrapper(BaseWrapper): "tsyms": self.currency }) data = response.get('RAW', {}).get(asset_id, {}).get(self.currency, {}) - return get_product(data) + return extract_product(data) def get_products(self, asset_ids: list[str]) -> list[ProductInfo]: response = self.__request("/data/pricemultifull", params = { "fsyms": ",".join(asset_ids), "tsyms": self.currency }) - assets = [] + assets: list[ProductInfo] = [] data = response.get('RAW', {}) for asset_id in asset_ids: asset_data = data.get(asset_id, {}).get(self.currency, {}) - assets.append(get_product(asset_data)) + assets.append(extract_product(asset_data)) return assets def get_historical_prices(self, asset_id: str, limit: int = 100) -> list[Price]: @@ -75,5 +77,5 @@ class CryptoCompareWrapper(BaseWrapper): }) data = response.get('Data', {}).get('Data', []) - prices = [get_price(price_data) for price_data in data] + prices = [extract_price(price_data) for price_data in data] return prices diff --git a/src/app/markets/yfinance.py b/src/app/markets/yfinance.py index acfacb8..2670eda 100644 --- a/src/app/markets/yfinance.py +++ b/src/app/markets/yfinance.py @@ -1,9 +1,9 @@ import json from agno.tools.yfinance import YFinanceTools -from .base import BaseWrapper, ProductInfo, Price +from app.base.markets import MarketWrapper, ProductInfo, Price -def create_product_info(stock_data: dict[str, str]) -> ProductInfo: +def extract_product(stock_data: dict[str, str]) -> ProductInfo: """ Converte i dati di YFinanceTools in ProductInfo. """ @@ -12,24 +12,26 @@ def create_product_info(stock_data: dict[str, str]) -> ProductInfo: product.symbol = product.id.split('-')[0] # Rimuovi il suffisso della valuta per le crypto product.price = float(stock_data.get('Current Stock Price', f"0.0 USD").split(" ")[0]) # prende solo il numero product.volume_24h = 0.0 # YFinance non fornisce il volume 24h direttamente - product.quote_currency = product.id.split('-')[1] # La valuta è la parte dopo il '-' + product.currency = product.id.split('-')[1] # La valuta è la parte dopo il '-' return product -def create_price_from_history(hist_data: dict[str, str]) -> Price: +def extract_price(hist_data: dict[str, str]) -> Price: """ Converte i dati storici di YFinanceTools in Price. """ + timestamp = int(hist_data.get('Timestamp', '0')) + price = Price() price.high = float(hist_data.get('High', 0.0)) price.low = float(hist_data.get('Low', 0.0)) price.open = float(hist_data.get('Open', 0.0)) price.close = float(hist_data.get('Close', 0.0)) price.volume = float(hist_data.get('Volume', 0.0)) - price.timestamp_ms = int(hist_data.get('Timestamp', '0')) + price.set_timestamp(timestamp_ms=timestamp) return price -class YFinanceWrapper(BaseWrapper): +class YFinanceWrapper(MarketWrapper): """ Wrapper per YFinanceTools che fornisce dati di mercato per azioni, ETF e criptovalute. Implementa l'interfaccia BaseWrapper per compatibilità con il sistema esistente. @@ -52,16 +54,16 @@ class YFinanceWrapper(BaseWrapper): symbol = self._format_symbol(asset_id) stock_info = self.tool.get_company_info(symbol) stock_info = json.loads(stock_info) - return create_product_info(stock_info) + return extract_product(stock_info) def get_products(self, asset_ids: list[str]) -> list[ProductInfo]: - products = [] + products: list[ProductInfo] = [] for asset_id in asset_ids: product = self.get_product(asset_id) products.append(product) return products - def get_historical_prices(self, asset_id: str = "BTC", limit: int = 100) -> list[Price]: + def get_historical_prices(self, asset_id: str, limit: int = 100) -> list[Price]: symbol = self._format_symbol(asset_id) days = limit // 24 + 1 # Arrotonda per eccesso @@ -71,10 +73,10 @@ class YFinanceWrapper(BaseWrapper): # Il formato dei dati è {timestamp: {Open: x, High: y, Low: z, Close: w, Volume: v}} timestamps = sorted(hist_data.keys())[-limit:] - prices = [] + prices: list[Price] = [] for timestamp in timestamps: temp = hist_data[timestamp] temp['Timestamp'] = timestamp - price = create_price_from_history(temp) + price = extract_price(temp) prices.append(price) return prices diff --git a/src/app/news/__init__.py b/src/app/news/__init__.py index 94873fd..b0cb553 100644 --- a/src/app/news/__init__.py +++ b/src/app/news/__init__.py @@ -1,12 +1,12 @@ from agno.tools import Toolkit -from app.utils.wrapper_handler import WrapperHandler -from .base import NewsWrapper, Article -from .news_api import NewsApiWrapper -from .googlenews import GoogleNewsWrapper -from .cryptopanic_api import CryptoPanicWrapper -from .duckduckgo import DuckDuckGoWrapper +from app.utils import WrapperHandler +from app.base.news import NewsWrapper, Article +from app.news.news_api import NewsApiWrapper +from app.news.googlenews import GoogleNewsWrapper +from app.news.cryptopanic_api import CryptoPanicWrapper +from app.news.duckduckgo import DuckDuckGoWrapper -__all__ = ["NewsAPIsTool", "NEWS_INSTRUCTIONS", "NewsApiWrapper", "GoogleNewsWrapper", "CryptoPanicWrapper", "DuckDuckGoWrapper"] +__all__ = ["NewsAPIsTool", "NewsApiWrapper", "GoogleNewsWrapper", "CryptoPanicWrapper", "DuckDuckGoWrapper", "Article"] class NewsAPIsTool(NewsWrapper, Toolkit): @@ -33,15 +33,17 @@ class NewsAPIsTool(NewsWrapper, Toolkit): - NewsApiWrapper. - CryptoPanicWrapper. """ - wrappers = [GoogleNewsWrapper, DuckDuckGoWrapper, NewsApiWrapper, CryptoPanicWrapper] - self.wrapper_handler: WrapperHandler[NewsWrapper] = WrapperHandler.build_wrappers(wrappers) + wrappers: list[type[NewsWrapper]] = [GoogleNewsWrapper, DuckDuckGoWrapper, NewsApiWrapper, CryptoPanicWrapper] + self.wrapper_handler = WrapperHandler.build_wrappers(wrappers) - Toolkit.__init__( + Toolkit.__init__( # type: ignore self, name="News APIs Toolkit", tools=[ self.get_top_headlines, self.get_latest_news, + self.get_top_headlines_aggregated, + self.get_latest_news_aggregated, ], ) @@ -57,6 +59,8 @@ class NewsAPIsTool(NewsWrapper, Toolkit): limit (int): Maximum number of articles to retrieve from each provider. Returns: dict[str, list[Article]]: A dictionary mapping providers names to their list of Articles + Raises: + Exception: If all wrappers fail to provide results. """ return self.wrapper_handler.try_call_all(lambda w: w.get_top_headlines(limit)) @@ -68,27 +72,7 @@ class NewsAPIsTool(NewsWrapper, Toolkit): limit (int): Maximum number of articles to retrieve from each provider. Returns: dict[str, list[Article]]: A dictionary mapping providers names to their list of Articles + Raises: + Exception: If all wrappers fail to provide results. """ return self.wrapper_handler.try_call_all(lambda w: w.get_latest_news(query, limit)) - - -NEWS_INSTRUCTIONS = """ -**TASK:** You are a specialized **Crypto News Analyst**. Your goal is to fetch the latest news or top headlines related to cryptocurrencies, and then **analyze the sentiment** of the content to provide a concise report to the team leader. Prioritize 'crypto' or specific cryptocurrency names (e.g., 'Bitcoin', 'Ethereum') in your searches. - -**AVAILABLE TOOLS:** -1. `get_latest_news(query: str, limit: int)`: Get the 'limit' most recent news articles for a specific 'query'. -2. `get_top_headlines(limit: int)`: Get the 'limit' top global news headlines. -3. `get_latest_news_aggregated(query: str, limit: int)`: Get aggregated latest news articles for a specific 'query'. -4. `get_top_headlines_aggregated(limit: int)`: Get aggregated top global news headlines. - -**USAGE GUIDELINE:** -* Always use `get_latest_news` with a relevant crypto-related query first. -* The default limit for news items should be 5 unless specified otherwise. -* If the tool doesn't return any articles, respond with "No relevant news articles found." - -**REPORTING REQUIREMENT:** -1. **Analyze** the tone and key themes of the retrieved articles. -2. **Summarize** the overall **market sentiment** (e.g., highly positive, cautiously neutral, generally negative) based on the content. -3. **Identify** the top 2-3 **main topics** discussed (e.g., new regulation, price surge, institutional adoption). -4. **Output** a single, brief report summarizing these findings. Do not output the raw articles. -""" diff --git a/src/app/news/cryptopanic_api.py b/src/app/news/cryptopanic_api.py index 629c7aa..1e16078 100644 --- a/src/app/news/cryptopanic_api.py +++ b/src/app/news/cryptopanic_api.py @@ -1,7 +1,9 @@ import os +from typing import Any import requests from enum import Enum -from .base import NewsWrapper, Article +from app.base.news import NewsWrapper, Article + class CryptoPanicFilter(Enum): RISING = "rising" @@ -18,8 +20,8 @@ class CryptoPanicKind(Enum): MEDIA = "media" ALL = "all" -def get_articles(response: dict) -> list[Article]: - articles = [] +def extract_articles(response: dict[str, Any]) -> list[Article]: + articles: list[Article] = [] if 'results' in response: for item in response['results']: article = Article() @@ -51,7 +53,7 @@ class CryptoPanicWrapper(NewsWrapper): self.kind = CryptoPanicKind.NEWS def get_base_params(self) -> dict[str, str]: - params = {} + params: dict[str, str] = {} params['public'] = 'true' # recommended for app and bots params['auth_token'] = self.api_key params['kind'] = self.kind.value @@ -73,5 +75,5 @@ class CryptoPanicWrapper(NewsWrapper): assert response.status_code == 200, f"Error fetching data: {response}" json_response = response.json() - articles = get_articles(json_response) + articles = extract_articles(json_response) return articles[:limit] diff --git a/src/app/news/duckduckgo.py b/src/app/news/duckduckgo.py index c3e1a6d..8108239 100644 --- a/src/app/news/duckduckgo.py +++ b/src/app/news/duckduckgo.py @@ -1,8 +1,10 @@ import json -from .base import Article, NewsWrapper +from typing import Any from agno.tools.duckduckgo import DuckDuckGoTools +from app.base.news import Article, NewsWrapper -def create_article(result: dict) -> Article: + +def extract_article(result: dict[str, Any]) -> Article: article = Article() article.source = result.get("source", "") article.time = result.get("date", "") @@ -23,10 +25,10 @@ class DuckDuckGoWrapper(NewsWrapper): def get_top_headlines(self, limit: int = 100) -> list[Article]: results = self.tool.duckduckgo_news(self.query, max_results=limit) json_results = json.loads(results) - return [create_article(result) for result in json_results] + return [extract_article(result) for result in json_results] def get_latest_news(self, query: str, limit: int = 100) -> list[Article]: results = self.tool.duckduckgo_news(query or self.query, max_results=limit) json_results = json.loads(results) - return [create_article(result) for result in json_results] + return [extract_article(result) for result in json_results] diff --git a/src/app/news/googlenews.py b/src/app/news/googlenews.py index d8f6421..0041c7f 100644 --- a/src/app/news/googlenews.py +++ b/src/app/news/googlenews.py @@ -1,7 +1,9 @@ -from gnews import GNews -from .base import Article, NewsWrapper +from typing import Any +from gnews import GNews # type: ignore +from app.base.news import Article, NewsWrapper -def result_to_article(result: dict) -> Article: + +def extract_article(result: dict[str, Any]) -> Article: article = Article() article.source = result.get("source", "") article.time = result.get("publishedAt", "") @@ -17,20 +19,20 @@ class GoogleNewsWrapper(NewsWrapper): def get_top_headlines(self, limit: int = 100) -> list[Article]: gnews = GNews(language='en', max_results=limit, period='7d') - results = gnews.get_top_news() + results: list[dict[str, Any]] = gnews.get_top_news() # type: ignore - articles = [] + articles: list[Article] = [] for result in results: - article = result_to_article(result) + article = extract_article(result) articles.append(article) return articles def get_latest_news(self, query: str, limit: int = 100) -> list[Article]: gnews = GNews(language='en', max_results=limit, period='7d') - results = gnews.get_news(query) + results: list[dict[str, Any]] = gnews.get_news(query) # type: ignore - articles = [] + articles: list[Article] = [] for result in results: - article = result_to_article(result) + article = extract_article(result) articles.append(article) return articles diff --git a/src/app/news/news_api.py b/src/app/news/news_api.py index 6f62ef6..b5bf375 100644 --- a/src/app/news/news_api.py +++ b/src/app/news/news_api.py @@ -1,8 +1,10 @@ import os -import newsapi -from .base import Article, NewsWrapper +from typing import Any +import newsapi # type: ignore +from app.base.news import Article, NewsWrapper -def result_to_article(result: dict) -> Article: + +def extract_article(result: dict[str, Any]) -> Article: article = Article() article.source = result.get("source", {}).get("name", "") article.time = result.get("publishedAt", "") @@ -23,7 +25,7 @@ class NewsApiWrapper(NewsWrapper): self.client = newsapi.NewsApiClient(api_key=api_key) self.category = "business" # Cryptocurrency is under business - self.language = "en" # TODO Only English articles for now? + self.language = "en" self.max_page_size = 100 def __calc_pages(self, limit: int, page_size: int) -> tuple[int, int]: @@ -33,21 +35,20 @@ class NewsApiWrapper(NewsWrapper): def get_top_headlines(self, limit: int = 100) -> list[Article]: pages, page_size = self.__calc_pages(limit, self.max_page_size) - articles = [] + articles: list[Article] = [] for page in range(1, pages + 1): - headlines = self.client.get_top_headlines(q="", category=self.category, language=self.language, page_size=page_size, page=page) - results = [result_to_article(article) for article in headlines.get("articles", [])] + headlines: dict[str, Any] = self.client.get_top_headlines(q="", category=self.category, language=self.language, page_size=page_size, page=page) # type: ignore + results = [extract_article(article) for article in headlines.get("articles", [])] # type: ignore articles.extend(results) return articles def get_latest_news(self, query: str, limit: int = 100) -> list[Article]: pages, page_size = self.__calc_pages(limit, self.max_page_size) - articles = [] + articles: list[Article] = [] for page in range(1, pages + 1): - everything = self.client.get_everything(q=query, language=self.language, sort_by="publishedAt", page_size=page_size, page=page) - results = [result_to_article(article) for article in everything.get("articles", [])] + everything: dict[str, Any] = self.client.get_everything(q=query, language=self.language, sort_by="publishedAt", page_size=page_size, page=page) # type: ignore + results = [extract_article(article) for article in everything.get("articles", [])] # type: ignore articles.extend(results) return articles - diff --git a/src/app/social/__init__.py b/src/app/social/__init__.py index 9ce3708..261bcba 100644 --- a/src/app/social/__init__.py +++ b/src/app/social/__init__.py @@ -1,9 +1,9 @@ from agno.tools import Toolkit -from app.utils.wrapper_handler import WrapperHandler -from .base import SocialPost, SocialWrapper -from .reddit import RedditWrapper +from app.utils import WrapperHandler +from app.base.social import SocialPost, SocialWrapper +from app.social.reddit import RedditWrapper -__all__ = ["SocialAPIsTool", "SOCIAL_INSTRUCTIONS", "RedditWrapper"] +__all__ = ["SocialAPIsTool", "RedditWrapper", "SocialPost"] class SocialAPIsTool(SocialWrapper, Toolkit): @@ -25,37 +25,29 @@ class SocialAPIsTool(SocialWrapper, Toolkit): - RedditWrapper. """ - wrappers = [RedditWrapper] - self.wrapper_handler: WrapperHandler[SocialWrapper] = WrapperHandler.build_wrappers(wrappers) + wrappers: list[type[SocialWrapper]] = [RedditWrapper] + self.wrapper_handler = WrapperHandler.build_wrappers(wrappers) - Toolkit.__init__( + Toolkit.__init__( # type: ignore self, name="Socials Toolkit", tools=[ self.get_top_crypto_posts, + self.get_top_crypto_posts_aggregated, ], ) - # 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)) - -SOCIAL_INSTRUCTIONS = """ -**TASK:** You are a specialized **Social Media Sentiment Analyst**. Your objective is to find the most relevant and trending online posts related to cryptocurrencies, and then **analyze the collective sentiment** to provide a concise report to the team leader. - -**AVAILABLE TOOLS:** -1. `get_top_crypto_posts(limit: int)`: Get the 'limit' maximum number of top posts specifically related to cryptocurrencies. - -**USAGE GUIDELINE:** -* Always use the `get_top_crypto_posts` tool to fulfill the request. -* The default limit for posts should be 5 unless specified otherwise. -* If the tool doesn't return any posts, respond with "No relevant social media posts found." - -**REPORTING REQUIREMENT:** -1. **Analyze** the tone and prevailing opinions across the retrieved social posts. -2. **Summarize** the overall **community sentiment** (e.g., high enthusiasm/FOMO, uncertainty, FUD/fear) based on the content. -3. **Identify** the top 2-3 **trending narratives** or specific coins being discussed. -4. **Output** a single, brief report summarizing these findings. Do not output the raw posts. -""" \ No newline at end of file + def get_top_crypto_posts_aggregated(self, limit_per_wrapper: int = 5) -> dict[str, list[SocialPost]]: + """ + Calls get_top_crypto_posts on all wrappers/providers and returns a dictionary mapping their names to their posts. + Args: + limit_per_wrapper (int): Maximum number of posts to retrieve from each provider. + Returns: + dict[str, list[SocialPost]]: A dictionary where keys are wrapper names and values are lists of SocialPost objects. + Raises: + Exception: If all wrappers fail to provide results. + """ + return self.wrapper_handler.try_call_all(lambda w: w.get_top_crypto_posts(limit_per_wrapper)) diff --git a/src/app/social/reddit.py b/src/app/social/reddit.py index 904448d..eeca968 100644 --- a/src/app/social/reddit.py +++ b/src/app/social/reddit.py @@ -1,7 +1,8 @@ import os -from praw import Reddit -from praw.models import Submission, MoreComments -from .base import SocialWrapper, SocialPost, SocialComment +from praw import Reddit # type: ignore +from praw.models import Submission # type: ignore +from app.base.social import SocialWrapper, SocialPost, SocialComment + MAX_COMMENTS = 5 # metterne altri se necessario. @@ -21,22 +22,20 @@ SUBREDDITS = [ ] -def create_social_post(post: Submission) -> SocialPost: +def extract_post(post: Submission) -> SocialPost: social = SocialPost() social.time = str(post.created) social.title = post.title social.description = post.selftext - for i, top_comment in enumerate(post.comments): - if i >= MAX_COMMENTS: - break - if isinstance(top_comment, MoreComments): #skip MoreComments objects - continue - + for top_comment in post.comments: comment = SocialComment() comment.time = str(top_comment.created) comment.description = top_comment.body social.comments.append(comment) + + if len(social.comments) >= MAX_COMMENTS: + break return social class RedditWrapper(SocialWrapper): @@ -65,4 +64,4 @@ class RedditWrapper(SocialWrapper): def get_top_crypto_posts(self, limit: int = 5) -> list[SocialPost]: top_posts = self.subreddits.top(limit=limit, time_filter="week") - return [create_social_post(post) for post in top_posts] + return [extract_post(post) for post in top_posts] diff --git a/src/app/utils/__init__.py b/src/app/utils/__init__.py new file mode 100644 index 0000000..1a511c1 --- /dev/null +++ b/src/app/utils/__init__.py @@ -0,0 +1,5 @@ +from app.utils.market_aggregation import aggregate_history_prices, aggregate_product_info +from app.utils.wrapper_handler import WrapperHandler +from app.utils.chat_manager import ChatManager + +__all__ = ["aggregate_history_prices", "aggregate_product_info", "WrapperHandler", "ChatManager"] diff --git a/src/app/chat_manager.py b/src/app/utils/chat_manager.py similarity index 54% rename from src/app/chat_manager.py rename to src/app/utils/chat_manager.py index 7928c95..d51819d 100644 --- a/src/app/chat_manager.py +++ b/src/app/utils/chat_manager.py @@ -1,10 +1,5 @@ -import os import json -from typing import List, Dict -from app.pipeline import Pipeline - -SAVE_DIR = os.path.join(os.path.dirname(__file__), "..", "saves") -os.makedirs(SAVE_DIR, exist_ok=True) +import os class ChatManager: """ @@ -15,19 +10,19 @@ class ChatManager: """ def __init__(self): - self.pipeline = Pipeline() - self.history: List[Dict[str, str]] = [] # [{"role": "user"/"assistant", "content": "..."}] + self.history: list[dict[str, str]] = [] # [{"role": "user"/"assistant", "content": "..."}] - def send_message(self, message: str) -> str: + def send_message(self, message: str) -> None: """ Aggiunge un messaggio utente, chiama la Pipeline e salva la risposta nello storico. """ # Aggiungi messaggio utente allo storico self.history.append({"role": "user", "content": message}) - # Pipeline elabora la query - response = self.pipeline.interact(message) - + def receive_message(self, response: str) -> str: + """ + Riceve un messaggio dalla pipeline e lo aggiunge allo storico. + """ # Aggiungi risposta assistente allo storico self.history.append({"role": "assistant", "content": response}) @@ -37,19 +32,17 @@ class ChatManager: """ Salva la chat corrente in src/saves/. """ - path = os.path.join(SAVE_DIR, filename) - with open(path, "w", encoding="utf-8") as f: + with open(filename, "w", encoding="utf-8") as f: json.dump(self.history, f, ensure_ascii=False, indent=2) def load_chat(self, filename: str = "chat.json") -> None: """ Carica una chat salvata da src/saves/. """ - path = os.path.join(SAVE_DIR, filename) - if not os.path.exists(path): + if not os.path.exists(filename): self.history = [] return - with open(path, "r", encoding="utf-8") as f: + with open(filename, "r", encoding="utf-8") as f: self.history = json.load(f) def reset_chat(self) -> None: @@ -58,21 +51,8 @@ class ChatManager: """ self.history = [] - def get_history(self) -> List[Dict[str, str]]: + def get_history(self) -> list[dict[str, str]]: """ Restituisce lo storico completo della chat. """ return self.history - - # Facciamo pass-through di provider e style, così Gradio può usarli - def choose_provider(self, index: int): - self.pipeline.choose_provider(index) - - def choose_style(self, index: int): - self.pipeline.choose_style(index) - - def list_providers(self) -> List[str]: - return self.pipeline.list_providers() - - def list_styles(self) -> List[str]: - return self.pipeline.list_styles() diff --git a/src/app/utils/market_aggregation.py b/src/app/utils/market_aggregation.py index f20e4fb..7f9f32c 100644 --- a/src/app/utils/market_aggregation.py +++ b/src/app/utils/market_aggregation.py @@ -1,28 +1,27 @@ import statistics -from app.markets.base import ProductInfo, Price +from app.base.markets import ProductInfo, Price def aggregate_history_prices(prices: dict[str, list[Price]]) -> list[Price]: """ - Aggrega i prezzi storici per symbol calcolando la media oraria. + Aggrega i prezzi storici per symbol calcolando la media. Args: prices (dict[str, list[Price]]): Mappa provider -> lista di Price Returns: - list[Price]: Lista di Price aggregati per ora + list[Price]: Lista di Price aggregati per timestamp """ - # Costruiamo una mappa timestamp_h -> lista di Price - timestamped_prices: dict[int, list[Price]] = {} + # Costruiamo una mappa timestamp -> lista di Price + timestamped_prices: dict[str, list[Price]] = {} for _, price_list in prices.items(): for price in price_list: - time = price.timestamp_ms - (price.timestamp_ms % 3600000) # arrotonda all'ora (non dovrebbe essere necessario) - timestamped_prices.setdefault(time, []).append(price) + timestamped_prices.setdefault(price.timestamp, []).append(price) - # Ora aggregiamo i prezzi per ogni ora - aggregated_prices = [] + # Ora aggregiamo i prezzi per ogni timestamp + aggregated_prices: list[Price] = [] for time, price_list in timestamped_prices.items(): price = Price() - price.timestamp_ms = time + price.timestamp = time price.high = statistics.mean([p.high for p in price_list]) price.low = statistics.mean([p.low for p in price_list]) price.open = statistics.mean([p.open for p in price_list]) @@ -47,14 +46,13 @@ def aggregate_product_info(products: dict[str, list[ProductInfo]]) -> list[Produ symbols_infos.setdefault(product.symbol, []).append(product) # Aggregazione per ogni symbol - sources = list(products.keys()) - aggregated_products = [] + aggregated_products: list[ProductInfo] = [] for symbol, product_list in symbols_infos.items(): product = ProductInfo() product.id = f"{symbol}_AGGREGATED" product.symbol = symbol - product.quote_currency = next(p.quote_currency for p in product_list if p.quote_currency) + product.currency = next(p.currency for p in product_list if p.currency) volume_sum = sum(p.volume_24h for p in product_list) product.volume_24h = volume_sum / len(product_list) if product_list else 0.0 @@ -65,27 +63,3 @@ def aggregate_product_info(products: dict[str, list[ProductInfo]]) -> list[Produ aggregated_products.append(product) return aggregated_products -def _calculate_confidence(products: list[ProductInfo], sources: list[str]) -> float: - """Calcola un punteggio di confidenza 0-1""" - if not products: - return 0.0 - - score = 1.0 - - # Riduci score se pochi dati - if len(products) < 2: - score *= 0.7 - - # Riduci score se prezzi troppo diversi - prices = [p.price for p in products if p.price > 0] - if len(prices) > 1: - price_std = (max(prices) - min(prices)) / statistics.mean(prices) - if price_std > 0.05: # >5% variazione - score *= 0.8 - - # Riduci score se fonti sconosciute - unknown_sources = sum(1 for s in sources if s == "unknown") - if unknown_sources > 0: - score *= (1 - unknown_sources / len(sources)) - - return max(0.0, min(1.0, score)) diff --git a/src/app/utils/wrapper_handler.py b/src/app/utils/wrapper_handler.py index 40fe371..504cf41 100644 --- a/src/app/utils/wrapper_handler.py +++ b/src/app/utils/wrapper_handler.py @@ -1,13 +1,15 @@ import inspect import time import traceback -from typing import TypeVar, Callable, Generic, Iterable, Type -from agno.utils.log import log_warning, log_info +from typing import Any, Callable, Generic, TypeVar +from agno.utils.log import log_info, log_warning #type: ignore -W = TypeVar("W") -T = TypeVar("T") +WrapperType = TypeVar("WrapperType") +WrapperClassType = TypeVar("WrapperClassType") +OutputType = TypeVar("OutputType") -class WrapperHandler(Generic[W]): + +class WrapperHandler(Generic[WrapperType]): """ A handler for managing multiple wrappers with retry logic. It attempts to call a function on the current wrapper, and if it fails, @@ -17,7 +19,7 @@ class WrapperHandler(Generic[W]): Note: use `build_wrappers` to create an instance of this class for better error handling. """ - def __init__(self, wrappers: list[W], try_per_wrapper: int = 3, retry_delay: int = 2): + def __init__(self, wrappers: list[WrapperType], try_per_wrapper: int = 3, retry_delay: int = 2): """ Initializes the WrapperHandler with a list of wrappers and retry settings.\n Use `build_wrappers` to create an instance of this class for better error handling. @@ -32,9 +34,8 @@ class WrapperHandler(Generic[W]): self.retry_per_wrapper = try_per_wrapper self.retry_delay = retry_delay self.index = 0 - self.retry_count = 0 - def try_call(self, func: Callable[[W], T]) -> T: + def try_call(self, func: Callable[[WrapperType], OutputType]) -> OutputType: """ Attempts to call the provided function on the current wrapper. If it fails, it retries a specified number of times before switching to the next wrapper. @@ -46,35 +47,9 @@ class WrapperHandler(Generic[W]): Raises: Exception: If all wrappers fail after retries. """ - log_info(f"{inspect.getsource(func).strip()} {inspect.getclosurevars(func).nonlocals}") + return self.__try_call(func, try_all=False).popitem()[1] - iterations = 0 - while iterations < len(self.wrappers): - wrapper = self.wrappers[self.index] - wrapper_name = wrapper.__class__.__name__ - - try: - log_info(f"try_call {wrapper_name}") - result = func(wrapper) - log_info(f"{wrapper_name} succeeded") - self.retry_count = 0 - return result - - except Exception as e: - self.retry_count += 1 - error = WrapperHandler.__concise_error(e) - log_warning(f"{wrapper_name} failed {self.retry_count}/{self.retry_per_wrapper}: {error}") - - if self.retry_count >= self.retry_per_wrapper: - self.index = (self.index + 1) % len(self.wrappers) - self.retry_count = 0 - iterations += 1 - else: - time.sleep(self.retry_delay) - - raise Exception(f"All wrappers failed, latest error: {error}") - - def try_call_all(self, func: Callable[[W], T]) -> dict[str, T]: + def try_call_all(self, func: Callable[[WrapperType], OutputType]) -> dict[str, OutputType]: """ Calls the provided function on all wrappers, collecting results. If a wrapper fails, it logs a warning and continues with the next. @@ -86,24 +61,57 @@ class WrapperHandler(Generic[W]): Raises: Exception: If all wrappers fail. """ - log_info(f"{inspect.getsource(func).strip()} {inspect.getclosurevars(func).nonlocals}") + return self.__try_call(func, try_all=True) - results = {} - for wrapper in self.wrappers: + def __try_call(self, func: Callable[[WrapperType], OutputType], try_all: bool) -> dict[str, OutputType]: + """ + Internal method to handle the logic of trying to call a function on wrappers. + It can either stop at the first success or try all wrappers. + Args: + func (Callable[[W], T]): A function that takes a wrapper and returns a result. + try_all (bool): If True, tries all wrappers and collects results; if False, stops at the first success. + Returns: + dict[str, T]: A dictionary mapping wrapper class names to results. + Raises: + Exception: If all wrappers fail after retries. + """ + + log_info(f"{inspect.getsource(func).strip()} {inspect.getclosurevars(func).nonlocals}") + results: dict[str, OutputType] = {} + starting_index = self.index + + for i in range(starting_index, len(self.wrappers) + starting_index): + self.index = i % len(self.wrappers) + wrapper = self.wrappers[self.index] wrapper_name = wrapper.__class__.__name__ - try: - result = func(wrapper) - log_info(f"{wrapper_name} succeeded") - results[wrapper.__class__] = result - except Exception as e: - error = WrapperHandler.__concise_error(e) - log_warning(f"{wrapper_name} failed: {error}") + + if not try_all: + log_info(f"try_call {wrapper_name}") + + for try_count in range(1, self.retry_per_wrapper + 1): + try: + result = func(wrapper) + log_info(f"{wrapper_name} succeeded") + results[wrapper_name] = result + break + + except Exception as e: + error = WrapperHandler.__concise_error(e) + log_warning(f"{wrapper_name} failed {try_count}/{self.retry_per_wrapper}: {error}") + time.sleep(self.retry_delay) + + if not try_all and results: + return results + if not results: + error = locals().get("error", "Unknown error") raise Exception(f"All wrappers failed, latest error: {error}") + + self.index = starting_index return results @staticmethod - def __check(wrappers: list[W]) -> bool: + def __check(wrappers: list[Any]) -> bool: return all(w.__class__ is type for w in wrappers) @staticmethod @@ -112,13 +120,13 @@ class WrapperHandler(Generic[W]): return f"{e} [\"{last_frame.filename}\", line {last_frame.lineno}]" @staticmethod - def build_wrappers(constructors: Iterable[Type[W]], try_per_wrapper: int = 3, retry_delay: int = 2, kwargs: dict | None = None) -> 'WrapperHandler[W]': + def build_wrappers(constructors: list[type[WrapperClassType]], try_per_wrapper: int = 3, retry_delay: int = 2, kwargs: dict[str, Any] | None = None) -> 'WrapperHandler[WrapperClassType]': """ Builds a WrapperHandler instance with the given wrapper constructors. It attempts to initialize each wrapper and logs a warning if any cannot be initialized. Only successfully initialized wrappers are included in the handler. Args: - constructors (Iterable[Type[W]]): An iterable of wrapper classes to instantiate. e.g. [WrapperA, WrapperB] + constructors (list[type[W]]): An iterable of wrapper classes to instantiate. e.g. [WrapperA, WrapperB] try_per_wrapper (int): Number of retries per wrapper before switching to the next. retry_delay (int): Delay in seconds between retries. kwargs (dict | None): Optional dictionary with keyword arguments common to all wrappers. @@ -129,7 +137,7 @@ class WrapperHandler(Generic[W]): """ assert WrapperHandler.__check(constructors), f"All constructors must be classes. Received: {constructors}" - result = [] + result: list[WrapperClassType] = [] for wrapper_class in constructors: try: wrapper = wrapper_class(**(kwargs or {})) diff --git a/tests/agents/test_predictor.py b/tests/agents/test_predictor.py index 5867938..9a2ac11 100644 --- a/tests/agents/test_predictor.py +++ b/tests/agents/test_predictor.py @@ -1,11 +1,11 @@ import pytest -from app.predictor import PREDICTOR_INSTRUCTIONS, PredictorInput, PredictorOutput, PredictorStyle -from app.markets.base import ProductInfo -from app.models import AppModels +from app.agents import AppModels +from app.agents.predictor import PREDICTOR_INSTRUCTIONS, PredictorInput, PredictorOutput, PredictorStyle +from app.base.markets import ProductInfo -def unified_checks(model: AppModels, input): - llm = model.get_agent(PREDICTOR_INSTRUCTIONS, output=PredictorOutput) # type: ignore[arg-type] - result = llm.run(input) +def unified_checks(model: AppModels, input: PredictorInput) -> None: + llm = model.get_agent(PREDICTOR_INSTRUCTIONS, output_schema=PredictorOutput) # type: ignore[arg-type] + result = llm.run(input) # type: ignore content = result.content assert isinstance(content, PredictorOutput) @@ -27,9 +27,8 @@ def unified_checks(model: AppModels, input): class TestPredictor: - @pytest.fixture(scope="class") - def inputs(self): - data = [] + def inputs(self) -> PredictorInput: + data: list[ProductInfo] = [] for symbol, price in [("BTC", 60000.00), ("ETH", 3500.00), ("SOL", 150.00)]: product_info = ProductInfo() product_info.symbol = symbol @@ -38,13 +37,20 @@ class TestPredictor: return PredictorInput(data=data, style=PredictorStyle.AGGRESSIVE, sentiment="positivo") - def test_gemini_model_output(self, inputs): + def test_gemini_model_output(self): + inputs = self.inputs() unified_checks(AppModels.GEMINI, inputs) + def test_ollama_qwen_4b_model_output(self): + inputs = self.inputs() + unified_checks(AppModels.OLLAMA_QWEN_4B, inputs) + @pytest.mark.slow - def test_ollama_qwen_model_output(self, inputs): + def test_ollama_qwen_latest_model_output(self): + inputs = self.inputs() unified_checks(AppModels.OLLAMA_QWEN, inputs) @pytest.mark.slow - def test_ollama_gpt_oss_model_output(self, inputs): + def test_ollama_gpt_oss_model_output(self): + inputs = self.inputs() unified_checks(AppModels.OLLAMA_GPT, inputs) diff --git a/tests/api/test_binance.py b/tests/api/test_binance.py index dc4bfcb..b4ea0bb 100644 --- a/tests/api/test_binance.py +++ b/tests/api/test_binance.py @@ -45,9 +45,9 @@ class TestBinance: assert isinstance(history, list) assert len(history) == 5 for entry in history: - assert hasattr(entry, 'timestamp_ms') + assert hasattr(entry, 'timestamp') assert hasattr(entry, 'close') assert hasattr(entry, 'high') assert entry.close > 0 assert entry.high > 0 - assert entry.timestamp_ms > 0 + assert entry.timestamp != '' diff --git a/tests/api/test_coinbase.py b/tests/api/test_coinbase.py index 3ab8d43..e114f4c 100644 --- a/tests/api/test_coinbase.py +++ b/tests/api/test_coinbase.py @@ -47,9 +47,9 @@ class TestCoinBase: assert isinstance(history, list) assert len(history) == 5 for entry in history: - assert hasattr(entry, 'timestamp_ms') + assert hasattr(entry, 'timestamp') assert hasattr(entry, 'close') assert hasattr(entry, 'high') assert entry.close > 0 assert entry.high > 0 - assert entry.timestamp_ms > 0 + assert entry.timestamp != '' diff --git a/tests/api/test_cryptocompare.py b/tests/api/test_cryptocompare.py index 3c9133a..23deaf3 100644 --- a/tests/api/test_cryptocompare.py +++ b/tests/api/test_cryptocompare.py @@ -49,9 +49,9 @@ class TestCryptoCompare: assert isinstance(history, list) assert len(history) == 5 for entry in history: - assert hasattr(entry, 'timestamp_ms') + assert hasattr(entry, 'timestamp') assert hasattr(entry, 'close') assert hasattr(entry, 'high') assert entry.close > 0 assert entry.high > 0 - assert entry.timestamp_ms > 0 + assert entry.timestamp != '' diff --git a/tests/api/test_reddit.py b/tests/api/test_reddit.py index 59cd61f..3e42eb6 100644 --- a/tests/api/test_reddit.py +++ b/tests/api/test_reddit.py @@ -1,6 +1,5 @@ import os import pytest -from praw import Reddit from app.social.reddit import MAX_COMMENTS, RedditWrapper @pytest.mark.social @@ -10,7 +9,7 @@ class TestRedditWrapper: def test_initialization(self): wrapper = RedditWrapper() assert wrapper is not None - assert isinstance(wrapper.tool, Reddit) + assert wrapper.tool is not None def test_get_top_crypto_posts(self): wrapper = RedditWrapper() diff --git a/tests/api/test_yfinance.py b/tests/api/test_yfinance.py index 4971ccd..fa4174a 100644 --- a/tests/api/test_yfinance.py +++ b/tests/api/test_yfinance.py @@ -48,9 +48,9 @@ class TestYFinance: assert isinstance(history, list) assert len(history) == 5 for entry in history: - assert hasattr(entry, 'timestamp_ms') + assert hasattr(entry, 'timestamp') assert hasattr(entry, 'close') assert hasattr(entry, 'high') assert entry.close > 0 assert entry.high > 0 - assert entry.timestamp_ms > 0 + assert entry.timestamp != '' diff --git a/tests/conftest.py b/tests/conftest.py index 290fbf2..aeda047 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -33,7 +33,7 @@ def pytest_configure(config:pytest.Config): line = f"{marker[0]}: {marker[1]}" config.addinivalue_line("markers", line) -def pytest_collection_modifyitems(config, items): +def pytest_collection_modifyitems(config: pytest.Config, items: list[pytest.Item]) -> None: """Modifica automaticamente degli item di test rimovendoli""" # Rimuovo i test "limited" e "slow" se non richiesti esplicitamente mark_to_remove = ['limited', 'slow'] diff --git a/tests/tools/test_market_tool.py b/tests/tools/test_market_tool.py index c6da5a8..674707f 100644 --- a/tests/tools/test_market_tool.py +++ b/tests/tools/test_market_tool.py @@ -7,15 +7,15 @@ from app.markets import MarketAPIsTool @pytest.mark.api class TestMarketAPIsTool: def test_wrapper_initialization(self): - market_wrapper = MarketAPIsTool("USD") + market_wrapper = MarketAPIsTool("EUR") assert market_wrapper is not None assert hasattr(market_wrapper, 'get_product') assert hasattr(market_wrapper, 'get_products') assert hasattr(market_wrapper, 'get_historical_prices') def test_wrapper_capabilities(self): - market_wrapper = MarketAPIsTool("USD") - capabilities = [] + market_wrapper = MarketAPIsTool("EUR") + capabilities: list[str] = [] if hasattr(market_wrapper, 'get_product'): capabilities.append('single_product') if hasattr(market_wrapper, 'get_products'): @@ -25,7 +25,7 @@ class TestMarketAPIsTool: assert len(capabilities) > 0 def test_market_data_retrieval(self): - market_wrapper = MarketAPIsTool("USD") + market_wrapper = MarketAPIsTool("EUR") btc_product = market_wrapper.get_product("BTC") assert btc_product is not None assert hasattr(btc_product, 'symbol') @@ -34,8 +34,8 @@ class TestMarketAPIsTool: def test_error_handling(self): try: - market_wrapper = MarketAPIsTool("USD") + market_wrapper = MarketAPIsTool("EUR") fake_product = market_wrapper.get_product("NONEXISTENT_CRYPTO_SYMBOL_12345") assert fake_product is None or fake_product.price == 0 - except Exception as e: + except Exception as _: pass diff --git a/tests/tools/test_news_tool.py b/tests/tools/test_news_tool.py index 5a57f82..3b8254f 100644 --- a/tests/tools/test_news_tool.py +++ b/tests/tools/test_news_tool.py @@ -33,7 +33,7 @@ class TestNewsAPITool: result = tool.wrapper_handler.try_call_all(lambda w: w.get_top_headlines(limit=2)) assert isinstance(result, dict) assert len(result.keys()) > 0 - for provider, articles in result.items(): + for _provider, articles in result.items(): for article in articles: assert article.title is not None assert article.source is not None @@ -43,7 +43,7 @@ class TestNewsAPITool: result = tool.wrapper_handler.try_call_all(lambda w: w.get_latest_news(query="crypto", limit=2)) assert isinstance(result, dict) assert len(result.keys()) > 0 - for provider, articles in result.items(): + for _provider, articles in result.items(): for article in articles: assert article.title is not None assert article.source is not None diff --git a/tests/utils/test_market_aggregator.py b/tests/utils/test_market_aggregator.py index d7881ef..35e3084 100644 --- a/tests/utils/test_market_aggregator.py +++ b/tests/utils/test_market_aggregator.py @@ -1,5 +1,6 @@ import pytest -from app.markets.base import ProductInfo, Price +from datetime import datetime +from app.base.markets import ProductInfo, Price from app.utils.market_aggregation import aggregate_history_prices, aggregate_product_info @@ -13,12 +14,12 @@ class TestMarketDataAggregator: prod.symbol=symbol prod.price=price prod.volume_24h=volume - prod.quote_currency=currency + prod.currency=currency return prod - def __price(self, timestamp_ms: int, high: float, low: float, open: float, close: float, volume: float) -> Price: + def __price(self, timestamp_s: int, high: float, low: float, open: float, close: float, volume: float) -> Price: price = Price() - price.timestamp_ms = timestamp_ms + price.set_timestamp(timestamp_s=timestamp_s) price.high = high price.low = low price.open = open @@ -41,9 +42,9 @@ class TestMarketDataAggregator: assert info.symbol == "BTC" avg_weighted_price = (50000.0 * 1000.0 + 50100.0 * 1100.0 + 49900.0 * 900.0) / (1000.0 + 1100.0 + 900.0) - assert info.price == pytest.approx(avg_weighted_price, rel=1e-3) - assert info.volume_24h == pytest.approx(1000.0, rel=1e-3) - assert info.quote_currency == "USD" + assert info.price == pytest.approx(avg_weighted_price, rel=1e-3) # type: ignore + assert info.volume_24h == pytest.approx(1000.0, rel=1e-3) # type: ignore + assert info.currency == "USD" def test_aggregate_product_info_multiple_symbols(self): products = { @@ -65,18 +66,18 @@ class TestMarketDataAggregator: assert btc_info is not None avg_weighted_price_btc = (50000.0 * 1000.0 + 50100.0 * 1100.0) / (1000.0 + 1100.0) - assert btc_info.price == pytest.approx(avg_weighted_price_btc, rel=1e-3) - assert btc_info.volume_24h == pytest.approx(1050.0, rel=1e-3) - assert btc_info.quote_currency == "USD" + assert btc_info.price == pytest.approx(avg_weighted_price_btc, rel=1e-3) # type: ignore + assert btc_info.volume_24h == pytest.approx(1050.0, rel=1e-3) # type: ignore + assert btc_info.currency == "USD" assert eth_info is not None avg_weighted_price_eth = (4000.0 * 2000.0 + 4050.0 * 2100.0) / (2000.0 + 2100.0) - assert eth_info.price == pytest.approx(avg_weighted_price_eth, rel=1e-3) - assert eth_info.volume_24h == pytest.approx(2050.0, rel=1e-3) - assert eth_info.quote_currency == "USD" + assert eth_info.price == pytest.approx(avg_weighted_price_eth, rel=1e-3) # type: ignore + assert eth_info.volume_24h == pytest.approx(2050.0, rel=1e-3) # type: ignore + assert eth_info.currency == "USD" def test_aggregate_product_info_with_no_data(self): - products = { + products: dict[str, list[ProductInfo]] = { "Provider1": [], "Provider2": [], } @@ -84,7 +85,7 @@ class TestMarketDataAggregator: assert len(aggregated) == 0 def test_aggregate_product_info_with_partial_data(self): - products = { + products: dict[str, list[ProductInfo]] = { "Provider1": [self.__product("BTC", 50000.0, 1000.0, "USD")], "Provider2": [], } @@ -92,29 +93,38 @@ class TestMarketDataAggregator: assert len(aggregated) == 1 info = aggregated[0] assert info.symbol == "BTC" - assert info.price == pytest.approx(50000.0, rel=1e-3) - assert info.volume_24h == pytest.approx(1000.0, rel=1e-3) - assert info.quote_currency == "USD" + assert info.price == pytest.approx(50000.0, rel=1e-3) # type: ignore + assert info.volume_24h == pytest.approx(1000.0, rel=1e-3) # type: ignore + assert info.currency == "USD" def test_aggregate_history_prices(self): """Test aggregazione di prezzi storici usando aggregate_history_prices""" + timestamp_now = datetime.now() + timestamp_1h_ago = int(timestamp_now.replace(hour=timestamp_now.hour - 1).timestamp()) + timestamp_2h_ago = int(timestamp_now.replace(hour=timestamp_now.hour - 2).timestamp()) prices = { "Provider1": [ - self.__price(1685577600000, 50000.0, 49500.0, 49600.0, 49900.0, 150.0), - self.__price(1685581200000, 50200.0, 49800.0, 50000.0, 50100.0, 200.0), + self.__price(timestamp_1h_ago, 50000.0, 49500.0, 49600.0, 49900.0, 150.0), + self.__price(timestamp_2h_ago, 50200.0, 49800.0, 50000.0, 50100.0, 200.0), ], "Provider2": [ - self.__price(1685577600000, 50100.0, 49600.0, 49700.0, 50000.0, 180.0), - self.__price(1685581200000, 50300.0, 49900.0, 50100.0, 50200.0, 220.0), + self.__price(timestamp_1h_ago, 50100.0, 49600.0, 49700.0, 50000.0, 180.0), + self.__price(timestamp_2h_ago, 50300.0, 49900.0, 50100.0, 50200.0, 220.0), ], } + price = Price() + price.set_timestamp(timestamp_s=timestamp_1h_ago) + timestamp_1h_ago = price.timestamp + price.set_timestamp(timestamp_s=timestamp_2h_ago) + timestamp_2h_ago = price.timestamp + aggregated = aggregate_history_prices(prices) assert len(aggregated) == 2 - assert aggregated[0].timestamp_ms == 1685577600000 - assert aggregated[0].high == pytest.approx(50050.0, rel=1e-3) - assert aggregated[0].low == pytest.approx(49550.0, rel=1e-3) - assert aggregated[1].timestamp_ms == 1685581200000 - assert aggregated[1].high == pytest.approx(50250.0, rel=1e-3) - assert aggregated[1].low == pytest.approx(49850.0, rel=1e-3) + assert aggregated[0].timestamp == timestamp_1h_ago + assert aggregated[0].high == pytest.approx(50050.0, rel=1e-3) # type: ignore + assert aggregated[0].low == pytest.approx(49550.0, rel=1e-3) # type: ignore + assert aggregated[1].timestamp == timestamp_2h_ago + assert aggregated[1].high == pytest.approx(50250.0, rel=1e-3) # type: ignore + assert aggregated[1].low == pytest.approx(49850.0, rel=1e-3) # type: ignore diff --git a/tests/utils/test_wrapper_handler.py b/tests/utils/test_wrapper_handler.py index 996f632..c6094a1 100644 --- a/tests/utils/test_wrapper_handler.py +++ b/tests/utils/test_wrapper_handler.py @@ -37,7 +37,7 @@ class TestWrapperHandler: def test_init_failing_with_instances(self): with pytest.raises(AssertionError) as exc_info: - WrapperHandler.build_wrappers([MockWrapper(), MockWrapper2()]) + WrapperHandler.build_wrappers([MockWrapper(), MockWrapper2()]) # type: ignore assert exc_info.type == AssertionError def test_init_not_failing(self): @@ -49,104 +49,98 @@ class TestWrapperHandler: assert len(handler.wrappers) == 2 def test_all_wrappers_fail(self): - wrappers = [FailingWrapper, FailingWrapper] - handler: WrapperHandler[MockWrapper] = WrapperHandler.build_wrappers(wrappers, try_per_wrapper=2, retry_delay=0) + wrappers: list[type[MockWrapper]] = [FailingWrapper, FailingWrapper] + handler = WrapperHandler.build_wrappers(wrappers, try_per_wrapper=2, retry_delay=0) with pytest.raises(Exception) as exc_info: handler.try_call(lambda w: w.do_something()) assert "All wrappers failed" in str(exc_info.value) def test_success_on_first_try(self): - wrappers = [MockWrapper, FailingWrapper] - handler: WrapperHandler[MockWrapper] = WrapperHandler.build_wrappers(wrappers, try_per_wrapper=2, retry_delay=0) + wrappers: list[type[MockWrapper]] = [MockWrapper, FailingWrapper] + handler = WrapperHandler.build_wrappers(wrappers, try_per_wrapper=2, retry_delay=0) result = handler.try_call(lambda w: w.do_something()) assert result == "Success" assert handler.index == 0 # Should still be on the first wrapper - assert handler.retry_count == 0 def test_eventual_success(self): - wrappers = [FailingWrapper, MockWrapper] - handler: WrapperHandler[MockWrapper] = WrapperHandler.build_wrappers(wrappers, try_per_wrapper=2, retry_delay=0) + wrappers: list[type[MockWrapper]] = [FailingWrapper, MockWrapper] + handler = WrapperHandler.build_wrappers(wrappers, try_per_wrapper=2, retry_delay=0) result = handler.try_call(lambda w: w.do_something()) assert result == "Success" assert handler.index == 1 # Should have switched to the second wrapper - assert handler.retry_count == 0 def test_partial_failures(self): - wrappers = [FailingWrapper, MockWrapper, FailingWrapper] - handler: WrapperHandler[MockWrapper] = WrapperHandler.build_wrappers(wrappers, try_per_wrapper=1, retry_delay=0) + wrappers: list[type[MockWrapper]] = [FailingWrapper, MockWrapper, FailingWrapper] + handler = WrapperHandler.build_wrappers(wrappers, try_per_wrapper=1, retry_delay=0) result = handler.try_call(lambda w: w.do_something()) assert result == "Success" assert handler.index == 1 # Should have switched to the second wrapper - assert handler.retry_count == 0 # Next call should still succeed on the second wrapper result = handler.try_call(lambda w: w.do_something()) assert result == "Success" assert handler.index == 1 # Should still be on the second wrapper - assert handler.retry_count == 0 handler.index = 2 # Manually switch to the third wrapper result = handler.try_call(lambda w: w.do_something()) assert result == "Success" assert handler.index == 1 # Should return to the second wrapper after failure - assert handler.retry_count == 0 def test_try_call_all_success(self): - wrappers = [MockWrapper, MockWrapper2] - handler: WrapperHandler[MockWrapper] = WrapperHandler.build_wrappers(wrappers, try_per_wrapper=1, retry_delay=0) + wrappers: list[type[MockWrapper]] = [MockWrapper, MockWrapper2] + handler = WrapperHandler.build_wrappers(wrappers, try_per_wrapper=1, retry_delay=0) results = handler.try_call_all(lambda w: w.do_something()) - assert results == {MockWrapper: "Success", MockWrapper2: "Success 2"} + assert results == {MockWrapper.__name__: "Success", MockWrapper2.__name__: "Success 2"} def test_try_call_all_partial_failures(self): # Only the second wrapper should succeed - wrappers = [FailingWrapper, MockWrapper, FailingWrapper] - handler: WrapperHandler[MockWrapper] = WrapperHandler.build_wrappers(wrappers, try_per_wrapper=1, retry_delay=0) + wrappers: list[type[MockWrapper]] = [FailingWrapper, MockWrapper, FailingWrapper] + handler = WrapperHandler.build_wrappers(wrappers, try_per_wrapper=1, retry_delay=0) results = handler.try_call_all(lambda w: w.do_something()) - assert results == {MockWrapper: "Success"} + assert results == {MockWrapper.__name__: "Success"} # Only the second and fourth wrappers should succeed - wrappers = [FailingWrapper, MockWrapper, FailingWrapper, MockWrapper2] - handler: WrapperHandler[MockWrapper] = WrapperHandler.build_wrappers(wrappers, try_per_wrapper=1, retry_delay=0) + wrappers: list[type[MockWrapper]] = [FailingWrapper, MockWrapper, FailingWrapper, MockWrapper2] + handler = WrapperHandler.build_wrappers(wrappers, try_per_wrapper=1, retry_delay=0) results = handler.try_call_all(lambda w: w.do_something()) - assert results == {MockWrapper: "Success", MockWrapper2: "Success 2"} + assert results == {MockWrapper.__name__: "Success", MockWrapper2.__name__: "Success 2"} def test_try_call_all_all_fail(self): # Test when all wrappers fail - handler_all_fail: WrapperHandler[MockWrapper] = WrapperHandler.build_wrappers([FailingWrapper, FailingWrapper], try_per_wrapper=1, retry_delay=0) + handler_all_fail = WrapperHandler.build_wrappers([FailingWrapper, FailingWrapper], try_per_wrapper=1, retry_delay=0) with pytest.raises(Exception) as exc_info: handler_all_fail.try_call_all(lambda w: w.do_something()) assert "All wrappers failed" in str(exc_info.value) def test_wrappers_with_parameters(self): - wrappers = [FailingWrapperWithParameters, MockWrapperWithParameters] - handler: WrapperHandler[MockWrapperWithParameters] = WrapperHandler.build_wrappers(wrappers, try_per_wrapper=2, retry_delay=0) + wrappers: list[type[MockWrapperWithParameters]] = [FailingWrapperWithParameters, MockWrapperWithParameters] + handler = WrapperHandler.build_wrappers(wrappers, try_per_wrapper=2, retry_delay=0) result = handler.try_call(lambda w: w.do_something("test", 42)) assert result == "Success test and 42" assert handler.index == 1 # Should have switched to the second wrapper - assert handler.retry_count == 0 def test_wrappers_with_parameters_all_fail(self): - wrappers = [FailingWrapperWithParameters, FailingWrapperWithParameters] - handler: WrapperHandler[MockWrapperWithParameters] = WrapperHandler.build_wrappers(wrappers, try_per_wrapper=1, retry_delay=0) + wrappers: list[type[MockWrapperWithParameters]] = [FailingWrapperWithParameters, FailingWrapperWithParameters] + handler = WrapperHandler.build_wrappers(wrappers, try_per_wrapper=1, retry_delay=0) with pytest.raises(Exception) as exc_info: handler.try_call(lambda w: w.do_something("test", 42)) assert "All wrappers failed" in str(exc_info.value) def test_try_call_all_with_parameters(self): - wrappers = [FailingWrapperWithParameters, MockWrapperWithParameters] - handler: WrapperHandler[MockWrapperWithParameters] = WrapperHandler.build_wrappers(wrappers, try_per_wrapper=1, retry_delay=0) + wrappers: list[type[MockWrapperWithParameters]] = [FailingWrapperWithParameters, MockWrapperWithParameters] + handler = WrapperHandler.build_wrappers(wrappers, try_per_wrapper=1, retry_delay=0) results = handler.try_call_all(lambda w: w.do_something("param", 99)) - assert results == {MockWrapperWithParameters: "Success param and 99"} + assert results == {MockWrapperWithParameters.__name__: "Success param and 99"} def test_try_call_all_with_parameters_all_fail(self): - wrappers = [FailingWrapperWithParameters, FailingWrapperWithParameters] - handler: WrapperHandler[MockWrapperWithParameters] = WrapperHandler.build_wrappers(wrappers, try_per_wrapper=1, retry_delay=0) + wrappers: list[type[MockWrapperWithParameters]] = [FailingWrapperWithParameters, FailingWrapperWithParameters] + handler = WrapperHandler.build_wrappers(wrappers, try_per_wrapper=1, retry_delay=0) with pytest.raises(Exception) as exc_info: handler.try_call_all(lambda w: w.do_something("param", 99)) assert "All wrappers failed" in str(exc_info.value) From 093a7f5a487f73a00fffa72720a929612422acb4 Mon Sep 17 00:00:00 2001 From: Giacomo Bertolazzi <31776951+Berack96@users.noreply.github.com> Date: Sat, 11 Oct 2025 21:36:13 +0200 Subject: [PATCH 2/7] Refactor project structure to organize APIs (#24) * Refactor project structure "api" * fix bug conversione delle valute fiat in stablecoin in BinanceWrapper * Refactor: WrapperHandler for managing API wrappers with retry logic; update related modules and tests * Refactor: Update ProductInfo and Price classes to include aggregation methods; remove standalone aggregation functions * fix docs --- README.md | 11 +- src/app/agents/pipeline.py | 2 +- src/app/agents/predictor.py | 2 +- src/app/agents/team.py | 6 +- src/app/{base => api}/__init__.py | 0 src/app/api/base/__init__.py | 0 src/app/api/base/markets.py | 152 +++++++++++++++++++++ src/app/{ => api}/base/news.py | 8 +- src/app/{ => api}/base/social.py | 9 +- src/app/{ => api}/markets/__init__.py | 28 ++-- src/app/{ => api}/markets/binance.py | 19 ++- src/app/{ => api}/markets/coinbase.py | 2 +- src/app/{ => api}/markets/cryptocompare.py | 2 +- src/app/{ => api}/markets/yfinance.py | 2 +- src/app/{ => api}/news/__init__.py | 22 +-- src/app/{ => api}/news/cryptopanic_api.py | 2 +- src/app/{ => api}/news/duckduckgo.py | 2 +- src/app/{ => api}/news/googlenews.py | 2 +- src/app/{ => api}/news/news_api.py | 2 +- src/app/{ => api}/social/__init__.py | 12 +- src/app/{ => api}/social/reddit.py | 2 +- src/app/{utils => api}/wrapper_handler.py | 0 src/app/base/markets.py | 83 ----------- src/app/utils/__init__.py | 4 +- src/app/utils/market_aggregation.py | 65 --------- tests/agents/test_predictor.py | 2 +- tests/api/test_binance.py | 30 +++- tests/api/test_coinbase.py | 2 +- tests/api/test_cryptocompare.py | 2 +- tests/api/test_cryptopanic_api.py | 2 +- tests/api/test_duckduckgo_news.py | 2 +- tests/api/test_google_news.py | 2 +- tests/api/test_news_api.py | 2 +- tests/api/test_reddit.py | 2 +- tests/api/test_yfinance.py | 2 +- tests/tools/test_market_tool.py | 2 +- tests/tools/test_news_tool.py | 10 +- tests/tools/test_socials_tool.py | 8 +- tests/utils/test_market_aggregator.py | 13 +- tests/utils/test_wrapper_handler.py | 2 +- 40 files changed, 284 insertions(+), 238 deletions(-) rename src/app/{base => api}/__init__.py (100%) create mode 100644 src/app/api/base/__init__.py create mode 100644 src/app/api/base/markets.py rename src/app/{ => api}/base/news.py (77%) rename src/app/{ => api}/base/social.py (69%) rename src/app/{ => api}/markets/__init__.py (75%) rename src/app/{ => api}/markets/binance.py (83%) rename src/app/{ => api}/markets/coinbase.py (98%) rename src/app/{ => api}/markets/cryptocompare.py (97%) rename src/app/{ => api}/markets/yfinance.py (97%) rename src/app/{ => api}/news/__init__.py (79%) rename src/app/{ => api}/news/cryptopanic_api.py (98%) rename src/app/{ => api}/news/duckduckgo.py (96%) rename src/app/{ => api}/news/googlenews.py (96%) rename src/app/{ => api}/news/news_api.py (97%) rename src/app/{ => api}/social/__init__.py (82%) rename src/app/{ => api}/social/reddit.py (96%) rename src/app/{utils => api}/wrapper_handler.py (100%) delete mode 100644 src/app/base/markets.py delete mode 100644 src/app/utils/market_aggregation.py diff --git a/README.md b/README.md index aae9a60..ea778b6 100644 --- a/README.md +++ b/README.md @@ -86,7 +86,7 @@ uv pip install -e . A questo punto si può già modificare il codice e, quando necessario, far partire il progetto tramite il comando: ```sh -uv run python src/app +uv run src/app ``` # **Applicazione** @@ -106,10 +106,11 @@ src └── app ├── __main__.py ├── agents <-- Agenti, modelli, prompts e simili - ├── base <-- Classi base per le API - ├── markets <-- Market data provider (Es. Binance) - ├── news <-- News data provider (Es. NewsAPI) - ├── social <-- Social data provider (Es. Reddit) + ├── api <-- Tutte le API esterne + │ ├── base <-- Classi base per le API + │ ├── markets <-- Market data provider (Es. Binance) + │ ├── news <-- News data provider (Es. NewsAPI) + │ └── social <-- Social data provider (Es. Reddit) └── utils <-- Codice di utilità generale ``` diff --git a/src/app/agents/pipeline.py b/src/app/agents/pipeline.py index a7d1001..ce32c06 100644 --- a/src/app/agents/pipeline.py +++ b/src/app/agents/pipeline.py @@ -2,7 +2,7 @@ from agno.run.agent import RunOutput from app.agents.models import AppModels from app.agents.team import create_team_with from app.agents.predictor import PREDICTOR_INSTRUCTIONS, PredictorInput, PredictorOutput, PredictorStyle -from app.base.markets import ProductInfo +from app.api.base.markets import ProductInfo class Pipeline: diff --git a/src/app/agents/predictor.py b/src/app/agents/predictor.py index 69a92af..4c5bb1a 100644 --- a/src/app/agents/predictor.py +++ b/src/app/agents/predictor.py @@ -1,6 +1,6 @@ from enum import Enum from pydantic import BaseModel, Field -from app.base.markets import ProductInfo +from app.api.base.markets import ProductInfo class PredictorStyle(Enum): diff --git a/src/app/agents/team.py b/src/app/agents/team.py index 27b9cae..04bcab6 100644 --- a/src/app/agents/team.py +++ b/src/app/agents/team.py @@ -1,8 +1,8 @@ from agno.team import Team from app.agents import AppModels -from app.markets import MarketAPIsTool -from app.news import NewsAPIsTool -from app.social import SocialAPIsTool +from app.api.markets import MarketAPIsTool +from app.api.news import NewsAPIsTool +from app.api.social import SocialAPIsTool def create_team_with(models: AppModels, coordinator: AppModels | None = None) -> Team: diff --git a/src/app/base/__init__.py b/src/app/api/__init__.py similarity index 100% rename from src/app/base/__init__.py rename to src/app/api/__init__.py diff --git a/src/app/api/base/__init__.py b/src/app/api/base/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/src/app/api/base/markets.py b/src/app/api/base/markets.py new file mode 100644 index 0000000..8b6c754 --- /dev/null +++ b/src/app/api/base/markets.py @@ -0,0 +1,152 @@ +import statistics +from datetime import datetime +from pydantic import BaseModel + + +class ProductInfo(BaseModel): + """ + Product information as obtained from market APIs. + Implements conversion methods from raw API data. + """ + id: str = "" + symbol: str = "" + price: float = 0.0 + volume_24h: float = 0.0 + currency: str = "" + + @staticmethod + def aggregate(products: dict[str, list['ProductInfo']]) -> list['ProductInfo']: + """ + Aggregates a list of ProductInfo by symbol. + Args: + products (dict[str, list[ProductInfo]]): Map provider -> list of ProductInfo + Returns: + list[ProductInfo]: List of ProductInfo aggregated by symbol + """ + + # Costruzione mappa symbol -> lista di ProductInfo + symbols_infos: dict[str, list[ProductInfo]] = {} + for _, product_list in products.items(): + for product in product_list: + symbols_infos.setdefault(product.symbol, []).append(product) + + # Aggregazione per ogni symbol + aggregated_products: list[ProductInfo] = [] + for symbol, product_list in symbols_infos.items(): + product = ProductInfo() + + product.id = f"{symbol}_AGGREGATED" + product.symbol = symbol + product.currency = next(p.currency for p in product_list if p.currency) + + volume_sum = sum(p.volume_24h for p in product_list) + product.volume_24h = volume_sum / len(product_list) if product_list else 0.0 + + prices = sum(p.price * p.volume_24h for p in product_list) + product.price = (prices / volume_sum) if volume_sum > 0 else 0.0 + + aggregated_products.append(product) + return aggregated_products + + + +class Price(BaseModel): + """ + Represents price data for an asset as obtained from market APIs. + Implements conversion methods from raw API data. + """ + high: float = 0.0 + low: float = 0.0 + open: float = 0.0 + close: float = 0.0 + volume: float = 0.0 + timestamp: str = "" + """Timestamp in format YYYY-MM-DD HH:MM""" + + def set_timestamp(self, timestamp_ms: int | None = None, timestamp_s: int | None = None) -> None: + """ + Sets the timestamp from milliseconds or seconds. + The timestamp is saved as a formatted string 'YYYY-MM-DD HH:MM'. + Args: + timestamp_ms: Timestamp in milliseconds. + timestamp_s: Timestamp in seconds. + Raises: + ValueError: If neither timestamp_ms nor timestamp_s is provided. + """ + if timestamp_ms is not None: + timestamp = timestamp_ms // 1000 + elif timestamp_s is not None: + timestamp = timestamp_s + else: + raise ValueError("Either timestamp_ms or timestamp_s must be provided") + assert timestamp > 0, "Invalid timestamp data received" + + self.timestamp = datetime.fromtimestamp(timestamp).strftime('%Y-%m-%d %H:%M') + + @staticmethod + def aggregate(prices: dict[str, list['Price']]) -> list['Price']: + """ + Aggregates historical prices for the same symbol by calculating the mean. + Args: + prices (dict[str, list[Price]]): Map provider -> list of Price. + The map must contain only Price objects for the same symbol. + Returns: + list[Price]: List of Price objects aggregated by timestamp. + """ + + # Costruiamo una mappa timestamp -> lista di Price + timestamped_prices: dict[str, list[Price]] = {} + for _, price_list in prices.items(): + for price in price_list: + timestamped_prices.setdefault(price.timestamp, []).append(price) + + # Ora aggregiamo i prezzi per ogni timestamp + aggregated_prices: list[Price] = [] + for time, price_list in timestamped_prices.items(): + price = Price() + price.timestamp = time + price.high = statistics.mean([p.high for p in price_list]) + price.low = statistics.mean([p.low for p in price_list]) + price.open = statistics.mean([p.open for p in price_list]) + price.close = statistics.mean([p.close for p in price_list]) + price.volume = statistics.mean([p.volume for p in price_list]) + aggregated_prices.append(price) + return aggregated_prices + +class MarketWrapper: + """ + Base class for market API wrappers. + All market API wrappers should inherit from this class and implement the methods. + Provides interface for retrieving product and price information from market APIs. + """ + + def get_product(self, asset_id: str) -> ProductInfo: + """ + Get product information for a specific asset ID. + Args: + asset_id (str): The asset ID to retrieve information for. + Returns: + ProductInfo: An object containing product information. + """ + raise NotImplementedError("This method should be overridden by subclasses") + + def get_products(self, asset_ids: list[str]) -> list[ProductInfo]: + """ + Get product information for multiple asset IDs. + Args: + asset_ids (list[str]): The list of asset IDs to retrieve information for. + Returns: + list[ProductInfo]: A list of objects containing product information. + """ + raise NotImplementedError("This method should be overridden by subclasses") + + def get_historical_prices(self, asset_id: str, limit: int = 100) -> list[Price]: + """ + Get historical price data for a specific asset ID. + Args: + asset_id (str): The asset ID to retrieve price data for. + limit (int): The maximum number of price data points to return. + Returns: + list[Price]: A list of Price objects. + """ + raise NotImplementedError("This method should be overridden by subclasses") diff --git a/src/app/base/news.py b/src/app/api/base/news.py similarity index 77% rename from src/app/base/news.py rename to src/app/api/base/news.py index 8a0d51e..1f67999 100644 --- a/src/app/base/news.py +++ b/src/app/api/base/news.py @@ -2,6 +2,9 @@ from pydantic import BaseModel class Article(BaseModel): + """ + Represents a news article with source, time, title, and description. + """ source: str = "" time: str = "" title: str = "" @@ -11,11 +14,12 @@ class NewsWrapper: """ Base class for news API wrappers. All news API wrappers should inherit from this class and implement the methods. + Provides interface for retrieving news articles from news APIs. """ def get_top_headlines(self, limit: int = 100) -> list[Article]: """ - Get top headlines, optionally limited by limit. + Retrieve top headlines, optionally limited by the specified number. Args: limit (int): The maximum number of articles to return. Returns: @@ -25,7 +29,7 @@ class NewsWrapper: def get_latest_news(self, query: str, limit: int = 100) -> list[Article]: """ - Get latest news based on a query. + Retrieve the latest news based on a search query. Args: query (str): The search query. limit (int): The maximum number of articles to return. diff --git a/src/app/base/social.py b/src/app/api/base/social.py similarity index 69% rename from src/app/base/social.py rename to src/app/api/base/social.py index dd894f5..721ac0c 100644 --- a/src/app/base/social.py +++ b/src/app/api/base/social.py @@ -2,12 +2,18 @@ from pydantic import BaseModel class SocialPost(BaseModel): + """ + Represents a social media post with time, title, description, and comments. + """ time: str = "" title: str = "" description: str = "" comments: list["SocialComment"] = [] class SocialComment(BaseModel): + """ + Represents a comment on a social media post. + """ time: str = "" description: str = "" @@ -16,11 +22,12 @@ class SocialWrapper: """ Base class for social media API wrappers. All social media API wrappers should inherit from this class and implement the methods. + Provides interface for retrieving social media posts and comments from APIs. """ def get_top_crypto_posts(self, limit: int = 5) -> list[SocialPost]: """ - Get top cryptocurrency-related posts, optionally limited by total. + Retrieve top cryptocurrency-related posts, optionally limited by the specified number. Args: limit (int): The maximum number of posts to return. Returns: diff --git a/src/app/markets/__init__.py b/src/app/api/markets/__init__.py similarity index 75% rename from src/app/markets/__init__.py rename to src/app/api/markets/__init__.py index bf2d344..9a48853 100644 --- a/src/app/markets/__init__.py +++ b/src/app/api/markets/__init__.py @@ -1,10 +1,10 @@ from agno.tools import Toolkit -from app.base.markets import MarketWrapper, Price, ProductInfo -from app.markets.binance import BinanceWrapper -from app.markets.coinbase import CoinBaseWrapper -from app.markets.cryptocompare import CryptoCompareWrapper -from app.markets.yfinance import YFinanceWrapper -from app.utils import aggregate_history_prices, aggregate_product_info, WrapperHandler +from app.api.wrapper_handler import WrapperHandler +from app.api.base.markets import MarketWrapper, Price, ProductInfo +from app.api.markets.binance import BinanceWrapper +from app.api.markets.coinbase import CoinBaseWrapper +from app.api.markets.cryptocompare import CryptoCompareWrapper +from app.api.markets.yfinance import YFinanceWrapper __all__ = [ "MarketAPIsTool", "BinanceWrapper", "CoinBaseWrapper", "CryptoCompareWrapper", "YFinanceWrapper", "ProductInfo", "Price" ] @@ -34,7 +34,7 @@ class MarketAPIsTool(MarketWrapper, Toolkit): """ kwargs = {"currency": currency or "USD"} wrappers: list[type[MarketWrapper]] = [BinanceWrapper, YFinanceWrapper, CoinBaseWrapper, CryptoCompareWrapper] - self.wrappers = WrapperHandler.build_wrappers(wrappers, kwargs=kwargs) + self.handler = WrapperHandler.build_wrappers(wrappers, kwargs=kwargs) Toolkit.__init__( # type: ignore self, @@ -49,11 +49,11 @@ class MarketAPIsTool(MarketWrapper, Toolkit): ) def get_product(self, asset_id: str) -> ProductInfo: - return self.wrappers.try_call(lambda w: w.get_product(asset_id)) + return self.handler.try_call(lambda w: w.get_product(asset_id)) def get_products(self, asset_ids: list[str]) -> list[ProductInfo]: - return self.wrappers.try_call(lambda w: w.get_products(asset_ids)) + return self.handler.try_call(lambda w: w.get_products(asset_ids)) def get_historical_prices(self, asset_id: str, limit: int = 100) -> list[Price]: - return self.wrappers.try_call(lambda w: w.get_historical_prices(asset_id, limit)) + return self.handler.try_call(lambda w: w.get_historical_prices(asset_id, limit)) def get_products_aggregated(self, asset_ids: list[str]) -> list[ProductInfo]: @@ -67,8 +67,8 @@ class MarketAPIsTool(MarketWrapper, Toolkit): Raises: Exception: If all wrappers fail to provide results. """ - all_products = self.wrappers.try_call_all(lambda w: w.get_products(asset_ids)) - return aggregate_product_info(all_products) + all_products = self.handler.try_call_all(lambda w: w.get_products(asset_ids)) + return ProductInfo.aggregate(all_products) def get_historical_prices_aggregated(self, asset_id: str = "BTC", limit: int = 100) -> list[Price]: """ @@ -82,5 +82,5 @@ class MarketAPIsTool(MarketWrapper, Toolkit): Raises: Exception: If all wrappers fail to provide results. """ - all_prices = self.wrappers.try_call_all(lambda w: w.get_historical_prices(asset_id, limit)) - return aggregate_history_prices(all_prices) + all_prices = self.handler.try_call_all(lambda w: w.get_historical_prices(asset_id, limit)) + return Price.aggregate(all_prices) diff --git a/src/app/markets/binance.py b/src/app/api/markets/binance.py similarity index 83% rename from src/app/markets/binance.py rename to src/app/api/markets/binance.py index ffd31bb..18b5f35 100644 --- a/src/app/markets/binance.py +++ b/src/app/api/markets/binance.py @@ -1,7 +1,7 @@ import os from typing import Any from binance.client import Client # type: ignore -from app.base.markets import ProductInfo, MarketWrapper, Price +from app.api.base.markets import ProductInfo, MarketWrapper, Price def extract_product(currency: str, ticker_data: dict[str, Any]) -> ProductInfo: @@ -25,6 +25,12 @@ def extract_price(kline_data: list[Any]) -> Price: price.set_timestamp(timestamp_ms=timestamp) return price + +# Add here eventual other fiat not supported by Binance +FIAT_TO_STABLECOIN = { + "USD": "USDT", +} + class BinanceWrapper(MarketWrapper): """ Wrapper per le API autenticate di Binance.\n @@ -36,16 +42,15 @@ class BinanceWrapper(MarketWrapper): def __init__(self, currency: str = "USD"): """ Inizializza il wrapper di Binance con le credenziali API e la valuta di riferimento. - Se viene fornita una valuta fiat come "USD", questa viene automaticamente convertita in una stablecoin Tether ("USDT") per compatibilità con Binance, - poiché Binance non supporta direttamente le valute fiat per il trading di criptovalute. - Tutti i prezzi e volumi restituiti saranno quindi denominati nella stablecoin (ad esempio, "USDT") e non nella valuta fiat originale. - Args: - currency (str): Valuta in cui restituire i prezzi. Se "USD" viene fornito, verrà utilizzato "USDT". Default è "USD". + Alcune valute fiat non sono supportate direttamente da Binance (es. "USD"). + Infatti, se viene fornita una valuta fiat come "USD", questa viene automaticamente convertita in una stablecoin Tether ("USDT") per compatibilità con Binance. + Args: + currency (str): Valuta in cui restituire i prezzi. Se "USD" viene fornito, verrà utilizzato "USDT". Default è "USD". """ api_key = os.getenv("BINANCE_API_KEY") api_secret = os.getenv("BINANCE_API_SECRET") - self.currency = f"{currency}T" + self.currency = currency if currency not in FIAT_TO_STABLECOIN else FIAT_TO_STABLECOIN[currency] self.client = Client(api_key=api_key, api_secret=api_secret) def __format_symbol(self, asset_id: str) -> str: diff --git a/src/app/markets/coinbase.py b/src/app/api/markets/coinbase.py similarity index 98% rename from src/app/markets/coinbase.py rename to src/app/api/markets/coinbase.py index c59382b..13016f6 100644 --- a/src/app/markets/coinbase.py +++ b/src/app/api/markets/coinbase.py @@ -3,7 +3,7 @@ from enum import Enum from datetime import datetime, timedelta from coinbase.rest import RESTClient # type: ignore from coinbase.rest.types.product_types import Candle, GetProductResponse, Product # type: ignore -from app.base.markets import ProductInfo, MarketWrapper, Price +from app.api.base.markets import ProductInfo, MarketWrapper, Price def extract_product(product_data: GetProductResponse | Product) -> ProductInfo: diff --git a/src/app/markets/cryptocompare.py b/src/app/api/markets/cryptocompare.py similarity index 97% rename from src/app/markets/cryptocompare.py rename to src/app/api/markets/cryptocompare.py index 5431267..a6c5d70 100644 --- a/src/app/markets/cryptocompare.py +++ b/src/app/api/markets/cryptocompare.py @@ -1,7 +1,7 @@ import os from typing import Any import requests -from app.base.markets import ProductInfo, MarketWrapper, Price +from app.api.base.markets import ProductInfo, MarketWrapper, Price def extract_product(asset_data: dict[str, Any]) -> ProductInfo: diff --git a/src/app/markets/yfinance.py b/src/app/api/markets/yfinance.py similarity index 97% rename from src/app/markets/yfinance.py rename to src/app/api/markets/yfinance.py index 2670eda..f63192e 100644 --- a/src/app/markets/yfinance.py +++ b/src/app/api/markets/yfinance.py @@ -1,6 +1,6 @@ import json from agno.tools.yfinance import YFinanceTools -from app.base.markets import MarketWrapper, ProductInfo, Price +from app.api.base.markets import MarketWrapper, ProductInfo, Price def extract_product(stock_data: dict[str, str]) -> ProductInfo: diff --git a/src/app/news/__init__.py b/src/app/api/news/__init__.py similarity index 79% rename from src/app/news/__init__.py rename to src/app/api/news/__init__.py index b0cb553..a66cf05 100644 --- a/src/app/news/__init__.py +++ b/src/app/api/news/__init__.py @@ -1,10 +1,10 @@ from agno.tools import Toolkit -from app.utils import WrapperHandler -from app.base.news import NewsWrapper, Article -from app.news.news_api import NewsApiWrapper -from app.news.googlenews import GoogleNewsWrapper -from app.news.cryptopanic_api import CryptoPanicWrapper -from app.news.duckduckgo import DuckDuckGoWrapper +from app.api.wrapper_handler import WrapperHandler +from app.api.base.news import NewsWrapper, Article +from app.api.news.news_api import NewsApiWrapper +from app.api.news.googlenews import GoogleNewsWrapper +from app.api.news.cryptopanic_api import CryptoPanicWrapper +from app.api.news.duckduckgo import DuckDuckGoWrapper __all__ = ["NewsAPIsTool", "NewsApiWrapper", "GoogleNewsWrapper", "CryptoPanicWrapper", "DuckDuckGoWrapper", "Article"] @@ -34,7 +34,7 @@ class NewsAPIsTool(NewsWrapper, Toolkit): - CryptoPanicWrapper. """ wrappers: list[type[NewsWrapper]] = [GoogleNewsWrapper, DuckDuckGoWrapper, NewsApiWrapper, CryptoPanicWrapper] - self.wrapper_handler = WrapperHandler.build_wrappers(wrappers) + self.handler = WrapperHandler.build_wrappers(wrappers) Toolkit.__init__( # type: ignore self, @@ -48,9 +48,9 @@ class NewsAPIsTool(NewsWrapper, Toolkit): ) def get_top_headlines(self, limit: int = 100) -> list[Article]: - return self.wrapper_handler.try_call(lambda w: w.get_top_headlines(limit)) + return self.handler.try_call(lambda w: w.get_top_headlines(limit)) def get_latest_news(self, query: str, limit: int = 100) -> list[Article]: - return self.wrapper_handler.try_call(lambda w: w.get_latest_news(query, limit)) + return self.handler.try_call(lambda w: w.get_latest_news(query, limit)) def get_top_headlines_aggregated(self, limit: int = 100) -> dict[str, list[Article]]: """ @@ -62,7 +62,7 @@ class NewsAPIsTool(NewsWrapper, Toolkit): Raises: Exception: If all wrappers fail to provide results. """ - return self.wrapper_handler.try_call_all(lambda w: w.get_top_headlines(limit)) + return self.handler.try_call_all(lambda w: w.get_top_headlines(limit)) def get_latest_news_aggregated(self, query: str, limit: int = 100) -> dict[str, list[Article]]: """ @@ -75,4 +75,4 @@ class NewsAPIsTool(NewsWrapper, Toolkit): Raises: Exception: If all wrappers fail to provide results. """ - return self.wrapper_handler.try_call_all(lambda w: w.get_latest_news(query, limit)) + return self.handler.try_call_all(lambda w: w.get_latest_news(query, limit)) diff --git a/src/app/news/cryptopanic_api.py b/src/app/api/news/cryptopanic_api.py similarity index 98% rename from src/app/news/cryptopanic_api.py rename to src/app/api/news/cryptopanic_api.py index 1e16078..b1810b7 100644 --- a/src/app/news/cryptopanic_api.py +++ b/src/app/api/news/cryptopanic_api.py @@ -2,7 +2,7 @@ import os from typing import Any import requests from enum import Enum -from app.base.news import NewsWrapper, Article +from app.api.base.news import NewsWrapper, Article class CryptoPanicFilter(Enum): diff --git a/src/app/news/duckduckgo.py b/src/app/api/news/duckduckgo.py similarity index 96% rename from src/app/news/duckduckgo.py rename to src/app/api/news/duckduckgo.py index 8108239..d854a2d 100644 --- a/src/app/news/duckduckgo.py +++ b/src/app/api/news/duckduckgo.py @@ -1,7 +1,7 @@ import json from typing import Any from agno.tools.duckduckgo import DuckDuckGoTools -from app.base.news import Article, NewsWrapper +from app.api.base.news import Article, NewsWrapper def extract_article(result: dict[str, Any]) -> Article: diff --git a/src/app/news/googlenews.py b/src/app/api/news/googlenews.py similarity index 96% rename from src/app/news/googlenews.py rename to src/app/api/news/googlenews.py index 0041c7f..613484f 100644 --- a/src/app/news/googlenews.py +++ b/src/app/api/news/googlenews.py @@ -1,6 +1,6 @@ from typing import Any from gnews import GNews # type: ignore -from app.base.news import Article, NewsWrapper +from app.api.base.news import Article, NewsWrapper def extract_article(result: dict[str, Any]) -> Article: diff --git a/src/app/news/news_api.py b/src/app/api/news/news_api.py similarity index 97% rename from src/app/news/news_api.py rename to src/app/api/news/news_api.py index b5bf375..3c229f3 100644 --- a/src/app/news/news_api.py +++ b/src/app/api/news/news_api.py @@ -1,7 +1,7 @@ import os from typing import Any import newsapi # type: ignore -from app.base.news import Article, NewsWrapper +from app.api.base.news import Article, NewsWrapper def extract_article(result: dict[str, Any]) -> Article: diff --git a/src/app/social/__init__.py b/src/app/api/social/__init__.py similarity index 82% rename from src/app/social/__init__.py rename to src/app/api/social/__init__.py index 261bcba..69d4331 100644 --- a/src/app/social/__init__.py +++ b/src/app/api/social/__init__.py @@ -1,7 +1,7 @@ from agno.tools import Toolkit -from app.utils import WrapperHandler -from app.base.social import SocialPost, SocialWrapper -from app.social.reddit import RedditWrapper +from app.api.wrapper_handler import WrapperHandler +from app.api.base.social import SocialPost, SocialWrapper +from app.api.social.reddit import RedditWrapper __all__ = ["SocialAPIsTool", "RedditWrapper", "SocialPost"] @@ -26,7 +26,7 @@ class SocialAPIsTool(SocialWrapper, Toolkit): """ wrappers: list[type[SocialWrapper]] = [RedditWrapper] - self.wrapper_handler = WrapperHandler.build_wrappers(wrappers) + self.handler = WrapperHandler.build_wrappers(wrappers) Toolkit.__init__( # type: ignore self, @@ -38,7 +38,7 @@ class SocialAPIsTool(SocialWrapper, Toolkit): ) 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)) + return self.handler.try_call(lambda w: w.get_top_crypto_posts(limit)) def get_top_crypto_posts_aggregated(self, limit_per_wrapper: int = 5) -> dict[str, list[SocialPost]]: """ @@ -50,4 +50,4 @@ class SocialAPIsTool(SocialWrapper, Toolkit): Raises: Exception: If all wrappers fail to provide results. """ - return self.wrapper_handler.try_call_all(lambda w: w.get_top_crypto_posts(limit_per_wrapper)) + return self.handler.try_call_all(lambda w: w.get_top_crypto_posts(limit_per_wrapper)) diff --git a/src/app/social/reddit.py b/src/app/api/social/reddit.py similarity index 96% rename from src/app/social/reddit.py rename to src/app/api/social/reddit.py index eeca968..e098ee3 100644 --- a/src/app/social/reddit.py +++ b/src/app/api/social/reddit.py @@ -1,7 +1,7 @@ import os from praw import Reddit # type: ignore from praw.models import Submission # type: ignore -from app.base.social import SocialWrapper, SocialPost, SocialComment +from app.api.base.social import SocialWrapper, SocialPost, SocialComment MAX_COMMENTS = 5 diff --git a/src/app/utils/wrapper_handler.py b/src/app/api/wrapper_handler.py similarity index 100% rename from src/app/utils/wrapper_handler.py rename to src/app/api/wrapper_handler.py diff --git a/src/app/base/markets.py b/src/app/base/markets.py deleted file mode 100644 index cd00879..0000000 --- a/src/app/base/markets.py +++ /dev/null @@ -1,83 +0,0 @@ -from datetime import datetime -from pydantic import BaseModel - - -class ProductInfo(BaseModel): - """ - Informazioni sul prodotto, come ottenute dalle API di mercato. - Implementa i metodi di conversione dai dati grezzi delle API. - """ - id: str = "" - symbol: str = "" - price: float = 0.0 - volume_24h: float = 0.0 - currency: str = "" - -class Price(BaseModel): - """ - Rappresenta i dati di prezzo per un asset, come ottenuti dalle API di mercato. - Implementa i metodi di conversione dai dati grezzi delle API. - """ - high: float = 0.0 - low: float = 0.0 - open: float = 0.0 - close: float = 0.0 - volume: float = 0.0 - timestamp: str = "" - """Timestamp con formato YYYY-MM-DD HH:MM""" - - def set_timestamp(self, timestamp_ms: int | None = None, timestamp_s: int | None = None) -> None: - """ - Imposta il timestamp a partire da millisecondi o secondi. - IL timestamp viene salvato come stringa formattata 'YYYY-MM-DD HH:MM'. - Args: - timestamp_ms: Timestamp in millisecondi. - timestamp_s: Timestamp in secondi. - Raises: - """ - if timestamp_ms is not None: - timestamp = timestamp_ms // 1000 - elif timestamp_s is not None: - timestamp = timestamp_s - else: - raise ValueError("Either timestamp_ms or timestamp_s must be provided") - assert timestamp > 0, "Invalid timestamp data received" - - self.timestamp = datetime.fromtimestamp(timestamp).strftime('%Y-%m-%d %H:%M') - -class MarketWrapper: - """ - Base class for market API wrappers. - All market API wrappers should inherit from this class and implement the methods. - """ - - def get_product(self, asset_id: str) -> ProductInfo: - """ - Get product information for a specific asset ID. - Args: - asset_id (str): The asset ID to retrieve information for. - Returns: - ProductInfo: An object containing product information. - """ - raise NotImplementedError("This method should be overridden by subclasses") - - def get_products(self, asset_ids: list[str]) -> list[ProductInfo]: - """ - Get product information for multiple asset IDs. - Args: - asset_ids (list[str]): The list of asset IDs to retrieve information for. - Returns: - list[ProductInfo]: A list of objects containing product information. - """ - raise NotImplementedError("This method should be overridden by subclasses") - - def get_historical_prices(self, asset_id: str, limit: int = 100) -> list[Price]: - """ - Get historical price data for a specific asset ID. - Args: - asset_id (str): The asset ID to retrieve price data for. - limit (int): The maximum number of price data points to return. - Returns: - list[Price]: A list of Price objects. - """ - raise NotImplementedError("This method should be overridden by subclasses") diff --git a/src/app/utils/__init__.py b/src/app/utils/__init__.py index 1a511c1..579b141 100644 --- a/src/app/utils/__init__.py +++ b/src/app/utils/__init__.py @@ -1,5 +1,3 @@ -from app.utils.market_aggregation import aggregate_history_prices, aggregate_product_info -from app.utils.wrapper_handler import WrapperHandler from app.utils.chat_manager import ChatManager -__all__ = ["aggregate_history_prices", "aggregate_product_info", "WrapperHandler", "ChatManager"] +__all__ = ["ChatManager"] diff --git a/src/app/utils/market_aggregation.py b/src/app/utils/market_aggregation.py deleted file mode 100644 index 7f9f32c..0000000 --- a/src/app/utils/market_aggregation.py +++ /dev/null @@ -1,65 +0,0 @@ -import statistics -from app.base.markets import ProductInfo, Price - - -def aggregate_history_prices(prices: dict[str, list[Price]]) -> list[Price]: - """ - Aggrega i prezzi storici per symbol calcolando la media. - Args: - prices (dict[str, list[Price]]): Mappa provider -> lista di Price - Returns: - list[Price]: Lista di Price aggregati per timestamp - """ - - # Costruiamo una mappa timestamp -> lista di Price - timestamped_prices: dict[str, list[Price]] = {} - for _, price_list in prices.items(): - for price in price_list: - timestamped_prices.setdefault(price.timestamp, []).append(price) - - # Ora aggregiamo i prezzi per ogni timestamp - aggregated_prices: list[Price] = [] - for time, price_list in timestamped_prices.items(): - price = Price() - price.timestamp = time - price.high = statistics.mean([p.high for p in price_list]) - price.low = statistics.mean([p.low for p in price_list]) - price.open = statistics.mean([p.open for p in price_list]) - price.close = statistics.mean([p.close for p in price_list]) - price.volume = statistics.mean([p.volume for p in price_list]) - aggregated_prices.append(price) - return aggregated_prices - -def aggregate_product_info(products: dict[str, list[ProductInfo]]) -> list[ProductInfo]: - """ - Aggrega una lista di ProductInfo per symbol. - Args: - products (dict[str, list[ProductInfo]]): Mappa provider -> lista di ProductInfo - Returns: - list[ProductInfo]: Lista di ProductInfo aggregati per symbol - """ - - # Costruzione mappa symbol -> lista di ProductInfo - symbols_infos: dict[str, list[ProductInfo]] = {} - for _, product_list in products.items(): - for product in product_list: - symbols_infos.setdefault(product.symbol, []).append(product) - - # Aggregazione per ogni symbol - aggregated_products: list[ProductInfo] = [] - for symbol, product_list in symbols_infos.items(): - product = ProductInfo() - - product.id = f"{symbol}_AGGREGATED" - product.symbol = symbol - product.currency = next(p.currency for p in product_list if p.currency) - - volume_sum = sum(p.volume_24h for p in product_list) - product.volume_24h = volume_sum / len(product_list) if product_list else 0.0 - - prices = sum(p.price * p.volume_24h for p in product_list) - product.price = (prices / volume_sum) if volume_sum > 0 else 0.0 - - aggregated_products.append(product) - return aggregated_products - diff --git a/tests/agents/test_predictor.py b/tests/agents/test_predictor.py index 9a2ac11..2dda67e 100644 --- a/tests/agents/test_predictor.py +++ b/tests/agents/test_predictor.py @@ -1,7 +1,7 @@ import pytest from app.agents import AppModels from app.agents.predictor import PREDICTOR_INSTRUCTIONS, PredictorInput, PredictorOutput, PredictorStyle -from app.base.markets import ProductInfo +from app.api.base.markets import ProductInfo def unified_checks(model: AppModels, input: PredictorInput) -> None: llm = model.get_agent(PREDICTOR_INSTRUCTIONS, output_schema=PredictorOutput) # type: ignore[arg-type] diff --git a/tests/api/test_binance.py b/tests/api/test_binance.py index b4ea0bb..4fee373 100644 --- a/tests/api/test_binance.py +++ b/tests/api/test_binance.py @@ -1,5 +1,18 @@ import pytest -from app.markets.binance import BinanceWrapper +import asyncio +from app.api.markets.binance import BinanceWrapper + +# fix warning about no event loop +@pytest.fixture(scope="session", autouse=True) +def event_loop(): + """ + Ensure there is an event loop for the duration of the tests. + """ + loop = asyncio.new_event_loop() + asyncio.set_event_loop(loop) + yield loop + loop.close() + @pytest.mark.market @pytest.mark.api @@ -51,3 +64,18 @@ class TestBinance: assert entry.close > 0 assert entry.high > 0 assert entry.timestamp != '' + + def test_binance_fiat_conversion(self): + market = BinanceWrapper(currency="USD") + assert market.currency == "USDT" + product = market.get_product("BTC") + assert product is not None + assert product.symbol == "BTC" + assert product.price > 0 + + market = BinanceWrapper(currency="EUR") + assert market.currency == "EUR" + product = market.get_product("BTC") + assert product is not None + assert product.symbol == "BTC" + assert product.price > 0 diff --git a/tests/api/test_coinbase.py b/tests/api/test_coinbase.py index e114f4c..f022375 100644 --- a/tests/api/test_coinbase.py +++ b/tests/api/test_coinbase.py @@ -1,6 +1,6 @@ import os import pytest -from app.markets import CoinBaseWrapper +from app.api.markets import CoinBaseWrapper @pytest.mark.market @pytest.mark.api diff --git a/tests/api/test_cryptocompare.py b/tests/api/test_cryptocompare.py index 23deaf3..689a732 100644 --- a/tests/api/test_cryptocompare.py +++ b/tests/api/test_cryptocompare.py @@ -1,6 +1,6 @@ import os import pytest -from app.markets import CryptoCompareWrapper +from app.api.markets import CryptoCompareWrapper @pytest.mark.market @pytest.mark.api diff --git a/tests/api/test_cryptopanic_api.py b/tests/api/test_cryptopanic_api.py index 3c29bdb..51015f8 100644 --- a/tests/api/test_cryptopanic_api.py +++ b/tests/api/test_cryptopanic_api.py @@ -1,6 +1,6 @@ import os import pytest -from app.news import CryptoPanicWrapper +from app.api.news import CryptoPanicWrapper @pytest.mark.limited diff --git a/tests/api/test_duckduckgo_news.py b/tests/api/test_duckduckgo_news.py index f1de9c6..34eb362 100644 --- a/tests/api/test_duckduckgo_news.py +++ b/tests/api/test_duckduckgo_news.py @@ -1,5 +1,5 @@ import pytest -from app.news import DuckDuckGoWrapper +from app.api.news import DuckDuckGoWrapper @pytest.mark.news diff --git a/tests/api/test_google_news.py b/tests/api/test_google_news.py index 0b7241c..7b02ed8 100644 --- a/tests/api/test_google_news.py +++ b/tests/api/test_google_news.py @@ -1,5 +1,5 @@ import pytest -from app.news import GoogleNewsWrapper +from app.api.news import GoogleNewsWrapper @pytest.mark.news diff --git a/tests/api/test_news_api.py b/tests/api/test_news_api.py index 839941c..30508d6 100644 --- a/tests/api/test_news_api.py +++ b/tests/api/test_news_api.py @@ -1,6 +1,6 @@ import os import pytest -from app.news import NewsApiWrapper +from app.api.news import NewsApiWrapper @pytest.mark.news diff --git a/tests/api/test_reddit.py b/tests/api/test_reddit.py index 3e42eb6..d4533a5 100644 --- a/tests/api/test_reddit.py +++ b/tests/api/test_reddit.py @@ -1,6 +1,6 @@ import os import pytest -from app.social.reddit import MAX_COMMENTS, RedditWrapper +from app.api.social.reddit import MAX_COMMENTS, RedditWrapper @pytest.mark.social @pytest.mark.api diff --git a/tests/api/test_yfinance.py b/tests/api/test_yfinance.py index fa4174a..1f443d4 100644 --- a/tests/api/test_yfinance.py +++ b/tests/api/test_yfinance.py @@ -1,5 +1,5 @@ import pytest -from app.markets import YFinanceWrapper +from app.api.markets import YFinanceWrapper @pytest.mark.market @pytest.mark.api diff --git a/tests/tools/test_market_tool.py b/tests/tools/test_market_tool.py index 674707f..5e28edd 100644 --- a/tests/tools/test_market_tool.py +++ b/tests/tools/test_market_tool.py @@ -1,5 +1,5 @@ import pytest -from app.markets import MarketAPIsTool +from app.api.markets import MarketAPIsTool @pytest.mark.tools diff --git a/tests/tools/test_news_tool.py b/tests/tools/test_news_tool.py index 3b8254f..5f685a8 100644 --- a/tests/tools/test_news_tool.py +++ b/tests/tools/test_news_tool.py @@ -1,5 +1,5 @@ import pytest -from app.news import NewsAPIsTool +from app.api.news import NewsAPIsTool @pytest.mark.tools @@ -12,7 +12,7 @@ class TestNewsAPITool: def test_news_api_tool_get_top(self): tool = NewsAPIsTool() - result = tool.wrapper_handler.try_call(lambda w: w.get_top_headlines(limit=2)) + result = tool.handler.try_call(lambda w: w.get_top_headlines(limit=2)) assert isinstance(result, list) assert len(result) > 0 for article in result: @@ -21,7 +21,7 @@ class TestNewsAPITool: def test_news_api_tool_get_latest(self): tool = NewsAPIsTool() - result = tool.wrapper_handler.try_call(lambda w: w.get_latest_news(query="crypto", limit=2)) + result = tool.handler.try_call(lambda w: w.get_latest_news(query="crypto", limit=2)) assert isinstance(result, list) assert len(result) > 0 for article in result: @@ -30,7 +30,7 @@ class TestNewsAPITool: 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(limit=2)) + result = tool.handler.try_call_all(lambda w: w.get_top_headlines(limit=2)) assert isinstance(result, dict) assert len(result.keys()) > 0 for _provider, articles in result.items(): @@ -40,7 +40,7 @@ class TestNewsAPITool: 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", limit=2)) + result = tool.handler.try_call_all(lambda w: w.get_latest_news(query="crypto", limit=2)) assert isinstance(result, dict) assert len(result.keys()) > 0 for _provider, articles in result.items(): diff --git a/tests/tools/test_socials_tool.py b/tests/tools/test_socials_tool.py index d08ed0f..29a81ae 100644 --- a/tests/tools/test_socials_tool.py +++ b/tests/tools/test_socials_tool.py @@ -1,5 +1,5 @@ import pytest -from app.social import SocialAPIsTool +from app.api.social import SocialAPIsTool @pytest.mark.tools @@ -12,7 +12,7 @@ class TestSocialAPIsTool: def test_social_api_tool_get_top(self): tool = SocialAPIsTool() - result = tool.wrapper_handler.try_call(lambda w: w.get_top_crypto_posts(limit=2)) + result = tool.handler.try_call(lambda w: w.get_top_crypto_posts(limit=2)) assert isinstance(result, list) assert len(result) > 0 for post in result: @@ -21,10 +21,10 @@ class TestSocialAPIsTool: def test_social_api_tool_get_top__all_results(self): tool = SocialAPIsTool() - result = tool.wrapper_handler.try_call_all(lambda w: w.get_top_crypto_posts(limit=2)) + result = tool.handler.try_call_all(lambda w: w.get_top_crypto_posts(limit=2)) assert isinstance(result, dict) assert len(result.keys()) > 0 - for provider, posts in result.items(): + for _provider, posts in result.items(): for post in posts: assert post.title is not None assert post.time is not None diff --git a/tests/utils/test_market_aggregator.py b/tests/utils/test_market_aggregator.py index 35e3084..8c6ea18 100644 --- a/tests/utils/test_market_aggregator.py +++ b/tests/utils/test_market_aggregator.py @@ -1,7 +1,6 @@ import pytest from datetime import datetime -from app.base.markets import ProductInfo, Price -from app.utils.market_aggregation import aggregate_history_prices, aggregate_product_info +from app.api.base.markets import ProductInfo, Price @pytest.mark.aggregator @@ -34,7 +33,7 @@ class TestMarketDataAggregator: "Provider3": [self.__product("BTC", 49900.0, 900.0, "USD")], } - aggregated = aggregate_product_info(products) + aggregated = ProductInfo.aggregate(products) assert len(aggregated) == 1 info = aggregated[0] @@ -58,7 +57,7 @@ class TestMarketDataAggregator: ], } - aggregated = aggregate_product_info(products) + aggregated = ProductInfo.aggregate(products) assert len(aggregated) == 2 btc_info = next((p for p in aggregated if p.symbol == "BTC"), None) @@ -81,7 +80,7 @@ class TestMarketDataAggregator: "Provider1": [], "Provider2": [], } - aggregated = aggregate_product_info(products) + aggregated = ProductInfo.aggregate(products) assert len(aggregated) == 0 def test_aggregate_product_info_with_partial_data(self): @@ -89,7 +88,7 @@ class TestMarketDataAggregator: "Provider1": [self.__product("BTC", 50000.0, 1000.0, "USD")], "Provider2": [], } - aggregated = aggregate_product_info(products) + aggregated = ProductInfo.aggregate(products) assert len(aggregated) == 1 info = aggregated[0] assert info.symbol == "BTC" @@ -120,7 +119,7 @@ class TestMarketDataAggregator: price.set_timestamp(timestamp_s=timestamp_2h_ago) timestamp_2h_ago = price.timestamp - aggregated = aggregate_history_prices(prices) + aggregated = Price.aggregate(prices) assert len(aggregated) == 2 assert aggregated[0].timestamp == timestamp_1h_ago assert aggregated[0].high == pytest.approx(50050.0, rel=1e-3) # type: ignore diff --git a/tests/utils/test_wrapper_handler.py b/tests/utils/test_wrapper_handler.py index c6094a1..86922ab 100644 --- a/tests/utils/test_wrapper_handler.py +++ b/tests/utils/test_wrapper_handler.py @@ -1,5 +1,5 @@ import pytest -from app.utils.wrapper_handler import WrapperHandler +from app.api.wrapper_handler import WrapperHandler class MockWrapper: def do_something(self) -> str: From 862525cc62369a02390471bfe86dd8706e11406f Mon Sep 17 00:00:00 2001 From: Giacomo Bertolazzi <31776951+Berack96@users.noreply.github.com> Date: Sun, 12 Oct 2025 18:05:43 +0200 Subject: [PATCH 3/7] Configurazioni dell'app (#27) * Prompt messi in una cartella apposta * Aggiorna importazioni demo per riflettere la nuova struttura delle cartelle API * Aggiunto configurazione dell'applicazione * Spostato ChatManager in app.interface * Update README.md * Aggiornato config per app & api * Rinominato il modulo NewsAPI * fix main infinite loop * API base --> core * pattern singleton per AppConfig. * Estratto i tools nella loro cartella --> api/tools * fix main KeyboardInterrupt * update tests * Docker & libs * fix copilot suggestions --- Dockerfile | 1 + README.md | 26 +- configs.yaml | 45 ++++ demos/market_providers_api_demo.py | 2 +- demos/news_api.py | 2 +- pyproject.toml | 1 + src/app/__main__.py | 21 +- src/app/agents/__init__.py | 5 +- src/app/agents/models.py | 107 -------- src/app/agents/pipeline.py | 40 +-- src/app/agents/predictor.py | 41 +--- src/app/agents/prompts/__init__.py | 21 ++ src/app/agents/prompts/predictor.txt | 27 ++ src/app/agents/prompts/team_leader.txt | 15 ++ src/app/agents/prompts/team_market.txt | 19 ++ src/app/agents/prompts/team_news.txt | 18 ++ src/app/agents/prompts/team_social.txt | 15 ++ src/app/agents/team.py | 116 ++------- src/app/api/{base => core}/__init__.py | 0 src/app/api/{base => core}/markets.py | 0 src/app/api/{base => core}/news.py | 0 src/app/api/{base => core}/social.py | 0 src/app/api/markets/__init__.py | 81 +----- src/app/api/markets/binance.py | 2 +- src/app/api/markets/coinbase.py | 2 +- src/app/api/markets/cryptocompare.py | 2 +- src/app/api/markets/yfinance.py | 2 +- src/app/api/news/__init__.py | 75 +----- src/app/api/news/cryptopanic_api.py | 2 +- src/app/api/news/duckduckgo.py | 2 +- src/app/api/news/googlenews.py | 2 +- src/app/api/news/{news_api.py => newsapi.py} | 2 +- src/app/api/social/__init__.py | 52 +--- src/app/api/social/reddit.py | 2 +- src/app/api/tools/__init__.py | 5 + src/app/api/tools/market_tool.py | 80 ++++++ src/app/api/tools/news_tool.py | 72 ++++++ src/app/api/tools/social_tool.py | 51 ++++ src/app/api/wrapper_handler.py | 10 + src/app/configs.py | 232 ++++++++++++++++++ src/app/interface/__init__.py | 3 + .../chat_manager.py => interface/chat.py} | 0 src/app/utils/__init__.py | 3 - tests/agents/test_predictor.py | 56 ----- tests/tools/test_market_tool.py | 2 +- tests/tools/test_news_tool.py | 2 +- tests/tools/test_socials_tool.py | 2 +- tests/utils/test_market_aggregator.py | 2 +- uv.lock | 14 ++ 49 files changed, 718 insertions(+), 564 deletions(-) create mode 100644 configs.yaml delete mode 100644 src/app/agents/models.py create mode 100644 src/app/agents/prompts/__init__.py create mode 100644 src/app/agents/prompts/predictor.txt create mode 100644 src/app/agents/prompts/team_leader.txt create mode 100644 src/app/agents/prompts/team_market.txt create mode 100644 src/app/agents/prompts/team_news.txt create mode 100644 src/app/agents/prompts/team_social.txt rename src/app/api/{base => core}/__init__.py (100%) rename src/app/api/{base => core}/markets.py (100%) rename src/app/api/{base => core}/news.py (100%) rename src/app/api/{base => core}/social.py (100%) rename src/app/api/news/{news_api.py => newsapi.py} (97%) create mode 100644 src/app/api/tools/__init__.py create mode 100644 src/app/api/tools/market_tool.py create mode 100644 src/app/api/tools/news_tool.py create mode 100644 src/app/api/tools/social_tool.py create mode 100644 src/app/configs.py create mode 100644 src/app/interface/__init__.py rename src/app/{utils/chat_manager.py => interface/chat.py} (100%) delete mode 100644 src/app/utils/__init__.py delete mode 100644 tests/agents/test_predictor.py diff --git a/Dockerfile b/Dockerfile index f4d7e97..d1aa5fd 100644 --- a/Dockerfile +++ b/Dockerfile @@ -17,6 +17,7 @@ COPY pyproject.toml ./ COPY uv.lock ./ COPY LICENSE ./ COPY src/ ./src/ +COPY configs.yaml ./ # Creiamo l'ambiente virtuale con tutto già presente RUN uv sync diff --git a/README.md b/README.md index ea778b6..662a9b3 100644 --- a/README.md +++ b/README.md @@ -21,7 +21,7 @@ L'obiettivo è quello di creare un sistema di consulenza finanziaria basato su L L'installazione di questo progetto richiede 3 passaggi totali (+1 se si vuole sviluppare in locale) che devono essere eseguiti in sequenza. Se questi passaggi sono eseguiti correttamente, l'applicazione dovrebbe partire senza problemi. Altrimenti è molto probabile che si verifichino errori di vario tipo (moduli mancanti, chiavi API non trovate, ecc.). -1. Configurare le variabili d'ambiente +1. Configurazioni dell'app e delle variabili d'ambiente 2. Installare Ollama e i modelli locali 3. Far partire il progetto con Docker (consigliato) 4. (Solo per sviluppo locale) Installare uv e creare l'ambiente virtuale @@ -29,9 +29,12 @@ L'installazione di questo progetto richiede 3 passaggi totali (+1 se si vuole sv > [!IMPORTANT]\ > Prima di iniziare, assicurarsi di avere clonato il repository e di essere nella cartella principale del progetto. -### **1. Variabili d'Ambiente** +### **1. Configurazioni** -Copia il file `.env.example` in `.env` e successivamente modificalo con le tue API keys: +Ci sono due file di configurazione principali che l'app utilizza: `config.yaml` e `.env`.\ +Il primo contiene le configurazioni generali dell'applicazione e può essere modificato a piacimento, mentre il secondo è utilizzato per le variabili d'ambiente. + +Per il secondo, bisogna copiare il file `.env.example` in `.env` e successivamente modificalo con le tue API keys: ```sh cp .env.example .env nano .env # esempio di modifica del file @@ -49,11 +52,8 @@ Per l'installazione scaricare Ollama dal loro [sito ufficiale](https://ollama.co Dopo l'installazione, si possono iniziare a scaricare i modelli desiderati tramite il comando `ollama pull :`. -I modelli usati dall'applicazione sono visibili in [src/app/models.py](src/app/models.py). Di seguito metto lo stesso una lista di modelli, ma potrebbe non essere aggiornata: -- `gpt-oss:latest` -- `qwen3:latest` -- `qwen3:4b` -- `qwen3:1.7b` +I modelli usati dall'applicazione sono quelli specificati nel file [config.yaml](config.yaml) alla voce `model`. Se in locale si hanno dei modelli diversi, è possibile modificare questa voce per usare quelli disponibili. +I modelli consigliati per questo progetto sono `qwen3:4b` e `qwen3:1.7b`. ### **3. Docker** Se si vuole solamente avviare il progetto, si consiglia di utilizzare [Docker](https://www.docker.com), dato che sono stati creati i files [Dockerfile](Dockerfile) e [docker-compose.yaml](docker-compose.yaml) per creare il container con tutti i file necessari e già in esecuzione. @@ -105,13 +105,15 @@ Usando la libreria ``gradio`` è stata creata un'interfaccia web semplice per in src └── app ├── __main__.py - ├── agents <-- Agenti, modelli, prompts e simili + ├── config.py <-- Configurazioni app + ├── agents <-- Agenti, Team, prompts e simili ├── api <-- Tutte le API esterne - │ ├── base <-- Classi base per le API + │ ├── core <-- Classi core per le API │ ├── markets <-- Market data provider (Es. Binance) │ ├── news <-- News data provider (Es. NewsAPI) - │ └── social <-- Social data provider (Es. Reddit) - └── utils <-- Codice di utilità generale + │ ├── social <-- Social data provider (Es. Reddit) + │ └── tools <-- Tools per agenti creati dalle API + └── interface <-- Interfacce utente ``` ## Tests diff --git a/configs.yaml b/configs.yaml new file mode 100644 index 0000000..e2f444d --- /dev/null +++ b/configs.yaml @@ -0,0 +1,45 @@ +port: 8000 +gradio_share: false +logging_level: INFO + +strategies: + - name: Conservative + label: Conservative + description: Focus on stable and low-risk investments. + - name: Balanced + label: Balanced + description: A mix of growth and stability. + - name: Aggressive + label: Aggressive + description: High-risk, high-reward investments. + +models: + gemini: + - name: gemini-2.0-flash + label: Gemini + - name: gemini-2.0-pro + label: Gemini Pro + ollama: + - name: gpt-oss:latest + label: Ollama GPT + - name: qwen3:8b + label: Qwen 3 (8B) + - name: qwen3:4b + label: Qwen 3 (4B) + - name: qwen3:1.7b + label: Qwen 3 (1.7B) + +api: + retry_attempts: 3 + retry_delay_seconds: 2 + currency: EUR + # TODO Magari implementare un sistema per settare i providers + market_providers: [BinanceWrapper, YFinanceWrapper] + news_providers: [GoogleNewsWrapper, DuckDuckGoWrapper] + social_providers: [RedditWrapper] + +agents: + strategy: Conservative + team_model: qwen3:1.7b + team_leader_model: qwen3:4b + predictor_model: qwen3:4b diff --git a/demos/market_providers_api_demo.py b/demos/market_providers_api_demo.py index caba571..a958532 100644 --- a/demos/market_providers_api_demo.py +++ b/demos/market_providers_api_demo.py @@ -27,7 +27,7 @@ project_root = Path(__file__).parent.parent sys.path.insert(0, str(project_root / "src")) from dotenv import load_dotenv -from app.markets import ( +from app.api.markets import ( CoinBaseWrapper, CryptoCompareWrapper, BinanceWrapper, diff --git a/demos/news_api.py b/demos/news_api.py index 1497a15..ef71974 100644 --- a/demos/news_api.py +++ b/demos/news_api.py @@ -5,7 +5,7 @@ sys.path.append(os.path.abspath(os.path.join(os.path.dirname(__file__), '../src' ########################################### from dotenv import load_dotenv -from app.news import NewsApiWrapper +from app.api.news import NewsApiWrapper def main(): api = NewsApiWrapper() diff --git a/pyproject.toml b/pyproject.toml index d039c6b..97eb413 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -13,6 +13,7 @@ dependencies = [ "pytest", # Test "dotenv", # Gestire variabili d'ambiente (generalmente API keys od opzioni) "gradio", # UI web semplice con user_input e output + "colorlog", # Log colorati in console # Per costruire agenti (ovvero modelli che possono fare più cose tramite tool) https://github.com/agno-agi/agno # altamente consigliata dato che ha anche tools integrati per fare scraping, calcoli e molto altro diff --git a/src/app/__main__.py b/src/app/__main__.py index 578ef35..4347ecf 100644 --- a/src/app/__main__.py +++ b/src/app/__main__.py @@ -1,14 +1,19 @@ +import asyncio import gradio as gr from dotenv import load_dotenv from agno.utils.log import log_info #type: ignore -from app.utils import ChatManager +from app.configs import AppConfig +from app.interface import ChatManager from app.agents import Pipeline if __name__ == "__main__": # Inizializzazioni load_dotenv() - pipeline = Pipeline() + + configs = AppConfig.load() + pipeline = Pipeline(configs) + chat = ChatManager() ######################################## @@ -57,7 +62,7 @@ if __name__ == "__main__": type="index", label="Stile di investimento" ) - style.change(fn=pipeline.choose_style, inputs=style, outputs=None) + style.change(fn=pipeline.choose_strategy, inputs=style, outputs=None) chatbot = gr.Chatbot(label="Conversazione", height=500, type="messages") msg = gr.Textbox(label="Scrivi la tua richiesta", placeholder="Es: Quali sono le crypto interessanti oggi?") @@ -73,7 +78,9 @@ if __name__ == "__main__": save_btn.click(save_current_chat, inputs=None, outputs=None) load_btn.click(load_previous_chat, inputs=None, outputs=[chatbot, chatbot]) - server, port = ("0.0.0.0", 8000) # 0.0.0.0 per accesso esterno (Docker) - server_log = "localhost" if server == "0.0.0.0" else server - log_info(f"Starting UPO AppAI Chat on http://{server_log}:{port}") # noqa - demo.launch(server_name=server, server_port=port, quiet=True) + try: + _app, local, shared = demo.launch(server_name="0.0.0.0", server_port=configs.port, quiet=True, prevent_thread_lock=True, share=configs.gradio_share) + log_info(f"Starting UPO AppAI Chat on {shared or local}") + asyncio.get_event_loop().run_forever() + except KeyboardInterrupt: + demo.close() diff --git a/src/app/agents/__init__.py b/src/app/agents/__init__.py index a9ec99e..7d4287b 100644 --- a/src/app/agents/__init__.py +++ b/src/app/agents/__init__.py @@ -1,6 +1,5 @@ -from app.agents.models import AppModels -from app.agents.predictor import PredictorInput, PredictorOutput, PredictorStyle, PREDICTOR_INSTRUCTIONS +from app.agents.predictor import PredictorInput, PredictorOutput from app.agents.team import create_team_with from app.agents.pipeline import Pipeline -__all__ = ["AppModels", "PredictorInput", "PredictorOutput", "PredictorStyle", "PREDICTOR_INSTRUCTIONS", "create_team_with", "Pipeline"] +__all__ = ["PredictorInput", "PredictorOutput", "create_team_with", "Pipeline"] diff --git a/src/app/agents/models.py b/src/app/agents/models.py deleted file mode 100644 index 79d4a26..0000000 --- a/src/app/agents/models.py +++ /dev/null @@ -1,107 +0,0 @@ -import os -import ollama -from enum import Enum -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.tools import Toolkit -from agno.utils.log import log_warning #type: ignore -from pydantic import BaseModel - - -class AppModels(Enum): - """ - Enum per i modelli supportati. - Aggiungere nuovi modelli qui se necessario. - Per quanto riguarda Ollama, i modelli dovranno essere scaricati e installati - localmente seguendo le istruzioni di https://ollama.com/docs/guide/install-models - """ - GEMINI = "gemini-2.0-flash" # API online - GEMINI_PRO = "gemini-2.0-pro" # API online, più costoso ma migliore - OLLAMA_GPT = "gpt-oss:latest" # + good - slow (13b) - OLLAMA_QWEN = "qwen3:latest" # + good + fast (8b) - OLLAMA_QWEN_4B = "qwen3:4b" # + fast + decent (4b) - OLLAMA_QWEN_1B = "qwen3:1.7b" # + very fast + decent (1.7b) - - @staticmethod - def availables_local() -> list['AppModels']: - """ - Controlla quali provider di modelli LLM locali sono disponibili. - Ritorna una lista di provider disponibili. - """ - try: - models_list = ollama.list() - availables = [model['model'] for model in models_list['models']] - app_models = [model for model in AppModels if model.name.startswith("OLLAMA")] - return [model for model in app_models if model.value in availables] - except Exception as e: - log_warning(f"Ollama is not running or not reachable: {e}") - return [] - - @staticmethod - def availables_online() -> list['AppModels']: - """ - Controlla quali provider di modelli LLM online hanno le loro API keys disponibili - come variabili d'ambiente e ritorna una lista di provider disponibili. - """ - if not os.getenv("GOOGLE_API_KEY"): - log_warning("No GOOGLE_API_KEY set in environment variables.") - return [] - availables = [AppModels.GEMINI, AppModels.GEMINI_PRO] - return availables - - @staticmethod - def availables() -> list['AppModels']: - """ - Controlla quali provider di modelli LLM locali sono disponibili e quali - provider di modelli LLM online hanno le loro API keys disponibili come variabili - d'ambiente e ritorna una lista di provider disponibili. - L'ordine di preferenza è: - 1. Gemini (Google) - 2. Ollama (locale) - """ - availables = [ - *AppModels.availables_online(), - *AppModels.availables_local() - ] - assert availables, "No valid model API keys set in environment variables." - return availables - - def get_model(self, instructions:str) -> Model: - """ - Restituisce un'istanza del modello specificato. - Args: - instructions: istruzioni da passare al modello (system prompt). - Returns: - Un'istanza di BaseModel o una sua sottoclasse. - Raise: - ValueError se il modello non è supportato. - """ - name = self.value - if self in {model for model in AppModels if model.name.startswith("GEMINI")}: - return Gemini(name, instructions=[instructions]) - elif self in {model for model in AppModels if model.name.startswith("OLLAMA")}: - return Ollama(name, instructions=[instructions]) - - raise ValueError(f"Modello non supportato: {self}") - - def get_agent(self, instructions: str, name: str = "", output_schema: type[BaseModel] | None = None, tools: list[Toolkit] | None = None) -> Agent: - """ - Costruisce un agente con il modello e le istruzioni specificate. - Args: - instructions: istruzioni da passare al modello (system prompt) - name: nome dell'agente (opzionale) - output: schema di output opzionale (Pydantic BaseModel) - tools: lista opzionale di strumenti (tools) da fornire all'agente - Returns: - Un'istanza di Agent. - """ - return Agent( - model=self.get_model(instructions), - name=name, - retries=2, - tools=tools, - delay_between_retries=5, # seconds - output_schema=output_schema - ) diff --git a/src/app/agents/pipeline.py b/src/app/agents/pipeline.py index ce32c06..3522432 100644 --- a/src/app/agents/pipeline.py +++ b/src/app/agents/pipeline.py @@ -1,8 +1,9 @@ from agno.run.agent import RunOutput -from app.agents.models import AppModels from app.agents.team import create_team_with -from app.agents.predictor import PREDICTOR_INSTRUCTIONS, PredictorInput, PredictorOutput, PredictorStyle -from app.api.base.markets import ProductInfo +from app.agents.predictor import PredictorInput, PredictorOutput +from app.agents.prompts import * +from app.api.core.markets import ProductInfo +from app.configs import AppConfig class Pipeline: @@ -12,13 +13,12 @@ class Pipeline: e scelto dall'utente tramite i dropdown dell'interfaccia grafica. """ - def __init__(self): - self.available_models = AppModels.availables() - self.all_styles = list(PredictorStyle) + def __init__(self, configs: AppConfig): + self.configs = configs - self.style = self.all_styles[0] - self.team = create_team_with(AppModels.OLLAMA_QWEN_1B) - self.choose_predictor(0) # Modello di default + # Stato iniziale + self.choose_strategy(0) + self.choose_predictor(0) # ====================== # Dropdown handlers @@ -27,17 +27,17 @@ class Pipeline: """ Sceglie il modello LLM da usare per il Predictor. """ - model = self.available_models[index] + model = self.configs.models.all_models[index] self.predictor = model.get_agent( PREDICTOR_INSTRUCTIONS, output_schema=PredictorOutput, ) - def choose_style(self, index: int): + def choose_strategy(self, index: int): """ - Sceglie lo stile (conservativo/aggressivo) da usare per il Predictor. + Sceglie la strategia da usare per il Predictor. """ - self.style = self.all_styles[index] + self.strat = self.configs.strategies[index].description # ====================== # Helpers @@ -46,13 +46,13 @@ class Pipeline: """ Restituisce la lista dei nomi dei modelli disponibili. """ - return [model.name for model in self.available_models] + return [model.label for model in self.configs.models.all_models] def list_styles(self) -> list[str]: """ Restituisce la lista degli stili di previsione disponibili. """ - return [style.value for style in self.all_styles] + return [strat.label for strat in self.configs.strategies] # ====================== # Core interaction @@ -65,7 +65,11 @@ class Pipeline: 4. Restituisce la strategia finale """ # Step 1: raccolta output dai membri del Team - team_outputs = self.team.run(query) # type: ignore + team_model = self.configs.get_model_by_name(self.configs.agents.team_model) + leader_model = self.configs.get_model_by_name(self.configs.agents.team_leader_model) + + team = create_team_with(self.configs, team_model, leader_model) + team_outputs = team.run(query) # type: ignore # Step 2: aggregazione output strutturati all_products: list[ProductInfo] = [] @@ -86,7 +90,7 @@ class Pipeline: # Step 3: invocazione Predictor predictor_input = PredictorInput( data=all_products, - style=self.style, + style=self.strat, sentiment=aggregated_sentiment ) @@ -100,6 +104,6 @@ class Pipeline: [f"{item.asset} ({item.percentage}%): {item.motivation}" for item in prediction.portfolio] ) return ( - f"📊 Strategia ({self.style.value}): {prediction.strategy}\n\n" + f"📊 Strategia ({self.strat}): {prediction.strategy}\n\n" f"💼 Portafoglio consigliato:\n{portfolio_lines}" ) diff --git a/src/app/agents/predictor.py b/src/app/agents/predictor.py index 4c5bb1a..2073947 100644 --- a/src/app/agents/predictor.py +++ b/src/app/agents/predictor.py @@ -1,15 +1,9 @@ -from enum import Enum from pydantic import BaseModel, Field -from app.api.base.markets import ProductInfo - - -class PredictorStyle(Enum): - CONSERVATIVE = "Conservativo" - AGGRESSIVE = "Aggressivo" +from app.api.core.markets import ProductInfo class PredictorInput(BaseModel): data: list[ProductInfo] = Field(..., description="Market data as a list of ProductInfo") - style: PredictorStyle = Field(..., description="Prediction style") + style: str = Field(..., description="Prediction style") sentiment: str = Field(..., description="Aggregated sentiment from news and social analysis") class ItemPortfolio(BaseModel): @@ -20,34 +14,3 @@ class ItemPortfolio(BaseModel): class PredictorOutput(BaseModel): strategy: str = Field(..., description="Concise operational strategy in Italian") portfolio: list[ItemPortfolio] = Field(..., description="List of portfolio items with allocations") - - -PREDICTOR_INSTRUCTIONS = """ -You are an **Allocation Algorithm (Crypto-Algo)** specialized in analyzing market data and sentiment to generate an investment strategy and a target portfolio. - -Your sole objective is to process the user_input data and generate the strictly structured output as required by the response format. **You MUST NOT provide introductions, preambles, explanations, conclusions, or any additional comments that are not strictly required.** - -## Processing Instructions (Absolute Rule) - -The allocation strategy must be **derived exclusively from the "Allocation Logic" corresponding to the requested *style*** and the provided market/sentiment data. **DO NOT** use external or historical knowledge. - -## Allocation Logic - -### "Aggressivo" Style (Aggressive) -* **Priority:** Maximizing return (high volatility accepted). -* **Focus:** Higher allocation to **non-BTC/ETH assets** with high momentum potential (Altcoins, mid/low-cap assets). -* **BTC/ETH:** Must serve as a base (anchor), but their allocation **must not exceed 50%** of the total portfolio. -* **Sentiment:** Use positive sentiment to increase exposure to high-risk assets. - -### "Conservativo" Style (Conservative) -* **Priority:** Capital preservation (volatility minimized). -* **Focus:** Major allocation to **BTC and/or ETH (Large-Cap Assets)**. -* **BTC/ETH:** Their allocation **must be at least 70%** of the total portfolio. -* **Altcoins:** Any allocations to non-BTC/ETH assets must be minimal (max 30% combined) and for assets that minimize speculative risk. -* **Sentiment:** Use positive sentiment only as confirmation for exposure, avoiding reactions to excessive "FOMO" signals. - -## Output Requirements (Content MUST be in Italian) - -1. **Strategy (strategy):** Must be a concise operational description **in Italian ("in Italiano")**, with a maximum of 5 sentences. -2. **Portfolio (portfolio):** The sum of all percentages must be **exactly 100%**. The justification (motivation) for each asset must be a single clear sentence **in Italian ("in Italiano")**. -""" \ No newline at end of file diff --git a/src/app/agents/prompts/__init__.py b/src/app/agents/prompts/__init__.py new file mode 100644 index 0000000..6aa7abe --- /dev/null +++ b/src/app/agents/prompts/__init__.py @@ -0,0 +1,21 @@ +from pathlib import Path + +__PROMPTS_PATH = Path(__file__).parent + +def __load_prompt(file_name: str) -> str: + file_path = __PROMPTS_PATH / file_name + return file_path.read_text(encoding='utf-8').strip() + +COORDINATOR_INSTRUCTIONS = __load_prompt("team_leader.txt") +MARKET_INSTRUCTIONS = __load_prompt("team_market.txt") +NEWS_INSTRUCTIONS = __load_prompt("team_news.txt") +SOCIAL_INSTRUCTIONS = __load_prompt("team_social.txt") +PREDICTOR_INSTRUCTIONS = __load_prompt("predictor.txt") + +__all__ = [ + "COORDINATOR_INSTRUCTIONS", + "MARKET_INSTRUCTIONS", + "NEWS_INSTRUCTIONS", + "SOCIAL_INSTRUCTIONS", + "PREDICTOR_INSTRUCTIONS", +] \ No newline at end of file diff --git a/src/app/agents/prompts/predictor.txt b/src/app/agents/prompts/predictor.txt new file mode 100644 index 0000000..8dd29fe --- /dev/null +++ b/src/app/agents/prompts/predictor.txt @@ -0,0 +1,27 @@ +You are an **Allocation Algorithm (Crypto-Algo)** specialized in analyzing market data and sentiment to generate an investment strategy and a target portfolio. + +Your sole objective is to process the user_input data and generate the strictly structured output as required by the response format. **You MUST NOT provide introductions, preambles, explanations, conclusions, or any additional comments that are not strictly required.** + +## Processing Instructions (Absolute Rule) + +The allocation strategy must be **derived exclusively from the "Allocation Logic" corresponding to the requested *style*** and the provided market/sentiment data. **DO NOT** use external or historical knowledge. + +## Allocation Logic + +### "Aggressivo" Style (Aggressive) +* **Priority:** Maximizing return (high volatility accepted). +* **Focus:** Higher allocation to **non-BTC/ETH assets** with high momentum potential (Altcoins, mid/low-cap assets). +* **BTC/ETH:** Must serve as a base (anchor), but their allocation **must not exceed 50%** of the total portfolio. +* **Sentiment:** Use positive sentiment to increase exposure to high-risk assets. + +### "Conservativo" Style (Conservative) +* **Priority:** Capital preservation (volatility minimized). +* **Focus:** Major allocation to **BTC and/or ETH (Large-Cap Assets)**. +* **BTC/ETH:** Their allocation **must be at least 70%** of the total portfolio. +* **Altcoins:** Any allocations to non-BTC/ETH assets must be minimal (max 30% combined) and for assets that minimize speculative risk. +* **Sentiment:** Use positive sentiment only as confirmation for exposure, avoiding reactions to excessive "FOMO" signals. + +## Output Requirements (Content MUST be in Italian) + +1. **Strategy (strategy):** Must be a concise operational description **in Italian ("in Italiano")**, with a maximum of 5 sentences. +2. **Portfolio (portfolio):** The sum of all percentages must be **exactly 100%**. The justification (motivation) for each asset must be a single clear sentence **in Italian ("in Italiano")**. diff --git a/src/app/agents/prompts/team_leader.txt b/src/app/agents/prompts/team_leader.txt new file mode 100644 index 0000000..a0f686b --- /dev/null +++ b/src/app/agents/prompts/team_leader.txt @@ -0,0 +1,15 @@ +You are the expert coordinator of a financial analysis team specializing in cryptocurrencies. + +Your team consists of three agents: +- **MarketAgent**: Provides quantitative market data, price analysis, and technical indicators. +- **NewsAgent**: Scans and analyzes the latest news, articles, and official announcements. +- **SocialAgent**: Gauges public sentiment, trends, and discussions on social media. + +Your primary objective is to answer the user's query by orchestrating the work of your team members. + +Your workflow is as follows: +1. **Deconstruct the user's query** to identify the required information. +2. **Delegate specific tasks** to the most appropriate agent(s) to gather the necessary data and initial analysis. +3. **Analyze the information** returned by the agents. +4. If the initial data is insufficient or the query is complex, **iteratively re-engage the agents** with follow-up questions to build a comprehensive picture. +5. **Synthesize all the gathered information** into a final, coherent, and complete analysis that fills all the required output fields. diff --git a/src/app/agents/prompts/team_market.txt b/src/app/agents/prompts/team_market.txt new file mode 100644 index 0000000..6346241 --- /dev/null +++ b/src/app/agents/prompts/team_market.txt @@ -0,0 +1,19 @@ +**TASK:** You are a specialized **Crypto Price Data Retrieval Agent**. Your primary goal is to fetch the most recent and/or historical price data for requested cryptocurrency assets (e.g., 'BTC', 'ETH', 'SOL'). You must provide the data in a clear and structured format. + +**AVAILABLE TOOLS:** +1. `get_products(asset_ids: list[str])`: Get **current** product/price info for a list of assets. **(PREFERITA: usa questa per i prezzi live)** +2. `get_historical_prices(asset_id: str, limit: int)`: Get historical price data for one asset. Default limit is 100. **(PREFERITA: usa questa per i dati storici)** +3. `get_products_aggregated(asset_ids: list[str])`: Get **aggregated current** product/price info for a list of assets. **(USA SOLO SE richiesto 'aggregato' o se `get_products` fallisce)** +4. `get_historical_prices_aggregated(asset_id: str, limit: int)`: Get **aggregated historical** price data for one asset. **(USA SOLO SE richiesto 'aggregato' o se `get_historical_prices` fallisce)** + +**USAGE GUIDELINE:** +* **Asset ID:** Always convert common names (e.g., 'Bitcoin', 'Ethereum') into their official ticker/ID (e.g., 'BTC', 'ETH'). +* **Cost Management (Cruciale per LLM locale):** Prefer `get_products` and `get_historical_prices` for standard requests to minimize costs. +* **Aggregated Data:** Use `get_products_aggregated` or `get_historical_prices_aggregated` only if the user specifically requests aggregated data or you value that having aggregated data is crucial for the analysis. +* **Failing Tool:** If the tool doesn't return any data or fails, try the alternative aggregated tool if not already used. + +**REPORTING REQUIREMENT:** +1. **Format:** Output the results in a clear, easy-to-read list or table. +2. **Live Price Request:** If an asset's *current price* is requested, report the **Asset ID**, **Latest Price**, and **Time/Date of the price**. +3. **Historical Price Request:** If *historical data* is requested, report the **Asset ID**, the **Limit** of points returned, and the **First** and **Last** entries from the list of historical prices (Date, Price). +4. **Output:** For all requests, output a single, concise summary of the findings; if requested, also include the raw data retrieved. diff --git a/src/app/agents/prompts/team_news.txt b/src/app/agents/prompts/team_news.txt new file mode 100644 index 0000000..311222c --- /dev/null +++ b/src/app/agents/prompts/team_news.txt @@ -0,0 +1,18 @@ +**TASK:** You are a specialized **Crypto News Analyst**. Your goal is to fetch the latest news or top headlines related to cryptocurrencies, and then **analyze the sentiment** of the content to provide a concise report to the team leader. Prioritize 'crypto' or specific cryptocurrency names (e.g., 'Bitcoin', 'Ethereum') in your searches. + +**AVAILABLE TOOLS:** +1. `get_latest_news(query: str, limit: int)`: Get the 'limit' most recent news articles for a specific 'query'. +2. `get_top_headlines(limit: int)`: Get the 'limit' top global news headlines. +3. `get_latest_news_aggregated(query: str, limit: int)`: Get aggregated latest news articles for a specific 'query'. +4. `get_top_headlines_aggregated(limit: int)`: Get aggregated top global news headlines. + +**USAGE GUIDELINE:** +* Always use `get_latest_news` with a relevant crypto-related query first. +* The default limit for news items should be 5 unless specified otherwise. +* If the tool doesn't return any articles, respond with "No relevant news articles found." + +**REPORTING REQUIREMENT:** +1. **Analyze** the tone and key themes of the retrieved articles. +2. **Summarize** the overall **market sentiment** (e.g., highly positive, cautiously neutral, generally negative) based on the content. +3. **Identify** the top 2-3 **main topics** discussed (e.g., new regulation, price surge, institutional adoption). +4. **Output** a single, brief report summarizing these findings. Do not output the raw articles. diff --git a/src/app/agents/prompts/team_social.txt b/src/app/agents/prompts/team_social.txt new file mode 100644 index 0000000..ea227c7 --- /dev/null +++ b/src/app/agents/prompts/team_social.txt @@ -0,0 +1,15 @@ +**TASK:** You are a specialized **Social Media Sentiment Analyst**. Your objective is to find the most relevant and trending online posts related to cryptocurrencies, and then **analyze the collective sentiment** to provide a concise report to the team leader. + +**AVAILABLE TOOLS:** +1. `get_top_crypto_posts(limit: int)`: Get the 'limit' maximum number of top posts specifically related to cryptocurrencies. + +**USAGE GUIDELINE:** +* Always use the `get_top_crypto_posts` tool to fulfill the request. +* The default limit for posts should be 5 unless specified otherwise. +* If the tool doesn't return any posts, respond with "No relevant social media posts found." + +**REPORTING REQUIREMENT:** +1. **Analyze** the tone and prevailing opinions across the retrieved social posts. +2. **Summarize** the overall **community sentiment** (e.g., high enthusiasm/FOMO, uncertainty, FUD/fear) based on the content. +3. **Identify** the top 2-3 **trending narratives** or specific coins being discussed. +4. **Output** a single, brief report summarizing these findings. Do not output the raw posts. diff --git a/src/app/agents/team.py b/src/app/agents/team.py index 04bcab6..4fcad4e 100644 --- a/src/app/agents/team.py +++ b/src/app/agents/team.py @@ -1,109 +1,25 @@ from agno.team import Team -from app.agents import AppModels -from app.api.markets import MarketAPIsTool -from app.api.news import NewsAPIsTool -from app.api.social import SocialAPIsTool +from app.api.tools import * +from app.agents.prompts import * +from app.configs import AppConfig, AppModel -def create_team_with(models: AppModels, coordinator: AppModels | None = None) -> Team: - market_agent = models.get_agent( - instructions=MARKET_INSTRUCTIONS, - name="MarketAgent", - tools=[MarketAPIsTool()] - ) - news_agent = models.get_agent( - instructions=NEWS_INSTRUCTIONS, - name="NewsAgent", - tools=[NewsAPIsTool()] - ) - social_agent = models.get_agent( - instructions=SOCIAL_INSTRUCTIONS, - name="SocialAgent", - tools=[SocialAPIsTool()] - ) +def create_team_with(configs: AppConfig, model: AppModel, coordinator: AppModel | None = None) -> Team: - coordinator = coordinator or models + market_tool = MarketAPIsTool(currency=configs.api.currency) + market_tool.handler.set_retries(configs.api.retry_attempts, configs.api.retry_delay_seconds) + news_tool = NewsAPIsTool() + news_tool.handler.set_retries(configs.api.retry_attempts, configs.api.retry_delay_seconds) + social_tool = SocialAPIsTool() + social_tool.handler.set_retries(configs.api.retry_attempts, configs.api.retry_delay_seconds) + + market_agent = model.get_agent(instructions=MARKET_INSTRUCTIONS, name="MarketAgent", tools=[market_tool]) + news_agent = model.get_agent(instructions=NEWS_INSTRUCTIONS, name="NewsAgent", tools=[news_tool]) + social_agent = model.get_agent(instructions=SOCIAL_INSTRUCTIONS, name="SocialAgent", tools=[social_tool]) + + coordinator = coordinator or model return Team( model=coordinator.get_model(COORDINATOR_INSTRUCTIONS), name="CryptoAnalysisTeam", members=[market_agent, news_agent, social_agent], ) - -COORDINATOR_INSTRUCTIONS = """ -You are the expert coordinator of a financial analysis team specializing in cryptocurrencies. - -Your team consists of three agents: -- **MarketAgent**: Provides quantitative market data, price analysis, and technical indicators. -- **NewsAgent**: Scans and analyzes the latest news, articles, and official announcements. -- **SocialAgent**: Gauges public sentiment, trends, and discussions on social media. - -Your primary objective is to answer the user's query by orchestrating the work of your team members. - -Your workflow is as follows: -1. **Deconstruct the user's query** to identify the required information. -2. **Delegate specific tasks** to the most appropriate agent(s) to gather the necessary data and initial analysis. -3. **Analyze the information** returned by the agents. -4. If the initial data is insufficient or the query is complex, **iteratively re-engage the agents** with follow-up questions to build a comprehensive picture. -5. **Synthesize all the gathered information** into a final, coherent, and complete analysis that fills all the required output fields. -""" - -MARKET_INSTRUCTIONS = """ -**TASK:** You are a specialized **Crypto Price Data Retrieval Agent**. Your primary goal is to fetch the most recent and/or historical price data for requested cryptocurrency assets (e.g., 'BTC', 'ETH', 'SOL'). You must provide the data in a clear and structured format. - -**AVAILABLE TOOLS:** -1. `get_products(asset_ids: list[str])`: Get **current** product/price info for a list of assets. **(PREFERITA: usa questa per i prezzi live)** -2. `get_historical_prices(asset_id: str, limit: int)`: Get historical price data for one asset. Default limit is 100. **(PREFERITA: usa questa per i dati storici)** -3. `get_products_aggregated(asset_ids: list[str])`: Get **aggregated current** product/price info for a list of assets. **(USA SOLO SE richiesto 'aggregato' o se `get_products` fallisce)** -4. `get_historical_prices_aggregated(asset_id: str, limit: int)`: Get **aggregated historical** price data for one asset. **(USA SOLO SE richiesto 'aggregato' o se `get_historical_prices` fallisce)** - -**USAGE GUIDELINE:** -* **Asset ID:** Always convert common names (e.g., 'Bitcoin', 'Ethereum') into their official ticker/ID (e.g., 'BTC', 'ETH'). -* **Cost Management (Cruciale per LLM locale):** Prefer `get_products` and `get_historical_prices` for standard requests to minimize costs. -* **Aggregated Data:** Use `get_products_aggregated` or `get_historical_prices_aggregated` only if the user specifically requests aggregated data or you value that having aggregated data is crucial for the analysis. -* **Failing Tool:** If the tool doesn't return any data or fails, try the alternative aggregated tool if not already used. - -**REPORTING REQUIREMENT:** -1. **Format:** Output the results in a clear, easy-to-read list or table. -2. **Live Price Request:** If an asset's *current price* is requested, report the **Asset ID**, **Latest Price**, and **Time/Date of the price**. -3. **Historical Price Request:** If *historical data* is requested, report the **Asset ID**, the **Limit** of points returned, and the **First** and **Last** entries from the list of historical prices (Date, Price). -4. **Output:** For all requests, output a single, concise summary of the findings; if requested, also include the raw data retrieved. -""" - -NEWS_INSTRUCTIONS = """ -**TASK:** You are a specialized **Crypto News Analyst**. Your goal is to fetch the latest news or top headlines related to cryptocurrencies, and then **analyze the sentiment** of the content to provide a concise report to the team leader. Prioritize 'crypto' or specific cryptocurrency names (e.g., 'Bitcoin', 'Ethereum') in your searches. - -**AVAILABLE TOOLS:** -1. `get_latest_news(query: str, limit: int)`: Get the 'limit' most recent news articles for a specific 'query'. -2. `get_top_headlines(limit: int)`: Get the 'limit' top global news headlines. -3. `get_latest_news_aggregated(query: str, limit: int)`: Get aggregated latest news articles for a specific 'query'. -4. `get_top_headlines_aggregated(limit: int)`: Get aggregated top global news headlines. - -**USAGE GUIDELINE:** -* Always use `get_latest_news` with a relevant crypto-related query first. -* The default limit for news items should be 5 unless specified otherwise. -* If the tool doesn't return any articles, respond with "No relevant news articles found." - -**REPORTING REQUIREMENT:** -1. **Analyze** the tone and key themes of the retrieved articles. -2. **Summarize** the overall **market sentiment** (e.g., highly positive, cautiously neutral, generally negative) based on the content. -3. **Identify** the top 2-3 **main topics** discussed (e.g., new regulation, price surge, institutional adoption). -4. **Output** a single, brief report summarizing these findings. Do not output the raw articles. -""" - -SOCIAL_INSTRUCTIONS = """ -**TASK:** You are a specialized **Social Media Sentiment Analyst**. Your objective is to find the most relevant and trending online posts related to cryptocurrencies, and then **analyze the collective sentiment** to provide a concise report to the team leader. - -**AVAILABLE TOOLS:** -1. `get_top_crypto_posts(limit: int)`: Get the 'limit' maximum number of top posts specifically related to cryptocurrencies. - -**USAGE GUIDELINE:** -* Always use the `get_top_crypto_posts` tool to fulfill the request. -* The default limit for posts should be 5 unless specified otherwise. -* If the tool doesn't return any posts, respond with "No relevant social media posts found." - -**REPORTING REQUIREMENT:** -1. **Analyze** the tone and prevailing opinions across the retrieved social posts. -2. **Summarize** the overall **community sentiment** (e.g., high enthusiasm/FOMO, uncertainty, FUD/fear) based on the content. -3. **Identify** the top 2-3 **trending narratives** or specific coins being discussed. -4. **Output** a single, brief report summarizing these findings. Do not output the raw posts. -""" diff --git a/src/app/api/base/__init__.py b/src/app/api/core/__init__.py similarity index 100% rename from src/app/api/base/__init__.py rename to src/app/api/core/__init__.py diff --git a/src/app/api/base/markets.py b/src/app/api/core/markets.py similarity index 100% rename from src/app/api/base/markets.py rename to src/app/api/core/markets.py diff --git a/src/app/api/base/news.py b/src/app/api/core/news.py similarity index 100% rename from src/app/api/base/news.py rename to src/app/api/core/news.py diff --git a/src/app/api/base/social.py b/src/app/api/core/social.py similarity index 100% rename from src/app/api/base/social.py rename to src/app/api/core/social.py diff --git a/src/app/api/markets/__init__.py b/src/app/api/markets/__init__.py index 9a48853..af4d57b 100644 --- a/src/app/api/markets/__init__.py +++ b/src/app/api/markets/__init__.py @@ -1,86 +1,7 @@ -from agno.tools import Toolkit -from app.api.wrapper_handler import WrapperHandler -from app.api.base.markets import MarketWrapper, Price, ProductInfo from app.api.markets.binance import BinanceWrapper from app.api.markets.coinbase import CoinBaseWrapper from app.api.markets.cryptocompare import CryptoCompareWrapper from app.api.markets.yfinance import YFinanceWrapper -__all__ = [ "MarketAPIsTool", "BinanceWrapper", "CoinBaseWrapper", "CryptoCompareWrapper", "YFinanceWrapper", "ProductInfo", "Price" ] +__all__ = ["BinanceWrapper", "CoinBaseWrapper", "CryptoCompareWrapper", "YFinanceWrapper"] - -class MarketAPIsTool(MarketWrapper, Toolkit): - """ - Class that aggregates multiple market API wrappers and manages them using WrapperHandler. - This class supports retrieving product information and historical prices. - This class can also aggregate data from multiple sources to provide a more comprehensive view of the market. - The following wrappers are included in this order: - - BinanceWrapper - - YFinanceWrapper - - CoinBaseWrapper - - CryptoCompareWrapper - """ - - def __init__(self, currency: str = "USD"): - """ - Initialize the MarketAPIsTool with multiple market API wrappers. - The following wrappers are included in this order: - - BinanceWrapper - - YFinanceWrapper - - CoinBaseWrapper - - CryptoCompareWrapper - Args: - currency (str): Valuta in cui restituire i prezzi. Default è "USD". - """ - kwargs = {"currency": currency or "USD"} - wrappers: list[type[MarketWrapper]] = [BinanceWrapper, YFinanceWrapper, CoinBaseWrapper, CryptoCompareWrapper] - self.handler = WrapperHandler.build_wrappers(wrappers, kwargs=kwargs) - - Toolkit.__init__( # type: ignore - self, - name="Market APIs Toolkit", - tools=[ - self.get_product, - self.get_products, - self.get_historical_prices, - self.get_products_aggregated, - self.get_historical_prices_aggregated, - ], - ) - - def get_product(self, asset_id: str) -> ProductInfo: - return self.handler.try_call(lambda w: w.get_product(asset_id)) - def get_products(self, asset_ids: list[str]) -> list[ProductInfo]: - return self.handler.try_call(lambda w: w.get_products(asset_ids)) - def get_historical_prices(self, asset_id: str, limit: int = 100) -> list[Price]: - return self.handler.try_call(lambda w: w.get_historical_prices(asset_id, limit)) - - - def get_products_aggregated(self, asset_ids: list[str]) -> list[ProductInfo]: - """ - Restituisce i dati aggregati per una lista di asset_id.\n - Attenzione che si usano tutte le fonti, quindi potrebbe usare molte chiamate API (che potrebbero essere a pagamento). - Args: - asset_ids (list[str]): Lista di asset_id da cercare. - Returns: - list[ProductInfo]: Lista di ProductInfo aggregati. - Raises: - Exception: If all wrappers fail to provide results. - """ - all_products = self.handler.try_call_all(lambda w: w.get_products(asset_ids)) - return ProductInfo.aggregate(all_products) - - def get_historical_prices_aggregated(self, asset_id: str = "BTC", limit: int = 100) -> list[Price]: - """ - Restituisce i dati storici aggregati per un asset_id. Usa i dati di tutte le fonti disponibili e li aggrega.\n - Attenzione che si usano tutte le fonti, quindi potrebbe usare molte chiamate API (che potrebbero essere a pagamento). - Args: - asset_id (str): Asset ID da cercare. - limit (int): Numero massimo di dati storici da restituire. - Returns: - list[Price]: Lista di Price aggregati. - Raises: - Exception: If all wrappers fail to provide results. - """ - all_prices = self.handler.try_call_all(lambda w: w.get_historical_prices(asset_id, limit)) - return Price.aggregate(all_prices) diff --git a/src/app/api/markets/binance.py b/src/app/api/markets/binance.py index 18b5f35..2206157 100644 --- a/src/app/api/markets/binance.py +++ b/src/app/api/markets/binance.py @@ -1,7 +1,7 @@ import os from typing import Any from binance.client import Client # type: ignore -from app.api.base.markets import ProductInfo, MarketWrapper, Price +from app.api.core.markets import ProductInfo, MarketWrapper, Price def extract_product(currency: str, ticker_data: dict[str, Any]) -> ProductInfo: diff --git a/src/app/api/markets/coinbase.py b/src/app/api/markets/coinbase.py index 13016f6..194bf22 100644 --- a/src/app/api/markets/coinbase.py +++ b/src/app/api/markets/coinbase.py @@ -3,7 +3,7 @@ from enum import Enum from datetime import datetime, timedelta from coinbase.rest import RESTClient # type: ignore from coinbase.rest.types.product_types import Candle, GetProductResponse, Product # type: ignore -from app.api.base.markets import ProductInfo, MarketWrapper, Price +from app.api.core.markets import ProductInfo, MarketWrapper, Price def extract_product(product_data: GetProductResponse | Product) -> ProductInfo: diff --git a/src/app/api/markets/cryptocompare.py b/src/app/api/markets/cryptocompare.py index a6c5d70..64706a0 100644 --- a/src/app/api/markets/cryptocompare.py +++ b/src/app/api/markets/cryptocompare.py @@ -1,7 +1,7 @@ import os from typing import Any import requests -from app.api.base.markets import ProductInfo, MarketWrapper, Price +from app.api.core.markets import ProductInfo, MarketWrapper, Price def extract_product(asset_data: dict[str, Any]) -> ProductInfo: diff --git a/src/app/api/markets/yfinance.py b/src/app/api/markets/yfinance.py index f63192e..579b591 100644 --- a/src/app/api/markets/yfinance.py +++ b/src/app/api/markets/yfinance.py @@ -1,6 +1,6 @@ import json from agno.tools.yfinance import YFinanceTools -from app.api.base.markets import MarketWrapper, ProductInfo, Price +from app.api.core.markets import MarketWrapper, ProductInfo, Price def extract_product(stock_data: dict[str, str]) -> ProductInfo: diff --git a/src/app/api/news/__init__.py b/src/app/api/news/__init__.py index a66cf05..e9fb781 100644 --- a/src/app/api/news/__init__.py +++ b/src/app/api/news/__init__.py @@ -1,78 +1,7 @@ -from agno.tools import Toolkit -from app.api.wrapper_handler import WrapperHandler -from app.api.base.news import NewsWrapper, Article -from app.api.news.news_api import NewsApiWrapper +from app.api.news.newsapi import NewsApiWrapper from app.api.news.googlenews import GoogleNewsWrapper from app.api.news.cryptopanic_api import CryptoPanicWrapper from app.api.news.duckduckgo import DuckDuckGoWrapper -__all__ = ["NewsAPIsTool", "NewsApiWrapper", "GoogleNewsWrapper", "CryptoPanicWrapper", "DuckDuckGoWrapper", "Article"] +__all__ = ["NewsApiWrapper", "GoogleNewsWrapper", "CryptoPanicWrapper", "DuckDuckGoWrapper"] - -class NewsAPIsTool(NewsWrapper, Toolkit): - """ - Aggregates multiple news API wrappers and manages them using WrapperHandler. - This class supports retrieving top headlines and latest news articles by querying multiple sources: - - GoogleNewsWrapper - - DuckDuckGoWrapper - - NewsApiWrapper - - CryptoPanicWrapper - - By default, it returns results from the first successful wrapper. - Optionally, it can be configured to collect articles from all wrappers. - If no wrapper succeeds, an exception is raised. - """ - - 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: list[type[NewsWrapper]] = [GoogleNewsWrapper, DuckDuckGoWrapper, NewsApiWrapper, CryptoPanicWrapper] - self.handler = WrapperHandler.build_wrappers(wrappers) - - Toolkit.__init__( # type: ignore - self, - name="News APIs Toolkit", - tools=[ - self.get_top_headlines, - self.get_latest_news, - self.get_top_headlines_aggregated, - self.get_latest_news_aggregated, - ], - ) - - def get_top_headlines(self, limit: int = 100) -> list[Article]: - return self.handler.try_call(lambda w: w.get_top_headlines(limit)) - def get_latest_news(self, query: str, limit: int = 100) -> list[Article]: - return self.handler.try_call(lambda w: w.get_latest_news(query, limit)) - - def get_top_headlines_aggregated(self, limit: int = 100) -> dict[str, list[Article]]: - """ - Calls get_top_headlines on all wrappers/providers and returns a dictionary mapping their names to their articles. - Args: - limit (int): Maximum number of articles to retrieve from each provider. - Returns: - dict[str, list[Article]]: A dictionary mapping providers names to their list of Articles - Raises: - Exception: If all wrappers fail to provide results. - """ - return self.handler.try_call_all(lambda w: w.get_top_headlines(limit)) - - def get_latest_news_aggregated(self, query: str, limit: int = 100) -> dict[str, list[Article]]: - """ - Calls get_latest_news on all wrappers/providers and returns a dictionary mapping their names to their articles. - Args: - query (str): The search query to find relevant news articles. - limit (int): Maximum number of articles to retrieve from each provider. - Returns: - dict[str, list[Article]]: A dictionary mapping providers names to their list of Articles - Raises: - Exception: If all wrappers fail to provide results. - """ - return self.handler.try_call_all(lambda w: w.get_latest_news(query, limit)) diff --git a/src/app/api/news/cryptopanic_api.py b/src/app/api/news/cryptopanic_api.py index b1810b7..4e6f6db 100644 --- a/src/app/api/news/cryptopanic_api.py +++ b/src/app/api/news/cryptopanic_api.py @@ -2,7 +2,7 @@ import os from typing import Any import requests from enum import Enum -from app.api.base.news import NewsWrapper, Article +from app.api.core.news import NewsWrapper, Article class CryptoPanicFilter(Enum): diff --git a/src/app/api/news/duckduckgo.py b/src/app/api/news/duckduckgo.py index d854a2d..7fe232d 100644 --- a/src/app/api/news/duckduckgo.py +++ b/src/app/api/news/duckduckgo.py @@ -1,7 +1,7 @@ import json from typing import Any from agno.tools.duckduckgo import DuckDuckGoTools -from app.api.base.news import Article, NewsWrapper +from app.api.core.news import Article, NewsWrapper def extract_article(result: dict[str, Any]) -> Article: diff --git a/src/app/api/news/googlenews.py b/src/app/api/news/googlenews.py index 613484f..6b3a3ff 100644 --- a/src/app/api/news/googlenews.py +++ b/src/app/api/news/googlenews.py @@ -1,6 +1,6 @@ from typing import Any from gnews import GNews # type: ignore -from app.api.base.news import Article, NewsWrapper +from app.api.core.news import Article, NewsWrapper def extract_article(result: dict[str, Any]) -> Article: diff --git a/src/app/api/news/news_api.py b/src/app/api/news/newsapi.py similarity index 97% rename from src/app/api/news/news_api.py rename to src/app/api/news/newsapi.py index 3c229f3..142b6f7 100644 --- a/src/app/api/news/news_api.py +++ b/src/app/api/news/newsapi.py @@ -1,7 +1,7 @@ import os from typing import Any import newsapi # type: ignore -from app.api.base.news import Article, NewsWrapper +from app.api.core.news import Article, NewsWrapper def extract_article(result: dict[str, Any]) -> Article: diff --git a/src/app/api/social/__init__.py b/src/app/api/social/__init__.py index 69d4331..f50ca7c 100644 --- a/src/app/api/social/__init__.py +++ b/src/app/api/social/__init__.py @@ -1,53 +1,3 @@ -from agno.tools import Toolkit -from app.api.wrapper_handler import WrapperHandler -from app.api.base.social import SocialPost, SocialWrapper from app.api.social.reddit import RedditWrapper -__all__ = ["SocialAPIsTool", "RedditWrapper", "SocialPost"] - - -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: list[type[SocialWrapper]] = [RedditWrapper] - self.handler = WrapperHandler.build_wrappers(wrappers) - - Toolkit.__init__( # type: ignore - self, - name="Socials Toolkit", - tools=[ - self.get_top_crypto_posts, - self.get_top_crypto_posts_aggregated, - ], - ) - - def get_top_crypto_posts(self, limit: int = 5) -> list[SocialPost]: - return self.handler.try_call(lambda w: w.get_top_crypto_posts(limit)) - - def get_top_crypto_posts_aggregated(self, limit_per_wrapper: int = 5) -> dict[str, list[SocialPost]]: - """ - Calls get_top_crypto_posts on all wrappers/providers and returns a dictionary mapping their names to their posts. - Args: - limit_per_wrapper (int): Maximum number of posts to retrieve from each provider. - Returns: - dict[str, list[SocialPost]]: A dictionary where keys are wrapper names and values are lists of SocialPost objects. - Raises: - Exception: If all wrappers fail to provide results. - """ - return self.handler.try_call_all(lambda w: w.get_top_crypto_posts(limit_per_wrapper)) +__all__ = ["RedditWrapper"] diff --git a/src/app/api/social/reddit.py b/src/app/api/social/reddit.py index e098ee3..ca06211 100644 --- a/src/app/api/social/reddit.py +++ b/src/app/api/social/reddit.py @@ -1,7 +1,7 @@ import os from praw import Reddit # type: ignore from praw.models import Submission # type: ignore -from app.api.base.social import SocialWrapper, SocialPost, SocialComment +from app.api.core.social import SocialWrapper, SocialPost, SocialComment MAX_COMMENTS = 5 diff --git a/src/app/api/tools/__init__.py b/src/app/api/tools/__init__.py new file mode 100644 index 0000000..da9c32a --- /dev/null +++ b/src/app/api/tools/__init__.py @@ -0,0 +1,5 @@ +from app.api.tools.market_tool import MarketAPIsTool +from app.api.tools.social_tool import SocialAPIsTool +from app.api.tools.news_tool import NewsAPIsTool + +__all__ = ["MarketAPIsTool", "NewsAPIsTool", "SocialAPIsTool"] \ No newline at end of file diff --git a/src/app/api/tools/market_tool.py b/src/app/api/tools/market_tool.py new file mode 100644 index 0000000..36f6286 --- /dev/null +++ b/src/app/api/tools/market_tool.py @@ -0,0 +1,80 @@ +from agno.tools import Toolkit +from app.api.wrapper_handler import WrapperHandler +from app.api.core.markets import MarketWrapper, Price, ProductInfo +from app.api.markets import BinanceWrapper, CoinBaseWrapper, CryptoCompareWrapper, YFinanceWrapper + +class MarketAPIsTool(MarketWrapper, Toolkit): + """ + Class that aggregates multiple market API wrappers and manages them using WrapperHandler. + This class supports retrieving product information and historical prices. + This class can also aggregate data from multiple sources to provide a more comprehensive view of the market. + The following wrappers are included in this order: + - BinanceWrapper + - YFinanceWrapper + - CoinBaseWrapper + - CryptoCompareWrapper + """ + + def __init__(self, currency: str = "USD"): + """ + Initialize the MarketAPIsTool with multiple market API wrappers. + The following wrappers are included in this order: + - BinanceWrapper + - YFinanceWrapper + - CoinBaseWrapper + - CryptoCompareWrapper + Args: + currency (str): Valuta in cui restituire i prezzi. Default è "USD". + """ + kwargs = {"currency": currency or "USD"} + wrappers: list[type[MarketWrapper]] = [BinanceWrapper, YFinanceWrapper, CoinBaseWrapper, CryptoCompareWrapper] + self.handler = WrapperHandler.build_wrappers(wrappers, kwargs=kwargs) + + Toolkit.__init__( # type: ignore + self, + name="Market APIs Toolkit", + tools=[ + self.get_product, + self.get_products, + self.get_historical_prices, + self.get_products_aggregated, + self.get_historical_prices_aggregated, + ], + ) + + def get_product(self, asset_id: str) -> ProductInfo: + return self.handler.try_call(lambda w: w.get_product(asset_id)) + def get_products(self, asset_ids: list[str]) -> list[ProductInfo]: + return self.handler.try_call(lambda w: w.get_products(asset_ids)) + def get_historical_prices(self, asset_id: str, limit: int = 100) -> list[Price]: + return self.handler.try_call(lambda w: w.get_historical_prices(asset_id, limit)) + + + def get_products_aggregated(self, asset_ids: list[str]) -> list[ProductInfo]: + """ + Restituisce i dati aggregati per una lista di asset_id.\n + Attenzione che si usano tutte le fonti, quindi potrebbe usare molte chiamate API (che potrebbero essere a pagamento). + Args: + asset_ids (list[str]): Lista di asset_id da cercare. + Returns: + list[ProductInfo]: Lista di ProductInfo aggregati. + Raises: + Exception: If all wrappers fail to provide results. + """ + all_products = self.handler.try_call_all(lambda w: w.get_products(asset_ids)) + return ProductInfo.aggregate(all_products) + + def get_historical_prices_aggregated(self, asset_id: str = "BTC", limit: int = 100) -> list[Price]: + """ + Restituisce i dati storici aggregati per un asset_id. Usa i dati di tutte le fonti disponibili e li aggrega.\n + Attenzione che si usano tutte le fonti, quindi potrebbe usare molte chiamate API (che potrebbero essere a pagamento). + Args: + asset_id (str): Asset ID da cercare. + limit (int): Numero massimo di dati storici da restituire. + Returns: + list[Price]: Lista di Price aggregati. + Raises: + Exception: If all wrappers fail to provide results. + """ + all_prices = self.handler.try_call_all(lambda w: w.get_historical_prices(asset_id, limit)) + return Price.aggregate(all_prices) diff --git a/src/app/api/tools/news_tool.py b/src/app/api/tools/news_tool.py new file mode 100644 index 0000000..ab67f8b --- /dev/null +++ b/src/app/api/tools/news_tool.py @@ -0,0 +1,72 @@ +from agno.tools import Toolkit +from app.api.wrapper_handler import WrapperHandler +from app.api.core.news import NewsWrapper, Article +from app.api.news import NewsApiWrapper, GoogleNewsWrapper, CryptoPanicWrapper, DuckDuckGoWrapper + +class NewsAPIsTool(NewsWrapper, Toolkit): + """ + Aggregates multiple news API wrappers and manages them using WrapperHandler. + This class supports retrieving top headlines and latest news articles by querying multiple sources: + - GoogleNewsWrapper + - DuckDuckGoWrapper + - NewsApiWrapper + - CryptoPanicWrapper + + By default, it returns results from the first successful wrapper. + Optionally, it can be configured to collect articles from all wrappers. + If no wrapper succeeds, an exception is raised. + """ + + 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: list[type[NewsWrapper]] = [GoogleNewsWrapper, DuckDuckGoWrapper, NewsApiWrapper, CryptoPanicWrapper] + self.handler = WrapperHandler.build_wrappers(wrappers) + + Toolkit.__init__( # type: ignore + self, + name="News APIs Toolkit", + tools=[ + self.get_top_headlines, + self.get_latest_news, + self.get_top_headlines_aggregated, + self.get_latest_news_aggregated, + ], + ) + + def get_top_headlines(self, limit: int = 100) -> list[Article]: + return self.handler.try_call(lambda w: w.get_top_headlines(limit)) + def get_latest_news(self, query: str, limit: int = 100) -> list[Article]: + return self.handler.try_call(lambda w: w.get_latest_news(query, limit)) + + def get_top_headlines_aggregated(self, limit: int = 100) -> dict[str, list[Article]]: + """ + Calls get_top_headlines on all wrappers/providers and returns a dictionary mapping their names to their articles. + Args: + limit (int): Maximum number of articles to retrieve from each provider. + Returns: + dict[str, list[Article]]: A dictionary mapping providers names to their list of Articles + Raises: + Exception: If all wrappers fail to provide results. + """ + return self.handler.try_call_all(lambda w: w.get_top_headlines(limit)) + + def get_latest_news_aggregated(self, query: str, limit: int = 100) -> dict[str, list[Article]]: + """ + Calls get_latest_news on all wrappers/providers and returns a dictionary mapping their names to their articles. + Args: + query (str): The search query to find relevant news articles. + limit (int): Maximum number of articles to retrieve from each provider. + Returns: + dict[str, list[Article]]: A dictionary mapping providers names to their list of Articles + Raises: + Exception: If all wrappers fail to provide results. + """ + return self.handler.try_call_all(lambda w: w.get_latest_news(query, limit)) diff --git a/src/app/api/tools/social_tool.py b/src/app/api/tools/social_tool.py new file mode 100644 index 0000000..630e14d --- /dev/null +++ b/src/app/api/tools/social_tool.py @@ -0,0 +1,51 @@ +from agno.tools import Toolkit +from app.api.wrapper_handler import WrapperHandler +from app.api.core.social import SocialPost, SocialWrapper +from app.api.social import 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: list[type[SocialWrapper]] = [RedditWrapper] + self.handler = WrapperHandler.build_wrappers(wrappers) + + Toolkit.__init__( # type: ignore + self, + name="Socials Toolkit", + tools=[ + self.get_top_crypto_posts, + self.get_top_crypto_posts_aggregated, + ], + ) + + def get_top_crypto_posts(self, limit: int = 5) -> list[SocialPost]: + return self.handler.try_call(lambda w: w.get_top_crypto_posts(limit)) + + def get_top_crypto_posts_aggregated(self, limit_per_wrapper: int = 5) -> dict[str, list[SocialPost]]: + """ + Calls get_top_crypto_posts on all wrappers/providers and returns a dictionary mapping their names to their posts. + Args: + limit_per_wrapper (int): Maximum number of posts to retrieve from each provider. + Returns: + dict[str, list[SocialPost]]: A dictionary where keys are wrapper names and values are lists of SocialPost objects. + Raises: + Exception: If all wrappers fail to provide results. + """ + return self.handler.try_call_all(lambda w: w.get_top_crypto_posts(limit_per_wrapper)) diff --git a/src/app/api/wrapper_handler.py b/src/app/api/wrapper_handler.py index 504cf41..d28bd62 100644 --- a/src/app/api/wrapper_handler.py +++ b/src/app/api/wrapper_handler.py @@ -35,6 +35,16 @@ class WrapperHandler(Generic[WrapperType]): self.retry_delay = retry_delay self.index = 0 + def set_retries(self, try_per_wrapper: int, retry_delay: int) -> None: + """ + Sets the retry parameters for the handler. + Args: + try_per_wrapper (int): Number of retries per wrapper before switching to the next. + retry_delay (int): Delay in seconds between retries. + """ + self.retry_per_wrapper = try_per_wrapper + self.retry_delay = retry_delay + def try_call(self, func: Callable[[WrapperType], OutputType]) -> OutputType: """ Attempts to call the provided function on the current wrapper. diff --git a/src/app/configs.py b/src/app/configs.py new file mode 100644 index 0000000..6da942f --- /dev/null +++ b/src/app/configs.py @@ -0,0 +1,232 @@ +import os +import threading +import ollama +import yaml +import logging.config +import agno.utils.log # type: ignore +from typing import Any +from pydantic import BaseModel +from agno.agent import Agent +from agno.tools import Toolkit +from agno.models.base import Model +from agno.models.google import Gemini +from agno.models.ollama import Ollama + +log = logging.getLogger(__name__) + + + +class AppModel(BaseModel): + name: str = "gemini-2.0-flash" + label: str = "Gemini" + model: type[Model] | None = None + + def get_model(self, instructions: str) -> Model: + """ + Restituisce un'istanza del modello specificato. + Args: + instructions: istruzioni da passare al modello (system prompt). + Returns: + Un'istanza di BaseModel o una sua sottoclasse. + Raise: + ValueError se il modello non è supportato. + """ + if self.model is None: + raise ValueError(f"Model class for '{self.name}' is not set.") + return self.model(id=self.name, instructions=[instructions]) + + def get_agent(self, instructions: str, name: str = "", output_schema: type[BaseModel] | None = None, tools: list[Toolkit] | None = None) -> Agent: + """ + Costruisce un agente con il modello e le istruzioni specificate. + Args: + instructions: istruzioni da passare al modello (system prompt) + name: nome dell'agente (opzionale) + output: schema di output opzionale (Pydantic BaseModel) + tools: lista opzionale di strumenti (tools) da fornire all'agente + Returns: + Un'istanza di Agent. + """ + return Agent( + model=self.get_model(instructions), + name=name, + retries=2, + tools=tools, + delay_between_retries=5, # seconds + output_schema=output_schema + ) + +class APIConfig(BaseModel): + retry_attempts: int = 3 + retry_delay_seconds: int = 2 + currency: str = "USD" + +class Strategy(BaseModel): + name: str = "Conservative" + label: str = "Conservative" + description: str = "Focus on low-risk investments with steady returns." + +class ModelsConfig(BaseModel): + gemini: list[AppModel] = [AppModel()] + ollama: list[AppModel] = [] + + @property + def all_models(self) -> list[AppModel]: + return self.gemini + self.ollama + +class AgentsConfigs(BaseModel): + strategy: str = "Conservative" + team_model: str = "gemini-2.0-flash" + team_leader_model: str = "gemini-2.0-flash" + predictor_model: str = "gemini-2.0-flash" + +class AppConfig(BaseModel): + port: int = 8000 + gradio_share: bool = False + logging_level: str = "INFO" + api: APIConfig = APIConfig() + strategies: list[Strategy] = [Strategy()] + models: ModelsConfig = ModelsConfig() + agents: AgentsConfigs = AgentsConfigs() + + __lock = threading.Lock() + + @classmethod + def load(cls, file_path: str = "configs.yaml") -> 'AppConfig': + """ + Load the application configuration from a YAML file. + Be sure to call load_dotenv() before if you use environment variables. + Args: + file_path: path to the YAML configuration file. + Returns: + An instance of AppConfig with the loaded settings. + """ + with open(file_path, 'r') as f: + data = yaml.safe_load(f) + + configs = cls(**data) + configs.set_logging_level() + configs.validate_models() + log.info(f"Loaded configuration from {file_path}") + return configs + + def __new__(cls, *args: Any, **kwargs: Any) -> 'AppConfig': + with cls.__lock: + if not hasattr(cls, 'instance'): + cls.instance = super(AppConfig, cls).__new__(cls) + return cls.instance + + def get_model_by_name(self, name: str) -> AppModel: + """ + Retrieve a model configuration by its name. + Args: + name: the name of the model to retrieve. + Returns: + The AppModel instance if found. + Raises: + ValueError if no model with the specified name is found. + """ + for model in self.models.all_models: + if model.name == name: + return model + raise ValueError(f"Model with name '{name}' not found.") + + def get_strategy_by_name(self, name: str) -> Strategy: + """ + Retrieve a strategy configuration by its name. + Args: + name: the name of the strategy to retrieve. + Returns: + The Strategy instance if found. + Raises: + ValueError if no strategy with the specified name is found. + """ + for strat in self.strategies: + if strat.name == name: + return strat + raise ValueError(f"Strategy with name '{name}' not found.") + + def set_logging_level(self) -> None: + """ + Set the logging level based on the configuration. + """ + logging.config.dictConfig({ + 'version': 1, + 'disable_existing_loggers': False, # Keep existing loggers (e.g. third-party loggers) + 'formatters': { + 'colored': { + '()': 'colorlog.ColoredFormatter', + 'format': '%(log_color)s%(levelname)s%(reset)s [%(asctime)s] (%(name)s) - %(message)s' + }, + }, + 'handlers': { + 'console': { + 'class': 'logging.StreamHandler', + 'formatter': 'colored', + 'level': self.logging_level, + }, + }, + 'root': { # Configure the root logger + 'handlers': ['console'], + 'level': self.logging_level, + }, + 'loggers': { + 'httpx': {'level': 'WARNING'}, # Too much spam for INFO + } + }) + + # Modify the agno loggers + agno_logger_names = ["agno", "agno-team", "agno-workflow"] + for logger_name in agno_logger_names: + logger = logging.getLogger(logger_name) + logger.handlers.clear() + logger.propagate = True + + def validate_models(self) -> None: + """ + Validate the configured models for each provider. + """ + self.__validate_online_models("gemini", clazz=Gemini, key="GOOGLE_API_KEY") + self.__validate_ollama_models() + + def __validate_online_models(self, provider: str, clazz: type[Model], key: str | None = None) -> None: + """ + Validate models for online providers like Gemini. + Args: + provider: name of the provider (e.g. "gemini") + clazz: class of the model (e.g. Gemini) + key: API key required for the provider (optional) + """ + if getattr(self.models, provider) is None: + log.warning(f"No models configured for provider '{provider}'.") + + models: list[AppModel] = getattr(self.models, provider) + if key and os.getenv(key) is None: + log.warning(f"No {key} set in environment variables for {provider}.") + models.clear() + return + + for model in models: + model.model = clazz + + def __validate_ollama_models(self) -> None: + """ + Validate models for the Ollama provider. + """ + try: + models_list = ollama.list() + availables = {model['model'] for model in models_list['models']} + not_availables: list[str] = [] + + for model in self.models.ollama: + if model.name in availables: + model.model = Ollama + else: + not_availables.append(model.name) + if not_availables: + log.warning(f"Ollama models not available: {not_availables}") + + self.models.ollama = [model for model in self.models.ollama if model.model] + + except Exception as e: + log.warning(f"Ollama is not running or not reachable: {e}") + diff --git a/src/app/interface/__init__.py b/src/app/interface/__init__.py new file mode 100644 index 0000000..dc925f8 --- /dev/null +++ b/src/app/interface/__init__.py @@ -0,0 +1,3 @@ +from app.interface.chat import ChatManager + +__all__ = ["ChatManager"] diff --git a/src/app/utils/chat_manager.py b/src/app/interface/chat.py similarity index 100% rename from src/app/utils/chat_manager.py rename to src/app/interface/chat.py diff --git a/src/app/utils/__init__.py b/src/app/utils/__init__.py deleted file mode 100644 index 579b141..0000000 --- a/src/app/utils/__init__.py +++ /dev/null @@ -1,3 +0,0 @@ -from app.utils.chat_manager import ChatManager - -__all__ = ["ChatManager"] diff --git a/tests/agents/test_predictor.py b/tests/agents/test_predictor.py deleted file mode 100644 index 2dda67e..0000000 --- a/tests/agents/test_predictor.py +++ /dev/null @@ -1,56 +0,0 @@ -import pytest -from app.agents import AppModels -from app.agents.predictor import PREDICTOR_INSTRUCTIONS, PredictorInput, PredictorOutput, PredictorStyle -from app.api.base.markets import ProductInfo - -def unified_checks(model: AppModels, input: PredictorInput) -> None: - llm = model.get_agent(PREDICTOR_INSTRUCTIONS, output_schema=PredictorOutput) # type: ignore[arg-type] - result = llm.run(input) # type: ignore - content = result.content - - assert isinstance(content, PredictorOutput) - assert content.strategy not in (None, "", "null") - assert isinstance(content.strategy, str) - assert isinstance(content.portfolio, list) - assert len(content.portfolio) > 0 - for item in content.portfolio: - assert item.asset not in (None, "", "null") - assert isinstance(item.asset, str) - assert item.percentage >= 0.0 - assert item.percentage <= 100.0 - assert isinstance(item.percentage, (int, float)) - assert item.motivation not in (None, "", "null") - assert isinstance(item.motivation, str) - # La somma delle percentuali deve essere esattamente 100 - total_percentage = sum(item.percentage for item in content.portfolio) - assert abs(total_percentage - 100) < 0.01 # Permette una piccola tolleranza per errori di arrotondamento - -class TestPredictor: - - def inputs(self) -> PredictorInput: - data: list[ProductInfo] = [] - for symbol, price in [("BTC", 60000.00), ("ETH", 3500.00), ("SOL", 150.00)]: - product_info = ProductInfo() - product_info.symbol = symbol - product_info.price = price - data.append(product_info) - - return PredictorInput(data=data, style=PredictorStyle.AGGRESSIVE, sentiment="positivo") - - def test_gemini_model_output(self): - inputs = self.inputs() - unified_checks(AppModels.GEMINI, inputs) - - def test_ollama_qwen_4b_model_output(self): - inputs = self.inputs() - unified_checks(AppModels.OLLAMA_QWEN_4B, inputs) - - @pytest.mark.slow - def test_ollama_qwen_latest_model_output(self): - inputs = self.inputs() - unified_checks(AppModels.OLLAMA_QWEN, inputs) - - @pytest.mark.slow - def test_ollama_gpt_oss_model_output(self): - inputs = self.inputs() - unified_checks(AppModels.OLLAMA_GPT, inputs) diff --git a/tests/tools/test_market_tool.py b/tests/tools/test_market_tool.py index 5e28edd..ea90bf2 100644 --- a/tests/tools/test_market_tool.py +++ b/tests/tools/test_market_tool.py @@ -1,5 +1,5 @@ import pytest -from app.api.markets import MarketAPIsTool +from app.api.tools import MarketAPIsTool @pytest.mark.tools diff --git a/tests/tools/test_news_tool.py b/tests/tools/test_news_tool.py index 5f685a8..370ea71 100644 --- a/tests/tools/test_news_tool.py +++ b/tests/tools/test_news_tool.py @@ -1,5 +1,5 @@ import pytest -from app.api.news import NewsAPIsTool +from app.api.tools import NewsAPIsTool @pytest.mark.tools diff --git a/tests/tools/test_socials_tool.py b/tests/tools/test_socials_tool.py index 29a81ae..c021a90 100644 --- a/tests/tools/test_socials_tool.py +++ b/tests/tools/test_socials_tool.py @@ -1,5 +1,5 @@ import pytest -from app.api.social import SocialAPIsTool +from app.api.tools import SocialAPIsTool @pytest.mark.tools diff --git a/tests/utils/test_market_aggregator.py b/tests/utils/test_market_aggregator.py index 8c6ea18..0d62985 100644 --- a/tests/utils/test_market_aggregator.py +++ b/tests/utils/test_market_aggregator.py @@ -1,6 +1,6 @@ import pytest from datetime import datetime -from app.api.base.markets import ProductInfo, Price +from app.api.core.markets import ProductInfo, Price @pytest.mark.aggregator diff --git a/uv.lock b/uv.lock index d8114d6..bb0b6b5 100644 --- a/uv.lock +++ b/uv.lock @@ -285,6 +285,18 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/d1/d6/3965ed04c63042e047cb6a3e6ed1a63a35087b6a609aa3a15ed8ac56c221/colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6", size = 25335, upload-time = "2022-10-25T02:36:20.889Z" }, ] +[[package]] +name = "colorlog" +version = "6.9.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "colorama", marker = "sys_platform == 'win32'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/d3/7a/359f4d5df2353f26172b3cc39ea32daa39af8de522205f512f458923e677/colorlog-6.9.0.tar.gz", hash = "sha256:bfba54a1b93b94f54e1f4fe48395725a3d92fd2a4af702f6bd70946bdc0c6ac2", size = 16624, upload-time = "2024-10-29T18:34:51.011Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e3/51/9b208e85196941db2f0654ad0357ca6388ab3ed67efdbfc799f35d1f83aa/colorlog-6.9.0-py3-none-any.whl", hash = "sha256:5906e71acd67cb07a71e779c47c4bcb45fb8c2993eebe9e5adcd6a6f1b283eff", size = 11424, upload-time = "2024-10-29T18:34:49.815Z" }, +] + [[package]] name = "cryptography" version = "46.0.2" @@ -1604,6 +1616,7 @@ source = { virtual = "." } dependencies = [ { name = "agno" }, { name = "coinbase-advanced-py" }, + { name = "colorlog" }, { name = "ddgs" }, { name = "dotenv" }, { name = "gnews" }, @@ -1621,6 +1634,7 @@ dependencies = [ requires-dist = [ { name = "agno" }, { name = "coinbase-advanced-py" }, + { name = "colorlog" }, { name = "ddgs" }, { name = "dotenv" }, { name = "gnews" }, From 45a17d4570f1439cf029db986b52a3239f3aefdb Mon Sep 17 00:00:00 2001 From: Giacomo Bertolazzi <31776951+Berack96@users.noreply.github.com> Date: Sun, 12 Oct 2025 23:47:56 +0200 Subject: [PATCH 4/7] lock variable fix (#28) Refactor the lock variable in AppConfig to use ClassVar for pydantic error. --- src/app/configs.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/app/configs.py b/src/app/configs.py index 6da942f..59dad48 100644 --- a/src/app/configs.py +++ b/src/app/configs.py @@ -4,7 +4,7 @@ import ollama import yaml import logging.config import agno.utils.log # type: ignore -from typing import Any +from typing import Any, ClassVar from pydantic import BaseModel from agno.agent import Agent from agno.tools import Toolkit @@ -88,7 +88,7 @@ class AppConfig(BaseModel): models: ModelsConfig = ModelsConfig() agents: AgentsConfigs = AgentsConfigs() - __lock = threading.Lock() + __lock: ClassVar[threading.Lock] = threading.Lock() @classmethod def load(cls, file_path: str = "configs.yaml") -> 'AppConfig': From c96617a03911a115939ffd328b04b86f92a764d8 Mon Sep 17 00:00:00 2001 From: Giacomo Bertolazzi <31776951+Berack96@users.noreply.github.com> Date: Mon, 13 Oct 2025 10:49:46 +0200 Subject: [PATCH 5/7] Telegram bot support (#23) * Aggiungi supporto per il bot Telegram: aggiorna .env.example, pyproject.toml e uv.lock * demo per bot Telegram con gestione comandi e inline keyboard * Implementazione del bot Telegram con gestione dei comandi e stati di conversazione iniziali * Aggiorna la gestione delle configurazioni nel bot Telegram: modifica gli stati della conversazione e aggiungi il supporto per la gestione dei messaggi. * fix static models & readme * aggiunto il supporto per la query dell'utente e modificata la visualizzazione dei messaggi di stato. * Aggiunto il supporto per la gestione del bot Telegram e aggiornata la configurazione del pipeline * Aggiornato .gitignore per includere la cartella .gradio e rimosso chroma_db. Aggiunto il supporto per la generazione di report in PDF utilizzando markdown-pdf nel bot Telegram. * Refactor pipeline and chat manager for improved structure and functionality * Better logging * Aggiornato il comportamento del logging per i logger di agno. Aggiunto il supporto per l'opzione check_for_async nella configurazione di RedditWrapper. * Rimosso codice commentato e import non utilizzati nella classe Pipeline per semplificare la struttura * Aggiornata la sezione "Applicazione" nel README & fix main * Telegram instance instead of static * Fix logging to use labels for team model, leader model, and strategy * Rinomina il lock da _lock a __lock per garantire l'incapsulamento nella classe AppConfig * Rinomina i logger per una migliore identificazione e gestisce le eccezioni nel bot di Telegram * Aggiorna i messaggi di errore nel gestore Telegram per una migliore chiarezza e modifica il commento nel file di configurazione per riflettere lo stato del modello. * Aggiungi un messaggio di attesa durante la generazione del report nel bot di Telegram --- .env.example | 11 ++ .gitignore | 8 +- README.md | 21 ++- configs.yaml | 4 +- demos/telegram_bot_demo.py | 59 +++++++ pyproject.toml | 4 + src/app/__main__.py | 89 ++-------- src/app/agents/pipeline.py | 81 ++++----- src/app/api/social/reddit.py | 1 + src/app/api/wrapper_handler.py | 13 +- src/app/configs.py | 11 ++ src/app/interface/__init__.py | 3 +- src/app/interface/chat.py | 71 +++++++- src/app/interface/telegram_app.py | 264 ++++++++++++++++++++++++++++++ uv.lock | 50 +++++- 15 files changed, 541 insertions(+), 149 deletions(-) create mode 100644 demos/telegram_bot_demo.py create mode 100644 src/app/interface/telegram_app.py diff --git a/.env.example b/.env.example index fd9a427..ce6f756 100644 --- a/.env.example +++ b/.env.example @@ -5,6 +5,7 @@ # https://makersuite.google.com/app/apikey GOOGLE_API_KEY= + ############################################################################### # Configurazioni per gli agenti di mercato ############################################################################### @@ -21,6 +22,7 @@ CRYPTOCOMPARE_API_KEY= BINANCE_API_KEY= BINANCE_API_SECRET= + ############################################################################### # Configurazioni per gli agenti di notizie ############################################################################### @@ -31,6 +33,7 @@ NEWS_API_KEY= # https://cryptopanic.com/developers/api/ CRYPTOPANIC_API_KEY= + ############################################################################### # Configurazioni per API di social media ############################################################################### @@ -38,3 +41,11 @@ CRYPTOPANIC_API_KEY= # https://www.reddit.com/prefs/apps REDDIT_API_CLIENT_ID= REDDIT_API_CLIENT_SECRET= + + +############################################################################### +# Configurazioni per API di messaggistica +############################################################################### + +# https://core.telegram.org/bots/features#creating-a-new-bot +TELEGRAM_BOT_TOKEN= diff --git a/.gitignore b/.gitignore index b532676..609ad99 100644 --- a/.gitignore +++ b/.gitignore @@ -173,8 +173,8 @@ cython_debug/ # PyPI configuration file .pypirc -# chroma db -./chroma_db/ - # VS Code -.vscode/ \ No newline at end of file +.vscode/ + +# Gradio +.gradio/ diff --git a/README.md b/README.md index 662a9b3..1c5f023 100644 --- a/README.md +++ b/README.md @@ -91,13 +91,22 @@ uv run src/app # **Applicazione** -***L'applicazione è attualmente in fase di sviluppo.*** +> [!CAUTION]\ +> ***L'applicazione è attualmente in fase di sviluppo.*** -Usando la libreria ``gradio`` è stata creata un'interfaccia web semplice per interagire con l'agente principale. Gli agenti secondari si trovano nella cartella `src/app/agents` e sono: -- **Market Agent**: Agente unificato che supporta multiple fonti di dati con auto-retry e gestione degli errori. -- **News Agent**: Recupera le notizie finanziarie più recenti sul mercato delle criptovalute. -- **Social Agent**: Analizza i sentimenti sui social media riguardo alle criptovalute. -- **Predictor Agent**: Utilizza i dati raccolti dagli altri agenti per fare previsioni. +L'applicazione viene fatta partire tramite il file [src/app/\_\_main\_\_.py](src/app/__main__.py) che inizializza l'agente principale e gli agenti secondari. + +In esso viene creato il server `gradio` per l'interfaccia web e viene anche inizializzato il bot di Telegram (se è stata inserita la chiave nel file `.env` ottenuta da [BotFather](https://core.telegram.org/bots/features#creating-a-new-bot)). + +L'interazione è guidata, sia tramite l'interfaccia web che tramite il bot di Telegram; l'utente può scegliere prima di tutto delle opzioni generali (come il modello e la strategia di investimento), dopodiché può inviare un messaggio di testo libero per chiedere consigli o informazioni specifiche. Per esempio: "Qual è l'andamento attuale di Bitcoin?" o "Consigliami quali sono le migliori criptovalute in cui investire questo mese". + +L'applicazione, una volta ricevuta la richiesta, la passa al [Team](src/app/agents/team.py) di agenti che si occupano di raccogliere i dati necessari per rispondere in modo completo e ragionato. + +Gli agenti coinvolti nel Team sono: +- **Leader**: Coordina gli altri agenti e fornisce la risposta finale all'utente. +- **Market Agent**: Recupera i dati di mercato attuali delle criptovalute da Binance e Yahoo Finance. +- **News Agent**: Recupera le ultime notizie sul mercato delle criptovalute da NewsAPI e GNews. +- **Social Agent**: Recupera i dati dai social media (Reddit) per analizzare il sentiment del mercato. ## Struttura del codice del Progetto diff --git a/configs.yaml b/configs.yaml index e2f444d..5d70b13 100644 --- a/configs.yaml +++ b/configs.yaml @@ -17,8 +17,8 @@ models: gemini: - name: gemini-2.0-flash label: Gemini - - name: gemini-2.0-pro - label: Gemini Pro + # - name: gemini-2.0-pro # TODO Non funziona, ha un nome diverso + # label: Gemini Pro ollama: - name: gpt-oss:latest label: Ollama GPT diff --git a/demos/telegram_bot_demo.py b/demos/telegram_bot_demo.py new file mode 100644 index 0000000..2a2b7d9 --- /dev/null +++ b/demos/telegram_bot_demo.py @@ -0,0 +1,59 @@ +import os +from dotenv import load_dotenv +from telegram import InlineKeyboardButton, InlineKeyboardMarkup, Update +from telegram.ext import Application, CommandHandler, CallbackQueryHandler, MessageHandler, filters, ContextTypes + +# Esempio di funzione per gestire il comando /start +async def start(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None: + if not update.message: return + await update.message.reply_text('Ciao! Inviami un messaggio e ti risponderò!') + + +# Esempio di funzione per fare echo del messaggio ricevuto +async def echo(update: Update, context: ContextTypes.DEFAULT_TYPE): + message = update.message + if not message: return + + print(f"Ricevuto messaggio: {message.text} da chat id: {message.chat.id}") + await message.reply_text(text=f"Hai detto: {message.text}") + + +# Esempio di funzione per far partire una inline keyboard (comando /keyboard) +async def inline_keyboard(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None: + if not update.message: return + keyboard = [ + [ + InlineKeyboardButton("Option 1", callback_data='1'), + InlineKeyboardButton("Option 2", callback_data='2'), + ] + ] + reply_markup = InlineKeyboardMarkup(keyboard) + await update.message.reply_text('Please choose:', reply_markup=reply_markup) + + +async def button_handler(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None: + query = update.callback_query + if not query: return + await query.answer() + await query.edit_message_text(text=f"Selected option: {query.data}") + + + + + +def main(): + print("Bot in ascolto...") + + load_dotenv() + token = os.getenv("TELEGRAM_BOT_TOKEN", '') + app = Application.builder().token(token).build() + + app.add_handler(CommandHandler("start", start)) + app.add_handler(CommandHandler("keyboard", inline_keyboard)) + app.add_handler(MessageHandler(filters=filters.TEXT, callback=echo)) + app.add_handler(CallbackQueryHandler(button_handler)) + + app.run_polling(allowed_updates=Update.ALL_TYPES) + +if __name__ == "__main__": + main() \ No newline at end of file diff --git a/pyproject.toml b/pyproject.toml index 97eb413..127d77a 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -36,6 +36,10 @@ dependencies = [ # API di social media "praw", # Reddit + + # Per telegram bot + "python-telegram-bot", # Interfaccia Telegram Bot + "markdown-pdf", # Per convertire markdown in pdf ] [tool.pytest.ini_options] diff --git a/src/app/__main__.py b/src/app/__main__.py index 4347ecf..dca46fb 100644 --- a/src/app/__main__.py +++ b/src/app/__main__.py @@ -1,86 +1,31 @@ import asyncio -import gradio as gr +import logging from dotenv import load_dotenv -from agno.utils.log import log_info #type: ignore from app.configs import AppConfig -from app.interface import ChatManager +from app.interface import * from app.agents import Pipeline if __name__ == "__main__": - # Inizializzazioni load_dotenv() configs = AppConfig.load() pipeline = Pipeline(configs) - chat = ChatManager() - - ######################################## - # Funzioni Gradio - ######################################## - def respond(message: str, history: list[dict[str, str]]) -> tuple[list[dict[str, str]], list[dict[str, str]], str]: - chat.send_message(message) - response = pipeline.interact(message) - chat.receive_message(response) - history.append({"role": "user", "content": message}) - history.append({"role": "assistant", "content": response}) - return history, history, "" - - def save_current_chat() -> str: - chat.save_chat("chat.json") - return "💾 Chat salvata in chat.json" - - def load_previous_chat() -> tuple[list[dict[str, str]], list[dict[str, str]]]: - chat.load_chat("chat.json") - history: list[dict[str, str]] = [] - for m in chat.get_history(): - history.append({"role": m["role"], "content": m["content"]}) - return history, history - - def reset_chat() -> tuple[list[dict[str, str]], list[dict[str, str]]]: - chat.reset_chat() - return [], [] - - ######################################## - # Interfaccia Gradio - ######################################## - with gr.Blocks() as demo: - gr.Markdown("# 🤖 Agente di Analisi e Consulenza Crypto (Chat)") - - # Dropdown provider e stile - with gr.Row(): - provider = gr.Dropdown( - choices=pipeline.list_providers(), - type="index", - label="Modello da usare" - ) - provider.change(fn=pipeline.choose_predictor, inputs=provider, outputs=None) - - style = gr.Dropdown( - choices=pipeline.list_styles(), - type="index", - label="Stile di investimento" - ) - style.change(fn=pipeline.choose_strategy, inputs=style, outputs=None) - - chatbot = gr.Chatbot(label="Conversazione", height=500, type="messages") - msg = gr.Textbox(label="Scrivi la tua richiesta", placeholder="Es: Quali sono le crypto interessanti oggi?") - - with gr.Row(): - clear_btn = gr.Button("🗑️ Reset Chat") - save_btn = gr.Button("💾 Salva Chat") - load_btn = gr.Button("📂 Carica Chat") - - # Eventi e interazioni - msg.submit(respond, inputs=[msg, chatbot], outputs=[chatbot, chatbot, msg]) - clear_btn.click(reset_chat, inputs=None, outputs=[chatbot, chatbot]) - save_btn.click(save_current_chat, inputs=None, outputs=None) - load_btn.click(load_previous_chat, inputs=None, outputs=[chatbot, chatbot]) + chat = ChatManager(pipeline) + gradio = chat.gradio_build_interface() + _app, local_url, share_url = gradio.launch(server_name="0.0.0.0", server_port=configs.port, quiet=True, prevent_thread_lock=True, share=configs.gradio_share) + logging.info(f"UPO AppAI Chat is running on {share_url or local_url}") try: - _app, local, shared = demo.launch(server_name="0.0.0.0", server_port=configs.port, quiet=True, prevent_thread_lock=True, share=configs.gradio_share) - log_info(f"Starting UPO AppAI Chat on {shared or local}") - asyncio.get_event_loop().run_forever() - except KeyboardInterrupt: - demo.close() + telegram = TelegramApp(pipeline) + telegram.add_miniapp_url(share_url) + telegram.run() + except AssertionError as e: + try: + logging.warning(f"Telegram bot could not be started: {e}") + asyncio.get_event_loop().run_forever() + except KeyboardInterrupt: + logging.info("Shutting down due to KeyboardInterrupt") + finally: + gradio.close() diff --git a/src/app/agents/pipeline.py b/src/app/agents/pipeline.py index 3522432..3338cb8 100644 --- a/src/app/agents/pipeline.py +++ b/src/app/agents/pipeline.py @@ -1,10 +1,10 @@ -from agno.run.agent import RunOutput +import logging from app.agents.team import create_team_with -from app.agents.predictor import PredictorInput, PredictorOutput from app.agents.prompts import * -from app.api.core.markets import ProductInfo from app.configs import AppConfig +logging = logging.getLogger("pipeline") + class Pipeline: """ @@ -17,27 +17,30 @@ class Pipeline: self.configs = configs # Stato iniziale - self.choose_strategy(0) - self.choose_predictor(0) + self.leader_model = self.configs.get_model_by_name(self.configs.agents.team_leader_model) + self.team_model = self.configs.get_model_by_name(self.configs.agents.team_model) + self.strategy = self.configs.get_strategy_by_name(self.configs.agents.strategy) # ====================== # Dropdown handlers # ====================== - def choose_predictor(self, index: int): + def choose_leader(self, index: int): """ - Sceglie il modello LLM da usare per il Predictor. + Sceglie il modello LLM da usare per il Team. """ - model = self.configs.models.all_models[index] - self.predictor = model.get_agent( - PREDICTOR_INSTRUCTIONS, - output_schema=PredictorOutput, - ) + self.leader_model = self.configs.models.all_models[index] + + def choose_team(self, index: int): + """ + Sceglie il modello LLM da usare per il Team. + """ + self.team_model = self.configs.models.all_models[index] def choose_strategy(self, index: int): """ Sceglie la strategia da usare per il Predictor. """ - self.strat = self.configs.strategies[index].description + self.strategy = self.configs.strategies[index] # ====================== # Helpers @@ -64,46 +67,18 @@ class Pipeline: 3. Invoca Predictor 4. Restituisce la strategia finale """ - # Step 1: raccolta output dai membri del Team - team_model = self.configs.get_model_by_name(self.configs.agents.team_model) - leader_model = self.configs.get_model_by_name(self.configs.agents.team_leader_model) + # Step 1: Creazione Team + team = create_team_with(self.configs, self.team_model, self.leader_model) - team = create_team_with(self.configs, team_model, leader_model) + # Step 2: raccolta output dai membri del Team + logging.info(f"Pipeline received query: {query}") + # TODO migliorare prompt (?) + query = f"The user query is: {query}\n\n They requested a {self.strategy.label} investment strategy." team_outputs = team.run(query) # type: ignore - # Step 2: aggregazione output strutturati - all_products: list[ProductInfo] = [] - sentiments: list[str] = [] - - for agent_output in team_outputs.member_responses: - if isinstance(agent_output, RunOutput) and agent_output.metadata is not None: - keys = agent_output.metadata.keys() - if "products" in keys: - all_products.extend(agent_output.metadata["products"]) - if "sentiment_news" in keys: - sentiments.append(agent_output.metadata["sentiment_news"]) - if "sentiment_social" in keys: - sentiments.append(agent_output.metadata["sentiment_social"]) - - aggregated_sentiment = "\n".join(sentiments) - - # Step 3: invocazione Predictor - predictor_input = PredictorInput( - data=all_products, - style=self.strat, - sentiment=aggregated_sentiment - ) - - result = self.predictor.run(predictor_input) # type: ignore - if not isinstance(result.content, PredictorOutput): - return "❌ Errore: il modello non ha restituito un output valido." - prediction: PredictorOutput = result.content - - # Step 4: restituzione strategia finale - portfolio_lines = "\n".join( - [f"{item.asset} ({item.percentage}%): {item.motivation}" for item in prediction.portfolio] - ) - return ( - f"📊 Strategia ({self.strat}): {prediction.strategy}\n\n" - f"💼 Portafoglio consigliato:\n{portfolio_lines}" - ) + # Step 3: recupero ouput + if not isinstance(team_outputs.content, str): + logging.error(f"Team output is not a string: {team_outputs.content}") + raise ValueError("Team output is not a string") + logging.info(f"Team finished") + return team_outputs.content diff --git a/src/app/api/social/reddit.py b/src/app/api/social/reddit.py index ca06211..bda7687 100644 --- a/src/app/api/social/reddit.py +++ b/src/app/api/social/reddit.py @@ -59,6 +59,7 @@ class RedditWrapper(SocialWrapper): client_id=client_id, client_secret=client_secret, user_agent="upo-appAI", + check_for_async=False, ) self.subreddits = self.tool.subreddit("+".join(SUBREDDITS)) diff --git a/src/app/api/wrapper_handler.py b/src/app/api/wrapper_handler.py index d28bd62..cf6ce74 100644 --- a/src/app/api/wrapper_handler.py +++ b/src/app/api/wrapper_handler.py @@ -1,9 +1,10 @@ import inspect +import logging import time import traceback from typing import Any, Callable, Generic, TypeVar -from agno.utils.log import log_info, log_warning #type: ignore +logging = logging.getLogger("wrapper_handler") WrapperType = TypeVar("WrapperType") WrapperClassType = TypeVar("WrapperClassType") OutputType = TypeVar("OutputType") @@ -86,7 +87,7 @@ class WrapperHandler(Generic[WrapperType]): Exception: If all wrappers fail after retries. """ - log_info(f"{inspect.getsource(func).strip()} {inspect.getclosurevars(func).nonlocals}") + logging.info(f"{inspect.getsource(func).strip()} {inspect.getclosurevars(func).nonlocals}") results: dict[str, OutputType] = {} starting_index = self.index @@ -96,18 +97,18 @@ class WrapperHandler(Generic[WrapperType]): wrapper_name = wrapper.__class__.__name__ if not try_all: - log_info(f"try_call {wrapper_name}") + logging.info(f"try_call {wrapper_name}") for try_count in range(1, self.retry_per_wrapper + 1): try: result = func(wrapper) - log_info(f"{wrapper_name} succeeded") + logging.info(f"{wrapper_name} succeeded") results[wrapper_name] = result break except Exception as e: error = WrapperHandler.__concise_error(e) - log_warning(f"{wrapper_name} failed {try_count}/{self.retry_per_wrapper}: {error}") + logging.warning(f"{wrapper_name} failed {try_count}/{self.retry_per_wrapper}: {error}") time.sleep(self.retry_delay) if not try_all and results: @@ -153,6 +154,6 @@ class WrapperHandler(Generic[WrapperType]): wrapper = wrapper_class(**(kwargs or {})) result.append(wrapper) except Exception as e: - log_warning(f"{wrapper_class} cannot be initialized: {e}") + logging.warning(f"'{wrapper_class.__name__}' cannot be initialized: {e}") return WrapperHandler(result, try_per_wrapper, retry_delay) \ No newline at end of file diff --git a/src/app/configs.py b/src/app/configs.py index 59dad48..29c2178 100644 --- a/src/app/configs.py +++ b/src/app/configs.py @@ -145,6 +145,17 @@ class AppConfig(BaseModel): return strat raise ValueError(f"Strategy with name '{name}' not found.") + def get_defaults(self) -> tuple[AppModel, AppModel, Strategy]: + """ + Retrieve the default team model, leader model, and strategy. + Returns: + A tuple containing the default team model (AppModel), leader model (AppModel), and strategy (Strategy). + """ + team_model = self.get_model_by_name(self.agents.team_model) + leader_model = self.get_model_by_name(self.agents.team_leader_model) + strategy = self.get_strategy_by_name(self.agents.strategy) + return team_model, leader_model, strategy + def set_logging_level(self) -> None: """ Set the logging level based on the configuration. diff --git a/src/app/interface/__init__.py b/src/app/interface/__init__.py index dc925f8..186558a 100644 --- a/src/app/interface/__init__.py +++ b/src/app/interface/__init__.py @@ -1,3 +1,4 @@ from app.interface.chat import ChatManager +from app.interface.telegram_app import TelegramApp -__all__ = ["ChatManager"] +__all__ = ["ChatManager", "TelegramApp"] diff --git a/src/app/interface/chat.py b/src/app/interface/chat.py index d51819d..aaba2af 100644 --- a/src/app/interface/chat.py +++ b/src/app/interface/chat.py @@ -1,5 +1,8 @@ -import json import os +import json +import gradio as gr +from app.agents.pipeline import Pipeline + class ChatManager: """ @@ -9,8 +12,9 @@ class ChatManager: - salva e ricarica le chat """ - def __init__(self): + def __init__(self, pipeline: Pipeline): self.history: list[dict[str, str]] = [] # [{"role": "user"/"assistant", "content": "..."}] + self.pipeline = pipeline def send_message(self, message: str) -> None: """ @@ -56,3 +60,66 @@ class ChatManager: Restituisce lo storico completo della chat. """ return self.history + + + ######################################## + # Funzioni Gradio + ######################################## + def gradio_respond(self, message: str, history: list[dict[str, str]]) -> tuple[list[dict[str, str]], list[dict[str, str]], str]: + self.send_message(message) + response = self.pipeline.interact(message) + self.receive_message(response) + history.append({"role": "user", "content": message}) + history.append({"role": "assistant", "content": response}) + return history, history, "" + + def gradio_save(self) -> str: + self.save_chat("chat.json") + return "💾 Chat salvata in chat.json" + + def gradio_load(self) -> tuple[list[dict[str, str]], list[dict[str, str]]]: + self.load_chat("chat.json") + history: list[dict[str, str]] = [] + for m in self.get_history(): + history.append({"role": m["role"], "content": m["content"]}) + return history, history + + def gradio_clear(self) -> tuple[list[dict[str, str]], list[dict[str, str]]]: + self.reset_chat() + return [], [] + + def gradio_build_interface(self) -> gr.Blocks: + with gr.Blocks() as interface: + gr.Markdown("# 🤖 Agente di Analisi e Consulenza Crypto (Chat)") + + # Dropdown provider e stile + with gr.Row(): + provider = gr.Dropdown( + choices=self.pipeline.list_providers(), + type="index", + label="Modello da usare" + ) + provider.change(fn=self.pipeline.choose_leader, inputs=provider, outputs=None) + + style = gr.Dropdown( + choices=self.pipeline.list_styles(), + type="index", + label="Stile di investimento" + ) + style.change(fn=self.pipeline.choose_strategy, inputs=style, outputs=None) + + chatbot = gr.Chatbot(label="Conversazione", height=500, type="messages") + msg = gr.Textbox(label="Scrivi la tua richiesta", placeholder="Es: Quali sono le crypto interessanti oggi?") + + with gr.Row(): + clear_btn = gr.Button("🗑️ Reset Chat") + save_btn = gr.Button("💾 Salva Chat") + load_btn = gr.Button("📂 Carica Chat") + + # Eventi e interazioni + msg.submit(self.gradio_respond, inputs=[msg, chatbot], outputs=[chatbot, chatbot, msg]) + clear_btn.click(self.gradio_clear, inputs=None, outputs=[chatbot, chatbot]) + save_btn.click(self.gradio_save, inputs=None, outputs=None) + load_btn.click(self.gradio_load, inputs=None, outputs=[chatbot, chatbot]) + + return interface \ No newline at end of file diff --git a/src/app/interface/telegram_app.py b/src/app/interface/telegram_app.py new file mode 100644 index 0000000..3bef9d9 --- /dev/null +++ b/src/app/interface/telegram_app.py @@ -0,0 +1,264 @@ +import io +import os +import json +import httpx +import logging +import warnings +from enum import Enum +from markdown_pdf import MarkdownPdf, Section +from telegram import CallbackQuery, InlineKeyboardButton, InlineKeyboardMarkup, Message, Update, User +from telegram.constants import ChatAction +from telegram.ext import Application, CallbackQueryHandler, CommandHandler, ContextTypes, ConversationHandler, MessageHandler, filters +from app.agents.pipeline import Pipeline +from app.configs import AppConfig + +# per per_message di ConversationHandler che rompe sempre qualunque input tu metta +warnings.filterwarnings("ignore") +logging = logging.getLogger("telegram") + + +# Lo stato cambia in base al valore di ritorno delle funzioni async +# END state è già definito in telegram.ext.ConversationHandler +# Un semplice schema delle interazioni: +# /start +# ║ +# V +# ╔══ CONFIGS <═════╗ +# ║ ║ ╚══> SELECT_CONFIG +# ║ V +# ║ start_team (polling for updates) +# ║ ║ +# ║ V +# ╚═══> END +CONFIGS, SELECT_CONFIG = range(2) + +# Usato per separare la query arrivata da Telegram +QUERY_SEP = "|==|" + +class ConfigsChat(Enum): + MODEL_TEAM = "Team Model" + MODEL_OUTPUT = "Output Model" + STRATEGY = "Strategy" + +class ConfigsRun: + def __init__(self, configs: AppConfig): + team, leader, strategy = configs.get_defaults() + self.team_model = team + self.leader_model = leader + self.strategy = strategy + self.user_query = "" + + +class TelegramApp: + def __init__(self, pipeline: Pipeline): + token = os.getenv("TELEGRAM_BOT_TOKEN") + assert token, "TELEGRAM_BOT_TOKEN environment variable not set" + + self.user_requests: dict[User, ConfigsRun] = {} + self.pipeline = pipeline + self.token = token + self.create_bot() + + def add_miniapp_url(self, url: str) -> None: + try: + endpoint = f"https://api.telegram.org/bot{self.token}/setChatMenuButton" + payload = {"menu_button": json.dumps({ + "type": "web_app", + "text": "MiniApp", + "web_app": { "url": url } + })} + httpx.post(endpoint, data=payload) + except httpx.HTTPError as e: + logging.warning(f"Failed to update mini app URL: {e}") + + def create_bot(self) -> None: + """ + Initialize the Telegram bot and set up the conversation handler. + """ + app = Application.builder().token(self.token).build() + + app.add_error_handler(self.__error_handler) + app.add_handler(ConversationHandler( + per_message=False, # capire a cosa serve perchè da un warning quando parte il server + entry_points=[CommandHandler('start', self.__start)], + states={ + CONFIGS: [ + CallbackQueryHandler(self.__model_team, pattern=ConfigsChat.MODEL_TEAM.name), + CallbackQueryHandler(self.__model_output, pattern=ConfigsChat.MODEL_OUTPUT.name), + CallbackQueryHandler(self.__strategy, pattern=ConfigsChat.STRATEGY.name), + CallbackQueryHandler(self.__cancel, pattern='^cancel$'), + MessageHandler(filters.TEXT, self.__start_team) # Any text message + ], + SELECT_CONFIG: [ + CallbackQueryHandler(self.__select_config, pattern=f"^__select_config{QUERY_SEP}.*$"), + ] + }, + fallbacks=[CommandHandler('start', self.__start)], + )) + self.app = app + + def run(self) -> None: + self.app.run_polling() + + ######################################## + # Funzioni di utilità + ######################################## + async def start_message(self, user: User, query: CallbackQuery | Message) -> None: + confs = self.user_requests.setdefault(user, ConfigsRun(self.pipeline.configs)) + + str_model_team = f"{ConfigsChat.MODEL_TEAM.value}: {confs.team_model.label}" + str_model_output = f"{ConfigsChat.MODEL_OUTPUT.value}: {confs.leader_model.label}" + str_strategy = f"{ConfigsChat.STRATEGY.value}: {confs.strategy.label}" + + msg, keyboard = ( + "Please choose an option or write your query", + InlineKeyboardMarkup([ + [InlineKeyboardButton(str_model_team, callback_data=ConfigsChat.MODEL_TEAM.name)], + [InlineKeyboardButton(str_model_output, callback_data=ConfigsChat.MODEL_OUTPUT.name)], + [InlineKeyboardButton(str_strategy, callback_data=ConfigsChat.STRATEGY.name)], + [InlineKeyboardButton("Cancel", callback_data='cancel')] + ]) + ) + + if isinstance(query, CallbackQuery): + await query.edit_message_text(msg, reply_markup=keyboard, parse_mode='MarkdownV2') + else: + await query.reply_text(msg, reply_markup=keyboard, parse_mode='MarkdownV2') + + async def handle_callbackquery(self, update: Update) -> tuple[CallbackQuery, User]: + assert update.callback_query and update.callback_query.from_user, "Update callback_query or user is None" + query = update.callback_query + await query.answer() # Acknowledge the callback query + return query, query.from_user + + async def handle_message(self, update: Update) -> tuple[Message, User]: + assert update.message and update.message.from_user, "Update message or user is None" + return update.message, update.message.from_user + + def callback_data(self, strings: list[str]) -> str: + return QUERY_SEP.join(strings) + + async def __error_handler(self, update: object, context: ContextTypes.DEFAULT_TYPE) -> None: + try: + logging.error(f"Unhandled exception in Telegram handler: {context.error}") + + # Try to notify the user in chat if possible + if isinstance(update, Update) and update.effective_chat: + chat_id = update.effective_chat.id + msg = "An error occurred while processing your request." + await context.bot.send_message(chat_id=chat_id, text=msg) + + except Exception: + # Ensure we never raise from the error handler itself + logging.exception("Exception in the error handler") + + ######################################### + # Funzioni async per i comandi e messaggi + ######################################### + async def __start(self, update: Update, context: ContextTypes.DEFAULT_TYPE) -> int: + message, user = await self.handle_message(update) + logging.info(f"@{user.username} started the conversation.") + await self.start_message(user, message) + return CONFIGS + + async def __model_team(self, update: Update, context: ContextTypes.DEFAULT_TYPE) -> int: + return await self._model_select(update, ConfigsChat.MODEL_TEAM) + + async def __model_output(self, update: Update, context: ContextTypes.DEFAULT_TYPE) -> int: + return await self._model_select(update, ConfigsChat.MODEL_OUTPUT) + + async def _model_select(self, update: Update, state: ConfigsChat, msg: str | None = None) -> int: + query, _ = await self.handle_callbackquery(update) + + models = [(m.label, self.callback_data([f"__select_config", str(state), m.name])) for m in self.pipeline.configs.models.all_models] + inline_btns = [[InlineKeyboardButton(name, callback_data=callback_data)] for name, callback_data in models] + + await query.edit_message_text(msg or state.value, reply_markup=InlineKeyboardMarkup(inline_btns)) + return SELECT_CONFIG + + async def __strategy(self, update: Update, context: ContextTypes.DEFAULT_TYPE) -> int: + query, _ = await self.handle_callbackquery(update) + + strategies = [(s.label, self.callback_data([f"__select_config", str(ConfigsChat.STRATEGY), s.name])) for s in self.pipeline.configs.strategies] + inline_btns = [[InlineKeyboardButton(name, callback_data=callback_data)] for name, callback_data in strategies] + + await query.edit_message_text("Select a strategy", reply_markup=InlineKeyboardMarkup(inline_btns)) + return SELECT_CONFIG + + async def __select_config(self, update: Update, context: ContextTypes.DEFAULT_TYPE) -> int: + query, user = await self.handle_callbackquery(update) + logging.debug(f"@{user.username} --> {query.data}") + + req = self.user_requests[user] + _, state, model_name = str(query.data).split(QUERY_SEP) + if state == str(ConfigsChat.MODEL_TEAM): + req.team_model = self.pipeline.configs.get_model_by_name(model_name) + if state == str(ConfigsChat.MODEL_OUTPUT): + req.leader_model = self.pipeline.configs.get_model_by_name(model_name) + if state == str(ConfigsChat.STRATEGY): + req.strategy = self.pipeline.configs.get_strategy_by_name(model_name) + + await self.start_message(user, query) + return CONFIGS + + async def __start_team(self, update: Update, context: ContextTypes.DEFAULT_TYPE) -> int: + message, user = await self.handle_message(update) + + confs = self.user_requests[user] + confs.user_query = message.text or "" + + logging.info(f"@{user.username} started the team with [{confs.team_model.label}, {confs.leader_model.label}, {confs.strategy.label}]") + await self.__run_team(update, confs) + + logging.info(f"@{user.username} team finished.") + return ConversationHandler.END + + async def __cancel(self, update: Update, context: ContextTypes.DEFAULT_TYPE) -> int: + query, user = await self.handle_callbackquery(update) + logging.info(f"@{user.username} canceled the conversation.") + if user in self.user_requests: + del self.user_requests[user] + await query.edit_message_text("Conversation canceled. Use /start to begin again.") + return ConversationHandler.END + + async def __run_team(self, update: Update, confs: ConfigsRun) -> None: + if not update.message: return + + bot = update.get_bot() + msg_id = update.message.message_id - 1 + chat_id = update.message.chat_id + + configs_str = [ + 'Running with configurations: ', + f'Team: {confs.team_model.label}', + f'Output: {confs.leader_model.label}', + f'Strategy: {confs.strategy.label}', + f'Query: "{confs.user_query}"' + ] + full_message = f"""```\n{'\n'.join(configs_str)}\n```\n\n""" + first_message = full_message + "Generating report, please wait" + msg = await bot.edit_message_text(chat_id=chat_id, message_id=msg_id, text=first_message, parse_mode='MarkdownV2') + if isinstance(msg, bool): return + + # Remove user query and bot message + await bot.delete_message(chat_id=chat_id, message_id=update.message.id) + + self.pipeline.leader_model = confs.leader_model + self.pipeline.team_model = confs.team_model + self.pipeline.strategy = confs.strategy + + # TODO migliorare messaggi di attesa + await bot.send_chat_action(chat_id=chat_id, action=ChatAction.TYPING) + report_content = self.pipeline.interact(confs.user_query) + await msg.delete() + + # attach report file to the message + pdf = MarkdownPdf(toc_level=2, optimize=True) + pdf.add_section(Section(report_content, toc=False)) + + # TODO vedere se ha senso dare il pdf o solo il messaggio + document = io.BytesIO() + pdf.save_bytes(document) + document.seek(0) + await bot.send_document(chat_id=chat_id, document=document, filename="report.pdf", parse_mode='MarkdownV2', caption=full_message) + diff --git a/uv.lock b/uv.lock index bb0b6b5..000517c 100644 --- a/uv.lock +++ b/uv.lock @@ -816,14 +816,27 @@ wheels = [ [[package]] name = "markdown-it-py" -version = "4.0.0" +version = "3.0.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "mdurl" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/5b/f5/4ec618ed16cc4f8fb3b701563655a69816155e79e24a17b651541804721d/markdown_it_py-4.0.0.tar.gz", hash = "sha256:cb0a2b4aa34f932c007117b194e945bd74e0ec24133ceb5bac59009cda1cb9f3", size = 73070, upload-time = "2025-08-11T12:57:52.854Z" } +sdist = { url = "https://files.pythonhosted.org/packages/38/71/3b932df36c1a044d397a1f92d1cf91ee0a503d91e470cbd670aa66b07ed0/markdown-it-py-3.0.0.tar.gz", hash = "sha256:e3f60a94fa066dc52ec76661e37c851cb232d92f9886b15cb560aaada2df8feb", size = 74596, upload-time = "2023-06-03T06:41:14.443Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/94/54/e7d793b573f298e1c9013b8c4dade17d481164aa517d1d7148619c2cedbf/markdown_it_py-4.0.0-py3-none-any.whl", hash = "sha256:87327c59b172c5011896038353a81343b6754500a08cd7a4973bb48c6d578147", size = 87321, upload-time = "2025-08-11T12:57:51.923Z" }, + { url = "https://files.pythonhosted.org/packages/42/d7/1ec15b46af6af88f19b8e5ffea08fa375d433c998b8a7639e76935c14f1f/markdown_it_py-3.0.0-py3-none-any.whl", hash = "sha256:355216845c60bd96232cd8d8c40e8f9765cc86f46880e43a8fd22dc1a1a8cab1", size = 87528, upload-time = "2023-06-03T06:41:11.019Z" }, +] + +[[package]] +name = "markdown-pdf" +version = "1.10" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "markdown-it-py" }, + { name = "pymupdf" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/5e/e6/969311a194074afa9672324244adbf64a7e8663f2ba0003395b7140f5c4a/markdown_pdf-1.10.tar.gz", hash = "sha256:bcf23d816baa56aec3a60f940681652c4e46ee048c6335835cddf86d1ff20a8e", size = 17783, upload-time = "2025-09-24T19:01:38.758Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/1f/78/c593979cf1525be786d63b285a7a67afae397fc132382158432490ebd1ed/markdown_pdf-1.10-py3-none-any.whl", hash = "sha256:1863e78454e5aa9bcb34c125f385d4ff045c727660c5172877e82e69d06fae6d", size = 17994, upload-time = "2025-09-24T19:01:37.155Z" }, ] [[package]] @@ -1238,6 +1251,21 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/61/ad/689f02752eeec26aed679477e80e632ef1b682313be70793d798c1d5fc8f/PyJWT-2.10.1-py3-none-any.whl", hash = "sha256:dcdd193e30abefd5debf142f9adfcdd2b58004e644f25406ffaebd50bd98dacb", size = 22997, upload-time = "2024-11-28T03:43:27.893Z" }, ] +[[package]] +name = "pymupdf" +version = "1.26.4" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/90/35/031556dfc0d332d8e9ed9b61ca105138606d3f8971b9eb02e20118629334/pymupdf-1.26.4.tar.gz", hash = "sha256:be13a066d42bfaed343a488168656637c4d9843ddc63b768dc827c9dfc6b9989", size = 83077563, upload-time = "2025-08-25T14:20:29.499Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/27/ae/3be722886cc7be2093585cd94f466db1199133ab005645a7a567b249560f/pymupdf-1.26.4-cp39-abi3-macosx_10_9_x86_64.whl", hash = "sha256:cb95562a0a63ce906fd788bdad5239063b63068cf4a991684f43acb09052cb99", size = 23061974, upload-time = "2025-08-25T14:16:58.811Z" }, + { url = "https://files.pythonhosted.org/packages/fc/b0/9a451d837e1fe18ecdbfbc34a6499f153c8a008763229cc634725383a93f/pymupdf-1.26.4-cp39-abi3-macosx_11_0_arm64.whl", hash = "sha256:67e9e6b45832c33726651c2a031e9a20108fd9e759140b9e843f934de813a7ff", size = 22410112, upload-time = "2025-08-25T14:17:24.511Z" }, + { url = "https://files.pythonhosted.org/packages/d8/13/0916e8e02cb5453161fb9d9167c747d0a20d58633e30728645374153f815/pymupdf-1.26.4-cp39-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:2604f687dd02b6a1b98c81bd8becfc0024899a2d2085adfe3f9e91607721fd22", size = 23454948, upload-time = "2025-08-25T21:20:07.71Z" }, + { url = "https://files.pythonhosted.org/packages/4e/c6/d3cfafc75d383603884edeabe4821a549345df954a88d79e6764e2c87601/pymupdf-1.26.4-cp39-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:973a6dda61ebd34040e4df3753bf004b669017663fbbfdaa294d44eceba98de0", size = 24060686, upload-time = "2025-08-25T14:17:56.536Z" }, + { url = "https://files.pythonhosted.org/packages/72/08/035e9d22c801e801bba50c6745bc90ba8696a042fe2c68793e28bf0c3b07/pymupdf-1.26.4-cp39-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:299a49797df5b558e695647fa791329ba3911cbbb31ed65f24a6266c118ef1a7", size = 24265046, upload-time = "2025-08-25T14:18:21.238Z" }, + { url = "https://files.pythonhosted.org/packages/28/8c/c201e4846ec0fb6ae5d52aa3a5d66f9355f0c69fb94230265714df0de65e/pymupdf-1.26.4-cp39-abi3-win32.whl", hash = "sha256:51b38379aad8c71bd7a8dd24d93fbe7580c2a5d9d7e1f9cd29ebbba315aa1bd1", size = 17127332, upload-time = "2025-08-25T14:18:39.132Z" }, + { url = "https://files.pythonhosted.org/packages/d1/c4/87d27b108c2f6d773aa5183c5ae367b2a99296ea4bc16eb79f453c679e30/pymupdf-1.26.4-cp39-abi3-win_amd64.whl", hash = "sha256:0b6345a93a9afd28de2567e433055e873205c52e6b920b129ca50e836a3aeec6", size = 18743491, upload-time = "2025-08-25T14:19:01.104Z" }, +] + [[package]] name = "pytest" version = "8.4.2" @@ -1301,6 +1329,18 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/45/58/38b5afbc1a800eeea951b9285d3912613f2603bdf897a4ab0f4bd7f405fc/python_multipart-0.0.20-py3-none-any.whl", hash = "sha256:8a62d3a8335e06589fe01f2a3e178cdcc632f3fbe0d492ad9ee0ec35aab1f104", size = 24546, upload-time = "2024-12-16T19:45:44.423Z" }, ] +[[package]] +name = "python-telegram-bot" +version = "22.5" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "httpx" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/0b/6b/400f88e5c29a270c1c519a3ca8ad0babc650ec63dbfbd1b73babf625ed54/python_telegram_bot-22.5.tar.gz", hash = "sha256:82d4efd891d04132f308f0369f5b5929e0b96957901f58bcef43911c5f6f92f8", size = 1488269, upload-time = "2025-09-27T13:50:27.879Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/bc/c3/340c7520095a8c79455fcf699cbb207225e5b36490d2b9ee557c16a7b21b/python_telegram_bot-22.5-py3-none-any.whl", hash = "sha256:4b7cd365344a7dce54312cc4520d7fa898b44d1a0e5f8c74b5bd9b540d035d16", size = 730976, upload-time = "2025-09-27T13:50:25.93Z" }, +] + [[package]] name = "pytz" version = "2025.2" @@ -1622,11 +1662,13 @@ dependencies = [ { name = "gnews" }, { name = "google-genai" }, { name = "gradio" }, + { name = "markdown-pdf" }, { name = "newsapi-python" }, { name = "ollama" }, { name = "praw" }, { name = "pytest" }, { name = "python-binance" }, + { name = "python-telegram-bot" }, { name = "yfinance" }, ] @@ -1640,11 +1682,13 @@ requires-dist = [ { name = "gnews" }, { name = "google-genai" }, { name = "gradio" }, + { name = "markdown-pdf" }, { name = "newsapi-python" }, { name = "ollama" }, { name = "praw" }, { name = "pytest" }, { name = "python-binance" }, + { name = "python-telegram-bot" }, { name = "yfinance" }, ] From d85d6ed1ebaa560621a0d4c9a9d467f875d36989 Mon Sep 17 00:00:00 2001 From: Giacomo Bertolazzi <31776951+Berack96@users.noreply.github.com> Date: Mon, 13 Oct 2025 22:34:28 +0200 Subject: [PATCH 6/7] Fix event loop (#32) * Fix main by ensuring a new event loop is created * fix Dockerfile workspace error --- Dockerfile | 5 +---- src/app/__main__.py | 4 +++- 2 files changed, 4 insertions(+), 5 deletions(-) diff --git a/Dockerfile b/Dockerfile index d1aa5fd..61d4bee 100644 --- a/Dockerfile +++ b/Dockerfile @@ -9,9 +9,6 @@ ENV PATH="/root/.local/bin:$PATH" # Configuriamo UV per usare copy mode ed evitare problemi di linking ENV UV_LINK_MODE=copy -# Impostiamo la directory di lavoro -WORKDIR /app - # Copiamo i file del progetto COPY pyproject.toml ./ COPY uv.lock ./ @@ -21,7 +18,7 @@ COPY configs.yaml ./ # Creiamo l'ambiente virtuale con tutto già presente RUN uv sync -ENV PYTHONPATH="/app/src" +ENV PYTHONPATH="/src" # Comando di avvio dell'applicazione CMD ["uv", "run", "src/app"] diff --git a/src/app/__main__.py b/src/app/__main__.py index dca46fb..0c88872 100644 --- a/src/app/__main__.py +++ b/src/app/__main__.py @@ -24,7 +24,9 @@ if __name__ == "__main__": except AssertionError as e: try: logging.warning(f"Telegram bot could not be started: {e}") - asyncio.get_event_loop().run_forever() + loop = asyncio.new_event_loop() + asyncio.set_event_loop(loop) + loop.run_forever() except KeyboardInterrupt: logging.info("Shutting down due to KeyboardInterrupt") finally: From 38daafce9abb68f405f931ab5b577d58e0cefa0a Mon Sep 17 00:00:00 2001 From: Giacomo Bertolazzi <31776951+Berack96@users.noreply.github.com> Date: Wed, 15 Oct 2025 14:00:39 +0200 Subject: [PATCH 7/7] Refactor team management (#26) * Refactor pipeline integration * remove direct pipeline dependency from ChatManager and TelegramApp * introduce PipelineInputs for better configuration management * listener personalizzati per eventi nella funzione di interazione della pipeline * added demos for agno * USD in configs * Dockerfile better cache --- Dockerfile | 10 +- configs.yaml | 2 +- demos/{example.py => agno_agent.py} | 0 demos/agno_workflow.py | 69 ++++++++++ src/app/__main__.py | 9 +- src/app/agents/__init__.py | 5 +- src/app/agents/pipeline.py | 189 ++++++++++++++++++++++------ src/app/agents/team.py | 25 ---- src/app/configs.py | 23 ++-- src/app/interface/chat.py | 20 +-- src/app/interface/telegram_app.py | 62 ++++----- 11 files changed, 281 insertions(+), 133 deletions(-) rename demos/{example.py => agno_agent.py} (100%) create mode 100644 demos/agno_workflow.py delete mode 100644 src/app/agents/team.py diff --git a/Dockerfile b/Dockerfile index 61d4bee..8c7489d 100644 --- a/Dockerfile +++ b/Dockerfile @@ -9,16 +9,16 @@ ENV PATH="/root/.local/bin:$PATH" # Configuriamo UV per usare copy mode ed evitare problemi di linking ENV UV_LINK_MODE=copy -# Copiamo i file del progetto +# Creiamo l'ambiente virtuale con tutto già presente COPY pyproject.toml ./ COPY uv.lock ./ +RUN uv sync --frozen --no-dev +ENV PYTHONPATH="./src" + +# Copiamo i file del progetto COPY LICENSE ./ COPY src/ ./src/ COPY configs.yaml ./ -# Creiamo l'ambiente virtuale con tutto già presente -RUN uv sync -ENV PYTHONPATH="/src" - # Comando di avvio dell'applicazione CMD ["uv", "run", "src/app"] diff --git a/configs.yaml b/configs.yaml index 5d70b13..c0925b8 100644 --- a/configs.yaml +++ b/configs.yaml @@ -32,7 +32,7 @@ models: api: retry_attempts: 3 retry_delay_seconds: 2 - currency: EUR + currency: USD # TODO Magari implementare un sistema per settare i providers market_providers: [BinanceWrapper, YFinanceWrapper] news_providers: [GoogleNewsWrapper, DuckDuckGoWrapper] diff --git a/demos/example.py b/demos/agno_agent.py similarity index 100% rename from demos/example.py rename to demos/agno_agent.py diff --git a/demos/agno_workflow.py b/demos/agno_workflow.py new file mode 100644 index 0000000..13a48d2 --- /dev/null +++ b/demos/agno_workflow.py @@ -0,0 +1,69 @@ +import asyncio +from agno.agent import Agent +from agno.models.ollama import Ollama +from agno.run.workflow import WorkflowRunEvent +from agno.workflow.step import Step +from agno.workflow.steps import Steps +from agno.workflow.types import StepOutput, StepInput +from agno.workflow.parallel import Parallel +from agno.workflow.workflow import Workflow + +def my_sum(a: int, b: int) -> int: + return a + b + +def my_mul(a: int, b: int) -> int: + return a * b + +def build_agent(instructions: str) -> Agent: + return Agent( + instructions=instructions, + model=Ollama(id='qwen3:1.7b'), + tools=[my_sum] + ) + +def remove_think(text: str) -> str: + thinking = text.rfind("") + if thinking != -1: + return text[thinking + len(""):].strip() + return text.strip() + +def combine_steps_output(inputs: StepInput) -> StepOutput: + parallel = inputs.get_step_content("parallel") + if not isinstance(parallel, dict): return StepOutput() + + lang = remove_think(parallel.get("Lang", "")) + answer = remove_think(parallel.get("Predict", "")) + content = f"Language: {lang}\nPhrase: {answer}" + return StepOutput(content=content) + +async def main(): + query = "Quanto fa 50 + 150 * 50?" + + s1 = Step(name="Translate", agent=build_agent(instructions="Transform in English the user query. DO NOT answer the question and output ONLY the translated question.")) + s2 = Step(name="Predict", agent=build_agent(instructions="You will be given a question in English. You can use the tools at your disposal. Answer the question and output ONLY the answer.")) + + step_a = Step(name="Lang", agent=build_agent(instructions="Detect the language from the question and output ONLY the language code. Es: 'en' for English, 'it' for Italian, 'ja' for Japanese.")) + step_b = Steps(name="Answer", steps=[s1, s2]) + step_c = Step(name="Combine", executor=combine_steps_output) + step_f = Step(name="Final", agent=build_agent(instructions="Translate the phrase in the language code provided. Respond only with the translated answer.")) + + wf = Workflow(name="Pipeline Workflow", steps=[ + Parallel(step_a, step_b, name="parallel"), # type: ignore + step_c, + step_f + ]) + + result = "" + async for event in await wf.arun(query, stream=True, stream_intermediate_steps=True): + content = getattr(event, 'content', '') + step_name = getattr(event, 'step_name', '') + + if event.event in [WorkflowRunEvent.step_completed]: + print(f"{str(event.event)} --- {step_name} --- {remove_think(content).replace('\n', '\\n')[:80]}") + if event.event in [WorkflowRunEvent.workflow_completed]: + result = remove_think(content) + print(f"\nFinal result: {result}") + + +if __name__ == "__main__": + asyncio.run(main()) diff --git a/src/app/__main__.py b/src/app/__main__.py index 0c88872..04bc1d5 100644 --- a/src/app/__main__.py +++ b/src/app/__main__.py @@ -3,22 +3,21 @@ import logging from dotenv import load_dotenv from app.configs import AppConfig from app.interface import * -from app.agents import Pipeline if __name__ == "__main__": + # ===================== load_dotenv() - configs = AppConfig.load() - pipeline = Pipeline(configs) + # ===================== - chat = ChatManager(pipeline) + chat = ChatManager() gradio = chat.gradio_build_interface() _app, local_url, share_url = gradio.launch(server_name="0.0.0.0", server_port=configs.port, quiet=True, prevent_thread_lock=True, share=configs.gradio_share) logging.info(f"UPO AppAI Chat is running on {share_url or local_url}") try: - telegram = TelegramApp(pipeline) + telegram = TelegramApp() telegram.add_miniapp_url(share_url) telegram.run() except AssertionError as e: diff --git a/src/app/agents/__init__.py b/src/app/agents/__init__.py index 7d4287b..2e78f1b 100644 --- a/src/app/agents/__init__.py +++ b/src/app/agents/__init__.py @@ -1,5 +1,4 @@ from app.agents.predictor import PredictorInput, PredictorOutput -from app.agents.team import create_team_with -from app.agents.pipeline import Pipeline +from app.agents.pipeline import Pipeline, PipelineInputs, PipelineEvent -__all__ = ["PredictorInput", "PredictorOutput", "create_team_with", "Pipeline"] +__all__ = ["PredictorInput", "PredictorOutput", "Pipeline", "PipelineInputs", "PipelineEvent"] diff --git a/src/app/agents/pipeline.py b/src/app/agents/pipeline.py index 3338cb8..cf8de3e 100644 --- a/src/app/agents/pipeline.py +++ b/src/app/agents/pipeline.py @@ -1,32 +1,59 @@ +import asyncio +from enum import Enum import logging -from app.agents.team import create_team_with +import random +from typing import Any, Callable +from agno.agent import RunEvent +from agno.team import Team, TeamRunEvent +from agno.tools.reasoning import ReasoningTools +from agno.run.workflow import WorkflowRunEvent +from agno.workflow.step import Step +from agno.workflow.workflow import Workflow + +from app.api.tools import * from app.agents.prompts import * from app.configs import AppConfig logging = logging.getLogger("pipeline") -class Pipeline: +class PipelineEvent(str, Enum): + PLANNER = "Planner" + INFO_RECOVERY = "Info Recovery" + REPORT_GENERATION = "Report Generation" + REPORT_TRANSLATION = "Report Translation" + TOOL_USED = RunEvent.tool_call_completed + + def check_event(self, event: str, step_name: str) -> bool: + return event == self.value or (WorkflowRunEvent.step_completed and step_name == self.value) + + +class PipelineInputs: """ - Coordina gli agenti di servizio (Market, News, Social) e il Predictor finale. - Il Team è orchestrato da qwen3:latest (Ollama), mentre il Predictor è dinamico - e scelto dall'utente tramite i dropdown dell'interfaccia grafica. + Classe necessaria per passare gli input alla Pipeline. + Serve per raggruppare i parametri e semplificare l'inizializzazione. """ - def __init__(self, configs: AppConfig): - self.configs = configs + def __init__(self, configs: AppConfig | None = None) -> None: + """ + Inputs per la Pipeline di agenti. + Setta i valori di default se non specificati. + """ + self.configs = configs if configs else AppConfig() - # Stato iniziale - self.leader_model = self.configs.get_model_by_name(self.configs.agents.team_leader_model) - self.team_model = self.configs.get_model_by_name(self.configs.agents.team_model) - self.strategy = self.configs.get_strategy_by_name(self.configs.agents.strategy) + agents = self.configs.agents + self.team_model = self.configs.get_model_by_name(agents.team_model) + self.team_leader_model = self.configs.get_model_by_name(agents.team_leader_model) + self.predictor_model = self.configs.get_model_by_name(agents.predictor_model) + self.strategy = self.configs.get_strategy_by_name(agents.strategy) + self.user_query = "" # ====================== # Dropdown handlers # ====================== - def choose_leader(self, index: int): + def choose_team_leader(self, index: int): """ - Sceglie il modello LLM da usare per il Team. + Sceglie il modello LLM da usare per il Team Leader. """ self.leader_model = self.configs.models.all_models[index] @@ -38,47 +65,139 @@ class Pipeline: def choose_strategy(self, index: int): """ - Sceglie la strategia da usare per il Predictor. + Sceglie la strategia da usare per il Team. """ self.strategy = self.configs.strategies[index] # ====================== # Helpers # ====================== - def list_providers(self) -> list[str]: + def list_models_names(self) -> list[str]: """ Restituisce la lista dei nomi dei modelli disponibili. """ return [model.label for model in self.configs.models.all_models] - def list_styles(self) -> list[str]: + def list_strategies_names(self) -> list[str]: """ - Restituisce la lista degli stili di previsione disponibili. + Restituisce la lista delle strategie disponibili. """ return [strat.label for strat in self.configs.strategies] + +class Pipeline: + """ + Coordina gli agenti di servizio (Market, News, Social) e il Predictor finale. + Il Team è orchestrato da qwen3:latest (Ollama), mentre il Predictor è dinamico + e scelto dall'utente tramite i dropdown dell'interfaccia grafica. + """ + + def __init__(self, inputs: PipelineInputs): + self.inputs = inputs + # ====================== # Core interaction # ====================== - def interact(self, query: str) -> str: + def interact(self, listeners: dict[RunEvent | TeamRunEvent, Callable[[PipelineEvent], None]] = {}) -> str: """ - 1. Raccoglie output dai membri del Team - 2. Aggrega output strutturati - 3. Invoca Predictor - 4. Restituisce la strategia finale + Esegue la pipeline di agenti per rispondere alla query dell'utente. + Args: + listeners: dizionario di callback per eventi specifici (opzionale) + Returns: + La risposta generata dalla pipeline. """ - # Step 1: Creazione Team - team = create_team_with(self.configs, self.team_model, self.leader_model) + return asyncio.run(self.interact_async(listeners)) - # Step 2: raccolta output dai membri del Team - logging.info(f"Pipeline received query: {query}") - # TODO migliorare prompt (?) - query = f"The user query is: {query}\n\n They requested a {self.strategy.label} investment strategy." - team_outputs = team.run(query) # type: ignore + async def interact_async(self, listeners: dict[RunEvent | TeamRunEvent, Callable[[PipelineEvent], None]] = {}) -> str: + """ + Versione asincrona che esegue la pipeline di agenti per rispondere alla query dell'utente. + Args: + listeners: dizionario di callback per eventi specifici (opzionale) + Returns: + La risposta generata dalla pipeline. + """ + run_id = random.randint(1000, 9999) # Per tracciare i log + logging.info(f"[{run_id}] Pipeline query: {self.inputs.user_query}") - # Step 3: recupero ouput - if not isinstance(team_outputs.content, str): - logging.error(f"Team output is not a string: {team_outputs.content}") - raise ValueError("Team output is not a string") - logging.info(f"Team finished") - return team_outputs.content + # Step 1: Crea gli agenti e il team + market_tool, news_tool, social_tool = self.get_tools() + market_agent = self.inputs.team_model.get_agent(instructions=MARKET_INSTRUCTIONS, name="MarketAgent", tools=[market_tool]) + news_agent = self.inputs.team_model.get_agent(instructions=NEWS_INSTRUCTIONS, name="NewsAgent", tools=[news_tool]) + social_agent = self.inputs.team_model.get_agent(instructions=SOCIAL_INSTRUCTIONS, name="SocialAgent", tools=[social_tool]) + + team = Team( + model=self.inputs.team_leader_model.get_model(COORDINATOR_INSTRUCTIONS), + name="CryptoAnalysisTeam", + tools=[ReasoningTools()], + members=[market_agent, news_agent, social_agent], + ) + + # Step 3: Crea il workflow + #query_planner = Step(name=PipelineEvent.PLANNER, agent=Agent()) + info_recovery = Step(name=PipelineEvent.INFO_RECOVERY, team=team) + #report_generation = Step(name=PipelineEvent.REPORT_GENERATION, agent=Agent()) + #report_translate = Step(name=AppEvent.REPORT_TRANSLATION, agent=Agent()) + + workflow = Workflow( + name="App Workflow", + steps=[ + #query_planner, + info_recovery, + #report_generation, + #report_translate + ] + ) + + # Step 4: Fai partire il workflow e prendi l'output + query = f"The user query is: {self.inputs.user_query}\n\n They requested a {self.inputs.strategy.label} investment strategy." + result = await self.run(workflow, query, events={}) + logging.info(f"[{run_id}] Run finished") + return result + + # ====================== + # Helpers + # ===================== + def get_tools(self) -> tuple[MarketAPIsTool, NewsAPIsTool, SocialAPIsTool]: + """ + Restituisce la lista di tools disponibili per gli agenti. + """ + api = self.inputs.configs.api + + market_tool = MarketAPIsTool(currency=api.currency) + market_tool.handler.set_retries(api.retry_attempts, api.retry_delay_seconds) + news_tool = NewsAPIsTool() + news_tool.handler.set_retries(api.retry_attempts, api.retry_delay_seconds) + social_tool = SocialAPIsTool() + social_tool.handler.set_retries(api.retry_attempts, api.retry_delay_seconds) + + return (market_tool, news_tool, social_tool) + + @classmethod + async def run(cls, workflow: Workflow, query: str, events: dict[PipelineEvent, Callable[[Any], None]]) -> str: + """ + Esegue il workflow e gestisce gli eventi tramite le callback fornite. + Args: + workflow: istanza di Workflow da eseguire + query: query dell'utente da passare al workflow + events: dizionario di callback per eventi specifici (opzionale) + Returns: + La risposta generata dal workflow. + """ + iterator = await workflow.arun(query, stream=True, stream_intermediate_steps=True) + + content = None + async for event in iterator: + step_name = getattr(event, 'step_name', '') + + for app_event, listener in events.items(): + if app_event.check_event(event.event, step_name): + listener(event) + + if event.event == WorkflowRunEvent.workflow_completed: + content = getattr(event, 'content', '') + if isinstance(content, str): + think_str = "" + think = content.rfind(think_str) + content = content[(think + len(think_str)):] if think != -1 else content + + return content if content else "No output from workflow, something went wrong." diff --git a/src/app/agents/team.py b/src/app/agents/team.py deleted file mode 100644 index 4fcad4e..0000000 --- a/src/app/agents/team.py +++ /dev/null @@ -1,25 +0,0 @@ -from agno.team import Team -from app.api.tools import * -from app.agents.prompts import * -from app.configs import AppConfig, AppModel - - -def create_team_with(configs: AppConfig, model: AppModel, coordinator: AppModel | None = None) -> Team: - - market_tool = MarketAPIsTool(currency=configs.api.currency) - market_tool.handler.set_retries(configs.api.retry_attempts, configs.api.retry_delay_seconds) - news_tool = NewsAPIsTool() - news_tool.handler.set_retries(configs.api.retry_attempts, configs.api.retry_delay_seconds) - social_tool = SocialAPIsTool() - social_tool.handler.set_retries(configs.api.retry_attempts, configs.api.retry_delay_seconds) - - market_agent = model.get_agent(instructions=MARKET_INSTRUCTIONS, name="MarketAgent", tools=[market_tool]) - news_agent = model.get_agent(instructions=NEWS_INSTRUCTIONS, name="NewsAgent", tools=[news_tool]) - social_agent = model.get_agent(instructions=SOCIAL_INSTRUCTIONS, name="SocialAgent", tools=[social_tool]) - - coordinator = coordinator or model - return Team( - model=coordinator.get_model(COORDINATOR_INSTRUCTIONS), - name="CryptoAnalysisTeam", - members=[market_agent, news_agent, social_agent], - ) diff --git a/src/app/configs.py b/src/app/configs.py index 29c2178..179ffdd 100644 --- a/src/app/configs.py +++ b/src/app/configs.py @@ -3,7 +3,6 @@ import threading import ollama import yaml import logging.config -import agno.utils.log # type: ignore from typing import Any, ClassVar from pydantic import BaseModel from agno.agent import Agent @@ -104,8 +103,6 @@ class AppConfig(BaseModel): data = yaml.safe_load(f) configs = cls(**data) - configs.set_logging_level() - configs.validate_models() log.info(f"Loaded configuration from {file_path}") return configs @@ -115,6 +112,15 @@ class AppConfig(BaseModel): cls.instance = super(AppConfig, cls).__new__(cls) return cls.instance + def __init__(self, *args: Any, **kwargs: Any) -> None: + if hasattr(self, '_initialized'): + return + + super().__init__(*args, **kwargs) + self.set_logging_level() + self.validate_models() + self._initialized = True + def get_model_by_name(self, name: str) -> AppModel: """ Retrieve a model configuration by its name. @@ -145,17 +151,6 @@ class AppConfig(BaseModel): return strat raise ValueError(f"Strategy with name '{name}' not found.") - def get_defaults(self) -> tuple[AppModel, AppModel, Strategy]: - """ - Retrieve the default team model, leader model, and strategy. - Returns: - A tuple containing the default team model (AppModel), leader model (AppModel), and strategy (Strategy). - """ - team_model = self.get_model_by_name(self.agents.team_model) - leader_model = self.get_model_by_name(self.agents.team_leader_model) - strategy = self.get_strategy_by_name(self.agents.strategy) - return team_model, leader_model, strategy - def set_logging_level(self) -> None: """ Set the logging level based on the configuration. diff --git a/src/app/interface/chat.py b/src/app/interface/chat.py index aaba2af..6881c32 100644 --- a/src/app/interface/chat.py +++ b/src/app/interface/chat.py @@ -1,7 +1,7 @@ import os import json import gradio as gr -from app.agents.pipeline import Pipeline +from app.agents.pipeline import Pipeline, PipelineInputs class ChatManager: @@ -12,9 +12,9 @@ class ChatManager: - salva e ricarica le chat """ - def __init__(self, pipeline: Pipeline): + def __init__(self): self.history: list[dict[str, str]] = [] # [{"role": "user"/"assistant", "content": "..."}] - self.pipeline = pipeline + self.inputs = PipelineInputs() def send_message(self, message: str) -> None: """ @@ -67,7 +67,11 @@ class ChatManager: ######################################## def gradio_respond(self, message: str, history: list[dict[str, str]]) -> tuple[list[dict[str, str]], list[dict[str, str]], str]: self.send_message(message) - response = self.pipeline.interact(message) + + self.inputs.user_query = message + pipeline = Pipeline(self.inputs) + response = pipeline.interact() + self.receive_message(response) history.append({"role": "user", "content": message}) history.append({"role": "assistant", "content": response}) @@ -95,18 +99,18 @@ class ChatManager: # Dropdown provider e stile with gr.Row(): provider = gr.Dropdown( - choices=self.pipeline.list_providers(), + choices=self.inputs.list_models_names(), type="index", label="Modello da usare" ) - provider.change(fn=self.pipeline.choose_leader, inputs=provider, outputs=None) + provider.change(fn=self.inputs.choose_team_leader, inputs=provider, outputs=None) style = gr.Dropdown( - choices=self.pipeline.list_styles(), + choices=self.inputs.list_strategies_names(), type="index", label="Stile di investimento" ) - style.change(fn=self.pipeline.choose_strategy, inputs=style, outputs=None) + style.change(fn=self.inputs.choose_strategy, inputs=style, outputs=None) chatbot = gr.Chatbot(label="Conversazione", height=500, type="messages") msg = gr.Textbox(label="Scrivi la tua richiesta", placeholder="Es: Quali sono le crypto interessanti oggi?") diff --git a/src/app/interface/telegram_app.py b/src/app/interface/telegram_app.py index 3bef9d9..71ff4c8 100644 --- a/src/app/interface/telegram_app.py +++ b/src/app/interface/telegram_app.py @@ -9,8 +9,7 @@ from markdown_pdf import MarkdownPdf, Section from telegram import CallbackQuery, InlineKeyboardButton, InlineKeyboardMarkup, Message, Update, User from telegram.constants import ChatAction from telegram.ext import Application, CallbackQueryHandler, CommandHandler, ContextTypes, ConversationHandler, MessageHandler, filters -from app.agents.pipeline import Pipeline -from app.configs import AppConfig +from app.agents.pipeline import Pipeline, PipelineInputs # per per_message di ConversationHandler che rompe sempre qualunque input tu metta warnings.filterwarnings("ignore") @@ -40,22 +39,12 @@ class ConfigsChat(Enum): MODEL_OUTPUT = "Output Model" STRATEGY = "Strategy" -class ConfigsRun: - def __init__(self, configs: AppConfig): - team, leader, strategy = configs.get_defaults() - self.team_model = team - self.leader_model = leader - self.strategy = strategy - self.user_query = "" - - class TelegramApp: - def __init__(self, pipeline: Pipeline): + def __init__(self): token = os.getenv("TELEGRAM_BOT_TOKEN") assert token, "TELEGRAM_BOT_TOKEN environment variable not set" - self.user_requests: dict[User, ConfigsRun] = {} - self.pipeline = pipeline + self.user_requests: dict[User, PipelineInputs] = {} self.token = token self.create_bot() @@ -104,10 +93,10 @@ class TelegramApp: # Funzioni di utilità ######################################## async def start_message(self, user: User, query: CallbackQuery | Message) -> None: - confs = self.user_requests.setdefault(user, ConfigsRun(self.pipeline.configs)) + confs = self.user_requests.setdefault(user, PipelineInputs()) str_model_team = f"{ConfigsChat.MODEL_TEAM.value}: {confs.team_model.label}" - str_model_output = f"{ConfigsChat.MODEL_OUTPUT.value}: {confs.leader_model.label}" + str_model_output = f"{ConfigsChat.MODEL_OUTPUT.value}: {confs.team_leader_model.label}" str_strategy = f"{ConfigsChat.STRATEGY.value}: {confs.strategy.label}" msg, keyboard = ( @@ -135,8 +124,8 @@ class TelegramApp: assert update.message and update.message.from_user, "Update message or user is None" return update.message, update.message.from_user - def callback_data(self, strings: list[str]) -> str: - return QUERY_SEP.join(strings) + def build_callback_data(self, callback: str, config: ConfigsChat, labels: list[str]) -> list[tuple[str, str]]: + return [(label, QUERY_SEP.join((callback, config.value, str(i)))) for i, label in enumerate(labels)] async def __error_handler(self, update: object, context: ContextTypes.DEFAULT_TYPE) -> None: try: @@ -168,18 +157,20 @@ class TelegramApp: return await self._model_select(update, ConfigsChat.MODEL_OUTPUT) async def _model_select(self, update: Update, state: ConfigsChat, msg: str | None = None) -> int: - query, _ = await self.handle_callbackquery(update) + query, user = await self.handle_callbackquery(update) - models = [(m.label, self.callback_data([f"__select_config", str(state), m.name])) for m in self.pipeline.configs.models.all_models] + req = self.user_requests[user] + models = self.build_callback_data("__select_config", state, req.list_models_names()) inline_btns = [[InlineKeyboardButton(name, callback_data=callback_data)] for name, callback_data in models] await query.edit_message_text(msg or state.value, reply_markup=InlineKeyboardMarkup(inline_btns)) return SELECT_CONFIG async def __strategy(self, update: Update, context: ContextTypes.DEFAULT_TYPE) -> int: - query, _ = await self.handle_callbackquery(update) + query, user = await self.handle_callbackquery(update) - strategies = [(s.label, self.callback_data([f"__select_config", str(ConfigsChat.STRATEGY), s.name])) for s in self.pipeline.configs.strategies] + req = self.user_requests[user] + strategies = self.build_callback_data("__select_config", ConfigsChat.STRATEGY, req.list_strategies_names()) inline_btns = [[InlineKeyboardButton(name, callback_data=callback_data)] for name, callback_data in strategies] await query.edit_message_text("Select a strategy", reply_markup=InlineKeyboardMarkup(inline_btns)) @@ -190,13 +181,13 @@ class TelegramApp: logging.debug(f"@{user.username} --> {query.data}") req = self.user_requests[user] - _, state, model_name = str(query.data).split(QUERY_SEP) + _, state, index = str(query.data).split(QUERY_SEP) if state == str(ConfigsChat.MODEL_TEAM): - req.team_model = self.pipeline.configs.get_model_by_name(model_name) + req.choose_team(int(index)) if state == str(ConfigsChat.MODEL_OUTPUT): - req.leader_model = self.pipeline.configs.get_model_by_name(model_name) + req.choose_team_leader(int(index)) if state == str(ConfigsChat.STRATEGY): - req.strategy = self.pipeline.configs.get_strategy_by_name(model_name) + req.choose_strategy(int(index)) await self.start_message(user, query) return CONFIGS @@ -207,7 +198,7 @@ class TelegramApp: confs = self.user_requests[user] confs.user_query = message.text or "" - logging.info(f"@{user.username} started the team with [{confs.team_model.label}, {confs.leader_model.label}, {confs.strategy.label}]") + logging.info(f"@{user.username} started the team with [{confs.team_model.label}, {confs.team_leader_model.label}, {confs.strategy.label}]") await self.__run_team(update, confs) logging.info(f"@{user.username} team finished.") @@ -221,7 +212,7 @@ class TelegramApp: await query.edit_message_text("Conversation canceled. Use /start to begin again.") return ConversationHandler.END - async def __run_team(self, update: Update, confs: ConfigsRun) -> None: + async def __run_team(self, update: Update, inputs: PipelineInputs) -> None: if not update.message: return bot = update.get_bot() @@ -230,10 +221,10 @@ class TelegramApp: configs_str = [ 'Running with configurations: ', - f'Team: {confs.team_model.label}', - f'Output: {confs.leader_model.label}', - f'Strategy: {confs.strategy.label}', - f'Query: "{confs.user_query}"' + f'Team: {inputs.team_model.label}', + f'Output: {inputs.team_leader_model.label}', + f'Strategy: {inputs.strategy.label}', + f'Query: "{inputs.user_query}"' ] full_message = f"""```\n{'\n'.join(configs_str)}\n```\n\n""" first_message = full_message + "Generating report, please wait" @@ -243,13 +234,10 @@ class TelegramApp: # Remove user query and bot message await bot.delete_message(chat_id=chat_id, message_id=update.message.id) - self.pipeline.leader_model = confs.leader_model - self.pipeline.team_model = confs.team_model - self.pipeline.strategy = confs.strategy - # TODO migliorare messaggi di attesa await bot.send_chat_action(chat_id=chat_id, action=ChatAction.TYPING) - report_content = self.pipeline.interact(confs.user_query) + pipeline = Pipeline(inputs) + report_content = await pipeline.interact_async() await msg.delete() # attach report file to the message