928 lines
38 KiB
Python
928 lines
38 KiB
Python
"""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]
|
||
|