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,163 @@
from __future__ import annotations
from typing import Annotated
from fastapi import APIRouter, Depends, Header, HTTPException, Query, status
from sqlalchemy.orm import Session
from app.api.deps import get_db
from app.schemas.agent_asset import (
AgentAssetCreate,
AgentAssetListItem,
AgentAssetRead,
AgentAssetReviewCreate,
AgentAssetReviewRead,
AgentAssetUpdate,
AgentAssetVersionCreate,
AgentAssetVersionRead,
)
from app.services.agent_assets import AgentAssetService
router = APIRouter(prefix="/agent-assets")
DbSession = Annotated[Session, Depends(get_db)]
def _handle_asset_error(exc: Exception) -> None:
if isinstance(exc, LookupError):
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=str(exc)) from exc
if isinstance(exc, PermissionError):
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(exc)) from exc
if isinstance(exc, ValueError):
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(exc)) from exc
raise exc
@router.get("", response_model=list[AgentAssetListItem])
def list_agent_assets(
db: DbSession,
asset_type: str | None = Query(default=None),
status_value: str | None = Query(default=None, alias="status"),
domain: str | None = Query(default=None),
keyword: str | None = Query(default=None),
) -> list[AgentAssetListItem]:
return AgentAssetService(db).list_assets(
asset_type=asset_type,
status=status_value,
domain=domain,
keyword=keyword,
)
@router.get("/{asset_id}", response_model=AgentAssetRead)
def get_agent_asset(asset_id: str, db: DbSession) -> AgentAssetRead:
asset = AgentAssetService(db).get_asset(asset_id)
if asset is None:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Asset not found")
return asset
@router.post("", response_model=AgentAssetRead, status_code=status.HTTP_201_CREATED)
def create_agent_asset(
payload: AgentAssetCreate,
db: DbSession,
x_actor: Annotated[str | None, Header()] = None,
x_request_id: Annotated[str | None, Header()] = None,
) -> AgentAssetRead:
try:
return AgentAssetService(db).create_asset(
payload,
actor=(x_actor or payload.owner).strip() or "system",
request_id=x_request_id,
)
except Exception as exc:
_handle_asset_error(exc)
@router.patch("/{asset_id}", response_model=AgentAssetRead)
def update_agent_asset(
asset_id: str,
payload: AgentAssetUpdate,
db: DbSession,
x_actor: Annotated[str | None, Header()] = None,
x_request_id: Annotated[str | None, Header()] = None,
) -> AgentAssetRead:
try:
return AgentAssetService(db).update_asset(
asset_id,
payload,
actor=(x_actor or "system").strip() or "system",
request_id=x_request_id,
)
except Exception as exc:
_handle_asset_error(exc)
@router.get("/{asset_id}/versions", response_model=list[AgentAssetVersionRead])
def list_agent_asset_versions(
asset_id: str, db: DbSession, limit: int = Query(default=20, ge=1, le=100)
) -> list[AgentAssetVersionRead]:
try:
return AgentAssetService(db).list_versions(asset_id, limit=limit)
except Exception as exc:
_handle_asset_error(exc)
@router.post(
"/{asset_id}/versions",
response_model=AgentAssetVersionRead,
status_code=status.HTTP_201_CREATED,
)
def create_agent_asset_version(
asset_id: str,
payload: AgentAssetVersionCreate,
db: DbSession,
x_actor: Annotated[str | None, Header()] = None,
x_request_id: Annotated[str | None, Header()] = None,
) -> AgentAssetVersionRead:
try:
return AgentAssetService(db).create_version(
asset_id,
payload,
actor=(x_actor or payload.created_by).strip() or "system",
request_id=x_request_id,
)
except Exception as exc:
_handle_asset_error(exc)
@router.post(
"/{asset_id}/reviews", response_model=AgentAssetReviewRead, status_code=status.HTTP_201_CREATED
)
def create_agent_asset_review(
asset_id: str,
payload: AgentAssetReviewCreate,
db: DbSession,
x_actor: Annotated[str | None, Header()] = None,
x_request_id: Annotated[str | None, Header()] = None,
) -> AgentAssetReviewRead:
try:
return AgentAssetService(db).create_review(
asset_id,
payload,
actor=(x_actor or payload.reviewer).strip() or "system",
request_id=x_request_id,
)
except Exception as exc:
_handle_asset_error(exc)
@router.post("/{asset_id}/activate", response_model=AgentAssetRead)
def activate_agent_asset(
asset_id: str,
db: DbSession,
x_actor: Annotated[str | None, Header()] = None,
x_request_id: Annotated[str | None, Header()] = None,
) -> AgentAssetRead:
try:
return AgentAssetService(db).activate_asset(
asset_id,
actor=(x_actor or "system").strip() or "system",
request_id=x_request_id,
)
except Exception as exc:
_handle_asset_error(exc)

View File

@@ -0,0 +1,34 @@
from __future__ import annotations
from typing import Annotated
from fastapi import APIRouter, Depends, HTTPException, Query, status
from sqlalchemy.orm import Session
from app.api.deps import get_db
from app.schemas.agent_run import AgentRunRead
from app.services.agent_runs import AgentRunService
router = APIRouter(prefix="/agent-runs")
DbSession = Annotated[Session, Depends(get_db)]
@router.get("", response_model=list[AgentRunRead])
def list_agent_runs(
db: DbSession,
agent: str | None = Query(default=None),
status_value: str | None = Query(default=None, alias="status"),
source: str | None = Query(default=None),
limit: int = Query(default=20, ge=1, le=100),
) -> list[AgentRunRead]:
return AgentRunService(db).list_runs(
agent=agent, status=status_value, source=source, limit=limit
)
@router.get("/{run_id}", response_model=AgentRunRead)
def get_agent_run(run_id: str, db: DbSession) -> AgentRunRead:
run = AgentRunService(db).get_run(run_id)
if run is None:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Run not found")
return run

View File

@@ -0,0 +1,29 @@
from __future__ import annotations
from typing import Annotated
from fastapi import APIRouter, Depends, Query
from sqlalchemy.orm import Session
from app.api.deps import get_db
from app.schemas.audit_log import AuditLogRead
from app.services.audit import AuditLogService
router = APIRouter(prefix="/audit-logs")
DbSession = Annotated[Session, Depends(get_db)]
@router.get("", response_model=list[AuditLogRead])
def list_audit_logs(
db: DbSession,
resource_type: str | None = Query(default=None),
resource_id: str | None = Query(default=None),
action: str | None = Query(default=None),
limit: int = Query(default=50, ge=1, le=200),
) -> list[AuditLogRead]:
return AuditLogService(db).list_logs(
resource_type=resource_type,
resource_id=resource_id,
action=action,
limit=limit,
)

View File

@@ -1,18 +1,24 @@
from fastapi import APIRouter
from app.api.v1.endpoints.auth import router as auth_router
from app.api.v1.endpoints.bootstrap import router as bootstrap_router
from app.api.v1.endpoints.employees import router as employees_router
from app.api.v1.endpoints.health import router as health_router
from app.api.v1.endpoints.knowledge import router as knowledge_router
from fastapi import APIRouter
from app.api.v1.endpoints.agent_assets import router as agent_assets_router
from app.api.v1.endpoints.agent_runs import router as agent_runs_router
from app.api.v1.endpoints.audit_logs import router as audit_logs_router
from app.api.v1.endpoints.auth import router as auth_router
from app.api.v1.endpoints.bootstrap import router as bootstrap_router
from app.api.v1.endpoints.employees import router as employees_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.reimbursements import router as reimbursements_router
from app.api.v1.endpoints.settings import router as settings_router
router = APIRouter()
router.include_router(health_router, tags=["health"])
router.include_router(bootstrap_router, tags=["bootstrap"])
router.include_router(auth_router, tags=["auth"])
router.include_router(knowledge_router, tags=["knowledge"])
router.include_router(employees_router, prefix="/employees", tags=["employees"])
router.include_router(reimbursements_router, prefix="/reimbursements", tags=["reimbursements"])
router.include_router(settings_router, tags=["settings"])
router = APIRouter()
router.include_router(health_router, tags=["health"])
router.include_router(bootstrap_router, tags=["bootstrap"])
router.include_router(auth_router, tags=["auth"])
router.include_router(agent_assets_router, tags=["agent-assets"])
router.include_router(agent_runs_router, tags=["agent-runs"])
router.include_router(audit_logs_router, tags=["audit-logs"])
router.include_router(knowledge_router, tags=["knowledge"])
router.include_router(employees_router, prefix="/employees", tags=["employees"])
router.include_router(reimbursements_router, prefix="/reimbursements", tags=["reimbursements"])
router.include_router(settings_router, tags=["settings"])

View File

@@ -0,0 +1,70 @@
from __future__ import annotations
from enum import StrEnum
class AgentAssetType(StrEnum):
RULE = "rule"
SKILL = "skill"
MCP = "mcp"
TASK = "task"
class AgentAssetStatus(StrEnum):
DRAFT = "draft"
REVIEW = "review"
ACTIVE = "active"
DISABLED = "disabled"
class AgentReviewStatus(StrEnum):
PENDING = "pending"
APPROVED = "approved"
REJECTED = "rejected"
class AgentName(StrEnum):
ORCHESTRATOR = "orchestrator"
USER_AGENT = "user_agent"
HERMES = "hermes"
class AgentRunSource(StrEnum):
USER_MESSAGE = "user_message"
SCHEDULE = "schedule"
SYSTEM_EVENT = "system_event"
class AgentPermissionLevel(StrEnum):
READ = "read"
DRAFT_WRITE = "draft_write"
APPROVAL_REQUIRED = "approval_required"
FORBIDDEN = "forbidden"
class AgentAssetContentType(StrEnum):
MARKDOWN = "markdown"
JSON = "json"
class AgentRunStatus(StrEnum):
RUNNING = "running"
SUCCEEDED = "succeeded"
FAILED = "failed"
BLOCKED = "blocked"
class AgentToolType(StrEnum):
MCP = "mcp"
DATABASE = "database"
LLM = "llm"
OCR = "ocr"
RULE_ENGINE = "rule_engine"
class AgentAssetDomain(StrEnum):
EXPENSE = "expense"
AR = "ar"
AP = "ap"
KNOWLEDGE = "knowledge"
SYSTEM = "system"

View File

@@ -1,23 +1,43 @@
from app.db.base_class import Base
from app.models.approval import ApprovalRecord
from app.models.employee_change_log import EmployeeChangeLog
from app.models.employee import Employee
from app.models.organization import OrganizationUnit
from app.models.reimbursement import ReimbursementRequest
from app.models.role import Role
from app.models.system_model_setting import SystemModelSetting
from app.models.system_setting import SystemSetting
from app.models.system_setting_secret import SystemSettingSecret
__all__ = [
"Base",
"ApprovalRecord",
"Employee",
"EmployeeChangeLog",
"OrganizationUnit",
"ReimbursementRequest",
"Role",
"SystemModelSetting",
"SystemSetting",
"SystemSettingSecret",
]
from app.db.base_class import Base
from app.models.agent_asset import AgentAsset, AgentAssetReview, AgentAssetVersion
from app.models.agent_run import AgentRun, AgentToolCall, SemanticParseLog
from app.models.approval import ApprovalRecord
from app.models.audit_log import AuditLog
from app.models.employee_change_log import EmployeeChangeLog
from app.models.employee import Employee
from app.models.financial_record import (
AccountsPayableRecord,
AccountsReceivableRecord,
ExpenseClaim,
ExpenseClaimItem,
)
from app.models.organization import OrganizationUnit
from app.models.reimbursement import ReimbursementRequest
from app.models.role import Role
from app.models.system_model_setting import SystemModelSetting
from app.models.system_setting import SystemSetting
from app.models.system_setting_secret import SystemSettingSecret
__all__ = [
"Base",
"AccountsPayableRecord",
"AccountsReceivableRecord",
"AgentAsset",
"AgentAssetReview",
"AgentAssetVersion",
"AgentRun",
"AgentToolCall",
"ApprovalRecord",
"AuditLog",
"Employee",
"EmployeeChangeLog",
"ExpenseClaim",
"ExpenseClaimItem",
"OrganizationUnit",
"ReimbursementRequest",
"Role",
"SemanticParseLog",
"SystemModelSetting",
"SystemSetting",
"SystemSettingSecret",
]

View File

@@ -3,12 +3,13 @@ from __future__ import annotations
from fastapi import FastAPI
from fastapi.middleware.cors import CORSMiddleware
from app.api.router import api_router
from app.core.config import get_settings
from app.core.logging import get_logger, setup_logging
from app.middleware.logging import AccessLogMiddleware
from app.services.employee import prepare_employee_directory
from app.services.knowledge import prepare_knowledge_library
from app.api.router import api_router
from app.core.config import get_settings
from app.core.logging import get_logger, setup_logging
from app.middleware.logging import AccessLogMiddleware
from app.services.agent_foundation import prepare_agent_foundation
from app.services.employee import prepare_employee_directory
from app.services.knowledge import prepare_knowledge_library
def create_app() -> FastAPI:
@@ -49,11 +50,12 @@ def create_app() -> FastAPI:
return {"message": f"{settings.app_name} is running"}
@app.on_event("startup")
def _on_startup() -> None:
prepare_employee_directory()
prepare_knowledge_library()
logger.info(
"Server ready - host=%s port=%s prefix=%s",
def _on_startup() -> None:
prepare_employee_directory()
prepare_agent_foundation()
prepare_knowledge_library()
logger.info(
"Server ready - host=%s port=%s prefix=%s",
settings.app_host,
settings.app_port,
settings.api_v1_prefix,

View File

@@ -1,21 +1,41 @@
from app.models.approval import ApprovalRecord
from app.models.employee_change_log import EmployeeChangeLog
from app.models.employee import Employee
from app.models.organization import OrganizationUnit
from app.models.reimbursement import ReimbursementRequest
from app.models.role import Role
from app.models.system_model_setting import SystemModelSetting
from app.models.system_setting import SystemSetting
from app.models.system_setting_secret import SystemSettingSecret
__all__ = [
"ApprovalRecord",
"Employee",
"EmployeeChangeLog",
"OrganizationUnit",
"ReimbursementRequest",
"Role",
"SystemModelSetting",
"SystemSetting",
"SystemSettingSecret",
]
from app.models.agent_asset import AgentAsset, AgentAssetReview, AgentAssetVersion
from app.models.agent_run import AgentRun, AgentToolCall, SemanticParseLog
from app.models.approval import ApprovalRecord
from app.models.audit_log import AuditLog
from app.models.employee_change_log import EmployeeChangeLog
from app.models.employee import Employee
from app.models.financial_record import (
AccountsPayableRecord,
AccountsReceivableRecord,
ExpenseClaim,
ExpenseClaimItem,
)
from app.models.organization import OrganizationUnit
from app.models.reimbursement import ReimbursementRequest
from app.models.role import Role
from app.models.system_model_setting import SystemModelSetting
from app.models.system_setting import SystemSetting
from app.models.system_setting_secret import SystemSettingSecret
__all__ = [
"AccountsPayableRecord",
"AccountsReceivableRecord",
"AgentAsset",
"AgentAssetReview",
"AgentAssetVersion",
"AgentRun",
"AgentToolCall",
"ApprovalRecord",
"AuditLog",
"Employee",
"EmployeeChangeLog",
"ExpenseClaim",
"ExpenseClaimItem",
"OrganizationUnit",
"ReimbursementRequest",
"Role",
"SemanticParseLog",
"SystemModelSetting",
"SystemSetting",
"SystemSettingSecret",
]

View File

@@ -0,0 +1,79 @@
from __future__ import annotations
import uuid
from datetime import datetime
from typing import Any
from sqlalchemy import DateTime, ForeignKey, String, Text, UniqueConstraint, func
from sqlalchemy.orm import Mapped, mapped_column, relationship
from sqlalchemy.types import JSON
from app.db.base_class import Base
class AgentAsset(Base):
__tablename__ = "agent_assets"
id: Mapped[str] = mapped_column(String(36), primary_key=True, default=lambda: str(uuid.uuid4()))
asset_type: Mapped[str] = mapped_column(String(20), index=True)
code: Mapped[str] = mapped_column(String(100), unique=True, index=True)
name: Mapped[str] = mapped_column(String(200))
description: Mapped[str] = mapped_column(Text(), default="")
domain: Mapped[str] = mapped_column(String(50), index=True)
scenario_json: Mapped[list[Any]] = mapped_column(JSON, default=list)
owner: Mapped[str] = mapped_column(String(100))
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)
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(
DateTime(timezone=True), server_default=func.now(), onupdate=func.now()
)
versions = relationship(
"AgentAssetVersion",
back_populates="asset",
cascade="all, delete-orphan",
order_by="desc(AgentAssetVersion.created_at)",
)
reviews = relationship(
"AgentAssetReview",
back_populates="asset",
cascade="all, delete-orphan",
order_by="desc(AgentAssetReview.created_at)",
)
scheduled_runs = relationship("AgentRun", back_populates="task_asset")
class AgentAssetVersion(Base):
__tablename__ = "agent_asset_versions"
__table_args__ = (
UniqueConstraint("asset_id", "version", name="uq_agent_asset_versions_asset_version"),
)
id: Mapped[str] = mapped_column(String(36), primary_key=True, default=lambda: str(uuid.uuid4()))
asset_id: Mapped[str] = mapped_column(ForeignKey("agent_assets.id"), index=True)
version: Mapped[str] = mapped_column(String(30))
content: Mapped[str] = mapped_column(Text())
content_type: Mapped[str] = mapped_column(String(20))
change_note: Mapped[str | None] = mapped_column(Text(), nullable=True)
created_by: Mapped[str] = mapped_column(String(100))
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
asset = relationship("AgentAsset", back_populates="versions")
class AgentAssetReview(Base):
__tablename__ = "agent_asset_reviews"
id: Mapped[str] = mapped_column(String(36), primary_key=True, default=lambda: str(uuid.uuid4()))
asset_id: Mapped[str] = mapped_column(ForeignKey("agent_assets.id"), index=True)
version: Mapped[str] = mapped_column(String(30))
reviewer: Mapped[str] = mapped_column(String(100))
review_status: Mapped[str] = mapped_column(String(20), index=True)
review_note: Mapped[str | None] = mapped_column(Text(), nullable=True)
reviewed_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
asset = relationship("AgentAsset", back_populates="reviews")

View File

@@ -0,0 +1,86 @@
from __future__ import annotations
import uuid
from datetime import datetime
from typing import Any
from sqlalchemy import DateTime, Float, ForeignKey, Integer, String, Text, func
from sqlalchemy.orm import Mapped, mapped_column, relationship
from sqlalchemy.types import JSON
from app.db.base_class import Base
class AgentRun(Base):
__tablename__ = "agent_runs"
id: Mapped[str] = mapped_column(String(36), primary_key=True, default=lambda: str(uuid.uuid4()))
run_id: Mapped[str] = mapped_column(String(50), unique=True, index=True)
agent: Mapped[str] = mapped_column(String(30), index=True)
source: Mapped[str] = mapped_column(String(30))
user_id: Mapped[str | None] = mapped_column(String(100), nullable=True)
task_id: Mapped[str | None] = mapped_column(
ForeignKey("agent_assets.id"), nullable=True, index=True
)
ontology_json: Mapped[dict[str, Any]] = mapped_column(JSON, default=dict)
route_json: Mapped[dict[str, Any]] = mapped_column(JSON, default=dict)
permission_level: Mapped[str] = mapped_column(String(30), default="read")
status: Mapped[str] = mapped_column(String(20), index=True)
result_summary: Mapped[str | None] = mapped_column(Text(), nullable=True)
error_message: Mapped[str | None] = mapped_column(Text(), nullable=True)
started_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), server_default=func.now(), index=True
)
finished_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
task_asset = relationship("AgentAsset", back_populates="scheduled_runs")
tool_calls = relationship(
"AgentToolCall",
back_populates="run",
cascade="all, delete-orphan",
order_by="asc(AgentToolCall.created_at)",
)
semantic_parse_logs = relationship(
"SemanticParseLog",
back_populates="run",
cascade="all, delete-orphan",
order_by="asc(SemanticParseLog.created_at)",
)
class AgentToolCall(Base):
__tablename__ = "agent_tool_calls"
id: Mapped[str] = mapped_column(String(36), primary_key=True, default=lambda: str(uuid.uuid4()))
run_id: Mapped[str] = mapped_column(ForeignKey("agent_runs.run_id"), index=True)
tool_type: Mapped[str] = mapped_column(String(30))
tool_name: Mapped[str] = mapped_column(String(100))
request_json: Mapped[dict[str, Any]] = mapped_column(JSON, default=dict)
response_json: Mapped[dict[str, Any]] = mapped_column(JSON, default=dict)
status: Mapped[str] = mapped_column(String(20))
duration_ms: Mapped[int] = mapped_column(Integer, default=0)
error_message: Mapped[str | None] = mapped_column(Text(), nullable=True)
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
run = relationship("AgentRun", back_populates="tool_calls")
class SemanticParseLog(Base):
__tablename__ = "semantic_parse_logs"
id: Mapped[str] = mapped_column(String(36), primary_key=True, default=lambda: str(uuid.uuid4()))
run_id: Mapped[str] = mapped_column(ForeignKey("agent_runs.run_id"), index=True)
user_id: Mapped[str | None] = mapped_column(String(100), nullable=True)
raw_query: Mapped[str] = mapped_column(Text())
scenario: Mapped[str] = mapped_column(String(50), index=True)
intent: Mapped[str] = mapped_column(String(50), index=True)
entities_json: Mapped[list[Any]] = mapped_column(JSON, default=list)
time_range_json: Mapped[dict[str, Any]] = mapped_column(JSON, default=dict)
metrics_json: Mapped[list[Any]] = mapped_column(JSON, default=list)
constraints_json: Mapped[list[Any]] = mapped_column(JSON, default=list)
risk_flags_json: Mapped[list[Any]] = mapped_column(JSON, default=list)
permission_json: Mapped[dict[str, Any]] = mapped_column(JSON, default=dict)
confidence: Mapped[float] = mapped_column(Float, default=0.0)
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
run = relationship("AgentRun", back_populates="semantic_parse_logs")

View File

@@ -0,0 +1,25 @@
from __future__ import annotations
import uuid
from datetime import datetime
from typing import Any
from sqlalchemy import DateTime, String, func
from sqlalchemy.orm import Mapped, mapped_column
from sqlalchemy.types import JSON
from app.db.base_class import Base
class AuditLog(Base):
__tablename__ = "audit_logs"
id: Mapped[str] = mapped_column(String(36), primary_key=True, default=lambda: str(uuid.uuid4()))
actor: Mapped[str] = mapped_column(String(100))
action: Mapped[str] = mapped_column(String(100), index=True)
resource_type: Mapped[str] = mapped_column(String(50), index=True)
resource_id: Mapped[str] = mapped_column(String(100), index=True)
before_json: Mapped[dict[str, Any] | None] = mapped_column(JSON, nullable=True)
after_json: Mapped[dict[str, Any] | None] = mapped_column(JSON, nullable=True)
request_id: Mapped[str] = mapped_column(String(64), index=True)
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())

View File

@@ -0,0 +1,118 @@
from __future__ import annotations
import uuid
from datetime import date, datetime
from decimal import Decimal
from typing import Any
from sqlalchemy import Date, DateTime, ForeignKey, Integer, Numeric, String, Text, func
from sqlalchemy.orm import Mapped, mapped_column, relationship
from sqlalchemy.types import JSON
from app.db.base_class import Base
class ExpenseClaim(Base):
__tablename__ = "expense_claims"
id: Mapped[str] = mapped_column(String(36), primary_key=True, default=lambda: str(uuid.uuid4()))
claim_no: Mapped[str] = mapped_column(String(50), unique=True, index=True)
employee_id: Mapped[str | None] = mapped_column(
ForeignKey("employees.id"), nullable=True, index=True
)
employee_name: Mapped[str] = mapped_column(String(100), index=True)
department_id: Mapped[str | None] = mapped_column(
ForeignKey("organization_units.id"), nullable=True, index=True
)
department_name: Mapped[str] = mapped_column(String(100), index=True)
project_code: Mapped[str | None] = mapped_column(String(50), nullable=True)
expense_type: Mapped[str] = mapped_column(String(50), index=True)
reason: Mapped[str] = mapped_column(Text())
location: Mapped[str] = mapped_column(String(100))
amount: Mapped[Decimal] = mapped_column(Numeric(12, 2))
currency: Mapped[str] = mapped_column(String(10), default="CNY")
invoice_count: Mapped[int] = mapped_column(Integer, default=0)
occurred_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), index=True)
submitted_at: Mapped[datetime | None] = mapped_column(
DateTime(timezone=True), nullable=True, index=True
)
status: Mapped[str] = mapped_column(String(30), index=True)
approval_stage: Mapped[str | None] = mapped_column(String(50), nullable=True)
risk_flags_json: Mapped[list[Any]] = mapped_column(JSON, default=list)
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
updated_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), server_default=func.now(), onupdate=func.now()
)
items = relationship(
"ExpenseClaimItem",
back_populates="claim",
cascade="all, delete-orphan",
order_by="asc(ExpenseClaimItem.item_date)",
)
class ExpenseClaimItem(Base):
__tablename__ = "expense_claim_items"
id: Mapped[str] = mapped_column(String(36), primary_key=True, default=lambda: str(uuid.uuid4()))
claim_id: Mapped[str] = mapped_column(ForeignKey("expense_claims.id"), index=True)
item_date: Mapped[date] = mapped_column(Date(), index=True)
item_type: Mapped[str] = mapped_column(String(50))
item_reason: Mapped[str] = mapped_column(Text())
item_location: Mapped[str] = mapped_column(String(100))
item_amount: Mapped[Decimal] = mapped_column(Numeric(12, 2))
invoice_id: Mapped[str | None] = mapped_column(String(100), nullable=True)
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
updated_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), server_default=func.now(), onupdate=func.now()
)
claim = relationship("ExpenseClaim", back_populates="items")
class AccountsReceivableRecord(Base):
__tablename__ = "accounts_receivable"
id: Mapped[str] = mapped_column(String(36), primary_key=True, default=lambda: str(uuid.uuid4()))
receivable_no: Mapped[str] = mapped_column(String(50), unique=True, index=True)
customer_id: Mapped[str] = mapped_column(String(64), index=True)
customer_name: Mapped[str] = mapped_column(String(120), index=True)
contract_no: Mapped[str | None] = mapped_column(String(100), nullable=True)
invoice_no: Mapped[str | None] = mapped_column(String(100), nullable=True)
amount_receivable: Mapped[Decimal] = mapped_column(Numeric(12, 2))
amount_received: Mapped[Decimal] = mapped_column(Numeric(12, 2))
amount_outstanding: Mapped[Decimal] = mapped_column(Numeric(12, 2))
currency: Mapped[str] = mapped_column(String(10), default="CNY")
posting_date: Mapped[date] = mapped_column(Date(), index=True)
due_date: Mapped[date] = mapped_column(Date(), index=True)
aging_days: Mapped[int] = mapped_column(Integer, default=0)
status: Mapped[str] = mapped_column(String(30), index=True)
risk_flags_json: Mapped[list[Any]] = mapped_column(JSON, default=list)
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
updated_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), server_default=func.now(), onupdate=func.now()
)
class AccountsPayableRecord(Base):
__tablename__ = "accounts_payable"
id: Mapped[str] = mapped_column(String(36), primary_key=True, default=lambda: str(uuid.uuid4()))
payable_no: Mapped[str] = mapped_column(String(50), unique=True, index=True)
vendor_id: Mapped[str] = mapped_column(String(64), index=True)
vendor_name: Mapped[str] = mapped_column(String(120), index=True)
invoice_no: Mapped[str | None] = mapped_column(String(100), nullable=True)
amount_payable: Mapped[Decimal] = mapped_column(Numeric(12, 2))
amount_paid: Mapped[Decimal] = mapped_column(Numeric(12, 2))
amount_outstanding: Mapped[Decimal] = mapped_column(Numeric(12, 2))
currency: Mapped[str] = mapped_column(String(10), default="CNY")
posting_date: Mapped[date] = mapped_column(Date(), index=True)
due_date: Mapped[date] = mapped_column(Date(), index=True)
aging_days: Mapped[int] = mapped_column(Integer, default=0)
status: Mapped[str] = mapped_column(String(30), index=True)
risk_flags_json: Mapped[list[Any]] = mapped_column(JSON, default=list)
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
updated_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), server_default=func.now(), onupdate=func.now()
)

View File

@@ -0,0 +1,110 @@
from __future__ import annotations
from sqlalchemy import or_, select
from sqlalchemy.orm import Session
from app.models.agent_asset import AgentAsset, AgentAssetReview, AgentAssetVersion
class AgentAssetRepository:
def __init__(self, db: Session) -> None:
self.db = db
def list(
self,
*,
asset_type: str | None = None,
status: str | None = None,
domain: str | None = None,
keyword: str | None = None,
) -> list[AgentAsset]:
stmt = select(AgentAsset)
if asset_type:
stmt = stmt.where(AgentAsset.asset_type == asset_type)
if status:
stmt = stmt.where(AgentAsset.status == status)
if domain:
stmt = stmt.where(AgentAsset.domain == domain)
if keyword:
like_keyword = f"%{keyword.strip()}%"
stmt = stmt.where(
or_(
AgentAsset.name.ilike(like_keyword),
AgentAsset.code.ilike(like_keyword),
AgentAsset.description.ilike(like_keyword),
)
)
stmt = stmt.order_by(AgentAsset.updated_at.desc(), AgentAsset.created_at.desc())
return list(self.db.scalars(stmt).all())
def get(self, asset_id: str) -> AgentAsset | None:
return self.db.get(AgentAsset, asset_id)
def get_by_code(self, code: str) -> AgentAsset | None:
stmt = select(AgentAsset).where(AgentAsset.code == code)
return self.db.scalar(stmt)
def list_versions(self, asset_id: str, *, limit: int | None = None) -> list[AgentAssetVersion]:
stmt = (
select(AgentAssetVersion)
.where(AgentAssetVersion.asset_id == asset_id)
.order_by(AgentAssetVersion.created_at.desc())
)
if limit is not None:
stmt = stmt.limit(limit)
return list(self.db.scalars(stmt).all())
def get_version(self, asset_id: str, version: str) -> AgentAssetVersion | None:
stmt = select(AgentAssetVersion).where(
AgentAssetVersion.asset_id == asset_id,
AgentAssetVersion.version == version,
)
return self.db.scalar(stmt)
def list_reviews(self, asset_id: str, *, limit: int | None = None) -> list[AgentAssetReview]:
stmt = (
select(AgentAssetReview)
.where(AgentAssetReview.asset_id == asset_id)
.order_by(AgentAssetReview.created_at.desc())
)
if limit is not None:
stmt = stmt.limit(limit)
return list(self.db.scalars(stmt).all())
def get_review(
self, asset_id: str, version: str, review_status: str | None = None
) -> AgentAssetReview | None:
stmt = select(AgentAssetReview).where(
AgentAssetReview.asset_id == asset_id,
AgentAssetReview.version == version,
)
if review_status:
stmt = stmt.where(AgentAssetReview.review_status == review_status)
stmt = stmt.order_by(AgentAssetReview.created_at.desc())
return self.db.scalar(stmt)
def create_asset(self, asset: AgentAsset) -> AgentAsset:
self.db.add(asset)
self.db.commit()
self.db.refresh(asset)
return asset
def save_asset(self, asset: AgentAsset) -> AgentAsset:
self.db.add(asset)
self.db.commit()
self.db.refresh(asset)
return asset
def create_version(self, version: AgentAssetVersion) -> AgentAssetVersion:
self.db.add(version)
self.db.commit()
self.db.refresh(version)
return version
def create_review(self, review: AgentAssetReview) -> AgentAssetReview:
self.db.add(review)
self.db.commit()
self.db.refresh(review)
return review

View File

@@ -0,0 +1,57 @@
from __future__ import annotations
from sqlalchemy import select
from sqlalchemy.orm import Session
from app.models.agent_run import AgentRun, AgentToolCall, SemanticParseLog
class AgentRunRepository:
def __init__(self, db: Session) -> None:
self.db = db
def list(
self,
*,
agent: str | None = None,
status: str | None = None,
source: str | None = None,
limit: int = 20,
) -> list[AgentRun]:
stmt = select(AgentRun)
if agent:
stmt = stmt.where(AgentRun.agent == agent)
if status:
stmt = stmt.where(AgentRun.status == status)
if source:
stmt = stmt.where(AgentRun.source == source)
stmt = stmt.order_by(AgentRun.started_at.desc()).limit(limit)
return list(self.db.scalars(stmt).all())
def get_by_run_id(self, run_id: str) -> AgentRun | None:
stmt = select(AgentRun).where(AgentRun.run_id == run_id)
return self.db.scalar(stmt)
def create_run(self, run: AgentRun) -> AgentRun:
self.db.add(run)
self.db.commit()
self.db.refresh(run)
return run
def save_run(self, run: AgentRun) -> AgentRun:
self.db.add(run)
self.db.commit()
self.db.refresh(run)
return run
def create_tool_call(self, tool_call: AgentToolCall) -> AgentToolCall:
self.db.add(tool_call)
self.db.commit()
self.db.refresh(tool_call)
return tool_call
def create_semantic_parse(self, semantic_parse: SemanticParseLog) -> SemanticParseLog:
self.db.add(semantic_parse)
self.db.commit()
self.db.refresh(semantic_parse)
return semantic_parse

View File

@@ -0,0 +1,35 @@
from __future__ import annotations
from sqlalchemy import select
from sqlalchemy.orm import Session
from app.models.audit_log import AuditLog
class AuditLogRepository:
def __init__(self, db: Session) -> None:
self.db = db
def list(
self,
*,
resource_type: str | None = None,
resource_id: str | None = None,
action: str | None = None,
limit: int = 50,
) -> list[AuditLog]:
stmt = select(AuditLog)
if resource_type:
stmt = stmt.where(AuditLog.resource_type == resource_type)
if resource_id:
stmt = stmt.where(AuditLog.resource_id == resource_id)
if action:
stmt = stmt.where(AuditLog.action == action)
stmt = stmt.order_by(AuditLog.created_at.desc()).limit(limit)
return list(self.db.scalars(stmt).all())
def create(self, log: AuditLog) -> AuditLog:
self.db.add(log)
self.db.commit()
self.db.refresh(log)
return log

View File

@@ -0,0 +1,115 @@
from __future__ import annotations
from datetime import datetime
from typing import Any
from pydantic import BaseModel, ConfigDict, Field
from app.core.agent_enums import (
AgentAssetContentType,
AgentAssetDomain,
AgentAssetStatus,
AgentAssetType,
AgentReviewStatus,
)
class AgentAssetCreate(BaseModel):
asset_type: AgentAssetType
code: str = Field(min_length=1, max_length=100)
name: str = Field(min_length=1, max_length=200)
description: str = ""
domain: AgentAssetDomain
scenario_json: list[Any] = Field(default_factory=list)
owner: str = Field(min_length=1, max_length=100)
reviewer: str | None = Field(default=None, max_length=100)
status: AgentAssetStatus = AgentAssetStatus.DRAFT
config_json: dict[str, Any] = Field(default_factory=dict)
class AgentAssetUpdate(BaseModel):
name: str | None = Field(default=None, min_length=1, max_length=200)
description: str | None = None
domain: AgentAssetDomain | None = None
scenario_json: list[Any] | None = None
owner: str | None = Field(default=None, min_length=1, max_length=100)
reviewer: str | None = Field(default=None, max_length=100)
status: AgentAssetStatus | None = None
current_version: str | None = Field(default=None, max_length=30)
config_json: dict[str, Any] | None = None
class AgentAssetVersionCreate(BaseModel):
version: str = Field(min_length=1, max_length=30)
content: Any
content_type: AgentAssetContentType
change_note: str | None = None
created_by: str = Field(min_length=1, max_length=100)
class RuleMarkdownUpdate(BaseModel):
version: str = Field(min_length=1, max_length=30)
content: str
change_note: str | None = None
created_by: str = Field(min_length=1, max_length=100)
class AgentAssetReviewCreate(BaseModel):
version: str = Field(min_length=1, max_length=30)
reviewer: str = Field(min_length=1, max_length=100)
review_status: AgentReviewStatus
review_note: str | None = None
class AgentAssetReviewRead(BaseModel):
model_config = ConfigDict(from_attributes=True)
id: str
asset_id: str
version: str
reviewer: str
review_status: str
review_note: str | None
reviewed_at: datetime | None
created_at: datetime
class AgentAssetVersionRead(BaseModel):
model_config = ConfigDict(from_attributes=True)
id: str
asset_id: str
version: str
content: Any
content_type: str
change_note: str | None
created_by: str
created_at: datetime
is_current: bool = False
class AgentAssetListItem(BaseModel):
model_config = ConfigDict(from_attributes=True)
id: str
asset_type: str
code: str
name: str
description: str
domain: str
scenario_json: list[Any]
owner: str
reviewer: str | None
status: str
current_version: str | None
config_json: dict[str, Any]
created_at: datetime
updated_at: datetime
class AgentAssetRead(AgentAssetListItem):
current_version_content: Any | None = None
current_version_content_type: str | None = None
current_version_change_note: str | None = None
recent_versions: list[AgentAssetVersionRead] = Field(default_factory=list)
latest_review: AgentAssetReviewRead | None = None

View File

@@ -0,0 +1,61 @@
from __future__ import annotations
from datetime import datetime
from typing import Any
from pydantic import BaseModel, ConfigDict, Field
class AgentToolCallRead(BaseModel):
model_config = ConfigDict(from_attributes=True)
id: str
run_id: str
tool_type: str
tool_name: str
request_json: dict[str, Any]
response_json: dict[str, Any]
status: str
duration_ms: int
error_message: str | None
created_at: datetime
class SemanticParseRead(BaseModel):
model_config = ConfigDict(from_attributes=True)
id: str
run_id: str
user_id: str | None
raw_query: str
scenario: str
intent: str
entities_json: list[Any]
time_range_json: dict[str, Any]
metrics_json: list[Any]
constraints_json: list[Any]
risk_flags_json: list[Any]
permission_json: dict[str, Any]
confidence: float
created_at: datetime
class AgentRunRead(BaseModel):
model_config = ConfigDict(from_attributes=True)
id: str
run_id: str
agent: str
source: str
user_id: str | None
task_id: str | None
ontology_json: dict[str, Any]
route_json: dict[str, Any]
permission_level: str
status: str
result_summary: str | None
error_message: str | None
started_at: datetime
finished_at: datetime | None
tool_calls: list[AgentToolCallRead] = Field(default_factory=list)
semantic_parse: SemanticParseRead | None = None

View File

@@ -0,0 +1,20 @@
from __future__ import annotations
from datetime import datetime
from typing import Any
from pydantic import BaseModel, ConfigDict
class AuditLogRead(BaseModel):
model_config = ConfigDict(from_attributes=True)
id: str
actor: str
action: str
resource_type: str
resource_id: str
before_json: dict[str, Any] | None
after_json: dict[str, Any] | None
request_id: str
created_at: datetime

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

View File

@@ -8,6 +8,9 @@ src/app/api/router.py
src/app/api/v1/__init__.py
src/app/api/v1/router.py
src/app/api/v1/endpoints/__init__.py
src/app/api/v1/endpoints/agent_assets.py
src/app/api/v1/endpoints/agent_runs.py
src/app/api/v1/endpoints/audit_logs.py
src/app/api/v1/endpoints/auth.py
src/app/api/v1/endpoints/bootstrap.py
src/app/api/v1/endpoints/employees.py
@@ -17,6 +20,7 @@ src/app/api/v1/endpoints/reimbursements.py
src/app/api/v1/endpoints/settings.py
src/app/core/__init__.py
src/app/core/admin_secret.py
src/app/core/agent_enums.py
src/app/core/bootstrap.py
src/app/core/config.py
src/app/core/logging.py
@@ -29,9 +33,13 @@ src/app/db/session.py
src/app/middleware/__init__.py
src/app/middleware/logging.py
src/app/models/__init__.py
src/app/models/agent_asset.py
src/app/models/agent_run.py
src/app/models/approval.py
src/app/models/audit_log.py
src/app/models/employee.py
src/app/models/employee_change_log.py
src/app/models/financial_record.py
src/app/models/organization.py
src/app/models/reimbursement.py
src/app/models/role.py
@@ -39,10 +47,16 @@ src/app/models/system_model_setting.py
src/app/models/system_setting.py
src/app/models/system_setting_secret.py
src/app/repositories/__init__.py
src/app/repositories/agent_asset.py
src/app/repositories/agent_run.py
src/app/repositories/audit_log.py
src/app/repositories/employee.py
src/app/repositories/reimbursement.py
src/app/repositories/settings.py
src/app/schemas/__init__.py
src/app/schemas/agent_asset.py
src/app/schemas/agent_run.py
src/app/schemas/audit_log.py
src/app/schemas/auth.py
src/app/schemas/bootstrap.py
src/app/schemas/employee.py
@@ -50,9 +64,14 @@ src/app/schemas/knowledge.py
src/app/schemas/reimbursement.py
src/app/schemas/settings.py
src/app/services/__init__.py
src/app/services/agent_assets.py
src/app/services/agent_foundation.py
src/app/services/agent_runs.py
src/app/services/audit.py
src/app/services/auth.py
src/app/services/employee.py
src/app/services/employee_seed.py
src/app/services/hermes_sync.py
src/app/services/knowledge.py
src/app/services/model_connectivity.py
src/app/services/reimbursement.py
@@ -62,9 +81,14 @@ src/x_financial_server.egg-info/SOURCES.txt
src/x_financial_server.egg-info/dependency_links.txt
src/x_financial_server.egg-info/requires.txt
src/x_financial_server.egg-info/top_level.txt
tests/test_agent_asset_service.py
tests/test_agent_foundation_endpoints.py
tests/test_auth_service.py
tests/test_config_settings_reload.py
tests/test_employee_service.py
tests/test_env_file_precedence.py
tests/test_imports.py
tests/test_knowledge_onlyoffice_config.py
tests/test_server_start_dependencies.py
tests/test_settings_persistence.py
tests/test_settings_service.py
tests/test_settings_service.py

View File

@@ -0,0 +1,186 @@
from __future__ import annotations
import uuid
import pytest
from sqlalchemy import create_engine
from sqlalchemy.orm import Session, sessionmaker
from sqlalchemy.pool import StaticPool
from app.core.agent_enums import (
AgentAssetContentType,
AgentAssetDomain,
AgentAssetStatus,
AgentAssetType,
AgentName,
AgentReviewStatus,
AgentRunSource,
AgentRunStatus,
)
from app.db.base import Base
from app.schemas.agent_asset import (
AgentAssetCreate,
AgentAssetReviewCreate,
AgentAssetVersionCreate,
)
from app.services.agent_assets import AgentAssetService
from app.services.agent_runs import AgentRunService
from app.services.audit import AuditLogService
def build_session() -> Session:
engine = create_engine(
"sqlite+pysqlite:///:memory:",
connect_args={"check_same_thread": False},
poolclass=StaticPool,
)
Base.metadata.create_all(bind=engine)
session_factory = sessionmaker(bind=engine, autoflush=False, autocommit=False)
return session_factory()
def test_agent_asset_service_seeds_assets_and_enforces_review_before_activation() -> None:
with build_session() as db:
service = AgentAssetService(db)
rules = service.list_assets(asset_type=AgentAssetType.RULE.value)
assert len(rules) >= 3
pending_rule = next(item for item in rules if item.status == AgentAssetStatus.REVIEW.value)
with pytest.raises(PermissionError):
service.activate_asset(pending_rule.id, actor="pytest")
def test_agent_asset_service_seeds_all_foundation_asset_types() -> None:
with build_session() as db:
service = AgentAssetService(db)
assert len(service.list_assets(asset_type=AgentAssetType.RULE.value)) >= 3
assert len(service.list_assets(asset_type=AgentAssetType.SKILL.value)) >= 2
assert len(service.list_assets(asset_type=AgentAssetType.MCP.value)) >= 2
assert len(service.list_assets(asset_type=AgentAssetType.TASK.value)) >= 3
def test_agent_asset_service_can_activate_rule_after_review() -> None:
with build_session() as db:
service = AgentAssetService(db)
created = service.create_asset(
AgentAssetCreate(
asset_type=AgentAssetType.RULE,
code=f"rule.test.{uuid.uuid4().hex[:8]}",
name="测试规则",
description="用于测试审核和上线流程。",
domain=AgentAssetDomain.EXPENSE,
scenario_json=["expense", "risk_check"],
owner="pytest",
reviewer="reviewer",
status=AgentAssetStatus.DRAFT,
config_json={"enabled": False},
),
actor="pytest",
)
service.create_version(
created.id,
AgentAssetVersionCreate(
version="v1.0.0",
content="# 测试规则\n\n- 仅用于测试。",
content_type=AgentAssetContentType.MARKDOWN,
change_note="初始化版本",
created_by="pytest",
),
actor="pytest",
)
service.create_review(
created.id,
AgentAssetReviewCreate(
version="v1.0.0",
reviewer="reviewer",
review_status=AgentReviewStatus.APPROVED,
review_note="可以上线",
),
actor="reviewer",
)
activated = service.activate_asset(created.id, actor="reviewer")
assert activated.status == AgentAssetStatus.ACTIVE.value
assert activated.current_version == "v1.0.0"
assert activated.latest_review is not None
assert activated.latest_review.review_status == AgentReviewStatus.APPROVED.value
def test_agent_asset_service_returns_recent_versions_for_rule_detail() -> None:
with build_session() as db:
service = AgentAssetService(db)
rule = next(
item
for item in service.list_assets(asset_type=AgentAssetType.RULE.value)
if item.code == "rule.expense.duplicate_expense_check"
)
detail = service.get_asset(rule.id)
assert detail is not None
assert detail.current_version == "v1.1.0"
assert detail.current_version_content_type == AgentAssetContentType.MARKDOWN.value
assert isinstance(detail.current_version_content, str)
assert len(detail.recent_versions) >= 2
assert any(item.is_current for item in detail.recent_versions)
assert {item.version for item in detail.recent_versions} >= {"v1.0.0", "v1.1.0"}
def test_agent_run_service_lists_seeded_trace_data() -> None:
with build_session() as db:
service = AgentRunService(db)
runs = service.list_runs()
assert len(runs) >= 3
assert any(item.tool_calls for item in runs)
assert any(item.semantic_parse is not None for item in runs)
def test_agent_run_service_creates_run_and_persists_error_message() -> None:
with build_session() as db:
service = AgentRunService(db)
created = service.create_run(
agent=AgentName.ORCHESTRATOR.value,
source=AgentRunSource.SYSTEM_EVENT.value,
status=AgentRunStatus.FAILED.value,
error_message="simulated failure",
result_summary="failed to route request",
)
fetched = service.get_run(created.run_id)
assert fetched is not None
assert fetched.run_id.startswith("run_")
assert fetched.status == AgentRunStatus.FAILED.value
assert fetched.error_message == "simulated failure"
assert fetched.result_summary == "failed to route request"
def test_agent_asset_creation_writes_audit_log() -> None:
with build_session() as db:
service = AgentAssetService(db)
created = service.create_asset(
AgentAssetCreate(
asset_type=AgentAssetType.SKILL,
code=f"skill.test.{uuid.uuid4().hex[:8]}",
name="测试技能",
description="用于测试审计日志写入。",
domain=AgentAssetDomain.KNOWLEDGE,
scenario_json=["knowledge", "query"],
owner="pytest",
reviewer="reviewer",
status=AgentAssetStatus.DRAFT,
config_json={"enabled": True},
),
actor="pytest",
)
logs = AuditLogService(db).list_logs(resource_id=created.id)
assert any(item.action == "create_agent_asset" for item in logs)

View File

@@ -0,0 +1,92 @@
from __future__ import annotations
from collections.abc import Generator
from fastapi.testclient import TestClient
from sqlalchemy import create_engine
from sqlalchemy.orm import Session, sessionmaker
from sqlalchemy.pool import StaticPool
from app.api.deps import get_db
from app.core.agent_enums import AgentAssetStatus
from app.db.base import Base
from app.main import create_app
from app.services.agent_assets import AgentAssetService
def build_client() -> tuple[TestClient, sessionmaker[Session]]:
engine = create_engine(
"sqlite+pysqlite:///:memory:",
connect_args={"check_same_thread": False},
poolclass=StaticPool,
)
Base.metadata.create_all(bind=engine)
session_factory = sessionmaker(bind=engine, autoflush=False, autocommit=False)
app = create_app()
def override_db() -> Generator[Session, None, None]:
db = session_factory()
try:
yield db
finally:
db.close()
app.dependency_overrides[get_db] = override_db
return TestClient(app), session_factory
def test_list_agent_assets_endpoint_returns_seeded_items() -> None:
client, _ = build_client()
response = client.get("/api/v1/agent-assets", params={"asset_type": "rule"})
assert response.status_code == 200
payload = response.json()
assert payload
assert all(item["asset_type"] == "rule" for item in payload)
def test_get_agent_asset_detail_endpoint_returns_version_history() -> None:
client, _ = build_client()
list_response = client.get("/api/v1/agent-assets", params={"asset_type": "rule"})
asset_id = list_response.json()[0]["id"]
response = client.get(f"/api/v1/agent-assets/{asset_id}")
assert response.status_code == 200
payload = response.json()
assert payload["recent_versions"]
assert payload["current_version_content_type"] == "markdown"
assert len(payload["recent_versions"]) >= 2
def test_activate_pending_rule_endpoint_is_blocked() -> None:
client, session_factory = build_client()
with session_factory() as db:
pending_rule = next(
item
for item in AgentAssetService(db).list_assets(asset_type="rule")
if item.status == AgentAssetStatus.REVIEW.value
)
response = client.post(
f"/api/v1/agent-assets/{pending_rule.id}/activate",
headers={"x-actor": "pytest"},
)
assert response.status_code == 400
assert "审核" in response.json()["detail"]
def test_list_audit_logs_endpoint_returns_seeded_logs() -> None:
client, _ = build_client()
response = client.get("/api/v1/audit-logs")
assert response.status_code == 200
payload = response.json()
assert payload
assert any(item["action"] == "review_rule" for item in payload)