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:
+177
-4
@@ -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)} 句):`);
|
||||
|
||||
Reference in New Issue
Block a user