模型常漂移成 *动作:弯起眼睛*(星号),前端/后端只认 <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组)
128 lines
5.4 KiB
Go
128 lines
5.4 KiB
Go
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
|
||
}
|