排查飞书机器人重复回复问题」 note: wait must be 18

This commit is contained in:
Yuuuu 2026-03-10 19:09:59 +08:00
parent 93185369b9
commit cf638e0a62
10 changed files with 673 additions and 67 deletions

View File

@ -16,7 +16,12 @@
3. **数据库规范**:使用 SQLAlchemy 2.0,所有模型继承 Base 基类,必须包含 `tenant_id` 字段,所有查询必须自动过滤 `tenant_id`(使用依赖注入实现)。
4. **AI 能力规范**:所有 AI 能力必须封装在独立的 service 层,使用 LangChain 实现,Prompt 放在 `./backend/app/config/ai_prompts.py` 独立管理,禁止硬编码。
4. **AI 能力规范**:
- 所有 AI 能力必须封装在独立的 service 层
- AI 能力通过 OpenClaw 路由层调用
- 路由规则参考 `backend/.openclaw/pmflow-routing-rules.json`
- Prompt 放在 `./backend/app/config/ai_prompts.py` 独立管理
- 禁止直接硬编码模型名称
5. **AI 调用规范**:所有 AI 调用必须:
- 记录日志到 `ai_call_logs` 表
@ -98,12 +103,37 @@ frontend/ # React 前端
- 飞书回调字段可能缺失文件名/大小,仅保证 `attachment_id/key/type` 可用
- `agents.defaults.timeoutSeconds` 在 OpenClaw 网关行为未验证,当前以 `LLM_REQUEST_TIMEOUT` 环境变量为准
- World Model 每次全量构建,高负载场景下存在性能风险,暂未缓存,改动前需评估影响
- World Model 静态层已接入 Redis 缓存(key: `wm:static:{tenant_id}:{project_id}`,TTL 600s)
- 写操作后主动 invalidate,动态层不缓存
## 12. 前端组件约束
- `ui/` 目录下组件禁止修改,仅使用
- `sstc/` 目录下组件已模块化拆分,修改前必须执行 `FIND_USAGE`
- `frontend/src/components/sstc/` 目录已模块化拆分,新增文件与职责如下:
- `SSTCCharterStagePanel.tsx`:宪章阶段主面板(内容编辑、版本/状态展示)
- `SSTCExecutionBoardPanel.tsx`:执行看板阶段主面板(执行视图聚合)
- `SSTCReleaseCreateDialog.tsx`:发布创建弹窗
- `SSTCReleaseStagePanel.tsx`:发布阶段主面板(发布列表与校验项)
- `SSTCReviewCreateDialog.tsx`:复盘创建弹窗
- `SSTCReviewStagePanel.tsx`:复盘阶段主面板(复盘内容与改进项)
- `SSTCSignalStagePanel.tsx`:信号阶段主面板(信号录入/状态流转)
- `SSTCTaskCreateDialog.tsx`:任务创建弹窗
- `SSTCTaskStagePanel.tsx`:任务阶段主面板(任务列表、筛选、状态流转)
- `sstcUtils.ts`:SSTC 跨阶段工具函数与转换逻辑
- `useSSTCWorkspaceOrchestration.ts`:SSTC 工作区编排 Hook(状态聚合、事件编排、数据装配)
- 修改前必须执行 `FIND_USAGE` 的组件/模块清单(强制):
- `SSTCWorkspace.tsx`
- `useSSTCWorkspaceOrchestration.ts`
- `sstcUtils.ts`
- `SSTCCharterStagePanel.tsx`
- `SSTCSignalStagePanel.tsx`
- `SSTCTaskStagePanel.tsx`
- `SSTCExecutionBoardPanel.tsx`
- `SSTCReleaseStagePanel.tsx`
- `SSTCReviewStagePanel.tsx`
- `SSTCReleaseCreateDialog.tsx`
- `SSTCReviewCreateDialog.tsx`
- `SSTCTaskCreateDialog.tsx`
- 新增业务组件放入对应功能目录,禁止在 `pages/` 内写内联组件
- 样式只用 Tailwind utility class,禁止内联 `style`
@ -112,3 +142,36 @@ frontend/ # React 前端
- 后端改动:`python -m py_compile` 通过 + 相关单测通过
- 前端改动:`npm run build` 通过 + `tsc --noEmit` 零错误
- 新增接口:必须在 `backend/tests/` 补充对应单测
## 14. 文档同步规则(强制)
每次任务完成后,若以下内容发生变化,必须同步更新 `CLAUDE.md` 对应章节:
- 新增/删除/重命名文件或目录
→ 更新第3-5节目录结构
- 新增/修改服务边界或禁止操作
→ 更新第8节服务边界约束
- 新增已知 `BOUNDARY` 或 `ASSUMPTION`
→ 更新第11节已知 `BOUNDARY`
- 新增/修改 API 接口
→ 更新第9节参考模式
文档更新必须在 `[Task:Done]` 之前完成。
未更新文档的任务视为未完成。
## 15. 反迎合约束(强制)
若检测到 Host 已预设方案倾向:
- 必须先输出 `[CRITIQUE: 当前方案的根本性风险]`
- 再输出执行路径
若 Host 方案是次优解:
- 必须声明 `[ALT: 更优方案]` + 理由
- Host 确认坚持后方可执行
## 16. Context 管理约定
- 单任务涉及文件超过5个 → 拆分为独立子任务
- 单任务预估改动超过200行 → 拆分执行
- 发生 context 不足时 → 停止并输出已完成部分
声明 `[CONTEXT: LIMIT]`,不要截断输出

View File

@ -62,6 +62,41 @@ frontend/
└── vite.config.ts
```
### 5.1 `frontend/src/components/sstc/` 目录说明(模块化拆分后)
- `SSTCWorkspace.tsx`:SSTC 工作区主容器(组装各阶段组件)
- `useSSTCWorkspaceOrchestration.ts`:工作区编排 Hook(状态聚合、请求编排、事件路由)
- `sstcUtils.ts`:SSTC 工具函数(数据转换、状态判断、通用 helper)
- `SSTCSignalStagePanel.tsx`:信号阶段面板
- `SSTCCharterStagePanel.tsx`:宪章阶段面板
- `SSTCTaskStagePanel.tsx`:任务阶段面板
- `SSTCExecutionBoardPanel.tsx`:执行看板阶段面板
- `SSTCReleaseStagePanel.tsx`:发布阶段面板
- `SSTCReviewStagePanel.tsx`:复盘阶段面板
- `SSTCTaskCreateDialog.tsx`:任务创建弹窗
- `SSTCReleaseCreateDialog.tsx`:发布创建弹窗
- `SSTCReviewCreateDialog.tsx`:复盘创建弹窗
- `AIVisibilityPanel.tsx`:AI 可视化信息面板
- `SSTCGlobalHeader.tsx`:SSTC 全局头部
- `SSTCStageNav.tsx`:阶段导航
- `SSTCChainView.tsx`:链路视图
- `SSTCRiskFloatPanel.tsx`:风险浮层
- `SignalContentHoverCard.tsx`:信号内容悬浮卡片
- `charterModules.ts`:宪章模块配置与映射
### 5.2 修改前必须执行 `FIND_USAGE` 的 SSTC 组件清单
- `SSTCWorkspace.tsx`
- `useSSTCWorkspaceOrchestration.ts`
- `sstcUtils.ts`
- `SSTCCharterStagePanel.tsx`
- `SSTCSignalStagePanel.tsx`
- `SSTCTaskStagePanel.tsx`
- `SSTCExecutionBoardPanel.tsx`
- `SSTCReleaseStagePanel.tsx`
- `SSTCReviewStagePanel.tsx`
- `SSTCReleaseCreateDialog.tsx`
- `SSTCReviewCreateDialog.tsx`
- `SSTCTaskCreateDialog.tsx`
## 6. 运行命令(高频)
### 根目录
```bash
@ -87,3 +122,27 @@ uvicorn app.main:app --reload --host 0.0.0.0 --port 8000
- 规则变更:只改 `AGENTS.md`
- 结构变更:同步改 `CLAUDE.md`
- API/契约变更:后端 `schemas` + 前端 `types` 同步更新
## 8. 服务边界约束(禁止越界)
- 数据库写操作必须通过 Alembic migration,禁止直接 `ALTER TABLE`
- 新增接口必须遵循响应包络:`{code, message, data}`,不得裸返回
- 禁止在 API 层写业务逻辑,业务逻辑归属 `services/`
- 禁止在前端组件内直接调用 `fetch`,必须通过 `frontend/src/api/` 封装层
- 前端状态管理:服务端数据用 React Query,本地 UI 状态用 `useState`
## 9. 新增代码的参考模式
- 新增后端接口:参考 `backend/app/api/sstc.py` 的结构
- 新增前端 API 调用:参考 `frontend/src/api/sstc.ts`
- 新增数据库表:参考 `alembic/versions/` 最新 migration
- 新增飞书事件处理:参考 `backend/app/services/feishu_integration.py`
## 10. 幂等与事务约束
- 所有入站事件必须经过 `ingest_events` 幂等去重
- 飞书回复必须经过 `feishu_reply_outbox`,禁止同步直接发送
- 跨表写操作必须在同一个数据库事务内完成
## 11. 已知 BOUNDARY(不要幻构行为)
- 飞书回调字段可能缺失文件名/大小,仅保证 `attachment_id/key/type` 可用
- `agents.defaults.timeoutSeconds` 在 OpenClaw 网关行为未验证,当前以 `LLM_REQUEST_TIMEOUT` 环境变量为准
- World Model 静态层已接入 Redis 缓存(key: `wm:static:{tenant_id}:{project_id}`,TTL 600s)
- 写操作后主动 invalidate,动态层不缓存

View File

@ -13,6 +13,7 @@ from sqlalchemy.ext.asyncio import AsyncSession
from ..database import get_db
from ..dependencies import get_current_user, get_tenant_id, require_roles
from ..models import Project, ProjectSignalLink, Signal, Task, User
from ..services.ai_pmo_kernel_service import AIPMOKernelService
from ..services.context_cache import context_cache_service
from ..utils.project_members import ensure_project_default_members
from ..utils.sequence import ensure_project_id_sequence
@ -227,6 +228,7 @@ async def create_project(
await db.commit()
await db.refresh(project)
await context_cache_service.invalidate_system_context(tenant_id=tenant_id)
await AIPMOKernelService.invalidate_world_model_cache(tenant_id=tenant_id, project_id=int(project.id))
return _serialize_project_row(project)
@ -256,6 +258,7 @@ async def update_project(
await db.commit()
await db.refresh(project)
await context_cache_service.invalidate_system_context(tenant_id=tenant_id)
await AIPMOKernelService.invalidate_world_model_cache(tenant_id=tenant_id, project_id=int(project.id))
return _serialize_project_row(project)

View File

@ -13,6 +13,7 @@ from sqlalchemy.ext.asyncio import AsyncSession
from ..database import get_db
from ..dependencies import get_current_user, get_tenant_id, require_roles
from ..models import SkillTag, User, UserSkillBinding
from ..services.ai_pmo_kernel_service import AIPMOKernelService
from ..services import skill_service
from ..services.auth import has_any_role
@ -235,6 +236,7 @@ async def bind_user_skill(
)
db.add(row)
await db.commit()
await AIPMOKernelService.invalidate_world_model_cache_for_user(db=db, tenant_id=tenant_id, user_id=user_id)
return {"message": "Skill bound"}
@ -285,6 +287,7 @@ async def update_user_skill(
row.efficiency_score = body.efficiency_score
row.updated_at = datetime.utcnow()
await db.commit()
await AIPMOKernelService.invalidate_world_model_cache_for_user(db=db, tenant_id=tenant_id, user_id=user_id)
return {"message": "Skill updated"}
@ -317,4 +320,5 @@ async def unbind_user_skill(
await db.delete(row)
await db.commit()
await AIPMOKernelService.invalidate_world_model_cache_for_user(db=db, tenant_id=tenant_id, user_id=user_id)
return {"message": "Skill unbound"}

View File

@ -58,6 +58,7 @@ from ..schemas.sstc import (
)
from ..services.llm import llm_service
from ..services.ai_service import AIService
from ..services.ai_pmo_kernel_service import AIPMOKernelService
from ..services.operation_log_service import log_operation
from ..services.auth import has_any_role
from ..services.skill_service import recommend_personnel
@ -2382,6 +2383,7 @@ async def update_charter(
)
await db.commit()
await db.refresh(new_node)
await AIPMOKernelService.invalidate_world_model_cache(tenant_id=tenant_id, project_id=pid)
_sync_pmflow_rag_async(
tenant_id=tenant_id,
record_type="charter",
@ -2424,6 +2426,7 @@ async def update_charter(
)
await db.commit()
await db.refresh(charter)
await AIPMOKernelService.invalidate_world_model_cache(tenant_id=tenant_id, project_id=pid)
_sync_pmflow_rag_async(
tenant_id=tenant_id,
record_type="charter",

View File

@ -39,6 +39,7 @@ from ..schemas import (
UserUpdate,
)
from ..services.auth import AuthService
from ..services.ai_pmo_kernel_service import AIPMOKernelService
from ..services.operation_log_service import log_operation
from ..services import skill_service
@ -211,6 +212,15 @@ async def _replace_user_projects(
user_id: int,
project_ids: list[int],
) -> None:
existing_rows = await db.execute(
select(UserProject.project_id).where(
and_(
UserProject.tenant_id == tenant_id,
UserProject.user_id == user_id,
)
)
)
existing_project_ids = {int(pid) for pid in existing_rows.scalars().all() if pid is not None}
await db.execute(
delete(UserProject).where(
and_(
@ -239,6 +249,10 @@ async def _replace_user_projects(
cache_payload["invalidate_project_list"] = True
cache_payload["user_ids"].add(int(user_id))
affected_project_ids = sorted(existing_project_ids.union({int(pid) for pid in unique_project_ids}))
for project_id in affected_project_ids:
await AIPMOKernelService.invalidate_world_model_cache(tenant_id=tenant_id, project_id=project_id)
async def _create_user_with_defaults(
db: AsyncSession,
@ -750,6 +764,11 @@ async def delete_user(
)
before_data = user.to_dict()
await AIPMOKernelService.invalidate_world_model_cache_for_user(
db=db,
tenant_id=tenant_id,
user_id=user_id,
)
await db.execute(
delete(UserSkillBinding).where(
and_(
@ -842,6 +861,11 @@ async def bulk_delete_users(
)
continue
await AIPMOKernelService.invalidate_world_model_cache_for_user(
db=db,
tenant_id=tenant_id,
user_id=user_id,
)
await db.execute(
delete(UserSkillBinding).where(
and_(

View File

@ -0,0 +1,136 @@
"""AI PMO World Model 类型契约。"""
from __future__ import annotations
from typing import Any, NotRequired, Optional, TypedDict
class ProjectSnapshot(TypedDict):
id: int
name: str
title: str
description: str
status: str
owner_id: Optional[int]
class ProjectDomainSnapshot(TypedDict):
id: int
name: str
slug: str
description: str
owner_id: Optional[int]
class PortfolioSummary(TypedDict):
active_project_count: int
focus_project_id: Optional[int]
class BudgetSummary(TypedDict):
budget_id: Optional[int]
usage_ratio: float
hard_limit_enabled: bool
budget_type: NotRequired[str]
period: NotRequired[str]
budget_amount_usd: NotRequired[float]
current_usage_usd: NotRequired[float]
class CapacityItem(TypedDict):
user_id: int
current_assigned_hours: float
max_available_hours_per_week: float
workload_ratio: float
class SkillGraphItem(TypedDict):
user_id: int
skill_tag_id: int
proficiency_level: str
quality_score: float
efficiency_score: float
class CharterItem(TypedDict):
id: int
title: str
status: str
content: dict[str, Any]
class BlockedTask(TypedDict):
id: int
title: str
class TaskSummary(TypedDict):
total: int
by_status: dict[str, int]
overdue_count: int
uncovered_skill_tag_ids: list[int]
blocked_tasks: list[BlockedTask]
required_skill_tag_ids: NotRequired[list[int]]
class RiskSummary(TypedDict):
open_risk_count: int
class DependencySummary(TypedDict):
pressure: str
blocked_tasks: list[BlockedTask]
overdue_count: int
charter_ready: bool
class RecentSignalItem(TypedDict):
id: int
title: str
status: str
signal_type: str
class DecisionContextSnapshot(TypedDict):
captured_at: str
project_id: Optional[int]
budget: BudgetSummary
capacity: list[CapacityItem]
skill_graph_size: int
dependency_pressure: Optional[str]
class WorldModel(TypedDict):
sender: str
resolved_project_id: Optional[int]
project: Optional[ProjectSnapshot]
project_domain: Optional[ProjectDomainSnapshot]
portfolio: PortfolioSummary
budget: BudgetSummary
capacity: list[CapacityItem]
member_count: int
skill_graph: list[SkillGraphItem]
charter: list[CharterItem]
task_summary: TaskSummary
risk_summary: RiskSummary
dependencies: DependencySummary
recent_signals: list[RecentSignalItem]
decision_context_snapshot: DecisionContextSnapshot
__all__ = [
"ProjectSnapshot",
"ProjectDomainSnapshot",
"PortfolioSummary",
"BudgetSummary",
"CapacityItem",
"SkillGraphItem",
"CharterItem",
"BlockedTask",
"TaskSummary",
"RiskSummary",
"DependencySummary",
"RecentSignalItem",
"DecisionContextSnapshot",
"WorldModel",
]

View File

@ -2,6 +2,7 @@
from __future__ import annotations
import asyncio
import hashlib
import json
import re
@ -15,6 +16,7 @@ from sqlalchemy.exc import IntegrityError
from sqlalchemy.ext.asyncio import AsyncSession
from ..core.logging import get_logger
from ..database import AsyncSessionLocal
from ..models import (
AIProcessingLog,
Budget,
@ -36,10 +38,26 @@ from ..models import (
UserWorkload,
WorkItem,
)
from ..schemas.world_model import (
BlockedTask,
BudgetSummary,
CapacityItem,
CharterItem,
DependencySummary,
PortfolioSummary,
ProjectDomainSnapshot,
ProjectSnapshot,
RecentSignalItem,
RiskSummary,
SkillGraphItem,
TaskSummary,
WorldModel,
)
from .adaptive_rag_service import AdaptiveRAGService
from .ai_pmo_cadence_service import AIPMOCadenceService
from .ai_steward import AIStewardService
from .ai_pmo_review_service import AIPMOReviewService
from .context_cache import ContextCacheService
from .notification_service import NotificationService
from .openclaw_commander_service import OpenClawCommanderService
from .ai_pmo_domain_workflow import SignalSourceDomainWorkflow
@ -103,6 +121,9 @@ POLICY_GATE_RULES: tuple[tuple[str, tuple[str, ...]], ...] = (
("critical_path_change", ("关键路径", "里程碑改动", "影响多个项目", "跨项目依赖调整")),
)
WORLD_MODEL_STATIC_CACHE_TTL_SECONDS = 10 * 60
WORLD_MODEL_STATIC_CACHE_KEY_PREFIX = "wm:static"
def _utc_now_naive() -> datetime:
return datetime.now(timezone.utc).replace(tzinfo=None)
@ -817,6 +838,7 @@ class AIPMOKernelService:
def __init__(self, db: AsyncSession, tenant_id: int = 1) -> None:
self.db = db
self.tenant_id = int(tenant_id)
self.context_cache = ContextCacheService()
self.intent_resolver = IntentResolver()
self.decision_engine = DecisionEngine()
self.reply_renderer = ReplyRenderer()
@ -923,11 +945,20 @@ class AIPMOKernelService:
)
try:
world_model = await self._build_world_model(
world_model_query_tables: set[str] = set()
project = await self._resolve_project(
message_text=clean_text,
sender=sender,
project_hint_id=project_hint_id,
query_tables=world_model_query_tables,
)
world_model = await self.build_world_model(
tenant_id=self.tenant_id,
project_id=_safe_int(getattr(project, "id", None)),
sender=sender,
project=project,
pre_queried_tables=world_model_query_tables,
)
self._schedule_portfolio_snapshot_persist(world_model=world_model)
work_item.state = STATE_MODELED
work_item.context_snapshot = world_model
intent = self.intent_resolver.resolve(
@ -1239,64 +1270,262 @@ class AIPMOKernelService:
await self.db.flush()
return row
async def build_world_model(
self,
*,
tenant_id: int,
project_id: Optional[int],
sender: str = "",
project: Optional[Project] = None,
pre_queried_tables: Optional[set[str]] = None,
) -> WorldModel:
if int(tenant_id) != int(self.tenant_id):
raise ValueError(f"tenant mismatch for world model build: input={tenant_id} service={self.tenant_id}")
started = time.perf_counter()
queried_tables: set[str] = set(pre_queried_tables or set())
resolved_project_id: Optional[int] = project_id
status = "failed"
try:
if project is None and project_id is not None:
self._record_world_model_query_table(queried_tables, "projects")
project = (
await self.db.execute(
select(Project).where(
and_(
Project.tenant_id == self.tenant_id,
Project.id == int(project_id),
)
).limit(1)
)
).scalar_one_or_none()
resolved_project_id = _safe_int(getattr(project, "id", None)) if project is not None else project_id
project_snapshot = self._serialize_project(project)
domain: Optional[ProjectDomainSnapshot] = None
skill_graph: list[SkillGraphItem] = []
charter: list[CharterItem] = []
cached_static = await self._get_static_world_model_cache(tenant_id=self.tenant_id, project_id=resolved_project_id)
if cached_static is not None:
cached_project = cached_static.get("project")
if isinstance(cached_project, dict):
project_snapshot = cached_project
cached_domain = cached_static.get("project_domain")
if isinstance(cached_domain, dict):
domain = cached_domain
cached_skill_graph = cached_static.get("skill_graph")
if isinstance(cached_skill_graph, list):
skill_graph = [item for item in cached_skill_graph if isinstance(item, dict)]
cached_charter = cached_static.get("charter")
if isinstance(cached_charter, list):
charter = [item for item in cached_charter if isinstance(item, dict)]
else:
skill_graph = await self._build_skill_graph(project_id=resolved_project_id, query_tables=queried_tables)
charter = await self._build_charter_snapshot(project_id=resolved_project_id, query_tables=queried_tables)
domain = await self._build_project_domain_snapshot(project_id=resolved_project_id, query_tables=queried_tables)
await self._set_static_world_model_cache(
tenant_id=self.tenant_id,
project_id=resolved_project_id,
data={
"project": project_snapshot,
"project_domain": domain,
"skill_graph": skill_graph,
"charter": charter,
},
)
portfolio_summary = await self._build_portfolio_summary(project_id=resolved_project_id, query_tables=queried_tables)
budget = await self._build_budget_summary(project_id=resolved_project_id, query_tables=queried_tables)
capacity = await self._build_capacity_summary(project_id=resolved_project_id, query_tables=queried_tables)
task_summary = await self._build_task_summary(
project_id=resolved_project_id,
skill_graph=skill_graph,
query_tables=queried_tables,
)
risk_summary = await self._build_risk_summary(project_id=resolved_project_id, query_tables=queried_tables)
dependencies = self._build_dependency_summary(charter=charter, task_summary=task_summary)
recent_signals = await self._build_recent_signal_summary(project_id=resolved_project_id, query_tables=queried_tables)
world_model: WorldModel = {
"sender": sender,
"resolved_project_id": resolved_project_id,
"project": project_snapshot,
"project_domain": domain,
"portfolio": portfolio_summary,
"budget": budget,
"capacity": capacity,
"member_count": len(capacity),
"skill_graph": skill_graph,
"charter": charter,
"task_summary": task_summary,
"risk_summary": risk_summary,
"dependencies": dependencies,
"recent_signals": recent_signals,
"decision_context_snapshot": {
"captured_at": _utc_now_naive().isoformat(),
"project_id": resolved_project_id,
"budget": budget,
"capacity": capacity,
"skill_graph_size": len(skill_graph),
"dependency_pressure": dependencies.get("pressure"),
},
}
status = "success"
return world_model
finally:
duration_ms = int((time.perf_counter() - started) * 1000)
logger.info(
"ai_pmo_world_model_build",
tenant_id=self.tenant_id,
project_id=resolved_project_id,
status=status,
duration_ms=duration_ms,
queried_tables=sorted(queried_tables),
query_table_count=len(queried_tables),
)
def _schedule_portfolio_snapshot_persist(self, *, world_model: WorldModel) -> None:
portfolio_summary = world_model.get("portfolio")
if not isinstance(portfolio_summary, dict):
return
project_id = _safe_int(world_model.get("resolved_project_id"))
asyncio.create_task(
self._persist_portfolio_snapshot(world_model=world_model, portfolio_summary=portfolio_summary),
name=f"ai-pmo-portfolio-snapshot:{self.tenant_id}:{project_id or 'none'}",
)
async def _persist_portfolio_snapshot(
self,
*,
world_model: WorldModel,
portfolio_summary: PortfolioSummary,
) -> None:
try:
async with AsyncSessionLocal() as bg_db:
bg_db.add(
PortfolioSnapshot(
tenant_id=self.tenant_id,
scope_type="tenant",
scope_ref=f"tenant:{self.tenant_id}",
summary=portfolio_summary,
payload=world_model,
)
)
await bg_db.commit()
except Exception as exc:
logger.warning(
"ai_pmo_portfolio_snapshot_persist_failed",
tenant_id=self.tenant_id,
project_id=_safe_int(world_model.get("resolved_project_id")),
error=str(exc),
)
@staticmethod
def _world_model_static_cache_key(*, tenant_id: int, project_id: int) -> str:
return f"{WORLD_MODEL_STATIC_CACHE_KEY_PREFIX}:{int(tenant_id)}:{int(project_id)}"
async def _get_static_world_model_cache(
self,
*,
tenant_id: int,
project_id: Optional[int],
) -> Optional[dict[str, Any]]:
if project_id is None:
return None
key = self._world_model_static_cache_key(tenant_id=tenant_id, project_id=int(project_id))
cached = await self.context_cache.get_json_key(key)
if not isinstance(cached, dict):
logger.debug(f"[WM_CACHE] MISS {tenant_id}:{project_id}")
return None
required_keys = {"project", "project_domain", "skill_graph", "charter"}
if not required_keys.issubset(set(cached.keys())):
logger.debug(f"[WM_CACHE] MISS {tenant_id}:{project_id}")
return None
logger.debug(f"[WM_CACHE] HIT {tenant_id}:{project_id}")
return cached
async def _set_static_world_model_cache(
self,
*,
tenant_id: int,
project_id: Optional[int],
data: dict[str, Any],
) -> None:
if project_id is None:
return
key = self._world_model_static_cache_key(tenant_id=tenant_id, project_id=int(project_id))
await self.context_cache.set_json_key(key, data, WORLD_MODEL_STATIC_CACHE_TTL_SECONDS)
async def _invalidate_world_model_cache(self, *, tenant_id: int, project_id: Optional[int]) -> None:
await self.invalidate_world_model_cache(tenant_id=tenant_id, project_id=project_id)
@classmethod
async def invalidate_world_model_cache(cls, *, tenant_id: int, project_id: Optional[int]) -> None:
if project_id is None:
return
cache = ContextCacheService()
key = cls._world_model_static_cache_key(tenant_id=int(tenant_id), project_id=int(project_id))
await cache.delete_keys(key)
@classmethod
async def invalidate_world_model_cache_for_user(
cls,
*,
db: AsyncSession,
tenant_id: int,
user_id: int,
) -> list[int]:
rows = (
await db.execute(
select(UserProject.project_id).where(
and_(
UserProject.tenant_id == int(tenant_id),
UserProject.user_id == int(user_id),
)
)
)
).scalars().all()
project_ids = sorted({int(pid) for pid in rows if pid is not None})
for pid in project_ids:
await cls.invalidate_world_model_cache(tenant_id=tenant_id, project_id=pid)
return project_ids
async def _build_world_model(
self,
*,
message_text: str,
sender: str,
project_hint_id: Optional[int],
) -> dict[str, Any]:
project = await self._resolve_project(message_text=message_text, project_hint_id=project_hint_id)
project_id = _safe_int(getattr(project, "id", None))
portfolio_summary = await self._build_portfolio_summary(project_id=project_id)
budget = await self._build_budget_summary(project_id=project_id)
capacity = await self._build_capacity_summary(project_id=project_id)
skill_graph = await self._build_skill_graph(project_id=project_id)
charter = await self._build_charter_snapshot(project_id=project_id)
task_summary = await self._build_task_summary(project_id=project_id)
risk_summary = await self._build_risk_summary(project_id=project_id)
domain = await self._build_project_domain_snapshot(project_id=project_id)
dependencies = self._build_dependency_summary(charter=charter, task_summary=task_summary)
recent_signals = await self._build_recent_signal_summary(project_id=project_id)
world_model = {
"sender": sender,
"resolved_project_id": project_id,
"project": self._serialize_project(project) if project is not None else None,
"project_domain": domain,
"portfolio": portfolio_summary,
"budget": budget,
"capacity": capacity,
"member_count": len(capacity),
"skill_graph": skill_graph,
"charter": charter,
"task_summary": task_summary,
"risk_summary": risk_summary,
"dependencies": dependencies,
"recent_signals": recent_signals,
"decision_context_snapshot": {
"captured_at": _utc_now_naive().isoformat(),
"project_id": project_id,
"budget": budget,
"capacity": capacity,
"skill_graph_size": len(skill_graph),
"dependency_pressure": dependencies.get("pressure"),
},
}
self.db.add(
PortfolioSnapshot(
tenant_id=self.tenant_id,
scope_type="tenant",
scope_ref=f"tenant:{self.tenant_id}",
summary=portfolio_summary,
payload=world_model,
)
) -> WorldModel:
query_tables: set[str] = set()
project = await self._resolve_project(
message_text=message_text,
project_hint_id=project_hint_id,
query_tables=query_tables,
)
return await self.build_world_model(
tenant_id=self.tenant_id,
project_id=_safe_int(getattr(project, "id", None)),
sender=sender,
project=project,
pre_queried_tables=query_tables,
)
await self.db.flush()
return world_model
async def _resolve_project(self, *, message_text: str, project_hint_id: Optional[int]) -> Optional[Project]:
@staticmethod
def _record_world_model_query_table(query_tables: Optional[set[str]], table_name: str) -> None:
if query_tables is not None:
query_tables.add(table_name)
async def _resolve_project(
self,
*,
message_text: str,
project_hint_id: Optional[int],
query_tables: Optional[set[str]] = None,
) -> Optional[Project]:
if project_hint_id is not None:
self._record_world_model_query_table(query_tables, "projects")
return (
await self.db.execute(
select(Project).where(
@ -1311,6 +1540,7 @@ class AIPMOKernelService:
id_match = re.search(r"(?:项目(?:id|ID)?|project(?:id)?)\s*[:#:]?\s*(\d+)", str(message_text or ""))
if id_match:
project_id = int(id_match.group(1))
self._record_world_model_query_table(query_tables, "projects")
return (
await self.db.execute(
select(Project).where(
@ -1322,6 +1552,7 @@ class AIPMOKernelService:
)
).scalar_one_or_none()
self._record_world_model_query_table(query_tables, "projects")
projects = (
await self.db.execute(
select(Project).where(Project.tenant_id == self.tenant_id).order_by(Project.updated_at.desc(), Project.id.desc()).limit(50)
@ -1335,7 +1566,13 @@ class AIPMOKernelService:
return project
return None
async def _build_portfolio_summary(self, *, project_id: Optional[int]) -> dict[str, Any]:
async def _build_portfolio_summary(
self,
*,
project_id: Optional[int],
query_tables: Optional[set[str]] = None,
) -> PortfolioSummary:
self._record_world_model_query_table(query_tables, "projects")
active_project_count = (
await self.db.execute(
select(func.count(Project.id)).where(
@ -1351,7 +1588,13 @@ class AIPMOKernelService:
"focus_project_id": project_id,
}
async def _build_budget_summary(self, *, project_id: Optional[int]) -> dict[str, Any]:
async def _build_budget_summary(
self,
*,
project_id: Optional[int],
query_tables: Optional[set[str]] = None,
) -> BudgetSummary:
self._record_world_model_query_table(query_tables, "budgets")
stmt = select(Budget).where(Budget.tenant_id == self.tenant_id)
if project_id is not None:
stmt = stmt.where(or_(Budget.project_id == int(project_id), Budget.project_id.is_(None)))
@ -1371,9 +1614,15 @@ class AIPMOKernelService:
"hard_limit_enabled": bool(row.hard_limit_enabled),
}
async def _build_capacity_summary(self, *, project_id: Optional[int]) -> list[dict[str, Any]]:
async def _build_capacity_summary(
self,
*,
project_id: Optional[int],
query_tables: Optional[set[str]] = None,
) -> list[CapacityItem]:
user_ids: list[int] = []
if project_id is not None:
self._record_world_model_query_table(query_tables, "user_projects")
rows = (
await self.db.execute(
select(UserProject.user_id).where(
@ -1386,6 +1635,7 @@ class AIPMOKernelService:
).scalars().all()
user_ids = [int(uid) for uid in rows]
if not user_ids and project_id is not None:
self._record_world_model_query_table(query_tables, "projects")
owner_id = (
await self.db.execute(
select(Project.owner_id).where(
@ -1400,6 +1650,7 @@ class AIPMOKernelService:
user_ids = [int(owner_id)]
if not user_ids:
return []
self._record_world_model_query_table(query_tables, "user_workloads")
rows = (
await self.db.execute(
select(UserWorkload).where(
@ -1420,9 +1671,15 @@ class AIPMOKernelService:
for row in rows
]
async def _build_skill_graph(self, *, project_id: Optional[int]) -> list[dict[str, Any]]:
async def _build_skill_graph(
self,
*,
project_id: Optional[int],
query_tables: Optional[set[str]] = None,
) -> list[SkillGraphItem]:
user_ids: list[int] = []
if project_id is not None:
self._record_world_model_query_table(query_tables, "user_projects")
rows = (
await self.db.execute(
select(UserProject.user_id).where(
@ -1436,6 +1693,7 @@ class AIPMOKernelService:
user_ids = [int(uid) for uid in rows]
if not user_ids:
return []
self._record_world_model_query_table(query_tables, "user_skill_bindings")
rows = (
await self.db.execute(
select(UserSkillBinding).where(
@ -1457,9 +1715,16 @@ class AIPMOKernelService:
for row in rows
]
async def _build_project_domain_snapshot(self, *, project_id: Optional[int]) -> Optional[dict[str, Any]]:
async def _build_project_domain_snapshot(
self,
*,
project_id: Optional[int],
query_tables: Optional[set[str]] = None,
) -> Optional[ProjectDomainSnapshot]:
if project_id is None:
return None
self._record_world_model_query_table(query_tables, "projects")
self._record_world_model_query_table(query_tables, "project_domains")
domain_row = (
await self.db.execute(
select(ProjectDomain)
@ -1484,9 +1749,15 @@ class AIPMOKernelService:
"owner_id": domain_row.owner_id,
}
async def _build_charter_snapshot(self, *, project_id: Optional[int]) -> list[dict[str, Any]]:
async def _build_charter_snapshot(
self,
*,
project_id: Optional[int],
query_tables: Optional[set[str]] = None,
) -> list[CharterItem]:
if project_id is None:
return []
self._record_world_model_query_table(query_tables, "truth_nodes")
rows = (
await self.db.execute(
select(TruthNode).where(
@ -1508,9 +1779,16 @@ class AIPMOKernelService:
for row in rows
]
async def _build_task_summary(self, *, project_id: Optional[int]) -> dict[str, Any]:
async def _build_task_summary(
self,
*,
project_id: Optional[int],
skill_graph: list[SkillGraphItem],
query_tables: Optional[set[str]] = None,
) -> TaskSummary:
if project_id is None:
return {"total": 0, "by_status": {}, "overdue_count": 0, "uncovered_skill_tag_ids": [], "blocked_tasks": []}
self._record_world_model_query_table(query_tables, "tasks")
tasks = (
await self.db.execute(
select(Task).where(and_(Task.tenant_id == self.tenant_id, Task.project_id == int(project_id)))
@ -1518,7 +1796,7 @@ class AIPMOKernelService:
).scalars().all()
by_status: dict[str, int] = {}
required_skill_tag_ids: set[int] = set()
blocked_tasks: list[dict[str, Any]] = []
blocked_tasks: list[BlockedTask] = []
overdue_count = 0
now = _utc_now_naive()
for row in tasks:
@ -1533,7 +1811,6 @@ class AIPMOKernelService:
due_date = row.due_date.replace(tzinfo=None) if row.due_date.tzinfo else row.due_date
if due_date < now:
overdue_count += 1
skill_graph = await self._build_skill_graph(project_id=project_id)
available_skill_ids = {int(item["skill_tag_id"]) for item in skill_graph if item.get("skill_tag_id") is not None}
uncovered_skill_ids = sorted(skill_id for skill_id in required_skill_tag_ids if skill_id not in available_skill_ids)
return {
@ -1545,9 +1822,16 @@ class AIPMOKernelService:
"blocked_tasks": blocked_tasks[:8],
}
async def _build_recent_signal_summary(self, *, project_id: Optional[int]) -> list[dict[str, Any]]:
async def _build_recent_signal_summary(
self,
*,
project_id: Optional[int],
query_tables: Optional[set[str]] = None,
) -> list[RecentSignalItem]:
if project_id is None:
return []
self._record_world_model_query_table(query_tables, "signals")
self._record_world_model_query_table(query_tables, "project_signal_links")
rows = (
await self.db.execute(
select(Signal).where(
@ -1578,7 +1862,13 @@ class AIPMOKernelService:
for row in rows
]
async def _build_risk_summary(self, *, project_id: Optional[int]) -> dict[str, Any]:
async def _build_risk_summary(
self,
*,
project_id: Optional[int],
query_tables: Optional[set[str]] = None,
) -> RiskSummary:
self._record_world_model_query_table(query_tables, "notifications")
stmt = select(func.count(Notification.id)).where(
and_(
Notification.tenant_id == self.tenant_id,
@ -1592,7 +1882,7 @@ class AIPMOKernelService:
return {"open_risk_count": int(open_risk_count or 0)}
@staticmethod
def _build_dependency_summary(*, charter: list[dict[str, Any]], task_summary: dict[str, Any]) -> dict[str, Any]:
def _build_dependency_summary(*, charter: list[CharterItem], task_summary: TaskSummary) -> DependencySummary:
blocked_tasks = task_summary.get("blocked_tasks") if isinstance(task_summary.get("blocked_tasks"), list) else []
overdue_count = int(task_summary.get("overdue_count") or 0)
pressure = "high" if blocked_tasks or overdue_count > 0 else ("medium" if not charter else "low")
@ -2287,7 +2577,7 @@ class AIPMOKernelService:
return "已按长连接模式处理飞书接入,当前不会走 webhook;这条说明不会再被当成信号源类型变更。"
@staticmethod
def _serialize_project(project: Optional[Project]) -> Optional[dict[str, Any]]:
def _serialize_project(project: Optional[Project]) -> Optional[ProjectSnapshot]:
if project is None:
return None
return {

View File

@ -106,6 +106,24 @@ class ContextCacheService:
except Exception:
pass
async def get_json_key(self, key: str) -> Optional[dict[str, Any]]:
return await self._get_json(key)
async def set_json_key(self, key: str, value: dict[str, Any], ttl: int) -> None:
await self._set_json(key, value, ttl)
async def delete_keys(self, *keys: str) -> None:
valid_keys = [str(key).strip() for key in keys if str(key).strip()]
if not valid_keys:
return
redis = await self._get_redis()
if redis is None:
return
try:
await redis.delete(*valid_keys)
except Exception:
pass
@staticmethod
def _is_system_snapshot_complete(payload: Optional[dict[str, Any]]) -> bool:
if not isinstance(payload, dict):

View File

@ -17,6 +17,7 @@ from sqlalchemy.orm import selectinload
from ..models import SkillTag, UserSkillBinding, UserWorkload, User, UserStatus, Task
from ..database import _is_sqlite
from .ai_pmo_kernel_service import AIPMOKernelService
# 系统预设 AI 开发专属技能标签
@ -337,6 +338,11 @@ async def batch_update_user_skill_bindings(
await db.commit()
for b in result_bindings:
await db.refresh(b)
await AIPMOKernelService.invalidate_world_model_cache_for_user(
db=db,
tenant_id=tenant_id,
user_id=user_id,
)
return result_bindings