diff --git a/server/src/app/services/agent_assets.py b/server/src/app/services/agent_assets.py index aacd9f8..acbcf4b 100644 --- a/server/src/app/services/agent_assets.py +++ b/server/src/app/services/agent_assets.py @@ -1,6 +1,7 @@ from __future__ import annotations import json +from collections import defaultdict from dataclasses import dataclass from datetime import UTC, datetime from pathlib import Path @@ -30,6 +31,8 @@ from app.schemas.agent_asset import ( AgentAssetRead, AgentAssetReviewCreate, AgentAssetReviewRead, + AgentAssetRuleJsonRead, + AgentAssetRuleJsonWrite, AgentAssetSpreadsheetChangeRecordRead, AgentAssetSpreadsheetDiffCellRead, AgentAssetSpreadsheetDiffSheetRead, @@ -39,9 +42,15 @@ from app.schemas.agent_asset import ( AgentAssetVersionRead, AgentAssetVersionTimelineItemRead, ) +from app.services.agent_asset_rule_library import AgentAssetRuleLibraryManager from app.services.agent_asset_spreadsheet import ( AgentAssetSpreadsheetManager, + COMPANY_COMMUNICATION_EXPENSE_RULE_CODE, + COMPANY_COMMUNICATION_EXPENSE_RULE_FILENAME, + COMPANY_TRAVEL_EXPENSE_RULE_CODE, COMPANY_TRAVEL_EXPENSE_RULE_FILENAME, + FINANCE_RULES_LIBRARY, + RISK_RULES_LIBRARY, RULE_LIBRARY_NAMES, RuleSpreadsheetMeta, SPREADSHEET_MIME_TYPE, @@ -74,6 +83,7 @@ class AgentAssetService: self.repository = AgentAssetRepository(db) self.audit_service = AuditLogService(db) self.spreadsheet_manager = AgentAssetSpreadsheetManager() + self.rule_library_manager = AgentAssetRuleLibraryManager() def list_assets( self, @@ -84,10 +94,16 @@ class AgentAssetService: keyword: str | None = None, ) -> list[AgentAssetListItem]: self._ensure_ready() - items = self.repository.list( + if asset_type in {None, "", AgentAssetType.RULE.value}: + self.sync_platform_risk_rules_from_library() + assets = self.repository.list( asset_type=asset_type, status=status, domain=domain, keyword=keyword ) - return [AgentAssetListItem.model_validate(item) for item in items] + version_stats = self._collect_version_stats(assets) + return [ + self._serialize_list_item(asset, version_stats.get(asset.id)) + for asset in assets + ] def get_asset(self, asset_id: str) -> AgentAssetRead | None: self._ensure_ready() @@ -110,8 +126,9 @@ class AgentAssetService: if working_version else None ) + version_stats = self._collect_version_stats([asset]).get(asset.id) return AgentAssetRead( - **AgentAssetListItem.model_validate(asset).model_dump(), + **self._serialize_list_item(asset, version_stats).model_dump(), current_version_content=self._deserialize_content(current_version) if current_version else None, @@ -500,8 +517,8 @@ class AgentAssetService: ) asset = self._require_spreadsheet_rule(asset_id) - resolved_version, metadata = self._resolve_spreadsheet_version_meta(asset, version=version) - editable = self._can_edit_spreadsheet_version(asset, current_user, resolved_version) + resolved_version, metadata = self._resolve_current_spreadsheet_meta(asset) + editable = self._can_edit_current_spreadsheet(current_user) return self._build_onlyoffice_spreadsheet_config( asset_id=asset.id, current_user=current_user, @@ -525,7 +542,7 @@ class AgentAssetService: return file_path, metadata.mime_type, metadata.file_name asset = self._require_spreadsheet_rule(asset_id) - _, metadata = self._resolve_spreadsheet_version_meta(asset, version=version) + _, metadata = self._resolve_current_spreadsheet_meta(asset) file_path = self.spreadsheet_manager.resolve_storage_path(metadata.storage_key) if not file_path.exists(): raise FileNotFoundError(metadata.file_name) @@ -575,58 +592,31 @@ class AgentAssetService: if not content: raise ValueError("规则表文件内容不能为空。") - next_version = self._increment_version(self._resolve_working_version(asset)) - metadata = self.spreadsheet_manager.store_spreadsheet( - asset_id=asset.id, - version=next_version, - file_name=normalized_name, + _, current_metadata = self._resolve_current_spreadsheet_meta(asset) + file_name = current_metadata.file_name or self._resolve_default_spreadsheet_file_name(asset) + metadata = self._store_current_rule_spreadsheet( + asset, + file_name=file_name, content=content, - actor_name=actor, + actor=actor, source=source, ) - markdown = self.spreadsheet_manager.build_version_markdown( - rule_name=asset.name, - version=next_version, - metadata=metadata, - ) - self.create_version( - asset.id, - AgentAssetVersionCreate( - version=next_version, - content=markdown, - content_type=AgentAssetContentType.MARKDOWN, - change_note=change_note or f"上传 Excel 规则表:{normalized_name}", - created_by=actor, - ), + self.audit_service.log_action( actor=actor, + action="edit_rule_spreadsheet", + resource_type=asset.asset_type, + resource_id=asset.id, + before_json={"storage_key": current_metadata.storage_key}, + after_json={ + "summary": change_note or f"上传并覆盖当前规则表:{normalized_name}", + "changed_sheet_count": 0, + "changed_cell_count": 0, + "sheet_changes": [], + "cell_changes": [], + "storage_key": metadata.storage_key, + }, request_id=request_id, ) - - refreshed = self.repository.get(asset.id) - if refreshed is None: - raise LookupError("Asset not found") - - config_json = dict(refreshed.config_json or {}) - config_json["detail_mode"] = "spreadsheet" - config_json["tag"] = str(config_json.get("tag") or "财务规则").strip() or "财务规则" - current_document_meta = metadata - rule_library = str(config_json.get("rule_library") or "").strip() - if rule_library in RULE_LIBRARY_NAMES: - current_document_meta = self.spreadsheet_manager.store_rule_library_spreadsheet( - library=rule_library, - file_name=normalized_name, - content=content, - actor_name=actor, - source=source, - ) - rule_document = self.spreadsheet_manager.build_rule_document_config( - current_document_meta, - asset_version=next_version, - ) - rule_document["storage_key"] = current_document_meta.storage_key - config_json["rule_document"] = rule_document - refreshed.config_json = config_json - self.repository.save_asset(refreshed) return self.get_asset(asset.id) # type: ignore[return-value] def import_rule_spreadsheet_content( @@ -646,10 +636,7 @@ class AgentAssetService: if Path(normalized_name).suffix.lower() != ".xlsx": raise ValueError("当前仅支持导入 .xlsx 格式的规则表。") - _, current_metadata = self._resolve_spreadsheet_version_meta( - asset, - version=self._resolve_working_version(asset), - ) + _, current_metadata = self._resolve_current_spreadsheet_meta(asset) imported_content = self.spreadsheet_manager.rebuild_from_uploaded_content(content) return self.upload_rule_spreadsheet( asset.id, @@ -681,10 +668,10 @@ class AgentAssetService: callback = self._parse_onlyoffice_callback(payload) if callback.status not in {2, 6} or not callback.download_url: return - if self._resolve_working_version(asset) != str(version or "").strip(): + if str(version or "").strip() not in {"", "current", self._resolve_working_version(asset)}: return - _, current_metadata = self._resolve_spreadsheet_version_meta(asset, version=version) + _, current_metadata = self._resolve_current_spreadsheet_meta(asset) request = Request( callback.download_url, headers={"User-Agent": "x-financial-onlyoffice-agent-asset"}, @@ -723,12 +710,11 @@ class AgentAssetService: resolved_actor_name = str(actor_name or "").strip() or ( callback.users[0] if callback.users else "ONLYOFFICE" ) - self.upload_rule_spreadsheet( - asset.id, - filename=current_metadata.file_name, + self._store_current_rule_spreadsheet( + asset, + file_name=current_metadata.file_name, content=content, actor=resolved_actor_name, - change_note=change_note, source="onlyoffice", ) if changed_sheet_count > 0 or changed_cell_count > 0: @@ -737,7 +723,7 @@ class AgentAssetService: action="edit_rule_spreadsheet", resource_type=asset.asset_type, resource_id=asset.id, - before_json={"version": version}, + before_json={"storage_key": current_metadata.storage_key}, after_json={ "summary": change_note, "changed_sheet_count": changed_sheet_count, @@ -750,6 +736,11 @@ class AgentAssetService: def _ensure_ready(self) -> None: AgentFoundationService(self.db).ensure_foundation_ready() + def sync_platform_risk_rules_from_library(self) -> int: + manifest_count = AgentFoundationService(self.db).sync_platform_risk_rules_from_library() + self.db.commit() + return manifest_count + def _validate_version_payload( self, asset: AgentAsset, payload: AgentAssetVersionCreate ) -> None: @@ -1003,6 +994,7 @@ class AgentAssetService: ) return [ AgentAssetSpreadsheetChangeRecordRead( + id=log.id, actor=log.actor, changed_at=log.created_at, summary=str((log.after_json or {}).get("summary") or "ONLYOFFICE 在线编辑保存。"), @@ -1046,6 +1038,80 @@ class AgentAssetService: ), ) + def _collect_version_stats( + self, assets: list[AgentAsset] + ) -> dict[str, dict[str, int | str | None]]: + asset_ids = [item.id for item in assets] + versions = self.repository.list_versions_for_assets(asset_ids) + spreadsheet_logs = self.audit_service.repository.list_for_resources( + resource_type=AgentAssetType.RULE.value, + resource_ids=[ + item.id + for item in assets + if item.asset_type == AgentAssetType.RULE.value + and str((item.config_json or {}).get("detail_mode") or "").strip().lower() + == "spreadsheet" + ], + action="edit_rule_spreadsheet", + ) + working_versions = { + item.id: self._resolve_working_version(item) for item in assets + } + version_counts: dict[str, int] = defaultdict(int) + modified_by: dict[str, str | None] = {item.id: None for item in assets} + spreadsheet_edit_counts: dict[str, int] = defaultdict(int) + spreadsheet_last_actor: dict[str, str | None] = {} + spreadsheet_last_changed_at: dict[str, datetime] = {} + + for version in versions: + version_counts[version.asset_id] += 1 + if ( + modified_by.get(version.asset_id) is None + and version.version == working_versions.get(version.asset_id) + ): + modified_by[version.asset_id] = version.created_by + + for log in spreadsheet_logs: + spreadsheet_edit_counts[log.resource_id] += 1 + last_changed_at = spreadsheet_last_changed_at.get(log.resource_id) + if last_changed_at is None or log.created_at >= last_changed_at: + spreadsheet_last_changed_at[log.resource_id] = log.created_at + spreadsheet_last_actor[log.resource_id] = log.actor + + return { + item.id: { + "change_count": ( + spreadsheet_edit_counts.get(item.id, 0) + if item.asset_type == AgentAssetType.RULE.value + and str((item.config_json or {}).get("detail_mode") or "").strip().lower() + == "spreadsheet" + and spreadsheet_edit_counts.get(item.id, 0) > 0 + else max(version_counts.get(item.id, 0) - 1, 0) + ), + "modified_by": ( + spreadsheet_last_actor.get(item.id) + if item.asset_type == AgentAssetType.RULE.value + and str((item.config_json or {}).get("detail_mode") or "").strip().lower() + == "spreadsheet" + and spreadsheet_last_actor.get(item.id) + else modified_by.get(item.id) + ), + } + for item in assets + } + + @staticmethod + def _serialize_list_item( + asset: AgentAsset, + version_stats: dict[str, int | str | None] | None = None, + ) -> AgentAssetListItem: + payload = AgentAssetListItem.model_validate(asset).model_dump() + payload["change_count"] = int((version_stats or {}).get("change_count") or 0) + payload["modified_by"] = ( + str((version_stats or {}).get("modified_by") or "").strip() or None + ) + return AgentAssetListItem.model_validate(payload) + @staticmethod def _sort_versions( versions: list[AgentAssetVersion], current_version: str | None @@ -1104,6 +1170,138 @@ class AgentAssetService: raise FileNotFoundError("规则表版本快照不存在。") return resolved_version, metadata + def _resolve_current_spreadsheet_meta( + self, + asset: AgentAsset, + ) -> tuple[str, RuleSpreadsheetMeta]: + config_json = dict(asset.config_json or {}) + current_meta = self._read_current_rule_document_meta(asset) + file_name = ( + current_meta.file_name + if current_meta is not None and current_meta.file_name + else self._resolve_default_spreadsheet_file_name(asset) + ) + library = self._resolve_spreadsheet_rule_library(asset) + storage_key = (Path("rules") / library / file_name).as_posix() + file_path = self.spreadsheet_manager.resolve_storage_path(storage_key) + + if not file_path.exists(): + content: bytes | None = None + if current_meta is not None and current_meta.storage_key: + try: + legacy_path = self.spreadsheet_manager.resolve_storage_path( + current_meta.storage_key + ) + except FileNotFoundError: + legacy_path = None + if legacy_path is not None and legacy_path.exists(): + content = legacy_path.read_bytes() + if content is None: + content = AgentAssetSpreadsheetManager.build_blank_rule_workbook( + Path(file_name).stem or "规则表" + ) + meta = self.spreadsheet_manager.store_rule_library_spreadsheet( + library=library, + file_name=file_name, + content=content, + actor_name=( + current_meta.updated_by + if current_meta is not None and current_meta.updated_by + else "system" + ), + source="current-rule", + ) + else: + content = file_path.read_bytes() + meta = RuleSpreadsheetMeta( + file_name=file_name, + storage_key=storage_key, + mime_type=( + current_meta.mime_type + if current_meta is not None and current_meta.mime_type + else SPREADSHEET_MIME_TYPE + ), + size_bytes=file_path.stat().st_size, + checksum=self._hash_bytes(content), + updated_at=datetime.fromtimestamp(file_path.stat().st_mtime, UTC).isoformat(), + updated_by=( + current_meta.updated_by + if current_meta is not None and current_meta.updated_by + else "system" + ), + source=( + current_meta.source + if current_meta is not None and current_meta.source + else "current-rule" + ), + ) + + expected_document = { + **self.spreadsheet_manager.build_rule_document_config( + meta, + asset_version="current", + ), + "storage_key": meta.storage_key, + } + if config_json.get("rule_document") != expected_document: + config_json["detail_mode"] = "spreadsheet" + config_json["tag"] = str(config_json.get("tag") or "财务规则").strip() or "财务规则" + config_json["rule_library"] = library + config_json["rule_document"] = expected_document + asset.config_json = config_json + self.repository.save_asset(asset) + + return "current", meta + + def _store_current_rule_spreadsheet( + self, + asset: AgentAsset, + *, + file_name: str, + content: bytes, + actor: str, + source: str, + ) -> RuleSpreadsheetMeta: + library = self._resolve_spreadsheet_rule_library(asset) + metadata = self.spreadsheet_manager.store_rule_library_spreadsheet( + library=library, + file_name=file_name, + content=content, + actor_name=actor, + source=source, + ) + config_json = dict(asset.config_json or {}) + config_json["detail_mode"] = "spreadsheet" + config_json["tag"] = str(config_json.get("tag") or "财务规则").strip() or "财务规则" + config_json["rule_library"] = library + config_json["rule_document"] = { + **self.spreadsheet_manager.build_rule_document_config( + metadata, + asset_version="current", + ), + "storage_key": metadata.storage_key, + } + asset.config_json = config_json + self.repository.save_asset(asset) + return metadata + + @staticmethod + def _resolve_spreadsheet_rule_library(asset: AgentAsset) -> str: + config_json = dict(asset.config_json or {}) + library = str(config_json.get("rule_library") or FINANCE_RULES_LIBRARY).strip() + if library not in RULE_LIBRARY_NAMES: + return FINANCE_RULES_LIBRARY + return library + + @staticmethod + def _resolve_default_spreadsheet_file_name(asset: AgentAsset) -> str: + if asset.code == COMPANY_TRAVEL_EXPENSE_RULE_CODE: + return COMPANY_TRAVEL_EXPENSE_RULE_FILENAME + if asset.code == COMPANY_COMMUNICATION_EXPENSE_RULE_CODE: + return COMPANY_COMMUNICATION_EXPENSE_RULE_FILENAME + fallback = Path(str(asset.name or "规则表").strip()).name + return fallback if fallback.lower().endswith(".xlsx") else f"{fallback}.xlsx" + def _build_onlyoffice_spreadsheet_config( self, *, @@ -1286,6 +1484,11 @@ class AgentAssetService: can_edit = current_user.is_admin or "manager" in role_codes or "finance" in role_codes return can_edit and AgentAssetService._resolve_working_version(asset) == str(version or "").strip() + @staticmethod + def _can_edit_current_spreadsheet(current_user: CurrentUserContext) -> bool: + role_codes = {str(item).strip() for item in current_user.role_codes} + return current_user.is_admin or "manager" in role_codes or "finance" in role_codes + @staticmethod def _build_onlyoffice_document_key( asset_id: str, @@ -1428,3 +1631,93 @@ class AgentAssetService: if not normalized.startswith(prefix) or suffix not in normalized: return None return normalized.removeprefix(prefix).split(suffix, 1)[0].strip() or None + + def _resolve_json_risk_rule_document(self, asset: AgentAsset) -> tuple[str, str]: + config_json = dict(asset.config_json or {}) + detail_mode = str(config_json.get("detail_mode") or "").strip().lower() + if detail_mode != "json_risk": + raise ValueError("当前资产不是 JSON 风险规则。") + + rule_library = str(config_json.get("rule_library") or RISK_RULES_LIBRARY).strip() + if rule_library not in RULE_LIBRARY_NAMES: + raise ValueError("规则库目录不合法。") + + rule_document = config_json.get("rule_document") + if not isinstance(rule_document, dict): + raise ValueError("规则资产缺少 rule_document 配置。") + + file_name = str(rule_document.get("file_name") or "").strip() + if not file_name: + raise ValueError("规则资产缺少 JSON 文件名。") + return rule_library, file_name + + def read_rule_json(self, asset_id: str) -> AgentAssetRuleJsonRead: + asset = self.repository.get(asset_id) + if asset is None: + raise LookupError("资产不存在。") + + rule_library, file_name = self._resolve_json_risk_rule_document(asset) + payload = self.rule_library_manager.read_rule_library_json( + library=rule_library, + file_name=file_name, + ) + return AgentAssetRuleJsonRead( + file_name=file_name, + rule_code=str(payload.get("rule_code") or asset.code or ""), + name=str(payload.get("name") or asset.name or ""), + description=str(payload.get("description") or asset.description or "").strip(), + evaluator=str(payload.get("evaluator") or ""), + ontology_signal=str(payload.get("ontology_signal") or "") or None, + inputs=payload.get("inputs") if isinstance(payload.get("inputs"), dict) else {}, + outcomes=payload.get("outcomes") if isinstance(payload.get("outcomes"), dict) else {}, + payload=payload, + ) + + def write_rule_json( + self, + asset_id: str, + *, + body: AgentAssetRuleJsonWrite, + actor: str, + request_id: str | None = None, + ) -> AgentAssetRuleJsonRead: + asset = self.repository.get(asset_id) + if asset is None: + raise LookupError("资产不存在。") + + rule_library, file_name = self._resolve_json_risk_rule_document(asset) + payload = dict(body.payload or {}) + asset_code = str(asset.code or "").strip() + if asset_code and str(payload.get("rule_code") or "").strip() not in {"", asset_code}: + raise ValueError("规则 JSON 的 rule_code 必须与资产编码一致。") + if asset_code and not str(payload.get("rule_code") or "").strip(): + payload["rule_code"] = asset_code + + saved = self.rule_library_manager.write_rule_library_json( + library=rule_library, + file_name=file_name, + payload=payload, + ) + rule_description = str(saved.get("description") or "").strip() + if rule_description: + asset.description = rule_description + rule_name = str(saved.get("name") or "").strip() + if rule_name: + asset.name = rule_name + risk_category = str(saved.get("risk_category") or "").strip() + if risk_category: + config_json = dict(asset.config_json or {}) + config_json["risk_category"] = risk_category + asset.config_json = config_json + asset.scenario_json = [risk_category] + self.audit_service.log_action( + actor=actor, + action="update_agent_asset_rule_json", + resource_type=asset.asset_type, + resource_id=asset.id, + before_json={"file_name": file_name}, + after_json={"file_name": file_name, "rule_code": saved.get("rule_code")}, + request_id=request_id, + ) + self.db.commit() + return self.read_rule_json(asset_id) diff --git a/server/src/app/services/agent_foundation.py b/server/src/app/services/agent_foundation.py index 845f2a7..9f273fb 100644 --- a/server/src/app/services/agent_foundation.py +++ b/server/src/app/services/agent_foundation.py @@ -1,12 +1,12 @@ -from __future__ import annotations - -import hashlib -import json -from datetime import UTC, date, datetime -from decimal import Decimal -from pathlib import Path +from __future__ import annotations -from sqlalchemy import inspect, select, text +import hashlib +import json +from datetime import UTC, date, datetime +from decimal import Decimal +from pathlib import Path + +from sqlalchemy import inspect, select, text from sqlalchemy.orm import Session from app.core.agent_enums import ( @@ -28,25 +28,30 @@ from app.db.session import get_session_factory from app.models.agent_asset import AgentAsset, AgentAssetReview, AgentAssetVersion from app.models.agent_run import AgentRun, AgentToolCall, SemanticParseLog from app.models.audit_log import AuditLog -from app.models.financial_record import ( - AccountsPayableRecord, - AccountsReceivableRecord, - ExpenseClaim, - ExpenseClaimItem, -) -from app.services.agent_asset_spreadsheet import ( - AgentAssetSpreadsheetManager, - COMPANY_COMMUNICATION_EXPENSE_RULE_CODE, - COMPANY_COMMUNICATION_EXPENSE_RULE_FILENAME, - COMPANY_TRAVEL_EXPENSE_RULE_CODE, - COMPANY_TRAVEL_EXPENSE_RULE_FILENAME, - FINANCE_RULES_LIBRARY, - RuleSpreadsheetMeta, -) -from app.services.expense_rule_runtime import ( - build_scene_submission_standard_markdown, - build_travel_risk_control_standard_markdown, -) +from app.models.financial_record import ( + AccountsPayableRecord, + AccountsReceivableRecord, + ExpenseClaim, + ExpenseClaimItem, +) +from app.services.agent_asset_rule_library import AgentAssetRuleLibraryManager +from app.services.agent_asset_spreadsheet import ( + AgentAssetSpreadsheetManager, + COMPANY_COMMUNICATION_EXPENSE_RULE_CODE, + COMPANY_COMMUNICATION_EXPENSE_RULE_FILENAME, + COMPANY_TRAVEL_EXPENSE_RULE_CODE, + COMPANY_TRAVEL_EXPENSE_RULE_FILENAME, + FINANCE_RULES_LIBRARY, + RISK_RULES_LIBRARY, + RuleSpreadsheetMeta, +) + +PLATFORM_DESTINATION_LOCATION_RULE_CODE = "risk.travel.destination_receipt_location" +PLATFORM_DESTINATION_LOCATION_RULE_FILENAME = "risk.travel.destination_receipt_location.json" +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") @@ -88,9 +93,9 @@ LEGACY_RULE_CODES = ( "rule.ap.payment_dual_review", ) -ATTACHMENT_RULE_ASSET_CODE = "rule.expense.attachment_submission_requirements" -COMPANY_TRAVEL_RULE_VERSION = "v1.0.0" -COMPANY_COMMUNICATION_RULE_VERSION = "v1.0.0" +ATTACHMENT_RULE_ASSET_CODE = "rule.expense.attachment_submission_requirements" +COMPANY_TRAVEL_RULE_VERSION = "v1.0.0" +COMPANY_COMMUNICATION_RULE_VERSION = "v1.0.0" ATTACHMENT_RULE_RUNTIME_CONFIG = { "kind": "policy_rule_draft", @@ -169,11 +174,11 @@ class AgentFoundationService: def __init__(self, db: Session) -> None: self.db = db - def ensure_foundation_ready(self) -> None: - try: - Base.metadata.create_all(bind=self.db.get_bind()) - self._ensure_agent_asset_schema() - self._seed_agent_assets() + def ensure_foundation_ready(self) -> None: + try: + Base.metadata.create_all(bind=self.db.get_bind()) + self._ensure_agent_asset_schema() + self._seed_agent_assets() self._sync_demo_financial_records() self._seed_runs_and_logs() self.db.commit() @@ -188,7 +193,7 @@ class AgentFoundationService: return self._purge_demo_financial_records() - def _seed_agent_assets(self) -> None: + def _seed_agent_assets(self) -> None: existing_codes = set(self.db.scalars(select(AgentAsset.code)).all()) if existing_codes: self._top_up_agent_assets(existing_codes) @@ -203,10 +208,10 @@ class AgentFoundationService: scenario_json=["expense", "risk_check", "attachment_policy", "invoice_anomaly"], owner="财务制度管理组", reviewer="高嘉禾", - status=AgentAssetStatus.REVIEW.value, - current_version="v1.0.0", - published_version=None, - working_version="v1.0.0", + status=AgentAssetStatus.REVIEW.value, + current_version="v1.0.0", + published_version=None, + working_version="v1.0.0", config_json={ "severity": "high", "enabled": False, @@ -225,10 +230,10 @@ class AgentFoundationService: scenario_json=["expense", "risk_check", "scene_policy", "attachment_policy"], owner="费用运营组", reviewer="顾承宇", - status=AgentAssetStatus.ACTIVE.value, - current_version="v1.0.0", - published_version="v1.0.0", - working_version="v1.0.0", + status=AgentAssetStatus.ACTIVE.value, + current_version="v1.0.0", + published_version="v1.0.0", + working_version="v1.0.0", config_json={ "severity": "high", "enabled": True, @@ -236,19 +241,19 @@ class AgentFoundationService: "rule_template_label": "系统内置场景矩阵规则", }, ) - travel_policy_rule = AgentAsset( - asset_type=AgentAssetType.RULE.value, - code="rule.expense.travel_risk_control_standard", - name="差旅报销风险管控制度", + 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", - published_version="v1.1.0", - working_version="v1.1.0", + status=AgentAssetStatus.ACTIVE.value, + current_version="v1.1.0", + published_version="v1.1.0", + working_version="v1.1.0", config_json={ "severity": "high", "enabled": True, @@ -256,67 +261,68 @@ class AgentFoundationService: "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": "差旅标准模板", - }, - ) - company_travel_rule = AgentAsset( - asset_type=AgentAssetType.RULE.value, - code=COMPANY_TRAVEL_EXPENSE_RULE_CODE, - name="公司差旅费报销规则", - description="通过 Excel 明细表维护差旅费报销标准、票据要求和审批口径。", - domain=AgentAssetDomain.EXPENSE.value, - scenario_json=["expense", "travel_policy", "travel_standard"], - owner="财务制度管理组", - reviewer="顾承宇", - status=AgentAssetStatus.ACTIVE.value, - current_version=COMPANY_TRAVEL_RULE_VERSION, - published_version=COMPANY_TRAVEL_RULE_VERSION, - working_version=COMPANY_TRAVEL_RULE_VERSION, - config_json={ - "severity": "medium", - "enabled": True, - "tag": "财务规则", - "detail_mode": "spreadsheet", - "rule_library": FINANCE_RULES_LIBRARY, - "rule_template_label": "差旅报销 Excel 模板", - }, - ) - company_communication_rule = AgentAsset( - asset_type=AgentAssetType.RULE.value, - code=COMPANY_COMMUNICATION_EXPENSE_RULE_CODE, - name="公司通信费报销规则", - description="通过 Excel 明细表维护员工通信费报销标准、专项补充口径和审批要求。", - domain=AgentAssetDomain.EXPENSE.value, - scenario_json=["expense", "communication_expense", "expense_standard"], - owner="财务制度管理组", - reviewer="顾承宇", - status=AgentAssetStatus.ACTIVE.value, - current_version=COMPANY_COMMUNICATION_RULE_VERSION, - published_version=COMPANY_COMMUNICATION_RULE_VERSION, - working_version=COMPANY_COMMUNICATION_RULE_VERSION, - config_json={ - "severity": "medium", - "enabled": True, - "tag": "财务规则", - "detail_mode": "spreadsheet", - "rule_library": FINANCE_RULES_LIBRARY, - "rule_template_label": "通信费报销 Excel 模板", - }, - ) - skill_expense_asset = AgentAsset( - asset_type=AgentAssetType.SKILL.value, - code="skill.expense.summary_lookup", + "rule_template_key": "travel_standard_v1", + "rule_template_label": "差旅标准模板", + }, + ) + company_travel_rule = AgentAsset( + asset_type=AgentAssetType.RULE.value, + code=COMPANY_TRAVEL_EXPENSE_RULE_CODE, + name="公司差旅费报销规则", + description="通过 Excel 明细表维护差旅费报销标准、票据要求和审批口径。", + domain=AgentAssetDomain.EXPENSE.value, + scenario_json=["expense", "travel_policy", "travel_standard"], + owner="财务制度管理组", + reviewer="顾承宇", + status=AgentAssetStatus.ACTIVE.value, + current_version=COMPANY_TRAVEL_RULE_VERSION, + published_version=COMPANY_TRAVEL_RULE_VERSION, + working_version=COMPANY_TRAVEL_RULE_VERSION, + config_json={ + "severity": "medium", + "enabled": True, + "tag": "财务规则", + "detail_mode": "spreadsheet", + "rule_library": FINANCE_RULES_LIBRARY, + "rule_template_label": "差旅报销 Excel 模板", + }, + ) + platform_risk_assets = self._build_platform_risk_seed_assets() + company_communication_rule = AgentAsset( + asset_type=AgentAssetType.RULE.value, + code=COMPANY_COMMUNICATION_EXPENSE_RULE_CODE, + name="公司通信费报销规则", + description="通过 Excel 明细表维护员工通信费报销标准、专项补充口径和审批要求。", + domain=AgentAssetDomain.EXPENSE.value, + scenario_json=["expense", "communication_expense", "expense_standard"], + owner="财务制度管理组", + reviewer="顾承宇", + status=AgentAssetStatus.ACTIVE.value, + current_version=COMPANY_COMMUNICATION_RULE_VERSION, + published_version=COMPANY_COMMUNICATION_RULE_VERSION, + working_version=COMPANY_COMMUNICATION_RULE_VERSION, + config_json={ + "severity": "medium", + "enabled": True, + "tag": "财务规则", + "detail_mode": "spreadsheet", + "rule_library": FINANCE_RULES_LIBRARY, + "rule_template_label": "通信费报销 Excel 模板", + }, + ) + skill_expense_asset = AgentAsset( + asset_type=AgentAssetType.SKILL.value, + code="skill.expense.summary_lookup", name="报销汇总查询技能", description="根据时间、员工和部门汇总报销金额与单据数量。", domain=AgentAssetDomain.EXPENSE.value, scenario_json=["expense", "query", "summary"], owner="平台研发组", reviewer="陈硕", - status=AgentAssetStatus.ACTIVE.value, - current_version="v1.0.0", - published_version="v1.0.0", - working_version="v1.0.0", + status=AgentAssetStatus.ACTIVE.value, + current_version="v1.0.0", + published_version="v1.0.0", + working_version="v1.0.0", config_json={"input_schema": ["time_range", "employee", "department"]}, ) skill_ar_asset = AgentAsset( @@ -328,10 +334,10 @@ class AgentFoundationService: scenario_json=["accounts_receivable", "query", "aging_summary"], owner="平台研发组", reviewer="陈硕", - status=AgentAssetStatus.ACTIVE.value, - current_version="v1.0.0", - published_version="v1.0.0", - working_version="v1.0.0", + status=AgentAssetStatus.ACTIVE.value, + current_version="v1.0.0", + published_version="v1.0.0", + working_version="v1.0.0", config_json={"input_schema": ["customer", "aging_bucket", "status"]}, ) invoice_mcp_asset = AgentAsset( @@ -343,10 +349,10 @@ class AgentFoundationService: scenario_json=["expense", "invoice_validation"], owner="平台研发组", reviewer="周悦宁", - status=AgentAssetStatus.ACTIVE.value, - current_version="v1.0.0", - published_version="v1.0.0", - working_version="v1.0.0", + status=AgentAssetStatus.ACTIVE.value, + current_version="v1.0.0", + published_version="v1.0.0", + working_version="v1.0.0", config_json={"endpoint": "mock://invoice/verify", "timeout_ms": 1200}, ) ledger_mcp_asset = AgentAsset( @@ -358,10 +364,10 @@ class AgentFoundationService: scenario_json=["expense", "accounts_receivable", "accounts_payable"], owner="平台研发组", reviewer="周悦宁", - status=AgentAssetStatus.ACTIVE.value, - current_version="v1.0.0", - published_version="v1.0.0", - working_version="v1.0.0", + status=AgentAssetStatus.ACTIVE.value, + current_version="v1.0.0", + published_version="v1.0.0", + working_version="v1.0.0", config_json={"endpoint": "mock://ledger/snapshot", "timeout_ms": 1500}, ) task_asset = AgentAsset( @@ -373,10 +379,10 @@ class AgentFoundationService: scenario_json=["schedule", "risk_check"], owner="风控与审计部", reviewer="顾承宇", - status=AgentAssetStatus.ACTIVE.value, - current_version="v1.0.0", - published_version="v1.0.0", - working_version="v1.0.0", + status=AgentAssetStatus.ACTIVE.value, + current_version="v1.0.0", + published_version="v1.0.0", + working_version="v1.0.0", config_json={"cron": "0 9 * * *", "agent": AgentName.HERMES.value}, ) ar_summary_task = AgentAsset( @@ -388,10 +394,10 @@ class AgentFoundationService: scenario_json=["schedule", "accounts_receivable", "summary"], owner="风控与审计部", reviewer="顾承宇", - status=AgentAssetStatus.ACTIVE.value, - current_version="v1.0.0", - published_version="v1.0.0", - working_version="v1.0.0", + status=AgentAssetStatus.ACTIVE.value, + current_version="v1.0.0", + published_version="v1.0.0", + working_version="v1.0.0", config_json={"cron": "0 10 * * 1", "agent": AgentName.HERMES.value}, ) rule_digest_task = AgentAsset( @@ -403,10 +409,10 @@ class AgentFoundationService: scenario_json=["schedule", "rule_center", "review_digest"], owner="风控与审计部", reviewer="顾承宇", - status=AgentAssetStatus.ACTIVE.value, - current_version="v1.0.0", - published_version="v1.0.0", - working_version="v1.0.0", + status=AgentAssetStatus.ACTIVE.value, + current_version="v1.0.0", + published_version="v1.0.0", + working_version="v1.0.0", config_json={"cron": "0 18 * * *", "agent": AgentName.HERMES.value}, ) knowledge_index_task = AgentAsset( @@ -418,45 +424,46 @@ class AgentFoundationService: scenario_json=["schedule", "knowledge", "rule_center"], owner="财务制度管理组", reviewer="顾承宇", - status=AgentAssetStatus.ACTIVE.value, - current_version="v1.0.0", - published_version="v1.0.0", - working_version="v1.0.0", - config_json={"cron": "0 0 * * *", "agent": AgentName.HERMES.value}, + status=AgentAssetStatus.ACTIVE.value, + current_version="v1.0.0", + published_version="v1.0.0", + working_version="v1.0.0", + config_json={"cron": "0 0 * * *", "agent": AgentName.HERMES.value}, ) self.db.add_all( [ - attachment_rule, - scene_submission_rule, - travel_policy_rule, - company_travel_rule, - company_communication_rule, - skill_expense_asset, - skill_ar_asset, - invoice_mcp_asset, + attachment_rule, + scene_submission_rule, + travel_policy_rule, + *platform_risk_assets, + company_travel_rule, + company_communication_rule, + skill_expense_asset, + skill_ar_asset, + invoice_mcp_asset, ledger_mcp_asset, task_asset, ar_summary_task, rule_digest_task, knowledge_index_task, ] - ) - self.db.flush() - - company_travel_rule_meta = self._ensure_company_travel_rule_spreadsheet_seed( - company_travel_rule, - version=COMPANY_TRAVEL_RULE_VERSION, - actor_name="系统初始化", - ) - company_communication_rule_meta = self._ensure_company_communication_rule_spreadsheet_seed( - company_communication_rule, - version=COMPANY_COMMUNICATION_RULE_VERSION, - actor_name="系统初始化", - ) - - self.db.add_all( - [ + ) + self.db.flush() + + company_travel_rule_meta = self._ensure_company_travel_rule_spreadsheet_seed( + company_travel_rule, + version=COMPANY_TRAVEL_RULE_VERSION, + actor_name="系统初始化", + ) + company_communication_rule_meta = self._ensure_company_communication_rule_spreadsheet_seed( + company_communication_rule, + version=COMPANY_COMMUNICATION_RULE_VERSION, + actor_name="系统初始化", + ) + + self.db.add_all( + [ AgentAssetVersion( asset=attachment_rule, version="v0.9.0", @@ -495,41 +502,52 @@ class AgentFoundationService: 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=company_travel_rule, - version=COMPANY_TRAVEL_RULE_VERSION, - content=AgentAssetSpreadsheetManager.build_version_markdown( - rule_name=company_travel_rule.name, - version=COMPANY_TRAVEL_RULE_VERSION, - metadata=company_travel_rule_meta, - ), - content_type=AgentAssetContentType.MARKDOWN.value, - change_note="初始化差旅费报销 Excel 规则表。", - created_by="系统初始化", - ), - AgentAssetVersion( - asset=company_communication_rule, - version=COMPANY_COMMUNICATION_RULE_VERSION, - content=AgentAssetSpreadsheetManager.build_version_markdown( - rule_name=company_communication_rule.name, - version=COMPANY_COMMUNICATION_RULE_VERSION, - metadata=company_communication_rule_meta, - ), - content_type=AgentAssetContentType.MARKDOWN.value, - change_note="初始化通信费报销 Excel 规则表。", - created_by="系统初始化", - ), - AgentAssetVersion( - asset=skill_expense_asset, - version="v1.0.0", + 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=asset, + version="v1.0.0", + content=self._platform_risk_rule_markdown(asset), + content_type=AgentAssetContentType.MARKDOWN.value, + change_note=f"平台通用风险规则:{asset.name}", + created_by="系统初始化", + ) + for asset in platform_risk_assets + ], + AgentAssetVersion( + asset=company_travel_rule, + version=COMPANY_TRAVEL_RULE_VERSION, + content=AgentAssetSpreadsheetManager.build_version_markdown( + rule_name=company_travel_rule.name, + version=COMPANY_TRAVEL_RULE_VERSION, + metadata=company_travel_rule_meta, + ), + content_type=AgentAssetContentType.MARKDOWN.value, + change_note="初始化差旅费报销 Excel 规则表。", + created_by="系统初始化", + ), + AgentAssetVersion( + asset=company_communication_rule, + version=COMPANY_COMMUNICATION_RULE_VERSION, + content=AgentAssetSpreadsheetManager.build_version_markdown( + rule_name=company_communication_rule.name, + version=COMPANY_COMMUNICATION_RULE_VERSION, + metadata=company_communication_rule_meta, + ), + content_type=AgentAssetContentType.MARKDOWN.value, + change_note="初始化通信费报销 Excel 规则表。", + created_by="系统初始化", + ), + AgentAssetVersion( + asset=skill_expense_asset, + version="v1.0.0", content=self._json_content( { "inputs": ["time_range", "employee", "department"], @@ -631,7 +649,7 @@ class AgentFoundationService: content=self._json_content( { "task_type": "knowledge_index_sync", - "schedule": "0 0 * * *", + "schedule": "0 0 * * *", "target_agent": AgentName.HERMES.value, "folder": "报销制度", "changed_only": True, @@ -662,32 +680,32 @@ class AgentFoundationService: 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), - ), - AgentAssetReview( - asset=company_travel_rule, - version=COMPANY_TRAVEL_RULE_VERSION, - reviewer="顾承宇", - review_status=AgentReviewStatus.APPROVED.value, - review_note="首版 Excel 规则表已确认,可作为财务规则使用。", - reviewed_at=datetime.now(UTC), - ), - AgentAssetReview( - asset=company_communication_rule, - version=COMPANY_COMMUNICATION_RULE_VERSION, - reviewer="顾承宇", - review_status=AgentReviewStatus.APPROVED.value, - review_note="首版 Excel 规则表已确认,可作为财务规则使用。", - 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), + ), + AgentAssetReview( + asset=company_travel_rule, + version=COMPANY_TRAVEL_RULE_VERSION, + reviewer="顾承宇", + review_status=AgentReviewStatus.APPROVED.value, + review_note="首版 Excel 规则表已确认,可作为财务规则使用。", + reviewed_at=datetime.now(UTC), + ), + AgentAssetReview( + asset=company_communication_rule, + version=COMPANY_COMMUNICATION_RULE_VERSION, + reviewer="顾承宇", + review_status=AgentReviewStatus.APPROVED.value, + review_note="首版 Excel 规则表已确认,可作为财务规则使用。", + reviewed_at=datetime.now(UTC), + ), + ] + ) def _seed_financial_records(self) -> None: if self.db.scalar(select(ExpenseClaim.id).limit(1)) is not None: @@ -1046,15 +1064,15 @@ class AgentFoundationService: scene_submission_rule = self.db.scalar( select(AgentAsset).where(AgentAsset.code == "rule.expense.scene_submission_standard") ) - travel_policy_rule = self.db.scalar( - select(AgentAsset).where(AgentAsset.code == "rule.expense.travel_risk_control_standard") - ) - company_travel_rule = self.db.scalar( - select(AgentAsset).where(AgentAsset.code == COMPANY_TRAVEL_EXPENSE_RULE_CODE) - ) - company_communication_rule = self.db.scalar( - select(AgentAsset).where(AgentAsset.code == COMPANY_COMMUNICATION_EXPENSE_RULE_CODE) - ) + travel_policy_rule = self.db.scalar( + select(AgentAsset).where(AgentAsset.code == "rule.expense.travel_risk_control_standard") + ) + company_travel_rule = self.db.scalar( + select(AgentAsset).where(AgentAsset.code == COMPANY_TRAVEL_EXPENSE_RULE_CODE) + ) + company_communication_rule = self.db.scalar( + select(AgentAsset).where(AgentAsset.code == COMPANY_COMMUNICATION_EXPENSE_RULE_CODE) + ) if ATTACHMENT_RULE_ASSET_CODE not in existing_codes: attachment_rule = self._create_seed_asset( @@ -1078,12 +1096,12 @@ class AgentFoundationService: }, ) - if attachment_rule is not None: - if not str(attachment_rule.current_version or "").strip(): - attachment_rule.current_version = "v1.0.0" - if not str(attachment_rule.working_version or "").strip(): - attachment_rule.working_version = attachment_rule.current_version - attachment_rule.status = attachment_rule.status or AgentAssetStatus.REVIEW.value + if attachment_rule is not None: + if not str(attachment_rule.current_version or "").strip(): + attachment_rule.current_version = "v1.0.0" + if not str(attachment_rule.working_version or "").strip(): + attachment_rule.working_version = attachment_rule.current_version + attachment_rule.status = attachment_rule.status or AgentAssetStatus.REVIEW.value attachment_rule.description = "统一定义报销提交时的附件数量、票据类型和补件处理口径,作为上线前待审核规则。" attachment_rule.config_json = { "severity": "high", @@ -1144,14 +1162,14 @@ class AgentFoundationService: }, ) - if scene_submission_rule is not None: - if not str(scene_submission_rule.current_version or "").strip(): - scene_submission_rule.current_version = "v1.0.0" - if not str(scene_submission_rule.working_version or "").strip(): - scene_submission_rule.working_version = scene_submission_rule.current_version - if not str(scene_submission_rule.published_version or "").strip(): - scene_submission_rule.published_version = scene_submission_rule.current_version - scene_submission_rule.status = scene_submission_rule.status or AgentAssetStatus.ACTIVE.value + if scene_submission_rule is not None: + if not str(scene_submission_rule.current_version or "").strip(): + scene_submission_rule.current_version = "v1.0.0" + if not str(scene_submission_rule.working_version or "").strip(): + scene_submission_rule.working_version = scene_submission_rule.current_version + if not str(scene_submission_rule.published_version or "").strip(): + scene_submission_rule.published_version = scene_submission_rule.current_version + scene_submission_rule.status = scene_submission_rule.status or AgentAssetStatus.ACTIVE.value scene_submission_rule.description = "统一定义各报销场景的必填字段、附件类型要求和金额阈值。" scene_submission_rule.config_json = { "severity": "high", @@ -1200,14 +1218,14 @@ class AgentFoundationService: }, ) - if travel_policy_rule is not None: - if not str(travel_policy_rule.current_version or "").strip(): - travel_policy_rule.current_version = "v1.1.0" - if not str(travel_policy_rule.working_version or "").strip(): - travel_policy_rule.working_version = travel_policy_rule.current_version - if not str(travel_policy_rule.published_version or "").strip(): - travel_policy_rule.published_version = travel_policy_rule.current_version - travel_policy_rule.status = travel_policy_rule.status or AgentAssetStatus.ACTIVE.value + if travel_policy_rule is not None: + if not str(travel_policy_rule.current_version or "").strip(): + travel_policy_rule.current_version = "v1.1.0" + if not str(travel_policy_rule.working_version or "").strip(): + travel_policy_rule.working_version = travel_policy_rule.current_version + if not str(travel_policy_rule.published_version or "").strip(): + travel_policy_rule.published_version = travel_policy_rule.current_version + travel_policy_rule.status = travel_policy_rule.status or AgentAssetStatus.ACTIVE.value travel_policy_rule.config_json = { "severity": "high", "enabled": True, @@ -1239,145 +1257,147 @@ class AgentFoundationService: version="v1.1.0", reviewer="顾承宇", review_status=AgentReviewStatus.APPROVED.value, - review_note="制度口径已确认,并已补充可执行配置供审核引擎读取。", - reviewed_at=datetime.now(UTC), - ) - - if COMPANY_TRAVEL_EXPENSE_RULE_CODE not in existing_codes: - company_travel_rule = self._create_seed_asset( - asset_type=AgentAssetType.RULE.value, - code=COMPANY_TRAVEL_EXPENSE_RULE_CODE, - name="公司差旅费报销规则", - description="通过 Excel 明细表维护差旅费报销标准、票据要求和审批口径。", - domain=AgentAssetDomain.EXPENSE.value, - scenario_json=["expense", "travel_policy", "travel_standard"], - owner="财务制度管理组", - reviewer="顾承宇", - status=AgentAssetStatus.ACTIVE.value, - current_version=COMPANY_TRAVEL_RULE_VERSION, - config_json={ - "severity": "medium", - "enabled": True, - "tag": "财务规则", - "detail_mode": "spreadsheet", - "rule_template_label": "差旅报销 Excel 模板", - }, - ) - if COMPANY_COMMUNICATION_EXPENSE_RULE_CODE not in existing_codes: - company_communication_rule = self._create_seed_asset( - asset_type=AgentAssetType.RULE.value, - code=COMPANY_COMMUNICATION_EXPENSE_RULE_CODE, - name="公司通信费报销规则", - description="通过 Excel 明细表维护员工通信费报销标准、专项补充口径和审批要求。", - domain=AgentAssetDomain.EXPENSE.value, - scenario_json=["expense", "communication_expense", "expense_standard"], - owner="财务制度管理组", - reviewer="顾承宇", - status=AgentAssetStatus.ACTIVE.value, - current_version=COMPANY_COMMUNICATION_RULE_VERSION, - config_json={ - "severity": "medium", - "enabled": True, - "tag": "财务规则", - "detail_mode": "spreadsheet", - "rule_template_label": "通信费报销 Excel 模板", - }, - ) - - if company_travel_rule is not None: - if not str(company_travel_rule.current_version or "").strip(): - company_travel_rule.current_version = COMPANY_TRAVEL_RULE_VERSION - if not str(company_travel_rule.working_version or "").strip(): - company_travel_rule.working_version = company_travel_rule.current_version - if not str(company_travel_rule.published_version or "").strip(): - company_travel_rule.published_version = company_travel_rule.current_version - if not str(company_travel_rule.status or "").strip(): - company_travel_rule.status = AgentAssetStatus.ACTIVE.value - company_travel_rule.description = "通过 Excel 明细表维护差旅费报销标准、票据要求和审批口径。" - company_travel_rule.config_json = { - **(company_travel_rule.config_json or {}), - "severity": "medium", - "enabled": True, - "tag": "财务规则", - "detail_mode": "spreadsheet", - "rule_library": FINANCE_RULES_LIBRARY, - "rule_template_label": "差旅报销 Excel 模板", - } - company_travel_rule_meta = self._ensure_company_travel_rule_spreadsheet_seed( - company_travel_rule, - version=str(company_travel_rule.current_version or COMPANY_TRAVEL_RULE_VERSION), - actor_name="系统初始化", - ) - self._ensure_asset_version( - company_travel_rule, - version=str(company_travel_rule.current_version or COMPANY_TRAVEL_RULE_VERSION), - content=AgentAssetSpreadsheetManager.build_version_markdown( - rule_name=company_travel_rule.name, - version=str(company_travel_rule.current_version or COMPANY_TRAVEL_RULE_VERSION), - metadata=company_travel_rule_meta, - ), - content_type=AgentAssetContentType.MARKDOWN.value, - change_note="初始化差旅费报销 Excel 规则表。", - created_by="系统初始化", - ) - if str(company_travel_rule.current_version or "").strip() == COMPANY_TRAVEL_RULE_VERSION: - self._ensure_asset_review( - company_travel_rule, - version=COMPANY_TRAVEL_RULE_VERSION, - reviewer="顾承宇", - review_status=AgentReviewStatus.APPROVED.value, - review_note="首版 Excel 规则表已确认,可作为财务规则使用。", - reviewed_at=datetime.now(UTC), - ) - - if company_communication_rule is not None: - if not str(company_communication_rule.current_version or "").strip(): - company_communication_rule.current_version = COMPANY_COMMUNICATION_RULE_VERSION - if not str(company_communication_rule.working_version or "").strip(): - company_communication_rule.working_version = company_communication_rule.current_version - if not str(company_communication_rule.published_version or "").strip(): - company_communication_rule.published_version = company_communication_rule.current_version - if not str(company_communication_rule.status or "").strip(): - company_communication_rule.status = AgentAssetStatus.ACTIVE.value - company_communication_rule.description = "通过 Excel 明细表维护员工通信费报销标准、专项补充口径和审批要求。" - company_communication_rule.config_json = { - **(company_communication_rule.config_json or {}), - "severity": "medium", - "enabled": True, - "tag": "财务规则", - "detail_mode": "spreadsheet", - "rule_library": FINANCE_RULES_LIBRARY, - "rule_template_label": "通信费报销 Excel 模板", - } - company_communication_rule_meta = self._ensure_company_communication_rule_spreadsheet_seed( - company_communication_rule, - version=str(company_communication_rule.current_version or COMPANY_COMMUNICATION_RULE_VERSION), - actor_name="系统初始化", - ) - self._ensure_asset_version( - company_communication_rule, - version=str(company_communication_rule.current_version or COMPANY_COMMUNICATION_RULE_VERSION), - content=AgentAssetSpreadsheetManager.build_version_markdown( - rule_name=company_communication_rule.name, - version=str(company_communication_rule.current_version or COMPANY_COMMUNICATION_RULE_VERSION), - metadata=company_communication_rule_meta, - ), - content_type=AgentAssetContentType.MARKDOWN.value, - change_note="初始化通信费报销 Excel 规则表。", - created_by="系统初始化", - ) - if str(company_communication_rule.current_version or "").strip() == COMPANY_COMMUNICATION_RULE_VERSION: - self._ensure_asset_review( - company_communication_rule, - version=COMPANY_COMMUNICATION_RULE_VERSION, - reviewer="顾承宇", - review_status=AgentReviewStatus.APPROVED.value, - review_note="首版 Excel 规则表已确认,可作为财务规则使用。", - reviewed_at=datetime.now(UTC), - ) - - if "skill.ar.aging_summary" not in existing_codes: - asset = self._create_seed_asset( + review_note="制度口径已确认,并已补充可执行配置供审核引擎读取。", + reviewed_at=datetime.now(UTC), + ) + + self.sync_platform_risk_rules_from_library() + + if COMPANY_TRAVEL_EXPENSE_RULE_CODE not in existing_codes: + company_travel_rule = self._create_seed_asset( + asset_type=AgentAssetType.RULE.value, + code=COMPANY_TRAVEL_EXPENSE_RULE_CODE, + name="公司差旅费报销规则", + description="通过 Excel 明细表维护差旅费报销标准、票据要求和审批口径。", + domain=AgentAssetDomain.EXPENSE.value, + scenario_json=["expense", "travel_policy", "travel_standard"], + owner="财务制度管理组", + reviewer="顾承宇", + status=AgentAssetStatus.ACTIVE.value, + current_version=COMPANY_TRAVEL_RULE_VERSION, + config_json={ + "severity": "medium", + "enabled": True, + "tag": "财务规则", + "detail_mode": "spreadsheet", + "rule_template_label": "差旅报销 Excel 模板", + }, + ) + if COMPANY_COMMUNICATION_EXPENSE_RULE_CODE not in existing_codes: + company_communication_rule = self._create_seed_asset( + asset_type=AgentAssetType.RULE.value, + code=COMPANY_COMMUNICATION_EXPENSE_RULE_CODE, + name="公司通信费报销规则", + description="通过 Excel 明细表维护员工通信费报销标准、专项补充口径和审批要求。", + domain=AgentAssetDomain.EXPENSE.value, + scenario_json=["expense", "communication_expense", "expense_standard"], + owner="财务制度管理组", + reviewer="顾承宇", + status=AgentAssetStatus.ACTIVE.value, + current_version=COMPANY_COMMUNICATION_RULE_VERSION, + config_json={ + "severity": "medium", + "enabled": True, + "tag": "财务规则", + "detail_mode": "spreadsheet", + "rule_template_label": "通信费报销 Excel 模板", + }, + ) + + if company_travel_rule is not None: + if not str(company_travel_rule.current_version or "").strip(): + company_travel_rule.current_version = COMPANY_TRAVEL_RULE_VERSION + if not str(company_travel_rule.working_version or "").strip(): + company_travel_rule.working_version = company_travel_rule.current_version + if not str(company_travel_rule.published_version or "").strip(): + company_travel_rule.published_version = company_travel_rule.current_version + if not str(company_travel_rule.status or "").strip(): + company_travel_rule.status = AgentAssetStatus.ACTIVE.value + company_travel_rule.description = "通过 Excel 明细表维护差旅费报销标准、票据要求和审批口径。" + company_travel_rule.config_json = { + **(company_travel_rule.config_json or {}), + "severity": "medium", + "enabled": True, + "tag": "财务规则", + "detail_mode": "spreadsheet", + "rule_library": FINANCE_RULES_LIBRARY, + "rule_template_label": "差旅报销 Excel 模板", + } + company_travel_rule_meta = self._ensure_company_travel_rule_spreadsheet_seed( + company_travel_rule, + version=str(company_travel_rule.current_version or COMPANY_TRAVEL_RULE_VERSION), + actor_name="系统初始化", + ) + self._ensure_asset_version( + company_travel_rule, + version=str(company_travel_rule.current_version or COMPANY_TRAVEL_RULE_VERSION), + content=AgentAssetSpreadsheetManager.build_version_markdown( + rule_name=company_travel_rule.name, + version=str(company_travel_rule.current_version or COMPANY_TRAVEL_RULE_VERSION), + metadata=company_travel_rule_meta, + ), + content_type=AgentAssetContentType.MARKDOWN.value, + change_note="初始化差旅费报销 Excel 规则表。", + created_by="系统初始化", + ) + if str(company_travel_rule.current_version or "").strip() == COMPANY_TRAVEL_RULE_VERSION: + self._ensure_asset_review( + company_travel_rule, + version=COMPANY_TRAVEL_RULE_VERSION, + reviewer="顾承宇", + review_status=AgentReviewStatus.APPROVED.value, + review_note="首版 Excel 规则表已确认,可作为财务规则使用。", + reviewed_at=datetime.now(UTC), + ) + + if company_communication_rule is not None: + if not str(company_communication_rule.current_version or "").strip(): + company_communication_rule.current_version = COMPANY_COMMUNICATION_RULE_VERSION + if not str(company_communication_rule.working_version or "").strip(): + company_communication_rule.working_version = company_communication_rule.current_version + if not str(company_communication_rule.published_version or "").strip(): + company_communication_rule.published_version = company_communication_rule.current_version + if not str(company_communication_rule.status or "").strip(): + company_communication_rule.status = AgentAssetStatus.ACTIVE.value + company_communication_rule.description = "通过 Excel 明细表维护员工通信费报销标准、专项补充口径和审批要求。" + company_communication_rule.config_json = { + **(company_communication_rule.config_json or {}), + "severity": "medium", + "enabled": True, + "tag": "财务规则", + "detail_mode": "spreadsheet", + "rule_library": FINANCE_RULES_LIBRARY, + "rule_template_label": "通信费报销 Excel 模板", + } + company_communication_rule_meta = self._ensure_company_communication_rule_spreadsheet_seed( + company_communication_rule, + version=str(company_communication_rule.current_version or COMPANY_COMMUNICATION_RULE_VERSION), + actor_name="系统初始化", + ) + self._ensure_asset_version( + company_communication_rule, + version=str(company_communication_rule.current_version or COMPANY_COMMUNICATION_RULE_VERSION), + content=AgentAssetSpreadsheetManager.build_version_markdown( + rule_name=company_communication_rule.name, + version=str(company_communication_rule.current_version or COMPANY_COMMUNICATION_RULE_VERSION), + metadata=company_communication_rule_meta, + ), + content_type=AgentAssetContentType.MARKDOWN.value, + change_note="初始化通信费报销 Excel 规则表。", + created_by="系统初始化", + ) + if str(company_communication_rule.current_version or "").strip() == COMPANY_COMMUNICATION_RULE_VERSION: + self._ensure_asset_review( + company_communication_rule, + version=COMPANY_COMMUNICATION_RULE_VERSION, + reviewer="顾承宇", + review_status=AgentReviewStatus.APPROVED.value, + review_note="首版 Excel 规则表已确认,可作为财务规则使用。", + reviewed_at=datetime.now(UTC), + ) + + if "skill.ar.aging_summary" not in existing_codes: + asset = self._create_seed_asset( asset_type=AgentAssetType.SKILL.value, code="skill.ar.aging_summary", name="应收账龄汇总技能", @@ -1492,10 +1512,10 @@ class AgentFoundationService: created_by="系统初始化", ) - if "task.hermes.knowledge_index_sync" not in existing_codes: - asset = self._create_seed_asset( - asset_type=AgentAssetType.TASK.value, - code="task.hermes.knowledge_index_sync", + if "task.hermes.knowledge_index_sync" not in existing_codes: + asset = self._create_seed_asset( + asset_type=AgentAssetType.TASK.value, + code="task.hermes.knowledge_index_sync", name="Hermes ??????", description="?????????? LightRAG ???????", domain=AgentAssetDomain.SYSTEM.value, @@ -1504,7 +1524,7 @@ class AgentFoundationService: reviewer="顾承宇", status=AgentAssetStatus.ACTIVE.value, current_version="v1.0.0", - config_json={"cron": "0 0 * * *", "agent": AgentName.HERMES.value}, + config_json={"cron": "0 0 * * *", "agent": AgentName.HERMES.value}, ) self._ensure_asset_version( asset, @@ -1512,241 +1532,197 @@ class AgentFoundationService: content=self._json_content( { "task_type": "knowledge_index_sync", - "schedule": "0 0 * * *", + "schedule": "0 0 * * *", "target_agent": AgentName.HERMES.value, "folder": "报销制度", "changed_only": True, } ), content_type=AgentAssetContentType.JSON.value, - change_note="初始化制度知识与规则草稿形成任务。", - created_by="系统初始化", - ) - - def _ensure_company_travel_rule_spreadsheet_seed( - self, - asset: AgentAsset, - *, - version: str, - actor_name: str, - ): - manager = AgentAssetSpreadsheetManager() - manager.ensure_rule_library_dirs() - live_document = manager.store_rule_library_spreadsheet( - library=FINANCE_RULES_LIBRARY, - file_name=COMPANY_TRAVEL_EXPENSE_RULE_FILENAME, - content=self._read_or_build_company_travel_rule_file(manager), - actor_name=actor_name, - source="rule-library", - ) - existing_document = ( - asset.config_json.get("rule_document") - if isinstance(asset.config_json, dict) - else None - ) - storage_key = ( - str(existing_document.get("storage_key") or "").strip() - if isinstance(existing_document, dict) - else "" - ) - if storage_key: - try: - existing_path = manager.resolve_storage_path(storage_key) - except FileNotFoundError: - existing_path = None - if existing_path is not None and existing_path.exists(): - metadata = RuleSpreadsheetMeta( - file_name=str(existing_document.get("file_name") or COMPANY_TRAVEL_EXPENSE_RULE_FILENAME), - storage_key=storage_key, - mime_type=str(existing_document.get("mime_type") or "").strip() - or "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet", - size_bytes=int(existing_document.get("size_bytes") or existing_path.stat().st_size), - checksum=hashlib.sha256(existing_path.read_bytes()).hexdigest(), - updated_at=str(existing_document.get("updated_at") or "").strip() - or datetime.now(UTC).isoformat(), - updated_by=str(existing_document.get("updated_by") or actor_name).strip() - or actor_name, - source=str(existing_document.get("source") or "seed").strip() or "seed", - ) - asset.config_json = { - **(asset.config_json or {}), - "detail_mode": "spreadsheet", - "tag": "财务规则", - "rule_library": FINANCE_RULES_LIBRARY, - "rule_document": { - **AgentAssetSpreadsheetManager.build_rule_document_config( - live_document, - asset_version=version, - ), - "storage_key": live_document.storage_key, - }, - } - return metadata - - live_content = manager.resolve_storage_path(live_document.storage_key).read_bytes() - metadata = manager.store_spreadsheet( - asset_id=asset.id, - version=version, - file_name=COMPANY_TRAVEL_EXPENSE_RULE_FILENAME, - content=live_content, - actor_name=actor_name, - source="seed", - ) - asset.config_json = { - **(asset.config_json or {}), - "detail_mode": "spreadsheet", - "tag": "财务规则", - "rule_library": FINANCE_RULES_LIBRARY, - "rule_document": { - **AgentAssetSpreadsheetManager.build_rule_document_config( - live_document, - asset_version=version, - ), - "storage_key": live_document.storage_key, - }, - } - return metadata - - def _ensure_company_communication_rule_spreadsheet_seed( - self, - asset: AgentAsset, - *, - version: str, - actor_name: str, - ): - return self._ensure_finance_rule_spreadsheet_seed( - asset, - version=version, - actor_name=actor_name, - file_name=COMPANY_COMMUNICATION_EXPENSE_RULE_FILENAME, - fallback_sheet_name="通信费报销规则", - ) - - @staticmethod - def _read_or_build_company_travel_rule_file( - manager: AgentAssetSpreadsheetManager, - ) -> bytes: - live_key = ( - Path("rules") - / FINANCE_RULES_LIBRARY - / COMPANY_TRAVEL_EXPENSE_RULE_FILENAME - ).as_posix() - live_path = manager.resolve_storage_path(live_key) - if live_path.exists(): - return live_path.read_bytes() - return AgentAssetSpreadsheetManager.build_blank_rule_workbook("差旅费报销规则") - - def _ensure_finance_rule_spreadsheet_seed( - self, - asset: AgentAsset, - *, - version: str, - actor_name: str, - file_name: str, - fallback_sheet_name: str, - ): - manager = AgentAssetSpreadsheetManager() - manager.ensure_rule_library_dirs() - live_document = manager.store_rule_library_spreadsheet( - library=FINANCE_RULES_LIBRARY, - file_name=file_name, - content=self._read_or_build_finance_rule_file( - manager, - file_name=file_name, - fallback_sheet_name=fallback_sheet_name, - ), - actor_name=actor_name, - source="rule-library", - ) - existing_document = ( - asset.config_json.get("rule_document") - if isinstance(asset.config_json, dict) - else None - ) - storage_key = ( - str(existing_document.get("storage_key") or "").strip() - if isinstance(existing_document, dict) - else "" - ) - if storage_key: - try: - existing_path = manager.resolve_storage_path(storage_key) - except FileNotFoundError: - existing_path = None - if existing_path is not None and existing_path.exists(): - metadata = RuleSpreadsheetMeta( - file_name=str(existing_document.get("file_name") or file_name), - storage_key=storage_key, - mime_type=str(existing_document.get("mime_type") or "").strip() - or "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet", - size_bytes=int(existing_document.get("size_bytes") or existing_path.stat().st_size), - checksum=hashlib.sha256(existing_path.read_bytes()).hexdigest(), - updated_at=str(existing_document.get("updated_at") or "").strip() - or datetime.now(UTC).isoformat(), - updated_by=str(existing_document.get("updated_by") or actor_name).strip() - or actor_name, - source=str(existing_document.get("source") or "seed").strip() or "seed", - ) - asset.config_json = { - **(asset.config_json or {}), - "detail_mode": "spreadsheet", - "tag": "财务规则", - "rule_library": FINANCE_RULES_LIBRARY, - "rule_document": { - **AgentAssetSpreadsheetManager.build_rule_document_config( - live_document, - asset_version=version, - ), - "storage_key": live_document.storage_key, - }, - } - return metadata - - live_content = manager.resolve_storage_path(live_document.storage_key).read_bytes() - metadata = manager.store_spreadsheet( - asset_id=asset.id, - version=version, - file_name=file_name, - content=live_content, - actor_name=actor_name, - source="seed", - ) - asset.config_json = { - **(asset.config_json or {}), - "detail_mode": "spreadsheet", - "tag": "财务规则", - "rule_library": FINANCE_RULES_LIBRARY, - "rule_document": { - **AgentAssetSpreadsheetManager.build_rule_document_config( - live_document, - asset_version=version, - ), - "storage_key": live_document.storage_key, - }, - } - return metadata - - @staticmethod - def _read_or_build_finance_rule_file( - manager: AgentAssetSpreadsheetManager, - *, - file_name: str, - fallback_sheet_name: str, - ) -> bytes: - live_key = ( - Path("rules") - / FINANCE_RULES_LIBRARY - / file_name - ).as_posix() - live_path = manager.resolve_storage_path(live_key) - if live_path.exists(): - return live_path.read_bytes() - return AgentAssetSpreadsheetManager.build_blank_rule_workbook(fallback_sheet_name) - - def _create_seed_asset( - self, - *, - asset_type: str, + change_note="初始化制度知识与规则草稿形成任务。", + created_by="系统初始化", + ) + + def _ensure_company_travel_rule_spreadsheet_seed( + self, + asset: AgentAsset, + *, + version: str, + actor_name: str, + ): + manager = AgentAssetSpreadsheetManager() + manager.ensure_rule_library_dirs() + live_document = manager.store_rule_library_spreadsheet( + library=FINANCE_RULES_LIBRARY, + file_name=COMPANY_TRAVEL_EXPENSE_RULE_FILENAME, + content=self._read_or_build_company_travel_rule_file(manager), + actor_name=actor_name, + source="rule-library", + ) + existing_document = ( + asset.config_json.get("rule_document") + if isinstance(asset.config_json, dict) + else None + ) + storage_key = ( + str(existing_document.get("storage_key") or "").strip() + if isinstance(existing_document, dict) + else "" + ) + if storage_key: + try: + existing_path = manager.resolve_storage_path(storage_key) + except FileNotFoundError: + existing_path = None + if existing_path is not None and existing_path.exists(): + asset.config_json = { + **(asset.config_json or {}), + "detail_mode": "spreadsheet", + "tag": "财务规则", + "rule_library": FINANCE_RULES_LIBRARY, + "rule_document": { + **AgentAssetSpreadsheetManager.build_rule_document_config( + live_document, + asset_version=version, + ), + "storage_key": live_document.storage_key, + }, + } + return live_document + + asset.config_json = { + **(asset.config_json or {}), + "detail_mode": "spreadsheet", + "tag": "财务规则", + "rule_library": FINANCE_RULES_LIBRARY, + "rule_document": { + **AgentAssetSpreadsheetManager.build_rule_document_config( + live_document, + asset_version=version, + ), + "storage_key": live_document.storage_key, + }, + } + return live_document + + def _ensure_company_communication_rule_spreadsheet_seed( + self, + asset: AgentAsset, + *, + version: str, + actor_name: str, + ): + return self._ensure_finance_rule_spreadsheet_seed( + asset, + version=version, + actor_name=actor_name, + file_name=COMPANY_COMMUNICATION_EXPENSE_RULE_FILENAME, + fallback_sheet_name="通信费报销规则", + ) + + @staticmethod + def _read_or_build_company_travel_rule_file( + manager: AgentAssetSpreadsheetManager, + ) -> bytes: + live_key = ( + Path("rules") + / FINANCE_RULES_LIBRARY + / COMPANY_TRAVEL_EXPENSE_RULE_FILENAME + ).as_posix() + live_path = manager.resolve_storage_path(live_key) + if live_path.exists(): + return live_path.read_bytes() + return AgentAssetSpreadsheetManager.build_blank_rule_workbook("差旅费报销规则") + + def _ensure_finance_rule_spreadsheet_seed( + self, + asset: AgentAsset, + *, + version: str, + actor_name: str, + file_name: str, + fallback_sheet_name: str, + ): + manager = AgentAssetSpreadsheetManager() + manager.ensure_rule_library_dirs() + live_document = manager.store_rule_library_spreadsheet( + library=FINANCE_RULES_LIBRARY, + file_name=file_name, + content=self._read_or_build_finance_rule_file( + manager, + file_name=file_name, + fallback_sheet_name=fallback_sheet_name, + ), + actor_name=actor_name, + source="rule-library", + ) + existing_document = ( + asset.config_json.get("rule_document") + if isinstance(asset.config_json, dict) + else None + ) + storage_key = ( + str(existing_document.get("storage_key") or "").strip() + if isinstance(existing_document, dict) + else "" + ) + if storage_key: + try: + existing_path = manager.resolve_storage_path(storage_key) + except FileNotFoundError: + existing_path = None + if existing_path is not None and existing_path.exists(): + asset.config_json = { + **(asset.config_json or {}), + "detail_mode": "spreadsheet", + "tag": "财务规则", + "rule_library": FINANCE_RULES_LIBRARY, + "rule_document": { + **AgentAssetSpreadsheetManager.build_rule_document_config( + live_document, + asset_version=version, + ), + "storage_key": live_document.storage_key, + }, + } + return live_document + + asset.config_json = { + **(asset.config_json or {}), + "detail_mode": "spreadsheet", + "tag": "财务规则", + "rule_library": FINANCE_RULES_LIBRARY, + "rule_document": { + **AgentAssetSpreadsheetManager.build_rule_document_config( + live_document, + asset_version=version, + ), + "storage_key": live_document.storage_key, + }, + } + return live_document + + @staticmethod + def _read_or_build_finance_rule_file( + manager: AgentAssetSpreadsheetManager, + *, + file_name: str, + fallback_sheet_name: str, + ) -> bytes: + live_key = ( + Path("rules") + / FINANCE_RULES_LIBRARY + / file_name + ).as_posix() + live_path = manager.resolve_storage_path(live_key) + if live_path.exists(): + return live_path.read_bytes() + return AgentAssetSpreadsheetManager.build_blank_rule_workbook(fallback_sheet_name) + + def _create_seed_asset( + self, + *, + asset_type: str, code: str, name: str, description: str, @@ -1767,12 +1743,12 @@ class AgentFoundationService: scenario_json=scenario_json, owner=owner, reviewer=reviewer, - status=status, - current_version=current_version, - published_version=current_version if status == AgentAssetStatus.ACTIVE.value else None, - working_version=current_version, - config_json=config_json, - ) + status=status, + current_version=current_version, + published_version=current_version if status == AgentAssetStatus.ACTIVE.value else None, + working_version=current_version, + config_json=config_json, + ) self.db.add(asset) self.db.flush() return asset @@ -1838,7 +1814,7 @@ class AgentFoundationService: ) ) - def _remove_legacy_rule_assets(self) -> None: + def _remove_legacy_rule_assets(self) -> None: assets = list( self.db.scalars( select(AgentAsset).where(AgentAsset.code.in_(LEGACY_RULE_CODES)) @@ -1852,38 +1828,38 @@ class AgentFoundationService: select(AuditLog).where(AuditLog.resource_id.in_(LEGACY_RULE_CODES)) ).all() ) - for log in obsolete_logs: - self.db.delete(log) - - def _ensure_agent_asset_schema(self) -> None: - bind = self.db.get_bind() - inspector = inspect(bind) - if "agent_assets" not in inspector.get_table_names(): - return - - column_names = {column["name"] for column in inspector.get_columns("agent_assets")} - migration_statements: list[str] = [] - if "published_version" not in column_names: - migration_statements.append("ALTER TABLE agent_assets ADD COLUMN published_version VARCHAR(30)") - if "working_version" not in column_names: - migration_statements.append("ALTER TABLE agent_assets ADD COLUMN working_version VARCHAR(30)") - - for statement in migration_statements: - self.db.execute(text(statement)) - - self.db.execute( - text( - "UPDATE agent_assets " - "SET working_version = COALESCE(working_version, current_version), " - "published_version = CASE " - "WHEN published_version IS NOT NULL THEN published_version " - "WHEN status = 'active' THEN current_version " - "ELSE published_version END" - ) - ) - - if migration_statements: - self.db.commit() + for log in obsolete_logs: + self.db.delete(log) + + def _ensure_agent_asset_schema(self) -> None: + bind = self.db.get_bind() + inspector = inspect(bind) + if "agent_assets" not in inspector.get_table_names(): + return + + column_names = {column["name"] for column in inspector.get_columns("agent_assets")} + migration_statements: list[str] = [] + if "published_version" not in column_names: + migration_statements.append("ALTER TABLE agent_assets ADD COLUMN published_version VARCHAR(30)") + if "working_version" not in column_names: + migration_statements.append("ALTER TABLE agent_assets ADD COLUMN working_version VARCHAR(30)") + + for statement in migration_statements: + self.db.execute(text(statement)) + + self.db.execute( + text( + "UPDATE agent_assets " + "SET working_version = COALESCE(working_version, current_version), " + "published_version = CASE " + "WHEN published_version IS NOT NULL THEN published_version " + "WHEN status = 'active' THEN current_version " + "ELSE published_version END" + ) + ) + + if migration_statements: + self.db.commit() def _attachment_submission_requirement_markdown( self, @@ -1952,6 +1928,229 @@ class AgentFoundationService: def _travel_risk_control_standard_markdown(self, *, version: str = "v1.1.0") -> str: return self._markdown_content(build_travel_risk_control_standard_markdown()) + def _iter_platform_risk_manifests(self) -> list[tuple[str, dict[str, object]]]: + manager = AgentAssetRuleLibraryManager() + manifests: list[tuple[str, dict[str, object]]] = [] + for file_name in sorted(manager.list_rule_library_json_files(library=RISK_RULES_LIBRARY)): + payload = manager.read_rule_library_json(library=RISK_RULES_LIBRARY, file_name=file_name) + if payload.get("enabled") is False: + continue + manifests.append((file_name, payload)) + return manifests + + @staticmethod + def _resolve_platform_risk_category(manifest: dict[str, object]) -> str: + explicit = str(manifest.get("risk_category") or "").strip() + if explicit: + return explicit + + rule_code = str(manifest.get("rule_code") or "").strip().lower() + applies_to = manifest.get("applies_to") if isinstance(manifest.get("applies_to"), dict) else {} + domains = {str(item or "").strip().lower() for item in applies_to.get("domains") or []} + expense_types = { + str(item or "").strip().lower() for item in applies_to.get("expense_types") or [] + } + + if rule_code.startswith("risk.invoice."): + return "发票" + if "meal" in domains or "entertainment" in expense_types: + return "餐饮招待" + if "transport" in expense_types or "consecutive_transport" in rule_code: + return "交通出行" + if "office" in expense_types: + return "办公物料" + if "travel" in domains or rule_code.startswith("risk.travel."): + return "差旅" + if rule_code.startswith("risk.expense."): + return "费用科目" + return "通用" + + def _platform_risk_scenario_json(self, manifest: dict[str, object]) -> list[str]: + category = self._resolve_platform_risk_category(manifest) + return [category] if category else ["通用"] + + def _platform_risk_config_json(self, file_name: str, manifest: dict[str, object]) -> dict[str, object]: + outcomes = manifest.get("outcomes") if isinstance(manifest.get("outcomes"), dict) else {} + fail_outcome = outcomes.get("fail") if isinstance(outcomes.get("fail"), dict) else {} + risk_category = self._resolve_platform_risk_category(manifest) + return { + "severity": str(fail_outcome.get("severity") or "medium"), + "enabled": True, + "tag": "风险规则", + "detail_mode": "json_risk", + "risk_category": risk_category, + "rule_library": RISK_RULES_LIBRARY, + "rule_document": { + "file_name": file_name, + "storage_key": f"rules/{RISK_RULES_LIBRARY}/{file_name}", + }, + "ontology_signal": str(manifest.get("ontology_signal") or "").strip(), + "evaluator": str(manifest.get("evaluator") or "").strip(), + "source_ref": ( + (manifest.get("metadata") or {}).get("source_ref") + if isinstance(manifest.get("metadata"), dict) + else "" + ), + } + + def _build_platform_risk_seed_assets(self) -> list[AgentAsset]: + assets: list[AgentAsset] = [] + for file_name, manifest in self._iter_platform_risk_manifests(): + rule_code = str(manifest.get("rule_code") or "").strip() + if not rule_code: + continue + metadata = manifest.get("metadata") if isinstance(manifest.get("metadata"), dict) else {} + source_ref = str(metadata.get("source_ref") or "").strip() + rule_description = str(manifest.get("description") or "").strip() + assets.append( + AgentAsset( + asset_type=AgentAssetType.RULE.value, + code=rule_code, + name=str(manifest.get("name") or rule_code), + description=rule_description + or f"平台通用风险规则:{source_ref or manifest.get('name') or rule_code}", + domain=AgentAssetDomain.EXPENSE.value, + scenario_json=self._platform_risk_scenario_json(manifest), + owner=str(metadata.get("owner") or "风控与审计部"), + reviewer="顾承宇", + status=AgentAssetStatus.ACTIVE.value, + current_version="v1.0.0", + published_version="v1.0.0", + working_version="v1.0.0", + config_json=self._platform_risk_config_json(file_name, manifest), + ) + ) + return assets + + def sync_platform_risk_rules_from_library(self) -> int: + existing_codes = set(self.db.scalars(select(AgentAsset.code)).all()) + before_count = len(existing_codes) + self._ensure_platform_risk_rules_from_library(existing_codes) + self.db.flush() + after_codes = set(self.db.scalars(select(AgentAsset.code)).all()) + synced = max(len(after_codes) - before_count, 0) + manifest_count = len(self._iter_platform_risk_manifests()) + logger.info( + "Platform risk rules synced from library", + extra={"manifest_count": manifest_count, "created_count": synced, "total": len(after_codes)}, + ) + return manifest_count + + def _ensure_platform_risk_rules_from_library(self, existing_codes: set[str]) -> None: + for file_name, manifest in self._iter_platform_risk_manifests(): + rule_code = str(manifest.get("rule_code") or "").strip() + if not rule_code: + continue + + metadata = manifest.get("metadata") if isinstance(manifest.get("metadata"), dict) else {} + source_ref = str(metadata.get("source_ref") or "").strip() + rule_description = str(manifest.get("description") or "").strip() + config_json = self._platform_risk_config_json(file_name, manifest) + scenario_json = self._platform_risk_scenario_json(manifest) + + asset = self.db.scalar(select(AgentAsset).where(AgentAsset.code == rule_code)) + if asset is None and rule_code not in existing_codes: + asset = self._create_seed_asset( + asset_type=AgentAssetType.RULE.value, + code=rule_code, + name=str(manifest.get("name") or rule_code), + description=rule_description + or f"平台通用风险规则:{source_ref or manifest.get('name') or rule_code}", + domain=AgentAssetDomain.EXPENSE.value, + scenario_json=scenario_json, + owner=str(metadata.get("owner") or "风控与审计部"), + reviewer="顾承宇", + status=AgentAssetStatus.ACTIVE.value, + current_version="v1.0.0", + config_json=config_json, + ) + + if asset is None: + continue + + if not str(asset.current_version or "").strip(): + asset.current_version = "v1.0.0" + if not str(asset.working_version or "").strip(): + asset.working_version = asset.current_version + if not str(asset.published_version or "").strip(): + asset.published_version = asset.current_version + asset.status = asset.status or AgentAssetStatus.ACTIVE.value + asset.name = str(manifest.get("name") or asset.name or rule_code) + if rule_description: + asset.description = rule_description + asset.config_json = config_json + asset.scenario_json = scenario_json + + self._ensure_asset_version( + asset, + version="v1.0.0", + content=self._platform_risk_rule_markdown(asset, manifest=manifest, file_name=file_name), + content_type=AgentAssetContentType.MARKDOWN.value, + change_note=f"平台通用风险规则:{asset.name}", + created_by="系统初始化", + ) + self._ensure_asset_review( + asset, + version="v1.0.0", + reviewer="顾承宇", + review_status=AgentReviewStatus.APPROVED.value, + review_note="平台内置风险规则,供提交验审与风险问答共用。", + reviewed_at=datetime.now(UTC), + ) + + @staticmethod + def _platform_risk_rule_markdown( + asset: AgentAsset, + *, + manifest: dict[str, object] | None = None, + file_name: str = "", + ) -> str: + config = asset.config_json if isinstance(asset.config_json, dict) else {} + rule_document = config.get("rule_document") if isinstance(config.get("rule_document"), dict) else {} + resolved_file_name = file_name or str(rule_document.get("file_name") or "").strip() + evaluator = str(config.get("evaluator") or (manifest or {}).get("evaluator") or "").strip() + ontology_signal = str(config.get("ontology_signal") or (manifest or {}).get("ontology_signal") or "").strip() + source_ref = str(config.get("source_ref") or "").strip() + if not source_ref and isinstance(manifest, dict): + metadata = manifest.get("metadata") if isinstance(manifest.get("metadata"), dict) else {} + source_ref = str(metadata.get("source_ref") or "").strip() + + lines = [ + f"# {asset.name}", + "", + "## 规则类型", + "", + "- 平台内置通用风险规则(`json_risk`)", + ] + if evaluator: + lines.append(f"- 检查器:`{evaluator}`") + if ontology_signal: + lines.append(f"- 本体信号:`{ontology_signal}`") + if source_ref: + lines.extend(["", "## 来源", "", f"- {source_ref}"]) + if resolved_file_name: + lines.extend( + [ + "", + "## 配置文件", + "", + f"- `rules/{RISK_RULES_LIBRARY}/{resolved_file_name}`", + ] + ) + return "\n".join(lines) + + @staticmethod + def _platform_destination_location_risk_markdown() -> str: + return AgentFoundationService._platform_risk_rule_markdown( + AgentAsset(name="申报地点与票据地点一致", config_json={"evaluator": "location_consistency"}), + manifest={ + "evaluator": "location_consistency", + "ontology_signal": "location_mismatch", + "metadata": {"source_ref": "常用risk.txt / 一、出差类 / 行程不符"}, + }, + file_name=PLATFORM_DESTINATION_LOCATION_RULE_FILENAME, + ) + @staticmethod def _markdown_content(content: str) -> str: return content diff --git a/server/src/app/services/user_agent.py b/server/src/app/services/user_agent.py index d1f10d2..22f3741 100644 --- a/server/src/app/services/user_agent.py +++ b/server/src/app/services/user_agent.py @@ -1,13 +1,13 @@ from __future__ import annotations -import json -import re -from datetime import UTC, datetime, timedelta -from decimal import Decimal, InvalidOperation -from typing import Any +import json +import re +from datetime import UTC, datetime, timedelta +from decimal import Decimal, InvalidOperation +from typing import Any from sqlalchemy import or_, select -from sqlalchemy.orm import Session +from sqlalchemy.orm import Session, selectinload from app.core.agent_enums import AgentAssetStatus, AgentAssetType from app.models.employee import Employee @@ -33,6 +33,8 @@ from app.schemas.user_agent import ( ) from app.services.agent_assets import AgentAssetService from app.services.agent_foundation import AgentFoundationService +from app.services.expense_claims import ExpenseClaimService +from app.services.risk_ontology_bridge import resolve_rule_codes_for_risk_check from app.services.runtime_chat import RuntimeChatService SCENARIO_LABELS = { @@ -45,6 +47,7 @@ SCENARIO_LABELS = { RISK_REASON_MAP = { "duplicate_expense": "检测到同员工、同金额或近似单据存在重复提交迹象。", + "location_mismatch": "申报出差地点与票据识别地点可能不一致,需要核对行程或补充说明。", "amount_over_limit": "金额超过当前制度或预算阈值,需要补充例外说明。", "invoice_anomaly": "票据或附件完整性不满足当前规则要求,需要补件或人工复核。", "ar_overdue": "应收账款已出现逾期,存在回款延迟风险。", @@ -77,8 +80,8 @@ EXPENSE_TYPE_LABELS = { "other": "其他费用", } -GROUP_SCENE_LABELS = { - "travel": "差旅费", +GROUP_SCENE_LABELS = { + "travel": "差旅费", "entertainment": "业务招待费", "meal": "伙食费", "transport": "交通费", @@ -88,62 +91,62 @@ GROUP_SCENE_LABELS = { "communication": "通讯费", "welfare": "福利费", "other": "其他费用", -} - -KNOWLEDGE_MODEL_MAIN_TIMEOUT_SECONDS = 3 -KNOWLEDGE_MODEL_BACKUP_TIMEOUT_SECONDS = 5 -KNOWLEDGE_MODEL_TIMEOUT_SECONDS = KNOWLEDGE_MODEL_BACKUP_TIMEOUT_SECONDS - -KNOWLEDGE_DIRECT_ANSWER_HINTS = ( - "是什么", - "标准", - "限额", - "流程", - "条件", - "规则", - "怎么", - "如何", - "哪些", - "需要", - "是否", - "区别", - "范围", - "额度", - "金额", - "多少", - "多少钱", - "上限", -) -KNOWLEDGE_QUERY_STOPWORDS = { - "什么", - "多少", - "哪些", - "怎么", - "如何", - "请问", - "一下", - "关于", - "规定", - "标准", - "可以", - "是否", - "一个", - "哪些人", - "目前", - "当前", - "一下子", -} -MAX_KNOWLEDGE_QUERY_TERMS = 12 -MAX_KNOWLEDGE_DIRECT_EVIDENCE = 4 -MAX_KNOWLEDGE_MODEL_HITS = 5 -KNOWLEDGE_SECTION_HEADING_PATTERN = re.compile( - r"^(#\s*.+|##\s*.+|###\s*.+|第[一二三四五六七八九十百零0-9]+[章节条]\s*.*|[一二三四五六七八九十]+、.*|([一二三四五六七八九十]+).*|\([一二三四五六七八九十]+\).*)$" -) -KNOWLEDGE_LIST_ITEM_PATTERN = re.compile(r"^[-*•]\s+.+$") -KNOWLEDGE_NUMBERED_ITEM_PATTERN = re.compile( - r"^(?:(?:\d+[.)、])|(?:[((][一二三四五六七八九十百零0-9]+[))])|[①②③④⑤⑥⑦⑧⑨⑩])\s*.+$" -) -KNOWLEDGE_ARTICLE_PATTERN = re.compile(r"^(第[一二三四五六七八九十百零0-9]+条)\s*.*$") +} + +KNOWLEDGE_MODEL_MAIN_TIMEOUT_SECONDS = 3 +KNOWLEDGE_MODEL_BACKUP_TIMEOUT_SECONDS = 5 +KNOWLEDGE_MODEL_TIMEOUT_SECONDS = KNOWLEDGE_MODEL_BACKUP_TIMEOUT_SECONDS + +KNOWLEDGE_DIRECT_ANSWER_HINTS = ( + "是什么", + "标准", + "限额", + "流程", + "条件", + "规则", + "怎么", + "如何", + "哪些", + "需要", + "是否", + "区别", + "范围", + "额度", + "金额", + "多少", + "多少钱", + "上限", +) +KNOWLEDGE_QUERY_STOPWORDS = { + "什么", + "多少", + "哪些", + "怎么", + "如何", + "请问", + "一下", + "关于", + "规定", + "标准", + "可以", + "是否", + "一个", + "哪些人", + "目前", + "当前", + "一下子", +} +MAX_KNOWLEDGE_QUERY_TERMS = 12 +MAX_KNOWLEDGE_DIRECT_EVIDENCE = 4 +MAX_KNOWLEDGE_MODEL_HITS = 5 +KNOWLEDGE_SECTION_HEADING_PATTERN = re.compile( + r"^(#\s*.+|##\s*.+|###\s*.+|第[一二三四五六七八九十百零0-9]+[章节条]\s*.*|[一二三四五六七八九十]+、.*|([一二三四五六七八九十]+).*|\([一二三四五六七八九十]+\).*)$" +) +KNOWLEDGE_LIST_ITEM_PATTERN = re.compile(r"^[-*•]\s+.+$") +KNOWLEDGE_NUMBERED_ITEM_PATTERN = re.compile( + r"^(?:(?:\d+[.)、])|(?:[((][一二三四五六七八九十百零0-9]+[))])|[①②③④⑤⑥⑦⑧⑨⑩])\s*.+$" +) +KNOWLEDGE_ARTICLE_PATTERN = re.compile(r"^(第[一二三四五六七八九十百零0-9]+条)\s*.*$") EXPENSE_STATUS_LABELS = { "draft": "草稿", @@ -174,14 +177,16 @@ SLOT_LABELS = { } DATE_TEXT_PATTERN = re.compile(r"(\d{4}[年/-]\d{1,2}[月/-]\d{1,2}日?)") -AMOUNT_TEXT_PATTERN = re.compile(r"(\d+(?:\.\d+)?)\s*(?:元|万元|万)") +AMOUNT_TEXT_PATTERN = re.compile( + r"(\d+(?:\.\d+)?)\s*(?:万元|万员|万圆|万园|万块|万元整|元整|块钱|块|元|员|圆|园|万)" +) DOCUMENT_AMOUNT_PATTERN = re.compile( r"(?:价税合计|合计金额|费用合计|订单(?:总)?金额|支付(?:金额)?|实付(?:金额)?|实收(?:金额)?|总(?:额|计|价)|票价|金额|车费|消费金额)" r"[::\s¥¥人民币]*([0-9]+(?:[.,][0-9]{1,2})?)" ) DOCUMENT_CURRENCY_AMOUNT_PATTERN = re.compile(r"[¥¥]\s*([0-9]+(?:[.,][0-9]{1,2})?)") -SOURCE_LABELS = { +SOURCE_LABELS = { "user_text": "用户描述", "user_form": "用户修改", "ocr": "票据识别", @@ -210,7 +215,7 @@ INFERRED_REASON_LABELS = { "welfare": "员工福利", "other": "其他费用", } -SYSTEM_GENERATED_REASON_PREFIXES = ( +SYSTEM_GENERATED_REASON_PREFIXES = ( "我上传了", "请按当前已识别信息", "请把当前上传的票据", @@ -220,7 +225,20 @@ SYSTEM_GENERATED_REASON_PREFIXES = ( "我已修改识别信息", "查看报销草稿", "请解释一下当前这笔报销的合规风险和待补充项", -) +) +AMOUNT_UNIT_ALIASES = { + "员": "元", + "圆": "元", + "园": "元", + "块": "元", + "块钱": "元", + "元整": "元", + "万员": "万元", + "万圆": "万元", + "万园": "万元", + "万块": "万元", + "万元整": "万元", +} class UserAgentService: @@ -275,42 +293,42 @@ class UserAgentService: requires_confirmation=payload.requires_confirmation, ) - guided_answer = None - if draft_payload is None or draft_payload.claim_id is None: - guided_answer = self._build_guided_answer(payload) - if guided_answer: - return UserAgentResponse( + guided_answer = None + if draft_payload is None or draft_payload.claim_id is None: + guided_answer = self._build_guided_answer(payload) + if guided_answer: + return UserAgentResponse( answer=guided_answer, citations=citations, suggested_actions=suggested_actions, query_payload=query_payload, draft_payload=draft_payload, review_payload=review_payload, - risk_flags=risk_flags, - requires_confirmation=payload.requires_confirmation, - ) - - fast_knowledge_answer = self._build_fast_knowledge_answer( - payload, - citations=citations, - ) - if fast_knowledge_answer: - return UserAgentResponse( - answer=fast_knowledge_answer, - citations=citations, - suggested_actions=suggested_actions, - query_payload=query_payload, - draft_payload=draft_payload, - review_payload=review_payload, - risk_flags=risk_flags, - requires_confirmation=payload.requires_confirmation, - ) - - fallback_answer = self._build_fallback_answer( - payload, - citations=citations, - draft_payload=draft_payload, - ) + risk_flags=risk_flags, + requires_confirmation=payload.requires_confirmation, + ) + + fast_knowledge_answer = self._build_fast_knowledge_answer( + payload, + citations=citations, + ) + if fast_knowledge_answer: + return UserAgentResponse( + answer=fast_knowledge_answer, + citations=citations, + suggested_actions=suggested_actions, + query_payload=query_payload, + draft_payload=draft_payload, + review_payload=review_payload, + risk_flags=risk_flags, + requires_confirmation=payload.requires_confirmation, + ) + + fallback_answer = self._build_fallback_answer( + payload, + citations=citations, + draft_payload=draft_payload, + ) answer = None if not self._should_skip_model_answer(payload, review_payload): answer = self._generate_answer_with_model( @@ -433,27 +451,27 @@ class UserAgentService: draft_payload=draft_payload, fallback_answer=fallback_answer, ) - answer = self._sanitize_model_answer( - self.runtime_chat_service.complete( - messages, - max_tokens=800 if payload.ontology.scenario == "knowledge" else 420, - temperature=0.2, - timeout_seconds=( - KNOWLEDGE_MODEL_TIMEOUT_SECONDS - if payload.ontology.scenario == "knowledge" - else None - ), - slot_timeouts=( - { - "main": KNOWLEDGE_MODEL_MAIN_TIMEOUT_SECONDS, - "backup": KNOWLEDGE_MODEL_BACKUP_TIMEOUT_SECONDS, - } - if payload.ontology.scenario == "knowledge" - else None - ), - max_attempts=1 if payload.ontology.scenario == "knowledge" else None, - ) - ) + answer = self._sanitize_model_answer( + self.runtime_chat_service.complete( + messages, + max_tokens=800 if payload.ontology.scenario == "knowledge" else 420, + temperature=0.2, + timeout_seconds=( + KNOWLEDGE_MODEL_TIMEOUT_SECONDS + if payload.ontology.scenario == "knowledge" + else None + ), + slot_timeouts=( + { + "main": KNOWLEDGE_MODEL_MAIN_TIMEOUT_SECONDS, + "backup": KNOWLEDGE_MODEL_BACKUP_TIMEOUT_SECONDS, + } + if payload.ontology.scenario == "knowledge" + else None + ), + max_attempts=1 if payload.ontology.scenario == "knowledge" else None, + ) + ) return self._reject_unsupported_location_inference(payload, answer) def _sanitize_model_answer(self, answer: str | None) -> str | None: @@ -476,10 +494,10 @@ class UserAgentService: return None return cleaned or None - @staticmethod - def _extract_query_location(message: str) -> str: - match = re.search(r"(?:去|到|前往)([\u4e00-\u9fff]{2,8})(?:出差|开会|培训)", str(message or "")) - return match.group(1) if match else "" + @staticmethod + def _extract_query_location(message: str) -> str: + match = re.search(r"(?:去|到|前往)([\u4e00-\u9fff]{2,8})(?:出差|开会|培训)", str(message or "")) + return match.group(1) if match else "" def _reject_unsupported_location_inference( self, @@ -499,15 +517,15 @@ class UserAgentService: draft_payload: UserAgentDraftPayload | None, fallback_answer: str, ) -> list[dict[str, str]]: - knowledge_question = ( - self._resolve_knowledge_question(payload) - if payload.ontology.scenario == "knowledge" - else "" - ) - facts = { - "run_id": payload.run_id, - "user_message": payload.message, - "ontology": payload.ontology.model_dump(mode="json"), + knowledge_question = ( + self._resolve_knowledge_question(payload) + if payload.ontology.scenario == "knowledge" + else "" + ) + facts = { + "run_id": payload.run_id, + "user_message": payload.message, + "ontology": payload.ontology.model_dump(mode="json"), "context": { "entry_source": payload.context_json.get("entry_source"), "user_name": payload.context_json.get("name"), @@ -527,69 +545,69 @@ class UserAgentService: "draft_claim_id": payload.context_json.get("draft_claim_id"), "conversation_history": self._resolve_conversation_history(payload), }, - "tool_payload": self._build_model_tool_payload( - payload.tool_payload, - question=knowledge_question, - ), + "tool_payload": self._build_model_tool_payload( + payload.tool_payload, + question=knowledge_question, + ), "citations": [item.model_dump(mode="json") for item in citations], "suggested_actions": [item.model_dump(mode="json") for item in suggested_actions], "risk_flags": risk_flags, "draft_payload": draft_payload.model_dump(mode="json") if draft_payload is not None else None, - "selected_capability_codes": payload.selected_capability_codes, - "requires_confirmation": payload.requires_confirmation, - "fallback_answer": fallback_answer, - } - if payload.ontology.scenario == "knowledge": - facts["knowledge_evidence_blocks"] = self._build_knowledge_evidence_blocks( - payload.tool_payload, - question=knowledge_question, - ) - facts["knowledge_answer_evidence"] = [ - { - "title": str(item.get("title") or "").strip(), - "heading": str(item.get("heading") or "").strip(), - "kind": str(item.get("kind") or "").strip(), - "content": str(item.get("content") or "").strip(), - } - for item in self._build_knowledge_answer_evidence(payload) - ] - - if payload.ontology.scenario == "knowledge": - answer_style_instruction = ( - "你是财务制度知识问答助手。只能依据 facts.tool_payload.hits、facts.knowledge_answer_evidence、citations 与 conversation_history 回答," - "不要扩展成通用助手。优先直接回答,不要复述思考过程,不要输出 JSON、代码块或 。" - "回答风格要像一位真正熟悉制度的财务伙伴:先直接回应用户的核心问题,再用一张简洁表格或短段落说明依据," - "最后补充最重要的注意事项。不要写成“已检索到内容”的系统回执,也不要把命中片段连缀成答案。" - "必须优先回答用户当前这句话本身,不能把制度标题、制度全文或完整标准表当成主答案。" - "如果用户问的是某次具体行程“一共能报多少”,就先给“当前已能确认的金额”,再用一张很短的表说明项目、" - "适用标准、计算式和结果;如果总额还缺少住宿晚数、实际票据或其他必要条件,就明确写出“暂不能确认的部分”。" - "只有用户明确在问“标准有哪些”或“制度全文怎么规定”时,才展开完整标准表。" - "如果命中的知识已经足够支持计算、比较或归纳,就直接给出结论;金额、标准、天数、补贴等问题要把计算过程写清楚。" - "适合时请使用 Markdown 二级标题、短段落和表格,让回答更清晰;表格必须保证每一行列数一致,不要出现空白残列。" - "只能陈述 hits 中明确出现的事实,不能用常识、外部知识或主观推断补齐缺失条件。" - "回答前先在全部 hits 中寻找与问题最直接相关的章节、表格或条目,不能只依赖排在最前面的片段。" - "如果 facts.knowledge_answer_evidence 中已经给出更短的高相关证据,优先基于这些证据组织答案,再回看原始 hits 补上下文。" - "如果某个表格在检索片段中已经被摊平成连续文本,只有在行、列和数值对应关系能够从片段本身明确确认时才能据此计算;" - "如果列对应关系不清楚,必须说明表格结构在当前片段中不够清晰,不能把第一列或相邻数字想当然套给用户。" - "如果 hits 中出现“结构化表格补充”,它表示知识归纳阶段已经把原文表格重新整理过," - "优先使用这类结构化表格来理解行列关系,再回看原文确认上下文。" - "facts.knowledge_evidence_blocks 中保留了原始换行和定宽排版;遇到表格时,优先按这些证据块阅读," - "必须按表头从左到右逐列对应数值,不能把第一列的数值直接套给后面的列名。" - "如果完成计算或归纳仍缺少某个关键映射关系、适用条件或数值依据,必须明确说明当前知识库还缺哪一项信息,再给出已能确认的部分。" - "如果用户问题里没有明确给出某个套用条件,而 hits 或 evidence 里也没有明确出现,就不能自己补一个默认值。" - "当问题涉及追问时,必须结合 conversation_history 延续上一轮上下文,而不是重新泛化成制度全文摘录。" - "不要大段粘贴原始命中文本;只提炼与问题直接相关的规则、条件、金额和注意事项。" - "如果依据仍然不足,明确指出缺少哪一项信息,再给出当前能确认的部分。" - ) + "selected_capability_codes": payload.selected_capability_codes, + "requires_confirmation": payload.requires_confirmation, + "fallback_answer": fallback_answer, + } + if payload.ontology.scenario == "knowledge": + facts["knowledge_evidence_blocks"] = self._build_knowledge_evidence_blocks( + payload.tool_payload, + question=knowledge_question, + ) + facts["knowledge_answer_evidence"] = [ + { + "title": str(item.get("title") or "").strip(), + "heading": str(item.get("heading") or "").strip(), + "kind": str(item.get("kind") or "").strip(), + "content": str(item.get("content") or "").strip(), + } + for item in self._build_knowledge_answer_evidence(payload) + ] + + if payload.ontology.scenario == "knowledge": + answer_style_instruction = ( + "你是财务制度知识问答助手。只能依据 facts.tool_payload.hits、facts.knowledge_answer_evidence、citations 与 conversation_history 回答," + "不要扩展成通用助手。优先直接回答,不要复述思考过程,不要输出 JSON、代码块或 。" + "回答风格要像一位真正熟悉制度的财务伙伴:先直接回应用户的核心问题,再用一张简洁表格或短段落说明依据," + "最后补充最重要的注意事项。不要写成“已检索到内容”的系统回执,也不要把命中片段连缀成答案。" + "必须优先回答用户当前这句话本身,不能把制度标题、制度全文或完整标准表当成主答案。" + "如果用户问的是某次具体行程“一共能报多少”,就先给“当前已能确认的金额”,再用一张很短的表说明项目、" + "适用标准、计算式和结果;如果总额还缺少住宿晚数、实际票据或其他必要条件,就明确写出“暂不能确认的部分”。" + "只有用户明确在问“标准有哪些”或“制度全文怎么规定”时,才展开完整标准表。" + "如果命中的知识已经足够支持计算、比较或归纳,就直接给出结论;金额、标准、天数、补贴等问题要把计算过程写清楚。" + "适合时请使用 Markdown 二级标题、短段落和表格,让回答更清晰;表格必须保证每一行列数一致,不要出现空白残列。" + "只能陈述 hits 中明确出现的事实,不能用常识、外部知识或主观推断补齐缺失条件。" + "回答前先在全部 hits 中寻找与问题最直接相关的章节、表格或条目,不能只依赖排在最前面的片段。" + "如果 facts.knowledge_answer_evidence 中已经给出更短的高相关证据,优先基于这些证据组织答案,再回看原始 hits 补上下文。" + "如果某个表格在检索片段中已经被摊平成连续文本,只有在行、列和数值对应关系能够从片段本身明确确认时才能据此计算;" + "如果列对应关系不清楚,必须说明表格结构在当前片段中不够清晰,不能把第一列或相邻数字想当然套给用户。" + "如果 hits 中出现“结构化表格补充”,它表示知识归纳阶段已经把原文表格重新整理过," + "优先使用这类结构化表格来理解行列关系,再回看原文确认上下文。" + "facts.knowledge_evidence_blocks 中保留了原始换行和定宽排版;遇到表格时,优先按这些证据块阅读," + "必须按表头从左到右逐列对应数值,不能把第一列的数值直接套给后面的列名。" + "如果完成计算或归纳仍缺少某个关键映射关系、适用条件或数值依据,必须明确说明当前知识库还缺哪一项信息,再给出已能确认的部分。" + "如果用户问题里没有明确给出某个套用条件,而 hits 或 evidence 里也没有明确出现,就不能自己补一个默认值。" + "当问题涉及追问时,必须结合 conversation_history 延续上一轮上下文,而不是重新泛化成制度全文摘录。" + "不要大段粘贴原始命中文本;只提炼与问题直接相关的规则、条件、金额和注意事项。" + "如果依据仍然不足,明确指出缺少哪一项信息,再给出当前能确认的部分。" + ) else: answer_style_instruction = "用 2 到 4 段完成回答,优先给结论,再补充最关键的依据与下一步建议。" - personalization_instruction = ( - "如果 context.user_name 存在,并且当前问题与员工本人适用标准、报销额度、审批权限、职级待遇有关," - "开头应自然称呼一次用户,例如“曹笑竹,您好”。" - "如果需要根据员工身份判断标准,优先参考 context.user_grade 与 context.user_position。" - "如果问题与用户身份无关,就不要生硬加入姓名、职级或岗位。" - ) + personalization_instruction = ( + "如果 context.user_name 存在,并且当前问题与员工本人适用标准、报销额度、审批权限、职级待遇有关," + "开头应自然称呼一次用户,例如“曹笑竹,您好”。" + "如果需要根据员工身份判断标准,优先参考 context.user_grade 与 context.user_position。" + "如果问题与用户身份无关,就不要生硬加入姓名、职级或岗位。" + ) system_prompt = ( "你是 X-Financial 的专业财务 AI 助手。" @@ -609,148 +627,148 @@ class UserAgentService: {"role": "user", "content": user_prompt}, ] - @staticmethod - def _build_model_tool_payload( - tool_payload: dict[str, Any], - *, - question: str | None = None, - ) -> dict[str, Any]: - normalized = dict(tool_payload or {}) - hits = [] - for item in UserAgentService._select_knowledge_model_hits( - tool_payload, - question=question, - ): - if not isinstance(item, dict): - continue - hits.append( + @staticmethod + def _build_model_tool_payload( + tool_payload: dict[str, Any], + *, + question: str | None = None, + ) -> dict[str, Any]: + normalized = dict(tool_payload or {}) + hits = [] + for item in UserAgentService._select_knowledge_model_hits( + tool_payload, + question=question, + ): + if not isinstance(item, dict): + continue + hits.append( { "title": str(item.get("title") or "").strip(), "document_name": str(item.get("document_name") or "").strip(), "excerpt": str(item.get("excerpt") or "").strip(), - "content": str(item.get("content") or "").strip()[:1200], + "content": str(item.get("content") or "").strip()[:1200], "tags": list(item.get("tags") or [])[:5], "evidence": list(item.get("evidence") or [])[:3], "code": str(item.get("code") or "").strip(), } ) - normalized["hits"] = hits - return normalized - - @staticmethod - def _build_knowledge_evidence_blocks( - tool_payload: dict[str, Any], - *, - question: str | None = None, - ) -> str: - blocks: list[str] = [] - for index, item in enumerate( - UserAgentService._select_knowledge_model_hits( - tool_payload, - question=question, - )[:3], - start=1, - ): - if not isinstance(item, dict): - continue - title = str(item.get("title") or item.get("document_name") or f"证据 {index}").strip() - code = str(item.get("code") or "").strip() - content = str(item.get("content") or "").strip() - if not content: - continue - blocks.append( - "\n".join( - [ - f"[证据 {index}] {title}" + (f" ({code})" if code else ""), - "```text", - content[:1200], - "```", - ] - ) - ) - return "\n\n".join(blocks) - - @staticmethod - def _select_knowledge_model_hits( - tool_payload: dict[str, Any], - *, - question: str | None = None, - ) -> list[dict[str, Any]]: - raw_hits = [ - item - for item in list(tool_payload.get("hits") or []) - if isinstance(item, dict) - ][: max(MAX_KNOWLEDGE_MODEL_HITS + 1, 6)] - if not raw_hits: - return [] - - query_terms = UserAgentService._extract_knowledge_query_terms(question or "") - if not query_terms: - return raw_hits[:MAX_KNOWLEDGE_MODEL_HITS] - - ranked_hits = sorted( - enumerate(raw_hits), - key=lambda value: ( - UserAgentService._score_knowledge_model_hit( - value[1], - query_terms=query_terms, - rank_index=value[0], - ), - -value[0], - ), - reverse=True, - ) - return [item for _, item in ranked_hits[:MAX_KNOWLEDGE_MODEL_HITS]] - - @staticmethod - def _score_knowledge_model_hit( - item: dict[str, Any], - *, - query_terms: list[str], - rank_index: int, - ) -> int: - title = str(item.get("title") or item.get("document_name") or "").lower() - excerpt = str(item.get("excerpt") or "").lower() - content = str(item.get("content") or "").lower() - haystack = "\n".join([title, excerpt, content[:1400]]) - - matched_terms = [term for term in query_terms if term in haystack] - score = max(1, 48 - rank_index * 4) - score += len(matched_terms) * 10 - score += sum(1 for term in matched_terms if term in title) * 8 - - leading_marker = UserAgentService._leading_knowledge_appendix_marker(content) - if leading_marker == "# 章节导航": - score -= 22 - elif leading_marker == "# 问答线索补充": - score += 6 if matched_terms else -8 - elif leading_marker == "# 重点章节摘录": - score += 4 if matched_terms else -4 - elif leading_marker == "# 结构化表格补充": - score += 8 if matched_terms else -3 - - if matched_terms and "|" in content: - score += 8 - if matched_terms and any(marker in content for marker in (":", ":")): - score += 10 - if matched_terms and "\n" in content: - score += 4 - if matched_terms and any(marker in content for marker in ("附表", "第", "条")): - score += 4 - if matched_terms and any(marker in content for marker in ("第", "条", ":", "-", "•")): - score += 4 - if re.search(r"没有.{0,8}(信息|规定|说明|依据)", content): - score -= 12 - return score - - @staticmethod - def _leading_knowledge_appendix_marker(content: str) -> str: - normalized = str(content or "").lstrip() - for marker in ("# 章节导航", "# 重点章节摘录", "# 问答线索补充", "# 结构化表格补充"): - index = normalized.find(marker) - if 0 <= index <= 220: - return marker - return "" + normalized["hits"] = hits + return normalized + + @staticmethod + def _build_knowledge_evidence_blocks( + tool_payload: dict[str, Any], + *, + question: str | None = None, + ) -> str: + blocks: list[str] = [] + for index, item in enumerate( + UserAgentService._select_knowledge_model_hits( + tool_payload, + question=question, + )[:3], + start=1, + ): + if not isinstance(item, dict): + continue + title = str(item.get("title") or item.get("document_name") or f"证据 {index}").strip() + code = str(item.get("code") or "").strip() + content = str(item.get("content") or "").strip() + if not content: + continue + blocks.append( + "\n".join( + [ + f"[证据 {index}] {title}" + (f" ({code})" if code else ""), + "```text", + content[:1200], + "```", + ] + ) + ) + return "\n\n".join(blocks) + + @staticmethod + def _select_knowledge_model_hits( + tool_payload: dict[str, Any], + *, + question: str | None = None, + ) -> list[dict[str, Any]]: + raw_hits = [ + item + for item in list(tool_payload.get("hits") or []) + if isinstance(item, dict) + ][: max(MAX_KNOWLEDGE_MODEL_HITS + 1, 6)] + if not raw_hits: + return [] + + query_terms = UserAgentService._extract_knowledge_query_terms(question or "") + if not query_terms: + return raw_hits[:MAX_KNOWLEDGE_MODEL_HITS] + + ranked_hits = sorted( + enumerate(raw_hits), + key=lambda value: ( + UserAgentService._score_knowledge_model_hit( + value[1], + query_terms=query_terms, + rank_index=value[0], + ), + -value[0], + ), + reverse=True, + ) + return [item for _, item in ranked_hits[:MAX_KNOWLEDGE_MODEL_HITS]] + + @staticmethod + def _score_knowledge_model_hit( + item: dict[str, Any], + *, + query_terms: list[str], + rank_index: int, + ) -> int: + title = str(item.get("title") or item.get("document_name") or "").lower() + excerpt = str(item.get("excerpt") or "").lower() + content = str(item.get("content") or "").lower() + haystack = "\n".join([title, excerpt, content[:1400]]) + + matched_terms = [term for term in query_terms if term in haystack] + score = max(1, 48 - rank_index * 4) + score += len(matched_terms) * 10 + score += sum(1 for term in matched_terms if term in title) * 8 + + leading_marker = UserAgentService._leading_knowledge_appendix_marker(content) + if leading_marker == "# 章节导航": + score -= 22 + elif leading_marker == "# 问答线索补充": + score += 6 if matched_terms else -8 + elif leading_marker == "# 重点章节摘录": + score += 4 if matched_terms else -4 + elif leading_marker == "# 结构化表格补充": + score += 8 if matched_terms else -3 + + if matched_terms and "|" in content: + score += 8 + if matched_terms and any(marker in content for marker in (":", ":")): + score += 10 + if matched_terms and "\n" in content: + score += 4 + if matched_terms and any(marker in content for marker in ("附表", "第", "条")): + score += 4 + if matched_terms and any(marker in content for marker in ("第", "条", ":", "-", "•")): + score += 4 + if re.search(r"没有.{0,8}(信息|规定|说明|依据)", content): + score -= 12 + return score + + @staticmethod + def _leading_knowledge_appendix_marker(content: str) -> str: + normalized = str(content or "").lstrip() + for marker in ("# 章节导航", "# 重点章节摘录", "# 问答线索补充", "# 结构化表格补充"): + index = normalized.find(marker) + if 0 <= index <= 220: + return marker + return "" def _build_query_answer(self, payload: UserAgentRequest) -> str: scenario = payload.ontology.scenario @@ -829,10 +847,10 @@ class UserAgentService: return "已完成当前查询,但暂时没有更多结构化结果可展示。" - def _build_query_payload( - self, - payload: UserAgentRequest, - ) -> UserAgentQueryPayload | None: + def _build_query_payload( + self, + payload: UserAgentRequest, + ) -> UserAgentQueryPayload | None: if payload.ontology.scenario != "expense" or payload.ontology.intent not in {"query", "compare"}: return None @@ -897,678 +915,678 @@ class UserAgentService: ), record_count=max(0, int(payload.tool_payload.get("record_count") or 0)), preview_count=max(0, int(payload.tool_payload.get("preview_count") or len(records))), - older_record_count=max(0, int(payload.tool_payload.get("older_record_count") or 0)), - has_more_in_window=bool(payload.tool_payload.get("has_more_in_window") or payload.tool_payload.get("has_more")), - total_amount=round(float(payload.tool_payload.get("total_amount") or 0), 2), - status_groups=status_groups, - records=records, - ) - - def _build_fast_knowledge_answer( - self, - payload: UserAgentRequest, - *, - citations: list[UserAgentCitation], - ) -> str | None: - if payload.ontology.scenario != "knowledge": - return None - if str(payload.tool_payload.get("result_type") or "").strip() != "knowledge_search": - return None - - evidence_items = self._build_knowledge_answer_evidence(payload) - if not evidence_items: - return None - - question = self._resolve_knowledge_question(payload) - if not self._should_use_direct_knowledge_answer(question, evidence_items): - return None - - return self._render_knowledge_direct_answer( - payload, - citations=citations, - evidence_items=evidence_items, - ) - - def _render_knowledge_direct_answer( - self, - payload: UserAgentRequest, - *, - citations: list[UserAgentCitation], - evidence_items: list[dict[str, Any]], - ) -> str | None: - if not evidence_items: - return None - - title = str( - (citations[0].title if citations else "") - or evidence_items[0].get("title") - or "相关制度" - ).strip() - user_name = str(payload.context_json.get("name") or "").strip() - question = self._resolve_knowledge_question(payload) - query_terms = self._extract_knowledge_query_terms(question) - ordered_evidence_items = self._prioritize_knowledge_evidence_items(question, evidence_items) - primary_item = ordered_evidence_items[0] - primary_heading = self._format_knowledge_heading_label( - str(primary_item.get("heading") or "").strip() - ) - primary_lines = self._collect_direct_knowledge_answer_lines(ordered_evidence_items) - - lines: list[str] = [] - if user_name: - lines.append(f"{user_name},您好。") - source_prefix = f"根据《{title}》" - if primary_heading: - source_prefix = f"{source_prefix}({primary_heading})" - - if str(primary_item.get("kind") or "") == "table": - lines.append(f"{source_prefix},当前能直接确认的是:") - lines.append(self._extract_relevant_table_preview(str(primary_item.get("content") or ""), query_terms)) - else: - if not primary_lines: - lines.append( - f"{source_prefix},当前能直接确认的是:" - f"{self._summarize_knowledge_evidence_content(primary_item, query_terms)}" - ) - elif len(primary_lines) == 1: - lines.append(f"{source_prefix},当前能直接确认的是:{primary_lines[0].strip()}") - else: - lines.append(f"{source_prefix},当前能直接确认的是:") - lines.extend(primary_lines) - - notes: list[str] = [] - location_note = self._build_missing_location_grounding_note(question, evidence_items) - if location_note: - notes.append(location_note) - if self._question_requires_explicit_condition(question) and not self._answer_evidence_has_numeric_or_condition(evidence_items): - notes.append("当前命中的证据更偏规则说明或流程约束,还没有直接给出可立即套用的数值或完整条件。") - - if notes: - lines.append("") - lines.append("说明:") - lines.extend(f"- {note}" for note in notes) - - return "\n".join(line for line in lines if line is not None).strip() - - def _prioritize_knowledge_evidence_items( - self, - question: str, - evidence_items: list[dict[str, Any]], - ) -> list[dict[str, Any]]: - if not evidence_items or not self._question_requires_explicit_condition(question): - return evidence_items - - for preferred_kind in ("table", "kv", "clause", "list"): - for index, item in enumerate(evidence_items): - if str(item.get("kind") or "") != preferred_kind: - continue - return [item, *evidence_items[:index], *evidence_items[index + 1 :]] - - for index, item in enumerate(evidence_items): - if re.search(r"\d", str(item.get("content") or "")): - return [item, *evidence_items[:index], *evidence_items[index + 1 :]] - - return evidence_items - - @staticmethod - def _resolve_knowledge_question(payload: UserAgentRequest) -> str: - return str(payload.context_json.get("user_input_text") or payload.message or "").strip() - - @staticmethod - def _looks_like_structured_knowledge_query(question: str) -> bool: - normalized = str(question or "").strip() - if not normalized: - return False - return any(keyword in normalized for keyword in KNOWLEDGE_DIRECT_ANSWER_HINTS) - - def _should_use_direct_knowledge_answer( - self, - question: str, - evidence_items: list[dict[str, Any]], - ) -> bool: - if not evidence_items: - return False - if self._looks_like_structured_knowledge_query(question): - return True - return str(evidence_items[0].get("kind") or "") in {"table", "kv", "list", "clause"} - - def _build_knowledge_answer_evidence( - self, - payload: UserAgentRequest, - ) -> list[dict[str, Any]]: - question = self._resolve_knowledge_question(payload) - query_terms = self._extract_knowledge_query_terms(question) - candidates: list[dict[str, Any]] = [] - - for hit in self._select_knowledge_model_hits( - payload.tool_payload, - question=question, - ): - if not isinstance(hit, dict): - continue - candidates.extend(self._extract_knowledge_evidence_candidates(hit, query_terms)) - - deduped: list[dict[str, Any]] = [] - seen: set[tuple[str, str, str]] = set() - ranked_candidates = sorted( - candidates, - key=lambda value: ( - float(value.get("score") or 0), - -len(str(value.get("content") or "")), - ), - reverse=True, - ) - top_score = float(ranked_candidates[0].get("score") or 0) if ranked_candidates else 0.0 - - for item in ranked_candidates: - score = float(item.get("score") or 0) - if deduped and score < max(6.0, top_score - 14): - continue - key = ( - str(item.get("title") or "").strip(), - str(item.get("heading") or "").strip(), - self._clean_knowledge_segment_text(str(item.get("content") or ""))[:180], - ) - if key in seen: - continue - seen.add(key) - deduped.append(item) - if len(deduped) >= MAX_KNOWLEDGE_DIRECT_EVIDENCE: - break - return deduped - - def _extract_knowledge_evidence_candidates( - self, - hit: dict[str, Any], - query_terms: list[str], - ) -> list[dict[str, Any]]: - title = str(hit.get("title") or hit.get("document_name") or "相关制度").strip() - content = str(hit.get("content") or "").strip() - if not content: - return [] - - raw_candidates = self._merge_knowledge_lead_in_segments( - self._split_knowledge_hit_into_segments(content) - ) - candidates: list[dict[str, Any]] = [] - for item in raw_candidates: - score = self._score_knowledge_evidence_candidate(item, query_terms) - if query_terms and score <= 0: - continue - normalized = dict(item) - normalized["title"] = title - normalized["score"] = score - candidates.append(normalized) - - if candidates: - return candidates - - fallback_text = str(hit.get("excerpt") or "").strip() or self._extract_excerpt(content) - if not fallback_text: - return [] - return [ - { - "title": title, - "heading": "", - "kind": "paragraph", - "content": fallback_text, - "score": 1, - } - ] - - @staticmethod - def _is_knowledge_lead_in_segment(item: dict[str, str]) -> bool: - kind = str(item.get("kind") or "").strip() - content = str(item.get("content") or "").strip() - return kind in {"kv", "list", "clause"} and content.endswith((":", ":")) - - @staticmethod - def _extract_knowledge_marker_family(content: str) -> str: - normalized = str(content or "").strip() - if not normalized: - return "" - if KNOWLEDGE_ARTICLE_PATTERN.match(normalized): - return "article" - if re.match(r"^\d+[.)、]\s*", normalized): - return "arabic" - if re.match(r"^[((][一二三四五六七八九十百零0-9]+[))]\s*", normalized): - return "paren" - if re.match(r"^[①②③④⑤⑥⑦⑧⑨⑩]\s*", normalized): - return "circled" - if KNOWLEDGE_LIST_ITEM_PATTERN.match(normalized): - return "bullet" - return "" - - @staticmethod - def _format_knowledge_heading_label(heading: str) -> str: - parts = [item.strip() for item in str(heading or "").split(">") if item.strip()] - return " / ".join(parts) - - def _merge_knowledge_lead_in_segments( - self, - segments: list[dict[str, str]], - ) -> list[dict[str, str]]: - if not segments: - return [] - - merged: list[dict[str, str]] = [] - index = 0 - while index < len(segments): - current = dict(segments[index]) - if not self._is_knowledge_lead_in_segment(current): - merged.append(current) - index += 1 - continue - - base_heading = str(current.get("heading") or "").strip() - current_marker = self._extract_knowledge_marker_family(str(current.get("content") or "")) - follow_segments: list[dict[str, str]] = [] - next_index = index + 1 - - while next_index < len(segments): - candidate = segments[next_index] - if str(candidate.get("heading") or "").strip() != base_heading: - break - - candidate_kind = str(candidate.get("kind") or "").strip() - candidate_content = str(candidate.get("content") or "").strip() - candidate_marker = self._extract_knowledge_marker_family(candidate_content) - if not candidate_content or candidate_kind == "table": - break - if current_marker and candidate_marker == current_marker: - break - if self._is_knowledge_lead_in_segment(candidate) and follow_segments: - break - if candidate_kind not in {"list", "paragraph", "kv", "clause"}: - break - - follow_segments.append(candidate) - next_index += 1 - if len(follow_segments) >= 4: - break - if candidate_kind == "paragraph" and len(candidate_content) >= 200: - break - - if follow_segments: - current["content"] = "\n".join( - [str(current.get("content") or "").strip()] - + [str(item.get("content") or "").strip() for item in follow_segments] - ) - if any(str(item.get("kind") or "").strip() == "list" for item in follow_segments): - current["kind"] = "list" - merged.append(current) - index = next_index - continue - - merged.append(current) - index += 1 - - return merged - - def _split_knowledge_hit_into_segments(self, content: str) -> list[dict[str, str]]: - segments: list[dict[str, str]] = [] - markdown_headings: list[str] = [] - section_heading = "" - paragraph_lines: list[str] = [] - table_lines: list[str] = [] - - def current_heading() -> str: - heading_parts = [item for item in markdown_headings if item] - if section_heading: - heading_parts.append(section_heading) - return " > ".join(heading_parts) - - def flush_paragraph() -> None: - nonlocal paragraph_lines - if not paragraph_lines: - return - merged = " ".join(line.strip() for line in paragraph_lines if line.strip()).strip() - paragraph_lines = [] - if merged: - segments.append( - { - "heading": current_heading(), - "kind": "paragraph", - "content": merged, - } - ) - - def flush_table() -> None: - nonlocal table_lines - if not table_lines: - return - merged = "\n".join(line.rstrip() for line in table_lines if line.strip()).strip() - table_lines = [] - if merged: - segments.append( - { - "heading": current_heading(), - "kind": "table", - "content": merged, - } - ) - - for raw_line in str(content or "").replace("\r\n", "\n").replace("\r", "\n").splitlines(): - line = raw_line.rstrip() - stripped = line.strip() - - if not stripped: - flush_paragraph() - flush_table() - continue - - markdown_heading_match = re.match(r"^(#{1,6})\s+(.+)$", stripped) - if markdown_heading_match: - flush_paragraph() - flush_table() - level = len(markdown_heading_match.group(1)) - heading_text = markdown_heading_match.group(2).strip() - markdown_headings = markdown_headings[: max(0, level - 1)] - markdown_headings.append(heading_text) - section_heading = "" - continue - - if KNOWLEDGE_SECTION_HEADING_PATTERN.match(stripped) and len(stripped) <= 90: - flush_paragraph() - flush_table() - section_heading = stripped.lstrip("#").strip() - continue - - if stripped.count("|") >= 2 and "|" in stripped: - flush_paragraph() - table_lines.append(stripped) - continue - - flush_table() - - if KNOWLEDGE_LIST_ITEM_PATTERN.match(stripped): - flush_paragraph() - segments.append( - { - "heading": current_heading(), - "kind": "list", - "content": stripped, - } - ) - continue - - if KNOWLEDGE_NUMBERED_ITEM_PATTERN.match(stripped): - flush_paragraph() - segments.append( - { - "heading": current_heading(), - "kind": "list", - "content": stripped, - } - ) - continue - - if KNOWLEDGE_ARTICLE_PATTERN.match(stripped): - flush_paragraph() - segments.append( - { - "heading": current_heading(), - "kind": "clause", - "content": stripped, - } - ) - continue - - if (":" in stripped or ":" in stripped) and len(stripped) <= 180: - flush_paragraph() - segments.append( - { - "heading": current_heading(), - "kind": "kv", - "content": stripped, - } - ) - continue - - paragraph_lines.append(stripped) - - flush_paragraph() - flush_table() - return segments - - def _score_knowledge_evidence_candidate( - self, - item: dict[str, str], - query_terms: list[str], - ) -> int: - heading = str(item.get("heading") or "").lower() - content = str(item.get("content") or "").lower() - kind = str(item.get("kind") or "").strip() - haystack = "\n".join([heading, content]) - - matched_terms = [term for term in query_terms if term in haystack] - score = len(matched_terms) * 10 - score += sum(1 for term in matched_terms if term in heading) * 6 - - if kind == "table": - score += 10 - elif kind in {"kv", "clause", "list"}: - score += 8 - elif kind == "paragraph": - score += 4 - - if "问答线索补充" in heading or "重点章节摘录" in heading: - score += 8 - if "结构化表格补充" in heading: - score += 10 - if "章节导航" in heading or "目录" in heading: - score -= 16 - if re.search(r"[.。…]{6,}", content): - score -= 12 - if any(hint in content for hint in ("应", "需", "不得", "可以", "标准", "条件", "材料", "审批", "流程", "包括")): - score += 3 - - content_length = len(content) - if content_length > 220: - score -= min(8, (content_length - 220) // 40) - return score - - @staticmethod - def _extract_knowledge_query_terms(question: str) -> list[str]: - normalized_question = str(question or "").strip().lower() - if not normalized_question: - return [] - - terms: list[str] = [] - seen: set[str] = set() - - def remember(term: str) -> None: - normalized = str(term or "").strip().lower() - if ( - not normalized - or normalized in seen - or normalized in KNOWLEDGE_QUERY_STOPWORDS - ): - return - seen.add(normalized) - terms.append(normalized) - - for item in re.findall(r"[a-z0-9][a-z0-9_\-]{1,}", normalized_question): - remember(item) - - for block in re.findall(r"[\u4e00-\u9fff]{2,20}", normalized_question): - if len(block) <= 4: - remember(block) - continue - for size in (4, 3, 2): - for start in range(0, len(block) - size + 1): - remember(block[start : start + size]) - if len(terms) >= MAX_KNOWLEDGE_QUERY_TERMS: - return terms - - return terms[:MAX_KNOWLEDGE_QUERY_TERMS] - - @staticmethod - def _clean_knowledge_segment_text(content: str) -> str: - normalized = str(content or "").strip() - normalized = re.sub(r"^[-*•]\s*", "", normalized) - normalized = re.sub(r"^(?:\d+[.)、]|[①②③④⑤⑥⑦⑧⑨⑩])\s*", "", normalized) - normalized = re.sub(r"^[((][一二三四五六七八九十百零0-9]+[))]\s*", "", normalized) - normalized = re.sub(r"\s+", " ", normalized) - if len(normalized) <= 180: - return normalized - return f"{normalized[:177].rstrip()}..." - - @staticmethod - def _normalize_knowledge_line(content: str, *, preserve_marker: bool) -> str: - normalized = str(content or "").strip() - normalized = re.sub(r"^[-*•]\s*", "", normalized) - if not preserve_marker: - normalized = re.sub(r"^(?:\d+[.)、]|[①②③④⑤⑥⑦⑧⑨⑩])\s*", "", normalized) - normalized = re.sub(r"^[((][一二三四五六七八九十百零0-9]+[))]\s*", "", normalized) - normalized = re.sub(r"\s+", " ", normalized) - return normalized - - def _split_clean_knowledge_lines( - self, - content: str, - *, - preserve_marker: bool, - ) -> list[str]: - return [ - line - for line in ( - self._normalize_knowledge_line(item, preserve_marker=preserve_marker) - for item in str(content or "").splitlines() - ) - if line - ] - - def _render_knowledge_evidence_text(self, item: dict[str, Any]) -> str: - lines = self._split_clean_knowledge_lines( - str(item.get("content") or ""), - preserve_marker=True, - ) - if not lines: - return "" - if len(lines) == 1: - return self._clean_knowledge_segment_text(lines[0]) - return "\n".join(f" {line}" for line in lines) - - def _collect_direct_knowledge_answer_lines( - self, - ordered_evidence_items: list[dict[str, Any]], - ) -> list[str]: - if not ordered_evidence_items: - return [] - - primary_item = ordered_evidence_items[0] - primary_title = str(primary_item.get("title") or "").strip() - primary_heading = str(primary_item.get("heading") or "").strip() - primary_kind = str(primary_item.get("kind") or "").strip() - - related_items = [primary_item] - if primary_kind != "table": - for item in ordered_evidence_items[1:]: - if len(related_items) >= 3: - break - if str(item.get("kind") or "").strip() != primary_kind: - continue - if str(item.get("title") or "").strip() != primary_title: - continue - if str(item.get("heading") or "").strip() != primary_heading: - continue - related_items.append(item) - - lines: list[str] = [] - seen: set[str] = set() - for item in related_items: - rendered = self._render_knowledge_evidence_text(item) - for line in rendered.splitlines(): - normalized = str(line or "").strip() - if not normalized or normalized in seen: - continue - seen.add(normalized) - lines.append(line) - return lines - - def _summarize_knowledge_evidence_content( - self, - item: dict[str, Any], - query_terms: list[str], - ) -> str: - kind = str(item.get("kind") or "").strip() - content = str(item.get("content") or "").strip() - if kind == "table": - preview = self._extract_relevant_table_preview(content, query_terms) - preview_rows = [line for line in preview.splitlines() if line.strip()][:4] - if len(preview_rows) >= 3: - return "当前命中的直接依据是一张与问题强相关的标准表,已摘出最相关的表头和行。" - return "当前命中的直接依据是一张与问题强相关的标准表。" - lines = self._split_clean_knowledge_lines(content, preserve_marker=True) - if len(lines) >= 2: - return self._clean_knowledge_segment_text(f"{lines[0]} {' '.join(lines[1:4])}") - return self._clean_knowledge_segment_text(content) - - @staticmethod - def _extract_relevant_table_preview(content: str, query_terms: list[str]) -> str: - lines = [line.strip() for line in str(content or "").splitlines() if line.strip()] - if len(lines) <= 3: - return "\n".join(lines) - - header = lines[0] - divider = lines[1] if len(lines) > 1 else "" - body = lines[2:] if divider.count("|") >= 2 else lines[1:] - - matched_rows = [ - row - for row in body - if any(term in row.lower() for term in query_terms) - ] - selected_rows = matched_rows[:3] or body[:2] - preview_lines = [header] - if divider: - preview_lines.append(divider) - preview_lines.extend(selected_rows) - return "\n".join(preview_lines).strip() - - @staticmethod - def _question_requires_explicit_condition(question: str) -> bool: - normalized = str(question or "").strip() - return any(keyword in normalized for keyword in ("多少", "金额", "上限", "限额", "标准", "条件", "需要")) - - def _build_missing_location_grounding_note( - self, - question: str, - evidence_items: list[dict[str, Any]], - ) -> str: - location = self._extract_query_location(question) - if not location: - return "" - - haystack = "\n".join( - str(item.get("heading") or "") + "\n" + str(item.get("content") or "") - for item in evidence_items - ) - if location in haystack: - return "" - return ( - f"当前命中的制度依据没有直接写出“{location}”对应的地区档位或映射关系," - "因此不能直接把它套用到表格中的某一列。" - ) - - @staticmethod - def _answer_evidence_has_numeric_or_condition(evidence_items: list[dict[str, Any]]) -> bool: - for item in evidence_items: - content = str(item.get("content") or "") - if re.search(r"\d", content): - return True - if any( - keyword in content - for keyword in ("应", "需", "不得", "可以", "条件", "材料", "审批", "流程", "标准", "适用") - ): - return True - return False - - def _build_explain_answer( - self, - payload: UserAgentRequest, - citations: list[UserAgentCitation], + older_record_count=max(0, int(payload.tool_payload.get("older_record_count") or 0)), + has_more_in_window=bool(payload.tool_payload.get("has_more_in_window") or payload.tool_payload.get("has_more")), + total_amount=round(float(payload.tool_payload.get("total_amount") or 0), 2), + status_groups=status_groups, + records=records, + ) + + def _build_fast_knowledge_answer( + self, + payload: UserAgentRequest, + *, + citations: list[UserAgentCitation], + ) -> str | None: + if payload.ontology.scenario != "knowledge": + return None + if str(payload.tool_payload.get("result_type") or "").strip() != "knowledge_search": + return None + + evidence_items = self._build_knowledge_answer_evidence(payload) + if not evidence_items: + return None + + question = self._resolve_knowledge_question(payload) + if not self._should_use_direct_knowledge_answer(question, evidence_items): + return None + + return self._render_knowledge_direct_answer( + payload, + citations=citations, + evidence_items=evidence_items, + ) + + def _render_knowledge_direct_answer( + self, + payload: UserAgentRequest, + *, + citations: list[UserAgentCitation], + evidence_items: list[dict[str, Any]], + ) -> str | None: + if not evidence_items: + return None + + title = str( + (citations[0].title if citations else "") + or evidence_items[0].get("title") + or "相关制度" + ).strip() + user_name = str(payload.context_json.get("name") or "").strip() + question = self._resolve_knowledge_question(payload) + query_terms = self._extract_knowledge_query_terms(question) + ordered_evidence_items = self._prioritize_knowledge_evidence_items(question, evidence_items) + primary_item = ordered_evidence_items[0] + primary_heading = self._format_knowledge_heading_label( + str(primary_item.get("heading") or "").strip() + ) + primary_lines = self._collect_direct_knowledge_answer_lines(ordered_evidence_items) + + lines: list[str] = [] + if user_name: + lines.append(f"{user_name},您好。") + source_prefix = f"根据《{title}》" + if primary_heading: + source_prefix = f"{source_prefix}({primary_heading})" + + if str(primary_item.get("kind") or "") == "table": + lines.append(f"{source_prefix},当前能直接确认的是:") + lines.append(self._extract_relevant_table_preview(str(primary_item.get("content") or ""), query_terms)) + else: + if not primary_lines: + lines.append( + f"{source_prefix},当前能直接确认的是:" + f"{self._summarize_knowledge_evidence_content(primary_item, query_terms)}" + ) + elif len(primary_lines) == 1: + lines.append(f"{source_prefix},当前能直接确认的是:{primary_lines[0].strip()}") + else: + lines.append(f"{source_prefix},当前能直接确认的是:") + lines.extend(primary_lines) + + notes: list[str] = [] + location_note = self._build_missing_location_grounding_note(question, evidence_items) + if location_note: + notes.append(location_note) + if self._question_requires_explicit_condition(question) and not self._answer_evidence_has_numeric_or_condition(evidence_items): + notes.append("当前命中的证据更偏规则说明或流程约束,还没有直接给出可立即套用的数值或完整条件。") + + if notes: + lines.append("") + lines.append("说明:") + lines.extend(f"- {note}" for note in notes) + + return "\n".join(line for line in lines if line is not None).strip() + + def _prioritize_knowledge_evidence_items( + self, + question: str, + evidence_items: list[dict[str, Any]], + ) -> list[dict[str, Any]]: + if not evidence_items or not self._question_requires_explicit_condition(question): + return evidence_items + + for preferred_kind in ("table", "kv", "clause", "list"): + for index, item in enumerate(evidence_items): + if str(item.get("kind") or "") != preferred_kind: + continue + return [item, *evidence_items[:index], *evidence_items[index + 1 :]] + + for index, item in enumerate(evidence_items): + if re.search(r"\d", str(item.get("content") or "")): + return [item, *evidence_items[:index], *evidence_items[index + 1 :]] + + return evidence_items + + @staticmethod + def _resolve_knowledge_question(payload: UserAgentRequest) -> str: + return str(payload.context_json.get("user_input_text") or payload.message or "").strip() + + @staticmethod + def _looks_like_structured_knowledge_query(question: str) -> bool: + normalized = str(question or "").strip() + if not normalized: + return False + return any(keyword in normalized for keyword in KNOWLEDGE_DIRECT_ANSWER_HINTS) + + def _should_use_direct_knowledge_answer( + self, + question: str, + evidence_items: list[dict[str, Any]], + ) -> bool: + if not evidence_items: + return False + if self._looks_like_structured_knowledge_query(question): + return True + return str(evidence_items[0].get("kind") or "") in {"table", "kv", "list", "clause"} + + def _build_knowledge_answer_evidence( + self, + payload: UserAgentRequest, + ) -> list[dict[str, Any]]: + question = self._resolve_knowledge_question(payload) + query_terms = self._extract_knowledge_query_terms(question) + candidates: list[dict[str, Any]] = [] + + for hit in self._select_knowledge_model_hits( + payload.tool_payload, + question=question, + ): + if not isinstance(hit, dict): + continue + candidates.extend(self._extract_knowledge_evidence_candidates(hit, query_terms)) + + deduped: list[dict[str, Any]] = [] + seen: set[tuple[str, str, str]] = set() + ranked_candidates = sorted( + candidates, + key=lambda value: ( + float(value.get("score") or 0), + -len(str(value.get("content") or "")), + ), + reverse=True, + ) + top_score = float(ranked_candidates[0].get("score") or 0) if ranked_candidates else 0.0 + + for item in ranked_candidates: + score = float(item.get("score") or 0) + if deduped and score < max(6.0, top_score - 14): + continue + key = ( + str(item.get("title") or "").strip(), + str(item.get("heading") or "").strip(), + self._clean_knowledge_segment_text(str(item.get("content") or ""))[:180], + ) + if key in seen: + continue + seen.add(key) + deduped.append(item) + if len(deduped) >= MAX_KNOWLEDGE_DIRECT_EVIDENCE: + break + return deduped + + def _extract_knowledge_evidence_candidates( + self, + hit: dict[str, Any], + query_terms: list[str], + ) -> list[dict[str, Any]]: + title = str(hit.get("title") or hit.get("document_name") or "相关制度").strip() + content = str(hit.get("content") or "").strip() + if not content: + return [] + + raw_candidates = self._merge_knowledge_lead_in_segments( + self._split_knowledge_hit_into_segments(content) + ) + candidates: list[dict[str, Any]] = [] + for item in raw_candidates: + score = self._score_knowledge_evidence_candidate(item, query_terms) + if query_terms and score <= 0: + continue + normalized = dict(item) + normalized["title"] = title + normalized["score"] = score + candidates.append(normalized) + + if candidates: + return candidates + + fallback_text = str(hit.get("excerpt") or "").strip() or self._extract_excerpt(content) + if not fallback_text: + return [] + return [ + { + "title": title, + "heading": "", + "kind": "paragraph", + "content": fallback_text, + "score": 1, + } + ] + + @staticmethod + def _is_knowledge_lead_in_segment(item: dict[str, str]) -> bool: + kind = str(item.get("kind") or "").strip() + content = str(item.get("content") or "").strip() + return kind in {"kv", "list", "clause"} and content.endswith((":", ":")) + + @staticmethod + def _extract_knowledge_marker_family(content: str) -> str: + normalized = str(content or "").strip() + if not normalized: + return "" + if KNOWLEDGE_ARTICLE_PATTERN.match(normalized): + return "article" + if re.match(r"^\d+[.)、]\s*", normalized): + return "arabic" + if re.match(r"^[((][一二三四五六七八九十百零0-9]+[))]\s*", normalized): + return "paren" + if re.match(r"^[①②③④⑤⑥⑦⑧⑨⑩]\s*", normalized): + return "circled" + if KNOWLEDGE_LIST_ITEM_PATTERN.match(normalized): + return "bullet" + return "" + + @staticmethod + def _format_knowledge_heading_label(heading: str) -> str: + parts = [item.strip() for item in str(heading or "").split(">") if item.strip()] + return " / ".join(parts) + + def _merge_knowledge_lead_in_segments( + self, + segments: list[dict[str, str]], + ) -> list[dict[str, str]]: + if not segments: + return [] + + merged: list[dict[str, str]] = [] + index = 0 + while index < len(segments): + current = dict(segments[index]) + if not self._is_knowledge_lead_in_segment(current): + merged.append(current) + index += 1 + continue + + base_heading = str(current.get("heading") or "").strip() + current_marker = self._extract_knowledge_marker_family(str(current.get("content") or "")) + follow_segments: list[dict[str, str]] = [] + next_index = index + 1 + + while next_index < len(segments): + candidate = segments[next_index] + if str(candidate.get("heading") or "").strip() != base_heading: + break + + candidate_kind = str(candidate.get("kind") or "").strip() + candidate_content = str(candidate.get("content") or "").strip() + candidate_marker = self._extract_knowledge_marker_family(candidate_content) + if not candidate_content or candidate_kind == "table": + break + if current_marker and candidate_marker == current_marker: + break + if self._is_knowledge_lead_in_segment(candidate) and follow_segments: + break + if candidate_kind not in {"list", "paragraph", "kv", "clause"}: + break + + follow_segments.append(candidate) + next_index += 1 + if len(follow_segments) >= 4: + break + if candidate_kind == "paragraph" and len(candidate_content) >= 200: + break + + if follow_segments: + current["content"] = "\n".join( + [str(current.get("content") or "").strip()] + + [str(item.get("content") or "").strip() for item in follow_segments] + ) + if any(str(item.get("kind") or "").strip() == "list" for item in follow_segments): + current["kind"] = "list" + merged.append(current) + index = next_index + continue + + merged.append(current) + index += 1 + + return merged + + def _split_knowledge_hit_into_segments(self, content: str) -> list[dict[str, str]]: + segments: list[dict[str, str]] = [] + markdown_headings: list[str] = [] + section_heading = "" + paragraph_lines: list[str] = [] + table_lines: list[str] = [] + + def current_heading() -> str: + heading_parts = [item for item in markdown_headings if item] + if section_heading: + heading_parts.append(section_heading) + return " > ".join(heading_parts) + + def flush_paragraph() -> None: + nonlocal paragraph_lines + if not paragraph_lines: + return + merged = " ".join(line.strip() for line in paragraph_lines if line.strip()).strip() + paragraph_lines = [] + if merged: + segments.append( + { + "heading": current_heading(), + "kind": "paragraph", + "content": merged, + } + ) + + def flush_table() -> None: + nonlocal table_lines + if not table_lines: + return + merged = "\n".join(line.rstrip() for line in table_lines if line.strip()).strip() + table_lines = [] + if merged: + segments.append( + { + "heading": current_heading(), + "kind": "table", + "content": merged, + } + ) + + for raw_line in str(content or "").replace("\r\n", "\n").replace("\r", "\n").splitlines(): + line = raw_line.rstrip() + stripped = line.strip() + + if not stripped: + flush_paragraph() + flush_table() + continue + + markdown_heading_match = re.match(r"^(#{1,6})\s+(.+)$", stripped) + if markdown_heading_match: + flush_paragraph() + flush_table() + level = len(markdown_heading_match.group(1)) + heading_text = markdown_heading_match.group(2).strip() + markdown_headings = markdown_headings[: max(0, level - 1)] + markdown_headings.append(heading_text) + section_heading = "" + continue + + if KNOWLEDGE_SECTION_HEADING_PATTERN.match(stripped) and len(stripped) <= 90: + flush_paragraph() + flush_table() + section_heading = stripped.lstrip("#").strip() + continue + + if stripped.count("|") >= 2 and "|" in stripped: + flush_paragraph() + table_lines.append(stripped) + continue + + flush_table() + + if KNOWLEDGE_LIST_ITEM_PATTERN.match(stripped): + flush_paragraph() + segments.append( + { + "heading": current_heading(), + "kind": "list", + "content": stripped, + } + ) + continue + + if KNOWLEDGE_NUMBERED_ITEM_PATTERN.match(stripped): + flush_paragraph() + segments.append( + { + "heading": current_heading(), + "kind": "list", + "content": stripped, + } + ) + continue + + if KNOWLEDGE_ARTICLE_PATTERN.match(stripped): + flush_paragraph() + segments.append( + { + "heading": current_heading(), + "kind": "clause", + "content": stripped, + } + ) + continue + + if (":" in stripped or ":" in stripped) and len(stripped) <= 180: + flush_paragraph() + segments.append( + { + "heading": current_heading(), + "kind": "kv", + "content": stripped, + } + ) + continue + + paragraph_lines.append(stripped) + + flush_paragraph() + flush_table() + return segments + + def _score_knowledge_evidence_candidate( + self, + item: dict[str, str], + query_terms: list[str], + ) -> int: + heading = str(item.get("heading") or "").lower() + content = str(item.get("content") or "").lower() + kind = str(item.get("kind") or "").strip() + haystack = "\n".join([heading, content]) + + matched_terms = [term for term in query_terms if term in haystack] + score = len(matched_terms) * 10 + score += sum(1 for term in matched_terms if term in heading) * 6 + + if kind == "table": + score += 10 + elif kind in {"kv", "clause", "list"}: + score += 8 + elif kind == "paragraph": + score += 4 + + if "问答线索补充" in heading or "重点章节摘录" in heading: + score += 8 + if "结构化表格补充" in heading: + score += 10 + if "章节导航" in heading or "目录" in heading: + score -= 16 + if re.search(r"[.。…]{6,}", content): + score -= 12 + if any(hint in content for hint in ("应", "需", "不得", "可以", "标准", "条件", "材料", "审批", "流程", "包括")): + score += 3 + + content_length = len(content) + if content_length > 220: + score -= min(8, (content_length - 220) // 40) + return score + + @staticmethod + def _extract_knowledge_query_terms(question: str) -> list[str]: + normalized_question = str(question or "").strip().lower() + if not normalized_question: + return [] + + terms: list[str] = [] + seen: set[str] = set() + + def remember(term: str) -> None: + normalized = str(term or "").strip().lower() + if ( + not normalized + or normalized in seen + or normalized in KNOWLEDGE_QUERY_STOPWORDS + ): + return + seen.add(normalized) + terms.append(normalized) + + for item in re.findall(r"[a-z0-9][a-z0-9_\-]{1,}", normalized_question): + remember(item) + + for block in re.findall(r"[\u4e00-\u9fff]{2,20}", normalized_question): + if len(block) <= 4: + remember(block) + continue + for size in (4, 3, 2): + for start in range(0, len(block) - size + 1): + remember(block[start : start + size]) + if len(terms) >= MAX_KNOWLEDGE_QUERY_TERMS: + return terms + + return terms[:MAX_KNOWLEDGE_QUERY_TERMS] + + @staticmethod + def _clean_knowledge_segment_text(content: str) -> str: + normalized = str(content or "").strip() + normalized = re.sub(r"^[-*•]\s*", "", normalized) + normalized = re.sub(r"^(?:\d+[.)、]|[①②③④⑤⑥⑦⑧⑨⑩])\s*", "", normalized) + normalized = re.sub(r"^[((][一二三四五六七八九十百零0-9]+[))]\s*", "", normalized) + normalized = re.sub(r"\s+", " ", normalized) + if len(normalized) <= 180: + return normalized + return f"{normalized[:177].rstrip()}..." + + @staticmethod + def _normalize_knowledge_line(content: str, *, preserve_marker: bool) -> str: + normalized = str(content or "").strip() + normalized = re.sub(r"^[-*•]\s*", "", normalized) + if not preserve_marker: + normalized = re.sub(r"^(?:\d+[.)、]|[①②③④⑤⑥⑦⑧⑨⑩])\s*", "", normalized) + normalized = re.sub(r"^[((][一二三四五六七八九十百零0-9]+[))]\s*", "", normalized) + normalized = re.sub(r"\s+", " ", normalized) + return normalized + + def _split_clean_knowledge_lines( + self, + content: str, + *, + preserve_marker: bool, + ) -> list[str]: + return [ + line + for line in ( + self._normalize_knowledge_line(item, preserve_marker=preserve_marker) + for item in str(content or "").splitlines() + ) + if line + ] + + def _render_knowledge_evidence_text(self, item: dict[str, Any]) -> str: + lines = self._split_clean_knowledge_lines( + str(item.get("content") or ""), + preserve_marker=True, + ) + if not lines: + return "" + if len(lines) == 1: + return self._clean_knowledge_segment_text(lines[0]) + return "\n".join(f" {line}" for line in lines) + + def _collect_direct_knowledge_answer_lines( + self, + ordered_evidence_items: list[dict[str, Any]], + ) -> list[str]: + if not ordered_evidence_items: + return [] + + primary_item = ordered_evidence_items[0] + primary_title = str(primary_item.get("title") or "").strip() + primary_heading = str(primary_item.get("heading") or "").strip() + primary_kind = str(primary_item.get("kind") or "").strip() + + related_items = [primary_item] + if primary_kind != "table": + for item in ordered_evidence_items[1:]: + if len(related_items) >= 3: + break + if str(item.get("kind") or "").strip() != primary_kind: + continue + if str(item.get("title") or "").strip() != primary_title: + continue + if str(item.get("heading") or "").strip() != primary_heading: + continue + related_items.append(item) + + lines: list[str] = [] + seen: set[str] = set() + for item in related_items: + rendered = self._render_knowledge_evidence_text(item) + for line in rendered.splitlines(): + normalized = str(line or "").strip() + if not normalized or normalized in seen: + continue + seen.add(normalized) + lines.append(line) + return lines + + def _summarize_knowledge_evidence_content( + self, + item: dict[str, Any], + query_terms: list[str], + ) -> str: + kind = str(item.get("kind") or "").strip() + content = str(item.get("content") or "").strip() + if kind == "table": + preview = self._extract_relevant_table_preview(content, query_terms) + preview_rows = [line for line in preview.splitlines() if line.strip()][:4] + if len(preview_rows) >= 3: + return "当前命中的直接依据是一张与问题强相关的标准表,已摘出最相关的表头和行。" + return "当前命中的直接依据是一张与问题强相关的标准表。" + lines = self._split_clean_knowledge_lines(content, preserve_marker=True) + if len(lines) >= 2: + return self._clean_knowledge_segment_text(f"{lines[0]} {' '.join(lines[1:4])}") + return self._clean_knowledge_segment_text(content) + + @staticmethod + def _extract_relevant_table_preview(content: str, query_terms: list[str]) -> str: + lines = [line.strip() for line in str(content or "").splitlines() if line.strip()] + if len(lines) <= 3: + return "\n".join(lines) + + header = lines[0] + divider = lines[1] if len(lines) > 1 else "" + body = lines[2:] if divider.count("|") >= 2 else lines[1:] + + matched_rows = [ + row + for row in body + if any(term in row.lower() for term in query_terms) + ] + selected_rows = matched_rows[:3] or body[:2] + preview_lines = [header] + if divider: + preview_lines.append(divider) + preview_lines.extend(selected_rows) + return "\n".join(preview_lines).strip() + + @staticmethod + def _question_requires_explicit_condition(question: str) -> bool: + normalized = str(question or "").strip() + return any(keyword in normalized for keyword in ("多少", "金额", "上限", "限额", "标准", "条件", "需要")) + + def _build_missing_location_grounding_note( + self, + question: str, + evidence_items: list[dict[str, Any]], + ) -> str: + location = self._extract_query_location(question) + if not location: + return "" + + haystack = "\n".join( + str(item.get("heading") or "") + "\n" + str(item.get("content") or "") + for item in evidence_items + ) + if location in haystack: + return "" + return ( + f"当前命中的制度依据没有直接写出“{location}”对应的地区档位或映射关系," + "因此不能直接把它套用到表格中的某一列。" + ) + + @staticmethod + def _answer_evidence_has_numeric_or_condition(evidence_items: list[dict[str, Any]]) -> bool: + for item in evidence_items: + content = str(item.get("content") or "") + if re.search(r"\d", content): + return True + if any( + keyword in content + for keyword in ("应", "需", "不得", "可以", "条件", "材料", "审批", "流程", "标准", "适用") + ): + return True + return False + + def _build_explain_answer( + self, + payload: UserAgentRequest, + citations: list[UserAgentCitation], ) -> str: if str(payload.tool_payload.get("result_type") or "").strip() == "knowledge_search": if citations: @@ -1588,72 +1606,72 @@ class UserAgentService: "强匹配的已上线规则引用,建议先人工复核或补充更具体的单据上下文。" ) - def _build_knowledge_search_answer( - self, - payload: UserAgentRequest, - citations: list[UserAgentCitation], - ) -> str: - hits = [item for item in list(payload.tool_payload.get("hits") or []) if isinstance(item, dict)] - evidence_items = self._build_knowledge_answer_evidence(payload) - primary_citation = citations[0] if citations else None - title = str( - (primary_citation.title if primary_citation else "") - or (hits[0].get("title") if hits else "") - or "相关制度" - ).strip() - user_name = str(payload.context_json.get("name") or "").strip() - prefix = f"{user_name},您好。\n" if user_name else "" - if not hits: - return ( - f"{prefix}我已经从《{title}》中检索到与你这次问题相关的制度依据," - "但本次答案生成环节暂时没有成功返回。请稍后重试一次;如果仍然失败," - "建议先检查主对话模型的连通性。" - ) - - evidence_lines: list[str] = [] - for item in evidence_items[:3]: - heading = str(item.get("heading") or "").strip() - heading_text = f" > {heading}" if heading else "" - if str(item.get("kind") or "") == "table": - preview = self._extract_relevant_table_preview( - str(item.get("content") or ""), - self._extract_knowledge_query_terms(self._resolve_knowledge_question(payload)), - ) - evidence_lines.append(f"- 《{item.get('title') or title}》{heading_text}:\n{preview}") - continue - rendered = self._render_knowledge_evidence_text(item) - if rendered: - if "\n" in rendered: - evidence_lines.append(f"- 《{item.get('title') or title}》{heading_text}:\n{rendered}") - else: - evidence_lines.append(f"- 《{item.get('title') or title}》{heading_text}:{rendered}") - - if not evidence_lines: - for item in hits[:2]: - item_title = str(item.get("title") or item.get("document_name") or "相关制度").strip() - excerpt = ( - str(item.get("excerpt") or "").strip() - or self._extract_excerpt(str(item.get("content") or "")) - ) - if not excerpt: - continue - evidence_lines.append(f"- 《{item_title}》:{excerpt}") - - if not evidence_lines: - return ( - f"{prefix}我已经从《{title}》中检索到与你这次问题相关的制度依据," - "但本次答案生成环节暂时没有成功返回。请稍后重试一次;如果仍然失败," - "建议先检查主对话模型的连通性。" - ) - - return "\n".join( - [ - f"{prefix}我已经命中与你这次问题最相关的制度依据,但答案整理阶段本轮没有及时返回。", - "先给你当前最直接的依据:", - *evidence_lines, - "如果你希望我继续把这些依据整理成更完整的结论、步骤或对比说明,可以继续缩小问题范围后再问一次。", - ] - ).strip() + def _build_knowledge_search_answer( + self, + payload: UserAgentRequest, + citations: list[UserAgentCitation], + ) -> str: + hits = [item for item in list(payload.tool_payload.get("hits") or []) if isinstance(item, dict)] + evidence_items = self._build_knowledge_answer_evidence(payload) + primary_citation = citations[0] if citations else None + title = str( + (primary_citation.title if primary_citation else "") + or (hits[0].get("title") if hits else "") + or "相关制度" + ).strip() + user_name = str(payload.context_json.get("name") or "").strip() + prefix = f"{user_name},您好。\n" if user_name else "" + if not hits: + return ( + f"{prefix}我已经从《{title}》中检索到与你这次问题相关的制度依据," + "但本次答案生成环节暂时没有成功返回。请稍后重试一次;如果仍然失败," + "建议先检查主对话模型的连通性。" + ) + + evidence_lines: list[str] = [] + for item in evidence_items[:3]: + heading = str(item.get("heading") or "").strip() + heading_text = f" > {heading}" if heading else "" + if str(item.get("kind") or "") == "table": + preview = self._extract_relevant_table_preview( + str(item.get("content") or ""), + self._extract_knowledge_query_terms(self._resolve_knowledge_question(payload)), + ) + evidence_lines.append(f"- 《{item.get('title') or title}》{heading_text}:\n{preview}") + continue + rendered = self._render_knowledge_evidence_text(item) + if rendered: + if "\n" in rendered: + evidence_lines.append(f"- 《{item.get('title') or title}》{heading_text}:\n{rendered}") + else: + evidence_lines.append(f"- 《{item.get('title') or title}》{heading_text}:{rendered}") + + if not evidence_lines: + for item in hits[:2]: + item_title = str(item.get("title") or item.get("document_name") or "相关制度").strip() + excerpt = ( + str(item.get("excerpt") or "").strip() + or self._extract_excerpt(str(item.get("content") or "")) + ) + if not excerpt: + continue + evidence_lines.append(f"- 《{item_title}》:{excerpt}") + + if not evidence_lines: + return ( + f"{prefix}我已经从《{title}》中检索到与你这次问题相关的制度依据," + "但本次答案生成环节暂时没有成功返回。请稍后重试一次;如果仍然失败," + "建议先检查主对话模型的连通性。" + ) + + return "\n".join( + [ + f"{prefix}我已经命中与你这次问题最相关的制度依据,但答案整理阶段本轮没有及时返回。", + "先给你当前最直接的依据:", + *evidence_lines, + "如果你希望我继续把这些依据整理成更完整的结论、步骤或对比说明,可以继续缩小问题范围后再问一次。", + ] + ).strip() def _build_risk_answer( self, @@ -1661,22 +1679,56 @@ class UserAgentService: citations: list[UserAgentCitation], ) -> str: risk_flags = self._resolve_risk_flags(payload) - if not risk_flags: + platform_messages = self._evaluate_platform_risk_messages(payload) + if not risk_flags and not platform_messages: return "当前未识别到明确风险标签,建议继续查看原始明细或补充更多上下文。" reasons = [RISK_REASON_MAP.get(flag, f"{flag} 需要人工进一步确认。") for flag in risk_flags] + if platform_messages: + reasons.extend(platform_messages) citation_text = ( f" 参考规则:{'、'.join(item.title for item in citations[:2])}。" if citations else "" ) + signal_count = len(risk_flags) + (1 if platform_messages else 0) return ( - f"本次识别到 {len(risk_flags)} 类风险:{'、'.join(risk_flags)}。" + f"本次识别到 {signal_count} 类风险信号。" f"触发原因:{';'.join(reasons)}。" "建议先复核明细、附件和审批链,再决定是否继续处理。" f"{citation_text}" ) + def _evaluate_platform_risk_messages(self, payload: UserAgentRequest) -> list[str]: + claim_id = str(payload.tool_payload.get("claim_id") or "").strip() + if not claim_id: + return [] + + claim = self.db.scalar( + select(ExpenseClaim) + .where(ExpenseClaim.id == claim_id) + .options(selectinload(ExpenseClaim.items)) + ) + if claim is None: + return [] + + rule_codes = resolve_rule_codes_for_risk_check( + payload.ontology, + query_text=payload.message, + ) + review = ExpenseClaimService(self.db).evaluate_platform_risk_rules( + claim, + rule_codes=rule_codes, + ) + messages: list[str] = [] + for flag in review.get("flags") or []: + if not isinstance(flag, dict): + continue + message = str(flag.get("message") or "").strip() + if message and message not in messages: + messages.append(message) + return messages + def _build_draft_payload(self, payload: UserAgentRequest) -> UserAgentDraftPayload: scenario_label = SCENARIO_LABELS.get(payload.ontology.scenario, "业务") subject = self._resolve_subject(payload) @@ -1690,7 +1742,7 @@ class UserAgentService: if is_submitted: body = ( f"主题:{subject}\n" - f"结论:报销单已完成 AI验审,当前节点为 {approval_stage or '审批中'}。\n" + f"结论:报销单已提交,当前节点为 {approval_stage or '审批中'}。\n" "建议:后续可在个人报销列表中跟踪审批进度,必要时再补充说明或附件。\n" f"原始问题:{payload.message}" ) @@ -2329,7 +2381,7 @@ class UserAgentService: if review_action == "next_step": if draft_payload is not None and draft_payload.status == "submitted": stage_text = draft_payload.approval_stage or "审批中" - return f"报销单 {draft_payload.claim_no or ''} 已完成 AI验审,当前节点为 {stage_text}。".strip() + return f"报销单 {draft_payload.claim_no or ''} 已提交,当前节点为 {stage_text}。".strip() if payload.tool_payload.get("submission_blocked"): return str(payload.tool_payload.get("message") or "").strip() or "当前报销单暂时还不能提交审批。" return ( @@ -2895,18 +2947,19 @@ class UserAgentService: "expense_type_code": "", } participants: list[str] = [] - for item in payload.ontology.entities: - if item.type == "employee" and not values["employee_name"]: - values["employee_name"] = item.value - elif item.type == "customer" and not values["customer"]: - values["customer"] = item.value - elif item.type == "amount" and item.role != "threshold" and not values["amount"]: - values["amount"] = f"{item.value}元" if "元" not in item.value else item.value - elif item.type == "expense_type" and not values["expense_type_code"]: - values["expense_type_code"] = item.normalized_value - values["expense_type"] = EXPENSE_TYPE_LABELS.get( - item.normalized_value, - item.value, + for item in payload.ontology.entities: + if item.type == "employee" and not values["employee_name"]: + values["employee_name"] = item.value + elif item.type == "customer" and not values["customer"]: + values["customer"] = item.value + elif item.type == "amount" and item.role != "threshold" and not values["amount"]: + normalized_amount = str(item.normalized_value or "").strip() + values["amount"] = f"{normalized_amount}元" if normalized_amount else item.value + elif item.type == "expense_type" and not values["expense_type_code"]: + values["expense_type_code"] = item.normalized_value + values["expense_type"] = EXPENSE_TYPE_LABELS.get( + item.normalized_value, + item.value, ) elif item.type in {"participant", "person"} and item.value.strip(): participants.append(item.value.strip()) @@ -3305,15 +3358,17 @@ class UserAgentService: return self._build_slot_value() @staticmethod - def _normalize_amount_text(value: str) -> str: - cleaned = str(value or "").strip() - if not cleaned: - return "" - match = AMOUNT_TEXT_PATTERN.search(cleaned) - if not match: - return cleaned - number = float(match.group(1)) - return f"{number:.2f}元" + def _normalize_amount_text(value: str) -> str: + cleaned = str(value or "").strip() + if not cleaned: + return "" + for alias, canonical in sorted(AMOUNT_UNIT_ALIASES.items(), key=lambda item: len(item[0]), reverse=True): + cleaned = cleaned.replace(alias, canonical) + match = AMOUNT_TEXT_PATTERN.search(cleaned) + if not match: + return cleaned + number = float(match.group(1)) + return f"{number:.2f}元" @staticmethod def _normalize_expense_type_input(value: str) -> tuple[str, str]: diff --git a/web/src/assets/styles/views/audit-view.css b/web/src/assets/styles/views/audit-view.css index 278d994..ca64c7b 100644 --- a/web/src/assets/styles/views/audit-view.css +++ b/web/src/assets/styles/views/audit-view.css @@ -2511,3 +2511,216 @@ tbody tr.spotlight { grid-column: span 1; } } + +.json-risk-skill-detail .detail-scroll { + display: grid; + grid-template-rows: auto minmax(0, 1fr); +} + +.json-risk-editor-shell { + min-height: 0; + height: 100%; + display: flex; + flex-direction: column; + gap: 10px; + padding: 10px; +} + +.json-risk-editor-head { + display: flex; + align-items: flex-start; + justify-content: space-between; + gap: 16px; +} + +.json-risk-editor-title { + min-width: 0; + display: flex; + align-items: flex-start; + gap: 12px; +} + +.json-risk-editor-title h2 { + color: #0f172a; + font-size: 18px; + font-weight: 850; +} + +.json-risk-editor-title p { + margin-top: 2px; + max-width: 760px; + color: #64748b; + font-size: 12px; + line-height: 1.4; +} + +.json-risk-head-subtitle { + display: -webkit-box; + margin: 6px 0 0; + max-width: 760px; + overflow: hidden; + color: #64748b; + font-size: 13px; + line-height: 1.55; + -webkit-box-orient: vertical; + -webkit-line-clamp: 2; +} + +.json-risk-head-category { + margin: 6px 0 0; + color: #be123c; + font-size: 12px; + font-weight: 600; +} + +.skill-name-cell .skill-list-subtitle { + display: -webkit-box; + overflow: hidden; + color: #94a3b8; + font-size: 12px; + line-height: 1.45; + -webkit-box-orient: vertical; + -webkit-line-clamp: 2; +} + +.json-risk-editor-actions { + display: flex; + align-items: center; + gap: 10px; + flex-wrap: wrap; +} + +.json-risk-mode-pill { + min-height: 28px; + display: inline-flex; + align-items: center; + padding: 0 10px; + border-radius: 999px; + background: #fff1f2; + color: #be123c; + font-size: 12px; + font-weight: 800; +} + +.json-risk-editor-body { + flex: 1 1 auto; + min-height: 0; + display: block; +} + +.json-risk-main-stage { + min-height: 0; + display: grid; + gap: 12px; +} + +.json-risk-description-card { + border-color: #fecdd3; + background: linear-gradient(180deg, #fffafb 0%, #ffffff 100%); +} + +.json-risk-description-text { + margin: 0; + padding: 0 4px 8px; + color: #334155; + font-size: 14px; + line-height: 1.75; + white-space: pre-wrap; + word-break: break-word; +} + +.json-risk-description-source { + margin: 0; + padding: 8px 12px 4px; + border-top: 1px solid #ffe4e6; + color: #94a3b8; + font-size: 12px; + line-height: 1.5; +} + +.json-risk-summary-grid { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 10px; +} + +.json-risk-summary-grid span { + min-height: 34px; + display: flex; + flex-direction: column; + gap: 4px; + padding: 10px 12px; + border-radius: 10px; + background: #f8fafc; + color: #475569; + font-size: 12px; +} + +.json-risk-summary-grid strong { + color: #0f172a; + font-size: 11px; + font-weight: 800; + text-transform: uppercase; + letter-spacing: 0.04em; +} + +.json-risk-flow-diagram { + display: grid; + grid-template-columns: minmax(0, 1fr) auto minmax(0, 1fr) auto minmax(0, 1fr); + gap: 10px; + align-items: center; +} + +.json-risk-flow-column { + display: grid; + gap: 6px; + padding: 12px; + border-radius: 12px; + border: 1px solid #e2e8f0; + background: #f8fafc; +} + +.json-risk-flow-column.center { + text-align: center; + background: #fff1f2; + border-color: #fecdd3; +} + +.json-risk-flow-column code { + font-size: 11px; + color: #334155; +} + +.json-risk-flow-label { + font-size: 11px; + font-weight: 800; + color: #64748b; + text-transform: uppercase; +} + +.json-risk-flow-arrow { + color: #94a3b8; + font-size: 18px; + font-weight: 800; +} + +.json-risk-editor-toolbar { + display: flex; + align-items: center; + gap: 8px; +} + +.json-risk-editor { + min-height: 280px; +} + +.json-risk-version-center { + min-height: 0; + display: grid; + gap: 12px; + align-content: start; + padding: 12px; + border-radius: 12px; + border: 1px solid #e2e8f0; + background: #ffffff; +} diff --git a/web/src/views/AuditView.vue b/web/src/views/AuditView.vue index 62d48de..fde0dad 100644 --- a/web/src/views/AuditView.vue +++ b/web/src/views/AuditView.vue @@ -5,10 +5,16 @@ v-if="selectedSkill" key="detail" class="skill-detail" - :class="{ 'spreadsheet-skill-detail': selectedSkill.usesSpreadsheetRule }" + :class="{ + 'spreadsheet-skill-detail': selectedSkill.usesSpreadsheetRule, + 'json-risk-skill-detail': selectedSkill.usesJsonRiskRule + }" >
-
+
{{ selectedSkill.typeLabel }}

{{ selectedSkill.name }}

@@ -95,12 +101,12 @@
-
- - {{ selectedSpreadsheetVersionModeLabel }} - -
- +
+ + {{ selectedSpreadsheetVersionModeLabel }} + +
+
-
- 文件{{ selectedSpreadsheetFileName }} - 负责人{{ selectedSkill.owner }} - 最近更新{{ selectedSkill.updatedAt }} -
+
+ 文件{{ selectedSpreadsheetFileName }} + 负责人{{ selectedSkill.owner }} + 最近更新{{ selectedSkill.updatedAt }} +
-
+
正在加载 Excel 规则表... @@ -135,62 +141,182 @@
-
- - {{ - canEditSpreadsheetInline - ? '可直接在线编辑;保存后,右侧会自动记录本次修改内容。' - : '当前为只读预览模式。' - }} - - 右侧仅展示最近 30 次修改操作。 -
+
+ + {{ + canEditSpreadsheetInline + ? '可直接在线编辑;保存后,右侧会自动记录本次修改内容。' + : '当前为只读预览模式。' + }} + + 右侧仅展示最近 30 次修改操作。 +
- - - + + + + +
+
+
+
{{ selectedSkill.typeLabel }}
+
+

{{ selectedSkill.name }}

+

+ {{ selectedSkill.riskRuleSubtitle || '平台通用风险规则' }} +

+

+ 适用场景:{{ selectedSkill.riskCategory }} +

+
+
+
+ JSON 风险规则 +
+
+ +
+
+
+
+
+

规则摘要

+

检查器与字段关系为只读说明,实际判断逻辑由平台代码实现。

+
+
+
+ 适用场景{{ selectedSkill.riskCategory || '-' }} + 检查器{{ selectedSkill.riskRuleSummary.evaluator || '-' }} + 本体信号{{ selectedSkill.riskRuleSummary.ontologySignal || '-' }} + + 申报字段 + {{ selectedSkill.riskRuleSummary.inputs?.declared || 'claim.location' }} + + + 证据字段 + {{ (selectedSkill.riskRuleSummary.inputs?.evidence || []).join('、') || '-' }} + +
+
+ +
+
+
+

字段关系

+

提交报销时从表单与 OCR 组装验审上下文,再执行一致性检查。

+
+
+
+
+ 输入 + claim.location + attachment.cities[] + item.item_location +
+
+
+ 检查 + {{ selectedSkill.riskRuleSummary?.evaluator || 'location_consistency' }} +
+
+
+ 输出 + risk_flags_json + severity / message +
+
+
+ +
+
+
+

规则说明

+

本条风险规则的业务背景、识别逻辑与适用场景(来自 JSON 契约 description 字段)。

+
+
+

{{ selectedSkill.riskRuleDescription }}

+

+ 来源:{{ selectedSkill.riskRuleSourceRef }} +

+
+ +
+
+
+

规则 JSON 契约

+

保存后写入 server/rules/risk-rules/,提交验审与 Agent 风险问答共用同一检查器。

+
+
+ +
+ 请勿在 JSON 中配置公司差标;evaluator 变更需同步发布服务端检查器。 + 平台内置规则,一般不频繁变更,直接维护 JSON 契约即可。 +
+
+
+
+
+
- + + - -
- -
-
- - -
- +
+
diff --git a/web/src/views/scripts/AuditView.js b/web/src/views/scripts/AuditView.js index 3fb9ba8..1a2e381 100644 --- a/web/src/views/scripts/AuditView.js +++ b/web/src/views/scripts/AuditView.js @@ -15,8 +15,10 @@ import { fetchAgentAssetSpreadsheetBlob, fetchAgentAssetSpreadsheetChangeRecords, fetchAgentAssetSpreadsheetOnlyOfficeConfig, + fetchAgentAssetRuleJson, fetchAgentAssetVersionTimeline, fetchAgentRuns, + saveAgentAssetRuleJson, importAgentAssetSpreadsheetContent, restoreAgentAssetVersion, updateAgentAsset @@ -30,9 +32,8 @@ const RULE_TABLE_COLUMNS = { category: '业务域', owner: '负责人', scope: '适用场景', - runtime: '风险等级', - version: '当前版本', - metric: '审核状态' + version: '修改次数', + metric: '修改人' } const TYPE_META = { @@ -106,6 +107,8 @@ const TAB_META = { hintText: '仅展示 tag 为“财务规则”的规则资产;未打新 tag 的旧规则已从规则中心隐藏。', searchPlaceholder: '搜索财务规则名称、编码或负责人', tableColumns: RULE_TABLE_COLUMNS, + showRuntimeColumn: false, + showStatusColumn: false, badgeTone: 'emerald' }, riskRules: { @@ -114,9 +117,12 @@ const TAB_META = { label: '风险规则', typeLabel: '风险规则', createButtonLabel: '风险规则已接入', - hintText: '仅展示 tag 为“风险规则”的规则资产;当前未打标签的规则不会显示在这里。', + hintText: '仅展示平台风险规则;适用场景按差旅、发票、餐饮招待等分类,可用「使用场景」筛选。', searchPlaceholder: '搜索风险规则名称、编码或负责人', tableColumns: RULE_TABLE_COLUMNS, + showRuntimeColumn: false, + showVersionColumn: false, + showStatusColumn: false, badgeTone: 'rose' }, skills: { @@ -287,7 +293,32 @@ const RULE_TAB_TAG_ALIASES = { riskRules: new Set(['风险规则', '风险', '风控', 'riskrule', 'riskrules', 'risk']) } +const RISK_SCENARIO_OPTIONS = [ + { value: '', label: '全部场景' }, + { value: '差旅', label: '差旅' }, + { value: '发票', label: '发票' }, + { value: '餐饮招待', label: '餐饮招待' }, + { value: '交通出行', label: '交通出行' }, + { value: '办公物料', label: '办公物料' }, + { value: '费用科目', label: '费用科目' }, + { value: '通用', label: '通用' } +] + +const LEGACY_RISK_SCENARIO_KEYS = new Set([ + 'expense', + 'risk_check', + 'travel', + 'meal', + 'invoice', + 'travel_policy', + 'travel_standard', + 'attachment_policy', + 'scene_policy', + 'invoice_anomaly' +]) + const SPREADSHEET_DETAIL_MODE = 'spreadsheet' +const JSON_RISK_DETAIL_MODE = 'json_risk' const PREVIEW_RULE_ID = 'preview-rule-expense-company-travel-expense' const PREVIEW_RULE_CODE = 'rule.expense.company_travel_expense_reimbursement' const PREVIEW_RULE_VERSION_SPECS = [ @@ -461,6 +492,11 @@ function isSpreadsheetRuleSource(value) { return normalizeText(configJson.detail_mode || configJson.rule_detail_mode).toLowerCase() === SPREADSHEET_DETAIL_MODE } +function isJsonRiskRuleSource(value) { + const configJson = readConfigJson(value) + return normalizeText(configJson.detail_mode || configJson.rule_detail_mode).toLowerCase() === JSON_RISK_DETAIL_MODE +} + function normalizeRuleTagValue(value) { return normalizeText(value).toLowerCase().replace(/[\s_-]+/g, '') } @@ -478,6 +514,14 @@ function collectRuleTagValues(source) { } function resolveRuleTabId(source) { + const code = normalizeText(source?.code || '').toLowerCase() + if (code.startsWith('risk.')) { + return 'riskRules' + } + if (isJsonRiskRuleSource(source)) { + return 'riskRules' + } + const normalizedTags = collectRuleTagValues(source).map((item) => normalizeRuleTagValue(item)) if (normalizedTags.some((item) => RULE_TAB_TAG_ALIASES.riskRules.has(item))) { @@ -510,6 +554,108 @@ function resolveTabMeta(tabId, typeKey) { return TAB_META[typeKey] } +function resolveRiskRuleDescription(payload) { + if (!isPlainObject(payload)) { + return '' + } + return normalizeText(payload.description) +} + +function resolveRiskRuleSourceRef(payload) { + if (!isPlainObject(payload)) { + return '' + } + const metadata = isPlainObject(payload.metadata) ? payload.metadata : {} + return normalizeText(metadata.source_ref) +} + +function inferRiskCategoryFromCode(code) { + const normalized = normalizeText(code).toLowerCase() + if (normalized.startsWith('risk.travel.')) { + return '差旅' + } + if (normalized.startsWith('risk.invoice.')) { + return '发票' + } + if (normalized.includes('entertainment') || normalized.includes('meal_localized')) { + return '餐饮招待' + } + if (normalized.includes('consecutive_transport')) { + return '交通出行' + } + if (normalized.startsWith('risk.expense.')) { + return '费用科目' + } + return '通用' +} + +function resolveRiskRuleCategory(source) { + const configJson = readConfigJson(source) + const explicit = normalizeText(configJson.risk_category) + if (explicit) { + return explicit + } + + const payloadCategory = normalizeText(source?.risk_category) + if (payloadCategory) { + return payloadCategory + } + + const scenarioItems = Array.isArray(source?.scenario_json) + ? source.scenario_json + : Array.isArray(source?.scenarioList) + ? source.scenarioList + : [] + const businessScenario = scenarioItems + .map((item) => normalizeText(item)) + .find((item) => item && !LEGACY_RISK_SCENARIO_KEYS.has(item)) + if (businessScenario) { + return businessScenario + } + + return inferRiskCategoryFromCode(source?.code) +} + +function buildRiskListSubtitle(text, maxLength = 42) { + const normalized = normalizeText(text) + if (!normalized) { + return '平台内置风险规则' + } + const firstSentence = normalized.split(/[。;;!?\n]/)[0] || normalized + if (firstSentence.length <= maxLength) { + return firstSentence + } + return `${firstSentence.slice(0, maxLength)}…` +} + +function applyRiskRuleJsonState(target, payload, apiPayload) { + const rulePayload = isPlainObject(payload) ? payload : {} + const fullDescription = + resolveRiskRuleDescription(rulePayload) || + normalizeText(apiPayload?.description) || + normalizeText(target.riskRuleDescription) + const riskCategory = + normalizeText(rulePayload.risk_category) || + resolveRiskRuleCategory({ ...target, risk_category: rulePayload.risk_category, config_json: rulePayload }) + + return { + ...target, + riskRuleDescription: fullDescription, + riskRuleSubtitle: buildRiskListSubtitle(fullDescription, 48), + riskCategory, + scope: riskCategory, + riskRuleSourceRef: resolveRiskRuleSourceRef(rulePayload), + riskRuleSummary: { + name: apiPayload?.name || target.name, + evaluator: apiPayload?.evaluator || rulePayload.evaluator || '', + ontologySignal: apiPayload?.ontology_signal || rulePayload.ontology_signal || '', + inputs: apiPayload?.inputs || rulePayload.inputs || {}, + outcomes: apiPayload?.outcomes || rulePayload.outcomes || {} + }, + riskRuleJsonText: JSON.stringify(rulePayload, null, 2) + } +} + function cloneJsonObject(value) { if (!isPlainObject(value)) { return null @@ -812,7 +958,7 @@ function buildRowRuntime(asset, typeKey) { function buildRowMetric(asset, typeKey) { if (typeKey === 'rules') { - return asset.reviewer ? `审核人:${asset.reviewer}` : '待分配审核人' + return normalizeText(asset.modified_by) || '未记录' } if (typeKey === 'skills') { return '进入详情查看输出' @@ -832,6 +978,25 @@ function buildListItem(asset) { const tabMeta = resolveTabMeta(tabId, typeKey) const statusMeta = resolveStatusMeta(asset.status) + const workingVersion = asset.working_version || asset.current_version || '-' + const changeCount = + typeof asset.change_count === 'number' + ? asset.change_count + : Array.isArray(asset.recent_versions) + ? Math.max(asset.recent_versions.length - 1, 0) + : 0 + const modifiedBy = + normalizeText(asset.modified_by) || + normalizeText( + Array.isArray(asset.recent_versions) + ? asset.recent_versions.find((item) => item.version === workingVersion)?.created_by + : '' + ) + const isRiskRule = tabId === 'riskRules' + const riskCategory = isRiskRule ? resolveRiskRuleCategory(asset) : '' + const listSubtitle = isRiskRule + ? buildRiskListSubtitle(asset.description) + : normalizeText(asset.description) return { id: asset.id, @@ -842,19 +1007,24 @@ function buildListItem(asset) { short: makeShort(asset.name), name: asset.name, code: asset.code, - summary: asset.description, + summary: listSubtitle, + listSubtitle, category: resolveDomainLabel(asset.domain), owner: asset.owner, reviewer: asset.reviewer || '待分配', - scope: formatScenarioList(asset.scenario_json), + scope: isRiskRule ? riskCategory || '通用' : formatScenarioList(asset.scenario_json), + riskCategory, model: buildRowRuntime(asset, typeKey), - version: asset.working_version || asset.current_version || '-', + version: workingVersion, + versionDisplay: typeKey === 'rules' ? `${changeCount} 次` : workingVersion, publishedVersion: asset.published_version || '-', - workingVersion: asset.working_version || asset.current_version || '-', + workingVersion, status: statusMeta.label, statusValue: asset.status, statusTone: statusMeta.tone, - hitRate: buildRowMetric(asset, typeKey), + hitRate: buildRowMetric({ ...asset, modified_by: modifiedBy }, typeKey), + modifiedBy, + changeCount, updatedAt: formatDateTime(asset.updated_at), badgeTone: tabMeta.badgeTone, spotlight: asset.status === 'active', @@ -1214,6 +1384,7 @@ function buildDetailViewModel(detail, runs) { const history = buildHistory(detail.recent_versions || [], detail) const previewVersion = history.find((item) => item.isWorking) || history[0] || null const usesSpreadsheetRule = typeKey === 'rules' && isSpreadsheetRuleSource(detail) + const usesJsonRiskRule = typeKey === 'rules' && isJsonRiskRuleSource(detail) const ruleDocument = readRuleDocumentMeta(detail) const previewRawMarkdown = detail.current_version_content_type === 'markdown' @@ -1239,11 +1410,12 @@ function buildDetailViewModel(detail, runs) { short: makeShort(detail.name), name: detail.name, code: detail.code, - summary: detail.description, + summary: usesJsonRiskRule ? buildRiskListSubtitle(detail.description) : detail.description, + listSubtitle: usesJsonRiskRule ? buildRiskListSubtitle(detail.description) : normalizeText(detail.description), owner: detail.owner, reviewer: detail.reviewer || detail.latest_review?.reviewer || '待分配', category: resolveDomainLabel(detail.domain), - scope: formatScenarioList(detail.scenario_json), + scope: usesJsonRiskRule ? resolveRiskRuleCategory(detail) || '通用' : formatScenarioList(detail.scenario_json), version: detail.working_version || detail.current_version || '-', currentVersion: detail.current_version || '-', publishedVersion: detail.published_version || '-', @@ -1257,6 +1429,13 @@ function buildDetailViewModel(detail, runs) { badgeTone: tabMeta.badgeTone, configJson, usesSpreadsheetRule, + usesJsonRiskRule, + riskRuleJsonText: '{}', + riskRuleSummary: null, + riskRuleDescription: '', + riskRuleSubtitle: usesJsonRiskRule ? buildRiskListSubtitle(detail.description) : '', + riskRuleSourceRef: '', + riskCategory: usesJsonRiskRule ? resolveRiskRuleCategory(detail) : '', ruleDocument, scenarioList: Array.isArray(detail.scenario_json) ? [...detail.scenario_json] : [], markdownContent: previewMarkdown, @@ -1380,6 +1559,7 @@ export default { const selectedDomain = ref('') const selectedOwner = ref('') const selectedStatus = ref('') + const selectedRiskScenario = ref('') const loading = ref(false) const errorMessage = ref('') const detailLoading = ref(false) @@ -1434,11 +1614,17 @@ export default { const createButtonLabel = computed(() => activeMeta.value.createButtonLabel) const hintText = computed(() => activeMeta.value.hintText) const tableColumns = computed(() => activeMeta.value.tableColumns) + const showRuntimeColumn = computed(() => activeMeta.value.showRuntimeColumn !== false) const showMetricColumn = computed(() => activeMeta.value.showMetricColumn !== false) + const showVersionColumn = computed(() => activeMeta.value.showVersionColumn !== false) + const showStatusColumn = computed(() => activeMeta.value.showStatusColumn !== false) const selectedSkillIsRule = computed(() => selectedSkill.value?.type === 'rules') const selectedSkillUsesSpreadsheet = computed( () => selectedSkillIsRule.value && Boolean(selectedSkill.value?.usesSpreadsheetRule) ) + const selectedSkillUsesJsonRisk = computed( + () => selectedSkillIsRule.value && Boolean(selectedSkill.value?.usesJsonRiskRule) + ) const canManageSelected = computed( () => isAdmin.value && Boolean(selectedSkill.value) && !selectedSkill.value?.isPreviewMock ) @@ -1581,13 +1767,23 @@ export default { const selectedStatusLabel = computed( () => STATUS_OPTIONS.find((item) => item.value === selectedStatus.value)?.label || '状态' ) + const showRiskScenarioFilter = computed(() => activeType.value === 'riskRules') + const showStatusFilter = computed(() => activeType.value !== 'riskRules') + const selectedRiskScenarioLabel = computed( + () => + RISK_SCENARIO_OPTIONS.find((item) => item.value === selectedRiskScenario.value)?.label || + '使用场景' + ) const activeFilterTokens = computed(() => { const tokens = [] if (selectedDomain.value) { tokens.push(`业务域:${resolveDomainLabel(selectedDomain.value)}`) } - if (selectedStatus.value) { + if (showRiskScenarioFilter.value && selectedRiskScenario.value) { + tokens.push(`使用场景:${selectedRiskScenario.value}`) + } + if (showStatusFilter.value && selectedStatus.value) { tokens.push(`状态:${resolveStatusMeta(selectedStatus.value).label}`) } if (selectedOwner.value) { @@ -1612,7 +1808,10 @@ export default { actionIcon: '', tone: 'amber', artLabel: 'ASSET', - tips: ['切换页签可查看其他资产类型', '支持按业务域、负责人和状态做过滤'] + tips: + activeType.value === 'riskRules' + ? ['切换页签可查看其他资产类型', '支持按业务域、负责人和使用场景做过滤'] + : ['切换页签可查看其他资产类型', '支持按业务域、负责人和状态做过滤'] } } @@ -1620,7 +1819,9 @@ export default { eyebrow: '筛选结果为空', title: `没有找到匹配的${activeTabLabel.value}`, desc: hasFilters - ? '试试清空业务域、负责人、状态或关键词筛选,再重新查看。' + ? showRiskScenarioFilter.value + ? '试试清空业务域、负责人、使用场景或关键词筛选,再重新查看。' + : '试试清空业务域、负责人、状态或关键词筛选,再重新查看。' : `当前列表中还没有满足展示条件的${activeTabLabel.value}资产。`, icon: hasFilters ? 'mdi mdi-tune-variant' : 'mdi mdi-view-grid-outline', actionLabel: hasFilters ? '清空筛选' : '', @@ -1628,7 +1829,9 @@ export default { tone: hasFilters ? 'emerald' : 'slate', artLabel: hasFilters ? 'FILTER' : 'QUEUE', tips: hasFilters - ? ['业务域、负责人、状态与关键词会叠加过滤', '可以换个编码、名称或负责人关键词继续搜索'] + ? showRiskScenarioFilter.value + ? ['业务域、负责人、使用场景与关键词会叠加过滤', '可以换个规则名称或场景分类继续搜索'] + : ['业务域、负责人、状态与关键词会叠加过滤', '可以换个编码、名称或负责人关键词继续搜索'] : ['列表展示来自真实资产 API', '切换资产类型后会自动重新拉取数据'] } }) @@ -1675,9 +1878,18 @@ export default { : true const matchesDomain = selectedDomain.value ? item.domainValue === selectedDomain.value : true const matchesOwner = selectedOwner.value ? item.owner === selectedOwner.value : true - const matchesStatus = selectedStatus.value ? item.statusValue === selectedStatus.value : true + const matchesStatus = showStatusFilter.value + ? selectedStatus.value + ? item.statusValue === selectedStatus.value + : true + : true + const matchesRiskScenario = showRiskScenarioFilter.value + ? selectedRiskScenario.value + ? item.riskCategory === selectedRiskScenario.value + : true + : true - return matchesKeyword && matchesDomain && matchesOwner && matchesStatus + return matchesKeyword && matchesDomain && matchesOwner && matchesStatus && matchesRiskScenario }) }) @@ -1723,6 +1935,7 @@ export default { selectedDomain.value = '' selectedOwner.value = '' selectedStatus.value = '' + selectedRiskScenario.value = '' activeFilterPopover.value = '' } @@ -1753,6 +1966,9 @@ export default { if (name === 'status') { selectedStatus.value = value } + if (name === 'riskScenario') { + selectedRiskScenario.value = value + } closeFilterPopover() } @@ -1840,7 +2056,7 @@ export default { ) spreadsheetChangeRecordsByAsset.value = { ...spreadsheetChangeRecordsByAsset.value, - [assetId]: [nextRecord, ...deduped].slice(0, 5) + [assetId]: [nextRecord, ...deduped].slice(0, 30) } } @@ -1898,22 +2114,51 @@ export default { function getLatestSpreadsheetChangeKey(assetId) { const records = spreadsheetChangeRecordsByAsset.value[assetId] || [] const latest = records.find((item) => item?.changed_at) - return latest ? `${latest.changed_at}-${latest.actor}-${latest.summary}` : '' + if (!latest) { + return '' + } + const previewSignature = Array.isArray(latest.cell_changes) + ? latest.cell_changes + .slice(0, 8) + .map((item) => + [ + item?.sheet_name, + item?.cell, + item?.change_type, + item?.before_value, + item?.after_value + ] + .map((value) => normalizeText(value)) + .join(':') + ) + .join('|') + : '' + return [ + latest.id, + latest.changed_at, + latest.actor, + latest.summary, + latest.changed_sheet_count, + latest.changed_cell_count, + previewSignature + ] + .map((value) => normalizeText(value)) + .join('-') } async function refreshSpreadsheetChangeRecordsAfterSave(assetId, previousLatestKey = '', attempt = 0) { const normalizedAssetId = normalizeText(assetId) if (!normalizedAssetId || selectedSkill.value?.id !== normalizedAssetId) { - return + return false } await loadSpreadsheetChangeRecords(normalizedAssetId) const nextLatestKey = getLatestSpreadsheetChangeKey(normalizedAssetId) if (nextLatestKey && nextLatestKey !== previousLatestKey) { - return + return true } if (attempt >= 9) { - return + return false } await new Promise((resolve) => window.setTimeout(resolve, 800)) return refreshSpreadsheetChangeRecordsAfterSave(normalizedAssetId, previousLatestKey, attempt + 1) @@ -1973,6 +2218,17 @@ export default { stopSpreadsheetOnlyOfficeVersionSync() return } + + const changeRecordRefreshed = await refreshSpreadsheetChangeRecordsAfterSave( + normalizedAssetId, + previousLatestChangeKey + ) + if (changeRecordRefreshed) { + clearSpreadsheetPendingChangeRecord(normalizedAssetId, normalizedVersion) + await refreshCurrentAssets() + stopSpreadsheetOnlyOfficeVersionSync() + return + } } catch { // Ignore transient polling failures and continue retrying within the window. } @@ -2275,10 +2531,15 @@ export default { const detail = await fetchAgentAssetDetail(assetId) selectedSkill.value = buildDetailViewModel(detail, runs.value) if (selectedSkill.value?.type === 'rules') { - loadVersionTimeline(assetId, { silent: true }).catch(() => {}) + if (!selectedSkill.value.usesJsonRiskRule) { + loadVersionTimeline(assetId, { silent: true }).catch(() => {}) + } if (selectedSkill.value.usesSpreadsheetRule) { loadSpreadsheetChangeRecords(assetId).catch(() => {}) } + if (selectedSkill.value.usesJsonRiskRule) { + await loadRiskRuleJson(assetId) + } } } catch (error) { detailError.value = error?.message || '资产详情加载失败,请稍后重试。' @@ -2288,6 +2549,67 @@ export default { } } + async function loadRiskRuleJson(assetId) { + if (!assetId || !selectedSkill.value?.usesJsonRiskRule) { + return + } + const payload = await fetchAgentAssetRuleJson(assetId) + const rulePayload = payload?.payload && typeof payload.payload === 'object' ? payload.payload : payload + selectedSkill.value = applyRiskRuleJsonState(selectedSkill.value, rulePayload, payload) + } + + async function saveRiskRuleJson() { + if (!selectedSkill.value?.id || !canEditMarkdown.value) { + return + } + actionState.value = 'save-risk-json' + detailBusy.value = true + try { + const parsed = JSON.parse(String(selectedSkill.value.riskRuleJsonText || '{}')) + const saved = await saveAgentAssetRuleJson(selectedSkill.value.id, { payload: parsed }) + const rulePayload = saved?.payload && typeof saved.payload === 'object' ? saved.payload : saved + selectedSkill.value = applyRiskRuleJsonState(selectedSkill.value, rulePayload, saved) + toast('风险规则 JSON 已保存。') + } catch (error) { + toast(error?.message || '风险规则 JSON 保存失败。') + } finally { + detailBusy.value = false + actionState.value = '' + } + } + + function formatRiskRuleJson() { + if (!selectedSkill.value?.usesJsonRiskRule) { + return + } + try { + const parsed = JSON.parse(String(selectedSkill.value.riskRuleJsonText || '{}')) + selectedSkill.value = applyRiskRuleJsonState(selectedSkill.value, parsed, { + name: selectedSkill.value.name, + description: resolveRiskRuleDescription(parsed) + }) + } catch (error) { + toast(error?.message || 'JSON 格式无效,无法格式化。') + } + } + + function downloadRiskRuleJson() { + if (!selectedSkill.value?.usesJsonRiskRule) { + return + } + const blob = new Blob([String(selectedSkill.value.riskRuleJsonText || '{}')], { + type: 'application/json;charset=utf-8' + }) + const fileName = + selectedSkill.value.ruleDocument?.file_name || + `${selectedSkill.value.code || 'risk-rule'}.json` + const link = document.createElement('a') + link.href = URL.createObjectURL(blob) + link.download = fileName + link.click() + URL.revokeObjectURL(link.href) + } + async function loadSpreadsheetChangeRecords(assetId) { if (!assetId) { return @@ -2328,6 +2650,11 @@ export default { configJson: {}, isPreviewMock: false, usesSpreadsheetRule: false, + usesJsonRiskRule: false, + riskRuleJsonText: '{}', + riskRuleSummary: null, + riskRuleDescription: '', + riskRuleSourceRef: '', ruleDocument: null, scenarioList: [], fields: [], @@ -2775,7 +3102,10 @@ export default { hintText, searchPlaceholder, tableColumns, + showRuntimeColumn, + showVersionColumn, showMetricColumn, + showStatusColumn, visibleSkills, auditEmptyState, loading, @@ -2785,12 +3115,17 @@ export default { selectedDomain, selectedOwner, selectedStatus, + selectedRiskScenario, selectedDomainLabel, selectedOwnerLabel, selectedStatusLabel, + selectedRiskScenarioLabel, + showRiskScenarioFilter, + showStatusFilter, domainOptions, ownerOptions, statusOptions: STATUS_OPTIONS, + riskScenarioOptions: RISK_SCENARIO_OPTIONS, activeFilterPopover, activeFilterTokens, canManageSelected, @@ -2806,6 +3141,7 @@ export default { activateBlockedReason, selectedSkillIsRule, selectedSkillUsesSpreadsheet, + selectedSkillUsesJsonRisk, selectedSpreadsheetFileName, selectedSpreadsheetVersionModeLabel, selectedVersionTimelineItems, @@ -2850,6 +3186,9 @@ export default { confirmVersionSwitch, saveRuleMarkdown, saveRuleRuntimeJson, + saveRiskRuleJson, + formatRiskRuleJson, + downloadRiskRuleJson, triggerSpreadsheetUpload, downloadSpreadsheetFile, handleSpreadsheetFileInput, diff --git a/web/src/views/scripts/TravelReimbursementCreateView.js b/web/src/views/scripts/TravelReimbursementCreateView.js index 58e417f..656db16 100644 --- a/web/src/views/scripts/TravelReimbursementCreateView.js +++ b/web/src/views/scripts/TravelReimbursementCreateView.js @@ -5,6 +5,7 @@ import ConfirmDialog from '../../components/shared/ConfirmDialog.vue' import { useSystemState } from '../../composables/useSystemState.js' import { useToast } from '../../composables/useToast.js' import { recognizeOcrFiles } from '../../services/ocr.js' +import { fetchAgentRunDetail } from '../../services/agentAssets.js' import { clearUserConversations, deleteConversation, fetchLatestConversation, runOrchestrator } from '../../services/orchestrator.js' import { renderMarkdown } from '../../utils/markdown.js' import { @@ -175,6 +176,36 @@ const SESSION_TYPE_KNOWLEDGE = 'knowledge' const REVIEW_DRAWER_MODE_REVIEW = 'review' const REVIEW_DRAWER_MODE_DOCUMENTS = 'documents' const REVIEW_DRAWER_MODE_RISK = 'risk' +const FLOW_STEP_STATUS_PENDING = 'pending' +const FLOW_STEP_STATUS_RUNNING = 'running' +const FLOW_STEP_STATUS_COMPLETED = 'completed' +const FLOW_STEP_STATUS_FAILED = 'failed' +const FLOW_STEP_FALLBACKS = { + intent: { + title: '意图识别', + tool: 'IntentRecognizer', + runningText: '正在识别业务意图...', + completedText: '意图识别完成' + }, + extraction: { + title: '信息提取', + tool: 'SemanticExtractor', + runningText: '正在提取时间、金额、费用类型和待补项...', + completedText: '信息提取完成' + }, + ocr: { + title: '票据/OCR识别', + tool: 'OCRService', + runningText: '正在识别票据附件...', + completedText: '票据识别完成' + }, + result: { + title: '生成结果', + tool: 'ResultGenerator', + runningText: '正在生成解释与草稿...', + completedText: '结果已生成' + } +} const HOT_KNOWLEDGE_QUESTIONS = [ '差旅住宿标准按什么规则执行?', '酒店超标后如何申请例外报销?', @@ -199,6 +230,23 @@ const CATEGORY_CONFIDENCE_KEYWORDS = { communication: [/通讯|电话|流量|话费|宽带|网络/], welfare: [/福利|体检|团建|节日|慰问|关怀/] } +const FLOW_MISSING_SLOT_LABELS = { + expense_type: '报销类型', + customer_name: '客户名称', + time_range: '发生时间', + location: '地点', + merchant_name: '酒店/商户', + amount: '金额', + reason: '事由说明', + participants: '参与人员', + attachments: '票据附件' +} +const FLOW_INTENT_KEYWORDS = { + draft: ['报销', '草稿', '生成', '提交', '申请', '请走报销'], + query: ['查询', '查一下', '多少', '明细', '统计'], + risk_check: ['风险', '异常', '重复', '超标'], + explain: ['为什么', '依据', '规则', '怎么'] +} let messageSeed = 0 @@ -246,6 +294,297 @@ function formatMessageTime(value) { }) } +function createFlowSteps() { + return [] +} + +function formatSemanticEntityValue(entity) { + const normalizedValue = String(entity?.normalized_value || '').trim() + const rawValue = String(entity?.value || '').trim() + const entityType = String(entity?.type || '').trim() + + if (entityType === 'amount') { + const numericValue = Number(normalizedValue || rawValue) + if (Number.isFinite(numericValue) && numericValue > 0) { + return Number.isInteger(numericValue) ? `${numericValue}元` : `${numericValue.toFixed(2)}元` + } + } + + return rawValue || normalizedValue +} + +function summarizeSemanticParseDetail(semanticParse, ontologyJson = {}) { + if (!semanticParse || typeof semanticParse !== 'object') { + return FLOW_STEP_FALLBACKS.extraction.completedText + } + + const entities = Array.isArray(semanticParse.entities_json) ? semanticParse.entities_json : [] + const entityMap = new Map() + for (const item of entities) { + const entityType = String(item?.type || '').trim() + if (!entityType || entityMap.has(entityType)) continue + entityMap.set(entityType, item) + } + + const extractedParts = [] + const timeRange = semanticParse.time_range_json && typeof semanticParse.time_range_json === 'object' + ? semanticParse.time_range_json + : {} + const startDate = String(timeRange.start_date || '').trim() + const endDate = String(timeRange.end_date || '').trim() + if (startDate) { + extractedParts.push(`时间 ${startDate}${endDate && endDate !== startDate ? ` 至 ${endDate}` : ''}`) + } + + const amountEntity = entityMap.get('amount') + if (amountEntity) { + const amountValue = formatSemanticEntityValue(amountEntity) + if (amountValue) { + extractedParts.push(`金额 ${amountValue}`) + } + } + + const expenseTypeEntity = entityMap.get('expense_type') + if (expenseTypeEntity) { + const expenseTypeLabel = resolveExpenseTypeLabel( + String(expenseTypeEntity?.normalized_value || '').trim(), + String(expenseTypeEntity?.value || '').trim() + ) + if (expenseTypeLabel) { + extractedParts.push(`费用类型 ${expenseTypeLabel}`) + } + } + + const customerEntity = entityMap.get('customer') + if (customerEntity) { + const customerValue = formatSemanticEntityValue(customerEntity) + if (customerValue) { + extractedParts.push(`客户 ${customerValue}`) + } + } + + const missingSlots = Array.isArray(ontologyJson?.missing_slots) ? ontologyJson.missing_slots : [] + const missingLabels = missingSlots + .map((item) => FLOW_MISSING_SLOT_LABELS[String(item || '').trim()] || String(item || '').trim()) + .filter(Boolean) + + if (extractedParts.length && missingLabels.length) { + return `已提取${extractedParts.join('、')};待补充 ${missingLabels.join('、')}` + } + if (extractedParts.length) { + return `已提取${extractedParts.join('、')}` + } + if (missingLabels.length) { + return `已完成信息提取;待补充 ${missingLabels.join('、')}` + } + return FLOW_STEP_FALLBACKS.extraction.completedText +} + +function summarizeSemanticIntentDetail(semanticParse) { + if (!semanticParse || typeof semanticParse !== 'object') { + return FLOW_STEP_FALLBACKS.intent.completedText + } + + const scenarioLabel = SCENARIO_LABELS[String(semanticParse.scenario || '').trim()] || String(semanticParse.scenario || '').trim() || '通用' + const intentLabel = INTENT_LABELS[String(semanticParse.intent || '').trim()] || String(semanticParse.intent || '').trim() || '处理' + return `已识别为${scenarioLabel}场景,当前目标是${intentLabel}` +} + +function extractLocalFlowCandidates(rawText) { + const text = String(rawText || '').trim() + const compact = text.replace(/\s+/g, '') + + let time = '' + const explicitTimeMatch = text.match(/发生时间[::]?\s*([0-9]{4}[-/年][0-9]{1,2}[-/月][0-9]{1,2}日?)/) + if (explicitTimeMatch?.[1]) { + time = explicitTimeMatch[1].replace(/年/g, '-').replace(/月/g, '-').replace(/日/g, '').replace(/\//g, '-') + } else { + const dateMatch = text.match(/([0-9]{4}[-/年][0-9]{1,2}[-/月][0-9]{1,2}日?)/) + if (dateMatch?.[1]) { + time = dateMatch[1].replace(/年/g, '-').replace(/月/g, '-').replace(/日/g, '').replace(/\//g, '-') + } else if (/今天|今日/.test(compact)) { + time = '今天' + } else if (/昨天|昨日/.test(compact)) { + time = '昨天' + } else if (/前天/.test(compact)) { + time = '前天' + } + } + + let amount = '' + const amountMatch = text.match(/([0-9]+(?:\.[0-9]{1,2})?)\s*(?:元|员|圆|园|块|块钱|万元|万)/) + if (amountMatch?.[1]) { + const numericValue = Number(amountMatch[1]) + if (Number.isFinite(numericValue)) { + amount = Number.isInteger(numericValue) ? `${numericValue}元` : `${numericValue.toFixed(2)}元` + } + } + + let event = '' + let expenseType = '' + if (/客户.*吃饭|请客户.*吃饭|招待|宴请|请客/.test(compact)) { + event = '请客户吃饭' + expenseType = '业务招待费' + } else if (/出差|差旅|机票|高铁|火车|行程/.test(compact)) { + event = '出差行程' + expenseType = '差旅费' + } else if (/打车|网约车|出租车|车费|停车/.test(compact)) { + event = '交通出行' + expenseType = '交通费' + } else if (/住宿|酒店|宾馆/.test(compact)) { + event = '住宿报销' + expenseType = '住宿费' + } else if (/餐费|用餐|午餐|晚餐|早餐|餐饮/.test(compact)) { + event = '餐饮用餐' + expenseType = '餐费' + } + + return { + time, + amount, + event, + expenseType + } +} + +function buildLocalIntentPreview(rawText, sessionType = SESSION_TYPE_EXPENSE) { + if (sessionType === SESSION_TYPE_KNOWLEDGE) { + return '初步识别为财务知识问答,正在准备检索范围' + } + + const text = String(rawText || '').trim() + const compact = text.replace(/\s+/g, '') + const intentKey = Object.entries(FLOW_INTENT_KEYWORDS).find(([, keywords]) => + keywords.some((keyword) => compact.includes(keyword)) + )?.[0] || 'draft' + const intentLabel = INTENT_LABELS[intentKey] || '处理' + return `初步识别为报销场景,准备进入${intentLabel}` +} + +function buildLocalExtractionProgressMessages(rawText, options = {}) { + const candidates = extractLocalFlowCandidates(rawText) + const messages = [] + + messages.push('正在提取发生时间...') + messages.push( + candidates.time + ? `发现发生时间 ${candidates.time},继续提取金额...` + : '暂未定位到明确时间,继续提取金额...' + ) + messages.push( + candidates.amount + ? `发现金额 ${candidates.amount},继续识别事件类型...` + : '暂未定位到明确金额,继续识别事件类型...' + ) + + if (candidates.event || candidates.expenseType) { + const eventParts = [candidates.event, candidates.expenseType].filter(Boolean) + messages.push(`识别到${eventParts.join(' / ')},继续判断待补项...`) + } else { + messages.push('正在识别事件类型和费用分类...') + } + + const attachmentHint = Number(options.attachmentCount || 0) > 0 ? '附件完整性' : '票据附件' + messages.push(`正在判断待补项:客户名称、参与人员、${attachmentHint}`) + + return messages +} + +function formatFlowDuration(ms) { + const numericValue = Number(ms) + if (!Number.isFinite(numericValue) || numericValue < 0) { + return '--' + } + if (numericValue < 100) { + return '<0.1s' + } + if (numericValue < 1000) { + return `${(numericValue / 1000).toFixed(1)}s` + } + if (numericValue < 10000) { + return `${(numericValue / 1000).toFixed(1)}s` + } + return `${Math.round(numericValue / 1000)}s` +} + +function parseFlowTimestamp(value) { + const timestamp = new Date(value || '').getTime() + return Number.isFinite(timestamp) ? timestamp : 0 +} + +function resolveSemanticPhaseDurations(run) { + const runStart = parseFlowTimestamp(run?.started_at) + const toolCalls = Array.isArray(run?.tool_calls) ? run.tool_calls : [] + const firstToolStartedAt = toolCalls + .map((item) => parseFlowTimestamp(item?.created_at)) + .filter((value) => value > 0) + .sort((left, right) => left - right)[0] || 0 + const runFinishedAt = parseFlowTimestamp(run?.finished_at) + const semanticFinishedAt = firstToolStartedAt || runFinishedAt + + if (!runStart || !semanticFinishedAt || semanticFinishedAt <= runStart) { + return { intentMs: null, extractionMs: null } + } + + const totalMs = semanticFinishedAt - runStart + const intentMs = Math.max(120, Math.round(totalMs * 0.35)) + const extractionMs = Math.max(160, totalMs - intentMs) + return { + intentMs, + extractionMs + } +} + +function resolveToolCallDurationMs(toolCall, index, toolCalls, run) { + const explicitDuration = Number(toolCall?.duration_ms) + if (Number.isFinite(explicitDuration) && explicitDuration > 0) { + return explicitDuration + } + + const startedAt = parseFlowTimestamp(toolCall?.created_at) + if (!startedAt) { + return null + } + + const nextStartedAt = parseFlowTimestamp(toolCalls[index + 1]?.created_at) + const runFinishedAt = parseFlowTimestamp(run?.finished_at) + const finishedAt = nextStartedAt > startedAt ? nextStartedAt : (runFinishedAt > startedAt ? runFinishedAt : 0) + + if (!finishedAt || finishedAt <= startedAt) { + return null + } + + return finishedAt - startedAt +} + +function resolveResultStepDurationMs(run) { + const runFinishedAt = parseFlowTimestamp(run?.finished_at) + if (!runFinishedAt) { + return null + } + + const toolCalls = Array.isArray(run?.tool_calls) ? run.tool_calls : [] + const semanticFinishedAt = ( + toolCalls + .map((item, index) => { + const startedAt = parseFlowTimestamp(item?.created_at) + const durationMs = resolveToolCallDurationMs(item, index, toolCalls, run) + if (!startedAt || !durationMs) { + return 0 + } + return startedAt + durationMs + }) + .filter((value) => value > 0) + .sort((left, right) => right - left)[0] + ) || parseFlowTimestamp(run?.started_at) + + if (!semanticFinishedAt || runFinishedAt <= semanticFinishedAt) { + return null + } + + return runFinishedAt - semanticFinishedAt +} + function sanitizeRequest(request) { if (!request || typeof request !== 'object') return null @@ -994,6 +1333,13 @@ function formatDraftApplyTime(date = new Date()) { return `${year}-${month}-${day} ${hours}:${minutes}` } +function formatDateInputValue(date = new Date()) { + const year = date.getFullYear() + const month = String(date.getMonth() + 1).padStart(2, '0') + const day = String(date.getDate()).padStart(2, '0') + return `${year}-${month}-${day}` +} + function buildDraftSavedPayload({ draftPayload, reviewPayload, @@ -2173,9 +2519,15 @@ export default { const fileInputMode = ref('composer') const messageListRef = ref(null) const composerDraft = ref('') + const composerDatePickerOpen = ref(false) + const composerDateMode = ref('single') + const composerSingleDate = ref(formatDateInputValue()) + const composerRangeStartDate = ref(formatDateInputValue()) + const composerRangeEndDate = ref(formatDateInputValue()) const attachedFiles = ref([]) const composerFilesExpanded = ref(false) const submitting = ref(false) + const workbenchVisible = ref(false) const linkedRequest = computed(() => sanitizeRequest(props.requestContext)) const initialSessionType = resolveInitialSessionType(props.initialConversation) const initialSessionState = props.initialConversation @@ -2222,10 +2574,61 @@ export default { url: '' }) const sessionSwitchBusy = ref(false) + const flowPanelOpen = ref(false) + const flowRunId = ref('') + const flowStartedAt = ref(0) + const flowFinishedAt = ref(0) + const flowSteps = ref(createFlowSteps()) + const flowRefreshBusy = ref(false) + const flowTick = ref(Date.now()) + let flowTickTimer = 0 + const flowSimulationTimers = [] const canSubmit = computed( () => !submitting.value && !sessionSwitchBusy.value && Boolean(composerDraft.value.trim() || attachedFiles.value.length) ) + const composerCanApplyDateSelection = computed(() => { + if (composerDateMode.value === 'single') { + return Boolean(composerSingleDate.value) + } + return Boolean( + composerRangeStartDate.value + && composerRangeEndDate.value + && composerRangeStartDate.value <= composerRangeEndDate.value + ) + }) const isKnowledgeSession = computed(() => activeSessionType.value === SESSION_TYPE_KNOWLEDGE) + const completedFlowStepCount = computed( + () => flowSteps.value.filter((step) => step.status === FLOW_STEP_STATUS_COMPLETED).length + ) + const runningFlowStep = computed( + () => flowSteps.value.find((step) => step.status === FLOW_STEP_STATUS_RUNNING) || null + ) + const flowOverallStatusTone = computed(() => { + if (flowSteps.value.some((step) => step.status === FLOW_STEP_STATUS_FAILED)) { + return 'failed' + } + if (runningFlowStep.value) { + return 'running' + } + if (flowSteps.value.length && completedFlowStepCount.value === flowSteps.value.length && flowStartedAt.value) { + return 'completed' + } + return 'pending' + }) + const flowOverallStatusText = computed(() => { + const total = flowSteps.value.length + const completed = completedFlowStepCount.value + if (flowOverallStatusTone.value === 'failed') { + return `异常 ${completed}/${total}` + } + if (flowOverallStatusTone.value === 'completed') { + return `已完成 ${total}/${total}` + } + if (flowOverallStatusTone.value === 'running') { + return `执行中 ${completed}/${total}` + } + return total ? `待执行 0/${total}` : '暂无流程' + }) const hasInsightPanelContent = computed( () => isKnowledgeSession.value || currentInsight.value.intent !== 'welcome' ) @@ -2578,6 +2981,12 @@ export default { ) onMounted(() => { + flowTickTimer = window.setInterval(() => { + flowTick.value = Date.now() + }, 250) + nextTick(() => { + workbenchVisible.value = true + }) void clearKnowledgeSessionOnEntry() currentInsight.value = currentInsight.value || buildWelcomeInsight(props.entrySource, linkedRequest.value, activeSessionType.value) if (props.initialPrompt?.trim() || props.initialFiles.length) { @@ -2598,6 +3007,10 @@ export default { }) onBeforeUnmount(() => { + if (flowTickTimer) { + window.clearInterval(flowTickTimer) + } + clearFlowSimulationTimers() for (const url of previewRegistry) { URL.revokeObjectURL(url) } @@ -2612,6 +3025,12 @@ export default { const emptyState = buildEmptySessionState(activeSessionType.value) sessionSnapshots.value[activeSessionType.value] = emptyState applySessionState(emptyState) + clearFlowSimulationTimers() + flowRunId.value = '' + flowStartedAt.value = 0 + flowFinishedAt.value = 0 + flowSteps.value = createFlowSteps() + flowPanelOpen.value = false } function adjustComposerTextareaHeight() { @@ -2633,6 +3052,346 @@ export default { adjustComposerTextareaHeight() } + function handleComposerEnter(event) { + if (event?.isComposing || submitting.value || reviewActionBusy.value || sessionSwitchBusy.value) { + return + } + submitComposer() + } + + function toggleFlowPanel() { + flowPanelOpen.value = !flowPanelOpen.value + } + + function openFlowPanel() { + flowPanelOpen.value = true + } + + function clearFlowSimulationTimers() { + while (flowSimulationTimers.length) { + const timerId = flowSimulationTimers.pop() + window.clearTimeout(timerId) + window.clearInterval(timerId) + } + } + + function scheduleFlowPanelAutoCollapse(delayMs = 1200) { + const collapseTimer = window.setTimeout(() => { + if (runningFlowStep.value || flowRefreshBusy.value || submitting.value) { + return + } + if (flowSteps.value.length && !flowSteps.value.some((step) => step.status === FLOW_STEP_STATUS_FAILED)) { + flowPanelOpen.value = false + } + }, delayMs) + flowSimulationTimers.push(collapseTimer) + } + + function resetFlowRun() { + clearFlowSimulationTimers() + flowPanelOpen.value = true + flowRunId.value = '' + flowStartedAt.value = Date.now() + flowFinishedAt.value = 0 + flowSteps.value = createFlowSteps() + } + + function findFlowDefinition(key) { + return FLOW_STEP_FALLBACKS[key] || null + } + + function normalizeFlowStepPatch(key, patch = {}) { + const definition = findFlowDefinition(key) || {} + const normalizedPatch = typeof patch === 'string' ? { detail: patch } : { ...patch } + return { + title: normalizedPatch.title || definition.title || '智能体工具调用', + tool: normalizedPatch.tool || definition.tool || 'AgentTool', + detail: normalizedPatch.detail || definition.runningText || '', + ...normalizedPatch + } + } + + function createFlowStep(key, patch = {}) { + const normalizedPatch = normalizeFlowStepPatch(key, patch) + return { + key, + index: flowSteps.value.length + 1, + title: normalizedPatch.title, + tool: normalizedPatch.tool, + status: normalizedPatch.status || FLOW_STEP_STATUS_PENDING, + detail: normalizedPatch.detail || '', + durationMs: normalizedPatch.durationMs ?? null, + startedAt: normalizedPatch.startedAt || 0, + finishedAt: normalizedPatch.finishedAt || 0, + error: normalizedPatch.error || '' + } + } + + function upsertFlowStep(key, patch) { + const existingStep = flowSteps.value.find((step) => step.key === key) + if (!existingStep) { + flowSteps.value = [...flowSteps.value, createFlowStep(key, patch)] + return + } + const normalizedPatch = normalizeFlowStepPatch(key, patch) + flowSteps.value = flowSteps.value.map((step) => ( + step.key === key ? { ...step, ...normalizedPatch } : step + )) + } + + function startFlowStep(key, patch = {}) { + const normalizedPatch = normalizeFlowStepPatch(key, patch) + upsertFlowStep(key, { + ...normalizedPatch, + status: FLOW_STEP_STATUS_RUNNING, + detail: normalizedPatch.detail, + startedAt: Date.now(), + finishedAt: 0, + durationMs: null, + error: '' + }) + } + + function completeFlowStep(key, detail = '', durationMs = null, patch = {}) { + const now = Date.now() + const definition = findFlowDefinition(key) + const currentStep = flowSteps.value.find((step) => step.key === key) + const startedAt = currentStep?.startedAt || now + upsertFlowStep(key, { + ...patch, + status: FLOW_STEP_STATUS_COMPLETED, + detail: detail || definition?.completedText || '', + startedAt, + finishedAt: now, + durationMs: Number.isFinite(Number(durationMs)) ? Number(durationMs) : now - startedAt, + error: '' + }) + } + + function failFlowStep(key, detail = '', error = '', patch = {}) { + const now = Date.now() + const definition = findFlowDefinition(key) + const currentStep = flowSteps.value.find((step) => step.key === key) + const startedAt = currentStep?.startedAt || now + upsertFlowStep(key, { + ...patch, + status: FLOW_STEP_STATUS_FAILED, + detail: detail || error || '调用失败', + startedAt, + finishedAt: now, + durationMs: now - startedAt, + error: String(error || definition?.title || '').trim() + }) + flowFinishedAt.value = now + } + + function completePendingFlowStep(key, detail = '', durationMs = null, patch = {}) { + const currentStep = flowSteps.value.find((step) => step.key === key) + if (currentStep?.status === FLOW_STEP_STATUS_COMPLETED) { + return + } + const normalizedDuration = Number(durationMs) + const hasMeasuredDuration = Number.isFinite(normalizedDuration) && normalizedDuration > 0 + if (!currentStep || currentStep.status === FLOW_STEP_STATUS_PENDING) { + if (!hasMeasuredDuration && !currentStep?.startedAt) { + upsertFlowStep(key, { + ...patch, + status: FLOW_STEP_STATUS_COMPLETED, + detail: detail || findFlowDefinition(key)?.completedText || '', + startedAt: 0, + finishedAt: 0, + durationMs: null, + error: '' + }) + return + } + startFlowStep(key, patch) + } + completeFlowStep(key, detail, hasMeasuredDuration ? normalizedDuration : null, patch) + } + + function failCurrentFlowStep(error) { + clearFlowSimulationTimers() + const currentStep = runningFlowStep.value || flowSteps.value.find((step) => step.status === FLOW_STEP_STATUS_PENDING) + failFlowStep(currentStep?.key || 'result', error?.message || '智能体调用失败', error?.message || '') + } + + function startSemanticFlowPreview(rawText, options = {}) { + clearFlowSimulationTimers() + const intentPreview = buildLocalIntentPreview(rawText, activeSessionType.value) + const extractionMessages = buildLocalExtractionProgressMessages(rawText, options) + + const completeIntentTimer = window.setTimeout(() => { + const currentStep = flowSteps.value.find((step) => step.key === 'intent') + if (currentStep?.status === FLOW_STEP_STATUS_COMPLETED || currentStep?.status === FLOW_STEP_STATUS_FAILED) { + return + } + completePendingFlowStep('intent', intentPreview, null) + }, 260) + flowSimulationTimers.push(completeIntentTimer) + + const startExtractionTimer = window.setTimeout(() => { + const currentStep = flowSteps.value.find((step) => step.key === 'extraction') + if (currentStep?.status === FLOW_STEP_STATUS_COMPLETED || currentStep?.status === FLOW_STEP_STATUS_FAILED) { + return + } + startFlowStep('extraction', extractionMessages[0] || FLOW_STEP_FALLBACKS.extraction.runningText) + + if (extractionMessages.length <= 1) { + return + } + + let index = 1 + const detailTimer = window.setInterval(() => { + const runningStep = flowSteps.value.find((step) => step.key === 'extraction') + if (!runningStep || runningStep.status !== FLOW_STEP_STATUS_RUNNING) { + window.clearInterval(detailTimer) + return + } + upsertFlowStep('extraction', { + detail: extractionMessages[index] || extractionMessages[extractionMessages.length - 1] + }) + index = Math.min(index + 1, extractionMessages.length - 1) + }, 650) + flowSimulationTimers.push(detailTimer) + }, 420) + flowSimulationTimers.push(startExtractionTimer) + } + + function resolveToolCallFlowMeta(toolCall, index) { + const toolType = String(toolCall?.tool_type || '').toLowerCase() + const toolName = String(toolCall?.tool_name || '').toLowerCase() + const key = `tool-${toolCall?.id || `${index}-${toolType}-${toolName}`}` + if (toolType.includes('rule')) { + return { key, title: '规则引擎校验', tool: toolCall?.tool_name || 'RuleEngine' } + } + if (toolType.includes('mcp')) { + return { key, title: toolName.includes('standard') ? '差旅补助标准查询' : 'MCP 服务调用', tool: toolCall?.tool_name || 'MCPService' } + } + if (toolName.includes('knowledge')) { + return { key, title: '知识库检索', tool: toolCall?.tool_name || 'KnowledgeSearch' } + } + if (toolName.includes('expense_claim') || toolName.includes('save_or_submit')) { + return { key, title: '报销草稿处理', tool: toolCall?.tool_name || 'ExpenseClaimService' } + } + if (toolType.includes('database')) { + return { key, title: '数据查询/字段处理', tool: toolCall?.tool_name || 'DatabaseTool' } + } + if (toolType.includes('llm') || toolName.includes('user_agent')) { + return { key, title: '智能体生成', tool: toolCall?.tool_name || 'UserAgent' } + } + return { key, title: '智能体工具调用', tool: toolCall?.tool_name || toolCall?.tool_type || 'AgentTool' } + } + + function summarizeFlowToolCall(toolCall) { + const response = toolCall?.response_json && typeof toolCall.response_json === 'object' + ? toolCall.response_json + : {} + return ( + String(response.message || response.summary || response.result_summary || '').trim() + || String(toolCall?.tool_name || '').trim() + || '工具调用完成' + ) + } + + function mergeFlowRunDetail(run) { + const toolCalls = Array.isArray(run?.tool_calls) ? run.tool_calls : [] + if (run?.semantic_parse && flowSteps.value.some((step) => step.key === 'intent')) { + clearFlowSimulationTimers() + const semanticDurations = resolveSemanticPhaseDurations(run) + completePendingFlowStep( + 'intent', + summarizeSemanticIntentDetail(run.semantic_parse), + semanticDurations.intentMs + ) + completePendingFlowStep( + 'extraction', + summarizeSemanticParseDetail(run.semantic_parse, run?.ontology_json || {}), + semanticDurations.extractionMs + ) + } + + toolCalls.forEach((toolCall, index) => { + const meta = resolveToolCallFlowMeta(toolCall, index) + const failed = String(toolCall?.status || '').toLowerCase() === 'failed' + if (failed) { + failFlowStep(meta.key, toolCall?.error_message || summarizeFlowToolCall(toolCall), toolCall?.error_message || '', meta) + } else { + const toolDurationMs = resolveToolCallDurationMs(toolCall, index, toolCalls, run) + completePendingFlowStep( + meta.key, + summarizeFlowToolCall(toolCall), + toolDurationMs, + meta + ) + } + }) + + if (String(run?.status || '').toLowerCase() === 'failed') { + failCurrentFlowStep({ message: run?.error_message || '智能体调用失败' }) + return + } + } + + function completeFlowResult(payload, run = null) { + const answer = String(payload?.result?.answer || payload?.result?.message || '').trim() + if (!answer && !payload?.result) { + return + } + startFlowStep('result', '正在返回处理结果...') + completeFlowStep('result', '结果已返回到对话区', resolveResultStepDurationMs(run)) + flowFinishedAt.value = Date.now() + scheduleFlowPanelAutoCollapse() + } + + async function refreshFlowRunDetail() { + if (!flowRunId.value || flowRefreshBusy.value) { + return null + } + flowRefreshBusy.value = true + try { + const run = await fetchAgentRunDetail(flowRunId.value) + mergeFlowRunDetail(run) + return run + } catch (error) { + console.warn('Failed to refresh agent run detail:', error) + return null + } finally { + flowRefreshBusy.value = false + } + } + + function formatFlowStepDuration(step) { + if (step?.status === FLOW_STEP_STATUS_RUNNING && step.startedAt) { + return formatFlowDuration(flowTick.value - step.startedAt) + } + return formatFlowDuration(step?.durationMs) + } + + function buildComposerDateSelectionText() { + if (composerDateMode.value === 'single') { + return `发生时间:${composerSingleDate.value}` + } + if (composerRangeStartDate.value === composerRangeEndDate.value) { + return `发生时间:${composerRangeStartDate.value}` + } + return `发生时间:${composerRangeStartDate.value} 至 ${composerRangeEndDate.value}` + } + + async function applyComposerDateSelection() { + if (!composerCanApplyDateSelection.value) { + return + } + + const dateText = buildComposerDateSelectionText() + const currentDraft = composerDraft.value.trim() + composerDraft.value = currentDraft ? `${currentDraft},${dateText}` : dateText + composerDatePickerOpen.value = false + await nextTick() + adjustComposerTextareaHeight() + composerTextareaRef.value?.focus() + } + function rememberFilePreviews(filePreviews) { reviewFilePreviews.value = mergeFilePreviews(reviewFilePreviews.value, filePreviews) } @@ -3102,6 +3861,10 @@ export default { } function requestCloseWorkbench() { + workbenchVisible.value = false + } + + function emitCloseAfterLeave() { emit('close') } @@ -3285,6 +4048,12 @@ export default { return null } + resetFlowRun() + if (rawText) { + startFlowStep('intent', '正在识别业务意图...') + startSemanticFlowPreview(rawText, { attachmentCount: files.length }) + } + const fileNames = files.map((file) => file.name) const filePreviews = buildFilePreviews(files, previewRegistry) rememberFilePreviews(filePreviews) @@ -3334,14 +4103,17 @@ export default { let ocrFilePreviews = [] if (files.length) { + startFlowStep('ocr', `正在识别 ${files.length} 份附件...`) try { ocrPayload = await recognizeOcrFiles(files) ocrSummary = buildOcrSummary(ocrPayload) ocrDocuments = normalizeOcrDocuments(ocrPayload) ocrFilePreviews = buildOcrFilePreviews(ocrPayload) rememberFilePreviews(ocrFilePreviews) + completeFlowStep('ocr', `识别到 ${ocrDocuments.length || files.length} 张票据`) } catch (error) { console.warn('OCR request failed:', error) + completeFlowStep('ocr', 'OCR识别失败,已继续使用附件名称') } } @@ -3396,6 +4168,11 @@ export default { : {} ) responsePayload = payload + flowRunId.value = String(payload?.run_id || '').trim() + let flowRunDetail = null + if (flowRunId.value) { + flowRunDetail = await refreshFlowRunDetail() + } conversationId.value = String(payload?.conversation_id || '').trim() || conversationId.value draftClaimId.value = @@ -3432,7 +4209,10 @@ export default { effectiveFileNames, mergeFilePreviews(filePreviews, ocrFilePreviews) ) + completeFlowResult(payload, flowRunDetail) } catch (error) { + clearFlowSimulationTimers() + failCurrentFlowStep(error) replaceMessage( pendingMessage.id, createMessage( @@ -3704,6 +4484,18 @@ export default { composerTextareaRef, messageListRef, composerDraft, + composerDatePickerOpen, + composerDateMode, + composerSingleDate, + composerRangeStartDate, + composerRangeEndDate, + composerCanApplyDateSelection, + flowPanelOpen, + flowSteps, + flowRunId, + flowRefreshBusy, + flowOverallStatusTone, + flowOverallStatusText, attachedFiles, composerFilesExpanded, visibleAttachedFiles, @@ -3755,6 +4547,7 @@ export default { REVIEW_SCENE_OTHER_OPTION, REVIEW_SCENE_OPTIONS, REVIEW_OTHER_CATEGORY_OPTIONS, + workbenchVisible, reviewPanelConfidence, reviewRiskScore, reviewRiskSummary, @@ -3806,12 +4599,18 @@ export default { getExpenseQueryVisibleRecords, resolveDocumentPreview, triggerFileUpload, + applyComposerDateSelection, handleFilesChange, handleComposerInput, + handleComposerEnter, runShortcut, askHotKnowledgeQuestion, resolveKnowledgeRankLabel, resolveKnowledgeRankTone, + toggleFlowPanel, + openFlowPanel, + refreshFlowRunDetail, + formatFlowStepDuration, toggleInsightPanel, toggleReviewDocumentDrawer, toggleReviewRiskDrawer, @@ -3819,6 +4618,7 @@ export default { removeAttachedFile, clearAttachedFiles, requestCloseWorkbench, + emitCloseAfterLeave, openExpenseQueryRecord, setExpenseQueryPage, shiftExpenseQueryPage,