Update chat interface #70

Merged
trojanhorse47 merged 9 commits from 47-update-chat-interface into main 2025-10-31 14:24:39 +01:00
5 changed files with 83 additions and 113 deletions
Showing only changes of commit 2803263bed - Show all commits

View File

@@ -6,4 +6,12 @@ def register_friendly_actions(actions: dict[str, str]):
Aggiunge le descrizioni di un Toolkit al registro globale.
"""
global ACTION_DESCRIPTIONS
ACTION_DESCRIPTIONS.update(actions)
ACTION_DESCRIPTIONS.update(actions)
def get_user_friendly_action(tool_name: str) -> str:
"""
Restituisce un messaggio leggibile e descrittivo per l'utente
leggendo dal registro globale.
"""
# Usa il dizionario ACTION_DESCRIPTIONS importato
return ACTION_DESCRIPTIONS.get(tool_name, f"⚙️ Eseguo l'operazione: {tool_name}...")

View File

@@ -155,12 +155,20 @@ class RunMessage:
self.base_message = f"Running configurations: \n{prefix}{inputs}{suffix}\n\n"
self.emojis = ['🔳', '➡️', '']
self.placeholder = '<<<>>>'
self.set_steps(["Query Check", "Info Recovery", "Report Generation"])
def set_steps(self, steps: list[str]) -> 'RunMessage':
"""
Inizializza gli step di esecuzione con lo stato iniziale.
Args:
steps (list[str]): Lista degli step da includere nel messaggio.
Returns:
RunMessage: L'istanza aggiornata di RunMessage.
"""
self.steps_total = [(f"{self.placeholder} {step}", 0) for step in steps]
self.steps_total[0] = (self.steps_total[0][0], 1) # Primo step in esecuzione
self.current = 0
self.steps_total = [
(f"{self.placeholder} Query Check", 1),
(f"{self.placeholder} Info Recovery", 0),
(f"{self.placeholder} Report Generation", 0),
]
return self
def update(self) -> 'RunMessage':
"""
@@ -177,15 +185,15 @@ class RunMessage:
self.steps_total[self.current] = (text_curr, state_curr + 1)
return self
def update_step(self, text_extra: str = "") -> 'RunMessage':
def update_step_with_tool(self, tool_used: str = "") -> 'RunMessage':
"""
Aggiorna il messaggio per lo step corrente.
Args:
text_extra (str, optional): Testo aggiuntivo da includere nello step. Defaults to "".
tool_used (str, optional): Testo aggiuntivo da includere nello step. Defaults to "".
"""
text_curr, state_curr = self.steps_total[self.current]
if text_extra:
text_curr = f"{text_curr.replace('', '')}\n╚═ {text_extra}"
if tool_used:
text_curr = f"{text_curr.replace('', '')}\n╚═ {tool_used}"
self.steps_total[self.current] = (text_curr, state_curr)
return self
@@ -197,3 +205,4 @@ class RunMessage:
"""
steps = [msg.replace(self.placeholder, self.emojis[state]) for msg, state in self.steps_total]
return self.base_message + "\n".join(steps)

View File

@@ -1,50 +1,45 @@
import asyncio
from enum import Enum
import logging
import random
from typing import Any, Callable
from typing import Any, AsyncGenerator, Callable
from agno.agent import RunEvent
from agno.run.workflow import WorkflowRunEvent
from agno.workflow.types import StepInput, StepOutput
from agno.workflow.step import Step
from agno.workflow.workflow import Workflow
from app.agents.action_registry import ACTION_DESCRIPTIONS
from app.agents.core import *
logging = logging.getLogger("pipeline")
def _get_user_friendly_action(tool_name: str) -> str:
"""
Restituisce un messaggio leggibile e descrittivo per l'utente
leggendo dal registro globale.
"""
# Usa il dizionario ACTION_DESCRIPTIONS importato
return ACTION_DESCRIPTIONS.get(tool_name, f"⚙️ Eseguo l'operazione: {tool_name}...")
class PipelineEvent(str, Enum):
QUERY_CHECK = "Query Check"
QUERY_ANALYZER = "Query Analyzer"
QUERY_CHECK_END = "Query Check End"
INFO_RECOVERY = "Info Recovery"
INFO_RECOVERY_END = "Info Recovery End"
REPORT_GENERATION = "Report Generation"
REPORT_TRANSLATION = "Report Translation"
RUN_FINISHED = WorkflowRunEvent.workflow_completed.value
TOOL_USED = RunEvent.tool_call_completed.value
REPORT_GENERATION_END = "Report Generation End"
TOOL_USED = RunEvent.tool_call_started.value
TOOL_USED_END = RunEvent.tool_call_completed.value
RUN_END = WorkflowRunEvent.workflow_completed.value
def check_event(self, event: str, step_name: str) -> bool:
return event == self.value or (WorkflowRunEvent.step_completed == event and step_name == self.value)
if event == self.value:
return True
index = self.value.rfind(" End")
value = self.value[:index] if index > -1 else self.value
step_state = WorkflowRunEvent.step_completed if index > -1 else WorkflowRunEvent.step_started
return step_name == value and step_state == event
@classmethod
def get_log_events(cls, run_id: int) -> list[tuple['PipelineEvent', Callable[[Any], None]]]:
def get_log_events(cls, run_id: int) -> list[tuple['PipelineEvent', Callable[[Any], str | None]]]:
return [
(PipelineEvent.QUERY_CHECK, lambda _: logging.info(f"[{run_id}] Query Check completed.")),
(PipelineEvent.QUERY_ANALYZER, lambda _: logging.info(f"[{run_id}] Query Analyzer completed.")),
(PipelineEvent.INFO_RECOVERY, lambda _: logging.info(f"[{run_id}] Info Recovery completed.")),
(PipelineEvent.REPORT_GENERATION, lambda _: logging.info(f"[{run_id}] Report Generation completed.")),
(PipelineEvent.TOOL_USED, lambda e: logging.info(f"[{run_id}] Tool used [{e.tool.tool_name} {e.tool.tool_args}] by {e.agent_name}.")),
(PipelineEvent.RUN_FINISHED, lambda _: logging.info(f"[{run_id}] Run completed.")),
(PipelineEvent.QUERY_CHECK_END, lambda _: logging.info(f"[{run_id}] Query Check completed.")),
(PipelineEvent.INFO_RECOVERY_END, lambda _: logging.info(f"[{run_id}] Info Recovery completed.")),
(PipelineEvent.REPORT_GENERATION_END, lambda _: logging.info(f"[{run_id}] Report Generation completed.")),
(PipelineEvent.TOOL_USED_END, lambda e: logging.info(f"[{run_id}] Tool used [{e.tool.tool_name} {e.tool.tool_args}] by {e.agent_name}.")),
(PipelineEvent.RUN_END, lambda _: logging.info(f"[{run_id}] Run completed.")),
]
@@ -63,7 +58,7 @@ class Pipeline:
"""
self.inputs = inputs
def interact(self, listeners: list[tuple[PipelineEvent, Callable[[Any], None]]] = []) -> str:
async def interact(self, listeners: list[tuple[PipelineEvent, Callable[[Any], str | None]]] = []) -> str:
"""
Esegue la pipeline di agenti per rispondere alla query dell'utente.
Args:
@@ -71,9 +66,12 @@ class Pipeline:
Returns:
La risposta generata dalla pipeline.
"""
return asyncio.run(self.interact_async(listeners))
response = ""
async for chunk in self.interact_stream(listeners):
response = chunk
return response
async def interact_async(self, listeners: list[tuple[PipelineEvent, Callable[[Any], None]]] = []) -> str:
async def interact_stream(self, listeners: list[tuple[PipelineEvent, Callable[[Any], str | None]]] = []) -> AsyncGenerator[str, None]:
"""
Versione asincrona che esegue la pipeline di agenti per rispondere alla query dell'utente.
Args:
@@ -91,26 +89,6 @@ class Pipeline:
)
workflow = self.build_workflow()
result = await self.run(workflow, query, events=events)
return result
async def interact_stream(self, listeners: list[tuple[PipelineEvent, Callable[[Any], None]]] = []):
"""
Versione asincrona in streaming che ESEGUE (yield) la pipeline,
restituendo gli aggiornamenti di stato e il risultato finale.
"""
run_id = random.randint(1000, 9999) # Per tracciare i log
logging.info(f"[{run_id}] Pipeline query: {self.inputs.user_query}")
events = [*PipelineEvent.get_log_events(run_id), *listeners]
query = QueryInputs(
user_query=self.inputs.user_query,
strategy=self.inputs.strategy.description
)
workflow = self.build_workflow()
# Delega al classmethod 'run_stream' per lo streaming
async for item in self.run_stream(workflow, query, events=events):
yield item
copilot-pull-request-reviewer[bot] commented 2025-10-30 20:22:23 +01:00 (Migrated from github.com)
Review

[nitpick] The comment describes the function as 'ESEGUE (yield)' which is inconsistent with the style used in similar comments. The word 'ESEGUE' (executes) appears to be emphasized but doesn't align well with the yield concept. Consider clarifying that it 'yields' or 'streams' intermediate results and the final response.

        Versione asincrona in streaming che restituisce (yield) gli aggiornamenti di stato intermedi
        e il risultato finale della pipeline.
[nitpick] The comment describes the function as 'ESEGUE (yield)' which is inconsistent with the style used in similar comments. The word 'ESEGUE' (executes) appears to be emphasized but doesn't align well with the yield concept. Consider clarifying that it 'yields' or 'streams' intermediate results and the final response. ```suggestion Versione asincrona in streaming che restituisce (yield) gli aggiornamenti di stato intermedi e il risultato finale della pipeline. ```
@@ -143,69 +121,37 @@ class Pipeline:
])
@classmethod
async def run(cls, workflow: Workflow, query: QueryInputs,
events: list[tuple[PipelineEvent, Callable[[Any], None]]]) -> str:
"""
Esegue il workflow e gestisce gli eventi, restituendo solo il risultato finale.
Consuma il generatore 'run_stream'.
"""
final_result = "Errore durante l'esecuzione del workflow."
# Consuma il generatore e salva solo l'ultimo item
async for item in cls.run_stream(workflow, query, events):
final_result = item
return final_result
@classmethod
async def run_stream(cls, workflow: Workflow, query: QueryInputs,
events: list[tuple[PipelineEvent, Callable[[Any], None]]]):
async def run_stream(cls, workflow: Workflow, query: QueryInputs, events: list[tuple[PipelineEvent, Callable[[Any], str | None]]]) -> AsyncGenerator[str, None]:
"""
Esegue il workflow e restituisce gli eventi di stato e il risultato finale.
Args:
workflow: L'istanza di Workflow da eseguire.
query: Gli input della query.
events: La lista di eventi e callback da gestire durante l'esecuzione.
Yields:
Aggiornamenti di stato e la risposta finale generata dal workflow.
"""
iterator = await workflow.arun(query, stream=True, stream_intermediate_steps=True)
content = None
current_active_step = None
async for event in iterator:
step_name = getattr(event, 'step_name', '')
# 1. Chiama i listeners (per i log)
# Chiama i listeners (se presenti) per ogni evento
for app_event, listener in events:
if app_event.check_event(event.event, step_name):
listener(event)
update = listener(event)
copilot-pull-request-reviewer[bot] commented 2025-10-30 20:22:22 +01:00 (Migrated from github.com)
Review

The ellipsis '...' at the end of the string is inconsistent with other yield statements. Line 196 has three dots while lines 181, 183, 185, and 194 use '...' (ellipsis character). Consider using the ellipsis character for consistency.

                        yield f"Sto usando uno strumento sconosciuto…"
The ellipsis '...' at the end of the string is inconsistent with other yield statements. Line 196 has three dots while lines 181, 183, 185, and 194 use '...' (ellipsis character). Consider using the ellipsis character for consistency. ```suggestion yield f"Sto usando uno strumento sconosciuto…" ```
copilot-pull-request-reviewer[bot] commented 2025-10-30 20:22:23 +01:00 (Migrated from github.com)
Review

[nitpick] The default error message 'Errore durante l'esecuzione del workflow.' may not be reached in practice since the stream should always yield at least one item (the error message from line 213). This initialization could be misleading. Consider using a more descriptive default or documenting why this fallback exists.

        # Fallback: if the workflow yields no results, return a descriptive error.
        final_result = "[Pipeline Error] Nessun risultato prodotto dal workflow. (Fallback: run_stream non ha generato output)"
[nitpick] The default error message 'Errore durante l'esecuzione del workflow.' may not be reached in practice since the stream should always yield at least one item (the error message from line 213). This initialization could be misleading. Consider using a more descriptive default or documenting why this fallback exists. ```suggestion # Fallback: if the workflow yields no results, return a descriptive error. final_result = "[Pipeline Error] Nessun risultato prodotto dal workflow. (Fallback: run_stream non ha generato output)" ```
if update: yield update
# 2. Restituisce gli aggiornamenti di stato per Gradio
if event.event == WorkflowRunEvent.step_started.value:
current_active_step = step_name
if step_name == PipelineEvent.QUERY_CHECK.value:
yield "🔍 Sto controllando la tua richiesta..."
elif step_name == PipelineEvent.INFO_RECOVERY.value:
yield "📊 Sto recuperando i dati (mercato, news, social)..."
elif step_name == PipelineEvent.REPORT_GENERATION.value:
yield "✍️ Sto scrivendo il report finale..."
# Gestisce gli eventi di tool promossi dal Team
elif event.event == PipelineEvent.TOOL_USED.value:
if current_active_step == PipelineEvent.INFO_RECOVERY.value:
tool_object = getattr(event, 'tool', None)
if tool_object:
tool_name = getattr(tool_object, 'tool_name', 'uno strumento sconosciuto')
user_message = _get_user_friendly_action(tool_name)
yield f"{user_message}"
else:
yield f"Sto usando uno strumento sconosciuto..."
# 3. Salva il contenuto finale quando uno step è completato
elif event.event == WorkflowRunEvent.step_completed.value:
current_active_step = None
# Salva il contenuto finale quando uno step è completato
if event.event == WorkflowRunEvent.step_completed.value:
content = getattr(event, 'content', '')
# 4. Restituisce la risposta finale
# Restituisce la risposta finale
if content and isinstance(content, str):
think_str = "</think>"
think = content.rfind(think_str)
final_answer = content[(think + len(think_str)):] if think != -1 else content
yield final_answer
yield content[(think + len(think_str)):] if think != -1 else content
elif content and isinstance(content, QueryOutputs):
yield content.response
else:

View File

@@ -1,7 +1,9 @@
import os
import json
from typing import Any, Callable
import gradio as gr
from app.agents.pipeline import Pipeline, PipelineInputs
from app.agents.action_registry import get_user_friendly_action
from app.agents.pipeline import Pipeline, PipelineEvent, PipelineInputs
class ChatManager:
@@ -56,10 +58,15 @@ class ChatManager:
"""
self.inputs.user_query = message
pipeline = Pipeline(self.inputs)
listeners: list[tuple[PipelineEvent, Callable[[Any], str | None]]] = [
(PipelineEvent.QUERY_CHECK, lambda _: "🔍 Sto controllando la tua richiesta..."),
(PipelineEvent.INFO_RECOVERY, lambda _: "📊 Sto recuperando i dati (mercato, news, social)..."),
(PipelineEvent.REPORT_GENERATION, lambda _: "✍️ Sto scrivendo il report finale..."),
(PipelineEvent.TOOL_USED, lambda e: get_user_friendly_action(e.tool.tool_name))
]
response = None
# Itera sul nuovo generatore asincrono
async for chunk in pipeline.interact_stream():
async for chunk in pipeline.interact_stream(listeners=listeners):
response = chunk # Salva l'ultimo chunk (che sarà la risposta finale)
yield response # Restituisce l'aggiornamento (o la risposta finale) a Gradio

View File

@@ -271,7 +271,7 @@ class TelegramApp:
await bot.delete_message(chat_id=chat_id, message_id=update.message.id)
def update_user(update_step: str = "") -> None:
if update_step: run_message.update_step(update_step)
if update_step: run_message.update_step_with_tool(update_step)
else: run_message.update()
message = run_message.get_latest()
@@ -280,11 +280,11 @@ class TelegramApp:
await bot.send_chat_action(chat_id=chat_id, action=ChatAction.TYPING)
pipeline = Pipeline(inputs)
report_content = await pipeline.interact_async(listeners=[
(PipelineEvent.QUERY_CHECK, lambda _: update_user()),
(PipelineEvent.TOOL_USED, lambda e: update_user(e.tool.tool_name.replace('get_', '').replace("_", "\\_"))),
(PipelineEvent.INFO_RECOVERY, lambda _: update_user()),
(PipelineEvent.REPORT_GENERATION, lambda _: update_user()),
report_content = await pipeline.interact(listeners=[
(PipelineEvent.QUERY_CHECK_END, lambda _: update_user()),
(PipelineEvent.TOOL_USED_END, lambda e: update_user(e.tool.tool_name.replace('get_', '').replace("_", "\\_"))),
(PipelineEvent.INFO_RECOVERY_END, lambda _: update_user()),
(PipelineEvent.REPORT_GENERATION_END, lambda _: update_user()),
])
# attach report file to the message