feat(server): 新增费用规则运行时服务和系统赫尔墨斯服务,增强报销规则执行和系统监控能力

This commit is contained in:
caoxiaozhu
2026-05-15 06:58:03 +00:00
parent ea339d883a
commit 45abd36430
2 changed files with 737 additions and 0 deletions

View File

@@ -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

View File

@@ -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,
)