feat: 增强 agent_assets 功能,支持更多资产操作

This commit is contained in:
caoxiaozhu
2026-05-18 02:48:51 +00:00
parent 68f663f2f4
commit 55e0591a5e
6 changed files with 1654 additions and 148 deletions

View File

@@ -2,19 +2,32 @@ from __future__ import annotations
from typing import Annotated
from fastapi import APIRouter, Depends, Header, HTTPException, Query, status
from fastapi import APIRouter, Body, Depends, Header, HTTPException, Query, status
from fastapi.responses import FileResponse
from sqlalchemy.orm import Session
from app.api.deps import get_db
from app.api.deps import (
CurrentUserContext,
get_current_user,
get_db,
require_admin_user,
require_rule_editor_user,
require_rule_reviewer_user,
)
from app.schemas.agent_asset import (
AgentAssetCreate,
AgentAssetListItem,
AgentAssetOnlyOfficeCallbackRead,
AgentAssetOnlyOfficeCallbackWrite,
AgentAssetOnlyOfficeConfigRead,
AgentAssetRead,
AgentAssetReviewCreate,
AgentAssetReviewRead,
AgentAssetVersionCompareRead,
AgentAssetUpdate,
AgentAssetVersionCreate,
AgentAssetVersionRead,
AgentAssetVersionTimelineItemRead,
)
from app.schemas.common import ErrorResponse
from app.services.agent_assets import AgentAssetService
@@ -29,6 +42,10 @@ RequestIdHeader = Annotated[
str | None,
Header(description="外部请求 ID用于串联审计日志和上游调用链。"),
]
CurrentUser = Annotated[CurrentUserContext, Depends(get_current_user)]
AdminUser = Annotated[CurrentUserContext, Depends(require_admin_user)]
RuleEditorUser = Annotated[CurrentUserContext, Depends(require_rule_editor_user)]
RuleReviewerUser = Annotated[CurrentUserContext, Depends(require_rule_reviewer_user)]
def _handle_asset_error(exc: Exception) -> None:
@@ -93,6 +110,185 @@ def get_agent_asset(asset_id: str, db: DbSession) -> AgentAssetRead:
return asset
@router.get(
"/{asset_id}/spreadsheet/onlyoffice-config",
response_model=AgentAssetOnlyOfficeConfigRead,
summary="读取规则 Excel 的 ONLYOFFICE 配置",
description="为规则详情页中的 Excel 规则表生成 ONLYOFFICE 配置。",
)
def get_agent_asset_spreadsheet_onlyoffice_config(
asset_id: str,
current_user: CurrentUser,
db: DbSession,
version: Annotated[
str | None,
Query(description="可选的规则版本号;不传时默认当前版本。"),
] = None,
) -> AgentAssetOnlyOfficeConfigRead:
try:
return AgentAssetService(db).build_rule_spreadsheet_onlyoffice_config(
asset_id,
current_user,
version=version,
)
except Exception as exc:
_handle_asset_error(exc)
@router.get(
"/{asset_id}/spreadsheet/content",
response_class=FileResponse,
summary="下载或预览规则 Excel 文件",
description="按版本返回规则的 Excel 快照,用于浏览器预览或下载。",
)
def get_agent_asset_spreadsheet_content(
asset_id: str,
_: CurrentUser,
db: DbSession,
version: Annotated[
str | None,
Query(description="可选的规则版本号;不传时默认当前版本。"),
] = None,
) -> FileResponse:
try:
file_path, media_type, filename = AgentAssetService(db).get_rule_spreadsheet_content(
asset_id,
version=version,
)
except Exception as exc:
_handle_asset_error(exc)
return FileResponse(file_path, media_type=media_type, filename=filename)
@router.get(
"/{asset_id}/spreadsheet/onlyoffice/content",
response_class=FileResponse,
summary="供 ONLYOFFICE 读取规则 Excel 源文件",
description="使用短时令牌供 ONLYOFFICE 拉取规则表源文件。",
)
def get_agent_asset_spreadsheet_onlyoffice_content(
asset_id: str,
db: DbSession,
version: Annotated[
str,
Query(min_length=1, description="规则版本号。"),
],
access_token: Annotated[
str,
Query(min_length=1, description="ONLYOFFICE 临时访问令牌。"),
],
) -> FileResponse:
try:
service = AgentAssetService(db)
service.validate_rule_spreadsheet_access_token(asset_id, version, access_token)
file_path, media_type, filename = service.get_rule_spreadsheet_content(
asset_id,
version=version,
)
except FileNotFoundError as exc:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=str(exc)) from exc
except ValueError as exc:
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail=str(exc)) from exc
except Exception as exc:
_handle_asset_error(exc)
return FileResponse(file_path, media_type=media_type, filename=filename)
@router.post(
"/{asset_id}/spreadsheet/upload",
response_model=AgentAssetRead,
status_code=status.HTTP_201_CREATED,
summary="上传规则 Excel 文件",
description="为指定规则上传新的 Excel 快照,并自动生成新规则版本。",
)
def upload_agent_asset_spreadsheet(
asset_id: str,
content: Annotated[
bytes,
Body(
media_type="application/octet-stream",
description="待上传的 Excel 文件二进制内容。",
),
],
filename: Annotated[str, Query(min_length=1, description="原始文件名。")],
current_user: RuleEditorUser,
db: DbSession,
x_request_id: RequestIdHeader = None,
) -> AgentAssetRead:
try:
return AgentAssetService(db).upload_rule_spreadsheet(
asset_id,
filename=filename,
content=content,
actor=current_user.name,
request_id=x_request_id,
)
except Exception as exc:
_handle_asset_error(exc)
@router.post(
"/{asset_id}/spreadsheet/import-content",
response_model=AgentAssetRead,
status_code=status.HTTP_201_CREATED,
summary="导入规则 Excel 表格内容",
description="读取上传 Excel 中的工作表内容,写回当前规则表;保留当前规则文件名与规则身份。",
)
def import_agent_asset_spreadsheet_content(
asset_id: str,
content: Annotated[
bytes,
Body(
media_type="application/octet-stream",
description="待导入的 Excel 文件二进制内容。",
),
],
filename: Annotated[str, Query(min_length=1, description="上传文件原始文件名。")],
current_user: RuleEditorUser,
db: DbSession,
x_request_id: RequestIdHeader = None,
) -> AgentAssetRead:
try:
return AgentAssetService(db).import_rule_spreadsheet_content(
asset_id,
filename=filename,
content=content,
actor=current_user.name,
request_id=x_request_id,
)
except Exception as exc:
_handle_asset_error(exc)
@router.post(
"/{asset_id}/spreadsheet/onlyoffice/callback",
response_model=AgentAssetOnlyOfficeCallbackRead,
summary="接收规则 Excel 的 ONLYOFFICE 回调",
description="接收 ONLYOFFICE 回写内容,并自动生成新的规则版本。",
)
def handle_agent_asset_spreadsheet_onlyoffice_callback(
asset_id: str,
payload: AgentAssetOnlyOfficeCallbackWrite,
db: DbSession,
version: Annotated[
str,
Query(min_length=1, description="打开编辑器时对应的规则版本号。"),
],
) -> AgentAssetOnlyOfficeCallbackRead:
try:
AgentAssetService(db).handle_rule_spreadsheet_onlyoffice_callback(
asset_id,
version=version,
payload=payload.model_dump(),
)
except Exception as exc:
_handle_asset_error(exc)
return AgentAssetOnlyOfficeCallbackRead()
@router.post(
"",
response_model=AgentAssetRead,
@@ -237,11 +433,22 @@ def create_agent_asset_version(
def create_agent_asset_review(
asset_id: str,
payload: AgentAssetReviewCreate,
current_user: CurrentUser,
db: DbSession,
x_actor: ActorHeader = None,
x_request_id: RequestIdHeader = None,
) -> AgentAssetReviewRead:
try:
role_codes = {item.strip() for item in current_user.role_codes}
if payload.review_status.value == "pending":
if not (
current_user.is_admin
or "manager" in role_codes
or "finance" in role_codes
):
raise PermissionError("只有财务人员或高级管理人员可以提交审核。")
elif not (current_user.is_admin or "manager" in role_codes):
raise PermissionError("只有高级管理人员可以审核规则。")
return AgentAssetService(db).create_review(
asset_id,
payload,
@@ -270,6 +477,7 @@ def create_agent_asset_review(
)
def activate_agent_asset(
asset_id: str,
_: RuleReviewerUser,
db: DbSession,
x_actor: ActorHeader = None,
x_request_id: RequestIdHeader = None,
@@ -282,3 +490,68 @@ def activate_agent_asset(
)
except Exception as exc:
_handle_asset_error(exc)
@router.post(
"/{asset_id}/versions/{version}/restore",
response_model=AgentAssetRead,
summary="基于历史版本恢复工作稿",
description="复制指定历史版本内容生成新的工作版本,用于误上线后的快速恢复与重新审核。",
)
def restore_agent_asset_version(
asset_id: str,
version: str,
current_user: RuleReviewerUser,
db: DbSession,
x_actor: ActorHeader = None,
x_request_id: RequestIdHeader = None,
) -> AgentAssetRead:
try:
return AgentAssetService(db).restore_version_as_working_copy(
asset_id,
version,
actor=(x_actor or current_user.name or "system").strip() or "system",
request_id=x_request_id,
)
except Exception as exc:
_handle_asset_error(exc)
@router.get(
"/{asset_id}/version-timeline",
response_model=list[AgentAssetVersionTimelineItemRead],
summary="读取规则版本流转时间线",
description="返回规则版本创建、提交审核、审核结果和正式上线等流转事件。",
)
def get_agent_asset_version_timeline(
asset_id: str,
_: CurrentUser,
db: DbSession,
) -> list[AgentAssetVersionTimelineItemRead]:
try:
return AgentAssetService(db).list_version_timeline(asset_id)
except Exception as exc:
_handle_asset_error(exc)
@router.get(
"/{asset_id}/versions/compare",
response_model=AgentAssetVersionCompareRead,
summary="比较两个规则表版本",
description="对比两个 Excel 规则表版本的工作表变化与单元格级差异。",
)
def compare_agent_asset_spreadsheet_versions(
asset_id: str,
_: CurrentUser,
db: DbSession,
base_version: Annotated[str, Query(min_length=1, description="基准版本号")],
target_version: Annotated[str, Query(min_length=1, description="对比版本号")],
) -> AgentAssetVersionCompareRead:
try:
return AgentAssetService(db).compare_spreadsheet_versions(
asset_id,
base_version=base_version,
target_version=target_version,
)
except Exception as exc:
_handle_asset_error(exc)

View File

@@ -25,6 +25,8 @@ class AgentAsset(Base):
reviewer: Mapped[str | None] = mapped_column(String(100), nullable=True)
status: Mapped[str] = mapped_column(String(20), index=True, default="draft")
current_version: Mapped[str | None] = mapped_column(String(30), nullable=True)
published_version: Mapped[str | None] = mapped_column(String(30), nullable=True)
working_version: Mapped[str | None] = mapped_column(String(30), nullable=True)
config_json: Mapped[dict[str, Any]] = mapped_column(JSON, default=dict)
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
updated_at: Mapped[datetime] = mapped_column(

View File

@@ -36,6 +36,8 @@ class AgentAssetUpdate(BaseModel):
reviewer: str | None = Field(default=None, max_length=100)
status: AgentAssetStatus | None = None
current_version: str | None = Field(default=None, max_length=30)
published_version: str | None = Field(default=None, max_length=30)
working_version: str | None = Field(default=None, max_length=30)
config_json: dict[str, Any] | None = None
@@ -74,6 +76,58 @@ class AgentAssetReviewRead(BaseModel):
created_at: datetime
class AgentAssetOnlyOfficeConfigRead(BaseModel):
documentServerUrl: str
config: dict[str, Any] = Field(default_factory=dict)
class AgentAssetOnlyOfficeCallbackRead(BaseModel):
error: int = 0
class AgentAssetOnlyOfficeCallbackWrite(BaseModel):
model_config = ConfigDict(extra="allow")
status: int = Field(description="ONLYOFFICE 回调状态码。")
url: str | None = Field(default=None, description="文档下载地址,状态为 2 或 6 时使用。")
users: list[str] = Field(default_factory=list, description="当前编辑用户列表。")
class AgentAssetVersionTimelineItemRead(BaseModel):
event_type: str
version: str
actor: str
event_time: datetime
title: str
description: str = ""
note: str | None = None
source_version: str | None = None
class AgentAssetSpreadsheetDiffCellRead(BaseModel):
sheet_name: str
cell: str
change_type: str
before_value: Any | None = None
after_value: Any | None = None
class AgentAssetSpreadsheetDiffSheetRead(BaseModel):
sheet_name: str
change_type: str
class AgentAssetVersionCompareRead(BaseModel):
base_version: str
target_version: str
added_sheet_count: int = 0
removed_sheet_count: int = 0
changed_sheet_count: int = 0
changed_cell_count: int = 0
sheet_changes: list[AgentAssetSpreadsheetDiffSheetRead] = Field(default_factory=list)
cell_changes: list[AgentAssetSpreadsheetDiffCellRead] = Field(default_factory=list)
class AgentAssetVersionRead(BaseModel):
model_config = ConfigDict(from_attributes=True)
@@ -86,6 +140,9 @@ class AgentAssetVersionRead(BaseModel):
created_by: str
created_at: datetime
is_current: bool = False
is_published: bool = False
is_working: bool = False
lifecycle_state: str = "history"
class AgentAssetListItem(BaseModel):
@@ -102,6 +159,8 @@ class AgentAssetListItem(BaseModel):
reviewer: str | None
status: str
current_version: str | None
published_version: str | None
working_version: str | None
config_json: dict[str, Any]
created_at: datetime
updated_at: datetime

File diff suppressed because it is too large Load Diff

View File

@@ -1,10 +1,12 @@
from __future__ import annotations
from __future__ import annotations
import hashlib
import json
from datetime import UTC, date, datetime
from decimal import Decimal
from pathlib import Path
import json
from datetime import UTC, date, datetime
from decimal import Decimal
from sqlalchemy import select
from sqlalchemy import inspect, select, text
from sqlalchemy.orm import Session
from app.core.agent_enums import (
@@ -26,16 +28,23 @@ from app.db.session import get_session_factory
from app.models.agent_asset import AgentAsset, AgentAssetReview, AgentAssetVersion
from app.models.agent_run import AgentRun, AgentToolCall, SemanticParseLog
from app.models.audit_log import AuditLog
from app.models.financial_record import (
AccountsPayableRecord,
AccountsReceivableRecord,
ExpenseClaim,
ExpenseClaimItem,
)
from app.services.expense_rule_runtime import (
build_scene_submission_standard_markdown,
build_travel_risk_control_standard_markdown,
)
from app.models.financial_record import (
AccountsPayableRecord,
AccountsReceivableRecord,
ExpenseClaim,
ExpenseClaimItem,
)
from app.services.agent_asset_spreadsheet import (
AgentAssetSpreadsheetManager,
COMPANY_TRAVEL_EXPENSE_RULE_CODE,
COMPANY_TRAVEL_EXPENSE_RULE_FILENAME,
FINANCE_RULES_LIBRARY,
RuleSpreadsheetMeta,
)
from app.services.expense_rule_runtime import (
build_scene_submission_standard_markdown,
build_travel_risk_control_standard_markdown,
)
logger = get_logger("app.services.agent_foundation")
@@ -77,7 +86,8 @@ LEGACY_RULE_CODES = (
"rule.ap.payment_dual_review",
)
ATTACHMENT_RULE_ASSET_CODE = "rule.expense.attachment_submission_requirements"
ATTACHMENT_RULE_ASSET_CODE = "rule.expense.attachment_submission_requirements"
COMPANY_TRAVEL_RULE_VERSION = "v1.0.0"
ATTACHMENT_RULE_RUNTIME_CONFIG = {
"kind": "policy_rule_draft",
@@ -156,10 +166,11 @@ class AgentFoundationService:
def __init__(self, db: Session) -> None:
self.db = db
def ensure_foundation_ready(self) -> None:
try:
Base.metadata.create_all(bind=self.db.get_bind())
self._seed_agent_assets()
def ensure_foundation_ready(self) -> None:
try:
Base.metadata.create_all(bind=self.db.get_bind())
self._ensure_agent_asset_schema()
self._seed_agent_assets()
self._sync_demo_financial_records()
self._seed_runs_and_logs()
self.db.commit()
@@ -174,7 +185,7 @@ class AgentFoundationService:
return
self._purge_demo_financial_records()
def _seed_agent_assets(self) -> None:
def _seed_agent_assets(self) -> None:
existing_codes = set(self.db.scalars(select(AgentAsset.code)).all())
if existing_codes:
self._top_up_agent_assets(existing_codes)
@@ -189,8 +200,10 @@ class AgentFoundationService:
scenario_json=["expense", "risk_check", "attachment_policy", "invoice_anomaly"],
owner="财务制度管理组",
reviewer="高嘉禾",
status=AgentAssetStatus.REVIEW.value,
current_version="v1.0.0",
status=AgentAssetStatus.REVIEW.value,
current_version="v1.0.0",
published_version=None,
working_version="v1.0.0",
config_json={
"severity": "high",
"enabled": False,
@@ -209,8 +222,10 @@ class AgentFoundationService:
scenario_json=["expense", "risk_check", "scene_policy", "attachment_policy"],
owner="费用运营组",
reviewer="顾承宇",
status=AgentAssetStatus.ACTIVE.value,
current_version="v1.0.0",
status=AgentAssetStatus.ACTIVE.value,
current_version="v1.0.0",
published_version="v1.0.0",
working_version="v1.0.0",
config_json={
"severity": "high",
"enabled": True,
@@ -218,17 +233,19 @@ class AgentFoundationService:
"rule_template_label": "系统内置场景矩阵规则",
},
)
travel_policy_rule = AgentAsset(
asset_type=AgentAssetType.RULE.value,
code="rule.expense.travel_risk_control_standard",
name="差旅报销风险管控制度",
travel_policy_rule = AgentAsset(
asset_type=AgentAssetType.RULE.value,
code="rule.expense.travel_risk_control_standard",
name="差旅报销风险管控制度",
description="统一定义差旅报销的行程闭环、酒店地点一致性、职级差标和风险处置口径。",
domain=AgentAssetDomain.EXPENSE.value,
scenario_json=["expense", "risk_check", "travel_policy", "travel_standard"],
owner="风控与审计部",
reviewer="顾承宇",
status=AgentAssetStatus.ACTIVE.value,
current_version="v1.1.0",
status=AgentAssetStatus.ACTIVE.value,
current_version="v1.1.0",
published_version="v1.1.0",
working_version="v1.1.0",
config_json={
"severity": "high",
"enabled": True,
@@ -236,21 +253,45 @@ class AgentFoundationService:
"warning_on_medium_risk": True,
"source_doc": "document/development/risks/travel-risk-control-standard.md",
"runtime_kind": "travel_policy",
"rule_template_key": "travel_standard_v1",
"rule_template_label": "差旅标准模板",
},
)
skill_expense_asset = AgentAsset(
asset_type=AgentAssetType.SKILL.value,
code="skill.expense.summary_lookup",
"rule_template_key": "travel_standard_v1",
"rule_template_label": "差旅标准模板",
},
)
company_travel_rule = AgentAsset(
asset_type=AgentAssetType.RULE.value,
code=COMPANY_TRAVEL_EXPENSE_RULE_CODE,
name="公司差旅费报销规则",
description="通过 Excel 明细表维护差旅费报销标准、票据要求和审批口径。",
domain=AgentAssetDomain.EXPENSE.value,
scenario_json=["expense", "travel_policy", "travel_standard"],
owner="财务制度管理组",
reviewer="顾承宇",
status=AgentAssetStatus.ACTIVE.value,
current_version=COMPANY_TRAVEL_RULE_VERSION,
published_version=COMPANY_TRAVEL_RULE_VERSION,
working_version=COMPANY_TRAVEL_RULE_VERSION,
config_json={
"severity": "medium",
"enabled": True,
"tag": "财务规则",
"detail_mode": "spreadsheet",
"rule_library": FINANCE_RULES_LIBRARY,
"rule_template_label": "差旅报销 Excel 模板",
},
)
skill_expense_asset = AgentAsset(
asset_type=AgentAssetType.SKILL.value,
code="skill.expense.summary_lookup",
name="报销汇总查询技能",
description="根据时间、员工和部门汇总报销金额与单据数量。",
domain=AgentAssetDomain.EXPENSE.value,
scenario_json=["expense", "query", "summary"],
owner="平台研发组",
reviewer="陈硕",
status=AgentAssetStatus.ACTIVE.value,
current_version="v1.0.0",
status=AgentAssetStatus.ACTIVE.value,
current_version="v1.0.0",
published_version="v1.0.0",
working_version="v1.0.0",
config_json={"input_schema": ["time_range", "employee", "department"]},
)
skill_ar_asset = AgentAsset(
@@ -262,8 +303,10 @@ class AgentFoundationService:
scenario_json=["accounts_receivable", "query", "aging_summary"],
owner="平台研发组",
reviewer="陈硕",
status=AgentAssetStatus.ACTIVE.value,
current_version="v1.0.0",
status=AgentAssetStatus.ACTIVE.value,
current_version="v1.0.0",
published_version="v1.0.0",
working_version="v1.0.0",
config_json={"input_schema": ["customer", "aging_bucket", "status"]},
)
invoice_mcp_asset = AgentAsset(
@@ -275,8 +318,10 @@ class AgentFoundationService:
scenario_json=["expense", "invoice_validation"],
owner="平台研发组",
reviewer="周悦宁",
status=AgentAssetStatus.ACTIVE.value,
current_version="v1.0.0",
status=AgentAssetStatus.ACTIVE.value,
current_version="v1.0.0",
published_version="v1.0.0",
working_version="v1.0.0",
config_json={"endpoint": "mock://invoice/verify", "timeout_ms": 1200},
)
ledger_mcp_asset = AgentAsset(
@@ -288,8 +333,10 @@ class AgentFoundationService:
scenario_json=["expense", "accounts_receivable", "accounts_payable"],
owner="平台研发组",
reviewer="周悦宁",
status=AgentAssetStatus.ACTIVE.value,
current_version="v1.0.0",
status=AgentAssetStatus.ACTIVE.value,
current_version="v1.0.0",
published_version="v1.0.0",
working_version="v1.0.0",
config_json={"endpoint": "mock://ledger/snapshot", "timeout_ms": 1500},
)
task_asset = AgentAsset(
@@ -301,8 +348,10 @@ class AgentFoundationService:
scenario_json=["schedule", "risk_check"],
owner="风控与审计部",
reviewer="顾承宇",
status=AgentAssetStatus.ACTIVE.value,
current_version="v1.0.0",
status=AgentAssetStatus.ACTIVE.value,
current_version="v1.0.0",
published_version="v1.0.0",
working_version="v1.0.0",
config_json={"cron": "0 9 * * *", "agent": AgentName.HERMES.value},
)
ar_summary_task = AgentAsset(
@@ -314,8 +363,10 @@ class AgentFoundationService:
scenario_json=["schedule", "accounts_receivable", "summary"],
owner="风控与审计部",
reviewer="顾承宇",
status=AgentAssetStatus.ACTIVE.value,
current_version="v1.0.0",
status=AgentAssetStatus.ACTIVE.value,
current_version="v1.0.0",
published_version="v1.0.0",
working_version="v1.0.0",
config_json={"cron": "0 10 * * 1", "agent": AgentName.HERMES.value},
)
rule_digest_task = AgentAsset(
@@ -327,8 +378,10 @@ class AgentFoundationService:
scenario_json=["schedule", "rule_center", "review_digest"],
owner="风控与审计部",
reviewer="顾承宇",
status=AgentAssetStatus.ACTIVE.value,
current_version="v1.0.0",
status=AgentAssetStatus.ACTIVE.value,
current_version="v1.0.0",
published_version="v1.0.0",
working_version="v1.0.0",
config_json={"cron": "0 18 * * *", "agent": AgentName.HERMES.value},
)
knowledge_index_task = AgentAsset(
@@ -340,30 +393,39 @@ class AgentFoundationService:
scenario_json=["schedule", "knowledge", "rule_center"],
owner="财务制度管理组",
reviewer="顾承宇",
status=AgentAssetStatus.ACTIVE.value,
current_version="v1.0.0",
status=AgentAssetStatus.ACTIVE.value,
current_version="v1.0.0",
published_version="v1.0.0",
working_version="v1.0.0",
config_json={"cron": "0 0 * * *", "agent": AgentName.HERMES.value},
)
self.db.add_all(
[
attachment_rule,
scene_submission_rule,
travel_policy_rule,
skill_expense_asset,
skill_ar_asset,
invoice_mcp_asset,
attachment_rule,
scene_submission_rule,
travel_policy_rule,
company_travel_rule,
skill_expense_asset,
skill_ar_asset,
invoice_mcp_asset,
ledger_mcp_asset,
task_asset,
ar_summary_task,
rule_digest_task,
knowledge_index_task,
]
)
self.db.flush()
self.db.add_all(
[
)
self.db.flush()
company_travel_rule_meta = self._ensure_company_travel_rule_spreadsheet_seed(
company_travel_rule,
version=COMPANY_TRAVEL_RULE_VERSION,
actor_name="系统初始化",
)
self.db.add_all(
[
AgentAssetVersion(
asset=attachment_rule,
version="v0.9.0",
@@ -402,17 +464,29 @@ class AgentFoundationService:
change_note="首版差旅制度执行规则,覆盖行程闭环与基础差标校验。",
created_by="系统初始化",
),
AgentAssetVersion(
asset=travel_policy_rule,
version="v1.1.0",
content=self._travel_risk_control_standard_markdown(version="v1.1.0"),
content_type=AgentAssetContentType.MARKDOWN.value,
change_note="补充可执行规则块,供审核引擎直接消费差旅制度标准。",
created_by="系统初始化",
),
AgentAssetVersion(
asset=skill_expense_asset,
version="v1.0.0",
AgentAssetVersion(
asset=travel_policy_rule,
version="v1.1.0",
content=self._travel_risk_control_standard_markdown(version="v1.1.0"),
content_type=AgentAssetContentType.MARKDOWN.value,
change_note="补充可执行规则块,供审核引擎直接消费差旅制度标准。",
created_by="系统初始化",
),
AgentAssetVersion(
asset=company_travel_rule,
version=COMPANY_TRAVEL_RULE_VERSION,
content=AgentAssetSpreadsheetManager.build_version_markdown(
rule_name=company_travel_rule.name,
version=COMPANY_TRAVEL_RULE_VERSION,
metadata=company_travel_rule_meta,
),
content_type=AgentAssetContentType.MARKDOWN.value,
change_note="初始化差旅费报销 Excel 规则表。",
created_by="系统初始化",
),
AgentAssetVersion(
asset=skill_expense_asset,
version="v1.0.0",
content=self._json_content(
{
"inputs": ["time_range", "employee", "department"],
@@ -545,16 +619,24 @@ class AgentFoundationService:
review_note="可作为报销场景统一审核标准正式执行。",
reviewed_at=datetime.now(UTC),
),
AgentAssetReview(
asset=travel_policy_rule,
version="v1.1.0",
reviewer="顾承宇",
review_status=AgentReviewStatus.APPROVED.value,
review_note="制度口径已确认,并已补充可执行配置供审核引擎读取。",
reviewed_at=datetime.now(UTC),
),
]
)
AgentAssetReview(
asset=travel_policy_rule,
version="v1.1.0",
reviewer="顾承宇",
review_status=AgentReviewStatus.APPROVED.value,
review_note="制度口径已确认,并已补充可执行配置供审核引擎读取。",
reviewed_at=datetime.now(UTC),
),
AgentAssetReview(
asset=company_travel_rule,
version=COMPANY_TRAVEL_RULE_VERSION,
reviewer="顾承宇",
review_status=AgentReviewStatus.APPROVED.value,
review_note="首版 Excel 规则表已确认,可作为财务规则使用。",
reviewed_at=datetime.now(UTC),
),
]
)
def _seed_financial_records(self) -> None:
if self.db.scalar(select(ExpenseClaim.id).limit(1)) is not None:
@@ -913,9 +995,12 @@ class AgentFoundationService:
scene_submission_rule = self.db.scalar(
select(AgentAsset).where(AgentAsset.code == "rule.expense.scene_submission_standard")
)
travel_policy_rule = self.db.scalar(
select(AgentAsset).where(AgentAsset.code == "rule.expense.travel_risk_control_standard")
)
travel_policy_rule = self.db.scalar(
select(AgentAsset).where(AgentAsset.code == "rule.expense.travel_risk_control_standard")
)
company_travel_rule = self.db.scalar(
select(AgentAsset).where(AgentAsset.code == COMPANY_TRAVEL_EXPENSE_RULE_CODE)
)
if ATTACHMENT_RULE_ASSET_CODE not in existing_codes:
attachment_rule = self._create_seed_asset(
@@ -939,9 +1024,12 @@ class AgentFoundationService:
},
)
if attachment_rule is not None:
attachment_rule.current_version = "v1.0.0"
attachment_rule.status = AgentAssetStatus.REVIEW.value
if attachment_rule is not None:
if not str(attachment_rule.current_version or "").strip():
attachment_rule.current_version = "v1.0.0"
if not str(attachment_rule.working_version or "").strip():
attachment_rule.working_version = attachment_rule.current_version
attachment_rule.status = attachment_rule.status or AgentAssetStatus.REVIEW.value
attachment_rule.description = "统一定义报销提交时的附件数量、票据类型和补件处理口径,作为上线前待审核规则。"
attachment_rule.config_json = {
"severity": "high",
@@ -1002,9 +1090,14 @@ class AgentFoundationService:
},
)
if scene_submission_rule is not None:
scene_submission_rule.current_version = "v1.0.0"
scene_submission_rule.status = AgentAssetStatus.ACTIVE.value
if scene_submission_rule is not None:
if not str(scene_submission_rule.current_version or "").strip():
scene_submission_rule.current_version = "v1.0.0"
if not str(scene_submission_rule.working_version or "").strip():
scene_submission_rule.working_version = scene_submission_rule.current_version
if not str(scene_submission_rule.published_version or "").strip():
scene_submission_rule.published_version = scene_submission_rule.current_version
scene_submission_rule.status = scene_submission_rule.status or AgentAssetStatus.ACTIVE.value
scene_submission_rule.description = "统一定义各报销场景的必填字段、附件类型要求和金额阈值。"
scene_submission_rule.config_json = {
"severity": "high",
@@ -1053,9 +1146,14 @@ class AgentFoundationService:
},
)
if travel_policy_rule is not None:
travel_policy_rule.current_version = "v1.1.0"
travel_policy_rule.status = AgentAssetStatus.ACTIVE.value
if travel_policy_rule is not None:
if not str(travel_policy_rule.current_version or "").strip():
travel_policy_rule.current_version = "v1.1.0"
if not str(travel_policy_rule.working_version or "").strip():
travel_policy_rule.working_version = travel_policy_rule.current_version
if not str(travel_policy_rule.published_version or "").strip():
travel_policy_rule.published_version = travel_policy_rule.current_version
travel_policy_rule.status = travel_policy_rule.status or AgentAssetStatus.ACTIVE.value
travel_policy_rule.config_json = {
"severity": "high",
"enabled": True,
@@ -1087,12 +1185,79 @@ class AgentFoundationService:
version="v1.1.0",
reviewer="顾承宇",
review_status=AgentReviewStatus.APPROVED.value,
review_note="制度口径已确认,并已补充可执行配置供审核引擎读取。",
reviewed_at=datetime.now(UTC),
)
if "skill.ar.aging_summary" not in existing_codes:
asset = self._create_seed_asset(
review_note="制度口径已确认,并已补充可执行配置供审核引擎读取。",
reviewed_at=datetime.now(UTC),
)
if COMPANY_TRAVEL_EXPENSE_RULE_CODE not in existing_codes:
company_travel_rule = self._create_seed_asset(
asset_type=AgentAssetType.RULE.value,
code=COMPANY_TRAVEL_EXPENSE_RULE_CODE,
name="公司差旅费报销规则",
description="通过 Excel 明细表维护差旅费报销标准、票据要求和审批口径。",
domain=AgentAssetDomain.EXPENSE.value,
scenario_json=["expense", "travel_policy", "travel_standard"],
owner="财务制度管理组",
reviewer="顾承宇",
status=AgentAssetStatus.ACTIVE.value,
current_version=COMPANY_TRAVEL_RULE_VERSION,
config_json={
"severity": "medium",
"enabled": True,
"tag": "财务规则",
"detail_mode": "spreadsheet",
"rule_template_label": "差旅报销 Excel 模板",
},
)
if company_travel_rule is not None:
if not str(company_travel_rule.current_version or "").strip():
company_travel_rule.current_version = COMPANY_TRAVEL_RULE_VERSION
if not str(company_travel_rule.working_version or "").strip():
company_travel_rule.working_version = company_travel_rule.current_version
if not str(company_travel_rule.published_version or "").strip():
company_travel_rule.published_version = company_travel_rule.current_version
if not str(company_travel_rule.status or "").strip():
company_travel_rule.status = AgentAssetStatus.ACTIVE.value
company_travel_rule.description = "通过 Excel 明细表维护差旅费报销标准、票据要求和审批口径。"
company_travel_rule.config_json = {
**(company_travel_rule.config_json or {}),
"severity": "medium",
"enabled": True,
"tag": "财务规则",
"detail_mode": "spreadsheet",
"rule_library": FINANCE_RULES_LIBRARY,
"rule_template_label": "差旅报销 Excel 模板",
}
company_travel_rule_meta = self._ensure_company_travel_rule_spreadsheet_seed(
company_travel_rule,
version=str(company_travel_rule.current_version or COMPANY_TRAVEL_RULE_VERSION),
actor_name="系统初始化",
)
self._ensure_asset_version(
company_travel_rule,
version=str(company_travel_rule.current_version or COMPANY_TRAVEL_RULE_VERSION),
content=AgentAssetSpreadsheetManager.build_version_markdown(
rule_name=company_travel_rule.name,
version=str(company_travel_rule.current_version or COMPANY_TRAVEL_RULE_VERSION),
metadata=company_travel_rule_meta,
),
content_type=AgentAssetContentType.MARKDOWN.value,
change_note="初始化差旅费报销 Excel 规则表。",
created_by="系统初始化",
)
if str(company_travel_rule.current_version or "").strip() == COMPANY_TRAVEL_RULE_VERSION:
self._ensure_asset_review(
company_travel_rule,
version=COMPANY_TRAVEL_RULE_VERSION,
reviewer="顾承宇",
review_status=AgentReviewStatus.APPROVED.value,
review_note="首版 Excel 规则表已确认,可作为财务规则使用。",
reviewed_at=datetime.now(UTC),
)
if "skill.ar.aging_summary" not in existing_codes:
asset = self._create_seed_asset(
asset_type=AgentAssetType.SKILL.value,
code="skill.ar.aging_summary",
name="应收账龄汇总技能",
@@ -1207,10 +1372,10 @@ class AgentFoundationService:
created_by="系统初始化",
)
if "task.hermes.knowledge_index_sync" not in existing_codes:
asset = self._create_seed_asset(
asset_type=AgentAssetType.TASK.value,
code="task.hermes.knowledge_index_sync",
if "task.hermes.knowledge_index_sync" not in existing_codes:
asset = self._create_seed_asset(
asset_type=AgentAssetType.TASK.value,
code="task.hermes.knowledge_index_sync",
name="Hermes ??????",
description="?????????? LightRAG ???????",
domain=AgentAssetDomain.SYSTEM.value,
@@ -1234,14 +1399,112 @@ class AgentFoundationService:
}
),
content_type=AgentAssetContentType.JSON.value,
change_note="初始化制度知识与规则草稿形成任务。",
created_by="系统初始化",
)
def _create_seed_asset(
self,
*,
asset_type: str,
change_note="初始化制度知识与规则草稿形成任务。",
created_by="系统初始化",
)
def _ensure_company_travel_rule_spreadsheet_seed(
self,
asset: AgentAsset,
*,
version: str,
actor_name: str,
):
manager = AgentAssetSpreadsheetManager()
manager.ensure_rule_library_dirs()
live_document = manager.store_rule_library_spreadsheet(
library=FINANCE_RULES_LIBRARY,
file_name=COMPANY_TRAVEL_EXPENSE_RULE_FILENAME,
content=self._read_or_build_company_travel_rule_file(manager),
actor_name=actor_name,
source="rule-library",
)
existing_document = (
asset.config_json.get("rule_document")
if isinstance(asset.config_json, dict)
else None
)
storage_key = (
str(existing_document.get("storage_key") or "").strip()
if isinstance(existing_document, dict)
else ""
)
if storage_key:
try:
existing_path = manager.resolve_storage_path(storage_key)
except FileNotFoundError:
existing_path = None
if existing_path is not None and existing_path.exists():
metadata = RuleSpreadsheetMeta(
file_name=str(existing_document.get("file_name") or COMPANY_TRAVEL_EXPENSE_RULE_FILENAME),
storage_key=storage_key,
mime_type=str(existing_document.get("mime_type") or "").strip()
or "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
size_bytes=int(existing_document.get("size_bytes") or existing_path.stat().st_size),
checksum=hashlib.sha256(existing_path.read_bytes()).hexdigest(),
updated_at=str(existing_document.get("updated_at") or "").strip()
or datetime.now(UTC).isoformat(),
updated_by=str(existing_document.get("updated_by") or actor_name).strip()
or actor_name,
source=str(existing_document.get("source") or "seed").strip() or "seed",
)
asset.config_json = {
**(asset.config_json or {}),
"detail_mode": "spreadsheet",
"tag": "财务规则",
"rule_library": FINANCE_RULES_LIBRARY,
"rule_document": {
**AgentAssetSpreadsheetManager.build_rule_document_config(
live_document,
asset_version=version,
),
"storage_key": live_document.storage_key,
},
}
return metadata
live_content = manager.resolve_storage_path(live_document.storage_key).read_bytes()
metadata = manager.store_spreadsheet(
asset_id=asset.id,
version=version,
file_name=COMPANY_TRAVEL_EXPENSE_RULE_FILENAME,
content=live_content,
actor_name=actor_name,
source="seed",
)
asset.config_json = {
**(asset.config_json or {}),
"detail_mode": "spreadsheet",
"tag": "财务规则",
"rule_library": FINANCE_RULES_LIBRARY,
"rule_document": {
**AgentAssetSpreadsheetManager.build_rule_document_config(
live_document,
asset_version=version,
),
"storage_key": live_document.storage_key,
},
}
return metadata
@staticmethod
def _read_or_build_company_travel_rule_file(
manager: AgentAssetSpreadsheetManager,
) -> bytes:
live_key = (
Path("rules")
/ FINANCE_RULES_LIBRARY
/ COMPANY_TRAVEL_EXPENSE_RULE_FILENAME
).as_posix()
live_path = manager.resolve_storage_path(live_key)
if live_path.exists():
return live_path.read_bytes()
return AgentAssetSpreadsheetManager.build_blank_rule_workbook("差旅费报销规则")
def _create_seed_asset(
self,
*,
asset_type: str,
code: str,
name: str,
description: str,
@@ -1262,10 +1525,12 @@ class AgentFoundationService:
scenario_json=scenario_json,
owner=owner,
reviewer=reviewer,
status=status,
current_version=current_version,
config_json=config_json,
)
status=status,
current_version=current_version,
published_version=current_version if status == AgentAssetStatus.ACTIVE.value else None,
working_version=current_version,
config_json=config_json,
)
self.db.add(asset)
self.db.flush()
return asset
@@ -1331,7 +1596,7 @@ class AgentFoundationService:
)
)
def _remove_legacy_rule_assets(self) -> None:
def _remove_legacy_rule_assets(self) -> None:
assets = list(
self.db.scalars(
select(AgentAsset).where(AgentAsset.code.in_(LEGACY_RULE_CODES))
@@ -1345,8 +1610,38 @@ class AgentFoundationService:
select(AuditLog).where(AuditLog.resource_id.in_(LEGACY_RULE_CODES))
).all()
)
for log in obsolete_logs:
self.db.delete(log)
for log in obsolete_logs:
self.db.delete(log)
def _ensure_agent_asset_schema(self) -> None:
bind = self.db.get_bind()
inspector = inspect(bind)
if "agent_assets" not in inspector.get_table_names():
return
column_names = {column["name"] for column in inspector.get_columns("agent_assets")}
migration_statements: list[str] = []
if "published_version" not in column_names:
migration_statements.append("ALTER TABLE agent_assets ADD COLUMN published_version VARCHAR(30)")
if "working_version" not in column_names:
migration_statements.append("ALTER TABLE agent_assets ADD COLUMN working_version VARCHAR(30)")
for statement in migration_statements:
self.db.execute(text(statement))
self.db.execute(
text(
"UPDATE agent_assets "
"SET working_version = COALESCE(working_version, current_version), "
"published_version = CASE "
"WHEN published_version IS NOT NULL THEN published_version "
"WHEN status = 'active' THEN current_version "
"ELSE published_version END"
)
)
if migration_statements:
self.db.commit()
def _attachment_submission_requirement_markdown(
self,

View File

@@ -60,6 +60,10 @@ function buildQuery(params = {}) {
search.set('limit', String(params.limit))
}
if (params.version) {
search.set('version', String(params.version))
}
if (params.agent) {
search.set('agent', params.agent)
}
@@ -80,10 +84,66 @@ export function fetchAgentAssetDetail(assetId) {
return apiRequest(`/agent-assets/${assetId}`)
}
export function fetchAgentAssetSpreadsheetOnlyOfficeConfig(assetId, version = '') {
const query = buildQuery({ version })
return apiRequest(`/agent-assets/${assetId}/spreadsheet/onlyoffice-config${query}`)
}
export function fetchAgentAssetSpreadsheetBlob(assetId, version = '', disposition = 'inline') {
const search = new URLSearchParams()
if (version) {
search.set('version', String(version).trim())
}
if (disposition) {
search.set('disposition', String(disposition).trim())
}
const query = search.toString()
return apiRequest(`/agent-assets/${assetId}/spreadsheet/content${query ? `?${query}` : ''}`, {
responseType: 'blob',
contentType: null
})
}
export function uploadAgentAssetSpreadsheet(assetId, file, options = {}) {
return apiRequest(
`/agent-assets/${assetId}/spreadsheet/upload?filename=${encodeURIComponent(file.name)}`,
{
method: 'POST',
body: file,
contentType: file.type || 'application/octet-stream',
headers: buildWriteHeaders(options)
}
)
}
export function importAgentAssetSpreadsheetContent(assetId, file, options = {}) {
return apiRequest(
`/agent-assets/${assetId}/spreadsheet/import-content?filename=${encodeURIComponent(file.name)}`,
{
method: 'POST',
body: file,
contentType: file.type || 'application/octet-stream',
headers: buildWriteHeaders(options)
}
)
}
export function fetchAgentAssetVersions(assetId, limit = 5) {
return apiRequest(`/agent-assets/${assetId}/versions${buildQuery({ limit })}`)
}
export function fetchAgentAssetVersionTimeline(assetId) {
return apiRequest(`/agent-assets/${assetId}/version-timeline`)
}
export function compareAgentAssetSpreadsheetVersions(assetId, baseVersion, targetVersion) {
const query = new URLSearchParams({
base_version: String(baseVersion || '').trim(),
target_version: String(targetVersion || '').trim()
})
return apiRequest(`/agent-assets/${assetId}/versions/compare?${query.toString()}`)
}
export function updateAgentAsset(assetId, payload, options = {}) {
return apiRequest(`/agent-assets/${assetId}`, {
method: 'PATCH',
@@ -115,6 +175,13 @@ export function activateAgentAsset(assetId, options = {}) {
})
}
export function restoreAgentAssetVersion(assetId, version, options = {}) {
return apiRequest(`/agent-assets/${assetId}/versions/${encodeURIComponent(version)}/restore`, {
method: 'POST',
headers: buildWriteHeaders(options)
})
}
export function fetchAgentRuns(params = {}) {
return apiRequest(`/agent-runs${buildQuery(params)}`)
}