fix(agent-assets): save rule markdown atomically
This commit is contained in:
70
server/src/app/api/v1/endpoints/agent_asset_rule_markdown.py
Normal file
70
server/src/app/api/v1/endpoints/agent_asset_rule_markdown.py
Normal file
@@ -0,0 +1,70 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Annotated
|
||||
|
||||
from fastapi import APIRouter, Depends, Header, HTTPException, status
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.api.deps import CurrentUserContext, get_db, require_rule_editor_user
|
||||
from app.schemas.agent_asset import AgentAssetVersionRead, RuleMarkdownUpdate
|
||||
from app.schemas.common import ErrorResponse
|
||||
from app.services.agent_asset_access import stable_user_principal
|
||||
from app.services.agent_assets import AgentAssetService
|
||||
|
||||
router = APIRouter(prefix="/agent-assets")
|
||||
DbSession = Annotated[Session, Depends(get_db)]
|
||||
RuleEditorUser = Annotated[CurrentUserContext, Depends(require_rule_editor_user)]
|
||||
RequestIdHeader = Annotated[
|
||||
str | None,
|
||||
Header(description="外部请求 ID,用于串联审计日志和上游调用链。"),
|
||||
]
|
||||
|
||||
|
||||
def _actor(current_user: CurrentUserContext) -> str:
|
||||
return stable_user_principal(current_user)
|
||||
|
||||
|
||||
def _handle_asset_error(exc: Exception) -> None:
|
||||
if isinstance(exc, (LookupError, FileNotFoundError)):
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=str(exc)) from exc
|
||||
if isinstance(exc, (PermissionError, ValueError)):
|
||||
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(exc)) from exc
|
||||
raise exc
|
||||
|
||||
|
||||
@router.post(
|
||||
"/{asset_id}/rule-markdown",
|
||||
response_model=AgentAssetVersionRead,
|
||||
status_code=status.HTTP_201_CREATED,
|
||||
summary="原子保存规则 Markdown 与运行时配置",
|
||||
description=(
|
||||
"在同一数据库事务内创建规则版本、更新工作版本指针,并同步运行时 JSON。"
|
||||
),
|
||||
responses={
|
||||
status.HTTP_400_BAD_REQUEST: {
|
||||
"model": ErrorResponse,
|
||||
"description": "版本重复、规则类型不支持或正文与运行时配置不一致。",
|
||||
},
|
||||
status.HTTP_404_NOT_FOUND: {
|
||||
"model": ErrorResponse,
|
||||
"description": "资产不存在。",
|
||||
},
|
||||
},
|
||||
)
|
||||
def save_agent_asset_rule_markdown(
|
||||
asset_id: str,
|
||||
payload: RuleMarkdownUpdate,
|
||||
current_user: RuleEditorUser,
|
||||
db: DbSession,
|
||||
x_request_id: RequestIdHeader = None,
|
||||
) -> AgentAssetVersionRead:
|
||||
try:
|
||||
payload = payload.model_copy(update={"created_by": _actor(current_user)})
|
||||
return AgentAssetService(db, current_user=current_user).save_rule_markdown(
|
||||
asset_id,
|
||||
payload,
|
||||
actor=_actor(current_user),
|
||||
request_id=x_request_id,
|
||||
)
|
||||
except Exception as exc:
|
||||
_handle_asset_error(exc)
|
||||
@@ -2,6 +2,9 @@ from fastapi import APIRouter
|
||||
|
||||
from app.api.v1.endpoints.agent_asset_releases import router as agent_asset_releases_router
|
||||
from app.api.v1.endpoints.agent_asset_risk_rules import router as agent_asset_risk_rules_router
|
||||
from app.api.v1.endpoints.agent_asset_rule_markdown import (
|
||||
router as agent_asset_rule_markdown_router,
|
||||
)
|
||||
from app.api.v1.endpoints.agent_assets import router as agent_assets_router
|
||||
from app.api.v1.endpoints.agent_feedback import router as agent_feedback_router
|
||||
from app.api.v1.endpoints.agent_runs import router as agent_runs_router
|
||||
@@ -28,8 +31,8 @@ from app.api.v1.endpoints.expense_application_previews import (
|
||||
router as expense_application_previews_router,
|
||||
)
|
||||
from app.api.v1.endpoints.expense_cases import router as expense_cases_router
|
||||
from app.api.v1.endpoints.financial_connectors import router as financial_connectors_router
|
||||
from app.api.v1.endpoints.finance_report_configs import router as finance_report_configs_router
|
||||
from app.api.v1.endpoints.financial_connectors import router as financial_connectors_router
|
||||
from app.api.v1.endpoints.health import router as health_router
|
||||
from app.api.v1.endpoints.knowledge import router as knowledge_router
|
||||
from app.api.v1.endpoints.linked_reimbursement_draft_jobs import (
|
||||
@@ -56,6 +59,7 @@ router.include_router(cfo_value_router, tags=["analytics"])
|
||||
router.include_router(commercial_router, tags=["commercial"])
|
||||
router.include_router(commercial_billing_router, tags=["commercial"])
|
||||
router.include_router(agent_assets_router, tags=["agent-assets"])
|
||||
router.include_router(agent_asset_rule_markdown_router, tags=["agent-assets"])
|
||||
router.include_router(agent_asset_risk_rules_router, tags=["agent-assets"])
|
||||
router.include_router(agent_asset_releases_router, tags=["agent-assets"])
|
||||
router.include_router(agent_feedback_router, tags=["agent-feedback"])
|
||||
|
||||
@@ -53,6 +53,7 @@ class AgentAssetVersionCreate(BaseModel):
|
||||
class RuleMarkdownUpdate(BaseModel):
|
||||
version: str = Field(min_length=1, max_length=30)
|
||||
content: str
|
||||
config_json: dict[str, Any]
|
||||
change_note: str | None = None
|
||||
created_by: str = Field(min_length=1, max_length=100)
|
||||
|
||||
|
||||
130
server/src/app/services/agent_asset_rule_markdown.py
Normal file
130
server/src/app/services/agent_asset_rule_markdown.py
Normal file
@@ -0,0 +1,130 @@
|
||||
"""规则 Markdown 与运行时配置的原子保存职责。"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
|
||||
from app.core.agent_enums import AgentAssetContentType, AgentAssetType
|
||||
from app.core.logging import get_logger
|
||||
from app.models.agent_asset import AgentAssetVersion
|
||||
from app.schemas.agent_asset import AgentAssetVersionRead, RuleMarkdownUpdate
|
||||
from app.services.expense_rule_runtime_defaults import EXPENSE_RULE_CODE_BLOCK_PATTERN
|
||||
|
||||
logger = get_logger("app.services.agent_asset_rule_markdown")
|
||||
|
||||
RULE_RUNTIME_CONFIG_FIELDS = (
|
||||
"runtime_kind",
|
||||
"runtime_rule",
|
||||
"rule_template_key",
|
||||
"rule_template_label",
|
||||
)
|
||||
|
||||
|
||||
class AgentAssetRuleMarkdownMixin:
|
||||
"""只处理普通 Markdown 规则的版本与运行配置同步。"""
|
||||
|
||||
@staticmethod
|
||||
def _validate_rule_markdown_runtime(payload: RuleMarkdownUpdate) -> None:
|
||||
runtime_rule = payload.config_json.get("runtime_rule")
|
||||
if not isinstance(runtime_rule, dict):
|
||||
raise ValueError("运行时配置必须包含 runtime_rule 对象。")
|
||||
|
||||
match = EXPENSE_RULE_CODE_BLOCK_PATTERN.search(payload.content)
|
||||
if match is None:
|
||||
raise ValueError("规则 Markdown 必须包含 expense-rule 运行时配置块。")
|
||||
try:
|
||||
embedded_runtime_rule = json.loads(match.group(1))
|
||||
except json.JSONDecodeError as exc:
|
||||
raise ValueError("规则 Markdown 中的运行时 JSON 不合法。") from exc
|
||||
if embedded_runtime_rule != runtime_rule:
|
||||
raise ValueError("规则 Markdown 与运行时配置不一致。")
|
||||
|
||||
runtime_kind = str(runtime_rule.get("kind") or "").strip()
|
||||
configured_kind = str(payload.config_json.get("runtime_kind") or "").strip()
|
||||
if not runtime_kind or configured_kind != runtime_kind:
|
||||
raise ValueError("runtime_kind 必须与 runtime_rule.kind 保持一致。")
|
||||
|
||||
def save_rule_markdown(
|
||||
self,
|
||||
asset_id: str,
|
||||
payload: RuleMarkdownUpdate,
|
||||
*,
|
||||
actor: str,
|
||||
request_id: str | None = None,
|
||||
) -> AgentAssetVersionRead:
|
||||
"""在单个事务内同时保存规则正文、版本指针与运行时配置。"""
|
||||
|
||||
self._ensure_ready()
|
||||
asset = self.repository.get(asset_id)
|
||||
if asset is None:
|
||||
raise LookupError("Asset not found")
|
||||
if self.access_scope is not None:
|
||||
self.access_scope.require_write(asset)
|
||||
if asset.asset_type != AgentAssetType.RULE.value:
|
||||
raise ValueError("只有规则资产可以保存规则 Markdown。")
|
||||
|
||||
detail_mode = str((asset.config_json or {}).get("detail_mode") or "").strip().lower()
|
||||
if detail_mode in {"spreadsheet", "json_risk"}:
|
||||
raise ValueError("当前规则类型必须使用专用编辑接口。")
|
||||
if self.repository.get_version(asset_id, payload.version):
|
||||
raise ValueError(f"版本号 {payload.version} 已存在")
|
||||
self._validate_rule_markdown_runtime(payload)
|
||||
|
||||
previous_config = asset.config_json or {}
|
||||
before = {
|
||||
**self._asset_snapshot(asset),
|
||||
"runtime_kind": str(previous_config.get("runtime_kind") or ""),
|
||||
"rule_template_key": str(previous_config.get("rule_template_key") or ""),
|
||||
}
|
||||
next_config = dict(previous_config)
|
||||
for field_name in RULE_RUNTIME_CONFIG_FIELDS:
|
||||
if field_name in payload.config_json:
|
||||
next_config[field_name] = payload.config_json[field_name]
|
||||
|
||||
version = AgentAssetVersion(
|
||||
tenant_id=asset.tenant_id,
|
||||
scope=asset.scope,
|
||||
asset_id=asset.id,
|
||||
version=payload.version,
|
||||
content=self._serialize_content(
|
||||
payload.content,
|
||||
AgentAssetContentType.MARKDOWN.value,
|
||||
),
|
||||
content_type=AgentAssetContentType.MARKDOWN.value,
|
||||
change_note=payload.change_note,
|
||||
created_by=actor,
|
||||
)
|
||||
try:
|
||||
self.db.add(version)
|
||||
asset.current_version = payload.version
|
||||
asset.working_version = payload.version
|
||||
asset.config_json = next_config
|
||||
self.db.add(asset)
|
||||
self.audit_service.log_action(
|
||||
actor=actor,
|
||||
action="save_rule_markdown",
|
||||
resource_type=asset.asset_type,
|
||||
resource_id=asset.id,
|
||||
before_json=before,
|
||||
after_json={
|
||||
**self._asset_snapshot(asset),
|
||||
"runtime_kind": str(next_config.get("runtime_kind") or ""),
|
||||
"rule_template_key": str(next_config.get("rule_template_key") or ""),
|
||||
},
|
||||
request_id=request_id,
|
||||
commit=False,
|
||||
)
|
||||
self.db.refresh(version)
|
||||
self.db.refresh(asset)
|
||||
saved_version = self._serialize_version(version, asset)
|
||||
self.db.commit()
|
||||
except Exception:
|
||||
self.db.rollback()
|
||||
raise
|
||||
|
||||
logger.info(
|
||||
"Saved rule markdown atomically asset_id=%s version=%s",
|
||||
asset_id,
|
||||
payload.version,
|
||||
)
|
||||
return saved_version
|
||||
@@ -36,6 +36,7 @@ from app.services.agent_asset_risk_rule_publish import AgentAssetRiskRulePublish
|
||||
from app.services.agent_asset_risk_rule_simulation import AgentAssetRiskRuleSimulationMixin
|
||||
from app.services.agent_asset_risk_rule_testing import AgentAssetRiskRuleTestingMixin
|
||||
from app.services.agent_asset_rule_library import AgentAssetRuleLibraryManager
|
||||
from app.services.agent_asset_rule_markdown import AgentAssetRuleMarkdownMixin
|
||||
from app.services.agent_asset_serialization import AgentAssetSerializationMixin
|
||||
from app.services.agent_asset_spreadsheet import AgentAssetSpreadsheetManager
|
||||
from app.services.agent_asset_spreadsheet_helpers import AgentAssetSpreadsheetHelperMixin
|
||||
@@ -149,6 +150,7 @@ class AgentAssetVersionMixin:
|
||||
class AgentAssetService(
|
||||
AgentAssetSerializationMixin,
|
||||
AgentAssetVersionMixin,
|
||||
AgentAssetRuleMarkdownMixin,
|
||||
AgentAssetOnlyOfficeMixin,
|
||||
AgentAssetSpreadsheetHelperMixin,
|
||||
AgentAssetRiskRuleLevelMixin,
|
||||
|
||||
Reference in New Issue
Block a user