feat:新增投顾agent和nl2sqlagent

This commit is contained in:
2026-09-13 16:19:24 +08:00
parent c80c6acac0
commit 163192bf55
122 changed files with 7488 additions and 362 deletions
+1
View File
@@ -0,0 +1 @@
"""投顾 Agent 的业务意图实现。"""
@@ -0,0 +1,97 @@
"""将意图计算结果组装为可持久化的 Agent 草稿。"""
from __future__ import annotations
from decimal import Decimal
from agent.advisor_agent.intent.rebalance import build_rebalance_plan
from agent.advisor_agent.intent.recommend import recommend_candidates
from common.common_const import (
AGENT_INTENT_REBALANCE,
AGENT_INTENT_RECOMMEND,
DRAFT_STATUS_DRAFT,
)
from service.advisor_agent.draft import build_generated_content
def _recommend_markdown(items: list[dict]) -> str:
lines = ["# 基金推荐草稿", ""]
for item in items:
lines.append(
f"- {item.get('product_name', item.get('product_code'))}"
f"({item.get('product_code')},风险等级 {item.get('risk_level')})"
)
return "\n".join(lines)
def build_recommendation_draft(
*,
customer_id: int,
advisor_id: int,
customer_risk: str,
candidates: list[dict],
relation_status: str,
memories: list[dict] | None = None,
) -> dict:
items = recommend_candidates(
customer_risk,
candidates,
memories=memories or [],
)
return {
"customer_id": customer_id,
"advisor_id": advisor_id,
"intent": AGENT_INTENT_RECOMMEND,
"title": "基金推荐草稿",
"status": DRAFT_STATUS_DRAFT,
"structured_data": {
"customer_risk": customer_risk,
"items": items,
"relation_status": relation_status,
},
"content": build_generated_content(_recommend_markdown(items)),
"disclaimer_ok": True,
}
def build_rebalance_draft(
*,
customer_id: int,
advisor_id: int,
customer_risk: str,
relation_status: str,
holdings: list[dict],
target_allocation: dict[str, int | float | Decimal],
threshold: Decimal,
candidates: list[dict],
) -> dict | None:
plan = build_rebalance_plan(
relation_status=relation_status,
customer_risk=customer_risk,
holdings=holdings,
target_allocation=target_allocation,
threshold=threshold,
candidates=candidates,
)
if plan is None:
return None
structured_data = dict(plan)
structured_data["customer_risk"] = customer_risk
content = ["# 组合调仓建议草稿", "", "## 赎回清单"]
content.extend(
f"- {item['product_code']}:{item['amount']} 元" for item in plan["sell"]
)
content.append("\n## 申购清单")
content.extend(
f"- {item['product_code']}:{item['amount']} 元" for item in plan["buy"]
)
return {
"customer_id": customer_id,
"advisor_id": advisor_id,
"intent": AGENT_INTENT_REBALANCE,
"title": "组合调仓建议草稿",
"status": DRAFT_STATUS_DRAFT,
"structured_data": structured_data,
"deviation": max((abs(value) for value in plan["deviation"].values()), default=Decimal("0")),
"content": build_generated_content("\n".join(content)),
"disclaimer_ok": True,
}
@@ -0,0 +1,61 @@
"""基金深度分析的数据整理层。"""
from __future__ import annotations
from decimal import Decimal
from typing import Iterable
def _number(value):
if isinstance(value, Decimal):
return float(value)
return value
def _display_value(value):
if value is None:
return "暂无"
if isinstance(value, float):
return f"{value:g}"
return str(value)
def _build_analysis_text(fund: dict, metrics: list[dict]) -> str:
fund_name = fund.get("fund_name") or fund.get("fund_code") or "该基金"
if not metrics:
return f"{fund_name}暂无足够业绩数据,暂无法形成完整解读。"
latest = metrics[-1]
period = _display_value(latest.get("period"))
return_rate = _display_value(latest.get("return_rate"))
max_drawdown = _display_value(latest.get("max_drawdown"))
sharpe = _display_value(latest.get("sharpe"))
return (
f"{fund_name}在{period}的历史收益率为{return_rate}%,"
f"最大回撤为{max_drawdown}%,夏普比率为{sharpe}。"
"以上仅基于历史业绩数据,不代表未来收益。"
)
def build_fund_analysis(fund: dict, performance_rows: Iterable[dict]) -> dict:
metrics = []
for row in performance_rows:
metrics.append(
{
key: _number(value)
for key, value in row.items()
}
)
return {
"fund_code": fund.get("fund_code"),
"fund_name": fund.get("fund_name"),
"risk_level": fund.get("risk_level"),
"metrics": metrics,
"analysis_text": _build_analysis_text(fund, metrics),
"chart_data": {
"periods": [row.get("period") for row in metrics],
"return_rate": [row.get("return_rate") for row in metrics],
"annual_volatility": [row.get("annual_volatility") for row in metrics],
"max_drawdown": [row.get("max_drawdown") for row in metrics],
"sharpe": [row.get("sharpe") for row in metrics],
},
}
@@ -0,0 +1,97 @@
"""投顾意图结果到草稿与事件的编排。"""
from __future__ import annotations
from decimal import Decimal
import json
from agent.advisor_agent.intent.draft_generation import (
build_rebalance_draft,
build_recommendation_draft,
)
from common.common_const import EVENT_ADVISOR_REBALANCE_DRAFT_CREATED
from service.advisor_agent.draft import create_draft
from agent.advisor_agent.llm import generate_text
async def generate_rebalance_draft(
*,
draft_repo,
publish,
customer_id: int,
advisor_id: int,
customer_risk: str,
relation_status: str,
holdings: list[dict],
target_allocation: dict[str, int | float | Decimal],
threshold: Decimal,
candidates: list[dict],
trace_id: str,
) -> dict | None:
draft_data = build_rebalance_draft(
customer_id=customer_id,
advisor_id=advisor_id,
customer_risk=customer_risk,
relation_status=relation_status,
holdings=holdings,
target_allocation=target_allocation,
threshold=threshold,
candidates=candidates,
)
if draft_data is None:
return None
draft = await create_draft(draft_repo, draft_data)
event_id = await publish(
event_name=EVENT_ADVISOR_REBALANCE_DRAFT_CREATED,
trace_id=trace_id,
trigger_user_id=advisor_id,
customer_id=customer_id,
payload={
"draft_id": draft.draft_id,
"customer_id": customer_id,
"advisor_id": advisor_id,
"deviation": float(draft.deviation or 0),
"created_at": draft.create_time.isoformat()
if draft.create_time
else None,
},
)
return {"draft": draft, "event_id": event_id}
async def generate_recommendation_draft(
*,
draft_repo,
customer_id: int,
advisor_id: int,
customer_risk: str,
relation_status: str,
candidates: list[dict],
trace_id: str,
memories: list[dict] | None = None,
llm_client=None,
llm_timeout: float = 5.0,
):
draft_data = build_recommendation_draft(
customer_id=customer_id,
advisor_id=advisor_id,
customer_risk=customer_risk,
candidates=candidates,
relation_status=relation_status,
memories=memories,
)
if llm_client is not None:
draft_data["content"] = await generate_text(
llm_client,
system_prompt="你是基金投顾助手,只生成内部投顾草稿说明,不下单、不承诺收益。",
user_prompt=(
"请根据以下候选基金和客户记忆生成简洁推荐说明:"
+ json.dumps(
{"candidates": candidates, "memories": memories or []},
ensure_ascii=False,
)
),
fallback=lambda: draft_data["content"],
timeout=llm_timeout,
)
return await create_draft(draft_repo, draft_data)
+107
View File
@@ -0,0 +1,107 @@
"""组合偏离度与调仓建议计算。"""
from __future__ import annotations
from collections import defaultdict
from decimal import Decimal, ROUND_HALF_UP
from typing import Iterable
from common.common_const import (
CUSTOMER_REL_STATUS_SIGNED,
ERR_CODE_NOT_SIGNED_REBALANCE,
)
from common.suitability import check_suitability
from utils.exceptions import ApiError
_MONEY = Decimal("0.01")
_PERCENT = Decimal("100")
def _money(value: Decimal) -> Decimal:
return value.quantize(_MONEY, rounding=ROUND_HALF_UP)
def build_rebalance_plan(
*,
relation_status: str,
customer_risk: str,
holdings: Iterable[dict],
target_allocation: dict[str, int | float | Decimal],
threshold: Decimal,
candidates: Iterable[dict],
) -> dict | None:
if relation_status != CUSTOMER_REL_STATUS_SIGNED:
raise ApiError(ERR_CODE_NOT_SIGNED_REBALANCE, "客户尚未签约,禁止生成调仓草稿")
values: dict[str, Decimal] = defaultdict(Decimal)
holdings_by_class: dict[str, list[dict]] = defaultdict(list)
for holding in holdings:
asset_class = str(holding.get("asset_class", ""))
value = Decimal(str(holding.get("market_value", 0) or 0))
values[asset_class] += value
holdings_by_class[asset_class].append(holding)
total = sum(values.values(), Decimal("0"))
if total <= 0:
return None
target = {
asset_class: Decimal(str(weight)) for asset_class, weight in target_allocation.items()
}
deviation: dict[str, Decimal] = {}
for asset_class in target:
actual = values.get(asset_class, Decimal("0")) / total * _PERCENT
deviation[asset_class] = (actual - target[asset_class]).quantize(
Decimal("0.01"), rounding=ROUND_HALF_UP
)
if not any(abs(value) > threshold for value in deviation.values()):
return None
sell: list[dict] = []
buy: list[dict] = []
for asset_class, drift in deviation.items():
if drift > threshold:
target_value = total * target[asset_class] / _PERCENT
excess = _money(values.get(asset_class, Decimal("0")) - target_value)
remaining = excess
for holding in holdings_by_class.get(asset_class, []):
amount = min(
remaining,
_money(Decimal(str(holding.get("market_value", 0) or 0))),
)
if amount > 0:
sell.append(
{
"product_code": holding.get("product_code"),
"asset_class": asset_class,
"amount": amount,
}
)
remaining -= amount
if remaining <= 0:
break
elif drift < -threshold:
target_value = total * target[asset_class] / _PERCENT
amount = _money(target_value - values.get(asset_class, Decimal("0")))
for candidate in candidates:
if candidate.get("asset_class") != asset_class:
continue
if not check_suitability(
customer_risk, candidate.get("risk_level", "")
).ok:
continue
buy.append(
{
"product_code": candidate.get("product_code"),
"asset_class": asset_class,
"amount": amount,
}
)
break
return {
"deviation": deviation,
"sell": sell,
"buy": buy,
}
+45
View File
@@ -0,0 +1,45 @@
"""基金推荐候选过滤与排序。"""
from __future__ import annotations
from collections import defaultdict
from typing import Iterable
from common.common_const import MEMORY_INFO_TYPE_OPINION
from common.suitability import check_suitability
def _opinion_bonuses(memories: Iterable[dict]) -> dict[str, float]:
bonuses: dict[str, float] = defaultdict(float)
for memory in memories:
if memory.get("info_type") != MEMORY_INFO_TYPE_OPINION:
continue
product_code = memory.get("product_code")
if product_code:
bonuses[str(product_code)] += float(memory.get("score", 0.0) or 0.0)
return bonuses
def recommend_candidates(
customer_risk: str,
candidates: Iterable[dict],
*,
memories: Iterable[dict] = (),
) -> list[dict]:
"""硬过滤不适当产品,再按业绩和主观观点排序。"""
bonuses = _opinion_bonuses(memories)
result = []
for candidate in candidates:
suitability = check_suitability(customer_risk, candidate.get("risk_level", ""))
if not suitability.ok:
continue
item = dict(candidate)
base_score = float(item.get("performance_score", 0.0) or 0.0)
item["recommendation_score"] = base_score + bonuses.get(
str(item.get("product_code")), 0.0
)
result.append(item)
return sorted(
result,
key=lambda item: item["recommendation_score"],
reverse=True,
)
+24
View File
@@ -0,0 +1,24 @@
"""投顾沟通话术安全兜底模板。"""
from __future__ import annotations
from common.common_const import (
TALK_SCENE_CUSTOMER_COMPLAINT,
TALK_SCENE_MARKET_FLUCTUATION,
TALK_SCENE_PORTFOLIO_DIVERGENCE,
TALK_SCENE_RISK_BLOCK_ORDER,
)
_TEMPLATES = {
TALK_SCENE_RISK_BLOCK_ORDER: "{name},这笔交易正在进行风险审核。请先查看审核结果,后续是否交易由您结合自身情况自行决定。",
TALK_SCENE_MARKET_FLUCTUATION: "{name},近期市场波动可能放大短期净值变化。建议先关注组合风险和自身资金安排,再审慎决定是否调整。",
TALK_SCENE_PORTFOLIO_DIVERGENCE: "{name},当前组合与既定配置基准存在偏离。我可以向您说明偏离来源和可选调整方向,具体决定请结合您的风险承受能力。",
TALK_SCENE_CUSTOMER_COMPLAINT: "{name},很抱歉给您带来不好的体验。我会记录您的问题并协助核实处理进展,具体结果以核查信息为准。",
}
def build_talk_script(scene_type: str, *, customer_name: str = "客户") -> dict:
template = _TEMPLATES.get(scene_type)
if template is None:
raise ValueError("不支持的话术场景")
return {"scene_type": scene_type, "content": template.format(name=customer_name)}