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 业务服务。"""
+50
View File
@@ -0,0 +1,50 @@
"""投顾 Agent 审计日志写入。"""
from __future__ import annotations
import json
from sqlalchemy import text
from common.common_const import AUDIT_AGENT_CHAT_CALL, AUDIT_DRAFT_DISCARD, AUDIT_DRAFT_SAVE
def audit_action_for_path(path: str) -> str:
if path.endswith("/save"):
return AUDIT_DRAFT_SAVE
if path.endswith("/operate"):
return AUDIT_DRAFT_DISCARD
return AUDIT_AGENT_CHAT_CALL
async def write_advisor_audit(
db,
*,
user,
action: str,
target: str | None,
trace_id: str,
detail: dict | None = None,
status: str = "成功",
) -> None:
statement = text(
"""
INSERT INTO audit_log
(user_id, username, module, action, target, detail, trace_id, status)
VALUES
(:user_id, :username, :module, :action, :target, :detail, :trace_id, :status)
"""
)
await db.execute(
statement,
{
"user_id": getattr(user, "id", None),
"username": getattr(user, "username", None),
"module": "advisor_agent",
"action": action,
"target": target,
"detail": json.dumps(detail or {}, ensure_ascii=False),
"trace_id": trace_id,
"status": status,
},
)
await db.commit()
+16
View File
@@ -0,0 +1,16 @@
"""投顾 Agent 输出合规校验。"""
from __future__ import annotations
from common.common_const import ERR_CODE_LLM_ERROR
from utils.exceptions import ApiError
def find_sensitive_words(content: str, words: list[str]) -> list[str]:
return [word for word in dict.fromkeys(words) if word and word in content]
def ensure_safe_content(content: str, words: list[str]) -> bool:
matched = find_sensitive_words(content, words)
if matched:
raise ApiError(ERR_CODE_LLM_ERROR, "AI输出包含敏感或违规表述")
return True
+177
View File
@@ -0,0 +1,177 @@
"""从现有业务表聚合投顾意图所需的本地上下文。"""
from __future__ import annotations
from decimal import Decimal, InvalidOperation
from common.common_const import (
CUSTOMER_REL_STATUS_SIGNED,
SYS_KEY_REBALANCE_DEVIATION_THRESHOLD,
)
from repositories.fin_customer_profile import FinCustomerProfileRepo
from repositories.fin_holdings import FinHoldingsRepo
from repositories.fin_product import FinProductRepo
from repositories.portfolio_benchmark import PortfolioBenchmarkRepo
from repositories.risk_assessment import RiskAssessmentRepo
from repositories.sys_config import SysConfigRepo
from model.fin_product import FinProduct
from service.advisor_agent.data import to_holding_input, to_product_candidate
from service.advisor_agent.data import to_fund_performance_row
async def _load_customer_risk(db, customer_id: int, profile_repo_cls, risk_repo_cls):
assessment = await risk_repo_cls(db).get_current_by_customer(customer_id)
if assessment is not None and assessment.risk_level:
return assessment.risk_level
profile = await profile_repo_cls(db).get_by_customer_id(customer_id)
return profile.risk_level if profile is not None else None
async def load_customer_risk(
db,
*,
customer_id: int,
profile_repo_cls=FinCustomerProfileRepo,
risk_repo_cls=RiskAssessmentRepo,
) -> str | None:
return await _load_customer_risk(db, customer_id, profile_repo_cls, risk_repo_cls)
async def load_rebalance_context(
db,
*,
customer_id: int,
profile_repo_cls=FinCustomerProfileRepo,
risk_repo_cls=RiskAssessmentRepo,
holdings_repo_cls=FinHoldingsRepo,
product_repo_cls=FinProductRepo,
benchmark_repo_cls=PortfolioBenchmarkRepo,
sys_config_repo_cls=SysConfigRepo,
) -> dict | None:
"""聚合画像、持仓、在售产品和组合基准,供调仓引擎使用。
关系签约状态由调用方读取并校验,本函数只负责客户投资数据。
"""
customer_risk = await _load_customer_risk(
db, customer_id, profile_repo_cls, risk_repo_cls
)
if not customer_risk:
return None
benchmark = await benchmark_repo_cls(db).get_active_by_risk(customer_risk)
if benchmark is None:
return None
threshold = benchmark.drift_threshold
if threshold is None:
raw_threshold = await sys_config_repo_cls(db).get_value(
SYS_KEY_REBALANCE_DEVIATION_THRESHOLD
)
try:
threshold = Decimal(str(raw_threshold))
except (InvalidOperation, TypeError, ValueError):
return None
if not threshold.is_finite() or threshold < 0:
return None
product_repo = product_repo_cls(db)
products = await product_repo.list(
where=[FinProduct.status == "在售"],
limit=1000,
)
products_by_id = {product.id: product for product in products}
holdings = await holdings_repo_cls(db).list_by_customer(customer_id, status="持有中")
holding_inputs = []
for holding in holdings:
product = products_by_id.get(holding.product_id)
if product is None:
product = await product_repo.get(holding.product_id)
if product is not None:
holding_inputs.append(to_holding_input(holding, product))
return {
"customer_id": customer_id,
"customer_risk": customer_risk,
"relation_status": CUSTOMER_REL_STATUS_SIGNED,
"holdings": holding_inputs,
"target_allocation": benchmark.target_allocation,
"threshold": threshold,
"candidates": [to_product_candidate(product) for product in products],
}
async def load_fund_analysis_context(
db,
*,
fund_codes: list[str],
product_repo_cls=FinProductRepo,
performance_repo_cls=None,
) -> list[dict]:
if performance_repo_cls is None:
from repositories.fund_performance import FundPerformanceRepo
performance_repo_cls = FundPerformanceRepo
product_repo = product_repo_cls(db)
performance_repo = performance_repo_cls(db)
result = []
for code in fund_codes:
product = await product_repo.get_by_code(code)
if product is None:
continue
performance = await performance_repo.list_for_product(product.id)
result.append(
{
"fund": {
"fund_code": product.product_code,
"fund_name": product.product_name,
"risk_level": product.risk_level,
},
"performance": [to_fund_performance_row(row) for row in performance],
}
)
return result
async def load_recommendation_context(
db,
*,
customer_id: int,
profile_repo_cls=FinCustomerProfileRepo,
product_repo_cls=FinProductRepo,
performance_repo_cls=None,
risk_repo_cls=RiskAssessmentRepo,
) -> dict | None:
customer_risk = await _load_customer_risk(
db, customer_id, profile_repo_cls, risk_repo_cls
)
if not customer_risk:
return None
products = await product_repo_cls(db).list(
where=[FinProduct.status == "在售"],
limit=1000,
)
if performance_repo_cls is None:
from repositories.fund_performance import FundPerformanceRepo
performance_repo_cls = FundPerformanceRepo
performance_repo = performance_repo_cls(db)
candidates = []
for product in products:
candidate = to_product_candidate(product)
latest = None
if hasattr(performance_repo, "get_latest_for_product"):
latest = await performance_repo.get_latest_for_product(product.id)
else:
rows = await performance_repo.list_for_product(product.id)
latest = rows[-1] if rows else None
return_rate = getattr(latest, "return_rate", None)
if return_rate is not None:
candidate["performance_score"] = float(return_rate)
candidates.append(candidate)
return {
"customer_id": customer_id,
"customer_risk": customer_risk,
"candidates": candidates,
}
+39
View File
@@ -0,0 +1,39 @@
"""现有业务 ORM 到投顾意图输入的适配。"""
from __future__ import annotations
from decimal import Decimal
def _number(value):
return float(value) if isinstance(value, Decimal) else value
def to_fund_performance_row(row) -> dict:
return {
"period": getattr(row, "period", None),
"return_rate": _number(getattr(row, "return_rate", None)),
"annual_volatility": _number(getattr(row, "annual_volatility", None)),
"max_drawdown": _number(getattr(row, "max_drawdown", None)),
"sharpe": _number(getattr(row, "sharpe", None)),
}
def to_holding_input(holding, product) -> dict:
return {
"product_id": holding.product_id,
"product_code": product.product_code,
"asset_class": product.product_type,
"market_value": holding.current_value or Decimal("0"),
}
def to_product_candidate(product) -> dict:
expected_return = product.expected_return or Decimal("0")
return {
"product_id": product.id,
"product_code": product.product_code,
"product_name": product.product_name,
"asset_class": product.product_type,
"risk_level": product.risk_level,
"performance_score": float(expected_return),
}
+179
View File
@@ -0,0 +1,179 @@
"""投顾 Agent 草稿服务。"""
from __future__ import annotations
from decimal import Decimal
from typing import Any
import uuid
from common.common_const import (
DRAFT_STATUS_DISCARDED,
DRAFT_STATUS_DRAFT,
ERR_CODE_DRAFT_NOT_FOUND,
ERR_CODE_FORBIDDEN_CUSTOMER,
ERR_CODE_SUITABILITY_INVALID,
REPORT_DISCLAIMER,
)
from common.suitability import check_suitability
from service.advisor_agent.compliance import ensure_safe_content
from repositories.sensitive_word import SensitiveWordRepo
from model.advisor_draft import AdvisorDraft
from utils.exceptions import ApiError
def build_generated_content(content: str) -> str:
"""生成阶段强制补齐免责声明,避免重复拼接。"""
if REPORT_DISCLAIMER in content:
return content
return f"{content.rstrip()}\n\n{REPORT_DISCLAIMER}"
def _disclaimer_warning(content: str) -> tuple[bool, str | None]:
if REPORT_DISCLAIMER in content:
return True, None
return False, "草稿缺少完整免责声明,工作台发送前必须补齐并重新校验"
def _not_found() -> ApiError:
return ApiError(ERR_CODE_DRAFT_NOT_FOUND, "草稿不存在或者已废弃")
async def _resolve_sensitive_words(repo, explicit_words: list[str] | None) -> list[str]:
if explicit_words is not None:
return explicit_words
if hasattr(repo, "db"):
return await SensitiveWordRepo(repo.db).list_active_words()
return []
def _validate_structured_suitability(
structured_data: dict | None, customer_risk: str | None = None
) -> None:
if not structured_data:
return
customer_risk = customer_risk or structured_data.get("customer_risk")
if not customer_risk:
return
for item in structured_data.get("items", []):
result = check_suitability(customer_risk, item.get("risk_level", ""))
if not result.ok:
raise ApiError(ERR_CODE_SUITABILITY_INVALID, result.reason)
async def create_draft(repo, data: dict) -> AdvisorDraft:
content = data.get("content", "")
sensitive_words = await _resolve_sensitive_words(repo, data.get("sensitive_words"))
ensure_safe_content(content, sensitive_words)
disclaimer_ok, warning = _disclaimer_warning(content)
draft = AdvisorDraft(
draft_id=uuid.uuid4().hex,
customer_id=data["customer_id"],
advisor_id=data["advisor_id"],
intent=data["intent"],
title=data["title"],
content=content,
structured_data=data.get("structured_data"),
status=DRAFT_STATUS_DRAFT,
deviation=data.get("deviation"),
disclaimer_ok=disclaimer_ok,
warning=warning,
)
return await repo.add(draft)
def ensure_draft_owner(draft: Any, *, advisor_id: int) -> None:
if draft.advisor_id != advisor_id:
raise ApiError(ERR_CODE_FORBIDDEN_CUSTOMER, "无权操作该客户数据")
def summarize_draft(draft: Any) -> dict:
deviation = getattr(draft, "deviation", None)
if isinstance(deviation, Decimal):
deviation = float(deviation)
return {
"draft_id": draft.draft_id,
"customer_id": draft.customer_id,
"advisor_id": draft.advisor_id,
"intent": getattr(draft, "intent", None),
"title": getattr(draft, "title", None),
"status": draft.status,
"deviation": deviation,
"disclaimer_ok": bool(getattr(draft, "disclaimer_ok", False)),
"created_at": draft.create_time.isoformat()
if getattr(draft, "create_time", None)
else None,
"update_time": draft.update_time.isoformat()
if getattr(draft, "update_time", None)
else None,
}
def detail_draft(draft: Any) -> dict:
result = summarize_draft(draft)
result.update(
{
"content": draft.content,
"structured_data": getattr(draft, "structured_data", None),
"warning": getattr(draft, "warning", None),
}
)
return result
async def get_draft(repo, draft_id: str):
draft = await repo.get_by_draft_id(draft_id)
if draft is None or draft.status == DRAFT_STATUS_DISCARDED:
raise _not_found()
return draft
async def list_drafts(
repo,
*,
advisor_id: int | None = None,
customer_id: int | None = None,
status: str | None = None,
page: int = 1,
page_size: int = 20,
) -> dict:
page = max(1, page)
page_size = min(100, max(1, page_size))
total, items = await repo.list_drafts(
advisor_id=advisor_id,
customer_id=customer_id,
status=status,
limit=page_size,
offset=(page - 1) * page_size,
)
return {"total": total, "items": [summarize_draft(item) for item in items]}
async def save_draft(
repo,
draft_id: str,
*,
title: str | None = None,
content: str | None = None,
structured_data: dict | None = None,
customer_risk: str | None = None,
sensitive_words: list[str] | None = None,
):
draft = await get_draft(repo, draft_id)
if draft.status != DRAFT_STATUS_DRAFT:
raise _not_found()
if title is not None:
draft.title = title
if content is not None:
draft.content = content
if structured_data is not None:
draft.structured_data = structured_data
resolved_sensitive_words = await _resolve_sensitive_words(repo, sensitive_words)
ensure_safe_content(draft.content, resolved_sensitive_words)
_validate_structured_suitability(draft.structured_data, customer_risk)
draft.disclaimer_ok, draft.warning = _disclaimer_warning(draft.content)
saved = await repo.save(draft)
return detail_draft(saved)
async def discard_draft(repo, draft_id: str):
draft = await get_draft(repo, draft_id)
return await repo.discard(draft)