Add consultant dispatch contract and Feishu logging
This commit is contained in:
parent
b30b0f4834
commit
d2672702bc
|
|
@ -112,6 +112,7 @@ backend/
|
||||||
- 新增:`backend/app/core/startup/model_probe.py`(启动期模型连通性探针:优先走 DB 里的实际引擎配置,失败不污染 provider circuit)
|
- 新增:`backend/app/core/startup/model_probe.py`(启动期模型连通性探针:优先走 DB 里的实际引擎配置,失败不污染 provider circuit)
|
||||||
- 新增:`backend/app/services/sstc/charter_payload.py`(轻量宪章上下文提取模块:供 Signal Planner / SSTC 共享,避免导入环)
|
- 新增:`backend/app/services/sstc/charter_payload.py`(轻量宪章上下文提取模块:供 Signal Planner / SSTC 共享,避免导入环)
|
||||||
- 新增:`backend/app/services/task_breakdown.py`(任务拆解业务对象归一、技能标签回填、自动指派阈值判断)
|
- 新增:`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_prompt_registry.py`(prompt registry / manifest,统一登记 prompt 场景、调用入口与契约)
|
||||||
- 更新:`backend/app/config/ai_prompts.py`(默认角色 Prompt 调整为“产品经理(立项评审)”,强调查重、MVP、前景与需求清单)
|
- 更新:`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 评审字段)
|
- 更新:`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 草稿决策接口(`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/...` 路径
|
- 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 问答主入口契约(`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/*`
|
- 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 消息台列表接口(`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`,用于草稿确认卡片渲染
|
- AI PMO 消息台列表接口补充:返回草稿字段 `draft_content/draft_expires_at/draft_version/confirm_message_id`,用于草稿确认卡片渲染
|
||||||
|
|
|
||||||
|
|
@ -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 {}
|
result = ingress_result if isinstance(ingress_result, dict) else {}
|
||||||
rag_result = result.get("rag_result") if isinstance(result.get("rag_result"), dict) else {}
|
rag_result = result.get("rag_result") if isinstance(result.get("rag_result"), dict) else {}
|
||||||
payload = dict(rag_result)
|
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()
|
answer = str(payload.get("answer") or result.get("direct_reply_text") or "").strip()
|
||||||
payload["answer"] = answer or "当前暂无可用回答。"
|
payload["answer"] = answer or "当前暂无可用回答。"
|
||||||
|
|
|
||||||
|
|
@ -3,6 +3,7 @@
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
from .... import ai_pmo_kernel_common_core as _common
|
from .... import ai_pmo_kernel_common_core as _common
|
||||||
|
from ....consultant_dispatch import build_consultant_dispatch_contract
|
||||||
from ....workflow_semantic_planner import WorkflowSemanticPlanner
|
from ....workflow_semantic_planner import WorkflowSemanticPlanner
|
||||||
|
|
||||||
for _name, _value in vars(_common).items():
|
for _name, _value in vars(_common).items():
|
||||||
|
|
@ -24,6 +25,17 @@ class KernelIngressRenderMixin:
|
||||||
) -> dict[str, Any]:
|
) -> dict[str, Any]:
|
||||||
linked_project_ids = [int(work_item.project_id)] if work_item.project_id is not None else []
|
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()
|
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 = (
|
mixed_sidecar_reply = (
|
||||||
str((intent.metadata or {}).get("mixed_sidecar_reply") or "").strip()
|
str((intent.metadata or {}).get("mixed_sidecar_reply") or "").strip()
|
||||||
if isinstance(intent.metadata, dict)
|
if isinstance(intent.metadata, dict)
|
||||||
|
|
@ -50,6 +62,17 @@ class KernelIngressRenderMixin:
|
||||||
signal_ids = [primary_signal_id, *signal_ids]
|
signal_ids = [primary_signal_id, *signal_ids]
|
||||||
if signal_ids and primary_signal_id is None:
|
if signal_ids and primary_signal_id is None:
|
||||||
primary_signal_id = signal_ids[0]
|
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 = {
|
result = {
|
||||||
"handled": True,
|
"handled": True,
|
||||||
"status": status,
|
"status": status,
|
||||||
|
|
@ -77,6 +100,7 @@ class KernelIngressRenderMixin:
|
||||||
"workflow_route": str(work_item.intent or execution_result.get("workflow_route") or "").strip() or None,
|
"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_required": bool(direct_reply_required),
|
||||||
"direct_reply_text": direct_reply_text,
|
"direct_reply_text": direct_reply_text,
|
||||||
|
"consultant_dispatch": consultant_dispatch,
|
||||||
"reply_template_key": str(render.get("template_key") or "").strip(),
|
"reply_template_key": str(render.get("template_key") or "").strip(),
|
||||||
"work_item_id": int(work_item.id),
|
"work_item_id": int(work_item.id),
|
||||||
"decision_level": work_item.decision_level,
|
"decision_level": work_item.decision_level,
|
||||||
|
|
@ -112,7 +136,10 @@ class KernelIngressRenderMixin:
|
||||||
if isinstance(draft_expires_at, str) and draft_expires_at.strip():
|
if isinstance(draft_expires_at, str) and draft_expires_at.strip():
|
||||||
result["draft_expires_at"] = 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):
|
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:
|
if intent.name == INTENT_PROJECT_CREATE:
|
||||||
raw_payload = execution_result.get("raw_payload") if isinstance(execution_result.get("raw_payload"), dict) else {}
|
raw_payload = execution_result.get("raw_payload") if isinstance(execution_result.get("raw_payload"), dict) else {}
|
||||||
result["project"] = raw_payload.get("project")
|
result["project"] = raw_payload.get("project")
|
||||||
|
|
|
||||||
|
|
@ -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",
|
||||||
|
]
|
||||||
|
|
@ -1,6 +1,7 @@
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
from .shared import *
|
from .shared import *
|
||||||
|
from ...ai_pmo.consultant_dispatch import build_channel_consultant_reply
|
||||||
from ...ai_pmo.workflow_semantic_planner import WorkflowSemanticPlanner
|
from ...ai_pmo.workflow_semantic_planner import WorkflowSemanticPlanner
|
||||||
|
|
||||||
class FeishuReplyLLMCoreMixin:
|
class FeishuReplyLLMCoreMixin:
|
||||||
|
|
@ -79,6 +80,19 @@ class FeishuReplyLLMCoreMixin:
|
||||||
)
|
)
|
||||||
return
|
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:
|
try:
|
||||||
status = str(capture_result.get("status") or "unknown")
|
status = str(capture_result.get("status") or "unknown")
|
||||||
classification = str(capture_result.get("classification") or "unknown")
|
classification = str(capture_result.get("classification") or "unknown")
|
||||||
|
|
@ -230,6 +244,17 @@ class FeishuReplyLLMCoreMixin:
|
||||||
) -> bool:
|
) -> bool:
|
||||||
return WorkflowSemanticPlanner.is_action_request_capture(capture_result=capture_result)
|
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
|
@classmethod
|
||||||
def _build_action_request_ack_reply(
|
def _build_action_request_ack_reply(
|
||||||
cls,
|
cls,
|
||||||
|
|
|
||||||
|
|
@ -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_common_core.constants import STATE_DRAFT_PENDING_CONFIRM
|
||||||
from app.services.ai_pmo.ai_pmo_kernel_ingress_flow import mixin_auto
|
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.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 (
|
from app.services.integrations.feishu_integration_service.mixin_reply_llm_core import (
|
||||||
FeishuReplyLLMCoreMixin,
|
FeishuReplyLLMCoreMixin,
|
||||||
)
|
)
|
||||||
|
|
@ -163,6 +164,77 @@ def test_feishu_reply_llm_core_action_ack_uses_semantic_bundle_only() -> None:
|
||||||
assert ack == "已收到,项目创建和信号源接入请求已记录。"
|
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
|
@pytest.mark.asyncio
|
||||||
async def test_draft_confirm_handler_repeated_confirm_returns_inbox_success_copy() -> None:
|
async def test_draft_confirm_handler_repeated_confirm_returns_inbox_success_copy() -> None:
|
||||||
class _FakeResult:
|
class _FakeResult:
|
||||||
|
|
|
||||||
|
|
@ -213,3 +213,29 @@ def test_build_rag_response_payload_preserves_rag_fields() -> None:
|
||||||
assert payload["trace_id"] == "ai-pmo-321"
|
assert payload["trace_id"] == "ai-pmo-321"
|
||||||
assert payload["reply_required"] is True
|
assert payload["reply_required"] is True
|
||||||
assert payload["reply_text"] == "fallback"
|
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
|
||||||
|
|
|
||||||
|
|
@ -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小时内未确认自动取消"}
|
||||||
|
|
@ -5,6 +5,7 @@ import time
|
||||||
import urllib.parse
|
import urllib.parse
|
||||||
import urllib.request
|
import urllib.request
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
|
from pathlib import Path
|
||||||
from typing import Any, Optional
|
from typing import Any, Optional
|
||||||
|
|
||||||
from sqlalchemy import func, select
|
from sqlalchemy import func, select
|
||||||
|
|
@ -16,6 +17,8 @@ BASE = "http://127.0.0.1:8000"
|
||||||
CHAT_ID = "oc_c487d87563e48c2f9566ccd5a758212c"
|
CHAT_ID = "oc_c487d87563e48c2f9566ccd5a758212c"
|
||||||
SENDER = "P1验收机器人"
|
SENDER = "P1验收机器人"
|
||||||
BANNED_PREFIXES = ["让我先", "让我尝试", "我来看看", "我先查", "让我看看", "我需要先"]
|
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:
|
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")
|
body = json.dumps(payload, ensure_ascii=False).encode("utf-8")
|
||||||
req = urllib.request.Request(
|
req = urllib.request.Request(
|
||||||
f"{BASE}/ai-steward/ai-steward/signal/capture-feishu",
|
f"{BASE}/ai-steward/signal/capture-feishu",
|
||||||
data=body,
|
data=body,
|
||||||
headers={"Authorization": f"Bearer {token}", "Content-Type": "application/json"},
|
headers={"Authorization": f"Bearer {token}", "Content-Type": "application/json"},
|
||||||
)
|
)
|
||||||
|
|
@ -91,6 +94,26 @@ def has_thinking_leak(text: str) -> bool:
|
||||||
return False
|
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:
|
async def fetch_max_ai_call_id(db) -> int:
|
||||||
row = (await db.execute(select(func.max(AICallLog.id)))).scalar()
|
row = (await db.execute(select(func.max(AICallLog.id)))).scalar()
|
||||||
return int(row or 0)
|
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
|
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]:
|
async def fetch_processing(db, source_message_id: str) -> Optional[AIProcessingLog]:
|
||||||
return (
|
return (
|
||||||
await db.execute(
|
await db.execute(
|
||||||
|
|
@ -217,6 +275,7 @@ async def main() -> None:
|
||||||
parser.add_argument("--message", type=str, required=True)
|
parser.add_argument("--message", type=str, required=True)
|
||||||
parser.add_argument("--keys", type=str, default="")
|
parser.add_argument("--keys", type=str, default="")
|
||||||
parser.add_argument("--prefix", type=str, default="real-scene-p1-manual")
|
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()
|
args = parser.parse_args()
|
||||||
|
|
||||||
keys = [k.strip() for k in str(args.keys).split("|") if k.strip()]
|
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)
|
capture = post_capture(token, args.message, source_message_id)
|
||||||
|
|
||||||
async with AsyncSessionLocal() as db:
|
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"
|
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)
|
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"]
|
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 ""
|
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(),
|
"generated_at": datetime.now().isoformat(),
|
||||||
"idx": args.idx,
|
"idx": args.idx,
|
||||||
"source_message_id": source_message_id,
|
"source_message_id": source_message_id,
|
||||||
|
"question": args.message,
|
||||||
"message": args.message,
|
"message": args.message,
|
||||||
"capture_status": str(capture.get("status") or ""),
|
"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,
|
"run_status": run_status,
|
||||||
"ack": ack,
|
"ack": ack,
|
||||||
"final_reply": final_reply,
|
"final_reply": final_reply,
|
||||||
|
|
@ -284,6 +348,7 @@ async def main() -> None:
|
||||||
"pass": passed,
|
"pass": passed,
|
||||||
"pass_rule": pass_rule,
|
"pass_rule": pass_rule,
|
||||||
}
|
}
|
||||||
|
payload["log_path"] = append_jsonl_log(args.log_path, payload)
|
||||||
print(json.dumps(payload, ensure_ascii=False))
|
print(json.dumps(payload, ensure_ascii=False))
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -14,6 +14,14 @@ type Helpfulness = "fully" | "partially" | "could_be_better";
|
||||||
type ThinkingMode = "normal" | "deep";
|
type ThinkingMode = "normal" | "deep";
|
||||||
type ActionTaken = "signal_created" | "signal_deduped" | "signal_failed";
|
type ActionTaken = "signal_created" | "signal_deduped" | "signal_failed";
|
||||||
type MessageSource = { source: string; label: string; score: number; metadata?: Record<string, unknown> };
|
type MessageSource = { source: string; label: string; score: number; metadata?: Record<string, unknown> };
|
||||||
|
type ConsultantDispatch = {
|
||||||
|
domain: string;
|
||||||
|
mode: string;
|
||||||
|
risk_level: string;
|
||||||
|
missing_fields: string[];
|
||||||
|
allow_execute: boolean;
|
||||||
|
next_action: string;
|
||||||
|
};
|
||||||
|
|
||||||
type SessionSummary = {
|
type SessionSummary = {
|
||||||
id: number;
|
id: number;
|
||||||
|
|
@ -52,6 +60,7 @@ type SessionMessage = {
|
||||||
ack_latency_ms?: number | null;
|
ack_latency_ms?: number | null;
|
||||||
trace_id?: string | null;
|
trace_id?: string | null;
|
||||||
processing_stage?: string | null;
|
processing_stage?: string | null;
|
||||||
|
consultant_dispatch?: ConsultantDispatch | null;
|
||||||
};
|
};
|
||||||
|
|
||||||
type SessionDetail = {
|
type SessionDetail = {
|
||||||
|
|
@ -75,6 +84,18 @@ const ERROR_OPTIONS = ["答案错误", "来源错误", "不完整", "答非所
|
||||||
const LAST_SEEN_KEY = "pmflow_qa_chat_last_seen";
|
const LAST_SEEN_KEY = "pmflow_qa_chat_last_seen";
|
||||||
const THINKING_MODE_KEY = "pmflow_qa_chat_thinking_mode";
|
const THINKING_MODE_KEY = "pmflow_qa_chat_thinking_mode";
|
||||||
const QUICK_QUESTIONS = ["你是谁", "系统架构是什么", "最近有哪些项目", "当前阻塞项有哪些"];
|
const QUICK_QUESTIONS = ["你是谁", "系统架构是什么", "最近有哪些项目", "当前阻塞项有哪些"];
|
||||||
|
const CONSULTANT_MODE_LABELS: Record<string, string> = {
|
||||||
|
clarify: "先澄清",
|
||||||
|
advise: "给建议",
|
||||||
|
challenge: "先劝退",
|
||||||
|
brainstorm: "一起想",
|
||||||
|
execute: "可执行",
|
||||||
|
};
|
||||||
|
const CONSULTANT_RISK_LABELS: Record<string, string> = {
|
||||||
|
low: "低风险",
|
||||||
|
medium: "中风险",
|
||||||
|
high: "高风险",
|
||||||
|
};
|
||||||
|
|
||||||
function normalizeRequestError(error: unknown, fallback = "请求失败,请稍后重试。"): string {
|
function normalizeRequestError(error: unknown, fallback = "请求失败,请稍后重试。"): string {
|
||||||
const raw = error instanceof Error ? error.message : String(error || "");
|
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<string, unknown>;
|
||||||
|
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 {
|
function looksLikeRequirementWrite(text: string): boolean {
|
||||||
const clean = String(text || "").trim();
|
const clean = String(text || "").trim();
|
||||||
if (!clean) return false;
|
if (!clean) return false;
|
||||||
|
|
@ -125,6 +167,34 @@ function getStageBadge(message: Pick<SessionMessage, "processing_stage" | "reply
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function getConsultantModeBadge(
|
||||||
|
consultantDispatch?: ConsultantDispatch | null,
|
||||||
|
): { label: string; variant: "secondary" | "destructive" | "outline" } | null {
|
||||||
|
const mode = String(consultantDispatch?.mode || "").trim().toLowerCase();
|
||||||
|
if (!mode) return null;
|
||||||
|
if (mode === "challenge") {
|
||||||
|
return { label: CONSULTANT_MODE_LABELS[mode] || mode, variant: "destructive" };
|
||||||
|
}
|
||||||
|
if (mode === "clarify" || mode === "execute") {
|
||||||
|
return { label: CONSULTANT_MODE_LABELS[mode] || mode, variant: "secondary" };
|
||||||
|
}
|
||||||
|
return { label: CONSULTANT_MODE_LABELS[mode] || mode, variant: "outline" };
|
||||||
|
}
|
||||||
|
|
||||||
|
function getConsultantRiskBadge(
|
||||||
|
consultantDispatch?: ConsultantDispatch | null,
|
||||||
|
): { label: string; variant: "secondary" | "destructive" | "outline" } | null {
|
||||||
|
const riskLevel = String(consultantDispatch?.risk_level || "").trim().toLowerCase();
|
||||||
|
if (!riskLevel) return null;
|
||||||
|
if (riskLevel === "high") {
|
||||||
|
return { label: CONSULTANT_RISK_LABELS[riskLevel] || riskLevel, variant: "destructive" };
|
||||||
|
}
|
||||||
|
if (riskLevel === "medium") {
|
||||||
|
return { label: CONSULTANT_RISK_LABELS[riskLevel] || riskLevel, variant: "outline" };
|
||||||
|
}
|
||||||
|
return { label: CONSULTANT_RISK_LABELS[riskLevel] || riskLevel, variant: "secondary" };
|
||||||
|
}
|
||||||
|
|
||||||
async function fetchApi<T>(path: string, init?: RequestInit): Promise<T | null> {
|
async function fetchApi<T>(path: string, init?: RequestInit): Promise<T | null> {
|
||||||
const token = getAuthToken();
|
const token = getAuthToken();
|
||||||
if (!token) return null;
|
if (!token) return null;
|
||||||
|
|
@ -164,6 +234,7 @@ async function askRAG(
|
||||||
ack_latency_ms?: number | null;
|
ack_latency_ms?: number | null;
|
||||||
trace_id?: string | null;
|
trace_id?: string | null;
|
||||||
processing_stage?: string | null;
|
processing_stage?: string | null;
|
||||||
|
consultant_dispatch?: ConsultantDispatch | null;
|
||||||
}> {
|
}> {
|
||||||
const token = getAuthToken();
|
const token = getAuthToken();
|
||||||
if (!token) {
|
if (!token) {
|
||||||
|
|
@ -224,6 +295,7 @@ async function askRAG(
|
||||||
ack_latency_ms?: number | null;
|
ack_latency_ms?: number | null;
|
||||||
trace_id?: string | null;
|
trace_id?: string | null;
|
||||||
processing_stage?: string | null;
|
processing_stage?: string | null;
|
||||||
|
consultant_dispatch?: unknown;
|
||||||
};
|
};
|
||||||
if (!data || !String(data.answer || "").trim()) {
|
if (!data || !String(data.answer || "").trim()) {
|
||||||
throw new Error("服务返回为空,未生成有效回答。");
|
throw new Error("服务返回为空,未生成有效回答。");
|
||||||
|
|
@ -242,6 +314,7 @@ async function askRAG(
|
||||||
ack_latency_ms: data.ack_latency_ms ?? null,
|
ack_latency_ms: data.ack_latency_ms ?? null,
|
||||||
trace_id: data.trace_id ?? null,
|
trace_id: data.trace_id ?? null,
|
||||||
processing_stage: data.processing_stage ?? 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,
|
ack_latency_ms: data.ack_latency_ms ?? null,
|
||||||
trace_id: data.trace_id ?? null,
|
trace_id: data.trace_id ?? null,
|
||||||
processing_stage: data.processing_stage ?? null,
|
processing_stage: data.processing_stage ?? null,
|
||||||
|
consultant_dispatch: data.consultant_dispatch ?? null,
|
||||||
},
|
},
|
||||||
]);
|
]);
|
||||||
const returnedSessionId = Number(data.session_id || 0) || null;
|
const returnedSessionId = Number(data.session_id || 0) || null;
|
||||||
|
|
@ -756,6 +830,12 @@ export function FloatingQAChat() {
|
||||||
const isLocalMessage = msg.id < 0;
|
const isLocalMessage = msg.id < 0;
|
||||||
const actionBadge = getActionBadge(msg.action_taken);
|
const actionBadge = getActionBadge(msg.action_taken);
|
||||||
const stageBadge = getStageBadge(msg);
|
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 (
|
return (
|
||||||
<div key={msg.id} className="space-y-2">
|
<div key={msg.id} className="space-y-2">
|
||||||
<div className="flex justify-end">
|
<div className="flex justify-end">
|
||||||
|
|
@ -779,7 +859,31 @@ export function FloatingQAChat() {
|
||||||
{msg.signal_id ? <Badge variant="outline">signal #{msg.signal_id}</Badge> : null}
|
{msg.signal_id ? <Badge variant="outline">signal #{msg.signal_id}</Badge> : null}
|
||||||
{stageBadge ? <Badge variant="outline">{stageBadge}</Badge> : null}
|
{stageBadge ? <Badge variant="outline">{stageBadge}</Badge> : null}
|
||||||
<Badge variant="secondary">{msg.thinking_mode === "deep" ? "深入思考" : "标准回答"}</Badge>
|
<Badge variant="secondary">{msg.thinking_mode === "deep" ? "深入思考" : "标准回答"}</Badge>
|
||||||
|
{consultantModeBadge ? (
|
||||||
|
<Badge variant={consultantModeBadge.variant}>{consultantModeBadge.label}</Badge>
|
||||||
|
) : null}
|
||||||
|
{consultantRiskBadge ? (
|
||||||
|
<Badge variant={consultantRiskBadge.variant}>{consultantRiskBadge.label}</Badge>
|
||||||
|
) : null}
|
||||||
|
{consultantDispatch ? (
|
||||||
|
<Badge variant={consultantDispatch.allow_execute ? "secondary" : "outline"}>
|
||||||
|
{consultantDispatch.allow_execute ? "允许执行" : "先别执行"}
|
||||||
|
</Badge>
|
||||||
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
|
{consultantDispatch ? (
|
||||||
|
<div className="mt-3 rounded-md border border-slate-200 bg-white/80 px-3 py-2 text-xs text-slate-700">
|
||||||
|
{consultantMissingFields ? (
|
||||||
|
<div>缺失信息:{consultantMissingFields}</div>
|
||||||
|
) : null}
|
||||||
|
{consultantDispatch.next_action ? (
|
||||||
|
<div className={consultantMissingFields ? "mt-2" : undefined}>
|
||||||
|
<span className="font-medium">下一步:</span>
|
||||||
|
{consultantDispatch.next_action}
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
{msg.trace_id ? (
|
{msg.trace_id ? (
|
||||||
<div className="mt-2 text-[11px] text-slate-500">trace: {msg.trace_id}</div>
|
<div className="mt-2 text-[11px] text-slate-500">trace: {msg.trace_id}</div>
|
||||||
) : null}
|
) : null}
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue