2026-05-26 09:15:14 +08:00
|
|
|
|
from __future__ import annotations
|
|
|
|
|
|
|
|
|
|
|
|
from typing import Any
|
|
|
|
|
|
|
|
|
|
|
|
from app.services.risk_rule_flow_diagram import (
|
|
|
|
|
|
RiskRuleFlowDiagramField,
|
|
|
|
|
|
RiskRuleFlowDiagramRenderer,
|
|
|
|
|
|
RiskRuleFlowDiagramSpec,
|
|
|
|
|
|
)
|
|
|
|
|
|
from app.services.risk_rule_generation_semantics import (
|
|
|
|
|
|
CITY_ATTACHMENT_FIELDS,
|
|
|
|
|
|
CITY_CONSISTENCY_SEMANTIC_TYPE,
|
|
|
|
|
|
CITY_CONSISTENCY_SEMANTIC_TYPES,
|
|
|
|
|
|
CITY_EXCEPTION_FIELDS,
|
|
|
|
|
|
CITY_EXCEPTION_KEYWORDS,
|
|
|
|
|
|
CITY_HOME_FIELDS,
|
|
|
|
|
|
CITY_REFERENCE_FIELDS,
|
|
|
|
|
|
build_city_consistency_params,
|
|
|
|
|
|
is_city_consistency_rule,
|
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
RISK_LEVEL_LABELS = {
|
|
|
|
|
|
"low": "低风险",
|
|
|
|
|
|
"medium": "中风险",
|
|
|
|
|
|
"high": "高风险",
|
|
|
|
|
|
"critical": "极高风险",
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
CITY_ROUTE_CONDITION_SUMMARY = (
|
|
|
|
|
|
"判断公式:A=交通票行程城市∪住宿发票城市,B=申报目的地∪明细发生地点,"
|
2026-06-03 15:46:56 +08:00
|
|
|
|
"若A或B为空则要求补充识别;若A与B无交集且无合理说明,"
|
|
|
|
|
|
"或票据路线中存在无法由本次票据起终点和申报目的地解释的额外城市,"
|
|
|
|
|
|
"则命中目的地不一致/中途周转异常风险。"
|
2026-05-26 09:15:14 +08:00
|
|
|
|
)
|
|
|
|
|
|
CITY_ROUTE_FLOW_DECISION = (
|
2026-06-03 15:46:56 +08:00
|
|
|
|
"附件城市是否覆盖申报行程,且票据路线是否出现无法由本次票据起终点和申报目的地解释的中转城市"
|
2026-05-26 09:15:14 +08:00
|
|
|
|
)
|
|
|
|
|
|
CITY_ROUTE_FLOW_EVIDENCE = (
|
2026-06-03 15:46:56 +08:00
|
|
|
|
"读取申报目的地、明细发生地点、交通票行程城市、住宿发票城市和报销事由"
|
2026-05-26 09:15:14 +08:00
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def normalize_risk_rule_manifest(manifest: dict[str, Any]) -> dict[str, Any]:
|
|
|
|
|
|
"""把历史误编译的城市一致性规则规范为受控语义 DSL。"""
|
|
|
|
|
|
|
|
|
|
|
|
if not isinstance(manifest, dict) or not _looks_like_city_consistency_manifest(manifest):
|
|
|
|
|
|
return manifest
|
|
|
|
|
|
|
|
|
|
|
|
payload = dict(manifest)
|
|
|
|
|
|
metadata = dict(payload.get("metadata") if isinstance(payload.get("metadata"), dict) else {})
|
|
|
|
|
|
params = dict(payload.get("params") if isinstance(payload.get("params"), dict) else {})
|
|
|
|
|
|
exception_keywords = _read_string_list(
|
|
|
|
|
|
payload.get("exception_keywords") or params.get("exception_keywords")
|
|
|
|
|
|
) or list(CITY_EXCEPTION_KEYWORDS)
|
|
|
|
|
|
field_keys = _resolve_city_field_keys(payload, params)
|
|
|
|
|
|
severity = _resolve_severity(payload)
|
|
|
|
|
|
severity_label = RISK_LEVEL_LABELS.get(severity, "中风险")
|
|
|
|
|
|
|
|
|
|
|
|
payload["template_key"] = "field_compare_v1"
|
|
|
|
|
|
payload["semantic_type"] = CITY_CONSISTENCY_SEMANTIC_TYPE
|
|
|
|
|
|
payload["keywords"] = []
|
|
|
|
|
|
payload["exception_keywords"] = exception_keywords
|
|
|
|
|
|
|
|
|
|
|
|
params.update(
|
|
|
|
|
|
build_city_consistency_params(
|
|
|
|
|
|
{
|
|
|
|
|
|
"exception_keywords": exception_keywords,
|
|
|
|
|
|
}
|
|
|
|
|
|
)
|
|
|
|
|
|
)
|
|
|
|
|
|
params["template_key"] = "field_compare_v1"
|
|
|
|
|
|
params["field_keys"] = field_keys
|
|
|
|
|
|
params["condition_summary"] = CITY_ROUTE_CONDITION_SUMMARY
|
|
|
|
|
|
payload["params"] = params
|
|
|
|
|
|
|
|
|
|
|
|
payload["condition_summary"] = CITY_ROUTE_CONDITION_SUMMARY
|
|
|
|
|
|
flow = dict(metadata.get("flow") if isinstance(metadata.get("flow"), dict) else {})
|
|
|
|
|
|
flow["evidence"] = CITY_ROUTE_FLOW_EVIDENCE
|
|
|
|
|
|
flow["decision"] = CITY_ROUTE_FLOW_DECISION
|
|
|
|
|
|
flow.setdefault(
|
|
|
|
|
|
"start",
|
|
|
|
|
|
"差旅报销单据提交,并上传交通票据、住宿票据或其他可识别城市的附件",
|
|
|
|
|
|
)
|
|
|
|
|
|
flow.setdefault(
|
|
|
|
|
|
"pass",
|
2026-06-03 15:46:56 +08:00
|
|
|
|
"票据城市覆盖申报行程,且未出现无法由本次票据起终点和申报目的地解释的额外城市",
|
2026-05-26 09:15:14 +08:00
|
|
|
|
)
|
|
|
|
|
|
flow["fail"] = (
|
|
|
|
|
|
f"票据路线存在目的地不一致或额外中转城市,命中{severity_label}并要求补充说明或退回修改"
|
|
|
|
|
|
)
|
|
|
|
|
|
metadata["condition_summary"] = CITY_ROUTE_CONDITION_SUMMARY
|
|
|
|
|
|
metadata["flow"] = flow
|
|
|
|
|
|
payload["metadata"] = metadata
|
|
|
|
|
|
payload["flow_diagram_svg"] = _build_city_flow_svg(payload, field_keys, severity, severity_label)
|
|
|
|
|
|
return payload
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _looks_like_city_consistency_manifest(manifest: dict[str, Any]) -> bool:
|
|
|
|
|
|
params = manifest.get("params") if isinstance(manifest.get("params"), dict) else {}
|
|
|
|
|
|
semantic_type = str(manifest.get("semantic_type") or params.get("semantic_type") or "").strip()
|
|
|
|
|
|
if semantic_type in CITY_CONSISTENCY_SEMANTIC_TYPES:
|
|
|
|
|
|
return True
|
|
|
|
|
|
|
|
|
|
|
|
metadata = manifest.get("metadata") if isinstance(manifest.get("metadata"), dict) else {}
|
|
|
|
|
|
text = "\n".join(
|
|
|
|
|
|
str(value or "")
|
|
|
|
|
|
for value in (
|
|
|
|
|
|
metadata.get("natural_language"),
|
|
|
|
|
|
params.get("natural_language"),
|
|
|
|
|
|
manifest.get("description"),
|
|
|
|
|
|
metadata.get("condition_summary"),
|
|
|
|
|
|
params.get("condition_summary"),
|
|
|
|
|
|
)
|
|
|
|
|
|
)
|
|
|
|
|
|
if is_city_consistency_rule(text):
|
|
|
|
|
|
return True
|
|
|
|
|
|
|
|
|
|
|
|
field_keys = set(_resolve_city_field_keys(manifest, params))
|
|
|
|
|
|
has_attachment_city = bool(field_keys & set(CITY_ATTACHMENT_FIELDS))
|
|
|
|
|
|
has_reference_city = bool(field_keys & set(CITY_REFERENCE_FIELDS))
|
|
|
|
|
|
return has_attachment_city and has_reference_city and "风险关键词" in text
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _resolve_city_field_keys(manifest: dict[str, Any], params: dict[str, Any]) -> list[str]:
|
|
|
|
|
|
inputs = manifest.get("inputs") if isinstance(manifest.get("inputs"), dict) else {}
|
|
|
|
|
|
input_fields = inputs.get("fields") if isinstance(inputs.get("fields"), list) else []
|
|
|
|
|
|
known = {
|
|
|
|
|
|
str(item.get("key") or "").strip()
|
|
|
|
|
|
for item in input_fields
|
|
|
|
|
|
if isinstance(item, dict) and str(item.get("key") or "").strip()
|
|
|
|
|
|
}
|
|
|
|
|
|
candidates = [
|
|
|
|
|
|
*_read_string_list(manifest.get("field_keys")),
|
|
|
|
|
|
*_read_string_list(params.get("field_keys") or params.get("search_fields")),
|
|
|
|
|
|
*CITY_ATTACHMENT_FIELDS,
|
|
|
|
|
|
*CITY_REFERENCE_FIELDS,
|
|
|
|
|
|
*CITY_HOME_FIELDS,
|
|
|
|
|
|
*CITY_EXCEPTION_FIELDS,
|
|
|
|
|
|
]
|
|
|
|
|
|
resolved: list[str] = []
|
|
|
|
|
|
for key in candidates:
|
|
|
|
|
|
if known and key not in known and key not in {
|
|
|
|
|
|
*CITY_ATTACHMENT_FIELDS,
|
|
|
|
|
|
*CITY_REFERENCE_FIELDS,
|
|
|
|
|
|
*CITY_HOME_FIELDS,
|
|
|
|
|
|
*CITY_EXCEPTION_FIELDS,
|
|
|
|
|
|
}:
|
|
|
|
|
|
continue
|
|
|
|
|
|
if key not in resolved:
|
|
|
|
|
|
resolved.append(key)
|
|
|
|
|
|
return resolved
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _build_city_flow_svg(
|
|
|
|
|
|
payload: dict[str, Any],
|
|
|
|
|
|
field_keys: list[str],
|
|
|
|
|
|
severity: str,
|
|
|
|
|
|
severity_label: str,
|
|
|
|
|
|
) -> str:
|
|
|
|
|
|
inputs = payload.get("inputs") if isinstance(payload.get("inputs"), dict) else {}
|
|
|
|
|
|
input_fields = inputs.get("fields") if isinstance(inputs.get("fields"), list) else []
|
|
|
|
|
|
label_by_key = {
|
|
|
|
|
|
str(item.get("key") or "").strip(): str(item.get("label") or item.get("key") or "").strip()
|
|
|
|
|
|
for item in input_fields
|
|
|
|
|
|
if isinstance(item, dict) and str(item.get("key") or "").strip()
|
|
|
|
|
|
}
|
|
|
|
|
|
fields = tuple(
|
|
|
|
|
|
RiskRuleFlowDiagramField(key=key, label=label_by_key.get(key) or key)
|
|
|
|
|
|
for key in field_keys[:8]
|
|
|
|
|
|
)
|
|
|
|
|
|
metadata = payload.get("metadata") if isinstance(payload.get("metadata"), dict) else {}
|
|
|
|
|
|
flow = metadata.get("flow") if isinstance(metadata.get("flow"), dict) else {}
|
|
|
|
|
|
return RiskRuleFlowDiagramRenderer().render(
|
|
|
|
|
|
RiskRuleFlowDiagramSpec(
|
|
|
|
|
|
title=str(payload.get("name") or "风险规则判断流程").strip(),
|
|
|
|
|
|
domain_label=str(payload.get("risk_category") or "差旅费").strip(),
|
|
|
|
|
|
severity=severity,
|
|
|
|
|
|
severity_label=severity_label,
|
|
|
|
|
|
fields=fields,
|
|
|
|
|
|
start=str(flow.get("start") or "差旅报销单据提交").strip(),
|
|
|
|
|
|
evidence=CITY_ROUTE_FLOW_EVIDENCE,
|
|
|
|
|
|
decision=CITY_ROUTE_FLOW_DECISION,
|
|
|
|
|
|
basis=CITY_ROUTE_CONDITION_SUMMARY,
|
|
|
|
|
|
pass_text=str(flow.get("pass") or "未命中风险,继续流转").strip(),
|
|
|
|
|
|
fail_text=str(flow.get("fail") or f"命中{severity_label},进入人工复核").strip(),
|
|
|
|
|
|
)
|
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _resolve_severity(payload: dict[str, Any]) -> str:
|
|
|
|
|
|
outcomes = payload.get("outcomes") if isinstance(payload.get("outcomes"), dict) else {}
|
|
|
|
|
|
fail = outcomes.get("fail") if isinstance(outcomes.get("fail"), dict) else {}
|
|
|
|
|
|
severity = str(fail.get("severity") or payload.get("severity") or "").strip().lower()
|
|
|
|
|
|
return severity if severity in RISK_LEVEL_LABELS else "medium"
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _read_string_list(value: Any) -> list[str]:
|
|
|
|
|
|
if not isinstance(value, list):
|
|
|
|
|
|
return []
|
|
|
|
|
|
return [str(item or "").strip() for item in value if str(item or "").strip()]
|