diff --git a/CLAUDE.md b/CLAUDE.md index a8fb6947..03e57705 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -390,6 +390,11 @@ frontend/ - 新增:`frontend/src/pages/ai-steward/types.ts`、`frontend/src/pages/ai-steward/constants.ts`(AI Steward 类型与常量集中管理) - 新增:`frontend/src/pages/ai-steward/components/FeatureOverviewCards.tsx`(AI Steward 首页功能卡片组件) - 新增:`frontend/src/pages/ai-steward/sections/OverviewTab.tsx`、`CreateProjectTab.tsx`、`SignalsTab.tsx`、`ConflictsTab.tsx`、`BlockersTab.tsx`、`BriefingTab.tsx`、`EngineTab.tsx`、`FeedbackCenterTab.tsx`(AI Steward 分阶段 UI 拆分) +- 新增:`backend/app/services/executive_briefing_service.py`、`backend/app/api/ai_steward_api/routes_executive_briefing.py`(老板晨报聚合服务与 manager-only 接口,配置持久化复用 `Integration(type='executive_briefing')`,快照复用 `PortfolioSnapshot(scope_type='executive_brief')`) +- 新增:`backend/tests/test_executive_briefing_api.py`、`backend/tests/test_ai_pmo_cadence_scheduler.py`(老板晨报接口包络、配置落库、管理群文案与调度时间窗回归) +- 更新:`backend/app/services/ai_pmo/ai_pmo_cadence/service.py`、`mixin_builders.py`、`mixin_snapshots.py`、`backend/app/services/scheduler_center_core/ai_pmo_cadence.py`(新增 `executive_brief` cadence type,支持租户级晨报快照生成与管理群 outbox 推送) +- 更新:`frontend/src/api/aiSteward.ts`(新增 cognitive conflicts / blockers / daily briefing / executive briefing API 封装,AI Steward 页面不再直接 `fetch`) +- 更新:`frontend/src/pages/ai-steward/AIStewardPage.tsx`、`components/FeatureOverviewCards.tsx`、`sections/OverviewTab.tsx`(BOSS/ADMIN 首页升级为老板晨报中心,支持晨报快照、实时刷新、调度配置与异常优先看板) - 更新:`frontend/src/pages/ai-steward/sections/CreateProjectTab.tsx`(零配置创建结果区内联展示 `signal_sources` 分组,复用现有 AI Steward 页面,不新增独立路由) - 新增:`frontend/src/pages/ai-steward/components/feedback-center/FeedbackList.tsx`、`FeedbackDetail.tsx`、`ReasonPicker.tsx`、`MetricsPanel.tsx`、`TrendPanel.tsx`(反馈中心三栏工作台、指标页与趋势页组件;趋势页支持 `7/30 天` 与 `all/task/qa/signal` 局部筛选,并提供洞察卡、原因筛选与一键钻取) - 新增:`frontend/src/pages/ai-steward/components/feedback-center/TrendPanel.tsx` 的趋势决策版布局(Scope / KPI / Insights / Drilldown / Evidence) @@ -497,6 +502,8 @@ uvicorn app.main:app --reload --host 0.0.0.0 --port 8000 - 禁止在 API 层写业务逻辑,业务逻辑归属 `services/` - 禁止在前端组件内直接调用 `fetch`,必须通过 `frontend/src/api/` 封装层 - 前端状态管理:服务端数据用 React Query,本地 UI 状态用 `useState` +- 老板晨报链路只允许 `BOSS/ADMIN` 访问;`GET /ai-steward/executive-briefing` 的 `snapshot/live` 语义必须分离,`live` 禁止覆盖 `PortfolioSnapshot(scope_type='executive_brief')` +- 老板晨报调度配置统一落在 `Integration(type='executive_briefing')`,禁止新增独立晨报配置表;管理群自动推送必须复用现有 `feishu_reply_outbox` - 飞书回执发送仅允许 `main.py` 的 drain_loop 作为消费者,禁止在业务路径即时派发 outbox 发送任务 - 所有写接口必须落审计日志(`operation_logs`):至少包含 `trace_id/interface_path/caller` - `created_at_beijing` 为计算属性返回,禁止新增数据库物理列 @@ -540,6 +547,7 @@ uvicorn app.main:app --reload --host 0.0.0.0 --port 8000 - LLM 引擎设置接口:参考 `backend/app/api/llm_engines.py`(`PUT /ai-steward/llm-engines/settings`,仅管理员可修改本地知识库问答开关) - 飞书捕获模拟接口:`POST /ai-steward/signal/capture-feishu` 在 `direct_reply_required=true` 时会同步写入 `feishu_reply_outbox`,并返回 `reply_outbox_enqueued/reply_outbox_id` 便于链路自检 - 配置读取类接口:参考 `backend/app/api/llm_providers.py`(`GET /ai-steward/llm/providers`) +- 老板晨报接口:参考 `backend/app/api/ai_steward_api/routes_executive_briefing.py`(`GET /ai-steward/executive-briefing?mode=snapshot|live`、`GET/PUT /ai-steward/executive-briefing/config`,仅 `BOSS/ADMIN` 可访问) - 模型配置唯一来源:参考 `backend/app/services/model/model_config.py`(统一读取 `backend/.openclaw/pmflow-routing-rules.json` 内的 `provider_defaults/tier_defaults/rules`) - 模型配置热更新后,LLM provider circuit 会在下一次调用时自动清空旧熔断状态,避免旧配置失败污染新配置的连通性判断 - 启动期模型探针只做连通性观测,不应把探针失败写回 provider circuit;否则会误伤真实业务请求 @@ -583,6 +591,8 @@ uvicorn app.main:app --reload --host 0.0.0.0 --port 8000 ## 11. 已知 BOUNDARY(不要幻构行为) - 飞书回调字段可能缺失文件名/大小,仅保证 `attachment_id/key/type` 可用 - 项目创建返回的 `signal_sources` 仅作为 AI Steward 创建结果区的内联展示,现阶段不新增独立页面路由;底层持久化统一复用现有 `SignalSourceRegistry` +- 老板晨报首版的 `external_metrics` 只返回占位区块,不接 CRM / BI / 收入系统,也不允许伪造经营指标 +- 老板晨报调度默认值为 `08:30 + workdays + Asia/Shanghai`;若 `delivery_target_ref` 未配置,调度器仍生成快照,但只记录告警、不静默伪装成已推送 - 飞书长连接 websocket 在本地/CI 环境默认使用 `certifi` CA bundle 作为信任链;如需自定义 CA,可通过 `FEISHU_LONG_CONNECTION_SSL_CERT_FILE` 或 `SSL_CERT_FILE` 覆盖 - 飞书长连接状态分为两层:`last_received_at/last_received_message_id` 表示 SDK 回调已收到事件,`last_event_at` 表示 worker 完成处理;排查“没吃进去”时必须优先看 `last_received_*` - 飞书长连接会话应复用 `Integration.config` 中已有的 `encrypt_key/verification_token`;配置保存时若未显式修改这两个字段,必须保留现有值,避免 SDK 因缺失事件密钥而拒收消息 diff --git a/backend/app/api/ai_steward_api/__init__.py b/backend/app/api/ai_steward_api/__init__.py index f764ac64..c7bf2695 100644 --- a/backend/app/api/ai_steward_api/__init__.py +++ b/backend/app/api/ai_steward_api/__init__.py @@ -6,6 +6,7 @@ from . import ( routes_brief_blockers, routes_capture, routes_conflicts, + routes_executive_briefing, routes_feedback_center, routes_reply_iteration, routes_rag_sources, @@ -19,6 +20,7 @@ for _module in [ routes_rag_sources, routes_capture, routes_conflicts, + routes_executive_briefing, routes_feedback_center, routes_reply_iteration, routes_brief_blockers, diff --git a/backend/app/api/ai_steward_api/routes_executive_briefing.py b/backend/app/api/ai_steward_api/routes_executive_briefing.py new file mode 100644 index 00000000..4b4edb05 --- /dev/null +++ b/backend/app/api/ai_steward_api/routes_executive_briefing.py @@ -0,0 +1,66 @@ +from __future__ import annotations + +from fastapi import Depends, Query +from fastapi.responses import JSONResponse + +from ...services.auth.service import parse_role_values +from ...services.executive_briefing_service import ExecutiveBriefingService +from .schemas import ExecutiveBriefingConfigRequest +from .shared import AsyncSession, ErrorCode, User, error_response, get_current_user, get_db, get_tenant_id, router, success_response + + +def _manager_roles(user: User) -> set[str]: + return set(parse_role_values(getattr(user, "role", ""))) + + +def _forbidden_response() -> JSONResponse: + return JSONResponse(status_code=403, content=error_response(ErrorCode.FORBIDDEN, "Manager permission required")) + + +@router.get("/executive-briefing") +async def get_executive_briefing( + mode: str = Query(default="snapshot", pattern="^(snapshot|live)$"), + current_user: User = Depends(get_current_user), + db: AsyncSession = Depends(get_db), + tenant_id: int = Depends(get_tenant_id), +): + if not _manager_roles(current_user).intersection({"BOSS", "ADMIN"}): + return _forbidden_response() + + try: + payload = await ExecutiveBriefingService(db, tenant_id=tenant_id).get_briefing(mode=mode) + except ValueError as exc: + return JSONResponse(status_code=400, content=error_response(400, str(exc))) + return success_response(payload) + + +@router.get("/executive-briefing/config") +async def get_executive_briefing_config( + current_user: User = Depends(get_current_user), + db: AsyncSession = Depends(get_db), + tenant_id: int = Depends(get_tenant_id), +): + if not _manager_roles(current_user).intersection({"BOSS", "ADMIN"}): + return _forbidden_response() + payload = await ExecutiveBriefingService(db, tenant_id=tenant_id).get_config() + return success_response(payload) + + +@router.put("/executive-briefing/config") +async def update_executive_briefing_config( + request: ExecutiveBriefingConfigRequest, + current_user: User = Depends(get_current_user), + db: AsyncSession = Depends(get_db), + tenant_id: int = Depends(get_tenant_id), +): + if not _manager_roles(current_user).intersection({"BOSS", "ADMIN"}): + return _forbidden_response() + + service = ExecutiveBriefingService(db, tenant_id=tenant_id) + try: + payload = await service.update_config(payload=request.model_dump(), operator_user_id=int(current_user.id)) + await db.commit() + except ValueError as exc: + await db.rollback() + return JSONResponse(status_code=422, content=error_response(ErrorCode.VALIDATION_ERROR, str(exc))) + return success_response(payload) diff --git a/backend/app/api/ai_steward_api/schemas.py b/backend/app/api/ai_steward_api/schemas.py index 228db27c..b047b03c 100644 --- a/backend/app/api/ai_steward_api/schemas.py +++ b/backend/app/api/ai_steward_api/schemas.py @@ -94,3 +94,53 @@ class WorkflowTriggerRequest(BaseModel): """工作流触发请求""" event_type: str event_data: Dict[str, Any] + + +class ExecutiveBriefingConfigRequest(BaseModel): + enabled: bool = True + send_time: str = Field(default="08:30", pattern=r"^\d{2}:\d{2}$") + send_cycle: Literal["workdays", "daily"] = "workdays" + delivery_target_type: Literal["management_group"] = "management_group" + delivery_target_ref: Optional[str] = Field(default=None, max_length=128) + timezone: str = Field(default="Asia/Shanghai", min_length=3, max_length=64) + + +class ExecutiveBriefingMetric(BaseModel): + label: str + value: Any + tone: str + + +class ExecutiveBriefingItem(BaseModel): + title: str + detail: str + tone: str + kind: Optional[str] = None + + +class ExecutiveBriefingSection(BaseModel): + key: str + title: str + summary: str + metrics: List[ExecutiveBriefingMetric] = Field(default_factory=list) + items: List[ExecutiveBriefingItem] = Field(default_factory=list) + drilldown_tab: Optional[str] = None + + +class ExecutiveBriefingHero(BaseModel): + title: str + decision_count: int + anomaly_count: int + baseline_generated_at: Optional[str] = None + live_generated_at: Optional[str] = None + key_alerts: List[ExecutiveBriefingMetric] = Field(default_factory=list) + snapshot_state: str = "missing" + + +class ExecutiveBriefingPayload(BaseModel): + brief_date: str + mode: Literal["snapshot", "live"] + summary: Dict[str, Any] + hero: ExecutiveBriefingHero + sections: List[ExecutiveBriefingSection] = Field(default_factory=list) + delivery: Dict[str, Any] = Field(default_factory=dict) diff --git a/backend/app/services/ai_pmo/ai_pmo_cadence/mixin_builders.py b/backend/app/services/ai_pmo/ai_pmo_cadence/mixin_builders.py index accfcb5d..d39a3167 100644 --- a/backend/app/services/ai_pmo/ai_pmo_cadence/mixin_builders.py +++ b/backend/app/services/ai_pmo/ai_pmo_cadence/mixin_builders.py @@ -94,6 +94,17 @@ class CadenceBuildersMixin: "overdue_tasks": overdue_tasks[:20], } + async def _build_executive_brief(self, *, project_id: Optional[int]) -> dict[str, Any]: + _ = project_id + from ...executive_briefing_service import ExecutiveBriefingService + + generated_at = utc_now_naive() + return await ExecutiveBriefingService(self.db, tenant_id=self.tenant_id).build_brief_payload( + mode="snapshot", + baseline_generated_at=generated_at, + live_generated_at=None, + ) + @staticmethod def _build_focus_actions(*, pending_manual_count: int, blocked_task_count: int) -> list[str]: actions: list[str] = [] diff --git a/backend/app/services/ai_pmo/ai_pmo_cadence/mixin_snapshots.py b/backend/app/services/ai_pmo/ai_pmo_cadence/mixin_snapshots.py index 6016cc1c..bf27cd58 100644 --- a/backend/app/services/ai_pmo/ai_pmo_cadence/mixin_snapshots.py +++ b/backend/app/services/ai_pmo/ai_pmo_cadence/mixin_snapshots.py @@ -24,6 +24,8 @@ class CadenceSnapshotsMixin: payload = await self._build_weekly_review(project_id=project_id) elif normalized_type == "risk_review": payload = await self._build_risk_review(project_id=project_id) + elif normalized_type == "executive_brief": + payload = await self._build_executive_brief(project_id=project_id) else: payload = await self._build_milestone_check(project_id=project_id) diff --git a/backend/app/services/ai_pmo/ai_pmo_cadence/service.py b/backend/app/services/ai_pmo/ai_pmo_cadence/service.py index 7bc1ff05..07c90d25 100644 --- a/backend/app/services/ai_pmo/ai_pmo_cadence/service.py +++ b/backend/app/services/ai_pmo/ai_pmo_cadence/service.py @@ -24,6 +24,7 @@ class AIPMOCadenceService( "weekly_review", "risk_review", "milestone_check", + "executive_brief", } def __init__(self, db: AsyncSession, tenant_id: int) -> None: diff --git a/backend/app/services/executive_briefing_service.py b/backend/app/services/executive_briefing_service.py new file mode 100644 index 00000000..8be1f449 --- /dev/null +++ b/backend/app/services/executive_briefing_service.py @@ -0,0 +1,927 @@ +"""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] + diff --git a/backend/app/services/scheduler_center_core/ai_pmo_cadence.py b/backend/app/services/scheduler_center_core/ai_pmo_cadence.py index 2e9096a9..e4aca728 100644 --- a/backend/app/services/scheduler_center_core/ai_pmo_cadence.py +++ b/backend/app/services/scheduler_center_core/ai_pmo_cadence.py @@ -2,6 +2,8 @@ from __future__ import annotations +from ..integrations.feishu_outbox import FeishuOutboxService +from ..executive_briefing_service import ExecutiveBriefingService from .shared import ( AIPMOCadenceService, PortfolioSnapshot, @@ -73,6 +75,7 @@ class AIPMOCadenceScheduler: emitted = 0 for tenant_id in effective_tenants: service = AIPMOCadenceService(db, tenant_id=int(tenant_id)) + executive_service = ExecutiveBriefingService(db, tenant_id=int(tenant_id)) project_ids = ( await db.execute( select(Project.id).where( @@ -84,6 +87,46 @@ class AIPMOCadenceScheduler: ) ).scalars().all() + executive_config = await executive_service.get_config() + last_executive_snapshot_at = ( + await db.execute( + select(PortfolioSnapshot.created_at) + .where( + and_( + PortfolioSnapshot.tenant_id == int(tenant_id), + PortfolioSnapshot.scope_type == "executive_brief", + PortfolioSnapshot.scope_ref == f"tenant:{tenant_id}", + ) + ) + .order_by(PortfolioSnapshot.id.desc()) + .limit(1) + ) + ).scalars().first() + if self._should_emit_executive_brief( + now=now, + config=executive_config, + last_snapshot_at=last_executive_snapshot_at, + ): + snapshot = await service.generate_snapshot(cadence_type="executive_brief", project_id=None) + emitted += 1 + delivery_target_ref = str(executive_config.get("delivery_target_ref") or "").strip() + if delivery_target_ref: + text = executive_service.render_management_group_message(snapshot.get("payload") or {}) + await FeishuOutboxService.enqueue( + db, + tenant_id=int(tenant_id), + chat_id=delivery_target_ref, + text=text, + source_message_id=None, + dedupe_seed=f"executive_brief:{tenant_id}:{(snapshot.get('payload') or {}).get('brief_date') or now.date().isoformat()}", + trace_id=f"executive_brief:{tenant_id}", + ) + else: + logger.warning( + "executive_brief_delivery_target_missing", + tenant_id=int(tenant_id), + ) + if await self._snapshot_due( db=db, tenant_id=int(tenant_id), @@ -152,6 +195,36 @@ class AIPMOCadenceScheduler: logger.info("ai_pmo_cadence_scheduler_done", tenant_count=len(effective_tenants), snapshot_count=emitted) return {"tenant_count": len(effective_tenants), "snapshot_count": emitted} + @staticmethod + def _should_emit_executive_brief( + *, + now: datetime, + config: dict, + last_snapshot_at: datetime | None, + ) -> bool: + if not bool(config.get("enabled", True)): + return False + + send_time = str(config.get("send_time") or "08:30").strip() + try: + hour_text, minute_text = send_time.split(":", 1) + send_hour = int(hour_text) + send_minute = int(minute_text) + except Exception: + send_hour = 8 + send_minute = 30 + + if str(config.get("send_cycle") or "workdays").strip().lower() == "workdays" and now.weekday() >= 5: + return False + + if (now.hour, now.minute) < (send_hour, send_minute): + return False + + if last_snapshot_at is not None and last_snapshot_at.date() == now.date(): + return False + + return True + @staticmethod async def _snapshot_due(*, db, tenant_id: int, scope_type: str, scope_ref: str, freshness: timedelta) -> bool: row = ( diff --git a/backend/tests/test_ai_pmo_cadence_scheduler.py b/backend/tests/test_ai_pmo_cadence_scheduler.py new file mode 100644 index 00000000..1b9d32c0 --- /dev/null +++ b/backend/tests/test_ai_pmo_cadence_scheduler.py @@ -0,0 +1,68 @@ +from __future__ import annotations + +from datetime import datetime + +from app.services.scheduler_center_core.ai_pmo_cadence import AIPMOCadenceScheduler + + +def test_should_emit_executive_brief_honors_send_time_and_cycle() -> None: + config = { + "enabled": True, + "send_time": "08:30", + "send_cycle": "workdays", + "timezone": "Asia/Shanghai", + } + + assert ( + AIPMOCadenceScheduler._should_emit_executive_brief( + now=datetime(2026, 3, 30, 9, 0, 0), + config=config, + last_snapshot_at=None, + ) + is True + ) + assert ( + AIPMOCadenceScheduler._should_emit_executive_brief( + now=datetime(2026, 3, 30, 8, 0, 0), + config=config, + last_snapshot_at=None, + ) + is False + ) + assert ( + AIPMOCadenceScheduler._should_emit_executive_brief( + now=datetime(2026, 4, 4, 9, 0, 0), + config=config, + last_snapshot_at=None, + ) + is False + ) + + +def test_should_emit_executive_brief_skips_when_today_snapshot_already_exists() -> None: + config = { + "enabled": True, + "send_time": "08:30", + "send_cycle": "daily", + "timezone": "Asia/Shanghai", + } + + assert ( + AIPMOCadenceScheduler._should_emit_executive_brief( + now=datetime(2026, 3, 30, 9, 0, 0), + config=config, + last_snapshot_at=datetime(2026, 3, 30, 8, 45, 0), + ) + is False + ) + assert ( + AIPMOCadenceScheduler._should_emit_executive_brief( + now=datetime(2026, 3, 30, 9, 0, 0), + config={ + **config, + "enabled": False, + }, + last_snapshot_at=None, + ) + is False + ) diff --git a/backend/tests/test_executive_briefing_api.py b/backend/tests/test_executive_briefing_api.py new file mode 100644 index 00000000..cc143f7f --- /dev/null +++ b/backend/tests/test_executive_briefing_api.py @@ -0,0 +1,228 @@ +from __future__ import annotations + +from datetime import datetime +from types import SimpleNamespace + +import pytest +import pytest_asyncio +from fastapi import FastAPI +from httpx import ASGITransport, AsyncClient +from sqlalchemy import JSON +from sqlalchemy.ext.asyncio import async_sessionmaker, create_async_engine + +from app.api.ai_steward_api import routes_executive_briefing +from app.core.database import get_db +from app.dependencies.auth import get_current_user +from app.dependencies.tenant import get_tenant_id +from app.models.base import Base +from app.models.integration import Integration +from app.services.executive_briefing_service import ExecutiveBriefingService + + +def _use_sqlite_json_types() -> None: + Integration.__table__.c.config.type = JSON() + + +@pytest_asyncio.fixture +async def db_session(): + engine = create_async_engine("sqlite+aiosqlite:///:memory:") + _use_sqlite_json_types() + async with engine.begin() as conn: + await conn.run_sync( + lambda sync_conn: Base.metadata.create_all( + sync_conn, + tables=[ + Integration.__table__, + ], + ) + ) + maker = async_sessionmaker(engine, expire_on_commit=False) + async with maker() as session: + yield session + await engine.dispose() + + +def _build_app(*, db_session, role: str = "ADMIN") -> FastAPI: + app = FastAPI() + app.include_router(routes_executive_briefing.router) + + async def _fake_get_db(): + yield db_session + + app.dependency_overrides[get_db] = _fake_get_db + app.dependency_overrides[get_tenant_id] = lambda: 1 + app.dependency_overrides[get_current_user] = lambda: SimpleNamespace( + id=9001, + tenant_id=1, + name="管理员", + username="admin", + role=role, + ) + return app + + +@pytest.mark.asyncio +async def test_executive_briefing_snapshot_and_live_are_enveloped(db_session, monkeypatch) -> None: + async def fake_get_briefing(self, *, mode: str): # noqa: ANN001 + _ = self + return { + "brief_date": "2026-03-30", + "mode": mode, + "hero": { + "title": "老板晨报", + "decision_count": 2, + "anomaly_count": 4, + }, + "sections": [ + { + "key": "decisions", + "title": "今日需拍板", + "summary": "有 2 项需要拍板", + "metrics": [{"label": "需拍板", "value": 2, "tone": "warning"}], + "items": [], + "drilldown_tab": "signals", + } + ], + } + + monkeypatch.setattr(ExecutiveBriefingService, "get_briefing", fake_get_briefing) + + app = _build_app(db_session=db_session) + transport = ASGITransport(app=app) + async with AsyncClient(transport=transport, base_url="http://test") as client: + snapshot_response = await client.get("/ai-steward/executive-briefing", params={"mode": "snapshot"}) + live_response = await client.get("/ai-steward/executive-briefing", params={"mode": "live"}) + + assert snapshot_response.status_code == 200 + snapshot_payload = snapshot_response.json() + assert snapshot_payload["code"] == 0 + assert snapshot_payload["message"] == "success" + assert snapshot_payload["data"]["mode"] == "snapshot" + assert snapshot_payload["data"]["hero"]["decision_count"] == 2 + + assert live_response.status_code == 200 + live_payload = live_response.json() + assert live_payload["code"] == 0 + assert live_payload["data"]["mode"] == "live" + assert live_payload["data"]["sections"][0]["key"] == "decisions" + + +@pytest.mark.asyncio +async def test_executive_briefing_config_round_trip_is_enveloped(db_session) -> None: + app = _build_app(db_session=db_session) + transport = ASGITransport(app=app) + async with AsyncClient(transport=transport, base_url="http://test") as client: + default_response = await client.get("/ai-steward/executive-briefing/config") + update_response = await client.put( + "/ai-steward/executive-briefing/config", + json={ + "enabled": True, + "send_time": "09:15", + "send_cycle": "daily", + "delivery_target_type": "management_group", + "delivery_target_ref": "oc_management_group", + "timezone": "Asia/Shanghai", + }, + ) + refreshed_response = await client.get("/ai-steward/executive-briefing/config") + + assert default_response.status_code == 200 + default_payload = default_response.json() + assert default_payload["code"] == 0 + assert default_payload["data"]["send_time"] == "08:30" + assert default_payload["data"]["send_cycle"] == "workdays" + + assert update_response.status_code == 200 + update_payload = update_response.json() + assert update_payload["code"] == 0 + assert update_payload["data"]["send_time"] == "09:15" + assert update_payload["data"]["delivery_target_ref"] == "oc_management_group" + assert update_payload["data"]["updated_by"] == 9001 + + refreshed_payload = refreshed_response.json() + assert refreshed_payload["code"] == 0 + assert refreshed_payload["data"]["send_time"] == "09:15" + assert refreshed_payload["data"]["send_cycle"] == "daily" + + +@pytest.mark.asyncio +async def test_executive_briefing_config_rejects_non_manager_users_with_envelope(db_session) -> None: + app = _build_app(db_session=db_session, role="BE") + transport = ASGITransport(app=app) + async with AsyncClient(transport=transport, base_url="http://test") as client: + response = await client.put( + "/ai-steward/executive-briefing/config", + json={ + "enabled": True, + "send_time": "09:15", + "send_cycle": "daily", + "delivery_target_type": "management_group", + "delivery_target_ref": "oc_management_group", + "timezone": "Asia/Shanghai", + }, + ) + + assert response.status_code == 403 + payload = response.json() + assert payload["code"] == 403 + assert payload["data"] is None + + +@pytest.mark.asyncio +async def test_executive_briefing_service_persists_config_in_integration_row(db_session) -> None: + service = ExecutiveBriefingService(db_session, tenant_id=1) + + updated = await service.update_config( + payload={ + "enabled": True, + "send_time": "09:45", + "send_cycle": "daily", + "delivery_target_type": "management_group", + "delivery_target_ref": "oc_management_group_2", + "timezone": "Asia/Shanghai", + }, + operator_user_id=777, + ) + await db_session.commit() + + assert updated["send_time"] == "09:45" + assert updated["updated_by"] == 777 + + row = ( + await db_session.execute( + Integration.__table__.select().where(Integration.tenant_id == 1, Integration.type == "executive_briefing") + ) + ).mappings().one() + assert row["config"]["send_time"] == "09:45" + assert row["config"]["delivery_target_ref"] == "oc_management_group_2" + + +@pytest.mark.asyncio +async def test_executive_briefing_service_formats_management_summary_text(db_session) -> None: + service = ExecutiveBriefingService(db_session, tenant_id=1) + + text = service.render_management_group_message( + { + "brief_date": "2026-03-30", + "hero": { + "title": "老板晨报", + "decision_count": 2, + "anomaly_count": 4, + "baseline_generated_at": datetime(2026, 3, 30, 8, 30, 0).isoformat(), + }, + "sections": [ + { + "title": "今日需拍板", + "summary": "有 2 项需要拍板", + "items": [ + {"title": "P0 信号无人接", "detail": "项目 A"}, + {"title": "持续过载成员", "detail": "张三 128%"}, + ], + } + ], + } + ) + + assert "老板晨报" in text + assert "今日需拍板" in text + assert "P0 信号无人接" in text diff --git a/frontend/src/api/aiSteward.ts b/frontend/src/api/aiSteward.ts index 8173a872..8c20ed4b 100644 --- a/frontend/src/api/aiSteward.ts +++ b/frontend/src/api/aiSteward.ts @@ -86,6 +86,86 @@ export interface SignalSourceRepairResponse { items: SignalSourceRepairItem[]; } +export interface CognitiveConflict { + id: string; + type: string; + description: string; + severity: string; + related_users: string[]; +} + +export interface Blocker { + id: string; + type: string; + description: string; + severity: string; + detected_at: string; +} + +export interface DailyBriefing { + date: string; + summary: string; + highlights: Array<{ type: string; priority: string; content: string }>; + your_tasks: Array<{ id: number; title: string; status: string; priority: string }>; + risks: Array<{ type: string; task: string; severity: string }>; + signals: Array<{ id: number; source: string; content: string; impact_score: number }>; + conflicts: Array<{ id: string; type: string; description: string }>; + action_items: Array<{ type: string; content: string; priority: string }>; + team_updates: Array<{ assignee_id: number; task: string; status: string }>; +} + +export interface ExecutiveBriefingMetric { + label: string; + value: string | number; + tone: string; +} + +export interface ExecutiveBriefingItem { + title: string; + detail: string; + tone: string; + kind?: string | null; +} + +export interface ExecutiveBriefingSection { + key: string; + title: string; + summary: string; + metrics: ExecutiveBriefingMetric[]; + items: ExecutiveBriefingItem[]; + drilldown_tab?: string | null; +} + +export interface ExecutiveBriefingHero { + title: string; + decision_count: number; + anomaly_count: number; + baseline_generated_at?: string | null; + live_generated_at?: string | null; + key_alerts: ExecutiveBriefingMetric[]; + snapshot_state: "ready" | "missing" | string; +} + +export interface ExecutiveBriefingConfig { + enabled: boolean; + send_time: string; + send_cycle: "workdays" | "daily"; + delivery_target_type: "management_group"; + delivery_target_ref: string | null; + timezone: string; + updated_at?: string | null; + updated_by?: number | null; +} + +export interface ExecutiveBriefingPayload { + brief_date: string; + mode: "snapshot" | "live"; + summary: Record; + hero: ExecutiveBriefingHero; + sections: ExecutiveBriefingSection[]; + delivery: ExecutiveBriefingConfig; +} + export type LLMProvider = "deepseek" | "glm" | "openai" | "ollama"; export interface LLMProviderPreset { @@ -145,6 +225,53 @@ export interface LLMEngineConnectionTestResponse { } export const aiStewardApi = { + async listCognitiveConflicts(projectId?: number): Promise { + const query = typeof projectId === "number" ? `?project_id=${projectId}` : ""; + return await apiRequest(`/ai-steward/cognitive-conflicts${query}`); + }, + async detectConflicts(projectId: number): Promise<{ project_id: number; detected_count: number; conflicts: CognitiveConflict[] }> { + return await apiRequest<{ project_id: number; detected_count: number; conflicts: CognitiveConflict[] }>( + "/ai-steward/conflicts/detect", + { + method: "POST", + body: JSON.stringify({ project_id: projectId, detect_type: "all" }), + } + ); + }, + async listActiveBlockers(): Promise<{ total: number; blockers: Blocker[] }> { + return await apiRequest<{ total: number; blockers: Blocker[] }>("/ai-steward/blockers/active"); + }, + async detectTaskBlockers(): Promise<{ total_detected: number; blockers: Blocker[] }> { + return await apiRequest<{ total_detected: number; blockers: Blocker[] }>("/ai-steward/blockers/detect-tasks", { + method: "POST", + }); + }, + async getDailyBriefing(): Promise { + return await apiRequest("/ai-steward/daily-briefing"); + }, + async startHnMonitor(intervalMinutes: number = 30): Promise> { + return await apiRequest>( + `/ai-steward/signal/start-hn-monitor?interval_minutes=${intervalMinutes}`, + { + method: "POST", + } + ); + }, + async getExecutiveBriefing(mode: "snapshot" | "live"): Promise { + const raw = await apiRequest>(`/ai-steward/executive-briefing?mode=${mode}`); + return unwrap(raw); + }, + async getExecutiveBriefingConfig(): Promise { + const raw = await apiRequest>("/ai-steward/executive-briefing/config"); + return unwrap(raw); + }, + async updateExecutiveBriefingConfig(body: ExecutiveBriefingConfig): Promise { + const raw = await apiRequest>("/ai-steward/executive-briefing/config", { + method: "PUT", + body: JSON.stringify(body), + }); + return unwrap(raw); + }, async getLlmProviderPresets(): Promise { const raw = await apiRequest>("/ai-steward/llm/providers"); return unwrap(raw); diff --git a/frontend/src/pages/ai-steward/AIStewardPage.tsx b/frontend/src/pages/ai-steward/AIStewardPage.tsx index 9815ed00..e5a0bb1c 100644 --- a/frontend/src/pages/ai-steward/AIStewardPage.tsx +++ b/frontend/src/pages/ai-steward/AIStewardPage.tsx @@ -2,7 +2,7 @@ import { useState, useEffect } from "react"; import { useMutation } from "@tanstack/react-query"; import { useNavigate, useSearchParams } from "react-router-dom"; import { Sparkles } from "lucide-react"; -import { apiBaseURL, getAuthToken } from "@/lib/api"; +import { getAuthToken } from "@/lib/api"; import { getApiErrorMessage } from "@/lib/apiError"; import { Card, CardContent, @@ -24,6 +24,8 @@ import type { Blocker, CognitiveConflict, DailyBriefing, + ExecutiveBriefingConfig, + ExecutiveBriefingPayload, FeedbackCenterSourceGroup, FeedbackCenterSection, FeedbackCenterTrendWindowDays, @@ -57,6 +59,16 @@ const AIS_STEWARD_TABS: AIStewardTab[] = [ ]; const FEEDBACK_SECTIONS: FeedbackCenterSection[] = ["overview", "metrics", "trend", "task", "qa", "signal"]; +const DEFAULT_EXECUTIVE_BRIEFING_CONFIG: ExecutiveBriefingConfig = { + enabled: true, + send_time: "08:30", + send_cycle: "workdays", + delivery_target_type: "management_group", + delivery_target_ref: null, + timezone: "Asia/Shanghai", + updated_at: null, + updated_by: null, +}; function normalizeTab(value: string | null): AIStewardTab { if (value && AIS_STEWARD_TABS.includes(value as AIStewardTab)) { @@ -88,6 +100,7 @@ export default function AIStewardPage() { const [searchParams, setSearchParams] = useSearchParams(); const user = useAuthStore((s) => s.user); const canViewFeedbackCenter = parseUserRoles(user?.role).some((role) => role === "ADMIN" || role === "BOSS"); + const canManageExecutiveBriefing = canViewFeedbackCenter; // Project creation state const [input, setInput] = useState(""); @@ -109,6 +122,17 @@ export default function AIStewardPage() { const [blockers, setBlockers] = useState([]); const [briefing, setBriefing] = useState(null); const [briefingError, setBriefingError] = useState(null); + const [executiveBriefing, setExecutiveBriefing] = useState(null); + const [executiveBriefingMode, setExecutiveBriefingMode] = useState<"snapshot" | "live">("snapshot"); + const [executiveBriefingError, setExecutiveBriefingError] = useState(null); + const [executiveBriefingLoading, setExecutiveBriefingLoading] = useState(false); + const [executiveBriefingConfigDraft, setExecutiveBriefingConfigDraft] = useState( + DEFAULT_EXECUTIVE_BRIEFING_CONFIG + ); + const [executiveBriefingConfigError, setExecutiveBriefingConfigError] = useState(null); + const [executiveBriefingSaveMessage, setExecutiveBriefingSaveMessage] = useState(null); + const [executiveBriefingConfigLoading, setExecutiveBriefingConfigLoading] = useState(false); + const [executiveBriefingConfigSaving, setExecutiveBriefingConfigSaving] = useState(false); const [featureLoading, setFeatureLoading] = useState(null); const [engineData, setEngineData] = useState({ active_engine_id: null, @@ -146,14 +170,8 @@ export default function AIStewardPage() { const loadConflicts = async () => { setFeatureLoading("conflicts"); try { - const token = getAuthToken(); - const response = await fetch(`${apiBaseURL}/ai-steward/cognitive-conflicts`, { - headers: { Authorization: `Bearer ${token}` }, - }); - if (response.ok) { - const data = await response.json(); - setConflicts(data); - } + const data = await aiStewardApi.listCognitiveConflicts(); + setConflicts(data); } catch (err) { console.error("Failed to load conflicts:", err); } finally { @@ -164,14 +182,8 @@ export default function AIStewardPage() { const loadBlockers = async () => { setFeatureLoading("blockers"); try { - const token = getAuthToken(); - const response = await fetch(`${apiBaseURL}/ai-steward/blockers/active`, { - headers: { Authorization: `Bearer ${token}` }, - }); - if (response.ok) { - const data = await response.json(); - setBlockers(data.blockers || []); - } + const data = await aiStewardApi.listActiveBlockers(); + setBlockers(data.blockers || []); } catch (err) { console.error("Failed to load blockers:", err); } finally { @@ -183,45 +195,74 @@ export default function AIStewardPage() { setFeatureLoading("briefing"); setBriefingError(null); try { - const token = getAuthToken(); - const response = await fetch(`${apiBaseURL}/ai-steward/daily-briefing`, { - headers: { Authorization: `Bearer ${token}` }, - }); - if (response.ok) { - const data = await response.json(); - setBriefing(data); - } else { - const payload = await response.json().catch(() => null); - const detail = typeof payload?.detail === "string" ? payload.detail : typeof payload?.message === "string" ? payload.message : ""; - setBriefing(null); - setBriefingError(detail || `获取简报失败 (${response.status})`); - } + const data = await aiStewardApi.getDailyBriefing(); + setBriefing(data); } catch (err) { console.error("Failed to load briefing:", err); setBriefing(null); - setBriefingError(err instanceof Error ? err.message : "获取简报失败"); + setBriefingError(getApiErrorMessage(err, "获取简报失败")); } finally { setFeatureLoading(null); } }; + const loadExecutiveBriefing = async (mode: "snapshot" | "live") => { + setExecutiveBriefingLoading(true); + setExecutiveBriefingError(null); + setExecutiveBriefingMode(mode); + try { + const data = await aiStewardApi.getExecutiveBriefing(mode); + setExecutiveBriefing(data); + setExecutiveBriefingMode(data.mode); + } catch (err) { + console.error("Failed to load executive briefing:", err); + setExecutiveBriefingError(getApiErrorMessage(err, "获取老板晨报失败")); + } finally { + setExecutiveBriefingLoading(false); + } + }; + + const loadExecutiveBriefingConfig = async () => { + setExecutiveBriefingConfigLoading(true); + setExecutiveBriefingConfigError(null); + try { + const data = await aiStewardApi.getExecutiveBriefingConfig(); + setExecutiveBriefingConfigDraft(data); + } catch (err) { + console.error("Failed to load executive briefing config:", err); + setExecutiveBriefingConfigError(getApiErrorMessage(err, "加载晨报配置失败")); + } finally { + setExecutiveBriefingConfigLoading(false); + } + }; + + const saveExecutiveBriefingConfig = async () => { + setExecutiveBriefingConfigSaving(true); + setExecutiveBriefingConfigError(null); + setExecutiveBriefingSaveMessage(null); + try { + const data = await aiStewardApi.updateExecutiveBriefingConfig(executiveBriefingConfigDraft); + setExecutiveBriefingConfigDraft(data); + setExecutiveBriefingSaveMessage({ type: "success", text: "晨报调度配置已保存" }); + if (activeTab === "overview") { + await loadExecutiveBriefing("snapshot"); + } + } catch (err) { + console.error("Failed to save executive briefing config:", err); + const message = getApiErrorMessage(err, "保存晨报配置失败"); + setExecutiveBriefingConfigError(message); + setExecutiveBriefingSaveMessage({ type: "error", text: message }); + } finally { + setExecutiveBriefingConfigSaving(false); + } + }; const detectConflicts = async (projectId: number = 1) => { setFeatureLoading("detect-conflicts"); try { - const token = getAuthToken(); - const response = await fetch(`${apiBaseURL}/ai-steward/conflicts/detect`, { - method: "POST", - headers: { - "Content-Type": "application/json", - Authorization: `Bearer ${token}`, - }, - body: JSON.stringify({ project_id: projectId, detect_type: "all" }), - }); - if (response.ok) { - await loadConflicts(); - return true; - } + await aiStewardApi.detectConflicts(projectId); + await loadConflicts(); + return true; } catch (err) { console.error("Failed to detect conflicts:", err); } finally { @@ -233,15 +274,9 @@ export default function AIStewardPage() { const detectTaskBlockers = async () => { setFeatureLoading("detect-blockers"); try { - const token = getAuthToken(); - const response = await fetch(`${apiBaseURL}/ai-steward/blockers/detect-tasks`, { - method: "POST", - headers: { Authorization: `Bearer ${token}` }, - }); - if (response.ok) { - await loadBlockers(); - return true; - } + await aiStewardApi.detectTaskBlockers(); + await loadBlockers(); + return true; } catch (err) { console.error("Failed to detect blockers:", err); } finally { @@ -253,14 +288,8 @@ export default function AIStewardPage() { const startHNMonitor = async () => { setFeatureLoading("hn-monitor"); try { - const token = getAuthToken(); - const response = await fetch(`${apiBaseURL}/ai-steward/signal/start-hn-monitor?interval_minutes=30`, { - method: "POST", - headers: { Authorization: `Bearer ${token}` }, - }); - if (response.ok) { - return true; - } + await aiStewardApi.startHnMonitor(30); + return true; } catch (err) { console.error("Failed to start HN monitor:", err); } finally { @@ -460,6 +489,13 @@ export default function AIStewardPage() { setEngineMessage(null); }; + const updateExecutiveBriefingDraft = (patch: Partial) => { + setExecutiveBriefingConfigDraft((current) => ({ + ...current, + ...patch, + })); + }; + // ==================== Project Creation ==================== const handleProjectSubmit = async () => { @@ -543,6 +579,10 @@ export default function AIStewardPage() { }, [searchParams, activeTab, feedbackSection, trendWindowDays, trendSourceGroup, canViewFeedbackCenter]); useEffect(() => { + if (activeTab === "overview" && canManageExecutiveBriefing) { + void loadExecutiveBriefing("snapshot"); + void loadExecutiveBriefingConfig(); + } if (activeTab === "conflicts") loadConflicts(); if (activeTab === "blockers") loadBlockers(); if (activeTab === "briefing") loadBriefing(); @@ -550,7 +590,7 @@ export default function AIStewardPage() { loadEngineProviderPresets(); loadEngineConfigs(); } - }, [activeTab]); + }, [activeTab, canManageExecutiveBriefing]); // ==================== Render ==================== @@ -558,26 +598,34 @@ export default function AIStewardPage() {
{/* Header */} -
-
- +
+
+

- AI 管家 + {canManageExecutiveBriefing ? "老板晨报中心" : "AI 管家"}

- 主动式 AI 管家 - 7x24 小时自动守护项目 + {canManageExecutiveBriefing + ? "晨报快照、实时刷新与管理群推送共用同一套经营简报内核" + : "主动式 AI 管家 - 7x24 小时自动守护项目"}

- handleTabChange(tab)} /> + handleTabChange(tab)} + canManageExecutiveBriefing={canManageExecutiveBriefing} + executiveBriefing={executiveBriefing} + /> {/* Main Content */} - 总览 + + {canManageExecutiveBriefing ? "晨报中心" : "总览"} + 零配置创建 信号捕获 认知冲突 @@ -590,7 +638,23 @@ export default function AIStewardPage() { 引擎配置 - + void loadExecutiveBriefing("snapshot")} + onRefreshLive={() => void loadExecutiveBriefing("live")} + onConfigChange={updateExecutiveBriefingDraft} + onSaveConfig={() => void saveExecutiveBriefingConfig()} + onSelectTab={(tab) => handleTabChange(tab)} + /> void; + onSelectTab: (tab: AIStewardTab) => void; + canManageExecutiveBriefing?: boolean; + executiveBriefing?: ExecutiveBriefingPayload | null; } -export default function FeatureOverviewCards({ onSelectTab }: FeatureOverviewCardsProps) { +const EXECUTIVE_CARD_META: Record< + string, + { badge: string; icon: typeof AlertTriangle; className: string; fallbackTab: AIStewardTab } +> = { + decisions: { + badge: "需拍板", + icon: BriefcaseBusiness, + className: "border-amber-200 bg-amber-50/80 dark:border-amber-900 dark:bg-amber-950/30", + fallbackTab: "signals", + }, + input_health: { + badge: "业务输入", + icon: Radar, + className: "border-sky-200 bg-sky-50/80 dark:border-sky-900 dark:bg-sky-950/30", + fallbackTab: "signals", + }, + delivery_risk: { + badge: "交付风险", + icon: Shield, + className: "border-rose-200 bg-rose-50/80 dark:border-rose-900 dark:bg-rose-950/30", + fallbackTab: "blockers", + }, + team_capacity: { + badge: "团队产能", + icon: Users2, + className: "border-emerald-200 bg-emerald-50/80 dark:border-emerald-900 dark:bg-emerald-950/30", + fallbackTab: "conflicts", + }, +}; + +export default function FeatureOverviewCards({ + onSelectTab, + canManageExecutiveBriefing = false, + executiveBriefing, +}: FeatureOverviewCardsProps) { + if (canManageExecutiveBriefing && executiveBriefing) { + const cards = executiveBriefing.sections.filter((section) => section.key in EXECUTIVE_CARD_META).slice(0, 4); + return ( +
+ {cards.map((section) => { + const meta = EXECUTIVE_CARD_META[section.key]; + const Icon = meta.icon; + const primaryMetric = section.metrics[0]; + const value = primaryMetric ? String(primaryMetric.value) : "-"; + const targetTab = (section.drilldown_tab as AIStewardTab | undefined) || meta.fallbackTab; + return ( + onSelectTab(targetTab)} + > + +
+ + {meta.badge} +
+ {section.title} +
+ +

{value}

+

{section.summary}

+
+
+ ); + })} +
+ ); + } + return (
onSelectTab("signals")}> diff --git a/frontend/src/pages/ai-steward/sections/OverviewTab.tsx b/frontend/src/pages/ai-steward/sections/OverviewTab.tsx index 91959db1..5c2f08cb 100644 --- a/frontend/src/pages/ai-steward/sections/OverviewTab.tsx +++ b/frontend/src/pages/ai-steward/sections/OverviewTab.tsx @@ -6,24 +6,95 @@ import { Clock, MessageSquare, Radar, + RefreshCcw, Shield, + Sparkles, } from "lucide-react"; import { TabsContent } from "@/components/ui/tabs"; +import { Badge } from "@/components/ui/badge"; +import { Button } from "@/components/ui/button"; +import { Card, CardContent } from "@/components/ui/card"; +import { Input } from "@/components/ui/input"; +import { Switch } from "@/components/ui/switch"; +import type { + AIStewardTab, + EngineMessage, + ExecutiveBriefingConfig, + ExecutiveBriefingPayload, +} from "@/pages/ai-steward/types"; -export default function OverviewTab() { +interface OverviewTabProps { + canManageExecutiveBriefing: boolean; + executiveBriefing: ExecutiveBriefingPayload | null; + executiveBriefingError: string | null; + executiveBriefingMode: "snapshot" | "live"; + executiveBriefingLoading: boolean; + executiveBriefingConfigDraft: ExecutiveBriefingConfig; + executiveBriefingConfigLoading: boolean; + executiveBriefingConfigError: string | null; + executiveBriefingSaveLoading: boolean; + executiveBriefingSaveMessage: EngineMessage | null; + onLoadSnapshot: () => void; + onRefreshLive: () => void; + onConfigChange: (patch: Partial) => void; + onSaveConfig: () => void; + onSelectTab: (tab: AIStewardTab) => void; +} + +const SECTION_STYLE: Record = { + decisions: "border-amber-200 bg-amber-50/70 dark:border-amber-900 dark:bg-amber-950/30", + input_health: "border-sky-200 bg-sky-50/70 dark:border-sky-900 dark:bg-sky-950/30", + delivery_risk: "border-rose-200 bg-rose-50/70 dark:border-rose-900 dark:bg-rose-950/30", + team_capacity: "border-emerald-200 bg-emerald-50/70 dark:border-emerald-900 dark:bg-emerald-950/30", + ai_governance: "border-indigo-200 bg-indigo-50/70 dark:border-indigo-900 dark:bg-indigo-950/30", + external_metrics: "border-slate-200 bg-slate-50/70 dark:border-slate-800 dark:bg-slate-950/30", +}; + +function formatTimestamp(value?: string | null): string { + if (!value) return "-"; + const date = new Date(value); + if (Number.isNaN(date.getTime())) return value; + return date.toLocaleString("zh-CN", { + month: "2-digit", + day: "2-digit", + hour: "2-digit", + minute: "2-digit", + }); +} + +function formatMetricValue(value: string | number): string { + return typeof value === "number" ? String(value) : value; +} + +function toneBadgeClass(tone: string): string { + switch (tone) { + case "critical": + return "bg-rose-100 text-rose-700 dark:bg-rose-950 dark:text-rose-200"; + case "warning": + return "bg-amber-100 text-amber-700 dark:bg-amber-950 dark:text-amber-200"; + case "positive": + return "bg-emerald-100 text-emerald-700 dark:bg-emerald-950 dark:text-emerald-200"; + case "info": + return "bg-sky-100 text-sky-700 dark:bg-sky-950 dark:text-sky-200"; + default: + return "bg-slate-100 text-slate-700 dark:bg-slate-900 dark:text-slate-200"; + } +} + +function LegacyOverview() { return (
-
-

AI 管家正在运行

+
+

AI 管家正在运行

4 大主动服务守护你的项目,无需手动触发

-
-
-
- +
+
+
+

1. 主动抓取信号

@@ -32,26 +103,26 @@ export default function OverviewTab() {
  • - - + + 飞书群聊监控 - 配置完成,正在运行
  • - - + + 技术资讯定时抓取 - 可启用
  • - + 浏览器标签监控 - 需安装扩展
-
-
-
- +
+
+
+

2. 主动消解认知差

@@ -60,24 +131,24 @@ export default function OverviewTab() {
  • - + 自动检测定义不一致(如"留存" vs "DAU")
  • - + 任务与宪章一致性检查
  • - + AI 生成对齐方案
-
-
-
- +
+
+
+

3. 主动解决阻塞

@@ -86,24 +157,24 @@ export default function OverviewTab() {
  • - + 识别阻塞模式("等待X接口"、"达不到效果")
  • - + AI 分析严重程度并生成解决方案
  • - + 自动创建追踪任务
-
-
-
- +
+
+
+

4. 主动同步进度

@@ -112,15 +183,15 @@ export default function OverviewTab() {
  • - + 按角色智能过滤信息
  • - + 只显示相关任务变更和风险
  • - + 推送行动项和对齐提醒
@@ -130,3 +201,273 @@ export default function OverviewTab() { ); } + +export default function OverviewTab({ + canManageExecutiveBriefing, + executiveBriefing, + executiveBriefingError, + executiveBriefingMode, + executiveBriefingLoading, + executiveBriefingConfigDraft, + executiveBriefingConfigLoading, + executiveBriefingConfigError, + executiveBriefingSaveLoading, + executiveBriefingSaveMessage, + onLoadSnapshot, + onRefreshLive, + onConfigChange, + onSaveConfig, + onSelectTab, +}: OverviewTabProps) { + if (!canManageExecutiveBriefing) { + return ; + } + + const sections = executiveBriefing?.sections ?? []; + const hero = executiveBriefing?.hero; + + return ( + +
+ + +
+
+
+ + {executiveBriefingMode === "live" ? "实时模式" : "晨报快照"} + + {hero?.snapshot_state === "missing" && ( + + 今日快照未生成 + + )} +
+
+

+ + 老板晨报中心 +

+

+ 用晨报快照做日常基线,用实时刷新做临时复盘。先看异常,再看输入、交付、团队和 AI 治理。 +

+
+
+
+

需拍板

+

+ {hero?.decision_count ?? 0} +

+
+
+

异常总数

+

+ {hero?.anomaly_count ?? 0} +

+
+
+

查看日期

+

+ {executiveBriefing?.brief_date || "-"} +

+
+
+
+ +
+
+
+ 晨报快照 + + {formatTimestamp(hero?.baseline_generated_at)} + +
+
+ 实时刷新 + + {formatTimestamp(hero?.live_generated_at)} + +
+
+
+ {(hero?.key_alerts ?? []).map((item) => ( + + {item.label} {formatMetricValue(item.value)} + + ))} +
+
+ + +
+
+
+ {executiveBriefingLoading && ( +

正在更新晨报数据...

+ )} + {executiveBriefingError && ( +

+ {executiveBriefingError} +

+ )} +
+
+ +
+ {sections.map((section) => ( + + +
+
+

{section.title}

+

{section.summary}

+
+ {section.drilldown_tab && section.drilldown_tab !== "overview" && ( + + )} +
+
+ {section.metrics.map((metric) => ( +
+

{metric.label}

+

+ {formatMetricValue(metric.value)} +

+
+ ))} +
+
+ {section.items.length ? ( + section.items.slice(0, 3).map((item, index) => ( +
+
+ {item.title} + {item.tone} +
+

{item.detail}

+
+ )) + ) : ( +
+ 当前区块暂无新增异常。 +
+ )} +
+
+
+ ))} +
+ + + +
+
+

晨报调度配置

+

+ 这份配置是租户级的。修改后,Web 快照、定时调度和管理群自动推送都会沿用同一套口径。 +

+
+
+ 自动发送 + onConfigChange({ enabled: checked })} + disabled={executiveBriefingConfigLoading || executiveBriefingSaveLoading} + /> +
+
+ +
+
+

发送时间

+ onConfigChange({ send_time: event.target.value })} + disabled={executiveBriefingConfigLoading || executiveBriefingSaveLoading} + /> +
+
+

发送周期

+
+ + +
+
+
+

管理群 Chat ID

+ onConfigChange({ delivery_target_ref: event.target.value || null })} + placeholder="oc_management_group" + disabled={executiveBriefingConfigLoading || executiveBriefingSaveLoading} + /> +
+
+

时区

+ onConfigChange({ timezone: event.target.value })} + placeholder="Asia/Shanghai" + disabled={executiveBriefingConfigLoading || executiveBriefingSaveLoading} + /> +
+
+ +
+
+ {executiveBriefingConfigError && ( +

{executiveBriefingConfigError}

+ )} + {executiveBriefingSaveMessage && ( +

+ {executiveBriefingSaveMessage.text} +

+ )} +
+ +
+
+
+
+
+ ); +} diff --git a/frontend/src/pages/ai-steward/types.ts b/frontend/src/pages/ai-steward/types.ts index b3e63dcc..b2cb1a85 100644 --- a/frontend/src/pages/ai-steward/types.ts +++ b/frontend/src/pages/ai-steward/types.ts @@ -1,5 +1,14 @@ import type { LLMEngine, LLMEngineListResponse } from "@/api/aiSteward"; export type { + Blocker, + CognitiveConflict, + DailyBriefing, + ExecutiveBriefingConfig, + ExecutiveBriefingHero, + ExecutiveBriefingItem, + ExecutiveBriefingMetric, + ExecutiveBriefingPayload, + ExecutiveBriefingSection, ProjectFromTextRequest, ProjectFromTextRequirementItem, ProjectFromTextResponse, @@ -19,34 +28,6 @@ export type AIStewardTab = | "feedback-center" | "engine"; -export interface CognitiveConflict { - id: string; - type: string; - description: string; - severity: string; - related_users: string[]; -} - -export interface Blocker { - id: string; - type: string; - description: string; - severity: string; - detected_at: string; -} - -export interface DailyBriefing { - date: string; - summary: string; - highlights: Array<{ type: string; priority: string; content: string }>; - your_tasks: Array<{ id: number; title: string; status: string; priority: string }>; - risks: Array<{ type: string; task: string; severity: string }>; - signals: Array<{ id: number; source: string; content: string; impact_score: number }>; - conflicts: Array<{ id: string; type: string; description: string }>; - action_items: Array<{ type: string; content: string; priority: string }>; - team_updates: Array<{ assignee_id: number; task: string; status: string }>; -} - export interface EngineProviderPreset { model: string; base_url: string;