Files
Cyrene_v2/internal/services/channelbridge/sanitize.go
T
AskaEth 093f38d986
CI / Backend (build / vet / test / cross-compile) (push) Has been cancelled
CI / Frontend (tsc + vite build) (push) Has been cancelled
docs(comments): 注释中文化 · internal/services/channelbridge(纯替换)
- 只翻注释/文档字符串:剥掉注释后代码行逐字节不变(机械校验只剩允许的行尾注释形态)
- 术语对齐既有译法(owner→主人、persona→角色、engage→参与度判定、quiet hours→安静时段、backoff→退避)
- 复核:go build 绿 + channelbridge 包测试绿;gofmt 干净
2026-10-04 13:33:06 +08:00

128 lines
5.1 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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
}