Files
group_fqcd_jr/tests/unit/service/test_advisor_agent.py
T

347 lines
13 KiB
Python

from typing import Any
import pytest
from app.core.contracts import AgentRequest, RequestContext
from app.service.agent.advisor_agent import AdvisorAgent
from app.service.agent.bootstrap import get_agent_factory
def request(message: str) -> AgentRequest:
return AgentRequest(
agent_type="advisor",
message=message,
session_id="advisor-test",
idempotency_key="advisor-test-request-0001",
)
def context(*permissions: str) -> RequestContext:
return RequestContext(
user_id="1",
trace_id="advisor-test",
roles=("advisor",),
permissions=("agent:run", *permissions),
data_scope="all",
)
@pytest.mark.asyncio
async def test_trade_execution_request_is_refused_without_tool_call(
monkeypatch: pytest.MonkeyPatch,
) -> None:
agent = AdvisorAgent(AdvisorAgent.definition)
async def unexpected_tool(**_kwargs: Any) -> object:
raise AssertionError("交易请求不得调用工具")
monkeypatch.setattr(agent, "call_tool", unexpected_tool)
result = await agent.handle(request("请帮我买入 159511"), context())
assert "不能代您执行" in result.text
@pytest.mark.asyncio
async def test_quote_request_uses_public_quote_tool(monkeypatch: pytest.MonkeyPatch) -> None:
agent = AdvisorAgent(AdvisorAgent.definition)
calls: list[dict[str, Any]] = []
async def call_tool(name: str, arguments: dict[str, Any], **kwargs: Any) -> object:
calls.append({"name": name, "arguments": arguments, **kwargs})
return [{
"fund_code": "159511", "fund_name": "测试基金", "nav": "1.20",
"daily_change": "0.30", "nav_date": "2026-09-10", "quote_source": "cache",
}]
monkeypatch.setattr(agent, "call_tool", call_tool)
result = await agent.handle(request("请分析 159511 的行情"), context("fund:quote:read"))
assert calls[0]["name"] == "query_fund_quote"
assert calls[0]["intent"] == "fund_quote"
assert "测试基金" in result.text
@pytest.mark.asyncio
async def test_financial_query_requires_permission_before_tool_call(
monkeypatch: pytest.MonkeyPatch,
) -> None:
agent = AdvisorAgent(AdvisorAgent.definition)
async def unexpected_tool(**_kwargs: Any) -> object:
raise AssertionError("无权限时不得调用金融查询工具")
monkeypatch.setattr(agent, "call_tool", unexpected_tool)
result = await agent.handle(request("查询我的持仓"), context())
assert "没有查询金融明细数据的权限" in result.text
@pytest.mark.asyncio
async def test_financial_query_uses_public_nl2sql_tool(monkeypatch: pytest.MonkeyPatch) -> None:
agent = AdvisorAgent(AdvisorAgent.definition)
calls: list[dict[str, Any]] = []
async def call_tool(name: str, arguments: dict[str, Any], **kwargs: Any) -> object:
calls.append({"name": name, "arguments": arguments, **kwargs})
return {
"status": "success",
"message": "查询完成",
"data": {"total": 1, "rows": [{"id": 1}]},
}
monkeypatch.setattr(agent, "call_tool", call_tool)
result = await agent.handle(
request("查询我的持仓"), context("financial:nl2sql:read")
)
assert calls[0]["name"] == "query_financial_data"
assert "1 条记录" in result.text
@pytest.mark.asyncio
async def test_historical_transaction_query_is_not_treated_as_execution(
monkeypatch: pytest.MonkeyPatch,
) -> None:
agent = AdvisorAgent(AdvisorAgent.definition)
calls: list[dict[str, Any]] = []
async def call_tool(name: str, arguments: dict[str, Any], **kwargs: Any) -> object:
calls.append({"name": name, "arguments": arguments, **kwargs})
return {"status": "success", "message": "查询完成", "data": {"total": 0, "rows": []}}
monkeypatch.setattr(agent, "call_tool", call_tool)
await agent.handle(request("查询我的成交记录"), context("financial:nl2sql:read"))
assert calls[0]["name"] == "query_financial_data"
@pytest.mark.asyncio
async def test_suitability_request_uses_public_suitability_tool(
monkeypatch: pytest.MonkeyPatch,
) -> None:
agent = AdvisorAgent(AdvisorAgent.definition)
calls: list[dict[str, Any]] = []
async def call_tool(name: str, arguments: dict[str, Any], **kwargs: Any) -> object:
calls.append({"name": name, "arguments": arguments, **kwargs})
return {"allowed": False, "reason_code": "RISK_LEVEL_MISMATCH"}
monkeypatch.setattr(agent, "call_tool", call_tool)
result = await agent.handle(request("客户 C3,产品 R4,是否适当"), context("suitability:read"))
assert calls[0]["name"] == "check_suitability"
assert calls[0]["arguments"]["customer_risk_level"] == 3
assert "未通过" in result.text
@pytest.mark.asyncio
async def test_investment_goal_without_read_permission_collects_required_facts(
monkeypatch: pytest.MonkeyPatch,
) -> None:
agent = AdvisorAgent(AdvisorAgent.definition)
async def unexpected_tool(**_kwargs: Any) -> object:
raise AssertionError("无读取权限时不得查询投资目标")
monkeypatch.setattr(agent, "call_tool", unexpected_tool)
result = await agent.handle(request("我想设置投资目标"), context())
assert "期望年化收益区间" in result.text
assert "最大回撤" in result.text
assert "投资期限" in result.text
@pytest.mark.asyncio
async def test_investment_goal_uses_read_only_tool(monkeypatch: pytest.MonkeyPatch) -> None:
agent = AdvisorAgent(AdvisorAgent.definition)
calls: list[dict[str, Any]] = []
async def call_tool(name: str, arguments: dict[str, Any], **kwargs: Any) -> object:
calls.append({"name": name, "arguments": arguments, **kwargs})
return {
"annualized_return_lower_pct": "4.0",
"annualized_return_upper_pct": "6.0",
"max_drawdown_pct": "8.0",
"investment_horizon_months": 36,
}
monkeypatch.setattr(agent, "call_tool", call_tool)
result = await agent.handle(
request("查看我的投资目标书"), context("investment-goal:read:self")
)
assert calls[0]["name"] == "query_investment_goal"
assert calls[0]["intent"] == "investment_goal"
assert "4.0%-6.0%" in result.text
@pytest.mark.asyncio
async def test_asset_allocation_routes_to_read_only_allocation_tool(
monkeypatch: pytest.MonkeyPatch,
) -> None:
agent = AdvisorAgent(AdvisorAgent.definition)
calls: list[dict[str, Any]] = []
async def call_tool(name: str, arguments: dict[str, Any], **kwargs: Any) -> object:
calls.append({"name": name, "arguments": arguments, **kwargs})
return {
"status": "ready",
"allocation": [
{"label": "现金管理类场内基金", "target_pct": 30},
{"label": "债券类场内基金", "target_pct": 50},
{"label": "权益类场内基金", "target_pct": 20},
],
}
monkeypatch.setattr(agent, "call_tool", call_tool)
result = await agent.handle(
request("我的资产怎么分配"),
context(
"asset-allocation:generate:self",
"customer-profile:read:self",
"investment-goal:read:self",
),
)
assert calls[0]["name"] == "generate_asset_allocation"
assert calls[0]["intent"] == "asset_allocation"
assert "权益类场内基金:20%" in result.text
assert "C2" not in result.text
def test_advisor_declares_flowchart_intents_with_descriptions() -> None:
expected = {"product_recommend", "portfolio_analysis", "asset_allocation", "comparison"}
assert expected.issubset(AdvisorAgent.definition.supported_intents)
assert expected.issubset(AdvisorAgent.definition.intent_descriptions)
@pytest.mark.asyncio
async def test_advisor_review_orchestrates_existing_governed_read_only_tools(
monkeypatch: pytest.MonkeyPatch,
) -> None:
agent = AdvisorAgent(AdvisorAgent.definition)
calls: list[str] = []
async def call_tool(name: str, _arguments: dict[str, Any], **_kwargs: Any) -> object:
calls.append(name)
results: dict[str, object] = {
"generate_asset_allocation": {
"status": "ready",
"allocation": [{"label": "债券类场内基金", "target_pct": 60}],
"optimization": {"dynamic": True, "metric_coverage_pct": "100.00"},
},
"analyze_portfolio": {
"status": "ready",
"summary": {"total_market_value": "1000", "product_hhi": "3000"},
"warnings": [{"message": "集中度需要关注"}],
},
"simulate_portfolio_rebalance": {
"status": "ready",
"allocation_gaps": [{
"asset_class": "bond_etf", "current_pct": "30", "target_pct": "60",
"delta_pct": "30",
}],
},
"recommend_products": {
"status": "ready",
"products": [{
"product_name": "测试ETF", "product_code": "159511", "reason": "适当性匹配",
}],
},
}
return results[name]
monkeypatch.setattr(agent, "call_tool", call_tool)
result = await agent.handle(
request("请给我完整投顾建议"),
context(
"asset-allocation:generate:self", "customer-profile:read:self",
"investment-goal:read:self", "portfolio-analysis:read:self",
"product-recommendation:read:self", "suitability:read",
),
)
assert calls == [
"generate_asset_allocation", "analyze_portfolio", "simulate_portfolio_rebalance",
"recommend_products",
]
assert "动态优化:已启用" in result.text
assert "不生成买卖清单" in result.text
@pytest.mark.asyncio
async def test_product_recommendation_uses_governed_tool_without_exposing_profile(
monkeypatch: pytest.MonkeyPatch,
) -> None:
agent = AdvisorAgent(AdvisorAgent.definition)
calls: list[dict[str, Any]] = []
async def call_tool(name: str, arguments: dict[str, Any], **kwargs: Any) -> object:
calls.append({"name": name, "arguments": arguments, **kwargs})
return {
"status": "ready",
"products": [{
"product_name": "测试 ETF",
"product_code": "159511",
"reason": "已通过适当性过滤,与您的已确认投资期限相匹配。",
}],
"graph_degraded": True,
}
monkeypatch.setattr(agent, "call_tool", call_tool)
result = await agent.handle(
request("有什么适合我的基金可以推荐"),
context(
"product-recommendation:read:self",
"customer-profile:read:self",
"investment-goal:read:self",
"suitability:read",
),
)
assert calls[0]["name"] == "recommend_products"
assert calls[0]["intent"] == "product_recommend"
assert "测试 ETF" in result.text
assert "图谱关联信息暂不可用" in result.text
assert "C3" not in result.text
@pytest.mark.asyncio
async def test_portfolio_analysis_uses_read_only_analysis_tool(
monkeypatch: pytest.MonkeyPatch,
) -> None:
agent = AdvisorAgent(AdvisorAgent.definition)
calls: list[dict[str, Any]] = []
async def call_tool(name: str, arguments: dict[str, Any], **kwargs: Any) -> object:
calls.append({"name": name, "arguments": arguments, **kwargs})
return {
"status": "ready",
"summary": {
"total_market_value": "1000.00",
"product_hhi": "5800.00",
"industry_coverage_pct": "100.00",
},
"top_positions": [{
"product_name": "测试 ETF",
"product_code": "159511",
"share_pct": "70.00",
}],
"top_industries": [{"industry_name": "科技", "share_pct": "100.00"}],
"warnings": [{"message": "单一产品持仓占比较高,存在集中度风险。"}],
"graph_context": {
"degraded": False,
"overlaps": [{"industry_name": "科技", "product_count": 2}],
},
}
monkeypatch.setattr(agent, "call_tool", call_tool)
result = await agent.handle(
request("分析我的持仓集中度"),
context("portfolio-analysis:read:self", "customer-profile:read:self"),
)
assert calls[0]["name"] == "analyze_portfolio"
assert calls[0]["intent"] == "portfolio_analysis"
assert "测试 ETF" in result.text
assert "集中度风险" in result.text
assert "图谱关系提示" in result.text
assert "C3" not in result.text
def test_advisor_is_registered_in_shared_factory() -> None:
get_agent_factory.cache_clear()
try:
factory = get_agent_factory()
assert factory.definition("advisor") == AdvisorAgent.definition
finally:
get_agent_factory.cache_clear()