feat:新增投顾agent和nl2sqlagent
This commit is contained in:
@@ -0,0 +1,8 @@
|
||||
"""投顾 Agent 领域层。
|
||||
|
||||
该模块只负责生成投顾内部草稿,不负责客户触达、交易下单或交易状态回流。
|
||||
"""
|
||||
|
||||
package_name = "advisor_agent"
|
||||
|
||||
__all__ = ["package_name"]
|
||||
@@ -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
|
||||
@@ -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, "不支持的草稿操作")
|
||||
@@ -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)
|
||||
@@ -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)}
|
||||
@@ -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"]
|
||||
@@ -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"]
|
||||
@@ -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,
|
||||
}
|
||||
@@ -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,
|
||||
)
|
||||
@@ -0,0 +1,5 @@
|
||||
"""数据查询 Agent 适配层。"""
|
||||
|
||||
from .agent import DataQueryAgent
|
||||
|
||||
__all__ = ["DataQueryAgent"]
|
||||
@@ -0,0 +1,27 @@
|
||||
"""面向其他 Agent 的数据查询适配器。"""
|
||||
from __future__ import annotations
|
||||
|
||||
from uuid import uuid4
|
||||
|
||||
from nl2sql.contracts import DataQueryRequest
|
||||
from nl2sql.streaming import stream_query_events
|
||||
from service.nl2sql.query_service import execute_query
|
||||
|
||||
|
||||
class DataQueryAgent:
|
||||
"""只依赖公共查询服务,不直接调用其他 Agent 或基础设施。"""
|
||||
|
||||
async def query(self, request: DataQueryRequest, **dependencies):
|
||||
"""转发查询请求,缺省生成内部查询 ID。"""
|
||||
dependencies.setdefault("query_id", uuid4().hex)
|
||||
return await execute_query(request, **dependencies)
|
||||
|
||||
async def stream(self, request: DataQueryRequest, **dependencies):
|
||||
"""以结构化事件流转发查询,不暴露 SQL、结果行和异常消息。"""
|
||||
dependencies.setdefault("query_id", uuid4().hex)
|
||||
async for event in stream_query_events(
|
||||
request,
|
||||
query_runner=execute_query,
|
||||
**dependencies,
|
||||
):
|
||||
yield event
|
||||
Reference in New Issue
Block a user