PMFlow/backend/app/services/ai_pmo/consultant_dispatch.py

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