feat: 扩展风险规则体系、审批动态路由与预算中心列表化改造
- 新增 25+ 条风险规则(预算/报销/申请/通用类),完善风险规则模拟与反馈发布机制 - 引入费用审批动态路由、平台风险分级、预审与风险阶段管理 - 预算中心列表化改造,优化票据夹仪表盘与数字员工工作看板 - 新增 Hermes 风险线索收集器、Agent 链路追踪中心 - 扩展数字员工能力库(18 个领域 Skill)与交通费用自动预估 - 完善报销申请快速预览、权限控制与前端测试覆盖
This commit is contained in:
637
server/src/app/services/digital_employee_dashboard.py
Normal file
637
server/src/app/services/digital_employee_dashboard.py
Normal 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 "未标记")
|
||||
Reference in New Issue
Block a user