from __future__ import annotations import re from calendar import monthrange from datetime import date, datetime, timedelta from typing import Any from app.models.financial_record import ExpenseClaim from app.services.risk_rule_execution_trace import build_risk_rule_execution_trace from app.services.risk_rule_generation_interpreter import COMPOSITE_RULE_TEMPLATE_KEY from app.services.risk_rule_value_compare import compare_numbers, duplicate_text_values, parse_number_value CITY_CONSISTENCY_SEMANTIC_TYPES = { "travel_city_consistency", "travel_route_city_consistency", } ROUTE_CITY_SPLIT_PATTERN = re.compile(r"\s*(?:至|到|→|->|-|-|—|~|~|/|、|,|,|;|;)\s*") class RiskRuleTemplateExecutor: def evaluate_with_trace( self, manifest: dict[str, Any], *, claim: ExpenseClaim, contexts: list[dict[str, Any]], ) -> dict[str, Any]: result = self.evaluate(manifest, claim=claim, contexts=contexts) return { "hit": result is not None, "result": result, "trace": build_risk_rule_execution_trace(manifest, result=result), } def evaluate( self, manifest: dict[str, Any], *, claim: ExpenseClaim, contexts: list[dict[str, Any]], ) -> dict[str, Any] | None: params = manifest.get("params") if isinstance(manifest.get("params"), dict) else {} template_key = str(manifest.get("template_key") or params.get("template_key") or "").strip() if template_key == "field_required_v1": return self._evaluate_required_fields(params, claim=claim, contexts=contexts) if template_key == "field_compare_v1": if str(params.get("semantic_type") or "").strip() in CITY_CONSISTENCY_SEMANTIC_TYPES: return self._evaluate_city_consistency_rule( params, claim=claim, contexts=contexts, ) return self._evaluate_compare_conditions(params, claim=claim, contexts=contexts) if template_key == "keyword_match_v1": return self._evaluate_keyword_match(params, claim=claim, contexts=contexts) if template_key == COMPOSITE_RULE_TEMPLATE_KEY: return self._evaluate_composite_rule(params, claim=claim, contexts=contexts) return None def _evaluate_required_fields( self, params: dict[str, Any], *, claim: ExpenseClaim, contexts: list[dict[str, Any]], ) -> dict[str, Any] | None: required_fields = self._read_string_list( params.get("required_fields") or params.get("field_keys") ) missing = [ field_key for field_key in required_fields if not self._resolve_values(field_key, claim=claim, contexts=contexts) ] if not missing: return None return { "message": self._resolve_message( params, fallback=f"规则要求的字段未完整提供:{'、'.join(missing[:4])}。", ), "evidence": { "missing_fields": missing, "condition_summary": params.get("condition_summary"), }, } def _evaluate_compare_conditions( self, params: dict[str, Any], *, claim: ExpenseClaim, contexts: list[dict[str, Any]], ) -> dict[str, Any] | None: conditions = params.get("conditions") if isinstance(params.get("conditions"), list) else [] failures: list[dict[str, Any]] = [] for index, condition in enumerate(conditions, start=1): if not isinstance(condition, dict): continue condition_id = str(condition.get("id") or f"condition_{index}").strip() left_key = str(condition.get("left") or "").strip() right_key = str(condition.get("right") or "").strip() operator = str(condition.get("operator") or "not_overlap").strip() left_values = self._resolve_values(left_key, claim=claim, contexts=contexts) right_values = self._resolve_values(right_key, claim=claim, contexts=contexts) if self._condition_passes(operator, left_values, right_values): continue failures.append( { "left": left_key, "id": condition_id, "operator": operator, "right": right_key, "left_values": left_values[:5], "right_values": right_values[:5], } ) if not failures: return None return { "message": self._resolve_message( params, fallback=( "规则字段对比未通过:" f"{params.get('condition_summary') or '字段关系不符合要求'}。" ), ), "evidence": { "failed_conditions": failures[:5], "condition_summary": params.get("condition_summary"), }, } def _evaluate_keyword_match( self, params: dict[str, Any], *, claim: ExpenseClaim, contexts: list[dict[str, Any]], ) -> dict[str, Any] | None: if self._looks_like_city_consistency_rule(params): return self._evaluate_city_consistency_rule( params, claim=claim, contexts=contexts, ) keywords = self._read_string_list(params.get("keywords")) search_fields = self._read_string_list( params.get("search_fields") or params.get("field_keys") ) if not keywords: return None corpus_parts: list[str] = [] for field_key in search_fields: corpus_parts.extend(self._resolve_values(field_key, claim=claim, contexts=contexts)) if not corpus_parts: corpus_parts.extend( [ str(claim.reason or ""), str(claim.location or ""), *[str(item.item_reason or "") for item in list(claim.items or [])], *[str(context.get("ocr_text") or "") for context in contexts], ] ) corpus = "\n".join(corpus_parts) hits = [keyword for keyword in keywords if keyword and keyword in corpus] if not hits: return None return { "message": self._resolve_message( params, fallback=f"识别到风险关键词:{'、'.join(hits[:5])}。", ), "evidence": { "keyword_hits": hits[:8], "search_fields": search_fields, "condition_summary": params.get("condition_summary"), }, } def _evaluate_city_consistency_rule( self, params: dict[str, Any], *, claim: ExpenseClaim, contexts: list[dict[str, Any]], ) -> dict[str, Any] | None: field_keys = self._read_string_list(params.get("search_fields") or params.get("field_keys")) reference_keys = self._read_string_list(params.get("reference_city_fields")) or [ key for key in field_keys if key in {"claim.location", "item.item_location"} ] or ["claim.location", "item.item_location"] attachment_keys = self._read_string_list(params.get("attachment_city_fields")) or [ key for key in field_keys if key in {"attachment.route_cities", "attachment.hotel_city"} ] or ["attachment.route_cities", "attachment.hotel_city"] home_keys = self._read_string_list(params.get("home_city_fields")) or ["employee.location"] reference_values: list[str] = [] attachment_values: list[str] = [] home_values: list[str] = [] route_values: list[str] = [] for key in reference_keys: reference_values.extend(self._resolve_values(key, claim=claim, contexts=contexts)) for key in attachment_keys: resolved = self._resolve_values(key, claim=claim, contexts=contexts) attachment_values.extend(resolved) if key == "attachment.route_cities": route_values.extend(resolved) for key in home_keys: home_values.extend(self._resolve_values(key, claim=claim, contexts=contexts)) reference_values = self._dedupe_values(reference_values) attachment_values = self._dedupe_values(attachment_values) home_values = self._dedupe_values(home_values) route_values = self._dedupe_values(route_values) if not reference_values or not attachment_values: return None explanation_keywords = self._read_string_list( params.get("exception_keywords") or params.get("keywords") ) exception_fields = self._read_string_list(params.get("exception_fields")) or [ "claim.reason", "item.item_reason", ] explanation_corpus = "\n".join( value for key in exception_fields for value in self._resolve_values(key, claim=claim, contexts=contexts) ) keyword_hits = [ keyword for keyword in explanation_keywords if keyword and keyword in explanation_corpus ] unexpected_route_cities = self._resolve_unexpected_route_cities( route_values, reference_values=reference_values, home_values=home_values, ) has_destination_overlap = self._condition_passes( "overlap", attachment_values, reference_values, ) if not unexpected_route_cities and (has_destination_overlap or keyword_hits): return None reason = ( "票据路线包含申报行程和常驻地之外的中转城市。" if unexpected_route_cities else "票据城市与申报目的地或明细地点不一致,且未说明绕行、跨城或改签原因。" ) return { "message": self._resolve_message( params, fallback=reason, ), "evidence": { "failed_conditions": [ { "left": "attachment.city", "operator": "overlap", "right": "claim.location", "left_values": attachment_values[:5], "right_values": reference_values[:5], } ], "condition_summary": params.get("condition_summary"), "formula": params.get("formula"), "condition_results": { "city_evidence_present": bool(attachment_values and reference_values), "destination_overlap": has_destination_overlap, "unexpected_route_city": bool(unexpected_route_cities), "reasonable_exception": bool(keyword_hits), }, "city_consistency": { "attachment_values": attachment_values[:8], "reference_values": reference_values[:8], "home_values": home_values[:8], "route_values": route_values[:8], "unexpected_route_cities": unexpected_route_cities[:8], "explanation_keywords": explanation_keywords[:8], "explanation_hits": keyword_hits[:8], }, }, } def _evaluate_composite_rule( self, params: dict[str, Any], *, claim: ExpenseClaim, contexts: list[dict[str, Any]], ) -> dict[str, Any] | None: conditions = params.get("conditions") if isinstance(params.get("conditions"), list) else [] condition_evidence: list[dict[str, Any]] = [] condition_results: dict[str, bool] = {} for index, condition in enumerate(conditions): if not isinstance(condition, dict): continue condition_id = str(condition.get("id") or f"condition_{index + 1}").strip() passed, evidence = self._evaluate_composite_condition( condition, claim=claim, contexts=contexts, ) condition_results[condition_id] = passed condition_evidence.append({"id": condition_id, **evidence, "passed": passed}) hit_logic = params.get("hit_logic") hit = ( self._evaluate_logic_node(hit_logic, condition_results) if isinstance(hit_logic, (dict, list, str)) else bool(condition_results) and all(condition_results.values()) ) if not hit: return None return { "message": self._resolve_message( params, fallback=str(params.get("condition_summary") or "复合规则条件命中,进入人工复核。"), ), "evidence": { "condition_summary": params.get("condition_summary"), "formula": params.get("formula"), "semantic_type": params.get("semantic_type"), "conditions": condition_evidence, "condition_results": condition_results, "hit_logic": hit_logic, "rule_ir": params.get("rule_ir") if isinstance(params.get("rule_ir"), dict) else {}, }, } def _evaluate_composite_condition( self, condition: dict[str, Any], *, claim: ExpenseClaim, contexts: list[dict[str, Any]], ) -> tuple[bool, dict[str, Any]]: operator = str(condition.get("operator") or "").strip() fields = self._read_string_list(condition.get("fields")) left_fields = self._read_string_list(condition.get("left_fields")) right_fields = self._read_string_list(condition.get("right_fields")) if operator == "exists_any": values = self._resolve_group_values(fields, claim=claim, contexts=contexts) return bool(values), {"operator": operator, "fields": fields, "values": values[:8]} if operator in {"exists_all", "all_present"}: missing = [ key for key in fields if not self._resolve_values(key, claim=claim, contexts=contexts) ] return not missing, {"operator": operator, "fields": fields, "missing_fields": missing} if operator in {"not_in_scope", "not_in_set", "not_overlap"}: left_values = self._resolve_group_values(left_fields, claim=claim, contexts=contexts) right_values = self._resolve_group_values(right_fields, claim=claim, contexts=contexts) matched = self._values_overlap(left_values, right_values) return bool(left_values and right_values and not matched), { "operator": operator, "left_fields": left_fields, "right_fields": right_fields, "left_values": left_values[:8], "right_values": right_values[:8], } if operator in {"in_scope", "overlap"}: left_values = self._resolve_group_values(left_fields, claim=claim, contexts=contexts) right_values = self._resolve_group_values(right_fields, claim=claim, contexts=contexts) return self._values_overlap(left_values, right_values), { "operator": operator, "left_fields": left_fields, "right_fields": right_fields, "left_values": left_values[:8], "right_values": right_values[:8], } if operator == "date_outside_range": return self._evaluate_date_outside_range(condition, claim=claim, contexts=contexts) if operator == "numeric_compare": return self._evaluate_numeric_compare(condition, claim=claim, contexts=contexts) if operator == "duplicate_value": values = [ value for key in fields for value in self._resolve_values(key, claim=claim, contexts=contexts) ] duplicates = duplicate_text_values(values) evidence = {"operator": operator, "fields": fields, "values": values[:8], "duplicates": duplicates[:8]} return bool(duplicates), evidence if operator in {"not_contains_any", "contains_any"}: keywords = self._read_string_list(condition.get("keywords")) values = self._resolve_group_values(fields, claim=claim, contexts=contexts) corpus = "\n".join(values) hits = [keyword for keyword in keywords if keyword and keyword in corpus] passed = not hits if operator == "not_contains_any" else bool(hits) return passed, { "operator": operator, "fields": fields, "keyword_hits": hits[:8], "values": values[:8], } left = str(condition.get("left") or "").strip() right = str(condition.get("right") or "").strip() if left: left_values = self._resolve_values(left, claim=claim, contexts=contexts) right_values = self._resolve_values(right, claim=claim, contexts=contexts) if right else [] passed = self._condition_passes(operator or "overlap", left_values, right_values) return passed, { "operator": operator or "overlap", "left": left, "right": right, "left_values": left_values[:8], "right_values": right_values[:8], } return False, {"operator": operator or "unknown"} def _evaluate_date_outside_range( self, condition: dict[str, Any], *, claim: ExpenseClaim, contexts: list[dict[str, Any]], ) -> tuple[bool, dict[str, Any]]: date_fields = self._read_string_list(condition.get("date_fields")) start_fields = self._read_string_list(condition.get("range_start_fields")) end_fields = self._read_string_list(condition.get("range_end_fields")) tolerance_days = int(condition.get("tolerance_days") or 0) dates = self._resolve_group_dates(date_fields, claim=claim, contexts=contexts) starts = self._resolve_group_dates(start_fields, claim=claim, contexts=contexts) ends = self._resolve_group_dates(end_fields, claim=claim, contexts=contexts) if not dates or not (starts or ends): return False, { "operator": "date_outside_range", "date_fields": date_fields, "range_start_fields": start_fields, "range_end_fields": end_fields, "dates": [item.isoformat() for item in dates], "range_start": None, "range_end": None, } start = min(starts or ends) - timedelta(days=tolerance_days) end = max(ends or starts) + timedelta(days=tolerance_days) outside = [item for item in dates if item < start or item > end] return bool(outside), { "operator": "date_outside_range", "date_fields": date_fields, "range_start_fields": start_fields, "range_end_fields": end_fields, "dates": [item.isoformat() for item in dates], "range_start": start.isoformat(), "range_end": end.isoformat(), "outside_dates": [item.isoformat() for item in outside], } def _evaluate_numeric_compare( self, condition: dict[str, Any], *, claim: ExpenseClaim, contexts: list[dict[str, Any]], ) -> tuple[bool, dict[str, Any]]: left_fields = self._read_string_list(condition.get("left_fields") or condition.get("fields")) right_fields = self._read_string_list(condition.get("right_fields")) left_numbers = self._resolve_group_numbers(left_fields, claim=claim, contexts=contexts) right_numbers = self._resolve_group_numbers(right_fields, claim=claim, contexts=contexts) threshold = parse_number_value(condition.get("threshold") or condition.get("value")) if threshold is not None: right_numbers.append(threshold) compare = str(condition.get("compare") or condition.get("comparator") or "gt").strip().lower() passed = any( compare_numbers(left, right, compare) for left in left_numbers for right in right_numbers ) return passed, { "operator": "numeric_compare", "compare": compare, "left_fields": left_fields, "right_fields": right_fields, "left_values": left_numbers[:8], "right_values": right_numbers[:8], } def _resolve_group_values( self, field_keys: list[str], *, claim: ExpenseClaim, contexts: list[dict[str, Any]], ) -> list[str]: values: list[str] = [] for key in field_keys: values.extend(self._resolve_values(key, claim=claim, contexts=contexts)) return self._dedupe_values(values) def _resolve_group_dates( self, field_keys: list[str], *, claim: ExpenseClaim, contexts: list[dict[str, Any]], ) -> list[date]: values: list[date] = [] 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) return values def _resolve_group_numbers( self, field_keys: list[str], *, claim: ExpenseClaim, contexts: list[dict[str, Any]], ) -> list[float]: values: list[float] = [] for key in field_keys: for value in self._resolve_values(key, claim=claim, contexts=contexts): parsed = parse_number_value(value) if parsed is not None and parsed not in values: values.append(parsed) return values @staticmethod def _evaluate_logic_node(node: Any, condition_results: dict[str, bool]) -> bool: if isinstance(node, str): return bool(condition_results.get(node)) if isinstance(node, list): return all(RiskRuleTemplateExecutor._evaluate_logic_node(item, condition_results) for item in node) if not isinstance(node, dict): return bool(node) if "all" in node: values = node.get("all") if isinstance(node.get("all"), list) else [] return all(RiskRuleTemplateExecutor._evaluate_logic_node(item, condition_results) for item in values) if "any" in node: values = node.get("any") if isinstance(node.get("any"), list) else [] return any(RiskRuleTemplateExecutor._evaluate_logic_node(item, condition_results) for item in values) if "not" in node: return not RiskRuleTemplateExecutor._evaluate_logic_node(node.get("not"), condition_results) return False @staticmethod def _looks_like_city_consistency_rule(params: dict[str, Any]) -> bool: field_keys = RiskRuleTemplateExecutor._read_string_list( params.get("search_fields") or params.get("field_keys") ) if str(params.get("semantic_type") or "").strip() in CITY_CONSISTENCY_SEMANTIC_TYPES: return True has_reference = any(key in {"claim.location", "item.item_location"} for key in field_keys) has_attachment_city = any( key in {"attachment.route_cities", "attachment.hotel_city"} for key in field_keys ) if not (has_reference and has_attachment_city): return False text = "\n".join( str(params.get(key) or "") for key in ("natural_language", "condition_summary", "message_template") ) consistency_terms = ("一致", "不一致", "匹配", "不符", "对应", "出现在") city_terms = ("城市", "地点", "目的地", "行程", "票据", "发票") return any(term in text for term in consistency_terms) and any( term in text for term in city_terms ) def _resolve_values( self, field_key: str, *, claim: ExpenseClaim, contexts: list[dict[str, Any]], ) -> list[str]: normalized = str(field_key or "").strip() if not normalized: return [] if normalized == "claim.trip_start_date": explicit = getattr(claim, "trip_start_date", None) return self._normalize_values([explicit or self._claim_trip_date(claim, start=True)]) if normalized == "claim.trip_end_date": explicit = getattr(claim, "trip_end_date", None) return self._normalize_values([explicit or self._claim_trip_date(claim, start=False)]) if normalized.startswith("claim."): return self._normalize_values([getattr(claim, normalized.removeprefix("claim."), "")]) if normalized.startswith("item."): attr = normalized.removeprefix("item.") return self._normalize_values( [getattr(item, attr, "") for item in list(claim.items or [])] ) if normalized.startswith("employee."): employee = getattr(claim, "employee", None) if employee is None: return [] return self._normalize_values( [getattr(employee, normalized.removeprefix("employee."), "")] ) if normalized.startswith("attachment."): return self._resolve_attachment_values(normalized.removeprefix("attachment."), contexts) if normalized.startswith("budget."): return self._resolve_budget_values(normalized.removeprefix("budget."), contexts) return [] @staticmethod def _resolve_unexpected_route_cities( route_values: list[str], *, reference_values: list[str], home_values: list[str], ) -> list[str]: if len(route_values) < 2: return [] allowed_values = [value for value in [*reference_values, *home_values] if value] if not allowed_values: return [] candidates = route_values if home_values else route_values[1:-1] unexpected: list[str] = [] for city in candidates: if RiskRuleTemplateExecutor._values_overlap([city], allowed_values): continue if city not in unexpected: unexpected.append(city) return unexpected @staticmethod def _expand_route_city_values(values: list[Any]) -> list[Any]: expanded: list[Any] = [] for value in values: if isinstance(value, (list, tuple, set)): expanded.extend(RiskRuleTemplateExecutor._expand_route_city_values(list(value))) continue text = str(value or "").strip() if not text: continue parts = [part.strip() for part in ROUTE_CITY_SPLIT_PATTERN.split(text) if part.strip()] expanded.extend(parts if len(parts) >= 2 else [text]) return expanded def _resolve_attachment_values( self, field_key: str, contexts: list[dict[str, Any]] ) -> list[str]: values: list[Any] = [] for context in contexts: document_info = context.get("document_info") if isinstance(context, dict) else {} if not isinstance(document_info, dict): document_info = {} if field_key == "ocr_text": values.extend([context.get("ocr_text"), context.get("ocr_summary")]) if field_key == "hotel_city": specific_values = self._scan_document_values(document_info, field_key) values.extend( specific_values if specific_values else self._scan_document_values(document_info, "city") ) elif field_key == "route_cities": values.extend(self._expand_route_city_values(self._scan_document_values(document_info, field_key))) else: values.extend(self._scan_document_values(document_info, field_key)) return self._normalize_values(values) def _resolve_budget_values(self, field_key: str, contexts: list[dict[str, Any]]) -> list[str]: values: list[Any] = [] for context in contexts: if not isinstance(context, dict): continue budget_context = context.get("budget_context") if not isinstance(budget_context, dict): continue for key in {field_key, field_key.replace("_", ""), field_key.replace("-", "_")}: if key in budget_context: values.append(budget_context.get(key)) return self._normalize_values(values) def _scan_document_values(self, document_info: dict[str, Any], field_key: str) -> list[Any]: values: list[Any] = [] for key in {field_key, field_key.replace("_", ""), field_key.replace("_", "-")}: if key in document_info: values.append(document_info.get(key)) for field in list(document_info.get("fields") or []): if not isinstance(field, dict): continue key = str(field.get("key") or "").strip().lower() label = str(field.get("label") or "").strip() if self._field_matches(key, label, field_key): values.append(field.get("value")) return values @staticmethod def _field_matches(key: str, label: str, field_key: str) -> bool: compact_key = key.replace("_", "") compact_target = field_key.replace("_", "") if compact_target in compact_key: return True label_map = { "invoice_no": ("发票号", "发票号码", "票号"), "buyer_name": ("购买方", "抬头", "买方"), "goods_name": ("品名", "商品", "服务名称"), "issue_date": ("日期", "开票日期", "发票日期"), "stay_start_date": ("入住日期", "住宿开始", "入住时间", "开始日期"), "stay_end_date": ("离店日期", "退房日期", "住宿结束", "结束日期"), "hotel_city": ("住宿城市", "酒店城市", "酒店地点", "住宿", "酒店"), "route_cities": ("行程", "路线", "目的地", "出差城市"), "city": ("城市", "地点"), } return any(item in label for item in label_map.get(field_key, ())) @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 []) if getattr(item, "item_date", None) is not None ] if item_dates: 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"(? 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"(? bool: if operator == "is_empty": return not left_values if not left_values or not right_values: return False left_set = {value.lower() for value in left_values} right_set = {value.lower() for value in right_values} if operator in {"equals", "in", "overlap"}: return RiskRuleTemplateExecutor._values_overlap(left_values, right_values) if operator in {"not_equals", "not_in", "not_overlap"}: return not RiskRuleTemplateExecutor._values_overlap(left_values, right_values) if operator == "contains_any": return any(any(right in left for right in right_set) for left in left_set) return bool(left_set & right_set) @staticmethod def _values_overlap(left_values: list[str], right_values: list[str]) -> bool: left_set = [RiskRuleTemplateExecutor._normalize_match_value(value) for value in left_values] right_set = [RiskRuleTemplateExecutor._normalize_match_value(value) for value in right_values] for left in left_set: for right in right_set: if left and right and (left == right or left in right or right in left): return True return False @staticmethod def _normalize_match_value(value: str) -> str: return re.sub(r"[省市区县\s]+$", "", str(value or "").strip().lower()) @staticmethod def _parse_date_value(value: Any) -> date | None: if isinstance(value, datetime): return value.date() if isinstance(value, date): return value text = str(value or "").strip() if not text: return None iso_match = re.search(r"(\d{4})[-/.](\d{1,2})[-/.](\d{1,2})", text) cn_match = re.search(r"(\d{4})年(\d{1,2})月(\d{1,2})日", text) match = iso_match or cn_match if match: year, month, day = (int(part) for part in match.groups()) try: return date(year, month, day) except ValueError: return None try: return date.fromisoformat(text[:10]) except ValueError: return None @staticmethod def _normalize_values(values: list[Any]) -> list[str]: normalized: list[str] = [] for value in values: if isinstance(value, (list, tuple, set)): normalized.extend(RiskRuleTemplateExecutor._normalize_values(list(value))) continue text = re.sub(r"\s+", " ", str(value or "")).strip() if text: normalized.append(text) return normalized @staticmethod def _dedupe_values(values: list[str]) -> list[str]: deduped: list[str] = [] for value in values: text = str(value or "").strip() if text and text not in deduped: deduped.append(text) return deduped @staticmethod 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()] @staticmethod def _resolve_message(params: dict[str, Any], *, fallback: str) -> str: template = str(params.get("message_template") or "").strip() return template or fallback