Files
Cyrene_v2/internal/services/channelbridge/sanitize.go
T
Aska 817f1e6355
CI / Backend (build / vet / test / cross-compile) (push) Has been cancelled
CI / Frontend (tsc + vite build) (push) Has been cancelled
feat(chat): 动作/旁白格式统一——兼容星号(*…*/*…) + 人设强制 XML
模型常漂移成 *动作:弯起眼睛*(星号),前端/后端只认 <action>/<narration>,导致星号版
没被剥成灰斜体注记/没从 IM 剥除→裸出在气泡、上下文不连贯。
- parseContent.ts(新): 把 XML <action>/<narration>、全角*…*(带'动作/旁白/内心/神态/轻声'
  关键词或整段≤40字)、半角*词…*(仅关键词前缀)统一解析成 ContentPart,剥出正文进 BubbleNotes
  灰斜体注记(*动作:…*); 超长无关键词*…*保留为文本; <p>拆气泡不变
- sanitize.go: stripNarration 同样剥星号动作(与 XML 一致); splitBubbles 不变
- cyrene.yaml: 新增格式铁律——动作/旁白必须用 <action>/<narration>,禁用*/Markdown等
验收: 后端 build/vet/test 全绿 + 前端 build 绿 + Node parseContent/splitBubble 实测(5组)
2026-09-07 20:29:47 +08:00

128 lines
5.4 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"
)
// This file holds the chat-reply sanitizer used before a reply is delivered to an
// IM channel adapter (design/17 §2 + the role-play convention: the Web client
// renders <action>/<narration> as muted italic; IM channels must receive plain chat
// text only, with the action/monologue markup removed).
var (
// pairedRoleplayRe matches a complete <action>...</action> / <narration>...</narration>
// block including its inner text, so an action/monologue is dropped wholesale before
// IM delivery. Non-greedy and case-insensitive to tolerate a model that emits <Action>.
pairedRoleplayRe = regexp.MustCompile(`(?is)<(action|narration)\b[^>]*?>.*?</(action|narration)\s*>`)
// strayRoleplayRe matches any remaining single opening/closing role-play tag (for a
// malformed or unpaired emission).
strayRoleplayRe = regexp.MustCompile(`(?is)</?(action|narration)\b[^>]*?>`)
// paragraphRe matches <p>...</p> / <p/> paragraph markers, keeping the inner text.
paragraphRe = regexp.MustCompile(`(?is)</?p\b[^>]*?>`)
// blankLinesRe collapses runs of blank lines left behind after stripping.
blankLinesRe = regexp.MustCompile(`\n\s*\n+`)
// starRoleplayRe matches a full-width star-wrapped block *...* — the drift
// form when the model forgets the <action>/<narration> XML tags. The body is
// captured so a non-role-play full-width emphasis can be left alone.
starRoleplayRe = regexp.MustCompile(`(?s)*([\s\S]*?)*`)
// halfStarRoleplayRe matches a half-width star block that clearly carries a
// role-play keyword (动作/旁白/内心/神态/轻声), so *动作:弯起眼睛* is stripped
// while plain markdown *emphasis* is left untouched.
halfStarRoleplayRe = regexp.MustCompile(`(?s)\*((?:动作|旁白|内心|神态|轻声)\s*[::]?[\s\S]*?)\*`)
)
// shortRoleplayRunes caps how long a keyword-less full-width star block may be
// and still be treated as a role-play remark (design §9.5: conservative — a long
// keyword-less *...* paragraph is left as text rather than dropped wholesale).
const shortRoleplayRunes = 40
// stripNarration returns the plain chat text of a reply with role-play/action/
// narration markup removed, paragraph markers flattened and blank-line runs
// collapsed. It never raises or panics on malformed input; a reply that was only an
// action/monologue becomes an empty string (caller may then skip the send).
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 reports whether a full-width star block *...* holds a role-play
// remark: it carries a role-play keyword, or it is a short stand-alone fragment
// (no newline, no long body) — the drift shape *弯起眼睛*. A long keyword-less
// paragraph wrapped in stars is kept so a real message is not dropped.
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 reports whether the block starts with a role-play keyword.
func hasRoleplayPrefix(t string) bool {
for _, p := range []string{"动作", "旁白", "内心", "神态", "轻声"} {
if strings.HasPrefix(t, p) {
return true
}
}
return false
}
// paragraphBlockRe matches an entire <p>...</p> block, capturing its inner text
// (design: each <p> is one chat bubble; the Web client renders each as its own
// bubble and the IM adapters deliver each as its own message).
var paragraphBlockRe = regexp.MustCompile(`(?is)<p\b[^>]*?>(.*?)</p\s*>`)
// splitBubbles turns a reply into the list of chat bubbles it carries, in order.
// Each <p>...</p> block is one bubble, with role-play/action/narration stripped from
// its text; empty bubbles are dropped. A reply with no <p> markers is treated as a
// single bubble (newlines preserved). A pure action/monologue yields an empty slice.
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> markers present but no well-formed block — fall back to whole text.
if c := stripNarration(text); c != "" {
out = append(out, c)
}
}
return out
}
// bubbleGap returns a randomized 0.5s..2s delay used between IM outbound bubbles so a
// burst of paragraphs doesn't flood the channel (design/17 §2). The Web frontend is
// not affected — it renders all bubbles from the persisted stream without any delay.
// It is only consulted when a reply carries more than one bubble.
func bubbleGap() time.Duration {
return 500*time.Millisecond + time.Duration(rand.Float64()*1500)*time.Millisecond
}