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/services/sstc/charter_payload.py`(轻量宪章上下文提取模块:供 Signal Planner / SSTC 共享,避免导入环)
- 新增:`backend/app/services/task_breakdown.py`(任务拆解业务对象归一、技能标签回填、自动指派阈值判断)
- 新增:`backend/app/services/ai_pmo/consultant_dispatch.py`(轻量顾问式统一调度合同与注册表:保留现有业务 Prompt 拆分,统一生成 `domain/mode/risk_level/missing_fields/allow_execute/next_action`)
- 新增:`backend/app/config/ai_prompt_registry.py`(prompt registry / manifest,统一登记 prompt 场景、调用入口与契约)
- 更新:`backend/app/config/ai_prompts.py`(默认角色 Prompt 调整为“产品经理(立项评审)”,强调查重、MVP、前景与需求清单)
- 更新:`backend/app/config/ai_prompts_catalog/part_02.py`(`PROMPT_DRAFT_GENERATOR` 增加 active_projects / similar_projects / MVP / prospect / requirements 评审字段)
@ -552,6 +553,7 @@ uvicorn app.main:app --reload --host 0.0.0.0 --port 8000
- AI PMO 草稿决策接口(`POST /ai-pmo/work-items/{work_item_id}/draft-decision`):支持 `action=confirm|revise|cancel`,与飞书“确认/修改/取消”行为一致
- AI PMO 路由包(`backend/app/api/ai_pmo_api/__init__.py`)只负责聚合与 tags,`/ai-pmo` 前缀只能由子模块共享 router 挂一次,禁止包级与模块级重复叠加前缀导致 `/ai-pmo/ai-pmo/...` 路径
- RAG 问答主入口契约(`POST /rag/ask`):HTTP 入口已收敛到 `AIPMOKernelService.process_ingress` 主链;请求侧统一透传 `asked_via/action_mode/message_intent/mixed_signal_part`,由 `ingress_options.rag_request_context` 下传到 query executor 并映射到 `AdaptiveRAGService.answer_question`,返回侧从 ingress `rag_result`/`direct_reply_text` 组装兼容响应
- RAG / AI 管家顾问合同:`POST /rag/ask` 返回体会透传 `consultant_dispatch={domain,mode,risk_level,missing_fields,allow_execute,next_action}`;该合同由 `backend/app/services/ai_pmo/consultant_dispatch.py` 统一生成,飞书 mention 回复与 AI 管家问答共享同一份模式判断
- RAG / AI Steward 路由包(`backend/app/api/rag_api/__init__.py`、`backend/app/api/ai_steward_api/__init__.py`)不再重复挂包级 prefix;路由 canonical 由 `shared.py` 内的共享 router 统一承担,避免运行中出现 `/rag/rag/*`、`/ai-steward/ai-steward/*`
- AI PMO 消息台列表接口(`GET /ai-pmo/work-items`):返回 `latest_policy_gate_code` 与规范化 `approval_requirement{required,reason,message}`(来源于最新 `decision_records` 投影)
- AI PMO 消息台列表接口补充:返回草稿字段 `draft_content/draft_expires_at/draft_version/confirm_message_id`,用于草稿确认卡片渲染

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 {}
rag_result = result.get("rag_result") if isinstance(result.get("rag_result"), dict) else {}
payload = dict(rag_result)
consultant_dispatch = (
payload.get("consultant_dispatch")
if isinstance(payload.get("consultant_dispatch"), dict)
else (result.get("consultant_dispatch") if isinstance(result.get("consultant_dispatch"), dict) else None)
)
if isinstance(consultant_dispatch, dict):
payload["consultant_dispatch"] = consultant_dispatch
answer = str(payload.get("answer") or result.get("direct_reply_text") or "").strip()
payload["answer"] = answer or "当前暂无可用回答。"

View File

@ -3,6 +3,7 @@
from __future__ import annotations
from .... import ai_pmo_kernel_common_core as _common
from ....consultant_dispatch import build_consultant_dispatch_contract
from ....workflow_semantic_planner import WorkflowSemanticPlanner
for _name, _value in vars(_common).items():
@ -24,6 +25,17 @@ class KernelIngressRenderMixin:
) -> dict[str, Any]:
linked_project_ids = [int(work_item.project_id)] if work_item.project_id is not None else []
direct_reply_text = str(render.get("text") or "").strip()
primary_operation_code = self._primary_operation_code(str(intent.name or ""))
risk_level = self._risk_level_for_intent(str(intent.name or ""))
missing_fields = (
execution_result.get("missing_fields")
if isinstance(execution_result.get("missing_fields"), list)
else (
(intent.metadata or {}).get("missing_fields")
if isinstance(intent.metadata, dict) and isinstance((intent.metadata or {}).get("missing_fields"), list)
else []
)
)
mixed_sidecar_reply = (
str((intent.metadata or {}).get("mixed_sidecar_reply") or "").strip()
if isinstance(intent.metadata, dict)
@ -50,6 +62,17 @@ class KernelIngressRenderMixin:
signal_ids = [primary_signal_id, *signal_ids]
if signal_ids and primary_signal_id is None:
primary_signal_id = signal_ids[0]
consultant_dispatch = build_consultant_dispatch_contract(
intent_name=str(intent.name or ""),
message_text=str(getattr(work_item, "raw_message", "") or direct_reply_text or intent.signal_summary or ""),
source_system=str(getattr(work_item, "source_system", "") or "pmflow"),
status=status,
classification=classification,
primary_operation_code=primary_operation_code,
risk_level=risk_level,
missing_fields=missing_fields,
direct_reply_text=direct_reply_text,
)
result = {
"handled": True,
"status": status,
@ -77,6 +100,7 @@ class KernelIngressRenderMixin:
"workflow_route": str(work_item.intent or execution_result.get("workflow_route") or "").strip() or None,
"direct_reply_required": bool(direct_reply_required),
"direct_reply_text": direct_reply_text,
"consultant_dispatch": consultant_dispatch,
"reply_template_key": str(render.get("template_key") or "").strip(),
"work_item_id": int(work_item.id),
"decision_level": work_item.decision_level,
@ -112,7 +136,10 @@ class KernelIngressRenderMixin:
if isinstance(draft_expires_at, str) and draft_expires_at.strip():
result["draft_expires_at"] = draft_expires_at.strip()
if intent.name == INTENT_QUERY and isinstance(execution_result.get("rag_result"), dict):
result["rag_result"] = execution_result["rag_result"]
rag_result = dict(execution_result["rag_result"])
if not isinstance(rag_result.get("consultant_dispatch"), dict):
rag_result["consultant_dispatch"] = consultant_dispatch
result["rag_result"] = rag_result
if intent.name == INTENT_PROJECT_CREATE:
raw_payload = execution_result.get("raw_payload") if isinstance(execution_result.get("raw_payload"), dict) else {}
result["project"] = raw_payload.get("project")

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 .shared import *
from ...ai_pmo.consultant_dispatch import build_channel_consultant_reply
from ...ai_pmo.workflow_semantic_planner import WorkflowSemanticPlanner
class FeishuReplyLLMCoreMixin:
@ -79,6 +80,19 @@ class FeishuReplyLLMCoreMixin:
)
return
consultant_reply = self._build_consultant_dispatch_reply(capture_result)
if consultant_reply:
await self._safe_reply_text(
chat_id=chat_id,
mention_open_id=sender_open_id,
mention_name=sender_name,
text=consultant_reply,
trace_id=trace_id,
source_message_id=source_message_id,
perf_t0=perf_t0,
)
return
try:
status = str(capture_result.get("status") or "unknown")
classification = str(capture_result.get("classification") or "unknown")
@ -230,6 +244,17 @@ class FeishuReplyLLMCoreMixin:
) -> bool:
return WorkflowSemanticPlanner.is_action_request_capture(capture_result=capture_result)
@staticmethod
def _build_consultant_dispatch_reply(capture_result: Dict[str, Any]) -> Optional[str]:
if not isinstance(capture_result, dict):
return None
reply = build_channel_consultant_reply(
consultant_dispatch=capture_result.get("consultant_dispatch"),
direct_reply_text=str(capture_result.get("direct_reply_text") or "").strip(),
channel="feishu",
)
return str(reply or "").strip() or None
@classmethod
def _build_action_request_ack_reply(
cls,

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_ingress_flow import mixin_auto
from app.services.adaptive_rag_service.mixin_deep_guard_answer import AdaptiveRAGDeepGuardAnswerMixin
from app.services.ai_pmo.consultant_dispatch import build_consultant_dispatch_contract
from app.services.integrations.feishu_integration_service.mixin_reply_llm_core import (
FeishuReplyLLMCoreMixin,
)
@ -163,6 +164,77 @@ def test_feishu_reply_llm_core_action_ack_uses_semantic_bundle_only() -> None:
assert ack == "已收到,项目创建和信号源接入请求已记录。"
def test_consultant_dispatch_contract_marks_incomplete_task_create_as_clarify() -> None:
contract = build_consultant_dispatch_contract(
intent_name="task.create",
message_text="帮我建个任务",
source_system="pmflow_manual",
status="pending_manual",
classification="command",
primary_operation_code="task.create",
risk_level="medium",
missing_fields=["project_id", "task_title"],
direct_reply_text="未执行:当前建任务请求还缺少 归属项目、任务标题。请补充后我再创建。",
)
assert contract["domain"] == "task"
assert contract["mode"] == "clarify"
assert contract["allow_execute"] is False
assert contract["missing_fields"] == ["project_id", "task_title"]
assert "补充" in contract["next_action"]
def test_consultant_dispatch_contract_marks_scope_expansion_as_challenge() -> None:
contract = build_consultant_dispatch_contract(
intent_name="project.create",
message_text="把整个系统全部重做并一次替换所有模块",
source_system="pmflow_manual",
status="pending_manual",
classification="command",
primary_operation_code="project.create",
risk_level="high",
missing_fields=[],
direct_reply_text="当前建议先拆分范围,再确认是否继续推进。",
)
assert contract["domain"] == "project"
assert contract["mode"] == "challenge"
assert contract["allow_execute"] is False
assert contract["next_action"]
def test_feishu_reply_llm_core_builds_contract_reply_for_clarify_and_challenge() -> None:
clarify = FeishuReplyLLMCoreMixin._build_consultant_dispatch_reply(
{
"direct_reply_text": "",
"consultant_dispatch": {
"domain": "task",
"mode": "clarify",
"risk_level": "medium",
"missing_fields": ["project_id", "task_title"],
"allow_execute": False,
"next_action": "请补充归属项目和任务标题后继续。",
},
}
)
challenge = FeishuReplyLLMCoreMixin._build_consultant_dispatch_reply(
{
"direct_reply_text": "",
"consultant_dispatch": {
"domain": "project",
"mode": "challenge",
"risk_level": "high",
"missing_fields": [],
"allow_execute": False,
"next_action": "建议先拆分范围,再确认是否继续推进。",
},
}
)
assert "补充" in str(clarify or "")
assert "建议" in str(challenge or "")
@pytest.mark.asyncio
async def test_draft_confirm_handler_repeated_confirm_returns_inbox_success_copy() -> None:
class _FakeResult:

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["reply_required"] is True
assert payload["reply_text"] == "fallback"
def test_build_rag_response_payload_falls_back_to_top_level_consultant_dispatch() -> None:
consultant_dispatch = {
"domain": "project",
"mode": "challenge",
"risk_level": "high",
"missing_fields": [],
"allow_execute": False,
"next_action": "请先拆分范围,再确认是否继续推进高风险动作。",
}
payload = rag_ask_contract.build_rag_response_payload(
ingress_result={
"status": "answered",
"intent": "project.create",
"direct_reply_text": "当前建议先拆分范围。",
"consultant_dispatch": consultant_dispatch,
"rag_result": {
"answer": "当前建议先拆分范围。",
"processing_stage": "qa",
},
}
)
assert payload["consultant_dispatch"] == consultant_dispatch

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.request
from datetime import datetime
from pathlib import Path
from typing import Any, Optional
from sqlalchemy import func, select
@ -16,6 +17,8 @@ BASE = "http://127.0.0.1:8000"
CHAT_ID = "oc_c487d87563e48c2f9566ccd5a758212c"
SENDER = "P1验收机器人"
BANNED_PREFIXES = ["让我先", "让我尝试", "我来看看", "我先查", "让我看看", "我需要先"]
DEFAULT_LOG_PATH = Path(__file__).resolve().parent / "tmp_acceptance" / "consultant_dispatch_feishu_runs.jsonl"
TERMINAL_RUN_STATUSES = {"succeeded", "failed", "cancelled", "answered", "completed"}
def login_token() -> str:
@ -39,7 +42,7 @@ def post_capture(token: str, message: str, source_message_id: str) -> dict[str,
}
body = json.dumps(payload, ensure_ascii=False).encode("utf-8")
req = urllib.request.Request(
f"{BASE}/ai-steward/ai-steward/signal/capture-feishu",
f"{BASE}/ai-steward/signal/capture-feishu",
data=body,
headers={"Authorization": f"Bearer {token}", "Content-Type": "application/json"},
)
@ -91,6 +94,26 @@ def has_thinking_leak(text: str) -> bool:
return False
def append_jsonl_log(log_path: str, payload: dict[str, Any]) -> str:
target = Path(log_path).expanduser()
if not target.is_absolute():
target = (Path.cwd() / target).resolve()
target.parent.mkdir(parents=True, exist_ok=True)
with target.open("a", encoding="utf-8") as fh:
fh.write(json.dumps(payload, ensure_ascii=False) + "\n")
return str(target)
def resolve_consultant_dispatch(payload: dict[str, Any]) -> Optional[dict[str, Any]]:
direct = payload.get("consultant_dispatch")
if isinstance(direct, dict):
return direct
rag_result = payload.get("rag_result")
if isinstance(rag_result, dict) and isinstance(rag_result.get("consultant_dispatch"), dict):
return dict(rag_result.get("consultant_dispatch") or {})
return None
async def fetch_max_ai_call_id(db) -> int:
row = (await db.execute(select(func.max(AICallLog.id)))).scalar()
return int(row or 0)
@ -136,6 +159,41 @@ async def wait_outbox(db, source_message_id: str, run_status: str, timeout_sec:
return latest
async def wait_capture_artifacts(
db,
*,
source_message_id: str,
timeout_sec: int = 300,
) -> tuple[Optional[AIOrchestrationRun], list[FeishuReplyOutbox]]:
deadline = time.time() + timeout_sec
latest_run: Optional[AIOrchestrationRun] = None
latest_outbox: list[FeishuReplyOutbox] = []
while time.time() < deadline:
latest_run = (
await db.execute(
select(AIOrchestrationRun)
.where(AIOrchestrationRun.source_message_id == source_message_id)
.order_by(AIOrchestrationRun.id.desc())
.limit(1)
)
).scalar_one_or_none()
latest_outbox = (
await db.execute(
select(FeishuReplyOutbox)
.where(FeishuReplyOutbox.source_message_id == source_message_id)
.order_by(FeishuReplyOutbox.id.asc())
)
).scalars().all()
sent_rows = [row for row in latest_outbox if str(row.status or "").lower() == "sent"]
run_status = str(getattr(latest_run, "status", "") or "").strip().lower()
if len(sent_rows) >= 2:
return latest_run, latest_outbox
if sent_rows and (run_status in TERMINAL_RUN_STATUSES or latest_run is None):
return latest_run, latest_outbox
await asyncio.sleep(2)
return latest_run, latest_outbox
async def fetch_processing(db, source_message_id: str) -> Optional[AIProcessingLog]:
return (
await db.execute(
@ -217,6 +275,7 @@ async def main() -> None:
parser.add_argument("--message", type=str, required=True)
parser.add_argument("--keys", type=str, default="")
parser.add_argument("--prefix", type=str, default="real-scene-p1-manual")
parser.add_argument("--log-path", type=str, default=str(DEFAULT_LOG_PATH))
args = parser.parse_args()
keys = [k.strip() for k in str(args.keys).split("|") if k.strip()]
@ -232,11 +291,13 @@ async def main() -> None:
capture = post_capture(token, args.message, source_message_id)
async with AsyncSessionLocal() as db:
run_row = await wait_run(db, source_message_id)
run_row, outbox_rows = await wait_capture_artifacts(db, source_message_id=source_message_id)
run_status = str(getattr(run_row, "status", "missing") or "missing") if run_row is not None else "missing"
outbox_rows = await wait_outbox(db, source_message_id, run_status)
processing = await fetch_processing(db, source_message_id)
if keys:
prompt_scene, candidates = await fetch_prompt_scene(db, before_call_id, keys)
else:
prompt_scene, candidates = "unknown", []
sent_rows = [r for r in outbox_rows if str(r.status or "") == "sent"]
ack = str(sent_rows[0].message_text or "").strip() if sent_rows else ""
@ -252,8 +313,11 @@ async def main() -> None:
"generated_at": datetime.now().isoformat(),
"idx": args.idx,
"source_message_id": source_message_id,
"question": args.message,
"message": args.message,
"capture_status": str(capture.get("status") or ""),
"capture_direct_reply_text": str(capture.get("direct_reply_text") or ""),
"consultant_dispatch": resolve_consultant_dispatch(capture),
"run_status": run_status,
"ack": ack,
"final_reply": final_reply,
@ -284,6 +348,7 @@ async def main() -> None:
"pass": passed,
"pass_rule": pass_rule,
}
payload["log_path"] = append_jsonl_log(args.log_path, payload)
print(json.dumps(payload, ensure_ascii=False))

View File

@ -14,6 +14,14 @@ type Helpfulness = "fully" | "partially" | "could_be_better";
type ThinkingMode = "normal" | "deep";
type ActionTaken = "signal_created" | "signal_deduped" | "signal_failed";
type MessageSource = { source: string; label: string; score: number; metadata?: Record<string, unknown> };
type ConsultantDispatch = {
domain: string;
mode: string;
risk_level: string;
missing_fields: string[];
allow_execute: boolean;
next_action: string;
};
type SessionSummary = {
id: number;
@ -52,6 +60,7 @@ type SessionMessage = {
ack_latency_ms?: number | null;
trace_id?: string | null;
processing_stage?: string | null;
consultant_dispatch?: ConsultantDispatch | null;
};
type SessionDetail = {
@ -75,6 +84,18 @@ const ERROR_OPTIONS = ["答案错误", "来源错误", "不完整", "答非所
const LAST_SEEN_KEY = "pmflow_qa_chat_last_seen";
const THINKING_MODE_KEY = "pmflow_qa_chat_thinking_mode";
const QUICK_QUESTIONS = ["你是谁", "系统架构是什么", "最近有哪些项目", "当前阻塞项有哪些"];
const CONSULTANT_MODE_LABELS: Record<string, string> = {
clarify: "先澄清",
advise: "给建议",
challenge: "先劝退",
brainstorm: "一起想",
execute: "可执行",
};
const CONSULTANT_RISK_LABELS: Record<string, string> = {
low: "低风险",
medium: "中风险",
high: "高风险",
};
function normalizeRequestError(error: unknown, fallback = "请求失败,请稍后重试。"): string {
const raw = error instanceof Error ? error.message : String(error || "");
@ -96,6 +117,27 @@ function normalizeSources(value: unknown): MessageSource[] {
}));
}
function normalizeConsultantDispatch(value: unknown): ConsultantDispatch | null {
if (!value || typeof value !== "object") return null;
const item = value as Record<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 {
const clean = String(text || "").trim();
if (!clean) return false;
@ -125,6 +167,34 @@ function getStageBadge(message: Pick<SessionMessage, "processing_stage" | "reply
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> {
const token = getAuthToken();
if (!token) return null;
@ -164,6 +234,7 @@ async function askRAG(
ack_latency_ms?: number | null;
trace_id?: string | null;
processing_stage?: string | null;
consultant_dispatch?: ConsultantDispatch | null;
}> {
const token = getAuthToken();
if (!token) {
@ -224,6 +295,7 @@ async function askRAG(
ack_latency_ms?: number | null;
trace_id?: string | null;
processing_stage?: string | null;
consultant_dispatch?: unknown;
};
if (!data || !String(data.answer || "").trim()) {
throw new Error("服务返回为空,未生成有效回答。");
@ -242,6 +314,7 @@ async function askRAG(
ack_latency_ms: data.ack_latency_ms ?? null,
trace_id: data.trace_id ?? null,
processing_stage: data.processing_stage ?? null,
consultant_dispatch: normalizeConsultantDispatch(data.consultant_dispatch),
};
}
@ -450,6 +523,7 @@ export function FloatingQAChat() {
ack_latency_ms: data.ack_latency_ms ?? null,
trace_id: data.trace_id ?? null,
processing_stage: data.processing_stage ?? null,
consultant_dispatch: data.consultant_dispatch ?? null,
},
]);
const returnedSessionId = Number(data.session_id || 0) || null;
@ -756,6 +830,12 @@ export function FloatingQAChat() {
const isLocalMessage = msg.id < 0;
const actionBadge = getActionBadge(msg.action_taken);
const stageBadge = getStageBadge(msg);
const consultantDispatch = msg.consultant_dispatch ?? null;
const consultantModeBadge = getConsultantModeBadge(consultantDispatch);
const consultantRiskBadge = getConsultantRiskBadge(consultantDispatch);
const consultantMissingFields = consultantDispatch?.missing_fields?.length
? consultantDispatch.missing_fields.join(" / ")
: "";
return (
<div key={msg.id} className="space-y-2">
<div className="flex justify-end">
@ -779,7 +859,31 @@ export function FloatingQAChat() {
{msg.signal_id ? <Badge variant="outline">signal #{msg.signal_id}</Badge> : null}
{stageBadge ? <Badge variant="outline">{stageBadge}</Badge> : null}
<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>
{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 ? (
<div className="mt-2 text-[11px] text-slate-500">trace: {msg.trace_id}</div>
) : null}