"""Tenant-level executive briefing service.""" from __future__ import annotations import json import re 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, Project, ProjectSignalLink, Signal, SignalSourceRegistry, Task, TruthNode, 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"} PRIORITY_LABELS = { "p0": "P0", "p1": "P1", "critical": "紧急", "high": "高优", "medium": "中优", "low": "低优", "紧急": "紧急", "高": "高优", } STATUS_LABELS = { "pending": "待处理", "todo": "待处理", "待处理": "待处理", "待确认": "待确认", "blocked": "阻塞", "blocking": "阻塞", "阻塞": "阻塞", "已阻塞": "阻塞", "scheduled": "定时任务", "active": "进行中", "in_progress": "进行中", "done": "已完成", "completed": "已完成", "closed": "已关闭", "resolved": "已解决", } SOURCE_KIND_LABELS = { "webhook": "Webhook 来源", "scheduled": "定时来源", "manual": "人工录入", "feishu": "飞书来源", "wecom": "企业微信来源", "github": "GitHub 仓库", "browser": "浏览器来源", "url": "网页来源", "custom": "自定义来源", "user_insight": "用户洞察来源", "social_media": "社媒来源", "data_analysis": "数据分析来源", "live_gift_analytics": "直播礼物分析来源", "market": "市场行情来源", "policy": "政策监测来源", "long": "长连接来源", } CONFLICT_TYPE_LABELS = { "skill_mismatch": "技能错配", "quality_drop": "质量下降预警", "schedule_delay": "进度延期预警", "risk": "风险预警", "unknown": "待识别冲突", } BRIEFING_KIND_LABELS = { "signal": "项目信号", "signal_source": "信号源", "capacity": "人员负载", "task": "项目任务", "notification": "项目风险", "conflict": "协作冲突", "briefing": "事项", } TECHNICAL_IDENTIFIER_RE = re.compile(r"^[a-z0-9][a-z0-9:._/-]*[a-z0-9]$", re.IGNORECASE) 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 _contains_cjk(text: str) -> bool: return any("\u4e00" <= char <= "\u9fff" for char in text) def _is_technical_identifier(value: Any) -> bool: text = str(value or "").strip() if not text or _contains_cjk(text) or " " in text: return False if not TECHNICAL_IDENTIFIER_RE.match(text): return False return any(separator in text for separator in ("_", ":", "/", "-", ".")) def _iso_or_none(value: Optional[datetime]) -> Optional[str]: if value is None: return None return value.isoformat() def _label_priority(value: Any) -> str: raw = str(value or "").strip() if not raw: return "-" return PRIORITY_LABELS.get(raw.lower(), raw) def _label_status(value: Any) -> str: raw = str(value or "").strip() if not raw: return "-" return STATUS_LABELS.get(raw.lower(), raw) def _label_source_kind(value: Any) -> str: raw = str(value or "").strip() if not raw: return "-" mapped = SOURCE_KIND_LABELS.get(raw.lower()) if mapped: return mapped if _is_technical_identifier(raw): return "其他来源" return raw def _label_conflict_type(value: Any) -> str: raw = str(value or "").strip() if not raw: return "待识别冲突" mapped = CONFLICT_TYPE_LABELS.get(raw.lower()) if mapped: return mapped if _is_technical_identifier(raw): return "其他冲突" return raw def _target( target_type: str, ref: str, label: Optional[str] = None, params: Optional[dict[str, Any]] = None, ) -> dict[str, Any]: payload: dict[str, Any] = {"type": str(target_type), "ref": str(ref)} if label: payload["label"] = str(label) if isinstance(params, dict) and params: payload["params"] = {str(key): value for key, value in params.items() if value not in (None, "", [])} return payload 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) self._project_name_cache: dict[int, str] = {} self._user_name_cache: dict[int, str] = {} @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) if self._snapshot_payload_is_current(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 # 兼容旧快照结构:默认页签优先返回当前可交互、可导出的新版结构, # 避免老板打开后仍停留在无详情、无精准跳转的旧版 payload。 rebuilt = await self.build_brief_payload( mode="snapshot", baseline_generated_at=snapshot.created_at, live_generated_at=None, ) rebuilt["brief_date"] = rebuilt.get("brief_date") or ( snapshot.created_at.date().isoformat() if snapshot.created_at else now_cn_naive().date().isoformat() ) return rebuilt 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, ) @staticmethod def _snapshot_payload_is_current(payload: dict[str, Any]) -> bool: sections = payload.get("sections") hero = payload.get("hero") if not isinstance(sections, list) or not isinstance(hero, dict): return False if str(hero.get("title") or "").strip() != "简报中心": return False for section in sections: if not isinstance(section, dict): return False items = section.get("items") if not isinstance(items, list): return False for item in items[:3]: if not isinstance(item, dict): return False if not str(item.get("id") or "").strip(): return False if not str(item.get("display_title") or "").strip(): return False if not str(item.get("display_subtitle") or "").strip(): return False if not str(item.get("display_source") or "").strip(): return False if not isinstance(item.get("detail_lines"), list): return False if not isinstance(item.get("meta"), list): return False target = item.get("target") if target is not None and not isinstance(target, dict): return False return True 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) item_limit = 12 decision_signals = await self._list_priority_pending_signals(limit=item_limit) pending_sources = await self._list_pending_signal_sources(limit=item_limit) overloaded_members = await self._list_workload_members(kind="overloaded", limit=item_limit) active_conflicts = await self._list_active_conflicts(limit=item_limit) risk_notifications = await self._list_risk_notifications(since=since, limit=item_limit) blocked_tasks = await self._list_blocked_tasks(limit=item_limit) overdue_tasks = await self._list_overdue_tasks(now=generated_at, limit=item_limit) recent_signals = await self._list_recent_signals(since=since, limit=item_limit) idle_members = await self._list_workload_members(kind="idle", limit=item_limit) 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 = [ *decision_signals, *pending_sources[:4], *overloaded_members[:4], *active_conflicts[:4], ][:item_limit] 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][:item_limit] 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][:item_limit] 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][:item_limit] 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 = await 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('display_title') or item.get('title'))}" f":{_safe_text(item.get('display_subtitle') or 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") @staticmethod def _detail_lines(*parts: Any) -> list[str]: lines: list[str] = [] for value in parts: text = str(value or "").strip() if text: lines.append(text) return lines @staticmethod def _pick_display_text(*values: Any) -> Optional[str]: fallback_text: Optional[str] = None for value in values: text = str(value or "").strip() if not text: continue if fallback_text is None: fallback_text = text if not _is_technical_identifier(text): return text return fallback_text @classmethod def _is_reportable_signal(cls, *, project_id: Optional[int], title: Optional[str]) -> bool: return project_id is not None and bool(cls._pick_display_text(title)) @classmethod def _is_reportable_task(cls, *, project_id: Optional[int], title: Optional[str]) -> bool: return project_id is not None and bool(cls._pick_display_text(title)) @classmethod def _is_reportable_signal_source(cls, *, title: Optional[str], source_identifier: Any) -> bool: return bool(cls._pick_display_text(title, source_identifier)) @classmethod def _is_reportable_notification( cls, *, project_id: Optional[int], user_id: Optional[int], title: Optional[str], content: Any, ) -> bool: return (project_id is not None or user_id is not None) and bool(cls._pick_display_text(title, content)) @classmethod def _is_reportable_conflict( cls, *, project_id: Optional[int], related_user_ids: list[int], title: Optional[str], description: Any, ) -> bool: return (project_id is not None or bool(related_user_ids)) and bool(cls._pick_display_text(title, description)) @staticmethod def _meta_entries(*pairs: tuple[str, Any]) -> list[dict[str, str]]: entries: list[dict[str, str]] = [] for label, value in pairs: label_text = str(label or "").strip() value_text = str(value or "").strip() if not label_text or not value_text: continue entries.append({"label": label_text, "value": value_text}) return entries @staticmethod def _display_subtitle(*values: Any) -> str: for value in values: text = str(value or "").strip() if text and not _is_technical_identifier(text): return text for value in values: text = str(value or "").strip() if text: return text return "-" async def _get_project_name(self, project_id: Optional[int]) -> Optional[str]: if project_id is None: return None if project_id in self._project_name_cache: return self._project_name_cache[project_id] row = ( await self.db.execute( select(Project.title, Project.name).where( and_( Project.tenant_id == self.tenant_id, Project.id == int(project_id), ) ) ) ).one_or_none() if row is None: return None title, name = row project_name = _safe_text(title or name, fallback=f"项目 #{project_id}") self._project_name_cache[int(project_id)] = project_name return project_name async def _get_user_name(self, user_id: Optional[int]) -> Optional[str]: if user_id is None: return None if user_id in self._user_name_cache: return self._user_name_cache[user_id] row = ( await self.db.execute( select(User.name, User.username).where( and_( User.tenant_id == self.tenant_id, User.id == int(user_id), ) ) ) ).one_or_none() if row is None: return None name, username = row user_name = _safe_text(name or username, fallback=f"成员 #{user_id}") self._user_name_cache[int(user_id)] = user_name return user_name async def _build_brief_item( self, *, item_id: str, title: Any, tone: str, kind: str, detail_lines: list[str], meta: list[dict[str, str]] | None = None, detail: Optional[str] = None, project_id: Optional[int] = None, user_id: Optional[int] = None, related_user_ids: Optional[list[int]] = None, occurred_at: Optional[datetime] = None, target: Optional[dict[str, Any]] = None, display_title: Optional[str] = None, display_subtitle: Optional[str] = None, display_source: Optional[str] = None, technical_identifier: Optional[str] = None, ) -> dict[str, Any]: cleaned_detail_lines = [line for line in detail_lines if str(line or "").strip()] cleaned_meta = [entry for entry in (meta or []) if str(entry.get("label") or "").strip() and str(entry.get("value") or "").strip()] safe_title = _safe_text(title, fallback="待处理事项") safe_display_title = _safe_text(display_title or safe_title, fallback="待处理事项") safe_display_subtitle = self._display_subtitle( display_subtitle, detail, *cleaned_detail_lines, ) safe_display_source = _safe_text( display_source or BRIEFING_KIND_LABELS.get(str(kind or "").strip().lower(), "事项"), fallback="事项", ) safe_technical_identifier = str(technical_identifier or "").strip() or None return { "id": str(item_id), "title": safe_title, "display_title": safe_display_title, "display_subtitle": safe_display_subtitle, "display_source": safe_display_source, "technical_identifier": safe_technical_identifier, "detail": detail or "|".join(cleaned_detail_lines[:3]) or "-", "detail_lines": cleaned_detail_lines, "meta": cleaned_meta, "tone": str(tone or "info"), "kind": str(kind or "briefing"), "target": target, "project_id": project_id, "project_name": await self._get_project_name(project_id), "user_id": user_id, "owner_name": await self._get_user_name(user_id), "related_user_ids": related_user_ids or [], "occurred_at": _iso_or_none(occurred_at), } 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 project_id = self._to_int(getattr(row, "matched_project_id", None)) user_id = self._to_int(getattr(row, "planner_assignee_user_id", None)) title_text = self._pick_display_text(row.title, row.summary) if not self._is_reportable_signal(project_id=project_id, title=title_text): continue priority_label = _label_priority(row.priority) status_label = _label_status(row.status or row.planner_status) occurred_label = self._display_time(row.created_at) items.append( await self._build_brief_item( item_id=f"signal:{row.id}", title=title_text, tone="warning", kind="signal", detail_lines=self._detail_lines( f"优先级 {priority_label}", f"状态 {status_label}", f"时间 {occurred_label}", _safe_text(row.summary, fallback=""), ), meta=self._meta_entries( ("优先级", priority_label), ("状态", status_label), ("负责人", await self._get_user_name(user_id)), ("项目", await self._get_project_name(project_id)), ("时间", occurred_label), ), project_id=project_id, user_id=user_id, occurred_at=row.created_at, target=self._project_target( project_id, params={ "stage": "signal", "signal_keyword": title_text, "signal_status": str(row.planner_status or row.status or "").strip().lower(), "signal_priority": str(row.priority or "").strip().lower(), }, ) or self._user_target(user_id) or _target("ai_steward_tab", "signals", "信号捕获"), ) ) 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() items: list[dict[str, Any]] = [] for row in rows: project_id = self._to_int(row.project_id) user_id = self._to_int(row.created_by_user_id) title_text = self._pick_display_text(row.source_name, row.source_identifier) if not self._is_reportable_signal_source(title=title_text, source_identifier=row.source_identifier): continue kind_label = _label_source_kind(row.source_kind) status_label = _label_status(row.onboarding_status) items.append( await self._build_brief_item( item_id=f"signal_source:{row.id}", title=title_text, tone="warning", kind="signal_source", detail_lines=self._detail_lines( kind_label, f"接入状态 {status_label}", _safe_text(row.description, fallback=""), ), meta=self._meta_entries( ("来源类型", kind_label), ("接入状态", status_label), ("负责人", await self._get_user_name(user_id)), ("项目", await self._get_project_name(project_id)), ), project_id=project_id, user_id=user_id, occurred_at=row.created_at, display_title=_safe_text(row.source_name, fallback=title_text or "未命名信号源"), display_subtitle=self._display_subtitle( f"{kind_label} · 接入状态 {status_label}", row.description, ), display_source=kind_label, technical_identifier=str(row.source_identifier or "").strip() or None, target=self._project_target( project_id, params={ "stage": "signal", }, ) or _target("ai_steward_tab", "signals", "信号捕获"), ) ) return items 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() items: list[dict[str, Any]] = [] for row in rows: project_id = self._to_int(row.matched_project_id) user_id = self._to_int(row.planner_assignee_user_id) title_text = self._pick_display_text(row.title, row.summary) if not self._is_reportable_signal(project_id=project_id, title=title_text): continue priority_label = _label_priority(row.priority) status_label = _label_status(row.status) occurred_label = self._display_time(row.created_at) items.append( await self._build_brief_item( item_id=f"signal:{row.id}", title=title_text, tone="info", kind="signal", detail_lines=self._detail_lines( f"优先级 {priority_label}", f"状态 {status_label}", f"时间 {occurred_label}", _safe_text(row.summary, fallback=""), ), meta=self._meta_entries( ("优先级", priority_label), ("状态", status_label), ("负责人", await self._get_user_name(user_id)), ("项目", await self._get_project_name(project_id)), ("时间", occurred_label), ), project_id=project_id, user_id=user_id, occurred_at=row.created_at, target=self._project_target( project_id, params={ "stage": "signal", "signal_keyword": title_text, "signal_status": str(row.status or "").strip().lower(), "signal_priority": str(row.priority or "").strip().lower(), }, ) or _target("ai_steward_tab", "signals", "信号捕获"), ) ) return items 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() items: list[dict[str, Any]] = [] for row in rows: project_id = self._to_int(row.project_id) user_id = self._to_int(row.assignee_id) title_text = self._pick_display_text(row.title) if not self._is_reportable_task(project_id=project_id, title=title_text): continue priority_label = _label_priority(row.priority) status_label = _label_status(row.status) items.append( await self._build_brief_item( item_id=f"task:{row.id}", title=title_text, tone="critical", kind="task", detail_lines=self._detail_lines( f"优先级 {priority_label}", f"状态 {status_label}", f"负责人 {await self._get_user_name(user_id) or '-'}", ), meta=self._meta_entries( ("优先级", priority_label), ("状态", status_label), ("负责人", await self._get_user_name(user_id)), ("项目", await self._get_project_name(project_id)), ), project_id=project_id, user_id=user_id, occurred_at=row.updated_at, target=self._project_target( project_id, params={ "stage": "task", "task_keyword": title_text, "task_status": str(row.status or "").strip().lower(), "task_priority": str(row.priority or "").strip().lower(), }, ) or _target("ai_steward_tab", "blockers", "阻塞检测"), ) ) return items 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() items: list[dict[str, Any]] = [] for row in rows: project_id = self._to_int(row.project_id) user_id = self._to_int(row.assignee_id) title_text = self._pick_display_text(row.title) if not self._is_reportable_task(project_id=project_id, title=title_text): continue due_label = row.due_date.date().isoformat() if row.due_date else "-" status_label = _label_status(row.status) priority_label = _label_priority(row.priority) items.append( await self._build_brief_item( item_id=f"task:{row.id}", title=title_text, tone="warning", kind="task", detail_lines=self._detail_lines( f"截止 {due_label}", f"状态 {status_label}", f"优先级 {priority_label}", ), meta=self._meta_entries( ("截止日期", due_label), ("状态", status_label), ("优先级", priority_label), ("负责人", await self._get_user_name(user_id)), ("项目", await self._get_project_name(project_id)), ), project_id=project_id, user_id=user_id, occurred_at=row.due_date, target=self._project_target( project_id, params={ "stage": "task", "task_keyword": title_text, "task_status": str(row.status or "").strip().lower(), "task_priority": str(row.priority or "").strip().lower(), }, ) or _target("ai_steward_tab", "blockers", "阻塞检测"), ) ) return items 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 notification_target = self._notification_target(row) project_id = self._to_int(getattr(row, "entity_id", None)) if str(getattr(row, "entity_type", "")).lower() == "project" else None user_id = self._to_int(getattr(row, "user_id", None)) title_text = self._pick_display_text(row.title, row.content, row.blocking_reason, row.priority) if not self._is_reportable_notification( project_id=project_id, user_id=user_id, title=title_text, content=row.content, ): continue items.append( await self._build_brief_item( item_id=f"notification:{row.id}", title=title_text, tone="warning", kind="notification", detail_lines=self._detail_lines( _safe_text(row.content or row.blocking_reason or row.priority), f"状态 {_label_status(row.status)}", f"时间 {self._display_time(row.created_at)}", ), meta=self._meta_entries( ("状态", _label_status(row.status)), ("优先级", _label_priority(row.priority)), ("负责人", await self._get_user_name(user_id)), ("项目", await self._get_project_name(project_id)), ), project_id=project_id, user_id=user_id, occurred_at=row.created_at, target=notification_target, ) ) 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, TruthNode.project_id) .join(TruthNode, TruthNode.id == CognitiveConflict.truth_node_id, isouter=True) .where(CognitiveConflict.tenant_id == self.tenant_id) .order_by(CognitiveConflict.created_at.desc(), CognitiveConflict.id.desc()) .limit(limit * 3) ) ).all() items: list[dict[str, Any]] = [] for row, project_id in rows: status = str(row.status or "").strip().lower() if status in ACTIVE_CONFLICT_DONE_STATUSES: continue normalized_project_id = self._to_int(project_id) related_user_ids = [uid for uid in [self._to_int(value) for value in (row.involved_user_ids or [])] if uid is not None] title_text = self._pick_display_text(row.conflict_type, row.description) if not self._is_reportable_conflict( project_id=normalized_project_id, related_user_ids=related_user_ids, title=title_text, description=row.description, ): continue related_names = [await self._get_user_name(uid) for uid in related_user_ids] items.append( await self._build_brief_item( item_id=f"conflict:{row.id}", title=title_text, tone="critical", kind="conflict", detail_lines=self._detail_lines( _safe_text(row.description), f"涉及 {len(related_user_ids)} 人", ), meta=self._meta_entries( ("相关项目", await self._get_project_name(normalized_project_id)), ("涉及成员", "、".join([name for name in related_names if name])), ), project_id=normalized_project_id, related_user_ids=related_user_ids, occurred_at=row.created_at, display_title=_label_conflict_type(row.conflict_type), display_subtitle=self._display_subtitle(row.description), display_source="协作冲突", technical_identifier=str(row.conflict_type or "").strip() if _is_technical_identifier(row.conflict_type) else None, target=self._project_target( normalized_project_id, params={"stage": "signal"}, ) or _target("ai_steward_tab", "conflicts", "认知冲突"), ) ) 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.id, 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 user_id, name, assigned_hours, max_hours, ratio_value in rows: ratio = self._display_workload_ratio( assigned_hours=assigned_hours, max_hours=max_hours, stored_ratio=ratio_value, ) if kind == "overloaded" and ratio < 1: continue if kind == "idle" and ratio > 0.3: continue normalized_user_id = self._to_int(user_id) ratio_percent = round(ratio * 100) items.append( await self._build_brief_item( item_id=f"capacity:{kind}:{user_id}", title=name, tone="critical" if kind == "overloaded" else "info", kind="capacity", detail_lines=self._detail_lines( f"{ratio_percent}% 负载", f"已分配 {round(float(assigned_hours or 0), 1)} 小时", f"周上限 {round(float(max_hours or 0), 1)} 小时", ), meta=self._meta_entries( ("负载", f"{ratio_percent}%"), ("已分配", f"{round(float(assigned_hours or 0), 1)} 小时"), ("周上限", f"{round(float(max_hours or 0), 1)} 小时"), ), user_id=normalized_user_id, target=self._user_target(normalized_user_id) or _target("ai_steward_tab", "conflicts", "团队产能"), ) ) 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, Signal.matched_project_id, Signal.title, Signal.summary, ).where(Signal.tenant_id == self.tenant_id) ) ).all() total = 0 for processed, status, planner_status, matched_project_id, title, summary in rows: normalized_status = str(status or planner_status or "").strip().lower() normalized_planner_status = str(planner_status or "").strip().lower() project_id = self._to_int(matched_project_id) title_text = self._pick_display_text(title, summary) if not self._is_reportable_signal(project_id=project_id, title=title_text): continue 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, Signal.matched_project_id, Signal.title, Signal.summary, ).where(Signal.tenant_id == self.tenant_id) ) ).all() total = 0 for priority, processed, status, planner_status, matched_project_id, title, summary in rows: normalized_priority = str(priority or "").strip().lower() normalized_status = str(status or planner_status or "").strip().lower() project_id = self._to_int(matched_project_id) title_text = self._pick_display_text(title, summary) if not self._is_reportable_signal(project_id=project_id, title=title_text): continue 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: rows = ( await self.db.execute( select(Signal.matched_project_id, Signal.title, Signal.summary).where( and_( Signal.tenant_id == self.tenant_id, Signal.created_at >= since, ) ) ) ).all() total = 0 for matched_project_id, title, summary in rows: project_id = self._to_int(matched_project_id) title_text = self._pick_display_text(title, summary) if self._is_reportable_signal(project_id=project_id, title=title_text): total += 1 return total 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), } async 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( await self._build_brief_item( item_id="governance:pending_feedback", title="反馈中心待审积压", tone="warning", kind="governance", detail_lines=self._detail_lines( f"{feedback_stats['pending_count']} 条待审", f"平均复核延迟 {feedback_stats['avg_review_latency_hours']}h", ), meta=self._meta_entries( ("待审反馈", feedback_stats["pending_count"]), ("平均复核延迟", f"{feedback_stats['avg_review_latency_hours']}h"), ), target=_target("ai_steward_tab", "feedback-center", "反馈中心"), ) ) if task_stats["manual_override_count"] > 0: items.append( await self._build_brief_item( item_id="governance:manual_override", title="人工覆盖偏高", tone="info", kind="governance", detail_lines=self._detail_lines(f"当前人工覆盖率 {task_stats['manual_override_rate']}"), meta=self._meta_entries( ("人工覆盖率", task_stats["manual_override_rate"]), ("人工覆盖次数", task_stats["manual_override_count"]), ), target=_target("ai_steward_tab", "feedback-center", "反馈中心"), ) ) if task_stats["rework_count"] > 0: items.append( await self._build_brief_item( item_id="governance:rework", title="返工任务存在", tone="warning", kind="governance", detail_lines=self._detail_lines(f"当前返工率 {task_stats['rework_rate']}"), meta=self._meta_entries( ("返工率", task_stats["rework_rate"]), ("返工任务数", task_stats["rework_count"]), ), target=_target("ai_steward_tab", "feedback-center", "反馈中心"), ) ) if not items: items.append( await self._build_brief_item( item_id="governance:healthy", title="治理状态正常", tone="positive", kind="governance", detail_lines=self._detail_lines("当前没有显著的 AI 治理积压。"), meta=[], target=_target("ai_steward_tab", "feedback-center", "反馈中心"), ) ) return items[:3] @staticmethod def _to_int(value: Any) -> Optional[int]: try: if value in (None, ""): return None return int(value) except (TypeError, ValueError): return None @staticmethod def _normalize_workload_ratio(value: Any) -> float: try: ratio = float(value or 0.0) except (TypeError, ValueError): return 0.0 if ratio <= 0: return 0.0 return ratio / 100.0 if ratio > 1.0 else ratio @classmethod def _display_workload_ratio(cls, *, assigned_hours: Any, max_hours: Any, stored_ratio: Any) -> float: try: assigned = max(float(assigned_hours or 0.0), 0.0) except (TypeError, ValueError): assigned = 0.0 try: weekly_limit = float(max_hours or 0.0) except (TypeError, ValueError): weekly_limit = 0.0 if weekly_limit > 0: return assigned / weekly_limit return cls._normalize_workload_ratio(stored_ratio) def _project_target( self, project_id: Optional[int], *, params: Optional[dict[str, Any]] = None, ) -> Optional[dict[str, Any]]: if project_id is None: return None return _target("project_board", str(project_id), "项目看板", params=params) def _user_target(self, user_id: Optional[int], *, params: Optional[dict[str, Any]] = None) -> Optional[dict[str, Any]]: if user_id is None: return None return _target("workload_detail", str(user_id), "成员负载", params=params) def _notification_target(self, row: Notification) -> dict[str, Any]: target_url = str(getattr(row, "target_url", "") or "").strip() if target_url.startswith("/projects/") and "/board" in target_url: project_ref = target_url.split("/projects/", 1)[1].split("/", 1)[0] if project_ref.isdigit(): return _target("project_board", project_ref, "项目看板", params={"stage": "task"}) if target_url.startswith("/workloads/"): user_ref = target_url.split("/workloads/", 1)[1].split("/", 1)[0] if user_ref.isdigit(): return _target("workload_detail", user_ref, "成员负载") entity_type = str(getattr(row, "entity_type", "") or "").strip().lower() entity_id = self._to_int(getattr(row, "entity_id", None)) if entity_type == "project" and entity_id is not None: return _target("project_board", str(entity_id), "项目看板", params={"stage": "task"}) if entity_type in {"user", "member"} and entity_id is not None: return _target("user_profile", str(entity_id), "成员档案") return _target("ai_steward_tab", "blockers", "阻塞检测")