Repository navigation
fix(dedupe): candidates were the type's top-100 by importance, not the same-title rows #506
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -1645,6 +1645,30 @@ export function createStore(path) { | |
| return rows.map(toRow); | ||
| } | ||
|
|
||
| // Issue #505:写路径的去重候选必须「按同标题定向取」,不能「按 importance 取 | ||
| // 前 N 条」。后者是「该类型最重要的 100 行」,与「找出同标题的那一行」无关: | ||
| // 某类型里 importance 高于待写行的行数一旦超过窗口,同标题行与待写行双双落在 | ||
| // 窗口外,saveWithDedupe 的 find 恒不命中,于是静默走创建分支——不报错、不告 | ||
| // 警,合并语义随库增长而失效(报告实测 1047 行类型、importance=5 有 180 行时 | ||
| // 稳定复现)。 | ||
| // | ||
| // 口径刻意与 saveWithDedupe 的比较式对齐:那边用 m.title.trim() 比 | ||
| // String(memory.title).trim(),而 title 入库时并不 trim(见 save() 的 | ||
| // `memory.title` 直写)⇒ 这里必须 TRIM(title),否则存量里带首尾空白的标题 | ||
| // 会被精确匹配漏掉,退回同一种静默降级。 | ||
| // | ||
| // archived/forgotten 的过滤与 list() 默认口径一致(两者都为 0):合并目标必须 | ||
| // 是活跃行,否则会把内容并进一条已经归档/遗忘的记忆里。 | ||
| // 无 LIMIT:同 (type, title) 的活跃行本就极少,加窗口等于把 #505 重新引进来。 | ||
| function activeByTypeAndTitle(type, title) { | ||
| const rows = db.prepare( | ||
| `SELECT ${memoryColumnList} FROM memories | ||
| WHERE type = ? AND TRIM(title) = ? | ||
| AND forgotten = 0 AND archived = 0` | ||
|
Comment on lines
+1665
to
+1667
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🚀 Performance & Scalability | 🟡 Minor | ⚡ Quick win 🔎 Supported by static analysis
为定向查询建立匹配索引。
请添加覆盖活跃行的部分表达式索引: 建议的索引
🧰 Tools
🤖 Prompt for AI Agents
|
||
| ).all(type, String(title ?? "").trim()); | ||
| return rows.map(toRow); | ||
| } | ||
|
|
||
| // Issue #430:写路径要的「最近若干条正文」。质量过滤的近重复判定只读 content | ||
| // 这一列 + 前 20 行,此前写成 all().slice(0, 20) —— 全表逐行 toRow(27 字段、 | ||
| // 三次 JSON 解析)之后只截前 20 条正文;5k 行实测 100ms 级 → 0.06ms 级。 | ||
|
|
@@ -3355,6 +3379,9 @@ export function createStore(path) { | |
| getUnrecalledSince, | ||
| list, | ||
| all, | ||
| // Issue #505:写路径去重的定向候选(按 type + TRIM(title) 精确取活跃行), | ||
| // 替代「importance 前 N 条」那个会随库增长失效的窗口。 | ||
| activeByTypeAndTitle, | ||
| // #430:有界单列读,写路径(质量过滤)用。 | ||
| recentContents, | ||
| search, | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,150 @@ | ||
| // Issue #505:saveWithDedupe 的去重候选窗口固定 100 条,库一变大就静默不再合并。 | ||
| // | ||
| // 根因:候选集是 store.list({ type, limit: 100 }),order 默认 importance | ||
| // ⇒「该类型最重要的 100 行」,与「找出同标题的那一行」无关。某类型里 importance | ||
| // 高于待写行的行数一旦超过窗口,同标题行与待写行双双落在窗口外,find 恒不命中, | ||
| // 于是走创建分支:不报错、不告警,合并语义随库增长而失效。 | ||
| // | ||
| // 这组用例刻意复刻报告里的实测形状(类型 1047 行、其中 importance=5 有 180 行), | ||
| // 但用更小的数字达成同样的窗口溢出,避免测试本身变慢。 | ||
| import test from "node:test"; | ||
| import assert from "node:assert/strict"; | ||
| import { createStore } from "../src/store.js"; | ||
| import { createService } from "../src/service.js"; | ||
|
|
||
| function freshService() { | ||
| const store = createStore(":memory:"); | ||
| const service = createService({ store, mirror: null, config: {} }); | ||
| return { store, service }; | ||
| } | ||
|
|
||
| // 往某个类型里灌 n 条 importance=5 的行,把窗口挤满。 | ||
| function crowdType(store, type, n) { | ||
| for (let i = 0; i < n; i++) { | ||
| store.save({ | ||
| type, | ||
| title: `crowd-${type}-${i}`, | ||
| content: `padding row ${i}`, | ||
| importance: 5 | ||
| }); | ||
| } | ||
| } | ||
|
|
||
| test("#505: 同类型 importance 更高的行超过窗口后,同标题第二次写入仍然合并", () => { | ||
| const { store, service } = freshService(); | ||
| // 窗口是 100。灌 120 条 importance=5,保证待写行(importance=3)与它的同标题 | ||
| // 行都排不进前 100。 | ||
| crowdType(store, "project", 120); | ||
|
|
||
| const payload = { | ||
| type: "project", | ||
| title: "dedupe-target", | ||
| content: "first content", | ||
| importance: 3 | ||
| }; | ||
| const first = service.saveWithDedupe(payload); | ||
| assert.equal(first.action, "created", "第一次写入应当是新建"); | ||
|
|
||
| const second = service.saveWithDedupe({ ...payload, content: "second content" }); | ||
| assert.equal( | ||
| second.action, | ||
| "merged", | ||
| "第二次同标题写入必须并入已有行;返回 created 说明候选窗口把同标题行挡在窗口外(#505 静默降级)" | ||
| ); | ||
| assert.equal(second.memory.id, first.memory.id, "合并必须落在同一行"); | ||
|
|
||
| const rows = store.list({ type: "project", limit: null }) | ||
| .filter((m) => m.title.trim() === "dedupe-target"); | ||
| assert.equal(rows.length, 1, "同标题只应存在一行"); | ||
| }); | ||
|
|
||
| test("#505: 定向候选不受 importance 分布影响(低 importance 的同标题行也能命中)", () => { | ||
| const { store, service } = freshService(); | ||
| // 先写一条 importance=1 的目标行,再用大量 importance=5 的行把它挤出窗口。 | ||
| const first = service.saveWithDedupe({ | ||
| type: "pattern", | ||
| title: "low-importance-target", | ||
| content: "seed", | ||
| importance: 1 | ||
| }); | ||
| assert.equal(first.action, "created"); | ||
|
|
||
| crowdType(store, "pattern", 120); | ||
|
|
||
| const second = service.saveWithDedupe({ | ||
| type: "pattern", | ||
| title: "low-importance-target", | ||
| content: "merged later", | ||
| importance: 1 | ||
| }); | ||
| assert.equal(second.action, "merged", "低 importance 的同标题行同样必须能被找到"); | ||
| assert.equal(second.memory.id, first.memory.id); | ||
| }); | ||
|
|
||
| test("#505: 标题首尾空白不影响去重命中(入库不 trim,比较 trim)", () => { | ||
| const { store, service } = freshService(); | ||
| crowdType(store, "constraint", 120); | ||
|
|
||
| const first = service.saveWithDedupe({ | ||
| type: "constraint", | ||
| title: " spaced title ", | ||
| content: "seed", | ||
| importance: 3 | ||
| }); | ||
| assert.equal(first.action, "created"); | ||
|
|
||
| // 同一个标题、首尾空白写法不同——按 trim 口径应当命中同一行。 | ||
| const second = service.saveWithDedupe({ | ||
| type: "constraint", | ||
| title: "spaced title", | ||
| content: "merged", | ||
| importance: 3 | ||
| }); | ||
| assert.equal(second.action, "merged", "trim 后同标题必须合并,否则存量带空白的标题会退回静默降级"); | ||
| assert.equal(second.memory.id, first.memory.id); | ||
| }); | ||
|
|
||
| test("#505: 定向候选不跨类型命中", () => { | ||
| const { store, service } = freshService(); | ||
| crowdType(store, "project", 120); | ||
|
|
||
| const a = service.saveWithDedupe({ | ||
| type: "project", | ||
| title: "same-title-different-type", | ||
| content: "in project", | ||
| importance: 3 | ||
| }); | ||
| const b = service.saveWithDedupe({ | ||
| type: "preference", | ||
| title: "same-title-different-type", | ||
| content: "in preference", | ||
| importance: 3 | ||
| }); | ||
| assert.equal(a.action, "created"); | ||
| assert.equal(b.action, "created", "不同 type 的同标题行不得合并"); | ||
| assert.notEqual(a.memory.id, b.memory.id); | ||
| }); | ||
|
|
||
| test("#505: 归档行不作为合并目标(口径与 list 默认一致)", () => { | ||
| const { store, service } = freshService(); | ||
| crowdType(store, "decision", 120); | ||
|
|
||
| const first = service.saveWithDedupe({ | ||
| type: "decision", | ||
| title: "archived-target", | ||
| content: "seed", | ||
| importance: 3 | ||
| }); | ||
| // 归档必须走 store.setArchived——store.update 的 SET 子句里没有 archived 列, | ||
| // 用 update({archived:true}) 根本不会归档,测试会假绿。 | ||
| store.setArchived(first.memory.id, true); | ||
|
|
||
| const second = service.saveWithDedupe({ | ||
| type: "decision", | ||
| title: "archived-target", | ||
| content: "after archive", | ||
| importance: 3 | ||
| }); | ||
| assert.equal(second.action, "created", "已归档行不该被当作活跃合并目标"); | ||
| assert.notEqual(second.memory.id, first.memory.id); | ||
| }); |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
🗄️ Data Integrity & Integration | 🟠 Major | 🏗️ Heavy lift
统一 SQL 查询与服务层的标题归一化。
如果已有标题以制表符、换行符或不间断空格开头,
TRIM(title)不会将其移除,但saveWithDedupe使用的 JavaScripttrim()会移除。新查询因此找不到原本符合去重条件的行,后续写入会静默创建重复记忆。请让查询键与服务层使用同一归一化规则,并补充非普通空格的回归用例。(sqlite.org)🧰 Tools
🪛 ast-grep (0.45.3)
[warning] 1665-1667: Avoid SQL injections
Context:
SELECT ${memoryColumnList} FROM memories WHERE type = ? AND TRIM(title) = ? AND forgotten = 0 AND archived = 0Note: [CWE-89] Improper Neutralization of Special Elements used in an SQL Command ('SQL Injection'). Security best practice.
(variable-sql-statement-injection)
🤖 Prompt for AI Agents