refactor: 重构 AuditView 和 TravelReimbursementCreateView 相关代码

- 优化 agent_assets、agent_foundation、user_agent 服务层结构
- 更新 AuditView 视图和脚本
- 更新 TravelReimbursementCreateView 脚本

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
caoxiaozhu
2026-05-19 20:23:58 +08:00
parent dc007f948a
commit 9472813739
7 changed files with 4287 additions and 2204 deletions

View File

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

View File

@@ -34,6 +34,7 @@ from app.models.financial_record import (
ExpenseClaim,
ExpenseClaimItem,
)
from app.services.agent_asset_rule_library import AgentAssetRuleLibraryManager
from app.services.agent_asset_spreadsheet import (
AgentAssetSpreadsheetManager,
COMPANY_COMMUNICATION_EXPENSE_RULE_CODE,
@@ -41,8 +42,12 @@ from app.services.agent_asset_spreadsheet import (
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,
@@ -282,6 +287,7 @@ class AgentFoundationService:
"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,
@@ -430,6 +436,7 @@ class AgentFoundationService:
attachment_rule,
scene_submission_rule,
travel_policy_rule,
*platform_risk_assets,
company_travel_rule,
company_communication_rule,
skill_expense_asset,
@@ -503,6 +510,17 @@ class AgentFoundationService:
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,
@@ -1243,6 +1261,8 @@ class AgentFoundationService:
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,
@@ -1555,19 +1575,6 @@ class AgentFoundationService:
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",
@@ -1581,17 +1588,8 @@ class AgentFoundationService:
"storage_key": live_document.storage_key,
},
}
return metadata
return live_document
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",
@@ -1605,7 +1603,7 @@ class AgentFoundationService:
"storage_key": live_document.storage_key,
},
}
return metadata
return live_document
def _ensure_company_communication_rule_spreadsheet_seed(
self,
@@ -1674,19 +1672,6 @@ class AgentFoundationService:
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",
@@ -1700,17 +1685,8 @@ class AgentFoundationService:
"storage_key": live_document.storage_key,
},
}
return metadata
return live_document
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",
@@ -1724,7 +1700,7 @@ class AgentFoundationService:
"storage_key": live_document.storage_key,
},
}
return metadata
return live_document
@staticmethod
def _read_or_build_finance_rule_file(
@@ -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

View File

@@ -7,7 +7,7 @@ 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": "应收账款已出现逾期,存在回款延迟风险。",
@@ -174,7 +177,9 @@ 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})?)"
@@ -221,6 +226,19 @@ SYSTEM_GENERATED_REASON_PREFIXES = (
"查看报销草稿",
"请解释一下当前这笔报销的合规风险和待补充项",
)
AMOUNT_UNIT_ALIASES = {
"": "",
"": "",
"": "",
"": "",
"块钱": "",
"元整": "",
"万员": "万元",
"万圆": "万元",
"万园": "万元",
"万块": "万元",
"万元整": "万元",
}
class UserAgentService:
@@ -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 (
@@ -2901,7 +2953,8 @@ class UserAgentService:
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
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(
@@ -3309,6 +3362,8 @@ class UserAgentService:
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

View File

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

View File

@@ -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
}"
>
<div class="detail-scroll">
<section v-if="!selectedSkill.usesSpreadsheetRule" class="detail-hero panel">
<section
v-if="!selectedSkill.usesSpreadsheetRule && !selectedSkill.usesJsonRiskRule"
class="detail-hero panel"
>
<div class="hero-title">
<div class="skill-badge" :class="selectedSkill.badgeTone">{{ selectedSkill.typeLabel }}</div>
<h2>{{ selectedSkill.name }}</h2>
@@ -192,6 +198,126 @@
</div>
</section>
<section
v-else-if="selectedSkill.usesJsonRiskRule"
class="json-risk-editor-shell panel"
>
<header class="json-risk-editor-head">
<div class="json-risk-editor-title">
<div class="skill-badge" :class="selectedSkill.badgeTone">{{ selectedSkill.typeLabel }}</div>
<div>
<h2>{{ selectedSkill.name }}</h2>
<p class="json-risk-head-subtitle">
{{ selectedSkill.riskRuleSubtitle || '平台通用风险规则' }}
</p>
<p v-if="selectedSkill.riskCategory" class="json-risk-head-category">
适用场景{{ selectedSkill.riskCategory }}
</p>
</div>
</div>
<div class="json-risk-editor-actions">
<span class="json-risk-mode-pill">JSON 风险规则</span>
</div>
</header>
<div class="json-risk-editor-body">
<section class="json-risk-main-stage">
<article class="detail-card panel json-risk-summary-card">
<div class="card-head">
<div>
<h3>规则摘要</h3>
<p>检查器与字段关系为只读说明实际判断逻辑由平台代码实现</p>
</div>
</div>
<div v-if="selectedSkill.riskRuleSummary" class="json-risk-summary-grid">
<span><strong>适用场景</strong>{{ selectedSkill.riskCategory || '-' }}</span>
<span><strong>检查器</strong>{{ selectedSkill.riskRuleSummary.evaluator || '-' }}</span>
<span><strong>本体信号</strong>{{ selectedSkill.riskRuleSummary.ontologySignal || '-' }}</span>
<span>
<strong>申报字段</strong>
{{ selectedSkill.riskRuleSummary.inputs?.declared || 'claim.location' }}
</span>
<span>
<strong>证据字段</strong>
{{ (selectedSkill.riskRuleSummary.inputs?.evidence || []).join('、') || '-' }}
</span>
</div>
</article>
<article class="detail-card panel json-risk-flow-card">
<div class="card-head">
<div>
<h3>字段关系</h3>
<p>提交报销时从表单与 OCR 组装验审上下文再执行一致性检查</p>
</div>
</div>
<div class="json-risk-flow-diagram">
<div class="json-risk-flow-column">
<span class="json-risk-flow-label">输入</span>
<code>claim.location</code>
<code>attachment.cities[]</code>
<code>item.item_location</code>
</div>
<div class="json-risk-flow-arrow"></div>
<div class="json-risk-flow-column center">
<span class="json-risk-flow-label">检查</span>
<strong>{{ selectedSkill.riskRuleSummary?.evaluator || 'location_consistency' }}</strong>
</div>
<div class="json-risk-flow-arrow"></div>
<div class="json-risk-flow-column">
<span class="json-risk-flow-label">输出</span>
<code>risk_flags_json</code>
<code>severity / message</code>
</div>
</div>
</article>
<article
v-if="selectedSkill.riskRuleDescription"
class="detail-card panel json-risk-description-card"
>
<div class="card-head">
<div>
<h3>规则说明</h3>
<p>本条风险规则的业务背景识别逻辑与适用场景来自 JSON 契约 description 字段</p>
</div>
</div>
<p class="json-risk-description-text">{{ selectedSkill.riskRuleDescription }}</p>
<p
v-if="selectedSkill.riskRuleSourceRef"
class="json-risk-description-source"
>
来源{{ selectedSkill.riskRuleSourceRef }}
</p>
</article>
<article class="detail-card panel json-editor-card">
<div class="card-head">
<div>
<h3>规则 JSON 契约</h3>
<p>保存后写入 server/rules/risk-rules/提交验审与 Agent 风险问答共用同一检查器</p>
</div>
</div>
<label class="field">
<span>{{ selectedSkill.ruleDocument?.file_name || `${selectedSkill.code}.json` }}</span>
<textarea
v-model="selectedSkill.riskRuleJsonText"
class="json-editor json-risk-editor"
:class="{ disabled: !canEditMarkdown }"
spellcheck="false"
:readonly="!canEditMarkdown || detailBusy"
></textarea>
</label>
<div class="editor-foot">
<span>请勿在 JSON 中配置公司差标evaluator 变更需同步发布服务端检查器</span>
<span>平台内置规则一般不频繁变更直接维护 JSON 契约即可</span>
</div>
</article>
</section>
</div>
</section>
<div
v-else
class="detail-grid"
@@ -531,7 +657,17 @@
<span>{{ actionState === 'upload-spreadsheet' ? '导入中...' : '上传表格' }}</span>
</button>
<button
v-else
v-else-if="selectedSkillUsesJsonRisk"
class="minor-action"
type="button"
:disabled="!canEditMarkdown || detailBusy"
@click="saveRiskRuleJson"
>
<i class="mdi mdi-content-save-outline"></i>
<span>{{ actionState === 'save-risk-json' ? '保存中...' : '保存 JSON' }}</span>
</button>
<button
v-else-if="!selectedSkill.usesSpreadsheetRule"
class="minor-action"
type="button"
:disabled="!canEditMarkdown || detailBusy"
@@ -648,7 +784,55 @@
</div>
</div>
<div class="picker-filter" :class="{ open: activeFilterPopover === 'status' }">
<div
v-if="showRiskScenarioFilter"
class="picker-filter"
:class="{ open: activeFilterPopover === 'riskScenario' }"
>
<button
class="picker-trigger"
type="button"
:aria-expanded="activeFilterPopover === 'riskScenario'"
aria-haspopup="dialog"
@click="toggleFilterPopover('riskScenario')"
>
<span class="picker-label">{{ selectedRiskScenarioLabel }}</span>
<i class="mdi mdi-chevron-down"></i>
</button>
<div
v-if="activeFilterPopover === 'riskScenario'"
class="picker-popover"
role="dialog"
aria-label="选择使用场景"
>
<header>
<strong>选择使用场景</strong>
<button type="button" aria-label="关闭使用场景选择" @click="closeFilterPopover">
<i class="mdi mdi-close"></i>
</button>
</header>
<div class="picker-option-list">
<button
v-for="option in riskScenarioOptions"
:key="option.value || 'all-risk-scenario'"
type="button"
class="picker-option"
:class="{ active: selectedRiskScenario === option.value }"
@click="selectFilter('riskScenario', option.value)"
>
{{ option.label }}
</button>
</div>
</div>
</div>
<div
v-else-if="showStatusFilter"
class="picker-filter"
:class="{ open: activeFilterPopover === 'status' }"
>
<button
class="picker-trigger"
type="button"
@@ -740,9 +924,9 @@
<th>{{ tableColumns.category }}</th>
<th>{{ tableColumns.owner }}</th>
<th>{{ tableColumns.scope }}</th>
<th>{{ tableColumns.runtime }}</th>
<th>{{ tableColumns.version }}</th>
<th>状态</th>
<th v-if="showRuntimeColumn">{{ tableColumns.runtime }}</th>
<th v-if="showVersionColumn">{{ tableColumns.version }}</th>
<th v-if="showStatusColumn">状态</th>
<th v-if="showMetricColumn">{{ tableColumns.metric }}</th>
<th>最近更新</th>
</tr>
@@ -759,16 +943,16 @@
<span class="skill-avatar" :class="skill.badgeTone">{{ skill.short }}</span>
<div>
<strong>{{ skill.name }}</strong>
<span>{{ skill.summary }}</span>
<span class="skill-list-subtitle">{{ skill.listSubtitle || skill.summary }}</span>
</div>
</div>
</td>
<td>{{ skill.category }}</td>
<td>{{ skill.owner }}</td>
<td><span class="scope-pill">{{ skill.scope }}</span></td>
<td>{{ skill.model }}</td>
<td>{{ skill.version }}</td>
<td><span class="status-pill" :class="skill.statusTone">{{ skill.status }}</span></td>
<td v-if="showRuntimeColumn">{{ skill.model }}</td>
<td v-if="showVersionColumn">{{ skill.versionDisplay || skill.version }}</td>
<td v-if="showStatusColumn"><span class="status-pill" :class="skill.statusTone">{{ skill.status }}</span></td>
<td v-if="showMetricColumn">{{ skill.hitRate }}</td>
<td>{{ skill.updatedAt }}</td>
</tr>

View File

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

View File

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