diff --git a/AGENTS.md b/AGENTS.md index c669508c..1420839d 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -97,6 +97,8 @@ frontend/ # React 前端 - 禁止在前端组件内直接调用 `fetch`,必须通过 `frontend/src/api/` 封装层 - MCP 模板接入中心只允许保存模板参数、命令预览、runtime probe 与“本机已执行”确认状态;后端 `backend_direct` 模板可由 PMFlow backend 直接校验/调用(包括远程 `streamable_http` MCP 与内建 `backend_adapter` runtime),本地 `stdio` 模板只能走 PMFlow Local Bridge 或兼容 MCP host;禁止从服务端直接写宿主机 `~/.codex/config.toml`、禁止替客户执行本机命令,GitHub 类模板只允许保存 env var 名称或占位符,不得落明文 token。 - 前端状态管理:服务端数据用 React Query,本地 UI 状态用 `useState` +- 前端身份边界必须与真实写接口一致:`/space`、真实 `/project-launch`、`/me`、项目主链 standalone 与管理/治理入口必须登录;管理/治理入口需 OWNER/REVIEWER;`/project-launch?demo=1` 或 `?dataSource=demo` 是唯一匿名启动台演示入口,且只能使用本地 mock/demo,不得调用真实写接口。 +- 用户可见错误必须使用产品语义分类,禁止直接展示 Axios/FastAPI 原始错误(如 `Request failed with status code 401`、`Could not validate credentials`、traceback);401/无 token 必须表达为登录/会话问题,pending intent 只能恢复输入,禁止登录后自动重放真实写请求。 - 用户可见核心业务内容零兜底:AI 失败、证据不足、结构化失败时必须显式失败或标记待确认,禁止用默认句、模板句、stub 冒充成功。 - 业务语义、问答/执行归因与前台动作拍板必须优先走“对象/上下文绑定 -> `SemanticContextPacket` -> `SemanticDecision` -> gatekeeper”主链,禁止在 `backend/app` 运行时代码里恢复 `looks_like_*`、关键词仓或散落正则拍板。 - `query.answer` 路由与外部查询规划不得在运行时代码里维护 `_INTERNAL_ROUTE_OBJECTS / _EXTERNAL_ROUTE_OBJECTS / _SEMANTIC_QUERY_OBJECT_TYPES` 一类对象词表,也不得新增 `*_probe_is_strong / *_route_probe / query_kind(weather/news/company/market/model)` 这类二次语义拍板。`query_profile / source_policy_hint / SemanticDecision` 只能作为上游结构化输入;若证据不足,只能显式返回 `uncertain/clarify`,禁止在执行层靠对象集合、`subject_focus`、`needs_freshness`、`referenced_objects` 再次裁决 internal vs external。 diff --git a/CLAUDE.md b/CLAUDE.md index 99fcd931..556c0791 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -81,6 +81,8 @@ backend/ - `backend/app/services/semantic_fact_service/`(semantic facts 读模型、八类业务对象统一抽取/失活、ORM 提交钩子入队、治理看板查询) - `backend/app/api/semantic_facts_api/`(semantic facts 治理后台接口:overview / objects / detail / reextract / backfill) - `backend/app/api/mcp_api/`(MCP 模板接入中心后台接口:catalog / servers / validate / confirm-client-apply) + - `backend/app/api/ai_routing.py` + `backend/app/services/ai_routing_governance_service.py`(AI 路由与供应商产品化控制台:聚合 low/high 槽位、供应商摘要、团队策略、近期指标、只读诊断和审计;默认真源仍是 `backend/.control_plane/pmflow-routing-rules.json`,团队策略只保存到 `integrations(type='ai_team_policy')`,不得直接改 runtime 路由;作为团队级 AI 运营中心核心接口,在 `API_ROUTE_PROFILE=lean` 下也必须加载) + - `backend/app/api/model_routing.py`(AI 运营中心“高级路由详情”兼容入口,`API_ROUTE_PROFILE=lean` 下也必须加载) - `backend/app/services/llm_service/`(显式 `routing_meta` 协议) - `backend/app/services/integrations/feishu_integration_service/`(飞书入站与卡片回调;`mixin_receive_routing.py` 的 `qa_direct` 现已支持 recommendation ask 直出推荐正文,并把原始 `Project database / signal_total / 当前盘面 / 建议动作 / External Freshness Guard` 这类内部/英文术语压成单行中文来源说明) - `backend/app/services/signal/`(信号接入、编排、planner、repair) @@ -97,7 +99,7 @@ frontend/ │ ├── api/ # 接口封装 │ ├── features/ # 新产品域与原型状态机(如 PMFlow OS / 个人空间 / 新项目启动台) │ ├── pages/ # 页面级组件(已目录化) -│ ├── components/ # 通用组件与 SSTC/Inbox 共享组件 +│ ├── components/ # 通用组件与 SSTC 等共享组件 │ ├── hooks/ # 自定义 hooks │ ├── lib/ # 通用工具 │ ├── stores/ # 状态管理 @@ -109,32 +111,34 @@ frontend/ ``` - 当前前端重点入口优先看: - `frontend/src/api/`(统一 API 封装) - - `frontend/src/router/index.tsx`(顶层路由懒加载入口;登录页与 Layout 保持同步首屏,登录后默认主入口已切到 `/command-center`,`/space`、`/project-launch`、`/pmflow-os` 与 `/me` 是可直接打开的个人空间 / 新项目启动台 / PMFlow OS 项目上下文 / 我的账号入口,`/command-center` 也提供免登录产品预览壳,其余后台重页面按 route-level lazy 进入) - - `frontend/src/features/personal-space/`(个人空间首页;通过 `frontend/src/api/space.ts` 只读聚合项目摘要、PMFlow OS truth-state / execution-summary,承载继续推进、待确认、AI 正在推进、草案箱和最近项目入口) + - `frontend/src/router/index.tsx`(顶层路由懒加载入口;登录页与 Layout 保持同步首屏;`/command-center` 保留免登录产品预览壳;`/space`、真实 `/project-launch`、`/pmflow-os`、`/change-review`、`/structured-extraction`、`/memory-writeback-result` 与 `/me` 均需登录;`/project-launch?demo=1` 或 `?dataSource=demo` 是唯一匿名启动台演示入口;管理/治理类页面统一走 OWNER/REVIEWER 权限边界) + - `frontend/src/auth/RouteBoundary.tsx`、`frontend/src/lib/apiError.ts`、`frontend/src/lib/pendingIntent.ts`(前端身份边界、产品化 API 错误分类与登录续接 pending intent 真源;用户可见 UI 禁止直接展示 Axios/FastAPI 原始 401/403/5xx 文案) +- `frontend/src/features/personal-space/`(个人空间首页;通过 `frontend/src/api/space.ts` 只读聚合项目摘要、PMFlow OS truth-state / execution-summary / execution-capabilities,并兼容消费旧 AI PMO `waiting_manual / draft_pending_confirm` 工作项,承载执行准备度、继续推进、待确认、AI 正在推进、草案箱、缺项目归属补全和最近项目入口) - `frontend/src/features/project-launch/`(首次进入页 / 新项目启动台;默认调用真实 `frontend/src/api/pmflowLaunch.ts` draft/confirm API 创建项目,只有显式 `?demo=1` 或 `?dataSource=demo` 才启用本地 mock 演示状态机) - `frontend/src/features/account-settings/`(`/me` 个人中心与设置;通过 `frontend/src/api/accountSettings.ts` 管理个人资料、默认语言、AI 回答深度、证据展示偏好和通知偏好,移动端使用分组列表与 bottom sheet,不进入 auth core) - `frontend/src/features/pmflow-os/`(PMFlow OS 前端产品域;共享 truth store 已支持 `?projectId=` 读取真实后端 projection,真实模式下 message、`proposed_items` extraction、ChangeItem `accept/edit/defer`、ChangeSet `commit`、review decision、Team reviewer routing、单条 iteration recommendation -> ChangeSet draft 与同组 iteration recommendation 批量 -> ChangeSet draft 均调后端,显式 `?demo=1` 才启用本地演示状态机) - `backend/app/api/pmflow_os_api/` + `backend/app/services/pmflow_os_projection_service.py` + `backend/app/services/pmflow_change_review_service.py` + `backend/app/schemas/pmflow_os.py` + `backend/app/models/pmflow_change_review.py`(PMFlow OS A3/C1/C2/P8 真实路径;提供 `truth-state`、`execution-summary`、thread message、`proposed_items` extraction、ChangeSet detail、ChangeItem `accept/edit/defer`、ChangeSet `commit`、review decision、execution handoff/progress writeback、release validation run、release feedback ingest、Team reviewer routing、recommendation change-set draft 与 recommendation group change-set draft;复用 `truth_nodes / tasks / semantic_facts / ai_project_control / dev_sync / FeedbackIngest / IterationRecommendation` 并投影 `pmflow_threads / pmflow_thread_messages / pmflow_change_sets / pmflow_change_items / pmflow_approvals / pmflow_evidence_links`;`truth-state.reviewerRouting` 是 `{status, warningCode, summary, items}` 合同,生成建议审查人/原因/来源/风险/跳转 URL,并投影 `reviewDecision / approvalState / requestedAt / decidedAt / decisionReason`;`POST /pmflow-os/change-sets/{change_set_id}/review-decisions` 支持 request_review/approve/request_changes/reject,复用 `PMFlowApproval` 审计,不自动 commit;C2 commit 默认 atomic,`ModelDecision / MetricSignal` 暂 fail-closed) - - `backend/app/api/pmflow_launch_api/` + `backend/app/services/pmflow_launch_service.py` + `backend/app/schemas/pmflow_launch.py` + `backend/app/models/pmflow_launch.py`(Project Launch Phase B 真实创建链;`drafts` 只写 `pmflow_launch_drafts`,`confirm` 在同一事务写真实 Project、启动线程、启动消息、初始 truth_nodes、tasks 与 semantic_facts seed) + - `backend/app/api/pmflow_launch_api/` + `backend/app/services/pmflow_launch_service.py` + `backend/app/schemas/pmflow_launch.py` + `backend/app/models/pmflow_launch.py`(Project Launch Phase B 真实创建链;`drafts` 只写 `pmflow_launch_drafts`,`confirm` 在同一事务写真实 Project、启动线程、启动消息、初始 truth_nodes、tasks 与 semantic_facts seed);`backend/app/api/legacy_ai_steward_api.py` 仅保留旧文本创建接口的 410 迁移响应,并在 lean profile 下始终挂载 - `docs/architecture/PMFLOW_OS_PRODUCT_CONTRACT.md`(PMFlow OS 产品合同真源;真实后端接入前先读,锁定本地 truth store 对象与 `truth_nodes / tasks / semantic_facts / ai_project_control / dev_sync` 的映射;Phase C1 review 表和审查 API 已实现,Phase C2 backend/frontend commit 已实现) - - `frontend/src/pages/inbox/`(消息台) - - `frontend/src/pages/ai-steward/`(AI 管家) + - `frontend/src/pages/ai-steward/`(历史 AI Steward 页面迁移占位;`/ai-steward` 只做 legacy redirect,不再作为产品入口) - `frontend/src/components/ai-steward/`(问答浮窗、状态条、回答元卡片;`FloatingQAChat.tsx` 现会透传并回放 `project_operating_context / execution_recipe / fit_brief / external_recommendation / analysis_packet / recommended_next_step`,`QAConversationStatusBar.tsx` 与 `QAResponseMetaCard.tsx` 负责显性展示) - - `frontend/src/pages/ai-config/mcp-config/`(AI Config -> Governance Tools 下的 MCP 模板接入中心) - - `frontend/src/pages/query-route-governance/`(AI Config -> Governance Tools 下的 route/context 治理页) - - `frontend/src/pages/semantic-facts-board/`(AI Config -> Governance Tools 下的 semantic facts 治理页) + - `frontend/src/pages/ai-config/AIConfigPage.tsx` + `frontend/src/pages/ai-config/constants.ts`(团队级 AI 运营中心;`/ai-config` 技术路由保留,用户可见命名为“AI 运营中心”,首页按策略 / 连接 / 治理 / 高级分组组织入口) + - `frontend/src/pages/ai-routing/` + `frontend/src/api/aiRouting.ts`(AI 运营中心 -> 团队 AI 策略与 AI 路由/供应商;负责人看业务策略,管理员看供应商连接、快速/深度槽位、诊断和高级详情,成员无独立入口) + - `frontend/src/pages/ai-config/mcp-config/`(AI 运营中心 -> MCP 模板接入中心) + - `frontend/src/pages/query-route-governance/`(AI 运营中心 -> route/context 治理页) + - `frontend/src/pages/semantic-facts-board/`(AI 运营中心 -> semantic facts 治理页) - `frontend/src/components/layout/LazyBoundary.tsx`(统一 route/page-section/dialog 级 loading skeleton) - `frontend/src/components/login/`(公开登录页组件目录;`LoginExperience.tsx` 负责双栏登录壳,`LoginWorkflowVisual.tsx` 负责左侧场景,`loginScene.ts` / `loginMath.ts` 拆出展示配置与路径几何,`login.css` 收口登录页专属样式) - `frontend/src/api/queryRouteGovernance.ts`(route/context 治理页前端 API) - `frontend/src/pages/scheduler-center/`(Mounted AI Console) - `frontend/src/components/sstc/`(SSTC 工作流组件) - `frontend/src/pages/user-skills/components/UserSkillsRadarPanel.tsx` 与 `frontend/src/pages/workload-detail/components/WorkloadTrendChart.tsx`(图表按页面内二级 chunk 拆分后的入口) -- 页面目录化、Mounted AI Console、Inbox、AI Steward 与 Recipes/Projects/Users 等模块化拆分的详细库存见库存文档。 -- 顶层路由已改为 `React.lazy + Suspense`,统一 fallback 使用 `frontend/src/components/layout/LazyBoundary.tsx`;AI Steward / AI Config 页面按 active tab 只渲染当前可见区块,Projects/Users/图表页则把重对话框与图表从同步入口拆出。 -- `frontend/src/features/project-launch/` 是新项目启动台独立产品域:`types.ts / useProjectLaunchState.ts` 承载状态合同和真实 API 编排,`mockData.ts` 只在显式 demo 模式下使用;默认路径调用 `POST /pmflow-launch/drafts` 生成可审查草案,确认后调用 `POST /pmflow-launch/drafts/{draft_id}/confirm` 创建真实项目并跳转 `/pmflow-os?projectId=`。`LaunchShell` 桌面端复用 `frontend/src/features/personal-space/components/SpaceChrome.tsx` 的空间左侧栏和顶部条,主画布仍由 `IntentInputPanel / AIProgressCanvas / HumanReviewCanvas / DraftEditCanvas / FailureStatePanel / SuccessTransitionPanel` 承接,移动端仍由 `MobileLaunchLayout` 承接;`frontend/src/pages/ProjectLaunchPage.tsx` 仅做薄入口。 -- `frontend/src/features/personal-space/` 是空间层入口:`PersonalSpacePage.tsx` 承接桌面个人工作面与独立移动单手布局,`frontend/src/api/space.ts` 只读聚合 `projects` summary、PMFlow OS `truth-state`、`execution-summary` 并生成 `nextActionQueue` 我的下一步队列、继续推进、待确认、review attention、发布关注、发布后反馈关注、下一轮迭代草稿关注、AI 正在推进、草案箱、最近项目摘要;Space P6 的下一步队列按高风险待确认、发布阻塞/失败证据、handoff consumed 后缺少后续 progress、下一轮 draft、发布后反馈/收入/合规信号排序;Space P7 新增 `review_attention`,只读消费 `truth-state.reviewerRouting`,按高风险待我审、`request_changes / rejected` 后待处理、`reviewer_missing` warning、普通 pending review 排序进入“我的下一步”,点击只跳 `/change-review?projectId=&changeSetId=`、已有 `/team-layer?projectId=`、`/execution-bridge?projectId=` 或 `/release-growth?projectId=`;Space P3 的发布关注优先消费 `truth-state.releases` 的 release validation 投影,没有验证投影时才回落 `execution-summary.releaseGate`;Space P4/P5 的发布后反馈/下一轮草稿关注只读消费 `execution-summary.releaseSignals`;不在 Space 写 Project Memory、PMFlowApproval、ProgressEvent、ReleaseValidationRun、FeedbackIngest、RevenueEvent、ComplianceCheckRun、ChangeSet 状态或发布执行,不虚构 reviewer、审查意见或待审 ChangeSet。`components/SpaceChrome.tsx` 承接空间层左侧栏、顶部条与空间用量组件,并被 `/project-launch` 桌面首次新建壳复用;`frontend/src/pages/PersonalSpacePage.tsx` 仅做薄入口。 +- 页面目录化、Mounted AI Console、AI Steward QA 组件与 Recipes/Projects/Users 等模块化拆分的详细库存见库存文档;旧独立收件页已退场,待处理能力收敛到 `/space`、`/change-review`、`/team-layer` 与 `/me`。 +- 顶层路由已改为 `React.lazy + Suspense`,统一 fallback 使用 `frontend/src/components/layout/LazyBoundary.tsx`;`/ai-steward` 只保留迁移重定向,`tab=create` 迁到 `/project-launch`,`tab=engine / tab=feedback-center` 迁到 `/ai-config`,其它 legacy tab 迁到 `/space`;AI 运营中心页面按 active tab 只渲染当前可见区块,Projects/Users/图表页则把重对话框与图表从同步入口拆出。 +- `frontend/src/features/project-launch/` 是新项目启动台独立产品域,也是新建项目唯一用户入口:`types.ts / useProjectLaunchState.ts` 承载状态合同和真实 API 编排,真实模式必须登录并调用 `POST /pmflow-launch/drafts` 生成可审查草案,确认后调用 `POST /pmflow-launch/drafts/{draft_id}/confirm` 创建真实项目并跳转 `/pmflow-os?projectId=`;旧 `POST /ai-steward/project-from-text` 只返回 410 迁移错误,不得绕过草案确认直接写正式对象;`mockData.ts` 只在显式 `?demo=1` 或 `?dataSource=demo` 匿名演示模式下使用。401/无 token 不得展示 `Request failed with status code 401` 或归类为网络异常,必须保存输入到 pending intent 并提示重新登录后继续。`LaunchShell` 桌面端复用 `frontend/src/features/personal-space/components/SpaceChrome.tsx` 的空间左侧栏和顶部条,主画布仍由 `IntentInputPanel / AIProgressCanvas / HumanReviewCanvas / DraftEditCanvas / FailureStatePanel / SuccessTransitionPanel` 承接,移动端仍由 `MobileLaunchLayout` 承接;`frontend/src/pages/ProjectLaunchPage.tsx` 仅做薄入口。 +- `frontend/src/features/personal-space/` 是登录后的空间层入口:`PersonalSpacePage.tsx` 承接桌面个人工作面与独立移动单手布局,`frontend/src/api/space.ts` 只读聚合 `projects` summary、PMFlow OS `truth-state`、`execution-summary`、`execution-capabilities`,并兼容消费旧 AI PMO `waiting_manual / draft_pending_confirm` 工作项,生成 `nextActionQueue` 我的下一步队列、执行准备度、我的今日容量、继续推进、待确认、review attention、发布关注、发布后反馈关注、下一轮迭代草稿关注、AI 正在推进、草案箱、缺项目归属补全、最近项目摘要;未登录访问 `/space` 必须跳登录,不展示个人空间语义。Space P6 的下一步队列按高风险待确认、发布阻塞/失败证据、handoff consumed 后缺少后续 progress、下一轮 draft、发布后反馈/收入/合规信号排序;Space P7 新增 `review_attention`,只读消费 `truth-state.reviewerRouting`,按高风险待我审、`request_changes / rejected` 后待处理、`reviewer_missing` warning、普通 pending review 排序进入“我的下一步”;旧 `missing_project_scope` 只允许在 Space 选择项目后复用 `POST /ai-pmo/work-items/{id}/decision` 继续既有审计链,不新增旁路接口,不直接写项目真相层。动作只跳 `/change-review?projectId=&changeSetId=`、已有 `/team-layer?projectId=`、`/execution-bridge?projectId=`、`/release-growth?projectId=` 或复用旧工作项决策 API;执行准备度只读展示工具连接、待确认、待审查、待验收和阻塞原因,今日容量只读展示有效容量、人工触点、剩余容量、动态建议结构、系统解释和 AI 预处理可释放时间,不写项目业务对象;Space P3/P4/P5 仍只读消费 release validation 与 releaseSignals;不在 Space 写 Project Memory、PMFlowApproval、ProgressEvent、ReleaseValidationRun、FeedbackIngest、RevenueEvent、ComplianceCheckRun、ChangeSet 状态、容量 override 或发布执行,不虚构 reviewer、审查意见、待审 ChangeSet、工具连接、执行能力或容量计划。`components/SpaceChrome.tsx` 承接空间层左侧栏、顶部条与空间用量组件,并被 `/project-launch` 桌面首次新建壳复用;`frontend/src/pages/PersonalSpacePage.tsx` 仅做薄入口。 - `frontend/src/features/account-settings/` 是 Account Settings 独立产品域:`AccountSettingsPanel.tsx` 承接 `/me` 桌面设置页、移动端分组列表与 bottom sheet,`frontend/src/api/accountSettings.ts` 定义个人资料、默认语言、AI 回答深度、证据展示偏好和四类通知偏好合同;当前已有账号同步 API fallback、本地 UI 通知消费模型、Release/Growth 偏好 helper、post-release / next change review 偏好 helper、`getAccountReminderConsumption(settings, event)` 提醒强度消费合同,以及 `getReviewDecisionNotificationPreference(settings, event)` review decision 偏好合同;支持 `reminderIntensity=silent|normal|prominent` 与 `evidenceDisplay=compact|expanded`,并覆盖 `review_requested / review_approved / review_changes_requested / review_rejected / high_risk_approval_required`;`releaseGate / failureAlert / pendingConfirmation / executionWriteback` 只影响页面内提醒表达强度和证据展开方式,不代表真实邮件、桌面推送或系统通知中心已接入,也不得隐藏真实业务证据、reviewerRouting、approval 或审计状态,不改 OAuth/JWT/auth core;`frontend/src/features/pmflow-os/pages/AccountPage.tsx` 仅做薄入口并复用既有退出登录 bottom sheet。 -- `frontend/src/features/pmflow-os/` 当前承载新的对话驱动前端产品域:`store.tsx` 提供共享 truth store;真实模式通过 `?projectId=` 调用 `frontend/src/api/pmflowOs.ts` 读取后端 `ProjectTruthState` projection,并调用真实 message、`proposed_items` extraction、ChangeItem `accept/edit/defer`、ChangeSet `commit`、review decision、Execution Handoff create/consume、execution progress writeback、release validation run create、release feedback ingest、单条 iteration recommendation -> ChangeSet draft 与同组 iteration recommendation 批量 -> ChangeSet draft API,成功后 refetch truth-state / execution-summary;显式 `?demo=1` 才启用 `command_idle -> ai_extracting -> extraction_ready -> review_open -> writeback_success` 本地演示状态机,以及本地 `promoteAcceptedToGraph` 等演示动作。`pages/` 下包含 `CommandCenter / StructuredExtraction / ChangeReview / MemoryWritebackResult / WorkGraph / ExecutionBridge / ReleaseGrowth / TeamLayer / ProjectMemory / Account` 主页面;`ChangeReviewPage` 真实模式可请求审查、通过、要求修改或拒绝当前 ChangeSet,展示审查状态、建议 reviewer、审查意见和 high-risk approval gate,但不自动 commit;`TeamLayerPage` 真实模式读取 `state.reviewerRouting` 展示 ready/empty/warning、待审 ChangeSet、来源、风险等级、建议审查人或 fallback role、审查原因、待审 item 数、证据数、审查状态、审查意见和请求/决策时间,动作跳转 `/change-review?projectId=&changeSetId=`;`ExecutionBridgePage` 已支持创建 handoff、运行端领取确认和执行进度回写;`ReleaseGrowthPage` 真实模式展示 `execution-summary.releaseGate`、execution evidence、阻塞原因、下一步动作、`latestValidationRun` 和 `releaseSignals`,可创建 release validation run、摄取发布后 feedback、展示 recommendation quality grouping、从真实 recommendation 单条或同组批量生成下一轮待确认 ChangeSet draft,并消费 Account notification preference 静默强调型提醒,但不隐藏真实证据、审计状态或发布后信号;静态发布/增长原型仅保留在显式 demo 模式;后端只记录 review decision/progress/release gate/release validation/release feedback/recommendation draft 元数据,不执行本机命令、真实发布命令、外部支付、真实推送、自动 Project Memory commit 或正式任务创建;`ProjectMemoryPage` 在真实模式消费后端 `truth_nodes + semantic_facts` 的 `projectMemory` 投影,真实模式下 accepted/edited ChangeItem 只有 commit 成功后才进入 Project Memory。`components/` 下除了 `ProjectStageRail / ThreadComposer / ExtractionRail / ChangeGroupPanel / ObjectGraphSection / ContextPacketPanel / ExecutionTimeline / ReleaseGatePanel / TeamFeedPanel` 之外,新增 `ProjectStructuredLoop / PMFlowOsFrame / AccountControls / FeedbackPrimitives` 等行为组件,用来承接项目指挥台、结构化提取、待确认审查、项目记忆写入结果、项目内面包屑、项目切换器、账号退出登录交互,以及 `PageBanner / InlineNotice / HumanDecisionBar / ConfirmDialog / MobileBottomSheet` 这组契约化提示提醒组件;`components/MobileBottomNav.tsx` 是正式应用壳唯一移动底部导航,固定为 `空间 / 指挥台 / 待确认 / 动态 / 我的`。 +- `frontend/src/features/pmflow-os/` 当前承载新的对话驱动前端产品域:`store.tsx` 提供共享 truth store;真实模式通过 `?projectId=` 调用 `frontend/src/api/pmflowOs.ts` 读取后端 `ProjectTruthState` projection,并调用真实 message、`proposed_items` extraction、ChangeItem `accept/edit/defer`、ChangeSet `commit`、review decision、Execution Handoff create/consume、execution progress writeback、release validation run create、release feedback ingest、单条 iteration recommendation -> ChangeSet draft、同组 iteration recommendation 批量 -> ChangeSet draft、capacity-plan 与 capacity override simulate/apply API,成功后 refetch truth-state / execution-summary / capacity plan;显式 `?demo=1` 才启用本地演示状态机。`pages/` 下包含 `CommandCenter / StructuredExtraction / ChangeReview / MemoryWritebackResult / WorkGraph / ExecutionBridge / ReleaseGrowth / TeamLayer / ProjectMemory / Account` 主页面;`ChangeReviewPage` 真实模式可请求审查、通过、要求修改或拒绝当前 ChangeSet,展示审查状态、建议 reviewer、审查意见和 high-risk approval gate,但不自动 commit;`TeamLayerPage` 真实模式读取 `state.reviewerRouting`、`execution-capabilities` 与 `capacity-plan`,展示待审 ChangeSet、建议 reviewer、审查状态、执行能力、个人执行准备度、工具准备度、团队今日容量、动态基线、瓶颈、AI 预处理建议、负责人模拟/应用容量 override 与调整记录;capacity override 只写审计,不自动分派、不自动审查、不写 ChangeSet;`ExecutionBridgePage` 已支持创建 handoff、运行端领取确认和执行进度回写;`ReleaseGrowthPage` 真实模式展示 `execution-summary.releaseGate`、execution evidence、阻塞原因、下一步动作、`latestValidationRun` 和 `releaseSignals`;静态发布/增长原型仅保留在显式 demo 模式;后端只记录 review decision/progress/release gate/release validation/release feedback/recommendation draft/capacity override 元数据,不执行本机命令、真实发布命令、外部支付、真实推送、自动 Project Memory commit、自动任务分配或正式任务创建;`ProjectMemoryPage` 在真实模式消费后端 `truth_nodes + semantic_facts` 的 `projectMemory` 投影。`components/` 下除了 `ProjectStageRail / ThreadComposer / ExtractionRail / ChangeGroupPanel / ObjectGraphSection / ContextPacketPanel / ExecutionTimeline / ReleaseGatePanel / TeamFeedPanel` 之外,新增 `ProjectStructuredLoop / PMFlowOsFrame / AccountControls / FeedbackPrimitives` 等行为组件;`components/MobileBottomNav.tsx` 是正式应用壳唯一移动底部导航,固定为 `空间 / 指挥台 / 待确认 / 动态 / 我的`。 - `ProjectTruthState.feed` 现在是 Team Feed 只读动态流,由 `backend/app/services/pmflow_os_projection_service.py` 聚合 review request、review decision、ChangeSet 状态、execution progress 与 release gate 事件;`TeamLayerPage` 复用 `TeamFeedPanel` 展示 `state.feed`,只做摘要、空态和跳转,不写审批、不写 ChangeSet、不写通知,也不在真实模式用 mock 动态补齐。 - 旧登录实现 `frontend/src/components/ui/sign-in.tsx` 与 `frontend/src/components/ui/demo.tsx` 已移除,`frontend/src/components/login/` 是当前唯一登录真源。 @@ -245,7 +249,7 @@ uvicorn app.main:app --reload --host 0.0.0.0 --port 8000 - 真实飞书网页 E2E 依赖本机已登录的 Chrome/Feishu 会话,结果校验统一走数据库探针。 - 路由配置唯一真源固定为 `backend/.control_plane/pmflow-routing-rules.json`;请求超时统一以 `LLM_REQUEST_TIMEOUT` 为准。 - World Model 静态层已接入 Redis 缓存(`wm:static:{tenant_id}:{project_id}`,TTL 600s),写操作后主动 invalidate,动态层不缓存。 - - `signal_sources` 仅作为 AI Steward 创建结果区的内联展示;项目看板与 SSTC 信号阶段统一只读正式 `Signal`。 + - `signal_sources` 仅作为历史创建结果区的兼容展示字段;项目看板与 SSTC 信号阶段统一只读正式 `Signal`。 ## 12. 技术债务清单 - [x] `mixin_auto.py` `_safe_int` NameError:已通过显式导入 `_safe_int` + 回归测试锁定,避免后续共享导出调整导致运行时未定义 @@ -258,7 +262,7 @@ uvicorn app.main:app --reload --host 0.0.0.0 --port 8000 - [x] AdaptiveRAG 职责已完成首轮收敛:`answer_question` 入口已分层为 `prepare runtime` 与 `base mainline` 两段,写确认逻辑通过 `_should_trigger_write_ack_gate` 独立门控;基础问答主路径不再与写确认判断混写在同一函数体。分层契约由 `test_adaptive_rag_mainline_contract.py` 锁定(read 绕过 ack、write 命中 ack、feishu write 维持主链) - [x] 执行层已形成统一执行账本与参数来源约束:ingress 执行路径统一通过 `KernelIngressStorageMixin` 生成 `execution_result.action_contract`(固定 `param_source=decision.execution_plan.actions+workflow_route+execution_result`)并写入 `operation_execution_ledger`(`applied/pending_manual/pending_confirmation/failed`);`mixin_auto.py`、`mixin_prepare_manual.py` 的 `operation_code` 日志来源已对齐 `action_contract`,回归由 `test_ai_pmo_execution_ledger_contract.py` 锁定 - [x] QA/RAG 路由契约双轨已收敛:`/ai-steward/rag/ask` 兼容入口已移除,问答统一收敛到 `POST /rag/ask`;参数构造与 tenant 解析收口在 `backend/app/api/rag_ask_contract.py`,前端 `FloatingQAChat` 与 Context Cache API 目录均已对齐,`backend/tests/test_rag_route_contract.py` 与 `backend/tests/test_context_cache_profile_catalog.py` 已锁定回归 -- [x] 三模式交互上下文基线已显性化:`/rag/ask`、QA session detail 与 session state replay 现统一透出 `route_decision / clarification_code / clarification_candidates / answer_mode / grounding_status / evidence_pack / project_operating_context / execution_recipe / fit_brief / external_recommendation / analysis_packet / handoff_mode / can_enter_plan / recommended_next_step`;前端 `FloatingQAChat` 会在即时回答与历史回放两条路径都接入这批字段,`QAConversationStatusBar` 与 `QAResponseMetaCard` 显式展示当前对象、来源模式、仓库/执行通道、项目画像、分析结果、外部推荐状态与下一步;`/ai-pmo/process-text` 与 `GET /ai-pmo/work-items` 现同步投影 `plan_mode / plan_status / requirement_plan_context_summary` 到 Inbox 展开区,回归由 `test_ai_pmo_qa_http_mainline_e2e.py`、`test_ai_pmo_ingress_mainline.py`、`FloatingQAChat.test.tsx`、`QAResponseMetaCard.test.tsx`、`InboxListItem.test.tsx` 锁定 +- [x] 三模式交互上下文基线已显性化:`/rag/ask`、QA session detail 与 session state replay 现统一透出 `route_decision / clarification_code / clarification_candidates / answer_mode / grounding_status / evidence_pack / project_operating_context / execution_recipe / fit_brief / external_recommendation / analysis_packet / handoff_mode / can_enter_plan / recommended_next_step`;前端 `FloatingQAChat` 会在即时回答与历史回放两条路径都接入这批字段,`QAConversationStatusBar` 与 `QAResponseMetaCard` 显式展示当前对象、来源模式、仓库/执行通道、项目画像、分析结果、外部推荐状态与下一步;`/ai-pmo/process-text` 与 `GET /ai-pmo/work-items` 现同步投影 `plan_mode / plan_status / requirement_plan_context_summary`,旧独立收件页已退场,相关待处理状态由 Space 兼容读模型消费 - [x] AI 管家浮窗交互已与 Grounding Runtime 对齐:回答区已切到 markdown 渲染,顾问标签升级为显式交互按钮;`consultant_mode_preference` 只用于 `/rag/ask` 内部重答,`允许执行` 明确交接到 `/ai-pmo/process-text`,避免 query/execute 边界回退到旧的一锅煮 - [x] Retrieval source registry / metadata 已统一:AdaptiveRAG 检索链已建立统一 source registry / metadata normalizer,`citations[]/sources_used[]` 直接消费标准字段 `source_type/source_id/chunk_id/target_url/project_id/updated_at/permission_scope/source_priority`;`doc/url/feishu/pmflow/graph/db` 的来源元数据与排序键已收口,并由 `test_rag_route_contract.py`、`test_adaptive_rag_mainline_contract.py` 锁定 - [x] 语义治理 baseline 已清零:`backend/app` 运行时代码中的 `looks_like_* / *_HINTS / *_KEYWORDS` 残留已清空,主链统一改为对象/上下文绑定 + `SemanticContextPacket -> SemanticDecision -> gatekeeper`;`scripts/semantic_lint_baseline.txt` 已清空,并由 `scripts/semantic_lint.py` 与 `backend/tests/test_semantic_lint.py` 做仓库级守门 diff --git a/backend/alembic/versions/20260427_add_human_capacity_overrides.py b/backend/alembic/versions/20260427_add_human_capacity_overrides.py new file mode 100644 index 00000000..5798f4f9 --- /dev/null +++ b/backend/alembic/versions/20260427_add_human_capacity_overrides.py @@ -0,0 +1,70 @@ +"""add human capacity overrides + +Revision ID: 20260427_add_human_capacity_overrides +Revises: 20260426_add_pmflow_change_review_tables +Create Date: 2026-04-27 +""" + +from __future__ import annotations + +from alembic import op +import sqlalchemy as sa + +try: + from sqlalchemy.dialects.postgresql import JSONB +except Exception: # pragma: no cover + JSONB = sa.JSON + + +revision = "20260427_add_human_capacity_overrides" +down_revision = "20260426_add_pmflow_change_review_tables" +branch_labels = None +depends_on = None + + +def _json_type(bind): + return JSONB() if bind.dialect.name == "postgresql" else sa.JSON() + + +def _table_exists(bind, table_name: str) -> bool: + return sa.inspect(bind).has_table(table_name) + + +def upgrade() -> None: + bind = op.get_bind() + json_type = _json_type(bind) + + if not _table_exists(bind, "human_capacity_overrides"): + op.create_table( + "human_capacity_overrides", + sa.Column("id", sa.Integer(), primary_key=True, autoincrement=True), + sa.Column("tenant_id", sa.BigInteger(), nullable=False), + sa.Column("created_at", sa.TIMESTAMP(), server_default=sa.func.now(), nullable=False), + sa.Column("updated_at", sa.TIMESTAMP(), server_default=sa.func.now(), nullable=False), + sa.Column("project_id", sa.BigInteger(), sa.ForeignKey("projects.id"), nullable=False), + sa.Column("user_id", sa.BigInteger(), sa.ForeignKey("users.id"), nullable=False), + sa.Column("date", sa.Date(), nullable=False), + sa.Column("override_type", sa.String(length=32), nullable=False), + sa.Column("added_minutes", sa.Integer(), nullable=False, server_default="0"), + sa.Column("overtime_type", sa.String(length=32), nullable=True), + sa.Column("max_planned_utilization", sa.Float(), nullable=True), + sa.Column("work_type_bias", json_type, nullable=False), + sa.Column("high_risk_limit", sa.Integer(), nullable=True), + sa.Column("reason", sa.Text(), nullable=False), + sa.Column("expires_at", sa.TIMESTAMP(), nullable=False), + sa.Column("created_by", sa.BigInteger(), sa.ForeignKey("users.id"), nullable=False), + ) + op.create_index("ix_human_capacity_overrides_tenant_id", "human_capacity_overrides", ["tenant_id"], unique=False) + op.create_index("ix_human_capacity_overrides_project_date", "human_capacity_overrides", ["project_id", "date"], unique=False) + op.create_index("ix_human_capacity_overrides_user_date", "human_capacity_overrides", ["user_id", "date"], unique=False) + op.create_index("ix_human_capacity_overrides_created_by", "human_capacity_overrides", ["created_by"], unique=False) + + +def downgrade() -> None: + bind = op.get_bind() + if _table_exists(bind, "human_capacity_overrides"): + op.drop_index("ix_human_capacity_overrides_created_by", table_name="human_capacity_overrides") + op.drop_index("ix_human_capacity_overrides_user_date", table_name="human_capacity_overrides") + op.drop_index("ix_human_capacity_overrides_project_date", table_name="human_capacity_overrides") + op.drop_index("ix_human_capacity_overrides_tenant_id", table_name="human_capacity_overrides") + op.drop_table("human_capacity_overrides") diff --git a/backend/app/api/ai_routing.py b/backend/app/api/ai_routing.py new file mode 100644 index 00000000..336e0201 --- /dev/null +++ b/backend/app/api/ai_routing.py @@ -0,0 +1,124 @@ +"""AI routing and provider governance API.""" + +from __future__ import annotations + +from typing import Any, Literal, Optional + +from fastapi import APIRouter, Depends +from fastapi.responses import JSONResponse +from pydantic import BaseModel, Field +from sqlalchemy.ext.asyncio import AsyncSession + +from ..core.database import get_db +from ..core.response import error_response, success_response +from ..dependencies.auth import get_current_user +from ..dependencies.tenant import get_tenant_id +from ..models import User +from ..services.ai_routing_governance_service import AIRoutingGovernanceService +from ..services.auth import can_approve_ai_routing_change, can_operate_ai_routing, can_view_ai_policy +from ..services.operation_log_service import log_operation + +router = APIRouter(prefix="/ai-routing", tags=["ai_routing"]) + + +class TeamPolicySceneUpdate(BaseModel): + key: str = Field(min_length=1, max_length=80) + policy: str = Field(min_length=1, max_length=300) + mode: Optional[str] = Field(default=None, max_length=80) + human_confirmation: bool = True + protected: bool = True + + +class TeamPolicyUpdateRequest(BaseModel): + strategy: Literal["quality_first", "balanced", "speed_first"] = "balanced" + scenes: list[TeamPolicySceneUpdate] = Field(default_factory=list) + automation_boundary: Optional[str] = Field(default=None, max_length=300) + budget_guard: dict[str, Any] = Field(default_factory=dict) + quality_targets: list[str] = Field(default_factory=list, max_length=8) + + +def _forbidden(message: str) -> JSONResponse: + return JSONResponse(status_code=403, content=error_response(403, message)) + + +def _service(db: AsyncSession, tenant_id: int) -> AIRoutingGovernanceService: + return AIRoutingGovernanceService(db, tenant_id=int(tenant_id)) + + +@router.get("/overview") +async def get_ai_routing_overview( + db: AsyncSession = Depends(get_db), + tenant_id: int = Depends(get_tenant_id), + current_user: User = Depends(get_current_user), +): + if not can_view_ai_policy(str(current_user.role or "")): + return _forbidden("仅团队负责人可查看 AI 路由概览") + data = await _service(db, tenant_id).get_overview() + return success_response(data) + + +@router.get("/team-policy") +async def get_ai_team_policy( + db: AsyncSession = Depends(get_db), + tenant_id: int = Depends(get_tenant_id), + current_user: User = Depends(get_current_user), +): + if not can_view_ai_policy(str(current_user.role or "")): + return _forbidden("仅团队负责人可查看团队 AI 策略") + data = await _service(db, tenant_id).get_team_policy() + return success_response(data) + + +@router.put("/team-policy") +async def update_ai_team_policy( + body: TeamPolicyUpdateRequest, + db: AsyncSession = Depends(get_db), + tenant_id: int = Depends(get_tenant_id), + current_user: User = Depends(get_current_user), +): + if not can_approve_ai_routing_change(str(current_user.role or "")): + return _forbidden("仅 OWNER 可保存团队 AI 策略") + service = _service(db, tenant_id) + before = await service.get_team_policy() + after = await service.update_team_policy( + payload=body.model_dump(mode="json"), + updated_by=int(current_user.id), + ) + await log_operation( + db, + tenant_id=int(tenant_id), + user_id=int(current_user.id), + project_id=None, + entity_type="ai_routing_policy", + entity_id="team-policy", + action="update", + before_data=before, + after_data=after, + remark="更新团队 AI 策略", + ) + await db.commit() + return success_response(after) + + +@router.get("/admin-console") +async def get_ai_routing_admin_console( + db: AsyncSession = Depends(get_db), + tenant_id: int = Depends(get_tenant_id), + current_user: User = Depends(get_current_user), +): + if not can_view_ai_policy(str(current_user.role or "")): + return _forbidden("仅团队负责人可查看 AI 路由控制台") + data = await _service(db, tenant_id).get_admin_console() + return success_response(data) + + +@router.post("/diagnostics/run") +async def run_ai_routing_diagnostics( + db: AsyncSession = Depends(get_db), + tenant_id: int = Depends(get_tenant_id), + current_user: User = Depends(get_current_user), +): + if not can_operate_ai_routing(str(current_user.role or "")): + return _forbidden("仅 OWNER / REVIEWER 可执行 AI 路由诊断") + data = await _service(db, tenant_id).run_diagnostics() + return success_response(data) diff --git a/backend/app/api/ai_steward_api/routes_capture.py b/backend/app/api/ai_steward_api/routes_capture.py index e4ba9266..c3bc706d 100644 --- a/backend/app/api/ai_steward_api/routes_capture.py +++ b/backend/app/api/ai_steward_api/routes_capture.py @@ -1,46 +1,14 @@ from __future__ import annotations from .shared import * -from .schemas import BrowserCaptureRequest, FeishuCaptureRequest, GitHubCaptureRequest, ProjectFromTextRequest, ProjectFromTextResponse +from .schemas import BrowserCaptureRequest, FeishuCaptureRequest, GitHubCaptureRequest -@router.post("/project-from-text", response_model=ApiResponse[ProjectFromTextResponse]) -async def create_project_from_text( - request: ProjectFromTextRequest, - current_user: User = Depends(get_current_user), - db: AsyncSession = Depends(get_db), - tenant_id: int = Depends(get_tenant_id) -): - """ - 零配置项目创建 - 从一句话生成完整项目 - - 示例输入: - ``` - 我们要做一个 AI 客服工具,基于 GPT-4o mini,对标 XX 竞品,3 周上线 - ``` - - AI 将自动生成: - - 完整的项目配置 - - 竞品分析信号 - - 项目宪章 - - 任务链(含依赖关系) - - 风险预警规则 - """ - try: - service = get_ai_steward_service(db) - - result = await service.create_project_from_natural_language( - input_text=request.input_text, - creator_id=int(current_user.id), - tenant_id=tenant_id - ) - - return success_response(ProjectFromTextResponse(**result).model_dump()) - - except Exception as e: - return JSONResponse( - status_code=500, - content=error_response(ErrorCode.INTERNAL_ERROR, f"项目创建失败: {str(e)}"), - ) +@router.post("/project-from-text") +async def create_project_from_text(): + return JSONResponse( + status_code=410, + content=error_response(410, "旧项目创建入口已下线,请使用新项目启动台生成草案并确认创建。"), + ) @router.post("/signal/capture-feishu") @audited_write(caller="ai") diff --git a/backend/app/api/ai_steward_api/schemas.py b/backend/app/api/ai_steward_api/schemas.py index 11f38ca9..b3df2ed0 100644 --- a/backend/app/api/ai_steward_api/schemas.py +++ b/backend/app/api/ai_steward_api/schemas.py @@ -2,46 +2,6 @@ from __future__ import annotations from .shared import * -class ProjectFromTextRequest(BaseModel): - """零配置项目创建请求""" - input_text: str = Field( - ..., - description="自然语言项目描述,例如:我们要做一个 AI 客服工具,基于 GPT-4o mini,对标 XX 竞品,3 周上线", - min_length=10, - max_length=500 - ) - -class ProjectFromTextSignalSource(BaseModel): - registry_id: int | str | None = None - id: int | str | None = None - project_id: int | str | None = None - signal_id: int | str | None = None - source_kind: str - source_name: str - source_identifier: str - display_source_kind: Optional[str] = None - technical_identifier: Optional[str] = None - description: Optional[str] = None - requested_via: Optional[str] = None - status: Optional[str] = None - onboarding_status: Optional[str] = None - section: Optional[str] = None - title: Optional[str] = None - priority: Optional[str] = None - origin: Optional[str] = None - raw_context: Optional[Dict[str, Any]] = None - config: Optional[Dict[str, Any]] = None - -class ProjectFromTextResponse(BaseModel): - """零配置项目创建响应""" - project: Dict[str, Any] - signal: Dict[str, Any] - charter: Dict[str, Any] - tasks: List[Dict[str, Any]] - risks: List[Dict[str, Any]] - signal_sources: List[ProjectFromTextSignalSource] = Field(default_factory=list) - analysis: Dict[str, Any] - class CognitiveConflictResponse(BaseModel): """认知冲突响应""" id: str diff --git a/backend/app/api/legacy_ai_steward_api.py b/backend/app/api/legacy_ai_steward_api.py new file mode 100644 index 00000000..156bd096 --- /dev/null +++ b/backend/app/api/legacy_ai_steward_api.py @@ -0,0 +1,16 @@ +from __future__ import annotations + +from fastapi import APIRouter +from starlette.responses import JSONResponse + +from ..core.response import error_response + +router = APIRouter(prefix="/ai-steward", tags=["AI Steward Legacy"]) + + +@router.post("/project-from-text") +async def create_project_from_text_legacy_gone(): + return JSONResponse( + status_code=410, + content=error_response(410, "旧项目创建入口已下线,请使用新项目启动台生成草案并确认创建。"), + ) diff --git a/backend/app/api/pmflow_os_api/__init__.py b/backend/app/api/pmflow_os_api/__init__.py index 6f7866d4..f190a079 100644 --- a/backend/app/api/pmflow_os_api/__init__.py +++ b/backend/app/api/pmflow_os_api/__init__.py @@ -1,6 +1,8 @@ from __future__ import annotations -from fastapi import APIRouter, Depends, HTTPException +from datetime import date as Date + +from fastapi import APIRouter, Depends, HTTPException, Query from fastapi.responses import JSONResponse from sqlalchemy import and_, select from sqlalchemy.ext.asyncio import AsyncSession @@ -10,6 +12,7 @@ from ...core.response import error_response, success_response from ...dependencies import get_current_user from ...models import Project, User, UserProject from ...schemas.pmflow_os import ( + PMFlowCapacityOverridePayload, PMFlowChangeItemDeferRequest, PMFlowChangeItemEditRequest, PMFlowChangeSetCommitRequest, @@ -25,8 +28,10 @@ from ...schemas.pmflow_os import ( PMFlowThreadMessageCreateRequest, ) from ...services.auth import has_any_role +from ...services.adaptive_capacity_service import AdaptiveCapacityService from ...services.pmflow_change_review_service import PMFlowChangeReviewError, PMFlowChangeReviewService from ...services.pmflow_os_projection_service import PMFlowOSProjectionService +from ...services.team_execution_capability_service import TeamExecutionCapabilityService router = APIRouter(prefix="/pmflow-os", tags=["pmflow-os"]) @@ -62,6 +67,20 @@ async def _ensure_project_member_or_manager( raise HTTPException(status_code=403, detail="forbidden") +def _ensure_capacity_override_manager(current_user: User) -> None: + if not has_any_role(str(getattr(current_user, "role", "") or ""), ["OWNER", "REVIEWER"]): + raise HTTPException(status_code=403, detail="forbidden") + + +def _parse_capacity_date(value: str | None) -> Date | None: + if not value: + return None + try: + return Date.fromisoformat(str(value)[:10]) + except ValueError as exc: + raise HTTPException(status_code=422, detail="invalid_date") from exc + + @router.get("/projects/{project_id}/truth-state") async def get_pmflow_os_truth_state( project_id: int, @@ -292,6 +311,137 @@ async def get_pmflow_os_execution_summary( return success_response(payload.model_dump(mode="json")) +@router.get("/projects/{project_id}/execution-capabilities") +async def get_pmflow_os_execution_capabilities( + project_id: int, + db: AsyncSession = Depends(get_db), + current_user: User = Depends(get_current_user), +): + tenant_id = int(current_user.tenant_id) + await _ensure_project_member_or_manager( + db, + tenant_id=tenant_id, + project_id=int(project_id), + current_user=current_user, + ) + service = TeamExecutionCapabilityService(db, tenant_id=tenant_id) + try: + payload = await service.get_snapshot( + project_id=int(project_id), + current_user_id=int(current_user.id), + ) + except ValueError as exc: + if str(exc) == "project_not_found": + raise HTTPException(status_code=404, detail="project_not_found") from exc + raise + return success_response(payload.model_dump(mode="json")) + + +@router.get("/projects/{project_id}/capacity-plan") +async def get_pmflow_os_capacity_plan( + project_id: int, + plan_date: str | None = Query(default=None, alias="date"), + db: AsyncSession = Depends(get_db), + current_user: User = Depends(get_current_user), +): + tenant_id = int(current_user.tenant_id) + await _ensure_project_member_or_manager( + db, + tenant_id=tenant_id, + project_id=int(project_id), + current_user=current_user, + ) + service = AdaptiveCapacityService(db, tenant_id=tenant_id) + try: + payload = await service.get_plan( + project_id=int(project_id), + current_user_id=int(current_user.id), + plan_date=_parse_capacity_date(plan_date), + ) + except ValueError as exc: + if str(exc) == "project_not_found": + raise HTTPException(status_code=404, detail="project_not_found") from exc + raise + return success_response(payload.model_dump(mode="json")) + + +@router.post("/projects/{project_id}/capacity-overrides/simulate") +async def simulate_pmflow_os_capacity_override( + project_id: int, + body: PMFlowCapacityOverridePayload, + db: AsyncSession = Depends(get_db), + current_user: User = Depends(get_current_user), +): + tenant_id = int(current_user.tenant_id) + await _ensure_project_member_or_manager( + db, + tenant_id=tenant_id, + project_id=int(project_id), + current_user=current_user, + ) + _ensure_capacity_override_manager(current_user) + service = AdaptiveCapacityService(db, tenant_id=tenant_id) + try: + payload = await service.simulate_override( + project_id=int(project_id), + current_user_id=int(current_user.id), + payload=body, + ) + except ValueError as exc: + if str(exc) == "capacity_member_not_found": + raise HTTPException(status_code=404, detail="capacity_member_not_found") from exc + raise + return success_response(payload.model_dump(mode="json")) + + +@router.post("/projects/{project_id}/capacity-overrides") +async def create_pmflow_os_capacity_override( + project_id: int, + body: PMFlowCapacityOverridePayload, + db: AsyncSession = Depends(get_db), + current_user: User = Depends(get_current_user), +): + tenant_id = int(current_user.tenant_id) + await _ensure_project_member_or_manager( + db, + tenant_id=tenant_id, + project_id=int(project_id), + current_user=current_user, + ) + _ensure_capacity_override_manager(current_user) + service = AdaptiveCapacityService(db, tenant_id=tenant_id) + try: + payload = await service.apply_override( + project_id=int(project_id), + current_user_id=int(current_user.id), + payload=body, + ) + except ValueError as exc: + if str(exc) == "capacity_member_not_found": + raise HTTPException(status_code=404, detail="capacity_member_not_found") from exc + raise + return success_response(payload.model_dump(mode="json")) + + +@router.get("/projects/{project_id}/capacity-overrides") +async def list_pmflow_os_capacity_overrides( + project_id: int, + plan_date: str | None = Query(default=None, alias="date"), + db: AsyncSession = Depends(get_db), + current_user: User = Depends(get_current_user), +): + tenant_id = int(current_user.tenant_id) + await _ensure_project_member_or_manager( + db, + tenant_id=tenant_id, + project_id=int(project_id), + current_user=current_user, + ) + service = AdaptiveCapacityService(db, tenant_id=tenant_id) + payload = await service.list_overrides(project_id=int(project_id), plan_date=_parse_capacity_date(plan_date)) + return success_response([item.model_dump(mode="json") for item in payload]) + + @router.post("/projects/{project_id}/execution-handoffs") async def create_pmflow_os_execution_handoff( project_id: int, diff --git a/backend/app/api/router.py b/backend/app/api/router.py index 4e334b48..d7df9030 100644 --- a/backend/app/api/router.py +++ b/backend/app/api/router.py @@ -32,7 +32,10 @@ CORE_ROUTE_MODULES = ( "task_briefs", "pmflow_os_api", "pmflow_launch_api", + "legacy_ai_steward_api", "account_settings_api", + "ai_routing", + "model_routing", ) OPTIONAL_ROUTE_GROUPS = { @@ -45,7 +48,6 @@ OPTIONAL_ROUTE_GROUPS = { "ai_logs_api", "prompt_profiles", "signal_sources_api", - "model_routing", ), } diff --git a/backend/app/models/__init__.py b/backend/app/models/__init__.py index 809870ec..0b5f05c9 100644 --- a/backend/app/models/__init__.py +++ b/backend/app/models/__init__.py @@ -7,6 +7,7 @@ from .user_project import UserProject from .skill import SkillTag from .user_skill_binding import UserSkillBinding from .user_workload import UserWorkload +from .human_capacity_override import HumanCapacityOverride from .signal import Signal from .signal_edit import SignalEdit from .signal_feedback import SignalFeedback @@ -122,6 +123,7 @@ __all__ = [ "SkillTag", "UserSkillBinding", "UserWorkload", + "HumanCapacityOverride", "Signal", "SignalEdit", "SignalFeedback", diff --git a/backend/app/models/human_capacity_override.py b/backend/app/models/human_capacity_override.py new file mode 100644 index 00000000..a27f9a4b --- /dev/null +++ b/backend/app/models/human_capacity_override.py @@ -0,0 +1,28 @@ +"""Human capacity override audit model.""" + +from __future__ import annotations + +from typing import Any, Optional + +from sqlalchemy import BigInteger, Date, Float, ForeignKey, Integer, JSON, String, Text, TIMESTAMP +from sqlalchemy.orm import Mapped, mapped_column + +from .base import Base + + +class HumanCapacityOverride(Base): + __tablename__ = "human_capacity_overrides" + + id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True) + project_id: Mapped[int] = mapped_column(BigInteger, ForeignKey("projects.id"), nullable=False, index=True) + user_id: Mapped[int] = mapped_column(BigInteger, ForeignKey("users.id"), nullable=False, index=True) + date: Mapped[Any] = mapped_column(Date, nullable=False, index=True) + override_type: Mapped[str] = mapped_column(String(32), nullable=False) + added_minutes: Mapped[int] = mapped_column(Integer, nullable=False, default=0) + overtime_type: Mapped[Optional[str]] = mapped_column(String(32), nullable=True) + max_planned_utilization: Mapped[Optional[float]] = mapped_column(Float, nullable=True) + work_type_bias: Mapped[dict[str, Any]] = mapped_column(JSON, nullable=False, default=dict) + high_risk_limit: Mapped[Optional[int]] = mapped_column(Integer, nullable=True) + reason: Mapped[str] = mapped_column(Text, nullable=False) + expires_at: Mapped[Any] = mapped_column(TIMESTAMP, nullable=False) + created_by: Mapped[int] = mapped_column(BigInteger, ForeignKey("users.id"), nullable=False, index=True) diff --git a/backend/app/schemas/pmflow_os.py b/backend/app/schemas/pmflow_os.py index 0e5a108b..5df68df5 100644 --- a/backend/app/schemas/pmflow_os.py +++ b/backend/app/schemas/pmflow_os.py @@ -40,6 +40,28 @@ PMFlowReviewerConfidence = Literal["low", "medium", "high"] PMFlowFallbackReviewerRole = Literal["OWNER", "REVIEWER", "MEMBER"] PMFlowReviewDecisionAction = Literal["request_review", "approve", "request_changes", "reject"] PMFlowApprovalState = Literal["not_requested", "requested", "approved", "changes_requested", "rejected"] +PMFlowExecutionCapabilityMode = Literal[ + "ai_direct", + "ai_with_user_acceptance", + "ai_with_technical_review", + "needs_human_judgement", + "blocked", +] +PMFlowExecutionCapabilityStatus = Literal["ready", "partial", "blocked", "unknown"] +PMFlowHumanWorkType = Literal["focus", "review", "acceptance", "coordination"] +PMFlowCapacityStatus = Literal["normal", "attention", "near_full", "overloaded", "blocked", "manually_extended"] +PMFlowCapacityFitStatus = Literal["recommended", "allowed_with_warning", "not_recommended", "blocked"] +PMFlowCapacityOverrideType = Literal["extend_minutes", "overtime", "lock_capacity", "work_type_bias", "high_risk_exception"] +PMFlowOvertimeType = Literal["general", "focus", "review", "release_guard"] +PMFlowHumanTouchSourceType = Literal[ + "changeset_confirmation", + "review", + "acceptance", + "release_decision", + "context_completion", + "ai_failure_recovery", +] +PMFlowHumanTouchBindingType = Literal["hard_bound", "role_bound", "ai_shiftable"] PMFlowObjectType = Literal[ "Requirement", "Decision", @@ -392,3 +414,185 @@ class ExecutionSummaryProjection(BaseModel): recentProgressEvents: list[dict[str, Any]] = Field(default_factory=list) recentDeliveries: list[dict[str, Any]] = Field(default_factory=list) warnings: list[str] = Field(default_factory=list) + + +class PMFlowExecutionModeCapability(BaseModel): + mode: PMFlowExecutionCapabilityMode + label: str + status: PMFlowExecutionCapabilityStatus = "unknown" + reason: str + targetHref: Optional[str] = None + + +class PMFlowToolReadinessItem(BaseModel): + key: str + label: str + status: PMFlowExecutionCapabilityStatus = "unknown" + summary: str + href: Optional[str] = None + + +class PMFlowPersonalExecutionReadiness(BaseModel): + status: PMFlowExecutionCapabilityStatus = "unknown" + toolReadiness: list[PMFlowToolReadinessItem] = Field(default_factory=list) + waitingForUser: dict[str, int] = Field(default_factory=dict) + blockers: list[str] = Field(default_factory=list) + recommendedNextStep: str = "回到项目主流程查看下一步。" + targetHref: str + + +class PMFlowTeamCapacitySignal(BaseModel): + status: PMFlowExecutionCapabilityStatus = "unknown" + label: str + workloadRatio: float = 0.0 + pendingReviewCount: int = 0 + activeExecutionCount: int = 0 + + +class PMFlowTeamContextSignal(BaseModel): + status: PMFlowExecutionCapabilityStatus = "unknown" + label: str + evidenceCount: int = 0 + recentContributionCount: int = 0 + + +class PMFlowTeamToolSignal(BaseModel): + status: PMFlowExecutionCapabilityStatus = "unknown" + label: str + connectedTools: list[str] = Field(default_factory=list) + + +class PMFlowTeamExecutionSignal(BaseModel): + userId: str + displayName: str + role: str + judgementResponsibilities: list[str] = Field(default_factory=list) + reviewResponsibilities: list[str] = Field(default_factory=list) + capacity: PMFlowTeamCapacitySignal + context: PMFlowTeamContextSignal + toolStatus: PMFlowTeamToolSignal + referenceSkills: list[str] = Field(default_factory=list) + recommendedModes: list[PMFlowExecutionCapabilityMode] = Field(default_factory=list) + warnings: list[str] = Field(default_factory=list) + + +class PMFlowWorkTypeDistribution(BaseModel): + focus: float = 0.0 + review: float = 0.0 + acceptance: float = 0.0 + coordination: float = 0.0 + + +class PMFlowWorkTypeMinutes(BaseModel): + focus: int = 0 + review: int = 0 + acceptance: int = 0 + coordination: int = 0 + total: int = 0 + + +class PMFlowHumanTouchEstimate(BaseModel): + sourceType: PMFlowHumanTouchSourceType + sourceId: str + targetHref: str + workType: PMFlowHumanWorkType + estimatedMinutes: int + riskLevel: PMFlowRiskLevel = "low" + urgency: str = "normal" + bindingType: PMFlowHumanTouchBindingType = "role_bound" + assignedUserId: Optional[str] = None + roleBinding: Optional[str] = None + contextReadiness: PMFlowExecutionCapabilityStatus = "unknown" + aiReductionPotentialMinutes: int = 0 + title: str + summary: str + + +class PMFlowCapacityFit(BaseModel): + workType: PMFlowHumanWorkType + label: str + status: PMFlowCapacityFitStatus + estimatedMinutes: int + afterTotalUtilization: float + afterTypeShare: float + reason: str + + +class PMFlowCapacityOverridePayload(BaseModel): + userId: str + date: Optional[str] = None + overrideType: PMFlowCapacityOverrideType + addedMinutes: int = Field(default=0, ge=0, le=720) + overtimeType: Optional[PMFlowOvertimeType] = None + maxPlannedUtilization: Optional[float] = Field(default=None, ge=0.5, le=1.25) + workTypeBias: dict[str, float] = Field(default_factory=dict) + highRiskLimit: Optional[int] = Field(default=None, ge=0, le=20) + reason: str = Field(..., min_length=2, max_length=500) + expiresAt: Optional[str] = None + + +class PMFlowCapacityOverrideRecord(BaseModel): + id: str + userId: str + date: str + overrideType: PMFlowCapacityOverrideType + addedMinutes: int = 0 + overtimeType: Optional[PMFlowOvertimeType] = None + maxPlannedUtilization: Optional[float] = None + workTypeBias: dict[str, float] = Field(default_factory=dict) + highRiskLimit: Optional[int] = None + reason: str + expiresAt: str + createdBy: str + createdAt: Optional[str] = None + + +class PMFlowMemberCapacityPlan(BaseModel): + userId: str + displayName: str + role: str + baseAvailableMinutes: int = 480 + calendarBlockedMinutes: int = 0 + effectiveMinutes: int = 480 + reservedBufferMinutes: int = 72 + maxPlannedUtilization: float = 0.85 + suggestedBaseline: PMFlowWorkTypeDistribution = Field(default_factory=PMFlowWorkTypeDistribution) + currentMix: PMFlowWorkTypeDistribution = Field(default_factory=PMFlowWorkTypeDistribution) + plannedTouchMinutes: PMFlowWorkTypeMinutes = Field(default_factory=PMFlowWorkTypeMinutes) + capacityStatus: PMFlowCapacityStatus = "normal" + remainingMinutes: int = 0 + bottleneckType: Optional[PMFlowHumanWorkType] = None + fatigueRisk: dict[str, str] = Field(default_factory=dict) + riskDensity: dict[str, int] = Field(default_factory=dict) + canTakeMore: list[PMFlowCapacityFit] = Field(default_factory=list) + manualOverride: Optional[PMFlowCapacityOverrideRecord] = None + explanations: list[str] = Field(default_factory=list) + + +class PMFlowDailyCapacityPlan(BaseModel): + projectId: str + date: str + generatedAt: str + teamDemandMix: PMFlowWorkTypeDistribution = Field(default_factory=PMFlowWorkTypeDistribution) + teamEffectiveMinutes: int = 0 + teamPlannedTouchMinutes: PMFlowWorkTypeMinutes = Field(default_factory=PMFlowWorkTypeMinutes) + memberPlans: list[PMFlowMemberCapacityPlan] = Field(default_factory=list) + humanTouches: list[PMFlowHumanTouchEstimate] = Field(default_factory=list) + warnings: list[str] = Field(default_factory=list) + recommendations: list[str] = Field(default_factory=list) + + +class PMFlowCapacityOverrideSimulation(BaseModel): + beforePlan: PMFlowMemberCapacityPlan + afterPlan: PMFlowMemberCapacityPlan + impactSummary: list[str] = Field(default_factory=list) + warnings: list[str] = Field(default_factory=list) + + +class PMFlowExecutionCapabilitySnapshot(BaseModel): + projectId: str + executionModes: list[PMFlowExecutionModeCapability] = Field(default_factory=list) + personalReadiness: PMFlowPersonalExecutionReadiness + teamSignals: list[PMFlowTeamExecutionSignal] = Field(default_factory=list) + adaptiveCapacitySummary: Optional[PMFlowDailyCapacityPlan] = None + warnings: list[str] = Field(default_factory=list) diff --git a/backend/app/services/adaptive_capacity_service.py b/backend/app/services/adaptive_capacity_service.py new file mode 100644 index 00000000..7117c315 --- /dev/null +++ b/backend/app/services/adaptive_capacity_service.py @@ -0,0 +1,614 @@ +"""Adaptive human capacity planning for PMFlow Team Layer and Space.""" + +from __future__ import annotations + +from dataclasses import dataclass +from datetime import date, datetime, time, timedelta +from typing import Any, Optional + +from sqlalchemy import and_, select +from sqlalchemy.ext.asyncio import AsyncSession + +from ..models import DevWorkspaceBinding, HumanCapacityOverride, Project, User, UserProject, UserWorkload +from ..schemas.pmflow_os import ( + PMFlowCapacityFit, + PMFlowCapacityOverridePayload, + PMFlowCapacityOverrideRecord, + PMFlowCapacityOverrideSimulation, + PMFlowDailyCapacityPlan, + PMFlowHumanTouchEstimate, + PMFlowHumanWorkType, + PMFlowMemberCapacityPlan, + PMFlowWorkTypeDistribution, + PMFlowWorkTypeMinutes, +) +from ..utils.timezone import now_cn_naive +from .pmflow_os_projection_service import PMFlowOSProjectionService + + +WORK_TYPES: tuple[PMFlowHumanWorkType, ...] = ("focus", "review", "acceptance", "coordination") + + +@dataclass +class _MemberContext: + user: User + tools: list[str] + workload_ratio: float + + +class AdaptiveCapacityService: + """Computes daily capacity plans from current task/review/execution facts plus overrides.""" + + def __init__(self, db: AsyncSession, *, tenant_id: int) -> None: + self.db = db + self.tenant_id = int(tenant_id) + + async def get_plan( + self, + *, + project_id: int, + current_user_id: int, + plan_date: Optional[date] = None, + extra_override: Optional[PMFlowCapacityOverridePayload] = None, + ) -> PMFlowDailyCapacityPlan: + plan_date = plan_date or now_cn_naive().date() + projection = PMFlowOSProjectionService(self.db, tenant_id=self.tenant_id) + truth_state = await projection.get_truth_state(project_id=project_id, current_user_id=current_user_id) + execution_summary = await projection.get_execution_summary(project_id=project_id, current_user_id=current_user_id) + members = await self._load_members(project_id=project_id) + project = await self._load_project(project_id=project_id) + member_ids = [int(ctx.user.id) for ctx in members] + overrides = await self._load_active_overrides(project_id=project_id, plan_date=plan_date, member_ids=member_ids) + if extra_override is not None: + overrides[int(extra_override.userId)] = self._payload_to_record(extra_override, plan_date=plan_date, created_by=current_user_id) + + touches = self._build_touches( + project_id=project_id, + owner_id=int(project.owner_id or current_user_id) if project else current_user_id, + member_ids=member_ids, + truth_state=truth_state, + execution_summary=execution_summary, + ) + team_minutes = self._sum_minutes(touches) + team_mix = self._distribution(team_minutes) + plans = [ + self._member_plan( + ctx=ctx, + project_id=project_id, + team_mix=team_mix, + touches=[touch for touch in touches if str(touch.assignedUserId or "") == str(ctx.user.id)], + override=overrides.get(int(ctx.user.id)), + ) + for ctx in members + ] + team_effective = sum(item.effectiveMinutes for item in plans) + warnings = list(execution_summary.warnings[:6]) + if not members: + warnings.append("capacity_members_empty: 当前项目没有可计算人类容量的成员。") + if not touches: + warnings.append("capacity_touches_empty: 当前没有真实人工触点,容量计划保持空态。") + recommendations = self._recommendations(touches=touches, plans=plans) + return PMFlowDailyCapacityPlan( + projectId=str(project_id), + date=plan_date.isoformat(), + generatedAt=now_cn_naive().isoformat(), + teamDemandMix=team_mix, + teamEffectiveMinutes=team_effective, + teamPlannedTouchMinutes=team_minutes, + memberPlans=plans, + humanTouches=touches[:80], + warnings=warnings[:12], + recommendations=recommendations[:8], + ) + + async def simulate_override( + self, + *, + project_id: int, + current_user_id: int, + payload: PMFlowCapacityOverridePayload, + ) -> PMFlowCapacityOverrideSimulation: + plan_date = self._parse_date(payload.date) + before = await self.get_plan(project_id=project_id, current_user_id=current_user_id, plan_date=plan_date) + after = await self.get_plan( + project_id=project_id, + current_user_id=current_user_id, + plan_date=plan_date, + extra_override=payload, + ) + before_member = self._find_member_plan(before, payload.userId) + after_member = self._find_member_plan(after, payload.userId) + impact = [ + f"有效容量 {before_member.effectiveMinutes}min -> {after_member.effectiveMinutes}min", + f"剩余可计划容量 {before_member.remainingMinutes}min -> {after_member.remainingMinutes}min", + f"状态 {before_member.capacityStatus} -> {after_member.capacityStatus}", + ] + if payload.addedMinutes: + impact.append("加班只增加时间预算,不消除疲劳与高风险判断密度。") + return PMFlowCapacityOverrideSimulation(beforePlan=before_member, afterPlan=after_member, impactSummary=impact, warnings=after.warnings[:6]) + + async def apply_override( + self, + *, + project_id: int, + current_user_id: int, + payload: PMFlowCapacityOverridePayload, + ) -> PMFlowCapacityOverrideRecord: + plan_date = self._parse_date(payload.date) + expires_at = self._parse_expires(payload.expiresAt, plan_date=plan_date) + record = HumanCapacityOverride( + tenant_id=self.tenant_id, + project_id=int(project_id), + user_id=int(payload.userId), + date=plan_date, + override_type=payload.overrideType, + added_minutes=int(payload.addedMinutes or 0), + overtime_type=payload.overtimeType, + max_planned_utilization=payload.maxPlannedUtilization, + work_type_bias=dict(payload.workTypeBias or {}), + high_risk_limit=payload.highRiskLimit, + reason=payload.reason.strip(), + expires_at=expires_at, + created_by=int(current_user_id), + ) + self.db.add(record) + await self.db.commit() + await self.db.refresh(record) + return self._override_record(record) + + async def list_overrides(self, *, project_id: int, plan_date: Optional[date] = None) -> list[PMFlowCapacityOverrideRecord]: + plan_date = plan_date or now_cn_naive().date() + rows = ( + await self.db.execute( + select(HumanCapacityOverride) + .where( + and_( + HumanCapacityOverride.tenant_id == self.tenant_id, + HumanCapacityOverride.project_id == int(project_id), + HumanCapacityOverride.date == plan_date, + ) + ) + .order_by(HumanCapacityOverride.created_at.desc(), HumanCapacityOverride.id.desc()) + ) + ).scalars().all() + return [self._override_record(row) for row in rows] + + async def _load_project(self, *, project_id: int) -> Optional[Project]: + return ( + await self.db.execute( + select(Project).where(and_(Project.tenant_id == self.tenant_id, Project.id == int(project_id))) + ) + ).scalar_one_or_none() + + async def _load_members(self, *, project_id: int) -> list[_MemberContext]: + rows = ( + await self.db.execute( + select(User) + .join(UserProject, UserProject.user_id == User.id) + .where( + and_( + User.tenant_id == self.tenant_id, + UserProject.tenant_id == self.tenant_id, + UserProject.project_id == int(project_id), + User.is_active.is_(True), + ) + ) + .order_by(User.id.asc()) + ) + ).scalars().all() + member_ids = [int(user.id) for user in rows] + tool_map = await self._load_tools(project_id=project_id, member_ids=member_ids) + workload_map = await self._load_workloads(member_ids=member_ids) + return [ + _MemberContext(user=user, tools=tool_map.get(int(user.id), []), workload_ratio=workload_map.get(int(user.id), 0.0)) + for user in rows + ] + + async def _load_tools(self, *, project_id: int, member_ids: list[int]) -> dict[int, list[str]]: + if not member_ids: + return {} + rows = ( + await self.db.execute( + select(DevWorkspaceBinding.user_id, DevWorkspaceBinding.editor_type).where( + and_( + DevWorkspaceBinding.tenant_id == self.tenant_id, + DevWorkspaceBinding.project_id == int(project_id), + DevWorkspaceBinding.user_id.in_(member_ids), + DevWorkspaceBinding.status == "active", + ) + ) + ) + ).all() + labels = {"cursor": "Cursor", "vscode": "VS Code", "codex": "Codex"} + tool_map: dict[int, list[str]] = {} + for user_id, editor_type in rows: + tool_map.setdefault(int(user_id), []) + label = labels.get(str(editor_type or "").lower(), str(editor_type or "Local Bridge")) + if label not in tool_map[int(user_id)]: + tool_map[int(user_id)].append(label) + return tool_map + + async def _load_workloads(self, *, member_ids: list[int]) -> dict[int, float]: + if not member_ids: + return {} + rows = ( + await self.db.execute( + select(UserWorkload.user_id, UserWorkload.workload_ratio).where( + and_(UserWorkload.tenant_id == self.tenant_id, UserWorkload.user_id.in_(member_ids)) + ) + ) + ).all() + return {int(user_id): float(ratio or 0.0) for user_id, ratio in rows} + + async def _load_active_overrides( + self, + *, + project_id: int, + plan_date: date, + member_ids: list[int], + ) -> dict[int, PMFlowCapacityOverrideRecord]: + if not member_ids: + return {} + rows = ( + await self.db.execute( + select(HumanCapacityOverride) + .where( + and_( + HumanCapacityOverride.tenant_id == self.tenant_id, + HumanCapacityOverride.project_id == int(project_id), + HumanCapacityOverride.user_id.in_(member_ids), + HumanCapacityOverride.date == plan_date, + HumanCapacityOverride.expires_at >= now_cn_naive(), + ) + ) + .order_by(HumanCapacityOverride.created_at.desc(), HumanCapacityOverride.id.desc()) + ) + ).scalars().all() + overrides: dict[int, PMFlowCapacityOverrideRecord] = {} + for row in rows: + overrides.setdefault(int(row.user_id), self._override_record(row)) + return overrides + + def _build_touches(self, *, project_id: int, owner_id: int, member_ids: list[int], truth_state: Any, execution_summary: Any) -> list[PMFlowHumanTouchEstimate]: + touches: list[PMFlowHumanTouchEstimate] = [] + fallback_user = str(owner_id if owner_id in member_ids else (member_ids[0] if member_ids else owner_id)) + for item in getattr(truth_state, "changeItems", []) or []: + if self._get(item, "status") != "pending_review": + continue + risk = self._risk(self._get(item, "riskLevel") or self._get(item, "risk_level")) + minutes = 60 if risk == "high" else 35 if risk == "medium" else 20 + touches.append( + PMFlowHumanTouchEstimate( + sourceType="changeset_confirmation", + sourceId=str(self._get(item, "id") or f"change-{len(touches)}"), + targetHref=f"/change-review?projectId={project_id}&changeSetId={self._get(item, 'changeSetId') or ''}", + workType="focus", + estimatedMinutes=minutes, + riskLevel=risk, + urgency="urgent" if risk == "high" else "normal", + bindingType="hard_bound", + assignedUserId=fallback_user, + roleBinding="OWNER", + contextReadiness="partial", + aiReductionPotentialMinutes=25 if risk != "low" else 10, + title=str(self._get(item, "title") or "待确认 ChangeSet"), + summary=str(self._get(item, "summary") or "需要人类确认 AI 提取结果。"), + ) + ) + for routing in getattr(getattr(truth_state, "reviewerRouting", None), "items", []) or []: + reviewer = getattr(routing, "suggestedReviewer", None) + assigned = str(getattr(reviewer, "userId", "") or fallback_user) + risk = self._risk(getattr(routing, "riskLevel", "low")) + touches.append( + PMFlowHumanTouchEstimate( + sourceType="review", + sourceId=str(getattr(routing, "changeSetId", "")), + targetHref=str(getattr(routing, "targetUrl", "") or f"/change-review?projectId={project_id}"), + workType="review", + estimatedMinutes=75 if risk == "high" else 45 if risk == "medium" else 30, + riskLevel=risk, + urgency=str(getattr(routing, "priority", "normal")), + bindingType="role_bound", + assignedUserId=assigned, + roleBinding=str(getattr(routing, "fallbackReviewerRole", None) or "REVIEWER"), + contextReadiness="partial", + aiReductionPotentialMinutes=30 if risk == "high" else 15, + title=str(getattr(routing, "changeSetTitle", "待审 ChangeSet")), + summary=str(getattr(routing, "reviewReason", "需要人工审查 ChangeSet。")), + ) + ) + for idx, event in enumerate(execution_summary.recentProgressEvents or []): + event_type = str(event.get("event_type") or "") + if event_type not in {"pr_opened", "test_passed", "checks_passed", "handoff_consumed", "failed", "blocked"}: + continue + work_type: PMFlowHumanWorkType = "coordination" if event_type in {"failed", "blocked"} else "acceptance" + touches.append( + PMFlowHumanTouchEstimate( + sourceType="ai_failure_recovery" if work_type == "coordination" else "acceptance", + sourceId=str(event.get("id") or f"event-{idx}"), + targetHref=f"/execution-bridge?projectId={project_id}", + workType=work_type, + estimatedMinutes=45 if work_type == "coordination" else 30, + riskLevel="medium" if work_type == "coordination" else "low", + urgency="elevated" if work_type == "coordination" else "normal", + bindingType="ai_shiftable", + assignedUserId=str(event.get("user_id") or fallback_user), + roleBinding="BUILDER", + contextReadiness="partial", + aiReductionPotentialMinutes=20, + title=str(event.get("summary") or "AI 执行结果待处理"), + summary="AI 执行结果需要验收或阻塞恢复。", + ) + ) + if not execution_summary.repoBindings: + touches.append(self._coordination_touch(project_id, fallback_user, "缺少 repo 绑定", "未接入 repo,AI 执行包缺少真实代码上下文。", 30)) + if not execution_summary.promptPackage: + touches.append(self._coordination_touch(project_id, fallback_user, "缺少执行包", "尚未生成可交给 Cursor / VS Code / Codex 的执行包。", 20)) + return touches + + def _coordination_touch(self, project_id: int, assigned_user_id: str, title: str, summary: str, minutes: int) -> PMFlowHumanTouchEstimate: + return PMFlowHumanTouchEstimate( + sourceType="context_completion", + sourceId=title, + targetHref=f"/execution-bridge?projectId={project_id}", + workType="coordination", + estimatedMinutes=minutes, + riskLevel="medium", + urgency="normal", + bindingType="ai_shiftable", + assignedUserId=assigned_user_id, + roleBinding="BUILDER", + contextReadiness="blocked", + aiReductionPotentialMinutes=10, + title=title, + summary=summary, + ) + + def _member_plan( + self, + *, + ctx: _MemberContext, + project_id: int, + team_mix: PMFlowWorkTypeDistribution, + touches: list[PMFlowHumanTouchEstimate], + override: Optional[PMFlowCapacityOverrideRecord], + ) -> PMFlowMemberCapacityPlan: + base_minutes = 480 + added = int(override.addedMinutes if override else 0) + effective = base_minutes + added + max_util = float(override.maxPlannedUtilization if override and override.maxPlannedUtilization else 0.85) + planned = self._sum_minutes(touches) + current_mix = self._distribution(planned) + baseline = self._suggested_baseline(role=str(ctx.user.role or ""), team_mix=team_mix, planned=planned, override=override) + utilization = planned.total / max(effective, 1) + capacity_status = self._capacity_status(utilization=utilization, override=override, tools=ctx.tools) + remaining = round(effective * max_util - planned.total) + bottleneck = self._bottleneck(planned) + explanations = self._explanations(ctx=ctx, planned=planned, baseline=baseline, override=override, touches=touches) + return PMFlowMemberCapacityPlan( + userId=str(ctx.user.id), + displayName=str(ctx.user.name or ctx.user.username), + role=str(ctx.user.role or "MEMBER"), + baseAvailableMinutes=base_minutes, + calendarBlockedMinutes=0, + effectiveMinutes=effective, + reservedBufferMinutes=round(effective * max(0.0, 1 - max_util)), + maxPlannedUtilization=max_util, + suggestedBaseline=baseline, + currentMix=current_mix, + plannedTouchMinutes=planned, + capacityStatus=capacity_status, + remainingMinutes=remaining, + bottleneckType=bottleneck, + fatigueRisk=self._fatigue_risk(planned=planned, effective=effective), + riskDensity={ + "high": len([touch for touch in touches if touch.riskLevel == "high"]), + "medium": len([touch for touch in touches if touch.riskLevel == "medium"]), + "total": len(touches), + }, + canTakeMore=self._capacity_fit(project_id=project_id, planned=planned, effective=effective, max_util=max_util), + manualOverride=override, + explanations=explanations, + ) + + def _suggested_baseline( + self, + *, + role: str, + team_mix: PMFlowWorkTypeDistribution, + planned: PMFlowWorkTypeMinutes, + override: Optional[PMFlowCapacityOverrideRecord], + ) -> PMFlowWorkTypeDistribution: + assigned_mix = self._distribution(planned) if planned.total else None + base = {key: getattr(team_mix, key) for key in WORK_TYPES} + if assigned_mix and planned.total: + base = {key: (getattr(assigned_mix, key) * 0.7) + (base[key] * 0.3) for key in WORK_TYPES} + elif not any(base.values()): + role_token = role.upper() + if "OWNER" in role_token: + base = {"focus": 0.5, "review": 0.15, "acceptance": 0.2, "coordination": 0.15} + elif "REVIEWER" in role_token: + base = {"focus": 0.2, "review": 0.5, "acceptance": 0.2, "coordination": 0.1} + elif "OPERATOR" in role_token: + base = {"focus": 0.15, "review": 0.1, "acceptance": 0.25, "coordination": 0.5} + else: + base = {"focus": 0.25, "review": 0.2, "acceptance": 0.35, "coordination": 0.2} + if override and override.workTypeBias: + for key in WORK_TYPES: + if key in override.workTypeBias: + base[key] = max(0.0, float(override.workTypeBias[key])) + return self._normalize_distribution(base) + + def _capacity_fit(self, *, project_id: int, planned: PMFlowWorkTypeMinutes, effective: int, max_util: float) -> list[PMFlowCapacityFit]: + candidates: list[tuple[PMFlowHumanWorkType, str, int]] = [ + ("focus", "低/中风险判断", 45), + ("review", "普通审查", 45), + ("acceptance", "AI 结果验收", 30), + ("coordination", "补上下文/工具阻塞", 25), + ] + result: list[PMFlowCapacityFit] = [] + for work_type, label, minutes in candidates: + next_total = planned.total + minutes + next_type = getattr(planned, work_type) + minutes + total_util = next_total / max(effective, 1) + type_share = next_type / max(effective, 1) + status = "recommended" + reason = "加入后仍处于可计划容量内。" + if total_util > max_util * 1.1: + status, reason = "not_recommended", "加入后超过今日建议计划容量。" + elif total_util > max_util or type_share > self._soft_share(work_type): + status, reason = "allowed_with_warning", "加入后接近容量或该类型疲劳风险上升。" + result.append( + PMFlowCapacityFit( + workType=work_type, + label=label, + status=status, + estimatedMinutes=minutes, + afterTotalUtilization=round(total_util, 3), + afterTypeShare=round(type_share, 3), + reason=reason, + ) + ) + return result + + def _recommendations(self, *, touches: list[PMFlowHumanTouchEstimate], plans: list[PMFlowMemberCapacityPlan]) -> list[str]: + reduction = sum(max(0, touch.aiReductionPotentialMinutes) for touch in touches) + recommendations: list[str] = [] + if reduction: + recommendations.append(f"建议先让 AI 预处理可转移触点,预计释放 {reduction} 分钟人工判断/审查容量。") + overloaded = [plan.displayName for plan in plans if plan.capacityStatus in {"near_full", "overloaded"}] + if overloaded: + recommendations.append(f"{'、'.join(overloaded[:3])} 接近或超过今日建议容量,新增任务前需要模拟。") + if not recommendations: + recommendations.append("当前容量计划未发现明显瓶颈,可继续按执行能力图谱推进。") + return recommendations + + def _sum_minutes(self, touches: list[PMFlowHumanTouchEstimate]) -> PMFlowWorkTypeMinutes: + totals = {key: 0 for key in WORK_TYPES} + for touch in touches: + totals[touch.workType] += int(touch.estimatedMinutes or 0) + return PMFlowWorkTypeMinutes(**totals, total=sum(totals.values())) + + def _distribution(self, minutes: PMFlowWorkTypeMinutes) -> PMFlowWorkTypeDistribution: + total = max(minutes.total, 1) + return PMFlowWorkTypeDistribution(**{key: round(getattr(minutes, key) / total, 3) for key in WORK_TYPES}) + + def _normalize_distribution(self, values: dict[str, float]) -> PMFlowWorkTypeDistribution: + total = sum(max(0.0, float(values.get(key, 0.0))) for key in WORK_TYPES) or 1.0 + return PMFlowWorkTypeDistribution(**{key: round(max(0.0, float(values.get(key, 0.0))) / total, 3) for key in WORK_TYPES}) + + def _capacity_status(self, *, utilization: float, override: Optional[PMFlowCapacityOverrideRecord], tools: list[str]) -> str: + if override and override.overrideType == "lock_capacity": + return "blocked" + if utilization > 1.1: + return "overloaded" + if override and override.addedMinutes > 0: + return "manually_extended" + if utilization >= 0.9: + return "near_full" + if utilization >= 0.7: + return "attention" + return "normal" + + def _fatigue_risk(self, *, planned: PMFlowWorkTypeMinutes, effective: int) -> dict[str, str]: + risks: dict[str, str] = {} + for key in WORK_TYPES: + share = getattr(planned, key) / max(effective, 1) + if share > self._hard_share(key): + risks[key] = "high" + elif share > self._soft_share(key): + risks[key] = "medium" + else: + risks[key] = "low" + return risks + + def _soft_share(self, work_type: str) -> float: + return {"focus": 0.7, "review": 0.65, "acceptance": 0.75, "coordination": 0.6}.get(work_type, 0.7) + + def _hard_share(self, work_type: str) -> float: + return {"focus": 0.85, "review": 0.8, "acceptance": 0.9, "coordination": 0.75}.get(work_type, 0.85) + + def _bottleneck(self, minutes: PMFlowWorkTypeMinutes) -> Optional[str]: + values = {key: getattr(minutes, key) for key in WORK_TYPES} + if not any(values.values()): + return None + return max(values.items(), key=lambda item: item[1])[0] + + def _explanations( + self, + *, + ctx: _MemberContext, + planned: PMFlowWorkTypeMinutes, + baseline: PMFlowWorkTypeDistribution, + override: Optional[PMFlowCapacityOverrideRecord], + touches: list[PMFlowHumanTouchEstimate], + ) -> list[str]: + explanations = [ + f"今日动态基线来自当前任务池:深度判断 {round(baseline.focus * 100)}%,审查 {round(baseline.review * 100)}%,验收 {round(baseline.acceptance * 100)}%,协调 {round(baseline.coordination * 100)}%。", + f"当前已计划人工触点 {planned.total} 分钟,包含 {len(touches)} 个真实触点。", + ] + if override: + explanations.append(f"负责人已手工调整:{override.reason}") + if not ctx.tools: + explanations.append("未检测到活跃 Cursor / VS Code / Codex 工具连接,工具准备度会影响可承接建议。") + return explanations[:4] + + def _payload_to_record(self, payload: PMFlowCapacityOverridePayload, *, plan_date: date, created_by: int) -> PMFlowCapacityOverrideRecord: + return PMFlowCapacityOverrideRecord( + id="simulated", + userId=str(payload.userId), + date=plan_date.isoformat(), + overrideType=payload.overrideType, + addedMinutes=int(payload.addedMinutes or 0), + overtimeType=payload.overtimeType, + maxPlannedUtilization=payload.maxPlannedUtilization, + workTypeBias=dict(payload.workTypeBias or {}), + highRiskLimit=payload.highRiskLimit, + reason=payload.reason, + expiresAt=self._parse_expires(payload.expiresAt, plan_date=plan_date).isoformat(), + createdBy=str(created_by), + createdAt=None, + ) + + def _override_record(self, row: HumanCapacityOverride) -> PMFlowCapacityOverrideRecord: + return PMFlowCapacityOverrideRecord( + id=str(row.id), + userId=str(row.user_id), + date=row.date.isoformat(), + overrideType=row.override_type, + addedMinutes=int(row.added_minutes or 0), + overtimeType=row.overtime_type, + maxPlannedUtilization=row.max_planned_utilization, + workTypeBias=dict(row.work_type_bias or {}), + highRiskLimit=row.high_risk_limit, + reason=row.reason, + expiresAt=row.expires_at.isoformat(), + createdBy=str(row.created_by), + createdAt=row.created_at.isoformat() if row.created_at else None, + ) + + def _find_member_plan(self, plan: PMFlowDailyCapacityPlan, user_id: str) -> PMFlowMemberCapacityPlan: + for item in plan.memberPlans: + if str(item.userId) == str(user_id): + return item + raise ValueError("capacity_member_not_found") + + def _parse_date(self, value: Optional[str]) -> date: + if not value: + return now_cn_naive().date() + return date.fromisoformat(str(value)[:10]) + + def _parse_expires(self, value: Optional[str], *, plan_date: date) -> datetime: + if value: + parsed = datetime.fromisoformat(str(value).replace("Z", "+00:00")) + return parsed.replace(tzinfo=None) + return datetime.combine(plan_date, time(23, 59, 59)) + + def _risk(self, value: Any) -> str: + token = str(value or "low").lower() + return token if token in {"low", "medium", "high"} else "low" + + def _get(self, obj: Any, key: str) -> Any: + if isinstance(obj, dict): + return obj.get(key) + return getattr(obj, key, None) diff --git a/backend/app/services/ai_routing_governance_service.py b/backend/app/services/ai_routing_governance_service.py new file mode 100644 index 00000000..09f6a1d2 --- /dev/null +++ b/backend/app/services/ai_routing_governance_service.py @@ -0,0 +1,586 @@ +"""AI routing and provider governance aggregation service.""" + +from __future__ import annotations + +import json +import os +from datetime import datetime, timedelta +from decimal import Decimal +from typing import Any + +from sqlalchemy import and_, select +from sqlalchemy.ext.asyncio import AsyncSession + +from ..models import AICallLog, Integration +from ..services.model.model_config import ( + build_routing_config_snapshot, + find_matching_rule, + get_provider_defaults, + get_tier_defaults, + load_model_config, +) +from ..utils.timezone import now_cn_naive + +TEAM_POLICY_INTEGRATION_TYPE = "ai_team_policy" +LLM_ENGINE_INTEGRATION_TYPE = "llm_engine" +MODEL_ROUTING_INTEGRATION_TYPE = "model_routing" + +PROVIDER_LABELS = { + "glm": "Qwen / DashScope", + "openai": "OpenAI 兼容网关", + "deepseek": "DeepSeek", + "ollama": "本地 Ollama", +} + +SLOT_LABELS = { + "low": "快速槽", + "high": "深度槽", +} + +PROTECTED_FUNCTIONS = { + "adaptive_rag_answer_deep", + "pmflow_launch_draft_v1", + "pmflow_requirement_plan_draft", + "pmflow_requirement_plan_round", + "project_requirement_extraction", + "generate_task_breakdown", +} + +SCENE_FUNCTIONS = { + "project_launch": ["pmflow_launch_draft_v1", "project_from_text_analysis", "project_from_text_charter"], + "requirement_structuring": ["project_requirement_extraction", "pmflow_requirement_plan_draft", "pmflow_requirement_plan_round"], + "task_breakdown": ["generate_task_breakdown", "decompose_tasks_from_charter", "sstc_ai_breakdown"], + "assignee_matching": ["recommend_assignees_for_task"], + "internal_qa": ["adaptive_rag_answer_normal", "adaptive_rag_answer_deep", "rag_db_project_semantic_retrieval"], + "external_query": ["external_query_plan", "adaptive_rag_answer"], + "feishu_understanding": ["feishu_level_classification", "feishu_signal_extraction", "pmflow_semantic_context_decision"], + "feishu_reply": ["feishu_humanized_reply", "feishu_qa_direct_natural_reply"], +} + +SCENE_LABELS = { + "project_launch": "项目启动草案", + "requirement_structuring": "需求结构化", + "task_breakdown": "任务拆解", + "assignee_matching": "人员匹配", + "internal_qa": "内部问答", + "external_query": "外部实时查询", + "feishu_understanding": "飞书理解", + "feishu_reply": "飞书回复", +} + +DEFAULT_SCENE_POLICIES = { + "project_launch": "快速起草,失败后深度修复,结果必须进入确认", + "requirement_structuring": "深度优先,保护项目真相层入口", + "task_breakdown": "快速结构化,失败后深度修复,仍失败则标记待补充", + "assignee_matching": "深度分析,必须结合人员能力图谱", + "internal_qa": "先走受控内部查询,再按复杂度组织答案", + "external_query": "外部查询必须经过计划、来源校验和答案验证", + "feishu_understanding": "快速理解与分流,低置信度进入澄清", + "feishu_reply": "快速润色,不改变业务结论", +} + + +def _safe_dict(value: Any) -> dict[str, Any]: + if isinstance(value, dict): + try: + return json.loads(json.dumps(value)) + except Exception: + return dict(value) + if isinstance(value, str): + try: + parsed = json.loads(value) + return parsed if isinstance(parsed, dict) else {} + except Exception: + return {} + return {} + + +def _mask_secret(secret: str) -> str: + value = str(secret or "").strip() + if not value: + return "" + if len(value) <= 8: + return "*" * len(value) + return f"{value[:4]}...{value[-4:]}" + + +def _looks_placeholder(value: str) -> bool: + text = str(value or "").strip().lower() + return bool(text) and any(marker in text for marker in ("placeholder", "dummy", "test-key", "local-placeholder")) + + +def _normalize_tier(value: Any) -> str: + text = str(value or "").strip().lower() + return text if text in {"low", "high"} else "" + + +def _iso(value: Any) -> str | None: + if isinstance(value, datetime): + return value.isoformat() + text = str(value or "").strip() + return text or None + + +class AIRoutingGovernanceService: + def __init__(self, db: AsyncSession, *, tenant_id: int): + self.db = db + self.tenant_id = int(tenant_id) + + async def get_overview(self) -> dict[str, Any]: + bundle = load_model_config() + engine_bundle = await self._load_integration_config(LLM_ENGINE_INTEGRATION_TYPE) + routing_bundle = await self._load_integration_config(MODEL_ROUTING_INTEGRATION_TYPE) + slots = self._build_slots(engine_bundle=engine_bundle, config_bundle=bundle) + metrics = await self._build_metrics() + diagnostics = await self._build_diagnostics( + engine_bundle=engine_bundle, + routing_bundle=routing_bundle, + slots=slots, + metrics=metrics, + ) + snapshot = build_routing_config_snapshot(bundle=bundle) + return { + "config_version": snapshot.get("routing_config_version") or "", + "config_updated_at": snapshot.get("routing_config_updated_at") or "", + "source_of_truth": "PMFlow 本地路由层 baseline", + "slots": slots, + "metrics": metrics, + "diagnostics": diagnostics, + "latency_status": "暂未采集平均耗时", + } + + async def get_admin_console(self) -> dict[str, Any]: + overview = await self.get_overview() + engine_bundle = await self._load_integration_config(LLM_ENGINE_INTEGRATION_TYPE) + routing_bundle = await self._load_integration_config(MODEL_ROUTING_INTEGRATION_TYPE) + config_bundle = load_model_config() + return { + "overview": overview, + "providers": self._build_providers(engine_bundle=engine_bundle), + "scene_routes": self._build_scene_routes(routing_bundle=routing_bundle, config_bundle=config_bundle), + "protected_functions": sorted(PROTECTED_FUNCTIONS), + "recent_failures": await self._recent_failures(limit=8), + "diagnostics": overview["diagnostics"], + "audit_hint": "策略变更会写入操作日志,底层模型默认值仍由 PMFlow 本地路由层 baseline 管理。", + } + + async def run_diagnostics(self) -> dict[str, Any]: + overview = await self.get_overview() + return { + "checked_at": now_cn_naive().isoformat(), + "items": overview["diagnostics"], + "summary": self._diagnostic_summary(overview["diagnostics"]), + } + + async def get_team_policy(self) -> dict[str, Any]: + saved = await self._load_integration_config(TEAM_POLICY_INTEGRATION_TYPE) + return self._merge_team_policy(saved) + + async def update_team_policy(self, *, payload: dict[str, Any], updated_by: int) -> dict[str, Any]: + normalized = self._merge_team_policy(payload) + normalized["updated_by"] = int(updated_by) + normalized["updated_at"] = now_cn_naive().isoformat() + row = await self._get_or_init_integration(TEAM_POLICY_INTEGRATION_TYPE, default_config={}) + row.config = json.loads(json.dumps(normalized, ensure_ascii=False)) + row.is_active = True + await self.db.flush() + return normalized + + async def _load_integration_config(self, integration_type: str) -> dict[str, Any]: + row = ( + await self.db.execute( + select(Integration).where( + and_( + Integration.tenant_id == self.tenant_id, + Integration.type == integration_type, + ) + ) + ) + ).scalar_one_or_none() + return _safe_dict(row.config if row else {}) + + async def _get_or_init_integration(self, integration_type: str, *, default_config: dict[str, Any]) -> Integration: + row = ( + await self.db.execute( + select(Integration).where( + and_( + Integration.tenant_id == self.tenant_id, + Integration.type == integration_type, + ) + ) + ) + ).scalar_one_or_none() + if row is None: + row = Integration( + tenant_id=self.tenant_id, + type=integration_type, + config=json.loads(json.dumps(default_config, ensure_ascii=False)), + is_active=True, + ) + self.db.add(row) + await self.db.flush() + return row + + def _merge_team_policy(self, saved: dict[str, Any]) -> dict[str, Any]: + strategy = str(saved.get("strategy") or "balanced").strip() + legacy_strategy_map = {"quality": "quality_first", "speed": "speed_first"} + strategy = legacy_strategy_map.get(strategy, strategy) + if strategy not in {"quality_first", "balanced", "speed_first"}: + strategy = "balanced" + saved_scenes = { + str(item.get("key") or ""): item + for item in saved.get("scenes", []) + if isinstance(item, dict) and str(item.get("key") or "") in SCENE_LABELS + } + scenes: list[dict[str, Any]] = [] + for key, label in SCENE_LABELS.items(): + item = saved_scenes.get(key, {}) + scenes.append( + { + "key": key, + "label": label, + "policy": str(item.get("policy") or DEFAULT_SCENE_POLICIES[key]).strip(), + "mode": str(item.get("mode") or self._default_scene_mode(key)).strip(), + "human_confirmation": bool(item.get("human_confirmation", key in {"project_launch", "requirement_structuring", "task_breakdown"})), + "protected": bool(item.get("protected", key in {"project_launch", "requirement_structuring", "task_breakdown", "assignee_matching"})), + } + ) + budget_guard = saved.get("budget_guard") if isinstance(saved.get("budget_guard"), dict) else {} + return { + "strategy": strategy, + "strategy_label": {"quality_first": "质量优先", "balanced": "成本均衡", "speed_first": "速度优先"}[strategy], + "scenes": scenes, + "automation_boundary": str( + saved.get("automation_boundary") + or "高风险写操作只生成待确认草案,禁止模型直接改生产状态。" + ).strip(), + "budget_guard": { + "enabled": bool(budget_guard.get("enabled", False)), + "monthly_budget_cny": budget_guard.get("monthly_budget_cny"), + "alert_threshold_ratio": float(budget_guard.get("alert_threshold_ratio", 0.8) or 0.8), + }, + "quality_targets": self._normalize_quality_targets(saved.get("quality_targets")), + "updated_at": str(saved.get("updated_at") or "") or None, + "updated_by": saved.get("updated_by"), + } + + @staticmethod + def _normalize_quality_targets(value: Any) -> list[str]: + if isinstance(value, list): + items = [str(item).strip() for item in value if str(item or "").strip()] + return items[:8] if items else AIRoutingGovernanceService._default_quality_targets() + return AIRoutingGovernanceService._default_quality_targets() + + @staticmethod + def _default_quality_targets() -> list[str]: + return [ + "项目启动、需求结构化、任务拆解失败时必须显式进入待确认或失败态", + "内部问答必须保留来源合同,外部查询必须经过实时来源校验", + "保护场景不得被租户覆盖降级到快速槽", + ] + + @staticmethod + def _default_scene_mode(key: str) -> str: + if key in {"requirement_structuring", "assignee_matching"}: + return "深度优先" + if key == "external_query": + return "外部校验" + if key in {"feishu_understanding", "feishu_reply"}: + return "快速处理" + return "快速起草,失败升级" + + def _build_slots(self, *, engine_bundle: dict[str, Any], config_bundle: dict[str, Any]) -> list[dict[str, Any]]: + engines = [item for item in engine_bundle.get("engines", []) if isinstance(item, dict)] + tier_defaults = config_bundle.get("tier_defaults") if isinstance(config_bundle.get("tier_defaults"), dict) else get_tier_defaults() + slots: list[dict[str, Any]] = [] + for tier in ("low", "high"): + defaults = tier_defaults.get(tier) if isinstance(tier_defaults.get(tier), dict) else {} + provider = str(defaults.get("provider") or "").strip().lower() + model = str(defaults.get("model") or "").strip() + base_url = str(defaults.get("base_url") or "").strip() + matching_engine = next( + (item for item in engines if str(item.get("provider") or "").strip().lower() == provider), + None, + ) + env_configured = bool(os.getenv(str(defaults.get("api_env") or "").strip(), "").strip()) if defaults.get("api_env") else False + source = "租户配置" if matching_engine else "环境变量" if env_configured else "未配置" + api_key_masked = _mask_secret(str((matching_engine or {}).get("api_key") or "")) if matching_engine else "" + slots.append( + { + "tier": tier, + "label": SLOT_LABELS[tier], + "provider": provider, + "provider_label": PROVIDER_LABELS.get(provider, provider or "未配置"), + "model": model, + "base_url": base_url, + "configured": bool(matching_engine or env_configured or provider == "ollama"), + "config_source": source, + "api_key_masked": api_key_masked, + } + ) + return slots + + def _build_providers(self, *, engine_bundle: dict[str, Any]) -> list[dict[str, Any]]: + active_id = str(engine_bundle.get("active_engine_id") or "") + engines = [item for item in engine_bundle.get("engines", []) if isinstance(item, dict)] + providers = [] + for item in engines: + provider = str(item.get("provider") or "").strip().lower() + providers.append( + { + "id": str(item.get("id") or ""), + "name": str(item.get("name") or PROVIDER_LABELS.get(provider, provider)).strip(), + "provider": provider, + "provider_label": PROVIDER_LABELS.get(provider, provider), + "model": str(item.get("model") or ""), + "base_url": str(item.get("base_url") or ""), + "api_key_masked": _mask_secret(str(item.get("api_key") or "")), + "active": str(item.get("id") or "") == active_id, + "status_label": "已启用" if str(item.get("id") or "") == active_id else "已配置", + "updated_at": _iso(item.get("updated_at")), + } + ) + return providers + + def _build_scene_routes(self, *, routing_bundle: dict[str, Any], config_bundle: dict[str, Any]) -> list[dict[str, Any]]: + mappings = routing_bundle.get("mappings") if isinstance(routing_bundle.get("mappings"), dict) else {} + routes: list[dict[str, Any]] = [] + for scene_key, functions in SCENE_FUNCTIONS.items(): + function_rows: list[dict[str, Any]] = [] + tiers: list[str] = [] + for function_name in functions: + override = mappings.get(function_name) if isinstance(mappings.get(function_name), dict) else None + override_tier = _normalize_tier((override or {}).get("tier_alias") or (override or {}).get("tier")) + rule = find_matching_rule(function_name, tier_filter={override_tier} if override_tier else None, bundle=config_bundle) + tier = override_tier or _normalize_tier((rule or {}).get("tier")) or "low" + tiers.append(tier) + function_rows.append( + { + "function_name": function_name, + "display_name": self._function_display_name(function_name), + "tier": tier, + "slot_label": SLOT_LABELS.get(tier, tier), + "source": "override" if override_tier else "default", + "rule_name": str((rule or {}).get("name") or "") or None, + } + ) + primary_tier = "mixed" if len(set(tiers)) > 1 else (tiers[0] if tiers else "low") + routes.append( + { + "scene_key": scene_key, + "label": SCENE_LABELS[scene_key], + "summary": DEFAULT_SCENE_POLICIES[scene_key], + "primary_slot_label": "混合策略" if primary_tier == "mixed" else SLOT_LABELS.get(primary_tier, "快速槽"), + "functions": function_rows, + } + ) + return routes + + async def _build_metrics(self) -> dict[str, Any]: + return { + "last_24h": await self._window_metrics(hours=24), + "last_7d": await self._window_metrics(hours=24 * 7), + } + + async def _window_metrics(self, *, hours: int) -> dict[str, Any]: + since = now_cn_naive() - timedelta(hours=hours) + rows = ( + await self.db.execute( + select(AICallLog).where( + and_( + AICallLog.tenant_id == self.tenant_id, + AICallLog.created_at >= since, + ) + ) + ) + ).scalars().all() + total = len(rows) + failed = sum(1 for row in rows if self._is_failed_status(str(row.status or ""))) + upgraded = sum( + 1 + for row in rows + if str(row.planned_tier or "").strip() + and str(row.executed_tier or "").strip() + and str(row.planned_tier or "").strip() != str(row.executed_tier or "").strip() + ) + structured_failed = sum(1 for row in rows if self._is_structured_failure(str(row.status or ""))) + total_cost = sum(float(row.cost or Decimal("0")) for row in rows) + return { + "total_calls": total, + "failed_calls": failed, + "failure_rate": round(failed / total, 4) if total else 0.0, + "upgraded_calls": upgraded, + "upgrade_rate": round(upgraded / total, 4) if total else 0.0, + "structured_failed_calls": structured_failed, + "estimated_cost": round(total_cost, 6), + "average_latency_ms": None, + } + + async def _build_diagnostics( + self, + *, + engine_bundle: dict[str, Any], + routing_bundle: dict[str, Any], + slots: list[dict[str, Any]], + metrics: dict[str, Any], + ) -> list[dict[str, Any]]: + items: list[dict[str, Any]] = [] + items.extend(self._env_override_diagnostics(engine_bundle=engine_bundle)) + if any(not slot.get("configured") for slot in slots if slot.get("tier") == "high"): + items.append(self._risk("high_unavailable", "critical", "深度槽未配置可用供应商", "项目启动、需求结构化和复杂问答可能显式失败。")) + if len(slots) == 2 and self._same_slot_runtime(slots[0], slots[1]): + items.append(self._risk("same_slot_runtime", "warning", "快速槽和深度槽指向同一运行时", "低阶失败后升级无法获得更强模型能力。")) + items.extend(self._protected_downgrade_diagnostics(routing_bundle=routing_bundle)) + last_24h = metrics.get("last_24h") if isinstance(metrics.get("last_24h"), dict) else {} + if int(last_24h.get("total_calls") or 0) >= 3 and float(last_24h.get("failure_rate") or 0) >= 0.3: + items.append(self._risk("high_failure_rate", "warning", "最近 24 小时 AI 调用失败率偏高", "建议先检查供应商 Key、额度、结构化输出和高阶槽位。")) + if int(last_24h.get("structured_failed_calls") or 0) >= 3: + items.append(self._risk("structured_failure_rate", "warning", "结构化输出失败偏多", "项目启动、任务拆解和语义事实抽取可能进入待补充或失败状态。")) + if self._ollama_runtime_risk(engine_bundle=engine_bundle, slots=slots): + items.append(self._risk("ollama_scope_risk", "warning", "本地 Ollama 只能用于知识库问答", "若被绑定到项目创建或任务拆解,会被运行边界拦截。")) + if not items: + items.append({"code": "ok", "severity": "ok", "title": "未发现明显配置风险", "message": "当前供应商、槽位和路由配置未触发内置风险规则。"}) + return items + + def _env_override_diagnostics(self, *, engine_bundle: dict[str, Any]) -> list[dict[str, Any]]: + items: list[dict[str, Any]] = [] + defaults = get_provider_defaults() + engines = [item for item in engine_bundle.get("engines", []) if isinstance(item, dict)] + for engine in engines: + provider = str(engine.get("provider") or "").strip().lower() + provider_defaults = defaults.get(provider) if isinstance(defaults.get(provider), dict) else {} + api_env = str(provider_defaults.get("api_env") or "").strip() + if not api_env: + continue + env_value = str(os.getenv(api_env, "") or "").strip() + db_value = str(engine.get("api_key") or "").strip() + if not env_value or not db_value or env_value == db_value: + continue + severity = "critical" if _looks_placeholder(env_value) else "warning" + items.append( + self._risk( + "env_key_override", + severity, + f"{PROVIDER_LABELS.get(provider, provider)} 环境变量正在覆盖租户密钥", + "运行时会优先使用环境变量中的密钥;若该值是占位符,会导致供应商请求失败。", + ) + ) + return items + + def _protected_downgrade_diagnostics(self, *, routing_bundle: dict[str, Any]) -> list[dict[str, Any]]: + mappings = routing_bundle.get("mappings") if isinstance(routing_bundle.get("mappings"), dict) else {} + downgraded = [] + for function_name in PROTECTED_FUNCTIONS: + override = mappings.get(function_name) if isinstance(mappings.get(function_name), dict) else None + tier = _normalize_tier((override or {}).get("tier_alias") or (override or {}).get("tier")) + if tier == "low": + downgraded.append(function_name) + if not downgraded: + return [] + return [ + self._risk( + "protected_function_downgraded", + "critical", + "保护场景被覆盖到快速槽", + f"涉及 {len(downgraded)} 个保护函数;这些场景应保持深度或 low-first 高阶修复。", + ) + ] + + @staticmethod + def _risk(code: str, severity: str, title: str, message: str) -> dict[str, Any]: + return {"code": code, "severity": severity, "title": title, "message": message} + + @staticmethod + def _same_slot_runtime(a: dict[str, Any], b: dict[str, Any]) -> bool: + return ( + str(a.get("provider") or "").strip().lower() == str(b.get("provider") or "").strip().lower() + and str(a.get("model") or "").strip().lower() == str(b.get("model") or "").strip().lower() + and str(a.get("base_url") or "").strip().rstrip("/").lower() + == str(b.get("base_url") or "").strip().rstrip("/").lower() + ) + + @staticmethod + def _ollama_runtime_risk(*, engine_bundle: dict[str, Any], slots: list[dict[str, Any]]) -> bool: + settings = engine_bundle.get("settings") if isinstance(engine_bundle.get("settings"), dict) else {} + if bool(settings.get("enable_local_kb_qa")): + return False + return any(str(slot.get("provider") or "").strip().lower() == "ollama" for slot in slots) + + @staticmethod + def _is_failed_status(status: str) -> bool: + normalized = str(status or "").strip().lower() + if not normalized: + return True + return not ( + normalized.startswith("success") + or normalized in {"ok", "success_cache_hit", "success_failover"} + ) + + @staticmethod + def _is_structured_failure(status: str) -> bool: + normalized = str(status or "").strip().lower() + return "structured" in normalized and any(marker in normalized for marker in ("error", "empty", "invalid", "failed")) + + async def _recent_failures(self, *, limit: int) -> list[dict[str, Any]]: + rows = ( + await self.db.execute( + select(AICallLog) + .where(AICallLog.tenant_id == self.tenant_id) + .order_by(AICallLog.created_at.desc(), AICallLog.id.desc()) + .limit(80) + ) + ).scalars().all() + failures: list[dict[str, Any]] = [] + for row in rows: + if not self._is_failed_status(str(row.status or "")): + continue + failures.append( + { + "function_label": self._function_display_name(str(row.function_name or "")), + "function_name": str(row.function_name or ""), + "status_label": self._status_label(str(row.status or "")), + "planned_slot": SLOT_LABELS.get(str(row.planned_tier or "").strip().lower(), str(row.planned_tier or "") or "-"), + "executed_slot": SLOT_LABELS.get(str(row.executed_tier or "").strip().lower(), str(row.executed_tier or "") or "-"), + "created_at": _iso(row.created_at), + } + ) + if len(failures) >= limit: + break + return failures + + @staticmethod + def _function_display_name(function_name: str) -> str: + labels = { + "pmflow_launch_draft_v1": "项目启动草案", + "pmflow_semantic_context_decision": "语义上下文决策", + "adaptive_rag_answer_deep": "深度问答生成", + "adaptive_rag_answer_normal": "常规问答生成", + "generate_task_breakdown": "任务拆解", + "rag_db_project_semantic_retrieval": "项目语义检索", + "feishu_humanized_reply": "飞书回复润色", + "signal_planner_analyze": "信号规划分析", + } + return labels.get(str(function_name or "").strip(), str(function_name or "").strip() or "未知调用") + + @staticmethod + def _status_label(status: str) -> str: + normalized = str(status or "").strip().lower() + if "http" in normalized: + return "供应商请求失败" + if "empty" in normalized: + return "模型返回为空" + if "invalid" in normalized: + return "结构化结果不合法" + if "cache" in normalized: + return "缓存命中" + if normalized.startswith("success"): + return "成功" + return "调用失败" + + @staticmethod + def _diagnostic_summary(items: list[dict[str, Any]]) -> dict[str, int]: + return { + "critical": sum(1 for item in items if item.get("severity") == "critical"), + "warning": sum(1 for item in items if item.get("severity") == "warning"), + "ok": sum(1 for item in items if item.get("severity") == "ok"), + } diff --git a/backend/app/services/auth/__init__.py b/backend/app/services/auth/__init__.py index afbf784b..9ad8b720 100644 --- a/backend/app/services/auth/__init__.py +++ b/backend/app/services/auth/__init__.py @@ -3,8 +3,11 @@ from .service import ( AuthService, MANAGER_ROLES, + can_approve_ai_routing_change, can_assign_tasks, can_manage_users, + can_operate_ai_routing, + can_view_ai_policy, has_any_role, has_permission, parse_role_values, @@ -18,4 +21,7 @@ __all__ = [ "parse_role_values", "can_manage_users", "can_assign_tasks", + "can_view_ai_policy", + "can_operate_ai_routing", + "can_approve_ai_routing_change", ] diff --git a/backend/app/services/auth/service.py b/backend/app/services/auth/service.py index 171b36ff..b14ab657 100644 --- a/backend/app/services/auth/service.py +++ b/backend/app/services/auth/service.py @@ -233,3 +233,18 @@ def can_manage_users(user_role: str) -> bool: def can_assign_tasks(user_role: str) -> bool: """Check if user can assign tasks to others""" return has_any_role(user_role, ["OWNER", "REVIEWER"]) + + +def can_view_ai_policy(user_role: str) -> bool: + """AI policy pages are visible to governance roles only.""" + return has_any_role(user_role, ["OWNER", "REVIEWER", "AI_CONFIG_VIEWER", "AI_OPERATOR", "AI_ADMIN"]) + + +def can_operate_ai_routing(user_role: str) -> bool: + """AI routing diagnostics/tests are available to reviewer-level operators.""" + return has_any_role(user_role, ["OWNER", "REVIEWER", "AI_OPERATOR", "AI_ADMIN"]) + + +def can_approve_ai_routing_change(user_role: str) -> bool: + """High-risk AI routing/policy changes require owner approval in v1.""" + return has_any_role(user_role, ["OWNER", "AI_ADMIN"]) diff --git a/backend/app/services/context_cache_service/mixin_profile_basics.py b/backend/app/services/context_cache_service/mixin_profile_basics.py index 0b514358..6c1ca2ec 100644 --- a/backend/app/services/context_cache_service/mixin_profile_basics.py +++ b/backend/app/services/context_cache_service/mixin_profile_basics.py @@ -82,13 +82,13 @@ class ContextCacheProfileBasicsMixin: "aliases": ["projects", "项目", "项目列表", "project list"], }, { - "route": "/ai-steward", - "name": "AI 管家", - "summary": "AI 任务协同与治理中心,包含简报、冲突检测、问答历史。", - "page_component": "AIStewardPage", - "page_file": "frontend/src/pages/AIStewardPage.tsx", - "apis": ["/ai-steward/", "/rag/ask", "/rag/qa-sessions", "/rag/qa-records"], - "aliases": ["ai steward", "ai管家", "问答", "rag", "智能问答"], + "route": "/space", + "name": "个人空间", + "summary": "聚合项目、草案、待确认、AI 动态和问答入口。", + "page_component": "PersonalSpacePage", + "page_file": "frontend/src/pages/PersonalSpacePage.tsx", + "apis": ["/projects/?summary=1", "/pmflow-os/projects/{project_id}/truth-state", "/rag/ask", "/rag/qa-sessions", "/rag/qa-records"], + "aliases": ["space", "空间", "个人空间", "问答", "rag", "智能问答"], }, { "route": "/ai-config", diff --git a/backend/app/services/context_cache_service/mixin_profile_route_parser.py b/backend/app/services/context_cache_service/mixin_profile_route_parser.py index f4e2ef77..a32295ec 100644 --- a/backend/app/services/context_cache_service/mixin_profile_route_parser.py +++ b/backend/app/services/context_cache_service/mixin_profile_route_parser.py @@ -60,10 +60,10 @@ class ContextCacheProfileRouteParserMixin: ["/projects/", "/projects/?summary=1"], ["projects", "项目", "项目列表", "project list"], ), - "/ai-steward": ( - "AI 任务协同与治理中心,包含简报、冲突检测、问答历史。", - ["/ai-steward/", "/rag/ask", "/rag/qa-sessions", "/rag/qa-records"], - ["ai steward", "ai管家", "问答", "rag", "智能问答"], + "/space": ( + "聚合项目、草案、待确认、AI 动态和问答入口。", + ["/projects/?summary=1", "/pmflow-os/projects/{project_id}/truth-state", "/rag/ask", "/rag/qa-sessions", "/rag/qa-records"], + ["space", "空间", "个人空间", "问答", "rag", "智能问答"], ), "/ai-config": ( "配置飞书/企微/GitHub/知识库与模型参数,决定 AI 数据源与行为。", diff --git a/backend/app/services/signal/signal_capture_service/mixin_capture_legacy.py b/backend/app/services/signal/signal_capture_service/mixin_capture_legacy.py index 8f18beb9..fe378118 100644 --- a/backend/app/services/signal/signal_capture_service/mixin_capture_legacy.py +++ b/backend/app/services/signal/signal_capture_service/mixin_capture_legacy.py @@ -162,7 +162,7 @@ class SignalCaptureLegacyMixin: signal.processed = bool(linked_project_ids or auto_tasks) await self.db.commit() - target_url = "/ai-steward" + target_url = "/space" if linked_project_ids: target_url = f"/projects/{linked_project_ids[0]}/board" try: diff --git a/backend/app/services/team_execution_capability_service.py b/backend/app/services/team_execution_capability_service.py new file mode 100644 index 00000000..05fc7c5d --- /dev/null +++ b/backend/app/services/team_execution_capability_service.py @@ -0,0 +1,433 @@ +"""Execution capability snapshot for solo-first, team-ready PMFlow surfaces.""" + +from __future__ import annotations + +from collections import defaultdict +from typing import Any, Optional + +from sqlalchemy import and_, select +from sqlalchemy.ext.asyncio import AsyncSession + +from ..models import DevWorkspaceBinding, SkillTag, User, UserProject, UserSkillBinding, UserWorkload +from ..schemas.pmflow_os import ( + PMFlowExecutionCapabilityMode, + PMFlowExecutionCapabilitySnapshot, + PMFlowExecutionModeCapability, + PMFlowPersonalExecutionReadiness, + PMFlowTeamCapacitySignal, + PMFlowTeamContextSignal, + PMFlowTeamExecutionSignal, + PMFlowTeamToolSignal, + PMFlowToolReadinessItem, +) +from .adaptive_capacity_service import AdaptiveCapacityService +from .pmflow_os_projection_service import PMFlowOSProjectionService + + +class TeamExecutionCapabilityService: + """Builds read-only execution capability signals from existing PMFlow data.""" + + def __init__(self, db: AsyncSession, *, tenant_id: int) -> None: + self.db = db + self.tenant_id = int(tenant_id) + + async def get_snapshot(self, *, project_id: int, current_user_id: int) -> PMFlowExecutionCapabilitySnapshot: + projection = PMFlowOSProjectionService(self.db, tenant_id=self.tenant_id) + truth_state = await projection.get_truth_state(project_id=project_id, current_user_id=current_user_id) + execution_summary = await projection.get_execution_summary(project_id=project_id, current_user_id=current_user_id) + members = await self._load_project_members(project_id=project_id) + member_ids = [int(user.id) for user in members] + skill_map = await self._load_skill_map(member_ids=member_ids) + workload_map = await self._load_workload_map(member_ids=member_ids) + workspace_map = await self._load_workspace_map(project_id=project_id, member_ids=member_ids) + pending_reviews_by_user = self._pending_review_counts_by_user(truth_state.reviewerRouting.items) + active_execution_by_user = self._active_execution_counts_by_user(execution_summary.activeDevTasks) + + warnings = list(execution_summary.warnings) + if not members: + warnings.append("team_members_empty: 当前项目没有可用于能力快照的成员归属。") + if truth_state.reviewerRouting.summary.missingReviewerCount > 0: + warnings.append("reviewer_missing: 存在待审 ChangeSet 缺少 reviewer/owner。") + if not execution_summary.repoBindings: + warnings.append("repo_missing: 未接入 repo,AI 执行包无法落到真实代码上下文。") + if not execution_summary.promptPackage: + warnings.append("prompt_package_missing: 未生成执行 Prompt Package。") + if not execution_summary.recentProgressEvents: + warnings.append("execution_evidence_missing: 缺少 branch/commit/PR/test 等执行证据。") + + personal_readiness = self._personal_readiness( + project_id=project_id, + current_user_id=current_user_id, + truth_state=truth_state, + execution_summary=execution_summary, + workspace_map=workspace_map, + ) + adaptive_capacity = await AdaptiveCapacityService(self.db, tenant_id=self.tenant_id).get_plan( + project_id=project_id, + current_user_id=current_user_id, + ) + team_signals = [ + self._member_signal( + user=user, + project_id=project_id, + skills=skill_map.get(int(user.id), []), + workload_ratio=workload_map.get(int(user.id), 0.0), + workspace_tools=workspace_map.get(int(user.id), []), + pending_review_count=pending_reviews_by_user.get(int(user.id), 0), + active_execution_count=active_execution_by_user.get(int(user.id), 0), + evidence_count=len(truth_state.evidence), + ) + for user in members + ] + + return PMFlowExecutionCapabilitySnapshot( + projectId=str(project_id), + executionModes=self._execution_modes( + project_id=project_id, + has_repo=bool(execution_summary.repoBindings), + has_prompt=bool(execution_summary.promptPackage), + has_progress=bool(execution_summary.recentProgressEvents), + missing_reviewer_count=truth_state.reviewerRouting.summary.missingReviewerCount, + pending_review_count=truth_state.reviewerRouting.summary.totalPendingReview, + ), + personalReadiness=personal_readiness, + teamSignals=team_signals, + adaptiveCapacitySummary=adaptive_capacity, + warnings=warnings[:12], + ) + + async def _load_project_members(self, *, project_id: int) -> list[User]: + rows = ( + await self.db.execute( + select(User) + .join(UserProject, UserProject.user_id == User.id) + .where( + and_( + User.tenant_id == self.tenant_id, + UserProject.tenant_id == self.tenant_id, + UserProject.project_id == int(project_id), + User.is_active.is_(True), + ) + ) + .order_by(User.id.asc()) + ) + ).scalars().all() + return list(rows) + + async def _load_skill_map(self, *, member_ids: list[int]) -> dict[int, list[str]]: + if not member_ids: + return {} + rows = ( + await self.db.execute( + select(UserSkillBinding.user_id, SkillTag.name) + .join(SkillTag, SkillTag.id == UserSkillBinding.skill_tag_id) + .where( + and_( + UserSkillBinding.tenant_id == self.tenant_id, + SkillTag.tenant_id == self.tenant_id, + UserSkillBinding.user_id.in_(member_ids), + ) + ) + .order_by(UserSkillBinding.user_id.asc(), SkillTag.sort_order.asc(), SkillTag.name.asc()) + ) + ).all() + skill_map: dict[int, list[str]] = defaultdict(list) + for user_id, skill_name in rows: + if skill_name and len(skill_map[int(user_id)]) < 5: + skill_map[int(user_id)].append(str(skill_name)) + return dict(skill_map) + + async def _load_workload_map(self, *, member_ids: list[int]) -> dict[int, float]: + if not member_ids: + return {} + rows = ( + await self.db.execute( + select(UserWorkload.user_id, UserWorkload.workload_ratio).where( + and_( + UserWorkload.tenant_id == self.tenant_id, + UserWorkload.user_id.in_(member_ids), + ) + ) + ) + ).all() + return {int(user_id): self._safe_float(ratio) for user_id, ratio in rows} + + async def _load_workspace_map(self, *, project_id: int, member_ids: list[int]) -> dict[int, list[str]]: + if not member_ids: + return {} + rows = ( + await self.db.execute( + select(DevWorkspaceBinding.user_id, DevWorkspaceBinding.editor_type).where( + and_( + DevWorkspaceBinding.tenant_id == self.tenant_id, + DevWorkspaceBinding.project_id == int(project_id), + DevWorkspaceBinding.user_id.in_(member_ids), + DevWorkspaceBinding.status == "active", + ) + ) + ) + ).all() + workspace_map: dict[int, list[str]] = defaultdict(list) + for user_id, editor_type in rows: + label = self._tool_label(str(editor_type or "local_bridge")) + if label not in workspace_map[int(user_id)]: + workspace_map[int(user_id)].append(label) + return dict(workspace_map) + + def _personal_readiness( + self, + *, + project_id: int, + current_user_id: int, + truth_state: Any, + execution_summary: Any, + workspace_map: dict[int, list[str]], + ) -> PMFlowPersonalExecutionReadiness: + pending_confirmations = len([item for item in truth_state.changeItems if item.status == "pending_review"]) + pending_reviews = len( + [ + item + for item in truth_state.reviewerRouting.items + if item.suggestedReviewer and str(item.suggestedReviewer.userId or "") == str(current_user_id) + ] + ) + waiting_acceptance = len( + [ + event + for event in execution_summary.recentProgressEvents + if str(event.get("event_type") or "") in {"pr_opened", "test_passed", "checks_passed", "handoff_consumed"} + ] + ) + personal_tools = workspace_map.get(int(current_user_id), []) + tool_readiness = [ + PMFlowToolReadinessItem( + key="repo", + label="Repo 连接", + status="ready" if execution_summary.repoBindings else "blocked", + summary="已接入代码仓库。" if execution_summary.repoBindings else "未接入 repo,AI 执行包缺少真实代码落点。", + href=f"/execution-bridge?projectId={project_id}", + ), + PMFlowToolReadinessItem( + key="prompt_package", + label="执行 Prompt Package", + status="ready" if execution_summary.promptPackage else "partial", + summary="已有可交给 Cursor / VS Code / Codex 的执行包。" + if execution_summary.promptPackage + else "尚未生成执行包,需要先从任务进入 Execution Bridge。", + href=f"/execution-bridge?projectId={project_id}", + ), + PMFlowToolReadinessItem( + key="local_workspace", + label="本地工具连接", + status="ready" if personal_tools else "unknown", + summary=f"已连接:{' / '.join(personal_tools)}。" if personal_tools else "未发现当前用户的活跃 Cursor / VS Code / Codex 工作区连接。", + href=f"/execution-bridge?projectId={project_id}", + ), + PMFlowToolReadinessItem( + key="execution_evidence", + label="执行证据回写", + status="ready" if execution_summary.recentProgressEvents else "partial", + summary="已有 branch / commit / PR / test 等回写证据。" + if execution_summary.recentProgressEvents + else "尚未回写执行证据,完成 IDE/Agent 操作后需要同步进度。", + href=f"/execution-bridge?projectId={project_id}", + ), + ] + blockers = [ + item.summary + for item in tool_readiness + if item.status == "blocked" or (item.key in {"prompt_package", "execution_evidence"} and item.status != "ready") + ][:4] + if pending_reviews > 0: + recommended = "先处理分配给你的审查项,再继续推进 AI 执行。" + target = f"/change-review?projectId={project_id}" + elif pending_confirmations > 0: + recommended = "先确认 AI 生成的 ChangeSet,避免执行包基于未确认范围继续扩散。" + target = f"/change-review?projectId={project_id}" + elif blockers: + recommended = "先补齐执行桥的 repo、执行包或回写证据。" + target = f"/execution-bridge?projectId={project_id}" + else: + recommended = "可以继续生成或验收 AI 执行包。" + target = f"/execution-bridge?projectId={project_id}" + status = "blocked" if any(item.status == "blocked" for item in tool_readiness) else "partial" if blockers or pending_confirmations else "ready" + return PMFlowPersonalExecutionReadiness( + status=status, + toolReadiness=tool_readiness, + waitingForUser={ + "pendingConfirmations": pending_confirmations, + "pendingReviews": pending_reviews, + "waitingAcceptance": waiting_acceptance, + }, + blockers=blockers, + recommendedNextStep=recommended, + targetHref=target, + ) + + def _member_signal( + self, + *, + user: User, + project_id: int, + skills: list[str], + workload_ratio: float, + workspace_tools: list[str], + pending_review_count: int, + active_execution_count: int, + evidence_count: int, + ) -> PMFlowTeamExecutionSignal: + role = str(user.role or "MEMBER") + capacity_status = "blocked" if workload_ratio >= 100 else "partial" if workload_ratio >= 70 or pending_review_count >= 3 else "ready" + capacity_label = "过载" if capacity_status == "blocked" else "偏紧" if capacity_status == "partial" else "可承接" + context_status = "partial" if skills or evidence_count else "unknown" + tool_status = "ready" if workspace_tools else "unknown" + warnings: list[str] = [] + if not workspace_tools: + warnings.append("未发现活跃 IDE / Agent 工具连接。") + if not skills: + warnings.append("缺少能力参考标签;只能依据角色、审查和执行证据判断。") + if capacity_status == "blocked": + warnings.append("容量过载,不建议继续追加高风险执行任务。") + + return PMFlowTeamExecutionSignal( + userId=str(user.id), + displayName=user.name or user.username, + role=role, + judgementResponsibilities=self._judgement_responsibilities(role), + reviewResponsibilities=self._review_responsibilities(role), + capacity=PMFlowTeamCapacitySignal( + status=capacity_status, + label=capacity_label, + workloadRatio=round(workload_ratio, 2), + pendingReviewCount=pending_review_count, + activeExecutionCount=active_execution_count, + ), + context=PMFlowTeamContextSignal( + status=context_status, + label="有项目证据或能力参考" if context_status == "partial" else "证据不足", + evidenceCount=evidence_count, + recentContributionCount=pending_review_count + active_execution_count, + ), + toolStatus=PMFlowTeamToolSignal( + status=tool_status, + label=" / ".join(workspace_tools) if workspace_tools else "未接入", + connectedTools=workspace_tools, + ), + referenceSkills=skills, + recommendedModes=self._recommended_modes(role=role, capacity_status=capacity_status, has_tools=bool(workspace_tools)), + warnings=warnings, + ) + + @staticmethod + def _execution_modes( + *, + project_id: int, + has_repo: bool, + has_prompt: bool, + has_progress: bool, + missing_reviewer_count: int, + pending_review_count: int, + ) -> list[PMFlowExecutionModeCapability]: + return [ + PMFlowExecutionModeCapability( + mode="ai_direct", + label="AI 直接生成执行包", + status="ready" if has_repo and has_prompt else "partial" if has_repo else "blocked", + reason="适合低风险、边界清晰的开发动作;必须有 repo 和执行包。", + targetHref=f"/execution-bridge?projectId={project_id}", + ), + PMFlowExecutionModeCapability( + mode="ai_with_user_acceptance", + label="AI 执行 + 用户验收", + status="ready" if has_repo else "blocked", + reason="适合非技术用户推进 UI、文案、轻量功能,用户负责验收结果。", + targetHref=f"/execution-bridge?projectId={project_id}", + ), + PMFlowExecutionModeCapability( + mode="ai_with_technical_review", + label="AI 执行 + 技术审查", + status="blocked" if missing_reviewer_count > 0 else "ready" if pending_review_count > 0 or has_repo else "partial", + reason="涉及权限、数据、安全、迁移或发布门禁时必须进入审查链。", + targetHref=f"/team-layer?projectId={project_id}", + ), + PMFlowExecutionModeCapability( + mode="needs_human_judgement", + label="需要人类先补判断", + status="ready" if pending_review_count > 0 else "partial", + reason="目标、范围、风险或商业取舍未确认时,先回到 Change Review。", + targetHref=f"/change-review?projectId={project_id}", + ), + PMFlowExecutionModeCapability( + mode="blocked", + label="当前不可执行", + status="ready" if not has_repo or not has_prompt or not has_progress or missing_reviewer_count > 0 else "unknown", + reason="缺 repo、执行包、回写证据或 reviewer 时必须显式阻塞,不冒充分配成功。", + targetHref=f"/execution-bridge?projectId={project_id}", + ), + ] + + @staticmethod + def _pending_review_counts_by_user(items: list[Any]) -> dict[int, int]: + counts: dict[int, int] = defaultdict(int) + for item in items: + reviewer = getattr(item, "suggestedReviewer", None) + user_id = getattr(reviewer, "userId", None) if reviewer else None + if user_id is not None: + counts[int(user_id)] += 1 + return dict(counts) + + @staticmethod + def _active_execution_counts_by_user(tasks: list[dict[str, Any]]) -> dict[int, int]: + counts: dict[int, int] = defaultdict(int) + for task in tasks: + assignee = task.get("assignee") if isinstance(task, dict) else None + if isinstance(assignee, dict) and assignee.get("id") is not None: + counts[int(assignee["id"])] += 1 + return dict(counts) + + @staticmethod + def _judgement_responsibilities(role: str) -> list[str]: + normalized = role.upper() + if normalized == "OWNER": + return ["目标取舍", "范围确认", "发布拍板", "高风险变更最终责任"] + if normalized == "REVIEWER": + return ["技术/产品审查", "风险判断", "发布门禁建议"] + return ["结果验收", "上下文补充", "低风险变更确认"] + + @staticmethod + def _review_responsibilities(role: str) -> list[str]: + normalized = role.upper() + if normalized == "OWNER": + return ["高风险 ChangeSet", "范围/发布/商业取舍"] + if normalized == "REVIEWER": + return ["实现风险", "架构/测试/权限审查"] + return ["本人相关任务验收"] + + @staticmethod + def _recommended_modes(*, role: str, capacity_status: str, has_tools: bool) -> list[PMFlowExecutionCapabilityMode]: + if capacity_status == "blocked": + return ["needs_human_judgement"] + normalized = role.upper() + if normalized in {"OWNER", "REVIEWER"}: + return ["ai_with_technical_review", "needs_human_judgement"] + if has_tools: + return ["ai_with_user_acceptance", "ai_direct"] + return ["ai_with_user_acceptance"] + + @staticmethod + def _tool_label(editor_type: str) -> str: + normalized = editor_type.lower() + if "cursor" in normalized: + return "Cursor" + if "codex" in normalized: + return "Codex" + if "vscode" in normalized or "vs_code" in normalized: + return "VS Code" + return editor_type or "本地桥" + + @staticmethod + def _safe_float(value: Any) -> float: + try: + return float(value or 0.0) + except (TypeError, ValueError): + return 0.0 diff --git a/backend/tests/test_ai_routing_governance_service.py b/backend/tests/test_ai_routing_governance_service.py new file mode 100644 index 00000000..757221f3 --- /dev/null +++ b/backend/tests/test_ai_routing_governance_service.py @@ -0,0 +1,253 @@ +from __future__ import annotations + +import ast +from pathlib import Path +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 import ai_routing +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.ai_call_log import AICallLog +from app.models.base import Base +from app.models.integration import Integration +from app.services.ai_routing_governance_service import AIRoutingGovernanceService + + +def _use_sqlite_json_types(*tables) -> None: + for table in tables: + for column in table.columns: + if column.type.__class__.__name__ in {"JSONB", "ARRAY", "Vector"}: + column.type = JSON() + + +@pytest_asyncio.fixture +async def db_session(): + engine = create_async_engine("sqlite+aiosqlite:///:memory:") + tables = [Integration.__table__, AICallLog.__table__] + _use_sqlite_json_types(*tables) + async with engine.begin() as conn: + await conn.run_sync(lambda sync_conn: Base.metadata.create_all(sync_conn, tables=tables)) + maker = async_sessionmaker(engine, expire_on_commit=False) + async with maker() as session: + yield session + await engine.dispose() + + +def _engine_bundle() -> dict: + return { + "active_engine_id": "low-1", + "settings": {"enable_local_kb_qa": False, "local_kb_qa_scope": "kb_qa_only"}, + "engines": [ + { + "id": "low-1", + "name": "Qwen 低阶引擎", + "provider": "glm", + "model": "qwen-plus", + "base_url": "https://dashscope.aliyuncs.com/compatible-mode/v1", + "api_key": "db-low-key", + }, + { + "id": "high-1", + "name": "OpenAI 高阶引擎", + "provider": "openai", + "model": "GLM-5", + "base_url": "https://api.edgefn.net/v1", + "api_key": "db-high-key", + }, + ], + } + + +@pytest.mark.asyncio +async def test_overview_masks_keys_and_reports_env_override(db_session, monkeypatch) -> None: + monkeypatch.setenv("DASHSCOPE_API_KEY", "local-placeholder") + db_session.add(Integration(tenant_id=1, type="llm_engine", config=_engine_bundle(), is_active=True)) + await db_session.commit() + + data = await AIRoutingGovernanceService(db_session, tenant_id=1).get_overview() + + assert data["config_version"] + assert data["slots"][0]["label"] == "快速槽" + assert data["slots"][0]["api_key_masked"] == "db-l...-key" + assert any(item["code"] == "env_key_override" for item in data["diagnostics"]) + assert "api_env" not in str(data) + assert "local-placeholder" not in str(data) + assert "db-low-key" not in str(data) + + +@pytest.mark.asyncio +async def test_admin_console_reports_protected_downgrade_and_structured_failures(db_session) -> None: + db_session.add(Integration(tenant_id=1, type="llm_engine", config=_engine_bundle(), is_active=True)) + db_session.add( + Integration( + tenant_id=1, + type="model_routing", + config={"mappings": {"adaptive_rag_answer_deep": {"tier_alias": "low", "enabled": True}}}, + is_active=True, + ) + ) + for idx in range(3): + db_session.add( + AICallLog( + tenant_id=1, + function_name="pmflow_launch_draft_v1", + prompt=f"prompt {idx}", + response="", + tokens_used=1, + cost=0, + status="structured_empty", + planned_tier="low", + executed_tier="low", + routing_decision="rule_match", + route_source="default", + route_rule_name="engine_low_fallback", + routing_config_version="v3", + routing_config_updated_at="2026-03-26T21:30:00+08:00", + ) + ) + await db_session.commit() + + data = await AIRoutingGovernanceService(db_session, tenant_id=1).get_admin_console() + + codes = {item["code"] for item in data["diagnostics"]} + assert "protected_function_downgraded" in codes + assert "structured_failure_rate" in codes + assert data["recent_failures"][0]["status_label"] == "模型返回为空" + assert data["scene_routes"][0]["label"] == "项目启动草案" + + +@pytest.mark.asyncio +async def test_team_policy_persists_integration_without_runtime_route_changes(db_session) -> None: + service = AIRoutingGovernanceService(db_session, tenant_id=1) + payload = { + "strategy": "quality_first", + "scenes": [{"key": "project_launch", "policy": "深度起草并进入确认", "mode": "深度优先"}], + "automation_boundary": "所有写操作进入待确认。", + } + + saved = await service.update_team_policy(payload=payload, updated_by=7) + await db_session.commit() + loaded = await service.get_team_policy() + + assert saved["strategy_label"] == "质量优先" + assert loaded["scenes"][0]["policy"] == "深度起草并进入确认" + assert loaded["automation_boundary"] == "所有写操作进入待确认。" + + +def _build_app(*, role: str = "REVIEWER") -> FastAPI: + app = FastAPI() + app.include_router(ai_routing.router) + + class FakeDb: + async def commit(self) -> None: + return None + + async def _fake_get_db(): + yield FakeDb() + + app.dependency_overrides[get_db] = _fake_get_db + app.dependency_overrides[get_tenant_id] = lambda: 9 + app.dependency_overrides[get_current_user] = lambda: SimpleNamespace( + id=101, + tenant_id=9, + username="tester", + role=role, + ) + return app + + +def test_api_router_exposes_ai_routing_routes() -> None: + router_source = (Path(__file__).parents[1] / "app" / "api" / "router.py").read_text() + router_tree = ast.parse(router_source) + route_assignments = {} + for node in router_tree.body: + if not isinstance(node, ast.Assign): + continue + for target in node.targets: + if isinstance(target, ast.Name) and target.id in {"CORE_ROUTE_MODULES", "OPTIONAL_ROUTE_GROUPS"}: + route_assignments[target.id] = ast.literal_eval(node.value) + + assert "ai_routing" in route_assignments["CORE_ROUTE_MODULES"] + assert "model_routing" in route_assignments["CORE_ROUTE_MODULES"] + assert "ai_routing" not in route_assignments["OPTIONAL_ROUTE_GROUPS"]["ai_control"] + assert "model_routing" not in route_assignments["OPTIONAL_ROUTE_GROUPS"]["ai_control"] + route_paths = {str(getattr(route, "path", "")).strip() for route in ai_routing.router.routes} + assert "/ai-routing/overview" in route_paths + assert "/ai-routing/team-policy" in route_paths + assert "/ai-routing/admin-console" in route_paths + + +@pytest.mark.asyncio +async def test_ai_routing_overview_requires_governance_role() -> None: + app = _build_app(role="BUILDER") + async with AsyncClient(transport=ASGITransport(app=app), base_url="http://test") as client: + response = await client.get("/ai-routing/overview") + + assert response.status_code == 403 + assert response.json()["code"] == 403 + + +@pytest.mark.asyncio +async def test_ai_routing_api_returns_enveloped_payloads(monkeypatch) -> None: + class FakeService: + def __init__(self, db, *, tenant_id: int): + assert tenant_id == 9 + _ = db + + async def get_overview(self): + return {"slots": [], "diagnostics": []} + + async def get_team_policy(self): + return {"strategy": "balanced", "scenes": []} + + async def update_team_policy(self, *, payload, updated_by: int): + assert updated_by == 101 + return {**payload, "updated_by": updated_by} + + async def get_admin_console(self): + return {"providers": [], "scene_routes": []} + + async def run_diagnostics(self): + return {"items": [], "summary": {"ok": 1}} + + async def fake_log_operation(*args, **kwargs): + _ = args, kwargs + + monkeypatch.setattr(ai_routing, "AIRoutingGovernanceService", FakeService) + monkeypatch.setattr(ai_routing, "log_operation", fake_log_operation) + + app = _build_app(role="OWNER") + async with AsyncClient(transport=ASGITransport(app=app), base_url="http://test") as client: + overview = await client.get("/ai-routing/overview") + policy = await client.get("/ai-routing/team-policy") + saved = await client.put("/ai-routing/team-policy", json={"strategy": "quality_first", "scenes": []}) + console = await client.get("/ai-routing/admin-console") + diagnostics = await client.post("/ai-routing/diagnostics/run") + + assert overview.json()["code"] == 0 + assert policy.json()["data"]["strategy"] == "balanced" + assert saved.json()["data"]["strategy"] == "quality_first" + assert console.json()["data"]["providers"] == [] + assert diagnostics.json()["data"]["summary"]["ok"] == 1 + + +@pytest.mark.asyncio +async def test_reviewer_cannot_save_team_policy(monkeypatch) -> None: + class FakeService: + pass + + monkeypatch.setattr(ai_routing, "AIRoutingGovernanceService", FakeService) + app = _build_app(role="REVIEWER") + async with AsyncClient(transport=ASGITransport(app=app), base_url="http://test") as client: + response = await client.put("/ai-routing/team-policy", json={"strategy": "quality_first", "scenes": []}) + + assert response.status_code == 403 + assert response.json()["message"] == "仅 OWNER 可保存团队 AI 策略" diff --git a/backend/tests/test_ai_steward_project_from_text_contract.py b/backend/tests/test_ai_steward_project_from_text_contract.py index da63f061..9972ec98 100644 --- a/backend/tests/test_ai_steward_project_from_text_contract.py +++ b/backend/tests/test_ai_steward_project_from_text_contract.py @@ -2,97 +2,52 @@ from __future__ import annotations import json from pathlib import Path -from types import SimpleNamespace import pytest from fastapi import FastAPI from httpx import ASGITransport, AsyncClient +from app.api import legacy_ai_steward_api from app.api.ai_steward_api import routes_capture -from app.api.ai_steward_api.schemas import ProjectFromTextResponse -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.services.ai_steward_service import mixin_creation_normalize_persist from app.services.ai_steward_service import AIStewardService from app.services.task_persistence_core import SkillTagResolution @pytest.mark.asyncio -async def test_project_from_text_returns_enveloped_signal_sources(monkeypatch) -> None: - async def fake_create_project_from_natural_language(self, **kwargs): # noqa: ANN001 - _ = (self, kwargs) - return { - "project": {"id": "11", "name": "AI 客服", "title": "AI 客服平台", "description": "demo", "status": "active"}, - "signal": {"id": "21", "content": "main signal", "impact_score": 0.91}, - "charter": {"id": "31", "title": "宪章", "content": {"summary": "demo"}}, - "tasks": [], - "risks": [], - "analysis": { - "project_name": "AI 客服", - "project_title": "AI 客服平台", - "tech_stack": ["FastAPI", "React"], - "timeline": "3 周", - "primary_role": "backend", - "requirement_items": [ - { - "section": "核心痛点", - "title": "内容本地化", - "description": "翻译公司贵、慢、不地道", - } - ], - }, - "signal_sources": [ - { - "id": "101", - "source_kind": "custom", - "source_name": "核心痛点 - 内容本地化", - "source_identifier": "custom:core-pain-content-localization", - "display_source_kind": "自定义来源", - "technical_identifier": "custom:core-pain-content-localization", - "description": "翻译公司贵、慢、不地道", - "section": "核心痛点", - } - ], - } - - async def _fake_get_db(): - yield object() - - monkeypatch.setattr(AIStewardService, "create_project_from_natural_language", fake_create_project_from_natural_language) - +async def test_project_from_text_returns_gone_envelope() -> None: app = FastAPI() app.include_router(routes_capture.router) - 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=9, - tenant_id=1, - name="管理员", - username="admin", - role="REVIEWER", - ) transport = ASGITransport(app=app) async with AsyncClient(transport=transport, base_url="http://test") as client: response = await client.post("/ai-steward/project-from-text", json={"input_text": "我们要做一个 AI 客服工具"}) - assert response.status_code == 200 + assert response.status_code == 410 payload = response.json() - assert payload["code"] == 0 - assert payload["message"] == "success" - assert payload["data"]["project"]["id"] == "11" - assert payload["data"]["signal_sources"][0]["source_name"] == "核心痛点 - 内容本地化" - assert payload["data"]["signal_sources"][0]["display_source_kind"] == "自定义来源" - assert payload["data"]["signal_sources"][0]["technical_identifier"] == "custom:core-pain-content-localization" + assert payload["code"] == 410 + assert payload["message"] == "旧项目创建入口已下线,请使用新项目启动台生成草案并确认创建。" + assert payload["data"] is None -def test_project_from_text_response_schema_exposes_signal_source_display_fields() -> None: - schema = ProjectFromTextResponse.model_json_schema() - signal_source = schema["$defs"]["ProjectFromTextSignalSource"] +@pytest.mark.asyncio +async def test_legacy_project_from_text_gone_route_is_core_mounted() -> None: + router_source = Path("app/api/router.py").read_text(encoding="utf-8") + assert '"legacy_ai_steward_api"' in router_source - assert "display_source_kind" in signal_source["properties"] - assert "technical_identifier" in signal_source["properties"] + app = FastAPI() + app.include_router(legacy_ai_steward_api.router) + + transport = ASGITransport(app=app) + async with AsyncClient(transport=transport, base_url="http://test") as client: + response = await client.post("/ai-steward/project-from-text", json={"input_text": "我们要做一个 AI 客服工具"}) + + assert response.status_code == 410 + assert response.json() == { + "code": 410, + "message": "旧项目创建入口已下线,请使用新项目启动台生成草案并确认创建。", + "data": None, + } def test_ai_steward_no_longer_keeps_instruction_or_stub_heuristics_in_runtime_modules() -> None: diff --git a/backend/tests/test_context_cache_profile_catalog.py b/backend/tests/test_context_cache_profile_catalog.py index 37c94946..70669bed 100644 --- a/backend/tests/test_context_cache_profile_catalog.py +++ b/backend/tests/test_context_cache_profile_catalog.py @@ -29,14 +29,14 @@ def _find_route(items: list[dict], route: str) -> dict: def test_default_feature_catalog_uses_canonical_rag_ask() -> None: items = ContextCacheProfileBasicsMixin._default_feature_catalog_items() - ai_steward_item = _find_route(items, "/ai-steward") - apis = list(ai_steward_item.get("apis") or []) + space_item = _find_route(items, "/space") + apis = list(space_item.get("apis") or []) assert "/rag/ask" in apis assert "/ai-steward/rag/ask" not in apis def test_route_feature_summary_uses_canonical_rag_ask() -> None: - _summary, apis, _aliases = ContextCacheProfileRouteParserMixin._route_feature_summary("/ai-steward") + _summary, apis, _aliases = ContextCacheProfileRouteParserMixin._route_feature_summary("/space") assert "/rag/ask" in apis assert "/ai-steward/rag/ask" not in apis diff --git a/backend/tests/test_pmflow_launch_api.py b/backend/tests/test_pmflow_launch_api.py index 247cdc89..b693ab75 100644 --- a/backend/tests/test_pmflow_launch_api.py +++ b/backend/tests/test_pmflow_launch_api.py @@ -21,6 +21,7 @@ from app.models import ( # noqa: E402 FeedbackIngest, FeishuItem, GitHubWebhookDelivery, + IterationRecommendation, ModelCatalogItem, ModelEvalRun, ModelRecommendationSnapshot, @@ -80,6 +81,7 @@ TABLES = [ ProgressEvent.__table__, ReleaseValidationRun.__table__, FeedbackIngest.__table__, + IterationRecommendation.__table__, RevenueEvent.__table__, ROISnapshot.__table__, GitHubWebhookDelivery.__table__, diff --git a/backend/tests/test_pmflow_os_projection_api.py b/backend/tests/test_pmflow_os_projection_api.py index 4fe8c2e2..fdcb0ae6 100644 --- a/backend/tests/test_pmflow_os_projection_api.py +++ b/backend/tests/test_pmflow_os_projection_api.py @@ -1,7 +1,7 @@ from __future__ import annotations import os -from datetime import datetime +from datetime import datetime, timedelta from types import SimpleNamespace import pytest @@ -21,6 +21,7 @@ from app.models import ( # noqa: E402 FeedbackIngest, FeishuItem, GitHubWebhookDelivery, + HumanCapacityOverride, IterationRecommendation, ModelCatalogItem, ModelEvalRun, @@ -47,12 +48,19 @@ from app.models import ( # noqa: E402 TruthNode, User, UserProject, + SkillTag, + UserSkillBinding, + UserWorkload, ) from app.models.base import Base # noqa: E402 TABLES = [ User.__table__, + SkillTag.__table__, + UserSkillBinding.__table__, + UserWorkload.__table__, + HumanCapacityOverride.__table__, FeishuItem.__table__, Signal.__table__, Project.__table__, @@ -150,6 +158,66 @@ def _build_app(session, current_user) -> FastAPI: return app +async def _seed_open_change_set(session, *, user, project, risk_level: str = "high", item_count: int = 1) -> None: + thread = PMFlowThread( + tenant_id=1, + project_id=project.id, + launch_draft_id=None, + title="容量计划测试线程", + objective="构造真实 ChangeSet 人工触点", + status="active", + source=f"capacity-test-{risk_level}-{item_count}", + ) + session.add(thread) + await session.flush() + message = PMFlowThreadMessage( + tenant_id=1, + thread_id=thread.id, + project_id=project.id, + role="user", + content="需要人工确认容量触点。", + source="web", + attachments=[], + message_metadata={}, + status="sent", + ) + session.add(message) + await session.flush() + change_set = PMFlowChangeSet( + tenant_id=1, + project_id=project.id, + thread_id=thread.id, + source_message_id=message.id, + status="open", + summary="容量计划 ChangeSet", + headline="容量计划 ChangeSet", + extraction_scope="latest_message", + routing_meta={}, + created_by=user.id, + ) + session.add(change_set) + await session.flush() + for index in range(item_count): + session.add( + PMFlowChangeItem( + tenant_id=1, + change_set_id=change_set.id, + project_id=project.id, + group="risk" if risk_level == "high" else "field_update", + object_type="Risk" if risk_level == "high" else "Task", + object_id=None, + title=f"容量触点 {index + 1}", + summary="用于验证动态容量基线。", + diff="pending review", + payload={}, + confidence=0.9, + risk_level=risk_level, + status="pending_review", + ) + ) + await session.commit() + + @pytest.mark.asyncio async def test_truth_state_empty_project_returns_compatible_empty_projection(db_env) -> None: session = db_env @@ -290,6 +358,192 @@ async def test_execution_summary_empty_state_is_pending_with_warnings(db_env) -> assert payload["activeDevTasks"] == [] +@pytest.mark.asyncio +async def test_execution_capabilities_returns_read_only_snapshot_with_warnings(db_env) -> None: + session = db_env + user, project = await _seed_user_project(session) + skill = SkillTag( + tenant_id=1, + name="产品判断", + slug="product-judgement", + category="product", + description="判断需求范围与验收结果", + is_system=True, + sort_order=1, + ) + session.add(skill) + await session.flush() + session.add_all( + [ + UserSkillBinding( + tenant_id=1, + user_id=user.id, + skill_tag_id=skill.id, + proficiency_level="advanced", + proficiency=4, + level="advanced", + years_experience=2, + ), + UserWorkload( + tenant_id=1, + user_id=user.id, + current_assigned_hours=18, + max_available_hours_per_week=40, + workload_ratio=45, + ), + DevWorkspaceBinding( + tenant_id=1, + project_id=project.id, + user_id=user.id, + workspace_key="cursor-local", + repo_root="/tmp/pmflow", + editor_type="cursor", + status="active", + ), + ] + ) + await session.commit() + app = _build_app(session, SimpleNamespace(id=user.id, tenant_id=1, role="MEMBER")) + + async with AsyncClient(transport=ASGITransport(app=app), base_url="http://test") as client: + response = await client.get(f"/pmflow-os/projects/{project.id}/execution-capabilities") + + assert response.status_code == 200 + payload = response.json()["data"] + assert payload["projectId"] == str(project.id) + assert {item["mode"] for item in payload["executionModes"]} >= { + "ai_direct", + "ai_with_user_acceptance", + "ai_with_technical_review", + "needs_human_judgement", + "blocked", + } + assert payload["personalReadiness"]["waitingForUser"]["pendingConfirmations"] == 0 + assert payload["teamSignals"][0]["displayName"] == "Owner" + assert payload["teamSignals"][0]["toolStatus"]["connectedTools"] == ["Cursor"] + assert payload["teamSignals"][0]["referenceSkills"] == ["产品判断"] + assert "repo_missing" in " ".join(payload["warnings"]) + + +@pytest.mark.asyncio +async def test_execution_capabilities_requires_project_membership(db_env) -> None: + session = db_env + user, project = await _seed_user_project(session, user_role="MEMBER", member=False) + app = _build_app(session, SimpleNamespace(id=user.id, tenant_id=1, role="MEMBER")) + + async with AsyncClient(transport=ASGITransport(app=app), base_url="http://test") as client: + response = await client.get(f"/pmflow-os/projects/{project.id}/execution-capabilities") + + assert response.status_code == 403 + + +@pytest.mark.asyncio +async def test_capacity_plan_uses_current_touch_pool_for_dynamic_baseline(db_env) -> None: + session = db_env + user, project = await _seed_user_project(session, user_role="OWNER") + await _seed_open_change_set(session, user=user, project=project, risk_level="high", item_count=3) + app = _build_app(session, SimpleNamespace(id=user.id, tenant_id=1, role="OWNER")) + + async with AsyncClient(transport=ASGITransport(app=app), base_url="http://test") as client: + response = await client.get(f"/pmflow-os/projects/{project.id}/capacity-plan") + + assert response.status_code == 200 + payload = response.json()["data"] + member_plan = payload["memberPlans"][0] + assert payload["teamPlannedTouchMinutes"]["focus"] > 0 + assert member_plan["suggestedBaseline"]["focus"] > member_plan["suggestedBaseline"]["acceptance"] + assert member_plan["plannedTouchMinutes"]["total"] > 0 + assert "动态基线来自当前任务池" in " ".join(member_plan["explanations"]) + + +@pytest.mark.asyncio +async def test_capacity_override_simulate_apply_and_permission_boundary(db_env) -> None: + session = db_env + owner, project = await _seed_user_project(session, user_role="OWNER") + member = User( + tenant_id=1, + username="capacity-member", + email="capacity-member@example.com", + password_hash="hashed", + name="普通成员", + role="MEMBER", + assigned_domains=[], + workload=0, + status="active", + is_active=True, + ) + session.add(member) + await session.flush() + session.add(UserProject(tenant_id=1, user_id=member.id, project_id=project.id)) + await session.commit() + payload = { + "userId": str(owner.id), + "overrideType": "overtime", + "addedMinutes": 120, + "overtimeType": "focus", + "maxPlannedUtilization": 0.95, + "reason": "今天需要负责人兜住高风险判断", + } + + owner_app = _build_app(session, SimpleNamespace(id=owner.id, tenant_id=1, role="OWNER")) + async with AsyncClient(transport=ASGITransport(app=owner_app), base_url="http://test") as client: + simulated = await client.post(f"/pmflow-os/projects/{project.id}/capacity-overrides/simulate", json=payload) + applied = await client.post(f"/pmflow-os/projects/{project.id}/capacity-overrides", json=payload) + plan = await client.get(f"/pmflow-os/projects/{project.id}/capacity-plan") + + assert simulated.status_code == 200 + assert simulated.json()["data"]["afterPlan"]["effectiveMinutes"] == 600 + assert "加班只增加时间预算" in " ".join(simulated.json()["data"]["impactSummary"]) + assert applied.status_code == 200 + assert applied.json()["data"]["reason"] == payload["reason"] + member_plan = next(item for item in plan.json()["data"]["memberPlans"] if item["userId"] == str(owner.id)) + assert member_plan["effectiveMinutes"] == 600 + assert member_plan["manualOverride"]["overrideType"] == "overtime" + + member_app = _build_app(session, SimpleNamespace(id=member.id, tenant_id=1, role="MEMBER")) + async with AsyncClient(transport=ASGITransport(app=member_app), base_url="http://test") as client: + forbidden = await client.post(f"/pmflow-os/projects/{project.id}/capacity-overrides", json=payload) + + assert forbidden.status_code == 403 + + +@pytest.mark.asyncio +async def test_expired_capacity_override_is_not_used_in_today_plan(db_env) -> None: + session = db_env + user, project = await _seed_user_project(session, user_role="OWNER") + yesterday = datetime.now() - timedelta(days=1) + session.add( + HumanCapacityOverride( + tenant_id=1, + project_id=project.id, + user_id=user.id, + date=datetime.now().date(), + override_type="overtime", + added_minutes=180, + overtime_type="general", + max_planned_utilization=0.95, + work_type_bias={}, + high_risk_limit=None, + reason="已过期的加班安排", + expires_at=yesterday, + created_by=user.id, + ) + ) + await session.commit() + app = _build_app(session, SimpleNamespace(id=user.id, tenant_id=1, role="OWNER")) + + async with AsyncClient(transport=ASGITransport(app=app), base_url="http://test") as client: + response = await client.get(f"/pmflow-os/projects/{project.id}/capacity-plan") + overrides = await client.get(f"/pmflow-os/projects/{project.id}/capacity-overrides") + + assert response.status_code == 200 + member_plan = response.json()["data"]["memberPlans"][0] + assert member_plan["effectiveMinutes"] == 480 + assert member_plan["manualOverride"] is None + assert overrides.status_code == 200 + assert overrides.json()["data"][0]["reason"] == "已过期的加班安排" + + @pytest.mark.asyncio async def test_execution_summary_projects_real_prompt_repo_tasks_and_events(db_env) -> None: session = db_env diff --git a/docs/INBOX_INLINE_OPS_3_PHASE_CHECKLIST.md b/docs/INBOX_INLINE_OPS_3_PHASE_CHECKLIST.md deleted file mode 100644 index 863af621..00000000 --- a/docs/INBOX_INLINE_OPS_3_PHASE_CHECKLIST.md +++ /dev/null @@ -1,121 +0,0 @@ -# Inbox 消息台内联操作(三期执行清单) - -> 目标:用户在消息台完成待处理动作,尽量不跳转;实现过程中不绕开现有 AI PMO 状态机闭环。 - -## Phase 1(先落地,最小闭环) - -### 范围 -- 前端内联处理 `missing_project_scope` 卡片。 -- 后端只做字段投影增强,不新增执行链路接口。 - -### 后端 Checklist -- [x] `GET /ai-pmo/work-items` 增加可直接渲染字段: - - [x] `latest_policy_gate_code` - - [x] `approval_requirement`(至少含 `required/reason/message`) -- [x] 字段来源明确为“最近一条 `decision_records` 投影”,禁止前端猜文案。 -- [x] 保持现有 `POST /ai-pmo/work-items/{id}/decision` 语义不变。 - -### 前端 Checklist -- [x] 消息台卡片识别条件: - - [x] `state=waiting_manual` - - [x] `latest_policy_gate_code=missing_project_scope` -- [x] 内联面板显示“项目下拉 + 确认关联”。 -- [x] 项目下拉数据源使用 `GET /projects?status=active`。 -- [x] 点击确认后调用: - - [x] `POST /ai-pmo/work-items/{id}/decision` - - [x] body: `{ action: "approve", project_id }` -- [x] 成功后刷新消息台并显示 followup 回执文案。 - -### 验收 -- [x] 缺项目归属卡片可在消息台直接补项目并继续执行。 -- [x] 不新增 `resolve-project` 接口。 -- [x] 不直连 `TypedExecutor`。 - ---- - -## Phase 2(扩展内联动作,不破坏闭环) - -### 范围 -- 支持更多 `waiting_manual` 场景统一内联处理。 -- 负载预警改为复用现有 workload 接口。 - -### 后端 Checklist -- [ ] `GET /ai-pmo/work-items` 继续补充前端所需稳定字段(如 `intent`、`execution_result` 关键子字段)。 -- [ ] 若需负责人相关动作,先定义 owner 写入目标(task/registry/work_item),再补接口,不先拍脑袋加 `assign-owner`。 - -### 前端 Checklist -- [ ] 通用 `waiting_manual` 面板(非 missing_project_scope)支持: - - [ ] 继续执行 - - [ ] 标记失效 - - [ ] 查看详情 -- [ ] 负载预警卡片实现方式: - - [ ] 预览建议:`POST /workloads/ai-optimize` - - [ ] 应用建议:`POST /workloads/ai-optimize/apply` -- [ ] 禁止依赖不存在接口:`GET /workloads/suggestions`。 - -### 验收 -- [ ] 负载预警可在消息台触发建议预览与应用。 -- [ ] 所有动作仍通过既有审计/回执链路。 - ---- - -## Phase 3(信号源治理与增强) - -### 范围 -- 信号源相关内联治理动作补齐(编辑/删除/确认信息)。 -- 明确通用信号源 API 边界后再接入消息台按钮。 - -### 后端 Checklist -- [ ] 先梳理“通用信号源 CRUD”真实接口归属(当前 `/signal-sources` 为食谱 webhook,不可直接复用)。 -- [ ] 若缺通用接口,按规范新增并补测试。 -- [ ] 补充操作审计(operation logs)与幂等约束。 - -### 前端 Checklist -- [ ] 基于真实通用接口接入: - - [ ] 编辑信号源 - - [ ] 删除信号源(确认弹窗) -- [ ] 不在 `InboxPage` 内直接写 `fetch`,统一走 `frontend/src/api/`。 - -### 验收 -- [ ] 消息台信号源治理动作可用,且接口与模型一致。 -- [ ] 审计可追踪,状态可回放。 - ---- - -## 统一守则(每期都要过) - -- [x] 不新增绕开 `ai_pmo` 闭环的执行入口。 -- [x] 不做“前端猜测字符串”分支判断,优先消费稳定结构字段。 -- [x] 变更后跑通: - - [x] `npm run build` - - [x] `tsc --noEmit` - - [x] 相关后端测试(新增接口时必须补测) - -## 回填记录(2026-03-12) - -- Phase 1 状态:已完成并通过验证。 -- 后端验证: - - `../venv/bin/python -m pytest -q tests/test_ai_pmo_work_item_decision_api.py` → `3 passed` - - `../venv/bin/python -m py_compile app/api/ai_pmo.py` → 通过 -- 前端验证: - - `npm run build` → 通过 - - `npx tsc --noEmit` → 通过 -- 热修(2026-03-12 同日): - - `POST /ai-pmo/work-items/{id}/decision` 在 `waiting_manual + project_id` 场景改为“快速返回 + 后台续跑”,避免消息台按钮长期卡在“处理中...” - - `POST /ai-pmo/work-items/{id}/decision` 在上述场景会先把目标 work item 状态置为 `executing`(前端即时从“待处理”转为“处理中”) - - 前端 `decideWorkItem` 增加 15s 超时保护,超时会回到可交互状态并提示错误 - - 口水话(短 query,如“怎么样了/在吗/辛苦了”)在后端列表接口过滤,不再下发到消息台前端(数据仍保留在后端记录) - - 修复信号源续跑事务穿透:`signal_source_registries` 重复键分支改为 savepoint,且 PMFlow 本地路由层 指挥链改为隔离 Session,避免 rollback 回滚外层新建 `signal`,彻底消除 `ai_orchestration_runs.signal_id` 外键报错 - - 修复“续跑后仍停在待人工确认”:`signal_source.onboard` 在 `pending_manual/signal_source_pending_manual` 且已具备 `project_id+signal_id` 时强制落 `succeeded`,并记录 `manual_gate_resolved=true` -- Phase 2/3:未开始,保持待办。 - -## 本清单对应文件范围(执行时参考) - -- `frontend/src/pages/InboxPage.tsx` -- `frontend/src/components/inbox/` -- `frontend/src/lib/inbox.ts` -- `frontend/src/types/inbox.ts` -- `frontend/src/api/aiPmo.ts` -- `frontend/src/api/projects.ts` -- `frontend/src/api/workloads.ts` -- `backend/app/api/ai_pmo.py` diff --git a/docs/PMFLOW_PRODUCT_BLUEPRINT.md b/docs/PMFLOW_PRODUCT_BLUEPRINT.md index 0d4ff557..de5c5d57 100644 --- a/docs/PMFLOW_PRODUCT_BLUEPRINT.md +++ b/docs/PMFLOW_PRODUCT_BLUEPRINT.md @@ -311,6 +311,36 @@ PMFlow 的团队方向是: 这比传统成员列表和任务看板更重要。 +### 6.4 Execution Capability Graph / 执行能力图谱 + +PMFlow 面向的是 AI 应用开发,不再把“人会哪种编程语言”作为任务分配中心。旧的用户、技能、负载三模块降级为底层信号;新的团队与个人能力模型是: + +**AI + 工具 + 人类判断责任 + 项目上下文 + 容量信号。** + +任务推进先判断执行模式,而不是先找会 React / Python 的人: + +- `ai_direct`:AI 可直接生成执行包,适合低风险、边界清晰的动作。 +- `ai_with_user_acceptance`:AI 执行,用户负责验收结果,适合非技术或半技术用户。 +- `ai_with_technical_review`:AI 执行但必须有人类 reviewer 审查,适合权限、数据、安全、迁移、发布门禁等高风险改动。 +- `needs_human_judgement`:范围、目标、商业取舍或验收标准不清时,先让人类补判断。 +- `blocked`:缺 repo、插件、执行包、测试命令、回写证据或 reviewer 时显式阻塞,禁止冒充可执行。 + +后续团队空间只能扩展此模型,不能回到传统“用户管理 / 技能管理 / 团队负载”三件套作为主体验。技能标签可以继续作为参考词库,但不能成为 AI 分配任务的唯一或核心依据。 + +### 6.5 Adaptive Human Capacity / 自适应人类容量 + +AI 应用时代不能继续把“工时负载”理解成固定工时槽或固定类型比例。PMFlow 的容量模型以人机混排为前提: + +- 时间是硬上限:人类一天仍有有限注意力、会议、休息和疲劳边界。 +- 类型是弹性的:深度判断、审查、验收、协调共享同一个人类容量池,不再写死 35/25/20/20。 +- 风险和疲劳决定边界:总时间未满不代表可以继续加高风险判断;加班只增加时间预算,不消除质量风险。 +- 每日建议基线由当天真实任务池动态生成,依据人工触点、风险等级、截止时间、强绑定关系、成员角色、上下文熟悉度和工具准备度。 +- 负责人可以手工调整今日有效容量、加班容量和任务类型偏向,但必须先看到模拟结果,调整必须有原因、审计和过期时间。 + +个人空间只展示“我的今日容量”:我今天有效容量多少、已计划人工触点多少、为什么还能/不能承接、AI 预处理可释放多少人工时间。Team Layer 展示“团队今日容量”:团队需求结构、成员容量卡、瓶颈、AI 预处理建议、负责人调整入口和调整记录。`/workloads` 只保留为高级容量明细,传统工时和旧负载比例仅作为历史成本参考。 + +后续团队空间只能扩展自适应容量模型,不能回退为传统工时表、固定工作类型槽或简单 workload ratio 分配。 + ## 7. 底层对象模型 首版必须先定对象层,否则 UI 会漂。 diff --git a/docs/PMFLOW_PRODUCT_DELIVERY_STANDARD.md b/docs/PMFLOW_PRODUCT_DELIVERY_STANDARD.md index 6b8243b6..07a37e32 100644 --- a/docs/PMFLOW_PRODUCT_DELIVERY_STANDARD.md +++ b/docs/PMFLOW_PRODUCT_DELIVERY_STANDARD.md @@ -91,6 +91,8 @@ 5. `ExecutionBridge` 页面还没有消费 dev-sync / GitHub / PromptPackage 真实接口。 6. `ReleaseGrowth` 已接入 execution evidence gate、release validation run 与 feedback ingest;revenue、ROI、compliance 还没有串成完整发布后反馈循环。 7. `TeamLayer` 已接入 reviewer routing / review decision 读模型与 Team Feed 只读动态流,能展示待审 ChangeSet、建议审查人、审查原因、风险类型、缺角色 warning、审查状态、审查意见,以及 review / execution / release gate 聚合动态;Role Inbox、Decision Desk 仍缺完整真实数据模型。 +8. `Execution Capability Graph` 已确认为团队空间前置模型:旧 `用户管理 / 能力管理 / 团队负载` 不再作为一级产品功能,降级为高级设置/明细路由;个人空间展示执行准备度,TeamLayer 展示执行能力与容量信号;后续团队空间只能扩展 `AI + 工具 + 人类判断责任 + 项目上下文 + 容量信号`,不得回到传统用户/技能/工时报表三件套。 +9. `Adaptive Human Capacity` 已确认为旧工时负载的替代模型:每日 `suggestedBaseline` 必须由当天真实任务池动态生成,所有人工触点共享同一个容量池;总时间判断硬上限,类型占比只判断疲劳和质量风险。负责人 override 必须先 simulate,再 apply,并记录原因、审计、过期时间;团队空间后续不得回到固定工作类型槽、静态技能标签或旧 workload ratio 直接派活。 ## 2. 下一步推进方向 @@ -294,6 +296,7 @@ 范围: - 成员归属。 +- 执行能力图谱:先判断 AI 直接执行、AI+用户验收、AI+技术审查、需人类补判断或当前阻塞。 - 角色收件箱 Role Inbox。 - Team Feed。 - Decision Desk 或高风险变更多人审查。 @@ -825,6 +828,65 @@ P8 可以三条 lane 并行,但共享高冲突文件必须由 Main Flow 或 Sh ## 8. 进度台账 +### 2026-04-27 / integration-current-worktree-cleanup / done + +完成内容: + +- 收口当前 `codex/team-feed-p1-review-activity-stream` 脏工作区,确认本批次改动属于同一轮 Shell / Integration 收口:Team Feed 只读动态流、Adaptive Human Capacity、AI Routing / Providers 产品化控制台、Legacy AI Steward 下线、旧 Inbox 独立页删除,以及对应文档台账同步。 +- 共享入口已统一:`frontend/src/router/index.tsx` 只保留 `/ai-steward` legacy redirect、旧项目路由 redirect、`/space` 与项目主链身份边界、AI 运营中心 manager boundary;`backend/app/api/router.py` 将 `legacy_ai_steward_api / ai_routing / model_routing` 放入核心路由,确保 lean profile 下治理入口不 404。 +- migration 链路已检查:`20260427_add_human_capacity_overrides` 单 head,父级为 `20260426_add_pmflow_change_review_tables`,未形成多 head。 +- Inbox 旧独立页已退场:`/inbox` route、侧边栏入口、旧 `frontend/src/pages/inbox/`、`frontend/src/components/inbox/`、`frontend/src/types/inbox.ts` 与三阶段清单文档删除;旧 AI PMO `waiting_manual / draft_pending_confirm / missing_project_scope` 由 Space legacy adapter 承接。 +- 未继续新增业务功能;本轮仅做集成一致性确认、文档台账补齐、验证命令收口和小范围测试稳定性修复。 + +验证: + +- 越界检查:`git diff --name-only` / `git status --short` 已检查,当前 diff 是同一 integration 批次,不再拆分;共享文件变更集中在 router、后端 API router、models init、docs 与测试配置。 +- 后端编译:`find backend/app -name '*.py' -print0 | xargs -0 /opt/homebrew/bin/python3.11 -m py_compile` 通过。 +- 后端测试:`PYTHONPATH=. /opt/homebrew/bin/python3.11 -m pytest tests/test_pmflow_os_projection_api.py tests/test_ai_routing_governance_service.py -q` 在 `backend/` 下通过,`36 passed`,仅剩既有 Pydantic V2 class-based config deprecation warning。 +- Alembic:`PYTHONPATH=. /opt/homebrew/bin/python3.11 -m alembic heads` 在 `backend/` 下返回 `20260427_add_human_capacity_overrides (head)`。 +- 前端:`npm run typecheck` 通过。 +- 前端:`npm run build` 通过。 +- Diff hygiene:`git diff --check` 通过;未发现 Git 冲突标记。 + +### 2026-04-27 / adaptive-human-capacity-p1 / implemented + +完成内容: + +- 将旧“工时负载”升级为 `Adaptive Human Capacity`:后端新增只读每日容量计划、simulate-before-apply、override 审计记录,计划由当前 truth-state / execution-summary / reviewer routing / workspace binding 实时聚合。 +- 新增 `human_capacity_overrides` 持久化表,只存负责人手工调整与审计,不存每日计划快照;过期 override 不参与今日计划。 +- `execution-capabilities` 补充 `adaptiveCapacitySummary`;个人空间展示“我的今日容量”;Team Layer 增加团队今日容量调度台、模拟结果、负责人调整入口和调整记录;`/workloads` 降级为高级容量明细并把传统工时标为成本参考。 +- 产品边界写入蓝图和 guardrails:每日建议基线不得固定,工作类型共享容量池,负责人 override 必须有原因、审计、过期时间,后续团队空间不得回到固定工时槽、静态技能标签或旧 workload ratio 派活。 + +涉及文件: + +- `backend/app/models/human_capacity_override.py` +- `backend/alembic/versions/20260427_add_human_capacity_overrides.py` +- `backend/app/services/adaptive_capacity_service.py` +- `backend/app/services/team_execution_capability_service.py` +- `backend/app/api/pmflow_os_api/__init__.py` +- `backend/app/schemas/pmflow_os.py` +- `backend/tests/test_pmflow_os_projection_api.py` +- `frontend/src/api/pmflowOs.ts` +- `frontend/src/api/space.ts` +- `frontend/src/features/pmflow-os/types.ts` +- `frontend/src/features/pmflow-os/pages/TeamLayerPage.tsx` +- `frontend/src/features/personal-space/PersonalSpacePage.tsx` +- `frontend/src/pages/workloads/WorkloadsPage.tsx` +- `docs/PMFLOW_PRODUCT_BLUEPRINT.md` +- `docs/architecture/API_CONTRACTS.md` +- `docs/architecture/CODE_REFERENCE_INDEX.md` +- `docs/architecture/RUNTIME_GUARDRAILS.md` +- `docs/architecture/ARCHITECTURE_INVENTORY.md` +- `CLAUDE.md` + +验证: + +- 后端:`python -m py_compile backend/app` 通过;`pytest tests/test_pmflow_os_projection_api.py -q` 通过。 +- 数据库:`alembic upgrade head` 成功升级到 `20260427_add_human_capacity_overrides`。 +- 前端:`npm run typecheck`、`npm run build`、`vitest run src/features/personal-space/PersonalSpacePage.test.tsx src/features/pmflow-os/store.api.test.tsx src/components/layout/Sidebar.test.tsx` 通过。 +- 浏览器:检查 `/space`、`/team-layer?projectId=1`、`/workloads?projectId=1`,容量区块/空态/不可用 warning 可见,未使用 mock 或旧负载比例冒充。 +- 运行态:`docker compose restart backend` 后 `pmflow-backend` health 为 `healthy`。 + ### 2026-04-26 / team-feed-p1-review-activity-stream / implemented 完成内容: @@ -2466,3 +2528,44 @@ P8 可以三条 lane 并行,但共享高冲突文件必须由 Main Flow 或 Sh 下一步: - 从 A1 对象合同映射开始,把前端 `pmflow-os/types.ts` 与后端现有对象逐一对齐。 + +### 2026-04-27 / Legacy AI Steward Launch Downline / done + +完成内容: + +- 下线用户可见的 `/ai-steward` 产品页与侧边栏入口;legacy route 只保留迁移重定向,`tab=create` 进入 `/project-launch`,`tab=engine / tab=feedback-center` 进入 `/ai-config`,其它 legacy tab 进入 `/space`。 +- 收敛新建项目入口:前端移除 `aiStewardApi` 的直接创建调用与相关类型,后端 `POST /ai-steward/project-from-text` 返回 410 包络错误;真实项目创建唯一走 `POST /pmflow-launch/drafts -> POST /pmflow-launch/drafts/{draft_id}/confirm`。 +- 保留并迁移仍有价值的能力:QA 浮窗、状态条、回答元卡片继续保留在 `frontend/src/components/ai-steward/`;QA URL 新生成到 `/space?view=ai-history`;反馈中心入口迁到 `/ai-config`;Context Cache Feature Catalog 与信号通知默认目标改到 `/space`。 +- 历史页面文件未做物理删除,只作为迁移占位或断链壳保留;如需后续清理文件,需要按删除确认流程列路径后单独执行。 + +涉及文件: + +- `frontend/src/router/index.tsx` +- `frontend/src/components/layout/Sidebar.tsx` +- `frontend/src/components/layout/TopBar.tsx` +- `frontend/src/components/ai-steward/qaPageAnchor.ts` +- `frontend/src/components/sstc/FeedbackSummaryCard.tsx` +- `frontend/src/api/aiSteward.ts` +- `frontend/src/pages/ai-steward/AIStewardPage.tsx` +- `frontend/src/pages/ai-steward/sections/CreateProjectTab.tsx` +- `backend/app/api/ai_steward_api/routes_capture.py` +- `backend/app/api/ai_steward_api/schemas.py` +- `backend/app/api/legacy_ai_steward_api.py` +- `backend/app/api/router.py` +- `backend/app/services/context_cache_service/mixin_profile_basics.py` +- `backend/app/services/context_cache_service/mixin_profile_route_parser.py` +- `backend/app/services/signal/signal_capture_service/mixin_capture_legacy.py` +- `CLAUDE.md` +- `docs/architecture/CODE_REFERENCE_INDEX.md` +- `docs/architecture/ARCHITECTURE_INVENTORY.md` +- `docs/architecture/RUNTIME_GUARDRAILS.md` +- `docs/PMFLOW_PRODUCT_DELIVERY_STANDARD.md` + +验证: + +- 残留检索:计划指定的 legacy create 入口、direct create API 名称、旧项目文本创建类型名与旧中文入口名均无命中。 +- 后端:`/opt/homebrew/bin/python3.11 -m compileall -q app` 通过。 +- 后端:`/opt/homebrew/bin/python3.11 -m pytest tests/test_ai_steward_project_from_text_contract.py tests/test_context_cache_profile_catalog.py tests/test_pmflow_launch_api.py -q` 通过,27 passed。 +- 前端:`npx vitest run src/router/legacyAIStewardRedirect.test.ts src/components/layout/Sidebar.test.tsx src/components/ai-steward/qaPageAnchor.test.ts` 通过,9 passed。 +- 前端:`npm run typecheck` 通过。 +- 前端:`npm run build` 通过。 diff --git a/docs/PMFLOW_UI_CONTRACT.md b/docs/PMFLOW_UI_CONTRACT.md index 17e4da56..69a59ab1 100644 --- a/docs/PMFLOW_UI_CONTRACT.md +++ b/docs/PMFLOW_UI_CONTRACT.md @@ -199,7 +199,7 @@ PMFlow 专属规则: ### 4.3 项目层 -用于 `项目指挥台 / 本轮结构化提取 / 决策与待确认 / 项目工作台 / 开发执行 / 发布与增长 / 团队协作 / 项目记忆`。 +用于 `项目指挥台 / 本轮结构化提取 / 决策与待确认 / 项目工作台 / 开发执行 / 发布与增长 / 团队层 / 项目记忆`。 结构: @@ -215,7 +215,7 @@ PMFlow 专属规则: 决策与待确认 开发执行 发布与增长 -团队协作 +团队层 项目记忆 ``` diff --git a/docs/architecture/API_CONTRACTS.md b/docs/architecture/API_CONTRACTS.md index b7a85f80..278c2e10 100644 --- a/docs/architecture/API_CONTRACTS.md +++ b/docs/architecture/API_CONTRACTS.md @@ -48,23 +48,106 @@ ## Model Routing ### GET `/model-routing/functions/{function_name}` -- Purpose: query effective routing for function +- Purpose: query function-level advanced routing override. This is an admin/advanced entry, not the default product UI for members or team leads. - Response data: - `function_name` - - `provider` - - `model` - - `api_env` - `tier` - `source: "default" | "override"` ### PUT `/model-routing/functions/{function_name}` - Auth: manager/admin only - Request body: - - `provider`, `model`, `api_env`, `tier` + - `tier` - Side effects: - persist override into `integrations(type='model_routing')` - append audit to `operation_logs` +## AI Routing & Providers +- UI placement: these endpoints power the team-level AI 运营中心 under `/ai-config`; they are not project-scoped and must not be presented as per-project provider/model settings. + +### GET `/ai-routing/overview` +- Purpose: productized AI routing overview for team leads and AI operators. +- Auth: `OWNER` or `REVIEWER` via `can_view_ai_policy`. +- Response data: + - `control_plane`: `{ version, source, updated_at, baseline_note }` + - `slots[]`: low/high slot summaries with Chinese labels, provider/model display summary, connection state, and masked key status. + - `metrics`: recent 24h/7d calls, failures, failure rate, escalation rate, structured failure rate, average latency, and cost estimate. + - `diagnostics[]`: risk cards such as env key override, high slot unavailable, same runtime for low/high, protected downgrade, structured failure spike, or local Ollama scope risk. +- Boundary: no cleartext key, `api_env`, prompt, raw provider secret, raw error, or raw routing meta. + +### GET `/ai-routing/team-policy` +- Purpose: team-lead-facing business policy view for project launch, requirement structuring, task breakdown, assignee matching, internal QA, external query, Feishu understanding, and Feishu reply. +- Auth: `OWNER` or `REVIEWER`. +- Response data: + - `strategy`: `quality_first | balanced | speed_first` + - `scenes[]`: Chinese business scene policies, preferred mode, escalation rule, fallback behavior, and editable business note. + - `automation_boundary`, `budget_guard`, `quality_targets`, `updated_at`, `updated_by` +- Boundary: team policy is saved product governance state only; it does not rewrite `llm_service` runtime routing in v1. + +### PUT `/ai-routing/team-policy` +- Purpose: save team AI policy display/governance state. +- Auth: `OWNER` only via `can_approve_ai_routing_change`. +- Request body: + - `strategy?: "quality_first" | "balanced" | "speed_first"` + - `scenes?: [{ key, policy, mode?, human_confirmation?, protected? }]` + - `automation_boundary?: string` + - `budget_guard?: { enabled?: boolean, monthly_budget_cny?: number | null, alert_threshold_ratio?: number }` + - `quality_targets?: string[]` +- Side effects: + - persist into `integrations(type='ai_team_policy')` + - append `operation_logs(entity_type='ai_routing_policy')` +- Boundary: never writes `backend/.control_plane/pmflow-routing-rules.json`, provider secrets, or model-routing overrides. + +### GET `/ai-routing/admin-console` +- Purpose: AI admin/operator console aggregating provider connection summaries, low/high slots, business-scene routes, recent failure samples, protected scenes, and env override diagnostics. +- Auth: `OWNER` or `REVIEWER`. +- Response data: + - `providers[]`: display-only provider health and masked key state. + - `slots[]`: fast/deep slot summaries. + - `scene_routes[]`: scene label, mode, contract, protection state; raw `function_name` may only be present in admin data and must be hidden by default in UI advanced details. + - `recent_failures[]`: sanitized failure samples without raw error or prompt. + - `protected_scenes[]`, `environment_overrides[]`, `diagnostics[]` + +### POST `/ai-routing/diagnostics/run` +- Purpose: run read-only diagnostics for routing/provider health. +- Auth: `OWNER` or `REVIEWER` via `can_operate_ai_routing`. +- Side effects: none; does not modify provider, slot, team policy, or runtime routing. + +## PMFlow OS Adaptive Capacity + +All endpoints return `{ code, message, data }`. Project members may read. Override simulate/apply requires `OWNER` or `REVIEWER`; write endpoints only persist capacity override audit records and must not assign tasks, request review, write ChangeSet, or mutate Project Memory. + +### GET `/pmflow-os/projects/{project_id}/capacity-plan?date=YYYY-MM-DD` +- Purpose: compute the daily adaptive human capacity plan from current truth-state, reviewer routing, execution summary, workspace bindings, user projects, legacy workload reference, and active overrides. +- Response data: + - `projectId`, `date`, `generatedAt` + - `teamDemandMix` + - `teamEffectiveMinutes`, `teamPlannedTouchMinutes` + - `memberPlans[]`: base/effective minutes, dynamic `suggestedBaseline`, current mix, planned touch minutes, status, remaining minutes, bottleneck, fatigue risk, risk density, `canTakeMore`, manual override, explanations + - `humanTouches[]`: source, target href, work type, estimated minutes, risk, urgency, binding, assigned user, context readiness, AI reduction potential + - `warnings`, `recommendations` +- Boundary: daily plan is computed at read time. Do not persist fixed baselines or daily snapshots. + +### POST `/pmflow-os/projects/{project_id}/capacity-overrides/simulate` +- Purpose: simulate a manager capacity override before applying it. +- Request body: `PMFlowCapacityOverridePayload` +- Response data: `{ beforePlan, afterPlan, impactSummary, warnings }` +- Boundary: no database write and no assignment side effect. + +### POST `/pmflow-os/projects/{project_id}/capacity-overrides` +- Purpose: apply a manager override and persist audit in `human_capacity_overrides`. +- Request body: `PMFlowCapacityOverridePayload` +- Response data: `PMFlowCapacityOverrideRecord` +- Side effects: insert one override audit row only. +- Boundary: `reason` is required; default expiry is same day 23:59 if omitted. Overtime increases time budget but must not clear fatigue or quality risk. + +### GET `/pmflow-os/projects/{project_id}/capacity-overrides?date=YYYY-MM-DD` +- Purpose: list override audit records for the given day. +- Response data: `PMFlowCapacityOverrideRecord[]` + +### GET `/pmflow-os/projects/{project_id}/execution-capabilities` +- Addition: response data may include `adaptiveCapacitySummary: DailyCapacityPlan`. + ## Task Briefs ### GET `/task-briefs/center` diff --git a/docs/architecture/ARCHITECTURE_INVENTORY.md b/docs/architecture/ARCHITECTURE_INVENTORY.md index 2e208a11..b7c5fa9e 100644 --- a/docs/architecture/ARCHITECTURE_INVENTORY.md +++ b/docs/architecture/ARCHITECTURE_INVENTORY.md @@ -20,7 +20,6 @@ - `docs/PMFLOW_PRODUCT_BLUEPRINT.md`:PMFlow 产品蓝图真源;锁定“对话驱动的 AI 产品执行操作系统”、`solo-first, team-ready`、项目真相层、ChangeSet 待确认、Execution Bridge 与人机混编团队方向 - `docs/PMFLOW_PRODUCT_DELIVERY_STANDARD.md`:PMFlow 产品推进执行标准;记录当前进度、Phase A-F/P8 推进顺序、单次任务范围、Codex 并行推进协议、进度台账和新线程接续规则;P8 已完成 Review Decision Loop、Space review attention queue、Account review decision preference 与 integration 收口 - `docs/PMFLOW_UI_CONTRACT.md`:PMFlow UI 契约真源;锁定 Logo、浅色白底 + `#2463EB`、移动端固定底部导航、四类视觉语法、核心功能 canonical 契约和当前前端一致性审计 -- `docs/INBOX_INLINE_OPS_3_PHASE_CHECKLIST.md`:消息台内联操作执行清单 - `docs/architecture/MASTER_CONTRACT.md`:跨栈主契约 - `docs/architecture/CODE_REFERENCE_INDEX.md`:新增代码参考入口与高频代码索引 - `docs/architecture/PMFLOW_OS_PRODUCT_CONTRACT.md`:PMFlow OS 产品合同真源;锁定前端 `ProjectTruthState / ChangeSet / Evidence / Approval / ExecutionContext` 与后端 `truth_nodes / tasks / semantic_facts / ai_project_control / dev_sync` 的复用边界;Phase C1 已实现 review 表和审查 API,Phase C2 backend/frontend 已实现 atomic commit @@ -148,10 +147,9 @@ ## 5. 前端库存 ### 5.1 页面分组 -- `frontend/src/pages/inbox/` -- `frontend/src/pages/ai-steward/` +- `frontend/src/pages/ai-steward/`(legacy redirect / 迁移占位,不再作为产品页入口) - `frontend/src/pages/scheduler-center/` -- `frontend/src/pages/project-board/` +- `frontend/src/pages/project-board/`(legacy 项目控制面,用户入口已下线;旧 `/projects/:id/board` 由 router 重定向到 PMFlow OS 主链,待确认后物理删除) - `frontend/src/pages/projects/` - `frontend/src/pages/users/` - `frontend/src/pages/recipes/` @@ -193,26 +191,29 @@ - `frontend/src/components/ai-steward/QAResponseMetaCard.test.tsx` - AI Steward 问答浮窗现分三层显性化:`FloatingQAChat.tsx` 负责即时回答/历史回放归一化,`QAConversationStatusBar.tsx` 展示对象/来源/仓库/执行通道/plan handoff,`QAResponseMetaCard.tsx` 展示 `fit_brief / external_recommendation / recommended_next_step`,对应前端回归由 `FloatingQAChat.test.tsx` 与 `QAResponseMetaCard.test.tsx` 锁定。 - 登录页与营销页仍共享 `frontend/src/components/marketing/` 的部分视觉基线;公开登录页现改由 `frontend/src/components/login/` 目录承载,其中 `LoginExperience.tsx` 负责登录壳层、`LoginWorkflowVisual.tsx` 负责左侧场景、`loginScene.ts` / `loginMath.ts` 拆出展示配置与路径几何、`login.css` 负责登录页专属样式边界,`frontend/src/pages/login/LoginPage.tsx` 继续负责真实鉴权、错误态和 redirect 提示;未登录访问 `/` 会经 `frontend/src/router/index.tsx` 直接重定向到 `/login`,无显式 `redirect` 的成功登录现在默认进入 `/command-center`。 -- 前端主包治理已切到“同步首屏最小集 + route/page-section lazy”模式:`frontend/src/router/index.tsx` 保留 `LoginPage / Layout` 这组同步入口,其余后台重页面通过 `React.lazy + Suspense` 懒加载;统一 fallback 在 `frontend/src/components/layout/LazyBoundary.tsx`。P0 公共壳冻结后,大模块 route 归属为:`/space` 归 Space lane,`/project-launch` 是启动层入口,`/pmflow-os` 与 `/command-center / /change-review / /execution-bridge / /release-growth` 归 Main Flow lane,`/me` 归 Account Settings lane,`/settings` 当前仅为 reserved route、尚未实现,新增必须由 Shell / Integration owner 串行处理。`frontend/src/features/personal-space/` 作为空间层入口,通过 `/space` 提供可直接打开的本地预览入口,承载继续推进、待我确认、AI 正在推进、暂存想法、草案箱与新建项目入口。`frontend/src/features/project-launch/` 作为首次进入页 / 新项目启动台产品域,通过 `/project-launch` 提供可直接打开的入口;Phase B 后默认调用真实 `pmflow-launch` draft/confirm API,创建成功态主动作跳转后端返回的 `/pmflow-os?projectId=`,只有显式 `?demo=1` 或 `?dataSource=demo` 才启用本地 mock 状态机。`frontend/src/features/account-settings/` 作为 Account Settings 产品域,通过 `/me` 提供个人资料、默认语言、AI 回答深度、证据展示偏好、待确认/执行回写/发布门禁/失败提醒通知偏好和账户操作入口;该域通过 `frontend/src/api/accountSettings.ts` 建立前端合同,有 token 时优先调用已挂入 `backend/app/api/router.py` 的 `backend/app/api/account_settings_api/` `GET /account-settings/me` / `PUT /account-settings/me` 同步当前账号偏好,后端不可用时明确回落本机暂存,不修改 OAuth/JWT/auth core。`frontend/src/features/pmflow-os/` 作为新产品域,通过 `PMFlowOsProvider` 在应用根部挂载共享 truth store,并提供 `CommandCenter / ChangeReview / WorkGraph / ExecutionBridge / ReleaseGrowth / TeamLayer / ProjectMemory / Account` 主入口;当前 PMFlow OS 页面已切到专属轻壳,桌面端默认隐藏旧 TopBar,并由 `frontend/src/features/pmflow-os/components/PMFlowOsFrame.tsx` 统一承接“左侧稳定导航 + 顶部上下文条 + 中央视图 + 右侧 AI 副驾”的全局骨架,同时承接项目内 `个人空间 / 当前项目名` 面包屑和项目切换器;账号退出登录由 `frontend/src/features/pmflow-os/components/AccountControls.tsx` 统一承接桌面头像下拉、退出确认与移动端 bottom sheet,`frontend/src/features/pmflow-os/pages/AccountPage.tsx` 提供 `/me` 入口并委托 Account Settings 域渲染设置页;正式应用壳移动端底部导航统一由 `frontend/src/features/pmflow-os/components/MobileBottomNav.tsx` 承接,固定为 `空间 / 指挥台 / 待确认 / 动态 / 我的`,其中 `动态` 当前仍是 `/command-center` 占位。`/command-center` 额外提供免登录产品预览壳,`/pmflow-os` 是真实项目上下文 alias。主视觉按 `#2463EB + 白色` 的 Linear-like 高密度原型展开。`UsersPage` 把创建/删除/重置/导入/批量分配对话框拆为按需 chunk,`UserSkillsPage` 与 `WorkloadDetailPage` 的图表也分别拆到了 `frontend/src/pages/user-skills/components/UserSkillsRadarPanel.tsx` 与 `frontend/src/pages/workload-detail/components/WorkloadTrendChart.tsx`。 +- 前端主包治理已切到“同步首屏最小集 + route/page-section lazy + 统一身份边界”模式:`frontend/src/router/index.tsx` 保留 `LoginPage / Layout` 这组同步入口,其余后台重页面通过 `React.lazy + Suspense` 懒加载;统一 fallback 在 `frontend/src/components/layout/LazyBoundary.tsx`,统一身份/权限边界在 `frontend/src/auth/RouteBoundary.tsx`。P0 公共壳冻结后,大模块 route 归属为:`/space` 归 Space lane,真实 `/project-launch` 是启动层入口,`/pmflow-os` 与 `/change-review / /structured-extraction / /memory-writeback-result / /execution-bridge / /release-growth` 归 Main Flow lane,`/me` 归 Account Settings lane,管理/治理类页面统一走 OWNER/REVIEWER 边界,`/settings` 当前仅为 reserved route、尚未实现,新增必须由 Shell / Integration owner 串行处理。`/ai-steward` 只做 legacy redirect:`tab=create` 迁到 `/project-launch`,`tab=engine / tab=feedback-center` 迁到 `/ai-config`,其它 legacy tab 迁到 `/space`;导航不再暴露该入口。`/command-center` 保留免登录产品预览壳;`/space`、真实 `/project-launch`、主链 standalone 与 `/me` 均需登录;`/project-launch?demo=1` 或 `?dataSource=demo` 是唯一匿名启动台演示入口。`frontend/src/features/personal-space/` 作为登录后的空间层入口,承载继续推进、待我确认、AI 正在推进、暂存想法、草案箱与新建项目入口。`frontend/src/features/project-launch/` 真实模式默认调用 `pmflow-launch` draft/confirm API,创建成功态主动作跳转后端返回的 `/pmflow-os?projectId=`,401/无 token 必须保存 pending input 并提示重新登录,不展示 Axios 原始错误;只有显式 demo 才启用本地 mock 状态机。`frontend/src/features/account-settings/` 作为登录后的 Account Settings 产品域,通过 `/me` 提供个人资料、默认语言、AI 回答深度、证据展示偏好、待确认/执行回写/发布门禁/失败提醒通知偏好和账户操作入口;该域通过 `frontend/src/api/accountSettings.ts` 建立前端合同,有 token 时优先调用已挂入 `backend/app/api/router.py` 的 `backend/app/api/account_settings_api/` `GET /account-settings/me` / `PUT /account-settings/me` 同步当前账号偏好,后端不可用时明确回落本机暂存,不修改 OAuth/JWT/auth core。`frontend/src/features/pmflow-os/` 作为登录后的真实项目产品域,通过 `PMFlowOsProvider` 在应用根部挂载共享 truth store,并提供 `CommandCenter / ChangeReview / WorkGraph / ExecutionBridge / ReleaseGrowth / TeamLayer / ProjectMemory / Account` 主入口;当前 PMFlow OS 页面已切到专属轻壳,桌面端默认隐藏旧 TopBar,并由 `frontend/src/features/pmflow-os/components/PMFlowOsFrame.tsx` 统一承接“左侧稳定导航 + 顶部上下文条 + 中央视图 + 右侧 AI 副驾”的全局骨架,同时承接项目内 `个人空间 / 当前项目名` 面包屑和项目切换器;账号退出登录由 `frontend/src/features/pmflow-os/components/AccountControls.tsx` 统一承接桌面头像下拉、退出确认与移动端 bottom sheet,正式应用壳移动端底部导航统一由 `frontend/src/features/pmflow-os/components/MobileBottomNav.tsx` 承接。 - PMFlow P8 Review Decision Loop 已由 `big-module-integration-p8` 收口:Main Flow `truth-state.reviewerRouting` 与 `POST /pmflow-os/change-sets/{change_set_id}/review-decisions` 是 reviewer decision / approval audit 真源;Space 只读消费为 `/space` 的 `review_attention` 下一步队列;Account 只提供 `getReviewDecisionNotificationPreference` 页面内提醒偏好;P8 未新增 route/nav,未接真实通知中心,不自动 assign reviewer 或 commit ChangeSet。 -- `frontend/src/features/personal-space/PersonalSpacePage.tsx` 是空间首页视图层,`frontend/src/api/space.ts` 是空间工作面只读聚合 API:从 `projects` summary、PMFlow OS `truth-state` 与 `execution-summary` 生成 `nextActionQueue` 我的下一步队列、继续推进、待确认、发布关注、发布后反馈关注、下一轮迭代草稿关注、执行中 / AI 正在推进、暂存想法、草案箱、最近项目摘要;Space P6 的 `nextActionQueue` 按高风险 ChangeSet 待确认、release blocked / failed evidence、handoff consumed 但无后续 progress、iteration draft 待确认、feedback/revenue/compliance 信号待处理排序,并只提供 `/change-review`、`/execution-bridge`、`/release-growth` 跳转。Space P3 的 `releaseAttention` 优先消费 `truth-state.releases` 中由 release validation run 投影出的 release 状态与检查项,把“发布验证阻塞 / 发布验证待确认”显示到 `/space`,没有发布验证投影时才回落到 `execution-summary.releaseGate` 的发布候选或发布门禁阻塞;Space P4/P5 的 `feedbackAttention` 只读消费 `execution-summary.releaseSignals`,把发布后 feedback、revenue、ROI、compliance、iteration recommendation 与已生成的下一轮 ChangeSet draft 关注项显示到 `/space`。发布验证、发布后信号或下一轮 draft 缺失时显示明确空态,不虚构验证、反馈、收入、ROI、合规、建议或 ChangeSet draft 结果。项目主动作进入 `/pmflow-os?projectId=`,待确认项进入 `/change-review?projectId=&changeSetId=`,发布关注和发布后反馈关注项进入 `/release-growth?projectId=`,下一轮 draft 关注项进入 `/change-review?projectId=&changeSetId=`,执行中项进入 `/execution-bridge?projectId=`;暂存想法后端只读聚合尚未接入时只显示 empty/warning,不使用 mock 数据冒充真实内容;移动端为空间首页独立 App 布局,不直接压缩桌面卡片,并保留最近项目区块;`frontend/src/pages/PersonalSpacePage.tsx` 是薄入口。该域只表达个人空间和主流程导航,不做团队空间强切换、企业组织管理、Project Memory 写入、ChangeSet 状态写入、ProgressEvent 写入、ReleaseValidationRun 写入、FeedbackIngest 写入或发布执行。 -- 前端主包治理已切到“同步首屏最小集 + route/page-section lazy”模式:`frontend/src/router/index.tsx` 保留 `LoginPage / Layout` 这组同步入口,其余后台重页面通过 `React.lazy + Suspense` 懒加载;统一 fallback 在 `frontend/src/components/layout/LazyBoundary.tsx`。P0 公共壳冻结后,大模块 route 归属为:`/space` 归 Space lane,`/project-launch` 是启动层入口,`/pmflow-os` 与 `/command-center / /change-review / /execution-bridge / /release-growth` 归 Main Flow lane,`/me` 归 Account Settings lane,`/settings` 当前仅为 reserved route、尚未实现,新增必须由 Shell / Integration owner 串行处理。`frontend/src/features/personal-space/` 作为空间层入口,通过 `/space` 提供可直接打开的本地预览入口,承载继续推进、待我确认、AI 正在推进、暂存想法、草案箱与新建项目入口。`frontend/src/features/project-launch/` 作为首次进入页 / 新项目启动台产品域,通过 `/project-launch` 提供可直接打开的入口;Phase B 后默认调用真实 `pmflow-launch` draft/confirm API,创建成功态主动作跳转后端返回的 `/pmflow-os?projectId=`,只有显式 `?demo=1` 或 `?dataSource=demo` 才启用本地 mock 状态机。`frontend/src/features/account-settings/` 作为 Account Settings 产品域,通过 `/me` 提供个人资料、默认语言、AI 回答深度、证据展示偏好、待确认/执行回写/发布门禁/失败提醒通知偏好和账户操作入口;该域通过 `frontend/src/api/accountSettings.ts` 建立前端合同,有 token 时优先调用已挂入 `backend/app/api/router.py` 的 `backend/app/api/account_settings_api/` `GET /account-settings/me` / `PUT /account-settings/me` 同步当前账号偏好,后端不可用时明确回落本机暂存,不修改 OAuth/JWT/auth core。`frontend/src/features/pmflow-os/` 作为新产品域,通过 `PMFlowOsProvider` 在应用根部挂载共享 truth store,并提供 `CommandCenter / ChangeReview / WorkGraph / ExecutionBridge / ReleaseGrowth / TeamLayer / ProjectMemory / Account` 主入口;当前 PMFlow OS 页面已切到专属轻壳,桌面端默认隐藏旧 TopBar,并由 `frontend/src/features/pmflow-os/components/PMFlowOsFrame.tsx` 统一承接“左侧稳定导航 + 顶部上下文条 + 中央视图 + 右侧 AI 副驾”的全局骨架,同时承接项目内 `个人空间 / 当前项目名` 面包屑和项目切换器;`ChangeReviewPage.tsx` 现在可以请求审查、通过、要求修改或拒绝当前 ChangeSet,并展示审查状态、审查意见与 high-risk approval gate;`TeamLayerPage.tsx` 现在消费 `ProjectTruthState.reviewerRouting(status/summary/items)` 展示待审 ChangeSet、来源、风险等级、建议审查人或 fallback role、审查原因、待审 item 数、证据数、warning、审查状态、审查意见和请求/决策时间,并复用 `TeamFeedPanel` 展示 `ProjectTruthState.feed` 中的 review request、review decision、ChangeSet 状态、execution progress 与 release gate 只读动态,动作跳 `/change-review?projectId=&changeSetId=`;账号退出登录由 `frontend/src/features/pmflow-os/components/AccountControls.tsx` 统一承接桌面头像下拉、退出确认与移动端 bottom sheet,`frontend/src/features/pmflow-os/pages/AccountPage.tsx` 提供 `/me` 入口并委托 Account Settings 域渲染设置页;正式应用壳移动端底部导航统一由 `frontend/src/features/pmflow-os/components/MobileBottomNav.tsx` 承接,固定为 `空间 / 指挥台 / 待确认 / 动态 / 我的`,其中 `动态` 当前仍是 `/command-center` 占位。`/command-center` 额外提供免登录产品预览壳,`/pmflow-os` 是真实项目上下文 alias。主视觉按 `#2463EB + 白色` 的 Linear-like 高密度原型展开。`UsersPage` 把创建/删除/重置/导入/批量分配对话框拆为按需 chunk,`UserSkillsPage` 与 `WorkloadDetailPage` 的图表也分别拆到了 `frontend/src/pages/user-skills/components/UserSkillsRadarPanel.tsx` 与 `frontend/src/pages/workload-detail/components/WorkloadTrendChart.tsx`。 -- `frontend/src/features/personal-space/PersonalSpacePage.tsx` 是空间首页视图层,`frontend/src/api/space.ts` 是空间工作面只读聚合 API:从 `projects` summary、PMFlow OS `truth-state` 与 `execution-summary` 生成 `nextActionQueue` 我的下一步队列、继续推进、待确认、发布关注、发布后反馈关注、下一轮迭代草稿关注、执行中 / AI 正在推进、暂存想法、草案箱、最近项目摘要;Space P6 的 `nextActionQueue` 按高风险 ChangeSet 待确认、release blocked / failed evidence、handoff consumed 但无后续 progress、iteration draft 待确认、feedback/revenue/compliance 信号待处理排序,并只提供 `/change-review`、`/execution-bridge`、`/release-growth` 跳转;Space P7 新增 `review_attention`,只读消费 `truth-state.reviewerRouting`,把高风险待我审、`request_changes / rejected` 后待处理、`reviewer_missing` warning 与普通 pending review 排入“我的下一步”,待审/退回/拒绝项只跳 `/change-review?projectId=&changeSetId=`,reviewer 缺失 warning 只跳已有 `/team-layer?projectId=`。Space P3 的 `releaseAttention` 优先消费 `truth-state.releases` 中由 release validation run 投影出的 release 状态与检查项,把“发布验证阻塞 / 发布验证待确认”显示到 `/space`,没有发布验证投影时才回落到 `execution-summary.releaseGate` 的发布候选或发布门禁阻塞;Space P4/P5 的 `feedbackAttention` 只读消费 `execution-summary.releaseSignals`,把发布后 feedback、revenue、ROI、compliance、iteration recommendation 与已生成的下一轮 ChangeSet draft 关注项显示到 `/space`。reviewerRouting、发布验证、发布后信号或下一轮 draft 缺失时显示明确空态,不虚构 reviewer、审查意见、待审 ChangeSet、验证、反馈、收入、ROI、合规、建议或 ChangeSet draft 结果。项目主动作进入 `/pmflow-os?projectId=`,待确认项进入 `/change-review?projectId=&changeSetId=`,发布关注和发布后反馈关注项进入 `/release-growth?projectId=`,下一轮 draft 关注项进入 `/change-review?projectId=&changeSetId=`,执行中项进入 `/execution-bridge?projectId=`;暂存想法后端只读聚合尚未接入时只显示 empty/warning,不使用 mock 数据冒充真实内容;移动端为空间首页独立 App 布局,不直接压缩桌面卡片,并保留最近项目区块;`frontend/src/pages/PersonalSpacePage.tsx` 是薄入口。该域只表达个人空间和主流程导航,不做团队空间强切换、企业组织管理、Project Memory 写入、ChangeSet 状态写入、PMFlowApproval 写入、ProgressEvent 写入、ReleaseValidationRun 写入、FeedbackIngest 写入或发布执行。 -- 前端主包治理已切到“同步首屏最小集 + route/page-section lazy”模式:`frontend/src/router/index.tsx` 保留 `LoginPage / Layout` 这组同步入口,其余后台重页面通过 `React.lazy + Suspense` 懒加载;统一 fallback 在 `frontend/src/components/layout/LazyBoundary.tsx`。P0 公共壳冻结后,大模块 route 归属为:`/space` 归 Space lane,`/project-launch` 是启动层入口,`/pmflow-os` 与 `/command-center / /change-review / /execution-bridge / /release-growth` 归 Main Flow lane,`/me` 归 Account Settings lane,`/settings` 当前仅为 reserved route、尚未实现,新增必须由 Shell / Integration owner 串行处理。`frontend/src/features/personal-space/` 作为空间层入口,通过 `/space` 提供可直接打开的本地预览入口,承载继续推进、待我确认、AI 正在推进、暂存想法、草案箱与新建项目入口。`frontend/src/features/project-launch/` 作为首次进入页 / 新项目启动台产品域,通过 `/project-launch` 提供可直接打开的入口;Phase B 后默认调用真实 `pmflow-launch` draft/confirm API,创建成功态主动作跳转后端返回的 `/pmflow-os?projectId=`,只有显式 `?demo=1` 或 `?dataSource=demo` 才启用本地 mock 状态机。`frontend/src/features/account-settings/` 作为 Account Settings 产品域,通过 `/me` 提供个人资料、默认语言、AI 回答深度、证据展示偏好、待确认/执行回写/发布门禁/失败提醒通知偏好和账户操作入口;该域通过 `frontend/src/api/accountSettings.ts` 建立前端合同,有 token 时优先调用已挂入 `backend/app/api/router.py` 的 `backend/app/api/account_settings_api/` `GET /account-settings/me` / `PUT /account-settings/me` 同步当前账号偏好,后端不可用时明确回落本机暂存,不修改 OAuth/JWT/auth core。`frontend/src/features/pmflow-os/` 作为新产品域,通过 `PMFlowOsProvider` 在应用根部挂载共享 truth store,并提供 `CommandCenter / ChangeReview / WorkGraph / ExecutionBridge / ReleaseGrowth / TeamLayer / ProjectMemory / Account` 主入口;当前 PMFlow OS 页面已切到专属轻壳,桌面端默认隐藏旧 TopBar,并由 `frontend/src/features/pmflow-os/components/PMFlowOsFrame.tsx` 统一承接“左侧稳定导航 + 顶部上下文条 + 中央视图 + 右侧 AI 副驾”的全局骨架,同时承接项目内 `个人空间 / 当前项目名` 面包屑和项目切换器;`TeamLayerPage.tsx` 现在消费 `ProjectTruthState.reviewerRouting(status/summary/items)` 展示待审 ChangeSet、来源、风险等级、建议审查人或 fallback role、审查原因、待审 item 数、证据数和 warning,并复用 `TeamFeedPanel` 展示 `ProjectTruthState.feed` 中的 review request、review decision、ChangeSet 状态、execution progress 与 release gate 只读动态,动作只跳 `/change-review?projectId=&changeSetId=`,不自动 assign reviewer;账号退出登录由 `frontend/src/features/pmflow-os/components/AccountControls.tsx` 统一承接桌面头像下拉、退出确认与移动端 bottom sheet,`frontend/src/features/pmflow-os/pages/AccountPage.tsx` 提供 `/me` 入口并委托 Account Settings 域渲染设置页;正式应用壳移动端底部导航统一由 `frontend/src/features/pmflow-os/components/MobileBottomNav.tsx` 承接,固定为 `空间 / 指挥台 / 待确认 / 动态 / 我的`,其中 `动态` 当前仍是 `/command-center` 占位。`/command-center` 额外提供免登录产品预览壳,`/pmflow-os` 是真实项目上下文 alias。主视觉按 `#2463EB + 白色` 的 Linear-like 高密度原型展开。`UsersPage` 把创建/删除/重置/导入/批量分配对话框拆为按需 chunk,`UserSkillsPage` 与 `WorkloadDetailPage` 的图表也分别拆到了 `frontend/src/pages/user-skills/components/UserSkillsRadarPanel.tsx` 与 `frontend/src/pages/workload-detail/components/WorkloadTrendChart.tsx`。 +- `frontend/src/features/personal-space/PersonalSpacePage.tsx` 是空间首页视图层,`frontend/src/api/space.ts` 是空间工作面只读聚合 API:从 `projects` summary、PMFlow OS `truth-state`、`execution-summary` 与 `execution-capabilities` 生成 `nextActionQueue` 我的下一步队列、执行准备度、继续推进、待确认、发布关注、发布后反馈关注、下一轮迭代草稿关注、执行中 / AI 正在推进、暂存想法、草案箱、最近项目摘要;Space P6 的 `nextActionQueue` 按高风险 ChangeSet 待确认、release blocked / failed evidence、handoff consumed 但无后续 progress、iteration draft 待确认、feedback/revenue/compliance 信号待处理排序,并只提供 `/change-review`、`/execution-bridge`、`/release-growth` 跳转。执行准备度只表达“我需要判断什么、AI 能继续做什么、工具链缺什么”,动作只跳 `/execution-bridge`、`/change-review`、`/release-growth` 或 `/team-layer`;缺少能力快照、工具连接、执行证据或阻塞数据时显示明确 empty/warning,不用传统技能表或 mock 数据冒充真实能力。Space P3 的 `releaseAttention` 优先消费 `truth-state.releases` 中由 release validation run 投影出的 release 状态与检查项,把“发布验证阻塞 / 发布验证待确认”显示到 `/space`,没有发布验证投影时才回落到 `execution-summary.releaseGate` 的发布候选或发布门禁阻塞;Space P4/P5 的 `feedbackAttention` 只读消费 `execution-summary.releaseSignals`,把发布后 feedback、revenue、ROI、compliance、iteration recommendation 与已生成的下一轮 ChangeSet draft 关注项显示到 `/space`。发布验证、发布后信号或下一轮 draft 缺失时显示明确空态,不虚构验证、反馈、收入、ROI、合规、建议或 ChangeSet draft 结果。项目主动作进入 `/pmflow-os?projectId=`,待确认项进入 `/change-review?projectId=&changeSetId=`,发布关注和发布后反馈关注项进入 `/release-growth?projectId=`,下一轮 draft 关注项进入 `/change-review?projectId=&changeSetId=`,执行中项进入 `/execution-bridge?projectId=`;暂存想法后端只读聚合尚未接入时只显示 empty/warning,不使用 mock 数据冒充真实内容;移动端为空间首页独立 App 布局,不直接压缩桌面卡片,并保留最近项目区块;`frontend/src/pages/PersonalSpacePage.tsx` 是薄入口。该域只表达个人空间和主流程导航,不做团队空间强切换、企业组织管理、Project Memory 写入、ChangeSet 状态写入、ProgressEvent 写入、ReleaseValidationRun 写入、FeedbackIngest 写入或发布执行。 +- 前端主包治理已切到“同步首屏最小集 + route/page-section lazy”模式:`frontend/src/router/index.tsx` 保留 `LoginPage / Layout` 这组同步入口,其余后台重页面通过 `React.lazy + Suspense` 懒加载;统一 fallback 在 `frontend/src/components/layout/LazyBoundary.tsx`。P0 公共壳冻结后,大模块 route 归属为:`/space` 归 Space lane,`/project-launch` 是启动层入口,`/pmflow-os` 与 `/command-center / /change-review / /execution-bridge / /release-growth` 归 Main Flow lane,`/me` 归 Account Settings lane,`/settings` 当前仅为 reserved route、尚未实现,新增必须由 Shell / Integration owner 串行处理。`frontend/src/features/personal-space/` 作为空间层入口,通过登录后的 `/space` 提供空间入口,承载继续推进、待我确认、AI 正在推进、暂存想法、草案箱与新建项目入口。`frontend/src/features/project-launch/` 作为首次进入页 / 新项目启动台产品域,通过登录后的真实 `/project-launch` 提供启动入口(显式 `?demo=1` 或 `?dataSource=demo` 可匿名演示);Phase B 后默认调用真实 `pmflow-launch` draft/confirm API,创建成功态主动作跳转后端返回的 `/pmflow-os?projectId=`,只有显式 `?demo=1` 或 `?dataSource=demo` 才启用本地 mock 状态机。`frontend/src/features/account-settings/` 作为 Account Settings 产品域,通过 `/me` 提供个人资料、默认语言、AI 回答深度、证据展示偏好、待确认/执行回写/发布门禁/失败提醒通知偏好和账户操作入口;该域通过 `frontend/src/api/accountSettings.ts` 建立前端合同,有 token 时优先调用已挂入 `backend/app/api/router.py` 的 `backend/app/api/account_settings_api/` `GET /account-settings/me` / `PUT /account-settings/me` 同步当前账号偏好,后端不可用时明确回落本机暂存,不修改 OAuth/JWT/auth core。`frontend/src/features/pmflow-os/` 作为新产品域,通过 `PMFlowOsProvider` 在应用根部挂载共享 truth store,并提供 `CommandCenter / ChangeReview / WorkGraph / ExecutionBridge / ReleaseGrowth / TeamLayer / ProjectMemory / Account` 主入口;当前 PMFlow OS 页面已切到专属轻壳,桌面端默认隐藏旧 TopBar,并由 `frontend/src/features/pmflow-os/components/PMFlowOsFrame.tsx` 统一承接“左侧稳定导航 + 顶部上下文条 + 中央视图 + 右侧 AI 副驾”的全局骨架,同时承接项目内 `个人空间 / 当前项目名` 面包屑和项目切换器;`ChangeReviewPage.tsx` 现在可以请求审查、通过、要求修改或拒绝当前 ChangeSet,并展示审查状态、审查意见与 high-risk approval gate;`TeamLayerPage.tsx` 现在消费 `ProjectTruthState.reviewerRouting(status/summary/items)` 展示待审 ChangeSet、来源、风险等级、建议审查人或 fallback role、审查原因、待审 item 数、证据数、warning、审查状态、审查意见和请求/决策时间,并复用 `TeamFeedPanel` 展示 `ProjectTruthState.feed` 中的 review request、review decision、ChangeSet 状态、execution progress 与 release gate 只读动态,动作跳 `/change-review?projectId=&changeSetId=`;账号退出登录由 `frontend/src/features/pmflow-os/components/AccountControls.tsx` 统一承接桌面头像下拉、退出确认与移动端 bottom sheet,`frontend/src/features/pmflow-os/pages/AccountPage.tsx` 提供 `/me` 入口并委托 Account Settings 域渲染设置页;正式应用壳移动端底部导航统一由 `frontend/src/features/pmflow-os/components/MobileBottomNav.tsx` 承接,固定为 `空间 / 指挥台 / 待确认 / 动态 / 我的`,其中 `动态` 当前仍是 `/command-center` 占位。`/command-center` 额外提供免登录产品预览壳,`/pmflow-os` 是真实项目上下文 alias。主视觉按 `#2463EB + 白色` 的 Linear-like 高密度原型展开。`UsersPage` 把创建/删除/重置/导入/批量分配对话框拆为按需 chunk,`UserSkillsPage` 与 `WorkloadDetailPage` 的图表也分别拆到了 `frontend/src/pages/user-skills/components/UserSkillsRadarPanel.tsx` 与 `frontend/src/pages/workload-detail/components/WorkloadTrendChart.tsx`。 +- `frontend/src/features/personal-space/PersonalSpacePage.tsx` 是空间首页视图层,`frontend/src/api/space.ts` 是空间工作面只读聚合 API:从 `projects` summary、PMFlow OS `truth-state`、`execution-summary` 与 `execution-capabilities` 生成 `nextActionQueue` 我的下一步队列、执行准备度、继续推进、待确认、发布关注、发布后反馈关注、下一轮迭代草稿关注、执行中 / AI 正在推进、暂存想法、草案箱、最近项目摘要;Space P6 的 `nextActionQueue` 按高风险 ChangeSet 待确认、release blocked / failed evidence、handoff consumed 但无后续 progress、iteration draft 待确认、feedback/revenue/compliance 信号待处理排序,并只提供 `/change-review`、`/execution-bridge`、`/release-growth` 跳转;Space P7 新增 `review_attention`,只读消费 `truth-state.reviewerRouting`,把高风险待我审、`request_changes / rejected` 后待处理、`reviewer_missing` warning 与普通 pending review 排入“我的下一步”,待审/退回/拒绝项只跳 `/change-review?projectId=&changeSetId=`,reviewer 缺失 warning 只跳已有 `/team-layer?projectId=`。执行准备度只表达“我需要判断什么、AI 能继续做什么、工具链缺什么”,动作只跳 `/execution-bridge`、`/change-review`、`/release-growth` 或 `/team-layer`。Space P3 的 `releaseAttention` 优先消费 `truth-state.releases` 中由 release validation run 投影出的 release 状态与检查项,把“发布验证阻塞 / 发布验证待确认”显示到 `/space`,没有发布验证投影时才回落到 `execution-summary.releaseGate` 的发布候选或发布门禁阻塞;Space P4/P5 的 `feedbackAttention` 只读消费 `execution-summary.releaseSignals`,把发布后 feedback、revenue、ROI、compliance、iteration recommendation 与已生成的下一轮 ChangeSet draft 关注项显示到 `/space`。reviewerRouting、execution-capabilities、发布验证、发布后信号或下一轮 draft 缺失时显示明确空态,不虚构 reviewer、审查意见、待审 ChangeSet、工具连接、执行能力、验证、反馈、收入、ROI、合规、建议或 ChangeSet draft 结果。项目主动作进入 `/pmflow-os?projectId=`,待确认项进入 `/change-review?projectId=&changeSetId=`,发布关注和发布后反馈关注项进入 `/release-growth?projectId=`,下一轮 draft 关注项进入 `/change-review?projectId=&changeSetId=`,执行中项进入 `/execution-bridge?projectId=`;暂存想法后端只读聚合尚未接入时只显示 empty/warning,不使用 mock 数据冒充真实内容;移动端为空间首页独立 App 布局,不直接压缩桌面卡片,并保留最近项目区块;`frontend/src/pages/PersonalSpacePage.tsx` 是薄入口。该域只表达个人空间和主流程导航,不做团队空间强切换、企业组织管理、Project Memory 写入、ChangeSet 状态写入、PMFlowApproval 写入、ProgressEvent 写入、ReleaseValidationRun 写入、FeedbackIngest 写入或发布执行。 +- 旧独立收件页已物理删除,`/inbox` route 和侧边栏入口已退场;旧 AI PMO `waiting_manual / draft_pending_confirm` 不再通过独立页面承载,而由 `frontend/src/api/space.ts` 作为 legacy adapter 只读接入 `/space` 的“我的下一步”和草案箱。`missing_project_scope` 只允许在 Space 选择项目后复用 `POST /ai-pmo/work-items/{id}/decision` 继续既有审计链,不新增旁路接口;底层 `notifications` primitive 暂留为未来 in-app reminder/偏好消费基础,不作为真实通知中心暴露。 +- 前端主包治理已切到“同步首屏最小集 + route/page-section lazy”模式:`frontend/src/router/index.tsx` 保留 `LoginPage / Layout` 这组同步入口,其余后台重页面通过 `React.lazy + Suspense` 懒加载;统一 fallback 在 `frontend/src/components/layout/LazyBoundary.tsx`。P0 公共壳冻结后,大模块 route 归属为:`/space` 归 Space lane,`/project-launch` 是启动层入口,`/pmflow-os` 与 `/command-center / /change-review / /execution-bridge / /release-growth` 归 Main Flow lane,`/me` 归 Account Settings lane,`/settings` 当前仅为 reserved route、尚未实现,新增必须由 Shell / Integration owner 串行处理。`frontend/src/features/personal-space/` 作为空间层入口,通过登录后的 `/space` 提供空间入口,承载继续推进、待我确认、AI 正在推进、暂存想法、草案箱与新建项目入口。`frontend/src/features/project-launch/` 作为首次进入页 / 新项目启动台产品域,通过登录后的真实 `/project-launch` 提供启动入口(显式 `?demo=1` 或 `?dataSource=demo` 可匿名演示);Phase B 后默认调用真实 `pmflow-launch` draft/confirm API,创建成功态主动作跳转后端返回的 `/pmflow-os?projectId=`,只有显式 `?demo=1` 或 `?dataSource=demo` 才启用本地 mock 状态机。`frontend/src/features/account-settings/` 作为 Account Settings 产品域,通过 `/me` 提供个人资料、默认语言、AI 回答深度、证据展示偏好、待确认/执行回写/发布门禁/失败提醒通知偏好和账户操作入口;该域通过 `frontend/src/api/accountSettings.ts` 建立前端合同,有 token 时优先调用已挂入 `backend/app/api/router.py` 的 `backend/app/api/account_settings_api/` `GET /account-settings/me` / `PUT /account-settings/me` 同步当前账号偏好,后端不可用时明确回落本机暂存,不修改 OAuth/JWT/auth core。`frontend/src/features/pmflow-os/` 作为新产品域,通过 `PMFlowOsProvider` 在应用根部挂载共享 truth store,并提供 `CommandCenter / ChangeReview / WorkGraph / ExecutionBridge / ReleaseGrowth / TeamLayer / ProjectMemory / Account` 主入口;当前 PMFlow OS 页面已切到专属轻壳,桌面端默认隐藏旧 TopBar,并由 `frontend/src/features/pmflow-os/components/PMFlowOsFrame.tsx` 统一承接“左侧稳定导航 + 顶部上下文条 + 中央视图 + 右侧 AI 副驾”的全局骨架,同时承接项目内 `个人空间 / 当前项目名` 面包屑和项目切换器;`TeamLayerPage.tsx` 现在消费 `ProjectTruthState.reviewerRouting(status/summary/items)` 展示待审 ChangeSet、来源、风险等级、建议审查人或 fallback role、审查原因、待审 item 数、证据数和 warning,并复用 `TeamFeedPanel` 展示 `ProjectTruthState.feed` 中的 review request、review decision、ChangeSet 状态、execution progress 与 release gate 只读动态,动作只跳 `/change-review?projectId=&changeSetId=`,不自动 assign reviewer;账号退出登录由 `frontend/src/features/pmflow-os/components/AccountControls.tsx` 统一承接桌面头像下拉、退出确认与移动端 bottom sheet,`frontend/src/features/pmflow-os/pages/AccountPage.tsx` 提供 `/me` 入口并委托 Account Settings 域渲染设置页;正式应用壳移动端底部导航统一由 `frontend/src/features/pmflow-os/components/MobileBottomNav.tsx` 承接,固定为 `空间 / 指挥台 / 待确认 / 动态 / 我的`,其中 `动态` 当前仍是 `/command-center` 占位。`/command-center` 额外提供免登录产品预览壳,`/pmflow-os` 是真实项目上下文 alias。主视觉按 `#2463EB + 白色` 的 Linear-like 高密度原型展开。`UsersPage` 把创建/删除/重置/导入/批量分配对话框拆为按需 chunk,`UserSkillsPage` 与 `WorkloadDetailPage` 的图表也分别拆到了 `frontend/src/pages/user-skills/components/UserSkillsRadarPanel.tsx` 与 `frontend/src/pages/workload-detail/components/WorkloadTrendChart.tsx`。 - `frontend/src/features/project-launch/types.ts`、`mockData.ts`、`useProjectLaunchState.ts` 是新项目启动台状态层;默认真实模式通过 `frontend/src/api/pmflowLaunch.ts` 生成草案并确认创建真实项目,API 失败进入明确失败态,不静默 fallback 成 mock 成功;`mockData.ts` 仅在显式 `?demo=1` 或 `?dataSource=demo` 下作为演示状态机使用。`components/LaunchShell.tsx` 负责桌面/移动分流,桌面端复用 `frontend/src/features/personal-space/components/SpaceChrome.tsx` 的空间左侧栏和顶部条,`LaunchCanvases.tsx` 和 `MobileLaunchLayout.tsx` 承接高保真图中的意图输入、AI 工作、人类确认、草案修改、生成失败、创建成功过渡与移动端 bottom sheet 交互。 - `frontend/src/features/pmflow-os/store.tsx` 当前是 PMFlow OS 共享 truth store:`?projectId=` 时通过 React Query 调用 `frontend/src/api/pmflowOs.ts` 读取真实 `GET /pmflow-os/projects/{project_id}/truth-state` 与 `GET /pmflow-os/projects/{project_id}/execution-summary`,真实模式下项目内 message、`proposed_items` extraction、ChangeItem `accept/edit/defer`、ChangeSet commit、review decision、Execution Handoff create/consume、execution progress writeback、release validation run create、release feedback ingest、单条 iteration recommendation -> ChangeSet draft 与同组 iteration recommendation 批量 -> ChangeSet draft 均调用 `/pmflow-os` 后端并 refetch;只有显式 `?demo=1` 或 `?dataSource=demo` 时才启用本地 mock 演示状态机。真实读取/动作失败会进入错误/空态,不静默 fallback 成 mock 成功。 - `frontend/src/features/pmflow-os/store.tsx` 仍保留演示用项目内 AI 结构化闭环状态机:`command_idle / ai_extracting / extraction_ready / review_open / writeback_success`。`frontend/src/features/pmflow-os/pages/StructuredExtractionPage.tsx` 是本轮结构化提取页,`ChangeReviewPage.tsx` 是决策与待确认中心,`MemoryWritebackResultPage.tsx` 是项目记忆写入结果页,`WorkGraphPage.tsx` 是项目工作台关系总览与对象投影视图,`ExecutionBridgePage.tsx` 是执行桥上下文包、交接按钮、运行端领取确认、PR/测试与回写状态页;`ReleaseGrowthPage.tsx` 真实模式读取 `execution-summary.releaseGate`、`latestValidationRun` 与 `releaseSignals`,从 branch、commit、PR、test、failed evidence 展示 `可发布 / 阻塞 / 需要人工确认 / 缺少证据` 判断、阻塞原因、下一步动作、发布验证记录和发布后 feedback/revenue/recommendation 信号,缺少发布后信号时显示 empty/warning,不执行发布命令、不展示 mock 发布结果;页面可展示 recommendation quality grouping,把真实 recommendation 单条或同组批量生成下一轮待确认 ChangeSet draft,生成后仍进入 Change Review accept/edit/defer,不自动 commit、不写 Project Memory、不创建正式任务;`ChangeReviewPage.tsx` 会展示 `iteration_recommendation_group` evidence 的来源组、feedback 数、recommendation 数、风险、影响和审查优先级;同时读取 Account notification preference helper,用 `releaseGate` 偏好控制发布门禁强调提醒,用 `failureAlert` 偏好控制失败强调提醒,偏好关闭时只静默提醒表达强度,不隐藏 release evidence、阻塞原因、审计状态、validation run 或 releaseSignals;静态发布/增长原型只保留在显式 demo 模式。A3 已接入后端 truth-state,C1 已接入真实 message/extraction/review 状态机,C2 已接入真实 ChangeSet commit,Phase D 已接入真实 execution-summary/dev-sync,D.2 已接入 `POST /execution-handoffs` 创建 PromptPackage + handoff_created progress event,P1 已接入 `POST /execution-handoffs/{prompt_package_id}/consume` 创建 handoff_consumed progress event 并把 execution status 投影为 coding,Release Growth P1 已接入 execution evidence gate,Release Validation P1 已接入 `POST /release-validation-runs` 创建 `ReleaseValidationRun` 快照,Release Feedback P1 已接入 `POST /release-feedback-ingests` 写入 `FeedbackIngest` 并生成 `IterationRecommendation`,Iteration Loop P1 已接入 `POST /iteration-recommendations/{recommendation_id}/change-set-drafts` 把 recommendation 回流到 Change Review,Iteration Loop P2 已接入 `POST /iteration-recommendation-groups/{group_key}/change-set-drafts` 并在 `releaseSignals.recommendationGroups` 暴露分组/去重/风险/影响合同;当前仍不调用真实 AI 提取,不把 mock 当真实失败 fallback。`ProjectMemoryPage.tsx` 在真实模式下展示后端 `truth_nodes + semantic_facts` 的 `projectMemory` 投影,accepted/edited ChangeItem 只有 commit 成功后才进入 Project Memory。`frontend/src/features/pmflow-os/components/ProjectStructuredLoop.tsx` 提供 `StatusLabel / EvidenceSnippet / ObjectChangeCard / ProjectConversationPanel / StructuredExtractionPanel / ProjectAICopilotPanel` 等闭环组件,并复用契约化 `InlineNotice` 表达“未确认不得写入”的局部提示。 - `frontend/src/features/account-settings/AccountSettingsPanel.tsx` 是 `/me` 设置页实现:桌面端为个人资料摘要 + 分区设置面板,移动端为分组列表 + `MobileBottomSheet` 编辑;当前覆盖个人资料、默认语言、AI 回答深度、证据展示偏好、待确认、执行回写、发布门禁和失败提醒通知偏好,并在通知偏好区展示页面内提醒消费说明、提醒强度和证据展示配置。`frontend/src/api/accountSettings.ts` 是前端合同层;有 token 时优先调用 `backend/app/api/account_settings_api/`,失败时回落浏览器本机暂存;P2 新增 `shouldShowAccountNotification` 与 `getAccountNotificationConsumption`,将四类通知偏好映射为本地 UI 的待确认、Execution Bridge、Release/Growth 和失败提醒显示/静默状态;P3 新增 `getReleaseGateNotificationPreference(settings, event)`,把 Release/Growth 的 `release_gate_* / release_validation_*` 事件映射到 `releaseGate` 偏好,把 `failed_event` 映射到 `failureAlert` 偏好;P5/P6 的 `getPostReleaseNotificationPreference(settings, event)` 把发布后 `feedback_received / revenue_signal / compliance_blocked` 映射到 `releaseGate` 偏好,把 `iteration_recommendation / next_change_review` 映射到 `pendingConfirmation` 偏好和 `change_review` surface,并返回 `spaceReadable / evidencePolicy / reminderPolicy` 供 Main Flow 与 Space 消费;P7 将通知偏好扩展为 `reminderIntensity=silent|normal|prominent` 与 `evidenceDisplay=compact|expanded`,并新增 `getAccountReminderConsumption(settings, event)` 覆盖 `change_review_high_risk / iteration_draft_ready / release_blocked / execution_stalled`,输出 `notificationTransport=in_app_only / evidencePolicy=preserve_business_evidence / auditPolicy=preserve_audit_state`;P8 新增 `getReviewDecisionNotificationPreference(settings, event)` 覆盖 `review_requested / review_approved / review_changes_requested / review_rejected / high_risk_approval_required`,继续使用 `pendingConfirmation` 偏好和 `change_review` surface,并声明 reviewerRouting、approval、evidence、audit state 均必须保留。该合同只控制页面内提醒表达,不代表邮件、桌面推送或系统通知中心已接入;偏好关闭也不得隐藏业务证据、reviewerRouting、approval、recommendation、ChangeSet、执行状态或审计状态。`backend/app/schemas/account_settings.py` 定义账号偏好合同,`backend/app/services/account_settings_service.py` 当前复用既有 `integrations` 表按 `tenant_id + user_id` 保存 `account_settings_preferences` 配置包,`backend/app/api/router.py` 已挂载该 API;未改用户表、OAuth/JWT/auth core 或 PMFlow OS truth store。 - `frontend/src/features/pmflow-os/store.tsx` 当前是 PMFlow OS 共享 truth store:`?projectId=` 时通过 React Query 调用 `frontend/src/api/pmflowOs.ts` 读取真实 `GET /pmflow-os/projects/{project_id}/truth-state` 与 `GET /pmflow-os/projects/{project_id}/execution-summary`,真实模式下项目内 message、`proposed_items` extraction、ChangeItem `accept/edit/defer`、ChangeSet commit、review decision、Execution Handoff create/consume、execution progress writeback、release validation run create、release feedback ingest 与 iteration recommendation -> ChangeSet draft 均调用 `/pmflow-os` 后端并 refetch;只有显式 `?demo=1` 或 `?dataSource=demo` 时才启用本地 mock 演示状态机。真实读取/动作失败会进入错误/空态,不静默 fallback 成 mock 成功。 - `frontend/src/features/pmflow-os/store.tsx` 仍保留演示用项目内 AI 结构化闭环状态机:`command_idle / ai_extracting / extraction_ready / review_open / writeback_success`。`frontend/src/features/pmflow-os/pages/StructuredExtractionPage.tsx` 是本轮结构化提取页,`ChangeReviewPage.tsx` 是决策与待确认中心,`MemoryWritebackResultPage.tsx` 是项目记忆写入结果页,`WorkGraphPage.tsx` 是项目工作台关系总览与对象投影视图,`ExecutionBridgePage.tsx` 是执行桥上下文包、交接按钮、运行端领取确认、PR/测试与回写状态页;`ReleaseGrowthPage.tsx` 真实模式读取 `execution-summary.releaseGate`、`latestValidationRun` 与 `releaseSignals`,从 branch、commit、PR、test、failed evidence 展示 `可发布 / 阻塞 / 需要人工确认 / 缺少证据` 判断、阻塞原因、下一步动作、发布验证记录和发布后 feedback/revenue/recommendation 信号,缺少发布后信号时显示 empty/warning,不执行发布命令、不展示 mock 发布结果;页面可把真实 recommendation 生成下一轮待确认 ChangeSet draft,生成后仍进入 Change Review accept/edit/defer,不自动 commit、不写 Project Memory、不创建正式任务;同时读取 Account notification preference helper,用 `releaseGate` 偏好控制发布门禁强调提醒,用 `failureAlert` 偏好控制失败强调提醒,偏好关闭时只静默提醒表达强度,不隐藏 release evidence、阻塞原因、审计状态、validation run 或 releaseSignals;静态发布/增长原型只保留在显式 demo 模式。A3 已接入后端 truth-state,C1 已接入真实 message/extraction/review 状态机,C2 已接入真实 ChangeSet commit,Phase D 已接入真实 execution-summary/dev-sync,D.2 已接入 `POST /execution-handoffs` 创建 PromptPackage + handoff_created progress event,P1 已接入 `POST /execution-handoffs/{prompt_package_id}/consume` 创建 handoff_consumed progress event 并把 execution status 投影为 coding,Release Growth P1 已接入 execution evidence gate,Release Validation P1 已接入 `POST /release-validation-runs` 创建 `ReleaseValidationRun` 快照,Release Feedback P1 已接入 `POST /release-feedback-ingests` 写入 `FeedbackIngest` 并生成 `IterationRecommendation`,Iteration Loop P1 已接入 `POST /iteration-recommendations/{recommendation_id}/change-set-drafts` 把 recommendation 回流到 Change Review;当前仍不调用真实 AI 提取,不把 mock 当真实失败 fallback。`ProjectMemoryPage.tsx` 在真实模式下展示后端 `truth_nodes + semantic_facts` 的 `projectMemory` 投影,accepted/edited ChangeItem 只有 commit 成功后才进入 Project Memory。`frontend/src/features/pmflow-os/components/ProjectStructuredLoop.tsx` 提供 `StatusLabel / EvidenceSnippet / ObjectChangeCard / ProjectConversationPanel / StructuredExtractionPanel / ProjectAICopilotPanel` 等闭环组件,并复用契约化 `InlineNotice` 表达“未确认不得写入”的局部提示。 -- PMFlow OS 后端入口位于 `backend/app/api/pmflow_os_api/`,projection service 位于 `backend/app/services/pmflow_os_projection_service.py`,Change Review / commit / review decision 服务位于 `backend/app/services/pmflow_change_review_service.py`,Pydantic 合同位于 `backend/app/schemas/pmflow_os.py`;当前聚合 `truth_nodes / tasks / semantic_facts / ai_project_control / dev_sync`,`Thread / Message` 来自 `pmflow_threads / pmflow_thread_messages`,`ChangeSet / ChangeItem / Approval / EvidenceLink` 来自 `backend/app/models/pmflow_change_review.py` 对应真实表。`ProjectTruthState.reviewerRouting` 是 Team layer 的 reviewer routing + decision 读模型:从 open/pending ChangeSet、待审 item 风险、跨对象类型、`iteration_recommendation` evidence、项目成员与 `OWNER / REVIEWER` 角色推导 `{status, warningCode, summary, items}`;item 内含 `sourceType / riskLevel / priority / suggestedReviewer / fallbackReviewerRole / targetUrl / reviewDecision / approvalState / requestedAt / decidedAt / decisionReason`;缺成员、缺角色或缺证据时 fail-closed 给 warning,不虚构 reviewer。P8 新增 `POST /pmflow-os/change-sets/{change_set_id}/review-decisions`,支持 `request_review / approve / request_changes / reject`,`approve / request_changes / reject` 写 `PMFlowApproval` 审计,high-risk approve 后才满足 C2 approval gate,所有 decision 都不自动 commit。C1 已实现 message、`proposed_items` extraction、ChangeSet detail、ChangeItem `accept/edit/defer`;C2 backend/frontend 已实现 `POST /pmflow-os/change-sets/{change_set_id}/commit` 与真实模式前端接入,默认 atomic,将 `Requirement / Decision / Risk` 写入 `truth_nodes`,将 `Task` 写入 `truth_nodes(node_type='task') + tasks`,高风险项强制 approval gate;D.2 已实现 `POST /pmflow-os/projects/{project_id}/execution-handoffs`,校验 task/repo project 边界,生成/复用 `PromptPackage(package_kind='execution_handoff')` 并写 `ProgressEvent(event_type='handoff_created')` 起点;P1 已实现 `POST /pmflow-os/projects/{project_id}/execution-handoffs/{prompt_package_id}/consume`,校验 execution handoff package 后更新 `PromptPackage.package_payload.latest_handoff.handoff_state=consumed` 并写 `ProgressEvent(event_type='handoff_consumed', derived_task_status='coding')`;Release Growth P1 在 `ExecutionSummaryProjection.releaseGate` 中从 `ProgressEvent` 推导发布门禁状态,失败事件 fail-closed 阻塞发布,无证据返回 empty/warning;Release Validation P1 新增 `POST /pmflow-os/projects/{project_id}/release-validation-runs`,复用现有 `ReleaseValidationRun` 模型固化当前 `releaseGate` 快照、人工决策、证据、阻塞原因和下一步动作,并在 `releaseGate.latestValidationRun` 返回最新记录;Release Feedback P1 新增 `POST /pmflow-os/projects/{project_id}/release-feedback-ingests`,复用现有 `FeedbackIngest / IterationRecommendation`,在 `ExecutionSummaryProjection.releaseSignals` 中返回真实发布后 feedback、revenue event 和 iteration recommendation 读模型;Iteration Loop P1 新增 `POST /pmflow-os/projects/{project_id}/iteration-recommendations/{recommendation_id}/change-set-drafts`,把真实 recommendation 转为 open ChangeSet、pending_review Requirement/Task/Risk/Decision items 与 `iteration_recommendation` evidence,重复调用复用既有 draft 并在 `releaseSignals` 标记 `changeSetCreated/changeSetId`;这些步骤都不直接执行本机命令、真实发布命令、外部支付、真实推送、自动 Project Memory commit 或正式任务创建;`ModelDecision / MetricSignal` 在 C2 V1 仍 fail-closed。 -- PMFlow Launch 后端 Phase B 创建链位于 `backend/app/api/pmflow_launch_api/`、`backend/app/services/pmflow_launch_service.py`、`backend/app/schemas/pmflow_launch.py` 与 `backend/app/models/pmflow_launch.py`;`POST /pmflow-launch/drafts` 只写 `pmflow_launch_drafts`,`POST /pmflow-launch/drafts/{draft_id}/confirm` 在同一事务内写 `projects / user_projects / pmflow_threads / pmflow_thread_messages / truth_nodes / tasks / semantic_facts seed / operation_logs`,并把 `launch_draft_id / thread_id / thread_message_id` 写入正式对象 metadata。 +- PMFlow OS 后端入口位于 `backend/app/api/pmflow_os_api/`,projection service 位于 `backend/app/services/pmflow_os_projection_service.py`,Execution Capability Graph 只读聚合位于 `backend/app/services/team_execution_capability_service.py`,Change Review / commit / review decision 服务位于 `backend/app/services/pmflow_change_review_service.py`,Pydantic 合同位于 `backend/app/schemas/pmflow_os.py`;当前聚合 `truth_nodes / tasks / semantic_facts / ai_project_control / dev_sync`,`Thread / Message` 来自 `pmflow_threads / pmflow_thread_messages`,`ChangeSet / ChangeItem / Approval / EvidenceLink` 来自 `backend/app/models/pmflow_change_review.py` 对应真实表。`GET /pmflow-os/projects/{project_id}/execution-capabilities` 返回 `executionModes / personalReadiness / teamSignals / warnings`,复用用户、项目成员、技能绑定、负载、reviewer routing、execution summary 与 workspace binding 数据,只给建议、阻塞和解释,不自动分派任务、不自动请求审查、不写 ChangeSet;缺 repo、工具、测试命令、reviewer 或证据时返回 warning、`blocked` 或 `needs_human_judgement`,不虚构能力结论。`ProjectTruthState.reviewerRouting` 是 Team layer 的 reviewer routing + decision 读模型:从 open/pending ChangeSet、待审 item 风险、跨对象类型、`iteration_recommendation` evidence、项目成员与 `OWNER / REVIEWER` 角色推导 `{status, warningCode, summary, items}`;item 内含 `sourceType / riskLevel / priority / suggestedReviewer / fallbackReviewerRole / targetUrl / reviewDecision / approvalState / requestedAt / decidedAt / decisionReason`;缺成员、缺角色或缺证据时 fail-closed 给 warning,不虚构 reviewer。P8 新增 `POST /pmflow-os/change-sets/{change_set_id}/review-decisions`,支持 `request_review / approve / request_changes / reject`,`approve / request_changes / reject` 写 `PMFlowApproval` 审计,high-risk approve 后才满足 C2 approval gate,所有 decision 都不自动 commit。C1 已实现 message、`proposed_items` extraction、ChangeSet detail、ChangeItem `accept/edit/defer`;C2 backend/frontend 已实现 `POST /pmflow-os/change-sets/{change_set_id}/commit` 与真实模式前端接入,默认 atomic,将 `Requirement / Decision / Risk` 写入 `truth_nodes`,将 `Task` 写入 `truth_nodes(node_type='task') + tasks`,高风险项强制 approval gate;D.2 已实现 `POST /pmflow-os/projects/{project_id}/execution-handoffs`,校验 task/repo project 边界,生成/复用 `PromptPackage(package_kind='execution_handoff')` 并写 `ProgressEvent(event_type='handoff_created')` 起点;P1 已实现 `POST /pmflow-os/projects/{project_id}/execution-handoffs/{prompt_package_id}/consume`,校验 execution handoff package 后更新 `PromptPackage.package_payload.latest_handoff.handoff_state=consumed` 并写 `ProgressEvent(event_type='handoff_consumed', derived_task_status='coding')`;Release Growth P1 在 `ExecutionSummaryProjection.releaseGate` 中从 `ProgressEvent` 推导发布门禁状态,失败事件 fail-closed 阻塞发布,无证据返回 empty/warning;Release Validation P1 新增 `POST /pmflow-os/projects/{project_id}/release-validation-runs`,复用现有 `ReleaseValidationRun` 模型固化当前 `releaseGate` 快照、人工决策、证据、阻塞原因和下一步动作,并在 `releaseGate.latestValidationRun` 返回最新记录;Release Feedback P1 新增 `POST /pmflow-os/projects/{project_id}/release-feedback-ingests`,复用现有 `FeedbackIngest / IterationRecommendation`,在 `ExecutionSummaryProjection.releaseSignals` 中返回真实发布后 feedback、revenue event 和 iteration recommendation 读模型;Iteration Loop P1 新增 `POST /pmflow-os/projects/{project_id}/iteration-recommendations/{recommendation_id}/change-set-drafts`,把真实 recommendation 转为 open ChangeSet、pending_review Requirement/Task/Risk/Decision items 与 `iteration_recommendation` evidence,重复调用复用既有 draft 并在 `releaseSignals` 标记 `changeSetCreated/changeSetId`;这些步骤都不直接执行本机命令、真实发布命令、外部支付、真实推送、自动 Project Memory commit 或正式任务创建;`ModelDecision / MetricSignal` 在 C2 V1 仍 fail-closed。 +- Adaptive Human Capacity 后端由 `backend/app/services/adaptive_capacity_service.py`、`backend/app/models/human_capacity_override.py`、`backend/alembic/versions/20260427_add_human_capacity_overrides.py` 与 `backend/app/schemas/pmflow_os.py` 承载;API 位于 `backend/app/api/pmflow_os_api/`:`GET /pmflow-os/projects/{project_id}/capacity-plan` 实时计算每日容量计划,`POST /pmflow-os/projects/{project_id}/capacity-overrides/simulate` 只模拟,`POST /pmflow-os/projects/{project_id}/capacity-overrides` 只写 override 审计,`GET /pmflow-os/projects/{project_id}/capacity-overrides` 返回调整记录;`execution-capabilities` 同步返回 `adaptiveCapacitySummary`。该模型不持久化每日计划快照,不写固定基线,不自动分配任务、不请求审查、不写 ChangeSet;旧 `UserWorkload.workload_ratio` 仅保留为成本参考信号。 +- PMFlow Launch 后端 Phase B 创建链位于 `backend/app/api/pmflow_launch_api/`、`backend/app/services/pmflow_launch_service.py`、`backend/app/schemas/pmflow_launch.py` 与 `backend/app/models/pmflow_launch.py`;`POST /pmflow-launch/drafts` 只写 `pmflow_launch_drafts`,`POST /pmflow-launch/drafts/{draft_id}/confirm` 在同一事务内写 `projects / user_projects / pmflow_threads / pmflow_thread_messages / truth_nodes / tasks / semantic_facts seed / operation_logs`,并把 `launch_draft_id / thread_id / thread_message_id` 写入正式对象 metadata。旧 `POST /ai-steward/project-from-text` 仅由 `backend/app/api/legacy_ai_steward_api.py` 保留 410 响应一个版本周期,且该模块在 lean profile 下也会挂载;禁止绕过草案确认直接创建正式项目。 - `frontend/src/features/pmflow-os/components/ChangeReviewGroup.tsx`、`WorkbenchPanel.tsx`、`ExecutionBridgeCards.tsx`、`PMFlowOsFrame.tsx`、`AccountControls.tsx`、`FeedbackPrimitives.tsx` 是高保真原型新增的行为组件层:分别承载变更 diff 审查块、工作台对象卡组、执行桥上下文/日志卡片、全局上下文条与 AI 副驾、账号菜单/退出登录确认交互,以及 `PageBanner / InlineNotice / HumanDecisionBar / ConfirmDialog / MobileBottomSheet` 这组提示、提醒、确认与移动端 bottom sheet 基础组件,避免再把这些业务片段内联塞回 `pages/`。 -- `frontend/vite.config.ts` 现补了固定 vendor chunk 分组(`framework / state-query / ui-radix / icons / charts / markdown`)与业务 chunk 分组(`ai-steward / sstc / governance`),目标是让登录页与基础导航不再背 AI Steward、SSTC、治理页和图表代码。 +- `frontend/vite.config.ts` 保留固定第三方依赖 chunk 分组(`framework / state-query / ui-radix / icons / charts / markdown`);业务源码不再强制切 `ai-steward / sstc / governance` chunk,避免业务模块循环依赖在生产预览中触发初始化顺序错误。登录页与基础导航的体积治理继续依赖 route/page-section lazy。 - 旧登录残留 `frontend/src/components/ui/sign-in.tsx` 与 `frontend/src/components/ui/demo.tsx` 已删除,`frontend/src/components/login/` 成为唯一登录实现真源。 - 前端开发链路已按职责拆分:`docker-compose.dev.yml` 下的 Vite 热更新开发前端固定占用 `5174`,静态生产预览前端默认改为 `5175`;`frontend/scripts/dev-entrypoint.sh` 避免每次容器启动都执行 `npm ci`,`frontend/scripts/login-live-check.mjs` 与 `package.json` 中的 `verify:login-fast / verify:login-responsive / verify:login-full`、`capture:login:matrix` 作为登录页快回路验证入口,截图产物统一落到 `frontend/output/login/`。 - `frontend/src/api/taskBriefs.ts` - `frontend/src/api/modelRouting.ts` +- `frontend/src/api/aiRouting.ts` - `frontend/src/api/mcp.ts` - `frontend/src/api/knowledgeGraph.ts` - `frontend/src/api/semanticFacts.ts` @@ -220,9 +221,9 @@ - `frontend/src/api/mountedAi.ts` - `frontend/src/api/feedbackCenter.ts` - `frontend/src/api/sstc.ts` -- `frontend/src/components/inbox/` - `frontend/src/components/sstc/` - `frontend/src/pages/scheduler-center/components/` +- `frontend/src/pages/ai-routing/` - `frontend/src/pages/ai-steward/components/` - `frontend/src/lib/` @@ -232,8 +233,10 @@ - `frontend/src/components/sstc/` 继续作为 SSTC 共享组件目录维护。 - AI Steward 浮窗的“当前对象/来源模式/证据状态”必须直接消费 `/rag/ask` 与 QA session API 的显式 contract 字段,禁止在前端靠回答正文反推 internal/external 或 plan 状态。 - 网页问答显式锚点 v1 只能消费稳定强锚点:项目页路径中的 `project_id` 与 URL query 中已显式存在的 `project_id/source_chat_id`;前端禁止自行猜测 `source_chat_id`。 -- `AI Config -> Governance Tools` 现新增 `/ai-config/mcp-config`、`/ai-config/query-route-governance` 与 `/ai-config/semantic-facts-board` 入口;三类治理页都只对 `BOSS/ADMIN` 开放。MCP 页负责模板化配置、后端直连/Local Bridge 状态展示、runtime probe、命令预览与本机确认;route/context 页负责 mismatch、clarify-heavy 与 negative feedback 样本排查;semantic facts 页负责 KPI、队列、对象表与详情抽屉。 -- PMFlow OS 原型页是前端独立产品域,不复用 `AI Steward` / `Project Board` 的旧信息架构;新页面只允许消费 `frontend/src/features/pmflow-os/store.tsx` 的共享状态和行为组件,避免把原型再拖回 SSTC/治理后台语义。个人空间首页、新项目启动台和 Account Settings 同样是独立产品域:Space 只能通过 `frontend/src/api/space.ts` 做项目摘要、PMFlow OS `truth-state / execution-summary` 的只读聚合并跳回主流程;Project Launch 默认走真实 draft/confirm 创建链,只有显式 demo 参数才使用本地状态机;Account Settings 只管理个人资料、偏好和通知,不改 OAuth/JWT/auth core、PMFlow OS truth store、Project Memory、ChangeSet 或 Execution Bridge 写回。 +- `/ai-config` 是团队级 AI 运营中心,技术路由继续保留但用户可见不再叫“管理后台”或泛化 AI Config;首页按策略 / 连接 / 治理 / 高级分组组织 `/ai-config/team-ai-policy`、`/ai-config/ai-routing-console`、`/ai-config/mcp-config`、`/ai-config/query-route-governance` 与 `/ai-config/semantic-facts-board` 等入口,并明确这些配置影响当前团队空间内所有项目。治理页默认只对 `OWNER/REVIEWER` 或既有管理角色开放。AI 路由与供应商控制台由 `backend/app/api/ai_routing.py` 与 `backend/app/services/ai_routing_governance_service.py` 提供聚合,负责人视图只展示业务场景策略,管理员视图展示供应商连接、快速/深度槽位、诊断、高级函数详情和审计提示;团队策略只写 `integrations(type='ai_team_policy')`,不改 `llm_service` runtime 真源或 `backend/.control_plane/pmflow-routing-rules.json`。MCP 页负责模板化配置、后端直连/Local Bridge 状态展示、runtime probe、命令预览与本机确认;route/context 页负责 mismatch、clarify-heavy 与 negative feedback 样本排查;semantic facts 页负责 KPI、队列、对象表与详情抽屉。 +- AI 路由与供应商用户可见层必须脱敏:成员视图不得出现 low/high、provider、model、`function_name`、`api_env`、raw routing meta 或明文 key;负责人只看“质量优先 / 成本均衡 / 速度优先”和项目启动、需求结构化、任务拆解、人员匹配、内部问答、外部查询、飞书理解、飞书回复等场景策略;管理员高级详情默认隐藏 `function_name`,只在明确展开时展示。 +- 左侧导航不再展示团队负载、能力管理和用户管理作为一级产品入口;旧 `/users`、`/skills`、`/workloads`、`/users/:id/skills`、`/workloads/:id` 路由保留为高级设置/明细能力,用户可见语义分别收敛为成员与权限、能力词库、容量明细。`/workloads` 只保留传统工时参考、今日容量计划和 override 记录,不再作为 AI 分配核心。团队相关主入口统一为 `/team-layer`(团队层),承载 reviewer routing、Team Feed、执行能力、自适应容量、负责人容量模拟/调整与团队空间预留;AI 路由、供应商和语义治理归入“AI 运营中心”,成员不显示 AI 运营中心入口。项目级页面只消费 AI 处理模式与可信状态,不提供供应商、模型或底层路由配置。 +- PMFlow OS 原型页是前端独立产品域,不复用 `AI Steward` / `Project Board` 的旧信息架构;新页面只允许消费 `frontend/src/features/pmflow-os/store.tsx` 的共享状态和行为组件,避免把原型再拖回 SSTC/治理后台语义。个人空间首页、新项目启动台和 Account Settings 同样是独立产品域:Space 只能通过 `frontend/src/api/space.ts` 做项目摘要、PMFlow OS `truth-state / execution-summary / execution-capabilities` 的只读聚合并跳回主流程;Project Launch 默认走真实 draft/confirm 创建链,只有显式 demo 参数才使用本地状态机;Account Settings 只管理个人资料、偏好和通知,不改 OAuth/JWT/auth core、PMFlow OS truth store、Project Memory、ChangeSet 或 Execution Bridge 写回。 ## 6. 工具与运行库存 - `backend/scripts/repair_requirement_plan_history.py` diff --git a/docs/architecture/CODE_REFERENCE_INDEX.md b/docs/architecture/CODE_REFERENCE_INDEX.md index 0c9fb596..fbbbbb54 100644 --- a/docs/architecture/CODE_REFERENCE_INDEX.md +++ b/docs/architecture/CODE_REFERENCE_INDEX.md @@ -16,10 +16,12 @@ - 审计查询接口:参考 `backend/app/api/audit.py`(`GET /audit/operations`) - 配置读取类接口:参考 `backend/app/api/llm_providers.py`(`GET /ai-steward/llm/providers`) - LLM 引擎设置接口:参考 `backend/app/api/llm_engines.py`(`PUT /ai-steward/llm-engines/settings`) +- AI 路由与供应商产品化控制台接口:参考 `backend/app/api/ai_routing.py`(`GET /ai-routing/overview`、`GET/PUT /ai-routing/team-policy`、`GET /ai-routing/admin-console`、`POST /ai-routing/diagnostics/run`);该接口由 `backend/app/api/router.py` 核心路由加载,`API_ROUTE_PROFILE=lean` 下也必须可用 ### 2.2 AI Steward / Training / Mounted Runtime - 项目创建接口:参考 `backend/app/api/ai_steward_api/routes_capture.py` -- 模型路由配置接口:参考 `backend/app/api/model_routing.py` +- 模型路由配置接口:参考 `backend/app/api/model_routing.py`;作为 AI 运营中心“高级路由详情”的兼容入口,`API_ROUTE_PROFILE=lean` 下也必须由核心路由加载 +- AI 路由治理聚合服务:参考 `backend/app/services/ai_routing_governance_service.py`;聚合 `integrations(type='llm_engine'|'model_routing'|'ai_team_policy')`、`ai_call_logs` 与 `backend/.control_plane/pmflow-routing-rules.json`,只返回中文展示字段与 masked key,不返回明文 key、`api_env`、prompt、raw error 或 raw routing meta - 回复质量闭环接口:参考 `backend/app/api/ai_steward_api/routes_reply_iteration.py` - 训练/微调管线接口:参考 `backend/app/api/ai_steward_api/routes_training_pipeline.py` - Mounted Runtime 控制台接口:参考 `backend/app/api/ai_steward_api/routes_mounted_ai.py` @@ -70,7 +72,7 @@ - AI PMO 恢复接口:参考 `POST /ai-pmo/work-items/{work_item_id}/resume` - AI PMO 草稿决策接口:参考 `POST /ai-pmo/work-items/{work_item_id}/draft-decision` - AI PMO 审批接口:参考 `POST /ai-pmo/work-items/{work_item_id}/decision` -- AI PMO 消息台列表接口:参考 `GET /ai-pmo/work-items` +- AI PMO legacy 工作项列表接口:参考 `GET /ai-pmo/work-items`;当前只作为 Space legacy adapter 输入,独立收件页已退场 - AI PMO `process-text/work-item` 的 `plan_mode / plan_status / requirement_plan_context_summary` 投影:参考 `backend/app/api/ai_pmo_api/helpers_inbox_payload.py` 与 `backend/app/api/ai_pmo_api/routes_core.py` - `/rag/ask` 与 `/ai-pmo/process-text` 注入 `project_operating_context`:参考 `backend/app/api/rag_ask_contract.py` 与 `backend/app/api/ai_pmo_api/routes_core.py` @@ -89,8 +91,10 @@ - SSTC 负责人推荐接口:参考 `POST /projects/{project_id}/tasks/{task_id}/recommend-assignee` - SSTC 任务更新接口:参考 `PATCH /projects/{project_id}/tasks/{task_id}` - SSTC 发布创建接口:参考 `POST /projects/{project_id}/releases` -- PMFlow OS 真实读路径、C1 Change Review API、C2 backend commit、Execution Handoff、Release Evidence Gate、Release Validation Run、Release Feedback Signal、Iteration Loop draft、Team reviewer decision loop 与 Team Feed 只读动态流:参考 `backend/app/api/pmflow_os_api/`、`backend/app/services/pmflow_os_projection_service.py`、`backend/app/services/pmflow_change_review_service.py`、`backend/app/schemas/pmflow_os.py`;只读提供 `GET /pmflow-os/projects/{project_id}/truth-state` 与 `GET /pmflow-os/projects/{project_id}/execution-summary`,复用 `truth_nodes / tasks / semantic_facts / ai_project_control / dev_sync`,并读取 `pmflow_threads / pmflow_thread_messages / pmflow_change_sets / pmflow_change_items / pmflow_approvals / pmflow_evidence_links`;`truth-state.reviewerRouting` 是 `{status, warningCode, summary, items}` 读模型,会从 open/pending ChangeSet、待审 item 风险、跨对象类型、evidence source、项目成员与 `OWNER / REVIEWER` 角色生成 `sourceType / riskLevel / priority / suggestedReviewer / fallbackReviewerRole / targetUrl`,并返回 `reviewDecision / approvalState / requestedAt / decidedAt / decisionReason` 审查决策只读字段;`truth-state.feed` 是 Team Feed 只读动态流,从 review request / review decision / ChangeSet 状态 / `ProgressEvent` / release gate 聚合,不写审批、不写 ChangeSet、不写通知;P8 新增 `POST /pmflow-os/change-sets/{change_set_id}/review-decisions`,支持 `request_review / approve / request_changes / reject`,复用 `PMFlowApproval` 写审计,`approve` 满足 high-risk C2 approval gate 但不自动 commit,`request_changes / reject` 不写 Project Memory;C1 写 API 包括 thread message、`proposed_items` extraction、ChangeSet detail、ChangeItem `accept/edit/defer`;C2 backend 已实现 `POST /pmflow-os/change-sets/{change_set_id}/commit`,默认 `mode=atomic`,将 `Requirement / Decision / Risk` 写入 `truth_nodes`,将 `Task` 写入 `truth_nodes(node_type='task') + tasks`,高风险项强制 approval gate;D.2 已实现 `POST /pmflow-os/projects/{project_id}/execution-handoffs`,对已确认任务生成/复用 `PromptPackage` 并创建 `ProgressEvent(event_type='handoff_created')` 起点;P1 已实现 `POST /pmflow-os/projects/{project_id}/execution-handoffs/{prompt_package_id}/consume`,记录运行端领取、更新 `PromptPackage.package_payload.latest_handoff.handoff_state=consumed` 并创建 `ProgressEvent(event_type='handoff_consumed', derived_task_status='coding')`;Release Growth P1 已在 `execution-summary.releaseGate` 中从 branch / commit / PR / test_passed / checks_passed / failed / blocked 等 `ProgressEvent` 推导 `ready / blocked / needs_confirmation / empty`;Release Validation P1 新增 `POST /pmflow-os/projects/{project_id}/release-validation-runs`,从当前 `releaseGate` 固化 `ReleaseValidationRun` 快照并在 `releaseGate.latestValidationRun` 返回最新记录,无 evidence 时返回 `release_validation_evidence_empty`;Release Feedback P1 新增 `POST /pmflow-os/projects/{project_id}/release-feedback-ingests`,复用 `FeedbackIngest / IterationRecommendation` 写入真实发布后反馈,并在 `execution-summary.releaseSignals` 投影真实 `feedback / revenueEvents / recommendations` 与 release gate / validation run 关系;Iteration Loop P1 新增 `POST /pmflow-os/projects/{project_id}/iteration-recommendations/{recommendation_id}/change-set-drafts`,把真实 `IterationRecommendation` 转为 open ChangeSet、pending_review Requirement/Task/Risk/Decision items 与 iteration evidence,重复调用复用既有 draft;Iteration Loop P2 新增 `POST /pmflow-os/projects/{project_id}/iteration-recommendation-groups/{group_key}/change-set-drafts`,并在 `execution-summary.releaseSignals.recommendations` 补 `groupKey / duplicateOf / mergedCount / impactLevel / riskLevel / suggestedChangeTypes / reviewPriority`,在 `recommendationGroups` 聚合同组建议,支持同组 recommendation 批量生成一个可复用 ChangeSet draft 与 `iteration_recommendation_group` evidence;这些步骤均不执行发布命令、外部支付、真实推送、自动 Project Memory commit 或正式 Task 创建 +- PMFlow OS 真实读路径、C1 Change Review API、C2 backend commit、Execution Handoff、Release Evidence Gate、Release Validation Run、Release Feedback Signal、Iteration Loop draft、Team reviewer decision loop、Execution Capability Graph 与 Team Feed 只读动态流:参考 `backend/app/api/pmflow_os_api/`、`backend/app/services/pmflow_os_projection_service.py`、`backend/app/services/pmflow_change_review_service.py`、`backend/app/services/team_execution_capability_service.py`、`backend/app/schemas/pmflow_os.py`;只读提供 `GET /pmflow-os/projects/{project_id}/truth-state`、`GET /pmflow-os/projects/{project_id}/execution-summary` 与 `GET /pmflow-os/projects/{project_id}/execution-capabilities`,复用 `truth_nodes / tasks / semantic_facts / ai_project_control / dev_sync / users / user_projects / user_workloads / user_skill_bindings`;`execution-capabilities` 只输出执行模式、个人执行准备度、成员执行能力与容量信号、warning,不自动分配任务、不自动请求审查、不写 ChangeSet;`truth-state.reviewerRouting` 是 `{status, warningCode, summary, items}` 读模型,会从 open/pending ChangeSet、待审 item 风险、跨对象类型、evidence source、项目成员与 `OWNER / REVIEWER` 角色生成 `sourceType / riskLevel / priority / suggestedReviewer / fallbackReviewerRole / targetUrl`,并返回 `reviewDecision / approvalState / requestedAt / decidedAt / decisionReason` 审查决策只读字段;`truth-state.feed` 是 Team Feed 只读动态流,从 review request / review decision / ChangeSet 状态 / `ProgressEvent` / release gate 聚合,不写审批、不写 ChangeSet、不写通知;P8 新增 `POST /pmflow-os/change-sets/{change_set_id}/review-decisions`,支持 `request_review / approve / request_changes / reject`,复用 `PMFlowApproval` 写审计,`approve` 满足 high-risk C2 approval gate 但不自动 commit,`request_changes / reject` 不写 Project Memory;C1 写 API 包括 thread message、`proposed_items` extraction、ChangeSet detail、ChangeItem `accept/edit/defer`;C2 backend 已实现 `POST /pmflow-os/change-sets/{change_set_id}/commit`,默认 `mode=atomic`,将 `Requirement / Decision / Risk` 写入 `truth_nodes`,将 `Task` 写入 `truth_nodes(node_type='task') + tasks`,高风险项强制 approval gate;D.2 已实现 `POST /pmflow-os/projects/{project_id}/execution-handoffs`,对已确认任务生成/复用 `PromptPackage` 并创建 `ProgressEvent(event_type='handoff_created')` 起点;P1 已实现 `POST /pmflow-os/projects/{project_id}/execution-handoffs/{prompt_package_id}/consume`,记录运行端领取、更新 `PromptPackage.package_payload.latest_handoff.handoff_state=consumed` 并创建 `ProgressEvent(event_type='handoff_consumed', derived_task_status='coding')`;Release Growth P1 已在 `execution-summary.releaseGate` 中从 branch / commit / PR / test_passed / checks_passed / failed / blocked 等 `ProgressEvent` 推导 `ready / blocked / needs_confirmation / empty`;Release Validation P1 新增 `POST /pmflow-os/projects/{project_id}/release-validation-runs`,从当前 `releaseGate` 固化 `ReleaseValidationRun` 快照并在 `releaseGate.latestValidationRun` 返回最新记录,无 evidence 时返回 `release_validation_evidence_empty`;Release Feedback P1 新增 `POST /pmflow-os/projects/{project_id}/release-feedback-ingests`,复用 `FeedbackIngest / IterationRecommendation` 写入真实发布后反馈,并在 `execution-summary.releaseSignals` 投影真实 `feedback / revenueEvents / recommendations` 与 release gate / validation run 关系;Iteration Loop P1/P2 支持 recommendation 单条或同组生成 ChangeSet draft;这些步骤均不执行发布命令、外部支付、真实推送、自动 Project Memory commit 或正式 Task 创建。 +- Adaptive Human Capacity:参考 `backend/app/services/adaptive_capacity_service.py`、`backend/app/models/human_capacity_override.py`、`backend/alembic/versions/20260427_add_human_capacity_overrides.py`、`frontend/src/api/pmflowOs.ts`、`frontend/src/api/space.ts`、`frontend/src/features/pmflow-os/pages/TeamLayerPage.tsx`、`frontend/src/features/personal-space/PersonalSpacePage.tsx` 与 `frontend/src/pages/workloads/WorkloadsPage.tsx`;接口为 `GET /pmflow-os/projects/{project_id}/capacity-plan`、`POST /pmflow-os/projects/{project_id}/capacity-overrides/simulate`、`POST /pmflow-os/projects/{project_id}/capacity-overrides`、`GET /pmflow-os/projects/{project_id}/capacity-overrides`,并在 `execution-capabilities.adaptiveCapacitySummary` 返回今日容量摘要。每日计划由当前任务池实时计算,不持久化固定基线;`human_capacity_overrides` 只存负责人手工调整、原因、过期时间和审计;写接口不自动派任务、不请求审查、不写 ChangeSet。 - Project Launch 真实创建链:参考 `backend/app/api/pmflow_launch_api/`、`backend/app/services/pmflow_launch_service.py`、`backend/app/schemas/pmflow_launch.py` 与模型 `backend/app/models/pmflow_launch.py`;接口为 `POST /pmflow-launch/drafts` 与 `POST /pmflow-launch/drafts/{draft_id}/confirm`,draft 只写 `pmflow_launch_drafts`,confirm 在同一事务内创建 `projects / user_projects / pmflow_threads / pmflow_thread_messages / truth_nodes / tasks / semantic_facts seed / operation_logs` +- Legacy project create 410 路由:参考 `backend/app/api/legacy_ai_steward_api.py`;该模块在 `CORE_ROUTE_MODULES` 中始终挂载,只保留 `POST /ai-steward/project-from-text` 的 410 包络响应,避免 lean profile 下未知外部调用落成 404。 - PMFlow Change Review 模型与 migration:参考 `backend/app/models/pmflow_change_review.py` 与 `backend/alembic/versions/20260426_add_pmflow_change_review_tables.py`;只包含 `pmflow_change_sets / pmflow_change_items / pmflow_approvals / pmflow_evidence_links`,本阶段不得新增或依赖 `/commit` ## 3. 前端参考 @@ -98,48 +102,49 @@ - PMFlow 大模块并行公共壳:参考 `docs/PMFLOW_PRODUCT_DELIVERY_STANDARD.md` 的 `4.4.1-4.4.3` 与 `frontend/src/router/index.tsx`、`frontend/src/main.tsx`、`frontend/src/features/pmflow-os/components/MobileBottomNav.tsx`、`frontend/src/features/pmflow-os/components/PMFlowOsFrame.tsx`;`/space` 归 Space lane,`/pmflow-os / /command-center / /change-review / /execution-bridge / /release-growth` 归 Main Flow lane,`/me` 归 Account Settings lane,`/settings` 当前仅为 reserved route,新增或改动必须由 Shell / Integration owner 串行处理 - PMFlow P8 Review Decision Loop 已集成收口:参考 `docs/PMFLOW_PRODUCT_DELIVERY_STANDARD.md` 第 7 节和 `big-module-integration-p8` 台账;Main Flow 以 `truth-state.reviewerRouting` 和 `POST /pmflow-os/change-sets/{change_set_id}/review-decisions` 提供 reviewer decision / approval audit 合同,Space 只读消费为 `/space` 的 `review_attention` 下一步队列,Account 只提供 review decision / high-risk approval 页面内提醒偏好;P8 不新增战略页面、不改 router/nav、不接真实通知中心,继续使用既有 `/change-review / /team-layer / /space / /me` 表面 - PMFlow OS 产品合同真源:先读 `docs/architecture/PMFLOW_OS_PRODUCT_CONTRACT.md`,再实现 `pmflow-os` 真实后端接入;该文档锁定 `ProjectTruthState / Thread / ChangeSet / ChangeItem / Evidence / Approval / Project Memory / ExecutionContext` 与 `truth_nodes / tasks / semantic_facts / ai_project_control / dev_sync` 的复用边界和后续 API 草案 -- 新增前端 API 调用:参考 `frontend/src/api/sstc.ts`;Space 工作面只读聚合参考 `frontend/src/api/space.ts`,只消费项目摘要与 PMFlow OS `truth-state / execution-summary`,不写 Project Memory、ChangeSet 状态、ProgressEvent 或 ReleaseValidationRun;Space P6 的 `nextActionQueue` 在 Space 本地读模型内把高风险待确认、发布阻塞/失败证据、handoff consumed 后缺少后续 progress、下一轮 draft、发布后 feedback/revenue/compliance 信号统一排序为“我的下一步”,所有 item 只跳 `/change-review`、`/execution-bridge` 或 `/release-growth`;Space P3 的 `releaseAttention` 优先从 `truth-state.releases` 的 release validation 投影生成“发布验证阻塞 / 发布验证待确认”,没有发布验证投影时才回落到 `PMFlowExecutionSummary.releaseGate` 的“发布候选 / 发布门禁阻塞”,所有动作只跳转 `/release-growth?projectId=`;PMFlow OS 真实接入参考 `frontend/src/api/pmflowOs.ts` 的 `getPMFlowTruthState(projectId)`、`getPMFlowExecutionSummary(projectId)`、`createPMFlowExecutionHandoff`、`consumePMFlowExecutionHandoff`、`createPMFlowExecutionProgressEvent`、`createPMFlowReleaseValidationRun`、`createPMFlowReleaseFeedbackIngest`、`createPMFlowIterationRecommendationChangeSetDraft`、`createPMFlowIterationRecommendationGroupChangeSetDraft`、`createPMFlowReviewDecision`、`createPMFlowThreadMessage`、`createPMFlowExtraction`、`getPMFlowChangeSet`、`acceptPMFlowChangeItem`、`editPMFlowChangeItem`、`deferPMFlowChangeItem`、`commitPMFlowChangeSet`;`PMFlowExecutionSummary.releaseGate` 是 ReleaseGrowth 真实发布门禁前端合同,`releaseGate.latestValidationRun` 是最新发布验证记录前端合同,`execution-summary.releaseSignals` 是 Release/Growth 发布后 feedback/revenue/recommendation 信号、recommendation quality grouping 与下一轮 ChangeSet draft 状态前端合同;Project Launch 创建链参考 `frontend/src/api/pmflowLaunch.ts` 的 `createPMFlowLaunchDraft` 与 `confirmPMFlowLaunchDraft` -- AI Steward 页面入口:参考 `frontend/src/pages/ai-steward/` +- 新增前端 API 调用:参考 `frontend/src/api/sstc.ts`;Space 工作面只读聚合参考 `frontend/src/api/space.ts`,只消费项目摘要与 PMFlow OS `truth-state / execution-summary / execution-capabilities`,不写 Project Memory、ChangeSet 状态、ProgressEvent 或 ReleaseValidationRun;Space P6 的 `nextActionQueue` 在 Space 本地读模型内把高风险待确认、发布阻塞/失败证据、handoff consumed 后缺少后续 progress、下一轮 draft、发布后 feedback/revenue/compliance 信号统一排序为“我的下一步”,所有 item 只跳 `/change-review`、`/execution-bridge` 或 `/release-growth`;Space 的 `executionReadiness` 只读展示工具连接、待确认、待审查、待验收和阻塞原因;PMFlow OS 真实接入参考 `frontend/src/api/pmflowOs.ts` 的 `getPMFlowTruthState(projectId)`、`getPMFlowExecutionSummary(projectId)`、`getPMFlowExecutionCapabilities(projectId)`、`createPMFlowExecutionHandoff`、`consumePMFlowExecutionHandoff`、`createPMFlowExecutionProgressEvent`、`createPMFlowReleaseValidationRun`、`createPMFlowReleaseFeedbackIngest`、`createPMFlowIterationRecommendationChangeSetDraft`、`createPMFlowIterationRecommendationGroupChangeSetDraft`、`createPMFlowReviewDecision`、`createPMFlowThreadMessage`、`createPMFlowExtraction`、`getPMFlowChangeSet`、`acceptPMFlowChangeItem`、`editPMFlowChangeItem`、`deferPMFlowChangeItem`、`commitPMFlowChangeSet`;`PMFlowExecutionSummary.releaseGate` 是 ReleaseGrowth 真实发布门禁前端合同,`execution-summary.releaseSignals` 是 Release/Growth 发布后信号与下一轮 ChangeSet draft 状态前端合同;Project Launch 创建链参考 `frontend/src/api/pmflowLaunch.ts`。 +- Legacy AI Steward route:`/ai-steward` 不再作为产品页;参考 `frontend/src/router/index.tsx` 的 `getLegacyAIStewardRedirectPath`,`tab=create` 迁到 `/project-launch`,`tab=engine / tab=feedback-center` 迁到 `/ai-config`,其它 legacy tab 迁到 `/space`;`frontend/src/pages/ai-steward/` 仅保留迁移占位和已断链的历史 tab 壳,不作为新功能入口。 - AI Steward 问答浮窗与上下文显性化组件:参考 `frontend/src/components/ai-steward/FloatingQAChat.tsx`、`frontend/src/components/ai-steward/QAConversationStatusBar.tsx`、`frontend/src/components/ai-steward/QAResponseMetaCard.tsx` - AI Steward QA contract 前端类型(含 `analysis_packet`):参考 `frontend/src/api/aiSteward.ts` - AI Steward handoff 元卡片回归:参考 `frontend/src/components/ai-steward/FloatingQAChat.test.tsx`、`frontend/src/components/ai-steward/QAResponseMetaCard.test.tsx` - AI Steward / QA session 前端 contract:参考 `frontend/src/api/aiSteward.ts` - PMFlow OS 前端 MVP 产品域:参考 `frontend/src/features/pmflow-os/` -- 个人空间首页 / 空间层入口前端产品域:参考 `frontend/src/features/personal-space/`、`frontend/src/api/space.ts` 与薄入口 `frontend/src/pages/PersonalSpacePage.tsx`;本地预览路由入口为 `/space`,首屏以 `nextActionQueue` 展示“我的下一步”,并保留继续推进、待我确认、发布关注、发布后反馈关注、下一轮迭代草稿关注、执行中 / AI 正在推进、暂存想法、草案箱与最近项目明细;点击项目主动作进入 `/pmflow-os?projectId=`,待确认项进入 `/change-review?projectId=&changeSetId=`,review attention 待审项进入 `/change-review?projectId=&changeSetId=`,reviewer 缺失 warning 进入已有 `/team-layer?projectId=`,执行中项进入 `/execution-bridge?projectId=`,发布关注和发布后反馈关注项进入 `/release-growth?projectId=`,已生成 ChangeSet draft 的下一轮建议进入 `/change-review?projectId=&changeSetId=`;Space 只读聚合项目摘要、PMFlow OS `truth-state.reviewerRouting / truth-state.releases / execution-summary.releaseGate / execution-summary.releaseSignals`,reviewerRouting、发布验证、发布后信号或 iteration draft 缺失时显示明确空态,不虚构 reviewer、审查意见、待审 ChangeSet、验证、反馈、收入、ROI、合规、iteration recommendation 或 ChangeSet draft,不直接写 Project Memory、ChangeSet 状态、PMFlowApproval、execution event、ReleaseValidationRun、FeedbackIngest、RevenueEvent 或 ComplianceCheckRun;暂存想法后端只读聚合尚未接入时必须显示 empty/warning,不得用 mock 数据冒充真实内容;空间层共享左侧栏、顶部条与空间用量组件在 `frontend/src/features/personal-space/components/SpaceChrome.tsx`,`/project-launch` 桌面首次新建壳也复用该组件 -- 新项目启动台 / 首次进入页前端产品域:参考 `frontend/src/features/project-launch/` 与薄入口 `frontend/src/pages/ProjectLaunchPage.tsx`;真实模式默认通过 `frontend/src/api/pmflowLaunch.ts` 调用后端 draft/confirm API,只有显式 `?demo=1` 或 `?dataSource=demo` 才启用 `mockData.ts` 本地演示状态机;本地预览路由入口为 `/project-launch` +- 个人空间首页 / 空间层入口前端产品域:参考 `frontend/src/features/personal-space/`、`frontend/src/api/space.ts` 与薄入口 `frontend/src/pages/PersonalSpacePage.tsx`;登录后路由入口为 `/space`,未登录由 `frontend/src/auth/RouteBoundary.tsx` 跳转 `/login?redirect=/space`;首屏以 `nextActionQueue` 展示“我的下一步”,并保留执行准备度、继续推进、待我确认、发布关注、发布后反馈关注、下一轮迭代草稿关注、执行中 / AI 正在推进、暂存想法、草案箱、缺项目归属补全与最近项目明细;点击项目主动作进入 `/pmflow-os?projectId=`,待确认项进入 `/change-review?projectId=&changeSetId=`,review attention 待审项进入 `/change-review?projectId=&changeSetId=`,reviewer 缺失 warning 进入已有 `/team-layer?projectId=`,执行中与执行准备度项进入 `/execution-bridge?projectId=`,发布关注和发布后反馈关注项进入 `/release-growth?projectId=`;Space 只读聚合项目摘要、PMFlow OS `truth-state.reviewerRouting / truth-state.releases / execution-summary / execution-capabilities`,并兼容读取旧 AI PMO `waiting_manual / draft_pending_confirm` 工作项;`missing_project_scope` 仅在 Space 选择项目后复用 `POST /ai-pmo/work-items/{id}/decision`,不新增旁路接口,不直接写项目真相层。缺数据时显示明确空态或 warning,不虚构 reviewer、审查意见、待审 ChangeSet、执行能力、工具连接、验证、反馈、收入、ROI、合规、iteration recommendation 或 ChangeSet draft,不直接写 Project Memory、ChangeSet 状态、PMFlowApproval、execution event、ReleaseValidationRun、FeedbackIngest、RevenueEvent 或 ComplianceCheckRun;暂存想法后端只读聚合尚未接入时必须显示 empty/warning,不得用 mock 数据冒充真实内容;空间层共享左侧栏、顶部条与空间用量组件在 `frontend/src/features/personal-space/components/SpaceChrome.tsx`,`/project-launch` 桌面首次新建壳也复用该组件 +- 新项目启动台 / 首次进入页前端产品域:参考 `frontend/src/features/project-launch/` 与薄入口 `frontend/src/pages/ProjectLaunchPage.tsx`;真实模式登录后路由入口为 `/project-launch`,默认通过 `frontend/src/api/pmflowLaunch.ts` 调用后端 draft/confirm API;新建项目唯一写链路是 `POST /pmflow-launch/drafts -> POST /pmflow-launch/drafts/{draft_id}/confirm`,旧 `POST /ai-steward/project-from-text` 只返回 410 迁移错误;只有显式 `?demo=1` 或 `?dataSource=demo` 才允许匿名并启用 `mockData.ts` 本地演示状态机;401/无 token 通过 `frontend/src/lib/apiError.ts` 归一为 `auth_required/session_expired`,通过 `frontend/src/lib/pendingIntent.ts` 保留输入,用户可见失败态不得展示 `Request failed with status code 401` 或把 401 归为网络异常 +- 前端身份边界与产品化错误:参考 `frontend/src/auth/RouteBoundary.tsx`、`frontend/src/lib/apiError.ts`、`frontend/src/lib/pendingIntent.ts`;`/space`、真实 `/project-launch`、`/me`、主链 standalone 与管理/治理入口均在前端边界收口,管理/治理入口使用 OWNER/REVIEWER manager 判断,后端 JWT/RBAC 仍是最终校验;pending intent 第一批只恢复 project launch 输入,不自动重放写操作 - PMFlow OS 共享 truth store 与主链状态动作:参考 `frontend/src/features/pmflow-os/store.tsx`;当前支持 `?projectId=` 走 React Query 读取真实 `truth-state` 与 `execution-summary`,真实模式下 thread message、`proposed_items` extraction、ChangeItem `accept/edit/defer`、ChangeSet commit、review decision、Execution Handoff create/consume、execution progress writeback、release validation run create、release feedback ingest、单条 iteration recommendation -> ChangeSet draft 与同组 iteration recommendation 批量 -> ChangeSet draft 均调后端并 refetch,不本地 promote 入 Project Memory、不伪造 approval、execution event、发布验证记录、发布后信号或下一轮 draft;`?demo=1` 才启用本地 mock 演示状态机和本地动作;项目内 AI 结构化闭环状态机为 `command_idle / ai_extracting / extraction_ready / review_open / writeback_success` - Account Settings / `/me` 前端产品域与 P1-P8 偏好合同:参考 `frontend/src/features/account-settings/AccountSettingsPanel.tsx`、`frontend/src/features/pmflow-os/pages/AccountPage.tsx`、`frontend/src/api/accountSettings.ts`、`backend/app/api/account_settings_api/`、`backend/app/schemas/account_settings.py` 与 `backend/app/services/account_settings_service.py`;当前覆盖个人资料、默认语言、AI 回答深度、证据展示偏好、待确认、执行回写、发布门禁和失败提醒通知偏好,移动端使用分组列表与 bottom sheet,账户退出仍复用 `AccountControls.tsx`,不改 OAuth/JWT/auth core;P1 后端合同提供 `GET /account-settings/me` 与 `PUT /account-settings/me`,已由 `backend/app/api/router.py` 挂入核心路由组,按当前 `tenant_id + user_id` 保存账号偏好,前端有 token 时优先账号同步,后端不可用时明确回落 `仅本机暂存 / 保存失败`;P2 在 `frontend/src/api/accountSettings.ts` 提供 `shouldShowAccountNotification` 与 `getAccountNotificationConsumption`,把通知偏好映射为页面内提醒消费模型;P3 新增 `getReleaseGateNotificationPreference(settings, event)` 供 Release/Growth 后续消费:`release_gate_*` 与 `release_validation_*` 使用 `releaseGate` 偏好,`failed_event` 使用 `failureAlert` 偏好;P5/P6 的 `getPostReleaseNotificationPreference(settings, event)` 供发布后反馈与下一轮待确认消费:`feedback_received / revenue_signal / compliance_blocked` 使用 `releaseGate` 偏好,`iteration_recommendation / next_change_review` 使用 `pendingConfirmation` 偏好并落到 `change_review` surface;P7 将每类通知偏好扩展为 `reminderIntensity=silent|normal|prominent` 与 `evidenceDisplay=compact|expanded`,并新增 `getAccountReminderConsumption(settings, event)` 覆盖 `change_review_high_risk / iteration_draft_ready / release_blocked / execution_stalled`,稳定返回 `notificationTransport=in_app_only / evidencePolicy=preserve_business_evidence / auditPolicy=preserve_audit_state`;P8 新增 `getReviewDecisionNotificationPreference(settings, event)` 覆盖 `review_requested / review_approved / review_changes_requested / review_rejected / high_risk_approval_required`,统一使用 `pendingConfirmation` 偏好和 `change_review` surface,返回 `reviewerRoutingPolicy=preserve_reviewer_routing / approvalPolicy=preserve_approval_state`,偏好关闭只静默页面内提醒,不隐藏 reviewerRouting、approval、evidence 或 audit state;ReleaseGrowthPage 当前消费 `releaseGate` 与 `failureAlert` 两个偏好,只静默强调型提醒,不隐藏 release evidence、阻塞原因、审计状态、validation run、releaseSignals 或下一轮 ChangeSet draft;这些 helper 只控制本地 UI 提醒消费合同,不代表真实邮件、桌面推送或系统通知中心已接入 -- PMFlow OS 产品页入口:参考 `frontend/src/features/pmflow-os/pages/CommandCenterPage.tsx`、`StructuredExtractionPage.tsx`、`ChangeReviewPage.tsx`、`MemoryWritebackResultPage.tsx`、`WorkGraphPage.tsx`、`ExecutionBridgePage.tsx`、`ReleaseGrowthPage.tsx`、`TeamLayerPage.tsx`、`ProjectMemoryPage.tsx`、`AccountPage.tsx`;`ChangeReviewPage.tsx` 真实模式可对当前 ChangeSet 请求审查、通过、要求修改或拒绝,展示审查状态、建议 reviewer、审查意见和 high-risk approval gate,但不自动 commit;`TeamLayerPage.tsx` 真实模式读取 `state.reviewerRouting` 展示 ready/empty/warning、待审 ChangeSet、来源(下一轮迭代 / AI 提取 / 手动)、风险等级、建议审查人或 fallback role、审查原因、待审 item 数、证据数、审查状态、审查意见、请求/决策时间,并跳转 `/change-review?projectId=&changeSetId=`;`ReleaseGrowthPage.tsx` 真实模式展示 `execution-summary.releaseGate`、execution evidence、阻塞原因、下一步动作、`latestValidationRun` 和 `releaseSignals`,缺证据或缺发布后信号时显示 empty/warning,不展示 mock 发布结果;页面可调用真实 API 创建 release validation run、摄取 release feedback,从真实 recommendation 单条或同组批量生成下一轮待确认 ChangeSet draft,并解释 `iteration_recommendation_group` evidence 的组、反馈、风险、影响和审查优先级;生成后仍进入 Change Review accept/edit/defer,不自动 commit、不写 Project Memory、不执行部署命令、不接外部支付;同时消费 Account notification preference helper,`releaseGate` 关闭时静默发布门禁强调提醒,`failureAlert` 关闭时静默失败强调提醒,所有真实证据和审计状态仍可见;静态高保真发布/增长数据仅保留在显式 demo 模式 +- PMFlow OS 产品页入口:参考 `frontend/src/features/pmflow-os/pages/CommandCenterPage.tsx`、`StructuredExtractionPage.tsx`、`ChangeReviewPage.tsx`、`MemoryWritebackResultPage.tsx`、`WorkGraphPage.tsx`、`ExecutionBridgePage.tsx`、`ReleaseGrowthPage.tsx`、`TeamLayerPage.tsx`、`ProjectMemoryPage.tsx`、`AccountPage.tsx`;`ChangeReviewPage.tsx` 真实模式可对当前 ChangeSet 请求审查、通过、要求修改或拒绝,展示审查状态、建议 reviewer、审查意见和 high-risk approval gate,但不自动 commit;`TeamLayerPage.tsx` 真实模式读取 `state.reviewerRouting` 与 `execution-capabilities`,展示待审 ChangeSet 审查路由、执行能力与容量信号、个人执行准备度、工具准备度和 Team Feed,只给建议与跳转,不自动分派、不自动审查、不写 ChangeSet;`ReleaseGrowthPage.tsx` 真实模式展示 `execution-summary.releaseGate`、execution evidence、阻塞原因、下一步动作、`latestValidationRun` 和 `releaseSignals`,缺证据或缺发布后信号时显示 empty/warning,不展示 mock 发布结果;页面可调用真实 API 创建 release validation run、摄取 release feedback,从真实 recommendation 单条或同组批量生成下一轮待确认 ChangeSet draft;生成后仍进入 Change Review accept/edit/defer,不自动 commit、不写 Project Memory、不执行部署命令、不接外部支付;所有真实证据和审计状态仍可见,静态高保真发布/增长数据仅保留在显式 demo 模式 - Team Feed 只读动态流:`TeamLayerPage.tsx` 复用 `frontend/src/features/pmflow-os/components/TeamFeedPanel.tsx` 展示 `state.feed`,由后端 `ProjectTruthState.feed` 聚合 review request、review decision、ChangeSet 状态、execution progress 与 release gate 事件;前端只做空态、摘要和跳转展示,不写审批、不写 ChangeSet、不写通知,也不在真实模式 fallback 到 mock 动态。 - PMFlow OS 行为组件:参考 `frontend/src/features/pmflow-os/components/ProjectStructuredLoop.tsx`、`ProjectStageRail.tsx`、`ThreadComposer.tsx`、`ExtractionRail.tsx`、`ChangeGroupPanel.tsx`、`ObjectGraphSection.tsx`、`ContextPacketPanel.tsx`、`ExecutionTimeline.tsx`、`ReleaseGatePanel.tsx`、`TeamFeedPanel.tsx`、`ChangeReviewGroup.tsx`、`WorkbenchPanel.tsx`、`ExecutionBridgeCards.tsx`、`PMFlowOsFrame.tsx`、`AccountControls.tsx`、`FeedbackPrimitives.tsx`、`MobileBottomNav.tsx`;其中 `ProjectStructuredLoop.tsx` 承接 `StatusLabel / EvidenceSnippet / ObjectChangeCard / ProjectConversationPanel / StructuredExtractionPanel / ProjectAICopilotPanel`,`PMFlowOsFrame.tsx` 承接项目内面包屑和项目切换器,`AccountControls.tsx` 承接桌面头像菜单、退出登录确认与移动端 bottom sheet,`FeedbackPrimitives.tsx` 承接 `PageBanner / InlineNotice / HumanDecisionBar / ConfirmDialog / MobileBottomSheet`,`MobileBottomNav.tsx` 承接正式应用壳移动端固定底部导航 - PMFlow OS 原型状态回归测试:参考 `frontend/src/features/pmflow-os/store.test.ts`;真实 API 动作回归参考 `frontend/src/features/pmflow-os/store.api.test.tsx` -- Space 工作面回归测试:参考 `frontend/src/features/personal-space/PersonalSpacePage.test.tsx`,覆盖空态、导航只读约束、发布/反馈/迭代草案队列,以及 reviewerRouting -> `review_attention` 的 next action 聚合排序与 `/change-review`、`/team-layer` 跳转合同。 +- Space 工作面回归测试:参考 `frontend/src/features/personal-space/PersonalSpacePage.test.tsx`,覆盖空态、导航只读约束、执行准备度、发布/反馈/迭代草案队列,以及 reviewerRouting -> `review_attention` 的 next action 聚合排序与 `/change-review`、`/team-layer` 跳转合同。 - Project Launch 真实/演示分支回归测试:参考 `frontend/src/features/project-launch/useProjectLaunchState.test.ts` - Account Settings 前端合同回归测试:参考 `frontend/src/features/account-settings/accountSettings.test.ts`,覆盖默认偏好、账号同步 fallback、通知偏好消费模型、提醒强度/证据展示合同、Release/Growth 通知消费合同、review decision / reviewer request / high-risk approval 偏好合同、失败事件映射和缺字段 fallback。 -- 网页问答显式锚点与 AI Steward URL 构造:参考 `frontend/src/components/ai-steward/qaPageAnchor.ts` -- Inbox 页面入口:参考 `frontend/src/pages/inbox/` -- Inbox `plan_mode` 展开卡片与映射:参考 `frontend/src/components/inbox/InboxListItem.tsx`、`frontend/src/lib/inbox.ts`、`frontend/src/api/aiPmo.ts` +- 网页问答显式锚点与 Space AI 历史 URL 构造:参考 `frontend/src/components/ai-steward/qaPageAnchor.ts`;新生成的 QA 锚点进入 `/space?view=ai-history`,函数仍能解析 legacy `/ai-steward` URL 以兼容历史链接 - 登录页/营销页的共享视觉基线:参考 `frontend/src/components/marketing/MarketingFrame.tsx` 与 `frontend/src/components/marketing/MarketingVisuals.tsx` - 公开登录页入口:参考 `frontend/src/pages/login/LoginPage.tsx`、`frontend/src/components/login/LoginExperience.tsx`、`frontend/src/components/login/LoginWorkflowVisual.tsx`、`frontend/src/components/login/loginScene.ts`、`frontend/src/components/login/loginMath.ts` 与 `frontend/src/router/index.tsx` - 登录页分级验证:参考 `frontend/package.json` 中的 `verify:login-fast / verify:login-responsive / verify:login-full`、`capture:login:matrix`、`frontend/scripts/login-live-check.mjs` 与 `frontend/src/pages/login/LoginPage.test.tsx` - 顶层路由懒加载与统一 fallback:参考 `frontend/src/router/index.tsx` 与 `frontend/src/components/layout/LazyBoundary.tsx` - 顶层前端 Provider 入口:参考 `frontend/src/main.tsx`(`PMFlowOsProvider` + `RouterProvider`) -- AI Steward tab 级懒加载入口:参考 `frontend/src/pages/ai-steward/AIStewardPage.tsx` -- AI Config section 级懒加载入口:参考 `frontend/src/pages/ai-config/AIConfigPage.tsx` -- 项目看板控制面/对话框懒加载入口:参考 `frontend/src/pages/project-board/ProjectBoardPage.tsx` +- AI Steward legacy redirect 入口:参考 `frontend/src/router/index.tsx` 与 `frontend/src/pages/ai-steward/AIStewardPage.tsx`;不再新增 tab 级业务入口 +- AI 运营中心 section 级懒加载入口:参考 `frontend/src/pages/ai-config/AIConfigPage.tsx`;`/ai-config` 技术路由保留,首页按策略 / 连接 / 治理 / 高级分组组织团队级 AI 配置与治理入口 +- 项目索引与旧项目路由兼容:项目索引参考 `frontend/src/pages/projects/ProjectsPage.tsx`;旧 `/projects/:id/board` 只在 `frontend/src/router/index.tsx` 中重定向到 `/pmflow-os?projectId=:id`,旧 `/projects/:id/charter/:charterId` 重定向到 `/project-memory?projectId=:id`;`frontend/src/pages/project-board/` 仅保留为待物理删除的 legacy 代码,不再作为新产品实现参考。 - 用户页对话框懒加载入口:参考 `frontend/src/pages/users/UsersPage.tsx` - 图表二级 chunk 入口:参考 `frontend/src/pages/user-skills/components/UserSkillsRadarPanel.tsx` 与 `frontend/src/pages/workload-detail/components/WorkloadTrendChart.tsx` - Vite chunk 分组与 watch 忽略策略:参考 `frontend/vite.config.ts` - Scheduler Center / Mounted AI Console:参考 `frontend/src/pages/scheduler-center/` - SSTC 工作流组件:参考 `frontend/src/components/sstc/` - Model Routing 页面:参考 `frontend/src/pages/model-routing-config/` +- AI 路由与供应商控制台页面:参考 `frontend/src/pages/ai-routing/`;`TeamAIPolicyPage.tsx` 是负责人策略视图,`AIRoutingConsolePage.tsx` 是管理员诊断与槽位视图,薄入口位于 `frontend/src/pages/TeamAIPolicyPage.tsx`、`frontend/src/pages/AIRoutingConsolePage.tsx` +- AI 路由与供应商前端 API:参考 `frontend/src/api/aiRouting.ts` - MCP 模板接入页:参考 `frontend/src/pages/ai-config/mcp-config/` - Route / Context 治理页:参考 `frontend/src/pages/query-route-governance/` - Semantic facts 治理页:参考 `frontend/src/pages/semantic-facts-board/` - MCP 前端 API:参考 `frontend/src/api/mcp.ts` - Route / Context 治理前端 API:参考 `frontend/src/api/queryRouteGovernance.ts` - Semantic facts 前端 API:参考 `frontend/src/api/semanticFacts.ts` -- AI Config 治理工具入口:参考 `frontend/src/pages/ai-config/constants.ts` 与 `frontend/src/router/index.tsx` +- AI 运营中心治理工具入口:参考 `frontend/src/pages/ai-config/constants.ts` 与 `frontend/src/router/index.tsx`;`GOVERNANCE_TOOL_GROUPS` 是首页分组真源,`GOVERNANCE_TOOLS` 保留给既有测试与入口兼容 ## 4. 模型与配置真源 - 模型配置唯一来源:参考 `backend/app/services/model/model_config.py` diff --git a/docs/architecture/RUNTIME_GUARDRAILS.md b/docs/architecture/RUNTIME_GUARDRAILS.md index e43d16e5..8d760a79 100644 --- a/docs/architecture/RUNTIME_GUARDRAILS.md +++ b/docs/architecture/RUNTIME_GUARDRAILS.md @@ -31,6 +31,8 @@ - 飞书回执发送仅允许 `main.py` 的 drain loop 作为消费者,禁止在业务路径即时派发发送任务。 - 所有写接口必须落审计日志(`operation_logs`),至少包含 `trace_id/interface_path/caller`。 - `created_at_beijing` 为计算属性返回,禁止新增数据库物理列。 +- 前端身份边界必须与真实写接口一致:`/space`、真实 `/project-launch`、`/me`、项目主链 standalone 与管理/治理入口必须登录,管理/治理入口需 OWNER/REVIEWER;`/project-launch?demo=1` 或 `?dataSource=demo` 是唯一匿名启动台演示入口,且只能使用本地 mock/demo,不得调用真实写接口。 +- 用户可见错误必须使用产品语义分类,禁止直接展示 Axios/FastAPI 原始错误(如 `Request failed with status code 401`、`Could not validate credentials`、traceback)。401/无 token 必须表达为登录/会话问题;需要恢复输入时只能保存 pending intent 并由用户重新触发写操作,禁止登录后自动重放真实写请求。 ### 3.2 核心业务零兜底 - 模型失败、超时、空响应、结构化解析失败时,必须显式返回真实失败原因。 @@ -64,13 +66,17 @@ - `fallback_policy` - `execution_mode` - 开发环境启动必须允许显式关闭重型副作用:`STARTUP_MODEL_PROBE_ENABLED`、`FEISHU_LONG_CONNECTION_ENABLED`、`STARTUP_WORLD_MODEL_WARMUP_ENABLED`、`STARTUP_SCHEDULERS_ENABLED`、`STARTUP_BACKGROUND_WORKERS_ENABLED` 是当前唯一允许的 startup 开关;需要压缩热重载时,应优先通过这些环境变量降载,而不是在代码里硬删启动逻辑或把副作用偷偷移到 import side effect。 -- `backend/app/api/router.py` 的 optional route group 只能通过 `API_ROUTE_PROFILE` 与 `API_OPTIONAL_ROUTE_GROUPS` 控制;默认 `full` 保持现有全量 API,`lean` 只允许作为开发减载配置使用,禁止在运行时代码里基于请求内容或关键词动态裁路由。 +- `backend/app/api/router.py` 的 optional route group 只能通过 `API_ROUTE_PROFILE` 与 `API_OPTIONAL_ROUTE_GROUPS` 控制;默认 `full` 保持现有全量 API,`lean` 只允许作为开发减载配置使用,禁止在运行时代码里基于请求内容或关键词动态裁路由。团队级 AI 运营中心依赖的 `ai_routing` API 与高级路由详情依赖的 `model_routing` API 属于核心路由,必须在 `lean` 下继续加载,避免 `/ai-config/*` 治理页面变成 404。 - 开发预览端口职责必须固定:`5174` 仅保留给源码热更新前端,`docker-compose.yml` 下的静态生产预览前端默认改用 `5175`(`FRONTEND_PROD_PORT` 可覆盖);禁止再让 `5174` 在静态容器和 Vite dev server 之间来回漂移,避免浏览器验证和真实源码脱节。 - 禁止再通过函数名片段、hint 常量或模板词在底层推断 tier、结构化输出或 fallback 策略。 - 所有 LLM 调用禁止静默兜底与 stub 假成功;low 失败时必须优先强制升级到 high 重试并保留低/高两段日志,high 仍失败时才显式报错。 - 用户可见问答链路(`function_name` 含 `answer/reply/qa`)禁止使用 `reasoning_content` 回退,避免思考过程外漏。 - `integrations(type='model_routing')` 的 runtime tier override 不得把 `adaptive_rag_answer_normal` / `adaptive_rag_answer_deep` 这类用户可见 QA 主函数降级到 `low`。 - 模型默认值与 provider/tier 映射以 `backend/.control_plane/pmflow-routing-rules.json` 为唯一配置源。 +- AI 路由与供应商产品化控制台 v1 只做治理聚合、脱敏展示、团队策略保存和只读诊断:团队策略保存到 `integrations(type='ai_team_policy')`,不得在本阶段改写 `llm_service` runtime 路由、`backend/.control_plane/pmflow-routing-rules.json` 或 provider secret。 +- 用户可见层必须使用产品语义表达 AI 模式与状态,例如“快速处理 / 深度分析 / 项目记忆查询 / 外部实时查询 / 待确认执行”。成员视图不得暴露 `low/high`、provider、model、`function_name`、`api_env`、raw routing meta 或明文 key;负责人视图只看业务场景策略;管理员控制台可在高级详情中查看函数级路线,但默认隐藏 `function_name`。 +- AI 路由诊断允许提示“环境变量 Key 可能覆盖租户 Key”等风险,但只能返回 masked key 和中文风险说明;禁止返回 prompt、raw error、raw provider payload、raw env value 或明文 token。 +- `/ai-config` 用户可见定位为团队级 AI 运营中心,不是项目级管理后台。AI 供应商、团队 AI 策略和路由诊断影响当前团队空间内所有项目;项目级页面只能展示 AI 处理模式、来源/可信状态和待确认状态,禁止提供项目级 provider/model/路由覆盖入口。 ### 3.5 Requirement Plan / Draft / Followup - `requirement_plan` 必须是多轮状态机,不得退回单轮模板问答。 @@ -105,7 +111,7 @@ - orphan requirement-plan followup 在显式 `reply/thread` 存在时,必须先尝试从 reply 命中的 chat context 恢复或升格 requirement workspace;只有恢复失败时才允许回“当前没有进行中的 A/B/C 收敛轮次”。 - Feishu 显式 `reply/thread` 到机器人消息时,ingress 与 mounted runtime 必须先通过 `feishu_reply_outbox.sent_message_id -> source_message_id` 归一化 `root_message_id/reply_to_message_id`,并在后续 active-context / requirement-plan / draft 主链里复用同一份 canonical thread context;禁止直接用机器人 `sent_message_id` 新开 chat topic,导致 requirement-plan / draft 线程分叉。 - requirement-plan 的唯一业务真源固定为 `MountedDraftWorkspace.requirement_card`:收敛轮次、已确认字段、`round_option_maps`、`last_round_options`、`source_material` 等业务状态只能从 draft 或结构化 `requirement_plan_context` 恢复;`mounted_topic_workspaces.facts_json` 只允许保留线程索引级最小字段(当前限定为 `title / plan_profile`),`work_items.execution_result` 与 `mounted_conversation_runs.final_reply` 仅作审计/回放,禁止再从 assistant prose、topic facts 或 run 文案反推需求字段。 -- `POST /ai-pmo/process-text` 与 `GET /ai-pmo/work-items` 对 requirement-plan 的前台可见状态必须直接投影 `plan_mode / plan_status / requirement_plan_context_summary`;Inbox 与其它前端入口不得再靠 `reply_template_key`、草稿 prose 或 stage 文案自行猜测当前是否仍在需求模式。 +- `POST /ai-pmo/process-text` 与 `GET /ai-pmo/work-items` 对 requirement-plan 的前台可见状态必须直接投影 `plan_mode / plan_status / requirement_plan_context_summary`;Space legacy adapter 与其它前端入口不得再靠 `reply_template_key`、草稿 prose 或 stage 文案自行猜测当前是否仍在需求模式。独立收件页已退场,旧 `waiting_manual / draft_pending_confirm` 只能作为兼容读模型接入 `/space` 或回到主流程。 - requirement-plan 的运行时路由命名也必须单独收口:收敛轮次统一写 `requirement_plan.collect`,草稿确认前的结构化草稿统一写 `requirement_plan.draft`;`query.answer` 只保留给通用问答,禁止在 requirement-plan 主链里继续借用旧 query route 记账。 - 冷启动 `WorkflowSemanticPlanner` / `pmflow_semantic_context_decision` 若因 provider 异常、空结构化结果或网络抖动失败,`_resolve_intent` 不得直接把首轮线程打成 `error_template`;必须集中降级为带 `semantic_planner_degraded=true` 审计标记的中性 `query.answer` 场景。后续运行时必须优先重试 semantic backfill;若命中显式 active requirement context / reply anchor,仍留在 requirement-plan 主链,否则继续按普通 QA 处理,禁止无锚点把事实问句硬降级成 requirement consultation。 - 飞书历史队列协议已停用:卡片动作与文本 followup 只允许走 `work_item_id + work_item.resume/draft_confirm` 主链。 @@ -186,8 +192,10 @@ ### 3.7 Signal / Project / SSTC / Briefing - `SignalSourceRegistry` 仅作为信号源接入/幂等内部表;用户可见的项目“信号源/信号”统一落到正式 `Signal`。 -- `create_project_from_natural_language` 必须支持透传 `requested_member_user_ids`,草稿确认时必须把推荐负责人并入项目创建事务。 -- AI Steward `project-from-text` 归一化阶段必须优先走人员能力图谱,禁止绕过能力图谱直接拍脑袋分配。 +- `create_project_from_natural_language` 仅可作为 Launch draft enrichment / 内部迁移能力复用,必须支持透传 `requested_member_user_ids`;草稿确认时必须把推荐负责人并入项目创建事务。 +- `POST /ai-steward/project-from-text` 已下线为 410 迁移响应;任何新建项目不得绕过 `POST /pmflow-launch/drafts -> POST /pmflow-launch/drafts/{draft_id}/confirm`,不得直接从自然语言写正式 `Project / Task / Signal`。 +- AI 任务分配与执行建议必须优先使用 Execution Capability Graph:先判断 `ai_direct / ai_with_user_acceptance / ai_with_technical_review / needs_human_judgement / blocked`,再给执行或审查建议;禁止仅凭静态技能标签、编程语言标签或旧团队负载百分比硬派任务。缺 repo、执行包、测试命令、工具连接、回写证据或 reviewer 时必须返回 `blocked` / `needs_human_judgement` 或明确 warning,禁止冒充可执行或已分配成功。 +- AI 分配和容量判断必须使用 Adaptive Human Capacity:每日 `suggestedBaseline` 必须由当前任务池动态生成,禁止固定深度判断/审查/验收/协调比例;总容量用有效时间判断硬上限,类型占比只用于疲劳和质量风险,不得作为固定槽位限制。旧 `UserWorkload.workload_ratio` 只可作为历史成本参考,不能直接判定可分配。负责人 override 必须先模拟、再应用,且必须包含原因、审计和过期时间;加班只增加时间预算,不清除疲劳风险、高风险密度或证据不足 warning。 - AI PMO `signal.create` 在用户显式要求“先分析再入库 / 自行分析”时,若批量结构化分析失败,必须返回失败安全态。 - SSTC 宪章 AI 预填充必须输出并持久化 `field_status/field_evidence/readiness/derived_artifacts`;总确认前必须校验 `readiness`。 - SSTC 发布验证清单必须优先消费 `derived_artifacts.verification_metrics`,禁止回退默认验收项。 @@ -211,7 +219,7 @@ - 真实飞书网页 E2E 依赖本机已登录的 Chrome/Feishu 会话。 - 真实飞书网页 E2E 只负责浏览器发消息与页面诊断,结果校验统一走数据库探针。 - 显式命名的新项目请求不得仅因正文出现历史项目标题就自动绑定已有 `project_id`。 -- 项目创建返回的 `signal_sources` 仅作为 AI Steward 创建结果区的内联展示;项目看板与 SSTC 信号阶段统一只读正式 `Signal`。 +- 项目创建返回的 `signal_sources` 仅作为历史创建结果区的兼容展示字段;项目看板与 SSTC 信号阶段统一只读正式 `Signal`。 - 老板晨报首版的 `external_metrics` 只返回占位区块,不接 CRM / BI / 收入系统。 - `daily-briefing-v2` 与老板晨报当前只做同会话内 `snapshot/live` 同步,不引入 WebSocket 推送。 - 飞书长连接 websocket 在本地/CI 环境默认使用 `certifi` CA bundle 作为信任链。 diff --git a/frontend/src/api/aiRouting.ts b/frontend/src/api/aiRouting.ts new file mode 100644 index 00000000..16465425 --- /dev/null +++ b/frontend/src/api/aiRouting.ts @@ -0,0 +1,166 @@ +import { api } from "@/lib/api"; + +type ApiEnvelope = { code: number; message: string; data: T }; + +function unwrap(raw: ApiEnvelope | T): T { + if (raw && typeof raw === "object" && "data" in (raw as Record)) { + return (raw as ApiEnvelope).data; + } + return raw as T; +} + +export type AIRoutingStrategy = "quality_first" | "balanced" | "speed_first"; +export type AIRoutingSeverity = "ok" | "warning" | "critical"; + +export interface AIRoutingSlot { + tier: "low" | "high"; + label: string; + provider: string; + provider_label: string; + model: string; + base_url: string; + configured: boolean; + config_source: string; + api_key_masked?: string; +} + +export interface AIRoutingMetricWindow { + total_calls: number; + failed_calls: number; + failure_rate: number; + upgraded_calls: number; + upgrade_rate: number; + structured_failed_calls: number; + estimated_cost: number; + average_latency_ms?: number | null; +} + +export interface AIRoutingDiagnostic { + code: string; + severity: AIRoutingSeverity; + title: string; + message: string; +} + +export interface AIRoutingOverview { + config_version: string; + config_updated_at: string; + source_of_truth: string; + slots: AIRoutingSlot[]; + metrics: { + last_24h: AIRoutingMetricWindow; + last_7d: AIRoutingMetricWindow; + }; + diagnostics: AIRoutingDiagnostic[]; + latency_status: string; +} + +export interface AITeamPolicyScene { + key: string; + label: string; + policy: string; + mode: string; + human_confirmation: boolean; + protected: boolean; +} + +export interface AITeamPolicy { + strategy: AIRoutingStrategy; + strategy_label: string; + scenes: AITeamPolicyScene[]; + automation_boundary: string; + budget_guard: { + enabled: boolean; + monthly_budget_cny?: number | null; + alert_threshold_ratio: number; + }; + quality_targets: string[]; + updated_at?: string | null; + updated_by?: number | null; +} + +export interface AIRoutingProvider { + id: string; + name: string; + provider: string; + provider_label: string; + model: string; + base_url: string; + api_key_masked: string; + active: boolean; + status_label: string; + updated_at?: string | null; +} + +export interface AIRoutingSceneFunction { + function_name: string; + display_name: string; + tier: "low" | "high"; + slot_label: string; + source: "default" | "override"; + rule_name?: string | null; +} + +export interface AIRoutingSceneRoute { + scene_key: string; + label: string; + summary: string; + primary_slot_label: string; + functions: AIRoutingSceneFunction[]; +} + +export interface AIRoutingRecentFailure { + function_label: string; + function_name: string; + status_label: string; + planned_slot: string; + executed_slot: string; + created_at?: string | null; +} + +export interface AIRoutingAdminConsole { + overview: AIRoutingOverview; + providers: AIRoutingProvider[]; + scene_routes: AIRoutingSceneRoute[]; + protected_functions: string[]; + recent_failures: AIRoutingRecentFailure[]; + diagnostics: AIRoutingDiagnostic[]; + audit_hint: string; +} + +export interface AIRoutingDiagnosticsRun { + checked_at: string; + items: AIRoutingDiagnostic[]; + summary: { + critical: number; + warning: number; + ok: number; + }; +} + +export const aiRoutingApi = { + async getOverview(): Promise { + const { data } = await api.get>("/ai-routing/overview"); + return unwrap(data); + }, + + async getTeamPolicy(): Promise { + const { data } = await api.get>("/ai-routing/team-policy"); + return unwrap(data); + }, + + async updateTeamPolicy(payload: Pick): Promise { + const { data } = await api.put>("/ai-routing/team-policy", payload); + return unwrap(data); + }, + + async getAdminConsole(): Promise { + const { data } = await api.get>("/ai-routing/admin-console"); + return unwrap(data); + }, + + async runDiagnostics(): Promise { + const { data } = await api.post>("/ai-routing/diagnostics/run", {}); + return unwrap(data); + }, +}; diff --git a/frontend/src/api/aiSteward.ts b/frontend/src/api/aiSteward.ts index a7444886..c3ed4c7e 100644 --- a/frontend/src/api/aiSteward.ts +++ b/frontend/src/api/aiSteward.ts @@ -9,66 +9,6 @@ function unwrap(raw: ApiEnvelope | T): T { return raw as T; } -export interface ProjectFromTextRequest { - input_text: string; -} - -export interface ProjectFromTextRequirementItem { - section: string; - title: string; - description: string; - priority?: string; - source_kind?: string; - source_name?: string; - source_identifier?: string; -} - -export interface ProjectFromTextSignalSource { - registry_id: number | string; - id?: number | string; - project_id?: number | string; - signal_id?: number | string | null; - source_kind: string; - source_name: string; - source_identifier: string; - display_source_kind?: string | null; - technical_identifier?: string | null; - description?: string | null; - requested_via?: string; - status?: string; - onboarding_status?: string; - section?: string; - title?: string; - priority?: string; - origin?: string; - raw_context?: Record | null; - config?: Record | null; -} - -export interface ProjectFromTextResponse { - project: { id: string; name: string; title: string; description: string; status: string }; - signal: { id: string; content: string; impact_score: number }; - charter: { id: string; title: string; content: string | Record | null }; - tasks: Array<{ id: string; title: string; type: string; estimated_hours: number; priority: string }>; - risks: Array<{ type: string; description: string; severity: string }>; - signal_sources: ProjectFromTextSignalSource[]; - analysis: { - project_name: string; - project_title?: string; - tech_stack: string[]; - timeline: string; - primary_role: string; - target_audience?: string; - domain_id?: number; - key_features?: string[]; - competitors?: string[]; - success_metrics?: string[]; - core_pain_points?: string[]; - mvp_features?: string[]; - requirement_items?: ProjectFromTextRequirementItem[]; - }; -} - export interface SignalSourceRepairRequest { project_id: number; registry_ids: number[]; @@ -700,11 +640,4 @@ export const aiStewardApi = { }); return unwrap(raw); }, - async createProjectFromText(body: ProjectFromTextRequest): Promise { - const raw = await apiRequest>("/ai-steward/project-from-text", { - method: "POST", - body: JSON.stringify(body), - }); - return unwrap(raw); - }, }; diff --git a/frontend/src/api/pmflowOs.ts b/frontend/src/api/pmflowOs.ts index a3f0277b..5fc7167f 100644 --- a/frontend/src/api/pmflowOs.ts +++ b/frontend/src/api/pmflowOs.ts @@ -1,6 +1,11 @@ import type { ChangeItem, + PMFlowCapacityOverridePayload, + PMFlowCapacityOverrideRecord, + PMFlowCapacityOverrideSimulation, + PMFlowDailyCapacityPlan, PMFlowExecutionContext, + PMFlowExecutionCapabilitySnapshot, PMFlowReviewDecisionAction, PMFlowRiskLevel, ProjectTruthState, @@ -28,7 +33,12 @@ function extractErrorMessage(payload: unknown, fallback: string): string { function storedAuthToken(): string | null { if (typeof window === "undefined") return null; - return window.localStorage.getItem("auth_token") || window.localStorage.getItem("token") || window.localStorage.getItem("access_token"); + return ( + window.localStorage.getItem("pmflow_token") || + window.localStorage.getItem("auth_token") || + window.localStorage.getItem("token") || + window.localStorage.getItem("access_token") + ); } async function request(endpoint: string, init: { method: "GET" | "POST"; body?: unknown }): Promise { @@ -446,6 +456,38 @@ export async function getPMFlowExecutionSummary(projectId: string): Promise(`/pmflow-os/projects/${projectId}/execution-summary`); } +export async function getPMFlowExecutionCapabilities(projectId: string): Promise { + return get(`/pmflow-os/projects/${projectId}/execution-capabilities`); +} + +export async function getPMFlowCapacityPlan(projectId: string, date?: string): Promise { + const params = new URLSearchParams(); + if (date) params.set("date", date); + const suffix = params.toString() ? `?${params.toString()}` : ""; + return get(`/pmflow-os/projects/${projectId}/capacity-plan${suffix}`); +} + +export async function simulatePMFlowCapacityOverride( + projectId: string, + payload: PMFlowCapacityOverridePayload +): Promise { + return post(`/pmflow-os/projects/${projectId}/capacity-overrides/simulate`, payload); +} + +export async function createPMFlowCapacityOverride( + projectId: string, + payload: PMFlowCapacityOverridePayload +): Promise { + return post(`/pmflow-os/projects/${projectId}/capacity-overrides`, payload); +} + +export async function getPMFlowCapacityOverrides(projectId: string, date?: string): Promise { + const params = new URLSearchParams(); + if (date) params.set("date", date); + const suffix = params.toString() ? `?${params.toString()}` : ""; + return get(`/pmflow-os/projects/${projectId}/capacity-overrides${suffix}`); +} + export async function createPMFlowExecutionHandoff( projectId: string, payload: PMFlowExecutionHandoffRequest diff --git a/frontend/src/api/space.ts b/frontend/src/api/space.ts index 28bb2f26..618876f3 100644 --- a/frontend/src/api/space.ts +++ b/frontend/src/api/space.ts @@ -1,11 +1,19 @@ -import { getPMFlowExecutionSummary, getPMFlowTruthState, type PMFlowExecutionSummary } from "./pmflowOs"; +import { + getPMFlowExecutionCapabilities, + getPMFlowExecutionSummary, + getPMFlowTruthState, + type PMFlowExecutionSummary, +} from "./pmflowOs"; +import { aiPmoApi, type AIPMOWorkItem } from "./aiPmo"; import { projectsApi, type ProjectSummary } from "./projects"; import type { ChangeItem, MetricSignal, PMFlowExecutionStatus, PMFlowObjectStatus, + PMFlowMemberCapacityPlan, PMFlowReviewerRoutingItem, + PMFlowExecutionCapabilitySnapshot, Release, ProjectTruthState, } from "@/features/pmflow-os/types"; @@ -94,13 +102,50 @@ export interface SpaceIterationDraftAttentionItem { href: string; } +export interface SpaceExecutionReadinessItem { + id: string; + projectId: string; + projectTitle: string; + status: "ready" | "partial" | "blocked" | "unknown"; + title: string; + summary: string; + connectedTools: string[]; + waitingForUser: { + pendingConfirmations: number; + pendingReviews: number; + waitingAcceptance: number; + }; + blockers: string[]; + recommendedNextStep: string; + href: string; +} + +export interface SpaceHumanCapacityItem { + id: string; + projectId: string; + projectTitle: string; + status: PMFlowMemberCapacityPlan["capacityStatus"]; + effectiveMinutes: number; + plannedMinutes: number; + remainingMinutes: number; + bottleneckType: PMFlowMemberCapacityPlan["bottleneckType"]; + suggestedBaseline: PMFlowMemberCapacityPlan["suggestedBaseline"]; + currentMix: PMFlowMemberCapacityPlan["currentMix"]; + canTakeMore: boolean; + aiReductionPotentialMinutes: number; + explanations: string[]; + recommendations: string[]; + href: string; +} + export type SpaceNextActionKind = | "pending_confirmation" | "review_attention" | "release_blocked" | "execution_attention" | "iteration_draft" - | "feedback_signal"; + | "feedback_signal" + | "legacy_manual_gate"; export interface SpaceNextActionQueueItem { id: string; @@ -137,16 +182,31 @@ export interface SpaceSavedIdea { href: string; } +export interface SpaceLegacyProjectScopeItem { + id: string; + workItemId: number; + title: string; + summary: string; + sourceSystem: string; + sourceMessageText?: string | null; + sourceSender?: string | null; + sourceChannelRef?: string | null; + updatedAt: string; +} + export interface SpaceWorkbench { projects: SpaceWorkbenchProject[]; nextActionQueue: SpaceNextActionQueueItem[]; pendingConfirmations: SpacePendingConfirmation[]; aiProgress: SpaceAIProgressItem[]; + executionReadiness: SpaceExecutionReadinessItem[]; + humanCapacity: SpaceHumanCapacityItem[]; releaseAttention: SpaceReleaseAttentionItem[]; feedbackAttention: SpaceFeedbackAttentionItem[]; iterationDraftAttention: SpaceIterationDraftAttentionItem[]; savedIdeas: SpaceSavedIdea[]; drafts: SpaceDraftItem[]; + legacyProjectScopeItems: SpaceLegacyProjectScopeItem[]; recentProjects: SpaceWorkbenchProject[]; warnings: string[]; generatedAt: string; @@ -156,6 +216,7 @@ interface ProjectSnapshot { project: ProjectSummary; truth: ProjectTruthState | null; execution: PMFlowExecutionSummary | null; + capabilities: PMFlowExecutionCapabilitySnapshot | null; warnings: string[]; } @@ -398,6 +459,88 @@ function deriveAIProgress(snapshot: ProjectSnapshot): SpaceAIProgressItem[] { return items; } +function deriveExecutionReadiness(snapshot: ProjectSnapshot): SpaceExecutionReadinessItem[] { + const { project, truth, capabilities } = snapshot; + if (!capabilities) return []; + const readiness = capabilities.personalReadiness; + const projectId = String(project.id); + const projectTitle = project.title || truth?.project.name || `项目 ${projectId}`; + const tools = readiness.toolReadiness + .filter((item) => item.status === "ready") + .map((item) => item.label) + .slice(0, 4); + const waiting = readiness.waitingForUser || {}; + return [ + { + id: `execution-readiness-${projectId}`, + projectId, + projectTitle, + status: readiness.status, + title: "执行准备度", + summary: readiness.recommendedNextStep, + connectedTools: tools, + waitingForUser: { + pendingConfirmations: Number(waiting.pendingConfirmations || 0), + pendingReviews: Number(waiting.pendingReviews || 0), + waitingAcceptance: Number(waiting.waitingAcceptance || 0), + }, + blockers: readiness.blockers.slice(0, 3), + recommendedNextStep: readiness.recommendedNextStep, + href: readiness.targetHref || executionHref(projectId), + }, + ]; +} + +function storedCurrentUserId(): string | null { + if (typeof window === "undefined") return null; + const direct = window.localStorage.getItem("pmflow_user"); + const persisted = window.localStorage.getItem("pmflow-auth"); + for (const raw of [direct, persisted]) { + if (!raw) continue; + try { + const parsed = JSON.parse(raw) as { id?: unknown; state?: { user?: { id?: unknown } }; user?: { id?: unknown } }; + const id = parsed.id ?? parsed.user?.id ?? parsed.state?.user?.id; + if (id != null) return String(id); + } catch { + continue; + } + } + return null; +} + +function deriveHumanCapacity(snapshot: ProjectSnapshot): SpaceHumanCapacityItem[] { + const { project, truth, capabilities } = snapshot; + const plan = capabilities?.adaptiveCapacitySummary; + if (!plan || plan.memberPlans.length === 0) return []; + const projectId = String(project.id); + const projectTitle = project.title || truth?.project.name || `项目 ${projectId}`; + const currentUserId = storedCurrentUserId(); + const memberPlan = (currentUserId ? plan.memberPlans.find((item) => item.userId === currentUserId) : null) ?? plan.memberPlans[0]; + if (!memberPlan) return []; + const aiReductionPotentialMinutes = plan.humanTouches + .filter((touch) => !touch.assignedUserId || touch.assignedUserId === memberPlan.userId) + .reduce((sum, touch) => sum + Number(touch.aiReductionPotentialMinutes || 0), 0); + return [ + { + id: `human-capacity-${projectId}-${memberPlan.userId}`, + projectId, + projectTitle, + status: memberPlan.capacityStatus, + effectiveMinutes: memberPlan.effectiveMinutes, + plannedMinutes: memberPlan.plannedTouchMinutes.total, + remainingMinutes: memberPlan.remainingMinutes, + bottleneckType: memberPlan.bottleneckType, + suggestedBaseline: memberPlan.suggestedBaseline, + currentMix: memberPlan.currentMix, + canTakeMore: memberPlan.canTakeMore.some((item) => item.status === "recommended"), + aiReductionPotentialMinutes, + explanations: memberPlan.explanations.slice(0, 3), + recommendations: plan.recommendations.slice(0, 2), + href: `/team-layer?projectId=${projectId}`, + }, + ]; +} + function releaseAttentionTitle(status: SpaceReleaseAttentionItem["gateStatus"]): string { if (status === "blocked") return "发布门禁阻塞"; if (status === "ready") return "发布候选"; @@ -728,6 +871,110 @@ function sortByDate dateValue(b) - dateValue(a)); } +function workItemUpdatedAt(item: AIPMOWorkItem): string { + return item.updated_at || item.created_at || new Date().toISOString(); +} + +function isLegacyActionWorkItem(item: AIPMOWorkItem): boolean { + const state = String(item.state || "").toLowerCase(); + return state === "waiting_manual" || state === "draft_pending_confirm"; +} + +function isMissingProjectScopeWorkItem(item: AIPMOWorkItem): boolean { + const state = String(item.state || "").toLowerCase(); + const gateCode = String(item.latest_policy_gate_code || "").toLowerCase(); + return state === "waiting_manual" && gateCode === "missing_project_scope"; +} + +function legacyWorkItemTitle(item: AIPMOWorkItem): string { + const state = String(item.state || "").toLowerCase(); + if (state === "draft_pending_confirm") { + const draftTitle = typeof item.draft_content?.title === "string" ? item.draft_content.title.trim() : ""; + return draftTitle || item.title || "草稿待确认"; + } + if (isMissingProjectScopeWorkItem(item)) return "需要补充项目归属"; + return item.title || "旧工作项待处理"; +} + +function legacyWorkItemSummary(item: AIPMOWorkItem): string { + const draftSummary = typeof item.draft_content?.summary === "string" ? item.draft_content.summary.trim() : ""; + return draftSummary || item.summary || item.ai_plan || item.next_step_text || item.source_message_text || "旧 AI PMO 工作项仍需人工处理。"; +} + +function legacyProjectTitle(item: AIPMOWorkItem, projectsById: Map): string { + const projectId = item.project_id ? String(item.project_id) : ""; + if (item.project_name) return item.project_name; + if (projectId && projectsById.has(projectId)) return projectsById.get(projectId)?.title || `项目 ${projectId}`; + return "未绑定项目"; +} + +function legacyWorkItemHref(item: AIPMOWorkItem): string { + if (item.project_id) { + return `/space?project_id=${encodeURIComponent(String(item.project_id))}&legacyWorkItemId=${encodeURIComponent(String(item.id))}`; + } + return `/space?legacyWorkItemId=${encodeURIComponent(String(item.id))}`; +} + +function deriveLegacyWorkItemActions( + items: AIPMOWorkItem[], + projectsById: Map +): SpaceNextActionQueueItem[] { + return items + .filter(isLegacyActionWorkItem) + .map((item): SpaceNextActionQueueItem => { + const state = String(item.state || "").toLowerCase(); + const missingProject = isMissingProjectScopeWorkItem(item); + const severity: "low" | "medium" | "high" = missingProject ? "high" : state === "draft_pending_confirm" ? "medium" : "medium"; + const projectId = item.project_id ? String(item.project_id) : "unbound"; + return { + id: `next-action-legacy-work-item-${item.id}`, + projectId, + projectTitle: legacyProjectTitle(item, projectsById), + kind: "legacy_manual_gate", + title: legacyWorkItemTitle(item), + summary: legacyWorkItemSummary(item), + categoryLabel: missingProject ? "补项目归属" : state === "draft_pending_confirm" ? "草稿待确认" : "旧工作项", + priorityLabel: nextActionPriorityLabel(severity), + priorityRank: missingProject ? 18 : state === "draft_pending_confirm" ? 28 : 42, + severity, + updatedAt: workItemUpdatedAt(item), + href: legacyWorkItemHref(item), + }; + }); +} + +function deriveLegacyDrafts(items: AIPMOWorkItem[], projectsById: Map): SpaceDraftItem[] { + return items + .filter((item) => String(item.state || "").toLowerCase() === "draft_pending_confirm") + .map((item): SpaceDraftItem => ({ + id: `legacy-draft-work-item-${item.id}`, + projectId: item.project_id ? String(item.project_id) : "unbound", + projectTitle: legacyProjectTitle(item, projectsById), + title: legacyWorkItemTitle(item), + summary: legacyWorkItemSummary(item), + objectType: "旧草稿", + status: "draft", + updatedAt: workItemUpdatedAt(item), + href: legacyWorkItemHref(item), + })); +} + +function deriveLegacyProjectScopeItems(items: AIPMOWorkItem[]): SpaceLegacyProjectScopeItem[] { + return items + .filter(isMissingProjectScopeWorkItem) + .map((item): SpaceLegacyProjectScopeItem => ({ + id: `legacy-project-scope-${item.id}`, + workItemId: item.id, + title: legacyWorkItemTitle(item), + summary: legacyWorkItemSummary(item), + sourceSystem: item.source_system || "PMFlow", + sourceMessageText: item.source_message_text, + sourceSender: item.source_sender, + sourceChannelRef: item.source_channel_ref, + updatedAt: workItemUpdatedAt(item), + })); +} + function nextActionPriorityLabel(severity: "low" | "medium" | "high"): string { if (severity === "high") return "高优先级"; if (severity === "medium") return "中优先级"; @@ -910,6 +1157,7 @@ function buildNextActionQueue({ iterationDraftAttention, reviewAttentionActions, staleExecutionActions, + legacyActions, }: { pendingConfirmations: SpacePendingConfirmation[]; aiProgress: SpaceAIProgressItem[]; @@ -918,6 +1166,7 @@ function buildNextActionQueue({ iterationDraftAttention: SpaceIterationDraftAttentionItem[]; reviewAttentionActions: SpaceNextActionQueueItem[]; staleExecutionActions: SpaceNextActionQueueItem[]; + legacyActions: SpaceNextActionQueueItem[]; }): SpaceNextActionQueueItem[] { const pendingActions = pendingConfirmations.map((item): SpaceNextActionQueueItem => { const severity = item.riskLevel; @@ -1000,7 +1249,7 @@ function buildNextActionQueue({ })); const seen = new Set(); - return [...reviewAttentionActions, ...pendingActions, ...releaseBlockedActions, ...staleExecutionActions, ...activeExecutionActions, ...iterationActions, ...feedbackActions] + return [...reviewAttentionActions, ...legacyActions, ...pendingActions, ...releaseBlockedActions, ...staleExecutionActions, ...activeExecutionActions, ...iterationActions, ...feedbackActions] .filter((item) => { const key = `${item.kind}:${item.href}:${item.title}`; if (seen.has(key)) return false; @@ -1013,31 +1262,50 @@ function buildNextActionQueue({ async function getProjectSnapshot(project: ProjectSummary): Promise { const projectId = String(project.id); - const [truthResult, executionResult] = await Promise.allSettled([ + const [truthResult, executionResult, capabilitiesResult] = await Promise.allSettled([ getPMFlowTruthState(projectId), getPMFlowExecutionSummary(projectId), + getPMFlowExecutionCapabilities(projectId), ]); const warnings: string[] = []; const truth = truthResult.status === "fulfilled" ? truthResult.value : null; const execution = executionResult.status === "fulfilled" ? executionResult.value : null; + const capabilities = capabilitiesResult.status === "fulfilled" ? capabilitiesResult.value : null; if (truthResult.status === "rejected") warnings.push(`${project.title}: 项目真相摘要未同步`); if (executionResult.status === "rejected") warnings.push(`${project.title}: 执行摘要未同步`); + if (capabilitiesResult.status === "rejected") warnings.push(`${project.title}: 执行能力快照未同步`); const projectionWarnings = truth ? (truth as unknown as { projectionWarnings?: string[] }).projectionWarnings ?? [] : []; if (projectionWarnings.length) warnings.push(...projectionWarnings.map((item) => `${project.title}: ${item}`)); if (execution?.warnings.length) warnings.push(...execution.warnings.map((item) => `${project.title}: ${item}`)); - return { project, truth, execution, warnings }; + if (capabilities?.warnings.length) warnings.push(...capabilities.warnings.map((item) => `${project.title}: ${item}`)); + return { project, truth, execution, capabilities, warnings }; } export const spaceApi = { async getWorkbench(): Promise { const summary = await projectsApi.getSummary({ status: "active" }); const projects = sortByDate(summary.projects).slice(0, 8); - const snapshots = await Promise.all(projects.map(getProjectSnapshot)); + const projectsById = new Map(projects.map((project) => [String(project.id), project])); + const [snapshots, legacyWorkItemsResult] = await Promise.all([ + Promise.all(projects.map(getProjectSnapshot)), + aiPmoApi + .listWorkItems({ limit: 80 }) + .then((payload) => ({ payload, warning: null as string | null })) + .catch(() => ({ + payload: { items: [] as AIPMOWorkItem[] }, + warning: "旧 AI PMO 工作项未同步,个人空间只展示新主链待处理项。", + })), + ]); + const legacyWorkItems = legacyWorkItemsResult.payload.items || []; + const legacyActions = sortByDate(deriveLegacyWorkItemActions(legacyWorkItems, projectsById)).slice(0, 8); + const legacyProjectScopeItems = sortByDate(deriveLegacyProjectScopeItems(legacyWorkItems)).slice(0, 6); const workbenchProjects = snapshots.map(deriveProject); const pendingConfirmations = sortByDate(snapshots.flatMap(derivePending)).slice(0, 8); const aiProgress = sortByDate(snapshots.flatMap(deriveAIProgress)).slice(0, 8); + const executionReadiness = snapshots.flatMap(deriveExecutionReadiness).slice(0, 4); + const humanCapacity = snapshots.flatMap(deriveHumanCapacity).slice(0, 4); const releaseAttention = sortByDate(snapshots.flatMap(deriveReleaseAttention)).slice(0, 8); const feedbackAttention = sortByDate(snapshots.flatMap(deriveFeedbackAttention)).slice(0, 8); const iterationDraftAttention = sortByDate(snapshots.flatMap(deriveIterationDraftAttention)).slice(0, 8); @@ -1053,17 +1321,22 @@ export const spaceApi = { iterationDraftAttention, reviewAttentionActions, staleExecutionActions, + legacyActions, }), pendingConfirmations, aiProgress, + executionReadiness, + humanCapacity, releaseAttention, feedbackAttention, iterationDraftAttention, savedIdeas: [], - drafts: sortByDate(snapshots.flatMap(deriveDrafts)).slice(0, 8), + drafts: sortByDate([...snapshots.flatMap(deriveDrafts), ...deriveLegacyDrafts(legacyWorkItems, projectsById)]).slice(0, 8), + legacyProjectScopeItems, recentProjects: sortByDate(workbenchProjects), warnings: [ "暂存想法只读聚合尚未接入后端合同,当前不使用 mock 数据冒充真实内容。", + ...(legacyWorkItemsResult.warning ? [legacyWorkItemsResult.warning] : []), ...snapshots.flatMap((snapshot) => snapshot.warnings), ].slice(0, 8), generatedAt: new Date().toISOString(), diff --git a/frontend/src/auth/RouteBoundary.test.tsx b/frontend/src/auth/RouteBoundary.test.tsx new file mode 100644 index 00000000..964d8367 --- /dev/null +++ b/frontend/src/auth/RouteBoundary.test.tsx @@ -0,0 +1,84 @@ +import { cleanup, screen } from "@testing-library/react"; +import { render } from "@testing-library/react"; +import { afterEach, beforeEach, describe, expect, it } from "vitest"; +import { MemoryRouter, Route, Routes, useLocation } from "react-router-dom"; + +import { AuthBoundary, ManagerBoundary } from "@/auth/RouteBoundary"; +import { useAuthStore } from "@/stores/auth"; + +function LocationProbe() { + const location = useLocation(); + return
{location.pathname + location.search}
; +} + +function BoundaryRoutes({ initialEntry }: { initialEntry: string }) { + return ( + + + } /> + +
space-ok
+ + } + /> + +
launch-ok
+ + } + /> + +
manager-ok
+ + } + /> +
+
+ ); +} + +describe("RouteBoundary", () => { + beforeEach(() => { + localStorage.clear(); + sessionStorage.clear(); + useAuthStore.setState({ token: null, user: null }); + }); + + afterEach(() => { + cleanup(); + localStorage.clear(); + sessionStorage.clear(); + useAuthStore.setState({ token: null, user: null }); + }); + + it("redirects unauthenticated space access to login with redirect", () => { + render(); + expect(screen.getByTestId("location")).toHaveTextContent("/login?redirect=%2Fspace"); + }); + + it("redirects unauthenticated real project launch to login", () => { + render(); + expect(screen.getByTestId("location")).toHaveTextContent("/login?redirect=%2Fproject-launch"); + }); + + it("allows project launch demo without login", () => { + render(); + expect(screen.getByText("launch-ok")).toBeInTheDocument(); + }); + + it("shows permission fallback for non-manager users", () => { + useAuthStore.setState({ token: "token", user: { id: "1", username: "member", role: "BUILDER" } }); + + render(); + + expect(screen.getByText("当前账号没有执行此操作的权限")).toBeInTheDocument(); + }); +}); diff --git a/frontend/src/auth/RouteBoundary.tsx b/frontend/src/auth/RouteBoundary.tsx new file mode 100644 index 00000000..9e9b4eed --- /dev/null +++ b/frontend/src/auth/RouteBoundary.tsx @@ -0,0 +1,71 @@ +import { Navigate, useLocation } from "react-router-dom"; +import { Button } from "@/components/ui/button"; +import { isManagerRole } from "@/lib/rbac"; +import { useAuthStore } from "@/stores/auth"; + +export function isProjectLaunchDemo(search: string): boolean { + const params = new URLSearchParams(search); + return params.get("demo") === "1" || params.get("dataSource") === "demo"; +} + +export function AuthBoundary({ + children, + allowProjectLaunchDemo = false, +}: { + children: JSX.Element; + allowProjectLaunchDemo?: boolean; +}) { + const token = useAuthStore((s) => s.token); + const location = useLocation(); + if (!token && allowProjectLaunchDemo && isProjectLaunchDemo(location.search)) { + return children; + } + if (!token) { + const redirect = location.pathname + location.search; + return ; + } + return children; +} + +export function ManagerBoundary({ children }: { children: JSX.Element }) { + const user = useAuthStore((s) => s.user); + const location = useLocation(); + if (!user?.id) { + const redirect = location.pathname + location.search; + return ; + } + if (!isManagerRole(user.role)) { + return ; + } + return children; +} + +export function PermissionDeniedSurface() { + const logout = useAuthStore((s) => s.logout); + return ( +
+
+
权限不足
+

当前账号没有执行此操作的权限

+

+ 该页面属于团队管理或系统治理能力,需要 OWNER 或 REVIEWER 权限。你可以返回个人空间,或切换到具备权限的账号。 +

+
+ + +
+
+
+ ); +} diff --git a/frontend/src/components/ai-steward/qaPageAnchor.test.ts b/frontend/src/components/ai-steward/qaPageAnchor.test.ts index 6a3595c4..6cc0759b 100644 --- a/frontend/src/components/ai-steward/qaPageAnchor.test.ts +++ b/frontend/src/components/ai-steward/qaPageAnchor.test.ts @@ -12,8 +12,8 @@ describe("resolveQAPageAnchor", () => { }); }); - it("uses query project filter for inbox routes", () => { - expect(resolveQAPageAnchor("/inbox", "?project_id=19")).toMatchObject({ + it("uses query project filter for space routes", () => { + expect(resolveQAPageAnchor("/space", "?project_id=19")).toMatchObject({ projectId: 19, sourceChatId: null, anchorType: "project_filter", @@ -30,12 +30,11 @@ describe("resolveQAPageAnchor", () => { }); }); - it("builds anchored ai steward urls for project entry points", () => { - expect(buildAIStewardQAUrl({ projectId: 17 })).toBe("/ai-steward?tab=qa&project_id=17"); + it("builds anchored space urls for project entry points", () => { + expect(buildAIStewardQAUrl({ projectId: 17 })).toBe("/space?view=ai-history&project_id=17"); expect(buildAIStewardQAUrl({ projectId: "23", sourceChatId: "chat-23" })).toBe( - "/ai-steward?tab=qa&project_id=23&source_chat_id=chat-23", + "/space?view=ai-history&project_id=23&source_chat_id=chat-23", ); - expect(buildAIStewardQAUrl()).toBe("/ai-steward?tab=qa"); + expect(buildAIStewardQAUrl()).toBe("/space?view=ai-history"); }); }); - diff --git a/frontend/src/components/ai-steward/qaPageAnchor.ts b/frontend/src/components/ai-steward/qaPageAnchor.ts index 78530eae..8c28420b 100644 --- a/frontend/src/components/ai-steward/qaPageAnchor.ts +++ b/frontend/src/components/ai-steward/qaPageAnchor.ts @@ -36,7 +36,7 @@ export function buildAIStewardQAUrl(params?: { sourceChatId?: string | null; }): string { const searchParams = new URLSearchParams(); - searchParams.set("tab", "qa"); + searchParams.set("view", "ai-history"); const projectId = parsePositiveInt( typeof params?.projectId === "string" || typeof params?.projectId === "number" @@ -51,7 +51,7 @@ export function buildAIStewardQAUrl(params?: { if (sourceChatId) { searchParams.set("source_chat_id", sourceChatId); } - return `/ai-steward?${searchParams.toString()}`; + return `/space?${searchParams.toString()}`; } export function resolveQAPageAnchor(pathname: string, search: string): QAPageAnchor { @@ -99,4 +99,3 @@ export function resolveQAPageAnchor(pathname: string, search: string): QAPageAnc origin: "none", }; } - diff --git a/frontend/src/components/inbox/InboxActionPanel.tsx b/frontend/src/components/inbox/InboxActionPanel.tsx deleted file mode 100644 index 060c359a..00000000 --- a/frontend/src/components/inbox/InboxActionPanel.tsx +++ /dev/null @@ -1,82 +0,0 @@ -import { CircleAlert } from "lucide-react"; -import { Button } from "@/components/ui/button"; - -export interface InboxProjectOption { - value: string; - label: string; -} - -interface InboxActionPanelProps { - selectedProjectId: string; - projectOptions: InboxProjectOption[]; - loadingProjects?: boolean; - projectsErrorText?: string | null; - pending: boolean; - onSelectProject: (projectId: string) => void; - onCancel: () => void; - onConfirm: () => void; -} - -export default function InboxActionPanel({ - selectedProjectId, - projectOptions, - loadingProjects = false, - projectsErrorText = null, - pending, - onSelectProject, - onCancel, - onConfirm, -}: InboxActionPanelProps) { - const isDisabled = pending || loadingProjects || projectOptions.length === 0; - const canConfirm = !isDisabled && Boolean(selectedProjectId); - - return ( -
-
- - 信号源接入缺少项目归属 -
- -
请选择关联项目:
- - - {projectsErrorText ? ( -

{projectsErrorText}

- ) : null} - -
- - -
-
- ); -} diff --git a/frontend/src/components/inbox/InboxListItem.test.tsx b/frontend/src/components/inbox/InboxListItem.test.tsx deleted file mode 100644 index 3205ff82..00000000 --- a/frontend/src/components/inbox/InboxListItem.test.tsx +++ /dev/null @@ -1,114 +0,0 @@ -import { screen } from "@testing-library/react"; -import { describe, expect, it, vi } from "vitest"; - -import InboxListItem from "@/components/inbox/InboxListItem"; -import { renderWithProviders } from "@/test/renderWithProviders"; -import type { InboxItemViewModel } from "@/types/inbox"; - -function buildItem(): InboxItemViewModel { - return { - id: "work-item-19", - source_id: "19", - source_kind: "ai_pmo_work_item", - display_kind: "action_required", - title: "换季搭配推荐", - summary: "先把需求继续收完整。", - ai_plan: "继续收敛需求范围", - blocking_reason: null, - primary_action: { kind: "confirm", label: "确认入库" }, - secondary_action: { kind: "revise_draft", label: "修改" }, - weak_action: { kind: "cancel_draft", label: "取消" }, - project_id: 52, - project_name: "换季推荐项目", - priority: "high", - priority_rank: 3, - is_read: false, - dedupe_count: 1, - can_undo: false, - undo_deadline: null, - target_url: "/projects/52/board", - created_at: "2026-04-17T10:00:00+08:00", - updated_at: "2026-04-17T10:05:00+08:00", - stage_text: "需求收集中", - next_step_text: "先补齐衣橱标签和反馈采集方式", - latest_policy_gate_code: null, - approval_requirement: null, - plan_mode: true, - plan_status: "collecting", - requirement_plan_context_summary: { - title: "换季搭配推荐", - goal: "先验证用户是否愿意采用推荐搭配。", - missing_info: ["衣橱标签", "反馈采集方式"], - question_round: 2, - last_round_field: "target_user", - }, - source_work_item: { - id: 19, - source_system: "pmflow_manual", - source_message_id: "msg-plan-19", - source_message_text: "帮我继续收这个需求", - source_conversation_closure: null, - source_captured_at: "2026-04-17T10:00:00+08:00", - source_sender: "产品经理A", - source_channel_ref: "pmflow_manual", - closure_summary: null, - implementation_modules: [], - source_closure_turns: [], - state: "draft_pending_confirm", - intent: "query.answer", - decision_level: "project", - project_id: 52, - project_name: "换季推荐项目", - signal_id: null, - reply_template_key: "requirement_plan_followup", - title: "换季搭配推荐", - summary: "先把需求继续收完整。", - ai_plan: "继续收敛需求范围", - blocking_reason: null, - next_step_text: "先补齐衣橱标签和反馈采集方式", - stage_text: "需求收集中", - priority: "high", - priority_rank: 3, - target_url: "/projects/52/board", - review_summary: null, - execution_result: null, - draft_content: null, - draft_expires_at: null, - draft_version: 2, - confirm_message_id: "om-plan-19", - latest_policy_gate_code: null, - approval_requirement: null, - created_at: "2026-04-17T10:00:00+08:00", - updated_at: "2026-04-17T10:05:00+08:00", - plan_mode: true, - plan_status: "collecting", - requirement_plan_context_summary: { - title: "换季搭配推荐", - goal: "先验证用户是否愿意采用推荐搭配。", - missing_info: ["衣橱标签", "反馈采集方式"], - question_round: 2, - last_round_field: "target_user", - }, - }, - }; -} - -describe("InboxListItem", () => { - it("renders planmode status and requirement summary cues", () => { - renderWithProviders( - , - ); - - expect(screen.queryByText("当前模式")).toBeTruthy(); - expect(screen.queryByText("需求模式")).toBeTruthy(); - expect(screen.queryByText("collecting")).toBeTruthy(); - expect(screen.getAllByText(/换季搭配推荐/).length).toBeGreaterThan(0); - expect(screen.getAllByText(/衣橱标签/).length).toBeGreaterThan(0); - }); -}); diff --git a/frontend/src/components/inbox/InboxListItem.tsx b/frontend/src/components/inbox/InboxListItem.tsx deleted file mode 100644 index 264c8058..00000000 --- a/frontend/src/components/inbox/InboxListItem.tsx +++ /dev/null @@ -1,376 +0,0 @@ -import type { ReactNode } from "react"; -import { BellRing, CheckCircle2, ChevronDown, ChevronUp, CircleAlert, LoaderCircle } from "lucide-react"; -import { Badge } from "@/components/ui/badge"; -import { Button } from "@/components/ui/button"; -import { formatInboxAbsoluteTime, formatInboxTime } from "@/lib/formatInboxTime"; -import { - mapDisplayKindLabel, - mapIntentLabel, - mapStageText, - normalizeProjectLabel, - sanitizeInboxSubtitle, - sanitizeInboxTitle, -} from "@/lib/inboxTextMap"; -import { cn } from "@/lib/utils"; -import type { InboxActionDescriptor, InboxDisplayKind, InboxItemViewModel } from "@/types/inbox"; - -const STATUS_STYLES: Record = { - action_required: "border-amber-500/40 bg-amber-500/10 text-amber-700", - auto_running: "border-sky-500/40 bg-sky-500/10 text-sky-700", - auto_done: "border-emerald-500/40 bg-emerald-500/10 text-emerald-700", - info: "border-slate-300 bg-slate-100 text-slate-600", -}; - -const STATUS_ICON_STYLES: Record = { - action_required: "bg-amber-100 text-amber-700", - auto_running: "bg-sky-100 text-sky-700", - auto_done: "bg-emerald-100 text-emerald-700", - info: "bg-slate-200 text-slate-600", -}; - -function formatTechnicalValue(value: unknown): string { - if (value === null || value === undefined) return "-"; - if (typeof value === "string") return value.trim() || "-"; - try { - return JSON.stringify(value); - } catch { - return String(value); - } -} - -function resolveStatusIcon(displayKind: InboxDisplayKind) { - if (displayKind === "action_required") return CircleAlert; - if (displayKind === "auto_running") return LoaderCircle; - if (displayKind === "auto_done") return CheckCircle2; - return BellRing; -} - -export function buildInboxActionKey(item: InboxItemViewModel, action: InboxActionDescriptor): string { - return `${item.id}:${action.kind}:${action.label}`; -} - -interface InboxListItemProps { - item: InboxItemViewModel; - expanded: boolean; - pendingActionKey: string | null; - inlinePanel?: ReactNode; - onToggleExpand: (itemId: string) => void; - onAction: (item: InboxItemViewModel, action: InboxActionDescriptor) => void; -} - -export default function InboxListItem({ - item, - expanded, - pendingActionKey, - inlinePanel, - onToggleExpand, - onAction, -}: InboxListItemProps) { - const isActionRow = item.display_kind === "action_required"; - const projectLabel = normalizeProjectLabel(item.project_name, item.project_id); - const title = sanitizeInboxTitle(item.title); - const subtitle = sanitizeInboxSubtitle(item.summary); - const StatusIcon = resolveStatusIcon(item.display_kind); - - const payload = (item.source_notification?.payload ?? {}) as Record; - const sourceMessageText = - String(item.source_work_item?.source_message_text || item.source_mounted_run?.final_reply || payload.message_text || payload.raw_message || "").trim(); - const closureDialogue = String(item.source_work_item?.source_conversation_closure || "").trim(); - const closureSummary = String(item.source_work_item?.closure_summary || "").trim(); - const implementationModules = Array.isArray(item.source_work_item?.implementation_modules) - ? item.source_work_item?.implementation_modules - .map((module) => String(module || "").trim()) - .filter(Boolean) - .slice(0, 6) - : []; - const sourceCapturedAt = - String( - item.source_work_item?.source_captured_at || - payload.captured_at || - payload.occurred_at || - "" - ).trim() || null; - const sourceSender = - String(item.source_work_item?.source_sender || payload.sender || payload.operator || "").trim() || "-"; - const sourceChannel = - String(item.source_work_item?.source_channel_ref || payload.channel_ref || payload.source_channel || "").trim() || "-"; - const sourceMessageId = - String(item.source_work_item?.source_message_id || item.source_mounted_run?.source_message_id || payload.source_message_id || payload.message_id || "").trim() || "-"; - const sourceSystem = - String(item.source_work_item?.source_system || item.source_mounted_run?.source_system || item.source_notification?.source || payload.source_system || "PMFlow").trim(); - const timelineAnchor = sourceCapturedAt || item.updated_at || item.created_at; - const displayTime = formatInboxTime(timelineAnchor); - const eventTime = formatInboxAbsoluteTime(timelineAnchor); - const queuedTime = formatInboxAbsoluteTime(item.created_at || item.updated_at); - const rawIntent = String(item.source_work_item?.intent || item.source_mounted_run?.trigger_reason || payload.intent || "").trim(); - const typedActions = payload.typed_actions ?? payload["typed actions"] ?? payload.typedActions; - const chatId = payload.chat_id ?? payload.source_chat_id ?? payload.chatId; - const requirementSummary = sanitizeInboxSubtitle(closureSummary || item.summary || item.ai_plan || item.next_step_text || "暂无摘要"); - const executionSummary = sanitizeInboxSubtitle(item.ai_plan || item.next_step_text || item.summary || "暂无"); - const readableInput = - closureDialogue || - sourceMessageText || - item.source_mounted_run?.final_reply || - ((item.source_work_item || item.source_mounted_run) ? sanitizeInboxTitle(item.title) : "-"); - const undoDeadlineText = item.can_undo ? `可撤销(截止 ${item.undo_deadline || "-"})` : "不可撤销"; - const requirementPlanSummary = - item.requirement_plan_context_summary || - item.source_work_item?.requirement_plan_context_summary || - null; - const planMode = Boolean(item.plan_mode ?? item.source_work_item?.plan_mode); - const planStatus = String(item.plan_status || item.source_work_item?.plan_status || "").trim() || null; - const planTitle = String(requirementPlanSummary?.title || "").trim(); - const planGoal = String(requirementPlanSummary?.goal || "").trim(); - const planMissingInfo = Array.isArray(requirementPlanSummary?.missing_info) - ? requirementPlanSummary.missing_info.map((entry) => String(entry || "").trim()).filter(Boolean).slice(0, 3) - : []; - const planQuestionRound = Number(requirementPlanSummary?.question_round || 0) || null; - const planLastRoundField = String(requirementPlanSummary?.last_round_field || "").trim() || null; - const showPlanModeCard = Boolean(planMode || planStatus || planTitle || planGoal || planMissingInfo.length); - - const primaryActionKey = buildInboxActionKey(item, item.primary_action); - const secondaryActionKey = buildInboxActionKey(item, item.secondary_action); - const weakActionKey = buildInboxActionKey(item, item.weak_action); - - return ( -
-
onToggleExpand(item.id)} - onKeyDown={(event) => { - if (event.key === "Enter" || event.key === " ") { - event.preventDefault(); - onToggleExpand(item.id); - } - }} - className={cn( - "w-full px-3 py-2 text-left focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-blue-500 md:px-4", - isActionRow ? "min-h-[72px]" : "min-h-[52px]" - )} - > -
- - - - -
-
-

{title}

-
-
{projectLabel}
-
{displayTime}
-
-
- -
-

{subtitle}

- - {mapDisplayKindLabel(item.display_kind)} - -
-
-
- -
- {isActionRow && ( - - )} - -
-
- - {expanded && ( -
-
-
-
当前阶段
-
{mapStageText(item.stage_text || mapDisplayKindLabel(item.display_kind))}
-
-
-
下一步
-
{sanitizeInboxSubtitle(item.next_step_text || item.ai_plan || "暂无")}
-
-
- -
-
-
消息闭环(原始对话)
-

{readableInput || "-"}

-
-
-
需求摘要(接口总结)
-

{requirementSummary}

-
-
执行摘要
-

{executionSummary}

-
-
-
- - {showPlanModeCard && ( -
-
-
-
当前模式
-
{planMode ? "需求模式" : "普通执行模式"}
-
- {planStatus ? {planStatus} : null} -
- {planTitle ? ( -
-
当前需求
-
{planTitle}
-
- ) : null} - {planGoal ? ( -
-
目标
-
{planGoal}
-
- ) : null} - {planMissingInfo.length > 0 ? ( -
-
待补信息
-
- {planMissingInfo.map((field) => ( - - {field} - - ))} -
-
- ) : null} - {planQuestionRound || planLastRoundField ? ( -
- {planQuestionRound ? 当前轮次:第 {planQuestionRound} 轮 : null} - {planQuestionRound && planLastRoundField ? · : null} - {planLastRoundField ? 最近收敛字段:{planLastRoundField} : null} -
- ) : null} -
- )} - - {implementationModules.length > 0 && ( -
-
实现模块(闭环提炼)
-
- {implementationModules.map((module) => ( - - {module} - - ))} -
-
- )} - -
-
-
-
事件时间
-
{eventTime}
-
-
-
入队时间
-
{queuedTime}
-
-
-
来源
-
{sourceSystem || "-"}
-
-
-
来源消息ID
-
{sourceMessageId}
-
-
-

- 时间说明:优先显示源系统事件时间(captured_at),无记录时回退为系统更新时间。 -

-
- -
- 技术字段(点击展开) -
-
intent(可读):{mapIntentLabel(rawIntent)}
-
intent(原始):{rawIntent || "-"}
-
typed_actions:{formatTechnicalValue(typedActions)}
-
chat_id:{formatTechnicalValue(chatId)}
-
source_sender:{sourceSender}
-
source_channel:{sourceChannel}
-
重复次数:{item.dedupe_count}
-
撤销状态:{undoDeadlineText}
-
-
- - {inlinePanel} - -
- {!isActionRow && item.primary_action.kind !== "none" && ( - - )} - - {item.secondary_action.kind !== "none" && ( - - )} - - {item.weak_action.kind === "none" ? ( - {item.weak_action.label} - ) : ( - - )} -
-
- )} -
- ); -} diff --git a/frontend/src/components/layout/Sidebar.test.tsx b/frontend/src/components/layout/Sidebar.test.tsx new file mode 100644 index 00000000..1fb58f12 --- /dev/null +++ b/frontend/src/components/layout/Sidebar.test.tsx @@ -0,0 +1,67 @@ +import { cleanup, screen } from "@testing-library/react"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +import { Sidebar } from "@/components/layout/Sidebar"; +import { useAuthStore } from "@/stores/auth"; +import { renderWithProviders } from "@/test/renderWithProviders"; + +const removedLegacyLaunchLabel = "旧" + "启动台"; +const removedLegacyProjectLabel = "旧" + "项目面"; + +vi.mock("@/features/pmflow-os/store", () => ({ + usePMFlowOsStore: () => ({ + state: { + changeItems: [], + project: { stage: "idea" }, + }, + }), +})); + +describe("Sidebar product navigation", () => { + beforeEach(() => { + localStorage.clear(); + }); + + afterEach(() => { + cleanup(); + localStorage.clear(); + useAuthStore.setState({ token: null, user: null }); + }); + + it("hides AI operations center for member users", () => { + useAuthStore.setState({ + token: "token", + user: { id: "9", username: "member", role: "BUILDER" }, + }); + + renderWithProviders(, ["/dashboard"]); + + expect(screen.getByText("团队层")).toBeInTheDocument(); + expect(screen.queryByText("新建项目")).toBeNull(); + expect(screen.queryByText(removedLegacyLaunchLabel)).toBeNull(); + expect(screen.queryByText(removedLegacyProjectLabel)).toBeNull(); + expect(screen.queryByText("团队负载")).toBeNull(); + expect(screen.queryByText("AI 运营中心")).toBeNull(); + expect(screen.queryByText("用户管理")).toBeNull(); + }); + + it("shows team operations and AI operations center for managers", () => { + useAuthStore.setState({ + token: "token", + user: { id: "7", username: "owner", role: "OWNER" }, + }); + + renderWithProviders(, ["/dashboard"]); + + expect(screen.getByText("团队层")).toBeInTheDocument(); + expect(screen.queryByText("新建项目")).toBeNull(); + expect(screen.queryByText(removedLegacyLaunchLabel)).toBeNull(); + expect(screen.queryByText(removedLegacyProjectLabel)).toBeNull(); + expect(screen.queryByText("团队负载")).toBeNull(); + expect(screen.queryByText("能力管理")).toBeNull(); + expect(screen.queryByText("用户管理")).toBeNull(); + expect(screen.getByText("AI 运营")).toBeInTheDocument(); + expect(screen.getByText("AI 运营中心")).toBeInTheDocument(); + expect(screen.queryByText("管理后台")).toBeNull(); + }); +}); diff --git a/frontend/src/components/layout/Sidebar.tsx b/frontend/src/components/layout/Sidebar.tsx index 8911ec11..79746d77 100644 --- a/frontend/src/components/layout/Sidebar.tsx +++ b/frontend/src/components/layout/Sidebar.tsx @@ -1,14 +1,11 @@ import { NavLink, useLocation } from "react-router-dom"; import { - Bot, BookMarked, FolderKanban, GitBranchPlus, - Inbox, LayoutDashboard, LineChart, Moon, - Plus, ShieldCheck, Sparkles, Sliders, @@ -27,28 +24,17 @@ import { usePMFlowOsStore } from "@/features/pmflow-os/store"; import type { PMFlowStage } from "@/features/pmflow-os/types"; const primaryNavItems = [ - { to: "/project-launch", label: "新建项目", icon: Plus }, { to: "/command-center", label: "指挥台", icon: Sparkles }, { to: "/work-graph", label: "项目工作台", icon: FolderKanban }, { to: "/change-review", label: "决策与待确认", icon: ShieldCheck }, { to: "/execution-bridge", label: "开发执行", icon: GitBranchPlus }, { to: "/release-growth", label: "发布与增长", icon: LineChart }, - { to: "/team-layer", label: "团队协作", icon: Users }, + { to: "/team-layer", label: "团队层", icon: Users }, { to: "/project-memory", label: "项目记忆", icon: BookMarked }, ]; -const projectNavItems = primaryNavItems.filter((item) => item.to !== "/project-launch"); - -const legacyNavItems = [ - { to: "/ai-steward", label: "旧启动台", icon: Bot }, - { to: "/projects", label: "旧项目面", icon: FolderKanban }, - { to: "/inbox", label: "消息台", icon: Inbox }, -]; - -const adminNavItems = [ - { to: "/workloads", label: "团队负载", icon: LayoutDashboard }, - { to: "/skills", label: "能力管理", icon: LineChart }, - { to: "/ai-config", label: "管理后台", icon: Sliders }, +const aiOpsNavItems = [ + { to: "/ai-config", label: "AI 运营中心", icon: Sliders }, ]; interface SidebarProps { @@ -120,6 +106,12 @@ export function Sidebar({ open, onClose }: SidebarProps) { ); + const renderSectionLabel = (label: string) => ( +
+ {label} +
+ ); + return ( ); @@ -134,10 +140,10 @@ export function SpaceTopBar({ newProjectTo = "/project-launch" }: { newProjectTo export function UsageLine({ label, value, percent }: { label: string; value: string; percent: string }) { return ( -
+
{label}
{value}
-
+
diff --git a/frontend/src/features/pmflow-os/components/chrome.tsx b/frontend/src/features/pmflow-os/components/chrome.tsx index 015630ac..a8c6dd9e 100644 --- a/frontend/src/features/pmflow-os/components/chrome.tsx +++ b/frontend/src/features/pmflow-os/components/chrome.tsx @@ -108,7 +108,7 @@ export function formatStageLabel(stage: PMFlowStage | string) { growth: "增长", "review gate": "变更审查", "execution bridge": "执行桥", - "team layer": "团队协作层", + "team layer": "团队层", }; return labelMap[stage] || stage; } diff --git a/frontend/src/features/pmflow-os/pages/TeamLayerPage.tsx b/frontend/src/features/pmflow-os/pages/TeamLayerPage.tsx index 27aa9205..e352705f 100644 --- a/frontend/src/features/pmflow-os/pages/TeamLayerPage.tsx +++ b/frontend/src/features/pmflow-os/pages/TeamLayerPage.tsx @@ -1,4 +1,5 @@ import type { CSSProperties, ReactNode } from "react"; +import { useMemo, useState } from "react"; import { ArrowRight, Bot, @@ -14,13 +15,36 @@ import { Send, Settings2, ShieldAlert, + TimerReset, TrendingUp, X, } from "lucide-react"; +import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; import { useNavigate } from "react-router-dom"; +import { + createPMFlowCapacityOverride, + getPMFlowCapacityOverrides, + getPMFlowCapacityPlan, + getPMFlowExecutionCapabilities, + simulatePMFlowCapacityOverride, +} from "@/api/pmflowOs"; import { TeamFeedPanel } from "@/features/pmflow-os/components/TeamFeedPanel"; import { usePMFlowOsStore } from "@/features/pmflow-os/store"; -import type { PMFlowReviewerRouting, PMFlowReviewerRoutingItem, PMFlowRiskLevel } from "@/features/pmflow-os/types"; +import type { + PMFlowCapacityOverridePayload, + PMFlowCapacityOverrideRecord, + PMFlowCapacityOverrideSimulation, + PMFlowDailyCapacityPlan, + PMFlowExecutionCapabilitySnapshot, + PMFlowExecutionCapabilityStatus, + PMFlowHumanWorkType, + PMFlowMemberCapacityPlan, + PMFlowReviewerRouting, + PMFlowReviewerRoutingItem, + PMFlowRiskLevel, + PMFlowTeamExecutionSignal, +} from "@/features/pmflow-os/types"; +import { useAuthStore } from "@/stores/auth"; const cn = (...classes: Array) => classes.filter(Boolean).join(" "); @@ -55,10 +79,40 @@ const agentCards = [ export default function TeamLayerPage() { const navigate = useNavigate(); - const { state, openReview, projectionWarnings } = usePMFlowOsStore(); + const queryClient = useQueryClient(); + const currentUser = useAuthStore((store) => store.user); + const { state, openReview, projectionWarnings, dataSourceMode } = usePMFlowOsStore(); const reviewerRouting = state.reviewerRouting || emptyReviewerRouting(); const pendingReviewCount = state.changeItems.filter((item) => item.status === "pending_review").length; const reviewerWarnings = projectionWarnings.filter((warning) => warning.includes("reviewer_routing_warning")); + const capabilityQuery = useQuery({ + queryKey: ["pmflow-os", "execution-capabilities", state.project.id], + queryFn: () => getPMFlowExecutionCapabilities(state.project.id), + enabled: dataSourceMode === "api" && Boolean(state.project.id) && state.project.id !== "unselected-project", + }); + const capacityQuery = useQuery({ + queryKey: ["pmflow-os", "capacity-plan", state.project.id], + queryFn: () => getPMFlowCapacityPlan(state.project.id), + enabled: dataSourceMode === "api" && Boolean(state.project.id) && state.project.id !== "unselected-project", + }); + const overridesQuery = useQuery({ + queryKey: ["pmflow-os", "capacity-overrides", state.project.id], + queryFn: () => getPMFlowCapacityOverrides(state.project.id), + enabled: dataSourceMode === "api" && Boolean(state.project.id) && state.project.id !== "unselected-project", + }); + const canManageCapacity = ["OWNER", "REVIEWER"].includes(String(currentUser?.role || "").toUpperCase()); + const invalidateCapacity = () => { + void queryClient.invalidateQueries({ queryKey: ["pmflow-os", "capacity-plan", state.project.id] }); + void queryClient.invalidateQueries({ queryKey: ["pmflow-os", "capacity-overrides", state.project.id] }); + void queryClient.invalidateQueries({ queryKey: ["pmflow-os", "execution-capabilities", state.project.id] }); + }; + const simulateMutation = useMutation({ + mutationFn: (payload: PMFlowCapacityOverridePayload) => simulatePMFlowCapacityOverride(state.project.id, payload), + }); + const applyMutation = useMutation({ + mutationFn: (payload: PMFlowCapacityOverridePayload) => createPMFlowCapacityOverride(state.project.id, payload), + onSuccess: invalidateCapacity, + }); const handleOpenReview = (item: PMFlowReviewerRoutingItem) => { openReview(item.changeSetId); navigate(item.targetUrl || `/change-review?projectId=${item.projectId}&changeSetId=${item.changeSetId}`); @@ -74,8 +128,27 @@ export default function TeamLayerPage() { warnings={reviewerWarnings} onOpenReview={handleOpenReview} /> + + simulateMutation.mutate(payload)} + onApply={(payload) => applyMutation.mutate(payload)} + /> -
@@ -215,6 +288,432 @@ function MetricPill({ label, value }: { label: string; value: string }) { ); } +function TeamCapacityPanel({ + plan, + overrides, + isLoading, + error, + canManage, + simulation, + isSimulating, + isApplying, + simulateError, + applyError, + onSimulate, + onApply, +}: { + plan?: PMFlowDailyCapacityPlan | null; + overrides: PMFlowCapacityOverrideRecord[]; + isLoading: boolean; + error: unknown; + canManage: boolean; + simulation?: PMFlowCapacityOverrideSimulation; + isSimulating: boolean; + isApplying: boolean; + simulateError: unknown; + applyError: unknown; + onSimulate: (payload: PMFlowCapacityOverridePayload) => void; + onApply: (payload: PMFlowCapacityOverridePayload) => void; +}) { + const [selectedUserId, setSelectedUserId] = useState(null); + const [addedMinutes, setAddedMinutes] = useState("60"); + const [maxUtilization, setMaxUtilization] = useState("0.95"); + const [overtimeType, setOvertimeType] = useState<"general" | "focus" | "review" | "release_guard">("general"); + const [reason, setReason] = useState(""); + const selectedMember = useMemo( + () => plan?.memberPlans.find((member) => member.userId === selectedUserId) ?? null, + [plan?.memberPlans, selectedUserId] + ); + const payload = selectedMember + ? { + userId: selectedMember.userId, + date: plan?.date, + overrideType: "overtime" as const, + addedMinutes: Number(addedMinutes || 0), + overtimeType, + maxPlannedUtilization: Number(maxUtilization || 0.85), + reason: reason.trim(), + } + : null; + + return ( + +
+
+
+ + 自适应人类容量 +
+

团队今日容量调度台

+

+ 时间是硬上限,任务类型是弹性的;系统按当天任务池动态生成基线,并用风险、疲劳、上下文和工具准备度判断是否还能承接。 +

+
+
+ + + + +
+
+ + {isLoading ? ( +
正在生成今日容量计划…
+ ) : error ? ( +
+ 今日容量计划暂时不可用;系统不会回退到固定工时比例或静态技能标签。 +
+ ) : plan ? ( + <> +
+
+
+
+
团队需求结构
+
+ 深度判断 {percent(plan.teamDemandMix.focus)} · 审查 {percent(plan.teamDemandMix.review)} · 验收 {percent(plan.teamDemandMix.acceptance)} · 协调 {percent(plan.teamDemandMix.coordination)} +
+
+ {plan.date} +
+
+ {plan.memberPlans.length ? ( + plan.memberPlans.map((member) => ( + setSelectedUserId(member.userId)} + /> + )) + ) : ( +
+ 当前项目没有可计算容量的成员。 +
+ )} +
+
+ +
+
AI 预处理与负责人调整
+
+ {plan.recommendations.slice(0, 3).map((item) => ( +
+ {item} +
+ ))} +
+ {canManage ? ( +
+
调整前必须先模拟
+ +
+ setAddedMinutes(event.target.value)} placeholder="增加分钟" /> + setMaxUtilization(event.target.value)} placeholder="最大利用率" /> +
+ +