# 负反馈闭环(Feedback → Experience)技术方案 > 状态:已确认(待实施) > 日期:2026-10-11 > 上游提案:[docs/proposals/2026-10-11-feedback-experience-loop.md](../proposals/2026-10-11-feedback-experience-loop.md) > 覆盖端:管理后台 admin、后端 backend-ts、桌面端 desktop(无改动,见 §2.3) --- ## 1. 需求背景 点踩反馈的采集侧已两轮落地:桌面端每条助手回复可点踩并选原因(`message_feedback` 表,V123,原因枚举 `WRONG_RESULT` / `SLOW_RESPONSE` / `NOT_SOLVED` / `OTHER` / `NO_REASON`),飞书进度卡可点踩(V125 加 `source` 列,`toggleSessionDislike` 固定写 `NO_REASON`),管理后台有汇总统计与明细页(`feedback:read` 权限)。 **采集之后没有任何事情发生**:反馈只进表供管理员看,Agent 下次执行时提示词照旧,同一个错误反复重演。而 Agent 已有「最佳实践经验」载体(`agent_experiences`,注入 system prompt,见 [docs/plan/2026-07-09-agent-experience-design.md](2026-07-09-agent-experience-design.md)),该表今天只能由管理员在后台手工逐条录入。 本需求把「用户点踩」到「Agent 学到教训」之间的链路补上:LLM 基于点踩上下文起草经验,管理员编辑确认后写入 `agent_experiences`,反馈行标记处理态。 ### 1.1 现状代码事实(实施前已核对) | 事项 | 现状 | 本方案的用法 | |---|---|---| | `message_feedback` 列 | `id / message_id / session_id / user_id / agent_id / reason / source / created_at / updated_at`,`uk_message(message_id)` 唯一 | 加 4 列处理态,不改既有列 | | `AgentExperienceService` | `MAX_CONTENT_LENGTH = 300` 硬校验 + `normalizeAndValidateContent()` + `create()` | 复校验逻辑;写入改走事务内轻量仓储(见 §5.2) | | 敏感路径规则 | `isSensitive(path)` 导出于 `file/git-write-operation.service.ts`,已有单测(`file.service.spec.ts:318` 起) | 直接 import 复用,**不新增正则** | | 运行轨迹读模型 | `RunTraceService.buildTrace(sessionId, {beforeRunId, limit, ...})` 按页取 run,无按 messageId 定位入口 | 新增 `locateRunByMessageId()`(见 §5.3) | | 活动表 | `session_activity` 有 `target`(≤2048) / `summary`(≤512) / `detail_json` / `status` / `duration_ms`;`selectBySessionAll(sessionId)` 全量可取 | 由 run-trace 间接使用,本方案不直接查 | | LLM 后台调用范式 | `GitCommitMessageService`:读系统设置模型 ID → 缺省回落 `resolveModel(null)`;`LlmCallContext.runAsync({scene, ...})` 包调用;`usageService.record(...)` 落用量 | 完全照搬该范式(见 §5.4) | | `LLM_CALL_SCENES` | 已有 `GIT_COMMIT_MESSAGE` / `PROXY_APPROVE` / `MEMORY_EXTRACT` 等 | 新增 `FEEDBACK_EXPERIENCE_DRAFT` | | 权限码注册范式 | V121 目录模式:`INSERT INTO permission` + `INSERT IGNORE INTO role_permission SELECT 1, id FROM permission WHERE code=?`(role_id=1 系统管理员) | V149 照此注册 `feedback:write` | | 管理后台反馈页 | `admin/src/views/feedback/FeedbackView.vue`,汇总卡 + 明细表 + 原因/时间筛选 + 分页,移动端卡片态 | 在其上增强,不新建页面 | | `Db.transaction(fn)` | 把连接包成新 `Db` 传入回调 | 事务内新建两个轻量仓储(见 §5.2) | ### 1.2 上游提案的修正(本方案对提案的实质变更) 1. **删除「用户侧可见的回应」**(提案目标 4):桌面端点踩后不加 toast,飞书卡片结构不改。点踩按钮自身的图标高亮已是充分反馈。理由:该提示对闭环无实质帮助,且需改前端提交面。 2. **轨迹原料来源变更**:提案写「复用 `recordActivity` 落库的工具名/目标/成败」,实施前核对发现 `ActivityService.listBySession` 默认 `limit=50` 且按 `id DESC` 取**最新** 50 条——用户时隔多日才点踩时取到的不是出事轮次。改用运行轨迹读模型并新增按 messageId 定位方法(§5.3)。 3. **脱敏范围收缩**:提案写「命中时目标字段摘要为 `[sensitive]`」,明确为**仅路径类敏感规则**(`isSensitive`),不做 shell 命令内容的正则脱敏。理由:与 git 提交信息口径完全一致、零新增正则;已知边界记录在 §9 风险。 4. **聚集判定口径**:按 `message_feedback.agent_id + reason` **快照口径**分组(不 JOIN session 取实时 Agent)。已知局限:会话更换 Agent 后同类反馈会被劈成两组,接受。 5. **写入目标 Agent**:由管理员在转经验向导里显式选择,默认预选该行 `agent_id`。提案隐含「写该 Agent」,本方案改为显式选择,以覆盖 Agent 已删 / 快照过期 / 教训归属另一 Agent 三种情况。 6. **P3 的「汇总页待处理矩阵」上移到 P2**:矩阵是转经验决策的直接依据,与状态筛选同期交付更有价值。 --- ## 2. 需求描述 ### 2.1 要做的 1. **反馈处理态**:`message_feedback` 新增 `status`(`PENDING` / `CONVERTED` / `DISMISSED`)、`handled_by`、`handled_at`、`experience_id` 四列。`PENDING` 是唯一入口态,`CONVERTED` 与 `DISMISSED` **均为终态,不可互相流转、不可回退**。 2. **状态可见与可筛**:管理后台明细表新增「状态」列与状态下拉筛选;汇总页新增「待处理反馈数」卡片与「Agent × 原因」待处理矩阵。 3. **同类聚集提示**:明细列表按 `agent_id + reason` 分组计数,行内展示「同类 N 条」;勾选同组多条后一次性进入转经验流程。 4. **经验起草**:选中 N 条反馈(N≥1)点「转为经验」→ 后端取「点踩原因 + 用户原话 + 被点踩的助手消息 + 该消息所在 run 的工具轨迹」调 LLM 起草 → 返回 1~2 条 ≤300 字中文经验草稿。**N=1 走单条 prompt,N≥2 走归纳 prompt**,由服务端按选中数量自动切换,前端无感。 5. **人工确认写入**:向导内可编辑草稿文字、增删条目、选择目标 Agent(默认预选)、确认写入。写入与反馈状态回写**同一事务**;成功后反馈行变 `CONVERTED` 并记录 `experience_id` 与处理人。 6. **忽略**:单选与批量忽略,二次确认,置 `DISMISSED` 终态并记录处理人。 7. **权限**:新增 `feedback:write` 权限码,管控草稿生成、确认写入、忽略三个动作;沿用 `feedback:read` 管控查询。默认均只授系统管理员角色(`role_id=1`)。 8. **可配置起草模型**:新增系统设置项 `feedback.draftModelId`,渲染于「系统设置 → Agent 与模型」分组的模型下拉,留空回落默认模型。 9. **脱敏**:轨迹 target 中命中 `isSensitive()` 的路径替换为 `[sensitive]`;不取工具结果全文;不用命令内容正则脱敏。 ### 2.2 不做的 | 项 | 说明 | |---|---| | 桌面端 / 飞书点踩后的用户提示 | 图标高亮即反馈,不加 toast、不改卡片结构 | | `DISMISSED` 恢复 | 终态,误判只能重新走流程 | | 经验效果回溯 | 「转经验后同类反馈是否下降」属 Evals 范畴(已否决,不重启) | | 全自动写入 | 不经人工确认直接写 `agent_experiences` 的路径不存在 | | 草稿持久化 | 不建 `feedback_draft` 表、不用 sessionStorage;草稿只活在浏览器内存,刷新/误关即弃,重新生成 | | 转经验入口按原因/来源过滤 | 全部 5 种原因、两种来源都允许进入流程,由模型判断价值 | | 跨用户团队级反馈聚合 | 个人点踩数据按归属隔离,管理侧本就是全量视角 | | `agent_experiences` 表结构与注入逻辑改造 | 只复用其写入通道与 300 字校验 | | 经验条数硬上限 | 沿用 2026-07-09 方案记录:本期不设上限,靠向导内「该 Agent 现有 N 条经验」提示抑制堆量 | | 聚集阈值的可配置化 | 固定按选中数量切换,不做阈值设置 | | 并发预占 | 不做进入向导即锁定,仅在最终写入时 CAS | | CLI 入口 | 本期只做管理后台;`mao-cli` 不加 feedback 转换命令 | --- ## 3. 技术选型 | 决策点 | 选型 | 理由 | |---|---|---| | 处理态存储 | `message_feedback` 加 4 列,不建新表 | 与消息一对一,无历史查询需求;避免新表与清理逻辑 | | 草稿存储 | 纯后端一次性返回,前端内存持有 | 生成即评审,不落库;无僵死数据、无 TTL 清理 | | 轨迹原料 | `RunTraceService` 新增 `locateRunByMessageId` | 复用已上线的归属口径(tool_call_id 挂轮、秒级时钟放宽、未挂到轮),不在 feedback 域重写 | | LLM 调用范式 | 照搬 `GitCommitMessageService` | 设置读模型 → 回落默认、`LlmCallContext` 包 scene、`usageService.record` 落用量、一次纠错重试,四处已有先例 | | 敏感规则 | 直接 import `isSensitive` | 已有实现与单测,与 git 提交信息同口径 | | 事务实现 | `db.transaction()` 内新建轻量仓储 | `Db.transaction` 把连接包成新 `Db` 传回调;不能把全局单例 service 塞进事务 | | 300 字校验 | 抽出共享纯函数供两处复用 | `AgentExperienceService` 与 feedback 事务内写入必须同口径,避免复制漂移 | | 冲突控制 | 最终写入 CAS(`UPDATE ... WHERE status='PENDING'` 影响行数判定) | 零新增状态、无僵死 claim | | 权限码 | `feedback:write`,V121 目录模式注册 | 与 `scheduled-task:write` / `budget:write` / `approval-rule:read` 惯例一致 | | 聚集口径 | `agent_id + reason` 快照口径 | 与明细行展示同源,不 JOIN session 造成 UI 与数据不一致 | --- ## 4. 数据模型 ### 4.1 V148:`message_feedback` 增加处理态 ```sql ALTER TABLE `message_feedback` ADD COLUMN `status` VARCHAR(16) NOT NULL DEFAULT 'PENDING' COMMENT 'PENDING 待处理 / CONVERTED 已转为经验 / DISMISSED 已忽略;后两者为终态' AFTER `source`, ADD COLUMN `handled_by` BIGINT NULL COMMENT '处理人(管理员 user_id)' AFTER `status`, ADD COLUMN `handled_at` DATETIME NULL COMMENT '处理时间' AFTER `handled_by`, ADD COLUMN `experience_id` BIGINT NULL COMMENT 'CONVERTED 时关联的 agent_experiences.id' AFTER `handled_at`; CREATE INDEX `idx_feedback_pending` ON `message_feedback` (`status`, `agent_id`, `reason`, `created_at`); ``` 存量行经列默认值回填为 `PENDING`,无需数据订正(与 V125 `source` 列同款处理)。 ### 4.2 V149:注册 `feedback:write` ```sql INSERT INTO `permission` (`name`, `code`, `description`) SELECT '处理点踩反馈', 'feedback:write', '将点踩反馈转为 Agent 经验或忽略' WHERE NOT EXISTS (SELECT 1 FROM `permission` WHERE `code` = 'feedback:write'); INSERT IGNORE INTO `role_permission` (`role_id`, `permission_id`) SELECT 1, id FROM `permission` WHERE `code` = 'feedback:write'; ``` ### 4.3 新增 scene 常量 `backend-ts/src/usage/llm-call-context.ts` 的 `LLM_CALL_SCENES` 增加 `FEEDBACK_EXPERIENCE_DRAFT: 'feedback_experience_draft'`。同时 `LlmUsageService` 的场景常量需对齐(与 `SCENE_GIT_COMMIT_MESSAGE` 并列)。 --- ## 5. 实现设计 ### 5.1 后端目录与文件 | 文件 | 动作 | 职责 | |---|---|---| | `backend-ts/src/feedback/experience-draft.service.ts` | 新建 | 原料装配、prompt 构建、LLM 调用、解析与一次纠错 | | `backend-ts/src/feedback/feedback.service.ts` | 修改 | 新增 `convertToExperience` / `dismiss` / 待处理矩阵查询;扩 `listDetails` 支持 status/agentId 筛选 | | `backend-ts/src/feedback/feedback.repository.ts` | 修改 | 新增状态列映射、`casMarkConverted` / `markDismissed`、`countPendingGroupBy`、筛选条件扩展 | | `backend-ts/src/feedback/feedback.routes.ts` | 修改 | 新增 4 个管理端点(§5.6) | | `backend-ts/src/agent/agent-experience.service.ts` | 修改 | 抽出 `normalizeAndValidateContent` 为导出纯函数 | | `backend-ts/src/session/run-trace.service.ts` | 修改 | 新增 `locateRunByMessageId`(§5.3) | | `backend-ts/src/settings/settings.service.ts` | 修改 | 新增 `FEEDBACK_DRAFT_MODEL_ID_KEY = 'feedback.draftModelId'` 常量与渲染分类 | | `backend-ts/src/create-app.ts` | 修改 | wiring:装配 `ExperienceDraftService`、事务内仓储、新路由 deps | | `admin/src/views/feedback/FeedbackView.vue` | 修改 | 状态列/筛选、同类聚集、待处理矩阵、转经验向导、忽略 | | `admin/src/api.ts`(或页面内) | 修改 | 新增 4 个端点调用 | ### 5.2 事务与校验复用(关键实现约束) `Db.transaction()` 把连接包成一个新 `Db` 传入回调,因此事务内必须用**该 `Db` 新建的仓储实例**,不能复用全局单例。实现: ```ts // feedback.service.ts async convertToExperience(operatorId: number, input: ConvertInput): Promise<{ experienceId: number }> { // 1. 事务外预校验:反馈存在、status=PENDING、目标 Agent 存在 // (预校验失败直接抛,不进事务,避免无谓开事务) // 2. 事务内:CAS 抢占 → 写经验 → 回写反馈 return this.db.transaction(async (tx) => { const feedbackRepo = new FeedbackRepository(tx); const experienceRepo = new AgentExperienceRepository(tx); const claimed = await feedbackRepo.casMarkConverted(input.feedbackIds, operatorId); if (claimed !== input.feedbackIds.length) { throw new BusinessException(ErrorCode.FEEDBACK_ALREADY_HANDLED); } const ids: number[] = []; for (const item of input.experiences) { const content = normalizeAndValidateContent(item.content); // 共享纯函数,超 300 字抛 AGENT_EXPERIENCE_CONTENT_INVALID ids.push(await experienceRepo.insert({ agentId: input.agentId, content, sortOrder: item.sortOrder ?? 0, enabled: 1 })); } await feedbackRepo.bindExperience(input.feedbackIds, ids[0], operatorId); return { experienceId: ids[0] }; }); } ``` 要点: - **CAS 先行**:`UPDATE message_feedback SET status='CONVERTED', handled_by=?, handled_at=NOW(3) WHERE id IN (...) AND status='PENDING'`,`affectedRows` 与传入数量不等即抛 `FEEDBACK_ALREADY_HANDLED`。CAS 放在事务内第一行,使并发冲突在开事务后立即失败并回滚。 - **多经验与 `experience_id` 的对应关系**:一次转换可能写 1~2 条经验,`experience_id` 只记**第一条**的 id(单值列,不做 JSON)。第二条的追溯通过 `handled_by/at` + 时间邻近性在后台人工判断,不建关联表。 - **共享校验**:把 `AgentExperienceService.normalizeAndValidateContent` 改为导出纯函数 `normalizeExperienceContent(content)`,`AgentExperienceService` 与 feedback 事务内写入都调它,错误码统一为 `AGENT_EXPERIENCE_CONTENT_INVALID`。 ### 5.3 轨迹定位:`RunTraceService.locateRunByMessageId` 现有 `buildTrace` 按 `beforeRunId + limit` 翻页。新增: ```ts async locateRunByMessageId(sessionId: number, messageId: number): Promise ``` 实现复用既有私有逻辑,不重写归属口径: 1. `selectUserStarts(sessionId, null, Number.MAX_SAFE_INTEGER)` 取全部用户消息锚点(与 `selectUserStamps` 同源,可只取 id + createdAt); 2. 二分找到满足 `anchor.id <= messageId < upperId` 的那个锚点;找不到(messageId 不属于本会话 / 是用户消息之前的系统消息)返回 `null`; 3. 以该锚点为 `beforeRunId` 的**上界锚**,按 `buildTrace` 里组装单个 run 的同一段逻辑(消息区间 `selectRange` + LLM 窗口 `selectBySessionWindow` + `selectBySessionAll` 活动 + 压缩事件 + `declByCallId` 挂轮)产出**一个** run 的 VO; 4. 返回的 VO 结构与 `buildTrace` 单页内的 run 元素完全一致,前端可复用同一渲染(本方案前端只用其文本摘要,但结构一致便于后续扩展)。 代价:约 40 行 + 单测。收益:原料精确落在出事轮次,不依赖翻页。 ### 5.4 经验起草服务 `ExperienceDraftService.draft(feedbackIds: number[], agentId: number | null)`: **原料装配**(每个反馈一项): ``` - 点踩原因:reason 中文标签(复用 REASON_LABELS) - 用户原话:该 run 的 anchor(USER 消息)content,截断 1000 字 - 被点踩回复:message_id 对应 ASSISTANT 消息 content,截断 2000 字 - 工具轨迹:locateRunByMessageId 得到的 run,序列化为紧凑文本: round 1: shell_session → "npm run build" (SUCCESS, 3.2s) round 1: read_file → "/repo/.env" (SUCCESS) ← 经 isSensitive 脱敏为 [sensitive] round 2: write_file → "/repo/src/a.ts" (ERROR) (只取 name / target / status / durationMs;不取工具结果正文) ``` **脱敏点**:对每个 tool 的 `target` 调 `isSensitive(target)`,命中则替换为 `[sensitive]`。只做路径匹配,**不做命令内容正则脱敏**(§2.2、§9)。 **prompt**: - system:声明只输出 JSON、产出 1~2 条 ≤300 字中文经验、形如「X 场景下优先 Y,避免 Z」、禁止臆测轨迹未体现的行为、禁止 Markdown 围栏。 - user:N=1 时附「基于以下单次点踩」;N≥2 时附「基于以下 N 条同类点踩归纳更通用的经验」。附该 Agent 现有经验条数(`listByAgentId(agentId).length`),提示模型避免与既有经验重复。 - 输出 schema:`{ "experiences": [{ "content": string }] }`,条数钳制 1~2。 **调用与纠错**(照搬 `GitCommitMessageService`): - `resolveDraftModel()`:读 `feedback.draftModelId`,非法/不存在回落 `resolveModel(null)`;都无则抛 `MODEL_NOT_FOUND`。 - 包 `LlmCallContext.runAsync({ scene: FEEDBACK_EXPERIENCE_DRAFT, userId: operatorId, sessionId: null, agentId })`。 - 60s 超时(`withTimeout` + `cancelFlag`),失败分类:`LLM_TIMEOUT` / `LLM_CALL_FAILED`。 - 解析失败或条数/字数不符 → 带错误信息重试一次 → 仍失败抛 `LLM_CALL_FAILED`,**不降级为模板文案**。 - `finally` 里 `usageService.record(operatorId, null, model.id, SCENE_FEEDBACK_EXPERIENCE_DRAFT, usage, success)`。注意 userId 用操作的管理员(不是点踩用户),因为这是管理动作。 - **实施修正(2026-10-15)**:`llm_usage.session_id` 建表时为 `NOT NULL`,上面这行 `record(..., null, ...)` 抛 `Column 'session_id' cannot be null`;异常发生在 `finally`,会顶掉真实业务异常并以 500「服务内部错误」暴露(`/feedback/admin/draft` 因此必然失败)。已由 V154 迁移把该列改为可空。另,`create-app.ts` 装配此前误传 `routeChatClient`(按协议路由的**工厂函数**,无 `chat` 方法),LLM 调用在发出请求前即 `TypeError` 失败;现改为 `llmAdapter`,并把服务的客户端依赖类型收敛为 `Pick`,让编译器兜住同类误配。 ### 5.5 忽略 `dismiss(operatorId, feedbackIds)`:`UPDATE ... SET status='DISMISSED', handled_by=?, handled_at=NOW(3) WHERE id IN (...) AND status='PENDING'`。影响行数小于传入数量时抛 `FEEDBACK_ALREADY_HANDLED`(与 convert 同码,前端统一提示「部分反馈已被处理,请刷新」)。批量场景下**不要求全部成功**的口径需评审确认——本方案采取**全或无一视同仁**,失败即整体不写,避免批量里混入已处理行时状态不一致。 ### 5.6 API 统一 `Result`,均需登录 + `feedback:write`(查询类沿用 `feedback:read`)。 | 方法 | 路径 | 权限 | 说明 | |---|---|---|---| | GET | `/v1/feedback/admin/list` | `feedback:read` | **扩展**:新增 `status`、`agentId` 查询参数;响应行新增 `status` / `handledByName` / `experienceId` / `sameGroupCount`(同类聚集数) | | GET | `/v1/feedback/admin/summary` | `feedback:read` | **扩展**:响应新增 `pendingCount` 与 `pendingMatrix: Array<{agentId, agentName, reason, count}>` | | POST | `/v1/feedback/admin/draft` | `feedback:write` | body `{ feedbackIds: number[], agentId?: number }` → `{ experiences: Array<{content}> }` | | POST | `/v1/feedback/admin/convert` | `feedback:write` | body `{ feedbackIds: number[], agentId: number, experiences: Array<{content, sortOrder?}> }` → `{ experienceId }` | | POST | `/v1/feedback/admin/dismiss` | `feedback:write` | body `{ feedbackIds: number[] }` → `{ updated: number }` | 设计说明: - **draft 与 convert 分离**:draft 只读不写,可反复生成(换模型、改原料重试);convert 才是写入。draft 接收可选 `agentId`,传入时用于「既有经验条数」提示与目标预判,不传入则用反馈行快照 agent_id。 - **convert 不带草稿 ID**:因为草稿不落库,前端把编辑后的 `experiences` 数组直接随 convert 提交。服务端重新执行全部校验(内容非空、≤300 字、条数 1~2、agentId 存在、反馈仍为 PENDING)。 - **不做逐条 convert 端点**:批量是主场景(聚集),单条是批量的特例(数组长度 1)。 ### 5.7 管理后台交互 `FeedbackView.vue` 增强(保持现有汇总卡 + 明细表 + 移动端卡片态的结构): 1. **汇总卡**:在「点踩总数」后插入「待处理」项(`pendingCount`),越线变色(>0 即有语义)。 2. **待处理矩阵**:汇总卡下方新增一张小表(Agent × 原因 → 待处理数),点击单元格把明细筛选设为该 Agent + 该原因 + `status=PENDING` 并滚到明细区。移动端折叠为「按 Agent」单维列表。 3. **明细筛选**:新增「状态」下拉(待处理 / 已转经验 / 已忽略,默认待处理)与「Agent」下拉(选项来自待处理矩阵的 agentId 集合 + 现有数据,用 `clearable`)。 4. **同类聚集**:行内 Agent 列旁展示「同类 N 条」徽标(`sameGroupCount`);表格开启多选,勾选 ≥2 条时顶部工具条显示「已选 N 条 · 转为经验」。 5. **转经验向导**(抽屉,宽 720px): - 左列:所选反馈的原消息 + 用户原话 + 原因 + 时间; - 右列:目标 Agent 下拉(默认预选快照 agent_id,若该 Agent 已删则下拉为空并要求必选)+ 经验草稿编辑区(每条一个 textarea,实时显示 `x/300`,可增删条目,最多 2 条); - 底部:「重新生成」(再调一次 draft)、「取消」、「确认写入」; - 生成中右列 loading,左列可滚动查看; - 写入成功后关闭抽屉、刷新明细、`ElMessage.success` 提示「已写入 N 条经验」。 6. **忽略**:行内「忽略」按钮 + 批量「忽略」,均 `ElMessageBox.confirm` 二次确认(说明「忽略后不可恢复」)。 7. **已处理行**:`CONVERTED` 行显示经验 ID(点击跳 Agent 编辑弹窗不可行——后台无该深链,改为 tooltip 展示经验正文前 50 字);`DISMISSED` 行操作列禁用。 --- ## 6. 实现步骤 | 步骤 | 内容 | 产出 | |---|---|---| | 1 | V148 / V149 两个迁移 | 表结构 + 权限码 | | 2 | `normalizeExperienceContent` 抽出 + `AgentExperienceService` 改造 | 共享校验 | | 3 | `RunTraceService.locateRunByMessageId` + 单测 | 轨迹定位 | | 4 | `ExperienceDraftService`(原料装配 + 脱敏 + prompt + 调用 + 纠错)+ 单测 | 起草服务 | | 5 | `FeedbackService` / `FeedbackRepository` 扩展(状态机、CAS、矩阵、筛选)+ 单测 | 领域层 | | 6 | 4 个管理端点 + 权限校验 + 路由单测 | API | | 7 | `feedback.draftModelId` 设置项(常量 + 渲染分类 + admin 系统设置页下拉) | 配置面 | | 8 | `create-app.ts` wiring + `LLM_CALL_SCENES` / `LlmUsageService` 场景常量 | 装配 | | 9 | 管理后台 `FeedbackView.vue` 增强 | UI | | 10 | 全量单测 + `npm run build` | 质量门 | 建议顺序:1 → 2 → 3 → 4 → 5 → 6 → 7 → 8 → 9 → 10(先数据与领域,再 API,最后 UI)。 --- ## 7. 落地清单 ### 7.1 后端 backend-ts - [ ] `db/migration/V148__message_feedback_status.sql` - [ ] `db/migration/V149__feedback_write_permission.sql` - [ ] `agent/agent-experience.service.ts`:抽出导出 `normalizeExperienceContent` - [ ] `session/run-trace.service.ts`:新增 `locateRunByMessageId` + 单测(命中 run / messageId 不属于会话 / 用户在首条之前的系统消息) - [ ] `feedback/experience-draft.service.ts`:新建 + 单测 - [ ] `feedback/feedback.service.ts`:`convertToExperience` / `dismiss` / `getSummary` 扩展 / `listDetails` 扩展 - [ ] `feedback/feedback.repository.ts`:状态列映射、`casMarkConverted`、`bindExperience`、`markDismissed`、`countPendingGroupBy`、筛选扩展 - [ ] `feedback/feedback.routes.ts`:4 个新端点 + 2 个既有端点扩展 + 权限校验 + 单测 - [ ] `usage/llm-call-context.ts` + `usage/llm-usage.service.ts`:新 scene 常量 - [ ] `settings/settings.service.ts`:`feedback.draftModelId` 常量与分类 - [ ] `create-app.ts`:装配 `ExperienceDraftService`、事务内仓储、路由 deps - [ ] `common/error-code.ts`:新增 `FEEDBACK_ALREADY_HANDLED`(HTTP 200 + `Result.code=3046`) > 实现口径校正:`common/http-error.ts` 的 `handleError` 对非 401/403 业务异常统一返回 HTTP 200 + `Result` code,因此该错误实际以 HTTP 200 + code=3046 返回,前端按 `code !== 0` 提示。方案早期草稿写的「HTTP 409」不成立。 ### 7.2 管理后台 admin - [ ] `views/feedback/FeedbackView.vue`:状态列/筛选、同类聚集徽标、多选批量、待处理卡片与矩阵、转经验向导、忽略确认 - [ ] 系统设置页「Agent 与模型」分组:`feedback.draftModelId` 模型下拉(若该分组由 schema 驱动则只需后端加分类;否则加表单项) ### 7.3 不做 - [ ] 不改桌面端 `desktop/` 任何文件 - [ ] 不改飞书卡片与点踩采集链路 - [ ] 不加 `mao-cli` 命令 - [ ] 不建 `feedback_draft` 表 - [ ] 不做 `DISMISSED` 恢复入口 --- ## 8. 测试要点 ### 8.1 状态机与并发 - `PENDING → CONVERTED`:成功后 `status/handled_by/handled_at/experience_id` 四列均正确,经验行写入目标 Agent 且 `enabled=1`。 - `PENDING → DISMISSED`:三列正确,`experience_id` 为 NULL。 - 重复 convert:第二次返回 `FEEDBACK_ALREADY_HANDLED`,且不重复写经验。 - 并发 convert 同一批:只有一个事务成功,另一个 `affectedRows` 不符而回滚,最终经验条数 = 草稿条数(不翻倍)。 - 经验内容超 300 字:抛 `AGENT_EXPERIENCE_CONTENT_INVALID`,事务回滚,反馈仍为 `PENDING`(可重试)。 - convert 与 dismiss 混批(部分已处理):整体失败,无部分写入。 ### 8.2 起草服务 - N=1 产出 1 条;N≥2 产出 1~2 条且措辞更通用。 - prompt 输入**不含**工具结果正文(断言序列化原料中无 `tool_result` 内容)。 - 脱敏:轨迹含 `.env` / `*.pem` / 含 `token`/`secret` 的文件名时,原料中该 target 为 `[sensitive]`。 - 模型返回非法 JSON / 条数越界 / 超 300 字:纠错一次;二次仍失败抛 `LLM_CALL_FAILED`,且不写任何状态。 - LLM 超时 / 调用失败:抛 `LLM_TIMEOUT` / `LLM_CALL_FAILED`,反馈状态不变。 - 模型解析:`feedback.draftModelId` 未配置 / 配了不存在的 id / 配了非法值 → 回落默认模型;默认模型也没有 → `MODEL_NOT_FOUND`。 - 用量:`llm_call` 落一行,`scene='feedback_experience_draft'`、`user_id`= 操作管理员、`agent_id`= 目标 Agent、`success` 正确;计入成本与预算口径(与 `git_commit_message` 一致)。 ### 8.3 轨迹定位 - messageId 落在某 run 内 → 返回该 run,轮次/工具/成败与 `buildTrace` 同页结果一致。 - messageId 属于其他会话 → 返回 `null`,draft 报「反馈关联消息不可用」。 - messageId 早于第一条用户消息(纯系统消息)→ 返回 `null`。 - 会话被压缩过 → 仍能返回压缩边界之后的 run;早于边界的返回 `null` 或该 run 的压缩后形态(按既有 `buildTrace` 口径,不新造语义)。 ### 8.4 API 与权限 - 非 `feedback:write` 调 draft/convert/dismiss → 403;`feedback:read` 不可调这三个。 - `listDetails` 的 `status` / `agentId` 筛选正确;`sameGroupCount` 与矩阵口径一致(同 `agent_id + reason + status=PENDING`)。 - `summary` 的 `pendingCount` / `pendingMatrix` 与明细筛选结果总数一致。 - convert 的 `agentId` 不存在 / 已删 → `AGENT_NOT_FOUND`,不写任何东西。 ### 8.5 回归 - `feedback:read` 明细查询的既有字段与排序不变(新增列不影响旧消费方)。 - 桌面端 / 飞书点踩写入仍走 `uk_message` 幂等(`upsert` 未改)。 - `AgentExperienceService` 既有 CRUD 与 `syncExperiences` 行为不变(抽出纯函数不改语义)。 - `buildTrace` 现有翻页行为零变化(只新增方法)。 --- ## 9. 风险与开放问题 1. **shell 命令内的明文密钥会进 prompt**(已知边界,评审已确认接受):`isSensitive` 只匹配路径,`shell_session` 的 target 是完整命令串,其中如 `curl -H "Authorization: Bearer sk-xxx"` 会原样进入 prompt 并发送到模型服务。缓解:原料只含 target 与成败、不含 stdout;管理后台使用者是受信任管理员;起草模型由运维自选。**不做**命令内容正则脱敏(与 git 提交信息口径一致)。 2. **草稿质量不可控**:轨迹只有工具名/目标/成败,模型可能写出泛泛而谈的经验。缓解:必须人工编辑确认;prompt 要求引用轨迹中的具体对象;写入后可随时在后台编辑或停用(既有能力)。 3. **提示词膨胀**:一次转换最多写 2 条,`agent_experiences` 无硬上限。缓解:draft 时把「该 Agent 现有 N 条经验」给模型并要求避免重复;向导内展示该数字。条数硬上限留待后续(2026-07-09 方案已记录)。 4. **`experience_id` 只记第一条**:一次转换写 2 条时,第二条无法从反馈行直接追溯。缓解:`handled_by/at` 时间邻近 + Agent 经验列表人工比对;不建关联表(低频操作,不值得)。 5. **快照口径的分裂**:会话更换 Agent 后,同类反馈按快照 agent_id 被劈成两组,聚集数与矩阵都会分裂。缓解:接受(文档记录);后续如需可按会话实时 agent_id 归并,属独立改动。 6. **飞书点踩行不稳定**:飞书是 toggle 语义,取消点踩会物理删行;若该行已 `CONVERTED`,删行后经验不回滚(写入即独立资产)。此行为符合预期,不做级联删除。 7. **批量全或无**:批量 convert/dismiss 中若有一行已被处理,整体失败。管理员需刷新后重试。这是为避免部分写入导致状态不一致而采取的口径,若评审认为应支持部分成功,改为逐行 CAS + 返回 `{updated, skipped}`。 --- ## 10. 已确认决策摘要 | # | 决策 | |---|---| | 1 | `DISMISSED` 为终态,不可恢复;`CONVERTED` 同为终态 | | 2 | 全部来源(desktop/feishu)、全部 5 种原因都允许进入转经验流程,无前置过滤 | | 3 | 目标 Agent 由管理员在向导里显式选择,默认预选反馈行快照 `agent_id` | | 4 | 起草模型可配置:新增系统设置 `feedback.draftModelId`,留空回落默认模型 | | 5 | 聚集按 `feedback.agent_id + reason` 快照口径分组 | | 6 | 单条 / 归纳由服务端按选中数量自动切换(N=1 单条,N≥2 归纳),前端无感 | | 7 | 写经验与回写反馈状态严格同事务(`db.transaction` 内新建轻量仓储) | | 8 | 并发冲突仅在最终写入时 CAS 判定,进入向导不预占、无中间状态 | | 9 | 桌面端点踩提示不做(图标高亮即反馈);飞书卡片不改 | | 10 | 权限拆分为 `feedback:read`(既有)+ `feedback:write`(新增,V149,默认授 role_id=1) | | 11 | 草稿纯前端内存持有,不建表、不用 sessionStorage | | 12 | 轨迹原料改用 run-trace,新增 `RunTraceService.locateRunByMessageId` 精确定位 | | 13 | 脱敏仅复用 `isSensitive` 路径规则,不做 shell 命令内容正则脱敏 | | 14 | 汇总页加「待处理数」卡片 + Agent×原因待处理矩阵,并入 P2 | | 15 | 迁移拆两个:V148 改表结构、V149 插权限码 | | 16 | 一次转换写多条经验时,`experience_id` 只记第一条 |