feat: deliver agent foundation day 1
This commit is contained in:
163
server/src/app/api/v1/endpoints/agent_assets.py
Normal file
163
server/src/app/api/v1/endpoints/agent_assets.py
Normal 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)
|
||||
34
server/src/app/api/v1/endpoints/agent_runs.py
Normal file
34
server/src/app/api/v1/endpoints/agent_runs.py
Normal 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
|
||||
29
server/src/app/api/v1/endpoints/audit_logs.py
Normal file
29
server/src/app/api/v1/endpoints/audit_logs.py
Normal 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,
|
||||
)
|
||||
@@ -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"])
|
||||
|
||||
70
server/src/app/core/agent_enums.py
Normal file
70
server/src/app/core/agent_enums.py
Normal 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"
|
||||
@@ -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",
|
||||
]
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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",
|
||||
]
|
||||
|
||||
79
server/src/app/models/agent_asset.py
Normal file
79
server/src/app/models/agent_asset.py
Normal 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")
|
||||
86
server/src/app/models/agent_run.py
Normal file
86
server/src/app/models/agent_run.py
Normal 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")
|
||||
25
server/src/app/models/audit_log.py
Normal file
25
server/src/app/models/audit_log.py
Normal 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())
|
||||
118
server/src/app/models/financial_record.py
Normal file
118
server/src/app/models/financial_record.py
Normal 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()
|
||||
)
|
||||
110
server/src/app/repositories/agent_asset.py
Normal file
110
server/src/app/repositories/agent_asset.py
Normal 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
|
||||
57
server/src/app/repositories/agent_run.py
Normal file
57
server/src/app/repositories/agent_run.py
Normal 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
|
||||
35
server/src/app/repositories/audit_log.py
Normal file
35
server/src/app/repositories/audit_log.py
Normal 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
|
||||
115
server/src/app/schemas/agent_asset.py
Normal file
115
server/src/app/schemas/agent_asset.py
Normal 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
|
||||
61
server/src/app/schemas/agent_run.py
Normal file
61
server/src/app/schemas/agent_run.py
Normal 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
|
||||
20
server/src/app/schemas/audit_log.py
Normal file
20
server/src/app/schemas/audit_log.py
Normal 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
|
||||
407
server/src/app/services/agent_assets.py
Normal file
407
server/src/app/services/agent_assets.py
Normal 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,
|
||||
}
|
||||
977
server/src/app/services/agent_foundation.py
Normal file
977
server/src/app/services/agent_foundation.py
Normal 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)
|
||||
168
server/src/app/services/agent_runs.py
Normal file
168
server/src/app/services/agent_runs.py
Normal 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,
|
||||
)
|
||||
70
server/src/app/services/audit.py
Normal file
70
server/src/app/services/audit.py
Normal 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()
|
||||
@@ -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
|
||||
186
server/tests/test_agent_asset_service.py
Normal file
186
server/tests/test_agent_asset_service.py
Normal 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)
|
||||
92
server/tests/test_agent_foundation_endpoints.py
Normal file
92
server/tests/test_agent_foundation_endpoints.py
Normal 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)
|
||||
Reference in New Issue
Block a user