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.schema_ownership import create_legacy_schema 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)", }, "finance_dashboard_snapshot": { "label": "财务看板指标快照", "category": "积累", "color": "var(--chart-blue)", }, "digital_employee_reminder_scan": { "label": "定时提醒扫描", "category": "整理", "color": "var(--success)", }, } 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: create_legacy_schema(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"] + metrics["finance_snapshots"] + metrics["reminders"] ) 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"], "financeDashboardSnapshots": metrics["finance_snapshots"], "reminders": metrics["reminders"], "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, "financeDashboardSnapshots": 0, "reminders": 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["financeDashboardSnapshots"] += metrics["finance_snapshots"] row["reminders"] += metrics["reminders"] row["businessOutputs"] += ( metrics["risk_observations"] + metrics["risk_clues"] + metrics["profile_snapshots"] + metrics["knowledge_documents"] + metrics["finance_snapshots"] + metrics["reminders"] ) 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"), ) if self._resolve_task_type(run) == "finance_dashboard_snapshot": metrics["profile_snapshots"] = 0 metrics["finance_snapshots"] = self._first_int( summary, ("finance_snapshot_count", "dashboard_snapshot_count"), ) 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")) metrics["reminders"] = self._first_int( summary, ( "reminder_count", "reminders", "approval_pending_count", "budget_reminder_count", ), ) return metrics @staticmethod def _empty_metrics() -> dict[str, int]: return { "risk_observations": 0, "risk_clues": 0, "profile_snapshots": 0, "finance_snapshots": 0, "knowledge_documents": 0, "scanned_claims": 0, "target_employees": 0, "rule_hits": 0, "facts": 0, "reminders": 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 "reminder" in name: return "digital_employee_reminder_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 "未标记")