feat: 增强风险规则生成引擎与预算中心页面
后端拆分风险规则生成为解释器、语义分析、本体对齐等子模块, 优化模板执行和流程图生成,完善员工种子数据和导入逻辑,增强 报销单权限策略和草稿持久化,前端新增预算中心视图和趋势图 组件,重构审计页面和风险规则测试对话框交互,完善文档中心 和报销创建页面细节,补充单元测试覆盖。
This commit is contained in:
@@ -2,7 +2,6 @@ from __future__ import annotations
|
||||
|
||||
import json
|
||||
import re
|
||||
from dataclasses import dataclass
|
||||
from datetime import UTC, datetime
|
||||
from typing import Any
|
||||
|
||||
@@ -14,135 +13,34 @@ 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_type_keywords import EXPENSE_TYPE_LABEL_BY_CODE
|
||||
from app.services.risk_rule_flow_diagram import (
|
||||
RiskRuleFlowDiagramField,
|
||||
RiskRuleFlowDiagramRenderer,
|
||||
RiskRuleFlowDiagramSpec,
|
||||
build_risk_rule_flow_diagram_details,
|
||||
)
|
||||
from app.services.risk_rule_generation_ontology import (
|
||||
BUSINESS_DOMAIN_LABELS,
|
||||
DOMAIN_FIELD_PREFIXES,
|
||||
EXPENSE_RISK_CATEGORY_ALIASES,
|
||||
EXPENSE_RISK_CATEGORY_LABELS,
|
||||
FIELD_ONTOLOGY,
|
||||
RISK_LEVEL_LABELS,
|
||||
RiskRuleField,
|
||||
)
|
||||
from app.services.risk_rule_generation_prompt import build_risk_rule_compiler_messages
|
||||
from app.services.risk_rule_generation_interpreter import COMPOSITE_RULE_TEMPLATE_KEY
|
||||
from app.services.risk_rule_generation_markdown import build_risk_rule_version_markdown
|
||||
from app.services.risk_rule_generation_semantics import (
|
||||
CITY_CONSISTENCY_SEMANTIC_TYPE,
|
||||
CITY_CONSISTENCY_SEMANTIC_TYPES,
|
||||
build_city_consistency_draft,
|
||||
build_city_consistency_params,
|
||||
)
|
||||
from app.services.risk_rule_scoring import apply_risk_score_to_draft, calculate_risk_rule_score
|
||||
from app.services.runtime_chat import RuntimeChatService
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class RiskRuleField:
|
||||
key: str
|
||||
label: str
|
||||
field_type: str
|
||||
source: str
|
||||
aliases: tuple[str, ...]
|
||||
|
||||
|
||||
BUSINESS_DOMAIN_LABELS: dict[str, str] = {
|
||||
AgentAssetDomain.EXPENSE.value: "报销",
|
||||
AgentAssetDomain.AR.value: "应收",
|
||||
AgentAssetDomain.AP.value: "应付",
|
||||
}
|
||||
|
||||
RISK_LEVEL_LABELS: dict[str, str] = {
|
||||
"low": "低风险",
|
||||
"medium": "中风险",
|
||||
"high": "高风险",
|
||||
}
|
||||
|
||||
EXPENSE_RISK_CATEGORY_CODES: tuple[str, ...] = (
|
||||
"travel",
|
||||
"hotel",
|
||||
"transport",
|
||||
"meal",
|
||||
"meeting",
|
||||
"office",
|
||||
"training",
|
||||
"communication",
|
||||
"welfare",
|
||||
)
|
||||
EXPENSE_RISK_CATEGORY_LABELS: dict[str, str] = {
|
||||
code: EXPENSE_TYPE_LABEL_BY_CODE[code] for code in EXPENSE_RISK_CATEGORY_CODES
|
||||
}
|
||||
EXPENSE_RISK_CATEGORY_ALIASES = {
|
||||
"entertainment": "meal",
|
||||
}
|
||||
|
||||
FIELD_ONTOLOGY: tuple[RiskRuleField, ...] = (
|
||||
RiskRuleField("claim.reason", "报销事由", "text", "claim", ("事由", "说明", "理由", "用途")),
|
||||
RiskRuleField(
|
||||
"claim.location",
|
||||
"申报地点",
|
||||
"text",
|
||||
"claim",
|
||||
("地点", "城市", "出差地", "申报地点", "申报目的地", "目的地"),
|
||||
),
|
||||
RiskRuleField("claim.amount", "申报金额", "number", "claim", ("金额", "费用", "超额", "额度")),
|
||||
RiskRuleField("claim.employee_name", "报销人", "text", "claim", ("报销人", "员工", "申请人")),
|
||||
RiskRuleField("claim.department_name", "部门", "text", "claim", ("部门", "组织")),
|
||||
RiskRuleField("item.item_type", "费用类型", "enum", "item", ("费用类型", "科目", "类型")),
|
||||
RiskRuleField("item.item_reason", "明细事由", "text", "item", ("明细事由", "明细说明")),
|
||||
RiskRuleField("item.item_location", "明细地点", "text", "item", ("明细地点", "发生地点")),
|
||||
RiskRuleField(
|
||||
"attachment.invoice_no", "发票号码", "text", "attachment", ("发票号", "发票号码", "票号")
|
||||
),
|
||||
RiskRuleField(
|
||||
"attachment.buyer_name", "购买方名称", "text", "attachment", ("抬头", "购买方", "开票单位")
|
||||
),
|
||||
RiskRuleField(
|
||||
"attachment.goods_name",
|
||||
"商品服务名称",
|
||||
"text",
|
||||
"attachment",
|
||||
("品名", "商品", "服务名称", "摘要"),
|
||||
),
|
||||
RiskRuleField(
|
||||
"attachment.issue_date",
|
||||
"开票日期",
|
||||
"date",
|
||||
"attachment",
|
||||
("开票日期", "发票日期", "票据日期"),
|
||||
),
|
||||
RiskRuleField(
|
||||
"attachment.hotel_city",
|
||||
"住宿城市",
|
||||
"text",
|
||||
"attachment",
|
||||
("住宿城市", "酒店城市", "酒店地点", "酒店发票城市", "酒店票城市", "住宿发票城市"),
|
||||
),
|
||||
RiskRuleField(
|
||||
"attachment.route_cities",
|
||||
"行程城市",
|
||||
"list",
|
||||
"attachment",
|
||||
("行程", "路线", "途经城市", "出差城市", "交通票行程", "交通票城市"),
|
||||
),
|
||||
RiskRuleField(
|
||||
"attachment.ocr_text",
|
||||
"票据全文",
|
||||
"text",
|
||||
"attachment",
|
||||
("票据内容", "OCR", "全文", "关键字", "关键词"),
|
||||
),
|
||||
RiskRuleField(
|
||||
"receivable.aging_days", "应收账龄", "number", "receivable", ("账龄", "逾期", "应收逾期")
|
||||
),
|
||||
RiskRuleField(
|
||||
"receivable.amount_outstanding",
|
||||
"应收未收金额",
|
||||
"number",
|
||||
"receivable",
|
||||
("未收金额", "欠款", "应收余额"),
|
||||
),
|
||||
RiskRuleField(
|
||||
"payable.vendor_name", "供应商名称", "text", "payable", ("供应商", "付款方", "往来单位")
|
||||
),
|
||||
RiskRuleField(
|
||||
"payable.amount_outstanding", "应付未付金额", "number", "payable", ("未付金额", "应付余额")
|
||||
),
|
||||
)
|
||||
|
||||
DOMAIN_FIELD_PREFIXES: dict[str, tuple[str, ...]] = {
|
||||
AgentAssetDomain.EXPENSE.value: ("claim.", "item.", "attachment."),
|
||||
AgentAssetDomain.AR.value: ("receivable.",),
|
||||
AgentAssetDomain.AP.value: ("payable.",),
|
||||
}
|
||||
|
||||
|
||||
class RiskRuleGenerationService:
|
||||
def __init__(
|
||||
self,
|
||||
@@ -172,9 +70,10 @@ class RiskRuleGenerationService:
|
||||
if len(natural_language) < 8:
|
||||
raise ValueError("请至少输入 8 个字的风险规则描述。")
|
||||
|
||||
risk_level = str(body.risk_level or "medium").strip().lower()
|
||||
if risk_level not in RISK_LEVEL_LABELS:
|
||||
raise ValueError("风险等级仅支持 low、medium、high。")
|
||||
rule_title = self._clean_text(body.rule_title)
|
||||
if rule_title and len(rule_title) < 2:
|
||||
raise ValueError("规则标题至少需要 2 个字。")
|
||||
|
||||
requires_attachment = bool(body.requires_attachment)
|
||||
expense_category = self._normalize_expense_category(body.expense_category, domain)
|
||||
expense_category_label = EXPENSE_RISK_CATEGORY_LABELS.get(expense_category or "", "")
|
||||
@@ -186,20 +85,30 @@ class RiskRuleGenerationService:
|
||||
domain=domain,
|
||||
expense_category=expense_category,
|
||||
expense_category_label=expense_category_label,
|
||||
risk_level=risk_level,
|
||||
fields=fields,
|
||||
) or self._build_fallback_draft(
|
||||
natural_language=natural_language,
|
||||
domain=domain,
|
||||
expense_category_label=expense_category_label,
|
||||
risk_level=risk_level,
|
||||
risk_level="medium",
|
||||
fields=fields,
|
||||
)
|
||||
draft = self._align_draft_fields(
|
||||
draft,
|
||||
natural_language=natural_language,
|
||||
risk_level="medium",
|
||||
fields=fields,
|
||||
)
|
||||
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._build_rule_payload(
|
||||
draft,
|
||||
natural_language=natural_language,
|
||||
@@ -211,6 +120,8 @@ class RiskRuleGenerationService:
|
||||
created_at=created_at,
|
||||
actor=actor,
|
||||
requires_attachment=requires_attachment,
|
||||
rule_title=rule_title,
|
||||
risk_score=risk_score,
|
||||
)
|
||||
rule_code = str(payload["rule_code"])
|
||||
file_name = f"{rule_code}.json"
|
||||
@@ -236,6 +147,10 @@ class RiskRuleGenerationService:
|
||||
working_version="v0.1.0",
|
||||
config_json={
|
||||
"severity": risk_level,
|
||||
"risk_score": risk_score["score"],
|
||||
"risk_level": risk_level,
|
||||
"risk_level_label": risk_score["level_label"],
|
||||
"risk_score_detail": risk_score,
|
||||
"enabled": True,
|
||||
"requires_attachment": requires_attachment,
|
||||
"tag": "风险规则",
|
||||
@@ -260,7 +175,7 @@ class RiskRuleGenerationService:
|
||||
AgentAssetVersion(
|
||||
asset_id=asset.id,
|
||||
version="v0.1.0",
|
||||
content=self._build_version_markdown(payload),
|
||||
content=build_risk_rule_version_markdown(payload),
|
||||
content_type="markdown",
|
||||
change_note="通过自然语言新建风险规则草稿。",
|
||||
created_by=actor,
|
||||
@@ -275,6 +190,7 @@ class RiskRuleGenerationService:
|
||||
after_json={
|
||||
"rule_code": rule_code,
|
||||
"risk_level": risk_level,
|
||||
"risk_score": risk_score["score"],
|
||||
"domain": domain,
|
||||
"expense_category": expense_category,
|
||||
"requires_attachment": requires_attachment,
|
||||
@@ -291,7 +207,6 @@ class RiskRuleGenerationService:
|
||||
domain: str,
|
||||
expense_category: str | None,
|
||||
expense_category_label: str,
|
||||
risk_level: str,
|
||||
fields: list[RiskRuleField],
|
||||
) -> dict[str, Any] | None:
|
||||
field_payload = [
|
||||
@@ -303,50 +218,17 @@ class RiskRuleGenerationService:
|
||||
}
|
||||
for item in fields
|
||||
]
|
||||
messages = [
|
||||
{
|
||||
"role": "system",
|
||||
"content": (
|
||||
"你是 X-Financial 风险规则编译器。只能输出 JSON 对象,不要解释。"
|
||||
"必须从给定字段本体中选择字段,不允许编造字段。"
|
||||
"template_key 只能是 field_required_v1、field_compare_v1、keyword_match_v1。"
|
||||
),
|
||||
},
|
||||
{
|
||||
"role": "user",
|
||||
"content": json.dumps(
|
||||
{
|
||||
"business_domain": domain,
|
||||
"business_domain_label": BUSINESS_DOMAIN_LABELS[domain],
|
||||
"expense_category": expense_category,
|
||||
"expense_category_label": expense_category_label,
|
||||
"risk_level": risk_level,
|
||||
"risk_level_label": RISK_LEVEL_LABELS[risk_level],
|
||||
"natural_language": natural_language,
|
||||
"available_fields": field_payload,
|
||||
"required_json_shape": {
|
||||
"name": "规则名称",
|
||||
"description": "面向业务用户的说明",
|
||||
"template_key": "field_required_v1",
|
||||
"field_keys": ["claim.reason"],
|
||||
"condition_summary": "判断依据",
|
||||
"keywords": [],
|
||||
"flow": {
|
||||
"start": "提交业务单据",
|
||||
"evidence": "读取字段",
|
||||
"decision": "判断依据",
|
||||
"pass": "继续流转",
|
||||
"fail": "提示风险",
|
||||
},
|
||||
},
|
||||
},
|
||||
ensure_ascii=False,
|
||||
),
|
||||
},
|
||||
]
|
||||
messages = build_risk_rule_compiler_messages(
|
||||
domain=domain,
|
||||
domain_label=BUSINESS_DOMAIN_LABELS[domain],
|
||||
expense_category=expense_category,
|
||||
expense_category_label=expense_category_label,
|
||||
natural_language=natural_language,
|
||||
available_fields=field_payload,
|
||||
)
|
||||
answer = self.runtime_chat_service.complete(
|
||||
messages,
|
||||
max_tokens=700,
|
||||
max_tokens=1400,
|
||||
temperature=0.1,
|
||||
timeout_seconds=12,
|
||||
max_attempts=1,
|
||||
@@ -370,7 +252,12 @@ class RiskRuleGenerationService:
|
||||
) -> dict[str, Any]:
|
||||
allowed_fields = {item.key for item in fields}
|
||||
template_key = str(payload.get("template_key") or "").strip()
|
||||
if template_key not in {"field_required_v1", "field_compare_v1", "keyword_match_v1"}:
|
||||
if template_key not in {
|
||||
"field_required_v1",
|
||||
"field_compare_v1",
|
||||
"keyword_match_v1",
|
||||
COMPOSITE_RULE_TEMPLATE_KEY,
|
||||
}:
|
||||
template_key = "field_required_v1"
|
||||
|
||||
raw_field_keys = payload.get("field_keys")
|
||||
@@ -389,14 +276,37 @@ class RiskRuleGenerationService:
|
||||
)
|
||||
if str(item or "").strip()
|
||||
]
|
||||
exception_keywords = [
|
||||
str(item or "").strip()
|
||||
for item in (
|
||||
payload.get("exception_keywords")
|
||||
if isinstance(payload.get("exception_keywords"), list)
|
||||
else []
|
||||
)
|
||||
if str(item or "").strip()
|
||||
]
|
||||
unsupported_fields = [
|
||||
str(item or "").strip()
|
||||
for item in (
|
||||
payload.get("unsupported_fields")
|
||||
if isinstance(payload.get("unsupported_fields"), list)
|
||||
else []
|
||||
)
|
||||
if str(item or "").strip()
|
||||
]
|
||||
flow = payload.get("flow") if isinstance(payload.get("flow"), dict) else {}
|
||||
return {
|
||||
rule_ir = payload.get("rule_ir") if isinstance(payload.get("rule_ir"), dict) else {}
|
||||
draft = {
|
||||
"name": self._clean_text(payload.get("name"))[:80],
|
||||
"description": self._clean_text(payload.get("description")),
|
||||
"template_key": template_key,
|
||||
"semantic_type": self._clean_text(payload.get("semantic_type")),
|
||||
"field_keys": field_keys,
|
||||
"condition_summary": self._clean_text(payload.get("condition_summary")),
|
||||
"keywords": keywords[:12],
|
||||
"exception_keywords": exception_keywords[:12],
|
||||
"unsupported_fields": unsupported_fields[:20],
|
||||
"rule_ir": rule_ir,
|
||||
"flow": {
|
||||
"start": self._clean_text(flow.get("start")) or "提交业务单据",
|
||||
"evidence": self._clean_text(flow.get("evidence")) or "读取规则字段",
|
||||
@@ -405,6 +315,18 @@ class RiskRuleGenerationService:
|
||||
"fail": self._clean_text(flow.get("fail")) or "提示风险并进入复核",
|
||||
},
|
||||
}
|
||||
for key in ("conditions", "hit_logic", "field_groups"):
|
||||
value = payload.get(key)
|
||||
if isinstance(value, (list, dict)):
|
||||
draft[key] = value
|
||||
scoring_evidence = payload.get("risk_scoring_evidence")
|
||||
if isinstance(scoring_evidence, dict):
|
||||
draft["risk_scoring_evidence"] = scoring_evidence
|
||||
for key in ("formula", "message_template"):
|
||||
value = self._clean_text(payload.get(key))
|
||||
if value:
|
||||
draft[key] = value
|
||||
return draft
|
||||
|
||||
def _build_fallback_draft(
|
||||
self,
|
||||
@@ -457,6 +379,8 @@ class RiskRuleGenerationService:
|
||||
created_at: datetime,
|
||||
actor: str,
|
||||
requires_attachment: bool,
|
||||
rule_title: str = "",
|
||||
risk_score: dict[str, Any] | None = None,
|
||||
) -> dict[str, Any]:
|
||||
created_stamp = created_at.strftime("%Y%m%d%H%M%S%f")
|
||||
domain_slug = {"expense": "expense", "ar": "ar", "ap": "ap"}[domain]
|
||||
@@ -472,6 +396,11 @@ class RiskRuleGenerationService:
|
||||
self._clean_text(draft.get("condition_summary")) or "判断是否符合自然语言规则描述"
|
||||
)
|
||||
risk_category = expense_category_label or BUSINESS_DOMAIN_LABELS[domain]
|
||||
risk_score_payload = dict(risk_score or {})
|
||||
risk_score_value = int(risk_score_payload.get("score") or 0)
|
||||
risk_level_label = str(
|
||||
risk_score_payload.get("level_label") or RISK_LEVEL_LABELS.get(risk_level, "风险")
|
||||
)
|
||||
keywords = list(draft.get("keywords") or [])
|
||||
field_by_key = {item.key: item for item in fields}
|
||||
params: dict[str, Any] = {
|
||||
@@ -480,9 +409,23 @@ class RiskRuleGenerationService:
|
||||
"condition_summary": condition_summary,
|
||||
"natural_language": natural_language,
|
||||
}
|
||||
semantic_type = str(draft.get("semantic_type") or "").strip()
|
||||
if semantic_type:
|
||||
params["semantic_type"] = semantic_type
|
||||
if template_key == COMPOSITE_RULE_TEMPLATE_KEY and isinstance(draft.get("rule_ir"), dict):
|
||||
params["rule_ir"] = draft["rule_ir"]
|
||||
for key in ("conditions", "hit_logic", "field_groups", "formula", "message_template"):
|
||||
if key in draft:
|
||||
params[key] = draft[key]
|
||||
for key in ("keywords", "exception_keywords", "unsupported_fields"):
|
||||
values = draft.get(key)
|
||||
if isinstance(values, list):
|
||||
params[key] = values
|
||||
if draft.get("semantic_type") == CITY_CONSISTENCY_SEMANTIC_TYPE:
|
||||
params.update(build_city_consistency_params(draft))
|
||||
if template_key == "field_required_v1":
|
||||
params["required_fields"] = field_keys
|
||||
if template_key == "field_compare_v1":
|
||||
if template_key == "field_compare_v1" and "conditions" not in params:
|
||||
params["conditions"] = self._build_compare_conditions(field_keys)
|
||||
if template_key == "keyword_match_v1":
|
||||
params["keywords"] = keywords
|
||||
@@ -494,7 +437,9 @@ class RiskRuleGenerationService:
|
||||
payload = {
|
||||
"schema_version": "2.0",
|
||||
"rule_code": rule_code,
|
||||
"name": self._clean_text(draft.get("name")) or self._infer_rule_name(natural_language),
|
||||
"name": rule_title
|
||||
or self._clean_text(draft.get("name"))
|
||||
or self._infer_rule_name(natural_language),
|
||||
"description": self._clean_text(draft.get("description")) or natural_language,
|
||||
"enabled": True,
|
||||
"requires_attachment": requires_attachment,
|
||||
@@ -503,6 +448,7 @@ class RiskRuleGenerationService:
|
||||
"ontology_signal": "natural_language_risk",
|
||||
"evaluator": "template_rule",
|
||||
"template_key": template_key,
|
||||
"semantic_type": str(draft.get("semantic_type") or "").strip() or None,
|
||||
"applies_to": applies_to,
|
||||
"inputs": {
|
||||
"fields": [
|
||||
@@ -521,6 +467,7 @@ class RiskRuleGenerationService:
|
||||
"fail": {
|
||||
"severity": risk_level,
|
||||
"action": "manual_review",
|
||||
"risk_score": risk_score_value,
|
||||
},
|
||||
},
|
||||
"metadata": {
|
||||
@@ -530,11 +477,18 @@ class RiskRuleGenerationService:
|
||||
"created_at": created_at.isoformat(),
|
||||
"created_by": actor,
|
||||
"requires_attachment": requires_attachment,
|
||||
"risk_score": risk_score_value,
|
||||
"risk_level": risk_level,
|
||||
"risk_level_label": risk_level_label,
|
||||
"risk_score_model": risk_score_payload.get("model"),
|
||||
"risk_score_detail": risk_score_payload,
|
||||
"rule_title": rule_title,
|
||||
"expense_category": expense_category,
|
||||
"expense_category_label": expense_category_label,
|
||||
"natural_language": natural_language,
|
||||
"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 {},
|
||||
"flow": draft.get("flow") if isinstance(draft.get("flow"), dict) else {},
|
||||
},
|
||||
}
|
||||
@@ -559,15 +513,17 @@ class RiskRuleGenerationService:
|
||||
metadata = payload.get("metadata") if isinstance(payload.get("metadata"), dict) else {}
|
||||
flow = metadata.get("flow") if isinstance(metadata.get("flow"), dict) else {}
|
||||
condition_summary = self._clean_text(metadata.get("condition_summary"))
|
||||
diagram_fields = [
|
||||
RiskRuleFlowDiagramField(key=field.key, label=field.label) for field in fields
|
||||
]
|
||||
details = build_risk_rule_flow_diagram_details(payload, diagram_fields)
|
||||
return self.flow_diagram_renderer.render(
|
||||
RiskRuleFlowDiagramSpec(
|
||||
title=self._clean_text(payload.get("name")) or "风险规则判断流程",
|
||||
domain_label=domain_label or BUSINESS_DOMAIN_LABELS.get(domain, "业务"),
|
||||
severity=risk_level,
|
||||
severity_label=RISK_LEVEL_LABELS.get(risk_level, "中风险"),
|
||||
fields=tuple(
|
||||
RiskRuleFlowDiagramField(key=field.key, label=field.label) for field in fields
|
||||
),
|
||||
fields=tuple(diagram_fields),
|
||||
start=self._clean_text(flow.get("start")) or "业务单据提交",
|
||||
evidence=self._clean_text(flow.get("evidence")) or "读取规则字段",
|
||||
decision=self._clean_text(flow.get("decision"))
|
||||
@@ -581,6 +537,9 @@ class RiskRuleGenerationService:
|
||||
pass_text=self._clean_text(flow.get("pass")) or "未命中风险,继续流转",
|
||||
fail_text=self._clean_text(flow.get("fail"))
|
||||
or f"命中{RISK_LEVEL_LABELS.get(risk_level, '风险')},进入人工复核",
|
||||
fact_lines=details["fact_lines"],
|
||||
condition_lines=details["condition_lines"],
|
||||
hit_logic=str(details["hit_logic"] or ""),
|
||||
)
|
||||
)
|
||||
|
||||
@@ -615,7 +574,19 @@ class RiskRuleGenerationService:
|
||||
(10, field)
|
||||
for field in candidates
|
||||
if field.key
|
||||
in {"claim.location", "attachment.hotel_city", "attachment.route_cities"}
|
||||
in {
|
||||
"claim.reason",
|
||||
"claim.location",
|
||||
"employee.location",
|
||||
"item.item_date",
|
||||
"item.item_reason",
|
||||
"item.item_location",
|
||||
"attachment.hotel_city",
|
||||
"attachment.route_cities",
|
||||
"attachment.issue_date",
|
||||
"attachment.stay_start_date",
|
||||
"attachment.stay_end_date",
|
||||
}
|
||||
)
|
||||
if any(keyword in text for keyword in ("发票", "票据", "品名", "抬头", "开票")):
|
||||
matched.extend(
|
||||
@@ -639,7 +610,7 @@ class RiskRuleGenerationService:
|
||||
seen.add(field.key)
|
||||
deduped.append(field)
|
||||
if deduped:
|
||||
return deduped[:8]
|
||||
return deduped[:10]
|
||||
return candidates[:4]
|
||||
|
||||
@staticmethod
|
||||
@@ -657,6 +628,14 @@ class RiskRuleGenerationService:
|
||||
term in text for term in ("行程", "交通票", "路线", "途经")
|
||||
):
|
||||
score += 10
|
||||
if field.key in {
|
||||
"claim.trip_start_date",
|
||||
"claim.trip_end_date",
|
||||
"item.item_date",
|
||||
"attachment.stay_start_date",
|
||||
"attachment.stay_end_date",
|
||||
} and any(term in text for term in ("日期", "时间", "出差开始", "出差结束", "入住", "离店")):
|
||||
score += 10
|
||||
if field.key == "claim.location" and any(
|
||||
term in text for term in ("申报目的地", "申报地点", "目的地", "出差地")
|
||||
):
|
||||
@@ -670,14 +649,26 @@ class RiskRuleGenerationService:
|
||||
draft: dict[str, Any],
|
||||
*,
|
||||
natural_language: str,
|
||||
risk_level: str,
|
||||
fields: list[RiskRuleField],
|
||||
) -> dict[str, Any]:
|
||||
if str(draft.get("semantic_type") or "").strip() in CITY_CONSISTENCY_SEMANTIC_TYPES:
|
||||
return build_city_consistency_draft(
|
||||
draft,
|
||||
natural_language=natural_language,
|
||||
fields=fields,
|
||||
risk_level=risk_level,
|
||||
)
|
||||
|
||||
field_by_key = {field.key: field for field in fields}
|
||||
original_keys = [
|
||||
str(item or "").strip()
|
||||
for item in list(draft.get("field_keys") or [])
|
||||
if str(item or "").strip() in field_by_key
|
||||
]
|
||||
if draft.get("template_key") == COMPOSITE_RULE_TEMPLATE_KEY:
|
||||
return {**draft, "field_keys": original_keys or [field.key for field in fields[:8]]}
|
||||
|
||||
preferred_keys: list[str] = []
|
||||
|
||||
def add_preferred(key: str, *terms: str) -> None:
|
||||
@@ -783,40 +774,3 @@ class RiskRuleGenerationService:
|
||||
if start < 0 or end <= start:
|
||||
raise ValueError("JSON object not found.")
|
||||
return normalized[start : end + 1]
|
||||
|
||||
@staticmethod
|
||||
def _build_version_markdown(payload: dict[str, Any]) -> str:
|
||||
metadata = payload.get("metadata") if isinstance(payload.get("metadata"), dict) else {}
|
||||
fields = (
|
||||
payload.get("inputs", {}).get("fields")
|
||||
if isinstance(payload.get("inputs"), dict)
|
||||
else []
|
||||
)
|
||||
field_labels = [
|
||||
str(item.get("label") or item.get("key") or "").strip()
|
||||
for item in fields
|
||||
if isinstance(item, dict) and str(item.get("label") or item.get("key") or "").strip()
|
||||
]
|
||||
return "\n".join(
|
||||
[
|
||||
f"# {payload.get('name')}",
|
||||
"",
|
||||
"## 业务说明",
|
||||
"",
|
||||
str(payload.get("description") or ""),
|
||||
"",
|
||||
"## 自然语言原文",
|
||||
"",
|
||||
str(metadata.get("natural_language") or ""),
|
||||
"",
|
||||
"## 使用字段",
|
||||
"",
|
||||
"、".join(field_labels) or "未识别字段",
|
||||
"",
|
||||
"## 运行时 JSON",
|
||||
"",
|
||||
"```json",
|
||||
json.dumps(payload, ensure_ascii=False, indent=2),
|
||||
"```",
|
||||
]
|
||||
)
|
||||
|
||||
Reference in New Issue
Block a user