316 lines
12 KiB
Python
316 lines
12 KiB
Python
"""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",
|
|
]
|