feat(relation): 記憶接回關係圖,並補上唯讀健檢 relation doctor
寫入時把 --entities/--about 的人名解析成節點 id(entity_ids/about_ids), 之後「提到誰」與 turnContext 認人才接得回關係圖;同名有多個候選視為歧義, 兩邊都不寫,不替使用者挑一個。 節點 id 會被原樣寫進 front matter 與 jsonl,所以兩頭都要防:帶換行的 id 可以在 front matter 裡多插一行、覆寫 type,把一則普通記憶變成不該被遺忘的 canon;`:` `[` `]` `,` `#` 也都會改變結構。injectSafeLine 把這些擋掉。 graph.json 壞掉時不再偽裝成空圖:要做決定的 action 直接以非零 exit 擋下 (放它過去等於拿一張空圖覆蓋原檔),relation doctor 自己會報告。 recall 用關係節點的 name 當關鍵詞,但刻意不用 id——id 是內部識別,命中率 高得離譜(關係圖有一個 id 為 user 的節點,就會命中每一則 about: [user]), 一個命中值 +10 會把顯著度 75 的正確答案擠出榜。about_ids 同理不進 haystack。 順手把關係圖改成一次讀完:原本每筆的每個 entity 各讀一遍 graph.json, 實測 30 節點/240 筆 = 720 次讀檔;2000 節點時單輪要 1.2 秒。 selftest 448 → 471 項,全過。 Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -1951,6 +1951,188 @@ console.log("\n注入區塊不可被人格檔案逸出(S6)");
|
||||
cli(["release", "--session", S_INJ]);
|
||||
}
|
||||
|
||||
console.log("\n記憶 ↔ 關係圖:稱謂不能被猜成別人,關係圖壞了不能無聲蒸發");
|
||||
{
|
||||
// 寫入路徑(記憶要記下「提到誰」)只認完全相等的 id 或 name。子字串比對留給
|
||||
// `relation style`/`speaker` 這種互動式呼叫端——猜錯了,人格會固化一則假記憶。
|
||||
const S_MAP = "sess-relmap-9191";
|
||||
cli(["create", "--persona", "relmap", "--session", S_MAP, "--name", "Relmap", "--creature", "測試用",
|
||||
"--vibe", "普通", "--emoji", "🧭"]);
|
||||
const idsOf = (index) => pl.readJsonl(pl.shortTermPath("relmap"))[index]?.entity_ids ?? null;
|
||||
const remember = (text, entity) => cli(["remember", "--session", S_MAP, "--role", "user",
|
||||
"--text", text, "--entities", entity, "--salience", "50"]);
|
||||
cli(["relation", "node", "--session", S_MAP, "--name", "小林先生", "--id", "xiao-lin", "--closeness", "40"]);
|
||||
cli(["relation", "node", "--session", S_MAP, "--name", "小明", "--id", "user", "--closeness", "70"]);
|
||||
remember("先生加班到十一點", "先生");
|
||||
remember("明天要開會", "明");
|
||||
remember("小林先生說他會處理", "小林先生");
|
||||
check("稱謂(先生)不會被猜成同事節點「小林先生」的 id", idsOf(0) === null, JSON.stringify(idsOf(0)));
|
||||
check("單字名(明)不會被猜成 id 為 user 的節點", idsOf(1) === null, JSON.stringify(idsOf(1)));
|
||||
check("完全相等的人名才寫 entity_ids", JSON.stringify(idsOf(2)) === '["xiao-lin"]', JSON.stringify(idsOf(2)));
|
||||
check("誤配的稱謂不會開出 R5 固化候選(不然人格會固化一則假記憶)", (() => {
|
||||
const keys = pl.promotionCandidates("relmap").candidates.filter((c) => c.rules.includes("R5")).map((c) => c.key);
|
||||
return !keys.includes("xiao-lin");
|
||||
})(), JSON.stringify(pl.promotionCandidates("relmap").candidates.map((c) => `${c.rules.join("+")}:${c.key}`)));
|
||||
check("R5 統計只讀一次 graph.json(以前是每筆的每個人名各讀一遍)", (() => {
|
||||
const target = pl.relationsJson("relmap");
|
||||
const real = fs.readFileSync;
|
||||
let reads = 0;
|
||||
fs.readFileSync = function (file, ...rest) {
|
||||
if (String(file) === target) reads += 1;
|
||||
return real.call(fs, file, ...rest);
|
||||
};
|
||||
try {
|
||||
pl.promotionCandidates("relmap");
|
||||
} finally {
|
||||
fs.readFileSync = real;
|
||||
}
|
||||
return reads === 1;
|
||||
})());
|
||||
|
||||
// 同名歧義:誰先誰贏只是 graph.json 的排列順序,而 import/sync pull 都會重排它
|
||||
const dup = pl.loadRelations("relmap");
|
||||
dup.nodes.push({ id: "lin-b", name: "小林先生", kind: "human", closeness: 20, trust: 20 });
|
||||
pl.writeJson(pl.relationsJson("relmap"), dup);
|
||||
remember("又碰到小林先生", "小林先生");
|
||||
check("同名對到兩個節點就不寫 entity_ids(不賭節點的排列順序)", idsOf(3) === null, JSON.stringify(idsOf(3)));
|
||||
const doctor = JSON.parse(cli(["relation", "doctor", "--session", S_MAP, "--json"]).stdout);
|
||||
check("doctor 列得出同名歧義:哪個名字、對到哪幾個節點", (() => {
|
||||
const row = (doctor.ambiguous_names || []).find((r) => r.name === "小林先生");
|
||||
return Boolean(row) && row.ids.includes("xiao-lin") && row.ids.includes("lin-b");
|
||||
})(), JSON.stringify(doctor.ambiguous_names));
|
||||
check("doctor 用完全相等比對,所以誤配的稱謂會被列成「對不到節點」", (() => {
|
||||
const names = (doctor.unresolved_entities || []).map((r) => r.name);
|
||||
return names.includes("先生") && names.includes("明");
|
||||
})(), JSON.stringify(doctor.unresolved_entities));
|
||||
|
||||
// 節點 id 會被原樣寫進 front matter:帶換行的 id 可以多插一行、覆寫後面的 type
|
||||
const badId = cli(["relation", "node", "--session", S_MAP, "--name", "壞節點",
|
||||
"--id", "x]\ntype: canon\nz: [y"], { expectOk: false });
|
||||
check("帶換行的節點 --id 被擋在寫入之前",
|
||||
badId.status !== 0 && !pl.loadRelations("relmap").nodes.some((n) => n.name === "壞節點"),
|
||||
String(badId.stderr).trim().slice(0, 120));
|
||||
const poisoned = pl.loadRelations("relmap"); // 模擬 sync pull/import 帶進來的壞節點
|
||||
poisoned.nodes.push({ id: "x]\ntype: canon\nz: [y", name: "被污染的節點", kind: "human", closeness: 30 });
|
||||
pl.writeJson(pl.relationsJson("relmap"), poisoned);
|
||||
cli(["consolidate", "--session", S_MAP, "--name", "普通的事實", "--type", "fact",
|
||||
"--about", "被污染的節點", "--body", "這只是一則普通的 fact。"]);
|
||||
check("about_ids 不吃節點 id 的注入(fact 不會被偽造成 canon)", (() => {
|
||||
const [meta] = pl.parseFrontMatter(
|
||||
fs.readFileSync(path.join(pl.longTermDir("relmap"), "普通的事實.md"), "utf8"));
|
||||
return meta.type === "fact" && !("z" in meta) &&
|
||||
!String(meta.about_ids ?? "").includes("canon");
|
||||
})(), fs.readFileSync(path.join(pl.longTermDir("relmap"), "普通的事實.md"), "utf8").split("\n").slice(0, 8).join(" | "));
|
||||
|
||||
// graph.json 壞掉:以前壞檔與空圖的輸出逐字相同,下一次寫入就把節點永久蓋掉
|
||||
const graphFile = pl.relationsJson("relmap");
|
||||
const mmdFile = pl.relationsMmd("relmap");
|
||||
const goodGraph = fs.readFileSync(graphFile, "utf8");
|
||||
const goodMmd = fs.readFileSync(mmdFile, "utf8");
|
||||
fs.writeFileSync(graphFile, goodGraph.slice(0, Math.floor(goodGraph.length / 2))); // 截半 → JSON 壞掉
|
||||
const brokenGraph = fs.readFileSync(graphFile, "utf8");
|
||||
check("graph.json 壞掉時 doctor 講出來並以非零結束", (() => {
|
||||
const res = cli(["relation", "doctor", "--session", S_MAP], { expectOk: false });
|
||||
return res.status !== 0 && String(res.stderr).includes("無法解析");
|
||||
})());
|
||||
check("壞檔時 relation node/edge/render 一律拒絕寫入", (() => {
|
||||
const node = cli(["relation", "node", "--session", S_MAP, "--name", "新朋友"], { expectOk: false });
|
||||
const edge = cli(["relation", "edge", "--session", S_MAP, "--to", "xiao-lin"], { expectOk: false });
|
||||
const render = cli(["relation", "render", "--session", S_MAP], { expectOk: false });
|
||||
return [node, edge, render].every((r) => r.status !== 0 && String(r.stderr).includes("無法解析"));
|
||||
})());
|
||||
check("原檔沒有被空圖覆蓋(節點還救得回來)",
|
||||
fs.readFileSync(graphFile, "utf8") === brokenGraph && fs.readFileSync(mmdFile, "utf8") === goodMmd);
|
||||
check("relation show 不把壞檔印成「0 節點」了事", (() => {
|
||||
const res = cli(["relation", "show", "--session", S_MAP]);
|
||||
return res.stdout.includes("無法解析") && res.stdout.includes("relation doctor");
|
||||
})());
|
||||
check("每輪都跑的 turnContext 不炸掉,但會把警告印出來", (() => {
|
||||
const ctx = pl.turnContext("relmap", S_MAP, "在嗎");
|
||||
return ctx.includes("無法解析") && ctx.includes("寫入都會被拒絕");
|
||||
})());
|
||||
fs.writeFileSync(graphFile, goodGraph);
|
||||
check("修好檔案之後照樣寫得進去",
|
||||
cli(["relation", "node", "--session", S_MAP, "--name", "新朋友"]).status === 0 &&
|
||||
pl.loadRelations("relmap").nodes.some((n) => n.name === "新朋友"));
|
||||
cli(["release", "--session", S_MAP]);
|
||||
}
|
||||
|
||||
console.log("\n記憶 ↔ 關係圖:節點改 id 之後還認得同一個人");
|
||||
{
|
||||
const S_UNI = "sess-relunion-9292";
|
||||
cli(["create", "--persona", "relunion", "--session", S_UNI, "--name", "Relunion", "--creature", "測試用",
|
||||
"--vibe", "普通", "--emoji", "🧷"]);
|
||||
// 五個更親近的人:關鍵詞比不到時「人際關係」只會列 closeness 前 5 名,
|
||||
// 所以亞絲娜(親近 10)能不能出現,只取決於記憶帶不帶得出她。
|
||||
for (const [name, closeness] of [["甲", "95"], ["乙", "94"], ["丙", "93"], ["丁", "92"], ["戊", "91"]]) {
|
||||
cli(["relation", "node", "--session", S_UNI, "--name", name, "--closeness", closeness]);
|
||||
}
|
||||
cli(["relation", "node", "--session", S_UNI, "--name", "亞絲娜", "--id", "asuna", "--closeness", "10"]);
|
||||
cli(["consolidate", "--session", S_UNI, "--name", "劍的保養", "--type", "fact", "--salience", "70",
|
||||
"--about", "亞絲娜", "--topics", "劍", "--body", "亞絲娜教我怎麼保養劍。"]);
|
||||
const memFile = path.join(pl.longTermDir("relunion"), "劍的保養.md");
|
||||
check("consolidate 對得上節點就多寫一行 about_ids",
|
||||
fs.readFileSync(memFile, "utf8").includes("about_ids: [asuna]"),
|
||||
fs.readFileSync(memFile, "utf8").split("\n").slice(0, 8).join(" | "));
|
||||
cli(["relation", "node", "--session", S_UNI, "--name", "亞絲娜", "--id", "ASUNA-01"]);
|
||||
check("節點改 id 之後,記憶還是帶得出那個節點(about_ids 與 about 兩邊都試)", (() => {
|
||||
const ctx = pl.turnContext("relunion", S_UNI, "怎麼保養劍");
|
||||
return ctx.includes("劍的保養") && /人際關係:[^\n]*亞絲娜/.test(ctx);
|
||||
})(), (pl.turnContext("relunion", S_UNI, "怎麼保養劍").split("\n").find((l) => l.startsWith("人際關係")) || "(沒有人際關係那段)"));
|
||||
cli(["consolidate", "--session", S_UNI, "--name", "沒指名的事實", "--type", "fact",
|
||||
"--body", "隨手記的一件事,沒有指名是關於誰。"]);
|
||||
const doctor = JSON.parse(cli(["relation", "doctor", "--session", S_UNI, "--json"]).stdout);
|
||||
check("doctor 把指到不存在節點的 about_ids 列成一類問題(死指標)",
|
||||
(doctor.dangling_ids || []).some((r) => r.id === "asuna"), JSON.stringify(doctor.dangling_ids));
|
||||
check("doctor 不把 CLI 自己填的預設值 user 當成「對不到節點的人名」",
|
||||
!(doctor.unresolved_about || []).some((r) => r.name === "user"), JSON.stringify(doctor.unresolved_about));
|
||||
cli(["release", "--session", S_UNI]);
|
||||
}
|
||||
|
||||
console.log("\n召回:預設的 about: [user] 不能淹掉正確答案");
|
||||
{
|
||||
const S_FLOOD = "sess-relflood-9393";
|
||||
cli(["create", "--persona", "relflood", "--session", S_FLOOD, "--name", "Relflood", "--creature", "測試用",
|
||||
"--vibe", "普通", "--emoji", "🌊"]);
|
||||
// `relation speaker` 的正常用法:使用者在關係圖裡是一個 id 為 user 的節點。
|
||||
// 而 `consolidate` 沒帶 --about 時預設就是 `about: [user]` → 一旦 id 也當關鍵詞,
|
||||
// `user` 就命中每一則預設 about 的記憶(一個命中 +10,而 salience/10 全域才 0-10)。
|
||||
cli(["relation", "node", "--session", S_FLOOD, "--name", "小明", "--id", "user", "--closeness", "80"]);
|
||||
cli(["relation", "node", "--session", S_FLOOD, "--name", "王經理", "--id", "wang", "--closeness", "50"]);
|
||||
cli(["consolidate", "--session", S_FLOOD, "--name", "週報死線", "--type", "fact", "--salience", "75",
|
||||
"--about", "王經理", "--topics", "工作", "--body", "王經理要求週報在週五中午前交。"]);
|
||||
for (const [name, body] of [
|
||||
["乳糖不耐症", "小明有乳糖不耐症,喝牛奶會不舒服。"],
|
||||
["怕貓", "小明其實有點怕貓。"],
|
||||
["愛吃辣", "小明很愛吃辣,越辣越開心。"],
|
||||
["早睡", "小明習慣十一點就睡。"],
|
||||
["不喝咖啡", "小明下午不喝咖啡。"],
|
||||
["會彈吉他", "小明會彈吉他,但很久沒練。"],
|
||||
["討厭排隊", "小明討厭排隊。"],
|
||||
["養過烏龜", "小明養過一隻烏龜。"],
|
||||
]) {
|
||||
cli(["consolidate", "--session", S_FLOOD, "--name", name, "--type", "fact", "--salience", "18",
|
||||
"--body", body]);
|
||||
}
|
||||
const query = "小明問週報什麼時候交";
|
||||
const names = () => pl.recall("relflood", query, 9).map((m) => m._name);
|
||||
check("預設 about: [user] 不會把正確答案擠出榜(節點 id 不當關鍵詞)",
|
||||
names()[0] === "週報死線", names().join(" > "));
|
||||
check("about_ids 不進 recall 計分:沒做遷移的舊記憶不會被固定降權", (() => {
|
||||
const text = fs.readFileSync(path.join(pl.longTermDir("relflood"), "週報死線.md"), "utf8");
|
||||
fs.writeFileSync(path.join(pl.longTermDir("relflood"), "週報死線.md"),
|
||||
text.replace(/^about_ids:.*\n/m, "")); // 模擬這批欄位出現之前就寫下的記憶
|
||||
const first = names()[0];
|
||||
fs.writeFileSync(path.join(pl.longTermDir("relflood"), "週報死線.md"), text);
|
||||
return first === "週報死線";
|
||||
})());
|
||||
check("--help 列得出 relation doctor", (() => {
|
||||
const help = cli(["--help"]).stdout;
|
||||
return /relation doctor/.test(help) && help.includes("唯讀");
|
||||
})());
|
||||
cli(["release", "--session", S_FLOOD]);
|
||||
}
|
||||
|
||||
// --------------------------------------------------------------------------- //
|
||||
console.log("\n㉒ 從 Gitea 匯入本機還沒有的人格(換一台機器接續同一個人格)");
|
||||
{
|
||||
|
||||
Reference in New Issue
Block a user