From 4714efaab948fea4f96a3211bfb867b893e3d47d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=B0=8F=E5=94=AF?= Date: Mon, 7 Sep 2026 21:50:09 +0800 Subject: [PATCH] =?UTF-8?q?fix(distill-quality):=20=E4=B8=A5=E6=A0=BC?= =?UTF-8?q?=E8=B4=A8=E9=87=8F=E9=97=A8=E6=A7=9B=201b=20(t=5Fcc07ef4b)=20?= =?UTF-8?q?=E2=80=94=20<20=E5=AD=97/=E7=BA=AF=E7=96=91=E9=97=AE=E5=8F=A5/?= =?UTF-8?q?=E7=8A=B6=E6=80=81=E6=B1=87=E6=8A=A5=E6=8B=92=E7=BB=9D;=2020-50?= =?UTF-8?q?=E9=9C=80=E5=AE=9E=E4=BD=93=E6=88=96=E5=8A=A8=E8=AF=8D?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 方案B 严格版(替代 946f0c9 宽松版): quality.go IsQualityFact 重写 - <20字一律拒绝(无用户信号豁免, 与质量看门狗 FRAG_LEN=20 对齐 → AC-2 碎片率<10%) - 纯疑问句拒绝(带?/?、疑问前缀+吗呢么、无陈述主语短问句) - 状态汇报型拒绝(无主状态句/健康检查/桥接/reflection/完成重启类) - 20-50字需含实体(织忆/hermes/牧尘/大写词/数字)或动词短语才入库 engine.go: distillOne 源头过滤(图谱/AAAK/记忆库同源干净) + fallbackSingle 过滤 server.go 写回 IsQualityFact 双保险(同一函数自动变严格) 单测 quality_test.go + quality_engine_test.go (mock LLM 短对话episode → 0碎片入库) AC-1 dev 验证: 构造含短对话episodes → 蒸馏后碎片不入库 ✅ --- go/internal/distill/engine.go | 12 + go/internal/distill/quality.go | 252 ++++++++++++++++++--- go/internal/distill/quality_engine_test.go | 76 +++++++ go/internal/distill/quality_test.go | 126 +++++++++++ 4 files changed, 433 insertions(+), 33 deletions(-) create mode 100644 go/internal/distill/quality_engine_test.go create mode 100644 go/internal/distill/quality_test.go diff --git a/go/internal/distill/engine.go b/go/internal/distill/engine.go index cf4b2e1..96fe8b6 100644 --- a/go/internal/distill/engine.go +++ b/go/internal/distill/engine.go @@ -273,6 +273,16 @@ func (e *Engine) distillOne(input DistillInput) DistillResult { facts = heuristicFacts } + // 2026-09-07 质量门槛(t_cc07ef4b): LLM 提炼 fact 入库前过滤 + // <20字/纯疑问句/状态汇报型 拒绝; 20-50字需含实体或动词短语 + // 在源头过滤, 使图谱/AAAK/记忆库一律只看到质量事实 + if before := len(facts); before > 0 { + facts = FilterQualityFacts(facts) + if dropped := before - len(facts); dropped > 0 { + log.Printf("[distill] quality gate dropped %d/%d facts for %s", dropped, before, input.EpisodeID) + } + } + // P4: 生成 AAAK 压缩索引(每条事实的紧凑摘要,供召回快速定位) index := buildAAAKIndex(facts, entities, overall) if len(index) > 0 { @@ -612,6 +622,8 @@ func fallbackSingle(input DistillInput) DistillResult { if len(input.Content) > 20 { facts = append(facts, truncate(input.Content, 100)) } + // 质量门槛同样作用于 fallback 路径(t_cc07ef4b) + facts = FilterQualityFacts(facts) return DistillResult{Facts: facts} } diff --git a/go/internal/distill/quality.go b/go/internal/distill/quality.go index 9c930ff..d8afbe6 100644 --- a/go/internal/distill/quality.go +++ b/go/internal/distill/quality.go @@ -1,50 +1,236 @@ +// 织忆 MemoryWeave — 蒸馏质量门槛(方案B 严格版, kanban t_cc07ef4b) +// +// 2026-09-07 t_cc07ef4b 继承 t_9316bf56 AC-1: LLM 提炼 fact 入库前过滤 +// 规则(按卡面 spec, 覆盖原 946f0c9 宽松版): +// - <20字 → 拒绝 (碎片; 与质量看门狗 FRAG_LEN=20 对齐, 无用户信号豁免) +// - 纯疑问句 → 拒绝 (OpenQuestions 合流 + LLM 直接输出问句) +// - 状态汇报型 → 拒绝 (健康检查/重启/验证/桥接/daemon reflection 类进程噪声) +// - 20-50字 → 需含实体或动词短语才入库 +// - >50字 → 通过 (除非命中上面 纯疑问/状态汇报 规则) +// 与 LightMem"保留小细节"哲学差异: 原版允许 8-20 字带用户信号(如"用户喜欢喝咖啡") +// 保留——但看门狗把 <20 字全部计为碎片, AC-2(新增碎片率<10%)要求一律拒绝, +// 故此处 <20 字无豁免。短对话原始内容仍完整保留在 episodes 层, 不丢失信息。 package distill -import "strings" +import ( + "strings" + "unicode" + "unicode/utf8" +) -// IsQualityFact 判断蒸馏事实是否有入库价值(2026-09-07 方案B 质量门槛) -// -// 背景: 审计发现 memories 74% <50 字符碎片("重启系统"/"健康检查完成"/daemon reflection 等) -// = 蒸馏/直接 commit 无质量门槛产物。这里拦碎片 + 噪声句。 -// 与 LightMem "保留小细节"哲学兼容: 咖啡偏好(18字+信息)仍保留, 纯噪声/碎片拒绝。 +// IsQualityFact 判断蒸馏事实是否有入库价值(严格质量门槛) func IsQualityFact(fact string) bool { s := strings.TrimSpace(fact) if s == "" { return false } - // 碎片判断: 绝对碎片(<8字)拒绝; 8-20字需含用户相关词才保留(短偏好如"用户喜欢喝咖啡"有价值) - runeLen := len([]rune(s)) - if runeLen < 8 { + // 纯疑问句 → 拒绝(任何长度) + if isPureQuestion(s) { return false } + // 状态汇报型/进程噪声 → 拒绝(任何长度) + if isStatusReportLike(s) { + return false + } + runeLen := utf8.RuneCountInString(s) + // <20字 → 拒绝(碎片) if runeLen < 20 { - userSignals := []string{"用户", "牧尘", "小唯", "偏好", "喜欢", "决定", "使用", "需要", "认为", "选择", "计划", "希望", "正在做", "完成了"} - hasSignal := false - for _, sg := range userSignals { - if strings.Contains(s, sg) { - hasSignal = true - break - } - } - if !hasSignal { - return false // 8-20字且无用户信号 = 碎片(如"重启系统"已<8; "所有进程运行中"无信号) - } + return false } - // 噪声: 状态汇报/桥接/daemon reflection 类前缀(这些是进程噪声不是用户事实) - noisePrefixes := []string{ - "健康检查完成", "状态同步", "所有进程运行中", "当前系统状态完全正常", - "系统健康检查", "正在评估上次决策", "用户在深度思考中", "上次修复了", - "小唯a06主profile", "小唯a06使用的模型", "小唯a06的织忆数据", "小唯a06的cron", "小唯a06的daemon", - } - lower := strings.ToLower(s) - for _, p := range noisePrefixes { - if strings.HasPrefix(lower, strings.ToLower(p)) { + // 20-50字 → 需含实体或动词短语 + if runeLen <= 50 { + if !(hasEntity(s) || hasVerbPhrase(s)) { return false } } - // 含桥接关键词且短(通信日志非事实) - if strings.Contains(s, "桥接") && len([]rune(s)) < 40 { - return false - } return true } + +// FilterQualityFacts 过滤事实列表, 返回通过质量门槛的子集(引擎入队后统一调用) +func FilterQualityFacts(facts []string) []string { + out := make([]string, 0, len(facts)) + for _, f := range facts { + if IsQualityFact(f) { + out = append(out, f) + } + } + return out +} + +// ─── 纯疑问句判定 ────────────────────────────────────────── + +var questionSuffixes = []string{"?", "?", "吗?", "呢?", "么?", "嘛?", "吗?", "呢?"} +var questionPrefixes = []string{ + "为什么", "怎么", "如何", "是否", "能不能", "可不可以", "要不要", + "有没有", "什么", "哪些", "哪个", "哪里", "几时", "多少", "多久", + "谁", "何时", "为何", "请问", "能否", "咋", "啥", +} + +// isPureQuestion 判断整句是否为疑问句(不是转述的疑问) +func isPureQuestion(s string) bool { + t := strings.TrimSpace(s) + if t == "" { + return false + } + // 以问号结尾 → 强信号 + for _, q := range []string{"?", "?"} { + if strings.HasSuffix(t, q) { + // 排除 "用户问是否升级?" 这类转述?——结尾问号仍视为疑问句本体, + // 蒸馏 facts 里不应出现任何问句; 转述型应改写为陈述("用户询问了...") + return true + } + } + // 疑问前缀 + 以 吗/呢/么 等结尾(无问号的口语问句) + for _, p := range questionPrefixes { + if strings.HasPrefix(t, p) { + for _, suf := range []string{"吗", "呢", "么", "嘛", "啊"} { + if strings.HasSuffix(t, suf) { + return true + } + } + // 疑问前缀且整句较短(<30)且无陈述主语 → 判为问句 + if utf8.RuneCountInString(t) <= 30 && !hasStatementSubject(t) { + return true + } + } + } + return false +} + +// hasStatementSubject 粗略判断是否带陈述主语(用户/小唯/牧尘/他/她/系统等) +func hasStatementSubject(s string) bool { + subjects := []string{"用户", "小唯", "牧尘", "他", "她", "它", "我", "我们", "系统", "服务器", "对方", "同事", "老板"} + for _, sub := range subjects { + if strings.HasPrefix(s, sub) { + return true + } + } + return false +} + +// ─── 状态汇报型 / 进程噪声判定 ───────────────────────────── + +// statusExactPhrases 无主状态句(完整或前缀命中即拒 —— 进程/守护噪声不是用户事实) +var statusExactPhrases = []string{ + "健康检查完成", "健康检查通过", "系统健康检查", "所有进程运行中", + "当前系统状态完全正常", "状态同步", "正在评估上次决策", "上次修复了", + "心跳正常", "运行状态正常", "一切正常", "无异常", "检查完毕", + "自检完成", "任务已完成", "处理完成", "收到指令", "开始执行", + "执行完毕", "正在执行任务", "测试通过", "验证通过", + "小唯a06主profile", "小唯a06使用的模型", "小唯a06的织忆数据", + "小唯a06的cron", "小唯a06的daemon", "小唯a06的", +} + +// statusNoiseMarkers 强噪声子串(含"健康检查"级信号, 无主句才拒) +var statusNoiseMarkers = []string{ + "健康检查", "桥接", "reflection", "reflecting", "heartbeat", + "cron运行", "cron任务", "状态同步", +} + +// completionMarkers 完成/重启/恢复类动词短语(无主句时才视为状态汇报) +var completionMarkers = []string{ + "重启完成", "重启成功", "已重启", "验证通过", "验证完成", "测试通过", + "测试完成", "恢复完成", "已恢复", "已启动", "已停止", "已完成清理", + "清理完成", "备份完成", "同步完成", "重启系统", "执行了重启", +} + +// isStatusReportLike 判断是否为状态汇报/进程噪声句。 +// 判定原则: 只有"无主语"的状态汇报才拒 —— 带用户/系统主体的真实事实(如 +// "用户重启了服务器")不在此列, 交给 <20字/20-50字 规则约束。 +func isStatusReportLike(s string) bool { + t := strings.TrimSpace(s) + lower := strings.ToLower(t) + + // 1) 完整/前缀命中已知无主状态句 → 拒 + for _, p := range statusExactPhrases { + if strings.HasPrefix(lower, strings.ToLower(p)) || lower == strings.ToLower(p) { + return true + } + } + + // 2) 无主句 + 强噪声子串 → 拒 + if !hasStatementSubject(t) { + for _, m := range statusNoiseMarkers { + if strings.Contains(lower, strings.ToLower(m)) { + return true + } + } + for _, c := range completionMarkers { + if strings.Contains(lower, strings.ToLower(c)) { + return true + } + } + } + return false +} + +// ─── 实体 / 动词短语 判定(20-50字 门槛)─────────────────── + +// strongEntities 常见强实体词(技术名/系统名/专有名词; 命中 = 有实体) +var strongEntities = []string{ + "牧尘", "小唯", "织忆", "KOCR", "Hermes", "hermes", "NewAPI", "newapi", + "飞书", "服务器", "笔记本", "zhiyid", "daemon", "llama", "qwen", "glm", + "deepseek", "agnes", "Gitea", "gitea", "Redis", "redis", "LanceDB", + "ComfyUI", "Windows", "Linux", "Deepin", "Vulkan", "CUDA", "ONNX", + "Python", "python", "Go语言", "golang", "Rust", "MySQL", "SQLite", + "ffmpeg", "docker", "nginx", "frpc", "frps", +} + +// strongVerbs 常见动词短语(动作/状态变化词; 命中 = 有动词) +var strongVerbs = []string{ + "安装", "部署", "修复", "升级", "使用", "需要", "决定", "选择", "计划", + "希望", "认为", "购买", "买了", "完成", "重启", "切换", "迁移", "创建", + "删除", "下载", "上传", "配置", "编写", "开发", "测试", "验证", "检查", + "发现", "解决", "提交", "推送", "更新", "设置", "启动", "停止", "运行", + "增加", "减少", "更换", "替换", "调整", "开始", "结束", "参加", "申请", + "同意", "拒绝", "喜欢", "讨厌", "学习", "研究", "阅读", "访问", "连接", + "发送", "接收", "保存", "修改", "整理", "清理", "备份", "恢复", "管理", + "维护", "分析", "讨论", "提出", "回答", "询问", "告知", "邀请", "约定", + "到达", "离开", "回来", "前往", "居住", "工作", "出生", "结婚", "毕业", + "入职", "离职", "订购", "预约", "取消", "返回", "打开", "关闭", "进入", + "退出", "登录", "注册", "写", "读", "做", "换", "买", "改", "拆", "装", + "尝试", "测试过", "调研", "评估", "对比", "选择用", "改用", "转用", + "处理", "解决掉", "搞定", "看过", "打开过", "拆过", +} + +// hasEntity 是否含实体(专有名词/技术名/大写英文词/数字) +func hasEntity(s string) bool { + // 强实体词 + for _, e := range strongEntities { + if strings.Contains(s, e) { + return true + } + } + // 大写英文单词(Hermes/ComfyUI/NewAPI 等) + hasUpperWord := false + words := strings.Fields(s) + for _, w := range words { + runes := []rune(w) + if len(runes) >= 2 && unicode.IsUpper(runes[0]) { + // 排除句首大写的中文拼音误判?中文在 Go range 下不是 IsUpper, + // 只捕获 ASCII 大写开头 → 安全 + hasUpperWord = true + break + } + } + if hasUpperWord { + return true + } + // 含数字(端口/型号/年份/数量) + for _, r := range s { + if r >= '0' && r <= '9' { + return true + } + } + return false +} + +// hasVerbPhrase 是否含动词短语 +func hasVerbPhrase(s string) bool { + for _, v := range strongVerbs { + if strings.Contains(s, v) { + return true + } + } + return false +} diff --git a/go/internal/distill/quality_engine_test.go b/go/internal/distill/quality_engine_test.go new file mode 100644 index 0000000..00806cc --- /dev/null +++ b/go/internal/distill/quality_engine_test.go @@ -0,0 +1,76 @@ +package distill + +import ( + "encoding/json" + "net/http" + "net/http/httptest" + "testing" +) + +// AC-1 dev 验证 (kanban t_cc07ef4b): 构造含短对话的 episodes → 蒸馏后碎片不入库。 +// 用 mock LLM 模拟真实蒸馏调用返回混合 facts(碎片+疑问句+状态汇报+合格事实), +// 断言 distillOne 返回的 facts 已按质量门槛过滤, 不会进入入库回调。 +func TestDistillOne_QualityGateBlocksFragments(t *testing.T) { + // mock LLM 返回: 短对话蒸馏产物——大部分是 <20字碎片/疑问句/状态汇报 + mockResp := LLMResponse{ + Facts: []string{ + "重启系统", // <20 碎片 → 拒 + "健康检查完成", // 状态汇报 → 拒 + "验证通过", // 状态汇报 → 拒 + "为什么系统会重启?", // 纯疑问句 → 拒 + "用户喜欢喝咖啡", // <20 碎片 → 拒 + "用户对织忆系统的蒸馏质量提出了改进要求并希望尽快处理", // 24字 含实体 → 通过 + }, + Entities: []string{"织忆"}, + IS: 0.9, SU: 0.8, PA: 0.8, VD: 0.9, RU: 0.8, + } + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + payload := map[string]interface{}{ + "choices": []map[string]interface{}{ + {"message": map[string]interface{}{"content": mustJSON(t, mockResp)}}, + }, + } + _ = json.NewEncoder(w).Encode(payload) + })) + defer srv.Close() + + e := NewEngine(srv.URL, "mock-model", "test-key") + // 构造短对话 episode: 大量碎片式短句 (类似 openclaw bridge/闲聊/状态行) + episode := DistillInput{ + EpisodeID: "ep_quality_gate_test", + Content: "[2026-09-07] user: 重启系统\n[2026-09-07] assistant: 好的\n[2026-09-07] user: 健康检查完成\n[2026-09-07] user: 为什么系统会重启?\n[2026-09-07] user: 用户喜欢喝咖啡\n[2026-09-07] user: 用户对织忆系统的蒸馏质量提出了改进要求并希望尽快处理", + Category: CatSystemFact, + Namespace: "dev-quality-gate", + AgentID: "a06", + } + + res := e.distillOne(episode) + t.Logf("distillOne facts kept = %d: %v", len(res.Facts), res.Facts) + + // 断言: 只有 1 条合格事实通过; 碎片/疑问/状态汇报全部被拒 + if len(res.Facts) != 1 { + t.Fatalf("expected exactly 1 quality fact to survive gate, got %d: %v", len(res.Facts), res.Facts) + } + if res.Facts[0] != "用户对织忆系统的蒸馏质量提出了改进要求并希望尽快处理" { + t.Errorf("unexpected surviving fact: %q", res.Facts[0]) + } +} + +// TestFilterQualityFacts_AllNoise 全噪声 episode: 蒸馏产物全碎片 → 0 条入库 +func TestFilterQualityFacts_AllNoise(t *testing.T) { + in := []string{"重启系统", "验证通过", "健康检查完成", "为什么?", "好的"} + got := FilterQualityFacts(in) + if len(got) != 0 { + t.Fatalf("expected 0 facts for all-noise input, got %v", got) + } +} + +func mustJSON(t *testing.T, v interface{}) string { + t.Helper() + b, err := json.Marshal(v) + if err != nil { + t.Fatalf("marshal: %v", err) + } + return string(b) +} diff --git a/go/internal/distill/quality_test.go b/go/internal/distill/quality_test.go new file mode 100644 index 0000000..f2176e4 --- /dev/null +++ b/go/internal/distill/quality_test.go @@ -0,0 +1,126 @@ +package distill + +import "testing" + +// 质量门槛单测 (kanban t_cc07ef4b 继承 t_9316bf56 AC-1) +// 规则: +// - <20字 → 拒绝 +// - 纯疑问句 → 拒绝 +// - 状态汇报型(无主句) → 拒绝 +// - 20-50字 → 需含实体或动词短语才入库 +// - >50字 → 通过(除非疑问/状态汇报) + +func TestIsQualityFact_ShortFragmentsRejected(t *testing.T) { + cases := []struct { + name string + fact string + want bool + }{ + // AC-1 审计样例: <20字碎片 一律拒绝(无用户信号豁免) + {"重启系统", "重启系统", false}, + {"健康检查完成", "健康检查完成", false}, + {"所有进程运行中", "所有进程运行中", false}, + {"验证通过", "验证通过", false}, + {"用户喜欢喝咖啡(短)", "用户喜欢喝咖啡", false}, + {"用户重启了系统", "用户重启了系统", false}, + {"19字边界", "用户今天完成了一次系统检查工作", false}, // 14字 + {"空串", "", false}, + {"纯空白", " ", false}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + if got := IsQualityFact(tc.fact); got != tc.want { + t.Errorf("IsQualityFact(%q) = %v, want %v", tc.fact, got, tc.want) + } + }) + } +} + +func TestIsQualityFact_PureQuestionRejected(t *testing.T) { + cases := []struct { + name string + fact string + want bool + }{ + {"带问号", "为什么系统会重启?", false}, + {"英文问号", "how to install hermes?", false}, + {"疑问词+吗", "用户要不要升级系统吗", false}, + {"怎么开头", "怎么解决这个报错问题呢", false}, + {"是否开头无标点", "是否应该把模型切换到本地", false}, + {"转述疑问句(带主语,陈述)", "用户询问了系统是否可以升级到最新稳定版本", true}, // 20字 主语转述非纯问句 + {"正常陈述带吗字(句中)", "用户说这个报错不用再管它了因为已经处理过了", true}, // 21字, 有主语+动词 + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + if got := IsQualityFact(tc.fact); got != tc.want { + t.Errorf("IsQualityFact(%q) = %v, want %v", tc.fact, got, tc.want) + } + }) + } +} + +func TestIsQualityFact_StatusReportRejected(t *testing.T) { + cases := []struct { + name string + fact string + want bool + }{ + {"无主健康检查", "健康检查完成,所有服务运行正常,无异常", false}, // 无主句+健康检查 + {"前缀状态句", "当前系统状态完全正常,无需处理", false}, + {"桥接噪声", "小唯a06与all桥接状态同步完成", false}, + {"重启完成无主", "重启完成,服务已恢复运行", false}, + {"带主语重启(真事实)", "用户昨天重启了家里的服务器并恢复了所有服务", true}, // 21字 有主语+实体服务器 + {"带主语升级", "小唯昨天将织忆系统升级到了新版本并验证通过", true}, // 有主语 + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + if got := IsQualityFact(tc.fact); got != tc.want { + t.Errorf("IsQualityFact(%q) = %v, want %v", tc.fact, got, tc.want) + } + }) + } +} + +func TestIsQualityFact_MidLengthNeedsEntityOrVerb(t *testing.T) { + cases := []struct { + name string + fact string + want bool + }{ + // 20-50字 无实体无动词 → 拒绝 + {"纯形容词堆叠", "很好很好很好很好很好很好很好很好很好", false}, // 20字 无实体无动词 + {"无实义名词串", "关于这个系统的一些非常详细的说明文档内容汇总介绍", false}, // 24字 无实体无动词 + // 20-50字 含实体 → 通过 + {"含织忆实体", "用户对织忆系统的蒸馏质量提出了改进要求并希望尽快处理", true}, + {"含hermes实体", "用户说hermes升级后速度明显变快了体验很好", true}, + {"含数字实体", "用户提到显卡是rtx3050只有4gb显存不够用", true}, + // 20-50字 含动词 → 通过 + {"含动词", "用户打算下个月把家里的网络设备全部更换一遍", true}, + {"含完成动词", "用户今天完成了对全部旧脚本的清理和归档工作", true}, + // >50字 → 通过 + {"超50字正常陈述", "用户详细讲述了昨天在整理旧项目时发现的问题以及处理思路并且记录了完整的解决方案方便以后遇到类似情况时参考", true}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + if got := IsQualityFact(tc.fact); got != tc.want { + t.Errorf("IsQualityFact(%q) = %v, want %v", tc.fact, got, tc.want) + } + }) + } +} + +func TestFilterQualityFacts(t *testing.T) { + in := []string{ + "重启系统", // <20 拒 + "为什么系统会重启?", // 疑问 拒 + "健康检查完成", // 状态 拒 + "用户对织忆系统提出了新的功能需求希望尽快实现", // 22字 实体 通过 + } + got := FilterQualityFacts(in) + if len(got) != 1 { + t.Fatalf("FilterQualityFacts(%v) len = %d, want 1 (got %v)", in, len(got), got) + } + if got[0] != in[3] { + t.Errorf("FilterQualityFacts kept %q, want %q", got[0], in[3]) + } +}