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
+8
View File
@@ -0,0 +1,8 @@
"""投顾 Agent 领域层。
该模块只负责生成投顾内部草稿,不负责客户触达、交易下单或交易状态回流。
"""
package_name = "advisor_agent"
__all__ = ["package_name"]
+34
View File
@@ -0,0 +1,34 @@
"""投顾 Agent 的角色与客户关系授权规则。"""
from __future__ import annotations
from common.common_const import (
CUSTOMER_REL_STATUS_SIGNED,
CUSTOMER_REL_STATUS_UNSIGNED,
EMPLOYEE_ROLE_ADVISOR,
ERR_CODE_FORBIDDEN_CUSTOMER,
)
from utils.exceptions import ApiError
from repositories.customer_relation import CustomerRelationRepo
def ensure_advisor_role(user) -> bool:
if (
getattr(user, "user_type", None) != "EMPLOYEE"
or getattr(user, "employee_role", None) != EMPLOYEE_ROLE_ADVISOR
):
raise ApiError(ERR_CODE_FORBIDDEN_CUSTOMER, "无权操作该客户数据")
return True
def relation_allows_access(status: str) -> bool:
return status in {CUSTOMER_REL_STATUS_UNSIGNED, CUSTOMER_REL_STATUS_SIGNED}
async def ensure_customer_access(db, *, advisor_id: int, customer_id: int):
relation = await CustomerRelationRepo(db).get_active_relation(
customer_id=customer_id,
advisor_id=advisor_id,
)
if relation is None:
raise ApiError(ERR_CODE_FORBIDDEN_CUSTOMER, "无权操作该客户数据")
return relation
+27
View File
@@ -0,0 +1,27 @@
"""投顾 Agent 草稿状态机基础规则。"""
from __future__ import annotations
from common.common_const import (
DRAFT_STATUS_DISCARDED,
DRAFT_STATUS_DRAFT,
ERR_CODE_DRAFT_NOT_FOUND,
)
class DraftStateError(Exception):
def __init__(self, code: int, message: str):
self.code = code
self.message = message
super().__init__(message)
def transition_draft_status(status: str, operation: str) -> str:
if status == DRAFT_STATUS_DRAFT and operation == "discard":
return DRAFT_STATUS_DISCARDED
if status == DRAFT_STATUS_DISCARDED:
raise DraftStateError(ERR_CODE_DRAFT_NOT_FOUND, "草稿不存在或者已废弃")
if status != DRAFT_STATUS_DRAFT:
raise DraftStateError(ERR_CODE_DRAFT_NOT_FOUND, "草稿状态无效")
if operation == "save":
return DRAFT_STATUS_DRAFT
raise DraftStateError(ERR_CODE_DRAFT_NOT_FOUND, "不支持的草稿操作")
+52
View File
@@ -0,0 +1,52 @@
"""投顾 Agent 的统一超时与降级执行器。"""
from __future__ import annotations
import asyncio
import inspect
import logging
from dataclasses import dataclass
from typing import Awaitable, Callable, TypeVar
from utils.exceptions import ApiError
T = TypeVar("T")
logger = logging.getLogger("advisor_agent.fallback")
@dataclass(frozen=True)
class FallbackResult:
value: object
degraded: bool
code: int | None = None
async def call_with_fallback(
primary: Callable[[], Awaitable[T]],
secondary: Callable[[], Awaitable[T]] | None,
*,
timeout: float,
degraded_code: int,
on_degraded: Callable[[Exception], None] | None = None,
) -> FallbackResult:
try:
return FallbackResult(
value=await asyncio.wait_for(primary(), timeout=timeout),
degraded=False,
)
except Exception as primary_error:
logger.warning("advisor dependency degraded; using fallback", exc_info=primary_error)
if on_degraded is not None:
try:
callback_result = on_degraded(primary_error)
if inspect.isawaitable(callback_result):
await callback_result
except Exception:
logger.warning("advisor degradation audit callback failed", exc_info=True)
if secondary is None:
raise ApiError(degraded_code, "Agent核心服务调用失败") from primary_error
try:
value = await asyncio.wait_for(secondary(), timeout=timeout)
except Exception as secondary_error:
raise ApiError(degraded_code, "Agent降级服务调用失败") from secondary_error
return FallbackResult(value=value, degraded=True, code=degraded_code)
+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)}
+40
View File
@@ -0,0 +1,40 @@
"""投顾 Agent 对共享 LLM 客户端的安全调用封装。"""
from __future__ import annotations
import inspect
from agent.advisor_agent.fallback import call_with_fallback
async def generate_text(
llm_client,
*,
system_prompt: str,
user_prompt: str,
fallback,
timeout: float = 5.0,
) -> str:
"""调用共享 LLM,超时或异常时返回本地安全兜底文本。"""
async def primary():
return await llm_client.chat(
[
{"role": "system", "content": system_prompt},
{"role": "user", "content": user_prompt},
]
)
async def secondary():
value = fallback()
return await value if inspect.isawaitable(value) else value
result = await call_with_fallback(
primary,
secondary,
timeout=timeout,
degraded_code=50001,
)
return str(result.value)
__all__ = ["generate_text"]
+58
View File
@@ -0,0 +1,58 @@
"""投顾 Agent 对现有统一记忆系统的适配边界。"""
from __future__ import annotations
from typing import Protocol
class MemoryProvider(Protocol):
async def recall(self, *, customer_id: int, query: str) -> list[dict]:
"""召回客户相关记忆,返回结构化记忆单元。"""
class EmptyMemoryProvider:
"""记忆系统未接入时的安全默认实现。"""
async def recall(self, *, customer_id: int, query: str) -> list[dict]:
return []
class MemoryServiceProvider:
"""将现有 MemoryService 的统一上下文转换为投顾侧记忆列表。"""
def __init__(self, memory_service, *, session_prefix: str = "advisor-agent"):
self.memory_service = memory_service
self.session_prefix = session_prefix
self.last_warnings: list[str] = []
async def recall(self, *, customer_id: int, query: str) -> list[dict]:
self.last_warnings = []
try:
context = await self.memory_service.recall(
customer_id=customer_id,
session_id=f"{self.session_prefix}:{customer_id}",
query=query,
)
except Exception as exc:
self.last_warnings = [f"advisor_memory_recall_failed:{type(exc).__name__}"]
return []
self.last_warnings.extend(getattr(context, "warnings", []) or [])
memories = getattr(context, "long_term_memories", context)
return [self._to_dict(memory) for memory in memories]
@staticmethod
def _to_dict(memory) -> dict:
if hasattr(memory, "model_dump"):
data = memory.model_dump(mode="json")
else:
data = dict(memory)
return {
"customer_id": data.get("customer_id"),
"tag": data.get("tag"),
"content": data.get("content"),
"info_type": data.get("info_type", "FACT"),
"memory_type": data.get("memory_type"),
}
__all__ = ["EmptyMemoryProvider", "MemoryProvider", "MemoryServiceProvider"]
+30
View File
@@ -0,0 +1,30 @@
"""投顾 Agent 与工作台之间的响应协议。"""
from __future__ import annotations
from typing import Any
from common.common_const import ERR_CODE_OK
def agent_success(data: Any = None, *, trace_id: str | None = None) -> dict:
return {
"code": ERR_CODE_OK,
"message": "success",
"data": data,
"trace_id": trace_id,
}
def agent_failure(
code: int,
message: str,
data: Any = None,
*,
trace_id: str | None = None,
) -> dict:
return {
"code": code,
"message": message,
"data": data,
"trace_id": trace_id,
}
+36
View File
@@ -0,0 +1,36 @@
"""投顾 Agent 运行时依赖组装。"""
from __future__ import annotations
from dataclasses import dataclass
from agent.advisor_agent.memory import (
EmptyMemoryProvider,
MemoryProvider,
MemoryServiceProvider,
)
@dataclass
class AdvisorAgentRuntime:
memory_provider: MemoryProvider
llm_client: object | None = None
graph_tool: object | None = None
redis: object | None = None
def build_default_runtime(
*,
memory_provider: MemoryProvider | None = None,
llm_client: object | None = None,
graph_tool: object | None = None,
redis: object | None = None,
memory_service: object | None = None,
) -> AdvisorAgentRuntime:
if memory_provider is None and memory_service is not None:
memory_provider = MemoryServiceProvider(memory_service)
return AdvisorAgentRuntime(
memory_provider=memory_provider or EmptyMemoryProvider(),
llm_client=llm_client,
graph_tool=graph_tool,
redis=redis,
)