feat: 扩展风险规则体系、审批动态路由与预算中心列表化改造

- 新增 25+ 条风险规则(预算/报销/申请/通用类),完善风险规则模拟与反馈发布机制
- 引入费用审批动态路由、平台风险分级、预审与风险阶段管理
- 预算中心列表化改造,优化票据夹仪表盘与数字员工工作看板
- 新增 Hermes 风险线索收集器、Agent 链路追踪中心
- 扩展数字员工能力库(18 个领域 Skill)与交通费用自动预估
- 完善报销申请快速预览、权限控制与前端测试覆盖
This commit is contained in:
caoxiaozhu
2026-06-01 17:07:14 +08:00
parent 7989f3a159
commit 92444e7eae
285 changed files with 25075 additions and 2986 deletions

View File

@@ -0,0 +1,637 @@
from __future__ import annotations
from datetime import UTC, date, datetime, timedelta
from typing import Any
from sqlalchemy import or_, select
from sqlalchemy.orm import Session, selectinload
from app.core.agent_enums import AgentName, AgentRunSource
from app.db.base import Base
from app.models.agent_run import AgentRun, AgentToolCall
from app.schemas.digital_employee_dashboard import DigitalEmployeeDashboardRead
SUCCESS_STATUSES = {"success", "succeeded", "ok", "done", "completed"}
FAILED_STATUSES = {"failed", "failure", "error", "errored"}
RUNNING_STATUSES = {"running", "pending"}
TASK_CODE_TO_TYPE = {
"task.hermes.global_risk_scan": "global_risk_scan",
"task.hermes.employee_behavior_profile_scan": "employee_behavior_profile_scan",
"task.hermes.risk_rule_discovery": "risk_clue_collect",
"task.hermes.finance_policy_knowledge_organize": "finance_policy_knowledge_organize",
"task.hermes.finance_policy_clause_extract": "finance_policy_clause_extract",
"task.hermes.expense_policy_alignment": "expense_policy_alignment",
"task.hermes.risk_rule_template_organize": "risk_rule_template_organize",
"task.hermes.department_expense_baseline_accumulate": "department_expense_baseline_accumulate",
"task.hermes.supplier_risk_profile_accumulate": "supplier_risk_profile_accumulate",
"task.hermes.false_positive_sample_accumulate": "false_positive_sample_accumulate",
"task.hermes.risk_feedback_sample_accumulate": "risk_feedback_sample_accumulate",
"task.hermes.multi_evidence_consistency_evaluate": "multi_evidence_consistency_evaluate",
"task.hermes.travel_spatiotemporal_consistency_evaluate": "travel_spatiotemporal_consistency_evaluate",
"task.hermes.budget_overrun_precontrol_evaluate": "budget_overrun_precontrol_evaluate",
"task.hermes.supplier_abnormal_relation_evaluate": "supplier_abnormal_relation_evaluate",
"task.hermes.risk_algorithm_replay_evaluate": "risk_algorithm_replay_evaluate",
"task.hermes.policy_gap_rule_optimization": "policy_gap_rule_optimization",
}
TASK_SPECS: dict[str, dict[str, str]] = {
"global_risk_scan": {
"label": "财务风险图谱巡检",
"category": "评估",
"color": "var(--theme-primary)",
},
"employee_behavior_profile_scan": {
"label": "员工行为画像巡检",
"category": "积累",
"color": "var(--chart-blue)",
},
"risk_clue_collect": {
"label": "风险线索归集",
"category": "升级",
"color": "var(--chart-amber)",
},
"finance_policy_knowledge_organize": {
"label": "知识制度整理",
"category": "整理",
"color": "var(--success)",
},
"knowledge_index_sync": {
"label": "知识制度整理",
"category": "整理",
"color": "var(--success)",
},
"llm_wiki_sync": {
"label": "知识制度整理",
"category": "整理",
"color": "var(--success)",
},
"llm_wiki_rule_formation": {
"label": "知识制度整理",
"category": "整理",
"color": "var(--success)",
},
"finance_policy_clause_extract": {
"label": "制度条款结构化抽取",
"category": "整理",
"color": "var(--success)",
},
"expense_policy_alignment": {
"label": "报销政策口径对齐",
"category": "整理",
"color": "var(--success)",
},
"risk_rule_template_organize": {
"label": "规则命中样本整理",
"category": "整理",
"color": "var(--success)",
},
"department_expense_baseline_accumulate": {
"label": "部门费用基线沉淀",
"category": "积累",
"color": "var(--chart-blue)",
},
"supplier_risk_profile_accumulate": {
"label": "供应商风险画像沉淀",
"category": "积累",
"color": "var(--chart-blue)",
},
"false_positive_sample_accumulate": {
"label": "历史误报样本沉淀",
"category": "积累",
"color": "var(--chart-blue)",
},
"risk_feedback_sample_accumulate": {
"label": "风险观察反馈样本沉淀",
"category": "积累",
"color": "var(--chart-blue)",
},
"multi_evidence_consistency_evaluate": {
"label": "多源证据一致性评估",
"category": "评估",
"color": "var(--theme-primary)",
},
"travel_spatiotemporal_consistency_evaluate": {
"label": "差旅时空一致性评估",
"category": "评估",
"color": "var(--theme-primary)",
},
"budget_overrun_precontrol_evaluate": {
"label": "预算超限预警评估",
"category": "评估",
"color": "var(--theme-primary)",
},
"supplier_abnormal_relation_evaluate": {
"label": "供应商异常关系评估",
"category": "评估",
"color": "var(--theme-primary)",
},
"risk_algorithm_replay_evaluate": {
"label": "风险算法回放升级",
"category": "升级",
"color": "var(--chart-amber)",
},
"policy_gap_rule_optimization": {
"label": "制度缺口优化建议",
"category": "升级",
"color": "var(--chart-amber)",
},
}
CATEGORY_SPECS = {
"积累": {"color": "var(--chart-blue)", "description": "沉淀画像、基线和反馈样本"},
"升级": {"color": "var(--chart-amber)", "description": "输出待复核线索和优化建议"},
"整理": {"color": "var(--success)", "description": "整理制度、条款、知识和样本"},
"评估": {"color": "var(--theme-primary)", "description": "评估异常、风险和一致性"},
}
class DigitalEmployeeDashboardService:
def __init__(self, db: Session) -> None:
self.db = db
def build_dashboard(self, *, days: int = 7, limit: int = 300) -> DigitalEmployeeDashboardRead:
window_days = max(1, min(int(days or 7), 30))
window_limit = max(1, min(int(limit or 300), 1000))
self._ensure_storage_ready()
now = datetime.now(UTC)
start = now - timedelta(days=window_days - 1)
labels = self._date_labels(start.date(), window_days)
all_runs = self._fetch_runs(start=start, limit=window_limit)
runs = [run for run in all_runs if self._is_digital_employee_run(run)]
totals = self._build_totals(runs)
return DigitalEmployeeDashboardRead(
window_days=window_days,
generated_at=now.isoformat(),
has_real_data=bool(runs),
totals=totals,
daily_work=self._daily_work(labels, runs),
task_distribution=self._task_distribution(runs),
category_distribution=self._category_distribution(runs),
recent_runs=self._recent_runs(runs),
)
def _ensure_storage_ready(self) -> None:
Base.metadata.create_all(bind=self.db.get_bind())
def _fetch_runs(self, *, start: datetime, limit: int) -> list[AgentRun]:
stmt = (
select(AgentRun)
.options(selectinload(AgentRun.tool_calls))
.where(
AgentRun.started_at >= start,
or_(
AgentRun.agent == AgentName.HERMES.value,
AgentRun.source == AgentRunSource.SCHEDULE.value,
),
)
.order_by(AgentRun.started_at.desc())
.limit(limit)
)
return list(self.db.scalars(stmt).all())
def _build_totals(self, runs: list[AgentRun]) -> dict[str, Any]:
metrics = self._sum_metrics(runs)
success_runs = sum(1 for run in runs if self._is_success(run.status))
failed_runs = sum(1 for run in runs if self._is_failed(run.status))
running_runs = sum(1 for run in runs if self._is_running(run.status))
total_runs = len(runs)
business_outputs = (
metrics["risk_observations"]
+ metrics["risk_clues"]
+ metrics["profile_snapshots"]
+ metrics["knowledge_documents"]
)
return {
"totalRuns": total_runs,
"successRuns": success_runs,
"failedRuns": failed_runs,
"runningRuns": running_runs,
"toolCalls": sum(len(run.tool_calls) for run in runs),
"businessOutputs": business_outputs,
"riskObservations": metrics["risk_observations"],
"riskClues": metrics["risk_clues"],
"profileSnapshots": metrics["profile_snapshots"],
"knowledgeDocuments": metrics["knowledge_documents"],
"successRate": self._percent(success_runs, total_runs),
"failureRate": self._percent(failed_runs, total_runs),
}
def _daily_work(self, labels: list[str], runs: list[AgentRun]) -> list[dict[str, Any]]:
rows = {
label: {
"date": label,
"total": 0,
"success": 0,
"failed": 0,
"running": 0,
"riskObservations": 0,
"riskClues": 0,
"profileSnapshots": 0,
"knowledgeDocuments": 0,
"businessOutputs": 0,
}
for label in labels
}
for run in runs:
label = self._date_label(run.started_at)
if label not in rows:
continue
row = rows[label]
metrics = self._extract_run_metrics(run)
row["total"] += 1
if self._is_success(run.status):
row["success"] += 1
elif self._is_failed(run.status):
row["failed"] += 1
elif self._is_running(run.status):
row["running"] += 1
row["riskObservations"] += metrics["risk_observations"]
row["riskClues"] += metrics["risk_clues"]
row["profileSnapshots"] += metrics["profile_snapshots"]
row["knowledgeDocuments"] += metrics["knowledge_documents"]
row["businessOutputs"] += (
metrics["risk_observations"]
+ metrics["risk_clues"]
+ metrics["profile_snapshots"]
+ metrics["knowledge_documents"]
)
return [rows[label] for label in labels]
def _task_distribution(self, runs: list[AgentRun]) -> list[dict[str, Any]]:
buckets: dict[str, dict[str, Any]] = {}
for run in runs:
task_type = self._resolve_task_type(run)
spec = self._task_spec(task_type)
bucket = buckets.setdefault(
task_type or "unknown",
{
"taskType": task_type or "unknown",
"name": spec["label"],
"category": spec["category"],
"count": 0,
"success": 0,
"failed": 0,
"value": 0,
"color": spec["color"],
},
)
bucket["count"] += 1
bucket["value"] += 1
if self._is_success(run.status):
bucket["success"] += 1
elif self._is_failed(run.status):
bucket["failed"] += 1
return sorted(buckets.values(), key=lambda item: (-item["count"], item["name"]))[:8]
def _category_distribution(self, runs: list[AgentRun]) -> list[dict[str, Any]]:
rows = {
category: {
"name": category,
"value": 0,
"count": 0,
"success": 0,
"failed": 0,
"color": spec["color"],
"description": spec["description"],
}
for category, spec in CATEGORY_SPECS.items()
}
for run in runs:
category = self._task_spec(self._resolve_task_type(run))["category"]
row = rows.setdefault(
category,
{
"name": category,
"value": 0,
"count": 0,
"success": 0,
"failed": 0,
"color": "var(--theme-primary)",
"description": "其他数字员工工作",
},
)
row["value"] += 1
row["count"] += 1
if self._is_success(run.status):
row["success"] += 1
elif self._is_failed(run.status):
row["failed"] += 1
return list(rows.values())
def _recent_runs(self, runs: list[AgentRun]) -> list[dict[str, Any]]:
rows = []
for run in sorted(runs, key=lambda item: item.started_at, reverse=True)[:8]:
task_type = self._resolve_task_type(run)
spec = self._task_spec(task_type)
rows.append(
{
"runId": run.run_id,
"taskType": task_type or "unknown",
"taskLabel": spec["label"],
"category": spec["category"],
"status": run.status,
"statusLabel": self._status_label(run.status),
"statusTone": self._status_tone(run.status),
"source": run.source,
"sourceLabel": self._source_label(run.source),
"startedAt": self._iso(run.started_at),
"finishedAt": self._iso(run.finished_at),
"durationMs": self._duration_ms(run),
"summary": self._summary_text(run),
"metrics": self._extract_run_metrics(run),
}
)
return rows
def _sum_metrics(self, runs: list[AgentRun]) -> dict[str, int]:
totals = self._empty_metrics()
for run in runs:
metrics = self._extract_run_metrics(run)
for key in totals:
totals[key] += int(metrics.get(key) or 0)
return totals
def _extract_run_metrics(self, run: AgentRun) -> dict[str, int]:
summary = self._extract_run_summary(run)
route_json = run.route_json or {}
metrics = self._empty_metrics()
metrics["risk_observations"] = self._first_int(
summary,
("risk_observation_count", "risk_observations", "created_observation_count"),
)
metrics["risk_clues"] = self._first_int(
summary,
("risk_clue_count", "risk_clues", "created_clue_count"),
)
metrics["profile_snapshots"] = self._first_int(
summary,
("snapshot_count", "profile_snapshot_count", "profile_snapshots"),
)
metrics["knowledge_documents"] = max(
self._first_int(
summary,
("knowledge_document_count", "document_count", "processed_document_count"),
),
self._list_length(summary, ("document_ids", "requested_document_ids")),
self._list_length(route_json, ("document_ids", "requested_document_ids")),
)
metrics["scanned_claims"] = self._first_int(summary, ("scanned_claim_count", "claim_count"))
metrics["target_employees"] = self._first_int(summary, ("target_employee_count", "employee_count"))
metrics["rule_hits"] = self._first_int(summary, ("rule_hit_count", "rule_hits"))
metrics["facts"] = self._first_int(summary, ("fact_count", "facts"))
return metrics
@staticmethod
def _empty_metrics() -> dict[str, int]:
return {
"risk_observations": 0,
"risk_clues": 0,
"profile_snapshots": 0,
"knowledge_documents": 0,
"scanned_claims": 0,
"target_employees": 0,
"rule_hits": 0,
"facts": 0,
}
def _extract_run_summary(self, run: AgentRun) -> dict[str, Any]:
task_type = self._resolve_task_type(run)
matched_tool = self._matched_tool_call(run, task_type)
if matched_tool is None:
return run.route_json or {}
response = matched_tool.response_json or {}
if isinstance(response, dict) and isinstance(response.get("summary"), dict):
return response["summary"]
return response if isinstance(response, dict) else {}
def _matched_tool_call(self, run: AgentRun, task_type: str) -> AgentToolCall | None:
digital_tools = [
tool for tool in run.tool_calls if str(tool.tool_name or "").startswith("digital_employee.")
]
for tool in run.tool_calls:
candidates = [
(tool.request_json or {}).get("task_type"),
(tool.request_json or {}).get("job_type"),
(tool.response_json or {}).get("report_type"),
(tool.response_json or {}).get("task_type"),
(tool.response_json or {}).get("job_type"),
self._task_type_from_tool_name(tool.tool_name),
]
if task_type and task_type in {self._normalize_task_type(item) for item in candidates}:
return tool
if digital_tools:
return digital_tools[0]
return run.tool_calls[0] if run.tool_calls else None
def _is_digital_employee_run(self, run: AgentRun) -> bool:
task_type = self._resolve_task_type(run)
if task_type in TASK_SPECS:
return True
if run.agent == AgentName.HERMES.value:
return True
if run.source == AgentRunSource.SCHEDULE.value and task_type:
return True
route_json = run.route_json or {}
if str(route_json.get("selected_agent") or "").strip() == AgentName.HERMES.value:
return True
return any(str(tool.tool_name or "").startswith("digital_employee.") for tool in run.tool_calls)
def _resolve_task_type(self, run: AgentRun) -> str:
route_json = run.route_json or {}
route_candidates = [
route_json.get("job_type"),
route_json.get("task_type"),
route_json.get("report_type"),
route_json.get("task_code"),
route_json.get("code"),
]
for candidate in route_candidates:
normalized = self._normalize_task_type(candidate)
if normalized:
return normalized
for tool in run.tool_calls:
for candidate in (
(tool.request_json or {}).get("task_type"),
(tool.request_json or {}).get("job_type"),
(tool.response_json or {}).get("report_type"),
(tool.response_json or {}).get("task_type"),
(tool.response_json or {}).get("job_type"),
self._task_type_from_tool_name(tool.tool_name),
):
normalized = self._normalize_task_type(candidate)
if normalized:
return normalized
return ""
@staticmethod
def _normalize_task_type(value: Any) -> str:
text = str(value or "").strip()
if not text:
return ""
text = TASK_CODE_TO_TYPE.get(text, text)
if text.startswith("task.hermes."):
text = text.removeprefix("task.hermes.")
text = text.replace("-", "_").replace(".", "_")
if text == "risk_rule_discovery":
return "risk_clue_collect"
return text
@staticmethod
def _task_type_from_tool_name(value: str | None) -> str:
name = str(value or "")
if "financial_risk_graph" in name:
return "global_risk_scan"
if "employee_behavior_profile" in name:
return "employee_behavior_profile_scan"
if "finance_policy_knowledge" in name:
return "finance_policy_knowledge_organize"
if "risk_clue" in name:
return "risk_clue_collect"
return ""
@staticmethod
def _task_spec(task_type: str) -> dict[str, str]:
return TASK_SPECS.get(
task_type,
{
"label": "数字员工工作",
"category": "评估",
"color": "var(--theme-primary)",
},
)
def _summary_text(self, run: AgentRun) -> str:
text = str(run.result_summary or "").strip()
if text:
return text
summary = self._extract_run_summary(run)
for key in ("message", "summary", "result_summary"):
value = str(summary.get(key) or "").strip()
if value:
return value
if run.error_message:
return str(run.error_message)
return "暂无摘要。"
@staticmethod
def _first_int(payload: Any, keys: tuple[str, ...]) -> int:
if isinstance(payload, dict):
for key in keys:
value = payload.get(key)
if isinstance(value, (int, float)) and value > 0:
return int(value)
for value in payload.values():
found = DigitalEmployeeDashboardService._first_int(value, keys)
if found:
return found
if isinstance(payload, list):
for value in payload:
found = DigitalEmployeeDashboardService._first_int(value, keys)
if found:
return found
return 0
@staticmethod
def _list_length(payload: Any, keys: tuple[str, ...]) -> int:
if isinstance(payload, dict):
for key in keys:
value = payload.get(key)
if isinstance(value, list):
return len(value)
for value in payload.values():
found = DigitalEmployeeDashboardService._list_length(value, keys)
if found:
return found
if isinstance(payload, list):
for value in payload:
found = DigitalEmployeeDashboardService._list_length(value, keys)
if found:
return found
return 0
@staticmethod
def _percent(value: int | float, total: int | float) -> float:
if not total:
return 0.0
return round((float(value) / float(total)) * 100, 1)
@staticmethod
def _duration_ms(run: AgentRun) -> int:
if not run.finished_at:
return 0
try:
finished_at = DigitalEmployeeDashboardService._as_utc(run.finished_at)
started_at = DigitalEmployeeDashboardService._as_utc(run.started_at)
return max(0, int((finished_at - started_at).total_seconds() * 1000))
except TypeError:
return 0
@staticmethod
def _date_labels(start_date: date, days: int) -> list[str]:
return [(start_date + timedelta(days=index)).strftime("%m-%d") for index in range(days)]
@staticmethod
def _date_label(value: datetime | None) -> str:
if value is None:
return ""
return DigitalEmployeeDashboardService._as_utc(value).strftime("%m-%d")
@staticmethod
def _iso(value: datetime | None) -> str:
if value is None:
return ""
return DigitalEmployeeDashboardService._as_utc(value).isoformat()
@staticmethod
def _as_utc(value: datetime) -> datetime:
if value.tzinfo is None:
return value.replace(tzinfo=UTC)
return value.astimezone(UTC)
@staticmethod
def _is_success(status: str | None) -> bool:
return str(status or "").strip().lower() in SUCCESS_STATUSES
@staticmethod
def _is_failed(status: str | None) -> bool:
return str(status or "").strip().lower() in FAILED_STATUSES
@staticmethod
def _is_running(status: str | None) -> bool:
return str(status or "").strip().lower() in RUNNING_STATUSES
def _status_label(self, status: str | None) -> str:
if self._is_success(status):
return "成功"
if self._is_failed(status):
return "失败"
if self._is_running(status):
return "运行中"
return str(status or "其他")
def _status_tone(self, status: str | None) -> str:
if self._is_success(status):
return "success"
if self._is_failed(status):
return "danger"
if self._is_running(status):
return "warning"
return "neutral"
@staticmethod
def _source_label(source: str | None) -> str:
labels = {
"schedule": "定时任务",
"system_event": "系统事件",
"user_message": "用户触发",
}
text = str(source or "").strip()
return labels.get(text, text or "未标记")