diff --git a/server/src/app/services/expense_rule_runtime.py b/server/src/app/services/expense_rule_runtime.py new file mode 100644 index 0000000..bc09740 --- /dev/null +++ b/server/src/app/services/expense_rule_runtime.py @@ -0,0 +1,660 @@ +from __future__ import annotations + +import json +import re +from dataclasses import dataclass, field +from decimal import Decimal +from typing import Any, Literal + +from pydantic import BaseModel, Field, ValidationError +from sqlalchemy import select +from sqlalchemy.orm import Session + +from app.core.agent_enums import AgentAssetDomain, AgentAssetStatus, AgentAssetType +from app.models.agent_asset import AgentAsset, AgentAssetVersion + +EXPENSE_RULE_CODE_BLOCK_PATTERN = re.compile(r"```expense-rule\s*(\{.*?\})\s*```", re.DOTALL) + +DOCUMENT_TYPE_LABELS = { + "flight_itinerary": "机票/航班行程单", + "train_ticket": "火车/高铁票", + "hotel_invoice": "酒店住宿票据", + "taxi_receipt": "出租车/网约车票据", + "parking_toll_receipt": "停车/通行费票据", + "meal_receipt": "餐饮票据", + "office_invoice": "办公用品票据", + "meeting_invoice": "会议/会务票据", + "training_invoice": "培训票据", + "vat_invoice": "增值税发票", + "receipt": "一般收据/凭证", + "other": "其他单据", +} + +SCENE_LABELS = { + "travel": "差旅", + "hotel": "住宿", + "transport": "交通", + "meal": "餐饮", + "entertainment": "业务招待", + "office": "办公", + "meeting": "会务", + "training": "培训", + "communication": "通讯", + "welfare": "福利", + "other": "其他", +} + +DEFAULT_SCENE_RULE_ASSET_CODE = "rule.expense.scene_submission_standard" +DEFAULT_TRAVEL_RULE_ASSET_CODE = "rule.expense.travel_risk_control_standard" + +DEFAULT_SCENE_MATRIX_CONFIG: dict[str, Any] = { + "kind": "scene_matrix", + "version": 1, + "scenes": { + "travel": { + "label": "差旅费", + "location_required": True, + "min_attachment_count": 1, + "allowed_scene_codes": ["travel"], + "allowed_document_types": ["flight_itinerary", "train_ticket"], + "attachment_mismatch_severity": "high", + }, + "hotel": { + "label": "住宿费", + "location_required": False, + "min_attachment_count": 1, + "allowed_scene_codes": ["hotel"], + "allowed_document_types": ["hotel_invoice", "vat_invoice", "receipt"], + "attachment_mismatch_severity": "high", + }, + "transport": { + "label": "交通费", + "location_required": False, + "min_attachment_count": 1, + "allowed_scene_codes": ["transport"], + "allowed_document_types": ["taxi_receipt", "parking_toll_receipt", "vat_invoice", "receipt"], + "attachment_mismatch_severity": "high", + "item_amount_limit": { + "scope": "item_amount", + "warn_amount": "300.00", + "block_amount": "800.00", + "exception_keywords": ["跨城", "夜间", "应急", "无公共交通", "机场", "火车站", "超标说明"], + "metric_label": "单笔交通金额", + }, + }, + "meal": { + "label": "餐费", + "location_required": False, + "min_attachment_count": 1, + "allowed_scene_codes": ["meal"], + "allowed_document_types": ["meal_receipt", "vat_invoice", "receipt"], + "attachment_mismatch_severity": "high", + "claim_amount_limit": { + "scope": "claim_total", + "warn_amount": "300.00", + "block_amount": "800.00", + "exception_keywords": ["客户接待", "团队活动", "加班", "展会", "超标说明"], + "metric_label": "餐费合计", + }, + }, + "entertainment": { + "label": "业务招待费", + "location_required": True, + "min_attachment_count": 1, + "allowed_scene_codes": ["meal"], + "allowed_document_types": ["meal_receipt", "vat_invoice", "receipt"], + "attachment_mismatch_severity": "high", + "claim_amount_limit": { + "scope": "claim_total", + "warn_amount": "2000.00", + "block_amount": "5000.00", + "exception_keywords": ["重要客户", "商务宴请", "项目签约", "超标说明"], + "metric_label": "招待费合计", + }, + }, + "office": { + "label": "办公费", + "location_required": False, + "min_attachment_count": 1, + "allowed_scene_codes": ["office"], + "allowed_document_types": ["office_invoice", "vat_invoice", "receipt"], + "attachment_mismatch_severity": "high", + "claim_amount_limit": { + "scope": "claim_total", + "warn_amount": "1500.00", + "block_amount": "5000.00", + "exception_keywords": ["批量采购", "固定资产", "部门集中采购", "超标说明"], + "metric_label": "办公费合计", + }, + }, + "meeting": { + "label": "会务费", + "location_required": True, + "min_attachment_count": 1, + "allowed_scene_codes": ["meeting"], + "allowed_document_types": ["meeting_invoice", "vat_invoice", "receipt"], + "attachment_mismatch_severity": "high", + "claim_amount_limit": { + "scope": "claim_total", + "warn_amount": "5000.00", + "block_amount": "30000.00", + "exception_keywords": ["大型会议", "外部场地", "超标说明"], + "metric_label": "会务费合计", + }, + }, + "training": { + "label": "培训费", + "location_required": False, + "min_attachment_count": 1, + "allowed_scene_codes": ["training"], + "allowed_document_types": ["training_invoice", "vat_invoice", "receipt"], + "attachment_mismatch_severity": "high", + "claim_amount_limit": { + "scope": "claim_total", + "warn_amount": "3000.00", + "block_amount": "15000.00", + "exception_keywords": ["认证考试", "外部培训", "超标说明"], + "metric_label": "培训费合计", + }, + }, + "communication": { + "label": "通讯费", + "location_required": False, + "min_attachment_count": 1, + "allowed_scene_codes": ["other"], + "allowed_document_types": ["vat_invoice", "receipt"], + "attachment_mismatch_severity": "medium", + "claim_amount_limit": { + "scope": "claim_total", + "warn_amount": "300.00", + "block_amount": "1000.00", + "exception_keywords": ["国际漫游", "专项通信", "超标说明"], + "metric_label": "通讯费合计", + }, + }, + "welfare": { + "label": "福利费", + "location_required": False, + "min_attachment_count": 1, + "allowed_scene_codes": ["other"], + "allowed_document_types": ["vat_invoice", "receipt"], + "attachment_mismatch_severity": "medium", + "claim_amount_limit": { + "scope": "claim_total", + "warn_amount": "1000.00", + "block_amount": "5000.00", + "exception_keywords": ["节日福利", "团队活动", "员工关怀", "超标说明"], + "metric_label": "福利费合计", + }, + }, + "other": { + "label": "其他费用", + "location_required": False, + "min_attachment_count": 1, + "allowed_scene_codes": ["other"], + "allowed_document_types": ["vat_invoice", "receipt"], + "attachment_mismatch_severity": "medium", + "always_warn": True, + "always_warn_message": "其他费用默认进入人工重点复核,请补充清晰用途说明并由审批人重点确认。", + "claim_amount_limit": { + "scope": "claim_total", + "warn_amount": "1000.00", + "block_amount": "3000.00", + "exception_keywords": ["特殊事项", "临时采购", "超标说明"], + "metric_label": "其他费用合计", + }, + }, + }, +} + +DEFAULT_TRAVEL_POLICY_CONFIG: dict[str, Any] = { + "kind": "travel_policy", + "version": 1, + "relevant_expense_types": ["travel", "hotel", "transport"], + "long_distance_document_types": ["flight_itinerary", "train_ticket"], + "route_exception_keywords": [ + "中转", + "转机", + "经停", + "改签", + "多地出差", + "多城市", + "多站", + "异地返程", + "异地结束", + "临时变更", + "继续前往", + "第二站", + ], + "standard_exception_keywords": [ + "超标说明", + "无直达", + "展会高峰", + "会议高峰", + "协议酒店满房", + "客户指定", + "临时改签", + "行程变更", + "红眼航班", + "晚到店", + ], + "band_labels": { + "junior": "P1-P3", + "mid": "P4-P5", + "senior": "P6-P7", + "manager": "M1-M2", + "executive": "M3及以上 / D序列", + }, + "city_tiers": { + "北京": "tier_1", + "上海": "tier_1", + "广州": "tier_1", + "深圳": "tier_1", + "杭州": "tier_2", + "南京": "tier_2", + "苏州": "tier_2", + "武汉": "tier_2", + "成都": "tier_2", + "重庆": "tier_2", + "西安": "tier_2", + "天津": "tier_2", + "宁波": "tier_2", + "厦门": "tier_2", + "青岛": "tier_2", + "长沙": "tier_2", + "郑州": "tier_2", + "合肥": "tier_2", + "济南": "tier_2", + "沈阳": "tier_2", + "大连": "tier_2", + "福州": "tier_2", + "昆明": "tier_2", + "海口": "tier_2", + "三亚": "tier_2", + "无锡": "tier_2", + "东莞": "tier_2", + "佛山": "tier_2", + }, + "hotel_limits": { + "junior": {"tier_1": "450.00", "tier_2": "380.00", "tier_3": "320.00"}, + "mid": {"tier_1": "550.00", "tier_2": "480.00", "tier_3": "380.00"}, + "senior": {"tier_1": "700.00", "tier_2": "620.00", "tier_3": "520.00"}, + "manager": {"tier_1": "900.00", "tier_2": "820.00", "tier_3": "720.00"}, + "executive": {"tier_1": "1200.00", "tier_2": "1000.00", "tier_3": "900.00"}, + }, + "transport_limits": { + "junior": {"flight": 1, "train": 1}, + "mid": {"flight": 1, "train": 1}, + "senior": {"flight": 2, "train": 2}, + "manager": {"flight": 3, "train": 3}, + "executive": {"flight": 4, "train": 3}, + }, + "flight_classes": [ + {"keyword": "头等舱", "level": 4}, + {"keyword": "公务舱", "level": 3}, + {"keyword": "商务舱", "level": 3}, + {"keyword": "超级经济舱", "level": 2}, + {"keyword": "高端经济舱", "level": 2}, + {"keyword": "明珠经济舱", "level": 2}, + {"keyword": "经济舱", "level": 1}, + ], + "train_classes": [ + {"keyword": "商务座", "level": 3}, + {"keyword": "一等座", "level": 2}, + {"keyword": "软卧", "level": 2}, + {"keyword": "二等座", "level": 1}, + {"keyword": "二等卧", "level": 1}, + {"keyword": "硬卧", "level": 1}, + ], +} + + +class AmountLimitConfig(BaseModel): + scope: Literal["claim_total", "item_amount"] = "claim_total" + warn_amount: Decimal | None = None + block_amount: Decimal | None = None + exception_keywords: list[str] = Field(default_factory=list) + metric_label: str = "金额" + + +class ScenePolicyConfig(BaseModel): + label: str + location_required: bool = False + min_attachment_count: int = 1 + allowed_scene_codes: list[str] = Field(default_factory=list) + allowed_document_types: list[str] = Field(default_factory=list) + attachment_mismatch_severity: Literal["low", "medium", "high"] = "high" + claim_amount_limit: AmountLimitConfig | None = None + item_amount_limit: AmountLimitConfig | None = None + always_warn: bool = False + always_warn_message: str = "" + + +class SceneMatrixRuleConfig(BaseModel): + kind: Literal["scene_matrix"] + version: int = 1 + scenes: dict[str, ScenePolicyConfig] + + +class TravelClassConfig(BaseModel): + keyword: str + level: int + + +class TravelPolicyConfig(BaseModel): + kind: Literal["travel_policy"] + version: int = 1 + relevant_expense_types: list[str] = Field(default_factory=list) + long_distance_document_types: list[str] = Field(default_factory=list) + route_exception_keywords: list[str] = Field(default_factory=list) + standard_exception_keywords: list[str] = Field(default_factory=list) + band_labels: dict[str, str] = Field(default_factory=dict) + city_tiers: dict[str, str] = Field(default_factory=dict) + hotel_limits: dict[str, dict[str, Decimal]] = Field(default_factory=dict) + transport_limits: dict[str, dict[str, int]] = Field(default_factory=dict) + flight_classes: list[TravelClassConfig] = Field(default_factory=list) + train_classes: list[TravelClassConfig] = Field(default_factory=list) + + +class ExpenseScenePolicy(ScenePolicyConfig): + expense_type: str + rule_code: str + rule_name: str + rule_version: str + + +class RuntimeTravelPolicy(TravelPolicyConfig): + rule_code: str + rule_name: str + rule_version: str + + +@dataclass +class ExpenseRuleCatalog: + scene_policies: dict[str, ExpenseScenePolicy] = field(default_factory=dict) + travel_policy: RuntimeTravelPolicy | None = None + + def get_scene_policy(self, expense_type: str | None) -> ExpenseScenePolicy | None: + normalized = str(expense_type or "").strip().lower() or "other" + return self.scene_policies.get(normalized) + + +def resolve_document_type_label(document_type: str | None) -> str: + normalized = str(document_type or "").strip().lower() or "other" + return DOCUMENT_TYPE_LABELS.get(normalized, normalized or "其他单据") + + +def build_default_expense_rule_catalog() -> ExpenseRuleCatalog: + catalog = ExpenseRuleCatalog() + scene_matrix = SceneMatrixRuleConfig.model_validate(DEFAULT_SCENE_MATRIX_CONFIG) + for expense_type, config in scene_matrix.scenes.items(): + catalog.scene_policies[expense_type] = ExpenseScenePolicy( + expense_type=expense_type, + rule_code=DEFAULT_SCENE_RULE_ASSET_CODE, + rule_name="报销场景提交与附件标准", + rule_version="v1.0.0", + **config.model_dump(), + ) + + travel_policy = TravelPolicyConfig.model_validate(DEFAULT_TRAVEL_POLICY_CONFIG) + catalog.travel_policy = RuntimeTravelPolicy( + rule_code=DEFAULT_TRAVEL_RULE_ASSET_CODE, + rule_name="差旅报销风险管控制度", + rule_version="v1.1.0", + **travel_policy.model_dump(), + ) + return catalog + + +def build_scene_submission_standard_markdown() -> str: + scene_matrix = SceneMatrixRuleConfig.model_validate(DEFAULT_SCENE_MATRIX_CONFIG) + sections: list[str] = [ + "# 报销场景提交与附件标准", + "", + "## 模板信息", + "", + "- 模板类型:系统内置场景矩阵规则", + "- 运行时类型:`scene_matrix`", + "- 适用对象:报销提交与附件校验", + "", + "## 目标", + "", + "统一约束各报销场景的必填字段、附件类型和金额预警口径,在上传附件和提交审核两个时点直接输出可执行风险判断。", + "", + "## 适用范围", + "", + "适用于差旅、住宿、交通、餐费、业务招待、办公、会务、培训、通讯、福利和其他费用场景。", + "", + "## 输入字段", + "", + "- expense_type", + "- attachments", + "- location", + "- amount / item_amount", + "- reason", + "", + "## 判断规则", + "", + ] + + for index, (expense_type, config) in enumerate(scene_matrix.scenes.items(), start=1): + expected_document_labels = "、".join( + resolve_document_type_label(item) for item in config.allowed_document_types + ) + expected_scene_labels = "、".join( + SCENE_LABELS.get(item, item) for item in config.allowed_scene_codes + ) + sections.extend( + [ + f"### 规则 {index} {config.label}(`{expense_type}`)", + "", + f"- 业务地点:{'必填' if config.location_required else '非必填'}", + f"- 最少附件数:{config.min_attachment_count}", + f"- 允许识别场景:{expected_scene_labels or '不限制'}", + f"- 允许附件类型:{expected_document_labels or '不限制'}", + f"- 附件不匹配处理:{config.attachment_mismatch_severity.upper()}", + ] + ) + if config.claim_amount_limit is not None: + sections.append( + f"- 合计金额阈值:预警 {config.claim_amount_limit.warn_amount or '-'} 元," + f"拦截 {config.claim_amount_limit.block_amount or '-'} 元" + ) + if config.item_amount_limit is not None: + sections.append( + f"- 单笔金额阈值:预警 {config.item_amount_limit.warn_amount or '-'} 元," + f"拦截 {config.item_amount_limit.block_amount or '-'} 元" + ) + if config.always_warn and config.always_warn_message: + sections.append(f"- 特殊处理:{config.always_warn_message}") + sections.append("") + + sections.extend( + [ + "## 输出", + "", + "- 命中高风险时退回待补充。", + "- 命中中风险时继续流转,并提示审批人重点复核。", + "- 命中 always_warn 场景时追加人工重点复核提示。", + "", + "## 来源依据", + "", + "- 公司报销制度中关于场景识别、附件要求、金额阈值和人工复核的统一口径。", + "", + "## 审核约束", + "", + "- 当前规则为系统内置真实运行规则,变更后需重新审核并评估回滚影响。", + "- 规则 JSON 与 Markdown 说明必须保持一致。", + "", + "## 管理员备注", + "", + "如后续制度调整附件类型、金额阈值或人工复核口径,应优先修改运行时 JSON 并同步更新说明。", + "", + "```expense-rule", + json.dumps(DEFAULT_SCENE_MATRIX_CONFIG, ensure_ascii=False, indent=2), + "```", + ] + ) + return "\n".join(sections) + + +def build_travel_risk_control_standard_markdown() -> str: + return "\n".join( + [ + "# 差旅报销风险管控制度", + "", + "## 模板信息", + "", + "- 模板键:`travel_standard_v1`", + "- 运行时类型:`travel_policy`", + "- 适用对象:差旅、住宿、交通相关报销审核", + "", + "## 目标", + "", + "校验差旅行程闭环、酒店地点一致性、住宿标准、飞机舱位和火车席别是否符合制度,并对例外情况保留人工复核入口。", + "", + "## 适用范围", + "", + "适用于差旅费、住宿费和交通费相关报销单,重点覆盖跨城市出差、改签、中转和超标说明场景。", + "", + "## 输入字段", + "", + "- expense_type", + "- attachments / OCR routes", + "- location", + "- employee_grade", + "- reason", + "", + "## 判断规则", + "", + "- 两段及以上长途交通票据必须首尾衔接。", + "- 最终终点应与申报目的地一致,或返回首段出发城市。", + "- 检测到多城市行程但无说明时,按高风险退回待补充。", + "- 酒店城市必须落在目的地或交通链路停留城市中。", + "- 住宿标准、飞机舱位和火车席别按职级与城市分级执行。", + "- 超标但有说明时记为中风险;超标且无说明时记为高风险。", + "", + "## 输出", + "", + "- 行程异常时输出高风险退回。", + "- 差标超限但有合理说明时输出中风险提醒。", + "- 命中差旅制度规则时,保留 `rule_code` 和 `rule_version` 供审批链追踪。", + "", + "## 来源依据", + "", + "- 公司差旅制度关于行程闭环、酒店地点一致性、职级差标和例外说明的规定。", + "", + "## 审核约束", + "", + "- 当前规则为系统内置真实运行规则,修改前需确认差旅制度版本与灰度回滚方案。", + "- 规则 JSON 与 Markdown 说明必须保持一致。", + "", + "## 管理员备注", + "", + "如制度调整职级带、城市分级或交通等级,应先更新运行时 JSON,再同步修改本说明。", + "", + "```expense-rule", + json.dumps(DEFAULT_TRAVEL_POLICY_CONFIG, ensure_ascii=False, indent=2), + "```", + ] + ) + + +class ExpenseRuleRuntimeService: + def __init__(self, db: Session) -> None: + self.db = db + + def load_catalog(self) -> ExpenseRuleCatalog: + catalog = build_default_expense_rule_catalog() + assets = list( + self.db.scalars( + select(AgentAsset) + .where(AgentAsset.asset_type == AgentAssetType.RULE.value) + .where(AgentAsset.status == AgentAssetStatus.ACTIVE.value) + .where(AgentAsset.domain == AgentAssetDomain.EXPENSE.value) + .order_by(AgentAsset.updated_at.desc(), AgentAsset.created_at.desc()) + ).all() + ) + if not assets: + return catalog + + for asset in assets: + version = self._get_current_version(asset) + if version is None: + continue + runtime_payload = self._extract_runtime_payload( + markdown_content=str(version.content or ""), + config_json=asset.config_json, + ) + if not isinstance(runtime_payload, dict): + continue + self._apply_runtime_payload( + catalog, + runtime_payload=runtime_payload, + asset=asset, + version=version, + ) + + return catalog + + def _get_current_version(self, asset: AgentAsset) -> AgentAssetVersion | None: + current_version = str(asset.current_version or "").strip() + if not current_version: + return None + return self.db.scalar( + select(AgentAssetVersion).where( + AgentAssetVersion.asset_id == asset.id, + AgentAssetVersion.version == current_version, + ) + ) + + @staticmethod + def _extract_runtime_payload( + *, + markdown_content: str, + config_json: dict[str, Any] | None, + ) -> dict[str, Any] | None: + match = EXPENSE_RULE_CODE_BLOCK_PATTERN.search(str(markdown_content or "")) + if match is not None: + try: + payload = json.loads(match.group(1)) + except json.JSONDecodeError: + payload = None + if isinstance(payload, dict): + return payload + + runtime_payload = (config_json or {}).get("runtime_rule") + return runtime_payload if isinstance(runtime_payload, dict) else None + + def _apply_runtime_payload( + self, + catalog: ExpenseRuleCatalog, + *, + runtime_payload: dict[str, Any], + asset: AgentAsset, + version: AgentAssetVersion, + ) -> None: + kind = str(runtime_payload.get("kind") or "").strip().lower() + try: + if kind == "scene_matrix": + config = SceneMatrixRuleConfig.model_validate(runtime_payload) + for expense_type, scene_config in config.scenes.items(): + catalog.scene_policies[expense_type] = ExpenseScenePolicy( + expense_type=expense_type, + rule_code=asset.code, + rule_name=asset.name, + rule_version=version.version, + **scene_config.model_dump(), + ) + return + + if kind == "travel_policy": + config = TravelPolicyConfig.model_validate(runtime_payload) + catalog.travel_policy = RuntimeTravelPolicy( + rule_code=asset.code, + rule_name=asset.name, + rule_version=version.version, + **config.model_dump(), + ) + except ValidationError: + return diff --git a/server/src/app/services/system_hermes.py b/server/src/app/services/system_hermes.py new file mode 100644 index 0000000..85663f8 --- /dev/null +++ b/server/src/app/services/system_hermes.py @@ -0,0 +1,77 @@ +from __future__ import annotations + +import os +import shutil +import subprocess +from dataclasses import dataclass +from pathlib import Path + + +@dataclass(frozen=True, slots=True) +class HermesCliResult: + response_text: str + session_id: str = "" + command: tuple[str, ...] = () + + +class SystemHermesService: + def __init__(self) -> None: + configured_bin = str(os.getenv("HERMES_BIN", "")).strip() + self.hermes_bin = configured_bin or shutil.which("hermes") or "/usr/local/bin/hermes" + + def is_available(self) -> bool: + return Path(self.hermes_bin).exists() + + def run_query( + self, + query: str, + *, + source: str = "tool", + max_turns: int = 1, + timeout_seconds: int = 180, + ) -> HermesCliResult: + if not self.is_available(): + raise RuntimeError(f"未找到系统 Hermes CLI:{self.hermes_bin}") + + command = ( + self.hermes_bin, + "chat", + "-Q", + "--source", + source, + "--max-turns", + str(max_turns), + "-q", + query, + ) + completed = subprocess.run( + command, + capture_output=True, + text=True, + timeout=timeout_seconds, + check=False, + ) + if completed.returncode != 0: + detail = (completed.stderr or completed.stdout or "").strip() + raise RuntimeError(detail or "Hermes CLI 返回非 0 状态码。") + + return self._parse_output(completed.stdout, command=command) + + @staticmethod + def _parse_output(stdout: str, *, command: tuple[str, ...]) -> HermesCliResult: + lines = [line.rstrip() for line in str(stdout or "").splitlines()] + session_id = "" + response_lines: list[str] = [] + + for line in lines: + if line.startswith("session_id:"): + session_id = line.split(":", 1)[1].strip() + continue + response_lines.append(line) + + response_text = "\n".join(line for line in response_lines if line.strip()).strip() + return HermesCliResult( + response_text=response_text, + session_id=session_id, + command=command, + )