From d2672702bc1673ce6303a16a02ba8c68abf64467 Mon Sep 17 00:00:00 2001 From: Yuuuu <1569752928@qq.com> Date: Sat, 28 Mar 2026 09:06:12 +0800 Subject: [PATCH] Add consultant dispatch contract and Feishu logging --- CLAUDE.md | 2 + backend/app/api/rag_ask_contract.py | 7 + .../ai_pmo_kernel_mixins_ingress_render.py | 29 +- .../services/ai_pmo/consultant_dispatch.py | 315 ++++++++++++++++++ .../mixin_reply_llm_core.py | 25 ++ backend/tests/test_draft_confirm_handler.py | 72 ++++ backend/tests/test_rag_route_contract.py | 26 ++ .../consultant_dispatch_feishu_runs.jsonl | 4 + backend/tmp_run_feishu_case.py | 73 +++- .../components/ai-steward/FloatingQAChat.tsx | 104 ++++++ 10 files changed, 652 insertions(+), 5 deletions(-) create mode 100644 backend/app/services/ai_pmo/consultant_dispatch.py create mode 100644 backend/tmp_acceptance/consultant_dispatch_feishu_runs.jsonl diff --git a/CLAUDE.md b/CLAUDE.md index c4a3b388..a8fb6947 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -112,6 +112,7 @@ backend/ - 新增:`backend/app/core/startup/model_probe.py`(启动期模型连通性探针:优先走 DB 里的实际引擎配置,失败不污染 provider circuit) - 新增:`backend/app/services/sstc/charter_payload.py`(轻量宪章上下文提取模块:供 Signal Planner / SSTC 共享,避免导入环) - 新增:`backend/app/services/task_breakdown.py`(任务拆解业务对象归一、技能标签回填、自动指派阈值判断) +- 新增:`backend/app/services/ai_pmo/consultant_dispatch.py`(轻量顾问式统一调度合同与注册表:保留现有业务 Prompt 拆分,统一生成 `domain/mode/risk_level/missing_fields/allow_execute/next_action`) - 新增:`backend/app/config/ai_prompt_registry.py`(prompt registry / manifest,统一登记 prompt 场景、调用入口与契约) - 更新:`backend/app/config/ai_prompts.py`(默认角色 Prompt 调整为“产品经理(立项评审)”,强调查重、MVP、前景与需求清单) - 更新:`backend/app/config/ai_prompts_catalog/part_02.py`(`PROMPT_DRAFT_GENERATOR` 增加 active_projects / similar_projects / MVP / prospect / requirements 评审字段) @@ -552,6 +553,7 @@ uvicorn app.main:app --reload --host 0.0.0.0 --port 8000 - AI PMO 草稿决策接口(`POST /ai-pmo/work-items/{work_item_id}/draft-decision`):支持 `action=confirm|revise|cancel`,与飞书“确认/修改/取消”行为一致 - AI PMO 路由包(`backend/app/api/ai_pmo_api/__init__.py`)只负责聚合与 tags,`/ai-pmo` 前缀只能由子模块共享 router 挂一次,禁止包级与模块级重复叠加前缀导致 `/ai-pmo/ai-pmo/...` 路径 - RAG 问答主入口契约(`POST /rag/ask`):HTTP 入口已收敛到 `AIPMOKernelService.process_ingress` 主链;请求侧统一透传 `asked_via/action_mode/message_intent/mixed_signal_part`,由 `ingress_options.rag_request_context` 下传到 query executor 并映射到 `AdaptiveRAGService.answer_question`,返回侧从 ingress `rag_result`/`direct_reply_text` 组装兼容响应 +- RAG / AI 管家顾问合同:`POST /rag/ask` 返回体会透传 `consultant_dispatch={domain,mode,risk_level,missing_fields,allow_execute,next_action}`;该合同由 `backend/app/services/ai_pmo/consultant_dispatch.py` 统一生成,飞书 mention 回复与 AI 管家问答共享同一份模式判断 - RAG / AI Steward 路由包(`backend/app/api/rag_api/__init__.py`、`backend/app/api/ai_steward_api/__init__.py`)不再重复挂包级 prefix;路由 canonical 由 `shared.py` 内的共享 router 统一承担,避免运行中出现 `/rag/rag/*`、`/ai-steward/ai-steward/*` - AI PMO 消息台列表接口(`GET /ai-pmo/work-items`):返回 `latest_policy_gate_code` 与规范化 `approval_requirement{required,reason,message}`(来源于最新 `decision_records` 投影) - AI PMO 消息台列表接口补充:返回草稿字段 `draft_content/draft_expires_at/draft_version/confirm_message_id`,用于草稿确认卡片渲染 diff --git a/backend/app/api/rag_ask_contract.py b/backend/app/api/rag_ask_contract.py index 41f9f2ae..43cab963 100644 --- a/backend/app/api/rag_ask_contract.py +++ b/backend/app/api/rag_ask_contract.py @@ -71,6 +71,13 @@ def build_rag_response_payload(*, ingress_result: dict[str, Any]) -> dict[str, A result = ingress_result if isinstance(ingress_result, dict) else {} rag_result = result.get("rag_result") if isinstance(result.get("rag_result"), dict) else {} payload = dict(rag_result) + consultant_dispatch = ( + payload.get("consultant_dispatch") + if isinstance(payload.get("consultant_dispatch"), dict) + else (result.get("consultant_dispatch") if isinstance(result.get("consultant_dispatch"), dict) else None) + ) + if isinstance(consultant_dispatch, dict): + payload["consultant_dispatch"] = consultant_dispatch answer = str(payload.get("answer") or result.get("direct_reply_text") or "").strip() payload["answer"] = answer or "当前暂无可用回答。" diff --git a/backend/app/services/ai_pmo/ai_pmo_kernel/mixins/ingress/ai_pmo_kernel_mixins_ingress_render.py b/backend/app/services/ai_pmo/ai_pmo_kernel/mixins/ingress/ai_pmo_kernel_mixins_ingress_render.py index 456d5fd2..d4b5ba47 100644 --- a/backend/app/services/ai_pmo/ai_pmo_kernel/mixins/ingress/ai_pmo_kernel_mixins_ingress_render.py +++ b/backend/app/services/ai_pmo/ai_pmo_kernel/mixins/ingress/ai_pmo_kernel_mixins_ingress_render.py @@ -3,6 +3,7 @@ from __future__ import annotations from .... import ai_pmo_kernel_common_core as _common +from ....consultant_dispatch import build_consultant_dispatch_contract from ....workflow_semantic_planner import WorkflowSemanticPlanner for _name, _value in vars(_common).items(): @@ -24,6 +25,17 @@ class KernelIngressRenderMixin: ) -> dict[str, Any]: linked_project_ids = [int(work_item.project_id)] if work_item.project_id is not None else [] direct_reply_text = str(render.get("text") or "").strip() + primary_operation_code = self._primary_operation_code(str(intent.name or "")) + risk_level = self._risk_level_for_intent(str(intent.name or "")) + missing_fields = ( + execution_result.get("missing_fields") + if isinstance(execution_result.get("missing_fields"), list) + else ( + (intent.metadata or {}).get("missing_fields") + if isinstance(intent.metadata, dict) and isinstance((intent.metadata or {}).get("missing_fields"), list) + else [] + ) + ) mixed_sidecar_reply = ( str((intent.metadata or {}).get("mixed_sidecar_reply") or "").strip() if isinstance(intent.metadata, dict) @@ -50,6 +62,17 @@ class KernelIngressRenderMixin: signal_ids = [primary_signal_id, *signal_ids] if signal_ids and primary_signal_id is None: primary_signal_id = signal_ids[0] + consultant_dispatch = build_consultant_dispatch_contract( + intent_name=str(intent.name or ""), + message_text=str(getattr(work_item, "raw_message", "") or direct_reply_text or intent.signal_summary or ""), + source_system=str(getattr(work_item, "source_system", "") or "pmflow"), + status=status, + classification=classification, + primary_operation_code=primary_operation_code, + risk_level=risk_level, + missing_fields=missing_fields, + direct_reply_text=direct_reply_text, + ) result = { "handled": True, "status": status, @@ -77,6 +100,7 @@ class KernelIngressRenderMixin: "workflow_route": str(work_item.intent or execution_result.get("workflow_route") or "").strip() or None, "direct_reply_required": bool(direct_reply_required), "direct_reply_text": direct_reply_text, + "consultant_dispatch": consultant_dispatch, "reply_template_key": str(render.get("template_key") or "").strip(), "work_item_id": int(work_item.id), "decision_level": work_item.decision_level, @@ -112,7 +136,10 @@ class KernelIngressRenderMixin: if isinstance(draft_expires_at, str) and draft_expires_at.strip(): result["draft_expires_at"] = draft_expires_at.strip() if intent.name == INTENT_QUERY and isinstance(execution_result.get("rag_result"), dict): - result["rag_result"] = execution_result["rag_result"] + rag_result = dict(execution_result["rag_result"]) + if not isinstance(rag_result.get("consultant_dispatch"), dict): + rag_result["consultant_dispatch"] = consultant_dispatch + result["rag_result"] = rag_result if intent.name == INTENT_PROJECT_CREATE: raw_payload = execution_result.get("raw_payload") if isinstance(execution_result.get("raw_payload"), dict) else {} result["project"] = raw_payload.get("project") diff --git a/backend/app/services/ai_pmo/consultant_dispatch.py b/backend/app/services/ai_pmo/consultant_dispatch.py new file mode 100644 index 00000000..64facc1e --- /dev/null +++ b/backend/app/services/ai_pmo/consultant_dispatch.py @@ -0,0 +1,315 @@ +"""Lightweight consultant dispatch contract and registries.""" + +from __future__ import annotations + +import re +from typing import Any, Optional + +DOMAIN_PROJECT = "project" +DOMAIN_TASK = "task" +DOMAIN_SIGNAL = "signal" +DOMAIN_RAG = "rag" +DOMAIN_FOLLOWUP = "followup" +DOMAIN_GENERIC = "generic" + +MODE_CLARIFY = "clarify" +MODE_ADVISE = "advise" +MODE_CHALLENGE = "challenge" +MODE_BRAINSTORM = "brainstorm" +MODE_EXECUTE = "execute" + +CONSULTANT_DOMAIN_PROMPT_REGISTRY: dict[str, dict[str, str]] = { + DOMAIN_PROJECT: {"prompt_key": "PROMPT_GENERATE_CHARTER", "role_hint": "pm_dispatcher"}, + DOMAIN_TASK: {"prompt_key": "PROMPT_DECOMPOSE_TASKS", "role_hint": "delivery_coach"}, + DOMAIN_SIGNAL: {"prompt_key": "PROMPT_FEISHU_DIRECT_QA", "role_hint": "delivery_coach"}, + DOMAIN_RAG: {"prompt_key": "PROMPT_RAG_ANSWER", "role_hint": "pm_dispatcher"}, + DOMAIN_FOLLOWUP: {"prompt_key": "PROMPT_AI_PMO_QA", "role_hint": "delivery_coach"}, + DOMAIN_GENERIC: {"prompt_key": "PROMPT_RAG_ANSWER", "role_hint": "pm_dispatcher"}, +} + +CONSULTANT_MODE_WRAPPER_REGISTRY: dict[str, dict[str, str]] = { + MODE_CLARIFY: {"label": "信息不足,先澄清再推进", "tone": "direct"}, + MODE_ADVISE: {"label": "直接建议最稳妥方案", "tone": "consultative"}, + MODE_CHALLENGE: {"label": "高风险或范围过大,先挑战假设", "tone": "guardrail"}, + MODE_BRAINSTORM: {"label": "先发散方案,再收敛边界", "tone": "creative"}, + MODE_EXECUTE: {"label": "信息齐全,可继续执行", "tone": "action"}, +} + +CONSULTANT_CHANNEL_STRATEGIES: dict[str, dict[str, Any]] = { + "feishu": {"max_chars": 220, "prefer_direct": True}, + "pmflow": {"max_chars": 500, "prefer_direct": False}, + "pmflow_manual": {"max_chars": 500, "prefer_direct": False}, +} + +_QUERY_OPERATION_CODES = {"query.answer", "query.read_only"} +_SCOPE_EXPANSION_RE = re.compile( + r"(整个系统|全部重做|全量重构|一次替换所有|一次性全部|推翻重来|从零重写|所有模块都|整体改造)", + re.IGNORECASE, +) +_BRAINSTORM_RE = re.compile( + r"(想.idea|想法|brainstorm|头脑风暴|一起想|给我几个方案|给几个方向|发散一下|有哪些思路)", + re.IGNORECASE, +) +_ADVISE_RE = re.compile( + r"(建议|怎么做|如何做|优先|取舍|方案|还是|更适合|更应该|值不值得|要不要)", + re.IGNORECASE, +) + +_MISSING_FIELD_LABELS = { + "project_id": "归属项目", + "task_title": "任务标题", + "title": "标题", + "description": "描述", + "project_scope": "项目范围", +} + + +def _normalize_missing_fields(value: Any) -> list[str]: + if not isinstance(value, list): + return [] + fields: list[str] = [] + for item in value: + text = str(item or "").strip() + if text and text not in fields: + fields.append(text) + return fields + + +def _resolve_domain(*, intent_name: str, primary_operation_code: Optional[str], classification: str) -> str: + normalized_op = str(primary_operation_code or "").strip().lower() + normalized_intent = str(intent_name or "").strip().lower() + normalized_classification = str(classification or "").strip().lower() + candidate = normalized_op or normalized_intent + + if candidate.startswith("project.") or normalized_classification in {"project", "new_project"}: + return DOMAIN_PROJECT + if candidate.startswith("task.") or normalized_classification == "task": + return DOMAIN_TASK + if ( + candidate.startswith("signal.") + or candidate.startswith("signal_source.") + or candidate.startswith("project_signal.") + or normalized_classification in {"signal", "project_signal", "command"} + ): + return DOMAIN_SIGNAL + if candidate.startswith("followup."): + return DOMAIN_FOLLOWUP + if candidate in _QUERY_OPERATION_CODES or normalized_classification in {"question", "qa"}: + return DOMAIN_RAG + return DOMAIN_GENERIC + + +def _is_query_like(*, primary_operation_code: Optional[str], intent_name: str) -> bool: + normalized_op = str(primary_operation_code or "").strip().lower() + normalized_intent = str(intent_name or "").strip().lower() + return normalized_op in _QUERY_OPERATION_CODES or normalized_intent == "query.read_only" + + +def _derive_allow_execute( + *, + primary_operation_code: Optional[str], + status: str, + missing_fields: list[str], +) -> bool: + if _is_query_like(primary_operation_code=primary_operation_code, intent_name=str(primary_operation_code or "")): + return False + if missing_fields: + return False + normalized_status = str(status or "").strip().lower() + return normalized_status in {"succeeded", "answered", "captured", "completed"} + + +def _looks_like_scope_expansion(message_text: str) -> bool: + return bool(_SCOPE_EXPANSION_RE.search(str(message_text or ""))) + + +def _looks_like_brainstorm(message_text: str) -> bool: + return bool(_BRAINSTORM_RE.search(str(message_text or ""))) + + +def _looks_like_advice(message_text: str) -> bool: + return bool(_ADVISE_RE.search(str(message_text or ""))) + + +def _derive_mode( + *, + intent_name: str, + message_text: str, + status: str, + risk_level: str, + missing_fields: list[str], + allow_execute: bool, + primary_operation_code: Optional[str], +) -> str: + if missing_fields: + return MODE_CLARIFY + if _looks_like_scope_expansion(message_text): + return MODE_CHALLENGE + if str(risk_level or "").strip().lower() == "high" and not allow_execute and not _is_query_like( + primary_operation_code=primary_operation_code, + intent_name=intent_name, + ): + return MODE_CHALLENGE + if _looks_like_brainstorm(message_text): + return MODE_BRAINSTORM + if allow_execute and not _is_query_like(primary_operation_code=primary_operation_code, intent_name=intent_name): + return MODE_EXECUTE + if _looks_like_advice(message_text) or _is_query_like(primary_operation_code=primary_operation_code, intent_name=intent_name): + return MODE_ADVISE + if str(status or "").strip().lower() == "pending_manual": + return MODE_CHALLENGE + return MODE_ADVISE + + +def _build_clarify_next_action(*, missing_fields: list[str], direct_reply_text: str) -> str: + if direct_reply_text: + return direct_reply_text[:240] + labels = [_MISSING_FIELD_LABELS.get(item, item) for item in missing_fields] + suffix = "、".join([label for label in labels if label]) or "关键信息" + return f"请先补充 {suffix},我再继续推进。" + + +def _build_next_action( + *, + domain: str, + mode: str, + risk_level: str, + missing_fields: list[str], + direct_reply_text: str, +) -> str: + if mode == MODE_CLARIFY: + return _build_clarify_next_action( + missing_fields=missing_fields, + direct_reply_text=direct_reply_text, + ) + if mode == MODE_CHALLENGE: + if direct_reply_text: + return direct_reply_text[:240] + if str(risk_level or "").strip().lower() == "high": + return "建议先拆分范围,再确认是否继续推进高风险动作。" + return "建议先确认目标边界与优先级,再决定是否继续推进。" + if mode == MODE_BRAINSTORM: + return "请补充目标用户、核心场景和约束,我会继续给出 2-3 个可选方向。" + if mode == MODE_EXECUTE: + if domain == DOMAIN_PROJECT: + return "可继续查看项目结果,或补充负责人与排期信息。" + if domain == DOMAIN_TASK: + return "可继续补充负责人、优先级或截止时间。" + if domain == DOMAIN_SIGNAL: + return "可继续确认归属项目,或查看后续自动处理结果。" + return "可继续查看结果,或补充更多执行细节。" + if direct_reply_text: + return direct_reply_text[:240] + return "你可以继续追问细节,或让我给出更具体的建议。" + + +def build_consultant_dispatch_contract( + *, + intent_name: str, + message_text: str, + source_system: str, + status: str, + classification: str, + primary_operation_code: Optional[str], + risk_level: str, + missing_fields: Optional[list[str]] = None, + direct_reply_text: str = "", +) -> dict[str, Any]: + normalized_missing = _normalize_missing_fields(missing_fields) + domain = _resolve_domain( + intent_name=intent_name, + primary_operation_code=primary_operation_code, + classification=classification, + ) + allow_execute = _derive_allow_execute( + primary_operation_code=primary_operation_code, + status=status, + missing_fields=normalized_missing, + ) + mode = _derive_mode( + intent_name=intent_name, + message_text=message_text, + status=status, + risk_level=risk_level, + missing_fields=normalized_missing, + allow_execute=allow_execute, + primary_operation_code=primary_operation_code, + ) + _ = resolve_prompt_dispatch(domain=domain, mode=mode, channel=source_system) + return { + "domain": domain, + "mode": mode, + "risk_level": str(risk_level or "low").strip().lower() or "low", + "missing_fields": normalized_missing, + "allow_execute": bool(allow_execute), + "next_action": _build_next_action( + domain=domain, + mode=mode, + risk_level=risk_level, + missing_fields=normalized_missing, + direct_reply_text=str(direct_reply_text or "").strip(), + ), + } + + +def resolve_prompt_dispatch(*, domain: str, mode: str, channel: str) -> dict[str, Any]: + normalized_domain = str(domain or "").strip().lower() or DOMAIN_GENERIC + normalized_mode = str(mode or "").strip().lower() or MODE_ADVISE + normalized_channel = str(channel or "").strip().lower() or "pmflow" + return { + "domain_prompt": CONSULTANT_DOMAIN_PROMPT_REGISTRY.get( + normalized_domain, + CONSULTANT_DOMAIN_PROMPT_REGISTRY[DOMAIN_GENERIC], + ), + "mode_wrapper": CONSULTANT_MODE_WRAPPER_REGISTRY.get( + normalized_mode, + CONSULTANT_MODE_WRAPPER_REGISTRY[MODE_ADVISE], + ), + "channel_strategy": CONSULTANT_CHANNEL_STRATEGIES.get( + normalized_channel, + CONSULTANT_CHANNEL_STRATEGIES["pmflow"], + ), + } + + +def build_channel_consultant_reply( + *, + consultant_dispatch: Any, + direct_reply_text: str = "", + channel: str, +) -> str: + if not isinstance(consultant_dispatch, dict): + return "" + mode = str(consultant_dispatch.get("mode") or "").strip().lower() + if mode not in {MODE_CLARIFY, MODE_CHALLENGE, MODE_BRAINSTORM, MODE_ADVISE}: + return "" + + dispatch_meta = resolve_prompt_dispatch( + domain=str(consultant_dispatch.get("domain") or DOMAIN_GENERIC), + mode=mode, + channel=channel, + ) + channel_strategy = dispatch_meta.get("channel_strategy") if isinstance(dispatch_meta, dict) else {} + max_chars = int((channel_strategy or {}).get("max_chars") or 220) + reply_text = str(direct_reply_text or "").strip() + if reply_text: + return reply_text[:max_chars] + + next_action = str(consultant_dispatch.get("next_action") or "").strip() + missing_fields = _normalize_missing_fields(consultant_dispatch.get("missing_fields")) + if mode == MODE_CLARIFY: + labels = [_MISSING_FIELD_LABELS.get(item, item) for item in missing_fields] + suffix = "、".join([label for label in labels if label]) or "关键信息" + return f"当前还不能继续推进,请先补充 {suffix}。{next_action or '补齐后我再继续处理。'}"[:max_chars] + if mode == MODE_CHALLENGE: + return f"这次动作风险较高,建议先不要直接推进。{next_action or '请先确认边界后再继续。'}"[:max_chars] + if mode == MODE_BRAINSTORM: + return f"我们可以先一起发散方案。{next_action or '请补充目标和约束,我给你几个方向。'}"[:max_chars] + return f"当前建议:{next_action or '先按最稳妥方向继续。'}"[:max_chars] + + +__all__ = [ + "build_channel_consultant_reply", + "build_consultant_dispatch_contract", + "resolve_prompt_dispatch", +] diff --git a/backend/app/services/integrations/feishu_integration_service/mixin_reply_llm_core.py b/backend/app/services/integrations/feishu_integration_service/mixin_reply_llm_core.py index 34580836..82b959e0 100644 --- a/backend/app/services/integrations/feishu_integration_service/mixin_reply_llm_core.py +++ b/backend/app/services/integrations/feishu_integration_service/mixin_reply_llm_core.py @@ -1,6 +1,7 @@ from __future__ import annotations from .shared import * +from ...ai_pmo.consultant_dispatch import build_channel_consultant_reply from ...ai_pmo.workflow_semantic_planner import WorkflowSemanticPlanner class FeishuReplyLLMCoreMixin: @@ -79,6 +80,19 @@ class FeishuReplyLLMCoreMixin: ) return + consultant_reply = self._build_consultant_dispatch_reply(capture_result) + if consultant_reply: + await self._safe_reply_text( + chat_id=chat_id, + mention_open_id=sender_open_id, + mention_name=sender_name, + text=consultant_reply, + trace_id=trace_id, + source_message_id=source_message_id, + perf_t0=perf_t0, + ) + return + try: status = str(capture_result.get("status") or "unknown") classification = str(capture_result.get("classification") or "unknown") @@ -230,6 +244,17 @@ class FeishuReplyLLMCoreMixin: ) -> bool: return WorkflowSemanticPlanner.is_action_request_capture(capture_result=capture_result) + @staticmethod + def _build_consultant_dispatch_reply(capture_result: Dict[str, Any]) -> Optional[str]: + if not isinstance(capture_result, dict): + return None + reply = build_channel_consultant_reply( + consultant_dispatch=capture_result.get("consultant_dispatch"), + direct_reply_text=str(capture_result.get("direct_reply_text") or "").strip(), + channel="feishu", + ) + return str(reply or "").strip() or None + @classmethod def _build_action_request_ack_reply( cls, diff --git a/backend/tests/test_draft_confirm_handler.py b/backend/tests/test_draft_confirm_handler.py index a0341e86..f9780475 100644 --- a/backend/tests/test_draft_confirm_handler.py +++ b/backend/tests/test_draft_confirm_handler.py @@ -13,6 +13,7 @@ from app.services.ai_pmo.draft_confirm_handler import DraftConfirmHandler from app.services.ai_pmo.ai_pmo_kernel_common_core.constants import STATE_DRAFT_PENDING_CONFIRM from app.services.ai_pmo.ai_pmo_kernel_ingress_flow import mixin_auto from app.services.adaptive_rag_service.mixin_deep_guard_answer import AdaptiveRAGDeepGuardAnswerMixin +from app.services.ai_pmo.consultant_dispatch import build_consultant_dispatch_contract from app.services.integrations.feishu_integration_service.mixin_reply_llm_core import ( FeishuReplyLLMCoreMixin, ) @@ -163,6 +164,77 @@ def test_feishu_reply_llm_core_action_ack_uses_semantic_bundle_only() -> None: assert ack == "已收到,项目创建和信号源接入请求已记录。" +def test_consultant_dispatch_contract_marks_incomplete_task_create_as_clarify() -> None: + contract = build_consultant_dispatch_contract( + intent_name="task.create", + message_text="帮我建个任务", + source_system="pmflow_manual", + status="pending_manual", + classification="command", + primary_operation_code="task.create", + risk_level="medium", + missing_fields=["project_id", "task_title"], + direct_reply_text="未执行:当前建任务请求还缺少 归属项目、任务标题。请补充后我再创建。", + ) + + assert contract["domain"] == "task" + assert contract["mode"] == "clarify" + assert contract["allow_execute"] is False + assert contract["missing_fields"] == ["project_id", "task_title"] + assert "补充" in contract["next_action"] + + +def test_consultant_dispatch_contract_marks_scope_expansion_as_challenge() -> None: + contract = build_consultant_dispatch_contract( + intent_name="project.create", + message_text="把整个系统全部重做并一次替换所有模块", + source_system="pmflow_manual", + status="pending_manual", + classification="command", + primary_operation_code="project.create", + risk_level="high", + missing_fields=[], + direct_reply_text="当前建议先拆分范围,再确认是否继续推进。", + ) + + assert contract["domain"] == "project" + assert contract["mode"] == "challenge" + assert contract["allow_execute"] is False + assert contract["next_action"] + + +def test_feishu_reply_llm_core_builds_contract_reply_for_clarify_and_challenge() -> None: + clarify = FeishuReplyLLMCoreMixin._build_consultant_dispatch_reply( + { + "direct_reply_text": "", + "consultant_dispatch": { + "domain": "task", + "mode": "clarify", + "risk_level": "medium", + "missing_fields": ["project_id", "task_title"], + "allow_execute": False, + "next_action": "请补充归属项目和任务标题后继续。", + }, + } + ) + challenge = FeishuReplyLLMCoreMixin._build_consultant_dispatch_reply( + { + "direct_reply_text": "", + "consultant_dispatch": { + "domain": "project", + "mode": "challenge", + "risk_level": "high", + "missing_fields": [], + "allow_execute": False, + "next_action": "建议先拆分范围,再确认是否继续推进。", + }, + } + ) + + assert "补充" in str(clarify or "") + assert "建议" in str(challenge or "") + + @pytest.mark.asyncio async def test_draft_confirm_handler_repeated_confirm_returns_inbox_success_copy() -> None: class _FakeResult: diff --git a/backend/tests/test_rag_route_contract.py b/backend/tests/test_rag_route_contract.py index 898e347d..d048c886 100644 --- a/backend/tests/test_rag_route_contract.py +++ b/backend/tests/test_rag_route_contract.py @@ -213,3 +213,29 @@ def test_build_rag_response_payload_preserves_rag_fields() -> None: assert payload["trace_id"] == "ai-pmo-321" assert payload["reply_required"] is True assert payload["reply_text"] == "fallback" + + +def test_build_rag_response_payload_falls_back_to_top_level_consultant_dispatch() -> None: + consultant_dispatch = { + "domain": "project", + "mode": "challenge", + "risk_level": "high", + "missing_fields": [], + "allow_execute": False, + "next_action": "请先拆分范围,再确认是否继续推进高风险动作。", + } + + payload = rag_ask_contract.build_rag_response_payload( + ingress_result={ + "status": "answered", + "intent": "project.create", + "direct_reply_text": "当前建议先拆分范围。", + "consultant_dispatch": consultant_dispatch, + "rag_result": { + "answer": "当前建议先拆分范围。", + "processing_stage": "qa", + }, + } + ) + + assert payload["consultant_dispatch"] == consultant_dispatch diff --git a/backend/tmp_acceptance/consultant_dispatch_feishu_runs.jsonl b/backend/tmp_acceptance/consultant_dispatch_feishu_runs.jsonl new file mode 100644 index 00000000..0a307911 --- /dev/null +++ b/backend/tmp_acceptance/consultant_dispatch_feishu_runs.jsonl @@ -0,0 +1,4 @@ +{"generated_at": "2026-03-28T08:49:51.757151", "case": "advise", "source_message_id": "codex-feishu-advise-1774658965", "question": "统一决策层值不值得先做", "capture_status": "answered", "capture_direct_reply_text": "不值得优先做。 \n依据:当前无项目明确关联“统一决策层”需求;系统中22个活跃项目均未在名称、信号或任务中体现该概念;核心功能模块(如AI管家、智能配置)也未定义相关能力路径;知识图谱与代码索引中无“decision layer”“unified governance”等关键词匹配。 \n下一步:请提供该需求的原始输入来源(如飞书消息ID、文档链接、负责人姓名)及预期交付物定义。 \n信息不足,缺少:①需求提出人;②对应项目ID或信号ID;③验收标准描述。", "consultant_dispatch": null, "run_status": "missing", "processing": {"status": "success", "route": "ai_pmo.query.answer", "intent": "query.answer", "reply_source": "action_succeeded", "error_message": ""}, "outbox": [{"id": 652, "status": "sent", "attempts": 1, "sent_message_id": "om_x100b5346147060a4c116c1d4a6ffe50", "text": "不值得优先做。 \n依据:当前无项目明确关联“统一决策层”需求;系统中22个活跃项目均未在名称、信号或任务中体现该概念;核心功能模块(如AI管家、智能配置)也未定义相关能力路径;知识图谱与代码索引中无“decision layer”“unified governance”等关键词匹配。 \n下一步:请提供该需求的原始输入来源(如飞书消息ID、文档链接、负责人姓名)及预期交付物定义。 \n信息不足,缺少:①需求提出人;②对应项目ID或信号ID;③验收标准描述。"}], "final_reply": "不值得优先做。 \n依据:当前无项目明确关联“统一决策层”需求;系统中22个活跃项目均未在名称、信号或任务中体现该概念;核心功能模块(如AI管家、智能配置)也未定义相关能力路径;知识图谱与代码索引中无“decision layer”“unified governance”等关键词匹配。 \n下一步:请提供该需求的原始输入来源(如飞书消息ID、文档链接、负责人姓名)及预期交付物定义。 \n信息不足,缺少:①需求提出人;②对应项目ID或信号ID;③验收标准描述。"} +{"generated_at": "2026-03-28T08:51:37.907500", "case": "advise", "source_message_id": "codex-feishu-advise-1774659056", "question": "统一决策层值不值得先做", "run_status": "missing", "processing": {"status": "success", "route": "ai_pmo.query.answer", "intent": "query.answer", "reply_source": "action_succeeded", "error_message": ""}, "outbox": [{"id": 654, "status": "sent", "attempts": 1, "sent_message_id": "om_x100b53462eac6ca4c2ccea9c028e82c", "text": "不值得优先做。 \n依据:当前无项目明确关联“统一决策层”需求;系统中22个活跃项目均未在名称、信号或任务中体现该概念;核心功能模块(如/ai-steward、/projects)也未定义相关能力或路由;知识图谱与代码索引中无“decision layer”“unified decision”等关键词匹配。 \n下一步:请确认该需求对应的具体项目ID、业务场景及预期交付物。信息不足,缺少:①需求提出方;②关联项目ID;③验收标准。"}], "final_reply": "不值得优先做。 \n依据:当前无项目明确关联“统一决策层”需求;系统中22个活跃项目均未在名称、信号或任务中体现该概念;核心功能模块(如/ai-steward、/projects)也未定义相关能力或路由;知识图谱与代码索引中无“decision layer”“unified decision”等关键词匹配。 \n下一步:请确认该需求对应的具体项目ID、业务场景及预期交付物。信息不足,缺少:①需求提出方;②关联项目ID;③验收标准。"} +{"generated_at": "2026-03-28T08:52:16.466761", "case": "challenge", "source_message_id": "codex-feishu-challenge-1774659109", "question": "我要把整个系统一次性全部重构掉,这么做靠谱吗", "run_status": "missing", "processing": {"status": "success", "route": "ai_pmo.query.answer", "intent": "query.answer", "reply_source": "action_succeeded", "error_message": ""}, "outbox": [{"id": 655, "status": "sent", "attempts": 1, "sent_message_id": "om_x100b53462dfeb8a4b4a22d394e5186c", "text": "不靠谱。 \n依据:当前系统后端模块统计为全零(api/services/models/schemas均为0),但热点模块集中在`backend/app/api/sstc.py`等5个文件,说明核心逻辑实际集中、未模块化;前端仅6个路由,功能边界清晰;156个待处理信号中P0级3个、P1级46个,系统处于高负载运行态。 \n下一步:立即暂停重构决策,优先完成`sstc.py`模块拆解图谱与接口契约定义,并确认杨宇(唯一跨角色开发者,workload_ratio=796.67)是否参与重构设计。"}], "final_reply": "不靠谱。 \n依据:当前系统后端模块统计为全零(api/services/models/schemas均为0),但热点模块集中在`backend/app/api/sstc.py`等5个文件,说明核心逻辑实际集中、未模块化;前端仅6个路由,功能边界清晰;156个待处理信号中P0级3个、P1级46个,系统处于高负载运行态。 \n下一步:立即暂停重构决策,优先完成`sstc.py`模块拆解图谱与接口契约定义,并确认杨宇(唯一跨角色开发者,workload_ratio=796.67)是否参与重构设计。"} +{"generated_at": "2026-03-28T08:53:35.385412", "case": "brainstorm", "source_message_id": "codex-feishu-brainstorm-1774659145", "question": "我们一起想几个 AI 劝退助手的方向", "run_status": "missing", "processing": {"status": "success", "route": "ai_pmo.draft_pending_confirm", "intent": "project_signal.create", "reply_source": "draft_pending_confirm", "error_message": ""}, "outbox": [{"id": 656, "status": "sent", "attempts": 1, "sent_message_id": "om_x100b5346298338a4b2c4f90aaa6bc9f", "text": "📋 我先按产品经理视角起草一版评审建议,请确认:\n\n📌 需求草稿\n我们一起想几个 AI 劝退助手的方向\n\n🔎 同名 / 相似项目:\n· #51 AI劝退助手:现有项目已有一定描述,建议对比当前需求的目标、范围和交付边界。;若范围重叠,可能造成重复建设或维护分散。;名称或目标高度接近,优先确认是否复用或合并。\n\n🚀 MVP 建议:\n· 明确项目目标与边界\n· 完成同名/相似项目查重\n· 交付最小可验证闭环\n\n一、定位\n· 一句话描述:我们一起想几个 AI 劝退助手的方向\n· 目标客户:待补充\n\n二、核心痛点\n· 待补充核心痛点\n\n三、MVP 功能范围(8周交付)\nPhase 1:核心功能(Week 1-4)\n· 明确项目目标与边界\n· 完成同名/相似项目查重\n· 交付最小可验证闭环\nPhase 2:差异化功能(Week 5-8)\n· 待补充:确认差异化方向后再展开\n\n四、技术架构(精简版)\n· 待补充技术架构\n\n五、商业模式\n· 待补充商业模式\n\n六、验证指标\n验证指标:\n· 待补充\n\n七、快速启动建议\n快速启动建议:\n· 待补充\n\n八、团队配置\n· 待补充团队配置\n\n📈 前景判断:\n· 该项目具备落地价值,但建议先确认范围边界和现有项目复用情况,再决定是否推进\n\n🧩 需求清单:\n· 项目定位与目标\n· MVP 功能范围\n\n❓ 还缺的信息:\n· 暂无\n\n✅ 可行性:待讨论\n信息仍需补充,建议先确认边界。\n\n👤 推荐负责人:系统管理员 · 当前负载100%\n基于当前负载与任务匹配度给出的建议。\n\n📅 跟进计划:\n· 明天:确认触发时机与验收口径\n· 一周后:复核执行效果并决定是否扩展\n\n---\n回复 确认 → 入库\n回复 修改 [具体修改意见] → 重新起草\n回复 取消 → 放弃\n⏰ 24小时内未确认自动取消"}], "final_reply": "📋 我先按产品经理视角起草一版评审建议,请确认:\n\n📌 需求草稿\n我们一起想几个 AI 劝退助手的方向\n\n🔎 同名 / 相似项目:\n· #51 AI劝退助手:现有项目已有一定描述,建议对比当前需求的目标、范围和交付边界。;若范围重叠,可能造成重复建设或维护分散。;名称或目标高度接近,优先确认是否复用或合并。\n\n🚀 MVP 建议:\n· 明确项目目标与边界\n· 完成同名/相似项目查重\n· 交付最小可验证闭环\n\n一、定位\n· 一句话描述:我们一起想几个 AI 劝退助手的方向\n· 目标客户:待补充\n\n二、核心痛点\n· 待补充核心痛点\n\n三、MVP 功能范围(8周交付)\nPhase 1:核心功能(Week 1-4)\n· 明确项目目标与边界\n· 完成同名/相似项目查重\n· 交付最小可验证闭环\nPhase 2:差异化功能(Week 5-8)\n· 待补充:确认差异化方向后再展开\n\n四、技术架构(精简版)\n· 待补充技术架构\n\n五、商业模式\n· 待补充商业模式\n\n六、验证指标\n验证指标:\n· 待补充\n\n七、快速启动建议\n快速启动建议:\n· 待补充\n\n八、团队配置\n· 待补充团队配置\n\n📈 前景判断:\n· 该项目具备落地价值,但建议先确认范围边界和现有项目复用情况,再决定是否推进\n\n🧩 需求清单:\n· 项目定位与目标\n· MVP 功能范围\n\n❓ 还缺的信息:\n· 暂无\n\n✅ 可行性:待讨论\n信息仍需补充,建议先确认边界。\n\n👤 推荐负责人:系统管理员 · 当前负载100%\n基于当前负载与任务匹配度给出的建议。\n\n📅 跟进计划:\n· 明天:确认触发时机与验收口径\n· 一周后:复核执行效果并决定是否扩展\n\n---\n回复 确认 → 入库\n回复 修改 [具体修改意见] → 重新起草\n回复 取消 → 放弃\n⏰ 24小时内未确认自动取消"} diff --git a/backend/tmp_run_feishu_case.py b/backend/tmp_run_feishu_case.py index 81359fda..69f56643 100644 --- a/backend/tmp_run_feishu_case.py +++ b/backend/tmp_run_feishu_case.py @@ -5,6 +5,7 @@ import time import urllib.parse import urllib.request from datetime import datetime +from pathlib import Path from typing import Any, Optional from sqlalchemy import func, select @@ -16,6 +17,8 @@ BASE = "http://127.0.0.1:8000" CHAT_ID = "oc_c487d87563e48c2f9566ccd5a758212c" SENDER = "P1验收机器人" BANNED_PREFIXES = ["让我先", "让我尝试", "我来看看", "我先查", "让我看看", "我需要先"] +DEFAULT_LOG_PATH = Path(__file__).resolve().parent / "tmp_acceptance" / "consultant_dispatch_feishu_runs.jsonl" +TERMINAL_RUN_STATUSES = {"succeeded", "failed", "cancelled", "answered", "completed"} def login_token() -> str: @@ -39,7 +42,7 @@ def post_capture(token: str, message: str, source_message_id: str) -> dict[str, } body = json.dumps(payload, ensure_ascii=False).encode("utf-8") req = urllib.request.Request( - f"{BASE}/ai-steward/ai-steward/signal/capture-feishu", + f"{BASE}/ai-steward/signal/capture-feishu", data=body, headers={"Authorization": f"Bearer {token}", "Content-Type": "application/json"}, ) @@ -91,6 +94,26 @@ def has_thinking_leak(text: str) -> bool: return False +def append_jsonl_log(log_path: str, payload: dict[str, Any]) -> str: + target = Path(log_path).expanduser() + if not target.is_absolute(): + target = (Path.cwd() / target).resolve() + target.parent.mkdir(parents=True, exist_ok=True) + with target.open("a", encoding="utf-8") as fh: + fh.write(json.dumps(payload, ensure_ascii=False) + "\n") + return str(target) + + +def resolve_consultant_dispatch(payload: dict[str, Any]) -> Optional[dict[str, Any]]: + direct = payload.get("consultant_dispatch") + if isinstance(direct, dict): + return direct + rag_result = payload.get("rag_result") + if isinstance(rag_result, dict) and isinstance(rag_result.get("consultant_dispatch"), dict): + return dict(rag_result.get("consultant_dispatch") or {}) + return None + + async def fetch_max_ai_call_id(db) -> int: row = (await db.execute(select(func.max(AICallLog.id)))).scalar() return int(row or 0) @@ -136,6 +159,41 @@ async def wait_outbox(db, source_message_id: str, run_status: str, timeout_sec: return latest +async def wait_capture_artifacts( + db, + *, + source_message_id: str, + timeout_sec: int = 300, +) -> tuple[Optional[AIOrchestrationRun], list[FeishuReplyOutbox]]: + deadline = time.time() + timeout_sec + latest_run: Optional[AIOrchestrationRun] = None + latest_outbox: list[FeishuReplyOutbox] = [] + while time.time() < deadline: + latest_run = ( + await db.execute( + select(AIOrchestrationRun) + .where(AIOrchestrationRun.source_message_id == source_message_id) + .order_by(AIOrchestrationRun.id.desc()) + .limit(1) + ) + ).scalar_one_or_none() + latest_outbox = ( + await db.execute( + select(FeishuReplyOutbox) + .where(FeishuReplyOutbox.source_message_id == source_message_id) + .order_by(FeishuReplyOutbox.id.asc()) + ) + ).scalars().all() + sent_rows = [row for row in latest_outbox if str(row.status or "").lower() == "sent"] + run_status = str(getattr(latest_run, "status", "") or "").strip().lower() + if len(sent_rows) >= 2: + return latest_run, latest_outbox + if sent_rows and (run_status in TERMINAL_RUN_STATUSES or latest_run is None): + return latest_run, latest_outbox + await asyncio.sleep(2) + return latest_run, latest_outbox + + async def fetch_processing(db, source_message_id: str) -> Optional[AIProcessingLog]: return ( await db.execute( @@ -217,6 +275,7 @@ async def main() -> None: parser.add_argument("--message", type=str, required=True) parser.add_argument("--keys", type=str, default="") parser.add_argument("--prefix", type=str, default="real-scene-p1-manual") + parser.add_argument("--log-path", type=str, default=str(DEFAULT_LOG_PATH)) args = parser.parse_args() keys = [k.strip() for k in str(args.keys).split("|") if k.strip()] @@ -232,11 +291,13 @@ async def main() -> None: capture = post_capture(token, args.message, source_message_id) async with AsyncSessionLocal() as db: - run_row = await wait_run(db, source_message_id) + run_row, outbox_rows = await wait_capture_artifacts(db, source_message_id=source_message_id) run_status = str(getattr(run_row, "status", "missing") or "missing") if run_row is not None else "missing" - outbox_rows = await wait_outbox(db, source_message_id, run_status) processing = await fetch_processing(db, source_message_id) - prompt_scene, candidates = await fetch_prompt_scene(db, before_call_id, keys) + if keys: + prompt_scene, candidates = await fetch_prompt_scene(db, before_call_id, keys) + else: + prompt_scene, candidates = "unknown", [] sent_rows = [r for r in outbox_rows if str(r.status or "") == "sent"] ack = str(sent_rows[0].message_text or "").strip() if sent_rows else "" @@ -252,8 +313,11 @@ async def main() -> None: "generated_at": datetime.now().isoformat(), "idx": args.idx, "source_message_id": source_message_id, + "question": args.message, "message": args.message, "capture_status": str(capture.get("status") or ""), + "capture_direct_reply_text": str(capture.get("direct_reply_text") or ""), + "consultant_dispatch": resolve_consultant_dispatch(capture), "run_status": run_status, "ack": ack, "final_reply": final_reply, @@ -284,6 +348,7 @@ async def main() -> None: "pass": passed, "pass_rule": pass_rule, } + payload["log_path"] = append_jsonl_log(args.log_path, payload) print(json.dumps(payload, ensure_ascii=False)) diff --git a/frontend/src/components/ai-steward/FloatingQAChat.tsx b/frontend/src/components/ai-steward/FloatingQAChat.tsx index 6640c9c4..7d06171f 100644 --- a/frontend/src/components/ai-steward/FloatingQAChat.tsx +++ b/frontend/src/components/ai-steward/FloatingQAChat.tsx @@ -14,6 +14,14 @@ type Helpfulness = "fully" | "partially" | "could_be_better"; type ThinkingMode = "normal" | "deep"; type ActionTaken = "signal_created" | "signal_deduped" | "signal_failed"; type MessageSource = { source: string; label: string; score: number; metadata?: Record }; +type ConsultantDispatch = { + domain: string; + mode: string; + risk_level: string; + missing_fields: string[]; + allow_execute: boolean; + next_action: string; +}; type SessionSummary = { id: number; @@ -52,6 +60,7 @@ type SessionMessage = { ack_latency_ms?: number | null; trace_id?: string | null; processing_stage?: string | null; + consultant_dispatch?: ConsultantDispatch | null; }; type SessionDetail = { @@ -75,6 +84,18 @@ const ERROR_OPTIONS = ["答案错误", "来源错误", "不完整", "答非所 const LAST_SEEN_KEY = "pmflow_qa_chat_last_seen"; const THINKING_MODE_KEY = "pmflow_qa_chat_thinking_mode"; const QUICK_QUESTIONS = ["你是谁", "系统架构是什么", "最近有哪些项目", "当前阻塞项有哪些"]; +const CONSULTANT_MODE_LABELS: Record = { + clarify: "先澄清", + advise: "给建议", + challenge: "先劝退", + brainstorm: "一起想", + execute: "可执行", +}; +const CONSULTANT_RISK_LABELS: Record = { + low: "低风险", + medium: "中风险", + high: "高风险", +}; function normalizeRequestError(error: unknown, fallback = "请求失败,请稍后重试。"): string { const raw = error instanceof Error ? error.message : String(error || ""); @@ -96,6 +117,27 @@ function normalizeSources(value: unknown): MessageSource[] { })); } +function normalizeConsultantDispatch(value: unknown): ConsultantDispatch | null { + if (!value || typeof value !== "object") return null; + const item = value as Record; + const missingFields = Array.isArray(item.missing_fields) + ? item.missing_fields + .map((field) => String(field || "").trim()) + .filter((field): field is string => Boolean(field)) + : []; + const mode = String(item.mode || "").trim().toLowerCase(); + const riskLevel = String(item.risk_level || "").trim().toLowerCase(); + if (!mode) return null; + return { + domain: String(item.domain || "generic").trim() || "generic", + mode, + risk_level: riskLevel || "low", + missing_fields: missingFields, + allow_execute: Boolean(item.allow_execute), + next_action: String(item.next_action || "").trim(), + }; +} + function looksLikeRequirementWrite(text: string): boolean { const clean = String(text || "").trim(); if (!clean) return false; @@ -125,6 +167,34 @@ function getStageBadge(message: Pick(path: string, init?: RequestInit): Promise { const token = getAuthToken(); if (!token) return null; @@ -164,6 +234,7 @@ async function askRAG( ack_latency_ms?: number | null; trace_id?: string | null; processing_stage?: string | null; + consultant_dispatch?: ConsultantDispatch | null; }> { const token = getAuthToken(); if (!token) { @@ -224,6 +295,7 @@ async function askRAG( ack_latency_ms?: number | null; trace_id?: string | null; processing_stage?: string | null; + consultant_dispatch?: unknown; }; if (!data || !String(data.answer || "").trim()) { throw new Error("服务返回为空,未生成有效回答。"); @@ -242,6 +314,7 @@ async function askRAG( ack_latency_ms: data.ack_latency_ms ?? null, trace_id: data.trace_id ?? null, processing_stage: data.processing_stage ?? null, + consultant_dispatch: normalizeConsultantDispatch(data.consultant_dispatch), }; } @@ -450,6 +523,7 @@ export function FloatingQAChat() { ack_latency_ms: data.ack_latency_ms ?? null, trace_id: data.trace_id ?? null, processing_stage: data.processing_stage ?? null, + consultant_dispatch: data.consultant_dispatch ?? null, }, ]); const returnedSessionId = Number(data.session_id || 0) || null; @@ -756,6 +830,12 @@ export function FloatingQAChat() { const isLocalMessage = msg.id < 0; const actionBadge = getActionBadge(msg.action_taken); const stageBadge = getStageBadge(msg); + const consultantDispatch = msg.consultant_dispatch ?? null; + const consultantModeBadge = getConsultantModeBadge(consultantDispatch); + const consultantRiskBadge = getConsultantRiskBadge(consultantDispatch); + const consultantMissingFields = consultantDispatch?.missing_fields?.length + ? consultantDispatch.missing_fields.join(" / ") + : ""; return (
@@ -779,7 +859,31 @@ export function FloatingQAChat() { {msg.signal_id ? signal #{msg.signal_id} : null} {stageBadge ? {stageBadge} : null} {msg.thinking_mode === "deep" ? "深入思考" : "标准回答"} + {consultantModeBadge ? ( + {consultantModeBadge.label} + ) : null} + {consultantRiskBadge ? ( + {consultantRiskBadge.label} + ) : null} + {consultantDispatch ? ( + + {consultantDispatch.allow_execute ? "允许执行" : "先别执行"} + + ) : null}
+ {consultantDispatch ? ( +
+ {consultantMissingFields ? ( +
缺失信息:{consultantMissingFields}
+ ) : null} + {consultantDispatch.next_action ? ( +
+ 下一步: + {consultantDispatch.next_action} +
+ ) : null} +
+ ) : null} {msg.trace_id ? (
trace: {msg.trace_id}
) : null}