diff --git a/.tmp/playwright-main-pages/ai-config.png b/.tmp/playwright-main-pages/ai-config.png new file mode 100644 index 00000000..b59aa5e2 Binary files /dev/null and b/.tmp/playwright-main-pages/ai-config.png differ diff --git a/.tmp/playwright-main-pages/ai-steward.png b/.tmp/playwright-main-pages/ai-steward.png new file mode 100644 index 00000000..ee54faf5 Binary files /dev/null and b/.tmp/playwright-main-pages/ai-steward.png differ diff --git a/.tmp/playwright-main-pages/dashboard.png b/.tmp/playwright-main-pages/dashboard.png new file mode 100644 index 00000000..69aa4ec4 Binary files /dev/null and b/.tmp/playwright-main-pages/dashboard.png differ diff --git a/.tmp/playwright-main-pages/feishu-config.png b/.tmp/playwright-main-pages/feishu-config.png new file mode 100644 index 00000000..a84fb6fc Binary files /dev/null and b/.tmp/playwright-main-pages/feishu-config.png differ diff --git a/.tmp/playwright-main-pages/inbox.png b/.tmp/playwright-main-pages/inbox.png new file mode 100644 index 00000000..80b71c27 Binary files /dev/null and b/.tmp/playwright-main-pages/inbox.png differ diff --git a/.tmp/playwright-main-pages/projects.png b/.tmp/playwright-main-pages/projects.png new file mode 100644 index 00000000..6862fc1f Binary files /dev/null and b/.tmp/playwright-main-pages/projects.png differ diff --git a/.tmp/playwright-main-pages/skills.png b/.tmp/playwright-main-pages/skills.png new file mode 100644 index 00000000..1702a3d8 Binary files /dev/null and b/.tmp/playwright-main-pages/skills.png differ diff --git a/.tmp/playwright-main-pages/users.png b/.tmp/playwright-main-pages/users.png new file mode 100644 index 00000000..a1d59bd9 Binary files /dev/null and b/.tmp/playwright-main-pages/users.png differ diff --git a/CLAUDE.md b/CLAUDE.md index be2f4932..6af7c398 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -26,6 +26,7 @@ AIProjectManger/ ├── tests/ # 根级测试(集成/脚本) └── docs/ # 额外文档 ``` +- 新增:`docs/INBOX_INLINE_OPS_3_PHASE_CHECKLIST.md`(消息台内联操作三期执行清单) ## 4. 后端结构(`backend/`) ```text @@ -88,8 +89,11 @@ frontend/ ``` - 新增:`frontend/src/components/inbox/InboxListItem.tsx`(消息台 Feed 行组件,支持移动端与行内展开) +- 新增:`frontend/src/components/inbox/InboxActionPanel.tsx`(消息台内联动作面板:缺少项目归属时的项目选择与确认关联) - 新增:`frontend/src/lib/inboxTextMap.ts`(消息台用户文案映射与标题清洗) - 新增:`frontend/src/lib/formatInboxTime.ts`(消息台相对时间与完整时间格式化) +- 新增:`frontend/src/components/ui/RoleBadge.tsx`(角色徽章组件,支持多角色与可选数量限制) +- 新增:`frontend/src/components/ui/SkillBadge.tsx`(技能徽章组件,默认显示 3 个并支持 +N) ### 5.1 `frontend/src/components/sstc/` 目录说明(模块化拆分后) - `SSTCWorkspace.tsx`:SSTC 工作区主容器(组装各阶段组件) @@ -181,6 +185,15 @@ uvicorn app.main:app --reload --host 0.0.0.0 --port 8000 - 项目成员分配接口(`PUT /projects/{project_id}/members`):参考 `backend/app/api/projects.py` + `backend/app/services/project_member_service.py`,要求显式传入 `member_user_ids` 并写入 `user_projects` - SSTC 总览接口(`GET /projects/{project_id}/sstc/overview`):`workload_health` 返回成员明细 `members[]`(姓名/角色/负载比例/状态)用于头部卡片展示 - AI PMO 审批接口(`POST /ai-pmo/work-items/{work_item_id}/decision`):显式 `approve/reject`,`reject` 必须落 `ignored`(失效)并同步 `decision_records.final_outcome` +- AI PMO 审批接口补充:`approve + waiting_manual + project_id` 走“快速返回 + 后台继续执行”,避免前端长时间阻塞 +- AI PMO 消息台列表接口(`GET /ai-pmo/work-items`):返回 `latest_policy_gate_code` 与规范化 `approval_requirement{required,reason,message}`(来源于最新 `decision_records` 投影) +- AI PMO 消息台列表接口补充:返回源消息上下文字段 `source_message_text/source_captured_at/source_sender/source_channel_ref`,用于详情页展示“原始输入”与事件时间 +- AI PMO 消息台列表接口补充:返回闭环语义字段 `source_conversation_closure/closure_summary/implementation_modules/source_closure_turns`,用于详情页展示“消息闭环 + 实现模块” +- AI PMO 消息台历史回填补充:当 `ingest_events.payload` 闭环字段缺失或低质量时,优先回放 `ai_processing_logs`(同 `feishu_message_id`)并补充 `qa_records` 最近问答,避免 Redis 会话过期后仅剩单句摘要 +- AI PMO 消息台列表接口补充:过滤短口水话 `query.read_only` 项(如“怎么样了/在吗”),保留后端记录但不下发前端收件流 +- AI PMO `signal_source.onboard` 入库规则:优先使用闭环摘要写入 `signals.summary`,闭环对话写入 `signals.content`,并在 `signals.strategic_analysis.source_context.implementation_modules` 持久化实现模块 +- SSTC 信号列表接口(`GET /projects/{project_id}/signals`)标题生成规则:优先 `signals.title`,其次 `signals.summary`,最后回退 `signals.content` 去前缀文本 +- 信号源编排事务补充:`SignalSourceOrchestrationService` 内部调用 OpenClaw 指挥链使用隔离 Session,禁止把子链路 rollback 传播到外层 `signal/work_item` 事务 - 新增前端 API 调用:参考 `frontend/src/api/sstc.ts` - 新增数据库表:参考 `alembic/versions/` 最新 migration - 新增飞书事件处理:参考 `backend/app/services/feishu_integration.py` diff --git a/backend/app/api/ai_pmo.py b/backend/app/api/ai_pmo.py index 67cfe490..fb81f253 100644 --- a/backend/app/api/ai_pmo.py +++ b/backend/app/api/ai_pmo.py @@ -2,7 +2,9 @@ from __future__ import annotations +import asyncio from datetime import datetime +import re from typing import Any, Literal, Optional from fastapi import APIRouter, Depends, HTTPException @@ -10,8 +12,9 @@ from pydantic import BaseModel, Field from sqlalchemy import and_, select from sqlalchemy.ext.asyncio import AsyncSession +from ..core.logging import get_logger from ..core.response import success_response -from ..database import get_db +from ..database import AsyncSessionLocal, get_db from ..dependencies.auth import get_current_user from ..dependencies.tenant import get_tenant_id from ..models import DecisionRecord, IngestEvent, PlanBundle, PortfolioSnapshot, Project, ReplyReceipt, User, WorkItem @@ -19,6 +22,32 @@ from ..services.ai_pmo_cadence_service import AIPMOCadenceService from ..services.ai_pmo_kernel_service import AIPMOKernelService router = APIRouter(prefix="/ai-pmo", tags=["AI PMO"]) +logger = get_logger(__name__) +FRONT_NOISE_INTENT = "query.read_only" +FRONT_NOISE_EXACT = { + "收到", + "收到啦", + "好的", + "ok", + "okk", + "yes", + "no", + "谢谢", + "thanks", + "thx", + "在吗", + "在不在", + "怎么样了", + "咋样了", + "辛苦了", +} +FRONT_NOISE_HINTS = ("怎么样了", "咋样了", "在吗", "辛苦了", "ok", "谢谢", "收到") +FRONT_NOISE_STRUCTURED_HINTS = ("项目", "信号", "任务", "风险", "进度", "状态", "work_item", "工作项", "#") +GENERIC_SOURCE_TEXT_RE = re.compile( + r"^(?:请)?(?:按|按照)?(?:这个|上述|这条)?(?:分步|步骤)?(?:骤)?(?:把|将)?(?:它|该需求|这条需求)?(?:加入|接入|录入).{0,24}信号源(?:中)?$", + re.IGNORECASE, +) +LOW_VALUE_CLOSURE_HINTS = ("缺少项目归属", "必须先确认项目", "无法创建", "不能创建") class PMOProcessTextRequest(BaseModel): @@ -91,9 +120,27 @@ async def list_work_items( if project_id is not None: stmt = stmt.where(WorkItem.project_id == int(project_id)) rows = (await db.execute(stmt.limit(max(1, min(limit, 100))))).all() + work_item_ids = [int(row.id) for row, _, _ in rows] + latest_decision_projection = await _build_latest_decision_projection( + db=db, + tenant_id=tenant_id, + work_item_ids=work_item_ids, + ) + closure_payload_overrides = await _build_closure_payload_overrides( + db=db, + tenant_id=tenant_id, + rows=rows, + ) items = [ - _serialize_work_item_summary(row=row, ingest=ingest, project=project) + _serialize_work_item_summary( + row=row, + ingest=ingest, + project=project, + latest_decision_projection=latest_decision_projection.get(int(row.id)), + payload_override=closure_payload_overrides.get(int(row.id)), + ) for row, ingest, project in rows + if not _should_hide_work_item_from_inbox(row=row, ingest=ingest) ] return success_response({"items": items}) @@ -271,6 +318,63 @@ async def decide_work_item( ) return success_response(result) + if str(target.state or "").strip() == "waiting_manual" and request.project_id is not None: + target.project_id = int(request.project_id) + target.state = "executing" + target.reply_template_key = "action_processing" + current_execution = target.execution_result if isinstance(target.execution_result, dict) else {} + target.execution_result = { + **current_execution, + "status": "executing", + "manual_decision": "approve", + "resume_trigger": "queued_background", + "project_id": int(request.project_id), + "queued_at": datetime.now().isoformat(), + } + decision_row = ( + await db.execute( + select(DecisionRecord) + .where( + and_( + DecisionRecord.tenant_id == tenant_id, + DecisionRecord.work_item_id == int(target.id), + ) + ) + .order_by(DecisionRecord.id.desc()) + .limit(1) + ) + ).scalar_one_or_none() + if decision_row is not None and str(decision_row.policy_gate_code or "").strip() == "missing_project_scope": + decision_row.policy_gate_hit = False + decision_row.policy_gate_code = None + decision_row.approval_requirement = { + "required": False, + "reason": "project_scope_confirmed", + "message": "项目归属已确认,正在后台继续执行。", + } + decision_row.final_outcome = "executing" + await db.commit() + _queue_resume_work_item( + tenant_id=tenant_id, + work_item_id=int(work_item_id), + source_message_id=request.source_message_id, + sender=str(current_user.name or current_user.username or "PMFlow用户"), + project_id=int(request.project_id), + ) + return success_response( + { + "handled": True, + "status": "pending_manual", + "followup_reply": "已确认关联项目,正在后台继续执行。", + "target_work_item_id": int(target.id), + "target_state": str(target.state or "").strip() or "executing", + "target_intent": str(target.intent or "").strip() or None, + "project_id": int(request.project_id), + "signal_id": _safe_int(target.signal_id), + "auto_tasks": [], + } + ) + service = AIPMOKernelService(db, tenant_id=tenant_id) raw_result = await service.process_ingress( source_system="ai_pmo_ui", @@ -287,6 +391,89 @@ async def decide_work_item( return success_response(result) +def _queue_resume_work_item( + *, + tenant_id: int, + work_item_id: int, + source_message_id: Optional[str], + sender: str, + project_id: int, +) -> None: + message_id = ( + str(source_message_id or "").strip() + or f"decision-approve-bg-{int(work_item_id)}-{int(datetime.now().timestamp() * 1000)}" + )[:128] + task = asyncio.create_task( + _run_resume_work_item_in_background( + tenant_id=int(tenant_id), + work_item_id=int(work_item_id), + source_message_id=message_id, + sender=str(sender or "PMFlow用户"), + project_id=int(project_id), + ), + name=f"ai-pmo-resume-{int(work_item_id)}", + ) + + def _on_done(done_task: asyncio.Task[None]) -> None: + if done_task.cancelled(): + return + exc = done_task.exception() + if exc is not None: + logger.warning( + "ai_pmo_background_resume_failed", + tenant_id=int(tenant_id), + work_item_id=int(work_item_id), + error=str(exc)[:400], + ) + + task.add_done_callback(_on_done) + + +async def _run_resume_work_item_in_background( + *, + tenant_id: int, + work_item_id: int, + source_message_id: str, + sender: str, + project_id: int, +) -> None: + async with AsyncSessionLocal() as bg_db: + service = AIPMOKernelService(bg_db, tenant_id=int(tenant_id)) + await service.process_ingress( + source_system="ai_pmo_ui", + source_message_id=source_message_id, + message_text=f"继续处理 work_item #{int(work_item_id)}", + sender=str(sender or "PMFlow用户"), + channel_ref="ai_pmo_ui", + source_sender_open_id=None, + captured_at=None, + chat_type="manual", + project_hint_id=int(project_id), + ) + + +def _normalize_noise_text(text: str) -> str: + return re.sub(r"[\\s\\??!!,,。~~、]+", "", str(text or "")).strip().lower() + + +def _should_hide_work_item_from_inbox(*, row: WorkItem, ingest: IngestEvent) -> bool: + intent_name = str(row.intent or "").strip().lower() + if intent_name != FRONT_NOISE_INTENT: + return False + payload = ingest.payload if isinstance(ingest.payload, dict) else {} + message_text = str(payload.get("message_text") or "").strip() + normalized = _normalize_noise_text(message_text) + if not normalized: + return True + if any(keyword in normalized for keyword in FRONT_NOISE_STRUCTURED_HINTS): + return False + if normalized in FRONT_NOISE_EXACT: + return True + if len(normalized) <= 8 and any(keyword in normalized for keyword in FRONT_NOISE_HINTS): + return True + return False + + @router.post("/cadence/run") async def run_cadence( request: PMOCadenceRunRequest, @@ -386,6 +573,43 @@ def _compact_text(value: Any, limit: int) -> str: return f"{text[: max(limit - 1, 1)].rstrip()}…" +def _normalize_module_list(value: Any) -> list[str]: + if not isinstance(value, list): + return [] + modules: list[str] = [] + for row in value: + item = str(row or "").strip() + if not item or item in modules: + continue + modules.append(item[:50]) + return modules[:8] + + +def _is_low_value_closure_payload(payload: dict[str, Any]) -> bool: + summary = str(payload.get("closure_summary") or "").strip() + dialogue = str(payload.get("closure_dialogue") or "").strip() + modules = _normalize_module_list(payload.get("closure_modules")) + turns = payload.get("closure_turns") if isinstance(payload.get("closure_turns"), list) else [] + has_closure = bool(summary or dialogue or modules) + if not has_closure: + return True + compact_summary = re.sub(r"\s+", "", summary).lower() if summary else "" + summary_is_generic = bool(compact_summary and GENERIC_SOURCE_TEXT_RE.match(compact_summary)) + weak_summary = bool(summary and any(token in summary for token in LOW_VALUE_CLOSURE_HINTS)) + has_assistant_turn = any( + str(item.get("role") or "").strip().lower() == "assistant" and str(item.get("text") or "").strip() + for item in turns + if isinstance(item, dict) + ) + if weak_summary and not has_assistant_turn: + return True + if summary_is_generic and not modules and not has_assistant_turn: + return True + if dialogue and "机器人:" not in dialogue and len(turns) <= 1 and weak_summary: + return True + return False + + def _priority_from_review(review_summary: Any) -> tuple[str, int]: payload = review_summary if isinstance(review_summary, dict) else {} risk_score = float(payload.get("risk_score_after") or 0.0) @@ -433,20 +657,188 @@ def _stage_text(state: str) -> str: return mapping.get(str(state or "").strip(), str(state or "").strip() or "-") -def _serialize_work_item_summary(*, row: WorkItem, ingest: IngestEvent, project: Optional[Project]) -> dict[str, Any]: - payload = ingest.payload if isinstance(ingest.payload, dict) else {} +def _normalize_approval_requirement(value: Any) -> Optional[dict[str, Any]]: + if not isinstance(value, dict): + return None + reason = str(value.get("reason") or value.get("policy_gate_code") or "").strip() or None + message = str(value.get("message") or value.get("hint") or value.get("detail") or "").strip() or None + return { + **value, + "required": bool(value.get("required")), + "reason": reason, + "message": message, + } + + +def _extract_gate_projection_from_work_item(row: WorkItem) -> dict[str, Any]: + execution_result = row.execution_result if isinstance(row.execution_result, dict) else {} + review_summary = row.review_summary if isinstance(row.review_summary, dict) else {} + policy_gate_code = ( + str( + execution_result.get("policy_gate_code") + or execution_result.get("gate_code") + or review_summary.get("policy_gate_code") + or review_summary.get("gate_code") + or "" + ).strip() + or None + ) + approval_requirement = _normalize_approval_requirement( + execution_result.get("approval_requirement") + if isinstance(execution_result.get("approval_requirement"), dict) + else review_summary.get("approval_requirement") + ) + return { + "latest_policy_gate_code": policy_gate_code, + "approval_requirement": approval_requirement, + } + + +async def _build_latest_decision_projection( + *, + db: AsyncSession, + tenant_id: int, + work_item_ids: list[int], +) -> dict[int, dict[str, Any]]: + if not work_item_ids: + return {} + rows = ( + await db.execute( + select(DecisionRecord) + .where( + and_( + DecisionRecord.tenant_id == tenant_id, + DecisionRecord.work_item_id.in_(work_item_ids), + ) + ) + .order_by(DecisionRecord.work_item_id.asc(), DecisionRecord.id.desc()) + ) + ).scalars().all() + + projection: dict[int, dict[str, Any]] = {} + for row in rows: + work_item_id = _safe_int(row.work_item_id) + if work_item_id is None or work_item_id in projection: + continue + projection[work_item_id] = { + "latest_policy_gate_code": str(row.policy_gate_code or "").strip() or None, + "approval_requirement": _normalize_approval_requirement(row.approval_requirement), + } + return projection + + +async def _build_closure_payload_overrides( + *, + db: AsyncSession, + tenant_id: int, + rows: list[tuple[WorkItem, IngestEvent, Optional[Project]]], +) -> dict[int, dict[str, Any]]: + if not rows: + return {} + service = AIPMOKernelService(db, tenant_id=tenant_id) + overrides: dict[int, dict[str, Any]] = {} + for row, ingest, _project in rows: + payload = ingest.payload if isinstance(ingest.payload, dict) else {} + if not isinstance(payload, dict): + continue + has_closure = bool( + str(payload.get("closure_summary") or "").strip() + or str(payload.get("closure_dialogue") or "").strip() + or (isinstance(payload.get("closure_modules"), list) and payload.get("closure_modules")) + ) + if has_closure and not _is_low_value_closure_payload(payload): + continue + message_text = str(payload.get("message_text") or "").strip() + if not message_text: + continue + source_system = str(row.source_system or "").strip() or "pmflow_manual" + captured_at_seed: Optional[str] = str(payload.get("captured_at") or "").strip() or None + if not captured_at_seed and isinstance(row.created_at, datetime): + captured_at_seed = row.created_at.isoformat() + closure_payload = await service._build_message_closure_payload( + source_system=source_system, + channel_ref=str(payload.get("channel_ref") or "").strip() or None, + sender=str(payload.get("sender") or "").strip(), + source_sender_open_id=str(payload.get("source_sender_open_id") or "").strip() or None, + message_text=message_text, + source_message_id=str(row.source_message_id or "").strip() or None, + owner_user_id=_safe_int(row.owner_user_id), + project_id=_safe_int(row.project_id), + captured_at=captured_at_seed, + ) + if not any( + ( + str(closure_payload.get("closure_summary") or "").strip(), + str(closure_payload.get("closure_dialogue") or "").strip(), + isinstance(closure_payload.get("closure_modules"), list) and closure_payload.get("closure_modules"), + ) + ): + continue + overrides[int(row.id)] = { + **payload, + "closure_summary": closure_payload.get("closure_summary"), + "closure_dialogue": closure_payload.get("closure_dialogue"), + "closure_modules": closure_payload.get("closure_modules"), + "closure_turns": closure_payload.get("closure_turns"), + } + return overrides + + +def _serialize_work_item_summary( + *, + row: WorkItem, + ingest: IngestEvent, + project: Optional[Project], + latest_decision_projection: Optional[dict[str, Any]] = None, + payload_override: Optional[dict[str, Any]] = None, +) -> dict[str, Any]: + payload = payload_override if isinstance(payload_override, dict) else ( + ingest.payload if isinstance(ingest.payload, dict) else {} + ) message_text = str(payload.get("message_text") or "").strip() - summary = _compact_text(message_text or _next_step_text(row) or row.intent or "AI PMO 工作项", 180) - title = _compact_text(message_text or f"AI PMO · {row.intent or 'work_item'}", 64) + closure_dialogue = str(payload.get("closure_dialogue") or "").strip() or None + closure_summary = str(payload.get("closure_summary") or "").strip() or None + closure_modules = _normalize_module_list(payload.get("closure_modules")) + closure_turns = payload.get("closure_turns") if isinstance(payload.get("closure_turns"), list) else [] + source_captured_at = str(payload.get("captured_at") or "").strip() or None + source_sender = str(payload.get("sender") or "").strip() or None + source_channel_ref = str(payload.get("channel_ref") or "").strip() or None + summary_seed = closure_summary or message_text or _next_step_text(row) or row.intent or "AI PMO 工作项" + summary = _compact_text(summary_seed, 180) + compact_message = re.sub(r"\s+", "", message_text).lower() if message_text else "" + prefer_closure_title = bool(compact_message and GENERIC_SOURCE_TEXT_RE.match(compact_message)) + title_seed = ( + (closure_summary or message_text) + if prefer_closure_title + else (message_text or closure_summary or f"AI PMO · {row.intent or 'work_item'}") + ) + title = _compact_text(title_seed, 64) priority, priority_rank = _priority_from_review(row.review_summary) project_name = None if project is not None: project_name = str(project.title or project.name or "").strip() or None project_id = _safe_int(row.project_id) + decision_projection = latest_decision_projection if isinstance(latest_decision_projection, dict) else {} + latest_policy_gate_code = str(decision_projection.get("latest_policy_gate_code") or "").strip() or None + approval_requirement = _normalize_approval_requirement(decision_projection.get("approval_requirement")) + if latest_policy_gate_code is None or approval_requirement is None: + fallback_projection = _extract_gate_projection_from_work_item(row) + if latest_policy_gate_code is None: + latest_policy_gate_code = str(fallback_projection.get("latest_policy_gate_code") or "").strip() or None + if approval_requirement is None: + approval_requirement = _normalize_approval_requirement(fallback_projection.get("approval_requirement")) return { "id": int(row.id), "source_system": row.source_system, "source_message_id": row.source_message_id, + "source_message_text": _compact_text(message_text, 600) or None, + "source_conversation_closure": _compact_text(closure_dialogue, 2000) or None, + "source_captured_at": source_captured_at, + "source_sender": source_sender, + "source_channel_ref": source_channel_ref, + "closure_summary": closure_summary, + "implementation_modules": closure_modules, + "source_closure_turns": closure_turns[:4], "state": row.state, "intent": row.intent, "decision_level": row.decision_level, @@ -462,6 +854,8 @@ def _serialize_work_item_summary(*, row: WorkItem, ingest: IngestEvent, project: "stage_text": _stage_text(str(row.state or "")), "priority": priority, "priority_rank": priority_rank, + "latest_policy_gate_code": latest_policy_gate_code, + "approval_requirement": approval_requirement, "target_url": f"/projects/{project_id}/board" if project_id is not None else None, "review_summary": row.review_summary, "execution_result": row.execution_result, diff --git a/backend/app/api/sstc.py b/backend/app/api/sstc.py index fb320d22..a922aa86 100644 --- a/backend/app/api/sstc.py +++ b/backend/app/api/sstc.py @@ -83,6 +83,9 @@ from ..utils.timezone import CN_TZ, now_cn_aware, now_cn_naive router = APIRouter(tags=["sstc"]) _FEISHU_PLACEHOLDER_RE = re.compile(r"^飞书用户\(([^)]+)\)$") _SIGNAL_PREFIX_RE = re.compile(r"^\[(.*?)\]\s*:\s*") +_GENERIC_SIGNAL_SOURCE_TITLE_RE = re.compile( + r"(加入到信号源|加入信号源|新增(?:一个)?信号源|创建(?:一个)?信号源|添加(?:一个)?信号源)" +) DEFAULT_SIGNAL_AUTO_ENTER_THRESHOLD = 0.80 DEFAULT_PROJECT_AUTO_CREATE_THRESHOLD = 0.85 @@ -519,7 +522,21 @@ def _extract_sender_from_content(content: str) -> Optional[str]: def _signal_title_text(signal: Signal) -> str: - return _strip_signal_sender_prefix(str(signal.content or "")) + for candidate in ( + str(signal.title or "").strip(), + str(signal.summary or "").strip(), + _strip_signal_sender_prefix(str(signal.content or "")), + ): + compacted = compact_title(candidate, max_len=80) + if not compacted: + continue + normalized = re.sub(r"\s+", "", compacted).lower() + if _GENERIC_SIGNAL_SOURCE_TITLE_RE.search(normalized): + specific_hints = ("chat_id", "http", "https", "飞书", "企业微信", "github", "gitlab", "jira", "webhook", "项目", "群聊", "repo") + if not any(hint in normalized for hint in specific_hints): + return "信号源接入请求(待补充参数)" + return compacted + return "" def _signal_proposer_name(signal: Signal) -> str: diff --git a/backend/app/services/ai_pmo_kernel_service.py b/backend/app/services/ai_pmo_kernel_service.py index b1d8270a..d99eaf18 100644 --- a/backend/app/services/ai_pmo_kernel_service.py +++ b/backend/app/services/ai_pmo_kernel_service.py @@ -31,6 +31,7 @@ from ..models import ( Project, ProjectDomain, ProjectSignalLink, + QARecord, ReplyReceipt, Signal, Task, @@ -152,12 +153,31 @@ SMALLTALK_EXACT = { "thx", "在吗", "在不在", + "怎么样了", + "咋样了", + "辛苦了", } +SMALLTALK_SHORT_HINTS = ("怎么样了", "咋样了", "在吗", "辛苦了", "收到", "谢谢", "ok") +SMALLTALK_STRUCTURED_HINTS = ("项目", "信号", "任务", "风险", "进度", "状态", "work_item", "工作项", "#") TRANSPORT_MODE_CORRECTION_HINTS = ("不是", "不再", "改为", "改成", "替换为", "取代", "现在都", "现在是") FOLLOWUP_CONFIRM_HINTS = ("确认归属", "确认项目", "确认到项目", "确认关联", "确认这条") FOLLOWUP_CONTINUE_HINTS = ("继续处理", "继续执行", "接着处理", "往下做", "继续推进") FOLLOWUP_WORK_ITEM_ID_RE = re.compile(r"(?:work[_ -]?item|工作项|队列项)\s*(?:id|ID)?\s*[:#:]?\s*(\d+)", re.IGNORECASE) SIGNAL_CREATE_HINTS = ("新增一个信号", "新增一条信号", "增加一个信号", "录入一个信号", "关注", "跟踪") +GENERIC_SIGNAL_SOURCE_MESSAGE_RE = re.compile( + r"^(?:请)?(?:按|按照)?(?:这个|上述|这条)?(?:分步|步骤)?(?:骤)?(?:把|将)?(?:它|该需求|这条需求)?(?:加入|接入|录入).{0,24}信号源(?:中)?$", + re.IGNORECASE, +) +MODULE_KEYWORD_RULES: tuple[tuple[str, tuple[str, ...]], ...] = ( + ("用户管理与权限", ("用户管理", "角色权限", "crud", "批量导入", "成员管理")), + ("技能图谱", ("技能图谱", "技能标签", "能力图谱", "雷达图")), + ("工作量管理", ("工作量", "负载", "产能", "workload")), + ("SSTC 工作流", ("sstc", "charter", "signal", "task", "execution", "release", "review", "工作流")), + ("AI 能力编排", ("ai能力", "智能", "自动化", "推荐", "编排", "agent", "llm")), + ("信号源接入", ("信号源", "source", "onboard", "接入")), + ("飞书集成", ("飞书", "feishu", "群聊", "机器人")), + ("消息台与详情页", ("消息台", "详情页", "展开详情", "摘要", "时间戳", "按钮")), +) PROJECT_CREATE_HINTS = ( "创建项目", "新建项目", @@ -379,7 +399,7 @@ class IntentResolver: pretty = re.sub(r"\s+", " ", str(text or "")).strip() if not pretty: return CanonicalIntent(INTENT_IGNORE, 1.0, project_id, "", "", {"reason": "empty"}) - if normalized in SMALLTALK_EXACT: + if IntentResolver._is_smalltalk_message(pretty=pretty, normalized=normalized): return CanonicalIntent(INTENT_IGNORE, 0.95, project_id, "", "", {"reason": "smalltalk"}) if any(token in pretty for token in FOLLOWUP_CONFIRM_HINTS): return CanonicalIntent(INTENT_FOLLOWUP_CONFIRM, 0.82, project_id, "", "", {}) @@ -433,6 +453,19 @@ class IntentResolver: {"fallback": True, "reason": "default_query_fallback"}, ) + @staticmethod + def _is_smalltalk_message(*, pretty: str, normalized: str) -> bool: + compact = re.sub(r"[\\s\\??!!,,。~~、]+", "", str(pretty or "")).strip().lower() + if not compact: + return True + if any(keyword in compact for keyword in SMALLTALK_STRUCTURED_HINTS): + return False + if compact in SMALLTALK_EXACT: + return True + if len(compact) <= 8 and any(keyword in compact for keyword in SMALLTALK_SHORT_HINTS): + return True + return False + @staticmethod def _looks_like_query(*, text: str) -> bool: normalized = re.sub(r"\s+", "", str(text or "")).lower() @@ -892,6 +925,71 @@ class TypedExecutor: self.tenant_id = int(tenant_id) self.signal_source_workflow = SignalSourceDomainWorkflow(db) + @staticmethod + def _normalize_short_text(value: Any, *, max_len: int) -> str: + text = re.sub(r"\s+", " ", str(value or "")).strip() + if not text: + return "" + return text[:max_len] + + @staticmethod + def _is_generic_signal_source_title(value: str) -> bool: + compact = re.sub(r"\s+", "", str(value or "")).strip().lower() + if not compact: + return True + if bool(GENERIC_SIGNAL_SOURCE_MESSAGE_RE.match(compact)): + return True + return compact in {"信号源接入请求", "新增信号源接入请求", "新增信号源"} + + @classmethod + def _build_signal_source_semantic_payload( + cls, + *, + intent: CanonicalIntent, + message_text: str, + ) -> dict[str, Any]: + metadata = intent.metadata if isinstance(intent.metadata, dict) else {} + modules = metadata.get("closure_modules") if isinstance(metadata.get("closure_modules"), list) else [] + normalized_modules = [str(item).strip() for item in modules if str(item).strip()][:6] + summary = cls._normalize_short_text( + metadata.get("closure_summary") or intent.signal_summary or message_text, + max_len=300, + ) + dialogue = cls._normalize_short_text( + metadata.get("closure_dialogue") or message_text, + max_len=1800, + ) + title_seed = cls._normalize_short_text(intent.signal_title or "信号源接入请求", max_len=120) + if cls._is_generic_signal_source_title(title_seed): + if normalized_modules: + title_seed = f"信号源接入 · {normalized_modules[0]}"[:120] + else: + title_seed = "信号源接入请求" + reasoning_bullets = ["由 AI PMO kernel 统一入库。"] + if normalized_modules: + reasoning_bullets.append(f"闭环提炼模块:{'、'.join(normalized_modules[:4])}") + if summary: + reasoning_bullets.append(f"闭环摘要:{summary[:120]}") + strategic_analysis = { + "source_context": { + "conversation_closure": dialogue or None, + "implementation_modules": normalized_modules, + }, + "ai_analysis": { + "summary": summary or None, + "intent": INTENT_SIGNAL_SOURCE_ONBOARD, + "requirement_project_scope": "existing_project" if intent.project_id is not None else "pending_classification", + }, + } + return { + "title": title_seed, + "summary": summary or cls._normalize_short_text(message_text, max_len=300), + "content": dialogue or cls._normalize_short_text(message_text, max_len=1800), + "modules": normalized_modules, + "reasoning_bullets": reasoning_bullets, + "strategic_analysis": strategic_analysis, + } + async def execute( self, *, @@ -946,14 +1044,28 @@ class TypedExecutor: } if intent.name == INTENT_SIGNAL_SOURCE_ONBOARD: + semantic_payload = self._build_signal_source_semantic_payload( + intent=intent, + message_text=message_text, + ) signal = await self._create_signal( - title=intent.signal_title or "新增信号源接入请求", - summary=intent.signal_summary or message_text, - content=message_text, + title=str(semantic_payload.get("title") or intent.signal_title or "新增信号源接入请求"), + summary=str(semantic_payload.get("summary") or intent.signal_summary or message_text), + content=str(semantic_payload.get("content") or message_text), project_id=intent.project_id, source="PMFlow", signal_type="新需求", recommended_action="enter_only", + reasoning_bullets=( + semantic_payload.get("reasoning_bullets") + if isinstance(semantic_payload.get("reasoning_bullets"), list) + else None + ), + strategic_analysis=( + semantic_payload.get("strategic_analysis") + if isinstance(semantic_payload.get("strategic_analysis"), dict) + else None + ), ) domain_workflow = await self.signal_source_workflow.run( tenant_id=self.tenant_id, @@ -1007,6 +1119,8 @@ class TypedExecutor: source: str, signal_type: str, recommended_action: str, + reasoning_bullets: Optional[list[str]] = None, + strategic_analysis: Optional[dict[str, Any]] = None, ) -> Signal: signal = Signal( tenant_id=self.tenant_id, @@ -1015,7 +1129,8 @@ class TypedExecutor: summary=(summary or content or "")[:300], content=(content or summary or "")[:2000], signal_type=signal_type, - reasoning=json.dumps(["由 AI PMO kernel 统一入库。"], ensure_ascii=False), + reasoning=json.dumps(reasoning_bullets or ["由 AI PMO kernel 统一入库。"], ensure_ascii=False), + strategic_analysis=(json.dumps(strategic_analysis, ensure_ascii=False) if isinstance(strategic_analysis, dict) else None), confidence=0.78, recommended_action=recommended_action, matched_project_id=int(project_id) if project_id is not None else None, @@ -1173,6 +1288,16 @@ class AIPMOKernelService: sender=sender, message_text=clean_text, ) + closure_payload = await self._build_message_closure_payload( + source_system=source_system, + channel_ref=channel_ref, + sender=sender, + source_sender_open_id=source_sender_open_id, + message_text=clean_text, + source_message_id=synthetic_message_id, + captured_at=captured_at, + project_id=project_hint_id, + ) ingest_event, created = await self._claim_ingress_event( source_system=source_system, @@ -1184,6 +1309,10 @@ class AIPMOKernelService: "chat_type": chat_type, "captured_at": captured_at.isoformat() if isinstance(captured_at, datetime) else None, "message_text": clean_text, + "closure_dialogue": closure_payload.get("closure_dialogue"), + "closure_summary": closure_payload.get("closure_summary"), + "closure_modules": closure_payload.get("closure_modules"), + "closure_turns": closure_payload.get("closure_turns"), }, ) if not created: @@ -1231,6 +1360,11 @@ class AIPMOKernelService: session=session, world_model=world_model, ) + intent = self._enrich_signal_source_intent_from_closure( + intent=intent, + message_text=clean_text, + closure_payload=closure_payload, + ) perf_intent_name = intent.name _log_perf("_resolve_intent", stage_started_at) if intent_context_source: @@ -3104,6 +3238,15 @@ class AIPMOKernelService: signal_summary=rebuilt_intent.signal_summary, metadata=dict(rebuilt_intent.metadata), ) + target_intent = self._enrich_signal_source_intent_from_closure( + intent=target_intent, + message_text=original_message, + closure_payload={ + "closure_summary": payload.get("closure_summary"), + "closure_dialogue": payload.get("closure_dialogue"), + "closure_modules": payload.get("closure_modules"), + }, + ) if target_intent.name not in FOLLOWUP_TARGET_INTENTS: reply_text = f"工作项#{int(target_work_item.id)} 当前类型为 {target_intent.name or 'unknown'},暂不支持继续处理。" return { @@ -3142,6 +3285,19 @@ class AIPMOKernelService: execution_result=target_execution_result, world_model=target_work_item.context_snapshot if isinstance(target_work_item.context_snapshot, dict) else world_model, ) + target_issues = target_verification.get("issues") if isinstance(target_verification.get("issues"), list) else [] + if ( + not bool(target_verification.get("verified")) + and target_intent.name == INTENT_SIGNAL_SOURCE_ONBOARD + and any(issue in {"signal_source_pending_manual", "pending_manual"} for issue in target_issues) + and _safe_int(target_execution_result.get("signal_id")) is not None + and _safe_int(target_work_item.project_id) is not None + ): + target_execution_result["post_process_status"] = str(target_execution_result.get("status") or "").strip() or "pending_manual" + target_execution_result["status"] = "succeeded" + target_execution_result["manual_gate_resolved"] = True + target_verification = {"verified": True, "issues": []} + target_issues = [] target_work_item.review_summary = await self.review_service.review_work_item( work_item=target_work_item, intent_name=target_intent.name, @@ -3162,7 +3318,6 @@ class AIPMOKernelService: target_work_item.state = STATE_SUCCEEDED await self._project_success(intent=target_intent, execution_result=target_execution_result) else: - target_issues = target_verification.get("issues") if isinstance(target_verification.get("issues"), list) else [] if any(issue in {"pending_manual", "signal_source_pending_manual"} for issue in target_issues): target_work_item.state = STATE_WAITING_MANUAL else: @@ -3488,6 +3643,430 @@ class AIPMOKernelService: labels = [f"工作项#{int(item.id)}({str(item.intent or '').strip() or 'unknown'})" for item in items[:3]] return f"当前存在多个待确认工作项:{'、'.join(labels)}。请补充工作项 ID 后再继续处理。" + @staticmethod + def _build_feishu_rag_conversation_key( + *, + source_chat_id: Optional[str], + source_sender_open_id: Optional[str], + sender: Optional[str], + ) -> str: + chat = str(source_chat_id or "").strip() + if not chat: + return "" + actor = str(source_sender_open_id or "").strip() + if not actor: + actor = str(sender or "").strip().lower() + actor = re.sub(r"\s+", "", actor) + actor = actor[:96] if actor else "unknown" + return f"feishu:{chat}:{actor}" + + @staticmethod + def _normalize_dialogue_text(value: Any, *, max_len: int) -> str: + text = re.sub(r"\s+", " ", str(value or "")).strip() + if not text: + return "" + return text[:max_len] + + @staticmethod + def _parse_datetime_like(value: Any) -> Optional[datetime]: + if isinstance(value, datetime): + dt = value + else: + raw = str(value or "").strip() + if not raw: + return None + try: + dt = datetime.fromisoformat(raw.replace("Z", "+00:00")) + except Exception: + return None + if dt.tzinfo is not None: + return dt.astimezone(timezone.utc).replace(tzinfo=None) + return dt + + @staticmethod + def _normalize_feishu_user_text(value: Any, *, max_len: int) -> str: + text = str(value or "") + text = re.sub(r"^@_user_\d+\s*", "", text) + text = re.sub(r"]*>", " ", text, flags=re.IGNORECASE) + text = re.sub(r"\s+", " ", text).strip() + if not text: + return "" + return text[:max_len] + + @classmethod + def _extract_assistant_text_from_log_payload(cls, payload: Any) -> str: + if not isinstance(payload, dict): + return "" + candidates = ( + payload.get("followup_reply"), + payload.get("direct_reply_text"), + payload.get("clarification_message"), + payload.get("answer"), + payload.get("reason"), + payload.get("result_summary"), + payload.get("error_message"), + payload.get("message"), + ) + for item in candidates: + text = cls._normalize_dialogue_text(item, max_len=420) + if not text: + continue + if any(token in text for token in ("chat_id", "message_type", "sender_name")) and len(text) < 48: + continue + return text + return "" + + @staticmethod + def _merge_closure_turns( + base_turns: list[dict[str, str]], + extra_turns: list[dict[str, str]], + *, + prepend: bool = False, + ) -> list[dict[str, str]]: + if not extra_turns: + return list(base_turns) + merged = list(extra_turns) + list(base_turns) if prepend else list(base_turns) + list(extra_turns) + compact: list[dict[str, str]] = [] + for row in merged: + role = str(row.get("role") or "").strip().lower() + text = str(row.get("text") or "").strip() + if role not in {"user", "assistant"} or not text: + continue + if compact and compact[-1].get("role") == role and compact[-1].get("text") == text: + continue + compact.append({"role": role, "text": text}) + return compact[-8:] + + @staticmethod + def _turns_have_assistant(turns: list[dict[str, str]]) -> bool: + return any(str(turn.get("role") or "").strip().lower() == "assistant" and str(turn.get("text") or "").strip() for turn in turns) + + async def _load_closure_turns_from_processing_logs( + self, + *, + source_message_id: Optional[str], + message_text: str, + ) -> list[dict[str, str]]: + message_id = str(source_message_id or "").strip() + if not message_id: + return [] + rows = ( + await self.db.execute( + select(AIProcessingLog) + .where( + and_( + AIProcessingLog.tenant_id == self.tenant_id, + AIProcessingLog.feishu_message_id == message_id, + ) + ) + .order_by(AIProcessingLog.id.asc()) + .limit(12) + ) + ).scalars().all() + if not rows: + return [] + normalized_message = self._normalize_dialogue_text(message_text, max_len=600) + turns: list[dict[str, str]] = [] + for row in rows: + user_text = self._normalize_feishu_user_text( + row.normalized_text or row.raw_message, + max_len=260, + ) + if user_text and (not normalized_message or user_text != normalized_message): + turns = self._merge_closure_turns(turns, [{"role": "user", "text": user_text}]) + assistant_text = self._extract_assistant_text_from_log_payload(row.operation_result) + if assistant_text: + turns = self._merge_closure_turns(turns, [{"role": "assistant", "text": assistant_text}]) + return turns[-4:] + + @classmethod + def _score_qa_row_for_closure( + cls, + *, + row: QARecord, + normalized_message: str, + reference_at: Optional[datetime], + ) -> float: + question = cls._normalize_dialogue_text(row.question, max_len=500) + answer = cls._normalize_dialogue_text(row.answer, max_len=900) + if not question or not answer: + return -1.0 + score = 0.0 + merged = f"{question} {answer}" + if "信号源" in merged: + score += 3.0 + if any(token in merged for token in ("模块", "后端", "前端", "流程", "SSTC", "任务", "执行", "接口")): + score += 2.0 + if any(token in answer for token in ("暂时无法生成可靠回答", "模型服务异常", "请稍后重试")): + score -= 5.0 + if normalized_message and cls._is_generic_signal_source_message(normalized_message): + if "信号源" in question: + score += 1.5 + if "项目" in answer: + score += 1.0 + created_at = cls._parse_datetime_like(getattr(row, "created_at", None)) + if reference_at is not None and created_at is not None: + gap_seconds = abs((reference_at - created_at).total_seconds()) + if gap_seconds > 7 * 24 * 3600: + score -= 4.0 + elif gap_seconds <= 6 * 3600: + score += 1.5 + elif gap_seconds <= 24 * 3600: + score += 0.8 + return score + + async def _load_closure_turns_from_qa_records( + self, + *, + owner_user_id: Optional[int], + project_id: Optional[int], + captured_at: Optional[datetime], + message_text: str, + ) -> list[dict[str, str]]: + uid = _safe_int(owner_user_id) + pid = _safe_int(project_id) + if uid is None and pid is None: + return [] + normalized_message = self._normalize_dialogue_text(message_text, max_len=600) + reference_at = self._parse_datetime_like(captured_at) + candidates: list[QARecord] = [] + conditions: list[Any] = [] + if uid is not None and pid is not None: + conditions.append(and_(QARecord.asked_by == int(uid), QARecord.project_id == int(pid))) + if pid is not None: + conditions.append(QARecord.project_id == int(pid)) + if uid is not None: + conditions.append(QARecord.asked_by == int(uid)) + for condition in conditions: + stmt = select(QARecord).where( + and_( + QARecord.tenant_id == self.tenant_id, + QARecord.asked_via == "feishu", + condition, + ) + ) + if reference_at is not None: + stmt = stmt.where(QARecord.created_at <= reference_at) + rows = ( + await self.db.execute( + stmt.order_by(QARecord.created_at.desc(), QARecord.id.desc()).limit(12) + ) + ).scalars().all() + candidates.extend(rows) + if rows: + break + if not candidates: + return [] + best_row: Optional[QARecord] = None + best_score = -999.0 + seen_ids: set[int] = set() + for row in candidates: + row_id = int(getattr(row, "id", 0) or 0) + if row_id > 0 and row_id in seen_ids: + continue + seen_ids.add(row_id) + score = self._score_qa_row_for_closure( + row=row, + normalized_message=normalized_message, + reference_at=reference_at, + ) + if score > best_score: + best_score = score + best_row = row + if best_row is None or best_score < 0.5: + return [] + question = self._normalize_dialogue_text(best_row.question, max_len=260) + answer = self._normalize_dialogue_text(best_row.answer, max_len=420) + if not question or not answer: + return [] + return [ + {"role": "user", "text": question}, + {"role": "assistant", "text": answer}, + ] + + @staticmethod + def _is_generic_signal_source_message(text: str) -> bool: + compact = re.sub(r"\s+", "", str(text or "")).strip().lower() + if not compact: + return False + if bool(GENERIC_SIGNAL_SOURCE_MESSAGE_RE.match(compact)): + return True + return len(compact) <= 24 and "信号源" in compact and ("这个" in compact or "步骤" in compact) + + @classmethod + def _extract_implementation_modules_from_text(cls, text: str) -> list[str]: + normalized = str(text or "").lower() + modules: list[str] = [] + for module_name, keywords in MODULE_KEYWORD_RULES: + if any(str(keyword).lower() in normalized for keyword in keywords): + modules.append(module_name) + return modules[:6] + + @staticmethod + def _build_closure_dialogue_excerpt(turns: list[dict[str, str]]) -> str: + if not turns: + return "" + lines: list[str] = [] + for turn in turns[-4:]: + role = str(turn.get("role") or "").strip().lower() + text = str(turn.get("text") or "").strip() + if not text: + continue + if role == "assistant": + lines.append(f"机器人:{text[:380]}") + else: + lines.append(f"用户:{text[:260]}") + return "\n".join(lines)[:1600] + + @classmethod + def _derive_closure_summary( + cls, + *, + turns: list[dict[str, str]], + modules: list[str], + fallback_text: str, + ) -> str: + latest_user = "" + latest_assistant = "" + for turn in reversed(turns): + role = str(turn.get("role") or "").strip().lower() + text = str(turn.get("text") or "").strip() + if not text: + continue + if role == "user" and not latest_user: + latest_user = text + if role == "assistant" and not latest_assistant: + latest_assistant = text + if latest_user and latest_assistant: + break + + if latest_assistant and any(token in latest_assistant for token in ("缺少项目归属", "不能创建", "无法创建", "先确认项目")): + if latest_user: + return f"机器人先提示条件不足,用户随后继续要求执行:{latest_user[:120]}" + return "机器人提示条件不足,需补齐项目归属后继续执行信号源接入。" + + if latest_user and cls._is_generic_signal_source_message(latest_user) and modules: + if len(modules) == 1 and str(modules[0]).strip() == "信号源接入": + return "用户要求将需求按步骤接入信号源,需要先补齐项目归属再继续执行。" + return f"同一消息闭环内,用户要求将需求按步骤接入信号源,重点模块:{'、'.join(modules[:4])}。" + + if modules: + return f"用户在本次消息闭环中推进信号源接入,涉及模块:{'、'.join(modules[:4])}。" + + if latest_user: + return latest_user[:200] + return str(fallback_text or "").strip()[:200] + + async def _build_message_closure_payload( + self, + *, + source_system: str, + channel_ref: Optional[str], + sender: str, + source_sender_open_id: Optional[str], + message_text: str, + source_message_id: Optional[str] = None, + owner_user_id: Optional[int] = None, + project_id: Optional[int] = None, + captured_at: Optional[Any] = None, + ) -> dict[str, Any]: + normalized_message = self._normalize_dialogue_text(message_text, max_len=600) + turns: list[dict[str, str]] = [] + normalized_source_system = str(source_system or "").strip().lower() + normalized_channel = str(channel_ref or "").strip() + if normalized_source_system == "feishu" and normalized_channel: + conversation_key = self._build_feishu_rag_conversation_key( + source_chat_id=normalized_channel, + source_sender_open_id=source_sender_open_id, + sender=sender, + ) + session_id = AdaptiveRAGService._external_session_id_from_key(conversation_key) + if session_id is not None: + cached_messages = await self.context_cache.get_session_messages( + tenant_id=self.tenant_id, + session_id=int(session_id), + ) + cached_turns = self.context_cache.messages_to_turns(cached_messages) + for row in cached_turns[-2:]: + question = self._normalize_dialogue_text(row.get("question"), max_len=260) + answer = self._normalize_dialogue_text(row.get("answer"), max_len=420) + if question: + turns.append({"role": "user", "text": question}) + if answer: + turns.append({"role": "assistant", "text": answer}) + + if normalized_source_system == "feishu" and not self._turns_have_assistant(turns): + log_turns = await self._load_closure_turns_from_processing_logs( + source_message_id=source_message_id, + message_text=normalized_message, + ) + turns = self._merge_closure_turns(turns, log_turns) + + needs_semantic_turns = ( + normalized_source_system == "feishu" + and ( + (not self._turns_have_assistant(turns)) + or (self._is_generic_signal_source_message(normalized_message) and len(turns) <= 2) + ) + ) + if needs_semantic_turns: + qa_turns = await self._load_closure_turns_from_qa_records( + owner_user_id=owner_user_id, + project_id=project_id, + captured_at=self._parse_datetime_like(captured_at), + message_text=normalized_message, + ) + turns = self._merge_closure_turns(turns, qa_turns, prepend=True) + + if normalized_message: + if not turns or str(turns[-1].get("text") or "").strip() != normalized_message: + turns.append({"role": "user", "text": normalized_message}) + + closure_dialogue = self._build_closure_dialogue_excerpt(turns) + modules = self._extract_implementation_modules_from_text( + " ".join(str(turn.get("text") or "") for turn in turns) + ) + closure_summary = self._derive_closure_summary( + turns=turns, + modules=modules, + fallback_text=normalized_message, + ) + return { + "closure_dialogue": closure_dialogue or None, + "closure_summary": closure_summary or None, + "closure_modules": modules, + "closure_turns": turns[-4:], + } + + @classmethod + def _enrich_signal_source_intent_from_closure( + cls, + *, + intent: CanonicalIntent, + message_text: str, + closure_payload: dict[str, Any], + ) -> CanonicalIntent: + if intent.name != INTENT_SIGNAL_SOURCE_ONBOARD: + return intent + modules = closure_payload.get("closure_modules") if isinstance(closure_payload.get("closure_modules"), list) else [] + closure_summary = cls._normalize_dialogue_text(closure_payload.get("closure_summary"), max_len=300) + closure_dialogue = cls._normalize_dialogue_text(closure_payload.get("closure_dialogue"), max_len=1600) + if closure_summary: + intent.signal_summary = closure_summary + elif not str(intent.signal_summary or "").strip(): + intent.signal_summary = cls._normalize_dialogue_text(message_text, max_len=300) + if cls._is_generic_signal_source_message(str(intent.signal_title or "")): + if modules: + intent.signal_title = f"信号源接入 · {str(modules[0])[:60]}"[:120] + else: + intent.signal_title = "信号源接入请求" + metadata = dict(intent.metadata or {}) + metadata["closure_summary"] = closure_summary or None + metadata["closure_dialogue"] = closure_dialogue or None + metadata["closure_modules"] = [str(item) for item in modules[:6] if str(item).strip()] + intent.metadata = metadata + return intent + @staticmethod def _build_source_message_id( *, diff --git a/backend/app/services/signal_source_orchestration_service.py b/backend/app/services/signal_source_orchestration_service.py index 7febc3a8..9aa00931 100644 --- a/backend/app/services/signal_source_orchestration_service.py +++ b/backend/app/services/signal_source_orchestration_service.py @@ -853,10 +853,11 @@ class SignalSourceOrchestrationService: }, last_signal_at=_utc_now_naive(), ) - self.db.add(row) try: - await self.db.flush() - await self.db.commit() + # 只回滚本次插入尝试,避免误回滚外层事务里已创建的 signal 记录。 + async with self.db.begin_nested(): + self.db.add(row) + await self.db.flush() await self.db.refresh(row) return { "status": "created", @@ -867,7 +868,8 @@ class SignalSourceOrchestrationService: "description": str(row.description or ""), } except IntegrityError: - await self.db.rollback() + if row in self.db: + self.db.expunge(row) existing = ( await self.db.execute( select(SignalSourceRegistry).where( @@ -895,7 +897,7 @@ class SignalSourceOrchestrationService: existing.status = "pending" existing.onboarding_status = "pending" existing.last_signal_at = _utc_now_naive() - await self.db.commit() + await self.db.flush() return { "status": "reused", "registry_id": int(existing.id), @@ -928,16 +930,50 @@ class SignalSourceOrchestrationService: try: from .openclaw_commander_service import OpenClawCommanderService - result = await OpenClawCommanderService(self.db).dispatch_command( - tenant_id=tenant_id, - operator_user=operator_user, - project_id=int(project.id), - instruction=instruction, - dry_run=dry_run, - max_operations=6, - response_mode=response_mode, - persist_run=False, - ) + session_factory = getattr(type(self), "session_factory", AsyncSessionLocal) + async with session_factory() as isolated_db: + isolated_project = ( + await isolated_db.execute( + select(Project).where( + and_( + Project.tenant_id == int(tenant_id), + Project.id == int(project.id), + ) + ) + ) + ).scalar_one_or_none() + if isolated_project is None: + return { + "status": "skipped", + "reason": "project_not_found", + } + isolated_operator = ( + await isolated_db.execute( + select(User).where( + and_( + User.tenant_id == int(tenant_id), + User.id == int(operator_user.id), + User.is_active.is_(True), + ) + ) + ) + ).scalar_one_or_none() + if isolated_operator is None: + return { + "status": "skipped", + "reason": "no_manager_operator", + } + result = await OpenClawCommanderService(isolated_db).dispatch_command( + tenant_id=tenant_id, + operator_user=isolated_operator, + project_id=int(isolated_project.id), + instruction=instruction, + dry_run=dry_run, + max_operations=6, + response_mode=response_mode, + persist_run=False, + ) + await isolated_db.commit() return { "status": "planned" if dry_run else "completed", "result": result, diff --git a/backend/tests/test_ai_pmo_kernel_service.py b/backend/tests/test_ai_pmo_kernel_service.py index 11d3d5a7..d76bc47a 100644 --- a/backend/tests/test_ai_pmo_kernel_service.py +++ b/backend/tests/test_ai_pmo_kernel_service.py @@ -1,3 +1,4 @@ +import json import os import unittest from datetime import datetime @@ -15,6 +16,8 @@ from app.models import ( PortfolioSnapshot, Project, ProjectDomain, + Signal, + SignalSourceRegistry, Task, TruthNode, User, @@ -196,6 +199,13 @@ class AIPMOKernelServiceTest(unittest.IsolatedAsyncioTestCase): ) self.assertEqual(intent.name, INTENT_PROJECT_CREATE) + def test_intent_resolver_filters_smalltalk_phrase(self) -> None: + intent = IntentResolver.resolve( + text="怎么样了?", + project_id=None, + ) + self.assertEqual(intent.name, "ignore") + async def test_build_world_model_includes_budget_capacity_and_skill_graph(self) -> None: async with self.session_factory() as db: service = AIPMOKernelService(db, tenant_id=1) @@ -336,6 +346,52 @@ class AIPMOKernelServiceTest(unittest.IsolatedAsyncioTestCase): self.assertEqual(session_row.last_intent, INTENT_SIGNAL_SOURCE_ONBOARD) self.assertEqual(int(session_row.current_project_id or 0), 1) + async def test_signal_source_execute_persists_closure_summary_and_modules(self) -> None: + async with self.session_factory() as db: + service = AIPMOKernelService(db, tenant_id=1) + intent = service._build_canonical_intent( + intent_name=INTENT_SIGNAL_SOURCE_ONBOARD, + message_text="按照这个分步骤加入到信号源中", + project_id=1, + confidence=0.93, + metadata={ + "closure_summary": "同一消息闭环内,用户要求将需求按步骤接入信号源,重点模块:用户管理与权限、SSTC 工作流。", + "closure_dialogue": "机器人:当前无法直接创建,请先确认项目。\n用户:按照这个分步骤加入到信号源中", + "closure_modules": ["用户管理与权限", "SSTC 工作流"], + }, + ) + with patch.object( + SignalSourceDomainWorkflow, + "run", + AsyncMock(return_value={"status": "completed", "raw_result": {}}), + ): + result = await service.executor.execute( + intent=intent, + message_text="按照这个分步骤加入到信号源中", + sender="老板", + operator_user_id=1, + source_system="feishu", + source_message_id="semantic-signal-source-1", + ) + + signal_row = ( + await db.execute( + select(Signal).where( + and_( + Signal.tenant_id == 1, + Signal.id == int(result["signal_id"]), + ) + ) + ) + ).scalar_one() + + strategic = json.loads(str(signal_row.strategic_analysis or "{}") or "{}") + source_context = strategic.get("source_context") if isinstance(strategic, dict) else {} + self.assertEqual(signal_row.title, "信号源接入 · 用户管理与权限") + self.assertIn("重点模块:用户管理与权限", str(signal_row.summary or "")) + self.assertIn("机器人:当前无法直接创建", str(signal_row.content or "")) + self.assertEqual(source_context.get("implementation_modules"), ["用户管理与权限", "SSTC 工作流"]) + async def test_project_id_inherits_from_recent_session_when_message_has_no_project_text(self) -> None: async with self.session_factory() as db: service = AIPMOKernelService(db, tenant_id=1) @@ -1028,6 +1084,98 @@ class AIPMOKernelServiceTest(unittest.IsolatedAsyncioTestCase): self.assertEqual(followup["status"], "answered") self.assertIn("已继续处理", followup["direct_reply_text"]) + async def test_followup_resume_marks_signal_source_pending_manual_as_succeeded_when_gate_resolved(self) -> None: + async with self.session_factory() as db: + service = AIPMOKernelService(db, tenant_id=1) + with patch( + "app.services.ai_pmo_kernel_service.SignalSourceDomainWorkflow.run", + AsyncMock( + return_value={ + "status": "pending_manual", + "registry_id": 12, + "typed_actions": [ + { + "operation_code": "notification.create", + "status": "applied", + "payload": {"title": "待确认"}, + } + ], + "material_action_count": 0, + "fallback_used": True, + "raw_result": { + "signal_source": { + "registry_id": 12, + "status": "pending", + "onboarding_status": "pending_manual", + }, + "openclaw": { + "status": "completed", + "result": { + "execution": { + "results": [ + { + "operation_code": "notification.create", + "status": "applied", + "payload": {"title": "待确认"}, + } + ] + } + }, + }, + "worker_executions": [], + "pending_workers": [], + }, + } + ), + ): + first = await service.process_ingress( + source_system="feishu", + source_message_id="feishu-source-pending-manual-1", + message_text="给项目ID: 1 新增一个 GitHub 信号源,监控 openai/codex。", + sender="老板", + channel_ref="oc_phase3_resume_pending", + source_sender_open_id="ou_boss", + project_hint_id=1, + ) + target_id = int(first["work_item_id"]) + followup = await service.process_ingress( + source_system="ai_pmo_ui", + source_message_id="resume-ui-pending-manual-1", + message_text=f"继续处理 work_item #{target_id}", + sender="老板", + channel_ref="ai_pmo_ui", + project_hint_id=1, + ) + original = ( + await db.execute( + select(WorkItem).where( + and_( + WorkItem.tenant_id == 1, + WorkItem.source_message_id == "feishu-source-pending-manual-1", + ) + ) + ) + ).scalar_one() + followup_item = ( + await db.execute( + select(WorkItem).where( + and_( + WorkItem.tenant_id == 1, + WorkItem.source_message_id == "resume-ui-pending-manual-1", + ) + ) + ) + ).scalar_one() + + self.assertEqual(first["status"], "captured") + self.assertEqual(followup["status"], "answered") + self.assertIn("已继续处理", followup["direct_reply_text"]) + self.assertEqual(original.state, "succeeded") + self.assertEqual(original.execution_result.get("status"), "succeeded") + self.assertTrue(bool(original.execution_result.get("manual_gate_resolved"))) + self.assertEqual(original.execution_result.get("post_process_status"), "pending_manual") + self.assertEqual(followup_item.state, "succeeded") + def test_signal_source_domain_workflow_keeps_notification_only_pending_manual(self) -> None: normalized = SignalSourceDomainWorkflow.normalize( { @@ -1116,3 +1264,157 @@ class AIPMOKernelServiceTest(unittest.IsolatedAsyncioTestCase): self.assertEqual(result["source_kind"], "feishu") self.assertNotEqual(result["source_name"], "给项目ID") self.assertTrue(str(result["source_identifier"]).startswith("feishu:")) + + async def test_signal_source_registry_reuse_does_not_rollback_new_signal_in_outer_tx(self) -> None: + async with self.session_factory() as db: + registry_service = SignalSourceOrchestrationService(db) + source_payload = { + "source_kind": "github", + "source_name": "openai/codex", + "source_identifier": "github:openai/codex", + "description": "监控仓库动态", + "config": {"repo": "openai/codex"}, + } + created = await registry_service._create_or_reuse_registry( + tenant_id=1, + project_id=1, + signal_id=None, + operator_user_id=1, + requested_via="feishu", + source_payload=source_payload, + initiating_text="新增 GitHub 信号源 openai/codex", + dry_run=False, + ) + signal = Signal( + tenant_id=1, + source="PMFlow", + title="重复信号源续跑校验", + summary="用于校验事务回滚边界", + content="用于校验事务回滚边界", + signal_type="新需求", + reasoning="[]", + confidence=0.78, + recommended_action="enter_only", + matched_project_id=1, + impact_score=0.7, + priority="medium", + processed=False, + tasks_generated="[]", + status="待处理", + ) + db.add(signal) + await db.flush() + reused = await registry_service._create_or_reuse_registry( + tenant_id=1, + project_id=1, + signal_id=int(signal.id), + operator_user_id=1, + requested_via="ai_pmo_ui", + source_payload=source_payload, + initiating_text="继续处理同一信号源", + dry_run=False, + ) + persisted_signal = ( + await db.execute( + select(Signal).where( + and_( + Signal.tenant_id == 1, + Signal.id == int(signal.id), + ) + ) + ) + ).scalar_one_or_none() + registry_row = ( + await db.execute( + select(SignalSourceRegistry).where( + and_( + SignalSourceRegistry.tenant_id == 1, + SignalSourceRegistry.project_id == 1, + SignalSourceRegistry.source_kind == "github", + SignalSourceRegistry.source_identifier == "github:openai/codex", + ) + ) + ) + ).scalar_one() + await db.commit() + + self.assertEqual(created["status"], "created") + self.assertEqual(reused["status"], "reused") + self.assertIsNotNone(persisted_signal) + self.assertEqual(int(registry_row.signal_id or 0), int(signal.id)) + + async def test_dispatch_openclaw_isolated_session_does_not_rollback_outer_signal(self) -> None: + async with self.session_factory() as db: + service = SignalSourceOrchestrationService(db) + project = ( + await db.execute( + select(Project).where( + and_( + Project.tenant_id == 1, + Project.id == 1, + ) + ) + ) + ).scalar_one() + operator = ( + await db.execute( + select(User).where( + and_( + User.tenant_id == 1, + User.id == 1, + ) + ) + ) + ).scalar_one() + signal = Signal( + tenant_id=1, + source="PMFlow", + title="OpenClaw 事务隔离校验", + summary="验证回滚不影响外层事务", + content="验证回滚不影响外层事务", + signal_type="新需求", + reasoning="[]", + confidence=0.78, + recommended_action="enter_only", + matched_project_id=1, + impact_score=0.7, + priority="medium", + processed=False, + tasks_generated="[]", + status="待处理", + ) + db.add(signal) + await db.flush() + signal_id = int(signal.id) + + async def _fake_dispatch(commander_self, **_: object): # type: ignore[no-untyped-def] + await commander_self.db.rollback() + return {"execution": {"results": []}} + + with patch( + "app.services.openclaw_commander_service.OpenClawCommanderService.dispatch_command", + new=_fake_dispatch, + ): + result = await service._dispatch_openclaw( + tenant_id=1, + operator_user=operator, + project=project, + instruction="生成任务并通知负责人", + dry_run=False, + response_mode="fast", + ) + + persisted_signal = ( + await db.execute( + select(Signal).where( + and_( + Signal.tenant_id == 1, + Signal.id == signal_id, + ) + ) + ) + ).scalar_one_or_none() + await db.commit() + + self.assertIn(result["status"], {"completed", "failed"}) + self.assertIsNotNone(persisted_signal) diff --git a/backend/tests/test_ai_pmo_work_item_decision_api.py b/backend/tests/test_ai_pmo_work_item_decision_api.py index 265e4cb5..4aef4c4b 100644 --- a/backend/tests/test_ai_pmo_work_item_decision_api.py +++ b/backend/tests/test_ai_pmo_work_item_decision_api.py @@ -2,12 +2,13 @@ import os import unittest from tempfile import NamedTemporaryFile from types import SimpleNamespace +from unittest.mock import patch from sqlalchemy import and_, select from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker, create_async_engine -from app.api.ai_pmo import PMOWorkItemDecisionRequest, _normalize_resume_result, decide_work_item -from app.models import Base, DecisionRecord, IngestEvent, User, WorkItem +from app.api.ai_pmo import PMOWorkItemDecisionRequest, _normalize_resume_result, decide_work_item, list_work_items +from app.models import AIProcessingLog, Base, DecisionRecord, IngestEvent, QARecord, User, WorkItem class AIPMOWorkItemDecisionApiTest(unittest.IsolatedAsyncioTestCase): @@ -141,3 +142,394 @@ class AIPMOWorkItemDecisionApiTest(unittest.IsolatedAsyncioTestCase): self.assertEqual(refreshed.state, "ignored") self.assertEqual(refreshed.reply_template_key, "action_rejected") self.assertEqual(refreshed_decision.final_outcome, "ignored") + + async def test_list_work_items_returns_latest_policy_projection(self) -> None: + async with self.session_factory() as db: + user = User( + tenant_id=1, + username="decision_viewer", + email="decision_viewer@example.com", + password_hash="hash", + name="查看人", + role="BOSS", + status="active", + is_active=True, + ) + db.add(user) + await db.flush() + + ingest = IngestEvent( + tenant_id=1, + channel="ai_pmo_ui_message", + event_key="decision-list-1", + status="done", + payload={ + "message_text": "按照这个分步骤加入到信号源中", + "closure_summary": "同一消息闭环内,用户要求将需求按步骤接入信号源,重点模块:SSTC 工作流、AI 能力编排。", + "closure_dialogue": "机器人:当前无法直接创建,请先确认项目。\n用户:按照这个分步骤加入到信号源中", + "closure_modules": ["SSTC 工作流", "AI 能力编排"], + }, + ) + db.add(ingest) + await db.flush() + + work_item = WorkItem( + tenant_id=1, + ingest_event_id=int(ingest.id), + source_system="ai_pmo_ui", + source_message_id="decision-list-item-1", + state="waiting_manual", + intent="signal_source.onboard", + project_id=None, + signal_id=None, + execution_result={"status": "waiting_manual"}, + ) + db.add(work_item) + await db.flush() + + db.add( + DecisionRecord( + tenant_id=1, + work_item_id=int(work_item.id), + decision_object_type="project", + decision_object_id=None, + decision_level="project", + policy_gate_hit=True, + policy_gate_code="legacy_gate", + approval_requirement={"required": True, "reason": "legacy_gate", "message": "旧规则"}, + ) + ) + db.add( + DecisionRecord( + tenant_id=1, + work_item_id=int(work_item.id), + decision_object_type="project", + decision_object_id=None, + decision_level="project", + policy_gate_hit=True, + policy_gate_code="missing_project_scope", + approval_requirement={"required": True, "reason": "missing_project_scope", "message": "请选择关联项目"}, + ) + ) + await db.commit() + + response = await list_work_items( + limit=20, + state=None, + project_id=None, + current_user=user, + db=db, + tenant_id=1, + ) + + self.assertEqual(response["code"], 0) + items = response["data"]["items"] + self.assertEqual(len(items), 1) + self.assertEqual(items[0]["latest_policy_gate_code"], "missing_project_scope") + self.assertEqual(items[0]["approval_requirement"]["required"], True) + self.assertEqual(items[0]["approval_requirement"]["reason"], "missing_project_scope") + self.assertEqual(items[0]["approval_requirement"]["message"], "请选择关联项目") + self.assertEqual(items[0]["closure_summary"], "同一消息闭环内,用户要求将需求按步骤接入信号源,重点模块:SSTC 工作流、AI 能力编排。") + self.assertEqual(items[0]["implementation_modules"], ["SSTC 工作流", "AI 能力编排"]) + self.assertIn("机器人:当前无法直接创建", str(items[0]["source_conversation_closure"])) + + async def test_list_work_items_fallbacks_to_execution_result_when_decision_missing(self) -> None: + async with self.session_factory() as db: + user = User( + tenant_id=1, + username="decision_viewer_fallback", + email="decision_viewer_fallback@example.com", + password_hash="hash", + name="查看人", + role="BOSS", + status="active", + is_active=True, + ) + db.add(user) + await db.flush() + + ingest = IngestEvent( + tenant_id=1, + channel="ai_pmo_ui_message", + event_key="decision-list-fallback-1", + status="done", + payload={"message_text": "按步骤加入信号源"}, + ) + db.add(ingest) + await db.flush() + + work_item = WorkItem( + tenant_id=1, + ingest_event_id=int(ingest.id), + source_system="ai_pmo_ui", + source_message_id="decision-list-fallback-item-1", + state="waiting_manual", + intent="signal_source.onboard", + project_id=None, + signal_id=None, + execution_result={ + "status": "waiting_manual", + "policy_gate_code": "missing_project_scope", + "approval_requirement": { + "required": True, + "reason": "missing_project_scope", + "message": "请先确认项目归属", + }, + }, + ) + db.add(work_item) + await db.commit() + + response = await list_work_items( + limit=20, + state=None, + project_id=None, + current_user=user, + db=db, + tenant_id=1, + ) + + self.assertEqual(response["code"], 0) + items = response["data"]["items"] + self.assertEqual(len(items), 1) + self.assertEqual(items[0]["latest_policy_gate_code"], "missing_project_scope") + self.assertEqual(items[0]["approval_requirement"]["required"], True) + self.assertEqual(items[0]["approval_requirement"]["reason"], "missing_project_scope") + self.assertEqual(items[0]["approval_requirement"]["message"], "请先确认项目归属") + + async def test_list_work_items_rebuilds_low_value_closure_from_persistent_logs(self) -> None: + async with self.session_factory() as db: + user = User( + tenant_id=1, + username="decision_viewer_closure_rebuild", + email="decision_viewer_closure_rebuild@example.com", + password_hash="hash", + name="查看人", + role="BOSS", + status="active", + is_active=True, + ) + db.add(user) + await db.flush() + + db.add( + QARecord( + tenant_id=1, + question="当前 PMFlow 项目已完成用户管理、SSTC 工作流和 AI 自动化编排。", + answer="核心模块包括用户管理、SSTC 工作流、AI 能力编排,建议按信号源接入流程继续推进。", + intent="question", + asked_by=int(user.id), + asked_via="feishu", + session_id=11, + turn_index=1, + ) + ) + + ingest = IngestEvent( + tenant_id=1, + channel="feishu_message", + event_key="om-closure-rebuild-1", + status="done", + payload={ + "message_text": "按照这个分步骤加入到信号源中", + "channel_ref": "oc_closure_rebuild", + "sender": "查看人", + "source_sender_open_id": "ou_closure_rebuild", + "closure_summary": "信号源接入缺少项目归属,必须先确认项目。", + "closure_dialogue": "用户:按照这个分步骤加入到信号源中", + "closure_modules": [], + "closure_turns": [{"role": "user", "text": "按照这个分步骤加入到信号源中"}], + }, + ) + db.add(ingest) + await db.flush() + + work_item = WorkItem( + tenant_id=1, + ingest_event_id=int(ingest.id), + source_system="feishu", + source_message_id="om-closure-rebuild-1", + state="waiting_manual", + intent="signal_source.onboard", + owner_user_id=int(user.id), + project_id=None, + signal_id=None, + execution_result={"status": "waiting_manual"}, + ) + db.add(work_item) + await db.flush() + + db.add( + AIProcessingLog( + tenant_id=1, + feishu_message_id="om-closure-rebuild-1", + raw_message="按照这个分步骤加入到信号源中", + normalized_text="按照这个分步骤加入到信号源中", + route="ai_pmo.waiting_manual", + intent="signal_source.onboard", + routing_decision="ai_pmo.waiting_manual", + processing_status="success", + operation_result={"reason": "信号源接入缺少项目归属,必须先确认项目。"}, + ) + ) + await db.commit() + + response = await list_work_items( + limit=20, + state=None, + project_id=None, + current_user=user, + db=db, + tenant_id=1, + ) + + self.assertEqual(response["code"], 0) + items = response["data"]["items"] + self.assertEqual(len(items), 1) + self.assertIn("机器人先提示条件不足,用户随后继续要求执行", str(items[0]["closure_summary"])) + self.assertIn("用户:按照这个分步骤加入到信号源中", str(items[0]["source_conversation_closure"])) + self.assertIn("机器人:信号源接入缺少项目归属,必须先确认项目。", str(items[0]["source_conversation_closure"])) + self.assertIn("SSTC 工作流", items[0]["implementation_modules"]) + + async def test_decision_approve_missing_project_scope_returns_fast_and_sets_executing(self) -> None: + async with self.session_factory() as db: + user = User( + tenant_id=1, + username="decision_approver", + email="decision_approver@example.com", + password_hash="hash", + name="审批人", + role="BOSS", + status="active", + is_active=True, + ) + db.add(user) + await db.flush() + + ingest = IngestEvent( + tenant_id=1, + channel="ai_pmo_ui_message", + event_key="decision-approve-fast-1", + status="done", + payload={"message_text": "按分步骤加入信号源"}, + ) + db.add(ingest) + await db.flush() + + work_item = WorkItem( + tenant_id=1, + ingest_event_id=int(ingest.id), + source_system="ai_pmo_ui", + source_message_id="decision-approve-fast-item-1", + state="waiting_manual", + intent="signal_source.onboard", + project_id=None, + signal_id=None, + execution_result={"status": "waiting_manual"}, + ) + db.add(work_item) + await db.flush() + + db.add( + DecisionRecord( + tenant_id=1, + work_item_id=int(work_item.id), + decision_object_type="project", + decision_object_id=None, + decision_level="project", + policy_gate_hit=True, + policy_gate_code="missing_project_scope", + approval_requirement={"required": True, "reason": "missing_project_scope", "message": "请选择项目"}, + ) + ) + await db.commit() + + with patch("app.api.ai_pmo._queue_resume_work_item") as mocked_queue: + response = await decide_work_item( + work_item_id=int(work_item.id), + request=PMOWorkItemDecisionRequest(action="approve", project_id=8), + current_user=user, + db=db, + tenant_id=1, + ) + mocked_queue.assert_called_once() + + refreshed = ( + await db.execute( + select(WorkItem).where( + and_( + WorkItem.tenant_id == 1, + WorkItem.id == int(work_item.id), + ) + ) + ) + ).scalar_one() + refreshed_decision = ( + await db.execute( + select(DecisionRecord).where( + and_( + DecisionRecord.tenant_id == 1, + DecisionRecord.work_item_id == int(work_item.id), + ) + ) + ) + ).scalar_one() + + self.assertEqual(response["code"], 0) + self.assertEqual(response["data"]["status"], "pending_manual") + self.assertEqual(response["data"]["project_id"], 8) + self.assertEqual(refreshed.state, "executing") + self.assertEqual(refreshed.project_id, 8) + self.assertEqual(refreshed_decision.policy_gate_code, None) + self.assertEqual(bool((refreshed_decision.approval_requirement or {}).get("required")), False) + + async def test_list_work_items_hides_smalltalk_query_rows(self) -> None: + async with self.session_factory() as db: + user = User( + tenant_id=1, + username="decision_smalltalk_viewer", + email="decision_smalltalk_viewer@example.com", + password_hash="hash", + name="查看人", + role="BOSS", + status="active", + is_active=True, + ) + db.add(user) + await db.flush() + + ingest = IngestEvent( + tenant_id=1, + channel="ai_pmo_ui_message", + event_key="decision-smalltalk-1", + status="done", + payload={"message_text": "怎么样了?"}, + ) + db.add(ingest) + await db.flush() + + work_item = WorkItem( + tenant_id=1, + ingest_event_id=int(ingest.id), + source_system="ai_pmo_ui", + source_message_id="decision-smalltalk-item-1", + state="succeeded", + intent="query.read_only", + project_id=None, + signal_id=None, + execution_result={"status": "succeeded", "answer": "已收到"}, + ) + db.add(work_item) + await db.commit() + + response = await list_work_items( + limit=20, + state=None, + project_id=None, + current_user=user, + db=db, + tenant_id=1, + ) + + self.assertEqual(response["code"], 0) + self.assertEqual(response["data"]["items"], []) diff --git a/docs/INBOX_INLINE_OPS_3_PHASE_CHECKLIST.md b/docs/INBOX_INLINE_OPS_3_PHASE_CHECKLIST.md new file mode 100644 index 00000000..7b3a1010 --- /dev/null +++ b/docs/INBOX_INLINE_OPS_3_PHASE_CHECKLIST.md @@ -0,0 +1,121 @@ +# 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,且 OpenClaw 指挥链改为隔离 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/frontend/src/api/aiPmo.ts b/frontend/src/api/aiPmo.ts index e1eb7449..ba336cf3 100644 --- a/frontend/src/api/aiPmo.ts +++ b/frontend/src/api/aiPmo.ts @@ -13,6 +13,14 @@ export interface AIPMOWorkItem { id: number; source_system: string; source_message_id: string; + source_message_text?: string | null; + source_conversation_closure?: string | null; + source_captured_at?: string | null; + source_sender?: string | null; + source_channel_ref?: string | null; + closure_summary?: string | null; + implementation_modules?: string[] | null; + source_closure_turns?: Array> | null; state: string; intent?: string | null; decision_level?: string | null; @@ -31,10 +39,19 @@ export interface AIPMOWorkItem { target_url?: string | null; review_summary?: Record | null; execution_result?: Record | null; + latest_policy_gate_code?: string | null; + approval_requirement?: AIPMOApprovalRequirement | null; created_at?: string | null; updated_at?: string | null; } +export interface AIPMOApprovalRequirement { + required?: boolean; + reason?: string | null; + message?: string | null; + [key: string]: unknown; +} + export interface AIPMOWorkItemListPayload { items: AIPMOWorkItem[]; } @@ -51,7 +68,7 @@ export interface AIPMODecisionRecordDetail { simulation_result?: Record | null; policy_gate_hit?: boolean; policy_gate_code?: string | null; - approval_requirement?: Record | null; + approval_requirement?: AIPMOApprovalRequirement | null; execution_result?: Record | null; final_outcome?: string | null; } @@ -159,7 +176,11 @@ export const aiPmoApi = { }, async decideWorkItem(workItemId: number, body: AIPMOWorkItemDecisionRequest): Promise { - const { data } = await api.post>(`/ai-pmo/work-items/${workItemId}/decision`, body); + const { data } = await api.post>( + `/ai-pmo/work-items/${workItemId}/decision`, + body, + { timeout: 15000 } + ); return unwrap(data); }, diff --git a/frontend/src/components/ai-steward/FloatingQAChat.tsx b/frontend/src/components/ai-steward/FloatingQAChat.tsx index 040577b6..d8e9a1e7 100644 --- a/frontend/src/components/ai-steward/FloatingQAChat.tsx +++ b/frontend/src/components/ai-steward/FloatingQAChat.tsx @@ -603,7 +603,7 @@ export function FloatingQAChat() {
diff --git a/frontend/src/components/inbox/InboxActionPanel.tsx b/frontend/src/components/inbox/InboxActionPanel.tsx new file mode 100644 index 00000000..060c359a --- /dev/null +++ b/frontend/src/components/inbox/InboxActionPanel.tsx @@ -0,0 +1,82 @@ +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.tsx b/frontend/src/components/inbox/InboxListItem.tsx index bd54bfec..c84bc158 100644 --- a/frontend/src/components/inbox/InboxListItem.tsx +++ b/frontend/src/components/inbox/InboxListItem.tsx @@ -1,3 +1,4 @@ +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"; @@ -52,6 +53,7 @@ interface InboxListItemProps { item: InboxItemViewModel; expanded: boolean; pendingActionKey: string | null; + inlinePanel?: ReactNode; onToggleExpand: (itemId: string) => void; onAction: (item: InboxItemViewModel, action: InboxActionDescriptor) => void; } @@ -60,6 +62,7 @@ export default function InboxListItem({ item, expanded, pendingActionKey, + inlinePanel, onToggleExpand, onAction, }: InboxListItemProps) { @@ -67,14 +70,45 @@ export default function InboxListItem({ const projectLabel = normalizeProjectLabel(item.project_name, item.project_id); const title = sanitizeInboxTitle(item.title); const subtitle = sanitizeInboxSubtitle(item.summary); - const displayTime = formatInboxTime(item.updated_at || item.created_at); - const fullTime = formatInboxAbsoluteTime(item.updated_at || item.created_at); const StatusIcon = resolveStatusIcon(item.display_kind); const payload = (item.source_notification?.payload ?? {}) as Record; + const sourceMessageText = + String(item.source_work_item?.source_message_text || 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 || payload.source_message_id || payload.message_id || "").trim() || "-"; + const sourceSystem = + String(item.source_work_item?.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 || 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_work_item ? sanitizeInboxTitle(item.title) : "-"); + const undoDeadlineText = item.can_undo ? `可撤销(截止 ${item.undo_deadline || "-"})` : "不可撤销"; const primaryActionKey = buildInboxActionKey(item, item.primary_action); const secondaryActionKey = buildInboxActionKey(item, item.secondary_action); @@ -162,19 +196,77 @@ export default function InboxListItem({
-
-
技术字段(默认折叠)
-
-
intent(可读):{mapIntentLabel(rawIntent)}
-
intent(原始):{rawIntent || "-"}
-
typed actions:{formatTechnicalValue(typedActions)}
-
chat_id:{formatTechnicalValue(chatId)}
-
重复次数:{item.dedupe_count}
-
撤销状态:{item.can_undo ? `可撤销(截止 ${item.undo_deadline || "-"})` : "不可撤销"}
-
完整时间戳:{fullTime}
+
+
+
消息闭环(原始对话)
+

{readableInput || "-"}

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

{requirementSummary}

+
+
执行摘要
+

{executionSummary}

+
+ {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" && (
-
+
{[ { key: "decision", diff --git a/frontend/src/components/sstc/SSTCWorkspace.tsx b/frontend/src/components/sstc/SSTCWorkspace.tsx index cadce634..462241b5 100644 --- a/frontend/src/components/sstc/SSTCWorkspace.tsx +++ b/frontend/src/components/sstc/SSTCWorkspace.tsx @@ -81,6 +81,11 @@ export function SSTCWorkspace(props: SSTCWorkspaceViewProps) { return (
+ {props.isMobileViewport && ( +
+ 请使用桌面端编辑宪章,移动端当前仅支持查看。 +
+ )} {props.activeStage === "signal" && } {props.activeStage === "charter" && } {props.activeStage === "task" && ( diff --git a/frontend/src/components/ui/RoleBadge.tsx b/frontend/src/components/ui/RoleBadge.tsx new file mode 100644 index 00000000..78339d7f --- /dev/null +++ b/frontend/src/components/ui/RoleBadge.tsx @@ -0,0 +1,77 @@ +type RoleBadgeProps = { + roles: string[]; + max?: number; + className?: string; +}; + +type RoleBadgeMeta = { + label: string; + tone: string; +}; + +const ROLE_BADGE_MAP: Record = { + FE: { label: "前端开发", tone: "bg-blue-100 text-blue-600" }, + "前端开发": { label: "前端开发", tone: "bg-blue-100 text-blue-600" }, + BE: { label: "后端开发", tone: "bg-purple-100 text-purple-600" }, + "后端开发": { label: "后端开发", tone: "bg-purple-100 text-purple-600" }, + AI: { label: "AI工程师", tone: "bg-cyan-100 text-cyan-600" }, + "AI工程师": { label: "AI工程师", tone: "bg-cyan-100 text-cyan-600" }, + BOSS: { label: "Boss", tone: "bg-red-100 text-red-600" }, + BOSS_CAP: { label: "Boss", tone: "bg-red-100 text-red-600" }, + ADMIN: { label: "管理员", tone: "bg-orange-100 text-orange-600" }, + "管理员": { label: "管理员", tone: "bg-orange-100 text-orange-600" }, + "系统管理员": { label: "系统管理员", tone: "bg-orange-100 text-orange-600" }, + "负责人": { label: "负责人", tone: "bg-gray-100 text-gray-600" }, +}; + +function normalizeRoles(input: string[]): string[] { + const flat = input + .flatMap((item) => String(item || "").split(",")) + .map((item) => item.trim()) + .filter(Boolean); + return [...new Set(flat)]; +} + +function roleMeta(raw: string): RoleBadgeMeta { + const normalized = raw.trim(); + const upper = normalized.toUpperCase(); + if (upper === "BOSS") return ROLE_BADGE_MAP.BOSS; + if (upper === "ADMIN") return ROLE_BADGE_MAP.ADMIN; + if (upper === "AI") return ROLE_BADGE_MAP.AI; + if (upper === "FE") return ROLE_BADGE_MAP.FE; + if (upper === "BE") return ROLE_BADGE_MAP.BE; + if (normalized.toLowerCase() === "boss") return ROLE_BADGE_MAP.BOSS_CAP; + return ROLE_BADGE_MAP[normalized] || { label: normalized, tone: "bg-gray-100 text-gray-600" }; +} + +export function RoleBadge({ roles, max, className }: RoleBadgeProps) { + const normalized = normalizeRoles(roles); + if (!normalized.length) { + return -; + } + + const visible = typeof max === "number" && max > 0 ? normalized.slice(0, max) : normalized; + const remain = typeof max === "number" && max > 0 ? normalized.length - visible.length : 0; + + return ( +
+ {visible.map((role) => { + const meta = roleMeta(role); + return ( + + {meta.label} + + ); + })} + {remain > 0 && ( + + +{remain} + + )} +
+ ); +} + diff --git a/frontend/src/components/ui/SkillBadge.tsx b/frontend/src/components/ui/SkillBadge.tsx new file mode 100644 index 00000000..b912d5f1 --- /dev/null +++ b/frontend/src/components/ui/SkillBadge.tsx @@ -0,0 +1,66 @@ +type SkillBadgeProps = { + skills: string[]; + max?: number; + className?: string; +}; + +type SkillBadgeMeta = { + label: string; + tone: string; +}; + +const SKILL_BADGE_MAP: Record = { + python: { label: "Python", tone: "bg-yellow-100 text-yellow-700" }, + typescript: { label: "TypeScript", tone: "bg-blue-100 text-blue-600" }, + javascript: { label: "JavaScript", tone: "bg-yellow-100 text-yellow-600" }, + react: { label: "React", tone: "bg-cyan-100 text-cyan-600" }, + go: { label: "Go", tone: "bg-teal-100 text-teal-700" }, + java: { label: "Java", tone: "bg-orange-100 text-orange-600" }, + fastapi: { label: "FastAPI", tone: "bg-green-100 text-green-700" }, + "rag构建": { label: "RAG构建", tone: "bg-violet-100 text-violet-600" }, +}; + +function normalizeSkills(input: string[]): string[] { + const flat = input + .flatMap((item) => String(item || "").split(",")) + .map((item) => item.trim()) + .filter(Boolean); + return [...new Set(flat)]; +} + +function skillMeta(raw: string): SkillBadgeMeta { + const key = raw.trim().toLowerCase(); + return SKILL_BADGE_MAP[key] || { label: raw.trim(), tone: "bg-gray-100 text-gray-500" }; +} + +export function SkillBadge({ skills, max = 3, className }: SkillBadgeProps) { + const normalized = normalizeSkills(skills); + if (!normalized.length) { + return -; + } + + const visible = max > 0 ? normalized.slice(0, max) : normalized; + const remain = max > 0 ? normalized.length - visible.length : 0; + + return ( +
+ {visible.map((skill) => { + const meta = skillMeta(skill); + return ( + + {meta.label} + + ); + })} + {remain > 0 && ( + + +{remain} + + )} +
+ ); +} + diff --git a/frontend/src/components/ui/dialog.tsx b/frontend/src/components/ui/dialog.tsx index 312993a7..b11c8cf8 100644 --- a/frontend/src/components/ui/dialog.tsx +++ b/frontend/src/components/ui/dialog.tsx @@ -32,7 +32,7 @@ const DialogContent = React.forwardRef< 0 ? integer : null; +} + +function isMissingProjectScopeItem(item: InboxItemViewModel): boolean { + if (item.source_kind !== "ai_pmo_work_item" || !item.source_work_item) return false; + const state = String(item.source_work_item.state || "").toLowerCase(); + const gateCode = String(item.latest_policy_gate_code || item.source_work_item.latest_policy_gate_code || "").toLowerCase(); + return state === "waiting_manual" && gateCode === "missing_project_scope"; +} + export default function InboxPage() { const navigate = useNavigate(); const queryClient = useQueryClient(); @@ -60,6 +76,8 @@ export default function InboxPage() { const [searchParams, setSearchParams] = useSearchParams(); const [pendingActionKey, setPendingActionKey] = useState(null); const [expandedItemId, setExpandedItemId] = useState(null); + const [selectedProjectByItem, setSelectedProjectByItem] = useState>({}); + const [autoExpandedByQuery, setAutoExpandedByQuery] = useState(false); const activeTab = normalizeTabKey(searchParams.get("section")); const projectFilter = searchParams.get("project_id") || "all"; @@ -77,6 +95,12 @@ export default function InboxPage() { refetchInterval: POLL_INTERVAL_MS, }); + const activeProjectsQuery = useQuery({ + queryKey: ["projects", "active", "inbox-inline-actions"], + queryFn: () => projectsApi.list({ status: "active" }), + refetchInterval: POLL_INTERVAL_MS, + }); + const inboxData = useMemo(() => { return buildInboxView({ notifications: notificationsQuery.data || { items: [], unread_count: 0 }, @@ -93,6 +117,14 @@ export default function InboxPage() { return Array.from(map.entries()).map(([value, label]) => ({ value, label })); }, [inboxData.items]); + const activeProjectOptions = useMemo(() => { + const projects = activeProjectsQuery.data || []; + return projects.map((project) => ({ + value: String(project.id), + label: String(project.title || `项目 #${project.id}`), + })); + }, [activeProjectsQuery.data]); + const projectScopedItems = useMemo(() => { return inboxData.items.filter((item) => { if (projectFilter === "all") return true; @@ -125,6 +157,18 @@ export default function InboxPage() { return sortByLatest(tabItems); }, [searchedItems, activeTab]); + useEffect(() => { + if (autoExpandedByQuery) return; + const targetId = parsePositiveInt(searchParams.get("workItemId")); + if (!targetId) return; + const targetKey = `work-item-${targetId}`; + const matched = feedItems.find((item) => item.id === targetKey); + if (matched) { + setExpandedItemId(targetKey); + setAutoExpandedByQuery(true); + } + }, [autoExpandedByQuery, feedItems, searchParams]); + const setTab = (tab: string) => { const normalized = normalizeTabKey(tab); const next = new URLSearchParams(searchParams); @@ -150,6 +194,23 @@ export default function InboxPage() { setExpandedItemId((current) => (current === itemId ? null : itemId)); }; + const getSelectedProjectId = (item: InboxItemViewModel): number | null => { + const selected = selectedProjectByItem[item.id]; + if (selected) return parsePositiveInt(selected); + if (item.project_id) return parsePositiveInt(item.project_id); + return null; + }; + + const getSelectedProjectValue = (item: InboxItemViewModel): string => { + const selected = selectedProjectByItem[item.id]; + if (selected) return selected; + return item.project_id ? String(item.project_id) : ""; + }; + + const setSelectedProjectValue = (item: InboxItemViewModel, value: string) => { + setSelectedProjectByItem((current) => ({ ...current, [item.id]: value })); + }; + const refreshAll = async () => { await Promise.all([ queryClient.invalidateQueries({ queryKey: ["notifications", "inbox"] }), @@ -198,9 +259,16 @@ export default function InboxPage() { } if (action.kind === "confirm" && item.source_kind === "ai_pmo_work_item" && item.source_work_item) { + const missingProjectScope = isMissingProjectScopeItem(item); + const selectedProjectId = missingProjectScope ? getSelectedProjectId(item) : (item.project_id ?? null); + if (missingProjectScope && !selectedProjectId) { + setExpandedItemId(item.id); + toast({ title: "请先选择关联项目后再确认", variant: "default" }); + return; + } const result = await aiPmoApi.decideWorkItem(item.source_work_item.id, { action: "approve", - project_id: item.project_id ?? undefined, + project_id: selectedProjectId ?? undefined, }); await refreshAll(); @@ -212,6 +280,12 @@ export default function InboxPage() { } else { toast({ title: followupReply || "继续执行失败,请查看详情", variant: "error" }); } + setSelectedProjectByItem((current) => { + if (!(item.id in current)) return current; + const next = { ...current }; + delete next[item.id]; + return next; + }); setExpandedItemId(item.id); return; } @@ -254,6 +328,9 @@ export default function InboxPage() { }; const isLoading = notificationsQuery.isLoading || workItemsQuery.isLoading; + const activeProjectErrorText = activeProjectsQuery.isError + ? getApiErrorMessage(activeProjectsQuery.error, "项目列表加载失败") + : null; return (
@@ -302,23 +379,25 @@ export default function InboxPage() {
信息流筛选(单列时间流,不分栏)
-
- {(Object.keys(TAB_LABELS) as InboxTabKey[]).map((tabKey) => ( - - ))} +
+
+ {(Object.keys(TAB_LABELS) as InboxTabKey[]).map((tabKey) => ( + + ))} +
@@ -346,6 +425,20 @@ export default function InboxPage() { item={item} expanded={expandedItemId === item.id} pendingActionKey={pendingActionKey} + inlinePanel={ + expandedItemId === item.id && isMissingProjectScopeItem(item) ? ( + setSelectedProjectValue(item, value)} + onCancel={() => setExpandedItemId(null)} + onConfirm={() => void handleAction(item, item.primary_action)} + /> + ) : undefined + } onToggleExpand={toggleExpand} onAction={(target, action) => void handleAction(target, action)} /> diff --git a/frontend/src/pages/LoginPage.tsx b/frontend/src/pages/LoginPage.tsx index bc17c081..efbccf8d 100644 --- a/frontend/src/pages/LoginPage.tsx +++ b/frontend/src/pages/LoginPage.tsx @@ -64,10 +64,10 @@ export default function LoginPage() { }; return ( -
+
- PMFlow + PMFlow 登录以继续 diff --git a/frontend/src/pages/SkillTasksPage.tsx b/frontend/src/pages/SkillTasksPage.tsx index 1d21dfd1..4ed94f09 100644 --- a/frontend/src/pages/SkillTasksPage.tsx +++ b/frontend/src/pages/SkillTasksPage.tsx @@ -66,47 +66,69 @@ export default function SkillTasksPage() {

) : ( -
- - - - 任务 - 状态 - 优先级 - 工时 (h) - 更新时间 - - - + <> +
+
+ + + 任务 + 状态 + 优先级 + 工时 (h) + 更新时间 + + + + {items.map((task) => ( + + +
+

{task.title}

+ {task.description && ( +

+ {task.description} +

+ )} +
+
+ {toZhStatus(task.status)} + {toZhPriority(task.priority)} + + {typeof task.estimated_hours === "number" + ? task.estimated_hours.toFixed(1) + : "-"} + + + {task.updated_at + ? formatBeijingDateTime(task.updated_at) + : "-"} + +
+ ))} +
+
+
+
{items.map((task) => ( - - -
-

{task.title}

- {task.description && ( -

- {task.description} -

- )} -
-
- {toZhStatus(task.status)} - {toZhPriority(task.priority)} - - {typeof task.estimated_hours === "number" - ? task.estimated_hours.toFixed(1) - : "-"} - - - {task.updated_at - ? formatBeijingDateTime(task.updated_at) - : "-"} - -
+
+

{task.title}

+ {task.description && ( +

+ {task.description} +

+ )} +
+ 状态:{toZhStatus(task.status)} + 优先级:{toZhPriority(task.priority)} +
+
+ 工时:{typeof task.estimated_hours === "number" ? task.estimated_hours.toFixed(1) : "-"}h + {task.updated_at ? formatBeijingDateTime(task.updated_at) : "-"} +
+
))} - - -
+
+ )} diff --git a/frontend/src/pages/SkillsPage.tsx b/frontend/src/pages/SkillsPage.tsx index 758dc752..d8fe0e98 100644 --- a/frontend/src/pages/SkillsPage.tsx +++ b/frontend/src/pages/SkillsPage.tsx @@ -116,6 +116,29 @@ export default function SkillsPage() { ); }); }, [data, search]); + const renderTagActions = (tag: SkillTag) => ( + + + + + + setEditRow(tag)}> + + 编辑 + + setDeleteRow(tag)} + > + + 删除 + + + + ); return (
@@ -139,25 +162,29 @@ export default function SkillsPage() { 标签列表 共 {rows.length} 个
-
+
setSearch(e.target.value)} className="w-full sm:w-72" /> - +
+
+ +
+
@@ -169,67 +196,71 @@ export default function SkillsPage() { ) : rows.length === 0 ? (

暂无技能标签

) : ( -
- - - - 名称 - 分类 - 关联人数 - 描述 - {canManage && } - - - - {rows.map((tag) => ( - - -
- {tag.name} - {tag.is_system && ( - - 系统 + <> +
+
+ + + 名称 + 分类 + 关联人数 + 描述 + {canManage && } + + + + {rows.map((tag) => ( + + +
+ {tag.name} + {tag.is_system && ( + + 系统 + + )} +
+
+ + + {toZhSkillCategory(tag.category)} - )} + + {tag.linked_user_count ?? 0} + {tag.description || "-"} + {canManage && {renderTagActions(tag)}} +
+ ))} +
+
+
+
+ {rows.map((tag) => ( +
+
+
+
+ {tag.name} + {tag.is_system && ( + + 系统 + + )} +
+
+ + {toZhSkillCategory(tag.category)} + +
- - - - {toZhSkillCategory(tag.category)} - - - {tag.linked_user_count ?? 0} - {tag.description || "-"} - {canManage && ( - - - - - - - setEditRow(tag)}> - - 编辑 - - setDeleteRow(tag)} - > - - 删除 - - - - - )} - + {canManage && renderTagActions(tag)} +
+

{tag.description || "暂无描述"}

+

关联人数:{tag.linked_user_count ?? 0}

+
))} - - -
+
+ )} diff --git a/frontend/src/pages/UsersPage.tsx b/frontend/src/pages/UsersPage.tsx index 39f9d948..ce07a78c 100644 --- a/frontend/src/pages/UsersPage.tsx +++ b/frontend/src/pages/UsersPage.tsx @@ -2,8 +2,6 @@ import { useMemo, useState } from "react"; import { Link } from "react-router-dom"; import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; import { - ChevronLeft, - ChevronRight, Eye, EyeOff, Import, @@ -34,6 +32,8 @@ import { useAuthStore } from "@/stores/auth"; import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"; import { Button } from "@/components/ui/button"; import { Input } from "@/components/ui/input"; +import { RoleBadge } from "@/components/ui/RoleBadge"; +import { SkillBadge } from "@/components/ui/SkillBadge"; import { DropdownMenu, DropdownMenuContent, @@ -138,10 +138,7 @@ export default function UsersPage() { const [roleFilter, setRoleFilter] = useState(""); const [statusFilter, setStatusFilter] = useState<"" | UserStatus>(""); const [search, setSearch] = useState(""); - const [projectIdFilter, setProjectIdFilter] = useState(""); const [workloadStateFilter, setWorkloadStateFilter] = useState<"" | "idle" | "normal" | "overloaded">(""); - const [skillIdsFilter, setSkillIdsFilter] = useState(""); - const [filterCollapsed, setFilterCollapsed] = useState(false); const [createOpen, setCreateOpen] = useState(false); const [editUser, setEditUser] = useState(null); const [deleteUserState, setDeleteUserState] = useState(null); @@ -174,9 +171,7 @@ export default function UsersPage() { search, roleFilter, statusFilter, - projectIdFilter, workloadStateFilter, - skillIdsFilter, ], queryFn: () => getUsers({ @@ -185,9 +180,7 @@ export default function UsersPage() { search: search || undefined, role: roleFilter || undefined, status: statusFilter || undefined, - project_id: projectIdFilter ? Number(projectIdFilter) : undefined, workload_state: workloadStateFilter || undefined, - skill_ids: skillIdsFilter || undefined, }), }); @@ -274,6 +267,45 @@ export default function UsersPage() { const allSelected = users.length > 0 && users.every((u) => selectedIds.includes(u.id)); const renderedUsers = useMemo(() => users, [users]); + const renderUserActions = (u: User) => ( + + + + + + setEditUser(u)}> + + 编辑 + + setResetPwdUser(u)}> + + 重置密码 + + + statusMutation.mutate({ + userId: u.id, + isActive: !(u.is_active ?? true), + }) + } + > + + {(u.is_active ?? true) ? "禁用用户" : "启用用户"} + + {canDelete && user?.id !== u.id && ( + setDeleteUserState(u)} + > + + 删除 + + )} + + + ); return (
@@ -284,52 +316,96 @@ export default function UsersPage() {
{canManage && (
+ - - - + {selectedIds.length > 0 && ( + <> + + + + )}
)}
-
- - -
-
- 用户列表 - 共 {total} 人 -
-
- setSearch(e.target.value)} - className="w-full sm:w-72" - /> + + +
+
+ 用户列表 + 共 {total} 人 +
+
+ setSearch(e.target.value)} + className="w-full xl:max-w-[320px]" + /> +
+
+ + + + + +
- - +
+
+ {isLoading ? (

加载中…

) : error ? ( @@ -337,199 +413,135 @@ export default function UsersPage() { ) : renderedUsers.length === 0 ? (

暂无用户

) : ( -
- - - - - { - setSelectedIds(e.target.checked ? renderedUsers.map((u) => u.id) : []); - }} - /> - - 用户 - 角色 - 项目 - 核心技能(前3) - 负载 - 状态 - {canManage && } - - - + <> +
+
+ + + + { + setSelectedIds(e.target.checked ? renderedUsers.map((u) => u.id) : []); + }} + /> + + 用户 + 角色 + 项目 + 核心技能(前3) + 负载 + 状态 + {canManage && } + + + + {renderedUsers.map((u) => { + const ratio = u.workload_ratio ?? u.workload ?? 0; + return ( + + + { + setSelectedIds((prev) => + e.target.checked ? [...prev, u.id] : prev.filter((id) => id !== u.id) + ); + }} + /> + + +
+
+ +
+
+ + {u.name || u.username} + +

{u.email || "-"}

+
+
+
+ + + + {(u.project_ids ?? []).length} + + + + + {ratio.toFixed(1)}% + + + {getStatusLabel((u.status || "offline") as UserStatus)} + + {canManage && {renderUserActions(u)}} +
+ ); + })} +
+
+
+
{renderedUsers.map((u) => { const ratio = u.workload_ratio ?? u.workload ?? 0; return ( - - - { - setSelectedIds((prev) => - e.target.checked ? [...prev, u.id] : prev.filter((id) => id !== u.id) - ); - }} - /> - - -
-
- -
-
- +
+
+
+ { + setSelectedIds((prev) => + e.target.checked ? [...prev, u.id] : prev.filter((id) => id !== u.id) + ); + }} + className="mt-1" + /> +
+ {u.name || u.username} -

- {u.username} - {u.email && ` · ${u.email}`} -

+

{u.email || "-"}

- - {getRoleLabel(u.role)} - {(u.project_ids ?? []).length} - -
- {(u.top_skills ?? []).slice(0, 3).map((tag) => ( - - {tag} - - ))} - {(!u.top_skills || u.top_skills.length === 0) && ( - - - )} +
+ {ratio.toFixed(1)}% + {canManage && renderUserActions(u)}
- - - {ratio.toFixed(1)}% - - {getStatusLabel((u.status || "offline") as UserStatus)} - {canManage && ( - - - - - - - setEditUser(u)}> - - 编辑 - - setResetPwdUser(u)}> - - 重置密码 - - - statusMutation.mutate({ - userId: u.id, - isActive: !(u.is_active ?? true), - }) - } - > - - {(u.is_active ?? true) ? "禁用用户" : "启用用户"} - - {canDelete && user?.id !== u.id && ( - setDeleteUserState(u)} - > - - 删除 - - )} - - - - )} - +
+
+ +
+
+ +
+
+ 项目 {(u.project_ids ?? []).length}个 + {getStatusLabel((u.status || "offline") as UserStatus)} +
+
); })} - - -
+
+ )} - - - {!filterCollapsed && ( -
- 筛选 - 角色/状态/项目/负载/技能 -
- )} - -
- {!filterCollapsed && ( - - - - - - setProjectIdFilter(e.target.value)} - /> - - - - setSkillIdsFilter(e.target.value)} - /> - - )} -
-
+ {canManage && ( + + )} 100) return "text-destructive"; - if (value >= 70) return "text-amber-600 dark:text-amber-400"; - return "text-emerald-600 dark:text-emerald-400"; + if (value > 100) return "text-red-500"; + if (value >= 60) return "text-yellow-500"; + return "text-green-500"; +} + +function progressClass(value: number): string { + if (value > 100) return "bg-red-500"; + if (value >= 60) return "bg-yellow-400"; + return "bg-green-400"; } export default function WorkloadsPage() { @@ -65,7 +71,7 @@ export default function WorkloadsPage() { () => items.map((r) => ({ member: r.user_name || r.username, - role: toZhRole(r.role), + role: String(r.role || ""), assigned_hours: r.current_assigned_hours, max_weekly_hours: r.max_weekly_hours, workload_ratio: r.workload_ratio, @@ -152,47 +158,99 @@ export default function WorkloadsPage() { }); return ( -
-
-

团队工作负载

-

查看成员负载比例、剩余工时与过载告警

+
+
+
+

团队工作负载

+

查看成员负载比例、剩余工时与过载告警

+
+ +
+ +
+
+
+

团队均值

+

+ {isLoading || hasQueryError ? "--" : `${(summary?.avg_ratio ?? 0).toFixed(1)}%`} +

+
+
+

闲置

+

{isLoading || hasQueryError ? "--" : `${summary?.idle_count ?? 0}人`}

+
+
+

正常

+

{isLoading || hasQueryError ? "--" : `${summary?.normal_count ?? 0}人`}

+
+
+

过载

+

+ {isLoading || hasQueryError ? "--" : `${summary?.overloaded_count ?? 0}人`} +

+
+
{hasOverloaded && ( -
- 过载告警:当前有 {summary?.overloaded_count ?? 0} 名成员负载超过 100%,建议立即执行任务迁移。 +
+ + {summary?.overloaded_count ?? 0}名成员负载超过100%,建议立即执行任务迁移
)} + {hasQueryError && ( -
+
负载数据请求失败,请稍后重试。
)} - - -
- setProjectId(e.target.value)} - /> - - {range === "custom" && ( - <> - setCustomFrom(e.target.value)} /> - setCustomTo(e.target.value)} /> - - )} +
+
+ setProjectId(e.target.value)} + /> +
+
+ + {range === "custom" && ( + <> + setCustomFrom(e.target.value)} + /> + setCustomTo(e.target.value)} + /> + + )} +
+
+
+
+
-
- - - -
- - - 团队平均负载 - - - {isLoading ? ( -

加载中…

- ) : hasQueryError ? ( -

加载失败

- ) : ( -

{(summary?.avg_ratio ?? 0).toFixed(1)}%

- )} -
-
- - - 状态人数 - - - {isLoading ? ( -

加载中…

- ) : hasQueryError ? ( -

加载失败

- ) : ( - <> -

闲置: {summary?.idle_count ?? 0}

-

正常: {summary?.normal_count ?? 0}

-

过载: {summary?.overloaded_count ?? 0}

- - )} -
-
- - - AI 预警摘要 - - - {isLoading ? ( -

加载中…

- ) : hasQueryError ? ( -

加载失败

- ) : ( - <> - {(summary?.overloaded_count ?? 0) > 0 && } - {(summary?.overloaded_count ?? 0) > 0 - ? `存在 ${summary?.overloaded_count ?? 0} 名过载成员,建议调整任务分配` - : "当前无过载成员"} - - )} -
-
+
@@ -277,40 +275,84 @@ export default function WorkloadsPage() { ) : items.length === 0 ? (

暂无数据

) : ( -
- - - - 成员 - 角色 - 已分配工时 - 每周上限 - 负载比例 - 剩余工时 - 操作 - - - + <> +
+
+ + + 成员 + 主要角色 + 负载进度条 + 比例 + 操作 + + + + {items.map((row) => ( + 100 ? "bg-red-50/40 dark:bg-red-900/10" : ""}> + {row.user_name || row.username} + + + + +
+
+
+
+ {row.workload_ratio.toFixed(1)}% +
+ + + {row.workload_ratio.toFixed(1)}% + + + + 查看详情 + + + + ))} + +
+
+
{items.map((row) => ( - 100 ? "bg-red-50/40 dark:bg-red-900/10" : ""}> - {row.user_name || row.username} - {toZhRole(row.role)} - {row.current_assigned_hours.toFixed(1)} - {row.max_weekly_hours.toFixed(1)} - - {row.workload_ratio.toFixed(1)}% - - {row.remaining_hours.toFixed(1)} - +
100 ? "border-red-200 bg-red-50/30" : ""}`} + > +
+
+

{row.user_name || row.username}

+
+ +
+
+ + {row.workload_ratio.toFixed(1)}% + +
+
+
+
+
+
+
+ 剩余工时 {(row.remaining_hours ?? 0).toFixed(1)}h 查看详情 - - +
+
))} - - -
+
+ )} diff --git a/frontend/src/types/inbox.ts b/frontend/src/types/inbox.ts index 8dec220c..d7d47563 100644 --- a/frontend/src/types/inbox.ts +++ b/frontend/src/types/inbox.ts @@ -1,4 +1,4 @@ -import type { AIPMOWorkItem } from "@/api/aiPmo"; +import type { AIPMOApprovalRequirement, AIPMOWorkItem } from "@/api/aiPmo"; import type { NotificationItem } from "@/api/notifications"; export type InboxSourceKind = "notification" | "ai_pmo_work_item"; @@ -42,6 +42,8 @@ export interface InboxItemViewModel { updated_at?: string | null; stage_text?: string | null; next_step_text?: string | null; + latest_policy_gate_code?: string | null; + approval_requirement?: AIPMOApprovalRequirement | null; source_notification?: NotificationItem; source_work_item?: AIPMOWorkItem; } diff --git a/frontend/tsconfig.tsbuildinfo b/frontend/tsconfig.tsbuildinfo index de3c47b4..99c5b4c9 100644 --- a/frontend/tsconfig.tsbuildinfo +++ b/frontend/tsconfig.tsbuildinfo @@ -1 +1 @@ -{"root":["./src/main.tsx","./src/vite-env.d.ts","./src/components/skillradarchart.tsx","./src/components/themeinit.tsx","./src/components/ai-steward/floatingqachat.tsx","./src/components/ai-steward/openclawcommanderpanel.tsx","./src/components/ai-steward/promptrolesettings.tsx","./src/components/ai-steward/qahistorypanel.tsx","./src/components/budget/budgetcontrolpanel.tsx","./src/components/command/commanditems.ts","./src/components/inbox/inboxlistitem.tsx","./src/components/layout/layout.tsx","./src/components/layout/sidebar.tsx","./src/components/layout/topbar.tsx","./src/components/model-capability/costoptimizationpanel.tsx","./src/components/model-capability/experimentsyncpanel.tsx","./src/components/model-capability/modelcapabilitymatrix.tsx","./src/components/model-capability/modelupdateimpactpanel.tsx","./src/components/sstc/aivisibilitypanel.tsx","./src/components/sstc/sstcchainview.tsx","./src/components/sstc/sstccharterstagepanel.tsx","./src/components/sstc/sstcexecutionboardpanel.tsx","./src/components/sstc/sstcglobalheader.tsx","./src/components/sstc/sstcreleasecreatedialog.tsx","./src/components/sstc/sstcreleasestagepanel.tsx","./src/components/sstc/sstcreviewcreatedialog.tsx","./src/components/sstc/sstcreviewstagepanel.tsx","./src/components/sstc/sstcriskfloatpanel.tsx","./src/components/sstc/sstcsignalstagepanel.tsx","./src/components/sstc/sstcstagenav.tsx","./src/components/sstc/sstctaskcreatedialog.tsx","./src/components/sstc/sstctaskstagepanel.tsx","./src/components/sstc/sstcworkspace.tsx","./src/components/sstc/signalcontenthovercard.tsx","./src/components/sstc/chartermodules.ts","./src/components/sstc/sstcutils.ts","./src/components/sstc/usesstcworkspaceorchestration.ts","./src/components/ui/badge.tsx","./src/components/ui/button.tsx","./src/components/ui/card.tsx","./src/components/ui/dialog.tsx","./src/components/ui/dropdown-menu.tsx","./src/components/ui/input.tsx","./src/components/ui/label.tsx","./src/components/ui/switch.tsx","./src/components/ui/table.tsx","./src/components/ui/tabs.tsx","./src/components/ui/textarea.tsx","./src/components/ui/toast.tsx","./src/components/users/userformdialog.tsx","./src/hooks/usetheme.ts","./src/lib/api.ts","./src/lib/apierror.ts","./src/lib/datetime.ts","./src/lib/display.ts","./src/lib/feishueventmode.ts","./src/lib/formatinboxtime.ts","./src/lib/inbox.ts","./src/lib/inboxtextmap.ts","./src/lib/rbac.ts","./src/lib/sessionguard.ts","./src/lib/title.ts","./src/lib/utils.ts","./src/lib/zhcn.ts","./src/pages/aiconfigpage.tsx","./src/pages/aistewardpage.tsx","./src/pages/charterdetailpage.tsx","./src/pages/dashboardpage.tsx","./src/pages/feishuconfigpage.tsx","./src/pages/inboxpage.tsx","./src/pages/loginpage.tsx","./src/pages/projectboardpage.tsx","./src/pages/projectspage.tsx","./src/pages/recipespage.tsx","./src/pages/schedulercenterpage.tsx","./src/pages/skilltaskspage.tsx","./src/pages/skillspage.tsx","./src/pages/userprofilepage.tsx","./src/pages/userskillspage.tsx","./src/pages/userspage.tsx","./src/pages/workloaddetailpage.tsx","./src/pages/workloadspage.tsx","./src/router/index.tsx","./src/stores/auth.ts","./src/types/inbox.ts"],"version":"5.6.3"} \ No newline at end of file +{"root":["./src/main.tsx","./src/vite-env.d.ts","./src/components/skillradarchart.tsx","./src/components/themeinit.tsx","./src/components/ai-steward/floatingqachat.tsx","./src/components/ai-steward/openclawcommanderpanel.tsx","./src/components/ai-steward/promptrolesettings.tsx","./src/components/ai-steward/qahistorypanel.tsx","./src/components/budget/budgetcontrolpanel.tsx","./src/components/command/commanditems.ts","./src/components/inbox/inboxactionpanel.tsx","./src/components/inbox/inboxlistitem.tsx","./src/components/layout/layout.tsx","./src/components/layout/sidebar.tsx","./src/components/layout/topbar.tsx","./src/components/model-capability/costoptimizationpanel.tsx","./src/components/model-capability/experimentsyncpanel.tsx","./src/components/model-capability/modelcapabilitymatrix.tsx","./src/components/model-capability/modelupdateimpactpanel.tsx","./src/components/sstc/aivisibilitypanel.tsx","./src/components/sstc/sstcchainview.tsx","./src/components/sstc/sstccharterstagepanel.tsx","./src/components/sstc/sstcexecutionboardpanel.tsx","./src/components/sstc/sstcglobalheader.tsx","./src/components/sstc/sstcreleasecreatedialog.tsx","./src/components/sstc/sstcreleasestagepanel.tsx","./src/components/sstc/sstcreviewcreatedialog.tsx","./src/components/sstc/sstcreviewstagepanel.tsx","./src/components/sstc/sstcriskfloatpanel.tsx","./src/components/sstc/sstcsignalstagepanel.tsx","./src/components/sstc/sstcstagenav.tsx","./src/components/sstc/sstctaskcreatedialog.tsx","./src/components/sstc/sstctaskstagepanel.tsx","./src/components/sstc/sstcworkspace.tsx","./src/components/sstc/signalcontenthovercard.tsx","./src/components/sstc/chartermodules.ts","./src/components/sstc/sstcutils.ts","./src/components/sstc/usesstcworkspaceorchestration.ts","./src/components/ui/rolebadge.tsx","./src/components/ui/skillbadge.tsx","./src/components/ui/badge.tsx","./src/components/ui/button.tsx","./src/components/ui/card.tsx","./src/components/ui/dialog.tsx","./src/components/ui/dropdown-menu.tsx","./src/components/ui/input.tsx","./src/components/ui/label.tsx","./src/components/ui/switch.tsx","./src/components/ui/table.tsx","./src/components/ui/tabs.tsx","./src/components/ui/textarea.tsx","./src/components/ui/toast.tsx","./src/components/users/userformdialog.tsx","./src/hooks/usetheme.ts","./src/lib/api.ts","./src/lib/apierror.ts","./src/lib/datetime.ts","./src/lib/display.ts","./src/lib/feishueventmode.ts","./src/lib/formatinboxtime.ts","./src/lib/inbox.ts","./src/lib/inboxtextmap.ts","./src/lib/rbac.ts","./src/lib/sessionguard.ts","./src/lib/title.ts","./src/lib/utils.ts","./src/lib/zhcn.ts","./src/pages/aiconfigpage.tsx","./src/pages/aistewardpage.tsx","./src/pages/charterdetailpage.tsx","./src/pages/dashboardpage.tsx","./src/pages/feishuconfigpage.tsx","./src/pages/inboxpage.tsx","./src/pages/loginpage.tsx","./src/pages/projectboardpage.tsx","./src/pages/projectspage.tsx","./src/pages/recipespage.tsx","./src/pages/schedulercenterpage.tsx","./src/pages/skilltaskspage.tsx","./src/pages/skillspage.tsx","./src/pages/userprofilepage.tsx","./src/pages/userskillspage.tsx","./src/pages/userspage.tsx","./src/pages/workloaddetailpage.tsx","./src/pages/workloadspage.tsx","./src/router/index.tsx","./src/stores/auth.ts","./src/types/inbox.ts"],"version":"5.6.3"} \ No newline at end of file