feat: 扩展风险规则体系、审批动态路由与预算中心列表化改造

- 新增 25+ 条风险规则(预算/报销/申请/通用类),完善风险规则模拟与反馈发布机制
- 引入费用审批动态路由、平台风险分级、预审与风险阶段管理
- 预算中心列表化改造,优化票据夹仪表盘与数字员工工作看板
- 新增 Hermes 风险线索收集器、Agent 链路追踪中心
- 扩展数字员工能力库(18 个领域 Skill)与交通费用自动预估
- 完善报销申请快速预览、权限控制与前端测试覆盖
This commit is contained in:
caoxiaozhu
2026-06-01 17:07:14 +08:00
parent 7989f3a159
commit 92444e7eae
285 changed files with 25075 additions and 2986 deletions

View File

@@ -0,0 +1,165 @@
{
"schema_version": "2.0",
"rule_code": "risk.application.large_expense_without_preapproval",
"name": "大额费用未事前申请",
"description": "达到财务制度中大额标准的费用,未找到有效事前申请即进入报销。",
"enabled": true,
"requires_attachment": false,
"risk_dimension": "expense_control_demo",
"risk_category": "申请前置",
"ontology_signal": "application_required",
"evaluator": "template_rule",
"template_key": "keyword_match_v1",
"finance_rule_code": "finance.preapproval.policy",
"finance_rule_sheet": "费用申请前置规则",
"business_stage": [
"reimbursement"
],
"expense_types": [
"all"
],
"budget_required": true,
"applies_to": {
"domains": [
"expense"
],
"expense_types": [
"all"
],
"business_stages": [
"reimbursement"
]
},
"inputs": {
"fields": [
{
"key": "claim.amount",
"label": "报销金额",
"type": "number",
"source": "claim"
},
{
"key": "claim.expense_type",
"label": "费用类型",
"type": "enum",
"source": "claim"
},
{
"key": "claim.department_name",
"label": "部门",
"type": "text",
"source": "claim"
},
{
"key": "claim.reason",
"label": "事由",
"type": "text",
"source": "claim"
},
{
"key": "item.item_reason",
"label": "明细说明",
"type": "text",
"source": "item"
},
{
"key": "application.id",
"label": "申请单",
"type": "text",
"source": "application"
},
{
"key": "application.status",
"label": "申请状态",
"type": "enum",
"source": "application"
},
{
"key": "application.approved_amount",
"label": "申请审批金额",
"type": "number",
"source": "application"
},
{
"key": "application.expense_type",
"label": "申请费用类型",
"type": "enum",
"source": "application"
},
{
"key": "application.department_name",
"label": "申请部门",
"type": "text",
"source": "application"
}
]
},
"params": {
"template_key": "keyword_match_v1",
"field_keys": [
"claim.amount",
"claim.expense_type",
"claim.department_name",
"claim.reason",
"item.item_reason",
"application.id",
"application.status",
"application.approved_amount",
"application.expense_type",
"application.department_name"
],
"search_fields": [
"claim.reason",
"item.item_reason",
"claim.expense_type"
],
"keywords": [
"大额费用",
"未申请",
"先申请后报销"
],
"condition_summary": "金额达到大额阈值且缺少已通过申请单时触发。",
"finance_rule_code": "finance.preapproval.policy",
"finance_rule_sheet": "费用申请前置规则",
"business_stage": [
"reimbursement"
],
"expense_types": [
"all"
],
"budget_required": true
},
"outcomes": {
"pass": {
"severity": "none",
"action": "continue"
},
"fail": {
"severity": "high",
"action": "manual_review",
"risk_score": 86
}
},
"metadata": {
"owner": "风控与审计部",
"stability": "platform",
"source_ref": "费用管控 Demo 风险规则库",
"created_at": "2026-05-31T00:10:41.805274+00:00",
"created_by": "system",
"risk_score": 86,
"risk_level": "high",
"rule_title": "大额费用未事前申请",
"finance_rule_code": "finance.preapproval.policy",
"finance_rule_sheet": "费用申请前置规则",
"business_stage": [
"reimbursement"
],
"expense_types": [
"all"
],
"budget_required": true
},
"severity": "high",
"risk_score": 86,
"risk_level": "high"
}

View File

@@ -3,7 +3,7 @@
"rule_code": "risk.application.marketing_without_campaign",
"name": "市场推广费无活动申请",
"description": "市场活动、投放、展会等推广费用,缺少已审批的活动申请或投放方案。",
"enabled": true,
"enabled": false,
"requires_attachment": false,
"risk_dimension": "expense_control_demo",
"risk_category": "申请前置",

View File

@@ -0,0 +1,172 @@
{
"schema_version": "2.0",
"rule_code": "risk.application.meal_high_value_without_preapproval",
"name": "大额业务招待未申请",
"description": "业务招待金额或人均金额超过制度阈值但未事前审批。",
"enabled": true,
"requires_attachment": false,
"risk_dimension": "expense_control_demo",
"risk_category": "申请前置",
"ontology_signal": "application_required",
"evaluator": "template_rule",
"template_key": "keyword_match_v1",
"finance_rule_code": "expense.application.policy",
"finance_rule_sheet": "费用申请前置规则",
"business_stage": [
"reimbursement"
],
"expense_types": [
"meal"
],
"budget_required": true,
"applies_to": {
"domains": [
"expense"
],
"expense_types": [
"meal"
],
"business_stages": [
"reimbursement"
]
},
"inputs": {
"fields": [
{
"key": "claim.amount",
"label": "报销金额",
"type": "number",
"source": "claim"
},
{
"key": "claim.expense_type",
"label": "费用类型",
"type": "enum",
"source": "claim"
},
{
"key": "claim.department_name",
"label": "部门",
"type": "text",
"source": "claim"
},
{
"key": "claim.reason",
"label": "事由",
"type": "text",
"source": "claim"
},
{
"key": "item.item_reason",
"label": "明细说明",
"type": "text",
"source": "item"
},
{
"key": "application.id",
"label": "申请单",
"type": "text",
"source": "application"
},
{
"key": "application.status",
"label": "申请状态",
"type": "enum",
"source": "application"
},
{
"key": "application.approved_amount",
"label": "申请审批金额",
"type": "number",
"source": "application"
},
{
"key": "application.expense_type",
"label": "申请费用类型",
"type": "enum",
"source": "application"
},
{
"key": "application.department_name",
"label": "申请部门",
"type": "text",
"source": "application"
},
{
"key": "material.attendee_list_uploaded",
"label": "参与人清单已上传",
"type": "boolean",
"source": "material"
}
]
},
"params": {
"template_key": "keyword_match_v1",
"field_keys": [
"claim.amount",
"claim.expense_type",
"claim.department_name",
"claim.reason",
"item.item_reason",
"application.id",
"application.status",
"application.approved_amount",
"application.expense_type",
"application.department_name",
"material.attendee_list_uploaded"
],
"search_fields": [
"claim.reason",
"item.item_reason",
"claim.expense_type"
],
"keywords": [
"业务招待",
"人均超标",
"未申请"
],
"condition_summary": "业务招待金额超过申请阈值且没有通过申请时触发。",
"finance_rule_code": "expense.application.policy",
"finance_rule_sheet": "费用申请前置规则",
"business_stage": [
"reimbursement"
],
"expense_types": [
"meal"
],
"budget_required": true
},
"outcomes": {
"pass": {
"severity": "none",
"action": "continue"
},
"fail": {
"severity": "high",
"action": "manual_review",
"risk_score": 84
}
},
"metadata": {
"owner": "风控与审计部",
"stability": "platform",
"source_ref": "费用管控 Demo 风险规则库",
"created_at": "2026-05-31T00:10:41.818641+00:00",
"created_by": "system",
"risk_score": 84,
"risk_level": "high",
"rule_title": "大额业务招待未申请",
"finance_rule_code": "expense.application.policy",
"finance_rule_sheet": "费用申请前置规则",
"business_stage": [
"reimbursement"
],
"expense_types": [
"meal"
],
"budget_required": true
},
"severity": "high",
"risk_score": 84,
"risk_level": "high"
}

View File

@@ -0,0 +1,165 @@
{
"schema_version": "2.0",
"rule_code": "risk.application.office_bulk_without_purchase",
"name": "办公用品大额采购未申请",
"description": "批量办公用品或设备采购达到阈值但未走采购申请。",
"enabled": true,
"requires_attachment": false,
"risk_dimension": "expense_control_demo",
"risk_category": "申请前置",
"ontology_signal": "application_required",
"evaluator": "template_rule",
"template_key": "keyword_match_v1",
"finance_rule_code": "expense.application.policy",
"finance_rule_sheet": "费用申请前置规则",
"business_stage": [
"reimbursement"
],
"expense_types": [
"office"
],
"budget_required": true,
"applies_to": {
"domains": [
"expense"
],
"expense_types": [
"office"
],
"business_stages": [
"reimbursement"
]
},
"inputs": {
"fields": [
{
"key": "claim.amount",
"label": "报销金额",
"type": "number",
"source": "claim"
},
{
"key": "claim.expense_type",
"label": "费用类型",
"type": "enum",
"source": "claim"
},
{
"key": "claim.department_name",
"label": "部门",
"type": "text",
"source": "claim"
},
{
"key": "claim.reason",
"label": "事由",
"type": "text",
"source": "claim"
},
{
"key": "item.item_reason",
"label": "明细说明",
"type": "text",
"source": "item"
},
{
"key": "application.id",
"label": "申请单",
"type": "text",
"source": "application"
},
{
"key": "application.status",
"label": "申请状态",
"type": "enum",
"source": "application"
},
{
"key": "application.approved_amount",
"label": "申请审批金额",
"type": "number",
"source": "application"
},
{
"key": "application.expense_type",
"label": "申请费用类型",
"type": "enum",
"source": "application"
},
{
"key": "application.department_name",
"label": "申请部门",
"type": "text",
"source": "application"
}
]
},
"params": {
"template_key": "keyword_match_v1",
"field_keys": [
"claim.amount",
"claim.expense_type",
"claim.department_name",
"claim.reason",
"item.item_reason",
"application.id",
"application.status",
"application.approved_amount",
"application.expense_type",
"application.department_name"
],
"search_fields": [
"claim.reason",
"item.item_reason",
"claim.expense_type"
],
"keywords": [
"办公采购",
"大额办公用品",
"采购申请"
],
"condition_summary": "办公用品单次金额达到采购阈值且缺少采购申请时触发。",
"finance_rule_code": "expense.application.policy",
"finance_rule_sheet": "费用申请前置规则",
"business_stage": [
"reimbursement"
],
"expense_types": [
"office"
],
"budget_required": true
},
"outcomes": {
"pass": {
"severity": "none",
"action": "continue"
},
"fail": {
"severity": "medium",
"action": "manual_review",
"risk_score": 78
}
},
"metadata": {
"owner": "风控与审计部",
"stability": "platform",
"source_ref": "费用管控 Demo 风险规则库",
"created_at": "2026-05-31T00:10:41.811910+00:00",
"created_by": "system",
"risk_score": 78,
"risk_level": "medium",
"rule_title": "办公用品大额采购未申请",
"finance_rule_code": "expense.application.policy",
"finance_rule_sheet": "费用申请前置规则",
"business_stage": [
"reimbursement"
],
"expense_types": [
"office"
],
"budget_required": true
},
"severity": "medium",
"risk_score": 78,
"risk_level": "medium"
}

View File

@@ -0,0 +1,165 @@
{
"schema_version": "2.0",
"rule_code": "risk.application.travel_large_without_preapproval",
"name": "大额差旅未申请",
"description": "多人出差、长周期出差或高金额差旅报销缺少出差申请。",
"enabled": true,
"requires_attachment": false,
"risk_dimension": "expense_control_demo",
"risk_category": "申请前置",
"ontology_signal": "application_required",
"evaluator": "template_rule",
"template_key": "keyword_match_v1",
"finance_rule_code": "rule.expense.company_travel_expense_reimbursement",
"finance_rule_sheet": "差旅住宿费标准",
"business_stage": [
"reimbursement"
],
"expense_types": [
"travel"
],
"budget_required": true,
"applies_to": {
"domains": [
"expense"
],
"expense_types": [
"travel"
],
"business_stages": [
"reimbursement"
]
},
"inputs": {
"fields": [
{
"key": "claim.amount",
"label": "报销金额",
"type": "number",
"source": "claim"
},
{
"key": "claim.expense_type",
"label": "费用类型",
"type": "enum",
"source": "claim"
},
{
"key": "claim.department_name",
"label": "部门",
"type": "text",
"source": "claim"
},
{
"key": "claim.reason",
"label": "事由",
"type": "text",
"source": "claim"
},
{
"key": "item.item_reason",
"label": "明细说明",
"type": "text",
"source": "item"
},
{
"key": "application.id",
"label": "申请单",
"type": "text",
"source": "application"
},
{
"key": "application.status",
"label": "申请状态",
"type": "enum",
"source": "application"
},
{
"key": "application.approved_amount",
"label": "申请审批金额",
"type": "number",
"source": "application"
},
{
"key": "application.expense_type",
"label": "申请费用类型",
"type": "enum",
"source": "application"
},
{
"key": "application.department_name",
"label": "申请部门",
"type": "text",
"source": "application"
}
]
},
"params": {
"template_key": "keyword_match_v1",
"field_keys": [
"claim.amount",
"claim.expense_type",
"claim.department_name",
"claim.reason",
"item.item_reason",
"application.id",
"application.status",
"application.approved_amount",
"application.expense_type",
"application.department_name"
],
"search_fields": [
"claim.reason",
"item.item_reason",
"claim.expense_type"
],
"keywords": [
"差旅申请",
"大额差旅",
"未申请"
],
"condition_summary": "差旅金额达到大额阈值且缺少有效出差申请时触发。",
"finance_rule_code": "rule.expense.company_travel_expense_reimbursement",
"finance_rule_sheet": "差旅住宿费标准",
"business_stage": [
"reimbursement"
],
"expense_types": [
"travel"
],
"budget_required": true
},
"outcomes": {
"pass": {
"severity": "none",
"action": "continue"
},
"fail": {
"severity": "high",
"action": "manual_review",
"risk_score": 82
}
},
"metadata": {
"owner": "风控与审计部",
"stability": "platform",
"source_ref": "费用管控 Demo 风险规则库",
"created_at": "2026-05-31T00:10:41.826264+00:00",
"created_by": "system",
"risk_score": 82,
"risk_level": "high",
"rule_title": "大额差旅未申请",
"finance_rule_code": "rule.expense.company_travel_expense_reimbursement",
"finance_rule_sheet": "差旅住宿费标准",
"business_stage": [
"reimbursement"
],
"expense_types": [
"travel"
],
"budget_required": true
},
"severity": "high",
"risk_score": 82,
"risk_level": "high"
}

View File

@@ -18,17 +18,7 @@
"budget_execution"
],
"expense_types": [
"travel",
"hotel",
"transport",
"meal",
"meeting",
"marketing",
"office",
"training",
"software",
"communication",
"welfare"
"all"
],
"budget_required": true,
"applies_to": {
@@ -36,17 +26,7 @@
"expense"
],
"expense_types": [
"travel",
"hotel",
"transport",
"meal",
"meeting",
"marketing",
"office",
"training",
"software",
"communication",
"welfare"
"all"
],
"business_stages": [
"expense_application",
@@ -68,17 +48,65 @@
"type": "enum",
"source": "claim"
},
{
"key": "claim.department_name",
"label": "部门",
"type": "text",
"source": "claim"
},
{
"key": "claim.reason",
"label": "事由",
"type": "text",
"source": "claim"
},
{
"key": "item.item_reason",
"label": "明细说明",
"type": "text",
"source": "item"
},
{
"key": "budget.line_id",
"label": "预算行",
"type": "text",
"source": "budget"
},
{
"key": "budget.available_amount",
"label": "预算可用金额",
"type": "number",
"source": "budget"
},
{
"key": "budget.used_rate",
"label": "预算使用率",
"type": "number",
"source": "budget"
},
{
"key": "budget.status",
"label": "预算状态",
"type": "enum",
"source": "budget"
},
{
"key": "budget.department_name",
"label": "预算部门",
"type": "text",
"source": "budget"
},
{
"key": "budget.quarter",
"label": "预算季度",
"type": "text",
"source": "budget"
},
{
"key": "budget.project_code",
"label": "预算项目",
"type": "text",
"source": "budget"
}
]
},
@@ -117,17 +145,7 @@
"budget_execution"
],
"expense_types": [
"travel",
"hotel",
"transport",
"meal",
"meeting",
"marketing",
"office",
"training",
"software",
"communication",
"welfare"
"all"
],
"budget_required": true
},
@@ -146,7 +164,7 @@
"owner": "风控与审计部",
"stability": "platform",
"source_ref": "费用管控 Demo 风险规则库",
"created_at": "2026-05-30T00:00:00Z",
"created_at": "2026-05-31T00:10:41.751292+00:00",
"created_by": "system",
"risk_score": 88,
"risk_level": "high",
@@ -159,17 +177,7 @@
"budget_execution"
],
"expense_types": [
"travel",
"hotel",
"transport",
"meal",
"meeting",
"marketing",
"office",
"training",
"software",
"communication",
"welfare"
"all"
],
"budget_required": true
},

View File

@@ -0,0 +1,187 @@
{
"schema_version": "2.0",
"rule_code": "risk.budget.consume_without_release",
"name": "预算占用未释放",
"description": "申请取消、退回或驳回后,预算占用未释放导致后续可用预算失真。",
"enabled": true,
"requires_attachment": false,
"risk_dimension": "expense_control_demo",
"risk_category": "预算管控",
"ontology_signal": "budget_over_limit",
"evaluator": "template_rule",
"template_key": "keyword_match_v1",
"finance_rule_code": "budget.execution.policy",
"finance_rule_sheet": "预算执行规则",
"business_stage": [
"expense_application",
"reimbursement",
"budget_execution"
],
"expense_types": [
"all"
],
"budget_required": true,
"applies_to": {
"domains": [
"expense"
],
"expense_types": [
"all"
],
"business_stages": [
"expense_application",
"reimbursement",
"budget_execution"
]
},
"inputs": {
"fields": [
{
"key": "claim.amount",
"label": "报销金额",
"type": "number",
"source": "claim"
},
{
"key": "claim.expense_type",
"label": "费用类型",
"type": "enum",
"source": "claim"
},
{
"key": "claim.department_name",
"label": "部门",
"type": "text",
"source": "claim"
},
{
"key": "claim.reason",
"label": "事由",
"type": "text",
"source": "claim"
},
{
"key": "item.item_reason",
"label": "明细说明",
"type": "text",
"source": "item"
},
{
"key": "budget.line_id",
"label": "预算行",
"type": "text",
"source": "budget"
},
{
"key": "budget.available_amount",
"label": "预算可用金额",
"type": "number",
"source": "budget"
},
{
"key": "budget.used_rate",
"label": "预算使用率",
"type": "number",
"source": "budget"
},
{
"key": "budget.status",
"label": "预算状态",
"type": "enum",
"source": "budget"
},
{
"key": "budget.department_name",
"label": "预算部门",
"type": "text",
"source": "budget"
},
{
"key": "budget.quarter",
"label": "预算季度",
"type": "text",
"source": "budget"
},
{
"key": "budget.project_code",
"label": "预算项目",
"type": "text",
"source": "budget"
}
]
},
"params": {
"template_key": "keyword_match_v1",
"field_keys": [
"claim.amount",
"claim.expense_type",
"claim.department_name",
"claim.reason",
"item.item_reason",
"budget.line_id",
"budget.available_amount",
"budget.used_rate",
"budget.status",
"budget.department_name",
"budget.quarter",
"budget.project_code"
],
"search_fields": [
"claim.reason",
"item.item_reason",
"claim.expense_type"
],
"keywords": [
"占用未释放",
"退回未释放",
"预算释放"
],
"condition_summary": "申请非有效状态但仍存在预算占用时触发。",
"finance_rule_code": "budget.execution.policy",
"finance_rule_sheet": "预算执行规则",
"business_stage": [
"expense_application",
"reimbursement",
"budget_execution"
],
"expense_types": [
"all"
],
"budget_required": true
},
"outcomes": {
"pass": {
"severity": "none",
"action": "continue"
},
"fail": {
"severity": "medium",
"action": "manual_review",
"risk_score": 72
}
},
"metadata": {
"owner": "风控与审计部",
"stability": "platform",
"source_ref": "费用管控 Demo 风险规则库",
"created_at": "2026-05-31T00:10:41.798394+00:00",
"created_by": "system",
"risk_score": 72,
"risk_level": "medium",
"rule_title": "预算占用未释放",
"finance_rule_code": "budget.execution.policy",
"finance_rule_sheet": "预算执行规则",
"business_stage": [
"expense_application",
"reimbursement",
"budget_execution"
],
"expense_types": [
"all"
],
"budget_required": true
},
"severity": "medium",
"risk_score": 72,
"risk_level": "medium"
}

View File

@@ -0,0 +1,187 @@
{
"schema_version": "2.0",
"rule_code": "risk.budget.cross_department_without_authorization",
"name": "跨部门预算未授权",
"description": "报销部门与预算归属部门不一致,且没有跨部门预算授权。",
"enabled": true,
"requires_attachment": false,
"risk_dimension": "expense_control_demo",
"risk_category": "预算管控",
"ontology_signal": "budget_over_limit",
"evaluator": "template_rule",
"template_key": "keyword_match_v1",
"finance_rule_code": "budget.execution.policy",
"finance_rule_sheet": "预算执行规则",
"business_stage": [
"expense_application",
"reimbursement",
"budget_execution"
],
"expense_types": [
"all"
],
"budget_required": true,
"applies_to": {
"domains": [
"expense"
],
"expense_types": [
"all"
],
"business_stages": [
"expense_application",
"reimbursement",
"budget_execution"
]
},
"inputs": {
"fields": [
{
"key": "claim.amount",
"label": "报销金额",
"type": "number",
"source": "claim"
},
{
"key": "claim.expense_type",
"label": "费用类型",
"type": "enum",
"source": "claim"
},
{
"key": "claim.department_name",
"label": "部门",
"type": "text",
"source": "claim"
},
{
"key": "claim.reason",
"label": "事由",
"type": "text",
"source": "claim"
},
{
"key": "item.item_reason",
"label": "明细说明",
"type": "text",
"source": "item"
},
{
"key": "budget.line_id",
"label": "预算行",
"type": "text",
"source": "budget"
},
{
"key": "budget.available_amount",
"label": "预算可用金额",
"type": "number",
"source": "budget"
},
{
"key": "budget.used_rate",
"label": "预算使用率",
"type": "number",
"source": "budget"
},
{
"key": "budget.status",
"label": "预算状态",
"type": "enum",
"source": "budget"
},
{
"key": "budget.department_name",
"label": "预算部门",
"type": "text",
"source": "budget"
},
{
"key": "budget.quarter",
"label": "预算季度",
"type": "text",
"source": "budget"
},
{
"key": "budget.project_code",
"label": "预算项目",
"type": "text",
"source": "budget"
}
]
},
"params": {
"template_key": "keyword_match_v1",
"field_keys": [
"claim.amount",
"claim.expense_type",
"claim.department_name",
"claim.reason",
"item.item_reason",
"budget.line_id",
"budget.available_amount",
"budget.used_rate",
"budget.status",
"budget.department_name",
"budget.quarter",
"budget.project_code"
],
"search_fields": [
"claim.reason",
"item.item_reason",
"claim.expense_type"
],
"keywords": [
"跨部门预算",
"部门不一致",
"未授权"
],
"condition_summary": "单据部门与预算部门不一致且无授权说明时触发。",
"finance_rule_code": "budget.execution.policy",
"finance_rule_sheet": "预算执行规则",
"business_stage": [
"expense_application",
"reimbursement",
"budget_execution"
],
"expense_types": [
"all"
],
"budget_required": true
},
"outcomes": {
"pass": {
"severity": "none",
"action": "continue"
},
"fail": {
"severity": "high",
"action": "manual_review",
"risk_score": 86
}
},
"metadata": {
"owner": "风控与审计部",
"stability": "platform",
"source_ref": "费用管控 Demo 风险规则库",
"created_at": "2026-05-31T00:10:41.774598+00:00",
"created_by": "system",
"risk_score": 86,
"risk_level": "high",
"rule_title": "跨部门预算未授权",
"finance_rule_code": "budget.execution.policy",
"finance_rule_sheet": "预算执行规则",
"business_stage": [
"expense_application",
"reimbursement",
"budget_execution"
],
"expense_types": [
"all"
],
"budget_required": true
},
"severity": "high",
"risk_score": 86,
"risk_level": "high"
}

View File

@@ -0,0 +1,187 @@
{
"schema_version": "2.0",
"rule_code": "risk.budget.cross_quarter_without_explanation",
"name": "跨季度预算未说明",
"description": "单据发生期间与预算季度不一致,且缺少跨季度使用说明。",
"enabled": true,
"requires_attachment": false,
"risk_dimension": "expense_control_demo",
"risk_category": "预算管控",
"ontology_signal": "budget_over_limit",
"evaluator": "template_rule",
"template_key": "keyword_match_v1",
"finance_rule_code": "budget.execution.policy",
"finance_rule_sheet": "预算执行规则",
"business_stage": [
"expense_application",
"reimbursement",
"budget_execution"
],
"expense_types": [
"all"
],
"budget_required": true,
"applies_to": {
"domains": [
"expense"
],
"expense_types": [
"all"
],
"business_stages": [
"expense_application",
"reimbursement",
"budget_execution"
]
},
"inputs": {
"fields": [
{
"key": "claim.amount",
"label": "报销金额",
"type": "number",
"source": "claim"
},
{
"key": "claim.expense_type",
"label": "费用类型",
"type": "enum",
"source": "claim"
},
{
"key": "claim.department_name",
"label": "部门",
"type": "text",
"source": "claim"
},
{
"key": "claim.reason",
"label": "事由",
"type": "text",
"source": "claim"
},
{
"key": "item.item_reason",
"label": "明细说明",
"type": "text",
"source": "item"
},
{
"key": "budget.line_id",
"label": "预算行",
"type": "text",
"source": "budget"
},
{
"key": "budget.available_amount",
"label": "预算可用金额",
"type": "number",
"source": "budget"
},
{
"key": "budget.used_rate",
"label": "预算使用率",
"type": "number",
"source": "budget"
},
{
"key": "budget.status",
"label": "预算状态",
"type": "enum",
"source": "budget"
},
{
"key": "budget.department_name",
"label": "预算部门",
"type": "text",
"source": "budget"
},
{
"key": "budget.quarter",
"label": "预算季度",
"type": "text",
"source": "budget"
},
{
"key": "budget.project_code",
"label": "预算项目",
"type": "text",
"source": "budget"
}
]
},
"params": {
"template_key": "keyword_match_v1",
"field_keys": [
"claim.amount",
"claim.expense_type",
"claim.department_name",
"claim.reason",
"item.item_reason",
"budget.line_id",
"budget.available_amount",
"budget.used_rate",
"budget.status",
"budget.department_name",
"budget.quarter",
"budget.project_code"
],
"search_fields": [
"claim.reason",
"item.item_reason",
"claim.expense_type"
],
"keywords": [
"跨季度预算",
"季度不一致",
"未说明"
],
"condition_summary": "发生季度与预算季度不一致且未说明时触发。",
"finance_rule_code": "budget.execution.policy",
"finance_rule_sheet": "预算执行规则",
"business_stage": [
"expense_application",
"reimbursement",
"budget_execution"
],
"expense_types": [
"all"
],
"budget_required": true
},
"outcomes": {
"pass": {
"severity": "none",
"action": "continue"
},
"fail": {
"severity": "medium",
"action": "manual_review",
"risk_score": 76
}
},
"metadata": {
"owner": "风控与审计部",
"stability": "platform",
"source_ref": "费用管控 Demo 风险规则库",
"created_at": "2026-05-31T00:10:41.779201+00:00",
"created_by": "system",
"risk_score": 76,
"risk_level": "medium",
"rule_title": "跨季度预算未说明",
"finance_rule_code": "budget.execution.policy",
"finance_rule_sheet": "预算执行规则",
"business_stage": [
"expense_application",
"reimbursement",
"budget_execution"
],
"expense_types": [
"all"
],
"budget_required": true
},
"severity": "medium",
"risk_score": 76,
"risk_level": "medium"
}

View File

@@ -0,0 +1,187 @@
{
"schema_version": "2.0",
"rule_code": "risk.budget.duplicate_reserve",
"name": "重复占用预算",
"description": "同一申请、项目或合同已占用预算,本次单据再次占用同一预算口径。",
"enabled": true,
"requires_attachment": false,
"risk_dimension": "expense_control_demo",
"risk_category": "预算管控",
"ontology_signal": "budget_over_limit",
"evaluator": "template_rule",
"template_key": "keyword_match_v1",
"finance_rule_code": "budget.execution.policy",
"finance_rule_sheet": "预算执行规则",
"business_stage": [
"expense_application",
"reimbursement",
"budget_execution"
],
"expense_types": [
"all"
],
"budget_required": true,
"applies_to": {
"domains": [
"expense"
],
"expense_types": [
"all"
],
"business_stages": [
"expense_application",
"reimbursement",
"budget_execution"
]
},
"inputs": {
"fields": [
{
"key": "claim.amount",
"label": "报销金额",
"type": "number",
"source": "claim"
},
{
"key": "claim.expense_type",
"label": "费用类型",
"type": "enum",
"source": "claim"
},
{
"key": "claim.department_name",
"label": "部门",
"type": "text",
"source": "claim"
},
{
"key": "claim.reason",
"label": "事由",
"type": "text",
"source": "claim"
},
{
"key": "item.item_reason",
"label": "明细说明",
"type": "text",
"source": "item"
},
{
"key": "budget.line_id",
"label": "预算行",
"type": "text",
"source": "budget"
},
{
"key": "budget.available_amount",
"label": "预算可用金额",
"type": "number",
"source": "budget"
},
{
"key": "budget.used_rate",
"label": "预算使用率",
"type": "number",
"source": "budget"
},
{
"key": "budget.status",
"label": "预算状态",
"type": "enum",
"source": "budget"
},
{
"key": "budget.department_name",
"label": "预算部门",
"type": "text",
"source": "budget"
},
{
"key": "budget.quarter",
"label": "预算季度",
"type": "text",
"source": "budget"
},
{
"key": "budget.project_code",
"label": "预算项目",
"type": "text",
"source": "budget"
}
]
},
"params": {
"template_key": "keyword_match_v1",
"field_keys": [
"claim.amount",
"claim.expense_type",
"claim.department_name",
"claim.reason",
"item.item_reason",
"budget.line_id",
"budget.available_amount",
"budget.used_rate",
"budget.status",
"budget.department_name",
"budget.quarter",
"budget.project_code"
],
"search_fields": [
"claim.reason",
"item.item_reason",
"claim.expense_type"
],
"keywords": [
"重复占用",
"预算锁定",
"重复申请"
],
"condition_summary": "相同业务标识存在未释放预算占用时触发。",
"finance_rule_code": "budget.execution.policy",
"finance_rule_sheet": "预算执行规则",
"business_stage": [
"expense_application",
"reimbursement",
"budget_execution"
],
"expense_types": [
"all"
],
"budget_required": true
},
"outcomes": {
"pass": {
"severity": "none",
"action": "continue"
},
"fail": {
"severity": "medium",
"action": "manual_review",
"risk_score": 74
}
},
"metadata": {
"owner": "风控与审计部",
"stability": "platform",
"source_ref": "费用管控 Demo 风险规则库",
"created_at": "2026-05-31T00:10:41.791584+00:00",
"created_by": "system",
"risk_score": 74,
"risk_level": "medium",
"rule_title": "重复占用预算",
"finance_rule_code": "budget.execution.policy",
"finance_rule_sheet": "预算执行规则",
"business_stage": [
"expense_application",
"reimbursement",
"budget_execution"
],
"expense_types": [
"all"
],
"budget_required": true
},
"severity": "medium",
"risk_score": 74,
"risk_level": "medium"
}

View File

@@ -0,0 +1,187 @@
{
"schema_version": "2.0",
"rule_code": "risk.budget.frozen_or_closed_used",
"name": "使用冻结或关闭预算",
"description": "单据引用了已冻结、已关闭或已作废的预算行。",
"enabled": true,
"requires_attachment": false,
"risk_dimension": "expense_control_demo",
"risk_category": "预算管控",
"ontology_signal": "budget_over_limit",
"evaluator": "template_rule",
"template_key": "keyword_match_v1",
"finance_rule_code": "budget.execution.policy",
"finance_rule_sheet": "预算执行规则",
"business_stage": [
"expense_application",
"reimbursement",
"budget_execution"
],
"expense_types": [
"all"
],
"budget_required": true,
"applies_to": {
"domains": [
"expense"
],
"expense_types": [
"all"
],
"business_stages": [
"expense_application",
"reimbursement",
"budget_execution"
]
},
"inputs": {
"fields": [
{
"key": "claim.amount",
"label": "报销金额",
"type": "number",
"source": "claim"
},
{
"key": "claim.expense_type",
"label": "费用类型",
"type": "enum",
"source": "claim"
},
{
"key": "claim.department_name",
"label": "部门",
"type": "text",
"source": "claim"
},
{
"key": "claim.reason",
"label": "事由",
"type": "text",
"source": "claim"
},
{
"key": "item.item_reason",
"label": "明细说明",
"type": "text",
"source": "item"
},
{
"key": "budget.line_id",
"label": "预算行",
"type": "text",
"source": "budget"
},
{
"key": "budget.available_amount",
"label": "预算可用金额",
"type": "number",
"source": "budget"
},
{
"key": "budget.used_rate",
"label": "预算使用率",
"type": "number",
"source": "budget"
},
{
"key": "budget.status",
"label": "预算状态",
"type": "enum",
"source": "budget"
},
{
"key": "budget.department_name",
"label": "预算部门",
"type": "text",
"source": "budget"
},
{
"key": "budget.quarter",
"label": "预算季度",
"type": "text",
"source": "budget"
},
{
"key": "budget.project_code",
"label": "预算项目",
"type": "text",
"source": "budget"
}
]
},
"params": {
"template_key": "keyword_match_v1",
"field_keys": [
"claim.amount",
"claim.expense_type",
"claim.department_name",
"claim.reason",
"item.item_reason",
"budget.line_id",
"budget.available_amount",
"budget.used_rate",
"budget.status",
"budget.department_name",
"budget.quarter",
"budget.project_code"
],
"search_fields": [
"claim.reason",
"item.item_reason",
"claim.expense_type"
],
"keywords": [
"冻结预算",
"关闭预算",
"预算作废"
],
"condition_summary": "预算状态不是启用时触发。",
"finance_rule_code": "budget.execution.policy",
"finance_rule_sheet": "预算执行规则",
"business_stage": [
"expense_application",
"reimbursement",
"budget_execution"
],
"expense_types": [
"all"
],
"budget_required": true
},
"outcomes": {
"pass": {
"severity": "none",
"action": "continue"
},
"fail": {
"severity": "high",
"action": "block_submit",
"risk_score": 90
}
},
"metadata": {
"owner": "风控与审计部",
"stability": "platform",
"source_ref": "费用管控 Demo 风险规则库",
"created_at": "2026-05-31T00:10:41.765154+00:00",
"created_by": "system",
"risk_score": 90,
"risk_level": "high",
"rule_title": "使用冻结或关闭预算",
"finance_rule_code": "budget.execution.policy",
"finance_rule_sheet": "预算执行规则",
"business_stage": [
"expense_application",
"reimbursement",
"budget_execution"
],
"expense_types": [
"all"
],
"budget_required": true
},
"severity": "high",
"risk_score": 90,
"risk_level": "high"
}

View File

@@ -0,0 +1,187 @@
{
"schema_version": "2.0",
"rule_code": "risk.budget.missing_budget_line",
"name": "缺少预算口径",
"description": "需要预算管控的费用未关联年度、季度、部门、项目或费用类型预算。",
"enabled": true,
"requires_attachment": false,
"risk_dimension": "expense_control_demo",
"risk_category": "预算管控",
"ontology_signal": "budget_over_limit",
"evaluator": "template_rule",
"template_key": "keyword_match_v1",
"finance_rule_code": "budget.execution.policy",
"finance_rule_sheet": "预算执行规则",
"business_stage": [
"expense_application",
"reimbursement",
"budget_execution"
],
"expense_types": [
"all"
],
"budget_required": true,
"applies_to": {
"domains": [
"expense"
],
"expense_types": [
"all"
],
"business_stages": [
"expense_application",
"reimbursement",
"budget_execution"
]
},
"inputs": {
"fields": [
{
"key": "claim.amount",
"label": "报销金额",
"type": "number",
"source": "claim"
},
{
"key": "claim.expense_type",
"label": "费用类型",
"type": "enum",
"source": "claim"
},
{
"key": "claim.department_name",
"label": "部门",
"type": "text",
"source": "claim"
},
{
"key": "claim.reason",
"label": "事由",
"type": "text",
"source": "claim"
},
{
"key": "item.item_reason",
"label": "明细说明",
"type": "text",
"source": "item"
},
{
"key": "budget.line_id",
"label": "预算行",
"type": "text",
"source": "budget"
},
{
"key": "budget.available_amount",
"label": "预算可用金额",
"type": "number",
"source": "budget"
},
{
"key": "budget.used_rate",
"label": "预算使用率",
"type": "number",
"source": "budget"
},
{
"key": "budget.status",
"label": "预算状态",
"type": "enum",
"source": "budget"
},
{
"key": "budget.department_name",
"label": "预算部门",
"type": "text",
"source": "budget"
},
{
"key": "budget.quarter",
"label": "预算季度",
"type": "text",
"source": "budget"
},
{
"key": "budget.project_code",
"label": "预算项目",
"type": "text",
"source": "budget"
}
]
},
"params": {
"template_key": "keyword_match_v1",
"field_keys": [
"claim.amount",
"claim.expense_type",
"claim.department_name",
"claim.reason",
"item.item_reason",
"budget.line_id",
"budget.available_amount",
"budget.used_rate",
"budget.status",
"budget.department_name",
"budget.quarter",
"budget.project_code"
],
"search_fields": [
"claim.reason",
"item.item_reason",
"claim.expense_type"
],
"keywords": [
"无预算",
"预算口径缺失",
"未关联预算"
],
"condition_summary": "费用类型要求预算管控但预算行为空时触发。",
"finance_rule_code": "budget.execution.policy",
"finance_rule_sheet": "预算执行规则",
"business_stage": [
"expense_application",
"reimbursement",
"budget_execution"
],
"expense_types": [
"all"
],
"budget_required": true
},
"outcomes": {
"pass": {
"severity": "none",
"action": "continue"
},
"fail": {
"severity": "high",
"action": "manual_review",
"risk_score": 82
}
},
"metadata": {
"owner": "风控与审计部",
"stability": "platform",
"source_ref": "费用管控 Demo 风险规则库",
"created_at": "2026-05-31T00:10:41.770132+00:00",
"created_by": "system",
"risk_score": 82,
"risk_level": "high",
"rule_title": "缺少预算口径",
"finance_rule_code": "budget.execution.policy",
"finance_rule_sheet": "预算执行规则",
"business_stage": [
"expense_application",
"reimbursement",
"budget_execution"
],
"expense_types": [
"all"
],
"budget_required": true
},
"severity": "high",
"risk_score": 82,
"risk_level": "high"
}

View File

@@ -0,0 +1,187 @@
{
"schema_version": "2.0",
"rule_code": "risk.budget.project_department_mismatch",
"name": "项目预算与部门不匹配",
"description": "单据引用的项目预算不属于当前部门或当前成本中心。",
"enabled": true,
"requires_attachment": false,
"risk_dimension": "expense_control_demo",
"risk_category": "预算管控",
"ontology_signal": "budget_over_limit",
"evaluator": "template_rule",
"template_key": "keyword_match_v1",
"finance_rule_code": "budget.execution.policy",
"finance_rule_sheet": "预算执行规则",
"business_stage": [
"expense_application",
"reimbursement",
"budget_execution"
],
"expense_types": [
"all"
],
"budget_required": true,
"applies_to": {
"domains": [
"expense"
],
"expense_types": [
"all"
],
"business_stages": [
"expense_application",
"reimbursement",
"budget_execution"
]
},
"inputs": {
"fields": [
{
"key": "claim.amount",
"label": "报销金额",
"type": "number",
"source": "claim"
},
{
"key": "claim.expense_type",
"label": "费用类型",
"type": "enum",
"source": "claim"
},
{
"key": "claim.department_name",
"label": "部门",
"type": "text",
"source": "claim"
},
{
"key": "claim.reason",
"label": "事由",
"type": "text",
"source": "claim"
},
{
"key": "item.item_reason",
"label": "明细说明",
"type": "text",
"source": "item"
},
{
"key": "budget.line_id",
"label": "预算行",
"type": "text",
"source": "budget"
},
{
"key": "budget.available_amount",
"label": "预算可用金额",
"type": "number",
"source": "budget"
},
{
"key": "budget.used_rate",
"label": "预算使用率",
"type": "number",
"source": "budget"
},
{
"key": "budget.status",
"label": "预算状态",
"type": "enum",
"source": "budget"
},
{
"key": "budget.department_name",
"label": "预算部门",
"type": "text",
"source": "budget"
},
{
"key": "budget.quarter",
"label": "预算季度",
"type": "text",
"source": "budget"
},
{
"key": "budget.project_code",
"label": "预算项目",
"type": "text",
"source": "budget"
}
]
},
"params": {
"template_key": "keyword_match_v1",
"field_keys": [
"claim.amount",
"claim.expense_type",
"claim.department_name",
"claim.reason",
"item.item_reason",
"budget.line_id",
"budget.available_amount",
"budget.used_rate",
"budget.status",
"budget.department_name",
"budget.quarter",
"budget.project_code"
],
"search_fields": [
"claim.reason",
"item.item_reason",
"claim.expense_type"
],
"keywords": [
"项目预算",
"成本中心不匹配",
"部门不匹配"
],
"condition_summary": "项目预算归属与报销部门不一致时触发。",
"finance_rule_code": "budget.execution.policy",
"finance_rule_sheet": "预算执行规则",
"business_stage": [
"expense_application",
"reimbursement",
"budget_execution"
],
"expense_types": [
"all"
],
"budget_required": true
},
"outcomes": {
"pass": {
"severity": "none",
"action": "continue"
},
"fail": {
"severity": "high",
"action": "manual_review",
"risk_score": 84
}
},
"metadata": {
"owner": "风控与审计部",
"stability": "platform",
"source_ref": "费用管控 Demo 风险规则库",
"created_at": "2026-05-31T00:10:41.785760+00:00",
"created_by": "system",
"risk_score": 84,
"risk_level": "high",
"rule_title": "项目预算与部门不匹配",
"finance_rule_code": "budget.execution.policy",
"finance_rule_sheet": "预算执行规则",
"business_stage": [
"expense_application",
"reimbursement",
"budget_execution"
],
"expense_types": [
"all"
],
"budget_required": true
},
"severity": "high",
"risk_score": 84,
"risk_level": "high"
}

View File

@@ -0,0 +1,187 @@
{
"schema_version": "2.0",
"rule_code": "risk.budget.usage_over_100",
"name": "预算使用率超过 100% 管控",
"description": "报销或申请通过后,预算使用率超过 100%,需要阻断或升级审批。",
"enabled": true,
"requires_attachment": false,
"risk_dimension": "expense_control_demo",
"risk_category": "预算管控",
"ontology_signal": "budget_over_limit",
"evaluator": "template_rule",
"template_key": "keyword_match_v1",
"finance_rule_code": "budget.execution.policy",
"finance_rule_sheet": "预算执行规则",
"business_stage": [
"expense_application",
"reimbursement",
"budget_execution"
],
"expense_types": [
"all"
],
"budget_required": true,
"applies_to": {
"domains": [
"expense"
],
"expense_types": [
"all"
],
"business_stages": [
"expense_application",
"reimbursement",
"budget_execution"
]
},
"inputs": {
"fields": [
{
"key": "claim.amount",
"label": "报销金额",
"type": "number",
"source": "claim"
},
{
"key": "claim.expense_type",
"label": "费用类型",
"type": "enum",
"source": "claim"
},
{
"key": "claim.department_name",
"label": "部门",
"type": "text",
"source": "claim"
},
{
"key": "claim.reason",
"label": "事由",
"type": "text",
"source": "claim"
},
{
"key": "item.item_reason",
"label": "明细说明",
"type": "text",
"source": "item"
},
{
"key": "budget.line_id",
"label": "预算行",
"type": "text",
"source": "budget"
},
{
"key": "budget.available_amount",
"label": "预算可用金额",
"type": "number",
"source": "budget"
},
{
"key": "budget.used_rate",
"label": "预算使用率",
"type": "number",
"source": "budget"
},
{
"key": "budget.status",
"label": "预算状态",
"type": "enum",
"source": "budget"
},
{
"key": "budget.department_name",
"label": "预算部门",
"type": "text",
"source": "budget"
},
{
"key": "budget.quarter",
"label": "预算季度",
"type": "text",
"source": "budget"
},
{
"key": "budget.project_code",
"label": "预算项目",
"type": "text",
"source": "budget"
}
]
},
"params": {
"template_key": "keyword_match_v1",
"field_keys": [
"claim.amount",
"claim.expense_type",
"claim.department_name",
"claim.reason",
"item.item_reason",
"budget.line_id",
"budget.available_amount",
"budget.used_rate",
"budget.status",
"budget.department_name",
"budget.quarter",
"budget.project_code"
],
"search_fields": [
"claim.reason",
"item.item_reason",
"claim.expense_type"
],
"keywords": [
"预算超支",
"超过100%",
"禁止提交"
],
"condition_summary": "预算使用率超过 100% 时触发。",
"finance_rule_code": "budget.execution.policy",
"finance_rule_sheet": "预算执行规则",
"business_stage": [
"expense_application",
"reimbursement",
"budget_execution"
],
"expense_types": [
"all"
],
"budget_required": true
},
"outcomes": {
"pass": {
"severity": "none",
"action": "continue"
},
"fail": {
"severity": "critical",
"action": "block_submit",
"risk_score": 96
}
},
"metadata": {
"owner": "风控与审计部",
"stability": "platform",
"source_ref": "费用管控 Demo 风险规则库",
"created_at": "2026-05-31T00:10:41.760043+00:00",
"created_by": "system",
"risk_score": 96,
"risk_level": "high",
"rule_title": "预算使用率超过 100% 管控",
"finance_rule_code": "budget.execution.policy",
"finance_rule_sheet": "预算执行规则",
"business_stage": [
"expense_application",
"reimbursement",
"budget_execution"
],
"expense_types": [
"all"
],
"budget_required": true
},
"severity": "critical",
"risk_score": 96,
"risk_level": "high"
}

View File

@@ -0,0 +1,187 @@
{
"schema_version": "2.0",
"rule_code": "risk.budget.usage_warning_80",
"name": "预算使用率达到 80% 预警",
"description": "报销或申请通过后,部门/项目/费用类型预算使用率达到 80% 以上。",
"enabled": true,
"requires_attachment": false,
"risk_dimension": "expense_control_demo",
"risk_category": "预算管控",
"ontology_signal": "budget_over_limit",
"evaluator": "template_rule",
"template_key": "keyword_match_v1",
"finance_rule_code": "budget.execution.policy",
"finance_rule_sheet": "预算执行规则",
"business_stage": [
"expense_application",
"reimbursement",
"budget_execution"
],
"expense_types": [
"all"
],
"budget_required": true,
"applies_to": {
"domains": [
"expense"
],
"expense_types": [
"all"
],
"business_stages": [
"expense_application",
"reimbursement",
"budget_execution"
]
},
"inputs": {
"fields": [
{
"key": "claim.amount",
"label": "报销金额",
"type": "number",
"source": "claim"
},
{
"key": "claim.expense_type",
"label": "费用类型",
"type": "enum",
"source": "claim"
},
{
"key": "claim.department_name",
"label": "部门",
"type": "text",
"source": "claim"
},
{
"key": "claim.reason",
"label": "事由",
"type": "text",
"source": "claim"
},
{
"key": "item.item_reason",
"label": "明细说明",
"type": "text",
"source": "item"
},
{
"key": "budget.line_id",
"label": "预算行",
"type": "text",
"source": "budget"
},
{
"key": "budget.available_amount",
"label": "预算可用金额",
"type": "number",
"source": "budget"
},
{
"key": "budget.used_rate",
"label": "预算使用率",
"type": "number",
"source": "budget"
},
{
"key": "budget.status",
"label": "预算状态",
"type": "enum",
"source": "budget"
},
{
"key": "budget.department_name",
"label": "预算部门",
"type": "text",
"source": "budget"
},
{
"key": "budget.quarter",
"label": "预算季度",
"type": "text",
"source": "budget"
},
{
"key": "budget.project_code",
"label": "预算项目",
"type": "text",
"source": "budget"
}
]
},
"params": {
"template_key": "keyword_match_v1",
"field_keys": [
"claim.amount",
"claim.expense_type",
"claim.department_name",
"claim.reason",
"item.item_reason",
"budget.line_id",
"budget.available_amount",
"budget.used_rate",
"budget.status",
"budget.department_name",
"budget.quarter",
"budget.project_code"
],
"search_fields": [
"claim.reason",
"item.item_reason",
"claim.expense_type"
],
"keywords": [
"预算预警",
"80%",
"使用率过高"
],
"condition_summary": "预算使用率大于等于 80% 且低于 100% 时触发。",
"finance_rule_code": "budget.execution.policy",
"finance_rule_sheet": "预算执行规则",
"business_stage": [
"expense_application",
"reimbursement",
"budget_execution"
],
"expense_types": [
"all"
],
"budget_required": true
},
"outcomes": {
"pass": {
"severity": "none",
"action": "continue"
},
"fail": {
"severity": "medium",
"action": "warning",
"risk_score": 70
}
},
"metadata": {
"owner": "风控与审计部",
"stability": "platform",
"source_ref": "费用管控 Demo 风险规则库",
"created_at": "2026-05-31T00:10:41.755636+00:00",
"created_by": "system",
"risk_score": 70,
"risk_level": "medium",
"rule_title": "预算使用率达到 80% 预警",
"finance_rule_code": "budget.execution.policy",
"finance_rule_sheet": "预算执行规则",
"business_stage": [
"expense_application",
"reimbursement",
"budget_execution"
],
"expense_types": [
"all"
],
"budget_required": true
},
"severity": "medium",
"risk_score": 70,
"risk_level": "medium"
}

View File

@@ -0,0 +1,165 @@
{
"schema_version": "2.0",
"rule_code": "risk.reimbursement.amount_over_application",
"name": "报销金额超过申请金额",
"description": "报销总金额超过已审批申请金额,需要按偏差规则复核。",
"enabled": true,
"requires_attachment": false,
"risk_dimension": "expense_control_demo",
"risk_category": "报销偏差",
"ontology_signal": "amount_over_application",
"evaluator": "template_rule",
"template_key": "keyword_match_v1",
"finance_rule_code": "application.reimbursement.linkage.policy",
"finance_rule_sheet": "申请报销关联规则",
"business_stage": [
"reimbursement"
],
"expense_types": [
"all"
],
"budget_required": true,
"applies_to": {
"domains": [
"expense"
],
"expense_types": [
"all"
],
"business_stages": [
"reimbursement"
]
},
"inputs": {
"fields": [
{
"key": "claim.amount",
"label": "报销金额",
"type": "number",
"source": "claim"
},
{
"key": "claim.expense_type",
"label": "费用类型",
"type": "enum",
"source": "claim"
},
{
"key": "claim.department_name",
"label": "部门",
"type": "text",
"source": "claim"
},
{
"key": "claim.reason",
"label": "事由",
"type": "text",
"source": "claim"
},
{
"key": "item.item_reason",
"label": "明细说明",
"type": "text",
"source": "item"
},
{
"key": "application.id",
"label": "申请单",
"type": "text",
"source": "application"
},
{
"key": "application.status",
"label": "申请状态",
"type": "enum",
"source": "application"
},
{
"key": "application.approved_amount",
"label": "申请审批金额",
"type": "number",
"source": "application"
},
{
"key": "application.expense_type",
"label": "申请费用类型",
"type": "enum",
"source": "application"
},
{
"key": "application.department_name",
"label": "申请部门",
"type": "text",
"source": "application"
}
]
},
"params": {
"template_key": "keyword_match_v1",
"field_keys": [
"claim.amount",
"claim.expense_type",
"claim.department_name",
"claim.reason",
"item.item_reason",
"application.id",
"application.status",
"application.approved_amount",
"application.expense_type",
"application.department_name"
],
"search_fields": [
"claim.reason",
"item.item_reason",
"claim.expense_type"
],
"keywords": [
"超过申请金额",
"报销偏差",
"申请金额"
],
"condition_summary": "报销金额大于申请审批金额时触发。",
"finance_rule_code": "application.reimbursement.linkage.policy",
"finance_rule_sheet": "申请报销关联规则",
"business_stage": [
"reimbursement"
],
"expense_types": [
"all"
],
"budget_required": true
},
"outcomes": {
"pass": {
"severity": "none",
"action": "continue"
},
"fail": {
"severity": "medium",
"action": "manual_review",
"risk_score": 76
}
},
"metadata": {
"owner": "风控与审计部",
"stability": "platform",
"source_ref": "费用管控 Demo 风险规则库",
"created_at": "2026-05-31T00:10:41.832940+00:00",
"created_by": "system",
"risk_score": 76,
"risk_level": "medium",
"rule_title": "报销金额超过申请金额",
"finance_rule_code": "application.reimbursement.linkage.policy",
"finance_rule_sheet": "申请报销关联规则",
"business_stage": [
"reimbursement"
],
"expense_types": [
"all"
],
"budget_required": true
},
"severity": "medium",
"risk_score": 76,
"risk_level": "medium"
}

View File

@@ -0,0 +1,165 @@
{
"schema_version": "2.0",
"rule_code": "risk.reimbursement.amount_over_application_10pct",
"name": "报销金额超过申请金额 10%",
"description": "报销金额比申请审批金额高出 10% 以上,需要升级审批或禁止提交。",
"enabled": true,
"requires_attachment": false,
"risk_dimension": "expense_control_demo",
"risk_category": "报销偏差",
"ontology_signal": "amount_over_application",
"evaluator": "template_rule",
"template_key": "keyword_match_v1",
"finance_rule_code": "application.reimbursement.linkage.policy",
"finance_rule_sheet": "申请报销关联规则",
"business_stage": [
"reimbursement"
],
"expense_types": [
"all"
],
"budget_required": true,
"applies_to": {
"domains": [
"expense"
],
"expense_types": [
"all"
],
"business_stages": [
"reimbursement"
]
},
"inputs": {
"fields": [
{
"key": "claim.amount",
"label": "报销金额",
"type": "number",
"source": "claim"
},
{
"key": "claim.expense_type",
"label": "费用类型",
"type": "enum",
"source": "claim"
},
{
"key": "claim.department_name",
"label": "部门",
"type": "text",
"source": "claim"
},
{
"key": "claim.reason",
"label": "事由",
"type": "text",
"source": "claim"
},
{
"key": "item.item_reason",
"label": "明细说明",
"type": "text",
"source": "item"
},
{
"key": "application.id",
"label": "申请单",
"type": "text",
"source": "application"
},
{
"key": "application.status",
"label": "申请状态",
"type": "enum",
"source": "application"
},
{
"key": "application.approved_amount",
"label": "申请审批金额",
"type": "number",
"source": "application"
},
{
"key": "application.expense_type",
"label": "申请费用类型",
"type": "enum",
"source": "application"
},
{
"key": "application.department_name",
"label": "申请部门",
"type": "text",
"source": "application"
}
]
},
"params": {
"template_key": "keyword_match_v1",
"field_keys": [
"claim.amount",
"claim.expense_type",
"claim.department_name",
"claim.reason",
"item.item_reason",
"application.id",
"application.status",
"application.approved_amount",
"application.expense_type",
"application.department_name"
],
"search_fields": [
"claim.reason",
"item.item_reason",
"claim.expense_type"
],
"keywords": [
"超过申请10%",
"金额偏差",
"升级审批"
],
"condition_summary": "报销金额超过申请审批金额 10% 以上时触发。",
"finance_rule_code": "application.reimbursement.linkage.policy",
"finance_rule_sheet": "申请报销关联规则",
"business_stage": [
"reimbursement"
],
"expense_types": [
"all"
],
"budget_required": true
},
"outcomes": {
"pass": {
"severity": "none",
"action": "continue"
},
"fail": {
"severity": "high",
"action": "manual_review",
"risk_score": 88
}
},
"metadata": {
"owner": "风控与审计部",
"stability": "platform",
"source_ref": "费用管控 Demo 风险规则库",
"created_at": "2026-05-31T00:10:41.839384+00:00",
"created_by": "system",
"risk_score": 88,
"risk_level": "high",
"rule_title": "报销金额超过申请金额 10%",
"finance_rule_code": "application.reimbursement.linkage.policy",
"finance_rule_sheet": "申请报销关联规则",
"business_stage": [
"reimbursement"
],
"expense_types": [
"all"
],
"budget_required": true
},
"severity": "high",
"risk_score": 88,
"risk_level": "high"
}

View File

@@ -0,0 +1,165 @@
{
"schema_version": "2.0",
"rule_code": "risk.reimbursement.department_mismatch_application",
"name": "报销部门与申请部门不一致",
"description": "报销部门、成本中心与关联申请单不一致,且缺少调整说明。",
"enabled": true,
"requires_attachment": false,
"risk_dimension": "expense_control_demo",
"risk_category": "报销偏差",
"ontology_signal": "department_mismatch",
"evaluator": "template_rule",
"template_key": "keyword_match_v1",
"finance_rule_code": "application.reimbursement.linkage.policy",
"finance_rule_sheet": "申请报销关联规则",
"business_stage": [
"reimbursement"
],
"expense_types": [
"all"
],
"budget_required": true,
"applies_to": {
"domains": [
"expense"
],
"expense_types": [
"all"
],
"business_stages": [
"reimbursement"
]
},
"inputs": {
"fields": [
{
"key": "claim.amount",
"label": "报销金额",
"type": "number",
"source": "claim"
},
{
"key": "claim.expense_type",
"label": "费用类型",
"type": "enum",
"source": "claim"
},
{
"key": "claim.department_name",
"label": "部门",
"type": "text",
"source": "claim"
},
{
"key": "claim.reason",
"label": "事由",
"type": "text",
"source": "claim"
},
{
"key": "item.item_reason",
"label": "明细说明",
"type": "text",
"source": "item"
},
{
"key": "application.id",
"label": "申请单",
"type": "text",
"source": "application"
},
{
"key": "application.status",
"label": "申请状态",
"type": "enum",
"source": "application"
},
{
"key": "application.approved_amount",
"label": "申请审批金额",
"type": "number",
"source": "application"
},
{
"key": "application.expense_type",
"label": "申请费用类型",
"type": "enum",
"source": "application"
},
{
"key": "application.department_name",
"label": "申请部门",
"type": "text",
"source": "application"
}
]
},
"params": {
"template_key": "keyword_match_v1",
"field_keys": [
"claim.amount",
"claim.expense_type",
"claim.department_name",
"claim.reason",
"item.item_reason",
"application.id",
"application.status",
"application.approved_amount",
"application.expense_type",
"application.department_name"
],
"search_fields": [
"claim.reason",
"item.item_reason",
"claim.expense_type"
],
"keywords": [
"部门不一致",
"成本中心偏差",
"申请部门"
],
"condition_summary": "报销部门与申请部门不一致时触发。",
"finance_rule_code": "application.reimbursement.linkage.policy",
"finance_rule_sheet": "申请报销关联规则",
"business_stage": [
"reimbursement"
],
"expense_types": [
"all"
],
"budget_required": true
},
"outcomes": {
"pass": {
"severity": "none",
"action": "continue"
},
"fail": {
"severity": "medium",
"action": "manual_review",
"risk_score": 72
}
},
"metadata": {
"owner": "风控与审计部",
"stability": "platform",
"source_ref": "费用管控 Demo 风险规则库",
"created_at": "2026-05-31T00:10:41.852823+00:00",
"created_by": "system",
"risk_score": 72,
"risk_level": "medium",
"rule_title": "报销部门与申请部门不一致",
"finance_rule_code": "application.reimbursement.linkage.policy",
"finance_rule_sheet": "申请报销关联规则",
"business_stage": [
"reimbursement"
],
"expense_types": [
"all"
],
"budget_required": true
},
"severity": "medium",
"risk_score": 72,
"risk_level": "medium"
}

View File

@@ -0,0 +1,165 @@
{
"schema_version": "2.0",
"rule_code": "risk.reimbursement.duplicate_against_application",
"name": "同一申请重复报销",
"description": "同一申请单或同一合同/项目存在多笔疑似重复报销。",
"enabled": true,
"requires_attachment": false,
"risk_dimension": "expense_control_demo",
"risk_category": "报销偏差",
"ontology_signal": "duplicate_reimbursement",
"evaluator": "template_rule",
"template_key": "keyword_match_v1",
"finance_rule_code": "application.reimbursement.linkage.policy",
"finance_rule_sheet": "申请报销关联规则",
"business_stage": [
"reimbursement"
],
"expense_types": [
"all"
],
"budget_required": true,
"applies_to": {
"domains": [
"expense"
],
"expense_types": [
"all"
],
"business_stages": [
"reimbursement"
]
},
"inputs": {
"fields": [
{
"key": "claim.amount",
"label": "报销金额",
"type": "number",
"source": "claim"
},
{
"key": "claim.expense_type",
"label": "费用类型",
"type": "enum",
"source": "claim"
},
{
"key": "claim.department_name",
"label": "部门",
"type": "text",
"source": "claim"
},
{
"key": "claim.reason",
"label": "事由",
"type": "text",
"source": "claim"
},
{
"key": "item.item_reason",
"label": "明细说明",
"type": "text",
"source": "item"
},
{
"key": "application.id",
"label": "申请单",
"type": "text",
"source": "application"
},
{
"key": "application.status",
"label": "申请状态",
"type": "enum",
"source": "application"
},
{
"key": "application.approved_amount",
"label": "申请审批金额",
"type": "number",
"source": "application"
},
{
"key": "application.expense_type",
"label": "申请费用类型",
"type": "enum",
"source": "application"
},
{
"key": "application.department_name",
"label": "申请部门",
"type": "text",
"source": "application"
}
]
},
"params": {
"template_key": "keyword_match_v1",
"field_keys": [
"claim.amount",
"claim.expense_type",
"claim.department_name",
"claim.reason",
"item.item_reason",
"application.id",
"application.status",
"application.approved_amount",
"application.expense_type",
"application.department_name"
],
"search_fields": [
"claim.reason",
"item.item_reason",
"claim.expense_type"
],
"keywords": [
"重复报销",
"同一申请",
"重复占用"
],
"condition_summary": "同一申请的已报销金额与本次金额超过申请金额时触发。",
"finance_rule_code": "application.reimbursement.linkage.policy",
"finance_rule_sheet": "申请报销关联规则",
"business_stage": [
"reimbursement"
],
"expense_types": [
"all"
],
"budget_required": true
},
"outcomes": {
"pass": {
"severity": "none",
"action": "continue"
},
"fail": {
"severity": "high",
"action": "manual_review",
"risk_score": 86
}
},
"metadata": {
"owner": "风控与审计部",
"stability": "platform",
"source_ref": "费用管控 Demo 风险规则库",
"created_at": "2026-05-31T00:10:41.873003+00:00",
"created_by": "system",
"risk_score": 86,
"risk_level": "high",
"rule_title": "同一申请重复报销",
"finance_rule_code": "application.reimbursement.linkage.policy",
"finance_rule_sheet": "申请报销关联规则",
"business_stage": [
"reimbursement"
],
"expense_types": [
"all"
],
"budget_required": true
},
"severity": "high",
"risk_score": 86,
"risk_level": "high"
}

View File

@@ -0,0 +1,165 @@
{
"schema_version": "2.0",
"rule_code": "risk.reimbursement.expense_type_mismatch_application",
"name": "报销费用类型与申请不一致",
"description": "报销单费用类型与关联申请单费用类型不一致。",
"enabled": true,
"requires_attachment": false,
"risk_dimension": "expense_control_demo",
"risk_category": "报销偏差",
"ontology_signal": "expense_type_mismatch",
"evaluator": "template_rule",
"template_key": "keyword_match_v1",
"finance_rule_code": "application.reimbursement.linkage.policy",
"finance_rule_sheet": "申请报销关联规则",
"business_stage": [
"reimbursement"
],
"expense_types": [
"all"
],
"budget_required": true,
"applies_to": {
"domains": [
"expense"
],
"expense_types": [
"all"
],
"business_stages": [
"reimbursement"
]
},
"inputs": {
"fields": [
{
"key": "claim.amount",
"label": "报销金额",
"type": "number",
"source": "claim"
},
{
"key": "claim.expense_type",
"label": "费用类型",
"type": "enum",
"source": "claim"
},
{
"key": "claim.department_name",
"label": "部门",
"type": "text",
"source": "claim"
},
{
"key": "claim.reason",
"label": "事由",
"type": "text",
"source": "claim"
},
{
"key": "item.item_reason",
"label": "明细说明",
"type": "text",
"source": "item"
},
{
"key": "application.id",
"label": "申请单",
"type": "text",
"source": "application"
},
{
"key": "application.status",
"label": "申请状态",
"type": "enum",
"source": "application"
},
{
"key": "application.approved_amount",
"label": "申请审批金额",
"type": "number",
"source": "application"
},
{
"key": "application.expense_type",
"label": "申请费用类型",
"type": "enum",
"source": "application"
},
{
"key": "application.department_name",
"label": "申请部门",
"type": "text",
"source": "application"
}
]
},
"params": {
"template_key": "keyword_match_v1",
"field_keys": [
"claim.amount",
"claim.expense_type",
"claim.department_name",
"claim.reason",
"item.item_reason",
"application.id",
"application.status",
"application.approved_amount",
"application.expense_type",
"application.department_name"
],
"search_fields": [
"claim.reason",
"item.item_reason",
"claim.expense_type"
],
"keywords": [
"费用类型不一致",
"申请报销不匹配",
"类型偏差"
],
"condition_summary": "报销费用类型与申请费用类型不一致时触发。",
"finance_rule_code": "application.reimbursement.linkage.policy",
"finance_rule_sheet": "申请报销关联规则",
"business_stage": [
"reimbursement"
],
"expense_types": [
"all"
],
"budget_required": true
},
"outcomes": {
"pass": {
"severity": "none",
"action": "continue"
},
"fail": {
"severity": "medium",
"action": "manual_review",
"risk_score": 74
}
},
"metadata": {
"owner": "风控与审计部",
"stability": "platform",
"source_ref": "费用管控 Demo 风险规则库",
"created_at": "2026-05-31T00:10:41.845894+00:00",
"created_by": "system",
"risk_score": 74,
"risk_level": "medium",
"rule_title": "报销费用类型与申请不一致",
"finance_rule_code": "application.reimbursement.linkage.policy",
"finance_rule_sheet": "申请报销关联规则",
"business_stage": [
"reimbursement"
],
"expense_types": [
"all"
],
"budget_required": true
},
"severity": "medium",
"risk_score": 74,
"risk_level": "medium"
}

View File

@@ -0,0 +1,179 @@
{
"schema_version": "2.0",
"rule_code": "risk.reimbursement.period_outside_application",
"name": "报销发生期间超出申请期间",
"description": "费用发生日期不在已审批申请的起止日期范围内。",
"enabled": true,
"requires_attachment": false,
"risk_dimension": "expense_control_demo",
"risk_category": "报销偏差",
"ontology_signal": "period_outside_application",
"evaluator": "template_rule",
"template_key": "keyword_match_v1",
"finance_rule_code": "application.reimbursement.linkage.policy",
"finance_rule_sheet": "申请报销关联规则",
"business_stage": [
"reimbursement"
],
"expense_types": [
"all"
],
"budget_required": true,
"applies_to": {
"domains": [
"expense"
],
"expense_types": [
"all"
],
"business_stages": [
"reimbursement"
]
},
"inputs": {
"fields": [
{
"key": "claim.amount",
"label": "报销金额",
"type": "number",
"source": "claim"
},
{
"key": "claim.expense_type",
"label": "费用类型",
"type": "enum",
"source": "claim"
},
{
"key": "claim.department_name",
"label": "部门",
"type": "text",
"source": "claim"
},
{
"key": "claim.reason",
"label": "事由",
"type": "text",
"source": "claim"
},
{
"key": "item.item_reason",
"label": "明细说明",
"type": "text",
"source": "item"
},
{
"key": "application.id",
"label": "申请单",
"type": "text",
"source": "application"
},
{
"key": "application.status",
"label": "申请状态",
"type": "enum",
"source": "application"
},
{
"key": "application.approved_amount",
"label": "申请审批金额",
"type": "number",
"source": "application"
},
{
"key": "application.expense_type",
"label": "申请费用类型",
"type": "enum",
"source": "application"
},
{
"key": "application.department_name",
"label": "申请部门",
"type": "text",
"source": "application"
},
{
"key": "application.start_date",
"label": "申请开始日期",
"type": "date",
"source": "application"
},
{
"key": "application.end_date",
"label": "申请结束日期",
"type": "date",
"source": "application"
}
]
},
"params": {
"template_key": "keyword_match_v1",
"field_keys": [
"claim.amount",
"claim.expense_type",
"claim.department_name",
"claim.reason",
"item.item_reason",
"application.id",
"application.status",
"application.approved_amount",
"application.expense_type",
"application.department_name",
"application.start_date",
"application.end_date"
],
"search_fields": [
"claim.reason",
"item.item_reason",
"claim.expense_type"
],
"keywords": [
"期间不一致",
"超出申请期间",
"日期偏差"
],
"condition_summary": "发生日期超出申请有效期间时触发。",
"finance_rule_code": "application.reimbursement.linkage.policy",
"finance_rule_sheet": "申请报销关联规则",
"business_stage": [
"reimbursement"
],
"expense_types": [
"all"
],
"budget_required": true
},
"outcomes": {
"pass": {
"severity": "none",
"action": "continue"
},
"fail": {
"severity": "medium",
"action": "manual_review",
"risk_score": 70
}
},
"metadata": {
"owner": "风控与审计部",
"stability": "platform",
"source_ref": "费用管控 Demo 风险规则库",
"created_at": "2026-05-31T00:10:41.859728+00:00",
"created_by": "system",
"risk_score": 70,
"risk_level": "medium",
"rule_title": "报销发生期间超出申请期间",
"finance_rule_code": "application.reimbursement.linkage.policy",
"finance_rule_sheet": "申请报销关联规则",
"business_stage": [
"reimbursement"
],
"expense_types": [
"all"
],
"budget_required": true
},
"severity": "medium",
"risk_score": 70,
"risk_level": "medium"
}

View File

@@ -0,0 +1,165 @@
{
"schema_version": "2.0",
"rule_code": "risk.reimbursement.rejected_application_claimed",
"name": "已驳回申请被用于报销",
"description": "报销单关联的申请单为驳回、撤回或已取消状态。",
"enabled": true,
"requires_attachment": false,
"risk_dimension": "expense_control_demo",
"risk_category": "报销偏差",
"ontology_signal": "invalid_application_status",
"evaluator": "template_rule",
"template_key": "keyword_match_v1",
"finance_rule_code": "application.reimbursement.linkage.policy",
"finance_rule_sheet": "申请报销关联规则",
"business_stage": [
"reimbursement"
],
"expense_types": [
"all"
],
"budget_required": true,
"applies_to": {
"domains": [
"expense"
],
"expense_types": [
"all"
],
"business_stages": [
"reimbursement"
]
},
"inputs": {
"fields": [
{
"key": "claim.amount",
"label": "报销金额",
"type": "number",
"source": "claim"
},
{
"key": "claim.expense_type",
"label": "费用类型",
"type": "enum",
"source": "claim"
},
{
"key": "claim.department_name",
"label": "部门",
"type": "text",
"source": "claim"
},
{
"key": "claim.reason",
"label": "事由",
"type": "text",
"source": "claim"
},
{
"key": "item.item_reason",
"label": "明细说明",
"type": "text",
"source": "item"
},
{
"key": "application.id",
"label": "申请单",
"type": "text",
"source": "application"
},
{
"key": "application.status",
"label": "申请状态",
"type": "enum",
"source": "application"
},
{
"key": "application.approved_amount",
"label": "申请审批金额",
"type": "number",
"source": "application"
},
{
"key": "application.expense_type",
"label": "申请费用类型",
"type": "enum",
"source": "application"
},
{
"key": "application.department_name",
"label": "申请部门",
"type": "text",
"source": "application"
}
]
},
"params": {
"template_key": "keyword_match_v1",
"field_keys": [
"claim.amount",
"claim.expense_type",
"claim.department_name",
"claim.reason",
"item.item_reason",
"application.id",
"application.status",
"application.approved_amount",
"application.expense_type",
"application.department_name"
],
"search_fields": [
"claim.reason",
"item.item_reason",
"claim.expense_type"
],
"keywords": [
"申请驳回",
"申请撤回",
"无效申请"
],
"condition_summary": "关联申请状态不是已通过或已完成时触发。",
"finance_rule_code": "application.reimbursement.linkage.policy",
"finance_rule_sheet": "申请报销关联规则",
"business_stage": [
"reimbursement"
],
"expense_types": [
"all"
],
"budget_required": true
},
"outcomes": {
"pass": {
"severity": "none",
"action": "continue"
},
"fail": {
"severity": "high",
"action": "block_submit",
"risk_score": 92
}
},
"metadata": {
"owner": "风控与审计部",
"stability": "platform",
"source_ref": "费用管控 Demo 风险规则库",
"created_at": "2026-05-31T00:10:41.865808+00:00",
"created_by": "system",
"risk_score": 92,
"risk_level": "high",
"rule_title": "已驳回申请被用于报销",
"finance_rule_code": "application.reimbursement.linkage.policy",
"finance_rule_sheet": "申请报销关联规则",
"business_stage": [
"reimbursement"
],
"expense_types": [
"all"
],
"budget_required": true
},
"severity": "high",
"risk_score": 92,
"risk_level": "high"
}

View File

@@ -0,0 +1,174 @@
{
"schema_version": "2.0",
"rule_code": "risk.standard.communication_account_mismatch",
"name": "通信账户归属与报销人不一致",
"description": "通信票据、运营商账单或号码归属信息与报销人不一致,且缺少代垫或统一缴费说明。",
"enabled": true,
"requires_attachment": true,
"risk_dimension": "expense_control_demo",
"risk_category": "费用归属",
"ontology_signal": "expense_owner_mismatch",
"evaluator": "template_rule",
"template_key": "keyword_match_v1",
"finance_rule_code": "expense.communication.policy",
"finance_rule_sheet": "通信费报销规则",
"business_stage": [
"reimbursement"
],
"expense_types": [
"communication"
],
"budget_required": true,
"applies_to": {
"domains": [
"expense"
],
"expense_types": [
"communication"
],
"business_stages": [
"reimbursement"
]
},
"inputs": {
"fields": [
{
"key": "claim.amount",
"label": "报销金额",
"type": "number",
"source": "claim"
},
{
"key": "claim.expense_type",
"label": "费用类型",
"type": "enum",
"source": "claim"
},
{
"key": "claim.department_name",
"label": "部门",
"type": "text",
"source": "claim"
},
{
"key": "claim.reason",
"label": "事由",
"type": "text",
"source": "claim"
},
{
"key": "item.item_reason",
"label": "明细说明",
"type": "text",
"source": "item"
},
{
"key": "material.attachment_count",
"label": "附件数量",
"type": "number",
"source": "material"
},
{
"key": "material.contract_uploaded",
"label": "合同已上传",
"type": "boolean",
"source": "material"
},
{
"key": "material.acceptance_uploaded",
"label": "验收材料已上传",
"type": "boolean",
"source": "material"
},
{
"key": "material.plan_uploaded",
"label": "方案已上传",
"type": "boolean",
"source": "material"
},
{
"key": "material.attendee_list_uploaded",
"label": "参与人清单已上传",
"type": "boolean",
"source": "material"
},
{
"key": "material.invoice_uploaded",
"label": "发票已上传",
"type": "boolean",
"source": "material"
}
]
},
"params": {
"template_key": "keyword_match_v1",
"field_keys": [
"claim.amount",
"claim.expense_type",
"claim.department_name",
"claim.reason",
"item.item_reason",
"material.attachment_count",
"material.contract_uploaded",
"material.acceptance_uploaded",
"material.plan_uploaded",
"material.attendee_list_uploaded",
"material.invoice_uploaded"
],
"search_fields": [
"claim.reason",
"item.item_reason",
"claim.expense_type"
],
"keywords": [
"号码归属",
"账户不一致",
"代垫",
"统一缴费",
"公共号码"
],
"condition_summary": "通信账户归属与报销人不一致且没有代垫、统一缴费或部门公共号码说明时触发。",
"finance_rule_code": "expense.communication.policy",
"finance_rule_sheet": "通信费报销规则",
"business_stage": [
"reimbursement"
],
"expense_types": [
"communication"
],
"budget_required": true
},
"outcomes": {
"pass": {
"severity": "none",
"action": "continue"
},
"fail": {
"severity": "high",
"action": "manual_review",
"risk_score": 82
}
},
"metadata": {
"owner": "风控与审计部",
"stability": "platform",
"source_ref": "费用管控 Demo 风险规则库",
"created_at": "2026-05-31T00:10:41.898564+00:00",
"created_by": "system",
"risk_score": 82,
"risk_level": "high",
"rule_title": "通信账户归属与报销人不一致",
"finance_rule_code": "expense.communication.policy",
"finance_rule_sheet": "通信费报销规则",
"business_stage": [
"reimbursement"
],
"expense_types": [
"communication"
],
"budget_required": true
},
"severity": "high",
"risk_score": 82,
"risk_level": "high"
}

View File

@@ -0,0 +1,143 @@
{
"schema_version": "2.0",
"rule_code": "risk.standard.communication_amount_over_policy",
"name": "通信费金额超过月度标准",
"description": "通信费、话费、流量费或宽带费超过公司月度标准,且缺少岗位必要性或专项审批说明。",
"enabled": true,
"requires_attachment": false,
"risk_dimension": "expense_control_demo",
"risk_category": "费用标准",
"ontology_signal": "expense_standard_over_limit",
"evaluator": "template_rule",
"template_key": "keyword_match_v1",
"finance_rule_code": "expense.communication.policy",
"finance_rule_sheet": "通信费报销规则",
"business_stage": [
"expense_application",
"reimbursement"
],
"expense_types": [
"communication"
],
"budget_required": true,
"applies_to": {
"domains": [
"expense"
],
"expense_types": [
"communication"
],
"business_stages": [
"expense_application",
"reimbursement"
]
},
"inputs": {
"fields": [
{
"key": "claim.amount",
"label": "报销金额",
"type": "number",
"source": "claim"
},
{
"key": "claim.expense_type",
"label": "费用类型",
"type": "enum",
"source": "claim"
},
{
"key": "claim.department_name",
"label": "部门",
"type": "text",
"source": "claim"
},
{
"key": "claim.reason",
"label": "事由",
"type": "text",
"source": "claim"
},
{
"key": "item.item_reason",
"label": "明细说明",
"type": "text",
"source": "item"
},
{
"key": "material.invoice_uploaded",
"label": "发票已上传",
"type": "boolean",
"source": "material"
}
]
},
"params": {
"template_key": "keyword_match_v1",
"field_keys": [
"claim.amount",
"claim.expense_type",
"claim.department_name",
"claim.reason",
"item.item_reason",
"material.invoice_uploaded"
],
"search_fields": [
"claim.reason",
"item.item_reason",
"claim.expense_type"
],
"keywords": [
"通信费",
"话费",
"流量费",
"宽带费",
"超标准"
],
"condition_summary": "通信费金额超过公司标准且没有岗位、项目或专项审批说明时触发。",
"finance_rule_code": "expense.communication.policy",
"finance_rule_sheet": "通信费报销规则",
"business_stage": [
"expense_application",
"reimbursement"
],
"expense_types": [
"communication"
],
"budget_required": true
},
"outcomes": {
"pass": {
"severity": "none",
"action": "continue"
},
"fail": {
"severity": "medium",
"action": "manual_review",
"risk_score": 68
}
},
"metadata": {
"owner": "风控与审计部",
"stability": "platform",
"source_ref": "费用管控 Demo 风险规则库",
"created_at": "2026-05-31T00:10:41.891463+00:00",
"created_by": "system",
"risk_score": 68,
"risk_level": "medium",
"rule_title": "通信费金额超过月度标准",
"finance_rule_code": "expense.communication.policy",
"finance_rule_sheet": "通信费报销规则",
"business_stage": [
"expense_application",
"reimbursement"
],
"expense_types": [
"communication"
],
"budget_required": true
},
"severity": "medium",
"risk_score": 68,
"risk_level": "medium"
}

View File

@@ -0,0 +1,172 @@
{
"schema_version": "2.0",
"rule_code": "risk.standard.meal_participants_missing",
"name": "业务招待缺少参与人清单",
"description": "业务招待费要求提供客户名称、参与人清单和招待说明。",
"enabled": true,
"requires_attachment": true,
"risk_dimension": "expense_control_demo",
"risk_category": "材料完整性",
"ontology_signal": "material_missing",
"evaluator": "template_rule",
"template_key": "keyword_match_v1",
"finance_rule_code": "expense.material.policy",
"finance_rule_sheet": "材料完整性规则",
"business_stage": [
"reimbursement"
],
"expense_types": [
"meal"
],
"budget_required": true,
"applies_to": {
"domains": [
"expense"
],
"expense_types": [
"meal"
],
"business_stages": [
"reimbursement"
]
},
"inputs": {
"fields": [
{
"key": "claim.amount",
"label": "报销金额",
"type": "number",
"source": "claim"
},
{
"key": "claim.expense_type",
"label": "费用类型",
"type": "enum",
"source": "claim"
},
{
"key": "claim.department_name",
"label": "部门",
"type": "text",
"source": "claim"
},
{
"key": "claim.reason",
"label": "事由",
"type": "text",
"source": "claim"
},
{
"key": "item.item_reason",
"label": "明细说明",
"type": "text",
"source": "item"
},
{
"key": "material.attachment_count",
"label": "附件数量",
"type": "number",
"source": "material"
},
{
"key": "material.contract_uploaded",
"label": "合同已上传",
"type": "boolean",
"source": "material"
},
{
"key": "material.acceptance_uploaded",
"label": "验收材料已上传",
"type": "boolean",
"source": "material"
},
{
"key": "material.plan_uploaded",
"label": "方案已上传",
"type": "boolean",
"source": "material"
},
{
"key": "material.attendee_list_uploaded",
"label": "参与人清单已上传",
"type": "boolean",
"source": "material"
},
{
"key": "material.invoice_uploaded",
"label": "发票已上传",
"type": "boolean",
"source": "material"
}
]
},
"params": {
"template_key": "keyword_match_v1",
"field_keys": [
"claim.amount",
"claim.expense_type",
"claim.department_name",
"claim.reason",
"item.item_reason",
"material.attachment_count",
"material.contract_uploaded",
"material.acceptance_uploaded",
"material.plan_uploaded",
"material.attendee_list_uploaded",
"material.invoice_uploaded"
],
"search_fields": [
"claim.reason",
"item.item_reason",
"claim.expense_type"
],
"keywords": [
"参与人清单",
"客户信息",
"业务招待"
],
"condition_summary": "业务招待费缺少参与人清单或客户信息时触发。",
"finance_rule_code": "expense.material.policy",
"finance_rule_sheet": "材料完整性规则",
"business_stage": [
"reimbursement"
],
"expense_types": [
"meal"
],
"budget_required": true
},
"outcomes": {
"pass": {
"severity": "none",
"action": "continue"
},
"fail": {
"severity": "medium",
"action": "manual_review",
"risk_score": 72
}
},
"metadata": {
"owner": "风控与审计部",
"stability": "platform",
"source_ref": "费用管控 Demo 风险规则库",
"created_at": "2026-05-31T00:10:41.879886+00:00",
"created_by": "system",
"risk_score": 72,
"risk_level": "medium",
"rule_title": "业务招待缺少参与人清单",
"finance_rule_code": "expense.material.policy",
"finance_rule_sheet": "材料完整性规则",
"business_stage": [
"reimbursement"
],
"expense_types": [
"meal"
],
"budget_required": true
},
"severity": "medium",
"risk_score": 72,
"risk_level": "medium"
}

View File

@@ -0,0 +1,204 @@
{
"schema_version": "2.0",
"rule_code": "risk.standard.office_fixed_asset_as_office",
"name": "固定资产伪装为办公用品费",
"description": "办公用品费明细疑似包含固定资产、电子设备或应走采购入库的物品。",
"enabled": true,
"requires_attachment": false,
"risk_dimension": "expense_control_demo",
"risk_category": "费用标准",
"ontology_signal": "expense_type_mismatch",
"evaluator": "template_rule",
"template_key": "keyword_match_v1",
"finance_rule_code": "expense.classification.policy",
"finance_rule_sheet": "费用类型归类规则",
"business_stage": [
"reimbursement"
],
"expense_types": [
"office"
],
"budget_required": true,
"applies_to": {
"domains": [
"expense"
],
"expense_types": [
"office"
],
"business_stages": [
"reimbursement"
]
},
"inputs": {
"fields": [
{
"key": "claim.amount",
"label": "报销金额",
"type": "number",
"source": "claim"
},
{
"key": "claim.expense_type",
"label": "费用类型",
"type": "enum",
"source": "claim"
},
{
"key": "claim.department_name",
"label": "部门",
"type": "text",
"source": "claim"
},
{
"key": "claim.reason",
"label": "事由",
"type": "text",
"source": "claim"
},
{
"key": "item.item_reason",
"label": "明细说明",
"type": "text",
"source": "item"
}
]
},
"params": {
"template_key": "keyword_match_v1",
"field_keys": [
"claim.amount",
"claim.expense_type",
"claim.department_name",
"claim.reason",
"item.item_reason"
],
"search_fields": [
"claim.reason",
"item.item_reason",
"claim.expense_type"
],
"keywords": [
"固定资产",
"电脑",
"显示器",
"办公设备"
],
"condition_summary": "办公用品费包含固定资产关键词或超过采购阈值时触发。",
"finance_rule_code": "expense.classification.policy",
"finance_rule_sheet": "费用类型归类规则",
"business_stage": [
"reimbursement"
],
"expense_types": [
"office"
],
"budget_required": true
},
"outcomes": {
"pass": {
"severity": "none",
"action": "continue"
},
"fail": {
"severity": "medium",
"action": "manual_review",
"risk_score": 52
}
},
"metadata": {
"owner": "风控与审计部",
"stability": "platform",
"source_ref": "费用管控 Demo 风险规则库",
"created_at": "2026-05-31T00:10:41.885450+00:00",
"created_by": "system",
"risk_score": 52,
"risk_level": "medium",
"rule_title": "固定资产伪装为办公用品费",
"finance_rule_code": "expense.classification.policy",
"finance_rule_sheet": "费用类型归类规则",
"business_stage": [
"reimbursement"
],
"expense_types": [
"office"
],
"budget_required": true,
"risk_level_label": "中风险",
"risk_score_model": "risk_score_v3",
"risk_score_detail": {
"score": 52,
"level": "medium",
"level_label": "中风险",
"model": "risk_score_v3",
"weights": {
"impact": 0.35,
"certainty": 0.25,
"evidence": 0.15,
"exception": 0.1,
"action": 0.1,
"sensitivity": 0.05
},
"components": {
"impact": 42,
"certainty": 58,
"evidence": 62,
"exception": 74,
"action": 35,
"sensitivity": 45
},
"calibration": {
"raw_score": 52,
"rules": []
},
"ai_evidence": {},
"basis": {
"template_key": "keyword_match_v1",
"field_count": 5,
"condition_count": 0,
"expense_category": null,
"expense_category_label": "费用标准",
"requires_attachment": false
}
}
},
"severity": "medium",
"risk_score": 52,
"risk_level": "medium",
"risk_level_label": "中风险",
"risk_score_detail": {
"score": 52,
"level": "medium",
"level_label": "中风险",
"model": "risk_score_v3",
"weights": {
"impact": 0.35,
"certainty": 0.25,
"evidence": 0.15,
"exception": 0.1,
"action": 0.1,
"sensitivity": 0.05
},
"components": {
"impact": 42,
"certainty": 58,
"evidence": 62,
"exception": 74,
"action": 35,
"sensitivity": 45
},
"calibration": {
"raw_score": 52,
"rules": []
},
"ai_evidence": {},
"basis": {
"template_key": "keyword_match_v1",
"field_count": 5,
"condition_count": 0,
"expense_category": null,
"expense_category_label": "费用标准",
"requires_attachment": false
}
}
}

View File

@@ -12,19 +12,16 @@ SERVER_DIR = Path(__file__).resolve().parents[1]
RISK_RULE_DIR = SERVER_DIR / "rules" / "risk-rules"
BUDGET_EXPENSE_TYPES = (
BUDGET_EXPENSE_TYPES = ("all",)
SUPPORTED_DEMO_EXPENSE_TYPES = {
"all",
"travel",
"hotel",
"transport",
"meal",
"meeting",
"marketing",
"office",
"training",
"software",
"communication",
"welfare",
)
}
FIELD_LABELS = {
@@ -456,7 +453,7 @@ RULES: tuple[DemoRiskRule, ...] = (
"rule.expense.company_travel_expense_reimbursement",
"差旅住宿费标准",
("reimbursement",),
("travel", "hotel", "transport"),
("travel",),
"差旅金额达到大额阈值且缺少有效出差申请时触发。",
("差旅申请", "大额差旅", "未申请"),
"high",
@@ -688,6 +685,54 @@ RULES: tuple[DemoRiskRule, ...] = (
)
COMMUNICATION_RULES: tuple[DemoRiskRule, ...] = (
DemoRiskRule(
"risk.standard.communication_amount_over_policy",
"通信费金额超过月度标准",
"通信费、话费、流量费或宽带费超过公司月度标准,且缺少岗位必要性或专项审批说明。",
"费用标准",
"expense_standard_over_limit",
"expense.communication.policy",
"通信费报销规则",
("expense_application", "reimbursement"),
("communication",),
"通信费金额超过公司标准且没有岗位、项目或专项审批说明时触发。",
("通信费", "话费", "流量费", "宽带费", "超标准"),
"medium",
"manual_review",
68,
"medium",
field_keys=BASE_FIELDS + ("material.invoice_uploaded",),
),
DemoRiskRule(
"risk.standard.communication_account_mismatch",
"通信账户归属与报销人不一致",
"通信票据、运营商账单或号码归属信息与报销人不一致,且缺少代垫或统一缴费说明。",
"费用归属",
"expense_owner_mismatch",
"expense.communication.policy",
"通信费报销规则",
("reimbursement",),
("communication",),
"通信账户归属与报销人不一致且没有代垫、统一缴费或部门公共号码说明时触发。",
("号码归属", "账户不一致", "代垫", "统一缴费", "公共号码"),
"high",
"manual_review",
82,
"high",
requires_attachment=True,
field_keys=MATERIAL_FIELDS,
),
)
def _is_supported_demo_rule(rule: DemoRiskRule) -> bool:
return all(expense_type in SUPPORTED_DEMO_EXPENSE_TYPES for expense_type in rule.expense_types)
RULES = tuple(rule for rule in RULES if _is_supported_demo_rule(rule)) + COMMUNICATION_RULES
def main() -> None:
RISK_RULE_DIR.mkdir(parents=True, exist_ok=True)
for rule in RULES:

View File

@@ -0,0 +1,99 @@
from __future__ import annotations
import argparse
from typing import Iterable
from app.api.deps import CurrentUserContext
from app.db.session import get_session_factory
from app.models.employee import Employee
from app.models.financial_record import ExpenseClaim
from app.services.expense_claim_workflow_constants import BUDGET_MANAGER_APPROVAL_STAGE
from app.services.expense_claims import ExpenseClaimService
def _role_codes(employee: Employee) -> list[str]:
return [
str(role.role_code or "").strip().lower()
for role in list(employee.roles or [])
if str(role.role_code or "").strip()
]
def _is_direct_manager(employee: Employee | None, budget_manager: Employee | None) -> bool:
if employee is None or budget_manager is None:
return False
if employee.manager_id and employee.manager_id == budget_manager.id:
return True
return employee.manager is not None and employee.manager.id == budget_manager.id
def _budget_manager_context(budget_manager: Employee) -> CurrentUserContext:
return CurrentUserContext(
username=str(budget_manager.email or budget_manager.employee_no or budget_manager.name or "").strip(),
name=str(budget_manager.name or "").strip(),
role_codes=_role_codes(budget_manager),
is_admin=False,
department_name=str(
budget_manager.organization_unit.name
if budget_manager.organization_unit is not None
else ""
).strip(),
grade=str(budget_manager.grade or "").strip(),
employee_no=str(budget_manager.employee_no or "").strip(),
)
def _iter_candidates(service: ExpenseClaimService) -> Iterable[tuple[ExpenseClaim, Employee]]:
claims = service.db.query(ExpenseClaim).filter(
ExpenseClaim.status == "submitted",
ExpenseClaim.approval_stage == BUDGET_MANAGER_APPROVAL_STAGE,
).all()
for claim in claims:
if not service._is_expense_application_claim(claim):
continue
budget_manager = service._access_policy.resolve_department_budget_manager(claim)
if budget_manager is None:
continue
if not _is_direct_manager(claim.employee, budget_manager):
continue
yield claim, budget_manager
def main() -> None:
parser = argparse.ArgumentParser(
description="Repair application claims stuck at budget approval when the direct manager is the budget approver.",
)
parser.add_argument("--apply", action="store_true", help="Apply the repair. Without it, only prints candidates.")
args = parser.parse_args()
session_factory = get_session_factory()
with session_factory() as db:
service = ExpenseClaimService(db)
candidates = list(_iter_candidates(service))
print(f"candidates={len(candidates)}")
for claim, budget_manager in candidates:
print(
"candidate "
f"claim_no={claim.claim_no} "
f"claim_id={claim.id} "
f"employee={claim.employee_name} "
f"budget_manager={budget_manager.name}"
)
if not args.apply:
continue
repaired = service.approve_claim(
claim.id,
_budget_manager_context(budget_manager),
opinion="历史流程修复:直属领导与预算审批人为同一人,合并预算审批。",
)
print(
"repaired "
f"claim_no={repaired.claim_no if repaired is not None else claim.claim_no} "
f"status={repaired.status if repaired is not None else ''} "
f"stage={repaired.approval_stage if repaired is not None else ''}"
)
if __name__ == "__main__":
main()

View File

@@ -24,6 +24,10 @@ class CurrentUserContext:
is_admin: bool
department_name: str = ""
cost_center: str = ""
position: str = ""
grade: str = ""
employee_no: str = ""
manager_name: str = ""
def get_current_user(
@@ -51,6 +55,22 @@ def get_current_user(
str | None,
Header(description="当前登录人的成本中心。"),
] = None,
x_auth_position: Annotated[
str | None,
Header(description="当前登录人的岗位。"),
] = None,
x_auth_grade: Annotated[
str | None,
Header(description="当前登录人的职级。"),
] = None,
x_auth_employee_no: Annotated[
str | None,
Header(description="当前登录人的员工编号。"),
] = None,
x_auth_manager_name: Annotated[
str | None,
Header(description="当前登录人的直属领导。"),
] = None,
) -> CurrentUserContext:
role_codes = [
_normalize_role_code(item)
@@ -79,6 +99,10 @@ def get_current_user(
is_admin=is_admin,
department_name=(x_auth_department or "").strip(),
cost_center=(x_auth_cost_center or "").strip(),
position=(x_auth_position or "").strip(),
grade=(x_auth_grade or "").strip(),
employee_no=(x_auth_employee_no or "").strip(),
manager_name=(x_auth_manager_name or "").strip(),
)

View File

@@ -2,21 +2,29 @@ from __future__ import annotations
from typing import Annotated, NoReturn
from fastapi import APIRouter, Depends, Header, HTTPException, status
from fastapi import APIRouter, Depends, Header, HTTPException, Query, status
from sqlalchemy.orm import Session
from app.api.deps import (
CurrentUserContext,
get_current_user,
get_db,
require_rule_editor_user,
require_rule_reviewer_user,
)
from app.schemas.agent_asset import (
AgentAssetRead,
AgentAssetRiskRuleDraftUpdate,
AgentAssetRiskRuleFeedbackCreate,
AgentAssetRiskRuleFeedbackRead,
AgentAssetRiskRuleRegenerateRequest,
AgentAssetRiskRuleRevisionCreate,
AgentAssetRiskRuleTemplateGroupRead,
)
from app.services.agent_asset_risk_rule_regeneration import AgentAssetRiskRuleRegenerationService
from app.services.agent_asset_risk_rule_revision import AgentAssetRiskRuleRevisionService
from app.services.agent_assets import AgentAssetService
from app.services.risk_rule_template_catalog import list_risk_rule_template_groups
router = APIRouter(prefix="/agent-assets")
DbSession = Annotated[Session, Depends(get_db)]
@@ -29,6 +37,8 @@ RequestIdHeader = Annotated[
Header(description="外部请求 ID用于串联审计日志和上游调用链。"),
]
RuleEditorUser = Annotated[CurrentUserContext, Depends(require_rule_editor_user)]
RuleReviewerUser = Annotated[CurrentUserContext, Depends(require_rule_reviewer_user)]
CurrentUser = Annotated[CurrentUserContext, Depends(get_current_user)]
def _handle_asset_error(exc: Exception) -> NoReturn:
@@ -50,6 +60,16 @@ def _read_asset(db: Session, asset_id: str) -> AgentAssetRead:
return asset
@router.get(
"/risk-rules/templates",
response_model=list[AgentAssetRiskRuleTemplateGroupRead],
summary="查询常见费控风险规则模板",
description="返回模板分组、默认自然语言、字段清单和 DSL 样例;模板只用于预填,不绕过通用生成链路。",
)
def list_risk_rule_templates(_: CurrentUser) -> list[AgentAssetRiskRuleTemplateGroupRead]:
return list_risk_rule_template_groups()
@router.patch(
"/{asset_id}/risk-rules/draft",
response_model=AgentAssetRead,
@@ -101,3 +121,80 @@ def create_risk_rule_revision(
return _read_asset(db, asset_id)
except Exception as exc:
_handle_asset_error(exc)
@router.post(
"/{asset_id}/risk-rules/regenerate",
response_model=AgentAssetRead,
summary="重新生成风险规则执行模板",
description="把未上线草稿或已上线规则的修订草稿重新解释为 DSL、流程图、风险评分和业务说明。",
)
def regenerate_risk_rule(
asset_id: str,
payload: AgentAssetRiskRuleRegenerateRequest,
current_user: RuleEditorUser,
db: DbSession,
x_actor: ActorHeader = None,
x_request_id: RequestIdHeader = None,
) -> AgentAssetRead:
try:
AgentAssetRiskRuleRegenerationService(db).regenerate(
asset_id,
payload,
actor=_actor_name(current_user, x_actor),
request_id=x_request_id,
)
return _read_asset(db, asset_id)
except Exception as exc:
_handle_asset_error(exc)
@router.post(
"/{asset_id}/risk-rules/feedback",
response_model=AgentAssetRiskRuleFeedbackRead,
status_code=status.HTTP_201_CREATED,
summary="提交风险规则误判或漏判反馈",
description="普通用户可提交规则误判、漏判或改进反馈;该接口只记录反馈,不直接修改规则。",
)
def create_risk_rule_feedback(
asset_id: str,
payload: AgentAssetRiskRuleFeedbackCreate,
current_user: CurrentUser,
db: DbSession,
x_actor: ActorHeader = None,
x_request_id: RequestIdHeader = None,
) -> AgentAssetRiskRuleFeedbackRead:
try:
return AgentAssetService(db).create_risk_rule_feedback(
asset_id,
payload,
actor=_actor_name(current_user, x_actor),
request_id=x_request_id,
)
except Exception as exc:
_handle_asset_error(exc)
@router.get(
"/{asset_id}/risk-rules/feedback",
response_model=list[AgentAssetRiskRuleFeedbackRead],
summary="查询风险规则反馈记录",
description="高级财务人员或 admin 管理员查看指定风险规则的误判、漏判和改进反馈记录。",
)
def list_risk_rule_feedback(
asset_id: str,
_: RuleReviewerUser,
db: DbSession,
version: Annotated[str | None, Query(max_length=30)] = None,
status_value: Annotated[str | None, Query(alias="status", max_length=30)] = None,
limit: Annotated[int, Query(ge=1, le=200)] = 50,
) -> list[AgentAssetRiskRuleFeedbackRead]:
try:
return AgentAssetService(db).list_risk_rule_feedback(
asset_id,
version=version,
status=status_value,
limit=limit,
)
except Exception as exc:
_handle_asset_error(exc)

View File

@@ -0,0 +1,84 @@
from __future__ import annotations
from typing import Annotated
from fastapi import APIRouter, Depends, HTTPException, Query, status
from sqlalchemy.orm import Session
from app.api.deps import get_db, require_admin_user
from app.schemas.agent_trace import (
AgentConversationTraceRead,
AgentTraceDetailRead,
AgentTraceListItem,
)
from app.schemas.common import ErrorResponse
from app.services.agent_traces import AgentTraceService
router = APIRouter(prefix="/agent-traces")
DbSession = Annotated[Session, Depends(get_db)]
@router.get(
"",
response_model=list[AgentTraceListItem],
summary="查询 Agent Trace 列表",
description="按 Agent、状态、来源、会话或关键字查询 Agent 链路追踪记录。",
)
def list_agent_traces(
db: DbSession,
_: Annotated[object, Depends(require_admin_user)],
agent: Annotated[str | None, Query(description="Agent 名称过滤。")] = None,
status_value: Annotated[
str | None,
Query(alias="status", description="运行状态过滤。"),
] = None,
source: Annotated[str | None, Query(description="运行来源过滤。")] = None,
conversation_id: Annotated[str | None, Query(description="会话 ID 过滤。")] = None,
keyword: Annotated[str | None, Query(description="Run ID、摘要或语义关键字。")] = None,
limit: Annotated[int, Query(ge=1, le=100, description="返回记录上限。")] = 30,
) -> list[AgentTraceListItem]:
return AgentTraceService(db).list_traces(
agent=agent,
status=status_value,
source=source,
conversation_id=conversation_id,
keyword=keyword,
limit=limit,
)
@router.get(
"/conversations/{conversation_id}",
response_model=AgentConversationTraceRead,
summary="读取会话 Agent Trace",
description="按 `conversation_id` 返回该会话下多轮运行的 trace 详情。",
)
def get_conversation_trace(
conversation_id: str,
db: DbSession,
_: Annotated[object, Depends(require_admin_user)],
) -> AgentConversationTraceRead:
return AgentTraceService(db).get_conversation_trace(conversation_id)
@router.get(
"/{run_id}",
response_model=AgentTraceDetailRead,
summary="读取单次 Agent Trace",
description="按 `run_id` 返回运行摘要、事件时间线、语义解析、工具调用和关联会话消息。",
responses={
status.HTTP_404_NOT_FOUND: {
"model": ErrorResponse,
"description": "Trace 运行记录不存在。",
}
},
)
def get_agent_trace(
run_id: str,
db: DbSession,
_: Annotated[object, Depends(require_admin_user)],
) -> AgentTraceDetailRead:
trace = AgentTraceService(db).get_trace(run_id)
if trace is None:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Agent trace not found")
return trace

View File

@@ -7,8 +7,10 @@ from fastapi import APIRouter, Depends, Query
from sqlalchemy.orm import Session
from app.api.deps import get_db
from app.schemas.digital_employee_dashboard import DigitalEmployeeDashboardRead
from app.schemas.finance_dashboard import FinanceDashboardRead
from app.schemas.system_dashboard import SystemDashboardRead
from app.services.digital_employee_dashboard import DigitalEmployeeDashboardService
from app.services.finance_dashboard import FinanceDashboardService
from app.services.system_dashboard import SystemDashboardService
@@ -32,6 +34,26 @@ def get_system_dashboard(
return SystemDashboardService(db).build_dashboard(days=days)
@router.get(
"/digital-employee-dashboard",
response_model=DigitalEmployeeDashboardRead,
summary="查询数字员工工作看板",
description="基于数字员工运行记录和工具调用结果聚合每日工作、技能类型、业务产出和近期执行明细。",
)
def get_digital_employee_dashboard(
db: DbSession,
days: Annotated[
int,
Query(ge=1, le=30, description="统计窗口天数。"),
] = 7,
limit: Annotated[
int,
Query(ge=1, le=1000, description="窗口内最多读取的运行记录数。"),
] = 300,
) -> DigitalEmployeeDashboardRead:
return DigitalEmployeeDashboardService(db).build_dashboard(days=days, limit=limit)
@router.get(
"/finance-dashboard",
response_model=FinanceDashboardRead,

View File

@@ -5,8 +5,9 @@ from typing import Annotated
from fastapi import APIRouter, Depends, HTTPException, status
from sqlalchemy.orm import Session
from app.api.deps import get_db
from app.api.deps import CurrentUserContext, get_current_user, get_db
from app.schemas.auth import (
AuthUserRead,
LoginRequest,
LoginResponse,
SessionFinishRequest,
@@ -39,6 +40,42 @@ def login(payload: LoginRequest, db: DbSession) -> LoginResponse:
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail=str(exc)) from exc
@router.get(
"/me",
response_model=AuthUserRead,
summary="读取当前登录用户",
description="根据当前会话请求头刷新前端登录态中的员工姓名、部门、岗位和职级。",
)
def get_current_auth_user(
current_user: Annotated[CurrentUserContext, Depends(get_current_user)],
db: DbSession,
) -> AuthUserRead:
user = AuthService(db).get_user_snapshot(current_user.username)
if user is not None:
return user
if current_user.is_admin:
name = current_user.name or current_user.username or "系统管理员"
return AuthUserRead(
username=current_user.username or name,
name=name,
role="管理员",
department=current_user.department_name,
departmentName=current_user.department_name,
position=current_user.position or "系统管理员",
grade=current_user.grade,
employeeNo=current_user.employee_no,
managerName=current_user.manager_name,
costCenter=current_user.cost_center,
roleCodes=current_user.role_codes or ["manager"],
email=current_user.username if "@" in current_user.username else f"{current_user.username}@local",
avatar=name[:1].upper(),
isAdmin=True,
)
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="当前登录用户不存在或已停用")
@router.post(
"/sessions/{session_id}/finish",
response_model=SessionFinishResponse,

View File

@@ -545,6 +545,34 @@ def delete_expense_claim_item_attachment(
return ExpenseClaimAttachmentActionResponse(**payload)
@router.post(
"/claims/{claim_id}/pre-review",
response_model=ExpenseClaimRead,
summary="执行报销单 AI 预审",
description="只执行 AI 预审并回写风险结果,不提交到审批流程。",
responses={
status.HTTP_404_NOT_FOUND: {
"model": ErrorResponse,
"description": "报销单不存在。",
},
status.HTTP_400_BAD_REQUEST: {
"model": ErrorResponse,
"description": "草稿信息不完整或状态不允许预审。",
},
},
)
def pre_review_expense_claim(claim_id: str, db: DbSession, current_user: CurrentUser) -> ExpenseClaimRead:
service = ExpenseClaimService(db)
try:
claim = service.pre_review_claim(claim_id, current_user)
except ValueError as error:
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(error)) from error
if claim is None:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Claim not found")
return claim
@router.post(
"/claims/{claim_id}/submit",
response_model=ExpenseClaimRead,

View File

@@ -4,6 +4,7 @@ from app.api.v1.endpoints.agent_asset_risk_rules import router as agent_asset_ri
from app.api.v1.endpoints.agent_assets import router as agent_assets_router
from app.api.v1.endpoints.agent_feedback import router as agent_feedback_router
from app.api.v1.endpoints.agent_runs import router as agent_runs_router
from app.api.v1.endpoints.agent_traces import router as agent_traces_router
from app.api.v1.endpoints.analytics import router as analytics_router
from app.api.v1.endpoints.audit_logs import router as audit_logs_router
from app.api.v1.endpoints.auth import router as auth_router
@@ -31,6 +32,7 @@ router.include_router(agent_assets_router, tags=["agent-assets"])
router.include_router(agent_asset_risk_rules_router, tags=["agent-assets"])
router.include_router(agent_feedback_router, tags=["agent-feedback"])
router.include_router(agent_runs_router, tags=["agent-runs"])
router.include_router(agent_traces_router, tags=["agent-traces"])
router.include_router(analytics_router, tags=["analytics"])
router.include_router(audit_logs_router, tags=["audit-logs"])
router.include_router(knowledge_router, tags=["knowledge"])

View File

@@ -1,8 +1,14 @@
from app.db.base_class import Base
from app.models.agent_conversation import AgentConversation, AgentConversationMessage
from app.models.agent_asset import AgentAsset, AgentAssetReview, AgentAssetTestRun, AgentAssetVersion
from app.models.agent_asset import (
AgentAsset,
AgentAssetReview,
AgentAssetRuleFeedback,
AgentAssetTestRun,
AgentAssetVersion,
)
from app.models.agent_feedback import AgentOperationFeedback
from app.models.agent_run import AgentRun, AgentToolCall, SemanticParseLog
from app.models.agent_run import AgentRun, AgentToolCall, AgentTraceEvent, SemanticParseLog
from app.models.approval import ApprovalRecord
from app.models.audit_log import AuditLog
from app.models.budget import BudgetAllocation, BudgetReservation, BudgetTransaction
@@ -34,11 +40,13 @@ __all__ = [
"AgentConversationMessage",
"AgentAsset",
"AgentAssetReview",
"AgentAssetRuleFeedback",
"AgentAssetTestRun",
"AgentAssetVersion",
"AgentOperationFeedback",
"AgentRun",
"AgentToolCall",
"AgentTraceEvent",
"ApprovalRecord",
"AuditLog",
"BudgetAllocation",

View File

@@ -1,7 +1,7 @@
from app.models.agent_conversation import AgentConversation, AgentConversationMessage
from app.models.agent_asset import AgentAsset, AgentAssetReview, AgentAssetVersion
from app.models.agent_asset import AgentAsset, AgentAssetReview, AgentAssetRuleFeedback, AgentAssetVersion
from app.models.agent_feedback import AgentOperationFeedback
from app.models.agent_run import AgentRun, AgentToolCall, SemanticParseLog
from app.models.agent_run import AgentRun, AgentToolCall, AgentTraceEvent, SemanticParseLog
from app.models.approval import ApprovalRecord
from app.models.audit_log import AuditLog
from app.models.budget import BudgetAllocation, BudgetReservation, BudgetTransaction
@@ -32,10 +32,12 @@ __all__ = [
"AgentConversationMessage",
"AgentAsset",
"AgentAssetReview",
"AgentAssetRuleFeedback",
"AgentAssetVersion",
"AgentOperationFeedback",
"AgentRun",
"AgentToolCall",
"AgentTraceEvent",
"ApprovalRecord",
"AuditLog",
"BudgetAllocation",

View File

@@ -4,7 +4,7 @@ import uuid
from datetime import datetime
from typing import Any
from sqlalchemy import Boolean, DateTime, ForeignKey, String, Text, UniqueConstraint, func
from sqlalchemy import Boolean, DateTime, ForeignKey, Index, String, Text, UniqueConstraint, func
from sqlalchemy.orm import Mapped, mapped_column, relationship
from sqlalchemy.types import JSON
@@ -52,6 +52,12 @@ class AgentAsset(Base):
cascade="all, delete-orphan",
order_by="desc(AgentAssetTestRun.created_at)",
)
rule_feedback_items = relationship(
"AgentAssetRuleFeedback",
back_populates="asset",
cascade="all, delete-orphan",
order_by="desc(AgentAssetRuleFeedback.created_at)",
)
class AgentAssetVersion(Base):
@@ -103,3 +109,34 @@ class AgentAssetTestRun(Base):
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
asset = relationship("AgentAsset", back_populates="test_runs")
class AgentAssetRuleFeedback(Base):
__tablename__ = "agent_asset_rule_feedback"
__table_args__ = (
Index("ix_agent_asset_rule_feedback_asset_version", "asset_id", "version"),
Index("ix_agent_asset_rule_feedback_type_status", "feedback_type", "status"),
)
id: Mapped[str] = mapped_column(String(36), primary_key=True, default=lambda: str(uuid.uuid4()))
feedback_id: Mapped[str] = mapped_column(
String(50),
unique=True,
index=True,
default=lambda: f"arf_{uuid.uuid4().hex[:16]}",
)
asset_id: Mapped[str] = mapped_column(ForeignKey("agent_assets.id"), index=True)
version: Mapped[str] = mapped_column(String(30), index=True)
feedback_type: Mapped[str] = mapped_column(String(30), index=True)
status: Mapped[str] = mapped_column(String(30), default="open", index=True)
subject_type: Mapped[str] = mapped_column(String(50), default="", index=True)
subject_key: Mapped[str] = mapped_column(String(160), default="", index=True)
subject_label: Mapped[str] = mapped_column(String(200), default="")
actual_result_json: Mapped[dict[str, Any]] = mapped_column(JSON, default=dict)
expected_result_json: Mapped[dict[str, Any]] = mapped_column(JSON, default=dict)
comment: Mapped[str | None] = mapped_column(Text(), nullable=True)
payload_json: Mapped[dict[str, Any]] = mapped_column(JSON, default=dict)
created_by: Mapped[str] = mapped_column(String(100), default="", index=True)
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now(), index=True)
asset = relationship("AgentAsset", back_populates="rule_feedback_items")

View File

@@ -4,7 +4,7 @@ import uuid
from datetime import datetime
from typing import Any
from sqlalchemy import DateTime, Float, ForeignKey, Integer, String, Text, func
from sqlalchemy import DateTime, Float, ForeignKey, Index, Integer, String, Text, func
from sqlalchemy.orm import Mapped, mapped_column, relationship
from sqlalchemy.types import JSON
@@ -84,3 +84,28 @@ class SemanticParseLog(Base):
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
run = relationship("AgentRun", back_populates="semantic_parse_logs")
class AgentTraceEvent(Base):
__tablename__ = "agent_trace_events"
__table_args__ = (
Index("ix_agent_trace_events_run_sequence", "run_id", "sequence"),
Index("ix_agent_trace_events_conversation_sequence", "conversation_id", "sequence"),
)
id: Mapped[str] = mapped_column(String(36), primary_key=True, default=lambda: str(uuid.uuid4()))
run_id: Mapped[str] = mapped_column(ForeignKey("agent_runs.run_id"), index=True)
conversation_id: Mapped[str | None] = mapped_column(String(50), nullable=True, index=True)
sequence: Mapped[int] = mapped_column(Integer, default=0, index=True)
stage: Mapped[str] = mapped_column(String(50), index=True)
event_name: Mapped[str] = mapped_column(String(100), index=True)
title: Mapped[str] = mapped_column(String(160))
summary: Mapped[str | None] = mapped_column(Text(), nullable=True)
status: Mapped[str] = mapped_column(String(20), index=True)
input_json: Mapped[dict[str, Any]] = mapped_column(JSON, default=dict)
output_json: Mapped[dict[str, Any]] = mapped_column(JSON, default=dict)
error_message: Mapped[str | None] = mapped_column(Text(), nullable=True)
started_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), index=True)
finished_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
duration_ms: Mapped[int] = mapped_column(Integer, default=0)
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())

View File

@@ -6,6 +6,7 @@ from sqlalchemy.orm import Session
from app.models.agent_asset import (
AgentAsset,
AgentAssetReview,
AgentAssetRuleFeedback,
AgentAssetTestRun,
AgentAssetVersion,
)
@@ -218,6 +219,36 @@ class AgentAssetRepository:
self.db.refresh(test_run)
return test_run
def list_rule_feedback(
self,
asset_id: str,
*,
version: str | None = None,
status: str | None = None,
limit: int | None = None,
) -> list[AgentAssetRuleFeedback]:
stmt = (
select(AgentAssetRuleFeedback)
.where(AgentAssetRuleFeedback.asset_id == asset_id)
.order_by(AgentAssetRuleFeedback.created_at.desc())
)
if version:
stmt = stmt.where(AgentAssetRuleFeedback.version == version)
if status:
stmt = stmt.where(AgentAssetRuleFeedback.status == status)
if limit is not None:
stmt = stmt.limit(limit)
return list(self.db.scalars(stmt).all())
def create_rule_feedback(
self,
feedback: AgentAssetRuleFeedback,
) -> AgentAssetRuleFeedback:
self.db.add(feedback)
self.db.commit()
self.db.refresh(feedback)
return feedback
def delete_asset(self, asset: AgentAsset) -> None:
self.db.delete(asset)
self.db.commit()

View File

@@ -146,6 +146,38 @@ class AgentAssetRiskRuleRegenerateRequest(BaseModel):
requires_attachment: bool | None = None
class AgentAssetRiskRuleTemplateFieldRead(BaseModel):
key: str
label: str
display: str
source: str
type: str
class AgentAssetRiskRuleTemplateRead(BaseModel):
template_id: str
group: str
group_label: str
title: str
description: str = ""
business_domain: str = "expense"
business_stage: str = "reimbursement"
business_stage_label: str = "费用报销"
expense_category: str | None = None
expense_category_label: str = ""
requires_attachment: bool = False
natural_language: str
fields: list[AgentAssetRiskRuleTemplateFieldRead] = Field(default_factory=list)
dsl_example: dict[str, Any] = Field(default_factory=dict)
class AgentAssetRiskRuleTemplateGroupRead(BaseModel):
group: str
group_label: str
order: int
templates: list[AgentAssetRiskRuleTemplateRead] = Field(default_factory=list)
class AgentAssetRiskRuleSampleCase(BaseModel):
case_id: str | None = Field(default=None, max_length=60)
name: str = Field(default="测试样例", min_length=1, max_length=80)
@@ -211,6 +243,9 @@ class AgentAssetRiskRuleSimulationRead(BaseModel):
trace: dict[str, Any] = Field(default_factory=dict)
attachments: list[dict[str, Any]] = Field(default_factory=list)
recognized_fields: list[dict[str, Any]] = Field(default_factory=list)
ocr_raw_fields: list[dict[str, Any]] = Field(default_factory=list)
hermes_normalized_fields: list[dict[str, Any]] = Field(default_factory=list)
executor_input_fields: list[dict[str, Any]] = Field(default_factory=list)
missing_fields: list[dict[str, Any]] = Field(default_factory=list)
recognition_summary: list[dict[str, Any]] = Field(default_factory=list)
execution_mode: str = "risk_rule_simulation"
@@ -229,6 +264,38 @@ class AgentAssetRiskRuleLevelUpdate(BaseModel):
risk_level: str = Field(pattern="^(low|medium|high|critical)$")
class AgentAssetRiskRuleFeedbackCreate(BaseModel):
version: str | None = Field(default=None, max_length=30)
feedback_type: str = Field(pattern="^(false_positive|false_negative|unclear|improvement)$")
subject_type: str | None = Field(default=None, max_length=50)
subject_key: str | None = Field(default=None, max_length=160)
subject_label: str | None = Field(default=None, max_length=200)
actual_result: dict[str, Any] = Field(default_factory=dict)
expected_result: dict[str, Any] = Field(default_factory=dict)
comment: str = Field(min_length=1, max_length=1000)
payload: dict[str, Any] = Field(default_factory=dict)
class AgentAssetRiskRuleFeedbackRead(BaseModel):
model_config = ConfigDict(from_attributes=True)
id: str
feedback_id: str
asset_id: str
version: str
feedback_type: str
status: str
subject_type: str = ""
subject_key: str = ""
subject_label: str = ""
actual_result_json: dict[str, Any] = Field(default_factory=dict)
expected_result_json: dict[str, Any] = Field(default_factory=dict)
comment: str | None = None
payload_json: dict[str, Any] = Field(default_factory=dict)
created_by: str
created_at: datetime
class AgentAssetRiskRuleTestRunRead(BaseModel):
model_config = ConfigDict(from_attributes=True)

View File

@@ -0,0 +1,63 @@
from __future__ import annotations
from datetime import datetime
from typing import Any
from pydantic import BaseModel, ConfigDict, Field
from app.schemas.agent_run import AgentRunRead, AgentToolCallRead, SemanticParseRead
from app.schemas.orchestrator import ConversationMessageRead
class AgentTraceEventRead(BaseModel):
model_config = ConfigDict(from_attributes=True)
id: str
run_id: str
conversation_id: str | None = None
sequence: int
stage: str
event_name: str
title: str
summary: str | None = None
status: str
input_json: dict[str, Any] = Field(default_factory=dict)
output_json: dict[str, Any] = Field(default_factory=dict)
error_message: str | None = None
started_at: datetime
finished_at: datetime | None = None
duration_ms: int = 0
created_at: datetime
class AgentTraceListItem(BaseModel):
run_id: str
conversation_id: str | None = None
agent: str
source: str
status: str
scenario: str | None = None
intent: str | None = None
title: str
summary: str | None = None
event_count: int = 0
tool_call_count: int = 0
failed_tool_call_count: int = 0
started_at: datetime
finished_at: datetime | None = None
duration_ms: int = 0
class AgentTraceDetailRead(BaseModel):
run: AgentRunRead
conversation_id: str | None = None
events: list[AgentTraceEventRead] = Field(default_factory=list)
semantic_parse: SemanticParseRead | None = None
tool_calls: list[AgentToolCallRead] = Field(default_factory=list)
conversation_messages: list[ConversationMessageRead] = Field(default_factory=list)
fallback_generated: bool = False
class AgentConversationTraceRead(BaseModel):
conversation_id: str
runs: list[AgentTraceDetailRead] = Field(default_factory=list)

View File

@@ -0,0 +1,16 @@
from __future__ import annotations
from typing import Any
from pydantic import BaseModel, Field
class DigitalEmployeeDashboardRead(BaseModel):
window_days: int
generated_at: str
has_real_data: bool
totals: dict[str, Any] = Field(default_factory=dict)
daily_work: list[dict[str, Any]] = Field(default_factory=list)
task_distribution: list[dict[str, Any]] = Field(default_factory=list)
category_distribution: list[dict[str, Any]] = Field(default_factory=list)
recent_runs: list[dict[str, Any]] = Field(default_factory=list)

View File

@@ -130,6 +130,9 @@ class ExpenseClaimRead(BaseModel):
employee_position: str | None = None
employee_grade: str | None = None
manager_name: str | None = None
budget_approver_name: str | None = None
budget_approver_grade: str | None = None
budget_approver_role_code: str | None = None
role_labels: list[str] = Field(default_factory=list)
project_code: str | None
expense_type: str
@@ -202,6 +205,7 @@ class ExpenseClaimAttachmentActionResponse(BaseModel):
item_location: str | None = None
item_amount: Decimal | None = None
claim_amount: Decimal | None = None
claim_risk_flags: list[Any] = Field(default_factory=list)
attachment: ExpenseClaimAttachmentRead | None = None

View File

@@ -117,9 +117,11 @@ class RiskObservationDashboardRead(BaseModel):
window_days: int
total_observations: int
pending_count: int
risk_clue_count: int = 0
high_or_above_count: int
confirmed_count: int
false_positive_count: int
feedback_sample_count: int = 0
total_amount: float = 0.0
average_score: float
level_distribution: dict[str, int] = Field(default_factory=dict)

View File

@@ -17,7 +17,7 @@ class AgentAssetJsonRuleMixin:
if rule_library not in RULE_LIBRARY_NAMES:
raise ValueError("规则库目录不合法。")
rule_document = config_json.get("rule_document")
rule_document = self._resolve_working_json_risk_rule_document(asset, config_json)
if not isinstance(rule_document, dict):
raise ValueError("规则资产缺少 rule_document 配置。")
@@ -26,6 +26,27 @@ class AgentAssetJsonRuleMixin:
raise ValueError("规则资产缺少 JSON 文件名。")
return rule_library, file_name
@staticmethod
def _resolve_working_json_risk_rule_document(
asset: AgentAsset,
config_json: dict,
) -> dict | None:
revision = config_json.get("revision_draft")
if isinstance(revision, dict):
revision_version = str(revision.get("version") or "").strip()
working_version = str(asset.working_version or "").strip()
published_version = str(asset.published_version or "").strip()
revision_document = revision.get("rule_document")
if (
revision_version
and revision_version == working_version
and revision_version != published_version
and isinstance(revision_document, dict)
and str(revision_document.get("file_name") or "").strip()
):
return revision_document
return config_json.get("rule_document")
def read_rule_json(self, asset_id: str) -> AgentAssetRuleJsonRead:
asset = self.repository.get(asset_id)
if asset is None:

View File

@@ -0,0 +1,79 @@
from __future__ import annotations
from typing import Any
from app.core.agent_enums import AgentAssetType
from app.models.agent_asset import AgentAssetRuleFeedback
from app.schemas.agent_asset import (
AgentAssetRiskRuleFeedbackCreate,
AgentAssetRiskRuleFeedbackRead,
)
class AgentAssetRiskRuleFeedbackMixin:
def create_risk_rule_feedback(
self,
asset_id: str,
payload: AgentAssetRiskRuleFeedbackCreate,
*,
actor: str,
request_id: str | None = None,
) -> AgentAssetRiskRuleFeedbackRead:
asset = self._resolve_asset(asset_id)
self._require_json_risk_asset(asset)
version = self._resolve_target_version(asset, payload.version)
feedback = AgentAssetRuleFeedback(
asset_id=asset.id,
version=version,
feedback_type=str(payload.feedback_type or "").strip(),
subject_type=str(payload.subject_type or "").strip(),
subject_key=str(payload.subject_key or "").strip(),
subject_label=str(payload.subject_label or "").strip(),
actual_result_json=self._safe_json_dict(payload.actual_result),
expected_result_json=self._safe_json_dict(payload.expected_result),
comment=str(payload.comment or "").strip(),
payload_json=self._safe_json_dict(payload.payload),
created_by=str(actor or "").strip() or "system",
)
created = self.repository.create_rule_feedback(feedback)
self.audit_service.log_action(
actor=created.created_by,
action="create_risk_rule_feedback",
resource_type=AgentAssetType.RULE.value,
resource_id=asset.id,
before_json=None,
after_json={
"feedback_id": created.feedback_id,
"version": created.version,
"feedback_type": created.feedback_type,
"subject_type": created.subject_type,
"subject_key": created.subject_key,
},
request_id=request_id,
)
return AgentAssetRiskRuleFeedbackRead.model_validate(created)
def list_risk_rule_feedback(
self,
asset_id: str,
*,
version: str | None = None,
status: str | None = None,
limit: int | None = None,
) -> list[AgentAssetRiskRuleFeedbackRead]:
asset = self._resolve_asset(asset_id)
self._require_json_risk_asset(asset)
target_version = self._resolve_target_version(asset, version) if version else None
return [
AgentAssetRiskRuleFeedbackRead.model_validate(item)
for item in self.repository.list_rule_feedback(
asset.id,
version=target_version,
status=status,
limit=limit,
)
]
@staticmethod
def _safe_json_dict(value: Any) -> dict[str, Any]:
return dict(value) if isinstance(value, dict) else {}

View File

@@ -0,0 +1,215 @@
from __future__ import annotations
from datetime import UTC, datetime
from typing import Any
from app.core.agent_enums import AgentAssetStatus, AgentAssetType, AgentReviewStatus
from app.models.agent_asset import AgentAsset, AgentAssetReview
from app.services.agent_asset_spreadsheet import RISK_RULES_LIBRARY
from app.services.risk_rule_manifest_normalizer import normalize_risk_rule_manifest
class AgentAssetRiskRulePublishMixin:
"""风险规则发布逻辑,支持普通待审核版本和已上线规则修订版本。"""
def publish_risk_rule(
self,
asset_id: str,
*,
actor: str,
request_id: str | None = None,
) -> AgentAsset:
asset = self._resolve_asset(asset_id)
self._require_json_risk_asset(asset)
revision = self._resolve_publishable_revision(asset)
if revision is not None:
return self._publish_revision(asset, revision, actor=actor, request_id=request_id)
return self._publish_reviewed_working_version(asset, actor=actor, request_id=request_id)
def _publish_reviewed_working_version(
self,
asset: AgentAsset,
*,
actor: str,
request_id: str | None,
) -> AgentAsset:
version = self._resolve_target_version(asset, None)
if asset.status != AgentAssetStatus.REVIEW.value:
raise ValueError("只有待审核风险规则可以发布上线。")
if not self.get_latest_risk_rule_test_summary(asset, version=version).test_passed:
raise PermissionError("当前规则版本尚未完成测试通过确认,不能发布。")
before = self._asset_snapshot(asset)
self._ensure_approved_review(asset, version=version, actor=actor, note="发布上线前审核通过。")
asset.reviewer = actor
asset.published_version = version
asset.status = AgentAssetStatus.ACTIVE.value
self.db.add(asset)
self.db.commit()
self.audit_service.log_action(
actor=actor,
action="publish_agent_asset",
resource_type=AgentAssetType.RULE.value,
resource_id=asset.id,
before_json=before,
after_json=self._asset_snapshot(asset),
request_id=request_id,
)
return self._refresh_asset(asset.id)
def _publish_revision(
self,
asset: AgentAsset,
revision: dict[str, Any],
*,
actor: str,
request_id: str | None,
) -> AgentAsset:
version = str(revision.get("version") or "").strip()
if not self.get_latest_risk_rule_test_summary(asset, version=version).test_passed:
raise PermissionError("当前修订版本尚未完成测试通过确认,不能发布。")
rule_document = revision.get("rule_document") if isinstance(revision.get("rule_document"), dict) else {}
file_name = str(rule_document.get("file_name") or "").strip()
if not file_name:
raise ValueError("修订版本尚未生成可发布的 JSON 规则文件。")
before = self._asset_snapshot(asset)
manifest = self.rule_library_manager.read_rule_library_json(
library=RISK_RULES_LIBRARY,
file_name=file_name,
)
manifest = normalize_risk_rule_manifest(manifest)
manifest["enabled"] = True
self.rule_library_manager.write_rule_library_json(
library=RISK_RULES_LIBRARY,
file_name=file_name,
payload=manifest,
)
config = dict(asset.config_json or {})
previous_rule_document = config.get("rule_document") if isinstance(config.get("rule_document"), dict) else {}
published_at = datetime.now(UTC).isoformat()
history = list(config.get("revision_history") if isinstance(config.get("revision_history"), list) else [])
history.insert(
0,
{
"version": version,
"base_version": revision.get("base_version"),
"change_reason": revision.get("change_reason"),
"published_by": actor,
"published_at": published_at,
"previous_rule_document": previous_rule_document,
"rule_document": rule_document,
},
)
config.update(self._config_from_published_manifest(manifest, rule_document))
config["revision_history"] = history[:20]
config.pop("revision_draft", None)
config["last_operation"] = {
"action": "publish_revision",
"actor": actor,
"at": published_at,
"target_version": version,
}
asset.name = str(manifest.get("name") or asset.name)
asset.description = str(manifest.get("description") or asset.description)
risk_category = str(manifest.get("risk_category") or "").strip()
if risk_category:
asset.scenario_json = [risk_category]
asset.config_json = config
asset.current_version = version
asset.working_version = version
asset.published_version = version
asset.reviewer = actor
asset.status = AgentAssetStatus.ACTIVE.value
self._ensure_approved_review(asset, version=version, actor=actor, note="修订版本发布上线。")
self.db.add(asset)
self.db.commit()
self.audit_service.log_action(
actor=actor,
action="publish_risk_rule_revision",
resource_type=AgentAssetType.RULE.value,
resource_id=asset.id,
before_json=before,
after_json=self._asset_snapshot(asset),
request_id=request_id,
)
return self._refresh_asset(asset.id)
def _resolve_publishable_revision(self, asset: AgentAsset) -> dict[str, Any] | None:
config = dict(asset.config_json or {})
revision = config.get("revision_draft")
if not isinstance(revision, dict):
return None
version = str(revision.get("version") or "").strip()
if not version or version != str(asset.working_version or "").strip():
return None
if version == str(asset.published_version or "").strip():
return None
if revision.get("generation_status") != "completed":
raise ValueError("修订版本尚未重新生成,不能发布上线。")
return dict(revision)
def _ensure_approved_review(
self,
asset: AgentAsset,
*,
version: str,
actor: str,
note: str,
) -> None:
approved_review = self.repository.get_review(
asset.id, version, AgentReviewStatus.APPROVED.value
)
if approved_review is not None:
return
self.db.add(
AgentAssetReview(
asset_id=asset.id,
version=version,
reviewer=actor,
review_status=AgentReviewStatus.APPROVED.value,
review_note=note,
reviewed_at=datetime.now(UTC),
)
)
@staticmethod
def _config_from_published_manifest(
manifest: dict[str, Any],
rule_document: dict[str, Any],
) -> dict[str, Any]:
metadata = manifest.get("metadata") if isinstance(manifest.get("metadata"), dict) else {}
risk_score_detail = metadata.get("risk_score_detail") if isinstance(metadata.get("risk_score_detail"), dict) else {}
risk_level = str(metadata.get("risk_level") or manifest.get("outcomes", {}).get("fail", {}).get("severity") or "medium")
risk_score = int(metadata.get("risk_score") or manifest.get("outcomes", {}).get("fail", {}).get("risk_score") or 0)
return {
"severity": risk_level,
"risk_score": risk_score,
"risk_level": risk_level,
"risk_level_label": metadata.get("risk_level_label"),
"risk_score_detail": risk_score_detail,
"enabled": True,
"requires_attachment": bool(metadata.get("requires_attachment") or manifest.get("requires_attachment")),
"detail_mode": "json_risk",
"business_stage": metadata.get("business_stage"),
"business_stage_label": metadata.get("business_stage_label"),
"expense_category": metadata.get("expense_category"),
"expense_category_label": metadata.get("expense_category_label"),
"risk_category": manifest.get("risk_category"),
"rule_library": RISK_RULES_LIBRARY,
"rule_document": rule_document,
"ontology_signal": manifest.get("ontology_signal"),
"evaluator": manifest.get("evaluator"),
"generated_by": "natural_language",
"source_ref": "自然语言风险规则",
"flow_diagram_svg": manifest.get("flow_diagram_svg"),
}
def _refresh_asset(self, asset_id: str) -> AgentAsset:
refreshed = self.repository.get(asset_id)
if refreshed is None:
raise LookupError("Asset not found")
return refreshed

View File

@@ -0,0 +1,404 @@
from __future__ import annotations
from datetime import UTC, datetime
from typing import Any
from sqlalchemy.orm import Session
from app.core.agent_enums import AgentAssetDomain, AgentAssetStatus, AgentAssetType
from app.models.agent_asset import AgentAsset, AgentAssetVersion
from app.repositories.agent_asset import AgentAssetRepository
from app.schemas.agent_asset import (
AgentAssetRiskRuleGenerateRequest,
AgentAssetRiskRuleRegenerateRequest,
)
from app.services.agent_asset_rule_library import AgentAssetRuleLibraryManager
from app.services.agent_asset_spreadsheet import RISK_RULES_LIBRARY
from app.services.audit import AuditLogService
from app.services.risk_rule_generation import (
BUSINESS_DOMAIN_LABELS,
EXPENSE_BUSINESS_STAGE_LABELS,
EXPENSE_RISK_CATEGORY_LABELS,
RiskRuleGenerationService,
)
from app.services.risk_rule_generation_markdown import build_risk_rule_version_markdown
from app.services.risk_rule_dsl_validator import validate_risk_rule_draft
from app.services.risk_rule_scoring import apply_risk_score_to_draft, calculate_risk_rule_score
from app.services.runtime_chat import RuntimeChatService
class AgentAssetRiskRuleRegenerationService:
"""重新把自然语言草稿或修订草稿解释为可执行 JSON 风险规则。"""
def __init__(
self,
db: Session,
*,
rule_library_manager: AgentAssetRuleLibraryManager | None = None,
runtime_chat_service: RuntimeChatService | None = None,
) -> None:
self.db = db
self.repository = AgentAssetRepository(db)
self.rule_library_manager = rule_library_manager or AgentAssetRuleLibraryManager()
self.generator = RiskRuleGenerationService(
db,
rule_library_manager=self.rule_library_manager,
runtime_chat_service=runtime_chat_service,
)
self.audit_service = AuditLogService(db)
def regenerate(
self,
asset_id: str,
body: AgentAssetRiskRuleRegenerateRequest,
*,
actor: str,
request_id: str | None = None,
) -> AgentAsset:
asset = self._resolve_json_risk_asset(asset_id)
if str(asset.published_version or "").strip():
return self._regenerate_revision_draft(
asset,
body,
actor=actor,
request_id=request_id,
)
return self._regenerate_unpublished_draft(
asset,
body,
actor=actor,
request_id=request_id,
)
def _regenerate_unpublished_draft(
self,
asset: AgentAsset,
body: AgentAssetRiskRuleRegenerateRequest,
*,
actor: str,
request_id: str | None,
) -> AgentAsset:
if asset.status not in {AgentAssetStatus.DRAFT.value, AgentAssetStatus.FAILED.value}:
raise ValueError("只有未上线草稿或生成失败规则可以重新生成。")
before = self._snapshot(asset)
config = dict(asset.config_json or {})
request = self._build_generation_request(asset, config, body.model_dump(exclude_unset=True))
payload, risk_score = self._compile_payload(request, actor=actor, created_at=asset.created_at)
rule_code = self._stable_rule_code(asset, payload)
payload["rule_code"] = rule_code
file_name = f"{rule_code}.json"
self.rule_library_manager.write_rule_library_json(
library=RISK_RULES_LIBRARY,
file_name=file_name,
payload=payload,
)
version = str(asset.working_version or asset.current_version or "v0.1.0")
now = datetime.now(UTC).isoformat()
self._upsert_version(
asset,
version=version,
content=build_risk_rule_version_markdown(payload),
change_note="重新生成自然语言风险规则草稿。",
actor=actor,
)
config.update(self._config_from_payload(payload, risk_score=risk_score, request=request))
config.update(
{
"generation_status": "completed",
"generation_completed_at": now,
"last_operation": {"action": "regenerate", "actor": actor, "at": now},
}
)
asset.code = rule_code
asset.name = str(payload["name"])
asset.description = str(payload["description"])
asset.domain = str(request.get("business_domain") or AgentAssetDomain.EXPENSE.value)
asset.scenario_json = [str(payload.get("risk_category") or BUSINESS_DOMAIN_LABELS[asset.domain])]
asset.status = AgentAssetStatus.DRAFT.value
asset.current_version = version
asset.working_version = version
asset.config_json = config
self.db.add(asset)
self.db.flush()
self.audit_service.log_action(
actor=actor,
action="regenerate_risk_rule_draft",
resource_type=AgentAssetType.RULE.value,
resource_id=asset.id,
before_json=before,
after_json=self._snapshot(asset),
request_id=request_id,
)
return asset
def _regenerate_revision_draft(
self,
asset: AgentAsset,
body: AgentAssetRiskRuleRegenerateRequest,
*,
actor: str,
request_id: str | None,
) -> AgentAsset:
revision = self._resolve_revision_draft(asset)
revision_version = str(revision.get("version") or "").strip()
before = self._snapshot(asset)
config = dict(asset.config_json or {})
request = self._build_generation_request(
asset,
config,
body.model_dump(exclude_unset=True),
base=revision.get("generation_request") if isinstance(revision.get("generation_request"), dict) else {},
)
payload, risk_score = self._compile_payload(request, actor=actor, created_at=datetime.now(UTC))
payload["rule_code"] = str(asset.code or payload["rule_code"]).strip()
payload["enabled"] = False
payload.setdefault("metadata", {})["revision_version"] = revision_version
payload["metadata"]["revision_base_version"] = revision.get("base_version")
file_name = f"{payload['rule_code']}.{revision_version.replace('.', '_')}.json"
self.rule_library_manager.write_rule_library_json(
library=RISK_RULES_LIBRARY,
file_name=file_name,
payload=payload,
)
now = datetime.now(UTC).isoformat()
revision.update(
{
"status": "generated",
"generation_status": "completed",
"generation_request": request,
"generated_by": actor,
"generated_at": now,
"rule_code": payload["rule_code"],
"rule_document": {
"file_name": file_name,
"storage_key": f"rules/{RISK_RULES_LIBRARY}/{file_name}",
},
"risk_score": risk_score["score"],
"risk_level": risk_score["level"],
"risk_level_label": risk_score["level_label"],
"flow_diagram_svg": payload.get("flow_diagram_svg"),
"business_explanation": payload.get("metadata", {}).get("business_explanation"),
}
)
config["revision_draft"] = revision
config["last_operation"] = {
"action": "regenerate_revision",
"actor": actor,
"at": now,
"target_version": revision_version,
}
asset.working_version = revision_version
asset.config_json = config
self._upsert_version(
asset,
version=revision_version,
content=build_risk_rule_version_markdown(payload),
change_note=str(revision.get("change_reason") or "重新生成修订草稿"),
actor=actor,
)
self.db.add(asset)
self.db.flush()
self.audit_service.log_action(
actor=actor,
action="regenerate_risk_rule_revision",
resource_type=AgentAssetType.RULE.value,
resource_id=asset.id,
before_json=before,
after_json=self._snapshot(asset),
request_id=request_id,
)
return asset
def _compile_payload(
self,
request: dict[str, Any],
*,
actor: str,
created_at: datetime | None,
) -> tuple[dict[str, Any], dict[str, Any]]:
body = AgentAssetRiskRuleGenerateRequest.model_validate(request)
domain = body.business_domain.value
natural_language = self.generator._clean_text(body.natural_language)
rule_title = self.generator._clean_text(body.rule_title)
requires_attachment = bool(body.requires_attachment)
business_stage = self.generator._normalize_business_stage(body.business_stage, domain)
business_stage_label = EXPENSE_BUSINESS_STAGE_LABELS.get(business_stage, "费用报销")
expense_category = self.generator._normalize_expense_category(body.expense_category, domain)
expense_category_label = EXPENSE_RISK_CATEGORY_LABELS.get(expense_category or "", "")
fields = self.generator._resolve_fields(natural_language, domain=domain)
draft = self.generator._compile_with_model(
natural_language=natural_language,
domain=domain,
business_stage=business_stage,
business_stage_label=business_stage_label,
expense_category=expense_category,
expense_category_label=expense_category_label,
fields=fields,
) or self.generator._build_fallback_draft(
natural_language=natural_language,
domain=domain,
expense_category_label=expense_category_label,
risk_level="medium",
fields=fields,
)
draft = validate_risk_rule_draft(draft, fields=fields, natural_language=natural_language)
draft = self.generator._align_draft_fields(
draft,
natural_language=natural_language,
risk_level="medium",
fields=fields,
)
draft = validate_risk_rule_draft(draft, fields=fields, natural_language=natural_language)
risk_score = calculate_risk_rule_score(
natural_language=natural_language,
draft=draft,
fields=fields,
expense_category=expense_category,
expense_category_label=expense_category_label,
requires_attachment=requires_attachment,
)
risk_level = str(risk_score["level"])
draft = apply_risk_score_to_draft(draft, risk_score)
payload = self.generator._build_rule_payload(
draft,
natural_language=natural_language,
domain=domain,
business_stage=business_stage,
business_stage_label=business_stage_label,
expense_category=expense_category,
expense_category_label=expense_category_label,
risk_level=risk_level,
fields=fields,
created_at=created_at or datetime.now(UTC),
actor=actor,
requires_attachment=requires_attachment,
rule_title=rule_title,
risk_score=risk_score,
)
return payload, risk_score
def _resolve_json_risk_asset(self, asset_id: str) -> AgentAsset:
asset = self.repository.get(asset_id)
if asset is None:
raise FileNotFoundError("风险规则不存在。")
config = asset.config_json or {}
if asset.asset_type != AgentAssetType.RULE.value or config.get("detail_mode") != "json_risk":
raise ValueError("当前资产不是自然语言风险规则。")
return asset
def _resolve_revision_draft(self, asset: AgentAsset) -> dict[str, Any]:
config = dict(asset.config_json or {})
revision = config.get("revision_draft")
if not isinstance(revision, dict):
raise ValueError("已上线规则需要先创建修订版本,再重新生成。")
revision_version = str(revision.get("version") or "").strip()
if not revision_version or revision_version != str(asset.working_version or "").strip():
raise ValueError("修订草稿版本与当前工作版本不一致。")
if revision.get("status") not in {"draft", "generated", "failed"}:
raise ValueError("当前修订草稿状态不允许重新生成。")
return dict(revision)
@staticmethod
def _build_generation_request(
asset: AgentAsset,
config: dict[str, Any],
updates: dict[str, Any],
*,
base: dict[str, Any] | None = None,
) -> dict[str, Any]:
source = base if isinstance(base, dict) else config.get("generation_request")
merged = dict(source if isinstance(source, dict) else {})
merged.setdefault("business_domain", asset.domain or AgentAssetDomain.EXPENSE.value)
merged.setdefault("business_stage", config.get("business_stage") or "reimbursement")
merged.setdefault("expense_category", config.get("expense_category"))
merged.setdefault("rule_title", asset.name)
merged.setdefault("natural_language", asset.description)
merged.setdefault("requires_attachment", bool(config.get("requires_attachment")))
for key, value in updates.items():
merged[key] = value
return merged
@staticmethod
def _stable_rule_code(asset: AgentAsset, payload: dict[str, Any]) -> str:
current = str(asset.code or "").strip()
if current and ".generating_" not in current:
return current
return str(payload.get("rule_code") or current).strip()
@staticmethod
def _config_from_payload(
payload: dict[str, Any],
*,
risk_score: dict[str, Any],
request: dict[str, Any],
) -> dict[str, Any]:
file_name = f"{payload['rule_code']}.json"
return {
"severity": risk_score["level"],
"risk_score": risk_score["score"],
"risk_level": risk_score["level"],
"risk_level_label": risk_score["level_label"],
"risk_score_detail": risk_score,
"requires_attachment": bool(request.get("requires_attachment")),
"detail_mode": "json_risk",
"business_stage": request.get("business_stage"),
"business_stage_label": payload.get("metadata", {}).get("business_stage_label"),
"expense_category": request.get("expense_category"),
"expense_category_label": payload.get("metadata", {}).get("expense_category_label"),
"risk_category": payload.get("risk_category"),
"rule_library": RISK_RULES_LIBRARY,
"rule_document": {
"file_name": file_name,
"storage_key": f"rules/{RISK_RULES_LIBRARY}/{file_name}",
},
"ontology_signal": payload.get("ontology_signal"),
"evaluator": payload.get("evaluator"),
"generated_by": "natural_language",
"source_ref": "自然语言风险规则",
"generation_request": request,
"flow_diagram_svg": payload.get("flow_diagram_svg"),
}
def _upsert_version(
self,
asset: AgentAsset,
*,
version: str,
content: str,
change_note: str,
actor: str,
) -> None:
existing = self.repository.get_version(asset.id, version)
if existing is None:
self.db.add(
AgentAssetVersion(
asset_id=asset.id,
version=version,
content=content,
content_type="markdown",
change_note=change_note,
created_by=actor,
)
)
return
existing.content = content
existing.change_note = change_note
existing.created_by = actor
self.db.add(existing)
@staticmethod
def _snapshot(asset: AgentAsset) -> dict[str, Any]:
return {
"id": asset.id,
"code": asset.code,
"name": asset.name,
"description": asset.description,
"status": asset.status,
"current_version": asset.current_version,
"published_version": asset.published_version,
"working_version": asset.working_version,
"config_json": asset.config_json or {},
}

View File

@@ -40,7 +40,20 @@ class AgentAssetRiskRuleSimulationMixin:
attachments=attachments,
)
recognition_summary = self._build_recognition_summary(attachments)
fields = self._extract_manifest_fields(manifest)
ocr_raw_fields = self._build_ocr_raw_fields(attachments)
hermes_normalized_fields = self._build_hermes_normalized_fields(
fields,
field_values,
source_map,
)
required_keys = self._extract_execution_field_keys(manifest)
executor_input_fields = self._build_executor_input_fields(
fields,
field_values,
source_map,
required_keys,
)
missing_fields = self._build_missing_fields(
manifest,
field_values=field_values,
@@ -67,6 +80,9 @@ class AgentAssetRiskRuleSimulationMixin:
normalized_fields=field_values,
attachments=attachments,
recognized_fields=recognized_fields,
ocr_raw_fields=ocr_raw_fields,
hermes_normalized_fields=hermes_normalized_fields,
executor_input_fields=executor_input_fields,
missing_fields=missing_fields,
recognition_summary=recognition_summary,
created_at=datetime.now(UTC),
@@ -108,6 +124,9 @@ class AgentAssetRiskRuleSimulationMixin:
trace=execution["trace"] if isinstance(execution.get("trace"), dict) else {},
attachments=attachments,
recognized_fields=recognized_fields,
ocr_raw_fields=ocr_raw_fields,
hermes_normalized_fields=hermes_normalized_fields,
executor_input_fields=executor_input_fields,
missing_fields=[],
recognition_summary=recognition_summary,
created_at=datetime.now(UTC),
@@ -565,6 +584,108 @@ class AgentAssetRiskRuleSimulationMixin:
if source_map.get(key)
]
@staticmethod
def _build_ocr_raw_fields(attachments: list[dict[str, Any]]) -> list[dict[str, Any]]:
rows: list[dict[str, Any]] = []
for attachment_index, attachment in enumerate(attachments):
attachment_name = str(attachment.get("name") or f"attachment-{attachment_index + 1}")
for field_index, field in enumerate(list(attachment.get("document_fields") or [])):
if not isinstance(field, dict):
continue
value = field.get("value")
if not AgentAssetRiskRuleSimulationMixin._has_meaningful_value(value):
continue
key = str(field.get("key") or f"field_{field_index + 1}").strip()
label = str(field.get("label") or key).strip()
rows.append(
{
"key": key,
"label": label,
"value": value,
"source": "ocr",
"source_label": "OCR结构字段",
"attachment_name": attachment_name,
}
)
for key, label in (("summary", "单据摘要"), ("ocr_text", "OCR原文")):
value = attachment.get(key)
if AgentAssetRiskRuleSimulationMixin._has_meaningful_value(value):
rows.append(
{
"key": key,
"label": label,
"value": value,
"source": "ocr",
"source_label": "OCR文本",
"attachment_name": attachment_name,
}
)
return rows[:80]
@staticmethod
def _build_hermes_normalized_fields(
fields: list[dict[str, str]],
values: dict[str, Any],
source_map: dict[str, str],
) -> list[dict[str, Any]]:
labels = {field["key"]: field["label"] for field in fields}
rows: list[dict[str, Any]] = []
for key, value in values.items():
if not AgentAssetRiskRuleSimulationMixin._has_meaningful_value(value):
continue
source = source_map.get(key, "")
rows.append(
{
"key": key,
"label": labels.get(key, key),
"value": value,
"source": source,
"source_label": AgentAssetRiskRuleSimulationMixin._field_source_label(source),
}
)
return rows
@staticmethod
def _build_executor_input_fields(
fields: list[dict[str, str]],
values: dict[str, Any],
source_map: dict[str, str],
required_keys: list[str],
) -> list[dict[str, Any]]:
labels = {field["key"]: field["label"] for field in fields}
required_set = set(required_keys or [])
ordered_keys = [*required_keys]
for field in fields:
key = field["key"]
if key not in ordered_keys and key in values:
ordered_keys.append(key)
rows: list[dict[str, Any]] = []
for key in ordered_keys:
value = values.get(key)
if not AgentAssetRiskRuleSimulationMixin._has_meaningful_value(value):
continue
source = source_map.get(key, "")
rows.append(
{
"key": key,
"label": labels.get(key, key),
"value": value,
"source": source,
"source_label": AgentAssetRiskRuleSimulationMixin._field_source_label(source),
"required": key in required_set,
}
)
return rows
@staticmethod
def _field_source_label(source: str) -> str:
return {
"manual": "用户输入",
"ocr": "OCR结构字段",
"inferred": "文本推断",
"model_refined": "Hermes规范化",
}.get(str(source or "").strip(), "未标注来源")
@staticmethod
def _build_recognition_summary(attachments: list[dict[str, Any]]) -> list[dict[str, Any]]:
return [

View File

@@ -28,7 +28,9 @@ from app.schemas.agent_asset import (
)
from app.services.agent_asset_json_rules import AgentAssetJsonRuleMixin
from app.services.agent_asset_onlyoffice import AgentAssetOnlyOfficeMixin
from app.services.agent_asset_risk_rule_feedback import AgentAssetRiskRuleFeedbackMixin
from app.services.agent_asset_risk_rule_level import AgentAssetRiskRuleLevelMixin
from app.services.agent_asset_risk_rule_publish import AgentAssetRiskRulePublishMixin
from app.services.agent_asset_risk_rule_simulation import AgentAssetRiskRuleSimulationMixin
from app.services.agent_asset_risk_rule_testing import AgentAssetRiskRuleTestingMixin
from app.services.agent_asset_rule_library import AgentAssetRuleLibraryManager
@@ -47,6 +49,8 @@ class AgentAssetService(
AgentAssetOnlyOfficeMixin,
AgentAssetSpreadsheetHelperMixin,
AgentAssetRiskRuleLevelMixin,
AgentAssetRiskRulePublishMixin,
AgentAssetRiskRuleFeedbackMixin,
AgentAssetRiskRuleTestingMixin,
AgentAssetRiskRuleSimulationMixin,
AgentAssetTimelineMixin,

View File

@@ -126,14 +126,31 @@ class AgentFoundationAssetSeedMixin:
[
"---",
"name: risk-rule-discovery",
"description: 用于根据风险观察反馈生成候选规则,不直接上线",
"description: 兼容别名。用于归集申请和报销事实中的潜在线索,不生成规则",
"---",
"",
"# 风险规则候选发现",
"# 风险线索归集",
"",
"## 功能说明",
"",
"风险观察、人工反馈和误报复盘中生成带证据、来源和置信度的候选规则",
"申请、报销、规则命中和人工反馈中整理事实、证据和待复核线索",
],
)
def _risk_clue_collector_skill_markdown(self) -> str:
return self._read_domain_skill_markdown(
"risk-clue-collector",
[
"---",
"name: risk-clue-collector",
"description: 用于归集申请和报销事实中的潜在线索,不生成规则、不发布规则、不替代人工确认。",
"---",
"",
"# 风险线索归集",
"",
"## 功能说明",
"",
"从申请、报销、规则命中和人工反馈中整理事实、证据和待复核线索。",
],
)
@@ -370,6 +387,15 @@ class AgentFoundationAssetSeedMixin:
"folder": "财务制度",
"changed_only": True,
"output_format": "knowledge_organizing_report",
"allowed_outputs": [
"facts",
"policy_refs",
"evidence_refs",
"knowledge_items",
"human_review_required",
],
"role_boundary": "规则由人定义,风险由人确认,数字员工只整理人提供的制度和报销事实。",
"writes_rules": False,
},
)
@@ -452,6 +478,10 @@ class AgentFoundationAssetSeedMixin:
]
)
self.db.flush()
self._upsert_runtime_digital_employee_tasks(
set(self.db.scalars(select(AgentAsset.code)).all())
)
self.db.flush()
company_travel_rule_meta = self._ensure_company_travel_rule_spreadsheet_seed(
@@ -615,22 +645,6 @@ class AgentFoundationAssetSeedMixin:
change_note="初始化整理公司财务知识制度能力。",
created_by="系统初始化",
),
AgentAssetVersion(
asset=risk_graph_scan_task,
version="v1.0.0",
content=self._financial_risk_graph_scan_skill_markdown(),
content_type=AgentAssetContentType.MARKDOWN.value,
change_note="初始化财务风险图谱巡检能力。",
created_by="系统初始化",
),
AgentAssetVersion(
asset=employee_profile_scan_task,
version="v1.0.0",
content=self._employee_behavior_profile_scan_skill_markdown(),
content_type=AgentAssetContentType.MARKDOWN.value,
change_note="初始化员工行为画像巡检能力。",
created_by="系统初始化",
),
]
)

View File

@@ -614,6 +614,15 @@ class AgentFoundationAssetTopUpMixin:
"folder": "财务制度",
"changed_only": True,
"output_format": "knowledge_organizing_report",
"allowed_outputs": [
"facts",
"policy_refs",
"evidence_refs",
"knowledge_items",
"human_review_required",
],
"role_boundary": "规则由人定义,风险由人确认,数字员工只整理人提供的制度和报销事实。",
"writes_rules": False,
}
if DIGITAL_EMPLOYEE_FINANCE_POLICY_TASK_CODE not in existing_codes:
@@ -669,6 +678,15 @@ class AgentFoundationAssetTopUpMixin:
"folder": existing_config.get("folder") or "财务制度",
"changed_only": existing_config.get("changed_only", True),
"output_format": "knowledge_organizing_report",
"allowed_outputs": [
"facts",
"policy_refs",
"evidence_refs",
"knowledge_items",
"human_review_required",
],
"role_boundary": "规则由人定义,风险由人确认,数字员工只整理人提供的制度和报销事实。",
"writes_rules": False,
**schedule_config,
}
self.db.add(asset)

View File

@@ -96,6 +96,32 @@ DIGITAL_EMPLOYEE_PROFILE_SCAN_TASK_CODE = "task.hermes.employee_behavior_profile
DIGITAL_EMPLOYEE_RULE_DISCOVERY_TASK_CODE = "task.hermes.risk_rule_discovery"
DIGITAL_EMPLOYEE_POLICY_CLAUSE_EXTRACT_TASK_CODE = "task.hermes.finance_policy_clause_extract"
DIGITAL_EMPLOYEE_POLICY_ALIGNMENT_TASK_CODE = "task.hermes.expense_policy_alignment"
DIGITAL_EMPLOYEE_RULE_TEMPLATE_ORGANIZE_TASK_CODE = "task.hermes.risk_rule_template_organize"
DIGITAL_EMPLOYEE_DEPARTMENT_BASELINE_TASK_CODE = "task.hermes.department_expense_baseline_accumulate"
DIGITAL_EMPLOYEE_SUPPLIER_PROFILE_TASK_CODE = "task.hermes.supplier_risk_profile_accumulate"
DIGITAL_EMPLOYEE_FALSE_POSITIVE_SAMPLE_TASK_CODE = "task.hermes.false_positive_sample_accumulate"
DIGITAL_EMPLOYEE_FEEDBACK_SAMPLE_TASK_CODE = "task.hermes.risk_feedback_sample_accumulate"
DIGITAL_EMPLOYEE_MULTI_EVIDENCE_TASK_CODE = "task.hermes.multi_evidence_consistency_evaluate"
DIGITAL_EMPLOYEE_SPATIOTEMPORAL_TASK_CODE = "task.hermes.travel_spatiotemporal_consistency_evaluate"
DIGITAL_EMPLOYEE_BUDGET_PRECONTROL_TASK_CODE = "task.hermes.budget_overrun_precontrol_evaluate"
DIGITAL_EMPLOYEE_SUPPLIER_RELATION_TASK_CODE = "task.hermes.supplier_abnormal_relation_evaluate"
DIGITAL_EMPLOYEE_ALGORITHM_REPLAY_TASK_CODE = "task.hermes.risk_algorithm_replay_evaluate"
DIGITAL_EMPLOYEE_POLICY_GAP_TASK_CODE = "task.hermes.policy_gap_rule_optimization"
DIGITAL_EMPLOYEE_LEGACY_TASK_CODES = (
"task.hermes.daily_risk_scan",
"task.hermes.weekly_ar_summary",
@@ -107,8 +133,21 @@ DIGITAL_EMPLOYEE_LEGACY_TASK_CODES = (
DIGITAL_EMPLOYEE_TASK_CATEGORY_MAP = {
DIGITAL_EMPLOYEE_FINANCE_POLICY_TASK_CODE: "整理",
DIGITAL_EMPLOYEE_RISK_GRAPH_SCAN_TASK_CODE: "评估",
DIGITAL_EMPLOYEE_PROFILE_SCAN_TASK_CODE: "评估",
DIGITAL_EMPLOYEE_PROFILE_SCAN_TASK_CODE: "积累",
DIGITAL_EMPLOYEE_RULE_DISCOVERY_TASK_CODE: "升级",
DIGITAL_EMPLOYEE_POLICY_CLAUSE_EXTRACT_TASK_CODE: "整理",
DIGITAL_EMPLOYEE_POLICY_ALIGNMENT_TASK_CODE: "整理",
DIGITAL_EMPLOYEE_RULE_TEMPLATE_ORGANIZE_TASK_CODE: "整理",
DIGITAL_EMPLOYEE_DEPARTMENT_BASELINE_TASK_CODE: "积累",
DIGITAL_EMPLOYEE_SUPPLIER_PROFILE_TASK_CODE: "积累",
DIGITAL_EMPLOYEE_FALSE_POSITIVE_SAMPLE_TASK_CODE: "积累",
DIGITAL_EMPLOYEE_FEEDBACK_SAMPLE_TASK_CODE: "积累",
DIGITAL_EMPLOYEE_MULTI_EVIDENCE_TASK_CODE: "评估",
DIGITAL_EMPLOYEE_SPATIOTEMPORAL_TASK_CODE: "评估",
DIGITAL_EMPLOYEE_BUDGET_PRECONTROL_TASK_CODE: "评估",
DIGITAL_EMPLOYEE_SUPPLIER_RELATION_TASK_CODE: "评估",
DIGITAL_EMPLOYEE_ALGORITHM_REPLAY_TASK_CODE: "升级",
DIGITAL_EMPLOYEE_POLICY_GAP_TASK_CODE: "升级",
}
ATTACHMENT_RULE_RUNTIME_CONFIG = {

View File

@@ -11,16 +11,126 @@ from app.core.agent_enums import (
)
from app.models.agent_asset import AgentAsset
from app.services.agent_foundation_constants import (
DIGITAL_EMPLOYEE_ALGORITHM_REPLAY_TASK_CODE,
DIGITAL_EMPLOYEE_BUDGET_PRECONTROL_TASK_CODE,
DIGITAL_EMPLOYEE_DEPARTMENT_BASELINE_TASK_CODE,
DIGITAL_EMPLOYEE_FALSE_POSITIVE_SAMPLE_TASK_CODE,
DIGITAL_EMPLOYEE_FEEDBACK_SAMPLE_TASK_CODE,
DIGITAL_EMPLOYEE_MULTI_EVIDENCE_TASK_CODE,
DIGITAL_EMPLOYEE_POLICY_ALIGNMENT_TASK_CODE,
DIGITAL_EMPLOYEE_POLICY_CLAUSE_EXTRACT_TASK_CODE,
DIGITAL_EMPLOYEE_POLICY_GAP_TASK_CODE,
DIGITAL_EMPLOYEE_PROFILE_SCAN_TASK_CODE,
DIGITAL_EMPLOYEE_RISK_GRAPH_SCAN_TASK_CODE,
DIGITAL_EMPLOYEE_RULE_DISCOVERY_TASK_CODE,
DIGITAL_EMPLOYEE_RULE_TEMPLATE_ORGANIZE_TASK_CODE,
DIGITAL_EMPLOYEE_SKILL_CATEGORIES,
DIGITAL_EMPLOYEE_SPATIOTEMPORAL_TASK_CODE,
DIGITAL_EMPLOYEE_SUPPLIER_PROFILE_TASK_CODE,
DIGITAL_EMPLOYEE_SUPPLIER_RELATION_TASK_CODE,
)
DIGITAL_EMPLOYEE_ANALYSIS_ROLE_BOUNDARY = (
"规则由人定义,风险由人确认,主流程由外层智能体执行,"
"数字员工只读取事实、规则命中和反馈结果,生成后台分析、报告和待复核材料。"
)
class AgentFoundationDigitalEmployeeTaskMixin:
def _runtime_digital_employee_task_specs(self) -> tuple[dict[str, object], ...]:
return (
self._digital_employee_task_spec(
code=DIGITAL_EMPLOYEE_POLICY_CLAUSE_EXTRACT_TASK_CODE,
name="制度条款结构化抽取",
description="按计划从财务制度和报销政策中抽取适用范围、限制条件、金额标准、审批要求和证据字段。",
scenario_json=["schedule", "knowledge", "policy_clause", "ontology"],
owner="财务制度管理组",
cron="15 3 * * *",
skill_category="整理",
skill_name="finance-policy-clause-extractor",
output_format="policy_clause_structuring_report",
input_sources=["finance_policies", "knowledge_documents", "ontology_parse_logs"],
execution_strategy="definition_ready",
),
self._digital_employee_task_spec(
code=DIGITAL_EMPLOYEE_POLICY_ALIGNMENT_TASK_CODE,
name="报销政策口径对齐",
description="对齐不同制度、规则中心和知识库中的报销口径,发现同义、冲突、缺失和过期条款。",
scenario_json=["schedule", "knowledge", "expense_policy", "rule_center"],
owner="财务制度管理组",
cron="30 3 * * *",
skill_category="整理",
skill_name="expense-policy-alignment",
output_format="policy_alignment_report",
input_sources=["finance_policies", "risk_rules", "knowledge_items"],
execution_strategy="definition_ready",
),
self._digital_employee_task_spec(
code=DIGITAL_EMPLOYEE_RULE_TEMPLATE_ORGANIZE_TASK_CODE,
name="规则命中样本整理",
description="把外层智能体流程已经产生的规则命中、制度引用和历史样本整理为字段映射与复核材料,不新增、不改写、不发布规则。",
scenario_json=["schedule", "rule_hit", "risk_rule", "policy_ref"],
owner="风控与审计部",
cron="45 3 * * 1",
skill_category="整理",
skill_name="rule-execution-case-organizer",
output_format="rule_hit_sample_pack",
input_sources=["approved_risk_rules", "policy_refs", "rule_hits"],
execution_strategy="definition_ready",
),
self._digital_employee_task_spec(
code=DIGITAL_EMPLOYEE_DEPARTMENT_BASELINE_TASK_CODE,
name="部门费用基线沉淀",
description="按部门、费用类型和时间窗口沉淀费用基线,为预算柔性控制和同类对比提供长期参照。",
scenario_json=["schedule", "department", "baseline", "expense"],
owner="风控与审计部",
cron="45 8 * * 1",
skill_category="积累",
skill_name="department-expense-baseline-accumulator",
output_format="department_expense_baseline_snapshot",
input_sources=["expense_claims", "expense_items", "profile_baselines"],
execution_strategy="reuse_employee_profile_baseline",
),
self._digital_employee_task_spec(
code=DIGITAL_EMPLOYEE_SUPPLIER_PROFILE_TASK_CODE,
name="供应商风险画像沉淀",
description="沉淀供应商、商户、酒店和收款方的费用频次、金额分布、异常关系和历史风险反馈。",
scenario_json=["schedule", "supplier", "baseline", "risk_graph"],
owner="风控与审计部",
cron="0 8 * * 2",
skill_category="积累",
skill_name="supplier-risk-profile-accumulator",
output_format="supplier_risk_profile_snapshot",
input_sources=["expense_claims", "invoice_entities", "risk_observations"],
execution_strategy="reuse_profile_baseline",
),
self._digital_employee_task_spec(
code=DIGITAL_EMPLOYEE_FALSE_POSITIVE_SAMPLE_TASK_CODE,
name="历史误报样本沉淀",
description="归集被人工标记为误报、忽略或撤销的风险观察,形成算法回放和人工复核校准样本。",
scenario_json=["schedule", "false_positive", "feedback", "replay"],
owner="风控与审计部",
cron="20 10 * * 1",
skill_category="积累",
skill_name="false-positive-sample-accumulator",
output_format="false_positive_sample_pool",
input_sources=["risk_observations", "risk_observation_feedback"],
execution_strategy="definition_ready",
),
self._digital_employee_task_spec(
code=DIGITAL_EMPLOYEE_FEEDBACK_SAMPLE_TASK_CODE,
name="风险观察反馈样本沉淀",
description="归集确认、补件、升级、改写和人工复核反馈,形成风险观察反馈样本池。",
scenario_json=["schedule", "feedback", "risk_observation", "sample_pool"],
owner="风控与审计部",
cron="40 10 * * 1",
skill_category="积累",
skill_name="risk-feedback-sample-accumulator",
output_format="risk_feedback_sample_pool",
input_sources=["risk_observations", "risk_observation_feedback", "agent_runs"],
execution_strategy="definition_ready",
),
{
"code": DIGITAL_EMPLOYEE_RISK_GRAPH_SCAN_TASK_CODE,
"name": "财务风险图谱巡检",
@@ -43,6 +153,15 @@ class AgentFoundationDigitalEmployeeTaskMixin:
],
"output_format": "risk_observation_report",
"writes_risk_observations": True,
"allowed_outputs": [
"facts",
"rule_hits",
"risk_clues",
"evidence_refs",
"human_review_required",
],
"role_boundary": DIGITAL_EMPLOYEE_ANALYSIS_ROLE_BOUNDARY,
"writes_rules": False,
},
},
{
@@ -53,7 +172,7 @@ class AgentFoundationDigitalEmployeeTaskMixin:
"owner": "风控与审计部",
"reviewer": "顾承宇",
"cron": "30 8 * * 1",
"skill_category": "评估",
"skill_category": "积累",
"markdown": self._employee_behavior_profile_scan_skill_markdown,
"change_note": "初始化员工行为画像巡检能力。",
"config": {
@@ -66,30 +185,199 @@ class AgentFoundationDigitalEmployeeTaskMixin:
],
"output_format": "employee_behavior_profile_snapshot",
"writes_profile_snapshots": True,
"allowed_outputs": [
"facts",
"profile_snapshots",
"baseline_metrics",
"evidence_refs",
"human_review_required",
],
"role_boundary": DIGITAL_EMPLOYEE_ANALYSIS_ROLE_BOUNDARY,
"writes_rules": False,
},
},
self._digital_employee_task_spec(
code=DIGITAL_EMPLOYEE_MULTI_EVIDENCE_TASK_CODE,
name="单据多凭证一致性评估",
description="比对报销单、费用明细、发票、流水、合同和事前申请之间的金额、数量、主体和时间字段。",
scenario_json=["schedule", "expense", "multi_evidence", "risk_observation"],
owner="风控与审计部",
cron="15 9 * * *",
skill_category="评估",
skill_name="multi-evidence-consistency-evaluator",
output_format="multi_evidence_consistency_report",
input_sources=["expense_claims", "expense_items", "invoices", "attachments"],
execution_strategy="reuse_financial_risk_graph_scan",
),
self._digital_employee_task_spec(
code=DIGITAL_EMPLOYEE_SPATIOTEMPORAL_TASK_CODE,
name="差旅时空一致性评估",
description="评估差旅发生时间、提交时间、票据地点、消费地点、行程轨迹和开票地点是否一致。",
scenario_json=["schedule", "travel", "spatiotemporal", "risk_observation"],
owner="风控与审计部",
cron="30 9 * * *",
skill_category="评估",
skill_name="travel-spatiotemporal-consistency-evaluator",
output_format="spatiotemporal_consistency_report",
input_sources=["expense_claims", "expense_items", "invoice_locations", "travel_routes"],
execution_strategy="reuse_financial_risk_graph_scan",
),
self._digital_employee_task_spec(
code=DIGITAL_EMPLOYEE_BUDGET_PRECONTROL_TASK_CODE,
name="预算占用与超标预警",
description="评估预算占用、费用标准、历史基线和柔性控制边界,输出提交前或审批前预警建议。",
scenario_json=["schedule", "budget", "expense", "precontrol"],
owner="预算管理组",
cron="45 9 * * *",
skill_category="评估",
skill_name="budget-overrun-precontrol-evaluator",
output_format="budget_precontrol_warning_report",
input_sources=["expense_claims", "budget_snapshots", "policy_refs", "profile_baselines"],
execution_strategy="definition_ready",
),
self._digital_employee_task_spec(
code=DIGITAL_EMPLOYEE_SUPPLIER_RELATION_TASK_CODE,
name="供应商异常关系评估",
description="识别员工、部门、供应商、票据和报销单之间的异常聚集、重复关系和跨部门集中风险。",
scenario_json=["schedule", "supplier", "risk_graph", "relationship"],
owner="风控与审计部",
cron="0 9 * * 2",
skill_category="评估",
skill_name="supplier-abnormal-relation-evaluator",
output_format="supplier_abnormal_relation_report",
input_sources=["risk_graph", "expense_claims", "invoice_entities", "entity_registry"],
execution_strategy="reuse_financial_risk_graph_scan",
),
{
"code": DIGITAL_EMPLOYEE_RULE_DISCOVERY_TASK_CODE,
"name": "风险规则候选发现",
"description": "按计划复盘风险观察和人工反馈,生成带证据、来源和置信度的候选规则,不直接上线",
"scenario_json": ["schedule", "risk_observation", "feedback", "rule_candidate"],
"name": "风险线索归集",
"description": "按计划复盘申请、报销、规则命中和人工反馈,归集带事实依据的潜在线索,提交人工复核,不生成规则",
"scenario_json": ["schedule", "application", "reimbursement", "risk_clue"],
"owner": "风控与审计部",
"reviewer": "顾承宇",
"cron": "0 10 * * 1",
"skill_category": "升级",
"markdown": self._risk_rule_discovery_skill_markdown,
"change_note": "初始化风险规则候选发现能力。",
"markdown": self._risk_clue_collector_skill_markdown,
"change_note": "初始化风险线索归集能力。",
"config": {
"skill_name": "risk-rule-discovery",
"task_type": "risk_clue_collect",
"skill_name": "risk-clue-collector",
"input_sources": [
"risk_observations",
"expense_applications",
"expense_claims",
"rule_hits",
"risk_observation_feedback",
"algorithm_replay_sets",
],
"output_format": "candidate_risk_rules",
"auto_publish": False,
"output_format": "risk_clue_review_packet",
"allowed_outputs": [
"facts",
"rule_hits",
"risk_clues",
"evidence_refs",
"human_review_required",
],
"role_boundary": DIGITAL_EMPLOYEE_ANALYSIS_ROLE_BOUNDARY,
"writes_rules": False,
"human_review_required": True,
},
},
self._digital_employee_task_spec(
code=DIGITAL_EMPLOYEE_ALGORITHM_REPLAY_TASK_CODE,
name="风险算法回放评测",
description="复跑历史风险观察、反馈标签、本体版本和规则版本,评估算法升级前后的误报率和确认率。",
scenario_json=["schedule", "algorithm_replay", "evaluation", "feedback"],
owner="风控与审计部",
cron="30 10 * * 1",
skill_category="升级",
skill_name="risk-algorithm-replay-evaluator",
output_format="algorithm_replay_evaluation_report",
input_sources=["algorithm_replay_sets", "risk_observations", "risk_observation_feedback"],
execution_strategy="definition_ready",
),
self._digital_employee_task_spec(
code=DIGITAL_EMPLOYEE_POLICY_GAP_TASK_CODE,
name="制度引用缺口提示",
description="整理申请、报销、规则命中和人工反馈中缺少制度引用的事实位置,提示人工补齐制度依据,不输出规则变更建议。",
scenario_json=["schedule", "policy_reference", "evidence_gap", "human_review"],
owner="财务制度管理组",
cron="0 11 * * 1",
skill_category="升级",
skill_name="policy-reference-gap-hinter",
output_format="policy_reference_gap_hint_report",
input_sources=["policy_refs", "rule_hits", "expense_claims", "risk_feedback_samples"],
execution_strategy="definition_ready",
),
)
def _digital_employee_task_spec(
self,
*,
code: str,
name: str,
description: str,
scenario_json: list[str],
owner: str,
cron: str,
skill_category: str,
skill_name: str,
output_format: str,
input_sources: list[str],
execution_strategy: str,
) -> dict[str, object]:
return {
"code": code,
"name": name,
"description": description,
"scenario_json": scenario_json,
"owner": owner,
"reviewer": "顾承宇",
"cron": cron,
"skill_category": skill_category,
"markdown": lambda: self._generic_digital_employee_skill_markdown(
skill_name=skill_name,
title=name,
description=description,
),
"change_note": f"初始化{name}能力。",
"config": {
"skill_name": skill_name,
"input_sources": input_sources,
"output_format": output_format,
"writes_work_record": True,
"execution_strategy": execution_strategy,
"allowed_outputs": [
"facts",
"rule_hits",
"risk_clues",
"evidence_refs",
"human_review_required",
],
"role_boundary": DIGITAL_EMPLOYEE_ANALYSIS_ROLE_BOUNDARY,
"writes_rules": False,
},
}
def _generic_digital_employee_skill_markdown(
self,
*,
skill_name: str,
title: str,
description: str,
) -> str:
return self._read_domain_skill_markdown(
skill_name,
[
"---",
f"name: {skill_name}",
f"description: {description}",
"---",
"",
f"# {title}",
"",
"## 功能说明",
"",
description,
],
)
def _upsert_runtime_digital_employee_tasks(self, existing_codes: set[str]) -> None:
@@ -146,6 +434,7 @@ class AgentFoundationDigitalEmployeeTaskMixin:
cron = str(spec["cron"])
base = {
**self._digital_employee_task_config(code, cron),
"skill_category": str(spec["skill_category"]),
"schedule": cron,
"cron_expression": cron,
**dict(spec["config"]),

View File

@@ -24,6 +24,7 @@ from app.services.agent_foundation_constants import (
logger = get_logger("app.services.agent_foundation")
EXPENSE_TYPE_SCENARIO_LABELS = {
"all": "全部",
"travel": "差旅费",
"hotel": "住宿费",
"transport": "交通费",
@@ -158,6 +159,10 @@ class AgentFoundationRiskRuleMixin:
metadata = manifest.get("metadata") if isinstance(manifest.get("metadata"), dict) else {}
candidates.extend(_collect(metadata.get("expense_types")))
all_scope_values = {"all", "*", "overall", "general", "全部", "通用"}
if any(str(item or "").strip().lower() in all_scope_values for item in candidates):
return ["all"]
normalized: list[str] = []
seen: set[str] = set()
for item in candidates:
@@ -170,6 +175,9 @@ class AgentFoundationRiskRuleMixin:
@staticmethod
def _expense_type_scenario_labels(expense_types: list[str]) -> list[str]:
if any(str(item or "").strip().lower() in {"all", "*", "overall", "general"} for item in expense_types):
return ["全部"]
labels: list[str] = []
seen: set[str] = set()
for expense_type in expense_types:

View File

@@ -0,0 +1,530 @@
from __future__ import annotations
from datetime import UTC, date, datetime
from decimal import Decimal
from typing import Any
from sqlalchemy import func, select
from sqlalchemy.orm import Session
from app.core.logging import get_logger
from app.db.base import Base
from app.models.agent_conversation import AgentConversationMessage
from app.models.agent_run import AgentRun, AgentToolCall, AgentTraceEvent, SemanticParseLog
from app.schemas.agent_run import AgentRunRead, AgentToolCallRead, SemanticParseRead
from app.schemas.agent_trace import (
AgentConversationTraceRead,
AgentTraceDetailRead,
AgentTraceEventRead,
AgentTraceListItem,
)
from app.schemas.orchestrator import ConversationMessageRead
logger = get_logger("app.services.agent_traces")
class AgentTraceService:
def __init__(self, db: Session) -> None:
self.db = db
def ensure_storage_ready(self) -> None:
Base.metadata.create_all(bind=self.db.get_bind(), tables=[AgentTraceEvent.__table__])
def record_event(
self,
*,
run_id: str,
stage: str,
event_name: str,
title: str,
status: str = "succeeded",
conversation_id: str | None = None,
summary: str | None = None,
input_json: dict[str, Any] | None = None,
output_json: dict[str, Any] | None = None,
error_message: str | None = None,
started_at: datetime | None = None,
finished_at: datetime | None = None,
duration_ms: int | None = None,
) -> AgentTraceEventRead:
self.ensure_storage_ready()
started = _normalize_datetime(started_at) or datetime.now(UTC)
finished = _normalize_datetime(finished_at)
if finished is None and status != "running":
finished = started
event = AgentTraceEvent(
run_id=str(run_id or "").strip(),
conversation_id=_optional_text(conversation_id),
sequence=self._next_sequence(run_id),
stage=str(stage or "orchestrator").strip() or "orchestrator",
event_name=str(event_name or "").strip() or "event",
title=str(title or event_name or "Trace event").strip(),
summary=_optional_text(summary),
status=str(status or "succeeded").strip() or "succeeded",
input_json=_json_safe(input_json or {}),
output_json=_json_safe(output_json or {}),
error_message=_optional_text(error_message),
started_at=started,
finished_at=finished,
duration_ms=_resolve_duration_ms(started, finished, duration_ms),
)
self.db.add(event)
self.db.commit()
self.db.refresh(event)
return AgentTraceEventRead.model_validate(event)
def record_event_safe(self, **kwargs: Any) -> AgentTraceEventRead | None:
try:
return self.record_event(**kwargs)
except Exception:
self.db.rollback()
logger.exception("Failed to record agent trace event run_id=%s", kwargs.get("run_id"))
return None
def record_tool_event_safe(
self,
run_id: str,
tool_type: str,
tool_name: str,
request_json: dict[str, Any],
response_json: dict[str, Any],
status: str,
duration_ms: int,
context_json: dict[str, Any],
error_message: str | None = None,
) -> AgentTraceEventRead | None:
return self.record_event_safe(
run_id=run_id,
conversation_id=str(context_json.get("conversation_id") or "").strip() or None,
stage="tool",
event_name="tool_invoked",
title=tool_name,
status=status,
summary=f"{tool_type} / {status}",
input_json=request_json,
output_json=response_json,
error_message=error_message,
duration_ms=duration_ms,
)
def list_traces(
self,
*,
agent: str | None = None,
status: str | None = None,
source: str | None = None,
conversation_id: str | None = None,
keyword: str | None = None,
limit: int = 30,
) -> list[AgentTraceListItem]:
self.ensure_storage_ready()
normalized_limit = max(1, min(int(limit or 30), 100))
fetch_limit = normalized_limit * 4 if keyword else normalized_limit
stmt = select(AgentRun)
if agent:
stmt = stmt.where(AgentRun.agent == agent)
if status:
stmt = stmt.where(AgentRun.status == status)
if source:
stmt = stmt.where(AgentRun.source == source)
if conversation_id:
run_ids = self._conversation_run_ids(conversation_id)
if not run_ids:
return []
stmt = stmt.where(AgentRun.run_id.in_(run_ids))
stmt = stmt.order_by(AgentRun.started_at.desc()).limit(fetch_limit)
runs = list(self.db.scalars(stmt).all())
event_counts = self._event_counts([run.run_id for run in runs])
keyword_text = str(keyword or "").strip().lower()
items = [self._build_trace_list_item(run, event_counts.get(run.run_id, 0)) for run in runs]
if keyword_text:
items = [item for item in items if self._matches_keyword(item, keyword_text)]
return items[:normalized_limit]
def get_trace(self, run_id: str) -> AgentTraceDetailRead | None:
self.ensure_storage_ready()
normalized_run_id = str(run_id or "").strip()
if not normalized_run_id:
return None
run = self.db.scalar(select(AgentRun).where(AgentRun.run_id == normalized_run_id))
if run is None:
return None
db_events = list(
self.db.scalars(
select(AgentTraceEvent)
.where(AgentTraceEvent.run_id == normalized_run_id)
.order_by(AgentTraceEvent.sequence.asc(), AgentTraceEvent.started_at.asc())
).all()
)
conversation_id = self._resolve_conversation_id(run, db_events)
events = [AgentTraceEventRead.model_validate(event) for event in db_events]
fallback_generated = False
if not events:
events = self._build_fallback_events(run, conversation_id)
fallback_generated = True
return AgentTraceDetailRead(
run=self._serialize_run(run),
conversation_id=conversation_id,
events=events,
semantic_parse=self._serialize_semantic_parse(self._first_semantic_parse(run)),
tool_calls=[AgentToolCallRead.model_validate(item) for item in run.tool_calls],
conversation_messages=self._conversation_messages(conversation_id),
fallback_generated=fallback_generated,
)
def get_conversation_trace(self, conversation_id: str) -> AgentConversationTraceRead:
normalized_conversation_id = str(conversation_id or "").strip()
run_ids = self._conversation_run_ids(normalized_conversation_id)
details = []
for run_id in run_ids:
detail = self.get_trace(run_id)
if detail is not None:
details.append(detail)
return AgentConversationTraceRead(
conversation_id=normalized_conversation_id,
runs=details,
)
def _next_sequence(self, run_id: str) -> int:
current = self.db.scalar(
select(func.max(AgentTraceEvent.sequence)).where(AgentTraceEvent.run_id == run_id)
)
return int(current or 0) + 1
def _event_counts(self, run_ids: list[str]) -> dict[str, int]:
if not run_ids:
return {}
rows = self.db.execute(
select(AgentTraceEvent.run_id, func.count(AgentTraceEvent.id))
.where(AgentTraceEvent.run_id.in_(run_ids))
.group_by(AgentTraceEvent.run_id)
).all()
return {str(run_id): int(count or 0) for run_id, count in rows}
def _build_trace_list_item(self, run: AgentRun, event_count: int) -> AgentTraceListItem:
semantic_parse = self._first_semantic_parse(run)
failed_tools = sum(1 for item in run.tool_calls if item.status == "failed")
title = self._resolve_run_title(run, semantic_parse)
finished_at = _normalize_datetime(run.finished_at)
started_at = _normalize_datetime(run.started_at) or datetime.now(UTC)
return AgentTraceListItem(
run_id=run.run_id,
conversation_id=self._resolve_conversation_id(run, []),
agent=run.agent,
source=run.source,
status=run.status,
scenario=semantic_parse.scenario if semantic_parse is not None else None,
intent=semantic_parse.intent if semantic_parse is not None else None,
title=title,
summary=run.result_summary,
event_count=event_count,
tool_call_count=len(run.tool_calls),
failed_tool_call_count=failed_tools,
started_at=started_at,
finished_at=finished_at,
duration_ms=_resolve_duration_ms(started_at, finished_at, None),
)
@staticmethod
def _matches_keyword(item: AgentTraceListItem, keyword: str) -> bool:
corpus = " ".join(
str(value or "")
for value in (
item.run_id,
item.conversation_id,
item.agent,
item.source,
item.status,
item.scenario,
item.intent,
item.title,
item.summary,
)
).lower()
return keyword in corpus
def _resolve_conversation_id(
self,
run: AgentRun,
events: list[AgentTraceEvent],
) -> str | None:
route_value = (run.route_json or {}).get("conversation_id")
if route_value:
return str(route_value).strip() or None
for event in events:
if event.conversation_id:
return str(event.conversation_id).strip() or None
message = self.db.scalar(
select(AgentConversationMessage)
.where(AgentConversationMessage.run_id == run.run_id)
.order_by(AgentConversationMessage.created_at.asc())
)
return str(message.conversation_id).strip() if message is not None else None
def _conversation_run_ids(self, conversation_id: str) -> list[str]:
normalized = str(conversation_id or "").strip()
if not normalized:
return []
self.ensure_storage_ready()
run_ids: list[str] = []
seen: set[str] = set()
def append_run_id(value: str | None) -> None:
run_id = str(value or "").strip()
if run_id and run_id not in seen:
seen.add(run_id)
run_ids.append(run_id)
messages = list(
self.db.scalars(
select(AgentConversationMessage)
.where(AgentConversationMessage.conversation_id == normalized)
.order_by(AgentConversationMessage.created_at.asc())
).all()
)
for message in messages:
append_run_id(message.run_id)
trace_event_run_ids = list(
self.db.scalars(
select(AgentTraceEvent.run_id)
.where(AgentTraceEvent.conversation_id == normalized)
.order_by(AgentTraceEvent.created_at.asc(), AgentTraceEvent.sequence.asc())
).all()
)
for run_id in trace_event_run_ids:
append_run_id(run_id)
recent_runs = list(
self.db.scalars(
select(AgentRun).order_by(AgentRun.started_at.desc()).limit(500)
).all()
)
for run in reversed(recent_runs):
if str((run.route_json or {}).get("conversation_id") or "").strip() == normalized:
append_run_id(run.run_id)
return run_ids
def _conversation_messages(self, conversation_id: str | None) -> list[ConversationMessageRead]:
if not conversation_id:
return []
messages = list(
self.db.scalars(
select(AgentConversationMessage)
.where(AgentConversationMessage.conversation_id == conversation_id)
.order_by(AgentConversationMessage.created_at.asc())
.limit(100)
).all()
)
return [
ConversationMessageRead(
id=item.id,
role=item.role,
content=item.content,
run_id=item.run_id,
message_json=item.message_json or {},
created_at=item.created_at,
)
for item in messages
]
def _build_fallback_events(
self,
run: AgentRun,
conversation_id: str | None,
) -> list[AgentTraceEventRead]:
events: list[AgentTraceEventRead] = []
started_at = _normalize_datetime(run.started_at) or datetime.now(UTC)
semantic_parse = self._first_semantic_parse(run)
def append_event(
*,
stage: str,
event_name: str,
title: str,
status: str,
summary: str | None,
started: datetime,
finished: datetime | None = None,
input_json: dict[str, Any] | None = None,
output_json: dict[str, Any] | None = None,
error_message: str | None = None,
) -> None:
sequence = len(events) + 1
resolved_finished = finished or started
events.append(
AgentTraceEventRead(
id=f"fallback-{run.run_id}-{sequence}",
run_id=run.run_id,
conversation_id=conversation_id,
sequence=sequence,
stage=stage,
event_name=event_name,
title=title,
summary=summary,
status=status,
input_json=_json_safe(input_json or {}),
output_json=_json_safe(output_json or {}),
error_message=error_message,
started_at=started,
finished_at=resolved_finished,
duration_ms=_resolve_duration_ms(started, resolved_finished, None),
created_at=started,
)
)
append_event(
stage="orchestrator",
event_name="run_created",
title="运行记录",
status="succeeded",
summary="由历史 AgentRun 合成的 trace 起点。",
started=started_at,
output_json={"agent": run.agent, "source": run.source, "status": run.status},
)
if semantic_parse is not None:
append_event(
stage="semantic",
event_name="semantic_parsed",
title="语义解析",
status="succeeded",
summary=f"{semantic_parse.scenario} / {semantic_parse.intent}",
started=_normalize_datetime(semantic_parse.created_at) or started_at,
input_json={"raw_query": semantic_parse.raw_query},
output_json=self._semantic_parse_payload(semantic_parse),
)
if run.route_json:
append_event(
stage="route",
event_name="route_resolved",
title="路由上下文",
status="succeeded",
summary=str(run.route_json.get("route_reason") or run.route_json.get("stage") or "已记录路由信息"),
started=started_at,
output_json=run.route_json,
)
for tool_call in run.tool_calls:
append_event(
stage="tool",
event_name="tool_invoked",
title=tool_call.tool_name,
status=tool_call.status,
summary=f"{tool_call.tool_type} / {tool_call.status}",
started=_normalize_datetime(tool_call.created_at) or started_at,
finished=_normalize_datetime(tool_call.created_at) or started_at,
input_json=tool_call.request_json,
output_json=tool_call.response_json,
error_message=tool_call.error_message,
)
append_event(
stage="response",
event_name="response_built" if run.status != "failed" else "failed",
title="最终结果",
status=run.status,
summary=run.result_summary or run.error_message,
started=_normalize_datetime(run.finished_at) or started_at,
output_json={"result_summary": run.result_summary},
error_message=run.error_message,
)
return events
@staticmethod
def _resolve_run_title(run: AgentRun, semantic_parse: SemanticParseLog | None) -> str:
if semantic_parse is not None:
return f"{semantic_parse.scenario} / {semantic_parse.intent}"
route_json = run.route_json or {}
return str(route_json.get("task_name") or route_json.get("selected_agent") or run.agent)
@staticmethod
def _first_semantic_parse(run: AgentRun) -> SemanticParseLog | None:
return run.semantic_parse_logs[0] if run.semantic_parse_logs else None
@staticmethod
def _serialize_semantic_parse(item: SemanticParseLog | None) -> SemanticParseRead | None:
return SemanticParseRead.model_validate(item) if item is not None else None
@staticmethod
def _serialize_run(run: AgentRun) -> AgentRunRead:
semantic_parse = AgentTraceService._first_semantic_parse(run)
return AgentRunRead(
id=run.id,
run_id=run.run_id,
agent=run.agent,
source=run.source,
user_id=run.user_id,
task_id=run.task_id,
ontology_json=run.ontology_json,
route_json=run.route_json,
permission_level=run.permission_level,
status=run.status,
result_summary=run.result_summary,
error_message=run.error_message,
started_at=run.started_at,
finished_at=run.finished_at,
tool_calls=[AgentToolCallRead.model_validate(item) for item in run.tool_calls],
semantic_parse=SemanticParseRead.model_validate(semantic_parse)
if semantic_parse is not None
else None,
)
@staticmethod
def _semantic_parse_payload(item: SemanticParseLog) -> dict[str, Any]:
return {
"scenario": item.scenario,
"intent": item.intent,
"confidence": item.confidence,
"entities": item.entities_json,
"time_range": item.time_range_json,
"metrics": item.metrics_json,
"constraints": item.constraints_json,
"risk_flags": item.risk_flags_json,
"permission": item.permission_json,
}
def _resolve_duration_ms(
started_at: datetime | None,
finished_at: datetime | None,
duration_ms: int | None,
) -> int:
if duration_ms is not None:
return max(0, int(duration_ms or 0))
if started_at is None or finished_at is None:
return 0
try:
return max(0, int((finished_at - started_at).total_seconds() * 1000))
except TypeError:
return 0
def _normalize_datetime(value: datetime | None) -> datetime | None:
if value is None:
return None
if value.tzinfo is None:
return value.replace(tzinfo=UTC)
return value
def _optional_text(value: Any) -> str | None:
text = str(value or "").strip()
return text or None
def _json_safe(value: Any) -> Any:
if value is None or isinstance(value, (str, int, float, bool)):
return value
if isinstance(value, Decimal):
return str(value)
if isinstance(value, (datetime, date)):
return value.isoformat()
if isinstance(value, dict):
return {str(key): _json_safe(item) for key, item in value.items()}
if isinstance(value, (list, tuple, set)):
return [_json_safe(item) for item in value]
if hasattr(value, "model_dump"):
return _json_safe(value.model_dump())
return str(value)

View File

@@ -0,0 +1,231 @@
from __future__ import annotations
import re
from datetime import date
from decimal import Decimal, InvalidOperation, ROUND_HALF_UP
LOCATION_BANDS = {
"premium": ("北京", "上海", "广州", "深圳", "杭州", "南京", "苏州", "成都", "重庆", "天津"),
"remote": ("新疆", "西藏", "青海", "甘肃", "宁夏", "内蒙古", "海南", "香港", "澳门", "台湾", "海外", "国外"),
"coastal": ("上海", "广州", "深圳", "厦门", "福州", "青岛", "大连", "宁波", "舟山", "海口", "三亚", "天津"),
}
TRANSPORT_PRICE_BASE = {
"火车": {"default": Decimal("360"), "premium": Decimal("520"), "remote": Decimal("900"), "coastal": Decimal("520")},
"飞机": {"default": Decimal("850"), "premium": Decimal("1100"), "remote": Decimal("1800"), "coastal": Decimal("1050")},
"轮船": {"default": Decimal("320"), "premium": Decimal("480"), "remote": Decimal("680"), "coastal": Decimal("520")},
}
LODGING_DAILY_BASE = {
"default": Decimal("420"),
"premium": Decimal("600"),
"remote": Decimal("520"),
"coastal": Decimal("500"),
}
ALLOWANCE_DAILY_BASE = {
"default": Decimal("100"),
"premium": Decimal("120"),
"remote": Decimal("120"),
"coastal": Decimal("110"),
}
def parse_application_days(days_text: str) -> int:
match = re.search(r"\d+", str(days_text or ""))
if not match:
return 1
return max(1, int(match.group(0)))
def parse_application_money(value: object) -> Decimal:
normalized = re.sub(r"[^\d.\-]", "", str(value or "").replace(",", ""))
if not normalized:
return Decimal("0")
try:
return Decimal(normalized)
except (InvalidOperation, ValueError):
return Decimal("0")
def format_application_money(value: Decimal | int | float | str) -> str:
amount = parse_application_money(value) if not isinstance(value, Decimal) else value
quantized = amount.quantize(Decimal("0.01"), rounding=ROUND_HALF_UP)
if quantized == quantized.to_integral():
return f"{int(quantized):,}"
return f"{quantized:,.2f}".rstrip("0").rstrip(".")
def normalize_application_transport_mode(value: str) -> str:
text = str(value or "").strip()
if re.search(r"飞机|机票|航班|乘机|坐飞机", text):
return "飞机"
if re.search(r"轮船|船票|客轮|渡轮|邮轮|坐船", text):
return "轮船"
if re.search(r"火车|高铁|动车|铁路|列车", text):
return "火车"
return text if text in TRANSPORT_PRICE_BASE else ""
def resolve_application_location_band(location: str) -> str:
text = str(location or "").strip()
if any(keyword in text for keyword in LOCATION_BANDS["remote"]):
return "remote"
if any(keyword in text for keyword in LOCATION_BANDS["premium"]):
return "premium"
if any(keyword in text for keyword in LOCATION_BANDS["coastal"]):
return "coastal"
return "default"
def _round_to_ten(value: Decimal) -> Decimal:
return (value / Decimal("10")).quantize(Decimal("1"), rounding=ROUND_HALF_UP) * Decimal("10")
def parse_application_start_date(time_text: object) -> str:
match = re.search(r"(20\d{2})[年\-/.](\d{1,2})[月\-/.](\d{1,2})", str(time_text or ""))
if not match:
return ""
try:
return date(int(match.group(1)), int(match.group(2)), int(match.group(3))).isoformat()
except ValueError:
return ""
def _resolve_ticket_price_factor(query_date: str) -> Decimal:
if not query_date:
return Decimal("1.00")
try:
parsed = date.fromisoformat(query_date)
except ValueError:
return Decimal("1.00")
factor = Decimal("1.00")
if parsed.weekday() == 0:
factor += Decimal("0.04")
if parsed.weekday() in (4, 6):
factor += Decimal("0.08")
if parsed.month in {1, 2, 7, 8, 10}:
factor += Decimal("0.06")
jitter = (parsed.year + parsed.month * 13 + parsed.day * 7) % 7 - 3
factor += Decimal(jitter) / Decimal("100")
if factor < Decimal("0.88"):
return Decimal("0.88")
if factor > Decimal("1.22"):
return Decimal("1.22")
return factor
def _resolve_mock_query_latency_ms(query_date: str, mode: str, location_band: str) -> int:
try:
parsed = date.fromisoformat(query_date) if query_date else None
except ValueError:
parsed = None
seed = len(mode) * 43 + len(location_band) * 29
if parsed:
seed += parsed.year + parsed.month * 17 + parsed.day * 31
return 360 + seed % 420
def build_application_system_estimate(
*,
transport_mode: str,
location: str,
days_text: str,
time_text: object = "",
lodging_amount: object = None,
allowance_amount: object = None,
) -> dict[str, str]:
mode = normalize_application_transport_mode(transport_mode)
if not mode:
return {}
days = parse_application_days(days_text)
location_band = resolve_application_location_band(location)
query_date = parse_application_start_date(time_text)
price_factor = _resolve_ticket_price_factor(query_date)
simulated_latency_ms = _resolve_mock_query_latency_ms(query_date, mode, location_band)
transport_one_way = TRANSPORT_PRICE_BASE[mode].get(location_band, TRANSPORT_PRICE_BASE[mode]["default"])
transport_amount = _round_to_ten(transport_one_way * Decimal("2") * price_factor)
lodging = parse_application_money(lodging_amount)
allowance = parse_application_money(allowance_amount)
lodging_daily = LODGING_DAILY_BASE.get(location_band, LODGING_DAILY_BASE["default"])
allowance_daily = ALLOWANCE_DAILY_BASE.get(location_band, ALLOWANCE_DAILY_BASE["default"])
if lodging <= 0:
lodging = lodging_daily * days
if allowance <= 0:
allowance = allowance_daily * days
total_amount = transport_amount + lodging + allowance
transport_display = format_application_money(transport_amount)
lodging_display = format_application_money(lodging)
allowance_display = format_application_money(allowance)
total_display = format_application_money(total_amount)
band_label = {
"premium": "一线/高频城市",
"remote": "远途地区",
"coastal": "沿海城市",
"default": "普通城市",
}[location_band]
query_label = query_date or "出行日期待确认"
return {
"amount": f"{total_display}",
"lodging_daily_cap": f"{format_application_money(lodging_daily)}元/天",
"subsidy_daily_cap": f"{format_application_money(allowance_daily)}元/天",
"transport_policy": (
f"已查询 {query_label} {mode}参考票价,按{band_label}往返 {transport_display}元预估"
f"(查询耗时 {simulated_latency_ms}ms报销阶段按真实票据复核"
),
"policy_estimate": (
f"交通 {transport_display}元(按 {query_label} 参考票价) + 住宿 {lodging_display}"
f" + 补贴 {allowance_display}元 = {total_display}元({days}天)"
),
"matched_city": str(location or "").strip(),
"transport_estimated_amount": f"{transport_display}",
"transport_estimate_date": query_date,
"transport_query_latency_ms": str(simulated_latency_ms),
"policy_total_amount": f"{total_display}",
"estimate_source": "mock_ticket_price_query_v1",
"estimate_confidence": "mock",
}
def _is_pending_application_amount(value: str) -> bool:
normalized = str(value or "").strip()
return not normalized or normalized in {"待测算", "待补充", "未知"}
def apply_application_system_estimate_to_facts(facts: dict[str, str]) -> None:
estimate = build_application_system_estimate(
transport_mode=str(facts.get("transport_mode") or ""),
location=str(facts.get("matched_city") or facts.get("location") or ""),
days_text=str(facts.get("days") or ""),
time_text=facts.get("time") or "",
lodging_amount=facts.get("hotel_amount") or None,
allowance_amount=facts.get("allowance_amount") or None,
)
if not estimate:
return
if _is_pending_application_amount(facts.get("amount", "")):
facts["amount"] = estimate["amount"]
field_map = {
"lodging_daily_cap": "lodging_daily_cap",
"subsidy_daily_cap": "subsidy_daily_cap",
"transport_policy": "transport_policy",
"policy_estimate": "policy_estimate",
"matched_city": "matched_city",
"transport_estimated_amount": "transport_estimated_amount",
"transport_estimate_date": "transport_estimate_date",
"transport_query_latency_ms": "transport_query_latency_ms",
"transport_estimate_source": "estimate_source",
"transport_estimate_confidence": "estimate_confidence",
"policy_total_amount": "policy_total_amount",
}
for target, source in field_map.items():
if not str(facts.get(target) or "").strip() and estimate.get(source):
facts[target] = estimate[source]

View File

@@ -81,6 +81,20 @@ class AuthService:
session = UserSessionMetricService(self.db).start_session(user)
return LoginResponse(user=self._serialize_user(user), sessionId=session.session_id)
def get_user_snapshot(self, identifier: str) -> AuthUserRead | None:
normalized = identifier.strip()
if not normalized or not self.settings.setup_completed:
return None
employee = self._find_employee_by_email(normalized)
if employee is None:
EmployeeService(self.db).ensure_directory_ready()
employee = self._find_employee_by_email(normalized)
if employee is None or employee.employment_status == "停用":
return None
return self._serialize_user(self._build_employee_user(employee))
def _authenticate_admin(self, identifier: str, password: str) -> AuthenticatedUser | None:
record = SettingsService(self.db).verify_admin_login(identifier, password)
if record is None:
@@ -114,17 +128,7 @@ class AuthService:
return None
EmployeeService(self.db).ensure_directory_ready()
stmt = (
select(Employee)
.options(
selectinload(Employee.organization_unit),
selectinload(Employee.manager),
selectinload(Employee.roles),
)
.where(func.lower(Employee.email) == identifier.lower())
)
employee = self.db.execute(stmt).scalars().first()
employee = self._find_employee_by_email(identifier)
if employee is None or not employee.password_hash:
return None
@@ -136,6 +140,21 @@ class AuthService:
if not verify_password(password, employee.password_hash):
return None
return self._build_employee_user(employee)
def _find_employee_by_email(self, identifier: str) -> Employee | None:
stmt = (
select(Employee)
.options(
selectinload(Employee.organization_unit),
selectinload(Employee.manager),
selectinload(Employee.roles),
)
.where(func.lower(Employee.email) == identifier.lower())
)
return self.db.execute(stmt).scalars().first()
def _build_employee_user(self, employee: Employee) -> AuthenticatedUser:
sorted_roles = sorted(
list(employee.roles),
key=lambda item: (ROLE_DISPLAY_ORDER.get(item.role_code, 999), item.name),

View File

@@ -24,6 +24,7 @@ from app.services.budget_types import (
SUPPORTED_BUDGET_SUBJECT_CODES,
)
from app.services.expense_claim_constants import EXPENSE_TYPE_LABELS
from app.services.expense_claim_risk_stage import enrich_risk_flag_semantics
from app.services.expense_type_keywords import resolve_expense_type_code_from_text
@@ -604,7 +605,12 @@ class BudgetSupportMixin:
"created_at": datetime.now(UTC).isoformat(),
}
payload.update(extra or {})
return payload
return enrich_risk_flag_semantics(
payload,
risk_domain="budget",
visibility_scope="budget_manager",
actionability="budget_governance",
)
def _build_operation_flag(
self,

View File

@@ -0,0 +1,637 @@
from __future__ import annotations
from datetime import UTC, date, datetime, timedelta
from typing import Any
from sqlalchemy import or_, select
from sqlalchemy.orm import Session, selectinload
from app.core.agent_enums import AgentName, AgentRunSource
from app.db.base import Base
from app.models.agent_run import AgentRun, AgentToolCall
from app.schemas.digital_employee_dashboard import DigitalEmployeeDashboardRead
SUCCESS_STATUSES = {"success", "succeeded", "ok", "done", "completed"}
FAILED_STATUSES = {"failed", "failure", "error", "errored"}
RUNNING_STATUSES = {"running", "pending"}
TASK_CODE_TO_TYPE = {
"task.hermes.global_risk_scan": "global_risk_scan",
"task.hermes.employee_behavior_profile_scan": "employee_behavior_profile_scan",
"task.hermes.risk_rule_discovery": "risk_clue_collect",
"task.hermes.finance_policy_knowledge_organize": "finance_policy_knowledge_organize",
"task.hermes.finance_policy_clause_extract": "finance_policy_clause_extract",
"task.hermes.expense_policy_alignment": "expense_policy_alignment",
"task.hermes.risk_rule_template_organize": "risk_rule_template_organize",
"task.hermes.department_expense_baseline_accumulate": "department_expense_baseline_accumulate",
"task.hermes.supplier_risk_profile_accumulate": "supplier_risk_profile_accumulate",
"task.hermes.false_positive_sample_accumulate": "false_positive_sample_accumulate",
"task.hermes.risk_feedback_sample_accumulate": "risk_feedback_sample_accumulate",
"task.hermes.multi_evidence_consistency_evaluate": "multi_evidence_consistency_evaluate",
"task.hermes.travel_spatiotemporal_consistency_evaluate": "travel_spatiotemporal_consistency_evaluate",
"task.hermes.budget_overrun_precontrol_evaluate": "budget_overrun_precontrol_evaluate",
"task.hermes.supplier_abnormal_relation_evaluate": "supplier_abnormal_relation_evaluate",
"task.hermes.risk_algorithm_replay_evaluate": "risk_algorithm_replay_evaluate",
"task.hermes.policy_gap_rule_optimization": "policy_gap_rule_optimization",
}
TASK_SPECS: dict[str, dict[str, str]] = {
"global_risk_scan": {
"label": "财务风险图谱巡检",
"category": "评估",
"color": "var(--theme-primary)",
},
"employee_behavior_profile_scan": {
"label": "员工行为画像巡检",
"category": "积累",
"color": "var(--chart-blue)",
},
"risk_clue_collect": {
"label": "风险线索归集",
"category": "升级",
"color": "var(--chart-amber)",
},
"finance_policy_knowledge_organize": {
"label": "知识制度整理",
"category": "整理",
"color": "var(--success)",
},
"knowledge_index_sync": {
"label": "知识制度整理",
"category": "整理",
"color": "var(--success)",
},
"llm_wiki_sync": {
"label": "知识制度整理",
"category": "整理",
"color": "var(--success)",
},
"llm_wiki_rule_formation": {
"label": "知识制度整理",
"category": "整理",
"color": "var(--success)",
},
"finance_policy_clause_extract": {
"label": "制度条款结构化抽取",
"category": "整理",
"color": "var(--success)",
},
"expense_policy_alignment": {
"label": "报销政策口径对齐",
"category": "整理",
"color": "var(--success)",
},
"risk_rule_template_organize": {
"label": "规则命中样本整理",
"category": "整理",
"color": "var(--success)",
},
"department_expense_baseline_accumulate": {
"label": "部门费用基线沉淀",
"category": "积累",
"color": "var(--chart-blue)",
},
"supplier_risk_profile_accumulate": {
"label": "供应商风险画像沉淀",
"category": "积累",
"color": "var(--chart-blue)",
},
"false_positive_sample_accumulate": {
"label": "历史误报样本沉淀",
"category": "积累",
"color": "var(--chart-blue)",
},
"risk_feedback_sample_accumulate": {
"label": "风险观察反馈样本沉淀",
"category": "积累",
"color": "var(--chart-blue)",
},
"multi_evidence_consistency_evaluate": {
"label": "多源证据一致性评估",
"category": "评估",
"color": "var(--theme-primary)",
},
"travel_spatiotemporal_consistency_evaluate": {
"label": "差旅时空一致性评估",
"category": "评估",
"color": "var(--theme-primary)",
},
"budget_overrun_precontrol_evaluate": {
"label": "预算超限预警评估",
"category": "评估",
"color": "var(--theme-primary)",
},
"supplier_abnormal_relation_evaluate": {
"label": "供应商异常关系评估",
"category": "评估",
"color": "var(--theme-primary)",
},
"risk_algorithm_replay_evaluate": {
"label": "风险算法回放升级",
"category": "升级",
"color": "var(--chart-amber)",
},
"policy_gap_rule_optimization": {
"label": "制度缺口优化建议",
"category": "升级",
"color": "var(--chart-amber)",
},
}
CATEGORY_SPECS = {
"积累": {"color": "var(--chart-blue)", "description": "沉淀画像、基线和反馈样本"},
"升级": {"color": "var(--chart-amber)", "description": "输出待复核线索和优化建议"},
"整理": {"color": "var(--success)", "description": "整理制度、条款、知识和样本"},
"评估": {"color": "var(--theme-primary)", "description": "评估异常、风险和一致性"},
}
class DigitalEmployeeDashboardService:
def __init__(self, db: Session) -> None:
self.db = db
def build_dashboard(self, *, days: int = 7, limit: int = 300) -> DigitalEmployeeDashboardRead:
window_days = max(1, min(int(days or 7), 30))
window_limit = max(1, min(int(limit or 300), 1000))
self._ensure_storage_ready()
now = datetime.now(UTC)
start = now - timedelta(days=window_days - 1)
labels = self._date_labels(start.date(), window_days)
all_runs = self._fetch_runs(start=start, limit=window_limit)
runs = [run for run in all_runs if self._is_digital_employee_run(run)]
totals = self._build_totals(runs)
return DigitalEmployeeDashboardRead(
window_days=window_days,
generated_at=now.isoformat(),
has_real_data=bool(runs),
totals=totals,
daily_work=self._daily_work(labels, runs),
task_distribution=self._task_distribution(runs),
category_distribution=self._category_distribution(runs),
recent_runs=self._recent_runs(runs),
)
def _ensure_storage_ready(self) -> None:
Base.metadata.create_all(bind=self.db.get_bind())
def _fetch_runs(self, *, start: datetime, limit: int) -> list[AgentRun]:
stmt = (
select(AgentRun)
.options(selectinload(AgentRun.tool_calls))
.where(
AgentRun.started_at >= start,
or_(
AgentRun.agent == AgentName.HERMES.value,
AgentRun.source == AgentRunSource.SCHEDULE.value,
),
)
.order_by(AgentRun.started_at.desc())
.limit(limit)
)
return list(self.db.scalars(stmt).all())
def _build_totals(self, runs: list[AgentRun]) -> dict[str, Any]:
metrics = self._sum_metrics(runs)
success_runs = sum(1 for run in runs if self._is_success(run.status))
failed_runs = sum(1 for run in runs if self._is_failed(run.status))
running_runs = sum(1 for run in runs if self._is_running(run.status))
total_runs = len(runs)
business_outputs = (
metrics["risk_observations"]
+ metrics["risk_clues"]
+ metrics["profile_snapshots"]
+ metrics["knowledge_documents"]
)
return {
"totalRuns": total_runs,
"successRuns": success_runs,
"failedRuns": failed_runs,
"runningRuns": running_runs,
"toolCalls": sum(len(run.tool_calls) for run in runs),
"businessOutputs": business_outputs,
"riskObservations": metrics["risk_observations"],
"riskClues": metrics["risk_clues"],
"profileSnapshots": metrics["profile_snapshots"],
"knowledgeDocuments": metrics["knowledge_documents"],
"successRate": self._percent(success_runs, total_runs),
"failureRate": self._percent(failed_runs, total_runs),
}
def _daily_work(self, labels: list[str], runs: list[AgentRun]) -> list[dict[str, Any]]:
rows = {
label: {
"date": label,
"total": 0,
"success": 0,
"failed": 0,
"running": 0,
"riskObservations": 0,
"riskClues": 0,
"profileSnapshots": 0,
"knowledgeDocuments": 0,
"businessOutputs": 0,
}
for label in labels
}
for run in runs:
label = self._date_label(run.started_at)
if label not in rows:
continue
row = rows[label]
metrics = self._extract_run_metrics(run)
row["total"] += 1
if self._is_success(run.status):
row["success"] += 1
elif self._is_failed(run.status):
row["failed"] += 1
elif self._is_running(run.status):
row["running"] += 1
row["riskObservations"] += metrics["risk_observations"]
row["riskClues"] += metrics["risk_clues"]
row["profileSnapshots"] += metrics["profile_snapshots"]
row["knowledgeDocuments"] += metrics["knowledge_documents"]
row["businessOutputs"] += (
metrics["risk_observations"]
+ metrics["risk_clues"]
+ metrics["profile_snapshots"]
+ metrics["knowledge_documents"]
)
return [rows[label] for label in labels]
def _task_distribution(self, runs: list[AgentRun]) -> list[dict[str, Any]]:
buckets: dict[str, dict[str, Any]] = {}
for run in runs:
task_type = self._resolve_task_type(run)
spec = self._task_spec(task_type)
bucket = buckets.setdefault(
task_type or "unknown",
{
"taskType": task_type or "unknown",
"name": spec["label"],
"category": spec["category"],
"count": 0,
"success": 0,
"failed": 0,
"value": 0,
"color": spec["color"],
},
)
bucket["count"] += 1
bucket["value"] += 1
if self._is_success(run.status):
bucket["success"] += 1
elif self._is_failed(run.status):
bucket["failed"] += 1
return sorted(buckets.values(), key=lambda item: (-item["count"], item["name"]))[:8]
def _category_distribution(self, runs: list[AgentRun]) -> list[dict[str, Any]]:
rows = {
category: {
"name": category,
"value": 0,
"count": 0,
"success": 0,
"failed": 0,
"color": spec["color"],
"description": spec["description"],
}
for category, spec in CATEGORY_SPECS.items()
}
for run in runs:
category = self._task_spec(self._resolve_task_type(run))["category"]
row = rows.setdefault(
category,
{
"name": category,
"value": 0,
"count": 0,
"success": 0,
"failed": 0,
"color": "var(--theme-primary)",
"description": "其他数字员工工作",
},
)
row["value"] += 1
row["count"] += 1
if self._is_success(run.status):
row["success"] += 1
elif self._is_failed(run.status):
row["failed"] += 1
return list(rows.values())
def _recent_runs(self, runs: list[AgentRun]) -> list[dict[str, Any]]:
rows = []
for run in sorted(runs, key=lambda item: item.started_at, reverse=True)[:8]:
task_type = self._resolve_task_type(run)
spec = self._task_spec(task_type)
rows.append(
{
"runId": run.run_id,
"taskType": task_type or "unknown",
"taskLabel": spec["label"],
"category": spec["category"],
"status": run.status,
"statusLabel": self._status_label(run.status),
"statusTone": self._status_tone(run.status),
"source": run.source,
"sourceLabel": self._source_label(run.source),
"startedAt": self._iso(run.started_at),
"finishedAt": self._iso(run.finished_at),
"durationMs": self._duration_ms(run),
"summary": self._summary_text(run),
"metrics": self._extract_run_metrics(run),
}
)
return rows
def _sum_metrics(self, runs: list[AgentRun]) -> dict[str, int]:
totals = self._empty_metrics()
for run in runs:
metrics = self._extract_run_metrics(run)
for key in totals:
totals[key] += int(metrics.get(key) or 0)
return totals
def _extract_run_metrics(self, run: AgentRun) -> dict[str, int]:
summary = self._extract_run_summary(run)
route_json = run.route_json or {}
metrics = self._empty_metrics()
metrics["risk_observations"] = self._first_int(
summary,
("risk_observation_count", "risk_observations", "created_observation_count"),
)
metrics["risk_clues"] = self._first_int(
summary,
("risk_clue_count", "risk_clues", "created_clue_count"),
)
metrics["profile_snapshots"] = self._first_int(
summary,
("snapshot_count", "profile_snapshot_count", "profile_snapshots"),
)
metrics["knowledge_documents"] = max(
self._first_int(
summary,
("knowledge_document_count", "document_count", "processed_document_count"),
),
self._list_length(summary, ("document_ids", "requested_document_ids")),
self._list_length(route_json, ("document_ids", "requested_document_ids")),
)
metrics["scanned_claims"] = self._first_int(summary, ("scanned_claim_count", "claim_count"))
metrics["target_employees"] = self._first_int(summary, ("target_employee_count", "employee_count"))
metrics["rule_hits"] = self._first_int(summary, ("rule_hit_count", "rule_hits"))
metrics["facts"] = self._first_int(summary, ("fact_count", "facts"))
return metrics
@staticmethod
def _empty_metrics() -> dict[str, int]:
return {
"risk_observations": 0,
"risk_clues": 0,
"profile_snapshots": 0,
"knowledge_documents": 0,
"scanned_claims": 0,
"target_employees": 0,
"rule_hits": 0,
"facts": 0,
}
def _extract_run_summary(self, run: AgentRun) -> dict[str, Any]:
task_type = self._resolve_task_type(run)
matched_tool = self._matched_tool_call(run, task_type)
if matched_tool is None:
return run.route_json or {}
response = matched_tool.response_json or {}
if isinstance(response, dict) and isinstance(response.get("summary"), dict):
return response["summary"]
return response if isinstance(response, dict) else {}
def _matched_tool_call(self, run: AgentRun, task_type: str) -> AgentToolCall | None:
digital_tools = [
tool for tool in run.tool_calls if str(tool.tool_name or "").startswith("digital_employee.")
]
for tool in run.tool_calls:
candidates = [
(tool.request_json or {}).get("task_type"),
(tool.request_json or {}).get("job_type"),
(tool.response_json or {}).get("report_type"),
(tool.response_json or {}).get("task_type"),
(tool.response_json or {}).get("job_type"),
self._task_type_from_tool_name(tool.tool_name),
]
if task_type and task_type in {self._normalize_task_type(item) for item in candidates}:
return tool
if digital_tools:
return digital_tools[0]
return run.tool_calls[0] if run.tool_calls else None
def _is_digital_employee_run(self, run: AgentRun) -> bool:
task_type = self._resolve_task_type(run)
if task_type in TASK_SPECS:
return True
if run.agent == AgentName.HERMES.value:
return True
if run.source == AgentRunSource.SCHEDULE.value and task_type:
return True
route_json = run.route_json or {}
if str(route_json.get("selected_agent") or "").strip() == AgentName.HERMES.value:
return True
return any(str(tool.tool_name or "").startswith("digital_employee.") for tool in run.tool_calls)
def _resolve_task_type(self, run: AgentRun) -> str:
route_json = run.route_json or {}
route_candidates = [
route_json.get("job_type"),
route_json.get("task_type"),
route_json.get("report_type"),
route_json.get("task_code"),
route_json.get("code"),
]
for candidate in route_candidates:
normalized = self._normalize_task_type(candidate)
if normalized:
return normalized
for tool in run.tool_calls:
for candidate in (
(tool.request_json or {}).get("task_type"),
(tool.request_json or {}).get("job_type"),
(tool.response_json or {}).get("report_type"),
(tool.response_json or {}).get("task_type"),
(tool.response_json or {}).get("job_type"),
self._task_type_from_tool_name(tool.tool_name),
):
normalized = self._normalize_task_type(candidate)
if normalized:
return normalized
return ""
@staticmethod
def _normalize_task_type(value: Any) -> str:
text = str(value or "").strip()
if not text:
return ""
text = TASK_CODE_TO_TYPE.get(text, text)
if text.startswith("task.hermes."):
text = text.removeprefix("task.hermes.")
text = text.replace("-", "_").replace(".", "_")
if text == "risk_rule_discovery":
return "risk_clue_collect"
return text
@staticmethod
def _task_type_from_tool_name(value: str | None) -> str:
name = str(value or "")
if "financial_risk_graph" in name:
return "global_risk_scan"
if "employee_behavior_profile" in name:
return "employee_behavior_profile_scan"
if "finance_policy_knowledge" in name:
return "finance_policy_knowledge_organize"
if "risk_clue" in name:
return "risk_clue_collect"
return ""
@staticmethod
def _task_spec(task_type: str) -> dict[str, str]:
return TASK_SPECS.get(
task_type,
{
"label": "数字员工工作",
"category": "评估",
"color": "var(--theme-primary)",
},
)
def _summary_text(self, run: AgentRun) -> str:
text = str(run.result_summary or "").strip()
if text:
return text
summary = self._extract_run_summary(run)
for key in ("message", "summary", "result_summary"):
value = str(summary.get(key) or "").strip()
if value:
return value
if run.error_message:
return str(run.error_message)
return "暂无摘要。"
@staticmethod
def _first_int(payload: Any, keys: tuple[str, ...]) -> int:
if isinstance(payload, dict):
for key in keys:
value = payload.get(key)
if isinstance(value, (int, float)) and value > 0:
return int(value)
for value in payload.values():
found = DigitalEmployeeDashboardService._first_int(value, keys)
if found:
return found
if isinstance(payload, list):
for value in payload:
found = DigitalEmployeeDashboardService._first_int(value, keys)
if found:
return found
return 0
@staticmethod
def _list_length(payload: Any, keys: tuple[str, ...]) -> int:
if isinstance(payload, dict):
for key in keys:
value = payload.get(key)
if isinstance(value, list):
return len(value)
for value in payload.values():
found = DigitalEmployeeDashboardService._list_length(value, keys)
if found:
return found
if isinstance(payload, list):
for value in payload:
found = DigitalEmployeeDashboardService._list_length(value, keys)
if found:
return found
return 0
@staticmethod
def _percent(value: int | float, total: int | float) -> float:
if not total:
return 0.0
return round((float(value) / float(total)) * 100, 1)
@staticmethod
def _duration_ms(run: AgentRun) -> int:
if not run.finished_at:
return 0
try:
finished_at = DigitalEmployeeDashboardService._as_utc(run.finished_at)
started_at = DigitalEmployeeDashboardService._as_utc(run.started_at)
return max(0, int((finished_at - started_at).total_seconds() * 1000))
except TypeError:
return 0
@staticmethod
def _date_labels(start_date: date, days: int) -> list[str]:
return [(start_date + timedelta(days=index)).strftime("%m-%d") for index in range(days)]
@staticmethod
def _date_label(value: datetime | None) -> str:
if value is None:
return ""
return DigitalEmployeeDashboardService._as_utc(value).strftime("%m-%d")
@staticmethod
def _iso(value: datetime | None) -> str:
if value is None:
return ""
return DigitalEmployeeDashboardService._as_utc(value).isoformat()
@staticmethod
def _as_utc(value: datetime) -> datetime:
if value.tzinfo is None:
return value.replace(tzinfo=UTC)
return value.astimezone(UTC)
@staticmethod
def _is_success(status: str | None) -> bool:
return str(status or "").strip().lower() in SUCCESS_STATUSES
@staticmethod
def _is_failed(status: str | None) -> bool:
return str(status or "").strip().lower() in FAILED_STATUSES
@staticmethod
def _is_running(status: str | None) -> bool:
return str(status or "").strip().lower() in RUNNING_STATUSES
def _status_label(self, status: str | None) -> str:
if self._is_success(status):
return "成功"
if self._is_failed(status):
return "失败"
if self._is_running(status):
return "运行中"
return str(status or "其他")
def _status_tone(self, status: str | None) -> str:
if self._is_success(status):
return "success"
if self._is_failed(status):
return "danger"
if self._is_running(status):
return "warning"
return "neutral"
@staticmethod
def _source_label(source: str | None) -> str:
labels = {
"schedule": "定时任务",
"system_event": "系统事件",
"user_message": "用户触发",
}
text = str(source or "").strip()
return labels.get(text, text or "未标记")

View File

@@ -4,7 +4,7 @@ from datetime import UTC, datetime, timedelta
from decimal import Decimal
from typing import Any
from sqlalchemy import or_, select
from sqlalchemy import func, or_, select
from sqlalchemy.orm import Session, selectinload
from app.algorithem.employee_behavior_profile import (
@@ -102,7 +102,8 @@ class EmployeeBehaviorProfileService(EmployeeBehaviorProfileMetricHelpers):
commit: bool = True,
) -> list[EmployeeBehaviorProfileSnapshot]:
self.ensure_storage_ready()
employee = self.db.get(Employee, employee_id)
requested_employee_id = str(employee_id or "").strip()
employee = self._resolve_employee_by_identifier(requested_employee_id)
if employee is None:
return []
@@ -161,10 +162,11 @@ class EmployeeBehaviorProfileService(EmployeeBehaviorProfileMetricHelpers):
expense_type_scope: str = "overall",
) -> EmployeeProfileLatestRead:
self.ensure_storage_ready()
employee = self.db.get(Employee, employee_id)
requested_employee_id = str(employee_id or "").strip()
employee = self._resolve_employee_by_identifier(requested_employee_id)
if employee is None:
return EmployeeProfileLatestRead(
employee_id=employee_id,
employee_id=requested_employee_id,
scene=scene,
window_days=window_days,
expense_type_scope=expense_type_scope,
@@ -172,22 +174,23 @@ class EmployeeBehaviorProfileService(EmployeeBehaviorProfileMetricHelpers):
)
resolved_scope = self._resolve_scope_from_claim(claim_id, expense_type_scope)
resolved_employee_id = employee.id
rows = self._load_latest_snapshots(
employee_id=employee_id,
employee_id=resolved_employee_id,
window_days=window_days,
expense_type_scope=resolved_scope,
scene=scene,
)
if not rows and claim_id:
self.refresh_employee_profiles(
employee_id=employee_id,
employee_id=resolved_employee_id,
window_days=(window_days,),
expense_type_scope=resolved_scope,
source_task_type="api_on_demand",
claim_id=claim_id,
)
rows = self._load_latest_snapshots(
employee_id=employee_id,
employee_id=resolved_employee_id,
window_days=window_days,
expense_type_scope=resolved_scope,
scene=scene,
@@ -201,6 +204,31 @@ class EmployeeBehaviorProfileService(EmployeeBehaviorProfileMetricHelpers):
expense_type_scope=resolved_scope,
)
def _resolve_employee_by_identifier(self, identifier: str) -> Employee | None:
normalized = str(identifier or "").strip()
if not normalized:
return None
employee = self.db.get(Employee, normalized)
if employee is not None:
return employee
normalized_email = normalized.lower()
conditions = [
Employee.name == normalized,
Employee.employee_no == normalized,
]
if "@" in normalized_email:
conditions.append(func.lower(Employee.email) == normalized_email)
stmt = (
select(Employee)
.where(or_(*conditions))
.order_by(Employee.created_at.asc())
.limit(1)
)
return self.db.scalars(stmt).first()
def _build_window_context(
self,
*,

View File

@@ -185,10 +185,7 @@ class ExpenseClaimAccessPolicy:
return False
if current_user.is_admin:
return True
role_codes = self.normalize_role_codes(current_user)
if "executive" in role_codes:
return True
return self.is_department_p8_budget_monitor(current_user, claim)
return self.is_department_budget_approver(current_user, claim)
def is_budget_manager_user(self, current_user: CurrentUserContext) -> bool:
if current_user.is_admin:
@@ -197,13 +194,16 @@ class ExpenseClaimAccessPolicy:
return bool(role_codes & BUDGET_APPROVAL_ROLE_CODES)
def is_department_p8_budget_monitor(self, current_user: CurrentUserContext, claim: ExpenseClaim) -> bool:
role_codes = self.normalize_role_codes(current_user)
if BUDGET_MONITOR_ROLE_CODE not in role_codes:
return False
return self.is_department_budget_approver(current_user, claim)
def is_department_budget_approver(self, current_user: CurrentUserContext, claim: ExpenseClaim) -> bool:
role_codes = self.normalize_role_codes(current_user)
current_employee = self.resolve_current_employee(current_user)
if current_employee is None:
return False
role_codes |= self._collect_employee_role_codes(current_employee)
if not role_codes & BUDGET_APPROVAL_ROLE_CODES:
return False
if not self._employee_has_budget_approval_grade(current_employee):
return False
@@ -224,7 +224,7 @@ class ExpenseClaimAccessPolicy:
.options(selectinload(Employee.organization_unit), selectinload(Employee.roles))
.where(
func.upper(func.coalesce(Employee.grade, "")) == BUDGET_MONITOR_APPROVAL_GRADE,
Employee.roles.any(Role.role_code == BUDGET_MONITOR_ROLE_CODE),
Employee.roles.any(Role.role_code.in_(BUDGET_APPROVAL_ROLE_CODES)),
or_(*department_conditions),
)
.order_by(Employee.name.asc(), Employee.employee_no.asc())
@@ -235,6 +235,37 @@ class ExpenseClaimAccessPolicy:
stmt = stmt.where(Employee.id != claim_employee_id)
return self.db.scalar(stmt)
def resolve_budget_approval_role_code(self, employee: Employee | None) -> str:
role_codes = self._collect_employee_role_codes(employee)
for role_code in ("budget_monitor", "executive"):
if role_code in role_codes:
return role_code
return BUDGET_MONITOR_ROLE_CODE
def attach_budget_approval_snapshot(self, claim: ExpenseClaim | None) -> ExpenseClaim | None:
if claim is None:
return None
if str(claim.approval_stage or "").strip() != BUDGET_MANAGER_APPROVAL_STAGE:
return claim
budget_manager = self.resolve_department_budget_manager(claim)
if budget_manager is None:
return claim
setattr(claim, "budget_approver_name", str(budget_manager.name or "").strip())
setattr(claim, "budget_approver_grade", str(budget_manager.grade or "").strip())
setattr(
claim,
"budget_approver_role_code",
self.resolve_budget_approval_role_code(budget_manager),
)
return claim
def attach_budget_approval_snapshots(self, claims: list[ExpenseClaim]) -> list[ExpenseClaim]:
for claim in claims:
self.attach_budget_approval_snapshot(claim)
return claims
@staticmethod
def normalize_role_codes(current_user: CurrentUserContext) -> set[str]:
return {
@@ -243,6 +274,16 @@ class ExpenseClaimAccessPolicy:
if str(item).strip()
}
@staticmethod
def _collect_employee_role_codes(employee: Employee | None) -> set[str]:
if employee is None:
return set()
return {
str(role.role_code or "").strip().lower()
for role in list(employee.roles or [])
if str(role.role_code or "").strip()
}
@staticmethod
def _employee_has_budget_approval_grade(employee: Employee) -> bool:
return str(employee.grade or "").strip().upper() == BUDGET_MONITOR_APPROVAL_GRADE
@@ -293,6 +334,7 @@ class ExpenseClaimAccessPolicy:
[
str(current_user.username or "").strip(),
str(current_user.name or "").strip(),
str(current_user.employee_no or "").strip(),
]
)
@@ -309,9 +351,10 @@ class ExpenseClaimAccessPolicy:
return str(current_user.username or current_user.name or "anonymous").strip() or "anonymous"
def is_claim_owned_by_current_user(self, claim: ExpenseClaim, current_user: CurrentUserContext) -> bool:
claim_employee_id = str(claim.employee_id or "").strip()
current_employee = self.resolve_current_employee(current_user)
if current_employee is not None:
if str(claim.employee_id or "").strip() == current_employee.id:
if claim_employee_id == current_employee.id:
return True
identity_values = {
str(current_employee.name or "").strip(),
@@ -325,9 +368,12 @@ class ExpenseClaimAccessPolicy:
{
str(current_user.username or "").strip(),
str(current_user.name or "").strip(),
str(current_user.employee_no or "").strip(),
}
)
identity_values.discard("")
if claim_employee_id and claim_employee_id in identity_values:
return True
return str(claim.employee_name or "").strip() in identity_values
@staticmethod
@@ -490,8 +536,10 @@ class ExpenseClaimAccessPolicy:
add_condition("employee_name", employee.name)
else:
add_condition("employee_id", username)
add_condition("employee_id", str(current_user.employee_no or "").strip())
add_condition("employee_name", username)
add_condition("employee_name", str(current_user.name or "").strip())
add_condition("employee_name", str(current_user.employee_no or "").strip())
return conditions
@@ -531,10 +579,10 @@ class ExpenseClaimAccessPolicy:
return conditions
def build_budget_approval_claim_conditions(self, current_user: CurrentUserContext) -> list[Any]:
role_codes = self.normalize_role_codes(current_user)
if BUDGET_MONITOR_ROLE_CODE not in role_codes:
return []
employee = self.resolve_current_employee(current_user)
role_codes = self.normalize_role_codes(current_user) | self._collect_employee_role_codes(employee)
if not role_codes & BUDGET_APPROVAL_ROLE_CODES:
return []
if employee is None or not self._employee_has_budget_approval_grade(employee):
return []
@@ -568,7 +616,7 @@ class ExpenseClaimAccessPolicy:
def apply_approval_claim_scope(self, stmt: Any, current_user: CurrentUserContext) -> Any:
role_codes = self.normalize_role_codes(current_user)
if current_user.is_admin or "executive" in role_codes:
if current_user.is_admin:
return stmt.where(ExpenseClaim.status == "submitted")
conditions = []
if "finance" in role_codes:

View File

@@ -15,6 +15,10 @@ from app.services.expense_claim_workflow_constants import (
PAYMENT_PENDING_STAGE,
PAYMENT_PENDING_STATUS,
)
from app.services.expense_claim_risk_stage import (
risk_business_stage_for_claim,
with_risk_business_stage,
)
class ExpenseClaimApprovalFlowMixin:
@@ -35,41 +39,72 @@ class ExpenseClaimApprovalFlowMixin:
previous_stage = str(claim.approval_stage or "").strip()
is_application_claim = self._is_expense_application_claim(claim)
business_stage = risk_business_stage_for_claim(is_application_claim=is_application_claim)
next_budget_manager = None
merged_budget_approval = False
route_decision_flag: dict[str, Any] | None = None
if previous_stage == DIRECT_MANAGER_APPROVAL_STAGE:
if not self._access_policy.can_approve_claim(current_user, claim):
raise ValueError("只有当前直属领导审批人可以审批通过该单据。")
approval_source = "manual_approval"
event_type = "expense_application_approval" if is_application_claim else "expense_claim_approval"
label = "领导审批通过"
route_decision_flag = self._build_approval_route_decision(
claim,
is_application_claim=is_application_claim,
)
requires_budget_review = bool(route_decision_flag.get("requires_budget_review"))
if is_application_claim:
merged_budget_approval = self._access_policy.is_department_p8_budget_monitor(current_user, claim)
merged_budget_approval = (
requires_budget_review
and self._access_policy.is_department_p8_budget_monitor(current_user, claim)
)
if merged_budget_approval:
label = "领导及预算审核通过"
next_status = "approved"
next_stage = APPROVAL_DONE_STAGE
default_message = "{operator} 已完成直属领导和预算管理者审核,申请流程完成并生成报销草稿。"
else:
elif requires_budget_review:
next_budget_manager = self._access_policy.resolve_department_budget_manager(claim)
if next_budget_manager is None:
raise ValueError("未找到同部门 P8 预算审批人,无法流转预算审批。请先配置预算审批人。")
next_status = "submitted"
next_stage = BUDGET_MANAGER_APPROVAL_STAGE
default_message = "{operator} 已确认直属领导审核,流转至预算管理者审批。"
default_message = "{operator} 已确认直属领导审核,因预算或风险关注项流转至预算管理者审批。"
else:
next_status = "approved"
next_stage = APPROVAL_DONE_STAGE
default_message = "{operator} 已确认直属领导审核,系统判断预算充足且无风险,申请流程完成并生成报销草稿。"
else:
if requires_budget_review:
next_budget_manager = self._access_policy.resolve_department_budget_manager(claim)
if next_budget_manager is None:
raise ValueError("未找到同部门 P8 预算审批人,无法流转预算审批。请先配置预算审批人。")
next_status = "submitted"
next_stage = BUDGET_MANAGER_APPROVAL_STAGE
default_message = "{operator} 已审批通过,因预算或风险关注项流转至预算管理者审批。"
else:
next_status = "submitted"
next_stage = FINANCE_APPROVAL_STAGE
default_message = "{operator} 已审批通过,系统判断预算充足且无风险,流转至{next_stage}"
elif previous_stage == BUDGET_MANAGER_APPROVAL_STAGE:
if not self._access_policy.can_approve_claim(current_user, claim):
raise ValueError("只有当前预算管理者可以审批通过该单据。")
approval_source = "budget_approval"
event_type = (
"expense_application_budget_approval"
if is_application_claim
else "expense_claim_budget_approval"
)
label = "预算管理者审核通过"
if is_application_claim:
next_status = "approved"
next_stage = APPROVAL_DONE_STAGE
default_message = "{operator} 已完成预算审核,申请流程完成并生成报销草稿。"
else:
next_status = "submitted"
next_stage = FINANCE_APPROVAL_STAGE
default_message = "{operator}审批通过,流转至{next_stage}"
elif previous_stage == BUDGET_MANAGER_APPROVAL_STAGE:
if not is_application_claim:
raise ValueError("只有费用申请需要预算管理者审批。")
if not self._access_policy.can_approve_claim(current_user, claim):
raise ValueError("只有当前预算管理者可以审批通过该费用申请。")
approval_source = "budget_approval"
event_type = "expense_application_budget_approval"
label = "预算管理者审核通过"
next_status = "approved"
next_stage = APPROVAL_DONE_STAGE
default_message = "{operator} 已完成预算审核,申请流程完成并生成报销草稿。"
default_message = "{operator}完成预算审核,流转至{next_stage}"
elif previous_stage == FINANCE_APPROVAL_STAGE:
if is_application_claim:
raise ValueError("费用申请需先完成预算管理者审批。")
@@ -95,32 +130,35 @@ class ExpenseClaimApprovalFlowMixin:
consumed_budget_flag = self._consume_budget_for_finance_approval(claim, current_user)
if consumed_budget_flag is not None:
budget_flags.append(consumed_budget_flag)
approval_flag = {
"source": approval_source,
"event_type": event_type,
"approval_event_id": str(uuid.uuid4()),
"severity": "info",
"label": label,
"message": approval_opinion or default_message.format(operator=operator, next_stage=next_stage),
"opinion": approval_opinion,
"operator": operator,
"operator_username": current_user.username,
"operator_role_codes": [
str(item).strip().lower()
for item in current_user.role_codes
if str(item).strip()
],
"previous_status": str(claim.status or "").strip(),
"previous_approval_stage": previous_stage,
"next_status": next_status,
"next_approval_stage": next_stage,
"created_at": datetime.now(UTC).isoformat(),
}
approval_flag = with_risk_business_stage(
{
"source": approval_source,
"event_type": event_type,
"approval_event_id": str(uuid.uuid4()),
"severity": "info",
"label": label,
"message": approval_opinion or default_message.format(operator=operator, next_stage=next_stage),
"opinion": approval_opinion,
"operator": operator,
"operator_username": current_user.username,
"operator_role_codes": [
str(item).strip().lower()
for item in current_user.role_codes
if str(item).strip()
],
"previous_status": str(claim.status or "").strip(),
"previous_approval_stage": previous_stage,
"next_status": next_status,
"next_approval_stage": next_stage,
"created_at": datetime.now(UTC).isoformat(),
},
business_stage,
)
if merged_budget_approval:
approval_flag.update(
{
"budget_approval_merged": True,
"budget_approval_merged_reason": "direct_manager_is_department_budget_monitor",
"budget_approval_merged_reason": "direct_manager_is_department_budget_approver",
}
)
if next_budget_manager is not None:
@@ -129,9 +167,20 @@ class ExpenseClaimApprovalFlowMixin:
"next_approver_name": str(next_budget_manager.name or "").strip(),
"next_approver_employee_id": next_budget_manager.id,
"next_approver_grade": str(next_budget_manager.grade or "").strip(),
"next_approver_role_code": "budget_monitor",
"next_approver_role_code": self._access_policy.resolve_budget_approval_role_code(
next_budget_manager,
),
}
)
if route_decision_flag is not None:
approval_flag["route_decision"] = {
"requires_budget_review": route_decision_flag.get("requires_budget_review"),
"route": route_decision_flag.get("route"),
"reasons": route_decision_flag.get("reasons", []),
"budget_result": route_decision_flag.get("budget_result", {}),
"current_risk_count": route_decision_flag.get("current_risk_count", 0),
"historical_risk_count": route_decision_flag.get("historical_risk_count", 0),
}
claim.status = next_status
claim.approval_stage = next_stage
@@ -147,6 +196,13 @@ class ExpenseClaimApprovalFlowMixin:
elif merged_budget_approval:
approval_flag["leader_opinion"] = approval_opinion
approval_flag["budget_opinion"] = approval_opinion
elif (
previous_stage == DIRECT_MANAGER_APPROVAL_STAGE
and route_decision_flag is not None
and not route_decision_flag.get("requires_budget_review")
):
approval_flag["leader_opinion"] = approval_opinion
approval_flag["budget_opinion"] = "系统动态路由跳过预算复核"
generated_draft = self._create_reimbursement_draft_from_application(
application_claim=claim,
approval_flag=approval_flag,
@@ -162,14 +218,21 @@ class ExpenseClaimApprovalFlowMixin:
generated_draft.risk_flags_json = self._append_budget_flags(
generated_draft.risk_flags_json,
transferred_budget_flag,
business_stage="reimbursement",
)
approval_flags: list[Any] = list(claim.risk_flags_json or [])
if route_decision_flag is not None:
approval_flags.append(route_decision_flag)
approval_flags.append(approval_flag)
claim.risk_flags_json = self._append_budget_flags(
[*list(claim.risk_flags_json or []), approval_flag],
approval_flags,
budget_flags,
business_stage=business_stage,
)
self.db.commit()
self.db.refresh(claim)
self._access_policy.attach_budget_approval_snapshot(claim)
self.audit_service.log_action(
actor=operator,
@@ -202,26 +265,29 @@ class ExpenseClaimApprovalFlowMixin:
before_json = self._serialize_claim(claim)
operator = self._access_policy.resolve_current_user_display_name(current_user)
previous_stage = str(claim.approval_stage or "").strip()
payment_flag = {
"source": "payment",
"event_type": "expense_claim_payment_completed",
"payment_event_id": str(uuid.uuid4()),
"severity": "info",
"label": "付款完成",
"message": f"{operator} 已确认付款,报销单进入已付款。",
"operator": operator,
"operator_username": current_user.username,
"operator_role_codes": [
str(item).strip().lower()
for item in current_user.role_codes
if str(item).strip()
],
"previous_status": str(claim.status or "").strip(),
"previous_approval_stage": previous_stage,
"next_status": PAYMENT_PAID_STATUS,
"next_approval_stage": PAYMENT_PAID_STAGE,
"created_at": datetime.now(UTC).isoformat(),
}
payment_flag = with_risk_business_stage(
{
"source": "payment",
"event_type": "expense_claim_payment_completed",
"payment_event_id": str(uuid.uuid4()),
"severity": "info",
"label": "付款完成",
"message": f"{operator} 已确认付款,报销单进入已付款。",
"operator": operator,
"operator_username": current_user.username,
"operator_role_codes": [
str(item).strip().lower()
for item in current_user.role_codes
if str(item).strip()
],
"previous_status": str(claim.status or "").strip(),
"previous_approval_stage": previous_stage,
"next_status": PAYMENT_PAID_STATUS,
"next_approval_stage": PAYMENT_PAID_STAGE,
"created_at": datetime.now(UTC).isoformat(),
},
"reimbursement",
)
claim.status = PAYMENT_PAID_STATUS
claim.approval_stage = PAYMENT_PAID_STAGE

View File

@@ -0,0 +1,227 @@
from __future__ import annotations
from datetime import UTC, datetime, timedelta
from decimal import Decimal, InvalidOperation
from typing import Any
from sqlalchemy import or_, select
from app.models.financial_record import ExpenseClaim
from app.services.budget import BudgetService
from app.services.expense_claim_constants import AI_REVIEW_LOOKBACK_DAYS
from app.services.expense_claim_risk_stage import (
risk_business_stage_for_claim,
risk_flag_business_stage,
with_risk_business_stage,
)
class ExpenseClaimApprovalRoutingMixin:
_BUDGET_REVIEW_RATINGS = {"block"}
_BUDGET_REVIEW_RISK_LEVELS = {"high", "critical"}
_ROUTE_RISK_SEVERITIES = {"medium", "high", "critical", "danger"}
_ROUTE_RISK_SOURCES = {
"attachment_analysis",
"budget",
"budget_control",
"manual_return",
"platform_risk",
"platform_risk_rule",
"policy_review",
"risk_rule",
"scene_policy",
"submission_review",
"travel_policy",
}
_ROUTE_IGNORED_SOURCES = {
"application_detail",
"application_handoff",
"approval_routing",
"budget_approval",
"finance_approval",
"manual_approval",
"payment",
}
_ROUTE_RISK_EVENT_TYPES = {
"budget_frozen",
"budget_insufficient",
"budget_missing",
"platform_risk_rule_hit",
"risk_rule_hit",
}
_BUDGET_ROUTE_EVENT_TYPES = {
"budget_frozen",
"budget_insufficient",
"budget_missing",
}
def _build_approval_route_decision(
self,
claim: ExpenseClaim,
*,
is_application_claim: bool,
) -> dict[str, Any]:
business_stage = risk_business_stage_for_claim(is_application_claim=is_application_claim)
budget_result = BudgetService(self.db).analyze_claim_budget(claim)
budget_reasons = self._collect_budget_route_reasons(budget_result)
current_risk_reasons = self._collect_current_route_risk_reasons(
claim.risk_flags_json,
business_stage=business_stage,
)
historical_risk_count = self._count_recent_substantive_risky_claims(claim)
historical_risk_reasons = (
[f"申请人近 {AI_REVIEW_LOOKBACK_DAYS} 天存在 {historical_risk_count} 笔实质风险记录"]
if historical_risk_count > 0
else []
)
reasons = self._dedupe_reasons(
[*budget_reasons, *current_risk_reasons, *historical_risk_reasons]
)
requires_budget_review = bool(reasons)
route = (
"budget_manager"
if requires_budget_review
else "approval_done"
if is_application_claim
else "finance"
)
label = "需要预算管理者复核" if requires_budget_review else "跳过预算管理者复核"
message = (
"系统根据预算、当前风险和历史风险判断,该单据需要预算管理者二次确认。"
if requires_budget_review
else "系统根据预算、当前风险和历史风险判断,该单据可跳过预算管理者复核。"
)
return with_risk_business_stage(
{
"source": "approval_routing",
"event_type": (
"expense_application_route_decision"
if is_application_claim
else "expense_claim_route_decision"
),
"severity": "medium" if requires_budget_review else "info",
"label": label,
"message": message,
"requires_budget_review": requires_budget_review,
"route": route,
"reasons": reasons,
"budget_result": self._compact_budget_result(budget_result),
"current_risk_count": len(current_risk_reasons),
"historical_risk_count": historical_risk_count,
"created_at": datetime.now(UTC).isoformat(),
},
business_stage,
)
def _collect_budget_route_reasons(self, budget_result: dict[str, Any]) -> list[str]:
rating = str(budget_result.get("rating") or "").strip().lower()
risk_level = str(budget_result.get("risk_level") or "").strip().lower()
metrics = budget_result.get("metrics") if isinstance(budget_result.get("metrics"), dict) else {}
context = (
budget_result.get("budget_context")
if isinstance(budget_result.get("budget_context"), dict)
else {}
)
reasons: list[str] = []
if context.get("budget_applicable") is True and context.get("matched") is False:
reasons.append("未匹配到可用预算池")
if rating in self._BUDGET_REVIEW_RATINGS:
summary = str(budget_result.get("summary") or "").strip()
reasons.append(summary or f"预算测算评级为 {rating}")
if risk_level in self._BUDGET_REVIEW_RISK_LEVELS:
reasons.append(f"预算风险等级为 {risk_level}")
over_budget_amount = self._decimal(metrics.get("over_budget_amount"))
if over_budget_amount > Decimal("0.00"):
reasons.append(f"预计超预算 {over_budget_amount}")
return self._dedupe_reasons(reasons)
def _collect_current_route_risk_reasons(
self,
risk_flags: list[Any] | None,
*,
business_stage: str,
) -> list[str]:
reasons: list[str] = []
for flag in list(risk_flags or []):
if not isinstance(flag, dict):
continue
flag_stage = risk_flag_business_stage(flag)
if flag_stage and flag_stage != business_stage:
continue
if not self._is_substantive_route_risk_flag(flag):
continue
label = str(flag.get("label") or flag.get("event_type") or "风险标记").strip()
message = str(flag.get("message") or "").strip()
reasons.append(f"{label}{message}" if message else label)
return self._dedupe_reasons(reasons)
def _count_recent_substantive_risky_claims(self, claim: ExpenseClaim) -> int:
filters = []
if claim.employee_id:
filters.append(ExpenseClaim.employee_id == claim.employee_id)
elif claim.employee_name:
filters.append(ExpenseClaim.employee_name == claim.employee_name)
if not filters:
return 0
since = datetime.now(UTC) - timedelta(days=AI_REVIEW_LOOKBACK_DAYS)
stmt = (
select(ExpenseClaim)
.where(or_(*filters))
.where(ExpenseClaim.id != claim.id)
.where(ExpenseClaim.occurred_at >= since)
)
return sum(
1
for item in self.db.scalars(stmt).all()
if any(
self._is_substantive_route_risk_flag(flag)
for flag in list(item.risk_flags_json or [])
if isinstance(flag, dict)
)
)
def _is_substantive_route_risk_flag(self, flag: dict[str, Any]) -> bool:
source = str(flag.get("source") or "").strip().lower()
if source in self._ROUTE_IGNORED_SOURCES:
return False
event_type = str(flag.get("event_type") or "").strip().lower()
severity = str(flag.get("severity") or "").strip().lower()
if source in {"budget", "budget_control"}:
return event_type in self._BUDGET_ROUTE_EVENT_TYPES or severity in {"high", "critical", "danger"}
if event_type in self._ROUTE_RISK_EVENT_TYPES:
return True
if severity in self._ROUTE_RISK_SEVERITIES:
return source in self._ROUTE_RISK_SOURCES or bool(source)
return source in self._ROUTE_RISK_SOURCES and bool(flag.get("triggered"))
@staticmethod
def _compact_budget_result(budget_result: dict[str, Any]) -> dict[str, Any]:
return {
"score": budget_result.get("score"),
"rating": budget_result.get("rating"),
"risk_level": budget_result.get("risk_level"),
"summary": budget_result.get("summary"),
"metrics": budget_result.get("metrics") if isinstance(budget_result.get("metrics"), dict) else {},
}
@staticmethod
def _dedupe_reasons(reasons: list[str]) -> list[str]:
deduped: list[str] = []
seen: set[str] = set()
for reason in reasons:
text = str(reason or "").strip()
if not text or text in seen:
continue
seen.add(text)
deduped.append(text)
return deduped
@staticmethod
def _decimal(value: Any) -> Decimal:
try:
return Decimal(str(value if value is not None else "0")).quantize(Decimal("0.01"))
except (InvalidOperation, ValueError):
return Decimal("0.00")

View File

@@ -277,6 +277,7 @@ class ExpenseClaimAttachmentOperationsMixin:
"item_location": item.item_location,
"item_amount": item.item_amount,
"claim_amount": claim.amount,
"claim_risk_flags": list(claim.risk_flags_json or []),
"attachment": self._build_attachment_payload(item),
}
@@ -371,6 +372,7 @@ class ExpenseClaimAttachmentOperationsMixin:
"claim_id": claim.id,
"item_id": item.id,
"invoice_id": item.invoice_id,
"claim_risk_flags": list(claim.risk_flags_json or []),
"attachment": None,
}

View File

@@ -5,6 +5,7 @@ from typing import Any
from app.api.deps import CurrentUserContext
from app.models.financial_record import ExpenseClaim
from app.services.budget import BudgetService
from app.services.expense_claim_risk_stage import enrich_risk_flag_semantics
class ExpenseClaimBudgetFlowMixin:
@@ -80,6 +81,8 @@ class ExpenseClaimBudgetFlowMixin:
def _append_budget_flags(
risk_flags: list[Any] | None,
budget_flags: list[dict[str, Any]] | dict[str, Any] | None,
*,
business_stage: str | None = None,
) -> list[Any]:
if budget_flags is None:
return list(risk_flags or [])
@@ -89,7 +92,19 @@ class ExpenseClaimBudgetFlowMixin:
next_flags = list(budget_flags or [])
if not next_flags:
return list(risk_flags or [])
return [*list(risk_flags or []), *next_flags]
enriched_flags = [
enrich_risk_flag_semantics(
flag,
business_stage=business_stage,
risk_domain="budget",
visibility_scope="budget_manager",
actionability="budget_governance",
)
if isinstance(flag, dict)
else flag
for flag in next_flags
]
return [*list(risk_flags or []), *enriched_flags]
@staticmethod
def _resolve_budget_operator(current_user: CurrentUserContext) -> str:

View File

@@ -224,6 +224,10 @@ class ExpenseClaimDraftFlowMixin:
existing_flags=list(claim.risk_flags_json or []) if claim is not None else [],
next_flags=list(ontology.risk_flags),
)
final_risk_flags = self._merge_application_link_flag(
final_risk_flags,
context_json=context_json,
)
if context_documents or attachment_names:
document_specs = self._build_context_item_specs(
context_documents=context_documents,
@@ -347,6 +351,7 @@ class ExpenseClaimDraftFlowMixin:
context_json=retry_context,
)
raise
except Exception:
self.db.rollback()
raise
@@ -374,6 +379,86 @@ class ExpenseClaimDraftFlowMixin:
"invoice_count": int(claim.invoice_count or 0),
}
@staticmethod
def _merge_application_link_flag(
risk_flags: list[Any],
*,
context_json: dict[str, Any],
) -> list[Any]:
link_flag = ExpenseClaimDraftFlowMixin._build_application_link_flag(context_json)
if link_flag is None:
return list(risk_flags or [])
application_claim_no = str(link_flag.get("application_claim_no") or "").strip()
for flag in list(risk_flags or []):
if not isinstance(flag, dict):
continue
existing_no = str(
flag.get("application_claim_no")
or flag.get("applicationClaimNo")
or ""
).strip()
if existing_no and existing_no == application_claim_no:
return list(risk_flags or [])
return [*list(risk_flags or []), link_flag]
@staticmethod
def _build_application_link_flag(context_json: dict[str, Any]) -> dict[str, Any] | None:
review_values = ExpenseClaimDraftFlowMixin._normalize_context_object(
context_json.get("review_form_values")
)
scene_selection = ExpenseClaimDraftFlowMixin._normalize_context_object(
context_json.get("expense_scene_selection")
)
def pick(*keys: str) -> str:
for source in (review_values, scene_selection, context_json):
for key in keys:
value = str(source.get(key) or "").strip()
if value:
return value
return ""
application_claim_no = pick("application_claim_no", "applicationClaimNo")
if not application_claim_no:
return None
application_claim_id = pick("application_claim_id", "applicationClaimId")
application_amount = pick("application_amount", "applicationAmount")
application_amount_label = pick("application_amount_label", "applicationAmountLabel")
application_reason = pick("application_reason", "applicationReason", "reason")
application_location = pick("application_location", "applicationLocation", "location")
application_date = pick("application_date", "applicationDate", "business_time", "time_range")
application_status = pick("application_status", "applicationStatus")
application_status_label = pick("application_status_label", "applicationStatusLabel")
return {
"source": "application_link",
"event_type": "expense_reimbursement_application_linked",
"severity": "info",
"label": "关联申请单",
"message": f"报销草稿已关联申请单 {application_claim_no}",
"application_claim_id": application_claim_id,
"application_claim_no": application_claim_no,
"application_amount_label": application_amount_label,
"application_status": application_status,
"application_status_label": application_status_label,
"application_detail": {
"application_reason": application_reason,
"application_location": application_location,
"application_amount": application_amount,
"application_amount_label": application_amount_label,
"application_time": application_date,
},
"review_form_values": review_values,
"expense_scene_selection": scene_selection,
"created_at": datetime.now(UTC).isoformat(),
}
@staticmethod
def _normalize_context_object(value: Any) -> dict[str, Any]:
return dict(value) if isinstance(value, dict) else {}
def _find_target_claim(
self,
*,

View File

@@ -27,6 +27,7 @@ from app.services.expense_claim_constants import (
TRAVEL_ALLOWANCE_TRIGGER_ITEM_TYPES,
TRAVEL_POLICY_HOTEL_NIGHT_PATTERN,
)
from app.services.expense_claim_risk_stage import with_risk_business_stage
from app.services.expense_rule_runtime import (
ExpenseRuleRuntimeService,
RuntimeTravelPolicy,
@@ -215,6 +216,7 @@ class ExpenseClaimItemSyncMixin:
claim.amount = Decimal("0.00")
claim.invoice_count = 0
claim.risk_flags_json = self._merge_claim_attachment_risk_flags(claim, [])
claim.risk_flags_json = self._merge_claim_platform_risk_preview_flags(claim, [])
return
ordered_items = sorted(
@@ -253,6 +255,7 @@ class ExpenseClaimItemSyncMixin:
claim,
self._build_claim_attachment_risk_flags(ordered_items),
)
self._refresh_claim_platform_risk_preview_flags(claim)
if str(claim.status or "").strip().lower() == "draft":
claim.approval_stage = "待提交"
@@ -359,15 +362,18 @@ class ExpenseClaimItemSyncMixin:
analysis.get("label") or ("高风险" if severity == "high" else "中风险")
).strip()
derived_flags.append(
{
"source": "attachment_analysis",
"item_id": item.id,
"severity": severity,
"label": label,
"message": f"费用明细第 {index} 条:{message_detail}",
"summary": summary,
"points": points,
}
with_risk_business_stage(
{
"source": "attachment_analysis",
"item_id": item.id,
"severity": severity,
"label": label,
"message": f"费用明细第 {index} 条:{message_detail}",
"summary": summary,
"points": points,
},
"reimbursement",
)
)
return derived_flags
@@ -412,6 +418,38 @@ class ExpenseClaimItemSyncMixin:
]
return preserved_flags + attachment_risk_flags
def _refresh_claim_platform_risk_preview_flags(self, claim: ExpenseClaim) -> None:
if str(claim.expense_type or "").strip().lower().endswith("_application"):
return
evaluator = getattr(self, "evaluate_platform_risk_rules", None)
if not callable(evaluator):
return
try:
review = evaluator(claim, business_stage="reimbursement")
except Exception:
return
platform_flags = list(review.get("flags") or []) if isinstance(review, dict) else []
claim.risk_flags_json = self._merge_claim_platform_risk_preview_flags(
claim,
platform_flags,
)
@staticmethod
def _merge_claim_platform_risk_preview_flags(
claim: ExpenseClaim,
platform_flags: list[dict[str, Any]],
) -> list[Any]:
preserved_flags = [
flag
for flag in list(claim.risk_flags_json or [])
if not (
isinstance(flag, dict)
and str(flag.get("source") or "").strip() == "submission_review"
and str(flag.get("hit_source") or "").strip() == "rule_center"
)
]
return preserved_flags + platform_flags
@staticmethod
def _format_submission_blocked_message(issues: list[str]) -> str:
normalized_issues = [str(issue or "").strip() for issue in issues if str(issue or "").strip()]

View File

@@ -29,7 +29,9 @@ class ExpenseClaimPaginationMixin:
ExpenseClaim.occurred_at.desc(),
)
stmt = self._access_policy.apply_claim_scope(stmt, current_user)
return paginate_select(self.db, stmt, page=page, page_size=page_size)
result = paginate_select(self.db, stmt, page=page, page_size=page_size)
self._access_policy.attach_budget_approval_snapshots(result.items)
return result
def list_approval_claims_page(
self,
@@ -43,7 +45,9 @@ class ExpenseClaimPaginationMixin:
ExpenseClaim.created_at.desc(),
)
stmt = self._access_policy.apply_approval_claim_scope(stmt, current_user)
return paginate_select(self.db, stmt, page=page, page_size=page_size)
result = paginate_select(self.db, stmt, page=page, page_size=page_size)
self._access_policy.attach_budget_approval_snapshots(result.items)
return result
def list_archived_claims_page(
self,

View File

@@ -15,18 +15,27 @@ from app.services.expense_rule_runtime import (
RuntimeTravelPolicy,
)
from app.services.expense_type_keywords import resolve_expense_type_code_from_text
from app.services.expense_claim_platform_risk_flag import build_platform_risk_flag
from app.services.risk_rule_manifest_normalizer import normalize_risk_rule_manifest
from app.services.risk_rule_template_executor import RiskRuleTemplateExecutor
class ExpenseClaimPlatformRiskMixin:
_DEFAULT_RISK_BUSINESS_STAGE = "reimbursement"
_SUPPORTED_RISK_BUSINESS_STAGES = {"expense_application", "reimbursement"}
def evaluate_platform_risk_rules(
self,
claim: ExpenseClaim,
*,
rule_codes: list[str] | None = None,
business_stage: str | None = None,
) -> dict[str, list[Any]]:
manifests = self._load_platform_risk_rule_manifests(rule_codes=rule_codes)
normalized_stage = self._normalize_platform_risk_business_stage(business_stage)
manifests = self._load_platform_risk_rule_manifests(
rule_codes=rule_codes,
business_stage=normalized_stage,
)
if not manifests:
return {"flags": [], "blocking_reasons": []}
@@ -69,6 +78,7 @@ class ExpenseClaimPlatformRiskMixin:
self,
*,
rule_codes: list[str] | None,
business_stage: str | None,
) -> list[dict[str, Any]]:
code_filter = {
str(code or "").strip() for code in list(rule_codes or []) if str(code or "").strip()
@@ -117,7 +127,10 @@ class ExpenseClaimPlatformRiskMixin:
manifest_code = str(payload.get("rule_code") or rule_code).strip()
if not manifest_code or (code_filter and manifest_code not in code_filter):
continue
if payload.get("enabled") is False:
if payload.get("enabled") is False or not self._risk_manifest_matches_business_stage(
payload,
business_stage=business_stage,
):
continue
payload = dict(payload)
@@ -149,7 +162,10 @@ class ExpenseClaimPlatformRiskMixin:
continue
if code_filter and rule_code not in missing_codes:
continue
if payload.get("enabled") is False:
if payload.get("enabled") is False or not self._risk_manifest_matches_business_stage(
payload,
business_stage=business_stage,
):
continue
payload = dict(payload)
payload["_rule_version"] = "v1.0.0"
@@ -157,6 +173,34 @@ class ExpenseClaimPlatformRiskMixin:
return list(manifests_by_code.values())
@classmethod
def _normalize_platform_risk_business_stage(cls, value: str | None) -> str:
normalized = str(value or cls._DEFAULT_RISK_BUSINESS_STAGE).strip().lower()
if not normalized or normalized not in cls._SUPPORTED_RISK_BUSINESS_STAGES:
return cls._DEFAULT_RISK_BUSINESS_STAGE
return normalized
@classmethod
def _risk_manifest_matches_business_stage(
cls,
manifest: dict[str, Any],
*,
business_stage: str | None,
) -> bool:
if not business_stage:
return True
applies_to = manifest.get("applies_to") if isinstance(manifest.get("applies_to"), dict) else {}
raw_stages = applies_to.get("business_stages")
if not isinstance(raw_stages, list):
metadata = manifest.get("metadata") if isinstance(manifest.get("metadata"), dict) else {}
raw_stages = [manifest.get("business_stage") or metadata.get("business_stage") or cls._DEFAULT_RISK_BUSINESS_STAGE]
stages = {
cls._normalize_platform_risk_business_stage(str(item))
for item in raw_stages
if str(item or "").strip()
}
return business_stage in (stages or {cls._DEFAULT_RISK_BUSINESS_STAGE})
def _risk_manifest_applies_to_claim(
self,
manifest: dict[str, Any],
@@ -187,9 +231,19 @@ class ExpenseClaimPlatformRiskMixin:
configured_expense_types = self._normalize_expense_type_values(
*[str(value or "") for value in list(applies_to.get("expense_types") or [])]
)
configured_expense_categories = self._normalize_expense_type_values(
*[str(value or "") for value in list(applies_to.get("expense_categories") or [])]
)
if self._is_all_expense_scope(configured_expense_types):
configured_expense_types = set()
if self._is_all_expense_scope(configured_expense_categories):
configured_expense_categories = set()
if configured_expense_types and not (expense_types & configured_expense_types):
return False
if configured_expense_categories and not (expense_types & configured_expense_categories):
return False
if domains and not self._risk_domains_match_claim(
domains,
expense_types=expense_types,
@@ -207,11 +261,19 @@ class ExpenseClaimPlatformRiskMixin:
if not raw:
continue
normalized.add(raw.lower())
if raw in {"全部", "通用"}:
normalized.add("all")
if raw.lower().endswith("_application"):
normalized.add(raw.lower().removesuffix("_application"))
resolved = resolve_expense_type_code_from_text(raw)
if resolved:
normalized.add(resolved)
return normalized
@staticmethod
def _is_all_expense_scope(values: set[str]) -> bool:
return bool(values & {"all", "*", "overall", "general", "全部", "通用"})
def _risk_domains_match_claim(
self,
domains: set[str],
@@ -634,25 +696,12 @@ class ExpenseClaimPlatformRiskMixin:
message: str,
evidence: dict[str, Any],
) -> dict[str, Any]:
outcomes = manifest.get("outcomes") if isinstance(manifest.get("outcomes"), dict) else {}
fail_outcome = outcomes.get("fail") if isinstance(outcomes.get("fail"), dict) else {}
severity = str(fail_outcome.get("severity") or "medium").strip().lower() or "medium"
default_action = "block" if severity in {"high", "critical"} else "manual_review"
action = str(fail_outcome.get("action") or default_action).strip()
label = str(manifest.get("name") or manifest.get("rule_code") or "风险规则命中").strip()
return {
"source": "submission_review",
"hit_source": "rule_center",
"rule_type": "risk",
"rule_code": str(manifest.get("rule_code") or "").strip(),
"rule_version": str(manifest.get("_rule_version") or "v1.0.0").strip(),
"severity": severity,
"action": action,
"label": label,
"message": message,
"evidence": evidence,
}
return build_platform_risk_flag(
manifest,
message=message,
evidence=evidence,
default_business_stage=self._DEFAULT_RISK_BUSINESS_STAGE,
)
@staticmethod
def _count_values(values: list[str]) -> dict[str, int]:

View File

@@ -0,0 +1,76 @@
from __future__ import annotations
from typing import Any
from app.services.expense_claim_risk_stage import (
infer_risk_domain,
normalize_risk_business_stage,
normalize_risk_actionability,
normalize_risk_visibility_scope,
with_risk_business_stage,
)
def build_platform_risk_flag(
manifest: dict[str, Any],
*,
message: str,
evidence: dict[str, Any],
default_business_stage: str,
) -> dict[str, Any]:
outcomes = manifest.get("outcomes") if isinstance(manifest.get("outcomes"), dict) else {}
fail_outcome = outcomes.get("fail") if isinstance(outcomes.get("fail"), dict) else {}
severity = str(fail_outcome.get("severity") or "medium").strip().lower() or "medium"
default_action = "block" if severity in {"high", "critical"} else "manual_review"
action = str(fail_outcome.get("action") or default_action).strip()
label = str(manifest.get("name") or manifest.get("rule_code") or "风险规则命中").strip()
metadata = manifest.get("metadata") if isinstance(manifest.get("metadata"), dict) else {}
applies_to = manifest.get("applies_to") if isinstance(manifest.get("applies_to"), dict) else {}
raw_stages = applies_to.get("business_stages")
applies_to_stage = raw_stages[0] if isinstance(raw_stages, list) and raw_stages else None
business_stage = normalize_risk_business_stage(
manifest.get("business_stage") or metadata.get("business_stage") or applies_to_stage,
default=default_business_stage,
)
risk_domain = infer_risk_domain(manifest)
default_visibility_scope = (
"budget_manager"
if risk_domain == "budget"
else "leader"
if business_stage == "expense_application"
else "submitter"
)
default_actionability = (
"budget_governance"
if risk_domain == "budget"
else "review_decision"
if business_stage == "expense_application"
else "fixable_by_submitter"
)
visibility_scope = normalize_risk_visibility_scope(
metadata.get("visibility_scope") or manifest.get("visibility_scope"),
default_visibility_scope,
)
actionability = normalize_risk_actionability(
metadata.get("actionability") or manifest.get("actionability"),
default_actionability,
)
return with_risk_business_stage(
{
"source": "submission_review",
"hit_source": "rule_center",
"rule_type": "risk",
"rule_code": str(manifest.get("rule_code") or "").strip(),
"rule_version": str(manifest.get("_rule_version") or "v1.0.0").strip(),
"severity": severity,
"action": action,
"label": label,
"message": message,
"evidence": evidence,
"risk_domain": risk_domain,
"visibility_scope": visibility_scope,
"actionability": actionability,
},
business_stage,
)

View File

@@ -27,6 +27,7 @@ from app.services.expense_claim_constants import (
TRAVEL_ALLOWANCE_TRIGGER_ITEM_TYPES,
TRAVEL_POLICY_HOTEL_NIGHT_PATTERN,
)
from app.services.expense_claim_risk_stage import with_risk_business_stage
from app.services.expense_rule_runtime import (
ExpenseRuleRuntimeService,
RuntimeTravelPolicy,
@@ -135,7 +136,7 @@ class ExpenseClaimPolicyReviewMixin:
)
return {
"flags": flags,
"flags": [with_risk_business_stage(flag, "reimbursement") for flag in flags],
"blocking_reasons": list(dict.fromkeys(reason for reason in blocking_reasons if reason)),
}
@@ -393,7 +394,7 @@ class ExpenseClaimPolicyReviewMixin:
blocking_reasons.append("交通舱位或席别超出当前职级差标,且未补充例外说明。")
return {
"flags": flags,
"flags": [with_risk_business_stage(flag, "reimbursement") for flag in flags],
"blocking_reasons": list(dict.fromkeys(reason for reason in blocking_reasons if reason)),
}

View File

@@ -0,0 +1,141 @@
from __future__ import annotations
from datetime import UTC, datetime
from typing import Any
from app.api.deps import CurrentUserContext
from app.models.financial_record import ExpenseClaim
from app.services.expense_claim_errors import ExpenseClaimSubmissionBlockedError
from app.services.expense_claim_risk_stage import risk_business_stage_for_claim, with_risk_business_stage
class ExpenseClaimPreReviewMixin:
def pre_review_claim(
self,
claim_id: str,
current_user: CurrentUserContext,
) -> ExpenseClaim | None:
claim = self.get_claim(claim_id, current_user)
if claim is None:
return None
self._ensure_draft_claim(claim)
self._access_policy.backfill_claim_identity_from_current_user(claim, current_user)
is_application_claim = self._is_expense_application_claim(claim)
if not is_application_claim:
self._sync_claim_from_items(claim)
missing_fields = (
self._validate_application_claim_for_submission(claim)
if is_application_claim
else self._validate_claim_for_submission(claim)
)
if missing_fields:
raise ExpenseClaimSubmissionBlockedError(missing_fields)
before_json = self._serialize_claim(claim)
reviewed_at = datetime.now(UTC)
if is_application_claim:
preserved_flags = [
flag
for flag in list(claim.risk_flags_json or [])
if not (
isinstance(flag, dict)
and str(flag.get("source") or "").strip() == "submission_review"
and str(flag.get("hit_source") or "").strip() == "rule_center"
)
]
application_review = self.evaluate_platform_risk_rules(
claim,
business_stage="expense_application",
)
review_flags = [*preserved_flags, *list(application_review.get("flags") or [])]
blocking_count = self._count_ai_pre_review_blocking_risks(review_flags)
passed = blocking_count <= 0
else:
review_result = self._run_ai_submission_review(claim)
review_flags = list(review_result.get("risk_flags") or [])
blocking_count = self._count_ai_pre_review_blocking_risks(review_flags)
passed = blocking_count <= 0
claim.risk_flags_json = self._replace_ai_pre_review_flag(
review_flags,
self._build_ai_pre_review_flag(
passed=passed,
blocking_count=blocking_count,
reviewed_at=reviewed_at,
business_stage=risk_business_stage_for_claim(
is_application_claim=is_application_claim,
),
),
)
claim.approval_stage = "AI预审" if not is_application_claim else claim.approval_stage
claim.submitted_at = None
self.db.commit()
self.db.refresh(claim)
self.audit_service.log_action(
actor=current_user.name or current_user.username,
action="expense_claim.pre_review",
resource_type="expense_claim",
resource_id=claim.id,
before_json=before_json,
after_json=self._serialize_claim(claim),
)
return claim
@staticmethod
def _count_ai_pre_review_blocking_risks(risk_flags: list[Any]) -> int:
return sum(
1
for flag in risk_flags
if (
isinstance(flag, dict)
and str(flag.get("source") or "").strip() != "ai_pre_review"
and str(flag.get("severity") or "").strip().lower() == "high"
)
)
@staticmethod
def _build_ai_pre_review_flag(
*,
passed: bool,
blocking_count: int,
reviewed_at: datetime,
business_stage: str,
) -> dict[str, Any]:
if passed:
message = "AI预审通过费用明细和附件可进入下一步提交审批。"
else:
message = f"AI预审发现 {blocking_count} 条重大风险,请逐条填写原因后再进入下一步。"
return with_risk_business_stage(
{
"source": "ai_pre_review",
"event_type": "expense_claim_ai_pre_review",
"severity": "info" if passed else "high",
"label": "AI预审通过" if passed else "AI预审未通过",
"message": message,
"status": "passed" if passed else "failed",
"passed": passed,
"blocking_risk_count": blocking_count,
"next_action": "next_step" if passed else "risk_explanation_required",
"created_at": reviewed_at.isoformat(),
},
business_stage,
)
@staticmethod
def _replace_ai_pre_review_flag(
risk_flags: list[Any],
next_flag: dict[str, Any],
) -> list[Any]:
preserved_flags = [
flag
for flag in list(risk_flags or [])
if not (
isinstance(flag, dict)
and str(flag.get("source") or "").strip() == "ai_pre_review"
)
]
return [*preserved_flags, next_flag]

View File

@@ -15,6 +15,7 @@ from app.services.expense_claim_constants import (
from app.services.expense_claim_item_sync import ExpenseClaimItemSyncMixin
from app.services.expense_claim_platform_risk import ExpenseClaimPlatformRiskMixin
from app.services.expense_claim_policy_review import ExpenseClaimPolicyReviewMixin
from app.services.expense_claim_risk_stage import with_risk_business_stage
from app.services.risk_observations import RiskObservationService
logger = get_logger("app.services.expense_claim_risk_review")
@@ -159,6 +160,8 @@ class ExpenseClaimRiskReviewMixin(
},
)
review_flags = [with_risk_business_stage(flag, "reimbursement") for flag in review_flags]
return {
"status": "submitted",
"approval_stage": "直属领导审批",

View File

@@ -0,0 +1,228 @@
from __future__ import annotations
from typing import Any
EXPENSE_APPLICATION_BUSINESS_STAGE = "expense_application"
REIMBURSEMENT_BUSINESS_STAGE = "reimbursement"
SUPPORTED_RISK_BUSINESS_STAGES = {
EXPENSE_APPLICATION_BUSINESS_STAGE,
REIMBURSEMENT_BUSINESS_STAGE,
}
SUPPORTED_RISK_DOMAINS = {
"budget",
"policy",
"invoice",
"trip",
"amount",
"workflow",
"profile",
}
SUPPORTED_RISK_VISIBILITY_SCOPES = {
"submitter",
"leader",
"budget_manager",
"finance",
"admin",
}
SUPPORTED_RISK_ACTIONABILITIES = {
"fixable_by_submitter",
"review_decision",
"budget_governance",
"finance_check",
"system_trace",
}
_NON_RISK_SYSTEM_SOURCES = {
"application_detail",
"application_handoff",
"application_submission",
"approval",
"approval_log",
"approval_routing",
"budget_approval",
"expense_claim_approval",
"expense_claim_finance_approval",
"finance_approval",
"manual_approval",
"payment",
"sla_reminder",
"reminder",
"urge",
}
def normalize_risk_business_stage(value: Any, default: str = REIMBURSEMENT_BUSINESS_STAGE) -> str:
normalized = str(value or "").strip().lower()
if normalized in SUPPORTED_RISK_BUSINESS_STAGES:
return normalized
return default
def normalize_risk_domain(value: Any, default: str = "policy") -> str:
normalized = str(value or "").strip().lower()
if normalized in SUPPORTED_RISK_DOMAINS:
return normalized
return default
def normalize_risk_visibility_scope(value: Any, default: str = "leader") -> str:
normalized = str(value or "").strip().lower()
if normalized in SUPPORTED_RISK_VISIBILITY_SCOPES:
return normalized
return default
def normalize_risk_actionability(value: Any, default: str = "review_decision") -> str:
normalized = str(value or "").strip().lower()
if normalized in SUPPORTED_RISK_ACTIONABILITIES:
return normalized
return default
def risk_business_stage_for_claim(*, is_application_claim: bool) -> str:
return EXPENSE_APPLICATION_BUSINESS_STAGE if is_application_claim else REIMBURSEMENT_BUSINESS_STAGE
def risk_flag_business_stage(flag: dict[str, Any], default: str = "") -> str:
return normalize_risk_business_stage(
flag.get("business_stage")
or flag.get("businessStage")
or flag.get("control_stage")
or flag.get("controlStage"),
default=default,
)
def infer_risk_domain(flag: dict[str, Any]) -> str:
explicit_domain = (
flag.get("risk_domain")
or flag.get("riskDomain")
or flag.get("domain")
)
if explicit_domain:
return normalize_risk_domain(explicit_domain)
source = str(flag.get("source") or "").strip().lower()
event_type = str(flag.get("event_type") or flag.get("eventType") or "").strip().lower()
if source == "budget_control" or "budget" in event_type:
return "budget"
if source in {"manual_return", "approval_routing"}:
return "workflow"
if source in {"attachment_analysis"}:
return "invoice"
if source in {"financial_risk_graph"}:
return "profile"
corpus = " ".join(
str(value or "")
for value in [
flag.get("rule_code"),
flag.get("risk_category"),
flag.get("ontology_signal"),
flag.get("label"),
flag.get("name"),
flag.get("title"),
flag.get("message"),
flag.get("summary"),
flag.get("description"),
]
).lower()
if any(token in corpus for token in ["预算", "budget"]):
return "budget"
if any(token in corpus for token in ["发票", "票据", "单据", "附件", "ocr", "invoice", "receipt"]):
return "invoice"
trip_tokens = [
"行程",
"城市",
"住宿",
"交通",
"差旅",
"酒店",
"日期",
"时间",
"trip",
"travel",
"city",
"hotel",
"transport",
"period",
]
if any(token in corpus for token in trip_tokens):
return "trip"
if any(token in corpus for token in ["金额", "超标", "阈值", "额度", "标准", "amount", "limit", "over"]):
return "amount"
if any(token in corpus for token in ["历史", "画像", "异常关系", "profile", "baseline"]):
return "profile"
if any(token in corpus for token in ["审批", "退回", "流程", "付款", "routing", "approval", "return", "payment"]):
return "workflow"
return "policy"
def infer_risk_semantics(
flag: dict[str, Any],
*,
business_stage: str,
) -> tuple[str, str, str]:
risk_domain = infer_risk_domain(flag)
source = str(flag.get("source") or "").strip().lower()
if source in _NON_RISK_SYSTEM_SOURCES:
return risk_domain, "admin", "system_trace"
if risk_domain == "budget":
return risk_domain, "budget_manager", "budget_governance"
if source == "attachment_analysis":
return risk_domain, "submitter", "fixable_by_submitter"
if risk_domain == "profile":
return risk_domain, "leader", "review_decision"
if business_stage == REIMBURSEMENT_BUSINESS_STAGE:
if risk_domain in {"policy", "invoice", "trip", "amount"}:
return risk_domain, "submitter", "fixable_by_submitter"
return risk_domain, "finance", "finance_check"
if business_stage == EXPENSE_APPLICATION_BUSINESS_STAGE:
return risk_domain, "leader", "review_decision"
return risk_domain, "leader", "review_decision"
def enrich_risk_flag_semantics(
flag: dict[str, Any],
*,
business_stage: str | None = None,
risk_domain: str | None = None,
visibility_scope: str | None = None,
actionability: str | None = None,
) -> dict[str, Any]:
stage = normalize_risk_business_stage(
business_stage
or flag.get("business_stage")
or flag.get("businessStage")
or flag.get("control_stage")
or flag.get("controlStage")
)
inferred_domain, inferred_scope, inferred_actionability = infer_risk_semantics(
flag,
business_stage=stage,
)
domain = normalize_risk_domain(risk_domain or flag.get("risk_domain") or flag.get("riskDomain"), inferred_domain)
scope = normalize_risk_visibility_scope(
visibility_scope or flag.get("visibility_scope") or flag.get("visibilityScope"),
inferred_scope,
)
action = normalize_risk_actionability(
actionability or flag.get("actionability"),
inferred_actionability,
)
return {
**flag,
"business_stage": stage,
"businessStage": stage,
"risk_domain": domain,
"visibility_scope": scope,
"actionability": action,
}
def with_risk_business_stage(
flag: dict[str, Any],
business_stage: str,
) -> dict[str, Any]:
return enrich_risk_flag_semantics(flag, business_stage=business_stage)

View File

@@ -36,6 +36,7 @@ from app.services.document_intelligence import build_document_insight
from app.services.document_numbering import is_application_claim_no
from app.services.expense_claim_access_policy import ExpenseClaimAccessPolicy
from app.services.expense_claim_approval_flow import ExpenseClaimApprovalFlowMixin
from app.services.expense_claim_approval_routing import ExpenseClaimApprovalRoutingMixin
from app.services.expense_claim_attachment_presentation import ExpenseClaimAttachmentPresentation
from app.services.expense_claim_attachment_storage import ExpenseClaimAttachmentStorage
from app.services.expense_claim_application_handoff import ExpenseClaimApplicationHandoffMixin
@@ -49,8 +50,10 @@ from app.services.expense_claim_draft_flow import ExpenseClaimDraftFlowMixin
from app.services.expense_claim_draft_persistence import ExpenseClaimDraftPersistenceMixin
from app.services.expense_claim_errors import ExpenseClaimSubmissionBlockedError
from app.services.expense_claim_pagination import ExpenseClaimPaginationMixin
from app.services.expense_claim_pre_review import ExpenseClaimPreReviewMixin
from app.services.expense_claim_ontology_resolvers import ExpenseClaimOntologyResolverMixin
from app.services.expense_claim_read_model import ExpenseClaimReadModelMixin
from app.services.expense_claim_risk_stage import with_risk_business_stage
from app.services.expense_claim_review_preview import ExpenseClaimReviewPreviewMixin
from app.services.expense_claim_constants import (
EXPENSE_TYPE_LABELS,
@@ -131,7 +134,9 @@ from app.services.ocr import OcrService
class ExpenseClaimService(
ExpenseClaimPaginationMixin,
ExpenseClaimApprovalFlowMixin,
ExpenseClaimApprovalRoutingMixin,
ExpenseClaimApplicationHandoffMixin,
ExpenseClaimPreReviewMixin,
ExpenseClaimBudgetFlowMixin,
ExpenseClaimAttachmentOperationsMixin,
ExpenseClaimReviewPreviewMixin,
@@ -197,7 +202,7 @@ class ExpenseClaimService(
.order_by(ExpenseClaim.created_at.desc(), ExpenseClaim.occurred_at.desc())
)
stmt = self._access_policy.apply_claim_scope(stmt, current_user)
return list(self.db.scalars(stmt).all())
return self._access_policy.attach_budget_approval_snapshots(list(self.db.scalars(stmt).all()))
def list_approval_claims(self, current_user: CurrentUserContext) -> list[ExpenseClaim]:
stmt = (
@@ -210,7 +215,7 @@ class ExpenseClaimService(
.order_by(ExpenseClaim.submitted_at.desc(), ExpenseClaim.created_at.desc())
)
stmt = self._access_policy.apply_approval_claim_scope(stmt, current_user)
return list(self.db.scalars(stmt).all())
return self._access_policy.attach_budget_approval_snapshots(list(self.db.scalars(stmt).all()))
def list_archived_claims(self, current_user: CurrentUserContext) -> list[ExpenseClaim]:
stmt = (
@@ -236,7 +241,7 @@ class ExpenseClaimService(
.where(ExpenseClaim.id == claim_id)
)
stmt = self._access_policy.apply_claim_scope(stmt, current_user, include_approval_scope=True)
return self.db.scalar(stmt)
return self._access_policy.attach_budget_approval_snapshot(self.db.scalar(stmt))
def can_view_budget_analysis(self, current_user: CurrentUserContext, claim: ExpenseClaim | None = None) -> bool:
if claim is None:
@@ -468,27 +473,44 @@ class ExpenseClaimService(
for flag in list(claim.risk_flags_json or [])
if not (
isinstance(flag, dict)
and str(flag.get("source") or "").strip() in {"submission_review", "attachment_analysis"}
and str(flag.get("source") or "").strip()
in {"submission_review", "attachment_analysis"}
)
]
submit_flag = {
"source": "application_submission",
"event_type": "expense_application_submission",
"severity": "info",
"label": "申请提交",
"message": "费用申请已提交至直属领导审批,请等待审核结果。",
"previous_status": str(claim.status or "").strip(),
"previous_approval_stage": str(claim.approval_stage or "").strip(),
"next_status": "submitted",
"next_approval_stage": "直属领导审批",
"created_at": submitted_at.isoformat(),
}
platform_review = self.evaluate_platform_risk_rules(
claim,
business_stage="expense_application",
)
platform_flags = list(platform_review.get("flags") or [])
submit_flag = with_risk_business_stage(
{
"source": "application_submission",
"event_type": "expense_application_submission",
"severity": "info",
"label": "申请提交",
"message": "费用申请已提交至直属领导审批,请等待审核结果。",
"previous_status": str(claim.status or "").strip(),
"previous_approval_stage": str(claim.approval_stage or "").strip(),
"next_status": "submitted",
"next_approval_stage": "直属领导审批",
"created_at": submitted_at.isoformat(),
},
"expense_application",
)
claim.status = "submitted"
claim.approval_stage = "直属领导审批"
claim.risk_flags_json = self._append_budget_flags([*preserved_flags, submit_flag], budget_flags)
claim.risk_flags_json = self._append_budget_flags(
[*preserved_flags, submit_flag, *platform_flags],
budget_flags,
business_stage="expense_application",
)
claim.submitted_at = submitted_at
else:
claim.risk_flags_json = self._append_budget_flags(claim.risk_flags_json, budget_flags)
claim.risk_flags_json = self._append_budget_flags(
claim.risk_flags_json,
budget_flags,
business_stage="reimbursement",
)
review_result = self._run_ai_submission_review(claim)
claim.status = str(review_result.get("status") or "supplement")
@@ -681,6 +703,7 @@ class ExpenseClaimService(
claim.risk_flags_json = self._append_budget_flags(
[*list(claim.risk_flags_json or []), return_flag],
budget_flags,
business_stage="expense_application" if is_application_claim else "reimbursement",
)
self.db.commit()
@@ -717,10 +740,6 @@ class ExpenseClaimService(

View File

@@ -0,0 +1,402 @@
from __future__ import annotations
from collections import Counter
from datetime import UTC, datetime
from decimal import Decimal
from typing import Any
from sqlalchemy import select
from sqlalchemy.orm import Session, selectinload
from app.models.financial_record import ExpenseClaim
from app.models.risk_observation import RiskObservation, RiskObservationFeedback
from app.services.document_numbering import is_application_claim_no
from app.services.risk_observations import RiskObservationService
class HermesRiskClueCollectorService:
"""归集待人工复核线索,不生成、不改写、不发布规则。"""
def __init__(self, db: Session) -> None:
self.db = db
def collect_risk_clues(
self,
*,
run_id: str | None = None,
limit: int = 100,
) -> dict[str, Any]:
RiskObservationService(self.db).ensure_storage_ready()
safe_limit = max(1, min(int(limit or 100), 200))
claims = self._fetch_recent_claims(safe_limit)
observations = self._fetch_recent_observations(safe_limit * 2)
feedback_items = self._fetch_recent_feedback(safe_limit)
facts = [self._claim_fact(claim) for claim in claims]
claim_rule_hits = self._claim_rule_hits(claims)
observation_rule_hits = self._observation_rule_hits(observations)
rule_hits = self._dedupe_by_id([*observation_rule_hits, *claim_rule_hits])
evidence_refs = self._evidence_refs(observations, claim_rule_hits)
risk_clues = self._risk_clues(
observations=observations,
claim_rule_hits=claim_rule_hits,
evidence_refs=evidence_refs,
)
feedback_summary = self._feedback_summary(feedback_items)
message = (
"风险线索归集完成:"
f"读取 {len(facts)} 条申请/报销事实,"
f"整理 {len(rule_hits)} 条规则命中,"
f"输出 {len(risk_clues)} 条待人工复核线索。"
)
return {
"message": message,
"task_type": "risk_clue_collect",
"output_format": "risk_clue_review_packet",
"run_id": run_id,
"fact_count": len(facts),
"rule_hit_count": len(rule_hits),
"risk_clue_count": len(risk_clues),
"evidence_ref_count": len(evidence_refs),
"facts": facts,
"rule_hits": rule_hits,
"risk_clues": risk_clues,
"evidence_refs": evidence_refs,
"feedback_summary": feedback_summary,
"human_review_required": True,
"writes_rules": False,
"role_boundary": (
"规则由人定义,风险由人确认,主流程由外层智能体执行,"
"数字员工只读取事实、规则命中和反馈结果,生成后台分析、报告和待复核材料。"
),
"allowed_outputs": [
"facts",
"rule_hits",
"risk_clues",
"evidence_refs",
"human_review_required",
],
"generated_at": datetime.now(UTC).isoformat(),
}
def _fetch_recent_claims(self, limit: int) -> list[ExpenseClaim]:
stmt = select(ExpenseClaim).order_by(ExpenseClaim.created_at.desc()).limit(limit)
return list(self.db.scalars(stmt).all())
def _fetch_recent_observations(self, limit: int) -> list[RiskObservation]:
stmt = (
select(RiskObservation)
.options(selectinload(RiskObservation.feedback_items))
.order_by(RiskObservation.risk_score.desc(), RiskObservation.created_at.desc())
.limit(limit)
)
return list(self.db.scalars(stmt).all())
def _fetch_recent_feedback(self, limit: int) -> list[RiskObservationFeedback]:
stmt = (
select(RiskObservationFeedback)
.options(selectinload(RiskObservationFeedback.observation))
.order_by(RiskObservationFeedback.created_at.desc())
.limit(limit)
)
return list(self.db.scalars(stmt).all())
def _claim_fact(self, claim: ExpenseClaim) -> dict[str, Any]:
return {
"fact_id": f"fact:claim:{claim.id}",
"source": "expense_claims",
"claim_id": claim.id,
"claim_no": claim.claim_no,
"claim_kind": "application" if is_application_claim_no(claim.claim_no) else "reimbursement",
"employee_name": claim.employee_name,
"department_name": claim.department_name,
"expense_type": claim.expense_type,
"amount": _decimal_to_float(claim.amount),
"currency": claim.currency,
"status": claim.status,
"approval_stage": claim.approval_stage,
"occurred_at": _isoformat(claim.occurred_at),
"submitted_at": _isoformat(claim.submitted_at),
"risk_flag_count": len(list(claim.risk_flags_json or [])),
}
def _claim_rule_hits(self, claims: list[ExpenseClaim]) -> list[dict[str, Any]]:
hits: list[dict[str, Any]] = []
for claim in claims:
for index, flag in enumerate(list(claim.risk_flags_json or [])):
if not isinstance(flag, dict):
continue
signal = _text(
flag.get("risk_signal")
or flag.get("risk_type")
or flag.get("rule_code")
or flag.get("code")
or flag.get("label")
)
if not signal:
continue
rule_code = _text(flag.get("rule_code") or flag.get("code") or signal)
hits.append(
{
"hit_id": f"rule_hit:claim:{claim.id}:{rule_code}:{index}",
"source": _text(flag.get("source")) or "claim_risk_flags",
"rule_code": rule_code,
"risk_signal": signal,
"claim_id": claim.id,
"claim_no": claim.claim_no,
"title": _text(flag.get("label") or flag.get("title")) or signal,
"message": _text(flag.get("message") or flag.get("summary") or flag.get("reason")),
"severity": _text(flag.get("severity") or flag.get("risk_level")),
"metadata": flag,
}
)
return hits
def _observation_rule_hits(self, observations: list[RiskObservation]) -> list[dict[str, Any]]:
hits: list[dict[str, Any]] = []
for observation in observations:
if not _is_rule_hit_observation(observation):
continue
rule_code = _text(
(observation.decision_trace_json or {}).get("rule_code")
or (observation.policy_refs_json or [""])[0]
or observation.risk_signal
)
hits.append(
{
"hit_id": f"rule_hit:observation:{observation.observation_key}",
"source": observation.source or "risk_observation",
"rule_code": rule_code,
"risk_signal": observation.risk_signal,
"claim_id": observation.claim_id,
"claim_no": observation.claim_no,
"title": observation.title,
"message": observation.description,
"severity": observation.risk_level,
"observation_key": observation.observation_key,
}
)
return hits
def _evidence_refs(
self,
observations: list[RiskObservation],
claim_rule_hits: list[dict[str, Any]],
) -> list[dict[str, Any]]:
refs: list[dict[str, Any]] = []
for observation in observations:
for index, evidence in enumerate(list(observation.evidence_json or [])):
if not isinstance(evidence, dict):
continue
refs.append(
{
"evidence_id": f"evidence:observation:{observation.observation_key}:{index}",
"source": _text(evidence.get("source")) or observation.source or "risk_observation",
"title": _text(evidence.get("title") or evidence.get("code")) or observation.title,
"detail": _text(
evidence.get("detail")
or evidence.get("message")
or evidence.get("summary")
),
"claim_id": observation.claim_id,
"claim_no": observation.claim_no,
"observation_key": observation.observation_key,
}
)
for hit in claim_rule_hits:
refs.append(
{
"evidence_id": f"evidence:{hit['hit_id']}",
"source": hit["source"],
"title": hit["title"],
"detail": hit["message"] or "单据风险标记记录了该规则命中。",
"claim_id": hit["claim_id"],
"claim_no": hit["claim_no"],
"rule_hit_id": hit["hit_id"],
}
)
return refs
def _risk_clues(
self,
*,
observations: list[RiskObservation],
claim_rule_hits: list[dict[str, Any]],
evidence_refs: list[dict[str, Any]],
) -> list[dict[str, Any]]:
clues = [
self._observation_clue(observation, evidence_refs)
for observation in observations
if _needs_human_review(observation)
]
observed_claim_signals = {
(clue.get("claim_id"), clue.get("risk_signal"))
for clue in clues
if clue.get("claim_id") and clue.get("risk_signal")
}
for hit in claim_rule_hits:
key = (hit.get("claim_id"), hit.get("risk_signal"))
if key in observed_claim_signals:
continue
clues.append(self._claim_flag_clue(hit, evidence_refs))
clues.sort(key=lambda item: float(item.get("confidence_score") or 0), reverse=True)
return clues[:30]
def _observation_clue(
self,
observation: RiskObservation,
evidence_refs: list[dict[str, Any]],
) -> dict[str, Any]:
evidence_ids = [
item["evidence_id"]
for item in evidence_refs
if item.get("observation_key") == observation.observation_key
]
confidence = _confidence(observation.confidence_score, observation.risk_score)
return {
"clue_id": f"risk_clue:observation:{observation.observation_key}",
"source": "risk_observation",
"status": "human_review_required",
"observation_key": observation.observation_key,
"feedback_status": observation.feedback_status,
"claim_id": observation.claim_id,
"claim_no": observation.claim_no,
"subject_type": observation.subject_type,
"subject_key": observation.subject_key,
"risk_signal": observation.risk_signal,
"risk_level": observation.risk_level,
"title": observation.title or observation.risk_signal,
"summary": observation.description
or f"{observation.claim_no or observation.subject_label} 存在待复核线索。",
"confidence_score": confidence,
"evidence_refs": evidence_ids,
"rule_hits": [
f"rule_hit:observation:{observation.observation_key}"
]
if _is_rule_hit_observation(observation)
else [],
"fact_refs": [f"fact:claim:{observation.claim_id}"] if observation.claim_id else [],
"review_reason": _review_reason(observation),
"next_action": "人工复核事实、规则命中和证据来源。",
"not_final_conclusion": True,
}
def _claim_flag_clue(
self,
hit: dict[str, Any],
evidence_refs: list[dict[str, Any]],
) -> dict[str, Any]:
evidence_ids = [
item["evidence_id"]
for item in evidence_refs
if item.get("rule_hit_id") == hit.get("hit_id")
]
return {
"clue_id": f"risk_clue:{hit['hit_id']}",
"source": "claim_risk_flags",
"status": "human_review_required",
"observation_key": "",
"feedback_status": "unreviewed",
"claim_id": hit.get("claim_id"),
"claim_no": hit.get("claim_no"),
"subject_type": "expense_claim",
"subject_key": f"claim:{hit.get('claim_id')}",
"risk_signal": hit.get("risk_signal"),
"risk_level": hit.get("severity") or "medium",
"title": hit.get("title") or hit.get("risk_signal"),
"summary": hit.get("message") or "单据存在规则命中,需要人工复核事实与制度依据。",
"confidence_score": 0.72,
"evidence_refs": evidence_ids,
"rule_hits": [hit["hit_id"]],
"fact_refs": [f"fact:claim:{hit.get('claim_id')}"] if hit.get("claim_id") else [],
"review_reason": "规则命中尚未形成已确认处置结论。",
"next_action": "人工复核该规则命中是否需要补充风险观察或处置反馈。",
"not_final_conclusion": True,
}
def _feedback_summary(self, feedback_items: list[RiskObservationFeedback]) -> dict[str, Any]:
counts = Counter(item.feedback_type for item in feedback_items)
return {
"total": len(feedback_items),
"by_type": dict(counts),
"recent": [
{
"feedback_id": item.id,
"feedback_type": item.feedback_type,
"action": item.action,
"actor": item.actor,
"observation_key": item.observation.observation_key if item.observation else "",
"created_at": _isoformat(item.created_at),
}
for item in feedback_items[:10]
],
}
@staticmethod
def _dedupe_by_id(items: list[dict[str, Any]]) -> list[dict[str, Any]]:
deduped: dict[str, dict[str, Any]] = {}
for item in items:
key = _text(item.get("hit_id"))
if key and key not in deduped:
deduped[key] = item
return list(deduped.values())
def _is_rule_hit_observation(observation: RiskObservation) -> bool:
if _text(observation.source) == "rule_center":
return True
if _number((observation.contribution_scores_json or {}).get("S_rule")) > 0:
return True
for evidence in list(observation.evidence_json or []):
if isinstance(evidence, dict) and _text(evidence.get("source")) == "rule_center":
return True
return False
def _needs_human_review(observation: RiskObservation) -> bool:
status = _text(observation.status)
feedback_status = _text(observation.feedback_status)
if status in {"confirmed", "false_positive", "ignored", "resolved"}:
return False
if feedback_status in {"confirmed", "false_positive", "ignored", "resolved"}:
return False
return observation.risk_score >= 50 or observation.risk_level in {"medium", "high", "critical"}
def _review_reason(observation: RiskObservation) -> str:
if not observation.feedback_items:
return "尚未记录人工复核反馈。"
latest = observation.feedback_items[0]
return latest.comment or f"最近反馈类型:{latest.feedback_type},仍需人工复核。"
def _confidence(value: float | None, score: int) -> float:
try:
parsed = float(value or 0)
except (TypeError, ValueError):
parsed = 0
if parsed <= 0:
parsed = max(0.35, min(0.92, float(score or 0) / 100))
return round(parsed, 2)
def _decimal_to_float(value: Decimal | int | float | None) -> float:
if value is None:
return 0.0
return float(value)
def _number(value: object) -> float:
try:
return float(value or 0)
except (TypeError, ValueError):
return 0.0
def _isoformat(value: datetime | None) -> str:
return value.isoformat() if value is not None else ""
def _text(value: object) -> str:
return str(value or "").strip()

View File

@@ -13,6 +13,7 @@ from app.algorithem.risk_graph import (
from app.core.logging import get_logger
from app.models.financial_record import ExpenseClaim
from app.models.hermes_report import HermesRiskReport
from app.services.expense_claim_risk_stage import with_risk_business_stage
from app.services.risk_observations import RiskObservationService
logger = get_logger("app.services.hermes_risk_scanner")
@@ -110,15 +111,18 @@ class HermesRiskScannerService:
@staticmethod
def _append_algorithm_flag(claim: ExpenseClaim, observation: dict) -> list:
existing = list(claim.risk_flags_json or [])
flag = {
"source": "financial_risk_graph",
"risk_signal": observation.get("risk_signal"),
"severity": observation.get("risk_level"),
"risk_score": observation.get("risk_score"),
"confidence_score": observation.get("confidence_score"),
"algorithm_version": observation.get("algorithm_version"),
"observation_key": observation.get("observation_key"),
}
flag = with_risk_business_stage(
{
"source": "financial_risk_graph",
"risk_signal": observation.get("risk_signal"),
"severity": observation.get("risk_level"),
"risk_score": observation.get("risk_score"),
"confidence_score": observation.get("confidence_score"),
"algorithm_version": observation.get("algorithm_version"),
"observation_key": observation.get("observation_key"),
},
"reimbursement",
)
if any(
isinstance(item, dict)
and item.get("observation_key") == flag["observation_key"]

View File

@@ -10,6 +10,7 @@ from app.db.session import get_session_factory
from app.models.hermes_config import HermesTaskConfig, HermesTaskExecutionLog
from app.services.hermes_employee_profile_scanner import HermesEmployeeProfileScannerService
from app.services.hermes_expense_report import HermesExpenseReportService
from app.services.hermes_risk_clue_collector import HermesRiskClueCollectorService
from app.services.hermes_risk_scanner import HermesRiskScannerService
logger = get_logger("app.services.hermes_scheduler")
@@ -168,6 +169,14 @@ class HermesScheduler:
f"生成 {summary.get('snapshot_count', 0)} 条快照,"
f"重点关注 {summary.get('high_attention_employee_count', 0)} 人。"
)
elif config.task_type == "risk_clue_collect":
collector = HermesRiskClueCollectorService(db)
summary = collector.collect_risk_clues(run_id=log_record.id)
log_record.result_summary = (
f"风险线索归集完成:读取 {summary.get('fact_count', 0)} 条事实,"
f"整理 {summary.get('rule_hit_count', 0)} 条规则命中,"
f"输出 {summary.get('risk_clue_count', 0)} 条待复核线索。"
)
log_record.status = "success"
log_record.completed_at = datetime.now(UTC)

View File

@@ -25,9 +25,11 @@ from app.schemas.orchestrator import (
from app.schemas.user_agent import UserAgentRequest
from app.services.agent_assets import AgentAssetService
from app.services.agent_conversations import AgentConversationService
from app.services.auth import AuthService
from app.services.expense_claims import ExpenseClaimService
from app.services.agent_foundation import AgentFoundationService
from app.services.agent_runs import AgentRunService
from app.services.agent_traces import AgentTraceService
from app.services.knowledge import KnowledgeService
from app.services.ontology import SemanticOntologyService
from app.services.orchestrator_execution import ExecutionOutcome, OrchestratorExecutionEngine
@@ -57,6 +59,7 @@ class OrchestratorService:
self.expense_claim_service = ExpenseClaimService(db)
self.knowledge_service = KnowledgeService(db=db)
self.run_service = AgentRunService(db)
self.trace_service = AgentTraceService(db)
self.ontology_service = SemanticOntologyService(db)
self.user_agent_service = UserAgentService(db)
self.database_query_builder = OrchestratorDatabaseQueryBuilder(db)
@@ -67,11 +70,15 @@ class OrchestratorService:
knowledge_service=self.knowledge_service,
user_agent_service=self.user_agent_service,
database_query_builder=self.database_query_builder,
trace_service=self.trace_service,
)
def run(self, payload: OrchestratorRequest) -> OrchestratorResponse:
AgentFoundationService(self.db).ensure_foundation_ready()
context_json = dict(payload.context_json or {})
context_json = self._hydrate_user_context(
user_id=payload.user_id,
context_json=dict(payload.context_json or {}),
)
conversation_id = str(payload.conversation_id or "").strip() or None
conversation = None
if payload.source == AgentRunSource.USER_MESSAGE.value:
@@ -87,6 +94,9 @@ class OrchestratorService:
context_json=context_json,
message=payload.message,
)
context_json["conversation_id"] = conversation_id
elif conversation_id:
context_json["conversation_id"] = conversation_id
route_json: dict[str, Any] = {
"orchestrated_by": AgentName.ORCHESTRATOR.value,
@@ -105,6 +115,25 @@ class OrchestratorService:
status=AgentRunStatus.RUNNING.value,
result_summary="Orchestrator 已接收请求。",
)
self._record_trace_event(
run_id=run.run_id,
conversation_id=conversation_id,
stage="orchestrator",
event_name="request_received",
title="接收用户请求",
summary=str(payload.message or payload.task_id or payload.source or "").strip(),
input_json={
"source": payload.source,
"user_id": payload.user_id,
"conversation_id": conversation_id,
"task_id": payload.task_id,
"message": payload.message,
},
output_json={
"run_id": run.run_id,
"context_keys": sorted(context_json.keys()),
},
)
try:
message, task_asset = self._resolve_message(payload)
@@ -120,6 +149,19 @@ class OrchestratorService:
"ocr_summary": context_json.get("ocr_summary", ""),
},
)
self._record_trace_event(
run_id=run.run_id,
conversation_id=conversation.conversation_id,
stage="conversation",
event_name="conversation_hydrated",
title="会话上下文补全",
summary=f"会话 {conversation.conversation_id} 已写入用户消息。",
input_json={"message": message},
output_json={
"conversation_id": conversation.conversation_id,
"context_keys": sorted(context_json.keys()),
},
)
ontology = self.ontology_service.parse_for_run(
OntologyParseRequest(
query=message,
@@ -128,6 +170,16 @@ class OrchestratorService:
),
run_id=run.run_id,
)
self._record_trace_event(
run_id=run.run_id,
conversation_id=conversation_id,
stage="semantic",
event_name="semantic_parsed",
title="语义识别完成",
summary=f"{ontology.scenario} / {ontology.intent}",
input_json={"query": message, "context_keys": sorted(context_json.keys())},
output_json=self.execution_engine._build_ontology_json(ontology),
)
if context_json.get("simulate_orchestrator_exception"):
raise RuntimeError("simulated orchestrator exception")
selected_agent, route_reason = self._select_agent(payload, ontology)
@@ -144,6 +196,25 @@ class OrchestratorService:
and not is_expense_review_action
and not is_expense_application_context
)
self._record_trace_event(
run_id=run.run_id,
conversation_id=conversation_id,
stage="route",
event_name="route_resolved",
title="路由与能力选择",
summary=route_reason,
input_json={
"scenario": ontology.scenario,
"intent": ontology.intent,
"permission_level": ontology.permission.level,
},
output_json={
"selected_agent": selected_agent,
"route_reason": route_reason,
"selected_capability_codes": selected_capability_codes,
"requires_confirmation": requires_confirmation,
},
)
route_json = {
"orchestrated_by": AgentName.ORCHESTRATOR.value,
@@ -337,6 +408,32 @@ class OrchestratorService:
},
},
)
self._record_trace_event(
run_id=run.run_id,
conversation_id=conversation_id,
stage="conversation",
event_name="conversation_updated",
title="会话状态写回",
summary="助手回复与会话状态已写回。",
input_json={"draft_payload_present": isinstance(draft_payload, dict)},
output_json={"status": final_status, "message": result_message},
)
self._record_trace_event(
run_id=run.run_id,
conversation_id=conversation_id,
stage="response",
event_name="response_built",
title="生成最终回复",
status=final_status,
summary=result_message,
input_json={"outcome_status": outcome.status},
output_json={
"status": response_status,
"requires_confirmation": response_requires_confirmation,
"trace_summary": trace_summary.model_dump(),
"result": outcome.result,
},
)
return OrchestratorResponse(
run_id=run.run_id,
conversation_id=conversation_id,
@@ -350,6 +447,17 @@ class OrchestratorService:
)
except Exception as exc:
logger.exception("Orchestrator run failed run_id=%s", run.run_id)
self._record_trace_event(
run_id=run.run_id,
conversation_id=conversation_id,
stage="orchestrator",
event_name="failed",
title="Orchestrator 执行失败",
status="failed",
summary=str(exc),
output_json={"route_json": route_json},
error_message=str(exc),
)
self.run_service.update_run(
run.run_id,
agent=AgentName.ORCHESTRATOR.value,
@@ -394,6 +502,40 @@ class OrchestratorService:
),
)
def _record_trace_event(self, **kwargs: Any) -> None:
self.trace_service.record_event_safe(**kwargs)
def _hydrate_user_context(self, user_id: str | None, context_json: dict[str, Any]) -> dict[str, Any]:
identifier = str(user_id or context_json.get("username") or context_json.get("email") or "").strip()
if not identifier:
return context_json
snapshot = AuthService(self.db).get_user_snapshot(identifier)
if snapshot is None:
return context_json
values = {
"name": snapshot.name,
"department": snapshot.department,
"department_name": snapshot.departmentName or snapshot.department,
"position": snapshot.position,
"grade": snapshot.grade,
"employee_no": snapshot.employeeNo,
"manager_name": snapshot.managerName,
"employee_location": snapshot.location,
"cost_center": snapshot.costCenter,
"finance_owner_name": snapshot.financeOwnerName,
"employee_risk_profile": snapshot.riskProfile,
"role_codes": snapshot.roleCodes,
"is_admin": snapshot.isAdmin,
}
for key, value in values.items():
if context_json.get(key) in (None, "", [], {}):
context_json[key] = value
return context_json
def _resolve_message(
self,
payload: OrchestratorRequest,

View File

@@ -13,6 +13,7 @@ from app.schemas.ontology import OntologyParseResult
from app.schemas.orchestrator import OrchestratorRequest
from app.schemas.user_agent import UserAgentRequest, UserAgentResponse
from app.services.hermes_employee_profile_scanner import HermesEmployeeProfileScannerService
from app.services.hermes_risk_clue_collector import HermesRiskClueCollectorService
from app.services.hermes_risk_scanner import HermesRiskScannerService
from app.services.knowledge_sync import KnowledgeSyncDispatchService
@@ -36,6 +37,7 @@ class OrchestratorExecutionEngine:
knowledge_service,
user_agent_service,
database_query_builder,
trace_service=None,
) -> None:
self.db = db
self.run_service = run_service
@@ -43,6 +45,7 @@ class OrchestratorExecutionEngine:
self.knowledge_service = knowledge_service
self.user_agent_service = user_agent_service
self.database_query_builder = database_query_builder
self.trace_service = trace_service
def _execute_user_agent(
self,
@@ -383,6 +386,8 @@ class OrchestratorExecutionEngine:
task_asset=task_asset,
context_json=context_json,
)
if task_type == "risk_clue_collect":
return self._execute_risk_clue_collect(run_id=run_id, context_json=context_json)
return None
def _execute_risk_graph_scan(self, *, run_id: str, context_json: dict[str, Any]) -> ExecutionOutcome:
@@ -502,6 +507,41 @@ class OrchestratorExecutionEngine:
failed_tool_count=1 if degraded else 0,
)
def _execute_risk_clue_collect(
self,
*,
run_id: str,
context_json: dict[str, Any],
) -> ExecutionOutcome:
summary, degraded = self._invoke_tool(
run_id=run_id,
tool_type=AgentToolType.DATABASE.value,
tool_name="digital_employee.risk_clue.collect",
request_json={"task_type": "risk_clue_collect"},
context_json=context_json,
executor=lambda: HermesRiskClueCollectorService(self.db).collect_risk_clues(
run_id=run_id
),
fallback_factory=lambda exc: {
"message": f"风险线索归集失败,已保留失败记录:{exc}",
"degraded": True,
},
)
message = (
str(summary.get("message") or "").strip()
or "风险线索归集完成:"
f"读取 {summary.get('fact_count', 0)} 条事实,"
f"整理 {summary.get('rule_hit_count', 0)} 条规则命中,"
f"输出 {summary.get('risk_clue_count', 0)} 条待复核线索。"
)
return ExecutionOutcome(
status=AgentRunStatus.SUCCEEDED.value,
result={"message": message, "report_type": "risk_clue_collect", "summary": summary, "degraded": degraded},
degraded=degraded,
tool_count=1,
failed_tool_count=1 if degraded else 0,
)
@staticmethod
def _resolve_task_type(task_asset: AgentAssetRead | None) -> str:
if task_asset is None:
@@ -613,6 +653,11 @@ class OrchestratorExecutionEngine:
status="succeeded",
duration_ms=duration_ms,
)
if self.trace_service:
self.trace_service.record_tool_event_safe(
run_id, tool_type, tool_name, request_json, response,
"succeeded", duration_ms, context_json,
)
return response, False
except Exception as exc:
duration_ms = int((perf_counter() - started) * 1000)
@@ -627,6 +672,11 @@ class OrchestratorExecutionEngine:
duration_ms=duration_ms,
error_message=str(exc),
)
if self.trace_service:
self.trace_service.record_tool_event_safe(
run_id, tool_type, tool_name, request_json, response,
"failed", duration_ms, context_json, str(exc),
)
return response, True
@staticmethod

View File

@@ -15,6 +15,7 @@ from app.schemas.risk_observation import (
RiskObservationDashboardRead,
RiskObservationFeedbackCreate,
)
from app.services.expense_claim_risk_stage import normalize_risk_business_stage
HIGH_LEVELS = {"high", "critical"}
SEVERITY_SCORE = {
@@ -122,6 +123,7 @@ class RiskObservationService:
severity = _normalize_level(flag.get("severity"))
score = SEVERITY_SCORE.get(severity, SEVERITY_SCORE["medium"])
rule_code = _text(flag.get("rule_code"))
business_stage = normalize_risk_business_stage(flag.get("business_stage"))
observation_key = (
f"risk:{claim.id}:platform:{rule_code or signal}"
)
@@ -141,7 +143,7 @@ class RiskObservationService:
"risk_score": score,
"risk_level": severity,
"confidence_score": "0.78",
"control_stage": "reimbursement",
"control_stage": business_stage,
"control_mode": "risk_observation",
"automation_mode": (
"semi_auto_review"
@@ -333,6 +335,14 @@ class RiskObservationService:
confirmed = sum(1 for item in observations if item.feedback_status == "confirmed")
false_positive = sum(1 for item in observations if item.feedback_status == "false_positive")
pending = sum(1 for item in observations if item.status == "pending_review")
feedback_samples = int(
self.db.scalar(
select(func.count())
.select_from(RiskObservationFeedback)
.where(RiskObservationFeedback.created_at >= since)
)
or 0
)
high_or_above = sum(1 for item in observations if item.risk_level in HIGH_LEVELS)
score_sum = sum(int(item.risk_score or 0) for item in observations)
reviewed = confirmed + false_positive
@@ -343,9 +353,11 @@ class RiskObservationService:
window_days=window_days,
total_observations=total,
pending_count=pending,
risk_clue_count=pending,
high_or_above_count=high_or_above,
confirmed_count=confirmed,
false_positive_count=false_positive,
feedback_sample_count=feedback_samples,
total_amount=float(total_amount),
average_score=round(score_sum / total, 2) if total else 0.0,
level_distribution=_count_by(observations, "risk_level"),

View File

@@ -13,6 +13,7 @@ from app.schemas.agent_asset import AgentAssetRiskRuleGenerateRequest
from app.services.agent_asset_rule_library import AgentAssetRuleLibraryManager
from app.services.agent_asset_spreadsheet import RISK_RULES_LIBRARY
from app.services.audit import AuditLogService
from app.services.expense_claim_risk_stage import infer_risk_domain
from app.services.risk_rule_explainability import build_risk_rule_explainability_artifacts
from app.services.risk_rule_generation_ontology import (
BUSINESS_DOMAIN_LABELS,
@@ -423,6 +424,28 @@ class RiskRuleGenerationService:
risk_level_label = str(
risk_score_payload.get("level_label") or RISK_LEVEL_LABELS.get(risk_level, "风险")
)
semantic_risk_domain = infer_risk_domain(
{
"rule_code": rule_code,
"risk_category": risk_category,
"name": rule_title or draft.get("name"),
"description": self._clean_text(draft.get("description")) or natural_language,
}
)
semantic_visibility_scope = (
"budget_manager"
if semantic_risk_domain == "budget"
else "leader"
if business_stage == "expense_application"
else "submitter"
)
semantic_actionability = (
"budget_governance"
if semantic_risk_domain == "budget"
else "review_decision"
if business_stage == "expense_application"
else "fixable_by_submitter"
)
keywords = list(draft.get("keywords") or [])
field_by_key = {item.key: item for item in fields}
params: dict[str, Any] = {
@@ -432,6 +455,9 @@ class RiskRuleGenerationService:
"natural_language": natural_language,
"business_stage": business_stage,
"business_stage_label": business_stage_label,
"risk_domain": semantic_risk_domain,
"visibility_scope": semantic_visibility_scope,
"actionability": semantic_actionability,
}
semantic_type = str(draft.get("semantic_type") or "").strip()
if semantic_type:
@@ -508,6 +534,9 @@ class RiskRuleGenerationService:
"risk_score": risk_score_value,
"risk_level": risk_level,
"risk_level_label": risk_level_label,
"risk_domain": semantic_risk_domain,
"visibility_scope": semantic_visibility_scope,
"actionability": semantic_actionability,
"risk_score_model": risk_score_payload.get("model"),
"risk_score_detail": risk_score_payload,
"rule_title": rule_title,
@@ -519,7 +548,11 @@ class RiskRuleGenerationService:
"business_explanation": self._clean_text(draft.get("description")),
"condition_summary": condition_summary,
"rule_ir": draft.get("rule_ir") if isinstance(draft.get("rule_ir"), dict) else {},
"model_semantic_plan": draft.get("model_semantic_plan") if isinstance(draft.get("model_semantic_plan"), dict) else {},
"model_semantic_plan": (
draft.get("model_semantic_plan")
if isinstance(draft.get("model_semantic_plan"), dict)
else {}
),
"flow": draft.get("flow") if isinstance(draft.get("flow"), dict) else {},
},
}

View File

@@ -164,6 +164,11 @@ class RiskRuleGenerationJobService:
"generation_status": AgentAssetStatus.FAILED.value,
"generation_error": error_message[:1000],
"generation_failed_at": datetime.now(UTC).isoformat(),
"last_operation": {
"action": "generation_failed",
"actor": actor,
"at": datetime.now(UTC).isoformat(),
},
}
)
asset.status = AgentAssetStatus.FAILED.value

View File

@@ -29,6 +29,7 @@ RISK_LEVEL_LABELS: dict[str, str] = {
}
EXPENSE_RISK_CATEGORY_CODES: tuple[str, ...] = (
"all",
"travel",
"hotel",
"transport",
@@ -40,10 +41,22 @@ EXPENSE_RISK_CATEGORY_CODES: tuple[str, ...] = (
"welfare",
)
EXPENSE_RISK_CATEGORY_LABELS: dict[str, str] = {
code: EXPENSE_TYPE_LABEL_BY_CODE[code] for code in EXPENSE_RISK_CATEGORY_CODES
"all": "全部",
**{
code: EXPENSE_TYPE_LABEL_BY_CODE[code]
for code in EXPENSE_RISK_CATEGORY_CODES
if code != "all"
},
}
EXPENSE_RISK_CATEGORY_ALIASES = {
"*": "all",
"overall": "all",
"general": "all",
"全部": "all",
"通用": "all",
"entertainment": "meal",
"business_meal": "meal",
"purchase": "office",
}
EXPENSE_BUSINESS_STAGE_LABELS: dict[str, str] = {

View File

@@ -0,0 +1,495 @@
from __future__ import annotations
from copy import deepcopy
from typing import Any
from app.core.agent_enums import AgentAssetDomain
from app.services.risk_rule_generation_interpreter import COMPOSITE_RULE_TEMPLATE_KEY
from app.services.risk_rule_generation_ontology import (
EXPENSE_BUSINESS_STAGE_LABELS,
EXPENSE_RISK_CATEGORY_LABELS,
FIELD_ONTOLOGY,
)
TEMPLATE_GROUPS: tuple[dict[str, str | int], ...] = (
{"group": "budget", "group_label": "预算", "order": 10},
{"group": "invoice", "group_label": "票据", "order": 20},
{"group": "travel", "group_label": "差旅", "order": 30},
{"group": "entertainment", "group_label": "招待", "order": 40},
{"group": "procurement_ap", "group_label": "采购/AP", "order": 50},
{"group": "corporate_card", "group_label": "企业卡", "order": 60},
{"group": "general", "group_label": "通用", "order": 70},
)
_GROUP_LABELS = {str(item["group"]): str(item["group_label"]) for item in TEMPLATE_GROUPS}
_FIELD_BY_KEY = {field.key: field for field in FIELD_ONTOLOGY}
def list_risk_rule_template_groups() -> list[dict[str, Any]]:
templates = [_build_template(item) for item in _TEMPLATE_DEFINITIONS]
groups: list[dict[str, Any]] = []
for group in TEMPLATE_GROUPS:
group_code = str(group["group"])
group_templates = [item for item in templates if item["group"] == group_code]
groups.append(
{
"group": group_code,
"group_label": str(group["group_label"]),
"order": int(group["order"]),
"templates": group_templates,
}
)
return deepcopy(groups)
def list_risk_rule_templates() -> list[dict[str, Any]]:
return [
template
for group in list_risk_rule_template_groups()
for template in group["templates"]
]
def _build_template(definition: dict[str, Any]) -> dict[str, Any]:
field_keys = list(definition["field_keys"])
business_stage = str(definition.get("business_stage") or "reimbursement")
expense_category = definition.get("expense_category")
group = str(definition["group"])
return {
"template_id": definition["template_id"],
"group": group,
"group_label": _GROUP_LABELS[group],
"title": definition["title"],
"description": definition["description"],
"business_domain": str(definition.get("business_domain") or AgentAssetDomain.EXPENSE.value),
"business_stage": business_stage,
"business_stage_label": EXPENSE_BUSINESS_STAGE_LABELS.get(business_stage, "费用报销"),
"expense_category": expense_category,
"expense_category_label": EXPENSE_RISK_CATEGORY_LABELS.get(str(expense_category or ""), ""),
"requires_attachment": bool(definition.get("requires_attachment")),
"natural_language": definition["natural_language"],
"fields": _field_rows(field_keys),
"dsl_example": _manifest(
field_keys=field_keys,
conditions=definition["conditions"],
hit_logic=definition["hit_logic"],
summary=definition["summary"],
message=definition["message"],
semantic_type=definition["semantic_type"],
),
}
def _field_rows(field_keys: list[str]) -> list[dict[str, str]]:
rows: list[dict[str, str]] = []
for key in field_keys:
field = _FIELD_BY_KEY.get(key)
if field is None:
continue
rows.append(
{
"key": field.key,
"label": field.label,
"display": f"{field.label}[{field.key}]",
"source": field.source,
"type": field.field_type,
}
)
return rows
def _manifest(
*,
field_keys: list[str],
conditions: list[dict[str, Any]],
hit_logic: dict[str, Any],
summary: str,
message: str,
semantic_type: str,
) -> dict[str, Any]:
return {
"template_key": COMPOSITE_RULE_TEMPLATE_KEY,
"params": {
"template_key": COMPOSITE_RULE_TEMPLATE_KEY,
"semantic_type": semantic_type,
"field_keys": field_keys,
"conditions": conditions,
"hit_logic": hit_logic,
"condition_summary": summary,
"message_template": message,
"keywords": [],
},
}
_TEMPLATE_DEFINITIONS: tuple[dict[str, Any], ...] = (
{
"template_id": "budget_available_balance",
"group": "budget",
"title": "费用申请预算余额校验",
"description": "申请金额超过预算可用余额时提示预算占用风险,适合费用申请阶段前置控制。",
"business_stage": "expense_application",
"expense_category": "all",
"requires_attachment": False,
"natural_language": (
"费用申请时,先读取申请金额、预算可用余额、部门、费用类型和申请事由。"
"若申请金额超过当前可用预算余额,且申请事由中没有预算追加、专项审批或紧急事项说明,"
"则标记为中风险,要求补充预算审批说明后再继续流转。"
),
"field_keys": [
"claim.amount",
"budget.remaining_amount",
"claim.department_name",
"item.item_type",
"claim.reason",
],
"conditions": [
{
"id": "amount_exceeds_available_budget",
"operator": "numeric_compare",
"left_fields": ["claim.amount"],
"right_fields": ["budget.remaining_amount"],
"compare": "gt",
},
{
"id": "missing_budget_exception",
"operator": "not_contains_any",
"fields": ["claim.reason"],
"keywords": ["预算追加", "专项审批", "紧急事项", "预算调整"],
},
],
"hit_logic": {"all": ["amount_exceeds_available_budget", "missing_budget_exception"]},
"summary": "申请金额大于预算可用余额,且缺少预算追加或专项审批说明时命中。",
"message": "申请金额超过预算可用余额,需补充预算审批说明。",
"semantic_type": "budget_available_balance_check",
},
{
"template_id": "duplicate_invoice_number",
"group": "invoice",
"title": "重复发票号码校验",
"description": "识别同一发票号码在本次提交中重复出现的报销风险。",
"business_stage": "reimbursement",
"expense_category": "office",
"requires_attachment": True,
"natural_language": (
"费用报销时,先确认已上传发票或票据附件,再读取附件识别出的发票号码、明细附件编号和报销事由。"
"若同一发票号码或同一明细附件编号在本次提交中重复出现,且报销事由没有说明拆单、补票或更正提交原因,"
"则标记为高风险,要求删除重复票据或补充说明。"
),
"field_keys": ["attachment.invoice_no", "item.invoice_id", "claim.reason"],
"conditions": [
{
"id": "invoice_evidence_present",
"operator": "exists_any",
"fields": ["attachment.invoice_no", "item.invoice_id"],
},
{
"id": "same_invoice_no_repeated",
"operator": "duplicate_value",
"fields": ["attachment.invoice_no", "item.invoice_id"],
},
{
"id": "missing_duplicate_reason",
"operator": "not_contains_any",
"fields": ["claim.reason"],
"keywords": ["拆单", "补票", "更正", "冲红"],
},
],
"hit_logic": {
"all": ["invoice_evidence_present", "same_invoice_no_repeated", "missing_duplicate_reason"]
},
"summary": "发票号码或明细附件编号重复,且缺少拆单、补票或更正说明时命中。",
"message": "存在重复发票或重复附件编号,需删除重复票据或补充合理说明。",
"semantic_type": "duplicate_invoice_check",
},
{
"template_id": "travel_city_route_consistency",
"group": "travel",
"title": "差旅票据城市一致性校验",
"description": "比对交通票、住宿票据城市与申报目的地,识别跨城、绕行或目的地不一致风险。",
"business_stage": "reimbursement",
"expense_category": "travel",
"requires_attachment": True,
"natural_language": (
"差旅报销时,先检查是否已上传交通票据、住宿票据或其他能识别城市的附件;"
"再读取员工常驻地、申报目的地、明细发生地点、交通票行程城市、住宿发票城市和报销事由。"
"若交通票或住宿票据中的城市无法与申报目的地、明细地点形成一致关系,"
"或票据路线中出现申报目的地与员工常驻地之外的额外中转城市,"
"且报销事由中没有说明绕行、跨城办事或临时改签原因,"
"则标记为高风险,要求补充行程说明或退回修改。"
),
"field_keys": [
"employee.location",
"claim.location",
"item.item_location",
"attachment.route_cities",
"attachment.hotel_city",
"claim.reason",
],
"conditions": [
{
"id": "attachment_city_evidence_present",
"operator": "exists_any",
"fields": ["attachment.route_cities", "attachment.hotel_city"],
},
{
"id": "city_outside_business_scope",
"operator": "not_in_scope",
"left_fields": ["attachment.route_cities", "attachment.hotel_city"],
"right_fields": ["claim.location", "item.item_location", "employee.location"],
},
{
"id": "missing_route_exception",
"operator": "not_contains_any",
"fields": ["claim.reason"],
"keywords": ["绕行", "跨城办事", "临时改签", "临时任务"],
},
],
"hit_logic": {
"all": [
"attachment_city_evidence_present",
"city_outside_business_scope",
"missing_route_exception",
]
},
"summary": "票据城市集合与申报行程集合无交集或出现额外中转城市,且缺少合理例外说明时命中。",
"message": "票据城市与申报行程不一致,需补充绕行、跨城或改签说明。",
"semantic_type": "travel_route_city_consistency",
},
{
"template_id": "travel_lodging_date_range",
"group": "travel",
"title": "差旅住宿日期范围校验",
"description": "校验住宿票据日期是否落在差旅开始和结束日期范围内。",
"business_stage": "reimbursement",
"expense_category": "travel",
"requires_attachment": True,
"natural_language": (
"差旅住宿报销时,先确认已上传住宿发票或酒店水单;"
"再读取报销事由、申报目的地、住宿城市、住宿开始日期、住宿结束日期、出差开始日期和出差结束日期。"
"若住宿发生时间早于出差开始或晚于出差结束,且没有延期、改签、临时任务等说明,"
"则标记为高风险,要求补充住宿原因、行程证明或重新提交票据。"
),
"field_keys": [
"attachment.stay_start_date",
"attachment.stay_end_date",
"claim.trip_start_date",
"claim.trip_end_date",
"attachment.hotel_city",
"claim.location",
"claim.reason",
],
"conditions": [
{
"id": "lodging_date_evidence_present",
"operator": "exists_any",
"fields": ["attachment.stay_start_date", "attachment.stay_end_date"],
},
{
"id": "lodging_date_outside_trip_range",
"operator": "date_outside_range",
"date_fields": ["attachment.stay_start_date", "attachment.stay_end_date"],
"range_start_fields": ["claim.trip_start_date"],
"range_end_fields": ["claim.trip_end_date"],
},
{
"id": "missing_lodging_exception",
"operator": "not_contains_any",
"fields": ["claim.reason"],
"keywords": ["延期", "改签", "临时任务", "行程变更"],
},
],
"hit_logic": {
"all": [
"lodging_date_evidence_present",
"lodging_date_outside_trip_range",
"missing_lodging_exception",
]
},
"summary": "住宿日期不在差旅日期范围内,且缺少延期、改签或临时任务说明时命中。",
"message": "住宿日期超出差旅行程范围,需补充行程证明或重新提交票据。",
"semantic_type": "lodging_date_range_consistency",
},
{
"template_id": "entertainment_per_capita_limit",
"group": "entertainment",
"title": "招待人均金额超标校验",
"description": "按参与人数计算人均招待金额,超过标准且缺少说明时提示风险。",
"business_stage": "reimbursement",
"expense_category": "meal",
"requires_attachment": True,
"natural_language": (
"业务招待报销时,读取申报总金额、参与人数、人均金额、报销事由和附件票据。"
"若人均金额超过公司招待标准 500 元,且事由中没有高级审批、重要客户接待或专项审批说明,"
"则标记为中风险,要求补充招待对象和审批依据。"
),
"field_keys": [
"claim.amount",
"claim.attendee_count",
"claim.per_capita_amount",
"claim.reason",
"attachment.invoice_no",
],
"conditions": [
{
"id": "per_capita_amount_exceeds_limit",
"operator": "numeric_compare",
"left_fields": ["claim.per_capita_amount"],
"threshold": 500,
"compare": "gt",
},
{
"id": "missing_special_approval_reason",
"operator": "not_contains_any",
"fields": ["claim.reason"],
"keywords": ["高级审批", "重要客户", "专项审批", "特殊接待"],
},
],
"hit_logic": {
"all": ["per_capita_amount_exceeds_limit", "missing_special_approval_reason"]
},
"summary": "人均金额大于招待标准阈值,且缺少合理审批说明时命中。",
"message": "业务招待人均金额超过公司标准,需补充审批依据。",
"semantic_type": "entertainment_per_capita_limit_check",
},
{
"template_id": "procurement_goods_category_mismatch",
"group": "procurement_ap",
"title": "采购票据品名与费用类型一致性校验",
"description": "检查发票商品服务名称是否与费用类型、采购用途一致。",
"business_stage": "reimbursement",
"expense_category": "office",
"requires_attachment": True,
"natural_language": (
"采购类费用报销时,先确认已上传发票或采购票据;"
"再读取商品服务名称、费用类型、明细事由、申报金额和报销事由。"
"若发票商品服务名称与费用类型或明细事由无法形成一致关系,"
"且报销事由没有说明代采、项目采购或费用归集原因,"
"则标记为中风险,要求补充采购用途或更换正确费用类型。"
),
"field_keys": [
"attachment.goods_name",
"item.item_type",
"item.item_reason",
"claim.amount",
"claim.reason",
],
"conditions": [
{
"id": "goods_evidence_present",
"operator": "exists_any",
"fields": ["attachment.goods_name"],
},
{
"id": "goods_outside_expense_scope",
"operator": "not_in_scope",
"left_fields": ["attachment.goods_name"],
"right_fields": ["item.item_type", "item.item_reason"],
},
{
"id": "missing_procurement_exception",
"operator": "not_contains_any",
"fields": ["claim.reason"],
"keywords": ["代采", "项目采购", "费用归集", "统一采购"],
},
],
"hit_logic": {
"all": [
"goods_evidence_present",
"goods_outside_expense_scope",
"missing_procurement_exception",
]
},
"summary": "发票品名与费用类型或明细事由不一致,且缺少采购归集说明时命中。",
"message": "采购票据品名与费用类型不一致,需补充采购用途说明。",
"semantic_type": "procurement_goods_category_consistency",
},
{
"template_id": "corporate_card_date_consistency",
"group": "corporate_card",
"title": "企业卡交易日期一致性校验",
"description": "用于企业卡消费日期与费用明细日期不一致的仿真规则。",
"business_stage": "reimbursement",
"expense_category": "travel",
"requires_attachment": True,
"natural_language": (
"企业卡费用报销时,读取企业卡交易日期、费用明细发生日期、开票日期、申报金额和报销事由。"
"若企业卡交易日期或开票日期明显不在明细发生日期对应的业务期间内,"
"且报销事由没有说明补录、跨月结算或集中开票原因,"
"则标记为低风险,提示补充交易说明。"
),
"field_keys": [
"attachment.issue_date",
"item.item_date",
"claim.amount",
"claim.reason",
],
"conditions": [
{
"id": "card_date_evidence_present",
"operator": "exists_any",
"fields": ["attachment.issue_date", "item.item_date"],
},
{
"id": "issue_date_outside_item_date",
"operator": "date_outside_range",
"date_fields": ["attachment.issue_date"],
"range_start_fields": ["item.item_date"],
"range_end_fields": ["item.item_date"],
"tolerance_days": 7,
},
{
"id": "missing_card_exception",
"operator": "not_contains_any",
"fields": ["claim.reason"],
"keywords": ["补录", "跨月结算", "集中开票", "统一结算"],
},
],
"hit_logic": {
"all": ["card_date_evidence_present", "issue_date_outside_item_date", "missing_card_exception"]
},
"summary": "开票日期偏离明细发生日期超过容忍范围,且缺少企业卡结算说明时命中。",
"message": "企业卡交易或开票日期与业务发生日期不一致,需补充说明。",
"semantic_type": "corporate_card_date_consistency",
},
{
"template_id": "general_missing_business_reason",
"group": "general",
"title": "报销事由完整性校验",
"description": "识别事由过于笼统、缺少业务对象或用途说明的低风险提示。",
"business_stage": "reimbursement",
"expense_category": "all",
"requires_attachment": False,
"natural_language": (
"费用报销时,读取报销事由、费用类型、申报金额、部门和明细事由。"
"若报销事由没有包含项目、客户、会议、用途或审批等业务背景信息,"
"且申报金额高于 300 元,则标记为低风险,提示补充业务背景说明。"
),
"field_keys": [
"claim.reason",
"item.item_reason",
"item.item_type",
"claim.amount",
"claim.department_name",
],
"conditions": [
{
"id": "amount_needs_reason_context",
"operator": "numeric_compare",
"left_fields": ["claim.amount"],
"threshold": 300,
"compare": "gt",
},
{
"id": "missing_business_context",
"operator": "not_contains_any",
"fields": ["claim.reason", "item.item_reason"],
"keywords": ["项目", "客户", "会议", "用途", "审批"],
},
],
"hit_logic": {"all": ["amount_needs_reason_context", "missing_business_context"]},
"summary": "申报金额超过低额阈值,且事由缺少业务背景关键词时命中。",
"message": "报销事由缺少业务背景,请补充用途或审批说明。",
"semantic_type": "general_business_reason_completeness",
},
)

View File

@@ -1,6 +1,7 @@
from __future__ import annotations
import re
from calendar import monthrange
from datetime import date, datetime, timedelta
from typing import Any
@@ -506,8 +507,8 @@ class RiskRuleTemplateExecutor:
for key in field_keys:
for value in self._resolve_values(key, claim=claim, contexts=contexts):
parsed = self._parse_date_value(value)
if parsed and parsed not in values:
values.append(parsed)
if parsed and parsed not in values:
values.append(parsed)
return values
def _resolve_group_numbers(
@@ -695,6 +696,9 @@ class RiskRuleTemplateExecutor:
@staticmethod
def _claim_trip_date(claim: ExpenseClaim, *, start: bool) -> date | datetime | None:
application_date = RiskRuleTemplateExecutor._claim_application_trip_date(claim, start=start)
if application_date is not None:
return application_date
item_dates = [
item.item_date
for item in list(claim.items or [])
@@ -704,6 +708,166 @@ class RiskRuleTemplateExecutor:
return min(item_dates) if start else max(item_dates)
return getattr(claim, "occurred_at", None)
@staticmethod
def _claim_application_trip_date(claim: ExpenseClaim, *, start: bool) -> date | None:
windows: list[tuple[date, date]] = []
reference_year = RiskRuleTemplateExecutor._claim_reference_year(claim)
for raw_value in RiskRuleTemplateExecutor._iter_application_time_values(claim):
windows.extend(
RiskRuleTemplateExecutor._parse_date_windows(
raw_value,
reference_year=reference_year,
)
)
if not windows:
return None
values = [window[0] if start else window[1] for window in windows]
return min(values) if start else max(values)
@staticmethod
def _claim_reference_year(claim: ExpenseClaim) -> int | None:
for value in [getattr(claim, "occurred_at", None)]:
parsed = RiskRuleTemplateExecutor._parse_date_value(value)
if parsed is not None:
return parsed.year
for item in list(claim.items or []):
parsed = RiskRuleTemplateExecutor._parse_date_value(getattr(item, "item_date", None))
if parsed is not None:
return parsed.year
return None
@staticmethod
def _iter_application_time_values(claim: ExpenseClaim) -> list[Any]:
values: list[Any] = []
application_sources = {"application_detail", "application_handoff", "application_link"}
time_keys = (
"application_time",
"applicationTime",
"application_date",
"applicationDate",
"business_time",
"businessTime",
"time_range",
"timeRange",
"time",
"date",
)
nested_keys = (
"application_detail",
"applicationDetail",
"review_form_values",
"reviewFormValues",
"expense_scene_selection",
"expenseSceneSelection",
)
for flag in list(getattr(claim, "risk_flags_json", None) or []):
if not isinstance(flag, dict):
continue
source = str(flag.get("source") or "").strip()
has_application_anchor = (
source in application_sources
or any(key in flag for key in ("application_claim_no", "applicationClaimNo"))
or any(isinstance(flag.get(key), dict) for key in ("application_detail", "applicationDetail"))
)
if not has_application_anchor:
continue
sources: list[dict[str, Any]] = [flag]
for key in nested_keys:
nested = flag.get(key)
if isinstance(nested, dict):
sources.append(nested)
for source_dict in sources:
for key in time_keys:
value = source_dict.get(key)
if value not in (None, ""):
values.append(value)
return values
@staticmethod
def _parse_date_windows(
value: Any,
*,
reference_year: int | None = None,
) -> list[tuple[date, date]]:
if isinstance(value, datetime):
item = value.date()
return [(item, item)]
if isinstance(value, date):
return [(value, value)]
text = str(value or "").strip()
if not text:
return []
exact_dates = RiskRuleTemplateExecutor._parse_exact_dates(
text,
reference_year=reference_year,
)
if exact_dates:
return [(min(exact_dates), max(exact_dates))]
month_windows = RiskRuleTemplateExecutor._parse_month_windows(
text,
reference_year=reference_year,
)
if month_windows:
return month_windows
return []
@staticmethod
def _parse_exact_dates(text: str, *, reference_year: int | None = None) -> list[date]:
values: list[date] = []
def append_date(year: int, month: int, day: int) -> None:
try:
parsed = date(year, month, day)
except ValueError:
return
if parsed not in values:
values.append(parsed)
for pattern in (
r"(\d{4})[-/.](\d{1,2})[-/.](\d{1,2})",
r"(\d{4})年(\d{1,2})月(\d{1,2})日?",
):
for match in re.finditer(pattern, text):
year, month, day = (int(part) for part in match.groups())
append_date(year, month, day)
if reference_year is not None:
for match in re.finditer(r"(?<!\d)(\d{1,2})月(\d{1,2})日?", text):
month, day = (int(part) for part in match.groups())
append_date(reference_year, month, day)
return values
@staticmethod
def _parse_month_windows(
text: str,
*,
reference_year: int | None = None,
) -> list[tuple[date, date]]:
windows: list[tuple[date, date]] = []
def append_month(year: int, month: int) -> None:
if month < 1 or month > 12:
return
last_day = monthrange(year, month)[1]
window = (date(year, month, 1), date(year, month, last_day))
if window not in windows:
windows.append(window)
for match in re.finditer(r"(\d{4})[-/.](\d{1,2})(?![-/.]\d)", text):
year, month = (int(part) for part in match.groups())
append_month(year, month)
for match in re.finditer(r"(\d{4})年(\d{1,2})月(?!\d)", text):
year, month = (int(part) for part in match.groups())
append_month(year, month)
if reference_year is not None:
for match in re.finditer(r"(?<!\d)(\d{1,2})月(?!\d|日)", text):
append_month(reference_year, int(match.group(1)))
return windows
@staticmethod
def _condition_passes(operator: str, left_values: list[str], right_values: list[str]) -> bool:
if operator == "is_empty":

View File

@@ -15,12 +15,14 @@ from app.schemas.user_agent import (
UserAgentSuggestedAction,
)
from app.services.expense_claim_access_policy import ExpenseClaimAccessPolicy
from app.services.expense_claim_risk_stage import with_risk_business_stage
from app.services.document_numbering import (
build_document_number,
generate_unique_expense_claim_no,
)
from app.services.user_agent_application_dates import expand_application_time_with_days
from app.services.user_agent_application_locations import normalize_application_location
from app.services.application_system_estimate import apply_application_system_estimate_to_facts
APPLICATION_CONTEXT_VALUES = {
"application",
@@ -152,7 +154,7 @@ class UserAgentApplicationMixin:
"我已按「费用申请 / 事前审批」来处理这条内容。",
"已识别信息:\n" + recognized_table,
f"当前还需要补充:{missing_text}",
"请一次性补齐上述字段,我会继续生成模拟申请结果并让你确认是否提交。",
"请一次性补齐上述字段,我会继续生成申请核对结果并让你确认是否提交。",
]
)
@@ -170,7 +172,7 @@ class UserAgentApplicationMixin:
return "\n\n".join(
[
"这是模拟的费用申请结果,请核对:",
"这是费用申请核对结果,请核对:",
self._build_application_summary_table(facts),
"请核对上述信息无误,确认无误后 [确认](#application-submit) 提交至审批流程。",
]
@@ -185,7 +187,11 @@ class UserAgentApplicationMixin:
"transport_mode": "",
"amount": "",
"application_type": "",
"applicant": "",
"grade": "",
"department": "",
"position": "",
"manager_name": "",
"lodging_daily_cap": "",
"subsidy_daily_cap": "",
"transport_policy": "",
@@ -193,6 +199,12 @@ class UserAgentApplicationMixin:
"matched_city": "",
"rule_name": "",
"rule_version": "",
"hotel_amount": "",
"allowance_amount": "",
"transport_estimated_amount": "",
"transport_estimate_source": "",
"transport_estimate_confidence": "",
"policy_total_amount": "",
}
for message, is_current in self._iter_application_user_messages(payload):
partial = {
@@ -212,6 +224,41 @@ class UserAgentApplicationMixin:
if value:
facts[key] = value
context_json = payload.context_json or {}
current_user = getattr(payload, "current_user", None)
if not facts["applicant"]:
facts["applicant"] = str(
context_json.get("name")
or context_json.get("user_name")
or context_json.get("applicant")
or getattr(current_user, "name", "")
or ""
).strip()
if not facts["department"]:
facts["department"] = str(
context_json.get("department")
or context_json.get("department_name")
or context_json.get("departmentName")
or getattr(current_user, "department_name", "")
or ""
).strip()
if not facts["position"]:
facts["position"] = str(
context_json.get("position")
or context_json.get("employee_position")
or context_json.get("employeePosition")
or ""
).strip()
if not facts["manager_name"]:
facts["manager_name"] = str(
context_json.get("manager_name")
or context_json.get("managerName")
or context_json.get("direct_manager_name")
or context_json.get("directManagerName")
or getattr(current_user, "manager_name", "")
or ""
).strip()
if not facts["application_type"]:
facts["application_type"] = self._infer_application_type(facts)
facts["time"] = self._expand_application_time_with_days(
@@ -219,6 +266,7 @@ class UserAgentApplicationMixin:
facts.get("days", ""),
payload.context_json or {},
)
apply_application_system_estimate_to_facts(facts)
return facts
@staticmethod
@@ -245,7 +293,11 @@ class UserAgentApplicationMixin:
"days": pick("days"),
"transport_mode": pick("transportMode", "transport_mode"),
"amount": pick("amount"),
"applicant": pick("applicant", "name", "userName", "user_name"),
"grade": pick("grade"),
"department": pick("department", "departmentName", "department_name"),
"position": pick("position", "employeePosition", "employee_position"),
"manager_name": pick("managerName", "manager_name", "directManagerName", "direct_manager_name"),
"lodging_daily_cap": pick("lodgingDailyCap", "lodging_daily_cap"),
"subsidy_daily_cap": pick("subsidyDailyCap", "subsidy_daily_cap"),
"transport_policy": pick("transportPolicy", "transport_policy"),
@@ -253,6 +305,12 @@ class UserAgentApplicationMixin:
"matched_city": pick("matchedCity", "matched_city"),
"rule_name": pick("ruleName", "rule_name"),
"rule_version": pick("ruleVersion", "rule_version"),
"hotel_amount": pick("hotelAmount", "hotel_amount"),
"allowance_amount": pick("allowanceAmount", "allowance_amount"),
"transport_estimated_amount": pick("transportEstimatedAmount", "transport_estimated_amount"),
"transport_estimate_source": pick("transportEstimateSource", "transport_estimate_source"),
"transport_estimate_confidence": pick("transportEstimateConfidence", "transport_estimate_confidence"),
"policy_total_amount": pick("policyTotalAmount", "policy_total_amount"),
}
def _resolve_expense_application_step(
@@ -294,7 +352,7 @@ class UserAgentApplicationMixin:
def _resolve_application_missing_followup_fields(facts: dict[str, str]) -> list[str]:
return [
field
for field in ("transport_mode", "amount")
for field in ("transport_mode",)
if not str(facts.get(field) or "").strip()
]
@@ -558,7 +616,7 @@ class UserAgentApplicationMixin:
def _display_application_slot_label(slot: str) -> str:
return {
"expense_type": "申请类型",
"amount": "用户预估费用",
"amount": "系统预估费用",
"time_range": "发生时间",
"time": "发生时间",
"location": "地点",
@@ -603,7 +661,7 @@ class UserAgentApplicationMixin:
"reason": ("补充申请事由", "事由:"),
"days": ("补充天数", "天数:"),
"transport_mode": ("补充出行方式", "出行方式:"),
"amount": ("补充预估费用", "用户预估费用:"),
"amount": ("补充系统预估费用", "系统预估费用:"),
}
return config.get(field, ("补充申请信息", ""))
@@ -646,17 +704,21 @@ class UserAgentApplicationMixin:
f"{label}{value or '待补充'}"
for label, value in (
("申请类型", facts.get("application_type", "")),
("姓名", facts.get("applicant", "")),
("部门", facts.get("department", "")),
("岗位", facts.get("position", "")),
("职级", facts.get("grade", "")),
("直属领导", facts.get("manager_name", "")),
("发生时间", facts.get("time", "")),
("地点", facts.get("location", "")),
("事由", facts.get("reason", "")),
("天数", facts.get("days", "")),
("出行方式", facts.get("transport_mode", "")),
("职级", facts.get("grade", "")),
("住宿上限/天", facts.get("lodging_daily_cap", "")),
("补贴标准/天", facts.get("subsidy_daily_cap", "")),
("交通费用口径", facts.get("transport_policy", "")),
("规则测算参考", facts.get("policy_estimate", "")),
("用户预估费用", facts.get("amount", "")),
("系统预估费用", facts.get("amount", "")),
)
)
@@ -668,17 +730,21 @@ class UserAgentApplicationMixin:
) -> str:
rows = [
("申请类型", facts.get("application_type", "")),
("姓名", facts.get("applicant", "")),
("部门", facts.get("department", "")),
("岗位", facts.get("position", "")),
("职级", facts.get("grade", "")),
("直属领导", facts.get("manager_name", "")),
("发生时间", facts.get("time", "")),
("地点", facts.get("location", "")),
("事由", facts.get("reason", "")),
("天数", facts.get("days", "")),
("出行方式", facts.get("transport_mode", "")),
("职级", facts.get("grade", "")),
("住宿上限/天", facts.get("lodging_daily_cap", "")),
("补贴标准/天", facts.get("subsidy_daily_cap", "")),
("交通费用口径", facts.get("transport_policy", "")),
("规则测算参考", facts.get("policy_estimate", "")),
("用户预估费用", facts.get("amount", "")),
("系统预估费用", facts.get("amount", "")),
]
visible_rows = rows if include_empty else [(label, value) for label, value in rows if str(value or "").strip()]
if not visible_rows:
@@ -736,34 +802,53 @@ class UserAgentApplicationMixin:
risk_flags_json=[self._build_application_detail_flag(facts)],
)
self.db.add(claim)
self.db.flush()
from app.services.expense_claims import ExpenseClaimService
platform_review = ExpenseClaimService(self.db).evaluate_platform_risk_rules(
claim,
business_stage="expense_application",
)
platform_flags = list(platform_review.get("flags") or [])
if platform_flags:
claim.risk_flags_json = [*list(claim.risk_flags_json or []), *platform_flags]
self.db.commit()
self.db.refresh(claim)
return claim
@staticmethod
def _build_application_detail_flag(facts: dict[str, str]) -> dict[str, object]:
return {
"source": "application_detail",
"severity": "info",
"label": "申请详情",
"application_detail": {
"application_type": str(facts.get("application_type") or "").strip(),
"time": str(facts.get("time") or "").strip(),
"location": str(facts.get("location") or "").strip(),
"reason": str(facts.get("reason") or "").strip(),
"days": str(facts.get("days") or "").strip(),
"transport_mode": str(facts.get("transport_mode") or "").strip(),
"amount": str(facts.get("amount") or "").strip(),
"grade": str(facts.get("grade") or "").strip(),
"lodging_daily_cap": str(facts.get("lodging_daily_cap") or "").strip(),
"subsidy_daily_cap": str(facts.get("subsidy_daily_cap") or "").strip(),
"transport_policy": str(facts.get("transport_policy") or "").strip(),
"policy_estimate": str(facts.get("policy_estimate") or "").strip(),
"matched_city": str(facts.get("matched_city") or "").strip(),
"rule_name": str(facts.get("rule_name") or "").strip(),
"rule_version": str(facts.get("rule_version") or "").strip(),
return with_risk_business_stage(
{
"source": "application_detail",
"severity": "info",
"label": "申请详情",
"application_detail": {
"application_type": str(facts.get("application_type") or "").strip(),
"time": str(facts.get("time") or "").strip(),
"location": str(facts.get("location") or "").strip(),
"reason": str(facts.get("reason") or "").strip(),
"days": str(facts.get("days") or "").strip(),
"transport_mode": str(facts.get("transport_mode") or "").strip(),
"amount": str(facts.get("amount") or "").strip(),
"grade": str(facts.get("grade") or "").strip(),
"lodging_daily_cap": str(facts.get("lodging_daily_cap") or "").strip(),
"subsidy_daily_cap": str(facts.get("subsidy_daily_cap") or "").strip(),
"transport_policy": str(facts.get("transport_policy") or "").strip(),
"policy_estimate": str(facts.get("policy_estimate") or "").strip(),
"matched_city": str(facts.get("matched_city") or "").strip(),
"rule_name": str(facts.get("rule_name") or "").strip(),
"rule_version": str(facts.get("rule_version") or "").strip(),
"hotel_amount": str(facts.get("hotel_amount") or "").strip(),
"allowance_amount": str(facts.get("allowance_amount") or "").strip(),
"transport_estimated_amount": str(facts.get("transport_estimated_amount") or "").strip(),
"transport_estimate_source": str(facts.get("transport_estimate_source") or "").strip(),
"transport_estimate_confidence": str(facts.get("transport_estimate_confidence") or "").strip(),
"policy_total_amount": str(facts.get("policy_total_amount") or "").strip(),
},
},
}
"expense_application",
)
def _resolve_application_manager_name(
self,
@@ -810,6 +895,13 @@ class UserAgentApplicationMixin:
or context_json.get("departmentName")
or ""
).strip(),
manager_name=str(
context_json.get("manager_name")
or context_json.get("managerName")
or context_json.get("direct_manager_name")
or context_json.get("directManagerName")
or ""
).strip(),
)
@staticmethod

View File

@@ -558,9 +558,16 @@ class UserAgentResponseMixin:
payload.ontology,
query_text=payload.message,
)
review = ExpenseClaimService(self.db).evaluate_platform_risk_rules(
claim_service = ExpenseClaimService(self.db)
business_stage = (
"expense_application"
if claim_service._is_expense_application_claim(claim)
else "reimbursement"
)
review = claim_service.evaluate_platform_risk_rules(
claim,
rule_codes=rule_codes,
business_stage=business_stage,
)
messages: list[str] = []
for flag in review.get("flags") or []:

View File

@@ -0,0 +1,33 @@
---
name: budget-overrun-precontrol-evaluator
description: 用于评估预算占用、费用标准和柔性控制边界,输出超标预警。
---
# 预算占用与超标预警
## 功能说明
结合预算快照、费用标准、部门基线和员工画像,识别预算占用异常、标准超限和柔性控制触发场景。
## 适用场景
- 报销提交前预警。
- 审批时解释预算占用风险。
- 月度预算风险巡检。
## 输入
- 报销单。
- 预算快照。
- 制度条款。
- 部门和费用类型基线。
## 输出
- 预算占用预警。
- 超标原因。
- 建议补充材料或审批动作。
## 边界
该技能输出预警建议,不直接冻结预算。

View File

@@ -0,0 +1,33 @@
---
name: department-expense-baseline-accumulator
description: 用于沉淀部门、费用类型和时间窗口下的费用基线,为预算柔性控制和同类对比提供参照。
---
# 部门费用基线沉淀
## 功能说明
按部门、费用类型、时间窗口和员工职级沉淀费用基线,输出可用于风险图谱、预算预警和画像分析的基线快照。
## 适用场景
- 周期性更新部门费用水平。
- 审批时对比同部门同类费用。
- 预算柔性控制需要历史参照。
## 输入
- 报销单。
- 费用明细。
- 员工部门和职级。
- 历史画像基线。
## 输出
- 部门费用总额、均值、中位数、P75、P90。
- 费用类型分布。
- 样本数量和降级原因。
## 边界
样本不足时只输出低置信基线,不触发强风险结论。

View File

@@ -0,0 +1,33 @@
---
name: expense-policy-alignment
description: 用于对齐报销政策、规则中心和知识库口径,发现同义、冲突、缺失和过期条款。
---
# 报销政策口径对齐
## 功能说明
对比不同来源的报销政策口径,识别同义条款、冲突条款、缺失字段和过期制度,输出可复核的政策对齐报告。
## 适用场景
- 制度变更后检查规则中心是否同步。
- 知识库存在多个版本政策时做口径合并。
- 审批争议中追溯当前有效口径。
## 输入
- 财务制度条款。
- 风险规则模板。
- 知识库政策片段。
- 历史风险观察引用。
## 输出
- 口径一致项。
- 冲突项和缺失项。
- 建议更新的规则或知识条目。
## 边界
该技能输出建议,不直接覆盖制度或规则。

View File

@@ -0,0 +1,33 @@
---
name: false-positive-sample-accumulator
description: 用于沉淀历史误报、忽略和撤销的风险观察样本,支撑算法回放与规则调优。
---
# 历史误报样本沉淀
## 功能说明
归集被人工标记为误报、忽略、撤销或无需处理的风险观察,形成可回放的误报样本池。
## 适用场景
- 算法升级前评估误报率。
- 规则阈值调优。
- 识别高误报风险信号。
## 输入
- 风险观察。
- 人工反馈。
- 决策追踪。
- 本体和规则版本。
## 输出
- 误报样本清单。
- 高误报规则和风险信号排行。
- 建议降低权重或调整阈值的规则。
## 边界
该技能不自动修改规则权重。

View File

@@ -0,0 +1,32 @@
---
name: finance-policy-clause-extractor
description: 用于从公司财务制度中抽取可结构化引用的制度条款、适用范围、金额标准和审批要求。
---
# 制度条款结构化抽取
## 功能说明
从财务制度、报销政策和知识库文档中抽取结构化条款,形成可被风险观察、规则模板和单据详情引用的制度证据。
## 适用场景
- 新制度入库后自动抽取条款。
- 制度内容更新后刷新条款版本。
- 风险规则需要引用制度依据时补充 `policy_refs`
## 输入
- 财务制度文档。
- 知识库条目。
- 本体解析结果。
## 输出
- 条款编号、标题、适用范围、限制条件、金额标准。
- 审批要求、必备凭证、例外口径。
- 可进入风险观察的 `risk_policy_refs`
## 边界
该技能只整理条款,不直接上线风险规则。

View File

@@ -39,7 +39,7 @@ description: 用于财务风险图谱巡检,把单据、票据、审批链、
- `risk_observations`:风险观察列表,包含风险类型、等级、置信度和证据。
- `graph_evidence`:局部图谱节点、边、来源和本体映射。
- `decision_trace`:规则命中、画像偏离、图谱评分和降级路径。
- `next_actions`:需要人工复核、补充制度或转候选规则的建议。
- `next_actions`:需要人工复核、补充制度或沉淀复核样本的建议。
## 执行约束

View File

@@ -0,0 +1,32 @@
---
name: multi-evidence-consistency-evaluator
description: 用于评估报销单、明细、发票、流水、合同和事前申请之间的多凭证一致性。
---
# 单据多凭证一致性评估
## 功能说明
比对报销单、费用明细、发票、流水、合同、附件和事前申请之间的金额、数量、主体、时间和地点字段。
## 适用场景
- 报销提交后自动校验。
- 审批前补充证据链。
- 风险图谱巡检时生成多凭证风险信号。
## 输入
- 报销单和费用明细。
- 发票、附件、流水和合同。
- 事前申请和审批记录。
## 输出
- 一致性检查结果。
- 不一致字段和证据来源。
- 风险观察候选。
## 边界
证据不足时只输出不确定性原因,不生成强拦截结论。

View File

@@ -0,0 +1,35 @@
---
name: policy-gap-rule-optimizer
description: 兼容别名。用于提示申请和报销事实缺少制度引用的位置,不输出规则变更建议。
---
# 制度引用缺口提示
## 功能说明
整理申请、报销、规则命中和人工反馈中缺少制度引用或证据来源的事实位置,提示管理员补齐制度依据。
## 适用场景
- 月度风控复盘。
- 制度修订前准备材料。
- 规则命中结果缺少制度引用时补充人工复核材料。
## 输入
- 制度条款引用。
- 申请和报销事实。
- 已确认规则命中结果。
- 风险观察和人工反馈样本。
## 输出
- `facts`:缺少制度引用的事实位置。
- `rule_hits`:相关规则命中结果。
- `evidence_refs`:已有制度、单据、附件和审批来源。
- `policy_reference_gaps`:待人工确认的制度引用缺口。
- `human_review_required`:必须为 `true`
## 边界
该技能不总结风险规则,不输出规则变更建议,不直接修改制度文本或发布规则。制度修订和规则调整必须由管理员完成。

View File

@@ -0,0 +1,35 @@
---
name: policy-reference-gap-hinter
description: 用于提示申请和报销事实缺少制度引用的位置,不输出规则变更建议。
---
# 制度引用缺口提示
## 功能说明
整理申请、报销、规则命中和人工反馈中缺少制度引用或证据来源的事实位置,提示管理员补齐制度依据。
## 适用场景
- 月度风控复盘。
- 制度修订前准备材料。
- 规则命中结果缺少制度引用时补充人工复核材料。
## 输入
- 制度条款引用。
- 申请和报销事实。
- 已确认规则命中结果。
- 风险观察和人工反馈样本。
## 输出
- `facts`:缺少制度引用的事实位置。
- `rule_hits`:相关规则命中结果。
- `evidence_refs`:已有制度、单据、附件和审批来源。
- `policy_reference_gaps`:待人工确认的制度引用缺口。
- `human_review_required`:必须为 `true`
## 边界
该技能不总结风险规则,不输出规则变更建议,不直接修改制度文本或发布规则。制度修订和规则调整必须由管理员完成。

View File

@@ -0,0 +1,33 @@
---
name: risk-algorithm-replay-evaluator
description: 用于复跑历史风险观察和反馈样本,评估算法、规则和本体版本升级效果。
---
# 风险算法回放评测
## 功能说明
基于历史风险观察、人工反馈、本体版本、规则版本和算法版本构建回放评测,比较升级前后的确认率、误报率和风险覆盖。
## 适用场景
- 风险算法升级前验收。
- 规则模板调整后复测。
- 误报率异常时定位原因。
## 输入
- 算法回放集。
- 风险观察。
- 人工反馈标签。
- 规则、本体和算法版本。
## 输出
- 回放评测报告。
- 确认率、误报率和覆盖变化。
- 风险信号级别的升级建议。
## 边界
该技能只做评测,不直接上线新算法。

Some files were not shown because too many files have changed in this diff Show More