Files
group_fqcd_jr/app/service/risk_analysis_service.py
T
张胜宇 5d0becb67d 客服 Agent 重构收口:五出口决策链 + 知识库档位隔离 + 前端入参边界(答辩演示版本)
一、客服 Agent 智能增强(正面回应"不智能、动不动就转人工")
- 决策链由 2 个出口扩到 5 个:E1 澄清 / E2 计算型 / E3 知识直返 / E4 证据约束生成 / E5 分级回退
- 转人工从"默认动作"降为最后一档 E5c,只保留 4 类白名单:
  P0 反诈 / P1 账户与个人数据 / P2 写操作与争议 / 用户明确要求人工
- 46 条金标实测(修复前 → 修复后):
  转人工率 43.5% → 10.9%;出口准确率 45.7% → 100%;事实正确率 69.6% → 100%
  禁忌违反 1 → 0;档位越权 / 无出处数字 / 误拒 四项零容忍全 0
- 安全不变量 INV-1~INV-5;零容忍规则未删,改的是挂载点
  (输出侧字面黑名单 → 检索层档位隔离 + 判定层合规词表 + 输出守护)

二、知识库:档位单点化与物理隔离
- 新增 app/core/knowledge_tier.py 作为档位规则唯一落点(G-03),
  knowledge_contracts.py 原定义块改为显式再导出(X as X,非副本)
- 档位过滤由 bool 默认值(fail-open)改为 tiers 必填集合(缺参即 TypeError)
- Milvus 侧四集合按 visibility 分区键物理隔离;双 schema 收敛为一套
- 新增 app/core/actor.py:访客三元组与匿名判定的唯一构造/判定点(G-01/G-01b)
- 新增 app/core/fund_fee_rules.py:费率计算纯函数

三、前端入参边界对齐(本轮 W11 新修,4 处"校验宽于存储")
- message 加 max_length=8000(与浮窗 widget.js 的 maxlength 一致)
- session_id 加 1—64;idempotency_key 上限 128 → 64(对齐列宽 String(64))
- feedback_type 加 max_length=32(对齐列宽 String(32))
- 8 条路径参数补 min_length=1 + max_length=64 + 字符集正则
  ({session_id} / {run_id} / {handover_id})
- 改前超限值会落到 MySQL 才失败(500);改后一律 422 AGENT_INPUT_INVALID + 字段级定位
- 新增 tests/unit/api/test_frontend_boundaries.py(33 例),含"端点表 ↔ OpenAPI 全量对照"

四、投顾模块整体清除(D4.4 / D4.5)
- 删除投顾相关 controller / schema / model / repository / service 及门户页面
- tools/portal_api_check.py 同步作废 AD003/AD005/AD011/A047 四条用例与 advisor_t 登录
  (端点与账号均已不存在,此前稳定报 3 条假红)

五、验证(提交前实测)
- pytest -q:1856 passed / 2 skipped / 0 failed
- ruff check app tools tests:19(= 基线);mypy app:2(= 基线)
- 前端接口契约体检 portal_api_check.py:38 项,通过 34,失败 0,跳过 4
- 全链路冒烟 e2e_smoke_test.py --read-only:31/31
- HTTP 全链路探针 http_probe.py:11/11 succeeded
- 跨文档一致性 _consistency.py:GATE PASS
- 真机边界复验 12 条:12/12 符合预期

六、纪律与文档
- 可改文件白名单 A-09(docs/46)与底座会签申请单 A-10(docs/47,组 1—组 4 全部受理)
- 零 DDL:未新增/修改任何表结构,89 张业务表与基线一致
- 证据留痕:docs/evidence/**(含 46 条金标 score、快照、清除与重建记录)
- 未提交(刻意排除,见提交说明):仓库内 客服agent/ 与 开发文档/ 是 2026-09-16 前的
  过期副本(Todolist 440 行 vs 权威 D2.1 1167 行),权威正本在仓库外;
  _chunks_report.txt 是 tools/build_knowledge_chunks.py 生成的本地产物
2026-09-20 14:33:30 +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]