feat:新增投顾agent和nl2sqlagent
This commit is contained in:
@@ -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)
|
||||
@@ -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,
|
||||
}
|
||||
@@ -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,
|
||||
)
|
||||
@@ -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)}
|
||||
Reference in New Issue
Block a user