relation: 由關係推導語氣層(bond × 親近度 + 節點上的個人化規則)

人格對伴侶與對女兒用同一種溫度講話,因為 kind 只說得出「這是人還是程式」,
說不出「跟我什麼關係」。語氣需要的是後者。

- 關係節點新增 bond(partner/child/parent/sibling/friend/mentor/ally/rival/
  stranger);舊節點沒有的話從 tags/note 推測
- bond × 親近度 → 語氣層查表(伴侶 98 = 老夫老妻、子女 97 = 母親),
  解決「都是 9x 分卻分不出戀人與母女」
- node.style[facet] 記使用者本人要求過的規則(稱呼/敬語/口頭禪/禁忌/習慣),
  優先於查表;不另開檔案,因為關係圖每輪都會被讀進 context,
  這類規則不必跟關鍵詞召回競爭
- style 規則可帶 except(如 anger>=40),情緒成立時該規則暫停、退回預設講法
- relation speaker 指定使用者是關係圖裡的哪個節點,turnContext 每輪注入語氣指示
- 新增 relation style / relation speaker 子指令;selftest +8 項(共 228 項)

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-07-30 07:45:51 +00:00
co-authored by Claude Opus 5
parent b3508a046b
commit 7c46db8e9a
3 changed files with 290 additions and 7 deletions
+44
View File
@@ -983,6 +983,50 @@ check("關係節點換 id 不會長出同名的第二個節點", (() => {
return same.length === 1 && same[0].id === "WELL-01" && same[0].closeness === 40 && same[0].trust === 55 &&
data.edges.some((e) => e.to === "WELL-01");
})());
// --- 語氣層:bond × 親近度 → 距離感,node.style → 他本人要求的稱呼 ------------ //
check("bond 決定語氣層:伴侶 98 是老夫老妻,子女 97 是母親(光看親近度分不出來)", (() => {
cli(["relation", "node", "--session", S_HOST, "--name", "伴侶甲", "--bond", "partner", "--closeness", "98"]);
cli(["relation", "node", "--session", S_HOST, "--name", "女兒乙", "--bond", "child", "--closeness", "97"]);
const nodes = pl.loadRelations("alpha").nodes;
const partner = pl.toneFor(nodes.find((n) => n.name === "伴侶甲"));
const child = pl.toneFor(nodes.find((n) => n.name === "女兒乙"));
return partner.layer === "老夫老妻" && child.layer === "母親/父親" && partner.layer !== child.layer;
})());
check("未知的 --bond 會被擋下", cli(["relation", "node", "--session", S_HOST, "--name", "路人丙", "--bond", "soulmate"]).status !== 0);
check("舊節點沒有 bond 也能從 tags 推測", (() => {
cli(["relation", "node", "--session", S_HOST, "--name", "老朋友丁", "--closeness", "75", "--tags", "摯友"]);
const node = pl.loadRelations("alpha").nodes.find((n) => n.name === "老朋友丁");
delete node.bond; // 模擬 bond 欄位存在之前建立的資料
return pl.inferBond(node) === "friend" && pl.toneFor(node).layer === "摯友";
})());
check("relation style:他要求的稱呼記在關係節點上,不另開檔案", (() => {
const res = cli(["relation", "style", "--session", S_HOST, "--name", "伴侶甲",
"--facet", "稱呼", "--value", "親愛的", "--except", "anger>=40"]);
const node = pl.loadRelations("alpha").nodes.find((n) => n.name === "伴侶甲");
return res.status === 0 && node.style?.["稱呼"]?.value === "親愛的" && node.style["稱呼"].since;
})());
check("語氣規則會被情緒暫停(anger 過門檻就退回預設講法)", (() => {
const node = pl.loadRelations("alpha").nodes.find((n) => n.name === "伴侶甲");
cli(["emotion", "--session", S_HOST, "--apply", "anger=-100", "--trigger", "selftest 平靜"]);
const calm = pl.styleRules("alpha", node);
cli(["emotion", "--session", S_HOST, "--apply", "anger=+90", "--trigger", "selftest 生氣"]);
const angry = pl.styleRules("alpha", node);
cli(["emotion", "--session", S_HOST, "--apply", "anger=-100", "--trigger", "selftest 復原"]);
return calm[0].suspended === false && angry[0].suspended === true;
})());
check("relation speaker 設定後,context 會注入語氣層與稱呼規則", (() => {
const res = cli(["relation", "speaker", "--session", S_HOST, "--name", "伴侶甲"]);
const ctx = pl.turnContext("alpha", S_HOST, "今天過得好嗎");
return res.status === 0 && pl.loadConfig("alpha").speaker_node === pl.slugify("伴侶甲") &&
ctx.includes("語氣層:老夫老妻") && ctx.includes("親愛的");
})());
check("relation speaker --clear 之後就不再注入語氣指示", (() => {
cli(["relation", "speaker", "--session", S_HOST, "--clear"]);
return !pl.loadConfig("alpha").speaker_node &&
!pl.turnContext("alpha", S_HOST, "今天過得好嗎").includes("對話對象:");
})());
check("speaker 指向不存在的人會被擋下",
cli(["relation", "speaker", "--session", S_HOST, "--name", "不存在的人"]).status !== 0);
check("sleeper 型別存在且宣告了唯讀工具限制", (() => {
const md = fs.readFileSync(path.join(HERE, "..", "agents", "persona-sleeper.md"), "utf8");
return md.includes("name: persona-sleeper") && md.includes("disallowedTools") &&