Handle low-overlap requirement topics as new plans

This commit is contained in:
Yuuuu 2026-04-05 20:15:38 +08:00
parent e372b88541
commit bd5a2813c3
6 changed files with 386 additions and 38 deletions

View File

@ -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` 收集完成时,必须直接整理成需求草稿,禁止继续空转追问

View File

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

View File

@ -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 = (
"重新回答", "重新回答",

View File

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

View File

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

View File

@ -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="那你觉得分配给谁做比较合适?",