Spaces:
Sleeping
Sleeping
Download src/app.py from ColettoGS/zico-agent: direct link, hf CLI and curl.
- Browser
- Download file 57.8 kB
-
https://huggingface.co/spaces/ColettoGS/zico-agent/resolve/main/src/app.py
- Command line
-
hf download hf://spaces/ColettoGS/zico-agent/src/app.py
-
curl -L -o app.py https://huggingface.co/spaces/ColettoGS/zico-agent/resolve/main/src/app.py
57.8 kB
| import asyncio | |
| import hashlib | |
| import base64 | |
| import json | |
| import os | |
| import time | |
| from typing import Any, Dict, List, Literal, Optional | |
| from fastapi import Depends, FastAPI, File, Form, HTTPException, Request, UploadFile | |
| from fastapi.middleware.cors import CORSMiddleware | |
| from fastapi.responses import StreamingResponse | |
| from langchain_core.messages import HumanMessage | |
| from pydantic import BaseModel | |
| from src.infrastructure.logging import setup_logging, get_logger | |
| from src.infrastructure.rate_limiter import setup_rate_limiter, limiter | |
| from src.agents.config import Config | |
| from src.graphs.factory import build_graph | |
| from src.graphs.nodes import initialize_agents | |
| from src.graphs.utils import get_text_content | |
| from src.models.chatMessage import ChatMessage | |
| from src.routes.chat_manager_routes import router as chat_manager_router | |
| from src.diagnostics.routes import router as diagnostics_router | |
| from src.service.chat_manager import StoragePersistenceError, chat_manager_instance | |
| from src.security.chat_auth import PanoramaPrincipal, require_chat_principal | |
| from src.agents.crypto_data.tools import get_coingecko_id, get_tradingview_symbol | |
| from src.agents.metadata import metadata | |
| from src.diagnostics.response_boundary import start_trace, update_trace | |
| # Setup structured logging | |
| log_level = os.getenv("LOG_LEVEL", "INFO") | |
| log_format = os.getenv("LOG_FORMAT", "color") | |
| setup_logging(level=log_level, format_type=log_format) | |
| logger = get_logger(__name__) | |
| logger.info("Starting Zico Agent API (StateGraph architecture)") | |
| # Initialize FastAPI app | |
| app = FastAPI( | |
| title="Zico Agent API", | |
| version="3.0", | |
| description="Multi-agent AI assistant with deterministic StateGraph routing", | |
| ) | |
| # Setup rate limiting | |
| setup_rate_limiter(app) | |
| # Enable CORS for local/frontend dev | |
| app.add_middleware( | |
| CORSMiddleware, | |
| allow_origins=["*"], | |
| allow_credentials=True, | |
| allow_methods=["*"], | |
| allow_headers=["*"], | |
| ) | |
| # Initialize agents and compile the StateGraph | |
| initialize_agents() | |
| graph = build_graph() | |
| class ChatRequest(BaseModel): | |
| message: ChatMessage | |
| chain_id: str = "default" | |
| wallet_address: str = "default" | |
| conversation_id: str = "default" | |
| user_id: str = "anonymous" | |
| metadata: Dict[str, Any] | None = None | |
| response_mode: Literal["fast", "reasoning"] = "fast" | |
| # Lightweight in-memory agent config for frontend integrations | |
| AVAILABLE_AGENTS = [ | |
| {"name": "default", "human_readable_name": "Default General Purpose", "description": "General chat and meta-queries about agents."}, | |
| {"name": "crypto data", "human_readable_name": "Crypto Data Fetcher", "description": "Real-time cryptocurrency prices, market cap, FDV, TVL."}, | |
| {"name": "token swap", "human_readable_name": "Token Swap Agent", "description": "Swap tokens using supported DEX APIs."}, | |
| {"name": "realtime search", "human_readable_name": "Real-Time Search", "description": "Search the web for recent information."}, | |
| {"name": "dexscreener", "human_readable_name": "DexScreener Analyst", "description": "Fetches and analyzes DEX trading data."}, | |
| {"name": "rugcheck", "human_readable_name": "Token Safety Analyzer", "description": "Analyzes token safety and trends (Solana)."}, | |
| {"name": "imagen", "human_readable_name": "Image Generator", "description": "Generate images from text prompts."}, | |
| {"name": "rag", "human_readable_name": "Document Assistant", "description": "Answer questions about uploaded documents."}, | |
| {"name": "tweet sizzler", "human_readable_name": "Tweet / X-Post Generator", "description": "Generate engaging tweets."}, | |
| {"name": "dca", "human_readable_name": "DCA Strategy Manager", "description": "Plan and manage DCA strategies."}, | |
| {"name": "base", "human_readable_name": "Base Transaction Manager", "description": "Handle transactions on Base network."}, | |
| {"name": "mor rewards", "human_readable_name": "MOR Rewards Tracker", "description": "Track MOR rewards and balances."}, | |
| {"name": "mor claims", "human_readable_name": "MOR Claims Agent", "description": "Claim MOR tokens."}, | |
| {"name": "lending", "human_readable_name": "Lending Agent", "description": "Supply, borrow, repay, or withdraw assets."}, | |
| {"name": "yield", "human_readable_name": "Yield / Liquidity Agent", "description": "Enter, exit, or claim rewards from LP pools on Base."}, | |
| ] | |
| # Default to a small, reasonable subset | |
| SELECTED_AGENTS = [agent["name"] for agent in AVAILABLE_AGENTS[:6]] | |
| # Commands exposed to the ChatInput autocomplete | |
| AGENT_COMMANDS = [ | |
| {"command": "morpheus", "name": "Default General Purpose", "description": "General assistant for simple queries and meta-questions."}, | |
| {"command": "crypto", "name": "Crypto Data Fetcher", "description": "Get prices, market cap, FDV, TVL and more."}, | |
| {"command": "document", "name": "Document Assistant", "description": "Ask questions about uploaded documents."}, | |
| {"command": "tweet", "name": "Tweet / X-Post Generator", "description": "Create engaging tweets about crypto and web3."}, | |
| {"command": "search", "name": "Real-Time Search", "description": "Search the web for recent events or updates."}, | |
| {"command": "dexscreener", "name": "DexScreener Analyst", "description": "Analyze DEX trading data on supported chains."}, | |
| {"command": "rugcheck", "name": "Token Safety Analyzer", "description": "Check token safety and view trending tokens."}, | |
| {"command": "dca", "name": "DCA Strategy Manager", "description": "Plan a dollar-cost averaging strategy."}, | |
| {"command": "base", "name": "Base Transaction Manager", "description": "Send tokens and swap on Base."}, | |
| {"command": "rewards", "name": "MOR Rewards Tracker", "description": "Check rewards balance and accrual."}, | |
| {"command": "lending", "name": "Lending Agent", "description": "Supply, borrow, repay, or withdraw assets."}, | |
| {"command": "yield", "name": "Yield / Liquidity Agent", "description": "Manage LP positions and rewards on Base."}, | |
| ] | |
| # Agents endpoints expected by the frontend | |
| def get_available_agents(): | |
| return { | |
| "selected_agents": SELECTED_AGENTS, | |
| "available_agents": AVAILABLE_AGENTS, | |
| } | |
| async def set_selected_agents(request: Request): | |
| global SELECTED_AGENTS | |
| data = await request.json() | |
| agents = data.get("agents", []) | |
| available_names = {a["name"] for a in AVAILABLE_AGENTS} | |
| valid_agents = [a for a in agents if a in available_names] | |
| if not valid_agents: | |
| return {"status": "no_change", "agents": SELECTED_AGENTS} | |
| SELECTED_AGENTS = valid_agents[:6] | |
| return {"status": "success", "agents": SELECTED_AGENTS} | |
| def get_agent_commands(): | |
| return {"commands": AGENT_COMMANDS} | |
| # Map agent runtime names to high-level types for storage/analytics | |
| def _map_agent_type(agent_name: str) -> str: | |
| mapping = { | |
| "crypto_agent": "crypto data", | |
| "default_agent": "default", | |
| "database_agent": "analysis", | |
| "search_agent": "realtime search", | |
| "swap_agent": "token swap", | |
| "lending_agent": "lending", | |
| "staking_agent": "staking", | |
| "liquidity_agent": "yield", | |
| "strategy_agent": "yield strategy", | |
| "portfolio_advisor": "portfolio analysis", | |
| "supervisor": "supervisor", | |
| } | |
| return mapping.get(agent_name, "supervisor") | |
| def _sanitize_user_message_content(content: str | None) -> str | None: | |
| """Strip wrapper prompts (e.g., 'User Message: ...') the frontend might send.""" | |
| if not content: | |
| return content | |
| text = content.strip() | |
| marker = "user message:" | |
| lowered = text.lower() | |
| idx = lowered.rfind(marker) | |
| if idx != -1: | |
| candidate = text[idx + len(marker) :].strip() | |
| if candidate: | |
| return candidate | |
| return text | |
| def _validate_non_empty_user_content(content: str | None, *, user_id: str, conversation_id: str) -> str: | |
| cleaned = (content or "").strip() | |
| if cleaned: | |
| return cleaned | |
| logger.warning( | |
| "Rejecting empty user message before LLM invocation user=%s conversation=%s", | |
| user_id, | |
| conversation_id, | |
| ) | |
| raise HTTPException(status_code=400, detail="message.content must be a non-empty string") | |
| def _storage_http_exception(exc: StoragePersistenceError) -> HTTPException: | |
| logger.warning("Chat storage failure: %s", exc, exc_info=True) | |
| return HTTPException( | |
| status_code=503, | |
| detail="Chat persistence is currently unavailable. Your message was not stored.", | |
| ) | |
| def _resolve_identity( | |
| request: ChatRequest, | |
| principal: PanoramaPrincipal, | |
| ) -> tuple[str, str]: | |
| """Resolve authenticated ownership and the requested conversation ID.""" | |
| user_id = principal.user_id | |
| conversation_id = (request.conversation_id or "").strip() or "default" | |
| return user_id, conversation_id | |
| def _invoke_graph( | |
| conversation_messages, | |
| user_id, | |
| conversation_id, | |
| *, | |
| wallet_address: str | None = None, | |
| pre_classified: Dict[str, Any] | None = None, | |
| response_mode: str = "fast", | |
| ): | |
| """Invoke the StateGraph and return the result state. | |
| If *pre_classified* is provided (e.g. from audio transcription) its | |
| fields are merged into the initial state so the semantic router can | |
| skip the embedding call. | |
| """ | |
| initial_state: Dict[str, Any] = { | |
| "messages": conversation_messages, | |
| "user_id": user_id, | |
| "conversation_id": conversation_id, | |
| "wallet_address": wallet_address, | |
| "response_mode": response_mode, | |
| } | |
| if pre_classified: | |
| initial_state.update(pre_classified) | |
| return graph.invoke(initial_state) | |
| def _build_response_payload(result, user_id, conversation_id, extra_fields=None): | |
| """Build the HTTP response from graph result state.""" | |
| final_response = result.get("final_response", "No response available") | |
| response_agent = result.get("response_agent", "supervisor") | |
| response_metadata = result.get("response_metadata", {}) | |
| nodes_executed = result.get("nodes_executed", []) | |
| agent_name = _map_agent_type(response_agent) | |
| # Build response metadata and enrich | |
| full_metadata = {"supervisor_result": result} | |
| swap_meta_snapshot = None | |
| if response_metadata: | |
| full_metadata.update(response_metadata) | |
| elif agent_name == "token swap": | |
| swap_meta = metadata.get_swap_agent(user_id=user_id, conversation_id=conversation_id) | |
| if swap_meta: | |
| full_metadata.update(swap_meta) | |
| swap_meta_snapshot = swap_meta | |
| elif agent_name == "lending": | |
| lending_meta = metadata.get_lending_agent(user_id=user_id, conversation_id=conversation_id) | |
| if lending_meta: | |
| full_metadata.update(lending_meta) | |
| elif agent_name == "staking": | |
| staking_meta = metadata.get_staking_agent(user_id=user_id, conversation_id=conversation_id) | |
| if staking_meta: | |
| full_metadata.update(staking_meta) | |
| elif agent_name == "yield": | |
| liquidity_meta = metadata.get_liquidity_agent(user_id=user_id, conversation_id=conversation_id) | |
| if liquidity_meta: | |
| full_metadata.update(liquidity_meta) | |
| elif agent_name == "yield strategy": | |
| strategy_meta = metadata.get_strategy_agent(user_id=user_id, conversation_id=conversation_id) | |
| if strategy_meta: | |
| full_metadata.update(strategy_meta) | |
| # Create and store the response message | |
| response_message = ChatMessage( | |
| role="assistant", | |
| content=final_response, | |
| agent_name=agent_name, | |
| agent_type=_map_agent_type(agent_name), | |
| metadata=response_metadata, | |
| conversation_id=conversation_id, | |
| user_id=user_id, | |
| requires_action=True if agent_name in ["token swap", "lending", "staking", "yield", "yield strategy"] else False, | |
| action_type="swap" if agent_name == "token swap" else "lending" if agent_name == "lending" else "staking" if agent_name == "staking" else "liquidity" if agent_name == "yield" else "strategy" if agent_name == "yield strategy" else None, | |
| ) | |
| chat_manager_instance.add_message( | |
| message=response_message.dict(), | |
| conversation_id=conversation_id, | |
| user_id=user_id, | |
| ) | |
| # Build payload | |
| response_payload = { | |
| "response": final_response, | |
| "agentName": agent_name, | |
| "nodesExecuted": nodes_executed, | |
| } | |
| # Add extra fields (e.g. transcription for audio) | |
| if extra_fields: | |
| response_payload.update(extra_fields) | |
| # Resolve metadata for payload | |
| response_meta = response_metadata or {} | |
| if agent_name == "token swap" and not response_meta: | |
| if swap_meta_snapshot: | |
| response_meta = swap_meta_snapshot | |
| else: | |
| swap_meta = metadata.get_swap_agent(user_id=user_id, conversation_id=conversation_id) | |
| if swap_meta: | |
| response_meta = swap_meta | |
| elif agent_name == "yield" and not response_meta: | |
| liquidity_meta = metadata.get_liquidity_agent(user_id=user_id, conversation_id=conversation_id) | |
| if liquidity_meta: | |
| response_meta = liquidity_meta | |
| if response_meta: | |
| response_payload["metadata"] = response_meta | |
| # Clear metadata after ready events | |
| _clear_ready_metadata(agent_name, response_meta, user_id, conversation_id) | |
| return response_payload | |
| def _clear_ready_metadata(agent_name, response_meta, user_id, conversation_id): | |
| """Clear DeFi metadata when intent is ready for execution.""" | |
| if not response_meta or not isinstance(response_meta, dict): | |
| return | |
| status = response_meta.get("status") | |
| event = response_meta.get("event") | |
| if agent_name == "token swap" and (status == "ready" or event == "swap_intent_ready"): | |
| metadata.set_swap_agent({}, user_id=user_id, conversation_id=conversation_id) | |
| elif agent_name == "lending" and (status == "ready" or event == "lending_intent_ready"): | |
| metadata.set_lending_agent({}, user_id=user_id, conversation_id=conversation_id) | |
| elif agent_name == "staking" and (status == "ready" or event == "staking_intent_ready"): | |
| metadata.set_staking_agent({}, user_id=user_id, conversation_id=conversation_id) | |
| elif agent_name == "yield" and (status == "ready" or event == "liquidity_intent_ready"): | |
| metadata.set_liquidity_agent({}, user_id=user_id, conversation_id=conversation_id) | |
| elif agent_name == "yield strategy" and (status == "ready" or event == "strategy_intent_ready"): | |
| metadata.set_strategy_agent({}, user_id=user_id, conversation_id=conversation_id) | |
| def health_check(): | |
| return {"status": "ok"} | |
| def get_costs(): | |
| """Get current LLM cost summary.""" | |
| cost_tracker = Config.get_cost_tracker() | |
| return cost_tracker.get_summary() | |
| def get_detailed_costs(): | |
| """Get detailed LLM cost report.""" | |
| cost_tracker = Config.get_cost_tracker() | |
| return cost_tracker.get_detailed_report() | |
| def get_conversation_costs(request: Request): | |
| """Get accumulated LLM costs for a specific conversation.""" | |
| params = request.query_params | |
| conversation_id = params.get("conversation_id") | |
| user_id = params.get("user_id") | |
| if not conversation_id or not user_id: | |
| raise HTTPException( | |
| status_code=400, | |
| detail="Both 'conversation_id' and 'user_id' query parameters are required.", | |
| ) | |
| costs = chat_manager_instance.get_conversation_costs( | |
| conversation_id=conversation_id, | |
| user_id=user_id, | |
| ) | |
| return { | |
| "conversation_id": conversation_id, | |
| "user_id": user_id, | |
| "costs": costs, | |
| } | |
| def get_available_models(): | |
| """List available LLM models.""" | |
| return { | |
| "models": Config.list_available_models(), | |
| "providers": Config.list_available_providers(), | |
| "default": Config.DEFAULT_MODEL, | |
| } | |
| def chat( | |
| request: ChatRequest, | |
| principal: PanoramaPrincipal = Depends(require_chat_principal), | |
| ): | |
| user_id: str | None = None | |
| conversation_id: str | None = None | |
| try: | |
| logger.debug("Received chat payload: %s", request.model_dump()) | |
| user_id, conversation_id = _resolve_identity( | |
| request, | |
| principal, | |
| ) | |
| logger.debug( | |
| "Resolved chat identity user=%s conversation=%s wallet=%s", | |
| user_id, | |
| conversation_id, | |
| (request.wallet_address or "").strip() if request.wallet_address else None, | |
| ) | |
| logger.info( | |
| "chat.identity_resolved user_id=%s conversation_id=%s source=chat_endpoint", | |
| user_id, | |
| conversation_id, | |
| ) | |
| wallet = request.wallet_address.strip() if request.wallet_address else None | |
| if wallet and wallet.lower() == "default": | |
| wallet = None | |
| display_name = None | |
| if isinstance(request.message.metadata, dict): | |
| display_name = request.message.metadata.get("display_name") | |
| try: | |
| chat_manager_instance.ensure_session( | |
| user_id, | |
| conversation_id, | |
| wallet_address=wallet, | |
| display_name=display_name, | |
| ) | |
| except StoragePersistenceError as exc: | |
| raise _storage_http_exception(exc) from exc | |
| if request.message.role == "user": | |
| clean_content = _sanitize_user_message_content(request.message.content) | |
| request.message.content = _validate_non_empty_user_content( | |
| clean_content, | |
| user_id=user_id, | |
| conversation_id=conversation_id, | |
| ) | |
| # Add the user message to the conversation | |
| try: | |
| chat_manager_instance.add_message( | |
| message=request.message.dict(), | |
| conversation_id=conversation_id, | |
| user_id=user_id, | |
| ) | |
| except StoragePersistenceError as exc: | |
| raise _storage_http_exception(exc) from exc | |
| # Get all messages from the conversation | |
| conversation_messages = chat_manager_instance.get_messages( | |
| conversation_id=conversation_id, | |
| user_id=user_id, | |
| ) | |
| # Take cost snapshot before invoking | |
| cost_tracker = Config.get_cost_tracker() | |
| cost_snapshot = cost_tracker.get_snapshot() | |
| # Invoke the StateGraph | |
| result = _invoke_graph( | |
| conversation_messages, user_id, conversation_id, | |
| wallet_address=wallet, response_mode=request.response_mode, | |
| ) | |
| # Calculate and save cost delta | |
| cost_delta = cost_tracker.calculate_delta(cost_snapshot) | |
| if cost_delta.get("cost", 0) > 0 or cost_delta.get("calls", 0) > 0: | |
| chat_manager_instance.update_conversation_costs( | |
| cost_delta, | |
| conversation_id=conversation_id, | |
| user_id=user_id, | |
| ) | |
| logger.debug( | |
| "Graph returned result for user=%s conversation=%s nodes=%s", | |
| user_id, | |
| conversation_id, | |
| result.get("nodes_executed", []), | |
| ) | |
| if result: | |
| return _build_response_payload(result, user_id, conversation_id) | |
| return {"response": "No response available", "agentName": "supervisor"} | |
| except StoragePersistenceError as exc: | |
| raise _storage_http_exception(exc) from exc | |
| except HTTPException: | |
| raise | |
| except Exception as e: | |
| logger.exception( | |
| "Chat handler failed for user=%s conversation=%s", | |
| user_id, | |
| conversation_id, | |
| ) | |
| raise HTTPException(status_code=500, detail=str(e)) | |
| # --------------------------------------------------------------------------- | |
| # SSE Streaming endpoint | |
| # --------------------------------------------------------------------------- | |
| # Human-readable labels for each graph node | |
| _NODE_LABELS: Dict[str, str] = { | |
| "entry_node": "Preparing context...", | |
| "semantic_router_node": "Routing your request...", | |
| "llm_router_node": "Analyzing intent...", | |
| "swap_agent_node": "Consulting swap protocols...", | |
| "lending_agent_node": "Checking lending markets...", | |
| "staking_agent_node": "Reviewing staking options...", | |
| "liquidity_agent_node": "Preparing liquidity flow...", | |
| "dca_agent_node": "Planning DCA strategy...", | |
| "strategy_agent_node": "Designing AVAX strategy...", | |
| "crypto_agent_node": "Fetching market data...", | |
| "search_agent_node": "Searching the web...", | |
| "default_agent_node": "Thinking...", | |
| "database_agent_node": "Querying portfolio...", | |
| "portfolio_advisor_node": "Analyzing your portfolio...", | |
| "formatter_node": "Formatting response...", | |
| "error_node": "Validating parameters...", | |
| } | |
| def _sse(event_type: str, data: dict) -> str: | |
| """Format a Server-Sent Event string.""" | |
| return f"event: {event_type}\ndata: {json.dumps(data, ensure_ascii=False)}\n\n" | |
| async def _persist_response_bg( | |
| full_response: str, | |
| response_agent: str, | |
| response_metadata: dict, | |
| user_id: str, | |
| conversation_id: str, | |
| cost_delta: dict, | |
| ) -> None: | |
| """Durably persist the streamed assistant response before acknowledgement.""" | |
| agent_name = _map_agent_type(response_agent) | |
| response_message = ChatMessage( | |
| role="assistant", | |
| content=full_response, | |
| agent_name=agent_name, | |
| agent_type=_map_agent_type(agent_name), | |
| metadata=response_metadata, | |
| conversation_id=conversation_id, | |
| user_id=user_id, | |
| requires_action=( | |
| True if agent_name in ("token swap", "lending", "staking", "yield", "yield strategy") else False | |
| ), | |
| action_type=( | |
| "swap" | |
| if agent_name == "token swap" | |
| else "lending" | |
| if agent_name == "lending" | |
| else "staking" | |
| if agent_name == "staking" | |
| else "liquidity" | |
| if agent_name == "yield" | |
| else "strategy" | |
| if agent_name == "yield strategy" | |
| else None | |
| ), | |
| ) | |
| # Assistant-message durability is part of successful chat completion. | |
| # Let this failure propagate to the stream so it cannot emit a false | |
| # successful completion acknowledgement. | |
| await asyncio.to_thread( | |
| chat_manager_instance.add_message, | |
| response_message.dict(), | |
| conversation_id, | |
| user_id, | |
| ) | |
| # Cost accounting is secondary to durable chat history. | |
| if cost_delta.get("cost", 0) > 0 or cost_delta.get("calls", 0) > 0: | |
| try: | |
| await asyncio.to_thread( | |
| chat_manager_instance.update_conversation_costs, | |
| cost_delta, | |
| conversation_id, | |
| user_id, | |
| ) | |
| except Exception: | |
| logger.exception( | |
| "Failed to update costs for streamed response " | |
| "user=%s conversation=%s", | |
| user_id, | |
| conversation_id, | |
| ) | |
| # Metadata cleanup is also secondary once the response is durable. | |
| try: | |
| _clear_ready_metadata( | |
| agent_name, | |
| response_metadata, | |
| user_id, | |
| conversation_id, | |
| ) | |
| except Exception: | |
| logger.exception( | |
| "Failed to clear metadata after streamed response " | |
| "user=%s conversation=%s", | |
| user_id, | |
| conversation_id, | |
| ) | |
| def _build_event_generator( | |
| initial_state: Dict[str, Any], | |
| user_id: str, | |
| conversation_id: str, | |
| response_mode: str, | |
| ): | |
| """Factory that returns an async generator yielding SSE events from the graph.""" | |
| async def event_generator(): | |
| start_trace(user_id, conversation_id) | |
| cost_tracker = Config.get_cost_tracker() | |
| cost_snapshot = cost_tracker.get_snapshot() | |
| final_response_chunks: List[str] = [] | |
| authoritative_graph_response = "" | |
| model_runs: Dict[str, Dict[str, Any]] = {} | |
| model_run_order: List[str] = [] | |
| response_agent = "supervisor" | |
| response_metadata: Dict[str, Any] = {} | |
| nodes_executed: List[str] = [] | |
| current_agent_node: str | None = None | |
| streaming_tokens = False | |
| try: | |
| async for event in graph.astream_events( | |
| initial_state, version="v2" | |
| ): | |
| kind = event["event"] | |
| name = event.get("name", "") | |
| if kind == "on_chain_start" and name in _NODE_LABELS: | |
| nodes_executed.append(name) | |
| if name.endswith("_agent_node"): | |
| current_agent_node = name | |
| yield _sse("status", { | |
| "step": name, | |
| "label": _NODE_LABELS[name], | |
| "ts": time.time(), | |
| }) | |
| elif kind == "on_chain_end" and name == "semantic_router_node": | |
| output = event.get("data", {}).get("output", {}) | |
| if isinstance(output, dict): | |
| yield _sse("status", { | |
| "step": "routed", | |
| "agent": output.get("route_agent", "unknown"), | |
| "confidence": output.get("route_confidence", 0), | |
| "ts": time.time(), | |
| }) | |
| elif kind == "on_tool_start": | |
| yield _sse("status", { | |
| "step": "tool", | |
| "tool": name, | |
| "label": f"Using {name}...", | |
| "ts": time.time(), | |
| }) | |
| elif kind == "on_tool_end": | |
| tool_output = event.get("data", {}).get("output", "") | |
| preview = str(tool_output)[:200] | |
| yield _sse("tool_io", { | |
| "tool": name, | |
| "output": preview, | |
| "ts": time.time(), | |
| }) | |
| elif kind == "on_chat_model_stream": | |
| chunk = event.get("data", {}).get("chunk") | |
| if chunk and hasattr(chunk, "content") and chunk.content: | |
| text = chunk.content if isinstance(chunk.content, str) else "" | |
| if text: | |
| parent_tags = event.get("tags", []) | |
| is_formatter = "formatter" in name.lower() or any( | |
| "formatter" in t for t in parent_tags | |
| ) | |
| if not is_formatter and current_agent_node: | |
| run_id = str(event.get("run_id") or "") | |
| if run_id: | |
| if run_id not in model_runs: | |
| model_runs[run_id] = { | |
| "sequence": len(model_run_order) + 1, | |
| "name": name, | |
| "parent_depth": len(event.get("parent_ids", []) or []), | |
| "chunks": [], | |
| "end_length": None, | |
| "end_sha256_16": None, | |
| "tool_call_count": None, | |
| } | |
| model_run_order.append(run_id) | |
| model_runs[run_id]["chunks"].append(text) | |
| if not streaming_tokens: | |
| streaming_tokens = True | |
| yield _sse("status", { | |
| "step": "generating", | |
| "label": "Generating response...", | |
| "ts": time.time(), | |
| }) | |
| final_response_chunks.append(text) | |
| yield _sse("token", {"t": text}) | |
| elif kind == "on_chat_model_end": | |
| run_id = str(event.get("run_id") or "") | |
| if run_id and run_id in model_runs: | |
| output = event.get("data", {}).get("output") | |
| end_text = get_text_content(output) or "" | |
| tool_calls = getattr(output, "tool_calls", None) | |
| model_runs[run_id]["end_length"] = len(end_text) | |
| model_runs[run_id]["end_sha256_16"] = ( | |
| hashlib.sha256(end_text.encode("utf-8")).hexdigest()[:16] | |
| if end_text | |
| else None | |
| ) | |
| model_runs[run_id]["tool_call_count"] = ( | |
| len(tool_calls) if isinstance(tool_calls, list) else 0 | |
| ) | |
| elif kind == "on_chain_end" and name == "LangGraph": | |
| output = event.get("data", {}).get("output", {}) | |
| if isinstance(output, dict): | |
| response_agent = output.get("response_agent", response_agent) | |
| response_metadata = output.get("response_metadata", response_metadata) | |
| graph_response = output.get("final_response", "") | |
| if isinstance(graph_response, str) and graph_response: | |
| authoritative_graph_response = graph_response | |
| except Exception as exc: | |
| logger.exception("Stream error for user=%s conversation=%s", user_id, conversation_id) | |
| yield _sse("error", {"message": str(exc)}) | |
| return | |
| # Token callbacks are presentation-only. They are not guaranteed to | |
| # represent a complete user-facing response across nested LangGraph / | |
| # agent model calls. The completed graph state is authoritative for | |
| # durability and the final completion acknowledgement. | |
| streamed_response = "".join(final_response_chunks) | |
| full_response = authoritative_graph_response or streamed_response | |
| def _digest(value: str) -> str: | |
| return hashlib.sha256(value.encode("utf-8")).hexdigest()[:16] | |
| logger.info( | |
| "response.boundary.stream_complete streamed_length=%d streamed_sha256_16=%s " | |
| "graph_length=%d graph_sha256_16=%s selected_length=%d selected_sha256_16=%s", | |
| len(streamed_response), | |
| _digest(streamed_response), | |
| len(authoritative_graph_response), | |
| _digest(authoritative_graph_response), | |
| len(full_response), | |
| _digest(full_response), | |
| ) | |
| model_run_evidence = [] | |
| for run_id in model_run_order: | |
| run = model_runs[run_id] | |
| run_streamed = "".join(run["chunks"]) | |
| model_run_evidence.append({ | |
| "sequence": run["sequence"], | |
| "name": run["name"], | |
| "parent_depth": run["parent_depth"], | |
| "streamed_length": len(run_streamed), | |
| "streamed_sha256_16": _digest(run_streamed) if run_streamed else None, | |
| "end_length": run["end_length"], | |
| "end_sha256_16": run["end_sha256_16"], | |
| "tool_call_count": run["tool_call_count"], | |
| }) | |
| update_trace( | |
| user_id, | |
| conversation_id, | |
| section="stream", | |
| value={ | |
| "streamed_length": len(streamed_response), | |
| "streamed_sha256_16": _digest(streamed_response), | |
| "graph_length": len(authoritative_graph_response), | |
| "graph_sha256_16": _digest(authoritative_graph_response), | |
| "selected_length": len(full_response), | |
| "selected_sha256_16": _digest(full_response), | |
| "model_run_count": len(model_run_evidence), | |
| "model_runs": model_run_evidence, | |
| }, | |
| ) | |
| cost_delta = cost_tracker.calculate_delta(cost_snapshot) | |
| agent_name = _map_agent_type(response_agent) | |
| if not response_metadata: | |
| if agent_name == "token swap": | |
| swap_meta = metadata.get_swap_agent(user_id=user_id, conversation_id=conversation_id) | |
| if swap_meta: | |
| response_metadata = swap_meta | |
| elif agent_name == "lending": | |
| lending_meta = metadata.get_lending_agent(user_id=user_id, conversation_id=conversation_id) | |
| if lending_meta: | |
| response_metadata = lending_meta | |
| elif agent_name == "staking": | |
| staking_meta = metadata.get_staking_agent(user_id=user_id, conversation_id=conversation_id) | |
| if staking_meta: | |
| response_metadata = staking_meta | |
| elif agent_name == "yield": | |
| liquidity_meta = metadata.get_liquidity_agent(user_id=user_id, conversation_id=conversation_id) | |
| if liquidity_meta: | |
| response_metadata = liquidity_meta | |
| elif agent_name == "yield strategy": | |
| strategy_meta = metadata.get_strategy_agent(user_id=user_id, conversation_id=conversation_id) | |
| if strategy_meta: | |
| response_metadata = strategy_meta | |
| try: | |
| await _persist_response_bg( | |
| full_response, | |
| response_agent, | |
| response_metadata, | |
| user_id, | |
| conversation_id, | |
| cost_delta, | |
| ) | |
| except Exception: | |
| logger.exception( | |
| "Failed to persist streamed response " | |
| "user=%s conversation=%s", | |
| user_id, | |
| conversation_id, | |
| ) | |
| yield _sse( | |
| "error", | |
| { | |
| "message": ( | |
| "The assistant response could not be durably " | |
| "saved. Please retry." | |
| ) | |
| }, | |
| ) | |
| return | |
| yield _sse("done", { | |
| "agent": agent_name, | |
| "nodes": nodes_executed, | |
| "metadata": response_metadata, | |
| "response": full_response, | |
| "response_mode": response_mode, | |
| "costs": {"total_usd": cost_delta.get("cost", 0)}, | |
| }) | |
| return event_generator | |
| def _streaming_response( | |
| initial_state: Dict[str, Any], | |
| user_id: str, | |
| conversation_id: str, | |
| response_mode: str, | |
| ) -> StreamingResponse: | |
| """Build a StreamingResponse from the shared event generator factory.""" | |
| gen = _build_event_generator(initial_state, user_id, conversation_id, response_mode) | |
| return StreamingResponse( | |
| gen(), | |
| media_type="text/event-stream", | |
| headers={ | |
| "Cache-Control": "no-cache", | |
| "Connection": "keep-alive", | |
| "X-Accel-Buffering": "no", | |
| }, | |
| ) | |
| async def chat_stream( | |
| request: ChatRequest, | |
| principal: PanoramaPrincipal = Depends(require_chat_principal), | |
| ): | |
| """SSE streaming endpoint β streams thought process + tokens in real-time. | |
| Event types: | |
| - ``status`` : node lifecycle (step label, routing info) | |
| - ``token`` : incremental text chunks from the final LLM response | |
| - ``tool_io`` : tool invocation results (truncated for the wire) | |
| - ``done`` : final metadata envelope (agent, nodes, costs) | |
| - ``error`` : unrecoverable error | |
| """ | |
| uid: str | None = None | |
| cid: str | None = None | |
| try: | |
| uid, cid = _resolve_identity( | |
| request, | |
| principal, | |
| ) | |
| except HTTPException as exc: | |
| # Return error as a streaming event so the client can parse it | |
| async def _err(): | |
| yield _sse("error", {"message": exc.detail}) | |
| return StreamingResponse(_err(), media_type="text/event-stream") | |
| user_id, conversation_id = uid, cid | |
| logger.info( | |
| "chat.identity_resolved user_id=%s conversation_id=%s source=chat_stream", | |
| user_id, | |
| conversation_id, | |
| ) | |
| wallet = request.wallet_address.strip() if request.wallet_address else None | |
| if wallet and wallet.lower() == "default": | |
| wallet = None | |
| display_name = None | |
| if isinstance(request.message.metadata, dict): | |
| display_name = request.message.metadata.get("display_name") | |
| # Session setup, message persistence, and history fetch (non-blocking) | |
| try: | |
| await asyncio.to_thread( | |
| chat_manager_instance.ensure_session, | |
| user_id, | |
| conversation_id, | |
| wallet_address=wallet, | |
| display_name=display_name, | |
| ) | |
| except StoragePersistenceError as exc: | |
| error_detail = _storage_http_exception(exc).detail | |
| async def _err(): | |
| yield _sse("error", {"message": error_detail}) | |
| return StreamingResponse(_err(), media_type="text/event-stream") | |
| if request.message.role == "user": | |
| clean_content = _sanitize_user_message_content(request.message.content) | |
| try: | |
| request.message.content = _validate_non_empty_user_content( | |
| clean_content, | |
| user_id=user_id, | |
| conversation_id=conversation_id, | |
| ) | |
| except HTTPException as exc: | |
| async def _err(): | |
| yield _sse("error", {"message": exc.detail}) | |
| return StreamingResponse(_err(), media_type="text/event-stream") | |
| try: | |
| await asyncio.to_thread( | |
| chat_manager_instance.add_message, | |
| request.message.dict(), | |
| conversation_id, | |
| user_id, | |
| ) | |
| except StoragePersistenceError as exc: | |
| error_detail = _storage_http_exception(exc).detail | |
| async def _err(): | |
| yield _sse("error", {"message": error_detail}) | |
| return StreamingResponse(_err(), media_type="text/event-stream") | |
| conversation_messages = await asyncio.to_thread( | |
| chat_manager_instance.get_messages, | |
| conversation_id, | |
| user_id, | |
| ) | |
| initial_state: Dict[str, Any] = { | |
| "messages": conversation_messages, | |
| "user_id": user_id, | |
| "conversation_id": conversation_id, | |
| "wallet_address": wallet, | |
| "response_mode": request.response_mode, | |
| } | |
| return _streaming_response(initial_state, user_id, conversation_id, request.response_mode) | |
| # --------------------------------------------------------------------------- | |
| # Streaming chat with file attachments (images + documents) | |
| # --------------------------------------------------------------------------- | |
| async def chat_stream_with_files( | |
| message: str = Form(...), | |
| user_id: str = Form(...), | |
| conversation_id: str = Form("default"), | |
| wallet_address: str = Form("default"), | |
| response_mode: Literal["fast", "reasoning"] = Form("fast"), | |
| files: List[UploadFile] = File(default=[]), | |
| principal: PanoramaPrincipal = Depends(require_chat_principal), | |
| ): | |
| """SSE streaming with file attachments (images & documents). | |
| Accepts ``multipart/form-data`` with one or more files. | |
| Images are encoded as base64 for Gemini multimodal content. | |
| Documents (PDF, txt, md, csv) are text-extracted and injected as context. | |
| """ | |
| from src.service.file_processing import ( | |
| get_file_mime_type, is_image, is_document, | |
| encode_image_for_gemini, extract_text_from_document, | |
| MAX_IMAGE_SIZE, MAX_DOCUMENT_SIZE, | |
| ) | |
| # Ownership comes exclusively from the authenticated Panorama principal. | |
| uid = principal.user_id | |
| cid = (conversation_id or "").strip() or "default" | |
| wallet = wallet_address.strip() if wallet_address else None | |
| if wallet and wallet.lower() == "default": | |
| wallet = None | |
| logger.info( | |
| "chat.identity_resolved user_id=%s conversation_id=%s source=chat_stream_with_files", | |
| uid, | |
| cid, | |
| ) | |
| clean_message = _sanitize_user_message_content(message) | |
| try: | |
| message = _validate_non_empty_user_content( | |
| clean_message, | |
| user_id=uid, | |
| conversation_id=cid, | |
| ) | |
| except HTTPException as exc: | |
| async def _err(): | |
| yield _sse("error", {"message": exc.detail}) | |
| return StreamingResponse(_err(), media_type="text/event-stream") | |
| # --- Process file attachments --- | |
| file_attachments: List[Dict[str, Any]] = [] | |
| for f in files: | |
| content = await f.read() | |
| mime = get_file_mime_type(f.filename or "", f.content_type) | |
| if is_image(mime): | |
| if len(content) > MAX_IMAGE_SIZE: | |
| async def _err(): | |
| yield _sse("error", {"message": f"Image '{f.filename}' exceeds {MAX_IMAGE_SIZE // (1024*1024)}MB limit."}) | |
| return StreamingResponse(_err(), media_type="text/event-stream") | |
| encoded = encode_image_for_gemini(content, mime) | |
| file_attachments.append({ | |
| "type": "image", | |
| "filename": f.filename, | |
| "mime_type": mime, | |
| "data": encoded["data"], | |
| }) | |
| elif is_document(mime): | |
| if len(content) > MAX_DOCUMENT_SIZE: | |
| async def _err(): | |
| yield _sse("error", {"message": f"Document '{f.filename}' exceeds {MAX_DOCUMENT_SIZE // (1024*1024)}MB limit."}) | |
| return StreamingResponse(_err(), media_type="text/event-stream") | |
| try: | |
| text = await asyncio.to_thread(extract_text_from_document, content, mime) | |
| logger.info("Extracted %d chars from document %s (%s)", len(text), f.filename, mime) | |
| except Exception as exc: | |
| logger.exception("Failed to extract text from %s (%s): %s", f.filename, mime, exc) | |
| text = "" | |
| file_attachments.append({ | |
| "type": "document", | |
| "filename": f.filename, | |
| "mime_type": mime, | |
| "text": text, | |
| }) | |
| else: | |
| async def _err(): | |
| yield _sse("error", {"message": f"Unsupported file type: {f.filename} ({mime or 'unknown'})"}) | |
| return StreamingResponse(_err(), media_type="text/event-stream") | |
| # --- Session setup + persist user message --- | |
| try: | |
| await asyncio.to_thread( | |
| chat_manager_instance.ensure_session, | |
| uid, cid, wallet_address=wallet, | |
| ) | |
| except StoragePersistenceError as exc: | |
| error_detail = _storage_http_exception(exc).detail | |
| async def _err(): | |
| yield _sse("error", {"message": error_detail}) | |
| return StreamingResponse(_err(), media_type="text/event-stream") | |
| user_msg = ChatMessage( | |
| role="user", | |
| content=message, | |
| metadata={"source": "miniapp-chat", "has_attachments": True, "attachment_count": len(file_attachments)}, | |
| ) | |
| try: | |
| await asyncio.to_thread( | |
| chat_manager_instance.add_message, user_msg.dict(), cid, uid, | |
| ) | |
| except StoragePersistenceError as exc: | |
| error_detail = _storage_http_exception(exc).detail | |
| async def _err(): | |
| yield _sse("error", {"message": error_detail}) | |
| return StreamingResponse(_err(), media_type="text/event-stream") | |
| conversation_messages = await asyncio.to_thread( | |
| chat_manager_instance.get_messages, cid, uid, | |
| ) | |
| initial_state: Dict[str, Any] = { | |
| "messages": conversation_messages, | |
| "user_id": uid, | |
| "conversation_id": cid, | |
| "wallet_address": wallet, | |
| "response_mode": response_mode, | |
| "file_attachments": file_attachments, | |
| } | |
| return _streaming_response(initial_state, uid, cid, response_mode) | |
| # Supported audio MIME types | |
| AUDIO_MIME_TYPES = { | |
| ".mp3": "audio/mpeg", | |
| ".wav": "audio/wav", | |
| ".flac": "audio/flac", | |
| ".ogg": "audio/ogg", | |
| ".webm": "audio/webm", | |
| ".m4a": "audio/mp4", | |
| ".aac": "audio/aac", | |
| } | |
| MAX_AUDIO_SIZE = 20 * 1024 * 1024 | |
| def _get_audio_mime_type(filename: str, content_type: str | None) -> str: | |
| """Determine the MIME type for an audio file.""" | |
| if filename: | |
| ext = os.path.splitext(filename.lower())[1] | |
| if ext in AUDIO_MIME_TYPES: | |
| return AUDIO_MIME_TYPES[ext] | |
| if content_type and content_type.startswith("audio/"): | |
| return content_type | |
| return "audio/mpeg" | |
| # --------------------------------------------------------------------------- | |
| # Audio: combined transcription + intent classification prompt | |
| # --------------------------------------------------------------------------- | |
| _AUDIO_TRANSCRIBE_AND_CLASSIFY_PROMPT = """\ | |
| You will receive an audio clip. Perform TWO tasks: | |
| 1. **Transcribe** exactly what is being said. | |
| 2. **Classify** the user's intent into one of these categories: | |
| swap, lending, staking, liquidity, dca, strategy, market_data, search, education, general | |
| Return ONLY a JSON object (no markdown fences) with these fields: | |
| {"transcription": "<exact transcription>", "intent": "<category>", "confidence": <0.0-1.0>} | |
| """ | |
| _AUDIO_TRANSCRIBE_ONLY_PROMPT = """\ | |
| You will receive an audio clip. Transcribe exactly what is being said. | |
| Return ONLY the transcription text, nothing else. No JSON, no markdown, no labels. | |
| """ | |
| # Maps audio classification intents to agent runtime names | |
| _AUDIO_INTENT_AGENT_MAP: Dict[str, str] = { | |
| "swap": "swap_agent", | |
| "lending": "lending_agent", | |
| "staking": "staking_agent", | |
| "liquidity": "liquidity_agent", | |
| "dca": "dca_agent", | |
| "strategy": "strategy_agent", | |
| "market_data": "crypto_agent", | |
| "portfolio": "portfolio_advisor", | |
| "search": "search_agent", | |
| "education": "default_agent", | |
| "general": "default_agent", | |
| } | |
| def _parse_audio_classification(raw_content: str) -> tuple[str, str | None, float]: | |
| """Parse the combined transcription + classification JSON response. | |
| Returns ``(transcription, intent, confidence)``. Falls back gracefully | |
| if the model doesn't return valid JSON β treats the entire response as | |
| plain transcription. | |
| """ | |
| text = raw_content.strip() | |
| # Strip markdown code fences if present | |
| if text.startswith("```"): | |
| text = text.split("\n", 1)[-1] | |
| if text.endswith("```"): | |
| text = text.rsplit("```", 1)[0] | |
| text = text.strip() | |
| try: | |
| data = json.loads(text) | |
| transcription = (data.get("transcription") or "").strip() | |
| intent = (data.get("intent") or "").strip().lower() | |
| confidence = float(data.get("confidence", 0.0)) | |
| if not transcription: | |
| # JSON parsed but no transcription field β use raw | |
| return raw_content.strip(), None, 0.0 | |
| valid_intents = set(_AUDIO_INTENT_AGENT_MAP.keys()) | |
| if intent not in valid_intents: | |
| intent = None | |
| confidence = 0.0 | |
| return transcription, intent, confidence | |
| except (json.JSONDecodeError, ValueError, TypeError): | |
| # Not JSON β treat entire response as transcription | |
| return raw_content.strip(), None, 0.0 | |
| async def chat_audio( | |
| audio: UploadFile = File(..., description="Audio file (mp3, wav, flac, ogg, webm, m4a)"), | |
| user_id: str = Form(..., description="User ID"), | |
| conversation_id: str = Form(..., description="Conversation ID"), | |
| wallet_address: str = Form("default", description="Wallet address"), | |
| principal: PanoramaPrincipal = Depends(require_chat_principal), | |
| ): | |
| """Process audio input through the agent pipeline. | |
| Optimisations over the naive sequential approach: | |
| 1. Combined transcription + intent classification in a single LLM call | |
| 2. Session setup + history fetch run in parallel with transcription | |
| 3. All blocking calls run in a thread pool (asyncio.to_thread) | |
| 4. Pre-classified intent is injected into graph state so semantic_router | |
| can skip the embedding call (~200 ms saved) | |
| """ | |
| request_user_id: str = principal.user_id | |
| request_conversation_id: str = ( | |
| (conversation_id or "").strip() or "default" | |
| ) | |
| try: | |
| logger.debug( | |
| "Received audio chat request user=%s conversation=%s filename=%s", | |
| request_user_id, | |
| request_conversation_id, | |
| audio.filename, | |
| ) | |
| # Validate file size | |
| audio_content = await audio.read() | |
| if len(audio_content) > MAX_AUDIO_SIZE: | |
| raise HTTPException( | |
| status_code=413, | |
| detail=f"Audio file too large. Maximum size is {MAX_AUDIO_SIZE // (1024 * 1024)}MB.", | |
| ) | |
| if len(audio_content) == 0: | |
| raise HTTPException(status_code=400, detail="Audio file is empty.") | |
| mime_type = _get_audio_mime_type(audio.filename or "", audio.content_type) | |
| logger.debug("Audio MIME type: %s, size: %d bytes", mime_type, len(audio_content)) | |
| encoded_audio = base64.b64encode(audio_content).decode("utf-8") | |
| wallet = wallet_address.strip() if wallet_address else None | |
| if wallet and wallet.lower() == "default": | |
| wallet = None | |
| # Take cost snapshot | |
| cost_tracker = Config.get_cost_tracker() | |
| cost_snapshot = cost_tracker.get_snapshot() | |
| # ββ Parallel phase: transcription + session/history ββββββββββββββ | |
| # These are independent β run concurrently. | |
| from src.llm.tiers import ModelTier | |
| llm = Config.get_llm(model=ModelTier.TRANSCRIPTION, with_cost_tracking=True) | |
| transcription_message = HumanMessage( | |
| content=[ | |
| {"type": "text", "text": _AUDIO_TRANSCRIBE_AND_CLASSIFY_PROMPT}, | |
| {"type": "media", "data": encoded_audio, "mime_type": mime_type}, | |
| ] | |
| ) | |
| async def _transcribe(): | |
| return await asyncio.to_thread(llm.invoke, [transcription_message]) | |
| async def _setup_session_and_history(): | |
| try: | |
| await asyncio.to_thread( | |
| chat_manager_instance.ensure_session, | |
| request_user_id, | |
| request_conversation_id, | |
| wallet_address=wallet, | |
| ) | |
| except StoragePersistenceError as exc: | |
| raise _storage_http_exception(exc) from exc | |
| return await asyncio.to_thread( | |
| chat_manager_instance.get_messages, | |
| request_conversation_id, | |
| request_user_id, | |
| ) | |
| transcription_response, conversation_messages = await asyncio.gather( | |
| _transcribe(), | |
| _setup_session_and_history(), | |
| ) | |
| # ββ Parse combined transcription + classification ββββββββββββββββ | |
| raw_content = transcription_response.content | |
| if isinstance(raw_content, list): | |
| text_parts = [] | |
| for part in raw_content: | |
| if isinstance(part, dict) and part.get("text"): | |
| text_parts.append(part["text"]) | |
| elif isinstance(part, str): | |
| text_parts.append(part) | |
| raw_content = " ".join(text_parts).strip() | |
| transcribed_text, audio_intent, audio_confidence = _parse_audio_classification( | |
| raw_content or "", | |
| ) | |
| if not transcribed_text: | |
| raise HTTPException( | |
| status_code=400, | |
| detail="Could not transcribe the audio. Please try again with a clearer recording.", | |
| ) | |
| logger.info( | |
| "Audio transcribed: '%s' | intent=%s confidence=%.2f", | |
| transcribed_text[:200], | |
| audio_intent, | |
| audio_confidence, | |
| ) | |
| # ββ Store user message βββββββββββββββββββββββββββββββββββββββββββ | |
| user_message = ChatMessage( | |
| role="user", | |
| content=transcribed_text, | |
| metadata={ | |
| "source": "audio", | |
| "audio_filename": audio.filename, | |
| "audio_size": len(audio_content), | |
| "audio_mime_type": mime_type, | |
| }, | |
| ) | |
| try: | |
| await asyncio.to_thread( | |
| chat_manager_instance.add_message, | |
| user_message.dict(), | |
| request_conversation_id, | |
| request_user_id, | |
| ) | |
| except StoragePersistenceError as exc: | |
| raise _storage_http_exception(exc) from exc | |
| # Re-fetch messages with the newly added user message | |
| conversation_messages = await asyncio.to_thread( | |
| chat_manager_instance.get_messages, | |
| request_conversation_id, | |
| request_user_id, | |
| ) | |
| # ββ Invoke graph with pre-classified intent ββββββββββββββββββββββ | |
| pre_classified: Dict[str, Any] | None = None | |
| if audio_intent and audio_confidence > 0.0: | |
| pre_classified = { | |
| "route_intent": audio_intent, | |
| "route_confidence": audio_confidence, | |
| "route_agent": _AUDIO_INTENT_AGENT_MAP.get(audio_intent, "default_agent"), | |
| } | |
| result = await asyncio.to_thread( | |
| _invoke_graph, | |
| conversation_messages, | |
| request_user_id, | |
| request_conversation_id, | |
| wallet_address=wallet, | |
| pre_classified=pre_classified, | |
| response_mode="fast", # Audio path defaults to fast | |
| ) | |
| # ββ Cost tracking ββββββββββββββββββββββββββββββββββββββββββββββββ | |
| cost_delta = cost_tracker.calculate_delta(cost_snapshot) | |
| if cost_delta.get("cost", 0) > 0 or cost_delta.get("calls", 0) > 0: | |
| chat_manager_instance.update_conversation_costs( | |
| cost_delta, | |
| conversation_id=request_conversation_id, | |
| user_id=request_user_id, | |
| ) | |
| logger.debug( | |
| "Graph returned result for audio user=%s conversation=%s nodes=%s", | |
| request_user_id, | |
| request_conversation_id, | |
| result.get("nodes_executed", []), | |
| ) | |
| if result: | |
| return _build_response_payload( | |
| result, | |
| request_user_id, | |
| request_conversation_id, | |
| extra_fields={"transcription": transcribed_text}, | |
| ) | |
| return { | |
| "response": "No response available", | |
| "agentName": "supervisor", | |
| "transcription": transcribed_text, | |
| } | |
| except StoragePersistenceError as exc: | |
| raise _storage_http_exception(exc) from exc | |
| except HTTPException: | |
| raise | |
| except Exception as e: | |
| logger.exception( | |
| "Audio chat handler failed for user=%s conversation=%s", | |
| request_user_id, | |
| request_conversation_id, | |
| ) | |
| raise HTTPException(status_code=500, detail=str(e)) | |
| async def transcribe_audio( | |
| audio: UploadFile = File(..., description="Audio file (mp3, wav, flac, ogg, webm, m4a)"), | |
| ): | |
| """Transcribe audio to text without invoking the agent pipeline. | |
| Stateless endpoint β no user_id, conversation_id, or persistence. | |
| Returns ``{"text": "<transcription>"}``. | |
| """ | |
| try: | |
| audio_content = await audio.read() | |
| if len(audio_content) > MAX_AUDIO_SIZE: | |
| raise HTTPException( | |
| status_code=413, | |
| detail=f"Audio file too large. Maximum size is {MAX_AUDIO_SIZE // (1024 * 1024)}MB.", | |
| ) | |
| if len(audio_content) == 0: | |
| raise HTTPException(status_code=400, detail="Audio file is empty.") | |
| mime_type = _get_audio_mime_type(audio.filename or "", audio.content_type) | |
| encoded_audio = base64.b64encode(audio_content).decode("utf-8") | |
| from src.llm.tiers import ModelTier | |
| llm = Config.get_llm(model=ModelTier.TRANSCRIPTION, with_cost_tracking=True) | |
| message = HumanMessage( | |
| content=[ | |
| {"type": "text", "text": _AUDIO_TRANSCRIBE_ONLY_PROMPT}, | |
| {"type": "media", "data": encoded_audio, "mime_type": mime_type}, | |
| ] | |
| ) | |
| response = await asyncio.to_thread(llm.invoke, [message]) | |
| raw_content = response.content | |
| if isinstance(raw_content, list): | |
| text_parts = [] | |
| for part in raw_content: | |
| if isinstance(part, dict) and part.get("text"): | |
| text_parts.append(part["text"]) | |
| elif isinstance(part, str): | |
| text_parts.append(part) | |
| raw_content = " ".join(text_parts).strip() | |
| text = (raw_content or "").strip() | |
| if not text: | |
| raise HTTPException( | |
| status_code=400, | |
| detail="Could not transcribe the audio. Please try again with a clearer recording.", | |
| ) | |
| return {"text": text} | |
| except HTTPException: | |
| raise | |
| except Exception as e: | |
| logger.exception("Transcribe endpoint failed") | |
| raise HTTPException(status_code=500, detail=str(e)) | |
| # Include chat manager router | |
| app.include_router(chat_manager_router) | |
| app.include_router(diagnostics_router) | |