Add reply iteration and training pipeline foundations
This commit is contained in:
parent
0e87cec76d
commit
b30b0f4834
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
{
|
{
|
||||||
"version": 1,
|
"version": 1,
|
||||||
"onboardingCompletedAt": "2026-03-26T16:00:06.817Z"
|
"setupCompletedAt": "2026-03-26T16:00:06.817Z"
|
||||||
}
|
}
|
||||||
|
|
|
||||||
15
AGENTS.md
15
AGENTS.md
|
|
@ -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. 等待用户输入"确认"后才能执行
|
||||||
33
CLAUDE.md
33
CLAUDE.md
|
|
@ -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 锚点或唯一业务目标匹配时才允许继续查询,避免多个话题并行时串线
|
||||||
|
|
|
||||||
|
|
@ -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 表**!
|
||||||
|
|
@ -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")
|
||||||
|
|
@ -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)
|
||||||
|
|
@ -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)
|
||||||
|
|
@ -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)
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
|
|
@ -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,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
@ -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",
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
)
|
)
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
|
|
|
||||||
|
|
@ -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",
|
||||||
|
]
|
||||||
|
|
@ -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",
|
||||||
|
]
|
||||||
|
|
@ -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)
|
||||||
|
|
@ -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
|
||||||
|
|
@ -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,
|
||||||
)
|
)
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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",
|
||||||
)
|
)
|
||||||
|
|
|
||||||
|
|
@ -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],
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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),
|
||||||
|
}
|
||||||
|
|
@ -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
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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",
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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]]] = {}
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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:
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
)
|
)
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
|
|
|
||||||
|
|
@ -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()
|
||||||
|
|
|
||||||
|
|
@ -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))
|
||||||
|
|
|
||||||
|
|
@ -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(),
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
)
|
)
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
|
|
||||||
|
|
@ -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]:
|
||||||
|
|
|
||||||
|
|
@ -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",
|
||||||
|
]
|
||||||
|
|
@ -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("__")]
|
||||||
|
|
@ -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"]
|
||||||
|
|
@ -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"]
|
||||||
|
|
@ -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"),
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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:
|
||||||
|
|
|
||||||
|
|
@ -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:
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
)
|
)
|
||||||
|
|
|
||||||
|
|
@ -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
|
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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 [],
|
||||||
|
)
|
||||||
),
|
),
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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:
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,3 @@
|
||||||
|
from .service import PromptReleaseService
|
||||||
|
|
||||||
|
__all__ = ["PromptReleaseService"]
|
||||||
|
|
@ -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()
|
||||||
|
|
@ -0,0 +1,3 @@
|
||||||
|
from .service import ReplyIterationService
|
||||||
|
|
||||||
|
__all__ = ["ReplyIterationService"]
|
||||||
|
|
@ -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)
|
||||||
|
|
@ -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"
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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")),
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,3 @@
|
||||||
|
from .service import TrainingPipelineService
|
||||||
|
|
||||||
|
__all__ = ["TrainingPipelineService"]
|
||||||
|
|
@ -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),
|
||||||
|
}
|
||||||
|
|
@ -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 ""
|
||||||
|
|
|
||||||
|
|
@ -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()
|
||||||
|
|
@ -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"},
|
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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:
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
@ -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"
|
||||||
|
|
@ -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"
|
||||||
|
|
@ -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
|
||||||
|
|
@ -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 == []
|
||||||
|
|
@ -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"
|
||||||
|
|
@ -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
|
||||||
|
|
@ -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())
|
||||||
|
|
@ -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
|
||||||
Binary file not shown.
Binary file not shown.
Loading…
Reference in New Issue