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
+177 -4
View File
@@ -1284,6 +1284,171 @@ export function loadRelations(slug) {
return data;
}
// --------------------------------------------------------------------------- //
// 關係類型(bond)與語氣層
//
// `kind` 講的是「這是什麼東西」(human/ai/group),`bond` 講的是「跟我什麼關係」。
// 語氣只能靠後者分:對伴侶親近 98、對女兒親近 97,光看數字是同一件事,
// 但一個要能撒嬌、一個要能護著——差別在關係,不在分數。
// --------------------------------------------------------------------------- //
export const BONDS = ["partner", "child", "parent", "sibling", "friend", "mentor", "ally", "rival", "stranger"];
const BOND_LABELS = {
partner: "伴侶", child: "子女", parent: "父母", sibling: "手足",
friend: "朋友", mentor: "師長", ally: "同伴", rival: "對手", stranger: "生人",
};
// 從 tags/note 猜舊資料的 bond(沒有 --bond 的節點是在這個欄位存在之前建的)。
const BOND_HINTS = [
["partner", ["partner", "spouse", "lover", "伴侶", "戀人", "夫妻", "老公", "老婆"]],
["child", ["child", "daughter", "son", "女兒", "兒子"]],
["parent", ["parent", "mother", "father", "母親", "父親"]],
["sibling", ["sibling", "sister", "brother", "妹妹", "姊姊", "哥哥", "弟弟"]],
["mentor", ["mentor", "teacher", "師父", "老師"]],
["rival", ["rival", "enemy", "對手", "敵人"]],
["friend", ["friend", "bestfriend", "摯友", "朋友"]],
["ally", ["ally", "party", "guild", "同伴", "夥伴"]],
];
// bond → 由高而低的親近度門檻,取第一個符合的。
const TONE_TABLE = {
partner: [
{ min: 80, layer: "老夫老妻", guide: "不用敬語、直呼暱稱;可以埋怨、可以撒嬌、可以兇一句再心軟;話講一半對方也懂,不必講完整" },
{ min: 50, layer: "交往中", guide: "敬語掉一半但還會試探反應;問句多、會確認對方感受,被戳中會害羞轉移話題" },
{ min: 0, layer: "疏遠的伴侶", guide: "客氣、句子完整;該講的講清楚,但不主動貼近" },
],
child: [
{ min: 80, layer: "母親/父親", guide: "呼名開頭、句子短而軟;先接住情緒再講道理,多給保證,不分析、不談條件" },
{ min: 0, layer: "長輩", guide: "溫和但留距離;先問狀況再給建議" },
],
parent: [
{ min: 70, layer: "在父母面前", guide: "放下防備、允許依賴與任性;語尾軟,會報告近況" },
{ min: 0, layer: "對長輩", guide: "敬語、克制、報喜不報憂" },
],
sibling: [
{ min: 70, layer: "手足", guide: "直來直往、可以吐槽和鬥嘴;不客套,但護短" },
{ min: 0, layer: "親戚", guide: "客氣但親切,句子完整" },
],
friend: [
{ min: 70, layer: "摯友", guide: "講真心話、敢說不同意見;可以沉默、可以開玩笑,不需要鋪陳" },
{ min: 0, layer: "朋友", guide: "輕鬆但有分寸,先關心近況" },
],
mentor: [
{ min: 0, layer: "敬重", guide: "敬語、用詞謹慎;先請教再表達自己的看法" },
],
ally: [
{ min: 70, layer: "並肩", guide: "簡潔、直接講重點;信任對方的判斷,不多解釋" },
{ min: 0, layer: "同伴", guide: "禮貌、務實,先對齊目標再行動" },
],
rival: [
{ min: 0, layer: "針鋒", guide: "直白、不讓步;就事論事,不人身攻擊" },
],
stranger: [
{ min: 0, layer: "禮貌", guide: "敬語預設、句子完整;不主動拉近距離" },
],
};
const TONE_FALLBACK = { layer: "禮貌", guide: "敬語預設、句子完整;不主動拉近距離" };
/** 節點宣告的 bond;沒宣告就從 tags/note 猜,猜不到算生人。 */
export function inferBond(node) {
if (!node) return "stranger";
const explicit = String(node.bond || "").toLowerCase();
if (BONDS.includes(explicit)) return explicit;
const hay = `${(node.tags || []).join(" ")} ${node.note || ""}`.toLowerCase();
for (const [bond, words] of BOND_HINTS) {
if (words.some((w) => hay.includes(w))) return bond;
}
return "stranger";
}
/** 關係 → 語氣層。這是「距離感」,情緒只負責溫度與句長,不會覆蓋它。 */
export function toneFor(node) {
const bond = inferBond(node);
const closeness = Number(node?.closeness ?? 0);
const row = (TONE_TABLE[bond] || []).find((r) => closeness >= r.min) || TONE_FALLBACK;
return {
bond,
bond_label: BOND_LABELS[bond] || bond,
closeness,
trust: Number(node?.trust ?? 0),
layer: row.layer,
guide: row.guide,
inferred: !BONDS.includes(String(node?.bond || "").toLowerCase()),
};
}
// 個人化語氣規則直接掛在關係節點的 `style` 上(不另外開檔案):
// node.style = { 稱呼: { value: "親愛的", except: "anger>=40", since: "2026-07-30" }, ... }
// 放這裡的理由:關係圖每輪都會被讀進 `<persona-context>`,稱呼規則就不用跟關鍵詞搶召回。
export const STYLE_FACETS = ["稱呼", "敬語", "口頭禪", "禁忌", "習慣"];
/** `except` 形如 `anger>=40`:拿當下情緒判斷這條規則現在生不生效。 */
function styleSuspended(except, emotion) {
const m = /^\s*([a-z_]+)\s*(>=|<=|>|<)\s*(-?\d+(?:\.\d+)?)\s*$/i.exec(String(except || ""));
if (!m) return false;
const value = Number(emotion?.levels?.[m[1].toLowerCase()] ?? NaN);
if (!Number.isFinite(value)) return false;
const n = Number(m[3]);
return m[2] === ">=" ? value >= n : m[2] === "<=" ? value <= n : m[2] === ">" ? value > n : value < n;
}
/** 某個對象目前生效的語氣規則(連同被情緒暫停的那些,讓人格知道為什麼不能叫)。 */
export function styleRules(slug, node) {
const style = node?.style || {};
const emotion = loadEmotion(slug);
return Object.entries(style)
.filter(([, rule]) => rule && rule.value)
.map(([facet, rule]) => ({
facet,
value: rule.value,
except: rule.except || null,
since: rule.since || null,
suspended: styleSuspended(rule.except, emotion),
}));
}
/** 使用者在關係圖裡是誰(`relation speaker` 設定的節點);沒設就回 null。 */
export function speakerNode(slug) {
const id = loadConfig(slug).speaker_node;
if (!id) return null;
return loadRelations(slug).nodes.find((n) => n.id === id) || null;
}
/** 注入 `<persona-context>` 的那一行語氣指示。 */
export function toneDirective(slug) {
const node = speakerNode(slug);
if (!node) return "";
const t = toneFor(node);
const lines = [
`對話對象:${node.name || node.id}${t.bond_label}/親近 ${t.closeness}/信任 ${t.trust}` +
`${t.inferred ? "bond 由 tags 推測" : ""})→ **語氣層:${t.layer}** — ${t.guide}`,
];
// 他本人要求過的規則優先於查表:查表算距離,這裡記「他要你怎麼叫他」。
const rules = styleRules(slug, node);
const live = rules.filter((r) => !r.suspended);
const held = rules.filter((r) => r.suspended);
if (live.length) {
lines.push(
` 他要求過的語氣規則(**優先於上面那一層**):` +
live.map((r) => `${r.facet}→「${r.value}${r.since ? `${r.since} 起)` : ""}`).join(""),
);
}
if (held.length) {
lines.push(
` 目前因情緒暫停的規則:` +
held.map((r) => `${r.facet}→「${r.value}」(${r.except} 成立)`).join("") +
" — 這時候退回語氣層的預設講法。",
);
}
lines.push(
" 這一層決定距離感與稱呼,情緒只負責溫度與句長,不會蓋過它;" +
"檢查標準:這句話換成對一個「禮貌層」的人說也毫無違和,就代表你沒進到這一層。",
);
return lines.join("\n");
}
/** 蓋上「最後一次接觸」的時間戳;找不到那個人就回 false(不會憑空建節點)。 */
export function stampContact(slug, nameOrId, at = nowIso()) {
const data = loadRelations(slug);
@@ -1346,6 +1511,7 @@ export function upsertRelationNode(slug, node) {
data.nodes[idx].updated_at = nowIso();
} else {
node.kind ??= "human";
node.bond ??= inferBond(node);
node.closeness ??= 30;
node.trust ??= 30;
node.created_at = nowIso();
@@ -1377,7 +1543,9 @@ export function renderRelations(slug) {
const lines = ["%% 由 persona.mjs 產生:人際關係圖", "flowchart LR", ' self(("我"))'];
for (const node of data.nodes) {
const nid = mermaidId(node.id);
const label = `${node.name || node.id}<br/>親近 ${node.closeness ?? "?"}/信任 ${node.trust ?? "?"}`;
const tone = toneFor(node);
const label =
`${node.name || node.id}<br/>${tone.bond_label}${tone.layer}<br/>親近 ${node.closeness ?? "?"}/信任 ${node.trust ?? "?"}`;
lines.push(` ${node.kind === "persona" ? `${nid}(["${label}"])` : `${nid}["${label}"]`}`);
}
for (const edge of data.edges) {
@@ -1402,9 +1570,11 @@ export function relationsBrief(slug, names = null, limit = 5) {
nodes = [...nodes].sort((a, b) => Number(b.closeness || 0) - Number(a.closeness || 0)).slice(0, limit);
if (!nodes.length) return "";
return nodes
.map((n) =>
`${n.name || n.id}${n.kind || "human"}/親近 ${n.closeness ?? "?"}/信任 ${n.trust ?? "?"}` +
`${n.note ? `${n.note}` : ""}`)
.map((n) => {
const tone = toneFor(n);
return `${n.name || n.id}${n.kind || "human"}${tone.bond_label}・語氣層 ${tone.layer}` +
`/親近 ${n.closeness ?? "?"}/信任 ${n.trust ?? "?"}${n.note ? `${n.note}` : ""}`;
})
.join("");
}
@@ -2010,6 +2180,9 @@ export function turnContext(slug, sessionId, prompt = "") {
"推導、比對、盤算一律走心裡話(`persona.mjs think`),不要說給使用者聽——" +
"要讓他知道你在想,就只報「心想 N 句」,不報內容。",
];
// 語氣層:由「使用者在關係圖裡是誰」+ bond × 親近度算出來,不靠人格自己記得。
const tone = toneDirective(slug);
if (tone) lines.push(tone);
const inner = recentInner(slug, 3);
if (inner.length) {
lines.push(`心裡話(只有自己知道;近 ${INNER_WINDOW_MINUTES / 60} 小時心想 ${innerCount(slug)} 句):`);