Files
group_fqcd_jr/app/service/risk_analysis_service.py
T
lzf_0626 f72a545c39 refactor: 品牌统一为「南方财富」(项目方定)
背景:品牌名此前三处不一致 —— 后端 Agent 自称「奶龙基金」(customer_service_rules
的防诈骗/转人工话术、risk_agent 与 risk_analysis_service 的系统提示词)、前端全站
「南方财富」、闲聊提示词与知识素材「南方科技」。docs/36 已把它登记为"上报项目方后
待定",现按项目方决定统一为「南方财富」。

改动:
- app/core/customer_service_rules.py:P0 防诈骗话术与 P2 转人工话术里的品牌名
- app/service/agent/implementations/customer_service.py:COMPANY 常量,
  以及那处引用实测样本的注释(改为不绑定具体品牌名,免得下次改名又过时)
- app/service/agent/implementations/risk_agent.py:docstring、自我介绍、system prompt
- app/service/risk_analysis_service.py:SYSTEM_PROMPT
- app/worker/risk_scan_scheduler.py:--help 描述
- app/static/index.html(旧联调页 4 处)、portal/employee-risk/dashboard/index.html
- tests/unit/api/test_customer_service_test_page.py:同步断言(它断言的正是页面里的品牌名)
- tools/publish_chitchat_prompt.py:SYSTEM_PROMPT 改品牌;并修掉"存在即跳过"的检查
  —— 原来只判当前版本有没有这一行,于是改了文案也发不出去(脚本打印"无需发布"直接
  退出),没有任何提示。改为比对 system_prompt/user_prompt_template 内容。

闲聊提示词已重发为 release 308 / v5,生效内容为"你是南方财富的智能客服助手…"。

刻意未动:
- fin_product.fund_manager = "南方基金" —— 它被 market_quote_sync_service 与
  product_history_sync_service 当过滤条件使用,改名会让同步链路查不到产品
- knowledge_search_service.py 注释里引用的知识库实际标题「南方科技有限公司…」
- docs/客服docs 下的历史素材与 docs/ 下的过程记录(属历史留痕)

⚠️ Milvus 里的知识条目仍写「奶龙基金」(RAG-*/NF-*)与「南方科技」(PROD-*),
属知识数据,需重灌才能统一;本轮不动。

同时:
- docs/40 把品牌条目标为已处理,并补上知识库缺口的现状
- 新增 docs/42-场内基金知识条目草稿.md:按 fin_product 的 20 只产品生成,
  含通用交易规则与产品清单;费率等缺失字段一律标"以交易页面为准",未编造数字。
  **该文件是草稿,未入库**,待审核后走 POST /api/v1/knowledge/documents 灌库。
2026-09-13 19:17:56 +08:00

206 lines
8.8 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""预警研判、回访话术和工单摘要服务。"""
from __future__ import annotations
import json
import logging
from datetime import UTC, datetime
from typing import Any
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from app.core.contracts import RequestContext
from app.core.errors import ForbiddenAgentError, GenericResourceNotFoundError
from app.infrastructure.db import SessionFactory
from app.model.audit import InteractionAudit
from app.model.fund import FundRiskAlert
from app.repository.risk_repository import RiskRepository
from app.service.authorization_service import AuthorizationService
from app.service.risk_query_service import scope_from_context
OUTPUT_TYPES = ("预警研判", "回访话术", "工单摘要")
FORBIDDEN_ACTION_CLAIMS = (
"我已确认接收", "我已关闭", "我已升级", "我已冻结", "我已放行",
"已为您确认接收", "已为您关闭", "已为您升级",
)
SYSTEM_PROMPT = "你是南方财富风控智能助手,只能基于已给证据做研判辅助,不得自动处置交易或预警。"
logger = logging.getLogger(__name__)
class RiskAnalysisService:
def __init__(
self,
session: AsyncSession,
*,
repository: RiskRepository | None = None,
model_service: Any | None = None,
endpoint_resolver: Any | None = None,
) -> None:
self.session = session
self.repository = repository
self.model_service = model_service
self.endpoint_resolver = endpoint_resolver
@classmethod
async def generate_for_context(
cls,
context: RequestContext,
alert_no: str,
output_type: str,
) -> dict[str, Any]:
async with SessionFactory() as session:
return await cls(session).generate(context, alert_no, output_type)
async def generate(
self,
context: RequestContext,
alert_no: str,
output_type: str,
) -> dict[str, Any]:
if output_type not in OUTPUT_TYPES:
raise ValueError("不支持的预警分析类型")
await AuthorizationService.require(context, "risk:alert:read")
repository = self.repository or RiskRepository(
self.session,
scope=scope_from_context(context),
)
detail = await repository.get_alert_detail(alert_no)
if detail is None:
raise GenericResourceNotFoundError("预警不存在")
content, source = await self._generate_content(output_type, detail.to_dict())
await self._save_result(context, alert_no, output_type, content, source)
return {"type": output_type, "content": content, "source": source}
async def _generate_content(
self,
output_type: str,
detail: dict[str, Any],
) -> tuple[str, str]:
model_service = self.model_service
resolver = self.endpoint_resolver
if model_service is None or resolver is None:
from app.service.agent.bootstrap import get_model_service
from app.service.model_gateway import DatabaseModelEndpointResolver
model_service = model_service or get_model_service()
resolver = resolver or DatabaseModelEndpointResolver()
try:
endpoints = await resolver.resolve(
agent_type="risk",
task_type={
"预警研判": "risk_analysis",
"回访话术": "risk_script",
"工单摘要": "risk_summary",
}[output_type],
)
prompt = (
f"{SYSTEM_PROMPT}\n{_task_instruction(output_type)}\n"
f"证据如下:{json.dumps(detail, ensure_ascii=False, default=str)}"
)
execution = await model_service.generate(endpoints, prompt, max_attempts=2)
content = execution.text.strip()
if (
content
and len(content) <= 6000
and not any(claim in content for claim in FORBIDDEN_ACTION_CLAIMS)
):
return content, "模型"
except Exception:
logger.exception("%s模型生成失败,已使用模板降级输出", output_type)
return self._fallback(output_type, detail), "模板降级输出"
async def _save_result(
self,
context: RequestContext,
alert_no: str,
output_type: str,
content: str,
source: str,
) -> None:
statement = (
select(FundRiskAlert)
.where(FundRiskAlert.alert_no == alert_no)
.with_for_update()
)
if context.data_scope != "all":
if not context.customer_ids:
raise ForbiddenAgentError("无权访问当前预警")
statement = statement.where(
FundRiskAlert.customer_id.in_(
tuple(int(customer_id) for customer_id in context.customer_ids)
)
)
alert = await self.session.scalar(statement)
if alert is None:
raise GenericResourceNotFoundError("预警不存在")
now = datetime.now(UTC).replace(tzinfo=None)
analysis = dict(alert.ai_analysis or {})
analysis[output_type] = {
"type": output_type,
"content": content,
"source": source,
"generated_at": now.isoformat(),
}
alert.ai_analysis = analysis
alert.updated_at = now
self.session.add(InteractionAudit(
actor_type="agent",
actor_id=int(context.user_id),
target_customer_id=alert.customer_id,
portal="api",
action_type="risk_ai_analysis_generated",
detail={"alert_no": alert_no, "output_type": output_type, "source": source},
created_at=now,
))
await self.session.commit()
@staticmethod
def _fallback(output_type: str, detail: dict[str, Any]) -> str:
alert = detail.get("alert") or {}
customer = detail.get("customer") or {}
transaction = detail.get("transaction") or {}
customer_name = customer.get("name") or "当前客户"
customer_no = customer.get("customer_no") or "-"
amount = transaction.get("amount") or "-"
rules = ",".join(alert.get("rule_codes") or []) or "-"
evidence = alert.get("evidence_summary") or "暂无证据摘要"
if output_type == "回访话术":
return (
f"回访对象:{customer_name}({customer_no})\n"
"开场说明:您好,我们在例行风控复核中关注到您近期账户交易存在需要核实的情况,"
"本次沟通仅用于确认交易意愿并完善留痕。\n"
f"核实问题:请确认近期交易金额 {amount} 元是否由您本人发起;"
"请说明资金入金和赎回用途;请确认是否本人常用设备操作。\n"
f"风险提示:本次预警命中 {rules},核心证据为:{evidence}。\n"
"留痕要求:请记录客户确认结果、异常解释、回访问答时间,并提交风控专员复核。"
)
if output_type == "工单摘要":
return (
f"工单标题:{alert.get('alert_type') or '风险预警'}人工复核工单\n"
f"客户信息:{customer_name}({customer_no})\n"
f"命中规则:{rules}\n"
f"关键证据:{evidence}\n"
f"风险等级:{alert.get('risk_level') or '-'}\n"
"建议动作:分派风控专员核验证据链,补充客户回访记录,"
"确认后选择继续调查、升级或关闭误报。\n"
f"处理时限:{alert.get('due_time') or '按风险等级时限处理'}"
)
return (
f"风险结论:{alert.get('alert_type') or '风险预警'}命中 {rules},建议进入人工复核。\n"
f"核心证据:{evidence}。\n"
f"客户特征:{customer_name}({customer_no}),"
f"风险等级 {customer.get('risk_level') or '-'}。\n"
"复核重点:核实资金来源和赎回用途,检查是否本人操作及设备是否异常,"
"确认交易是否符合客户历史行为。\n"
"处置建议:由风控专员人工复核并留痕,必要时发起客户回访或升级处理。"
)
def _task_instruction(output_type: str) -> str:
return {
"预警研判": "请生成预警研判,输出风险结论、核心证据、复核重点和处置建议。",
"回访话术": "请生成客户回访话术,输出开场说明、核实问题、风险提示和留痕提醒。",
"工单摘要": "请生成工单摘要,输出工单标题、命中规则、关键证据、建议动作和处理时限。",
}[output_type]