PMFlow/backend/app/services/executive_briefing_service.py

1519 lines
63 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"""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,
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": "飞书来源",
}
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()
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 "-"
return SOURCE_KIND_LABELS.get(raw.lower(), 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 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('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")
@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]:
for value in values:
text = str(value or "").strip()
if text:
return text
return None
@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
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,
) -> 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()]
return {
"id": str(item_id),
"title": _safe_text(title, fallback="待处理事项"),
"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)
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,
_safe_text(row.source_identifier),
f"状态 {_label_status(row.onboarding_status)}",
),
meta=self._meta_entries(
("来源类型", kind_label),
("标识", _safe_text(row.source_identifier)),
("接入状态", _label_status(row.onboarding_status)),
("负责人", 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=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,
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", "阻塞检测")