Refine semantic routing and ingress decision flow
This commit is contained in:
parent
38adaed6e6
commit
7d39cf503c
|
|
@ -90,6 +90,8 @@ frontend/ # React 前端
|
|||
- 禁止模型兜底回复:模型失败/超时/空响应/结构化解析失败时,必须显式报错并保留真实失败原因,禁止返回固定兜底文案冒充成功结果
|
||||
- 核心业务内容零兜底:宪章、信号分析、任务拆解、项目建议、范围边界、风险判断、资源计划、对齐结论等用户可见核心业务内容,禁止使用默认文案、模板句子、占位字段或 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 抽取与结构化结果解析
|
||||
- 用户可见业务动作拍板必须优先走“上下文 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 系统定位
|
||||
|
||||
|
|
@ -225,6 +227,7 @@ frontend/ # React 前端
|
|||
## 13. 测试与验证
|
||||
|
||||
- 后端改动:`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` 零错误
|
||||
- 新增接口:必须在 `backend/tests/` 补充对应单测
|
||||
|
||||
|
|
|
|||
10
CLAUDE.md
10
CLAUDE.md
|
|
@ -6,6 +6,8 @@
|
|||
- 建议:规则放 `AGENTS.md`,架构/目录说明放 `CLAUDE.md`
|
||||
- 当前 AI 行为的系统宪章已在 `AGENTS.md` 中升级为“事实层 / 评审层 / 生成层 / 执行层”四层分治;所有新增 AI 业务默认按该宪章接入,`CLAUDE.md` 仅保留结构与背景说明
|
||||
- 新增执行约定:计划类需求一旦确认进入实现阶段,默认连续执行到“实现完成 + 验证完成 + 统一回复验收结果”;仅在明确阻塞、高风险冲突、context 限制或用户主动中断时才中途打断。
|
||||
- 新增 bugfix 执行约定:缺陷修复/回归修复允许跨多个直接相关文件端到端完成,不再按文件数硬性拆分;但必须同时满足“改动范围围绕同一根因”“不夹带无关重构”“最终附带编译/测试验证”。大功能开发或结构性重构仍优先按子任务拆分。
|
||||
- 新增发布约定:凡后端代码或数据库迁移发生改动,完成编译/测试后必须继续执行当前环境对应的热部署/重启/重载步骤,确认服务已加载新版本;若当前机器不是部署环境、缺少权限或不具备热部署条件,必须在结果中明确说明未执行原因与阻塞点,禁止默认省略。
|
||||
|
||||
## 2. 项目定位
|
||||
- 项目名:`PMFlow / Neural Sync`
|
||||
|
|
@ -197,7 +199,7 @@ backend/
|
|||
- 新增:`backend/app/utils/project_command_utils.py`(项目创建标题/描述提取与归一化辅助)
|
||||
- 新增:`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/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/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 锚定)
|
||||
|
|
@ -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` 收集完成时,必须直接整理成需求草稿,禁止继续空转追问
|
||||
- `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` 当作主链真源或绕过统一执行门禁
|
||||
- `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` 的追问节奏必须像自然聊天:上一轮只确认新增信息、下一轮只问一个缺口;初始描述中的“每日/自动”不能过早推断成已确认的更新频率,避免少问关键问题或提前出草稿
|
||||
- `requirement_plan` 必须按 profile 区分场景:`scoring` 需求继续收集 `打分依据/目标用户/输出形式/更新频率/第一版范围`,`generic` 需求收集 `输入材料/目标用户/输出形式/响应时效/第一版范围`;像“会议纪要提待办”这类普通工具需求,禁止再复用“这个分数怎么打”的旧问题模板
|
||||
- `generic requirement_plan` 的 `A/B/C` 选项只能出现在 active requirement session 的 `collecting` 轮次里,并且只允许绑定当前缺口字段(如 `target_user / output_form / cadence`);普通闲聊、query.answer、非 requirement followup 必须继续保持自由文案,禁止全局套用选项模板
|
||||
- `requirement_plan` 当前用户可见主链只保留 IT 项目收敛口径;历史 `generic/scoring` 数据只允许作为兼容读取或旧测试场景,不得再作为新会话的用户可见模板扩散。普通闲聊、query.answer、非 requirement followup 必须继续保持自由文案,禁止全局套用 A/B/C 选项骨架
|
||||
- 用户显式说“聊个新需求 / 另一个需求 / 跟上一个无关”时,AI PMO 必须先清掉当前会话里继承的旧 `current_project_id`,避免后续短回复继续被旧项目上下文污染
|
||||
- 裸 `A/B/C/确认/你自行推荐` 这类低信息量回复,如果没有命中当前 active requirement session 或待确认草稿,必须直接提示“当前没有进行中的收敛轮次/草稿”,禁止再回落到 `query.answer` 检索图谱并生成旧项目结论
|
||||
- 飞书私聊中的“答错了/不对/调用高级模型/用更强模型/重新回答”必须被识别为 `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 写操作幂等以消息级锚点为准:优先使用 `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 抽取与结构化结果解析
|
||||
- 用户可见业务动作拍板必须优先走 `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 仅记录已知兼容残留
|
||||
- 只读问答链路的 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` 只能作为派生层,禁止再造平行原始数据主表
|
||||
|
|
|
|||
|
|
@ -20,11 +20,18 @@ def _should_hide_work_item_from_inbox(*, row: WorkItem, ingest: IngestEvent) ->
|
|||
normalized = _normalize_noise_text(message_text)
|
||||
if not normalized:
|
||||
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
|
||||
if normalized in FRONT_NOISE_EXACT:
|
||||
return True
|
||||
if len(normalized) <= 8 and any(keyword in normalized for keyword in FRONT_NOISE_HINTS):
|
||||
if len(normalized) <= 3:
|
||||
return True
|
||||
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)
|
||||
if not has_closure:
|
||||
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(
|
||||
str(item.get("role") or "").strip().lower() == "assistant" and str(item.get("text") or "").strip()
|
||||
for item in turns
|
||||
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
|
||||
if summary_is_generic and not modules and not has_assistant_turn:
|
||||
return True
|
||||
if dialogue and "机器人:" not in dialogue and len(turns) <= 1 and weak_summary:
|
||||
if dialogue and "机器人:" not in dialogue and len(turns) <= 1 and summary_len <= 12 and not modules:
|
||||
return True
|
||||
return False
|
||||
|
||||
|
|
|
|||
|
|
@ -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 = _compact_text(summary_seed, 180)
|
||||
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 = (
|
||||
(closure_summary or message_text)
|
||||
if prefer_closure_title
|
||||
|
|
|
|||
|
|
@ -17,16 +17,7 @@ from ...dependencies.tenant import get_tenant_id
|
|||
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_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"])
|
||||
logger = get_logger(__name__)
|
||||
FRONT_NOISE_INTENTS = {INTENT_QUERY, ROUTE_QUERY_ANSWER}
|
||||
FRONT_NOISE_INTENTS = {"query.read_only", "query.answer"}
|
||||
|
|
|
|||
|
|
@ -72,24 +72,78 @@ PMFLOW_SEMANTIC_CONTEXT_DECISION_PROMPT = """
|
|||
你的输入是系统整理好的 semantic context packet。你的职责不是执行写操作,而是:
|
||||
1. 判断用户这句话是否继续当前 topic。
|
||||
2. 判断当前 workflow 是否继续停留在 plan。
|
||||
3. 给出 draft patch 建议。
|
||||
4. 给出要回复给用户的内容。
|
||||
5. 明确 requested_actions 与是否需要确认。
|
||||
3. 如果 packet.ingress.candidate_kind == "draft_followup",要判断这句回复是在确认草稿、修改草稿、取消草稿,还是仅仅反馈展示/沟通方式。
|
||||
4. 如果 packet.ingress.candidate_kind == "active_context_followup",要判断这句短追问应该留在当前需求、留在当前草稿,还是升级成独立 workflow。
|
||||
5. 给出 draft patch 建议。
|
||||
6. 给出要回复给用户的内容。
|
||||
7. 明确 requested_actions 与是否需要确认。
|
||||
|
||||
硬约束:
|
||||
- 只能依据 packet 中的事实、草稿、recent turns、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 schema:
|
||||
{
|
||||
"topic_relation": "continue_current_topic | new_topic | uncertain",
|
||||
"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": {},
|
||||
"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",
|
||||
"requires_confirmation": true,
|
||||
"requested_actions": ["reply.compose"],
|
||||
"requested_actions": ["reply.compose | workflow.bundle.compose"],
|
||||
"confidence": 0.0,
|
||||
"uncertainties": []
|
||||
}
|
||||
|
|
@ -98,6 +152,136 @@ semantic_context_packet:
|
|||
{semantic_packet}
|
||||
""".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:
|
||||
"""返回默认角色 Prompt 配置(每次调用返回新对象)。"""
|
||||
|
|
@ -230,6 +414,8 @@ __all__ = [
|
|||
"PROMPT_EXTERNAL_TREND_QA",
|
||||
"PROMPT_INDUSTRY_TREND_QA",
|
||||
"PMFLOW_SEMANTIC_CONTEXT_DECISION_PROMPT",
|
||||
"PMFLOW_REQUIREMENT_PLAN_ROUND_PROMPT",
|
||||
"PMFLOW_REQUIREMENT_PLAN_DRAFT_PROMPT",
|
||||
"PROMPT_REGISTRY",
|
||||
"PROMPT_REGISTRY_MAP",
|
||||
"get_prompt_registry",
|
||||
|
|
|
|||
|
|
@ -164,10 +164,11 @@ class AdaptiveRAGAnswerMainlineMixin:
|
|||
)
|
||||
query_type = self._classify_query_type(effective_q)
|
||||
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,
|
||||
question=resolved_question,
|
||||
effective_question=effective_q,
|
||||
asked_via=asked_via,
|
||||
)
|
||||
return {
|
||||
"session_id": session_id,
|
||||
|
|
|
|||
|
|
@ -13,37 +13,41 @@ class AdaptiveRAGRequirementAckCoreMixin:
|
|||
return ACTION_MODE_AUTO
|
||||
|
||||
@classmethod
|
||||
def _looks_like_write_intent(cls, question: str) -> bool:
|
||||
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(
|
||||
async def _resolve_action_mode_async(
|
||||
cls,
|
||||
*,
|
||||
requested_mode: str,
|
||||
question: str,
|
||||
effective_question: str,
|
||||
asked_via: str,
|
||||
) -> str:
|
||||
mode = cls._normalize_action_mode(requested_mode)
|
||||
if mode != ACTION_MODE_AUTO:
|
||||
return mode
|
||||
|
||||
merged = f"{question} {effective_question}".strip()
|
||||
if not cls._looks_like_write_intent(merged):
|
||||
if not merged:
|
||||
return ACTION_MODE_READ
|
||||
has_question_form = ("?" in merged) or ("?" in merged) or cls._contains_any(merged, QUESTION_HINTS)
|
||||
if has_question_form:
|
||||
try:
|
||||
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_WRITE
|
||||
|
||||
|
|
@ -194,4 +198,3 @@ class AdaptiveRAGRequirementAckCoreMixin:
|
|||
reason = error_reason or "写入异常"
|
||||
return f"未完成入库:{reason}。我已记录本次失败,请稍后重试。"
|
||||
return "已收到需求,正在处理中。"
|
||||
|
||||
|
|
|
|||
|
|
@ -2,7 +2,6 @@
|
|||
from __future__ import annotations
|
||||
|
||||
from .shared import *
|
||||
from ..ai_pmo.workflow_semantic_planner import WorkflowSemanticPlanner
|
||||
|
||||
|
||||
class AdaptiveRAGRequirementAckFlowMixin:
|
||||
|
|
@ -45,7 +44,6 @@ class AdaptiveRAGRequirementAckFlowMixin:
|
|||
linked_signal_id is not None
|
||||
and resolved_project_id is not None
|
||||
and action_taken in {"signal_created", "signal_deduped"}
|
||||
and WorkflowSemanticPlanner.looks_like_signal_source_request(effective_question or question)
|
||||
):
|
||||
try:
|
||||
source_orchestration_result = await SignalSourceOrchestrationService(self.db).orchestrate_from_signal(
|
||||
|
|
|
|||
|
|
@ -68,41 +68,6 @@ ACTION_MODE_AUTO = "auto"
|
|||
ACTION_MODE_READ = "read"
|
||||
ACTION_MODE_WRITE = "write"
|
||||
ACTION_MODE_READ_WRITE = "read_write"
|
||||
REQUIREMENT_WRITE_VERB_KEYWORDS = (
|
||||
"新增",
|
||||
"增加",
|
||||
"添加",
|
||||
"接入",
|
||||
"支持",
|
||||
"实现",
|
||||
"创建",
|
||||
"新建",
|
||||
"记录",
|
||||
"入库",
|
||||
"同步",
|
||||
"补充",
|
||||
)
|
||||
REQUIREMENT_WRITE_OBJECT_KEYWORDS = (
|
||||
"信号",
|
||||
"需求",
|
||||
"任务",
|
||||
"项目",
|
||||
"功能",
|
||||
"接口",
|
||||
"页面",
|
||||
"按钮",
|
||||
"规则",
|
||||
"模块",
|
||||
"卡路里",
|
||||
"食谱",
|
||||
)
|
||||
WRITE_EXPRESSIONS = (
|
||||
"给",
|
||||
"帮我",
|
||||
"请帮",
|
||||
"请给",
|
||||
"麻烦",
|
||||
)
|
||||
COUNT_HINTS = ("多少", "几个", "数量", "总数", "count")
|
||||
LIST_HINTS = ("哪些", "列表", "清单", "列出", "列一下", "分别", "名单", "都有哪些")
|
||||
STATUS_HINTS = ("进度", "状态", "阶段", "完成率", "完成度", "情况")
|
||||
|
|
|
|||
|
|
@ -44,23 +44,7 @@ __all__ = [
|
|||
"WORKFLOW_ROUTE_KEYS",
|
||||
"ROUTE_CANONICAL_INTENT_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",
|
||||
"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_CANDIDATES",
|
||||
"WRITE_INTENTS",
|
||||
|
|
|
|||
|
|
@ -6,53 +6,9 @@ from ..workflow_semantic_planner import WorkflowSemanticPlanner
|
|||
class IntentResolver:
|
||||
"""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
|
||||
def resolve(*, text: str, project_id: Optional[int]) -> CanonicalIntent:
|
||||
pretty = re.sub(r"\s+", " ", str(text or "")).strip()
|
||||
normalized = re.sub(r"\s+", "", str(text or "")).lower()
|
||||
if not pretty:
|
||||
return CanonicalIntent(INTENT_IGNORE, 1.0, project_id, "", "", {"reason": "empty"})
|
||||
bundle = WorkflowSemanticPlanner.plan_message(
|
||||
|
|
@ -61,17 +17,9 @@ class IntentResolver:
|
|||
metadata={"project_id": project_id},
|
||||
)
|
||||
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"})
|
||||
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)
|
||||
if explicit_followup_target is not None:
|
||||
return CanonicalIntent(
|
||||
|
|
@ -86,10 +34,6 @@ class IntentResolver:
|
|||
"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":
|
||||
followup_action = bundle.workflows[0].workflow_action
|
||||
followup_intent = INTENT_FOLLOWUP_CONFIRM if followup_action == "confirm" else INTENT_FOLLOWUP_CONTINUE
|
||||
|
|
@ -104,7 +48,7 @@ class IntentResolver:
|
|||
"semantic_bundle": bundle.to_dict(),
|
||||
},
|
||||
)
|
||||
if WorkflowSemanticPlanner.looks_like_signal_source_request(pretty):
|
||||
if "signal_source_flow" in workflow_types:
|
||||
return CanonicalIntent(
|
||||
INTENT_SIGNAL_SOURCE_ONBOARD,
|
||||
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(
|
||||
INTENT_QUERY,
|
||||
0.88,
|
||||
|
|
@ -169,22 +113,6 @@ class IntentResolver:
|
|||
{"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
|
||||
def _extract_task_title(text: str) -> str:
|
||||
cleaned = re.sub(r"\s+", " ", str(text or "")).strip()
|
||||
|
|
@ -215,27 +143,6 @@ class IntentResolver:
|
|||
return ""
|
||||
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
|
||||
def _build_signal_title(text: str, *, default: str) -> str:
|
||||
raw = str(text or "").strip()
|
||||
|
|
|
|||
|
|
@ -1,7 +1,6 @@
|
|||
from __future__ import annotations
|
||||
|
||||
from .models import *
|
||||
from ..workflow_semantic_planner import WorkflowSemanticPlanner
|
||||
|
||||
class RouteResolver:
|
||||
"""Map canonical intent + text into workflow route."""
|
||||
|
|
@ -58,7 +57,7 @@ class RouteResolver:
|
|||
return 1
|
||||
|
||||
@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 ""
|
||||
if explicit_route == ROUTE_PROJECT_SIGNAL_DISCOVER:
|
||||
return True
|
||||
|
|
@ -66,17 +65,20 @@ class RouteResolver:
|
|||
return explicit_action == ROUTE_ACTION_DISCOVER
|
||||
|
||||
@staticmethod
|
||||
def parse_followup_action(
|
||||
def resolve_followup_route_action(
|
||||
*,
|
||||
metadata: Optional[dict[str, Any]] = None,
|
||||
canonical_intent: str,
|
||||
message_text: str = "",
|
||||
) -> str:
|
||||
return WorkflowSemanticPlanner.parse_followup_action(
|
||||
metadata=metadata,
|
||||
canonical_intent=canonical_intent,
|
||||
message_text=message_text,
|
||||
)
|
||||
_ = message_text
|
||||
payload = metadata if isinstance(metadata, dict) else {}
|
||||
explicit_action = str(payload.get("followup_action") or payload.get("action") or "").strip().lower()
|
||||
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
|
||||
def from_canonical(
|
||||
|
|
@ -91,7 +93,7 @@ class RouteResolver:
|
|||
if not route_key:
|
||||
route_key = LEGACY_INTENT_ROUTE_MAP.get(canonical_intent.name, ROUTE_QUERY_ANSWER)
|
||||
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
|
||||
elif route_key not in {ROUTE_PROJECT_SIGNAL_CREATE, ROUTE_PROJECT_SIGNAL_DISCOVER}:
|
||||
route_key = ROUTE_PROJECT_SIGNAL_CREATE
|
||||
|
|
@ -99,7 +101,7 @@ class RouteResolver:
|
|||
if route_key != ROUTE_TASK_CREATE:
|
||||
route_key = ROUTE_TASK_CREATE
|
||||
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},
|
||||
canonical_intent=canonical_intent.name,
|
||||
message_text=message_text,
|
||||
|
|
|
|||
|
|
@ -8,22 +8,10 @@ from typing import Any, Optional
|
|||
|
||||
from ....utils.project_command_utils import extract_project_create_title
|
||||
from ...signal.signal_source_orchestration import SignalSourceOrchestrationService
|
||||
from ..workflow_semantic_planner.constants import (
|
||||
FOLLOWUP_CANCEL_HINTS,
|
||||
FOLLOWUP_COMMIT_HINTS,
|
||||
FOLLOWUP_CONFIRM_HINTS,
|
||||
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,
|
||||
|
||||
FOLLOWUP_WORK_ITEM_ID_RE = re.compile(
|
||||
r"(?:work[_ -]?item|工作项|队列项|草稿)\s*(?:id|ID)?\s*[:#:]?\s*(\d+)",
|
||||
re.IGNORECASE,
|
||||
)
|
||||
|
||||
INTENT_QUERY = "query.read_only"
|
||||
|
|
@ -232,8 +220,6 @@ LEGACY_INTENT_ROUTE_MAP: dict[str, str] = {
|
|||
"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_CANDIDATES = (
|
||||
INTENT_SIGNAL_SOURCE_ONBOARD,
|
||||
|
|
|
|||
|
|
@ -7,13 +7,6 @@ import re
|
|||
from dataclasses import dataclass
|
||||
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_COMPLEX = "classify_intent_complex"
|
||||
|
||||
|
|
@ -33,10 +26,6 @@ def _normalize_text(text: str) -> str:
|
|||
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)
|
||||
class IntentRouteDecision:
|
||||
function_name: str
|
||||
|
|
@ -59,53 +48,10 @@ class CorrectionDecision:
|
|||
confidence: float
|
||||
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]]:
|
||||
normalized = _normalize_text(text)
|
||||
if not normalized:
|
||||
_ = (text, project_names)
|
||||
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:
|
||||
is_complex, reasons = is_complex_instruction(text, project_names=project_names)
|
||||
|
|
@ -125,19 +71,7 @@ def select_intent_route(text: str, *, project_names: Sequence[str] | None = None
|
|||
|
||||
|
||||
def resolve_correction(text: str) -> CorrectionDecision:
|
||||
normalized = _normalize_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,
|
||||
)
|
||||
_ = text
|
||||
return CorrectionDecision(action="none", confidence=0.0, matched_keywords=[])
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -97,10 +97,6 @@ from ..ai_pmo_intent import (
|
|||
IntentResolver,
|
||||
RouteResolver,
|
||||
)
|
||||
from ..workflow_semantic_planner.constants import (
|
||||
GENERIC_SIGNAL_SOURCE_MESSAGE_RE,
|
||||
MODULE_KEYWORD_RULES,
|
||||
)
|
||||
from ...ai_steward_service import AIStewardService
|
||||
from ...llm_service import llm_service
|
||||
from ...model.model_context_bundle import ModelContextBundleService
|
||||
|
|
|
|||
|
|
@ -68,7 +68,7 @@ class TypedExecutorParseMixin:
|
|||
return "".join(str(value or "").split()).strip().lower()
|
||||
|
||||
@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_source = cls._compact_text(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)
|
||||
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 = ""
|
||||
if cls._looks_like_raw_instruction(candidate=summary, source=cleaned_message):
|
||||
if cls._candidate_echoes_source_instruction(candidate=summary, source=cleaned_message):
|
||||
summary = ""
|
||||
|
||||
if not summary:
|
||||
|
|
|
|||
|
|
@ -61,18 +61,15 @@ class KernelClosureDataMixin:
|
|||
if not question or not answer:
|
||||
return -1.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 ("暂时无法生成可靠回答", "模型服务异常", "请稍后重试")):
|
||||
score -= 5.0
|
||||
if normalized_message and cls._is_generic_signal_source_message(normalized_message):
|
||||
if "信号源" in question:
|
||||
if normalized_message:
|
||||
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
|
||||
if "项目" in answer:
|
||||
score += 1.0
|
||||
if answer_norm and (normalized_message in answer_norm or answer_norm in normalized_message):
|
||||
score += 0.8
|
||||
created_at = cls._parse_datetime_like(getattr(row, "created_at", None))
|
||||
if reference_at is not None and created_at is not None:
|
||||
gap_seconds = abs((reference_at - created_at).total_seconds())
|
||||
|
|
|
|||
|
|
@ -13,21 +13,13 @@ for _name, _value in vars(_common).items():
|
|||
class KernelClosurePayloadMixin:
|
||||
@staticmethod
|
||||
def _is_generic_signal_source_message(text: str) -> bool:
|
||||
compact = "".join(str(text or "").split()).strip().lower()
|
||||
if not compact:
|
||||
_ = text
|
||||
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
|
||||
def _extract_implementation_modules_from_text(cls, text: str) -> list[str]:
|
||||
normalized = str(text or "").lower()
|
||||
modules: list[str] = []
|
||||
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]
|
||||
_ = (cls, text)
|
||||
return []
|
||||
|
||||
@staticmethod
|
||||
def _build_closure_dialogue_excerpt(turns: list[dict[str, str]]) -> str:
|
||||
|
|
@ -72,11 +64,6 @@ class KernelClosurePayloadMixin:
|
|||
return f"机器人先提示条件不足,用户随后继续要求执行:{latest_user[:120]}"
|
||||
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:
|
||||
return f"用户在本次消息闭环中推进信号源接入,涉及模块:{'、'.join(modules[:4])}。"
|
||||
|
||||
|
|
@ -136,7 +123,6 @@ class KernelClosurePayloadMixin:
|
|||
normalized_source_system == "feishu"
|
||||
and (
|
||||
(not self._turns_have_assistant(turns))
|
||||
or (self._is_generic_signal_source_message(normalized_message) and len(turns) <= 2)
|
||||
)
|
||||
)
|
||||
if needs_semantic_turns:
|
||||
|
|
|
|||
|
|
@ -121,7 +121,7 @@ class KernelFollowupRoutingMixin:
|
|||
workflow_route: WorkflowRoute,
|
||||
) -> tuple[dict[str, Any], dict[str, Any]]:
|
||||
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,
|
||||
canonical_intent=intent.name,
|
||||
)
|
||||
|
|
|
|||
|
|
@ -70,7 +70,7 @@ class KernelIngressRenderMixin:
|
|||
direct_reply_text = f"{mixed_sidecar_reply}\n\n{direct_reply_text}"
|
||||
else:
|
||||
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(
|
||||
"ai_pmo_upstream_timeout_reply_detected",
|
||||
tenant_id=int(self.tenant_id),
|
||||
|
|
@ -288,8 +288,6 @@ class KernelIngressRenderMixin:
|
|||
has_webhook = "webhook" in normalized
|
||||
if not (has_long_connection and has_webhook):
|
||||
return None
|
||||
if not any(token in normalized for token in TRANSPORT_MODE_CORRECTION_HINTS):
|
||||
return None
|
||||
return "已按长连接模式处理飞书接入,当前不会走 webhook;这条说明不会再被当成信号源类型变更。"
|
||||
|
||||
@staticmethod
|
||||
|
|
|
|||
|
|
@ -21,15 +21,17 @@ class KernelIntentLLMMixin:
|
|||
rule_intent: Optional[CanonicalIntent] = None,
|
||||
session_last_intent_override: Optional[str] = None,
|
||||
session_project_id_override: Optional[int] = None,
|
||||
inherit_session_context: bool = True,
|
||||
) -> Optional[CanonicalIntent]:
|
||||
session_last_intent = (
|
||||
str(session_last_intent_override or "").strip()
|
||||
if session_last_intent_override is not None
|
||||
else str(getattr(session, "last_intent", "") or "").strip() if session is not None else ""
|
||||
)
|
||||
if session_last_intent_override is not None:
|
||||
session_last_intent = str(session_last_intent_override or "").strip()
|
||||
elif inherit_session_context and session is not None:
|
||||
session_last_intent = str(getattr(session, "last_intent", "") or "").strip()
|
||||
else:
|
||||
session_last_intent = ""
|
||||
if session_project_id_override is not None:
|
||||
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))
|
||||
else:
|
||||
session_project_id = None
|
||||
|
|
|
|||
|
|
@ -11,26 +11,6 @@ for _name, _value in vars(_common).items():
|
|||
|
||||
|
||||
class KernelIntentResolveMixin:
|
||||
_QUERY_FOLLOWUP_TOKENS = (
|
||||
"需求",
|
||||
"mvp",
|
||||
"方案",
|
||||
"对应",
|
||||
"这个",
|
||||
"它",
|
||||
"给我",
|
||||
"补充",
|
||||
"展开",
|
||||
"细化",
|
||||
"重答",
|
||||
"重新回答",
|
||||
"高级模型",
|
||||
"更强模型",
|
||||
"分析",
|
||||
"评审",
|
||||
"怎么做",
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def _within_recent_window(last_active_at: Optional[datetime], *, window_seconds: int) -> bool:
|
||||
if not isinstance(last_active_at, datetime):
|
||||
|
|
@ -38,12 +18,10 @@ class KernelIntentResolveMixin:
|
|||
delta = _utc_now_naive() - last_active_at
|
||||
return 0 <= delta.total_seconds() <= float(window_seconds)
|
||||
|
||||
@classmethod
|
||||
def _looks_like_query_scene_followup(cls, message_text: str) -> bool:
|
||||
normalized = re.sub(r"\s+", "", str(message_text or "")).lower()
|
||||
if not normalized:
|
||||
return False
|
||||
return any(token in normalized for token in cls._QUERY_FOLLOWUP_TOKENS)
|
||||
@staticmethod
|
||||
def _is_query_context_intent(intent_name: str) -> bool:
|
||||
normalized = str(intent_name or "").strip().lower()
|
||||
return normalized in {INTENT_QUERY, ROUTE_QUERY_ANSWER}
|
||||
|
||||
@classmethod
|
||||
def _should_inherit_recent_feishu_session_context(
|
||||
|
|
@ -52,21 +30,12 @@ class KernelIntentResolveMixin:
|
|||
message_text: str,
|
||||
session: Optional[ConversationSession],
|
||||
) -> bool:
|
||||
if session is None:
|
||||
_ = (cls, message_text, session)
|
||||
# OpenCow-style object binding does not allow bare recent-session query
|
||||
# inheritance without an explicit bound object. Query context may still
|
||||
# flow through reply anchors, but no longer through no-anchor token
|
||||
# heuristics on the latest message text.
|
||||
return False
|
||||
if WorkflowSemanticPlanner.looks_like_explicit_new_requirement_topic(message_text):
|
||||
return False
|
||||
sender = str(getattr(session, "sender", "") or "").strip().lower()
|
||||
if not sender.startswith("feishu:"):
|
||||
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(
|
||||
self,
|
||||
|
|
@ -86,28 +55,6 @@ class KernelIntentResolveMixin:
|
|||
message_text=message_text,
|
||||
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():
|
||||
|
|
@ -124,6 +71,7 @@ class KernelIntentResolveMixin:
|
|||
session_project_id_override=(
|
||||
_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:
|
||||
raise RuntimeError("intent_model_only_no_valid_llm_intent")
|
||||
|
|
|
|||
|
|
@ -68,10 +68,6 @@ class KernelIntentRulesMixin:
|
|||
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(
|
||||
self,
|
||||
*,
|
||||
|
|
@ -80,47 +76,13 @@ class KernelIntentRulesMixin:
|
|||
world_model: dict[str, Any],
|
||||
rule_intent: CanonicalIntent,
|
||||
) -> Optional[CanonicalIntent]:
|
||||
if session is None:
|
||||
_ = (message_text, session, world_model, rule_intent)
|
||||
# OpenCow-style routing requires object binding before any write action.
|
||||
# This compat recovery path used to elevate low-context session followups
|
||||
# into write intents without a bound object, which is exactly the class of
|
||||
# bug we are removing. Keep the method as an inert compat shim so callers
|
||||
# don't break, but never recover a write intent from session state alone.
|
||||
return None
|
||||
if rule_intent.name != INTENT_QUERY:
|
||||
return None
|
||||
if not self._within_recent_window(
|
||||
getattr(session, "last_active_at", None),
|
||||
window_seconds=SESSION_SHORT_CONFIRM_WINDOW_SECONDS,
|
||||
):
|
||||
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
|
||||
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),
|
||||
), 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
|
||||
def _coerce_naive_datetime(value: Any) -> Optional[datetime]:
|
||||
if isinstance(value, datetime):
|
||||
|
|
@ -343,39 +271,3 @@ class KernelIntentRulesMixin:
|
|||
extras,
|
||||
)
|
||||
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(),
|
||||
},
|
||||
)
|
||||
|
|
|
|||
|
|
@ -3,6 +3,7 @@
|
|||
from __future__ import annotations
|
||||
|
||||
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
|
||||
|
||||
for _name, _value in vars(_common).items():
|
||||
|
|
@ -19,18 +20,14 @@ class KernelWorldModelSessionMixin:
|
|||
delta = now - last_active_at
|
||||
return 0 <= delta.total_seconds() <= float(window_seconds)
|
||||
|
||||
def _extract_inherited_project_id(self, *, session: Optional[ConversationSession]) -> Optional[int]:
|
||||
if session is None:
|
||||
def _extract_inherited_project_id(
|
||||
self,
|
||||
*,
|
||||
session: Optional[ConversationSession],
|
||||
message_text: str = "",
|
||||
) -> Optional[int]:
|
||||
_ = (session, message_text)
|
||||
return None
|
||||
session_project_id = _safe_int(getattr(session, "current_project_id", None))
|
||||
if session_project_id is None or int(session_project_id) <= 0:
|
||||
return None
|
||||
if not self._within_recent_window(
|
||||
getattr(session, "last_active_at", None),
|
||||
window_seconds=SESSION_PROJECT_INHERIT_WINDOW_SECONDS,
|
||||
):
|
||||
return None
|
||||
return session_project_id
|
||||
|
||||
async def _get_or_create_session(
|
||||
self,
|
||||
|
|
|
|||
|
|
@ -3,12 +3,6 @@
|
|||
from __future__ import annotations
|
||||
|
||||
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_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_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, ...]], ...] = ()
|
||||
|
|
|
|||
|
|
@ -2,7 +2,6 @@
|
|||
|
||||
from __future__ import annotations
|
||||
|
||||
from .constants import TRANSPORT_MODE_CORRECTION_HINTS
|
||||
from .env import (
|
||||
env_bool,
|
||||
env_float,
|
||||
|
|
@ -42,7 +41,7 @@ def _intent_llm_stage2_min_remaining_seconds() -> float:
|
|||
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()
|
||||
if not lowered:
|
||||
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:
|
||||
if str(correction_action or "").strip().lower() != "none":
|
||||
return True
|
||||
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)
|
||||
_ = message_text
|
||||
return str(correction_action or "").strip().lower() != "none"
|
||||
|
||||
|
||||
def _should_escalate_to_high_intent_route(
|
||||
|
|
|
|||
|
|
@ -172,9 +172,6 @@ from ..ai_pmo_intent import (
|
|||
ROUTE_SIGNAL_SOURCE_ONBOARD,
|
||||
ROUTE_TASK_CREATE,
|
||||
WORKFLOW_ROUTE_KEYS,
|
||||
SESSION_SHORT_CONFIRM_WINDOW_SECONDS,
|
||||
SHORT_CONFIRM_HINTS,
|
||||
SHORT_CONFIRM_MAX_MESSAGE_LENGTH,
|
||||
WRITE_INTENTS,
|
||||
CanonicalIntent,
|
||||
IntentResolver,
|
||||
|
|
|
|||
|
|
@ -16,9 +16,6 @@ from app.services.ai_pmo.workflow_semantic_planner.constants import (
|
|||
from app.services.mounted_ai_runtime_write_service import MountedAIRuntimeWriteService
|
||||
|
||||
class KernelIngressFlowEntryMixin:
|
||||
_MIXED_HINT_CONNECTORS = ("顺便", "另外", "同时", "并且", "还有", "再问")
|
||||
_MIXED_HINT_QUESTION = ("?", "?", "问下", "多少", "几个", "进展", "状态", "吗", "呢", "如何", "怎么", "有没有")
|
||||
_MIXED_HINT_REQUIREMENT = ("创建", "新建", "新增", "安排", "执行", "处理", "接入", "录入", "添加")
|
||||
_RAG_QUERY_SCENE_KEYS = (
|
||||
"message_intent",
|
||||
"review_framework",
|
||||
|
|
@ -32,48 +29,6 @@ class KernelIngressFlowEntryMixin:
|
|||
QUERY_SCENE_CORRECTION_ESCALATION,
|
||||
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
|
||||
def _extract_route_scene_metadata(cls, route_metadata: Optional[dict[str, Any]]) -> dict[str, str]:
|
||||
metadata = route_metadata if isinstance(route_metadata, dict) else {}
|
||||
|
|
@ -86,34 +41,7 @@ class KernelIngressFlowEntryMixin:
|
|||
|
||||
@classmethod
|
||||
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 {}
|
||||
|
||||
@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
|
||||
def _supports_mixed_sidecar(*, source_system: str) -> bool:
|
||||
|
|
@ -129,12 +57,8 @@ class KernelIngressFlowEntryMixin:
|
|||
semantic_bundle: Optional[dict[str, Any]],
|
||||
) -> dict[str, Any]:
|
||||
context = rag_request_context if isinstance(rag_request_context, dict) else {}
|
||||
derived = cls._derive_query_scene_metadata(message_text)
|
||||
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, "")
|
||||
}
|
||||
_ = message_text
|
||||
scene_metadata: dict[str, Any] = {}
|
||||
for key in cls._RAG_QUERY_SCENE_KEYS:
|
||||
value = context.get(key)
|
||||
if value in (None, ""):
|
||||
|
|
@ -276,14 +200,15 @@ class KernelIngressFlowEntryMixin:
|
|||
message_text: str,
|
||||
project_hint_id: Optional[int],
|
||||
) -> tuple[str, dict[str, Any]]:
|
||||
normalized_text = str(message_text or "").strip()
|
||||
if not self._supports_mixed_sidecar(source_system=source_system):
|
||||
return str(message_text or "").strip(), {}
|
||||
if not self._looks_like_mixed_candidate(text=message_text):
|
||||
return str(message_text or "").strip(), {}
|
||||
return normalized_text, {}
|
||||
if not normalized_text:
|
||||
return normalized_text, {}
|
||||
|
||||
rag_service = AdaptiveRAGService(self.db, tenant_id=self.tenant_id)
|
||||
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:
|
||||
logger.warning(
|
||||
"ai_pmo_mixed_precheck_failed",
|
||||
|
|
@ -291,14 +216,14 @@ class KernelIngressFlowEntryMixin:
|
|||
source_system=source_system,
|
||||
error=str(exc),
|
||||
)
|
||||
return str(message_text or "").strip(), {}
|
||||
return normalized_text, {}
|
||||
|
||||
intent_label = str(intent_result.get("intent") or "").strip().lower()
|
||||
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()
|
||||
signal_part = str(intent_result.get("statement_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 normalized_text
|
||||
sidecar_answer = ""
|
||||
try:
|
||||
conversation_key = source_message_id
|
||||
|
|
@ -645,6 +570,19 @@ class KernelIngressFlowEntryMixin:
|
|||
ingest_event=ingest_event,
|
||||
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(
|
||||
session=session,
|
||||
sender=sender,
|
||||
|
|
@ -727,17 +665,26 @@ class KernelIngressFlowEntryMixin:
|
|||
source_sender_open_id=source_sender_open_id,
|
||||
ingress_options=ingress_options,
|
||||
message_text=clean_text,
|
||||
session=session,
|
||||
sender=sender,
|
||||
)
|
||||
if orphan_requirement_followup_result is not None:
|
||||
await _safe_finalize_mounted_runtime(result=orphan_requirement_followup_result)
|
||||
return orphan_requirement_followup_result
|
||||
mixed_meta: dict[str, Any] = {}
|
||||
if not self._is_short_confirmation_message(
|
||||
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,
|
||||
session=session,
|
||||
message_sent_at=message_sent_at,
|
||||
session_snapshot=session_snapshot,
|
||||
):
|
||||
thread_context=thread_context,
|
||||
sender=sender,
|
||||
force_bind=False,
|
||||
)
|
||||
if active_context_followup_result is not None:
|
||||
await _safe_finalize_mounted_runtime(result=active_context_followup_result)
|
||||
return active_context_followup_result
|
||||
mixed_meta: dict[str, Any] = {}
|
||||
clean_text, mixed_meta = await self._prepare_mixed_sidecar_context(
|
||||
source_system=source_system,
|
||||
source_message_id=synthetic_message_id,
|
||||
|
|
@ -1307,6 +1254,37 @@ class KernelIngressFlowEntryMixin:
|
|||
thread_context = cls._extract_thread_context_from_ingress_options(ingress_options)
|
||||
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
|
||||
def _should_route_to_draft_confirmation(
|
||||
cls,
|
||||
|
|
@ -1321,31 +1299,19 @@ class KernelIngressFlowEntryMixin:
|
|||
if DraftConfirmHandler._extract_work_item_id(message_text) is not None:
|
||||
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 (
|
||||
WorkflowSemanticPlanner.looks_like_signal_source_request(message_text)
|
||||
or WorkflowSemanticPlanner.looks_like_signal_create_request(message_text)
|
||||
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)
|
||||
str(getattr(session, "last_intent", "") or "").strip() != STATE_DRAFT_PENDING_CONFIRM
|
||||
and len(str(message_text or "").strip()) <= 16
|
||||
):
|
||||
return False
|
||||
|
||||
return (
|
||||
str(getattr(session, "last_intent", "") or "").strip() == STATE_DRAFT_PENDING_CONFIRM
|
||||
and WorkflowSemanticPlanner.looks_like_natural_draft_revision(message_text)
|
||||
)
|
||||
thread_context = cls._extract_thread_context_from_ingress_options(ingress_options)
|
||||
if str(thread_context.get("reply_to_message_id") or "").strip():
|
||||
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(
|
||||
self,
|
||||
|
|
@ -1358,34 +1324,290 @@ class KernelIngressFlowEntryMixin:
|
|||
message_text: str,
|
||||
ingress_options: Optional[dict[str, Any]],
|
||||
) -> bool:
|
||||
if WorkflowSemanticPlanner.looks_like_explicit_new_requirement_topic(message_text):
|
||||
return False
|
||||
from ..draft_confirm_handler import DraftConfirmHandler
|
||||
|
||||
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(
|
||||
session=session,
|
||||
message_text=message_text,
|
||||
ingress_options=ingress_options,
|
||||
):
|
||||
return True
|
||||
|
||||
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):
|
||||
if not normalized_text:
|
||||
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)
|
||||
return await handler.has_active_draft_session(
|
||||
has_active_draft_session = 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=self._extract_thread_context_from_ingress_options(ingress_options),
|
||||
thread_context=thread_context,
|
||||
)
|
||||
return bool(has_active_draft_session)
|
||||
|
||||
@staticmethod
|
||||
def _build_orphan_requirement_followup_reply() -> str:
|
||||
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(
|
||||
self,
|
||||
*,
|
||||
|
|
@ -1399,24 +1621,17 @@ class KernelIngressFlowEntryMixin:
|
|||
) -> bool:
|
||||
if session is None:
|
||||
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:
|
||||
return False
|
||||
await self._update_session(
|
||||
tenant_id=self.tenant_id,
|
||||
sender=sender,
|
||||
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,
|
||||
_ = (
|
||||
sender,
|
||||
channel_ref,
|
||||
source_sender_open_id,
|
||||
source_message_id,
|
||||
thread_context,
|
||||
message_text,
|
||||
)
|
||||
return True
|
||||
return False
|
||||
|
||||
async def _maybe_handle_orphan_requirement_followup(
|
||||
self,
|
||||
|
|
@ -1427,8 +1642,10 @@ class KernelIngressFlowEntryMixin:
|
|||
source_sender_open_id: Optional[str],
|
||||
ingress_options: Optional[dict[str, Any]],
|
||||
message_text: str,
|
||||
session: Optional[ConversationSession] = None,
|
||||
sender: Optional[str] = None,
|
||||
) -> 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
|
||||
checker = getattr(self, "_has_active_requirement_plan_workspace", None)
|
||||
if not callable(checker):
|
||||
|
|
@ -1456,6 +1673,20 @@ class KernelIngressFlowEntryMixin:
|
|||
)
|
||||
if has_active_requirement_plan_workspace:
|
||||
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 {
|
||||
"handled": True,
|
||||
"status": "answered",
|
||||
|
|
|
|||
|
|
@ -3,7 +3,6 @@
|
|||
from __future__ import annotations
|
||||
|
||||
from .shared import * # noqa: F401, F403
|
||||
from ..workflow_semantic_planner import WorkflowSemanticPlanner
|
||||
|
||||
|
||||
class KernelWorldModelCoreProjectMixin:
|
||||
|
|
@ -39,8 +38,6 @@ class KernelWorldModelCoreProjectMixin:
|
|||
cleaned = re.sub(r"\s+", " ", str(message_text or "")).strip()
|
||||
if not cleaned:
|
||||
return False
|
||||
if not WorkflowSemanticPlanner.looks_like_project_create_request(cleaned):
|
||||
return False
|
||||
return bool(cls._extract_explicit_project_title_anchor(message_text=cleaned))
|
||||
|
||||
async def _resolve_project(
|
||||
|
|
@ -97,7 +94,10 @@ class KernelWorldModelCoreProjectMixin:
|
|||
normalized_candidate = re.sub(r"\s+", "", str(candidate or "")).lower()
|
||||
if normalized_candidate and normalized_candidate in normalized_message:
|
||||
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:
|
||||
self._record_world_model_query_table(query_tables, "projects")
|
||||
return (
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load Diff
|
|
@ -42,19 +42,6 @@ CONSULTANT_CHANNEL_STRATEGIES: dict[str, dict[str, Any]] = {
|
|||
}
|
||||
|
||||
_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 = {
|
||||
"project_id": "归属项目",
|
||||
"task_title": "任务标题",
|
||||
|
|
@ -63,15 +50,6 @@ _MISSING_FIELD_LABELS = {
|
|||
"project_scope": "项目范围",
|
||||
}
|
||||
|
||||
_LONG_REPLY_SECTION_HINTS = (
|
||||
"结论:",
|
||||
"依据:",
|
||||
"不确定项:",
|
||||
"事实:",
|
||||
"建议:",
|
||||
"待确认项:",
|
||||
)
|
||||
|
||||
_DOMAIN_NEXT_ACTION_FALLBACKS: dict[str, dict[str, str]] = {
|
||||
DOMAIN_RAG: {
|
||||
MODE_ADVISE: "继续追问具体模块、入口函数或文件路径,我会把调用链收窄到那一段。",
|
||||
|
|
@ -121,14 +99,12 @@ def _normalize_action_text(text: str) -> str:
|
|||
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 "")
|
||||
normalized = _normalize_action_text(raw)
|
||||
if not normalized:
|
||||
return False
|
||||
if "\n" in raw or len(normalized) > 120:
|
||||
return True
|
||||
return any(token in normalized for token in _LONG_REPLY_SECTION_HINTS)
|
||||
return "\n" in raw or len(normalized) > 120
|
||||
|
||||
|
||||
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"}
|
||||
|
||||
|
||||
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(
|
||||
*,
|
||||
intent_name: str,
|
||||
|
|
@ -204,20 +168,17 @@ def _derive_mode(
|
|||
allow_execute: bool,
|
||||
primary_operation_code: Optional[str],
|
||||
) -> str:
|
||||
_ = message_text
|
||||
if missing_fields:
|
||||
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(
|
||||
primary_operation_code=primary_operation_code,
|
||||
intent_name=intent_name,
|
||||
):
|
||||
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):
|
||||
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
|
||||
if str(status or "").strip().lower() == "pending_manual":
|
||||
return MODE_CHALLENGE
|
||||
|
|
@ -225,7 +186,7 @@ def _derive_mode(
|
|||
|
||||
|
||||
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]
|
||||
labels = [_MISSING_FIELD_LABELS.get(item, item) for item in missing_fields]
|
||||
suffix = "、".join([label for label in labels if label]) or "关键信息"
|
||||
|
|
@ -246,7 +207,7 @@ def _build_next_action(
|
|||
direct_reply_text=direct_reply_text,
|
||||
)
|
||||
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]
|
||||
if str(risk_level or "").strip().lower() == "high":
|
||||
return "建议先拆分范围,再确认是否继续推进高风险动作。"[:120]
|
||||
|
|
@ -261,7 +222,7 @@ def _build_next_action(
|
|||
if domain == DOMAIN_SIGNAL:
|
||||
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 _domain_next_action(domain=domain, mode=mode)
|
||||
|
||||
|
|
|
|||
|
|
@ -2,7 +2,7 @@
|
|||
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime
|
||||
from datetime import datetime, timedelta
|
||||
from typing import Any, Optional
|
||||
|
||||
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 ..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 ...models import MountedDraftWorkspace, MountedTopicWorkspace
|
||||
from ...models.feishu_reply_outbox import FeishuReplyOutbox
|
||||
|
|
@ -25,7 +28,14 @@ class DraftConfirmHandler:
|
|||
_ACTION_CONFIRM = "confirm"
|
||||
_ACTION_REVISE = "revise"
|
||||
_ACTION_CANCEL = "cancel"
|
||||
_ACTION_ANSWER_ONLY = "answer_only"
|
||||
_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)
|
||||
|
||||
|
|
@ -35,11 +45,6 @@ class DraftConfirmHandler:
|
|||
self.tenant_id = int(kernel.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(
|
||||
self,
|
||||
*,
|
||||
|
|
@ -51,11 +56,12 @@ class DraftConfirmHandler:
|
|||
source_sender_open_id: Optional[str],
|
||||
expected_version: Optional[int] = None,
|
||||
thread_context: Optional[dict[str, Any]] = None,
|
||||
semantic_followup_override: Optional[dict[str, str]] = None,
|
||||
) -> 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)
|
||||
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(
|
||||
sender=sender,
|
||||
source_system=source_system,
|
||||
|
|
@ -65,7 +71,10 @@ class DraftConfirmHandler:
|
|||
thread_context=thread_context,
|
||||
)
|
||||
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 {
|
||||
"handled": True,
|
||||
"status": "failed_safe",
|
||||
|
|
@ -90,6 +99,46 @@ class DraftConfirmHandler:
|
|||
}
|
||||
|
||||
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:
|
||||
return await self.confirm_draft(
|
||||
target_work_item=target_work_item,
|
||||
|
|
@ -112,6 +161,18 @@ class DraftConfirmHandler:
|
|||
target_work_item=target_work_item,
|
||||
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 {
|
||||
"handled": True,
|
||||
"status": "pending_manual",
|
||||
|
|
@ -124,6 +185,131 @@ class DraftConfirmHandler:
|
|||
"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(
|
||||
self,
|
||||
*,
|
||||
|
|
@ -523,11 +709,18 @@ class DraftConfirmHandler:
|
|||
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 "")))
|
||||
if self._is_requirement_plan_draft_payload(locked.draft_content):
|
||||
generated = await self._revise_requirement_plan_draft(
|
||||
locked=locked,
|
||||
target_ingest=target_ingest,
|
||||
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(locked.context_snapshot, dict) else {},
|
||||
world_model=locked.context_snapshot if isinstance(getattr(locked, "context_snapshot", None), dict) else {},
|
||||
revision_note=revision_note,
|
||||
)
|
||||
locked.state = STATE_DRAFT_PENDING_CONFIRM
|
||||
|
|
@ -553,6 +746,73 @@ class DraftConfirmHandler:
|
|||
"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(
|
||||
self,
|
||||
*,
|
||||
|
|
@ -963,13 +1223,6 @@ class DraftConfirmHandler:
|
|||
return None
|
||||
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
|
||||
def _safe_float(value: Any) -> Optional[float]:
|
||||
try:
|
||||
|
|
@ -1134,6 +1387,21 @@ class DraftConfirmHandler:
|
|||
if body:
|
||||
requirement_lines.append(f"{prefix}{body}")
|
||||
_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(
|
||||
"待确认信息",
|
||||
|
|
@ -1147,7 +1415,7 @@ class DraftConfirmHandler:
|
|||
return "\n".join(lines).strip()
|
||||
|
||||
@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()
|
||||
normalized = re.sub(r"\s+", " ", raw).lower()
|
||||
return any(
|
||||
|
|
@ -1264,7 +1532,7 @@ class DraftConfirmHandler:
|
|||
return False
|
||||
|
||||
@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):
|
||||
return False
|
||||
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"))
|
||||
if assignee_id is not None and assignee_id > 0:
|
||||
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(
|
||||
"draft_confirm_project_creation_use_structured_draft_directly",
|
||||
tenant_id=int(self.tenant_id),
|
||||
|
|
@ -1426,7 +1694,7 @@ class DraftConfirmHandler:
|
|||
requested_member_user_ids=requested_member_user_ids or None,
|
||||
)
|
||||
except Exception as exc:
|
||||
if not self._looks_like_project_creation_reextract_failure(exc):
|
||||
if not self._is_project_creation_reextract_failure(exc):
|
||||
raise
|
||||
logger.warning(
|
||||
"draft_confirm_project_creation_reextract_failed_fallback",
|
||||
|
|
@ -1565,6 +1833,12 @@ class DraftConfirmHandler:
|
|||
message_text = summary
|
||||
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
|
||||
def _build_execution_route(*, work_item: WorkItem) -> WorkflowRoute:
|
||||
normalized_key = RouteResolver.normalize_route_key(str(work_item.intent or ""))
|
||||
|
|
|
|||
|
|
@ -9,6 +9,7 @@ from ....models import MountedConversationRun, MountedDraftWorkspace, MountedTop
|
|||
from .mode_card_service import build_mode_card
|
||||
from .packet_schema import (
|
||||
SemanticContextDraftSnapshot,
|
||||
SemanticContextIngress,
|
||||
SemanticContextPacket,
|
||||
SemanticContextThread,
|
||||
SemanticContextTopic,
|
||||
|
|
@ -109,6 +110,7 @@ class SemanticContextAssembler:
|
|||
operator_user_id: int,
|
||||
topic_workspace_id: int,
|
||||
latest_user_message: str,
|
||||
ingress_context: Optional[dict[str, Any]] = None,
|
||||
) -> SemanticContextPacket:
|
||||
topic_row = await self._load_topic(
|
||||
tenant_id=tenant_id,
|
||||
|
|
@ -162,6 +164,44 @@ class SemanticContextAssembler:
|
|||
recent_turns=recent_turns,
|
||||
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(
|
||||
packet_version="v1",
|
||||
tenant_id=int(tenant_id),
|
||||
|
|
@ -171,8 +211,6 @@ class SemanticContextAssembler:
|
|||
topic=topic,
|
||||
draft=draft,
|
||||
thread=thread,
|
||||
mode_card=build_mode_card(
|
||||
topic_mode=topic_row.mode,
|
||||
plan_status=topic_row.plan_status,
|
||||
),
|
||||
mode_card=mode_card,
|
||||
ingress=ingress,
|
||||
)
|
||||
|
|
|
|||
|
|
@ -15,12 +15,38 @@ class ModeCard(BaseModel):
|
|||
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):
|
||||
model_config = ConfigDict(extra="ignore")
|
||||
|
||||
topic_relation: Literal["continue_current_topic", "new_topic", "uncertain"]
|
||||
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)
|
||||
workflow_plans: list[DecisionWorkflowPlan] = Field(default_factory=list)
|
||||
scene_metadata: dict[str, Any] = Field(default_factory=dict)
|
||||
assistant_reply: str = ""
|
||||
requires_confirmation: bool = False
|
||||
requested_actions: list[str] = Field(default_factory=list)
|
||||
|
|
|
|||
|
|
@ -53,6 +53,18 @@ class SemanticContextDraftSnapshot(BaseModel):
|
|||
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):
|
||||
model_config = ConfigDict(extra="ignore")
|
||||
|
||||
|
|
@ -65,3 +77,4 @@ class SemanticContextPacket(BaseModel):
|
|||
draft: SemanticContextDraftSnapshot | None = None
|
||||
thread: SemanticContextThread
|
||||
mode_card: ModeCard
|
||||
ingress: SemanticContextIngress | None = None
|
||||
|
|
|
|||
|
|
@ -17,8 +17,9 @@ class SemanticDecisionService:
|
|||
|
||||
@staticmethod
|
||||
def build_prompt(packet: SemanticContextPacket) -> str:
|
||||
return PMFLOW_SEMANTIC_CONTEXT_DECISION_PROMPT.format(
|
||||
semantic_packet=json.dumps(packet.model_dump(mode="json"), ensure_ascii=False, indent=2)
|
||||
return PMFLOW_SEMANTIC_CONTEXT_DECISION_PROMPT.replace(
|
||||
"{semantic_packet}",
|
||||
json.dumps(packet.model_dump(mode="json"), ensure_ascii=False, indent=2),
|
||||
)
|
||||
|
||||
async def decide(self, *, packet: SemanticContextPacket) -> SemanticDecision:
|
||||
|
|
|
|||
|
|
@ -1,622 +1,13 @@
|
|||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
|
||||
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_CORRECTION_ESCALATION = "correction_escalation"
|
||||
QUERY_SCENE_REQUIREMENT_PLAN = "requirement_plan"
|
||||
|
||||
MESSAGE_GATE_AUTO = "auto_execute"
|
||||
MESSAGE_GATE_PENDING = "pending_confirmation"
|
||||
MESSAGE_GATE_FAILED_SAFE = "failed_safe"
|
||||
|
||||
REPLY_MODE_CARD = "aggregate_card"
|
||||
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("__")]
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load Diff
|
|
@ -1,7 +1,6 @@
|
|||
from __future__ import annotations
|
||||
|
||||
from .shared import *
|
||||
from ...ai_pmo.workflow_semantic_planner import WorkflowSemanticPlanner
|
||||
|
||||
class FeishuReceiveRoutingMixin:
|
||||
async def _route_scene_reply(
|
||||
|
|
@ -152,7 +151,27 @@ class FeishuReceiveRoutingMixin:
|
|||
if not isinstance(capture_result, dict):
|
||||
return "error"
|
||||
_ = 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
|
||||
def _build_error_reply_text() -> str:
|
||||
|
|
|
|||
|
|
@ -1,7 +1,6 @@
|
|||
from __future__ import annotations
|
||||
|
||||
from .shared import *
|
||||
from ...ai_pmo.workflow_semantic_planner import WorkflowSemanticPlanner
|
||||
|
||||
class FeishuReplyLLMCoreMixin:
|
||||
async def _reply_with_active_role_prompt(
|
||||
|
|
@ -121,7 +120,7 @@ class FeishuReplyLLMCoreMixin:
|
|||
capture_result,
|
||||
reason="模型返回空内容",
|
||||
)
|
||||
elif self._looks_like_provider_error(reply_text):
|
||||
elif self._is_provider_error(reply_text):
|
||||
reply_text = self._build_llm_failure_reply_text(
|
||||
capture_result,
|
||||
reason=reply_text,
|
||||
|
|
@ -228,7 +227,17 @@ class FeishuReplyLLMCoreMixin:
|
|||
*,
|
||||
capture_result: Dict[str, Any],
|
||||
) -> 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
|
||||
def _build_consultant_dispatch_reply(capture_result: Dict[str, Any]) -> Optional[str]:
|
||||
|
|
@ -245,8 +254,13 @@ class FeishuReplyLLMCoreMixin:
|
|||
_ = message_content
|
||||
if not cls._is_action_request_message(capture_result=capture_result):
|
||||
return None
|
||||
bundle = WorkflowSemanticPlanner.deserialize_bundle(capture_result.get("semantic_bundle"))
|
||||
workflow_types = [item.workflow_type for item in (bundle.workflows if bundle is not None else [])]
|
||||
semantic_bundle = capture_result.get("semantic_bundle")
|
||||
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
|
||||
signal_like = "signal_source_flow" in workflow_types or "project_signal_flow" in workflow_types
|
||||
if project_like and signal_like:
|
||||
|
|
@ -283,7 +297,7 @@ class FeishuReplyLLMCoreMixin:
|
|||
return text
|
||||
|
||||
@staticmethod
|
||||
def _looks_like_provider_error(text: str) -> bool:
|
||||
def _is_provider_error(text: str) -> bool:
|
||||
lowered = str(text or "").strip().lower()
|
||||
if not lowered:
|
||||
return False
|
||||
|
|
|
|||
|
|
@ -4,6 +4,7 @@ from .shared import *
|
|||
|
||||
class FeishuReplyOutboxMixin:
|
||||
_CARD_MESSAGE_PREFIX = "__pmflow_feishu_card__:"
|
||||
_PLAIN_TEXT_MESSAGE_LIMIT = 12000
|
||||
|
||||
@classmethod
|
||||
def _is_card_outbox_message(cls, value: Optional[str]) -> bool:
|
||||
|
|
@ -26,6 +27,31 @@ class FeishuReplyOutboxMixin:
|
|||
return parsed
|
||||
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(
|
||||
self,
|
||||
*,
|
||||
|
|
@ -116,10 +142,7 @@ class FeishuReplyOutboxMixin:
|
|||
) -> int:
|
||||
async with AsyncSessionLocal() as db:
|
||||
normalized_source_message_id = (source_message_id or "")[:128] or None
|
||||
if self._is_card_outbox_message(message_text):
|
||||
normalized_message_text = (message_text or "")[:12000]
|
||||
else:
|
||||
normalized_message_text = (message_text or "")[:2000] or "已收到,正在处理。"
|
||||
normalized_message_text = self._normalize_reply_outbox_message_text(message_text)
|
||||
reply_hash = self._build_reply_hash(
|
||||
source_message_id=normalized_source_message_id,
|
||||
message_text=normalized_message_text,
|
||||
|
|
@ -382,8 +405,8 @@ class FeishuReplyOutboxMixin:
|
|||
text = text.replace("```", "").strip()
|
||||
if text.lower().startswith("json"):
|
||||
text = text[4:].strip()
|
||||
looks_like_provider_error = getattr(cls, "_looks_like_provider_error", None)
|
||||
has_provider_error = callable(looks_like_provider_error) and bool(looks_like_provider_error(text))
|
||||
provider_error_detector = getattr(cls, "_is_provider_error", None)
|
||||
has_provider_error = callable(provider_error_detector) and bool(provider_error_detector(text))
|
||||
if not text or has_provider_error:
|
||||
error_builder = getattr(cls, "_build_error_reply_text", None)
|
||||
if callable(error_builder):
|
||||
|
|
|
|||
|
|
@ -14,6 +14,7 @@ from ....models import FeishuReplyOutbox
|
|||
|
||||
class FeishuOutboxService:
|
||||
"""复用 reply_hash 幂等策略的 outbox 写入入口。"""
|
||||
_PLAIN_TEXT_MESSAGE_LIMIT = 12000
|
||||
|
||||
@staticmethod
|
||||
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")
|
||||
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
|
||||
async def enqueue(
|
||||
cls,
|
||||
|
|
@ -39,7 +62,7 @@ class FeishuOutboxService:
|
|||
normalized_chat_id = str(chat_id or "").strip()[:128]
|
||||
if not normalized_chat_id:
|
||||
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)
|
||||
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)
|
||||
|
|
|
|||
|
|
@ -6,7 +6,7 @@ from .shared import *
|
|||
|
||||
class FeishuPipelineContextPrefillAMixin:
|
||||
@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()
|
||||
if not normalized:
|
||||
return True
|
||||
|
|
@ -33,7 +33,7 @@ class FeishuPipelineContextPrefillAMixin:
|
|||
)
|
||||
|
||||
@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()
|
||||
if not normalized:
|
||||
return False
|
||||
|
|
@ -60,7 +60,7 @@ class FeishuPipelineContextPrefillAMixin:
|
|||
)
|
||||
|
||||
@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()
|
||||
if not normalized:
|
||||
return True
|
||||
|
|
@ -209,7 +209,7 @@ class FeishuPipelineContextPrefillAMixin:
|
|||
text = self._strip_feishu_mention_prefix(str(row.message_text or ""))
|
||||
if not text:
|
||||
continue
|
||||
if self._looks_like_unusable_project_context(text):
|
||||
if self._is_unusable_project_context(text):
|
||||
continue
|
||||
return text[:4000]
|
||||
return ""
|
||||
|
|
@ -224,19 +224,19 @@ class FeishuPipelineContextPrefillAMixin:
|
|||
return fields
|
||||
|
||||
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
|
||||
|
||||
current_description = str(fields.get("description") or "").strip()
|
||||
if (
|
||||
self._looks_like_placeholder_project_text(current_description)
|
||||
or self._looks_like_project_request_artifact(current_description)
|
||||
self._is_placeholder_project_text(current_description)
|
||||
or self._is_project_request_artifact(current_description)
|
||||
):
|
||||
current_description = ""
|
||||
current_scope = str(fields.get("project_scope") or "").strip()
|
||||
if (
|
||||
self._looks_like_placeholder_project_text(current_scope)
|
||||
or self._looks_like_project_request_artifact(current_scope)
|
||||
self._is_placeholder_project_text(current_scope)
|
||||
or self._is_project_request_artifact(current_scope)
|
||||
):
|
||||
current_scope = ""
|
||||
|
||||
|
|
@ -258,11 +258,11 @@ class FeishuPipelineContextPrefillAMixin:
|
|||
suggested_name = normalize_project_title_candidate(
|
||||
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 = ""
|
||||
title = title or suggested_name
|
||||
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 = ""
|
||||
project_scope = project_scope or self._extract_project_scope(embedded_context, description=description)
|
||||
|
||||
|
|
|
|||
|
|
@ -18,7 +18,7 @@ class FeishuPipelineContextPrefillBMixin:
|
|||
) -> dict[str, Any]:
|
||||
if fields.get("ready"):
|
||||
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
|
||||
|
||||
turns = await self._load_recent_chat_turns(
|
||||
|
|
@ -39,7 +39,7 @@ class FeishuPipelineContextPrefillBMixin:
|
|||
if recent_question:
|
||||
context_blocks.append(f"最近用户问题:{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}")
|
||||
answer_hint = recent_answer
|
||||
|
||||
|
|
@ -48,8 +48,8 @@ class FeishuPipelineContextPrefillBMixin:
|
|||
|
||||
base_description = str(fields.get("description") or "").strip()
|
||||
if (
|
||||
self._looks_like_placeholder_project_text(base_description)
|
||||
or self._looks_like_project_request_artifact(base_description)
|
||||
self._is_placeholder_project_text(base_description)
|
||||
or self._is_project_request_artifact(base_description)
|
||||
):
|
||||
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_scope = str(fields.get("project_scope") or "").strip()
|
||||
if (
|
||||
self._looks_like_placeholder_project_text(heuristic_scope)
|
||||
or self._looks_like_project_request_artifact(heuristic_scope)
|
||||
self._is_placeholder_project_text(heuristic_scope)
|
||||
or self._is_project_request_artifact(heuristic_scope)
|
||||
):
|
||||
heuristic_scope = ""
|
||||
if not heuristic_scope and heuristic_description:
|
||||
|
|
@ -85,14 +85,14 @@ class FeishuPipelineContextPrefillBMixin:
|
|||
project_info = await self._extract_project_prefill(prefill_source)
|
||||
current_description = str(fields.get("description") or "").strip()
|
||||
if (
|
||||
self._looks_like_placeholder_project_text(current_description)
|
||||
or self._looks_like_project_request_artifact(current_description)
|
||||
self._is_placeholder_project_text(current_description)
|
||||
or self._is_project_request_artifact(current_description)
|
||||
):
|
||||
current_description = ""
|
||||
current_scope = str(fields.get("project_scope") or "").strip()
|
||||
if (
|
||||
self._looks_like_placeholder_project_text(current_scope)
|
||||
or self._looks_like_project_request_artifact(current_scope)
|
||||
self._is_placeholder_project_text(current_scope)
|
||||
or self._is_project_request_artifact(current_scope)
|
||||
):
|
||||
current_scope = ""
|
||||
title = (
|
||||
|
|
@ -103,7 +103,7 @@ class FeishuPipelineContextPrefillBMixin:
|
|||
description = current_description or str(project_info.get("suggested_description") or "").strip()
|
||||
if not description and answer_hint:
|
||||
description = answer_hint[:4000]
|
||||
if self._looks_like_unusable_project_context(description):
|
||||
if self._is_unusable_project_context(description):
|
||||
description = ""
|
||||
project_scope = current_scope
|
||||
if not project_scope:
|
||||
|
|
@ -190,12 +190,12 @@ class FeishuPipelineContextPrefillBMixin:
|
|||
project_scope: str,
|
||||
) -> str:
|
||||
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
|
||||
|
||||
for candidate in (project_scope, description):
|
||||
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
|
||||
normalized = normalize_project_title_candidate(cleaned[:48])
|
||||
if normalized:
|
||||
|
|
@ -212,10 +212,10 @@ class FeishuPipelineContextPrefillBMixin:
|
|||
fallback_text: str,
|
||||
) -> str:
|
||||
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_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 = ""
|
||||
|
||||
fragments: list[str] = []
|
||||
|
|
|
|||
|
|
@ -2,7 +2,6 @@
|
|||
from __future__ import annotations
|
||||
|
||||
from .shared import *
|
||||
from ...ai_pmo.workflow_semantic_planner import WorkflowSemanticPlanner
|
||||
|
||||
|
||||
class FeishuPipelineCoreBaseMixin:
|
||||
|
|
@ -34,32 +33,12 @@ class FeishuPipelineCoreBaseMixin:
|
|||
|
||||
@staticmethod
|
||||
def _is_healthcheck_message(message: str) -> bool:
|
||||
return WorkflowSemanticPlanner.is_healthcheck_message(message)
|
||||
|
||||
@staticmethod
|
||||
def _is_short_confirmation_message(message: str) -> bool:
|
||||
return WorkflowSemanticPlanner.is_short_confirmation_noise(message)
|
||||
return bool(HEALTHCHECK_RE.search(str(message or "").strip()))
|
||||
|
||||
@staticmethod
|
||||
def _is_correction_message(message: str) -> bool:
|
||||
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
|
||||
def _can_execute_high_value_action(user: Optional[User]) -> bool:
|
||||
if user is None:
|
||||
|
|
|
|||
|
|
@ -2,6 +2,7 @@
|
|||
from __future__ import annotations
|
||||
|
||||
from .shared import *
|
||||
from ...ai_pmo.workflow_semantic_planner import WorkflowSemanticPlanner
|
||||
|
||||
|
||||
class FeishuPipelineIntentGateCoreMixin:
|
||||
|
|
@ -27,9 +28,23 @@ class FeishuPipelineIntentGateCoreMixin:
|
|||
"correction_match": None,
|
||||
}
|
||||
|
||||
explicit_project_request = self._looks_like_project_create_request(text)
|
||||
explicit_task_request = self._looks_like_task_create_request(text)
|
||||
explicit_external_request = self._looks_like_external_connect_request(text)
|
||||
semantic_bundle = await WorkflowSemanticPlanner.aplan_message(
|
||||
message_text=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
|
||||
if self.db is not None and len(text) >= 4:
|
||||
try:
|
||||
|
|
@ -76,16 +91,6 @@ class FeishuPipelineIntentGateCoreMixin:
|
|||
"fields": {},
|
||||
"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 (
|
||||
correction_match
|
||||
and float(correction_match.get("score") or 0.0) >= 0.84
|
||||
|
|
@ -175,17 +180,7 @@ class FeishuPipelineIntentGateCoreMixin:
|
|||
"correction_match": correction_match,
|
||||
}
|
||||
|
||||
if self._is_query_like_question(text):
|
||||
return {
|
||||
"intent": INTENT_QUERY,
|
||||
"canonical_action": None,
|
||||
"clarification_template": "",
|
||||
"risk_level": "low",
|
||||
"fields": {},
|
||||
"correction_match": correction_match,
|
||||
}
|
||||
|
||||
if self._is_obvious_question(text):
|
||||
if query_only:
|
||||
return {
|
||||
"intent": INTENT_QUERY,
|
||||
"canonical_action": None,
|
||||
|
|
@ -193,6 +188,7 @@ class FeishuPipelineIntentGateCoreMixin:
|
|||
"risk_level": "low",
|
||||
"fields": {},
|
||||
"correction_match": correction_match,
|
||||
"semantic_bundle": semantic_bundle.to_dict(),
|
||||
}
|
||||
|
||||
return {
|
||||
|
|
@ -257,7 +253,7 @@ class FeishuPipelineIntentGateCoreMixin:
|
|||
}
|
||||
|
||||
@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()
|
||||
if not normalized:
|
||||
return False
|
||||
|
|
|
|||
|
|
@ -2,21 +2,50 @@
|
|||
from __future__ import annotations
|
||||
|
||||
from .shared import *
|
||||
from ...ai_pmo.workflow_semantic_planner import WorkflowSemanticPlanner
|
||||
|
||||
|
||||
class FeishuPipelineLookupAndBlockMixin:
|
||||
@staticmethod
|
||||
def _looks_like_project_create_command(message: str) -> bool: # semantic-lint: ignore - compat wrapper over workflow_semantic_planner
|
||||
return WorkflowSemanticPlanner.looks_like_project_create_request(message)
|
||||
def _extract_project_lookup_keyword(message: str) -> str:
|
||||
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
|
||||
def _looks_like_project_lookup_question(cls, message: str) -> bool: # semantic-lint: ignore - compat wrapper over workflow_semantic_planner
|
||||
return WorkflowSemanticPlanner.looks_like_project_lookup_question(message)
|
||||
|
||||
@classmethod
|
||||
def _is_legacy_business_command(cls, message: str) -> bool:
|
||||
return WorkflowSemanticPlanner.is_legacy_business_command(message)
|
||||
def _is_project_lookup_question(cls, message: str) -> bool:
|
||||
raw = str(message or "").strip()
|
||||
if not raw:
|
||||
return False
|
||||
if PROJECT_ID_QUERY_RE.search(raw) or PROJECT_LIST_QUERY_RE.search(raw):
|
||||
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(
|
||||
self,
|
||||
|
|
@ -31,16 +60,18 @@ class FeishuPipelineLookupAndBlockMixin:
|
|||
projects: list[dict[str, Any]],
|
||||
process_started_at: float,
|
||||
) -> Optional[dict[str, Any]]:
|
||||
if not self._is_legacy_business_command(message):
|
||||
return None
|
||||
|
||||
_ = sender, source_chat_id, source_sender_open_id, captured_at, inferred_project_id, projects
|
||||
return await self._build_legacy_command_blocked_capture(
|
||||
message=message,
|
||||
feishu_message_id=feishu_message_id,
|
||||
duration_ms=int((time.perf_counter() - process_started_at) * 1000),
|
||||
reason="旧 command-first 规划链路已下线,请改用 GLM 分析后直接调用显式接口。",
|
||||
_ = (
|
||||
message,
|
||||
sender,
|
||||
source_chat_id,
|
||||
source_sender_open_id,
|
||||
feishu_message_id,
|
||||
captured_at,
|
||||
inferred_project_id,
|
||||
projects,
|
||||
process_started_at,
|
||||
)
|
||||
return None
|
||||
|
||||
async def _maybe_answer_project_lookup_question(
|
||||
self,
|
||||
|
|
@ -50,7 +81,7 @@ class FeishuPipelineLookupAndBlockMixin:
|
|||
process_started_at: float,
|
||||
projects: list[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
|
||||
|
||||
linked_project_ids: list[int] = []
|
||||
|
|
@ -71,7 +102,7 @@ class FeishuPipelineLookupAndBlockMixin:
|
|||
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"))]
|
||||
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)
|
||||
if matched_rows:
|
||||
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
|
||||
if cls._is_healthcheck_message(raw_message):
|
||||
return True
|
||||
if cls._is_short_confirmation_message(raw_message):
|
||||
return True
|
||||
if cls._looks_like_project_lookup_question(raw_message):
|
||||
if cls._is_project_lookup_question(raw_message):
|
||||
return True
|
||||
if "长连接" in normalized and any(token in normalized for token in ("已通", "都已通", "正常", "在线", "已恢复")):
|
||||
return True
|
||||
|
|
|
|||
|
|
@ -35,64 +35,6 @@ class FeishuPipelineProcessMessageDefaultMixin:
|
|||
intent_for_response = "statement"
|
||||
force_signal_fastpath = False
|
||||
rag_question_part = message
|
||||
|
||||
if self._is_obvious_question(message):
|
||||
intent_result: dict[str, Any] = {}
|
||||
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 = (
|
||||
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,
|
||||
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="question_obvious",
|
||||
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
|
||||
|
|
|
|||
|
|
@ -2,7 +2,6 @@
|
|||
from __future__ import annotations
|
||||
|
||||
from .shared import *
|
||||
from ...ai_pmo.workflow_semantic_planner import WorkflowSemanticPlanner
|
||||
|
||||
|
||||
class FeishuPipelineProcessMessageHelpersAMixin:
|
||||
|
|
@ -175,11 +174,3 @@ class FeishuPipelineProcessMessageHelpersAMixin:
|
|||
text = re.sub(r"\n{2,}", "\n", text)
|
||||
text = re.sub(r"\s+", " ", text).strip()
|
||||
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)
|
||||
|
|
|
|||
|
|
@ -68,9 +68,9 @@ class FeishuPipelineSerializationMixin:
|
|||
"ready": False,
|
||||
}
|
||||
if (
|
||||
self._looks_like_placeholder_project_text(project_fields["title"])
|
||||
or self._looks_like_placeholder_project_text(project_fields["description"])
|
||||
or self._looks_like_placeholder_project_text(project_fields["project_scope"])
|
||||
self._is_placeholder_project_text(project_fields["title"])
|
||||
or self._is_placeholder_project_text(project_fields["description"])
|
||||
or self._is_placeholder_project_text(project_fields["project_scope"])
|
||||
):
|
||||
project_fields = await self._maybe_prefill_project_fields_from_context(
|
||||
message=str(item.raw_message or ""),
|
||||
|
|
|
|||
|
|
@ -41,33 +41,6 @@ from ...ai_pmo.ai_pmo_kernel import (
|
|||
gate_execution as kernel_gate_execution,
|
||||
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 ...notification_service import NotificationService
|
||||
from ...operation_log_service import log_operation
|
||||
|
|
|
|||
|
|
@ -153,7 +153,7 @@ class DraftGeneratorService:
|
|||
max_tokens=max_tokens,
|
||||
error=str(exc),
|
||||
)
|
||||
if self._looks_like_provider_budget_rejection(exc):
|
||||
if self._is_provider_budget_rejection(exc):
|
||||
break
|
||||
continue
|
||||
payload = extraction.payload if extraction is not None and isinstance(extraction.payload, dict) else {}
|
||||
|
|
@ -199,7 +199,7 @@ class DraftGeneratorService:
|
|||
return ordered
|
||||
|
||||
@staticmethod
|
||||
def _looks_like_provider_budget_rejection(exc: Exception) -> bool:
|
||||
def _is_provider_budget_rejection(exc: Exception) -> bool:
|
||||
lowered = str(exc or "").lower()
|
||||
return "400 bad request" in lowered and any(
|
||||
token in lowered
|
||||
|
|
|
|||
|
|
@ -204,26 +204,11 @@ class SignalCaptureAnalysisNormalizeMixin:
|
|||
if not content:
|
||||
return True
|
||||
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():
|
||||
return True
|
||||
non_requirement_keywords = [
|
||||
"收到",
|
||||
"辛苦了",
|
||||
"赞",
|
||||
"哈哈",
|
||||
"good",
|
||||
"nice",
|
||||
"ok",
|
||||
]
|
||||
if len(content) <= 8 and any(k in content for k in 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 ("?", "?", "项目", "需求", "任务", "信号")):
|
||||
return True
|
||||
if ONLY_PUNCT_OR_SPACE_RE.match(content):
|
||||
return True
|
||||
|
|
|
|||
|
|
@ -11,7 +11,7 @@ class SignalCaptureEntryMixin:
|
|||
self.llm = LLMService()
|
||||
|
||||
@classmethod
|
||||
def _should_process_feishu_high_accuracy(
|
||||
async def _should_process_feishu_high_accuracy(
|
||||
cls,
|
||||
*,
|
||||
message_text: str,
|
||||
|
|
@ -20,7 +20,7 @@ class SignalCaptureEntryMixin:
|
|||
if not clean_text:
|
||||
return False, "empty"
|
||||
|
||||
bundle = WorkflowSemanticPlanner.plan_message(
|
||||
bundle = await WorkflowSemanticPlanner.aplan_message(
|
||||
message_text=clean_text,
|
||||
source_system="feishu",
|
||||
)
|
||||
|
|
@ -131,7 +131,7 @@ class SignalCaptureEntryMixin:
|
|||
should_sync_process = bool(force_sync)
|
||||
sync_reason = "force_sync" if should_sync_process else ""
|
||||
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,
|
||||
)
|
||||
|
||||
|
|
@ -145,21 +145,13 @@ class SignalCaptureEntryMixin:
|
|||
source_message_id=source_message_id,
|
||||
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:
|
||||
semantic_bundle = None
|
||||
normalized_sync_reason = str(sync_reason or "").strip().lower()
|
||||
should_forward_semantic_bundle = bool(force_sync) or normalized_sync_reason.startswith("semantic:")
|
||||
if should_forward_semantic_bundle:
|
||||
candidate_bundle = WorkflowSemanticPlanner.plan_message(
|
||||
candidate_bundle = await WorkflowSemanticPlanner.aplan_message(
|
||||
message_text=cleaned_message,
|
||||
source_system="feishu",
|
||||
)
|
||||
|
|
@ -407,6 +399,7 @@ class SignalCaptureEntryMixin:
|
|||
)
|
||||
except Exception:
|
||||
pass
|
||||
if hasattr(self.db, "commit"):
|
||||
try:
|
||||
await self.db.commit()
|
||||
except Exception as exc:
|
||||
|
|
@ -424,6 +417,8 @@ class SignalCaptureEntryMixin:
|
|||
return pipeline_result
|
||||
|
||||
async def _rollback_capture_failure(self, *, trace_id: str) -> None:
|
||||
if not hasattr(self.db, "rollback"):
|
||||
return
|
||||
try:
|
||||
await self.db.rollback()
|
||||
except Exception as rollback_exc:
|
||||
|
|
|
|||
|
|
@ -111,7 +111,10 @@ class SignalCapturePipelineCacheMixin:
|
|||
return pipeline_result
|
||||
|
||||
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:
|
||||
source_orchestration_result = await SignalSourceOrchestrationService(self.db).orchestrate_from_signal(
|
||||
tenant_id=tenant_id,
|
||||
|
|
|
|||
|
|
@ -41,11 +41,6 @@ from ...llm_service import LLMService
|
|||
from ...notification_service import NotificationService
|
||||
from ... import workload as workload_service
|
||||
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 ...context_cache_service import context_cache_service
|
||||
from ..signal_source_orchestration import SignalSourceOrchestrationService
|
||||
|
|
|
|||
|
|
@ -1,16 +1,11 @@
|
|||
from __future__ import annotations
|
||||
|
||||
from .shared import *
|
||||
from ...ai_pmo.workflow_semantic_planner import WorkflowSemanticPlanner
|
||||
|
||||
class SignalSourceEntryOrchestrateMixin:
|
||||
def __init__(self, db: AsyncSession) -> None:
|
||||
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(
|
||||
self,
|
||||
*,
|
||||
|
|
@ -61,17 +56,6 @@ class SignalSourceEntryOrchestrateMixin:
|
|||
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
|
||||
|
||||
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(
|
||||
tenant_id=tenant_id,
|
||||
project=project_ref,
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
@staticmethod
|
||||
|
|
@ -92,4 +84,3 @@ class SignalSourcePlanningLocatorRolesMixin:
|
|||
"请创建必要任务、分配合适负责人,并通知相关成员。"
|
||||
f"补充上下文:{str(initiating_text or '').strip()[:240]}"
|
||||
)
|
||||
|
||||
|
|
|
|||
|
|
@ -155,7 +155,12 @@ class SignalSourcePlanningOnboardMixin:
|
|||
fallback_text: str,
|
||||
requested_via: str,
|
||||
) -> 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())
|
||||
if not source_name:
|
||||
source_name = self._extract_source_name(fallback_text, default=f"{source_kind}信号源")
|
||||
|
|
@ -183,20 +188,36 @@ class SignalSourcePlanningOnboardMixin:
|
|||
}
|
||||
|
||||
@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()
|
||||
for kind, hints in SOURCE_KIND_HINTS:
|
||||
if cleaned == kind:
|
||||
return kind
|
||||
if any(token in cleaned for token in hints):
|
||||
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
|
||||
payload = source_payload if isinstance(source_payload, dict) else {}
|
||||
config = payload.get("config") if isinstance(payload.get("config"), dict) else {}
|
||||
if cleaned in SUPPORTED_SOURCE_KINDS:
|
||||
return cleaned
|
||||
|
||||
via = str(requested_via or "").strip().lower()
|
||||
if via in {"feishu", "pmflow", "github", "browser"}:
|
||||
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"
|
||||
|
||||
@classmethod
|
||||
|
|
@ -268,4 +289,3 @@ class SignalSourcePlanningOnboardMixin:
|
|||
if normalized in {"", "项目id", "给项目id", "复合", "复合信号源", "信号源", "来源", "监控源"}:
|
||||
return ""
|
||||
return cleaned
|
||||
|
||||
|
|
|
|||
|
|
@ -34,16 +34,8 @@ from ...model.prompt_profile import prompt_profile_service
|
|||
from ...llm_service import llm_service
|
||||
|
||||
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, ...]], ...] = (
|
||||
("feishu", ("飞书", "lark", "群聊", "chat")),
|
||||
("github", ("github", "repo", "仓库", "pull request", "issue")),
|
||||
("webhook", ("webhook", "回调", "回传")),
|
||||
("url", ("url", "链接", "网页", "站点", "rss")),
|
||||
("browser", ("浏览器", "标签页", "页面")),
|
||||
("pmflow", ("pmflow", "内置ai", "ai管家", "steward")),
|
||||
)
|
||||
SUPPORTED_SOURCE_KINDS = ("feishu", "github", "webhook", "url", "browser", "pmflow", "custom")
|
||||
DEFAULT_WORKER_ROLE_KEYS = ("pm_dispatcher", "tech_architect", "delivery_coach")
|
||||
MAX_SOURCE_NAME_LEN = 120
|
||||
MAX_SOURCE_IDENTIFIER_LEN = 180
|
||||
|
|
|
|||
|
|
@ -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 (
|
||||
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.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(
|
||||
|
|
@ -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
|
||||
|
||||
|
||||
@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:
|
||||
class _FakePlannerService(AdaptiveRAGAnswerMainlineMixin):
|
||||
pass
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load Diff
|
|
@ -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 (
|
||||
ExecuteSignalSourceMixin,
|
||||
)
|
||||
from app.services.signal.signal_source_orchestration import SignalSourceOrchestrationService
|
||||
|
||||
|
||||
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["project_id"] == 2
|
||||
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"
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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_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(
|
||||
text="请在项目#12 新建任务:修复登录超时",
|
||||
project_id=12,
|
||||
|
|
@ -47,13 +76,54 @@ def test_route_resolver_builds_task_create_route() -> None:
|
|||
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(
|
||||
text="先不要创建任务,告诉我当前进展和风险",
|
||||
project_id=7,
|
||||
)
|
||||
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:
|
||||
|
|
@ -74,20 +144,20 @@ def test_route_resolver_builds_query_answer_route() -> 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,
|
||||
message_text="先取消这个工作项",
|
||||
)
|
||||
assert action == ROUTE_ACTION_CONTINUE
|
||||
|
||||
|
||||
def test_route_resolver_followup_action_allows_message_parsing_with_context() -> None:
|
||||
action = RouteResolver.parse_followup_action(
|
||||
def test_route_resolver_followup_action_no_longer_parses_message_text_with_context() -> None:
|
||||
action = RouteResolver.resolve_followup_route_action(
|
||||
canonical_intent=INTENT_FOLLOWUP_CONTINUE,
|
||||
message_text="先取消这个工作项",
|
||||
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:
|
||||
|
|
|
|||
|
|
@ -20,31 +20,6 @@ from app.services.integrations.feishu_integration_service.mixin_reply_llm_core i
|
|||
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:
|
||||
ingest = type(
|
||||
"Ingest",
|
||||
|
|
@ -185,6 +160,47 @@ def test_feishu_reply_llm_core_action_ack_uses_semantic_bundle_only() -> None:
|
|||
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:
|
||||
contract = build_consultant_dispatch_contract(
|
||||
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"]
|
||||
|
||||
|
||||
@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
|
||||
async def test_draft_confirm_handler_ignores_non_draft_reply_without_target(monkeypatch) -> None:
|
||||
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
|
||||
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})()
|
||||
handler = DraftConfirmHandler(kernel)
|
||||
|
||||
|
|
@ -1015,12 +1146,362 @@ async def test_draft_confirm_handler_natural_revision_without_target_returns_fai
|
|||
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_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"},
|
||||
)
|
||||
|
||||
assert result["handled"] is True
|
||||
assert result["status"] == "failed_safe"
|
||||
assert "当前没有可确认草稿" in result["followup_reply"]
|
||||
assert result["status"] == "pending_manual"
|
||||
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:
|
||||
|
|
|
|||
|
|
@ -1,3 +1,4 @@
|
|||
import asyncio
|
||||
import inspect
|
||||
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:
|
||||
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:
|
||||
@staticmethod
|
||||
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(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())
|
||||
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:
|
||||
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:
|
||||
@staticmethod
|
||||
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(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())
|
||||
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:
|
||||
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:
|
||||
async def commit(self) -> 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(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())
|
||||
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
|
||||
|
||||
|
||||
def test_should_process_feishu_high_accuracy_treats_requirement_plan_as_sync() -> None:
|
||||
should_sync, reason = mixin_capture_entry.SignalCaptureEntryMixin._should_process_feishu_high_accuracy(
|
||||
@pytest.mark.asyncio
|
||||
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趋势打分的小工具,先不创建,先收敛第一版。",
|
||||
)
|
||||
|
||||
|
|
@ -739,8 +843,20 @@ def test_should_process_feishu_high_accuracy_treats_requirement_plan_as_sync() -
|
|||
assert reason == "semantic:requirement_plan"
|
||||
|
||||
|
||||
def test_should_process_feishu_high_accuracy_treats_requirement_plan_slot_answer_as_sync() -> None:
|
||||
should_sync, reason = mixin_capture_entry.SignalCaptureEntryMixin._should_process_feishu_high_accuracy(
|
||||
@pytest.mark.asyncio
|
||||
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 先按技术指标来,主要给投顾看。",
|
||||
)
|
||||
|
||||
|
|
@ -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:
|
||||
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:
|
||||
async def commit(self) -> 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, "MountedAIRuntimeWriteService", _FakeMountedRuntimeWriteService)
|
||||
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())
|
||||
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:
|
||||
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:
|
||||
async def commit(self) -> 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, "MountedAIRuntimeWriteService", _FakeMountedRuntimeWriteService)
|
||||
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())
|
||||
result = await service.capture_from_feishu(
|
||||
|
|
|
|||
|
|
@ -71,6 +71,44 @@ def test_classify_message_scene_does_not_infer_execute_from_raw_text_only() -> N
|
|||
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
|
||||
async def test_handle_message_receive_orchestrates_capture_and_finalize(monkeypatch) -> None:
|
||||
service = FeishuIntegrationService(db=object())
|
||||
|
|
|
|||
|
|
@ -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[-1].role == "assistant"
|
||||
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
|
||||
|
|
|
|||
|
|
@ -1,5 +1,6 @@
|
|||
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 (
|
||||
DecisionGateResult,
|
||||
ModeCard,
|
||||
|
|
@ -58,3 +59,18 @@ def test_semantic_decision_gatekeeper_blocks_forbidden_action() -> None:
|
|||
assert result.accepted is False
|
||||
assert "forbidden_action:project.create" in result.blocked_reasons
|
||||
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
|
||||
|
|
|
|||
|
|
@ -32,7 +32,7 @@ def test_semantic_lint_flags_new_scattered_hint_tables(tmp_path) -> None:
|
|||
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()
|
||||
target = tmp_path / "backend" / "app" / "services" / "ai_pmo" / "workflow_semantic_planner" / "foo.py"
|
||||
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"),
|
||||
)
|
||||
|
||||
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:
|
||||
|
|
|
|||
|
|
@ -1,278 +1,119 @@
|
|||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
from typing import Any
|
||||
|
||||
import pytest
|
||||
|
||||
from app.services.ai_pmo.workflow_semantic_planner import (
|
||||
WorkflowSemanticPlanner,
|
||||
from app.services.ai_pmo.semantic_context.decision_schema import SemanticDecision
|
||||
from app.services.ai_pmo.workflow_semantic_planner import WorkflowSemanticPlanner
|
||||
|
||||
|
||||
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_builds_multi_workflow_bundle() -> None:
|
||||
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(
|
||||
message_text="帮我创建一个项目,并把这个需求接入信号源",
|
||||
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 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"
|
||||
|
||||
|
||||
def test_workflow_semantic_planner_derives_query_bundle() -> None:
|
||||
bundle = WorkflowSemanticPlanner.plan_message(
|
||||
message_text="现在项目进展怎么样?",
|
||||
source_system="pmflow_manual",
|
||||
)
|
||||
|
||||
assert bundle.primary_workflow == "query_flow"
|
||||
assert [item.workflow_type for item in bundle.workflows] == ["query_flow"]
|
||||
assert bundle.message_gate == "auto_execute"
|
||||
|
||||
|
||||
def test_workflow_semantic_planner_helpers_cover_legacy_entry_semantics() -> None:
|
||||
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",
|
||||
)
|
||||
|
||||
assert bundle.primary_workflow == "query_flow"
|
||||
assert bundle.workflows[0].metadata["message_intent"] == "requirement_plan"
|
||||
assert bundle.workflows[0].metadata["topic_switch"] == "new"
|
||||
assert bundle.diagnostics["topic_switch"] == "new"
|
||||
|
||||
|
||||
def test_workflow_semantic_planner_marks_chatty_new_requirement_phrase_as_topic_switch() -> None:
|
||||
bundle = WorkflowSemanticPlanner.plan_message(
|
||||
message_text="想跟你聊个新需求关于批量自动化部署openclaw的",
|
||||
source_system="feishu",
|
||||
)
|
||||
|
||||
assert bundle.primary_workflow == "query_flow"
|
||||
assert bundle.workflows[0].metadata["message_intent"] == "requirement_plan"
|
||||
assert bundle.workflows[0].metadata["topic_switch"] == "new"
|
||||
assert bundle.diagnostics["topic_switch"] == "new"
|
||||
|
||||
|
||||
def test_workflow_semantic_planner_routes_assignee_recommendation_back_to_query_flow() -> None:
|
||||
bundle = WorkflowSemanticPlanner.plan_message(
|
||||
message_text="那你觉得分配给谁做比较合适?",
|
||||
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 和研发负责人用",
|
||||
"飞书总结就行",
|
||||
"每次会后半小时内整理出来",
|
||||
@pytest.mark.asyncio
|
||||
async def test_workflow_semantic_planner_aplan_message_preserves_scene_metadata(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
async def _fake_decide(cls, *, message_text: str, source_system: str, metadata: dict[str, Any] | None = None):
|
||||
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",
|
||||
},
|
||||
)
|
||||
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,
|
||||
|
||||
monkeypatch.setattr(WorkflowSemanticPlanner, "_decide_cold_start_semantics", classmethod(_fake_decide))
|
||||
|
||||
bundle = await WorkflowSemanticPlanner.aplan_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"
|
||||
assert bundle.diagnostics["topic_switch"] == "new"
|
||||
|
||||
|
||||
def test_workflow_semantic_planner_routes_generic_requirement_chat_into_requirement_plan() -> None:
|
||||
bundle = WorkflowSemanticPlanner.plan_message(
|
||||
message_text="想跟你聊个需求,我们开完会以后待办经常散掉,想做个小工具自动从会议纪要里提待办,先别创建,先收第一版。",
|
||||
source_system="feishu",
|
||||
def test_workflow_semantic_planner_sync_wrapper_fails_inside_running_loop() -> None:
|
||||
async def _runner() -> None:
|
||||
with pytest.raises(RuntimeError):
|
||||
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"
|
||||
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 == ""
|
||||
for name in legacy_names:
|
||||
assert not hasattr(WorkflowSemanticPlanner, name), name
|
||||
|
|
|
|||
|
|
@ -17,7 +17,6 @@ DEFAULT_SCAN_ROOTS = (
|
|||
Path("backend/app/api/ai_pmo_api"),
|
||||
)
|
||||
ALLOWLIST_PREFIXES = (
|
||||
Path("backend/app/services/ai_pmo/workflow_semantic_planner"),
|
||||
Path("backend/app/config/ai_prompt_registry.py"),
|
||||
Path("backend/app/config/ai_prompts.py"),
|
||||
Path("backend/app/config/ai_prompts_catalog"),
|
||||
|
|
|
|||
Binary file not shown.
Loading…
Reference in New Issue