feat: add advisory product comparison tool

This commit is contained in:
Windows
2026-09-11 19:20:32 +08:00
parent 6757ca047d
commit b6429e0e9c
8 changed files with 227 additions and 3 deletions
+13
View File
@@ -0,0 +1,13 @@
"""Read-only contract for comparing exchange-traded products."""
from typing import Annotated
from pydantic import BaseModel, ConfigDict, Field, StringConstraints
ProductCode = Annotated[str, StringConstraints(pattern=r"^\d{6}$")]
class ProductComparisonQuery(BaseModel):
model_config = ConfigDict(extra="forbid", frozen=True)
product_codes: Annotated[tuple[ProductCode, ...], Field(min_length=2, max_length=4)]
+10
View File
@@ -10,6 +10,7 @@ from app.core.errors import RecoverableAgentError
from app.core.fund_contracts import FundQuoteQuery
from app.core.investment_goal_contracts import InvestmentGoalQuery
from app.core.portfolio_analysis_contracts import PortfolioAnalysisQuery
from app.core.product_comparison_contracts import ProductComparisonQuery
from app.core.product_recommendation_contracts import ProductRecommendationQuery
from app.infrastructure.fund_quote_cache import FundQuoteCache
from app.infrastructure.memory_cache import MemoryCacheAdapter
@@ -31,6 +32,7 @@ from app.service.model_gateway import (
ModelGenerationService,
)
from app.service.portfolio_analysis_service import portfolio_analysis_tool
from app.service.product_comparison_service import product_comparison_tool
from app.service.product_recommendation_service import product_recommendation_tool
from app.service.runtime_config_service import load_active_intent_configs
from app.service.suitability_service import SuitabilityToolInput, suitability_tool_handler
@@ -183,6 +185,14 @@ def get_agent_factory() -> AgentFactory:
allowed_roles=("customer", "advisor", "operator", "admin"),
timeout_seconds=15,
))
registry.register(ToolDefinition(
name="compare_products",
input_model=ProductComparisonQuery,
handler=cast(Any, product_comparison_tool),
required_permission="product-comparison:read:self",
allowed_roles=("customer", "advisor", "operator", "admin"),
timeout_seconds=10,
))
model_service = get_model_service()
endpoint_resolver = DatabaseModelEndpointResolver()
factory = AgentFactory(
+32 -1
View File
@@ -1,5 +1,6 @@
"""投顾 Agent 的公共底座实现。"""
import re
from typing import Any
from app.core.contracts import AgentDefinition, AgentRequest, CoreResult, RequestContext
@@ -21,6 +22,7 @@ class AdvisorAgent(FundQueryDemoAgent):
"analyze_portfolio",
"generate_asset_allocation",
"recommend_products",
"compare_products",
),
supported_intents=(
"fund_quote",
@@ -77,7 +79,16 @@ class AdvisorAgent(FundQueryDemoAgent):
self._classified_intent is not None
and self._classified_intent.intent == "comparison"
):
return CoreResult(text="对比分析需要明确两个或多个场内基金产品,请提供基金代码或名称。")
codes = tuple(dict.fromkeys(re.findall(r"(?<!\d)(\d{6})(?!\d)", request.message)))
if len(codes) < 2:
return CoreResult(
text="对比分析需要明确两个或多个场内基金产品,请提供基金代码或名称。"
)
output = await self.call_tool(
"compare_products", {"product_codes": list(codes[:4])},
intent="comparison", context=context,
)
return CoreResult(text=self._describe_comparison(output))
return await super().handle(request, context)
@staticmethod
@@ -156,3 +167,23 @@ class AdvisorAgent(FundQueryDemoAgent):
if isinstance(item, dict)
]
return "推荐分析结果:" + "、".join(names) + "。方案须经审核发布,不构成交易指令。"
@staticmethod
def _describe_comparison(result: object) -> str:
if not isinstance(result, dict):
return "基金对比分析暂不可用,请稍后重试。"
if result.get("status") == "evidence_required":
return "部分基金缺少当前有效的权威证据,暂不能完成可靠对比。"
products = result.get("products")
if result.get("status") != "ready" or not isinstance(products, list):
return "对比分析需要至少两个有效的场内基金产品。"
names = [
f"{item.get('product_code')} {item.get('product_name')}"
for item in products if isinstance(item, dict)
]
common = result.get("common_industries")
common_text = "、".join(str(item) for item in common) if isinstance(common, list) else "无"
return (
"对比分析:" + ";".join(names)
+ f"。共同行业暴露:{common_text}。仅供分析参考,不构成交易指令。"
)
+118
View File
@@ -0,0 +1,118 @@
"""Evidence-based comparison for listed Southern Fund products."""
from collections.abc import Callable
from datetime import UTC, date, datetime
from typing import Any
from app.core.contracts import RequestContext
from app.core.product_comparison_contracts import ProductComparisonQuery
from app.infrastructure.db import SessionFactory
from app.repository.advisor_product_repository import AdvisorProductRepository
from app.repository.portfolio_analysis_repository import PortfolioAnalysisRepository
from app.service.authorization_service import AuthorizationService
from app.service.product_governance_monitor_service import SALES_INSTITUTION
class ProductComparisonService:
def __init__(self, *, session_factory: Callable[[], Any] = SessionFactory) -> None:
self.session_factory = session_factory
async def compare(
self, payload: ProductComparisonQuery, context: RequestContext
) -> dict[str, object]:
await AuthorizationService.require(context, "product-comparison:read:self")
codes = tuple(dict.fromkeys(payload.product_codes))
if len(codes) < 2:
return {"status": "products_required", "message": "至少需要两个不同的基金代码。"}
now = datetime.now(UTC).replace(tzinfo=None)
async with self.session_factory() as session:
candidates = await AdvisorProductRepository(session).authoritative_tradable_products(
now, sales_institution=SALES_INSTITUTION, fund_manager="南方基金", limit=50
)
selected = [item for item in candidates if item.product.product_code in codes]
by_code = {item.product.product_code: item for item in selected}
missing = [code for code in codes if code not in by_code]
ids = tuple(item.product.id for item in selected)
exposures = await PortfolioAnalysisRepository(session).latest_industry_exposures(
ids, date.today()
)
if missing:
return {
"status": "evidence_required",
"missing_product_codes": missing,
"message": "部分产品缺少当前有效的权威适当性或合同证据,无法完成对比。",
}
rows = [
self._product_view(by_code[code], exposures.get(by_code[code].product.id, []))
for code in codes
]
industry_sets: list[set[str]] = []
for row in rows:
raw_exposure = row.get("industry_exposure")
industry_sets.append({
str(item["industry_name"])
for item in raw_exposure
if isinstance(item, dict) and "industry_name" in item
} if isinstance(raw_exposure, list) else set())
common_industries = sorted(set.intersection(*industry_sets)) if industry_sets else []
return {
"status": "ready",
"products": rows,
"common_industries": common_industries,
"differences": self._differences(rows),
"analysis_only": True,
"disclaimer": "对比结果仅供分析参考,不构成交易指令。",
}
@staticmethod
def _product_view(candidate: Any, exposures: list[Any]) -> dict[str, object]:
product = candidate.product
contract = candidate.contract
liquidity = candidate.liquidity
return {
"product_code": product.product_code,
"product_name": product.product_name,
"product_category": product.product_category,
"suitability_risk_level": candidate.suitability.risk_level,
"fund_type": contract.fund_type,
"investment_scope": contract.investment_scope,
"performance_benchmark": contract.performance_benchmark,
"management_fee_rate_pct": str(contract.management_fee_rate_pct)
if contract.management_fee_rate_pct is not None else None,
"custodian_fee_rate_pct": str(contract.custodian_fee_rate_pct)
if contract.custodian_fee_rate_pct is not None else None,
"asset_scale_billion": str(candidate.asset_scale_billion)
if candidate.asset_scale_billion is not None else None,
"liquidity": {
"status": liquidity.status if liquidity else "unknown",
"average_daily_turnover_amount": str(liquidity.average_daily_turnover_amount)
if liquidity and liquidity.average_daily_turnover_amount is not None else None,
},
"industry_exposure": [
{"industry_name": item.industry_name,
"exposure_weight_pct": str(item.exposure_weight_pct)}
for item in exposures
],
"evidence": {
"suitability_source_url": candidate.suitability.source_url,
"contract_source_url": contract.source_url,
},
}
@staticmethod
def _differences(rows: list[dict[str, object]]) -> dict[str, list[object]]:
fields = (
"product_category", "suitability_risk_level", "fund_type",
"performance_benchmark", "management_fee_rate_pct", "custodian_fee_rate_pct",
)
return {
field: [row[field] for row in rows]
for field in fields
if len({str(row[field]) for row in rows}) > 1
}
async def product_comparison_tool(
arguments: ProductComparisonQuery, context: RequestContext
) -> dict[str, object]:
return await ProductComparisonService().compare(arguments, context)