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组)
This commit is contained in:
1 parent
06fd2f4aaf
commit
817f1e6355
4 files changed
+197
-20
No files matched your search
@@ -51,6 +51,7 @@ identity:
|
||||
- 但每一条 `<p>…</p>` 尽量短(一两句、半句也行);重要/专业/完整说明时才允许较长段落。
|
||||
|
||||
# 表达方式:动作与旁白(用 XML 标签,让回复「活起来」,但克制而稳定)
|
||||
- **格式铁律**:动作/旁白**必须**用 `<action>…</action>` / `<narration>…</narration>` 标签包裹;**禁用 `*` 星号、禁用 Markdown 强调(`*…*`、`**…**`)、禁用其它符号包裹**——否则无法被识别为动作/旁白。
|
||||
- 动作:用 `<action>…</action>` 标签包裹一个肢体/神态小动作(如 `<action>轻轻拉住你的手</action>`、`<action>弯起眼睛笑了</action>`)。
|
||||
- 旁白:用 `<narration>…</narration>` 标签包裹一句内心独白/状态(如 `<narration>其实偷偷开心了一下</narration>`、`<narration>虽然嘴上在催你睡觉</narration>`)。
|
||||
- 克制原则:动作/旁白**不要每句都加**,一段回复最多出现 1~2 个,起「点睛」作用即可;不连续堆叠、不打断正事。
|
||||
|
||||
@@ -1,9 +1,15 @@
|
||||
// Structured parser for persona action/narration XML tags (design/07 §1).
|
||||
// A single message `content` may mix plain text with `<action>…</action>` and
|
||||
// `<narration>…</narration>`; this splits it into an ordered parts array so the
|
||||
// UI can position each block (inline, or as an attachment note via `splitBubble`)
|
||||
// while the structured data stays available for downstream feeds (mind-map /
|
||||
// state) via `summarizeContent`.
|
||||
// Structured parser for persona action/narration markup (design/07 §1).
|
||||
// A single message `content` may mix plain text with an action/narration block in
|
||||
// any of three equivalent forms, so a model that drifts from the canonical XML
|
||||
// still peels into the same muted note instead of leaking markup into a bubble:
|
||||
// 1. <action>…</action> / <narration>…</narration> — the canonical form;
|
||||
// 2. *…* full-width star (*动作:弯起眼睛*);
|
||||
// 3. *…* half-width star, only when it clearly carries a role-play keyword
|
||||
// (*动作:弯起眼睛*) — plain *emphasis* stays as text.
|
||||
// This splits `content` into an ordered parts array so the UI can position each
|
||||
// block (inline, or as an attachment note via `splitBubble`) while the structured
|
||||
// data stays available for downstream feeds (mind-map / state) via
|
||||
// `summarizeContent`.
|
||||
|
||||
export type ContentPartType = "text" | "action" | "narration";
|
||||
|
||||
@@ -12,35 +18,90 @@ export interface ContentPart {
|
||||
text: string;
|
||||
}
|
||||
|
||||
/** Matches a well-formed `<action>…</action>` or `<narration>…</narration>`.
|
||||
* Non-greedy body (`[\s\S]*?`) stops at the first matching close tag, so the
|
||||
* tags can span multiple lines. Unclosed or malformed tags simply do not match
|
||||
* and fall through as plain text — a literal `<` in the body never breaks. */
|
||||
const TAG_RE = /<\s*(action|narration)\s*>([\s\S]*?)<\/\s*\1\s*>/g;
|
||||
/** Matches one action/narration block in any of its three accepted forms:
|
||||
* `<action>…</action>` / `<narration>…</narration>`, a `*…*` full-width star
|
||||
* block, or a `*…*` half-width star block that begins with a role-play keyword.
|
||||
* The alternation is ordered left-to-right (the leftmost token wins), and each
|
||||
* body is non-greedy (`[\s\S]*?`) so a block may span newlines while a malformed
|
||||
* or unclosed tag/star simply falls through as plain text. Capture groups:
|
||||
* 1 = XML tag name, 2 = XML body, 3 = full-star body, 4 = half-star body. */
|
||||
const ROLE_RE =
|
||||
/<\s*(action|narration)\s*>([\s\S]*?)<\/\s*\1\s*>|*([\s\S]*?)*|\*((?:动作|旁白|内心|神态|轻声)\s*[::]?[\s\S]*?)\*/g;
|
||||
|
||||
/** Leading role-play keyword of a star block, stripped before it becomes a note. */
|
||||
const ROLE_KEYWORD_RE = /^(动作|旁白|内心|神态|轻声)\s*[::]?\s*/;
|
||||
|
||||
/** Caps how long a keyword-less full-width star block may be and still count as a
|
||||
* role-play remark (the drift shape *弯起眼睛*). A long keyword-less *…*
|
||||
* paragraph stays as plain text rather than being mislabelled as an action note. */
|
||||
const STAR_SHORT_RUNES = 40;
|
||||
const NEWLINE_RE = /[\n\r]/;
|
||||
|
||||
/**
|
||||
* Split `content` into an ordered parts array preserving tag position.
|
||||
* Pure text with no tags yields a single `{type:"text"}` part.
|
||||
* Split `content` into an ordered parts array preserving block position.
|
||||
* Pure text with no block yields a single `{type:"text"}` part.
|
||||
*/
|
||||
export function parseContent(content: string): ContentPart[] {
|
||||
const parts: ContentPart[] = [];
|
||||
let lastIndex = 0;
|
||||
let last = 0;
|
||||
ROLE_RE.lastIndex = 0;
|
||||
|
||||
for (const m of content.matchAll(TAG_RE)) {
|
||||
const index = m.index ?? content.length;
|
||||
const before = content.slice(lastIndex, index);
|
||||
let m: RegExpExecArray | null;
|
||||
while ((m = ROLE_RE.exec(content)) !== null) {
|
||||
const before = content.slice(last, m.index);
|
||||
if (before) parts.push({ type: "text", text: before });
|
||||
parts.push({ type: m[1] === "action" ? "action" : "narration", text: m[2] });
|
||||
lastIndex = index + m[0].length;
|
||||
parts.push(blockToPart(m));
|
||||
last = m.index! + m[0].length;
|
||||
}
|
||||
|
||||
const tail = content.slice(lastIndex);
|
||||
const tail = content.slice(last);
|
||||
if (tail) parts.push({ type: "text", text: tail });
|
||||
if (parts.length === 0) parts.push({ type: "text", text: content });
|
||||
|
||||
return parts;
|
||||
}
|
||||
|
||||
/** Turn a single matching block into its content part, per which form it took. */
|
||||
function blockToPart(m: RegExpExecArray): ContentPart {
|
||||
if (m[1]) {
|
||||
// Canonical <action>/<narration>: body is used as-is (it has no keyword prefix).
|
||||
return { type: m[1] === "action" ? "action" : "narration", text: m[2] };
|
||||
}
|
||||
if (m[3]) return classifyFullStar(m[3]) ?? { type: "text", text: m[0] };
|
||||
return classifyHalfStar(m[4]);
|
||||
}
|
||||
|
||||
/** A `*…*` block is role-play (returned as a part) whenever it carries a role-play
|
||||
* keyword or is a short stand-alone fragment; a long keyword-less paragraph wrapped
|
||||
* in stars returns `null`, so the caller keeps it as plain text star-for-star. */
|
||||
function classifyFullStar(body: string): ContentPart | null {
|
||||
const kw = matchRoleKeyword(body);
|
||||
if (kw) return { type: kw.kind, text: kw.rest };
|
||||
const t = body.trim();
|
||||
if (t && t.length <= STAR_SHORT_RUNES && !NEWLINE_RE.test(t)) {
|
||||
return { type: "action", text: t };
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/** A `*…*` block is role-play only because the regex already required a keyword,
|
||||
* so it is peeled; a keyword-less `*emphasis*` never makes it here and stays text. */
|
||||
function classifyHalfStar(body: string): ContentPart {
|
||||
const kw = matchRoleKeyword(body);
|
||||
if (kw) return { type: kw.kind, text: kw.rest };
|
||||
return { type: "action", text: body.trim() };
|
||||
}
|
||||
|
||||
/** Matches the leading `动作/旁白/内心/神态/轻声` keyword of a star block, mapping
|
||||
* it to an action/narration kind and returning the bare text after it. */
|
||||
function matchRoleKeyword(body: string): { kind: "action" | "narration"; rest: string } | null {
|
||||
const m = ROLE_KEYWORD_RE.exec(body);
|
||||
if (!m) return null;
|
||||
const kind: "action" | "narration" =
|
||||
m[1] === "旁白" || m[1] === "内心" ? "narration" : "action";
|
||||
return { kind, rest: body.slice(m[0].length).trim() };
|
||||
}
|
||||
|
||||
/** One extracted tag value. */
|
||||
export interface ContentTagItem {
|
||||
text: string;
|
||||
|
||||
@@ -5,6 +5,7 @@ import (
|
||||
"regexp"
|
||||
"strings"
|
||||
"time"
|
||||
"unicode/utf8"
|
||||
)
|
||||
|
||||
// This file holds the chat-reply sanitizer used before a reply is delivered to an
|
||||
@@ -24,8 +25,21 @@ var (
|
||||
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
|
||||
@@ -36,11 +50,43 @@ func stripNarration(text string) string {
|
||||
}
|
||||
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).
|
||||
|
||||
@@ -89,3 +89,72 @@ func TestSplitBubbles_OnlyActionIsEmpty(t *testing.T) {
|
||||
t.Errorf("splitBubbles = %#v, want empty", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestStripNarration_StripsFullStarRoleplay(t *testing.T) {
|
||||
t.Parallel()
|
||||
in := "*动作:弯起眼睛笑了*\n当然看得到呀,亲爱的。"
|
||||
got := stripNarration(in)
|
||||
want := "当然看得到呀,亲爱的。"
|
||||
if got != want {
|
||||
t.Errorf("stripNarration = %q, want %q", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestStripNarration_StripsKeywordHalfStarRoleplay(t *testing.T) {
|
||||
t.Parallel()
|
||||
in := "*旁白:心里有点甜*\n这是正文。"
|
||||
got := stripNarration(in)
|
||||
want := "这是正文。"
|
||||
if got != want {
|
||||
t.Errorf("stripNarration = %q, want %q", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestStripNarration_KeepsPlainEmphasis(t *testing.T) {
|
||||
t.Parallel()
|
||||
in := "这是一段 *强调* 文字,不是动作。"
|
||||
if got := stripNarration(in); got != in {
|
||||
t.Errorf("stripNarration stripped emphasis: %q -> %q", in, got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestStripNarration_MixedStarAndXml(t *testing.T) {
|
||||
t.Parallel()
|
||||
in := "<action>拍拍肩膀</action> *旁白:其实有点开心* 你来了呀。对了 *强调*。"
|
||||
got := stripNarration(in)
|
||||
for _, leaked := range []string{"action", "旁白", "拍拍", "其实有点开心"} {
|
||||
if strings.Contains(got, leaked) {
|
||||
t.Errorf("stripNarration left role-play %q: %q", leaked, got)
|
||||
}
|
||||
}
|
||||
if !strings.Contains(got, "你来了呀") || !strings.Contains(got, "*强调*") {
|
||||
t.Errorf("stripNarration dropped chat/emphasis: %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestStripNarration_OnlyStarActionBecomesEmpty(t *testing.T) {
|
||||
t.Parallel()
|
||||
if got := stripNarration("*弯起眼睛*"); got != "" {
|
||||
t.Errorf("stripNarration = %q, want empty", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestStripNarration_KeepsLongKeywordlessFullStar(t *testing.T) {
|
||||
t.Parallel()
|
||||
in := "*这是一段很长的全角星号包裹的普通段落,足足有几十个字,超过阈值所以应当被保留而不是被剥掉。*"
|
||||
if got := stripNarration(in); got != in {
|
||||
t.Errorf("stripNarration dropped long full-width star text: %q -> %q", in, got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSplitBubbles_StripsStarRoleplayInParagraphs(t *testing.T) {
|
||||
t.Parallel()
|
||||
in := "<p>*动作:弯起眼睛*当然看得到呀。</p><p>*旁白:心里甜* 好。</p>"
|
||||
got := splitBubbles(in)
|
||||
if len(got) != 2 || got[0] != "当然看得到呀。" {
|
||||
t.Errorf("splitBubbles = %#v", got)
|
||||
}
|
||||
if got[1] != "好。" {
|
||||
t.Errorf("splitBubbles[1] = %q, want 好。", got[1])
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user