"""Tenant-level executive briefing service.""" from __future__ import annotations import json from datetime import datetime, timedelta from typing import Any, Optional from zoneinfo import ZoneInfo from sqlalchemy import and_, select from sqlalchemy.ext.asyncio import AsyncSession from ..models import ( CognitiveConflict, FeedbackCenterRecord, Integration, Notification, PortfolioSnapshot, Signal, SignalSourceRegistry, Task, User, UserWorkload, ) from ..utils.timezone import CN_TZ, now_cn_naive INTEGRATION_TYPE = "executive_briefing" DEFAULT_TIMEZONE = "Asia/Shanghai" DEFAULT_SEND_TIME = "08:30" DEFAULT_SEND_CYCLE = "workdays" DEFAULT_DELIVERY_TARGET_TYPE = "management_group" VALID_SEND_CYCLES = {"workdays", "daily"} VALID_DELIVERY_TARGET_TYPES = {DEFAULT_DELIVERY_TARGET_TYPE} HIGH_SIGNAL_PRIORITIES = {"p0", "p1", "high", "critical", "紧急", "高"} PENDING_SIGNAL_STATUSES = {"pending", "todo", "待处理", "待确认"} BLOCKED_TASK_STATUSES = {"blocked", "blocking", "阻塞", "已阻塞"} DONE_TASK_STATUSES = {"done", "completed", "closed", "已完成", "已关闭"} ACTIVE_CONFLICT_DONE_STATUSES = {"resolved", "closed", "done", "已解决", "已关闭"} REWORK_TASK_STATUSES = {"rework", "reopened", "返工", "重新打开"} REVIEW_ACCEPTED_STATUSES = {"accepted", "approved", "done"} REVIEW_REJECTED_STATUSES = {"rejected"} def _deepcopy_dict(value: Any) -> dict[str, Any]: if isinstance(value, dict): try: return json.loads(json.dumps(value, ensure_ascii=False)) except Exception: return dict(value) if isinstance(value, str): try: parsed = json.loads(value) if isinstance(parsed, dict): return parsed except Exception: return {} return {} def _safe_text(value: Any, *, fallback: str = "-") -> str: text = str(value or "").strip() return text or fallback def _iso_or_none(value: Optional[datetime]) -> Optional[str]: if value is None: return None return value.isoformat() class ExecutiveBriefingService: """Builds tenant-wide executive briefing payloads and config.""" def __init__(self, db: AsyncSession, *, tenant_id: int) -> None: self.db = db self.tenant_id = int(tenant_id) @classmethod def default_config(cls) -> dict[str, Any]: return { "enabled": True, "send_time": DEFAULT_SEND_TIME, "send_cycle": DEFAULT_SEND_CYCLE, "delivery_target_type": DEFAULT_DELIVERY_TARGET_TYPE, "delivery_target_ref": None, "timezone": DEFAULT_TIMEZONE, "updated_at": None, "updated_by": None, } async def get_config(self) -> dict[str, Any]: row = await self._get_config_row() if row is None: return self.default_config() normalized = self._normalize_config(row.config) if normalized != _deepcopy_dict(row.config): row.config = normalized row.is_active = True await self.db.flush() return normalized async def update_config(self, *, payload: dict[str, Any], operator_user_id: int) -> dict[str, Any]: normalized = self._normalize_config(payload) normalized["updated_by"] = int(operator_user_id) normalized["updated_at"] = now_cn_naive().isoformat() row = await self._get_config_row() if row is None: row = Integration( tenant_id=self.tenant_id, type=INTEGRATION_TYPE, config=normalized, is_active=True, ) self.db.add(row) else: row.config = normalized row.is_active = True await self.db.flush() return normalized async def get_briefing(self, *, mode: str) -> dict[str, Any]: normalized_mode = str(mode or "").strip().lower() or "snapshot" if normalized_mode not in {"snapshot", "live"}: raise ValueError("invalid mode") if normalized_mode == "snapshot": snapshot = await self._get_latest_today_snapshot() if snapshot is not None: payload = _deepcopy_dict(snapshot.payload) hero = payload.setdefault("hero", {}) payload["mode"] = "snapshot" hero["baseline_generated_at"] = hero.get("baseline_generated_at") or _iso_or_none(snapshot.created_at) payload["brief_date"] = payload.get("brief_date") or ( snapshot.created_at.date().isoformat() if snapshot.created_at else now_cn_naive().date().isoformat() ) return payload return await self.build_brief_payload(mode="snapshot", baseline_generated_at=None, live_generated_at=None) live_generated_at = now_cn_naive() latest_snapshot = await self._get_latest_snapshot() baseline_generated_at = latest_snapshot.created_at if latest_snapshot is not None else None return await self.build_brief_payload( mode="live", baseline_generated_at=baseline_generated_at, live_generated_at=live_generated_at, ) async def build_brief_payload( self, *, mode: str, baseline_generated_at: Optional[datetime], live_generated_at: Optional[datetime], ) -> dict[str, Any]: generated_at = live_generated_at or baseline_generated_at or now_cn_naive() since = generated_at - timedelta(hours=24) decision_signals = await self._list_priority_pending_signals(limit=3) pending_sources = await self._list_pending_signal_sources(limit=3) overloaded_members = await self._list_workload_members(kind="overloaded", limit=3) active_conflicts = await self._list_active_conflicts(limit=3) risk_notifications = await self._list_risk_notifications(since=since, limit=3) blocked_tasks = await self._list_blocked_tasks(limit=3) overdue_tasks = await self._list_overdue_tasks(now=generated_at, limit=3) recent_signals = await self._list_recent_signals(since=since, limit=3) idle_members = await self._list_workload_members(kind="idle", limit=3) pending_signal_count = await self._count_pending_signals() high_priority_signal_count = await self._count_high_priority_pending_signals() recent_signal_count = await self._count_recent_signals(since=since) pending_source_count = await self._count_pending_signal_sources() blocked_task_count = await self._count_blocked_tasks() overdue_task_count = await self._count_overdue_tasks(now=generated_at) risk_notification_count = await self._count_risk_notifications(since=since) overloaded_member_count = await self._count_workload_members(kind="overloaded") idle_member_count = await self._count_workload_members(kind="idle") active_conflict_count = await self._count_active_conflicts() feedback_stats = await self._get_feedback_stats() task_stats = await self._get_task_governance_stats() decision_items = [ *[ { "title": _safe_text(item.get("title"), fallback="高优先级信号待跟进"), "detail": _safe_text(item.get("detail")), "tone": "warning", "kind": "signal", } for item in decision_signals[:2] ], *[ { "title": _safe_text(item.get("title"), fallback="信号源待确认"), "detail": _safe_text(item.get("detail")), "tone": "warning", "kind": "signal_source", } for item in pending_sources[:1] ], *[ { "title": _safe_text(item.get("title"), fallback="成员持续过载"), "detail": _safe_text(item.get("detail")), "tone": "critical", "kind": "capacity", } for item in overloaded_members[:1] ], *[ { "title": _safe_text(item.get("title"), fallback="关键冲突待解"), "detail": _safe_text(item.get("detail")), "tone": "critical", "kind": "conflict", } for item in active_conflicts[:1] ], ][:3] decisions_section = { "key": "decisions", "title": "今日需拍板", "summary": ( f"有 {len(decision_items)} 项事项需要管理层拍板或立即关注。" if decision_items else "当前没有需要老板拍板的事项。" ), "metrics": [ {"label": "需拍板", "value": len(decision_items), "tone": "critical" if decision_items else "positive"}, {"label": "高优信号", "value": high_priority_signal_count, "tone": "warning"}, {"label": "活跃冲突", "value": active_conflict_count, "tone": "warning"}, ], "items": decision_items, "drilldown_tab": "signals", } input_items = [*recent_signals, *pending_sources][:3] input_section = { "key": "input_health", "title": "业务机会与输入健康", "summary": ( f"最近 24 小时新增 {recent_signal_count} 条信号,待处理 {pending_signal_count} 条。" if recent_signal_count or pending_signal_count or pending_source_count else "最近 24 小时没有新增输入,当前输入面为空。" ), "metrics": [ {"label": "24h 新信号", "value": recent_signal_count, "tone": "info"}, {"label": "待处理信号", "value": pending_signal_count, "tone": "warning"}, {"label": "待接入来源", "value": pending_source_count, "tone": "warning"}, ], "items": input_items, "drilldown_tab": "signals", } delivery_items = [*blocked_tasks, *overdue_tasks, *risk_notifications][:3] delivery_section = { "key": "delivery_risk", "title": "交付兑现与项目风险", "summary": ( f"当前有 {blocked_task_count} 个阻塞任务、{overdue_task_count} 个逾期任务。" if blocked_task_count or overdue_task_count or risk_notification_count else "当前交付节奏平稳,未发现显著的阻塞或逾期。" ), "metrics": [ {"label": "阻塞任务", "value": blocked_task_count, "tone": "critical" if blocked_task_count else "positive"}, {"label": "逾期任务", "value": overdue_task_count, "tone": "warning" if overdue_task_count else "positive"}, {"label": "风险通知", "value": risk_notification_count, "tone": "warning"}, ], "items": delivery_items, "drilldown_tab": "blockers", } capacity_items = [*overloaded_members, *idle_members, *active_conflicts][:3] capacity_section = { "key": "team_capacity", "title": "团队产能与协同失衡", "summary": ( f"当前过载成员 {overloaded_member_count} 人,闲置成员 {idle_member_count} 人。" if overloaded_member_count or idle_member_count or active_conflict_count else "团队产能分布基本均衡。" ), "metrics": [ {"label": "过载成员", "value": overloaded_member_count, "tone": "critical" if overloaded_member_count else "positive"}, {"label": "闲置成员", "value": idle_member_count, "tone": "info"}, {"label": "协同冲突", "value": active_conflict_count, "tone": "warning"}, ], "items": capacity_items, "drilldown_tab": "conflicts", } governance_items = self._build_governance_items(feedback_stats=feedback_stats, task_stats=task_stats) governance_section = { "key": "ai_governance", "title": "AI 治理与系统可信度", "summary": ( f"反馈待审 {feedback_stats['pending_count']} 条,人工覆盖率 {task_stats['manual_override_rate']}。" if feedback_stats["pending_count"] or feedback_stats["reviewed_total"] or task_stats["manual_override_count"] else "当前没有待处理的 AI 治理积压。" ), "metrics": [ {"label": "待审反馈", "value": feedback_stats["pending_count"], "tone": "warning"}, {"label": "人工覆盖率", "value": task_stats["manual_override_rate"], "tone": "info"}, {"label": "返工率", "value": task_stats["rework_rate"], "tone": "warning"}, ], "items": governance_items, "drilldown_tab": "feedback-center", } external_section = { "key": "external_metrics", "title": "外部经营指标", "summary": "首版仅保留占位,待接入 CRM / BI / 收入指标后展示。", "metrics": [ {"label": "接入状态", "value": "待接入", "tone": "muted"}, ], "items": [], "drilldown_tab": "overview", } sections = [ decisions_section, input_section, delivery_section, capacity_section, governance_section, external_section, ] decision_count = len(decision_items) anomaly_count = ( high_priority_signal_count + blocked_task_count + overdue_task_count + overloaded_member_count + active_conflict_count + feedback_stats["pending_count"] ) key_alerts = [ {"label": "需拍板", "value": decision_count, "tone": "critical"}, {"label": "阻塞任务", "value": blocked_task_count, "tone": "critical"}, {"label": "过载成员", "value": overloaded_member_count, "tone": "warning"}, {"label": "待审反馈", "value": feedback_stats["pending_count"], "tone": "warning"}, ] key_alerts = [item for item in key_alerts if self._alert_has_value(item.get("value"))][:4] return { "brief_date": generated_at.date().isoformat(), "mode": mode, "summary": { "cadence_type": "executive_brief", "decision_count": decision_count, "anomaly_count": anomaly_count, "high_priority_signal_count": high_priority_signal_count, "blocked_task_count": blocked_task_count, "overloaded_member_count": overloaded_member_count, "pending_feedback_count": feedback_stats["pending_count"], }, "hero": { "title": "老板晨报中心", "decision_count": decision_count, "anomaly_count": anomaly_count, "baseline_generated_at": _iso_or_none(baseline_generated_at), "live_generated_at": _iso_or_none(live_generated_at), "key_alerts": key_alerts, "snapshot_state": "ready" if baseline_generated_at else "missing", }, "sections": sections, "delivery": await self.get_config(), } def render_management_group_message(self, payload: dict[str, Any]) -> str: hero = payload.get("hero") if isinstance(payload.get("hero"), dict) else {} sections = payload.get("sections") if isinstance(payload.get("sections"), list) else [] lines = [ f"【{_safe_text(hero.get('title'), fallback='老板晨报')}】 {payload.get('brief_date') or '-'}", ( f"需拍板 {hero.get('decision_count', 0)} 项" f"|异常 {hero.get('anomaly_count', 0)} 项" ), ] generated_at = hero.get("baseline_generated_at") or hero.get("live_generated_at") if generated_at: lines.append(f"生成时间:{generated_at}") for section in sections[:4]: if not isinstance(section, dict): continue lines.append(f"\n{_safe_text(section.get('title'))}") summary = str(section.get("summary") or "").strip() if summary: lines.append(summary) items = section.get("items") if isinstance(section.get("items"), list) else [] for item in items[:3]: if not isinstance(item, dict): continue lines.append(f"- {_safe_text(item.get('title'))}:{_safe_text(item.get('detail'))}") return "\n".join(lines).strip() async def _get_config_row(self) -> Optional[Integration]: return ( await self.db.execute( select(Integration).where( and_( Integration.tenant_id == self.tenant_id, Integration.type == INTEGRATION_TYPE, ) ) ) ).scalar_one_or_none() async def _get_latest_snapshot(self) -> Optional[PortfolioSnapshot]: return ( await self.db.execute( select(PortfolioSnapshot) .where( and_( PortfolioSnapshot.tenant_id == self.tenant_id, PortfolioSnapshot.scope_type == "executive_brief", PortfolioSnapshot.scope_ref == f"tenant:{self.tenant_id}", ) ) .order_by(PortfolioSnapshot.id.desc()) .limit(1) ) ).scalar_one_or_none() async def _get_latest_today_snapshot(self) -> Optional[PortfolioSnapshot]: row = await self._get_latest_snapshot() if row is None or row.created_at is None: return None if row.created_at.date() != now_cn_naive().date(): return None return row def _normalize_config(self, raw: Any) -> dict[str, Any]: config = self.default_config() incoming = _deepcopy_dict(raw) config["enabled"] = bool(incoming.get("enabled", config["enabled"])) send_time = str(incoming.get("send_time") or config["send_time"]).strip() if not self._is_valid_send_time(send_time): raise ValueError("send_time must be HH:MM") config["send_time"] = send_time send_cycle = str(incoming.get("send_cycle") or config["send_cycle"]).strip().lower() if send_cycle not in VALID_SEND_CYCLES: raise ValueError("send_cycle must be workdays or daily") config["send_cycle"] = send_cycle delivery_target_type = str( incoming.get("delivery_target_type") or config["delivery_target_type"] ).strip().lower() if delivery_target_type not in VALID_DELIVERY_TARGET_TYPES: raise ValueError("delivery_target_type must be management_group") config["delivery_target_type"] = delivery_target_type delivery_target_ref = str(incoming.get("delivery_target_ref") or "").strip() or None config["delivery_target_ref"] = delivery_target_ref timezone_value = str(incoming.get("timezone") or config["timezone"]).strip() or DEFAULT_TIMEZONE config["timezone"] = self._normalize_timezone(timezone_value) config["updated_at"] = str(incoming.get("updated_at") or "").strip() or None updated_by = incoming.get("updated_by") try: config["updated_by"] = int(updated_by) if updated_by is not None else None except (TypeError, ValueError): config["updated_by"] = None return config @staticmethod def _normalize_timezone(timezone_value: str) -> str: try: ZoneInfo(timezone_value) return timezone_value except Exception: return DEFAULT_TIMEZONE @staticmethod def _is_valid_send_time(value: str) -> bool: if len(value) != 5 or value[2] != ":": return False hour_text, minute_text = value.split(":", 1) if not hour_text.isdigit() or not minute_text.isdigit(): return False hour = int(hour_text) minute = int(minute_text) return 0 <= hour <= 23 and 0 <= minute <= 59 @staticmethod def _alert_has_value(value: Any) -> bool: if isinstance(value, (int, float)): return value > 0 return bool(str(value or "").strip()) @staticmethod def _format_percent(numerator: int, denominator: int) -> str: if denominator <= 0: return "0%" return f"{round((numerator / denominator) * 100)}%" def _display_time(self, value: datetime) -> str: localized = value.replace(tzinfo=CN_TZ) if value.tzinfo is None else value.astimezone(CN_TZ) return localized.strftime("%m-%d %H:%M") async def _list_priority_pending_signals(self, *, limit: int) -> list[dict[str, Any]]: rows = ( await self.db.execute( select(Signal) .where( and_( Signal.tenant_id == self.tenant_id, Signal.priority.is_not(None), ) ) .order_by(Signal.created_at.desc(), Signal.id.desc()) .limit(max(limit * 4, limit)) ) ).scalars().all() items: list[dict[str, Any]] = [] for row in rows: priority = str(row.priority or "").strip().lower() status = str(row.status or row.planner_status or "").strip().lower() pending = (not bool(row.processed)) or status in PENDING_SIGNAL_STATUSES or str(row.planner_status or "").strip().lower() == "pending" if priority not in HIGH_SIGNAL_PRIORITIES or not pending: continue items.append( { "title": _safe_text(row.title or row.summary, fallback="高优先级信号待跟进"), "detail": f"{_safe_text(row.priority)}|{_safe_text(row.status or row.planner_status)}|{self._display_time(row.created_at)}", "tone": "warning", } ) if len(items) >= limit: break return items async def _list_pending_signal_sources(self, *, limit: int) -> list[dict[str, Any]]: rows = ( await self.db.execute( select(SignalSourceRegistry) .where( and_( SignalSourceRegistry.tenant_id == self.tenant_id, SignalSourceRegistry.onboarding_status == "pending", ) ) .order_by(SignalSourceRegistry.created_at.desc(), SignalSourceRegistry.id.desc()) .limit(limit) ) ).scalars().all() return [ { "title": _safe_text(row.source_name, fallback="信号源待确认"), "detail": f"{_safe_text(row.source_kind)}|{_safe_text(row.source_identifier)}", "tone": "warning", } for row in rows ] async def _list_recent_signals(self, *, since: datetime, limit: int) -> list[dict[str, Any]]: rows = ( await self.db.execute( select(Signal) .where( and_( Signal.tenant_id == self.tenant_id, Signal.created_at >= since, ) ) .order_by(Signal.created_at.desc(), Signal.id.desc()) .limit(limit) ) ).scalars().all() return [ { "title": _safe_text(row.title or row.summary, fallback="新增业务信号"), "detail": f"{_safe_text(row.priority)}|{_safe_text(row.status)}|{self._display_time(row.created_at)}", "tone": "info", } for row in rows ] async def _list_blocked_tasks(self, *, limit: int) -> list[dict[str, Any]]: rows = ( await self.db.execute( select(Task) .where( and_( Task.tenant_id == self.tenant_id, Task.status.in_(tuple(BLOCKED_TASK_STATUSES)), ) ) .order_by(Task.updated_at.desc(), Task.id.desc()) .limit(limit) ) ).scalars().all() return [ { "title": _safe_text(row.title, fallback="阻塞任务"), "detail": f"{_safe_text(row.priority)}|状态 {_safe_text(row.status)}", "tone": "critical", } for row in rows ] async def _list_overdue_tasks(self, *, now: datetime, limit: int) -> list[dict[str, Any]]: rows = ( await self.db.execute( select(Task) .where( and_( Task.tenant_id == self.tenant_id, Task.due_date.is_not(None), Task.due_date < now, Task.status.not_in(tuple(DONE_TASK_STATUSES)), ) ) .order_by(Task.due_date.asc(), Task.id.desc()) .limit(limit) ) ).scalars().all() return [ { "title": _safe_text(row.title, fallback="逾期任务"), "detail": f"截止 {row.due_date.date().isoformat() if row.due_date else '-'}|{_safe_text(row.status)}", "tone": "warning", } for row in rows ] async def _list_risk_notifications(self, *, since: datetime, limit: int) -> list[dict[str, Any]]: rows = ( await self.db.execute( select(Notification) .where( and_( Notification.tenant_id == self.tenant_id, Notification.created_at >= since, Notification.is_read.is_(False), ) ) .order_by(Notification.created_at.desc(), Notification.id.desc()) .limit(limit * 3) ) ).scalars().all() items: list[dict[str, Any]] = [] for row in rows: title = f"{row.title} {row.content or ''}".lower() if not any(keyword in title for keyword in ("risk", "风险", "blocked", "阻塞", "overdue", "逾期")): continue items.append( { "title": _safe_text(row.title, fallback="风险通知"), "detail": _safe_text(row.content or row.blocking_reason or row.priority), "tone": "warning", } ) if len(items) >= limit: break return items async def _list_active_conflicts(self, *, limit: int) -> list[dict[str, Any]]: rows = ( await self.db.execute( select(CognitiveConflict) .where(CognitiveConflict.tenant_id == self.tenant_id) .order_by(CognitiveConflict.created_at.desc(), CognitiveConflict.id.desc()) .limit(limit * 3) ) ).scalars().all() items: list[dict[str, Any]] = [] for row in rows: status = str(row.status or "").strip().lower() if status in ACTIVE_CONFLICT_DONE_STATUSES: continue items.append( { "title": _safe_text(row.conflict_type, fallback="认知冲突"), "detail": _safe_text(row.description), "tone": "critical", } ) if len(items) >= limit: break return items async def _list_workload_members(self, *, kind: str, limit: int) -> list[dict[str, Any]]: rows = ( await self.db.execute( select(User.name, UserWorkload.current_assigned_hours, UserWorkload.max_available_hours_per_week, UserWorkload.workload_ratio) .join(User, User.id == UserWorkload.user_id) .where(UserWorkload.tenant_id == self.tenant_id) .order_by(UserWorkload.workload_ratio.desc(), UserWorkload.id.desc()) ) ).all() items: list[dict[str, Any]] = [] for name, assigned_hours, max_hours, ratio_value in rows: ratio = float(ratio_value or 0) if ratio <= 0 and max_hours: try: ratio = float(assigned_hours or 0) / float(max_hours or 1) except Exception: ratio = 0 if kind == "overloaded" and ratio < 1: continue if kind == "idle" and ratio > 0.3: continue items.append( { "title": _safe_text(name, fallback="未命名成员"), "detail": f"{round(ratio * 100)}% 负载", "tone": "critical" if kind == "overloaded" else "info", } ) if len(items) >= limit: break return items async def _count_pending_signals(self) -> int: rows = ( await self.db.execute( select(Signal.processed, Signal.status, Signal.planner_status).where(Signal.tenant_id == self.tenant_id) ) ).all() total = 0 for processed, status, planner_status in rows: normalized_status = str(status or planner_status or "").strip().lower() normalized_planner_status = str(planner_status or "").strip().lower() if (not bool(processed)) or normalized_status in PENDING_SIGNAL_STATUSES or normalized_planner_status == "pending": total += 1 return total async def _count_high_priority_pending_signals(self) -> int: rows = ( await self.db.execute( select(Signal.priority, Signal.processed, Signal.status, Signal.planner_status).where(Signal.tenant_id == self.tenant_id) ) ).all() total = 0 for priority, processed, status, planner_status in rows: normalized_priority = str(priority or "").strip().lower() normalized_status = str(status or planner_status or "").strip().lower() pending = (not bool(processed)) or normalized_status in PENDING_SIGNAL_STATUSES or str(planner_status or "").strip().lower() == "pending" if normalized_priority in HIGH_SIGNAL_PRIORITIES and pending: total += 1 return total async def _count_recent_signals(self, *, since: datetime) -> int: return len( ( await self.db.execute( select(Signal.id).where( and_( Signal.tenant_id == self.tenant_id, Signal.created_at >= since, ) ) ) ).scalars().all() ) async def _count_pending_signal_sources(self) -> int: return len( ( await self.db.execute( select(SignalSourceRegistry.id).where( and_( SignalSourceRegistry.tenant_id == self.tenant_id, SignalSourceRegistry.onboarding_status == "pending", ) ) ) ).scalars().all() ) async def _count_blocked_tasks(self) -> int: return len( ( await self.db.execute( select(Task.id).where( and_( Task.tenant_id == self.tenant_id, Task.status.in_(tuple(BLOCKED_TASK_STATUSES)), ) ) ) ).scalars().all() ) async def _count_overdue_tasks(self, *, now: datetime) -> int: return len( ( await self.db.execute( select(Task.id).where( and_( Task.tenant_id == self.tenant_id, Task.due_date.is_not(None), Task.due_date < now, Task.status.not_in(tuple(DONE_TASK_STATUSES)), ) ) ) ).scalars().all() ) async def _count_risk_notifications(self, *, since: datetime) -> int: rows = ( await self.db.execute( select(Notification.title, Notification.content) .where( and_( Notification.tenant_id == self.tenant_id, Notification.created_at >= since, Notification.is_read.is_(False), ) ) ) ).all() total = 0 for title, content in rows: text = f"{title or ''} {content or ''}".lower() if any(keyword in text for keyword in ("risk", "风险", "blocked", "阻塞", "overdue", "逾期")): total += 1 return total async def _count_active_conflicts(self) -> int: rows = ( await self.db.execute(select(CognitiveConflict.status).where(CognitiveConflict.tenant_id == self.tenant_id)) ).scalars().all() return sum(1 for status in rows if str(status or "").strip().lower() not in ACTIVE_CONFLICT_DONE_STATUSES) async def _count_workload_members(self, *, kind: str) -> int: rows = await self._list_workload_members(kind=kind, limit=100) return len(rows) async def _get_feedback_stats(self) -> dict[str, Any]: rows = ( await self.db.execute( select(FeedbackCenterRecord.review_status, FeedbackCenterRecord.created_at, FeedbackCenterRecord.reviewed_at).where( FeedbackCenterRecord.tenant_id == self.tenant_id ) ) ).all() pending_count = 0 accepted_count = 0 rejected_count = 0 latency_samples: list[float] = [] for status, created_at, reviewed_at in rows: normalized_status = str(status or "").strip().lower() if normalized_status == "pending": pending_count += 1 elif normalized_status in REVIEW_ACCEPTED_STATUSES: accepted_count += 1 elif normalized_status in REVIEW_REJECTED_STATUSES: rejected_count += 1 if created_at and reviewed_at: latency_samples.append(max((reviewed_at - created_at).total_seconds() / 3600, 0)) avg_latency_hours = round(sum(latency_samples) / len(latency_samples), 1) if latency_samples else 0.0 reviewed_total = accepted_count + rejected_count return { "pending_count": pending_count, "accepted_count": accepted_count, "rejected_count": rejected_count, "reviewed_total": reviewed_total, "avg_review_latency_hours": avg_latency_hours, } async def _get_task_governance_stats(self) -> dict[str, Any]: rows = ( await self.db.execute(select(Task.manual_overrides, Task.status).where(Task.tenant_id == self.tenant_id)) ).all() total = len(rows) manual_override_count = 0 rework_count = 0 for manual_overrides, status in rows: if manual_overrides not in (None, {}, []): manual_override_count += 1 if str(status or "").strip().lower() in REWORK_TASK_STATUSES: rework_count += 1 return { "total": total, "manual_override_count": manual_override_count, "manual_override_rate": self._format_percent(manual_override_count, total), "rework_count": rework_count, "rework_rate": self._format_percent(rework_count, total), } def _build_governance_items(self, *, feedback_stats: dict[str, Any], task_stats: dict[str, Any]) -> list[dict[str, Any]]: items: list[dict[str, Any]] = [] if feedback_stats["pending_count"] > 0: items.append( { "title": "反馈中心待审积压", "detail": f"{feedback_stats['pending_count']} 条待审,平均复核延迟 {feedback_stats['avg_review_latency_hours']}h", "tone": "warning", } ) if task_stats["manual_override_count"] > 0: items.append( { "title": "人工覆盖偏高", "detail": f"当前人工覆盖率 {task_stats['manual_override_rate']}", "tone": "info", } ) if task_stats["rework_count"] > 0: items.append( { "title": "返工任务存在", "detail": f"当前返工率 {task_stats['rework_rate']}", "tone": "warning", } ) if not items: items.append( { "title": "治理状态正常", "detail": "当前没有显著的 AI 治理积压。", "tone": "positive", } ) return items[:3]