feat(server): 新增费用规则运行时服务和系统赫尔墨斯服务,增强报销规则执行和系统监控能力
This commit is contained in:
660
server/src/app/services/expense_rule_runtime.py
Normal file
660
server/src/app/services/expense_rule_runtime.py
Normal 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
|
||||
77
server/src/app/services/system_hermes.py
Normal file
77
server/src/app/services/system_hermes.py
Normal 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,
|
||||
)
|
||||
Reference in New Issue
Block a user