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
import hashlib
import json
from datetime import UTC, date, datetime
from decimal import Decimal
from pathlib import Path
from sqlalchemy import select
from sqlalchemy import inspect, select, text
from sqlalchemy.orm import Session
from app.core.agent_enums import (
@@ -32,6 +34,13 @@ from app.models.financial_record import (
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,
@@ -78,6 +87,7 @@ LEGACY_RULE_CODES = (
)
ATTACHMENT_RULE_ASSET_CODE = "rule.expense.attachment_submission_requirements"
COMPANY_TRAVEL_RULE_VERSION = "v1.0.0"
ATTACHMENT_RULE_RUNTIME_CONFIG = {
"kind": "policy_rule_draft",
@@ -159,6 +169,7 @@ class AgentFoundationService:
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()
@@ -191,6 +202,8 @@ class AgentFoundationService:
reviewer="高嘉禾",
status=AgentAssetStatus.REVIEW.value,
current_version="v1.0.0",
published_version=None,
working_version="v1.0.0",
config_json={
"severity": "high",
"enabled": False,
@@ -211,6 +224,8 @@ class AgentFoundationService:
reviewer="顾承宇",
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,
@@ -229,6 +244,8 @@ class AgentFoundationService:
reviewer="顾承宇",
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,
@@ -240,6 +257,28 @@ class AgentFoundationService:
"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",
@@ -251,6 +290,8 @@ class AgentFoundationService:
reviewer="陈硕",
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(
@@ -264,6 +305,8 @@ class AgentFoundationService:
reviewer="陈硕",
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(
@@ -277,6 +320,8 @@ class AgentFoundationService:
reviewer="周悦宁",
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(
@@ -290,6 +335,8 @@ class AgentFoundationService:
reviewer="周悦宁",
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(
@@ -303,6 +350,8 @@ class AgentFoundationService:
reviewer="顾承宇",
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(
@@ -316,6 +365,8 @@ class AgentFoundationService:
reviewer="顾承宇",
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(
@@ -329,6 +380,8 @@ class AgentFoundationService:
reviewer="顾承宇",
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(
@@ -342,6 +395,8 @@ class AgentFoundationService:
reviewer="顾承宇",
status=AgentAssetStatus.ACTIVE.value,
current_version="v1.0.0",
published_version="v1.0.0",
working_version="v1.0.0",
config_json={"cron": "0 0 * * *", "agent": AgentName.HERMES.value},
)
@@ -350,6 +405,7 @@ class AgentFoundationService:
attachment_rule,
scene_submission_rule,
travel_policy_rule,
company_travel_rule,
skill_expense_asset,
skill_ar_asset,
invoice_mcp_asset,
@@ -362,6 +418,12 @@ class AgentFoundationService:
)
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(
@@ -410,6 +472,18 @@ class AgentFoundationService:
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",
@@ -553,6 +627,14 @@ class AgentFoundationService:
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),
),
]
)
@@ -916,6 +998,9 @@ class AgentFoundationService:
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(
@@ -940,8 +1025,11 @@ class AgentFoundationService:
)
if attachment_rule is not None:
if not str(attachment_rule.current_version or "").strip():
attachment_rule.current_version = "v1.0.0"
attachment_rule.status = AgentAssetStatus.REVIEW.value
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",
@@ -1003,8 +1091,13 @@ class AgentFoundationService:
)
if scene_submission_rule is not None:
if not str(scene_submission_rule.current_version or "").strip():
scene_submission_rule.current_version = "v1.0.0"
scene_submission_rule.status = AgentAssetStatus.ACTIVE.value
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",
@@ -1054,8 +1147,13 @@ class AgentFoundationService:
)
if travel_policy_rule is not None:
if not str(travel_policy_rule.current_version or "").strip():
travel_policy_rule.current_version = "v1.1.0"
travel_policy_rule.status = AgentAssetStatus.ACTIVE.value
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,
@@ -1091,6 +1189,73 @@ class AgentFoundationService:
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,
@@ -1238,6 +1403,104 @@ class AgentFoundationService:
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,
*,
@@ -1264,6 +1527,8 @@ class AgentFoundationService:
reviewer=reviewer,
status=status,
current_version=current_version,
published_version=current_version if status == AgentAssetStatus.ACTIVE.value else None,
working_version=current_version,
config_json=config_json,
)
self.db.add(asset)
@@ -1348,6 +1613,36 @@ class AgentFoundationService:
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)}`)
}