diff --git a/.github/workflows/contract-check.yml b/.github/workflows/contract-check.yml
index 65ae838c..84e65398 100644
--- a/.github/workflows/contract-check.yml
+++ b/.github/workflows/contract-check.yml
@@ -10,6 +10,8 @@ on:
- "frontend/src/types/**"
- "docs/architecture/**"
- "scripts/check_contract_sync.sh"
+ - "scripts/semantic_lint.py"
+ - "scripts/semantic_lint_baseline.txt"
- ".github/workflows/contract-check.yml"
push:
branches: [ main, master ]
@@ -21,6 +23,8 @@ on:
- "frontend/src/types/**"
- "docs/architecture/**"
- "scripts/check_contract_sync.sh"
+ - "scripts/semantic_lint.py"
+ - "scripts/semantic_lint_baseline.txt"
- ".github/workflows/contract-check.yml"
jobs:
@@ -31,3 +35,6 @@ jobs:
- name: Run contract sync check
run: |
bash scripts/check_contract_sync.sh
+ - name: Run semantic lint
+ run: |
+ python3 scripts/semantic_lint.py
diff --git a/.openclaw/workspace-state.json b/.openclaw/workspace-state.json
index f4d03dda..07fe13b6 100644
--- a/.openclaw/workspace-state.json
+++ b/.openclaw/workspace-state.json
@@ -1,4 +1,4 @@
{
"version": 1,
- "onboardingCompletedAt": "2026-03-26T16:00:06.817Z"
+ "setupCompletedAt": "2026-03-26T16:00:06.817Z"
}
diff --git a/AGENTS.md b/AGENTS.md
index 7ad4204c..1d4c1e1b 100644
--- a/AGENTS.md
+++ b/AGENTS.md
@@ -86,6 +86,7 @@ frontend/ # React 前端
- 禁止在前端组件内直接调用 `fetch`,必须通过 `frontend/src/api/` 封装层
- 前端状态管理:服务端数据用 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. 新增代码的参考模式
@@ -176,3 +177,17 @@ frontend/ # React 前端
- 单任务预估改动超过200行 → 拆分执行
- 发生 context 不足时 → 停止并输出已完成部分
声明 `[CONTEXT: LIMIT]`,不要截断输出
+
+
+## 安全约束(必须严格遵守)
+
+### 🚫 绝对禁止的操作:
+- 禁止删除任何文件或目录(rm、rmdir、unlink、trash 等)
+- 禁止执行带 -rf、--force、-y 参数的命令
+- 禁止 DROP TABLE、TRUNCATE 等数据库破坏性操作
+- 禁止修改 /etc、/sys、/boot 等系统目录
+
+### ✅ 文件操作前必须:
+1. 先列出将要操作的文件路径
+2. 明确告知用户操作后果
+3. 等待用户输入"确认"后才能执行
\ No newline at end of file
diff --git a/CLAUDE.md b/CLAUDE.md
index 7755f081..c4a3b388 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -92,7 +92,8 @@ backend/
- 新增:`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/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/api/audit.py`(审计查询接口:`GET /audit/operations`)
- 新增:`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_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/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/app/services/signal/signal_planner/signal_strategic_analysis_service.py`(Signal Planner 写回收口)
- 新增:`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_pipeline_queue_contract.py`(飞书待确认队列契约回归:action card 元数据与 follow-up 唯一目标约束)
- 新增:`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_signal_planner_contract.py`(Signal Planner 契约回归:最新宪章加载、prompt 注入、无宪章降级)
- 新增:`backend/tests/test_ai_prompt_registry_contract.py`(Prompt registry 契约回归:覆盖率与元数据完整性)
@@ -178,6 +183,16 @@ backend/
- 移除:`backend/.env.example`(配置模板统一到根目录 `.env.example`)
- 新增:`backend/app/utils/project_command_utils.py`(项目创建标题/描述提取与归一化辅助)
- 新增:`backend/app/services/ai_pmo_intent_routing.py`(AI PMO 意图/路由常量与解析器收口,统一 `family.action` 路由映射)
+- 新增:`backend/app/services/ai_pmo/workflow_semantic_planner/`(统一业务语义规划层:唯一允许承载 workflow bundle 语义关键词/正则/兼容 helper 的目录)
+- 新增:`backend/app/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_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`)
@@ -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` 处理
- AI PMO 执行层必须统一产出 `execution_result.action_contract` 与 `action_param_source`,并写入 `operation_execution_ledger`;禁止在执行链路旁路写副作用且不入账
- AI PMO 写操作幂等以消息级锚点为准:优先使用 `source_message_id` / `feishu_message_id` 作为执行账本幂等键,标题/正文仅用于业务内容,不得参与重复执行判定
+- PMFlow 业务语义治理:会决定 `project.create / signal_source.onboard / followup.* / query vs execute` 的中文关键词、正则、`looks_like_*`、`*_HINTS/*_KEYWORDS` 只能定义在 `backend/app/services/ai_pmo/workflow_semantic_planner/` 或 prompt/catalog 配置层;其他目录仅允许做文本清洗、ID/URL/repo/locator 抽取与结构化结果解析
+- `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. 新增代码的参考模式
- 新增后端接口:参考 `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/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`
+- 回复质量闭环接口:参考 `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`)
- 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` 持久化)
@@ -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`
- 飞书异步入站队列在 `ai_orchestration_runs.result_payload.task_payload` 中必须保留 `thread_context`,worker 重放时不得仅依赖 `source_message_id`,否则 reply-thread 会在异步链路丢锚点
- 飞书纯问答直答不再注入群聊级 `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` 的确定性直查;只有姓名/账号能唯一匹配时才直接返回数据库负载答案,不再把项目列表或历史上下文混入负载结论
- 会话命中 `session.last_intent=draft_pending_confirm` 不再自动劫持普通消息;只有明确 `reply_to_message_id` 且消息本身是草稿动作,或显式 `work_item_id` 命中挂起草稿时,才进入草稿确认处理器
- 飞书 follow-up 状态追问不再猜测“最近一条处理项”;只有显式 reply 锚点或唯一业务目标匹配时才允许继续查询,避免多个话题并行时串线
diff --git a/MEMORY.md b/MEMORY.md
new file mode 100644
index 00000000..07582bdd
--- /dev/null
+++ b/MEMORY.md
@@ -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 表**!
diff --git a/backend/alembic/versions/20260327_add_external_qa_followup_state.py b/backend/alembic/versions/20260327_add_external_qa_followup_state.py
new file mode 100644
index 00000000..89f7e41c
--- /dev/null
+++ b/backend/alembic/versions/20260327_add_external_qa_followup_state.py
@@ -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")
diff --git a/backend/alembic/versions/20260327_add_qa_record_followup_anchor_fields.py b/backend/alembic/versions/20260327_add_qa_record_followup_anchor_fields.py
new file mode 100644
index 00000000..f3d6c387
--- /dev/null
+++ b/backend/alembic/versions/20260327_add_qa_record_followup_anchor_fields.py
@@ -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)
diff --git a/backend/alembic/versions/20260327_add_reply_iteration_training_tables.py b/backend/alembic/versions/20260327_add_reply_iteration_training_tables.py
new file mode 100644
index 00000000..763da22c
--- /dev/null
+++ b/backend/alembic/versions/20260327_add_reply_iteration_training_tables.py
@@ -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)
diff --git a/backend/app/api/ai_steward_api/__init__.py b/backend/app/api/ai_steward_api/__init__.py
index cab4ee34..f764ac64 100644
--- a/backend/app/api/ai_steward_api/__init__.py
+++ b/backend/app/api/ai_steward_api/__init__.py
@@ -7,8 +7,10 @@ from . import (
routes_capture,
routes_conflicts,
routes_feedback_center,
+ routes_reply_iteration,
routes_rag_sources,
routes_stats_config,
+ routes_training_pipeline,
routes_workflow,
)
@@ -18,8 +20,10 @@ for _module in [
routes_capture,
routes_conflicts,
routes_feedback_center,
+ routes_reply_iteration,
routes_brief_blockers,
routes_stats_config,
+ routes_training_pipeline,
routes_workflow,
]:
router.include_router(_module.router)
diff --git a/backend/app/api/ai_steward_api/routes_reply_iteration.py b/backend/app/api/ai_steward_api/routes_reply_iteration.py
new file mode 100644
index 00000000..3558e577
--- /dev/null
+++ b/backend/app/api/ai_steward_api/routes_reply_iteration.py
@@ -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)
diff --git a/backend/app/api/ai_steward_api/routes_training_pipeline.py b/backend/app/api/ai_steward_api/routes_training_pipeline.py
new file mode 100644
index 00000000..4a053c1d
--- /dev/null
+++ b/backend/app/api/ai_steward_api/routes_training_pipeline.py
@@ -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,
+ )
+ )
diff --git a/backend/app/models/__init__.py b/backend/app/models/__init__.py
index 3ce356f3..221ea90b 100644
--- a/backend/app/models/__init__.py
+++ b/backend/app/models/__init__.py
@@ -46,6 +46,16 @@ from .knowledge_base_chunk import KnowledgeBaseChunk
from .qa_record import QARecord
from .qa_feedback import QAFeedback
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_state import QASessionState
from .kg_entity import KGEntity
@@ -114,6 +124,18 @@ __all__ = [
"QAFeedback",
"FeedbackCenterRecord",
"FeedbackCenterRecordRevision",
+ "QualityAsset",
+ "QualityAssetPair",
+ "JudgeRun",
+ "OptimizerCandidate",
+ "PromptRelease",
+ "TrainingDataset",
+ "TrainingDatasetItem",
+ "TrainingJob",
+ "TrainingJobRun",
+ "TrainingEvaluation",
+ "TrainedModelArtifact",
+ "TrainedModelDeployment",
"QASession",
"QASessionState",
"KGEntity",
diff --git a/backend/app/models/experiment.py b/backend/app/models/experiment.py
index 47e33706..c5c6608f 100644
--- a/backend/app/models/experiment.py
+++ b/backend/app/models/experiment.py
@@ -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 .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):
@@ -16,6 +18,12 @@ class Experiment(Base):
target_metric: Mapped[Optional[str]] = mapped_column(VARCHAR(50), nullable=True)
current_score: Mapped[Optional[float]] = mapped_column(Float, 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(
BigInteger, ForeignKey("projects.id"), nullable=True, index=True
)
diff --git a/backend/app/models/qa_record.py b/backend/app/models/qa_record.py
index ead8fad9..7d067770 100644
--- a/backend/app/models/qa_record.py
+++ b/backend/app/models/qa_record.py
@@ -31,3 +31,12 @@ class QARecord(Base):
index=True,
)
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)
diff --git a/backend/app/models/qa_session.py b/backend/app/models/qa_session.py
index b7c675e2..6ee05bc6 100644
--- a/backend/app/models/qa_session.py
+++ b/backend/app/models/qa_session.py
@@ -4,7 +4,7 @@ from __future__ import annotations
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 .base import Base
@@ -12,9 +12,17 @@ from .base import Base
class QASession(Base):
__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)
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="新会话")
project_id: Mapped[Optional[int]] = mapped_column(BigInteger, nullable=True, index=True)
started_at: Mapped[Optional[TIMESTAMP]] = mapped_column(TIMESTAMP, nullable=True, index=True)
diff --git a/backend/app/models/qa_session_state.py b/backend/app/models/qa_session_state.py
index dc81410d..346959b1 100644
--- a/backend/app/models/qa_session_state.py
+++ b/backend/app/models/qa_session_state.py
@@ -4,7 +4,7 @@ from __future__ import annotations
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 .base import Base
@@ -27,3 +27,5 @@ class QASessionState(Base):
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)
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)
diff --git a/backend/app/models/reply_iteration.py b/backend/app/models/reply_iteration.py
new file mode 100644
index 00000000..72714f36
--- /dev/null
+++ b/backend/app/models/reply_iteration.py
@@ -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",
+]
diff --git a/backend/app/models/training_pipeline.py b/backend/app/models/training_pipeline.py
new file mode 100644
index 00000000..32f135d4
--- /dev/null
+++ b/backend/app/models/training_pipeline.py
@@ -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",
+]
diff --git a/backend/app/schemas/reply_iteration.py b/backend/app/schemas/reply_iteration.py
new file mode 100644
index 00000000..a18e5971
--- /dev/null
+++ b/backend/app/schemas/reply_iteration.py
@@ -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)
diff --git a/backend/app/schemas/training_pipeline.py b/backend/app/schemas/training_pipeline.py
new file mode 100644
index 00000000..7dd8aa5d
--- /dev/null
+++ b/backend/app/schemas/training_pipeline.py
@@ -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
diff --git a/backend/app/services/adaptive_rag_service/mixin_answer_entry.py b/backend/app/services/adaptive_rag_service/mixin_answer_entry.py
index ffb67262..29a08e4a 100644
--- a/backend/app/services/adaptive_rag_service/mixin_answer_entry.py
+++ b/backend/app/services/adaptive_rag_service/mixin_answer_entry.py
@@ -69,8 +69,11 @@ class AdaptiveRAGAnswerEntryMixin:
asked_via: str,
project_id: Optional[int] = None,
source_chat_id: Optional[str] = None,
+ source_message_id: Optional[str] = None,
session_id: Optional[int] = 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,
thinking_mode: str = THINKING_MODE_NORMAL,
action_mode: str = ACTION_MODE_AUTO,
@@ -116,11 +119,15 @@ class AdaptiveRAGAnswerEntryMixin:
asked_via=asked_via,
project_id=project_id,
source_chat_id=source_chat_id,
+ source_message_id=source_message_id,
session_id=session_id,
conversation_key=conversation_key,
force_new_session=force_new_session,
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"]
turn_index = int(runtime["turn_index"] or 1)
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 {}
query_type = str(runtime["query_type"] or QUERY_TYPE_UNKNOWN)
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(
sync_requirement=sync_requirement,
@@ -177,4 +185,5 @@ class AdaptiveRAGAnswerEntryMixin:
started=started,
message_intent=message_intent,
mixed_signal_part=mixed_signal_part,
+ anchor_resolution=anchor_resolution,
)
diff --git a/backend/app/services/adaptive_rag_service/mixin_answer_mainline.py b/backend/app/services/adaptive_rag_service/mixin_answer_mainline.py
index 5132d588..6004e751 100644
--- a/backend/app/services/adaptive_rag_service/mixin_answer_mainline.py
+++ b/backend/app/services/adaptive_rag_service/mixin_answer_mainline.py
@@ -17,11 +17,24 @@ class AdaptiveRAGAnswerMainlineMixin:
conversation_key: Optional[str],
force_new_session: bool,
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]:
external_conversation_key = self._build_external_conversation_key(
asked_via=asked_via,
source_chat_id=source_chat_id,
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(
asked_by=asked_by,
@@ -30,6 +43,7 @@ class AdaptiveRAGAnswerMainlineMixin:
question=clean_q,
preferred_session_id=session_id,
force_new_session=bool(force_new_session),
+ external_conversation_key=external_conversation_key,
)
context_session_id: Optional[int]
if session_id is not None:
@@ -46,17 +60,39 @@ class AdaptiveRAGAnswerMainlineMixin:
external_session_id=context_session_id,
limit=10,
)
- state_row = await self._ensure_session_state(session_id=session_id)
- effective_q, rewrite_meta = self._rewrite_question_with_state(
+ state_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
+ )
+ state_row = await self._ensure_session_state(session_id=state_session_id)
+ pending_selection = self._consume_pending_clarification_selection(
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,
session_state=state_row,
+ anchor_resolution=anchor_resolution,
)
query_type = self._classify_query_type(effective_q)
requested_action_mode = self._normalize_action_mode(action_mode)
effective_action_mode = self._resolve_action_mode(
requested_mode=requested_action_mode,
- question=clean_q,
+ question=resolved_question,
effective_question=effective_q,
)
return {
@@ -65,10 +101,12 @@ class AdaptiveRAGAnswerMainlineMixin:
"context_session_id": context_session_id,
"conversation": conversation,
"state_row": state_row,
+ "resolved_question": resolved_question,
"effective_q": effective_q,
"rewrite_meta": rewrite_meta,
"query_type": query_type,
"effective_action_mode": effective_action_mode,
+ "anchor_resolution": anchor_resolution,
}
async def _execute_base_answer_mainline(
@@ -96,6 +134,7 @@ class AdaptiveRAGAnswerMainlineMixin:
started: float,
message_intent: Optional[str],
mixed_signal_part: Optional[str],
+ anchor_resolution: Optional[FollowupAnchorResolution] = None,
) -> dict[str, Any]:
forced_deep_reason = ""
if not deep_thinking:
@@ -115,13 +154,36 @@ class AdaptiveRAGAnswerMainlineMixin:
else:
forced_deep_reason = ""
- clarification_answer = self._build_clarification_question(
- original_question=clean_q,
- effective_question=effective_q,
+ clarification_payload = self._build_followup_clarification_payload(
+ question=clean_q,
+ anchor_resolution=anchor_resolution,
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_answer=clarification_answer,
+ clarification_payload=clarification_payload,
started=started,
clean_q=clean_q,
effective_q=effective_q,
diff --git a/backend/app/services/adaptive_rag_service/mixin_answer_payload.py b/backend/app/services/adaptive_rag_service/mixin_answer_payload.py
index a693a868..42abbfdc 100644
--- a/backend/app/services/adaptive_rag_service/mixin_answer_payload.py
+++ b/backend/app/services/adaptive_rag_service/mixin_answer_payload.py
@@ -34,6 +34,12 @@ class AdaptiveRAGAnswerPayloadMixin:
ack_latency_ms: Optional[int] = None,
trace_id: 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]:
evidence: list[dict[str, Any]] = []
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,
"trace_id": str(trace_id 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
@@ -127,4 +143,3 @@ class AdaptiveRAGAnswerPayloadMixin:
"trace_id": trace_id or None,
"processing_stage": normalized_stage or None,
}
-
diff --git a/backend/app/services/adaptive_rag_service/mixin_answer_precheck.py b/backend/app/services/adaptive_rag_service/mixin_answer_precheck.py
index e995fa21..d39d049c 100644
--- a/backend/app/services/adaptive_rag_service/mixin_answer_precheck.py
+++ b/backend/app/services/adaptive_rag_service/mixin_answer_precheck.py
@@ -5,10 +5,10 @@ from .shared import *
class AdaptiveRAGAnswerPrecheckMixin:
- async def _maybe_return_clarification_response(
+ async def _maybe_return_meta_followup_response(
self,
*,
- clarification_answer: Optional[str],
+ meta_followup_payload: Optional[dict[str, Any]],
started: float,
clean_q: str,
effective_q: str,
@@ -23,8 +23,137 @@ class AdaptiveRAGAnswerPrecheckMixin:
context_session_id: Optional[int],
active_thinking_mode: str,
) -> 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:
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)
clarify_sources = [
@@ -32,9 +161,12 @@ class AdaptiveRAGAnswerPrecheckMixin:
"source": "system",
"label": "Session State",
"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(
tenant_id=self.tenant_id,
question=clean_q,
@@ -46,10 +178,23 @@ class AdaptiveRAGAnswerPrecheckMixin:
asked_by=asked_by,
asked_via=(asked_via or "pmflow")[:20],
project_id=project_id,
- session_id=session_id,
+ session_id=active_session_id,
turn_index=turn_index,
+ **self._build_qa_record_external_fields(),
)
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(
session_state=state_row,
question=clean_q,
@@ -78,13 +223,16 @@ class AdaptiveRAGAnswerPrecheckMixin:
db_timeout=False,
vector_timeout=False,
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),
relevance_max=0.95,
query_type=query_type,
confidence=0.92,
- missing_fields=["subject"],
+ missing_fields=missing_fields,
thinking_mode=active_thinking_mode,
+ clarification_code=clarification_code,
+ clarification_candidates=clarification_candidates,
+ reason_code=clarification_code,
)
async def _maybe_return_snapshot_direct_response(
@@ -138,8 +286,9 @@ class AdaptiveRAGAnswerPrecheckMixin:
asked_by=asked_by,
asked_via=(asked_via or "pmflow")[:20],
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,
+ **self._build_qa_record_external_fields(),
)
self.db.add(qa)
await self._update_session_state_after_turn(
@@ -178,4 +327,3 @@ class AdaptiveRAGAnswerPrecheckMixin:
missing_fields=[],
thinking_mode=active_thinking_mode,
)
-
diff --git a/backend/app/services/adaptive_rag_service/mixin_answer_retrieval.py b/backend/app/services/adaptive_rag_service/mixin_answer_retrieval.py
index c07a197d..45ccaf3f 100644
--- a/backend/app/services/adaptive_rag_service/mixin_answer_retrieval.py
+++ b/backend/app/services/adaptive_rag_service/mixin_answer_retrieval.py
@@ -83,6 +83,7 @@ class AdaptiveRAGAnswerRetrievalMixin:
mixed_signal_part=mixed_signal_part,
query_type=query_type,
)
+ routing_snapshot = self._peek_last_answer_routing_snapshot()
used_sources = self._dedupe_sources(retrieved)
if not used_sources:
used_sources = [
@@ -244,6 +245,11 @@ class AdaptiveRAGAnswerRetrievalMixin:
project_id=project_id,
session_id=session_id,
turn_index=turn_index,
+ **self._build_qa_record_external_fields(
+ anchor_kind=None,
+ anchor_ref=None,
+ routing_snapshot=routing_snapshot,
+ ),
)
self.db.add(qa)
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,
missing_fields=missing_fields,
thinking_mode=active_thinking_mode,
+ reason_code="answer_generated",
)
diff --git a/backend/app/services/adaptive_rag_service/mixin_core_intent.py b/backend/app/services/adaptive_rag_service/mixin_core_intent.py
index 7a4b7de8..36fa074d 100644
--- a/backend/app/services/adaptive_rag_service/mixin_core_intent.py
+++ b/backend/app/services/adaptive_rag_service/mixin_core_intent.py
@@ -10,6 +10,7 @@ class AdaptiveRAGCoreIntentMixin:
self.tenant_id = int(tenant_id)
self.llm = LLMService()
self.context_cache = context_cache_service
+ self._active_external_qa_context: dict[str, Any] = {}
@staticmethod
def _normalize_thinking_mode(value: Optional[str]) -> str:
@@ -33,17 +34,107 @@ class AdaptiveRAGCoreIntentMixin:
asked_via: str,
source_chat_id: 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:
+ 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)
if explicit:
return explicit
- channel = str(asked_via or "").strip().lower()
if channel == "feishu":
- chat = str(source_chat_id or "").strip()
- if chat:
- return self._normalize_conversation_key(f"feishu:{chat}")
+ return self._normalize_conversation_key(
+ build_feishu_thread_key(
+ source_chat_id=source_chat_id,
+ source_sender_open_id=source_sender_open_id,
+ sender=sender,
+ thread_context=thread_context,
+ )
+ )
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
def _external_session_id_from_key(conversation_key: str) -> Optional[int]:
key = str(conversation_key or "").strip()
@@ -144,4 +235,3 @@ class AdaptiveRAGCoreIntentMixin:
"question_part": "",
"statement_part": text[:1000],
}
-
diff --git a/backend/app/services/adaptive_rag_service/mixin_deep_guard_answer.py b/backend/app/services/adaptive_rag_service/mixin_deep_guard_answer.py
index b11e0e6c..c3970309 100644
--- a/backend/app/services/adaptive_rag_service/mixin_deep_guard_answer.py
+++ b/backend/app/services/adaptive_rag_service/mixin_deep_guard_answer.py
@@ -2,6 +2,7 @@
from __future__ import annotations
from ...config.ai_prompts import PROMPT_PROJECT_STATUS_QA
+from ..prompt_release import PromptReleaseService
from .shared import *
@@ -140,7 +141,21 @@ class AdaptiveRAGDeepGuardAnswerMixin:
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,
*,
question: str,
@@ -167,12 +182,14 @@ class AdaptiveRAGDeepGuardAnswerMixin:
compact_user_snapshot_text = json.dumps(compact_user_snapshot or {}, ensure_ascii=False)[:6000]
return (
"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,
system_snapshot=system_snapshot_text or "{}",
user_snapshot=compact_user_snapshot_text or "{}",
context_blocks=filtered_context_blocks,
- ).strip(),
+ ),
)
if str(message_intent or "").strip().lower() == "mixed":
@@ -180,26 +197,30 @@ class AdaptiveRAGDeepGuardAnswerMixin:
merged_message = f"{question}\n{safe_signal_part}".strip()
return (
"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",
message=merged_message,
question_part=question,
signal_part=safe_signal_part,
system_snapshot=system_snapshot_text or "{}",
context_blocks=context_blocks_text or "无",
- ).strip(),
+ ),
)
if self._is_project_status_question(question, query_type):
return (
"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",
question=question,
system_snapshot=system_snapshot_text or "{}",
user_snapshot=user_snapshot_text or "{}",
context_blocks=context_blocks_text or "无",
- ).strip(),
+ ),
)
if self._contains_any_keyword(normalized_question, trend_keywords):
@@ -231,25 +252,31 @@ class AdaptiveRAGDeepGuardAnswerMixin:
)[:16000]
return (
"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,
project_context=project_context_text or "{}",
external_results=context_blocks_text or "无",
- ).strip(),
+ ),
)
return (
"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,
- ).strip(),
+ ),
)
if self._is_decision_choice_question(question):
return (
"decision_choice",
- PROMPT_DECISION_QA.format(
+ await self._render_scene_prompt(
+ prompt_key="PROMPT_DECISION_QA",
+ fallback_template=PROMPT_DECISION_QA,
question=question,
- ).strip(),
+ ),
)
if self._is_ai_pmo_process_entry(asked_via):
@@ -262,31 +289,37 @@ class AdaptiveRAGDeepGuardAnswerMixin:
)[:18000]
return (
"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,
project_id=project_id if project_id is not None else "unknown",
world_model=world_model_text or "{}",
context_blocks=context_blocks_text or "无",
- ).strip(),
+ ),
)
normalized_asked_via = str(asked_via or "").strip().lower()
if normalized_asked_via in {"feishu", "lark", "feishu_group", "feishu_dm"}:
return (
"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",
question=question,
system_snapshot=system_snapshot_text or "{}",
user_snapshot=user_snapshot_text or "{}",
conversation=conversation_text or "无",
context_blocks=context_blocks_text or "无",
- ).strip(),
+ ),
)
return (
"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",
question=question,
conversation=conversation_text or "无",
@@ -294,7 +327,7 @@ class AdaptiveRAGDeepGuardAnswerMixin:
system_snapshot=system_snapshot_text or "{}",
user_snapshot=user_snapshot_text or "{}",
relevance_max=f"{max(0.0, float(relevance_max or 0.0)):.3f}",
- ).strip(),
+ ),
)
def _build_snapshot_direct_answer(
@@ -535,7 +568,7 @@ class AdaptiveRAGDeepGuardAnswerMixin:
context_blocks_text = chr(10).join(context_blocks) if context_blocks else "无"
system_snapshot_text = json.dumps(system_snapshot or {}, ensure_ascii=False)[:12000]
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,
project_id=project_id,
asked_via=asked_via,
diff --git a/backend/app/services/adaptive_rag_service/mixin_fast_answer_semantic_p1.py b/backend/app/services/adaptive_rag_service/mixin_fast_answer_semantic_p1.py
index 693815bc..366002a3 100644
--- a/backend/app/services/adaptive_rag_service/mixin_fast_answer_semantic_p1.py
+++ b/backend/app/services/adaptive_rag_service/mixin_fast_answer_semantic_p1.py
@@ -1,6 +1,7 @@
"""Adaptive RAG mixin module."""
from __future__ import annotations
+from ..prompt_release import PromptReleaseService
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],
project_index=json.dumps(project_index, ensure_ascii=False),
- ).strip()
+ )
llm_started = time.perf_counter()
try:
raw = await self.llm.generate(
@@ -165,4 +169,3 @@ class AdaptiveRAGFastAnswerSemanticMixinP1:
}
except Exception:
return None
-
diff --git a/backend/app/services/adaptive_rag_service/mixin_fast_answer_semantic_p2.py b/backend/app/services/adaptive_rag_service/mixin_fast_answer_semantic_p2.py
index 9a5e298c..8750815f 100644
--- a/backend/app/services/adaptive_rag_service/mixin_fast_answer_semantic_p2.py
+++ b/backend/app/services/adaptive_rag_service/mixin_fast_answer_semantic_p2.py
@@ -56,6 +56,7 @@ class AdaptiveRAGFastAnswerSemanticMixinP2:
project_id=project_id,
session_id=active_session_id_int,
turn_index=active_turn_index,
+ **self._build_qa_record_external_fields(),
)
self.db.add(qa)
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 []),
thinking_mode=thinking_mode,
)
-
diff --git a/backend/app/services/adaptive_rag_service/mixin_followup_anchor.py b/backend/app/services/adaptive_rag_service/mixin_followup_anchor.py
new file mode 100644
index 00000000..4727b336
--- /dev/null
+++ b/backend/app/services/adaptive_rag_service/mixin_followup_anchor.py
@@ -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"]*>.*?", "", 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),
+ }
diff --git a/backend/app/services/adaptive_rag_service/mixin_qa_session_core.py b/backend/app/services/adaptive_rag_service/mixin_qa_session_core.py
index a74bc50d..000aa9d2 100644
--- a/backend/app/services/adaptive_rag_service/mixin_qa_session_core.py
+++ b/backend/app/services/adaptive_rag_service/mixin_qa_session_core.py
@@ -44,8 +44,10 @@ class AdaptiveRAGQASessionCoreMixin:
question: str,
preferred_session_id: Optional[int] = None,
force_new_session: bool = False,
+ external_conversation_key: Optional[str] = None,
) -> 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)
now_utc = now_cn_naive()
@@ -57,15 +59,31 @@ class AdaptiveRAGQASessionCoreMixin:
select(QASession).where(
and_(
QASession.tenant_id == self.tenant_id,
- QASession.user_id == int(asked_by),
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()
# 无显式 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 = (
await self.db.execute(
select(QARecord.session_id, QARecord.created_at)
@@ -103,7 +121,8 @@ class AdaptiveRAGQASessionCoreMixin:
title = self._build_auto_session_title(question) or SESSION_DEFAULT_TITLE
chosen = QASession(
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],
project_id=project_id,
started_at=now_utc,
@@ -115,6 +134,8 @@ class AdaptiveRAGQASessionCoreMixin:
else:
if chosen.project_id is None and project_id is not None:
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.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:
latest_feedback[key] = fb
return latest_feedback
-
diff --git a/backend/app/services/adaptive_rag_service/mixin_requirement_ack_flow.py b/backend/app/services/adaptive_rag_service/mixin_requirement_ack_flow.py
index 4f8294cf..dc046fdc 100644
--- a/backend/app/services/adaptive_rag_service/mixin_requirement_ack_flow.py
+++ b/backend/app/services/adaptive_rag_service/mixin_requirement_ack_flow.py
@@ -2,6 +2,7 @@
from __future__ import annotations
from .shared import *
+from ..ai_pmo.workflow_semantic_planner import WorkflowSemanticPlanner
class AdaptiveRAGRequirementAckFlowMixin:
@@ -44,7 +45,7 @@ class AdaptiveRAGRequirementAckFlowMixin:
linked_signal_id is not None
and resolved_project_id is not None
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:
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,
session_id=session_id,
turn_index=int(turn_index or 1),
+ **self._build_qa_record_external_fields(),
)
self.db.add(qa)
await self._update_session_state_after_turn(
@@ -170,4 +172,3 @@ class AdaptiveRAGRequirementAckFlowMixin:
trace_id=trace_id,
processing_stage="ack_only",
)
-
diff --git a/backend/app/services/adaptive_rag_service/mixin_session_state.py b/backend/app/services/adaptive_rag_service/mixin_session_state.py
index bbe1d30d..7d3e2e6c 100644
--- a/backend/app/services/adaptive_rag_service/mixin_session_state.py
+++ b/backend/app/services/adaptive_rag_service/mixin_session_state.py
@@ -27,6 +27,8 @@ class AdaptiveRAGSessionStateMixin:
last_intent=None,
ambiguity="unknown",
rewrite_history=[],
+ pending_clarification=None,
+ pending_clarification_expires_at=None,
)
self.db.add(row)
await self.db.flush()
@@ -38,6 +40,7 @@ class AdaptiveRAGSessionStateMixin:
question: str,
conversation: list[dict[str, str]],
session_state: Optional[QASessionState],
+ anchor_resolution: Optional[FollowupAnchorResolution] = None,
) -> tuple[str, dict[str, Any]]:
expanded = self._expand_followup_question(question, conversation)
current = str(expanded or question or "").strip()
@@ -66,37 +69,14 @@ class AdaptiveRAGSessionStateMixin:
anchor = ",".join(str(x) for x in state_entity_ids[:6])
rewritten = f"围绕会话实体ID[{anchor}]回答:{current}"
return rewritten[:1200], {"mode": "state_entities", "entity_ids": state_entity_ids[:6]}
+ anchored_question, anchor_meta = self._rewrite_question_with_followup_anchor(
+ question=current,
+ anchor_resolution=anchor_resolution,
+ )
+ if str(anchor_meta.get("mode") or "").strip() not in {"", "direct"}:
+ return anchored_question, anchor_meta
return current, {"mode": "referential_without_subject"}
- def _build_clarification_question(
- 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 not has_referential:
- return None
- 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(
self,
*,
@@ -111,12 +91,14 @@ class AdaptiveRAGSessionStateMixin:
) -> None:
if session_state is None:
return
- subject = self._extract_state_subject(question=question, answer=answer, sources=sources)
- if subject:
- 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)
+ if str(ambiguity or "").strip() != "needs_clarification":
+ subject = self._extract_state_subject(question=question, answer=answer, sources=sources)
+ if subject:
+ session_state.last_subject = subject[:255]
+ 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.append(
@@ -220,6 +202,11 @@ class AdaptiveRAGSessionStateMixin:
"last_intent": str(state.last_intent or "") if state else "",
"ambiguity": str(state.ambiguity or "") if state else "",
"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,
}
-
diff --git a/backend/app/services/adaptive_rag_service/service.py b/backend/app/services/adaptive_rag_service/service.py
index 05bec306..3e98e08f 100644
--- a/backend/app/services/adaptive_rag_service/service.py
+++ b/backend/app/services/adaptive_rag_service/service.py
@@ -39,6 +39,7 @@ from .mixin_deep_guard_answer import AdaptiveRAGDeepGuardAnswerMixin
from .mixin_conversation_utils_a_p1 import AdaptiveRAGConversationUtilsAMixinP1
from .mixin_conversation_utils_a_p2 import AdaptiveRAGConversationUtilsAMixinP2
from .mixin_conversation_utils_b import AdaptiveRAGConversationUtilsBMixin
+from .mixin_followup_anchor import AdaptiveRAGFollowupAnchorMixin
class AdaptiveRAGService(
@@ -80,6 +81,7 @@ class AdaptiveRAGService(
AdaptiveRAGConversationUtilsAMixinP1,
AdaptiveRAGConversationUtilsAMixinP2,
AdaptiveRAGConversationUtilsBMixin,
+ AdaptiveRAGFollowupAnchorMixin,
):
"""项目内统一 RAG 服务。"""
_symbol_snapshot_cache: dict[str, tuple[float, dict[str, Any]]] = {}
diff --git a/backend/app/services/adaptive_rag_service/shared_imports.py b/backend/app/services/adaptive_rag_service/shared_imports.py
index db0b00b9..f3f51fbb 100644
--- a/backend/app/services/adaptive_rag_service/shared_imports.py
+++ b/backend/app/services/adaptive_rag_service/shared_imports.py
@@ -46,6 +46,9 @@ from ...models import (
KGEvidence,
KGEntity,
KGRelation,
+ AICallLog,
+ FeishuReplyOutbox,
+ IngestEvent,
Project,
ProjectSignalLink,
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.thread_context import build_feishu_conversation_scope_key, build_feishu_thread_key
diff --git a/backend/app/services/ai_pmo/ai_pmo_intent/intent_resolver.py b/backend/app/services/ai_pmo/ai_pmo_intent/intent_resolver.py
index 6c88445d..22333a45 100644
--- a/backend/app/services/ai_pmo/ai_pmo_intent/intent_resolver.py
+++ b/backend/app/services/ai_pmo/ai_pmo_intent/intent_resolver.py
@@ -1,6 +1,7 @@
from __future__ import annotations
from .models import *
+from ..workflow_semantic_planner import WorkflowSemanticPlanner
class IntentResolver:
"""Resolve raw text to canonical intent."""
@@ -50,10 +51,16 @@ class IntentResolver:
@staticmethod
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()
+ normalized = re.sub(r"\s+", "", str(text or "")).lower()
if not pretty:
return CanonicalIntent(INTENT_IGNORE, 1.0, project_id, "", "", {"reason": "empty"})
+ bundle = WorkflowSemanticPlanner.plan_message(
+ 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):
return CanonicalIntent(INTENT_IGNORE, 0.95, project_id, "", "", {"reason": "smalltalk"})
if IntentResolver._looks_like_negated_write_statement(text=pretty):
@@ -76,34 +83,46 @@ class IntentResolver:
{
"followup_by_work_item_id": True,
"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):
return CanonicalIntent(INTENT_FOLLOWUP_CONFIRM, 0.82, project_id, "", "", {})
if any(token in pretty for token in FOLLOWUP_CONTINUE_HINTS):
return CanonicalIntent(INTENT_FOLLOWUP_CONTINUE, 0.82, project_id, "", "", {})
- if 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(
INTENT_SIGNAL_SOURCE_ONBOARD,
0.92,
project_id,
IntentResolver._build_signal_title(pretty, default="新增信号源接入请求"),
pretty[:300],
- {"source_request": True},
+ {"source_request": True, "semantic_bundle": bundle.to_dict()},
)
- if (
- IntentResolver._looks_like_project_create_request(text=pretty)
- or extract_project_create_title(pretty)
- ):
+ if "new_project_flow" in workflow_types or extract_project_create_title(pretty):
return CanonicalIntent(
INTENT_PROJECT_CREATE,
0.9,
project_id,
IntentResolver._build_signal_title(pretty, default="新项目创建请求"),
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)
return CanonicalIntent(
INTENT_TASK_CREATE,
@@ -111,25 +130,35 @@ class IntentResolver:
project_id,
extracted_title or IntentResolver._build_signal_title(pretty, default="新任务"),
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(
INTENT_SIGNAL_CREATE,
0.78,
project_id,
IntentResolver._build_signal_title(pretty, default="新信号"),
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(
INTENT_QUERY,
0.88,
project_id,
"",
pretty[:300],
- {},
+ {"semantic_bundle": bundle.to_dict()},
)
return CanonicalIntent(
INTENT_QUERY,
@@ -137,61 +166,24 @@ class IntentResolver:
project_id,
"",
pretty[:300],
- {"fallback": True, "reason": "default_query_fallback"},
+ {"fallback": True, "reason": "default_query_fallback", "semantic_bundle": bundle.to_dict()},
)
@staticmethod
- def _looks_like_signal_create_request(*, text: str) -> bool:
- normalized = re.sub(r"\s+", "", str(text or "")).lower()
- if not normalized:
- return False
- 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,
- )
+ def _looks_like_signal_create_request(*, text: str) -> bool: # semantic-lint: ignore - compat wrapper over workflow_semantic_planner
+ bundle = WorkflowSemanticPlanner.plan_message(
+ message_text=str(text or ""),
+ source_system="compat_intent_resolver",
)
+ return any(item.workflow_type == "project_signal_flow" for item in bundle.workflows)
@staticmethod
- def _looks_like_task_create_request(*, text: str) -> bool:
- normalized = re.sub(r"\s+", "", str(text or "")).lower()
- if not normalized:
- return False
- 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,
- )
+ def _looks_like_task_create_request(*, text: str) -> bool: # semantic-lint: ignore - compat wrapper over workflow_semantic_planner
+ bundle = WorkflowSemanticPlanner.plan_message(
+ message_text=str(text or ""),
+ source_system="compat_intent_resolver",
)
+ return any(item.workflow_type == "task_flow" for item in bundle.workflows)
@staticmethod
def _extract_task_title(text: str) -> str:
@@ -225,52 +217,24 @@ class IntentResolver:
@staticmethod
def _is_smalltalk_message(*, pretty: str, normalized: str) -> bool:
- compact = re.sub(r"[\\s\\??!!,,。~~、]+", "", str(pretty 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
+ _ = normalized
+ return WorkflowSemanticPlanner._is_smalltalk_message(pretty)
@staticmethod
- def _looks_like_query(*, text: str) -> bool:
- normalized = re.sub(r"\s+", "", str(text or "")).lower()
- if not normalized:
- return False
- 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 ("吗", "呢", "吧", "是否", "是不是"))
+ def _looks_like_query(*, text: str) -> bool: # semantic-lint: ignore - compat wrapper over workflow_semantic_planner
+ bundle = WorkflowSemanticPlanner.plan_message(
+ message_text=str(text or ""),
+ source_system="compat_intent_resolver",
)
- if not has_question_structure:
- return False
- has_operation = any(token in normalized for token in IntentResolver.OPERATION_VERBS)
- return not has_operation
+ return len(bundle.workflows) == 1 and bundle.workflows[0].workflow_type == "query_flow"
@staticmethod
- def _looks_like_project_create_request(*, text: str) -> bool:
- normalized = re.sub(r"\s+", "", str(text or "")).lower()
- if not normalized:
- return False
- if any(token in normalized for token in PROJECT_CREATE_HINTS):
- return True
- return bool(
- re.search(
- r"(?:新增|增加|添加|新建|创建|建立|开个|开一个|建个|建一个|做个|做一个|加个|加一个|立项)(?:一个|个)?(?:[^,。!?,.!?]{0,24})项目",
- normalized,
- re.IGNORECASE,
- )
+ def _looks_like_project_create_request(*, text: str) -> bool: # semantic-lint: ignore - compat wrapper over workflow_semantic_planner
+ bundle = WorkflowSemanticPlanner.plan_message(
+ message_text=str(text or ""),
+ source_system="compat_intent_resolver",
)
+ return any(item.workflow_type == "new_project_flow" for item in bundle.workflows)
@staticmethod
def _build_signal_title(text: str, *, default: str) -> str:
diff --git a/backend/app/services/ai_pmo/ai_pmo_intent/route_resolver.py b/backend/app/services/ai_pmo/ai_pmo_intent/route_resolver.py
index 609d19f0..86fd945a 100644
--- a/backend/app/services/ai_pmo/ai_pmo_intent/route_resolver.py
+++ b/backend/app/services/ai_pmo/ai_pmo_intent/route_resolver.py
@@ -1,6 +1,7 @@
from __future__ import annotations
from .models import *
+from ..workflow_semantic_planner import WorkflowSemanticPlanner
class RouteResolver:
"""Map canonical intent + text into workflow route."""
@@ -57,7 +58,7 @@ class RouteResolver:
return 1
@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 ""
if explicit_route == ROUTE_PROJECT_SIGNAL_DISCOVER:
return True
@@ -71,26 +72,11 @@ class RouteResolver:
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 {
- ROUTE_ACTION_CONTINUE,
- 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
+ return WorkflowSemanticPlanner.parse_followup_action(
+ metadata=metadata,
+ canonical_intent=canonical_intent,
+ message_text=message_text,
+ )
@classmethod
def from_canonical(
@@ -114,7 +100,7 @@ class RouteResolver:
route_key = ROUTE_TASK_CREATE
elif canonical_intent.name in {INTENT_FOLLOWUP_CONFIRM, INTENT_FOLLOWUP_CONTINUE}:
action = cls.parse_followup_action(
- metadata=metadata,
+ metadata={**metadata, "followup_context_present": True},
canonical_intent=canonical_intent.name,
message_text=message_text,
)
diff --git a/backend/app/services/ai_pmo/ai_pmo_intent/shared.py b/backend/app/services/ai_pmo/ai_pmo_intent/shared.py
index 758636cc..710c92a6 100644
--- a/backend/app/services/ai_pmo/ai_pmo_intent/shared.py
+++ b/backend/app/services/ai_pmo/ai_pmo_intent/shared.py
@@ -8,6 +8,23 @@ from typing import Any, Optional
from ....utils.project_command_utils import extract_project_create_title
from ...signal.signal_source_orchestration import SignalSourceOrchestrationService
+from ..workflow_semantic_planner.constants import (
+ FOLLOWUP_CANCEL_HINTS,
+ FOLLOWUP_COMMIT_HINTS,
+ FOLLOWUP_CONFIRM_HINTS,
+ FOLLOWUP_CONTINUE_HINTS,
+ FOLLOWUP_REFRESH_HINTS,
+ FOLLOWUP_WORK_ITEM_ID_RE,
+ PROJECT_CREATE_HINTS,
+ PROJECT_SIGNAL_DISCOVER_HINTS,
+ QUESTION_HINTS,
+ SHORT_CONFIRM_HINTS,
+ SIGNAL_CREATE_HINTS,
+ SMALLTALK_EXACT,
+ SMALLTALK_SHORT_HINTS,
+ SMALLTALK_STRUCTURED_HINTS,
+ TASK_CREATE_HINTS,
+)
INTENT_QUERY = "query.read_only"
INTENT_PROJECT_CREATE = "project.create"
@@ -215,116 +232,8 @@ LEGACY_INTENT_ROUTE_MAP: dict[str, str] = {
"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
SHORT_CONFIRM_MAX_MESSAGE_LENGTH = 10
-SHORT_CONFIRM_HINTS = (
- "是",
- "好",
- "对",
- "确认",
- "新增",
- "加",
- "嗯",
- "yes",
- "ok",
- "好的",
- "是的",
- "行",
- "可以",
- "没问题",
-)
INTENT_LLM_FUNCTION_NAME = "classify_intent_ai_pmo_kernel"
INTENT_CANDIDATES = (
INTENT_SIGNAL_SOURCE_ONBOARD,
diff --git a/backend/app/services/ai_pmo/ai_pmo_kernel/ai_agent_kernel.py b/backend/app/services/ai_pmo/ai_pmo_kernel/ai_agent_kernel.py
index 22a9eeaa..b483f0e6 100644
--- a/backend/app/services/ai_pmo/ai_pmo_kernel/ai_agent_kernel.py
+++ b/backend/app/services/ai_pmo/ai_pmo_kernel/ai_agent_kernel.py
@@ -7,25 +7,16 @@ import re
from dataclasses import dataclass
from typing import Iterable, Sequence
+from ..workflow_semantic_planner.constants import (
+ BACKGROUND_MARKERS,
+ CORRECTION_ACTION_PATTERNS,
+ INTENT_GROUP_KEYWORDS,
+ MULTI_SEGMENT_MARKERS,
+)
+
INTENT_FUNCTION_SIMPLE = "classify_intent_ai_pmo_kernel"
INTENT_FUNCTION_COMPLEX = "classify_intent_complex"
-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:
raw = os.getenv(name)
diff --git a/backend/app/services/ai_pmo/ai_pmo_kernel/ai_pmo_typed_executor_common.py b/backend/app/services/ai_pmo/ai_pmo_kernel/ai_pmo_typed_executor_common.py
index e6594f4f..a0ec0dcf 100644
--- a/backend/app/services/ai_pmo/ai_pmo_kernel/ai_pmo_typed_executor_common.py
+++ b/backend/app/services/ai_pmo/ai_pmo_kernel/ai_pmo_typed_executor_common.py
@@ -4,7 +4,6 @@ from __future__ import annotations
import hashlib
import json
-import re
from datetime import timedelta
from typing import Any, Optional
@@ -96,6 +95,10 @@ from ..ai_pmo_intent import (
IntentResolver,
RouteResolver,
)
+from ..workflow_semantic_planner.constants import (
+ GENERIC_SIGNAL_SOURCE_MESSAGE_RE,
+ MODULE_KEYWORD_RULES,
+)
from ...ai_steward_service import AIStewardService
from ...llm_service import llm_service
from ...model.model_context_bundle import ModelContextBundleService
@@ -103,22 +106,6 @@ from ...signal.signal_planner import SignalPlannerService
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():
return now_cn_naive()
diff --git a/backend/app/services/ai_pmo/ai_pmo_kernel/mixins/ingress/ai_pmo_kernel_mixins_ingress_render.py b/backend/app/services/ai_pmo/ai_pmo_kernel/mixins/ingress/ai_pmo_kernel_mixins_ingress_render.py
index bcd25372..456d5fd2 100644
--- a/backend/app/services/ai_pmo/ai_pmo_kernel/mixins/ingress/ai_pmo_kernel_mixins_ingress_render.py
+++ b/backend/app/services/ai_pmo/ai_pmo_kernel/mixins/ingress/ai_pmo_kernel_mixins_ingress_render.py
@@ -3,6 +3,7 @@
from __future__ import annotations
from .... import ai_pmo_kernel_common_core as _common
+from ....workflow_semantic_planner import WorkflowSemanticPlanner
for _name, _value in vars(_common).items():
if not _name.startswith("__"):
@@ -86,6 +87,24 @@ class KernelIngressRenderMixin:
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":
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))
diff --git a/backend/app/services/ai_pmo/ai_pmo_kernel/mixins/intent/ai_pmo_kernel_mixins_intent_llm.py b/backend/app/services/ai_pmo/ai_pmo_kernel/mixins/intent/ai_pmo_kernel_mixins_intent_llm.py
index e022d1b5..1083bcbe 100644
--- a/backend/app/services/ai_pmo/ai_pmo_kernel/mixins/intent/ai_pmo_kernel_mixins_intent_llm.py
+++ b/backend/app/services/ai_pmo/ai_pmo_kernel/mixins/intent/ai_pmo_kernel_mixins_intent_llm.py
@@ -3,6 +3,7 @@
from __future__ import annotations
from .... import ai_pmo_kernel_common_core as _common
+from ....workflow_semantic_planner import WorkflowSemanticPlanner
for _name, _value in vars(_common).items():
if not _name.startswith("__"):
@@ -198,6 +199,26 @@ class KernelIntentLLMMixin:
if structured_route is not None
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(),
},
)
diff --git a/backend/app/services/ai_pmo/ai_pmo_kernel/mixins/intent/ai_pmo_kernel_mixins_intent_rules.py b/backend/app/services/ai_pmo/ai_pmo_kernel/mixins/intent/ai_pmo_kernel_mixins_intent_rules.py
index 28673432..3e63aaab 100644
--- a/backend/app/services/ai_pmo/ai_pmo_kernel/mixins/intent/ai_pmo_kernel_mixins_intent_rules.py
+++ b/backend/app/services/ai_pmo/ai_pmo_kernel/mixins/intent/ai_pmo_kernel_mixins_intent_rules.py
@@ -3,6 +3,7 @@
from __future__ import annotations
from .... import ai_pmo_kernel_common_core as _common
+from ....workflow_semantic_planner import WorkflowSemanticPlanner
for _name, _value in vars(_common).items():
if not _name.startswith("__"):
@@ -10,42 +11,6 @@ for _name, _value in vars(_common).items():
class KernelIntentRulesMixin:
- CONTEXTUAL_CONFIRM_HINTS = (
- "来吧",
- "那就",
- "就这么办",
- "就按这个",
- "可以",
- "好的",
- "不错",
- "开始吧",
- "行",
- "没问题",
- "同意",
- "确认",
- )
- CONTEXTUAL_DEICTIC_HINTS = (
- "这个",
- "这个项目",
- "这条",
- "它",
- "按这个",
- "照这个",
- )
- CONTEXTUAL_WRITE_ACTION_HINTS = (
- "创建",
- "新建",
- "新增",
- "增加",
- "添加",
- "执行",
- "开始",
- "继续处理",
- "继续执行",
- "推进",
- "落地",
- )
-
@staticmethod
def _intent_llm_enabled() -> bool:
return _env_bool("AI_PMO_INTENT_LLM_ENABLED", True)
@@ -104,18 +69,8 @@ class KernelIntentRulesMixin:
)
@classmethod
- def _looks_like_contextual_write_confirmation(cls, *, message_text: str) -> bool:
- normalized = re.sub(r"\s+", "", str(message_text or "")).lower()
- 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 _looks_like_contextual_write_confirmation(cls, *, message_text: str) -> bool: # semantic-lint: ignore - compat wrapper over workflow_semantic_planner
+ return WorkflowSemanticPlanner.looks_like_contextual_write_confirmation(message_text)
def _recover_session_followup_write_intent(
self,
diff --git a/backend/app/services/ai_pmo/ai_pmo_kernel_common_core/constants.py b/backend/app/services/ai_pmo/ai_pmo_kernel_common_core/constants.py
index 73158e7c..4eab709b 100644
--- a/backend/app/services/ai_pmo/ai_pmo_kernel_common_core/constants.py
+++ b/backend/app/services/ai_pmo/ai_pmo_kernel_common_core/constants.py
@@ -3,6 +3,12 @@
from __future__ import annotations
from .imports import re
+from ..workflow_semantic_planner.constants import (
+ GENERIC_SIGNAL_SOURCE_MESSAGE_RE,
+ MODULE_KEYWORD_RULES,
+ POLICY_GATE_RULES,
+ TRANSPORT_MODE_CORRECTION_HINTS,
+)
STATE_RECEIVED = "received"
STATE_MODELED = "modeled"
@@ -20,37 +26,6 @@ RECEIPT_PHASE_ACTION = "action_applied"
RECEIPT_PHASE_PENDING = "pending_manual"
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", "是"}
SESSION_PROJECT_INHERIT_WINDOW_SECONDS = 30 * 60
INTENT_LLM_TIMEOUT_DEFAULT_SECONDS = 30.0
diff --git a/backend/app/services/ai_pmo/ai_pmo_kernel_ingress_flow/mixin_entry.py b/backend/app/services/ai_pmo/ai_pmo_kernel_ingress_flow/mixin_entry.py
index e2826739..000d4cb8 100644
--- a/backend/app/services/ai_pmo/ai_pmo_kernel_ingress_flow/mixin_entry.py
+++ b/backend/app/services/ai_pmo/ai_pmo_kernel_ingress_flow/mixin_entry.py
@@ -1,6 +1,7 @@
from __future__ import annotations
from .shared import *
+from app.utils.thread_context import build_feishu_conversation_scope_key
class KernelIngressFlowEntryMixin:
_MIXED_HINT_CONNECTORS = ("顺便", "另外", "同时", "并且", "还有", "再问")
@@ -30,6 +31,8 @@ class KernelIngressFlowEntryMixin:
source_system: str,
source_message_id: str,
channel_ref: Optional[str],
+ source_sender_open_id: Optional[str],
+ thread_context: Optional[dict[str, Any]],
message_text: str,
project_hint_id: Optional[int],
) -> 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()
sidecar_answer = ""
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(
question=question_part,
asked_by=None,
asked_via=source_system,
project_id=None,
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",
mixed_signal_part=signal_part,
)
@@ -203,6 +216,8 @@ class KernelIngressFlowEntryMixin:
source_system=source_system,
source_message_id=synthetic_message_id,
channel_ref=channel_ref,
+ source_sender_open_id=source_sender_open_id,
+ thread_context=thread_context,
message_text=clean_text,
project_hint_id=project_hint_id,
)
diff --git a/backend/app/services/ai_pmo/ai_pmo_typed_executor_execute/mixin_execute_query_project.py b/backend/app/services/ai_pmo/ai_pmo_typed_executor_execute/mixin_execute_query_project.py
index 749fd880..93c402bd 100644
--- a/backend/app/services/ai_pmo/ai_pmo_typed_executor_execute/mixin_execute_query_project.py
+++ b/backend/app/services/ai_pmo/ai_pmo_typed_executor_execute/mixin_execute_query_project.py
@@ -8,7 +8,7 @@ from ...adaptive_rag_service import AdaptiveRAGService
from ...context_cache_service import context_cache_service
from ...sstc_service_core import ALIGNED_STATUSES
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:
@@ -45,6 +45,7 @@ class ExecuteQueryProjectMixin:
mixed_signal_part = str(rag_request_context.get("mixed_signal_part") or "").strip() or None
else:
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"
if is_feishu_source:
asked_by = None
@@ -55,12 +56,10 @@ class ExecuteQueryProjectMixin:
requested_project_id = None
source_chat_id = None
conversation_key = (
- build_feishu_thread_key(
+ build_feishu_conversation_scope_key(
source_chat_id=None,
- source_sender_open_id=None,
+ source_sender_open_id=source_sender_open_id,
sender=None,
- source_message_id=source_message_id,
- thread_context=thread_context,
)
if is_feishu_source
else source_message_id
@@ -72,13 +71,19 @@ class ExecuteQueryProjectMixin:
sync_requirement = False
message_intent = 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] = {
"question": message_text,
"asked_by": asked_by,
"asked_via": asked_via,
"project_id": requested_project_id if requested_project_id is not None else intent.project_id,
"source_chat_id": source_chat_id,
+ "source_message_id": source_message_id,
"conversation_key": conversation_key,
+ "source_sender_open_id": source_sender_open_id,
+ "thread_context": thread_context,
"action_mode": action_mode,
"sync_requirement": sync_requirement,
"message_intent": message_intent,
diff --git a/backend/app/services/ai_pmo/draft_confirm_handler.py b/backend/app/services/ai_pmo/draft_confirm_handler.py
index e73cbdfa..457b7652 100644
--- a/backend/app/services/ai_pmo/draft_confirm_handler.py
+++ b/backend/app/services/ai_pmo/draft_confirm_handler.py
@@ -12,6 +12,7 @@ for _name, _value in vars(_common).items():
globals()[_name] = _value
from ..ai_steward_service import AIStewardService
+from .workflow_semantic_planner import WorkflowSemanticPlanner
from ..signal.draft_generator_service import DraftGeneratorService
from ...models.feishu_reply_outbox import FeishuReplyOutbox
@@ -24,10 +25,6 @@ class DraftConfirmHandler:
_ACTION_CANCEL = "cancel"
_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)
def __init__(self, kernel: Any) -> None:
@@ -38,15 +35,8 @@ class DraftConfirmHandler:
@classmethod
def parse_action_from_text(cls, *, message_text: str) -> tuple[str, str]:
- text = str(message_text or "").strip()
- lowered = text.lower()
- 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, ""
+ action, note = WorkflowSemanticPlanner.parse_draft_action(message_text=message_text)
+ return action, note
async def handle_text_reply(
self,
@@ -812,12 +802,10 @@ class DraftConfirmHandler:
@classmethod
def _extract_revision_note(cls, text: str) -> str:
- normalized = str(text or "").strip()
- for hint in cls._REVISE_HINTS:
- if normalized.startswith(hint):
- note = normalized[len(hint):].strip(" ::")
- return note[:500]
- return ""
+ action, note = WorkflowSemanticPlanner.parse_draft_action(message_text=text)
+ if action != cls._ACTION_REVISE:
+ return ""
+ return str(note or "")[:500]
@staticmethod
def _safe_float(value: Any) -> Optional[float]:
diff --git a/backend/app/services/ai_pmo/workflow_semantic_planner/__init__.py b/backend/app/services/ai_pmo/workflow_semantic_planner/__init__.py
new file mode 100644
index 00000000..65ecc2ea
--- /dev/null
+++ b/backend/app/services/ai_pmo/workflow_semantic_planner/__init__.py
@@ -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",
+]
diff --git a/backend/app/services/ai_pmo/workflow_semantic_planner/constants.py b/backend/app/services/ai_pmo/workflow_semantic_planner/constants.py
new file mode 100644
index 00000000..846a2afe
--- /dev/null
+++ b/backend/app/services/ai_pmo/workflow_semantic_planner/constants.py
@@ -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("__")]
diff --git a/backend/app/services/ai_pmo/workflow_semantic_planner/models.py b/backend/app/services/ai_pmo/workflow_semantic_planner/models.py
new file mode 100644
index 00000000..e34e73a6
--- /dev/null
+++ b/backend/app/services/ai_pmo/workflow_semantic_planner/models.py
@@ -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"]
diff --git a/backend/app/services/ai_pmo/workflow_semantic_planner/service.py b/backend/app/services/ai_pmo/workflow_semantic_planner/service.py
new file mode 100644
index 00000000..9185fb38
--- /dev/null
+++ b/backend/app/services/ai_pmo/workflow_semantic_planner/service.py
@@ -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"]
diff --git a/backend/app/services/integrations/feishu_integration_service/mixin_receive_capture_flow.py b/backend/app/services/integrations/feishu_integration_service/mixin_receive_capture_flow.py
index cd07850f..0165e593 100644
--- a/backend/app/services/integrations/feishu_integration_service/mixin_receive_capture_flow.py
+++ b/backend/app/services/integrations/feishu_integration_service/mixin_receive_capture_flow.py
@@ -158,5 +158,8 @@ class FeishuReceiveCaptureFlowMixin:
"ai_trace_id": result.get("ai_trace_id"),
"state_trace_id": state_trace_id,
"scene": scene,
+ "semantic_bundle_id": result.get("semantic_bundle_id"),
+ "semantic_version": result.get("semantic_version"),
+ "semantic_source": result.get("semantic_source"),
},
}
diff --git a/backend/app/services/integrations/feishu_integration_service/mixin_receive_routing.py b/backend/app/services/integrations/feishu_integration_service/mixin_receive_routing.py
index 6787d5aa..07c578f9 100644
--- a/backend/app/services/integrations/feishu_integration_service/mixin_receive_routing.py
+++ b/backend/app/services/integrations/feishu_integration_service/mixin_receive_routing.py
@@ -1,7 +1,7 @@
from __future__ import annotations
from .shared import *
-from ...ai_pmo.ai_pmo_intent import IntentResolver
+from ...ai_pmo.workflow_semantic_planner import WorkflowSemanticPlanner
class FeishuReceiveRoutingMixin:
async def _route_scene_reply(
@@ -151,48 +151,8 @@ class FeishuReceiveRoutingMixin:
def _classify_message_scene(*, capture_result: Dict[str, Any], message_content: str = "") -> str:
if not isinstance(capture_result, dict):
return "error"
- intent = str(capture_result.get("intent") or "").strip().lower()
- status = str(capture_result.get("status") or "").strip().lower()
- 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"
+ _ = message_content
+ return WorkflowSemanticPlanner.classify_scene(capture_result=capture_result)
@staticmethod
def _build_error_reply_text() -> str:
diff --git a/backend/app/services/integrations/feishu_integration_service/mixin_reply_llm_core.py b/backend/app/services/integrations/feishu_integration_service/mixin_reply_llm_core.py
index e85d1111..34580836 100644
--- a/backend/app/services/integrations/feishu_integration_service/mixin_reply_llm_core.py
+++ b/backend/app/services/integrations/feishu_integration_service/mixin_reply_llm_core.py
@@ -1,6 +1,7 @@
from __future__ import annotations
from .shared import *
+from ...ai_pmo.workflow_semantic_planner import WorkflowSemanticPlanner
class FeishuReplyLLMCoreMixin:
async def _reply_with_active_role_prompt(
@@ -221,22 +222,13 @@ class FeishuReplyLLMCoreMixin:
"你直接说目标、对象和期望动作即可。"
)
- @staticmethod
- def _is_action_request_message(message_content: Optional[str]) -> bool:
- normalized = re.sub(r"\s+", "", str(message_content or "").lower())
- if not normalized:
- return False
- action_tokens = (
- "创建项目",
- "新建项目",
- "立项",
- "创建信号",
- "创建信号源",
- "新增信号源",
- "接入信号源",
- "信号源接入",
- )
- return any(token in normalized for token in action_tokens)
+ @classmethod
+ def _is_action_request_message(
+ cls,
+ *,
+ capture_result: Dict[str, Any],
+ ) -> bool:
+ return WorkflowSemanticPlanner.is_action_request_capture(capture_result=capture_result)
@classmethod
def _build_action_request_ack_reply(
@@ -245,11 +237,13 @@ class FeishuReplyLLMCoreMixin:
message_content: Optional[str],
capture_result: Dict[str, Any],
) -> 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
- normalized = re.sub(r"\s+", "", str(message_content or "").lower())
- project_like = any(token in normalized for token in ("创建项目", "新建项目", "立项"))
- signal_like = any(token in normalized for token in ("创建信号", "创建信号源", "新增信号源", "接入信号源", "信号源接入"))
+ bundle = WorkflowSemanticPlanner.deserialize_bundle(capture_result.get("semantic_bundle"))
+ workflow_types = [item.workflow_type for item in (bundle.workflows if bundle is not None else [])]
+ 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:
return "已收到,项目创建和信号源接入请求已记录。"
if project_like:
diff --git a/backend/app/services/integrations/feishu_signal_pipeline/mixin_context_prefill_a.py b/backend/app/services/integrations/feishu_signal_pipeline/mixin_context_prefill_a.py
index dc8e20e0..16ec94cb 100644
--- a/backend/app/services/integrations/feishu_signal_pipeline/mixin_context_prefill_a.py
+++ b/backend/app/services/integrations/feishu_signal_pipeline/mixin_context_prefill_a.py
@@ -33,7 +33,7 @@ class FeishuPipelineContextPrefillAMixin:
)
@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()
if not normalized:
return False
diff --git a/backend/app/services/integrations/feishu_signal_pipeline/mixin_core_base.py b/backend/app/services/integrations/feishu_signal_pipeline/mixin_core_base.py
index 58c977aa..88a40231 100644
--- a/backend/app/services/integrations/feishu_signal_pipeline/mixin_core_base.py
+++ b/backend/app/services/integrations/feishu_signal_pipeline/mixin_core_base.py
@@ -2,6 +2,7 @@
from __future__ import annotations
from .shared import *
+from ...ai_pmo.workflow_semantic_planner import WorkflowSemanticPlanner
class FeishuPipelineCoreBaseMixin:
@@ -33,11 +34,11 @@ class FeishuPipelineCoreBaseMixin:
@staticmethod
def _is_healthcheck_message(message: str) -> bool:
- return bool(HEALTHCHECK_RE.search(str(message or "").strip()))
+ return WorkflowSemanticPlanner.is_healthcheck_message(message)
@staticmethod
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
def _is_correction_message(message: str) -> bool:
@@ -45,32 +46,19 @@ class FeishuPipelineCoreBaseMixin:
@staticmethod
def _looks_like_continue_followup(message: str) -> bool:
- text = str(message or "").strip()
- 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))
+ return WorkflowSemanticPlanner.wants_followup_continue(message)
@staticmethod
- def _looks_like_task_create_request(message: str) -> bool:
- return bool(TASK_CREATE_RE.search(str(message or "").strip()))
+ def _looks_like_task_create_request(message: str) -> bool: # semantic-lint: ignore - compat wrapper over workflow_semantic_planner
+ return WorkflowSemanticPlanner.looks_like_task_create_request(message)
@staticmethod
- def _looks_like_project_create_request(message: str) -> bool:
- text = str(message or "").strip()
- if not text:
- return False
- if PROJECT_CREATE_GATE_RE.search(text):
- return True
- return bool(LEGACY_COMMAND_PROJECT_CREATE_RE.search(text))
+ def _looks_like_project_create_request(message: str) -> bool: # semantic-lint: ignore - compat wrapper over workflow_semantic_planner
+ return WorkflowSemanticPlanner.looks_like_project_create_request(message)
@staticmethod
- def _looks_like_external_connect_request(message: str) -> bool:
- text = str(message or "").strip()
- if not text:
- return False
- return bool(CREATE_ACTION_RE.search(text) and EXTERNAL_INTEGRATION_RE.search(text))
+ def _looks_like_external_connect_request(message: str) -> bool: # semantic-lint: ignore - compat wrapper over workflow_semantic_planner
+ return WorkflowSemanticPlanner.looks_like_external_connect_request(message)
@staticmethod
def _can_execute_high_value_action(user: Optional[User]) -> bool:
@@ -169,4 +157,3 @@ class FeishuPipelineCoreBaseMixin:
if signal.matched_project_id is not None:
result["project_id"] = int(signal.matched_project_id)
return result
-
diff --git a/backend/app/services/integrations/feishu_signal_pipeline/mixin_followup_a.py b/backend/app/services/integrations/feishu_signal_pipeline/mixin_followup_a.py
index 65cb07f0..fc8f427d 100644
--- a/backend/app/services/integrations/feishu_signal_pipeline/mixin_followup_a.py
+++ b/backend/app/services/integrations/feishu_signal_pipeline/mixin_followup_a.py
@@ -2,6 +2,7 @@
from __future__ import annotations
from .shared import *
+from ...ai_pmo.workflow_semantic_planner import WorkflowSemanticPlanner
from app.utils.thread_context import extract_thread_anchor
@@ -20,7 +21,7 @@ class FeishuPipelineFollowupAMixin:
if not source_chat_id:
return None
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:
return None
diff --git a/backend/app/services/integrations/feishu_signal_pipeline/mixin_gate_dispatch.py b/backend/app/services/integrations/feishu_signal_pipeline/mixin_gate_dispatch.py
index 22fa3a50..ed78ffe0 100644
--- a/backend/app/services/integrations/feishu_signal_pipeline/mixin_gate_dispatch.py
+++ b/backend/app/services/integrations/feishu_signal_pipeline/mixin_gate_dispatch.py
@@ -176,7 +176,10 @@ class FeishuPipelineGateDispatchMixin:
asked_via="feishu",
project_id=None,
source_chat_id=source_chat_id,
+ source_message_id=feishu_message_id,
conversation_key=conversation_key,
+ source_sender_open_id=source_sender_open_id,
+ thread_context=thread_context,
)
qa_capture = await self._build_direct_qa_capture(
message=message,
diff --git a/backend/app/services/integrations/feishu_signal_pipeline/mixin_intent_gate_core.py b/backend/app/services/integrations/feishu_signal_pipeline/mixin_intent_gate_core.py
index f61fb9cd..46ea2629 100644
--- a/backend/app/services/integrations/feishu_signal_pipeline/mixin_intent_gate_core.py
+++ b/backend/app/services/integrations/feishu_signal_pipeline/mixin_intent_gate_core.py
@@ -257,7 +257,7 @@ class FeishuPipelineIntentGateCoreMixin:
}
@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()
if not normalized:
return False
diff --git a/backend/app/services/integrations/feishu_signal_pipeline/mixin_lookup_and_block.py b/backend/app/services/integrations/feishu_signal_pipeline/mixin_lookup_and_block.py
index 6c3a06e4..879649fc 100644
--- a/backend/app/services/integrations/feishu_signal_pipeline/mixin_lookup_and_block.py
+++ b/backend/app/services/integrations/feishu_signal_pipeline/mixin_lookup_and_block.py
@@ -2,39 +2,21 @@
from __future__ import annotations
from .shared import *
+from ...ai_pmo.workflow_semantic_planner import WorkflowSemanticPlanner
class FeishuPipelineLookupAndBlockMixin:
@staticmethod
- def _looks_like_project_create_command(message: str) -> bool:
- return bool(LEGACY_COMMAND_PROJECT_CREATE_RE.search(str(message or "")))
+ def _looks_like_project_create_command(message: str) -> bool: # semantic-lint: ignore - compat wrapper over workflow_semantic_planner
+ return WorkflowSemanticPlanner.looks_like_project_create_request(message)
@classmethod
- def _looks_like_project_lookup_question(cls, message: str) -> bool:
- text = str(message or "").strip()
- 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 ("有吗", "有没有", "有无", "是否有", "是否存在", "存在吗", "是什么", "哪些"))
+ def _looks_like_project_lookup_question(cls, message: str) -> bool: # semantic-lint: ignore - compat wrapper over workflow_semantic_planner
+ return WorkflowSemanticPlanner.looks_like_project_lookup_question(message)
@classmethod
def _is_legacy_business_command(cls, message: str) -> bool:
- text = str(message or "").strip()
- 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))
+ return WorkflowSemanticPlanner.is_legacy_business_command(message)
async def _maybe_block_legacy_command_request(
self,
@@ -89,7 +71,7 @@ class FeishuPipelineLookupAndBlockMixin:
reply_text = self._format_project_lookup_hit_rows(projects[:8], prefix="当前项目")
linked_project_ids = [int(item["id"]) for item in projects[:8] if _safe_int(item.get("id"))]
else:
- keyword = self._extract_project_lookup_keyword(message)
+ keyword = WorkflowSemanticPlanner.extract_project_lookup_keyword(message)
matched_rows = self._match_projects_by_keyword(projects=projects, keyword=keyword)
if matched_rows:
linked_project_ids = [int(item["id"]) for item in matched_rows if _safe_int(item.get("id"))]
@@ -177,21 +159,6 @@ class FeishuPipelineLookupAndBlockMixin:
"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
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()
@@ -235,4 +202,3 @@ class FeishuPipelineLookupAndBlockMixin:
if "长连接" in normalized and any(token in normalized for token in ("已通", "都已通", "正常", "在线", "已恢复")):
return True
return False
-
diff --git a/backend/app/services/integrations/feishu_signal_pipeline/mixin_message_utils_a.py b/backend/app/services/integrations/feishu_signal_pipeline/mixin_message_utils_a.py
index 667c200b..9dd66471 100644
--- a/backend/app/services/integrations/feishu_signal_pipeline/mixin_message_utils_a.py
+++ b/backend/app/services/integrations/feishu_signal_pipeline/mixin_message_utils_a.py
@@ -2,7 +2,7 @@
from __future__ import annotations
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:
@@ -79,12 +79,10 @@ class FeishuPipelineMessageUtilsAMixin:
source_message_id: Optional[str] = None,
thread_context: Optional[dict[str, Any]] = None,
) -> str:
- return build_feishu_thread_key(
+ return build_feishu_conversation_scope_key(
source_chat_id=source_chat_id,
source_sender_open_id=source_sender_open_id,
sender=sender,
- source_message_id=source_message_id,
- thread_context=thread_context,
)
@staticmethod
diff --git a/backend/app/services/integrations/feishu_signal_pipeline/mixin_process_message_default.py b/backend/app/services/integrations/feishu_signal_pipeline/mixin_process_message_default.py
index ce933a49..1a5b69ac 100644
--- a/backend/app/services/integrations/feishu_signal_pipeline/mixin_process_message_default.py
+++ b/backend/app/services/integrations/feishu_signal_pipeline/mixin_process_message_default.py
@@ -59,7 +59,10 @@ class FeishuPipelineProcessMessageDefaultMixin:
asked_via="feishu",
project_id=None,
source_chat_id=source_chat_id,
+ source_message_id=feishu_message_id,
conversation_key=conversation_key,
+ source_sender_open_id=source_sender_open_id,
+ thread_context=thread_context,
message_intent="mixed",
mixed_signal_part=mixed_signal_part,
)
@@ -74,7 +77,10 @@ class FeishuPipelineProcessMessageDefaultMixin:
asked_via="feishu",
project_id=None,
source_chat_id=source_chat_id,
+ source_message_id=feishu_message_id,
conversation_key=conversation_key,
+ source_sender_open_id=source_sender_open_id,
+ thread_context=thread_context,
)
return await self._finalize_direct_qa_capture_result(
message=message,
@@ -108,7 +114,10 @@ class FeishuPipelineProcessMessageDefaultMixin:
asked_via="feishu",
project_id=None,
source_chat_id=source_chat_id,
+ source_message_id=feishu_message_id,
conversation_key=conversation_key,
+ source_sender_open_id=source_sender_open_id,
+ thread_context=thread_context,
message_intent="mixed",
mixed_signal_part=mixed_signal_part,
)
@@ -123,7 +132,10 @@ class FeishuPipelineProcessMessageDefaultMixin:
asked_via="feishu",
project_id=None,
source_chat_id=source_chat_id,
+ source_message_id=feishu_message_id,
conversation_key=conversation_key,
+ source_sender_open_id=source_sender_open_id,
+ thread_context=thread_context,
)
return await self._finalize_direct_qa_capture_result(
message=message,
@@ -150,7 +162,10 @@ class FeishuPipelineProcessMessageDefaultMixin:
asked_via="feishu",
project_id=None,
source_chat_id=source_chat_id,
+ source_message_id=feishu_message_id,
conversation_key=conversation_key,
+ source_sender_open_id=source_sender_open_id,
+ thread_context=thread_context,
message_intent="mixed",
mixed_signal_part=mixed_signal_part,
)
diff --git a/backend/app/services/integrations/feishu_signal_pipeline/mixin_process_message_helpers_a.py b/backend/app/services/integrations/feishu_signal_pipeline/mixin_process_message_helpers_a.py
index 94c25775..270593ca 100644
--- a/backend/app/services/integrations/feishu_signal_pipeline/mixin_process_message_helpers_a.py
+++ b/backend/app/services/integrations/feishu_signal_pipeline/mixin_process_message_helpers_a.py
@@ -2,6 +2,7 @@
from __future__ import annotations
from .shared import *
+from ...ai_pmo.workflow_semantic_planner import WorkflowSemanticPlanner
class FeishuPipelineProcessMessageHelpersAMixin:
@@ -176,29 +177,9 @@ class FeishuPipelineProcessMessageHelpersAMixin:
return text[:2000]
@classmethod
- def _is_obvious_question(cls, message: str) -> bool:
- text = str(message or "").strip()
- 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))
+ def _is_obvious_question(cls, message: str) -> bool: # semantic-lint: ignore - compat wrapper over workflow_semantic_planner
+ return WorkflowSemanticPlanner.is_obvious_question(message)
@classmethod
- def _is_query_like_question(cls, message: str) -> bool:
- text = str(message or "").strip()
- 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
-
+ def _is_query_like_question(cls, message: str) -> bool: # semantic-lint: ignore - compat wrapper over workflow_semantic_planner
+ return WorkflowSemanticPlanner.is_query_like_question(message)
diff --git a/backend/app/services/integrations/feishu_signal_pipeline/mixin_process_message_helpers_b.py b/backend/app/services/integrations/feishu_signal_pipeline/mixin_process_message_helpers_b.py
index 714b2ca9..584d9ce0 100644
--- a/backend/app/services/integrations/feishu_signal_pipeline/mixin_process_message_helpers_b.py
+++ b/backend/app/services/integrations/feishu_signal_pipeline/mixin_process_message_helpers_b.py
@@ -190,9 +190,23 @@ class FeishuPipelineProcessMessageHelpersBMixin:
"ai_trace_id": f"feishu-qa-{(feishu_message_id or 'noid')[:32]}",
"results": [],
"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_text": self._build_rag_reply_text(
- answer=str(rag_result.get("answer") or ""),
- sources=rag_result.get("sources") or [],
+ "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 ""),
+ sources=rag_result.get("sources") or [],
+ )
),
}
diff --git a/backend/app/services/llm_service/mixin_prompt_profile_runtime.py b/backend/app/services/llm_service/mixin_prompt_profile_runtime.py
index 2f3237ad..c9c45fe2 100644
--- a/backend/app/services/llm_service/mixin_prompt_profile_runtime.py
+++ b/backend/app/services/llm_service/mixin_prompt_profile_runtime.py
@@ -169,6 +169,15 @@ class LLMPromptProfileRuntimeMixin:
"routing_config_version": meta.get("routing_config_version") 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:
for attempt in range(2):
try:
diff --git a/backend/app/services/prompt_release/__init__.py b/backend/app/services/prompt_release/__init__.py
new file mode 100644
index 00000000..78246758
--- /dev/null
+++ b/backend/app/services/prompt_release/__init__.py
@@ -0,0 +1,3 @@
+from .service import PromptReleaseService
+
+__all__ = ["PromptReleaseService"]
diff --git a/backend/app/services/prompt_release/service.py b/backend/app/services/prompt_release/service.py
new file mode 100644
index 00000000..d3b03f7e
--- /dev/null
+++ b/backend/app/services/prompt_release/service.py
@@ -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()
diff --git a/backend/app/services/reply_iteration/__init__.py b/backend/app/services/reply_iteration/__init__.py
new file mode 100644
index 00000000..8aacde78
--- /dev/null
+++ b/backend/app/services/reply_iteration/__init__.py
@@ -0,0 +1,3 @@
+from .service import ReplyIterationService
+
+__all__ = ["ReplyIterationService"]
diff --git a/backend/app/services/reply_iteration/service.py b/backend/app/services/reply_iteration/service.py
new file mode 100644
index 00000000..c9b23050
--- /dev/null
+++ b/backend/app/services/reply_iteration/service.py
@@ -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)
diff --git a/backend/app/services/signal/signal_capture_service/mixin_capture_entry.py b/backend/app/services/signal/signal_capture_service/mixin_capture_entry.py
index 93b3cf82..00fd0fcc 100644
--- a/backend/app/services/signal/signal_capture_service/mixin_capture_entry.py
+++ b/backend/app/services/signal/signal_capture_service/mixin_capture_entry.py
@@ -1,7 +1,8 @@
from __future__ import annotations
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:
def __init__(self, db: AsyncSession):
@@ -18,10 +19,15 @@ class SignalCaptureEntryMixin:
if not clean_text:
return False, "empty"
- resolved_intent = IntentResolver.resolve(text=clean_text, project_id=None)
- resolved_name = str(resolved_intent.name or "").strip().lower()
- if resolved_name in {INTENT_QUERY, INTENT_IGNORE}:
- return True, f"intent:{resolved_name}"
+ bundle = WorkflowSemanticPlanner.plan_message(
+ message_text=clean_text,
+ source_system="feishu",
+ )
+ 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):
return True, "question_like"
return False, "async_queue"
diff --git a/backend/app/services/signal/signal_capture_service/mixin_pipeline_cache.py b/backend/app/services/signal/signal_capture_service/mixin_pipeline_cache.py
index 7a4b4cef..af1526ba 100644
--- a/backend/app/services/signal/signal_capture_service/mixin_pipeline_cache.py
+++ b/backend/app/services/signal/signal_capture_service/mixin_pipeline_cache.py
@@ -1,6 +1,7 @@
from __future__ import annotations
from .shared import *
+from ...ai_pmo.workflow_semantic_planner import WorkflowSemanticPlanner
class SignalCapturePipelineCacheMixin:
async def _sync_feishu_recent_cache(
@@ -110,7 +111,7 @@ class SignalCapturePipelineCacheMixin:
return pipeline_result
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:
source_orchestration_result = await SignalSourceOrchestrationService(self.db).orchestrate_from_signal(
tenant_id=tenant_id,
diff --git a/backend/app/services/signal/signal_source_orchestration/mixin_entry_orchestrate.py b/backend/app/services/signal/signal_source_orchestration/mixin_entry_orchestrate.py
index 7fb9adff..55c196c0 100644
--- a/backend/app/services/signal/signal_source_orchestration/mixin_entry_orchestrate.py
+++ b/backend/app/services/signal/signal_source_orchestration/mixin_entry_orchestrate.py
@@ -1,22 +1,15 @@
from __future__ import annotations
from .shared import *
+from ...ai_pmo.workflow_semantic_planner import WorkflowSemanticPlanner
class SignalSourceEntryOrchestrateMixin:
def __init__(self, db: AsyncSession) -> None:
self.db = db
@classmethod
- def looks_like_signal_source_request(cls, text: str) -> bool:
- normalized = re.sub(r"\s+", "", str(text or ""))
- 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))
+ def looks_like_signal_source_request(cls, text: str) -> bool: # semantic-lint: ignore - compat wrapper over workflow_semantic_planner
+ return WorkflowSemanticPlanner.looks_like_signal_source_request(text)
async def orchestrate_from_signal(
self,
@@ -202,4 +195,3 @@ class SignalSourceEntryOrchestrateMixin:
base_result=base_result,
run_service=run_service,
)
-
diff --git a/backend/app/services/signal/signal_source_orchestration/shared.py b/backend/app/services/signal/signal_source_orchestration/shared.py
index e634a082..4af989f4 100644
--- a/backend/app/services/signal/signal_source_orchestration/shared.py
+++ b/backend/app/services/signal/signal_source_orchestration/shared.py
@@ -33,10 +33,8 @@ from ...model.prompt_profile import prompt_profile_service
from ...llm_service import llm_service
from ....utils.timezone import now_cn_naive
-SOURCE_ONBOARDING_ACTION_RE = re.compile(
- r"(新增|增加|添加|接入|配置|注册|建立|加个|加一个|加).{0,8}(信号源|来源|监控源|channel|source|webhook|飞书|github|url|rss)",
- re.IGNORECASE,
-)
+from ...ai_pmo.workflow_semantic_planner.constants import SOURCE_ONBOARDING_ACTION_RE
+
SOURCE_KIND_HINTS: tuple[tuple[str, tuple[str, ...]], ...] = (
("feishu", ("飞书", "lark", "群聊", "chat")),
("github", ("github", "repo", "仓库", "pull request", "issue")),
diff --git a/backend/app/services/training_pipeline/__init__.py b/backend/app/services/training_pipeline/__init__.py
new file mode 100644
index 00000000..bee99f6c
--- /dev/null
+++ b/backend/app/services/training_pipeline/__init__.py
@@ -0,0 +1,3 @@
+from .service import TrainingPipelineService
+
+__all__ = ["TrainingPipelineService"]
diff --git a/backend/app/services/training_pipeline/service.py b/backend/app/services/training_pipeline/service.py
new file mode 100644
index 00000000..b4007d82
--- /dev/null
+++ b/backend/app/services/training_pipeline/service.py
@@ -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),
+ }
diff --git a/backend/app/utils/thread_context.py b/backend/app/utils/thread_context.py
index e8ab8d32..33f06035 100644
--- a/backend/app/utils/thread_context.py
+++ b/backend/app/utils/thread_context.py
@@ -44,3 +44,21 @@ def build_feishu_thread_key(
return f"feishu:{chat}:{actor}" if chat else f"feishu:{actor}"
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 ""
diff --git a/backend/tests/test_adaptive_rag_external_followup_contract.py b/backend/tests/test_adaptive_rag_external_followup_contract.py
new file mode 100644
index 00000000..94aafb64
--- /dev/null
+++ b/backend/tests/test_adaptive_rag_external_followup_contract.py
@@ -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='林宇航 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()
diff --git a/backend/tests/test_ai_pmo_ingress_mainline.py b/backend/tests/test_ai_pmo_ingress_mainline.py
index 41285880..1c8b34bc 100644
--- a/backend/tests/test_ai_pmo_ingress_mainline.py
+++ b/backend/tests/test_ai_pmo_ingress_mainline.py
@@ -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 mixin_tier_escalation
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
@@ -128,6 +128,8 @@ async def test_prepare_mixed_sidecar_context_passes_mixed_contract(monkeypatch)
source_system="pmflow_manual",
source_message_id="manual-msg-2",
channel_ref="pmflow-chat",
+ source_sender_open_id="ou-pmflow-1",
+ thread_context={"reply_to_message_id": "reply-1"},
message_text="顺便问下现在进展如何,并且新增任务确认流",
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["project_id"] is None
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["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)
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(
intent=intent,
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_via"] == "feishu"
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_sender_open_id=None,
+ source_sender_open_id="ou-feishu-99",
sender=None,
- source_message_id="msg-feishu-1",
- thread_context={"root_message_id": "root-1"},
)
diff --git a/backend/tests/test_ai_pmo_task_intent_contract.py b/backend/tests/test_ai_pmo_task_intent_contract.py
index b72945a0..08eb396f 100644
--- a/backend/tests/test_ai_pmo_task_intent_contract.py
+++ b/backend/tests/test_ai_pmo_task_intent_contract.py
@@ -6,6 +6,7 @@ from app.services.ai_pmo.ai_pmo_intent import (
INTENT_TASK_CREATE,
ROUTE_ACTION_ANSWER,
ROUTE_ACTION_CANCEL,
+ ROUTE_ACTION_CONTINUE,
ROUTE_ACTION_CREATE,
ROUTE_FAMILY_QUERY,
ROUTE_FAMILY_TASK,
@@ -72,11 +73,20 @@ def test_route_resolver_builds_query_answer_route() -> None:
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(
canonical_intent=INTENT_FOLLOWUP_CONTINUE,
message_text="先取消这个工作项",
)
+ assert action == ROUTE_ACTION_CONTINUE
+
+
+def test_route_resolver_followup_action_allows_message_parsing_with_context() -> None:
+ action = RouteResolver.parse_followup_action(
+ canonical_intent=INTENT_FOLLOWUP_CONTINUE,
+ message_text="先取消这个工作项",
+ metadata={"followup_context_present": True, "target_work_item_id": 8},
+ )
assert action == ROUTE_ACTION_CANCEL
diff --git a/backend/tests/test_draft_confirm_handler.py b/backend/tests/test_draft_confirm_handler.py
index be8b526e..a0341e86 100644
--- a/backend/tests/test_draft_confirm_handler.py
+++ b/backend/tests/test_draft_confirm_handler.py
@@ -115,6 +115,54 @@ def test_feishu_reply_llm_core_strips_action_process_prefaces() -> None:
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
async def test_draft_confirm_handler_repeated_confirm_returns_inbox_success_copy() -> None:
class _FakeResult:
diff --git a/backend/tests/test_external_qa_followup_migration.py b/backend/tests/test_external_qa_followup_migration.py
new file mode 100644
index 00000000..c3ef0dca
--- /dev/null
+++ b/backend/tests/test_external_qa_followup_migration.py
@@ -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
diff --git a/backend/tests/test_feishu_pipeline_queue_contract.py b/backend/tests/test_feishu_pipeline_queue_contract.py
index af35af1f..186f566b 100644
--- a/backend/tests/test_feishu_pipeline_queue_contract.py
+++ b/backend/tests/test_feishu_pipeline_queue_contract.py
@@ -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 (
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 (
FeishuPipelineProcessMessageHelpersBMixin,
)
@@ -212,6 +215,56 @@ def test_decorate_capture_result_adds_queue_action_card_metadata() -> None:
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
async def test_intent_gate_query_does_not_inherit_project_context(monkeypatch) -> None:
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 result["linked_project_ids"] == []
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
diff --git a/backend/tests/test_feishu_receive_orchestration_contract.py b/backend/tests/test_feishu_receive_orchestration_contract.py
index a1535db5..29f444d5 100644
--- a/backend/tests/test_feishu_receive_orchestration_contract.py
+++ b/backend/tests/test_feishu_receive_orchestration_contract.py
@@ -58,7 +58,7 @@ async def test_handle_message_receive_routes_empty_text_branch(monkeypatch) -> N
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(
capture_result={
"status": "answered",
@@ -68,7 +68,7 @@ def test_classify_message_scene_prefers_execute_for_action_like_raw_text() -> No
message_content="帮我创建项目和信号源",
)
- assert scene == "execute"
+ assert scene == "qa"
@pytest.mark.asyncio
diff --git a/backend/tests/test_qa_record_followup_anchor_migration.py b/backend/tests/test_qa_record_followup_anchor_migration.py
new file mode 100644
index 00000000..ee7f7f59
--- /dev/null
+++ b/backend/tests/test_qa_record_followup_anchor_migration.py
@@ -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
diff --git a/backend/tests/test_reply_iteration_api.py b/backend/tests/test_reply_iteration_api.py
new file mode 100644
index 00000000..03792b21
--- /dev/null
+++ b/backend/tests/test_reply_iteration_api.py
@@ -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"
diff --git a/backend/tests/test_reply_iteration_service.py b/backend/tests/test_reply_iteration_service.py
new file mode 100644
index 00000000..bceb58b6
--- /dev/null
+++ b/backend/tests/test_reply_iteration_service.py
@@ -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"
diff --git a/backend/tests/test_reply_iteration_training_migration.py b/backend/tests/test_reply_iteration_training_migration.py
new file mode 100644
index 00000000..0bd16cce
--- /dev/null
+++ b/backend/tests/test_reply_iteration_training_migration.py
@@ -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
diff --git a/backend/tests/test_semantic_lint.py b/backend/tests/test_semantic_lint.py
new file mode 100644
index 00000000..a21e48de
--- /dev/null
+++ b/backend/tests/test_semantic_lint.py
@@ -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 == []
diff --git a/backend/tests/test_training_pipeline_service.py b/backend/tests/test_training_pipeline_service.py
new file mode 100644
index 00000000..14aa4e5e
--- /dev/null
+++ b/backend/tests/test_training_pipeline_service.py
@@ -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"
diff --git a/backend/tests/test_workflow_semantic_planner.py b/backend/tests/test_workflow_semantic_planner.py
new file mode 100644
index 00000000..0d7c55f9
--- /dev/null
+++ b/backend/tests/test_workflow_semantic_planner.py
@@ -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
diff --git a/scripts/semantic_lint.py b/scripts/semantic_lint.py
new file mode 100644
index 00000000..5659b49d
--- /dev/null
+++ b/scripts/semantic_lint.py
@@ -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())
diff --git a/scripts/semantic_lint_baseline.txt b/scripts/semantic_lint_baseline.txt
new file mode 100644
index 00000000..c6b78219
--- /dev/null
+++ b/scripts/semantic_lint_baseline.txt
@@ -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
diff --git a/task_instance_20260326.xlsx b/task_instance_20260326.xlsx
new file mode 100644
index 00000000..3a809b71
Binary files /dev/null and b/task_instance_20260326.xlsx differ
diff --git a/task_instance_20260327_today.xlsx b/task_instance_20260327_today.xlsx
new file mode 100644
index 00000000..02bce95d
Binary files /dev/null and b/task_instance_20260327_today.xlsx differ