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
+69 -3
View File
@@ -986,10 +986,14 @@ commands.relation = ({ flags, positional }) => {
if (action === "node") {
const name = str(flags.name);
if (!name) die("`node` 需要 `--name`。");
const bond = str(flags.bond)?.toLowerCase() || null;
if (bond && !pl.BONDS.includes(bond)) die(`未知的 --bond${bond}(可用 ${pl.BONDS.join("/")}`);
pl.upsertRelationNode(slug, {
id: str(flags.id) || pl.slugify(name),
name,
kind: str(flags.kind) || "human",
// bond 決定語氣層(伴侶/子女/摯友…);kind 只說這是人還是程式,分不出語氣。
bond,
closeness: flags.closeness !== undefined ? pl.clamp(num(flags.closeness, 30)) : null,
trust: flags.trust !== undefined ? pl.clamp(num(flags.trust, 30)) : null,
note: str(flags.note) || null,
@@ -998,10 +1002,70 @@ commands.relation = ({ flags, positional }) => {
last_contact_at: flags.contact ? pl.nowIso() : null,
});
pl.renderRelations(slug);
ok(`關係節點 \`${name}\` 已更新${flags.contact ? "(已記錄本次接觸時間)" : ""}`);
const tone = pl.toneFor(pl.loadRelations(slug).nodes.find((n) => n.id === (str(flags.id) || pl.slugify(name))));
ok(
`關係節點 \`${name}\` 已更新${flags.contact ? "(已記錄本次接觸時間)" : ""}` +
`${tone.bond_label}・語氣層 ${tone.layer}`,
);
pushWikiLater(slug, session, flags);
return;
}
// 他本人要求過的語氣規則 → 直接掛在關係節點上(不另開檔案;關係圖每輪都會被讀)。
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 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);
if (!facet) {
const rules = pl.styleRules(slug, node);
emit({ node: node.id, rules }, flags.json, [
`\`${node.name || node.id}\` 的語氣規則(${rules.length}):`,
...rules.map((r) =>
` - ${r.facet}→「${r.value}${r.except ? `(例外:${r.except}${r.suspended ? ",目前成立→暫停" : ""}` : ""}` +
`${r.since ? `${r.since}` : ""}`),
]);
return;
}
node.style ??= {};
if (flags.clear) {
delete node.style[facet];
ok(`已移除 \`${node.name || node.id}\` 的「${facet}」規則。`);
} else {
const value = str(flags.value);
if (!value) die("`style` 需要 `--value`(移除請用 `--clear`)。");
node.style[facet] = {
value,
except: str(flags.except) || null, // 形如 anger>=40:情緒成立時這條規則暫停
since: str(flags.since) || pl.nowIso().slice(0, 10),
};
ok(`\`${node.name || node.id}\`${facet}→「${value}${str(flags.except) ? `(例外 ${str(flags.except)}` : ""} 已記下。`);
}
node.updated_at = pl.nowIso();
pl.writeJson(pl.relationsJson(slug), data);
pushWikiLater(slug, session, flags);
return;
}
// 使用者在關係圖裡是誰 → 每輪 `<persona-context>` 就能算出該用哪一層語氣說話。
if (action === "speaker") {
const config = pl.loadConfig(slug);
if (flags.clear) {
delete config.speaker_node;
pl.writeJson(pl.configPath(slug), config);
ok("已取消對話對象設定(語氣層回到預設)。");
return;
}
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);
if (!node) die(`關係圖裡找不到 \`${str(flags.name) || key}\`,請先用 \`relation node\` 建立。`);
config.speaker_node = node.id;
pl.writeJson(pl.configPath(slug), config);
const tone = pl.toneFor(node);
ok(`對話對象設為 \`${node.name || node.id}\`${tone.bond_label}・語氣層 ${tone.layer}${tone.guide}`);
return;
}
if (action === "edge") {
const to = str(flags.to);
if (!to) die("`edge` 需要 `--to`。");
@@ -1028,7 +1092,7 @@ commands.relation = ({ flags, positional }) => {
]);
return;
}
die(`未知 action${action}(可用 node/edge/render/show`);
die(`未知 action${action}(可用 node/edge/style/speaker/render/show`);
};
commands.invite = ({ flags }) => {
@@ -1789,7 +1853,9 @@ const HELP = `persona.mjs — jsc-persona 人格 / 記憶 / 情緒 / 關係圖 C
情緒與圖:
emotion --session <id> [--apply joy=+10,...] [--baseline ...] [--trigger <why>]
mindmap show|thread|list --session <id> [--topic <t>] [--force]
relation node|edge|render|show --session <id> [--name --id --kind --closeness --trust --note --tags --from --to --label --affinity]
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 (使用者在關係圖裡是誰 → 決定語氣層)
多人格對話:
invite --session <id> --guest <slug> [--guests <slug,slug>] [--host --room --topic] (自動開啟劇場模式)