Handle low-overlap requirement topics as new plans
This commit is contained in:
parent
e372b88541
commit
bd5a2813c3
|
|
@ -572,7 +572,8 @@ uvicorn app.main:app --reload --host 0.0.0.0 --port 8000
|
||||||
- AdaptiveRAG 场景化 Prompt 选择器必须在 `mixin_deep_guard_answer.py` 组装 Prompt 前执行,并遵循固定优先级:成员负载 > 趋势(项目关联 external_trend / 通用 industry_trend) > mixed > AI PMO 入口 > 默认 RAG
|
- AdaptiveRAG 场景化 Prompt 选择器必须在 `mixin_deep_guard_answer.py` 组装 Prompt 前执行,并遵循固定优先级:成员负载 > 趋势(项目关联 external_trend / 通用 industry_trend) > mixed > AI PMO 入口 > 默认 RAG
|
||||||
- 飞书中的“想聊需求 / 先别创建 / 先收敛第一版”类探索问题必须先由 `workflow_semantic_planner` 标记为 `message_intent=requirement_plan`,再统一落到 `PROMPT_AI_PMO_REQUIREMENT_PLAN_QA` 的需求收敛场景;该场景禁止落回“五大口径”评审或直接自动创建
|
- 飞书中的“想聊需求 / 先别创建 / 先收敛第一版”类探索问题必须先由 `workflow_semantic_planner` 标记为 `message_intent=requirement_plan`,再统一落到 `PROMPT_AI_PMO_REQUIREMENT_PLAN_QA` 的需求收敛场景;该场景禁止落回“五大口径”评审或直接自动创建
|
||||||
- `requirement_plan` 现在必须是多轮状态机,而不是单轮问一句:`ExecuteQueryProjectMixin` 要把同线程/同群同人的需求收敛状态持久化到 `mounted_topic_workspaces + mounted_draft_workspaces`,每轮只推进 1 个关键问题,信息足够后直接整理成 Feishu 友好的需求草稿
|
- `requirement_plan` 现在必须是多轮状态机,而不是单轮问一句:`ExecuteQueryProjectMixin` 要把同线程/同群同人的需求收敛状态持久化到 `mounted_topic_workspaces + mounted_draft_workspaces`,每轮只推进 1 个关键问题,信息足够后直接整理成 Feishu 友好的需求草稿
|
||||||
- `requirement_plan` 不允许再把“同群同人”直接等同于“同一个需求 topic”:Mounted Runtime 必须用 `conversation_scope_key` 归并会话范围,再用显式 `topic_key` 区分具体需求;用户说“另一个需求 / 换个需求 / 不是这个需求”时必须新开 topic,并把旧 topic 置为 `suspended`
|
- `requirement_plan` 不允许再把“同群同人”直接等同于“同一个需求 topic”:Mounted Runtime 必须用 `conversation_scope_key` 归并会话范围,再用显式 `topic_key` 区分具体需求;用户说“另一个需求 / 换个需求 / 不是这个需求 / 聊个新需求”时必须新开 topic,并把旧 topic 置为 `suspended`
|
||||||
|
- `requirement_plan` 的 topic resolver 不能只靠显式关键词:当同会话存在 active plan workspace,且新消息是新的需求起手式、与当前 topic card/draft 的重叠度明显偏低时,必须按 `candidate_new_topic` 优先切新 topic;尤其旧 topic 已处于 `awaiting_execute_confirm / plan_ready` 时,不得继续把“关于 openclaw 批量部署”这类低重叠消息续写到“A 股打分工具”草稿上
|
||||||
- `workflow_semantic_planner` 必须把“先按技术指标来 / 主要给投顾看 / 先做成飞书日报 / 每天收盘后更新”这类短槽位回答继续识别为 `message_intent=requirement_plan`,禁止第二轮开始掉回普通 query 或高阶 RAG
|
- `workflow_semantic_planner` 必须把“先按技术指标来 / 主要给投顾看 / 先做成飞书日报 / 每天收盘后更新”这类短槽位回答继续识别为 `message_intent=requirement_plan`,禁止第二轮开始掉回普通 query 或高阶 RAG
|
||||||
- `workflow_semantic_planner` 也必须把通用需求收敛里的短槽位回答继续识别为 `message_intent=requirement_plan`,例如“先给内部 PM 和研发负责人用 / 飞书总结就行 / 每次会后半小时内整理出来”;这类碎片化回复不得因为没有新 `semantic_bundle` 就退回普通 query
|
- `workflow_semantic_planner` 也必须把通用需求收敛里的短槽位回答继续识别为 `message_intent=requirement_plan`,例如“先给内部 PM 和研发负责人用 / 飞书总结就行 / 每次会后半小时内整理出来”;这类碎片化回复不得因为没有新 `semantic_bundle` 就退回普通 query
|
||||||
- `ExecuteQueryProjectMixin` 对 `message_intent=requirement_plan` 的查询必须优先走 mounted runtime 直答快路径:默认只输出自然短话术,每轮最多只保留 1 个关键问题;当 `scoring_basis/target_user/output_form/cadence/first_scope` 收集完成时,必须直接整理成需求草稿,禁止继续空转追问
|
- `ExecuteQueryProjectMixin` 对 `message_intent=requirement_plan` 的查询必须优先走 mounted runtime 直答快路径:默认只输出自然短话术,每轮最多只保留 1 个关键问题;当 `scoring_basis/target_user/output_form/cadence/first_scope` 收集完成时,必须直接整理成需求草稿,禁止继续空转追问
|
||||||
|
|
|
||||||
|
|
@ -610,10 +610,193 @@ class ExecuteQueryProjectMixin:
|
||||||
return False
|
return False
|
||||||
if WorkflowSemanticPlanner._looks_like_requirement_plan_slot_answer(message_text):
|
if WorkflowSemanticPlanner._looks_like_requirement_plan_slot_answer(message_text):
|
||||||
return True
|
return True
|
||||||
if cls._extract_requirement_plan_updates(message_text=message_text, card={}):
|
material_updates = {
|
||||||
|
key: value
|
||||||
|
for key, value in cls._extract_requirement_plan_updates(message_text=message_text, card={}).items()
|
||||||
|
if key not in {"plan_profile", "title", "goal"}
|
||||||
|
}
|
||||||
|
if material_updates:
|
||||||
return True
|
return True
|
||||||
return any(token in normalized for token in ("整理成草稿", "出草稿", "汇总一下", "按这版", "就这样先收"))
|
return any(token in normalized for token in ("整理成草稿", "出草稿", "汇总一下", "按这版", "就这样先收"))
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def _collect_requirement_topic_text_fragments(cls, payload: Any) -> list[str]:
|
||||||
|
fragments: list[str] = []
|
||||||
|
if isinstance(payload, dict):
|
||||||
|
for value in payload.values():
|
||||||
|
fragments.extend(cls._collect_requirement_topic_text_fragments(value))
|
||||||
|
return fragments
|
||||||
|
if isinstance(payload, (list, tuple, set)):
|
||||||
|
for item in payload:
|
||||||
|
fragments.extend(cls._collect_requirement_topic_text_fragments(item))
|
||||||
|
return fragments
|
||||||
|
text = str(payload or "").strip()
|
||||||
|
if text:
|
||||||
|
fragments.append(text)
|
||||||
|
return fragments
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def _build_requirement_topic_overlap_tokens(cls, text: str) -> set[str]:
|
||||||
|
normalized = WorkflowSemanticPlanner._normalize_text(text)
|
||||||
|
if not normalized:
|
||||||
|
return set()
|
||||||
|
for stopword in (
|
||||||
|
"想跟你聊",
|
||||||
|
"跟你聊",
|
||||||
|
"聊聊",
|
||||||
|
"聊个",
|
||||||
|
"聊需求",
|
||||||
|
"先不创建",
|
||||||
|
"先别创建",
|
||||||
|
"先不要创建",
|
||||||
|
"先收第一版",
|
||||||
|
"先收一版",
|
||||||
|
"第一版",
|
||||||
|
"关于",
|
||||||
|
"需求",
|
||||||
|
"项目",
|
||||||
|
"工具",
|
||||||
|
"系统",
|
||||||
|
"平台",
|
||||||
|
"方案",
|
||||||
|
"一个",
|
||||||
|
"这个",
|
||||||
|
"那个",
|
||||||
|
"我们",
|
||||||
|
"这次",
|
||||||
|
"现在",
|
||||||
|
):
|
||||||
|
normalized = normalized.replace(stopword, "")
|
||||||
|
tokens: set[str] = {
|
||||||
|
token
|
||||||
|
for token in re.findall(r"[a-z][a-z0-9_-]{1,}", normalized)
|
||||||
|
if len(token) >= 2
|
||||||
|
}
|
||||||
|
chinese_text = re.sub(r"[a-z0-9_-]+", "", normalized)
|
||||||
|
for index in range(len(chinese_text) - 1):
|
||||||
|
token = chinese_text[index : index + 2]
|
||||||
|
if len(token.strip()) == 2:
|
||||||
|
tokens.add(token)
|
||||||
|
return tokens
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def _compute_requirement_topic_overlap_score(
|
||||||
|
cls,
|
||||||
|
*,
|
||||||
|
topic: Optional[MountedTopicWorkspace],
|
||||||
|
draft: Optional[MountedDraftWorkspace],
|
||||||
|
message_text: str,
|
||||||
|
) -> float:
|
||||||
|
context_parts: list[str] = []
|
||||||
|
if topic is not None:
|
||||||
|
context_parts.extend(cls._collect_requirement_topic_text_fragments(topic.facts_json))
|
||||||
|
context_parts.extend(cls._collect_requirement_topic_text_fragments(topic.goals_json))
|
||||||
|
context_parts.extend(cls._collect_requirement_topic_text_fragments(topic.constraints_json))
|
||||||
|
if draft is not None:
|
||||||
|
context_parts.extend(cls._collect_requirement_topic_text_fragments(draft.requirement_card))
|
||||||
|
context_parts.extend(cls._collect_requirement_topic_text_fragments(draft.draft_payload))
|
||||||
|
topic_tokens = cls._build_requirement_topic_overlap_tokens(" ".join(context_parts))
|
||||||
|
message_tokens = cls._build_requirement_topic_overlap_tokens(message_text)
|
||||||
|
if not topic_tokens or not message_tokens:
|
||||||
|
return 0.0
|
||||||
|
overlap = len(topic_tokens & message_tokens)
|
||||||
|
denominator = max(1, min(len(topic_tokens), len(message_tokens)))
|
||||||
|
return round(overlap / denominator, 4)
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def _looks_like_requirement_new_topic_candidate(cls, *, message_text: str) -> bool:
|
||||||
|
normalized = WorkflowSemanticPlanner._normalize_text(message_text)
|
||||||
|
if not normalized:
|
||||||
|
return False
|
||||||
|
if WorkflowSemanticPlanner.looks_like_explicit_new_requirement_topic(message_text):
|
||||||
|
return True
|
||||||
|
if cls._looks_like_requirement_plan_continuation(message_text=message_text):
|
||||||
|
return False
|
||||||
|
if not cls._looks_like_requirement_plan_seed(message_text=message_text):
|
||||||
|
return False
|
||||||
|
return (
|
||||||
|
WorkflowSemanticPlanner._looks_like_requirement_plan_chat(message_text)
|
||||||
|
or WorkflowSemanticPlanner.looks_like_project_create_request(message_text)
|
||||||
|
or any(token in normalized for token in ("想做", "做个", "做一个", "有个需求", "关于"))
|
||||||
|
)
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def _should_switch_requirement_topic_by_overlap(
|
||||||
|
cls,
|
||||||
|
*,
|
||||||
|
topic: MountedTopicWorkspace,
|
||||||
|
draft: Optional[MountedDraftWorkspace],
|
||||||
|
message_text: str,
|
||||||
|
) -> bool:
|
||||||
|
if not cls._looks_like_requirement_new_topic_candidate(message_text=message_text):
|
||||||
|
return False
|
||||||
|
overlap_score = cls._compute_requirement_topic_overlap_score(
|
||||||
|
topic=topic,
|
||||||
|
draft=draft,
|
||||||
|
message_text=message_text,
|
||||||
|
)
|
||||||
|
plan_status = str(getattr(topic, "plan_status", "") or "").strip().lower()
|
||||||
|
if plan_status in {"awaiting_execute_confirm", "plan_ready"}:
|
||||||
|
return overlap_score <= 0.12
|
||||||
|
if plan_status == "options_ready":
|
||||||
|
return overlap_score <= 0.08
|
||||||
|
return overlap_score == 0.0
|
||||||
|
|
||||||
|
async def _find_active_requirement_plan_workspace(
|
||||||
|
self,
|
||||||
|
*,
|
||||||
|
source_system: str,
|
||||||
|
source_message_id: str,
|
||||||
|
source_chat_id: Optional[str],
|
||||||
|
sender: Optional[str],
|
||||||
|
source_sender_open_id: Optional[str],
|
||||||
|
thread_context: Optional[dict[str, Any]],
|
||||||
|
conversation_key: Optional[str],
|
||||||
|
) -> tuple[Optional[MountedTopicWorkspace], Optional[MountedDraftWorkspace]]:
|
||||||
|
if not self._supports_requirement_plan_workspace(self.db):
|
||||||
|
return None, None
|
||||||
|
topic_key, conditions, root_message_id, thread_id, scope_key = self._build_requirement_plan_anchor_candidates(
|
||||||
|
source_system=source_system,
|
||||||
|
source_message_id=source_message_id,
|
||||||
|
source_chat_id=source_chat_id,
|
||||||
|
sender=sender,
|
||||||
|
source_sender_open_id=source_sender_open_id,
|
||||||
|
thread_context=thread_context,
|
||||||
|
conversation_key=conversation_key,
|
||||||
|
force_new_topic=False,
|
||||||
|
)
|
||||||
|
topic_rows = (
|
||||||
|
await self.db.execute(
|
||||||
|
select(MountedTopicWorkspace).where(
|
||||||
|
MountedTopicWorkspace.tenant_id == int(self.tenant_id),
|
||||||
|
MountedTopicWorkspace.source_system == str(source_system or "").strip().lower(),
|
||||||
|
MountedTopicWorkspace.mode == "plan",
|
||||||
|
MountedTopicWorkspace.status == "active",
|
||||||
|
or_(*conditions),
|
||||||
|
)
|
||||||
|
.order_by(MountedTopicWorkspace.updated_at.desc(), MountedTopicWorkspace.id.desc())
|
||||||
|
)
|
||||||
|
).scalars().all()
|
||||||
|
topic = self._select_requirement_plan_topic(
|
||||||
|
candidates=list(topic_rows),
|
||||||
|
topic_key=topic_key,
|
||||||
|
root_message_id=root_message_id,
|
||||||
|
thread_id=thread_id,
|
||||||
|
conversation_scope_key=scope_key or conversation_key,
|
||||||
|
)
|
||||||
|
if topic is None:
|
||||||
|
return None, None
|
||||||
|
draft = (
|
||||||
|
await self.db.execute(
|
||||||
|
select(MountedDraftWorkspace).where(
|
||||||
|
MountedDraftWorkspace.tenant_id == int(self.tenant_id),
|
||||||
|
MountedDraftWorkspace.topic_workspace_id == int(topic.id),
|
||||||
|
MountedDraftWorkspace.state.in_(("collecting", "ready")),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
).scalar_one_or_none()
|
||||||
|
return topic, draft
|
||||||
|
|
||||||
async def _load_requirement_plan_workspace(
|
async def _load_requirement_plan_workspace(
|
||||||
self,
|
self,
|
||||||
*,
|
*,
|
||||||
|
|
@ -723,9 +906,9 @@ class ExecuteQueryProjectMixin:
|
||||||
conversation_key: Optional[str],
|
conversation_key: Optional[str],
|
||||||
force_new_topic: bool = False,
|
force_new_topic: bool = False,
|
||||||
) -> bool:
|
) -> bool:
|
||||||
if not self._supports_requirement_plan_workspace(self.db):
|
if force_new_topic:
|
||||||
return False
|
return False
|
||||||
topic_key, conditions, root_message_id, thread_id, scope_key = self._build_requirement_plan_anchor_candidates(
|
topic, draft = await self._find_active_requirement_plan_workspace(
|
||||||
source_system=source_system,
|
source_system=source_system,
|
||||||
source_message_id=source_message_id,
|
source_message_id=source_message_id,
|
||||||
source_chat_id=source_chat_id,
|
source_chat_id=source_chat_id,
|
||||||
|
|
@ -733,41 +916,42 @@ class ExecuteQueryProjectMixin:
|
||||||
source_sender_open_id=source_sender_open_id,
|
source_sender_open_id=source_sender_open_id,
|
||||||
thread_context=thread_context,
|
thread_context=thread_context,
|
||||||
conversation_key=conversation_key,
|
conversation_key=conversation_key,
|
||||||
force_new_topic=force_new_topic,
|
|
||||||
)
|
)
|
||||||
if force_new_topic:
|
return topic is not None and draft is not None
|
||||||
return False
|
|
||||||
topic_rows = (
|
async def _infer_requirement_plan_topic_switch(
|
||||||
await self.db.execute(
|
self,
|
||||||
select(MountedTopicWorkspace).where(
|
*,
|
||||||
MountedTopicWorkspace.tenant_id == int(self.tenant_id),
|
message_text: str,
|
||||||
MountedTopicWorkspace.source_system == str(source_system or "").strip().lower(),
|
source_system: str,
|
||||||
MountedTopicWorkspace.mode == "plan",
|
source_message_id: str,
|
||||||
MountedTopicWorkspace.status == "active",
|
source_chat_id: Optional[str],
|
||||||
or_(*conditions),
|
sender: Optional[str],
|
||||||
)
|
source_sender_open_id: Optional[str],
|
||||||
.order_by(MountedTopicWorkspace.updated_at.desc(), MountedTopicWorkspace.id.desc())
|
thread_context: Optional[dict[str, Any]],
|
||||||
)
|
conversation_key: Optional[str],
|
||||||
).scalars().all()
|
message_intent: Optional[str],
|
||||||
topic = self._select_requirement_plan_topic(
|
) -> Optional[str]:
|
||||||
candidates=list(topic_rows),
|
if str(message_intent or "").strip().lower() != "requirement_plan":
|
||||||
topic_key=topic_key,
|
return None
|
||||||
root_message_id=root_message_id,
|
topic, draft = await self._find_active_requirement_plan_workspace(
|
||||||
thread_id=thread_id,
|
source_system=source_system,
|
||||||
conversation_scope_key=scope_key or conversation_key,
|
source_message_id=source_message_id,
|
||||||
|
source_chat_id=source_chat_id,
|
||||||
|
sender=sender,
|
||||||
|
source_sender_open_id=source_sender_open_id,
|
||||||
|
thread_context=thread_context,
|
||||||
|
conversation_key=conversation_key,
|
||||||
)
|
)
|
||||||
if topic is None:
|
if topic is None:
|
||||||
return False
|
return None
|
||||||
draft = (
|
if self._should_switch_requirement_topic_by_overlap(
|
||||||
await self.db.execute(
|
topic=topic,
|
||||||
select(MountedDraftWorkspace).where(
|
draft=draft,
|
||||||
MountedDraftWorkspace.tenant_id == int(self.tenant_id),
|
message_text=message_text,
|
||||||
MountedDraftWorkspace.topic_workspace_id == int(topic.id),
|
):
|
||||||
MountedDraftWorkspace.state.in_(("collecting", "ready")),
|
return "new"
|
||||||
)
|
return None
|
||||||
)
|
|
||||||
).scalar_one_or_none()
|
|
||||||
return draft is not None
|
|
||||||
|
|
||||||
async def _build_requirement_planning_direct_result(
|
async def _build_requirement_planning_direct_result(
|
||||||
self,
|
self,
|
||||||
|
|
@ -1189,6 +1373,18 @@ class ExecuteQueryProjectMixin:
|
||||||
topic_switch = "new"
|
topic_switch = "new"
|
||||||
message_intent = message_intent or "requirement_plan"
|
message_intent = message_intent or "requirement_plan"
|
||||||
consultant_mode_preference = consultant_mode_preference or "clarify"
|
consultant_mode_preference = consultant_mode_preference or "clarify"
|
||||||
|
if not topic_switch:
|
||||||
|
topic_switch = await self._infer_requirement_plan_topic_switch(
|
||||||
|
message_text=message_text,
|
||||||
|
source_system=source_system,
|
||||||
|
source_message_id=source_message_id,
|
||||||
|
source_chat_id=source_chat_id,
|
||||||
|
sender=None,
|
||||||
|
source_sender_open_id=source_sender_open_id,
|
||||||
|
thread_context=thread_context,
|
||||||
|
conversation_key=conversation_key,
|
||||||
|
message_intent=message_intent,
|
||||||
|
)
|
||||||
if not message_intent and self._looks_like_requirement_plan_continuation(message_text=message_text):
|
if not message_intent and self._looks_like_requirement_plan_continuation(message_text=message_text):
|
||||||
has_active_plan_workspace = await self._has_active_requirement_plan_workspace(
|
has_active_plan_workspace = await self._has_active_requirement_plan_workspace(
|
||||||
source_system=source_system,
|
source_system=source_system,
|
||||||
|
|
|
||||||
|
|
@ -176,9 +176,22 @@ REQUIREMENT_PLAN_CHAT_HINTS = (
|
||||||
"先不要入库",
|
"先不要入库",
|
||||||
)
|
)
|
||||||
REQUIREMENT_TOPIC_SWITCH_NEW_HINTS = (
|
REQUIREMENT_TOPIC_SWITCH_NEW_HINTS = (
|
||||||
|
"聊个新需求",
|
||||||
|
"聊一个新需求",
|
||||||
|
"聊聊新需求",
|
||||||
|
"有个新需求",
|
||||||
|
"这是个新需求",
|
||||||
|
"这是新需求",
|
||||||
|
"这次是新需求",
|
||||||
|
"现在是新需求",
|
||||||
|
"换个新需求",
|
||||||
|
"换一个新需求",
|
||||||
|
"切到新需求",
|
||||||
|
"切个新需求",
|
||||||
"另一个需求",
|
"另一个需求",
|
||||||
"换个需求",
|
"换个需求",
|
||||||
"换一个需求",
|
"换一个需求",
|
||||||
|
"新的需求",
|
||||||
"不是这个需求",
|
"不是这个需求",
|
||||||
"不是刚才那个需求",
|
"不是刚才那个需求",
|
||||||
"别沿用刚才那版",
|
"别沿用刚才那版",
|
||||||
|
|
@ -186,6 +199,17 @@ REQUIREMENT_TOPIC_SWITCH_NEW_HINTS = (
|
||||||
"另起一个需求",
|
"另起一个需求",
|
||||||
"重新开一个需求",
|
"重新开一个需求",
|
||||||
"单开一个需求",
|
"单开一个需求",
|
||||||
|
"跟上一个无关",
|
||||||
|
"和上一个无关",
|
||||||
|
"不是延续刚才那个",
|
||||||
|
"不是接着刚才那个",
|
||||||
|
)
|
||||||
|
REQUIREMENT_TOPIC_SWITCH_NEW_PATTERNS = (
|
||||||
|
re.compile(r"(?:聊|说|讨论|讲).{0,6}(?:个|一个)?新需求", re.IGNORECASE),
|
||||||
|
re.compile(r"(?:这是|这次是|现在是|我有|这边有|还有|另外有).{0,8}(?:个|一个)?新需求", re.IGNORECASE),
|
||||||
|
re.compile(r"(?:切|换|转).{0,4}(?:到|个|一个)?(?:新需求|新的需求|新话题|newtopic|topic)", re.IGNORECASE),
|
||||||
|
re.compile(r"(?:另起|单开|重新开|新开|开个新|拉个新|起个新).{0,6}(?:需求|话题|topic)", re.IGNORECASE),
|
||||||
|
re.compile(r"(?:跟上一个无关|和上一个无关|不是延续刚才那个|不是接着刚才那个)", re.IGNORECASE),
|
||||||
)
|
)
|
||||||
CORRECTION_ESCALATION_HINTS = (
|
CORRECTION_ESCALATION_HINTS = (
|
||||||
"重新回答",
|
"重新回答",
|
||||||
|
|
|
||||||
|
|
@ -65,6 +65,7 @@ from .constants import (
|
||||||
QUESTION_HINTS,
|
QUESTION_HINTS,
|
||||||
REQUIREMENT_PLAN_CHAT_HINTS,
|
REQUIREMENT_PLAN_CHAT_HINTS,
|
||||||
REQUIREMENT_TOPIC_SWITCH_NEW_HINTS,
|
REQUIREMENT_TOPIC_SWITCH_NEW_HINTS,
|
||||||
|
REQUIREMENT_TOPIC_SWITCH_NEW_PATTERNS,
|
||||||
REPLY_MODE_CARD,
|
REPLY_MODE_CARD,
|
||||||
REPLY_MODE_TEXT,
|
REPLY_MODE_TEXT,
|
||||||
ROUTE_FOLLOWUP_CONFIRM,
|
ROUTE_FOLLOWUP_CONFIRM,
|
||||||
|
|
@ -254,6 +255,8 @@ class WorkflowSemanticPlanner:
|
||||||
return False
|
return False
|
||||||
if any(token in normalized for token in REQUIREMENT_TOPIC_SWITCH_NEW_HINTS):
|
if any(token in normalized for token in REQUIREMENT_TOPIC_SWITCH_NEW_HINTS):
|
||||||
return True
|
return True
|
||||||
|
if any(pattern.search(str(text or "")) for pattern in REQUIREMENT_TOPIC_SWITCH_NEW_PATTERNS):
|
||||||
|
return True
|
||||||
if "现在是" in normalized and any(token in normalized for token in ("另一个需求", "新的需求", "新话题")):
|
if "现在是" in normalized and any(token in normalized for token in ("另一个需求", "新的需求", "新话题")):
|
||||||
return True
|
return True
|
||||||
if "不对" in normalized and any(token in normalized for token in ("另一个需求", "不是这个需求", "别沿用", "不要沿用")):
|
if "不对" in normalized and any(token in normalized for token in ("另一个需求", "不是这个需求", "别沿用", "不要沿用")):
|
||||||
|
|
@ -272,7 +275,7 @@ class WorkflowSemanticPlanner:
|
||||||
):
|
):
|
||||||
return False
|
return False
|
||||||
slot_hits = 0
|
slot_hits = 0
|
||||||
slot_hits += int(any(token in normalized for token in ("技术指标", "均线", "成交量", "涨跌比", "macd", "rsi", "规则", "自动化")))
|
slot_hits += int(any(token in normalized for token in ("技术指标", "均线", "成交量", "涨跌比", "macd", "rsi", "规则")))
|
||||||
slot_hits += int(any(token in normalized for token in ("情绪", "涨停家数", "北向", "北向资金", "舆情", "热度")))
|
slot_hits += int(any(token in normalized for token in ("情绪", "涨停家数", "北向", "北向资金", "舆情", "热度")))
|
||||||
slot_hits += int(
|
slot_hits += int(
|
||||||
any(
|
any(
|
||||||
|
|
@ -299,7 +302,7 @@ class WorkflowSemanticPlanner:
|
||||||
slot_hits += int(any(token in normalized for token in ("第一版", "先保留", "先只做", "别做太重", "先做成", "先按")))
|
slot_hits += int(any(token in normalized for token in ("第一版", "先保留", "先只做", "别做太重", "先做成", "先按")))
|
||||||
has_planning_prefix = any(
|
has_planning_prefix = any(
|
||||||
token in normalized
|
token in normalized
|
||||||
for token in ("先按", "主要给", "先给", "先做成", "第一版", "先保留")
|
for token in ("先按", "主要给", "先给", "先做成", "先保留")
|
||||||
)
|
)
|
||||||
has_generic_target_user_shape = any(
|
has_generic_target_user_shape = any(
|
||||||
token in normalized
|
token in normalized
|
||||||
|
|
@ -310,6 +313,8 @@ class WorkflowSemanticPlanner:
|
||||||
for token in ("会后", "半小时", "当天内", "整理出来", "整理出", "产出")
|
for token in ("会后", "半小时", "当天内", "整理出来", "整理出", "产出")
|
||||||
)
|
)
|
||||||
compact_length = len(normalized)
|
compact_length = len(normalized)
|
||||||
|
if cls._looks_like_requirement_plan_chat(text) and slot_hits <= 1 and not has_generic_target_user_shape and not has_generic_cadence_shape:
|
||||||
|
return False
|
||||||
return (
|
return (
|
||||||
slot_hits >= 2
|
slot_hits >= 2
|
||||||
or (has_planning_prefix and slot_hits >= 1)
|
or (has_planning_prefix and slot_hits >= 1)
|
||||||
|
|
@ -465,6 +470,8 @@ class WorkflowSemanticPlanner:
|
||||||
return True
|
return True
|
||||||
if any(token in normalized for token in PROJECT_CREATE_DEFERRED_CONFIRM_HINTS):
|
if any(token in normalized for token in PROJECT_CREATE_DEFERRED_CONFIRM_HINTS):
|
||||||
return True
|
return True
|
||||||
|
if cls._looks_like_requirement_plan_chat(text):
|
||||||
|
return True
|
||||||
if cls._looks_like_requirement_plan_slot_answer(text):
|
if cls._looks_like_requirement_plan_slot_answer(text):
|
||||||
return True
|
return True
|
||||||
return cls._looks_like_assignee_recommendation(text)
|
return cls._looks_like_assignee_recommendation(text)
|
||||||
|
|
|
||||||
|
|
@ -1949,6 +1949,114 @@ async def test_execute_query_intent_explicit_new_requirement_topic_suspends_prev
|
||||||
assert rows[1].plan_status == "collecting"
|
assert rows[1].plan_status == "collecting"
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_execute_query_intent_low_overlap_requirement_seed_switches_new_topic(
|
||||||
|
monkeypatch,
|
||||||
|
requirement_plan_db_session,
|
||||||
|
) -> None:
|
||||||
|
class FakeAdaptiveRAGService:
|
||||||
|
def __init__(self, db, tenant_id: int):
|
||||||
|
_ = (db, tenant_id)
|
||||||
|
|
||||||
|
async def answer_question(self, **kwargs):
|
||||||
|
_ = kwargs
|
||||||
|
raise AssertionError("low-overlap requirement switching should not enter heavy RAG path")
|
||||||
|
|
||||||
|
class FakeExecutor(mixin_execute_query_project.ExecuteQueryProjectMixin):
|
||||||
|
def __init__(self) -> None:
|
||||||
|
self.db = requirement_plan_db_session
|
||||||
|
self.tenant_id = 3
|
||||||
|
|
||||||
|
monkeypatch.setattr(mixin_execute_query_project, "AdaptiveRAGService", FakeAdaptiveRAGService)
|
||||||
|
executor = FakeExecutor()
|
||||||
|
|
||||||
|
current_topic = MountedTopicWorkspace(
|
||||||
|
tenant_id=3,
|
||||||
|
topic_key="feishu:chat-plan-overlap:root:msg-plan-overlap-root",
|
||||||
|
source_system="feishu",
|
||||||
|
channel_ref="chat-plan-overlap",
|
||||||
|
conversation_scope_key=build_feishu_conversation_scope_key(
|
||||||
|
source_chat_id="chat-plan-overlap",
|
||||||
|
source_sender_open_id="ou-plan-overlap",
|
||||||
|
sender=None,
|
||||||
|
),
|
||||||
|
root_message_id="msg-plan-overlap-root",
|
||||||
|
mode="plan",
|
||||||
|
status="active",
|
||||||
|
plan_status="awaiting_execute_confirm",
|
||||||
|
facts_json={
|
||||||
|
"title": "每日 A 股趋势打分工具",
|
||||||
|
"target_user": "投顾",
|
||||||
|
"scoring_basis": "技术指标(均线、成交量、涨跌比等)",
|
||||||
|
"output_form": "飞书日报",
|
||||||
|
},
|
||||||
|
goals_json=["每天产出一个可解释的 A 股趋势分数,辅助判断和记录。"],
|
||||||
|
constraints_json={"first_scope": "先保留一个分数、方向判断和一句解释"},
|
||||||
|
pending_actions=["confirm_requirement_draft"],
|
||||||
|
metadata_json={"plan_mode": True},
|
||||||
|
)
|
||||||
|
requirement_plan_db_session.add(current_topic)
|
||||||
|
await requirement_plan_db_session.flush()
|
||||||
|
requirement_plan_db_session.add(
|
||||||
|
MountedDraftWorkspace(
|
||||||
|
tenant_id=3,
|
||||||
|
topic_workspace_id=int(current_topic.id),
|
||||||
|
state="ready",
|
||||||
|
draft_version=1,
|
||||||
|
requirement_card={
|
||||||
|
"title": "每日 A 股趋势打分工具",
|
||||||
|
"goal": "每天产出一个可解释的 A 股趋势分数,辅助判断和记录。",
|
||||||
|
"target_user": "投顾",
|
||||||
|
"scoring_basis": "技术指标(均线、成交量、涨跌比等)",
|
||||||
|
"output_form": "飞书日报",
|
||||||
|
"cadence": "每日",
|
||||||
|
"first_scope": "先保留一个分数、方向判断和一句解释",
|
||||||
|
"plan_profile": "scoring",
|
||||||
|
},
|
||||||
|
draft_payload={"title": "每日 A 股趋势打分工具"},
|
||||||
|
required_confirmations=["需求草稿需要人工确认后才允许正式创建"],
|
||||||
|
)
|
||||||
|
)
|
||||||
|
await requirement_plan_db_session.flush()
|
||||||
|
|
||||||
|
implicit_switch_intent = SimpleNamespace(
|
||||||
|
project_id=None,
|
||||||
|
metadata={
|
||||||
|
"source_chat_id": "chat-plan-overlap",
|
||||||
|
"source_sender_open_id": "ou-plan-overlap",
|
||||||
|
"semantic_bundle": {
|
||||||
|
"diagnostics": {
|
||||||
|
"message_intent": "requirement_plan",
|
||||||
|
"consultant_mode_preference": "clarify",
|
||||||
|
}
|
||||||
|
},
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
switch_result = await executor._execute_query_intent(
|
||||||
|
intent=implicit_switch_intent,
|
||||||
|
message_text="想跟你聊需求,关于批量自动化部署openclaw,先不创建,先收第一版。",
|
||||||
|
operator_user_id=999,
|
||||||
|
source_system="feishu",
|
||||||
|
source_message_id="msg-plan-overlap-new",
|
||||||
|
)
|
||||||
|
|
||||||
|
rows = (
|
||||||
|
await requirement_plan_db_session.execute(
|
||||||
|
select(MountedTopicWorkspace)
|
||||||
|
.where(MountedTopicWorkspace.tenant_id == 3)
|
||||||
|
.order_by(MountedTopicWorkspace.id.asc())
|
||||||
|
)
|
||||||
|
).scalars().all()
|
||||||
|
|
||||||
|
assert len(rows) == 2
|
||||||
|
assert "每日 A 股趋势打分工具" not in str(switch_result["answer"] or "")
|
||||||
|
assert rows[0].status == "suspended"
|
||||||
|
assert rows[0].plan_status == "suspended"
|
||||||
|
assert rows[1].status == "active"
|
||||||
|
assert rows[1].plan_status == "collecting"
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_execute_query_intent_requirement_plan_accepts_fragmented_short_answers_and_only_drafts_when_scope_arrives(
|
async def test_execute_query_intent_requirement_plan_accepts_fragmented_short_answers_and_only_drafts_when_scope_arrives(
|
||||||
monkeypatch,
|
monkeypatch,
|
||||||
|
|
|
||||||
|
|
@ -129,6 +129,18 @@ def test_workflow_semantic_planner_marks_explicit_new_requirement_topic_switch()
|
||||||
assert bundle.diagnostics["topic_switch"] == "new"
|
assert bundle.diagnostics["topic_switch"] == "new"
|
||||||
|
|
||||||
|
|
||||||
|
def test_workflow_semantic_planner_marks_chatty_new_requirement_phrase_as_topic_switch() -> None:
|
||||||
|
bundle = WorkflowSemanticPlanner.plan_message(
|
||||||
|
message_text="想跟你聊个新需求关于批量自动化部署openclaw的",
|
||||||
|
source_system="feishu",
|
||||||
|
)
|
||||||
|
|
||||||
|
assert bundle.primary_workflow == "query_flow"
|
||||||
|
assert bundle.workflows[0].metadata["message_intent"] == "requirement_plan"
|
||||||
|
assert bundle.workflows[0].metadata["topic_switch"] == "new"
|
||||||
|
assert bundle.diagnostics["topic_switch"] == "new"
|
||||||
|
|
||||||
|
|
||||||
def test_workflow_semantic_planner_routes_assignee_recommendation_back_to_query_flow() -> None:
|
def test_workflow_semantic_planner_routes_assignee_recommendation_back_to_query_flow() -> None:
|
||||||
bundle = WorkflowSemanticPlanner.plan_message(
|
bundle = WorkflowSemanticPlanner.plan_message(
|
||||||
message_text="那你觉得分配给谁做比较合适?",
|
message_text="那你觉得分配给谁做比较合适?",
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue