排查飞书机器人重复回复问题」 note: wait must be 18
This commit is contained in:
parent
93185369b9
commit
cf638e0a62
69
AGENTS.md
69
AGENTS.md
|
|
@ -16,7 +16,12 @@
|
||||||
|
|
||||||
3. **数据库规范**:使用 SQLAlchemy 2.0,所有模型继承 Base 基类,必须包含 `tenant_id` 字段,所有查询必须自动过滤 `tenant_id`(使用依赖注入实现)。
|
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 调用必须:
|
5. **AI 调用规范**:所有 AI 调用必须:
|
||||||
- 记录日志到 `ai_call_logs` 表
|
- 记录日志到 `ai_call_logs` 表
|
||||||
|
|
@ -98,12 +103,37 @@ frontend/ # React 前端
|
||||||
|
|
||||||
- 飞书回调字段可能缺失文件名/大小,仅保证 `attachment_id/key/type` 可用
|
- 飞书回调字段可能缺失文件名/大小,仅保证 `attachment_id/key/type` 可用
|
||||||
- `agents.defaults.timeoutSeconds` 在 OpenClaw 网关行为未验证,当前以 `LLM_REQUEST_TIMEOUT` 环境变量为准
|
- `agents.defaults.timeoutSeconds` 在 OpenClaw 网关行为未验证,当前以 `LLM_REQUEST_TIMEOUT` 环境变量为准
|
||||||
- World Model 每次全量构建,高负载场景下存在性能风险,暂未缓存,改动前需评估影响
|
- World Model 静态层已接入 Redis 缓存(key: `wm:static:{tenant_id}:{project_id}`,TTL 600s)
|
||||||
|
- 写操作后主动 invalidate,动态层不缓存
|
||||||
|
|
||||||
## 12. 前端组件约束
|
## 12. 前端组件约束
|
||||||
|
|
||||||
- `ui/` 目录下组件禁止修改,仅使用
|
- `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/` 内写内联组件
|
- 新增业务组件放入对应功能目录,禁止在 `pages/` 内写内联组件
|
||||||
- 样式只用 Tailwind utility class,禁止内联 `style`
|
- 样式只用 Tailwind utility class,禁止内联 `style`
|
||||||
|
|
||||||
|
|
@ -112,3 +142,36 @@ frontend/ # React 前端
|
||||||
- 后端改动:`python -m py_compile` 通过 + 相关单测通过
|
- 后端改动:`python -m py_compile` 通过 + 相关单测通过
|
||||||
- 前端改动:`npm run build` 通过 + `tsc --noEmit` 零错误
|
- 前端改动:`npm run build` 通过 + `tsc --noEmit` 零错误
|
||||||
- 新增接口:必须在 `backend/tests/` 补充对应单测
|
- 新增接口:必须在 `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]`,不要截断输出
|
||||||
|
|
|
||||||
59
CLAUDE.md
59
CLAUDE.md
|
|
@ -62,6 +62,41 @@ frontend/
|
||||||
└── vite.config.ts
|
└── 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. 运行命令(高频)
|
## 6. 运行命令(高频)
|
||||||
### 根目录
|
### 根目录
|
||||||
```bash
|
```bash
|
||||||
|
|
@ -87,3 +122,27 @@ uvicorn app.main:app --reload --host 0.0.0.0 --port 8000
|
||||||
- 规则变更:只改 `AGENTS.md`
|
- 规则变更:只改 `AGENTS.md`
|
||||||
- 结构变更:同步改 `CLAUDE.md`
|
- 结构变更:同步改 `CLAUDE.md`
|
||||||
- API/契约变更:后端 `schemas` + 前端 `types` 同步更新
|
- 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,动态层不缓存
|
||||||
|
|
|
||||||
|
|
@ -13,6 +13,7 @@ from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
from ..database import get_db
|
from ..database import get_db
|
||||||
from ..dependencies import get_current_user, get_tenant_id, require_roles
|
from ..dependencies import get_current_user, get_tenant_id, require_roles
|
||||||
from ..models import Project, ProjectSignalLink, Signal, Task, User
|
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 ..services.context_cache import context_cache_service
|
||||||
from ..utils.project_members import ensure_project_default_members
|
from ..utils.project_members import ensure_project_default_members
|
||||||
from ..utils.sequence import ensure_project_id_sequence
|
from ..utils.sequence import ensure_project_id_sequence
|
||||||
|
|
@ -227,6 +228,7 @@ async def create_project(
|
||||||
await db.commit()
|
await db.commit()
|
||||||
await db.refresh(project)
|
await db.refresh(project)
|
||||||
await context_cache_service.invalidate_system_context(tenant_id=tenant_id)
|
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)
|
return _serialize_project_row(project)
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -256,6 +258,7 @@ async def update_project(
|
||||||
await db.commit()
|
await db.commit()
|
||||||
await db.refresh(project)
|
await db.refresh(project)
|
||||||
await context_cache_service.invalidate_system_context(tenant_id=tenant_id)
|
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)
|
return _serialize_project_row(project)
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -13,6 +13,7 @@ from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
from ..database import get_db
|
from ..database import get_db
|
||||||
from ..dependencies import get_current_user, get_tenant_id, require_roles
|
from ..dependencies import get_current_user, get_tenant_id, require_roles
|
||||||
from ..models import SkillTag, User, UserSkillBinding
|
from ..models import SkillTag, User, UserSkillBinding
|
||||||
|
from ..services.ai_pmo_kernel_service import AIPMOKernelService
|
||||||
from ..services import skill_service
|
from ..services import skill_service
|
||||||
from ..services.auth import has_any_role
|
from ..services.auth import has_any_role
|
||||||
|
|
||||||
|
|
@ -235,6 +236,7 @@ async def bind_user_skill(
|
||||||
)
|
)
|
||||||
db.add(row)
|
db.add(row)
|
||||||
await db.commit()
|
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"}
|
return {"message": "Skill bound"}
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -285,6 +287,7 @@ async def update_user_skill(
|
||||||
row.efficiency_score = body.efficiency_score
|
row.efficiency_score = body.efficiency_score
|
||||||
row.updated_at = datetime.utcnow()
|
row.updated_at = datetime.utcnow()
|
||||||
await db.commit()
|
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"}
|
return {"message": "Skill updated"}
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -317,4 +320,5 @@ async def unbind_user_skill(
|
||||||
|
|
||||||
await db.delete(row)
|
await db.delete(row)
|
||||||
await db.commit()
|
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"}
|
return {"message": "Skill unbound"}
|
||||||
|
|
|
||||||
|
|
@ -58,6 +58,7 @@ from ..schemas.sstc import (
|
||||||
)
|
)
|
||||||
from ..services.llm import llm_service
|
from ..services.llm import llm_service
|
||||||
from ..services.ai_service import AIService
|
from ..services.ai_service import AIService
|
||||||
|
from ..services.ai_pmo_kernel_service import AIPMOKernelService
|
||||||
from ..services.operation_log_service import log_operation
|
from ..services.operation_log_service import log_operation
|
||||||
from ..services.auth import has_any_role
|
from ..services.auth import has_any_role
|
||||||
from ..services.skill_service import recommend_personnel
|
from ..services.skill_service import recommend_personnel
|
||||||
|
|
@ -2382,6 +2383,7 @@ async def update_charter(
|
||||||
)
|
)
|
||||||
await db.commit()
|
await db.commit()
|
||||||
await db.refresh(new_node)
|
await db.refresh(new_node)
|
||||||
|
await AIPMOKernelService.invalidate_world_model_cache(tenant_id=tenant_id, project_id=pid)
|
||||||
_sync_pmflow_rag_async(
|
_sync_pmflow_rag_async(
|
||||||
tenant_id=tenant_id,
|
tenant_id=tenant_id,
|
||||||
record_type="charter",
|
record_type="charter",
|
||||||
|
|
@ -2424,6 +2426,7 @@ async def update_charter(
|
||||||
)
|
)
|
||||||
await db.commit()
|
await db.commit()
|
||||||
await db.refresh(charter)
|
await db.refresh(charter)
|
||||||
|
await AIPMOKernelService.invalidate_world_model_cache(tenant_id=tenant_id, project_id=pid)
|
||||||
_sync_pmflow_rag_async(
|
_sync_pmflow_rag_async(
|
||||||
tenant_id=tenant_id,
|
tenant_id=tenant_id,
|
||||||
record_type="charter",
|
record_type="charter",
|
||||||
|
|
|
||||||
|
|
@ -39,6 +39,7 @@ from ..schemas import (
|
||||||
UserUpdate,
|
UserUpdate,
|
||||||
)
|
)
|
||||||
from ..services.auth import AuthService
|
from ..services.auth import AuthService
|
||||||
|
from ..services.ai_pmo_kernel_service import AIPMOKernelService
|
||||||
from ..services.operation_log_service import log_operation
|
from ..services.operation_log_service import log_operation
|
||||||
from ..services import skill_service
|
from ..services import skill_service
|
||||||
|
|
||||||
|
|
@ -211,6 +212,15 @@ async def _replace_user_projects(
|
||||||
user_id: int,
|
user_id: int,
|
||||||
project_ids: list[int],
|
project_ids: list[int],
|
||||||
) -> None:
|
) -> 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(
|
await db.execute(
|
||||||
delete(UserProject).where(
|
delete(UserProject).where(
|
||||||
and_(
|
and_(
|
||||||
|
|
@ -239,6 +249,10 @@ async def _replace_user_projects(
|
||||||
cache_payload["invalidate_project_list"] = True
|
cache_payload["invalidate_project_list"] = True
|
||||||
cache_payload["user_ids"].add(int(user_id))
|
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(
|
async def _create_user_with_defaults(
|
||||||
db: AsyncSession,
|
db: AsyncSession,
|
||||||
|
|
@ -750,6 +764,11 @@ async def delete_user(
|
||||||
)
|
)
|
||||||
|
|
||||||
before_data = user.to_dict()
|
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(
|
await db.execute(
|
||||||
delete(UserSkillBinding).where(
|
delete(UserSkillBinding).where(
|
||||||
and_(
|
and_(
|
||||||
|
|
@ -842,6 +861,11 @@ async def bulk_delete_users(
|
||||||
)
|
)
|
||||||
continue
|
continue
|
||||||
|
|
||||||
|
await AIPMOKernelService.invalidate_world_model_cache_for_user(
|
||||||
|
db=db,
|
||||||
|
tenant_id=tenant_id,
|
||||||
|
user_id=user_id,
|
||||||
|
)
|
||||||
await db.execute(
|
await db.execute(
|
||||||
delete(UserSkillBinding).where(
|
delete(UserSkillBinding).where(
|
||||||
and_(
|
and_(
|
||||||
|
|
|
||||||
|
|
@ -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",
|
||||||
|
]
|
||||||
|
|
@ -2,6 +2,7 @@
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import asyncio
|
||||||
import hashlib
|
import hashlib
|
||||||
import json
|
import json
|
||||||
import re
|
import re
|
||||||
|
|
@ -15,6 +16,7 @@ from sqlalchemy.exc import IntegrityError
|
||||||
from sqlalchemy.ext.asyncio import AsyncSession
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
from ..core.logging import get_logger
|
from ..core.logging import get_logger
|
||||||
|
from ..database import AsyncSessionLocal
|
||||||
from ..models import (
|
from ..models import (
|
||||||
AIProcessingLog,
|
AIProcessingLog,
|
||||||
Budget,
|
Budget,
|
||||||
|
|
@ -36,10 +38,26 @@ from ..models import (
|
||||||
UserWorkload,
|
UserWorkload,
|
||||||
WorkItem,
|
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 .adaptive_rag_service import AdaptiveRAGService
|
||||||
from .ai_pmo_cadence_service import AIPMOCadenceService
|
from .ai_pmo_cadence_service import AIPMOCadenceService
|
||||||
from .ai_steward import AIStewardService
|
from .ai_steward import AIStewardService
|
||||||
from .ai_pmo_review_service import AIPMOReviewService
|
from .ai_pmo_review_service import AIPMOReviewService
|
||||||
|
from .context_cache import ContextCacheService
|
||||||
from .notification_service import NotificationService
|
from .notification_service import NotificationService
|
||||||
from .openclaw_commander_service import OpenClawCommanderService
|
from .openclaw_commander_service import OpenClawCommanderService
|
||||||
from .ai_pmo_domain_workflow import SignalSourceDomainWorkflow
|
from .ai_pmo_domain_workflow import SignalSourceDomainWorkflow
|
||||||
|
|
@ -103,6 +121,9 @@ POLICY_GATE_RULES: tuple[tuple[str, tuple[str, ...]], ...] = (
|
||||||
("critical_path_change", ("关键路径", "里程碑改动", "影响多个项目", "跨项目依赖调整")),
|
("critical_path_change", ("关键路径", "里程碑改动", "影响多个项目", "跨项目依赖调整")),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
WORLD_MODEL_STATIC_CACHE_TTL_SECONDS = 10 * 60
|
||||||
|
WORLD_MODEL_STATIC_CACHE_KEY_PREFIX = "wm:static"
|
||||||
|
|
||||||
|
|
||||||
def _utc_now_naive() -> datetime:
|
def _utc_now_naive() -> datetime:
|
||||||
return datetime.now(timezone.utc).replace(tzinfo=None)
|
return datetime.now(timezone.utc).replace(tzinfo=None)
|
||||||
|
|
@ -817,6 +838,7 @@ class AIPMOKernelService:
|
||||||
def __init__(self, db: AsyncSession, tenant_id: int = 1) -> None:
|
def __init__(self, db: AsyncSession, tenant_id: int = 1) -> None:
|
||||||
self.db = db
|
self.db = db
|
||||||
self.tenant_id = int(tenant_id)
|
self.tenant_id = int(tenant_id)
|
||||||
|
self.context_cache = ContextCacheService()
|
||||||
self.intent_resolver = IntentResolver()
|
self.intent_resolver = IntentResolver()
|
||||||
self.decision_engine = DecisionEngine()
|
self.decision_engine = DecisionEngine()
|
||||||
self.reply_renderer = ReplyRenderer()
|
self.reply_renderer = ReplyRenderer()
|
||||||
|
|
@ -923,11 +945,20 @@ class AIPMOKernelService:
|
||||||
)
|
)
|
||||||
|
|
||||||
try:
|
try:
|
||||||
world_model = await self._build_world_model(
|
world_model_query_tables: set[str] = set()
|
||||||
|
project = await self._resolve_project(
|
||||||
message_text=clean_text,
|
message_text=clean_text,
|
||||||
sender=sender,
|
|
||||||
project_hint_id=project_hint_id,
|
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.state = STATE_MODELED
|
||||||
work_item.context_snapshot = world_model
|
work_item.context_snapshot = world_model
|
||||||
intent = self.intent_resolver.resolve(
|
intent = self.intent_resolver.resolve(
|
||||||
|
|
@ -1239,30 +1270,87 @@ class AIPMOKernelService:
|
||||||
await self.db.flush()
|
await self.db.flush()
|
||||||
return row
|
return row
|
||||||
|
|
||||||
async def _build_world_model(
|
async def build_world_model(
|
||||||
self,
|
self,
|
||||||
*,
|
*,
|
||||||
message_text: str,
|
tenant_id: int,
|
||||||
sender: str,
|
project_id: Optional[int],
|
||||||
project_hint_id: Optional[int],
|
sender: str = "",
|
||||||
) -> dict[str, Any]:
|
project: Optional[Project] = None,
|
||||||
project = await self._resolve_project(message_text=message_text, project_hint_id=project_hint_id)
|
pre_queried_tables: Optional[set[str]] = None,
|
||||||
project_id = _safe_int(getattr(project, "id", None))
|
) -> WorldModel:
|
||||||
portfolio_summary = await self._build_portfolio_summary(project_id=project_id)
|
if int(tenant_id) != int(self.tenant_id):
|
||||||
budget = await self._build_budget_summary(project_id=project_id)
|
raise ValueError(f"tenant mismatch for world model build: input={tenant_id} service={self.tenant_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 = {
|
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,
|
"sender": sender,
|
||||||
"resolved_project_id": project_id,
|
"resolved_project_id": resolved_project_id,
|
||||||
"project": self._serialize_project(project) if project is not None else None,
|
"project": project_snapshot,
|
||||||
"project_domain": domain,
|
"project_domain": domain,
|
||||||
"portfolio": portfolio_summary,
|
"portfolio": portfolio_summary,
|
||||||
"budget": budget,
|
"budget": budget,
|
||||||
|
|
@ -1276,14 +1364,46 @@ class AIPMOKernelService:
|
||||||
"recent_signals": recent_signals,
|
"recent_signals": recent_signals,
|
||||||
"decision_context_snapshot": {
|
"decision_context_snapshot": {
|
||||||
"captured_at": _utc_now_naive().isoformat(),
|
"captured_at": _utc_now_naive().isoformat(),
|
||||||
"project_id": project_id,
|
"project_id": resolved_project_id,
|
||||||
"budget": budget,
|
"budget": budget,
|
||||||
"capacity": capacity,
|
"capacity": capacity,
|
||||||
"skill_graph_size": len(skill_graph),
|
"skill_graph_size": len(skill_graph),
|
||||||
"dependency_pressure": dependencies.get("pressure"),
|
"dependency_pressure": dependencies.get("pressure"),
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
self.db.add(
|
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(
|
PortfolioSnapshot(
|
||||||
tenant_id=self.tenant_id,
|
tenant_id=self.tenant_id,
|
||||||
scope_type="tenant",
|
scope_type="tenant",
|
||||||
|
|
@ -1292,11 +1412,120 @@ class AIPMOKernelService:
|
||||||
payload=world_model,
|
payload=world_model,
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
await self.db.flush()
|
await bg_db.commit()
|
||||||
return world_model
|
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),
|
||||||
|
)
|
||||||
|
|
||||||
async def _resolve_project(self, *, message_text: str, project_hint_id: Optional[int]) -> Optional[Project]:
|
@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],
|
||||||
|
) -> 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,
|
||||||
|
)
|
||||||
|
|
||||||
|
@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:
|
if project_hint_id is not None:
|
||||||
|
self._record_world_model_query_table(query_tables, "projects")
|
||||||
return (
|
return (
|
||||||
await self.db.execute(
|
await self.db.execute(
|
||||||
select(Project).where(
|
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 ""))
|
id_match = re.search(r"(?:项目(?:id|ID)?|project(?:id)?)\s*[:#:]?\s*(\d+)", str(message_text or ""))
|
||||||
if id_match:
|
if id_match:
|
||||||
project_id = int(id_match.group(1))
|
project_id = int(id_match.group(1))
|
||||||
|
self._record_world_model_query_table(query_tables, "projects")
|
||||||
return (
|
return (
|
||||||
await self.db.execute(
|
await self.db.execute(
|
||||||
select(Project).where(
|
select(Project).where(
|
||||||
|
|
@ -1322,6 +1552,7 @@ class AIPMOKernelService:
|
||||||
)
|
)
|
||||||
).scalar_one_or_none()
|
).scalar_one_or_none()
|
||||||
|
|
||||||
|
self._record_world_model_query_table(query_tables, "projects")
|
||||||
projects = (
|
projects = (
|
||||||
await self.db.execute(
|
await self.db.execute(
|
||||||
select(Project).where(Project.tenant_id == self.tenant_id).order_by(Project.updated_at.desc(), Project.id.desc()).limit(50)
|
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 project
|
||||||
return None
|
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 = (
|
active_project_count = (
|
||||||
await self.db.execute(
|
await self.db.execute(
|
||||||
select(func.count(Project.id)).where(
|
select(func.count(Project.id)).where(
|
||||||
|
|
@ -1351,7 +1588,13 @@ class AIPMOKernelService:
|
||||||
"focus_project_id": project_id,
|
"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)
|
stmt = select(Budget).where(Budget.tenant_id == self.tenant_id)
|
||||||
if project_id is not None:
|
if project_id is not None:
|
||||||
stmt = stmt.where(or_(Budget.project_id == int(project_id), Budget.project_id.is_(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),
|
"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] = []
|
user_ids: list[int] = []
|
||||||
if project_id is not None:
|
if project_id is not None:
|
||||||
|
self._record_world_model_query_table(query_tables, "user_projects")
|
||||||
rows = (
|
rows = (
|
||||||
await self.db.execute(
|
await self.db.execute(
|
||||||
select(UserProject.user_id).where(
|
select(UserProject.user_id).where(
|
||||||
|
|
@ -1386,6 +1635,7 @@ class AIPMOKernelService:
|
||||||
).scalars().all()
|
).scalars().all()
|
||||||
user_ids = [int(uid) for uid in rows]
|
user_ids = [int(uid) for uid in rows]
|
||||||
if not user_ids and project_id is not None:
|
if not user_ids and project_id is not None:
|
||||||
|
self._record_world_model_query_table(query_tables, "projects")
|
||||||
owner_id = (
|
owner_id = (
|
||||||
await self.db.execute(
|
await self.db.execute(
|
||||||
select(Project.owner_id).where(
|
select(Project.owner_id).where(
|
||||||
|
|
@ -1400,6 +1650,7 @@ class AIPMOKernelService:
|
||||||
user_ids = [int(owner_id)]
|
user_ids = [int(owner_id)]
|
||||||
if not user_ids:
|
if not user_ids:
|
||||||
return []
|
return []
|
||||||
|
self._record_world_model_query_table(query_tables, "user_workloads")
|
||||||
rows = (
|
rows = (
|
||||||
await self.db.execute(
|
await self.db.execute(
|
||||||
select(UserWorkload).where(
|
select(UserWorkload).where(
|
||||||
|
|
@ -1420,9 +1671,15 @@ class AIPMOKernelService:
|
||||||
for row in rows
|
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] = []
|
user_ids: list[int] = []
|
||||||
if project_id is not None:
|
if project_id is not None:
|
||||||
|
self._record_world_model_query_table(query_tables, "user_projects")
|
||||||
rows = (
|
rows = (
|
||||||
await self.db.execute(
|
await self.db.execute(
|
||||||
select(UserProject.user_id).where(
|
select(UserProject.user_id).where(
|
||||||
|
|
@ -1436,6 +1693,7 @@ class AIPMOKernelService:
|
||||||
user_ids = [int(uid) for uid in rows]
|
user_ids = [int(uid) for uid in rows]
|
||||||
if not user_ids:
|
if not user_ids:
|
||||||
return []
|
return []
|
||||||
|
self._record_world_model_query_table(query_tables, "user_skill_bindings")
|
||||||
rows = (
|
rows = (
|
||||||
await self.db.execute(
|
await self.db.execute(
|
||||||
select(UserSkillBinding).where(
|
select(UserSkillBinding).where(
|
||||||
|
|
@ -1457,9 +1715,16 @@ class AIPMOKernelService:
|
||||||
for row in rows
|
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:
|
if project_id is None:
|
||||||
return None
|
return None
|
||||||
|
self._record_world_model_query_table(query_tables, "projects")
|
||||||
|
self._record_world_model_query_table(query_tables, "project_domains")
|
||||||
domain_row = (
|
domain_row = (
|
||||||
await self.db.execute(
|
await self.db.execute(
|
||||||
select(ProjectDomain)
|
select(ProjectDomain)
|
||||||
|
|
@ -1484,9 +1749,15 @@ class AIPMOKernelService:
|
||||||
"owner_id": domain_row.owner_id,
|
"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:
|
if project_id is None:
|
||||||
return []
|
return []
|
||||||
|
self._record_world_model_query_table(query_tables, "truth_nodes")
|
||||||
rows = (
|
rows = (
|
||||||
await self.db.execute(
|
await self.db.execute(
|
||||||
select(TruthNode).where(
|
select(TruthNode).where(
|
||||||
|
|
@ -1508,9 +1779,16 @@ class AIPMOKernelService:
|
||||||
for row in rows
|
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:
|
if project_id is None:
|
||||||
return {"total": 0, "by_status": {}, "overdue_count": 0, "uncovered_skill_tag_ids": [], "blocked_tasks": []}
|
return {"total": 0, "by_status": {}, "overdue_count": 0, "uncovered_skill_tag_ids": [], "blocked_tasks": []}
|
||||||
|
self._record_world_model_query_table(query_tables, "tasks")
|
||||||
tasks = (
|
tasks = (
|
||||||
await self.db.execute(
|
await self.db.execute(
|
||||||
select(Task).where(and_(Task.tenant_id == self.tenant_id, Task.project_id == int(project_id)))
|
select(Task).where(and_(Task.tenant_id == self.tenant_id, Task.project_id == int(project_id)))
|
||||||
|
|
@ -1518,7 +1796,7 @@ class AIPMOKernelService:
|
||||||
).scalars().all()
|
).scalars().all()
|
||||||
by_status: dict[str, int] = {}
|
by_status: dict[str, int] = {}
|
||||||
required_skill_tag_ids: set[int] = set()
|
required_skill_tag_ids: set[int] = set()
|
||||||
blocked_tasks: list[dict[str, Any]] = []
|
blocked_tasks: list[BlockedTask] = []
|
||||||
overdue_count = 0
|
overdue_count = 0
|
||||||
now = _utc_now_naive()
|
now = _utc_now_naive()
|
||||||
for row in tasks:
|
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
|
due_date = row.due_date.replace(tzinfo=None) if row.due_date.tzinfo else row.due_date
|
||||||
if due_date < now:
|
if due_date < now:
|
||||||
overdue_count += 1
|
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}
|
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)
|
uncovered_skill_ids = sorted(skill_id for skill_id in required_skill_tag_ids if skill_id not in available_skill_ids)
|
||||||
return {
|
return {
|
||||||
|
|
@ -1545,9 +1822,16 @@ class AIPMOKernelService:
|
||||||
"blocked_tasks": blocked_tasks[:8],
|
"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:
|
if project_id is None:
|
||||||
return []
|
return []
|
||||||
|
self._record_world_model_query_table(query_tables, "signals")
|
||||||
|
self._record_world_model_query_table(query_tables, "project_signal_links")
|
||||||
rows = (
|
rows = (
|
||||||
await self.db.execute(
|
await self.db.execute(
|
||||||
select(Signal).where(
|
select(Signal).where(
|
||||||
|
|
@ -1578,7 +1862,13 @@ class AIPMOKernelService:
|
||||||
for row in rows
|
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(
|
stmt = select(func.count(Notification.id)).where(
|
||||||
and_(
|
and_(
|
||||||
Notification.tenant_id == self.tenant_id,
|
Notification.tenant_id == self.tenant_id,
|
||||||
|
|
@ -1592,7 +1882,7 @@ class AIPMOKernelService:
|
||||||
return {"open_risk_count": int(open_risk_count or 0)}
|
return {"open_risk_count": int(open_risk_count or 0)}
|
||||||
|
|
||||||
@staticmethod
|
@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 []
|
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)
|
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")
|
pressure = "high" if blocked_tasks or overdue_count > 0 else ("medium" if not charter else "low")
|
||||||
|
|
@ -2287,7 +2577,7 @@ class AIPMOKernelService:
|
||||||
return "已按长连接模式处理飞书接入,当前不会走 webhook;这条说明不会再被当成信号源类型变更。"
|
return "已按长连接模式处理飞书接入,当前不会走 webhook;这条说明不会再被当成信号源类型变更。"
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def _serialize_project(project: Optional[Project]) -> Optional[dict[str, Any]]:
|
def _serialize_project(project: Optional[Project]) -> Optional[ProjectSnapshot]:
|
||||||
if project is None:
|
if project is None:
|
||||||
return None
|
return None
|
||||||
return {
|
return {
|
||||||
|
|
|
||||||
|
|
@ -106,6 +106,24 @@ class ContextCacheService:
|
||||||
except Exception:
|
except Exception:
|
||||||
pass
|
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
|
@staticmethod
|
||||||
def _is_system_snapshot_complete(payload: Optional[dict[str, Any]]) -> bool:
|
def _is_system_snapshot_complete(payload: Optional[dict[str, Any]]) -> bool:
|
||||||
if not isinstance(payload, dict):
|
if not isinstance(payload, dict):
|
||||||
|
|
|
||||||
|
|
@ -17,6 +17,7 @@ from sqlalchemy.orm import selectinload
|
||||||
|
|
||||||
from ..models import SkillTag, UserSkillBinding, UserWorkload, User, UserStatus, Task
|
from ..models import SkillTag, UserSkillBinding, UserWorkload, User, UserStatus, Task
|
||||||
from ..database import _is_sqlite
|
from ..database import _is_sqlite
|
||||||
|
from .ai_pmo_kernel_service import AIPMOKernelService
|
||||||
|
|
||||||
|
|
||||||
# 系统预设 AI 开发专属技能标签
|
# 系统预设 AI 开发专属技能标签
|
||||||
|
|
@ -337,6 +338,11 @@ async def batch_update_user_skill_bindings(
|
||||||
await db.commit()
|
await db.commit()
|
||||||
for b in result_bindings:
|
for b in result_bindings:
|
||||||
await db.refresh(b)
|
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
|
return result_bindings
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue