Fix requirement-plan followup routing in Feishu replies

This commit is contained in:
Yuuuu 2026-04-11 20:05:42 +08:00
parent d80f155866
commit 5724cdc2dc
28 changed files with 2102 additions and 221 deletions

3
.gitignore vendored
View File

@ -68,3 +68,6 @@ Pixelle-Video/
.env
.env.*
!.env.example
# Playwright browser profiles
frontend/.playwright-profiles/

View File

@ -98,12 +98,15 @@ frontend/ # React 前端
- `llm_service` 运行时路由必须显式传入 `routing_meta`,至少统一 `tier_alias / response_contract / fallback_policy / execution_mode`;禁止再通过函数名片段、hint 常量或模板词在底层推断 tier、结构化输出或本地 fallback 策略
- AI Steward 与其它生成链的结果判定必须优先使用结构校验、字段完整性和证据一致性;禁止继续依赖“像不像模板句/指令句/stub 文案”这类启发式判断生成成功或失败
- 显式 `reply/thread` 命中的需求收敛线程,一旦上下文里已经完成 `A/B/C` 选项收敛,后续追问必须优先升格到 `requirement_plan` workspace;禁止继续落回通用 `query.answer`,也禁止把代码路径、内部函数名或工程证据直接暴露给用户
- requirement-plan 运行时路由必须显式落在 `requirement_plan.collect / requirement_plan.draft`;`query.answer` 仅保留给通用问答,只要已经进入需求收敛/草稿确认主链,ingress render、runtime 写回、审计记录与 topic metadata 都不得再把该轮记成 `query.answer`
- requirement-plan 选项协议回复必须覆盖裸 `A/B/C`、`我选C`、`选C`、`就C吧`、`先按C` 等显式选项文本,并优先按上一轮真实选项表解释;若缺少上一轮选项表,只能显式失败或重发该轮,禁止回退到旧的 IT 默认映射误译用户选择
- requirement-plan 选项协议识别属于协议层守门,必须复用单点协议解析器输出结构化 `choice + remaining_text`;禁止在运行时代码里重新散落正则或关键词规则来拍板这类协议回复
- 若要在任意模块新增正则/关键词驱动的协议或业务语义判断,必须先向 Host 明确请示并获得确认;未经确认禁止新增这类规则。协议解析优先使用单点结构化 parser / state machine / 上下文快照,若结构证据不足必须显式失败,禁止再用“最小补丁式”扩词表、补正则或追加前缀枚举搪塞
- 显式 `reply/thread` 下的低信息量协议回复(如 `A/B/C`、`我选C`、`就C吧`)禁止先走 mixed sidecar 预检查;必须优先交给 active-context / requirement-plan 主链处理
- orphan requirement-plan followup 在有显式 `reply/thread` 锚点时,必须先尝试从 reply 命中的 chat context 恢复或升格 requirement workspace;只有恢复失败时才允许回“当前没有进行中的 A/B/C 收敛轮次”
- Feishu 显式 `reply/thread` 到机器人消息时,ingress 与 mounted runtime 必须先通过 `feishu_reply_outbox.sent_message_id -> source_message_id` 归一化 `root_message_id/reply_to_message_id`,并在后续 active-context / requirement-plan / draft 主链里复用同一份 canonical thread context;禁止直接用机器人 `sent_message_id` 新开 chat topic 把 requirement-plan / draft 线程分叉出去
- requirement-plan 运行时必须以 `MountedDraftWorkspace.requirement_card` 作为唯一业务真源;`mounted_topic_workspaces` 只允许保留线程索引与最小摘要(如 `title / plan_profile`),`work_items.execution_result` 与 `mounted_conversation_runs.final_reply` 只做审计/回放,禁止再从 assistant prose、topic facts 或 run 文案反推需求字段
- `project_create.create / followup.confirm` 的任务拆解属于从属子步骤,不得再阻塞项目、主信号与宪章的确定性创建;运行时必须先走 `low-first` 的结构化任务生成,并在质量不足或 provider 失败时升级 `high`,若仍失败则显式写入 `task_generation=pending` 并保持可重试,不得冒充任务已生成,也不得把整条确认入库打成失败
- 长运行边界、已知运行假设与跨链路守门细节统一维护在 `docs/architecture/RUNTIME_GUARDRAILS.md`;`AGENTS.md` 保留必须立即遵守的摘要规则
### 8.1 系统定位

View File

@ -48,7 +48,7 @@ backend/
│ ├── config/ # 配置与 prompt
│ └── utils/ # 通用工具
├── alembic/ # 数据库迁移真源
├── scripts/ # 后端运维/修复脚本
├── scripts/ # 后端运维/修复脚本(含历史 backfill / requirement-plan 修复)
├── tests/ # 后端回归测试
├── requirements.txt
└── Dockerfile
@ -136,6 +136,7 @@ frontend/
- 正常启动器:`./start.sh`
- 公网穿透启动器:`./devops/tunnel/start.sh`
- 运行脚本、模型探针、契约校验与 E2E 工具清单已迁到 [`docs/architecture/ARCHITECTURE_INVENTORY.md`](/Users/yuuuu/Documents/AIProjectManger/docs/architecture/ARCHITECTURE_INVENTORY.md)。
- 当前新增的 requirement-plan 历史修复工具位于 `backend/scripts/repair_requirement_plan_history.py`,默认 dry-run,仅在显式 `--apply` 时写回 `mounted_draft_workspaces / mounted_topic_workspaces`。
### 前端
```bash

View File

@ -21,7 +21,9 @@ __all__ = [
"ROUTE_FAMILY_PROJECT_SIGNAL",
"ROUTE_FAMILY_SIGNAL_SOURCE",
"ROUTE_FAMILY_FOLLOWUP",
"ROUTE_FAMILY_REQUIREMENT_PLAN",
"ROUTE_ACTION_ANSWER",
"ROUTE_ACTION_COLLECT",
"ROUTE_ACTION_CREATE",
"ROUTE_ACTION_DISCOVER",
"ROUTE_ACTION_COMMIT",
@ -41,6 +43,8 @@ __all__ = [
"ROUTE_SIGNAL_SOURCE_ONBOARD",
"ROUTE_FOLLOWUP_CONFIRM",
"ROUTE_FOLLOWUP_CONTINUE",
"ROUTE_REQUIREMENT_PLAN_COLLECT",
"ROUTE_REQUIREMENT_PLAN_DRAFT",
"WORKFLOW_ROUTE_KEYS",
"ROUTE_CANONICAL_INTENT_MAP",
"LEGACY_INTENT_ROUTE_MAP",

View File

@ -57,6 +57,7 @@ ROUTE_FAMILY_NOTIFICATION = "notification"
ROUTE_FAMILY_PROJECT_SIGNAL = "project_signal"
ROUTE_FAMILY_SIGNAL_SOURCE = "signal_source"
ROUTE_FAMILY_FOLLOWUP = "followup"
ROUTE_FAMILY_REQUIREMENT_PLAN = "requirement_plan"
ROUTE_ACTION_ANSWER = "answer"
ROUTE_ACTION_CREATE = "create"
@ -83,6 +84,8 @@ ROUTE_ACTION_REFRESH = "refresh"
ROUTE_ACTION_ONBOARD = "onboard"
ROUTE_ACTION_CONTINUE = "continue"
ROUTE_ACTION_CONFIRM = "confirm"
ROUTE_ACTION_COLLECT = "collect"
ROUTE_ACTION_DRAFT = "draft"
ROUTE_QUERY_ANSWER = f"{ROUTE_FAMILY_QUERY}.{ROUTE_ACTION_ANSWER}"
ROUTE_PROJECT_CREATE = f"{ROUTE_FAMILY_PROJECT_CREATE}.{ROUTE_ACTION_CREATE}"
@ -114,6 +117,8 @@ ROUTE_PROJECT_SIGNAL_REFRESH = f"{ROUTE_FAMILY_PROJECT_SIGNAL}.{ROUTE_ACTION_REF
ROUTE_SIGNAL_SOURCE_ONBOARD = f"{ROUTE_FAMILY_SIGNAL_SOURCE}.{ROUTE_ACTION_ONBOARD}"
ROUTE_FOLLOWUP_CONFIRM = f"{ROUTE_FAMILY_FOLLOWUP}.{ROUTE_ACTION_CONFIRM}"
ROUTE_FOLLOWUP_CONTINUE = f"{ROUTE_FAMILY_FOLLOWUP}.{ROUTE_ACTION_CONTINUE}"
ROUTE_REQUIREMENT_PLAN_COLLECT = f"{ROUTE_FAMILY_REQUIREMENT_PLAN}.{ROUTE_ACTION_COLLECT}"
ROUTE_REQUIREMENT_PLAN_DRAFT = f"{ROUTE_FAMILY_REQUIREMENT_PLAN}.{ROUTE_ACTION_DRAFT}"
WORKFLOW_ROUTE_KEYS = {
ROUTE_QUERY_ANSWER,
@ -146,6 +151,8 @@ WORKFLOW_ROUTE_KEYS = {
ROUTE_SIGNAL_SOURCE_ONBOARD,
ROUTE_FOLLOWUP_CONFIRM,
ROUTE_FOLLOWUP_CONTINUE,
ROUTE_REQUIREMENT_PLAN_COLLECT,
ROUTE_REQUIREMENT_PLAN_DRAFT,
}
ROUTE_CANONICAL_INTENT_MAP: dict[str, str] = {
@ -179,6 +186,8 @@ ROUTE_CANONICAL_INTENT_MAP: dict[str, str] = {
ROUTE_SIGNAL_SOURCE_ONBOARD: INTENT_SIGNAL_SOURCE_ONBOARD,
ROUTE_FOLLOWUP_CONFIRM: INTENT_FOLLOWUP_CONFIRM,
ROUTE_FOLLOWUP_CONTINUE: INTENT_FOLLOWUP_CONTINUE,
ROUTE_REQUIREMENT_PLAN_COLLECT: INTENT_QUERY,
ROUTE_REQUIREMENT_PLAN_DRAFT: INTENT_QUERY,
}
LEGACY_INTENT_ROUTE_MAP: dict[str, str] = {
@ -217,6 +226,8 @@ LEGACY_INTENT_ROUTE_MAP: dict[str, str] = {
"project_signal.cancel": ROUTE_PROJECT_SIGNAL_CANCEL,
"project_signal.refresh": ROUTE_PROJECT_SIGNAL_REFRESH,
"signal_source.onboard": ROUTE_SIGNAL_SOURCE_ONBOARD,
"requirement_plan.collect": ROUTE_REQUIREMENT_PLAN_COLLECT,
"requirement_plan.draft": ROUTE_REQUIREMENT_PLAN_DRAFT,
}
INTENT_LLM_FUNCTION_NAME = "pmflow_semantic_context_decision"

View File

@ -93,6 +93,9 @@ from ..ai_pmo_intent import (
ROUTE_NOTIFICATION_CREATE,
ROUTE_NOTIFICATION_ACK,
ROUTE_NOTIFICATION_CLOSE,
ROUTE_QUERY_ANSWER,
ROUTE_REQUIREMENT_PLAN_COLLECT,
ROUTE_REQUIREMENT_PLAN_DRAFT,
CanonicalIntent,
IntentResolver,
RouteResolver,

View File

@ -33,9 +33,15 @@ class KernelIngressRenderMixin:
diagnostics[key] = value
if str(intent.name or "").strip():
diagnostics["intent_name"] = str(intent.name or "").strip()
if str(workflow_route or "").strip():
diagnostics["workflow_route"] = str(workflow_route or "").strip()
primary_workflow = "query_flow" if str(workflow_route or "").strip().startswith("query.") else "action_flow"
normalized_route = str(workflow_route or "").strip()
if normalized_route:
diagnostics["workflow_route"] = normalized_route
if normalized_route.startswith(f"{ROUTE_FAMILY_REQUIREMENT_PLAN}."):
primary_workflow = "plan_flow"
elif normalized_route.startswith("query."):
primary_workflow = "query_flow"
else:
primary_workflow = "action_flow"
return {
"bundle_id": None,
"semantic_version": "compat-direct-metadata-v1",
@ -59,7 +65,9 @@ class KernelIngressRenderMixin:
) -> dict[str, Any]:
linked_project_ids = [int(work_item.project_id)] if work_item.project_id is not None else []
direct_reply_text = str(render.get("text") or "").strip()
workflow_route = str(work_item.intent or execution_result.get("workflow_route") or "").strip() or None
workflow_route = str(execution_result.get("workflow_route") or work_item.intent or "").strip() or None
normalized_route = str(workflow_route or "").strip().lower()
is_requirement_plan_route = normalized_route.startswith(f"{ROUTE_FAMILY_REQUIREMENT_PLAN}.")
mixed_sidecar_reply = (
str((intent.metadata or {}).get("mixed_sidecar_reply") or "").strip()
if isinstance(intent.metadata, dict)
@ -145,9 +153,9 @@ class KernelIngressRenderMixin:
draft_expires_at = payload.get("draft_expires_at")
if isinstance(draft_expires_at, str) and draft_expires_at.strip():
result["draft_expires_at"] = draft_expires_at.strip()
if workflow_route == ROUTE_QUERY_ANSWER and isinstance(execution_result.get("rag_result"), dict):
if (normalized_route == ROUTE_QUERY_ANSWER or is_requirement_plan_route) and isinstance(execution_result.get("rag_result"), dict):
result["rag_result"] = dict(execution_result["rag_result"])
if workflow_route == ROUTE_QUERY_ANSWER and isinstance(execution_result.get("requirement_plan_context"), dict):
if (normalized_route == ROUTE_QUERY_ANSWER or is_requirement_plan_route) and isinstance(execution_result.get("requirement_plan_context"), dict):
result["requirement_plan_context"] = dict(execution_result["requirement_plan_context"])
if isinstance(execution_result.get("draft_artifact"), dict):
result["draft_artifact"] = dict(execution_result["draft_artifact"])
@ -165,6 +173,8 @@ class KernelIngressRenderMixin:
normalized_route = RouteResolver.normalize_route_key(intent_name)
if normalized_route in INTENT_CANDIDATES:
return normalized_route
if normalized_route in {ROUTE_REQUIREMENT_PLAN_COLLECT, ROUTE_REQUIREMENT_PLAN_DRAFT}:
return normalized_route
if normalized_route == ROUTE_QUERY_ANSWER:
return "query.answer"
if normalized_route == ROUTE_PROJECT_CREATE:

View File

@ -112,6 +112,7 @@ from ..ai_pmo_intent import (
ROUTE_ACTION_CLOSE,
ROUTE_ACTION_COMMIT,
ROUTE_ACTION_CONFIRM,
ROUTE_ACTION_COLLECT,
ROUTE_ACTION_CONTINUE,
ROUTE_ACTION_CREATE,
ROUTE_ACTION_DISCOVER,
@ -138,6 +139,7 @@ from ..ai_pmo_intent import (
ROUTE_FAMILY_PROJECT_CREATE,
ROUTE_FAMILY_PROJECT_SIGNAL,
ROUTE_FAMILY_QUERY,
ROUTE_FAMILY_REQUIREMENT_PLAN,
ROUTE_FAMILY_SIGNAL_SOURCE,
ROUTE_FAMILY_TASK,
ROUTE_FOLLOWUP_CONFIRM,
@ -163,6 +165,8 @@ from ..ai_pmo_intent import (
ROUTE_PROJECT_SIGNAL_DISCOVER,
ROUTE_PROJECT_SIGNAL_REFRESH,
ROUTE_QUERY_ANSWER,
ROUTE_REQUIREMENT_PLAN_COLLECT,
ROUTE_REQUIREMENT_PLAN_DRAFT,
ROUTE_TASK_ASSIGN,
ROUTE_TASK_DELETE,
ROUTE_TASK_PRIORITIZE,

View File

@ -1401,6 +1401,7 @@ class KernelIngressFlowEntryMixin:
handler = DraftConfirmHandler(self)
thread_context = self._extract_thread_context_from_ingress_options(ingress_options)
normalized_text = str(message_text or "").strip()
has_reply_anchor = bool(str(thread_context.get("reply_to_message_id") or "").strip())
if self._should_route_to_draft_confirmation(
session=session,
message_text=message_text,
@ -1409,7 +1410,7 @@ class KernelIngressFlowEntryMixin:
return True
if not normalized_text:
return False
if await self._should_skip_no_anchor_draft_routing_by_bundle(
if not has_reply_anchor and await self._should_skip_no_anchor_draft_routing_by_bundle(
message_text=normalized_text,
source_system=source_system,
):

View File

@ -740,14 +740,7 @@ class ExecuteQueryProjectMixin:
*,
topic_workspace_id: int,
) -> MountedDraftWorkspace:
draft = (
await self.db.execute(
select(MountedDraftWorkspace).where(
MountedDraftWorkspace.tenant_id == int(self.tenant_id),
MountedDraftWorkspace.topic_workspace_id == int(topic_workspace_id),
)
)
).scalar_one_or_none()
draft = await self._load_requirement_plan_draft_workspace(topic_workspace_id=int(topic_workspace_id))
if draft is None:
draft = MountedDraftWorkspace(
tenant_id=int(self.tenant_id),
@ -762,6 +755,20 @@ class ExecuteQueryProjectMixin:
await self.db.flush()
return draft
async def _load_requirement_plan_draft_workspace(
self,
*,
topic_workspace_id: int,
) -> Optional[MountedDraftWorkspace]:
return (
await self.db.execute(
select(MountedDraftWorkspace).where(
MountedDraftWorkspace.tenant_id == int(self.tenant_id),
MountedDraftWorkspace.topic_workspace_id == int(topic_workspace_id),
)
)
).scalar_one_or_none()
async def _load_contextual_reply_turns(
self,
*,
@ -878,7 +885,6 @@ class ExecuteQueryProjectMixin:
card.update(cls._extract_requirement_plan_updates(message_text=user_text, card=card))
if assistant_text:
assistant_turns.append(assistant_text)
card.update(cls._extract_requirement_plan_resolved_updates_from_assistant_reply(assistant_text))
card = cls._merge_requirement_plan_round_options_from_assistant_reply(card=card, assistant_text=assistant_text)
card = cls._merge_requirement_plan_context_snapshot(
card=card,
@ -1183,6 +1189,52 @@ class ExecuteQueryProjectMixin:
}
return snapshot
@classmethod
def _build_requirement_plan_topic_index_facts(
cls,
*,
card: dict[str, Any],
) -> dict[str, Any]:
if not isinstance(card, dict):
return {}
facts: dict[str, Any] = {}
title = str(card.get("title") or "").strip()
if title:
facts["title"] = title
plan_profile = str(card.get("plan_profile") or "").strip()
if plan_profile:
facts["plan_profile"] = plan_profile
return facts
async def _load_contextual_requirement_plan_snapshot(
self,
*,
topic: Optional[MountedTopicWorkspace],
current_message_text: Optional[str] = None,
prefer_draft_snapshot: bool = True,
) -> tuple[dict[str, Any], list[dict[str, Any]]]:
if topic is None:
return {}, []
if prefer_draft_snapshot:
contextual_draft = await self._load_requirement_plan_draft_workspace(topic_workspace_id=int(topic.id))
if contextual_draft is not None and isinstance(contextual_draft.requirement_card, dict):
sanitized_card = self._sanitize_requirement_plan_card_runtime_context(
card=dict(contextual_draft.requirement_card or {})
)
draft_snapshot = self._build_requirement_plan_context_snapshot(card=sanitized_card)
if draft_snapshot:
return draft_snapshot, []
contextual_turns = await self._load_contextual_reply_turns(topic_workspace_id=int(topic.id))
if not self._reply_context_contains_requirement_plan_contract(
turns=contextual_turns,
current_message_text=current_message_text,
):
return {}, contextual_turns
contextual_card = self._build_contextual_requirement_plan_card(topic=topic, turns=contextual_turns)
return self._build_requirement_plan_context_snapshot(card=contextual_card), contextual_turns
@classmethod
def _infer_requirement_plan_round_field_from_assistant_reply(
cls,
@ -1217,28 +1269,6 @@ class ExecuteQueryProjectMixin:
return "delivery_mode"
return None
@classmethod
def _extract_requirement_plan_resolved_updates_from_assistant_reply(cls, assistant_text: str) -> dict[str, str]:
text = str(assistant_text or "").strip()
if not text:
return {}
updates: dict[str, str] = {}
marker = "当前确认走"
marker_index = text.find(marker)
if marker_index >= 0:
suffix = text[marker_index + len(marker) :].lstrip()
if suffix.startswith("「"):
end_index = suffix.find("」")
if end_index > 1:
value = suffix[1:end_index].strip()
if value:
updates["project_nature"] = value
elif "路线" in suffix:
value = suffix.split("路线", 1)[0].strip(" ::,,。.!!??")
if value:
updates["project_nature"] = value
return updates
@classmethod
def _parse_requirement_plan_option_line(cls, line: str) -> Optional[tuple[str, str]]:
raw = str(line or "").strip()
@ -1392,15 +1422,7 @@ class ExecuteQueryProjectMixin:
)
missing_fields = self._requirement_plan_missing_fields(draft_card)
topic.facts_json = {
"plan_profile": draft_card.get("plan_profile"),
"title": draft_card.get("title"),
"source_material": draft_card.get("source_material"),
"target_user": draft_card.get("target_user"),
"scoring_basis": draft_card.get("scoring_basis"),
"output_form": draft_card.get("output_form"),
"cadence": draft_card.get("cadence"),
}
topic.facts_json = self._build_requirement_plan_topic_index_facts(card=draft_card)
topic.goals_json = [draft_card.get("goal")] if str(draft_card.get("goal") or "").strip() else []
topic.constraints_json = {"first_scope": draft_card.get("first_scope")}
topic.last_message_id = str(source_message_id or "").strip() or topic.last_message_id
@ -1461,15 +1483,11 @@ class ExecuteQueryProjectMixin:
)
return topic, draft
contextual_turns = await self._load_contextual_reply_turns(topic_workspace_id=int(context_topic.id))
if not self._reply_context_contains_requirement_plan_contract(
turns=contextual_turns,
contextual_snapshot, contextual_turns = await self._load_contextual_requirement_plan_snapshot(
topic=context_topic,
current_message_text=message_text,
):
return topic, draft
contextual_card = self._build_contextual_requirement_plan_card(topic=context_topic, turns=contextual_turns)
contextual_snapshot = self._build_requirement_plan_context_snapshot(card=contextual_card)
prefer_draft_snapshot=int(getattr(context_topic, "id", 0) or 0) != int(getattr(topic, "id", 0) or 0),
)
if not contextual_snapshot:
return topic, draft
@ -1530,8 +1548,13 @@ class ExecuteQueryProjectMixin:
"project_id": getattr(intent, "project_id", None),
}
contextual_turns = await self._load_contextual_reply_turns(topic_workspace_id=int(context_topic.id))
card = self._build_contextual_requirement_plan_card(topic=context_topic, turns=contextual_turns)
contextual_snapshot, contextual_turns = await self._load_contextual_requirement_plan_snapshot(
topic=context_topic,
current_message_text=message_text,
)
card = self._sanitize_requirement_plan_card_runtime_context(
card=dict(contextual_snapshot) if isinstance(contextual_snapshot, dict) else {}
)
if not str(card.get("title") or "").strip() or not str(card.get("source_material") or "").strip():
return {
"status": "failed_safe",
@ -1583,15 +1606,7 @@ class ExecuteQueryProjectMixin:
}
missing_fields = self._requirement_plan_missing_fields(draft_card)
topic.facts_json = {
"plan_profile": draft_card.get("plan_profile"),
"title": draft_card.get("title"),
"source_material": draft_card.get("source_material"),
"target_user": draft_card.get("target_user"),
"scoring_basis": draft_card.get("scoring_basis"),
"output_form": draft_card.get("output_form"),
"cadence": draft_card.get("cadence"),
}
topic.facts_json = self._build_requirement_plan_topic_index_facts(card=draft_card)
topic.goals_json = [draft_card.get("goal")] if str(draft_card.get("goal") or "").strip() else []
topic.constraints_json = {"first_scope": draft_card.get("first_scope")}
topic.last_message_id = str(source_message_id or "").strip() or topic.last_message_id
@ -1624,6 +1639,7 @@ class ExecuteQueryProjectMixin:
scene_label="Mounted Runtime Draft",
reason_code="contextual_requirement_draft_ready",
query_type="draft",
workflow_route=ROUTE_REQUIREMENT_PLAN_DRAFT,
)
result["reply_template_key"] = "draft_pending_confirm"
result["draft_version"] = int(draft.draft_version or 0)
@ -2613,7 +2629,7 @@ class ExecuteQueryProjectMixin:
if not message_tokens:
return 0.0
context_fragments: list[str] = []
if topic is not None:
if topic is not None and draft is None:
context_fragments.extend(cls._collect_requirement_topic_text_fragments(getattr(topic, "facts_json", None)))
context_fragments.extend(cls._collect_requirement_topic_text_fragments(getattr(topic, "goals_json", None)))
context_fragments.extend(
@ -3892,6 +3908,7 @@ class ExecuteQueryProjectMixin:
"project_id": getattr(intent, "project_id", None),
}
if isinstance(direct_result, dict):
direct_result = self._ensure_requirement_plan_workflow_route(direct_result)
direct_result["project_id"] = getattr(intent, "project_id", None)
return direct_result
@ -3947,6 +3964,7 @@ class ExecuteQueryProjectMixin:
scene_label="Mounted Runtime Plan",
reason_code="requirement_planning_direct_fallback",
query_type="plan",
workflow_route=ROUTE_REQUIREMENT_PLAN_COLLECT,
)
requirement_plan_context = self._build_requirement_plan_context_snapshot(card=card)
if requirement_plan_context:
@ -4013,6 +4031,7 @@ class ExecuteQueryProjectMixin:
scene_label="Mounted Runtime Plan",
reason_code="requirement_planning_new_topic",
query_type="plan",
workflow_route=ROUTE_REQUIREMENT_PLAN_COLLECT,
)
if self._is_low_information_choice_reply_without_option_map(message_text=message_text, card=card):
@ -4024,6 +4043,7 @@ class ExecuteQueryProjectMixin:
scene_label="Mounted Runtime Plan",
reason_code="requirement_planning_choice_context_missing",
query_type="plan",
workflow_route=ROUTE_REQUIREMENT_PLAN_COLLECT,
)
updates = self._extract_requirement_plan_updates(message_text=message_text, card=card)
@ -4037,15 +4057,7 @@ class ExecuteQueryProjectMixin:
readiness_denominator = 5
readiness = round((readiness_denominator - len(missing_fields)) / readiness_denominator, 2)
topic.facts_json = {
"plan_profile": profile,
"title": card.get("title"),
"source_material": card.get("source_material"),
"target_user": card.get("target_user"),
"scoring_basis": card.get("scoring_basis"),
"output_form": card.get("output_form"),
"cadence": card.get("cadence"),
}
topic.facts_json = self._build_requirement_plan_topic_index_facts(card=card)
topic.goals_json = [card.get("goal")] if str(card.get("goal") or "").strip() else []
topic.constraints_json = {"first_scope": card.get("first_scope")}
topic.last_message_id = str(source_message_id or "").strip() or topic.last_message_id
@ -4102,6 +4114,7 @@ class ExecuteQueryProjectMixin:
scene_label="Mounted Runtime Draft",
reason_code="requirement_planning_draft_ready",
query_type="draft",
workflow_route=ROUTE_REQUIREMENT_PLAN_DRAFT,
)
requirement_plan_context = self._build_requirement_plan_context_snapshot(card=card)
if requirement_plan_context:
@ -4155,6 +4168,7 @@ class ExecuteQueryProjectMixin:
scene_label="Mounted Runtime Plan",
reason_code="requirement_planning_direct",
query_type="plan",
workflow_route=ROUTE_REQUIREMENT_PLAN_COLLECT,
)
requirement_plan_context = self._build_requirement_plan_context_snapshot(card=card)
if requirement_plan_context:
@ -4448,11 +4462,19 @@ class ExecuteQueryProjectMixin:
}
@staticmethod
def _build_direct_query_result(*, answer: str, scene_label: str, reason_code: str, query_type: str) -> dict[str, Any]:
def _build_direct_query_result(
*,
answer: str,
scene_label: str,
reason_code: str,
query_type: str,
workflow_route: str = ROUTE_QUERY_ANSWER,
) -> dict[str, Any]:
text = str(answer or "").strip()
return {
"status": "succeeded",
"answer": text,
"workflow_route": str(workflow_route or ROUTE_QUERY_ANSWER).strip() or ROUTE_QUERY_ANSWER,
"rag_result": {
"answer": text,
"sources": [
@ -4472,6 +4494,21 @@ class ExecuteQueryProjectMixin:
},
}
@staticmethod
def _ensure_requirement_plan_workflow_route(result: Optional[dict[str, Any]]) -> Optional[dict[str, Any]]:
if not isinstance(result, dict):
return result
if str(result.get("workflow_route") or "").strip():
return result
direct_query_type = str((result.get("rag_result") or {}).get("query_type") or "").strip().lower()
reply_template_key = str(result.get("reply_template_key") or "").strip().lower()
if direct_query_type == "draft" or reply_template_key == "draft_pending_confirm":
result["workflow_route"] = ROUTE_REQUIREMENT_PLAN_DRAFT
return result
if direct_query_type == "plan" or reply_template_key == "requirement_plan_followup":
result["workflow_route"] = ROUTE_REQUIREMENT_PLAN_COLLECT
return result
async def _execute_query_intent(
self,
*,
@ -4848,6 +4885,7 @@ class ExecuteQueryProjectMixin:
conversation_key=conversation_key,
topic_switch=topic_switch,
)
direct_result = self._ensure_requirement_plan_workflow_route(direct_result)
direct_result["project_id"] = intent.project_id
return direct_result
if self._should_use_assignee_recommendation_answer(

View File

@ -633,6 +633,7 @@ class DraftConfirmHandler:
signal_id=_safe_int(locked.signal_id),
project_id=_safe_int(locked.project_id),
signal_source_count=signal_source_count,
task_generation=project_payload.get("task_generation") if isinstance(project_payload, dict) else None,
)
locked.execution_result = {
**existing_execution,
@ -643,6 +644,7 @@ class DraftConfirmHandler:
"project_id": _safe_int(locked.project_id),
"signal_source_count": signal_source_count,
"signal_sources": project_payload.get("signal_sources") if isinstance(project_payload, dict) else [],
"task_generation": project_payload.get("task_generation") if isinstance(project_payload, dict) else None,
"created_project": project_payload.get("project") if isinstance(project_payload, dict) else None,
"created_signal": project_payload.get("signal") if isinstance(project_payload, dict) else None,
"draft_version": current_version,
@ -1037,7 +1039,6 @@ class DraftConfirmHandler:
final_state=str(getattr(target[0], "state", "") or "").strip() if target is not None else "missing",
mounted=active_mounted,
)
return None
stmt = (
select(WorkItem, IngestEvent)
@ -1123,9 +1124,228 @@ class DraftConfirmHandler:
candidates.append((work_item, ingest))
if not candidates:
if active_mounted is not None:
return await self._materialize_retryable_pending_draft_from_mounted(
mounted=active_mounted,
sender=sender,
source_system=source_system,
channel_ref=channel_ref,
source_sender_open_id=source_sender_open_id,
thread_context=thread_context,
)
return None
if active_mounted is not None:
mounted_draft, _ = active_mounted
preferred_source_message_id = str(
getattr(mounted_draft, "latest_source_message_id", "") or ""
).strip()
preferred_reply_message_id = str(
getattr(mounted_draft, "latest_reply_message_id", "") or ""
).strip()
if preferred_source_message_id:
for candidate in candidates:
candidate_work_item, _ = candidate
candidate_source_message_id = str(
getattr(candidate_work_item, "source_message_id", "") or ""
).strip()
if candidate_source_message_id == preferred_source_message_id:
return candidate
elif preferred_reply_message_id:
for candidate in candidates:
candidate_work_item, _ = candidate
candidate_source_message_id = str(
getattr(candidate_work_item, "source_message_id", "") or ""
).strip()
candidate_reply_message_id = str(
sent_message_id_map.get(candidate_source_message_id) or ""
).strip()
if candidate_reply_message_id == preferred_reply_message_id:
return candidate
for candidate in candidates:
candidate_work_item, _ = candidate
if self._candidate_matches_mounted_draft(
work_item=candidate_work_item,
mounted_draft=mounted_draft,
):
return candidate
return await self._materialize_retryable_pending_draft_from_mounted(
mounted=active_mounted,
sender=sender,
source_system=source_system,
channel_ref=channel_ref,
source_sender_open_id=source_sender_open_id,
thread_context=thread_context,
)
return candidates[0]
@staticmethod
def _extract_draft_title(payload: Any) -> str:
if not isinstance(payload, dict):
return ""
return str(payload.get("title") or "").strip()
@classmethod
def _candidate_matches_mounted_draft(
cls,
*,
work_item: WorkItem,
mounted_draft: MountedDraftWorkspace,
) -> bool:
candidate_payload = work_item.draft_content if isinstance(getattr(work_item, "draft_content", None), dict) else {}
mounted_payload = (
mounted_draft.draft_payload
if isinstance(getattr(mounted_draft, "draft_payload", None), dict)
else {}
)
mounted_card = (
mounted_draft.requirement_card
if isinstance(getattr(mounted_draft, "requirement_card", None), dict)
else {}
)
candidate_title = cls._extract_draft_title(candidate_payload)
mounted_title = cls._extract_draft_title(mounted_payload) or cls._extract_draft_title(mounted_card)
if candidate_title and mounted_title and candidate_title == mounted_title:
return True
return False
async def _materialize_retryable_pending_draft_from_mounted(
self,
*,
mounted: tuple[MountedDraftWorkspace, MountedTopicWorkspace],
sender: str,
source_system: str,
channel_ref: Optional[str],
source_sender_open_id: Optional[str],
thread_context: Optional[dict[str, Any]],
) -> Optional[tuple[WorkItem, IngestEvent]]:
if not hasattr(self.db, "add") or not hasattr(self.db, "flush"):
return None
mounted_draft, mounted_topic = mounted
draft_payload = (
mounted_draft.draft_payload
if isinstance(getattr(mounted_draft, "draft_payload", None), dict)
else {}
)
if not draft_payload:
return None
normalized_source_system = str(source_system or "").strip() or "unknown"
owner_user_id = await self._resolve_retryable_draft_owner_user_id(
mounted_draft=mounted_draft,
mounted_topic=mounted_topic,
)
retry_source_message_id = (
f"draft-retry-{int(getattr(mounted_draft, 'id', 0) or 0)}-{int(datetime.now().timestamp() * 1000)}"
)[:128]
normalized_thread_context = self._normalize_thread_context(thread_context)
ingest_payload = {
"message_text": str(draft_payload.get("draft_text") or draft_payload.get("title") or "mounted draft retry"),
"sender": str(sender or "").strip() or None,
"channel_ref": str(channel_ref or getattr(mounted_topic, "channel_ref", "") or "").strip() or None,
"source_sender_open_id": str(source_sender_open_id or "").strip() or None,
"owner_user_id": owner_user_id,
"reply_to_message_id": str(normalized_thread_context.get("reply_to_message_id") or "").strip() or None,
"root_message_id": str(
normalized_thread_context.get("root_message_id")
or getattr(mounted_topic, "root_message_id", "")
or ""
).strip()
or None,
"mounted_draft_id": _safe_int(getattr(mounted_draft, "id", None)),
"mounted_topic_workspace_id": _safe_int(getattr(mounted_topic, "id", None)),
"resumed_from": "draft_pending_confirm",
}
ingest_event = IngestEvent(
tenant_id=int(self.tenant_id),
channel=f"{normalized_source_system}_message",
event_key=retry_source_message_id,
status="processing",
payload=ingest_payload,
retry_count=0,
error_message=None,
processed_at=None,
)
self.db.add(ingest_event)
await self.db.flush()
work_item = WorkItem(
tenant_id=int(self.tenant_id),
ingest_event_id=int(ingest_event.id),
source_system=normalized_source_system,
source_message_id=retry_source_message_id,
state=STATE_DRAFT_PENDING_CONFIRM,
intent=ROUTE_PROJECT_CREATE,
owner_user_id=owner_user_id,
dedupe_key=f"{normalized_source_system}:{retry_source_message_id}",
)
work_item.draft_content = dict(draft_payload)
work_item.draft_version = int(getattr(mounted_draft, "draft_version", 0) or 0)
work_item.reply_template_key = "draft_pending_confirm"
work_item.execution_result = {
"status": "draft_pending_confirm",
"workflow_route": ROUTE_PROJECT_CREATE,
"workflow_action": ROUTE_ACTION_CREATE,
"draft": dict(draft_payload),
"draft_version": int(getattr(mounted_draft, "draft_version", 0) or 0),
"resumed_from": "mounted_draft_retry",
"topic_workspace_id": _safe_int(getattr(mounted_topic, "id", None)),
"mounted_draft_id": _safe_int(getattr(mounted_draft, "id", None)),
}
self.db.add(work_item)
await self.db.flush()
mounted_draft.active_work_item_id = int(work_item.id)
mounted_draft.confirm_message_id = None
mounted_draft.state = STATE_DRAFT_PENDING_CONFIRM
mounted_topic.status = "active"
mounted_topic.plan_status = STATE_DRAFT_PENDING_CONFIRM
mounted_topic.mode = "plan"
mounted_topic.metadata_json = {
**(mounted_topic.metadata_json if isinstance(mounted_topic.metadata_json, dict) else {}),
"active_draft_work_item_id": int(work_item.id),
"draft_lifecycle_state": STATE_DRAFT_PENDING_CONFIRM,
}
await self.db.flush()
return work_item, ingest_event
async def _resolve_retryable_draft_owner_user_id(
self,
*,
mounted_draft: MountedDraftWorkspace,
mounted_topic: MountedTopicWorkspace,
) -> Optional[int]:
if not hasattr(self.db, "execute"):
return None
candidate_source_message_ids = [
str(getattr(mounted_draft, "latest_source_message_id", "") or "").strip(),
str(getattr(mounted_topic, "last_message_id", "") or "").strip(),
str(getattr(mounted_topic, "root_message_id", "") or "").strip(),
]
seen: set[str] = set()
normalized_candidates = []
for value in candidate_source_message_ids:
if not value or value in seen:
continue
seen.add(value)
normalized_candidates.append(value)
if not normalized_candidates:
return None
row = (
await self.db.execute(
select(WorkItem.owner_user_id)
.where(
and_(
WorkItem.tenant_id == int(self.tenant_id),
WorkItem.source_message_id.in_(normalized_candidates),
WorkItem.owner_user_id.is_not(None),
)
)
.order_by(WorkItem.id.desc())
.limit(1)
)
).scalar_one_or_none()
return _safe_int(row)
async def has_active_draft_session(
self,
*,
@ -1199,6 +1419,13 @@ class DraftConfirmHandler:
for draft_row, topic_row in rows:
draft_row.active_work_item_id = None
draft_row.confirm_message_id = None
retryable_failure = normalized_final_state == STATE_FAILED and bool(getattr(draft_row, "draft_payload", None))
if retryable_failure:
draft_row.state = STATE_DRAFT_PENDING_CONFIRM
topic_row.status = "active"
topic_row.plan_status = STATE_DRAFT_PENDING_CONFIRM
topic_row.pending_actions = ["confirm_requirement_draft"]
else:
draft_row.state = "ready" if getattr(draft_row, "draft_payload", None) else "collecting"
topic_row.status = "suspended"
topic_row.plan_status = "suspended"
@ -1260,8 +1487,7 @@ class DraftConfirmHandler:
if not hasattr(draft_row, "topic_workspace_id") or not hasattr(topic_row, "mode"):
continue
active_work_item_id = _safe_int(getattr(draft_row, "active_work_item_id", None))
if active_work_item_id is None:
continue
if active_work_item_id is not None:
target = await self._load_work_item_target_by_id(work_item_id=int(active_work_item_id))
if target is None:
await self._release_mounted_draft_binding(
@ -1411,6 +1637,7 @@ class DraftConfirmHandler:
signal_id: Optional[int],
project_id: Optional[int] = None,
signal_source_count: int = 0,
task_generation: Optional[dict[str, Any]] = None,
) -> str:
title = str(draft.get("title") or "需求").strip() or "需求"
assignee_name = str(draft.get("recommended_assignee_name") or "待指定").strip() or "待指定"
@ -1428,6 +1655,13 @@ class DraftConfirmHandler:
lines.append(f"信号 #{int(signal_id)} 已创建")
else:
lines.append("已完成入库并开始执行")
task_generation_status = (
str((task_generation or {}).get("status") or "").strip().lower()
if isinstance(task_generation, dict)
else ""
)
if task_generation_status == "pending":
lines.append("任务拆解:待补全(可稍后重试)")
lines.append(f"负责人:{assignee_name}")
return "\n".join(lines)
@ -1752,6 +1986,10 @@ class DraftConfirmHandler:
fallback_draft: dict[str, Any],
) -> dict[str, Any]:
if self._has_meaningful_task_drafts(fallback_draft.get("tasks")):
fallback_draft["task_generation"] = {
"status": "ready",
"error_message": None,
}
return fallback_draft
analysis = fallback_draft.get("analysis") if isinstance(fallback_draft.get("analysis"), dict) else {}
@ -1773,6 +2011,26 @@ class DraftConfirmHandler:
if not isinstance(analysis.get("success_metrics"), list) or not analysis.get("success_metrics"):
analysis["success_metrics"] = self._merge_validation_metrics_for_task_chain(fallback_draft)
generate_task_chain_bundle = getattr(steward, "_generate_task_chain_bundle", None)
if callable(generate_task_chain_bundle):
task_bundle = await generate_task_chain_bundle(
analysis=analysis,
tenant_id=int(self.tenant_id),
charter_content=(
fallback_draft.get("charter", {}).get("content")
if isinstance(fallback_draft.get("charter"), dict)
else None
),
)
fallback_draft["tasks"] = task_bundle.get("tasks") if isinstance(task_bundle.get("tasks"), list) else []
fallback_draft["task_generation"] = (
task_bundle.get("task_generation")
if isinstance(task_bundle.get("task_generation"), dict)
else steward._task_generation_ready_state()
)
return fallback_draft
try:
generated_tasks = await steward._generate_task_chain_draft(
analysis=analysis,
tenant_id=int(self.tenant_id),
@ -1782,7 +2040,23 @@ class DraftConfirmHandler:
else None
),
)
except Exception as exc:
logger.warning(
"draft_confirm_structured_task_chain_generation_deferred",
tenant_id=int(self.tenant_id),
error=str(exc),
)
fallback_draft["tasks"] = []
fallback_draft["task_generation"] = {
"status": "pending",
"error_message": str(exc)[:240],
}
return fallback_draft
fallback_draft["tasks"] = generated_tasks if isinstance(generated_tasks, list) else []
fallback_draft["task_generation"] = {
"status": "ready",
"error_message": None,
}
return fallback_draft
async def _persist_project_from_structured_draft(

View File

@ -194,11 +194,15 @@ class SemanticContextAssembler:
)
metadata = topic_row.metadata_json if isinstance(topic_row.metadata_json, dict) else {}
draft_title = None
if draft is not None and isinstance(draft.requirement_card, dict):
draft_title = str(draft.requirement_card.get("title") or "").strip() or None
thread = SemanticContextThread(
thread_key=topic_row.thread_key,
conversation_scope_key=topic_row.conversation_scope_key,
thread_summary=str(
metadata.get("last_message_preview")
or draft_title
or (topic_row.facts_json or {}).get("title")
or ""
).strip()

View File

@ -107,6 +107,7 @@ class AIServiceBusinessTasksMixin:
project_id: Union[str, int],
tenant_id: Union[str, int] = "default",
db=None,
routing_meta: Optional[dict[str, Any]] = None,
) -> dict:
"""从宪章拆解任务,自动识别所需技能标签,返回带技能要求的任务列表"""
from ...config.ai_prompts import PROMPT_DECOMPOSE_TASKS
@ -123,7 +124,11 @@ class AIServiceBusinessTasksMixin:
available_skills=available_skills,
)
text = await self._invoke_with_retry(
prompt, db=db, tenant_id=tid, function_name="decompose_tasks_from_charter",
prompt,
db=db,
tenant_id=tid,
function_name="decompose_tasks_from_charter",
routing_meta=routing_meta,
)
return self._parse_json(text)

View File

@ -124,6 +124,7 @@ class AIServiceRuntimePromptRetryMixin:
use_cache: bool = True,
cache_key: Optional[str] = None,
project_id: Optional[int] = None,
routing_meta: Optional[dict[str, Any]] = None,
) -> str:
"""带缓存、重试、预算治理的 LLM 调用(统一走 llm_service)。"""
from ..llm_service import llm_service
@ -169,6 +170,7 @@ class AIServiceRuntimePromptRetryMixin:
tenant_id=_tenant_to_int(tenant_id),
function_name=function_name,
use_prompt_profile=True,
routing_meta=routing_meta,
)
text = str(text or "")
if not text.strip():

View File

@ -265,6 +265,7 @@ class AIStewardCreationPersistMixin:
},
"tasks": normalized_task_drafts,
"task_audit": task_audit,
"task_generation": draft.get("task_generation") if isinstance(draft.get("task_generation"), dict) else {},
"risks": normalized_risks,
"creator_id": int(creator_id),
}
@ -439,6 +440,7 @@ class AIStewardCreationPersistMixin:
"content": charter.content,
},
"tasks": persisted_tasks,
"task_generation": normalized.get("task_generation") if isinstance(normalized.get("task_generation"), dict) else {},
"risks": risks_payload,
"signal_sources": signal_sources,
"analysis": normalized["analysis"],

View File

@ -75,7 +75,7 @@ class AIStewardCreationPipelineMixin:
if PROJECT_CREATION_SERIAL_DRAFT_STEPS:
signal_draft = await self._generate_competitive_signal_draft(analysis=analysis, tenant_id=tenant_id)
charter_draft = await self._generate_charter_draft(analysis=analysis, tenant_id=tenant_id)
task_drafts = await self._generate_task_chain_draft(
task_bundle = await self._generate_task_chain_bundle(
analysis=analysis,
tenant_id=tenant_id,
charter_content=charter_draft.get("content"),
@ -85,7 +85,7 @@ class AIStewardCreationPipelineMixin:
self._generate_competitive_signal_draft(analysis=analysis, tenant_id=tenant_id),
self._generate_charter_draft(analysis=analysis, tenant_id=tenant_id),
)
task_drafts = await self._generate_task_chain_draft(
task_bundle = await self._generate_task_chain_bundle(
analysis=analysis,
tenant_id=tenant_id,
charter_content=charter_draft.get("content"),
@ -101,6 +101,11 @@ class AIStewardCreationPipelineMixin:
},
"signal": signal_draft,
"charter": charter_draft,
"tasks": task_drafts,
"tasks": task_bundle.get("tasks") if isinstance(task_bundle.get("tasks"), list) else [],
"task_generation": (
task_bundle.get("task_generation")
if isinstance(task_bundle.get("task_generation"), dict)
else self._task_generation_ready_state()
),
"risks": self._build_risk_alert_drafts(analysis=analysis),
}

View File

@ -5,6 +5,31 @@ from ...config.ai_prompts import PROMPT_PROJECT_REQUIREMENT_EXTRACTION
class AIStewardDraftGenerationMixin:
@staticmethod
def _task_chain_generation_routing_meta(*, tier_alias: str = "low") -> dict[str, Any]:
tier = "high" if str(tier_alias or "").strip().lower() == "high" else "low"
return {
"tier_alias": tier,
"planned_tier": tier,
"routing_decision": "force_low_first" if tier == "low" else "explicit_high_retry",
"response_contract": "structured",
"execution_mode": "local_planner",
}
@staticmethod
def _task_generation_ready_state() -> dict[str, Any]:
return {
"status": "ready",
"error_message": None,
}
@staticmethod
def _task_generation_pending_state(error: Exception | str) -> dict[str, Any]:
return {
"status": "pending",
"error_message": str(error or "").strip()[:240] or "task_generation_pending",
}
@staticmethod
def _normalize_generation_text_list(values: Any, *, max_items: int = 8, max_len: int = 120) -> list[str]:
if not isinstance(values, list):
@ -604,6 +629,8 @@ class AIStewardDraftGenerationMixin:
analysis: Dict[str, Any],
tenant_id: int,
charter_content: Any = None,
routing_meta: dict[str, Any] | None = None,
available_skill_names: list[str] | None = None,
) -> list[dict[str, Any]]:
from ..ai_service_core.service import AIService
@ -617,16 +644,24 @@ class AIStewardDraftGenerationMixin:
project_id=0,
tenant_id=int(tenant_id),
db=self.db,
routing_meta=routing_meta or self._task_chain_generation_routing_meta(),
)
tasks = self._normalize_decomposed_task_rows(payload.get("tasks"))
if tasks:
return tasks
if not tasks:
raise RuntimeError("AI 管家任务链生成失败:charter decomposition returned empty tasks")
report = await self._inspect_task_chain_candidate(tasks=tasks, tenant_id=int(tenant_id))
requires_skill_gate = bool(available_skill_names)
if not requires_skill_gate:
return tasks
if not report["invalid_tasks"] and int(report.get("mappable_task_count") or 0) > 0:
return tasks
raise RuntimeError("AI 管家任务链生成失败:charter decomposition returned low-quality tasks")
except Exception as exc:
logger.warning(
"ai_steward_charter_task_chain_decomposition_failed",
tenant_id=int(tenant_id),
project_title=str(analysis.get("project_title") or analysis.get("project_name") or "").strip()[:120],
tier_alias=str((routing_meta or {}).get("tier_alias") or "").strip() or "default",
error=str(exc),
)
raise
@ -699,6 +734,103 @@ class AIStewardDraftGenerationMixin:
int(report.get("total_task_count") or 0),
)
@staticmethod
def _task_chain_candidate_is_acceptable(
report: dict[str, Any],
*,
available_skill_names: list[str],
) -> bool:
if not available_skill_names:
return True
return not report.get("invalid_tasks") and int(report.get("mappable_task_count") or 0) > 0
async def _generate_task_chain_from_primary_prompt(
self,
*,
analysis: Dict[str, Any],
tenant_id: int,
available_skill_names: list[str],
routing_meta: dict[str, Any],
) -> list[dict[str, Any]]:
prompt = self._build_task_chain_prompt(
analysis=analysis,
available_skill_names=available_skill_names,
)
tasks_data = await self._generate_project_creation_object(
prompt=prompt,
tenant_id=tenant_id,
function_name="project_from_text_task_chain",
stage_label="AI 管家任务链生成失败",
timeout_seconds=PROJECT_CREATION_TASK_CHAIN_TIMEOUT_SECONDS,
routing_meta=routing_meta,
)
tasks_list = tasks_data.get("tasks", [])
if not isinstance(tasks_list, list) or not tasks_list:
raise RuntimeError("AI 管家任务链生成失败:返回 tasks 为空")
report = await self._inspect_task_chain_candidate(tasks=tasks_list, tenant_id=int(tenant_id))
best_tasks = tasks_list
best_report = report
if self._task_chain_candidate_is_acceptable(report, available_skill_names=available_skill_names):
return best_tasks
retry_feedback = self._build_task_chain_retry_feedback(
invalid_tasks=report["invalid_tasks"],
available_skill_names=available_skill_names,
)
retry_prompt = self._build_task_chain_prompt(
analysis=analysis,
available_skill_names=available_skill_names,
retry_feedback=retry_feedback,
)
retry_data = await self._generate_project_creation_object(
prompt=retry_prompt,
tenant_id=tenant_id,
function_name="project_from_text_task_chain",
stage_label="AI 管家任务链生成失败",
timeout_seconds=PROJECT_CREATION_TASK_CHAIN_TIMEOUT_SECONDS,
routing_meta=routing_meta,
)
retry_tasks = retry_data.get("tasks", [])
if isinstance(retry_tasks, list) and retry_tasks:
retry_report = await self._inspect_task_chain_candidate(tasks=retry_tasks, tenant_id=int(tenant_id))
if self._task_chain_candidate_score(retry_report) >= self._task_chain_candidate_score(best_report):
best_tasks = retry_tasks
best_report = retry_report
if self._task_chain_candidate_is_acceptable(retry_report, available_skill_names=available_skill_names):
return retry_tasks
raise RuntimeError("AI 管家任务链生成失败:prompt route returned low-quality tasks")
async def _generate_task_chain_bundle(
self,
*,
analysis: Dict[str, Any],
tenant_id: int,
charter_content: Any = None,
) -> dict[str, Any]:
try:
tasks = await self._generate_task_chain_draft(
analysis=analysis,
tenant_id=int(tenant_id),
charter_content=charter_content,
)
except Exception as exc:
logger.warning(
"ai_steward_task_chain_generation_deferred",
tenant_id=int(tenant_id),
project_title=str(analysis.get("project_title") or analysis.get("project_name") or "").strip()[:120],
error=str(exc),
)
return {
"tasks": [],
"task_generation": self._task_generation_pending_state(exc),
}
return {
"tasks": tasks if isinstance(tasks, list) else [],
"task_generation": self._task_generation_ready_state(),
}
async def _analyze_project_input(self, input_text: str, tenant_id: int) -> Dict[str, Any]:
"""使用 AI 分析项目输入,提取关键信息。"""
prompt = PROMPT_PROJECT_REQUIREMENT_EXTRACTION.format(
@ -828,67 +960,72 @@ class AIStewardDraftGenerationMixin:
tenant_id: int,
charter_content: Any = None,
) -> List[Dict[str, Any]]:
available_skill_names = await self._load_available_skill_names(tenant_id=int(tenant_id))
if self._has_six_module_charter_content(charter_content):
last_error: Exception | None = None
for tier_alias in ("low", "high"):
try:
return await self._generate_task_chain_via_charter_decomposition(
analysis=analysis,
tenant_id=int(tenant_id),
charter_content=charter_content,
)
available_skill_names = await self._load_available_skill_names(tenant_id=int(tenant_id))
prompt = self._build_task_chain_prompt(
analysis=analysis,
routing_meta=self._task_chain_generation_routing_meta(tier_alias=tier_alias),
available_skill_names=available_skill_names,
)
try:
tasks_data = await self._generate_project_creation_object(
prompt=prompt,
tenant_id=tenant_id,
function_name="project_from_text_task_chain",
stage_label="AI 管家任务链生成失败",
timeout_seconds=PROJECT_CREATION_TASK_CHAIN_TIMEOUT_SECONDS,
)
tasks_list = tasks_data.get("tasks", [])
if isinstance(tasks_list, list) and tasks_list:
report = await self._inspect_task_chain_candidate(tasks=tasks_list, tenant_id=int(tenant_id))
if not report["invalid_tasks"] or not available_skill_names:
return tasks_list
retry_feedback = self._build_task_chain_retry_feedback(
invalid_tasks=report["invalid_tasks"],
available_skill_names=available_skill_names,
)
retry_prompt = self._build_task_chain_prompt(
analysis=analysis,
available_skill_names=available_skill_names,
retry_feedback=retry_feedback,
)
retry_data = await self._generate_project_creation_object(
prompt=retry_prompt,
tenant_id=tenant_id,
function_name="project_from_text_task_chain",
stage_label="AI 管家任务链生成失败",
timeout_seconds=PROJECT_CREATION_TASK_CHAIN_TIMEOUT_SECONDS,
)
retry_tasks = retry_data.get("tasks", [])
if isinstance(retry_tasks, list) and retry_tasks:
retry_report = await self._inspect_task_chain_candidate(tasks=retry_tasks, tenant_id=int(tenant_id))
if self._task_chain_candidate_score(retry_report) >= self._task_chain_candidate_score(report):
return retry_tasks
return tasks_list
raise RuntimeError("AI 管家任务链生成失败:返回 tasks 为空")
except Exception as exc:
last_error = exc
logger.warning(
"ai_steward_task_chain_primary_failed_fallback_to_charter_decomposition",
"ai_steward_task_chain_charter_tier_attempt_failed",
tenant_id=int(tenant_id),
project_title=str(analysis.get("project_title") or analysis.get("project_name") or "").strip()[:120],
tier_alias=tier_alias,
error=str(exc),
)
if last_error is not None:
raise last_error
raise RuntimeError("AI 管家任务链生成失败:charter decomposition unavailable")
last_error: Exception | None = None
for tier_alias in ("low", "high"):
try:
return await self._generate_task_chain_from_primary_prompt(
analysis=analysis,
tenant_id=int(tenant_id),
available_skill_names=available_skill_names,
routing_meta=self._task_chain_generation_routing_meta(tier_alias=tier_alias),
)
except Exception as exc:
last_error = exc
logger.warning(
"ai_steward_task_chain_prompt_tier_attempt_failed",
tenant_id=int(tenant_id),
project_title=str(analysis.get("project_title") or analysis.get("project_name") or "").strip()[:120],
tier_alias=tier_alias,
error=str(exc),
)
for tier_alias in ("low", "high"):
try:
return await self._generate_task_chain_via_charter_decomposition(
analysis=analysis,
tenant_id=int(tenant_id),
charter_content=charter_content,
routing_meta=self._task_chain_generation_routing_meta(tier_alias=tier_alias),
available_skill_names=available_skill_names,
)
except Exception as exc:
last_error = exc
logger.warning(
"ai_steward_task_chain_charter_fallback_attempt_failed",
tenant_id=int(tenant_id),
project_title=str(analysis.get("project_title") or analysis.get("project_name") or "").strip()[:120],
tier_alias=tier_alias,
error=str(exc),
)
if last_error is not None:
raise last_error
raise RuntimeError("AI 管家任务链生成失败:no task generation route available")
@staticmethod
def _build_risk_alert_drafts(*, analysis: Dict[str, Any]) -> List[Dict[str, Any]]:

View File

@ -141,6 +141,7 @@ class AIStewardJsonParsingMixin:
function_name: str,
stage_label: str,
timeout_seconds: float | None = None,
routing_meta: dict[str, Any] | None = None,
) -> Dict[str, Any]:
max_rounds = PROJECT_CREATION_JSON_PARSE_RETRY_COUNT + 1
last_error: Exception | None = None
@ -153,6 +154,7 @@ class AIStewardJsonParsingMixin:
function_name=function_name,
timeout_seconds=float(timeout_seconds) if timeout_seconds is not None else PROJECT_CREATION_DRAFT_TIMEOUT_SECONDS,
max_tokens=max_tokens,
routing_meta=routing_meta,
)
except Exception as exc:
last_error = exc if isinstance(exc, Exception) else RuntimeError(str(exc))
@ -193,6 +195,7 @@ class AIStewardJsonParsingMixin:
function_name: str,
timeout_seconds: float = PROJECT_CREATION_DRAFT_TIMEOUT_SECONDS,
max_tokens: int = PROJECT_CREATION_STEP_INITIAL_MAX_TOKENS,
routing_meta: dict[str, Any] | None = None,
) -> str:
attempts_per_function = PROJECT_CREATION_STEP_RETRY_COUNT + 1
last_error: Exception | None = None
@ -208,6 +211,7 @@ class AIStewardJsonParsingMixin:
use_prompt_profile=False,
temperature=0.0,
max_tokens=max_tokens,
routing_meta=routing_meta,
),
timeout=timeout_seconds,
)

View File

@ -7,6 +7,7 @@ from typing import Any, Optional
from sqlalchemy import or_, select
from sqlalchemy.ext.asyncio import AsyncSession
from .ai_pmo.ai_pmo_intent import ROUTE_QUERY_ANSWER, ROUTE_REQUIREMENT_PLAN_COLLECT, ROUTE_REQUIREMENT_PLAN_DRAFT
from ..models import FeishuReplyOutbox, MountedAgentTrace, MountedConversationRun, MountedSourceActivation, MountedTopicWorkspace
from ..utils.thread_context import build_feishu_conversation_scope_key
from ..utils.timezone import now_cn_naive
@ -158,6 +159,43 @@ class MountedAIRuntimeWriteService:
return "plan"
return "plan"
@classmethod
def _normalize_runtime_workflow_route(
cls,
*,
workflow_route: Any,
topic_row: Optional[MountedTopicWorkspace] = None,
route_metadata: Optional[dict[str, Any]] = None,
result_payload: Optional[dict[str, Any]] = None,
) -> Optional[str]:
normalized_route = str(workflow_route or "").strip().lower()
if normalized_route in {ROUTE_REQUIREMENT_PLAN_COLLECT, ROUTE_REQUIREMENT_PLAN_DRAFT}:
return normalized_route
message_intent = (
str((route_metadata or {}).get("message_intent") or "").strip().lower()
if isinstance(route_metadata, dict)
else ""
)
has_requirement_context = bool(
topic_row is not None and cls._topic_has_requirement_plan_context(topic_row)
)
has_requirement_snapshot = bool(
isinstance((result_payload or {}).get("requirement_plan_context"), dict)
)
has_requirement_draft = bool(
isinstance((result_payload or {}).get("draft_artifact"), dict)
and str(((result_payload or {}).get("draft_artifact") or {}).get("type") or "").strip() == "requirement_plan_draft"
)
if has_requirement_draft:
return ROUTE_REQUIREMENT_PLAN_DRAFT
if normalized_route == ROUTE_QUERY_ANSWER and (message_intent == "requirement_plan" or has_requirement_context or has_requirement_snapshot):
return ROUTE_REQUIREMENT_PLAN_COLLECT
if not normalized_route and (message_intent == "requirement_plan" or has_requirement_context or has_requirement_snapshot):
return ROUTE_REQUIREMENT_PLAN_COLLECT
return normalized_route or None
@classmethod
def _topic_has_requirement_plan_context(cls, topic_row: MountedTopicWorkspace) -> bool:
metadata = topic_row.metadata_json if isinstance(getattr(topic_row, "metadata_json", None), dict) else {}
@ -578,8 +616,13 @@ class MountedAIRuntimeWriteService:
route_metadata: Optional[dict[str, Any]],
mode_override: Optional[str] = None,
) -> str:
mode_after = self._mode_for_route(
normalized_workflow_route = self._normalize_runtime_workflow_route(
workflow_route=workflow_route,
topic_row=tracking.topic_workspace,
route_metadata=route_metadata,
) or str(workflow_route or "").strip()
mode_after = self._mode_for_route(
workflow_route=normalized_workflow_route,
auto_execute=auto_execute,
mode_override=mode_override,
)
@ -590,7 +633,7 @@ class MountedAIRuntimeWriteService:
topic_row.mode = mode_after
topic_row.metadata_json = {
**(topic_row.metadata_json if isinstance(topic_row.metadata_json, dict) else {}),
"last_workflow_route": str(workflow_route or "").strip() or None,
"last_workflow_route": normalized_workflow_route or None,
"last_intent_name": str(intent_name or "").strip() or None,
"last_intent_confidence": float(intent_confidence or 0.0),
"last_work_item_id": int(work_item_id) if work_item_id is not None else None,
@ -615,7 +658,7 @@ class MountedAIRuntimeWriteService:
},
output_summary={
"mode_after": mode_after,
"workflow_route": str(workflow_route or "").strip() or None,
"workflow_route": normalized_workflow_route or None,
"intent_name": str(intent_name or "").strip() or None,
"auto_execute": bool(auto_execute),
"route_metadata": dict(route_metadata or {}),
@ -754,6 +797,13 @@ class MountedAIRuntimeWriteService:
if isinstance(value, str) and not value.strip():
continue
persisted_result_fields[field] = value
normalized_workflow_route = self._normalize_runtime_workflow_route(
workflow_route=persisted_result_fields.get("workflow_route"),
topic_row=topic_row,
result_payload=result,
)
if normalized_workflow_route:
persisted_result_fields["workflow_route"] = normalized_workflow_route
run_row.status = "completed"
run_row.active_agent = "ReplyComposer"
run_row.final_reply = final_reply or None
@ -779,6 +829,22 @@ class MountedAIRuntimeWriteService:
topic_row = canonical_topic
tracking.topic_workspace = canonical_topic
run_row.topic_workspace_id = int(canonical_topic.id)
if normalized_workflow_route:
topic_row.metadata_json = {
**(topic_row.metadata_json if isinstance(topic_row.metadata_json, dict) else {}),
"last_workflow_route": normalized_workflow_route,
"plan_mode": (
True
if normalized_workflow_route.startswith("requirement_plan.")
else (
topic_row.metadata_json.get("plan_mode")
if isinstance(topic_row.metadata_json, dict)
else None
)
),
}
if normalized_workflow_route.startswith("requirement_plan.") and str(topic_row.mode or "").strip().lower() == "chat":
topic_row.mode = "plan"
run_row.completed_at = now_at
self.db.add(
MountedAgentTrace(

View File

@ -0,0 +1,180 @@
#!/usr/bin/env python3
"""预览或修复历史 requirement-plan draft/topic 污染。"""
from __future__ import annotations
import argparse
import asyncio
from dataclasses import dataclass
from typing import Any
from sqlalchemy import select
from app.core.database import AsyncSessionLocal
from app.models import MountedDraftWorkspace, MountedTopicWorkspace
from app.services.ai_pmo.ai_pmo_typed_executor_execute.mixin_execute_query_project import ExecuteQueryProjectMixin
@dataclass
class RepairCandidate:
draft_id: int
topic_id: int
reasons: list[str]
before_card: dict[str, Any]
after_card: dict[str, Any]
before_facts: dict[str, Any]
after_facts: dict[str, Any]
before_goals: Any
after_goals: list[str]
before_constraints: Any
after_constraints: dict[str, Any]
def _repair_candidate(
*,
draft: MountedDraftWorkspace,
topic: MountedTopicWorkspace,
) -> RepairCandidate | None:
before_card = dict(draft.requirement_card or {}) if isinstance(draft.requirement_card, dict) else {}
if not before_card:
return None
after_card = ExecuteQueryProjectMixin._sanitize_requirement_plan_card_runtime_context(card=before_card)
before_facts = dict(topic.facts_json or {}) if isinstance(topic.facts_json, dict) else {}
after_facts = ExecuteQueryProjectMixin._build_requirement_plan_topic_index_facts(card=after_card)
before_goals = topic.goals_json
after_goals = [after_card.get("goal")] if str(after_card.get("goal") or "").strip() else []
before_constraints = topic.constraints_json
after_constraints = {"first_scope": after_card.get("first_scope")}
reasons: list[str] = []
if after_card != before_card:
reasons.append("requirement_card")
if before_facts != after_facts:
reasons.append("facts_json")
if before_goals != after_goals:
reasons.append("goals_json")
if before_constraints != after_constraints:
reasons.append("constraints_json")
if not reasons:
return None
return RepairCandidate(
draft_id=int(draft.id),
topic_id=int(topic.id),
reasons=reasons,
before_card=before_card,
after_card=after_card,
before_facts=before_facts,
after_facts=after_facts,
before_goals=before_goals,
after_goals=after_goals,
before_constraints=before_constraints,
after_constraints=after_constraints,
)
async def _load_candidates(
*,
tenant_id: int,
topic_id: int | None,
draft_id: int | None,
) -> list[RepairCandidate]:
async with AsyncSessionLocal() as db:
stmt = (
select(MountedDraftWorkspace, MountedTopicWorkspace)
.join(
MountedTopicWorkspace,
MountedTopicWorkspace.id == MountedDraftWorkspace.topic_workspace_id,
)
.where(MountedDraftWorkspace.tenant_id == int(tenant_id))
.order_by(MountedDraftWorkspace.id.asc())
)
if topic_id is not None:
stmt = stmt.where(MountedDraftWorkspace.topic_workspace_id == int(topic_id))
if draft_id is not None:
stmt = stmt.where(MountedDraftWorkspace.id == int(draft_id))
rows = (await db.execute(stmt)).all()
candidates: list[RepairCandidate] = []
for draft, topic in rows:
candidate = _repair_candidate(draft=draft, topic=topic)
if candidate is not None:
candidates.append(candidate)
return candidates
async def _apply_candidates(
*,
tenant_id: int,
topic_id: int | None,
draft_id: int | None,
) -> int:
async with AsyncSessionLocal() as db:
stmt = (
select(MountedDraftWorkspace, MountedTopicWorkspace)
.join(
MountedTopicWorkspace,
MountedTopicWorkspace.id == MountedDraftWorkspace.topic_workspace_id,
)
.where(MountedDraftWorkspace.tenant_id == int(tenant_id))
.order_by(MountedDraftWorkspace.id.asc())
)
if topic_id is not None:
stmt = stmt.where(MountedDraftWorkspace.topic_workspace_id == int(topic_id))
if draft_id is not None:
stmt = stmt.where(MountedDraftWorkspace.id == int(draft_id))
rows = (await db.execute(stmt)).all()
updated = 0
for draft, topic in rows:
candidate = _repair_candidate(draft=draft, topic=topic)
if candidate is None:
continue
draft.requirement_card = candidate.after_card
topic.facts_json = candidate.after_facts
topic.goals_json = candidate.after_goals
topic.constraints_json = candidate.after_constraints
updated += 1
if updated:
await db.commit()
return updated
async def _main() -> None:
parser = argparse.ArgumentParser(description="Preview/apply requirement-plan history cleanup")
parser.add_argument("--tenant-id", type=int, default=1, help="目标租户 ID,默认 1")
parser.add_argument("--topic-id", type=int, default=None, help="仅检查指定 topic_workspace_id")
parser.add_argument("--draft-id", type=int, default=None, help="仅检查指定 draft_workspace_id")
parser.add_argument("--apply", action="store_true", help="执行修复写入;默认仅预览")
args = parser.parse_args()
candidates = await _load_candidates(
tenant_id=args.tenant_id,
topic_id=args.topic_id,
draft_id=args.draft_id,
)
print(f"[preview] tenant_id={args.tenant_id} candidate_count={len(candidates)}")
for item in candidates[:20]:
title = str(item.after_card.get("title") or item.before_card.get("title") or "")[:80]
print(
f"- draft_id={item.draft_id} topic_id={item.topic_id} reasons={','.join(item.reasons)} title={title}"
)
if len(candidates) > 20:
print(f"... ({len(candidates) - 20} more)")
if not args.apply:
print("[dry-run] 未执行写入。加 --apply 以修复历史 requirement-plan 污染。")
return
updated = await _apply_candidates(
tenant_id=args.tenant_id,
topic_id=args.topic_id,
draft_id=args.draft_id,
)
remaining = await _load_candidates(
tenant_id=args.tenant_id,
topic_id=args.topic_id,
draft_id=args.draft_id,
)
print(f"[apply] updated={updated} remaining_candidates={len(remaining)}")
if __name__ == "__main__":
asyncio.run(_main())

View File

@ -563,6 +563,27 @@ def test_build_contextual_requirement_plan_card_keeps_source_material_user_only(
assert "当前需求草稿已完成第一轮关键判断" not in str(card.get("source_material") or "")
def test_build_contextual_requirement_plan_card_does_not_infer_business_fields_from_assistant_prose() -> None:
turns = [
{
"user": "做一个门店库存异常晨报助手,先不要创建项目,只收第一版需求。",
"assistant": (
"已确认选项 A(运营团队),继续收第二轮关键信息。\n\n"
"A. 单一区域经理\nB. 区域经理+店长\nC. 总部+区域+门店"
),
}
]
card = mixin_execute_query_project.ExecuteQueryProjectMixin._build_contextual_requirement_plan_card(
topic=None,
turns=turns,
)
assert "target_user" not in card
assert "project_nature" not in card
assert str(card.get("source_material") or "").startswith("用户:做一个门店库存异常晨报助手")
def test_build_requirement_plan_draft_packet_uses_contextual_goal_instead_of_generic_it_project() -> None:
card = {
"title": "每晚自动汇总门禁异常开门记录并发给行政安保的轻量助手",
@ -5882,6 +5903,7 @@ async def test_execute_query_intent_promotes_explicit_choice_followup_into_requi
assert result["status"] == "succeeded"
assert str(result["rag_result"]["query_type"]) == "plan"
assert str(result["workflow_route"]) == "requirement_plan.collect"
direct_kwargs = captured.get("direct_kwargs")
assert isinstance(direct_kwargs, dict)
assert str(direct_kwargs["message_text"]) == "三个问题都有"
@ -6087,6 +6109,7 @@ async def test_execute_query_intent_converts_query_answer_requirement_contract_i
assert result["status"] == "succeeded"
assert str(result["rag_result"]["query_type"]) == "plan"
assert str(result["workflow_route"]) == "requirement_plan.collect"
assert isinstance(captured.get("rag_kwargs"), dict)
direct_kwargs = captured.get("direct_kwargs")
assert isinstance(direct_kwargs, dict)
@ -7151,7 +7174,7 @@ async def test_build_requirement_planning_direct_result_emits_structured_require
@pytest.mark.asyncio
async def test_repair_requirement_plan_workspace_from_reply_context_prefers_semantic_reply_snapshot(
async def test_repair_requirement_plan_workspace_from_reply_context_prefers_structured_reply_snapshot(
requirement_plan_db_session,
) -> None:
class FakeExecutor(mixin_execute_query_project.ExecuteQueryProjectMixin):
@ -7258,6 +7281,30 @@ async def test_repair_requirement_plan_workspace_from_reply_context_prefers_sema
}
],
},
"requirement_plan_context": {
"plan_profile": "it_project",
"title": "门店库存异常日报助手",
"goal": "先把门店库存异常日报助手的第一版目标、技术路线和推进方式收清楚,再决定是否正式创建。",
"project_nature": "轻量定时简报",
"target_user": "单一区域经理",
"question_round": 2,
"round_option_maps": {
"target_user": {
"A": "单一区域经理",
"B": "区域经理 + 店长",
"C": "总部 + 区域 + 门店多层级",
}
},
"last_round_options": {
"field": "target_user",
"options": [
{"key": "A", "title": "单一区域经理"},
{"key": "B", "title": "区域经理 + 店长"},
{"key": "C", "title": "总部 + 区域 + 门店多层级"},
],
"recommendation": {"key": "A"},
},
},
"semantic_decision": {
"assistant_reply": (
"已确认选项 A(单一层级区域经理),继续收第二轮关键信息 👇\n\n"
@ -7432,6 +7479,101 @@ async def test_build_requirement_planning_direct_result_forces_new_topic_when_sa
assert int(current_draft.topic_workspace_id) == int(current_topic.id)
@pytest.mark.asyncio
async def test_build_requirement_planning_direct_result_only_persists_topic_index_facts(
monkeypatch,
requirement_plan_db_session,
) -> None:
class FakeExecutor(mixin_execute_query_project.ExecuteQueryProjectMixin):
def __init__(self) -> None:
self.db = requirement_plan_db_session
self.tenant_id = 3
async def _fake_build_requirement_plan_next_question(
self,
*,
missing_field: str,
profile: str,
message_text: str = "",
card: dict[str, object] | None = None,
latest_updates: dict[str, object] | None = None,
) -> str:
_ = (self, profile, message_text, latest_updates)
assert isinstance(card, dict)
card["round_option_maps"] = {
**(dict(card.get("round_option_maps") or {}) if isinstance(card.get("round_option_maps"), dict) else {}),
str(missing_field): {
"A": "轻量晨报工具",
"B": "业务平台入口",
"C": "能力底座组件",
},
}
card["last_round_options"] = {
"field": str(missing_field),
"options": [
{"key": "A", "title": "轻量晨报工具"},
{"key": "B", "title": "业务平台入口"},
{"key": "C", "title": "能力底座组件"},
],
"recommendation": {"key": "A"},
}
return "第一轮:项目性质\n\nA. 轻量晨报工具\nB. 业务平台入口\nC. 能力底座组件"
monkeypatch.setattr(
mixin_execute_query_project.ExecuteQueryProjectMixin,
"_build_requirement_plan_next_question",
_fake_build_requirement_plan_next_question,
raising=False,
)
conversation_scope_key = build_feishu_conversation_scope_key(
source_chat_id="chat-plan-topic-index-facts",
source_sender_open_id="ou-plan-topic-index-facts",
sender=None,
)
topic = MountedTopicWorkspace(
tenant_id=3,
topic_key="feishu:chat-plan-topic-index-facts:message:msg-plan-topic-index-facts-root",
source_system="feishu",
channel_ref="chat-plan-topic-index-facts",
conversation_scope_key=conversation_scope_key,
root_message_id=None,
last_message_id="msg-plan-topic-index-facts-root",
mode="chat",
status="active",
plan_status="idle",
metadata_json={},
)
requirement_plan_db_session.add(topic)
await requirement_plan_db_session.flush()
executor = FakeExecutor()
result = await executor._build_requirement_planning_direct_result(
message_text="做一个给采购经理用的跨仓调拨异常晚报助手,只收第一版需求,不要直接创建项目。",
source_system="feishu",
source_message_id="msg-plan-topic-index-facts-root",
source_chat_id="chat-plan-topic-index-facts",
sender=None,
source_sender_open_id="ou-plan-topic-index-facts",
thread_context=None,
conversation_key=conversation_scope_key,
topic_switch=None,
)
await requirement_plan_db_session.flush()
saved_topic = (
await requirement_plan_db_session.execute(
select(MountedTopicWorkspace).where(MountedTopicWorkspace.id == int(topic.id))
)
).scalar_one()
assert result["status"] == "succeeded"
assert saved_topic.facts_json == {
"title": "给采购经理用的跨仓调拨异常晚报助手",
"plan_profile": "it_project",
}
@pytest.mark.asyncio
async def test_build_requirement_planning_direct_result_prefers_reply_anchor_topic_over_same_scope_sibling(
monkeypatch,
@ -7760,6 +7902,134 @@ async def test_repair_requirement_plan_workspace_from_reply_context_blocks_sibli
assert "tech_route" not in target_draft.requirement_card
@pytest.mark.asyncio
async def test_repair_requirement_plan_workspace_from_reply_context_prefers_contextual_draft_snapshot_over_turn_history(
monkeypatch,
requirement_plan_db_session,
) -> None:
class FakeExecutor(mixin_execute_query_project.ExecuteQueryProjectMixin):
def __init__(self) -> None:
self.db = requirement_plan_db_session
self.tenant_id = 3
conversation_scope_key = build_feishu_conversation_scope_key(
source_chat_id="chat-plan-draft-snapshot-repair",
source_sender_open_id="ou-plan-draft-snapshot-repair",
sender=None,
)
current_topic = MountedTopicWorkspace(
tenant_id=3,
topic_key="feishu:chat-plan-draft-snapshot-repair:message:om-plan-current-root",
source_system="feishu",
channel_ref="chat-plan-draft-snapshot-repair",
conversation_scope_key=conversation_scope_key,
root_message_id=None,
last_message_id="om-plan-current-root",
mode="plan",
status="active",
plan_status="collecting",
metadata_json={"plan_mode": True},
)
requirement_plan_db_session.add(current_topic)
await requirement_plan_db_session.flush()
current_draft = MountedDraftWorkspace(
tenant_id=3,
topic_workspace_id=int(current_topic.id),
state="collecting",
draft_version=0,
requirement_card={
"plan_profile": "it_project",
"title": "门店库存异常晨报助手",
"goal": "先把门店库存异常晨报助手的第一版需求收敛清楚。",
"question_round": 1,
},
draft_payload=None,
required_confirmations=[],
)
requirement_plan_db_session.add(current_draft)
contextual_topic = MountedTopicWorkspace(
tenant_id=3,
topic_key="feishu:chat-plan-draft-snapshot-repair:message:om-plan-context-root",
source_system="feishu",
channel_ref="chat-plan-draft-snapshot-repair",
conversation_scope_key=conversation_scope_key,
root_message_id=None,
last_message_id="om-plan-context-root",
mode="plan",
status="active",
plan_status="options_ready",
metadata_json={"plan_mode": True},
)
requirement_plan_db_session.add(contextual_topic)
await requirement_plan_db_session.flush()
requirement_plan_db_session.add(
MountedDraftWorkspace(
tenant_id=3,
topic_workspace_id=int(contextual_topic.id),
state="collecting",
draft_version=0,
requirement_card={
"plan_profile": "it_project",
"title": "门店库存异常晨报助手",
"goal": "先把门店库存异常晨报助手的第一版需求收敛清楚。",
"project_nature": "轻量晨报工具",
"target_user": "区域经理",
"question_round": 2,
"round_option_maps": {
"project_nature": {
"A": "轻量晨报工具",
"B": "业务平台入口",
"C": "能力底座组件",
}
},
"last_round_options": {
"field": "project_nature",
"options": [
{"key": "A", "title": "轻量晨报工具"},
{"key": "B", "title": "业务平台入口"},
{"key": "C", "title": "能力底座组件"},
],
"recommendation": {"key": "A"},
},
},
draft_payload=None,
required_confirmations=[],
)
)
await requirement_plan_db_session.flush()
async def _fake_find_contextual_reply_topic(self, **kwargs): # noqa: ANN001
_ = (self, kwargs)
return contextual_topic
monkeypatch.setattr(
mixin_execute_query_project.ExecuteQueryProjectMixin,
"_find_contextual_reply_topic",
_fake_find_contextual_reply_topic,
raising=False,
)
executor = FakeExecutor()
repaired_topic, repaired_draft = await executor._repair_requirement_plan_workspace_from_reply_context(
topic=current_topic,
draft=current_draft,
source_system="feishu",
source_message_id="msg-plan-draft-snapshot-repair-followup",
source_chat_id="chat-plan-draft-snapshot-repair",
source_sender_open_id="ou-plan-draft-snapshot-repair",
thread_context=None,
conversation_key=conversation_scope_key,
message_text="A,继续往下收。",
)
assert repaired_topic is current_topic
assert repaired_draft is current_draft
assert current_draft.requirement_card["project_nature"] == "轻量晨报工具"
assert current_draft.requirement_card["target_user"] == "区域经理"
assert current_draft.requirement_card["last_round_options"]["field"] == "project_nature"
@pytest.mark.asyncio
async def test_execute_query_intent_contextual_draft_generation_bootstraps_requirement_plan_from_reply_topic(
monkeypatch,
@ -7873,7 +8143,13 @@ async def test_execute_query_intent_contextual_draft_generation_bootstraps_requi
source_message_id="msg-anchor-1",
status="completed",
final_reply="你提到的是给运营公司打造主播这个新需求,我更推荐 C:主播协同作战平台。",
execution_outcome={"source_message_preview": "想跟你聊个新需求,关于给运营公司打造主播推流的需求。"},
execution_outcome={
"source_message_preview": "想跟你聊个新需求,关于给运营公司打造主播推流的需求。",
"rag_result": {
"query_type": "plan",
"sources": [{"source": "system", "metadata": {"reason_code": "requirement_planning_direct"}}],
},
},
),
MountedConversationRun(
tenant_id=3,
@ -7883,7 +8159,37 @@ async def test_execute_query_intent_contextual_draft_generation_bootstraps_requi
source_message_id="msg-anchor-2",
status="completed",
final_reply="咱们还没确认它到底要解决哪类业务卡点:是开播前脚本耗时、直播中突发问题不会接、还是下播后不知道优化啥?",
execution_outcome={"source_message_preview": "C"},
execution_outcome={
"source_message_preview": "C",
"rag_result": {
"query_type": "plan",
"sources": [{"source": "system", "metadata": {"reason_code": "requirement_planning_direct"}}],
},
"requirement_plan_context": {
"plan_profile": "it_project",
"title": "给运营公司打造主播协同作战平台",
"goal": "先验证主播在关键节点是否能得到更及时的脚本、应变和复盘支持。",
"project_nature": "主播协同作战平台",
"question_round": 1,
"round_option_maps": {
"project_nature": {
"A": "脚本提效助手",
"B": "直播问题应答台",
"C": "主播协同作战平台",
}
},
"last_round_options": {
"field": "project_nature",
"options": [
{"key": "A", "title": "脚本提效助手"},
{"key": "B", "title": "直播问题应答台"},
{"key": "C", "title": "主播协同作战平台"},
],
"recommendation": {"key": "C"},
},
"source_material": "用户:想跟你聊个新需求,关于给运营公司打造主播推流的需求。 | 用户:C",
},
},
),
MountedConversationRun(
tenant_id=3,
@ -7893,7 +8199,25 @@ async def test_execute_query_intent_contextual_draft_generation_bootstraps_requi
source_message_id="msg-anchor-3",
status="completed",
final_reply="收到,我会按当前上下文继续。",
execution_outcome={"source_message_preview": "三个问题都有"},
execution_outcome={
"source_message_preview": "三个问题都有",
"rag_result": {
"query_type": "plan",
"sources": [{"source": "system", "metadata": {"reason_code": "contextual_requirement_plan_draft"}}],
},
"requirement_plan_context": {
"plan_profile": "it_project",
"title": "给运营公司打造主播协同作战平台",
"goal": "先验证主播在关键节点是否能得到更及时的脚本、应变和复盘支持。",
"project_nature": "主播协同作战平台",
"first_scope": "开播前脚本、直播中应变、下播后复盘",
"question_round": 2,
"source_material": (
"用户:想跟你聊个新需求,关于给运营公司打造主播推流的需求。"
" | 用户:C | 用户:三个问题都有"
),
},
},
),
]
)
@ -9135,6 +9459,56 @@ async def test_should_route_to_draft_confirmation_session_uses_active_mounted_dr
}
@pytest.mark.asyncio
async def test_should_route_to_draft_confirmation_session_prioritizes_explicit_reply_anchor_over_bundle_skip(
monkeypatch,
) -> None:
captured: dict[str, object] = {"skip_called": False}
class FakeKernel(mixin_entry.KernelIngressFlowEntryMixin):
def __init__(self) -> None:
self.db = object()
self.tenant_id = 1
async def _fake_has_active_draft_session(self, **kwargs):
_ = self
captured["kwargs"] = kwargs
return True
async def _fake_skip(*, message_text: str, source_system: str) -> bool:
_ = (message_text, source_system)
captured["skip_called"] = True
return True
monkeypatch.setattr(DraftConfirmHandler, "has_active_draft_session", _fake_has_active_draft_session)
monkeypatch.setattr(
mixin_entry.KernelIngressFlowEntryMixin,
"_should_skip_no_anchor_draft_routing_by_bundle",
staticmethod(_fake_skip),
)
kernel = FakeKernel()
should_route = await kernel._should_route_to_draft_confirmation_session(
session=SimpleNamespace(last_intent="query.answer"),
sender="tester",
source_system="feishu",
channel_ref="chat-active",
source_sender_open_id="ou_active",
message_text="确认",
ingress_options={"thread_context": {"reply_to_message_id": "msg-bot-reply-1"}},
)
assert should_route is True
assert captured["skip_called"] is False
assert captured["kwargs"] == {
"sender": "tester",
"source_system": "feishu",
"channel_ref": "chat-active",
"source_sender_open_id": "ou_active",
"thread_context": {"reply_to_message_id": "msg-bot-reply-1"},
}
@pytest.mark.asyncio
async def test_should_route_to_draft_confirmation_session_does_not_hijack_smalltalk_even_if_active_draft_exists(
monkeypatch,

View File

@ -3,12 +3,15 @@ import pytest
from app.services.ai_pmo.ai_pmo_intent import (
INTENT_FOLLOWUP_CONTINUE,
INTENT_QUERY,
ROUTE_ACTION_COLLECT,
INTENT_TASK_CREATE,
ROUTE_ACTION_ANSWER,
ROUTE_ACTION_CANCEL,
ROUTE_ACTION_CONTINUE,
ROUTE_ACTION_CREATE,
ROUTE_FAMILY_QUERY,
ROUTE_FAMILY_REQUIREMENT_PLAN,
ROUTE_REQUIREMENT_PLAN_COLLECT,
ROUTE_FAMILY_TASK,
ROUTE_QUERY_ANSWER,
ROUTE_TASK_CREATE,
@ -171,6 +174,23 @@ def test_route_resolver_builds_query_answer_route() -> None:
assert route.key == ROUTE_QUERY_ANSWER
def test_route_resolver_keeps_explicit_requirement_plan_collect_route_for_query_intent() -> None:
route = RouteResolver.from_canonical(
canonical_intent=CanonicalIntent(
name=INTENT_QUERY,
confidence=0.92,
project_id=7,
signal_title="",
signal_summary="收敛第一版需求",
metadata={"workflow_route": ROUTE_REQUIREMENT_PLAN_COLLECT, "message_intent": "requirement_plan"},
),
message_text="继续收敛第一版需求",
)
assert route.family == ROUTE_FAMILY_REQUIREMENT_PLAN
assert route.action == ROUTE_ACTION_COLLECT
assert route.key == ROUTE_REQUIREMENT_PLAN_COLLECT
def test_route_resolver_followup_action_requires_structured_metadata() -> None:
action = RouteResolver.resolve_followup_route_action(
canonical_intent=INTENT_FOLLOWUP_CONTINUE,
@ -190,6 +210,7 @@ def test_route_resolver_followup_action_no_longer_parses_message_text_with_conte
def test_kernel_common_imports_exposes_workflow_route_keys() -> None:
assert "query.answer" in WORKFLOW_ROUTE_KEYS
assert ROUTE_REQUIREMENT_PLAN_COLLECT in WORKFLOW_ROUTE_KEYS
assert "task.create" in WORKFLOW_ROUTE_KEYS

View File

@ -579,8 +579,15 @@ async def test_generate_task_chain_falls_back_to_charter_decomposition_when_prim
captured["primary_function_name"] = kwargs.get("function_name")
raise RuntimeError("provider_circuit_open:openai")
async def fake_decompose_tasks_from_charter(self, charter_content, project_id, tenant_id="default", db=None): # noqa: ANN001
_ = self, project_id, db
async def fake_decompose_tasks_from_charter(
self,
charter_content,
project_id,
tenant_id="default",
db=None,
routing_meta=None,
): # noqa: ANN001
_ = self, project_id, db, routing_meta
captured["charter_content"] = charter_content
captured["decompose_tenant_id"] = tenant_id
return {
@ -606,8 +613,18 @@ async def test_generate_task_chain_falls_back_to_charter_decomposition_when_prim
]
}
async def fake_inspect_task_chain_candidate(self, *, tasks, tenant_id): # noqa: ANN001
_ = self, tenant_id
return {
"tasks": tasks,
"invalid_tasks": [],
"mappable_task_count": len(tasks),
"total_task_count": len(tasks),
}
monkeypatch.setattr(AIStewardService, "_load_available_skill_names", fake_load_available_skill_names)
monkeypatch.setattr(AIStewardService, "_generate_project_creation_object", fake_generate_project_creation_object)
monkeypatch.setattr(AIStewardService, "_inspect_task_chain_candidate", fake_inspect_task_chain_candidate)
monkeypatch.setattr(
"app.services.ai_service_core.service.AIService.decompose_tasks_from_charter",
fake_decompose_tasks_from_charter,
@ -651,10 +668,18 @@ async def test_generate_task_chain_prefers_six_module_charter_content_when_prese
_ = self, kwargs
raise AssertionError("six-module charter path should bypass project_from_text_task_chain prompt")
async def fake_decompose_tasks_from_charter(self, charter_content, project_id, tenant_id="default", db=None): # noqa: ANN001
async def fake_decompose_tasks_from_charter(
self,
charter_content,
project_id,
tenant_id="default",
db=None,
routing_meta=None,
): # noqa: ANN001
_ = self, project_id, db
captured["charter_content"] = charter_content
captured["decompose_tenant_id"] = tenant_id
captured["routing_meta"] = routing_meta
return {
"tasks": [
{
@ -669,8 +694,18 @@ async def test_generate_task_chain_prefers_six_module_charter_content_when_prese
]
}
async def fake_inspect_task_chain_candidate(self, *, tasks, tenant_id): # noqa: ANN001
_ = self, tenant_id
return {
"tasks": tasks,
"invalid_tasks": [],
"mappable_task_count": len(tasks),
"total_task_count": len(tasks),
}
monkeypatch.setattr(AIStewardService, "_load_available_skill_names", fake_load_available_skill_names)
monkeypatch.setattr(AIStewardService, "_generate_project_creation_object", fake_generate_project_creation_object)
monkeypatch.setattr(AIStewardService, "_inspect_task_chain_candidate", fake_inspect_task_chain_candidate)
monkeypatch.setattr(
"app.services.ai_service_core.service.AIService.decompose_tasks_from_charter",
fake_decompose_tasks_from_charter,
@ -707,6 +742,9 @@ async def test_generate_task_chain_prefers_six_module_charter_content_when_prese
assert captured["decompose_tenant_id"] == 1
assert "\"modules\"" in str(captured["charter_content"] or "")
assert "直播录屏与评论回放切片" in str(captured["charter_content"] or "")
assert captured["routing_meta"]["tier_alias"] == "low"
assert captured["routing_meta"]["routing_decision"] == "force_low_first"
assert captured["routing_meta"]["response_contract"] == "structured"
assert tasks[0]["title"] == "直播数据采集与清洗"
assert tasks[0]["required_skills"] == ["Python", "FastAPI"]
@ -715,14 +753,26 @@ async def test_generate_task_chain_prefers_six_module_charter_content_when_prese
async def test_generate_task_chain_raises_when_charter_decomposition_provider_fails(monkeypatch) -> None:
service = AIStewardService(object())
async def fake_decompose_tasks_from_charter(self, charter_content, project_id, tenant_id="default", db=None): # noqa: ANN001
_ = self, charter_content, project_id, tenant_id, db
async def fake_load_available_skill_names(self, *, tenant_id: int, limit: int = 24): # noqa: ANN001
_ = self, tenant_id, limit
return ["Python", "FastAPI", "React", "TypeScript"]
async def fake_decompose_tasks_from_charter(
self,
charter_content,
project_id,
tenant_id="default",
db=None,
routing_meta=None,
): # noqa: ANN001
_ = self, charter_content, project_id, tenant_id, db, routing_meta
raise RuntimeError("provider_circuit_open:openai")
monkeypatch.setattr(
"app.services.ai_service_core.service.AIService.decompose_tasks_from_charter",
fake_decompose_tasks_from_charter,
)
monkeypatch.setattr(AIStewardService, "_load_available_skill_names", fake_load_available_skill_names)
with pytest.raises(RuntimeError, match="provider_circuit_open:openai"):
await service._generate_task_chain_draft(
@ -744,32 +794,204 @@ async def test_generate_task_chain_raises_when_charter_decomposition_provider_fa
"out_of_scope": "- 不做达人 CRM",
"alignment_qa": "优先交付复盘效率闭环",
},
"module_3": {
"engineering_view": "Python FastAPI React TypeScript 数据分析",
},
"module_4": {
"milestones": "- 第 1 周完成直播与订单数据对齐\n- 第 2 周完成复盘看板 MVP",
},
"module_5": {
"risk_register": "- 数据口径不一致",
},
"module_6": {
"governance_rule": "- 复盘记录保留 180 天",
},
},
"derived_artifacts": {
"task_seeds": [
{
"title": "直播录屏与评论回放切片",
"description": "接入录屏与评论数据,支持按时间轴回放。",
"evidence_refs": ["module_2.in_scope"],
},
{
"title": "亮点和掉点自动总结",
"description": "自动总结直播亮点、掉点与问题复现线索。",
"evidence_refs": ["module_1.goal_statement", "module_5.risk_register"],
},
]
"module_3": {"engineering_view": "Python FastAPI React TypeScript 数据分析"},
"module_4": {"milestones": "- 第 1 周完成直播与订单数据对齐"},
"module_5": {"risk_register": "- 数据口径不一致"},
"module_6": {"governance_rule": "- 复盘记录保留 180 天"},
},
},
)
@pytest.mark.asyncio
async def test_generate_task_chain_primary_prompt_uses_low_first_routing_meta(monkeypatch) -> None:
service = AIStewardService(object())
captured: dict[str, object] = {}
async def fake_load_available_skill_names(self, *, tenant_id: int, limit: int = 24): # noqa: ANN001
_ = self, tenant_id, limit
return ["Python", "FastAPI", "React"]
async def fake_generate_project_creation_object(self, **kwargs): # noqa: ANN001
_ = self
captured["function_name"] = kwargs.get("function_name")
captured["routing_meta"] = kwargs.get("routing_meta")
return {
"tasks": [
{
"title": "直播数据采集与清洗",
"description": "接入录屏与评论回放。",
"priority": "high",
"estimated_hours": 16,
"required_skills": ["Python", "FastAPI"],
}
]
}
async def fake_inspect_task_chain_candidate(self, *, tasks, tenant_id): # noqa: ANN001
_ = self, tenant_id
return {
"tasks": tasks,
"invalid_tasks": [],
"mappable_task_count": len(tasks),
"total_task_count": len(tasks),
}
monkeypatch.setattr(AIStewardService, "_load_available_skill_names", fake_load_available_skill_names)
monkeypatch.setattr(AIStewardService, "_generate_project_creation_object", fake_generate_project_creation_object)
monkeypatch.setattr(AIStewardService, "_inspect_task_chain_candidate", fake_inspect_task_chain_candidate)
tasks = await service._generate_task_chain_draft(
analysis={
"project_title": "跨境直播复盘",
"primary_role": "backend",
"key_features": ["直播录屏与评论回放切片"],
"success_metrics": ["7天内复盘时长下降60%"],
},
tenant_id=1,
)
assert captured["function_name"] == "project_from_text_task_chain"
assert captured["routing_meta"]["tier_alias"] == "low"
assert captured["routing_meta"]["routing_decision"] == "force_low_first"
assert captured["routing_meta"]["response_contract"] == "structured"
assert tasks[0]["title"] == "直播数据采集与清洗"
@pytest.mark.asyncio
async def test_generate_task_chain_escalates_to_high_when_low_route_returns_low_quality_tasks(
monkeypatch,
) -> None:
service = AIStewardService(object())
captured_tiers: list[str] = []
async def fake_load_available_skill_names(self, *, tenant_id: int, limit: int = 24): # noqa: ANN001
_ = self, tenant_id, limit
return ["Python", "FastAPI", "React", "TypeScript"]
async def fake_generate_project_creation_object(self, **kwargs): # noqa: ANN001
_ = self
routing_meta = kwargs.get("routing_meta") or {}
captured_tiers.append(str(routing_meta.get("tier_alias") or ""))
if routing_meta.get("tier_alias") == "low":
return {
"tasks": [
{
"title": "权限系统",
"description": "实现统一权限系统。",
"priority": "high",
"estimated_hours": 16,
"required_skills": ["JWT", "Node.js"],
}
]
}
return {
"tasks": [
{
"title": "直播数据采集与清洗",
"description": "接入录屏与评论回放。",
"priority": "high",
"estimated_hours": 16,
"required_skills": ["Python", "FastAPI"],
}
]
}
async def fake_inspect_task_chain_candidate(self, *, tasks, tenant_id): # noqa: ANN001
_ = self, tenant_id
required_skills = list(tasks[0].get("required_skills") or []) if tasks else []
if required_skills == ["Python", "FastAPI"]:
return {
"tasks": tasks,
"invalid_tasks": [],
"mappable_task_count": len(tasks),
"total_task_count": len(tasks),
}
return {
"tasks": tasks,
"invalid_tasks": [
{
"title": str(tasks[0].get("title") or ""),
"required_skills": required_skills,
"reason": "required_skills_unmappable",
}
],
"mappable_task_count": 0,
"total_task_count": len(tasks),
}
monkeypatch.setattr(AIStewardService, "_load_available_skill_names", fake_load_available_skill_names)
monkeypatch.setattr(AIStewardService, "_generate_project_creation_object", fake_generate_project_creation_object)
monkeypatch.setattr(AIStewardService, "_inspect_task_chain_candidate", fake_inspect_task_chain_candidate)
tasks = await service._generate_task_chain_draft(
analysis={
"project_title": "跨境直播复盘",
"primary_role": "backend",
"key_features": ["直播录屏与评论回放切片"],
},
tenant_id=1,
)
assert captured_tiers[0] == "low"
assert "high" in captured_tiers
assert tasks[0]["title"] == "直播数据采集与清洗"
assert tasks[0]["required_skills"] == ["Python", "FastAPI"]
@pytest.mark.asyncio
async def test_generate_project_creation_draft_keeps_task_generation_pending_when_task_chain_fails(
monkeypatch,
) -> None:
service = AIStewardService(object())
async def fake_generate_signal(self, *, analysis, tenant_id): # noqa: ANN001
_ = self, analysis, tenant_id
return {
"content": "直播复盘主信号",
"impact_score": 0.9,
"priority": "high",
"strategic_analysis": {},
}
async def fake_generate_charter(self, *, analysis, tenant_id): # noqa: ANN001
_ = self, analysis, tenant_id
return {
"title": "跨境直播复盘项目宪章",
"content": {
"schema_version": "charter_ai_first_v1",
"modules": {
"module_1": {"problem_statement": "复盘慢"},
"module_2": {"in_scope": "- 直播复盘"},
"module_3": {},
"module_4": {},
"module_5": {},
"module_6": {},
},
},
}
async def fake_generate_task_chain(self, *, analysis, tenant_id, charter_content=None): # noqa: ANN001
_ = self, analysis, tenant_id, charter_content
raise RuntimeError("provider_circuit_open:openai")
monkeypatch.setattr(AIStewardService, "_generate_competitive_signal_draft", fake_generate_signal)
monkeypatch.setattr(AIStewardService, "_generate_charter_draft", fake_generate_charter)
monkeypatch.setattr(AIStewardService, "_generate_task_chain_draft", fake_generate_task_chain)
draft = await service._generate_project_creation_draft(
input_text="做一个给北美 DTC 运营团队用的跨境直播复盘助手",
analysis={
"project_name": "跨境直播复盘",
"project_title": "跨境直播复盘",
"primary_role": "backend",
},
tenant_id=1,
)
assert draft["project"]["title"] == "跨境直播复盘"
assert draft["signal"]["content"] == "直播复盘主信号"
assert draft["charter"]["title"] == "跨境直播复盘项目宪章"
assert draft["tasks"] == []
assert draft["task_generation"]["status"] == "pending"
assert "provider_circuit_open:openai" in str(draft["task_generation"]["error_message"] or "")

View File

@ -905,10 +905,59 @@ async def test_draft_confirm_handler_project_create_confirm_fails_when_charter_t
_ = self, tenant_id, limit
return ["Python", "FastAPI", "React", "TypeScript", "数据分析", "项目管理"]
async def _fail_decompose_tasks_from_charter(self, charter_content, project_id, tenant_id="default", db=None): # noqa: ANN001
_ = self, charter_content, project_id, tenant_id, db
async def _fail_decompose_tasks_from_charter(
self,
charter_content,
project_id,
tenant_id="default",
db=None,
routing_meta=None,
): # noqa: ANN001
_ = self, charter_content, project_id, tenant_id, db, routing_meta
raise RuntimeError("provider_circuit_open:openai")
async def _fake_normalize_project_creation_draft(self, **kwargs): # noqa: ANN001
captured["normalize_kwargs"] = kwargs
return {
"analysis": {},
"project": {
"name": "跨境直播复盘工具",
"title": "跨境直播复盘工具",
"description": "项目:跨境直播复盘工具",
"assigned_role": "backend",
"domain_id": None,
},
"signal": {
"content": "main signal",
"impact_score": 0.9,
"priority": "high",
"strategic_analysis": {},
},
"charter": {
"title": "跨境直播复盘工具项目宪章",
"content": {"summary": "demo"},
},
"tasks": kwargs["draft"].get("tasks", []),
"task_audit": [],
"task_generation": kwargs["draft"].get("task_generation", {}),
"risks": [],
"creator_id": 77,
}
async def _fake_persist_project_creation_draft(self, **kwargs): # noqa: ANN001
captured["persist_kwargs"] = kwargs
return {
"project": {"id": "101", "title": "跨境直播复盘工具", "name": "跨境直播复盘工具"},
"project_id": 101,
"signal": {"id": "202", "content": "main signal"},
"signal_id": 202,
"charter": {"id": "303", "title": "宪章", "content": {"summary": "demo"}},
"tasks": [],
"task_generation": kwargs["normalized"].get("task_generation", {}),
"risks": [],
"signal_sources": [],
}
monkeypatch.setattr(
"app.services.ai_pmo.draft_confirm_handler.AIStewardService.create_project_from_natural_language",
_fail_create_project_from_natural_language,
@ -921,6 +970,14 @@ async def test_draft_confirm_handler_project_create_confirm_fails_when_charter_t
"app.services.ai_service_core.service.AIService.decompose_tasks_from_charter",
_fail_decompose_tasks_from_charter,
)
monkeypatch.setattr(
"app.services.ai_pmo.draft_confirm_handler.AIStewardService._normalize_project_creation_draft",
_fake_normalize_project_creation_draft,
)
monkeypatch.setattr(
"app.services.ai_pmo.draft_confirm_handler.AIStewardService._persist_project_creation_draft",
_fake_persist_project_creation_draft,
)
result = await handler.confirm_draft(
target_work_item=SimpleNamespace(
@ -940,11 +997,17 @@ async def test_draft_confirm_handler_project_create_confirm_fails_when_charter_t
)
assert result["handled"] is True
assert result["status"] == "failed_safe"
assert result["project_id"] is None
assert "provider_circuit_open:openai" in str(result["followup_reply"] or "")
assert "normalize_kwargs" not in captured
assert "persist_kwargs" not in captured
assert result["status"] == "succeeded"
assert result["project_id"] == 101
assert result["signal_id"] == 202
assert "任务拆解:待补全" in str(result["followup_reply"] or "")
assert captured["normalize_kwargs"]["draft"]["tasks"] == []
assert captured["normalize_kwargs"]["draft"]["task_generation"]["status"] == "pending"
assert "provider_circuit_open:openai" in str(
captured["normalize_kwargs"]["draft"]["task_generation"]["error_message"] or ""
)
assert captured["persist_kwargs"]["normalized"]["tasks"] == []
assert captured["persist_kwargs"]["normalized"]["task_generation"]["status"] == "pending"
@pytest.mark.asyncio
@ -1385,7 +1448,19 @@ async def test_draft_confirm_handler_semantic_regenerate_routes_to_regenerate_dr
@pytest.mark.asyncio
async def test_resolve_pending_draft_releases_stale_active_work_item_binding(monkeypatch) -> None:
kernel = type("Kernel", (), {"db": object(), "tenant_id": 1})()
class _FakeResult:
def __init__(self, rows) -> None:
self._rows = list(rows)
def all(self):
return list(self._rows)
class _FakeDB:
async def execute(self, stmt): # pragma: no cover - structure only
_ = stmt
return _FakeResult([])
kernel = type("Kernel", (), {"db": _FakeDB(), "tenant_id": 1})()
handler = DraftConfirmHandler(kernel)
captured: dict[str, object] = {}
@ -1431,6 +1506,346 @@ async def test_resolve_pending_draft_releases_stale_active_work_item_binding(mon
assert captured["release_kwargs"]["final_state"] == "cancelled"
@pytest.mark.asyncio
async def test_resolve_active_mounted_draft_keeps_retryable_pending_confirm_without_active_work_item_when_anchor_matches(
monkeypatch,
) -> None:
class _FakeResult:
def __init__(self, rows) -> None:
self._rows = list(rows)
def all(self):
return list(self._rows)
draft_row = SimpleNamespace(
id=8,
topic_workspace_id=77,
active_work_item_id=None,
state=STATE_DRAFT_PENDING_CONFIRM,
latest_reply_message_id="om-bot-retry-1",
latest_source_message_id="msg-draft-root",
)
topic_row = SimpleNamespace(
id=77,
tenant_id=1,
source_system="feishu",
mode="plan",
status="active",
channel_ref="chat-1",
conversation_scope_key="feishu:chat-1:ou_xxx",
plan_status=STATE_DRAFT_PENDING_CONFIRM,
root_message_id="msg-draft-root",
thread_key="",
last_message_id="msg-draft-root",
updated_at=None,
)
class _FakeDB:
async def execute(self, query): # noqa: ARG002
return _FakeResult([(draft_row, topic_row)])
async def _unexpected_load(*args, **kwargs):
raise AssertionError(f"released retryable draft should not load active work item: {args} {kwargs}")
async def _unexpected_release(*args, **kwargs):
raise AssertionError(f"released retryable draft should not release binding again: {args} {kwargs}")
handler = DraftConfirmHandler(type("Kernel", (), {"db": _FakeDB(), "tenant_id": 1})())
monkeypatch.setattr(handler, "_load_work_item_target_by_id", _unexpected_load)
monkeypatch.setattr(handler, "_release_mounted_draft_binding", _unexpected_release)
result = await handler._resolve_active_mounted_draft(
sender="林宇航",
source_system="feishu",
channel_ref="chat-1",
source_sender_open_id="ou_xxx",
thread_context={"reply_to_message_id": "om-bot-retry-1"},
)
assert result == (draft_row, topic_row)
@pytest.mark.asyncio
async def test_resolve_pending_draft_falls_back_to_anchor_matched_pending_work_item_when_active_mounted_draft_has_no_active_work_item_id(
monkeypatch,
) -> None:
class _FakeResult:
def __init__(self, rows) -> None:
self._rows = list(rows)
def all(self):
return list(self._rows)
canonical_work_item = SimpleNamespace(
id=1092,
state=STATE_DRAFT_PENDING_CONFIRM,
intent="project_create.create",
project_id=None,
signal_id=None,
source_message_id="src-draft-confirm-1",
)
canonical_ingest = SimpleNamespace(
payload={
"sender": "Codex真实验收机器人",
"channel_ref": "chat-1",
"source_sender_open_id": "ou_xxx",
"reply_to_message_id": "src-draft-confirm-1",
"root_message_id": "msg-draft-root",
},
source_message_id="src-draft-confirm-1",
)
wrong_newer_work_item = SimpleNamespace(
id=1097,
state=STATE_DRAFT_PENDING_CONFIRM,
intent="project_create.create",
project_id=None,
signal_id=None,
source_message_id="src-wrong-newer-confirm",
)
wrong_newer_ingest = SimpleNamespace(
payload={
"sender": "Codex真实验收机器人",
"channel_ref": "chat-1",
"source_sender_open_id": "ou_xxx",
"reply_to_message_id": "src-draft-confirm-1",
"root_message_id": "msg-draft-root",
},
source_message_id="src-wrong-newer-confirm",
)
class _FakeDB:
async def execute(self, query):
sql = str(query.compile(compile_kwargs={"literal_binds": True})).lower()
if "from work_items" in sql:
return _FakeResult(
[
(wrong_newer_work_item, wrong_newer_ingest),
(canonical_work_item, canonical_ingest),
]
)
if "from feishu_reply_outbox" in sql:
return _FakeResult(
[
("src-wrong-newer-confirm", "om-bot-retry-1"),
("src-draft-confirm-1", "om-bot-retry-1"),
]
)
raise AssertionError(f"unexpected query: {sql}")
released_mounted = (
SimpleNamespace(
id=8,
topic_workspace_id=77,
active_work_item_id=None,
state=STATE_DRAFT_PENDING_CONFIRM,
latest_reply_message_id="om-bot-retry-1",
latest_source_message_id="src-draft-confirm-1",
),
SimpleNamespace(
id=77,
mode="plan",
status="active",
plan_status=STATE_DRAFT_PENDING_CONFIRM,
channel_ref="chat-1",
conversation_scope_key="feishu:chat-1:ou_xxx",
root_message_id="msg-draft-root",
thread_key="",
last_message_id="src-draft-confirm-1",
updated_at=None,
),
)
handler = DraftConfirmHandler(type("Kernel", (), {"db": _FakeDB(), "tenant_id": 1})())
async def _fake_resolve_active_mounted_draft(**kwargs):
_ = kwargs
return released_mounted
monkeypatch.setattr(handler, "_resolve_active_mounted_draft", _fake_resolve_active_mounted_draft)
result = await handler._resolve_pending_draft(
sender="Codex真实验收机器人",
source_system="feishu",
channel_ref="chat-1",
source_sender_open_id="ou_xxx",
explicit_work_item_id=None,
thread_context={
"reply_to_message_id": "om-bot-retry-1",
"root_message_id": "msg-draft-root",
},
)
assert result == (canonical_work_item, canonical_ingest)
@pytest.mark.asyncio
async def test_resolve_pending_draft_materializes_retryable_work_item_from_mounted_draft_when_only_wrong_candidate_matches_anchor(
monkeypatch,
) -> None:
class _FakeResult:
def __init__(self, rows) -> None:
self._rows = list(rows)
def all(self):
return list(self._rows)
wrong_newer_work_item = SimpleNamespace(
id=1097,
state=STATE_DRAFT_PENDING_CONFIRM,
intent="project_create.create",
project_id=None,
signal_id=None,
source_message_id="src-wrong-newer-confirm",
draft_content={"title": "需求确认立项"},
)
wrong_newer_ingest = SimpleNamespace(
payload={
"sender": "Codex真实验收机器人",
"channel_ref": "chat-1",
"source_sender_open_id": "ou_xxx",
"reply_to_message_id": "src-draft-confirm-1",
"root_message_id": "msg-draft-root",
},
source_message_id="src-wrong-newer-confirm",
)
mounted_draft = SimpleNamespace(
id=8,
topic_workspace_id=77,
active_work_item_id=None,
state=STATE_DRAFT_PENDING_CONFIRM,
latest_reply_message_id="om-bot-retry-1",
latest_source_message_id="src-draft-confirm-1",
draft_payload={"title": "直播复盘辅助工具(速记版)"},
draft_version=1,
requirement_card={"title": "直播复盘辅助工具"},
)
mounted_topic = SimpleNamespace(
id=77,
mode="plan",
status="active",
plan_status=STATE_DRAFT_PENDING_CONFIRM,
channel_ref="chat-1",
conversation_scope_key="feishu:chat-1:ou_xxx",
root_message_id="msg-draft-root",
thread_key="",
last_message_id="src-draft-confirm-1",
updated_at=None,
metadata_json={},
)
class _FakeDB:
async def execute(self, query):
sql = str(query.compile(compile_kwargs={"literal_binds": True})).lower()
if "from work_items" in sql:
return _FakeResult([(wrong_newer_work_item, wrong_newer_ingest)])
if "from feishu_reply_outbox" in sql:
return _FakeResult([("src-wrong-newer-confirm", "om-bot-retry-1")])
raise AssertionError(f"unexpected query: {sql}")
handler = DraftConfirmHandler(type("Kernel", (), {"db": _FakeDB(), "tenant_id": 1})())
materialized = (
SimpleNamespace(id=2001, state=STATE_DRAFT_PENDING_CONFIRM, source_message_id="draft-retry-2001"),
SimpleNamespace(source_message_id="draft-retry-2001", payload={"message_text": "mounted draft retry"}),
)
captured: dict[str, object] = {}
async def _fake_resolve_active_mounted_draft(**kwargs):
_ = kwargs
return (mounted_draft, mounted_topic)
async def _fake_materialize(**kwargs):
captured.update(kwargs)
return materialized
monkeypatch.setattr(handler, "_resolve_active_mounted_draft", _fake_resolve_active_mounted_draft)
monkeypatch.setattr(handler, "_materialize_retryable_pending_draft_from_mounted", _fake_materialize)
result = await handler._resolve_pending_draft(
sender="Codex真实验收机器人",
source_system="feishu",
channel_ref="chat-1",
source_sender_open_id="ou_xxx",
explicit_work_item_id=None,
thread_context={
"reply_to_message_id": "om-bot-retry-1",
"root_message_id": "msg-draft-root",
},
)
assert result == materialized
assert captured["mounted"] == (mounted_draft, mounted_topic)
assert captured["source_system"] == "feishu"
assert captured["channel_ref"] == "chat-1"
@pytest.mark.asyncio
async def test_resolve_retryable_draft_owner_user_id_prefers_latest_source_message_id(monkeypatch) -> None:
class _FakeScalarResult:
def __init__(self, value) -> None:
self._value = value
def scalar_one_or_none(self):
return self._value
class _FakeDB:
async def execute(self, query):
sql = str(query.compile(compile_kwargs={"literal_binds": True})).lower()
assert "from work_items" in sql
assert "src-draft-confirm-1" in sql
return _FakeScalarResult(42)
handler = DraftConfirmHandler(type("Kernel", (), {"db": _FakeDB(), "tenant_id": 1})())
owner_user_id = await handler._resolve_retryable_draft_owner_user_id(
mounted_draft=SimpleNamespace(latest_source_message_id="src-draft-confirm-1"),
mounted_topic=SimpleNamespace(last_message_id="src-wrong-newer-confirm", root_message_id="msg-draft-root"),
)
assert owner_user_id == 42
@pytest.mark.asyncio
async def test_release_mounted_draft_binding_keeps_retryable_failed_confirm_in_pending_confirm() -> None:
class _FakeDB:
async def execute(self, query): # noqa: ARG002
raise AssertionError("mounted rows are injected directly; execute should not be called")
async def flush(self):
return None
handler = DraftConfirmHandler(type("Kernel", (), {"db": _FakeDB(), "tenant_id": 1})())
draft = SimpleNamespace(
id=8,
topic_workspace_id=77,
active_work_item_id=42,
confirm_message_id="msg-confirm-1",
state="executing",
draft_payload={"title": "跨境直播复盘助手"},
)
topic = SimpleNamespace(
id=77,
mode="plan",
status="active",
plan_status="executing",
pending_actions=[],
metadata_json={"active_draft_work_item_id": 42},
)
await handler._release_mounted_draft_binding(
work_item_id=42,
final_state="failed",
mounted=(draft, topic),
)
assert draft.active_work_item_id is None
assert draft.confirm_message_id is None
assert draft.state == STATE_DRAFT_PENDING_CONFIRM
assert topic.status == "active"
assert topic.plan_status == STATE_DRAFT_PENDING_CONFIRM
assert topic.pending_actions == ["confirm_requirement_draft"]
assert topic.metadata_json["active_draft_work_item_id"] is None
assert topic.metadata_json["draft_lifecycle_state"] == "failed"
@pytest.mark.asyncio
async def test_draft_confirm_handler_uses_work_item_bound_mounted_draft_for_semantic_followup(monkeypatch) -> None:
kernel = type("Kernel", (), {"db": SimpleNamespace(execute=object()), "tenant_id": 1})()

View File

@ -306,7 +306,7 @@ async def test_record_decision_and_finalize_run_persist_topic_mode_and_traces(db
@pytest.mark.asyncio
async def test_record_orchestrator_decision_preserves_requirement_plan_mode_for_query_answer_turns(db_session) -> None:
async def test_record_orchestrator_decision_promotes_requirement_plan_route_for_requirement_plan_turns(db_session) -> None:
service = MountedAIRuntimeWriteService(db_session)
tracking = await service.begin_ingress_run(
@ -345,7 +345,7 @@ async def test_record_orchestrator_decision_preserves_requirement_plan_mode_for_
assert saved_topic.mode == "plan"
assert saved_run.mode_after == "plan"
assert saved_topic.metadata_json["last_workflow_route"] == "query.answer"
assert saved_topic.metadata_json["last_workflow_route"] == "requirement_plan.collect"
@pytest.mark.asyncio
@ -654,6 +654,54 @@ async def test_finalize_run_reconciles_semantic_packet_summary_requirement_card_
assert packet_card["last_round_options"]["field"] == "first_scope"
@pytest.mark.asyncio
async def test_finalize_run_updates_topic_last_workflow_route_from_requirement_plan_result(db_session) -> None:
service = MountedAIRuntimeWriteService(db_session)
tracking = await service.begin_ingress_run(
tenant_id=1,
source_system="feishu",
source_message_id="om_message_plan_route_sync",
message_text="继续往下收。",
sender="林宇航",
channel_ref="chat_plan_route_sync",
thread_context={"root_message_id": "om_root_plan_route_sync"},
source_sender_open_id="ou_plan_route_sync",
)
tracking.topic_workspace.metadata_json = {
**(tracking.topic_workspace.metadata_json if isinstance(tracking.topic_workspace.metadata_json, dict) else {}),
"last_workflow_route": "query.answer",
"plan_mode": True,
}
tracking.topic_workspace.mode = "plan"
tracking.topic_workspace.plan_status = "collecting"
await db_session.flush()
await service.finalize_run(
tracking=tracking,
result={
"handled": True,
"status": "answered",
"classification": "question",
"workflow_route": "requirement_plan.collect",
"work_item_id": 902,
"direct_reply_text": "我先继续收下一轮关键信息。",
"rag_result": {
"answer": "我先继续收下一轮关键信息。",
"query_type": "plan",
},
"requirement_plan_context": {"question_round": 3},
},
)
await db_session.commit()
saved_topic = (await db_session.execute(select(MountedTopicWorkspace))).scalar_one()
saved_run = (await db_session.execute(select(MountedConversationRun))).scalar_one()
assert saved_topic.metadata_json["last_workflow_route"] == "requirement_plan.collect"
assert saved_run.execution_outcome["workflow_route"] == "requirement_plan.collect"
@pytest.mark.asyncio
async def test_finalize_run_rebinds_conversation_run_to_canonical_draft_artifact_topic(db_session) -> None:
service = MountedAIRuntimeWriteService(db_session)

View File

@ -202,6 +202,46 @@ async def test_semantic_context_assembler_prefers_accepted_semantic_requirement_
assert assistant_turns[-1] == "已确认选项 A(单一层级区域经理),继续收第二轮关键信息。"
@pytest.mark.asyncio
async def test_semantic_context_assembler_prefers_draft_title_for_thread_summary_when_topic_facts_are_minimal(db_session) -> None:
topic = MountedTopicWorkspace(
tenant_id=7,
topic_key="feishu:chat_semantic:root:om_root_semantic_draft_summary",
source_system="feishu",
channel_ref="chat_semantic_draft_summary",
conversation_scope_key="feishu:chat_semantic_draft_summary:ou_semantic",
root_message_id="om_root_semantic_draft_summary",
mode="plan",
status="active",
plan_status="collecting",
facts_json={"plan_profile": "it_project"},
metadata_json={},
)
db_session.add(topic)
await db_session.flush()
db_session.add(
MountedDraftWorkspace(
tenant_id=7,
topic_workspace_id=int(topic.id),
state="collecting",
draft_version=1,
requirement_card={"title": "门店库存异常晨报助手", "goal": "先收第一版需求"},
draft_payload=None,
required_confirmations=[],
)
)
await db_session.commit()
packet = await SemanticContextAssembler(db_session).assemble(
tenant_id=7,
operator_user_id=99,
topic_workspace_id=int(topic.id),
latest_user_message="继续往下收。",
)
assert packet.thread.thread_summary == "门店库存异常晨报助手"
@pytest.mark.asyncio
async def test_semantic_context_assembler_adds_draft_followup_ingress_actions(db_session) -> None:
topic = MountedTopicWorkspace(

View File

@ -139,6 +139,7 @@
- `frontend/src/components/sstc/` 继续作为 SSTC 共享组件目录维护。
## 6. 工具与运行库存
- `backend/scripts/repair_requirement_plan_history.py`
- `scripts/start_model_probe.sh`
- `scripts/model_probe.py`
- `scripts/gen_task_brief.sh`

View File

@ -79,12 +79,15 @@
- AI PMO ingress 的 mixed sidecar 预检查不得拦截显式 `reply/thread` 下的低信息量协议回复(如 `A/B/C`、`我选C`、`就C吧`);这类消息必须优先交给 active-context / requirement-plan 主链处理,禁止先生成 `[回答]...[已收到需求]` 的 mixed 拼接回复污染主结果。
- orphan requirement-plan followup 在显式 `reply/thread` 存在时,必须先尝试从 reply 命中的 chat context 恢复或升格 requirement workspace;只有恢复失败时才允许回“当前没有进行中的 A/B/C 收敛轮次”。
- Feishu 显式 `reply/thread` 到机器人消息时,ingress 与 mounted runtime 必须先通过 `feishu_reply_outbox.sent_message_id -> source_message_id` 归一化 `root_message_id/reply_to_message_id`,并在后续 active-context / requirement-plan / draft 主链里复用同一份 canonical thread context;禁止直接用机器人 `sent_message_id` 新开 chat topic,导致 requirement-plan / draft 线程分叉。
- requirement-plan 的唯一业务真源固定为 `MountedDraftWorkspace.requirement_card`:收敛轮次、已确认字段、`round_option_maps`、`last_round_options`、`source_material` 等业务状态只能从 draft 或结构化 `requirement_plan_context` 恢复;`mounted_topic_workspaces.facts_json` 只允许保留线程索引级最小字段(当前限定为 `title / plan_profile`),`work_items.execution_result` 与 `mounted_conversation_runs.final_reply` 仅作审计/回放,禁止再从 assistant prose、topic facts 或 run 文案反推需求字段。
- requirement-plan 的运行时路由命名也必须单独收口:收敛轮次统一写 `requirement_plan.collect`,草稿确认前的结构化草稿统一写 `requirement_plan.draft`;`query.answer` 只保留给通用问答,禁止在 requirement-plan 主链里继续借用旧 query route 记账。
- 飞书历史队列协议已停用:卡片动作与文本 followup 只允许走 `work_item_id + work_item.resume/draft_confirm` 主链。
- AI PMO 写操作默认先生成草稿并进入 `draft_pending_confirm`,未确认前禁止自动执行入库。
- `project_create.create` 与 requirement draft confirm 的任务拆解是从属子步骤,不再允许阻塞项目、主信号、来源挂载与宪章的确定性创建。任务生成链必须先按 `routing_meta(tier_alias=low, response_contract=structured, execution_mode=local_planner)` 尝试,并在结构质量不足、空任务、不可映射技能或 provider 失败时升级 `high`;若高阶仍失败,运行时必须显式写回 `task_generation.status=pending` 与真实失败原因,保持 `draft_pending_confirm` 可重试,禁止返回默认任务、模板任务或把整条确认入库伪装成成功/失败错态。
### 3.6 AdaptiveRAG / Query 主链
- `POST /rag/ask` 在命中 `business_review/correction_escalation` 时,必须强制留在 `query.read_only` 主链,禁止误送到写操作草稿链。
- Feishu 新消息即使被低阶 LLM 先判成写动作,只要统一语义规划层命中 `requirement_plan/business_review/correction_escalation`,就必须在 ingress 阶段强制拉回 `query.answer`。
- Feishu 新消息即使被低阶 LLM 先判成写动作,只要统一语义规划层命中 `requirement_plan/business_review/correction_escalation`,就必须在 ingress 阶段强制拉回受控问答主链;其中 requirement-plan 收敛轮次必须落到 `requirement_plan.collect / requirement_plan.draft`,不得再回写为 `query.answer`。
- 飞书群聊 `query/followup` 同步直答只允许显式触发:`@PMFlow`、显式 `reply_to_message_id`、卡片动作或显式 `work_item_id`;无显式触发时不得同步直答,也不得写入 followup QA 桶。
- Feishu `query/followup` 会话键在命中 `reply/thread` 锚点时必须优先使用 thread key;仅在没有显式锚点时才回落到默认的 `chat + sender` scope key。
- AdaptiveRAG 命中显式 `reply/thread` 锚点后,问题改写必须优先使用该锚点;`session_state.last_subject / last_entity_ids` 只允许在没有显式锚点时参与续写,禁止旧会话主题覆盖显式引用上下文。