PMFlow/backend/app/services/executive_briefing_service.py

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