Add reply iteration and training pipeline foundations

This commit is contained in:
Yuuuu 2026-03-27 20:43:07 +08:00
parent 0e87cec76d
commit b30b0f4834
94 changed files with 7538 additions and 653 deletions

View File

@ -10,6 +10,8 @@ on:
- "frontend/src/types/**" - "frontend/src/types/**"
- "docs/architecture/**" - "docs/architecture/**"
- "scripts/check_contract_sync.sh" - "scripts/check_contract_sync.sh"
- "scripts/semantic_lint.py"
- "scripts/semantic_lint_baseline.txt"
- ".github/workflows/contract-check.yml" - ".github/workflows/contract-check.yml"
push: push:
branches: [ main, master ] branches: [ main, master ]
@ -21,6 +23,8 @@ on:
- "frontend/src/types/**" - "frontend/src/types/**"
- "docs/architecture/**" - "docs/architecture/**"
- "scripts/check_contract_sync.sh" - "scripts/check_contract_sync.sh"
- "scripts/semantic_lint.py"
- "scripts/semantic_lint_baseline.txt"
- ".github/workflows/contract-check.yml" - ".github/workflows/contract-check.yml"
jobs: jobs:
@ -31,3 +35,6 @@ jobs:
- name: Run contract sync check - name: Run contract sync check
run: | run: |
bash scripts/check_contract_sync.sh bash scripts/check_contract_sync.sh
- name: Run semantic lint
run: |
python3 scripts/semantic_lint.py

View File

@ -1,4 +1,4 @@
{ {
"version": 1, "version": 1,
"onboardingCompletedAt": "2026-03-26T16:00:06.817Z" "setupCompletedAt": "2026-03-26T16:00:06.817Z"
} }

View File

@ -86,6 +86,7 @@ frontend/ # React 前端
- 禁止在前端组件内直接调用 `fetch`,必须通过 `frontend/src/api/` 封装层 - 禁止在前端组件内直接调用 `fetch`,必须通过 `frontend/src/api/` 封装层
- 前端状态管理:服务端数据用 React Query,本地 UI 状态用 `useState` - 前端状态管理:服务端数据用 React Query,本地 UI 状态用 `useState`
- 禁止模型兜底回复:模型失败/超时/空响应/结构化解析失败时,必须显式报错并保留真实失败原因,禁止返回固定兜底文案冒充成功结果 - 禁止模型兜底回复:模型失败/超时/空响应/结构化解析失败时,必须显式报错并保留真实失败原因,禁止返回固定兜底文案冒充成功结果
- 业务语义治理:凡是会决定 `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 抽取与结构化结果解析
## 9. 新增代码的参考模式 ## 9. 新增代码的参考模式
@ -176,3 +177,17 @@ frontend/ # React 前端
- 单任务预估改动超过200行 → 拆分执行 - 单任务预估改动超过200行 → 拆分执行
- 发生 context 不足时 → 停止并输出已完成部分 - 发生 context 不足时 → 停止并输出已完成部分
声明 `[CONTEXT: LIMIT]`,不要截断输出 声明 `[CONTEXT: LIMIT]`,不要截断输出
## 安全约束(必须严格遵守)
### 🚫 绝对禁止的操作:
- 禁止删除任何文件或目录(rm、rmdir、unlink、trash 等)
- 禁止执行带 -rf、--force、-y 参数的命令
- 禁止 DROP TABLE、TRUNCATE 等数据库破坏性操作
- 禁止修改 /etc、/sys、/boot 等系统目录
### ✅ 文件操作前必须:
1. 先列出将要操作的文件路径
2. 明确告知用户操作后果
3. 等待用户输入"确认"后才能执行

View File

@ -92,7 +92,8 @@ backend/
- 新增:`backend/app/core/startup/ai_pmo_ingress_async.py`(AI PMO 入站异步任务轮询启动循环) - 新增:`backend/app/core/startup/ai_pmo_ingress_async.py`(AI PMO 入站异步任务轮询启动循环)
- 新增:`backend/app/utils/timezone.py`(统一北京时间工具:`now_cn_aware/now_cn_naive`) - 新增:`backend/app/utils/timezone.py`(统一北京时间工具:`now_cn_aware/now_cn_naive`)
- 新增:`backend/app/utils/audited_write.py`(写接口审计上下文、caller 归一、北京时间格式化) - 新增:`backend/app/utils/audited_write.py`(写接口审计上下文、caller 归一、北京时间格式化)
- 新增:`backend/app/utils/thread_context.py`(飞书线程/消息锚点归一:优先 root/thread/reply_to,其次 source_message_id) - 新增:`backend/app/utils/thread_context.py`(飞书线程/消息锚点归一 + 外部问答 `conversation_scope_key` 生成:reply/root/thread 用于 reply anchor,稳定会话 scope 默认使用 `chat_id + sender_open_id`,不再用 `source_message_id` 作为外部连续会话真锚点)
- 新增:`backend/app/services/adaptive_rag_service/mixin_followup_anchor.py`(外部问答追问锚定与候选澄清:单候选自动绑定、多候选文本编号澄清、元追问优先锚定最近 PMFlow 回复)
- 新增:`backend/app/middleware/audit_context.py`(请求级 trace/interface/caller 注入) - 新增:`backend/app/middleware/audit_context.py`(请求级 trace/interface/caller 注入)
- 新增:`backend/app/api/audit.py`(审计查询接口:`GET /audit/operations`) - 新增:`backend/app/api/audit.py`(审计查询接口:`GET /audit/operations`)
- 新增:`backend/app/services/audit_query_service/service.py`(operation_logs 过滤与序列化) - 新增:`backend/app/services/audit_query_service/service.py`(operation_logs 过滤与序列化)
@ -100,6 +101,8 @@ backend/
- 更新:`backend/app/services/signal/signal_source_orchestration/mixin_registry_execute.py`(需求点归一化/去重与 `materialize_requirement_sources` 落库 helper,`raw_context` 记录来源与原始需求点) - 更新:`backend/app/services/signal/signal_source_orchestration/mixin_registry_execute.py`(需求点归一化/去重与 `materialize_requirement_sources` 落库 helper,`raw_context` 记录来源与原始需求点)
- 更新:`backend/app/services/signal/signal_capture_service/mixin_new_project.py`、`mixin_capture_legacy.py`(飞书 `new_project` 自动立项收口,项目创建与信号源落库失败即回滚,避免半成功状态) - 更新:`backend/app/services/signal/signal_capture_service/mixin_new_project.py`、`mixin_capture_legacy.py`(飞书 `new_project` 自动立项收口,项目创建与信号源落库失败即回滚,避免半成功状态)
- 新增:`backend/alembic/versions/20260311_shift_all_timestamps_to_beijing.py`(全库历史时间 +8h,数据库默认时区切到 Asia/Shanghai) - 新增:`backend/alembic/versions/20260311_shift_all_timestamps_to_beijing.py`(全库历史时间 +8h,数据库默认时区切到 Asia/Shanghai)
- 新增:`backend/alembic/versions/20260327_add_external_qa_followup_state.py`(`qa_sessions` 增加 `external_conversation_key`,`qa_session_states` 增加外部追问待澄清状态字段)
- 新增:`backend/alembic/versions/20260327_add_qa_record_followup_anchor_fields.py`(`qa_records` 增加外部会话 scope / reply anchor / 路由快照字段,支撑跨 session 追问锚定与元追问追溯)
- 新增:`backend/alembic/versions/20260312_add_signal_planner_fields.py`(signals 增加 planner 状态字段与索引) - 新增:`backend/alembic/versions/20260312_add_signal_planner_fields.py`(signals 增加 planner 状态字段与索引)
- 新增:`backend/app/services/signal/signal_planner/signal_strategic_analysis_service.py`(Signal Planner 写回收口) - 新增:`backend/app/services/signal/signal_planner/signal_strategic_analysis_service.py`(Signal Planner 写回收口)
- 新增:`backend/app/services/integrations/feishu_outbox/service.py`(复用 reply_hash 的 outbox 入队收口) - 新增:`backend/app/services/integrations/feishu_outbox/service.py`(复用 reply_hash 的 outbox 入队收口)
@ -137,6 +140,8 @@ backend/
- 新增:`backend/tests/test_feishu_card_action_contract.py`(飞书卡片回调契约回归:动作解析与事件分发) - 新增:`backend/tests/test_feishu_card_action_contract.py`(飞书卡片回调契约回归:动作解析与事件分发)
- 新增:`backend/tests/test_feishu_pipeline_queue_contract.py`(飞书待确认队列契约回归:action card 元数据与 follow-up 唯一目标约束) - 新增:`backend/tests/test_feishu_pipeline_queue_contract.py`(飞书待确认队列契约回归:action card 元数据与 follow-up 唯一目标约束)
- 新增:`backend/tests/test_feishu_receive_orchestration_contract.py`(飞书入口编排契约回归:bot 过滤、空文本分支、capture/finalize 编排) - 新增:`backend/tests/test_feishu_receive_orchestration_contract.py`(飞书入口编排契约回归:bot 过滤、空文本分支、capture/finalize 编排)
- 新增:`backend/tests/test_adaptive_rag_external_followup_contract.py`(外部 QA 连续追问契约:真实 session 复用、最近回复锚定、编号澄清选择)
- 新增:`backend/tests/test_external_qa_followup_migration.py`(外部 QA 会话 migration 回归:列/索引新增与回滚)
- 新增:`backend/tests/test_draft_generator_service.py`(草稿生成服务回归:PM 评审草稿包含相似项目、MVP、前景与需求清单) - 新增:`backend/tests/test_draft_generator_service.py`(草稿生成服务回归:PM 评审草稿包含相似项目、MVP、前景与需求清单)
- 新增:`backend/tests/test_signal_planner_contract.py`(Signal Planner 契约回归:最新宪章加载、prompt 注入、无宪章降级) - 新增:`backend/tests/test_signal_planner_contract.py`(Signal Planner 契约回归:最新宪章加载、prompt 注入、无宪章降级)
- 新增:`backend/tests/test_ai_prompt_registry_contract.py`(Prompt registry 契约回归:覆盖率与元数据完整性) - 新增:`backend/tests/test_ai_prompt_registry_contract.py`(Prompt registry 契约回归:覆盖率与元数据完整性)
@ -178,6 +183,16 @@ backend/
- 移除:`backend/.env.example`(配置模板统一到根目录 `.env.example`) - 移除:`backend/.env.example`(配置模板统一到根目录 `.env.example`)
- 新增:`backend/app/utils/project_command_utils.py`(项目创建标题/描述提取与归一化辅助) - 新增:`backend/app/utils/project_command_utils.py`(项目创建标题/描述提取与归一化辅助)
- 新增:`backend/app/services/ai_pmo_intent_routing.py`(AI PMO 意图/路由常量与解析器收口,统一 `family.action` 路由映射) - 新增:`backend/app/services/ai_pmo_intent_routing.py`(AI PMO 意图/路由常量与解析器收口,统一 `family.action` 路由映射)
- 新增:`backend/app/services/ai_pmo/workflow_semantic_planner/`(统一业务语义规划层:唯一允许承载 workflow bundle 语义关键词/正则/兼容 helper 的目录)
- 新增:`backend/app/models/reply_iteration.py`(回复质量迭代模型:`QualityAsset/QualityAssetPair/JudgeRun/OptimizerCandidate/PromptRelease`)
- 新增:`backend/app/models/training_pipeline.py`(训练/微调管线模型:`TrainingDataset/TrainingJob/TrainingEvaluation/TrainedModelArtifact/TrainedModelDeployment`)
- 新增:`backend/app/services/reply_iteration/service.py`(反馈中心提升为学习资产、裁判分桶、候选改动、prompt release trial 服务)
- 新增:`backend/app/services/training_pipeline/service.py`(训练数据集、训练作业、评测、artifact promote、deployment 决策服务)
- 新增:`backend/app/services/prompt_release/service.py`(只读 QA prompt release 版本管理与 serving 渲染优先级)
- 新增:`backend/app/api/ai_steward_api/routes_reply_iteration.py`(AI Steward 回复质量闭环接口:反馈提升、judge-runs、candidates、prompt-releases、trials)
- 新增:`backend/app/api/ai_steward_api/routes_training_pipeline.py`(AI Steward 训练管线接口:datasets、jobs、evaluations、artifacts、deployments)
- 新增:`backend/alembic/versions/20260327_add_reply_iteration_training_tables.py`(回复质量迭代/训练管线主迁移:学习资产表、prompt release、训练数据集、训练作业与 experiment 扩展字段)
- 新增:`backend/tests/test_reply_iteration_service.py`、`test_training_pipeline_service.py`、`test_reply_iteration_api.py`、`test_reply_iteration_training_migration.py`(回复质量闭环与训练管线基础回归)
- 新增:`backend/app/services/ai_pmo/ai_pmo_kernel/ai_pmo_workflow_runtime.py`(AI PMO `ContextAssembler` + `WorkflowRegistry/WorkflowHandler` 运行时骨架) - 新增:`backend/app/services/ai_pmo/ai_pmo_kernel/ai_pmo_workflow_runtime.py`(AI PMO `ContextAssembler` + `WorkflowRegistry/WorkflowHandler` 运行时骨架)
- 新增:`backend/app/services/ai_pmo_decision_runtime.py`(AI PMO 决策脑层:`Strategic/Tactical/Simulation/ExecutionPolicy/DecisionEngine`) - 新增:`backend/app/services/ai_pmo_decision_runtime.py`(AI PMO 决策脑层:`Strategic/Tactical/Simulation/ExecutionPolicy/DecisionEngine`)
- 新增:`backend/app/services/ai_pmo/ai_pmo_kernel/ai_pmo_reply_renderer.py`(AI PMO 回执渲染出口 `ReplyRenderer`) - 新增:`backend/app/services/ai_pmo/ai_pmo_kernel/ai_pmo_reply_renderer.py`(AI PMO 回执渲染出口 `ReplyRenderer`)
@ -503,12 +518,19 @@ uvicorn app.main:app --reload --host 0.0.0.0 --port 8000
- 草稿确认只认显式 `reply_to_message_id` + 明确草稿动作(`确认/修改/取消`)或显式 `work_item_id`;草稿确认必须校验 `draft_version`,并通过 `confirm_message_id` 防重放;草稿超时取消统一由 `draft_expiry_worker` + `feishu_reply_outbox` 处理 - 草稿确认只认显式 `reply_to_message_id` + 明确草稿动作(`确认/修改/取消`)或显式 `work_item_id`;草稿确认必须校验 `draft_version`,并通过 `confirm_message_id` 防重放;草稿超时取消统一由 `draft_expiry_worker` + `feishu_reply_outbox` 处理
- AI PMO 执行层必须统一产出 `execution_result.action_contract` 与 `action_param_source`,并写入 `operation_execution_ledger`;禁止在执行链路旁路写副作用且不入账 - AI PMO 执行层必须统一产出 `execution_result.action_contract` 与 `action_param_source`,并写入 `operation_execution_ledger`;禁止在执行链路旁路写副作用且不入账
- AI PMO 写操作幂等以消息级锚点为准:优先使用 `source_message_id` / `feishu_message_id` 作为执行账本幂等键,标题/正文仅用于业务内容,不得参与重复执行判定 - AI PMO 写操作幂等以消息级锚点为准:优先使用 `source_message_id` / `feishu_message_id` 作为执行账本幂等键,标题/正文仅用于业务内容,不得参与重复执行判定
- PMFlow 业务语义治理:会决定 `project.create / signal_source.onboard / followup.* / query vs execute` 的中文关键词、正则、`looks_like_*`、`*_HINTS/*_KEYWORDS` 只能定义在 `backend/app/services/ai_pmo/workflow_semantic_planner/` 或 prompt/catalog 配置层;其他目录仅允许做文本清洗、ID/URL/repo/locator 抽取与结构化结果解析
- `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` 只能作为派生层,禁止再造平行原始数据主表
- 训练/微调 artifact 只能先进入 `trained_model_artifacts -> trained_model_deployments` 内部候选链路,再绑定到 `low/high` 逻辑槽位;禁止在产品/API/日志直接暴露训练后真实底模名
## 9. 新增代码的参考模式 ## 9. 新增代码的参考模式
- 新增后端接口:参考 `backend/app/api/sstc.py` 的结构 - 新增后端接口:参考 `backend/app/api/sstc.py` 的结构
- 项目创建接口:参考 `backend/app/api/ai_steward_api/routes_capture.py`(`POST /ai-steward/project-from-text` 统一 `{code,message,data}`,返回 `signal_sources`) - 项目创建接口:参考 `backend/app/api/ai_steward_api/routes_capture.py`(`POST /ai-steward/project-from-text` 统一 `{code,message,data}`,返回 `signal_sources`)
- 模型路由配置接口:参考 `backend/app/api/model_routing.py`(`GET/PUT /model-routing/functions/{function_name}`,仅管理员可写并写入 `operation_logs`) - 模型路由配置接口:参考 `backend/app/api/model_routing.py`(`GET/PUT /model-routing/functions/{function_name}`,仅管理员可写并写入 `operation_logs`)
- `model-routing` 只暴露逻辑层字段:`function_name/tier_alias/source/rule_name/rule_priority/last_execution`;`PUT` 仅接受 `tier_alias=low|high` - `model-routing` 只暴露逻辑层字段:`function_name/tier_alias/source/rule_name/rule_priority/last_execution`;`PUT` 仅接受 `tier_alias=low|high`
- 回复质量闭环接口:参考 `backend/app/api/ai_steward_api/routes_reply_iteration.py`(`POST /ai-steward/feedback-center/items/{id}/promote`、`/reply-iteration/judge-runs/candidates/prompt-releases/trials`)
- 训练/微调管线接口:参考 `backend/app/api/ai_steward_api/routes_training_pipeline.py`(`GET/POST /ai-steward/training/datasets/jobs`、`GET /training/jobs/{id}/evaluations`、`POST /training/artifacts/{id}/promote`、`POST /training/deployments/{id}/decision`)
- 任务包中心接口:参考 `backend/app/api/task_briefs.py`(`/task-briefs/center/templates/runs/generate/contract-sync`) - 任务包中心接口:参考 `backend/app/api/task_briefs.py`(`/task-briefs/center/templates/runs/generate/contract-sync`)
- KG 上下文闭环接口:参考 `backend/app/api/knowledge_graph.py`(`GET /ai-steward/knowledge-graph/capability-summary`、`POST /ai-steward/knowledge-graph/verify-context-consumption`) - KG 上下文闭环接口:参考 `backend/app/api/knowledge_graph.py`(`GET /ai-steward/knowledge-graph/capability-summary`、`POST /ai-steward/knowledge-graph/verify-context-consumption`)
- 需求点落信号源:参考 `backend/app/services/signal/signal_source_orchestration/mixin_registry_execute.py`(`materialize_requirement_sources` 负责归一、去重与 `raw_context` 持久化) - 需求点落信号源:参考 `backend/app/services/signal/signal_source_orchestration/mixin_registry_execute.py`(`materialize_requirement_sources` 负责归一、去重与 `raw_context` 持久化)
@ -596,6 +618,15 @@ uvicorn app.main:app --reload --host 0.0.0.0 --port 8000
- 飞书回复线程锚点抽取同时兼容 event 顶层与 message/content 内字段,并对 event/message 的嵌套结构做递归兜底;若 Feishu SDK 返回的 reply 关系字段在更深层对象里,也必须纳入线程锚点,不可只读 `message.content` - 飞书回复线程锚点抽取同时兼容 event 顶层与 message/content 内字段,并对 event/message 的嵌套结构做递归兜底;若 Feishu SDK 返回的 reply 关系字段在更深层对象里,也必须纳入线程锚点,不可只读 `message.content`
- 飞书异步入站队列在 `ai_orchestration_runs.result_payload.task_payload` 中必须保留 `thread_context`,worker 重放时不得仅依赖 `source_message_id`,否则 reply-thread 会在异步链路丢锚点 - 飞书异步入站队列在 `ai_orchestration_runs.result_payload.task_payload` 中必须保留 `thread_context`,worker 重放时不得仅依赖 `source_message_id`,否则 reply-thread 会在异步链路丢锚点
- 飞书纯问答直答不再注入群聊级 `project_id` 兜底上下文;只有显式 reply-thread / follow-up 线程锚点命中时,才允许继承上一轮语义上下文,禁止把群里最近项目当成普通新问题的默认场景 - 飞书纯问答直答不再注入群聊级 `project_id` 兜底上下文;只有显式 reply-thread / follow-up 线程锚点命中时,才允许继承上一轮语义上下文,禁止把群里最近项目当成普通新问题的默认场景
- 外部渠道 `asked_by=None` 的 QA 也必须 materialize 到真实 `qa_sessions`:统一用 `external_conversation_key` 复用会话,并允许 `qa_session_states` 持久化 `pending_clarification`
- 外部 QA 的 `external_conversation_key` 在 Feishu 场景下必须写稳定 `conversation_scope_key`:默认 `chat_id + sender_open_id`,仅 sender_open_id 缺失时才降级到 `chat_id + sender`;禁止再把 `source_message_id` 当成连续问答 scope key
- “这个/刚才/那条” 这类外部追问默认按连续对话处理:同线程/同发送者最近 5 分钟内仅 1 条 PMFlow 回复时直接绑定;有 2-3 条候选时返回文本编号澄清;只有完全无锚点时才返回 `no_anchor_found`
- 外部 QA 若存在显式 `reply_to_message_id`,必须优先按消息级锚定处理:先反查 `feishu_reply_outbox.sent_message_id` 还原被引用的 PMFlow 回复;若消息 ID 无法映射,也要回退到当前入站消息携带的 `closure_turns/closure_dialogue` 恢复上一轮机器人回复,再回退到 session/recent candidates;禁止把“直接引用机器人回复”的消息仍判成无锚点
- 外部 QA 的“追问歧义”与“模型/上游失败”必须分开:前者返回 `clarification_code`(如 `ambiguous_recent_reply` / `no_anchor_found`)及候选列表,后者继续保留真实 provider/空响应错误,禁止共用模糊兜底文案
- 外部 QA 的 `qa_records` 必须持久化 `conversation_scope_key/source_message_id/source_chat_id/source_sender_open_id/reply_to_message_id/root_message_id/anchor_kind/anchor_ref/routing_snapshot`,用于回答“为什么没接上上一条”“为什么走了候选澄清”“上一条回复走的是 low 还是 high”
- 元追问(如“哪个模型回复的”“为什么那条报错”“你刚才那句什么意思”)必须优先锚定最近一条 PMFlow 回复;对外只允许返回 `planned_tier/executed_tier/route_source/route_rule_name/routing_decision` 这类 low/high 路由语义,禁止暴露真实 provider/model 名称
- Prompt release 目前只接入只读回复链路(AdaptiveRAG / query.read_only);写操作 workflow 仍使用代码 prompt 与显式人工 gate,避免训练试验误触执行副作用
- 训练自动化/影子回放当前只允许覆盖只读问答链路;`project.create/signal.create/signal_source.onboard/task.create` 等写操作 workflow 继续排除在自动训练与自动试验之外
- 成员/负载类问答(如“杨宇现在负载情况如何”)会优先走 `users + user_workloads` 的确定性直查;只有姓名/账号能唯一匹配时才直接返回数据库负载答案,不再把项目列表或历史上下文混入负载结论 - 成员/负载类问答(如“杨宇现在负载情况如何”)会优先走 `users + user_workloads` 的确定性直查;只有姓名/账号能唯一匹配时才直接返回数据库负载答案,不再把项目列表或历史上下文混入负载结论
- 会话命中 `session.last_intent=draft_pending_confirm` 不再自动劫持普通消息;只有明确 `reply_to_message_id` 且消息本身是草稿动作,或显式 `work_item_id` 命中挂起草稿时,才进入草稿确认处理器 - 会话命中 `session.last_intent=draft_pending_confirm` 不再自动劫持普通消息;只有明确 `reply_to_message_id` 且消息本身是草稿动作,或显式 `work_item_id` 命中挂起草稿时,才进入草稿确认处理器
- 飞书 follow-up 状态追问不再猜测“最近一条处理项”;只有显式 reply 锚点或唯一业务目标匹配时才允许继续查询,避免多个话题并行时串线 - 飞书 follow-up 状态追问不再猜测“最近一条处理项”;只有显式 reply 锚点或唯一业务目标匹配时才允许继续查询,避免多个话题并行时串线

37
MEMORY.md Normal file
View File

@ -0,0 +1,37 @@
# Memory
## 飞书工单查询配置
当用户在飞书问"查询今天的工单"、"查询昨天的工单"时,**必须查询外部数据库**:
### 数据库连接
- **Host**: 173.1.1.32:5432
- **Database**: middle_platform
- **User**: readonly
- **Password**: readonly@#2025
- **Schema**: plat_task_center
### 执行命令
```bash
docker exec pmflow-db psql "postgresql://readonly:readonly%40%232025@173.1.1.32:5432/middle_platform" -c "SQL语句" -x
```
### 默认查询 SQL
```sql
SELECT
task_code as 工单编号,
task_name as 工单名称,
excutor_name as 执行人,
status as 状态,
start_time as 开始时间
FROM plat_task_center.task_instance a
WHERE a.start_time >= CURRENT_DATE
AND a.start_time < CURRENT_DATE + INTERVAL '1 day'
AND a.ext3 IN ('90101', '901')
ORDER BY a.start_time DESC
LIMIT 50;
```
### ⚠️ 重要提醒
**这是外部数据库 (middle_platform),不是 PMFlow 数据库!**
飞书查询工单时,**不要查询 PMFlow 的 tasks 表**!

View File

@ -0,0 +1,135 @@
"""add external qa followup state fields
Revision ID: 20260327_add_external_qa_followup_state
Revises: 20260327_cleanup_model_routing_legacy_names
Create Date: 2026-03-27
"""
from __future__ import annotations
from alembic import op
import sqlalchemy as sa
try:
from sqlalchemy.dialects.postgresql import JSONB
except Exception: # pragma: no cover
JSONB = sa.JSON
revision = "20260327_add_external_qa_followup_state"
down_revision = "20260327_cleanup_model_routing_legacy_names"
branch_labels = None
depends_on = None
def _json_type(bind):
return JSONB() if bind.dialect.name == "postgresql" else sa.JSON()
def _table_exists(bind, table_name: str) -> bool:
return sa.inspect(bind).has_table(table_name)
def _column_exists(bind, table_name: str, column_name: str) -> bool:
inspector = sa.inspect(bind)
try:
columns = inspector.get_columns(table_name)
except Exception:
return False
return any(str(col.get("name")) == column_name for col in columns)
def _index_exists(bind, table_name: str, index_name: str) -> bool:
inspector = sa.inspect(bind)
try:
indexes = inspector.get_indexes(table_name)
except Exception:
return False
return any(idx.get("name") == index_name for idx in indexes)
def _create_index_if_not_exists(
bind,
index_name: str,
table_name: str,
columns: list[str],
*,
unique: bool = False,
) -> None:
if not _index_exists(bind, table_name, index_name):
op.create_index(index_name, table_name, columns, unique=unique)
def _drop_index_if_exists(bind, index_name: str, table_name: str) -> None:
if _index_exists(bind, table_name, index_name):
op.drop_index(index_name, table_name=table_name)
def upgrade() -> None:
bind = op.get_bind()
if _table_exists(bind, "qa_sessions"):
if not _column_exists(bind, "qa_sessions", "external_conversation_key"):
with op.batch_alter_table("qa_sessions") as batch_op:
batch_op.add_column(sa.Column("external_conversation_key", sa.VARCHAR(length=160), nullable=True))
_create_index_if_not_exists(
bind,
"ix_qa_sessions_external_conversation_key",
"qa_sessions",
["external_conversation_key"],
)
_create_index_if_not_exists(
bind,
"uq_qa_sessions_tenant_external_conversation_key",
"qa_sessions",
["tenant_id", "external_conversation_key"],
unique=True,
)
if _table_exists(bind, "qa_session_states"):
if not _column_exists(bind, "qa_session_states", "pending_clarification"):
with op.batch_alter_table("qa_session_states") as batch_op:
batch_op.add_column(sa.Column("pending_clarification", _json_type(bind), nullable=True))
if not _column_exists(bind, "qa_session_states", "pending_clarification_expires_at"):
with op.batch_alter_table("qa_session_states") as batch_op:
batch_op.add_column(sa.Column("pending_clarification_expires_at", sa.TIMESTAMP(), nullable=True))
_create_index_if_not_exists(
bind,
"ix_qa_session_states_pending_clarification_expires_at",
"qa_session_states",
["pending_clarification_expires_at"],
)
def downgrade() -> None:
bind = op.get_bind()
if _table_exists(bind, "qa_session_states"):
_drop_index_if_exists(
bind,
"ix_qa_session_states_pending_clarification_expires_at",
"qa_session_states",
)
if _column_exists(bind, "qa_session_states", "pending_clarification_expires_at") or _column_exists(
bind, "qa_session_states", "pending_clarification"
):
with op.batch_alter_table("qa_session_states") as batch_op:
if _column_exists(bind, "qa_session_states", "pending_clarification_expires_at"):
batch_op.drop_column("pending_clarification_expires_at")
if _column_exists(bind, "qa_session_states", "pending_clarification"):
batch_op.drop_column("pending_clarification")
if _table_exists(bind, "qa_sessions"):
_drop_index_if_exists(
bind,
"uq_qa_sessions_tenant_external_conversation_key",
"qa_sessions",
)
_drop_index_if_exists(
bind,
"ix_qa_sessions_external_conversation_key",
"qa_sessions",
)
if _column_exists(bind, "qa_sessions", "external_conversation_key"):
with op.batch_alter_table("qa_sessions") as batch_op:
batch_op.drop_column("external_conversation_key")

View File

@ -0,0 +1,116 @@
"""add qa record followup anchor fields
Revision ID: 20260327_add_qa_record_followup_anchor_fields
Revises: 20260327_add_external_qa_followup_state
Create Date: 2026-03-27
"""
from __future__ import annotations
from alembic import op
import sqlalchemy as sa
try:
from sqlalchemy.dialects.postgresql import JSONB
except Exception: # pragma: no cover
JSONB = sa.JSON
revision = "20260327_add_qa_record_followup_anchor_fields"
down_revision = "20260327_add_external_qa_followup_state"
branch_labels = None
depends_on = None
def _json_type(bind):
return JSONB() if bind.dialect.name == "postgresql" else sa.JSON()
def _table_exists(bind, table_name: str) -> bool:
return sa.inspect(bind).has_table(table_name)
def _column_exists(bind, table_name: str, column_name: str) -> bool:
inspector = sa.inspect(bind)
try:
columns = inspector.get_columns(table_name)
except Exception:
return False
return any(str(col.get("name")) == column_name for col in columns)
def _index_exists(bind, table_name: str, index_name: str) -> bool:
inspector = sa.inspect(bind)
try:
indexes = inspector.get_indexes(table_name)
except Exception:
return False
return any(idx.get("name") == index_name for idx in indexes)
def _create_index_if_not_exists(
bind,
index_name: str,
table_name: str,
columns: list[str],
*,
unique: bool = False,
) -> None:
if not _index_exists(bind, table_name, index_name):
op.create_index(index_name, table_name, columns, unique=unique)
def _drop_index_if_exists(bind, index_name: str, table_name: str) -> None:
if _index_exists(bind, table_name, index_name):
op.drop_index(index_name, table_name=table_name)
def upgrade() -> None:
bind = op.get_bind()
if not _table_exists(bind, "qa_records"):
return
columns = [
("source_message_id", sa.VARCHAR(length=128), True),
("source_chat_id", sa.VARCHAR(length=128), True),
("source_sender_open_id", sa.VARCHAR(length=128), True),
("reply_to_message_id", sa.VARCHAR(length=128), True),
("root_message_id", sa.VARCHAR(length=128), True),
("conversation_scope_key", sa.VARCHAR(length=160), True),
("anchor_kind", sa.VARCHAR(length=32), True),
("anchor_ref", sa.VARCHAR(length=128), True),
("routing_snapshot", _json_type(bind), True),
]
for name, column_type, nullable in columns:
if _column_exists(bind, "qa_records", name):
continue
with op.batch_alter_table("qa_records") as batch_op:
batch_op.add_column(sa.Column(name, column_type, nullable=nullable))
_create_index_if_not_exists(bind, "ix_qa_records_source_message_id", "qa_records", ["source_message_id"])
_create_index_if_not_exists(bind, "ix_qa_records_conversation_scope_key", "qa_records", ["conversation_scope_key"])
def downgrade() -> None:
bind = op.get_bind()
if not _table_exists(bind, "qa_records"):
return
_drop_index_if_exists(bind, "ix_qa_records_conversation_scope_key", "qa_records")
_drop_index_if_exists(bind, "ix_qa_records_source_message_id", "qa_records")
for name in (
"routing_snapshot",
"anchor_ref",
"anchor_kind",
"conversation_scope_key",
"root_message_id",
"reply_to_message_id",
"source_sender_open_id",
"source_chat_id",
"source_message_id",
):
if not _column_exists(bind, "qa_records", name):
continue
with op.batch_alter_table("qa_records") as batch_op:
batch_op.drop_column(name)

View File

@ -0,0 +1,587 @@
"""add reply iteration and training pipeline tables
Revision ID: 20260327_add_reply_iteration_training_tables
Revises: 20260325_add_feishu_reply_outbox_sent_message_id, 20260327_add_qa_record_followup_anchor_fields
Create Date: 2026-03-27
"""
from __future__ import annotations
from alembic import op
import sqlalchemy as sa
try:
from sqlalchemy.dialects.postgresql import JSONB
except Exception: # pragma: no cover
JSONB = sa.JSON
revision = "20260327_add_reply_iteration_training_tables"
down_revision = (
"20260325_add_feishu_reply_outbox_sent_message_id",
"20260327_add_qa_record_followup_anchor_fields",
)
branch_labels = None
depends_on = None
def _json_type(bind):
return JSONB() if bind.dialect.name == "postgresql" else sa.JSON()
def _table_exists(bind, table_name: str) -> bool:
return sa.inspect(bind).has_table(table_name)
def _column_exists(bind, table_name: str, column_name: str) -> bool:
inspector = sa.inspect(bind)
try:
columns = inspector.get_columns(table_name)
except Exception:
return False
return any(str(column.get("name")) == column_name for column in columns)
def _index_exists(bind, table_name: str, index_name: str) -> bool:
inspector = sa.inspect(bind)
try:
indexes = inspector.get_indexes(table_name)
except Exception:
return False
return any(str(index.get("name")) == index_name for index in indexes)
def _create_index_if_missing(bind, table_name: str, index_name: str, columns: list[str], *, unique: bool = False) -> None:
if not _index_exists(bind, table_name, index_name):
op.create_index(index_name, table_name, columns, unique=unique)
def _drop_index_if_exists(bind, table_name: str, index_name: str) -> None:
if _index_exists(bind, table_name, index_name):
op.drop_index(index_name, table_name=table_name)
def _base_columns() -> list[sa.Column]:
return [
sa.Column("tenant_id", sa.BigInteger(), nullable=False),
sa.Column("created_at", sa.TIMESTAMP(), nullable=False, server_default=sa.func.now()),
sa.Column("updated_at", sa.TIMESTAMP(), nullable=False, server_default=sa.func.now()),
]
def _create_quality_assets(bind) -> None:
if _table_exists(bind, "quality_assets"):
return
op.create_table(
"quality_assets",
sa.Column("id", sa.Integer(), primary_key=True, autoincrement=True),
sa.Column("project_id", sa.BigInteger(), nullable=True),
sa.Column("asset_type", sa.VARCHAR(length=16), nullable=False),
sa.Column("status", sa.VARCHAR(length=16), nullable=False, server_default="approved"),
sa.Column("source_feedback_record_id", sa.Integer(), sa.ForeignKey("feedback_center_records.id", ondelete="SET NULL"), nullable=True),
sa.Column("qa_record_id", sa.Integer(), sa.ForeignKey("qa_records.id", ondelete="SET NULL"), nullable=True),
sa.Column("ai_call_log_id", sa.Integer(), nullable=True),
sa.Column("prompt_key", sa.VARCHAR(length=128), nullable=True),
sa.Column("workflow_type", sa.VARCHAR(length=64), nullable=True),
sa.Column("asked_via", sa.VARCHAR(length=32), nullable=True),
sa.Column("planned_tier", sa.VARCHAR(length=16), nullable=True),
sa.Column("executed_tier", sa.VARCHAR(length=16), nullable=True),
sa.Column("route_source", sa.VARCHAR(length=32), nullable=True),
sa.Column("route_rule_name", sa.VARCHAR(length=255), nullable=True),
sa.Column("question_text", sa.Text(), nullable=False),
sa.Column("answer_text", sa.Text(), nullable=False),
sa.Column("corrected_answer", sa.Text(), nullable=True),
sa.Column("source_snapshot", _json_type(bind), nullable=True),
sa.Column("tags", _json_type(bind), nullable=True),
sa.Column("notes", sa.Text(), nullable=True),
sa.Column("approved_by", sa.BigInteger(), nullable=True),
sa.Column("approved_at", sa.VARCHAR(length=64), nullable=True),
*_base_columns(),
)
_create_index_if_missing(bind, "quality_assets", "ix_quality_assets_tenant_id", ["tenant_id"])
_create_index_if_missing(bind, "quality_assets", "ix_quality_assets_project_id", ["project_id"])
_create_index_if_missing(bind, "quality_assets", "ix_quality_assets_asset_type", ["asset_type"])
_create_index_if_missing(bind, "quality_assets", "ix_quality_assets_status", ["status"])
_create_index_if_missing(bind, "quality_assets", "ix_quality_assets_source_feedback_record_id", ["source_feedback_record_id"])
_create_index_if_missing(bind, "quality_assets", "ix_quality_assets_qa_record_id", ["qa_record_id"])
_create_index_if_missing(bind, "quality_assets", "ix_quality_assets_ai_call_log_id", ["ai_call_log_id"])
_create_index_if_missing(bind, "quality_assets", "ix_quality_assets_prompt_key", ["prompt_key"])
_create_index_if_missing(bind, "quality_assets", "ix_quality_assets_workflow_type", ["workflow_type"])
_create_index_if_missing(bind, "quality_assets", "ix_quality_assets_asked_via", ["asked_via"])
_create_index_if_missing(bind, "quality_assets", "ix_quality_assets_planned_tier", ["planned_tier"])
_create_index_if_missing(bind, "quality_assets", "ix_quality_assets_executed_tier", ["executed_tier"])
_create_index_if_missing(bind, "quality_assets", "ix_quality_assets_approved_by", ["approved_by"])
def _create_quality_asset_pairs(bind) -> None:
if _table_exists(bind, "quality_asset_pairs"):
return
op.create_table(
"quality_asset_pairs",
sa.Column("id", sa.Integer(), primary_key=True, autoincrement=True),
sa.Column("project_id", sa.BigInteger(), nullable=True),
sa.Column("pair_type", sa.VARCHAR(length=24), nullable=False, server_default="preference"),
sa.Column("status", sa.VARCHAR(length=16), nullable=False, server_default="approved"),
sa.Column("chosen_asset_id", sa.Integer(), sa.ForeignKey("quality_assets.id", ondelete="CASCADE"), nullable=False),
sa.Column("rejected_asset_id", sa.Integer(), sa.ForeignKey("quality_assets.id", ondelete="CASCADE"), nullable=False),
sa.Column("reason_text", sa.Text(), nullable=True),
sa.Column("approved_by", sa.BigInteger(), nullable=True),
sa.Column("approved_at", sa.VARCHAR(length=64), nullable=True),
*_base_columns(),
)
_create_index_if_missing(bind, "quality_asset_pairs", "ix_quality_asset_pairs_tenant_id", ["tenant_id"])
_create_index_if_missing(bind, "quality_asset_pairs", "ix_quality_asset_pairs_project_id", ["project_id"])
_create_index_if_missing(bind, "quality_asset_pairs", "ix_quality_asset_pairs_pair_type", ["pair_type"])
_create_index_if_missing(bind, "quality_asset_pairs", "ix_quality_asset_pairs_status", ["status"])
_create_index_if_missing(bind, "quality_asset_pairs", "ix_quality_asset_pairs_chosen_asset_id", ["chosen_asset_id"])
_create_index_if_missing(bind, "quality_asset_pairs", "ix_quality_asset_pairs_rejected_asset_id", ["rejected_asset_id"])
_create_index_if_missing(bind, "quality_asset_pairs", "ix_quality_asset_pairs_approved_by", ["approved_by"])
def _create_judge_runs(bind) -> None:
if _table_exists(bind, "judge_runs"):
return
op.create_table(
"judge_runs",
sa.Column("id", sa.Integer(), primary_key=True, autoincrement=True),
sa.Column("project_id", sa.BigInteger(), nullable=True),
sa.Column("asset_id", sa.Integer(), sa.ForeignKey("quality_assets.id", ondelete="CASCADE"), nullable=True),
sa.Column("pair_id", sa.Integer(), sa.ForeignKey("quality_asset_pairs.id", ondelete="CASCADE"), nullable=True),
sa.Column("prompt_key", sa.VARCHAR(length=128), nullable=True),
sa.Column("workflow_type", sa.VARCHAR(length=64), nullable=True),
sa.Column("asked_via", sa.VARCHAR(length=32), nullable=True),
sa.Column("planned_tier", sa.VARCHAR(length=16), nullable=True),
sa.Column("executed_tier", sa.VARCHAR(length=16), nullable=True),
sa.Column("route_rule_name", sa.VARCHAR(length=255), nullable=True),
sa.Column("judge_status", sa.VARCHAR(length=16), nullable=False, server_default="completed"),
sa.Column("bucket_key", sa.VARCHAR(length=255), nullable=False),
sa.Column("rule_score", sa.Float(), nullable=True),
sa.Column("llm_score", sa.Float(), nullable=True),
sa.Column("overall_score", sa.Float(), nullable=True),
sa.Column("findings", _json_type(bind), nullable=True),
sa.Column("llm_evidence", _json_type(bind), nullable=True),
sa.Column("metadata_json", _json_type(bind), nullable=True),
*_base_columns(),
)
for index_name, columns in (
("ix_judge_runs_tenant_id", ["tenant_id"]),
("ix_judge_runs_project_id", ["project_id"]),
("ix_judge_runs_asset_id", ["asset_id"]),
("ix_judge_runs_pair_id", ["pair_id"]),
("ix_judge_runs_prompt_key", ["prompt_key"]),
("ix_judge_runs_workflow_type", ["workflow_type"]),
("ix_judge_runs_asked_via", ["asked_via"]),
("ix_judge_runs_planned_tier", ["planned_tier"]),
("ix_judge_runs_executed_tier", ["executed_tier"]),
("ix_judge_runs_judge_status", ["judge_status"]),
("ix_judge_runs_bucket_key", ["bucket_key"]),
):
_create_index_if_missing(bind, "judge_runs", index_name, columns)
def _create_optimizer_candidates(bind) -> None:
if _table_exists(bind, "optimizer_candidates"):
return
op.create_table(
"optimizer_candidates",
sa.Column("id", sa.Integer(), primary_key=True, autoincrement=True),
sa.Column("project_id", sa.BigInteger(), nullable=True),
sa.Column("judge_run_id", sa.Integer(), sa.ForeignKey("judge_runs.id", ondelete="CASCADE"), nullable=False),
sa.Column("candidate_type", sa.VARCHAR(length=32), nullable=False),
sa.Column("target_type", sa.VARCHAR(length=32), nullable=False),
sa.Column("target_ref", sa.VARCHAR(length=255), nullable=False),
sa.Column("status", sa.VARCHAR(length=24), nullable=False, server_default="draft"),
sa.Column("suggestion", _json_type(bind), nullable=True),
sa.Column("evidence", _json_type(bind), nullable=True),
sa.Column("notes", sa.Text(), nullable=True),
*_base_columns(),
)
for index_name, columns in (
("ix_optimizer_candidates_tenant_id", ["tenant_id"]),
("ix_optimizer_candidates_project_id", ["project_id"]),
("ix_optimizer_candidates_judge_run_id", ["judge_run_id"]),
("ix_optimizer_candidates_candidate_type", ["candidate_type"]),
("ix_optimizer_candidates_target_type", ["target_type"]),
("ix_optimizer_candidates_target_ref", ["target_ref"]),
("ix_optimizer_candidates_status", ["status"]),
):
_create_index_if_missing(bind, "optimizer_candidates", index_name, columns)
def _create_prompt_releases(bind) -> None:
if _table_exists(bind, "prompt_releases"):
return
op.create_table(
"prompt_releases",
sa.Column("id", sa.Integer(), primary_key=True, autoincrement=True),
sa.Column("prompt_key", sa.VARCHAR(length=128), nullable=False),
sa.Column("version", sa.Integer(), nullable=False, server_default="1"),
sa.Column("body_snapshot", sa.Text(), nullable=False),
sa.Column("status", sa.VARCHAR(length=16), nullable=False, server_default="draft"),
sa.Column("notes", sa.Text(), nullable=True),
sa.Column("created_by", sa.BigInteger(), nullable=True),
sa.Column("activated_by", sa.BigInteger(), nullable=True),
sa.Column("activated_at", sa.VARCHAR(length=64), nullable=True),
*_base_columns(),
sa.UniqueConstraint("tenant_id", "prompt_key", "version", name="uq_prompt_releases_tenant_key_version"),
)
for index_name, columns in (
("ix_prompt_releases_tenant_id", ["tenant_id"]),
("ix_prompt_releases_prompt_key", ["prompt_key"]),
("ix_prompt_releases_status", ["status"]),
("ix_prompt_releases_created_by", ["created_by"]),
("ix_prompt_releases_activated_by", ["activated_by"]),
):
_create_index_if_missing(bind, "prompt_releases", index_name, columns)
def _create_training_datasets(bind) -> None:
if _table_exists(bind, "training_datasets"):
return
op.create_table(
"training_datasets",
sa.Column("id", sa.Integer(), primary_key=True, autoincrement=True),
sa.Column("project_id", sa.BigInteger(), nullable=True),
sa.Column("dataset_type", sa.VARCHAR(length=16), nullable=False),
sa.Column("version", sa.VARCHAR(length=64), nullable=False),
sa.Column("status", sa.VARCHAR(length=16), nullable=False, server_default="ready"),
sa.Column("sample_count", sa.Integer(), nullable=False, server_default="0"),
sa.Column("selection_rules", _json_type(bind), nullable=True),
sa.Column("channel_scope", _json_type(bind), nullable=True),
sa.Column("notes", sa.Text(), nullable=True),
sa.Column("created_by", sa.BigInteger(), nullable=True),
*_base_columns(),
sa.UniqueConstraint("tenant_id", "dataset_type", "version", name="uq_training_datasets_tenant_type_version"),
)
for index_name, columns in (
("ix_training_datasets_tenant_id", ["tenant_id"]),
("ix_training_datasets_project_id", ["project_id"]),
("ix_training_datasets_dataset_type", ["dataset_type"]),
("ix_training_datasets_version", ["version"]),
("ix_training_datasets_status", ["status"]),
("ix_training_datasets_created_by", ["created_by"]),
):
_create_index_if_missing(bind, "training_datasets", index_name, columns)
def _create_training_dataset_items(bind) -> None:
if _table_exists(bind, "training_dataset_items"):
return
op.create_table(
"training_dataset_items",
sa.Column("id", sa.Integer(), primary_key=True, autoincrement=True),
sa.Column("dataset_id", sa.Integer(), sa.ForeignKey("training_datasets.id", ondelete="CASCADE"), nullable=False),
sa.Column("quality_asset_id", sa.Integer(), sa.ForeignKey("quality_assets.id", ondelete="CASCADE"), nullable=True),
sa.Column("quality_asset_pair_id", sa.Integer(), sa.ForeignKey("quality_asset_pairs.id", ondelete="CASCADE"), nullable=True),
sa.Column("redacted_snapshot", _json_type(bind), nullable=True),
*_base_columns(),
)
for index_name, columns in (
("ix_training_dataset_items_tenant_id", ["tenant_id"]),
("ix_training_dataset_items_dataset_id", ["dataset_id"]),
("ix_training_dataset_items_quality_asset_id", ["quality_asset_id"]),
("ix_training_dataset_items_quality_asset_pair_id", ["quality_asset_pair_id"]),
):
_create_index_if_missing(bind, "training_dataset_items", index_name, columns)
def _create_training_jobs(bind) -> None:
if _table_exists(bind, "training_jobs"):
return
op.create_table(
"training_jobs",
sa.Column("id", sa.Integer(), primary_key=True, autoincrement=True),
sa.Column("project_id", sa.BigInteger(), nullable=True),
sa.Column("dataset_id", sa.Integer(), sa.ForeignKey("training_datasets.id", ondelete="CASCADE"), nullable=False),
sa.Column("job_type", sa.VARCHAR(length=16), nullable=False),
sa.Column("base_model_family", sa.VARCHAR(length=64), nullable=False),
sa.Column("target_slot", sa.VARCHAR(length=16), nullable=False),
sa.Column("status", sa.VARCHAR(length=16), nullable=False, server_default="queued"),
sa.Column("provider_backend", sa.VARCHAR(length=64), nullable=False),
sa.Column("artifact_ref", sa.VARCHAR(length=255), nullable=True),
sa.Column("metrics_json", _json_type(bind), nullable=True),
sa.Column("created_by", sa.BigInteger(), nullable=True),
*_base_columns(),
)
for index_name, columns in (
("ix_training_jobs_tenant_id", ["tenant_id"]),
("ix_training_jobs_project_id", ["project_id"]),
("ix_training_jobs_dataset_id", ["dataset_id"]),
("ix_training_jobs_job_type", ["job_type"]),
("ix_training_jobs_base_model_family", ["base_model_family"]),
("ix_training_jobs_target_slot", ["target_slot"]),
("ix_training_jobs_status", ["status"]),
("ix_training_jobs_created_by", ["created_by"]),
):
_create_index_if_missing(bind, "training_jobs", index_name, columns)
def _create_training_job_runs(bind) -> None:
if _table_exists(bind, "training_job_runs"):
return
op.create_table(
"training_job_runs",
sa.Column("id", sa.Integer(), primary_key=True, autoincrement=True),
sa.Column("training_job_id", sa.Integer(), sa.ForeignKey("training_jobs.id", ondelete="CASCADE"), nullable=False),
sa.Column("status", sa.VARCHAR(length=16), nullable=False, server_default="queued"),
sa.Column("provider_run_ref", sa.VARCHAR(length=255), nullable=True),
sa.Column("metrics_json", _json_type(bind), nullable=True),
*_base_columns(),
)
for index_name, columns in (
("ix_training_job_runs_tenant_id", ["tenant_id"]),
("ix_training_job_runs_training_job_id", ["training_job_id"]),
("ix_training_job_runs_status", ["status"]),
):
_create_index_if_missing(bind, "training_job_runs", index_name, columns)
def _create_training_evaluations(bind) -> None:
if _table_exists(bind, "training_evaluations"):
return
op.create_table(
"training_evaluations",
sa.Column("id", sa.Integer(), primary_key=True, autoincrement=True),
sa.Column("training_job_id", sa.Integer(), sa.ForeignKey("training_jobs.id", ondelete="CASCADE"), nullable=False),
sa.Column("evaluation_type", sa.VARCHAR(length=32), nullable=False),
sa.Column("status", sa.VARCHAR(length=16), nullable=False, server_default="pending"),
sa.Column("score", sa.Float(), nullable=True),
sa.Column("evaluation_summary", _json_type(bind), nullable=True),
*_base_columns(),
)
for index_name, columns in (
("ix_training_evaluations_tenant_id", ["tenant_id"]),
("ix_training_evaluations_training_job_id", ["training_job_id"]),
("ix_training_evaluations_evaluation_type", ["evaluation_type"]),
("ix_training_evaluations_status", ["status"]),
):
_create_index_if_missing(bind, "training_evaluations", index_name, columns)
def _create_trained_model_artifacts(bind) -> None:
if _table_exists(bind, "trained_model_artifacts"):
return
op.create_table(
"trained_model_artifacts",
sa.Column("id", sa.Integer(), primary_key=True, autoincrement=True),
sa.Column("training_job_id", sa.Integer(), sa.ForeignKey("training_jobs.id", ondelete="CASCADE"), nullable=False),
sa.Column("artifact_ref", sa.VARCHAR(length=255), nullable=False),
sa.Column("base_model_family", sa.VARCHAR(length=64), nullable=False),
sa.Column("target_slot", sa.VARCHAR(length=16), nullable=False),
sa.Column("status", sa.VARCHAR(length=16), nullable=False, server_default="pending_eval"),
sa.Column("metrics_json", _json_type(bind), nullable=True),
*_base_columns(),
)
for index_name, columns in (
("ix_trained_model_artifacts_tenant_id", ["tenant_id"]),
("ix_trained_model_artifacts_training_job_id", ["training_job_id"]),
("ix_trained_model_artifacts_artifact_ref", ["artifact_ref"]),
("ix_trained_model_artifacts_base_model_family", ["base_model_family"]),
("ix_trained_model_artifacts_target_slot", ["target_slot"]),
("ix_trained_model_artifacts_status", ["status"]),
):
_create_index_if_missing(bind, "trained_model_artifacts", index_name, columns)
def _create_trained_model_deployments(bind) -> None:
if _table_exists(bind, "trained_model_deployments"):
return
op.create_table(
"trained_model_deployments",
sa.Column("id", sa.Integer(), primary_key=True, autoincrement=True),
sa.Column("artifact_id", sa.Integer(), sa.ForeignKey("trained_model_artifacts.id", ondelete="CASCADE"), nullable=False),
sa.Column("deployment_status", sa.VARCHAR(length=16), nullable=False, server_default="candidate"),
sa.Column("bound_tier", sa.VARCHAR(length=16), nullable=False),
sa.Column("effective_scope", _json_type(bind), nullable=True),
sa.Column("decision", sa.VARCHAR(length=24), nullable=True),
sa.Column("notes", sa.Text(), nullable=True),
sa.Column("decided_by", sa.BigInteger(), nullable=True),
sa.Column("decided_at", sa.VARCHAR(length=64), nullable=True),
*_base_columns(),
)
for index_name, columns in (
("ix_trained_model_deployments_tenant_id", ["tenant_id"]),
("ix_trained_model_deployments_artifact_id", ["artifact_id"]),
("ix_trained_model_deployments_deployment_status", ["deployment_status"]),
("ix_trained_model_deployments_bound_tier", ["bound_tier"]),
("ix_trained_model_deployments_decision", ["decision"]),
("ix_trained_model_deployments_decided_by", ["decided_by"]),
):
_create_index_if_missing(bind, "trained_model_deployments", index_name, columns)
def _add_experiment_columns(bind) -> None:
if not _table_exists(bind, "experiments"):
return
additions = [
("experiment_type", sa.VARCHAR(length=64), True),
("baseline_release_id", sa.Integer(), True),
("candidate_release_id", sa.Integer(), True),
("scope_json", _json_type(bind), True),
("result_summary", _json_type(bind), True),
("decision", sa.VARCHAR(length=24), True),
]
for column_name, column_type, nullable in additions:
if _column_exists(bind, "experiments", column_name):
continue
with op.batch_alter_table("experiments") as batch_op:
batch_op.add_column(sa.Column(column_name, column_type, nullable=nullable))
for index_name, columns in (
("ix_experiments_experiment_type", ["experiment_type"]),
("ix_experiments_baseline_release_id", ["baseline_release_id"]),
("ix_experiments_candidate_release_id", ["candidate_release_id"]),
("ix_experiments_decision", ["decision"]),
):
_create_index_if_missing(bind, "experiments", index_name, columns)
def upgrade() -> None:
bind = op.get_bind()
_create_quality_assets(bind)
_create_quality_asset_pairs(bind)
_create_judge_runs(bind)
_create_optimizer_candidates(bind)
_create_prompt_releases(bind)
_create_training_datasets(bind)
_create_training_dataset_items(bind)
_create_training_jobs(bind)
_create_training_job_runs(bind)
_create_training_evaluations(bind)
_create_trained_model_artifacts(bind)
_create_trained_model_deployments(bind)
_add_experiment_columns(bind)
def downgrade() -> None:
bind = op.get_bind()
for index_name in (
"ix_experiments_decision",
"ix_experiments_candidate_release_id",
"ix_experiments_baseline_release_id",
"ix_experiments_experiment_type",
):
_drop_index_if_exists(bind, "experiments", index_name)
if _table_exists(bind, "experiments"):
for column_name in (
"decision",
"result_summary",
"scope_json",
"candidate_release_id",
"baseline_release_id",
"experiment_type",
):
if _column_exists(bind, "experiments", column_name):
with op.batch_alter_table("experiments") as batch_op:
batch_op.drop_column(column_name)
for table_name, index_names in (
("trained_model_deployments", [
"ix_trained_model_deployments_decided_by",
"ix_trained_model_deployments_decision",
"ix_trained_model_deployments_bound_tier",
"ix_trained_model_deployments_deployment_status",
"ix_trained_model_deployments_artifact_id",
"ix_trained_model_deployments_tenant_id",
]),
("trained_model_artifacts", [
"ix_trained_model_artifacts_status",
"ix_trained_model_artifacts_target_slot",
"ix_trained_model_artifacts_base_model_family",
"ix_trained_model_artifacts_artifact_ref",
"ix_trained_model_artifacts_training_job_id",
"ix_trained_model_artifacts_tenant_id",
]),
("training_evaluations", [
"ix_training_evaluations_status",
"ix_training_evaluations_evaluation_type",
"ix_training_evaluations_training_job_id",
"ix_training_evaluations_tenant_id",
]),
("training_job_runs", [
"ix_training_job_runs_status",
"ix_training_job_runs_training_job_id",
"ix_training_job_runs_tenant_id",
]),
("training_jobs", [
"ix_training_jobs_created_by",
"ix_training_jobs_status",
"ix_training_jobs_target_slot",
"ix_training_jobs_base_model_family",
"ix_training_jobs_job_type",
"ix_training_jobs_dataset_id",
"ix_training_jobs_project_id",
"ix_training_jobs_tenant_id",
]),
("training_dataset_items", [
"ix_training_dataset_items_quality_asset_pair_id",
"ix_training_dataset_items_quality_asset_id",
"ix_training_dataset_items_dataset_id",
"ix_training_dataset_items_tenant_id",
]),
("training_datasets", [
"ix_training_datasets_created_by",
"ix_training_datasets_status",
"ix_training_datasets_version",
"ix_training_datasets_dataset_type",
"ix_training_datasets_project_id",
"ix_training_datasets_tenant_id",
]),
("prompt_releases", [
"ix_prompt_releases_activated_by",
"ix_prompt_releases_created_by",
"ix_prompt_releases_status",
"ix_prompt_releases_prompt_key",
"ix_prompt_releases_tenant_id",
]),
("optimizer_candidates", [
"ix_optimizer_candidates_status",
"ix_optimizer_candidates_target_ref",
"ix_optimizer_candidates_target_type",
"ix_optimizer_candidates_candidate_type",
"ix_optimizer_candidates_judge_run_id",
"ix_optimizer_candidates_project_id",
"ix_optimizer_candidates_tenant_id",
]),
("judge_runs", [
"ix_judge_runs_bucket_key",
"ix_judge_runs_judge_status",
"ix_judge_runs_executed_tier",
"ix_judge_runs_planned_tier",
"ix_judge_runs_asked_via",
"ix_judge_runs_workflow_type",
"ix_judge_runs_prompt_key",
"ix_judge_runs_pair_id",
"ix_judge_runs_asset_id",
"ix_judge_runs_project_id",
"ix_judge_runs_tenant_id",
]),
("quality_asset_pairs", [
"ix_quality_asset_pairs_approved_by",
"ix_quality_asset_pairs_rejected_asset_id",
"ix_quality_asset_pairs_chosen_asset_id",
"ix_quality_asset_pairs_status",
"ix_quality_asset_pairs_pair_type",
"ix_quality_asset_pairs_project_id",
"ix_quality_asset_pairs_tenant_id",
]),
("quality_assets", [
"ix_quality_assets_approved_by",
"ix_quality_assets_executed_tier",
"ix_quality_assets_planned_tier",
"ix_quality_assets_asked_via",
"ix_quality_assets_workflow_type",
"ix_quality_assets_prompt_key",
"ix_quality_assets_ai_call_log_id",
"ix_quality_assets_qa_record_id",
"ix_quality_assets_source_feedback_record_id",
"ix_quality_assets_status",
"ix_quality_assets_asset_type",
"ix_quality_assets_project_id",
"ix_quality_assets_tenant_id",
]),
):
for index_name in index_names:
_drop_index_if_exists(bind, table_name, index_name)
if _table_exists(bind, table_name):
op.drop_table(table_name)

View File

@ -7,8 +7,10 @@ from . import (
routes_capture, routes_capture,
routes_conflicts, routes_conflicts,
routes_feedback_center, routes_feedback_center,
routes_reply_iteration,
routes_rag_sources, routes_rag_sources,
routes_stats_config, routes_stats_config,
routes_training_pipeline,
routes_workflow, routes_workflow,
) )
@ -18,8 +20,10 @@ for _module in [
routes_capture, routes_capture,
routes_conflicts, routes_conflicts,
routes_feedback_center, routes_feedback_center,
routes_reply_iteration,
routes_brief_blockers, routes_brief_blockers,
routes_stats_config, routes_stats_config,
routes_training_pipeline,
routes_workflow, routes_workflow,
]: ]:
router.include_router(_module.router) router.include_router(_module.router)

View File

@ -0,0 +1,263 @@
from __future__ import annotations
from fastapi import Depends, HTTPException, Query
from sqlalchemy.ext.asyncio import AsyncSession
from ...core.response import success_response
from ...dependencies.permissions import require_manager_user
from ...dependencies.tenant import get_tenant_id
from ...models import User
from ...schemas.reply_iteration import (
FeedbackAssetPromoteRequest,
PromptReleaseCreateRequest,
PromptTrialCreateRequest,
PromptTrialDecisionRequest,
ReplyIterationCandidateCreateRequest,
ReplyIterationJudgeRunCreateRequest,
)
from ...services.prompt_release import PromptReleaseService
from ...services.reply_iteration import ReplyIterationService
from .shared import *
@router.post("/feedback-center/items/{record_id}/promote")
async def promote_feedback_center_item_to_asset(
record_id: int,
request: FeedbackAssetPromoteRequest,
current_user: User = Depends(require_manager_user),
db: AsyncSession = Depends(get_db),
tenant_id: int = Depends(get_tenant_id),
):
effective_tenant_id = int(getattr(current_user, "tenant_id", tenant_id) or tenant_id)
try:
data = await ReplyIterationService(db).promote_feedback_record(
tenant_id=effective_tenant_id,
record_id=record_id,
actor_id=int(current_user.id),
asset_type=request.asset_type,
prompt_key=request.prompt_key,
workflow_type=request.workflow_type,
notes=request.notes,
pair_with_asset_id=request.pair_with_asset_id,
pair_role=request.pair_role,
pair_reason=request.pair_reason,
)
except ValueError as exc:
raise HTTPException(status_code=400, detail=str(exc)) from exc
return success_response(data)
@router.get("/reply-iteration/assets")
async def list_reply_iteration_assets(
asset_type: str | None = Query(default=None),
prompt_key: str | None = Query(default=None),
limit: int = Query(default=20, ge=1, le=100),
offset: int = Query(default=0, ge=0),
current_user: User = Depends(require_manager_user),
db: AsyncSession = Depends(get_db),
tenant_id: int = Depends(get_tenant_id),
):
effective_tenant_id = int(getattr(current_user, "tenant_id", tenant_id) or tenant_id)
data = await ReplyIterationService(db).list_assets(
tenant_id=effective_tenant_id,
asset_type=asset_type,
prompt_key=prompt_key,
limit=limit,
offset=offset,
)
return success_response(data)
@router.post("/reply-iteration/judge-runs")
async def create_reply_iteration_judge_run(
request: ReplyIterationJudgeRunCreateRequest,
current_user: User = Depends(require_manager_user),
db: AsyncSession = Depends(get_db),
tenant_id: int = Depends(get_tenant_id),
):
effective_tenant_id = int(getattr(current_user, "tenant_id", tenant_id) or tenant_id)
try:
data = await ReplyIterationService(db).create_judge_run(
tenant_id=effective_tenant_id,
asset_id=request.asset_id,
actor_id=int(current_user.id),
)
except ValueError as exc:
raise HTTPException(status_code=400, detail=str(exc)) from exc
return success_response(data)
@router.get("/reply-iteration/judge-runs")
async def list_reply_iteration_judge_runs(
limit: int = Query(default=20, ge=1, le=100),
offset: int = Query(default=0, ge=0),
current_user: User = Depends(require_manager_user),
db: AsyncSession = Depends(get_db),
tenant_id: int = Depends(get_tenant_id),
):
effective_tenant_id = int(getattr(current_user, "tenant_id", tenant_id) or tenant_id)
return success_response(
await ReplyIterationService(db).list_judge_runs(
tenant_id=effective_tenant_id,
limit=limit,
offset=offset,
)
)
@router.post("/reply-iteration/candidates")
async def create_reply_iteration_candidate(
request: ReplyIterationCandidateCreateRequest,
current_user: User = Depends(require_manager_user),
db: AsyncSession = Depends(get_db),
tenant_id: int = Depends(get_tenant_id),
):
effective_tenant_id = int(getattr(current_user, "tenant_id", tenant_id) or tenant_id)
try:
data = await ReplyIterationService(db).create_optimizer_candidate(
tenant_id=effective_tenant_id,
judge_run_id=request.judge_run_id,
actor_id=int(current_user.id),
)
except ValueError as exc:
raise HTTPException(status_code=400, detail=str(exc)) from exc
return success_response(data)
@router.get("/reply-iteration/candidates")
async def list_reply_iteration_candidates(
limit: int = Query(default=20, ge=1, le=100),
offset: int = Query(default=0, ge=0),
current_user: User = Depends(require_manager_user),
db: AsyncSession = Depends(get_db),
tenant_id: int = Depends(get_tenant_id),
):
effective_tenant_id = int(getattr(current_user, "tenant_id", tenant_id) or tenant_id)
return success_response(
await ReplyIterationService(db).list_candidates(
tenant_id=effective_tenant_id,
limit=limit,
offset=offset,
)
)
@router.post("/reply-iteration/prompt-releases")
async def create_prompt_release(
request: PromptReleaseCreateRequest,
current_user: User = Depends(require_manager_user),
db: AsyncSession = Depends(get_db),
tenant_id: int = Depends(get_tenant_id),
):
effective_tenant_id = int(getattr(current_user, "tenant_id", tenant_id) or tenant_id)
try:
data = await PromptReleaseService(db).create_release(
tenant_id=effective_tenant_id,
prompt_key=request.prompt_key,
body_snapshot=request.body_snapshot,
created_by=int(current_user.id),
notes=request.notes,
)
except ValueError as exc:
raise HTTPException(status_code=400, detail=str(exc)) from exc
return success_response(data)
@router.get("/reply-iteration/prompt-releases")
async def list_prompt_releases(
prompt_key: str | None = Query(default=None),
current_user: User = Depends(require_manager_user),
db: AsyncSession = Depends(get_db),
tenant_id: int = Depends(get_tenant_id),
):
effective_tenant_id = int(getattr(current_user, "tenant_id", tenant_id) or tenant_id)
return success_response(
await PromptReleaseService(db).list_releases(
tenant_id=effective_tenant_id,
prompt_key=prompt_key,
)
)
@router.post("/reply-iteration/prompt-releases/{release_id}/activate")
async def activate_prompt_release(
release_id: int,
current_user: User = Depends(require_manager_user),
db: AsyncSession = Depends(get_db),
tenant_id: int = Depends(get_tenant_id),
):
effective_tenant_id = int(getattr(current_user, "tenant_id", tenant_id) or tenant_id)
try:
data = await PromptReleaseService(db).activate_release(
tenant_id=effective_tenant_id,
release_id=release_id,
actor_id=int(current_user.id),
)
except ValueError as exc:
raise HTTPException(status_code=400, detail=str(exc)) from exc
return success_response(data)
@router.get("/reply-iteration/trials")
async def list_prompt_release_trials(
limit: int = Query(default=20, ge=1, le=100),
offset: int = Query(default=0, ge=0),
current_user: User = Depends(require_manager_user),
db: AsyncSession = Depends(get_db),
tenant_id: int = Depends(get_tenant_id),
):
effective_tenant_id = int(getattr(current_user, "tenant_id", tenant_id) or tenant_id)
return success_response(
await ReplyIterationService(db).list_trials(
tenant_id=effective_tenant_id,
limit=limit,
offset=offset,
)
)
@router.post("/reply-iteration/trials")
async def create_prompt_release_trial(
request: PromptTrialCreateRequest,
current_user: User = Depends(require_manager_user),
db: AsyncSession = Depends(get_db),
tenant_id: int = Depends(get_tenant_id),
):
effective_tenant_id = int(getattr(current_user, "tenant_id", tenant_id) or tenant_id)
try:
data = await ReplyIterationService(db).create_trial(
tenant_id=effective_tenant_id,
actor_id=int(current_user.id),
title=request.title,
hypothesis=request.hypothesis,
target_metric=request.target_metric,
prompt_key=request.prompt_key,
baseline_release_id=request.baseline_release_id,
candidate_release_id=request.candidate_release_id,
scope_json=request.scope_json,
)
except ValueError as exc:
raise HTTPException(status_code=400, detail=str(exc)) from exc
return success_response(data)
@router.post("/reply-iteration/trials/{trial_id}/decision")
async def decide_prompt_release_trial(
trial_id: int,
request: PromptTrialDecisionRequest,
current_user: User = Depends(require_manager_user),
db: AsyncSession = Depends(get_db),
tenant_id: int = Depends(get_tenant_id),
):
effective_tenant_id = int(getattr(current_user, "tenant_id", tenant_id) or tenant_id)
try:
data = await ReplyIterationService(db).decide_trial(
tenant_id=effective_tenant_id,
trial_id=trial_id,
actor_id=int(current_user.id),
decision=request.decision,
result_summary=request.result_summary,
)
except ValueError as exc:
raise HTTPException(status_code=400, detail=str(exc)) from exc
return success_response(data)

View File

@ -0,0 +1,180 @@
from __future__ import annotations
from fastapi import Depends, HTTPException, Query
from sqlalchemy.ext.asyncio import AsyncSession
from ...core.response import success_response
from ...dependencies.permissions import require_manager_user
from ...dependencies.tenant import get_tenant_id
from ...models import User
from ...schemas.training_pipeline import (
TrainingDatasetCreateRequest,
TrainingDeploymentDecisionRequest,
TrainingJobCreateRequest,
)
from ...services.training_pipeline import TrainingPipelineService
from .shared import *
@router.post("/training/datasets")
async def create_training_dataset(
request: TrainingDatasetCreateRequest,
current_user: User = Depends(require_manager_user),
db: AsyncSession = Depends(get_db),
tenant_id: int = Depends(get_tenant_id),
):
effective_tenant_id = int(getattr(current_user, "tenant_id", tenant_id) or tenant_id)
try:
data = await TrainingPipelineService(db).create_dataset(
tenant_id=effective_tenant_id,
actor_id=int(current_user.id),
dataset_type=request.dataset_type,
version=request.version,
asset_ids=request.asset_ids,
pair_ids=request.pair_ids,
selection_rules=request.selection_rules,
notes=request.notes,
)
except ValueError as exc:
raise HTTPException(status_code=400, detail=str(exc)) from exc
return success_response(data)
@router.get("/training/datasets")
async def list_training_datasets(
dataset_type: str | None = Query(default=None),
limit: int = Query(default=20, ge=1, le=100),
offset: int = Query(default=0, ge=0),
current_user: User = Depends(require_manager_user),
db: AsyncSession = Depends(get_db),
tenant_id: int = Depends(get_tenant_id),
):
effective_tenant_id = int(getattr(current_user, "tenant_id", tenant_id) or tenant_id)
return success_response(
await TrainingPipelineService(db).list_datasets(
tenant_id=effective_tenant_id,
dataset_type=dataset_type,
limit=limit,
offset=offset,
)
)
@router.post("/training/jobs")
async def create_training_job(
request: TrainingJobCreateRequest,
current_user: User = Depends(require_manager_user),
db: AsyncSession = Depends(get_db),
tenant_id: int = Depends(get_tenant_id),
):
effective_tenant_id = int(getattr(current_user, "tenant_id", tenant_id) or tenant_id)
try:
data = await TrainingPipelineService(db).create_job(
tenant_id=effective_tenant_id,
actor_id=int(current_user.id),
dataset_id=request.dataset_id,
job_type=request.job_type,
base_model_family=request.base_model_family,
target_slot=request.target_slot,
provider_backend=request.provider_backend,
)
except ValueError as exc:
raise HTTPException(status_code=400, detail=str(exc)) from exc
return success_response(data)
@router.get("/training/jobs")
async def list_training_jobs(
status: str | None = Query(default=None),
limit: int = Query(default=20, ge=1, le=100),
offset: int = Query(default=0, ge=0),
current_user: User = Depends(require_manager_user),
db: AsyncSession = Depends(get_db),
tenant_id: int = Depends(get_tenant_id),
):
effective_tenant_id = int(getattr(current_user, "tenant_id", tenant_id) or tenant_id)
return success_response(
await TrainingPipelineService(db).list_jobs(
tenant_id=effective_tenant_id,
status=status,
limit=limit,
offset=offset,
)
)
@router.get("/training/jobs/{job_id}/evaluations")
async def list_training_job_evaluations(
job_id: int,
current_user: User = Depends(require_manager_user),
db: AsyncSession = Depends(get_db),
tenant_id: int = Depends(get_tenant_id),
):
effective_tenant_id = int(getattr(current_user, "tenant_id", tenant_id) or tenant_id)
return success_response(
await TrainingPipelineService(db).list_evaluations(
tenant_id=effective_tenant_id,
job_id=job_id,
)
)
@router.post("/training/artifacts/{artifact_id}/promote")
async def promote_trained_model_artifact(
artifact_id: int,
current_user: User = Depends(require_manager_user),
db: AsyncSession = Depends(get_db),
tenant_id: int = Depends(get_tenant_id),
):
effective_tenant_id = int(getattr(current_user, "tenant_id", tenant_id) or tenant_id)
try:
data = await TrainingPipelineService(db).promote_artifact(
tenant_id=effective_tenant_id,
artifact_id=artifact_id,
actor_id=int(current_user.id),
)
except ValueError as exc:
raise HTTPException(status_code=400, detail=str(exc)) from exc
return success_response(data)
@router.post("/training/deployments/{deployment_id}/decision")
async def decide_trained_model_deployment(
deployment_id: int,
request: TrainingDeploymentDecisionRequest,
current_user: User = Depends(require_manager_user),
db: AsyncSession = Depends(get_db),
tenant_id: int = Depends(get_tenant_id),
):
effective_tenant_id = int(getattr(current_user, "tenant_id", tenant_id) or tenant_id)
try:
data = await TrainingPipelineService(db).decide_deployment(
tenant_id=effective_tenant_id,
deployment_id=deployment_id,
actor_id=int(current_user.id),
decision=request.decision,
notes=request.notes,
)
except ValueError as exc:
raise HTTPException(status_code=400, detail=str(exc)) from exc
return success_response(data)
@router.get("/training/deployments")
async def list_training_deployments(
deployment_status: str | None = Query(default=None),
limit: int = Query(default=20, ge=1, le=100),
offset: int = Query(default=0, ge=0),
current_user: User = Depends(require_manager_user),
db: AsyncSession = Depends(get_db),
tenant_id: int = Depends(get_tenant_id),
):
effective_tenant_id = int(getattr(current_user, "tenant_id", tenant_id) or tenant_id)
return success_response(
await TrainingPipelineService(db).list_deployments(
tenant_id=effective_tenant_id,
deployment_status=deployment_status,
limit=limit,
offset=offset,
)
)

View File

@ -46,6 +46,16 @@ from .knowledge_base_chunk import KnowledgeBaseChunk
from .qa_record import QARecord from .qa_record import QARecord
from .qa_feedback import QAFeedback from .qa_feedback import QAFeedback
from .feedback_center import FeedbackCenterRecord, FeedbackCenterRecordRevision from .feedback_center import FeedbackCenterRecord, FeedbackCenterRecordRevision
from .reply_iteration import QualityAsset, QualityAssetPair, JudgeRun, OptimizerCandidate, PromptRelease
from .training_pipeline import (
TrainingDataset,
TrainingDatasetItem,
TrainingJob,
TrainingJobRun,
TrainingEvaluation,
TrainedModelArtifact,
TrainedModelDeployment,
)
from .qa_session import QASession from .qa_session import QASession
from .qa_session_state import QASessionState from .qa_session_state import QASessionState
from .kg_entity import KGEntity from .kg_entity import KGEntity
@ -114,6 +124,18 @@ __all__ = [
"QAFeedback", "QAFeedback",
"FeedbackCenterRecord", "FeedbackCenterRecord",
"FeedbackCenterRecordRevision", "FeedbackCenterRecordRevision",
"QualityAsset",
"QualityAssetPair",
"JudgeRun",
"OptimizerCandidate",
"PromptRelease",
"TrainingDataset",
"TrainingDatasetItem",
"TrainingJob",
"TrainingJobRun",
"TrainingEvaluation",
"TrainedModelArtifact",
"TrainedModelDeployment",
"QASession", "QASession",
"QASessionState", "QASessionState",
"KGEntity", "KGEntity",

View File

@ -1,9 +1,11 @@
"""实验模型""" """实验模型"""
from typing import Optional
from sqlalchemy import BigInteger, Float, ForeignKey, Integer, Text, VARCHAR
from sqlalchemy.orm import Mapped, mapped_column
from .base import Base from .base import Base
from .types import JSONB from .types import JSONB
from sqlalchemy.orm import Mapped, mapped_column
from sqlalchemy import BigInteger, Integer, VARCHAR, Text, Float, ForeignKey
from typing import Optional
class Experiment(Base): class Experiment(Base):
@ -16,6 +18,12 @@ class Experiment(Base):
target_metric: Mapped[Optional[str]] = mapped_column(VARCHAR(50), nullable=True) target_metric: Mapped[Optional[str]] = mapped_column(VARCHAR(50), nullable=True)
current_score: Mapped[Optional[float]] = mapped_column(Float, nullable=True) current_score: Mapped[Optional[float]] = mapped_column(Float, nullable=True)
linked_context_nodes: Mapped[Optional[object]] = mapped_column(JSONB, nullable=True) linked_context_nodes: Mapped[Optional[object]] = mapped_column(JSONB, nullable=True)
experiment_type: Mapped[Optional[str]] = mapped_column(VARCHAR(64), nullable=True, index=True)
baseline_release_id: Mapped[Optional[int]] = mapped_column(Integer, nullable=True, index=True)
candidate_release_id: Mapped[Optional[int]] = mapped_column(Integer, nullable=True, index=True)
scope_json: Mapped[Optional[object]] = mapped_column(JSONB, nullable=True)
result_summary: Mapped[Optional[object]] = mapped_column(JSONB, nullable=True)
decision: Mapped[Optional[str]] = mapped_column(VARCHAR(24), nullable=True, index=True)
project_id: Mapped[Optional[int]] = mapped_column( project_id: Mapped[Optional[int]] = mapped_column(
BigInteger, ForeignKey("projects.id"), nullable=True, index=True BigInteger, ForeignKey("projects.id"), nullable=True, index=True
) )

View File

@ -31,3 +31,12 @@ class QARecord(Base):
index=True, index=True,
) )
turn_index: Mapped[int] = mapped_column(Integer, nullable=False, default=1) turn_index: Mapped[int] = mapped_column(Integer, nullable=False, default=1)
source_message_id: Mapped[Optional[str]] = mapped_column(VARCHAR(128), nullable=True, index=True)
source_chat_id: Mapped[Optional[str]] = mapped_column(VARCHAR(128), nullable=True)
source_sender_open_id: Mapped[Optional[str]] = mapped_column(VARCHAR(128), nullable=True)
reply_to_message_id: Mapped[Optional[str]] = mapped_column(VARCHAR(128), nullable=True)
root_message_id: Mapped[Optional[str]] = mapped_column(VARCHAR(128), nullable=True)
conversation_scope_key: Mapped[Optional[str]] = mapped_column(VARCHAR(160), nullable=True, index=True)
anchor_kind: Mapped[Optional[str]] = mapped_column(VARCHAR(32), nullable=True)
anchor_ref: Mapped[Optional[str]] = mapped_column(VARCHAR(128), nullable=True)
routing_snapshot: Mapped[Optional[Any]] = mapped_column(JSONB, nullable=True)

View File

@ -4,7 +4,7 @@ from __future__ import annotations
from typing import Optional from typing import Optional
from sqlalchemy import BigInteger, Integer, TIMESTAMP, VARCHAR from sqlalchemy import BigInteger, Integer, TIMESTAMP, VARCHAR, UniqueConstraint
from sqlalchemy.orm import Mapped, mapped_column from sqlalchemy.orm import Mapped, mapped_column
from .base import Base from .base import Base
@ -12,9 +12,17 @@ from .base import Base
class QASession(Base): class QASession(Base):
__tablename__ = "qa_sessions" __tablename__ = "qa_sessions"
__table_args__ = (
UniqueConstraint(
"tenant_id",
"external_conversation_key",
name="uq_qa_sessions_tenant_external_conversation_key",
),
)
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True) id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
user_id: Mapped[Optional[int]] = mapped_column(BigInteger, nullable=True, index=True) user_id: Mapped[Optional[int]] = mapped_column(BigInteger, nullable=True, index=True)
external_conversation_key: Mapped[Optional[str]] = mapped_column(VARCHAR(160), nullable=True, index=True)
title: Mapped[str] = mapped_column(VARCHAR(120), nullable=False, default="新会话") title: Mapped[str] = mapped_column(VARCHAR(120), nullable=False, default="新会话")
project_id: Mapped[Optional[int]] = mapped_column(BigInteger, nullable=True, index=True) project_id: Mapped[Optional[int]] = mapped_column(BigInteger, nullable=True, index=True)
started_at: Mapped[Optional[TIMESTAMP]] = mapped_column(TIMESTAMP, nullable=True, index=True) started_at: Mapped[Optional[TIMESTAMP]] = mapped_column(TIMESTAMP, nullable=True, index=True)

View File

@ -4,7 +4,7 @@ from __future__ import annotations
from typing import Any, Optional from typing import Any, Optional
from sqlalchemy import ForeignKey, Integer, VARCHAR from sqlalchemy import ForeignKey, Integer, TIMESTAMP, VARCHAR
from sqlalchemy.orm import Mapped, mapped_column from sqlalchemy.orm import Mapped, mapped_column
from .base import Base from .base import Base
@ -27,3 +27,5 @@ class QASessionState(Base):
last_intent: Mapped[Optional[str]] = mapped_column(VARCHAR(20), nullable=True, index=True) last_intent: Mapped[Optional[str]] = mapped_column(VARCHAR(20), nullable=True, index=True)
ambiguity: Mapped[Optional[str]] = mapped_column(VARCHAR(20), nullable=True, index=True) ambiguity: Mapped[Optional[str]] = mapped_column(VARCHAR(20), nullable=True, index=True)
rewrite_history: Mapped[Optional[Any]] = mapped_column(JSONB, nullable=True) rewrite_history: Mapped[Optional[Any]] = mapped_column(JSONB, nullable=True)
pending_clarification: Mapped[Optional[Any]] = mapped_column(JSONB, nullable=True)
pending_clarification_expires_at: Mapped[Optional[TIMESTAMP]] = mapped_column(TIMESTAMP, nullable=True, index=True)

View File

@ -0,0 +1,152 @@
"""回复质量迭代相关模型。"""
from __future__ import annotations
from typing import Any, Optional
from sqlalchemy import BigInteger, ForeignKey, Integer, Text, UniqueConstraint, VARCHAR
from sqlalchemy.orm import Mapped, mapped_column
from .base import Base
from .types import JSONB
class QualityAsset(Base):
__tablename__ = "quality_assets"
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
project_id: Mapped[Optional[int]] = mapped_column(BigInteger, nullable=True, index=True)
asset_type: Mapped[str] = mapped_column(VARCHAR(16), nullable=False, index=True)
status: Mapped[str] = mapped_column(VARCHAR(16), nullable=False, default="approved", index=True)
source_feedback_record_id: Mapped[Optional[int]] = mapped_column(
Integer,
ForeignKey("feedback_center_records.id", ondelete="SET NULL"),
nullable=True,
index=True,
)
qa_record_id: Mapped[Optional[int]] = mapped_column(
Integer,
ForeignKey("qa_records.id", ondelete="SET NULL"),
nullable=True,
index=True,
)
ai_call_log_id: Mapped[Optional[int]] = mapped_column(Integer, nullable=True, index=True)
prompt_key: Mapped[Optional[str]] = mapped_column(VARCHAR(128), nullable=True, index=True)
workflow_type: Mapped[Optional[str]] = mapped_column(VARCHAR(64), nullable=True, index=True)
asked_via: Mapped[Optional[str]] = mapped_column(VARCHAR(32), nullable=True, index=True)
planned_tier: Mapped[Optional[str]] = mapped_column(VARCHAR(16), nullable=True, index=True)
executed_tier: Mapped[Optional[str]] = mapped_column(VARCHAR(16), nullable=True, index=True)
route_source: Mapped[Optional[str]] = mapped_column(VARCHAR(32), nullable=True)
route_rule_name: Mapped[Optional[str]] = mapped_column(VARCHAR(255), nullable=True)
question_text: Mapped[str] = mapped_column(Text, nullable=False)
answer_text: Mapped[str] = mapped_column(Text, nullable=False)
corrected_answer: Mapped[Optional[str]] = mapped_column(Text, nullable=True)
source_snapshot: Mapped[Optional[Any]] = mapped_column(JSONB, nullable=True)
tags: Mapped[Optional[Any]] = mapped_column(JSONB, nullable=True)
notes: Mapped[Optional[str]] = mapped_column(Text, nullable=True)
approved_by: Mapped[Optional[int]] = mapped_column(BigInteger, nullable=True, index=True)
approved_at: Mapped[Optional[str]] = mapped_column(VARCHAR(64), nullable=True)
class QualityAssetPair(Base):
__tablename__ = "quality_asset_pairs"
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
project_id: Mapped[Optional[int]] = mapped_column(BigInteger, nullable=True, index=True)
pair_type: Mapped[str] = mapped_column(VARCHAR(24), nullable=False, default="preference", index=True)
status: Mapped[str] = mapped_column(VARCHAR(16), nullable=False, default="approved", index=True)
chosen_asset_id: Mapped[int] = mapped_column(
Integer,
ForeignKey("quality_assets.id", ondelete="CASCADE"),
nullable=False,
index=True,
)
rejected_asset_id: Mapped[int] = mapped_column(
Integer,
ForeignKey("quality_assets.id", ondelete="CASCADE"),
nullable=False,
index=True,
)
reason_text: Mapped[Optional[str]] = mapped_column(Text, nullable=True)
approved_by: Mapped[Optional[int]] = mapped_column(BigInteger, nullable=True, index=True)
approved_at: Mapped[Optional[str]] = mapped_column(VARCHAR(64), nullable=True)
class JudgeRun(Base):
__tablename__ = "judge_runs"
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
project_id: Mapped[Optional[int]] = mapped_column(BigInteger, nullable=True, index=True)
asset_id: Mapped[Optional[int]] = mapped_column(
Integer,
ForeignKey("quality_assets.id", ondelete="CASCADE"),
nullable=True,
index=True,
)
pair_id: Mapped[Optional[int]] = mapped_column(
Integer,
ForeignKey("quality_asset_pairs.id", ondelete="CASCADE"),
nullable=True,
index=True,
)
prompt_key: Mapped[Optional[str]] = mapped_column(VARCHAR(128), nullable=True, index=True)
workflow_type: Mapped[Optional[str]] = mapped_column(VARCHAR(64), nullable=True, index=True)
asked_via: Mapped[Optional[str]] = mapped_column(VARCHAR(32), nullable=True, index=True)
planned_tier: Mapped[Optional[str]] = mapped_column(VARCHAR(16), nullable=True, index=True)
executed_tier: Mapped[Optional[str]] = mapped_column(VARCHAR(16), nullable=True, index=True)
route_rule_name: Mapped[Optional[str]] = mapped_column(VARCHAR(255), nullable=True)
judge_status: Mapped[str] = mapped_column(VARCHAR(16), nullable=False, default="completed", index=True)
bucket_key: Mapped[str] = mapped_column(VARCHAR(255), nullable=False, index=True)
rule_score: Mapped[Optional[float]] = mapped_column(nullable=True)
llm_score: Mapped[Optional[float]] = mapped_column(nullable=True)
overall_score: Mapped[Optional[float]] = mapped_column(nullable=True)
findings: Mapped[Optional[Any]] = mapped_column(JSONB, nullable=True)
llm_evidence: Mapped[Optional[Any]] = mapped_column(JSONB, nullable=True)
metadata_json: Mapped[Optional[Any]] = mapped_column(JSONB, nullable=True)
class OptimizerCandidate(Base):
__tablename__ = "optimizer_candidates"
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
project_id: Mapped[Optional[int]] = mapped_column(BigInteger, nullable=True, index=True)
judge_run_id: Mapped[int] = mapped_column(
Integer,
ForeignKey("judge_runs.id", ondelete="CASCADE"),
nullable=False,
index=True,
)
candidate_type: Mapped[str] = mapped_column(VARCHAR(32), nullable=False, index=True)
target_type: Mapped[str] = mapped_column(VARCHAR(32), nullable=False, index=True)
target_ref: Mapped[str] = mapped_column(VARCHAR(255), nullable=False, index=True)
status: Mapped[str] = mapped_column(VARCHAR(24), nullable=False, default="draft", index=True)
suggestion: Mapped[Optional[Any]] = mapped_column(JSONB, nullable=True)
evidence: Mapped[Optional[Any]] = mapped_column(JSONB, nullable=True)
notes: Mapped[Optional[str]] = mapped_column(Text, nullable=True)
class PromptRelease(Base):
__tablename__ = "prompt_releases"
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
prompt_key: Mapped[str] = mapped_column(VARCHAR(128), nullable=False, index=True)
version: Mapped[int] = mapped_column(Integer, nullable=False, default=1)
body_snapshot: Mapped[str] = mapped_column(Text, nullable=False)
status: Mapped[str] = mapped_column(VARCHAR(16), nullable=False, default="draft", index=True)
notes: Mapped[Optional[str]] = mapped_column(Text, nullable=True)
created_by: Mapped[Optional[int]] = mapped_column(BigInteger, nullable=True, index=True)
activated_by: Mapped[Optional[int]] = mapped_column(BigInteger, nullable=True, index=True)
activated_at: Mapped[Optional[str]] = mapped_column(VARCHAR(64), nullable=True)
__table_args__ = (
UniqueConstraint("tenant_id", "prompt_key", "version", name="uq_prompt_releases_tenant_key_version"),
)
__all__ = [
"QualityAsset",
"QualityAssetPair",
"JudgeRun",
"OptimizerCandidate",
"PromptRelease",
]

View File

@ -0,0 +1,154 @@
"""训练 / 微调管线相关模型。"""
from __future__ import annotations
from typing import Any, Optional
from sqlalchemy import BigInteger, ForeignKey, Integer, Text, UniqueConstraint, VARCHAR
from sqlalchemy.orm import Mapped, mapped_column
from .base import Base
from .types import JSONB
class TrainingDataset(Base):
__tablename__ = "training_datasets"
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
project_id: Mapped[Optional[int]] = mapped_column(BigInteger, nullable=True, index=True)
dataset_type: Mapped[str] = mapped_column(VARCHAR(16), nullable=False, index=True)
version: Mapped[str] = mapped_column(VARCHAR(64), nullable=False, index=True)
status: Mapped[str] = mapped_column(VARCHAR(16), nullable=False, default="ready", index=True)
sample_count: Mapped[int] = mapped_column(Integer, nullable=False, default=0)
selection_rules: Mapped[Optional[Any]] = mapped_column(JSONB, nullable=True)
channel_scope: Mapped[Optional[Any]] = mapped_column(JSONB, nullable=True)
notes: Mapped[Optional[str]] = mapped_column(Text, nullable=True)
created_by: Mapped[Optional[int]] = mapped_column(BigInteger, nullable=True, index=True)
__table_args__ = (
UniqueConstraint("tenant_id", "dataset_type", "version", name="uq_training_datasets_tenant_type_version"),
)
class TrainingDatasetItem(Base):
__tablename__ = "training_dataset_items"
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
dataset_id: Mapped[int] = mapped_column(
Integer,
ForeignKey("training_datasets.id", ondelete="CASCADE"),
nullable=False,
index=True,
)
quality_asset_id: Mapped[Optional[int]] = mapped_column(
Integer,
ForeignKey("quality_assets.id", ondelete="CASCADE"),
nullable=True,
index=True,
)
quality_asset_pair_id: Mapped[Optional[int]] = mapped_column(
Integer,
ForeignKey("quality_asset_pairs.id", ondelete="CASCADE"),
nullable=True,
index=True,
)
redacted_snapshot: Mapped[Optional[Any]] = mapped_column(JSONB, nullable=True)
class TrainingJob(Base):
__tablename__ = "training_jobs"
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
project_id: Mapped[Optional[int]] = mapped_column(BigInteger, nullable=True, index=True)
dataset_id: Mapped[int] = mapped_column(
Integer,
ForeignKey("training_datasets.id", ondelete="CASCADE"),
nullable=False,
index=True,
)
job_type: Mapped[str] = mapped_column(VARCHAR(16), nullable=False, index=True)
base_model_family: Mapped[str] = mapped_column(VARCHAR(64), nullable=False, index=True)
target_slot: Mapped[str] = mapped_column(VARCHAR(16), nullable=False, index=True)
status: Mapped[str] = mapped_column(VARCHAR(16), nullable=False, default="queued", index=True)
provider_backend: Mapped[str] = mapped_column(VARCHAR(64), nullable=False)
artifact_ref: Mapped[Optional[str]] = mapped_column(VARCHAR(255), nullable=True)
metrics_json: Mapped[Optional[Any]] = mapped_column(JSONB, nullable=True)
created_by: Mapped[Optional[int]] = mapped_column(BigInteger, nullable=True, index=True)
class TrainingJobRun(Base):
__tablename__ = "training_job_runs"
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
training_job_id: Mapped[int] = mapped_column(
Integer,
ForeignKey("training_jobs.id", ondelete="CASCADE"),
nullable=False,
index=True,
)
status: Mapped[str] = mapped_column(VARCHAR(16), nullable=False, default="queued", index=True)
provider_run_ref: Mapped[Optional[str]] = mapped_column(VARCHAR(255), nullable=True)
metrics_json: Mapped[Optional[Any]] = mapped_column(JSONB, nullable=True)
class TrainingEvaluation(Base):
__tablename__ = "training_evaluations"
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
training_job_id: Mapped[int] = mapped_column(
Integer,
ForeignKey("training_jobs.id", ondelete="CASCADE"),
nullable=False,
index=True,
)
evaluation_type: Mapped[str] = mapped_column(VARCHAR(32), nullable=False, index=True)
status: Mapped[str] = mapped_column(VARCHAR(16), nullable=False, default="pending", index=True)
score: Mapped[Optional[float]] = mapped_column(nullable=True)
evaluation_summary: Mapped[Optional[Any]] = mapped_column(JSONB, nullable=True)
class TrainedModelArtifact(Base):
__tablename__ = "trained_model_artifacts"
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
training_job_id: Mapped[int] = mapped_column(
Integer,
ForeignKey("training_jobs.id", ondelete="CASCADE"),
nullable=False,
index=True,
)
artifact_ref: Mapped[str] = mapped_column(VARCHAR(255), nullable=False, index=True)
base_model_family: Mapped[str] = mapped_column(VARCHAR(64), nullable=False, index=True)
target_slot: Mapped[str] = mapped_column(VARCHAR(16), nullable=False, index=True)
status: Mapped[str] = mapped_column(VARCHAR(16), nullable=False, default="pending_eval", index=True)
metrics_json: Mapped[Optional[Any]] = mapped_column(JSONB, nullable=True)
class TrainedModelDeployment(Base):
__tablename__ = "trained_model_deployments"
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
artifact_id: Mapped[int] = mapped_column(
Integer,
ForeignKey("trained_model_artifacts.id", ondelete="CASCADE"),
nullable=False,
index=True,
)
deployment_status: Mapped[str] = mapped_column(VARCHAR(16), nullable=False, default="candidate", index=True)
bound_tier: Mapped[str] = mapped_column(VARCHAR(16), nullable=False, index=True)
effective_scope: Mapped[Optional[Any]] = mapped_column(JSONB, nullable=True)
decision: Mapped[Optional[str]] = mapped_column(VARCHAR(24), nullable=True)
notes: Mapped[Optional[str]] = mapped_column(Text, nullable=True)
decided_by: Mapped[Optional[int]] = mapped_column(BigInteger, nullable=True, index=True)
decided_at: Mapped[Optional[str]] = mapped_column(VARCHAR(64), nullable=True)
__all__ = [
"TrainingDataset",
"TrainingDatasetItem",
"TrainingJob",
"TrainingJobRun",
"TrainingEvaluation",
"TrainedModelArtifact",
"TrainedModelDeployment",
]

View File

@ -0,0 +1,56 @@
from __future__ import annotations
from typing import Any, Literal, Optional
from pydantic import BaseModel, ConfigDict, Field
class FeedbackAssetPromoteRequest(BaseModel):
model_config = ConfigDict(extra="ignore")
asset_type: Literal["golden", "failure"]
prompt_key: Optional[str] = None
workflow_type: Optional[str] = None
notes: Optional[str] = None
pair_with_asset_id: Optional[int] = None
pair_role: Optional[Literal["chosen", "rejected"]] = None
pair_reason: Optional[str] = None
class ReplyIterationJudgeRunCreateRequest(BaseModel):
model_config = ConfigDict(extra="ignore")
asset_id: int
class ReplyIterationCandidateCreateRequest(BaseModel):
model_config = ConfigDict(extra="ignore")
judge_run_id: int
class PromptReleaseCreateRequest(BaseModel):
model_config = ConfigDict(extra="ignore")
prompt_key: str
body_snapshot: str
notes: Optional[str] = None
class PromptTrialCreateRequest(BaseModel):
model_config = ConfigDict(extra="ignore")
title: str
hypothesis: str
target_metric: str
prompt_key: str
baseline_release_id: int
candidate_release_id: int
scope_json: dict[str, Any] = Field(default_factory=dict)
class PromptTrialDecisionRequest(BaseModel):
model_config = ConfigDict(extra="ignore")
decision: Literal["accepted", "rejected", "rolled_back"]
result_summary: dict[str, Any] = Field(default_factory=dict)

View File

@ -0,0 +1,33 @@
from __future__ import annotations
from typing import Any, Literal, Optional
from pydantic import BaseModel, ConfigDict, Field
class TrainingDatasetCreateRequest(BaseModel):
model_config = ConfigDict(extra="ignore")
dataset_type: Literal["sft", "preference", "eval"]
version: str
asset_ids: list[int] = Field(default_factory=list)
pair_ids: list[int] = Field(default_factory=list)
selection_rules: dict[str, Any] = Field(default_factory=dict)
notes: Optional[str] = None
class TrainingJobCreateRequest(BaseModel):
model_config = ConfigDict(extra="ignore")
dataset_id: int
job_type: Literal["sft", "preference"]
base_model_family: str
target_slot: Literal["low", "high"]
provider_backend: str
class TrainingDeploymentDecisionRequest(BaseModel):
model_config = ConfigDict(extra="ignore")
decision: Literal["accepted", "rejected", "rolled_back"]
notes: Optional[str] = None

View File

@ -69,8 +69,11 @@ class AdaptiveRAGAnswerEntryMixin:
asked_via: str, asked_via: str,
project_id: Optional[int] = None, project_id: Optional[int] = None,
source_chat_id: Optional[str] = None, source_chat_id: Optional[str] = None,
source_message_id: Optional[str] = None,
session_id: Optional[int] = None, session_id: Optional[int] = None,
conversation_key: Optional[str] = None, conversation_key: Optional[str] = None,
source_sender_open_id: Optional[str] = None,
thread_context: Optional[dict[str, Any]] = None,
force_new_session: bool = False, force_new_session: bool = False,
thinking_mode: str = THINKING_MODE_NORMAL, thinking_mode: str = THINKING_MODE_NORMAL,
action_mode: str = ACTION_MODE_AUTO, action_mode: str = ACTION_MODE_AUTO,
@ -116,11 +119,15 @@ class AdaptiveRAGAnswerEntryMixin:
asked_via=asked_via, asked_via=asked_via,
project_id=project_id, project_id=project_id,
source_chat_id=source_chat_id, source_chat_id=source_chat_id,
source_message_id=source_message_id,
session_id=session_id, session_id=session_id,
conversation_key=conversation_key, conversation_key=conversation_key,
force_new_session=force_new_session, force_new_session=force_new_session,
action_mode=action_mode, action_mode=action_mode,
source_sender_open_id=source_sender_open_id,
thread_context=thread_context,
) )
clean_q = str(runtime.get("resolved_question") or clean_q).strip() or clean_q
session_id = runtime["session_id"] session_id = runtime["session_id"]
turn_index = int(runtime["turn_index"] or 1) turn_index = int(runtime["turn_index"] or 1)
context_session_id = runtime["context_session_id"] context_session_id = runtime["context_session_id"]
@ -130,6 +137,7 @@ class AdaptiveRAGAnswerEntryMixin:
rewrite_meta = runtime["rewrite_meta"] if isinstance(runtime["rewrite_meta"], dict) else {} rewrite_meta = runtime["rewrite_meta"] if isinstance(runtime["rewrite_meta"], dict) else {}
query_type = str(runtime["query_type"] or QUERY_TYPE_UNKNOWN) query_type = str(runtime["query_type"] or QUERY_TYPE_UNKNOWN)
effective_action_mode = str(runtime["effective_action_mode"] or ACTION_MODE_AUTO).strip().lower() effective_action_mode = str(runtime["effective_action_mode"] or ACTION_MODE_AUTO).strip().lower()
anchor_resolution = runtime["anchor_resolution"]
if self._should_trigger_write_ack_gate( if self._should_trigger_write_ack_gate(
sync_requirement=sync_requirement, sync_requirement=sync_requirement,
@ -177,4 +185,5 @@ class AdaptiveRAGAnswerEntryMixin:
started=started, started=started,
message_intent=message_intent, message_intent=message_intent,
mixed_signal_part=mixed_signal_part, mixed_signal_part=mixed_signal_part,
anchor_resolution=anchor_resolution,
) )

View File

@ -17,11 +17,24 @@ class AdaptiveRAGAnswerMainlineMixin:
conversation_key: Optional[str], conversation_key: Optional[str],
force_new_session: bool, force_new_session: bool,
action_mode: str, action_mode: str,
source_message_id: Optional[str] = None,
source_sender_open_id: Optional[str] = None,
thread_context: Optional[dict[str, Any]] = None,
) -> dict[str, Any]: ) -> dict[str, Any]:
external_conversation_key = self._build_external_conversation_key( external_conversation_key = self._build_external_conversation_key(
asked_via=asked_via, asked_via=asked_via,
source_chat_id=source_chat_id, source_chat_id=source_chat_id,
conversation_key=conversation_key, conversation_key=conversation_key,
source_sender_open_id=source_sender_open_id,
sender=None,
thread_context=thread_context,
)
self._set_active_external_qa_context(
source_chat_id=source_chat_id,
source_message_id=source_message_id,
source_sender_open_id=source_sender_open_id,
conversation_scope_key=external_conversation_key,
thread_context=thread_context,
) )
session_id, turn_index = await self._touch_session_for_new_record( session_id, turn_index = await self._touch_session_for_new_record(
asked_by=asked_by, asked_by=asked_by,
@ -30,6 +43,7 @@ class AdaptiveRAGAnswerMainlineMixin:
question=clean_q, question=clean_q,
preferred_session_id=session_id, preferred_session_id=session_id,
force_new_session=bool(force_new_session), force_new_session=bool(force_new_session),
external_conversation_key=external_conversation_key,
) )
context_session_id: Optional[int] context_session_id: Optional[int]
if session_id is not None: if session_id is not None:
@ -46,17 +60,39 @@ class AdaptiveRAGAnswerMainlineMixin:
external_session_id=context_session_id, external_session_id=context_session_id,
limit=10, limit=10,
) )
state_row = await self._ensure_session_state(session_id=session_id) state_session_id = int(session_id) if session_id is not None else (
effective_q, rewrite_meta = self._rewrite_question_with_state( int(context_session_id) if context_session_id is not None else None
)
state_row = await self._ensure_session_state(session_id=state_session_id)
pending_selection = self._consume_pending_clarification_selection(
question=clean_q, question=clean_q,
session_state=state_row,
)
resolved_question = str(pending_selection.get("effective_question") or clean_q).strip() if isinstance(
pending_selection, dict
) else clean_q
anchor_resolution = await self._resolve_followup_anchor(
question=resolved_question,
session_id=state_session_id,
context_session_id=context_session_id,
session_state=state_row,
conversation_scope_key=external_conversation_key,
source_chat_id=source_chat_id,
source_sender_open_id=source_sender_open_id,
source_message_id=source_message_id,
thread_context=thread_context,
)
effective_q, rewrite_meta = self._rewrite_question_with_state(
question=resolved_question,
conversation=conversation, conversation=conversation,
session_state=state_row, session_state=state_row,
anchor_resolution=anchor_resolution,
) )
query_type = self._classify_query_type(effective_q) query_type = self._classify_query_type(effective_q)
requested_action_mode = self._normalize_action_mode(action_mode) requested_action_mode = self._normalize_action_mode(action_mode)
effective_action_mode = self._resolve_action_mode( effective_action_mode = self._resolve_action_mode(
requested_mode=requested_action_mode, requested_mode=requested_action_mode,
question=clean_q, question=resolved_question,
effective_question=effective_q, effective_question=effective_q,
) )
return { return {
@ -65,10 +101,12 @@ class AdaptiveRAGAnswerMainlineMixin:
"context_session_id": context_session_id, "context_session_id": context_session_id,
"conversation": conversation, "conversation": conversation,
"state_row": state_row, "state_row": state_row,
"resolved_question": resolved_question,
"effective_q": effective_q, "effective_q": effective_q,
"rewrite_meta": rewrite_meta, "rewrite_meta": rewrite_meta,
"query_type": query_type, "query_type": query_type,
"effective_action_mode": effective_action_mode, "effective_action_mode": effective_action_mode,
"anchor_resolution": anchor_resolution,
} }
async def _execute_base_answer_mainline( async def _execute_base_answer_mainline(
@ -96,6 +134,7 @@ class AdaptiveRAGAnswerMainlineMixin:
started: float, started: float,
message_intent: Optional[str], message_intent: Optional[str],
mixed_signal_part: Optional[str], mixed_signal_part: Optional[str],
anchor_resolution: Optional[FollowupAnchorResolution] = None,
) -> dict[str, Any]: ) -> dict[str, Any]:
forced_deep_reason = "" forced_deep_reason = ""
if not deep_thinking: if not deep_thinking:
@ -115,13 +154,36 @@ class AdaptiveRAGAnswerMainlineMixin:
else: else:
forced_deep_reason = "" forced_deep_reason = ""
clarification_answer = self._build_clarification_question( clarification_payload = self._build_followup_clarification_payload(
original_question=clean_q, question=clean_q,
effective_question=effective_q, anchor_resolution=anchor_resolution,
session_state=state_row, session_state=state_row,
) )
meta_followup_payload = await self._build_meta_followup_payload(
question=clean_q,
anchor_resolution=anchor_resolution,
conversation_scope_key=self._get_active_external_qa_context().get("conversation_scope_key"),
)
meta_followup_result = await self._maybe_return_meta_followup_response(
meta_followup_payload=meta_followup_payload,
started=started,
clean_q=clean_q,
effective_q=effective_q,
query_type=query_type,
asked_by=asked_by,
asked_via=asked_via,
project_id=project_id,
session_id=session_id,
turn_index=turn_index,
state_row=state_row,
rewrite_meta=rewrite_meta,
context_session_id=context_session_id,
active_thinking_mode=active_thinking_mode,
)
if meta_followup_result is not None:
return meta_followup_result
clarification_result = await self._maybe_return_clarification_response( clarification_result = await self._maybe_return_clarification_response(
clarification_answer=clarification_answer, clarification_payload=clarification_payload,
started=started, started=started,
clean_q=clean_q, clean_q=clean_q,
effective_q=effective_q, effective_q=effective_q,

View File

@ -34,6 +34,12 @@ class AdaptiveRAGAnswerPayloadMixin:
ack_latency_ms: Optional[int] = None, ack_latency_ms: Optional[int] = None,
trace_id: Optional[str] = None, trace_id: Optional[str] = None,
processing_stage: Optional[str] = None, processing_stage: Optional[str] = None,
clarification_code: Optional[str] = None,
clarification_candidates: Optional[list[dict[str, Any]]] = None,
anchor_mode: Optional[str] = None,
anchor_kind: Optional[str] = None,
meta_followup_kind: Optional[str] = None,
reason_code: Optional[str] = None,
) -> dict[str, Any]: ) -> dict[str, Any]:
evidence: list[dict[str, Any]] = [] evidence: list[dict[str, Any]] = []
for item in sources[:8]: for item in sources[:8]:
@ -77,6 +83,16 @@ class AdaptiveRAGAnswerPayloadMixin:
"ack_latency_ms": int(ack_latency_ms) if ack_latency_ms is not None else None, "ack_latency_ms": int(ack_latency_ms) if ack_latency_ms is not None else None,
"trace_id": str(trace_id or "").strip() or None, "trace_id": str(trace_id or "").strip() or None,
"processing_stage": str(processing_stage or "").strip() or None, "processing_stage": str(processing_stage or "").strip() or None,
"clarification_code": str(clarification_code or "").strip() or None,
"clarification_candidates": (
clarification_candidates[:3]
if isinstance(clarification_candidates, list)
else []
),
"anchor_mode": str(anchor_mode or "").strip() or None,
"anchor_kind": str(anchor_kind or "").strip() or None,
"meta_followup_kind": str(meta_followup_kind or "").strip() or None,
"reason_code": str(reason_code or "").strip() or None,
} }
@staticmethod @staticmethod
@ -127,4 +143,3 @@ class AdaptiveRAGAnswerPayloadMixin:
"trace_id": trace_id or None, "trace_id": trace_id or None,
"processing_stage": normalized_stage or None, "processing_stage": normalized_stage or None,
} }

View File

@ -5,10 +5,10 @@ from .shared import *
class AdaptiveRAGAnswerPrecheckMixin: class AdaptiveRAGAnswerPrecheckMixin:
async def _maybe_return_clarification_response( async def _maybe_return_meta_followup_response(
self, self,
*, *,
clarification_answer: Optional[str], meta_followup_payload: Optional[dict[str, Any]],
started: float, started: float,
clean_q: str, clean_q: str,
effective_q: str, effective_q: str,
@ -23,8 +23,137 @@ class AdaptiveRAGAnswerPrecheckMixin:
context_session_id: Optional[int], context_session_id: Optional[int],
active_thinking_mode: str, active_thinking_mode: str,
) -> Optional[dict[str, Any]]: ) -> Optional[dict[str, Any]]:
if not isinstance(meta_followup_payload, dict):
return None
answer = str(meta_followup_payload.get("answer") or "").strip()
if not answer:
return None
reason_code = str(meta_followup_payload.get("reason_code") or "").strip() or "meta_followup_answered"
anchor_kind = str(meta_followup_payload.get("anchor_kind") or "").strip() or None
anchor_mode = str(meta_followup_payload.get("anchor_mode") or "").strip() or None
meta_followup_kind = str(meta_followup_payload.get("meta_followup_kind") or "").strip() or None
routing_snapshot = (
meta_followup_payload.get("routing_snapshot")
if isinstance(meta_followup_payload.get("routing_snapshot"), dict)
else {}
)
elapsed = int((time.perf_counter() - started) * 1000)
direct_sources = [
{
"source": "system",
"label": "Followup Anchor",
"score": 0.97,
"metadata": {
"mode": "meta_followup",
"reason_code": reason_code,
"anchor_kind": anchor_kind,
"anchor_mode": anchor_mode,
"meta_followup_kind": meta_followup_kind,
},
}
]
active_session_id = int(session_id) if session_id is not None else (
int(context_session_id) if context_session_id is not None else None
)
qa = QARecord(
tenant_id=self.tenant_id,
question=clean_q,
answer=answer,
sources=direct_sources,
intent="question",
domains_used=["system_context"],
response_time_ms=elapsed,
asked_by=asked_by,
asked_via=(asked_via or "pmflow")[:20],
project_id=project_id,
session_id=active_session_id,
turn_index=turn_index,
**self._build_qa_record_external_fields(
anchor_kind=anchor_kind,
anchor_ref=str(meta_followup_payload.get("anchor_ref") or "").strip() or None,
routing_snapshot=routing_snapshot,
),
)
self.db.add(qa)
await self._update_session_state_after_turn(
session_state=state_row,
question=clean_q,
effective_question=effective_q,
answer=answer,
query_type=query_type,
ambiguity="clear",
rewrite_meta=rewrite_meta,
sources=direct_sources,
)
await self.db.commit()
await self.db.refresh(qa)
await self.context_cache.append_session_messages(
tenant_id=self.tenant_id,
session_id=context_session_id,
question=clean_q,
answer=answer,
)
return self._build_response_payload(
answer=answer,
sources=direct_sources,
domains_used=["system_context"],
response_time_ms=elapsed,
db_query_ms=0,
vector_search_ms=0,
db_timeout=False,
vector_timeout=False,
qa_id=int(qa.id),
session_id=active_session_id,
turn_index=int(turn_index),
relevance_max=0.97,
query_type=query_type,
confidence=0.94,
missing_fields=[],
thinking_mode=active_thinking_mode,
anchor_mode=anchor_mode,
anchor_kind=anchor_kind,
meta_followup_kind=meta_followup_kind,
reason_code=reason_code,
)
async def _maybe_return_clarification_response(
self,
*,
clarification_payload: Optional[dict[str, Any]],
started: float,
clean_q: str,
effective_q: str,
query_type: str,
asked_by: Optional[int],
asked_via: str,
project_id: Optional[int],
session_id: Optional[int],
turn_index: int,
state_row: Optional[QASessionState],
rewrite_meta: dict[str, Any],
context_session_id: Optional[int],
active_thinking_mode: str,
) -> Optional[dict[str, Any]]:
if not isinstance(clarification_payload, dict):
return None
clarification_answer = str(clarification_payload.get("answer") or "").strip()
if not clarification_answer: if not clarification_answer:
return None return None
clarification_code = str(clarification_payload.get("code") or "no_anchor_found").strip() or "no_anchor_found"
clarification_candidates = (
clarification_payload.get("candidates")
if isinstance(clarification_payload.get("candidates"), list)
else []
)
missing_fields = [
str(item)
for item in (
clarification_payload.get("missing_fields")
if isinstance(clarification_payload.get("missing_fields"), list)
else ["reply_anchor"]
)
if str(item).strip()
]
elapsed = int((time.perf_counter() - started) * 1000) elapsed = int((time.perf_counter() - started) * 1000)
clarify_sources = [ clarify_sources = [
@ -32,9 +161,12 @@ class AdaptiveRAGAnswerPrecheckMixin:
"source": "system", "source": "system",
"label": "Session State", "label": "Session State",
"score": 0.95, "score": 0.95,
"metadata": {"mode": "clarification", "reason": "missing_subject"}, "metadata": {"mode": "clarification", "reason": clarification_code},
} }
] ]
active_session_id = int(session_id) if session_id is not None else (
int(context_session_id) if context_session_id is not None else None
)
qa = QARecord( qa = QARecord(
tenant_id=self.tenant_id, tenant_id=self.tenant_id,
question=clean_q, question=clean_q,
@ -46,10 +178,23 @@ class AdaptiveRAGAnswerPrecheckMixin:
asked_by=asked_by, asked_by=asked_by,
asked_via=(asked_via or "pmflow")[:20], asked_via=(asked_via or "pmflow")[:20],
project_id=project_id, project_id=project_id,
session_id=session_id, session_id=active_session_id,
turn_index=turn_index, turn_index=turn_index,
**self._build_qa_record_external_fields(),
) )
self.db.add(qa) self.db.add(qa)
if clarification_code == "ambiguous_recent_reply" and clarification_candidates:
previous = self._get_pending_clarification(session_state=state_row)
previous_attempts = int(previous.get("attempts") or 0) if isinstance(previous, dict) else 0
self._store_pending_clarification(
session_state=state_row,
original_question=clean_q,
clarification_code=clarification_code,
candidates=clarification_candidates,
previous_attempts=previous_attempts,
)
else:
self._clear_pending_clarification(session_state=state_row)
await self._update_session_state_after_turn( await self._update_session_state_after_turn(
session_state=state_row, session_state=state_row,
question=clean_q, question=clean_q,
@ -78,13 +223,16 @@ class AdaptiveRAGAnswerPrecheckMixin:
db_timeout=False, db_timeout=False,
vector_timeout=False, vector_timeout=False,
qa_id=int(qa.id), qa_id=int(qa.id),
session_id=int(session_id) if session_id is not None else None, session_id=active_session_id,
turn_index=int(turn_index), turn_index=int(turn_index),
relevance_max=0.95, relevance_max=0.95,
query_type=query_type, query_type=query_type,
confidence=0.92, confidence=0.92,
missing_fields=["subject"], missing_fields=missing_fields,
thinking_mode=active_thinking_mode, thinking_mode=active_thinking_mode,
clarification_code=clarification_code,
clarification_candidates=clarification_candidates,
reason_code=clarification_code,
) )
async def _maybe_return_snapshot_direct_response( async def _maybe_return_snapshot_direct_response(
@ -138,8 +286,9 @@ class AdaptiveRAGAnswerPrecheckMixin:
asked_by=asked_by, asked_by=asked_by,
asked_via=(asked_via or "pmflow")[:20], asked_via=(asked_via or "pmflow")[:20],
project_id=project_id, project_id=project_id,
session_id=session_id, session_id=int(session_id) if session_id is not None else (int(context_session_id) if context_session_id is not None else None),
turn_index=turn_index, turn_index=turn_index,
**self._build_qa_record_external_fields(),
) )
self.db.add(qa) self.db.add(qa)
await self._update_session_state_after_turn( await self._update_session_state_after_turn(
@ -178,4 +327,3 @@ class AdaptiveRAGAnswerPrecheckMixin:
missing_fields=[], missing_fields=[],
thinking_mode=active_thinking_mode, thinking_mode=active_thinking_mode,
) )

View File

@ -83,6 +83,7 @@ class AdaptiveRAGAnswerRetrievalMixin:
mixed_signal_part=mixed_signal_part, mixed_signal_part=mixed_signal_part,
query_type=query_type, query_type=query_type,
) )
routing_snapshot = self._peek_last_answer_routing_snapshot()
used_sources = self._dedupe_sources(retrieved) used_sources = self._dedupe_sources(retrieved)
if not used_sources: if not used_sources:
used_sources = [ used_sources = [
@ -244,6 +245,11 @@ class AdaptiveRAGAnswerRetrievalMixin:
project_id=project_id, project_id=project_id,
session_id=session_id, session_id=session_id,
turn_index=turn_index, turn_index=turn_index,
**self._build_qa_record_external_fields(
anchor_kind=None,
anchor_ref=None,
routing_snapshot=routing_snapshot,
),
) )
self.db.add(qa) self.db.add(qa)
confidence = round(max(0.2, min(1.0, max_score if max_score > 0 else 0.55)), 4) confidence = round(max(0.2, min(1.0, max_score if max_score > 0 else 0.55)), 4)
@ -293,4 +299,5 @@ class AdaptiveRAGAnswerRetrievalMixin:
confidence=confidence, confidence=confidence,
missing_fields=missing_fields, missing_fields=missing_fields,
thinking_mode=active_thinking_mode, thinking_mode=active_thinking_mode,
reason_code="answer_generated",
) )

View File

@ -10,6 +10,7 @@ class AdaptiveRAGCoreIntentMixin:
self.tenant_id = int(tenant_id) self.tenant_id = int(tenant_id)
self.llm = LLMService() self.llm = LLMService()
self.context_cache = context_cache_service self.context_cache = context_cache_service
self._active_external_qa_context: dict[str, Any] = {}
@staticmethod @staticmethod
def _normalize_thinking_mode(value: Optional[str]) -> str: def _normalize_thinking_mode(value: Optional[str]) -> str:
@ -33,17 +34,107 @@ class AdaptiveRAGCoreIntentMixin:
asked_via: str, asked_via: str,
source_chat_id: Optional[str], source_chat_id: Optional[str],
conversation_key: Optional[str], conversation_key: Optional[str],
source_sender_open_id: Optional[str] = None,
sender: Optional[str] = None,
thread_context: Optional[dict[str, Any]] = None,
) -> str: ) -> str:
channel = str(asked_via or "").strip().lower()
if channel == "feishu":
stable_scope = self._normalize_conversation_key(
build_feishu_conversation_scope_key(
source_chat_id=source_chat_id,
source_sender_open_id=source_sender_open_id,
sender=sender,
)
)
if stable_scope:
return stable_scope
explicit = self._normalize_conversation_key(conversation_key) explicit = self._normalize_conversation_key(conversation_key)
if explicit: if explicit:
return explicit return explicit
channel = str(asked_via or "").strip().lower()
if channel == "feishu": if channel == "feishu":
chat = str(source_chat_id or "").strip() return self._normalize_conversation_key(
if chat: build_feishu_thread_key(
return self._normalize_conversation_key(f"feishu:{chat}") source_chat_id=source_chat_id,
source_sender_open_id=source_sender_open_id,
sender=sender,
thread_context=thread_context,
)
)
return "" return ""
def _set_active_external_qa_context(
self,
*,
source_chat_id: Optional[str],
source_message_id: Optional[str],
source_sender_open_id: Optional[str],
conversation_scope_key: Optional[str],
thread_context: Optional[dict[str, Any]],
) -> None:
context = {
"source_chat_id": str(source_chat_id or "").strip()[:128] or None,
"source_message_id": str(source_message_id or "").strip()[:128] or None,
"source_sender_open_id": str(source_sender_open_id or "").strip()[:128] or None,
"conversation_scope_key": str(conversation_scope_key or "").strip()[:160] or None,
"reply_to_message_id": str((thread_context or {}).get("reply_to_message_id") or "").strip()[:128] or None,
"root_message_id": str((thread_context or {}).get("root_message_id") or "").strip()[:128] or None,
}
self._active_external_qa_context = context
def _get_active_external_qa_context(self) -> dict[str, Any]:
payload = self._active_external_qa_context if isinstance(self._active_external_qa_context, dict) else {}
return dict(payload)
@staticmethod
def _normalize_routing_snapshot(raw: Any) -> dict[str, Any]:
payload = raw if isinstance(raw, dict) else {}
planned_tier = str(payload.get("planned_tier") or "").strip().lower()
executed_tier = str(payload.get("executed_tier") or "").strip().lower()
route_source = str(payload.get("route_source") or "").strip().lower()
route_rule_name = str(payload.get("route_rule_name") or "").strip()
routing_decision = str(payload.get("routing_decision") or "").strip()
normalized = {
"planned_tier": planned_tier if planned_tier in {"low", "high"} else None,
"executed_tier": executed_tier if executed_tier in {"low", "high"} else None,
"route_source": route_source or None,
"route_rule_name": route_rule_name or None,
"routing_decision": routing_decision or None,
}
return {key: value for key, value in normalized.items() if value is not None}
def _peek_last_answer_routing_snapshot(self) -> dict[str, Any]:
payload = getattr(self.llm, "_last_ai_call_summary", None)
if not isinstance(payload, dict):
return {}
if str(payload.get("function_name") or "").strip() not in {
"adaptive_rag_answer_normal",
"adaptive_rag_answer_deep",
}:
return {}
return self._normalize_routing_snapshot(payload)
def _build_qa_record_external_fields(
self,
*,
anchor_kind: Optional[str] = None,
anchor_ref: Optional[str] = None,
routing_snapshot: Optional[dict[str, Any]] = None,
) -> dict[str, Any]:
context = self._get_active_external_qa_context()
normalized_snapshot = self._normalize_routing_snapshot(routing_snapshot) or None
return {
"source_message_id": context.get("source_message_id"),
"source_chat_id": context.get("source_chat_id"),
"source_sender_open_id": context.get("source_sender_open_id"),
"reply_to_message_id": context.get("reply_to_message_id"),
"root_message_id": context.get("root_message_id"),
"conversation_scope_key": context.get("conversation_scope_key"),
"anchor_kind": str(anchor_kind or "").strip()[:32] or None,
"anchor_ref": str(anchor_ref or "").strip()[:128] or None,
"routing_snapshot": normalized_snapshot,
}
@staticmethod @staticmethod
def _external_session_id_from_key(conversation_key: str) -> Optional[int]: def _external_session_id_from_key(conversation_key: str) -> Optional[int]:
key = str(conversation_key or "").strip() key = str(conversation_key or "").strip()
@ -144,4 +235,3 @@ class AdaptiveRAGCoreIntentMixin:
"question_part": "", "question_part": "",
"statement_part": text[:1000], "statement_part": text[:1000],
} }

View File

@ -2,6 +2,7 @@
from __future__ import annotations from __future__ import annotations
from ...config.ai_prompts import PROMPT_PROJECT_STATUS_QA from ...config.ai_prompts import PROMPT_PROJECT_STATUS_QA
from ..prompt_release import PromptReleaseService
from .shared import * from .shared import *
@ -140,7 +141,21 @@ class AdaptiveRAGDeepGuardAnswerMixin:
return f"[回答] {answer_block}\n[已收到需求] {signal_block},已入库处理" return f"[回答] {answer_block}\n[已收到需求] {signal_block},已入库处理"
def _select_scene_prompt( async def _render_scene_prompt(
self,
*,
prompt_key: str,
fallback_template: str,
**kwargs: Any,
) -> str:
return await PromptReleaseService(self.db).render_prompt(
tenant_id=self.tenant_id,
prompt_key=prompt_key,
fallback_template=fallback_template,
**kwargs,
)
async def _select_scene_prompt(
self, self,
*, *,
question: str, question: str,
@ -167,12 +182,14 @@ class AdaptiveRAGDeepGuardAnswerMixin:
compact_user_snapshot_text = json.dumps(compact_user_snapshot or {}, ensure_ascii=False)[:6000] compact_user_snapshot_text = json.dumps(compact_user_snapshot or {}, ensure_ascii=False)[:6000]
return ( return (
"member_workload", "member_workload",
PROMPT_MEMBER_WORKLOAD_QA.format( await self._render_scene_prompt(
prompt_key="PROMPT_MEMBER_WORKLOAD_QA",
fallback_template=PROMPT_MEMBER_WORKLOAD_QA,
question=question, question=question,
system_snapshot=system_snapshot_text or "{}", system_snapshot=system_snapshot_text or "{}",
user_snapshot=compact_user_snapshot_text or "{}", user_snapshot=compact_user_snapshot_text or "{}",
context_blocks=filtered_context_blocks, context_blocks=filtered_context_blocks,
).strip(), ),
) )
if str(message_intent or "").strip().lower() == "mixed": if str(message_intent or "").strip().lower() == "mixed":
@ -180,26 +197,30 @@ class AdaptiveRAGDeepGuardAnswerMixin:
merged_message = f"{question}\n{safe_signal_part}".strip() merged_message = f"{question}\n{safe_signal_part}".strip()
return ( return (
"mixed", "mixed",
PROMPT_FEISHU_MIXED_QA.format( await self._render_scene_prompt(
prompt_key="PROMPT_FEISHU_MIXED_QA",
fallback_template=PROMPT_FEISHU_MIXED_QA,
project_id=project_id if project_id is not None else "unknown", project_id=project_id if project_id is not None else "unknown",
message=merged_message, message=merged_message,
question_part=question, question_part=question,
signal_part=safe_signal_part, signal_part=safe_signal_part,
system_snapshot=system_snapshot_text or "{}", system_snapshot=system_snapshot_text or "{}",
context_blocks=context_blocks_text or "无", context_blocks=context_blocks_text or "无",
).strip(), ),
) )
if self._is_project_status_question(question, query_type): if self._is_project_status_question(question, query_type):
return ( return (
"project_status", "project_status",
PROMPT_PROJECT_STATUS_QA.format( await self._render_scene_prompt(
prompt_key="PROMPT_PROJECT_STATUS_QA",
fallback_template=PROMPT_PROJECT_STATUS_QA,
project_id=project_id if project_id is not None else "unknown", project_id=project_id if project_id is not None else "unknown",
question=question, question=question,
system_snapshot=system_snapshot_text or "{}", system_snapshot=system_snapshot_text or "{}",
user_snapshot=user_snapshot_text or "{}", user_snapshot=user_snapshot_text or "{}",
context_blocks=context_blocks_text or "无", context_blocks=context_blocks_text or "无",
).strip(), ),
) )
if self._contains_any_keyword(normalized_question, trend_keywords): if self._contains_any_keyword(normalized_question, trend_keywords):
@ -231,25 +252,31 @@ class AdaptiveRAGDeepGuardAnswerMixin:
)[:16000] )[:16000]
return ( return (
"external_trend", "external_trend",
PROMPT_EXTERNAL_TREND_QA.format( await self._render_scene_prompt(
prompt_key="PROMPT_EXTERNAL_TREND_QA",
fallback_template=PROMPT_EXTERNAL_TREND_QA,
question=question, question=question,
project_context=project_context_text or "{}", project_context=project_context_text or "{}",
external_results=context_blocks_text or "无", external_results=context_blocks_text or "无",
).strip(), ),
) )
return ( return (
"industry_trend", "industry_trend",
PROMPT_INDUSTRY_TREND_QA.format( await self._render_scene_prompt(
prompt_key="PROMPT_INDUSTRY_TREND_QA",
fallback_template=PROMPT_INDUSTRY_TREND_QA,
question=question, question=question,
).strip(), ),
) )
if self._is_decision_choice_question(question): if self._is_decision_choice_question(question):
return ( return (
"decision_choice", "decision_choice",
PROMPT_DECISION_QA.format( await self._render_scene_prompt(
prompt_key="PROMPT_DECISION_QA",
fallback_template=PROMPT_DECISION_QA,
question=question, question=question,
).strip(), ),
) )
if self._is_ai_pmo_process_entry(asked_via): if self._is_ai_pmo_process_entry(asked_via):
@ -262,31 +289,37 @@ class AdaptiveRAGDeepGuardAnswerMixin:
)[:18000] )[:18000]
return ( return (
"ai_pmo", "ai_pmo",
PROMPT_AI_PMO_QA.format( await self._render_scene_prompt(
prompt_key="PROMPT_AI_PMO_QA",
fallback_template=PROMPT_AI_PMO_QA,
question=question, question=question,
project_id=project_id if project_id is not None else "unknown", project_id=project_id if project_id is not None else "unknown",
world_model=world_model_text or "{}", world_model=world_model_text or "{}",
context_blocks=context_blocks_text or "无", context_blocks=context_blocks_text or "无",
).strip(), ),
) )
normalized_asked_via = str(asked_via or "").strip().lower() normalized_asked_via = str(asked_via or "").strip().lower()
if normalized_asked_via in {"feishu", "lark", "feishu_group", "feishu_dm"}: if normalized_asked_via in {"feishu", "lark", "feishu_group", "feishu_dm"}:
return ( return (
"feishu_direct", "feishu_direct",
PROMPT_FEISHU_DIRECT_QA.format( await self._render_scene_prompt(
prompt_key="PROMPT_FEISHU_DIRECT_QA",
fallback_template=PROMPT_FEISHU_DIRECT_QA,
project_id=project_id if project_id is not None else "unknown", project_id=project_id if project_id is not None else "unknown",
question=question, question=question,
system_snapshot=system_snapshot_text or "{}", system_snapshot=system_snapshot_text or "{}",
user_snapshot=user_snapshot_text or "{}", user_snapshot=user_snapshot_text or "{}",
conversation=conversation_text or "无", conversation=conversation_text or "无",
context_blocks=context_blocks_text or "无", context_blocks=context_blocks_text or "无",
).strip(), ),
) )
return ( return (
"default", "default",
PROMPT_RAG_ANSWER.format( await self._render_scene_prompt(
prompt_key="PROMPT_RAG_ANSWER",
fallback_template=PROMPT_RAG_ANSWER,
project_id=project_id if project_id is not None else "unknown", project_id=project_id if project_id is not None else "unknown",
question=question, question=question,
conversation=conversation_text or "无", conversation=conversation_text or "无",
@ -294,7 +327,7 @@ class AdaptiveRAGDeepGuardAnswerMixin:
system_snapshot=system_snapshot_text or "{}", system_snapshot=system_snapshot_text or "{}",
user_snapshot=user_snapshot_text or "{}", user_snapshot=user_snapshot_text or "{}",
relevance_max=f"{max(0.0, float(relevance_max or 0.0)):.3f}", relevance_max=f"{max(0.0, float(relevance_max or 0.0)):.3f}",
).strip(), ),
) )
def _build_snapshot_direct_answer( def _build_snapshot_direct_answer(
@ -535,7 +568,7 @@ class AdaptiveRAGDeepGuardAnswerMixin:
context_blocks_text = chr(10).join(context_blocks) if context_blocks else "无" context_blocks_text = chr(10).join(context_blocks) if context_blocks else "无"
system_snapshot_text = json.dumps(system_snapshot or {}, ensure_ascii=False)[:12000] system_snapshot_text = json.dumps(system_snapshot or {}, ensure_ascii=False)[:12000]
user_snapshot_text = json.dumps(user_snapshot or {}, ensure_ascii=False)[:6000] user_snapshot_text = json.dumps(user_snapshot or {}, ensure_ascii=False)[:6000]
scene_key, prompt = self._select_scene_prompt( scene_key, prompt = await self._select_scene_prompt(
question=question, question=question,
project_id=project_id, project_id=project_id,
asked_via=asked_via, asked_via=asked_via,

View File

@ -1,6 +1,7 @@
"""Adaptive RAG mixin module.""" """Adaptive RAG mixin module."""
from __future__ import annotations from __future__ import annotations
from ..prompt_release import PromptReleaseService
from .shared import * from .shared import *
@ -112,10 +113,13 @@ class AdaptiveRAGFastAnswerSemanticMixinP1:
} }
) )
prompt = PROMPT_RAG_DB_PROJECT_QA.format( prompt = await PromptReleaseService(self.db).render_prompt(
tenant_id=self.tenant_id,
prompt_key="PROMPT_RAG_DB_PROJECT_QA",
fallback_template=PROMPT_RAG_DB_PROJECT_QA,
question=question[:600], question=question[:600],
project_index=json.dumps(project_index, ensure_ascii=False), project_index=json.dumps(project_index, ensure_ascii=False),
).strip() )
llm_started = time.perf_counter() llm_started = time.perf_counter()
try: try:
raw = await self.llm.generate( raw = await self.llm.generate(
@ -165,4 +169,3 @@ class AdaptiveRAGFastAnswerSemanticMixinP1:
} }
except Exception: except Exception:
return None return None

View File

@ -56,6 +56,7 @@ class AdaptiveRAGFastAnswerSemanticMixinP2:
project_id=project_id, project_id=project_id,
session_id=active_session_id_int, session_id=active_session_id_int,
turn_index=active_turn_index, turn_index=active_turn_index,
**self._build_qa_record_external_fields(),
) )
self.db.add(qa) self.db.add(qa)
if state_row is None and active_session_id_int is not None: if state_row is None and active_session_id_int is not None:
@ -100,4 +101,3 @@ class AdaptiveRAGFastAnswerSemanticMixinP2:
missing_fields=list(missing_fields or []), missing_fields=list(missing_fields or []),
thinking_mode=thinking_mode, thinking_mode=thinking_mode,
) )

View File

@ -0,0 +1,742 @@
"""Adaptive RAG follow-up anchor resolution helpers."""
from __future__ import annotations
from dataclasses import dataclass, field
from datetime import timedelta
from .shared import *
FOLLOWUP_RECENT_REPLY_WINDOW_MINUTES = 5
FOLLOWUP_CANDIDATE_LIMIT = 3
FOLLOWUP_META_HINTS = (
"哪个模型",
"什么模型",
"谁回复",
"谁回答",
"为什么报错",
"为什么会报错",
"为什么失败",
"什么意思",
"你说的这个",
"你刚才那句",
"刚才那条",
"那条回复",
"上一条回复",
)
FOLLOWUP_SELECTION_DIGIT_RE = re.compile(r"^\s*([1-3])\s*$")
FOLLOWUP_SELECTION_ORDINAL_RE = re.compile(r"^\s*第?([一二三123])(?:条|个|项)?\s*$")
FOLLOWUP_FRESH_QUESTION_HINTS = ("?", "?", "为什么", "怎么", "如何", "哪些", "多少", "有没有", "帮我", "请", "解释")
FOLLOWUP_REMOVABLE_TOKENS = (
"上一条",
"上一句",
"刚才",
"那条",
"这条",
"那个",
"这个",
"回复",
"回答",
"一下",
"帮我",
"请",
"解释",
"说下",
"说说",
"那句",
"这句",
"的",
)
@dataclass
class FollowupAnchorResolution:
anchor_mode: str = "no_anchor"
anchor_kind: Optional[str] = None
anchor_reply_id: Optional[int] = None
anchor_session_id: Optional[int] = None
candidate_replies: list[dict[str, Any]] = field(default_factory=list)
is_meta_followup: bool = False
meta_followup_kind: Optional[str] = None
clarification_code: Optional[str] = None
anchor_reply: Optional[dict[str, Any]] = None
effective_question: Optional[str] = None
class AdaptiveRAGFollowupAnchorMixin:
@staticmethod
def _normalize_followup_created_at(created_at: Optional[datetime]) -> Optional[datetime]:
if created_at is None:
return None
normalized = created_at if created_at.tzinfo is None else created_at.astimezone(CN_TZ).replace(tzinfo=None)
now_value = now_cn_naive()
direct_gap = abs((now_value - normalized).total_seconds())
shifted = normalized + timedelta(hours=8)
shifted_gap = abs((now_value - shifted).total_seconds())
# SQLite/部分环境下 server_default=now() 可能落成 UTC naive;优先选择更贴近当前会话时间的解释。
if shifted_gap + 60 < direct_gap:
return shifted
return normalized
@staticmethod
def _normalize_followup_selection_text(text: str) -> str:
normalized = re.sub(r"\s+", "", str(text or "")).strip().lower()
for token in FOLLOWUP_REMOVABLE_TOKENS:
normalized = normalized.replace(token, "")
normalized = re.sub(r"[^\w\u4e00-\u9fff]", "", normalized)
return normalized[:120]
@staticmethod
def _normalize_followup_preview(text: str) -> str:
cleaned = re.sub(r"<at\b[^>]*>.*?</at>", "", str(text or ""), flags=re.IGNORECASE)
cleaned = " ".join(cleaned.split()).strip()
return cleaned[:160]
@staticmethod
def _looks_like_meta_followup(question: str) -> bool:
normalized = re.sub(r"\s+", "", str(question or "")).lower()
return any(token in normalized for token in FOLLOWUP_META_HINTS)
@staticmethod
def _classify_meta_followup_kind(question: str) -> Optional[str]:
normalized = re.sub(r"\s+", "", str(question or "")).lower()
if any(token in normalized for token in ("哪个模型", "什么模型", "谁回复", "谁回答")):
return "reply_model_identity"
if any(token in normalized for token in ("为什么报错", "为什么会报错", "为什么失败")):
return "reply_error_reason"
if any(token in normalized for token in ("来源", "根据什么", "什么依据")):
return "reply_source"
if any(token in normalized for token in ("什么意思", "你说的这个", "你刚才那句", "刚才那条", "那条回复", "上一条回复")):
return "reply_explanation"
return "reply_explanation" if normalized else None
@staticmethod
def _looks_like_referential_followup(question: str) -> bool:
normalized = re.sub(r"\s+", "", str(question or "")).lower()
return any(token in normalized for token in (*FOLLOWUP_REFERENTIAL_KEYWORDS, "那条", "这条", "上一条", "上一句"))
@staticmethod
def _looks_like_fresh_question(question: str) -> bool:
text = str(question or "").strip()
normalized = re.sub(r"\s+", "", text)
if not normalized:
return False
if FOLLOWUP_SELECTION_DIGIT_RE.match(text) or FOLLOWUP_SELECTION_ORDINAL_RE.match(text):
return False
return len(normalized) >= 5 and any(token in normalized for token in FOLLOWUP_FRESH_QUESTION_HINTS)
@staticmethod
def _candidate_relative_time(created_at: Optional[datetime]) -> str:
if created_at is None:
return "刚才"
now_value = now_cn_naive()
delta_seconds = max(0, int((now_value - created_at).total_seconds()))
if delta_seconds < 60:
return "刚才"
minutes = max(1, delta_seconds // 60)
if minutes < 60:
return f"{minutes}分钟前"
hours = max(1, minutes // 60)
return f"{hours}小时前"
def _build_followup_candidate(self, *, index: int, row: QARecord) -> dict[str, Any]:
question = " ".join(str(row.question or "").split()).strip()
answer = " ".join(str(row.answer or "").split()).strip()
label = question or "最近回复"
label = re.sub(r"^(?:帮我|请|麻烦|我想知道|想问下|问下|给我|解释下|解释一下|告诉我|说下)", "", label).strip()
label = re.sub(r"[??!!。\.].*$", "", label).strip(" ,,::")
label = label[:20] or (question[:20] if question else "最近回复")
answer_preview = answer[:56] + ("..." if len(answer) > 56 else "")
created_at = self._normalize_followup_created_at(getattr(row, "created_at", None))
return {
"index": int(index),
"qa_id": int(row.id),
"label": label,
"question": question[:160],
"anchor_reply_preview": answer_preview,
"created_at": created_at.isoformat() if created_at else None,
"relative_time": self._candidate_relative_time(created_at),
"match_text": self._normalize_followup_selection_text(label or question),
}
async def _load_explicit_reply_anchor(
self,
*,
thread_context: Optional[dict[str, Any]],
source_message_id: Optional[str] = None,
) -> Optional[dict[str, Any]]:
if not isinstance(thread_context, dict):
return None
reply_to_message_id = str(thread_context.get("reply_to_message_id") or "").strip()
if not reply_to_message_id:
return None
outbox = (
await self.db.execute(
select(FeishuReplyOutbox)
.where(
and_(
FeishuReplyOutbox.tenant_id == self.tenant_id,
FeishuReplyOutbox.status == "sent",
or_(
FeishuReplyOutbox.sent_message_id == reply_to_message_id,
FeishuReplyOutbox.source_message_id == reply_to_message_id,
),
)
)
.order_by(FeishuReplyOutbox.id.desc())
.limit(1)
)
).scalars().first()
if outbox is None:
return await self._load_current_ingest_closure_anchor(source_message_id=source_message_id)
source_message_id = str(outbox.source_message_id or "").strip()
previous_question = ""
if source_message_id:
ingest = (
await self.db.execute(
select(IngestEvent)
.where(
and_(
IngestEvent.tenant_id == self.tenant_id,
IngestEvent.channel == "feishu_message",
IngestEvent.event_key == source_message_id,
)
)
.order_by(IngestEvent.id.desc())
.limit(1)
)
).scalars().first()
payload = ingest.payload if ingest is not None and isinstance(ingest.payload, dict) else {}
previous_question = (
str(payload.get("message_text") or "").strip()
or str(payload.get("closure_summary") or "").strip()
or str(payload.get("closure_dialogue") or "").strip().splitlines()[-1].replace("用户:", "").strip()
)
preview = self._normalize_followup_preview(str(outbox.message_text or ""))
created_at = self._normalize_followup_created_at(getattr(outbox, "sent_at", None) or getattr(outbox, "created_at", None))
label = previous_question or preview or "最近回复"
label = re.sub(r"[??!!。\.].*$", "", label).strip(" ,,::")
label = label[:20] or "最近回复"
return {
"index": 1,
"qa_id": 0,
"label": label,
"question": previous_question[:160],
"anchor_reply_preview": preview[:56] + ("..." if len(preview) > 56 else ""),
"created_at": created_at.isoformat() if created_at else None,
"relative_time": self._candidate_relative_time(created_at),
"match_text": self._normalize_followup_selection_text(previous_question or label),
"source_message_id": source_message_id or None,
"sent_message_id": str(outbox.sent_message_id or "").strip() or None,
"anchor_kind": "feishu_reply_outbox",
"anchor_id": int(outbox.id),
}
async def _load_current_ingest_closure_anchor(
self,
*,
source_message_id: Optional[str],
) -> Optional[dict[str, Any]]:
event_key = str(source_message_id or "").strip()
if not event_key:
return None
ingest = (
await self.db.execute(
select(IngestEvent)
.where(
and_(
IngestEvent.tenant_id == self.tenant_id,
IngestEvent.channel == "feishu_message",
IngestEvent.event_key == event_key,
)
)
.order_by(IngestEvent.id.desc())
.limit(1)
)
).scalars().first()
payload = ingest.payload if ingest is not None and isinstance(ingest.payload, dict) else {}
turns = payload.get("closure_turns") if isinstance(payload.get("closure_turns"), list) else []
assistant_text = ""
previous_question = ""
for idx in range(len(turns) - 1, -1, -1):
turn = turns[idx]
if not isinstance(turn, dict):
continue
role = str(turn.get("role") or "").strip().lower()
text = str(turn.get("text") or "").strip()
if role != "assistant" or not text:
continue
assistant_text = text
for previous_idx in range(idx - 1, -1, -1):
previous_turn = turns[previous_idx]
if not isinstance(previous_turn, dict):
continue
if str(previous_turn.get("role") or "").strip().lower() != "user":
continue
previous_question = str(previous_turn.get("text") or "").strip()
if previous_question:
break
break
if not assistant_text:
return None
preview = self._normalize_followup_preview(assistant_text)
created_at = self._normalize_followup_created_at(getattr(ingest, "created_at", None))
label = previous_question or preview or "最近回复"
label = re.sub(r"[??!!。\.].*$", "", label).strip(" ,,::")
label = label[:20] or "最近回复"
return {
"index": 1,
"qa_id": 0,
"label": label,
"question": previous_question[:160],
"anchor_reply_preview": preview[:56] + ("..." if len(preview) > 56 else ""),
"created_at": created_at.isoformat() if created_at else None,
"relative_time": self._candidate_relative_time(created_at),
"match_text": self._normalize_followup_selection_text(previous_question or label),
"source_message_id": event_key,
"sent_message_id": None,
"anchor_kind": "ingest_closure_turns",
"anchor_id": int(getattr(ingest, "id", 0) or 0),
}
async def _load_recent_followup_candidates(
self,
*,
session_id: Optional[int],
conversation_scope_key: Optional[str] = None,
) -> list[dict[str, Any]]:
if session_id is None and not str(conversation_scope_key or "").strip():
return []
recent_after = now_cn_naive() - timedelta(minutes=FOLLOWUP_RECENT_REPLY_WINDOW_MINUTES)
filters = [QARecord.tenant_id == self.tenant_id]
normalized_scope = str(conversation_scope_key or "").strip()
if normalized_scope:
filters.append(QARecord.conversation_scope_key == normalized_scope)
elif session_id is not None:
filters.append(QARecord.session_id == int(session_id))
rows = (
await self.db.execute(
select(QARecord)
.where(and_(*filters))
.order_by(QARecord.created_at.desc(), QARecord.id.desc())
.limit(FOLLOWUP_CANDIDATE_LIMIT * 4)
)
).scalars().all()
candidates: list[dict[str, Any]] = []
for row in rows:
if not str(row.answer or "").strip():
continue
created_at = self._normalize_followup_created_at(getattr(row, "created_at", None))
if created_at is not None and created_at < recent_after:
continue
candidates.append(self._build_followup_candidate(index=len(candidates) + 1, row=row))
if len(candidates) >= FOLLOWUP_CANDIDATE_LIMIT:
break
return candidates
def _clear_pending_clarification(self, *, session_state: Optional[QASessionState]) -> None:
if session_state is None:
return
session_state.pending_clarification = None
session_state.pending_clarification_expires_at = None
def _store_pending_clarification(
self,
*,
session_state: Optional[QASessionState],
original_question: str,
clarification_code: str,
candidates: list[dict[str, Any]],
previous_attempts: int = 0,
) -> None:
if session_state is None:
return
session_state.pending_clarification = {
"kind": "followup_anchor_selection",
"clarification_code": clarification_code,
"original_question": str(original_question or "").strip()[:500],
"attempts": int(previous_attempts) + 1,
"candidates": [
{
"index": int(candidate.get("index") or 0),
"qa_id": int(candidate.get("qa_id") or 0),
"label": str(candidate.get("label") or "").strip()[:80],
"match_text": str(candidate.get("match_text") or "").strip()[:120],
"question": str(candidate.get("question") or "").strip()[:180],
}
for candidate in candidates[:FOLLOWUP_CANDIDATE_LIMIT]
if int(candidate.get("qa_id") or 0) > 0
],
}
session_state.pending_clarification_expires_at = now_cn_naive() + timedelta(
minutes=FOLLOWUP_RECENT_REPLY_WINDOW_MINUTES
)
def _get_pending_clarification(self, *, session_state: Optional[QASessionState]) -> Optional[dict[str, Any]]:
if session_state is None:
return None
payload = session_state.pending_clarification if isinstance(session_state.pending_clarification, dict) else None
expires_at = self._to_naive_utc(getattr(session_state, "pending_clarification_expires_at", None))
if not payload:
return None
if expires_at is not None and expires_at < now_cn_naive():
self._clear_pending_clarification(session_state=session_state)
return None
return payload
def _consume_pending_clarification_selection(
self,
*,
question: str,
session_state: Optional[QASessionState],
) -> Optional[dict[str, Any]]:
payload = self._get_pending_clarification(session_state=session_state)
if not payload:
return None
text = str(question or "").strip()
index: Optional[int] = None
digit_match = FOLLOWUP_SELECTION_DIGIT_RE.match(text)
if digit_match:
index = int(digit_match.group(1))
else:
ordinal_match = FOLLOWUP_SELECTION_ORDINAL_RE.match(text)
if ordinal_match:
raw = ordinal_match.group(1)
index = {"一": 1, "二": 2, "三": 3}.get(raw)
if index is None:
index = int(raw)
candidates = payload.get("candidates") if isinstance(payload.get("candidates"), list) else []
normalized = self._normalize_followup_selection_text(text)
selected: Optional[dict[str, Any]] = None
for candidate in candidates:
if not isinstance(candidate, dict):
continue
candidate_index = int(candidate.get("index") or 0)
if index is not None and candidate_index == index:
selected = candidate
break
match_text = str(candidate.get("match_text") or "").strip()
if normalized and match_text and (normalized in match_text or match_text in normalized):
selected = candidate
break
if selected is None:
if self._looks_like_fresh_question(question):
self._clear_pending_clarification(session_state=session_state)
return None
self._clear_pending_clarification(session_state=session_state)
return {
"selected_qa_id": int(selected.get("qa_id") or 0),
"effective_question": str(payload.get("original_question") or "").strip(),
}
async def _resolve_followup_anchor(
self,
*,
question: str,
session_id: Optional[int],
context_session_id: Optional[int],
session_state: Optional[QASessionState],
conversation_scope_key: Optional[str] = None,
source_chat_id: Optional[str] = None,
source_sender_open_id: Optional[str] = None,
source_message_id: Optional[str] = None,
thread_context: Optional[dict[str, Any]] = None,
) -> FollowupAnchorResolution:
text = str(question or "").strip()
if not text:
return FollowupAnchorResolution()
is_meta_followup = self._looks_like_meta_followup(text)
meta_followup_kind = self._classify_meta_followup_kind(text) if is_meta_followup else None
is_referential_followup = self._looks_like_referential_followup(text)
if not is_meta_followup and not is_referential_followup:
return FollowupAnchorResolution(is_meta_followup=False)
active_session_id = int(session_id) if session_id is not None else (
int(context_session_id) if context_session_id is not None else None
)
explicit_anchor = await self._load_explicit_reply_anchor(
thread_context=thread_context,
source_message_id=source_message_id,
)
if explicit_anchor is not None:
return FollowupAnchorResolution(
anchor_mode="explicit",
anchor_kind=str(explicit_anchor.get("anchor_kind") or "explicit_reply"),
anchor_reply_id=int(explicit_anchor.get("anchor_id") or 0) or None,
anchor_session_id=active_session_id,
is_meta_followup=is_meta_followup,
meta_followup_kind=meta_followup_kind,
anchor_reply=explicit_anchor,
)
scope_key = str(conversation_scope_key or "").strip()
if not scope_key and (source_chat_id or source_sender_open_id):
scope_key = build_feishu_conversation_scope_key(
source_chat_id=source_chat_id,
source_sender_open_id=source_sender_open_id,
sender=None,
)
candidates = await self._load_recent_followup_candidates(
session_id=active_session_id,
conversation_scope_key=scope_key,
)
if not candidates:
return FollowupAnchorResolution(
anchor_mode="no_anchor",
anchor_session_id=active_session_id,
is_meta_followup=is_meta_followup,
meta_followup_kind=meta_followup_kind,
clarification_code="no_anchor_found",
)
normalized = self._normalize_followup_selection_text(text)
matching = [
candidate
for candidate in candidates
if normalized
and str(candidate.get("match_text") or "").strip()
and (
normalized in str(candidate.get("match_text") or "").strip()
or str(candidate.get("match_text") or "").strip() in normalized
)
]
chosen = matching[0] if len(matching) == 1 else None
if chosen is not None:
return FollowupAnchorResolution(
anchor_mode="explicit",
anchor_kind="recent_scope_reply",
anchor_reply_id=int(chosen["qa_id"]),
anchor_session_id=active_session_id,
is_meta_followup=is_meta_followup,
meta_followup_kind=meta_followup_kind,
anchor_reply=chosen,
)
if len(candidates) == 1:
chosen = candidates[0]
return FollowupAnchorResolution(
anchor_mode="implicit_recent_reply",
anchor_kind="meta_recent_reply" if is_meta_followup else "recent_scope_reply",
anchor_reply_id=int(chosen["qa_id"]),
anchor_session_id=active_session_id,
is_meta_followup=is_meta_followup,
meta_followup_kind=meta_followup_kind,
anchor_reply=chosen,
)
if isinstance(thread_context, dict) and str(thread_context.get("reply_to_message_id") or "").strip():
chosen = candidates[0]
return FollowupAnchorResolution(
anchor_mode="implicit_recent_reply",
anchor_kind="meta_recent_reply" if is_meta_followup else "recent_scope_reply",
anchor_reply_id=int(chosen["qa_id"]),
anchor_session_id=active_session_id,
is_meta_followup=is_meta_followup,
meta_followup_kind=meta_followup_kind,
anchor_reply=chosen,
)
return FollowupAnchorResolution(
anchor_mode="ambiguous_candidates",
anchor_kind="recent_scope_reply",
anchor_session_id=active_session_id,
candidate_replies=candidates,
is_meta_followup=is_meta_followup,
meta_followup_kind=meta_followup_kind,
clarification_code="ambiguous_recent_reply",
)
def _rewrite_question_with_followup_anchor(
self,
*,
question: str,
anchor_resolution: Optional[FollowupAnchorResolution],
) -> tuple[str, dict[str, Any]]:
resolution = anchor_resolution or FollowupAnchorResolution()
anchor_reply = resolution.anchor_reply if isinstance(resolution.anchor_reply, dict) else None
if anchor_reply is None:
return str(question or "").strip(), {"mode": "direct"}
current = str(question or "").strip()
label = str(anchor_reply.get("label") or anchor_reply.get("question") or "最近回复").strip()
preview = str(anchor_reply.get("anchor_reply_preview") or "").strip()
previous_question = str(anchor_reply.get("question") or "").strip()
if resolution.is_meta_followup:
rewritten = (
f"围绕上一条 PMFlow 回复回答。上一轮用户问题:{previous_question}。"
f"上一轮回复摘要:{preview}。当前追问:{current}"
)
return rewritten[:1200], {"mode": "followup_anchor_meta", "qa_id": int(anchor_reply.get("qa_id") or 0)}
rewritten = f"围绕上一轮对话“{label}”继续回答:{current}"
return rewritten[:1200], {"mode": "followup_anchor_recent_reply", "qa_id": int(anchor_reply.get("qa_id") or 0)}
def _build_followup_clarification_payload(
self,
*,
question: str,
anchor_resolution: Optional[FollowupAnchorResolution],
session_state: Optional[QASessionState],
) -> Optional[dict[str, Any]]:
resolution = anchor_resolution or FollowupAnchorResolution()
if resolution.clarification_code == "ambiguous_recent_reply":
existing = self._get_pending_clarification(session_state=session_state)
attempts = int(existing.get("attempts") or 0) if isinstance(existing, dict) else 0
if attempts >= 1:
return {
"answer": (
"我不确定你指的是哪一条我刚才的回复。"
"你可以直接回复那条消息,或者告诉我是“系统架构”那条还是“系统模型”那条。"
),
"code": "no_anchor_found",
"candidates": [],
"missing_fields": ["reply_anchor"],
}
lines = ["我猜你在追问我刚才其中一条回复,请回我编号或短标题:"]
for candidate in resolution.candidate_replies[:FOLLOWUP_CANDIDATE_LIMIT]:
if not isinstance(candidate, dict):
continue
lines.append(
f"{int(candidate.get('index') or 0)}. {str(candidate.get('label') or '最近回复')} "
f"({str(candidate.get('relative_time') or '刚才')})"
)
return {
"answer": "\n".join(lines),
"code": "ambiguous_recent_reply",
"candidates": resolution.candidate_replies[:FOLLOWUP_CANDIDATE_LIMIT],
"missing_fields": ["reply_anchor"],
}
if resolution.clarification_code == "no_anchor_found":
return {
"answer": (
"我没定位到你指的是哪条我刚才的回复。"
"你可以直接引用那条消息,或者补一句关键词让我对上。"
),
"code": "no_anchor_found",
"candidates": [],
"missing_fields": ["reply_anchor"],
}
_ = question
return None
async def _resolve_anchor_qa_record(
self,
*,
anchor_resolution: Optional[FollowupAnchorResolution],
conversation_scope_key: Optional[str] = None,
) -> Optional[QARecord]:
resolution = anchor_resolution or FollowupAnchorResolution()
if resolution.anchor_reply_id:
row = await self.db.get(QARecord, int(resolution.anchor_reply_id))
if row is not None:
return row
anchor_reply = resolution.anchor_reply if isinstance(resolution.anchor_reply, dict) else {}
source_message_id = str(anchor_reply.get("source_message_id") or "").strip()
if source_message_id:
row = (
await self.db.execute(
select(QARecord)
.where(
and_(
QARecord.tenant_id == self.tenant_id,
QARecord.source_message_id == source_message_id,
)
)
.order_by(QARecord.created_at.desc(), QARecord.id.desc())
.limit(1)
)
).scalars().first()
if row is not None:
return row
scope_key = str(conversation_scope_key or "").strip()
if not scope_key:
return None
return (
await self.db.execute(
select(QARecord)
.where(
and_(
QARecord.tenant_id == self.tenant_id,
QARecord.conversation_scope_key == scope_key,
)
)
.order_by(QARecord.created_at.desc(), QARecord.id.desc())
.limit(1)
)
).scalars().first()
async def _build_meta_followup_payload(
self,
*,
question: str,
anchor_resolution: Optional[FollowupAnchorResolution],
conversation_scope_key: Optional[str] = None,
) -> Optional[dict[str, Any]]:
resolution = anchor_resolution or FollowupAnchorResolution()
if not resolution.is_meta_followup:
return None
meta_kind = str(resolution.meta_followup_kind or self._classify_meta_followup_kind(question) or "").strip()
if not meta_kind:
return None
anchor_row = await self._resolve_anchor_qa_record(
anchor_resolution=resolution,
conversation_scope_key=conversation_scope_key,
)
anchor_kind = str(resolution.anchor_kind or "").strip() or None
if anchor_row is None:
return {
"answer": "当前无法追溯到本次回复的路由记录。",
"meta_followup_kind": meta_kind,
"reason_code": "meta_followup_no_route_record",
"anchor_kind": anchor_kind,
"anchor_mode": resolution.anchor_mode,
"routing_snapshot": {},
}
routing_snapshot = (
anchor_row.routing_snapshot
if isinstance(anchor_row.routing_snapshot, dict)
else {}
)
normalized_snapshot = self._normalize_routing_snapshot(routing_snapshot)
answer_text = ""
reason_code = "meta_followup_answered"
if meta_kind == "reply_model_identity":
if normalized_snapshot:
answer_text = (
"这条回复的逻辑路由为:"
f"planned_tier={normalized_snapshot.get('planned_tier') or 'unknown'}, "
f"executed_tier={normalized_snapshot.get('executed_tier') or normalized_snapshot.get('planned_tier') or 'unknown'}, "
f"source={normalized_snapshot.get('route_source') or 'unknown'}, "
f"rule={normalized_snapshot.get('route_rule_name') or 'unknown'}, "
f"decision={normalized_snapshot.get('routing_decision') or 'unknown'}。"
)
else:
source_labels = [str(item.get("label") or "").strip() for item in (anchor_row.sources or []) if isinstance(item, dict)]
if any("snapshot" in label.lower() or "system" in label.lower() for label in source_labels):
answer_text = "这条回复不是模型生成结果,而是系统快照/确定性查询回执,所以没有 low/high 路由记录。"
reason_code = "meta_followup_non_model_answer"
else:
answer_text = "当前无法追溯到本次回复的路由记录。"
reason_code = "meta_followup_no_route_record"
elif meta_kind == "reply_error_reason":
anchored_answer = str(anchor_row.answer or "").strip()
if "系统处理中出现异常" in anchored_answer or "未执行" in anchored_answer:
answer_text = "那条回复是一次执行异常回执,不是正常问答结果。"
else:
answer_text = "那条回复本身不是错误回执;如果你指的是模型失败,需要引用具体那条报错消息。"
elif meta_kind == "reply_source":
source_labels = [str(item.get("label") or "").strip() for item in (anchor_row.sources or []) if isinstance(item, dict)]
labels_text = "、".join([label for label in source_labels[:3] if label]) or "system_context"
answer_text = f"那条回复主要基于这些来源:{labels_text}。"
else:
answer_text = f"我刚才那条回复的核心意思是:{str(anchor_row.answer or '').strip()[:200]}"
return {
"answer": answer_text,
"meta_followup_kind": meta_kind,
"reason_code": reason_code,
"anchor_kind": anchor_kind,
"anchor_mode": resolution.anchor_mode,
"routing_snapshot": normalized_snapshot,
"anchor_ref": str(anchor_row.id),
}

View File

@ -44,8 +44,10 @@ class AdaptiveRAGQASessionCoreMixin:
question: str, question: str,
preferred_session_id: Optional[int] = None, preferred_session_id: Optional[int] = None,
force_new_session: bool = False, force_new_session: bool = False,
external_conversation_key: Optional[str] = None,
) -> tuple[Optional[int], int]: ) -> tuple[Optional[int], int]:
if asked_by is None: external_key = str(external_conversation_key or "").strip()[:160] or None
if asked_by is None and external_key is None:
return (None, 1) return (None, 1)
now_utc = now_cn_naive() now_utc = now_cn_naive()
@ -57,15 +59,31 @@ class AdaptiveRAGQASessionCoreMixin:
select(QASession).where( select(QASession).where(
and_( and_(
QASession.tenant_id == self.tenant_id, QASession.tenant_id == self.tenant_id,
QASession.user_id == int(asked_by),
QASession.id == preferred_id, QASession.id == preferred_id,
(
QASession.user_id == int(asked_by)
if asked_by is not None
else QASession.external_conversation_key == external_key
),
)
)
)
).scalar_one_or_none()
if chosen is None and external_key:
chosen = (
await self.db.execute(
select(QASession).where(
and_(
QASession.tenant_id == self.tenant_id,
QASession.external_conversation_key == external_key,
) )
) )
) )
).scalar_one_or_none() ).scalar_one_or_none()
# 无显式 session_id 时,仅在非强制新会话模式下复用同渠道(asked_via)且时间窗口内的最近会话。 # 无显式 session_id 时,仅在非强制新会话模式下复用同渠道(asked_via)且时间窗口内的最近会话。
if chosen is None and not force_new_session: if chosen is None and asked_by is not None and not force_new_session:
latest_record = ( latest_record = (
await self.db.execute( await self.db.execute(
select(QARecord.session_id, QARecord.created_at) select(QARecord.session_id, QARecord.created_at)
@ -103,7 +121,8 @@ class AdaptiveRAGQASessionCoreMixin:
title = self._build_auto_session_title(question) or SESSION_DEFAULT_TITLE title = self._build_auto_session_title(question) or SESSION_DEFAULT_TITLE
chosen = QASession( chosen = QASession(
tenant_id=self.tenant_id, tenant_id=self.tenant_id,
user_id=int(asked_by), user_id=int(asked_by) if asked_by is not None else None,
external_conversation_key=external_key,
title=title[:120], title=title[:120],
project_id=project_id, project_id=project_id,
started_at=now_utc, started_at=now_utc,
@ -115,6 +134,8 @@ class AdaptiveRAGQASessionCoreMixin:
else: else:
if chosen.project_id is None and project_id is not None: if chosen.project_id is None and project_id is not None:
chosen.project_id = int(project_id) chosen.project_id = int(project_id)
if not str(chosen.external_conversation_key or "").strip() and external_key:
chosen.external_conversation_key = external_key
chosen.last_message_at = now_utc chosen.last_message_at = now_utc
chosen.message_count = int(chosen.message_count or 0) + 1 chosen.message_count = int(chosen.message_count or 0) + 1
@ -149,4 +170,3 @@ class AdaptiveRAGQASessionCoreMixin:
if curr is None or curr_at is None or fb_at >= curr_at: if curr is None or curr_at is None or fb_at >= curr_at:
latest_feedback[key] = fb latest_feedback[key] = fb
return latest_feedback return latest_feedback

View File

@ -2,6 +2,7 @@
from __future__ import annotations from __future__ import annotations
from .shared import * from .shared import *
from ..ai_pmo.workflow_semantic_planner import WorkflowSemanticPlanner
class AdaptiveRAGRequirementAckFlowMixin: class AdaptiveRAGRequirementAckFlowMixin:
@ -44,7 +45,7 @@ class AdaptiveRAGRequirementAckFlowMixin:
linked_signal_id is not None linked_signal_id is not None
and resolved_project_id is not None and resolved_project_id is not None
and action_taken in {"signal_created", "signal_deduped"} and action_taken in {"signal_created", "signal_deduped"}
and SignalSourceOrchestrationService.looks_like_signal_source_request(effective_question or question) and WorkflowSemanticPlanner.looks_like_signal_source_request(effective_question or question)
): ):
try: try:
source_orchestration_result = await SignalSourceOrchestrationService(self.db).orchestrate_from_signal( source_orchestration_result = await SignalSourceOrchestrationService(self.db).orchestrate_from_signal(
@ -115,6 +116,7 @@ class AdaptiveRAGRequirementAckFlowMixin:
project_id=resolved_project_id if resolved_project_id is not None else project_id, project_id=resolved_project_id if resolved_project_id is not None else project_id,
session_id=session_id, session_id=session_id,
turn_index=int(turn_index or 1), turn_index=int(turn_index or 1),
**self._build_qa_record_external_fields(),
) )
self.db.add(qa) self.db.add(qa)
await self._update_session_state_after_turn( await self._update_session_state_after_turn(
@ -170,4 +172,3 @@ class AdaptiveRAGRequirementAckFlowMixin:
trace_id=trace_id, trace_id=trace_id,
processing_stage="ack_only", processing_stage="ack_only",
) )

View File

@ -27,6 +27,8 @@ class AdaptiveRAGSessionStateMixin:
last_intent=None, last_intent=None,
ambiguity="unknown", ambiguity="unknown",
rewrite_history=[], rewrite_history=[],
pending_clarification=None,
pending_clarification_expires_at=None,
) )
self.db.add(row) self.db.add(row)
await self.db.flush() await self.db.flush()
@ -38,6 +40,7 @@ class AdaptiveRAGSessionStateMixin:
question: str, question: str,
conversation: list[dict[str, str]], conversation: list[dict[str, str]],
session_state: Optional[QASessionState], session_state: Optional[QASessionState],
anchor_resolution: Optional[FollowupAnchorResolution] = None,
) -> tuple[str, dict[str, Any]]: ) -> tuple[str, dict[str, Any]]:
expanded = self._expand_followup_question(question, conversation) expanded = self._expand_followup_question(question, conversation)
current = str(expanded or question or "").strip() current = str(expanded or question or "").strip()
@ -66,36 +69,13 @@ class AdaptiveRAGSessionStateMixin:
anchor = ",".join(str(x) for x in state_entity_ids[:6]) anchor = ",".join(str(x) for x in state_entity_ids[:6])
rewritten = f"围绕会话实体ID[{anchor}]回答:{current}" rewritten = f"围绕会话实体ID[{anchor}]回答:{current}"
return rewritten[:1200], {"mode": "state_entities", "entity_ids": state_entity_ids[:6]} return rewritten[:1200], {"mode": "state_entities", "entity_ids": state_entity_ids[:6]}
return current, {"mode": "referential_without_subject"} anchored_question, anchor_meta = self._rewrite_question_with_followup_anchor(
question=current,
def _build_clarification_question( anchor_resolution=anchor_resolution,
self,
*,
original_question: str,
effective_question: str,
session_state: Optional[QASessionState],
) -> Optional[str]:
text = str(original_question or "").strip().lower()
if not text:
return None
if self._is_correction_query(text):
if self._has_explicit_target_scope(text):
return None
return (
"收到,你在纠正上一条回答。请直接说你要的目标范围,例如:"
"`我问的是 AI 引擎驱动方式,第一层是否调用 openclaw?`"
) )
has_referential = any(k in text for k in FOLLOWUP_REFERENTIAL_KEYWORDS) if str(anchor_meta.get("mode") or "").strip() not in {"", "direct"}:
if not has_referential: return anchored_question, anchor_meta
return None return current, {"mode": "referential_without_subject"}
state_subject = str(getattr(session_state, "last_subject", "") or "").strip()
state_entity_ids = getattr(session_state, "last_entity_ids", None)
has_state_entities = isinstance(state_entity_ids, list) and any(str(x).strip() for x in state_entity_ids)
if state_subject or has_state_entities:
return None
if str(effective_question or "").strip() != text and "补充问题" in str(effective_question):
return None
return "你这句缺少明确主语。请先指明对象,例如:`分别是哪些项目?` 或 `请列出这些项目的名称和ID`。"
async def _update_session_state_after_turn( async def _update_session_state_after_turn(
self, self,
@ -111,12 +91,14 @@ class AdaptiveRAGSessionStateMixin:
) -> None: ) -> None:
if session_state is None: if session_state is None:
return return
session_state.last_intent = "question"
session_state.ambiguity = str(ambiguity or "clear")[:20]
if str(ambiguity or "").strip() != "needs_clarification":
subject = self._extract_state_subject(question=question, answer=answer, sources=sources) subject = self._extract_state_subject(question=question, answer=answer, sources=sources)
if subject: if subject:
session_state.last_subject = subject[:255] session_state.last_subject = subject[:255]
session_state.last_intent = "question"
session_state.ambiguity = str(ambiguity or "clear")[:20]
session_state.last_entity_ids = self._collect_entity_ids_from_sources(sources) session_state.last_entity_ids = self._collect_entity_ids_from_sources(sources)
self._clear_pending_clarification(session_state=session_state)
history = session_state.rewrite_history if isinstance(session_state.rewrite_history, list) else [] history = session_state.rewrite_history if isinstance(session_state.rewrite_history, list) else []
history.append( history.append(
@ -220,6 +202,11 @@ class AdaptiveRAGSessionStateMixin:
"last_intent": str(state.last_intent or "") if state else "", "last_intent": str(state.last_intent or "") if state else "",
"ambiguity": str(state.ambiguity or "") if state else "", "ambiguity": str(state.ambiguity or "") if state else "",
"rewrite_history": history[-20:], "rewrite_history": history[-20:],
"pending_clarification": state.pending_clarification if state else None,
"pending_clarification_expires_at": (
state.pending_clarification_expires_at.isoformat()
if state and state.pending_clarification_expires_at
else None
),
"updated_at": state.updated_at.isoformat() if state and state.updated_at else None, "updated_at": state.updated_at.isoformat() if state and state.updated_at else None,
} }

View File

@ -39,6 +39,7 @@ from .mixin_deep_guard_answer import AdaptiveRAGDeepGuardAnswerMixin
from .mixin_conversation_utils_a_p1 import AdaptiveRAGConversationUtilsAMixinP1 from .mixin_conversation_utils_a_p1 import AdaptiveRAGConversationUtilsAMixinP1
from .mixin_conversation_utils_a_p2 import AdaptiveRAGConversationUtilsAMixinP2 from .mixin_conversation_utils_a_p2 import AdaptiveRAGConversationUtilsAMixinP2
from .mixin_conversation_utils_b import AdaptiveRAGConversationUtilsBMixin from .mixin_conversation_utils_b import AdaptiveRAGConversationUtilsBMixin
from .mixin_followup_anchor import AdaptiveRAGFollowupAnchorMixin
class AdaptiveRAGService( class AdaptiveRAGService(
@ -80,6 +81,7 @@ class AdaptiveRAGService(
AdaptiveRAGConversationUtilsAMixinP1, AdaptiveRAGConversationUtilsAMixinP1,
AdaptiveRAGConversationUtilsAMixinP2, AdaptiveRAGConversationUtilsAMixinP2,
AdaptiveRAGConversationUtilsBMixin, AdaptiveRAGConversationUtilsBMixin,
AdaptiveRAGFollowupAnchorMixin,
): ):
"""项目内统一 RAG 服务。""" """项目内统一 RAG 服务。"""
_symbol_snapshot_cache: dict[str, tuple[float, dict[str, Any]]] = {} _symbol_snapshot_cache: dict[str, tuple[float, dict[str, Any]]] = {}

View File

@ -46,6 +46,9 @@ from ...models import (
KGEvidence, KGEvidence,
KGEntity, KGEntity,
KGRelation, KGRelation,
AICallLog,
FeishuReplyOutbox,
IngestEvent,
Project, Project,
ProjectSignalLink, ProjectSignalLink,
QAFeedback, QAFeedback,
@ -66,3 +69,4 @@ from ..signal.symbol_index import SymbolIndexService
from ...utils.timezone import CN_TZ, now_cn_aware, now_cn_naive from ...utils.timezone import CN_TZ, now_cn_aware, now_cn_naive
from ...utils.thread_context import build_feishu_conversation_scope_key, build_feishu_thread_key

View File

@ -1,6 +1,7 @@
from __future__ import annotations from __future__ import annotations
from .models import * from .models import *
from ..workflow_semantic_planner import WorkflowSemanticPlanner
class IntentResolver: class IntentResolver:
"""Resolve raw text to canonical intent.""" """Resolve raw text to canonical intent."""
@ -50,10 +51,16 @@ class IntentResolver:
@staticmethod @staticmethod
def resolve(*, text: str, project_id: Optional[int]) -> CanonicalIntent: def resolve(*, text: str, project_id: Optional[int]) -> CanonicalIntent:
normalized = re.sub(r"\s+", "", str(text or "")).lower()
pretty = re.sub(r"\s+", " ", str(text or "")).strip() pretty = re.sub(r"\s+", " ", str(text or "")).strip()
normalized = re.sub(r"\s+", "", str(text or "")).lower()
if not pretty: if not pretty:
return CanonicalIntent(INTENT_IGNORE, 1.0, project_id, "", "", {"reason": "empty"}) return CanonicalIntent(INTENT_IGNORE, 1.0, project_id, "", "", {"reason": "empty"})
bundle = WorkflowSemanticPlanner.plan_message(
message_text=pretty,
source_system="compat_intent_resolver",
metadata={"project_id": project_id},
)
workflow_types = [item.workflow_type for item in bundle.workflows]
if IntentResolver._is_smalltalk_message(pretty=pretty, normalized=normalized): if IntentResolver._is_smalltalk_message(pretty=pretty, normalized=normalized):
return CanonicalIntent(INTENT_IGNORE, 0.95, project_id, "", "", {"reason": "smalltalk"}) return CanonicalIntent(INTENT_IGNORE, 0.95, project_id, "", "", {"reason": "smalltalk"})
if IntentResolver._looks_like_negated_write_statement(text=pretty): if IntentResolver._looks_like_negated_write_statement(text=pretty):
@ -76,34 +83,46 @@ class IntentResolver:
{ {
"followup_by_work_item_id": True, "followup_by_work_item_id": True,
"target_work_item_id": _safe_int(explicit_followup_target.group(1)), "target_work_item_id": _safe_int(explicit_followup_target.group(1)),
"semantic_bundle": bundle.to_dict(),
}, },
) )
if any(token in pretty for token in FOLLOWUP_CONFIRM_HINTS): if any(token in pretty for token in FOLLOWUP_CONFIRM_HINTS):
return CanonicalIntent(INTENT_FOLLOWUP_CONFIRM, 0.82, project_id, "", "", {}) return CanonicalIntent(INTENT_FOLLOWUP_CONFIRM, 0.82, project_id, "", "", {})
if any(token in pretty for token in FOLLOWUP_CONTINUE_HINTS): if any(token in pretty for token in FOLLOWUP_CONTINUE_HINTS):
return CanonicalIntent(INTENT_FOLLOWUP_CONTINUE, 0.82, project_id, "", "", {}) return CanonicalIntent(INTENT_FOLLOWUP_CONTINUE, 0.82, project_id, "", "", {})
if SignalSourceOrchestrationService.looks_like_signal_source_request(pretty): 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
return CanonicalIntent(
followup_intent,
0.84,
project_id,
"",
"",
{
"followup_action": followup_action,
"semantic_bundle": bundle.to_dict(),
},
)
if WorkflowSemanticPlanner.looks_like_signal_source_request(pretty):
return CanonicalIntent( return CanonicalIntent(
INTENT_SIGNAL_SOURCE_ONBOARD, INTENT_SIGNAL_SOURCE_ONBOARD,
0.92, 0.92,
project_id, project_id,
IntentResolver._build_signal_title(pretty, default="新增信号源接入请求"), IntentResolver._build_signal_title(pretty, default="新增信号源接入请求"),
pretty[:300], pretty[:300],
{"source_request": True}, {"source_request": True, "semantic_bundle": bundle.to_dict()},
) )
if ( if "new_project_flow" in workflow_types or extract_project_create_title(pretty):
IntentResolver._looks_like_project_create_request(text=pretty)
or extract_project_create_title(pretty)
):
return CanonicalIntent( return CanonicalIntent(
INTENT_PROJECT_CREATE, INTENT_PROJECT_CREATE,
0.9, 0.9,
project_id, project_id,
IntentResolver._build_signal_title(pretty, default="新项目创建请求"), IntentResolver._build_signal_title(pretty, default="新项目创建请求"),
pretty[:300], pretty[:300],
{}, {"semantic_bundle": bundle.to_dict()},
) )
if IntentResolver._looks_like_task_create_request(text=pretty): if "task_flow" in workflow_types:
extracted_title = IntentResolver._extract_task_title(pretty) extracted_title = IntentResolver._extract_task_title(pretty)
return CanonicalIntent( return CanonicalIntent(
INTENT_TASK_CREATE, INTENT_TASK_CREATE,
@ -111,25 +130,35 @@ class IntentResolver:
project_id, project_id,
extracted_title or IntentResolver._build_signal_title(pretty, default="新任务"), extracted_title or IntentResolver._build_signal_title(pretty, default="新任务"),
pretty[:300], pretty[:300],
{"task_title": extracted_title} if extracted_title else {}, {
**({"task_title": extracted_title} if extracted_title else {}),
"semantic_bundle": bundle.to_dict(),
},
) )
if IntentResolver._looks_like_signal_create_request(text=pretty): if "project_signal_flow" in workflow_types:
return CanonicalIntent( return CanonicalIntent(
INTENT_SIGNAL_CREATE, INTENT_SIGNAL_CREATE,
0.78, 0.78,
project_id, project_id,
IntentResolver._build_signal_title(pretty, default="新信号"), IntentResolver._build_signal_title(pretty, default="新信号"),
pretty[:300], pretty[:300],
{}, {
"semantic_bundle": bundle.to_dict(),
"workflow_route": (
"project_signal.discover"
if bundle.workflows and bundle.workflows[0].workflow_action == "discover"
else "project_signal.create"
),
},
) )
if IntentResolver._looks_like_query(text=pretty): if "query_flow" in workflow_types or IntentResolver._looks_like_query(text=pretty):
return CanonicalIntent( return CanonicalIntent(
INTENT_QUERY, INTENT_QUERY,
0.88, 0.88,
project_id, project_id,
"", "",
pretty[:300], pretty[:300],
{}, {"semantic_bundle": bundle.to_dict()},
) )
return CanonicalIntent( return CanonicalIntent(
INTENT_QUERY, INTENT_QUERY,
@ -137,61 +166,24 @@ class IntentResolver:
project_id, project_id,
"", "",
pretty[:300], pretty[:300],
{"fallback": True, "reason": "default_query_fallback"}, {"fallback": True, "reason": "default_query_fallback", "semantic_bundle": bundle.to_dict()},
) )
@staticmethod @staticmethod
def _looks_like_signal_create_request(*, text: str) -> bool: def _looks_like_signal_create_request(*, text: str) -> bool: # semantic-lint: ignore - compat wrapper over workflow_semantic_planner
normalized = re.sub(r"\s+", "", str(text or "")).lower() bundle = WorkflowSemanticPlanner.plan_message(
if not normalized: message_text=str(text or ""),
return False source_system="compat_intent_resolver",
if IntentResolver._looks_like_negated_write_statement(text=normalized):
return False
candidate_discover_hints = ("候选", "换一批", "刷新", "再来一批", "先分析")
if (
any(token in normalized for token in candidate_discover_hints)
and any(token in normalized for token in ("确认", "入库", "提交"))
and any(token in normalized for token in ("项目", "project"))
):
return True
if re.search(r"(?:抓|找|给).{0,8}(?:\d+|[一二两三四五])(?:条|个)?候选", normalized, re.IGNORECASE):
return True
if any(token in normalized for token in SIGNAL_CREATE_HINTS):
return True
signal_object_hints = ("信号", "需求", "事项", "问题", "改造", "优化")
has_operation = any(token in normalized for token in IntentResolver.OPERATION_VERBS)
has_signal_object = any(token in normalized for token in signal_object_hints)
if has_operation and has_signal_object:
return True
return bool(
re.search(
r"(?:给|帮|请).{0,20}(?:新增|增加|添加|补充|加).{0,20}(?:信号|需求|事项)",
normalized,
re.IGNORECASE,
)
) )
return any(item.workflow_type == "project_signal_flow" for item in bundle.workflows)
@staticmethod @staticmethod
def _looks_like_task_create_request(*, text: str) -> bool: def _looks_like_task_create_request(*, text: str) -> bool: # semantic-lint: ignore - compat wrapper over workflow_semantic_planner
normalized = re.sub(r"\s+", "", str(text or "")).lower() bundle = WorkflowSemanticPlanner.plan_message(
if not normalized: message_text=str(text or ""),
return False source_system="compat_intent_resolver",
if IntentResolver._looks_like_negated_write_statement(text=normalized):
return False
if any(token in normalized for token in TASK_CREATE_HINTS):
return True
task_object_hints = ("任务", "工单", "待办", "todo")
has_operation = any(token in normalized for token in IntentResolver.OPERATION_VERBS)
has_task_object = any(token in normalized for token in task_object_hints)
if has_operation and has_task_object:
return True
return bool(
re.search(
r"(?:给|帮|请).{0,24}(?:新增|增加|添加|创建|新建|加).{0,16}(?:任务|工单|待办)",
normalized,
re.IGNORECASE,
)
) )
return any(item.workflow_type == "task_flow" for item in bundle.workflows)
@staticmethod @staticmethod
def _extract_task_title(text: str) -> str: def _extract_task_title(text: str) -> str:
@ -225,52 +217,24 @@ class IntentResolver:
@staticmethod @staticmethod
def _is_smalltalk_message(*, pretty: str, normalized: str) -> bool: def _is_smalltalk_message(*, pretty: str, normalized: str) -> bool:
compact = re.sub(r"[\\s\\??!!,,。~~、]+", "", str(pretty or "")).strip().lower() _ = normalized
if not compact: return WorkflowSemanticPlanner._is_smalltalk_message(pretty)
return True
if any(keyword in compact for keyword in SMALLTALK_STRUCTURED_HINTS):
return False
if compact in SMALLTALK_EXACT:
return True
if len(compact) <= 8 and any(keyword in compact for keyword in SMALLTALK_SHORT_HINTS):
return True
return False
@staticmethod @staticmethod
def _looks_like_query(*, text: str) -> bool: def _looks_like_query(*, text: str) -> bool: # semantic-lint: ignore - compat wrapper over workflow_semantic_planner
normalized = re.sub(r"\s+", "", str(text or "")).lower() bundle = WorkflowSemanticPlanner.plan_message(
if not normalized: message_text=str(text or ""),
return False source_system="compat_intent_resolver",
if IntentResolver._looks_like_signal_create_request(text=normalized):
return False
if IntentResolver._looks_like_task_create_request(text=normalized):
return False
if any(token in normalized for token in QUESTION_HINTS):
return True
has_question_structure = (
"?" in normalized
or "?" in normalized
or any(token in normalized for token in ("吗", "呢", "吧", "是否", "是不是"))
) )
if not has_question_structure: return len(bundle.workflows) == 1 and bundle.workflows[0].workflow_type == "query_flow"
return False
has_operation = any(token in normalized for token in IntentResolver.OPERATION_VERBS)
return not has_operation
@staticmethod @staticmethod
def _looks_like_project_create_request(*, text: str) -> bool: def _looks_like_project_create_request(*, text: str) -> bool: # semantic-lint: ignore - compat wrapper over workflow_semantic_planner
normalized = re.sub(r"\s+", "", str(text or "")).lower() bundle = WorkflowSemanticPlanner.plan_message(
if not normalized: message_text=str(text or ""),
return False source_system="compat_intent_resolver",
if any(token in normalized for token in PROJECT_CREATE_HINTS):
return True
return bool(
re.search(
r"(?:新增|增加|添加|新建|创建|建立|开个|开一个|建个|建一个|做个|做一个|加个|加一个|立项)(?:一个|个)?(?:[^,。!?,.!?]{0,24})项目",
normalized,
re.IGNORECASE,
)
) )
return any(item.workflow_type == "new_project_flow" for item in bundle.workflows)
@staticmethod @staticmethod
def _build_signal_title(text: str, *, default: str) -> str: def _build_signal_title(text: str, *, default: str) -> str:

View File

@ -1,6 +1,7 @@
from __future__ import annotations from __future__ import annotations
from .models import * from .models import *
from ..workflow_semantic_planner import WorkflowSemanticPlanner
class RouteResolver: class RouteResolver:
"""Map canonical intent + text into workflow route.""" """Map canonical intent + text into workflow route."""
@ -57,7 +58,7 @@ class RouteResolver:
return 1 return 1
@staticmethod @staticmethod
def looks_like_project_signal_discover(*, message_text: str, metadata: dict[str, Any]) -> bool: def looks_like_project_signal_discover(*, message_text: str, metadata: dict[str, Any]) -> bool: # semantic-lint: ignore - compat route helper over planner metadata
explicit_route = str(metadata.get("workflow_route") or "").strip().lower() if isinstance(metadata, dict) else "" explicit_route = str(metadata.get("workflow_route") or "").strip().lower() if isinstance(metadata, dict) else ""
if explicit_route == ROUTE_PROJECT_SIGNAL_DISCOVER: if explicit_route == ROUTE_PROJECT_SIGNAL_DISCOVER:
return True return True
@ -71,26 +72,11 @@ class RouteResolver:
canonical_intent: str, canonical_intent: str,
message_text: str = "", message_text: str = "",
) -> str: ) -> str:
payload = metadata if isinstance(metadata, dict) else {} return WorkflowSemanticPlanner.parse_followup_action(
explicit_action = str(payload.get("followup_action") or payload.get("action") or "").strip().lower() metadata=metadata,
if explicit_action in { canonical_intent=canonical_intent,
ROUTE_ACTION_CONTINUE, message_text=message_text,
ROUTE_ACTION_CONFIRM, )
ROUTE_ACTION_COMMIT,
ROUTE_ACTION_CANCEL,
ROUTE_ACTION_REFRESH,
}:
return explicit_action
normalized_text = re.sub(r"\s+", "", str(message_text or "")).lower()
if any(token in normalized_text for token in FOLLOWUP_REFRESH_HINTS):
return ROUTE_ACTION_REFRESH
if any(token in normalized_text for token in FOLLOWUP_CANCEL_HINTS):
return ROUTE_ACTION_CANCEL
if any(token in normalized_text for token in FOLLOWUP_COMMIT_HINTS):
return ROUTE_ACTION_COMMIT
if canonical_intent in {INTENT_FOLLOWUP_CONFIRM, INTENT_FOLLOWUP_CONTINUE}:
return ROUTE_ACTION_COMMIT
return ROUTE_ACTION_CONTINUE
@classmethod @classmethod
def from_canonical( def from_canonical(
@ -114,7 +100,7 @@ class RouteResolver:
route_key = ROUTE_TASK_CREATE route_key = ROUTE_TASK_CREATE
elif canonical_intent.name in {INTENT_FOLLOWUP_CONFIRM, INTENT_FOLLOWUP_CONTINUE}: elif canonical_intent.name in {INTENT_FOLLOWUP_CONFIRM, INTENT_FOLLOWUP_CONTINUE}:
action = cls.parse_followup_action( action = cls.parse_followup_action(
metadata=metadata, metadata={**metadata, "followup_context_present": True},
canonical_intent=canonical_intent.name, canonical_intent=canonical_intent.name,
message_text=message_text, message_text=message_text,
) )

View File

@ -8,6 +8,23 @@ from typing import Any, Optional
from ....utils.project_command_utils import extract_project_create_title from ....utils.project_command_utils import extract_project_create_title
from ...signal.signal_source_orchestration import SignalSourceOrchestrationService from ...signal.signal_source_orchestration import SignalSourceOrchestrationService
from ..workflow_semantic_planner.constants import (
FOLLOWUP_CANCEL_HINTS,
FOLLOWUP_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,
)
INTENT_QUERY = "query.read_only" INTENT_QUERY = "query.read_only"
INTENT_PROJECT_CREATE = "project.create" INTENT_PROJECT_CREATE = "project.create"
@ -215,116 +232,8 @@ LEGACY_INTENT_ROUTE_MAP: dict[str, str] = {
"signal_source.onboard": ROUTE_SIGNAL_SOURCE_ONBOARD, "signal_source.onboard": ROUTE_SIGNAL_SOURCE_ONBOARD,
} }
PROJECT_SIGNAL_DISCOVER_HINTS = (
"候选",
"先分析",
"先看候选",
"给我确认",
"待确认",
"再让我确认",
"确认后入库",
"先不要入库",
"先别入库",
)
FOLLOWUP_REFRESH_HINTS = ("换一批", "再来一批", "刷新候选", "重新找", "再找一组", "换一组")
FOLLOWUP_CANCEL_HINTS = ("取消", "先不做", "先别做", "不要了", "算了", "reject", "驳回")
FOLLOWUP_COMMIT_HINTS = ("确认", "继续处理", "继续执行", "同意", "批准", "approve")
QUESTION_HINTS = (
"?",
"?",
"怎么",
"如何",
"为何",
"为什么",
"什么",
"是什么",
"哪些",
"有哪些",
"特点",
"技术特点",
"能力",
"介绍",
"说明",
"多少",
"是否",
"帮我看",
"状态",
"风险",
"能做什么",
"可以做什么",
)
SMALLTALK_EXACT = {
"收到",
"收到啦",
"好的",
"ok",
"okk",
"yes",
"no",
"谢谢",
"thanks",
"thx",
"在吗",
"在不在",
"怎么样了",
"咋样了",
"辛苦了",
}
SMALLTALK_SHORT_HINTS = ("怎么样了", "咋样了", "在吗", "辛苦了", "收到", "谢谢", "ok")
SMALLTALK_STRUCTURED_HINTS = ("项目", "信号", "任务", "风险", "进度", "状态", "work_item", "工作项", "#")
FOLLOWUP_CONFIRM_HINTS = ("确认归属", "确认项目", "确认到项目", "确认关联", "确认这条")
FOLLOWUP_CONTINUE_HINTS = ("继续处理", "继续执行", "接着处理", "往下做", "继续推进")
FOLLOWUP_WORK_ITEM_ID_RE = re.compile(r"(?:work[_ -]?item|工作项|队列项)\s*(?:id|ID)?\s*[:#:]?\s*(\d+)", re.IGNORECASE)
SIGNAL_CREATE_HINTS = ("新增一个信号", "新增一条信号", "增加一个信号", "录入一个信号", "关注", "跟踪")
TASK_CREATE_HINTS = (
"创建任务",
"新建任务",
"新增任务",
"增加任务",
"添加任务",
"加个任务",
"加一个任务",
"安排任务",
"分配任务",
"派个任务",
)
PROJECT_CREATE_HINTS = (
"创建项目",
"新建项目",
"建立项目",
"做个项目",
"立项",
"开个项目",
"新增项目",
"新增个项目",
"新增一个项目",
"增加项目",
"增加个项目",
"增加一个项目",
"添加项目",
"添加一个项目",
"加个项目",
"加一个项目",
)
SESSION_SHORT_CONFIRM_WINDOW_SECONDS = 5 * 60 SESSION_SHORT_CONFIRM_WINDOW_SECONDS = 5 * 60
SHORT_CONFIRM_MAX_MESSAGE_LENGTH = 10 SHORT_CONFIRM_MAX_MESSAGE_LENGTH = 10
SHORT_CONFIRM_HINTS = (
"是",
"好",
"对",
"确认",
"新增",
"加",
"嗯",
"yes",
"ok",
"好的",
"是的",
"行",
"可以",
"没问题",
)
INTENT_LLM_FUNCTION_NAME = "classify_intent_ai_pmo_kernel" INTENT_LLM_FUNCTION_NAME = "classify_intent_ai_pmo_kernel"
INTENT_CANDIDATES = ( INTENT_CANDIDATES = (
INTENT_SIGNAL_SOURCE_ONBOARD, INTENT_SIGNAL_SOURCE_ONBOARD,

View File

@ -7,25 +7,16 @@ import re
from dataclasses import dataclass from dataclasses import dataclass
from typing import Iterable, Sequence from typing import Iterable, Sequence
from ..workflow_semantic_planner.constants import (
BACKGROUND_MARKERS,
CORRECTION_ACTION_PATTERNS,
INTENT_GROUP_KEYWORDS,
MULTI_SEGMENT_MARKERS,
)
INTENT_FUNCTION_SIMPLE = "classify_intent_ai_pmo_kernel" INTENT_FUNCTION_SIMPLE = "classify_intent_ai_pmo_kernel"
INTENT_FUNCTION_COMPLEX = "classify_intent_complex" INTENT_FUNCTION_COMPLEX = "classify_intent_complex"
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", ("重新识别", "重新判断", "重新路由", "再判断一次")),
)
def _env_float(name: str, default: float) -> float: def _env_float(name: str, default: float) -> float:
raw = os.getenv(name) raw = os.getenv(name)

View File

@ -4,7 +4,6 @@ from __future__ import annotations
import hashlib import hashlib
import json import json
import re
from datetime import timedelta from datetime import timedelta
from typing import Any, Optional from typing import Any, Optional
@ -96,6 +95,10 @@ from ..ai_pmo_intent import (
IntentResolver, IntentResolver,
RouteResolver, RouteResolver,
) )
from ..workflow_semantic_planner.constants import (
GENERIC_SIGNAL_SOURCE_MESSAGE_RE,
MODULE_KEYWORD_RULES,
)
from ...ai_steward_service import AIStewardService from ...ai_steward_service import AIStewardService
from ...llm_service import llm_service from ...llm_service import llm_service
from ...model.model_context_bundle import ModelContextBundleService from ...model.model_context_bundle import ModelContextBundleService
@ -103,22 +106,6 @@ from ...signal.signal_planner import SignalPlannerService
logger = get_logger(__name__) logger = get_logger(__name__)
GENERIC_SIGNAL_SOURCE_MESSAGE_RE = re.compile(
r"^(?:请)?(?:按|按照)?(?:这个|上述|这条)?(?:分步|步骤)?(?:骤)?(?:把|将)?(?:它|该需求|这条需求)?(?:加入|接入|录入).{0,24}信号源(?:中)?$",
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", "群聊", "机器人")),
("消息台与详情页", ("消息台", "详情页", "展开详情", "摘要", "时间戳", "按钮")),
)
def _utc_now_naive(): def _utc_now_naive():
return now_cn_naive() return now_cn_naive()

View File

@ -3,6 +3,7 @@
from __future__ import annotations from __future__ import annotations
from .... import ai_pmo_kernel_common_core as _common from .... import ai_pmo_kernel_common_core as _common
from ....workflow_semantic_planner import WorkflowSemanticPlanner
for _name, _value in vars(_common).items(): for _name, _value in vars(_common).items():
if not _name.startswith("__"): if not _name.startswith("__"):
@ -86,6 +87,24 @@ class KernelIngressRenderMixin:
else ((intent.metadata or {}).get("decision_trace") if isinstance(intent.metadata, dict) else None) else ((intent.metadata or {}).get("decision_trace") if isinstance(intent.metadata, dict) else None)
), ),
} }
semantic_bundle = (
(intent.metadata or {}).get("semantic_bundle")
if isinstance(intent.metadata, dict)
else None
)
if not isinstance(semantic_bundle, dict):
semantic_bundle = WorkflowSemanticPlanner.plan_from_intent(
intent_name=str(intent.name or ""),
message_text=str(getattr(work_item, "raw_message", "") or direct_reply_text or intent.signal_summary or ""),
source_system=str(getattr(work_item, "source_system", "") or "unknown"),
workflow_route=str(work_item.intent or execution_result.get("workflow_route") or "").strip(),
project_id=_safe_int(work_item.project_id),
metadata=intent.metadata if isinstance(intent.metadata, dict) else {},
).to_dict()
result["semantic_bundle"] = semantic_bundle
result["semantic_bundle_id"] = str(semantic_bundle.get("bundle_id") or "").strip() or None
result["semantic_version"] = str(semantic_bundle.get("semantic_version") or "").strip() or None
result["semantic_source"] = "workflow_semantic_planner"
if str(render.get("template_key") or "").strip().lower() == "draft_pending_confirm": if str(render.get("template_key") or "").strip().lower() == "draft_pending_confirm":
payload = render.get("payload") if isinstance(render.get("payload"), dict) else {} payload = render.get("payload") if isinstance(render.get("payload"), dict) else {}
result["draft_version"] = _safe_int(payload.get("draft_version")) or _safe_int(getattr(work_item, "draft_version", None)) result["draft_version"] = _safe_int(payload.get("draft_version")) or _safe_int(getattr(work_item, "draft_version", None))

View File

@ -3,6 +3,7 @@
from __future__ import annotations from __future__ import annotations
from .... import ai_pmo_kernel_common_core as _common from .... import ai_pmo_kernel_common_core as _common
from ....workflow_semantic_planner import WorkflowSemanticPlanner
for _name, _value in vars(_common).items(): for _name, _value in vars(_common).items():
if not _name.startswith("__"): if not _name.startswith("__"):
@ -198,6 +199,26 @@ class KernelIntentLLMMixin:
if structured_route is not None if structured_route is not None
else bool(payload.get("requires_confirmation")) else bool(payload.get("requires_confirmation"))
), ),
"semantic_bundle": WorkflowSemanticPlanner.plan_from_intent(
intent_name=intent_name,
message_text=message_text,
source_system="ai_pmo_ingress",
workflow_route=(
structured_route.key
if structured_route is not None
else LEGACY_INTENT_ROUTE_MAP.get(intent_name, ROUTE_QUERY_ANSWER)
),
project_id=project_id,
metadata={
**payload,
"confidence": confidence,
"requires_confirmation": (
structured_route.requires_confirmation
if structured_route is not None
else bool(payload.get("requires_confirmation"))
),
},
).to_dict(),
}, },
) )

View File

@ -3,6 +3,7 @@
from __future__ import annotations from __future__ import annotations
from .... import ai_pmo_kernel_common_core as _common from .... import ai_pmo_kernel_common_core as _common
from ....workflow_semantic_planner import WorkflowSemanticPlanner
for _name, _value in vars(_common).items(): for _name, _value in vars(_common).items():
if not _name.startswith("__"): if not _name.startswith("__"):
@ -10,42 +11,6 @@ for _name, _value in vars(_common).items():
class KernelIntentRulesMixin: class KernelIntentRulesMixin:
CONTEXTUAL_CONFIRM_HINTS = (
"来吧",
"那就",
"就这么办",
"就按这个",
"可以",
"好的",
"不错",
"开始吧",
"行",
"没问题",
"同意",
"确认",
)
CONTEXTUAL_DEICTIC_HINTS = (
"这个",
"这个项目",
"这条",
"它",
"按这个",
"照这个",
)
CONTEXTUAL_WRITE_ACTION_HINTS = (
"创建",
"新建",
"新增",
"增加",
"添加",
"执行",
"开始",
"继续处理",
"继续执行",
"推进",
"落地",
)
@staticmethod @staticmethod
def _intent_llm_enabled() -> bool: def _intent_llm_enabled() -> bool:
return _env_bool("AI_PMO_INTENT_LLM_ENABLED", True) return _env_bool("AI_PMO_INTENT_LLM_ENABLED", True)
@ -104,18 +69,8 @@ class KernelIntentRulesMixin:
) )
@classmethod @classmethod
def _looks_like_contextual_write_confirmation(cls, *, message_text: str) -> bool: def _looks_like_contextual_write_confirmation(cls, *, message_text: str) -> bool: # semantic-lint: ignore - compat wrapper over workflow_semantic_planner
normalized = re.sub(r"\s+", "", str(message_text or "")).lower() return WorkflowSemanticPlanner.looks_like_contextual_write_confirmation(message_text)
if not normalized:
return False
if cls._has_question_structure(normalized=normalized):
return False
has_write_action = any(token in normalized for token in cls.CONTEXTUAL_WRITE_ACTION_HINTS)
if not has_write_action:
return False
has_confirm = any(token in normalized for token in cls.CONTEXTUAL_CONFIRM_HINTS)
has_deictic = any(token in normalized for token in cls.CONTEXTUAL_DEICTIC_HINTS)
return has_confirm or has_deictic
def _recover_session_followup_write_intent( def _recover_session_followup_write_intent(
self, self,

View File

@ -3,6 +3,12 @@
from __future__ import annotations from __future__ import annotations
from .imports import re from .imports import re
from ..workflow_semantic_planner.constants import (
GENERIC_SIGNAL_SOURCE_MESSAGE_RE,
MODULE_KEYWORD_RULES,
POLICY_GATE_RULES,
TRANSPORT_MODE_CORRECTION_HINTS,
)
STATE_RECEIVED = "received" STATE_RECEIVED = "received"
STATE_MODELED = "modeled" STATE_MODELED = "modeled"
@ -20,37 +26,6 @@ RECEIPT_PHASE_ACTION = "action_applied"
RECEIPT_PHASE_PENDING = "pending_manual" RECEIPT_PHASE_PENDING = "pending_manual"
RECEIPT_PHASE_ERROR = "error" RECEIPT_PHASE_ERROR = "error"
TRANSPORT_MODE_CORRECTION_HINTS = (
"不是",
"不再",
"改为",
"改成",
"替换为",
"取代",
"现在都",
"现在是",
)
GENERIC_SIGNAL_SOURCE_MESSAGE_RE = re.compile(
r"^(?:请)?(?:按|按照)?(?:这个|上述|这条)?(?:分步|步骤)?(?:骤)?(?:把|将)?(?:它|该需求|这条需求)?(?:加入|接入|录入).{0,24}信号源(?:中)?$",
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", ("关键路径", "里程碑改动", "影响多个项目", "跨项目依赖调整")),
)
BOOL_TRUE_VALUES = {"1", "true", "yes", "y", "on", "是"} BOOL_TRUE_VALUES = {"1", "true", "yes", "y", "on", "是"}
SESSION_PROJECT_INHERIT_WINDOW_SECONDS = 30 * 60 SESSION_PROJECT_INHERIT_WINDOW_SECONDS = 30 * 60
INTENT_LLM_TIMEOUT_DEFAULT_SECONDS = 30.0 INTENT_LLM_TIMEOUT_DEFAULT_SECONDS = 30.0

View File

@ -1,6 +1,7 @@
from __future__ import annotations from __future__ import annotations
from .shared import * from .shared import *
from app.utils.thread_context import build_feishu_conversation_scope_key
class KernelIngressFlowEntryMixin: class KernelIngressFlowEntryMixin:
_MIXED_HINT_CONNECTORS = ("顺便", "另外", "同时", "并且", "还有", "再问") _MIXED_HINT_CONNECTORS = ("顺便", "另外", "同时", "并且", "还有", "再问")
@ -30,6 +31,8 @@ class KernelIngressFlowEntryMixin:
source_system: str, source_system: str,
source_message_id: str, source_message_id: str,
channel_ref: Optional[str], channel_ref: Optional[str],
source_sender_open_id: Optional[str],
thread_context: Optional[dict[str, Any]],
message_text: str, message_text: str,
project_hint_id: Optional[int], project_hint_id: Optional[int],
) -> tuple[str, dict[str, Any]]: ) -> tuple[str, dict[str, Any]]:
@ -58,13 +61,23 @@ class KernelIngressFlowEntryMixin:
signal_part = str(intent_result.get("statement_part") or "").strip() or str(message_text or "").strip() signal_part = str(intent_result.get("statement_part") or "").strip() or str(message_text or "").strip()
sidecar_answer = "" sidecar_answer = ""
try: try:
conversation_key = source_message_id
if 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,
) or source_message_id
rag_result = await rag_service.answer_question( rag_result = await rag_service.answer_question(
question=question_part, question=question_part,
asked_by=None, asked_by=None,
asked_via=source_system, asked_via=source_system,
project_id=None, project_id=None,
source_chat_id=channel_ref, source_chat_id=channel_ref,
conversation_key=source_message_id, source_message_id=source_message_id,
conversation_key=conversation_key,
source_sender_open_id=source_sender_open_id,
thread_context=thread_context,
message_intent="mixed", message_intent="mixed",
mixed_signal_part=signal_part, mixed_signal_part=signal_part,
) )
@ -203,6 +216,8 @@ class KernelIngressFlowEntryMixin:
source_system=source_system, source_system=source_system,
source_message_id=synthetic_message_id, source_message_id=synthetic_message_id,
channel_ref=channel_ref, channel_ref=channel_ref,
source_sender_open_id=source_sender_open_id,
thread_context=thread_context,
message_text=clean_text, message_text=clean_text,
project_hint_id=project_hint_id, project_hint_id=project_hint_id,
) )

View File

@ -8,7 +8,7 @@ from ...adaptive_rag_service import AdaptiveRAGService
from ...context_cache_service import context_cache_service from ...context_cache_service import context_cache_service
from ...sstc_service_core import ALIGNED_STATUSES from ...sstc_service_core import ALIGNED_STATUSES
from ...task_persistence_core import build_task_create_draft, persist_task from ...task_persistence_core import build_task_create_draft, persist_task
from app.utils.thread_context import build_feishu_thread_key from app.utils.thread_context import build_feishu_conversation_scope_key, build_feishu_thread_key
class ExecuteQueryProjectMixin: class ExecuteQueryProjectMixin:
@ -45,6 +45,7 @@ class ExecuteQueryProjectMixin:
mixed_signal_part = str(rag_request_context.get("mixed_signal_part") or "").strip() or None mixed_signal_part = str(rag_request_context.get("mixed_signal_part") or "").strip() or None
else: else:
thread_context = metadata.get("thread_context") if isinstance(metadata.get("thread_context"), dict) else None thread_context = metadata.get("thread_context") if isinstance(metadata.get("thread_context"), dict) else None
source_sender_open_id = str(metadata.get("source_sender_open_id") or "").strip() or None
is_feishu_source = str(source_system or "").strip().lower() == "feishu" is_feishu_source = str(source_system or "").strip().lower() == "feishu"
if is_feishu_source: if is_feishu_source:
asked_by = None asked_by = None
@ -55,12 +56,10 @@ class ExecuteQueryProjectMixin:
requested_project_id = None requested_project_id = None
source_chat_id = None source_chat_id = None
conversation_key = ( conversation_key = (
build_feishu_thread_key( build_feishu_conversation_scope_key(
source_chat_id=None, source_chat_id=None,
source_sender_open_id=None, source_sender_open_id=source_sender_open_id,
sender=None, sender=None,
source_message_id=source_message_id,
thread_context=thread_context,
) )
if is_feishu_source if is_feishu_source
else source_message_id else source_message_id
@ -72,13 +71,19 @@ class ExecuteQueryProjectMixin:
sync_requirement = False sync_requirement = False
message_intent = None message_intent = None
mixed_signal_part = None mixed_signal_part = None
if isinstance(rag_request_context, dict):
source_sender_open_id = str(rag_request_context.get("source_sender_open_id") or "").strip() or None
thread_context = rag_request_context.get("thread_context") if isinstance(rag_request_context.get("thread_context"), dict) else None
rag_kwargs: dict[str, Any] = { rag_kwargs: dict[str, Any] = {
"question": message_text, "question": message_text,
"asked_by": asked_by, "asked_by": asked_by,
"asked_via": asked_via, "asked_via": asked_via,
"project_id": requested_project_id if requested_project_id is not None else intent.project_id, "project_id": requested_project_id if requested_project_id is not None else intent.project_id,
"source_chat_id": source_chat_id, "source_chat_id": source_chat_id,
"source_message_id": source_message_id,
"conversation_key": conversation_key, "conversation_key": conversation_key,
"source_sender_open_id": source_sender_open_id,
"thread_context": thread_context,
"action_mode": action_mode, "action_mode": action_mode,
"sync_requirement": sync_requirement, "sync_requirement": sync_requirement,
"message_intent": message_intent, "message_intent": message_intent,

View File

@ -12,6 +12,7 @@ for _name, _value in vars(_common).items():
globals()[_name] = _value globals()[_name] = _value
from ..ai_steward_service import AIStewardService from ..ai_steward_service import AIStewardService
from .workflow_semantic_planner import WorkflowSemanticPlanner
from ..signal.draft_generator_service import DraftGeneratorService from ..signal.draft_generator_service import DraftGeneratorService
from ...models.feishu_reply_outbox import FeishuReplyOutbox from ...models.feishu_reply_outbox import FeishuReplyOutbox
@ -24,10 +25,6 @@ class DraftConfirmHandler:
_ACTION_CANCEL = "cancel" _ACTION_CANCEL = "cancel"
_ACTION_UNKNOWN = "unknown" _ACTION_UNKNOWN = "unknown"
_CONFIRM_HINTS = ("确认", "好的", "就这样", "行", "ok", "okay", "同意", "没问题")
_REVISE_HINTS = ("修改", "改成", "改为", "改", "换成")
_CANCEL_HINTS = ("取消", "不要", "算了", "放弃")
_WORK_ITEM_ID_RE = re.compile(r"(?:work[_ -]?item|工作项|草稿)\s*(?:id|ID)?\s*[:#:]?\s*(\d+)", re.IGNORECASE) _WORK_ITEM_ID_RE = re.compile(r"(?:work[_ -]?item|工作项|草稿)\s*(?:id|ID)?\s*[:#:]?\s*(\d+)", re.IGNORECASE)
def __init__(self, kernel: Any) -> None: def __init__(self, kernel: Any) -> None:
@ -38,15 +35,8 @@ class DraftConfirmHandler:
@classmethod @classmethod
def parse_action_from_text(cls, *, message_text: str) -> tuple[str, str]: def parse_action_from_text(cls, *, message_text: str) -> tuple[str, str]:
text = str(message_text or "").strip() action, note = WorkflowSemanticPlanner.parse_draft_action(message_text=message_text)
lowered = text.lower() return action, note
if any(hint in text for hint in cls._REVISE_HINTS):
return cls._ACTION_REVISE, cls._extract_revision_note(text)
if any(hint in text for hint in cls._CANCEL_HINTS):
return cls._ACTION_CANCEL, ""
if any(hint in text for hint in cls._CONFIRM_HINTS) or lowered in {"y", "yes"}:
return cls._ACTION_CONFIRM, ""
return cls._ACTION_UNKNOWN, ""
async def handle_text_reply( async def handle_text_reply(
self, self,
@ -812,12 +802,10 @@ class DraftConfirmHandler:
@classmethod @classmethod
def _extract_revision_note(cls, text: str) -> str: def _extract_revision_note(cls, text: str) -> str:
normalized = str(text or "").strip() action, note = WorkflowSemanticPlanner.parse_draft_action(message_text=text)
for hint in cls._REVISE_HINTS: if action != cls._ACTION_REVISE:
if normalized.startswith(hint):
note = normalized[len(hint):].strip(" ::")
return note[:500]
return "" return ""
return str(note or "")[:500]
@staticmethod @staticmethod
def _safe_float(value: Any) -> Optional[float]: def _safe_float(value: Any) -> Optional[float]:

View File

@ -0,0 +1,12 @@
from .constants import *
from .models import ReplyContract, WorkflowBundle, WorkflowPlan, WorkflowSlot, workflow_slot
from .service import WorkflowSemanticPlanner
__all__ = [
"ReplyContract",
"WorkflowBundle",
"WorkflowPlan",
"WorkflowSlot",
"WorkflowSemanticPlanner",
"workflow_slot",
]

View File

@ -0,0 +1,319 @@
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"
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 = (
"创建",
"新建",
"新增",
"增加",
"添加",
"执行",
"开始",
"继续处理",
"继续执行",
"推进",
"落地",
)
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 = ("继续处理", "继续执行", "接着处理", "往下做", "继续推进")
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", "工作项", "#")
SIGNAL_CREATE_HINTS = ("新增一个信号", "新增一条信号", "增加一个信号", "录入一个信号", "关注", "跟踪")
TASK_CREATE_HINTS = (
"创建任务",
"新建任务",
"新增任务",
"增加任务",
"添加任务",
"加个任务",
"加一个任务",
"安排任务",
"分配任务",
"派个任务",
)
PROJECT_CREATE_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,
)
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)
EXTERNAL_INTEGRATION_RE = re.compile(
r"(github|gitlab|jira|slack|webhook|飞书|企业微信|企微|lark|api|接入|授权|同步)",
re.IGNORECASE,
)
__all__ = [name for name in globals() if not name.startswith("__")]

View File

@ -0,0 +1,74 @@
from __future__ import annotations
from dataclasses import asdict, dataclass, field
from typing import Any, Optional
@dataclass(slots=True)
class WorkflowSlot:
name: str
value: Any
source: str = "semantic_planner"
@dataclass(slots=True)
class WorkflowPlan:
workflow_type: str
workflow_action: str
target_scope: str
operation_codes: list[str]
requires_confirmation: bool
confidence: float
slots: list[WorkflowSlot] = field(default_factory=list)
clarification_needed: bool = False
metadata: dict[str, Any] = field(default_factory=dict)
def to_dict(self) -> dict[str, Any]:
payload = asdict(self)
payload["slots"] = [asdict(slot) for slot in self.slots]
return payload
@dataclass(slots=True)
class ReplyContract:
mode: str
template_key: str = "workflow_bundle"
summary: str = ""
def to_dict(self) -> dict[str, Any]:
return asdict(self)
@dataclass(slots=True)
class WorkflowBundle:
bundle_id: str
semantic_version: str
source_system: str
primary_workflow: str
workflows: list[WorkflowPlan]
message_gate: str
reply_contract: ReplyContract
diagnostics: dict[str, Any] = field(default_factory=dict)
def to_dict(self) -> dict[str, Any]:
return {
"bundle_id": self.bundle_id,
"semantic_version": self.semantic_version,
"source_system": self.source_system,
"primary_workflow": self.primary_workflow,
"workflows": [item.to_dict() for item in self.workflows],
"message_gate": self.message_gate,
"reply_contract": self.reply_contract.to_dict(),
"diagnostics": dict(self.diagnostics or {}),
}
def workflow_slot(name: str, value: Any, *, source: str = "semantic_planner") -> Optional[WorkflowSlot]:
if value is None:
return None
if isinstance(value, str) and not value.strip():
return None
return WorkflowSlot(name=name, value=value, source=source)
__all__ = ["ReplyContract", "WorkflowBundle", "WorkflowPlan", "WorkflowSlot", "workflow_slot"]

View File

@ -0,0 +1,747 @@
from __future__ import annotations
import hashlib
import re
from typing import Any, Iterable, Optional
from .constants import (
CONTEXTUAL_CONFIRM_HINTS,
CONTEXTUAL_DEICTIC_HINTS,
CONTEXTUAL_WRITE_ACTION_HINTS,
CREATE_ACTION_RE,
EXTERNAL_INTEGRATION_RE,
FOLLOWUP_CANCEL_HINTS,
FOLLOWUP_CREATE_CONTINUE_RE,
FOLLOWUP_COMMIT_HINTS,
FOLLOWUP_CONFIRM_HINTS,
FOLLOWUP_CONTINUE_HINTS,
FOLLOWUP_CONTINUE_RE,
FOLLOWUP_REFERENCE_RE,
FOLLOWUP_REFRESH_HINTS,
FOLLOWUP_STATUS_RE,
FOLLOWUP_WORK_ITEM_ID_RE,
GENERIC_SIGNAL_SOURCE_MESSAGE_RE,
HEALTHCHECK_RE,
INTENT_FOLLOWUP_CONFIRM,
INTENT_FOLLOWUP_CONTINUE,
INTENT_IGNORE,
INTENT_PROJECT_ARCHIVE,
INTENT_PROJECT_CREATE,
INTENT_PROJECT_DELETE,
INTENT_PROJECT_REOPEN,
INTENT_PROJECT_UPDATE,
INTENT_QUERY,
INTENT_SIGNAL_CREATE,
INTENT_SIGNAL_SOURCE_ONBOARD,
INTENT_TASK_ASSIGN,
INTENT_TASK_CREATE,
INTENT_TASK_DELETE,
INTENT_TASK_PRIORITIZE,
INTENT_TASK_STATUS_TRANSITION,
INTENT_TASK_UPDATE,
MESSAGE_GATE_AUTO,
MESSAGE_GATE_PENDING,
PROJECT_CREATE_HINTS,
PROJECT_CREATE_REGEX,
PROJECT_ID_QUERY_RE,
PROJECT_LIST_QUERY_RE,
PROJECT_SIGNAL_DISCOVER_HINTS,
QUESTION_HINTS,
REPLY_MODE_CARD,
REPLY_MODE_TEXT,
ROUTE_FOLLOWUP_CONFIRM,
ROUTE_FOLLOWUP_CONTINUE,
ROUTE_PROJECT_CREATE,
ROUTE_PROJECT_SIGNAL_CANCEL,
ROUTE_PROJECT_SIGNAL_COMMIT,
ROUTE_PROJECT_SIGNAL_CREATE,
ROUTE_PROJECT_SIGNAL_DISCOVER,
ROUTE_PROJECT_SIGNAL_REFRESH,
ROUTE_QUERY_ANSWER,
ROUTE_SIGNAL_SOURCE_ONBOARD,
ROUTE_TASK_CREATE,
SEMANTIC_VERSION,
SIGNAL_CREATE_HINTS,
SIGNAL_CREATE_REGEX,
SHORT_CONFIRM_RE,
SMALLTALK_EXACT,
SMALLTALK_SHORT_HINTS,
SMALLTALK_STRUCTURED_HINTS,
SOURCE_ONBOARDING_ACTION_RE,
TASK_CREATE_HINTS,
TASK_CREATE_REGEX,
LEGACY_COMMAND_OBJECT_RE,
LEGACY_COMMAND_PROJECT_CREATE_RE,
LEGACY_COMMAND_VERB_RE,
OBVIOUS_QUESTION_RE,
QUERY_INTENT_RE,
WORKFLOW_FOLLOWUP,
WORKFLOW_NEW_PROJECT,
WORKFLOW_PROJECT,
WORKFLOW_PROJECT_SIGNAL,
WORKFLOW_QUERY,
WORKFLOW_SIGNAL_SOURCE,
WORKFLOW_TASK,
)
from .models import ReplyContract, WorkflowBundle, WorkflowPlan, workflow_slot
class WorkflowSemanticPlanner:
_WRITE_VERBS = (
"新增",
"增加",
"添加",
"加",
"新建",
"创建",
"建立",
"立项",
"接入",
"修复",
"优化",
"改成",
"改为",
"更新",
"分配",
"指派",
"归档",
"执行",
)
_FOLLOWUP_ALLOWED_ACTIONS = ("continue", "confirm", "commit", "cancel", "refresh")
_FOLLOWUP_CONTEXT_KEYS = ("target_work_item_id", "followup_allowed_actions", "reply_to_message_id", "thread_anchor")
@staticmethod
def _normalize_text(text: str) -> str:
return re.sub(r"\s+", "", str(text or "")).lower()
@classmethod
def _has_question_structure(cls, text: str) -> bool:
normalized = cls._normalize_text(text)
if not normalized:
return False
return "?" in normalized or "?" in normalized or any(token in normalized for token in QUESTION_HINTS)
@classmethod
def _is_smalltalk_message(cls, text: str) -> bool:
compact = re.sub(r"[\s\??!!,,。~~、]+", "", str(text or "")).strip().lower()
if not compact:
return True
if any(keyword in compact for keyword in SMALLTALK_STRUCTURED_HINTS):
return False
if compact in SMALLTALK_EXACT:
return True
if len(compact) <= 8 and any(keyword in compact for keyword in SMALLTALK_SHORT_HINTS):
return True
return False
@classmethod
def _looks_like_project_create(cls, text: str) -> bool:
normalized = cls._normalize_text(text)
return any(token in normalized for token in PROJECT_CREATE_HINTS) or bool(PROJECT_CREATE_REGEX.search(normalized))
@classmethod
def _looks_like_task_create(cls, text: str) -> bool:
normalized = cls._normalize_text(text)
return any(token in normalized for token in TASK_CREATE_HINTS) or bool(TASK_CREATE_REGEX.search(normalized))
@classmethod
def _looks_like_signal_create(cls, text: str) -> bool:
normalized = cls._normalize_text(text)
if any(token in normalized for token in PROJECT_SIGNAL_DISCOVER_HINTS):
return True
return any(token in normalized for token in SIGNAL_CREATE_HINTS) or bool(SIGNAL_CREATE_REGEX.search(normalized))
@classmethod
def looks_like_project_create_request(cls, text: str) -> bool:
return cls._looks_like_project_create(text)
@classmethod
def looks_like_task_create_request(cls, text: str) -> bool:
return cls._looks_like_task_create(text)
@classmethod
def looks_like_signal_create_request(cls, text: str) -> bool:
return cls._looks_like_signal_create(text)
@classmethod
def looks_like_signal_source_request(cls, text: str) -> bool:
normalized = cls._normalize_text(text)
if len(normalized) < 3:
return False
if GENERIC_SIGNAL_SOURCE_MESSAGE_RE.search(normalized):
return True
return bool(SOURCE_ONBOARDING_ACTION_RE.search(normalized))
@staticmethod
def is_healthcheck_message(text: str) -> bool:
return bool(HEALTHCHECK_RE.search(str(text or "").strip()))
@staticmethod
def is_short_confirmation_noise(text: str) -> bool:
return bool(SHORT_CONFIRM_RE.search(str(text or "").strip()))
@classmethod
def wants_followup_continue(cls, text: str) -> bool:
raw = str(text or "").strip()
if not raw:
return False
if FOLLOWUP_CREATE_CONTINUE_RE.search(raw):
return bool(FOLLOWUP_REFERENCE_RE.search(raw))
return bool(FOLLOWUP_CONTINUE_RE.search(raw))
@staticmethod
def wants_followup_status(text: str) -> bool:
return bool(FOLLOWUP_STATUS_RE.search(str(text or "")))
@classmethod
def looks_like_external_connect_request(cls, text: str) -> bool:
raw = str(text or "").strip()
if not raw:
return False
return bool(CREATE_ACTION_RE.search(raw) and EXTERNAL_INTEGRATION_RE.search(raw))
@classmethod
def is_legacy_business_command(cls, text: str) -> bool:
raw = str(text or "").strip()
if not raw:
return False
if cls.is_query_like_question(raw):
return False
if LEGACY_COMMAND_PROJECT_CREATE_RE.search(raw):
return True
return bool(LEGACY_COMMAND_VERB_RE.search(raw) and LEGACY_COMMAND_OBJECT_RE.search(raw))
@classmethod
def looks_like_project_lookup_question(cls, text: str) -> bool:
raw = str(text or "").strip()
if not raw:
return False
if cls.is_legacy_business_command(raw):
return False
normalized = cls._normalize_text(raw)
if PROJECT_LIST_QUERY_RE.search(raw):
return True
if PROJECT_ID_QUERY_RE.search(raw):
return True
if "项目" not in normalized:
return False
return any(token in normalized for token in ("有吗", "有没有", "有无", "是否有", "是否存在", "存在吗", "是什么", "哪些"))
@staticmethod
def extract_project_lookup_keyword(text: str) -> str:
raw = str(text or "").strip()
if not raw:
return ""
cleaned = re.sub(
r"(请问|麻烦问下|问下|现在|当前|目前|系统里|系统中|项目里|项目中|有没有|有无|有吗|是否有|是否存在|存在吗|这个|那个|一个|个|项目|列表|清单|有|吗|呢|呀|啊|么)",
" ",
raw,
flags=re.IGNORECASE,
)
cleaned = re.sub(r"[,。,.;;::!??()()\\[\\]【】]+", " ", cleaned)
cleaned = re.sub(r"\s+", " ", cleaned).strip(" ??")
return cleaned[:40]
@classmethod
def is_query_like_question(cls, text: str) -> bool:
raw = str(text or "").strip()
if not raw:
return False
normalized = cls._normalize_text(raw)
has_question_structure = bool(OBVIOUS_QUESTION_RE.search(raw) or QUERY_INTENT_RE.search(raw))
if not has_question_structure:
return False
has_operation_verb = bool(LEGACY_COMMAND_VERB_RE.search(raw))
imperative_tokens = ("帮我", "请", "麻烦", "直接", "立刻", "马上", "执行", "处理", "安排")
has_imperative = any(token in normalized for token in imperative_tokens)
return not has_operation_verb and not has_imperative
@classmethod
def is_obvious_question(cls, text: str) -> bool:
raw = str(text or "").strip()
if not raw:
return False
if cls.is_query_like_question(raw):
return True
if cls.is_legacy_business_command(raw):
return False
if cls.looks_like_project_lookup_question(raw):
return True
return bool(OBVIOUS_QUESTION_RE.search(raw))
@classmethod
def looks_like_contextual_write_confirmation(cls, text: str) -> bool:
normalized = cls._normalize_text(text)
if not normalized:
return False
if cls._has_question_structure(text):
return False
has_write_action = any(token in normalized for token in CONTEXTUAL_WRITE_ACTION_HINTS)
if not has_write_action:
return False
has_confirm = any(token in normalized for token in CONTEXTUAL_CONFIRM_HINTS)
has_deictic = any(token in normalized for token in CONTEXTUAL_DEICTIC_HINTS)
return has_confirm or has_deictic
@classmethod
def _looks_like_followup(cls, text: str, *, metadata: Optional[dict[str, Any]] = None) -> bool:
payload = metadata if isinstance(metadata, dict) else {}
if any(str(payload.get(key) or "").strip() for key in cls._FOLLOWUP_CONTEXT_KEYS):
return True
if FOLLOWUP_WORK_ITEM_ID_RE.search(str(text or "")):
return True
normalized = cls._normalize_text(text)
followup_tokens = (*FOLLOWUP_CONFIRM_HINTS, *FOLLOWUP_CONTINUE_HINTS, *FOLLOWUP_CANCEL_HINTS, *FOLLOWUP_REFRESH_HINTS)
return any(token in normalized for token in followup_tokens) or cls.wants_followup_continue(text) or cls.wants_followup_status(text)
@classmethod
def parse_followup_action(
cls,
*,
metadata: Optional[dict[str, Any]] = None,
canonical_intent: str,
message_text: str = "",
) -> str:
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 cls._FOLLOWUP_ALLOWED_ACTIONS:
return explicit_action
followup_context_present = bool(
payload.get("followup_context_present")
or payload.get("target_work_item_id")
or payload.get("followup_allowed_actions")
or payload.get("reply_to_message_id")
or payload.get("thread_anchor")
)
if followup_context_present:
normalized = cls._normalize_text(message_text)
if any(token in normalized for token in FOLLOWUP_REFRESH_HINTS):
return "refresh"
if any(token in normalized for token in FOLLOWUP_CANCEL_HINTS):
return "cancel"
if any(token in normalized for token in FOLLOWUP_COMMIT_HINTS):
return "commit"
if canonical_intent == INTENT_FOLLOWUP_CONFIRM:
return "confirm"
return "continue"
@classmethod
def parse_draft_action(cls, *, message_text: str) -> tuple[str, str]:
text = str(message_text or "").strip()
lowered = text.lower()
if any(hint in text for hint in ("修改", "改成", "改为", "改", "换成")):
note = re.sub(r"^(?:修改|改成|改为|改|换成)\s*", "", text).strip()
return "revise", note
if any(hint in text for hint in FOLLOWUP_CANCEL_HINTS):
return "cancel", ""
if any(hint in text for hint in ("确认", "好的", "就这样", "行", "ok", "okay", "同意", "没问题")) or lowered in {"y", "yes"}:
return "confirm", ""
return "unknown", ""
@classmethod
def _bundle_id(
cls,
*,
source_system: str,
message_text: str,
workflows: Iterable[WorkflowPlan],
) -> str:
basis = "|".join(
[
str(source_system or "").strip().lower(),
str(message_text or "").strip(),
",".join(f"{item.workflow_type}:{item.workflow_action}" for item in workflows),
]
)
digest = hashlib.sha1(basis.encode("utf-8"), usedforsecurity=False).hexdigest()
return f"semantic-{digest[:16]}"
@classmethod
def _reply_contract(cls, *, source_system: str, primary_workflow: str) -> ReplyContract:
mode = REPLY_MODE_CARD if str(source_system or "").strip().lower() == "feishu" else REPLY_MODE_TEXT
return ReplyContract(mode=mode, template_key="workflow_bundle", summary=primary_workflow)
@classmethod
def _gate_for_workflows(cls, workflows: list[WorkflowPlan]) -> str:
if any(item.requires_confirmation or item.clarification_needed for item in workflows):
return MESSAGE_GATE_PENDING
return MESSAGE_GATE_AUTO
@classmethod
def _plan_from_intent(
cls,
*,
intent_name: str,
message_text: str,
source_system: str,
workflow_route: str = "",
project_id: Optional[int] = None,
metadata: Optional[dict[str, Any]] = None,
) -> WorkflowBundle:
payload = dict(metadata or {})
normalized_route = str(workflow_route or payload.get("workflow_route") or "").strip().lower()
workflows: list[WorkflowPlan] = []
slots = [slot for slot in [workflow_slot("project_id", project_id)] if slot is not None]
if intent_name == INTENT_QUERY:
workflows.append(
WorkflowPlan(
workflow_type=WORKFLOW_QUERY,
workflow_action="answer",
target_scope="project" if project_id is not None else "global",
operation_codes=[ROUTE_QUERY_ANSWER],
requires_confirmation=False,
confidence=0.88,
slots=slots,
)
)
elif intent_name == INTENT_PROJECT_CREATE:
workflows.append(
WorkflowPlan(
workflow_type=WORKFLOW_NEW_PROJECT,
workflow_action="create",
target_scope="global",
operation_codes=["project.create"],
requires_confirmation=bool(payload.get("requires_confirmation")),
confidence=float(payload.get("confidence") or 0.9),
slots=slots,
)
)
elif intent_name in {INTENT_PROJECT_UPDATE, INTENT_PROJECT_ARCHIVE, INTENT_PROJECT_REOPEN, INTENT_PROJECT_DELETE}:
workflows.append(
WorkflowPlan(
workflow_type=WORKFLOW_PROJECT,
workflow_action=str(intent_name.split(".", 1)[1] if "." in intent_name else "update"),
target_scope="project",
operation_codes=[intent_name],
requires_confirmation=bool(payload.get("requires_confirmation")),
confidence=float(payload.get("confidence") or 0.82),
slots=slots,
)
)
elif intent_name in {INTENT_TASK_CREATE, INTENT_TASK_UPDATE, INTENT_TASK_ASSIGN, INTENT_TASK_PRIORITIZE, INTENT_TASK_STATUS_TRANSITION, INTENT_TASK_DELETE}:
workflows.append(
WorkflowPlan(
workflow_type=WORKFLOW_TASK,
workflow_action=str(intent_name.split(".", 1)[1] if "." in intent_name else "create"),
target_scope="project" if project_id is not None else "global",
operation_codes=[intent_name if intent_name != INTENT_TASK_CREATE else ROUTE_TASK_CREATE],
requires_confirmation=bool(payload.get("requires_confirmation")),
confidence=float(payload.get("confidence") or 0.82),
slots=slots,
)
)
elif intent_name == INTENT_SIGNAL_SOURCE_ONBOARD:
workflows.append(
WorkflowPlan(
workflow_type=WORKFLOW_SIGNAL_SOURCE,
workflow_action="onboard",
target_scope="project" if project_id is not None else "global",
operation_codes=[ROUTE_SIGNAL_SOURCE_ONBOARD],
requires_confirmation=bool(payload.get("requires_confirmation")),
confidence=float(payload.get("confidence") or 0.92),
slots=slots,
)
)
elif intent_name == INTENT_SIGNAL_CREATE:
route_to_action = {
ROUTE_PROJECT_SIGNAL_DISCOVER: "discover",
ROUTE_PROJECT_SIGNAL_COMMIT: "commit",
ROUTE_PROJECT_SIGNAL_CANCEL: "cancel",
ROUTE_PROJECT_SIGNAL_REFRESH: "refresh",
}
workflow_action = route_to_action.get(normalized_route, "create")
operation_codes = [normalized_route or ROUTE_PROJECT_SIGNAL_CREATE]
workflows.append(
WorkflowPlan(
workflow_type=WORKFLOW_PROJECT_SIGNAL,
workflow_action=workflow_action,
target_scope="project" if project_id is not None else "global",
operation_codes=operation_codes,
requires_confirmation=workflow_action in {"discover", "commit", "cancel", "refresh"} or bool(payload.get("requires_confirmation")),
confidence=float(payload.get("confidence") or 0.8),
slots=slots,
)
)
elif intent_name in {INTENT_FOLLOWUP_CONFIRM, INTENT_FOLLOWUP_CONTINUE}:
followup_action = cls.parse_followup_action(
metadata={**payload, "followup_context_present": True},
canonical_intent=intent_name,
message_text=message_text,
)
work_item_id = payload.get("target_work_item_id")
followup_slots = [slot for slot in [*slots, workflow_slot("target_work_item_id", work_item_id)] if slot is not None]
workflows.append(
WorkflowPlan(
workflow_type=WORKFLOW_FOLLOWUP,
workflow_action=followup_action,
target_scope="thread",
operation_codes=[ROUTE_FOLLOWUP_CONFIRM if followup_action == "confirm" else ROUTE_FOLLOWUP_CONTINUE],
requires_confirmation=False,
confidence=float(payload.get("confidence") or 0.82),
slots=followup_slots,
)
)
else:
return cls.plan_message(message_text=message_text, source_system=source_system, metadata=payload)
primary_workflow = workflows[0].workflow_type
return WorkflowBundle(
bundle_id=cls._bundle_id(source_system=source_system, message_text=message_text, workflows=workflows),
semantic_version=SEMANTIC_VERSION,
source_system=str(source_system or "").strip().lower() or "unknown",
primary_workflow=primary_workflow,
workflows=workflows,
message_gate=cls._gate_for_workflows(workflows),
reply_contract=cls._reply_contract(source_system=source_system, primary_workflow=primary_workflow),
diagnostics={"intent_name": intent_name, "workflow_route": normalized_route or None},
)
@classmethod
def plan_message(
cls,
*,
message_text: str,
source_system: str,
metadata: Optional[dict[str, Any]] = None,
) -> WorkflowBundle:
text = str(message_text or "").strip()
payload = dict(metadata or {})
normalized = cls._normalize_text(text)
workflows: list[WorkflowPlan] = []
if not text or cls._is_smalltalk_message(text):
workflows.append(
WorkflowPlan(
workflow_type=WORKFLOW_QUERY,
workflow_action="answer",
target_scope="global",
operation_codes=[ROUTE_QUERY_ANSWER],
requires_confirmation=False,
confidence=0.55,
clarification_needed=False,
)
)
elif cls._looks_like_followup(text, metadata=payload):
workflows.append(
WorkflowPlan(
workflow_type=WORKFLOW_FOLLOWUP,
workflow_action=cls.parse_followup_action(
metadata={**payload, "followup_context_present": True},
canonical_intent=str(payload.get("intent_name") or INTENT_FOLLOWUP_CONTINUE),
message_text=text,
),
target_scope="thread",
operation_codes=[ROUTE_FOLLOWUP_CONTINUE],
requires_confirmation=False,
confidence=0.84,
slots=[
slot
for slot in [
workflow_slot(
"target_work_item_id",
int(FOLLOWUP_WORK_ITEM_ID_RE.search(text).group(1))
if FOLLOWUP_WORK_ITEM_ID_RE.search(text)
else payload.get("target_work_item_id"),
)
]
if slot is not None
],
)
)
else:
project_like = cls._looks_like_project_create(text)
signal_source_like = cls.looks_like_signal_source_request(text)
task_like = cls._looks_like_task_create(text)
signal_like = cls._looks_like_signal_create(text)
question_like = cls._has_question_structure(text)
if project_like:
workflows.append(
WorkflowPlan(
workflow_type=WORKFLOW_NEW_PROJECT,
workflow_action="create",
target_scope="global",
operation_codes=["project.create"],
requires_confirmation=False,
confidence=0.9,
)
)
if signal_source_like:
workflows.append(
WorkflowPlan(
workflow_type=WORKFLOW_SIGNAL_SOURCE,
workflow_action="onboard",
target_scope="project" if project_like else "global",
operation_codes=[ROUTE_SIGNAL_SOURCE_ONBOARD],
requires_confirmation=False,
confidence=0.92,
)
)
if task_like and not project_like:
workflows.append(
WorkflowPlan(
workflow_type=WORKFLOW_TASK,
workflow_action="create",
target_scope="project",
operation_codes=[ROUTE_TASK_CREATE],
requires_confirmation=False,
confidence=0.82,
)
)
if signal_like and not signal_source_like:
workflow_action = "discover" if any(token in normalized for token in PROJECT_SIGNAL_DISCOVER_HINTS) else "create"
route_code = ROUTE_PROJECT_SIGNAL_DISCOVER if workflow_action == "discover" else ROUTE_PROJECT_SIGNAL_CREATE
workflows.append(
WorkflowPlan(
workflow_type=WORKFLOW_PROJECT_SIGNAL,
workflow_action=workflow_action,
target_scope="project",
operation_codes=[route_code],
requires_confirmation=workflow_action == "discover",
confidence=0.78,
)
)
if not workflows:
workflows.append(
WorkflowPlan(
workflow_type=WORKFLOW_QUERY if question_like or not any(token in normalized for token in cls._WRITE_VERBS) else WORKFLOW_QUERY,
workflow_action="answer",
target_scope="global",
operation_codes=[ROUTE_QUERY_ANSWER],
requires_confirmation=False,
confidence=0.88 if question_like else 0.6,
)
)
primary_workflow = workflows[0].workflow_type
return WorkflowBundle(
bundle_id=cls._bundle_id(source_system=source_system, message_text=text, workflows=workflows),
semantic_version=SEMANTIC_VERSION,
source_system=str(source_system or "").strip().lower() or "unknown",
primary_workflow=primary_workflow,
workflows=workflows,
message_gate=cls._gate_for_workflows(workflows),
reply_contract=cls._reply_contract(source_system=source_system, primary_workflow=primary_workflow),
diagnostics={},
)
@classmethod
def plan_from_intent(
cls,
*,
intent_name: str,
message_text: str,
source_system: str,
workflow_route: str = "",
project_id: Optional[int] = None,
metadata: Optional[dict[str, Any]] = None,
) -> WorkflowBundle:
return cls._plan_from_intent(
intent_name=intent_name,
message_text=message_text,
source_system=source_system,
workflow_route=workflow_route,
project_id=project_id,
metadata=metadata,
)
@classmethod
def deserialize_bundle(cls, value: Any) -> Optional[WorkflowBundle]:
if not isinstance(value, dict):
return None
workflows_raw = value.get("workflows")
reply_contract_raw = value.get("reply_contract")
if not isinstance(workflows_raw, list) or not isinstance(reply_contract_raw, dict):
return None
workflows: list[WorkflowPlan] = []
for item in workflows_raw:
if not isinstance(item, dict):
continue
slots_raw = item.get("slots")
workflows.append(
WorkflowPlan(
workflow_type=str(item.get("workflow_type") or "").strip(),
workflow_action=str(item.get("workflow_action") or "").strip(),
target_scope=str(item.get("target_scope") or "").strip(),
operation_codes=[str(code) for code in item.get("operation_codes") or [] if str(code).strip()],
requires_confirmation=bool(item.get("requires_confirmation")),
confidence=float(item.get("confidence") or 0.0),
slots=[
workflow_slot(str(slot.get("name") or "").strip(), slot.get("value"), source=str(slot.get("source") or "semantic_planner"))
for slot in (slots_raw if isinstance(slots_raw, list) else [])
if isinstance(slot, dict) and workflow_slot(str(slot.get("name") or "").strip(), slot.get("value"), source=str(slot.get("source") or "semantic_planner")) is not None
],
clarification_needed=bool(item.get("clarification_needed")),
metadata=dict(item.get("metadata") or {}),
)
)
return WorkflowBundle(
bundle_id=str(value.get("bundle_id") or "").strip(),
semantic_version=str(value.get("semantic_version") or SEMANTIC_VERSION).strip(),
source_system=str(value.get("source_system") or "").strip(),
primary_workflow=str(value.get("primary_workflow") or "").strip(),
workflows=workflows,
message_gate=str(value.get("message_gate") or MESSAGE_GATE_AUTO).strip(),
reply_contract=ReplyContract(
mode=str(reply_contract_raw.get("mode") or REPLY_MODE_TEXT).strip(),
template_key=str(reply_contract_raw.get("template_key") or "workflow_bundle").strip(),
summary=str(reply_contract_raw.get("summary") or "").strip(),
),
diagnostics=dict(value.get("diagnostics") or {}),
)
@classmethod
def classify_scene(cls, *, capture_result: dict[str, Any]) -> str:
bundle = cls.deserialize_bundle(capture_result.get("semantic_bundle"))
if bundle is not None and bundle.workflows:
if any(item.workflow_type != WORKFLOW_QUERY for item in bundle.workflows):
return "execute"
return "qa"
status = str(capture_result.get("status") or "").strip().lower()
classification = str(capture_result.get("classification") or "").strip().lower()
intent = str(capture_result.get("intent") or "").strip().lower()
workflow_route = str(capture_result.get("workflow_route") or "").strip().lower()
execute_intents = {
INTENT_PROJECT_CREATE,
INTENT_PROJECT_UPDATE,
INTENT_PROJECT_ARCHIVE,
INTENT_PROJECT_REOPEN,
INTENT_PROJECT_DELETE,
INTENT_TASK_CREATE,
INTENT_TASK_UPDATE,
INTENT_TASK_ASSIGN,
INTENT_TASK_PRIORITIZE,
INTENT_TASK_STATUS_TRANSITION,
INTENT_TASK_DELETE,
INTENT_SIGNAL_CREATE,
INTENT_SIGNAL_SOURCE_ONBOARD,
INTENT_FOLLOWUP_CONFIRM,
INTENT_FOLLOWUP_CONTINUE,
}
execute_routes = {
ROUTE_PROJECT_CREATE,
ROUTE_TASK_CREATE,
ROUTE_SIGNAL_SOURCE_ONBOARD,
ROUTE_PROJECT_SIGNAL_CREATE,
ROUTE_PROJECT_SIGNAL_DISCOVER,
ROUTE_PROJECT_SIGNAL_COMMIT,
ROUTE_PROJECT_SIGNAL_CANCEL,
ROUTE_PROJECT_SIGNAL_REFRESH,
ROUTE_FOLLOWUP_CONFIRM,
ROUTE_FOLLOWUP_CONTINUE,
}
if intent in execute_intents or workflow_route in execute_routes or classification in {"project", "signal", "command", "new_project", "project_signal"}:
return "execute"
if status == "error" or classification == "error":
return "error"
if status == "answered" or classification in {"question", "qa"}:
return "qa"
if status in {"captured", "ignored"}:
return "execute"
return "qa"
@classmethod
def is_action_request_capture(cls, *, capture_result: dict[str, Any]) -> bool:
bundle = cls.deserialize_bundle(capture_result.get("semantic_bundle"))
if bundle is not None and bundle.workflows:
return any(item.workflow_type != WORKFLOW_QUERY for item in bundle.workflows)
return False
__all__ = ["WorkflowSemanticPlanner"]

View File

@ -158,5 +158,8 @@ class FeishuReceiveCaptureFlowMixin:
"ai_trace_id": result.get("ai_trace_id"), "ai_trace_id": result.get("ai_trace_id"),
"state_trace_id": state_trace_id, "state_trace_id": state_trace_id,
"scene": scene, "scene": scene,
"semantic_bundle_id": result.get("semantic_bundle_id"),
"semantic_version": result.get("semantic_version"),
"semantic_source": result.get("semantic_source"),
}, },
} }

View File

@ -1,7 +1,7 @@
from __future__ import annotations from __future__ import annotations
from .shared import * from .shared import *
from ...ai_pmo.ai_pmo_intent import IntentResolver from ...ai_pmo.workflow_semantic_planner import WorkflowSemanticPlanner
class FeishuReceiveRoutingMixin: class FeishuReceiveRoutingMixin:
async def _route_scene_reply( async def _route_scene_reply(
@ -151,48 +151,8 @@ class FeishuReceiveRoutingMixin:
def _classify_message_scene(*, capture_result: Dict[str, Any], message_content: str = "") -> str: def _classify_message_scene(*, capture_result: Dict[str, Any], message_content: str = "") -> str:
if not isinstance(capture_result, dict): if not isinstance(capture_result, dict):
return "error" return "error"
intent = str(capture_result.get("intent") or "").strip().lower() _ = message_content
status = str(capture_result.get("status") or "").strip().lower() return WorkflowSemanticPlanner.classify_scene(capture_result=capture_result)
classification = str(capture_result.get("classification") or "").strip().lower()
execute_intents = {
"project.create",
"project.update",
"project.archive",
"project.reopen",
"project.delete",
"task.create",
"task.update",
"task.assign",
"task.prioritize",
"task.status_transition",
"task.delete",
"personnel.role_update",
"personnel.workload_update",
"personnel.availability_update",
"knowledge.doc_upsert",
"knowledge.chunk_upsert",
"knowledge.link_upsert",
"knowledge.reindex",
"notification.create",
"notification.ack",
"notification.close",
"signal.create",
"signal_source.onboard",
"followup.confirm",
"followup.continue",
}
raw_intent = str(IntentResolver.resolve(text=message_content, project_id=None).name or "").strip().lower()
if raw_intent in execute_intents or raw_intent in {"project", "signal", "command", "new_project", "project_signal"}:
return "execute"
if intent in execute_intents or classification in {"project", "signal", "command", "new_project", "project_signal"}:
return "execute"
if status in {"captured", "ignored"}:
return "execute"
if status == "answered" or classification in {"question", "qa"}:
return "qa"
if status == "error" or classification == "error":
return "error"
return "qa"
@staticmethod @staticmethod
def _build_error_reply_text() -> str: def _build_error_reply_text() -> str:

View File

@ -1,6 +1,7 @@
from __future__ import annotations from __future__ import annotations
from .shared import * from .shared import *
from ...ai_pmo.workflow_semantic_planner import WorkflowSemanticPlanner
class FeishuReplyLLMCoreMixin: class FeishuReplyLLMCoreMixin:
async def _reply_with_active_role_prompt( async def _reply_with_active_role_prompt(
@ -221,22 +222,13 @@ class FeishuReplyLLMCoreMixin:
"你直接说目标、对象和期望动作即可。" "你直接说目标、对象和期望动作即可。"
) )
@staticmethod @classmethod
def _is_action_request_message(message_content: Optional[str]) -> bool: def _is_action_request_message(
normalized = re.sub(r"\s+", "", str(message_content or "").lower()) cls,
if not normalized: *,
return False capture_result: Dict[str, Any],
action_tokens = ( ) -> bool:
"创建项目", return WorkflowSemanticPlanner.is_action_request_capture(capture_result=capture_result)
"新建项目",
"立项",
"创建信号",
"创建信号源",
"新增信号源",
"接入信号源",
"信号源接入",
)
return any(token in normalized for token in action_tokens)
@classmethod @classmethod
def _build_action_request_ack_reply( def _build_action_request_ack_reply(
@ -245,11 +237,13 @@ class FeishuReplyLLMCoreMixin:
message_content: Optional[str], message_content: Optional[str],
capture_result: Dict[str, Any], capture_result: Dict[str, Any],
) -> Optional[str]: ) -> Optional[str]:
if not cls._is_action_request_message(message_content): _ = message_content
if not cls._is_action_request_message(capture_result=capture_result):
return None return None
normalized = re.sub(r"\s+", "", str(message_content or "").lower()) bundle = WorkflowSemanticPlanner.deserialize_bundle(capture_result.get("semantic_bundle"))
project_like = any(token in normalized for token in ("创建项目", "新建项目", "立项")) workflow_types = [item.workflow_type for item in (bundle.workflows if bundle is not None else [])]
signal_like = any(token in normalized for token in ("创建信号", "创建信号源", "新增信号源", "接入信号源", "信号源接入")) 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: if project_like and signal_like:
return "已收到,项目创建和信号源接入请求已记录。" return "已收到,项目创建和信号源接入请求已记录。"
if project_like: if project_like:

View File

@ -33,7 +33,7 @@ class FeishuPipelineContextPrefillAMixin:
) )
@staticmethod @staticmethod
def _looks_like_project_request_artifact(text: str) -> bool: def _looks_like_project_request_artifact(text: str) -> bool: # semantic-lint: ignore - project context artifact detector, not workflow routing
normalized = re.sub(r"\s+", "", str(text or "")).lower() normalized = re.sub(r"\s+", "", str(text or "")).lower()
if not normalized: if not normalized:
return False return False

View File

@ -2,6 +2,7 @@
from __future__ import annotations from __future__ import annotations
from .shared import * from .shared import *
from ...ai_pmo.workflow_semantic_planner import WorkflowSemanticPlanner
class FeishuPipelineCoreBaseMixin: class FeishuPipelineCoreBaseMixin:
@ -33,11 +34,11 @@ class FeishuPipelineCoreBaseMixin:
@staticmethod @staticmethod
def _is_healthcheck_message(message: str) -> bool: def _is_healthcheck_message(message: str) -> bool:
return bool(HEALTHCHECK_RE.search(str(message or "").strip())) return WorkflowSemanticPlanner.is_healthcheck_message(message)
@staticmethod @staticmethod
def _is_short_confirmation_message(message: str) -> bool: def _is_short_confirmation_message(message: str) -> bool:
return bool(SHORT_CONFIRM_RE.search(str(message or "").strip())) return WorkflowSemanticPlanner.is_short_confirmation_noise(message)
@staticmethod @staticmethod
def _is_correction_message(message: str) -> bool: def _is_correction_message(message: str) -> bool:
@ -45,32 +46,19 @@ class FeishuPipelineCoreBaseMixin:
@staticmethod @staticmethod
def _looks_like_continue_followup(message: str) -> bool: def _looks_like_continue_followup(message: str) -> bool:
text = str(message or "").strip() return WorkflowSemanticPlanner.wants_followup_continue(message)
if not text:
return False
if FOLLOWUP_CREATE_CONTINUE_RE.search(text):
return bool(FOLLOWUP_REFERENCE_RE.search(text))
return bool(FOLLOWUP_CONTINUE_RE.search(text))
@staticmethod @staticmethod
def _looks_like_task_create_request(message: str) -> bool: def _looks_like_task_create_request(message: str) -> bool: # semantic-lint: ignore - compat wrapper over workflow_semantic_planner
return bool(TASK_CREATE_RE.search(str(message or "").strip())) return WorkflowSemanticPlanner.looks_like_task_create_request(message)
@staticmethod @staticmethod
def _looks_like_project_create_request(message: str) -> bool: def _looks_like_project_create_request(message: str) -> bool: # semantic-lint: ignore - compat wrapper over workflow_semantic_planner
text = str(message or "").strip() return WorkflowSemanticPlanner.looks_like_project_create_request(message)
if not text:
return False
if PROJECT_CREATE_GATE_RE.search(text):
return True
return bool(LEGACY_COMMAND_PROJECT_CREATE_RE.search(text))
@staticmethod @staticmethod
def _looks_like_external_connect_request(message: str) -> bool: def _looks_like_external_connect_request(message: str) -> bool: # semantic-lint: ignore - compat wrapper over workflow_semantic_planner
text = str(message or "").strip() return WorkflowSemanticPlanner.looks_like_external_connect_request(message)
if not text:
return False
return bool(CREATE_ACTION_RE.search(text) and EXTERNAL_INTEGRATION_RE.search(text))
@staticmethod @staticmethod
def _can_execute_high_value_action(user: Optional[User]) -> bool: def _can_execute_high_value_action(user: Optional[User]) -> bool:
@ -169,4 +157,3 @@ class FeishuPipelineCoreBaseMixin:
if signal.matched_project_id is not None: if signal.matched_project_id is not None:
result["project_id"] = int(signal.matched_project_id) result["project_id"] = int(signal.matched_project_id)
return result return result

View File

@ -2,6 +2,7 @@
from __future__ import annotations from __future__ import annotations
from .shared import * from .shared import *
from ...ai_pmo.workflow_semantic_planner import WorkflowSemanticPlanner
from app.utils.thread_context import extract_thread_anchor from app.utils.thread_context import extract_thread_anchor
@ -20,7 +21,7 @@ class FeishuPipelineFollowupAMixin:
if not source_chat_id: if not source_chat_id:
return None return None
wants_continue = self._looks_like_continue_followup(message) wants_continue = self._looks_like_continue_followup(message)
wants_status = bool(FOLLOWUP_STATUS_RE.search(message)) wants_status = WorkflowSemanticPlanner.wants_followup_status(message)
if not wants_continue and not wants_status: if not wants_continue and not wants_status:
return None return None

View File

@ -176,7 +176,10 @@ class FeishuPipelineGateDispatchMixin:
asked_via="feishu", asked_via="feishu",
project_id=None, project_id=None,
source_chat_id=source_chat_id, source_chat_id=source_chat_id,
source_message_id=feishu_message_id,
conversation_key=conversation_key, conversation_key=conversation_key,
source_sender_open_id=source_sender_open_id,
thread_context=thread_context,
) )
qa_capture = await self._build_direct_qa_capture( qa_capture = await self._build_direct_qa_capture(
message=message, message=message,

View File

@ -257,7 +257,7 @@ class FeishuPipelineIntentGateCoreMixin:
} }
@staticmethod @staticmethod
def _looks_like_contextual_project_request(message: str) -> bool: def _looks_like_contextual_project_request(message: str) -> bool: # semantic-lint: ignore - context prefill helper for missing fields only
normalized = re.sub(r"\s+", "", str(message or "")).lower() normalized = re.sub(r"\s+", "", str(message or "")).lower()
if not normalized: if not normalized:
return False return False

View File

@ -2,39 +2,21 @@
from __future__ import annotations from __future__ import annotations
from .shared import * from .shared import *
from ...ai_pmo.workflow_semantic_planner import WorkflowSemanticPlanner
class FeishuPipelineLookupAndBlockMixin: class FeishuPipelineLookupAndBlockMixin:
@staticmethod @staticmethod
def _looks_like_project_create_command(message: str) -> bool: def _looks_like_project_create_command(message: str) -> bool: # semantic-lint: ignore - compat wrapper over workflow_semantic_planner
return bool(LEGACY_COMMAND_PROJECT_CREATE_RE.search(str(message or ""))) return WorkflowSemanticPlanner.looks_like_project_create_request(message)
@classmethod @classmethod
def _looks_like_project_lookup_question(cls, message: str) -> bool: def _looks_like_project_lookup_question(cls, message: str) -> bool: # semantic-lint: ignore - compat wrapper over workflow_semantic_planner
text = str(message or "").strip() return WorkflowSemanticPlanner.looks_like_project_lookup_question(message)
if not text:
return False
if cls._is_legacy_business_command(text):
return False
normalized = re.sub(r"\s+", "", text.lower())
if PROJECT_LIST_QUERY_RE.search(text):
return True
if PROJECT_ID_QUERY_RE.search(text):
return True
if "项目" not in normalized:
return False
return any(token in normalized for token in ("有吗", "有没有", "有无", "是否有", "是否存在", "存在吗", "是什么", "哪些"))
@classmethod @classmethod
def _is_legacy_business_command(cls, message: str) -> bool: def _is_legacy_business_command(cls, message: str) -> bool:
text = str(message or "").strip() return WorkflowSemanticPlanner.is_legacy_business_command(message)
if not text:
return False
if cls._is_query_like_question(message):
return False
if cls._looks_like_project_create_command(text):
return True
return bool(LEGACY_COMMAND_VERB_RE.search(text) and LEGACY_COMMAND_OBJECT_RE.search(text))
async def _maybe_block_legacy_command_request( async def _maybe_block_legacy_command_request(
self, self,
@ -89,7 +71,7 @@ class FeishuPipelineLookupAndBlockMixin:
reply_text = self._format_project_lookup_hit_rows(projects[:8], prefix="当前项目") reply_text = self._format_project_lookup_hit_rows(projects[:8], prefix="当前项目")
linked_project_ids = [int(item["id"]) for item in projects[:8] if _safe_int(item.get("id"))] linked_project_ids = [int(item["id"]) for item in projects[:8] if _safe_int(item.get("id"))]
else: else:
keyword = self._extract_project_lookup_keyword(message) keyword = WorkflowSemanticPlanner.extract_project_lookup_keyword(message)
matched_rows = self._match_projects_by_keyword(projects=projects, keyword=keyword) matched_rows = self._match_projects_by_keyword(projects=projects, keyword=keyword)
if matched_rows: if matched_rows:
linked_project_ids = [int(item["id"]) for item in matched_rows if _safe_int(item.get("id"))] linked_project_ids = [int(item["id"]) for item in matched_rows if _safe_int(item.get("id"))]
@ -177,21 +159,6 @@ class FeishuPipelineLookupAndBlockMixin:
"command_result": None, "command_result": None,
} }
@staticmethod
def _extract_project_lookup_keyword(message: str) -> str:
text = str(message or "").strip()
if not text:
return ""
cleaned = re.sub(
r"(请问|麻烦问下|问下|现在|当前|目前|系统里|系统中|项目里|项目中|有没有|有无|有吗|是否有|是否存在|存在吗|这个|那个|一个|个|项目|列表|清单|有|吗|呢|呀|啊|么)",
" ",
text,
flags=re.IGNORECASE,
)
cleaned = re.sub(r"[,。,.;;::!??()()\\[\\]【】]+", " ", cleaned)
cleaned = re.sub(r"\s+", " ", cleaned).strip()
return cleaned[:40]
@staticmethod @staticmethod
def _match_projects_by_keyword(*, projects: list[dict[str, Any]], keyword: str) -> list[dict[str, Any]]: def _match_projects_by_keyword(*, projects: list[dict[str, Any]], keyword: str) -> list[dict[str, Any]]:
normalized_keyword = re.sub(r"\s+", "", str(keyword or "")).lower() normalized_keyword = re.sub(r"\s+", "", str(keyword or "")).lower()
@ -235,4 +202,3 @@ class FeishuPipelineLookupAndBlockMixin:
if "长连接" in normalized and any(token in normalized for token in ("已通", "都已通", "正常", "在线", "已恢复")): if "长连接" in normalized and any(token in normalized for token in ("已通", "都已通", "正常", "在线", "已恢复")):
return True return True
return False return False

View File

@ -2,7 +2,7 @@
from __future__ import annotations from __future__ import annotations
from .shared import * from .shared import *
from app.utils.thread_context import build_feishu_thread_key from app.utils.thread_context import build_feishu_conversation_scope_key
class FeishuPipelineMessageUtilsAMixin: class FeishuPipelineMessageUtilsAMixin:
@ -79,12 +79,10 @@ class FeishuPipelineMessageUtilsAMixin:
source_message_id: Optional[str] = None, source_message_id: Optional[str] = None,
thread_context: Optional[dict[str, Any]] = None, thread_context: Optional[dict[str, Any]] = None,
) -> str: ) -> str:
return build_feishu_thread_key( return build_feishu_conversation_scope_key(
source_chat_id=source_chat_id, source_chat_id=source_chat_id,
source_sender_open_id=source_sender_open_id, source_sender_open_id=source_sender_open_id,
sender=sender, sender=sender,
source_message_id=source_message_id,
thread_context=thread_context,
) )
@staticmethod @staticmethod

View File

@ -59,7 +59,10 @@ class FeishuPipelineProcessMessageDefaultMixin:
asked_via="feishu", asked_via="feishu",
project_id=None, project_id=None,
source_chat_id=source_chat_id, source_chat_id=source_chat_id,
source_message_id=feishu_message_id,
conversation_key=conversation_key, conversation_key=conversation_key,
source_sender_open_id=source_sender_open_id,
thread_context=thread_context,
message_intent="mixed", message_intent="mixed",
mixed_signal_part=mixed_signal_part, mixed_signal_part=mixed_signal_part,
) )
@ -74,7 +77,10 @@ class FeishuPipelineProcessMessageDefaultMixin:
asked_via="feishu", asked_via="feishu",
project_id=None, project_id=None,
source_chat_id=source_chat_id, source_chat_id=source_chat_id,
source_message_id=feishu_message_id,
conversation_key=conversation_key, conversation_key=conversation_key,
source_sender_open_id=source_sender_open_id,
thread_context=thread_context,
) )
return await self._finalize_direct_qa_capture_result( return await self._finalize_direct_qa_capture_result(
message=message, message=message,
@ -108,7 +114,10 @@ class FeishuPipelineProcessMessageDefaultMixin:
asked_via="feishu", asked_via="feishu",
project_id=None, project_id=None,
source_chat_id=source_chat_id, source_chat_id=source_chat_id,
source_message_id=feishu_message_id,
conversation_key=conversation_key, conversation_key=conversation_key,
source_sender_open_id=source_sender_open_id,
thread_context=thread_context,
message_intent="mixed", message_intent="mixed",
mixed_signal_part=mixed_signal_part, mixed_signal_part=mixed_signal_part,
) )
@ -123,7 +132,10 @@ class FeishuPipelineProcessMessageDefaultMixin:
asked_via="feishu", asked_via="feishu",
project_id=None, project_id=None,
source_chat_id=source_chat_id, source_chat_id=source_chat_id,
source_message_id=feishu_message_id,
conversation_key=conversation_key, conversation_key=conversation_key,
source_sender_open_id=source_sender_open_id,
thread_context=thread_context,
) )
return await self._finalize_direct_qa_capture_result( return await self._finalize_direct_qa_capture_result(
message=message, message=message,
@ -150,7 +162,10 @@ class FeishuPipelineProcessMessageDefaultMixin:
asked_via="feishu", asked_via="feishu",
project_id=None, project_id=None,
source_chat_id=source_chat_id, source_chat_id=source_chat_id,
source_message_id=feishu_message_id,
conversation_key=conversation_key, conversation_key=conversation_key,
source_sender_open_id=source_sender_open_id,
thread_context=thread_context,
message_intent="mixed", message_intent="mixed",
mixed_signal_part=mixed_signal_part, mixed_signal_part=mixed_signal_part,
) )

View File

@ -2,6 +2,7 @@
from __future__ import annotations from __future__ import annotations
from .shared import * from .shared import *
from ...ai_pmo.workflow_semantic_planner import WorkflowSemanticPlanner
class FeishuPipelineProcessMessageHelpersAMixin: class FeishuPipelineProcessMessageHelpersAMixin:
@ -176,29 +177,9 @@ class FeishuPipelineProcessMessageHelpersAMixin:
return text[:2000] return text[:2000]
@classmethod @classmethod
def _is_obvious_question(cls, message: str) -> bool: def _is_obvious_question(cls, message: str) -> bool: # semantic-lint: ignore - compat wrapper over workflow_semantic_planner
text = str(message or "").strip() return WorkflowSemanticPlanner.is_obvious_question(message)
if not text:
return False
if cls._is_query_like_question(message):
return True
if cls._is_legacy_business_command(text):
return False
if cls._looks_like_project_lookup_question(text):
return True
return bool(OBVIOUS_QUESTION_RE.search(text))
@classmethod @classmethod
def _is_query_like_question(cls, message: str) -> bool: def _is_query_like_question(cls, message: str) -> bool: # semantic-lint: ignore - compat wrapper over workflow_semantic_planner
text = str(message or "").strip() return WorkflowSemanticPlanner.is_query_like_question(message)
if not text:
return False
normalized = re.sub(r"\s+", "", text.lower())
has_question_structure = bool(OBVIOUS_QUESTION_RE.search(text) or QUERY_INTENT_RE.search(text))
if not has_question_structure:
return False
has_operation_verb = bool(LEGACY_COMMAND_VERB_RE.search(text))
imperative_tokens = ("帮我", "请", "麻烦", "直接", "立刻", "马上", "执行", "处理", "安排")
has_imperative = any(token in normalized for token in imperative_tokens)
return not has_operation_verb and not has_imperative

View File

@ -190,9 +190,23 @@ class FeishuPipelineProcessMessageHelpersBMixin:
"ai_trace_id": f"feishu-qa-{(feishu_message_id or 'noid')[:32]}", "ai_trace_id": f"feishu-qa-{(feishu_message_id or 'noid')[:32]}",
"results": [], "results": [],
"rag_result": rag_result, "rag_result": rag_result,
"clarification_code": str(rag_result.get("clarification_code") or "").strip() or None,
"clarification_candidates": (
rag_result.get("clarification_candidates")
if isinstance(rag_result.get("clarification_candidates"), list)
else []
),
"reason_code": str(rag_result.get("reason_code") or "").strip() or None,
"anchor_kind": str(rag_result.get("anchor_kind") or "").strip() or None,
"anchor_mode": str(rag_result.get("anchor_mode") or "").strip() or None,
"meta_followup_kind": str(rag_result.get("meta_followup_kind") or "").strip() or None,
"direct_reply_required": True, "direct_reply_required": True,
"direct_reply_text": self._build_rag_reply_text( "direct_reply_text": (
str(rag_result.get("answer") or "").strip()
if str(rag_result.get("clarification_code") or "").strip()
else self._build_rag_reply_text(
answer=str(rag_result.get("answer") or ""), answer=str(rag_result.get("answer") or ""),
sources=rag_result.get("sources") or [], sources=rag_result.get("sources") or [],
)
), ),
} }

View File

@ -169,6 +169,15 @@ class LLMPromptProfileRuntimeMixin:
"routing_config_version": meta.get("routing_config_version") or None, "routing_config_version": meta.get("routing_config_version") or None,
"routing_config_updated_at": meta.get("routing_config_updated_at") or None, "routing_config_updated_at": meta.get("routing_config_updated_at") or None,
} }
self._last_ai_call_summary = {
"function_name": function_name,
"status": status,
"planned_tier": meta.get("planned_tier") or None,
"executed_tier": meta.get("executed_tier") or None,
"route_source": meta.get("route_source") or None,
"route_rule_name": meta.get("route_rule_name") or None,
"routing_decision": meta.get("routing_decision") or None,
}
async with AsyncSessionLocal() as session: async with AsyncSessionLocal() as session:
for attempt in range(2): for attempt in range(2):
try: try:

View File

@ -0,0 +1,3 @@
from .service import PromptReleaseService
__all__ = ["PromptReleaseService"]

View File

@ -0,0 +1,156 @@
from __future__ import annotations
from typing import Any, Optional
from sqlalchemy import func, select
from sqlalchemy.ext.asyncio import AsyncSession
from ...config.ai_prompt_registry import get_prompt_registry_entry
from ...models import PromptRelease
from ...utils.timezone import now_cn_naive
class PromptReleaseService:
def __init__(self, db: AsyncSession):
self.db = db
@staticmethod
def _snapshot(row: PromptRelease) -> dict[str, Any]:
return {
"id": int(row.id),
"tenant_id": int(row.tenant_id),
"prompt_key": row.prompt_key,
"version": int(row.version),
"body_snapshot": row.body_snapshot,
"status": row.status,
"notes": row.notes,
"created_by": int(row.created_by) if row.created_by is not None else None,
"activated_by": int(row.activated_by) if row.activated_by is not None else None,
"activated_at": row.activated_at,
}
async def create_release(
self,
*,
tenant_id: int,
prompt_key: str,
body_snapshot: str,
created_by: Optional[int],
notes: Optional[str] = None,
) -> dict[str, Any]:
prompt_key = str(prompt_key or "").strip()
if not prompt_key:
raise ValueError("prompt_key is required")
if get_prompt_registry_entry(prompt_key) is None:
raise ValueError("unsupported prompt_key")
body_snapshot = str(body_snapshot or "").strip()
if not body_snapshot:
raise ValueError("body_snapshot is required")
current_max = (
await self.db.execute(
select(func.max(PromptRelease.version)).where(
PromptRelease.tenant_id == int(tenant_id),
PromptRelease.prompt_key == prompt_key,
)
)
).scalar_one_or_none()
row = PromptRelease(
tenant_id=int(tenant_id),
prompt_key=prompt_key,
version=int(current_max or 0) + 1,
body_snapshot=body_snapshot,
status="draft",
notes=str(notes or "").strip() or None,
created_by=created_by,
)
self.db.add(row)
await self.db.commit()
await self.db.refresh(row)
return self._snapshot(row)
async def activate_release(
self,
*,
tenant_id: int,
release_id: int,
actor_id: Optional[int],
) -> dict[str, Any]:
row = (
await self.db.execute(
select(PromptRelease).where(
PromptRelease.tenant_id == int(tenant_id),
PromptRelease.id == int(release_id),
)
)
).scalar_one_or_none()
if row is None:
raise ValueError("prompt release not found")
previous = (
await self.db.execute(
select(PromptRelease).where(
PromptRelease.tenant_id == int(tenant_id),
PromptRelease.prompt_key == row.prompt_key,
PromptRelease.status == "active",
)
)
).scalars().all()
for item in previous:
item.status = "archived"
row.status = "active"
row.activated_by = actor_id
row.activated_at = now_cn_naive().isoformat()
await self.db.commit()
await self.db.refresh(row)
return self._snapshot(row)
async def list_releases(
self,
*,
tenant_id: int,
prompt_key: Optional[str] = None,
) -> dict[str, Any]:
stmt = select(PromptRelease).where(PromptRelease.tenant_id == int(tenant_id))
if prompt_key:
stmt = stmt.where(PromptRelease.prompt_key == str(prompt_key).strip())
rows = (
await self.db.execute(
stmt.order_by(PromptRelease.prompt_key.asc(), PromptRelease.version.desc(), PromptRelease.id.desc())
)
).scalars().all()
return {"items": [self._snapshot(row) for row in rows], "total": len(rows)}
async def get_active_template(
self,
*,
tenant_id: int,
prompt_key: str,
) -> Optional[str]:
row = (
await self.db.execute(
select(PromptRelease.body_snapshot).where(
PromptRelease.tenant_id == int(tenant_id),
PromptRelease.prompt_key == str(prompt_key or "").strip(),
PromptRelease.status == "active",
)
)
).scalar_one_or_none()
return str(row).strip() if row else None
async def render_prompt(
self,
*,
tenant_id: int,
prompt_key: str,
fallback_template: str,
_override_template: Optional[str] = None,
**kwargs: Any,
) -> str:
template = str(_override_template or "").strip() if _override_template is not None else ""
if not template:
template = str(await self.get_active_template(tenant_id=tenant_id, prompt_key=prompt_key) or "").strip()
if not template:
template = str(fallback_template or "")
try:
return str(template).format(**kwargs).strip()
except Exception:
return str(fallback_template or "").format(**kwargs).strip()

View File

@ -0,0 +1,3 @@
from .service import ReplyIterationService
__all__ = ["ReplyIterationService"]

View File

@ -0,0 +1,511 @@
from __future__ import annotations
import re
from typing import Any, Optional
from sqlalchemy import and_, select
from sqlalchemy.ext.asyncio import AsyncSession
from ...models import (
Experiment,
FeedbackCenterRecord,
JudgeRun,
OptimizerCandidate,
PromptRelease,
QARecord,
QualityAsset,
QualityAssetPair,
)
from ...utils.timezone import now_cn_naive
PROCESS_TALK_PREFIXES = ("让我先", "我先", "我来看看", "我先看", "让我看")
EXECUTION_CLAIM_TOKENS = ("通过 API", "帮你创建", "我先检查", "后端服务", "已帮你")
class ReplyIterationService:
def __init__(self, db: AsyncSession):
self.db = db
@staticmethod
def _asset_snapshot(row: QualityAsset) -> dict[str, Any]:
return {
"id": int(row.id),
"tenant_id": int(row.tenant_id),
"project_id": int(row.project_id) if row.project_id is not None else None,
"asset_type": row.asset_type,
"status": row.status,
"source_feedback_record_id": int(row.source_feedback_record_id) if row.source_feedback_record_id is not None else None,
"qa_record_id": int(row.qa_record_id) if row.qa_record_id is not None else None,
"ai_call_log_id": int(row.ai_call_log_id) if row.ai_call_log_id is not None else None,
"prompt_key": row.prompt_key,
"workflow_type": row.workflow_type,
"asked_via": row.asked_via,
"planned_tier": row.planned_tier,
"executed_tier": row.executed_tier,
"route_source": row.route_source,
"route_rule_name": row.route_rule_name,
"question_text": row.question_text,
"answer_text": row.answer_text,
"corrected_answer": row.corrected_answer,
"source_snapshot": row.source_snapshot or {},
"tags": row.tags or [],
"notes": row.notes,
"approved_by": int(row.approved_by) if row.approved_by is not None else None,
"approved_at": row.approved_at,
}
@staticmethod
def _pair_snapshot(row: QualityAssetPair) -> dict[str, Any]:
return {
"id": int(row.id),
"tenant_id": int(row.tenant_id),
"project_id": int(row.project_id) if row.project_id is not None else None,
"pair_type": row.pair_type,
"status": row.status,
"chosen_asset_id": int(row.chosen_asset_id),
"rejected_asset_id": int(row.rejected_asset_id),
"reason_text": row.reason_text,
"approved_by": int(row.approved_by) if row.approved_by is not None else None,
"approved_at": row.approved_at,
}
@staticmethod
def _judge_snapshot(row: JudgeRun) -> dict[str, Any]:
return {
"id": int(row.id),
"tenant_id": int(row.tenant_id),
"project_id": int(row.project_id) if row.project_id is not None else None,
"asset_id": int(row.asset_id) if row.asset_id is not None else None,
"pair_id": int(row.pair_id) if row.pair_id is not None else None,
"prompt_key": row.prompt_key,
"workflow_type": row.workflow_type,
"asked_via": row.asked_via,
"planned_tier": row.planned_tier,
"executed_tier": row.executed_tier,
"route_rule_name": row.route_rule_name,
"judge_status": row.judge_status,
"bucket_key": row.bucket_key,
"rule_score": float(row.rule_score or 0.0) if row.rule_score is not None else None,
"llm_score": float(row.llm_score or 0.0) if row.llm_score is not None else None,
"overall_score": float(row.overall_score or 0.0) if row.overall_score is not None else None,
"findings": row.findings or [],
"metadata_json": row.metadata_json or {},
}
@staticmethod
def _candidate_snapshot(row: OptimizerCandidate) -> dict[str, Any]:
return {
"id": int(row.id),
"tenant_id": int(row.tenant_id),
"project_id": int(row.project_id) if row.project_id is not None else None,
"judge_run_id": int(row.judge_run_id),
"candidate_type": row.candidate_type,
"target_type": row.target_type,
"target_ref": row.target_ref,
"status": row.status,
"suggestion": row.suggestion or {},
"evidence": row.evidence or {},
"notes": row.notes,
}
@staticmethod
def _experiment_snapshot(row: Experiment) -> dict[str, Any]:
return {
"id": int(row.id),
"tenant_id": int(row.tenant_id),
"title": row.title,
"hypothesis": row.hypothesis,
"status": row.status,
"target_metric": row.target_metric,
"current_score": float(row.current_score or 0.0) if row.current_score is not None else None,
"experiment_type": row.experiment_type,
"baseline_release_id": int(row.baseline_release_id) if row.baseline_release_id is not None else None,
"candidate_release_id": int(row.candidate_release_id) if row.candidate_release_id is not None else None,
"scope_json": row.scope_json or {},
"result_summary": row.result_summary or {},
"decision": row.decision,
"project_id": int(row.project_id) if row.project_id is not None else None,
}
async def promote_feedback_record(
self,
*,
tenant_id: int,
record_id: int,
actor_id: Optional[int],
asset_type: str,
prompt_key: Optional[str] = None,
workflow_type: Optional[str] = None,
notes: Optional[str] = None,
pair_with_asset_id: Optional[int] = None,
pair_role: Optional[str] = None,
pair_reason: Optional[str] = None,
) -> dict[str, Any]:
record = (
await self.db.execute(
select(FeedbackCenterRecord).where(
FeedbackCenterRecord.tenant_id == int(tenant_id),
FeedbackCenterRecord.id == int(record_id),
)
)
).scalar_one_or_none()
if record is None:
raise ValueError("feedback record not found")
qa_record = None
if str(record.object_type or "").strip() == "qa_record" and record.object_id is not None:
qa_record = (
await self.db.execute(
select(QARecord).where(
and_(
QARecord.tenant_id == int(tenant_id),
QARecord.id == int(record.object_id),
)
)
)
).scalar_one_or_none()
before = record.before_value if isinstance(record.before_value, dict) else {}
after = record.after_value if isinstance(record.after_value, dict) else {}
ai_snapshot = record.ai_snapshot if isinstance(record.ai_snapshot, dict) else {}
routing_snapshot = (
qa_record.routing_snapshot
if qa_record is not None and isinstance(qa_record.routing_snapshot, dict)
else {}
)
asset = QualityAsset(
tenant_id=int(tenant_id),
project_id=int(record.project_id) if record.project_id is not None else None,
asset_type=str(asset_type or "").strip().lower(),
status="approved",
source_feedback_record_id=int(record.id),
qa_record_id=int(qa_record.id) if qa_record is not None else None,
prompt_key=str(prompt_key or ai_snapshot.get("prompt_key") or "").strip() or None,
workflow_type=str(workflow_type or ai_snapshot.get("workflow_type") or "query.read_only").strip() or None,
asked_via=str(getattr(qa_record, "asked_via", "") or "").strip() or None,
planned_tier=str(routing_snapshot.get("planned_tier") or "").strip() or None,
executed_tier=str(routing_snapshot.get("executed_tier") or "").strip() or None,
route_source=str(routing_snapshot.get("route_source") or "").strip() or None,
route_rule_name=str(routing_snapshot.get("route_rule_name") or "").strip() or None,
question_text=str(before.get("question") or getattr(qa_record, "question", "") or "").strip(),
answer_text=str(before.get("answer") or getattr(qa_record, "answer", "") or "").strip(),
corrected_answer=str(after.get("correct_answer") or record.reason_text or "").strip() or None,
source_snapshot={
"feedback_record_id": int(record.id),
"reason_code": record.reason_code,
"review_status": record.review_status,
"routing_snapshot": routing_snapshot,
},
tags=[
code
for code in (
str(record.reason_code or "").strip(),
str(after.get("rating") or "").strip(),
)
if code
],
notes=str(notes or "").strip() or None,
approved_by=actor_id,
approved_at=now_cn_naive().isoformat(),
)
if not asset.question_text or not asset.answer_text:
raise ValueError("feedback record missing question/answer evidence")
self.db.add(asset)
await self.db.flush()
pair_payload = None
if pair_with_asset_id is not None:
paired_asset = (
await self.db.execute(
select(QualityAsset).where(
QualityAsset.tenant_id == int(tenant_id),
QualityAsset.id == int(pair_with_asset_id),
)
)
).scalar_one_or_none()
if paired_asset is None:
raise ValueError("paired quality asset not found")
role = str(pair_role or "chosen").strip().lower()
chosen_id = int(asset.id) if role == "chosen" else int(paired_asset.id)
rejected_id = int(paired_asset.id) if role == "chosen" else int(asset.id)
pair = QualityAssetPair(
tenant_id=int(tenant_id),
project_id=int(record.project_id) if record.project_id is not None else None,
pair_type="preference",
status="approved",
chosen_asset_id=chosen_id,
rejected_asset_id=rejected_id,
reason_text=str(pair_reason or "").strip() or None,
approved_by=actor_id,
approved_at=now_cn_naive().isoformat(),
)
self.db.add(pair)
await self.db.flush()
pair_payload = self._pair_snapshot(pair)
await self.db.commit()
await self.db.refresh(asset)
return {"asset": self._asset_snapshot(asset), "pair": pair_payload}
async def list_assets(
self,
*,
tenant_id: int,
asset_type: Optional[str] = None,
prompt_key: Optional[str] = None,
limit: int = 20,
offset: int = 0,
) -> dict[str, Any]:
stmt = select(QualityAsset).where(QualityAsset.tenant_id == int(tenant_id))
if asset_type:
stmt = stmt.where(QualityAsset.asset_type == str(asset_type).strip().lower())
if prompt_key:
stmt = stmt.where(QualityAsset.prompt_key == str(prompt_key).strip())
rows = (
await self.db.execute(
stmt.order_by(QualityAsset.id.desc()).offset(int(offset)).limit(int(limit))
)
).scalars().all()
return {
"items": [self._asset_snapshot(row) for row in rows],
"total": len(rows),
"limit": int(limit),
"offset": int(offset),
}
@staticmethod
def _build_rule_findings(asset: QualityAsset) -> list[dict[str, Any]]:
findings: list[dict[str, Any]] = []
answer = str(asset.answer_text or "").strip()
normalized = re.sub(r"\s+", "", answer)
if any(answer.startswith(prefix) for prefix in PROCESS_TALK_PREFIXES):
findings.append({"code": "process_talk_prefix", "severity": "warning", "message": "存在过程话术前缀"})
if any(token in normalized for token in [re.sub(r"\s+", "", token) for token in EXECUTION_CLAIM_TOKENS]):
findings.append({"code": "execution_claim_prefix", "severity": "warning", "message": "存在执行型兜底话术"})
if str(asset.planned_tier or "").strip() == "low" and str(asset.executed_tier or "").strip() == "high":
findings.append({"code": "tier_escalation", "severity": "info", "message": "存在 low -> high 升级"})
return findings
async def create_judge_run(
self,
*,
tenant_id: int,
asset_id: int,
actor_id: Optional[int],
) -> dict[str, Any]:
_ = actor_id
asset = (
await self.db.execute(
select(QualityAsset).where(
QualityAsset.tenant_id == int(tenant_id),
QualityAsset.id == int(asset_id),
)
)
).scalar_one_or_none()
if asset is None:
raise ValueError("quality asset not found")
findings = self._build_rule_findings(asset)
penalty = min(0.8, 0.25 * len(findings))
score = round(max(0.0, 1.0 - penalty), 4)
bucket_key = "|".join(
[
str(asset.prompt_key or "unknown"),
str(asset.workflow_type or "unknown"),
str(asset.asked_via or "unknown"),
str(asset.planned_tier or "unknown"),
str(asset.executed_tier or "unknown"),
]
)
judge = JudgeRun(
tenant_id=int(tenant_id),
project_id=int(asset.project_id) if asset.project_id is not None else None,
asset_id=int(asset.id),
prompt_key=asset.prompt_key,
workflow_type=asset.workflow_type,
asked_via=asset.asked_via,
planned_tier=asset.planned_tier,
executed_tier=asset.executed_tier,
route_rule_name=asset.route_rule_name,
judge_status="completed",
bucket_key=bucket_key,
rule_score=score,
llm_score=None,
overall_score=score,
findings=findings,
metadata_json={"llm_status": "pending", "asset_type": asset.asset_type},
)
self.db.add(judge)
await self.db.commit()
await self.db.refresh(judge)
return self._judge_snapshot(judge)
async def list_judge_runs(self, *, tenant_id: int, limit: int = 20, offset: int = 0) -> dict[str, Any]:
rows = (
await self.db.execute(
select(JudgeRun)
.where(JudgeRun.tenant_id == int(tenant_id))
.order_by(JudgeRun.id.desc())
.offset(int(offset))
.limit(int(limit))
)
).scalars().all()
return {"items": [self._judge_snapshot(row) for row in rows], "total": len(rows), "limit": int(limit), "offset": int(offset)}
async def create_optimizer_candidate(
self,
*,
tenant_id: int,
judge_run_id: int,
actor_id: Optional[int],
) -> dict[str, Any]:
_ = actor_id
judge = (
await self.db.execute(
select(JudgeRun).where(
JudgeRun.tenant_id == int(tenant_id),
JudgeRun.id == int(judge_run_id),
)
)
).scalar_one_or_none()
if judge is None:
raise ValueError("judge run not found")
findings = judge.findings if isinstance(judge.findings, list) else []
finding_codes = {str(item.get("code") or "").strip() for item in findings if isinstance(item, dict)}
if {"process_talk_prefix", "execution_claim_prefix"}.intersection(finding_codes):
candidate_type = "renderer_policy_change"
target_type = "renderer_policy"
target_ref = "reply_renderer"
summary = "收紧过程话术和执行型前缀"
elif "tier_escalation" in finding_codes:
candidate_type = "routing_policy_change"
target_type = "routing_policy"
target_ref = "model_routing"
summary = "复核 low/high 路由升级门槛"
else:
candidate_type = "prompt_change"
target_type = "prompt_key"
target_ref = str(judge.prompt_key or "unknown")
summary = "优化回答提示词结构"
row = OptimizerCandidate(
tenant_id=int(tenant_id),
project_id=int(judge.project_id) if judge.project_id is not None else None,
judge_run_id=int(judge.id),
candidate_type=candidate_type,
target_type=target_type,
target_ref=target_ref,
status="draft",
suggestion={"summary": summary, "bucket_key": judge.bucket_key},
evidence={"findings": findings, "overall_score": judge.overall_score},
)
self.db.add(row)
await self.db.commit()
await self.db.refresh(row)
return self._candidate_snapshot(row)
async def list_candidates(self, *, tenant_id: int, limit: int = 20, offset: int = 0) -> dict[str, Any]:
rows = (
await self.db.execute(
select(OptimizerCandidate)
.where(OptimizerCandidate.tenant_id == int(tenant_id))
.order_by(OptimizerCandidate.id.desc())
.offset(int(offset))
.limit(int(limit))
)
).scalars().all()
return {"items": [self._candidate_snapshot(row) for row in rows], "total": len(rows), "limit": int(limit), "offset": int(offset)}
async def list_trials(self, *, tenant_id: int, limit: int = 20, offset: int = 0) -> dict[str, Any]:
rows = (
await self.db.execute(
select(Experiment)
.where(
Experiment.tenant_id == int(tenant_id),
Experiment.experiment_type == "prompt_release_trial",
)
.order_by(Experiment.id.desc())
.offset(int(offset))
.limit(int(limit))
)
).scalars().all()
return {"items": [self._experiment_snapshot(row) for row in rows], "total": len(rows), "limit": int(limit), "offset": int(offset)}
async def create_trial(
self,
*,
tenant_id: int,
actor_id: Optional[int],
title: str,
hypothesis: str,
target_metric: str,
prompt_key: str,
baseline_release_id: int,
candidate_release_id: int,
scope_json: Optional[dict[str, Any]],
) -> dict[str, Any]:
for release_id in (baseline_release_id, candidate_release_id):
release = (
await self.db.execute(
select(PromptRelease).where(
PromptRelease.tenant_id == int(tenant_id),
PromptRelease.id == int(release_id),
PromptRelease.prompt_key == str(prompt_key or "").strip(),
)
)
).scalar_one_or_none()
if release is None:
raise ValueError("prompt release not found for trial")
row = Experiment(
tenant_id=int(tenant_id),
title=str(title or "").strip(),
hypothesis=str(hypothesis or "").strip() or None,
status="running",
target_metric=str(target_metric or "").strip() or None,
experiment_type="prompt_release_trial",
baseline_release_id=int(baseline_release_id),
candidate_release_id=int(candidate_release_id),
scope_json=scope_json or {},
decision=None,
)
row.linked_context_nodes = {"prompt_key": str(prompt_key or "").strip(), "actor_id": actor_id}
self.db.add(row)
await self.db.commit()
await self.db.refresh(row)
return self._experiment_snapshot(row)
async def decide_trial(
self,
*,
tenant_id: int,
trial_id: int,
actor_id: Optional[int],
decision: str,
result_summary: Optional[dict[str, Any]],
) -> dict[str, Any]:
row = (
await self.db.execute(
select(Experiment).where(
Experiment.tenant_id == int(tenant_id),
Experiment.id == int(trial_id),
Experiment.experiment_type == "prompt_release_trial",
)
)
).scalar_one_or_none()
if row is None:
raise ValueError("prompt release trial not found")
normalized = str(decision or "").strip().lower()
if normalized not in {"accepted", "rejected", "rolled_back"}:
raise ValueError("unsupported decision")
row.decision = normalized
row.status = "success" if normalized == "accepted" else "failed"
row.result_summary = result_summary or {}
row.current_score = float((result_summary or {}).get("accepted_rate") or 0.0) if result_summary else row.current_score
row.linked_context_nodes = {
**(row.linked_context_nodes if isinstance(row.linked_context_nodes, dict) else {}),
"decision_actor_id": actor_id,
"decided_at": now_cn_naive().isoformat(),
}
await self.db.commit()
await self.db.refresh(row)
return self._experiment_snapshot(row)

View File

@ -1,7 +1,8 @@
from __future__ import annotations from __future__ import annotations
from .shared import * from .shared import *
from ...ai_pmo.ai_pmo_intent import INTENT_IGNORE, INTENT_QUERY, IntentResolver from ...ai_pmo.ai_pmo_intent import INTENT_QUERY
from ...ai_pmo.workflow_semantic_planner import WorkflowSemanticPlanner
class SignalCaptureEntryMixin: class SignalCaptureEntryMixin:
def __init__(self, db: AsyncSession): def __init__(self, db: AsyncSession):
@ -18,10 +19,15 @@ class SignalCaptureEntryMixin:
if not clean_text: if not clean_text:
return False, "empty" return False, "empty"
resolved_intent = IntentResolver.resolve(text=clean_text, project_id=None) bundle = WorkflowSemanticPlanner.plan_message(
resolved_name = str(resolved_intent.name or "").strip().lower() message_text=clean_text,
if resolved_name in {INTENT_QUERY, INTENT_IGNORE}: source_system="feishu",
return True, f"intent:{resolved_name}" )
workflow_types = [item.workflow_type for item in bundle.workflows]
if workflow_types == ["query_flow"]:
return True, "semantic:query_flow"
if workflow_types == ["followup_flow"]:
return True, "semantic:followup_flow"
if cls._is_question_like(clean_text): if cls._is_question_like(clean_text):
return True, "question_like" return True, "question_like"
return False, "async_queue" return False, "async_queue"

View File

@ -1,6 +1,7 @@
from __future__ import annotations from __future__ import annotations
from .shared import * from .shared import *
from ...ai_pmo.workflow_semantic_planner import WorkflowSemanticPlanner
class SignalCapturePipelineCacheMixin: class SignalCapturePipelineCacheMixin:
async def _sync_feishu_recent_cache( async def _sync_feishu_recent_cache(
@ -110,7 +111,7 @@ class SignalCapturePipelineCacheMixin:
return pipeline_result return pipeline_result
source_orchestration_result: Optional[Dict[str, Any]] = None source_orchestration_result: Optional[Dict[str, Any]] = None
if SignalSourceOrchestrationService.looks_like_signal_source_request(cleaned_message): if WorkflowSemanticPlanner.looks_like_signal_source_request(cleaned_message):
try: try:
source_orchestration_result = await SignalSourceOrchestrationService(self.db).orchestrate_from_signal( source_orchestration_result = await SignalSourceOrchestrationService(self.db).orchestrate_from_signal(
tenant_id=tenant_id, tenant_id=tenant_id,

View File

@ -1,22 +1,15 @@
from __future__ import annotations from __future__ import annotations
from .shared import * from .shared import *
from ...ai_pmo.workflow_semantic_planner import WorkflowSemanticPlanner
class SignalSourceEntryOrchestrateMixin: class SignalSourceEntryOrchestrateMixin:
def __init__(self, db: AsyncSession) -> None: def __init__(self, db: AsyncSession) -> None:
self.db = db self.db = db
@classmethod @classmethod
def looks_like_signal_source_request(cls, text: str) -> bool: def looks_like_signal_source_request(cls, text: str) -> bool: # semantic-lint: ignore - compat wrapper over workflow_semantic_planner
normalized = re.sub(r"\s+", "", str(text or "")) return WorkflowSemanticPlanner.looks_like_signal_source_request(text)
if len(normalized) < 3:
return False
if "信号源" in normalized and any(
token in normalized
for token in ("新增", "增加", "添加", "接入", "配置", "注册", "建立", "加个", "加一个", "加")
):
return True
return bool(SOURCE_ONBOARDING_ACTION_RE.search(normalized))
async def orchestrate_from_signal( async def orchestrate_from_signal(
self, self,
@ -202,4 +195,3 @@ class SignalSourceEntryOrchestrateMixin:
base_result=base_result, base_result=base_result,
run_service=run_service, run_service=run_service,
) )

View File

@ -33,10 +33,8 @@ from ...model.prompt_profile import prompt_profile_service
from ...llm_service import llm_service from ...llm_service import llm_service
from ....utils.timezone import now_cn_naive from ....utils.timezone import now_cn_naive
SOURCE_ONBOARDING_ACTION_RE = re.compile( from ...ai_pmo.workflow_semantic_planner.constants import SOURCE_ONBOARDING_ACTION_RE
r"(新增|增加|添加|接入|配置|注册|建立|加个|加一个|加).{0,8}(信号源|来源|监控源|channel|source|webhook|飞书|github|url|rss)",
re.IGNORECASE,
)
SOURCE_KIND_HINTS: tuple[tuple[str, tuple[str, ...]], ...] = ( SOURCE_KIND_HINTS: tuple[tuple[str, tuple[str, ...]], ...] = (
("feishu", ("飞书", "lark", "群聊", "chat")), ("feishu", ("飞书", "lark", "群聊", "chat")),
("github", ("github", "repo", "仓库", "pull request", "issue")), ("github", ("github", "repo", "仓库", "pull request", "issue")),

View File

@ -0,0 +1,3 @@
from .service import TrainingPipelineService
__all__ = ["TrainingPipelineService"]

View File

@ -0,0 +1,422 @@
from __future__ import annotations
from typing import Any, Optional
from sqlalchemy import and_, select
from sqlalchemy.ext.asyncio import AsyncSession
from ...models import (
QualityAsset,
QualityAssetPair,
TrainedModelArtifact,
TrainedModelDeployment,
TrainingDataset,
TrainingDatasetItem,
TrainingEvaluation,
TrainingJob,
TrainingJobRun,
)
from ...utils.timezone import now_cn_naive
EVALUATION_TYPES = ("offline_judge", "structure_compliance", "shadow_replay")
class TrainingPipelineService:
def __init__(self, db: AsyncSession):
self.db = db
@staticmethod
def _dataset_snapshot(row: TrainingDataset) -> dict[str, Any]:
return {
"id": int(row.id),
"tenant_id": int(row.tenant_id),
"project_id": int(row.project_id) if row.project_id is not None else None,
"dataset_type": row.dataset_type,
"version": row.version,
"status": row.status,
"sample_count": int(row.sample_count or 0),
"selection_rules": row.selection_rules or {},
"channel_scope": row.channel_scope or {},
"notes": row.notes,
}
def _job_snapshot(self, row: TrainingJob, *, artifact: Optional[TrainedModelArtifact] = None) -> dict[str, Any]:
payload = {
"id": int(row.id),
"tenant_id": int(row.tenant_id),
"project_id": int(row.project_id) if row.project_id is not None else None,
"dataset_id": int(row.dataset_id),
"job_type": row.job_type,
"base_model_family": row.base_model_family,
"target_slot": row.target_slot,
"status": row.status,
"provider_backend": row.provider_backend,
"artifact_ref": row.artifact_ref,
"metrics_json": row.metrics_json or {},
}
if artifact is not None:
payload["artifact"] = self._artifact_snapshot(artifact)
return payload
@staticmethod
def _artifact_snapshot(row: TrainedModelArtifact) -> dict[str, Any]:
return {
"id": int(row.id),
"tenant_id": int(row.tenant_id),
"training_job_id": int(row.training_job_id),
"artifact_ref": row.artifact_ref,
"base_model_family": row.base_model_family,
"target_slot": row.target_slot,
"status": row.status,
"metrics_json": row.metrics_json or {},
}
@staticmethod
def _deployment_snapshot(row: TrainedModelDeployment) -> dict[str, Any]:
return {
"id": int(row.id),
"tenant_id": int(row.tenant_id),
"artifact_id": int(row.artifact_id),
"deployment_status": row.deployment_status,
"bound_tier": row.bound_tier,
"effective_scope": row.effective_scope or {},
"decision": row.decision,
"notes": row.notes,
"decided_by": int(row.decided_by) if row.decided_by is not None else None,
"decided_at": row.decided_at,
}
@staticmethod
def _evaluation_snapshot(row: TrainingEvaluation) -> dict[str, Any]:
return {
"id": int(row.id),
"tenant_id": int(row.tenant_id),
"training_job_id": int(row.training_job_id),
"evaluation_type": row.evaluation_type,
"status": row.status,
"score": float(row.score or 0.0) if row.score is not None else None,
"evaluation_summary": row.evaluation_summary or {},
}
async def create_dataset(
self,
*,
tenant_id: int,
actor_id: Optional[int],
dataset_type: str,
version: str,
asset_ids: list[int],
pair_ids: list[int],
selection_rules: Optional[dict[str, Any]],
notes: Optional[str],
) -> dict[str, Any]:
dataset_type = str(dataset_type or "").strip().lower()
if dataset_type not in {"sft", "preference", "eval"}:
raise ValueError("unsupported dataset_type")
asset_ids = [int(x) for x in asset_ids or []]
pair_ids = [int(x) for x in pair_ids or []]
assets = []
if asset_ids:
assets = (
await self.db.execute(
select(QualityAsset).where(
QualityAsset.tenant_id == int(tenant_id),
QualityAsset.id.in_(asset_ids),
)
)
).scalars().all()
pairs = []
if pair_ids:
pairs = (
await self.db.execute(
select(QualityAssetPair).where(
QualityAssetPair.tenant_id == int(tenant_id),
QualityAssetPair.id.in_(pair_ids),
)
)
).scalars().all()
if dataset_type == "sft":
if not assets or any(asset.asset_type != "golden" or asset.status != "approved" for asset in assets):
raise ValueError("SFT dataset only accepts approved golden assets")
if dataset_type == "preference":
if not pairs or any(pair.status != "approved" for pair in pairs):
raise ValueError("Preference dataset only accepts approved asset pairs")
if dataset_type == "eval" and not (assets or pairs):
raise ValueError("Eval dataset requires assets or pairs")
project_id = None
if assets:
project_id = assets[0].project_id
elif pairs:
project_id = pairs[0].project_id
dataset = TrainingDataset(
tenant_id=int(tenant_id),
project_id=int(project_id) if project_id is not None else None,
dataset_type=dataset_type,
version=str(version or "").strip() or f"{dataset_type}-v1",
status="ready",
sample_count=len(assets) + len(pairs),
selection_rules=selection_rules or {},
channel_scope={},
notes=str(notes or "").strip() or None,
created_by=actor_id,
)
self.db.add(dataset)
await self.db.flush()
for asset in assets:
self.db.add(
TrainingDatasetItem(
tenant_id=int(tenant_id),
dataset_id=int(dataset.id),
quality_asset_id=int(asset.id),
redacted_snapshot={
"question_text": asset.question_text,
"answer_text": asset.answer_text,
"corrected_answer": asset.corrected_answer,
"prompt_key": asset.prompt_key,
},
)
)
for pair in pairs:
self.db.add(
TrainingDatasetItem(
tenant_id=int(tenant_id),
dataset_id=int(dataset.id),
quality_asset_pair_id=int(pair.id),
redacted_snapshot={
"pair_type": pair.pair_type,
"reason_text": pair.reason_text,
},
)
)
await self.db.commit()
await self.db.refresh(dataset)
return self._dataset_snapshot(dataset)
async def create_job(
self,
*,
tenant_id: int,
actor_id: Optional[int],
dataset_id: int,
job_type: str,
base_model_family: str,
target_slot: str,
provider_backend: str,
) -> dict[str, Any]:
normalized_job_type = str(job_type or "").strip().lower()
if normalized_job_type not in {"sft", "preference"}:
raise ValueError("unsupported training job_type")
normalized_target_slot = str(target_slot or "").strip().lower()
if normalized_target_slot not in {"low", "high"}:
raise ValueError("unsupported target_slot")
dataset = (
await self.db.execute(
select(TrainingDataset).where(
TrainingDataset.tenant_id == int(tenant_id),
TrainingDataset.id == int(dataset_id),
)
)
).scalar_one_or_none()
if dataset is None:
raise ValueError("training dataset not found")
job = TrainingJob(
tenant_id=int(tenant_id),
project_id=int(dataset.project_id) if dataset.project_id is not None else None,
dataset_id=int(dataset.id),
job_type=normalized_job_type,
base_model_family=str(base_model_family or "").strip(),
target_slot=normalized_target_slot,
status="queued",
provider_backend=str(provider_backend or "").strip(),
artifact_ref=None,
metrics_json={},
created_by=actor_id,
)
self.db.add(job)
await self.db.flush()
run = TrainingJobRun(
tenant_id=int(tenant_id),
training_job_id=int(job.id),
status="queued",
provider_run_ref=None,
metrics_json={},
)
self.db.add(run)
artifact = TrainedModelArtifact(
tenant_id=int(tenant_id),
training_job_id=int(job.id),
artifact_ref=f"artifact:{job.job_type}:{job.id}",
base_model_family=job.base_model_family,
target_slot=job.target_slot,
status="pending_eval",
metrics_json={},
)
self.db.add(artifact)
await self.db.flush()
job.artifact_ref = artifact.artifact_ref
for evaluation_type in EVALUATION_TYPES:
self.db.add(
TrainingEvaluation(
tenant_id=int(tenant_id),
training_job_id=int(job.id),
evaluation_type=evaluation_type,
status="pending",
score=None,
evaluation_summary={},
)
)
await self.db.commit()
await self.db.refresh(job)
await self.db.refresh(artifact)
return self._job_snapshot(job, artifact=artifact)
async def list_datasets(
self,
*,
tenant_id: int,
dataset_type: Optional[str] = None,
limit: int = 20,
offset: int = 0,
) -> dict[str, Any]:
stmt = select(TrainingDataset).where(TrainingDataset.tenant_id == int(tenant_id))
if dataset_type:
stmt = stmt.where(TrainingDataset.dataset_type == str(dataset_type).strip().lower())
rows = (
await self.db.execute(
stmt.order_by(TrainingDataset.id.desc()).offset(int(offset)).limit(int(limit))
)
).scalars().all()
return {"items": [self._dataset_snapshot(row) for row in rows], "total": len(rows), "limit": int(limit), "offset": int(offset)}
async def list_jobs(
self,
*,
tenant_id: int,
status: Optional[str] = None,
limit: int = 20,
offset: int = 0,
) -> dict[str, Any]:
stmt = select(TrainingJob).where(TrainingJob.tenant_id == int(tenant_id))
if status:
stmt = stmt.where(TrainingJob.status == str(status).strip().lower())
rows = (
await self.db.execute(
stmt.order_by(TrainingJob.id.desc()).offset(int(offset)).limit(int(limit))
)
).scalars().all()
return {"items": [self._job_snapshot(row) for row in rows], "total": len(rows), "limit": int(limit), "offset": int(offset)}
async def list_evaluations(self, *, tenant_id: int, job_id: int) -> dict[str, Any]:
rows = (
await self.db.execute(
select(TrainingEvaluation)
.where(
TrainingEvaluation.tenant_id == int(tenant_id),
TrainingEvaluation.training_job_id == int(job_id),
)
.order_by(TrainingEvaluation.id.asc())
)
).scalars().all()
return {"items": [self._evaluation_snapshot(row) for row in rows], "total": len(rows)}
async def promote_artifact(
self,
*,
tenant_id: int,
artifact_id: int,
actor_id: Optional[int],
) -> dict[str, Any]:
artifact = (
await self.db.execute(
select(TrainedModelArtifact).where(
TrainedModelArtifact.tenant_id == int(tenant_id),
TrainedModelArtifact.id == int(artifact_id),
)
)
).scalar_one_or_none()
if artifact is None:
raise ValueError("trained model artifact not found")
artifact.status = "candidate"
row = TrainedModelDeployment(
tenant_id=int(tenant_id),
artifact_id=int(artifact.id),
deployment_status="candidate",
bound_tier=str(artifact.target_slot or "").strip().lower() or "low",
effective_scope={},
decision=None,
notes=None,
decided_by=actor_id,
decided_at=now_cn_naive().isoformat(),
)
self.db.add(row)
await self.db.commit()
await self.db.refresh(row)
return self._deployment_snapshot(row)
async def decide_deployment(
self,
*,
tenant_id: int,
deployment_id: int,
actor_id: Optional[int],
decision: str,
notes: Optional[str],
) -> dict[str, Any]:
row = (
await self.db.execute(
select(TrainedModelDeployment).where(
and_(
TrainedModelDeployment.tenant_id == int(tenant_id),
TrainedModelDeployment.id == int(deployment_id),
)
)
)
).scalar_one_or_none()
if row is None:
raise ValueError("trained model deployment not found")
normalized = str(decision or "").strip().lower()
if normalized not in {"accepted", "rejected", "rolled_back"}:
raise ValueError("unsupported decision")
row.decision = normalized
row.notes = str(notes or "").strip() or None
row.decided_by = actor_id
row.decided_at = now_cn_naive().isoformat()
row.deployment_status = "active" if normalized == "accepted" else "rollback"
await self.db.commit()
await self.db.refresh(row)
return self._deployment_snapshot(row)
async def list_deployments(
self,
*,
tenant_id: int,
deployment_status: Optional[str] = None,
limit: int = 20,
offset: int = 0,
) -> dict[str, Any]:
stmt = select(TrainedModelDeployment).where(TrainedModelDeployment.tenant_id == int(tenant_id))
if deployment_status:
stmt = stmt.where(
TrainedModelDeployment.deployment_status == str(deployment_status).strip().lower()
)
rows = (
await self.db.execute(
stmt.order_by(TrainedModelDeployment.id.desc()).offset(int(offset)).limit(int(limit))
)
).scalars().all()
return {
"items": [self._deployment_snapshot(row) for row in rows],
"total": len(rows),
"limit": int(limit),
"offset": int(offset),
}

View File

@ -44,3 +44,21 @@ def build_feishu_thread_key(
return f"feishu:{chat}:{actor}" if chat else f"feishu:{actor}" return f"feishu:{chat}:{actor}" if chat else f"feishu:{actor}"
return "" return ""
def build_feishu_conversation_scope_key(
*,
source_chat_id: Optional[str],
source_sender_open_id: Optional[str],
sender: Optional[str],
) -> str:
chat = _normalize_token(source_chat_id)
actor = _normalize_token(source_sender_open_id)
if not actor:
actor = _normalize_token(sender).lower()
if chat and actor:
return f"feishu:{chat}:{actor}"
if actor:
return f"feishu:{actor}"
if chat:
return f"feishu:{chat}"
return ""

View File

@ -0,0 +1,484 @@
from __future__ import annotations
from datetime import timedelta
import pytest
import pytest_asyncio
from sqlalchemy import JSON
from sqlalchemy.ext.asyncio import async_sessionmaker, create_async_engine
from app.models.base import Base
from app.models.feishu_reply_outbox import FeishuReplyOutbox
from app.models.ingest_event import IngestEvent
from app.models.qa_record import QARecord
from app.models.qa_session import QASession
from app.models.qa_session_state import QASessionState
from app.services.adaptive_rag_service.mixin_core_intent import AdaptiveRAGCoreIntentMixin
from app.services.adaptive_rag_service.mixin_followup_anchor import (
AdaptiveRAGFollowupAnchorMixin,
FollowupAnchorResolution,
)
from app.services.adaptive_rag_service.mixin_qa_session_core import AdaptiveRAGQASessionCoreMixin
from app.services.adaptive_rag_service.mixin_session_state import AdaptiveRAGSessionStateMixin
from app.utils.timezone import now_cn_naive
def _use_sqlite_json_types() -> None:
for table in (QARecord.__table__, QASessionState.__table__, IngestEvent.__table__):
for column in table.columns:
if column.name in {
"sources",
"domains_used",
"last_entity_ids",
"rewrite_history",
"pending_clarification",
"routing_snapshot",
}:
column.type = JSON()
if table.name == "ingest_events" and column.name == "payload":
column.type = JSON()
@pytest_asyncio.fixture
async def db_session():
engine = create_async_engine("sqlite+aiosqlite:///:memory:")
_use_sqlite_json_types()
async with engine.begin() as conn:
await conn.run_sync(
lambda sync_conn: Base.metadata.create_all(
sync_conn,
tables=[
QASession.__table__,
QASessionState.__table__,
QARecord.__table__,
FeishuReplyOutbox.__table__,
IngestEvent.__table__,
],
)
)
maker = async_sessionmaker(engine, expire_on_commit=False)
async with maker() as session:
yield session
await engine.dispose()
class _FakeContextCache:
async def get_session_messages(self, *, tenant_id: int, session_id: int | None):
_ = (tenant_id, session_id)
return []
async def append_session_messages(self, *, tenant_id: int, session_id: int | None, question: str, answer: str):
_ = (tenant_id, session_id, question, answer)
return None
@staticmethod
def messages_to_turns(messages):
_ = messages
return []
class _Harness(
AdaptiveRAGCoreIntentMixin,
AdaptiveRAGQASessionCoreMixin,
AdaptiveRAGSessionStateMixin,
AdaptiveRAGFollowupAnchorMixin,
):
def __init__(self, db) -> None:
super().__init__(db, tenant_id=1)
self.context_cache = _FakeContextCache()
@pytest.mark.asyncio
async def test_external_conversation_key_creates_and_reuses_real_qa_session(db_session) -> None:
service = _Harness(db_session)
session_id, turn_index = await service._touch_session_for_new_record(
asked_by=None,
asked_via="feishu",
project_id=None,
question="帮我解释下系统架构",
external_conversation_key="feishu:chat-1:root-1",
)
assert session_id is not None
assert turn_index == 1
reused_session_id, reused_turn_index = await service._touch_session_for_new_record(
asked_by=None,
asked_via="feishu",
project_id=None,
question="系统有什么模型?",
external_conversation_key="feishu:chat-1:root-1",
)
assert reused_session_id == session_id
assert reused_turn_index == 2
row = await db_session.get(QASession, int(session_id))
assert row is not None
assert row.user_id is None
assert row.external_conversation_key == "feishu:chat-1:root-1"
@pytest.mark.asyncio
async def test_meta_followup_prefers_single_recent_reply_candidate(db_session) -> None:
service = _Harness(db_session)
session = QASession(
tenant_id=1,
user_id=None,
title="系统架构",
external_conversation_key="feishu:chat-1:root-1",
started_at=now_cn_naive(),
last_message_at=now_cn_naive(),
message_count=1,
)
db_session.add(session)
await db_session.flush()
db_session.add(
QARecord(
tenant_id=1,
question="帮我解释下系统的架构",
answer="PMFlow 系统采用前后端分离架构。",
sources=[],
intent="question",
domains_used=[],
response_time_ms=10,
asked_by=None,
asked_via="feishu",
project_id=None,
session_id=int(session.id),
turn_index=1,
)
)
await db_session.commit()
state = await service._ensure_session_state(session_id=int(session.id))
resolution = await service._resolve_followup_anchor(
question="我问你这个是哪个模型执行回复的?",
session_id=int(session.id),
context_session_id=int(session.id),
session_state=state,
)
assert resolution.anchor_mode == "implicit_recent_reply"
assert resolution.anchor_reply_id is not None
assert resolution.is_meta_followup is True
assert resolution.candidate_replies == []
@pytest.mark.asyncio
async def test_ambiguous_recent_reply_returns_numbered_candidates(db_session) -> None:
service = _Harness(db_session)
now = now_cn_naive()
session = QASession(
tenant_id=1,
user_id=None,
title="新会话",
external_conversation_key="feishu:chat-1:root-1",
started_at=now,
last_message_at=now,
message_count=2,
)
db_session.add(session)
await db_session.flush()
db_session.add_all(
[
QARecord(
tenant_id=1,
question="帮我解释下系统架构",
answer="PMFlow 系统采用前后端分离架构。",
sources=[],
intent="question",
domains_used=[],
response_time_ms=10,
asked_by=None,
asked_via="feishu",
project_id=None,
session_id=int(session.id),
turn_index=1,
created_at=now - timedelta(minutes=1),
),
QARecord(
tenant_id=1,
question="系统有什么模型?",
answer="当前有 low/high 两层模型路由。",
sources=[],
intent="question",
domains_used=[],
response_time_ms=12,
asked_by=None,
asked_via="feishu",
project_id=None,
session_id=int(session.id),
turn_index=2,
created_at=now,
),
]
)
await db_session.commit()
state = await service._ensure_session_state(session_id=int(session.id))
resolution = await service._resolve_followup_anchor(
question="那你刚才那条是哪个模型回复的?",
session_id=int(session.id),
context_session_id=int(session.id),
session_state=state,
)
assert resolution.anchor_mode == "ambiguous_candidates"
assert resolution.anchor_reply_id is None
assert len(resolution.candidate_replies) == 2
assert resolution.candidate_replies[0]["index"] == 1
assert resolution.candidate_replies[1]["index"] == 2
@pytest.mark.asyncio
async def test_pending_clarification_selection_restores_original_question(db_session) -> None:
service = _Harness(db_session)
session = QASession(
tenant_id=1,
user_id=None,
title="新会话",
external_conversation_key="feishu:chat-1:root-1",
started_at=now_cn_naive(),
last_message_at=now_cn_naive(),
message_count=1,
)
db_session.add(session)
await db_session.flush()
state = await service._ensure_session_state(session_id=int(session.id))
state.pending_clarification = {
"kind": "followup_anchor_selection",
"original_question": "我问你这个是哪个模型执行回复的?",
"candidates": [
{"index": 1, "qa_id": 101, "label": "系统架构那条"},
{"index": 2, "qa_id": 102, "label": "系统模型那条"},
],
}
await db_session.commit()
resolved = service._consume_pending_clarification_selection(
question="第二条",
session_state=state,
)
assert resolved is not None
assert resolved["selected_qa_id"] == 102
assert resolved["effective_question"] == "我问你这个是哪个模型执行回复的?"
@pytest.mark.asyncio
async def test_explicit_reply_to_message_id_resolves_anchor_without_session_history(db_session) -> None:
service = _Harness(db_session)
now = now_cn_naive()
db_session.add(
IngestEvent(
tenant_id=1,
channel="feishu_message",
event_key="om_user_message_1",
status="done",
payload={"message_text": "帮我解释下系统的架构"},
)
)
db_session.add(
FeishuReplyOutbox(
tenant_id=1,
trace_id="trace-1",
source_message_id="om_user_message_1",
sent_message_id="om_bot_reply_1",
chat_id="oc-chat-1",
message_text='<at user_id="ou-1">林宇航</at> PMFlow 系统采用前后端分离架构。',
status="sent",
created_at=now - timedelta(minutes=1),
sent_at=now - timedelta(minutes=1),
)
)
await db_session.commit()
resolution = await service._resolve_followup_anchor(
question="这个是哪个模型去回复我问题的?",
session_id=None,
context_session_id=None,
session_state=None,
thread_context={"reply_to_message_id": "om_bot_reply_1"},
)
assert resolution.anchor_mode == "explicit"
assert resolution.is_meta_followup is True
assert resolution.anchor_reply is not None
assert resolution.anchor_reply["question"] == "帮我解释下系统的架构"
assert "前后端分离架构" in resolution.anchor_reply["anchor_reply_preview"]
@pytest.mark.asyncio
async def test_explicit_reply_falls_back_to_current_ingest_closure_turns_when_outbox_mapping_missing(db_session) -> None:
service = _Harness(db_session)
db_session.add(
IngestEvent(
tenant_id=1,
channel="feishu_message",
event_key="om_current_message_1",
status="done",
payload={
"message_text": "这个是哪个模型去回复我问题的?",
"closure_turns": [
{"role": "user", "text": "帮我解释下系统的架构"},
{"role": "assistant", "text": "PMFlow 系统采用前后端分离架构。"},
{"role": "user", "text": "这个是哪个模型去回复我问题的?"},
],
},
)
)
await db_session.commit()
resolution = await service._resolve_followup_anchor(
question="这个是哪个模型去回复我问题的?",
session_id=None,
context_session_id=None,
session_state=None,
source_message_id="om_current_message_1",
thread_context={"reply_to_message_id": "om_unmapped_reply"},
)
assert resolution.anchor_mode == "explicit"
assert resolution.is_meta_followup is True
assert resolution.anchor_reply is not None
assert resolution.anchor_reply["question"] == "帮我解释下系统的架构"
assert "前后端分离架构" in resolution.anchor_reply["anchor_reply_preview"]
@pytest.mark.asyncio
async def test_recent_followup_candidates_can_cross_sessions_by_conversation_scope_key(db_session) -> None:
service = _Harness(db_session)
now = now_cn_naive()
previous_session = QASession(
tenant_id=1,
user_id=None,
title="旧会话",
external_conversation_key="feishu:msg-old-1",
started_at=now - timedelta(minutes=2),
last_message_at=now - timedelta(minutes=1),
message_count=1,
)
current_session = QASession(
tenant_id=1,
user_id=None,
title="新会话",
external_conversation_key="feishu:msg-new-1",
started_at=now,
last_message_at=now,
message_count=1,
)
db_session.add_all([previous_session, current_session])
await db_session.flush()
db_session.add(
QARecord(
tenant_id=1,
question="帮我解释下系统架构",
answer="PMFlow 系统采用前后端分离架构。",
sources=[],
intent="question",
domains_used=[],
response_time_ms=10,
asked_by=None,
asked_via="feishu",
project_id=None,
session_id=int(previous_session.id),
turn_index=1,
conversation_scope_key="feishu:chat-1:ou-1",
source_chat_id="chat-1",
source_sender_open_id="ou-1",
source_message_id="om_user_1",
created_at=now - timedelta(minutes=1),
)
)
await db_session.commit()
state = await service._ensure_session_state(session_id=int(current_session.id))
resolution = await service._resolve_followup_anchor(
question="这个是哪个模型回复的?",
session_id=int(current_session.id),
context_session_id=int(current_session.id),
session_state=state,
conversation_scope_key="feishu:chat-1:ou-1",
source_chat_id="chat-1",
source_sender_open_id="ou-1",
)
assert resolution.anchor_mode == "implicit_recent_reply"
assert resolution.anchor_reply_id is not None
assert resolution.anchor_kind == "meta_recent_reply"
assert resolution.anchor_reply is not None
assert resolution.anchor_reply["question"] == "帮我解释下系统架构"
@pytest.mark.asyncio
async def test_meta_followup_model_identity_reads_routing_snapshot_and_hides_real_model(db_session) -> None:
service = _Harness(db_session)
now = now_cn_naive()
session = QASession(
tenant_id=1,
user_id=None,
title="系统架构",
external_conversation_key="feishu:chat-1:ou-1",
started_at=now,
last_message_at=now,
message_count=1,
)
db_session.add(session)
await db_session.flush()
qa = QARecord(
tenant_id=1,
question="帮我解释下系统架构",
answer="PMFlow 系统采用前后端分离架构。",
sources=[],
intent="question",
domains_used=[],
response_time_ms=12,
asked_by=None,
asked_via="feishu",
project_id=None,
session_id=int(session.id),
turn_index=1,
conversation_scope_key="feishu:chat-1:ou-1",
source_chat_id="chat-1",
source_sender_open_id="ou-1",
source_message_id="om_user_1",
routing_snapshot={
"planned_tier": "low",
"executed_tier": "high",
"route_source": "default",
"route_rule_name": "engine_high_reasoning_glm5",
"routing_decision": "low_to_high_escalated",
"provider": "openai",
"model": "GLM-5",
},
)
db_session.add(qa)
await db_session.commit()
payload = await service._build_meta_followup_payload(
question="这个是哪个模型回复的?",
anchor_resolution=FollowupAnchorResolution(
anchor_mode="implicit_recent_reply",
anchor_reply_id=int(qa.id),
anchor_session_id=int(session.id),
anchor_kind="meta_recent_reply",
is_meta_followup=True,
anchor_reply={
"qa_id": int(qa.id),
"question": "帮我解释下系统架构",
"anchor_reply_preview": "PMFlow 系统采用前后端分离架构。",
"source_message_id": "om_user_1",
},
),
conversation_scope_key="feishu:chat-1:ou-1",
)
assert payload is not None
assert payload["meta_followup_kind"] == "reply_model_identity"
assert payload["reason_code"] == "meta_followup_answered"
assert "planned_tier=low" in payload["answer"]
assert "executed_tier=high" in payload["answer"]
assert "engine_high_reasoning_glm5" in payload["answer"]
assert "glm-5" not in payload["answer"].lower()

View File

@ -18,7 +18,7 @@ from app.services.ai_pmo.ai_pmo_intent import CanonicalIntent
from app.services.llm_service import ChatCompletionResult, StructuredExtractionResult from app.services.llm_service import ChatCompletionResult, StructuredExtractionResult
from app.services.llm_service import mixin_tier_escalation from app.services.llm_service import mixin_tier_escalation
from app.services.llm_service.mixin_tier_escalation import LLMTierEscalationMixin from app.services.llm_service.mixin_tier_escalation import LLMTierEscalationMixin
from app.utils.thread_context import build_feishu_thread_key from app.utils.thread_context import build_feishu_conversation_scope_key, build_feishu_thread_key
@pytest.mark.asyncio @pytest.mark.asyncio
@ -128,6 +128,8 @@ async def test_prepare_mixed_sidecar_context_passes_mixed_contract(monkeypatch)
source_system="pmflow_manual", source_system="pmflow_manual",
source_message_id="manual-msg-2", source_message_id="manual-msg-2",
channel_ref="pmflow-chat", channel_ref="pmflow-chat",
source_sender_open_id="ou-pmflow-1",
thread_context={"reply_to_message_id": "reply-1"},
message_text="顺便问下现在进展如何,并且新增任务确认流", message_text="顺便问下现在进展如何,并且新增任务确认流",
project_hint_id=22, project_hint_id=22,
) )
@ -142,6 +144,8 @@ async def test_prepare_mixed_sidecar_context_passes_mixed_contract(monkeypatch)
assert answer_kwargs["asked_via"] == "pmflow_manual" assert answer_kwargs["asked_via"] == "pmflow_manual"
assert answer_kwargs["project_id"] is None assert answer_kwargs["project_id"] is None
assert answer_kwargs["message_intent"] == "mixed" assert answer_kwargs["message_intent"] == "mixed"
assert answer_kwargs["source_sender_open_id"] == "ou-pmflow-1"
assert answer_kwargs["thread_context"] == {"reply_to_message_id": "reply-1"}
assert answer_kwargs["mixed_signal_part"] == "新增任务确认流" assert answer_kwargs["mixed_signal_part"] == "新增任务确认流"
assert answer_kwargs["conversation_key"] == "manual-msg-2" assert answer_kwargs["conversation_key"] == "manual-msg-2"
@ -543,7 +547,13 @@ async def test_execute_query_intent_feishu_fallback_uses_thread_key(monkeypatch)
monkeypatch.setattr(mixin_execute_query_project, "AdaptiveRAGService", FakeAdaptiveRAGService) monkeypatch.setattr(mixin_execute_query_project, "AdaptiveRAGService", FakeAdaptiveRAGService)
executor = FakeExecutor() executor = FakeExecutor()
intent = SimpleNamespace(project_id=None, metadata={"thread_context": {"root_message_id": "root-1"}}) intent = SimpleNamespace(
project_id=None,
metadata={
"thread_context": {"root_message_id": "root-1"},
"source_sender_open_id": "ou-feishu-99",
},
)
result = await executor._execute_query_intent( result = await executor._execute_query_intent(
intent=intent, intent=intent,
message_text="最近AI行业有什么动态", message_text="最近AI行业有什么动态",
@ -558,12 +568,12 @@ async def test_execute_query_intent_feishu_fallback_uses_thread_key(monkeypatch)
assert answer_kwargs["asked_by"] is None assert answer_kwargs["asked_by"] is None
assert answer_kwargs["asked_via"] == "feishu" assert answer_kwargs["asked_via"] == "feishu"
assert answer_kwargs["source_chat_id"] is None assert answer_kwargs["source_chat_id"] is None
assert answer_kwargs["conversation_key"] == build_feishu_thread_key( assert answer_kwargs["source_sender_open_id"] == "ou-feishu-99"
assert answer_kwargs["thread_context"] == {"root_message_id": "root-1"}
assert answer_kwargs["conversation_key"] == build_feishu_conversation_scope_key(
source_chat_id=None, source_chat_id=None,
source_sender_open_id=None, source_sender_open_id="ou-feishu-99",
sender=None, sender=None,
source_message_id="msg-feishu-1",
thread_context={"root_message_id": "root-1"},
) )

View File

@ -6,6 +6,7 @@ from app.services.ai_pmo.ai_pmo_intent import (
INTENT_TASK_CREATE, INTENT_TASK_CREATE,
ROUTE_ACTION_ANSWER, ROUTE_ACTION_ANSWER,
ROUTE_ACTION_CANCEL, ROUTE_ACTION_CANCEL,
ROUTE_ACTION_CONTINUE,
ROUTE_ACTION_CREATE, ROUTE_ACTION_CREATE,
ROUTE_FAMILY_QUERY, ROUTE_FAMILY_QUERY,
ROUTE_FAMILY_TASK, ROUTE_FAMILY_TASK,
@ -72,11 +73,20 @@ def test_route_resolver_builds_query_answer_route() -> None:
assert route.key == ROUTE_QUERY_ANSWER assert route.key == ROUTE_QUERY_ANSWER
def test_route_resolver_followup_action_supports_message_text_fallback() -> None: def test_route_resolver_followup_action_requires_structured_metadata() -> None:
action = RouteResolver.parse_followup_action( action = RouteResolver.parse_followup_action(
canonical_intent=INTENT_FOLLOWUP_CONTINUE, canonical_intent=INTENT_FOLLOWUP_CONTINUE,
message_text="先取消这个工作项", message_text="先取消这个工作项",
) )
assert action == ROUTE_ACTION_CONTINUE
def test_route_resolver_followup_action_allows_message_parsing_with_context() -> None:
action = RouteResolver.parse_followup_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_CANCEL

View File

@ -115,6 +115,54 @@ def test_feishu_reply_llm_core_strips_action_process_prefaces() -> None:
assert FeishuReplyLLMCoreMixin._strip_leading_thinking_paragraphs(reply) == "" assert FeishuReplyLLMCoreMixin._strip_leading_thinking_paragraphs(reply) == ""
def test_feishu_reply_llm_core_action_ack_uses_semantic_bundle_only() -> None:
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": "new_project_flow",
"workflows": [
{
"workflow_type": "new_project_flow",
"workflow_action": "create",
"target_scope": "global",
"operation_codes": ["project.create"],
"requires_confirmation": False,
"confidence": 0.9,
"slots": [],
"clarification_needed": False,
"metadata": {},
},
{
"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": "new_project_flow",
},
"diagnostics": {},
},
},
)
assert ack == "已收到,项目创建和信号源接入请求已记录。"
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_draft_confirm_handler_repeated_confirm_returns_inbox_success_copy() -> None: async def test_draft_confirm_handler_repeated_confirm_returns_inbox_success_copy() -> None:
class _FakeResult: class _FakeResult:

View File

@ -0,0 +1,96 @@
from __future__ import annotations
import importlib.util
from pathlib import Path
import sqlalchemy as sa
from alembic.migration import MigrationContext
from alembic.operations import Operations
def _load_migration_module():
path = (
Path(__file__).resolve().parents[1]
/ "alembic"
/ "versions"
/ "20260327_add_external_qa_followup_state.py"
)
spec = importlib.util.spec_from_file_location("external_qa_followup_state_migration", path)
module = importlib.util.module_from_spec(spec)
assert spec is not None
assert spec.loader is not None
spec.loader.exec_module(module)
return module
def _create_qa_tables(metadata: sa.MetaData) -> None:
sa.Table(
"qa_sessions",
metadata,
sa.Column("id", sa.Integer(), primary_key=True, autoincrement=True),
sa.Column("tenant_id", sa.BigInteger(), nullable=False),
sa.Column("created_at", sa.TIMESTAMP(), nullable=False),
sa.Column("updated_at", sa.TIMESTAMP(), nullable=False),
sa.Column("user_id", sa.BigInteger(), nullable=True),
sa.Column("title", sa.String(length=120), nullable=False),
sa.Column("project_id", sa.BigInteger(), nullable=True),
sa.Column("started_at", sa.TIMESTAMP(), nullable=True),
sa.Column("last_message_at", sa.TIMESTAMP(), nullable=True),
sa.Column("message_count", sa.Integer(), nullable=False),
)
sa.Table(
"qa_session_states",
metadata,
sa.Column("id", sa.Integer(), primary_key=True, autoincrement=True),
sa.Column("tenant_id", sa.BigInteger(), nullable=False),
sa.Column("created_at", sa.TIMESTAMP(), nullable=False),
sa.Column("updated_at", sa.TIMESTAMP(), nullable=False),
sa.Column("session_id", sa.Integer(), nullable=False),
sa.Column("last_subject", sa.String(length=255), nullable=True),
sa.Column("last_entity_ids", sa.JSON(), nullable=True),
sa.Column("last_intent", sa.String(length=20), nullable=True),
sa.Column("ambiguity", sa.String(length=20), nullable=True),
sa.Column("rewrite_history", sa.JSON(), nullable=True),
)
def test_external_qa_followup_migration_adds_and_removes_columns_and_indexes() -> None:
migration = _load_migration_module()
engine = sa.create_engine("sqlite:///:memory:")
metadata = sa.MetaData()
_create_qa_tables(metadata)
metadata.create_all(engine)
with engine.begin() as conn:
context = MigrationContext.configure(conn)
migration.op = Operations(context)
migration.upgrade()
inspector = sa.inspect(conn)
session_columns = {column["name"] for column in inspector.get_columns("qa_sessions")}
state_columns = {column["name"] for column in inspector.get_columns("qa_session_states")}
session_indexes = {index["name"]: index for index in inspector.get_indexes("qa_sessions")}
state_indexes = {index["name"]: index for index in inspector.get_indexes("qa_session_states")}
assert "external_conversation_key" in session_columns
assert "pending_clarification" in state_columns
assert "pending_clarification_expires_at" in state_columns
assert "ix_qa_sessions_external_conversation_key" in session_indexes
assert "uq_qa_sessions_tenant_external_conversation_key" in session_indexes
assert bool(session_indexes["uq_qa_sessions_tenant_external_conversation_key"]["unique"]) is True
assert "ix_qa_session_states_pending_clarification_expires_at" in state_indexes
migration.downgrade()
inspector = sa.inspect(conn)
session_columns = {column["name"] for column in inspector.get_columns("qa_sessions")}
state_columns = {column["name"] for column in inspector.get_columns("qa_session_states")}
session_indexes = {index["name"] for index in inspector.get_indexes("qa_sessions")}
state_indexes = {index["name"] for index in inspector.get_indexes("qa_session_states")}
assert "external_conversation_key" not in session_columns
assert "pending_clarification" not in state_columns
assert "pending_clarification_expires_at" not in state_columns
assert "ix_qa_sessions_external_conversation_key" not in session_indexes
assert "uq_qa_sessions_tenant_external_conversation_key" not in session_indexes
assert "ix_qa_session_states_pending_clarification_expires_at" not in state_indexes

View File

@ -33,6 +33,9 @@ from app.services.integrations.feishu_signal_pipeline.mixin_item_finalize_routin
from app.services.integrations.feishu_signal_pipeline.mixin_message_utils_b import ( from app.services.integrations.feishu_signal_pipeline.mixin_message_utils_b import (
FeishuPipelineMessageUtilsBMixin, FeishuPipelineMessageUtilsBMixin,
) )
from app.services.integrations.feishu_signal_pipeline.mixin_message_utils_a import (
FeishuPipelineMessageUtilsAMixin,
)
from app.services.integrations.feishu_signal_pipeline.mixin_process_message_helpers_b import ( from app.services.integrations.feishu_signal_pipeline.mixin_process_message_helpers_b import (
FeishuPipelineProcessMessageHelpersBMixin, FeishuPipelineProcessMessageHelpersBMixin,
) )
@ -212,6 +215,56 @@ def test_decorate_capture_result_adds_queue_action_card_metadata() -> None:
assert result["queue_item_id"] == 301 assert result["queue_item_id"] == 301
def test_rag_conversation_key_uses_stable_scope_without_reply_anchor() -> None:
key = FeishuPipelineMessageUtilsAMixin._build_rag_conversation_key(
source_chat_id="chat-1",
source_sender_open_id="ou-1",
sender="测试用户",
source_message_id="msg-123",
thread_context=None,
)
assert key == "feishu:chat-1:ou-1"
@pytest.mark.asyncio
async def test_build_direct_qa_capture_exposes_reason_code_and_anchor_kind() -> None:
class _FakeHelpers(FeishuPipelineProcessMessageHelpersBMixin):
def __init__(self) -> None:
self.db = object()
self.tenant_id = 1
async def _write_processing_log(self, **kwargs):
_ = kwargs
def _build_rag_reply_text(self, *, answer: str, sources: list[dict[str, object]]) -> str:
_ = sources
return answer
helpers = _FakeHelpers()
capture = await helpers._build_direct_qa_capture(
message="这个是哪个模型回复的?",
rag_result={
"answer": "这条回复命中的逻辑层是 low。",
"clarification_code": None,
"reason_code": "meta_followup_answered",
"anchor_kind": "meta_recent_reply",
"meta_followup_kind": "reply_model_identity",
"sources": [],
},
inferred_project_id=None,
feishu_message_id="msg-1",
process_started_at=0.0,
route_reason="direct qa",
intent_label="question",
)
assert capture["reason_code"] == "meta_followup_answered"
assert capture["anchor_kind"] == "meta_recent_reply"
assert capture["meta_followup_kind"] == "reply_model_identity"
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_intent_gate_query_does_not_inherit_project_context(monkeypatch) -> None: async def test_intent_gate_query_does_not_inherit_project_context(monkeypatch) -> None:
captured: dict[str, object] = {} captured: dict[str, object] = {}
@ -272,6 +325,8 @@ async def test_intent_gate_query_does_not_inherit_project_context(monkeypatch) -
assert isinstance(result, dict) assert isinstance(result, dict)
assert result["linked_project_ids"] == [] assert result["linked_project_ids"] == []
assert captured["answer_kwargs"]["project_id"] is None assert captured["answer_kwargs"]["project_id"] is None
assert captured["answer_kwargs"]["source_sender_open_id"] == "ou-1"
assert captured["answer_kwargs"]["thread_context"] == {"reply_to_message_id": "msg-root-1"}
@pytest.mark.asyncio @pytest.mark.asyncio

View File

@ -58,7 +58,7 @@ async def test_handle_message_receive_routes_empty_text_branch(monkeypatch) -> N
assert called["empty_reply"]["chat_id"] == "chat-empty" assert called["empty_reply"]["chat_id"] == "chat-empty"
def test_classify_message_scene_prefers_execute_for_action_like_raw_text() -> None: def test_classify_message_scene_does_not_infer_execute_from_raw_text_only() -> None:
scene = FeishuIntegrationService._classify_message_scene( scene = FeishuIntegrationService._classify_message_scene(
capture_result={ capture_result={
"status": "answered", "status": "answered",
@ -68,7 +68,7 @@ def test_classify_message_scene_prefers_execute_for_action_like_raw_text() -> No
message_content="帮我创建项目和信号源", message_content="帮我创建项目和信号源",
) )
assert scene == "execute" assert scene == "qa"
@pytest.mark.asyncio @pytest.mark.asyncio

View File

@ -0,0 +1,92 @@
from __future__ import annotations
import importlib.util
from pathlib import Path
import sqlalchemy as sa
from alembic.migration import MigrationContext
from alembic.operations import Operations
def _load_migration_module():
path = (
Path(__file__).resolve().parents[1]
/ "alembic"
/ "versions"
/ "20260327_add_qa_record_followup_anchor_fields.py"
)
spec = importlib.util.spec_from_file_location("qa_record_followup_anchor_migration", path)
module = importlib.util.module_from_spec(spec)
assert spec is not None
assert spec.loader is not None
spec.loader.exec_module(module)
return module
def _create_qa_record_table(metadata: sa.MetaData) -> None:
sa.Table(
"qa_records",
metadata,
sa.Column("id", sa.Integer(), primary_key=True, autoincrement=True),
sa.Column("tenant_id", sa.BigInteger(), nullable=False),
sa.Column("created_at", sa.TIMESTAMP(), nullable=False),
sa.Column("updated_at", sa.TIMESTAMP(), nullable=False),
sa.Column("question", sa.Text(), nullable=False),
sa.Column("answer", sa.Text(), nullable=False),
sa.Column("sources", sa.JSON(), nullable=True),
sa.Column("intent", sa.String(length=20), nullable=False),
sa.Column("domains_used", sa.JSON(), nullable=True),
sa.Column("response_time_ms", sa.Integer(), nullable=True),
sa.Column("asked_by", sa.BigInteger(), nullable=True),
sa.Column("asked_via", sa.String(length=20), nullable=False),
sa.Column("project_id", sa.BigInteger(), nullable=True),
sa.Column("session_id", sa.Integer(), nullable=True),
sa.Column("turn_index", sa.Integer(), nullable=False),
)
def test_qa_record_followup_anchor_migration_adds_and_removes_columns_and_indexes() -> None:
migration = _load_migration_module()
engine = sa.create_engine("sqlite:///:memory:")
metadata = sa.MetaData()
_create_qa_record_table(metadata)
metadata.create_all(engine)
with engine.begin() as conn:
context = MigrationContext.configure(conn)
migration.op = Operations(context)
migration.upgrade()
inspector = sa.inspect(conn)
record_columns = {column["name"] for column in inspector.get_columns("qa_records")}
record_indexes = {index["name"] for index in inspector.get_indexes("qa_records")}
assert "source_message_id" in record_columns
assert "source_chat_id" in record_columns
assert "source_sender_open_id" in record_columns
assert "reply_to_message_id" in record_columns
assert "root_message_id" in record_columns
assert "conversation_scope_key" in record_columns
assert "anchor_kind" in record_columns
assert "anchor_ref" in record_columns
assert "routing_snapshot" in record_columns
assert "ix_qa_records_source_message_id" in record_indexes
assert "ix_qa_records_conversation_scope_key" in record_indexes
migration.downgrade()
inspector = sa.inspect(conn)
record_columns = {column["name"] for column in inspector.get_columns("qa_records")}
record_indexes = {index["name"] for index in inspector.get_indexes("qa_records")}
assert "source_message_id" not in record_columns
assert "source_chat_id" not in record_columns
assert "source_sender_open_id" not in record_columns
assert "reply_to_message_id" not in record_columns
assert "root_message_id" not in record_columns
assert "conversation_scope_key" not in record_columns
assert "anchor_kind" not in record_columns
assert "anchor_ref" not in record_columns
assert "routing_snapshot" not in record_columns
assert "ix_qa_records_source_message_id" not in record_indexes
assert "ix_qa_records_conversation_scope_key" not in record_indexes

View File

@ -0,0 +1,283 @@
from __future__ import annotations
import os
from types import SimpleNamespace
import pytest
import pytest_asyncio
from fastapi import FastAPI
from httpx import ASGITransport, AsyncClient
from sqlalchemy import JSON
from sqlalchemy.ext.asyncio import async_sessionmaker, create_async_engine
os.environ["DATABASE_URL"] = "postgresql+asyncpg://tester:tester@localhost:5432/pmflow_test"
from app.api.ai_steward_api import routes_feedback_center, routes_reply_iteration, routes_training_pipeline
from app.core.database import get_db
from app.dependencies.auth import get_current_user
from app.dependencies.tenant import get_tenant_id
from app.models.base import Base
from app.models.experiment import Experiment
from app.models.feedback_center import FeedbackCenterRecord
from app.models.qa_record import QARecord
from app.models.reply_iteration import (
JudgeRun,
OptimizerCandidate,
PromptRelease,
QualityAsset,
QualityAssetPair,
)
from app.models.training_pipeline import (
TrainedModelArtifact,
TrainedModelDeployment,
TrainingDataset,
TrainingDatasetItem,
TrainingEvaluation,
TrainingJob,
TrainingJobRun,
)
def _use_sqlite_json_types() -> None:
for table in (
FeedbackCenterRecord.__table__,
QARecord.__table__,
QualityAsset.__table__,
QualityAssetPair.__table__,
JudgeRun.__table__,
OptimizerCandidate.__table__,
PromptRelease.__table__,
Experiment.__table__,
TrainingDataset.__table__,
TrainingDatasetItem.__table__,
TrainingJob.__table__,
TrainingJobRun.__table__,
TrainingEvaluation.__table__,
TrainedModelArtifact.__table__,
TrainedModelDeployment.__table__,
):
for column in table.columns:
if column.name in {
"before_value",
"after_value",
"ai_snapshot",
"reason_candidates",
"sources",
"domains_used",
"routing_snapshot",
"evidence",
"source_snapshot",
"tags",
"metadata_json",
"findings",
"llm_evidence",
"suggestion",
"scope_json",
"linked_context_nodes",
"result_summary",
"selection_rules",
"channel_scope",
"redacted_snapshot",
"metrics_json",
"evaluation_summary",
"effective_scope",
}:
column.type = JSON()
@pytest_asyncio.fixture
async def db_session():
engine = create_async_engine("sqlite+aiosqlite:///:memory:")
_use_sqlite_json_types()
async with engine.begin() as conn:
await conn.run_sync(
lambda sync_conn: Base.metadata.create_all(
sync_conn,
tables=[
FeedbackCenterRecord.__table__,
QARecord.__table__,
QualityAsset.__table__,
QualityAssetPair.__table__,
JudgeRun.__table__,
OptimizerCandidate.__table__,
PromptRelease.__table__,
Experiment.__table__,
TrainingDataset.__table__,
TrainingDatasetItem.__table__,
TrainingJob.__table__,
TrainingJobRun.__table__,
TrainingEvaluation.__table__,
TrainedModelArtifact.__table__,
TrainedModelDeployment.__table__,
],
)
)
maker = async_sessionmaker(engine, expire_on_commit=False)
async with maker() as session:
yield session
await engine.dispose()
def _build_app(db_session):
app = FastAPI()
app.include_router(routes_feedback_center.router)
app.include_router(routes_reply_iteration.router)
app.include_router(routes_training_pipeline.router)
async def _fake_get_db():
yield db_session
app.dependency_overrides[get_db] = _fake_get_db
app.dependency_overrides[get_tenant_id] = lambda: 1
app.dependency_overrides[get_current_user] = lambda: SimpleNamespace(
id=9001,
tenant_id=1,
role="ADMIN",
name="管理员",
username="admin",
)
return app
@pytest.mark.asyncio
async def test_reply_iteration_and_training_admin_routes(db_session) -> None:
qa_record = QARecord(
tenant_id=1,
question="这个回复为什么不对",
answer="让我先看一下。",
sources=[],
intent="question",
domains_used=[],
asked_via="feishu",
project_id=7,
turn_index=1,
routing_snapshot={
"planned_tier": "low",
"executed_tier": "high",
"route_source": "default",
"route_rule_name": "engine_high_answer",
},
)
db_session.add(qa_record)
await db_session.flush()
feedback = FeedbackCenterRecord(
tenant_id=1,
project_id=7,
source_type="qa_feedback",
source_id=501,
object_type="qa_record",
object_id=qa_record.id,
field_name="qa_feedback",
before_value={"question": qa_record.question, "answer": qa_record.answer},
after_value={"rating": "negative", "correct_answer": "这条回复实际走的是 high。"},
ai_snapshot={"prompt_key": "PROMPT_FEISHU_DIRECT_QA"},
review_status="accepted",
)
db_session.add(feedback)
await db_session.commit()
app = _build_app(db_session)
transport = ASGITransport(app=app)
async with AsyncClient(transport=transport, base_url="http://test") as client:
promote_resp = await client.post(
f"/ai-steward/feedback-center/items/{feedback.id}/promote",
json={
"asset_type": "failure",
"prompt_key": "PROMPT_FEISHU_DIRECT_QA",
"workflow_type": "query.read_only",
},
)
assert promote_resp.status_code == 200
asset_id = promote_resp.json()["data"]["asset"]["id"]
judge_resp = await client.post(
"/ai-steward/reply-iteration/judge-runs",
json={"asset_id": asset_id},
)
assert judge_resp.status_code == 200
judge_id = judge_resp.json()["data"]["id"]
candidate_resp = await client.post(
"/ai-steward/reply-iteration/candidates",
json={"judge_run_id": judge_id},
)
assert candidate_resp.status_code == 200
release_resp = await client.post(
"/ai-steward/reply-iteration/prompt-releases",
json={
"prompt_key": "PROMPT_FEISHU_DIRECT_QA",
"body_snapshot": "【试验版】{question}",
"notes": "试验候选",
},
)
assert release_resp.status_code == 200
candidate_release_id = release_resp.json()["data"]["id"]
activate_resp = await client.post(
f"/ai-steward/reply-iteration/prompt-releases/{candidate_release_id}/activate",
)
assert activate_resp.status_code == 200
trial_resp = await client.post(
"/ai-steward/reply-iteration/trials",
json={
"title": "回复质量试验",
"hypothesis": "试验 prompt 能减少过程话术",
"target_metric": "accepted_rate",
"prompt_key": "PROMPT_FEISHU_DIRECT_QA",
"baseline_release_id": candidate_release_id,
"candidate_release_id": candidate_release_id,
"scope_json": {"asked_via": ["feishu"]},
},
)
assert trial_resp.status_code == 200
trial_id = trial_resp.json()["data"]["id"]
trial_decision_resp = await client.post(
f"/ai-steward/reply-iteration/trials/{trial_id}/decision",
json={"decision": "accepted", "result_summary": {"accepted_rate": 0.91}},
)
assert trial_decision_resp.status_code == 200
dataset_resp = await client.post(
"/ai-steward/training/datasets",
json={
"dataset_type": "eval",
"version": "eval-v1",
"asset_ids": [asset_id],
"pair_ids": [],
"selection_rules": {"scope": "reply_iteration"},
"notes": "评测集",
},
)
assert dataset_resp.status_code == 200
dataset_id = dataset_resp.json()["data"]["id"]
job_resp = await client.post(
"/ai-steward/training/jobs",
json={
"dataset_id": dataset_id,
"job_type": "sft",
"base_model_family": "glm",
"target_slot": "high",
"provider_backend": "openclaw-training",
},
)
assert job_resp.status_code == 200
job_id = job_resp.json()["data"]["id"]
artifact_id = job_resp.json()["data"]["artifact"]["id"]
eval_resp = await client.get(f"/ai-steward/training/jobs/{job_id}/evaluations")
promote_artifact_resp = await client.post(f"/ai-steward/training/artifacts/{artifact_id}/promote")
deployment_id = promote_artifact_resp.json()["data"]["id"]
deployment_decision_resp = await client.post(
f"/ai-steward/training/deployments/{deployment_id}/decision",
json={"decision": "accepted", "notes": "允许候选部署"},
)
assert judge_resp.json()["code"] == 0
assert candidate_resp.json()["data"]["status"] == "draft"
assert trial_decision_resp.json()["data"]["decision"] == "accepted"
assert eval_resp.json()["data"]["items"]
assert deployment_decision_resp.json()["data"]["deployment_status"] == "active"

View File

@ -0,0 +1,253 @@
from __future__ import annotations
import os
import pytest
import pytest_asyncio
from sqlalchemy import JSON
from sqlalchemy.ext.asyncio import async_sessionmaker, create_async_engine
os.environ["DATABASE_URL"] = "sqlite+aiosqlite:///:memory:"
from app.models.base import Base
from app.models.experiment import Experiment
from app.models.feedback_center import FeedbackCenterRecord
from app.models.qa_record import QARecord
from app.models.reply_iteration import (
JudgeRun,
OptimizerCandidate,
PromptRelease,
QualityAsset,
QualityAssetPair,
)
from app.services.prompt_release.service import PromptReleaseService
from app.services.reply_iteration.service import ReplyIterationService
def _use_sqlite_json_types() -> None:
for table in (
FeedbackCenterRecord.__table__,
QARecord.__table__,
QualityAsset.__table__,
QualityAssetPair.__table__,
JudgeRun.__table__,
OptimizerCandidate.__table__,
PromptRelease.__table__,
Experiment.__table__,
):
for column in table.columns:
if column.name in {
"before_value",
"after_value",
"ai_snapshot",
"reason_candidates",
"sources",
"domains_used",
"routing_snapshot",
"evidence",
"source_snapshot",
"tags",
"metadata_json",
"findings",
"llm_evidence",
"suggestion",
"scope_json",
"linked_context_nodes",
"result_summary",
}:
column.type = JSON()
@pytest_asyncio.fixture
async def db_session():
engine = create_async_engine("sqlite+aiosqlite:///:memory:")
_use_sqlite_json_types()
async with engine.begin() as conn:
await conn.run_sync(
lambda sync_conn: Base.metadata.create_all(
sync_conn,
tables=[
FeedbackCenterRecord.__table__,
QARecord.__table__,
QualityAsset.__table__,
QualityAssetPair.__table__,
JudgeRun.__table__,
OptimizerCandidate.__table__,
PromptRelease.__table__,
Experiment.__table__,
],
)
)
maker = async_sessionmaker(engine, expire_on_commit=False)
async with maker() as session:
yield session
await engine.dispose()
@pytest.mark.asyncio
async def test_promote_feedback_record_builds_asset_and_preference_pair(db_session) -> None:
qa_record = QARecord(
tenant_id=1,
question="这个回复为什么不对",
answer="让我先看一下你的情况,再帮你判断。",
sources=[],
intent="question",
domains_used=[],
response_time_ms=120,
asked_by=None,
asked_via="feishu",
project_id=8,
turn_index=1,
routing_snapshot={
"planned_tier": "low",
"executed_tier": "high",
"route_source": "default",
"route_rule_name": "engine_high_answer",
},
)
db_session.add(qa_record)
await db_session.flush()
failure_record = FeedbackCenterRecord(
tenant_id=1,
project_id=8,
source_type="qa_feedback",
source_id=101,
object_type="qa_record",
object_id=qa_record.id,
field_name="qa_feedback",
source_title="飞书问答反馈",
object_title="问答修正",
before_value={"question": qa_record.question, "answer": qa_record.answer},
after_value={"rating": "negative", "correct_answer": "上一条回复走的是 high 路由。"},
ai_snapshot={"prompt_key": "PROMPT_FEISHU_DIRECT_QA"},
reason_code="answer_incorrect",
review_status="accepted",
)
golden_record = FeedbackCenterRecord(
tenant_id=1,
project_id=8,
source_type="qa_feedback",
source_id=102,
object_type="qa_record",
object_id=qa_record.id,
field_name="qa_feedback",
source_title="飞书问答反馈",
object_title="问答修正",
before_value={"question": qa_record.question, "answer": qa_record.answer},
after_value={"rating": "positive", "correct_answer": "这条回复实际走的是 high,来源于默认规则。"},
ai_snapshot={"prompt_key": "PROMPT_FEISHU_DIRECT_QA"},
reason_code="answer_incorrect",
review_status="accepted",
)
db_session.add_all([failure_record, golden_record])
await db_session.commit()
service = ReplyIterationService(db_session)
failure_result = await service.promote_feedback_record(
tenant_id=1,
record_id=failure_record.id,
actor_id=9001,
asset_type="failure",
prompt_key="PROMPT_FEISHU_DIRECT_QA",
workflow_type="query.read_only",
)
golden_result = await service.promote_feedback_record(
tenant_id=1,
record_id=golden_record.id,
actor_id=9001,
asset_type="golden",
prompt_key="PROMPT_FEISHU_DIRECT_QA",
workflow_type="query.read_only",
pair_with_asset_id=failure_result["asset"]["id"],
pair_role="chosen",
pair_reason="人工确认修正版明显优于原答复",
)
assert failure_result["asset"]["asset_type"] == "failure"
assert failure_result["asset"]["qa_record_id"] == qa_record.id
assert failure_result["asset"]["planned_tier"] == "low"
assert failure_result["asset"]["executed_tier"] == "high"
assert failure_result["asset"]["route_rule_name"] == "engine_high_answer"
assert failure_result["pair"] is None
assert golden_result["asset"]["asset_type"] == "golden"
assert golden_result["pair"] is not None
assert golden_result["pair"]["chosen_asset_id"] == golden_result["asset"]["id"]
assert golden_result["pair"]["rejected_asset_id"] == failure_result["asset"]["id"]
@pytest.mark.asyncio
async def test_judge_run_and_optimizer_candidate_follow_bucket_rules(db_session) -> None:
asset = QualityAsset(
tenant_id=1,
project_id=3,
asset_type="failure",
status="approved",
prompt_key="PROMPT_FEISHU_DIRECT_QA",
workflow_type="query.read_only",
asked_via="feishu",
planned_tier="low",
executed_tier="low",
route_rule_name="engine_low_fallback",
question_text="这个回复为什么不对",
answer_text="让我先看一下,再通过 API 帮你创建。",
corrected_answer="直接说明这条回复走的是 low,并给出路由来源。",
source_snapshot={"reason_code": "answer_incorrect"},
)
db_session.add(asset)
await db_session.commit()
service = ReplyIterationService(db_session)
judge = await service.create_judge_run(
tenant_id=1,
asset_id=asset.id,
actor_id=9001,
)
candidate = await service.create_optimizer_candidate(
tenant_id=1,
judge_run_id=judge["id"],
actor_id=9001,
)
assert judge["bucket_key"] == "PROMPT_FEISHU_DIRECT_QA|query.read_only|feishu|low|low"
finding_codes = {item["code"] for item in judge["findings"]}
assert "process_talk_prefix" in finding_codes
assert "execution_claim_prefix" in finding_codes
assert candidate["target_type"] == "renderer_policy"
assert candidate["status"] == "draft"
assert candidate["target_ref"] == "reply_renderer"
@pytest.mark.asyncio
async def test_prompt_release_service_prefers_active_release_and_falls_back_on_format_error(db_session) -> None:
service = PromptReleaseService(db_session)
release = await service.create_release(
tenant_id=1,
prompt_key="PROMPT_FEISHU_DIRECT_QA",
body_snapshot="【定制版】问题:{question}",
created_by=9001,
notes="定制回复语气",
)
await service.activate_release(
tenant_id=1,
release_id=release["id"],
actor_id=9001,
)
rendered = await service.render_prompt(
tenant_id=1,
prompt_key="PROMPT_FEISHU_DIRECT_QA",
fallback_template="默认:{question}",
question="系统现在走哪个 tier",
)
broken = await service.render_prompt(
tenant_id=1,
prompt_key="PROMPT_FEISHU_DIRECT_QA",
fallback_template="默认:{question}",
question="系统现在走哪个 tier",
_override_template="坏模板:{missing_key}",
)
assert rendered == "【定制版】问题:系统现在走哪个 tier"
assert broken == "默认:系统现在走哪个 tier"

View File

@ -0,0 +1,104 @@
from __future__ import annotations
import importlib.util
from pathlib import Path
import sqlalchemy as sa
from alembic.migration import MigrationContext
from alembic.operations import Operations
def _load_migration_module():
path = (
Path(__file__).resolve().parents[1]
/ "alembic"
/ "versions"
/ "20260327_add_reply_iteration_training_tables.py"
)
spec = importlib.util.spec_from_file_location("reply_iteration_training_tables_migration", path)
module = importlib.util.module_from_spec(spec)
assert spec is not None
assert spec.loader is not None
spec.loader.exec_module(module)
return module
def _create_base_tables(metadata: sa.MetaData) -> None:
sa.Table(
"feedback_center_records",
metadata,
sa.Column("id", sa.Integer(), primary_key=True, autoincrement=True),
sa.Column("tenant_id", sa.BigInteger(), nullable=False),
sa.Column("created_at", sa.TIMESTAMP(), nullable=False),
sa.Column("updated_at", sa.TIMESTAMP(), nullable=False),
)
sa.Table(
"qa_records",
metadata,
sa.Column("id", sa.Integer(), primary_key=True, autoincrement=True),
sa.Column("tenant_id", sa.BigInteger(), nullable=False),
sa.Column("created_at", sa.TIMESTAMP(), nullable=False),
sa.Column("updated_at", sa.TIMESTAMP(), nullable=False),
)
sa.Table(
"experiments",
metadata,
sa.Column("id", sa.Integer(), primary_key=True, autoincrement=True),
sa.Column("tenant_id", sa.BigInteger(), nullable=False),
sa.Column("created_at", sa.TIMESTAMP(), nullable=False),
sa.Column("updated_at", sa.TIMESTAMP(), nullable=False),
sa.Column("title", sa.String(length=255), nullable=True),
sa.Column("hypothesis", sa.Text(), nullable=True),
sa.Column("status", sa.String(length=50), nullable=False),
sa.Column("target_metric", sa.String(length=50), nullable=True),
sa.Column("current_score", sa.Float(), nullable=True),
sa.Column("linked_context_nodes", sa.JSON(), nullable=True),
sa.Column("project_id", sa.BigInteger(), nullable=True),
)
def test_reply_iteration_training_migration_adds_tables_and_experiment_columns() -> None:
migration = _load_migration_module()
engine = sa.create_engine("sqlite:///:memory:")
metadata = sa.MetaData()
_create_base_tables(metadata)
metadata.create_all(engine)
with engine.begin() as conn:
context = MigrationContext.configure(conn)
migration.op = Operations(context)
migration.upgrade()
inspector = sa.inspect(conn)
tables = set(inspector.get_table_names())
experiment_columns = {column["name"] for column in inspector.get_columns("experiments")}
assert "quality_assets" in tables
assert "quality_asset_pairs" in tables
assert "judge_runs" in tables
assert "optimizer_candidates" in tables
assert "prompt_releases" in tables
assert "training_datasets" in tables
assert "training_dataset_items" in tables
assert "training_jobs" in tables
assert "training_job_runs" in tables
assert "training_evaluations" in tables
assert "trained_model_artifacts" in tables
assert "trained_model_deployments" in tables
assert "experiment_type" in experiment_columns
assert "baseline_release_id" in experiment_columns
assert "candidate_release_id" in experiment_columns
assert "scope_json" in experiment_columns
assert "result_summary" in experiment_columns
assert "decision" in experiment_columns
migration.downgrade()
inspector = sa.inspect(conn)
tables = set(inspector.get_table_names())
experiment_columns = {column["name"] for column in inspector.get_columns("experiments")}
assert "quality_assets" not in tables
assert "training_jobs" not in tables
assert "experiment_type" not in experiment_columns
assert "baseline_release_id" not in experiment_columns

View File

@ -0,0 +1,47 @@
from __future__ import annotations
import importlib.util
from pathlib import Path
import sys
def _load_semantic_lint_module():
repo_root = Path(__file__).resolve().parents[2]
module_path = repo_root / "scripts" / "semantic_lint.py"
spec = importlib.util.spec_from_file_location("semantic_lint", module_path)
assert spec is not None and spec.loader is not None
module = importlib.util.module_from_spec(spec)
sys.modules[spec.name] = module
spec.loader.exec_module(module)
return module
def test_semantic_lint_flags_new_scattered_hint_tables(tmp_path) -> None:
module = _load_semantic_lint_module()
target = tmp_path / "backend" / "app" / "services" / "integrations" / "foo.py"
target.parent.mkdir(parents=True, exist_ok=True)
target.write_text('PROJECT_CREATE_HINTS = ("创建项目",)\\n', encoding="utf-8")
problems = module.collect_problems(
root=tmp_path,
scan_roots=(Path("backend/app/services/integrations"),),
baseline_path=Path("scripts/semantic_lint_baseline.txt"),
)
assert len(problems) == 1
assert problems[0].rule_id == "semantic_hint_constant"
def test_semantic_lint_allows_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)
target.write_text('PROJECT_CREATE_HINTS = ("创建项目",)\\n', encoding="utf-8")
problems = module.collect_problems(
root=tmp_path,
scan_roots=(Path("backend/app/services/ai_pmo"),),
baseline_path=Path("scripts/semantic_lint_baseline.txt"),
)
assert problems == []

View File

@ -0,0 +1,196 @@
from __future__ import annotations
import os
import pytest
import pytest_asyncio
from sqlalchemy import JSON
from sqlalchemy.ext.asyncio import async_sessionmaker, create_async_engine
os.environ["DATABASE_URL"] = "sqlite+aiosqlite:///:memory:"
from app.models.base import Base
from app.models.reply_iteration import QualityAsset, QualityAssetPair
from app.models.training_pipeline import (
TrainedModelArtifact,
TrainedModelDeployment,
TrainingDataset,
TrainingDatasetItem,
TrainingEvaluation,
TrainingJob,
TrainingJobRun,
)
from app.services.training_pipeline.service import TrainingPipelineService
def _use_sqlite_json_types() -> None:
for table in (
QualityAsset.__table__,
QualityAssetPair.__table__,
TrainingDataset.__table__,
TrainingDatasetItem.__table__,
TrainingJob.__table__,
TrainingJobRun.__table__,
TrainingEvaluation.__table__,
TrainedModelArtifact.__table__,
TrainedModelDeployment.__table__,
):
for column in table.columns:
if column.name in {
"source_snapshot",
"tags",
"selection_rules",
"channel_scope",
"redacted_snapshot",
"metrics_json",
"scope_json",
"evaluation_summary",
"effective_scope",
}:
column.type = JSON()
@pytest_asyncio.fixture
async def db_session():
engine = create_async_engine("sqlite+aiosqlite:///:memory:")
_use_sqlite_json_types()
async with engine.begin() as conn:
await conn.run_sync(
lambda sync_conn: Base.metadata.create_all(
sync_conn,
tables=[
QualityAsset.__table__,
QualityAssetPair.__table__,
TrainingDataset.__table__,
TrainingDatasetItem.__table__,
TrainingJob.__table__,
TrainingJobRun.__table__,
TrainingEvaluation.__table__,
TrainedModelArtifact.__table__,
TrainedModelDeployment.__table__,
],
)
)
maker = async_sessionmaker(engine, expire_on_commit=False)
async with maker() as session:
yield session
await engine.dispose()
@pytest.mark.asyncio
async def test_training_dataset_rules_and_job_bootstrap(db_session) -> None:
golden = QualityAsset(
tenant_id=1,
project_id=8,
asset_type="golden",
status="approved",
prompt_key="PROMPT_FEISHU_DIRECT_QA",
workflow_type="query.read_only",
asked_via="feishu",
question_text="现在系统走哪个层级",
answer_text="当前走 high。",
corrected_answer="当前走 high。",
source_snapshot={"approved": True},
)
failure = QualityAsset(
tenant_id=1,
project_id=8,
asset_type="failure",
status="approved",
prompt_key="PROMPT_FEISHU_DIRECT_QA",
workflow_type="query.read_only",
asked_via="feishu",
question_text="这个回复为什么不对",
answer_text="让我先看看。",
corrected_answer="直接回答路由层级和原因。",
source_snapshot={"approved": True},
)
db_session.add_all([golden, failure])
await db_session.flush()
pair = QualityAssetPair(
tenant_id=1,
project_id=8,
pair_type="preference",
status="approved",
chosen_asset_id=golden.id,
rejected_asset_id=failure.id,
reason_text="黄金样本明显更符合产品要求",
)
db_session.add(pair)
await db_session.commit()
service = TrainingPipelineService(db_session)
sft_dataset = await service.create_dataset(
tenant_id=1,
actor_id=9001,
dataset_type="sft",
version="sft-v1",
asset_ids=[golden.id],
pair_ids=[],
selection_rules={"source": "manual"},
notes="SFT 首批数据集",
)
preference_dataset = await service.create_dataset(
tenant_id=1,
actor_id=9001,
dataset_type="preference",
version="pref-v1",
asset_ids=[],
pair_ids=[pair.id],
selection_rules={"source": "manual"},
notes="偏好对首批数据集",
)
assert sft_dataset["dataset_type"] == "sft"
assert sft_dataset["sample_count"] == 1
assert preference_dataset["dataset_type"] == "preference"
assert preference_dataset["sample_count"] == 1
with pytest.raises(ValueError, match="SFT dataset only accepts approved golden assets"):
await service.create_dataset(
tenant_id=1,
actor_id=9001,
dataset_type="sft",
version="sft-v2",
asset_ids=[failure.id],
pair_ids=[],
selection_rules={"source": "manual"},
notes="invalid",
)
job = await service.create_job(
tenant_id=1,
actor_id=9001,
dataset_id=sft_dataset["id"],
job_type="sft",
base_model_family="glm",
target_slot="high",
provider_backend="openclaw-training",
)
evaluations = await service.list_evaluations(
tenant_id=1,
job_id=job["id"],
)
promoted = await service.promote_artifact(
tenant_id=1,
artifact_id=job["artifact"]["id"],
actor_id=9001,
)
decided = await service.decide_deployment(
tenant_id=1,
deployment_id=promoted["id"],
actor_id=9001,
decision="accepted",
notes="评测通过,允许候选部署",
)
assert job["status"] == "queued"
assert job["artifact"]["target_slot"] == "high"
assert {item["evaluation_type"] for item in evaluations["items"]} == {
"offline_judge",
"structure_compliance",
"shadow_replay",
}
assert promoted["deployment_status"] == "candidate"
assert decided["deployment_status"] == "active"

View File

@ -0,0 +1,44 @@
import pytest
from app.services.ai_pmo.workflow_semantic_planner import (
WorkflowSemanticPlanner,
)
def test_workflow_semantic_planner_builds_multi_workflow_bundle() -> None:
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 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

195
scripts/semantic_lint.py Normal file
View File

@ -0,0 +1,195 @@
#!/usr/bin/env python3
from __future__ import annotations
import argparse
import sys
from dataclasses import dataclass
from pathlib import Path
import re
from typing import Iterable, Sequence
ROOT = Path(__file__).resolve().parents[1]
DEFAULT_SCAN_ROOTS = (
Path("backend/app/services/ai_pmo"),
Path("backend/app/services/integrations"),
Path("backend/app/services/signal"),
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"),
Path("backend/app/config/operation_catalog_core"),
Path("backend/app/services/ai_pmo/ai_pmo_kernel_world_model_core/mixin_project.py"),
Path("backend/app/services/signal/signal_source_orchestration/mixin_planning_locator_roles.py"),
Path("backend/app/services/signal/signal_source_orchestration/mixin_planning_onboard.py"),
Path("backend/app/services/signal/signal_capture_service/mixin_analysis_normalize.py"),
Path("backend/app/services/signal/symbol_index/shared.py"),
Path("backend/app/services/integrations/feishu_integration_service/mixin_message_payload.py"),
)
DEFAULT_BASELINE = Path("scripts/semantic_lint_baseline.txt")
@dataclass(frozen=True)
class Problem:
path: str
line: int
rule_id: str
excerpt: str
def key(self) -> str:
return f"{self.path}:{self.line}:{self.rule_id}"
@dataclass(frozen=True)
class Rule:
rule_id: str
pattern: re.Pattern[str]
message: str
RULES: tuple[Rule, ...] = (
Rule(
rule_id="semantic_hint_constant",
pattern=re.compile(r"\b[A-Z][A-Z0-9_]*(?:HINTS|KEYWORDS)\b\s*="),
message="semantic hint/keyword table should live in workflow_semantic_planner or prompt/catalog config",
),
Rule(
rule_id="semantic_regex_constant",
pattern=re.compile(
r"\b[A-Z][A-Z0-9_]*(?:PROJECT|TASK|SIGNAL|FOLLOWUP|COMMAND|QUERY|SOURCE|INTENT|EXTERNAL|CORRECTION)[A-Z0-9_]*_RE\b\s*=\s*re\.compile"
),
message="business routing regex should live in workflow_semantic_planner",
),
Rule(
rule_id="looks_like_helper",
pattern=re.compile(
r"def\s+_?looks_like_(?:project|task|signal|followup|query|command|external|contextual|write|source|intent)[A-Za-z0-9_]*\s*\("
),
message="behavior-routing looks_like helper should live in workflow_semantic_planner",
),
Rule(
rule_id="question_routing_helper",
pattern=re.compile(r"def\s+_is_(?:query_like_question|obvious_question)\s*\("),
message="question routing helper should live in workflow_semantic_planner",
),
)
def _normalize_relpath(root: Path, path: Path) -> str:
return path.resolve().relative_to(root.resolve()).as_posix()
def _is_allowlisted(relpath: str) -> bool:
relative = Path(relpath)
for prefix in ALLOWLIST_PREFIXES:
if relative == prefix or prefix in relative.parents:
return True
return False
def _iter_files(root: Path, scan_roots: Sequence[Path]) -> Iterable[Path]:
for rel_root in scan_roots:
candidate = (root / rel_root).resolve()
if not candidate.exists():
continue
if candidate.is_file():
yield candidate
continue
for path in candidate.rglob("*"):
if not path.is_file():
continue
if path.suffix not in {".py", ".ts", ".tsx"}:
continue
yield path
def _load_baseline(root: Path, baseline_path: Path) -> set[str]:
candidate = (root / baseline_path).resolve()
if not candidate.exists():
return set()
rows: set[str] = set()
for line in candidate.read_text(encoding="utf-8").splitlines():
stripped = line.strip()
if not stripped or stripped.startswith("#"):
continue
rows.add(stripped)
return rows
def collect_problems(
*,
root: Path,
scan_roots: Sequence[Path],
baseline_path: Path,
) -> list[Problem]:
baseline = _load_baseline(root, baseline_path)
problems: list[Problem] = []
for path in _iter_files(root, scan_roots):
relpath = _normalize_relpath(root, path)
if _is_allowlisted(relpath):
continue
lines = path.read_text(encoding="utf-8").splitlines()
for idx, raw_line in enumerate(lines, start=1):
line = raw_line.rstrip("\n")
if "semantic-lint: ignore" in line:
continue
for rule in RULES:
if not rule.pattern.search(line):
continue
problem = Problem(
path=relpath,
line=idx,
rule_id=rule.rule_id,
excerpt=line.strip()[:240],
)
if problem.key() in baseline:
continue
problems.append(problem)
break
return problems
def _parse_args(argv: Sequence[str]) -> argparse.Namespace:
parser = argparse.ArgumentParser(description="Guard PMFlow business semantics from leaking outside workflow_semantic_planner.")
parser.add_argument(
"--root",
default=str(ROOT),
help="Repository root to scan.",
)
parser.add_argument(
"--baseline",
default=str(DEFAULT_BASELINE),
help="Baseline file with approved compatibility exceptions.",
)
parser.add_argument(
"scan_roots",
nargs="*",
help="Optional relative roots/files to scan. Defaults to backend/app and frontend/src.",
)
return parser.parse_args(argv)
def main(argv: Sequence[str] | None = None) -> int:
args = _parse_args(argv or sys.argv[1:])
root = Path(args.root).resolve()
scan_roots = tuple(Path(item) for item in (args.scan_roots or DEFAULT_SCAN_ROOTS))
problems = collect_problems(
root=root,
scan_roots=scan_roots,
baseline_path=Path(args.baseline),
)
if not problems:
print("semantic-lint: ok")
return 0
print("semantic-lint: problems found")
for item in problems:
print(f"- {item.path}:{item.line} [{item.rule_id}] {item.excerpt}")
return 1
if __name__ == "__main__":
raise SystemExit(main())

View File

@ -0,0 +1,15 @@
# Approved compatibility exceptions for semantic-lint.
# Format: relative/path.py:line:rule_id
backend/app/services/integrations/feishu_signal_pipeline/shared_core.py:71:semantic_regex_constant
backend/app/services/integrations/feishu_signal_pipeline/shared_core.py:79:semantic_regex_constant
backend/app/services/integrations/feishu_signal_pipeline/shared_core.py:95:semantic_regex_constant
backend/app/services/integrations/feishu_signal_pipeline/shared_core.py:99:semantic_regex_constant
backend/app/services/integrations/feishu_signal_pipeline/shared_core.py:103:semantic_regex_constant
backend/app/services/integrations/feishu_signal_pipeline/shared_core.py:107:semantic_regex_constant
backend/app/services/integrations/feishu_signal_pipeline/shared_core.py:111:semantic_regex_constant
backend/app/services/integrations/feishu_signal_pipeline/shared_core.py:129:semantic_regex_constant
backend/app/services/signal/signal_capture_service/shared.py:107:semantic_hint_constant
backend/app/api/ai_pmo_api/shared.py:42:semantic_hint_constant
backend/app/api/ai_pmo_api/shared.py:43:semantic_hint_constant
backend/app/api/ai_pmo_api/shared.py:44:semantic_regex_constant
backend/app/api/ai_pmo_api/shared.py:48:semantic_hint_constant

BIN
task_instance_20260326.xlsx Normal file

Binary file not shown.

Binary file not shown.