Add consultant dispatch contract and Feishu logging

This commit is contained in:
Yuuuu 2026-03-28 09:06:12 +08:00
parent b30b0f4834
commit d2672702bc
10 changed files with 652 additions and 5 deletions

View File

@ -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`,用于草稿确认卡片渲染

View File

@ -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 "当前暂无可用回答。"

View File

@ -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")

View File

@ -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",
]

View File

@ -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,

View File

@ -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:

View File

@ -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

View File

@ -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小时内未确认自动取消"}

View File

@ -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)
if keys:
prompt_scene, candidates = await fetch_prompt_scene(db, before_call_id, 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))

View File

@ -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}