diff --git a/TARGET.md b/TARGET.md index 7f479cc..ae4222e 100644 --- a/TARGET.md +++ b/TARGET.md @@ -100,10 +100,16 @@ Q5–Q10 六題已於 2026/08/03 由使用者拍板,設計不再需要確認 —— `emojiAudit()` 掛在 `emotion --audit`,另外算「只有符號沒有句子」那幾則(那才是真的退化) - 驗收:羞恥度 45/70/90 三種輸出的 emoji 不同;把所有 emoji 刪掉之後,句子的形狀仍然分得出是哪一級 -- [ ] **5.2 標點預算(情緒 → 標點與句長的分布)** - - [ ] 把標點納入 `speechLint()` 的可調參數:焦慮 → 刪節號與逗號斷句變多;憤怒 → 句號密度上升、幾乎不用問號; +- [x] **5.2 標點預算(情緒 → 標點與句長的分布)**(2026/08/03 16:21:13 完成) + - [x] 把標點納入 `speechLint()` 的可調參數:焦慮 → 刪節號與逗號斷句變多;憤怒 → 句號密度上升、幾乎不用問號; 害羞 → 疊字與破折;喜悅 → 允許一個驚嘆號 - - [ ] 上限夾死(一則最多一個驚嘆號、刪節號不連發),否則會變成顏文字的替代品 + —— `PUNCTUATION_BUDGET`(**只寫拍板指名的這四種**,其餘不硬配, + 免得標點變成裝飾)+ `punctuationBudget()` 每輪注入,門檻與破口同一條線(40)。 + **只回一種**:兩種情緒各配一套標點會互相打架(憤怒不用問號、焦慮要斷句) + - [x] 上限夾死(一則最多一個驚嘆號、刪節號不連發),否則會變成顏文字的替代品 + —— `MAX_EXCLAIM = 1`、`MAX_ELLIPSIS = 3`,另擋 `[…⋯]{3,}` 的連發 + (中文刪節號常寫兩個,所以三個起才算連發)。 + 界線寫進注入文字:**標點管分布、強度歸 emoji** - 驗收:同一句話在四種情緒下輸出,標點分布看得出差別,且都沒有過量 - [ ] **5.1 訊息的節奏:一輪可以拆成兩則(純文字唯一的「時間」訊號)** diff --git a/scripts/persona-lib.mjs b/scripts/persona-lib.mjs index 05a80f6..b512d26 100644 --- a/scripts/persona-lib.mjs +++ b/scripts/persona-lib.mjs @@ -905,6 +905,44 @@ export function emotionTells(slug, state = null, { min = 40, top = 2 } = {}) { })); } +/** + * 標點預算:情緒 → 標點與句長的分布。 + * + * 只有文字的話,標點就是語調。同一句「我知道了」,`我知道了。` 與 `我、我知道了⋯` + * 是兩種情緒——差別全在標點,不在用詞。 + * + * 這裡只寫四種(拍板時指名的那四種)。其餘情緒不給標點指示:沒有把握的就不編, + * 硬給每一種情緒配一套標點會讓它變成裝飾。 + */ +export const PUNCTUATION_BUDGET = { + anxiety: "刪節號與逗號斷句變多,句子常常沒說完(「我、我知道⋯」)", + anger: "句號密度上升、句子切短;幾乎不用問號——問句是留餘地,生氣的人不留", + shame: "疊字與破折(「才、才不是」「你——不要看這邊」),話講一半就收", + joy: "可以用一個驚嘆號,句子輕、節奏快", +}; + +/** 硬上限:一則最多一個驚嘆號、刪節號不連發。 */ +export const MAX_EXCLAIM = 1; +export const MAX_ELLIPSIS = 3; + +/** + * 這一輪的標點傾向:取主導情緒裡強度夠、而且**有寫標點指示**的那一種。 + * + * 只回一種。兩種情緒各配一套標點會互相打架(憤怒不用問號、焦慮要斷句), + * 混起來的結果不是「複雜的情緒」,是標點亂撒。 + */ +export function punctuationBudget(slug, state = null, { min = 40 } = {}) { + const st = decayEmotion(structuredClone(state ?? loadEmotion(slug))); + const hit = dominant(st, 4).find(({ key, level }) => level >= min && PUNCTUATION_BUDGET[key]); + if (!hit) return null; + return { + key: hit.key, + zh: EMOTIONS[hit.key].zh, + level: Math.round(hit.level), + hint: PUNCTUATION_BUDGET[hit.key], + }; +} + // --------------------------------------------------------------------------- // // 情緒 → emoji(種類表情緒、數量表程度) // @@ -3163,6 +3201,18 @@ export function speechLint(text) { if (emoji.length > EMOJI_HINT_AT) issues.push({ kind: "emoji", level: "hint", count: emoji.length }); const dashes = (raw.match(/——/g) || []).length; if (dashes > 1) issues.push({ kind: "dash", level: "block", count: dashes }); + // 標點預算的上限要夾死,否則它會變成 emoji 的替代品:情緒不夠就多打幾個驚嘆號。 + // 標點是**分布**,不是強度計——強度歸 emoji(那邊才是開放的)。 + const exclaims = (body.match(/!/g) || []).length; + if (exclaims > MAX_EXCLAIM) { + issues.push({ kind: "exclaim", level: "block", count: exclaims, max: MAX_EXCLAIM }); + } + // 連發:`⋯⋯⋯` 以上。中文的刪節號本來就常寫成兩個,所以三個起才算。 + if (/[…⋯]{3,}/.test(raw)) issues.push({ kind: "ellipsis-run", level: "block" }); + const ellipses = (body.match(/[…⋯]/g) || []).length; + if (ellipses > MAX_ELLIPSIS) { + issues.push({ kind: "ellipsis", level: "block", count: ellipses, max: MAX_ELLIPSIS }); + } if (/\*\*|(^|\n)\s*[-*+]\s|(^|\n)#{1,6}\s/.test(raw)) issues.push({ kind: "markdown", level: "block" }); const negParallel = (body.match(/不是[^,。!?]{1,20}而是|不只是[^,。!?]{1,20}更是|不僅[^,。!?]{1,20}更/g) || []).length; if (negParallel > 1) issues.push({ kind: "parallel", level: "block", count: negParallel }); @@ -3192,6 +3242,12 @@ export function speechLintMessage(issue) { return `這一則有 ${issue.count} 個 emoji——沒有上限,但看一下是不是在拿符號代替句子`; case "dash": return `${issue.count} 個破折號——一輪留一個,其他改成逗號或句號`; + case "exclaim": + return `${issue.count} 個驚嘆號,上限 ${issue.max}——情緒的強度歸 emoji,標點只管分布`; + case "ellipsis-run": + return "刪節號連發了(`⋯⋯⋯`)——最多兩個,話講一半的效果靠句子斷掉,不靠符號拉長"; + case "ellipsis": + return `${issue.count} 個刪節號,上限 ${issue.max}——再多就變成語氣的裝飾了`; case "markdown": return "有粗體、清單或標題——講話的人不會排版,收掉"; case "parallel": @@ -5712,6 +5768,15 @@ export function turnContext(slug, sessionId, prompt = "") { "強度不到就不演。", ); } + // 標點預算:只有文字的話,標點就是語調。跟破口同一條門檻(強度不到就不調)。 + const punct = punctuationBudget(slug, state); + if (punct) { + lines.push( + `此刻的標點:${punct.zh} ${punct.level} → ${punct.hint}。` + + `上限夾死——一則最多 ${MAX_EXCLAIM} 個驚嘆號、刪節號不連發(最多兩個)。` + + "標點管的是**分布**,強度歸 emoji;用標點加強度會變成用符號代替情緒。", + ); + } // 情緒的 emoji:種類表情緒、數量表程度。算出來的,不要自己挑 const nowEmoji = emotionEmojiNow(slug, state); if (nowEmoji) { diff --git a/scripts/selftest.mjs b/scripts/selftest.mjs index 233d49b..147e9d5 100644 --- a/scripts/selftest.mjs +++ b/scripts/selftest.mjs @@ -1203,6 +1203,34 @@ console.log("⑥-d 鬧彆扭的階梯與三段式"); `${pl.modestyOf("ownsulk").value}|${ownShape.slice(0, 220)}`); check("沒寫覆寫的人格照舊用預設模板", pl.personaSulk("shyladder").length === 0); } + +// 標點預算:情緒 → 標點與句長的分布。上限夾死,否則變成 emoji 的替代品 +console.log("⑥-e 標點預算"); +check("拍板的四種情緒都有標點指示,而且四種不一樣", (() => { + const keys = ["anxiety", "anger", "shame", "joy"]; + const hints = keys.map((k) => pl.PUNCTUATION_BUDGET[k]); + return hints.every(Boolean) && new Set(hints).size === 4; +})(), JSON.stringify(pl.PUNCTUATION_BUDGET)); +check("沒把握的情緒就不配標點(不硬給十二種)", + Object.keys(pl.PUNCTUATION_BUDGET).length === 4, JSON.stringify(Object.keys(pl.PUNCTUATION_BUDGET))); +check("一個驚嘆號可以", pl.speechBlockers("太好了!").length === 0); +check("兩個驚嘆號會被擋(強度歸 emoji,不歸標點)", + pl.speechBlockers("太好了!真的!").some((i) => i.kind === "exclaim")); +check("刪節號連發會被擋", pl.speechBlockers("我知道⋯⋯⋯").some((i) => i.kind === "ellipsis-run")); +check("兩個刪節號是正常的中文寫法,不擋", + pl.speechBlockers("我知道⋯⋯").every((i) => !String(i.kind).startsWith("ellipsis"))); +check("刪節號整則超過上限會被擋", + pl.speechBlockers("我⋯不知道⋯你說⋯這樣⋯").some((i) => i.kind === "ellipsis")); +check("標點預算取主導情緒,而且只回一種(兩種會互相打架)", (() => { + const p = pl.punctuationBudget("alpha"); + return p !== null && typeof p.hint === "string" && typeof p.key === "string" && p.level >= 40; +})(), JSON.stringify(pl.punctuationBudget("alpha"))); +check("強度不到就不調標點(跟破口同一條線)", + pl.punctuationBudget("alpha", null, { min: 99 }) === null); +check("標點指示有注入,而且講明上限與「強度歸 emoji」", (() => { + const ctxP = pl.turnContext("alpha", S_SPEAK, "那件事後來怎麼了"); + return ctxP.includes("此刻的標點") && ctxP.includes("刪節號不連發") && ctxP.includes("強度歸 emoji"); +})(), pl.turnContext("alpha", S_SPEAK, "那件事後來怎麼了").slice(0, 500)); check("說話規則有注入「話短、日常字、看得見的東西、不要回頭解釋」", (() => { const ctxSay = pl.turnContext("alpha", S_SPEAK, "隨便講"); return ["話短一點", "平常會說的字", "看得見的東西", "不要回頭解釋"].every((w) => ctxSay.includes(w));