feat: 扩展风险规则体系、审批动态路由与预算中心列表化改造
- 新增 25+ 条风险规则(预算/报销/申请/通用类),完善风险规则模拟与反馈发布机制 - 引入费用审批动态路由、平台风险分级、预审与风险阶段管理 - 预算中心列表化改造,优化票据夹仪表盘与数字员工工作看板 - 新增 Hermes 风险线索收集器、Agent 链路追踪中心 - 扩展数字员工能力库(18 个领域 Skill)与交通费用自动预估 - 完善报销申请快速预览、权限控制与前端测试覆盖
This commit is contained in:
@@ -2,21 +2,29 @@ from __future__ import annotations
|
||||
|
||||
from typing import Annotated, NoReturn
|
||||
|
||||
from fastapi import APIRouter, Depends, Header, HTTPException, status
|
||||
from fastapi import APIRouter, Depends, Header, HTTPException, Query, status
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.api.deps import (
|
||||
CurrentUserContext,
|
||||
get_current_user,
|
||||
get_db,
|
||||
require_rule_editor_user,
|
||||
require_rule_reviewer_user,
|
||||
)
|
||||
from app.schemas.agent_asset import (
|
||||
AgentAssetRead,
|
||||
AgentAssetRiskRuleDraftUpdate,
|
||||
AgentAssetRiskRuleFeedbackCreate,
|
||||
AgentAssetRiskRuleFeedbackRead,
|
||||
AgentAssetRiskRuleRegenerateRequest,
|
||||
AgentAssetRiskRuleRevisionCreate,
|
||||
AgentAssetRiskRuleTemplateGroupRead,
|
||||
)
|
||||
from app.services.agent_asset_risk_rule_regeneration import AgentAssetRiskRuleRegenerationService
|
||||
from app.services.agent_asset_risk_rule_revision import AgentAssetRiskRuleRevisionService
|
||||
from app.services.agent_assets import AgentAssetService
|
||||
from app.services.risk_rule_template_catalog import list_risk_rule_template_groups
|
||||
|
||||
router = APIRouter(prefix="/agent-assets")
|
||||
DbSession = Annotated[Session, Depends(get_db)]
|
||||
@@ -29,6 +37,8 @@ RequestIdHeader = Annotated[
|
||||
Header(description="外部请求 ID,用于串联审计日志和上游调用链。"),
|
||||
]
|
||||
RuleEditorUser = Annotated[CurrentUserContext, Depends(require_rule_editor_user)]
|
||||
RuleReviewerUser = Annotated[CurrentUserContext, Depends(require_rule_reviewer_user)]
|
||||
CurrentUser = Annotated[CurrentUserContext, Depends(get_current_user)]
|
||||
|
||||
|
||||
def _handle_asset_error(exc: Exception) -> NoReturn:
|
||||
@@ -50,6 +60,16 @@ def _read_asset(db: Session, asset_id: str) -> AgentAssetRead:
|
||||
return asset
|
||||
|
||||
|
||||
@router.get(
|
||||
"/risk-rules/templates",
|
||||
response_model=list[AgentAssetRiskRuleTemplateGroupRead],
|
||||
summary="查询常见费控风险规则模板",
|
||||
description="返回模板分组、默认自然语言、字段清单和 DSL 样例;模板只用于预填,不绕过通用生成链路。",
|
||||
)
|
||||
def list_risk_rule_templates(_: CurrentUser) -> list[AgentAssetRiskRuleTemplateGroupRead]:
|
||||
return list_risk_rule_template_groups()
|
||||
|
||||
|
||||
@router.patch(
|
||||
"/{asset_id}/risk-rules/draft",
|
||||
response_model=AgentAssetRead,
|
||||
@@ -101,3 +121,80 @@ def create_risk_rule_revision(
|
||||
return _read_asset(db, asset_id)
|
||||
except Exception as exc:
|
||||
_handle_asset_error(exc)
|
||||
|
||||
|
||||
@router.post(
|
||||
"/{asset_id}/risk-rules/regenerate",
|
||||
response_model=AgentAssetRead,
|
||||
summary="重新生成风险规则执行模板",
|
||||
description="把未上线草稿或已上线规则的修订草稿重新解释为 DSL、流程图、风险评分和业务说明。",
|
||||
)
|
||||
def regenerate_risk_rule(
|
||||
asset_id: str,
|
||||
payload: AgentAssetRiskRuleRegenerateRequest,
|
||||
current_user: RuleEditorUser,
|
||||
db: DbSession,
|
||||
x_actor: ActorHeader = None,
|
||||
x_request_id: RequestIdHeader = None,
|
||||
) -> AgentAssetRead:
|
||||
try:
|
||||
AgentAssetRiskRuleRegenerationService(db).regenerate(
|
||||
asset_id,
|
||||
payload,
|
||||
actor=_actor_name(current_user, x_actor),
|
||||
request_id=x_request_id,
|
||||
)
|
||||
return _read_asset(db, asset_id)
|
||||
except Exception as exc:
|
||||
_handle_asset_error(exc)
|
||||
|
||||
|
||||
@router.post(
|
||||
"/{asset_id}/risk-rules/feedback",
|
||||
response_model=AgentAssetRiskRuleFeedbackRead,
|
||||
status_code=status.HTTP_201_CREATED,
|
||||
summary="提交风险规则误判或漏判反馈",
|
||||
description="普通用户可提交规则误判、漏判或改进反馈;该接口只记录反馈,不直接修改规则。",
|
||||
)
|
||||
def create_risk_rule_feedback(
|
||||
asset_id: str,
|
||||
payload: AgentAssetRiskRuleFeedbackCreate,
|
||||
current_user: CurrentUser,
|
||||
db: DbSession,
|
||||
x_actor: ActorHeader = None,
|
||||
x_request_id: RequestIdHeader = None,
|
||||
) -> AgentAssetRiskRuleFeedbackRead:
|
||||
try:
|
||||
return AgentAssetService(db).create_risk_rule_feedback(
|
||||
asset_id,
|
||||
payload,
|
||||
actor=_actor_name(current_user, x_actor),
|
||||
request_id=x_request_id,
|
||||
)
|
||||
except Exception as exc:
|
||||
_handle_asset_error(exc)
|
||||
|
||||
|
||||
@router.get(
|
||||
"/{asset_id}/risk-rules/feedback",
|
||||
response_model=list[AgentAssetRiskRuleFeedbackRead],
|
||||
summary="查询风险规则反馈记录",
|
||||
description="高级财务人员或 admin 管理员查看指定风险规则的误判、漏判和改进反馈记录。",
|
||||
)
|
||||
def list_risk_rule_feedback(
|
||||
asset_id: str,
|
||||
_: RuleReviewerUser,
|
||||
db: DbSession,
|
||||
version: Annotated[str | None, Query(max_length=30)] = None,
|
||||
status_value: Annotated[str | None, Query(alias="status", max_length=30)] = None,
|
||||
limit: Annotated[int, Query(ge=1, le=200)] = 50,
|
||||
) -> list[AgentAssetRiskRuleFeedbackRead]:
|
||||
try:
|
||||
return AgentAssetService(db).list_risk_rule_feedback(
|
||||
asset_id,
|
||||
version=version,
|
||||
status=status_value,
|
||||
limit=limit,
|
||||
)
|
||||
except Exception as exc:
|
||||
_handle_asset_error(exc)
|
||||
|
||||
84
server/src/app/api/v1/endpoints/agent_traces.py
Normal file
84
server/src/app/api/v1/endpoints/agent_traces.py
Normal file
@@ -0,0 +1,84 @@
|
||||
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, require_admin_user
|
||||
from app.schemas.agent_trace import (
|
||||
AgentConversationTraceRead,
|
||||
AgentTraceDetailRead,
|
||||
AgentTraceListItem,
|
||||
)
|
||||
from app.schemas.common import ErrorResponse
|
||||
from app.services.agent_traces import AgentTraceService
|
||||
|
||||
router = APIRouter(prefix="/agent-traces")
|
||||
DbSession = Annotated[Session, Depends(get_db)]
|
||||
|
||||
|
||||
@router.get(
|
||||
"",
|
||||
response_model=list[AgentTraceListItem],
|
||||
summary="查询 Agent Trace 列表",
|
||||
description="按 Agent、状态、来源、会话或关键字查询 Agent 链路追踪记录。",
|
||||
)
|
||||
def list_agent_traces(
|
||||
db: DbSession,
|
||||
_: Annotated[object, Depends(require_admin_user)],
|
||||
agent: Annotated[str | None, Query(description="Agent 名称过滤。")] = None,
|
||||
status_value: Annotated[
|
||||
str | None,
|
||||
Query(alias="status", description="运行状态过滤。"),
|
||||
] = None,
|
||||
source: Annotated[str | None, Query(description="运行来源过滤。")] = None,
|
||||
conversation_id: Annotated[str | None, Query(description="会话 ID 过滤。")] = None,
|
||||
keyword: Annotated[str | None, Query(description="Run ID、摘要或语义关键字。")] = None,
|
||||
limit: Annotated[int, Query(ge=1, le=100, description="返回记录上限。")] = 30,
|
||||
) -> list[AgentTraceListItem]:
|
||||
return AgentTraceService(db).list_traces(
|
||||
agent=agent,
|
||||
status=status_value,
|
||||
source=source,
|
||||
conversation_id=conversation_id,
|
||||
keyword=keyword,
|
||||
limit=limit,
|
||||
)
|
||||
|
||||
|
||||
@router.get(
|
||||
"/conversations/{conversation_id}",
|
||||
response_model=AgentConversationTraceRead,
|
||||
summary="读取会话 Agent Trace",
|
||||
description="按 `conversation_id` 返回该会话下多轮运行的 trace 详情。",
|
||||
)
|
||||
def get_conversation_trace(
|
||||
conversation_id: str,
|
||||
db: DbSession,
|
||||
_: Annotated[object, Depends(require_admin_user)],
|
||||
) -> AgentConversationTraceRead:
|
||||
return AgentTraceService(db).get_conversation_trace(conversation_id)
|
||||
|
||||
|
||||
@router.get(
|
||||
"/{run_id}",
|
||||
response_model=AgentTraceDetailRead,
|
||||
summary="读取单次 Agent Trace",
|
||||
description="按 `run_id` 返回运行摘要、事件时间线、语义解析、工具调用和关联会话消息。",
|
||||
responses={
|
||||
status.HTTP_404_NOT_FOUND: {
|
||||
"model": ErrorResponse,
|
||||
"description": "Trace 运行记录不存在。",
|
||||
}
|
||||
},
|
||||
)
|
||||
def get_agent_trace(
|
||||
run_id: str,
|
||||
db: DbSession,
|
||||
_: Annotated[object, Depends(require_admin_user)],
|
||||
) -> AgentTraceDetailRead:
|
||||
trace = AgentTraceService(db).get_trace(run_id)
|
||||
if trace is None:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Agent trace not found")
|
||||
return trace
|
||||
@@ -7,8 +7,10 @@ from fastapi import APIRouter, Depends, Query
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.api.deps import get_db
|
||||
from app.schemas.digital_employee_dashboard import DigitalEmployeeDashboardRead
|
||||
from app.schemas.finance_dashboard import FinanceDashboardRead
|
||||
from app.schemas.system_dashboard import SystemDashboardRead
|
||||
from app.services.digital_employee_dashboard import DigitalEmployeeDashboardService
|
||||
from app.services.finance_dashboard import FinanceDashboardService
|
||||
from app.services.system_dashboard import SystemDashboardService
|
||||
|
||||
@@ -32,6 +34,26 @@ def get_system_dashboard(
|
||||
return SystemDashboardService(db).build_dashboard(days=days)
|
||||
|
||||
|
||||
@router.get(
|
||||
"/digital-employee-dashboard",
|
||||
response_model=DigitalEmployeeDashboardRead,
|
||||
summary="查询数字员工工作看板",
|
||||
description="基于数字员工运行记录和工具调用结果聚合每日工作、技能类型、业务产出和近期执行明细。",
|
||||
)
|
||||
def get_digital_employee_dashboard(
|
||||
db: DbSession,
|
||||
days: Annotated[
|
||||
int,
|
||||
Query(ge=1, le=30, description="统计窗口天数。"),
|
||||
] = 7,
|
||||
limit: Annotated[
|
||||
int,
|
||||
Query(ge=1, le=1000, description="窗口内最多读取的运行记录数。"),
|
||||
] = 300,
|
||||
) -> DigitalEmployeeDashboardRead:
|
||||
return DigitalEmployeeDashboardService(db).build_dashboard(days=days, limit=limit)
|
||||
|
||||
|
||||
@router.get(
|
||||
"/finance-dashboard",
|
||||
response_model=FinanceDashboardRead,
|
||||
|
||||
@@ -5,8 +5,9 @@ from typing import Annotated
|
||||
from fastapi import APIRouter, Depends, HTTPException, status
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.api.deps import get_db
|
||||
from app.api.deps import CurrentUserContext, get_current_user, get_db
|
||||
from app.schemas.auth import (
|
||||
AuthUserRead,
|
||||
LoginRequest,
|
||||
LoginResponse,
|
||||
SessionFinishRequest,
|
||||
@@ -39,6 +40,42 @@ def login(payload: LoginRequest, db: DbSession) -> LoginResponse:
|
||||
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail=str(exc)) from exc
|
||||
|
||||
|
||||
@router.get(
|
||||
"/me",
|
||||
response_model=AuthUserRead,
|
||||
summary="读取当前登录用户",
|
||||
description="根据当前会话请求头刷新前端登录态中的员工姓名、部门、岗位和职级。",
|
||||
)
|
||||
def get_current_auth_user(
|
||||
current_user: Annotated[CurrentUserContext, Depends(get_current_user)],
|
||||
db: DbSession,
|
||||
) -> AuthUserRead:
|
||||
user = AuthService(db).get_user_snapshot(current_user.username)
|
||||
if user is not None:
|
||||
return user
|
||||
|
||||
if current_user.is_admin:
|
||||
name = current_user.name or current_user.username or "系统管理员"
|
||||
return AuthUserRead(
|
||||
username=current_user.username or name,
|
||||
name=name,
|
||||
role="管理员",
|
||||
department=current_user.department_name,
|
||||
departmentName=current_user.department_name,
|
||||
position=current_user.position or "系统管理员",
|
||||
grade=current_user.grade,
|
||||
employeeNo=current_user.employee_no,
|
||||
managerName=current_user.manager_name,
|
||||
costCenter=current_user.cost_center,
|
||||
roleCodes=current_user.role_codes or ["manager"],
|
||||
email=current_user.username if "@" in current_user.username else f"{current_user.username}@local",
|
||||
avatar=name[:1].upper(),
|
||||
isAdmin=True,
|
||||
)
|
||||
|
||||
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="当前登录用户不存在或已停用")
|
||||
|
||||
|
||||
@router.post(
|
||||
"/sessions/{session_id}/finish",
|
||||
response_model=SessionFinishResponse,
|
||||
|
||||
@@ -545,6 +545,34 @@ def delete_expense_claim_item_attachment(
|
||||
return ExpenseClaimAttachmentActionResponse(**payload)
|
||||
|
||||
|
||||
@router.post(
|
||||
"/claims/{claim_id}/pre-review",
|
||||
response_model=ExpenseClaimRead,
|
||||
summary="执行报销单 AI 预审",
|
||||
description="只执行 AI 预审并回写风险结果,不提交到审批流程。",
|
||||
responses={
|
||||
status.HTTP_404_NOT_FOUND: {
|
||||
"model": ErrorResponse,
|
||||
"description": "报销单不存在。",
|
||||
},
|
||||
status.HTTP_400_BAD_REQUEST: {
|
||||
"model": ErrorResponse,
|
||||
"description": "草稿信息不完整或状态不允许预审。",
|
||||
},
|
||||
},
|
||||
)
|
||||
def pre_review_expense_claim(claim_id: str, db: DbSession, current_user: CurrentUser) -> ExpenseClaimRead:
|
||||
service = ExpenseClaimService(db)
|
||||
try:
|
||||
claim = service.pre_review_claim(claim_id, current_user)
|
||||
except ValueError as error:
|
||||
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(error)) from error
|
||||
|
||||
if claim is None:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Claim not found")
|
||||
return claim
|
||||
|
||||
|
||||
@router.post(
|
||||
"/claims/{claim_id}/submit",
|
||||
response_model=ExpenseClaimRead,
|
||||
|
||||
@@ -4,6 +4,7 @@ from app.api.v1.endpoints.agent_asset_risk_rules import router as agent_asset_ri
|
||||
from app.api.v1.endpoints.agent_assets import router as agent_assets_router
|
||||
from app.api.v1.endpoints.agent_feedback import router as agent_feedback_router
|
||||
from app.api.v1.endpoints.agent_runs import router as agent_runs_router
|
||||
from app.api.v1.endpoints.agent_traces import router as agent_traces_router
|
||||
from app.api.v1.endpoints.analytics import router as analytics_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
|
||||
@@ -31,6 +32,7 @@ router.include_router(agent_assets_router, tags=["agent-assets"])
|
||||
router.include_router(agent_asset_risk_rules_router, tags=["agent-assets"])
|
||||
router.include_router(agent_feedback_router, tags=["agent-feedback"])
|
||||
router.include_router(agent_runs_router, tags=["agent-runs"])
|
||||
router.include_router(agent_traces_router, tags=["agent-traces"])
|
||||
router.include_router(analytics_router, tags=["analytics"])
|
||||
router.include_router(audit_logs_router, tags=["audit-logs"])
|
||||
router.include_router(knowledge_router, tags=["knowledge"])
|
||||
|
||||
Reference in New Issue
Block a user