feat: 新增风险图谱算法与系统仪表盘及操作反馈体系
后端新增风险图谱算法模块、风险观察与反馈服务、规则 DSL 校验器和可解释性引擎,完善系统仪表盘和财务仪表盘统计, 优化 agent 运行和编排执行链路,清理旧开发文档,前端新增 系统趋势、负载热力图等多种仪表盘图表组件,完善操作反馈 对话框和工作台日期选择器,优化报销创建和审批详情交互, 补充单元测试覆盖。
This commit is contained in:
103
server/src/app/api/v1/endpoints/agent_asset_risk_rules.py
Normal file
103
server/src/app/api/v1/endpoints/agent_asset_risk_rules.py
Normal file
@@ -0,0 +1,103 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Annotated, NoReturn
|
||||
|
||||
from fastapi import APIRouter, Depends, Header, HTTPException, status
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.api.deps import (
|
||||
CurrentUserContext,
|
||||
get_db,
|
||||
require_rule_editor_user,
|
||||
)
|
||||
from app.schemas.agent_asset import (
|
||||
AgentAssetRead,
|
||||
AgentAssetRiskRuleDraftUpdate,
|
||||
AgentAssetRiskRuleRevisionCreate,
|
||||
)
|
||||
from app.services.agent_asset_risk_rule_revision import AgentAssetRiskRuleRevisionService
|
||||
from app.services.agent_assets import AgentAssetService
|
||||
|
||||
router = APIRouter(prefix="/agent-assets")
|
||||
DbSession = Annotated[Session, Depends(get_db)]
|
||||
ActorHeader = Annotated[
|
||||
str | None,
|
||||
Header(description="审计操作人。未传时使用当前登录用户名称。"),
|
||||
]
|
||||
RequestIdHeader = Annotated[
|
||||
str | None,
|
||||
Header(description="外部请求 ID,用于串联审计日志和上游调用链。"),
|
||||
]
|
||||
RuleEditorUser = Annotated[CurrentUserContext, Depends(require_rule_editor_user)]
|
||||
|
||||
|
||||
def _handle_asset_error(exc: Exception) -> NoReturn:
|
||||
if isinstance(exc, (LookupError, FileNotFoundError)):
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=str(exc)) from exc
|
||||
if isinstance(exc, (PermissionError, ValueError)):
|
||||
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(exc)) from exc
|
||||
raise exc
|
||||
|
||||
|
||||
def _actor_name(current_user: CurrentUserContext, x_actor: str | None) -> str:
|
||||
return (x_actor or current_user.name or current_user.username or "system").strip() or "system"
|
||||
|
||||
|
||||
def _read_asset(db: Session, asset_id: str) -> AgentAssetRead:
|
||||
asset = AgentAssetService(db).get_asset(asset_id)
|
||||
if asset is None:
|
||||
raise LookupError("Asset not found")
|
||||
return asset
|
||||
|
||||
|
||||
@router.patch(
|
||||
"/{asset_id}/risk-rules/draft",
|
||||
response_model=AgentAssetRead,
|
||||
summary="编辑未上线风险规则草稿",
|
||||
description="仅允许编辑从未上线的自然语言风险规则草稿或生成失败规则,不直接覆盖已上线版本。",
|
||||
)
|
||||
def update_risk_rule_draft(
|
||||
asset_id: str,
|
||||
payload: AgentAssetRiskRuleDraftUpdate,
|
||||
current_user: RuleEditorUser,
|
||||
db: DbSession,
|
||||
x_actor: ActorHeader = None,
|
||||
x_request_id: RequestIdHeader = None,
|
||||
) -> AgentAssetRead:
|
||||
try:
|
||||
AgentAssetRiskRuleRevisionService(db).update_unpublished_draft(
|
||||
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/revisions",
|
||||
response_model=AgentAssetRead,
|
||||
status_code=status.HTTP_201_CREATED,
|
||||
summary="创建已上线风险规则修订草稿",
|
||||
description="为已上线或已下线的自然语言风险规则创建修订草稿,保留当前生效版本不变。",
|
||||
)
|
||||
def create_risk_rule_revision(
|
||||
asset_id: str,
|
||||
payload: AgentAssetRiskRuleRevisionCreate,
|
||||
current_user: RuleEditorUser,
|
||||
db: DbSession,
|
||||
x_actor: ActorHeader = None,
|
||||
x_request_id: RequestIdHeader = None,
|
||||
) -> AgentAssetRead:
|
||||
try:
|
||||
AgentAssetRiskRuleRevisionService(db).create_revision_draft(
|
||||
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)
|
||||
47
server/src/app/api/v1/endpoints/agent_feedback.py
Normal file
47
server/src/app/api/v1/endpoints/agent_feedback.py
Normal file
@@ -0,0 +1,47 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Annotated
|
||||
|
||||
from fastapi import APIRouter, Depends, Query, status
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.api.deps import get_db
|
||||
from app.schemas.agent_feedback import (
|
||||
AgentFeedbackCreate,
|
||||
AgentFeedbackRead,
|
||||
AgentFeedbackSummaryRead,
|
||||
)
|
||||
from app.services.agent_feedback import AgentFeedbackService
|
||||
|
||||
router = APIRouter(prefix="/agent-feedback")
|
||||
DbSession = Annotated[Session, Depends(get_db)]
|
||||
|
||||
|
||||
@router.post(
|
||||
"",
|
||||
response_model=AgentFeedbackRead,
|
||||
status_code=status.HTTP_201_CREATED,
|
||||
summary="记录 Agent 操作评价",
|
||||
description="记录用户对一次智能体处理结果的 1-5 星评价和低分原因。",
|
||||
)
|
||||
def create_agent_feedback(payload: AgentFeedbackCreate, db: DbSession) -> AgentFeedbackRead:
|
||||
return AgentFeedbackService(db).create_feedback(payload)
|
||||
|
||||
|
||||
@router.get(
|
||||
"/summary",
|
||||
response_model=AgentFeedbackSummaryRead,
|
||||
summary="查询 Agent 操作评价统计",
|
||||
description="按最近反馈记录汇总评分分布、低分数量和低分原因。",
|
||||
)
|
||||
def summarize_agent_feedback(
|
||||
db: DbSession,
|
||||
agent: Annotated[str | None, Query(description="Agent 名称筛选。")] = None,
|
||||
session_type: Annotated[str | None, Query(description="会话类型筛选。")] = None,
|
||||
limit: Annotated[int, Query(ge=1, le=500, description="统计最近记录数。")] = 200,
|
||||
) -> AgentFeedbackSummaryRead:
|
||||
return AgentFeedbackService(db).summarize_feedback(
|
||||
agent=agent,
|
||||
session_type=session_type,
|
||||
limit=limit,
|
||||
)
|
||||
@@ -6,7 +6,7 @@ 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.schemas.agent_run import AgentRunRead, AgentRunStatsRead
|
||||
from app.schemas.common import ErrorResponse
|
||||
from app.services.agent_runs import AgentRunService
|
||||
|
||||
@@ -44,6 +44,39 @@ def list_agent_runs(
|
||||
)
|
||||
|
||||
|
||||
@router.get(
|
||||
"/summary",
|
||||
response_model=AgentRunStatsRead,
|
||||
summary="查询 Agent 运行统计",
|
||||
description="按最近运行记录实时汇总 Agent、工具调用、模型调用和错误统计。",
|
||||
)
|
||||
def summarize_agent_runs(
|
||||
db: DbSession,
|
||||
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,
|
||||
limit: Annotated[
|
||||
int,
|
||||
Query(ge=1, le=500, description="统计最近记录数。"),
|
||||
] = 200,
|
||||
) -> AgentRunStatsRead:
|
||||
return AgentRunService(db).summarize_runs(
|
||||
agent=agent,
|
||||
status=status_value,
|
||||
source=source,
|
||||
limit=limit,
|
||||
)
|
||||
|
||||
|
||||
@router.get(
|
||||
"/{run_id}",
|
||||
response_model=AgentRunRead,
|
||||
|
||||
55
server/src/app/api/v1/endpoints/analytics.py
Normal file
55
server/src/app/api/v1/endpoints/analytics.py
Normal file
@@ -0,0 +1,55 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import date
|
||||
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.finance_dashboard import FinanceDashboardRead
|
||||
from app.schemas.system_dashboard import SystemDashboardRead
|
||||
from app.services.finance_dashboard import FinanceDashboardService
|
||||
from app.services.system_dashboard import SystemDashboardService
|
||||
|
||||
router = APIRouter(prefix="/analytics")
|
||||
DbSession = Annotated[Session, Depends(get_db)]
|
||||
|
||||
|
||||
@router.get(
|
||||
"/system-dashboard",
|
||||
response_model=SystemDashboardRead,
|
||||
summary="查询系统看板真实指标",
|
||||
description="基于 Agent 运行、工具调用、用户会话和反馈数据聚合系统看板指标。",
|
||||
)
|
||||
def get_system_dashboard(
|
||||
db: DbSession,
|
||||
days: Annotated[
|
||||
int,
|
||||
Query(ge=1, le=30, description="统计窗口天数。"),
|
||||
] = 7,
|
||||
) -> SystemDashboardRead:
|
||||
return SystemDashboardService(db).build_dashboard(days=days)
|
||||
|
||||
|
||||
@router.get(
|
||||
"/finance-dashboard",
|
||||
response_model=FinanceDashboardRead,
|
||||
summary="查询财务看板真实指标",
|
||||
description="基于报销单据、风险观察和预算池数据聚合财务看板指标。",
|
||||
)
|
||||
def get_finance_dashboard(
|
||||
db: DbSession,
|
||||
range_key: Annotated[str, Query(max_length=30, description="顶部时间范围。")] = "近10日",
|
||||
start_date: Annotated[date | None, Query(description="自定义开始日期。")] = None,
|
||||
end_date: Annotated[date | None, Query(description="自定义结束日期。")] = None,
|
||||
trend_range: Annotated[str, Query(max_length=30, description="趋势图时间范围。")] = "近12天",
|
||||
department_range: Annotated[str, Query(max_length=30, description="部门排行时间范围。")] = "本月",
|
||||
) -> FinanceDashboardRead:
|
||||
return FinanceDashboardService(db).build_dashboard(
|
||||
range_key=range_key,
|
||||
start_date=start_date,
|
||||
end_date=end_date,
|
||||
trend_range=trend_range,
|
||||
department_range=department_range,
|
||||
)
|
||||
@@ -6,9 +6,15 @@ from fastapi import APIRouter, Depends, HTTPException, status
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.api.deps import get_db
|
||||
from app.schemas.auth import LoginRequest, LoginResponse
|
||||
from app.schemas.auth import (
|
||||
LoginRequest,
|
||||
LoginResponse,
|
||||
SessionFinishRequest,
|
||||
SessionFinishResponse,
|
||||
)
|
||||
from app.schemas.common import ErrorResponse
|
||||
from app.services.auth import AuthService
|
||||
from app.services.user_session_metrics import UserSessionMetricService
|
||||
|
||||
router = APIRouter(prefix="/auth")
|
||||
DbSession = Annotated[Session, Depends(get_db)]
|
||||
@@ -31,3 +37,32 @@ def login(payload: LoginRequest, db: DbSession) -> LoginResponse:
|
||||
return AuthService(db).login(payload)
|
||||
except ValueError as exc:
|
||||
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail=str(exc)) from exc
|
||||
|
||||
|
||||
@router.post(
|
||||
"/sessions/{session_id}/finish",
|
||||
response_model=SessionFinishResponse,
|
||||
summary="结算用户在线会话",
|
||||
)
|
||||
def finish_session(
|
||||
session_id: str,
|
||||
payload: SessionFinishRequest,
|
||||
db: DbSession,
|
||||
) -> SessionFinishResponse:
|
||||
session = UserSessionMetricService(db).finish_session(
|
||||
session_id=session_id,
|
||||
reason=payload.reason,
|
||||
last_activity_at=payload.lastActivityAt,
|
||||
activity_event_count=payload.activityEventCount,
|
||||
event={"page_path": payload.pagePath},
|
||||
)
|
||||
if session is None:
|
||||
return SessionFinishResponse(
|
||||
detail="会话不存在或已被清理。",
|
||||
sessionId=session_id,
|
||||
durationMs=0,
|
||||
)
|
||||
return SessionFinishResponse(
|
||||
sessionId=session.session_id,
|
||||
durationMs=int(session.duration_ms or 0),
|
||||
)
|
||||
|
||||
@@ -124,7 +124,7 @@ def _missing_usage_duration_metric(latest: EmployeeProfileLatestRead) -> bool:
|
||||
|
||||
for profile in latest.profiles:
|
||||
if profile.profile_type == "ai_usage":
|
||||
return "ai_run_duration_ms" not in profile.metrics
|
||||
return "usage_duration_ms" not in profile.metrics
|
||||
return False
|
||||
|
||||
|
||||
|
||||
146
server/src/app/api/v1/endpoints/risk_observations.py
Normal file
146
server/src/app/api/v1/endpoints/risk_observations.py
Normal file
@@ -0,0 +1,146 @@
|
||||
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.common import ErrorResponse
|
||||
from app.schemas.risk_observation import (
|
||||
RiskObservationDashboardRead,
|
||||
RiskObservationFeedbackCreate,
|
||||
RiskObservationFeedbackRead,
|
||||
RiskObservationListRead,
|
||||
RiskObservationRead,
|
||||
)
|
||||
from app.services.risk_observations import RiskObservationService
|
||||
|
||||
router = APIRouter(prefix="/risk-observations")
|
||||
DbSession = Annotated[Session, Depends(get_db)]
|
||||
|
||||
|
||||
@router.get(
|
||||
"",
|
||||
response_model=RiskObservationListRead,
|
||||
summary="查询风险观察列表",
|
||||
description="按单据、风险等级、风险信号、状态和来源筛选统一风险观察池。",
|
||||
)
|
||||
def list_risk_observations(
|
||||
db: DbSession,
|
||||
claim_id: Annotated[str | None, Query(max_length=80)] = None,
|
||||
run_id: Annotated[str | None, Query(max_length=80)] = None,
|
||||
execution_log_id: Annotated[str | None, Query(max_length=80)] = None,
|
||||
risk_level: Annotated[str | None, Query(max_length=20)] = None,
|
||||
risk_signal: Annotated[str | None, Query(max_length=100)] = None,
|
||||
status_value: Annotated[
|
||||
str | None,
|
||||
Query(alias="status", max_length=30),
|
||||
] = None,
|
||||
source: Annotated[str | None, Query(max_length=60)] = None,
|
||||
limit: Annotated[int, Query(ge=1, le=200)] = 50,
|
||||
offset: Annotated[int, Query(ge=0)] = 0,
|
||||
) -> RiskObservationListRead:
|
||||
items, total = RiskObservationService(db).list_observations(
|
||||
claim_id=claim_id,
|
||||
run_id=run_id,
|
||||
execution_log_id=execution_log_id,
|
||||
risk_level=risk_level,
|
||||
risk_signal=risk_signal,
|
||||
status=status_value,
|
||||
source=source,
|
||||
limit=limit,
|
||||
offset=offset,
|
||||
)
|
||||
return RiskObservationListRead(items=items, total=total, limit=limit, offset=offset)
|
||||
|
||||
|
||||
@router.get(
|
||||
"/dashboard",
|
||||
response_model=RiskObservationDashboardRead,
|
||||
summary="查询风险看板聚合",
|
||||
description="返回风险观察池的总量、分布、算法效果和近期高风险记录。",
|
||||
)
|
||||
def summarize_risk_observations(
|
||||
db: DbSession,
|
||||
window_days: Annotated[int, Query(ge=1, le=365)] = 30,
|
||||
limit: Annotated[int, Query(ge=1, le=2000)] = 500,
|
||||
) -> RiskObservationDashboardRead:
|
||||
return RiskObservationService(db).summarize_dashboard(
|
||||
window_days=window_days,
|
||||
limit=limit,
|
||||
)
|
||||
|
||||
|
||||
@router.get(
|
||||
"/claim/{claim_id}",
|
||||
response_model=list[RiskObservationRead],
|
||||
summary="查询单据风险观察",
|
||||
description="按报销单 ID 返回该单据关联的风险观察,供单据详情证据链使用。",
|
||||
)
|
||||
def list_claim_risk_observations(claim_id: str, db: DbSession) -> list[RiskObservationRead]:
|
||||
return RiskObservationService(db).list_claim_observations(claim_id)
|
||||
|
||||
|
||||
@router.get(
|
||||
"/execution-log/{execution_log_id}",
|
||||
response_model=list[RiskObservationRead],
|
||||
summary="查询数字员工工作记录风险观察",
|
||||
description="按数字员工执行日志 ID 返回本次任务生成的风险观察。",
|
||||
)
|
||||
def list_execution_log_risk_observations(
|
||||
execution_log_id: str,
|
||||
db: DbSession,
|
||||
) -> list[RiskObservationRead]:
|
||||
return RiskObservationService(db).list_execution_log_observations(execution_log_id)
|
||||
|
||||
|
||||
@router.get(
|
||||
"/{observation_key_or_id}",
|
||||
response_model=RiskObservationRead,
|
||||
summary="读取风险观察详情",
|
||||
description="按观察 key 或 ID 返回风险评分、证据链、图谱节点、制度引用和决策追踪。",
|
||||
responses={
|
||||
status.HTTP_404_NOT_FOUND: {
|
||||
"model": ErrorResponse,
|
||||
"description": "风险观察不存在。",
|
||||
}
|
||||
},
|
||||
)
|
||||
def get_risk_observation(
|
||||
observation_key_or_id: str,
|
||||
db: DbSession,
|
||||
) -> RiskObservationRead:
|
||||
observation = RiskObservationService(db).get_observation(observation_key_or_id)
|
||||
if observation is None:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail="Risk observation not found",
|
||||
)
|
||||
return observation
|
||||
|
||||
|
||||
@router.post(
|
||||
"/{observation_key_or_id}/feedback",
|
||||
response_model=RiskObservationFeedbackRead,
|
||||
summary="写入风险观察反馈",
|
||||
description="记录人工确认、误报、忽略、已处理或备注反馈,并同步更新观察状态。",
|
||||
responses={
|
||||
status.HTTP_404_NOT_FOUND: {
|
||||
"model": ErrorResponse,
|
||||
"description": "风险观察不存在。",
|
||||
}
|
||||
},
|
||||
)
|
||||
def create_risk_observation_feedback(
|
||||
observation_key_or_id: str,
|
||||
payload: RiskObservationFeedbackCreate,
|
||||
db: DbSession,
|
||||
) -> RiskObservationFeedbackRead:
|
||||
try:
|
||||
return RiskObservationService(db).create_feedback(observation_key_or_id, payload)
|
||||
except LookupError:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail="Risk observation not found",
|
||||
) from None
|
||||
@@ -1,7 +1,10 @@
|
||||
from fastapi import APIRouter
|
||||
|
||||
from app.api.v1.endpoints.agent_asset_risk_rules import router as agent_asset_risk_rules_router
|
||||
from app.api.v1.endpoints.agent_assets import router as agent_assets_router
|
||||
from app.api.v1.endpoints.agent_feedback import router as agent_feedback_router
|
||||
from app.api.v1.endpoints.agent_runs import router as agent_runs_router
|
||||
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
|
||||
from app.api.v1.endpoints.bootstrap import router as bootstrap_router
|
||||
@@ -15,6 +18,7 @@ from app.api.v1.endpoints.ontology import router as ontology_router
|
||||
from app.api.v1.endpoints.orchestrator import router as orchestrator_router
|
||||
from app.api.v1.endpoints.receipt_folder import router as receipt_folder_router
|
||||
from app.api.v1.endpoints.reimbursements import router as reimbursements_router
|
||||
from app.api.v1.endpoints.risk_observations import router as risk_observations_router
|
||||
from app.api.v1.endpoints.settings import router as settings_router
|
||||
from app.api.v1.endpoints.system_logs import router as system_logs_router
|
||||
|
||||
@@ -24,7 +28,10 @@ router.include_router(bootstrap_router, tags=["bootstrap"])
|
||||
router.include_router(auth_router, tags=["auth"])
|
||||
router.include_router(budgets_router, tags=["budgets"])
|
||||
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(analytics_router, tags=["analytics"])
|
||||
router.include_router(audit_logs_router, tags=["audit-logs"])
|
||||
router.include_router(knowledge_router, tags=["knowledge"])
|
||||
router.include_router(ocr_router, tags=["ocr"])
|
||||
@@ -34,5 +41,6 @@ router.include_router(receipt_folder_router, tags=["receipt-folder"])
|
||||
router.include_router(employees_router, prefix="/employees", tags=["employees"])
|
||||
router.include_router(employee_profiles_router, tags=["employee-profiles"])
|
||||
router.include_router(reimbursements_router, prefix="/reimbursements", tags=["reimbursements"])
|
||||
router.include_router(risk_observations_router, tags=["risk-observations"])
|
||||
router.include_router(settings_router, tags=["settings"])
|
||||
router.include_router(system_logs_router, tags=["system-logs"])
|
||||
|
||||
Reference in New Issue
Block a user