diff --git a/server/src/app/services/agent_foundation.py b/server/src/app/services/agent_foundation.py index c33ecbf..fd44527 100644 --- a/server/src/app/services/agent_foundation.py +++ b/server/src/app/services/agent_foundation.py @@ -32,6 +32,10 @@ from app.models.financial_record import ( ExpenseClaim, ExpenseClaimItem, ) +from app.services.expense_rule_runtime import ( + build_scene_submission_standard_markdown, + build_travel_risk_control_standard_markdown, +) logger = get_logger("app.services.agent_foundation") @@ -67,6 +71,75 @@ DEMO_PAYABLE_SIGNATURES = { ("AP-202605-002", "供应商B", "96000.00", "overdue"), } +LEGACY_RULE_CODES = ( + "rule.expense.duplicate_expense_check", + "rule.expense.travel_receipt_requirements", + "rule.ap.payment_dual_review", +) + +ATTACHMENT_RULE_ASSET_CODE = "rule.expense.attachment_submission_requirements" + +ATTACHMENT_RULE_RUNTIME_CONFIG = { + "kind": "policy_rule_draft", + "version": 1, + "template_key": "attachment_requirement_v1", + "rule_name": "报销附件与单据完整性规则", + "scenario": "attachment_policy", + "source_document_name": "报销制度 / 单据与附件要求", + "review_required": True, + "target": { + "expense_types": [ + "travel", + "hotel", + "transport", + "meal", + "office", + "meeting", + "training", + "communication", + "welfare", + "other", + ], + "scene_codes": ["expense", "attachment_policy", "invoice_anomaly"], + }, + "attachment_requirements": { + "min_attachment_count": 1, + "items": [ + { + "document_type": "vat_invoice", + "required": True, + "min_count": 1, + "description": "金额类报销原则上必须提供合法票据。", + }, + { + "document_type": "receipt", + "required": False, + "min_count": 1, + "description": "特殊场景无发票时需补充收据与情况说明。", + }, + { + "document_type": "flight_itinerary", + "required": False, + "min_count": 1, + "description": "差旅交通报销需提供行程单或等效凭证。", + }, + { + "document_type": "hotel_invoice", + "required": False, + "min_count": 1, + "description": "住宿报销需提供酒店票据或等效住宿凭证。", + }, + ], + "manual_fill_required": False, + }, + "missing_attachment_action": "block", + "output": { + "risk_code": "invoice_anomaly", + "action": "block", + "message": "附件或单据不完整,需补件后再提交。", + }, +} + def prepare_agent_foundation() -> None: settings = get_settings() @@ -107,44 +180,65 @@ class AgentFoundationService: self._top_up_agent_assets(existing_codes) return - approved_rule = AgentAsset( + attachment_rule = AgentAsset( asset_type=AgentAssetType.RULE.value, - code="rule.expense.duplicate_expense_check", - name="重复报销识别规则", - description="识别同一员工短时间内同金额、同地点、同理由的重复报销风险。", + code=ATTACHMENT_RULE_ASSET_CODE, + name="报销附件与单据完整性规则", + description="统一定义报销提交时的附件数量、票据类型和补件处理口径,作为上线前待审核规则。", domain=AgentAssetDomain.EXPENSE.value, - scenario_json=["expense", "risk_check", "duplicate_expense"], - owner="财务共享中心", - reviewer="张晓晴", - status=AgentAssetStatus.ACTIVE.value, - current_version="v1.1.0", - config_json={"severity": "high", "enabled": True}, - ) - pending_rule = AgentAsset( - asset_type=AgentAssetType.RULE.value, - code="rule.expense.travel_receipt_requirements", - name="差旅票据完整性规则", - description="检查差旅报销是否附齐发票、行程单和住宿凭证。", - domain=AgentAssetDomain.EXPENSE.value, - scenario_json=["expense", "explain", "invoice_anomaly"], - owner="费用运营组", + scenario_json=["expense", "risk_check", "attachment_policy", "invoice_anomaly"], + owner="财务制度管理组", reviewer="高嘉禾", status=AgentAssetStatus.REVIEW.value, current_version="v1.0.0", - config_json={"severity": "medium", "enabled": False}, + config_json={ + "severity": "high", + "enabled": False, + "runtime_kind": "policy_rule_draft", + "rule_template_key": "attachment_requirement_v1", + "rule_template_label": "附件要求模板", + "runtime_rule": ATTACHMENT_RULE_RUNTIME_CONFIG, + }, ) - rejected_rule = AgentAsset( + scene_submission_rule = AgentAsset( asset_type=AgentAssetType.RULE.value, - code="rule.ap.payment_dual_review", - name="付款双人复核规则", - description="大额付款必须由两名财务人员复核后再进入付款建议。", - domain=AgentAssetDomain.AP.value, - scenario_json=["accounts_payable", "approval_required"], - owner="付款管理组", - reviewer="孙楠", - status=AgentAssetStatus.DRAFT.value, - current_version="v0.9.0", - config_json={"amount_threshold": 50000}, + code="rule.expense.scene_submission_standard", + name="报销场景提交与附件标准", + description="统一定义各报销场景的必填字段、附件类型要求和金额阈值。", + domain=AgentAssetDomain.EXPENSE.value, + scenario_json=["expense", "risk_check", "scene_policy", "attachment_policy"], + owner="费用运营组", + reviewer="顾承宇", + status=AgentAssetStatus.ACTIVE.value, + current_version="v1.0.0", + config_json={ + "severity": "high", + "enabled": True, + "runtime_kind": "scene_matrix", + "rule_template_label": "系统内置场景矩阵规则", + }, + ) + travel_policy_rule = AgentAsset( + asset_type=AgentAssetType.RULE.value, + code="rule.expense.travel_risk_control_standard", + name="差旅报销风险管控制度", + description="统一定义差旅报销的行程闭环、酒店地点一致性、职级差标和风险处置口径。", + domain=AgentAssetDomain.EXPENSE.value, + scenario_json=["expense", "risk_check", "travel_policy", "travel_standard"], + owner="风控与审计部", + reviewer="顾承宇", + status=AgentAssetStatus.ACTIVE.value, + current_version="v1.1.0", + config_json={ + "severity": "high", + "enabled": True, + "block_on_high_risk": True, + "warning_on_medium_risk": True, + "source_doc": "document/development/risks/travel-risk-control-standard.md", + "runtime_kind": "travel_policy", + "rule_template_key": "travel_standard_v1", + "rule_template_label": "差旅标准模板", + }, ) skill_expense_asset = AgentAsset( asset_type=AgentAssetType.SKILL.value, @@ -237,12 +331,25 @@ class AgentFoundationService: current_version="v1.0.0", config_json={"cron": "0 18 * * *", "agent": AgentName.HERMES.value}, ) + llm_wiki_task = AgentAsset( + asset_type=AgentAssetType.TASK.value, + code="task.hermes.llm_wiki_rule_formation", + name="Hermes 制度知识与规则草稿形成", + description="按知识库变化增量重建报销制度 LLM Wiki,并形成知识候选与规则草稿。", + domain=AgentAssetDomain.SYSTEM.value, + scenario_json=["schedule", "knowledge", "rule_center"], + owner="财务制度管理组", + reviewer="顾承宇", + status=AgentAssetStatus.ACTIVE.value, + current_version="v1.0.0", + config_json={"cron": "0 3 * * *", "agent": AgentName.HERMES.value}, + ) self.db.add_all( [ - approved_rule, - pending_rule, - rejected_rule, + attachment_rule, + scene_submission_rule, + travel_policy_rule, skill_expense_asset, skill_ar_asset, invoice_mcp_asset, @@ -250,6 +357,7 @@ class AgentFoundationService: task_asset, ar_summary_task, rule_digest_task, + llm_wiki_task, ] ) self.db.flush() @@ -257,79 +365,50 @@ class AgentFoundationService: self.db.add_all( [ AgentAssetVersion( - asset=approved_rule, - version="v1.0.0", - content=self._markdown_content( - "# 重复报销识别规则\n\n" - "- 检查员工、金额、地点、发生日期是否高度重复。\n" - "- 命中后输出 `duplicate_expense` 风险标签。" - ), - content_type=AgentAssetContentType.MARKDOWN.value, - change_note="初始化生产规则版本。", - created_by="系统初始化", - ), - AgentAssetVersion( - asset=approved_rule, - version="v1.1.0", - content=self._markdown_content( - "# 重复报销识别规则\n\n" - "- 检查员工、金额、地点、发生日期是否高度重复。\n" - "- 新增对同项目、同金额、跨单重复提交的识别。\n" - "- 命中后输出 `duplicate_expense` 风险标签。" - ), - content_type=AgentAssetContentType.MARKDOWN.value, - change_note="补充跨单重复提交判断。", - created_by="系统初始化", - ), - AgentAssetVersion( - asset=pending_rule, + asset=attachment_rule, version="v0.9.0", - content=self._markdown_content( - "# 差旅票据完整性规则\n\n" - "- 差旅报销必须具备发票、行程单、住宿凭证。\n" - "- 缺失时输出 `invoice_anomaly`。" + content=self._attachment_submission_requirement_markdown( + version_note="首版附件完整性规则草稿,覆盖基础票据与补件口径。", + include_review_note=True, ), content_type=AgentAssetContentType.MARKDOWN.value, change_note="首版草稿。", created_by="高嘉禾", ), AgentAssetVersion( - asset=pending_rule, + asset=attachment_rule, version="v1.0.0", - content=self._markdown_content( - "# 差旅票据完整性规则\n\n" - "- 差旅报销必须具备发票、行程单、住宿凭证。\n" - "- 新增高铁改签和住宿分拆票据的补件说明。\n" - "- 缺失时输出 `invoice_anomaly`。" + content=self._attachment_submission_requirement_markdown( + version_note="补充票据缺失、收据替代和差旅等效凭证口径,待审核。", + include_review_note=True, ), content_type=AgentAssetContentType.MARKDOWN.value, - change_note="补充差旅特殊票据口径,待审核。", + change_note="补充票据替代与差旅等效凭证口径,待审核。", created_by="高嘉禾", ), AgentAssetVersion( - asset=rejected_rule, - version="v0.8.0", - content=self._markdown_content( - "# 付款双人复核规则\n\n" - "- 单笔付款超过阈值时必须双人复核。\n" - "- 本版本规则口径过宽,待修订。" - ), + asset=scene_submission_rule, + version="v1.0.0", + content=self._scene_submission_standard_markdown(), content_type=AgentAssetContentType.MARKDOWN.value, - change_note="首版方案。", - created_by="孙楠", + change_note="首版报销场景提交标准,覆盖附件类型、必填字段和金额阈值。", + created_by="系统初始化", ), AgentAssetVersion( - asset=rejected_rule, - version="v0.9.0", - content=self._markdown_content( - "# 付款双人复核规则\n\n" - "- 单笔付款超过阈值时必须双人复核。\n" - "- 新增跨币种付款也进入复核队列。\n" - "- 当前阈值定义仍不清晰,需继续修订。" - ), + asset=travel_policy_rule, + version="v1.0.0", + content=self._travel_risk_control_standard_markdown(version="v1.0.0"), content_type=AgentAssetContentType.MARKDOWN.value, - change_note="补充跨币种场景,但阈值仍待明确。", - created_by="孙楠", + change_note="首版差旅制度执行规则,覆盖行程闭环与基础差标校验。", + created_by="系统初始化", + ), + AgentAssetVersion( + asset=travel_policy_rule, + version="v1.1.0", + content=self._travel_risk_control_standard_markdown(version="v1.1.0"), + content_type=AgentAssetContentType.MARKDOWN.value, + change_note="补充可执行规则块,供审核引擎直接消费差旅制度标准。", + created_by="系统初始化", ), AgentAssetVersion( asset=skill_expense_asset, @@ -429,32 +508,48 @@ class AgentFoundationService: change_note="初始化规则待审摘要任务。", created_by="系统初始化", ), + AgentAssetVersion( + asset=llm_wiki_task, + version="v1.0.0", + content=self._json_content( + { + "task_type": "llm_wiki_rule_formation", + "schedule": "0 3 * * *", + "target_agent": AgentName.HERMES.value, + "folder": "报销制度", + "changed_only": True, + } + ), + content_type=AgentAssetContentType.JSON.value, + change_note="初始化制度知识与规则草稿形成任务。", + created_by="系统初始化", + ), ] ) self.db.add_all( [ AgentAssetReview( - asset=approved_rule, - version="v1.1.0", - reviewer="张晓晴", - review_status=AgentReviewStatus.APPROVED.value, - review_note="规则口径清晰,可上线。", - reviewed_at=datetime.now(UTC), - ), - AgentAssetReview( - asset=pending_rule, + asset=attachment_rule, version="v1.0.0", reviewer="高嘉禾", review_status=AgentReviewStatus.PENDING.value, - review_note="等待补充票据异常样例。", + review_note="等待制度管理员确认收据替代与补件时限口径。", reviewed_at=None, ), AgentAssetReview( - asset=rejected_rule, - version="v0.9.0", - reviewer="孙楠", - review_status=AgentReviewStatus.REJECTED.value, - review_note="阈值定义不清,暂不通过。", + asset=scene_submission_rule, + version="v1.0.0", + reviewer="顾承宇", + review_status=AgentReviewStatus.APPROVED.value, + review_note="可作为报销场景统一审核标准正式执行。", + reviewed_at=datetime.now(UTC), + ), + AgentAssetReview( + asset=travel_policy_rule, + version="v1.1.0", + reviewer="顾承宇", + review_status=AgentReviewStatus.APPROVED.value, + review_note="制度口径已确认,并已补充可执行配置供审核引擎读取。", reviewed_at=datetime.now(UTC), ), ] @@ -772,26 +867,26 @@ class AgentFoundationService: actor="系统初始化", action="save_rule_markdown", resource_type="rule", - resource_id="rule.expense.duplicate_expense_check", + resource_id=ATTACHMENT_RULE_ASSET_CODE, before_json=None, after_json={"version": "v1.0.0"}, request_id="seed-audit-001", ), AuditLog( - actor="张晓晴", + actor="高嘉禾", action="review_rule", resource_type="rule", - resource_id="rule.expense.duplicate_expense_check", + resource_id=ATTACHMENT_RULE_ASSET_CODE, before_json={"review_status": "pending"}, - after_json={"review_status": "approved"}, + after_json={"review_status": "pending"}, request_id="seed-audit-002", ), AuditLog( actor="系统初始化", action="activate_rule", resource_type="rule", - resource_id="rule.expense.duplicate_expense_check", - before_json={"status": "review"}, + resource_id="rule.expense.scene_submission_standard", + before_json={"status": "draft"}, after_json={"status": "active"}, request_id="seed-audit-003", ), @@ -808,59 +903,191 @@ class AgentFoundationService: ) def _top_up_agent_assets(self, existing_codes: set[str]) -> None: - approved_rule = self.db.scalar( - select(AgentAsset).where(AgentAsset.code == "rule.expense.duplicate_expense_check") + self._remove_legacy_rule_assets() + existing_codes = set(self.db.scalars(select(AgentAsset.code)).all()) + + attachment_rule = self.db.scalar( + select(AgentAsset).where(AgentAsset.code == ATTACHMENT_RULE_ASSET_CODE) ) - pending_rule = self.db.scalar( - select(AgentAsset).where(AgentAsset.code == "rule.expense.travel_receipt_requirements") + scene_submission_rule = self.db.scalar( + select(AgentAsset).where(AgentAsset.code == "rule.expense.scene_submission_standard") ) - rejected_rule = self.db.scalar( - select(AgentAsset).where(AgentAsset.code == "rule.ap.payment_dual_review") + travel_policy_rule = self.db.scalar( + select(AgentAsset).where(AgentAsset.code == "rule.expense.travel_risk_control_standard") ) - if approved_rule is not None: - self._ensure_asset_version( - approved_rule, - version="v1.1.0", - content=self._markdown_content( - "# 重复报销识别规则\n\n" - "- 检查员工、金额、地点、发生日期是否高度重复。\n" - "- 新增对同项目、同金额、跨单重复提交的识别。\n" - "- 命中后输出 `duplicate_expense` 风险标签。" - ), - content_type=AgentAssetContentType.MARKDOWN.value, - change_note="补充跨单重复提交判断。", - created_by="系统初始化", + if ATTACHMENT_RULE_ASSET_CODE not in existing_codes: + attachment_rule = self._create_seed_asset( + asset_type=AgentAssetType.RULE.value, + code=ATTACHMENT_RULE_ASSET_CODE, + name="报销附件与单据完整性规则", + description="统一定义报销提交时的附件数量、票据类型和补件处理口径,作为上线前待审核规则。", + domain=AgentAssetDomain.EXPENSE.value, + scenario_json=["expense", "risk_check", "attachment_policy", "invoice_anomaly"], + owner="财务制度管理组", + reviewer="高嘉禾", + status=AgentAssetStatus.REVIEW.value, + current_version="v1.0.0", + config_json={ + "severity": "high", + "enabled": False, + "runtime_kind": "policy_rule_draft", + "rule_template_key": "attachment_requirement_v1", + "rule_template_label": "附件要求模板", + "runtime_rule": ATTACHMENT_RULE_RUNTIME_CONFIG, + }, ) - if pending_rule is not None: + if attachment_rule is not None: + attachment_rule.current_version = "v1.0.0" + attachment_rule.status = AgentAssetStatus.REVIEW.value + attachment_rule.description = "统一定义报销提交时的附件数量、票据类型和补件处理口径,作为上线前待审核规则。" + attachment_rule.config_json = { + "severity": "high", + "enabled": False, + "runtime_kind": "policy_rule_draft", + "rule_template_key": "attachment_requirement_v1", + "rule_template_label": "附件要求模板", + "runtime_rule": ATTACHMENT_RULE_RUNTIME_CONFIG, + } self._ensure_asset_version( - pending_rule, - version="v1.0.0", - content=self._markdown_content( - "# 差旅票据完整性规则\n\n" - "- 差旅报销必须具备发票、行程单、住宿凭证。\n" - "- 新增高铁改签和住宿分拆票据的补件说明。\n" - "- 缺失时输出 `invoice_anomaly`。" + attachment_rule, + version="v0.9.0", + content=self._attachment_submission_requirement_markdown( + version_note="首版附件完整性规则草稿,覆盖基础票据与补件口径。", + include_review_note=True, ), content_type=AgentAssetContentType.MARKDOWN.value, - change_note="补充差旅特殊票据口径,待审核。", + change_note="首版草稿。", created_by="高嘉禾", ) - - if rejected_rule is not None: self._ensure_asset_version( - rejected_rule, - version="v0.9.0", - content=self._markdown_content( - "# 付款双人复核规则\n\n" - "- 单笔付款超过阈值时必须双人复核。\n" - "- 新增跨币种付款也进入复核队列。\n" - "- 当前阈值定义仍不清晰,需继续修订。" + attachment_rule, + version="v1.0.0", + content=self._attachment_submission_requirement_markdown( + version_note="补充票据缺失、收据替代和差旅等效凭证口径,待审核。", + include_review_note=True, ), content_type=AgentAssetContentType.MARKDOWN.value, - change_note="补充跨币种场景,但阈值仍待明确。", - created_by="孙楠", + change_note="补充票据替代与差旅等效凭证口径,待审核。", + created_by="高嘉禾", + ) + self._ensure_asset_review( + attachment_rule, + version="v1.0.0", + reviewer="高嘉禾", + review_status=AgentReviewStatus.PENDING.value, + review_note="等待制度管理员确认收据替代与补件时限口径。", + reviewed_at=None, + ) + + if "rule.expense.scene_submission_standard" not in existing_codes: + scene_submission_rule = self._create_seed_asset( + asset_type=AgentAssetType.RULE.value, + code="rule.expense.scene_submission_standard", + name="报销场景提交与附件标准", + description="统一定义各报销场景的必填字段、附件类型要求和金额阈值。", + domain=AgentAssetDomain.EXPENSE.value, + scenario_json=["expense", "risk_check", "scene_policy", "attachment_policy"], + owner="费用运营组", + reviewer="顾承宇", + status=AgentAssetStatus.ACTIVE.value, + current_version="v1.0.0", + config_json={ + "severity": "high", + "enabled": True, + "runtime_kind": "scene_matrix", + "rule_template_label": "系统内置场景矩阵规则", + }, + ) + + if scene_submission_rule is not None: + scene_submission_rule.current_version = "v1.0.0" + scene_submission_rule.status = AgentAssetStatus.ACTIVE.value + scene_submission_rule.description = "统一定义各报销场景的必填字段、附件类型要求和金额阈值。" + scene_submission_rule.config_json = { + "severity": "high", + "enabled": True, + "runtime_kind": "scene_matrix", + "rule_template_label": "系统内置场景矩阵规则", + } + self._ensure_asset_version( + scene_submission_rule, + version="v1.0.0", + content=self._scene_submission_standard_markdown(), + content_type=AgentAssetContentType.MARKDOWN.value, + change_note="首版报销场景提交标准,覆盖附件类型、必填字段和金额阈值。", + created_by="系统初始化", + ) + self._ensure_asset_review( + scene_submission_rule, + version="v1.0.0", + reviewer="顾承宇", + review_status=AgentReviewStatus.APPROVED.value, + review_note="可作为报销场景统一审核标准正式执行。", + reviewed_at=datetime.now(UTC), + ) + + if "rule.expense.travel_risk_control_standard" not in existing_codes: + travel_policy_rule = self._create_seed_asset( + asset_type=AgentAssetType.RULE.value, + code="rule.expense.travel_risk_control_standard", + name="差旅报销风险管控制度", + description="统一定义差旅报销的行程闭环、酒店地点一致性、职级差标和风险处置口径。", + domain=AgentAssetDomain.EXPENSE.value, + scenario_json=["expense", "risk_check", "travel_policy", "travel_standard"], + owner="风控与审计部", + reviewer="顾承宇", + status=AgentAssetStatus.ACTIVE.value, + current_version="v1.1.0", + config_json={ + "severity": "high", + "enabled": True, + "block_on_high_risk": True, + "warning_on_medium_risk": True, + "source_doc": "document/development/risks/travel-risk-control-standard.md", + "runtime_kind": "travel_policy", + "rule_template_key": "travel_standard_v1", + "rule_template_label": "差旅标准模板", + }, + ) + + if travel_policy_rule is not None: + travel_policy_rule.current_version = "v1.1.0" + travel_policy_rule.status = AgentAssetStatus.ACTIVE.value + travel_policy_rule.config_json = { + "severity": "high", + "enabled": True, + "block_on_high_risk": True, + "warning_on_medium_risk": True, + "source_doc": "document/development/risks/travel-risk-control-standard.md", + "runtime_kind": "travel_policy", + "rule_template_key": "travel_standard_v1", + "rule_template_label": "差旅标准模板", + } + self._ensure_asset_version( + travel_policy_rule, + version="v1.0.0", + content=self._travel_risk_control_standard_markdown(version="v1.0.0"), + content_type=AgentAssetContentType.MARKDOWN.value, + change_note="首版差旅制度执行规则,覆盖行程闭环与基础差标校验。", + created_by="系统初始化", + ) + self._ensure_asset_version( + travel_policy_rule, + version="v1.1.0", + content=self._travel_risk_control_standard_markdown(version="v1.1.0"), + content_type=AgentAssetContentType.MARKDOWN.value, + change_note="补充可执行规则块,供审核引擎直接消费差旅制度标准。", + created_by="系统初始化", + ) + self._ensure_asset_review( + travel_policy_rule, + version="v1.1.0", + reviewer="顾承宇", + review_status=AgentReviewStatus.APPROVED.value, + review_note="制度口径已确认,并已补充可执行配置供审核引擎读取。", + reviewed_at=datetime.now(UTC), ) if "skill.ar.aging_summary" not in existing_codes: @@ -979,6 +1206,37 @@ class AgentFoundationService: created_by="系统初始化", ) + if "task.hermes.llm_wiki_rule_formation" not in existing_codes: + asset = self._create_seed_asset( + asset_type=AgentAssetType.TASK.value, + code="task.hermes.llm_wiki_rule_formation", + name="Hermes 制度知识与规则草稿形成", + description="按知识库变化增量重建报销制度 LLM Wiki,并形成知识候选与规则草稿。", + domain=AgentAssetDomain.SYSTEM.value, + scenario_json=["schedule", "knowledge", "rule_center"], + owner="财务制度管理组", + reviewer="顾承宇", + status=AgentAssetStatus.ACTIVE.value, + current_version="v1.0.0", + config_json={"cron": "0 3 * * *", "agent": AgentName.HERMES.value}, + ) + self._ensure_asset_version( + asset, + version="v1.0.0", + content=self._json_content( + { + "task_type": "llm_wiki_rule_formation", + "schedule": "0 3 * * *", + "target_agent": AgentName.HERMES.value, + "folder": "报销制度", + "changed_only": True, + } + ), + content_type=AgentAssetContentType.JSON.value, + change_note="初始化制度知识与规则草稿形成任务。", + created_by="系统初始化", + ) + def _create_seed_asset( self, *, @@ -1041,6 +1299,121 @@ class AgentFoundationService: ) ) + def _ensure_asset_review( + self, + asset: AgentAsset, + *, + version: str, + reviewer: str, + review_status: str, + review_note: str, + reviewed_at: datetime | None, + ) -> None: + existing = self.db.scalar( + select(AgentAssetReview).where( + AgentAssetReview.asset_id == asset.id, + AgentAssetReview.version == version, + AgentAssetReview.review_status == review_status, + ) + ) + if existing is not None: + return + + self.db.add( + AgentAssetReview( + asset_id=asset.id, + version=version, + reviewer=reviewer, + review_status=review_status, + review_note=review_note, + reviewed_at=reviewed_at, + ) + ) + + def _remove_legacy_rule_assets(self) -> None: + assets = list( + self.db.scalars( + select(AgentAsset).where(AgentAsset.code.in_(LEGACY_RULE_CODES)) + ).all() + ) + for asset in assets: + self.db.delete(asset) + + obsolete_logs = list( + self.db.scalars( + select(AuditLog).where(AuditLog.resource_id.in_(LEGACY_RULE_CODES)) + ).all() + ) + for log in obsolete_logs: + self.db.delete(log) + + def _attachment_submission_requirement_markdown( + self, + *, + version_note: str, + include_review_note: bool, + ) -> str: + sections = [ + "# 报销附件与单据完整性规则", + "", + "## 模板信息", + "", + "- 模板键:`attachment_requirement_v1`", + "- 来源文档:报销制度 / 单据与附件要求", + "- 审核状态:待审核", + "", + "## 目标", + "", + "统一约束报销提交时的票据、附件与替代凭证要求,避免缺件、错件和无依据流转。", + "", + "## 适用范围", + "", + "适用于员工报销提交场景,重点覆盖差旅、住宿、交通、餐费、办公和其他费用的附件校验。", + "", + "## 输入字段", + "", + "- expense_type", + "- attachments", + "- invoice_count", + "- reason", + "", + "## 判断规则", + "", + "- 报销提交前至少需要 1 份有效附件。", + "- 金额类报销原则上应提供合法票据;特殊场景无发票时,必须补充收据与情况说明。", + "- 差旅交通报销需提供行程单或等效凭证;住宿报销需提供酒店票据或等效住宿凭证。", + "- 缺少必要附件时直接拦截,并提示补件后重新提交。", + "", + "## 输出", + "", + "- 风险编码:`invoice_anomaly`", + "- 默认动作:`block`", + "- 处理说明:附件或单据不完整时退回补充。", + "", + "## 来源依据", + "", + "- 报销制度对票据、附件、替代凭证和补件要求的统一约束。", + "", + "## 审核约束", + "", + "- 当前规则属于真实业务规则,但仍处于待审核状态。", + "- 上线前需由制度管理员确认收据替代、补件时限和特殊场景豁免口径。", + f"- 当前版本说明:{version_note}", + "", + "## 管理员备注", + "", + "需要结合公司正式报销制度,补充各场景附件替代口径与例外审批要求。", + ] + if include_review_note: + sections.extend(["", "```expense-rule", json.dumps(ATTACHMENT_RULE_RUNTIME_CONFIG, ensure_ascii=False, indent=2), "```"]) + return "\n".join(sections) + + def _scene_submission_standard_markdown(self) -> str: + return self._markdown_content(build_scene_submission_standard_markdown()) + + def _travel_risk_control_standard_markdown(self, *, version: str = "v1.1.0") -> str: + return self._markdown_content(build_travel_risk_control_standard_markdown()) + @staticmethod def _markdown_content(content: str) -> str: return content diff --git a/server/tests/test_agent_asset_service.py b/server/tests/test_agent_asset_service.py index 08d4be5..19e7506 100644 --- a/server/tests/test_agent_asset_service.py +++ b/server/tests/test_agent_asset_service.py @@ -45,6 +45,19 @@ def test_agent_asset_service_seeds_assets_and_enforces_review_before_activation( rules = service.list_assets(asset_type=AgentAssetType.RULE.value) assert len(rules) >= 3 + assert any( + item.code == "rule.expense.travel_risk_control_standard" and item.status == AgentAssetStatus.ACTIVE.value + for item in rules + ) + assert all( + item.code + not in { + "rule.expense.duplicate_expense_check", + "rule.expense.travel_receipt_requirements", + "rule.ap.payment_dual_review", + } + for item in rules + ) pending_rule = next(item for item in rules if item.status == AgentAssetStatus.REVIEW.value) @@ -118,17 +131,39 @@ def test_agent_asset_service_returns_recent_versions_for_rule_detail() -> None: rule = next( item for item in service.list_assets(asset_type=AgentAssetType.RULE.value) - if item.code == "rule.expense.duplicate_expense_check" + if item.code == "rule.expense.attachment_submission_requirements" ) detail = service.get_asset(rule.id) assert detail is not None - assert detail.current_version == "v1.1.0" + assert detail.current_version == "v1.0.0" assert detail.current_version_content_type == AgentAssetContentType.MARKDOWN.value assert isinstance(detail.current_version_content, str) assert len(detail.recent_versions) >= 2 assert any(item.is_current for item in detail.recent_versions) - assert {item.version for item in detail.recent_versions} >= {"v1.0.0", "v1.1.0"} + assert {item.version for item in detail.recent_versions} >= {"v0.9.0", "v1.0.0"} + assert detail.config_json["rule_template_key"] == "attachment_requirement_v1" + assert "附件或单据不完整" in str(detail.current_version_content) + + +def test_agent_asset_service_returns_travel_policy_rule_detail() -> None: + with build_session() as db: + service = AgentAssetService(db) + + rule = next( + item + for item in service.list_assets(asset_type=AgentAssetType.RULE.value) + if item.code == "rule.expense.travel_risk_control_standard" + ) + detail = service.get_asset(rule.id) + + assert detail is not None + assert detail.status == AgentAssetStatus.ACTIVE.value + assert detail.current_version == "v1.1.0" + assert detail.latest_review is not None + assert detail.latest_review.review_status == AgentReviewStatus.APPROVED.value + assert "行程闭环" in str(detail.current_version_content) + assert "住宿标准、飞机舱位和火车席别" in str(detail.current_version_content) def test_agent_run_service_lists_seeded_trace_data() -> None: diff --git a/server/tests/test_agent_foundation_endpoints.py b/server/tests/test_agent_foundation_endpoints.py index 90eb49d..557207d 100644 --- a/server/tests/test_agent_foundation_endpoints.py +++ b/server/tests/test_agent_foundation_endpoints.py @@ -45,13 +45,18 @@ def test_list_agent_assets_endpoint_returns_seeded_items() -> None: payload = response.json() assert payload assert all(item["asset_type"] == "rule" for item in payload) + assert any(item["code"] == "rule.expense.travel_risk_control_standard" for item in payload) def test_get_agent_asset_detail_endpoint_returns_version_history() -> None: client, _ = build_client() list_response = client.get("/api/v1/agent-assets", params={"asset_type": "rule"}) - asset_id = list_response.json()[0]["id"] + asset_id = next( + item["id"] + for item in list_response.json() + if item["code"] == "rule.expense.travel_risk_control_standard" + ) response = client.get(f"/api/v1/agent-assets/{asset_id}") @@ -59,7 +64,8 @@ def test_get_agent_asset_detail_endpoint_returns_version_history() -> None: payload = response.json() assert payload["recent_versions"] assert payload["current_version_content_type"] == "markdown" - assert len(payload["recent_versions"]) >= 2 + assert payload["current_version"] == "v1.1.0" + assert "行程闭环" in payload["current_version_content"] def test_activate_pending_rule_endpoint_is_blocked() -> None: