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:
+134
-8
@@ -930,21 +930,31 @@ commands.consolidate = ({ flags }) => {
|
||||
"fact", "preference", "event", "promise", "relationship", "insight", "boundary", "canon", "diary",
|
||||
];
|
||||
if (!VALID_TYPES.includes(type)) die(`--type 只能是 ${VALID_TYPES.join("/")}。`);
|
||||
const about = csv(flags.about).length ? csv(flags.about) : ["user"];
|
||||
// `about` 是自由字串(原樣保留);對得上關係圖節點的才多寫一行 id,之後 recall 與
|
||||
// 「人際關係」那段才知道講的是同一個人。一個都對不上就不輸出這一行。
|
||||
// `resolveRelationRefs` 已經把不能寫進 front matter 的 id 濾掉了(帶換行的節點 id
|
||||
// 可以在這裡多插一行、覆寫下面的 `type`,把一則 fact 變成不該被遺忘的 canon)。
|
||||
const aboutIds = pl.resolveRelationRefs(slug, about);
|
||||
// front matter 的每個值都只能是一行:帶換行的旗標值同樣能偽造出別的欄位,
|
||||
// 而 `parseFrontMatter` 取後出現的值 → 後面宣告的 type/salience 會被前面偽造的蓋掉。
|
||||
const fm = (value) => pl.injectSafeLine(value);
|
||||
const front = [
|
||||
"---",
|
||||
`name: ${name}`,
|
||||
// 原本的 --name(沒被 slugify 吃掉的那個)。下次撞名時就是靠這行認出「不是同一則」。
|
||||
`title: ${rawName.replace(/[\r\n]+/g, " ").replace(/-{3,}/g, "—").trim().slice(0, 120)}`,
|
||||
`type: ${type}`,
|
||||
`about: [${csv(flags.about).join(", ") || "user"}]`,
|
||||
`topics: [${csv(flags.topics).join(", ")}]`,
|
||||
`about: [${about.map(fm).filter(Boolean).join(", ")}]`,
|
||||
...(aboutIds.length ? [`about_ids: [${aboutIds.join(", ")}]`] : []),
|
||||
`topics: [${csv(flags.topics).map(fm).filter(Boolean).join(", ")}]`,
|
||||
`salience: ${num(flags.salience, 60)}`,
|
||||
`emotion: ${str(flags.emotion) || "none"}`,
|
||||
`rules: ${str(flags.rules) || "manual"}`,
|
||||
`emotion: ${fm(str(flags.emotion) || "none")}`,
|
||||
`rules: ${fm(str(flags.rules) || "manual")}`,
|
||||
`first_seen: ${existing.first_seen || today}`,
|
||||
`last_seen: ${today}`,
|
||||
`recall_count: ${existing.recall_count || 0}`,
|
||||
`source: ${str(flags.source) || "short-term"}`,
|
||||
`source: ${fm(str(flags.source) || "short-term")}`,
|
||||
"---",
|
||||
"",
|
||||
body.trim(),
|
||||
@@ -1115,6 +1125,15 @@ commands.relation = ({ flags, positional }) => {
|
||||
const slug = hostOf(flags, session);
|
||||
requireOwner(slug, session);
|
||||
const action = positional[0] || "show";
|
||||
// graph.json 壞掉時「空圖」與「壞檔」不能長得一樣:要做決定的 action 直接擋下來
|
||||
// (放它過去就是拿一張空圖去覆蓋原檔)。doctor 自己會報告,不走這裡。
|
||||
const graphOrDie = () => {
|
||||
const data = pl.loadRelations(slug);
|
||||
if (data._error) {
|
||||
die(`${data._error}\n 先修好這個檔案(或把它移開讓人格從空的關係圖重新開始),再用 \`relation doctor\` 確認。`);
|
||||
}
|
||||
return data;
|
||||
};
|
||||
if (action === "node") {
|
||||
const name = str(flags.name);
|
||||
if (!name) die("`node` 需要 `--name`。");
|
||||
@@ -1146,7 +1165,7 @@ commands.relation = ({ flags, positional }) => {
|
||||
if (action === "style") {
|
||||
const key = str(flags.id) || pl.slugify(str(flags.name) || "");
|
||||
if (!key) die("`style` 需要 `--name`(或 `--id`)。");
|
||||
const data = pl.loadRelations(slug);
|
||||
const data = graphOrDie();
|
||||
const node = data.nodes.find((n) => n.id === key || pl.slugify(n.name || "") === key);
|
||||
if (!node) die(`關係圖裡找不到 \`${str(flags.name) || key}\`,請先用 \`relation node\` 建立。`);
|
||||
const facet = str(flags.facet);
|
||||
@@ -1190,7 +1209,7 @@ commands.relation = ({ flags, positional }) => {
|
||||
}
|
||||
const key = str(flags.id) || pl.slugify(str(flags.name) || "");
|
||||
if (!key) die("`speaker` 需要 `--name`(或 `--id`),取消請用 `--clear`。");
|
||||
const node = pl.loadRelations(slug).nodes.find((n) => n.id === key || pl.slugify(n.name || "") === key);
|
||||
const node = graphOrDie().nodes.find((n) => n.id === key || pl.slugify(n.name || "") === key);
|
||||
if (!node) die(`關係圖裡找不到 \`${str(flags.name) || key}\`,請先用 \`relation node\` 建立。`);
|
||||
config.speaker_node = node.id;
|
||||
pl.writeJson(pl.configPath(slug), config);
|
||||
@@ -1218,13 +1237,115 @@ commands.relation = ({ flags, positional }) => {
|
||||
}
|
||||
if (action === "show") {
|
||||
const data = pl.loadRelations(slug);
|
||||
// 壞檔不能印成「0 節點」了事(那跟空圖逐字相同,看不出東西還在不在)
|
||||
emit(data, flags.json, [
|
||||
`人格 \`${slug}\` 人際關係圖:${data.nodes.length} 節點 / ${data.edges.length} 連線`,
|
||||
...(data._error ? [`⚠ ${data._error}(節點可能還在檔案裡,只是讀不出來 → \`relation doctor\`)`] : []),
|
||||
pl.relationsBrief(slug, null, 20) || "(空)",
|
||||
]);
|
||||
return;
|
||||
}
|
||||
die(`未知 action:${action}(可用 node/edge/style/speaker/render/show)`);
|
||||
// 健檢:記憶裡的人名與關係圖節點對不對得上。**唯讀**——只印報告,一個檔案都不改。
|
||||
if (action === "doctor") {
|
||||
const graph = pl.loadRelations(slug);
|
||||
// 壞檔不能報「節點 0 個、0 問題」(那跟空圖逐字相同,exit 也一樣是 0)。
|
||||
if (graph._error) {
|
||||
die(
|
||||
`${graph._error}\n 健檢無法進行:這不是「沒有關係圖」,是「讀不出來」。` +
|
||||
`\n 所有關係圖的寫入都會被拒絕(免得覆蓋掉原檔),修好之後再跑一次。`,
|
||||
);
|
||||
}
|
||||
const nodes = graph.nodes;
|
||||
const mentioned = new Set(); // 有記憶提到、且對得上節點的 id
|
||||
const missingAbout = new Map();
|
||||
const missingEntities = new Map();
|
||||
const dangling = new Map(); // about_ids/entity_ids 指到不存在的節點
|
||||
const ambiguous = new Map(); // 一個名字對到多個節點 → 兩邊都不寫
|
||||
// `user`/`self` 是 CLI 自己填的佔位字(`consolidate` 沒帶 `--about` 就是 `about: [user]`),
|
||||
// 不是人名。不跳過的話它會是筆數最高那一行,把真正的問題壓到看不見。
|
||||
const PLACEHOLDERS = new Set(["user", "self"]);
|
||||
const bump = (map, key) => map.set(key, (map.get(key) || 0) + 1);
|
||||
const sortByCount = (map) => [...map.entries()].sort((a, b) => b[1] - a[1] || a[0].localeCompare(b[0]));
|
||||
// 用**完全相等**比對,跟 `resolveRelationRefs`(寫入路徑)同一套規則:
|
||||
// 拿 `findRelationNode` 的子字串 fallback 來健檢,會把「猜對的」當成「對上的」,
|
||||
// 於是「先生 → 小林先生」這種誤配在報告上是 0 問題。
|
||||
// front matter/jsonl 的值不保證是陣列(`about: 王經理` 會給一個字串)→ 一律先攤成清單,
|
||||
// 不然 `for..of` 會逐字元跑,一個人名變成一堆單字的假人名。
|
||||
const asList = (v) => (Array.isArray(v) ? v : v === undefined || v === null || v === "" ? [] : [v]);
|
||||
const tally = (names, missing) => {
|
||||
for (const raw of asList(names)) {
|
||||
const name = String(raw).trim();
|
||||
if (!name) continue;
|
||||
const matched = pl.matchRelationNodes(nodes, name);
|
||||
if (matched.length === 1) mentioned.add(matched[0].id);
|
||||
else if (matched.length > 1) ambiguous.set(name, matched.map((n) => String(n.id)));
|
||||
else if (!PLACEHOLDERS.has(name.toLowerCase())) bump(missing, name);
|
||||
}
|
||||
};
|
||||
const tallyIds = (ids) => {
|
||||
for (const raw of asList(ids)) {
|
||||
const id = String(raw).trim();
|
||||
if (!id) continue;
|
||||
// id 不是人名:對不上就是「死指標」(節點被改 id 或被刪掉),單獨列一類
|
||||
if (nodes.some((n) => String(n.id).trim() === id)) mentioned.add(id);
|
||||
else bump(dangling, id);
|
||||
}
|
||||
};
|
||||
for (const meta of pl.longTermEntries(slug)) {
|
||||
tally(meta.about, missingAbout);
|
||||
tallyIds(meta.about_ids);
|
||||
}
|
||||
for (const row of pl.readJsonl(pl.shortTermPath(slug))) {
|
||||
tally(row.entities, missingEntities);
|
||||
tallyIds(row.entity_ids);
|
||||
}
|
||||
const orphans = nodes
|
||||
.filter((n) => !mentioned.has(n.id))
|
||||
.map((n) => ({ id: n.id, name: n.name || n.id, closeness: Number(n.closeness ?? 0) }))
|
||||
.sort((a, b) => b.closeness - a.closeness);
|
||||
const aboutRows = sortByCount(missingAbout);
|
||||
const entityRows = sortByCount(missingEntities);
|
||||
const danglingRows = sortByCount(dangling);
|
||||
const ambiguousRows = [...ambiguous.entries()].sort((a, b) => a[0].localeCompare(b[0]));
|
||||
const lines = [`人格 \`${slug}\` 關係圖健檢(只讀,不改記憶與關係圖):節點 ${nodes.length} 個`];
|
||||
lines.push(`長期記憶 about 對不到節點的人名(${aboutRows.length}):`);
|
||||
lines.push(...(aboutRows.length ? aboutRows.map(([n, c]) => ` - ${n}(${c} 則)`) : [" (無)"]));
|
||||
lines.push(`短期記憶 entities 對不到節點的人名(${entityRows.length}):`);
|
||||
lines.push(...(entityRows.length ? entityRows.map(([n, c]) => ` - ${n}(${c} 筆)`) : [" (無)"]));
|
||||
lines.push(`同名歧義:一個名字對到多個節點,兩邊都不會寫進 about_ids/entity_ids(${ambiguousRows.length}):`);
|
||||
lines.push(...(ambiguousRows.length
|
||||
? ambiguousRows.map(([n, ids]) => ` - ${n} → ${ids.join("/")}(改掉其中一個的名字或 id)`)
|
||||
: [" (無)"]));
|
||||
lines.push(`about_ids/entity_ids 指到不存在的節點(${danglingRows.length}):`);
|
||||
lines.push(...(danglingRows.length
|
||||
? danglingRows.map(([id, c]) => ` - ${id}(${c} 處;節點被改 id 或被刪掉了)`)
|
||||
: [" (無)"]));
|
||||
lines.push(`關係圖裡有節點、但沒有任何記憶提到(${orphans.length}):`);
|
||||
lines.push(...(orphans.length
|
||||
? orphans.map((n) => ` - ${n.name}(${n.id}/親近 ${n.closeness})`)
|
||||
: [" (無)"]));
|
||||
lines.push(
|
||||
`總計:節點 ${nodes.length}/被記憶提到 ${mentioned.size}/沒人提到 ${orphans.length}` +
|
||||
`|對不到節點的人名:長期 ${aboutRows.length} 種、短期 ${entityRows.length} 種` +
|
||||
`|同名歧義 ${ambiguousRows.length} 個|死指標 ${danglingRows.length} 個。`,
|
||||
);
|
||||
emit(
|
||||
{
|
||||
persona: slug,
|
||||
nodes: nodes.length,
|
||||
mentioned: [...mentioned],
|
||||
unresolved_about: aboutRows.map(([name, count]) => ({ name, count })),
|
||||
unresolved_entities: entityRows.map(([name, count]) => ({ name, count })),
|
||||
ambiguous_names: ambiguousRows.map(([name, ids]) => ({ name, ids })),
|
||||
dangling_ids: danglingRows.map(([id, count]) => ({ id, count })),
|
||||
unmentioned_nodes: orphans,
|
||||
},
|
||||
flags.json,
|
||||
lines,
|
||||
);
|
||||
return;
|
||||
}
|
||||
die(`未知 action:${action}(可用 node/edge/style/speaker/render/show/doctor)`);
|
||||
};
|
||||
|
||||
commands.invite = ({ flags }) => {
|
||||
@@ -2210,6 +2331,9 @@ const HELP = `persona.mjs — jsc-persona 人格 / 記憶 / 情緒 / 關係圖 C
|
||||
relation node|edge|render|show --session <id> [--name --id --kind --bond --closeness --trust --note --tags --from --to --label --affinity]
|
||||
relation style --session <id> --name <who> [--facet 稱呼 --value 親愛的 --except anger>=40 --since --clear]
|
||||
relation speaker --session <id> --name <who> | --clear (使用者在關係圖裡是誰 → 決定語氣層)
|
||||
relation doctor --session <id> [--json] 記憶裡的人名對不對得上關係圖節點:對不到的人名、
|
||||
同名歧義、指到不存在節點的 id、沒人提到的節點。
|
||||
**唯讀**(一個檔案都不改);graph.json 壞掉時以非零結束
|
||||
|
||||
多人格對話:
|
||||
invite --session <id> --guest <slug> [--guests <slug,slug>] [--host --room --topic] (自動開啟劇場模式)
|
||||
@@ -2284,6 +2408,8 @@ async function main(argv) {
|
||||
await command({ flags: parsed.flags, positional: parsed._ });
|
||||
} catch (err) {
|
||||
if (err instanceof pl.LockError) die(err.message);
|
||||
// 關係圖壞掉/id 不合法:使用者要看得懂的一行,不是 stack trace
|
||||
if (err instanceof pl.RelationsError) die(err.message);
|
||||
throw err;
|
||||
}
|
||||
return 0;
|
||||
|
||||
Reference in New Issue
Block a user