feat:新增投顾agent和nl2sqlagent
This commit is contained in:
@@ -0,0 +1 @@
|
||||
"""投顾 Agent 业务服务。"""
|
||||
@@ -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()
|
||||
@@ -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
|
||||
@@ -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,
|
||||
}
|
||||
@@ -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),
|
||||
}
|
||||
@@ -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)
|
||||
Reference in New Issue
Block a user