2026-05-23 19:54:42 +08:00
|
|
|
|
from __future__ import annotations
|
|
|
|
|
|
|
|
|
|
|
|
import json
|
|
|
|
|
|
import re
|
|
|
|
|
|
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.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
|
2026-05-30 15:46:51 +08:00
|
|
|
|
from app.services.risk_rule_explainability import build_risk_rule_explainability_artifacts
|
2026-05-26 09:15:14 +08:00
|
|
|
|
from app.services.risk_rule_generation_ontology import (
|
|
|
|
|
|
BUSINESS_DOMAIN_LABELS,
|
|
|
|
|
|
DOMAIN_FIELD_PREFIXES,
|
2026-05-26 12:16:20 +08:00
|
|
|
|
EXPENSE_BUSINESS_STAGE_LABELS,
|
2026-05-26 09:15:14 +08:00
|
|
|
|
EXPENSE_RISK_CATEGORY_ALIASES,
|
|
|
|
|
|
EXPENSE_RISK_CATEGORY_LABELS,
|
|
|
|
|
|
FIELD_ONTOLOGY,
|
|
|
|
|
|
RISK_LEVEL_LABELS,
|
|
|
|
|
|
RiskRuleField,
|
2026-05-24 21:44:17 +08:00
|
|
|
|
)
|
2026-05-26 09:15:14 +08:00
|
|
|
|
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,
|
2026-05-23 19:54:42 +08:00
|
|
|
|
)
|
2026-05-30 15:46:51 +08:00
|
|
|
|
from app.services.risk_rule_generation_semantic_plan import unwrap_semantic_plan_payload
|
|
|
|
|
|
from app.services.risk_rule_dsl_validator import validate_risk_rule_draft
|
2026-05-26 09:15:14 +08:00
|
|
|
|
from app.services.risk_rule_scoring import apply_risk_score_to_draft, calculate_risk_rule_score
|
|
|
|
|
|
from app.services.runtime_chat import RuntimeChatService
|
2026-05-23 19:54:42 +08:00
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
class RiskRuleGenerationService:
|
|
|
|
|
|
def __init__(
|
|
|
|
|
|
self,
|
|
|
|
|
|
db: Session,
|
|
|
|
|
|
*,
|
|
|
|
|
|
rule_library_manager: AgentAssetRuleLibraryManager | None = None,
|
|
|
|
|
|
runtime_chat_service: RuntimeChatService | None = None,
|
|
|
|
|
|
) -> None:
|
|
|
|
|
|
self.db = db
|
|
|
|
|
|
self.rule_library_manager = rule_library_manager or AgentAssetRuleLibraryManager()
|
|
|
|
|
|
self.runtime_chat_service = runtime_chat_service or RuntimeChatService(db)
|
|
|
|
|
|
self.audit_service = AuditLogService(db)
|
|
|
|
|
|
|
|
|
|
|
|
def generate_rule_asset(
|
|
|
|
|
|
self,
|
|
|
|
|
|
body: AgentAssetRiskRuleGenerateRequest,
|
|
|
|
|
|
*,
|
|
|
|
|
|
actor: str,
|
|
|
|
|
|
request_id: str | None = None,
|
|
|
|
|
|
) -> str:
|
|
|
|
|
|
domain = body.business_domain.value
|
|
|
|
|
|
if domain not in BUSINESS_DOMAIN_LABELS:
|
|
|
|
|
|
raise ValueError("当前仅支持报销、应收、应付业务域的新建风险规则。")
|
|
|
|
|
|
|
|
|
|
|
|
natural_language = self._clean_text(body.natural_language)
|
|
|
|
|
|
if len(natural_language) < 8:
|
|
|
|
|
|
raise ValueError("请至少输入 8 个字的风险规则描述。")
|
|
|
|
|
|
|
2026-05-26 09:15:14 +08:00
|
|
|
|
rule_title = self._clean_text(body.rule_title)
|
|
|
|
|
|
if rule_title and len(rule_title) < 2:
|
|
|
|
|
|
raise ValueError("规则标题至少需要 2 个字。")
|
|
|
|
|
|
|
2026-05-24 21:44:17 +08:00
|
|
|
|
requires_attachment = bool(body.requires_attachment)
|
2026-05-26 12:16:20 +08:00
|
|
|
|
business_stage = self._normalize_business_stage(body.business_stage, domain)
|
|
|
|
|
|
business_stage_label = EXPENSE_BUSINESS_STAGE_LABELS.get(business_stage, "费用报销")
|
2026-05-24 21:44:17 +08:00
|
|
|
|
expense_category = self._normalize_expense_category(body.expense_category, domain)
|
|
|
|
|
|
expense_category_label = EXPENSE_RISK_CATEGORY_LABELS.get(expense_category or "", "")
|
2026-05-23 19:54:42 +08:00
|
|
|
|
|
|
|
|
|
|
created_at = datetime.now(UTC)
|
|
|
|
|
|
fields = self._resolve_fields(natural_language, domain=domain)
|
|
|
|
|
|
draft = self._compile_with_model(
|
|
|
|
|
|
natural_language=natural_language,
|
|
|
|
|
|
domain=domain,
|
2026-05-26 12:16:20 +08:00
|
|
|
|
business_stage=business_stage,
|
|
|
|
|
|
business_stage_label=business_stage_label,
|
2026-05-24 21:44:17 +08:00
|
|
|
|
expense_category=expense_category,
|
|
|
|
|
|
expense_category_label=expense_category_label,
|
2026-05-23 19:54:42 +08:00
|
|
|
|
fields=fields,
|
|
|
|
|
|
) or self._build_fallback_draft(
|
|
|
|
|
|
natural_language=natural_language,
|
|
|
|
|
|
domain=domain,
|
2026-05-24 21:44:17 +08:00
|
|
|
|
expense_category_label=expense_category_label,
|
2026-05-26 09:15:14 +08:00
|
|
|
|
risk_level="medium",
|
2026-05-23 19:54:42 +08:00
|
|
|
|
fields=fields,
|
|
|
|
|
|
)
|
2026-05-30 15:46:51 +08:00
|
|
|
|
draft = validate_risk_rule_draft(draft, fields=fields, natural_language=natural_language)
|
2026-05-23 19:54:42 +08:00
|
|
|
|
draft = self._align_draft_fields(
|
|
|
|
|
|
draft,
|
|
|
|
|
|
natural_language=natural_language,
|
2026-05-26 09:15:14 +08:00
|
|
|
|
risk_level="medium",
|
2026-05-23 19:54:42 +08:00
|
|
|
|
fields=fields,
|
|
|
|
|
|
)
|
2026-05-30 15:46:51 +08:00
|
|
|
|
draft = validate_risk_rule_draft(draft, fields=fields, natural_language=natural_language)
|
2026-05-26 09:15:14 +08:00
|
|
|
|
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)
|
2026-05-23 19:54:42 +08:00
|
|
|
|
payload = self._build_rule_payload(
|
|
|
|
|
|
draft,
|
|
|
|
|
|
natural_language=natural_language,
|
|
|
|
|
|
domain=domain,
|
2026-05-26 12:16:20 +08:00
|
|
|
|
business_stage=business_stage,
|
|
|
|
|
|
business_stage_label=business_stage_label,
|
2026-05-24 21:44:17 +08:00
|
|
|
|
expense_category=expense_category,
|
|
|
|
|
|
expense_category_label=expense_category_label,
|
2026-05-23 19:54:42 +08:00
|
|
|
|
risk_level=risk_level,
|
|
|
|
|
|
fields=fields,
|
|
|
|
|
|
created_at=created_at,
|
|
|
|
|
|
actor=actor,
|
2026-05-24 21:44:17 +08:00
|
|
|
|
requires_attachment=requires_attachment,
|
2026-05-26 09:15:14 +08:00
|
|
|
|
rule_title=rule_title,
|
|
|
|
|
|
risk_score=risk_score,
|
2026-05-23 19:54:42 +08:00
|
|
|
|
)
|
|
|
|
|
|
rule_code = str(payload["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,
|
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
asset = AgentAsset(
|
|
|
|
|
|
asset_type=AgentAssetType.RULE.value,
|
|
|
|
|
|
code=rule_code,
|
|
|
|
|
|
name=str(payload["name"]),
|
|
|
|
|
|
description=str(payload["description"]),
|
|
|
|
|
|
domain=domain,
|
|
|
|
|
|
scenario_json=[str(payload.get("risk_category") or BUSINESS_DOMAIN_LABELS[domain])],
|
|
|
|
|
|
owner=actor,
|
|
|
|
|
|
reviewer=None,
|
|
|
|
|
|
status=AgentAssetStatus.DRAFT.value,
|
|
|
|
|
|
current_version="v0.1.0",
|
|
|
|
|
|
published_version=None,
|
|
|
|
|
|
working_version="v0.1.0",
|
|
|
|
|
|
config_json={
|
|
|
|
|
|
"severity": risk_level,
|
2026-05-26 09:15:14 +08:00
|
|
|
|
"risk_score": risk_score["score"],
|
|
|
|
|
|
"risk_level": risk_level,
|
|
|
|
|
|
"risk_level_label": risk_score["level_label"],
|
|
|
|
|
|
"risk_score_detail": risk_score,
|
2026-05-23 19:54:42 +08:00
|
|
|
|
"enabled": True,
|
2026-05-24 21:44:17 +08:00
|
|
|
|
"requires_attachment": requires_attachment,
|
2026-05-23 19:54:42 +08:00
|
|
|
|
"tag": "风险规则",
|
|
|
|
|
|
"detail_mode": "json_risk",
|
2026-05-26 12:16:20 +08:00
|
|
|
|
"business_stage": business_stage,
|
|
|
|
|
|
"business_stage_label": business_stage_label,
|
2026-05-24 21:44:17 +08:00
|
|
|
|
"expense_category": expense_category,
|
|
|
|
|
|
"expense_category_label": expense_category_label,
|
2026-05-23 19:54:42 +08:00
|
|
|
|
"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": "自然语言风险规则",
|
2026-05-26 12:16:20 +08:00
|
|
|
|
"last_operation": {
|
|
|
|
|
|
"action": "create",
|
|
|
|
|
|
"actor": actor,
|
|
|
|
|
|
"at": datetime.now(UTC).isoformat(),
|
|
|
|
|
|
},
|
2026-05-23 19:54:42 +08:00
|
|
|
|
},
|
|
|
|
|
|
)
|
|
|
|
|
|
self.db.add(asset)
|
|
|
|
|
|
self.db.flush()
|
|
|
|
|
|
self.db.add(
|
|
|
|
|
|
AgentAssetVersion(
|
|
|
|
|
|
asset_id=asset.id,
|
|
|
|
|
|
version="v0.1.0",
|
2026-05-26 09:15:14 +08:00
|
|
|
|
content=build_risk_rule_version_markdown(payload),
|
2026-05-23 19:54:42 +08:00
|
|
|
|
content_type="markdown",
|
|
|
|
|
|
change_note="通过自然语言新建风险规则草稿。",
|
|
|
|
|
|
created_by=actor,
|
|
|
|
|
|
)
|
|
|
|
|
|
)
|
|
|
|
|
|
self.audit_service.log_action(
|
|
|
|
|
|
actor=actor,
|
|
|
|
|
|
action="generate_agent_asset_risk_rule",
|
|
|
|
|
|
resource_type=AgentAssetType.RULE.value,
|
|
|
|
|
|
resource_id=asset.id,
|
|
|
|
|
|
before_json=None,
|
2026-05-24 21:44:17 +08:00
|
|
|
|
after_json={
|
|
|
|
|
|
"rule_code": rule_code,
|
|
|
|
|
|
"risk_level": risk_level,
|
2026-05-26 09:15:14 +08:00
|
|
|
|
"risk_score": risk_score["score"],
|
2026-05-24 21:44:17 +08:00
|
|
|
|
"domain": domain,
|
2026-05-26 12:16:20 +08:00
|
|
|
|
"business_stage": business_stage,
|
2026-05-24 21:44:17 +08:00
|
|
|
|
"expense_category": expense_category,
|
|
|
|
|
|
"requires_attachment": requires_attachment,
|
|
|
|
|
|
},
|
2026-05-23 19:54:42 +08:00
|
|
|
|
request_id=request_id,
|
|
|
|
|
|
)
|
|
|
|
|
|
self.db.refresh(asset)
|
|
|
|
|
|
return asset.id
|
|
|
|
|
|
|
|
|
|
|
|
def _compile_with_model(
|
|
|
|
|
|
self,
|
|
|
|
|
|
*,
|
|
|
|
|
|
natural_language: str,
|
|
|
|
|
|
domain: str,
|
2026-05-26 12:16:20 +08:00
|
|
|
|
business_stage: str,
|
|
|
|
|
|
business_stage_label: str,
|
2026-05-24 21:44:17 +08:00
|
|
|
|
expense_category: str | None,
|
|
|
|
|
|
expense_category_label: str,
|
2026-05-23 19:54:42 +08:00
|
|
|
|
fields: list[RiskRuleField],
|
|
|
|
|
|
) -> dict[str, Any] | None:
|
|
|
|
|
|
field_payload = [
|
|
|
|
|
|
{
|
|
|
|
|
|
"key": item.key,
|
|
|
|
|
|
"label": item.label,
|
|
|
|
|
|
"type": item.field_type,
|
|
|
|
|
|
"source": item.source,
|
|
|
|
|
|
}
|
|
|
|
|
|
for item in fields
|
|
|
|
|
|
]
|
2026-05-26 09:15:14 +08:00
|
|
|
|
messages = build_risk_rule_compiler_messages(
|
|
|
|
|
|
domain=domain,
|
|
|
|
|
|
domain_label=BUSINESS_DOMAIN_LABELS[domain],
|
2026-05-26 12:16:20 +08:00
|
|
|
|
business_stage=business_stage,
|
|
|
|
|
|
business_stage_label=business_stage_label,
|
2026-05-26 09:15:14 +08:00
|
|
|
|
expense_category=expense_category,
|
|
|
|
|
|
expense_category_label=expense_category_label,
|
|
|
|
|
|
natural_language=natural_language,
|
|
|
|
|
|
available_fields=field_payload,
|
|
|
|
|
|
)
|
2026-05-23 19:54:42 +08:00
|
|
|
|
answer = self.runtime_chat_service.complete(
|
|
|
|
|
|
messages,
|
2026-05-26 09:15:14 +08:00
|
|
|
|
max_tokens=1400,
|
2026-05-23 19:54:42 +08:00
|
|
|
|
temperature=0.1,
|
|
|
|
|
|
timeout_seconds=12,
|
|
|
|
|
|
max_attempts=1,
|
|
|
|
|
|
)
|
|
|
|
|
|
if not answer:
|
|
|
|
|
|
return None
|
|
|
|
|
|
|
|
|
|
|
|
try:
|
|
|
|
|
|
payload = json.loads(self._extract_json_object(answer))
|
|
|
|
|
|
except (json.JSONDecodeError, ValueError):
|
|
|
|
|
|
return None
|
|
|
|
|
|
if not isinstance(payload, dict):
|
|
|
|
|
|
return None
|
2026-05-30 15:46:51 +08:00
|
|
|
|
payload = unwrap_semantic_plan_payload(payload)
|
2026-05-23 19:54:42 +08:00
|
|
|
|
return self._sanitize_model_draft(payload, fields=fields)
|
|
|
|
|
|
|
|
|
|
|
|
def _sanitize_model_draft(
|
|
|
|
|
|
self,
|
|
|
|
|
|
payload: dict[str, Any],
|
|
|
|
|
|
*,
|
|
|
|
|
|
fields: list[RiskRuleField],
|
|
|
|
|
|
) -> dict[str, Any]:
|
|
|
|
|
|
allowed_fields = {item.key for item in fields}
|
|
|
|
|
|
template_key = str(payload.get("template_key") or "").strip()
|
2026-05-26 09:15:14 +08:00
|
|
|
|
if template_key not in {
|
|
|
|
|
|
"field_required_v1",
|
|
|
|
|
|
"field_compare_v1",
|
|
|
|
|
|
"keyword_match_v1",
|
|
|
|
|
|
COMPOSITE_RULE_TEMPLATE_KEY,
|
|
|
|
|
|
}:
|
2026-05-23 19:54:42 +08:00
|
|
|
|
template_key = "field_required_v1"
|
|
|
|
|
|
|
|
|
|
|
|
raw_field_keys = payload.get("field_keys")
|
|
|
|
|
|
field_keys = [
|
|
|
|
|
|
str(item or "").strip()
|
|
|
|
|
|
for item in (raw_field_keys if isinstance(raw_field_keys, list) else [])
|
|
|
|
|
|
if str(item or "").strip() in allowed_fields
|
|
|
|
|
|
]
|
|
|
|
|
|
if not field_keys and fields:
|
|
|
|
|
|
field_keys = [fields[0].key]
|
|
|
|
|
|
|
|
|
|
|
|
keywords = [
|
|
|
|
|
|
str(item or "").strip()
|
|
|
|
|
|
for item in (
|
|
|
|
|
|
payload.get("keywords") if isinstance(payload.get("keywords"), list) else []
|
|
|
|
|
|
)
|
|
|
|
|
|
if str(item or "").strip()
|
|
|
|
|
|
]
|
2026-05-26 09:15:14 +08:00
|
|
|
|
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()
|
|
|
|
|
|
]
|
2026-05-23 19:54:42 +08:00
|
|
|
|
flow = payload.get("flow") if isinstance(payload.get("flow"), dict) else {}
|
2026-05-26 09:15:14 +08:00
|
|
|
|
rule_ir = payload.get("rule_ir") if isinstance(payload.get("rule_ir"), dict) else {}
|
|
|
|
|
|
draft = {
|
2026-05-23 19:54:42 +08:00
|
|
|
|
"name": self._clean_text(payload.get("name"))[:80],
|
|
|
|
|
|
"description": self._clean_text(payload.get("description")),
|
|
|
|
|
|
"template_key": template_key,
|
2026-05-26 09:15:14 +08:00
|
|
|
|
"semantic_type": self._clean_text(payload.get("semantic_type")),
|
2026-05-23 19:54:42 +08:00
|
|
|
|
"field_keys": field_keys,
|
|
|
|
|
|
"condition_summary": self._clean_text(payload.get("condition_summary")),
|
|
|
|
|
|
"keywords": keywords[:12],
|
2026-05-26 09:15:14 +08:00
|
|
|
|
"exception_keywords": exception_keywords[:12],
|
|
|
|
|
|
"unsupported_fields": unsupported_fields[:20],
|
|
|
|
|
|
"rule_ir": rule_ir,
|
2026-05-23 19:54:42 +08:00
|
|
|
|
"flow": {
|
|
|
|
|
|
"start": self._clean_text(flow.get("start")) or "提交业务单据",
|
|
|
|
|
|
"evidence": self._clean_text(flow.get("evidence")) or "读取规则字段",
|
|
|
|
|
|
"decision": self._clean_text(flow.get("decision")) or "判断是否命中风险",
|
|
|
|
|
|
"pass": self._clean_text(flow.get("pass")) or "继续流转",
|
|
|
|
|
|
"fail": self._clean_text(flow.get("fail")) or "提示风险并进入复核",
|
|
|
|
|
|
},
|
|
|
|
|
|
}
|
2026-05-26 09:15:14 +08:00
|
|
|
|
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
|
2026-05-30 15:46:51 +08:00
|
|
|
|
if isinstance(payload.get("model_semantic_plan"), dict):
|
|
|
|
|
|
draft["model_semantic_plan"] = payload["model_semantic_plan"]
|
2026-05-26 09:15:14 +08:00
|
|
|
|
for key in ("formula", "message_template"):
|
|
|
|
|
|
value = self._clean_text(payload.get(key))
|
|
|
|
|
|
if value:
|
|
|
|
|
|
draft[key] = value
|
|
|
|
|
|
return draft
|
2026-05-23 19:54:42 +08:00
|
|
|
|
|
|
|
|
|
|
def _build_fallback_draft(
|
|
|
|
|
|
self,
|
|
|
|
|
|
*,
|
|
|
|
|
|
natural_language: str,
|
|
|
|
|
|
domain: str,
|
2026-05-24 21:44:17 +08:00
|
|
|
|
expense_category_label: str,
|
2026-05-23 19:54:42 +08:00
|
|
|
|
risk_level: str,
|
|
|
|
|
|
fields: list[RiskRuleField],
|
|
|
|
|
|
) -> dict[str, Any]:
|
|
|
|
|
|
field_keys = [item.key for item in fields[:4]]
|
|
|
|
|
|
template_key = self._infer_template_key(natural_language)
|
|
|
|
|
|
condition_summary = self._build_condition_summary(
|
|
|
|
|
|
natural_language,
|
|
|
|
|
|
template_key=template_key,
|
|
|
|
|
|
fields=fields,
|
|
|
|
|
|
)
|
|
|
|
|
|
name = self._infer_rule_name(natural_language)
|
2026-05-24 21:44:17 +08:00
|
|
|
|
business_label = expense_category_label or BUSINESS_DOMAIN_LABELS[domain]
|
2026-05-23 19:54:42 +08:00
|
|
|
|
description = (
|
2026-05-24 21:44:17 +08:00
|
|
|
|
f"当{business_label}业务满足“{natural_language}”时,系统会按"
|
2026-05-23 19:54:42 +08:00
|
|
|
|
f"{RISK_LEVEL_LABELS[risk_level]}进行提示,并要求经办人或审核人补充核对依据。"
|
|
|
|
|
|
)
|
|
|
|
|
|
return {
|
|
|
|
|
|
"name": name,
|
|
|
|
|
|
"description": description,
|
|
|
|
|
|
"template_key": template_key,
|
|
|
|
|
|
"field_keys": field_keys,
|
|
|
|
|
|
"condition_summary": condition_summary,
|
|
|
|
|
|
"keywords": self._infer_keywords(natural_language),
|
|
|
|
|
|
"flow": {
|
2026-05-24 21:44:17 +08:00
|
|
|
|
"start": f"{business_label}单据提交",
|
2026-05-23 19:54:42 +08:00
|
|
|
|
"evidence": "读取" + "、".join(item.label for item in fields[:3]),
|
|
|
|
|
|
"decision": condition_summary,
|
|
|
|
|
|
"pass": "未命中风险,继续业务流转",
|
|
|
|
|
|
"fail": f"命中{RISK_LEVEL_LABELS[risk_level]},提示复核",
|
|
|
|
|
|
},
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
def _build_rule_payload(
|
|
|
|
|
|
self,
|
|
|
|
|
|
draft: dict[str, Any],
|
|
|
|
|
|
*,
|
|
|
|
|
|
natural_language: str,
|
|
|
|
|
|
domain: str,
|
2026-05-26 12:16:20 +08:00
|
|
|
|
business_stage: str,
|
|
|
|
|
|
business_stage_label: str,
|
2026-05-24 21:44:17 +08:00
|
|
|
|
expense_category: str | None,
|
|
|
|
|
|
expense_category_label: str,
|
2026-05-23 19:54:42 +08:00
|
|
|
|
risk_level: str,
|
|
|
|
|
|
fields: list[RiskRuleField],
|
|
|
|
|
|
created_at: datetime,
|
|
|
|
|
|
actor: str,
|
2026-05-24 21:44:17 +08:00
|
|
|
|
requires_attachment: bool,
|
2026-05-26 09:15:14 +08:00
|
|
|
|
rule_title: str = "",
|
|
|
|
|
|
risk_score: dict[str, Any] | None = None,
|
2026-05-23 19:54:42 +08:00
|
|
|
|
) -> dict[str, Any]:
|
2026-05-24 21:44:17 +08:00
|
|
|
|
created_stamp = created_at.strftime("%Y%m%d%H%M%S%f")
|
2026-05-23 19:54:42 +08:00
|
|
|
|
domain_slug = {"expense": "expense", "ar": "ar", "ap": "ap"}[domain]
|
2026-05-24 21:44:17 +08:00
|
|
|
|
category_slug = f".{expense_category}" if expense_category else ""
|
|
|
|
|
|
rule_code = f"risk.{domain_slug}{category_slug}.generated_{created_stamp}"
|
2026-05-23 19:54:42 +08:00
|
|
|
|
template_key = str(draft.get("template_key") or "field_required_v1").strip()
|
|
|
|
|
|
field_keys = [
|
|
|
|
|
|
str(item or "").strip()
|
|
|
|
|
|
for item in list(draft.get("field_keys") or [])
|
|
|
|
|
|
if str(item or "").strip()
|
|
|
|
|
|
]
|
|
|
|
|
|
condition_summary = (
|
|
|
|
|
|
self._clean_text(draft.get("condition_summary")) or "判断是否符合自然语言规则描述"
|
|
|
|
|
|
)
|
2026-05-24 21:44:17 +08:00
|
|
|
|
risk_category = expense_category_label or BUSINESS_DOMAIN_LABELS[domain]
|
2026-05-26 09:15:14 +08:00
|
|
|
|
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, "风险")
|
|
|
|
|
|
)
|
2026-05-23 19:54:42 +08:00
|
|
|
|
keywords = list(draft.get("keywords") or [])
|
|
|
|
|
|
field_by_key = {item.key: item for item in fields}
|
|
|
|
|
|
params: dict[str, Any] = {
|
|
|
|
|
|
"template_key": template_key,
|
|
|
|
|
|
"field_keys": field_keys,
|
|
|
|
|
|
"condition_summary": condition_summary,
|
|
|
|
|
|
"natural_language": natural_language,
|
2026-05-26 12:16:20 +08:00
|
|
|
|
"business_stage": business_stage,
|
|
|
|
|
|
"business_stage_label": business_stage_label,
|
2026-05-23 19:54:42 +08:00
|
|
|
|
}
|
2026-05-26 09:15:14 +08:00
|
|
|
|
semantic_type = str(draft.get("semantic_type") or "").strip()
|
|
|
|
|
|
if semantic_type:
|
|
|
|
|
|
params["semantic_type"] = semantic_type
|
2026-05-30 15:46:51 +08:00
|
|
|
|
if isinstance(draft.get("dsl_validation"), dict):
|
|
|
|
|
|
params["dsl_validation"] = draft["dsl_validation"]
|
2026-05-26 09:15:14 +08:00
|
|
|
|
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))
|
2026-05-23 19:54:42 +08:00
|
|
|
|
if template_key == "field_required_v1":
|
|
|
|
|
|
params["required_fields"] = field_keys
|
2026-05-26 09:15:14 +08:00
|
|
|
|
if template_key == "field_compare_v1" and "conditions" not in params:
|
2026-05-23 19:54:42 +08:00
|
|
|
|
params["conditions"] = self._build_compare_conditions(field_keys)
|
|
|
|
|
|
if template_key == "keyword_match_v1":
|
|
|
|
|
|
params["keywords"] = keywords
|
|
|
|
|
|
params["search_fields"] = field_keys
|
2026-05-24 21:44:17 +08:00
|
|
|
|
applies_to: dict[str, Any] = {"domains": [domain]}
|
2026-05-26 12:16:20 +08:00
|
|
|
|
if business_stage:
|
|
|
|
|
|
applies_to["business_stages"] = [business_stage]
|
2026-05-24 21:44:17 +08:00
|
|
|
|
if expense_category:
|
|
|
|
|
|
applies_to["expense_categories"] = [expense_category]
|
2026-05-23 19:54:42 +08:00
|
|
|
|
|
|
|
|
|
|
payload = {
|
|
|
|
|
|
"schema_version": "2.0",
|
|
|
|
|
|
"rule_code": rule_code,
|
2026-05-26 09:15:14 +08:00
|
|
|
|
"name": rule_title
|
|
|
|
|
|
or self._clean_text(draft.get("name"))
|
|
|
|
|
|
or self._infer_rule_name(natural_language),
|
2026-05-23 19:54:42 +08:00
|
|
|
|
"description": self._clean_text(draft.get("description")) or natural_language,
|
|
|
|
|
|
"enabled": True,
|
2026-05-24 21:44:17 +08:00
|
|
|
|
"requires_attachment": requires_attachment,
|
2026-05-23 19:54:42 +08:00
|
|
|
|
"risk_dimension": "natural_language_rule",
|
|
|
|
|
|
"risk_category": risk_category,
|
|
|
|
|
|
"ontology_signal": "natural_language_risk",
|
|
|
|
|
|
"evaluator": "template_rule",
|
|
|
|
|
|
"template_key": template_key,
|
2026-05-26 09:15:14 +08:00
|
|
|
|
"semantic_type": str(draft.get("semantic_type") or "").strip() or None,
|
2026-05-24 21:44:17 +08:00
|
|
|
|
"applies_to": applies_to,
|
2026-05-23 19:54:42 +08:00
|
|
|
|
"inputs": {
|
|
|
|
|
|
"fields": [
|
|
|
|
|
|
{
|
|
|
|
|
|
"key": item.key,
|
|
|
|
|
|
"label": item.label,
|
|
|
|
|
|
"type": item.field_type,
|
|
|
|
|
|
"source": item.source,
|
|
|
|
|
|
}
|
|
|
|
|
|
for item in [field_by_key[key] for key in field_keys if key in field_by_key]
|
|
|
|
|
|
],
|
|
|
|
|
|
},
|
|
|
|
|
|
"params": params,
|
|
|
|
|
|
"outcomes": {
|
|
|
|
|
|
"pass": {"severity": "none", "action": "continue"},
|
|
|
|
|
|
"fail": {
|
|
|
|
|
|
"severity": risk_level,
|
|
|
|
|
|
"action": "manual_review",
|
2026-05-26 09:15:14 +08:00
|
|
|
|
"risk_score": risk_score_value,
|
2026-05-23 19:54:42 +08:00
|
|
|
|
},
|
|
|
|
|
|
},
|
|
|
|
|
|
"metadata": {
|
|
|
|
|
|
"owner": actor,
|
|
|
|
|
|
"stability": "generated_draft",
|
|
|
|
|
|
"source_ref": "自然语言风险规则",
|
|
|
|
|
|
"created_at": created_at.isoformat(),
|
|
|
|
|
|
"created_by": actor,
|
2026-05-24 21:44:17 +08:00
|
|
|
|
"requires_attachment": requires_attachment,
|
2026-05-26 09:15:14 +08:00
|
|
|
|
"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,
|
2026-05-24 21:44:17 +08:00
|
|
|
|
"expense_category": expense_category,
|
|
|
|
|
|
"expense_category_label": expense_category_label,
|
2026-05-26 12:16:20 +08:00
|
|
|
|
"business_stage": business_stage,
|
|
|
|
|
|
"business_stage_label": business_stage_label,
|
2026-05-23 19:54:42 +08:00
|
|
|
|
"natural_language": natural_language,
|
|
|
|
|
|
"business_explanation": self._clean_text(draft.get("description")),
|
|
|
|
|
|
"condition_summary": condition_summary,
|
2026-05-26 09:15:14 +08:00
|
|
|
|
"rule_ir": draft.get("rule_ir") if isinstance(draft.get("rule_ir"), dict) else {},
|
2026-05-30 15:46:51 +08:00
|
|
|
|
"model_semantic_plan": draft.get("model_semantic_plan") if isinstance(draft.get("model_semantic_plan"), dict) else {},
|
2026-05-23 19:54:42 +08:00
|
|
|
|
"flow": draft.get("flow") if isinstance(draft.get("flow"), dict) else {},
|
|
|
|
|
|
},
|
|
|
|
|
|
}
|
2026-05-30 15:46:51 +08:00
|
|
|
|
explainability = build_risk_rule_explainability_artifacts(
|
2026-05-23 19:54:42 +08:00
|
|
|
|
payload,
|
|
|
|
|
|
fields=[field_by_key[key] for key in field_keys if key in field_by_key],
|
2026-05-24 21:44:17 +08:00
|
|
|
|
domain_label=risk_category,
|
2026-05-23 19:54:42 +08:00
|
|
|
|
risk_level=risk_level,
|
2026-05-30 15:46:51 +08:00
|
|
|
|
risk_level_label=risk_level_label,
|
2026-05-23 19:54:42 +08:00
|
|
|
|
)
|
2026-05-30 15:46:51 +08:00
|
|
|
|
payload.update(explainability)
|
|
|
|
|
|
payload["metadata"].update(
|
|
|
|
|
|
{
|
|
|
|
|
|
"semantic_plan": explainability["semantic_plan"],
|
|
|
|
|
|
"flow_model": explainability["flow_model"],
|
|
|
|
|
|
"flow_explanation": explainability["flow_explanation"],
|
|
|
|
|
|
"flow_diagram_svg": explainability["flow_diagram_svg"],
|
|
|
|
|
|
}
|
2026-05-23 19:54:42 +08:00
|
|
|
|
)
|
2026-05-30 15:46:51 +08:00
|
|
|
|
return payload
|
2026-05-23 19:54:42 +08:00
|
|
|
|
|
2026-05-24 21:44:17 +08:00
|
|
|
|
@staticmethod
|
|
|
|
|
|
def _normalize_expense_category(value: str | None, domain: str) -> str | None:
|
|
|
|
|
|
if domain != AgentAssetDomain.EXPENSE.value:
|
|
|
|
|
|
return None
|
|
|
|
|
|
|
|
|
|
|
|
normalized = str(value or "").strip().lower()
|
|
|
|
|
|
if not normalized:
|
|
|
|
|
|
return None
|
|
|
|
|
|
|
|
|
|
|
|
normalized = EXPENSE_RISK_CATEGORY_ALIASES.get(normalized, normalized)
|
|
|
|
|
|
if normalized not in EXPENSE_RISK_CATEGORY_LABELS:
|
|
|
|
|
|
allowed = "、".join(EXPENSE_RISK_CATEGORY_LABELS.values())
|
|
|
|
|
|
raise ValueError(f"费用领域仅支持:{allowed}。")
|
|
|
|
|
|
return normalized
|
|
|
|
|
|
|
2026-05-26 12:16:20 +08:00
|
|
|
|
@staticmethod
|
|
|
|
|
|
def _normalize_business_stage(value: str | None, domain: str) -> str:
|
|
|
|
|
|
if domain != AgentAssetDomain.EXPENSE.value:
|
|
|
|
|
|
return "reimbursement"
|
|
|
|
|
|
|
|
|
|
|
|
normalized = str(value or "reimbursement").strip().lower()
|
|
|
|
|
|
if not normalized:
|
|
|
|
|
|
normalized = "reimbursement"
|
|
|
|
|
|
if normalized not in EXPENSE_BUSINESS_STAGE_LABELS:
|
|
|
|
|
|
allowed = "、".join(EXPENSE_BUSINESS_STAGE_LABELS.values())
|
|
|
|
|
|
raise ValueError(f"业务环节仅支持:{allowed}。")
|
|
|
|
|
|
return normalized
|
|
|
|
|
|
|
2026-05-23 19:54:42 +08:00
|
|
|
|
def _resolve_fields(self, text: str, *, domain: str) -> list[RiskRuleField]:
|
|
|
|
|
|
prefixes = DOMAIN_FIELD_PREFIXES.get(domain, ())
|
|
|
|
|
|
candidates = [field for field in FIELD_ONTOLOGY if field.key.startswith(prefixes)]
|
|
|
|
|
|
normalized = text.lower()
|
|
|
|
|
|
matched: list[tuple[int, RiskRuleField]] = []
|
|
|
|
|
|
for field in candidates:
|
|
|
|
|
|
score = self._score_field_match(field, text, normalized)
|
|
|
|
|
|
if score > 0:
|
|
|
|
|
|
matched.append((score, field))
|
|
|
|
|
|
|
|
|
|
|
|
if domain == AgentAssetDomain.EXPENSE.value:
|
|
|
|
|
|
if any(keyword in text for keyword in ("住宿", "酒店", "行程", "城市", "出差")):
|
|
|
|
|
|
matched.extend(
|
|
|
|
|
|
(10, field)
|
|
|
|
|
|
for field in candidates
|
|
|
|
|
|
if field.key
|
2026-05-26 09:15:14 +08:00
|
|
|
|
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",
|
|
|
|
|
|
}
|
2026-05-23 19:54:42 +08:00
|
|
|
|
)
|
|
|
|
|
|
if any(keyword in text for keyword in ("发票", "票据", "品名", "抬头", "开票")):
|
|
|
|
|
|
matched.extend(
|
|
|
|
|
|
(6, field)
|
|
|
|
|
|
for field in candidates
|
|
|
|
|
|
if field.key
|
|
|
|
|
|
in {
|
|
|
|
|
|
"attachment.invoice_no",
|
|
|
|
|
|
"attachment.buyer_name",
|
|
|
|
|
|
"attachment.goods_name",
|
|
|
|
|
|
"attachment.ocr_text",
|
|
|
|
|
|
}
|
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
matched.sort(key=lambda item: item[0], reverse=True)
|
|
|
|
|
|
deduped: list[RiskRuleField] = []
|
|
|
|
|
|
seen: set[str] = set()
|
|
|
|
|
|
for _, field in matched:
|
|
|
|
|
|
if field.key in seen:
|
|
|
|
|
|
continue
|
|
|
|
|
|
seen.add(field.key)
|
|
|
|
|
|
deduped.append(field)
|
|
|
|
|
|
if deduped:
|
2026-05-26 09:15:14 +08:00
|
|
|
|
return deduped[:10]
|
2026-05-23 19:54:42 +08:00
|
|
|
|
return candidates[:4]
|
|
|
|
|
|
|
|
|
|
|
|
@staticmethod
|
|
|
|
|
|
def _score_field_match(field: RiskRuleField, text: str, normalized: str) -> int:
|
|
|
|
|
|
score = 0
|
|
|
|
|
|
if field.label in text:
|
|
|
|
|
|
score += 8
|
|
|
|
|
|
for alias in field.aliases:
|
|
|
|
|
|
if alias.lower() in normalized:
|
|
|
|
|
|
score += 4 + min(len(alias), 6)
|
|
|
|
|
|
|
|
|
|
|
|
if field.key == "attachment.hotel_city" and any(term in text for term in ("酒店", "住宿")):
|
|
|
|
|
|
score += 12
|
|
|
|
|
|
if field.key == "attachment.route_cities" and any(
|
|
|
|
|
|
term in text for term in ("行程", "交通票", "路线", "途经")
|
|
|
|
|
|
):
|
|
|
|
|
|
score += 10
|
2026-05-26 09:15:14 +08:00
|
|
|
|
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
|
2026-05-23 19:54:42 +08:00
|
|
|
|
if field.key == "claim.location" and any(
|
|
|
|
|
|
term in text for term in ("申报目的地", "申报地点", "目的地", "出差地")
|
|
|
|
|
|
):
|
|
|
|
|
|
score += 10
|
|
|
|
|
|
if field.key.startswith("attachment.") and any(term in text for term in ("发票", "票据")):
|
|
|
|
|
|
score += 2
|
|
|
|
|
|
return score
|
|
|
|
|
|
|
|
|
|
|
|
def _align_draft_fields(
|
|
|
|
|
|
self,
|
|
|
|
|
|
draft: dict[str, Any],
|
|
|
|
|
|
*,
|
|
|
|
|
|
natural_language: str,
|
2026-05-26 09:15:14 +08:00
|
|
|
|
risk_level: str,
|
2026-05-23 19:54:42 +08:00
|
|
|
|
fields: list[RiskRuleField],
|
|
|
|
|
|
) -> dict[str, Any]:
|
2026-05-26 09:15:14 +08:00
|
|
|
|
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,
|
|
|
|
|
|
)
|
|
|
|
|
|
|
2026-05-23 19:54:42 +08:00
|
|
|
|
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
|
|
|
|
|
|
]
|
2026-05-26 09:15:14 +08:00
|
|
|
|
if draft.get("template_key") == COMPOSITE_RULE_TEMPLATE_KEY:
|
|
|
|
|
|
return {**draft, "field_keys": original_keys or [field.key for field in fields[:8]]}
|
|
|
|
|
|
|
2026-05-23 19:54:42 +08:00
|
|
|
|
preferred_keys: list[str] = []
|
|
|
|
|
|
|
|
|
|
|
|
def add_preferred(key: str, *terms: str) -> None:
|
|
|
|
|
|
if key in field_by_key and any(term in natural_language for term in terms):
|
|
|
|
|
|
preferred_keys.append(key)
|
|
|
|
|
|
|
|
|
|
|
|
add_preferred("attachment.hotel_city", "酒店", "住宿")
|
|
|
|
|
|
add_preferred("claim.location", "申报目的地", "申报地点", "目的地", "出差地")
|
|
|
|
|
|
add_preferred("attachment.route_cities", "行程", "交通票", "路线", "途经")
|
|
|
|
|
|
|
|
|
|
|
|
merged_keys: list[str] = []
|
|
|
|
|
|
for key in [*preferred_keys, *original_keys, *[field.key for field in fields]]:
|
|
|
|
|
|
if key in field_by_key and key not in merged_keys:
|
|
|
|
|
|
merged_keys.append(key)
|
|
|
|
|
|
if len(merged_keys) >= 4:
|
|
|
|
|
|
break
|
|
|
|
|
|
|
|
|
|
|
|
if draft.get("template_key") == "field_compare_v1" and len(merged_keys) < 2:
|
|
|
|
|
|
for field in fields:
|
|
|
|
|
|
if field.key not in merged_keys:
|
|
|
|
|
|
merged_keys.append(field.key)
|
|
|
|
|
|
if len(merged_keys) >= 2:
|
|
|
|
|
|
break
|
|
|
|
|
|
|
|
|
|
|
|
aligned = {**draft, "field_keys": merged_keys}
|
|
|
|
|
|
selected_fields = [field_by_key[key] for key in merged_keys if key in field_by_key]
|
|
|
|
|
|
if selected_fields:
|
|
|
|
|
|
aligned["condition_summary"] = self._build_condition_summary(
|
|
|
|
|
|
natural_language,
|
|
|
|
|
|
template_key=str(aligned.get("template_key") or "field_required_v1"),
|
|
|
|
|
|
fields=selected_fields,
|
|
|
|
|
|
)
|
|
|
|
|
|
flow = aligned.get("flow") if isinstance(aligned.get("flow"), dict) else {}
|
|
|
|
|
|
aligned["flow"] = {
|
|
|
|
|
|
**flow,
|
|
|
|
|
|
"evidence": "读取" + "、".join(field.label for field in selected_fields[:3]),
|
|
|
|
|
|
"decision": aligned["condition_summary"],
|
|
|
|
|
|
}
|
|
|
|
|
|
return aligned
|
|
|
|
|
|
|
|
|
|
|
|
@staticmethod
|
|
|
|
|
|
def _build_compare_conditions(field_keys: list[str]) -> list[dict[str, str]]:
|
|
|
|
|
|
if len(field_keys) >= 2:
|
|
|
|
|
|
return [{"left": field_keys[0], "operator": "overlap", "right": field_keys[1]}]
|
|
|
|
|
|
if field_keys:
|
|
|
|
|
|
return [{"left": field_keys[0], "operator": "is_empty", "right": ""}]
|
|
|
|
|
|
return []
|
|
|
|
|
|
|
|
|
|
|
|
@staticmethod
|
|
|
|
|
|
def _infer_template_key(text: str) -> str:
|
2026-05-30 15:46:51 +08:00
|
|
|
|
if any(keyword in text for keyword in ("超过", "超出", "超预算", "预算", "阈值", "早于", "晚于", "范围")):
|
|
|
|
|
|
return COMPOSITE_RULE_TEMPLATE_KEY
|
2026-05-23 19:54:42 +08:00
|
|
|
|
if any(
|
|
|
|
|
|
keyword in text
|
|
|
|
|
|
for keyword in ("一致", "匹配", "相同", "不一致", "不符", "对应", "出现在")
|
|
|
|
|
|
):
|
|
|
|
|
|
return "field_compare_v1"
|
|
|
|
|
|
if any(
|
|
|
|
|
|
keyword in text
|
|
|
|
|
|
for keyword in ("关键词", "包含", "出现", "品名", "摘要", "服务费", "咨询费")
|
|
|
|
|
|
):
|
|
|
|
|
|
return "keyword_match_v1"
|
|
|
|
|
|
return "field_required_v1"
|
|
|
|
|
|
|
|
|
|
|
|
@staticmethod
|
|
|
|
|
|
def _infer_keywords(text: str) -> list[str]:
|
|
|
|
|
|
quoted = re.findall(r"[“\"']([^“”\"']{2,20})[”\"']", text)
|
|
|
|
|
|
keywords = [item.strip() for item in quoted if item.strip()]
|
|
|
|
|
|
for candidate in ("咨询费", "服务费", "其他", "办公用品", "招待", "红冲", "作废"):
|
|
|
|
|
|
if candidate in text and candidate not in keywords:
|
|
|
|
|
|
keywords.append(candidate)
|
|
|
|
|
|
return keywords[:8]
|
|
|
|
|
|
|
|
|
|
|
|
@staticmethod
|
|
|
|
|
|
def _infer_rule_name(text: str) -> str:
|
|
|
|
|
|
normalized = re.sub(r"\s+", "", str(text or ""))
|
|
|
|
|
|
normalized = re.sub(r"[,。;;::、,.!?!?]", "", normalized)
|
|
|
|
|
|
if not normalized:
|
|
|
|
|
|
return "自然语言风险规则"
|
|
|
|
|
|
return f"{normalized[:18]}风险规则"
|
|
|
|
|
|
|
|
|
|
|
|
@staticmethod
|
|
|
|
|
|
def _build_condition_summary(
|
|
|
|
|
|
natural_language: str,
|
|
|
|
|
|
*,
|
|
|
|
|
|
template_key: str,
|
|
|
|
|
|
fields: list[RiskRuleField],
|
|
|
|
|
|
) -> str:
|
|
|
|
|
|
field_text = "、".join(item.label for item in fields[:3]) or "业务字段"
|
|
|
|
|
|
if template_key == "field_compare_v1":
|
|
|
|
|
|
return f"对比{field_text}之间是否一致或存在交集"
|
|
|
|
|
|
if template_key == "keyword_match_v1":
|
|
|
|
|
|
return f"检查{field_text}是否出现规则描述中的风险关键词"
|
|
|
|
|
|
return f"检查{field_text}是否满足必填和完整性要求"
|
|
|
|
|
|
|
|
|
|
|
|
@staticmethod
|
|
|
|
|
|
def _clean_text(value: Any) -> str:
|
|
|
|
|
|
return re.sub(r"\s+", " ", str(value or "")).strip()
|
|
|
|
|
|
|
|
|
|
|
|
@staticmethod
|
|
|
|
|
|
def _extract_json_object(text: str) -> str:
|
|
|
|
|
|
normalized = re.sub(r"^```(?:json)?|```$", "", str(text or "").strip(), flags=re.IGNORECASE)
|
|
|
|
|
|
start = normalized.find("{")
|
|
|
|
|
|
end = normalized.rfind("}")
|
|
|
|
|
|
if start < 0 or end <= start:
|
|
|
|
|
|
raise ValueError("JSON object not found.")
|
|
|
|
|
|
return normalized[start : end + 1]
|