feat: deliver agent foundation day 1

This commit is contained in:
caoxiaozhu
2026-05-11 03:51:24 +00:00
parent f738b6cdd4
commit b2beeaa136
54 changed files with 6747 additions and 1724 deletions

View File

@@ -0,0 +1,407 @@
from __future__ import annotations
import json
from datetime import UTC, datetime
from typing import Any
from sqlalchemy.orm import Session
from app.core.agent_enums import (
AgentAssetContentType,
AgentAssetStatus,
AgentAssetType,
AgentReviewStatus,
)
from app.core.logging import get_logger
from app.models.agent_asset import AgentAsset, AgentAssetReview, AgentAssetVersion
from app.repositories.agent_asset import AgentAssetRepository
from app.schemas.agent_asset import (
AgentAssetCreate,
AgentAssetListItem,
AgentAssetRead,
AgentAssetReviewCreate,
AgentAssetReviewRead,
AgentAssetUpdate,
AgentAssetVersionCreate,
AgentAssetVersionRead,
)
from app.services.agent_foundation import AgentFoundationService
from app.services.audit import AuditLogService
logger = get_logger("app.services.agent_assets")
class AgentAssetService:
def __init__(self, db: Session) -> None:
self.db = db
self.repository = AgentAssetRepository(db)
self.audit_service = AuditLogService(db)
def list_assets(
self,
*,
asset_type: str | None = None,
status: str | None = None,
domain: str | None = None,
keyword: str | None = None,
) -> list[AgentAssetListItem]:
self._ensure_ready()
items = self.repository.list(
asset_type=asset_type, status=status, domain=domain, keyword=keyword
)
return [AgentAssetListItem.model_validate(item) for item in items]
def get_asset(self, asset_id: str) -> AgentAssetRead | None:
self._ensure_ready()
asset = self.repository.get(asset_id)
if asset is None:
return None
recent_versions = self._sort_versions(
self.repository.list_versions(asset_id, limit=5),
asset.current_version,
)
latest_review = next(iter(self.repository.list_reviews(asset_id, limit=1)), None)
current_version = (
self.repository.get_version(asset_id, asset.current_version)
if asset.current_version
else None
)
return AgentAssetRead(
**AgentAssetListItem.model_validate(asset).model_dump(),
current_version_content=self._deserialize_content(current_version)
if current_version
else None,
current_version_content_type=current_version.content_type if current_version else None,
current_version_change_note=current_version.change_note if current_version else None,
recent_versions=[
self._serialize_version(item, asset.current_version) for item in recent_versions
],
latest_review=AgentAssetReviewRead.model_validate(latest_review)
if latest_review
else None,
)
def create_asset(
self,
payload: AgentAssetCreate,
*,
actor: str,
request_id: str | None = None,
) -> AgentAssetRead:
self._ensure_ready()
if self.repository.get_by_code(payload.code):
raise ValueError(f"资产编码 {payload.code} 已存在")
if payload.status == AgentAssetStatus.ACTIVE:
raise ValueError("请先创建资产并完成审核,再通过上线接口激活。")
asset = AgentAsset(
asset_type=payload.asset_type.value,
code=payload.code,
name=payload.name,
description=payload.description,
domain=payload.domain.value,
scenario_json=payload.scenario_json,
owner=payload.owner,
reviewer=payload.reviewer,
status=payload.status.value,
config_json=payload.config_json,
)
created = self.repository.create_asset(asset)
self.audit_service.log_action(
actor=actor,
action="create_agent_asset",
resource_type=created.asset_type,
resource_id=created.id,
before_json=None,
after_json=self._asset_snapshot(created),
request_id=request_id,
)
logger.info("Created agent asset id=%s code=%s", created.id, created.code)
return self.get_asset(created.id) # type: ignore[return-value]
def update_asset(
self,
asset_id: str,
payload: AgentAssetUpdate,
*,
actor: str,
request_id: str | None = None,
) -> AgentAssetRead:
self._ensure_ready()
asset = self.repository.get(asset_id)
if asset is None:
raise LookupError("Asset not found")
before = self._asset_snapshot(asset)
if payload.status == AgentAssetStatus.ACTIVE:
raise ValueError("请使用上线接口激活资产。")
for field_name in (
"name",
"description",
"owner",
"reviewer",
"current_version",
"config_json",
"scenario_json",
):
value = getattr(payload, field_name)
if value is not None:
setattr(asset, field_name, value)
if payload.domain is not None:
asset.domain = payload.domain.value
if payload.status is not None:
asset.status = payload.status.value
if payload.current_version is not None and not self.repository.get_version(
asset_id, payload.current_version
):
raise LookupError(f"版本 {payload.current_version} 不存在")
updated = self.repository.save_asset(asset)
self.audit_service.log_action(
actor=actor,
action="update_agent_asset",
resource_type=updated.asset_type,
resource_id=updated.id,
before_json=before,
after_json=self._asset_snapshot(updated),
request_id=request_id,
)
logger.info("Updated agent asset id=%s code=%s", updated.id, updated.code)
return self.get_asset(updated.id) # type: ignore[return-value]
def list_versions(self, asset_id: str, *, limit: int = 20) -> list[AgentAssetVersionRead]:
self._ensure_ready()
asset = self.repository.get(asset_id)
if asset is None:
raise LookupError("Asset not found")
versions = self._sort_versions(
self.repository.list_versions(asset_id, limit=limit),
asset.current_version,
)
return [self._serialize_version(item, asset.current_version) for item in versions]
def create_version(
self,
asset_id: str,
payload: AgentAssetVersionCreate,
*,
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.repository.get_version(asset_id, payload.version):
raise ValueError(f"版本号 {payload.version} 已存在")
self._validate_version_payload(asset, payload)
serialized_content = self._serialize_content(payload.content, payload.content_type.value)
version = AgentAssetVersion(
asset_id=asset_id,
version=payload.version,
content=serialized_content,
content_type=payload.content_type.value,
change_note=payload.change_note,
created_by=payload.created_by,
)
created = self.repository.create_version(version)
before = self._asset_snapshot(asset)
asset.current_version = payload.version
if (
asset.asset_type == AgentAssetType.RULE.value
and asset.status == AgentAssetStatus.ACTIVE.value
):
asset.status = AgentAssetStatus.REVIEW.value
updated_asset = self.repository.save_asset(asset)
self.audit_service.log_action(
actor=actor,
action="save_agent_asset_version",
resource_type=updated_asset.asset_type,
resource_id=updated_asset.id,
before_json=before,
after_json={
"current_version": updated_asset.current_version,
"status": updated_asset.status,
},
request_id=request_id,
)
logger.info("Created agent asset version asset_id=%s version=%s", asset_id, payload.version)
return self._serialize_version(created, updated_asset.current_version)
def create_review(
self,
asset_id: str,
payload: AgentAssetReviewCreate,
*,
actor: str,
request_id: str | None = None,
) -> AgentAssetReviewRead:
self._ensure_ready()
asset = self.repository.get(asset_id)
if asset is None:
raise LookupError("Asset not found")
if self.repository.get_version(asset_id, payload.version) is None:
raise LookupError(f"版本 {payload.version} 不存在")
review = AgentAssetReview(
asset_id=asset_id,
version=payload.version,
reviewer=payload.reviewer,
review_status=payload.review_status.value,
review_note=payload.review_note,
reviewed_at=None
if payload.review_status == AgentReviewStatus.PENDING
else datetime.now(UTC),
)
created = self.repository.create_review(review)
before = self._asset_snapshot(asset)
asset.reviewer = payload.reviewer
if payload.review_status == AgentReviewStatus.PENDING:
asset.status = AgentAssetStatus.REVIEW.value
elif payload.review_status == AgentReviewStatus.REJECTED:
asset.status = AgentAssetStatus.DRAFT.value
elif asset.status != AgentAssetStatus.ACTIVE.value:
asset.status = AgentAssetStatus.REVIEW.value
self.repository.save_asset(asset)
self.audit_service.log_action(
actor=actor,
action="review_agent_asset",
resource_type=asset.asset_type,
resource_id=asset.id,
before_json=before,
after_json={
"review_version": payload.version,
"review_status": payload.review_status.value,
"asset_status": asset.status,
},
request_id=request_id,
)
logger.info(
"Created review asset_id=%s version=%s status=%s",
asset_id,
payload.version,
payload.review_status.value,
)
return AgentAssetReviewRead.model_validate(created)
def activate_asset(
self,
asset_id: str,
*,
actor: str,
request_id: str | None = None,
) -> AgentAssetRead:
self._ensure_ready()
asset = self.repository.get(asset_id)
if asset is None:
raise LookupError("Asset not found")
if not asset.current_version:
raise ValueError("资产尚未设置当前版本,无法上线。")
if asset.asset_type == AgentAssetType.RULE.value:
review = self.repository.get_review(
asset.id, asset.current_version, AgentReviewStatus.APPROVED.value
)
if review is None:
raise PermissionError("规则当前版本尚未审核通过,不能上线。")
before = self._asset_snapshot(asset)
asset.status = AgentAssetStatus.ACTIVE.value
updated = self.repository.save_asset(asset)
self.audit_service.log_action(
actor=actor,
action="activate_agent_asset",
resource_type=updated.asset_type,
resource_id=updated.id,
before_json=before,
after_json=self._asset_snapshot(updated),
request_id=request_id,
)
logger.info("Activated agent asset id=%s code=%s", updated.id, updated.code)
return self.get_asset(updated.id) # type: ignore[return-value]
def _ensure_ready(self) -> None:
AgentFoundationService(self.db).ensure_foundation_ready()
def _validate_version_payload(
self, asset: AgentAsset, payload: AgentAssetVersionCreate
) -> None:
if (
asset.asset_type == AgentAssetType.RULE.value
and payload.content_type != AgentAssetContentType.MARKDOWN
):
raise ValueError("规则资产版本内容必须使用 markdown。")
if (
asset.asset_type != AgentAssetType.RULE.value
and payload.content_type != AgentAssetContentType.JSON
):
raise ValueError("技能、MCP、任务资产版本内容必须使用 json。")
if payload.content_type == AgentAssetContentType.MARKDOWN and not isinstance(
payload.content, str
):
raise ValueError("Markdown 内容必须是字符串。")
if payload.content_type == AgentAssetContentType.JSON and not isinstance(
payload.content, (dict, list)
):
raise ValueError("JSON 内容必须是对象或数组。")
def _serialize_version(
self, version: AgentAssetVersion, current_version: str | None
) -> AgentAssetVersionRead:
return AgentAssetVersionRead(
id=version.id,
asset_id=version.asset_id,
version=version.version,
content=self._deserialize_content(version),
content_type=version.content_type,
change_note=version.change_note,
created_by=version.created_by,
created_at=version.created_at,
is_current=version.version == current_version,
)
@staticmethod
def _sort_versions(
versions: list[AgentAssetVersion], current_version: str | None
) -> list[AgentAssetVersion]:
return sorted(
versions,
key=lambda item: (item.version == current_version, item.created_at),
reverse=True,
)
@staticmethod
def _serialize_content(content: Any, content_type: str) -> str:
if content_type == AgentAssetContentType.MARKDOWN.value:
return str(content)
return json.dumps(content, ensure_ascii=False, sort_keys=True, indent=2)
@staticmethod
def _deserialize_content(version: AgentAssetVersion | None) -> Any:
if version is None:
return None
if version.content_type == AgentAssetContentType.MARKDOWN.value:
return version.content
return json.loads(version.content)
@staticmethod
def _asset_snapshot(asset: AgentAsset) -> dict[str, Any]:
return {
"asset_type": asset.asset_type,
"code": asset.code,
"name": asset.name,
"status": asset.status,
"current_version": asset.current_version,
"domain": asset.domain,
"owner": asset.owner,
"reviewer": asset.reviewer,
}

View File

@@ -0,0 +1,977 @@
from __future__ import annotations
import json
from datetime import UTC, date, datetime
from decimal import Decimal
from sqlalchemy import select
from sqlalchemy.orm import Session
from app.core.agent_enums import (
AgentAssetContentType,
AgentAssetDomain,
AgentAssetStatus,
AgentAssetType,
AgentName,
AgentPermissionLevel,
AgentReviewStatus,
AgentRunSource,
AgentRunStatus,
AgentToolType,
)
from app.core.config import get_settings
from app.core.logging import get_logger
from app.db.base import Base
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,
)
logger = get_logger("app.services.agent_foundation")
def prepare_agent_foundation() -> None:
settings = get_settings()
if not settings.setup_completed:
logger.info("Agent foundation bootstrap skipped because setup is incomplete")
return
session_factory = get_session_factory()
with session_factory() as db:
AgentFoundationService(db).ensure_foundation_ready()
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()
self._seed_financial_records()
self._seed_runs_and_logs()
self.db.commit()
except Exception:
self.db.rollback()
logger.exception("Failed to prepare agent foundation")
raise
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)
return
approved_rule = AgentAsset(
asset_type=AgentAssetType.RULE.value,
code="rule.expense.duplicate_expense_check",
name="重复报销识别规则",
description="识别同一员工短时间内同金额、同地点、同理由的重复报销风险。",
domain=AgentAssetDomain.EXPENSE.value,
scenario_json=["expense", "risk_check", "duplicate_expense"],
owner="财务共享中心",
reviewer="张晓晴",
status=AgentAssetStatus.ACTIVE.value,
current_version="v1.1.0",
config_json={"severity": "high", "enabled": True},
)
pending_rule = AgentAsset(
asset_type=AgentAssetType.RULE.value,
code="rule.expense.travel_receipt_requirements",
name="差旅票据完整性规则",
description="检查差旅报销是否附齐发票、行程单和住宿凭证。",
domain=AgentAssetDomain.EXPENSE.value,
scenario_json=["expense", "explain", "invoice_anomaly"],
owner="费用运营组",
reviewer="高嘉禾",
status=AgentAssetStatus.REVIEW.value,
current_version="v1.0.0",
config_json={"severity": "medium", "enabled": False},
)
rejected_rule = AgentAsset(
asset_type=AgentAssetType.RULE.value,
code="rule.ap.payment_dual_review",
name="付款双人复核规则",
description="大额付款必须由两名财务人员复核后再进入付款建议。",
domain=AgentAssetDomain.AP.value,
scenario_json=["accounts_payable", "approval_required"],
owner="付款管理组",
reviewer="孙楠",
status=AgentAssetStatus.DRAFT.value,
current_version="v0.9.0",
config_json={"amount_threshold": 50000},
)
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",
config_json={"input_schema": ["time_range", "employee", "department"]},
)
skill_ar_asset = AgentAsset(
asset_type=AgentAssetType.SKILL.value,
code="skill.ar.aging_summary",
name="应收账龄汇总技能",
description="按客户、账龄和逾期状态汇总应收风险分布。",
domain=AgentAssetDomain.AR.value,
scenario_json=["accounts_receivable", "query", "aging_summary"],
owner="平台研发组",
reviewer="陈硕",
status=AgentAssetStatus.ACTIVE.value,
current_version="v1.0.0",
config_json={"input_schema": ["customer", "aging_bucket", "status"]},
)
invoice_mcp_asset = AgentAsset(
asset_type=AgentAssetType.MCP.value,
code="mcp.invoice.verify_mock",
name="发票验真 Mock 服务",
description="模拟发票验真、发票状态查询和异常降级说明。",
domain=AgentAssetDomain.SYSTEM.value,
scenario_json=["expense", "invoice_validation"],
owner="平台研发组",
reviewer="周悦宁",
status=AgentAssetStatus.ACTIVE.value,
current_version="v1.0.0",
config_json={"endpoint": "mock://invoice/verify", "timeout_ms": 1200},
)
ledger_mcp_asset = AgentAsset(
asset_type=AgentAssetType.MCP.value,
code="mcp.ledger.snapshot_mock",
name="总账快照 Mock 服务",
description="模拟返回应收、应付和费用汇总快照,供 Agent 查询和巡检。",
domain=AgentAssetDomain.SYSTEM.value,
scenario_json=["expense", "accounts_receivable", "accounts_payable"],
owner="平台研发组",
reviewer="周悦宁",
status=AgentAssetStatus.ACTIVE.value,
current_version="v1.0.0",
config_json={"endpoint": "mock://ledger/snapshot", "timeout_ms": 1500},
)
task_asset = AgentAsset(
asset_type=AgentAssetType.TASK.value,
code="task.hermes.daily_risk_scan",
name="Hermes 每日风险巡检",
description="每天早上巡检重复报销、金额超标、逾期应收和异常付款。",
domain=AgentAssetDomain.SYSTEM.value,
scenario_json=["schedule", "risk_check"],
owner="风控与审计部",
reviewer="顾承宇",
status=AgentAssetStatus.ACTIVE.value,
current_version="v1.0.0",
config_json={"cron": "0 9 * * *", "agent": AgentName.HERMES.value},
)
ar_summary_task = AgentAsset(
asset_type=AgentAssetType.TASK.value,
code="task.hermes.weekly_ar_summary",
name="Hermes 每周应收账龄汇总",
description="每周汇总逾期应收、账龄分布和客户风险变化。",
domain=AgentAssetDomain.SYSTEM.value,
scenario_json=["schedule", "accounts_receivable", "summary"],
owner="风控与审计部",
reviewer="顾承宇",
status=AgentAssetStatus.ACTIVE.value,
current_version="v1.0.0",
config_json={"cron": "0 10 * * 1", "agent": AgentName.HERMES.value},
)
rule_digest_task = AgentAsset(
asset_type=AgentAssetType.TASK.value,
code="task.hermes.rule_review_digest",
name="Hermes 规则待审摘要",
description="每天汇总待审规则、待补样例和被拒规则修订建议。",
domain=AgentAssetDomain.SYSTEM.value,
scenario_json=["schedule", "rule_center", "review_digest"],
owner="风控与审计部",
reviewer="顾承宇",
status=AgentAssetStatus.ACTIVE.value,
current_version="v1.0.0",
config_json={"cron": "0 18 * * *", "agent": AgentName.HERMES.value},
)
self.db.add_all(
[
approved_rule,
pending_rule,
rejected_rule,
skill_expense_asset,
skill_ar_asset,
invoice_mcp_asset,
ledger_mcp_asset,
task_asset,
ar_summary_task,
rule_digest_task,
]
)
self.db.flush()
self.db.add_all(
[
AgentAssetVersion(
asset=approved_rule,
version="v1.0.0",
content=self._markdown_content(
"# 重复报销识别规则\n\n"
"- 检查员工、金额、地点、发生日期是否高度重复。\n"
"- 命中后输出 `duplicate_expense` 风险标签。"
),
content_type=AgentAssetContentType.MARKDOWN.value,
change_note="初始化生产规则版本。",
created_by="系统初始化",
),
AgentAssetVersion(
asset=approved_rule,
version="v1.1.0",
content=self._markdown_content(
"# 重复报销识别规则\n\n"
"- 检查员工、金额、地点、发生日期是否高度重复。\n"
"- 新增对同项目、同金额、跨单重复提交的识别。\n"
"- 命中后输出 `duplicate_expense` 风险标签。"
),
content_type=AgentAssetContentType.MARKDOWN.value,
change_note="补充跨单重复提交判断。",
created_by="系统初始化",
),
AgentAssetVersion(
asset=pending_rule,
version="v0.9.0",
content=self._markdown_content(
"# 差旅票据完整性规则\n\n"
"- 差旅报销必须具备发票、行程单、住宿凭证。\n"
"- 缺失时输出 `invoice_anomaly`。"
),
content_type=AgentAssetContentType.MARKDOWN.value,
change_note="首版草稿。",
created_by="高嘉禾",
),
AgentAssetVersion(
asset=pending_rule,
version="v1.0.0",
content=self._markdown_content(
"# 差旅票据完整性规则\n\n"
"- 差旅报销必须具备发票、行程单、住宿凭证。\n"
"- 新增高铁改签和住宿分拆票据的补件说明。\n"
"- 缺失时输出 `invoice_anomaly`。"
),
content_type=AgentAssetContentType.MARKDOWN.value,
change_note="补充差旅特殊票据口径,待审核。",
created_by="高嘉禾",
),
AgentAssetVersion(
asset=rejected_rule,
version="v0.8.0",
content=self._markdown_content(
"# 付款双人复核规则\n\n"
"- 单笔付款超过阈值时必须双人复核。\n"
"- 本版本规则口径过宽,待修订。"
),
content_type=AgentAssetContentType.MARKDOWN.value,
change_note="首版方案。",
created_by="孙楠",
),
AgentAssetVersion(
asset=rejected_rule,
version="v0.9.0",
content=self._markdown_content(
"# 付款双人复核规则\n\n"
"- 单笔付款超过阈值时必须双人复核。\n"
"- 新增跨币种付款也进入复核队列。\n"
"- 当前阈值定义仍不清晰,需继续修订。"
),
content_type=AgentAssetContentType.MARKDOWN.value,
change_note="补充跨币种场景,但阈值仍待明确。",
created_by="孙楠",
),
AgentAssetVersion(
asset=skill_expense_asset,
version="v1.0.0",
content=self._json_content(
{
"inputs": ["time_range", "employee", "department"],
"outputs": ["total_amount", "claim_count"],
"dependencies": ["database.expense_claims"],
}
),
content_type=AgentAssetContentType.JSON.value,
change_note="初始化技能快照。",
created_by="系统初始化",
),
AgentAssetVersion(
asset=skill_ar_asset,
version="v1.0.0",
content=self._json_content(
{
"inputs": ["customer", "aging_bucket", "status"],
"outputs": ["receivable_total", "overdue_total", "customer_count"],
"dependencies": ["database.accounts_receivable"],
}
),
content_type=AgentAssetContentType.JSON.value,
change_note="初始化应收账龄技能快照。",
created_by="系统初始化",
),
AgentAssetVersion(
asset=invoice_mcp_asset,
version="v1.0.0",
content=self._json_content(
{
"service_type": "mock",
"auth_mode": "none",
"degrade_strategy": "return_stub_with_warning",
}
),
content_type=AgentAssetContentType.JSON.value,
change_note="初始化 MCP 快照。",
created_by="系统初始化",
),
AgentAssetVersion(
asset=ledger_mcp_asset,
version="v1.0.0",
content=self._json_content(
{
"service_type": "mock",
"auth_mode": "service_account",
"degrade_strategy": "return_cached_snapshot_with_warning",
}
),
content_type=AgentAssetContentType.JSON.value,
change_note="初始化总账快照 MCP。",
created_by="系统初始化",
),
AgentAssetVersion(
asset=task_asset,
version="v1.0.0",
content=self._json_content(
{
"task_type": "daily_risk_scan",
"schedule": "0 9 * * *",
"target_agent": AgentName.HERMES.value,
}
),
content_type=AgentAssetContentType.JSON.value,
change_note="初始化任务快照。",
created_by="系统初始化",
),
AgentAssetVersion(
asset=ar_summary_task,
version="v1.0.0",
content=self._json_content(
{
"task_type": "weekly_ar_summary",
"schedule": "0 10 * * 1",
"target_agent": AgentName.HERMES.value,
}
),
content_type=AgentAssetContentType.JSON.value,
change_note="初始化应收账龄汇总任务。",
created_by="系统初始化",
),
AgentAssetVersion(
asset=rule_digest_task,
version="v1.0.0",
content=self._json_content(
{
"task_type": "rule_review_digest",
"schedule": "0 18 * * *",
"target_agent": AgentName.HERMES.value,
}
),
content_type=AgentAssetContentType.JSON.value,
change_note="初始化规则待审摘要任务。",
created_by="系统初始化",
),
]
)
self.db.add_all(
[
AgentAssetReview(
asset=approved_rule,
version="v1.1.0",
reviewer="张晓晴",
review_status=AgentReviewStatus.APPROVED.value,
review_note="规则口径清晰,可上线。",
reviewed_at=datetime.now(UTC),
),
AgentAssetReview(
asset=pending_rule,
version="v1.0.0",
reviewer="高嘉禾",
review_status=AgentReviewStatus.PENDING.value,
review_note="等待补充票据异常样例。",
reviewed_at=None,
),
AgentAssetReview(
asset=rejected_rule,
version="v0.9.0",
reviewer="孙楠",
review_status=AgentReviewStatus.REJECTED.value,
review_note="阈值定义不清,暂不通过。",
reviewed_at=datetime.now(UTC),
),
]
)
def _seed_financial_records(self) -> None:
if self.db.scalar(select(ExpenseClaim.id).limit(1)) is not None:
return
claim_1 = ExpenseClaim(
claim_no="EXP-202605-001",
employee_name="张三",
department_name="财务共享中心",
project_code="PRJ-EXP-01",
expense_type="travel",
reason="华南客户拜访差旅报销",
location="深圳",
amount=Decimal("3280.00"),
currency="CNY",
invoice_count=3,
occurred_at=datetime(2026, 5, 6, 9, 0, tzinfo=UTC),
submitted_at=datetime(2026, 5, 7, 10, 20, tzinfo=UTC),
status="submitted",
approval_stage="finance_review",
risk_flags_json=["amount_over_limit"],
)
claim_1.items = [
ExpenseClaimItem(
item_date=date(2026, 5, 5),
item_type="hotel",
item_reason="客户拜访住宿",
item_location="深圳",
item_amount=Decimal("1880.00"),
invoice_id="INV-HOTEL-001",
),
ExpenseClaimItem(
item_date=date(2026, 5, 6),
item_type="transport",
item_reason="往返交通",
item_location="深圳",
item_amount=Decimal("1400.00"),
invoice_id="INV-TRANS-009",
),
]
claim_2 = ExpenseClaim(
claim_no="EXP-202605-002",
employee_name="李四",
department_name="华东销售部",
project_code="PRJ-SALES-02",
expense_type="meal",
reason="客户路演餐费",
location="上海",
amount=Decimal("860.00"),
currency="CNY",
invoice_count=1,
occurred_at=datetime(2026, 5, 8, 12, 0, tzinfo=UTC),
submitted_at=datetime(2026, 5, 8, 18, 30, tzinfo=UTC),
status="approved",
approval_stage="completed",
risk_flags_json=[],
)
claim_3 = ExpenseClaim(
claim_no="EXP-202605-003",
employee_name="王五",
department_name="市场品牌部",
project_code="PRJ-MKT-08",
expense_type="travel",
reason="市场活动会务差旅",
location="北京",
amount=Decimal("3280.00"),
currency="CNY",
invoice_count=2,
occurred_at=datetime(2026, 5, 6, 11, 30, tzinfo=UTC),
submitted_at=datetime(2026, 5, 8, 9, 10, tzinfo=UTC),
status="review",
approval_stage="risk_check",
risk_flags_json=["duplicate_expense"],
)
ar_records = [
AccountsReceivableRecord(
receivable_no="AR-202605-001",
customer_id="CUS-A",
customer_name="客户A",
contract_no="CTR-AR-1001",
invoice_no="INV-AR-9001",
amount_receivable=Decimal("120000.00"),
amount_received=Decimal("70000.00"),
amount_outstanding=Decimal("50000.00"),
currency="CNY",
posting_date=date(2026, 4, 1),
due_date=date(2026, 4, 30),
aging_days=11,
status="partial",
risk_flags_json=[],
),
AccountsReceivableRecord(
receivable_no="AR-202605-002",
customer_id="CUS-B",
customer_name="客户B",
contract_no="CTR-AR-1002",
invoice_no="INV-AR-9002",
amount_receivable=Decimal("88000.00"),
amount_received=Decimal("10000.00"),
amount_outstanding=Decimal("78000.00"),
currency="CNY",
posting_date=date(2026, 3, 15),
due_date=date(2026, 4, 15),
aging_days=26,
status="overdue",
risk_flags_json=["ar_overdue"],
),
]
ap_records = [
AccountsPayableRecord(
payable_no="AP-202605-001",
vendor_id="VEN-A",
vendor_name="供应商A",
invoice_no="INV-AP-5001",
amount_payable=Decimal("43000.00"),
amount_paid=Decimal("10000.00"),
amount_outstanding=Decimal("33000.00"),
currency="CNY",
posting_date=date(2026, 4, 20),
due_date=date(2026, 5, 12),
aging_days=0,
status="scheduled",
risk_flags_json=[],
),
AccountsPayableRecord(
payable_no="AP-202605-002",
vendor_id="VEN-B",
vendor_name="供应商B",
invoice_no="INV-AP-5002",
amount_payable=Decimal("96000.00"),
amount_paid=Decimal("0.00"),
amount_outstanding=Decimal("96000.00"),
currency="CNY",
posting_date=date(2026, 4, 10),
due_date=date(2026, 5, 5),
aging_days=6,
status="overdue",
risk_flags_json=["ap_overdue"],
),
]
self.db.add_all([claim_1, claim_2, claim_3, *ar_records, *ap_records])
def _seed_runs_and_logs(self) -> None:
if self.db.scalar(select(AgentRun.id).limit(1)) is not None:
return
task_asset = self.db.scalar(
select(AgentAsset).where(AgentAsset.code == "task.hermes.daily_risk_scan")
)
user_run = AgentRun(
run_id="run_user_20260511_001",
agent=AgentName.USER_AGENT.value,
source=AgentRunSource.USER_MESSAGE.value,
user_id="emp_001",
task_id=None,
ontology_json={"scenario": "expense", "intent": "query"},
route_json={"selected_agent": AgentName.USER_AGENT.value, "route_reason": "user query"},
permission_level=AgentPermissionLevel.READ.value,
status=AgentRunStatus.SUCCEEDED.value,
result_summary="已返回本周报销金额和风险摘要。",
started_at=datetime(2026, 5, 11, 8, 35, tzinfo=UTC),
finished_at=datetime(2026, 5, 11, 8, 35, 2, tzinfo=UTC),
)
hermes_run = AgentRun(
run_id="run_hermes_20260511_001",
agent=AgentName.HERMES.value,
source=AgentRunSource.SCHEDULE.value,
user_id=None,
task_id=task_asset.id if task_asset else None,
ontology_json={"scenario": "expense", "intent": "risk_check"},
route_json={
"selected_agent": AgentName.HERMES.value,
"route_reason": "scheduled risk scan",
},
permission_level=AgentPermissionLevel.READ.value,
status=AgentRunStatus.SUCCEEDED.value,
result_summary="Hermes 已生成今日风险巡检摘要。",
started_at=datetime(2026, 5, 11, 9, 0, tzinfo=UTC),
finished_at=datetime(2026, 5, 11, 9, 0, 4, tzinfo=UTC),
)
blocked_run = AgentRun(
run_id="run_user_20260511_002",
agent=AgentName.ORCHESTRATOR.value,
source=AgentRunSource.USER_MESSAGE.value,
user_id="emp_002",
task_id=None,
ontology_json={"scenario": "accounts_payable", "intent": "operate"},
route_json={
"selected_agent": AgentName.USER_AGENT.value,
"route_reason": "payment request",
},
permission_level=AgentPermissionLevel.APPROVAL_REQUIRED.value,
status=AgentRunStatus.BLOCKED.value,
result_summary="动作需要人工确认。",
error_message="直接付款属于高风险动作,已阻断自动执行。",
started_at=datetime(2026, 5, 11, 10, 5, tzinfo=UTC),
finished_at=datetime(2026, 5, 11, 10, 5, 1, tzinfo=UTC),
)
self.db.add_all([user_run, hermes_run, blocked_run])
self.db.flush()
self.db.add_all(
[
AgentToolCall(
run_id=user_run.run_id,
tool_type=AgentToolType.DATABASE.value,
tool_name="expense_claims.lookup",
request_json={"time_range": "this_week", "employee": "all"},
response_json={"claim_count": 3, "total_amount": "7420.00"},
status="succeeded",
duration_ms=48,
),
AgentToolCall(
run_id=hermes_run.run_id,
tool_type=AgentToolType.MCP.value,
tool_name="invoice.verify_mock",
request_json={"claim_no": "EXP-202605-003"},
response_json={
"warning": "external service degraded",
"fallback": "used mock response",
},
status="failed",
duration_ms=132,
error_message="mock upstream timeout",
),
AgentToolCall(
run_id=blocked_run.run_id,
tool_type=AgentToolType.RULE_ENGINE.value,
tool_name="permission.guard",
request_json={"action": "direct_payment"},
response_json={"requires_confirmation": True},
status="succeeded",
duration_ms=5,
),
SemanticParseLog(
run_id=user_run.run_id,
user_id="emp_001",
raw_query="查一下本周报销超标风险",
scenario="expense",
intent="risk_check",
entities_json=[],
time_range_json={"start_date": "2026-05-11", "end_date": "2026-05-17"},
metrics_json=["amount"],
constraints_json=[],
risk_flags_json=["amount_over_limit"],
permission_json={"level": AgentPermissionLevel.READ.value},
confidence=0.93,
),
SemanticParseLog(
run_id=blocked_run.run_id,
user_id="emp_002",
raw_query="帮我直接付款给供应商B",
scenario="accounts_payable",
intent="operate",
entities_json=[{"type": "vendor", "value": "供应商B"}],
time_range_json={},
metrics_json=["amount"],
constraints_json=[],
risk_flags_json=["ap_overdue"],
permission_json={"level": AgentPermissionLevel.APPROVAL_REQUIRED.value},
confidence=0.96,
),
]
)
if self.db.scalar(select(AuditLog.id).limit(1)) is None:
self.db.add_all(
[
AuditLog(
actor="系统初始化",
action="save_rule_markdown",
resource_type="rule",
resource_id="rule.expense.duplicate_expense_check",
before_json=None,
after_json={"version": "v1.0.0"},
request_id="seed-audit-001",
),
AuditLog(
actor="张晓晴",
action="review_rule",
resource_type="rule",
resource_id="rule.expense.duplicate_expense_check",
before_json={"review_status": "pending"},
after_json={"review_status": "approved"},
request_id="seed-audit-002",
),
AuditLog(
actor="系统初始化",
action="activate_rule",
resource_type="rule",
resource_id="rule.expense.duplicate_expense_check",
before_json={"status": "review"},
after_json={"status": "active"},
request_id="seed-audit-003",
),
AuditLog(
actor="Hermes",
action="update_task_status",
resource_type="task",
resource_id="task.hermes.daily_risk_scan",
before_json={"status": "idle"},
after_json={"status": "succeeded"},
request_id="seed-audit-004",
),
]
)
def _top_up_agent_assets(self, existing_codes: set[str]) -> None:
approved_rule = self.db.scalar(
select(AgentAsset).where(AgentAsset.code == "rule.expense.duplicate_expense_check")
)
pending_rule = self.db.scalar(
select(AgentAsset).where(AgentAsset.code == "rule.expense.travel_receipt_requirements")
)
rejected_rule = self.db.scalar(
select(AgentAsset).where(AgentAsset.code == "rule.ap.payment_dual_review")
)
if approved_rule is not None:
self._ensure_asset_version(
approved_rule,
version="v1.1.0",
content=self._markdown_content(
"# 重复报销识别规则\n\n"
"- 检查员工、金额、地点、发生日期是否高度重复。\n"
"- 新增对同项目、同金额、跨单重复提交的识别。\n"
"- 命中后输出 `duplicate_expense` 风险标签。"
),
content_type=AgentAssetContentType.MARKDOWN.value,
change_note="补充跨单重复提交判断。",
created_by="系统初始化",
)
if pending_rule is not None:
self._ensure_asset_version(
pending_rule,
version="v1.0.0",
content=self._markdown_content(
"# 差旅票据完整性规则\n\n"
"- 差旅报销必须具备发票、行程单、住宿凭证。\n"
"- 新增高铁改签和住宿分拆票据的补件说明。\n"
"- 缺失时输出 `invoice_anomaly`。"
),
content_type=AgentAssetContentType.MARKDOWN.value,
change_note="补充差旅特殊票据口径,待审核。",
created_by="高嘉禾",
)
if rejected_rule is not None:
self._ensure_asset_version(
rejected_rule,
version="v0.9.0",
content=self._markdown_content(
"# 付款双人复核规则\n\n"
"- 单笔付款超过阈值时必须双人复核。\n"
"- 新增跨币种付款也进入复核队列。\n"
"- 当前阈值定义仍不清晰,需继续修订。"
),
content_type=AgentAssetContentType.MARKDOWN.value,
change_note="补充跨币种场景,但阈值仍待明确。",
created_by="孙楠",
)
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="应收账龄汇总技能",
description="按客户、账龄和逾期状态汇总应收风险分布。",
domain=AgentAssetDomain.AR.value,
scenario_json=["accounts_receivable", "query", "aging_summary"],
owner="平台研发组",
reviewer="陈硕",
status=AgentAssetStatus.ACTIVE.value,
current_version="v1.0.0",
config_json={"input_schema": ["customer", "aging_bucket", "status"]},
)
self._ensure_asset_version(
asset,
version="v1.0.0",
content=self._json_content(
{
"inputs": ["customer", "aging_bucket", "status"],
"outputs": ["receivable_total", "overdue_total", "customer_count"],
"dependencies": ["database.accounts_receivable"],
}
),
content_type=AgentAssetContentType.JSON.value,
change_note="初始化应收账龄技能快照。",
created_by="系统初始化",
)
if "mcp.ledger.snapshot_mock" not in existing_codes:
asset = self._create_seed_asset(
asset_type=AgentAssetType.MCP.value,
code="mcp.ledger.snapshot_mock",
name="总账快照 Mock 服务",
description="模拟返回应收、应付和费用汇总快照,供 Agent 查询和巡检。",
domain=AgentAssetDomain.SYSTEM.value,
scenario_json=["expense", "accounts_receivable", "accounts_payable"],
owner="平台研发组",
reviewer="周悦宁",
status=AgentAssetStatus.ACTIVE.value,
current_version="v1.0.0",
config_json={"endpoint": "mock://ledger/snapshot", "timeout_ms": 1500},
)
self._ensure_asset_version(
asset,
version="v1.0.0",
content=self._json_content(
{
"service_type": "mock",
"auth_mode": "service_account",
"degrade_strategy": "return_cached_snapshot_with_warning",
}
),
content_type=AgentAssetContentType.JSON.value,
change_note="初始化总账快照 MCP。",
created_by="系统初始化",
)
if "task.hermes.weekly_ar_summary" not in existing_codes:
asset = self._create_seed_asset(
asset_type=AgentAssetType.TASK.value,
code="task.hermes.weekly_ar_summary",
name="Hermes 每周应收账龄汇总",
description="每周汇总逾期应收、账龄分布和客户风险变化。",
domain=AgentAssetDomain.SYSTEM.value,
scenario_json=["schedule", "accounts_receivable", "summary"],
owner="风控与审计部",
reviewer="顾承宇",
status=AgentAssetStatus.ACTIVE.value,
current_version="v1.0.0",
config_json={"cron": "0 10 * * 1", "agent": AgentName.HERMES.value},
)
self._ensure_asset_version(
asset,
version="v1.0.0",
content=self._json_content(
{
"task_type": "weekly_ar_summary",
"schedule": "0 10 * * 1",
"target_agent": AgentName.HERMES.value,
}
),
content_type=AgentAssetContentType.JSON.value,
change_note="初始化应收账龄汇总任务。",
created_by="系统初始化",
)
if "task.hermes.rule_review_digest" not in existing_codes:
asset = self._create_seed_asset(
asset_type=AgentAssetType.TASK.value,
code="task.hermes.rule_review_digest",
name="Hermes 规则待审摘要",
description="每天汇总待审规则、待补样例和被拒规则修订建议。",
domain=AgentAssetDomain.SYSTEM.value,
scenario_json=["schedule", "rule_center", "review_digest"],
owner="风控与审计部",
reviewer="顾承宇",
status=AgentAssetStatus.ACTIVE.value,
current_version="v1.0.0",
config_json={"cron": "0 18 * * *", "agent": AgentName.HERMES.value},
)
self._ensure_asset_version(
asset,
version="v1.0.0",
content=self._json_content(
{
"task_type": "rule_review_digest",
"schedule": "0 18 * * *",
"target_agent": AgentName.HERMES.value,
}
),
content_type=AgentAssetContentType.JSON.value,
change_note="初始化规则待审摘要任务。",
created_by="系统初始化",
)
def _create_seed_asset(
self,
*,
asset_type: str,
code: str,
name: str,
description: str,
domain: str,
scenario_json: list[str],
owner: str,
reviewer: str,
status: str,
current_version: str,
config_json: dict[str, object],
) -> AgentAsset:
asset = AgentAsset(
asset_type=asset_type,
code=code,
name=name,
description=description,
domain=domain,
scenario_json=scenario_json,
owner=owner,
reviewer=reviewer,
status=status,
current_version=current_version,
config_json=config_json,
)
self.db.add(asset)
self.db.flush()
return asset
def _ensure_asset_version(
self,
asset: AgentAsset,
*,
version: str,
content: str,
content_type: str,
change_note: str,
created_by: str,
) -> None:
existing = self.db.scalar(
select(AgentAssetVersion).where(
AgentAssetVersion.asset_id == asset.id,
AgentAssetVersion.version == version,
)
)
if existing is not None:
return
self.db.add(
AgentAssetVersion(
asset_id=asset.id,
version=version,
content=content,
content_type=content_type,
change_note=change_note,
created_by=created_by,
)
)
@staticmethod
def _markdown_content(content: str) -> str:
return content
@staticmethod
def _json_content(content: dict[str, object]) -> str:
return json.dumps(content, ensure_ascii=False, sort_keys=True, indent=2)

View File

@@ -0,0 +1,168 @@
from __future__ import annotations
import uuid
from datetime import UTC, datetime
from typing import Any
from sqlalchemy.orm import Session
from app.core.agent_enums import AgentPermissionLevel, AgentRunStatus
from app.core.logging import get_logger
from app.models.agent_run import AgentRun, AgentToolCall, SemanticParseLog
from app.repositories.agent_run import AgentRunRepository
from app.schemas.agent_run import AgentRunRead, AgentToolCallRead, SemanticParseRead
from app.services.agent_foundation import AgentFoundationService
logger = get_logger("app.services.agent_runs")
class AgentRunService:
def __init__(self, db: Session) -> None:
self.db = db
self.repository = AgentRunRepository(db)
def list_runs(
self,
*,
agent: str | None = None,
status: str | None = None,
source: str | None = None,
limit: int = 20,
) -> list[AgentRunRead]:
self._ensure_ready()
runs = self.repository.list(agent=agent, status=status, source=source, limit=limit)
return [self._serialize_run(item) for item in runs]
def get_run(self, run_id: str) -> AgentRunRead | None:
self._ensure_ready()
run = self.repository.get_by_run_id(run_id)
if run is None:
return None
return self._serialize_run(run)
def create_run(
self,
*,
agent: str,
source: str,
user_id: str | None = None,
task_id: str | None = None,
ontology_json: dict[str, Any] | None = None,
route_json: dict[str, Any] | None = None,
permission_level: str = AgentPermissionLevel.READ.value,
status: str = AgentRunStatus.RUNNING.value,
result_summary: str | None = None,
error_message: str | None = None,
started_at: datetime | None = None,
finished_at: datetime | None = None,
) -> AgentRunRead:
self._ensure_ready()
run = AgentRun(
run_id=f"run_{uuid.uuid4().hex[:16]}",
agent=agent,
source=source,
user_id=user_id,
task_id=task_id,
ontology_json=ontology_json or {},
route_json=route_json or {},
permission_level=permission_level,
status=status,
result_summary=result_summary,
error_message=error_message,
started_at=started_at or datetime.now(UTC),
finished_at=finished_at,
)
created = self.repository.create_run(run)
logger.info("Created agent run id=%s run_id=%s", created.id, created.run_id)
return self._serialize_run(created)
def record_tool_call(
self,
*,
run_id: str,
tool_type: str,
tool_name: str,
request_json: dict[str, Any] | None = None,
response_json: dict[str, Any] | None = None,
status: str,
duration_ms: int = 0,
error_message: str | None = None,
) -> AgentToolCallRead:
self._ensure_ready()
tool_call = AgentToolCall(
run_id=run_id,
tool_type=tool_type,
tool_name=tool_name,
request_json=request_json or {},
response_json=response_json or {},
status=status,
duration_ms=duration_ms,
error_message=error_message,
)
created = self.repository.create_tool_call(tool_call)
logger.info("Recorded tool call run_id=%s tool=%s", run_id, tool_name)
return AgentToolCallRead.model_validate(created)
def record_semantic_parse(
self,
*,
run_id: str,
user_id: str | None,
raw_query: str,
scenario: str,
intent: str,
entities_json: list[Any] | None = None,
time_range_json: dict[str, Any] | None = None,
metrics_json: list[Any] | None = None,
constraints_json: list[Any] | None = None,
risk_flags_json: list[Any] | None = None,
permission_json: dict[str, Any] | None = None,
confidence: float = 0.0,
) -> SemanticParseRead:
self._ensure_ready()
semantic_parse = SemanticParseLog(
run_id=run_id,
user_id=user_id,
raw_query=raw_query,
scenario=scenario,
intent=intent,
entities_json=entities_json or [],
time_range_json=time_range_json or {},
metrics_json=metrics_json or [],
constraints_json=constraints_json or [],
risk_flags_json=risk_flags_json or [],
permission_json=permission_json or {},
confidence=confidence,
)
created = self.repository.create_semantic_parse(semantic_parse)
logger.info(
"Recorded semantic parse run_id=%s scenario=%s intent=%s", run_id, scenario, intent
)
return SemanticParseRead.model_validate(created)
def _ensure_ready(self) -> None:
AgentFoundationService(self.db).ensure_foundation_ready()
@staticmethod
def _serialize_run(run: AgentRun) -> AgentRunRead:
semantic_parse = run.semantic_parse_logs[0] if run.semantic_parse_logs else None
return AgentRunRead(
id=run.id,
run_id=run.run_id,
agent=run.agent,
source=run.source,
user_id=run.user_id,
task_id=run.task_id,
ontology_json=run.ontology_json,
route_json=run.route_json,
permission_level=run.permission_level,
status=run.status,
result_summary=run.result_summary,
error_message=run.error_message,
started_at=run.started_at,
finished_at=run.finished_at,
tool_calls=[AgentToolCallRead.model_validate(item) for item in run.tool_calls],
semantic_parse=SemanticParseRead.model_validate(semantic_parse)
if semantic_parse
else None,
)

View File

@@ -0,0 +1,70 @@
from __future__ import annotations
import uuid
from typing import Any
from sqlalchemy.orm import Session
from app.core.logging import get_logger
from app.models.audit_log import AuditLog
from app.repositories.audit_log import AuditLogRepository
from app.schemas.audit_log import AuditLogRead
from app.services.agent_foundation import AgentFoundationService
logger = get_logger("app.services.audit")
class AuditLogService:
def __init__(self, db: Session) -> None:
self.db = db
self.repository = AuditLogRepository(db)
def list_logs(
self,
*,
resource_type: str | None = None,
resource_id: str | None = None,
action: str | None = None,
limit: int = 50,
) -> list[AuditLogRead]:
self._ensure_ready()
items = self.repository.list(
resource_type=resource_type,
resource_id=resource_id,
action=action,
limit=limit,
)
return [AuditLogRead.model_validate(item) for item in items]
def log_action(
self,
*,
actor: str,
action: str,
resource_type: str,
resource_id: str,
before_json: dict[str, Any] | None = None,
after_json: dict[str, Any] | None = None,
request_id: str | None = None,
) -> AuditLog:
log = AuditLog(
actor=actor,
action=action,
resource_type=resource_type,
resource_id=resource_id,
before_json=before_json,
after_json=after_json,
request_id=request_id or uuid.uuid4().hex,
)
created = self.repository.create(log)
logger.info(
"Created audit log id=%s action=%s resource=%s:%s",
created.id,
created.action,
created.resource_type,
created.resource_id,
)
return created
def _ensure_ready(self) -> None:
AgentFoundationService(self.db).ensure_foundation_ready()