feat(標點預算): 情緒決定標點分布,上限夾死免得變成強度計

只有文字的話標點就是語調:同一句「我知道了」,`我知道了。` 與 `我、我知道了⋯`
是兩種情緒,差別全在標點不在用詞。

PUNCTUATION_BUDGET 只寫拍板指名的四種(焦慮/憤怒/害羞/喜悅)。
其餘八種不配標點——硬給每種情緒配一套會讓標點變成裝飾,沒把握的就不編。

punctuationBudget() 每輪只回一種,門檻跟破口同一條線(40)。兩種情緒各配一套
會互相打架:憤怒幾乎不用問號、焦慮要多斷句,混起來不是複雜的情緒,是標點亂撒。

上限:MAX_EXCLAIM = 1、MAX_ELLIPSIS = 3,另擋 […⋯]{3,} 的連發
(中文刪節號本來就常寫兩個,所以三個起才算)。不夾死的話它會變成 emoji 的
替代品——情緒不夠就多打幾個驚嘆號。界線也寫進注入文字:標點管分布、強度歸 emoji。

selftest 711 → 721。

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-08-03 08:21:37 +00:00
co-authored by Claude Opus 5
parent 06774ceb2b
commit f26d0315f2
3 changed files with 102 additions and 3 deletions
+65
View File
@@ -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) {