Refine semantic routing and ingress decision flow

This commit is contained in:
Yuuuu 2026-04-08 09:11:52 +08:00
parent 38adaed6e6
commit 7d39cf503c
74 changed files with 7395 additions and 4175 deletions

View File

@ -90,6 +90,8 @@ frontend/ # React 前端
- 禁止模型兜底回复:模型失败/超时/空响应/结构化解析失败时,必须显式报错并保留真实失败原因,禁止返回固定兜底文案冒充成功结果 - 禁止模型兜底回复:模型失败/超时/空响应/结构化解析失败时,必须显式报错并保留真实失败原因,禁止返回固定兜底文案冒充成功结果
- 核心业务内容零兜底:宪章、信号分析、任务拆解、项目建议、范围边界、风险判断、资源计划、对齐结论等用户可见核心业务内容,禁止使用默认文案、模板句子、占位字段或 stub 数据冒充真实生成结果;AI 失败、证据不足或结构化失败时,必须显式返回失败或标记为“待确认/证据不足/生成失败” - 核心业务内容零兜底:宪章、信号分析、任务拆解、项目建议、范围边界、风险判断、资源计划、对齐结论等用户可见核心业务内容,禁止使用默认文案、模板句子、占位字段或 stub 数据冒充真实生成结果;AI 失败、证据不足或结构化失败时,必须显式返回失败或标记为“待确认/证据不足/生成失败”
- 业务语义治理:凡是会决定 `project.create / signal_source.onboard / followup.* / query vs execute` 的中文关键词、正则、`looks_like_*`、`*_HINTS/*_KEYWORDS`,只能定义在 `backend/app/services/ai_pmo/workflow_semantic_planner/` 或 prompt/catalog 配置层;其它目录只允许做文本清洗、ID/URL/repo/locator 抽取与结构化结果解析 - 业务语义治理:凡是会决定 `project.create / signal_source.onboard / followup.* / query vs execute` 的中文关键词、正则、`looks_like_*`、`*_HINTS/*_KEYWORDS`,只能定义在 `backend/app/services/ai_pmo/workflow_semantic_planner/` 或 prompt/catalog 配置层;其它目录只允许做文本清洗、ID/URL/repo/locator 抽取与结构化结果解析
- 用户可见业务动作拍板必须优先走“上下文 packet -> 模型结构化决策 -> gatekeeper”链路;`reply anchor / button click / work_item_id / tenant / permission / safety / idempotency` 这类协议级判断允许保留确定性规则,但展示反馈、草稿确认/修改/取消、query vs execute、followup 动作归类不得再由 `looks_like_*` 或关键词直接最终拍板
- 若问题根因属于“动作路由错误 / 上下文劫持 / 独立业务流误触发”,禁止把“改 prompt、补反例、扩关键词”当作主修法;必须优先检查 ingress 分流、active topic/draft 检测、`SemanticContextPacket` 组装、`SemanticDecision` 结构化输出与 gatekeeper 放行条件。Prompt 只允许补结构、口径和禁止事项,不得替代主链裁决
### 8.1 系统定位 ### 8.1 系统定位
@ -225,6 +227,7 @@ frontend/ # React 前端
## 13. 测试与验证 ## 13. 测试与验证
- 后端改动:`python -m py_compile` 通过 + 相关单测通过 - 后端改动:`python -m py_compile` 通过 + 相关单测通过
- 后端代码或数据库迁移改动:在编译/测试通过后,必须继续执行当前环境对应的热部署/重启/重载步骤,确保变更真实生效;可用方式包括 `docker compose restart backend`、`scripts/docker_stack.sh restart|dev-restart`、`systemctl restart neural-sync`、`nginx -s reload` 等。若当前环境不是部署机、权限不足或不具备热部署条件,必须明确说明未执行原因与阻塞点,禁止默认跳过
- 前端改动:`npm run build` 通过 + `tsc --noEmit` 零错误 - 前端改动:`npm run build` 通过 + `tsc --noEmit` 零错误
- 新增接口:必须在 `backend/tests/` 补充对应单测 - 新增接口:必须在 `backend/tests/` 补充对应单测

View File

@ -6,6 +6,8 @@
- 建议:规则放 `AGENTS.md`,架构/目录说明放 `CLAUDE.md` - 建议:规则放 `AGENTS.md`,架构/目录说明放 `CLAUDE.md`
- 当前 AI 行为的系统宪章已在 `AGENTS.md` 中升级为“事实层 / 评审层 / 生成层 / 执行层”四层分治;所有新增 AI 业务默认按该宪章接入,`CLAUDE.md` 仅保留结构与背景说明 - 当前 AI 行为的系统宪章已在 `AGENTS.md` 中升级为“事实层 / 评审层 / 生成层 / 执行层”四层分治;所有新增 AI 业务默认按该宪章接入,`CLAUDE.md` 仅保留结构与背景说明
- 新增执行约定:计划类需求一旦确认进入实现阶段,默认连续执行到“实现完成 + 验证完成 + 统一回复验收结果”;仅在明确阻塞、高风险冲突、context 限制或用户主动中断时才中途打断。 - 新增执行约定:计划类需求一旦确认进入实现阶段,默认连续执行到“实现完成 + 验证完成 + 统一回复验收结果”;仅在明确阻塞、高风险冲突、context 限制或用户主动中断时才中途打断。
- 新增 bugfix 执行约定:缺陷修复/回归修复允许跨多个直接相关文件端到端完成,不再按文件数硬性拆分;但必须同时满足“改动范围围绕同一根因”“不夹带无关重构”“最终附带编译/测试验证”。大功能开发或结构性重构仍优先按子任务拆分。
- 新增发布约定:凡后端代码或数据库迁移发生改动,完成编译/测试后必须继续执行当前环境对应的热部署/重启/重载步骤,确认服务已加载新版本;若当前机器不是部署环境、缺少权限或不具备热部署条件,必须在结果中明确说明未执行原因与阻塞点,禁止默认省略。
## 2. 项目定位 ## 2. 项目定位
- 项目名:`PMFlow / Neural Sync` - 项目名:`PMFlow / Neural Sync`
@ -197,7 +199,7 @@ backend/
- 新增:`backend/app/utils/project_command_utils.py`(项目创建标题/描述提取与归一化辅助) - 新增:`backend/app/utils/project_command_utils.py`(项目创建标题/描述提取与归一化辅助)
- 新增:`backend/app/services/ai_pmo_intent_routing.py`(AI PMO 意图/路由常量与解析器收口,统一 `family.action` 路由映射) - 新增:`backend/app/services/ai_pmo_intent_routing.py`(AI PMO 意图/路由常量与解析器收口,统一 `family.action` 路由映射)
- 新增:`backend/app/services/ai_pmo/workflow_semantic_planner/`(统一业务语义规划层:唯一允许承载 workflow bundle 语义关键词/正则/兼容 helper 的目录) - 新增:`backend/app/services/ai_pmo/workflow_semantic_planner/`(统一业务语义规划层:唯一允许承载 workflow bundle 语义关键词/正则/兼容 helper 的目录)
- 新增:`backend/app/services/ai_pmo/semantic_context/`(OpenCow 风格语义上下文子模块:统一 `SemanticContextPacket/ModeCard/SemanticDecision/DecisionGateResult` 契约、`SemanticContextAssembler`、`SemanticDecisionGatekeeper` 与 `SemanticDecisionService`;当前已接入 ingress shadow 观测,并为 intent/query-scene 主链提供 direct metadata 替代语义入口) - 新增:`backend/app/services/ai_pmo/semantic_context/`(OpenCow 风格语义上下文子模块:统一 `SemanticContextPacket/ModeCard/SemanticDecision/DecisionGateResult` 契约、`SemanticContextAssembler`、`SemanticDecisionGatekeeper` 与 `SemanticDecisionService`;当前除 ingress shadow 观测外,也开始承接 draft followup 这类用户可见动作判定,作为 intent/query-scene/followup 统一 packet 决策底座)
- 新增:`backend/app/models/reply_iteration.py`(回复质量迭代模型:`QualityAsset/QualityAssetPair/JudgeRun/OptimizerCandidate/PromptRelease`) - 新增:`backend/app/models/reply_iteration.py`(回复质量迭代模型:`QualityAsset/QualityAssetPair/JudgeRun/OptimizerCandidate/PromptRelease`)
- 新增:`backend/app/models/training_pipeline.py`(训练/微调管线模型:`TrainingDataset/TrainingJob/TrainingEvaluation/TrainedModelArtifact/TrainedModelDeployment`) - 新增:`backend/app/models/training_pipeline.py`(训练/微调管线模型:`TrainingDataset/TrainingJob/TrainingEvaluation/TrainedModelArtifact/TrainedModelDeployment`)
- 新增:`backend/app/models/mounted_ai_runtime.py`(Mounted Runtime 运行时模型:`MountedTopicWorkspace/MountedDraftWorkspace/MountedConversationRun/MountedAgentTrace/MountedSourceActivation/MountedToolCapabilityPolicy`;`MountedTopicWorkspace` 额外承载 `conversation_scope_key + plan_status`,用于同群同人 topic 隔离与 plan 状态机;`MountedDraftWorkspace` 额外承载 `active_work_item_id + latest_source_message_id + latest_reply_message_id`,用于当前 active draft 绑定与 reply anchor 锚定) - 新增:`backend/app/models/mounted_ai_runtime.py`(Mounted Runtime 运行时模型:`MountedTopicWorkspace/MountedDraftWorkspace/MountedConversationRun/MountedAgentTrace/MountedSourceActivation/MountedToolCapabilityPolicy`;`MountedTopicWorkspace` 额外承载 `conversation_scope_key + plan_status`,用于同群同人 topic 隔离与 plan 状态机;`MountedDraftWorkspace` 额外承载 `active_work_item_id + latest_source_message_id + latest_reply_message_id`,用于当前 active draft 绑定与 reply anchor 锚定)
@ -583,10 +585,10 @@ uvicorn app.main:app --reload --host 0.0.0.0 --port 8000
- `ExecuteQueryProjectMixin` 对 `message_intent=requirement_plan` 的查询必须优先走 mounted runtime 直答快路径:默认只输出自然短话术,每轮最多只保留 1 个关键问题;当 `scoring_basis/target_user/output_form/cadence/first_scope` 收集完成时,必须直接整理成需求草稿,禁止继续空转追问 - `ExecuteQueryProjectMixin` 对 `message_intent=requirement_plan` 的查询必须优先走 mounted runtime 直答快路径:默认只输出自然短话术,每轮最多只保留 1 个关键问题;当 `scoring_basis/target_user/output_form/cadence/first_scope` 收集完成时,必须直接整理成需求草稿,禁止继续空转追问
- `requirement_plan` 的 Mounted Runtime 状态必须显式落库到 `MountedTopicWorkspace.plan_status`:至少区分 `idle / collecting / options_ready / plan_ready / awaiting_execute_confirm / suspended`,禁止只靠 `draft.state` 或 `metadata_json` 猜当前阶段 - `requirement_plan` 的 Mounted Runtime 状态必须显式落库到 `MountedTopicWorkspace.plan_status`:至少区分 `idle / collecting / options_ready / plan_ready / awaiting_execute_confirm / suspended`,禁止只靠 `draft.state` 或 `metadata_json` 猜当前阶段
- 新的 OpenCow 风格语义上下文链必须先组装 `SemanticContextPacket`,再由模型返回 `SemanticDecision`,最后由 gatekeeper 决定 `plan_status`;当前 shadow 结果已进入 Mounted Runtime 观测,同时 intent/query-scene 主链已切到 direct metadata,不得再把 `semantic_bundle` 当作主链真源或绕过统一执行门禁 - 新的 OpenCow 风格语义上下文链必须先组装 `SemanticContextPacket`,再由模型返回 `SemanticDecision`,最后由 gatekeeper 决定 `plan_status`;当前 shadow 结果已进入 Mounted Runtime 观测,同时 intent/query-scene 主链已切到 direct metadata,不得再把 `semantic_bundle` 当作主链真源或绕过统一执行门禁
- `requirement_plan` 的 A/B/C 是用户可见需求收敛骨架,可以固定结构;但 A/B/C 的业务选项、AI 推荐项、推荐理由和代价必须参照 OpenCow 的 `上下文包 -> 模型结构化决策 -> gatekeeper -> 渲染` 模式生成,禁止在代码里硬编码业务候选项或旧场景模板。用户回复 `A/B/C` 时必须优先回写上一轮模型生成的选项映射,不能再落回静态选项表。
- 正在收敛中的 `requirement_plan` 必须支持普通人碎片化回复:像“技术指标吧 / 给投顾看 / 飞书日报就行 / 每天收盘后发”这种单字段短句,即使缺少新的 `semantic_bundle`,只要同线程/同群同人存在 active plan workspace,也必须自动续接当前需求收敛,不得回落到通用 query 或高阶 RAG - 正在收敛中的 `requirement_plan` 必须支持普通人碎片化回复:像“技术指标吧 / 给投顾看 / 飞书日报就行 / 每天收盘后发”这种单字段短句,即使缺少新的 `semantic_bundle`,只要同线程/同群同人存在 active plan workspace,也必须自动续接当前需求收敛,不得回落到通用 query 或高阶 RAG
- `requirement_plan` 的追问节奏必须像自然聊天:上一轮只确认新增信息、下一轮只问一个缺口;初始描述中的“每日/自动”不能过早推断成已确认的更新频率,避免少问关键问题或提前出草稿 - `requirement_plan` 的追问节奏必须像自然聊天:上一轮只确认新增信息、下一轮只问一个缺口;初始描述中的“每日/自动”不能过早推断成已确认的更新频率,避免少问关键问题或提前出草稿
- `requirement_plan` 必须按 profile 区分场景:`scoring` 需求继续收集 `打分依据/目标用户/输出形式/更新频率/第一版范围`,`generic` 需求收集 `输入材料/目标用户/输出形式/响应时效/第一版范围`;像“会议纪要提待办”这类普通工具需求,禁止再复用“这个分数怎么打”的旧问题模板 - `requirement_plan` 当前用户可见主链只保留 IT 项目收敛口径;历史 `generic/scoring` 数据只允许作为兼容读取或旧测试场景,不得再作为新会话的用户可见模板扩散。普通闲聊、query.answer、非 requirement followup 必须继续保持自由文案,禁止全局套用 A/B/C 选项骨架
- `generic requirement_plan` 的 `A/B/C` 选项只能出现在 active requirement session 的 `collecting` 轮次里,并且只允许绑定当前缺口字段(如 `target_user / output_form / cadence`);普通闲聊、query.answer、非 requirement followup 必须继续保持自由文案,禁止全局套用选项模板
- 用户显式说“聊个新需求 / 另一个需求 / 跟上一个无关”时,AI PMO 必须先清掉当前会话里继承的旧 `current_project_id`,避免后续短回复继续被旧项目上下文污染 - 用户显式说“聊个新需求 / 另一个需求 / 跟上一个无关”时,AI PMO 必须先清掉当前会话里继承的旧 `current_project_id`,避免后续短回复继续被旧项目上下文污染
- 裸 `A/B/C/确认/你自行推荐` 这类低信息量回复,如果没有命中当前 active requirement session 或待确认草稿,必须直接提示“当前没有进行中的收敛轮次/草稿”,禁止再回落到 `query.answer` 检索图谱并生成旧项目结论 - 裸 `A/B/C/确认/你自行推荐` 这类低信息量回复,如果没有命中当前 active requirement session 或待确认草稿,必须直接提示“当前没有进行中的收敛轮次/草稿”,禁止再回落到 `query.answer` 检索图谱并生成旧项目结论
- 飞书私聊中的“答错了/不对/调用高级模型/用更强模型/重新回答”必须被识别为 `correction_escalation` 指令:优先复用上一轮上下文主题并强制深度回答,禁止把这类话术当成新的系统配置查询 - 飞书私聊中的“答错了/不对/调用高级模型/用更强模型/重新回答”必须被识别为 `correction_escalation` 指令:优先复用上一轮上下文主题并强制深度回答,禁止把这类话术当成新的系统配置查询
@ -607,6 +609,8 @@ uvicorn app.main:app --reload --host 0.0.0.0 --port 8000
- AI PMO 执行层必须统一产出 `execution_result.action_contract` 与 `action_param_source`,并写入 `operation_execution_ledger`;禁止在执行链路旁路写副作用且不入账 - AI PMO 执行层必须统一产出 `execution_result.action_contract` 与 `action_param_source`,并写入 `operation_execution_ledger`;禁止在执行链路旁路写副作用且不入账
- AI PMO 写操作幂等以消息级锚点为准:优先使用 `source_message_id` / `feishu_message_id` 作为执行账本幂等键,标题/正文仅用于业务内容,不得参与重复执行判定 - AI PMO 写操作幂等以消息级锚点为准:优先使用 `source_message_id` / `feishu_message_id` 作为执行账本幂等键,标题/正文仅用于业务内容,不得参与重复执行判定
- PMFlow 业务语义治理:会决定 `project.create / signal_source.onboard / followup.* / query vs execute` 的中文关键词、正则、`looks_like_*`、`*_HINTS/*_KEYWORDS` 只能定义在 `backend/app/services/ai_pmo/workflow_semantic_planner/` 或 prompt/catalog 配置层;其他目录仅允许做文本清洗、ID/URL/repo/locator 抽取与结构化结果解析 - PMFlow 业务语义治理:会决定 `project.create / signal_source.onboard / followup.* / query vs execute` 的中文关键词、正则、`looks_like_*`、`*_HINTS/*_KEYWORDS` 只能定义在 `backend/app/services/ai_pmo/workflow_semantic_planner/` 或 prompt/catalog 配置层;其他目录仅允许做文本清洗、ID/URL/repo/locator 抽取与结构化结果解析
- 用户可见业务动作拍板必须优先走 `SemanticContextPacket -> SemanticDecision -> gatekeeper` 主链;协议级锚点(`reply_to_message_id/button/work_item_id`)、租户权限、幂等等确定性规则可以保留,但展示反馈、草稿确认/修改/取消、followup 动作归类、`query vs execute` 不能再由 `looks_like_*` 或关键词直接最终拍板
- 若问题根因属于“动作路由错误 / 上下文劫持 / 独立业务流误触发”,主修法必须优先落在 ingress 分流、active topic/draft 检测、`SemanticContextPacket` 组包、`SemanticDecision` 结构化判断与 gatekeeper 放行条件;禁止先用“补 prompt 反例 / 扩关键词 / 调语义文案”顶上去。Prompt 只负责结构、口径、禁止事项,不替代主链裁决
- `scripts/semantic_lint.py` + `scripts/semantic_lint_baseline.txt` 为语义治理守门脚本;CI 会阻止新的散落语义进入非 allowlist 目录,baseline 仅记录已知兼容残留 - `scripts/semantic_lint.py` + `scripts/semantic_lint_baseline.txt` 为语义治理守门脚本;CI 会阻止新的散落语义进入非 allowlist 目录,baseline 仅记录已知兼容残留
- 只读问答链路的 prompt 读取优先级固定为:`prompt registry manifest 校验 -> active prompt release -> 代码 seed prompt fallback`;业务服务禁止直接绕过 `PromptReleaseService` 拼接已纳管的 read-only QA prompt - 只读问答链路的 prompt 读取优先级固定为:`prompt registry manifest 校验 -> active prompt release -> 代码 seed prompt fallback`;业务服务禁止直接绕过 `PromptReleaseService` 拼接已纳管的 read-only QA prompt
- 回复质量迭代与训练管线的原始反馈入口统一复用 `feedback_center_records` / `qa_records` / `ai_call_logs`;新增 `quality_assets/training_datasets` 只能作为派生层,禁止再造平行原始数据主表 - 回复质量迭代与训练管线的原始反馈入口统一复用 `feedback_center_records` / `qa_records` / `ai_call_logs`;新增 `quality_assets/training_datasets` 只能作为派生层,禁止再造平行原始数据主表

View File

@ -20,11 +20,18 @@ def _should_hide_work_item_from_inbox(*, row: WorkItem, ingest: IngestEvent) ->
normalized = _normalize_noise_text(message_text) normalized = _normalize_noise_text(message_text)
if not normalized: if not normalized:
return True return True
if any(keyword in normalized for keyword in FRONT_NOISE_STRUCTURED_HINTS): has_structured_context = any(
(
_safe_int(row.project_id) is not None,
_safe_int(row.signal_id) is not None,
bool(payload.get("closure_summary")),
bool(payload.get("closure_dialogue")),
bool(payload.get("closure_modules")),
)
)
if has_structured_context:
return False return False
if normalized in FRONT_NOISE_EXACT: if len(normalized) <= 3:
return True
if len(normalized) <= 8 and any(keyword in normalized for keyword in FRONT_NOISE_HINTS):
return True return True
return False return False
@ -61,19 +68,15 @@ def _is_low_value_closure_payload(payload: dict[str, Any]) -> bool:
has_closure = bool(summary or dialogue or modules) has_closure = bool(summary or dialogue or modules)
if not has_closure: if not has_closure:
return True return True
compact_summary = re.sub(r"\s+", "", summary).lower() if summary else ""
summary_is_generic = bool(compact_summary and GENERIC_SOURCE_TEXT_RE.match(compact_summary))
weak_summary = bool(summary and any(token in summary for token in LOW_VALUE_CLOSURE_HINTS))
has_assistant_turn = any( has_assistant_turn = any(
str(item.get("role") or "").strip().lower() == "assistant" and str(item.get("text") or "").strip() str(item.get("role") or "").strip().lower() == "assistant" and str(item.get("text") or "").strip()
for item in turns for item in turns
if isinstance(item, dict) if isinstance(item, dict)
) )
if weak_summary and not has_assistant_turn: summary_len = len(re.sub(r"\s+", "", summary)) if summary else 0
if summary_len <= 6 and not has_assistant_turn and not modules:
return True return True
if summary_is_generic and not modules and not has_assistant_turn: if dialogue and "机器人:" not in dialogue and len(turns) <= 1 and summary_len <= 12 and not modules:
return True
if dialogue and "机器人:" not in dialogue and len(turns) <= 1 and weak_summary:
return True return True
return False return False

View File

@ -97,7 +97,7 @@ def _serialize_work_item_summary(
summary_seed = closure_summary or message_text or _next_step_text(row) or row.intent or "AI PMO 工作项" summary_seed = closure_summary or message_text or _next_step_text(row) or row.intent or "AI PMO 工作项"
summary = _compact_text(summary_seed, 180) summary = _compact_text(summary_seed, 180)
compact_message = re.sub(r"\s+", "", message_text).lower() if message_text else "" compact_message = re.sub(r"\s+", "", message_text).lower() if message_text else ""
prefer_closure_title = bool(compact_message and GENERIC_SOURCE_TEXT_RE.match(compact_message)) prefer_closure_title = bool(compact_message and len(compact_message) <= 8 and closure_summary)
title_seed = ( title_seed = (
(closure_summary or message_text) (closure_summary or message_text)
if prefer_closure_title if prefer_closure_title

View File

@ -17,16 +17,7 @@ from ...dependencies.tenant import get_tenant_id
from ...models import DecisionRecord, IngestEvent, PlanBundle, PortfolioSnapshot, Project, ReplyReceipt, User, WorkItem from ...models import DecisionRecord, IngestEvent, PlanBundle, PortfolioSnapshot, Project, ReplyReceipt, User, WorkItem
from ...services.ai_pmo.ai_pmo_cadence import AIPMOCadenceService from ...services.ai_pmo.ai_pmo_cadence import AIPMOCadenceService
from ...services.ai_pmo.ai_pmo_kernel import AIPMOKernelService from ...services.ai_pmo.ai_pmo_kernel import AIPMOKernelService
from ...services.ai_pmo.workflow_semantic_planner.constants import (
FRONT_NOISE_HINTS,
FRONT_NOISE_STRUCTURED_HINTS,
GENERIC_SIGNAL_SOURCE_MESSAGE_RE as GENERIC_SOURCE_TEXT_RE,
INTENT_QUERY,
LOW_VALUE_CLOSURE_HINTS,
ROUTE_QUERY_ANSWER,
SMALLTALK_EXACT as FRONT_NOISE_EXACT,
)
router = APIRouter(prefix="/ai-pmo", tags=["AI PMO"]) router = APIRouter(prefix="/ai-pmo", tags=["AI PMO"])
logger = get_logger(__name__) logger = get_logger(__name__)
FRONT_NOISE_INTENTS = {INTENT_QUERY, ROUTE_QUERY_ANSWER} FRONT_NOISE_INTENTS = {"query.read_only", "query.answer"}

View File

@ -72,24 +72,78 @@ PMFLOW_SEMANTIC_CONTEXT_DECISION_PROMPT = """
你的输入是系统整理好的 semantic context packet。你的职责不是执行写操作,而是: 你的输入是系统整理好的 semantic context packet。你的职责不是执行写操作,而是:
1. 判断用户这句话是否继续当前 topic。 1. 判断用户这句话是否继续当前 topic。
2. 判断当前 workflow 是否继续停留在 plan。 2. 判断当前 workflow 是否继续停留在 plan。
3. 给出 draft patch 建议。 3. 如果 packet.ingress.candidate_kind == "draft_followup",要判断这句回复是在确认草稿、修改草稿、取消草稿,还是仅仅反馈展示/沟通方式。
4. 给出要回复给用户的内容。 4. 如果 packet.ingress.candidate_kind == "active_context_followup",要判断这句短追问应该留在当前需求、留在当前草稿,还是升级成独立 workflow。
5. 明确 requested_actions 与是否需要确认。 5. 给出 draft patch 建议。
6. 给出要回复给用户的内容。
7. 明确 requested_actions 与是否需要确认。
硬约束: 硬约束:
- 只能依据 packet 中的事实、草稿、recent turns、mode card 判断。 - 只能依据 packet 中的事实、草稿、recent turns、mode card 判断。
- 不得把推断包装成事实。 - 不得把推断包装成事实。
- 不得请求 mode card 明确禁止的动作。 - 不得请求 mode card 明确禁止的动作。
- 如果 packet.ingress.candidate_kind == "draft_followup":
- 只有用户明确要求改内容、补内容、删内容、缩范围、改字段时,才能输出 followup_action="revise"。
- 如果用户是在追问当前草稿里明显缺失的模块或字段,这属于补充当前草稿内容,输出 followup_action="revise",并把 revision_note 写成要补充的模块。
- 如果用户只是反馈展示形式、消息长短、卡片样式、沟通方式,输出 followup_action="answer_only"。
- 如果用户明确同意草稿内容,输出 followup_action="confirm"。
- 如果用户明确不要当前草稿,输出 followup_action="cancel"。
- 展示反馈不能包装成 revision_note。
- 如果 packet.ingress.candidate_kind == "active_context_followup":
- 这是“当前会话已经有 active requirement / active draft 时”的上下文优先决策,不是在做通用 intent 分类。
- 必须先基于当前已绑定的对象来判断动作,不要把这句消息先当成全局 query 或通用新意图。
- 对于只补充当前讨论模块的短追问,默认优先判断为 current_requirement 或 current_draft,而不是独立 workflow。
- 如果 active_context_kind == "draft_pending_confirm":
- 用户明确表示确认当前草稿时,输出 followup_scope="current_draft" 且 followup_action="confirm"。
- 用户明确表示重写、重出一版、按这版重来、补充缺失模块时,输出 followup_scope="current_draft" 且 followup_action="revise"。
- 用户明确表示不要当前草稿时,输出 followup_scope="current_draft" 且 followup_action="cancel"。
- 仅当用户真正要脱离当前草稿、发起独立动作时,才允许输出 followup_scope="independent_workflow"。
- 只有当用户同时表达了明确动作、明确对象、明确作用域时,才能输出 followup_scope="independent_workflow"。
- 如果当前上下文是待确认草稿,且用户追问的是草稿里缺失模块,应输出 followup_scope="current_draft",同时给出 followup_action="revise" 和 revision_note。
- 如果当前上下文是收敛中的 requirement plan,且用户只是在追问某个模块或缺口,应输出 followup_scope="current_requirement"。
- 如果证据不足以判断归属,输出 followup_scope="clarify",并用 assistant_reply 追问,不要擅自跳到独立 workflow。
- followup_scope 仅在 active_context_followup 场景下使用;其它场景必须输出 "none"。
- followup_action 在 draft_followup 场景下必须使用;在 active_context_followup 场景下仅当 followup_scope="current_draft" 时允许使用,其它情况输出 "none"。
- 如果 packet.ingress.candidate_kind == "cold_start_route":
- 这是“当前消息还没有绑定到明确对象时”的冷启动路由决策。
- 你的职责是基于 packet.latest_user_message、packet.topic.metadata、packet.thread、packet.ingress.thread_context 决定应该进入哪些 workflow。
- 不要使用硬编码关键词仓库思维,不要把业务词表当成主裁决器;必须根据当前消息表达的动作、对象、作用域和上下文关系做整体判断。
- 如果消息本质上是在咨询、分析、聊需求、收敛范围、问建议,而不是立即执行写动作,应输出 query_flow。
- 如果消息明确表达了新建项目、创建任务、接入信号源、创建项目信号等独立动作,才输出对应 workflow。
- 允许多 workflow,但只有当消息同时表达了多个明确动作时才这样做。
- scene_metadata 只允许包含:message_intent、review_framework、consultant_mode_preference、force_thinking_mode、advise_subtype、topic_switch。
- workflow_plans 里的 workflow_type 只允许:query_flow、new_project_flow、project_flow、task_flow、signal_source_flow、project_signal_flow、followup_flow。
- workflow_action 应与 workflow_type 匹配,例如 answer/create/onboard/discover/continue/confirm/cancel/refresh/commit。
- target_scope 只允许:global、project、thread。
- operation_codes 必须使用系统路由键,例如 query.answer、project.create、task.create、signal_source.onboard、project_signal.create、project_signal.discover、project_signal.commit、project_signal.cancel、project_signal.refresh、followup.continue、followup.confirm。
- cold_start_route 场景下,requested_actions 只允许请求 workflow.bundle.compose。
- 如果证据不足,不要伪造执行意图;优先保守为 query_flow,并把不确定性写入 uncertainties。
- 输出必须是一个 JSON 对象,不要输出 Markdown。 - 输出必须是一个 JSON 对象,不要输出 Markdown。
JSON schema: JSON schema:
{ {
"topic_relation": "continue_current_topic | new_topic | uncertain", "topic_relation": "continue_current_topic | new_topic | uncertain",
"mode_decision": "stay_plan | ready_confirm | request_execute | answer_only", "mode_decision": "stay_plan | ready_confirm | request_execute | answer_only",
"followup_scope": "none | current_requirement | current_draft | independent_workflow | clarify",
"followup_action": "none | confirm | revise | cancel | answer_only",
"revision_note": "string",
"draft_patch": {}, "draft_patch": {},
"workflow_plans": [
{
"workflow_type": "query_flow | new_project_flow | project_flow | task_flow | signal_source_flow | project_signal_flow | followup_flow",
"workflow_action": "string",
"target_scope": "global | project | thread",
"operation_codes": ["string"],
"requires_confirmation": false,
"confidence": 0.0,
"clarification_needed": false,
"metadata": {}
}
],
"scene_metadata": {},
"assistant_reply": "string", "assistant_reply": "string",
"requires_confirmation": true, "requires_confirmation": true,
"requested_actions": ["reply.compose"], "requested_actions": ["reply.compose | workflow.bundle.compose"],
"confidence": 0.0, "confidence": 0.0,
"uncertainties": [] "uncertainties": []
} }
@ -98,6 +152,136 @@ semantic_context_packet:
{semantic_packet} {semantic_packet}
""".strip() """.strip()
PMFLOW_REQUIREMENT_PLAN_ROUND_PROMPT = """
你是 PMFlow 的需求收敛 PM。
你的输入是系统整理好的 requirement_plan_round_packet。你的职责是:在当前需求上下文内,为“本轮缺口字段”生成 A/B/C 三个可选收敛方向,并给出一个 AI 推荐。
硬约束:
- A/B/C 是固定需求收敛骨架,必须保留。
- 不要复用旧项目模板、旧业务场景或 packet 中不存在的产品名。
- 不要把模板占位句冒充业务判断。
- 只能基于 packet.current_context / collected_fields / collected_field_summary / latest_user_message / latest_user_reply_summary 生成选项。
- 这是要发给飞书的一条文本消息,必须严格遵守 packet.reply_budget。
- 最终渲染后的整条消息必须控制在 packet.reply_budget.max_chars 以内,避免消息被截断。
- opening / focus / 每个 option 字段 / recommendation.reason / reply_instruction 都要短句化,不要写成长段说明。
- 不要输出“当前我们正在收敛”“project_nature 字段需判断”这类内部字段说明。
- 不要复述 packet、current_context、latest_user_message、collected_fields、reply_budget 这类内部 JSON 键名,也不要把 snake_case 英文字段名直接写给用户看。
- 如果 latest_user_message 只是 A / B / C 这种短回复,优先参考 packet.latest_user_reply_summary 和 packet.collected_field_summary 理解用户刚刚确认的方向。
- 如果证据不足,要把不确定性写进 recommendation.reason 或 uncertainties,不要编造成事实。
- 输出必须是一个 JSON 对象,不要输出 Markdown。
JSON schema:
{
"opening": "string",
"focus": "string",
"options": [
{
"key": "A",
"title": "string",
"best_for": "string",
"suggested_capability": "string",
"rationale": "string",
"tradeoff": "string"
},
{
"key": "B",
"title": "string",
"best_for": "string",
"suggested_capability": "string",
"rationale": "string",
"tradeoff": "string"
},
{
"key": "C",
"title": "string",
"best_for": "string",
"suggested_capability": "string",
"rationale": "string",
"tradeoff": "string"
}
],
"recommendation": {
"key": "A | B | C",
"reason": "string"
},
"reply_instruction": "string",
"uncertainties": []
}
requirement_plan_round_packet:
{round_packet}
""".strip()
PMFLOW_REQUIREMENT_PLAN_DRAFT_PROMPT = """
你是 PMFlow 的需求收敛 PM。
你的输入是系统整理好的 requirement_plan_draft_packet。你的职责是:基于当前已经收敛出的事实,生成一版贴合当前业务语境、可以直接给用户确认的需求草稿。
硬约束:
- 只能基于 packet.current_context / collected_fields / collected_field_summary / pending_items / known_constraints / revision_request 生成草稿。
- 草稿必须贴合当前需求,不要复用旧项目模板、旧业务场景或 packet 中不存在的产品名。
- 不要复述 packet、current_context、collected_fields、known_constraints 这类内部 JSON 键名,也不要把 snake_case 英文字段名直接写给用户看。
- 不要输出“审批、审计、权限一次做满”“人员能力图谱”“工作流状态与审计日志”“确认门禁”这类与当前需求无关的旧系统固定话术。
- 如果当前需求不是部署/发布/运维类项目,不要引入回滚、部署目标清单、环境凭证、平台运维团队等旧部署语境。
- 不要把模板占位句冒充业务判断。
- 如果证据不足,要把不确定性放进 pending_items,不要编造成事实。
- 必须给出 3-5 个外部信号源候选,它们属于当前需求下面的子需求候选,不是新的独立 workflow。
- external_signal_sources 只能写外部来源,例如开放平台、第三方 API、外部趋势源、外部商品源、外部回流源;不能写飞书消息、项目任务、责任人、数据库表、内部日志、内部工作流。
- 输出必须是一个 JSON 对象,不要输出 Markdown。
JSON schema:
{
"intro": "string",
"title": "string",
"one_liner": "string",
"goal": "string",
"project_nature": "string",
"target_user": "string",
"signal_inputs": "string",
"known_info": "string",
"tech_route": "string",
"first_scope": "string",
"core_flow": "string",
"delivery_mode": "string",
"success_signal": "string",
"risks": "string",
"pending_items": ["string"],
"closing": "string",
"summary": "string",
"positioning_one_liner": "string",
"target_customers": "string",
"external_signal_sources": [
{
"name": "string",
"purpose": "string",
"phase": "string"
}
],
"requirement_items": [
{
"section": "string",
"title": "string",
"description": "string"
}
],
"missing_info": ["string"],
"mvp_items": ["string"],
"team_setup": [
{
"role": "string",
"notes": "string"
}
],
"validation_metrics": {
"product": ["string"]
}
}
requirement_plan_draft_packet:
{draft_packet}
""".strip()
def build_default_role_prompt_bundle() -> dict: def build_default_role_prompt_bundle() -> dict:
"""返回默认角色 Prompt 配置(每次调用返回新对象)。""" """返回默认角色 Prompt 配置(每次调用返回新对象)。"""
@ -230,6 +414,8 @@ __all__ = [
"PROMPT_EXTERNAL_TREND_QA", "PROMPT_EXTERNAL_TREND_QA",
"PROMPT_INDUSTRY_TREND_QA", "PROMPT_INDUSTRY_TREND_QA",
"PMFLOW_SEMANTIC_CONTEXT_DECISION_PROMPT", "PMFLOW_SEMANTIC_CONTEXT_DECISION_PROMPT",
"PMFLOW_REQUIREMENT_PLAN_ROUND_PROMPT",
"PMFLOW_REQUIREMENT_PLAN_DRAFT_PROMPT",
"PROMPT_REGISTRY", "PROMPT_REGISTRY",
"PROMPT_REGISTRY_MAP", "PROMPT_REGISTRY_MAP",
"get_prompt_registry", "get_prompt_registry",

View File

@ -164,10 +164,11 @@ class AdaptiveRAGAnswerMainlineMixin:
) )
query_type = self._classify_query_type(effective_q) query_type = self._classify_query_type(effective_q)
requested_action_mode = self._normalize_action_mode(action_mode) requested_action_mode = self._normalize_action_mode(action_mode)
effective_action_mode = self._resolve_action_mode( effective_action_mode = await self._resolve_action_mode_async(
requested_mode=requested_action_mode, requested_mode=requested_action_mode,
question=resolved_question, question=resolved_question,
effective_question=effective_q, effective_question=effective_q,
asked_via=asked_via,
) )
return { return {
"session_id": session_id, "session_id": session_id,

View File

@ -13,37 +13,41 @@ class AdaptiveRAGRequirementAckCoreMixin:
return ACTION_MODE_AUTO return ACTION_MODE_AUTO
@classmethod @classmethod
def _looks_like_write_intent(cls, question: str) -> bool: async def _resolve_action_mode_async(
text = str(question or "").strip().lower()
if not text:
return False
has_verb = any(k in text for k in REQUIREMENT_WRITE_VERB_KEYWORDS)
has_object = any(k in text for k in REQUIREMENT_WRITE_OBJECT_KEYWORDS)
if not has_verb or not has_object:
return False
if any(prefix in text for prefix in WRITE_EXPRESSIONS):
return True
if "新增" in text or "增加" in text or "记录" in text or "入库" in text:
return True
return False
@classmethod
def _resolve_action_mode(
cls, cls,
*, *,
requested_mode: str, requested_mode: str,
question: str, question: str,
effective_question: str, effective_question: str,
asked_via: str,
) -> str: ) -> str:
mode = cls._normalize_action_mode(requested_mode) mode = cls._normalize_action_mode(requested_mode)
if mode != ACTION_MODE_AUTO: if mode != ACTION_MODE_AUTO:
return mode return mode
merged = f"{question} {effective_question}".strip() merged = f"{question} {effective_question}".strip()
if not cls._looks_like_write_intent(merged): if not merged:
return ACTION_MODE_READ return ACTION_MODE_READ
has_question_form = ("?" in merged) or ("?" in merged) or cls._contains_any(merged, QUESTION_HINTS) try:
if has_question_form: from ..ai_pmo.workflow_semantic_planner import WorkflowSemanticPlanner
bundle = await WorkflowSemanticPlanner.aplan_message(
message_text=merged,
source_system=str(asked_via or "").strip() or "adaptive_rag_ack",
metadata={},
)
except Exception:
return ACTION_MODE_READ
workflows = list(getattr(bundle, "workflows", []) or [])
if not workflows:
return ACTION_MODE_READ
has_query_flow = any(str(getattr(item, "workflow_type", "") or "").strip() == "query_flow" for item in workflows)
has_non_query_flow = any(str(getattr(item, "workflow_type", "") or "").strip() != "query_flow" for item in workflows)
if not has_non_query_flow:
return ACTION_MODE_READ
if has_query_flow:
return ACTION_MODE_READ_WRITE return ACTION_MODE_READ_WRITE
return ACTION_MODE_WRITE return ACTION_MODE_WRITE
@ -194,4 +198,3 @@ class AdaptiveRAGRequirementAckCoreMixin:
reason = error_reason or "写入异常" reason = error_reason or "写入异常"
return f"未完成入库:{reason}。我已记录本次失败,请稍后重试。" return f"未完成入库:{reason}。我已记录本次失败,请稍后重试。"
return "已收到需求,正在处理中。" return "已收到需求,正在处理中。"

View File

@ -2,7 +2,6 @@
from __future__ import annotations from __future__ import annotations
from .shared import * from .shared import *
from ..ai_pmo.workflow_semantic_planner import WorkflowSemanticPlanner
class AdaptiveRAGRequirementAckFlowMixin: class AdaptiveRAGRequirementAckFlowMixin:
@ -45,7 +44,6 @@ class AdaptiveRAGRequirementAckFlowMixin:
linked_signal_id is not None linked_signal_id is not None
and resolved_project_id is not None and resolved_project_id is not None
and action_taken in {"signal_created", "signal_deduped"} and action_taken in {"signal_created", "signal_deduped"}
and WorkflowSemanticPlanner.looks_like_signal_source_request(effective_question or question)
): ):
try: try:
source_orchestration_result = await SignalSourceOrchestrationService(self.db).orchestrate_from_signal( source_orchestration_result = await SignalSourceOrchestrationService(self.db).orchestrate_from_signal(

View File

@ -68,41 +68,6 @@ ACTION_MODE_AUTO = "auto"
ACTION_MODE_READ = "read" ACTION_MODE_READ = "read"
ACTION_MODE_WRITE = "write" ACTION_MODE_WRITE = "write"
ACTION_MODE_READ_WRITE = "read_write" ACTION_MODE_READ_WRITE = "read_write"
REQUIREMENT_WRITE_VERB_KEYWORDS = (
"新增",
"增加",
"添加",
"接入",
"支持",
"实现",
"创建",
"新建",
"记录",
"入库",
"同步",
"补充",
)
REQUIREMENT_WRITE_OBJECT_KEYWORDS = (
"信号",
"需求",
"任务",
"项目",
"功能",
"接口",
"页面",
"按钮",
"规则",
"模块",
"卡路里",
"食谱",
)
WRITE_EXPRESSIONS = (
"给",
"帮我",
"请帮",
"请给",
"麻烦",
)
COUNT_HINTS = ("多少", "几个", "数量", "总数", "count") COUNT_HINTS = ("多少", "几个", "数量", "总数", "count")
LIST_HINTS = ("哪些", "列表", "清单", "列出", "列一下", "分别", "名单", "都有哪些") LIST_HINTS = ("哪些", "列表", "清单", "列出", "列一下", "分别", "名单", "都有哪些")
STATUS_HINTS = ("进度", "状态", "阶段", "完成率", "完成度", "情况") STATUS_HINTS = ("进度", "状态", "阶段", "完成率", "完成度", "情况")

View File

@ -44,23 +44,7 @@ __all__ = [
"WORKFLOW_ROUTE_KEYS", "WORKFLOW_ROUTE_KEYS",
"ROUTE_CANONICAL_INTENT_MAP", "ROUTE_CANONICAL_INTENT_MAP",
"LEGACY_INTENT_ROUTE_MAP", "LEGACY_INTENT_ROUTE_MAP",
"PROJECT_SIGNAL_DISCOVER_HINTS",
"FOLLOWUP_REFRESH_HINTS",
"FOLLOWUP_CANCEL_HINTS",
"FOLLOWUP_COMMIT_HINTS",
"QUESTION_HINTS",
"SMALLTALK_EXACT",
"SMALLTALK_SHORT_HINTS",
"SMALLTALK_STRUCTURED_HINTS",
"FOLLOWUP_CONFIRM_HINTS",
"FOLLOWUP_CONTINUE_HINTS",
"FOLLOWUP_WORK_ITEM_ID_RE", "FOLLOWUP_WORK_ITEM_ID_RE",
"SIGNAL_CREATE_HINTS",
"TASK_CREATE_HINTS",
"PROJECT_CREATE_HINTS",
"SESSION_SHORT_CONFIRM_WINDOW_SECONDS",
"SHORT_CONFIRM_MAX_MESSAGE_LENGTH",
"SHORT_CONFIRM_HINTS",
"INTENT_LLM_FUNCTION_NAME", "INTENT_LLM_FUNCTION_NAME",
"INTENT_CANDIDATES", "INTENT_CANDIDATES",
"WRITE_INTENTS", "WRITE_INTENTS",

View File

@ -6,53 +6,9 @@ from ..workflow_semantic_planner import WorkflowSemanticPlanner
class IntentResolver: class IntentResolver:
"""Resolve raw text to canonical intent.""" """Resolve raw text to canonical intent."""
OPERATION_VERBS = (
"新增",
"增加",
"添加",
"加",
"加一个",
"加个",
"新建",
"创建",
"建立",
"立项",
"接入",
"修复",
"优化",
"改成",
"改为",
"更新",
"分配",
"指派",
"归档",
"执行",
)
@staticmethod
def _looks_like_negated_write_statement(*, text: str) -> bool:
normalized = re.sub(r"\s+", "", str(text or "")).lower()
if not normalized:
return False
negation_tokens = ("别", "别再", "别乱", "不要", "不要再", "先别", "先不要", "禁止", "不能", "无需", "不用")
if not any(token in normalized for token in negation_tokens):
return False
write_tokens = ("创建", "新建", "新增", "增加", "添加", "加", "接入", "执行", "分配", "指派", "立项")
object_tokens = ("信号", "需求", "任务", "项目", "事项", "工单", "流程", "信号源")
if any(w in normalized for w in write_tokens) and any(o in normalized for o in object_tokens):
return True
return bool(
re.search(
r"(?:别|不要|先别|先不要|禁止|不能).{0,8}(?:创建|新建|新增|增加|添加|加|接入|执行|分配|指派|立项)",
normalized,
re.IGNORECASE,
)
)
@staticmethod @staticmethod
def resolve(*, text: str, project_id: Optional[int]) -> CanonicalIntent: def resolve(*, text: str, project_id: Optional[int]) -> CanonicalIntent:
pretty = re.sub(r"\s+", " ", str(text or "")).strip() pretty = re.sub(r"\s+", " ", str(text or "")).strip()
normalized = re.sub(r"\s+", "", str(text or "")).lower()
if not pretty: if not pretty:
return CanonicalIntent(INTENT_IGNORE, 1.0, project_id, "", "", {"reason": "empty"}) return CanonicalIntent(INTENT_IGNORE, 1.0, project_id, "", "", {"reason": "empty"})
bundle = WorkflowSemanticPlanner.plan_message( bundle = WorkflowSemanticPlanner.plan_message(
@ -61,17 +17,9 @@ class IntentResolver:
metadata={"project_id": project_id}, metadata={"project_id": project_id},
) )
workflow_types = [item.workflow_type for item in bundle.workflows] workflow_types = [item.workflow_type for item in bundle.workflows]
if IntentResolver._is_smalltalk_message(pretty=pretty, normalized=normalized): diagnostics = bundle.diagnostics if isinstance(bundle.diagnostics, dict) else {}
if str(diagnostics.get("message_intent") or "").strip().lower() == "casual_chat":
return CanonicalIntent(INTENT_IGNORE, 0.95, project_id, "", "", {"reason": "smalltalk"}) return CanonicalIntent(INTENT_IGNORE, 0.95, project_id, "", "", {"reason": "smalltalk"})
if IntentResolver._looks_like_negated_write_statement(text=pretty):
return CanonicalIntent(
INTENT_QUERY,
0.9,
project_id,
"",
pretty[:300],
{"reason": "negated_write_statement"},
)
explicit_followup_target = FOLLOWUP_WORK_ITEM_ID_RE.search(pretty) explicit_followup_target = FOLLOWUP_WORK_ITEM_ID_RE.search(pretty)
if explicit_followup_target is not None: if explicit_followup_target is not None:
return CanonicalIntent( return CanonicalIntent(
@ -86,10 +34,6 @@ class IntentResolver:
"semantic_bundle": bundle.to_dict(), "semantic_bundle": bundle.to_dict(),
}, },
) )
if any(token in pretty for token in FOLLOWUP_CONFIRM_HINTS):
return CanonicalIntent(INTENT_FOLLOWUP_CONFIRM, 0.82, project_id, "", "", {})
if any(token in pretty for token in FOLLOWUP_CONTINUE_HINTS):
return CanonicalIntent(INTENT_FOLLOWUP_CONTINUE, 0.82, project_id, "", "", {})
if workflow_types and workflow_types[0] == "followup_flow": if workflow_types and workflow_types[0] == "followup_flow":
followup_action = bundle.workflows[0].workflow_action followup_action = bundle.workflows[0].workflow_action
followup_intent = INTENT_FOLLOWUP_CONFIRM if followup_action == "confirm" else INTENT_FOLLOWUP_CONTINUE followup_intent = INTENT_FOLLOWUP_CONFIRM if followup_action == "confirm" else INTENT_FOLLOWUP_CONTINUE
@ -104,7 +48,7 @@ class IntentResolver:
"semantic_bundle": bundle.to_dict(), "semantic_bundle": bundle.to_dict(),
}, },
) )
if WorkflowSemanticPlanner.looks_like_signal_source_request(pretty): if "signal_source_flow" in workflow_types:
return CanonicalIntent( return CanonicalIntent(
INTENT_SIGNAL_SOURCE_ONBOARD, INTENT_SIGNAL_SOURCE_ONBOARD,
0.92, 0.92,
@ -151,7 +95,7 @@ class IntentResolver:
), ),
}, },
) )
if "query_flow" in workflow_types or IntentResolver._looks_like_query(text=pretty): if "query_flow" in workflow_types:
return CanonicalIntent( return CanonicalIntent(
INTENT_QUERY, INTENT_QUERY,
0.88, 0.88,
@ -169,22 +113,6 @@ class IntentResolver:
{"fallback": True, "reason": "default_query_fallback", "semantic_bundle": bundle.to_dict()}, {"fallback": True, "reason": "default_query_fallback", "semantic_bundle": bundle.to_dict()},
) )
@staticmethod
def _looks_like_signal_create_request(*, text: str) -> bool: # semantic-lint: ignore - compat wrapper over workflow_semantic_planner
bundle = WorkflowSemanticPlanner.plan_message(
message_text=str(text or ""),
source_system="compat_intent_resolver",
)
return any(item.workflow_type == "project_signal_flow" for item in bundle.workflows)
@staticmethod
def _looks_like_task_create_request(*, text: str) -> bool: # semantic-lint: ignore - compat wrapper over workflow_semantic_planner
bundle = WorkflowSemanticPlanner.plan_message(
message_text=str(text or ""),
source_system="compat_intent_resolver",
)
return any(item.workflow_type == "task_flow" for item in bundle.workflows)
@staticmethod @staticmethod
def _extract_task_title(text: str) -> str: def _extract_task_title(text: str) -> str:
cleaned = re.sub(r"\s+", " ", str(text or "")).strip() cleaned = re.sub(r"\s+", " ", str(text or "")).strip()
@ -215,27 +143,6 @@ class IntentResolver:
return "" return ""
return fallback[:120] return fallback[:120]
@staticmethod
def _is_smalltalk_message(*, pretty: str, normalized: str) -> bool:
_ = normalized
return WorkflowSemanticPlanner._is_smalltalk_message(pretty)
@staticmethod
def _looks_like_query(*, text: str) -> bool: # semantic-lint: ignore - compat wrapper over workflow_semantic_planner
bundle = WorkflowSemanticPlanner.plan_message(
message_text=str(text or ""),
source_system="compat_intent_resolver",
)
return len(bundle.workflows) == 1 and bundle.workflows[0].workflow_type == "query_flow"
@staticmethod
def _looks_like_project_create_request(*, text: str) -> bool: # semantic-lint: ignore - compat wrapper over workflow_semantic_planner
bundle = WorkflowSemanticPlanner.plan_message(
message_text=str(text or ""),
source_system="compat_intent_resolver",
)
return any(item.workflow_type == "new_project_flow" for item in bundle.workflows)
@staticmethod @staticmethod
def _build_signal_title(text: str, *, default: str) -> str: def _build_signal_title(text: str, *, default: str) -> str:
raw = str(text or "").strip() raw = str(text or "").strip()

View File

@ -1,7 +1,6 @@
from __future__ import annotations from __future__ import annotations
from .models import * from .models import *
from ..workflow_semantic_planner import WorkflowSemanticPlanner
class RouteResolver: class RouteResolver:
"""Map canonical intent + text into workflow route.""" """Map canonical intent + text into workflow route."""
@ -58,7 +57,7 @@ class RouteResolver:
return 1 return 1
@staticmethod @staticmethod
def looks_like_project_signal_discover(*, message_text: str, metadata: dict[str, Any]) -> bool: # semantic-lint: ignore - compat route helper over planner metadata def requests_project_signal_discover_route(*, message_text: str, metadata: dict[str, Any]) -> bool:
explicit_route = str(metadata.get("workflow_route") or "").strip().lower() if isinstance(metadata, dict) else "" explicit_route = str(metadata.get("workflow_route") or "").strip().lower() if isinstance(metadata, dict) else ""
if explicit_route == ROUTE_PROJECT_SIGNAL_DISCOVER: if explicit_route == ROUTE_PROJECT_SIGNAL_DISCOVER:
return True return True
@ -66,17 +65,20 @@ class RouteResolver:
return explicit_action == ROUTE_ACTION_DISCOVER return explicit_action == ROUTE_ACTION_DISCOVER
@staticmethod @staticmethod
def parse_followup_action( def resolve_followup_route_action(
*, *,
metadata: Optional[dict[str, Any]] = None, metadata: Optional[dict[str, Any]] = None,
canonical_intent: str, canonical_intent: str,
message_text: str = "", message_text: str = "",
) -> str: ) -> str:
return WorkflowSemanticPlanner.parse_followup_action( _ = message_text
metadata=metadata, payload = metadata if isinstance(metadata, dict) else {}
canonical_intent=canonical_intent, explicit_action = str(payload.get("followup_action") or payload.get("action") or "").strip().lower()
message_text=message_text, if explicit_action in {"continue", "confirm", "commit", "cancel", "refresh"}:
) return explicit_action
if canonical_intent == INTENT_FOLLOWUP_CONFIRM:
return ROUTE_ACTION_CONFIRM
return ROUTE_ACTION_CONTINUE
@classmethod @classmethod
def from_canonical( def from_canonical(
@ -91,7 +93,7 @@ class RouteResolver:
if not route_key: if not route_key:
route_key = LEGACY_INTENT_ROUTE_MAP.get(canonical_intent.name, ROUTE_QUERY_ANSWER) route_key = LEGACY_INTENT_ROUTE_MAP.get(canonical_intent.name, ROUTE_QUERY_ANSWER)
if canonical_intent.name == INTENT_SIGNAL_CREATE: if canonical_intent.name == INTENT_SIGNAL_CREATE:
if cls.looks_like_project_signal_discover(message_text=message_text, metadata=metadata): if cls.requests_project_signal_discover_route(message_text=message_text, metadata=metadata):
route_key = ROUTE_PROJECT_SIGNAL_DISCOVER route_key = ROUTE_PROJECT_SIGNAL_DISCOVER
elif route_key not in {ROUTE_PROJECT_SIGNAL_CREATE, ROUTE_PROJECT_SIGNAL_DISCOVER}: elif route_key not in {ROUTE_PROJECT_SIGNAL_CREATE, ROUTE_PROJECT_SIGNAL_DISCOVER}:
route_key = ROUTE_PROJECT_SIGNAL_CREATE route_key = ROUTE_PROJECT_SIGNAL_CREATE
@ -99,7 +101,7 @@ class RouteResolver:
if route_key != ROUTE_TASK_CREATE: if route_key != ROUTE_TASK_CREATE:
route_key = ROUTE_TASK_CREATE route_key = ROUTE_TASK_CREATE
elif canonical_intent.name in {INTENT_FOLLOWUP_CONFIRM, INTENT_FOLLOWUP_CONTINUE}: elif canonical_intent.name in {INTENT_FOLLOWUP_CONFIRM, INTENT_FOLLOWUP_CONTINUE}:
action = cls.parse_followup_action( action = cls.resolve_followup_route_action(
metadata={**metadata, "followup_context_present": True}, metadata={**metadata, "followup_context_present": True},
canonical_intent=canonical_intent.name, canonical_intent=canonical_intent.name,
message_text=message_text, message_text=message_text,

View File

@ -8,22 +8,10 @@ from typing import Any, Optional
from ....utils.project_command_utils import extract_project_create_title from ....utils.project_command_utils import extract_project_create_title
from ...signal.signal_source_orchestration import SignalSourceOrchestrationService from ...signal.signal_source_orchestration import SignalSourceOrchestrationService
from ..workflow_semantic_planner.constants import (
FOLLOWUP_CANCEL_HINTS, FOLLOWUP_WORK_ITEM_ID_RE = re.compile(
FOLLOWUP_COMMIT_HINTS, r"(?:work[_ -]?item|工作项|队列项|草稿)\s*(?:id|ID)?\s*[:#:]?\s*(\d+)",
FOLLOWUP_CONFIRM_HINTS, re.IGNORECASE,
FOLLOWUP_CONTINUE_HINTS,
FOLLOWUP_REFRESH_HINTS,
FOLLOWUP_WORK_ITEM_ID_RE,
PROJECT_CREATE_HINTS,
PROJECT_SIGNAL_DISCOVER_HINTS,
QUESTION_HINTS,
SHORT_CONFIRM_HINTS,
SIGNAL_CREATE_HINTS,
SMALLTALK_EXACT,
SMALLTALK_SHORT_HINTS,
SMALLTALK_STRUCTURED_HINTS,
TASK_CREATE_HINTS,
) )
INTENT_QUERY = "query.read_only" INTENT_QUERY = "query.read_only"
@ -232,8 +220,6 @@ LEGACY_INTENT_ROUTE_MAP: dict[str, str] = {
"signal_source.onboard": ROUTE_SIGNAL_SOURCE_ONBOARD, "signal_source.onboard": ROUTE_SIGNAL_SOURCE_ONBOARD,
} }
SESSION_SHORT_CONFIRM_WINDOW_SECONDS = 5 * 60
SHORT_CONFIRM_MAX_MESSAGE_LENGTH = 10
INTENT_LLM_FUNCTION_NAME = "classify_intent_ai_pmo_kernel" INTENT_LLM_FUNCTION_NAME = "classify_intent_ai_pmo_kernel"
INTENT_CANDIDATES = ( INTENT_CANDIDATES = (
INTENT_SIGNAL_SOURCE_ONBOARD, INTENT_SIGNAL_SOURCE_ONBOARD,

View File

@ -7,13 +7,6 @@ import re
from dataclasses import dataclass from dataclasses import dataclass
from typing import Iterable, Sequence from typing import Iterable, Sequence
from ..workflow_semantic_planner.constants import (
BACKGROUND_MARKERS,
CORRECTION_ACTION_PATTERNS,
INTENT_GROUP_KEYWORDS,
MULTI_SEGMENT_MARKERS,
)
INTENT_FUNCTION_SIMPLE = "classify_intent_ai_pmo_kernel" INTENT_FUNCTION_SIMPLE = "classify_intent_ai_pmo_kernel"
INTENT_FUNCTION_COMPLEX = "classify_intent_complex" INTENT_FUNCTION_COMPLEX = "classify_intent_complex"
@ -33,10 +26,6 @@ def _normalize_text(text: str) -> str:
return re.sub(r"\s+", "", str(text or "")).lower() return re.sub(r"\s+", "", str(text or "")).lower()
def _normalize_name(name: str) -> str:
return re.sub(r"[\s_\-]+", "", str(name or "")).lower()
@dataclass(slots=True) @dataclass(slots=True)
class IntentRouteDecision: class IntentRouteDecision:
function_name: str function_name: str
@ -59,52 +48,9 @@ class CorrectionDecision:
confidence: float confidence: float
matched_keywords: list[str] matched_keywords: list[str]
def _count_intent_groups(text: str) -> int:
normalized = _normalize_text(text)
hits = 0
for tokens in INTENT_GROUP_KEYWORDS.values():
if any(_normalize_name(token) in normalized for token in tokens):
hits += 1
return hits
def is_complex_instruction(text: str, *, project_names: Sequence[str] | None = None) -> tuple[bool, list[str]]: def is_complex_instruction(text: str, *, project_names: Sequence[str] | None = None) -> tuple[bool, list[str]]:
normalized = _normalize_text(text) _ = (text, project_names)
if not normalized: return False, []
return False, []
reasons: list[str] = []
length_threshold = int(_env_float("AGENT_COMPLEX_LENGTH_THRESHOLD", 20))
if len(normalized) > length_threshold:
reasons.append("length_gt_threshold")
has_multi_segment = any(marker in text for marker in MULTI_SEGMENT_MARKERS)
if has_multi_segment:
reasons.append("multi_segment")
has_background = any(marker in text for marker in BACKGROUND_MARKERS)
if has_background:
reasons.append("has_background")
intent_group_hits = _count_intent_groups(text)
if intent_group_hits >= 3 or (
intent_group_hits >= 2
and (
len(normalized) > max(14, length_threshold)
or has_multi_segment
or has_background
)
):
reasons.append("multi_intent_groups")
for name in project_names or []:
candidate = _normalize_name(name)
if len(candidate) >= 2 and candidate in normalized:
reasons.append("contains_project_name")
break
return bool(reasons), reasons
def select_intent_route(text: str, *, project_names: Sequence[str] | None = None) -> IntentRouteDecision: def select_intent_route(text: str, *, project_names: Sequence[str] | None = None) -> IntentRouteDecision:
@ -125,19 +71,7 @@ def select_intent_route(text: str, *, project_names: Sequence[str] | None = None
def resolve_correction(text: str) -> CorrectionDecision: def resolve_correction(text: str) -> CorrectionDecision:
normalized = _normalize_text(text) _ = text
if not normalized:
return CorrectionDecision(action="none", confidence=0.0, matched_keywords=[])
for action, tokens in CORRECTION_ACTION_PATTERNS:
matched = [token for token in tokens if _normalize_name(token) in normalized]
if matched:
confidence = 0.9 if len(matched) >= 2 else 0.78
return CorrectionDecision(
action=action,
confidence=float(min(1.0, confidence)),
matched_keywords=matched,
)
return CorrectionDecision(action="none", confidence=0.0, matched_keywords=[]) return CorrectionDecision(action="none", confidence=0.0, matched_keywords=[])

View File

@ -97,10 +97,6 @@ from ..ai_pmo_intent import (
IntentResolver, IntentResolver,
RouteResolver, RouteResolver,
) )
from ..workflow_semantic_planner.constants import (
GENERIC_SIGNAL_SOURCE_MESSAGE_RE,
MODULE_KEYWORD_RULES,
)
from ...ai_steward_service import AIStewardService from ...ai_steward_service import AIStewardService
from ...llm_service import llm_service from ...llm_service import llm_service
from ...model.model_context_bundle import ModelContextBundleService from ...model.model_context_bundle import ModelContextBundleService

View File

@ -68,7 +68,7 @@ class TypedExecutorParseMixin:
return "".join(str(value or "").split()).strip().lower() return "".join(str(value or "").split()).strip().lower()
@classmethod @classmethod
def _looks_like_raw_instruction(cls, *, candidate: str, source: str) -> bool: def _candidate_echoes_source_instruction(cls, *, candidate: str, source: str) -> bool:
normalized_candidate = cls._compact_text(candidate) normalized_candidate = cls._compact_text(candidate)
normalized_source = cls._compact_text(source) normalized_source = cls._compact_text(source)
if not normalized_candidate or not normalized_source: if not normalized_candidate or not normalized_source:
@ -225,9 +225,9 @@ class TypedExecutorParseMixin:
summary = cls._normalize_short_text(row.get("summary"), max_len=300) summary = cls._normalize_short_text(row.get("summary"), max_len=300)
content = cls._normalize_short_text(row.get("content"), max_len=1800) content = cls._normalize_short_text(row.get("content"), max_len=1800)
if cls._looks_like_raw_instruction(candidate=title, source=cleaned_message): if cls._candidate_echoes_source_instruction(candidate=title, source=cleaned_message):
title = "" title = ""
if cls._looks_like_raw_instruction(candidate=summary, source=cleaned_message): if cls._candidate_echoes_source_instruction(candidate=summary, source=cleaned_message):
summary = "" summary = ""
if not summary: if not summary:

View File

@ -61,18 +61,15 @@ class KernelClosureDataMixin:
if not question or not answer: if not question or not answer:
return -1.0 return -1.0
score = 0.0 score = 0.0
merged = f"{question} {answer}"
if "信号源" in merged:
score += 3.0
if any(token in merged for token in ("模块", "后端", "前端", "流程", "SSTC", "任务", "执行", "接口")):
score += 2.0
if any(token in answer for token in ("暂时无法生成可靠回答", "模型服务异常", "请稍后重试")): if any(token in answer for token in ("暂时无法生成可靠回答", "模型服务异常", "请稍后重试")):
score -= 5.0 score -= 5.0
if normalized_message and cls._is_generic_signal_source_message(normalized_message): if normalized_message:
if "信号源" in question: question_norm = cls._normalize_dialogue_text(question, max_len=500)
answer_norm = cls._normalize_dialogue_text(answer, max_len=900)
if question_norm and (normalized_message in question_norm or question_norm in normalized_message):
score += 1.5 score += 1.5
if "项目" in answer: if answer_norm and (normalized_message in answer_norm or answer_norm in normalized_message):
score += 1.0 score += 0.8
created_at = cls._parse_datetime_like(getattr(row, "created_at", None)) created_at = cls._parse_datetime_like(getattr(row, "created_at", None))
if reference_at is not None and created_at is not None: if reference_at is not None and created_at is not None:
gap_seconds = abs((reference_at - created_at).total_seconds()) gap_seconds = abs((reference_at - created_at).total_seconds())

View File

@ -13,21 +13,13 @@ for _name, _value in vars(_common).items():
class KernelClosurePayloadMixin: class KernelClosurePayloadMixin:
@staticmethod @staticmethod
def _is_generic_signal_source_message(text: str) -> bool: def _is_generic_signal_source_message(text: str) -> bool:
compact = "".join(str(text or "").split()).strip().lower() _ = text
if not compact: return False
return False
if bool(GENERIC_SIGNAL_SOURCE_MESSAGE_RE.match(compact)):
return True
return len(compact) <= 24 and "信号源" in compact and ("这个" in compact or "步骤" in compact)
@classmethod @classmethod
def _extract_implementation_modules_from_text(cls, text: str) -> list[str]: def _extract_implementation_modules_from_text(cls, text: str) -> list[str]:
normalized = str(text or "").lower() _ = (cls, text)
modules: list[str] = [] return []
for module_name, keywords in MODULE_KEYWORD_RULES:
if any(str(keyword).lower() in normalized for keyword in keywords):
modules.append(module_name)
return modules[:6]
@staticmethod @staticmethod
def _build_closure_dialogue_excerpt(turns: list[dict[str, str]]) -> str: def _build_closure_dialogue_excerpt(turns: list[dict[str, str]]) -> str:
@ -72,11 +64,6 @@ class KernelClosurePayloadMixin:
return f"机器人先提示条件不足,用户随后继续要求执行:{latest_user[:120]}" return f"机器人先提示条件不足,用户随后继续要求执行:{latest_user[:120]}"
return "机器人提示条件不足,需补齐项目归属后继续执行信号源接入。" return "机器人提示条件不足,需补齐项目归属后继续执行信号源接入。"
if latest_user and cls._is_generic_signal_source_message(latest_user) and modules:
if len(modules) == 1 and str(modules[0]).strip() == "信号源接入":
return "用户要求将需求按步骤接入信号源,需要先补齐项目归属再继续执行。"
return f"同一消息闭环内,用户要求将需求按步骤接入信号源,重点模块:{'、'.join(modules[:4])}。"
if modules: if modules:
return f"用户在本次消息闭环中推进信号源接入,涉及模块:{'、'.join(modules[:4])}。" return f"用户在本次消息闭环中推进信号源接入,涉及模块:{'、'.join(modules[:4])}。"
@ -136,7 +123,6 @@ class KernelClosurePayloadMixin:
normalized_source_system == "feishu" normalized_source_system == "feishu"
and ( and (
(not self._turns_have_assistant(turns)) (not self._turns_have_assistant(turns))
or (self._is_generic_signal_source_message(normalized_message) and len(turns) <= 2)
) )
) )
if needs_semantic_turns: if needs_semantic_turns:

View File

@ -121,7 +121,7 @@ class KernelFollowupRoutingMixin:
workflow_route: WorkflowRoute, workflow_route: WorkflowRoute,
) -> tuple[dict[str, Any], dict[str, Any]]: ) -> tuple[dict[str, Any], dict[str, Any]]:
explicit_target_id = self._extract_followup_work_item_id(message_text) explicit_target_id = self._extract_followup_work_item_id(message_text)
followup_action = RouteResolver.parse_followup_action( followup_action = RouteResolver.resolve_followup_route_action(
message_text=message_text, message_text=message_text,
canonical_intent=intent.name, canonical_intent=intent.name,
) )

View File

@ -70,7 +70,7 @@ class KernelIngressRenderMixin:
direct_reply_text = f"{mixed_sidecar_reply}\n\n{direct_reply_text}" direct_reply_text = f"{mixed_sidecar_reply}\n\n{direct_reply_text}"
else: else:
direct_reply_text = mixed_sidecar_reply direct_reply_text = mixed_sidecar_reply
if _looks_like_upstream_timeout_message(direct_reply_text): if _is_upstream_timeout_message(direct_reply_text):
logger.warning( logger.warning(
"ai_pmo_upstream_timeout_reply_detected", "ai_pmo_upstream_timeout_reply_detected",
tenant_id=int(self.tenant_id), tenant_id=int(self.tenant_id),
@ -288,8 +288,6 @@ class KernelIngressRenderMixin:
has_webhook = "webhook" in normalized has_webhook = "webhook" in normalized
if not (has_long_connection and has_webhook): if not (has_long_connection and has_webhook):
return None return None
if not any(token in normalized for token in TRANSPORT_MODE_CORRECTION_HINTS):
return None
return "已按长连接模式处理飞书接入,当前不会走 webhook;这条说明不会再被当成信号源类型变更。" return "已按长连接模式处理飞书接入,当前不会走 webhook;这条说明不会再被当成信号源类型变更。"
@staticmethod @staticmethod

View File

@ -21,15 +21,17 @@ class KernelIntentLLMMixin:
rule_intent: Optional[CanonicalIntent] = None, rule_intent: Optional[CanonicalIntent] = None,
session_last_intent_override: Optional[str] = None, session_last_intent_override: Optional[str] = None,
session_project_id_override: Optional[int] = None, session_project_id_override: Optional[int] = None,
inherit_session_context: bool = True,
) -> Optional[CanonicalIntent]: ) -> Optional[CanonicalIntent]:
session_last_intent = ( if session_last_intent_override is not None:
str(session_last_intent_override or "").strip() session_last_intent = str(session_last_intent_override or "").strip()
if session_last_intent_override is not None elif inherit_session_context and session is not None:
else str(getattr(session, "last_intent", "") or "").strip() if session is not None else "" session_last_intent = str(getattr(session, "last_intent", "") or "").strip()
) else:
session_last_intent = ""
if session_project_id_override is not None: if session_project_id_override is not None:
session_project_id = _safe_int(session_project_id_override) session_project_id = _safe_int(session_project_id_override)
elif session is not None: elif inherit_session_context and session is not None:
session_project_id = _safe_int(getattr(session, "current_project_id", None)) session_project_id = _safe_int(getattr(session, "current_project_id", None))
else: else:
session_project_id = None session_project_id = None

View File

@ -11,26 +11,6 @@ for _name, _value in vars(_common).items():
class KernelIntentResolveMixin: class KernelIntentResolveMixin:
_QUERY_FOLLOWUP_TOKENS = (
"需求",
"mvp",
"方案",
"对应",
"这个",
"它",
"给我",
"补充",
"展开",
"细化",
"重答",
"重新回答",
"高级模型",
"更强模型",
"分析",
"评审",
"怎么做",
)
@staticmethod @staticmethod
def _within_recent_window(last_active_at: Optional[datetime], *, window_seconds: int) -> bool: def _within_recent_window(last_active_at: Optional[datetime], *, window_seconds: int) -> bool:
if not isinstance(last_active_at, datetime): if not isinstance(last_active_at, datetime):
@ -38,12 +18,10 @@ class KernelIntentResolveMixin:
delta = _utc_now_naive() - last_active_at delta = _utc_now_naive() - last_active_at
return 0 <= delta.total_seconds() <= float(window_seconds) return 0 <= delta.total_seconds() <= float(window_seconds)
@classmethod @staticmethod
def _looks_like_query_scene_followup(cls, message_text: str) -> bool: def _is_query_context_intent(intent_name: str) -> bool:
normalized = re.sub(r"\s+", "", str(message_text or "")).lower() normalized = str(intent_name or "").strip().lower()
if not normalized: return normalized in {INTENT_QUERY, ROUTE_QUERY_ANSWER}
return False
return any(token in normalized for token in cls._QUERY_FOLLOWUP_TOKENS)
@classmethod @classmethod
def _should_inherit_recent_feishu_session_context( def _should_inherit_recent_feishu_session_context(
@ -52,21 +30,12 @@ class KernelIntentResolveMixin:
message_text: str, message_text: str,
session: Optional[ConversationSession], session: Optional[ConversationSession],
) -> bool: ) -> bool:
if session is None: _ = (cls, message_text, session)
return False # OpenCow-style object binding does not allow bare recent-session query
if WorkflowSemanticPlanner.looks_like_explicit_new_requirement_topic(message_text): # inheritance without an explicit bound object. Query context may still
return False # flow through reply anchors, but no longer through no-anchor token
sender = str(getattr(session, "sender", "") or "").strip().lower() # heuristics on the latest message text.
if not sender.startswith("feishu:"): return False
return False
if not cls._within_recent_window(
getattr(session, "last_active_at", None),
window_seconds=SESSION_PROJECT_INHERIT_WINDOW_SECONDS,
):
return False
if not str(getattr(session, "last_intent", "") or "").strip():
return False
return cls._looks_like_query_scene_followup(message_text)
async def _resolve_intent( async def _resolve_intent(
self, self,
@ -86,28 +55,6 @@ class KernelIntentResolveMixin:
message_text=message_text, message_text=message_text,
session=session, session=session,
) )
if self._is_short_confirmation_message(
message_text=message_text,
session=session,
message_sent_at=message_sent_at,
session_snapshot=session_snapshot,
reply_anchor_present=reply_anchor_present,
) and session is not None:
inherited = self._build_short_confirm_intent(
message_text=message_text,
session=session,
world_model=world_model,
session_snapshot=session_snapshot,
)
logger.info(
"ai_pmo_short_confirm_inherited",
tenant_id=self.tenant_id,
sender=str(getattr(session, "sender", "") or ""),
inherited_from=str(getattr(session, "last_intent", "") or ""),
inherited_intent=str(inherited.name or ""),
project_id=_safe_int(getattr(session, "current_project_id", None)) or _safe_int(world_model.get("resolved_project_id")),
)
return inherited, "session_inherited"
# 模型唯一入口:不再使用规则/正则意图分流。 # 模型唯一入口:不再使用规则/正则意图分流。
if not self._intent_llm_enabled(): if not self._intent_llm_enabled():
@ -124,6 +71,7 @@ class KernelIntentResolveMixin:
session_project_id_override=( session_project_id_override=(
_safe_int(getattr(session, "current_project_id", None)) if inherit_session_context else None _safe_int(getattr(session, "current_project_id", None)) if inherit_session_context else None
), ),
inherit_session_context=inherit_session_context,
) )
if llm_intent is None: if llm_intent is None:
raise RuntimeError("intent_model_only_no_valid_llm_intent") raise RuntimeError("intent_model_only_no_valid_llm_intent")

View File

@ -68,10 +68,6 @@ class KernelIntentRulesMixin:
or any(token in normalized for token in ("怎么", "如何", "为何", "为什么", "是什么", "有哪些", "吗", "呢", "是否", "是不是")) or any(token in normalized for token in ("怎么", "如何", "为何", "为什么", "是什么", "有哪些", "吗", "呢", "是否", "是不是"))
) )
@classmethod
def _looks_like_contextual_write_confirmation(cls, *, message_text: str) -> bool: # semantic-lint: ignore - compat wrapper over workflow_semantic_planner
return WorkflowSemanticPlanner.looks_like_contextual_write_confirmation(message_text)
def _recover_session_followup_write_intent( def _recover_session_followup_write_intent(
self, self,
*, *,
@ -80,47 +76,13 @@ class KernelIntentRulesMixin:
world_model: dict[str, Any], world_model: dict[str, Any],
rule_intent: CanonicalIntent, rule_intent: CanonicalIntent,
) -> Optional[CanonicalIntent]: ) -> Optional[CanonicalIntent]:
if session is None: _ = (message_text, session, world_model, rule_intent)
return None # OpenCow-style routing requires object binding before any write action.
if rule_intent.name != INTENT_QUERY: # This compat recovery path used to elevate low-context session followups
return None # into write intents without a bound object, which is exactly the class of
if not self._within_recent_window( # bug we are removing. Keep the method as an inert compat shim so callers
getattr(session, "last_active_at", None), # don't break, but never recover a write intent from session state alone.
window_seconds=SESSION_SHORT_CONFIRM_WINDOW_SECONDS, return None
):
return None
if not (self._is_default_rule_query(rule_intent) or float(rule_intent.confidence or 0.0) <= 0.7):
return None
if not self._looks_like_contextual_write_confirmation(message_text=message_text):
return None
inherited_name = str(getattr(session, "last_intent", "") or "").strip()
inherited_text = str(getattr(session, "last_message_text", "") or "").strip()
if inherited_name not in WRITE_INTENTS and inherited_text:
inherited_project_id = _safe_int(getattr(session, "current_project_id", None))
inferred = self.intent_resolver.resolve(text=inherited_text, project_id=inherited_project_id)
if inferred.name in WRITE_INTENTS:
inherited_name = inferred.name
if inherited_name not in WRITE_INTENTS:
return None
project_id = _safe_int(getattr(session, "current_project_id", None)) or _safe_int(world_model.get("resolved_project_id"))
if project_id is not None and int(project_id) <= 0:
project_id = None
canonical_text = inherited_text or message_text
return self._build_canonical_intent(
intent_name=inherited_name,
message_text=canonical_text,
project_id=project_id,
confidence=0.84,
metadata={
"context_source": "session_write_recovered",
"inherited_from_intent": str(getattr(session, "last_intent", "") or "").strip(),
"trigger_message": re.sub(r"\s+", " ", str(message_text or "")).strip()[:120],
},
)
@staticmethod @staticmethod
def _has_required_slots(*, intent: CanonicalIntent, message_text: str) -> tuple[bool, dict[str, Any]]: def _has_required_slots(*, intent: CanonicalIntent, message_text: str) -> tuple[bool, dict[str, Any]]:
@ -223,40 +185,6 @@ class KernelIntentRulesMixin:
arbitration_llm_confidence=float(llm_intent.confidence or 0.0), arbitration_llm_confidence=float(llm_intent.confidence or 0.0),
), source ), source
@classmethod
def _is_short_confirmation_message(
cls,
*,
message_text: str,
session: Optional[ConversationSession],
message_sent_at: Optional[Any] = None,
session_snapshot: Optional[dict[str, Any]] = None,
reply_anchor_present: bool = False,
) -> bool:
if not reply_anchor_present:
return False
snapshot = session_snapshot if isinstance(session_snapshot, dict) else {}
if session is None and not snapshot:
return False
last_active_at = cls._coerce_naive_datetime(snapshot.get("last_active_at"))
if last_active_at is None and session is not None:
last_active_at = cls._coerce_naive_datetime(getattr(session, "last_active_at", None))
sent_at = cls._coerce_naive_datetime(message_sent_at) or _utc_now_naive()
if last_active_at is None:
return False
delta = sent_at - last_active_at
if not (0 <= delta.total_seconds() <= float(SESSION_SHORT_CONFIRM_WINDOW_SECONDS)):
return False
last_intent = str(snapshot.get("last_intent") or (getattr(session, "last_intent", "") if session is not None else "") or "").strip()
if not last_intent:
return False
normalized = re.sub(r"\s+", "", str(message_text or "")).lower()
if not normalized:
return False
if len(normalized) > SHORT_CONFIRM_MAX_MESSAGE_LENGTH:
return False
return any(token in normalized for token in SHORT_CONFIRM_HINTS)
@staticmethod @staticmethod
def _coerce_naive_datetime(value: Any) -> Optional[datetime]: def _coerce_naive_datetime(value: Any) -> Optional[datetime]:
if isinstance(value, datetime): if isinstance(value, datetime):
@ -343,39 +271,3 @@ class KernelIntentRulesMixin:
extras, extras,
) )
return CanonicalIntent(INTENT_IGNORE, safe_confidence, project_id, "", "", extras or {"reason": "unsupported_intent"}) return CanonicalIntent(INTENT_IGNORE, safe_confidence, project_id, "", "", extras or {"reason": "unsupported_intent"})
def _build_short_confirm_intent(
self,
*,
message_text: str,
session: ConversationSession,
world_model: dict[str, Any],
session_snapshot: Optional[dict[str, Any]] = None,
) -> CanonicalIntent:
snapshot = session_snapshot if isinstance(session_snapshot, dict) else {}
inherited_name = str(snapshot.get("last_intent") or getattr(session, "last_intent", "") or "").strip()
if inherited_name not in INTENT_CANDIDATES:
inherited_name = INTENT_QUERY
snapshot_project_id = _safe_int(snapshot.get("current_project_id"))
inherited_project_id = (
snapshot_project_id
or _safe_int(getattr(session, "current_project_id", None))
or _safe_int(world_model.get("resolved_project_id"))
)
if inherited_project_id is not None and int(inherited_project_id) <= 0:
inherited_project_id = None
inherited_text = (
str(snapshot.get("last_message_text") or "").strip()
or str(getattr(session, "last_message_text", "") or "").strip()
or message_text
)
return self._build_canonical_intent(
intent_name=inherited_name,
message_text=inherited_text,
project_id=inherited_project_id,
confidence=0.86,
metadata={
"context_source": "session_inherited",
"inherited_from_intent": str(snapshot.get("last_intent") or getattr(session, "last_intent", "") or "").strip(),
},
)

View File

@ -3,6 +3,7 @@
from __future__ import annotations from __future__ import annotations
from .... import ai_pmo_kernel_common_core as _common from .... import ai_pmo_kernel_common_core as _common
from ....workflow_semantic_planner import WorkflowSemanticPlanner
from app.utils.thread_context import build_feishu_thread_key from app.utils.thread_context import build_feishu_thread_key
for _name, _value in vars(_common).items(): for _name, _value in vars(_common).items():
@ -19,18 +20,14 @@ class KernelWorldModelSessionMixin:
delta = now - last_active_at delta = now - last_active_at
return 0 <= delta.total_seconds() <= float(window_seconds) return 0 <= delta.total_seconds() <= float(window_seconds)
def _extract_inherited_project_id(self, *, session: Optional[ConversationSession]) -> Optional[int]: def _extract_inherited_project_id(
if session is None: self,
return None *,
session_project_id = _safe_int(getattr(session, "current_project_id", None)) session: Optional[ConversationSession],
if session_project_id is None or int(session_project_id) <= 0: message_text: str = "",
return None ) -> Optional[int]:
if not self._within_recent_window( _ = (session, message_text)
getattr(session, "last_active_at", None), return None
window_seconds=SESSION_PROJECT_INHERIT_WINDOW_SECONDS,
):
return None
return session_project_id
async def _get_or_create_session( async def _get_or_create_session(
self, self,

View File

@ -3,12 +3,6 @@
from __future__ import annotations from __future__ import annotations
from .imports import re from .imports import re
from ..workflow_semantic_planner.constants import (
GENERIC_SIGNAL_SOURCE_MESSAGE_RE,
MODULE_KEYWORD_RULES,
POLICY_GATE_RULES,
TRANSPORT_MODE_CORRECTION_HINTS,
)
STATE_RECEIVED = "received" STATE_RECEIVED = "received"
STATE_MODELED = "modeled" STATE_MODELED = "modeled"
@ -37,3 +31,7 @@ INTENT_LLM_STAGE2_MIN_REMAINING_DEFAULT_SECONDS = 12.0
WORLD_MODEL_STATIC_CACHE_TTL_SECONDS = 10 * 60 WORLD_MODEL_STATIC_CACHE_TTL_SECONDS = 10 * 60
WORLD_MODEL_STATIC_CACHE_KEY_PREFIX = "wm:static" WORLD_MODEL_STATIC_CACHE_KEY_PREFIX = "wm:static"
# OpenCow-style routing no longer relies on centralized semantic token warehouses.
# Keep the policy gate input structural-only unless a caller passes explicit gates.
POLICY_GATE_RULES: tuple[tuple[str, tuple[str, ...]], ...] = ()

View File

@ -2,7 +2,6 @@
from __future__ import annotations from __future__ import annotations
from .constants import TRANSPORT_MODE_CORRECTION_HINTS
from .env import ( from .env import (
env_bool, env_bool,
env_float, env_float,
@ -42,7 +41,7 @@ def _intent_llm_stage2_min_remaining_seconds() -> float:
return intent_llm_stage2_min_remaining_seconds() return intent_llm_stage2_min_remaining_seconds()
def _looks_like_upstream_timeout_message(text: str) -> bool: def _is_upstream_timeout_message(text: str) -> bool:
lowered = str(text or "").strip().lower() lowered = str(text or "").strip().lower()
if not lowered: if not lowered:
return False return False
@ -55,12 +54,8 @@ def _looks_like_upstream_timeout_message(text: str) -> bool:
def _should_force_low_tier_intent_route(*, message_text: str, correction_action: str) -> bool: def _should_force_low_tier_intent_route(*, message_text: str, correction_action: str) -> bool:
if str(correction_action or "").strip().lower() != "none": _ = message_text
return True return str(correction_action or "").strip().lower() != "none"
normalized = re.sub(r"\s+", "", str(message_text or "")).lower()
if not normalized:
return False
return any(token in normalized for token in TRANSPORT_MODE_CORRECTION_HINTS)
def _should_escalate_to_high_intent_route( def _should_escalate_to_high_intent_route(

View File

@ -172,9 +172,6 @@ from ..ai_pmo_intent import (
ROUTE_SIGNAL_SOURCE_ONBOARD, ROUTE_SIGNAL_SOURCE_ONBOARD,
ROUTE_TASK_CREATE, ROUTE_TASK_CREATE,
WORKFLOW_ROUTE_KEYS, WORKFLOW_ROUTE_KEYS,
SESSION_SHORT_CONFIRM_WINDOW_SECONDS,
SHORT_CONFIRM_HINTS,
SHORT_CONFIRM_MAX_MESSAGE_LENGTH,
WRITE_INTENTS, WRITE_INTENTS,
CanonicalIntent, CanonicalIntent,
IntentResolver, IntentResolver,

View File

@ -16,9 +16,6 @@ from app.services.ai_pmo.workflow_semantic_planner.constants import (
from app.services.mounted_ai_runtime_write_service import MountedAIRuntimeWriteService from app.services.mounted_ai_runtime_write_service import MountedAIRuntimeWriteService
class KernelIngressFlowEntryMixin: class KernelIngressFlowEntryMixin:
_MIXED_HINT_CONNECTORS = ("顺便", "另外", "同时", "并且", "还有", "再问")
_MIXED_HINT_QUESTION = ("?", "?", "问下", "多少", "几个", "进展", "状态", "吗", "呢", "如何", "怎么", "有没有")
_MIXED_HINT_REQUIREMENT = ("创建", "新建", "新增", "安排", "执行", "处理", "接入", "录入", "添加")
_RAG_QUERY_SCENE_KEYS = ( _RAG_QUERY_SCENE_KEYS = (
"message_intent", "message_intent",
"review_framework", "review_framework",
@ -32,48 +29,6 @@ class KernelIngressFlowEntryMixin:
QUERY_SCENE_CORRECTION_ESCALATION, QUERY_SCENE_CORRECTION_ESCALATION,
QUERY_SCENE_REQUIREMENT_PLAN, QUERY_SCENE_REQUIREMENT_PLAN,
}) })
_QUERY_SCENE_TOKEN_RULES = (
(
QUERY_SCENE_CORRECTION_ESCALATION,
{
"consultant_mode_preference": "challenge",
"force_thinking_mode": "deep",
},
("不对", "错了", "答错", "纠正", "更正", "重新回答", "重答", "高级模型", "更强模型"),
),
(
QUERY_SCENE_BUSINESS_REVIEW,
{
"review_framework": "five_lenses",
"consultant_mode_preference": "clarify",
},
("分析", "拆解", "评审", "评估", "复盘", "诊断", "怎么做", "mvp", "需求"),
),
(
QUERY_SCENE_BUSINESS_REVIEW,
{
"consultant_mode_preference": "advise",
"advise_subtype": "assignee",
},
("谁来做", "谁负责", "安排谁", "推荐谁", "负责人"),
),
(
QUERY_SCENE_REQUIREMENT_PLAN,
{
"consultant_mode_preference": "clarify",
"topic_switch": "new",
},
("新需求", "新项目", "做个", "想做个", "准备做", "创建需求"),
),
(
QUERY_SCENE_REQUIREMENT_PLAN,
{
"consultant_mode_preference": "clarify",
},
("需求", "方案", "范围", "排期", "补充", "细化", "展开", "mvp"),
),
)
@classmethod @classmethod
def _extract_route_scene_metadata(cls, route_metadata: Optional[dict[str, Any]]) -> dict[str, str]: def _extract_route_scene_metadata(cls, route_metadata: Optional[dict[str, Any]]) -> dict[str, str]:
metadata = route_metadata if isinstance(route_metadata, dict) else {} metadata = route_metadata if isinstance(route_metadata, dict) else {}
@ -86,35 +41,8 @@ class KernelIngressFlowEntryMixin:
@classmethod @classmethod
def _derive_query_scene_metadata(cls, text: str) -> dict[str, Any]: def _derive_query_scene_metadata(cls, text: str) -> dict[str, Any]:
normalized = re.sub(r"\s+", "", str(text or "")).lower()
if not normalized:
return {}
if any(token in normalized for token in ("最近怎么样", "在吗", "你好", "哈哈", "hi", "hello")):
return {
"message_intent": "casual_chat",
"consultant_mode_preference": "brainstorm",
}
for message_intent, extra_fields, tokens in cls._QUERY_SCENE_TOKEN_RULES:
if any(token in normalized for token in tokens):
return {
"message_intent": message_intent,
**extra_fields,
}
return {} return {}
@classmethod
def _looks_like_mixed_candidate(cls, *, text: str) -> bool:
normalized = re.sub(r"\s+", "", str(text or "")).lower()
if not normalized:
return False
has_question = any(token in normalized for token in cls._MIXED_HINT_QUESTION)
has_requirement = any(token in normalized for token in cls._MIXED_HINT_REQUIREMENT)
if not (has_question and has_requirement):
return False
if any(token in normalized for token in cls._MIXED_HINT_CONNECTORS):
return True
return "问" in normalized and "创建" in normalized
@staticmethod @staticmethod
def _supports_mixed_sidecar(*, source_system: str) -> bool: def _supports_mixed_sidecar(*, source_system: str) -> bool:
return str(source_system or "").strip().lower() in {"feishu", "pmflow_manual", "ai_pmo_ui"} return str(source_system or "").strip().lower() in {"feishu", "pmflow_manual", "ai_pmo_ui"}
@ -129,12 +57,8 @@ class KernelIngressFlowEntryMixin:
semantic_bundle: Optional[dict[str, Any]], semantic_bundle: Optional[dict[str, Any]],
) -> dict[str, Any]: ) -> dict[str, Any]:
context = rag_request_context if isinstance(rag_request_context, dict) else {} context = rag_request_context if isinstance(rag_request_context, dict) else {}
derived = cls._derive_query_scene_metadata(message_text) _ = message_text
scene_metadata: dict[str, Any] = { scene_metadata: dict[str, Any] = {}
key: value
for key, value in derived.items()
if key in cls._RAG_QUERY_SCENE_KEYS and value not in (None, "")
}
for key in cls._RAG_QUERY_SCENE_KEYS: for key in cls._RAG_QUERY_SCENE_KEYS:
value = context.get(key) value = context.get(key)
if value in (None, ""): if value in (None, ""):
@ -276,14 +200,15 @@ class KernelIngressFlowEntryMixin:
message_text: str, message_text: str,
project_hint_id: Optional[int], project_hint_id: Optional[int],
) -> tuple[str, dict[str, Any]]: ) -> tuple[str, dict[str, Any]]:
normalized_text = str(message_text or "").strip()
if not self._supports_mixed_sidecar(source_system=source_system): if not self._supports_mixed_sidecar(source_system=source_system):
return str(message_text or "").strip(), {} return normalized_text, {}
if not self._looks_like_mixed_candidate(text=message_text): if not normalized_text:
return str(message_text or "").strip(), {} return normalized_text, {}
rag_service = AdaptiveRAGService(self.db, tenant_id=self.tenant_id) rag_service = AdaptiveRAGService(self.db, tenant_id=self.tenant_id)
try: try:
intent_result = await rag_service.classify_intent(str(message_text or "").strip()) intent_result = await rag_service.classify_intent(normalized_text)
except Exception as exc: except Exception as exc:
logger.warning( logger.warning(
"ai_pmo_mixed_precheck_failed", "ai_pmo_mixed_precheck_failed",
@ -291,14 +216,14 @@ class KernelIngressFlowEntryMixin:
source_system=source_system, source_system=source_system,
error=str(exc), error=str(exc),
) )
return str(message_text or "").strip(), {} return normalized_text, {}
intent_label = str(intent_result.get("intent") or "").strip().lower() intent_label = str(intent_result.get("intent") or "").strip().lower()
if intent_label != "mixed": if intent_label != "mixed":
return str(message_text or "").strip(), {} return normalized_text, {}
question_part = str(intent_result.get("question_part") or "").strip() or str(message_text or "").strip() question_part = str(intent_result.get("question_part") or "").strip() or normalized_text
signal_part = str(intent_result.get("statement_part") or "").strip() or str(message_text or "").strip() signal_part = str(intent_result.get("statement_part") or "").strip() or normalized_text
sidecar_answer = "" sidecar_answer = ""
try: try:
conversation_key = source_message_id conversation_key = source_message_id
@ -645,6 +570,19 @@ class KernelIngressFlowEntryMixin:
ingest_event=ingest_event, ingest_event=ingest_event,
ingress_options=ingress_options, ingress_options=ingress_options,
) )
active_context_followup_result = await self._maybe_handle_active_context_followup(
source_system=source_system,
source_message_id=synthetic_message_id,
channel_ref=channel_ref,
source_sender_open_id=source_sender_open_id,
message_text=clean_text,
thread_context=thread_context,
sender=sender,
force_bind=True,
)
if active_context_followup_result is not None:
await _safe_finalize_mounted_runtime(result=active_context_followup_result)
return active_context_followup_result
if await self._should_route_to_draft_confirmation_session( if await self._should_route_to_draft_confirmation_session(
session=session, session=session,
sender=sender, sender=sender,
@ -727,26 +665,35 @@ class KernelIngressFlowEntryMixin:
source_sender_open_id=source_sender_open_id, source_sender_open_id=source_sender_open_id,
ingress_options=ingress_options, ingress_options=ingress_options,
message_text=clean_text, message_text=clean_text,
session=session,
sender=sender,
) )
if orphan_requirement_followup_result is not None: if orphan_requirement_followup_result is not None:
await _safe_finalize_mounted_runtime(result=orphan_requirement_followup_result) await _safe_finalize_mounted_runtime(result=orphan_requirement_followup_result)
return orphan_requirement_followup_result return orphan_requirement_followup_result
mixed_meta: dict[str, Any] = {} active_context_followup_result = await self._maybe_handle_active_context_followup(
if not self._is_short_confirmation_message( source_system=source_system,
source_message_id=synthetic_message_id,
channel_ref=channel_ref,
source_sender_open_id=source_sender_open_id,
message_text=clean_text, message_text=clean_text,
session=session, thread_context=thread_context,
message_sent_at=message_sent_at, sender=sender,
session_snapshot=session_snapshot, force_bind=False,
): )
clean_text, mixed_meta = await self._prepare_mixed_sidecar_context( if active_context_followup_result is not None:
source_system=source_system, await _safe_finalize_mounted_runtime(result=active_context_followup_result)
source_message_id=synthetic_message_id, return active_context_followup_result
channel_ref=channel_ref, mixed_meta: dict[str, Any] = {}
source_sender_open_id=source_sender_open_id, clean_text, mixed_meta = await self._prepare_mixed_sidecar_context(
thread_context=thread_context, source_system=source_system,
message_text=clean_text, source_message_id=synthetic_message_id,
project_hint_id=project_hint_id, channel_ref=channel_ref,
) source_sender_open_id=source_sender_open_id,
thread_context=thread_context,
message_text=clean_text,
project_hint_id=project_hint_id,
)
perf_work_item_id = int(work_item.id) perf_work_item_id = int(work_item.id)
try: try:
stage_started_at = time.perf_counter() stage_started_at = time.perf_counter()
@ -1307,6 +1254,37 @@ class KernelIngressFlowEntryMixin:
thread_context = cls._extract_thread_context_from_ingress_options(ingress_options) thread_context = cls._extract_thread_context_from_ingress_options(ingress_options)
return bool(str(thread_context.get("reply_to_message_id") or "").strip()) return bool(str(thread_context.get("reply_to_message_id") or "").strip())
@staticmethod
async def _should_skip_no_anchor_draft_routing_by_bundle(
*,
message_text: str,
source_system: str,
) -> bool:
text = str(message_text or "").strip()
if not text:
return False
try:
bundle = await WorkflowSemanticPlanner.aplan_message(
message_text=text,
source_system=str(source_system or "").strip() or "unknown",
metadata={},
)
except Exception:
return False
workflows = list(getattr(bundle, "workflows", []) or [])
if any(
str(getattr(item, "workflow_type", "") or "").strip()
in {"new_project_flow", "project_flow", "task_flow", "signal_source_flow", "project_signal_flow"}
for item in workflows
):
return True
diagnostics = getattr(bundle, "diagnostics", None)
if not isinstance(diagnostics, dict):
return False
message_intent = str(diagnostics.get("message_intent") or "").strip().lower()
topic_switch = str(diagnostics.get("topic_switch") or "").strip().lower()
return message_intent == QUERY_SCENE_REQUIREMENT_PLAN and topic_switch == "new"
@classmethod @classmethod
def _should_route_to_draft_confirmation( def _should_route_to_draft_confirmation(
cls, cls,
@ -1321,31 +1299,19 @@ class KernelIngressFlowEntryMixin:
if DraftConfirmHandler._extract_work_item_id(message_text) is not None: if DraftConfirmHandler._extract_work_item_id(message_text) is not None:
return True return True
thread_context = cls._extract_thread_context_from_ingress_options(ingress_options)
action, _ = DraftConfirmHandler.parse_action_from_text(message_text=message_text)
if str(thread_context.get("reply_to_message_id") or "").strip():
return action in {
DraftConfirmHandler._ACTION_CONFIRM,
DraftConfirmHandler._ACTION_REVISE,
DraftConfirmHandler._ACTION_CANCEL,
} or WorkflowSemanticPlanner.looks_like_natural_draft_revision(message_text)
if WorkflowSemanticPlanner.looks_like_project_create_request(message_text):
return False
if ( if (
WorkflowSemanticPlanner.looks_like_signal_source_request(message_text) str(getattr(session, "last_intent", "") or "").strip() != STATE_DRAFT_PENDING_CONFIRM
or WorkflowSemanticPlanner.looks_like_signal_create_request(message_text) and len(str(message_text or "").strip()) <= 16
or WorkflowSemanticPlanner.looks_like_task_create_request(message_text)
or WorkflowSemanticPlanner.is_legacy_business_command(message_text)
or WorkflowSemanticPlanner.is_query_like_question(message_text)
or WorkflowSemanticPlanner.looks_like_project_lookup_question(message_text)
): ):
return False return False
return ( thread_context = cls._extract_thread_context_from_ingress_options(ingress_options)
str(getattr(session, "last_intent", "") or "").strip() == STATE_DRAFT_PENDING_CONFIRM if str(thread_context.get("reply_to_message_id") or "").strip():
and WorkflowSemanticPlanner.looks_like_natural_draft_revision(message_text) if str(getattr(session, "last_intent", "") or "").strip() != STATE_DRAFT_PENDING_CONFIRM:
) return False
return not str(message_text or "").strip().endswith(("?", "?"))
return False
async def _should_route_to_draft_confirmation_session( async def _should_route_to_draft_confirmation_session(
self, self,
@ -1358,34 +1324,290 @@ class KernelIngressFlowEntryMixin:
message_text: str, message_text: str,
ingress_options: Optional[dict[str, Any]], ingress_options: Optional[dict[str, Any]],
) -> bool: ) -> bool:
if WorkflowSemanticPlanner.looks_like_explicit_new_requirement_topic(message_text): from ..draft_confirm_handler import DraftConfirmHandler
return False
handler = DraftConfirmHandler(self)
thread_context = self._extract_thread_context_from_ingress_options(ingress_options)
normalized_text = str(message_text or "").strip()
if str(thread_context.get("reply_to_message_id") or "").strip():
if str(getattr(session, "last_intent", "") or "").strip() == STATE_DRAFT_PENDING_CONFIRM:
return True
return await handler.has_active_draft_session(
sender=sender,
source_system=source_system,
channel_ref=channel_ref,
source_sender_open_id=source_sender_open_id,
thread_context=thread_context,
)
if self._should_route_to_draft_confirmation( if self._should_route_to_draft_confirmation(
session=session, session=session,
message_text=message_text, message_text=message_text,
ingress_options=ingress_options, ingress_options=ingress_options,
): ):
return True return True
if not normalized_text:
from ..draft_confirm_handler import DraftConfirmHandler
action, _ = DraftConfirmHandler.parse_action_from_text(message_text=message_text)
if action == DraftConfirmHandler._ACTION_UNKNOWN and not WorkflowSemanticPlanner.looks_like_natural_draft_revision(message_text):
return False return False
if await self._should_skip_no_anchor_draft_routing_by_bundle(
message_text=normalized_text,
source_system=source_system,
):
return False
if str(getattr(session, "last_intent", "") or "").strip() == STATE_DRAFT_PENDING_CONFIRM:
return True
handler = DraftConfirmHandler(self) has_active_draft_session = await handler.has_active_draft_session(
return await handler.has_active_draft_session(
sender=sender, sender=sender,
source_system=source_system, source_system=source_system,
channel_ref=channel_ref, channel_ref=channel_ref,
source_sender_open_id=source_sender_open_id, source_sender_open_id=source_sender_open_id,
thread_context=self._extract_thread_context_from_ingress_options(ingress_options), thread_context=thread_context,
) )
return bool(has_active_draft_session)
@staticmethod @staticmethod
def _build_orphan_requirement_followup_reply() -> str: def _build_orphan_requirement_followup_reply() -> str:
return "当前没有进行中的 A/B/C 收敛轮次,也没有待确认草稿。你先发一句新需求,我按新的 topic 接着收。" return "当前没有进行中的 A/B/C 收敛轮次,也没有待确认草稿。你先发一句新需求,我按新的 topic 接着收。"
@staticmethod
def _derive_active_context_kind(
*,
topic: Any,
draft: Any,
) -> str:
draft_state = str(getattr(draft, "state", "") or "").strip().lower()
plan_status = str(getattr(topic, "plan_status", "") or "").strip().lower()
if draft_state in {"ready", STATE_DRAFT_PENDING_CONFIRM} or plan_status in {
STATE_DRAFT_PENDING_CONFIRM,
"awaiting_execute_confirm",
"plan_ready",
"draft_ready",
}:
return "draft_pending_confirm"
return "requirement_plan"
async def _bind_active_context_target(
self,
*,
source_system: str,
source_message_id: str,
channel_ref: Optional[str],
source_sender_open_id: Optional[str],
thread_context: Optional[dict[str, Any]],
sender: Optional[str],
conversation_key: str,
) -> Optional[tuple[Any, Any, str]]:
from ..draft_confirm_handler import DraftConfirmHandler
handler = DraftConfirmHandler(self)
try:
active_mounted = await handler._resolve_active_mounted_draft(
sender=str(sender or ""),
source_system=source_system,
channel_ref=channel_ref,
source_sender_open_id=source_sender_open_id,
thread_context=thread_context,
)
except AttributeError:
if hasattr(self.db, "execute"):
raise
active_mounted = None
if active_mounted is not None:
draft, topic = active_mounted
return topic, draft, self._derive_active_context_kind(topic=topic, draft=draft)
finder = getattr(self, "_find_active_requirement_plan_workspace", None)
if not callable(finder):
return None
topic, draft = await finder(
source_system=source_system,
source_message_id=source_message_id,
source_chat_id=channel_ref,
sender=None,
source_sender_open_id=source_sender_open_id,
thread_context=thread_context,
conversation_key=conversation_key,
)
if topic is None or draft is None:
return None
return topic, draft, self._derive_active_context_kind(topic=topic, draft=draft)
@staticmethod
def _build_active_context_direct_reply_result(*, answer: str, reason_code: str) -> dict[str, Any]:
text = str(answer or "").strip()
return {
"handled": True,
"status": "answered",
"classification": "question",
"linked_project_ids": [],
"signal_id": None,
"created_project_id": None,
"auto_tasks": [],
"ai_trace_id": None,
"results": [],
"intent": INTENT_QUERY,
"direct_reply_required": True,
"direct_reply_text": text,
"reply_template_key": reason_code,
"work_item_id": None,
"decision_level": None,
"review_summary": None,
"workflow_route": ROUTE_QUERY_ANSWER,
}
@staticmethod
def _extract_direct_answer_text(payload: Optional[dict[str, Any]]) -> str:
if not isinstance(payload, dict):
return ""
direct_reply = str(payload.get("direct_reply_text") or "").strip()
if direct_reply:
return direct_reply
answer = str(payload.get("answer") or "").strip()
if answer:
return answer
rag_result = payload.get("rag_result")
if isinstance(rag_result, dict):
return str(rag_result.get("answer") or "").strip()
return ""
async def _maybe_handle_active_context_followup(
self,
*,
source_system: str,
source_message_id: str,
channel_ref: Optional[str],
source_sender_open_id: Optional[str],
message_text: str,
thread_context: Optional[dict[str, Any]],
sender: Optional[str],
force_bind: bool = False,
) -> Optional[dict[str, Any]]:
if not str(message_text or "").strip():
return None
is_feishu_source = str(source_system or "").strip().lower() == "feishu"
conversation_key = (
build_feishu_conversation_scope_key(
source_chat_id=channel_ref,
source_sender_open_id=source_sender_open_id,
sender=None,
)
if is_feishu_source
else source_message_id
)
bound_context = await self._bind_active_context_target(
source_system=source_system,
source_message_id=source_message_id,
channel_ref=channel_ref,
source_sender_open_id=source_sender_open_id,
thread_context=thread_context,
conversation_key=conversation_key,
sender=sender,
)
if bound_context is None:
return None
topic, draft, active_context_kind = bound_context
try:
packet = await SemanticContextAssembler(self.db).assemble(
tenant_id=int(self.tenant_id),
operator_user_id=0,
topic_workspace_id=int(getattr(topic, "id")),
latest_user_message=message_text,
ingress_context={
"candidate_kind": "active_context_followup",
"source_system": source_system,
"reply_anchor_present": bool(thread_context),
"active_context_kind": active_context_kind,
"allowed_context_scopes": [
"current_requirement",
"current_draft",
"independent_workflow",
"clarify",
],
"thread_context": thread_context or {},
},
)
decision = await SemanticDecisionService(llm_service).decide(packet=packet)
gate_result = SemanticDecisionGatekeeper.evaluate(
mode_card=packet.mode_card,
decision=decision,
)
except Exception as exc:
logger.warning(
"active_context_followup_decision_failed",
tenant_id=self.tenant_id,
source_system=source_system,
source_message_id=source_message_id,
error=str(exc),
)
return None
if not bool(getattr(gate_result, "accepted", False)):
return None
allowed_actions = {
str(item).strip()
for item in (getattr(gate_result, "allowed_actions", None) or [])
if str(item or "").strip()
}
followup_scope = str(getattr(decision, "followup_scope", "") or "").strip().lower()
assistant_reply = str(getattr(decision, "assistant_reply", "") or "").strip()
if followup_scope == "current_requirement":
if "context.followup.current_requirement" not in allowed_actions:
return None
builder = getattr(self, "_build_requirement_planning_direct_result", None)
if callable(builder):
direct_result = await builder(
message_text=message_text,
source_system=source_system,
source_message_id=source_message_id,
source_chat_id=channel_ref,
sender=None,
source_sender_open_id=source_sender_open_id,
thread_context=thread_context,
conversation_key=conversation_key,
topic_switch=None,
)
answer = self._extract_direct_answer_text(direct_result)
if answer:
return self._build_active_context_direct_reply_result(
answer=answer,
reason_code="requirement_plan_followup",
)
if assistant_reply:
return self._build_active_context_direct_reply_result(
answer=assistant_reply,
reason_code="requirement_plan_followup",
)
return None
if followup_scope == "current_draft":
if "context.followup.current_draft" not in allowed_actions:
return None
from ..draft_confirm_handler import DraftConfirmHandler
handler = DraftConfirmHandler(self)
return await handler.handle_text_reply(
message_text=message_text,
sender=str(sender or ""),
source_system=source_system,
source_message_id=source_message_id,
channel_ref=channel_ref,
source_sender_open_id=source_sender_open_id,
thread_context=thread_context,
semantic_followup_override={
"action": str(getattr(decision, "followup_action", "") or "").strip().lower(),
"revision_note": str(getattr(decision, "revision_note", "") or "").strip(),
"assistant_reply": assistant_reply,
},
)
if followup_scope == "clarify" and "context.followup.clarify" in allowed_actions and assistant_reply:
return self._build_active_context_direct_reply_result(
answer=assistant_reply,
reason_code="active_context_followup_clarify",
)
return None
async def _maybe_reset_session_project_context_for_new_requirement( async def _maybe_reset_session_project_context_for_new_requirement(
self, self,
*, *,
@ -1399,24 +1621,17 @@ class KernelIngressFlowEntryMixin:
) -> bool: ) -> bool:
if session is None: if session is None:
return False return False
if not WorkflowSemanticPlanner.looks_like_explicit_new_requirement_topic(message_text):
return False
if _safe_int(getattr(session, "current_project_id", None)) is None: if _safe_int(getattr(session, "current_project_id", None)) is None:
return False return False
await self._update_session( _ = (
tenant_id=self.tenant_id, sender,
sender=sender, channel_ref,
session=session, source_sender_open_id,
source_chat_id=channel_ref, source_message_id,
source_sender_open_id=source_sender_open_id, thread_context,
source_message_id=source_message_id, message_text,
thread_context=thread_context,
current_project_id=None,
clear_current_project_id=True,
last_intent=str(getattr(session, "last_intent", "") or "").strip() or None,
last_message_text=str(getattr(session, "last_message_text", "") or "").strip() or None,
) )
return True return False
async def _maybe_handle_orphan_requirement_followup( async def _maybe_handle_orphan_requirement_followup(
self, self,
@ -1427,8 +1642,10 @@ class KernelIngressFlowEntryMixin:
source_sender_open_id: Optional[str], source_sender_open_id: Optional[str],
ingress_options: Optional[dict[str, Any]], ingress_options: Optional[dict[str, Any]],
message_text: str, message_text: str,
session: Optional[ConversationSession] = None,
sender: Optional[str] = None,
) -> Optional[dict[str, Any]]: ) -> Optional[dict[str, Any]]:
if not WorkflowSemanticPlanner.looks_like_low_context_requirement_followup(message_text): if str(getattr(session, "last_intent", "") or "").strip() != QUERY_SCENE_REQUIREMENT_PLAN:
return None return None
checker = getattr(self, "_has_active_requirement_plan_workspace", None) checker = getattr(self, "_has_active_requirement_plan_workspace", None)
if not callable(checker): if not callable(checker):
@ -1456,6 +1673,20 @@ class KernelIngressFlowEntryMixin:
) )
if has_active_requirement_plan_workspace: if has_active_requirement_plan_workspace:
return None return None
if session is not None and _safe_int(getattr(session, "current_project_id", None)) is not None:
await self._update_session(
tenant_id=self.tenant_id,
sender=str(sender or getattr(session, "sender", "") or ""),
session=session,
source_chat_id=channel_ref,
source_sender_open_id=source_sender_open_id,
source_message_id=source_message_id,
thread_context=thread_context,
current_project_id=None,
clear_current_project_id=True,
last_intent=str(getattr(session, "last_intent", "") or "").strip() or None,
last_message_text=str(getattr(session, "last_message_text", "") or "").strip() or None,
)
return { return {
"handled": True, "handled": True,
"status": "answered", "status": "answered",

View File

@ -3,7 +3,6 @@
from __future__ import annotations from __future__ import annotations
from .shared import * # noqa: F401, F403 from .shared import * # noqa: F401, F403
from ..workflow_semantic_planner import WorkflowSemanticPlanner
class KernelWorldModelCoreProjectMixin: class KernelWorldModelCoreProjectMixin:
@ -39,8 +38,6 @@ class KernelWorldModelCoreProjectMixin:
cleaned = re.sub(r"\s+", " ", str(message_text or "")).strip() cleaned = re.sub(r"\s+", " ", str(message_text or "")).strip()
if not cleaned: if not cleaned:
return False return False
if not WorkflowSemanticPlanner.looks_like_project_create_request(cleaned):
return False
return bool(cls._extract_explicit_project_title_anchor(message_text=cleaned)) return bool(cls._extract_explicit_project_title_anchor(message_text=cleaned))
async def _resolve_project( async def _resolve_project(
@ -97,7 +94,10 @@ class KernelWorldModelCoreProjectMixin:
normalized_candidate = re.sub(r"\s+", "", str(candidate or "")).lower() normalized_candidate = re.sub(r"\s+", "", str(candidate or "")).lower()
if normalized_candidate and normalized_candidate in normalized_message: if normalized_candidate and normalized_candidate in normalized_message:
return project return project
inherited_project_id = self._extract_inherited_project_id(session=session) inherited_project_id = self._extract_inherited_project_id(
session=session,
message_text=message_text,
)
if inherited_project_id is not None: if inherited_project_id is not None:
self._record_world_model_query_table(query_tables, "projects") self._record_world_model_query_table(query_tables, "projects")
return ( return (

View File

@ -42,19 +42,6 @@ CONSULTANT_CHANNEL_STRATEGIES: dict[str, dict[str, Any]] = {
} }
_QUERY_OPERATION_CODES = {"query.answer", "query.read_only"} _QUERY_OPERATION_CODES = {"query.answer", "query.read_only"}
_SCOPE_EXPANSION_RE = re.compile(
r"(整个系统|全部重做|全量重构|一次替换所有|一次性全部|推翻重来|从零重写|所有模块都|整体改造)",
re.IGNORECASE,
)
_BRAINSTORM_RE = re.compile(
r"(想.idea|想法|brainstorm|头脑风暴|一起想|给我几个方案|给几个方向|发散一下|有哪些思路)",
re.IGNORECASE,
)
_ADVISE_RE = re.compile(
r"(建议|怎么做|如何做|优先|取舍|方案|还是|更适合|更应该|值不值得|要不要)",
re.IGNORECASE,
)
_MISSING_FIELD_LABELS = { _MISSING_FIELD_LABELS = {
"project_id": "归属项目", "project_id": "归属项目",
"task_title": "任务标题", "task_title": "任务标题",
@ -63,15 +50,6 @@ _MISSING_FIELD_LABELS = {
"project_scope": "项目范围", "project_scope": "项目范围",
} }
_LONG_REPLY_SECTION_HINTS = (
"结论:",
"依据:",
"不确定项:",
"事实:",
"建议:",
"待确认项:",
)
_DOMAIN_NEXT_ACTION_FALLBACKS: dict[str, dict[str, str]] = { _DOMAIN_NEXT_ACTION_FALLBACKS: dict[str, dict[str, str]] = {
DOMAIN_RAG: { DOMAIN_RAG: {
MODE_ADVISE: "继续追问具体模块、入口函数或文件路径,我会把调用链收窄到那一段。", MODE_ADVISE: "继续追问具体模块、入口函数或文件路径,我会把调用链收窄到那一段。",
@ -121,14 +99,12 @@ def _normalize_action_text(text: str) -> str:
return re.sub(r"\s+", " ", str(text or "")).strip() return re.sub(r"\s+", " ", str(text or "")).strip()
def _looks_like_long_answer(text: str) -> bool: def _is_multiline_or_long_answer(text: str) -> bool:
raw = str(text or "") raw = str(text or "")
normalized = _normalize_action_text(raw) normalized = _normalize_action_text(raw)
if not normalized: if not normalized:
return False return False
if "\n" in raw or len(normalized) > 120: return "\n" in raw or len(normalized) > 120
return True
return any(token in normalized for token in _LONG_REPLY_SECTION_HINTS)
def _domain_next_action(domain: str, mode: str) -> str: def _domain_next_action(domain: str, mode: str) -> str:
@ -182,18 +158,6 @@ def _derive_allow_execute(
return normalized_status in {"succeeded", "answered", "captured", "completed"} return normalized_status in {"succeeded", "answered", "captured", "completed"}
def _looks_like_scope_expansion(message_text: str) -> bool:
return bool(_SCOPE_EXPANSION_RE.search(str(message_text or "")))
def _looks_like_brainstorm(message_text: str) -> bool:
return bool(_BRAINSTORM_RE.search(str(message_text or "")))
def _looks_like_advice(message_text: str) -> bool:
return bool(_ADVISE_RE.search(str(message_text or "")))
def _derive_mode( def _derive_mode(
*, *,
intent_name: str, intent_name: str,
@ -204,20 +168,17 @@ def _derive_mode(
allow_execute: bool, allow_execute: bool,
primary_operation_code: Optional[str], primary_operation_code: Optional[str],
) -> str: ) -> str:
_ = message_text
if missing_fields: if missing_fields:
return MODE_CLARIFY return MODE_CLARIFY
if _looks_like_scope_expansion(message_text):
return MODE_CHALLENGE
if str(risk_level or "").strip().lower() == "high" and not allow_execute and not _is_query_like( if str(risk_level or "").strip().lower() == "high" and not allow_execute and not _is_query_like(
primary_operation_code=primary_operation_code, primary_operation_code=primary_operation_code,
intent_name=intent_name, intent_name=intent_name,
): ):
return MODE_CHALLENGE return MODE_CHALLENGE
if _looks_like_brainstorm(message_text):
return MODE_BRAINSTORM
if allow_execute and not _is_query_like(primary_operation_code=primary_operation_code, intent_name=intent_name): if allow_execute and not _is_query_like(primary_operation_code=primary_operation_code, intent_name=intent_name):
return MODE_EXECUTE return MODE_EXECUTE
if _looks_like_advice(message_text) or _is_query_like(primary_operation_code=primary_operation_code, intent_name=intent_name): if _is_query_like(primary_operation_code=primary_operation_code, intent_name=intent_name):
return MODE_ADVISE return MODE_ADVISE
if str(status or "").strip().lower() == "pending_manual": if str(status or "").strip().lower() == "pending_manual":
return MODE_CHALLENGE return MODE_CHALLENGE
@ -225,7 +186,7 @@ def _derive_mode(
def _build_clarify_next_action(*, missing_fields: list[str], direct_reply_text: str) -> str: def _build_clarify_next_action(*, missing_fields: list[str], direct_reply_text: str) -> str:
if direct_reply_text and not _looks_like_long_answer(direct_reply_text): if direct_reply_text and not _is_multiline_or_long_answer(direct_reply_text):
return _normalize_action_text(direct_reply_text)[:120] return _normalize_action_text(direct_reply_text)[:120]
labels = [_MISSING_FIELD_LABELS.get(item, item) for item in missing_fields] labels = [_MISSING_FIELD_LABELS.get(item, item) for item in missing_fields]
suffix = "、".join([label for label in labels if label]) or "关键信息" suffix = "、".join([label for label in labels if label]) or "关键信息"
@ -246,7 +207,7 @@ def _build_next_action(
direct_reply_text=direct_reply_text, direct_reply_text=direct_reply_text,
) )
if mode == MODE_CHALLENGE: if mode == MODE_CHALLENGE:
if direct_reply_text and not _looks_like_long_answer(direct_reply_text): if direct_reply_text and not _is_multiline_or_long_answer(direct_reply_text):
return _normalize_action_text(direct_reply_text)[:120] return _normalize_action_text(direct_reply_text)[:120]
if str(risk_level or "").strip().lower() == "high": if str(risk_level or "").strip().lower() == "high":
return "建议先拆分范围,再确认是否继续推进高风险动作。"[:120] return "建议先拆分范围,再确认是否继续推进高风险动作。"[:120]
@ -261,7 +222,7 @@ def _build_next_action(
if domain == DOMAIN_SIGNAL: if domain == DOMAIN_SIGNAL:
return "可继续确认归属项目,或查看后续自动处理结果。" return "可继续确认归属项目,或查看后续自动处理结果。"
return "可继续查看结果,或补充更多执行细节。" return "可继续查看结果,或补充更多执行细节。"
if direct_reply_text and not _looks_like_long_answer(direct_reply_text): if direct_reply_text and not _is_multiline_or_long_answer(direct_reply_text):
return _normalize_action_text(direct_reply_text)[:120] return _normalize_action_text(direct_reply_text)[:120]
return _domain_next_action(domain=domain, mode=mode) return _domain_next_action(domain=domain, mode=mode)

View File

@ -2,7 +2,7 @@
from __future__ import annotations from __future__ import annotations
from datetime import datetime from datetime import datetime, timedelta
from typing import Any, Optional from typing import Any, Optional
from . import ai_pmo_kernel_common_core as _common from . import ai_pmo_kernel_common_core as _common
@ -13,7 +13,10 @@ for _name, _value in vars(_common).items():
from ..ai_steward_service import AIStewardService from ..ai_steward_service import AIStewardService
from ..mounted_ai_runtime_write_service import MountedAIRuntimeWriteService from ..mounted_ai_runtime_write_service import MountedAIRuntimeWriteService
from .workflow_semantic_planner import WorkflowSemanticPlanner from .ai_pmo_typed_executor_execute import mixin_execute_query_project
from .semantic_context.assembler import SemanticContextAssembler
from .semantic_context.gatekeeper import SemanticDecisionGatekeeper
from .semantic_context.semantic_decision_service import SemanticDecisionService
from ..signal.draft_generator_service import DraftGeneratorService from ..signal.draft_generator_service import DraftGeneratorService
from ...models import MountedDraftWorkspace, MountedTopicWorkspace from ...models import MountedDraftWorkspace, MountedTopicWorkspace
from ...models.feishu_reply_outbox import FeishuReplyOutbox from ...models.feishu_reply_outbox import FeishuReplyOutbox
@ -25,7 +28,14 @@ class DraftConfirmHandler:
_ACTION_CONFIRM = "confirm" _ACTION_CONFIRM = "confirm"
_ACTION_REVISE = "revise" _ACTION_REVISE = "revise"
_ACTION_CANCEL = "cancel" _ACTION_CANCEL = "cancel"
_ACTION_ANSWER_ONLY = "answer_only"
_ACTION_UNKNOWN = "unknown" _ACTION_UNKNOWN = "unknown"
_SEMANTIC_FOLLOWUP_ACTIONS = (_ACTION_CONFIRM, _ACTION_REVISE, _ACTION_CANCEL, _ACTION_ANSWER_ONLY)
_EXPLICIT_CONFIRM_COMMANDS = frozenset({"确认", "confirm", "ok", "好的", "好", "yes", "y"})
_EXPLICIT_CANCEL_COMMANDS = frozenset({"取消", "cancel", "不用了", "算了"})
_DEFAULT_DISPLAY_FEEDBACK_REPLY = (
"收到,这是展示形式反馈:卡片可以更完整,聊天直发我会控制得更短;当前草稿内容我先不重起。"
)
_WORK_ITEM_ID_RE = re.compile(r"(?:work[_ -]?item|工作项|草稿)\s*(?:id|ID)?\s*[:#:]?\s*(\d+)", re.IGNORECASE) _WORK_ITEM_ID_RE = re.compile(r"(?:work[_ -]?item|工作项|草稿)\s*(?:id|ID)?\s*[:#:]?\s*(\d+)", re.IGNORECASE)
@ -35,11 +45,6 @@ class DraftConfirmHandler:
self.tenant_id = int(kernel.tenant_id) self.tenant_id = int(kernel.tenant_id)
self.draft_generator = DraftGeneratorService(self.db, tenant_id=self.tenant_id) self.draft_generator = DraftGeneratorService(self.db, tenant_id=self.tenant_id)
@classmethod
def parse_action_from_text(cls, *, message_text: str) -> tuple[str, str]:
action, note = WorkflowSemanticPlanner.parse_draft_action(message_text=message_text)
return action, note
async def handle_text_reply( async def handle_text_reply(
self, self,
*, *,
@ -51,11 +56,12 @@ class DraftConfirmHandler:
source_sender_open_id: Optional[str], source_sender_open_id: Optional[str],
expected_version: Optional[int] = None, expected_version: Optional[int] = None,
thread_context: Optional[dict[str, Any]] = None, thread_context: Optional[dict[str, Any]] = None,
semantic_followup_override: Optional[dict[str, str]] = None,
) -> dict[str, Any]: ) -> dict[str, Any]:
action, revision_note = self.parse_action_from_text(message_text=message_text)
explicit_work_item_id = self._extract_work_item_id(message_text) explicit_work_item_id = self._extract_work_item_id(message_text)
looks_like_revision = WorkflowSemanticPlanner.looks_like_natural_draft_revision(message_text) normalized_thread_context = self._normalize_thread_context(thread_context)
has_reply_anchor = bool(str(normalized_thread_context.get("reply_to_message_id") or "").strip())
normalized_text = str(message_text or "").strip().lower()
target = await self._resolve_pending_draft( target = await self._resolve_pending_draft(
sender=sender, sender=sender,
source_system=source_system, source_system=source_system,
@ -65,7 +71,10 @@ class DraftConfirmHandler:
thread_context=thread_context, thread_context=thread_context,
) )
if target is None: if target is None:
if explicit_work_item_id is not None or action != self._ACTION_UNKNOWN or looks_like_revision: if explicit_work_item_id is not None or (
has_reply_anchor
and normalized_text in (self._EXPLICIT_CONFIRM_COMMANDS | self._EXPLICIT_CANCEL_COMMANDS)
):
return { return {
"handled": True, "handled": True,
"status": "failed_safe", "status": "failed_safe",
@ -90,6 +99,46 @@ class DraftConfirmHandler:
} }
target_work_item, target_ingest = target target_work_item, target_ingest = target
action = self._ACTION_UNKNOWN
revision_note = ""
active_mounted = await self._resolve_target_mounted_draft(
sender=sender,
source_system=source_system,
channel_ref=channel_ref,
source_sender_open_id=source_sender_open_id,
thread_context=thread_context,
target_work_item_id=int(target_work_item.id),
)
semantic_followup = (
{
"action": str((semantic_followup_override or {}).get("action") or "").strip().lower(),
"revision_note": str((semantic_followup_override or {}).get("revision_note") or "").strip(),
"assistant_reply": str((semantic_followup_override or {}).get("assistant_reply") or "").strip(),
}
if isinstance(semantic_followup_override, dict)
else await self._resolve_semantic_followup_decision(
message_text=message_text,
source_system=source_system,
thread_context=thread_context,
active_mounted=active_mounted,
)
)
if semantic_followup is not None:
action = str(semantic_followup.get("action") or action).strip().lower() or action
revision_note = str(semantic_followup.get("revision_note") or revision_note).strip()
if action == self._ACTION_ANSWER_ONLY:
return {
"handled": True,
"status": "pending_manual",
"followup_reply": str(semantic_followup.get("assistant_reply") or "").strip()
or self._DEFAULT_DISPLAY_FEEDBACK_REPLY,
"target_work_item_id": int(target_work_item.id),
"target_state": str(target_work_item.state or "").strip() or None,
"target_intent": str(target_work_item.intent or "").strip() or None,
"project_id": _safe_int(target_work_item.project_id),
"signal_id": _safe_int(target_work_item.signal_id),
"workflow_route": RouteResolver.normalize_route_key(str(target_work_item.intent or "")),
}
if action == self._ACTION_CONFIRM: if action == self._ACTION_CONFIRM:
return await self.confirm_draft( return await self.confirm_draft(
target_work_item=target_work_item, target_work_item=target_work_item,
@ -112,6 +161,18 @@ class DraftConfirmHandler:
target_work_item=target_work_item, target_work_item=target_work_item,
reason="用户取消草稿", reason="用户取消草稿",
) )
if explicit_work_item_id is None and not has_reply_anchor:
return {
"handled": False,
"status": "ignored",
"followup_reply": "",
"target_work_item_id": None,
"target_state": None,
"target_intent": None,
"project_id": None,
"signal_id": None,
"workflow_route": None,
}
return { return {
"handled": True, "handled": True,
"status": "pending_manual", "status": "pending_manual",
@ -124,6 +185,131 @@ class DraftConfirmHandler:
"workflow_route": RouteResolver.normalize_route_key(str(target_work_item.intent or "")), "workflow_route": RouteResolver.normalize_route_key(str(target_work_item.intent or "")),
} }
async def _resolve_target_mounted_draft(
self,
*,
sender: str,
source_system: str,
channel_ref: Optional[str],
source_sender_open_id: Optional[str],
thread_context: Optional[dict[str, Any]],
target_work_item_id: int,
) -> Optional[tuple[MountedDraftWorkspace, MountedTopicWorkspace]]:
if not hasattr(self.db, "execute"):
return None
active_mounted = await self._resolve_active_mounted_draft(
sender=sender,
source_system=source_system,
channel_ref=channel_ref,
source_sender_open_id=source_sender_open_id,
thread_context=thread_context,
)
if active_mounted is not None:
return active_mounted
return await self._resolve_mounted_draft_by_work_item_id(work_item_id=int(target_work_item_id))
async def _resolve_semantic_followup_decision(
self,
*,
message_text: str,
source_system: str,
thread_context: Optional[dict[str, Any]],
active_mounted: Optional[tuple[MountedDraftWorkspace, MountedTopicWorkspace]],
) -> Optional[dict[str, str]]:
if active_mounted is None:
return None
_, mounted_topic = active_mounted
try:
packet = await SemanticContextAssembler(self.db).assemble(
tenant_id=int(self.tenant_id),
operator_user_id=0,
topic_workspace_id=int(mounted_topic.id),
latest_user_message=message_text,
ingress_context={
"candidate_kind": "draft_followup",
"source_system": source_system,
"reply_anchor_present": bool(self._normalize_thread_context(thread_context)),
"allowed_followup_actions": list(self._SEMANTIC_FOLLOWUP_ACTIONS),
"thread_context": self._normalize_thread_context(thread_context),
},
)
decision = await SemanticDecisionService(llm_service).decide(packet=packet)
followup_action = str(getattr(decision, "followup_action", "") or "").strip().lower()
if followup_action not in self._SEMANTIC_FOLLOWUP_ACTIONS:
return None
gate_result = SemanticDecisionGatekeeper.evaluate(
mode_card=packet.mode_card,
decision=decision,
)
if not bool(gate_result.accepted):
return None
allowed_actions = {
str(item).strip()
for item in (getattr(packet.mode_card, "allowed_actions", None) or [])
if str(item or "").strip()
}
action_code = (
"draft.followup.answer_only"
if followup_action == self._ACTION_ANSWER_ONLY
else f"draft.followup.{followup_action}"
)
if action_code not in allowed_actions:
return None
revision_note = str(getattr(decision, "revision_note", "") or "").strip()
assistant_reply = str(getattr(decision, "assistant_reply", "") or "").strip()
except Exception as exc:
logger.warning(
"draft_confirm_semantic_followup_failed",
tenant_id=self.tenant_id,
topic_workspace_id=_safe_int(getattr(mounted_topic, "id", None)),
error=str(exc),
)
return None
if followup_action == self._ACTION_REVISE and not revision_note:
return {
"action": self._ACTION_REVISE,
"revision_note": "",
"assistant_reply": assistant_reply,
}
if followup_action == self._ACTION_ANSWER_ONLY and not assistant_reply:
assistant_reply = self._DEFAULT_DISPLAY_FEEDBACK_REPLY
return {
"action": followup_action,
"revision_note": revision_note,
"assistant_reply": assistant_reply,
}
async def _resolve_mounted_draft_by_work_item_id(
self,
*,
work_item_id: int,
) -> Optional[tuple[MountedDraftWorkspace, MountedTopicWorkspace]]:
if not hasattr(self.db, "execute"):
return None
row = (
await self.db.execute(
select(MountedDraftWorkspace, MountedTopicWorkspace)
.join(
MountedTopicWorkspace,
MountedTopicWorkspace.id == MountedDraftWorkspace.topic_workspace_id,
)
.where(
and_(
MountedDraftWorkspace.tenant_id == int(self.tenant_id),
MountedTopicWorkspace.tenant_id == int(self.tenant_id),
MountedDraftWorkspace.active_work_item_id == int(work_item_id),
MountedTopicWorkspace.status == "active",
)
)
.order_by(MountedDraftWorkspace.id.desc())
.limit(1)
)
).first()
if row is None:
return None
return row
async def handle_api_decision( async def handle_api_decision(
self, self,
*, *,
@ -523,13 +709,20 @@ class DraftConfirmHandler:
original_message = str((locked.draft_content or {}).get("original_text") or "").strip() original_message = str((locked.draft_content or {}).get("original_text") or "").strip()
intent_name = RouteResolver.canonical_intent_for_route(RouteResolver.normalize_route_key(str(locked.intent or ""))) intent_name = RouteResolver.canonical_intent_for_route(RouteResolver.normalize_route_key(str(locked.intent or "")))
generated = await self.draft_generator.generate_and_persist( if self._is_requirement_plan_draft_payload(locked.draft_content):
work_item=locked, generated = await self._revise_requirement_plan_draft(
intent_name=intent_name, locked=locked,
message_text=original_message, target_ingest=target_ingest,
world_model=locked.context_snapshot if isinstance(locked.context_snapshot, dict) else {}, revision_note=revision_note,
revision_note=revision_note, )
) else:
generated = await self.draft_generator.generate_and_persist(
work_item=locked,
intent_name=intent_name,
message_text=original_message,
world_model=locked.context_snapshot if isinstance(getattr(locked, "context_snapshot", None), dict) else {},
revision_note=revision_note,
)
locked.state = STATE_DRAFT_PENDING_CONFIRM locked.state = STATE_DRAFT_PENDING_CONFIRM
locked.reply_template_key = "draft_pending_confirm" locked.reply_template_key = "draft_pending_confirm"
await self.db.flush() await self.db.flush()
@ -553,6 +746,73 @@ class DraftConfirmHandler:
"draft_content": locked.draft_content if isinstance(locked.draft_content, dict) else None, "draft_content": locked.draft_content if isinstance(locked.draft_content, dict) else None,
} }
@staticmethod
def _is_requirement_plan_draft_payload(draft: Any) -> bool:
return isinstance(draft, dict) and str(draft.get("draft_source") or "").strip() == "requirement_plan"
async def _revise_requirement_plan_draft(
self,
*,
locked: WorkItem,
target_ingest: IngestEvent,
revision_note: str,
) -> dict[str, Any]:
draft_payload = locked.draft_content if isinstance(locked.draft_content, dict) else {}
base_card = draft_payload.get("requirement_card") if isinstance(draft_payload.get("requirement_card"), dict) else {}
if not base_card:
raise RuntimeError("requirement_plan_draft_missing_card")
runtime = _RequirementPlanDraftRuntime(self.db, tenant_id=int(self.tenant_id))
merged_card = dict(base_card)
merged_card.update(runtime._extract_requirement_plan_updates(message_text=revision_note, card=merged_card))
if not str(merged_card.get("title") or "").strip():
merged_card["title"] = runtime._derive_requirement_plan_title(message_text=revision_note, card=merged_card)
if not str(merged_card.get("goal") or "").strip():
merged_card["goal"] = runtime._derive_requirement_plan_goal(message_text=revision_note, card=merged_card)
rendered_text, _, project_creation_draft = await runtime._generate_requirement_plan_confirmation_draft(
card=merged_card,
revision_note=revision_note,
)
next_version = int(locked.draft_version or 0) + 1
locked.draft_content = dict(project_creation_draft)
locked.draft_version = next_version
locked.draft_expires_at = _utc_now_naive() + timedelta(hours=24)
locked.execution_result = {
**(locked.execution_result if isinstance(locked.execution_result, dict) else {}),
"status": "draft_pending_confirm",
"draft": dict(project_creation_draft),
"draft_version": next_version,
"draft_expires_at": locked.draft_expires_at.isoformat() if locked.draft_expires_at else None,
"revision_note": str(revision_note or "")[:500] or None,
}
mounted_draft = (
await self.db.execute(
select(MountedDraftWorkspace)
.where(
and_(
MountedDraftWorkspace.tenant_id == int(self.tenant_id),
MountedDraftWorkspace.active_work_item_id == int(locked.id),
)
)
.order_by(MountedDraftWorkspace.id.desc())
.limit(1)
)
).scalar_one_or_none()
if mounted_draft is not None:
mounted_draft.state = "ready"
mounted_draft.draft_version = next_version
mounted_draft.requirement_card = dict(merged_card)
mounted_draft.draft_payload = dict(project_creation_draft)
mounted_draft.last_ready_at = _utc_now_naive()
return {
"draft": dict(project_creation_draft),
"draft_text": rendered_text,
"draft_version": next_version,
}
async def cancel_draft( async def cancel_draft(
self, self,
*, *,
@ -963,13 +1223,6 @@ class DraftConfirmHandler:
return None return None
return _safe_int(matched.group(1)) return _safe_int(matched.group(1))
@classmethod
def _extract_revision_note(cls, text: str) -> str:
action, note = WorkflowSemanticPlanner.parse_draft_action(message_text=text)
if action != cls._ACTION_REVISE:
return ""
return str(note or "")[:500]
@staticmethod @staticmethod
def _safe_float(value: Any) -> Optional[float]: def _safe_float(value: Any) -> Optional[float]:
try: try:
@ -1134,6 +1387,21 @@ class DraftConfirmHandler:
if body: if body:
requirement_lines.append(f"{prefix}{body}") requirement_lines.append(f"{prefix}{body}")
_append_list_section("需求清单", requirement_lines) _append_list_section("需求清单", requirement_lines)
external_signal_sources = (
draft.get("external_signal_sources") if isinstance(draft.get("external_signal_sources"), list) else []
)
external_signal_lines: list[str] = []
for item in external_signal_sources[:5]:
if not isinstance(item, dict):
continue
name = str(item.get("name") or "").strip()
purpose = str(item.get("purpose") or "").strip()
phase = str(item.get("phase") or "").strip()
if name and purpose and phase:
external_signal_lines.append(f"{name}:{purpose}({phase})")
elif name and purpose:
external_signal_lines.append(f"{name}:{purpose}")
_append_list_section("外部信号源", external_signal_lines)
_append_list_section( _append_list_section(
"待确认信息", "待确认信息",
@ -1147,7 +1415,7 @@ class DraftConfirmHandler:
return "\n".join(lines).strip() return "\n".join(lines).strip()
@staticmethod @staticmethod
def _looks_like_project_creation_reextract_failure(error: Exception) -> bool: def _is_project_creation_reextract_failure(error: Exception) -> bool:
raw = str(error or "").strip() raw = str(error or "").strip()
normalized = re.sub(r"\s+", " ", raw).lower() normalized = re.sub(r"\s+", " ", raw).lower()
return any( return any(
@ -1264,7 +1532,7 @@ class DraftConfirmHandler:
return False return False
@classmethod @classmethod
def _looks_like_rich_project_creation_draft(cls, draft: dict[str, Any]) -> bool: def _is_rich_project_creation_draft(cls, draft: dict[str, Any]) -> bool:
if not isinstance(draft, dict): if not isinstance(draft, dict):
return False return False
title = str(draft.get("title") or draft.get("project_title") or "").strip() title = str(draft.get("title") or draft.get("project_title") or "").strip()
@ -1404,7 +1672,7 @@ class DraftConfirmHandler:
assignee_id = _safe_int(draft.get("recommended_assignee_id")) assignee_id = _safe_int(draft.get("recommended_assignee_id"))
if assignee_id is not None and assignee_id > 0: if assignee_id is not None and assignee_id > 0:
requested_member_user_ids = [int(assignee_id)] requested_member_user_ids = [int(assignee_id)]
if self._looks_like_rich_project_creation_draft(draft): if self._is_rich_project_creation_draft(draft):
logger.info( logger.info(
"draft_confirm_project_creation_use_structured_draft_directly", "draft_confirm_project_creation_use_structured_draft_directly",
tenant_id=int(self.tenant_id), tenant_id=int(self.tenant_id),
@ -1426,7 +1694,7 @@ class DraftConfirmHandler:
requested_member_user_ids=requested_member_user_ids or None, requested_member_user_ids=requested_member_user_ids or None,
) )
except Exception as exc: except Exception as exc:
if not self._looks_like_project_creation_reextract_failure(exc): if not self._is_project_creation_reextract_failure(exc):
raise raise
logger.warning( logger.warning(
"draft_confirm_project_creation_reextract_failed_fallback", "draft_confirm_project_creation_reextract_failed_fallback",
@ -1565,6 +1833,12 @@ class DraftConfirmHandler:
message_text = summary message_text = summary
return signal return signal
class _RequirementPlanDraftRuntime(mixin_execute_query_project.ExecuteQueryProjectMixin):
def __init__(self, db: Any, *, tenant_id: int) -> None:
self.db = db
self.tenant_id = int(tenant_id)
@staticmethod @staticmethod
def _build_execution_route(*, work_item: WorkItem) -> WorkflowRoute: def _build_execution_route(*, work_item: WorkItem) -> WorkflowRoute:
normalized_key = RouteResolver.normalize_route_key(str(work_item.intent or "")) normalized_key = RouteResolver.normalize_route_key(str(work_item.intent or ""))

View File

@ -9,6 +9,7 @@ from ....models import MountedConversationRun, MountedDraftWorkspace, MountedTop
from .mode_card_service import build_mode_card from .mode_card_service import build_mode_card
from .packet_schema import ( from .packet_schema import (
SemanticContextDraftSnapshot, SemanticContextDraftSnapshot,
SemanticContextIngress,
SemanticContextPacket, SemanticContextPacket,
SemanticContextThread, SemanticContextThread,
SemanticContextTopic, SemanticContextTopic,
@ -109,6 +110,7 @@ class SemanticContextAssembler:
operator_user_id: int, operator_user_id: int,
topic_workspace_id: int, topic_workspace_id: int,
latest_user_message: str, latest_user_message: str,
ingress_context: Optional[dict[str, Any]] = None,
) -> SemanticContextPacket: ) -> SemanticContextPacket:
topic_row = await self._load_topic( topic_row = await self._load_topic(
tenant_id=tenant_id, tenant_id=tenant_id,
@ -162,6 +164,44 @@ class SemanticContextAssembler:
recent_turns=recent_turns, recent_turns=recent_turns,
open_questions=self._normalize_list(topic_row.open_questions), open_questions=self._normalize_list(topic_row.open_questions),
) )
mode_card = build_mode_card(
topic_mode=topic_row.mode,
plan_status=topic_row.plan_status,
)
ingress = None
if isinstance(ingress_context, dict):
ingress = SemanticContextIngress(
candidate_kind=str(ingress_context.get("candidate_kind") or "").strip() or None,
source_system=str(ingress_context.get("source_system") or "").strip() or None,
reply_anchor_present=bool(ingress_context.get("reply_anchor_present")),
active_context_kind=str(ingress_context.get("active_context_kind") or "").strip() or None,
allowed_followup_actions=self._normalize_list(ingress_context.get("allowed_followup_actions")),
allowed_context_scopes=self._normalize_list(ingress_context.get("allowed_context_scopes")),
thread_context=self._normalize_mapping(ingress_context.get("thread_context")),
)
if ingress.candidate_kind == "draft_followup":
merged_allowed = list(mode_card.allowed_actions)
for action in (
"draft.followup.confirm",
"draft.followup.revise",
"draft.followup.cancel",
"draft.followup.answer_only",
):
if action not in merged_allowed:
merged_allowed.append(action)
mode_card.allowed_actions = merged_allowed
if ingress.candidate_kind == "active_context_followup":
merged_allowed = list(mode_card.allowed_actions)
for action in (
"context.followup.current_requirement",
"context.followup.current_draft",
"context.followup.independent_workflow",
"context.followup.clarify",
):
if action not in merged_allowed:
merged_allowed.append(action)
mode_card.allowed_actions = merged_allowed
return SemanticContextPacket( return SemanticContextPacket(
packet_version="v1", packet_version="v1",
tenant_id=int(tenant_id), tenant_id=int(tenant_id),
@ -171,8 +211,6 @@ class SemanticContextAssembler:
topic=topic, topic=topic,
draft=draft, draft=draft,
thread=thread, thread=thread,
mode_card=build_mode_card( mode_card=mode_card,
topic_mode=topic_row.mode, ingress=ingress,
plan_status=topic_row.plan_status,
),
) )

View File

@ -15,12 +15,38 @@ class ModeCard(BaseModel):
exit_conditions: list[str] = Field(default_factory=list) exit_conditions: list[str] = Field(default_factory=list)
class DecisionWorkflowPlan(BaseModel):
model_config = ConfigDict(extra="ignore")
workflow_type: Literal[
"query_flow",
"new_project_flow",
"project_flow",
"task_flow",
"signal_source_flow",
"project_signal_flow",
"followup_flow",
]
workflow_action: str
target_scope: Literal["global", "project", "thread"] = "global"
operation_codes: list[str] = Field(default_factory=list)
requires_confirmation: bool = False
confidence: float = 0.0
clarification_needed: bool = False
metadata: dict[str, Any] = Field(default_factory=dict)
class SemanticDecision(BaseModel): class SemanticDecision(BaseModel):
model_config = ConfigDict(extra="ignore") model_config = ConfigDict(extra="ignore")
topic_relation: Literal["continue_current_topic", "new_topic", "uncertain"] topic_relation: Literal["continue_current_topic", "new_topic", "uncertain"]
mode_decision: Literal["stay_plan", "ready_confirm", "request_execute", "answer_only"] mode_decision: Literal["stay_plan", "ready_confirm", "request_execute", "answer_only"]
followup_scope: Literal["none", "current_requirement", "current_draft", "independent_workflow", "clarify"] = "none"
followup_action: Literal["none", "confirm", "revise", "cancel", "answer_only"] = "none"
revision_note: str = ""
draft_patch: dict[str, Any] = Field(default_factory=dict) draft_patch: dict[str, Any] = Field(default_factory=dict)
workflow_plans: list[DecisionWorkflowPlan] = Field(default_factory=list)
scene_metadata: dict[str, Any] = Field(default_factory=dict)
assistant_reply: str = "" assistant_reply: str = ""
requires_confirmation: bool = False requires_confirmation: bool = False
requested_actions: list[str] = Field(default_factory=list) requested_actions: list[str] = Field(default_factory=list)

View File

@ -53,6 +53,18 @@ class SemanticContextDraftSnapshot(BaseModel):
required_confirmations: list[str] = Field(default_factory=list) required_confirmations: list[str] = Field(default_factory=list)
class SemanticContextIngress(BaseModel):
model_config = ConfigDict(extra="ignore")
candidate_kind: Optional[str] = None
source_system: Optional[str] = None
reply_anchor_present: bool = False
active_context_kind: Optional[str] = None
allowed_followup_actions: list[str] = Field(default_factory=list)
allowed_context_scopes: list[str] = Field(default_factory=list)
thread_context: Optional[Any] = None
class SemanticContextPacket(BaseModel): class SemanticContextPacket(BaseModel):
model_config = ConfigDict(extra="ignore") model_config = ConfigDict(extra="ignore")
@ -65,3 +77,4 @@ class SemanticContextPacket(BaseModel):
draft: SemanticContextDraftSnapshot | None = None draft: SemanticContextDraftSnapshot | None = None
thread: SemanticContextThread thread: SemanticContextThread
mode_card: ModeCard mode_card: ModeCard
ingress: SemanticContextIngress | None = None

View File

@ -17,8 +17,9 @@ class SemanticDecisionService:
@staticmethod @staticmethod
def build_prompt(packet: SemanticContextPacket) -> str: def build_prompt(packet: SemanticContextPacket) -> str:
return PMFLOW_SEMANTIC_CONTEXT_DECISION_PROMPT.format( return PMFLOW_SEMANTIC_CONTEXT_DECISION_PROMPT.replace(
semantic_packet=json.dumps(packet.model_dump(mode="json"), ensure_ascii=False, indent=2) "{semantic_packet}",
json.dumps(packet.model_dump(mode="json"), ensure_ascii=False, indent=2),
) )
async def decide(self, *, packet: SemanticContextPacket) -> SemanticDecision: async def decide(self, *, packet: SemanticContextPacket) -> SemanticDecision:

View File

@ -1,622 +1,13 @@
from __future__ import annotations from __future__ import annotations
import re
SEMANTIC_VERSION = "2026-03-27.workflow-v1" SEMANTIC_VERSION = "2026-03-27.workflow-v1"
INTENT_QUERY = "query.read_only"
INTENT_PROJECT_CREATE = "project.create"
INTENT_PROJECT_UPDATE = "project.update"
INTENT_PROJECT_ARCHIVE = "project.archive"
INTENT_PROJECT_REOPEN = "project.reopen"
INTENT_PROJECT_DELETE = "project.delete"
INTENT_TASK_CREATE = "task.create"
INTENT_TASK_UPDATE = "task.update"
INTENT_TASK_ASSIGN = "task.assign"
INTENT_TASK_PRIORITIZE = "task.prioritize"
INTENT_TASK_STATUS_TRANSITION = "task.status_transition"
INTENT_TASK_DELETE = "task.delete"
INTENT_SIGNAL_CREATE = "signal.create"
INTENT_SIGNAL_SOURCE_ONBOARD = "signal_source.onboard"
INTENT_FOLLOWUP_CONFIRM = "followup.confirm"
INTENT_FOLLOWUP_CONTINUE = "followup.continue"
INTENT_IGNORE = "ignore"
ROUTE_QUERY_ANSWER = "query.answer"
ROUTE_PROJECT_CREATE = "project_create.create"
ROUTE_TASK_CREATE = "task.create"
ROUTE_SIGNAL_SOURCE_ONBOARD = "signal_source.onboard"
ROUTE_PROJECT_SIGNAL_CREATE = "project_signal.create"
ROUTE_PROJECT_SIGNAL_DISCOVER = "project_signal.discover"
ROUTE_PROJECT_SIGNAL_COMMIT = "project_signal.commit"
ROUTE_PROJECT_SIGNAL_CANCEL = "project_signal.cancel"
ROUTE_PROJECT_SIGNAL_REFRESH = "project_signal.refresh"
ROUTE_FOLLOWUP_CONFIRM = "followup.confirm"
ROUTE_FOLLOWUP_CONTINUE = "followup.continue"
WORKFLOW_QUERY = "query_flow"
WORKFLOW_NEW_PROJECT = "new_project_flow"
WORKFLOW_PROJECT = "project_flow"
WORKFLOW_TASK = "task_flow"
WORKFLOW_PROJECT_SIGNAL = "project_signal_flow"
WORKFLOW_SIGNAL_SOURCE = "signal_source_flow"
WORKFLOW_FOLLOWUP = "followup_flow"
QUERY_SCENE_BUSINESS_REVIEW = "business_review" QUERY_SCENE_BUSINESS_REVIEW = "business_review"
QUERY_SCENE_CORRECTION_ESCALATION = "correction_escalation" QUERY_SCENE_CORRECTION_ESCALATION = "correction_escalation"
QUERY_SCENE_REQUIREMENT_PLAN = "requirement_plan" QUERY_SCENE_REQUIREMENT_PLAN = "requirement_plan"
MESSAGE_GATE_AUTO = "auto_execute" MESSAGE_GATE_AUTO = "auto_execute"
MESSAGE_GATE_PENDING = "pending_confirmation" MESSAGE_GATE_PENDING = "pending_confirmation"
MESSAGE_GATE_FAILED_SAFE = "failed_safe"
REPLY_MODE_CARD = "aggregate_card" REPLY_MODE_CARD = "aggregate_card"
REPLY_MODE_TEXT = "aggregate_text" REPLY_MODE_TEXT = "aggregate_text"
BACKGROUND_MARKERS = ("因为", "所以", "用来", "为了", "方便", "希望", "背景", "场景")
MULTI_SEGMENT_MARKERS = (",", ",", "、", ";", ";", "并且", "同时", "然后")
INTENT_GROUP_KEYWORDS: dict[str, tuple[str, ...]] = {
"project": ("项目", "立项", "创建项目", "新增项目", "新建项目"),
"signal": ("信号", "信号源", "接入", "onboard", "source"),
"task": ("任务", "分配", "指派", "执行", "部署"),
"change": ("修改", "更新", "改为", "改成", "撤销", "回滚", "归档"),
}
CORRECTION_ACTION_PATTERNS: tuple[tuple[str, tuple[str, ...]], ...] = (
("undo_last", ("撤销", "撤回", "undo", "作废上一步", "取消上一步")),
("rollback", ("回滚", "恢复到", "还原", "回退")),
("replace_target", ("改为", "改成", "替换为", "不是", "不对", "纠正")),
("reroute", ("重新识别", "重新判断", "重新路由", "再判断一次")),
)
TRANSPORT_MODE_CORRECTION_HINTS = (
"不是",
"不再",
"改为",
"改成",
"替换为",
"取代",
"现在都",
"现在是",
)
CONTEXTUAL_CONFIRM_HINTS = (
"来吧",
"那就",
"就这么办",
"就按这个",
"可以",
"好的",
"不错",
"开始吧",
"行",
"没问题",
"同意",
"确认",
)
CONTEXTUAL_DEICTIC_HINTS = (
"这个",
"这个项目",
"这条",
"它",
"按这个",
"照这个",
)
CONTEXTUAL_WRITE_ACTION_HINTS = (
"创建",
"新建",
"新增",
"增加",
"添加",
"执行",
"开始",
"继续处理",
"继续执行",
"推进",
"落地",
)
BUSINESS_REVIEW_ANALYSIS_HINTS = (
"分析",
"分析下",
"怎么做",
"如何做",
"方案",
"规划",
"梳理",
"评审",
"mvp",
"首期交付",
"需求",
"要做一个",
"想做一个",
"给我需求对应的mvp",
"我需要对应的需求mvp",
"需求mvp",
"对应的mvp",
)
BUSINESS_REVIEW_TARGET_HINTS = (
"平台",
"系统",
"产品",
"业务",
"api",
"llm",
"模型",
"聚合",
"需求",
"mvp",
"方案",
)
ASSIGNEE_RECOMMENDATION_HINTS = (
"分配给谁",
"谁做比较合适",
"谁更合适",
"推荐负责人",
"负责人怎么定",
"谁来负责",
"给谁做合适",
)
REQUIREMENT_PLAN_CHAT_HINTS = (
"聊聊需求",
"聊需求",
"先聊需求",
"想聊需求",
"想跟你聊聊需求",
"一起聊需求",
"讨论需求",
"先不创建",
"先别创建",
"先不要创建",
"先不要直接创建",
"别直接创建",
"先不入库",
"先别入库",
"先不要入库",
)
REQUIREMENT_TOPIC_SWITCH_NEW_HINTS = (
"聊个新需求",
"聊一个新需求",
"聊聊新需求",
"有个新需求",
"这是个新需求",
"这是新需求",
"这次是新需求",
"现在是新需求",
"换个新需求",
"换一个新需求",
"切到新需求",
"切个新需求",
"另一个需求",
"换个需求",
"换一个需求",
"新的需求",
"不是这个需求",
"不是刚才那个需求",
"别沿用刚才那版",
"不要沿用刚才那版",
"另起一个需求",
"重新开一个需求",
"单开一个需求",
"跟上一个无关",
"和上一个无关",
"不是延续刚才那个",
"不是接着刚才那个",
)
REQUIREMENT_TOPIC_SWITCH_NEW_PATTERNS = (
re.compile(r"(?:聊|说|讨论|讲).{0,6}(?:个|一个)?新需求", re.IGNORECASE),
re.compile(r"(?:这是|这次是|现在是|我有|这边有|还有|另外有).{0,8}(?:个|一个)?新需求", re.IGNORECASE),
re.compile(r"(?:切|换|转).{0,4}(?:到|个|一个)?(?:新需求|新的需求|新话题|newtopic|topic)", re.IGNORECASE),
re.compile(r"(?:另起|单开|重新开|新开|开个新|拉个新|起个新).{0,6}(?:需求|话题|topic)", re.IGNORECASE),
re.compile(r"(?:跟上一个无关|和上一个无关|不是延续刚才那个|不是接着刚才那个)", re.IGNORECASE),
)
CORRECTION_ESCALATION_HINTS = (
"重新回答",
"重答",
"调用高级模型",
"高级模型",
"更强模型",
"用更强模型",
"高配模型",
)
NON_REQUIREMENT_EXACT = {
"收到",
"收到啦",
"好的",
"ok",
"okk",
"okay",
"yes",
"no",
"1",
"2",
"3",
"thanks",
"thx",
"赞",
"辛苦",
"你是谁",
"你是誰",
"你是谁?",
"你是誰?",
"说话",
"在吗",
"在不在",
"今天什么日期",
"今天几号",
"今天星期几",
"现在几点",
"当前日期",
}
NON_REQUIREMENT_CONTAINS = (
"你是谁",
"什么身份",
"身份确认",
"今天什么日期",
"当前日期",
"日期疑问",
"询问日期",
"现在几点",
"几点了",
"今天周几",
"今天星期几",
"说话",
"在吗",
"在不在",
)
REQUIREMENT_HINTS = (
"需求",
"优化",
"改一下",
"调整",
"修复",
"bug",
"故障",
"报错",
"项目",
"页面",
"接口",
"功能",
"排版",
"样式",
"任务",
"立项",
"上线",
"配置",
)
LOW_VALUE_CLOSURE_HINTS = ("缺少项目归属", "必须先确认项目", "无法创建", "不能创建")
GENERIC_SIGNAL_SOURCE_MESSAGE_RE = re.compile(
r"^(?:请)?(?:按|按照)?(?:这个|上述|这条)?(?:分步|步骤)?(?:骤)?(?:把|将)?(?:它|该需求|这条需求)?(?:加入|接入|录入).{0,24}信号源(?:中)?$",
re.IGNORECASE,
)
SOURCE_ONBOARDING_ACTION_RE = re.compile(
r"(新增|增加|添加|接入|配置|注册|建立|加个|加一个|加).{0,8}(信号源|来源|监控源|channel|source|webhook|飞书|github|url|rss)",
re.IGNORECASE,
)
MODULE_KEYWORD_RULES: tuple[tuple[str, tuple[str, ...]], ...] = (
("用户管理与权限", ("用户管理", "角色权限", "crud", "批量导入", "成员管理")),
("技能图谱", ("技能图谱", "技能标签", "能力图谱", "雷达图")),
("工作量管理", ("工作量", "负载", "产能", "workload")),
("SSTC 工作流", ("sstc", "charter", "signal", "task", "execution", "release", "review", "工作流")),
("AI 能力编排", ("ai能力", "智能", "自动化", "推荐", "编排", "agent", "llm")),
("信号源接入", ("信号源", "source", "onboard", "接入")),
("飞书集成", ("飞书", "feishu", "群聊", "机器人")),
("消息台与详情页", ("消息台", "详情页", "展开详情", "摘要", "时间戳", "按钮")),
)
POLICY_GATE_RULES: tuple[tuple[str, tuple[str, ...]], ...] = (
("delete_or_archive", ("删除项目", "删除信号", "归档项目", "归档", "archive", "清空项目")),
("budget_overrun", ("超预算", "突破预算", "额外预算", "加预算")),
("cross_project_staffing", ("跨项目抢人", "抽调", "抢占关键人力", "挪人")),
("charter_rewrite", ("改写charter", "success criteria", "成功标准改", "范围重大变更")),
("critical_path_change", ("关键路径", "里程碑改动", "影响多个项目", "跨项目依赖调整")),
)
PROJECT_SIGNAL_DISCOVER_HINTS = (
"候选",
"先分析",
"先看候选",
"给我确认",
"待确认",
"再让我确认",
"确认后入库",
"先不要入库",
"先别入库",
)
FOLLOWUP_REFRESH_HINTS = ("换一批", "再来一批", "刷新候选", "重新找", "再找一组", "换一组")
FOLLOWUP_CANCEL_HINTS = ("取消", "先不做", "先别做", "不要了", "算了", "reject", "驳回")
FOLLOWUP_COMMIT_HINTS = ("确认", "继续处理", "继续执行", "同意", "批准", "approve")
FOLLOWUP_CONFIRM_HINTS = ("确认归属", "确认项目", "确认到项目", "确认关联", "确认这条")
FOLLOWUP_CONTINUE_HINTS = ("继续处理", "继续执行", "接着处理", "往下做", "继续推进")
DRAFT_CONFIRM_EXACT_HINTS = ("确认", "好的", "就这样", "ok", "okay", "同意", "没问题", "y", "yes")
DRAFT_REVISION_PREFIX_HINTS = ("修改", "改成", "改为", "换成", "改")
DRAFT_NATURAL_REVISION_HINTS = (
"第一版",
"首期",
"先做",
"先别做",
"只做",
"主要给",
"给运营",
"给团队",
"目标客户",
"用户",
"痛点",
"麻烦",
"能力",
"功能",
"数据",
"来源",
"信号源",
"团队",
"角色",
"负责人",
"技能",
"指标",
"目标",
"先接",
"补充",
"加上",
"加个",
"再加",
"再补",
)
FOLLOWUP_WORK_ITEM_ID_RE = re.compile(r"(?:work[_ -]?item|工作项|队列项|草稿)\s*(?:id|ID)?\s*[:#:]?\s*(\d+)", re.IGNORECASE)
FOLLOWUP_CONTINUE_RE = re.compile(r"(继续处理|继续推进|继续执行|接着处理|继续跑)", re.IGNORECASE)
FOLLOWUP_CREATE_CONTINUE_RE = re.compile(r"(继续创建|接着创建|继续建|接着建)", re.IGNORECASE)
FOLLOWUP_STATUS_RE = re.compile(
r"(进度|状态|处理到哪|处理了吗|现在怎么样|怎么样了|结果呢|是否已经创建|已经创建了吗|创建了吗|创建成功了吗|建好了吗|建成了吗)",
re.IGNORECASE,
)
FOLLOWUP_REFERENCE_RE = re.compile(
r"(刚才|刚刚|前面|之前|上次|上一条|我刚才要求|我刚才说|我说的|那个)",
re.IGNORECASE,
)
QUESTION_HINTS = (
"?",
"?",
"怎么",
"如何",
"为何",
"为什么",
"什么",
"是什么",
"哪些",
"有哪些",
"特点",
"技术特点",
"能力",
"介绍",
"说明",
"多少",
"是否",
"帮我看",
"状态",
"风险",
"能做什么",
"可以做什么",
)
OBVIOUS_QUESTION_RE = re.compile(
r"([??]$|吗$|呢$|呀$|吧$|怎么|如何|为什么|多少|几号|几点|周几|有没有|是否|是不是|是什么|什么|哪些|有哪些|在哪|哪一个|哪几个|特点|能力|介绍|说明|风险)",
re.IGNORECASE,
)
QUERY_INTENT_RE = re.compile(
r"(哪些|有哪些|特点|技术特点|能力|介绍|说明|怎么|如何|是什么|什么|能做什么|可以做什么|项目有哪些风险)",
re.IGNORECASE,
)
SMALLTALK_EXACT = {
"收到",
"收到啦",
"好的",
"ok",
"okk",
"yes",
"no",
"谢谢",
"thanks",
"thx",
"在吗",
"在不在",
"怎么样了",
"咋样了",
"辛苦了",
}
SMALLTALK_SHORT_HINTS = ("怎么样了", "咋样了", "在吗", "辛苦了", "收到", "谢谢", "ok")
SMALLTALK_STRUCTURED_HINTS = ("项目", "信号", "任务", "风险", "进度", "状态", "work_item", "工作项", "#")
FRONT_NOISE_HINTS = SMALLTALK_SHORT_HINTS
FRONT_NOISE_STRUCTURED_HINTS = SMALLTALK_STRUCTURED_HINTS
PROJECT_INTENT_RE = re.compile(r"(立项|新项目|我们要做|需要开发|启动|规划)", re.IGNORECASE)
SIGNAL_FACT_RE = re.compile(r"(竞品|上线|发布|反馈|风险|bug|故障|告警|投诉)", re.IGNORECASE)
TIMELINE_RE = re.compile(r"(\d+\s*(天|周|月)|本周|本月|月底|下周|下月|Q[1-4]|\d{1,2}月\d{1,2}日)")
OWNER_RE = re.compile(r"(由|负责人|owner|owner:|@)(\S+)")
INDEPENDENT_REQUIREMENT_RE = re.compile(
r"(独立需求|单独需求|单开|另起|新增需求|新建项目|新增项目|单独立项|开个新|做个新)",
re.IGNORECASE,
)
SIMPLE_SIGNAL_REQUEST_RE = re.compile(
r"(新增|增加|加一个|加个|补充|支持|实现|接入|优化|修复|改进).{0,18}(功能|能力|接口|页面|按钮|流程|识别|提醒|校验|报表|字段|规则|模块)",
re.IGNORECASE,
)
PROJECT_COMPLEXITY_RE = re.compile(
r"(立项|新项目|项目章程|里程碑|预算|排期|roadmap|跨团队|跨部门|季度目标|年度目标|okr)",
re.IGNORECASE,
)
QUESTION_REQUIREMENT_VERB_RE = re.compile(
r"(新增|增加|加一个|加个|支持|接入|实现|优化|修复|改进|调整|补充|完善|同步|对齐|落库|入库)",
re.IGNORECASE,
)
QUESTION_REQUIREMENT_OBJECT_RE = re.compile(
r"(功能|能力|接口|页面|按钮|流程|校验|报表|字段|规则|模块|任务|项目|需求|通知|提醒|配置|归属|队列|信号)",
re.IGNORECASE,
)
SIGNAL_CREATE_HINTS = ("新增一个信号", "新增一条信号", "增加一个信号", "录入一个信号", "关注", "跟踪")
TASK_CREATE_HINTS = (
"创建任务",
"新建任务",
"新增任务",
"增加任务",
"添加任务",
"加个任务",
"加一个任务",
"安排任务",
"分配任务",
"派个任务",
)
PROJECT_CREATE_HINTS = (
"创建项目",
"新建项目",
"建立项目",
"做个项目",
"立项",
"开个项目",
"新增项目",
"新增个项目",
"新增一个项目",
"增加项目",
"增加个项目",
"增加一个项目",
"添加项目",
"添加一个项目",
"加个项目",
"加一个项目",
)
NATURAL_PROJECT_CREATE_HINTS = (
"想做个",
"想做一个",
"想弄个",
"想弄一个",
"想搞个",
"想搞一个",
"做个工具",
"做一个工具",
"做个小工具",
"做一个小工具",
"做个助手",
"做一个助手",
"做个系统",
"做一个系统",
"做个平台",
"做一个平台",
"做个产品",
"做一个产品",
"搞个工具",
"搞一个工具",
"弄个工具",
"弄一个工具",
"给运营用的小工具",
"给团队用的小工具",
"给销售用的小工具",
"希望做个",
"打算做个",
"准备做个",
)
PROJECT_CREATE_QUERY_GUARD_HINTS = (
"先帮我分析",
"先帮我梳理",
"帮我分析",
"分析一下",
"分析下",
"先分析",
"怎么做",
"如何做",
"先聊方案",
"给我mvp",
"先给我mvp",
"先梳理一期",
)
PROJECT_CREATE_DEFERRED_CONFIRM_HINTS = (
"先别直接创建",
"先别创建项目",
"先别建项目",
"不要直接创建",
"先不要直接创建",
"先不要创建",
"先别入库",
"最后确认再入库",
"补充几轮再确认",
"等我确认再入库",
"我再补充几轮",
"我还在补范围",
"还在补范围",
"范围还没定",
)
SHORT_CONFIRM_HINTS = (
"是",
"好",
"对",
"确认",
"新增",
"加",
"嗯",
"yes",
"ok",
"好的",
"是的",
"行",
"可以",
"没问题",
)
HEALTHCHECK_RE = re.compile(
r"^(?:ping|pong|health(?:check)?|在线吗|活着吗|存活吗|还在吗|服务正常吗)\??$",
re.IGNORECASE,
)
SHORT_CONFIRM_RE = re.compile(
r"^(?:收到|好的|好|ok|okay|明白|了解|知道了|收到啦|嗯嗯|行|辛苦了|thanks?|thx)[!!.。]?$",
re.IGNORECASE,
)
PROJECT_CREATE_REGEX = re.compile(
r"(?:新增|增加|添加|新建|创建|建立|开个|开一个|建个|建一个|做个|做一个|加个|加一个|立项)(?:一个|个)?(?:[^,。!?,.!?]{0,24})项目",
re.IGNORECASE,
)
NATURAL_PROJECT_CREATE_REGEX = re.compile(
r"(?:想|想要|希望|打算|准备).{0,8}(?:做|弄|搞|整).{0,8}(?:个|一个)?.{0,20}(?:工具|小工具|助手|系统|平台|产品|应用|服务)",
re.IGNORECASE,
)
TASK_CREATE_REGEX = re.compile(
r"(?:给|帮|请).{0,24}(?:新增|增加|添加|创建|新建|加).{0,16}(?:任务|工单|待办)",
re.IGNORECASE,
)
SIGNAL_CREATE_REGEX = re.compile(
r"(?:给|帮|请).{0,20}(?:新增|增加|添加|补充|加).{0,20}(?:信号|需求|事项)",
re.IGNORECASE,
)
PROJECT_LIST_QUERY_RE = re.compile(
r"(现在|当前|目前|系统里|系统中|项目里|项目中)?.{0,4}(有哪些项目|项目列表|项目清单|哪(些|几个)项目)",
re.IGNORECASE,
)
PROJECT_ID_QUERY_RE = re.compile(
r"项目\s*id\s*[::]?\s*(\d+).{0,12}(有吗|存在吗|还在吗|是否存在|是不是)",
re.IGNORECASE,
)
LEGACY_COMMAND_PROJECT_CREATE_RE = re.compile(
r"(创建|新建|建立|开个|开一个|建个|建一个|做个|做一个).{0,6}项目",
re.IGNORECASE,
)
LEGACY_COMMAND_VERB_RE = re.compile(
r"(创建|新建|建立|开个|开一个|建个|建一个|做个|做一个|修改|更新|改成|改为|设为|设成|分配|指派|归档|重开|重新打开|恢复|标记完成|完成|提醒|通知|调整工时|调整工作量)",
re.IGNORECASE,
)
LEGACY_COMMAND_OBJECT_RE = re.compile(
r"(项目|任务|负责人|优先级|状态|通知|提醒|工时|工作量)",
re.IGNORECASE,
)
CREATE_ACTION_RE = re.compile(r"(新增|新建|创建|建立|接入|配置|录入|加入|放到|放进|立项)", re.IGNORECASE)
CORRECTION_CUE_RE = re.compile(
r"(不对|错了|理解错|不是这个|不是这个意思|我说的是|我要的是|不是啊|不是新增 md|不是写 markdown)",
re.IGNORECASE,
)
EXTERNAL_INTEGRATION_RE = re.compile(
r"(github|gitlab|jira|slack|webhook|飞书|企业微信|企微|lark|api|接入|授权|同步)",
re.IGNORECASE,
)
__all__ = [name for name in globals() if not name.startswith("__")]

View File

@ -1,7 +1,6 @@
from __future__ import annotations from __future__ import annotations
from .shared import * from .shared import *
from ...ai_pmo.workflow_semantic_planner import WorkflowSemanticPlanner
class FeishuReceiveRoutingMixin: class FeishuReceiveRoutingMixin:
async def _route_scene_reply( async def _route_scene_reply(
@ -152,7 +151,27 @@ class FeishuReceiveRoutingMixin:
if not isinstance(capture_result, dict): if not isinstance(capture_result, dict):
return "error" return "error"
_ = message_content _ = message_content
return WorkflowSemanticPlanner.classify_scene(capture_result=capture_result) semantic_bundle = capture_result.get("semantic_bundle")
if isinstance(semantic_bundle, dict):
workflows = semantic_bundle.get("workflows")
if isinstance(workflows, list) and workflows:
workflow_types = {
str(item.get("workflow_type") or "").strip()
for item in workflows
if isinstance(item, dict)
}
if any(workflow_type and workflow_type != "query_flow" for workflow_type in workflow_types):
return "execute"
return "qa"
status = str(capture_result.get("status") or "").strip().lower()
classification = str(capture_result.get("classification") or "").strip().lower()
if status == "error" or classification == "error":
return "error"
if classification in {"project", "signal", "command", "new_project", "project_signal"}:
return "execute"
if status in {"captured", "ignored"}:
return "execute"
return "qa"
@staticmethod @staticmethod
def _build_error_reply_text() -> str: def _build_error_reply_text() -> str:

View File

@ -1,7 +1,6 @@
from __future__ import annotations from __future__ import annotations
from .shared import * from .shared import *
from ...ai_pmo.workflow_semantic_planner import WorkflowSemanticPlanner
class FeishuReplyLLMCoreMixin: class FeishuReplyLLMCoreMixin:
async def _reply_with_active_role_prompt( async def _reply_with_active_role_prompt(
@ -121,7 +120,7 @@ class FeishuReplyLLMCoreMixin:
capture_result, capture_result,
reason="模型返回空内容", reason="模型返回空内容",
) )
elif self._looks_like_provider_error(reply_text): elif self._is_provider_error(reply_text):
reply_text = self._build_llm_failure_reply_text( reply_text = self._build_llm_failure_reply_text(
capture_result, capture_result,
reason=reply_text, reason=reply_text,
@ -228,7 +227,17 @@ class FeishuReplyLLMCoreMixin:
*, *,
capture_result: Dict[str, Any], capture_result: Dict[str, Any],
) -> bool: ) -> bool:
return WorkflowSemanticPlanner.is_action_request_capture(capture_result=capture_result) semantic_bundle = capture_result.get("semantic_bundle")
if not isinstance(semantic_bundle, dict):
return False
workflows = semantic_bundle.get("workflows")
if not isinstance(workflows, list) or not workflows:
return False
return any(
str(item.get("workflow_type") or "").strip() not in {"", "query_flow"}
for item in workflows
if isinstance(item, dict)
)
@staticmethod @staticmethod
def _build_consultant_dispatch_reply(capture_result: Dict[str, Any]) -> Optional[str]: def _build_consultant_dispatch_reply(capture_result: Dict[str, Any]) -> Optional[str]:
@ -245,8 +254,13 @@ class FeishuReplyLLMCoreMixin:
_ = message_content _ = message_content
if not cls._is_action_request_message(capture_result=capture_result): if not cls._is_action_request_message(capture_result=capture_result):
return None return None
bundle = WorkflowSemanticPlanner.deserialize_bundle(capture_result.get("semantic_bundle")) semantic_bundle = capture_result.get("semantic_bundle")
workflow_types = [item.workflow_type for item in (bundle.workflows if bundle is not None else [])] workflows = semantic_bundle.get("workflows") if isinstance(semantic_bundle, dict) else []
workflow_types = [
str(item.get("workflow_type") or "").strip()
for item in workflows
if isinstance(item, dict)
]
project_like = "new_project_flow" in workflow_types or "project_flow" in workflow_types project_like = "new_project_flow" in workflow_types or "project_flow" in workflow_types
signal_like = "signal_source_flow" in workflow_types or "project_signal_flow" in workflow_types signal_like = "signal_source_flow" in workflow_types or "project_signal_flow" in workflow_types
if project_like and signal_like: if project_like and signal_like:
@ -283,7 +297,7 @@ class FeishuReplyLLMCoreMixin:
return text return text
@staticmethod @staticmethod
def _looks_like_provider_error(text: str) -> bool: def _is_provider_error(text: str) -> bool:
lowered = str(text or "").strip().lower() lowered = str(text or "").strip().lower()
if not lowered: if not lowered:
return False return False

View File

@ -4,6 +4,7 @@ from .shared import *
class FeishuReplyOutboxMixin: class FeishuReplyOutboxMixin:
_CARD_MESSAGE_PREFIX = "__pmflow_feishu_card__:" _CARD_MESSAGE_PREFIX = "__pmflow_feishu_card__:"
_PLAIN_TEXT_MESSAGE_LIMIT = 12000
@classmethod @classmethod
def _is_card_outbox_message(cls, value: Optional[str]) -> bool: def _is_card_outbox_message(cls, value: Optional[str]) -> bool:
@ -26,6 +27,31 @@ class FeishuReplyOutboxMixin:
return parsed return parsed
return None return None
@staticmethod
def _is_requirement_plan_reply_text(value: Optional[str]) -> bool:
text = str(value or "")
return any(marker in text for marker in ("A. ", "B. ", "C. ", "⭐ 当前我更推荐:"))
@classmethod
def _guard_requirement_plan_reply_integrity(cls, value: Optional[str]) -> None:
text = str(value or "").strip()
if not cls._is_requirement_plan_reply_text(text):
return
required_markers = ("A. ", "B. ", "C. ", "⭐ 当前我更推荐:")
if any(marker not in text for marker in required_markers):
raise ValueError("incomplete_requirement_plan_reply")
@classmethod
def _normalize_reply_outbox_message_text(cls, message_text: Optional[str]) -> str:
raw_text = str(message_text or "")
if cls._is_card_outbox_message(raw_text):
return raw_text[:12000]
normalized = raw_text.strip()
cls._guard_requirement_plan_reply_integrity(normalized)
if len(normalized) > cls._PLAIN_TEXT_MESSAGE_LIMIT and cls._is_requirement_plan_reply_text(normalized):
raise ValueError("requirement_plan_reply_exceeds_plain_text_outbox_limit")
return normalized[: cls._PLAIN_TEXT_MESSAGE_LIMIT] or "已收到,正在处理。"
async def _safe_reply_text( async def _safe_reply_text(
self, self,
*, *,
@ -116,10 +142,7 @@ class FeishuReplyOutboxMixin:
) -> int: ) -> int:
async with AsyncSessionLocal() as db: async with AsyncSessionLocal() as db:
normalized_source_message_id = (source_message_id or "")[:128] or None normalized_source_message_id = (source_message_id or "")[:128] or None
if self._is_card_outbox_message(message_text): normalized_message_text = self._normalize_reply_outbox_message_text(message_text)
normalized_message_text = (message_text or "")[:12000]
else:
normalized_message_text = (message_text or "")[:2000] or "已收到,正在处理。"
reply_hash = self._build_reply_hash( reply_hash = self._build_reply_hash(
source_message_id=normalized_source_message_id, source_message_id=normalized_source_message_id,
message_text=normalized_message_text, message_text=normalized_message_text,
@ -382,8 +405,8 @@ class FeishuReplyOutboxMixin:
text = text.replace("```", "").strip() text = text.replace("```", "").strip()
if text.lower().startswith("json"): if text.lower().startswith("json"):
text = text[4:].strip() text = text[4:].strip()
looks_like_provider_error = getattr(cls, "_looks_like_provider_error", None) provider_error_detector = getattr(cls, "_is_provider_error", None)
has_provider_error = callable(looks_like_provider_error) and bool(looks_like_provider_error(text)) has_provider_error = callable(provider_error_detector) and bool(provider_error_detector(text))
if not text or has_provider_error: if not text or has_provider_error:
error_builder = getattr(cls, "_build_error_reply_text", None) error_builder = getattr(cls, "_build_error_reply_text", None)
if callable(error_builder): if callable(error_builder):

View File

@ -14,6 +14,7 @@ from ....models import FeishuReplyOutbox
class FeishuOutboxService: class FeishuOutboxService:
"""复用 reply_hash 幂等策略的 outbox 写入入口。""" """复用 reply_hash 幂等策略的 outbox 写入入口。"""
_PLAIN_TEXT_MESSAGE_LIMIT = 12000
@staticmethod @staticmethod
def _build_reply_hash(*, source_message_id: Optional[str], message_text: str) -> Optional[str]: def _build_reply_hash(*, source_message_id: Optional[str], message_text: str) -> Optional[str]:
@ -24,6 +25,28 @@ class FeishuOutboxService:
raw = f"{source}{body}".encode("utf-8") raw = f"{source}{body}".encode("utf-8")
return hashlib.md5(raw, usedforsecurity=False).hexdigest() return hashlib.md5(raw, usedforsecurity=False).hexdigest()
@staticmethod
def _is_requirement_plan_reply_text(value: Optional[str]) -> bool:
text = str(value or "")
return any(marker in text for marker in ("A. ", "B. ", "C. ", "⭐ 当前我更推荐:"))
@classmethod
def _guard_requirement_plan_reply_integrity(cls, value: Optional[str]) -> None:
text = str(value or "").strip()
if not cls._is_requirement_plan_reply_text(text):
return
required_markers = ("A. ", "B. ", "C. ", "⭐ 当前我更推荐:")
if any(marker not in text for marker in required_markers):
raise ValueError("incomplete_requirement_plan_reply")
@classmethod
def _normalize_outbox_text(cls, value: Optional[str]) -> str:
normalized = str(value or "").strip()
cls._guard_requirement_plan_reply_integrity(normalized)
if len(normalized) > cls._PLAIN_TEXT_MESSAGE_LIMIT and cls._is_requirement_plan_reply_text(normalized):
raise ValueError("requirement_plan_reply_exceeds_plain_text_outbox_limit")
return normalized[: cls._PLAIN_TEXT_MESSAGE_LIMIT] or "已收到,正在处理。"
@classmethod @classmethod
async def enqueue( async def enqueue(
cls, cls,
@ -39,7 +62,7 @@ class FeishuOutboxService:
normalized_chat_id = str(chat_id or "").strip()[:128] normalized_chat_id = str(chat_id or "").strip()[:128]
if not normalized_chat_id: if not normalized_chat_id:
return None return None
normalized_text = str(text or "").strip()[:2000] or "已收到,正在处理。" normalized_text = cls._normalize_outbox_text(text)
normalized_source_message_id = (str(source_message_id or "").strip()[:128] or None) normalized_source_message_id = (str(source_message_id or "").strip()[:128] or None)
hash_source = normalized_source_message_id or (str(dedupe_seed or "").strip()[:128] or None) hash_source = normalized_source_message_id or (str(dedupe_seed or "").strip()[:128] or None)
reply_hash = cls._build_reply_hash(source_message_id=hash_source, message_text=normalized_text) reply_hash = cls._build_reply_hash(source_message_id=hash_source, message_text=normalized_text)

View File

@ -6,7 +6,7 @@ from .shared import *
class FeishuPipelineContextPrefillAMixin: class FeishuPipelineContextPrefillAMixin:
@staticmethod @staticmethod
def _looks_like_placeholder_project_text(text: str) -> bool: def _is_placeholder_project_text(text: str) -> bool:
normalized = re.sub(r"\s+", "", str(text or "")).lower() normalized = re.sub(r"\s+", "", str(text or "")).lower()
if not normalized: if not normalized:
return True return True
@ -33,7 +33,7 @@ class FeishuPipelineContextPrefillAMixin:
) )
@staticmethod @staticmethod
def _looks_like_project_request_artifact(text: str) -> bool: # semantic-lint: ignore - project context artifact detector, not workflow routing def _is_project_request_artifact(text: str) -> bool:
normalized = re.sub(r"\s+", "", str(text or "")).lower() normalized = re.sub(r"\s+", "", str(text or "")).lower()
if not normalized: if not normalized:
return False return False
@ -60,7 +60,7 @@ class FeishuPipelineContextPrefillAMixin:
) )
@staticmethod @staticmethod
def _looks_like_unusable_project_context(text: str) -> bool: def _is_unusable_project_context(text: str) -> bool:
normalized = re.sub(r"\s+", "", str(text or "")).lower() normalized = re.sub(r"\s+", "", str(text or "")).lower()
if not normalized: if not normalized:
return True return True
@ -209,7 +209,7 @@ class FeishuPipelineContextPrefillAMixin:
text = self._strip_feishu_mention_prefix(str(row.message_text or "")) text = self._strip_feishu_mention_prefix(str(row.message_text or ""))
if not text: if not text:
continue continue
if self._looks_like_unusable_project_context(text): if self._is_unusable_project_context(text):
continue continue
return text[:4000] return text[:4000]
return "" return ""
@ -224,19 +224,19 @@ class FeishuPipelineContextPrefillAMixin:
return fields return fields
embedded_context = self._extract_embedded_project_context(message) embedded_context = self._extract_embedded_project_context(message)
if not embedded_context or self._looks_like_unusable_project_context(embedded_context): if not embedded_context or self._is_unusable_project_context(embedded_context):
return fields return fields
current_description = str(fields.get("description") or "").strip() current_description = str(fields.get("description") or "").strip()
if ( if (
self._looks_like_placeholder_project_text(current_description) self._is_placeholder_project_text(current_description)
or self._looks_like_project_request_artifact(current_description) or self._is_project_request_artifact(current_description)
): ):
current_description = "" current_description = ""
current_scope = str(fields.get("project_scope") or "").strip() current_scope = str(fields.get("project_scope") or "").strip()
if ( if (
self._looks_like_placeholder_project_text(current_scope) self._is_placeholder_project_text(current_scope)
or self._looks_like_project_request_artifact(current_scope) or self._is_project_request_artifact(current_scope)
): ):
current_scope = "" current_scope = ""
@ -258,11 +258,11 @@ class FeishuPipelineContextPrefillAMixin:
suggested_name = normalize_project_title_candidate( suggested_name = normalize_project_title_candidate(
str(project_info.get("suggested_name") or "").strip() str(project_info.get("suggested_name") or "").strip()
) )
if self._looks_like_placeholder_project_text(suggested_name): if self._is_placeholder_project_text(suggested_name):
suggested_name = "" suggested_name = ""
title = title or suggested_name title = title or suggested_name
description = description or str(project_info.get("suggested_description") or "").strip()[:4000] description = description or str(project_info.get("suggested_description") or "").strip()[:4000]
if self._looks_like_unusable_project_context(description): if self._is_unusable_project_context(description):
description = "" description = ""
project_scope = project_scope or self._extract_project_scope(embedded_context, description=description) project_scope = project_scope or self._extract_project_scope(embedded_context, description=description)

View File

@ -18,7 +18,7 @@ class FeishuPipelineContextPrefillBMixin:
) -> dict[str, Any]: ) -> dict[str, Any]:
if fields.get("ready"): if fields.get("ready"):
return fields return fields
if not source_chat_id or not self._looks_like_contextual_project_request(message): if not source_chat_id or not self._is_contextual_project_request(message):
return fields return fields
turns = await self._load_recent_chat_turns( turns = await self._load_recent_chat_turns(
@ -39,7 +39,7 @@ class FeishuPipelineContextPrefillBMixin:
if recent_question: if recent_question:
context_blocks.append(f"最近用户问题:{recent_question}") context_blocks.append(f"最近用户问题:{recent_question}")
question_hint = recent_question question_hint = recent_question
if recent_answer and not self._looks_like_unusable_project_context(recent_answer): if recent_answer and not self._is_unusable_project_context(recent_answer):
context_blocks.append(f"最近系统回答:{recent_answer}") context_blocks.append(f"最近系统回答:{recent_answer}")
answer_hint = recent_answer answer_hint = recent_answer
@ -48,8 +48,8 @@ class FeishuPipelineContextPrefillBMixin:
base_description = str(fields.get("description") or "").strip() base_description = str(fields.get("description") or "").strip()
if ( if (
self._looks_like_placeholder_project_text(base_description) self._is_placeholder_project_text(base_description)
or self._looks_like_project_request_artifact(base_description) or self._is_project_request_artifact(base_description)
): ):
base_description = "" base_description = ""
if not answer_hint and not base_description: if not answer_hint and not base_description:
@ -59,8 +59,8 @@ class FeishuPipelineContextPrefillBMixin:
heuristic_description = base_description or answer_hint[:4000] heuristic_description = base_description or answer_hint[:4000]
heuristic_scope = str(fields.get("project_scope") or "").strip() heuristic_scope = str(fields.get("project_scope") or "").strip()
if ( if (
self._looks_like_placeholder_project_text(heuristic_scope) self._is_placeholder_project_text(heuristic_scope)
or self._looks_like_project_request_artifact(heuristic_scope) or self._is_project_request_artifact(heuristic_scope)
): ):
heuristic_scope = "" heuristic_scope = ""
if not heuristic_scope and heuristic_description: if not heuristic_scope and heuristic_description:
@ -85,14 +85,14 @@ class FeishuPipelineContextPrefillBMixin:
project_info = await self._extract_project_prefill(prefill_source) project_info = await self._extract_project_prefill(prefill_source)
current_description = str(fields.get("description") or "").strip() current_description = str(fields.get("description") or "").strip()
if ( if (
self._looks_like_placeholder_project_text(current_description) self._is_placeholder_project_text(current_description)
or self._looks_like_project_request_artifact(current_description) or self._is_project_request_artifact(current_description)
): ):
current_description = "" current_description = ""
current_scope = str(fields.get("project_scope") or "").strip() current_scope = str(fields.get("project_scope") or "").strip()
if ( if (
self._looks_like_placeholder_project_text(current_scope) self._is_placeholder_project_text(current_scope)
or self._looks_like_project_request_artifact(current_scope) or self._is_project_request_artifact(current_scope)
): ):
current_scope = "" current_scope = ""
title = ( title = (
@ -103,7 +103,7 @@ class FeishuPipelineContextPrefillBMixin:
description = current_description or str(project_info.get("suggested_description") or "").strip() description = current_description or str(project_info.get("suggested_description") or "").strip()
if not description and answer_hint: if not description and answer_hint:
description = answer_hint[:4000] description = answer_hint[:4000]
if self._looks_like_unusable_project_context(description): if self._is_unusable_project_context(description):
description = "" description = ""
project_scope = current_scope project_scope = current_scope
if not project_scope: if not project_scope:
@ -190,12 +190,12 @@ class FeishuPipelineContextPrefillBMixin:
project_scope: str, project_scope: str,
) -> str: ) -> str:
normalized_title = normalize_project_title_candidate(title) normalized_title = normalize_project_title_candidate(title)
if normalized_title and not cls._looks_like_placeholder_project_text(normalized_title): if normalized_title and not cls._is_placeholder_project_text(normalized_title):
return normalized_title return normalized_title
for candidate in (project_scope, description): for candidate in (project_scope, description):
cleaned = str(candidate or "").strip() cleaned = str(candidate or "").strip()
if not cleaned or cls._looks_like_placeholder_project_text(cleaned): if not cleaned or cls._is_placeholder_project_text(cleaned):
continue continue
normalized = normalize_project_title_candidate(cleaned[:48]) normalized = normalize_project_title_candidate(cleaned[:48])
if normalized: if normalized:
@ -212,10 +212,10 @@ class FeishuPipelineContextPrefillBMixin:
fallback_text: str, fallback_text: str,
) -> str: ) -> str:
normalized_description = str(description or "").strip() normalized_description = str(description or "").strip()
if cls._looks_like_placeholder_project_text(normalized_description): if cls._is_placeholder_project_text(normalized_description):
normalized_description = "" normalized_description = ""
normalized_scope = str(project_scope or "").strip() normalized_scope = str(project_scope or "").strip()
if cls._looks_like_placeholder_project_text(normalized_scope): if cls._is_placeholder_project_text(normalized_scope):
normalized_scope = "" normalized_scope = ""
fragments: list[str] = [] fragments: list[str] = []

View File

@ -2,7 +2,6 @@
from __future__ import annotations from __future__ import annotations
from .shared import * from .shared import *
from ...ai_pmo.workflow_semantic_planner import WorkflowSemanticPlanner
class FeishuPipelineCoreBaseMixin: class FeishuPipelineCoreBaseMixin:
@ -34,32 +33,12 @@ class FeishuPipelineCoreBaseMixin:
@staticmethod @staticmethod
def _is_healthcheck_message(message: str) -> bool: def _is_healthcheck_message(message: str) -> bool:
return WorkflowSemanticPlanner.is_healthcheck_message(message) return bool(HEALTHCHECK_RE.search(str(message or "").strip()))
@staticmethod
def _is_short_confirmation_message(message: str) -> bool:
return WorkflowSemanticPlanner.is_short_confirmation_noise(message)
@staticmethod @staticmethod
def _is_correction_message(message: str) -> bool: def _is_correction_message(message: str) -> bool:
return bool(CORRECTION_CUE_RE.search(str(message or "").strip())) return bool(CORRECTION_CUE_RE.search(str(message or "").strip()))
@staticmethod
def _looks_like_continue_followup(message: str) -> bool:
return WorkflowSemanticPlanner.wants_followup_continue(message)
@staticmethod
def _looks_like_task_create_request(message: str) -> bool: # semantic-lint: ignore - compat wrapper over workflow_semantic_planner
return WorkflowSemanticPlanner.looks_like_task_create_request(message)
@staticmethod
def _looks_like_project_create_request(message: str) -> bool: # semantic-lint: ignore - compat wrapper over workflow_semantic_planner
return WorkflowSemanticPlanner.looks_like_project_create_request(message)
@staticmethod
def _looks_like_external_connect_request(message: str) -> bool: # semantic-lint: ignore - compat wrapper over workflow_semantic_planner
return WorkflowSemanticPlanner.looks_like_external_connect_request(message)
@staticmethod @staticmethod
def _can_execute_high_value_action(user: Optional[User]) -> bool: def _can_execute_high_value_action(user: Optional[User]) -> bool:
if user is None: if user is None:

View File

@ -2,6 +2,7 @@
from __future__ import annotations from __future__ import annotations
from .shared import * from .shared import *
from ...ai_pmo.workflow_semantic_planner import WorkflowSemanticPlanner
class FeishuPipelineIntentGateCoreMixin: class FeishuPipelineIntentGateCoreMixin:
@ -27,9 +28,23 @@ class FeishuPipelineIntentGateCoreMixin:
"correction_match": None, "correction_match": None,
} }
explicit_project_request = self._looks_like_project_create_request(text) semantic_bundle = await WorkflowSemanticPlanner.aplan_message(
explicit_task_request = self._looks_like_task_create_request(text) message_text=text,
explicit_external_request = self._looks_like_external_connect_request(text) source_system="feishu_signal_pipeline",
metadata={
"project_id": inferred_project_id,
"source_chat_id": source_chat_id,
"source_sender_open_id": source_sender_open_id,
"sender": sender,
"source_message_id": source_message_id,
"thread_context": dict(thread_context or {}),
},
)
workflow_types = {str(item.workflow_type or "").strip().lower() for item in semantic_bundle.workflows}
query_only = bool(semantic_bundle.workflows) and workflow_types == {"query_flow"}
explicit_project_request = "new_project_flow" in workflow_types
explicit_task_request = "task_flow" in workflow_types
explicit_external_request = "signal_source_flow" in workflow_types
correction_match: Optional[dict[str, Any]] = None correction_match: Optional[dict[str, Any]] = None
if self.db is not None and len(text) >= 4: if self.db is not None and len(text) >= 4:
try: try:
@ -76,16 +91,6 @@ class FeishuPipelineIntentGateCoreMixin:
"fields": {}, "fields": {},
"correction_match": correction_match, "correction_match": correction_match,
} }
if self._is_short_confirmation_message(text):
return {
"intent": INTENT_SMALLTALK,
"canonical_action": None,
"clarification_template": "",
"risk_level": "low",
"fields": {},
"correction_match": correction_match,
}
if ( if (
correction_match correction_match
and float(correction_match.get("score") or 0.0) >= 0.84 and float(correction_match.get("score") or 0.0) >= 0.84
@ -175,17 +180,7 @@ class FeishuPipelineIntentGateCoreMixin:
"correction_match": correction_match, "correction_match": correction_match,
} }
if self._is_query_like_question(text): if query_only:
return {
"intent": INTENT_QUERY,
"canonical_action": None,
"clarification_template": "",
"risk_level": "low",
"fields": {},
"correction_match": correction_match,
}
if self._is_obvious_question(text):
return { return {
"intent": INTENT_QUERY, "intent": INTENT_QUERY,
"canonical_action": None, "canonical_action": None,
@ -193,6 +188,7 @@ class FeishuPipelineIntentGateCoreMixin:
"risk_level": "low", "risk_level": "low",
"fields": {}, "fields": {},
"correction_match": correction_match, "correction_match": correction_match,
"semantic_bundle": semantic_bundle.to_dict(),
} }
return { return {
@ -257,7 +253,7 @@ class FeishuPipelineIntentGateCoreMixin:
} }
@staticmethod @staticmethod
def _looks_like_contextual_project_request(message: str) -> bool: # semantic-lint: ignore - context prefill helper for missing fields only def _is_contextual_project_request(message: str) -> bool:
normalized = re.sub(r"\s+", "", str(message or "")).lower() normalized = re.sub(r"\s+", "", str(message or "")).lower()
if not normalized: if not normalized:
return False return False

View File

@ -2,21 +2,50 @@
from __future__ import annotations from __future__ import annotations
from .shared import * from .shared import *
from ...ai_pmo.workflow_semantic_planner import WorkflowSemanticPlanner
class FeishuPipelineLookupAndBlockMixin: class FeishuPipelineLookupAndBlockMixin:
@staticmethod @staticmethod
def _looks_like_project_create_command(message: str) -> bool: # semantic-lint: ignore - compat wrapper over workflow_semantic_planner def _extract_project_lookup_keyword(message: str) -> str:
return WorkflowSemanticPlanner.looks_like_project_create_request(message) text = str(message or "").strip()
if not text:
return ""
text = text.replace("?", "?")
direct_patterns = [
r"(?:是否有|有没有|有无)([\u4e00-\u9fffA-Za-z0-9_-]{1,24})(?:项目|系统)?",
r"(?:是否存在|存在)([\u4e00-\u9fffA-Za-z0-9_-]{1,24})(?:项目|系统)?",
]
for pattern in direct_patterns:
match = re.search(pattern, text)
if not match:
continue
value = str(match.group(1) or "").strip(" ,。,.!??::;;“”\"'()()")
value = QUESTION_NOISE_RE.sub("", value).strip()
if len(value) >= 2:
return value[:24]
anchored_matches = list(re.finditer(r"([\u4e00-\u9fffA-Za-z0-9_-]{1,24})(?:项目|系统)", text))
for match in reversed(anchored_matches):
value = str(match.group(1) or "").strip(" ,。,.!??::;;“”\"'()()")
value = QUESTION_NOISE_RE.sub("", value).strip()
if len(value) >= 2:
return value[:24]
return ""
@classmethod @classmethod
def _looks_like_project_lookup_question(cls, message: str) -> bool: # semantic-lint: ignore - compat wrapper over workflow_semantic_planner def _is_project_lookup_question(cls, message: str) -> bool:
return WorkflowSemanticPlanner.looks_like_project_lookup_question(message) raw = str(message or "").strip()
if not raw:
@classmethod return False
def _is_legacy_business_command(cls, message: str) -> bool: if PROJECT_ID_QUERY_RE.search(raw) or PROJECT_LIST_QUERY_RE.search(raw):
return WorkflowSemanticPlanner.is_legacy_business_command(message) return True
if "项目" not in raw and "系统" not in raw:
return False
if "?" not in raw and "?" not in raw and not any(token in raw for token in ("有没有", "是否有", "有无", "存在", "列表", "清单")):
return False
return bool(cls._extract_project_lookup_keyword(raw))
async def _maybe_block_legacy_command_request( async def _maybe_block_legacy_command_request(
self, self,
@ -31,16 +60,18 @@ class FeishuPipelineLookupAndBlockMixin:
projects: list[dict[str, Any]], projects: list[dict[str, Any]],
process_started_at: float, process_started_at: float,
) -> Optional[dict[str, Any]]: ) -> Optional[dict[str, Any]]:
if not self._is_legacy_business_command(message): _ = (
return None message,
sender,
_ = sender, source_chat_id, source_sender_open_id, captured_at, inferred_project_id, projects source_chat_id,
return await self._build_legacy_command_blocked_capture( source_sender_open_id,
message=message, feishu_message_id,
feishu_message_id=feishu_message_id, captured_at,
duration_ms=int((time.perf_counter() - process_started_at) * 1000), inferred_project_id,
reason="旧 command-first 规划链路已下线,请改用 GLM 分析后直接调用显式接口。", projects,
process_started_at,
) )
return None
async def _maybe_answer_project_lookup_question( async def _maybe_answer_project_lookup_question(
self, self,
@ -50,7 +81,7 @@ class FeishuPipelineLookupAndBlockMixin:
process_started_at: float, process_started_at: float,
projects: list[dict[str, Any]], projects: list[dict[str, Any]],
) -> Optional[dict[str, Any]]: ) -> Optional[dict[str, Any]]:
if not self._looks_like_project_lookup_question(message): if not self._is_project_lookup_question(message):
return None return None
linked_project_ids: list[int] = [] linked_project_ids: list[int] = []
@ -71,7 +102,7 @@ class FeishuPipelineLookupAndBlockMixin:
reply_text = self._format_project_lookup_hit_rows(projects[:8], prefix="当前项目") reply_text = self._format_project_lookup_hit_rows(projects[:8], prefix="当前项目")
linked_project_ids = [int(item["id"]) for item in projects[:8] if _safe_int(item.get("id"))] linked_project_ids = [int(item["id"]) for item in projects[:8] if _safe_int(item.get("id"))]
else: else:
keyword = WorkflowSemanticPlanner.extract_project_lookup_keyword(message) keyword = self._extract_project_lookup_keyword(message)
matched_rows = self._match_projects_by_keyword(projects=projects, keyword=keyword) matched_rows = self._match_projects_by_keyword(projects=projects, keyword=keyword)
if matched_rows: if matched_rows:
linked_project_ids = [int(item["id"]) for item in matched_rows if _safe_int(item.get("id"))] linked_project_ids = [int(item["id"]) for item in matched_rows if _safe_int(item.get("id"))]
@ -195,9 +226,7 @@ class FeishuPipelineLookupAndBlockMixin:
return True return True
if cls._is_healthcheck_message(raw_message): if cls._is_healthcheck_message(raw_message):
return True return True
if cls._is_short_confirmation_message(raw_message): if cls._is_project_lookup_question(raw_message):
return True
if cls._looks_like_project_lookup_question(raw_message):
return True return True
if "长连接" in normalized and any(token in normalized for token in ("已通", "都已通", "正常", "在线", "已恢复")): if "长连接" in normalized and any(token in normalized for token in ("已通", "都已通", "正常", "在线", "已恢复")):
return True return True

View File

@ -35,17 +35,14 @@ class FeishuPipelineProcessMessageDefaultMixin:
intent_for_response = "statement" intent_for_response = "statement"
force_signal_fastpath = False force_signal_fastpath = False
rag_question_part = message rag_question_part = message
intent_result = await rag_service.classify_intent(message)
intent = str(intent_result.get("intent") or "statement").lower()
intent_for_response = intent
rag_question_part = str(intent_result.get("question_part") or "").strip() or message
rag_statement_part = str(intent_result.get("statement_part") or "").strip()
if self._is_obvious_question(message): if intent == "question":
intent_result: dict[str, Any] = {} if self._is_requirement_like_question(message):
try:
intent_result = await rag_service.classify_intent(message)
except Exception:
intent_result = {}
intent = str(intent_result.get("intent") or "question").lower()
rag_question_part = str(intent_result.get("question_part") or "").strip() or message
rag_statement_part = str(intent_result.get("statement_part") or "").strip()
if intent == "mixed" or self._is_requirement_like_question(message):
mixed_signal_part = ( mixed_signal_part = (
rag_statement_part rag_statement_part
or self._convert_requirement_question_to_signal_text( or self._convert_requirement_question_to_signal_text(
@ -88,89 +85,34 @@ class FeishuPipelineProcessMessageDefaultMixin:
inferred_project_id=None, inferred_project_id=None,
feishu_message_id=feishu_message_id, feishu_message_id=feishu_message_id,
process_started_at=process_started_at, process_started_at=process_started_at,
route_reason="明显问句直接走 RAG 直答通道,不进入决策队列。", route_reason="识别为问答消息,已走 RAG 直答通道,不进入决策队列。",
intent_label="question_obvious", intent_label=intent,
normalized_chat_type=normalized_chat_type, normalized_chat_type=normalized_chat_type,
) )
else:
intent_result = await rag_service.classify_intent(message)
intent = str(intent_result.get("intent") or "statement").lower()
intent_for_response = intent
rag_question_part = str(intent_result.get("question_part") or "").strip() or message
rag_statement_part = str(intent_result.get("statement_part") or "").strip()
if intent == "question": if intent == "mixed":
if self._is_requirement_like_question(message): mixed_signal_part = (
mixed_signal_part = ( rag_statement_part
rag_statement_part or self._convert_requirement_question_to_signal_text(
or self._convert_requirement_question_to_signal_text( message=message,
message=message, fallback_content=message_content,
fallback_content=message_content,
)
)
mixed_rag_result = await rag_service.answer_question(
question=rag_question_part,
asked_by=None,
asked_via="feishu",
project_id=None,
source_chat_id=source_chat_id,
source_message_id=feishu_message_id,
conversation_key=conversation_key,
source_sender_open_id=source_sender_open_id,
thread_context=thread_context,
message_intent="mixed",
mixed_signal_part=mixed_signal_part,
)
sidecar_rag_result = mixed_rag_result
intent_for_response = "question_requirement"
message = mixed_signal_part
force_signal_fastpath = True
else:
rag_result = await rag_service.answer_question(
question=rag_question_part,
asked_by=None,
asked_via="feishu",
project_id=None,
source_chat_id=source_chat_id,
source_message_id=feishu_message_id,
conversation_key=conversation_key,
source_sender_open_id=source_sender_open_id,
thread_context=thread_context,
)
return await self._finalize_direct_qa_capture_result(
message=message,
rag_result=rag_result,
inferred_project_id=None,
feishu_message_id=feishu_message_id,
process_started_at=process_started_at,
route_reason="识别为问答消息,已走 RAG 直答通道,不进入决策队列。",
intent_label=intent,
normalized_chat_type=normalized_chat_type,
)
if intent == "mixed":
mixed_signal_part = (
rag_statement_part
or self._convert_requirement_question_to_signal_text(
message=message,
fallback_content=message_content,
)
) )
mixed_rag_result = await rag_service.answer_question( )
question=rag_question_part, mixed_rag_result = await rag_service.answer_question(
asked_by=None, question=rag_question_part,
asked_via="feishu", asked_by=None,
project_id=None, asked_via="feishu",
source_chat_id=source_chat_id, project_id=None,
source_message_id=feishu_message_id, source_chat_id=source_chat_id,
conversation_key=conversation_key, source_message_id=feishu_message_id,
source_sender_open_id=source_sender_open_id, conversation_key=conversation_key,
thread_context=thread_context, source_sender_open_id=source_sender_open_id,
message_intent="mixed", thread_context=thread_context,
mixed_signal_part=mixed_signal_part, message_intent="mixed",
) mixed_signal_part=mixed_signal_part,
message = mixed_signal_part )
sidecar_rag_result = mixed_rag_result message = mixed_signal_part
sidecar_rag_result = mixed_rag_result
try: try:
if force_signal_fastpath: if force_signal_fastpath:

View File

@ -2,7 +2,6 @@
from __future__ import annotations from __future__ import annotations
from .shared import * from .shared import *
from ...ai_pmo.workflow_semantic_planner import WorkflowSemanticPlanner
class FeishuPipelineProcessMessageHelpersAMixin: class FeishuPipelineProcessMessageHelpersAMixin:
@ -175,11 +174,3 @@ class FeishuPipelineProcessMessageHelpersAMixin:
text = re.sub(r"\n{2,}", "\n", text) text = re.sub(r"\n{2,}", "\n", text)
text = re.sub(r"\s+", " ", text).strip() text = re.sub(r"\s+", " ", text).strip()
return text[:2000] return text[:2000]
@classmethod
def _is_obvious_question(cls, message: str) -> bool: # semantic-lint: ignore - compat wrapper over workflow_semantic_planner
return WorkflowSemanticPlanner.is_obvious_question(message)
@classmethod
def _is_query_like_question(cls, message: str) -> bool: # semantic-lint: ignore - compat wrapper over workflow_semantic_planner
return WorkflowSemanticPlanner.is_query_like_question(message)

View File

@ -68,9 +68,9 @@ class FeishuPipelineSerializationMixin:
"ready": False, "ready": False,
} }
if ( if (
self._looks_like_placeholder_project_text(project_fields["title"]) self._is_placeholder_project_text(project_fields["title"])
or self._looks_like_placeholder_project_text(project_fields["description"]) or self._is_placeholder_project_text(project_fields["description"])
or self._looks_like_placeholder_project_text(project_fields["project_scope"]) or self._is_placeholder_project_text(project_fields["project_scope"])
): ):
project_fields = await self._maybe_prefill_project_fields_from_context( project_fields = await self._maybe_prefill_project_fields_from_context(
message=str(item.raw_message or ""), message=str(item.raw_message or ""),

View File

@ -41,33 +41,6 @@ from ...ai_pmo.ai_pmo_kernel import (
gate_execution as kernel_gate_execution, gate_execution as kernel_gate_execution,
resolve_correction as kernel_resolve_correction, resolve_correction as kernel_resolve_correction,
) )
from ...ai_pmo.workflow_semantic_planner.constants import (
CORRECTION_CUE_RE,
CREATE_ACTION_RE,
EXTERNAL_INTEGRATION_RE,
FOLLOWUP_CONTINUE_RE,
FOLLOWUP_CREATE_CONTINUE_RE,
FOLLOWUP_REFERENCE_RE,
FOLLOWUP_STATUS_RE,
HEALTHCHECK_RE,
INDEPENDENT_REQUIREMENT_RE,
LEGACY_COMMAND_OBJECT_RE,
LEGACY_COMMAND_PROJECT_CREATE_RE,
LEGACY_COMMAND_VERB_RE,
OBVIOUS_QUESTION_RE,
OWNER_RE,
PROJECT_COMPLEXITY_RE,
PROJECT_ID_QUERY_RE,
PROJECT_INTENT_RE,
PROJECT_LIST_QUERY_RE,
QUERY_INTENT_RE,
QUESTION_REQUIREMENT_OBJECT_RE,
QUESTION_REQUIREMENT_VERB_RE,
SHORT_CONFIRM_RE,
SIGNAL_FACT_RE,
SIMPLE_SIGNAL_REQUEST_RE,
TIMELINE_RE,
)
from ...context_cache_service import context_cache_service from ...context_cache_service import context_cache_service
from ...notification_service import NotificationService from ...notification_service import NotificationService
from ...operation_log_service import log_operation from ...operation_log_service import log_operation

View File

@ -153,7 +153,7 @@ class DraftGeneratorService:
max_tokens=max_tokens, max_tokens=max_tokens,
error=str(exc), error=str(exc),
) )
if self._looks_like_provider_budget_rejection(exc): if self._is_provider_budget_rejection(exc):
break break
continue continue
payload = extraction.payload if extraction is not None and isinstance(extraction.payload, dict) else {} payload = extraction.payload if extraction is not None and isinstance(extraction.payload, dict) else {}
@ -199,7 +199,7 @@ class DraftGeneratorService:
return ordered return ordered
@staticmethod @staticmethod
def _looks_like_provider_budget_rejection(exc: Exception) -> bool: def _is_provider_budget_rejection(exc: Exception) -> bool:
lowered = str(exc or "").lower() lowered = str(exc or "").lower()
return "400 bad request" in lowered and any( return "400 bad request" in lowered and any(
token in lowered token in lowered

View File

@ -204,26 +204,11 @@ class SignalCaptureAnalysisNormalizeMixin:
if not content: if not content:
return True return True
normalized = re.sub(r"\s+", "", content) normalized = re.sub(r"\s+", "", content)
if content in NON_REQUIREMENT_EXACT:
return True
if normalized in NON_REQUIREMENT_EXACT:
return True
if any(k in normalized for k in NON_REQUIREMENT_CONTAINS) and not any(
hint in normalized for hint in REQUIREMENT_HINTS
):
return True
if len(content) <= 2 and content.isdigit(): if len(content) <= 2 and content.isdigit():
return True return True
non_requirement_keywords = [ # Keep this structural-only: very short acknowledgements/noise can be dropped,
"收到", # but anything longer should go to the model/kernel instead of keyword heuristics.
"辛苦了", if len(normalized) <= 3 and not any(token in content for token in ("?", "?", "项目", "需求", "任务", "信号")):
"赞",
"哈哈",
"good",
"nice",
"ok",
]
if len(content) <= 8 and any(k in content for k in non_requirement_keywords):
return True return True
if ONLY_PUNCT_OR_SPACE_RE.match(content): if ONLY_PUNCT_OR_SPACE_RE.match(content):
return True return True

View File

@ -11,7 +11,7 @@ class SignalCaptureEntryMixin:
self.llm = LLMService() self.llm = LLMService()
@classmethod @classmethod
def _should_process_feishu_high_accuracy( async def _should_process_feishu_high_accuracy(
cls, cls,
*, *,
message_text: str, message_text: str,
@ -20,7 +20,7 @@ class SignalCaptureEntryMixin:
if not clean_text: if not clean_text:
return False, "empty" return False, "empty"
bundle = WorkflowSemanticPlanner.plan_message( bundle = await WorkflowSemanticPlanner.aplan_message(
message_text=clean_text, message_text=clean_text,
source_system="feishu", source_system="feishu",
) )
@ -131,7 +131,7 @@ class SignalCaptureEntryMixin:
should_sync_process = bool(force_sync) should_sync_process = bool(force_sync)
sync_reason = "force_sync" if should_sync_process else "" sync_reason = "force_sync" if should_sync_process else ""
if not should_sync_process: if not should_sync_process:
should_sync_process, sync_reason = self._should_process_feishu_high_accuracy( should_sync_process, sync_reason = await self._should_process_feishu_high_accuracy(
message_text=cleaned_message, message_text=cleaned_message,
) )
@ -145,21 +145,13 @@ class SignalCaptureEntryMixin:
source_message_id=source_message_id, source_message_id=source_message_id,
thread_context=thread_context, thread_context=thread_context,
) )
should_sync_process = kernel._is_short_confirmation_message(
message_text=cleaned_message,
session=enqueue_session,
message_sent_at=message_sent_at,
session_snapshot=session_snapshot,
)
if should_sync_process:
sync_reason = "short_confirmation"
if should_sync_process: if should_sync_process:
semantic_bundle = None semantic_bundle = None
normalized_sync_reason = str(sync_reason or "").strip().lower() normalized_sync_reason = str(sync_reason or "").strip().lower()
should_forward_semantic_bundle = bool(force_sync) or normalized_sync_reason.startswith("semantic:") should_forward_semantic_bundle = bool(force_sync) or normalized_sync_reason.startswith("semantic:")
if should_forward_semantic_bundle: if should_forward_semantic_bundle:
candidate_bundle = WorkflowSemanticPlanner.plan_message( candidate_bundle = await WorkflowSemanticPlanner.aplan_message(
message_text=cleaned_message, message_text=cleaned_message,
source_system="feishu", source_system="feishu",
) )
@ -407,23 +399,26 @@ class SignalCaptureEntryMixin:
) )
except Exception: except Exception:
pass pass
try: if hasattr(self.db, "commit"):
await self.db.commit() try:
except Exception as exc: await self.db.commit()
self._emit_ai_think( except Exception as exc:
"capture_kernel_commit_failed", self._emit_ai_think(
trace_id=trace_id, "capture_kernel_commit_failed",
error=str(exc), trace_id=trace_id,
) error=str(exc),
await self._rollback_capture_failure(trace_id=trace_id) )
return self._build_kernel_error_result( await self._rollback_capture_failure(trace_id=trace_id)
trace_id=trace_id, return self._build_kernel_error_result(
reason="kernel_commit_failed", trace_id=trace_id,
error=str(exc), reason="kernel_commit_failed",
) error=str(exc),
)
return pipeline_result return pipeline_result
async def _rollback_capture_failure(self, *, trace_id: str) -> None: async def _rollback_capture_failure(self, *, trace_id: str) -> None:
if not hasattr(self.db, "rollback"):
return
try: try:
await self.db.rollback() await self.db.rollback()
except Exception as rollback_exc: except Exception as rollback_exc:

View File

@ -111,7 +111,10 @@ class SignalCapturePipelineCacheMixin:
return pipeline_result return pipeline_result
source_orchestration_result: Optional[Dict[str, Any]] = None source_orchestration_result: Optional[Dict[str, Any]] = None
if WorkflowSemanticPlanner.looks_like_signal_source_request(cleaned_message): if (
str(pipeline_result.get("intent") or "").strip().lower() == INTENT_SIGNAL_SOURCE_ONBOARD
or str(pipeline_result.get("workflow_route") or "").strip().lower() == ROUTE_SIGNAL_SOURCE_ONBOARD
):
try: try:
source_orchestration_result = await SignalSourceOrchestrationService(self.db).orchestrate_from_signal( source_orchestration_result = await SignalSourceOrchestrationService(self.db).orchestrate_from_signal(
tenant_id=tenant_id, tenant_id=tenant_id,

View File

@ -41,11 +41,6 @@ from ...llm_service import LLMService
from ...notification_service import NotificationService from ...notification_service import NotificationService
from ... import workload as workload_service from ... import workload as workload_service
from ...adaptive_rag_service import AdaptiveRAGService from ...adaptive_rag_service import AdaptiveRAGService
from ...ai_pmo.workflow_semantic_planner.constants import (
NON_REQUIREMENT_CONTAINS,
NON_REQUIREMENT_EXACT,
REQUIREMENT_HINTS,
)
from ...ai_orchestration_run import AIOrchestrationRunService from ...ai_orchestration_run import AIOrchestrationRunService
from ...context_cache_service import context_cache_service from ...context_cache_service import context_cache_service
from ..signal_source_orchestration import SignalSourceOrchestrationService from ..signal_source_orchestration import SignalSourceOrchestrationService

View File

@ -1,16 +1,11 @@
from __future__ import annotations from __future__ import annotations
from .shared import * from .shared import *
from ...ai_pmo.workflow_semantic_planner import WorkflowSemanticPlanner
class SignalSourceEntryOrchestrateMixin: class SignalSourceEntryOrchestrateMixin:
def __init__(self, db: AsyncSession) -> None: def __init__(self, db: AsyncSession) -> None:
self.db = db self.db = db
@classmethod
def looks_like_signal_source_request(cls, text: str) -> bool: # semantic-lint: ignore - compat wrapper over workflow_semantic_planner
return WorkflowSemanticPlanner.looks_like_signal_source_request(text)
async def orchestrate_from_signal( async def orchestrate_from_signal(
self, self,
*, *,
@ -61,17 +56,6 @@ class SignalSourceEntryOrchestrateMixin:
real_signal_id = signal_ref.id real_signal_id = signal_ref.id
persisted_signal_id = int(real_signal_id) if real_signal_id and int(real_signal_id) > 0 else None persisted_signal_id = int(real_signal_id) if real_signal_id and int(real_signal_id) > 0 else None
if not self.looks_like_signal_source_request(initiating_text):
return await self._handle_non_source_request(
tenant_id=tenant_id,
project_id=project_id,
persisted_signal_id=persisted_signal_id,
operator_user_id=operator_user_id,
trace_id=trace_id,
response_mode=response_mode,
run_service=run_service,
)
plan, planner_status, planner_diagnostics = await self._plan_onboarding( plan, planner_status, planner_diagnostics = await self._plan_onboarding(
tenant_id=tenant_id, tenant_id=tenant_id,
project=project_ref, project=project_ref,

View File

@ -63,14 +63,6 @@ class SignalSourcePlanningLocatorRolesMixin:
"复合", "复合",
"复合信号源", "复合信号源",
} }
for kind, hints in SOURCE_KIND_HINTS:
if kind != source_kind:
continue
generic_tokens.update(
re.sub(r"[^a-zA-Z0-9\u4e00-\u9fff]+", "", str(token)).lower()
for token in hints
)
break
return normalized in generic_tokens return normalized in generic_tokens
@staticmethod @staticmethod
@ -92,4 +84,3 @@ class SignalSourcePlanningLocatorRolesMixin:
"请创建必要任务、分配合适负责人,并通知相关成员。" "请创建必要任务、分配合适负责人,并通知相关成员。"
f"补充上下文:{str(initiating_text or '').strip()[:240]}" f"补充上下文:{str(initiating_text or '').strip()[:240]}"
) )

View File

@ -155,7 +155,12 @@ class SignalSourcePlanningOnboardMixin:
fallback_text: str, fallback_text: str,
requested_via: str, requested_via: str,
) -> dict[str, Any]: ) -> dict[str, Any]:
source_kind = self._normalize_source_kind(source_payload.get("source_kind"), text=fallback_text, requested_via=requested_via) source_kind = self._normalize_source_kind(
source_payload.get("source_kind"),
text=fallback_text,
requested_via=requested_via,
source_payload=source_payload,
)
source_name = self._clean_source_name_candidate(str(source_payload.get("source_name") or "").strip()) source_name = self._clean_source_name_candidate(str(source_payload.get("source_name") or "").strip())
if not source_name: if not source_name:
source_name = self._extract_source_name(fallback_text, default=f"{source_kind}信号源") source_name = self._extract_source_name(fallback_text, default=f"{source_kind}信号源")
@ -183,20 +188,36 @@ class SignalSourcePlanningOnboardMixin:
} }
@staticmethod @staticmethod
def _normalize_source_kind(raw_kind: Any, *, text: str, requested_via: str) -> str: def _normalize_source_kind(raw_kind: Any, *, text: str, requested_via: str, source_payload: Optional[dict[str, Any]] = None) -> str:
cleaned = str(raw_kind or "").strip().lower() cleaned = str(raw_kind or "").strip().lower()
for kind, hints in SOURCE_KIND_HINTS: payload = source_payload if isinstance(source_payload, dict) else {}
if cleaned == kind: config = payload.get("config") if isinstance(payload.get("config"), dict) else {}
return kind if cleaned in SUPPORTED_SOURCE_KINDS:
if any(token in cleaned for token in hints): return cleaned
return kind
text_lower = str(text or "").lower()
for kind, hints in SOURCE_KIND_HINTS:
if any(token in text_lower for token in hints):
return kind
via = str(requested_via or "").strip().lower() via = str(requested_via or "").strip().lower()
if via in {"feishu", "pmflow", "github", "browser"}: if via in {"feishu", "pmflow", "github", "browser"}:
return via return via
if any(str(payload.get(key) or config.get(key) or "").strip() for key in ("repo", "repository")):
return "github"
if any(str(payload.get(key) or config.get(key) or "").strip() for key in ("channel_id", "chat_id")):
return "feishu"
if any(str(payload.get(key) or config.get(key) or "").strip() for key in ("webhook_url",)):
return "webhook"
if any(str(payload.get(key) or config.get(key) or "").strip() for key in ("url",)):
return "url"
explicit_locator = SignalSourcePlanningOnboardMixin._extract_explicit_source_locator(
source_payload=payload,
text=text,
)
if explicit_locator.startswith("repo:"):
return "github"
if explicit_locator.startswith("chat:"):
return "feishu"
if explicit_locator.startswith(("http://", "https://")):
return "webhook" if cleaned == "webhook" else "url"
return "custom" return "custom"
@classmethod @classmethod
@ -268,4 +289,3 @@ class SignalSourcePlanningOnboardMixin:
if normalized in {"", "项目id", "给项目id", "复合", "复合信号源", "信号源", "来源", "监控源"}: if normalized in {"", "项目id", "给项目id", "复合", "复合信号源", "信号源", "来源", "监控源"}:
return "" return ""
return cleaned return cleaned

View File

@ -34,16 +34,8 @@ from ...model.prompt_profile import prompt_profile_service
from ...llm_service import llm_service from ...llm_service import llm_service
from ....utils.timezone import now_cn_naive from ....utils.timezone import now_cn_naive
from ...ai_pmo.workflow_semantic_planner.constants import SOURCE_ONBOARDING_ACTION_RE
SOURCE_KIND_HINTS: tuple[tuple[str, tuple[str, ...]], ...] = ( SUPPORTED_SOURCE_KINDS = ("feishu", "github", "webhook", "url", "browser", "pmflow", "custom")
("feishu", ("飞书", "lark", "群聊", "chat")),
("github", ("github", "repo", "仓库", "pull request", "issue")),
("webhook", ("webhook", "回调", "回传")),
("url", ("url", "链接", "网页", "站点", "rss")),
("browser", ("浏览器", "标签页", "页面")),
("pmflow", ("pmflow", "内置ai", "ai管家", "steward")),
)
DEFAULT_WORKER_ROLE_KEYS = ("pm_dispatcher", "tech_architect", "delivery_coach") DEFAULT_WORKER_ROLE_KEYS = ("pm_dispatcher", "tech_architect", "delivery_coach")
MAX_SOURCE_NAME_LEN = 120 MAX_SOURCE_NAME_LEN = 120
MAX_SOURCE_IDENTIFIER_LEN = 180 MAX_SOURCE_IDENTIFIER_LEN = 180

View File

@ -9,8 +9,10 @@ from app.services.adaptive_rag_service.mixin_deep_guard_rules import AdaptiveRAG
from app.services.adaptive_rag_service.mixin_deterministic_query_core import ( from app.services.adaptive_rag_service.mixin_deterministic_query_core import (
AdaptiveRAGDeterministicQueryCoreMixin, AdaptiveRAGDeterministicQueryCoreMixin,
) )
from app.services.adaptive_rag_service.mixin_requirement_ack_core import AdaptiveRAGRequirementAckCoreMixin
from app.services.adaptive_rag_service.mixin_retrieval_vector import AdaptiveRAGRetrievalVectorMixin from app.services.adaptive_rag_service.mixin_retrieval_vector import AdaptiveRAGRetrievalVectorMixin
from app.services.adaptive_rag_service.shared_part_02 import DEEP_RETRIEVAL_STEPS, DEEP_VECTOR_TOP_K from app.services.adaptive_rag_service.shared_part_02 import ACTION_MODE_READ, DEEP_RETRIEVAL_STEPS, DEEP_VECTOR_TOP_K
from app.services.ai_pmo.workflow_semantic_planner import WorkflowSemanticPlanner
class _FakeAdaptiveRAGService( class _FakeAdaptiveRAGService(
@ -168,6 +170,49 @@ async def test_answer_entry_feishu_write_mode_still_goes_base_mainline() -> None
assert int(service.base_kwargs["deep_vector_top_k"]) == DEEP_VECTOR_TOP_K assert int(service.base_kwargs["deep_vector_top_k"]) == DEEP_VECTOR_TOP_K
@pytest.mark.asyncio
async def test_requirement_ack_auto_mode_respects_structured_query_bundle(monkeypatch: pytest.MonkeyPatch) -> None:
class _ModeService(AdaptiveRAGRequirementAckCoreMixin):
pass
async def _fake_aplan_message(cls, *, message_text: str, source_system: str, metadata=None):
_ = (cls, message_text, source_system, metadata)
return WorkflowSemanticPlanner.deserialize_bundle(
{
"bundle_id": "semantic-test",
"semantic_version": "v1",
"source_system": "adaptive_rag_ack",
"primary_workflow": "query_flow",
"workflows": [
{
"workflow_type": "query_flow",
"workflow_action": "answer",
"target_scope": "global",
"operation_codes": ["query.answer"],
"requires_confirmation": False,
"confidence": 0.91,
"clarification_needed": False,
"metadata": {},
}
],
"message_gate": "auto",
"reply_contract": {"mode": "text", "template_key": "workflow_bundle", "summary": "query_flow"},
"diagnostics": {"message_intent": "question"},
}
)
monkeypatch.setattr(WorkflowSemanticPlanner, "aplan_message", classmethod(_fake_aplan_message))
mode = await _ModeService._resolve_action_mode_async(
requested_mode="auto",
question="先不要创建任务,告诉我当前进展和风险",
effective_question="先不要创建任务,告诉我当前进展和风险",
asked_via="pmflow_manual",
)
assert mode == ACTION_MODE_READ
def test_query_runtime_planner_prefers_fast_path_for_list_question_even_if_requested_deep() -> None: def test_query_runtime_planner_prefers_fast_path_for_list_question_even_if_requested_deep() -> None:
class _FakePlannerService(AdaptiveRAGAnswerMainlineMixin): class _FakePlannerService(AdaptiveRAGAnswerMainlineMixin):
pass pass

File diff suppressed because it is too large Load Diff

View File

@ -9,6 +9,7 @@ from app.services.ai_pmo.ai_pmo_typed_executor_discovery.mixin_discover import (
from app.services.ai_pmo.ai_pmo_typed_executor_execute.mixin_execute_signal_source import ( from app.services.ai_pmo.ai_pmo_typed_executor_execute.mixin_execute_signal_source import (
ExecuteSignalSourceMixin, ExecuteSignalSourceMixin,
) )
from app.services.signal.signal_source_orchestration import SignalSourceOrchestrationService
def test_signal_source_onboard_idempotency_key_prefers_message_key() -> None: def test_signal_source_onboard_idempotency_key_prefers_message_key() -> None:
@ -185,3 +186,27 @@ async def test_project_signal_discovery_uses_project_id_without_name_error() ->
assert result["status"] == "pending_confirmation" assert result["status"] == "pending_confirmation"
assert result["project_id"] == 2 assert result["project_id"] == 2
assert result["candidate_signals"][0]["title"] == "信号A" assert result["candidate_signals"][0]["title"] == "信号A"
def test_signal_source_kind_uses_explicit_repo_locator_without_hint_table() -> None:
assert (
SignalSourceOrchestrationService._normalize_source_kind(
None,
text="请把这个来源接进来",
requested_via="",
source_payload={"repo": "acme/reco-engine"},
)
== "github"
)
def test_signal_source_kind_uses_channel_config_without_hint_table() -> None:
assert (
SignalSourceOrchestrationService._normalize_source_kind(
None,
text="先挂到当前群",
requested_via="",
source_payload={"config": {"channel_id": "oc_abc123"}},
)
== "feishu"
)

View File

@ -18,9 +18,38 @@ from app.services.ai_pmo.ai_pmo_intent import (
) )
from app.services.ai_pmo.ai_pmo_kernel_common_core.imports import WORKFLOW_ROUTE_KEYS from app.services.ai_pmo.ai_pmo_kernel_common_core.imports import WORKFLOW_ROUTE_KEYS
from app.services.ai_pmo.ai_pmo_typed_executor_execute.service import TypedExecutorExecuteMixin from app.services.ai_pmo.ai_pmo_typed_executor_execute.service import TypedExecutorExecuteMixin
from app.services.ai_pmo.semantic_context.decision_schema import SemanticDecision
from app.services.ai_pmo.workflow_semantic_planner import WorkflowSemanticPlanner
def test_intent_resolver_recognizes_task_create() -> None: def _planner_decision(*, workflows: list[dict]) -> SemanticDecision:
return SemanticDecision(
topic_relation="new_topic",
mode_decision="answer_only",
workflow_plans=workflows,
scene_metadata={},
requested_actions=["workflow.bundle.compose"],
confidence=0.92,
)
def test_intent_resolver_recognizes_task_create(monkeypatch: pytest.MonkeyPatch) -> None:
async def _fake_decide(cls, *, message_text: str, source_system: str, metadata=None):
_ = (message_text, source_system, metadata)
return _planner_decision(
workflows=[
{
"workflow_type": "task_flow",
"workflow_action": "create",
"target_scope": "project",
"operation_codes": ["task.create"],
"confidence": 0.92,
}
]
)
monkeypatch.setattr(WorkflowSemanticPlanner, "_decide_cold_start_semantics", classmethod(_fake_decide))
intent = IntentResolver.resolve( intent = IntentResolver.resolve(
text="请在项目#12 新建任务:修复登录超时", text="请在项目#12 新建任务:修复登录超时",
project_id=12, project_id=12,
@ -47,13 +76,54 @@ def test_route_resolver_builds_task_create_route() -> None:
assert route.key == ROUTE_TASK_CREATE assert route.key == ROUTE_TASK_CREATE
def test_intent_resolver_negated_write_statement_falls_back_to_query_read_only() -> None: def test_intent_resolver_negated_write_statement_follows_semantic_query_bundle(monkeypatch: pytest.MonkeyPatch) -> None:
async def _fake_decide(cls, *, message_text: str, source_system: str, metadata=None):
_ = (message_text, source_system, metadata)
return _planner_decision(
workflows=[
{
"workflow_type": "query_flow",
"workflow_action": "answer",
"target_scope": "global",
"operation_codes": ["query.answer"],
"confidence": 0.86,
}
]
)
monkeypatch.setattr(WorkflowSemanticPlanner, "_decide_cold_start_semantics", classmethod(_fake_decide))
intent = IntentResolver.resolve( intent = IntentResolver.resolve(
text="先不要创建任务,告诉我当前进展和风险", text="先不要创建任务,告诉我当前进展和风险",
project_id=7, project_id=7,
) )
assert intent.name == INTENT_QUERY assert intent.name == INTENT_QUERY
assert str((intent.metadata or {}).get("reason") or "") == "negated_write_statement" assert str((intent.metadata or {}).get("reason") or "") != "negated_write_statement"
def test_intent_resolver_followup_tokens_no_longer_override_semantic_query(monkeypatch: pytest.MonkeyPatch) -> None:
async def _fake_decide(cls, *, message_text: str, source_system: str, metadata=None):
_ = (cls, message_text, source_system, metadata)
return _planner_decision(
workflows=[
{
"workflow_type": "query_flow",
"workflow_action": "answer",
"target_scope": "global",
"operation_codes": ["query.answer"],
"confidence": 0.86,
}
]
)
monkeypatch.setattr(WorkflowSemanticPlanner, "_decide_cold_start_semantics", classmethod(_fake_decide))
intent = IntentResolver.resolve(
text="确认这条",
project_id=9,
)
assert intent.name == INTENT_QUERY
def test_route_resolver_builds_query_answer_route() -> None: def test_route_resolver_builds_query_answer_route() -> None:
@ -74,20 +144,20 @@ def test_route_resolver_builds_query_answer_route() -> None:
def test_route_resolver_followup_action_requires_structured_metadata() -> None: def test_route_resolver_followup_action_requires_structured_metadata() -> None:
action = RouteResolver.parse_followup_action( action = RouteResolver.resolve_followup_route_action(
canonical_intent=INTENT_FOLLOWUP_CONTINUE, canonical_intent=INTENT_FOLLOWUP_CONTINUE,
message_text="先取消这个工作项", message_text="先取消这个工作项",
) )
assert action == ROUTE_ACTION_CONTINUE assert action == ROUTE_ACTION_CONTINUE
def test_route_resolver_followup_action_allows_message_parsing_with_context() -> None: def test_route_resolver_followup_action_no_longer_parses_message_text_with_context() -> None:
action = RouteResolver.parse_followup_action( action = RouteResolver.resolve_followup_route_action(
canonical_intent=INTENT_FOLLOWUP_CONTINUE, canonical_intent=INTENT_FOLLOWUP_CONTINUE,
message_text="先取消这个工作项", message_text="先取消这个工作项",
metadata={"followup_context_present": True, "target_work_item_id": 8}, metadata={"followup_context_present": True, "target_work_item_id": 8},
) )
assert action == ROUTE_ACTION_CANCEL assert action == ROUTE_ACTION_CONTINUE
def test_kernel_common_imports_exposes_workflow_route_keys() -> None: def test_kernel_common_imports_exposes_workflow_route_keys() -> None:

View File

@ -20,31 +20,6 @@ from app.services.integrations.feishu_integration_service.mixin_reply_llm_core i
FeishuReplyLLMCoreMixin, FeishuReplyLLMCoreMixin,
) )
def test_parse_action_confirm() -> None:
action, note = DraftConfirmHandler.parse_action_from_text(message_text="确认")
assert action == "confirm"
assert note == ""
def test_parse_action_revise_with_note() -> None:
action, note = DraftConfirmHandler.parse_action_from_text(message_text="修改 改成每周跟进")
assert action == "revise"
assert note == "改成每周跟进"
def test_parse_action_cancel() -> None:
action, note = DraftConfirmHandler.parse_action_from_text(message_text="取消")
assert action == "cancel"
assert note == ""
def test_parse_action_yes_as_confirm() -> None:
action, note = DraftConfirmHandler.parse_action_from_text(message_text="yes")
assert action == "confirm"
assert note == ""
def test_draft_confirm_handler_thread_anchor_match() -> None: def test_draft_confirm_handler_thread_anchor_match() -> None:
ingest = type( ingest = type(
"Ingest", "Ingest",
@ -185,6 +160,47 @@ def test_feishu_reply_llm_core_action_ack_uses_semantic_bundle_only() -> None:
assert ack == "已收到,项目创建和信号源接入请求已记录。" assert ack == "已收到,项目创建和信号源接入请求已记录。"
def test_feishu_reply_llm_core_action_ack_no_longer_calls_planner_action_classifier(
monkeypatch: pytest.MonkeyPatch,
) -> None:
_ = monkeypatch
ack = FeishuReplyLLMCoreMixin._build_action_request_ack_reply(
message_content="随便一句不带动作的话",
capture_result={
"status": "captured",
"semantic_bundle": {
"bundle_id": "semantic-1",
"semantic_version": "2026-03-27.workflow-v1",
"source_system": "feishu",
"primary_workflow": "signal_source_flow",
"workflows": [
{
"workflow_type": "signal_source_flow",
"workflow_action": "onboard",
"target_scope": "global",
"operation_codes": ["signal_source.onboard"],
"requires_confirmation": False,
"confidence": 0.92,
"slots": [],
"clarification_needed": False,
"metadata": {},
}
],
"message_gate": "auto_execute",
"reply_contract": {
"mode": "aggregate_card",
"template_key": "workflow_bundle",
"summary": "signal_source_flow",
},
"diagnostics": {},
},
},
)
assert ack == "已收到,信号源接入请求已记录。"
def test_consultant_dispatch_contract_marks_incomplete_task_create_as_clarify() -> None: def test_consultant_dispatch_contract_marks_incomplete_task_create_as_clarify() -> None:
contract = build_consultant_dispatch_contract( contract = build_consultant_dispatch_contract(
intent_name="task.create", intent_name="task.create",
@ -945,6 +961,121 @@ async def test_draft_confirm_handler_revise_without_note_requests_feedback(monke
assert "修改" in result["followup_reply"] assert "修改" in result["followup_reply"]
@pytest.mark.asyncio
async def test_draft_confirm_handler_revise_requirement_plan_bypasses_generic_draft_generator(monkeypatch) -> None:
class _FakeResult:
def __init__(self, locked):
self._locked = locked
def scalar_one_or_none(self):
return self._locked
class _FakeDB:
def __init__(self, locked):
self._locked = locked
async def execute(self, query): # noqa: ARG002
return _FakeResult(self._locked)
async def flush(self):
return None
locked = SimpleNamespace(
id=42,
state=STATE_DRAFT_PENDING_CONFIRM,
intent="project.create",
project_id=None,
signal_id=None,
draft_version=2,
draft_expires_at=None,
confirm_message_id=None,
owner_user_id=77,
draft_content={
"draft_source": "requirement_plan",
"plan_profile": "it_project",
"title": "换季穿搭助手",
"original_text": "做一个换季穿搭助手。",
"requirement_card": {
"plan_profile": "it_project",
"title": "换季穿搭助手",
"goal": "先验证换季穿搭推荐是否有用。",
"project_nature": "轻量级推荐工具",
"target_user": "穿搭新手个体用户",
"tech_route": "规则推荐版",
"first_scope": "基础属性匹配",
"delivery_mode": "小范围试用",
"question_round": 5,
},
},
execution_result={},
)
handler = DraftConfirmHandler(type("Kernel", (), {"db": _FakeDB(locked), "tenant_id": 1})())
captured: dict[str, object] = {}
async def _unexpected_generate_and_persist(**kwargs): # noqa: ANN001
raise AssertionError(f"generic draft generator should not run for requirement_plan drafts: {kwargs}")
async def _fake_revise_requirement_plan_draft(self, **kwargs): # noqa: ANN001
captured["requirement_revise_kwargs"] = kwargs
locked.draft_version = 3
locked.draft_content = {
**locked.draft_content,
"draft_text": (
"📋 我先按当前收敛结果,整理成一版需求草稿:\n\n"
"🌐【外部信号源】\n"
"1. 天气 API:提供实时温度和体感变化。\n"
"2. 淘宝开放平台:补充当季商品与类目属性。\n"
"3. 得物开放数据:补充年轻用户偏好的款式热度。\n"
"4. 小红书趋势榜:补充换季搭配内容趋势。\n"
"5. 微信收藏/点击回流:补充用户采纳反馈。\n\n"
"👤【推荐负责人】陈后端(当前负载 28%)\n"
"理由:系统检索到他当前负载较低,且技能更接近推荐与数据接入。"
),
"recommended_assignee_id": 18,
"recommended_assignee_name": "陈后端",
"recommended_assignee_workload": 28.0,
"recommended_assignee_reason": "系统检索到他当前负载较低,且技能更接近推荐与数据接入。",
"external_signal_sources": [
{"name": "天气 API", "purpose": "提供实时温度和体感变化", "phase": "首版优先接入"},
{"name": "淘宝开放平台", "purpose": "补充当季商品与类目属性", "phase": "首版优先接入"},
{"name": "得物开放数据", "purpose": "补充年轻用户偏好的款式热度", "phase": "第二阶段接入"},
],
}
return {
"draft_text": str(locked.draft_content["draft_text"]),
"draft_content": dict(locked.draft_content),
"draft_version": 3,
}
monkeypatch.setattr(handler.draft_generator, "generate_and_persist", _unexpected_generate_and_persist)
monkeypatch.setattr(
DraftConfirmHandler,
"_revise_requirement_plan_draft",
_fake_revise_requirement_plan_draft,
raising=False,
)
result = await handler.revise_draft(
target_work_item=SimpleNamespace(
id=42,
state=STATE_DRAFT_PENDING_CONFIRM,
intent="project.create",
project_id=None,
signal_id=None,
),
target_ingest=SimpleNamespace(payload={"message_text": "做一个换季穿搭助手。"}),
revision_note="补充外部信号源和推荐负责人。",
expected_version=2,
)
assert result["handled"] is True
assert result["status"] == "draft_pending_confirm"
assert "🌐【外部信号源】" in result["followup_reply"]
assert "天气 API" in result["followup_reply"]
assert "👤【推荐负责人】陈后端" in result["followup_reply"]
assert captured["requirement_revise_kwargs"]["revision_note"] == "补充外部信号源和推荐负责人。"
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_draft_confirm_handler_ignores_non_draft_reply_without_target(monkeypatch) -> None: async def test_draft_confirm_handler_ignores_non_draft_reply_without_target(monkeypatch) -> None:
kernel = type("Kernel", (), {"db": object(), "tenant_id": 1})() kernel = type("Kernel", (), {"db": object(), "tenant_id": 1})()
@ -998,7 +1129,7 @@ async def test_draft_confirm_handler_confirm_without_target_returns_failed_safe(
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_draft_confirm_handler_natural_revision_without_target_returns_failed_safe(monkeypatch) -> None: async def test_draft_confirm_handler_natural_revision_without_target_and_without_anchor_is_ignored(monkeypatch) -> None:
kernel = type("Kernel", (), {"db": object(), "tenant_id": 1})() kernel = type("Kernel", (), {"db": object(), "tenant_id": 1})()
handler = DraftConfirmHandler(kernel) handler = DraftConfirmHandler(kernel)
@ -1015,12 +1146,362 @@ async def test_draft_confirm_handler_natural_revision_without_target_returns_fai
source_message_id="msg-1", source_message_id="msg-1",
channel_ref="chat-1", channel_ref="chat-1",
source_sender_open_id="ou_xxx", source_sender_open_id="ou_xxx",
thread_context=None,
)
assert result["handled"] is False
assert result["status"] == "ignored"
assert result["followup_reply"] == ""
@pytest.mark.asyncio
async def test_draft_confirm_handler_without_target_and_without_anchor_confirm_is_ignored(monkeypatch) -> None:
kernel = type("Kernel", (), {"db": object(), "tenant_id": 1})()
handler = DraftConfirmHandler(kernel)
async def _fake_resolve_pending_draft(**kwargs):
_ = kwargs
return None
monkeypatch.setattr(handler, "_resolve_pending_draft", _fake_resolve_pending_draft)
result = await handler.handle_text_reply(
message_text="确认",
sender="林宇航",
source_system="feishu",
source_message_id="msg-1",
channel_ref="chat-1",
source_sender_open_id="ou_xxx",
thread_context=None,
)
assert result["handled"] is False
assert result["status"] == "ignored"
assert result["followup_reply"] == ""
@pytest.mark.asyncio
async def test_draft_confirm_handler_display_feedback_uses_semantic_answer_only(monkeypatch) -> None:
kernel = type("Kernel", (), {"db": SimpleNamespace(execute=object()), "tenant_id": 1})()
handler = DraftConfirmHandler(kernel)
async def _fake_resolve_pending_draft(**kwargs):
_ = kwargs
return (
SimpleNamespace(
id=42,
state=STATE_DRAFT_PENDING_CONFIRM,
intent="project.create",
project_id=None,
signal_id=None,
draft_version=2,
),
SimpleNamespace(payload={"message_text": "原始需求"}),
)
async def _fake_resolve_active_mounted_draft(**kwargs):
_ = kwargs
return (
SimpleNamespace(id=8, topic_workspace_id=77, active_work_item_id=42, state="ready"),
SimpleNamespace(id=77, mode="plan", plan_status="awaiting_execute_confirm"),
)
class _FakeAssembler:
def __init__(self, db):
self.db = db
async def assemble(self, **kwargs):
return SimpleNamespace(
mode_card=SimpleNamespace(
allowed_actions=["reply.compose", "draft.followup.answer_only"],
forbidden_actions=[],
)
)
class _FakeDecisionService:
def __init__(self, llm_service):
self.llm_service = llm_service
async def decide(self, *, packet):
_ = packet
return SimpleNamespace(
topic_relation="continue_current_topic",
mode_decision="answer_only",
followup_action="answer_only",
revision_note="",
assistant_reply="收到,这是展示形式反馈:卡片可以长,聊天直发需要更短,我不会重起草当前草稿。",
requires_confirmation=False,
requested_actions=["reply.compose", "draft.followup.answer_only"],
)
async def _unexpected_confirm(**kwargs): # noqa: ANN001
raise AssertionError(f"confirm_draft should not run for display feedback: {kwargs}")
async def _unexpected_revise(**kwargs): # noqa: ANN001
raise AssertionError(f"revise_draft should not run for display feedback: {kwargs}")
async def _unexpected_cancel(**kwargs): # noqa: ANN001
raise AssertionError(f"cancel_draft should not run for display feedback: {kwargs}")
monkeypatch.setattr(handler, "_resolve_pending_draft", _fake_resolve_pending_draft)
monkeypatch.setattr(handler, "_resolve_active_mounted_draft", _fake_resolve_active_mounted_draft)
monkeypatch.setattr("app.services.ai_pmo.draft_confirm_handler.SemanticContextAssembler", _FakeAssembler, raising=False)
monkeypatch.setattr(
"app.services.ai_pmo.draft_confirm_handler.SemanticDecisionService",
_FakeDecisionService,
raising=False,
)
monkeypatch.setattr(handler, "confirm_draft", _unexpected_confirm)
monkeypatch.setattr(handler, "revise_draft", _unexpected_revise)
monkeypatch.setattr(handler, "cancel_draft", _unexpected_cancel)
result = await handler.handle_text_reply(
message_text="是这种的可以很长,但是聊天内容发的那种就不行这么长",
sender="杨宇",
source_system="feishu",
source_message_id="msg-1",
channel_ref="chat-1",
source_sender_open_id="ou_xxx",
thread_context={"reply_to_message_id": "reply-1"}, thread_context={"reply_to_message_id": "reply-1"},
) )
assert result["handled"] is True assert result["handled"] is True
assert result["status"] == "failed_safe" assert result["status"] == "pending_manual"
assert "当前没有可确认草稿" in result["followup_reply"] assert "展示形式反馈" in result["followup_reply"]
assert "不会重起草" in result["followup_reply"]
@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})()
handler = DraftConfirmHandler(kernel)
captured: dict[str, object] = {}
async def _fake_resolve_pending_draft(**kwargs):
_ = kwargs
return (
SimpleNamespace(
id=42,
state=STATE_DRAFT_PENDING_CONFIRM,
intent="project.create",
project_id=None,
signal_id=None,
draft_version=2,
),
SimpleNamespace(payload={"message_text": "原始需求"}),
)
async def _fake_resolve_active_mounted_draft(**kwargs):
_ = kwargs
return None
async def _fake_resolve_mounted_draft_by_work_item_id(**kwargs):
captured["mounted_lookup"] = kwargs
return (
SimpleNamespace(id=8, topic_workspace_id=77, active_work_item_id=42, state="draft_pending_confirm"),
SimpleNamespace(id=77, mode="plan", plan_status="draft_pending_confirm"),
)
async def _fake_semantic_followup(**kwargs):
captured["semantic_kwargs"] = kwargs
return {
"action": "revise",
"revision_note": "补充外部信号源。",
"assistant_reply": "",
}
async def _fake_revise_draft(**kwargs):
captured["revise_kwargs"] = kwargs
return {
"handled": True,
"status": "draft_pending_confirm",
"followup_reply": "我先按当前草稿补外部信号源。",
}
async def _unexpected_confirm(**kwargs): # noqa: ANN001
raise AssertionError(f"confirm_draft should not run here: {kwargs}")
async def _unexpected_cancel(**kwargs): # noqa: ANN001
raise AssertionError(f"cancel_draft should not run here: {kwargs}")
monkeypatch.setattr(handler, "_resolve_pending_draft", _fake_resolve_pending_draft)
monkeypatch.setattr(handler, "_resolve_active_mounted_draft", _fake_resolve_active_mounted_draft)
monkeypatch.setattr(handler, "_resolve_mounted_draft_by_work_item_id", _fake_resolve_mounted_draft_by_work_item_id)
monkeypatch.setattr(handler, "_resolve_semantic_followup_decision", _fake_semantic_followup)
monkeypatch.setattr(handler, "revise_draft", _fake_revise_draft)
monkeypatch.setattr(handler, "confirm_draft", _unexpected_confirm)
monkeypatch.setattr(handler, "cancel_draft", _unexpected_cancel)
result = await handler.handle_text_reply(
message_text="按这版继续",
sender="林宇航",
source_system="feishu",
source_message_id="msg-continue-1",
channel_ref="chat-1",
source_sender_open_id="ou_xxx",
thread_context=None,
)
assert result["handled"] is True
assert result["status"] == "draft_pending_confirm"
assert "当前草稿" in result["followup_reply"]
assert captured["mounted_lookup"] == {"work_item_id": 42}
assert captured["semantic_kwargs"]["active_mounted"][1].id == 77
assert captured["revise_kwargs"]["revision_note"] == "补充外部信号源。"
@pytest.mark.asyncio
async def test_draft_confirm_handler_target_bound_semantic_override_revises_without_parser_fallback(monkeypatch) -> None:
kernel = type("Kernel", (), {"db": object(), "tenant_id": 1})()
handler = DraftConfirmHandler(kernel)
async def _fake_resolve_pending_draft(**kwargs):
_ = kwargs
return (
SimpleNamespace(
id=42,
state=STATE_DRAFT_PENDING_CONFIRM,
intent="project.create",
project_id=None,
signal_id=None,
draft_version=2,
),
SimpleNamespace(payload={"message_text": "原始需求"}),
)
async def _fake_resolve_target_mounted_draft(**kwargs):
_ = kwargs
return (
SimpleNamespace(id=8, topic_workspace_id=77, active_work_item_id=42, state="draft_pending_confirm"),
SimpleNamespace(id=77, mode="plan", plan_status="draft_pending_confirm"),
)
async def _fake_revise_draft(**kwargs):
return {
"handled": True,
"status": "draft_pending_confirm",
"followup_reply": f"按当前草稿重起:{kwargs['revision_note']}",
}
monkeypatch.setattr(handler, "_resolve_pending_draft", _fake_resolve_pending_draft)
monkeypatch.setattr(handler, "_resolve_target_mounted_draft", _fake_resolve_target_mounted_draft)
monkeypatch.setattr(handler, "revise_draft", _fake_revise_draft)
result = await handler.handle_text_reply(
message_text="把外部信号源补上",
sender="林宇航",
source_system="feishu",
source_message_id="msg-override-1",
channel_ref="chat-1",
source_sender_open_id="ou_xxx",
thread_context={"reply_to_message_id": "reply-1"},
semantic_followup_override={
"action": "revise",
"revision_note": "补充外部信号源。",
"assistant_reply": "",
},
)
assert result["handled"] is True
assert result["status"] == "draft_pending_confirm"
assert "补充外部信号源" in result["followup_reply"]
@pytest.mark.asyncio
async def test_draft_confirm_handler_target_bound_without_semantic_action_returns_pending_manual(monkeypatch) -> None:
kernel = type("Kernel", (), {"db": object(), "tenant_id": 1})()
handler = DraftConfirmHandler(kernel)
async def _fake_resolve_pending_draft(**kwargs):
_ = kwargs
return (
SimpleNamespace(
id=42,
state=STATE_DRAFT_PENDING_CONFIRM,
intent="project.create",
project_id=None,
signal_id=None,
draft_version=2,
),
SimpleNamespace(payload={"message_text": "原始需求"}),
)
async def _fake_resolve_target_mounted_draft(**kwargs):
_ = kwargs
return (
SimpleNamespace(id=8, topic_workspace_id=77, active_work_item_id=42, state="draft_pending_confirm"),
SimpleNamespace(id=77, mode="plan", plan_status="draft_pending_confirm"),
)
monkeypatch.setattr(handler, "_resolve_pending_draft", _fake_resolve_pending_draft)
monkeypatch.setattr(handler, "_resolve_target_mounted_draft", _fake_resolve_target_mounted_draft)
result = await handler.handle_text_reply(
message_text="把目标用户改成平台运维团队",
sender="林宇航",
source_system="feishu",
source_message_id="msg-no-semantic-action-1",
channel_ref="chat-1",
source_sender_open_id="ou_xxx",
thread_context={"reply_to_message_id": "reply-1"},
semantic_followup_override={
"action": "",
"revision_note": "",
"assistant_reply": "",
},
)
assert result["handled"] is True
assert result["status"] == "pending_manual"
assert "请回复“确认”、“修改 + 你的意见”或“取消”" in result["followup_reply"]
@pytest.mark.asyncio
async def test_draft_confirm_handler_target_bound_without_anchor_and_without_semantic_action_is_ignored(monkeypatch) -> None:
kernel = type("Kernel", (), {"db": object(), "tenant_id": 1})()
handler = DraftConfirmHandler(kernel)
async def _fake_resolve_pending_draft(**kwargs):
_ = kwargs
return (
SimpleNamespace(
id=42,
state=STATE_DRAFT_PENDING_CONFIRM,
intent="project.create",
project_id=None,
signal_id=None,
draft_version=2,
),
SimpleNamespace(payload={"message_text": "原始需求"}),
)
async def _fake_resolve_target_mounted_draft(**kwargs):
_ = kwargs
return (
SimpleNamespace(id=8, topic_workspace_id=77, active_work_item_id=42, state="draft_pending_confirm"),
SimpleNamespace(id=77, mode="plan", plan_status="draft_pending_confirm"),
)
monkeypatch.setattr(handler, "_resolve_pending_draft", _fake_resolve_pending_draft)
monkeypatch.setattr(handler, "_resolve_target_mounted_draft", _fake_resolve_target_mounted_draft)
result = await handler.handle_text_reply(
message_text="收到,辛苦了",
sender="林宇航",
source_system="feishu",
source_message_id="msg-no-anchor-ignore-1",
channel_ref="chat-1",
source_sender_open_id="ou_xxx",
thread_context=None,
semantic_followup_override={
"action": "",
"revision_note": "",
"assistant_reply": "",
},
)
assert result["handled"] is False
assert result["status"] == "ignored"
def test_kernel_ingress_mixin_auto_safe_int_regression() -> None: def test_kernel_ingress_mixin_auto_safe_int_regression() -> None:

View File

@ -1,3 +1,4 @@
import asyncio
import inspect import inspect
from types import SimpleNamespace from types import SimpleNamespace
@ -355,6 +356,40 @@ async def test_intent_gate_query_does_not_inherit_project_context(monkeypatch) -
async def test_feishu_question_like_messages_use_sync_high_accuracy_path(monkeypatch) -> None: async def test_feishu_question_like_messages_use_sync_high_accuracy_path(monkeypatch) -> None:
calls = {"sync": 0, "session": 0, "cache": 0} calls = {"sync": 0, "session": 0, "cache": 0}
class _FakeBundle:
def __init__(self) -> None:
self.primary_workflow = "query_flow"
self.workflows = [SimpleNamespace(workflow_type="query_flow", workflow_action="answer")]
self.diagnostics = {}
def to_dict(self) -> dict[str, object]:
return {
"bundle_id": "semantic-query",
"semantic_version": "2026-03-27.workflow-v1",
"source_system": "feishu",
"primary_workflow": self.primary_workflow,
"workflows": [
{
"workflow_type": "query_flow",
"workflow_action": "answer",
"target_scope": "global",
"operation_codes": ["query.answer"],
"requires_confirmation": False,
"confidence": 0.9,
"slots": [],
"clarification_needed": False,
"metadata": {},
}
],
"message_gate": "auto_execute",
"reply_contract": {
"mode": "aggregate_card",
"template_key": "workflow_bundle",
"summary": "query_flow",
},
"diagnostics": {},
}
class _FakeKernel: class _FakeKernel:
@staticmethod @staticmethod
def _build_source_message_id(*, source_system: str, source_message_id, channel_ref: str, sender: str, message_text: str): def _build_source_message_id(*, source_system: str, source_message_id, channel_ref: str, sender: str, message_text: str):
@ -395,6 +430,11 @@ async def test_feishu_question_like_messages_use_sync_high_accuracy_path(monkeyp
monkeypatch.setattr(mixin_capture_entry, "AIPMOKernelService", _FakeKernel) monkeypatch.setattr(mixin_capture_entry, "AIPMOKernelService", _FakeKernel)
monkeypatch.setattr(ProactiveSignalCaptureService, "_sync_feishu_recent_cache", _noop_sync_recent_cache) monkeypatch.setattr(ProactiveSignalCaptureService, "_sync_feishu_recent_cache", _noop_sync_recent_cache)
monkeypatch.setattr(
mixin_capture_entry.WorkflowSemanticPlanner,
"aplan_message",
classmethod(lambda cls, **kwargs: asyncio.sleep(0, result=_FakeBundle())),
)
service = ProactiveSignalCaptureService(object()) service = ProactiveSignalCaptureService(object())
result = await service.capture_from_feishu( result = await service.capture_from_feishu(
@ -417,6 +457,27 @@ async def test_feishu_question_like_messages_use_sync_high_accuracy_path(monkeyp
async def test_feishu_business_review_query_forwards_semantic_bundle_into_kernel(monkeypatch) -> None: async def test_feishu_business_review_query_forwards_semantic_bundle_into_kernel(monkeypatch) -> None:
captured: dict[str, object] = {} captured: dict[str, object] = {}
class _FakeBundle:
def __init__(self) -> None:
self.primary_workflow = "query_flow"
self.workflows = [SimpleNamespace(workflow_type="query_flow", workflow_action="answer")]
self.diagnostics = {
"message_intent": "business_review",
"consultant_mode_preference": "clarify",
}
def to_dict(self) -> dict[str, object]:
return {
"primary_workflow": "query_flow",
"workflows": [
{
"workflow_type": "query_flow",
"workflow_action": "answer",
}
],
"diagnostics": dict(self.diagnostics),
}
class _FakeKernel: class _FakeKernel:
@staticmethod @staticmethod
def _build_source_message_id(*, source_system: str, source_message_id, channel_ref: str, sender: str, message_text: str): def _build_source_message_id(*, source_system: str, source_message_id, channel_ref: str, sender: str, message_text: str):
@ -454,6 +515,11 @@ async def test_feishu_business_review_query_forwards_semantic_bundle_into_kernel
monkeypatch.setattr(mixin_capture_entry, "AIPMOKernelService", _FakeKernel) monkeypatch.setattr(mixin_capture_entry, "AIPMOKernelService", _FakeKernel)
monkeypatch.setattr(ProactiveSignalCaptureService, "_sync_feishu_recent_cache", _noop_sync_recent_cache) monkeypatch.setattr(ProactiveSignalCaptureService, "_sync_feishu_recent_cache", _noop_sync_recent_cache)
monkeypatch.setattr(
mixin_capture_entry.WorkflowSemanticPlanner,
"aplan_message",
classmethod(lambda cls, **kwargs: asyncio.sleep(0, result=_FakeBundle())),
)
service = ProactiveSignalCaptureService(object()) service = ProactiveSignalCaptureService(object())
result = await service.capture_from_feishu( result = await service.capture_from_feishu(
@ -477,6 +543,27 @@ async def test_feishu_business_review_query_forwards_semantic_bundle_into_kernel
async def test_force_sync_feishu_query_flow_still_forwards_semantic_bundle_into_kernel(monkeypatch) -> None: async def test_force_sync_feishu_query_flow_still_forwards_semantic_bundle_into_kernel(monkeypatch) -> None:
captured: dict[str, object] = {} captured: dict[str, object] = {}
class _FakeBundle:
def __init__(self) -> None:
self.primary_workflow = "query_flow"
self.workflows = [SimpleNamespace(workflow_type="query_flow", workflow_action="answer")]
self.diagnostics = {
"message_intent": "business_review",
"consultant_mode_preference": "clarify",
}
def to_dict(self) -> dict[str, object]:
return {
"primary_workflow": "query_flow",
"workflows": [
{
"workflow_type": "query_flow",
"workflow_action": "answer",
}
],
"diagnostics": dict(self.diagnostics),
}
class _FakeDB: class _FakeDB:
async def commit(self) -> None: async def commit(self) -> None:
return None return None
@ -521,6 +608,11 @@ async def test_force_sync_feishu_query_flow_still_forwards_semantic_bundle_into_
monkeypatch.setattr(mixin_capture_entry, "AIPMOKernelService", _FakeKernel) monkeypatch.setattr(mixin_capture_entry, "AIPMOKernelService", _FakeKernel)
monkeypatch.setattr(ProactiveSignalCaptureService, "_sync_feishu_recent_cache", _noop_sync_recent_cache) monkeypatch.setattr(ProactiveSignalCaptureService, "_sync_feishu_recent_cache", _noop_sync_recent_cache)
monkeypatch.setattr(
mixin_capture_entry.WorkflowSemanticPlanner,
"aplan_message",
classmethod(lambda cls, **kwargs: asyncio.sleep(0, result=_FakeBundle())),
)
service = ProactiveSignalCaptureService(_FakeDB()) service = ProactiveSignalCaptureService(_FakeDB())
result = await service.capture_from_feishu( result = await service.capture_from_feishu(
@ -730,8 +822,20 @@ def test_capture_from_feishu_no_longer_references_legacy_fallback() -> None:
assert "_capture_from_feishu_legacy_flow" not in source assert "_capture_from_feishu_legacy_flow" not in source
def test_should_process_feishu_high_accuracy_treats_requirement_plan_as_sync() -> None: @pytest.mark.asyncio
should_sync, reason = mixin_capture_entry.SignalCaptureEntryMixin._should_process_feishu_high_accuracy( async def test_should_process_feishu_high_accuracy_treats_requirement_plan_as_sync(monkeypatch) -> None:
class _FakeBundle:
primary_workflow = "query_flow"
workflows = [SimpleNamespace(workflow_type="query_flow", workflow_action="answer")]
diagnostics = {"message_intent": "requirement_plan"}
monkeypatch.setattr(
mixin_capture_entry.WorkflowSemanticPlanner,
"aplan_message",
classmethod(lambda cls, **kwargs: asyncio.sleep(0, result=_FakeBundle())),
)
should_sync, reason = await mixin_capture_entry.SignalCaptureEntryMixin._should_process_feishu_high_accuracy(
message_text="@PMFlow 想跟你聊聊需求,做一个自动给每日的大A趋势打分的小工具,先不创建,先收敛第一版。", message_text="@PMFlow 想跟你聊聊需求,做一个自动给每日的大A趋势打分的小工具,先不创建,先收敛第一版。",
) )
@ -739,8 +843,20 @@ def test_should_process_feishu_high_accuracy_treats_requirement_plan_as_sync() -
assert reason == "semantic:requirement_plan" assert reason == "semantic:requirement_plan"
def test_should_process_feishu_high_accuracy_treats_requirement_plan_slot_answer_as_sync() -> None: @pytest.mark.asyncio
should_sync, reason = mixin_capture_entry.SignalCaptureEntryMixin._should_process_feishu_high_accuracy( async def test_should_process_feishu_high_accuracy_treats_requirement_plan_slot_answer_as_sync(monkeypatch) -> None:
class _FakeBundle:
primary_workflow = "query_flow"
workflows = [SimpleNamespace(workflow_type="query_flow", workflow_action="answer")]
diagnostics = {"message_intent": "requirement_plan"}
monkeypatch.setattr(
mixin_capture_entry.WorkflowSemanticPlanner,
"aplan_message",
classmethod(lambda cls, **kwargs: asyncio.sleep(0, result=_FakeBundle())),
)
should_sync, reason = await mixin_capture_entry.SignalCaptureEntryMixin._should_process_feishu_high_accuracy(
message_text="@PMFlow 先按技术指标来,主要给投顾看。", message_text="@PMFlow 先按技术指标来,主要给投顾看。",
) )
@ -752,6 +868,18 @@ def test_should_process_feishu_high_accuracy_treats_requirement_plan_slot_answer
async def test_capture_from_feishu_requirement_plan_skips_async_ack_and_forwards_semantic_bundle(monkeypatch) -> None: async def test_capture_from_feishu_requirement_plan_skips_async_ack_and_forwards_semantic_bundle(monkeypatch) -> None:
captured: dict[str, object] = {} captured: dict[str, object] = {}
class _FakeBundle:
primary_workflow = "query_flow"
workflows = [SimpleNamespace(workflow_type="query_flow", workflow_action="answer")]
diagnostics = {"message_intent": "requirement_plan"}
def to_dict(self) -> dict[str, object]:
return {
"primary_workflow": "query_flow",
"workflows": [{"workflow_type": "query_flow", "workflow_action": "answer"}],
"diagnostics": dict(self.diagnostics),
}
class _FakeDB: class _FakeDB:
async def commit(self) -> None: async def commit(self) -> None:
return None return None
@ -812,6 +940,11 @@ async def test_capture_from_feishu_requirement_plan_skips_async_ack_and_forwards
monkeypatch.setattr(mixin_capture_entry, "AIPMOKernelService", _FakeKernel) monkeypatch.setattr(mixin_capture_entry, "AIPMOKernelService", _FakeKernel)
monkeypatch.setattr(mixin_capture_entry, "MountedAIRuntimeWriteService", _FakeMountedRuntimeWriteService) monkeypatch.setattr(mixin_capture_entry, "MountedAIRuntimeWriteService", _FakeMountedRuntimeWriteService)
monkeypatch.setattr(ProactiveSignalCaptureService, "_sync_feishu_recent_cache", _noop_sync_recent_cache) monkeypatch.setattr(ProactiveSignalCaptureService, "_sync_feishu_recent_cache", _noop_sync_recent_cache)
monkeypatch.setattr(
mixin_capture_entry.WorkflowSemanticPlanner,
"aplan_message",
classmethod(lambda cls, **kwargs: asyncio.sleep(0, result=_FakeBundle())),
)
service = ProactiveSignalCaptureService(_FakeDB()) service = ProactiveSignalCaptureService(_FakeDB())
result = await service.capture_from_feishu( result = await service.capture_from_feishu(
@ -836,6 +969,18 @@ async def test_capture_from_feishu_requirement_plan_skips_async_ack_and_forwards
async def test_capture_from_feishu_requirement_plan_slot_answer_stays_sync_without_async_ack(monkeypatch) -> None: async def test_capture_from_feishu_requirement_plan_slot_answer_stays_sync_without_async_ack(monkeypatch) -> None:
captured: dict[str, object] = {} captured: dict[str, object] = {}
class _FakeBundle:
primary_workflow = "query_flow"
workflows = [SimpleNamespace(workflow_type="query_flow", workflow_action="answer")]
diagnostics = {"message_intent": "requirement_plan"}
def to_dict(self) -> dict[str, object]:
return {
"primary_workflow": "query_flow",
"workflows": [{"workflow_type": "query_flow", "workflow_action": "answer"}],
"diagnostics": dict(self.diagnostics),
}
class _FakeDB: class _FakeDB:
async def commit(self) -> None: async def commit(self) -> None:
return None return None
@ -896,6 +1041,11 @@ async def test_capture_from_feishu_requirement_plan_slot_answer_stays_sync_witho
monkeypatch.setattr(mixin_capture_entry, "AIPMOKernelService", _FakeKernel) monkeypatch.setattr(mixin_capture_entry, "AIPMOKernelService", _FakeKernel)
monkeypatch.setattr(mixin_capture_entry, "MountedAIRuntimeWriteService", _FakeMountedRuntimeWriteService) monkeypatch.setattr(mixin_capture_entry, "MountedAIRuntimeWriteService", _FakeMountedRuntimeWriteService)
monkeypatch.setattr(ProactiveSignalCaptureService, "_sync_feishu_recent_cache", _noop_sync_recent_cache) monkeypatch.setattr(ProactiveSignalCaptureService, "_sync_feishu_recent_cache", _noop_sync_recent_cache)
monkeypatch.setattr(
mixin_capture_entry.WorkflowSemanticPlanner,
"aplan_message",
classmethod(lambda cls, **kwargs: asyncio.sleep(0, result=_FakeBundle())),
)
service = ProactiveSignalCaptureService(_FakeDB()) service = ProactiveSignalCaptureService(_FakeDB())
result = await service.capture_from_feishu( result = await service.capture_from_feishu(

View File

@ -71,6 +71,44 @@ def test_classify_message_scene_does_not_infer_execute_from_raw_text_only() -> N
assert scene == "qa" assert scene == "qa"
def test_classify_message_scene_uses_bundle_without_planner_classifier(monkeypatch: pytest.MonkeyPatch) -> None:
_ = monkeypatch
scene = FeishuIntegrationService._classify_message_scene(
capture_result={
"semantic_bundle": {
"bundle_id": "semantic-qa",
"semantic_version": "2026-03-27.workflow-v1",
"source_system": "feishu",
"primary_workflow": "query_flow",
"workflows": [
{
"workflow_type": "query_flow",
"workflow_action": "answer",
"target_scope": "global",
"operation_codes": ["query.answer"],
"requires_confirmation": False,
"confidence": 0.9,
"slots": [],
"clarification_needed": False,
"metadata": {},
}
],
"message_gate": "auto_execute",
"reply_contract": {
"mode": "aggregate_card",
"template_key": "workflow_bundle",
"summary": "query_flow",
},
"diagnostics": {},
}
},
message_content="帮我创建项目和信号源",
)
assert scene == "qa"
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_handle_message_receive_orchestrates_capture_and_finalize(monkeypatch) -> None: async def test_handle_message_receive_orchestrates_capture_and_finalize(monkeypatch) -> None:
service = FeishuIntegrationService(db=object()) service = FeishuIntegrationService(db=object())

View File

@ -139,3 +139,101 @@ async def test_semantic_context_assembler_builds_packet_from_runtime_state(db_se
assert packet.thread.recent_turns[0].role == "user" assert packet.thread.recent_turns[0].role == "user"
assert packet.thread.recent_turns[-1].role == "assistant" assert packet.thread.recent_turns[-1].role == "assistant"
assert packet.thread.open_questions == ["当前是不是新需求 topic"] assert packet.thread.open_questions == ["当前是不是新需求 topic"]
@pytest.mark.asyncio
async def test_semantic_context_assembler_adds_draft_followup_ingress_actions(db_session) -> None:
topic = MountedTopicWorkspace(
tenant_id=7,
topic_key="feishu:chat_semantic:root:om_root_followup",
source_system="feishu",
channel_ref="chat_semantic",
conversation_scope_key="feishu:chat_semantic:ou_semantic",
thread_key="thread_semantic",
root_message_id="om_root_followup",
mode="plan",
status="active",
plan_status="awaiting_execute_confirm",
facts_json={"title": "换季穿搭推荐"},
goals_json=["确认草稿"],
constraints_json={},
open_questions=[],
pending_actions=[],
readiness_score=0.8,
metadata_json={"last_message_preview": "请确认当前草稿"},
)
db_session.add(topic)
await db_session.flush()
packet = await SemanticContextAssembler(db_session).assemble(
tenant_id=7,
operator_user_id=99,
topic_workspace_id=int(topic.id),
latest_user_message="是这种的可以很长,但是聊天内容发的那种就不行这么长",
ingress_context={
"candidate_kind": "draft_followup",
"source_system": "feishu",
"reply_anchor_present": True,
"allowed_followup_actions": ["confirm", "revise", "cancel", "answer_only"],
"thread_context": {"reply_to_message_id": "om_reply_1"},
},
)
assert packet.ingress is not None
assert packet.ingress.candidate_kind == "draft_followup"
assert packet.ingress.reply_anchor_present is True
assert "draft.followup.answer_only" in packet.mode_card.allowed_actions
assert "draft.followup.revise" in packet.mode_card.allowed_actions
@pytest.mark.asyncio
async def test_semantic_context_assembler_adds_active_context_followup_actions(db_session) -> None:
topic = MountedTopicWorkspace(
tenant_id=7,
topic_key="feishu:chat_semantic:root:om_root_active_followup",
source_system="feishu",
channel_ref="chat_semantic",
conversation_scope_key="feishu:chat_semantic:ou_semantic",
thread_key="thread_semantic",
root_message_id="om_root_active_followup",
mode="plan",
status="active",
plan_status="collecting",
facts_json={"title": "AI 直播复盘工具"},
goals_json=["继续收敛信号源模块"],
constraints_json={},
open_questions=["信号源这块第一版怎么收"],
pending_actions=[],
readiness_score=0.5,
metadata_json={"last_message_preview": "先把这版需求收清楚"},
)
db_session.add(topic)
await db_session.flush()
packet = await SemanticContextAssembler(db_session).assemble(
tenant_id=7,
operator_user_id=99,
topic_workspace_id=int(topic.id),
latest_user_message="信号源呢?",
ingress_context={
"candidate_kind": "active_context_followup",
"source_system": "feishu",
"reply_anchor_present": False,
"active_context_kind": "requirement_plan",
"allowed_context_scopes": [
"current_requirement",
"current_draft",
"independent_workflow",
"clarify",
],
"thread_context": {},
},
)
assert packet.ingress is not None
assert packet.ingress.candidate_kind == "active_context_followup"
assert packet.ingress.active_context_kind == "requirement_plan"
assert "context.followup.current_requirement" in packet.mode_card.allowed_actions
assert "context.followup.current_draft" in packet.mode_card.allowed_actions
assert "context.followup.independent_workflow" in packet.mode_card.allowed_actions
assert "context.followup.clarify" in packet.mode_card.allowed_actions

View File

@ -1,5 +1,6 @@
from __future__ import annotations from __future__ import annotations
from app.config.ai_prompts import PMFLOW_SEMANTIC_CONTEXT_DECISION_PROMPT
from app.services.ai_pmo.semantic_context.decision_schema import ( from app.services.ai_pmo.semantic_context.decision_schema import (
DecisionGateResult, DecisionGateResult,
ModeCard, ModeCard,
@ -58,3 +59,18 @@ def test_semantic_decision_gatekeeper_blocks_forbidden_action() -> None:
assert result.accepted is False assert result.accepted is False
assert "forbidden_action:project.create" in result.blocked_reasons assert "forbidden_action:project.create" in result.blocked_reasons
assert result.next_plan_status == "active" assert result.next_plan_status == "active"
def test_semantic_context_decision_prompt_avoids_business_semantic_examples() -> None:
forbidden_fragments = (
"信号源呢?",
"负责人呢?",
"技术路线呢?",
"商业模式呢?",
"给项目2接入",
"GitHub webhook",
"RTMP/WebRTC",
)
for fragment in forbidden_fragments:
assert fragment not in PMFLOW_SEMANTIC_CONTEXT_DECISION_PROMPT

View File

@ -32,7 +32,7 @@ def test_semantic_lint_flags_new_scattered_hint_tables(tmp_path) -> None:
assert problems[0].rule_id == "semantic_hint_constant" assert problems[0].rule_id == "semantic_hint_constant"
def test_semantic_lint_allows_workflow_semantic_planner_directory(tmp_path) -> None: def test_semantic_lint_flags_workflow_semantic_planner_directory(tmp_path) -> None:
module = _load_semantic_lint_module() module = _load_semantic_lint_module()
target = tmp_path / "backend" / "app" / "services" / "ai_pmo" / "workflow_semantic_planner" / "foo.py" target = tmp_path / "backend" / "app" / "services" / "ai_pmo" / "workflow_semantic_planner" / "foo.py"
target.parent.mkdir(parents=True, exist_ok=True) target.parent.mkdir(parents=True, exist_ok=True)
@ -44,7 +44,8 @@ def test_semantic_lint_allows_workflow_semantic_planner_directory(tmp_path) -> N
baseline_path=Path("scripts/semantic_lint_baseline.txt"), baseline_path=Path("scripts/semantic_lint_baseline.txt"),
) )
assert problems == [] assert len(problems) == 1
assert problems[0].rule_id == "semantic_hint_constant"
def test_semantic_lint_repo_has_no_compat_baseline_exceptions() -> None: def test_semantic_lint_repo_has_no_compat_baseline_exceptions() -> None:

View File

@ -1,278 +1,119 @@
from __future__ import annotations
import asyncio
from typing import Any
import pytest import pytest
from app.services.ai_pmo.workflow_semantic_planner import ( from app.services.ai_pmo.semantic_context.decision_schema import SemanticDecision
WorkflowSemanticPlanner, from app.services.ai_pmo.workflow_semantic_planner import WorkflowSemanticPlanner
)
def test_workflow_semantic_planner_builds_multi_workflow_bundle() -> None: def _decision(*, workflows: list[dict[str, Any]], scene_metadata: dict[str, Any] | None = None) -> SemanticDecision:
return SemanticDecision(
topic_relation="new_topic",
mode_decision="answer_only",
workflow_plans=workflows,
scene_metadata=scene_metadata or {},
requested_actions=["workflow.bundle.compose"],
confidence=0.91,
)
def test_workflow_semantic_planner_plan_message_uses_model_decision(monkeypatch: pytest.MonkeyPatch) -> None:
async def _fake_decide(cls, *, message_text: str, source_system: str, metadata: dict[str, Any] | None = None):
assert message_text == "帮我创建一个项目,并把这个需求接入信号源"
assert source_system == "feishu"
return _decision(
workflows=[
{
"workflow_type": "new_project_flow",
"workflow_action": "create",
"target_scope": "global",
"operation_codes": ["project.create"],
"confidence": 0.93,
},
{
"workflow_type": "signal_source_flow",
"workflow_action": "onboard",
"target_scope": "global",
"operation_codes": ["signal_source.onboard"],
"confidence": 0.92,
},
]
)
monkeypatch.setattr(WorkflowSemanticPlanner, "_decide_cold_start_semantics", classmethod(_fake_decide))
bundle = WorkflowSemanticPlanner.plan_message( bundle = WorkflowSemanticPlanner.plan_message(
message_text="帮我创建一个项目,并把这个需求接入信号源", message_text="帮我创建一个项目,并把这个需求接入信号源",
source_system="feishu", source_system="feishu",
) )
workflow_types = [item.workflow_type for item in bundle.workflows]
assert bundle.source_system == "feishu"
assert bundle.primary_workflow == "new_project_flow" assert bundle.primary_workflow == "new_project_flow"
assert workflow_types == ["new_project_flow", "signal_source_flow"] assert [item.workflow_type for item in bundle.workflows] == ["new_project_flow", "signal_source_flow"]
assert bundle.reply_contract.mode == "aggregate_card" assert bundle.reply_contract.mode == "aggregate_card"
def test_workflow_semantic_planner_derives_query_bundle() -> None: @pytest.mark.asyncio
bundle = WorkflowSemanticPlanner.plan_message( async def test_workflow_semantic_planner_aplan_message_preserves_scene_metadata(monkeypatch: pytest.MonkeyPatch) -> None:
message_text="现在项目进展怎么样?", async def _fake_decide(cls, *, message_text: str, source_system: str, metadata: dict[str, Any] | None = None):
source_system="pmflow_manual", assert message_text == "想跟你聊个新需求"
) return _decision(
workflows=[
{
"workflow_type": "query_flow",
"workflow_action": "answer",
"target_scope": "global",
"operation_codes": ["query.answer"],
"confidence": 0.88,
"metadata": {"message_intent": "requirement_plan"},
}
],
scene_metadata={
"message_intent": "requirement_plan",
"consultant_mode_preference": "clarify",
"topic_switch": "new",
},
)
assert bundle.primary_workflow == "query_flow" monkeypatch.setattr(WorkflowSemanticPlanner, "_decide_cold_start_semantics", classmethod(_fake_decide))
assert [item.workflow_type for item in bundle.workflows] == ["query_flow"]
assert bundle.message_gate == "auto_execute"
bundle = await WorkflowSemanticPlanner.aplan_message(
def test_workflow_semantic_planner_helpers_cover_legacy_entry_semantics() -> None: message_text="想跟你聊个新需求",
assert WorkflowSemanticPlanner.looks_like_project_lookup_question("现在有哪些项目?") is True
assert WorkflowSemanticPlanner.extract_project_lookup_keyword("请问项目 智能风控平台 有吗?") == "智能风控平台"
assert WorkflowSemanticPlanner.is_legacy_business_command("把项目状态改成已完成") is True
assert WorkflowSemanticPlanner.is_legacy_business_command("项目现在状态怎么样?") is False
assert WorkflowSemanticPlanner.wants_followup_continue("继续处理刚才那个") is True
assert WorkflowSemanticPlanner.wants_followup_status("它现在怎么样了?") is True
def test_workflow_semantic_planner_contextual_write_confirmation_is_stateful() -> None:
assert WorkflowSemanticPlanner.looks_like_contextual_write_confirmation("那就按这个继续执行") is True
assert WorkflowSemanticPlanner.looks_like_contextual_write_confirmation("这个项目现在怎么样?") is False
def test_workflow_semantic_planner_routes_new_business_review_with_five_lenses() -> None:
bundle = WorkflowSemanticPlanner.plan_message(
message_text="我要做一个大模型 API 聚合平台,帮我分析下要怎么做",
source_system="feishu",
)
assert bundle.primary_workflow == "query_flow"
assert [item.workflow_type for item in bundle.workflows] == ["query_flow"]
assert bundle.message_gate == "auto_execute"
assert bundle.workflows[0].metadata["message_intent"] == "business_review"
assert bundle.workflows[0].metadata["review_framework"] == "five_lenses"
assert bundle.diagnostics["message_intent"] == "business_review"
assert bundle.diagnostics["consultant_mode_preference"] == "clarify"
def test_workflow_semantic_planner_marks_correction_escalation_queries() -> None:
bundle = WorkflowSemanticPlanner.plan_message(
message_text="不对,调用高级模型重新回答我的问题",
source_system="feishu",
)
assert bundle.primary_workflow == "query_flow"
assert bundle.workflows[0].metadata["message_intent"] == "correction_escalation"
assert bundle.workflows[0].metadata["force_thinking_mode"] == "deep"
assert bundle.diagnostics["message_intent"] == "correction_escalation"
assert bundle.diagnostics["consultant_mode_preference"] == "challenge"
def test_workflow_semantic_planner_treats_natural_problem_statement_as_new_project_candidate() -> None:
bundle = WorkflowSemanticPlanner.plan_message(
message_text="最近直播复盘太慢了,我想做个给运营用的小工具,能自动切片、总结亮点掉点,再给复盘清单。",
source_system="feishu",
)
assert bundle.primary_workflow == "new_project_flow"
assert [item.workflow_type for item in bundle.workflows] == ["new_project_flow"]
assert bundle.workflows[0].workflow_action == "create"
def test_workflow_semantic_planner_keeps_natural_solution_consulting_in_query_flow() -> None:
bundle = WorkflowSemanticPlanner.plan_message(
message_text="我们最近直播复盘很慢,先帮我分析一下第一版应该怎么做,不要直接创建。",
source_system="feishu",
)
assert bundle.primary_workflow == "query_flow"
assert [item.workflow_type for item in bundle.workflows] == ["query_flow"]
def test_workflow_semantic_planner_keeps_deferred_creation_request_in_new_project_flow() -> None:
bundle = WorkflowSemanticPlanner.plan_message(
message_text="最近直播复盘太慢了,想做个给运营团队用的小工具,先不要直接创建,我再补充几轮,最后确认再入库。",
source_system="feishu",
)
assert bundle.primary_workflow == "query_flow"
assert [item.workflow_type for item in bundle.workflows] == ["query_flow"]
assert bundle.workflows[0].metadata["message_intent"] == "requirement_plan"
assert bundle.workflows[0].metadata["consultant_mode_preference"] == "clarify"
def test_workflow_semantic_planner_keeps_scope_framing_discussion_in_query_flow() -> None:
bundle = WorkflowSemanticPlanner.plan_message(
message_text="我们想做一个面向医疗器械 BD 的政策雷达工具,先聚焦华东三省,先别创建项目,我还在补范围。",
source_system="feishu",
)
assert bundle.primary_workflow == "query_flow"
assert [item.workflow_type for item in bundle.workflows] == ["query_flow"]
assert bundle.workflows[0].metadata["message_intent"] == "requirement_plan"
assert bundle.workflows[0].metadata["consultant_mode_preference"] == "clarify"
def test_workflow_semantic_planner_marks_explicit_new_requirement_topic_switch() -> None:
bundle = WorkflowSemanticPlanner.plan_message(
message_text="不对啊,现在是另一个需求,先不要沿用刚才那版。",
source_system="feishu", source_system="feishu",
) )
assert bundle.primary_workflow == "query_flow" assert bundle.primary_workflow == "query_flow"
assert bundle.workflows[0].metadata["message_intent"] == "requirement_plan" assert bundle.workflows[0].metadata["message_intent"] == "requirement_plan"
assert bundle.workflows[0].metadata["topic_switch"] == "new"
assert bundle.diagnostics["topic_switch"] == "new" assert bundle.diagnostics["topic_switch"] == "new"
def test_workflow_semantic_planner_marks_chatty_new_requirement_phrase_as_topic_switch() -> None: def test_workflow_semantic_planner_sync_wrapper_fails_inside_running_loop() -> None:
bundle = WorkflowSemanticPlanner.plan_message( async def _runner() -> None:
message_text="想跟你聊个新需求关于批量自动化部署openclaw的", with pytest.raises(RuntimeError):
source_system="feishu", WorkflowSemanticPlanner.plan_message(message_text="test", source_system="feishu")
asyncio.run(_runner())
def test_workflow_semantic_planner_no_longer_exposes_legacy_semantic_helpers() -> None:
legacy_names = (
"plan_from_intent",
"classify_scene",
"is_action_request_capture",
"parse_followup_action",
"extract_project_lookup_keyword",
"wants_followup_continue",
"wants_followup_status",
"looks_like_project_create_request",
"looks_like_task_create_request",
"looks_like_signal_source_request",
"looks_like_external_connect_request",
"looks_like_contextual_write_confirmation",
"is_query_like_question",
"is_obvious_question",
) )
assert bundle.primary_workflow == "query_flow" for name in legacy_names:
assert bundle.workflows[0].metadata["message_intent"] == "requirement_plan" assert not hasattr(WorkflowSemanticPlanner, name), name
assert bundle.workflows[0].metadata["topic_switch"] == "new"
assert bundle.diagnostics["topic_switch"] == "new"
def test_workflow_semantic_planner_routes_assignee_recommendation_back_to_query_flow() -> None:
bundle = WorkflowSemanticPlanner.plan_message(
message_text="那你觉得分配给谁做比较合适?",
source_system="feishu",
)
assert bundle.primary_workflow == "query_flow"
assert [item.workflow_type for item in bundle.workflows] == ["query_flow"]
assert bundle.workflows[0].metadata["message_intent"] == "business_review"
assert bundle.workflows[0].metadata["consultant_mode_preference"] == "advise"
def test_workflow_semantic_planner_routes_plain_assignee_recommendation_without_question_mark() -> None:
bundle = WorkflowSemanticPlanner.plan_message(
message_text="那你觉得这件事分配给谁做比较合适",
source_system="feishu",
)
assert bundle.primary_workflow == "query_flow"
assert bundle.workflows[0].metadata["message_intent"] == "business_review"
assert bundle.workflows[0].metadata["consultant_mode_preference"] == "advise"
def test_workflow_semantic_planner_marks_casual_chat_as_chat_query() -> None:
bundle = WorkflowSemanticPlanner.plan_message(
message_text="最近有点累,今天先不推进需求了,陪我简单聊两句。",
source_system="feishu",
)
assert bundle.primary_workflow == "query_flow"
assert bundle.workflows[0].metadata["message_intent"] == "casual_chat"
assert bundle.workflows[0].metadata["consultant_mode_preference"] == "brainstorm"
def test_workflow_semantic_planner_marks_requirement_followup_clarify_as_business_review() -> None:
bundle = WorkflowSemanticPlanner.plan_message(
message_text="那就先按上面的方向继续,但是别太重",
source_system="feishu",
)
assert bundle.primary_workflow == "query_flow"
assert bundle.workflows[0].metadata["message_intent"] == "business_review"
assert bundle.workflows[0].metadata["consultant_mode_preference"] == "clarify"
def test_workflow_semantic_planner_marks_requirement_scope_advise_as_business_review() -> None:
bundle = WorkflowSemanticPlanner.plan_message(
message_text="如果第一版先做 MVP,这个需求范围怎么收才比较稳?",
source_system="feishu",
)
assert bundle.primary_workflow == "query_flow"
assert bundle.workflows[0].metadata["message_intent"] == "business_review"
assert bundle.workflows[0].metadata["consultant_mode_preference"] == "advise"
def test_workflow_semantic_planner_routes_requirement_chat_into_requirement_plan() -> None:
bundle = WorkflowSemanticPlanner.plan_message(
message_text="想跟你聊聊需求,关于自动给每日的大A趋势打分",
source_system="feishu",
)
assert bundle.primary_workflow == "query_flow"
assert [item.workflow_type for item in bundle.workflows] == ["query_flow"]
assert bundle.workflows[0].metadata["message_intent"] == "requirement_plan"
assert bundle.workflows[0].metadata["consultant_mode_preference"] == "clarify"
assert bundle.diagnostics["message_intent"] == "requirement_plan"
@pytest.mark.parametrize(
"message_text",
[
"技术指标吧",
"主要给投顾看",
"飞书日报就行",
"每天收盘后发",
"先给内部 PM 和研发负责人用",
"飞书总结就行",
"每次会后半小时内整理出来",
],
)
def test_workflow_semantic_planner_treats_short_requirement_slot_answers_as_requirement_plan(
message_text: str,
) -> None:
bundle = WorkflowSemanticPlanner.plan_message(
message_text=message_text,
source_system="feishu",
)
assert bundle.primary_workflow == "query_flow"
assert bundle.workflows[0].metadata["message_intent"] == "requirement_plan"
assert bundle.workflows[0].metadata["consultant_mode_preference"] == "clarify"
def test_workflow_semantic_planner_routes_generic_requirement_chat_into_requirement_plan() -> None:
bundle = WorkflowSemanticPlanner.plan_message(
message_text="想跟你聊个需求,我们开完会以后待办经常散掉,想做个小工具自动从会议纪要里提待办,先别创建,先收第一版。",
source_system="feishu",
)
assert bundle.primary_workflow == "query_flow"
assert bundle.workflows[0].metadata["message_intent"] == "requirement_plan"
assert bundle.workflows[0].metadata["consultant_mode_preference"] == "clarify"
def test_workflow_semantic_planner_treats_natural_draft_details_as_revision() -> None:
action, note = WorkflowSemanticPlanner.parse_draft_action(
message_text="第一版先做直播录屏和评论回放切片,再加亮点和掉点总结,最后给一份可执行复盘清单。"
)
assert action == "revise"
assert note == "第一版先做直播录屏和评论回放切片,再加亮点和掉点总结,最后给一份可执行复盘清单。"
def test_workflow_semantic_planner_does_not_treat_smalltalk_as_revision() -> None:
action, note = WorkflowSemanticPlanner.parse_draft_action(message_text="收到,辛苦了")
assert action == "unknown"
assert note == ""
def test_workflow_semantic_planner_treats_scope_narrowing_as_revision_not_cancel() -> None:
message = "第一版先别做太大,先把三件事做好就行:直播录屏和评论回放切片、亮点和掉点自动总结、给团队一份能直接执行的复盘清单。"
action, note = WorkflowSemanticPlanner.parse_draft_action(message_text=message)
assert action == "revise"
assert note == message
def test_workflow_semantic_planner_keeps_terminal_stop_phrase_as_cancel() -> None:
action, note = WorkflowSemanticPlanner.parse_draft_action(message_text="这个需求先别做了,取消吧。")
assert action == "cancel"
assert note == ""

View File

@ -17,7 +17,6 @@ DEFAULT_SCAN_ROOTS = (
Path("backend/app/api/ai_pmo_api"), Path("backend/app/api/ai_pmo_api"),
) )
ALLOWLIST_PREFIXES = ( ALLOWLIST_PREFIXES = (
Path("backend/app/services/ai_pmo/workflow_semantic_planner"),
Path("backend/app/config/ai_prompt_registry.py"), Path("backend/app/config/ai_prompt_registry.py"),
Path("backend/app/config/ai_prompts.py"), Path("backend/app/config/ai_prompts.py"),
Path("backend/app/config/ai_prompts_catalog"), Path("backend/app/config/ai_prompts_catalog"),

Binary file not shown.