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
|
||||
- 飞书中的“想聊需求 / 先别创建 / 先收敛第一版”类探索问题必须先由 `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` 不允许再把“同群同人”直接等同于“同一个需求 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`,例如“先给内部 PM 和研发负责人用 / 飞书总结就行 / 每次会后半小时内整理出来”;这类碎片化回复不得因为没有新 `semantic_bundle` 就退回普通 query
|
||||
- `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
|
||||
if WorkflowSemanticPlanner._looks_like_requirement_plan_slot_answer(message_text):
|
||||
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 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(
|
||||
self,
|
||||
*,
|
||||
|
|
@ -723,9 +906,9 @@ class ExecuteQueryProjectMixin:
|
|||
conversation_key: Optional[str],
|
||||
force_new_topic: bool = False,
|
||||
) -> bool:
|
||||
if not self._supports_requirement_plan_workspace(self.db):
|
||||
if force_new_topic:
|
||||
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_message_id=source_message_id,
|
||||
source_chat_id=source_chat_id,
|
||||
|
|
@ -733,41 +916,42 @@ class ExecuteQueryProjectMixin:
|
|||
source_sender_open_id=source_sender_open_id,
|
||||
thread_context=thread_context,
|
||||
conversation_key=conversation_key,
|
||||
force_new_topic=force_new_topic,
|
||||
)
|
||||
if force_new_topic:
|
||||
return 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,
|
||||
return topic is not None and draft is not None
|
||||
|
||||
async def _infer_requirement_plan_topic_switch(
|
||||
self,
|
||||
*,
|
||||
message_text: str,
|
||||
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],
|
||||
message_intent: Optional[str],
|
||||
) -> Optional[str]:
|
||||
if str(message_intent or "").strip().lower() != "requirement_plan":
|
||||
return None
|
||||
topic, draft = await self._find_active_requirement_plan_workspace(
|
||||
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,
|
||||
)
|
||||
if topic is None:
|
||||
return False
|
||||
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 draft is not None
|
||||
return None
|
||||
if self._should_switch_requirement_topic_by_overlap(
|
||||
topic=topic,
|
||||
draft=draft,
|
||||
message_text=message_text,
|
||||
):
|
||||
return "new"
|
||||
return None
|
||||
|
||||
async def _build_requirement_planning_direct_result(
|
||||
self,
|
||||
|
|
@ -1189,6 +1373,18 @@ class ExecuteQueryProjectMixin:
|
|||
topic_switch = "new"
|
||||
message_intent = message_intent or "requirement_plan"
|
||||
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):
|
||||
has_active_plan_workspace = await self._has_active_requirement_plan_workspace(
|
||||
source_system=source_system,
|
||||
|
|
|
|||
|
|
@ -176,9 +176,22 @@ REQUIREMENT_PLAN_CHAT_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 = (
|
||||
"重新回答",
|
||||
|
|
|
|||
|
|
@ -65,6 +65,7 @@ from .constants import (
|
|||
QUESTION_HINTS,
|
||||
REQUIREMENT_PLAN_CHAT_HINTS,
|
||||
REQUIREMENT_TOPIC_SWITCH_NEW_HINTS,
|
||||
REQUIREMENT_TOPIC_SWITCH_NEW_PATTERNS,
|
||||
REPLY_MODE_CARD,
|
||||
REPLY_MODE_TEXT,
|
||||
ROUTE_FOLLOWUP_CONFIRM,
|
||||
|
|
@ -254,6 +255,8 @@ class WorkflowSemanticPlanner:
|
|||
return False
|
||||
if any(token in normalized for token in REQUIREMENT_TOPIC_SWITCH_NEW_HINTS):
|
||||
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 ("另一个需求", "新的需求", "新话题")):
|
||||
return True
|
||||
if "不对" in normalized and any(token in normalized for token in ("另一个需求", "不是这个需求", "别沿用", "不要沿用")):
|
||||
|
|
@ -272,7 +275,7 @@ class WorkflowSemanticPlanner:
|
|||
):
|
||||
return False
|
||||
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(
|
||||
|
|
@ -299,7 +302,7 @@ class WorkflowSemanticPlanner:
|
|||
slot_hits += int(any(token in normalized for token in ("第一版", "先保留", "先只做", "别做太重", "先做成", "先按")))
|
||||
has_planning_prefix = any(
|
||||
token in normalized
|
||||
for token in ("先按", "主要给", "先给", "先做成", "第一版", "先保留")
|
||||
for token in ("先按", "主要给", "先给", "先做成", "先保留")
|
||||
)
|
||||
has_generic_target_user_shape = any(
|
||||
token in normalized
|
||||
|
|
@ -310,6 +313,8 @@ class WorkflowSemanticPlanner:
|
|||
for token in ("会后", "半小时", "当天内", "整理出来", "整理出", "产出")
|
||||
)
|
||||
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 (
|
||||
slot_hits >= 2
|
||||
or (has_planning_prefix and slot_hits >= 1)
|
||||
|
|
@ -465,6 +470,8 @@ class WorkflowSemanticPlanner:
|
|||
return True
|
||||
if any(token in normalized for token in PROJECT_CREATE_DEFERRED_CONFIRM_HINTS):
|
||||
return True
|
||||
if cls._looks_like_requirement_plan_chat(text):
|
||||
return True
|
||||
if cls._looks_like_requirement_plan_slot_answer(text):
|
||||
return True
|
||||
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"
|
||||
|
||||
|
||||
@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
|
||||
async def test_execute_query_intent_requirement_plan_accepts_fragmented_short_answers_and_only_drafts_when_scope_arrives(
|
||||
monkeypatch,
|
||||
|
|
|
|||
|
|
@ -129,6 +129,18 @@ def test_workflow_semantic_planner_marks_explicit_new_requirement_topic_switch()
|
|||
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:
|
||||
bundle = WorkflowSemanticPlanner.plan_message(
|
||||
message_text="那你觉得分配给谁做比较合适?",
|
||||
|
|
|
|||
Loading…
Reference in New Issue