feat(語域): 自稱/句尾/口癖/不說做成第三種 voice kind,四格輪替注入

語氣層原本只有減法(SPEECH_BLACKLIST 刪 AI 腔)與原句樣本。刪完剩下的是「乾淨的
中文」,不是「這個人的中文」——把名字遮掉還認得出是誰,靠的是自稱、句尾、口癖。

做成 voice kind(voice/idiolect.md)而不是 IDENTITY 的新區塊,是為了讓
persona-story 與 persona-anime 天生寫同一格:同一個目錄、同一支 voice add、
同一條 Gitea Wiki 同步路徑(voice/ 是前綴比對,新檔自動涵蓋)。
TODO 要的「兩邊用同一個欄位」因此不必再另外對齊。

四格寫死在 IDIOLECT_FACETS。自由欄位會長出「風格」「語氣」這種什麼都能塞的格子,
注入端就不知道該怎麼講給人格聽。「不說」單獨一格而不是塞進禁忌:禁忌是話題
(不能提的事),不說是用詞(這個人不會用的字),混在一起會讓人格以為某個話題不能碰。

一輪注入 3 格、四格輪替,起點由第幾輪(felt.jsonl 筆數)算出來。不用隨機數——
那是本專案明文禁止的充人味手段,而且同一輪重跑要得到同樣的結果。
一次塞四格會變成模仿腔:他開始逐條照著演,每句都要有口癖。

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-08-03 08:43:48 +00:00
co-authored by Claude Opus 5
parent fe17b20010
commit 45a6f2cad9
2 changed files with 93 additions and 12 deletions
+17 -8
View File
@@ -1233,12 +1233,16 @@ commands.voice = ({ flags, positional }) => {
const kind = str(flags.kind) || "sample";
if (!(kind in pl.VOICE_KINDS)) {
die(`--kind 只能是 ${Object.keys(pl.VOICE_KINDS).join("/")}` +
"sample=他自己講過的原句,reaction=事件對上他做了什麼)。");
"sample=他自己講過的原句,reaction=事件對上他做了什麼idiolect=語域的四格)。");
}
const render = (entry) => (kind === "sample"
? `${entry.text}${[entry.to ? `${entry.to}` : "", entry.scene].filter(Boolean).join("")
? `${[entry.to ? `${entry.to}` : "", entry.scene].filter(Boolean).join("")}` : ""}`
: `${entry.event}${entry.action || "(沒記)"}${entry.emotion ? `${entry.emotion}` : ""}`);
const render = (entry) => {
if (kind === "idiolect") return `${entry.facet}${entry.value}`;
if (kind === "reaction") {
return `${entry.event}${entry.action || "(沒記)"}${entry.emotion ? `${entry.emotion}` : ""}`;
}
const at = [entry.to ? `${entry.to}` : "", entry.scene].filter(Boolean).join("");
return `${entry.text}${at ? `${at}` : ""}`;
};
if (action === "show" || action === "list") {
requireMember(slug, session, Boolean(flags["as-guest"]));
const voice = pl.loadVoice(slug);
@@ -1254,7 +1258,7 @@ commands.voice = ({ flags, positional }) => {
]);
return;
}
const rows = kind === "sample" ? voice.samples : voice.reactions;
const rows = kind === "sample" ? voice.samples : kind === "reaction" ? voice.reactions : voice.idiolect;
const limit = num(flags.limit, 20);
const shown = Number.isFinite(limit) && limit > 0 ? rows.slice(-limit) : rows;
emit({ persona: slug, kind, total: rows.length, entries: shown }, flags.json, [
@@ -1268,11 +1272,16 @@ commands.voice = ({ flags, positional }) => {
if (action === "add") {
const res = kind === "sample"
? pl.addVoiceSample(slug, { text: str(flags.text), to: str(flags.to), scene: str(flags.scene) })
: pl.addVoiceReaction(slug, { event: str(flags.event), action: str(flags.action), emotion: str(flags.emotion) });
: kind === "reaction"
? pl.addVoiceReaction(slug, { event: str(flags.event), action: str(flags.action), emotion: str(flags.emotion) })
: pl.addVoiceIdiolect(slug, { facet: str(flags.facet), value: str(flags.value) });
if (!res) {
die(kind === "sample"
? "需要 `--text`(他自己講過的原句,照抄不要改寫)。"
: "需要 `--event`(發生了什麼事);`--action` 寫他做了什麼,不要寫他感覺到什麼。");
: kind === "reaction"
? "需要 `--event`(發生了什麼事);`--action` 寫他做了什麼,不要寫他感覺到什麼。"
: `需要 \`--facet\`(只能是 ${pl.IDIOLECT_FACETS.join("")})與 \`--value\`` +
"「不說」寫的是**用詞**(這個人不會用的字),不是話題禁忌。");
}
if (!res.added) {
ok(`已經有一模一樣的一條了,沒有重複寫入:${res.line}`);