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