- 只翻注释/文档字符串:剥掉注释后代码行逐字节不变(机械校验只剩允许的行尾注释形态) - 术语对齐既有译法(owner→主人、persona→角色、engage→参与度判定、quiet hours→安静时段、backoff→退避) - 复核:go build 绿 + channelbridge 包测试绿;gofmt 干净
128 lines
5.1 KiB
Go
128 lines
5.1 KiB
Go
package channelbridge
|
||
|
||
import (
|
||
"math/rand"
|
||
"regexp"
|
||
"strings"
|
||
"time"
|
||
"unicode/utf8"
|
||
)
|
||
|
||
// 本文件承载聊天回复清洗器,用于在回复投递给
|
||
// IM 渠道适配器之前(design/17 §2 + 角色扮演约定:Web 客户端
|
||
// 把 <action>/<narration> 渲染为暗色斜体;IM 渠道只能收到纯聊天
|
||
// 文本,动作/独白标记必须移除)。
|
||
|
||
var (
|
||
// pairedRoleplayRe 匹配完整的 <action>...</action> / <narration>...</narration>
|
||
// 块(含内部文本),因此动作/独白会在投递给
|
||
// IM 前被整体丢弃。非贪婪且不区分大小写,以容忍模型写出 <Action>。
|
||
pairedRoleplayRe = regexp.MustCompile(`(?is)<(action|narration)\b[^>]*?>.*?</(action|narration)\s*>`)
|
||
// strayRoleplayRe 匹配任何残留的单个角色扮演开/闭标签
|
||
// (用于格式错误或未配对的情况)。
|
||
strayRoleplayRe = regexp.MustCompile(`(?is)</?(action|narration)\b[^>]*?>`)
|
||
// paragraphRe 匹配 <p>...</p> / <p/> 段落标记,保留内部文本。
|
||
paragraphRe = regexp.MustCompile(`(?is)</?p\b[^>]*?>`)
|
||
// blankLinesRe 把剥离后残留的连续空行折叠掉。
|
||
blankLinesRe = regexp.MustCompile(`\n\s*\n+`)
|
||
// starRoleplayRe 匹配全角星号包裹块 *...* ——这是模型忘记
|
||
// <action>/<narration> XML 标签时的漂移形式。正文被
|
||
// 捕获,以便非角色扮演的全角强调可以原样保留。
|
||
starRoleplayRe = regexp.MustCompile(`(?s)*([\s\S]*?)*`)
|
||
// halfStarRoleplayRe 匹配明确带有角色扮演
|
||
// 关键词(动作/旁白/内心/神态/轻声)的半角星号块,因此 *动作:弯起眼睛* 会被剥离,
|
||
// 而普通的 markdown *emphasis* 保持不动。
|
||
halfStarRoleplayRe = regexp.MustCompile(`(?s)\*((?:动作|旁白|内心|神态|轻声)\s*[::]?[\s\S]*?)\*`)
|
||
)
|
||
|
||
// shortRoleplayRunes 限定无关键词的全角星号块最长可以是多少
|
||
// 仍算作角色扮演批注(design §9.5:保守起见——长的
|
||
// 无关键词 *...* 段落保留为文本,而不整体丢弃)。
|
||
const shortRoleplayRunes = 40
|
||
|
||
// stripNarration 返回去掉角色扮演/动作/
|
||
// 旁白标记、段落标记被抹平、连续空行被折叠后的
|
||
// 纯聊天文本。它对畸形输入绝不报错或 panic;纯动作/独白的
|
||
// 回复会变成空字符串(调用方可据此跳过发送)。
|
||
func stripNarration(text string) string {
|
||
if strings.TrimSpace(text) == "" {
|
||
return ""
|
||
}
|
||
s := pairedRoleplayRe.ReplaceAllString(text, "")
|
||
s = strayRoleplayRe.ReplaceAllString(s, "")
|
||
s = starRoleplayRe.ReplaceAllStringFunc(s, func(m string) string {
|
||
if isStarRoleplay(strings.TrimSpace(strings.TrimSuffix(strings.TrimPrefix(m, "*"), "*"))) {
|
||
return ""
|
||
}
|
||
return m
|
||
})
|
||
s = halfStarRoleplayRe.ReplaceAllString(s, "")
|
||
s = paragraphRe.ReplaceAllString(s, "")
|
||
s = blankLinesRe.ReplaceAllString(s, "\n")
|
||
return strings.TrimSpace(s)
|
||
}
|
||
|
||
// isStarRoleplay 报告全角星号块 *...* 是否承载角色扮演
|
||
// 批注:它带角色扮演关键词,或者是短的独立片段
|
||
// (无换行、无长正文)——即漂移形态 *弯起眼睛*。长的无关键词
|
||
// 星号包裹段落会保留,以免丢掉真实消息。
|
||
func isStarRoleplay(inner string) bool {
|
||
t := strings.TrimSpace(inner)
|
||
if t == "" {
|
||
return false
|
||
}
|
||
if hasRoleplayPrefix(t) {
|
||
return true
|
||
}
|
||
return !strings.Contains(t, "\n") && utf8.RuneCountInString(t) <= shortRoleplayRunes
|
||
}
|
||
|
||
// hasRoleplayPrefix 报告该块是否以角色扮演关键词开头。
|
||
func hasRoleplayPrefix(t string) bool {
|
||
for _, p := range []string{"动作", "旁白", "内心", "神态", "轻声"} {
|
||
if strings.HasPrefix(t, p) {
|
||
return true
|
||
}
|
||
}
|
||
return false
|
||
}
|
||
|
||
// paragraphBlockRe 匹配整个 <p>...</p> 块并捕获其内部文本
|
||
// (设计:每个 <p> 即一个聊天气泡;Web 客户端各自渲染为一个
|
||
// 气泡,IM 适配器各自投递为一条消息)。
|
||
var paragraphBlockRe = regexp.MustCompile(`(?is)<p\b[^>]*?>(.*?)</p\s*>`)
|
||
|
||
// splitBubbles 把一条回复按顺序拆成它承载的聊天气泡列表。
|
||
// 每个 <p>...</p> 块为一个气泡,其文本已剥离角色扮演/动作/
|
||
// 旁白;空气泡被丢弃。没有 <p> 标记的回复按
|
||
// 单个气泡处理(保留换行)。纯动作/独白得到空切片。
|
||
func splitBubbles(text string) []string {
|
||
if !strings.Contains(strings.ToLower(text), "<p") {
|
||
if c := stripNarration(text); c != "" {
|
||
return []string{c}
|
||
}
|
||
return nil
|
||
}
|
||
out := make([]string, 0)
|
||
for _, m := range paragraphBlockRe.FindAllStringSubmatch(text, -1) {
|
||
if c := stripNarration(m[1]); c != "" {
|
||
out = append(out, c)
|
||
}
|
||
}
|
||
if len(out) == 0 {
|
||
// 有 <p> 标记但没有格式良好的块——回退为整段文本。
|
||
if c := stripNarration(text); c != "" {
|
||
out = append(out, c)
|
||
}
|
||
}
|
||
return out
|
||
}
|
||
|
||
// bubbleGap 返回 IM 出站气泡之间使用的 0.5s..2s 随机延时,
|
||
// 使连发段落不会刷屏(design/17 §2)。Web 前端
|
||
// 不受影响——它从已持久化的流渲染所有气泡,没有任何延时。
|
||
// 仅当一条回复承载多于一个气泡时才会用到它。
|
||
func bubbleGap() time.Duration {
|
||
return 500*time.Millisecond + time.Duration(rand.Float64()*1500)*time.Millisecond
|
||
}
|