1636 lines
68 KiB
Python
1636 lines
68 KiB
Python
"""Tenant-level executive briefing service."""
|
||
|
||
from __future__ import annotations
|
||
|
||
import json
|
||
import re
|
||
from datetime import datetime, timedelta
|
||
from typing import Any, Optional
|
||
from zoneinfo import ZoneInfo
|
||
|
||
from sqlalchemy import and_, select
|
||
from sqlalchemy.ext.asyncio import AsyncSession
|
||
|
||
from ..models import (
|
||
CognitiveConflict,
|
||
FeedbackCenterRecord,
|
||
Integration,
|
||
Notification,
|
||
PortfolioSnapshot,
|
||
Project,
|
||
ProjectSignalLink,
|
||
Signal,
|
||
SignalSourceRegistry,
|
||
Task,
|
||
TruthNode,
|
||
User,
|
||
UserWorkload,
|
||
)
|
||
from ..utils.timezone import CN_TZ, now_cn_naive
|
||
|
||
INTEGRATION_TYPE = "executive_briefing"
|
||
DEFAULT_TIMEZONE = "Asia/Shanghai"
|
||
DEFAULT_SEND_TIME = "08:30"
|
||
DEFAULT_SEND_CYCLE = "workdays"
|
||
DEFAULT_DELIVERY_TARGET_TYPE = "management_group"
|
||
VALID_SEND_CYCLES = {"workdays", "daily"}
|
||
VALID_DELIVERY_TARGET_TYPES = {DEFAULT_DELIVERY_TARGET_TYPE}
|
||
HIGH_SIGNAL_PRIORITIES = {"p0", "p1", "high", "critical", "紧急", "高"}
|
||
PENDING_SIGNAL_STATUSES = {"pending", "todo", "待处理", "待确认"}
|
||
BLOCKED_TASK_STATUSES = {"blocked", "blocking", "阻塞", "已阻塞"}
|
||
DONE_TASK_STATUSES = {"done", "completed", "closed", "已完成", "已关闭"}
|
||
ACTIVE_CONFLICT_DONE_STATUSES = {"resolved", "closed", "done", "已解决", "已关闭"}
|
||
REWORK_TASK_STATUSES = {"rework", "reopened", "返工", "重新打开"}
|
||
REVIEW_ACCEPTED_STATUSES = {"accepted", "approved", "done"}
|
||
REVIEW_REJECTED_STATUSES = {"rejected"}
|
||
PRIORITY_LABELS = {
|
||
"p0": "P0",
|
||
"p1": "P1",
|
||
"critical": "紧急",
|
||
"high": "高优",
|
||
"medium": "中优",
|
||
"low": "低优",
|
||
"紧急": "紧急",
|
||
"高": "高优",
|
||
}
|
||
STATUS_LABELS = {
|
||
"pending": "待处理",
|
||
"todo": "待处理",
|
||
"待处理": "待处理",
|
||
"待确认": "待确认",
|
||
"blocked": "阻塞",
|
||
"blocking": "阻塞",
|
||
"阻塞": "阻塞",
|
||
"已阻塞": "阻塞",
|
||
"scheduled": "定时任务",
|
||
"active": "进行中",
|
||
"in_progress": "进行中",
|
||
"done": "已完成",
|
||
"completed": "已完成",
|
||
"closed": "已关闭",
|
||
"resolved": "已解决",
|
||
}
|
||
SOURCE_KIND_LABELS = {
|
||
"webhook": "Webhook 来源",
|
||
"scheduled": "定时来源",
|
||
"manual": "人工录入",
|
||
"feishu": "飞书来源",
|
||
"wecom": "企业微信来源",
|
||
"github": "GitHub 仓库",
|
||
"browser": "浏览器来源",
|
||
"url": "网页来源",
|
||
"custom": "自定义来源",
|
||
"user_insight": "用户洞察来源",
|
||
"social_media": "社媒来源",
|
||
"data_analysis": "数据分析来源",
|
||
"live_gift_analytics": "直播礼物分析来源",
|
||
"market": "市场行情来源",
|
||
"policy": "政策监测来源",
|
||
"long": "长连接来源",
|
||
}
|
||
CONFLICT_TYPE_LABELS = {
|
||
"skill_mismatch": "技能错配",
|
||
"quality_drop": "质量下降预警",
|
||
"schedule_delay": "进度延期预警",
|
||
"risk": "风险预警",
|
||
"unknown": "待识别冲突",
|
||
}
|
||
BRIEFING_KIND_LABELS = {
|
||
"signal": "项目信号",
|
||
"signal_source": "信号源",
|
||
"capacity": "人员负载",
|
||
"task": "项目任务",
|
||
"notification": "项目风险",
|
||
"conflict": "协作冲突",
|
||
"briefing": "事项",
|
||
}
|
||
TECHNICAL_IDENTIFIER_RE = re.compile(r"^[a-z0-9][a-z0-9:._/-]*[a-z0-9]$", re.IGNORECASE)
|
||
|
||
|
||
def _deepcopy_dict(value: Any) -> dict[str, Any]:
|
||
if isinstance(value, dict):
|
||
try:
|
||
return json.loads(json.dumps(value, ensure_ascii=False))
|
||
except Exception:
|
||
return dict(value)
|
||
if isinstance(value, str):
|
||
try:
|
||
parsed = json.loads(value)
|
||
if isinstance(parsed, dict):
|
||
return parsed
|
||
except Exception:
|
||
return {}
|
||
return {}
|
||
|
||
|
||
def _safe_text(value: Any, *, fallback: str = "-") -> str:
|
||
text = str(value or "").strip()
|
||
return text or fallback
|
||
|
||
|
||
def _contains_cjk(text: str) -> bool:
|
||
return any("\u4e00" <= char <= "\u9fff" for char in text)
|
||
|
||
|
||
def _looks_like_technical_identifier(value: Any) -> bool:
|
||
text = str(value or "").strip()
|
||
if not text or _contains_cjk(text) or " " in text:
|
||
return False
|
||
if not TECHNICAL_IDENTIFIER_RE.match(text):
|
||
return False
|
||
return any(separator in text for separator in ("_", ":", "/", "-", "."))
|
||
|
||
|
||
def _iso_or_none(value: Optional[datetime]) -> Optional[str]:
|
||
if value is None:
|
||
return None
|
||
return value.isoformat()
|
||
|
||
|
||
def _label_priority(value: Any) -> str:
|
||
raw = str(value or "").strip()
|
||
if not raw:
|
||
return "-"
|
||
return PRIORITY_LABELS.get(raw.lower(), raw)
|
||
|
||
|
||
def _label_status(value: Any) -> str:
|
||
raw = str(value or "").strip()
|
||
if not raw:
|
||
return "-"
|
||
return STATUS_LABELS.get(raw.lower(), raw)
|
||
|
||
|
||
def _label_source_kind(value: Any) -> str:
|
||
raw = str(value or "").strip()
|
||
if not raw:
|
||
return "-"
|
||
mapped = SOURCE_KIND_LABELS.get(raw.lower())
|
||
if mapped:
|
||
return mapped
|
||
if _looks_like_technical_identifier(raw):
|
||
return "其他来源"
|
||
return raw
|
||
|
||
|
||
def _label_conflict_type(value: Any) -> str:
|
||
raw = str(value or "").strip()
|
||
if not raw:
|
||
return "待识别冲突"
|
||
mapped = CONFLICT_TYPE_LABELS.get(raw.lower())
|
||
if mapped:
|
||
return mapped
|
||
if _looks_like_technical_identifier(raw):
|
||
return "其他冲突"
|
||
return raw
|
||
|
||
|
||
def _target(
|
||
target_type: str,
|
||
ref: str,
|
||
label: Optional[str] = None,
|
||
params: Optional[dict[str, Any]] = None,
|
||
) -> dict[str, Any]:
|
||
payload: dict[str, Any] = {"type": str(target_type), "ref": str(ref)}
|
||
if label:
|
||
payload["label"] = str(label)
|
||
if isinstance(params, dict) and params:
|
||
payload["params"] = {str(key): value for key, value in params.items() if value not in (None, "", [])}
|
||
return payload
|
||
|
||
|
||
class ExecutiveBriefingService:
|
||
"""Builds tenant-wide executive briefing payloads and config."""
|
||
|
||
def __init__(self, db: AsyncSession, *, tenant_id: int) -> None:
|
||
self.db = db
|
||
self.tenant_id = int(tenant_id)
|
||
self._project_name_cache: dict[int, str] = {}
|
||
self._user_name_cache: dict[int, str] = {}
|
||
|
||
@classmethod
|
||
def default_config(cls) -> dict[str, Any]:
|
||
return {
|
||
"enabled": True,
|
||
"send_time": DEFAULT_SEND_TIME,
|
||
"send_cycle": DEFAULT_SEND_CYCLE,
|
||
"delivery_target_type": DEFAULT_DELIVERY_TARGET_TYPE,
|
||
"delivery_target_ref": None,
|
||
"timezone": DEFAULT_TIMEZONE,
|
||
"updated_at": None,
|
||
"updated_by": None,
|
||
}
|
||
|
||
async def get_config(self) -> dict[str, Any]:
|
||
row = await self._get_config_row()
|
||
if row is None:
|
||
return self.default_config()
|
||
normalized = self._normalize_config(row.config)
|
||
if normalized != _deepcopy_dict(row.config):
|
||
row.config = normalized
|
||
row.is_active = True
|
||
await self.db.flush()
|
||
return normalized
|
||
|
||
async def update_config(self, *, payload: dict[str, Any], operator_user_id: int) -> dict[str, Any]:
|
||
normalized = self._normalize_config(payload)
|
||
normalized["updated_by"] = int(operator_user_id)
|
||
normalized["updated_at"] = now_cn_naive().isoformat()
|
||
|
||
row = await self._get_config_row()
|
||
if row is None:
|
||
row = Integration(
|
||
tenant_id=self.tenant_id,
|
||
type=INTEGRATION_TYPE,
|
||
config=normalized,
|
||
is_active=True,
|
||
)
|
||
self.db.add(row)
|
||
else:
|
||
row.config = normalized
|
||
row.is_active = True
|
||
await self.db.flush()
|
||
return normalized
|
||
|
||
async def get_briefing(self, *, mode: str) -> dict[str, Any]:
|
||
normalized_mode = str(mode or "").strip().lower() or "snapshot"
|
||
if normalized_mode not in {"snapshot", "live"}:
|
||
raise ValueError("invalid mode")
|
||
|
||
if normalized_mode == "snapshot":
|
||
snapshot = await self._get_latest_today_snapshot()
|
||
if snapshot is not None:
|
||
payload = _deepcopy_dict(snapshot.payload)
|
||
if self._snapshot_payload_is_current(payload):
|
||
hero = payload.setdefault("hero", {})
|
||
payload["mode"] = "snapshot"
|
||
hero["baseline_generated_at"] = hero.get("baseline_generated_at") or _iso_or_none(snapshot.created_at)
|
||
payload["brief_date"] = payload.get("brief_date") or (
|
||
snapshot.created_at.date().isoformat() if snapshot.created_at else now_cn_naive().date().isoformat()
|
||
)
|
||
return payload
|
||
|
||
# 兼容旧快照结构:默认页签优先返回当前可交互、可导出的新版结构,
|
||
# 避免老板打开后仍停留在无详情、无精准跳转的旧版 payload。
|
||
rebuilt = await self.build_brief_payload(
|
||
mode="snapshot",
|
||
baseline_generated_at=snapshot.created_at,
|
||
live_generated_at=None,
|
||
)
|
||
rebuilt["brief_date"] = rebuilt.get("brief_date") or (
|
||
snapshot.created_at.date().isoformat() if snapshot.created_at else now_cn_naive().date().isoformat()
|
||
)
|
||
return rebuilt
|
||
return await self.build_brief_payload(mode="snapshot", baseline_generated_at=None, live_generated_at=None)
|
||
|
||
live_generated_at = now_cn_naive()
|
||
latest_snapshot = await self._get_latest_snapshot()
|
||
baseline_generated_at = latest_snapshot.created_at if latest_snapshot is not None else None
|
||
return await self.build_brief_payload(
|
||
mode="live",
|
||
baseline_generated_at=baseline_generated_at,
|
||
live_generated_at=live_generated_at,
|
||
)
|
||
|
||
@staticmethod
|
||
def _snapshot_payload_is_current(payload: dict[str, Any]) -> bool:
|
||
sections = payload.get("sections")
|
||
hero = payload.get("hero")
|
||
if not isinstance(sections, list) or not isinstance(hero, dict):
|
||
return False
|
||
if str(hero.get("title") or "").strip() != "简报中心":
|
||
return False
|
||
|
||
for section in sections:
|
||
if not isinstance(section, dict):
|
||
return False
|
||
items = section.get("items")
|
||
if not isinstance(items, list):
|
||
return False
|
||
for item in items[:3]:
|
||
if not isinstance(item, dict):
|
||
return False
|
||
if not str(item.get("id") or "").strip():
|
||
return False
|
||
if not str(item.get("display_title") or "").strip():
|
||
return False
|
||
if not str(item.get("display_subtitle") or "").strip():
|
||
return False
|
||
if not str(item.get("display_source") or "").strip():
|
||
return False
|
||
if not isinstance(item.get("detail_lines"), list):
|
||
return False
|
||
if not isinstance(item.get("meta"), list):
|
||
return False
|
||
target = item.get("target")
|
||
if target is not None and not isinstance(target, dict):
|
||
return False
|
||
return True
|
||
|
||
async def build_brief_payload(
|
||
self,
|
||
*,
|
||
mode: str,
|
||
baseline_generated_at: Optional[datetime],
|
||
live_generated_at: Optional[datetime],
|
||
) -> dict[str, Any]:
|
||
generated_at = live_generated_at or baseline_generated_at or now_cn_naive()
|
||
since = generated_at - timedelta(hours=24)
|
||
|
||
item_limit = 12
|
||
|
||
decision_signals = await self._list_priority_pending_signals(limit=item_limit)
|
||
pending_sources = await self._list_pending_signal_sources(limit=item_limit)
|
||
overloaded_members = await self._list_workload_members(kind="overloaded", limit=item_limit)
|
||
active_conflicts = await self._list_active_conflicts(limit=item_limit)
|
||
risk_notifications = await self._list_risk_notifications(since=since, limit=item_limit)
|
||
blocked_tasks = await self._list_blocked_tasks(limit=item_limit)
|
||
overdue_tasks = await self._list_overdue_tasks(now=generated_at, limit=item_limit)
|
||
recent_signals = await self._list_recent_signals(since=since, limit=item_limit)
|
||
idle_members = await self._list_workload_members(kind="idle", limit=item_limit)
|
||
|
||
pending_signal_count = await self._count_pending_signals()
|
||
high_priority_signal_count = await self._count_high_priority_pending_signals()
|
||
recent_signal_count = await self._count_recent_signals(since=since)
|
||
pending_source_count = await self._count_pending_signal_sources()
|
||
blocked_task_count = await self._count_blocked_tasks()
|
||
overdue_task_count = await self._count_overdue_tasks(now=generated_at)
|
||
risk_notification_count = await self._count_risk_notifications(since=since)
|
||
overloaded_member_count = await self._count_workload_members(kind="overloaded")
|
||
idle_member_count = await self._count_workload_members(kind="idle")
|
||
active_conflict_count = await self._count_active_conflicts()
|
||
feedback_stats = await self._get_feedback_stats()
|
||
task_stats = await self._get_task_governance_stats()
|
||
|
||
decision_items = [
|
||
*decision_signals,
|
||
*pending_sources[:4],
|
||
*overloaded_members[:4],
|
||
*active_conflicts[:4],
|
||
][:item_limit]
|
||
|
||
decisions_section = {
|
||
"key": "decisions",
|
||
"title": "今日需拍板",
|
||
"summary": (
|
||
f"有 {len(decision_items)} 项事项需要管理层拍板或立即关注。"
|
||
if decision_items
|
||
else "当前没有需要老板拍板的事项。"
|
||
),
|
||
"metrics": [
|
||
{"label": "需拍板", "value": len(decision_items), "tone": "critical" if decision_items else "positive"},
|
||
{"label": "高优信号", "value": high_priority_signal_count, "tone": "warning"},
|
||
{"label": "活跃冲突", "value": active_conflict_count, "tone": "warning"},
|
||
],
|
||
"items": decision_items,
|
||
"drilldown_tab": "signals",
|
||
}
|
||
|
||
input_items = [*recent_signals, *pending_sources][:item_limit]
|
||
input_section = {
|
||
"key": "input_health",
|
||
"title": "业务机会与输入健康",
|
||
"summary": (
|
||
f"最近 24 小时新增 {recent_signal_count} 条信号,待处理 {pending_signal_count} 条。"
|
||
if recent_signal_count or pending_signal_count or pending_source_count
|
||
else "最近 24 小时没有新增输入,当前输入面为空。"
|
||
),
|
||
"metrics": [
|
||
{"label": "24h 新信号", "value": recent_signal_count, "tone": "info"},
|
||
{"label": "待处理信号", "value": pending_signal_count, "tone": "warning"},
|
||
{"label": "待接入来源", "value": pending_source_count, "tone": "warning"},
|
||
],
|
||
"items": input_items,
|
||
"drilldown_tab": "signals",
|
||
}
|
||
|
||
delivery_items = [*blocked_tasks, *overdue_tasks, *risk_notifications][:item_limit]
|
||
delivery_section = {
|
||
"key": "delivery_risk",
|
||
"title": "交付兑现与项目风险",
|
||
"summary": (
|
||
f"当前有 {blocked_task_count} 个阻塞任务、{overdue_task_count} 个逾期任务。"
|
||
if blocked_task_count or overdue_task_count or risk_notification_count
|
||
else "当前交付节奏平稳,未发现显著的阻塞或逾期。"
|
||
),
|
||
"metrics": [
|
||
{"label": "阻塞任务", "value": blocked_task_count, "tone": "critical" if blocked_task_count else "positive"},
|
||
{"label": "逾期任务", "value": overdue_task_count, "tone": "warning" if overdue_task_count else "positive"},
|
||
{"label": "风险通知", "value": risk_notification_count, "tone": "warning"},
|
||
],
|
||
"items": delivery_items,
|
||
"drilldown_tab": "blockers",
|
||
}
|
||
|
||
capacity_items = [*overloaded_members, *idle_members, *active_conflicts][:item_limit]
|
||
capacity_section = {
|
||
"key": "team_capacity",
|
||
"title": "团队产能与协同失衡",
|
||
"summary": (
|
||
f"当前过载成员 {overloaded_member_count} 人,闲置成员 {idle_member_count} 人。"
|
||
if overloaded_member_count or idle_member_count or active_conflict_count
|
||
else "团队产能分布基本均衡。"
|
||
),
|
||
"metrics": [
|
||
{"label": "过载成员", "value": overloaded_member_count, "tone": "critical" if overloaded_member_count else "positive"},
|
||
{"label": "闲置成员", "value": idle_member_count, "tone": "info"},
|
||
{"label": "协同冲突", "value": active_conflict_count, "tone": "warning"},
|
||
],
|
||
"items": capacity_items,
|
||
"drilldown_tab": "conflicts",
|
||
}
|
||
|
||
governance_items = await self._build_governance_items(feedback_stats=feedback_stats, task_stats=task_stats)
|
||
governance_section = {
|
||
"key": "ai_governance",
|
||
"title": "AI 治理与系统可信度",
|
||
"summary": (
|
||
f"反馈待审 {feedback_stats['pending_count']} 条,人工覆盖率 {task_stats['manual_override_rate']}。"
|
||
if feedback_stats["pending_count"] or feedback_stats["reviewed_total"] or task_stats["manual_override_count"]
|
||
else "当前没有待处理的 AI 治理积压。"
|
||
),
|
||
"metrics": [
|
||
{"label": "待审反馈", "value": feedback_stats["pending_count"], "tone": "warning"},
|
||
{"label": "人工覆盖率", "value": task_stats["manual_override_rate"], "tone": "info"},
|
||
{"label": "返工率", "value": task_stats["rework_rate"], "tone": "warning"},
|
||
],
|
||
"items": governance_items,
|
||
"drilldown_tab": "feedback-center",
|
||
}
|
||
|
||
external_section = {
|
||
"key": "external_metrics",
|
||
"title": "外部经营指标",
|
||
"summary": "首版仅保留占位,待接入 CRM / BI / 收入指标后展示。",
|
||
"metrics": [
|
||
{"label": "接入状态", "value": "待接入", "tone": "muted"},
|
||
],
|
||
"items": [],
|
||
"drilldown_tab": "overview",
|
||
}
|
||
|
||
sections = [
|
||
decisions_section,
|
||
input_section,
|
||
delivery_section,
|
||
capacity_section,
|
||
governance_section,
|
||
external_section,
|
||
]
|
||
|
||
decision_count = len(decision_items)
|
||
anomaly_count = (
|
||
high_priority_signal_count
|
||
+ blocked_task_count
|
||
+ overdue_task_count
|
||
+ overloaded_member_count
|
||
+ active_conflict_count
|
||
+ feedback_stats["pending_count"]
|
||
)
|
||
|
||
key_alerts = [
|
||
{"label": "需拍板", "value": decision_count, "tone": "critical"},
|
||
{"label": "阻塞任务", "value": blocked_task_count, "tone": "critical"},
|
||
{"label": "过载成员", "value": overloaded_member_count, "tone": "warning"},
|
||
{"label": "待审反馈", "value": feedback_stats["pending_count"], "tone": "warning"},
|
||
]
|
||
key_alerts = [item for item in key_alerts if self._alert_has_value(item.get("value"))][:4]
|
||
|
||
return {
|
||
"brief_date": generated_at.date().isoformat(),
|
||
"mode": mode,
|
||
"summary": {
|
||
"cadence_type": "executive_brief",
|
||
"decision_count": decision_count,
|
||
"anomaly_count": anomaly_count,
|
||
"high_priority_signal_count": high_priority_signal_count,
|
||
"blocked_task_count": blocked_task_count,
|
||
"overloaded_member_count": overloaded_member_count,
|
||
"pending_feedback_count": feedback_stats["pending_count"],
|
||
},
|
||
"hero": {
|
||
"title": "简报中心",
|
||
"decision_count": decision_count,
|
||
"anomaly_count": anomaly_count,
|
||
"baseline_generated_at": _iso_or_none(baseline_generated_at),
|
||
"live_generated_at": _iso_or_none(live_generated_at),
|
||
"key_alerts": key_alerts,
|
||
"snapshot_state": "ready" if baseline_generated_at else "missing",
|
||
},
|
||
"sections": sections,
|
||
"delivery": await self.get_config(),
|
||
}
|
||
|
||
def render_management_group_message(self, payload: dict[str, Any]) -> str:
|
||
hero = payload.get("hero") if isinstance(payload.get("hero"), dict) else {}
|
||
sections = payload.get("sections") if isinstance(payload.get("sections"), list) else []
|
||
lines = [
|
||
f"【{_safe_text(hero.get('title'), fallback='老板晨报')}】 {payload.get('brief_date') or '-'}",
|
||
(
|
||
f"需拍板 {hero.get('decision_count', 0)} 项"
|
||
f"|异常 {hero.get('anomaly_count', 0)} 项"
|
||
),
|
||
]
|
||
generated_at = hero.get("baseline_generated_at") or hero.get("live_generated_at")
|
||
if generated_at:
|
||
lines.append(f"生成时间:{generated_at}")
|
||
for section in sections[:4]:
|
||
if not isinstance(section, dict):
|
||
continue
|
||
lines.append(f"\n{_safe_text(section.get('title'))}")
|
||
summary = str(section.get("summary") or "").strip()
|
||
if summary:
|
||
lines.append(summary)
|
||
items = section.get("items") if isinstance(section.get("items"), list) else []
|
||
for item in items[:3]:
|
||
if not isinstance(item, dict):
|
||
continue
|
||
lines.append(
|
||
f"- {_safe_text(item.get('display_title') or item.get('title'))}"
|
||
f":{_safe_text(item.get('display_subtitle') or item.get('detail'))}"
|
||
)
|
||
return "\n".join(lines).strip()
|
||
|
||
async def _get_config_row(self) -> Optional[Integration]:
|
||
return (
|
||
await self.db.execute(
|
||
select(Integration).where(
|
||
and_(
|
||
Integration.tenant_id == self.tenant_id,
|
||
Integration.type == INTEGRATION_TYPE,
|
||
)
|
||
)
|
||
)
|
||
).scalar_one_or_none()
|
||
|
||
async def _get_latest_snapshot(self) -> Optional[PortfolioSnapshot]:
|
||
return (
|
||
await self.db.execute(
|
||
select(PortfolioSnapshot)
|
||
.where(
|
||
and_(
|
||
PortfolioSnapshot.tenant_id == self.tenant_id,
|
||
PortfolioSnapshot.scope_type == "executive_brief",
|
||
PortfolioSnapshot.scope_ref == f"tenant:{self.tenant_id}",
|
||
)
|
||
)
|
||
.order_by(PortfolioSnapshot.id.desc())
|
||
.limit(1)
|
||
)
|
||
).scalar_one_or_none()
|
||
|
||
async def _get_latest_today_snapshot(self) -> Optional[PortfolioSnapshot]:
|
||
row = await self._get_latest_snapshot()
|
||
if row is None or row.created_at is None:
|
||
return None
|
||
if row.created_at.date() != now_cn_naive().date():
|
||
return None
|
||
return row
|
||
|
||
def _normalize_config(self, raw: Any) -> dict[str, Any]:
|
||
config = self.default_config()
|
||
incoming = _deepcopy_dict(raw)
|
||
|
||
config["enabled"] = bool(incoming.get("enabled", config["enabled"]))
|
||
|
||
send_time = str(incoming.get("send_time") or config["send_time"]).strip()
|
||
if not self._is_valid_send_time(send_time):
|
||
raise ValueError("send_time must be HH:MM")
|
||
config["send_time"] = send_time
|
||
|
||
send_cycle = str(incoming.get("send_cycle") or config["send_cycle"]).strip().lower()
|
||
if send_cycle not in VALID_SEND_CYCLES:
|
||
raise ValueError("send_cycle must be workdays or daily")
|
||
config["send_cycle"] = send_cycle
|
||
|
||
delivery_target_type = str(
|
||
incoming.get("delivery_target_type") or config["delivery_target_type"]
|
||
).strip().lower()
|
||
if delivery_target_type not in VALID_DELIVERY_TARGET_TYPES:
|
||
raise ValueError("delivery_target_type must be management_group")
|
||
config["delivery_target_type"] = delivery_target_type
|
||
|
||
delivery_target_ref = str(incoming.get("delivery_target_ref") or "").strip() or None
|
||
config["delivery_target_ref"] = delivery_target_ref
|
||
|
||
timezone_value = str(incoming.get("timezone") or config["timezone"]).strip() or DEFAULT_TIMEZONE
|
||
config["timezone"] = self._normalize_timezone(timezone_value)
|
||
config["updated_at"] = str(incoming.get("updated_at") or "").strip() or None
|
||
updated_by = incoming.get("updated_by")
|
||
try:
|
||
config["updated_by"] = int(updated_by) if updated_by is not None else None
|
||
except (TypeError, ValueError):
|
||
config["updated_by"] = None
|
||
return config
|
||
|
||
@staticmethod
|
||
def _normalize_timezone(timezone_value: str) -> str:
|
||
try:
|
||
ZoneInfo(timezone_value)
|
||
return timezone_value
|
||
except Exception:
|
||
return DEFAULT_TIMEZONE
|
||
|
||
@staticmethod
|
||
def _is_valid_send_time(value: str) -> bool:
|
||
if len(value) != 5 or value[2] != ":":
|
||
return False
|
||
hour_text, minute_text = value.split(":", 1)
|
||
if not hour_text.isdigit() or not minute_text.isdigit():
|
||
return False
|
||
hour = int(hour_text)
|
||
minute = int(minute_text)
|
||
return 0 <= hour <= 23 and 0 <= minute <= 59
|
||
|
||
@staticmethod
|
||
def _alert_has_value(value: Any) -> bool:
|
||
if isinstance(value, (int, float)):
|
||
return value > 0
|
||
return bool(str(value or "").strip())
|
||
|
||
@staticmethod
|
||
def _format_percent(numerator: int, denominator: int) -> str:
|
||
if denominator <= 0:
|
||
return "0%"
|
||
return f"{round((numerator / denominator) * 100)}%"
|
||
|
||
def _display_time(self, value: datetime) -> str:
|
||
localized = value.replace(tzinfo=CN_TZ) if value.tzinfo is None else value.astimezone(CN_TZ)
|
||
return localized.strftime("%m-%d %H:%M")
|
||
|
||
@staticmethod
|
||
def _detail_lines(*parts: Any) -> list[str]:
|
||
lines: list[str] = []
|
||
for value in parts:
|
||
text = str(value or "").strip()
|
||
if text:
|
||
lines.append(text)
|
||
return lines
|
||
|
||
@staticmethod
|
||
def _pick_display_text(*values: Any) -> Optional[str]:
|
||
fallback_text: Optional[str] = None
|
||
for value in values:
|
||
text = str(value or "").strip()
|
||
if not text:
|
||
continue
|
||
if fallback_text is None:
|
||
fallback_text = text
|
||
if not _looks_like_technical_identifier(text):
|
||
return text
|
||
return fallback_text
|
||
|
||
@classmethod
|
||
def _is_reportable_signal(cls, *, project_id: Optional[int], title: Optional[str]) -> bool:
|
||
return project_id is not None and bool(cls._pick_display_text(title))
|
||
|
||
@classmethod
|
||
def _is_reportable_task(cls, *, project_id: Optional[int], title: Optional[str]) -> bool:
|
||
return project_id is not None and bool(cls._pick_display_text(title))
|
||
|
||
@classmethod
|
||
def _is_reportable_signal_source(cls, *, title: Optional[str], source_identifier: Any) -> bool:
|
||
return bool(cls._pick_display_text(title, source_identifier))
|
||
|
||
@classmethod
|
||
def _is_reportable_notification(
|
||
cls,
|
||
*,
|
||
project_id: Optional[int],
|
||
user_id: Optional[int],
|
||
title: Optional[str],
|
||
content: Any,
|
||
) -> bool:
|
||
return (project_id is not None or user_id is not None) and bool(cls._pick_display_text(title, content))
|
||
|
||
@classmethod
|
||
def _is_reportable_conflict(
|
||
cls,
|
||
*,
|
||
project_id: Optional[int],
|
||
related_user_ids: list[int],
|
||
title: Optional[str],
|
||
description: Any,
|
||
) -> bool:
|
||
return (project_id is not None or bool(related_user_ids)) and bool(cls._pick_display_text(title, description))
|
||
|
||
@staticmethod
|
||
def _meta_entries(*pairs: tuple[str, Any]) -> list[dict[str, str]]:
|
||
entries: list[dict[str, str]] = []
|
||
for label, value in pairs:
|
||
label_text = str(label or "").strip()
|
||
value_text = str(value or "").strip()
|
||
if not label_text or not value_text:
|
||
continue
|
||
entries.append({"label": label_text, "value": value_text})
|
||
return entries
|
||
|
||
@staticmethod
|
||
def _display_subtitle(*values: Any) -> str:
|
||
for value in values:
|
||
text = str(value or "").strip()
|
||
if text and not _looks_like_technical_identifier(text):
|
||
return text
|
||
for value in values:
|
||
text = str(value or "").strip()
|
||
if text:
|
||
return text
|
||
return "-"
|
||
|
||
async def _get_project_name(self, project_id: Optional[int]) -> Optional[str]:
|
||
if project_id is None:
|
||
return None
|
||
if project_id in self._project_name_cache:
|
||
return self._project_name_cache[project_id]
|
||
row = (
|
||
await self.db.execute(
|
||
select(Project.title, Project.name).where(
|
||
and_(
|
||
Project.tenant_id == self.tenant_id,
|
||
Project.id == int(project_id),
|
||
)
|
||
)
|
||
)
|
||
).one_or_none()
|
||
if row is None:
|
||
return None
|
||
title, name = row
|
||
project_name = _safe_text(title or name, fallback=f"项目 #{project_id}")
|
||
self._project_name_cache[int(project_id)] = project_name
|
||
return project_name
|
||
|
||
async def _get_user_name(self, user_id: Optional[int]) -> Optional[str]:
|
||
if user_id is None:
|
||
return None
|
||
if user_id in self._user_name_cache:
|
||
return self._user_name_cache[user_id]
|
||
row = (
|
||
await self.db.execute(
|
||
select(User.name, User.username).where(
|
||
and_(
|
||
User.tenant_id == self.tenant_id,
|
||
User.id == int(user_id),
|
||
)
|
||
)
|
||
)
|
||
).one_or_none()
|
||
if row is None:
|
||
return None
|
||
name, username = row
|
||
user_name = _safe_text(name or username, fallback=f"成员 #{user_id}")
|
||
self._user_name_cache[int(user_id)] = user_name
|
||
return user_name
|
||
|
||
async def _build_brief_item(
|
||
self,
|
||
*,
|
||
item_id: str,
|
||
title: Any,
|
||
tone: str,
|
||
kind: str,
|
||
detail_lines: list[str],
|
||
meta: list[dict[str, str]] | None = None,
|
||
detail: Optional[str] = None,
|
||
project_id: Optional[int] = None,
|
||
user_id: Optional[int] = None,
|
||
related_user_ids: Optional[list[int]] = None,
|
||
occurred_at: Optional[datetime] = None,
|
||
target: Optional[dict[str, Any]] = None,
|
||
display_title: Optional[str] = None,
|
||
display_subtitle: Optional[str] = None,
|
||
display_source: Optional[str] = None,
|
||
technical_identifier: Optional[str] = None,
|
||
) -> dict[str, Any]:
|
||
cleaned_detail_lines = [line for line in detail_lines if str(line or "").strip()]
|
||
cleaned_meta = [entry for entry in (meta or []) if str(entry.get("label") or "").strip() and str(entry.get("value") or "").strip()]
|
||
safe_title = _safe_text(title, fallback="待处理事项")
|
||
safe_display_title = _safe_text(display_title or safe_title, fallback="待处理事项")
|
||
safe_display_subtitle = self._display_subtitle(
|
||
display_subtitle,
|
||
detail,
|
||
*cleaned_detail_lines,
|
||
)
|
||
safe_display_source = _safe_text(
|
||
display_source or BRIEFING_KIND_LABELS.get(str(kind or "").strip().lower(), "事项"),
|
||
fallback="事项",
|
||
)
|
||
safe_technical_identifier = str(technical_identifier or "").strip() or None
|
||
return {
|
||
"id": str(item_id),
|
||
"title": safe_title,
|
||
"display_title": safe_display_title,
|
||
"display_subtitle": safe_display_subtitle,
|
||
"display_source": safe_display_source,
|
||
"technical_identifier": safe_technical_identifier,
|
||
"detail": detail or "|".join(cleaned_detail_lines[:3]) or "-",
|
||
"detail_lines": cleaned_detail_lines,
|
||
"meta": cleaned_meta,
|
||
"tone": str(tone or "info"),
|
||
"kind": str(kind or "briefing"),
|
||
"target": target,
|
||
"project_id": project_id,
|
||
"project_name": await self._get_project_name(project_id),
|
||
"user_id": user_id,
|
||
"owner_name": await self._get_user_name(user_id),
|
||
"related_user_ids": related_user_ids or [],
|
||
"occurred_at": _iso_or_none(occurred_at),
|
||
}
|
||
|
||
async def _list_priority_pending_signals(self, *, limit: int) -> list[dict[str, Any]]:
|
||
rows = (
|
||
await self.db.execute(
|
||
select(Signal)
|
||
.where(
|
||
and_(
|
||
Signal.tenant_id == self.tenant_id,
|
||
Signal.priority.is_not(None),
|
||
)
|
||
)
|
||
.order_by(Signal.created_at.desc(), Signal.id.desc())
|
||
.limit(max(limit * 4, limit))
|
||
)
|
||
).scalars().all()
|
||
|
||
items: list[dict[str, Any]] = []
|
||
for row in rows:
|
||
priority = str(row.priority or "").strip().lower()
|
||
status = str(row.status or row.planner_status or "").strip().lower()
|
||
pending = (not bool(row.processed)) or status in PENDING_SIGNAL_STATUSES or str(row.planner_status or "").strip().lower() == "pending"
|
||
if priority not in HIGH_SIGNAL_PRIORITIES or not pending:
|
||
continue
|
||
project_id = self._to_int(getattr(row, "matched_project_id", None))
|
||
user_id = self._to_int(getattr(row, "planner_assignee_user_id", None))
|
||
title_text = self._pick_display_text(row.title, row.summary)
|
||
if not self._is_reportable_signal(project_id=project_id, title=title_text):
|
||
continue
|
||
priority_label = _label_priority(row.priority)
|
||
status_label = _label_status(row.status or row.planner_status)
|
||
occurred_label = self._display_time(row.created_at)
|
||
items.append(
|
||
await self._build_brief_item(
|
||
item_id=f"signal:{row.id}",
|
||
title=title_text,
|
||
tone="warning",
|
||
kind="signal",
|
||
detail_lines=self._detail_lines(
|
||
f"优先级 {priority_label}",
|
||
f"状态 {status_label}",
|
||
f"时间 {occurred_label}",
|
||
_safe_text(row.summary, fallback=""),
|
||
),
|
||
meta=self._meta_entries(
|
||
("优先级", priority_label),
|
||
("状态", status_label),
|
||
("负责人", await self._get_user_name(user_id)),
|
||
("项目", await self._get_project_name(project_id)),
|
||
("时间", occurred_label),
|
||
),
|
||
project_id=project_id,
|
||
user_id=user_id,
|
||
occurred_at=row.created_at,
|
||
target=self._project_target(
|
||
project_id,
|
||
params={
|
||
"stage": "signal",
|
||
"signal_keyword": title_text,
|
||
"signal_status": str(row.planner_status or row.status or "").strip().lower(),
|
||
"signal_priority": str(row.priority or "").strip().lower(),
|
||
},
|
||
)
|
||
or self._user_target(user_id)
|
||
or _target("ai_steward_tab", "signals", "信号捕获"),
|
||
)
|
||
)
|
||
if len(items) >= limit:
|
||
break
|
||
return items
|
||
|
||
async def _list_pending_signal_sources(self, *, limit: int) -> list[dict[str, Any]]:
|
||
rows = (
|
||
await self.db.execute(
|
||
select(SignalSourceRegistry)
|
||
.where(
|
||
and_(
|
||
SignalSourceRegistry.tenant_id == self.tenant_id,
|
||
SignalSourceRegistry.onboarding_status == "pending",
|
||
)
|
||
)
|
||
.order_by(SignalSourceRegistry.created_at.desc(), SignalSourceRegistry.id.desc())
|
||
.limit(limit)
|
||
)
|
||
).scalars().all()
|
||
items: list[dict[str, Any]] = []
|
||
for row in rows:
|
||
project_id = self._to_int(row.project_id)
|
||
user_id = self._to_int(row.created_by_user_id)
|
||
title_text = self._pick_display_text(row.source_name, row.source_identifier)
|
||
if not self._is_reportable_signal_source(title=title_text, source_identifier=row.source_identifier):
|
||
continue
|
||
kind_label = _label_source_kind(row.source_kind)
|
||
status_label = _label_status(row.onboarding_status)
|
||
items.append(
|
||
await self._build_brief_item(
|
||
item_id=f"signal_source:{row.id}",
|
||
title=title_text,
|
||
tone="warning",
|
||
kind="signal_source",
|
||
detail_lines=self._detail_lines(
|
||
kind_label,
|
||
f"接入状态 {status_label}",
|
||
_safe_text(row.description, fallback=""),
|
||
),
|
||
meta=self._meta_entries(
|
||
("来源类型", kind_label),
|
||
("接入状态", status_label),
|
||
("负责人", await self._get_user_name(user_id)),
|
||
("项目", await self._get_project_name(project_id)),
|
||
),
|
||
project_id=project_id,
|
||
user_id=user_id,
|
||
occurred_at=row.created_at,
|
||
display_title=_safe_text(row.source_name, fallback=title_text or "未命名信号源"),
|
||
display_subtitle=self._display_subtitle(
|
||
f"{kind_label} · 接入状态 {status_label}",
|
||
row.description,
|
||
),
|
||
display_source=kind_label,
|
||
technical_identifier=str(row.source_identifier or "").strip() or None,
|
||
target=self._project_target(
|
||
project_id,
|
||
params={
|
||
"stage": "signal",
|
||
},
|
||
)
|
||
or _target("ai_steward_tab", "signals", "信号捕获"),
|
||
)
|
||
)
|
||
return items
|
||
|
||
async def _list_recent_signals(self, *, since: datetime, limit: int) -> list[dict[str, Any]]:
|
||
rows = (
|
||
await self.db.execute(
|
||
select(Signal)
|
||
.where(
|
||
and_(
|
||
Signal.tenant_id == self.tenant_id,
|
||
Signal.created_at >= since,
|
||
)
|
||
)
|
||
.order_by(Signal.created_at.desc(), Signal.id.desc())
|
||
.limit(limit)
|
||
)
|
||
).scalars().all()
|
||
items: list[dict[str, Any]] = []
|
||
for row in rows:
|
||
project_id = self._to_int(row.matched_project_id)
|
||
user_id = self._to_int(row.planner_assignee_user_id)
|
||
title_text = self._pick_display_text(row.title, row.summary)
|
||
if not self._is_reportable_signal(project_id=project_id, title=title_text):
|
||
continue
|
||
priority_label = _label_priority(row.priority)
|
||
status_label = _label_status(row.status)
|
||
occurred_label = self._display_time(row.created_at)
|
||
items.append(
|
||
await self._build_brief_item(
|
||
item_id=f"signal:{row.id}",
|
||
title=title_text,
|
||
tone="info",
|
||
kind="signal",
|
||
detail_lines=self._detail_lines(
|
||
f"优先级 {priority_label}",
|
||
f"状态 {status_label}",
|
||
f"时间 {occurred_label}",
|
||
_safe_text(row.summary, fallback=""),
|
||
),
|
||
meta=self._meta_entries(
|
||
("优先级", priority_label),
|
||
("状态", status_label),
|
||
("负责人", await self._get_user_name(user_id)),
|
||
("项目", await self._get_project_name(project_id)),
|
||
("时间", occurred_label),
|
||
),
|
||
project_id=project_id,
|
||
user_id=user_id,
|
||
occurred_at=row.created_at,
|
||
target=self._project_target(
|
||
project_id,
|
||
params={
|
||
"stage": "signal",
|
||
"signal_keyword": title_text,
|
||
"signal_status": str(row.status or "").strip().lower(),
|
||
"signal_priority": str(row.priority or "").strip().lower(),
|
||
},
|
||
)
|
||
or _target("ai_steward_tab", "signals", "信号捕获"),
|
||
)
|
||
)
|
||
return items
|
||
|
||
async def _list_blocked_tasks(self, *, limit: int) -> list[dict[str, Any]]:
|
||
rows = (
|
||
await self.db.execute(
|
||
select(Task)
|
||
.where(
|
||
and_(
|
||
Task.tenant_id == self.tenant_id,
|
||
Task.status.in_(tuple(BLOCKED_TASK_STATUSES)),
|
||
)
|
||
)
|
||
.order_by(Task.updated_at.desc(), Task.id.desc())
|
||
.limit(limit)
|
||
)
|
||
).scalars().all()
|
||
items: list[dict[str, Any]] = []
|
||
for row in rows:
|
||
project_id = self._to_int(row.project_id)
|
||
user_id = self._to_int(row.assignee_id)
|
||
title_text = self._pick_display_text(row.title)
|
||
if not self._is_reportable_task(project_id=project_id, title=title_text):
|
||
continue
|
||
priority_label = _label_priority(row.priority)
|
||
status_label = _label_status(row.status)
|
||
items.append(
|
||
await self._build_brief_item(
|
||
item_id=f"task:{row.id}",
|
||
title=title_text,
|
||
tone="critical",
|
||
kind="task",
|
||
detail_lines=self._detail_lines(
|
||
f"优先级 {priority_label}",
|
||
f"状态 {status_label}",
|
||
f"负责人 {await self._get_user_name(user_id) or '-'}",
|
||
),
|
||
meta=self._meta_entries(
|
||
("优先级", priority_label),
|
||
("状态", status_label),
|
||
("负责人", await self._get_user_name(user_id)),
|
||
("项目", await self._get_project_name(project_id)),
|
||
),
|
||
project_id=project_id,
|
||
user_id=user_id,
|
||
occurred_at=row.updated_at,
|
||
target=self._project_target(
|
||
project_id,
|
||
params={
|
||
"stage": "task",
|
||
"task_keyword": title_text,
|
||
"task_status": str(row.status or "").strip().lower(),
|
||
"task_priority": str(row.priority or "").strip().lower(),
|
||
},
|
||
)
|
||
or _target("ai_steward_tab", "blockers", "阻塞检测"),
|
||
)
|
||
)
|
||
return items
|
||
|
||
async def _list_overdue_tasks(self, *, now: datetime, limit: int) -> list[dict[str, Any]]:
|
||
rows = (
|
||
await self.db.execute(
|
||
select(Task)
|
||
.where(
|
||
and_(
|
||
Task.tenant_id == self.tenant_id,
|
||
Task.due_date.is_not(None),
|
||
Task.due_date < now,
|
||
Task.status.not_in(tuple(DONE_TASK_STATUSES)),
|
||
)
|
||
)
|
||
.order_by(Task.due_date.asc(), Task.id.desc())
|
||
.limit(limit)
|
||
)
|
||
).scalars().all()
|
||
items: list[dict[str, Any]] = []
|
||
for row in rows:
|
||
project_id = self._to_int(row.project_id)
|
||
user_id = self._to_int(row.assignee_id)
|
||
title_text = self._pick_display_text(row.title)
|
||
if not self._is_reportable_task(project_id=project_id, title=title_text):
|
||
continue
|
||
due_label = row.due_date.date().isoformat() if row.due_date else "-"
|
||
status_label = _label_status(row.status)
|
||
priority_label = _label_priority(row.priority)
|
||
items.append(
|
||
await self._build_brief_item(
|
||
item_id=f"task:{row.id}",
|
||
title=title_text,
|
||
tone="warning",
|
||
kind="task",
|
||
detail_lines=self._detail_lines(
|
||
f"截止 {due_label}",
|
||
f"状态 {status_label}",
|
||
f"优先级 {priority_label}",
|
||
),
|
||
meta=self._meta_entries(
|
||
("截止日期", due_label),
|
||
("状态", status_label),
|
||
("优先级", priority_label),
|
||
("负责人", await self._get_user_name(user_id)),
|
||
("项目", await self._get_project_name(project_id)),
|
||
),
|
||
project_id=project_id,
|
||
user_id=user_id,
|
||
occurred_at=row.due_date,
|
||
target=self._project_target(
|
||
project_id,
|
||
params={
|
||
"stage": "task",
|
||
"task_keyword": title_text,
|
||
"task_status": str(row.status or "").strip().lower(),
|
||
"task_priority": str(row.priority or "").strip().lower(),
|
||
},
|
||
)
|
||
or _target("ai_steward_tab", "blockers", "阻塞检测"),
|
||
)
|
||
)
|
||
return items
|
||
|
||
async def _list_risk_notifications(self, *, since: datetime, limit: int) -> list[dict[str, Any]]:
|
||
rows = (
|
||
await self.db.execute(
|
||
select(Notification)
|
||
.where(
|
||
and_(
|
||
Notification.tenant_id == self.tenant_id,
|
||
Notification.created_at >= since,
|
||
Notification.is_read.is_(False),
|
||
)
|
||
)
|
||
.order_by(Notification.created_at.desc(), Notification.id.desc())
|
||
.limit(limit * 3)
|
||
)
|
||
).scalars().all()
|
||
items: list[dict[str, Any]] = []
|
||
for row in rows:
|
||
title = f"{row.title} {row.content or ''}".lower()
|
||
if not any(keyword in title for keyword in ("risk", "风险", "blocked", "阻塞", "overdue", "逾期")):
|
||
continue
|
||
notification_target = self._notification_target(row)
|
||
project_id = self._to_int(getattr(row, "entity_id", None)) if str(getattr(row, "entity_type", "")).lower() == "project" else None
|
||
user_id = self._to_int(getattr(row, "user_id", None))
|
||
title_text = self._pick_display_text(row.title, row.content, row.blocking_reason, row.priority)
|
||
if not self._is_reportable_notification(
|
||
project_id=project_id,
|
||
user_id=user_id,
|
||
title=title_text,
|
||
content=row.content,
|
||
):
|
||
continue
|
||
items.append(
|
||
await self._build_brief_item(
|
||
item_id=f"notification:{row.id}",
|
||
title=title_text,
|
||
tone="warning",
|
||
kind="notification",
|
||
detail_lines=self._detail_lines(
|
||
_safe_text(row.content or row.blocking_reason or row.priority),
|
||
f"状态 {_label_status(row.status)}",
|
||
f"时间 {self._display_time(row.created_at)}",
|
||
),
|
||
meta=self._meta_entries(
|
||
("状态", _label_status(row.status)),
|
||
("优先级", _label_priority(row.priority)),
|
||
("负责人", await self._get_user_name(user_id)),
|
||
("项目", await self._get_project_name(project_id)),
|
||
),
|
||
project_id=project_id,
|
||
user_id=user_id,
|
||
occurred_at=row.created_at,
|
||
target=notification_target,
|
||
)
|
||
)
|
||
if len(items) >= limit:
|
||
break
|
||
return items
|
||
|
||
async def _list_active_conflicts(self, *, limit: int) -> list[dict[str, Any]]:
|
||
rows = (
|
||
await self.db.execute(
|
||
select(CognitiveConflict, TruthNode.project_id)
|
||
.join(TruthNode, TruthNode.id == CognitiveConflict.truth_node_id, isouter=True)
|
||
.where(CognitiveConflict.tenant_id == self.tenant_id)
|
||
.order_by(CognitiveConflict.created_at.desc(), CognitiveConflict.id.desc())
|
||
.limit(limit * 3)
|
||
)
|
||
).all()
|
||
items: list[dict[str, Any]] = []
|
||
for row, project_id in rows:
|
||
status = str(row.status or "").strip().lower()
|
||
if status in ACTIVE_CONFLICT_DONE_STATUSES:
|
||
continue
|
||
normalized_project_id = self._to_int(project_id)
|
||
related_user_ids = [uid for uid in [self._to_int(value) for value in (row.involved_user_ids or [])] if uid is not None]
|
||
title_text = self._pick_display_text(row.conflict_type, row.description)
|
||
if not self._is_reportable_conflict(
|
||
project_id=normalized_project_id,
|
||
related_user_ids=related_user_ids,
|
||
title=title_text,
|
||
description=row.description,
|
||
):
|
||
continue
|
||
related_names = [await self._get_user_name(uid) for uid in related_user_ids]
|
||
items.append(
|
||
await self._build_brief_item(
|
||
item_id=f"conflict:{row.id}",
|
||
title=title_text,
|
||
tone="critical",
|
||
kind="conflict",
|
||
detail_lines=self._detail_lines(
|
||
_safe_text(row.description),
|
||
f"涉及 {len(related_user_ids)} 人",
|
||
),
|
||
meta=self._meta_entries(
|
||
("相关项目", await self._get_project_name(normalized_project_id)),
|
||
("涉及成员", "、".join([name for name in related_names if name])),
|
||
),
|
||
project_id=normalized_project_id,
|
||
related_user_ids=related_user_ids,
|
||
occurred_at=row.created_at,
|
||
display_title=_label_conflict_type(row.conflict_type),
|
||
display_subtitle=self._display_subtitle(row.description),
|
||
display_source="协作冲突",
|
||
technical_identifier=str(row.conflict_type or "").strip() if _looks_like_technical_identifier(row.conflict_type) else None,
|
||
target=self._project_target(
|
||
normalized_project_id,
|
||
params={"stage": "signal"},
|
||
)
|
||
or _target("ai_steward_tab", "conflicts", "认知冲突"),
|
||
)
|
||
)
|
||
if len(items) >= limit:
|
||
break
|
||
return items
|
||
|
||
async def _list_workload_members(self, *, kind: str, limit: int) -> list[dict[str, Any]]:
|
||
rows = (
|
||
await self.db.execute(
|
||
select(User.id, User.name, UserWorkload.current_assigned_hours, UserWorkload.max_available_hours_per_week, UserWorkload.workload_ratio)
|
||
.join(User, User.id == UserWorkload.user_id)
|
||
.where(UserWorkload.tenant_id == self.tenant_id)
|
||
.order_by(UserWorkload.workload_ratio.desc(), UserWorkload.id.desc())
|
||
)
|
||
).all()
|
||
|
||
items: list[dict[str, Any]] = []
|
||
for user_id, name, assigned_hours, max_hours, ratio_value in rows:
|
||
ratio = self._display_workload_ratio(
|
||
assigned_hours=assigned_hours,
|
||
max_hours=max_hours,
|
||
stored_ratio=ratio_value,
|
||
)
|
||
if kind == "overloaded" and ratio < 1:
|
||
continue
|
||
if kind == "idle" and ratio > 0.3:
|
||
continue
|
||
normalized_user_id = self._to_int(user_id)
|
||
ratio_percent = round(ratio * 100)
|
||
items.append(
|
||
await self._build_brief_item(
|
||
item_id=f"capacity:{kind}:{user_id}",
|
||
title=name,
|
||
tone="critical" if kind == "overloaded" else "info",
|
||
kind="capacity",
|
||
detail_lines=self._detail_lines(
|
||
f"{ratio_percent}% 负载",
|
||
f"已分配 {round(float(assigned_hours or 0), 1)} 小时",
|
||
f"周上限 {round(float(max_hours or 0), 1)} 小时",
|
||
),
|
||
meta=self._meta_entries(
|
||
("负载", f"{ratio_percent}%"),
|
||
("已分配", f"{round(float(assigned_hours or 0), 1)} 小时"),
|
||
("周上限", f"{round(float(max_hours or 0), 1)} 小时"),
|
||
),
|
||
user_id=normalized_user_id,
|
||
target=self._user_target(normalized_user_id) or _target("ai_steward_tab", "conflicts", "团队产能"),
|
||
)
|
||
)
|
||
if len(items) >= limit:
|
||
break
|
||
return items
|
||
|
||
async def _count_pending_signals(self) -> int:
|
||
rows = (
|
||
await self.db.execute(
|
||
select(
|
||
Signal.processed,
|
||
Signal.status,
|
||
Signal.planner_status,
|
||
Signal.matched_project_id,
|
||
Signal.title,
|
||
Signal.summary,
|
||
).where(Signal.tenant_id == self.tenant_id)
|
||
)
|
||
).all()
|
||
total = 0
|
||
for processed, status, planner_status, matched_project_id, title, summary in rows:
|
||
normalized_status = str(status or planner_status or "").strip().lower()
|
||
normalized_planner_status = str(planner_status or "").strip().lower()
|
||
project_id = self._to_int(matched_project_id)
|
||
title_text = self._pick_display_text(title, summary)
|
||
if not self._is_reportable_signal(project_id=project_id, title=title_text):
|
||
continue
|
||
if (not bool(processed)) or normalized_status in PENDING_SIGNAL_STATUSES or normalized_planner_status == "pending":
|
||
total += 1
|
||
return total
|
||
|
||
async def _count_high_priority_pending_signals(self) -> int:
|
||
rows = (
|
||
await self.db.execute(
|
||
select(
|
||
Signal.priority,
|
||
Signal.processed,
|
||
Signal.status,
|
||
Signal.planner_status,
|
||
Signal.matched_project_id,
|
||
Signal.title,
|
||
Signal.summary,
|
||
).where(Signal.tenant_id == self.tenant_id)
|
||
)
|
||
).all()
|
||
total = 0
|
||
for priority, processed, status, planner_status, matched_project_id, title, summary in rows:
|
||
normalized_priority = str(priority or "").strip().lower()
|
||
normalized_status = str(status or planner_status or "").strip().lower()
|
||
project_id = self._to_int(matched_project_id)
|
||
title_text = self._pick_display_text(title, summary)
|
||
if not self._is_reportable_signal(project_id=project_id, title=title_text):
|
||
continue
|
||
pending = (not bool(processed)) or normalized_status in PENDING_SIGNAL_STATUSES or str(planner_status or "").strip().lower() == "pending"
|
||
if normalized_priority in HIGH_SIGNAL_PRIORITIES and pending:
|
||
total += 1
|
||
return total
|
||
|
||
async def _count_recent_signals(self, *, since: datetime) -> int:
|
||
rows = (
|
||
await self.db.execute(
|
||
select(Signal.matched_project_id, Signal.title, Signal.summary).where(
|
||
and_(
|
||
Signal.tenant_id == self.tenant_id,
|
||
Signal.created_at >= since,
|
||
)
|
||
)
|
||
)
|
||
).all()
|
||
total = 0
|
||
for matched_project_id, title, summary in rows:
|
||
project_id = self._to_int(matched_project_id)
|
||
title_text = self._pick_display_text(title, summary)
|
||
if self._is_reportable_signal(project_id=project_id, title=title_text):
|
||
total += 1
|
||
return total
|
||
|
||
async def _count_pending_signal_sources(self) -> int:
|
||
return len(
|
||
(
|
||
await self.db.execute(
|
||
select(SignalSourceRegistry.id).where(
|
||
and_(
|
||
SignalSourceRegistry.tenant_id == self.tenant_id,
|
||
SignalSourceRegistry.onboarding_status == "pending",
|
||
)
|
||
)
|
||
)
|
||
).scalars().all()
|
||
)
|
||
|
||
async def _count_blocked_tasks(self) -> int:
|
||
return len(
|
||
(
|
||
await self.db.execute(
|
||
select(Task.id).where(
|
||
and_(
|
||
Task.tenant_id == self.tenant_id,
|
||
Task.status.in_(tuple(BLOCKED_TASK_STATUSES)),
|
||
)
|
||
)
|
||
)
|
||
).scalars().all()
|
||
)
|
||
|
||
async def _count_overdue_tasks(self, *, now: datetime) -> int:
|
||
return len(
|
||
(
|
||
await self.db.execute(
|
||
select(Task.id).where(
|
||
and_(
|
||
Task.tenant_id == self.tenant_id,
|
||
Task.due_date.is_not(None),
|
||
Task.due_date < now,
|
||
Task.status.not_in(tuple(DONE_TASK_STATUSES)),
|
||
)
|
||
)
|
||
)
|
||
).scalars().all()
|
||
)
|
||
|
||
async def _count_risk_notifications(self, *, since: datetime) -> int:
|
||
rows = (
|
||
await self.db.execute(
|
||
select(Notification.title, Notification.content)
|
||
.where(
|
||
and_(
|
||
Notification.tenant_id == self.tenant_id,
|
||
Notification.created_at >= since,
|
||
Notification.is_read.is_(False),
|
||
)
|
||
)
|
||
)
|
||
).all()
|
||
total = 0
|
||
for title, content in rows:
|
||
text = f"{title or ''} {content or ''}".lower()
|
||
if any(keyword in text for keyword in ("risk", "风险", "blocked", "阻塞", "overdue", "逾期")):
|
||
total += 1
|
||
return total
|
||
|
||
async def _count_active_conflicts(self) -> int:
|
||
rows = (
|
||
await self.db.execute(select(CognitiveConflict.status).where(CognitiveConflict.tenant_id == self.tenant_id))
|
||
).scalars().all()
|
||
return sum(1 for status in rows if str(status or "").strip().lower() not in ACTIVE_CONFLICT_DONE_STATUSES)
|
||
|
||
async def _count_workload_members(self, *, kind: str) -> int:
|
||
rows = await self._list_workload_members(kind=kind, limit=100)
|
||
return len(rows)
|
||
|
||
async def _get_feedback_stats(self) -> dict[str, Any]:
|
||
rows = (
|
||
await self.db.execute(
|
||
select(FeedbackCenterRecord.review_status, FeedbackCenterRecord.created_at, FeedbackCenterRecord.reviewed_at).where(
|
||
FeedbackCenterRecord.tenant_id == self.tenant_id
|
||
)
|
||
)
|
||
).all()
|
||
pending_count = 0
|
||
accepted_count = 0
|
||
rejected_count = 0
|
||
latency_samples: list[float] = []
|
||
for status, created_at, reviewed_at in rows:
|
||
normalized_status = str(status or "").strip().lower()
|
||
if normalized_status == "pending":
|
||
pending_count += 1
|
||
elif normalized_status in REVIEW_ACCEPTED_STATUSES:
|
||
accepted_count += 1
|
||
elif normalized_status in REVIEW_REJECTED_STATUSES:
|
||
rejected_count += 1
|
||
if created_at and reviewed_at:
|
||
latency_samples.append(max((reviewed_at - created_at).total_seconds() / 3600, 0))
|
||
avg_latency_hours = round(sum(latency_samples) / len(latency_samples), 1) if latency_samples else 0.0
|
||
reviewed_total = accepted_count + rejected_count
|
||
return {
|
||
"pending_count": pending_count,
|
||
"accepted_count": accepted_count,
|
||
"rejected_count": rejected_count,
|
||
"reviewed_total": reviewed_total,
|
||
"avg_review_latency_hours": avg_latency_hours,
|
||
}
|
||
|
||
async def _get_task_governance_stats(self) -> dict[str, Any]:
|
||
rows = (
|
||
await self.db.execute(select(Task.manual_overrides, Task.status).where(Task.tenant_id == self.tenant_id))
|
||
).all()
|
||
total = len(rows)
|
||
manual_override_count = 0
|
||
rework_count = 0
|
||
for manual_overrides, status in rows:
|
||
if manual_overrides not in (None, {}, []):
|
||
manual_override_count += 1
|
||
if str(status or "").strip().lower() in REWORK_TASK_STATUSES:
|
||
rework_count += 1
|
||
return {
|
||
"total": total,
|
||
"manual_override_count": manual_override_count,
|
||
"manual_override_rate": self._format_percent(manual_override_count, total),
|
||
"rework_count": rework_count,
|
||
"rework_rate": self._format_percent(rework_count, total),
|
||
}
|
||
|
||
async def _build_governance_items(self, *, feedback_stats: dict[str, Any], task_stats: dict[str, Any]) -> list[dict[str, Any]]:
|
||
items: list[dict[str, Any]] = []
|
||
if feedback_stats["pending_count"] > 0:
|
||
items.append(
|
||
await self._build_brief_item(
|
||
item_id="governance:pending_feedback",
|
||
title="反馈中心待审积压",
|
||
tone="warning",
|
||
kind="governance",
|
||
detail_lines=self._detail_lines(
|
||
f"{feedback_stats['pending_count']} 条待审",
|
||
f"平均复核延迟 {feedback_stats['avg_review_latency_hours']}h",
|
||
),
|
||
meta=self._meta_entries(
|
||
("待审反馈", feedback_stats["pending_count"]),
|
||
("平均复核延迟", f"{feedback_stats['avg_review_latency_hours']}h"),
|
||
),
|
||
target=_target("ai_steward_tab", "feedback-center", "反馈中心"),
|
||
)
|
||
)
|
||
if task_stats["manual_override_count"] > 0:
|
||
items.append(
|
||
await self._build_brief_item(
|
||
item_id="governance:manual_override",
|
||
title="人工覆盖偏高",
|
||
tone="info",
|
||
kind="governance",
|
||
detail_lines=self._detail_lines(f"当前人工覆盖率 {task_stats['manual_override_rate']}"),
|
||
meta=self._meta_entries(
|
||
("人工覆盖率", task_stats["manual_override_rate"]),
|
||
("人工覆盖次数", task_stats["manual_override_count"]),
|
||
),
|
||
target=_target("ai_steward_tab", "feedback-center", "反馈中心"),
|
||
)
|
||
)
|
||
if task_stats["rework_count"] > 0:
|
||
items.append(
|
||
await self._build_brief_item(
|
||
item_id="governance:rework",
|
||
title="返工任务存在",
|
||
tone="warning",
|
||
kind="governance",
|
||
detail_lines=self._detail_lines(f"当前返工率 {task_stats['rework_rate']}"),
|
||
meta=self._meta_entries(
|
||
("返工率", task_stats["rework_rate"]),
|
||
("返工任务数", task_stats["rework_count"]),
|
||
),
|
||
target=_target("ai_steward_tab", "feedback-center", "反馈中心"),
|
||
)
|
||
)
|
||
if not items:
|
||
items.append(
|
||
await self._build_brief_item(
|
||
item_id="governance:healthy",
|
||
title="治理状态正常",
|
||
tone="positive",
|
||
kind="governance",
|
||
detail_lines=self._detail_lines("当前没有显著的 AI 治理积压。"),
|
||
meta=[],
|
||
target=_target("ai_steward_tab", "feedback-center", "反馈中心"),
|
||
)
|
||
)
|
||
return items[:3]
|
||
|
||
@staticmethod
|
||
def _to_int(value: Any) -> Optional[int]:
|
||
try:
|
||
if value in (None, ""):
|
||
return None
|
||
return int(value)
|
||
except (TypeError, ValueError):
|
||
return None
|
||
|
||
@staticmethod
|
||
def _normalize_workload_ratio(value: Any) -> float:
|
||
try:
|
||
ratio = float(value or 0.0)
|
||
except (TypeError, ValueError):
|
||
return 0.0
|
||
if ratio <= 0:
|
||
return 0.0
|
||
return ratio / 100.0 if ratio > 1.0 else ratio
|
||
|
||
@classmethod
|
||
def _display_workload_ratio(cls, *, assigned_hours: Any, max_hours: Any, stored_ratio: Any) -> float:
|
||
try:
|
||
assigned = max(float(assigned_hours or 0.0), 0.0)
|
||
except (TypeError, ValueError):
|
||
assigned = 0.0
|
||
try:
|
||
weekly_limit = float(max_hours or 0.0)
|
||
except (TypeError, ValueError):
|
||
weekly_limit = 0.0
|
||
if weekly_limit > 0:
|
||
return assigned / weekly_limit
|
||
return cls._normalize_workload_ratio(stored_ratio)
|
||
|
||
def _project_target(
|
||
self,
|
||
project_id: Optional[int],
|
||
*,
|
||
params: Optional[dict[str, Any]] = None,
|
||
) -> Optional[dict[str, Any]]:
|
||
if project_id is None:
|
||
return None
|
||
return _target("project_board", str(project_id), "项目看板", params=params)
|
||
|
||
def _user_target(self, user_id: Optional[int], *, params: Optional[dict[str, Any]] = None) -> Optional[dict[str, Any]]:
|
||
if user_id is None:
|
||
return None
|
||
return _target("workload_detail", str(user_id), "成员负载", params=params)
|
||
|
||
def _notification_target(self, row: Notification) -> dict[str, Any]:
|
||
target_url = str(getattr(row, "target_url", "") or "").strip()
|
||
if target_url.startswith("/projects/") and "/board" in target_url:
|
||
project_ref = target_url.split("/projects/", 1)[1].split("/", 1)[0]
|
||
if project_ref.isdigit():
|
||
return _target("project_board", project_ref, "项目看板", params={"stage": "task"})
|
||
if target_url.startswith("/workloads/"):
|
||
user_ref = target_url.split("/workloads/", 1)[1].split("/", 1)[0]
|
||
if user_ref.isdigit():
|
||
return _target("workload_detail", user_ref, "成员负载")
|
||
entity_type = str(getattr(row, "entity_type", "") or "").strip().lower()
|
||
entity_id = self._to_int(getattr(row, "entity_id", None))
|
||
if entity_type == "project" and entity_id is not None:
|
||
return _target("project_board", str(entity_id), "项目看板", params={"stage": "task"})
|
||
if entity_type in {"user", "member"} and entity_id is not None:
|
||
return _target("user_profile", str(entity_id), "成员档案")
|
||
return _target("ai_steward_tab", "blockers", "阻塞检测")
|