Spaces:
Running
Running
github-actions[bot] commited on
Commit ·
bc3dd51
1
Parent(s): 48bf8be
Deploy from GitHub Actions: a7d40a05e1c8e0f277a3e01ef50ae8f13f98ee0a
Browse files- src/agents/portfolio/__init__.py +0 -0
- src/agents/portfolio/agent.py +32 -0
- src/agents/portfolio/config.py +53 -0
- src/agents/portfolio/prompt.py +98 -0
- src/agents/portfolio/tools.py +412 -0
- src/agents/routing/semantic_router.py +28 -0
- src/app.py +385 -1
- src/graphs/edges.py +2 -0
- src/graphs/factory.py +5 -0
- src/graphs/nodes.py +56 -1
- src/graphs/state.py +1 -0
src/agents/portfolio/__init__.py
ADDED
|
File without changes
|
src/agents/portfolio/agent.py
ADDED
|
@@ -0,0 +1,32 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Portfolio Advisor agent — reads wallet holdings and cross-references with
|
| 3 |
+
web search to produce risk analysis and swap recommendations.
|
| 4 |
+
"""
|
| 5 |
+
|
| 6 |
+
import logging
|
| 7 |
+
|
| 8 |
+
from langgraph.prebuilt import create_react_agent
|
| 9 |
+
|
| 10 |
+
from src.agents.portfolio.prompt import PORTFOLIO_ADVISOR_SYSTEM_PROMPT
|
| 11 |
+
from src.agents.portfolio.tools import get_tools as get_portfolio_tools
|
| 12 |
+
from src.agents.search.tools import get_tools as get_search_tools
|
| 13 |
+
|
| 14 |
+
logger = logging.getLogger(__name__)
|
| 15 |
+
|
| 16 |
+
|
| 17 |
+
class PortfolioAdvisorAgent:
|
| 18 |
+
"""Agent that analyses on-chain portfolio data and recommends actions."""
|
| 19 |
+
|
| 20 |
+
def __init__(self, llm):
|
| 21 |
+
self.llm = llm
|
| 22 |
+
|
| 23 |
+
# Combine portfolio tools + search tools so the agent can
|
| 24 |
+
# both read the wallet AND look up recent news for held tokens.
|
| 25 |
+
tools = get_portfolio_tools() + get_search_tools()
|
| 26 |
+
|
| 27 |
+
self.agent = create_react_agent(
|
| 28 |
+
model=llm,
|
| 29 |
+
tools=tools,
|
| 30 |
+
name="portfolio_advisor",
|
| 31 |
+
prompt=PORTFOLIO_ADVISOR_SYSTEM_PROMPT,
|
| 32 |
+
)
|
src/agents/portfolio/config.py
ADDED
|
@@ -0,0 +1,53 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Configuration for the Portfolio Advisor agent.
|
| 3 |
+
|
| 4 |
+
Uses free, keyless APIs:
|
| 5 |
+
• Blockscout — ETH, Polygon, Arbitrum, Base, Optimism
|
| 6 |
+
• Routescan — Avalanche
|
| 7 |
+
"""
|
| 8 |
+
|
| 9 |
+
# ---------------------------------------------------------------------------
|
| 10 |
+
# Blockscout instances (no API key required)
|
| 11 |
+
# ---------------------------------------------------------------------------
|
| 12 |
+
BLOCKSCOUT_CHAINS: dict[str, str] = {
|
| 13 |
+
"ethereum": "https://eth.blockscout.com",
|
| 14 |
+
"polygon": "https://polygon.blockscout.com",
|
| 15 |
+
"arbitrum": "https://arbitrum.blockscout.com",
|
| 16 |
+
"base": "https://base.blockscout.com",
|
| 17 |
+
"optimism": "https://optimism.blockscout.com",
|
| 18 |
+
}
|
| 19 |
+
|
| 20 |
+
# ---------------------------------------------------------------------------
|
| 21 |
+
# Routescan (no API key required) — chain name → EVM chain ID
|
| 22 |
+
# ---------------------------------------------------------------------------
|
| 23 |
+
ROUTESCAN_CHAINS: dict[str, int] = {
|
| 24 |
+
"avalanche": 43114,
|
| 25 |
+
}
|
| 26 |
+
|
| 27 |
+
# ---------------------------------------------------------------------------
|
| 28 |
+
# Native token metadata per chain
|
| 29 |
+
# ---------------------------------------------------------------------------
|
| 30 |
+
NATIVE_SYMBOL: dict[str, str] = {
|
| 31 |
+
"ethereum": "ETH",
|
| 32 |
+
"polygon": "POL",
|
| 33 |
+
"arbitrum": "ETH",
|
| 34 |
+
"base": "ETH",
|
| 35 |
+
"optimism": "ETH",
|
| 36 |
+
"avalanche": "AVAX",
|
| 37 |
+
}
|
| 38 |
+
|
| 39 |
+
NATIVE_DECIMALS: int = 18
|
| 40 |
+
|
| 41 |
+
# ---------------------------------------------------------------------------
|
| 42 |
+
# Classification sets
|
| 43 |
+
# ---------------------------------------------------------------------------
|
| 44 |
+
STABLECOIN_SYMBOLS: set[str] = {
|
| 45 |
+
"USDC", "USDT", "DAI", "TUSD", "BUSD", "FRAX", "MIM", "USDe",
|
| 46 |
+
}
|
| 47 |
+
|
| 48 |
+
BLUE_CHIP_SYMBOLS: set[str] = {
|
| 49 |
+
"ETH", "WETH", "BTC", "WBTC", "BNB", "AVAX", "WAVAX", "MATIC", "POL",
|
| 50 |
+
}
|
| 51 |
+
|
| 52 |
+
# Minimum USD value for a position to be included in the report.
|
| 53 |
+
MIN_VALUE_USD: float = 0.01
|
src/agents/portfolio/prompt.py
ADDED
|
@@ -0,0 +1,98 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
System prompt for the Portfolio Advisor agent.
|
| 3 |
+
|
| 4 |
+
Combines detailed per-token analysis, risk assessment, market context,
|
| 5 |
+
and proactive Swap upselling.
|
| 6 |
+
"""
|
| 7 |
+
|
| 8 |
+
from src.agents.markdown_instructions import MARKDOWN_INSTRUCTIONS
|
| 9 |
+
|
| 10 |
+
PORTFOLIO_ADVISOR_SYSTEM_PROMPT = f"""You are Zico's **Senior Portfolio & Risk Analyst**.
|
| 11 |
+
Your mission is to help users understand their on-chain portfolio in full detail — every single token, its value, its risk, and what action to take.
|
| 12 |
+
|
| 13 |
+
Always respond in English, regardless of the user's language.
|
| 14 |
+
|
| 15 |
+
## Workflow
|
| 16 |
+
|
| 17 |
+
1. **Fetch the portfolio** — Always call `get_user_portfolio` first to get real, live data. Never guess or fabricate holdings.
|
| 18 |
+
2. **Present EVERY token** — List ALL tokens from the tool result in a detailed table. Never omit or summarize tokens.
|
| 19 |
+
3. **Analyze each position** — For every token, assess the risk level based on its category and market position.
|
| 20 |
+
4. **Research** — For the top 3 holdings or any position that raises concern, use the search tool to look up recent news, hacks, governance issues, or market sentiment.
|
| 21 |
+
5. **Advise** — Provide concrete recommendations with specific swap actions.
|
| 22 |
+
|
| 23 |
+
## CRITICAL: Always Show the Complete Token Table
|
| 24 |
+
|
| 25 |
+
When presenting the portfolio, you MUST include a full table with ALL tokens. Use this exact format:
|
| 26 |
+
|
| 27 |
+
| Token | Chain | Balance | Value (USD) | % of Portfolio | Risk Level |
|
| 28 |
+
|-------|-------|---------|-------------|----------------|------------|
|
| 29 |
+
| ETH | ethereum | 0.0026 | $5.04 | 58.1% | 🟢 Low |
|
| 30 |
+
| STETH | ethereum | 0.001 | $2.03 | 23.4% | 🟢 Low |
|
| 31 |
+
| ... | ... | ... | ... | ... | ... |
|
| 32 |
+
|
| 33 |
+
List EVERY token returned by the tool. Do NOT skip any token, even those with tiny values. The user wants to see their complete inventory.
|
| 34 |
+
|
| 35 |
+
## Risk Level Classification
|
| 36 |
+
|
| 37 |
+
Assign risk levels to each token based on these rules:
|
| 38 |
+
|
| 39 |
+
- **🟢 Low Risk**: BTC, ETH, WETH, WBTC — established, battle-tested protocols with massive liquidity. Users can comfortably hold large positions.
|
| 40 |
+
- **🟡 Medium Risk**: stETH, MATIC/POL, BNB, AVAX, WAVAX — top-tier L1/L2 tokens or major liquid staking derivatives. Solid but more volatile than BTC/ETH.
|
| 41 |
+
- **🟠 High Risk**: Smaller-cap altcoins, meme tokens, newer DeFi tokens — these can lose 50-90% of value quickly. Position sizes should be small.
|
| 42 |
+
- **🔴 Critical Risk**: Unknown tokens, tokens with very low liquidity, tokens that appeared via airdrop without the user buying them — may be scam tokens.
|
| 43 |
+
- **⚪ Stable**: USDC, USDT, DAI — stablecoins. Low risk but capital is not growing.
|
| 44 |
+
|
| 45 |
+
## Market Perspective (Use This for Analysis)
|
| 46 |
+
|
| 47 |
+
When advising, provide market-informed perspective:
|
| 48 |
+
- **BTC/ETH** are considered the "blue chips" of crypto — higher exposure is generally acceptable for long-term holders
|
| 49 |
+
- **Layer 2 tokens** (ARB, OP, BASE) are higher risk than ETH but still quality projects
|
| 50 |
+
- **Stablecoins** are safe but idle capital — suggest yield opportunities (lending, staking) when appropriate
|
| 51 |
+
- **Altcoins/Meme coins** — warn about concentration risk, suggest keeping these under 10-20% of total portfolio
|
| 52 |
+
- Always consider: is the user over-concentrated in one asset? Under-diversified? Holding idle capital?
|
| 53 |
+
|
| 54 |
+
## Risk Identification Rules
|
| 55 |
+
|
| 56 |
+
- **Over-concentration**: Flag any single non-stablecoin asset that represents ≥ 30% of total portfolio value.
|
| 57 |
+
- **Low diversification**: Flag if the portfolio has < 3 distinct assets.
|
| 58 |
+
- **High altcoin exposure**: Flag if altcoins represent > 50% of total value.
|
| 59 |
+
- **Stablecoin dominance**: Note if stablecoins are > 70% (capital may not be working for the user).
|
| 60 |
+
- **News-driven risk**: If search reveals negative news (hacks, exploits, depegs, regulatory action) for a held asset, escalate the alert.
|
| 61 |
+
|
| 62 |
+
## Swap Upsell Guidelines
|
| 63 |
+
|
| 64 |
+
Whenever you identify a risk or improvement opportunity, suggest a **specific Swap action** the user can perform through our platform:
|
| 65 |
+
|
| 66 |
+
- Be specific: mention exact tokens, approximate amounts, and the reasoning.
|
| 67 |
+
- Frame swaps as protective or opportunistic moves, not sales pitches.
|
| 68 |
+
- Use the pattern: "I can help you **swap X% of [Token] to [Token]** right now to [reason]."
|
| 69 |
+
- Suggest diversifying into ETH, stablecoins (USDC), or other blue chips when reducing risk.
|
| 70 |
+
- If the portfolio looks healthy, acknowledge it and suggest yield opportunities (staking, DCA).
|
| 71 |
+
|
| 72 |
+
## Response Structure
|
| 73 |
+
|
| 74 |
+
Always structure your response with these sections:
|
| 75 |
+
|
| 76 |
+
### 1. Portfolio Overview
|
| 77 |
+
- Total value, total asset count, chains with holdings
|
| 78 |
+
|
| 79 |
+
### 2. Complete Holdings Table
|
| 80 |
+
- Full table with ALL tokens (see format above) — NEVER skip tokens
|
| 81 |
+
|
| 82 |
+
### 3. Allocation Breakdown
|
| 83 |
+
- Blue chips %, Stablecoins %, Altcoins %
|
| 84 |
+
- Visual summary of where the money is
|
| 85 |
+
|
| 86 |
+
### 4. Risk Assessment
|
| 87 |
+
- Per-token risk analysis with severity
|
| 88 |
+
- Overall portfolio health score
|
| 89 |
+
|
| 90 |
+
### 5. Market Context & News
|
| 91 |
+
- Relevant findings from web search for key holdings
|
| 92 |
+
- Current market sentiment for major positions
|
| 93 |
+
|
| 94 |
+
### 6. Recommendations
|
| 95 |
+
- Specific swap or rebalancing actions with exact amounts
|
| 96 |
+
- Priority-ordered: most important action first
|
| 97 |
+
|
| 98 |
+
{MARKDOWN_INSTRUCTIONS}"""
|
src/agents/portfolio/tools.py
ADDED
|
@@ -0,0 +1,412 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Portfolio tools — fetches multi-chain wallet balances via free, keyless APIs.
|
| 3 |
+
|
| 4 |
+
Data sources:
|
| 5 |
+
• Blockscout API v2 — ETH, Polygon, Arbitrum, Base, Optimism
|
| 6 |
+
• Routescan API v2 — Avalanche
|
| 7 |
+
|
| 8 |
+
Both APIs return token balances with USD pricing included, so no separate
|
| 9 |
+
price-feed is needed.
|
| 10 |
+
|
| 11 |
+
Session management follows the same ContextVar pattern used by the swap,
|
| 12 |
+
lending, and staking agents so the wallet_address is injected by the graph
|
| 13 |
+
node rather than asked from the user.
|
| 14 |
+
"""
|
| 15 |
+
|
| 16 |
+
from __future__ import annotations
|
| 17 |
+
|
| 18 |
+
import json
|
| 19 |
+
import logging
|
| 20 |
+
import time
|
| 21 |
+
from concurrent.futures import ThreadPoolExecutor, as_completed
|
| 22 |
+
from contextlib import contextmanager
|
| 23 |
+
from contextvars import ContextVar
|
| 24 |
+
from threading import Lock
|
| 25 |
+
from typing import Any, Dict, List, Optional
|
| 26 |
+
|
| 27 |
+
import requests
|
| 28 |
+
from langchain_core.tools import tool
|
| 29 |
+
from pydantic import BaseModel
|
| 30 |
+
|
| 31 |
+
from src.agents.portfolio.config import (
|
| 32 |
+
BLOCKSCOUT_CHAINS,
|
| 33 |
+
BLUE_CHIP_SYMBOLS,
|
| 34 |
+
MIN_VALUE_USD,
|
| 35 |
+
NATIVE_DECIMALS,
|
| 36 |
+
NATIVE_SYMBOL,
|
| 37 |
+
ROUTESCAN_CHAINS,
|
| 38 |
+
STABLECOIN_SYMBOLS,
|
| 39 |
+
)
|
| 40 |
+
|
| 41 |
+
logger = logging.getLogger(__name__)
|
| 42 |
+
|
| 43 |
+
_HTTP_TIMEOUT = 15 # seconds per request
|
| 44 |
+
|
| 45 |
+
# ---------------------------------------------------------------------------
|
| 46 |
+
# Session context (user_id, conversation_id, wallet_address)
|
| 47 |
+
# ---------------------------------------------------------------------------
|
| 48 |
+
|
| 49 |
+
_CURRENT_SESSION: ContextVar[tuple[str, str, str]] = ContextVar(
|
| 50 |
+
"_current_portfolio_session",
|
| 51 |
+
default=("", "", ""),
|
| 52 |
+
)
|
| 53 |
+
|
| 54 |
+
|
| 55 |
+
def set_current_portfolio_session(
|
| 56 |
+
user_id: Optional[str],
|
| 57 |
+
conversation_id: Optional[str],
|
| 58 |
+
wallet_address: Optional[str],
|
| 59 |
+
) -> None:
|
| 60 |
+
_CURRENT_SESSION.set((
|
| 61 |
+
(user_id or "").strip(),
|
| 62 |
+
(conversation_id or "").strip(),
|
| 63 |
+
(wallet_address or "").strip(),
|
| 64 |
+
))
|
| 65 |
+
|
| 66 |
+
|
| 67 |
+
def clear_current_portfolio_session() -> None:
|
| 68 |
+
_CURRENT_SESSION.set(("", "", ""))
|
| 69 |
+
|
| 70 |
+
|
| 71 |
+
@contextmanager
|
| 72 |
+
def portfolio_session(
|
| 73 |
+
user_id: Optional[str],
|
| 74 |
+
conversation_id: Optional[str],
|
| 75 |
+
wallet_address: Optional[str],
|
| 76 |
+
):
|
| 77 |
+
"""Context manager that guarantees session scoping for portfolio tool calls."""
|
| 78 |
+
set_current_portfolio_session(user_id, conversation_id, wallet_address)
|
| 79 |
+
try:
|
| 80 |
+
yield
|
| 81 |
+
finally:
|
| 82 |
+
clear_current_portfolio_session()
|
| 83 |
+
|
| 84 |
+
|
| 85 |
+
# ---------------------------------------------------------------------------
|
| 86 |
+
# Simple TTL cache (avoids hammering explorers on follow-up questions)
|
| 87 |
+
# ---------------------------------------------------------------------------
|
| 88 |
+
|
| 89 |
+
_PORTFOLIO_CACHE: Dict[str, tuple[Any, float]] = {}
|
| 90 |
+
_CACHE_TTL = 60 # seconds
|
| 91 |
+
_cache_lock = Lock()
|
| 92 |
+
|
| 93 |
+
|
| 94 |
+
def _get_cached(key: str) -> Optional[Any]:
|
| 95 |
+
with _cache_lock:
|
| 96 |
+
entry = _PORTFOLIO_CACHE.get(key)
|
| 97 |
+
if entry and (time.time() - entry[1]) < _CACHE_TTL:
|
| 98 |
+
return entry[0]
|
| 99 |
+
return None
|
| 100 |
+
|
| 101 |
+
|
| 102 |
+
def _set_cached(key: str, value: Any) -> None:
|
| 103 |
+
with _cache_lock:
|
| 104 |
+
_PORTFOLIO_CACHE[key] = (value, time.time())
|
| 105 |
+
|
| 106 |
+
|
| 107 |
+
# ---------------------------------------------------------------------------
|
| 108 |
+
# Blockscout helpers
|
| 109 |
+
# ---------------------------------------------------------------------------
|
| 110 |
+
|
| 111 |
+
def _blockscout_fetch_address(base_url: str, address: str) -> Dict[str, Any]:
|
| 112 |
+
"""GET /api/v2/addresses/{addr} — native balance + coin exchange rate."""
|
| 113 |
+
try:
|
| 114 |
+
resp = requests.get(
|
| 115 |
+
f"{base_url}/api/v2/addresses/{address}",
|
| 116 |
+
timeout=_HTTP_TIMEOUT,
|
| 117 |
+
)
|
| 118 |
+
resp.raise_for_status()
|
| 119 |
+
return resp.json()
|
| 120 |
+
except Exception as exc:
|
| 121 |
+
logger.warning("Blockscout address fetch failed (%s): %s", base_url, exc)
|
| 122 |
+
return {}
|
| 123 |
+
|
| 124 |
+
|
| 125 |
+
def _blockscout_fetch_tokens(base_url: str, address: str) -> List[Dict[str, Any]]:
|
| 126 |
+
"""GET /api/v2/addresses/{addr}/token-balances — ERC-20 holdings with price."""
|
| 127 |
+
try:
|
| 128 |
+
resp = requests.get(
|
| 129 |
+
f"{base_url}/api/v2/addresses/{address}/token-balances",
|
| 130 |
+
timeout=_HTTP_TIMEOUT,
|
| 131 |
+
)
|
| 132 |
+
resp.raise_for_status()
|
| 133 |
+
return resp.json() # list of token objects
|
| 134 |
+
except Exception as exc:
|
| 135 |
+
logger.warning("Blockscout token-balances fetch failed (%s): %s", base_url, exc)
|
| 136 |
+
return []
|
| 137 |
+
|
| 138 |
+
|
| 139 |
+
def _process_blockscout_chain(
|
| 140 |
+
chain_name: str,
|
| 141 |
+
base_url: str,
|
| 142 |
+
address: str,
|
| 143 |
+
) -> List[Dict[str, Any]]:
|
| 144 |
+
"""Fetch and normalise all holdings on a single Blockscout-indexed chain."""
|
| 145 |
+
assets: List[Dict[str, Any]] = []
|
| 146 |
+
|
| 147 |
+
# ── Native token ──
|
| 148 |
+
addr_data = _blockscout_fetch_address(base_url, address)
|
| 149 |
+
if addr_data:
|
| 150 |
+
try:
|
| 151 |
+
raw_balance = int(addr_data.get("coin_balance") or "0")
|
| 152 |
+
balance = raw_balance / (10 ** NATIVE_DECIMALS)
|
| 153 |
+
exchange_rate = float(addr_data.get("exchange_rate") or 0)
|
| 154 |
+
value_usd = balance * exchange_rate
|
| 155 |
+
symbol = NATIVE_SYMBOL.get(chain_name, "ETH")
|
| 156 |
+
if value_usd >= MIN_VALUE_USD or balance >= 0.000001:
|
| 157 |
+
assets.append({
|
| 158 |
+
"symbol": symbol,
|
| 159 |
+
"name": symbol,
|
| 160 |
+
"balance": round(balance, 6),
|
| 161 |
+
"value_usd": round(value_usd, 2),
|
| 162 |
+
"chain": chain_name,
|
| 163 |
+
"contract_address": "native",
|
| 164 |
+
"category": _classify(symbol),
|
| 165 |
+
})
|
| 166 |
+
except (ValueError, TypeError):
|
| 167 |
+
pass
|
| 168 |
+
|
| 169 |
+
# ── ERC-20 tokens ──
|
| 170 |
+
tokens = _blockscout_fetch_tokens(base_url, address)
|
| 171 |
+
for tok in tokens:
|
| 172 |
+
try:
|
| 173 |
+
token_info = tok.get("token", {})
|
| 174 |
+
decimals = int(token_info.get("decimals") or 18)
|
| 175 |
+
raw_value = int(tok.get("value") or "0")
|
| 176 |
+
balance = raw_value / (10 ** decimals)
|
| 177 |
+
if balance <= 0:
|
| 178 |
+
continue
|
| 179 |
+
|
| 180 |
+
symbol = (token_info.get("symbol") or "???").upper()
|
| 181 |
+
exchange_rate = float(token_info.get("exchange_rate") or 0)
|
| 182 |
+
value_usd = balance * exchange_rate
|
| 183 |
+
|
| 184 |
+
if value_usd < MIN_VALUE_USD and balance < 0.000001:
|
| 185 |
+
continue
|
| 186 |
+
|
| 187 |
+
assets.append({
|
| 188 |
+
"symbol": symbol,
|
| 189 |
+
"name": token_info.get("name", symbol),
|
| 190 |
+
"balance": round(balance, 6),
|
| 191 |
+
"value_usd": round(value_usd, 2),
|
| 192 |
+
"chain": chain_name,
|
| 193 |
+
"contract_address": token_info.get("address_hash", ""),
|
| 194 |
+
"category": _classify(symbol),
|
| 195 |
+
})
|
| 196 |
+
except (ValueError, TypeError):
|
| 197 |
+
continue
|
| 198 |
+
|
| 199 |
+
return assets
|
| 200 |
+
|
| 201 |
+
|
| 202 |
+
# ---------------------------------------------------------------------------
|
| 203 |
+
# Routescan helpers
|
| 204 |
+
# ---------------------------------------------------------------------------
|
| 205 |
+
|
| 206 |
+
_ROUTESCAN_BASE = "https://api.routescan.io/v2/network/mainnet/evm"
|
| 207 |
+
|
| 208 |
+
|
| 209 |
+
def _routescan_fetch_tokens(chain_id: int, address: str) -> List[Dict[str, Any]]:
|
| 210 |
+
"""GET /v2/network/mainnet/evm/{chainId}/address/{addr}/erc20-holdings."""
|
| 211 |
+
try:
|
| 212 |
+
resp = requests.get(
|
| 213 |
+
f"{_ROUTESCAN_BASE}/{chain_id}/address/{address}/erc20-holdings",
|
| 214 |
+
timeout=_HTTP_TIMEOUT,
|
| 215 |
+
)
|
| 216 |
+
resp.raise_for_status()
|
| 217 |
+
data = resp.json()
|
| 218 |
+
return data.get("items", [])
|
| 219 |
+
except Exception as exc:
|
| 220 |
+
logger.warning("Routescan fetch failed (chain %s): %s", chain_id, exc)
|
| 221 |
+
return []
|
| 222 |
+
|
| 223 |
+
|
| 224 |
+
def _routescan_fetch_native(chain_id: int, address: str) -> Dict[str, Any]:
|
| 225 |
+
"""GET /v2/network/mainnet/evm/{chainId}/address/{addr}."""
|
| 226 |
+
try:
|
| 227 |
+
resp = requests.get(
|
| 228 |
+
f"{_ROUTESCAN_BASE}/{chain_id}/address/{address}",
|
| 229 |
+
timeout=_HTTP_TIMEOUT,
|
| 230 |
+
)
|
| 231 |
+
resp.raise_for_status()
|
| 232 |
+
return resp.json()
|
| 233 |
+
except Exception as exc:
|
| 234 |
+
logger.warning("Routescan native fetch failed (chain %s): %s", chain_id, exc)
|
| 235 |
+
return {}
|
| 236 |
+
|
| 237 |
+
|
| 238 |
+
def _process_routescan_chain(
|
| 239 |
+
chain_name: str,
|
| 240 |
+
chain_id: int,
|
| 241 |
+
address: str,
|
| 242 |
+
) -> List[Dict[str, Any]]:
|
| 243 |
+
"""Fetch and normalise all holdings on a single Routescan-indexed chain."""
|
| 244 |
+
assets: List[Dict[str, Any]] = []
|
| 245 |
+
|
| 246 |
+
# ── Native token ──
|
| 247 |
+
native_data = _routescan_fetch_native(chain_id, address)
|
| 248 |
+
if native_data:
|
| 249 |
+
try:
|
| 250 |
+
raw_balance = int(native_data.get("balance") or "0")
|
| 251 |
+
balance = raw_balance / (10 ** NATIVE_DECIMALS)
|
| 252 |
+
# Routescan native endpoint may not include price;
|
| 253 |
+
# use CoinGecko-style fallback only for AVAX
|
| 254 |
+
native_price = float(native_data.get("tokenPriceUsd") or 0)
|
| 255 |
+
value_usd = balance * native_price
|
| 256 |
+
symbol = NATIVE_SYMBOL.get(chain_name, "AVAX")
|
| 257 |
+
if value_usd >= MIN_VALUE_USD or balance >= 0.000001:
|
| 258 |
+
assets.append({
|
| 259 |
+
"symbol": symbol,
|
| 260 |
+
"name": symbol,
|
| 261 |
+
"balance": round(balance, 6),
|
| 262 |
+
"value_usd": round(value_usd, 2),
|
| 263 |
+
"chain": chain_name,
|
| 264 |
+
"contract_address": "native",
|
| 265 |
+
"category": _classify(symbol),
|
| 266 |
+
})
|
| 267 |
+
except (ValueError, TypeError):
|
| 268 |
+
pass
|
| 269 |
+
|
| 270 |
+
# ── ERC-20 tokens ──
|
| 271 |
+
tokens = _routescan_fetch_tokens(chain_id, address)
|
| 272 |
+
for tok in tokens:
|
| 273 |
+
try:
|
| 274 |
+
decimals = int(tok.get("tokenDecimals") or 18)
|
| 275 |
+
raw_qty = int(tok.get("tokenQuantity") or "0")
|
| 276 |
+
balance = raw_qty / (10 ** decimals)
|
| 277 |
+
if balance <= 0:
|
| 278 |
+
continue
|
| 279 |
+
|
| 280 |
+
symbol = (tok.get("tokenSymbol") or "???").upper()
|
| 281 |
+
value_usd = float(tok.get("tokenValueInUsd") or 0)
|
| 282 |
+
|
| 283 |
+
if value_usd < MIN_VALUE_USD and balance < 0.000001:
|
| 284 |
+
continue
|
| 285 |
+
|
| 286 |
+
assets.append({
|
| 287 |
+
"symbol": symbol,
|
| 288 |
+
"name": tok.get("tokenName", symbol),
|
| 289 |
+
"balance": round(balance, 6),
|
| 290 |
+
"value_usd": round(value_usd, 2),
|
| 291 |
+
"chain": chain_name,
|
| 292 |
+
"contract_address": tok.get("tokenAddress", ""),
|
| 293 |
+
"category": _classify(symbol),
|
| 294 |
+
})
|
| 295 |
+
except (ValueError, TypeError):
|
| 296 |
+
continue
|
| 297 |
+
|
| 298 |
+
return assets
|
| 299 |
+
|
| 300 |
+
|
| 301 |
+
# ---------------------------------------------------------------------------
|
| 302 |
+
# Classification
|
| 303 |
+
# ---------------------------------------------------------------------------
|
| 304 |
+
|
| 305 |
+
def _classify(symbol: str) -> str:
|
| 306 |
+
upper = symbol.upper()
|
| 307 |
+
if upper in STABLECOIN_SYMBOLS:
|
| 308 |
+
return "stablecoin"
|
| 309 |
+
if upper in BLUE_CHIP_SYMBOLS:
|
| 310 |
+
return "blue_chip"
|
| 311 |
+
return "altcoin"
|
| 312 |
+
|
| 313 |
+
|
| 314 |
+
# ---------------------------------------------------------------------------
|
| 315 |
+
# The Tool
|
| 316 |
+
# ---------------------------------------------------------------------------
|
| 317 |
+
|
| 318 |
+
class _GetPortfolioArgs(BaseModel):
|
| 319 |
+
"""No user-facing args — wallet_address comes from the session context."""
|
| 320 |
+
pass
|
| 321 |
+
|
| 322 |
+
|
| 323 |
+
@tool("get_user_portfolio", args_schema=_GetPortfolioArgs)
|
| 324 |
+
def get_user_portfolio_tool() -> str:
|
| 325 |
+
"""Fetch the connected user's multi-chain token holdings with USD values.
|
| 326 |
+
|
| 327 |
+
Returns a JSON object with total_value_usd, top_holdings, allocation
|
| 328 |
+
breakdown (stablecoins %, blue_chips %, altcoins %), and the full
|
| 329 |
+
asset list. Use this data to analyze portfolio risk and concentration.
|
| 330 |
+
"""
|
| 331 |
+
_, _, wallet_address = _CURRENT_SESSION.get()
|
| 332 |
+
|
| 333 |
+
if not wallet_address:
|
| 334 |
+
return json.dumps({"error": "No wallet address available. Ask the user to connect their wallet."})
|
| 335 |
+
|
| 336 |
+
# Check cache
|
| 337 |
+
cache_key = f"portfolio:{wallet_address.lower()}"
|
| 338 |
+
cached = _get_cached(cache_key)
|
| 339 |
+
if cached:
|
| 340 |
+
return cached
|
| 341 |
+
|
| 342 |
+
# ── Fetch all chains in parallel ──
|
| 343 |
+
all_assets: List[Dict[str, Any]] = []
|
| 344 |
+
|
| 345 |
+
with ThreadPoolExecutor(max_workers=6) as pool:
|
| 346 |
+
futures = {}
|
| 347 |
+
|
| 348 |
+
# Blockscout chains
|
| 349 |
+
for chain_name, base_url in BLOCKSCOUT_CHAINS.items():
|
| 350 |
+
fut = pool.submit(_process_blockscout_chain, chain_name, base_url, wallet_address)
|
| 351 |
+
futures[fut] = chain_name
|
| 352 |
+
|
| 353 |
+
# Routescan chains
|
| 354 |
+
for chain_name, chain_id in ROUTESCAN_CHAINS.items():
|
| 355 |
+
fut = pool.submit(_process_routescan_chain, chain_name, chain_id, wallet_address)
|
| 356 |
+
futures[fut] = chain_name
|
| 357 |
+
|
| 358 |
+
for fut in as_completed(futures):
|
| 359 |
+
chain = futures[fut]
|
| 360 |
+
try:
|
| 361 |
+
all_assets.extend(fut.result())
|
| 362 |
+
except Exception as exc:
|
| 363 |
+
logger.warning("Failed to fetch %s: %s", chain, exc)
|
| 364 |
+
|
| 365 |
+
if not all_assets:
|
| 366 |
+
return json.dumps({
|
| 367 |
+
"wallet_address": wallet_address,
|
| 368 |
+
"total_value_usd": 0,
|
| 369 |
+
"asset_count": 0,
|
| 370 |
+
"chains_checked": list(BLOCKSCOUT_CHAINS.keys()) + list(ROUTESCAN_CHAINS.keys()),
|
| 371 |
+
"top_holdings": [],
|
| 372 |
+
"allocation": {"stablecoins_pct": 0, "blue_chips_pct": 0, "altcoins_pct": 0},
|
| 373 |
+
"all_assets": [],
|
| 374 |
+
"note": "No token balances found. The wallet may be empty or the APIs may be temporarily unavailable.",
|
| 375 |
+
})
|
| 376 |
+
|
| 377 |
+
# ── Aggregate ──
|
| 378 |
+
total_value = sum(a["value_usd"] for a in all_assets)
|
| 379 |
+
for a in all_assets:
|
| 380 |
+
a["percentage"] = round((a["value_usd"] / total_value * 100) if total_value > 0 else 0, 2)
|
| 381 |
+
|
| 382 |
+
all_assets.sort(key=lambda a: a["value_usd"], reverse=True)
|
| 383 |
+
|
| 384 |
+
stablecoins_usd = sum(a["value_usd"] for a in all_assets if a["category"] == "stablecoin")
|
| 385 |
+
blue_chips_usd = sum(a["value_usd"] for a in all_assets if a["category"] == "blue_chip")
|
| 386 |
+
altcoins_usd = sum(a["value_usd"] for a in all_assets if a["category"] == "altcoin")
|
| 387 |
+
|
| 388 |
+
result = json.dumps({
|
| 389 |
+
"wallet_address": wallet_address,
|
| 390 |
+
"total_value_usd": round(total_value, 2),
|
| 391 |
+
"asset_count": len(all_assets),
|
| 392 |
+
"chains_checked": list(BLOCKSCOUT_CHAINS.keys()) + list(ROUTESCAN_CHAINS.keys()),
|
| 393 |
+
"top_holdings": all_assets[:5],
|
| 394 |
+
"allocation": {
|
| 395 |
+
"stablecoins_pct": round((stablecoins_usd / total_value * 100) if total_value > 0 else 0, 1),
|
| 396 |
+
"blue_chips_pct": round((blue_chips_usd / total_value * 100) if total_value > 0 else 0, 1),
|
| 397 |
+
"altcoins_pct": round((altcoins_usd / total_value * 100) if total_value > 0 else 0, 1),
|
| 398 |
+
},
|
| 399 |
+
"all_assets": all_assets,
|
| 400 |
+
})
|
| 401 |
+
|
| 402 |
+
_set_cached(cache_key, result)
|
| 403 |
+
return result
|
| 404 |
+
|
| 405 |
+
|
| 406 |
+
# ---------------------------------------------------------------------------
|
| 407 |
+
# Public API
|
| 408 |
+
# ---------------------------------------------------------------------------
|
| 409 |
+
|
| 410 |
+
def get_tools() -> list:
|
| 411 |
+
"""Return the toolset for the portfolio advisor agent."""
|
| 412 |
+
return [get_user_portfolio_tool]
|
src/agents/routing/semantic_router.py
CHANGED
|
@@ -27,6 +27,7 @@ class IntentCategory(str, Enum):
|
|
| 27 |
STAKING = "staking"
|
| 28 |
DCA = "dca"
|
| 29 |
MARKET_DATA = "market_data"
|
|
|
|
| 30 |
EDUCATION = "education"
|
| 31 |
SEARCH = "search"
|
| 32 |
GENERAL = "general"
|
|
@@ -39,6 +40,7 @@ _INTENT_AGENT_MAP: Dict[IntentCategory, str] = {
|
|
| 39 |
IntentCategory.STAKING: "staking_agent",
|
| 40 |
IntentCategory.DCA: "dca_agent",
|
| 41 |
IntentCategory.MARKET_DATA: "crypto_agent",
|
|
|
|
| 42 |
IntentCategory.EDUCATION: "default_agent",
|
| 43 |
IntentCategory.SEARCH: "search_agent",
|
| 44 |
IntentCategory.GENERAL: "default_agent",
|
|
@@ -114,6 +116,32 @@ INTENT_EXEMPLARS: Dict[IntentCategory, List[str]] = {
|
|
| 114 |
"Bitcoin market cap",
|
| 115 |
"What is the TVL of Avalanche?",
|
| 116 |
],
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 117 |
IntentCategory.EDUCATION: [
|
| 118 |
"What is DeFi?",
|
| 119 |
"How does liquid staking work?",
|
|
|
|
| 27 |
STAKING = "staking"
|
| 28 |
DCA = "dca"
|
| 29 |
MARKET_DATA = "market_data"
|
| 30 |
+
PORTFOLIO = "portfolio"
|
| 31 |
EDUCATION = "education"
|
| 32 |
SEARCH = "search"
|
| 33 |
GENERAL = "general"
|
|
|
|
| 40 |
IntentCategory.STAKING: "staking_agent",
|
| 41 |
IntentCategory.DCA: "dca_agent",
|
| 42 |
IntentCategory.MARKET_DATA: "crypto_agent",
|
| 43 |
+
IntentCategory.PORTFOLIO: "portfolio_advisor",
|
| 44 |
IntentCategory.EDUCATION: "default_agent",
|
| 45 |
IntentCategory.SEARCH: "search_agent",
|
| 46 |
IntentCategory.GENERAL: "default_agent",
|
|
|
|
| 116 |
"Bitcoin market cap",
|
| 117 |
"What is the TVL of Avalanche?",
|
| 118 |
],
|
| 119 |
+
IntentCategory.PORTFOLIO: [
|
| 120 |
+
"Analyze my portfolio",
|
| 121 |
+
"What's my biggest risk?",
|
| 122 |
+
"How should I rebalance my wallet?",
|
| 123 |
+
"Show me my token holdings",
|
| 124 |
+
"What tokens do I have?",
|
| 125 |
+
"Am I too exposed to any single token?",
|
| 126 |
+
"Give me a risk assessment of my wallet",
|
| 127 |
+
"How diversified is my portfolio?",
|
| 128 |
+
"What should I do with my tokens?",
|
| 129 |
+
"What is the amount of each token?",
|
| 130 |
+
"How much of each token do I hold?",
|
| 131 |
+
"Show me the value of each position",
|
| 132 |
+
"List all my tokens with balances",
|
| 133 |
+
"Where am I most exposed?",
|
| 134 |
+
"Which tokens should I sell?",
|
| 135 |
+
"Where should I take more risk?",
|
| 136 |
+
"Where should I be more careful?",
|
| 137 |
+
"Is my wallet safe?",
|
| 138 |
+
"Analise minha carteira",
|
| 139 |
+
"Qual meu maior risco?",
|
| 140 |
+
"Como balancear minha carteira?",
|
| 141 |
+
"Quais tokens eu tenho?",
|
| 142 |
+
"Quanto tenho de cada token?",
|
| 143 |
+
"Onde posso arriscar mais?",
|
| 144 |
+
],
|
| 145 |
IntentCategory.EDUCATION: [
|
| 146 |
"What is DeFi?",
|
| 147 |
"How does liquid staking work?",
|
src/app.py
CHANGED
|
@@ -2,10 +2,12 @@ import asyncio
|
|
| 2 |
import base64
|
| 3 |
import json
|
| 4 |
import os
|
|
|
|
| 5 |
from typing import Any, Dict, List, Optional
|
| 6 |
|
| 7 |
from fastapi import FastAPI, File, Form, HTTPException, Request, UploadFile
|
| 8 |
from fastapi.middleware.cors import CORSMiddleware
|
|
|
|
| 9 |
from langchain_core.messages import HumanMessage
|
| 10 |
from pydantic import BaseModel
|
| 11 |
|
|
@@ -135,6 +137,7 @@ def _map_agent_type(agent_name: str) -> str:
|
|
| 135 |
"swap_agent": "token swap",
|
| 136 |
"lending_agent": "lending",
|
| 137 |
"staking_agent": "staking",
|
|
|
|
| 138 |
"supervisor": "supervisor",
|
| 139 |
}
|
| 140 |
return mapping.get(agent_name, "supervisor")
|
|
@@ -177,6 +180,7 @@ def _invoke_graph(
|
|
| 177 |
user_id,
|
| 178 |
conversation_id,
|
| 179 |
*,
|
|
|
|
| 180 |
pre_classified: Dict[str, Any] | None = None,
|
| 181 |
):
|
| 182 |
"""Invoke the StateGraph and return the result state.
|
|
@@ -189,6 +193,7 @@ def _invoke_graph(
|
|
| 189 |
"messages": conversation_messages,
|
| 190 |
"user_id": user_id,
|
| 191 |
"conversation_id": conversation_id,
|
|
|
|
| 192 |
}
|
| 193 |
if pre_classified:
|
| 194 |
initial_state.update(pre_classified)
|
|
@@ -408,7 +413,7 @@ def chat(request: ChatRequest):
|
|
| 408 |
cost_snapshot = cost_tracker.get_snapshot()
|
| 409 |
|
| 410 |
# Invoke the StateGraph
|
| 411 |
-
result = _invoke_graph(conversation_messages, user_id, conversation_id)
|
| 412 |
|
| 413 |
# Calculate and save cost delta
|
| 414 |
cost_delta = cost_tracker.calculate_delta(cost_snapshot)
|
|
@@ -441,6 +446,318 @@ def chat(request: ChatRequest):
|
|
| 441 |
raise HTTPException(status_code=500, detail=str(e))
|
| 442 |
|
| 443 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 444 |
# Supported audio MIME types
|
| 445 |
AUDIO_MIME_TYPES = {
|
| 446 |
".mp3": "audio/mpeg",
|
|
@@ -481,6 +798,11 @@ Return ONLY a JSON object (no markdown fences) with these fields:
|
|
| 481 |
{"transcription": "<exact transcription>", "intent": "<category>", "confidence": <0.0-1.0>}
|
| 482 |
"""
|
| 483 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 484 |
# Maps audio classification intents to agent runtime names
|
| 485 |
_AUDIO_INTENT_AGENT_MAP: Dict[str, str] = {
|
| 486 |
"swap": "swap_agent",
|
|
@@ -488,6 +810,7 @@ _AUDIO_INTENT_AGENT_MAP: Dict[str, str] = {
|
|
| 488 |
"staking": "staking_agent",
|
| 489 |
"dca": "dca_agent",
|
| 490 |
"market_data": "crypto_agent",
|
|
|
|
| 491 |
"search": "search_agent",
|
| 492 |
"education": "default_agent",
|
| 493 |
"general": "default_agent",
|
|
@@ -697,6 +1020,7 @@ async def chat_audio(
|
|
| 697 |
conversation_messages,
|
| 698 |
request_user_id,
|
| 699 |
request_conversation_id,
|
|
|
|
| 700 |
pre_classified=pre_classified,
|
| 701 |
)
|
| 702 |
|
|
@@ -742,5 +1066,65 @@ async def chat_audio(
|
|
| 742 |
raise HTTPException(status_code=500, detail=str(e))
|
| 743 |
|
| 744 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 745 |
# Include chat manager router
|
| 746 |
app.include_router(chat_manager_router)
|
|
|
|
| 2 |
import base64
|
| 3 |
import json
|
| 4 |
import os
|
| 5 |
+
import time
|
| 6 |
from typing import Any, Dict, List, Optional
|
| 7 |
|
| 8 |
from fastapi import FastAPI, File, Form, HTTPException, Request, UploadFile
|
| 9 |
from fastapi.middleware.cors import CORSMiddleware
|
| 10 |
+
from fastapi.responses import StreamingResponse
|
| 11 |
from langchain_core.messages import HumanMessage
|
| 12 |
from pydantic import BaseModel
|
| 13 |
|
|
|
|
| 137 |
"swap_agent": "token swap",
|
| 138 |
"lending_agent": "lending",
|
| 139 |
"staking_agent": "staking",
|
| 140 |
+
"portfolio_advisor": "portfolio analysis",
|
| 141 |
"supervisor": "supervisor",
|
| 142 |
}
|
| 143 |
return mapping.get(agent_name, "supervisor")
|
|
|
|
| 180 |
user_id,
|
| 181 |
conversation_id,
|
| 182 |
*,
|
| 183 |
+
wallet_address: str | None = None,
|
| 184 |
pre_classified: Dict[str, Any] | None = None,
|
| 185 |
):
|
| 186 |
"""Invoke the StateGraph and return the result state.
|
|
|
|
| 193 |
"messages": conversation_messages,
|
| 194 |
"user_id": user_id,
|
| 195 |
"conversation_id": conversation_id,
|
| 196 |
+
"wallet_address": wallet_address,
|
| 197 |
}
|
| 198 |
if pre_classified:
|
| 199 |
initial_state.update(pre_classified)
|
|
|
|
| 413 |
cost_snapshot = cost_tracker.get_snapshot()
|
| 414 |
|
| 415 |
# Invoke the StateGraph
|
| 416 |
+
result = _invoke_graph(conversation_messages, user_id, conversation_id, wallet_address=wallet)
|
| 417 |
|
| 418 |
# Calculate and save cost delta
|
| 419 |
cost_delta = cost_tracker.calculate_delta(cost_snapshot)
|
|
|
|
| 446 |
raise HTTPException(status_code=500, detail=str(e))
|
| 447 |
|
| 448 |
|
| 449 |
+
# ---------------------------------------------------------------------------
|
| 450 |
+
# SSE Streaming endpoint
|
| 451 |
+
# ---------------------------------------------------------------------------
|
| 452 |
+
|
| 453 |
+
# Human-readable labels for each graph node
|
| 454 |
+
_NODE_LABELS: Dict[str, str] = {
|
| 455 |
+
"entry_node": "Preparing context...",
|
| 456 |
+
"semantic_router_node": "Routing your request...",
|
| 457 |
+
"llm_router_node": "Analyzing intent...",
|
| 458 |
+
"swap_agent_node": "Consulting swap protocols...",
|
| 459 |
+
"lending_agent_node": "Checking lending markets...",
|
| 460 |
+
"staking_agent_node": "Reviewing staking options...",
|
| 461 |
+
"dca_agent_node": "Planning DCA strategy...",
|
| 462 |
+
"crypto_agent_node": "Fetching market data...",
|
| 463 |
+
"search_agent_node": "Searching the web...",
|
| 464 |
+
"default_agent_node": "Thinking...",
|
| 465 |
+
"database_agent_node": "Querying portfolio...",
|
| 466 |
+
"portfolio_advisor_node": "Analyzing your portfolio...",
|
| 467 |
+
"formatter_node": "Formatting response...",
|
| 468 |
+
"error_node": "Validating parameters...",
|
| 469 |
+
}
|
| 470 |
+
|
| 471 |
+
|
| 472 |
+
def _sse(event_type: str, data: dict) -> str:
|
| 473 |
+
"""Format a Server-Sent Event string."""
|
| 474 |
+
return f"event: {event_type}\ndata: {json.dumps(data, ensure_ascii=False)}\n\n"
|
| 475 |
+
|
| 476 |
+
|
| 477 |
+
async def _persist_response_bg(
|
| 478 |
+
full_response: str,
|
| 479 |
+
response_agent: str,
|
| 480 |
+
response_metadata: dict,
|
| 481 |
+
user_id: str,
|
| 482 |
+
conversation_id: str,
|
| 483 |
+
cost_delta: dict,
|
| 484 |
+
) -> None:
|
| 485 |
+
"""Background task: persist assistant message and update costs."""
|
| 486 |
+
try:
|
| 487 |
+
agent_name = _map_agent_type(response_agent)
|
| 488 |
+
response_message = ChatMessage(
|
| 489 |
+
role="assistant",
|
| 490 |
+
content=full_response,
|
| 491 |
+
agent_name=agent_name,
|
| 492 |
+
agent_type=_map_agent_type(agent_name),
|
| 493 |
+
metadata=response_metadata,
|
| 494 |
+
conversation_id=conversation_id,
|
| 495 |
+
user_id=user_id,
|
| 496 |
+
requires_action=(
|
| 497 |
+
True if agent_name in ("token swap", "lending", "staking") else False
|
| 498 |
+
),
|
| 499 |
+
action_type=(
|
| 500 |
+
"swap"
|
| 501 |
+
if agent_name == "token swap"
|
| 502 |
+
else "lending"
|
| 503 |
+
if agent_name == "lending"
|
| 504 |
+
else "staking"
|
| 505 |
+
if agent_name == "staking"
|
| 506 |
+
else None
|
| 507 |
+
),
|
| 508 |
+
)
|
| 509 |
+
await asyncio.to_thread(
|
| 510 |
+
chat_manager_instance.add_message,
|
| 511 |
+
response_message.dict(),
|
| 512 |
+
conversation_id,
|
| 513 |
+
user_id,
|
| 514 |
+
)
|
| 515 |
+
|
| 516 |
+
if cost_delta.get("cost", 0) > 0 or cost_delta.get("calls", 0) > 0:
|
| 517 |
+
await asyncio.to_thread(
|
| 518 |
+
chat_manager_instance.update_conversation_costs,
|
| 519 |
+
cost_delta,
|
| 520 |
+
conversation_id,
|
| 521 |
+
user_id,
|
| 522 |
+
)
|
| 523 |
+
|
| 524 |
+
# Clear DeFi metadata when intent is ready
|
| 525 |
+
_clear_ready_metadata(agent_name, response_metadata, user_id, conversation_id)
|
| 526 |
+
except Exception:
|
| 527 |
+
logger.exception("Failed to persist streamed response")
|
| 528 |
+
|
| 529 |
+
|
| 530 |
+
@app.post("/chat/stream")
|
| 531 |
+
async def chat_stream(request: ChatRequest):
|
| 532 |
+
"""SSE streaming endpoint — streams thought process + tokens in real-time.
|
| 533 |
+
|
| 534 |
+
Event types:
|
| 535 |
+
- ``status`` : node lifecycle (step label, routing info)
|
| 536 |
+
- ``token`` : incremental text chunks from the final LLM response
|
| 537 |
+
- ``tool_io`` : tool invocation results (truncated for the wire)
|
| 538 |
+
- ``done`` : final metadata envelope (agent, nodes, costs)
|
| 539 |
+
- ``error`` : unrecoverable error
|
| 540 |
+
"""
|
| 541 |
+
uid: str | None = None
|
| 542 |
+
cid: str | None = None
|
| 543 |
+
try:
|
| 544 |
+
uid, cid = _resolve_identity(request)
|
| 545 |
+
except HTTPException as exc:
|
| 546 |
+
# Return error as a streaming event so the client can parse it
|
| 547 |
+
async def _err():
|
| 548 |
+
yield _sse("error", {"message": exc.detail})
|
| 549 |
+
|
| 550 |
+
return StreamingResponse(_err(), media_type="text/event-stream")
|
| 551 |
+
|
| 552 |
+
user_id, conversation_id = uid, cid
|
| 553 |
+
|
| 554 |
+
wallet = request.wallet_address.strip() if request.wallet_address else None
|
| 555 |
+
if wallet and wallet.lower() == "default":
|
| 556 |
+
wallet = None
|
| 557 |
+
display_name = None
|
| 558 |
+
if isinstance(request.message.metadata, dict):
|
| 559 |
+
display_name = request.message.metadata.get("display_name")
|
| 560 |
+
|
| 561 |
+
# Session setup, message persistence, and history fetch (non-blocking)
|
| 562 |
+
await asyncio.to_thread(
|
| 563 |
+
chat_manager_instance.ensure_session,
|
| 564 |
+
user_id,
|
| 565 |
+
conversation_id,
|
| 566 |
+
wallet_address=wallet,
|
| 567 |
+
display_name=display_name,
|
| 568 |
+
)
|
| 569 |
+
|
| 570 |
+
if request.message.role == "user":
|
| 571 |
+
clean_content = _sanitize_user_message_content(request.message.content)
|
| 572 |
+
if clean_content is not None:
|
| 573 |
+
request.message.content = clean_content
|
| 574 |
+
|
| 575 |
+
await asyncio.to_thread(
|
| 576 |
+
chat_manager_instance.add_message,
|
| 577 |
+
request.message.dict(),
|
| 578 |
+
conversation_id,
|
| 579 |
+
user_id,
|
| 580 |
+
)
|
| 581 |
+
|
| 582 |
+
conversation_messages = await asyncio.to_thread(
|
| 583 |
+
chat_manager_instance.get_messages,
|
| 584 |
+
conversation_id,
|
| 585 |
+
user_id,
|
| 586 |
+
)
|
| 587 |
+
|
| 588 |
+
initial_state: Dict[str, Any] = {
|
| 589 |
+
"messages": conversation_messages,
|
| 590 |
+
"user_id": user_id,
|
| 591 |
+
"conversation_id": conversation_id,
|
| 592 |
+
"wallet_address": wallet,
|
| 593 |
+
}
|
| 594 |
+
|
| 595 |
+
async def event_generator():
|
| 596 |
+
"""Yields SSE events from LangGraph astream_events."""
|
| 597 |
+
cost_tracker = Config.get_cost_tracker()
|
| 598 |
+
cost_snapshot = cost_tracker.get_snapshot()
|
| 599 |
+
|
| 600 |
+
final_response_chunks: List[str] = []
|
| 601 |
+
response_agent = "supervisor"
|
| 602 |
+
response_metadata: Dict[str, Any] = {}
|
| 603 |
+
nodes_executed: List[str] = []
|
| 604 |
+
# Track which node is the "final agent" so we only stream its tokens
|
| 605 |
+
current_agent_node: str | None = None
|
| 606 |
+
streaming_tokens = False
|
| 607 |
+
|
| 608 |
+
try:
|
| 609 |
+
async for event in graph.astream_events(
|
| 610 |
+
initial_state, version="v2"
|
| 611 |
+
):
|
| 612 |
+
kind = event["event"]
|
| 613 |
+
name = event.get("name", "")
|
| 614 |
+
|
| 615 |
+
# ── Node starts ──
|
| 616 |
+
if kind == "on_chain_start" and name in _NODE_LABELS:
|
| 617 |
+
nodes_executed.append(name)
|
| 618 |
+
# Track agent node for token attribution
|
| 619 |
+
if name.endswith("_agent_node"):
|
| 620 |
+
current_agent_node = name
|
| 621 |
+
yield _sse("status", {
|
| 622 |
+
"step": name,
|
| 623 |
+
"label": _NODE_LABELS[name],
|
| 624 |
+
"ts": time.time(),
|
| 625 |
+
})
|
| 626 |
+
|
| 627 |
+
# ── Semantic router result ──
|
| 628 |
+
elif kind == "on_chain_end" and name == "semantic_router_node":
|
| 629 |
+
output = event.get("data", {}).get("output", {})
|
| 630 |
+
if isinstance(output, dict):
|
| 631 |
+
yield _sse("status", {
|
| 632 |
+
"step": "routed",
|
| 633 |
+
"agent": output.get("route_agent", "unknown"),
|
| 634 |
+
"confidence": output.get("route_confidence", 0),
|
| 635 |
+
"ts": time.time(),
|
| 636 |
+
})
|
| 637 |
+
|
| 638 |
+
# ── Tool invocations ──
|
| 639 |
+
elif kind == "on_tool_start":
|
| 640 |
+
yield _sse("status", {
|
| 641 |
+
"step": "tool",
|
| 642 |
+
"tool": name,
|
| 643 |
+
"label": f"Using {name}...",
|
| 644 |
+
"ts": time.time(),
|
| 645 |
+
})
|
| 646 |
+
|
| 647 |
+
elif kind == "on_tool_end":
|
| 648 |
+
tool_output = event.get("data", {}).get("output", "")
|
| 649 |
+
preview = str(tool_output)[:200]
|
| 650 |
+
yield _sse("tool_io", {
|
| 651 |
+
"tool": name,
|
| 652 |
+
"output": preview,
|
| 653 |
+
"ts": time.time(),
|
| 654 |
+
})
|
| 655 |
+
|
| 656 |
+
# ── LLM token streaming ──
|
| 657 |
+
elif kind == "on_chat_model_stream":
|
| 658 |
+
chunk = event.get("data", {}).get("chunk")
|
| 659 |
+
if chunk and hasattr(chunk, "content") and chunk.content:
|
| 660 |
+
text = chunk.content if isinstance(chunk.content, str) else ""
|
| 661 |
+
if text:
|
| 662 |
+
# Only stream tokens from agent nodes, not from
|
| 663 |
+
# router/formatter internal calls
|
| 664 |
+
parent_tags = event.get("tags", [])
|
| 665 |
+
is_formatter = "formatter" in name.lower() or any(
|
| 666 |
+
"formatter" in t for t in parent_tags
|
| 667 |
+
)
|
| 668 |
+
if not is_formatter and current_agent_node:
|
| 669 |
+
if not streaming_tokens:
|
| 670 |
+
streaming_tokens = True
|
| 671 |
+
yield _sse("status", {
|
| 672 |
+
"step": "generating",
|
| 673 |
+
"label": "Generating response...",
|
| 674 |
+
"ts": time.time(),
|
| 675 |
+
})
|
| 676 |
+
final_response_chunks.append(text)
|
| 677 |
+
yield _sse("token", {"t": text})
|
| 678 |
+
|
| 679 |
+
# ── Node ends — capture graph output ──
|
| 680 |
+
elif kind == "on_chain_end" and name == "LangGraph":
|
| 681 |
+
output = event.get("data", {}).get("output", {})
|
| 682 |
+
if isinstance(output, dict):
|
| 683 |
+
response_agent = output.get(
|
| 684 |
+
"response_agent", response_agent
|
| 685 |
+
)
|
| 686 |
+
response_metadata = output.get(
|
| 687 |
+
"response_metadata", response_metadata
|
| 688 |
+
)
|
| 689 |
+
# If we didn't stream tokens (e.g. formatter rewrote),
|
| 690 |
+
# use the final_response from the graph
|
| 691 |
+
if not final_response_chunks:
|
| 692 |
+
graph_response = output.get("final_response", "")
|
| 693 |
+
if graph_response:
|
| 694 |
+
final_response_chunks.append(graph_response)
|
| 695 |
+
|
| 696 |
+
except Exception as exc:
|
| 697 |
+
logger.exception("Stream error for user=%s conversation=%s", user_id, conversation_id)
|
| 698 |
+
yield _sse("error", {"message": str(exc)})
|
| 699 |
+
return
|
| 700 |
+
|
| 701 |
+
# ── Build final metadata ──
|
| 702 |
+
full_response = "".join(final_response_chunks)
|
| 703 |
+
cost_delta = cost_tracker.calculate_delta(cost_snapshot)
|
| 704 |
+
|
| 705 |
+
agent_name = _map_agent_type(response_agent)
|
| 706 |
+
|
| 707 |
+
# Enrich metadata (same logic as _build_response_payload)
|
| 708 |
+
if not response_metadata:
|
| 709 |
+
if agent_name == "token swap":
|
| 710 |
+
swap_meta = metadata.get_swap_agent(
|
| 711 |
+
user_id=user_id, conversation_id=conversation_id
|
| 712 |
+
)
|
| 713 |
+
if swap_meta:
|
| 714 |
+
response_metadata = swap_meta
|
| 715 |
+
elif agent_name == "lending":
|
| 716 |
+
lending_meta = metadata.get_lending_agent(
|
| 717 |
+
user_id=user_id, conversation_id=conversation_id
|
| 718 |
+
)
|
| 719 |
+
if lending_meta:
|
| 720 |
+
response_metadata = lending_meta
|
| 721 |
+
elif agent_name == "staking":
|
| 722 |
+
staking_meta = metadata.get_staking_agent(
|
| 723 |
+
user_id=user_id, conversation_id=conversation_id
|
| 724 |
+
)
|
| 725 |
+
if staking_meta:
|
| 726 |
+
response_metadata = staking_meta
|
| 727 |
+
|
| 728 |
+
yield _sse("done", {
|
| 729 |
+
"agent": agent_name,
|
| 730 |
+
"nodes": nodes_executed,
|
| 731 |
+
"metadata": response_metadata,
|
| 732 |
+
"response": full_response,
|
| 733 |
+
"costs": {
|
| 734 |
+
"total_usd": cost_delta.get("cost", 0),
|
| 735 |
+
},
|
| 736 |
+
})
|
| 737 |
+
|
| 738 |
+
# ── Background: persist response + costs ──
|
| 739 |
+
asyncio.create_task(
|
| 740 |
+
_persist_response_bg(
|
| 741 |
+
full_response,
|
| 742 |
+
response_agent,
|
| 743 |
+
response_metadata,
|
| 744 |
+
user_id,
|
| 745 |
+
conversation_id,
|
| 746 |
+
cost_delta,
|
| 747 |
+
)
|
| 748 |
+
)
|
| 749 |
+
|
| 750 |
+
return StreamingResponse(
|
| 751 |
+
event_generator(),
|
| 752 |
+
media_type="text/event-stream",
|
| 753 |
+
headers={
|
| 754 |
+
"Cache-Control": "no-cache",
|
| 755 |
+
"Connection": "keep-alive",
|
| 756 |
+
"X-Accel-Buffering": "no",
|
| 757 |
+
},
|
| 758 |
+
)
|
| 759 |
+
|
| 760 |
+
|
| 761 |
# Supported audio MIME types
|
| 762 |
AUDIO_MIME_TYPES = {
|
| 763 |
".mp3": "audio/mpeg",
|
|
|
|
| 798 |
{"transcription": "<exact transcription>", "intent": "<category>", "confidence": <0.0-1.0>}
|
| 799 |
"""
|
| 800 |
|
| 801 |
+
_AUDIO_TRANSCRIBE_ONLY_PROMPT = """\
|
| 802 |
+
You will receive an audio clip. Transcribe exactly what is being said.
|
| 803 |
+
Return ONLY the transcription text, nothing else. No JSON, no markdown, no labels.
|
| 804 |
+
"""
|
| 805 |
+
|
| 806 |
# Maps audio classification intents to agent runtime names
|
| 807 |
_AUDIO_INTENT_AGENT_MAP: Dict[str, str] = {
|
| 808 |
"swap": "swap_agent",
|
|
|
|
| 810 |
"staking": "staking_agent",
|
| 811 |
"dca": "dca_agent",
|
| 812 |
"market_data": "crypto_agent",
|
| 813 |
+
"portfolio": "portfolio_advisor",
|
| 814 |
"search": "search_agent",
|
| 815 |
"education": "default_agent",
|
| 816 |
"general": "default_agent",
|
|
|
|
| 1020 |
conversation_messages,
|
| 1021 |
request_user_id,
|
| 1022 |
request_conversation_id,
|
| 1023 |
+
wallet_address=wallet,
|
| 1024 |
pre_classified=pre_classified,
|
| 1025 |
)
|
| 1026 |
|
|
|
|
| 1066 |
raise HTTPException(status_code=500, detail=str(e))
|
| 1067 |
|
| 1068 |
|
| 1069 |
+
@app.post("/transcribe")
|
| 1070 |
+
async def transcribe_audio(
|
| 1071 |
+
audio: UploadFile = File(..., description="Audio file (mp3, wav, flac, ogg, webm, m4a)"),
|
| 1072 |
+
):
|
| 1073 |
+
"""Transcribe audio to text without invoking the agent pipeline.
|
| 1074 |
+
|
| 1075 |
+
Stateless endpoint — no user_id, conversation_id, or persistence.
|
| 1076 |
+
Returns ``{"text": "<transcription>"}``.
|
| 1077 |
+
"""
|
| 1078 |
+
try:
|
| 1079 |
+
audio_content = await audio.read()
|
| 1080 |
+
if len(audio_content) > MAX_AUDIO_SIZE:
|
| 1081 |
+
raise HTTPException(
|
| 1082 |
+
status_code=413,
|
| 1083 |
+
detail=f"Audio file too large. Maximum size is {MAX_AUDIO_SIZE // (1024 * 1024)}MB.",
|
| 1084 |
+
)
|
| 1085 |
+
if len(audio_content) == 0:
|
| 1086 |
+
raise HTTPException(status_code=400, detail="Audio file is empty.")
|
| 1087 |
+
|
| 1088 |
+
mime_type = _get_audio_mime_type(audio.filename or "", audio.content_type)
|
| 1089 |
+
encoded_audio = base64.b64encode(audio_content).decode("utf-8")
|
| 1090 |
+
|
| 1091 |
+
from src.llm.tiers import ModelTier
|
| 1092 |
+
llm = Config.get_llm(model=ModelTier.TRANSCRIPTION, with_cost_tracking=True)
|
| 1093 |
+
|
| 1094 |
+
message = HumanMessage(
|
| 1095 |
+
content=[
|
| 1096 |
+
{"type": "text", "text": _AUDIO_TRANSCRIBE_ONLY_PROMPT},
|
| 1097 |
+
{"type": "media", "data": encoded_audio, "mime_type": mime_type},
|
| 1098 |
+
]
|
| 1099 |
+
)
|
| 1100 |
+
|
| 1101 |
+
response = await asyncio.to_thread(llm.invoke, [message])
|
| 1102 |
+
|
| 1103 |
+
raw_content = response.content
|
| 1104 |
+
if isinstance(raw_content, list):
|
| 1105 |
+
text_parts = []
|
| 1106 |
+
for part in raw_content:
|
| 1107 |
+
if isinstance(part, dict) and part.get("text"):
|
| 1108 |
+
text_parts.append(part["text"])
|
| 1109 |
+
elif isinstance(part, str):
|
| 1110 |
+
text_parts.append(part)
|
| 1111 |
+
raw_content = " ".join(text_parts).strip()
|
| 1112 |
+
|
| 1113 |
+
text = (raw_content or "").strip()
|
| 1114 |
+
if not text:
|
| 1115 |
+
raise HTTPException(
|
| 1116 |
+
status_code=400,
|
| 1117 |
+
detail="Could not transcribe the audio. Please try again with a clearer recording.",
|
| 1118 |
+
)
|
| 1119 |
+
|
| 1120 |
+
return {"text": text}
|
| 1121 |
+
|
| 1122 |
+
except HTTPException:
|
| 1123 |
+
raise
|
| 1124 |
+
except Exception as e:
|
| 1125 |
+
logger.exception("Transcribe endpoint failed")
|
| 1126 |
+
raise HTTPException(status_code=500, detail=str(e))
|
| 1127 |
+
|
| 1128 |
+
|
| 1129 |
# Include chat manager router
|
| 1130 |
app.include_router(chat_manager_router)
|
src/graphs/edges.py
CHANGED
|
@@ -27,6 +27,7 @@ _INTENT_TO_NODE = {
|
|
| 27 |
IntentCategory.STAKING.value: "staking_agent_node",
|
| 28 |
IntentCategory.DCA.value: "dca_agent_node",
|
| 29 |
IntentCategory.MARKET_DATA.value: "crypto_agent_node",
|
|
|
|
| 30 |
IntentCategory.SEARCH.value: "search_agent_node",
|
| 31 |
IntentCategory.EDUCATION.value: "default_agent_node",
|
| 32 |
IntentCategory.GENERAL.value: "default_agent_node",
|
|
@@ -41,6 +42,7 @@ _AGENT_NAME_TO_NODE = {
|
|
| 41 |
"search_agent": "search_agent_node",
|
| 42 |
"default_agent": "default_agent_node",
|
| 43 |
"database_agent": "database_agent_node",
|
|
|
|
| 44 |
}
|
| 45 |
|
| 46 |
# DeFi state key → node mapping
|
|
|
|
| 27 |
IntentCategory.STAKING.value: "staking_agent_node",
|
| 28 |
IntentCategory.DCA.value: "dca_agent_node",
|
| 29 |
IntentCategory.MARKET_DATA.value: "crypto_agent_node",
|
| 30 |
+
IntentCategory.PORTFOLIO.value: "portfolio_advisor_node",
|
| 31 |
IntentCategory.SEARCH.value: "search_agent_node",
|
| 32 |
IntentCategory.EDUCATION.value: "default_agent_node",
|
| 33 |
IntentCategory.GENERAL.value: "default_agent_node",
|
|
|
|
| 42 |
"search_agent": "search_agent_node",
|
| 43 |
"default_agent": "default_agent_node",
|
| 44 |
"database_agent": "database_agent_node",
|
| 45 |
+
"portfolio_advisor": "portfolio_advisor_node",
|
| 46 |
}
|
| 47 |
|
| 48 |
# DeFi state key → node mapping
|
src/graphs/factory.py
CHANGED
|
@@ -24,6 +24,7 @@ from src.graphs.nodes import (
|
|
| 24 |
search_agent_node,
|
| 25 |
default_agent_node,
|
| 26 |
database_agent_node,
|
|
|
|
| 27 |
)
|
| 28 |
from src.graphs.edges import decide_route, after_llm_router
|
| 29 |
from src.agents.formatter.node import formatter_node
|
|
@@ -40,6 +41,7 @@ _AGENT_NODES = [
|
|
| 40 |
"search_agent_node",
|
| 41 |
"default_agent_node",
|
| 42 |
"database_agent_node",
|
|
|
|
| 43 |
]
|
| 44 |
|
| 45 |
|
|
@@ -70,6 +72,7 @@ def build_graph() -> StateGraph:
|
|
| 70 |
graph.add_node("search_agent_node", search_agent_node)
|
| 71 |
graph.add_node("default_agent_node", default_agent_node)
|
| 72 |
graph.add_node("database_agent_node", database_agent_node)
|
|
|
|
| 73 |
|
| 74 |
# --- Entry point ---
|
| 75 |
graph.set_entry_point("entry_node")
|
|
@@ -92,6 +95,7 @@ def build_graph() -> StateGraph:
|
|
| 92 |
"search_agent_node": "search_agent_node",
|
| 93 |
"default_agent_node": "default_agent_node",
|
| 94 |
"database_agent_node": "database_agent_node",
|
|
|
|
| 95 |
},
|
| 96 |
)
|
| 97 |
|
|
@@ -108,6 +112,7 @@ def build_graph() -> StateGraph:
|
|
| 108 |
"search_agent_node": "search_agent_node",
|
| 109 |
"default_agent_node": "default_agent_node",
|
| 110 |
"database_agent_node": "database_agent_node",
|
|
|
|
| 111 |
},
|
| 112 |
)
|
| 113 |
|
|
|
|
| 24 |
search_agent_node,
|
| 25 |
default_agent_node,
|
| 26 |
database_agent_node,
|
| 27 |
+
portfolio_advisor_node,
|
| 28 |
)
|
| 29 |
from src.graphs.edges import decide_route, after_llm_router
|
| 30 |
from src.agents.formatter.node import formatter_node
|
|
|
|
| 41 |
"search_agent_node",
|
| 42 |
"default_agent_node",
|
| 43 |
"database_agent_node",
|
| 44 |
+
"portfolio_advisor_node",
|
| 45 |
]
|
| 46 |
|
| 47 |
|
|
|
|
| 72 |
graph.add_node("search_agent_node", search_agent_node)
|
| 73 |
graph.add_node("default_agent_node", default_agent_node)
|
| 74 |
graph.add_node("database_agent_node", database_agent_node)
|
| 75 |
+
graph.add_node("portfolio_advisor_node", portfolio_advisor_node)
|
| 76 |
|
| 77 |
# --- Entry point ---
|
| 78 |
graph.set_entry_point("entry_node")
|
|
|
|
| 95 |
"search_agent_node": "search_agent_node",
|
| 96 |
"default_agent_node": "default_agent_node",
|
| 97 |
"database_agent_node": "database_agent_node",
|
| 98 |
+
"portfolio_advisor_node": "portfolio_advisor_node",
|
| 99 |
},
|
| 100 |
)
|
| 101 |
|
|
|
|
| 112 |
"search_agent_node": "search_agent_node",
|
| 113 |
"default_agent_node": "default_agent_node",
|
| 114 |
"database_agent_node": "database_agent_node",
|
| 115 |
+
"portfolio_advisor_node": "portfolio_advisor_node",
|
| 116 |
},
|
| 117 |
)
|
| 118 |
|
src/graphs/nodes.py
CHANGED
|
@@ -48,6 +48,9 @@ from src.agents.staking.agent import StakingAgent
|
|
| 48 |
from src.agents.staking.tools import staking_session
|
| 49 |
from src.agents.staking.prompt import STAKING_AGENT_SYSTEM_PROMPT
|
| 50 |
from src.agents.search.agent import SearchAgent
|
|
|
|
|
|
|
|
|
|
| 51 |
from src.agents.database.client import is_database_available
|
| 52 |
|
| 53 |
logger = logging.getLogger(__name__)
|
|
@@ -90,6 +93,8 @@ def initialize_agents() -> None:
|
|
| 90 |
_agents["lending_agent"] = LendingAgent(llm).agent
|
| 91 |
_agents["staking_agent"] = StakingAgent(llm).agent
|
| 92 |
|
|
|
|
|
|
|
| 93 |
if is_database_available():
|
| 94 |
_agents["database_agent"] = DatabaseAgent(llm)
|
| 95 |
else:
|
|
@@ -261,6 +266,7 @@ Available agents:
|
|
| 261 |
- dca_agent: Dollar-cost averaging strategies.
|
| 262 |
- lending_agent: Lending operations (supply, borrow, repay, withdraw).
|
| 263 |
- staking_agent: Staking operations (stake ETH, unstake stETH via Lido).
|
|
|
|
| 264 |
- search_agent: Web search for current events and factual lookups.
|
| 265 |
- database_agent: Database queries and data analysis.
|
| 266 |
- default_agent: General conversation, education, greetings.
|
|
@@ -287,7 +293,8 @@ def llm_router_node(state: AgentState) -> dict:
|
|
| 287 |
# Validate
|
| 288 |
valid_agents = {
|
| 289 |
"crypto_agent", "swap_agent", "dca_agent", "lending_agent",
|
| 290 |
-
"staking_agent", "
|
|
|
|
| 291 |
}
|
| 292 |
if chosen not in valid_agents:
|
| 293 |
chosen = "default_agent"
|
|
@@ -464,5 +471,53 @@ def default_agent_node(state: AgentState, config: RunnableConfig | None = None)
|
|
| 464 |
return _invoke_simple_agent("default_agent", state, config)
|
| 465 |
|
| 466 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 467 |
def database_agent_node(state: AgentState, config: RunnableConfig | None = None) -> dict:
|
| 468 |
return _invoke_simple_agent("database_agent", state, config)
|
|
|
|
| 48 |
from src.agents.staking.tools import staking_session
|
| 49 |
from src.agents.staking.prompt import STAKING_AGENT_SYSTEM_PROMPT
|
| 50 |
from src.agents.search.agent import SearchAgent
|
| 51 |
+
from src.agents.portfolio.agent import PortfolioAdvisorAgent
|
| 52 |
+
from src.agents.portfolio.tools import portfolio_session
|
| 53 |
+
from src.agents.portfolio.prompt import PORTFOLIO_ADVISOR_SYSTEM_PROMPT
|
| 54 |
from src.agents.database.client import is_database_available
|
| 55 |
|
| 56 |
logger = logging.getLogger(__name__)
|
|
|
|
| 93 |
_agents["lending_agent"] = LendingAgent(llm).agent
|
| 94 |
_agents["staking_agent"] = StakingAgent(llm).agent
|
| 95 |
|
| 96 |
+
_agents["portfolio_advisor"] = PortfolioAdvisorAgent(llm).agent
|
| 97 |
+
|
| 98 |
if is_database_available():
|
| 99 |
_agents["database_agent"] = DatabaseAgent(llm)
|
| 100 |
else:
|
|
|
|
| 266 |
- dca_agent: Dollar-cost averaging strategies.
|
| 267 |
- lending_agent: Lending operations (supply, borrow, repay, withdraw).
|
| 268 |
- staking_agent: Staking operations (stake ETH, unstake stETH via Lido).
|
| 269 |
+
- portfolio_advisor: Portfolio analysis, risk assessment, wallet holdings, rebalancing advice.
|
| 270 |
- search_agent: Web search for current events and factual lookups.
|
| 271 |
- database_agent: Database queries and data analysis.
|
| 272 |
- default_agent: General conversation, education, greetings.
|
|
|
|
| 293 |
# Validate
|
| 294 |
valid_agents = {
|
| 295 |
"crypto_agent", "swap_agent", "dca_agent", "lending_agent",
|
| 296 |
+
"staking_agent", "portfolio_advisor", "search_agent",
|
| 297 |
+
"database_agent", "default_agent",
|
| 298 |
}
|
| 299 |
if chosen not in valid_agents:
|
| 300 |
chosen = "default_agent"
|
|
|
|
| 471 |
return _invoke_simple_agent("default_agent", state, config)
|
| 472 |
|
| 473 |
|
| 474 |
+
def portfolio_advisor_node(state: AgentState, config: RunnableConfig | None = None) -> dict:
|
| 475 |
+
"""Invoke the portfolio advisor with wallet_address session context."""
|
| 476 |
+
user_id = state.get("user_id")
|
| 477 |
+
conversation_id = state.get("conversation_id")
|
| 478 |
+
wallet_address = state.get("wallet_address")
|
| 479 |
+
langchain_messages = list(state.get("langchain_messages", []))
|
| 480 |
+
nodes = list(state.get("nodes_executed", []))
|
| 481 |
+
nodes.append("portfolio_advisor_node")
|
| 482 |
+
|
| 483 |
+
agent = _agents.get("portfolio_advisor")
|
| 484 |
+
if not agent:
|
| 485 |
+
return {
|
| 486 |
+
"final_response": "Portfolio advisor is not available.",
|
| 487 |
+
"response_agent": "portfolio_advisor",
|
| 488 |
+
"response_metadata": {},
|
| 489 |
+
"raw_agent_messages": [],
|
| 490 |
+
"nodes_executed": nodes,
|
| 491 |
+
}
|
| 492 |
+
|
| 493 |
+
# Inject system prompt
|
| 494 |
+
scoped_messages = [SystemMessage(content=PORTFOLIO_ADVISOR_SYSTEM_PROMPT)]
|
| 495 |
+
scoped_messages.extend(langchain_messages)
|
| 496 |
+
|
| 497 |
+
try:
|
| 498 |
+
with portfolio_session(user_id=user_id, conversation_id=conversation_id, wallet_address=wallet_address):
|
| 499 |
+
response = agent.invoke({"messages": scoped_messages}, config=config)
|
| 500 |
+
except Exception:
|
| 501 |
+
logger.exception("Error invoking portfolio_advisor")
|
| 502 |
+
return {
|
| 503 |
+
"final_response": "Sorry, an error occurred while analyzing your portfolio.",
|
| 504 |
+
"response_agent": "portfolio_advisor",
|
| 505 |
+
"response_metadata": {},
|
| 506 |
+
"raw_agent_messages": [],
|
| 507 |
+
"nodes_executed": nodes,
|
| 508 |
+
}
|
| 509 |
+
|
| 510 |
+
agent_name, text, messages_out = extract_response_from_graph(response)
|
| 511 |
+
meta = build_metadata(agent_name or "portfolio_advisor", user_id, conversation_id, messages_out)
|
| 512 |
+
|
| 513 |
+
return {
|
| 514 |
+
"final_response": text,
|
| 515 |
+
"response_agent": agent_name or "portfolio_advisor",
|
| 516 |
+
"response_metadata": meta,
|
| 517 |
+
"raw_agent_messages": messages_out,
|
| 518 |
+
"nodes_executed": nodes,
|
| 519 |
+
}
|
| 520 |
+
|
| 521 |
+
|
| 522 |
def database_agent_node(state: AgentState, config: RunnableConfig | None = None) -> dict:
|
| 523 |
return _invoke_simple_agent("database_agent", state, config)
|
src/graphs/state.py
CHANGED
|
@@ -14,6 +14,7 @@ class AgentState(TypedDict, total=False):
|
|
| 14 |
messages: List[Dict[str, Any]] # Raw conversation messages from the gateway
|
| 15 |
user_id: str
|
| 16 |
conversation_id: str
|
|
|
|
| 17 |
|
| 18 |
# --- Windowed context ---
|
| 19 |
windowed_messages: List[Dict[str, Any]] # After context windowing
|
|
|
|
| 14 |
messages: List[Dict[str, Any]] # Raw conversation messages from the gateway
|
| 15 |
user_id: str
|
| 16 |
conversation_id: str
|
| 17 |
+
wallet_address: Optional[str] # EVM wallet address (from HTTP request)
|
| 18 |
|
| 19 |
# --- Windowed context ---
|
| 20 |
windowed_messages: List[Dict[str, Any]] # After context windowing
|