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:
+76
-4
@@ -3613,8 +3613,21 @@ export function innerCount(slug, minutes = INNER_WINDOW_MINUTES) {
|
||||
// 所以讀進來的每一欄都是外部輸入 → 一律過 `injectSafeLine`。
|
||||
// --------------------------------------------------------------------------- //
|
||||
|
||||
export const VOICE_KINDS = { sample: "samples.md", reaction: "reactions.md" };
|
||||
export const VOICE_LABELS = { sample: "語氣樣本", reaction: "情緒反應" };
|
||||
export const VOICE_KINDS = { sample: "samples.md", reaction: "reactions.md", idiolect: "idiolect.md" };
|
||||
export const VOICE_LABELS = { sample: "語氣樣本", reaction: "情緒反應", idiolect: "語域" };
|
||||
|
||||
/**
|
||||
* 語域的四格。
|
||||
*
|
||||
* 語氣樣本是**減法之外的加法**:`SPEECH_BLACKLIST` 刪掉 AI 腔之後,剩下的是「乾淨的
|
||||
* 中文」,不是「這個人的中文」。自稱、句尾、口癖才是把名字遮掉還認得出是誰的東西。
|
||||
*
|
||||
* 「不說」單獨一格而不是塞進禁忌:禁忌是**話題**(不能提的事),不說是**用詞**
|
||||
* (這個人不會用的字)。混在一起會讓人格以為某個話題不能碰。
|
||||
*/
|
||||
export const IDIOLECT_FACETS = ["自稱", "句尾", "口癖", "不說"];
|
||||
/** 每輪最多注入幾格語域(跟語氣樣本一樣:全注入會變成模仿腔)。 */
|
||||
export const IDIOLECT_INJECT_MAX = 3;
|
||||
// 每輪最多注入幾條。全注入會變成模仿腔——他會開始照抄自己的舊台詞,
|
||||
// 那比沒有語氣樣本更糟(TODO 故事匯入階段 4 明寫)。
|
||||
export const VOICE_INJECT_MAX = 3;
|
||||
@@ -3624,6 +3637,7 @@ export const VOICE_KEEP = 400; // 一個檔最多認幾條(手改可以更多
|
||||
const VOICE_FIELDS = {
|
||||
sample: [["text", "原句"], ["to", "對象"], ["scene", "場合"]],
|
||||
reaction: [["event", "事件"], ["action", "反應"], ["emotion", "情緒"]],
|
||||
idiolect: [["facet", "格"], ["value", "值"]],
|
||||
};
|
||||
|
||||
const VOICE_HEADERS = {
|
||||
@@ -3641,6 +3655,15 @@ const VOICE_HEADERS = {
|
||||
"`- 事件:<發生什麼>|反應:<他做了什麼>|情緒:<當時的情緒>`",
|
||||
"",
|
||||
],
|
||||
idiolect: [
|
||||
"# 語域",
|
||||
"",
|
||||
"把名字遮掉,光看三句話能認出是誰——靠的是這四格:",
|
||||
"`- 格:自稱|值:我、俺`(其餘:句尾/口癖/不說)",
|
||||
"",
|
||||
"「不說」是**用詞**(這個人不會用的字),不是話題禁忌。",
|
||||
"",
|
||||
],
|
||||
};
|
||||
|
||||
export function voicePath(slug, kind = "sample") {
|
||||
@@ -3716,10 +3739,22 @@ export const addVoiceSample = (slug, { text, to = "", scene = "" } = {}) =>
|
||||
export const addVoiceReaction = (slug, { event, action = "", emotion = "" } = {}) =>
|
||||
addVoiceLine(slug, "reaction", { event, action, emotion });
|
||||
|
||||
/**
|
||||
* 加一格語域。
|
||||
*
|
||||
* `facet` 必須是 `IDIOLECT_FACETS` 其中一格——自由欄位會長出「語氣」「風格」這種
|
||||
* 什麼都能塞的格子,注入端就不知道該怎麼講給人格聽。
|
||||
*/
|
||||
export function addVoiceIdiolect(slug, { facet, value } = {}) {
|
||||
const key = String(facet || "").trim();
|
||||
if (!IDIOLECT_FACETS.includes(key)) return null;
|
||||
return addVoiceLine(slug, "idiolect", { facet: key, value });
|
||||
}
|
||||
|
||||
/** 讀整份語氣檔(`{ samples, reactions }`)。壞行、缺主欄位的行直接跳過。 */
|
||||
export function loadVoice(slug) {
|
||||
const out = { samples: [], reactions: [] };
|
||||
const bucket = { sample: "samples", reaction: "reactions" };
|
||||
const out = { samples: [], reactions: [], idiolect: [] };
|
||||
const bucket = { sample: "samples", reaction: "reactions", idiolect: "idiolect" };
|
||||
for (const kind of Object.keys(VOICE_KINDS)) {
|
||||
let text;
|
||||
try {
|
||||
@@ -3783,6 +3818,39 @@ export function voiceBrief(slug, { limit = VOICE_INJECT_MAX, to = null, hint = "
|
||||
return lines.join("\n");
|
||||
}
|
||||
|
||||
/**
|
||||
* 這一輪要注入的語域(最多 `IDIOLECT_INJECT_MAX` 格)。
|
||||
*
|
||||
* 四格輪替,起點由**第幾輪**算出來(`felt.jsonl` 的筆數),不用隨機數——
|
||||
* 隨機是這個專案明文禁止的充人味手段,而且會讓同一輪重跑出現不同結果。
|
||||
*
|
||||
* 一輪塞四格會變成模仿腔:他開始逐條照著演,每句都要有口癖。
|
||||
*/
|
||||
export function idiolectBrief(slug, { limit = IDIOLECT_INJECT_MAX, turn = null } = {}) {
|
||||
const rows = loadVoice(slug).idiolect;
|
||||
if (!rows.length) return "";
|
||||
const byFacet = new Map();
|
||||
for (const row of rows) {
|
||||
if (!IDIOLECT_FACETS.includes(row.facet) || !row.value) continue;
|
||||
if (!byFacet.has(row.facet)) byFacet.set(row.facet, []);
|
||||
byFacet.get(row.facet).push(row.value);
|
||||
}
|
||||
const present = IDIOLECT_FACETS.filter((f) => byFacet.has(f));
|
||||
if (!present.length) return "";
|
||||
const max = Math.max(1, Math.min(Number(limit) || IDIOLECT_INJECT_MAX, IDIOLECT_INJECT_MAX));
|
||||
const t = Number.isFinite(Number(turn)) ? Number(turn) : readJsonl(feltPath(slug), 400).length;
|
||||
const start = ((t % present.length) + present.length) % present.length;
|
||||
const take = Math.min(max, present.length);
|
||||
const picked = Array.from({ length: take }, (_, i) => present[(start + i) % present.length]);
|
||||
const rest = present.length - take;
|
||||
return (
|
||||
`語域(這一輪 ${take} 格${rest > 0 ? `,另外 ${rest} 格下一輪輪到` : ""}):` +
|
||||
picked.map((f) => `${f}→${byFacet.get(f).slice(-3).join("、")}`).join(";") + "。\n" +
|
||||
" 這是**加法**:黑名單刪掉的是 AI 腔,剩下的是乾淨的中文、不是他的中文,這幾格把他自己的講法補回去。" +
|
||||
"照著用,但不要每句都塞——把名字遮掉、光看三句話認得出是誰就夠了。"
|
||||
);
|
||||
}
|
||||
|
||||
// --------------------------------------------------------------------------- //
|
||||
// 故事匯入(`memory/import/<work>/`):機械的那半
|
||||
//
|
||||
@@ -5902,6 +5970,10 @@ export function turnContext(slug, sessionId, prompt = "") {
|
||||
try {
|
||||
const voice = voiceBrief(slug, { to: speakerNode(slug)?.name || null, hint: prompt });
|
||||
if (voice) lines.push(voice);
|
||||
// 語域是語氣層的**加法面**(自稱/句尾/口癖/不說),跟原句同一個目錄、
|
||||
// 同一支 `voice add`——所以 persona-story 與 persona-anime 寫的是同一格。
|
||||
const idiolect = idiolectBrief(slug);
|
||||
if (idiolect) lines.push(idiolect);
|
||||
} catch {
|
||||
/* 語氣檔壞掉不該讓整輪掛掉 */
|
||||
}
|
||||
|
||||
+17
-8
@@ -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}`);
|
||||
|
||||
Reference in New Issue
Block a user