feat: add executive briefing center

This commit is contained in:
Yuuuu 2026-03-30 10:50:59 +08:00
parent d2672702bc
commit 1b2b64d575
16 changed files with 2156 additions and 134 deletions

View File

@ -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 因缺失事件密钥而拒收消息

View File

@ -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,

View File

@ -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)

View File

@ -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)

View File

@ -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] = []

View File

@ -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)

View File

@ -24,6 +24,7 @@ class AIPMOCadenceService(
"weekly_review",
"risk_review",
"milestone_check",
"executive_brief",
}
def __init__(self, db: AsyncSession, tenant_id: int) -> None:

View File

@ -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]

View File

@ -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 = (

View File

@ -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
)

View File

@ -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

View File

@ -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<string, unknown>;
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<CognitiveConflict[]> {
const query = typeof projectId === "number" ? `?project_id=${projectId}` : "";
return await apiRequest<CognitiveConflict[]>(`/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<DailyBriefing> {
return await apiRequest<DailyBriefing>("/ai-steward/daily-briefing");
},
async startHnMonitor(intervalMinutes: number = 30): Promise<Record<string, unknown>> {
return await apiRequest<Record<string, unknown>>(
`/ai-steward/signal/start-hn-monitor?interval_minutes=${intervalMinutes}`,
{
method: "POST",
}
);
},
async getExecutiveBriefing(mode: "snapshot" | "live"): Promise<ExecutiveBriefingPayload> {
const raw = await apiRequest<ApiEnvelope<ExecutiveBriefingPayload>>(`/ai-steward/executive-briefing?mode=${mode}`);
return unwrap(raw);
},
async getExecutiveBriefingConfig(): Promise<ExecutiveBriefingConfig> {
const raw = await apiRequest<ApiEnvelope<ExecutiveBriefingConfig>>("/ai-steward/executive-briefing/config");
return unwrap(raw);
},
async updateExecutiveBriefingConfig(body: ExecutiveBriefingConfig): Promise<ExecutiveBriefingConfig> {
const raw = await apiRequest<ApiEnvelope<ExecutiveBriefingConfig>>("/ai-steward/executive-briefing/config", {
method: "PUT",
body: JSON.stringify(body),
});
return unwrap(raw);
},
async getLlmProviderPresets(): Promise<LLMProviderPresetResponse> {
const raw = await apiRequest<ApiEnvelope<LLMProviderPresetResponse>>("/ai-steward/llm/providers");
return unwrap(raw);

View File

@ -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<Blocker[]>([]);
const [briefing, setBriefing] = useState<DailyBriefing | null>(null);
const [briefingError, setBriefingError] = useState<string | null>(null);
const [executiveBriefing, setExecutiveBriefing] = useState<ExecutiveBriefingPayload | null>(null);
const [executiveBriefingMode, setExecutiveBriefingMode] = useState<"snapshot" | "live">("snapshot");
const [executiveBriefingError, setExecutiveBriefingError] = useState<string | null>(null);
const [executiveBriefingLoading, setExecutiveBriefingLoading] = useState(false);
const [executiveBriefingConfigDraft, setExecutiveBriefingConfigDraft] = useState<ExecutiveBriefingConfig>(
DEFAULT_EXECUTIVE_BRIEFING_CONFIG
);
const [executiveBriefingConfigError, setExecutiveBriefingConfigError] = useState<string | null>(null);
const [executiveBriefingSaveMessage, setExecutiveBriefingSaveMessage] = useState<EngineMessage | null>(null);
const [executiveBriefingConfigLoading, setExecutiveBriefingConfigLoading] = useState(false);
const [executiveBriefingConfigSaving, setExecutiveBriefingConfigSaving] = useState(false);
const [featureLoading, setFeatureLoading] = useState<string | null>(null);
const [engineData, setEngineData] = useState<EngineDataState>({
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();
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();
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();
const data = await aiStewardApi.getDailyBriefing();
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})`);
}
} 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 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 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) {
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<ExecutiveBriefingConfig>) => {
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() {
<div className="min-h-screen bg-gradient-to-br from-slate-50 to-slate-100 p-3 dark:from-slate-950 dark:to-slate-900 sm:p-4 md:p-6">
<div className="max-w-7xl mx-auto">
{/* Header */}
<div className="text-center mb-8">
<div className="flex items-center justify-center gap-3 mb-4">
<Sparkles className="w-10 h-10 text-violet-500" />
<div className="mb-8 text-center">
<div className="mb-4 flex items-center justify-center gap-3">
<Sparkles className={`h-10 w-10 ${canManageExecutiveBriefing ? "text-amber-500" : "text-violet-500"}`} />
<h1 className="text-4xl font-bold text-slate-900 dark:text-slate-100">
AI 管家
{canManageExecutiveBriefing ? "老板晨报中心" : "AI 管家"}
</h1>
</div>
<p className="text-slate-600 dark:text-slate-400 text-lg">
主动式 AI 管家 - 7x24 小时自动守护项目
{canManageExecutiveBriefing
? "晨报快照、实时刷新与管理群推送共用同一套经营简报内核"
: "主动式 AI 管家 - 7x24 小时自动守护项目"}
</p>
</div>
<FeatureOverviewCards onSelectTab={(tab) => handleTabChange(tab)} />
<FeatureOverviewCards
onSelectTab={(tab) => handleTabChange(tab)}
canManageExecutiveBriefing={canManageExecutiveBriefing}
executiveBriefing={executiveBriefing}
/>
{/* Main Content */}
<Card>
<CardContent className="pt-6">
<Tabs value={activeTab} onValueChange={handleTabChange}>
<TabsList className="flex w-full min-w-0 flex-nowrap gap-1 overflow-x-auto rounded-lg p-1 [-webkit-overflow-scrolling:touch]">
<TabsTrigger value="overview" className="shrink-0 whitespace-nowrap">总览</TabsTrigger>
<TabsTrigger value="overview" className="shrink-0 whitespace-nowrap">
{canManageExecutiveBriefing ? "晨报中心" : "总览"}
</TabsTrigger>
<TabsTrigger value="create" className="shrink-0 whitespace-nowrap">零配置创建</TabsTrigger>
<TabsTrigger value="signals" className="shrink-0 whitespace-nowrap">信号捕获</TabsTrigger>
<TabsTrigger value="conflicts" className="shrink-0 whitespace-nowrap">认知冲突</TabsTrigger>
@ -590,7 +638,23 @@ export default function AIStewardPage() {
<TabsTrigger value="engine" className="shrink-0 whitespace-nowrap">引擎配置</TabsTrigger>
</TabsList>
<OverviewTab />
<OverviewTab
canManageExecutiveBriefing={canManageExecutiveBriefing}
executiveBriefing={executiveBriefing}
executiveBriefingError={executiveBriefingError}
executiveBriefingMode={executiveBriefingMode}
executiveBriefingLoading={executiveBriefingLoading}
executiveBriefingConfigDraft={executiveBriefingConfigDraft}
executiveBriefingConfigLoading={executiveBriefingConfigLoading}
executiveBriefingConfigError={executiveBriefingConfigError}
executiveBriefingSaveLoading={executiveBriefingConfigSaving}
executiveBriefingSaveMessage={executiveBriefingSaveMessage}
onLoadSnapshot={() => void loadExecutiveBriefing("snapshot")}
onRefreshLive={() => void loadExecutiveBriefing("live")}
onConfigChange={updateExecutiveBriefingDraft}
onSaveConfig={() => void saveExecutiveBriefingConfig()}
onSelectTab={(tab) => handleTabChange(tab)}
/>
<CreateProjectTab
input={input}

View File

@ -1,12 +1,83 @@
import { AlertTriangle, Bell, Radar, Shield } from "lucide-react";
import { AlertTriangle, Bell, BriefcaseBusiness, Radar, Shield, Users2 } from "lucide-react";
import { Badge } from "@/components/ui/badge";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import type { AIStewardTab, ExecutiveBriefingPayload } from "@/pages/ai-steward/types";
interface FeatureOverviewCardsProps {
onSelectTab: (tab: "signals" | "conflicts" | "blockers" | "briefing") => 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 (
<div className="mb-8 grid grid-cols-1 gap-4 md:grid-cols-2 xl:grid-cols-4">
{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 (
<Card
key={section.key}
className={`${meta.className} cursor-pointer transition-all hover:-translate-y-0.5 hover:shadow-lg`}
onClick={() => onSelectTab(targetTab)}
>
<CardHeader className="pb-3">
<div className="flex items-center justify-between">
<Icon className="h-8 w-8 text-slate-900 dark:text-slate-100" />
<Badge variant="secondary">{meta.badge}</Badge>
</div>
<CardTitle className="text-lg">{section.title}</CardTitle>
</CardHeader>
<CardContent className="space-y-2">
<p className="text-3xl font-semibold tracking-tight text-slate-950 dark:text-slate-50">{value}</p>
<p className="min-h-[40px] text-sm text-slate-600 dark:text-slate-300">{section.summary}</p>
</CardContent>
</Card>
);
})}
</div>
);
}
return (
<div className="mb-8 grid grid-cols-1 gap-4 sm:grid-cols-2 xl:grid-cols-4">
<Card className="border-blue-200 dark:border-blue-800 hover:shadow-lg transition-shadow cursor-pointer" onClick={() => onSelectTab("signals")}>

View File

@ -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<ExecutiveBriefingConfig>) => void;
onSaveConfig: () => void;
onSelectTab: (tab: AIStewardTab) => void;
}
const SECTION_STYLE: Record<string, string> = {
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 (
<TabsContent value="overview" className="mt-6">
<div className="space-y-6">
<div className="text-center mb-8">
<h2 className="text-2xl font-bold mb-2">AI 管家正在运行</h2>
<div className="mb-8 text-center">
<h2 className="mb-2 text-2xl font-bold">AI 管家正在运行</h2>
<p className="text-slate-600 dark:text-slate-400">4 大主动服务守护你的项目,无需手动触发</p>
</div>
<div className="grid grid-cols-1 gap-6 lg:grid-cols-2">
<div className="p-6 bg-blue-50 dark:bg-blue-900/20 rounded-lg border border-blue-200 dark:border-blue-800">
<div className="flex items-center gap-3 mb-4">
<div className="w-12 h-12 rounded-full bg-blue-500 flex items-center justify-center">
<Radar className="w-6 h-6 text-white" />
<div className="rounded-lg border border-blue-200 bg-blue-50 p-6 dark:border-blue-800 dark:bg-blue-900/20">
<div className="mb-4 flex items-center gap-3">
<div className="flex h-12 w-12 items-center justify-center rounded-full bg-blue-500">
<Radar className="h-6 w-6 text-white" />
</div>
<div>
<h3 className="font-semibold">1. 主动抓取信号</h3>
@ -32,26 +103,26 @@ export default function OverviewTab() {
</div>
<ul className="space-y-2 text-sm">
<li className="flex items-center gap-2">
<CheckCircle2 className="w-4 h-4 text-green-500" />
<MessageSquare className="w-4 h-4 text-blue-500" />
<CheckCircle2 className="h-4 w-4 text-green-500" />
<MessageSquare className="h-4 w-4 text-blue-500" />
飞书群聊监控 - 配置完成,正在运行
</li>
<li className="flex items-center gap-2">
<CheckCircle2 className="w-4 h-4 text-green-500" />
<Activity className="w-4 h-4 text-purple-500" />
<CheckCircle2 className="h-4 w-4 text-green-500" />
<Activity className="h-4 w-4 text-purple-500" />
技术资讯定时抓取 - 可启用
</li>
<li className="flex items-center gap-2 opacity-50">
<Clock className="w-4 h-4" />
<Clock className="h-4 w-4" />
浏览器标签监控 - 需安装扩展
</li>
</ul>
</div>
<div className="p-6 bg-orange-50 dark:bg-orange-900/20 rounded-lg border border-orange-200 dark:border-orange-800">
<div className="flex items-center gap-3 mb-4">
<div className="w-12 h-12 rounded-full bg-orange-500 flex items-center justify-center">
<AlertTriangle className="w-6 h-6 text-white" />
<div className="rounded-lg border border-orange-200 bg-orange-50 p-6 dark:border-orange-800 dark:bg-orange-900/20">
<div className="mb-4 flex items-center gap-3">
<div className="flex h-12 w-12 items-center justify-center rounded-full bg-orange-500">
<AlertTriangle className="h-6 w-6 text-white" />
</div>
<div>
<h3 className="font-semibold">2. 主动消解认知差</h3>
@ -60,24 +131,24 @@ export default function OverviewTab() {
</div>
<ul className="space-y-2 text-sm">
<li className="flex items-center gap-2">
<CheckCircle2 className="w-4 h-4 text-green-500" />
<CheckCircle2 className="h-4 w-4 text-green-500" />
自动检测定义不一致(如"留存" vs "DAU")
</li>
<li className="flex items-center gap-2">
<CheckCircle2 className="w-4 h-4 text-green-500" />
<CheckCircle2 className="h-4 w-4 text-green-500" />
任务与宪章一致性检查
</li>
<li className="flex items-center gap-2">
<CheckCircle2 className="w-4 h-4 text-green-500" />
<CheckCircle2 className="h-4 w-4 text-green-500" />
AI 生成对齐方案
</li>
</ul>
</div>
<div className="p-6 bg-red-50 dark:bg-red-900/20 rounded-lg border border-red-200 dark:border-red-800">
<div className="flex items-center gap-3 mb-4">
<div className="w-12 h-12 rounded-full bg-red-500 flex items-center justify-center">
<Shield className="w-6 h-6 text-white" />
<div className="rounded-lg border border-red-200 bg-red-50 p-6 dark:border-red-800 dark:bg-red-900/20">
<div className="mb-4 flex items-center gap-3">
<div className="flex h-12 w-12 items-center justify-center rounded-full bg-red-500">
<Shield className="h-6 w-6 text-white" />
</div>
<div>
<h3 className="font-semibold">3. 主动解决阻塞</h3>
@ -86,24 +157,24 @@ export default function OverviewTab() {
</div>
<ul className="space-y-2 text-sm">
<li className="flex items-center gap-2">
<CheckCircle2 className="w-4 h-4 text-green-500" />
<CheckCircle2 className="h-4 w-4 text-green-500" />
识别阻塞模式("等待X接口"、"达不到效果")
</li>
<li className="flex items-center gap-2">
<CheckCircle2 className="w-4 h-4 text-green-500" />
<CheckCircle2 className="h-4 w-4 text-green-500" />
AI 分析严重程度并生成解决方案
</li>
<li className="flex items-center gap-2">
<CheckCircle2 className="w-4 h-4 text-green-500" />
<CheckCircle2 className="h-4 w-4 text-green-500" />
自动创建追踪任务
</li>
</ul>
</div>
<div className="p-6 bg-green-50 dark:bg-green-900/20 rounded-lg border border-green-200 dark:border-green-800">
<div className="flex items-center gap-3 mb-4">
<div className="w-12 h-12 rounded-full bg-green-500 flex items-center justify-center">
<Bell className="w-6 h-6 text-white" />
<div className="rounded-lg border border-green-200 bg-green-50 p-6 dark:border-green-800 dark:bg-green-900/20">
<div className="mb-4 flex items-center gap-3">
<div className="flex h-12 w-12 items-center justify-center rounded-full bg-green-500">
<Bell className="h-6 w-6 text-white" />
</div>
<div>
<h3 className="font-semibold">4. 主动同步进度</h3>
@ -112,15 +183,15 @@ export default function OverviewTab() {
</div>
<ul className="space-y-2 text-sm">
<li className="flex items-center gap-2">
<CheckCircle2 className="w-4 h-4 text-green-500" />
<CheckCircle2 className="h-4 w-4 text-green-500" />
按角色智能过滤信息
</li>
<li className="flex items-center gap-2">
<CheckCircle2 className="w-4 h-4 text-green-500" />
<CheckCircle2 className="h-4 w-4 text-green-500" />
只显示相关任务变更和风险
</li>
<li className="flex items-center gap-2">
<CheckCircle2 className="w-4 h-4 text-green-500" />
<CheckCircle2 className="h-4 w-4 text-green-500" />
推送行动项和对齐提醒
</li>
</ul>
@ -130,3 +201,273 @@ export default function OverviewTab() {
</TabsContent>
);
}
export default function OverviewTab({
canManageExecutiveBriefing,
executiveBriefing,
executiveBriefingError,
executiveBriefingMode,
executiveBriefingLoading,
executiveBriefingConfigDraft,
executiveBriefingConfigLoading,
executiveBriefingConfigError,
executiveBriefingSaveLoading,
executiveBriefingSaveMessage,
onLoadSnapshot,
onRefreshLive,
onConfigChange,
onSaveConfig,
onSelectTab,
}: OverviewTabProps) {
if (!canManageExecutiveBriefing) {
return <LegacyOverview />;
}
const sections = executiveBriefing?.sections ?? [];
const hero = executiveBriefing?.hero;
return (
<TabsContent value="overview" className="mt-6">
<div className="space-y-6">
<Card className="overflow-hidden border-amber-200 bg-gradient-to-br from-stone-50 via-amber-50 to-orange-100 shadow-sm dark:border-amber-900 dark:from-slate-950 dark:via-stone-950 dark:to-amber-950/50">
<CardContent className="p-6">
<div className="flex flex-col gap-6 xl:flex-row xl:items-end xl:justify-between">
<div className="space-y-4">
<div className="flex flex-wrap items-center gap-3">
<Badge className="bg-slate-900 text-white dark:bg-slate-100 dark:text-slate-900">
{executiveBriefingMode === "live" ? "实时模式" : "晨报快照"}
</Badge>
{hero?.snapshot_state === "missing" && (
<Badge className="bg-amber-100 text-amber-700 dark:bg-amber-950 dark:text-amber-200">
今日快照未生成
</Badge>
)}
</div>
<div>
<h2 className="flex items-center gap-3 text-3xl font-semibold tracking-tight text-slate-950 dark:text-slate-50">
<Sparkles className="h-7 w-7 text-amber-600 dark:text-amber-300" />
老板晨报中心
</h2>
<p className="mt-2 max-w-3xl text-sm leading-6 text-slate-600 dark:text-slate-300">
用晨报快照做日常基线,用实时刷新做临时复盘。先看异常,再看输入、交付、团队和 AI 治理。
</p>
</div>
<div className="grid grid-cols-1 gap-3 sm:grid-cols-3">
<div className="rounded-2xl border border-slate-200/80 bg-white/80 p-4 dark:border-slate-800 dark:bg-slate-900/70">
<p className="text-xs uppercase tracking-[0.18em] text-slate-500">需拍板</p>
<p className="mt-2 text-3xl font-semibold text-slate-950 dark:text-slate-50">
{hero?.decision_count ?? 0}
</p>
</div>
<div className="rounded-2xl border border-slate-200/80 bg-white/80 p-4 dark:border-slate-800 dark:bg-slate-900/70">
<p className="text-xs uppercase tracking-[0.18em] text-slate-500">异常总数</p>
<p className="mt-2 text-3xl font-semibold text-slate-950 dark:text-slate-50">
{hero?.anomaly_count ?? 0}
</p>
</div>
<div className="rounded-2xl border border-slate-200/80 bg-white/80 p-4 dark:border-slate-800 dark:bg-slate-900/70">
<p className="text-xs uppercase tracking-[0.18em] text-slate-500">查看日期</p>
<p className="mt-2 text-3xl font-semibold text-slate-950 dark:text-slate-50">
{executiveBriefing?.brief_date || "-"}
</p>
</div>
</div>
</div>
<div className="min-w-0 space-y-3 xl:w-[360px]">
<div className="rounded-2xl border border-slate-200/80 bg-white/80 p-4 text-sm dark:border-slate-800 dark:bg-slate-900/70">
<div className="flex items-center justify-between gap-3">
<span className="text-slate-500">晨报快照</span>
<span className="font-medium text-slate-900 dark:text-slate-100">
{formatTimestamp(hero?.baseline_generated_at)}
</span>
</div>
<div className="mt-2 flex items-center justify-between gap-3">
<span className="text-slate-500">实时刷新</span>
<span className="font-medium text-slate-900 dark:text-slate-100">
{formatTimestamp(hero?.live_generated_at)}
</span>
</div>
</div>
<div className="flex flex-wrap gap-2">
{(hero?.key_alerts ?? []).map((item) => (
<Badge key={`${item.label}-${item.value}`} className={toneBadgeClass(item.tone)}>
{item.label} {formatMetricValue(item.value)}
</Badge>
))}
</div>
<div className="flex flex-wrap gap-3">
<Button
variant="outline"
onClick={onLoadSnapshot}
disabled={executiveBriefingLoading}
className="border-slate-300 bg-white/80 dark:border-slate-700 dark:bg-slate-900/60"
>
查看晨报快照
</Button>
<Button
onClick={onRefreshLive}
disabled={executiveBriefingLoading}
className="bg-slate-900 text-white hover:bg-slate-800 dark:bg-slate-100 dark:text-slate-900 dark:hover:bg-slate-200"
>
<RefreshCcw className="h-4 w-4" />
实时刷新
</Button>
</div>
</div>
</div>
{executiveBriefingLoading && (
<p className="mt-4 text-sm text-slate-500 dark:text-slate-400">正在更新晨报数据...</p>
)}
{executiveBriefingError && (
<p className="mt-4 rounded-xl border border-rose-200 bg-rose-50 px-4 py-3 text-sm text-rose-700 dark:border-rose-900 dark:bg-rose-950/40 dark:text-rose-200">
{executiveBriefingError}
</p>
)}
</CardContent>
</Card>
<div className="grid grid-cols-1 gap-5 xl:grid-cols-2">
{sections.map((section) => (
<Card key={section.key} className={`${SECTION_STYLE[section.key] || SECTION_STYLE.external_metrics} shadow-sm`}>
<CardContent className="p-6">
<div className="flex items-start justify-between gap-4">
<div>
<h3 className="text-xl font-semibold text-slate-950 dark:text-slate-50">{section.title}</h3>
<p className="mt-2 text-sm leading-6 text-slate-600 dark:text-slate-300">{section.summary}</p>
</div>
{section.drilldown_tab && section.drilldown_tab !== "overview" && (
<Button variant="outline" size="sm" onClick={() => onSelectTab(section.drilldown_tab as AIStewardTab)}>
查看明细
</Button>
)}
</div>
<div className="mt-4 grid grid-cols-1 gap-3 sm:grid-cols-3">
{section.metrics.map((metric) => (
<div key={`${section.key}-${metric.label}`} className="rounded-xl border border-white/60 bg-white/70 p-3 dark:border-slate-800 dark:bg-slate-950/50">
<p className="text-xs uppercase tracking-[0.14em] text-slate-500">{metric.label}</p>
<p className="mt-2 text-2xl font-semibold text-slate-950 dark:text-slate-50">
{formatMetricValue(metric.value)}
</p>
</div>
))}
</div>
<div className="mt-4 space-y-3">
{section.items.length ? (
section.items.slice(0, 3).map((item, index) => (
<div key={`${section.key}-${item.title}-${index}`} className="rounded-xl border border-white/70 bg-white/80 px-4 py-3 dark:border-slate-800 dark:bg-slate-950/50">
<div className="flex flex-wrap items-center gap-2">
<span className="font-medium text-slate-950 dark:text-slate-50">{item.title}</span>
<Badge className={toneBadgeClass(item.tone)}>{item.tone}</Badge>
</div>
<p className="mt-1 text-sm text-slate-600 dark:text-slate-300">{item.detail}</p>
</div>
))
) : (
<div className="rounded-xl border border-dashed border-slate-300 bg-white/70 px-4 py-6 text-center text-sm text-slate-500 dark:border-slate-700 dark:bg-slate-950/40 dark:text-slate-400">
当前区块暂无新增异常。
</div>
)}
</div>
</CardContent>
</Card>
))}
</div>
<Card className="border-slate-200 shadow-sm dark:border-slate-800">
<CardContent className="p-6">
<div className="flex flex-col gap-6 lg:flex-row lg:items-start lg:justify-between">
<div className="max-w-xl">
<h3 className="text-xl font-semibold text-slate-950 dark:text-slate-50">晨报调度配置</h3>
<p className="mt-2 text-sm leading-6 text-slate-600 dark:text-slate-300">
这份配置是租户级的。修改后,Web 快照、定时调度和管理群自动推送都会沿用同一套口径。
</p>
</div>
<div className="flex items-center gap-3">
<span className="text-sm text-slate-500">自动发送</span>
<Switch
checked={executiveBriefingConfigDraft.enabled}
onCheckedChange={(checked) => onConfigChange({ enabled: checked })}
disabled={executiveBriefingConfigLoading || executiveBriefingSaveLoading}
/>
</div>
</div>
<div className="mt-6 grid grid-cols-1 gap-4 md:grid-cols-2 xl:grid-cols-4">
<div className="space-y-2">
<p className="text-sm font-medium text-slate-700 dark:text-slate-200">发送时间</p>
<Input
type="time"
value={executiveBriefingConfigDraft.send_time}
onChange={(event) => onConfigChange({ send_time: event.target.value })}
disabled={executiveBriefingConfigLoading || executiveBriefingSaveLoading}
/>
</div>
<div className="space-y-2">
<p className="text-sm font-medium text-slate-700 dark:text-slate-200">发送周期</p>
<div className="flex gap-2">
<Button
variant={executiveBriefingConfigDraft.send_cycle === "workdays" ? "default" : "outline"}
size="sm"
onClick={() => onConfigChange({ send_cycle: "workdays" })}
disabled={executiveBriefingConfigLoading || executiveBriefingSaveLoading}
>
工作日
</Button>
<Button
variant={executiveBriefingConfigDraft.send_cycle === "daily" ? "default" : "outline"}
size="sm"
onClick={() => onConfigChange({ send_cycle: "daily" })}
disabled={executiveBriefingConfigLoading || executiveBriefingSaveLoading}
>
每天
</Button>
</div>
</div>
<div className="space-y-2">
<p className="text-sm font-medium text-slate-700 dark:text-slate-200">管理群 Chat ID</p>
<Input
value={executiveBriefingConfigDraft.delivery_target_ref || ""}
onChange={(event) => onConfigChange({ delivery_target_ref: event.target.value || null })}
placeholder="oc_management_group"
disabled={executiveBriefingConfigLoading || executiveBriefingSaveLoading}
/>
</div>
<div className="space-y-2">
<p className="text-sm font-medium text-slate-700 dark:text-slate-200">时区</p>
<Input
value={executiveBriefingConfigDraft.timezone}
onChange={(event) => onConfigChange({ timezone: event.target.value })}
placeholder="Asia/Shanghai"
disabled={executiveBriefingConfigLoading || executiveBriefingSaveLoading}
/>
</div>
</div>
<div className="mt-6 flex flex-col gap-3 md:flex-row md:items-center md:justify-between">
<div className="space-y-1">
{executiveBriefingConfigError && (
<p className="text-sm text-rose-600 dark:text-rose-300">{executiveBriefingConfigError}</p>
)}
{executiveBriefingSaveMessage && (
<p
className={`text-sm ${
executiveBriefingSaveMessage.type === "success"
? "text-emerald-600 dark:text-emerald-300"
: "text-rose-600 dark:text-rose-300"
}`}
>
{executiveBriefingSaveMessage.text}
</p>
)}
</div>
<Button onClick={onSaveConfig} disabled={executiveBriefingConfigLoading || executiveBriefingSaveLoading}>
保存晨报配置
</Button>
</div>
</CardContent>
</Card>
</div>
</TabsContent>
);
}

View File

@ -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;