merge: W55 —— 解析器剥掉落单标签(修后端畸形 <p><p>正文</p> 导致标签字面显示在气泡里)
This commit is contained in:
commit
9ff2de3a1d
3 files changed
+287
-7
No files matched your search
@@ -130,7 +130,8 @@ data class ConclusionParagraph(val parts: List<ContentPart>)
|
||||
*
|
||||
* 该字段承载的是**与聊天消息同一套 Web 端 markup**(真实样本里有连写的空 `<p></p>`、
|
||||
* `<action>` 等),所以这里**不新增任何 markup 规则**,一律委托 [parse]:
|
||||
* 一个 `<p>` 块 = 一段,旁白/动作块依聊天页的归属规则挂在该段内,畸形标签按普通文本回落。
|
||||
* 一个 `<p>` 块 = 一段,旁白/动作块依聊天页的归属规则挂在该段内,畸形标签由 [parse] 兜底
|
||||
* (落单的段落标签剥掉,其余按普通文本回落)。
|
||||
* 空段(`<p></p>` 连写产生的空白)不产出模型项——否则界面上会出现成片空行。
|
||||
* 段内片段顺序取**渲染顺序**:`head` → 正文 → `tail`(与聊天页气泡上/下方旁白的排布一致)。
|
||||
* `null` / 纯空白输入返回空列表。
|
||||
|
||||
@@ -19,7 +19,20 @@ import kotlin.math.min
|
||||
* - 漂移兼容:全角 `*…*`(无关键词时要求去空白后 ≤40 字符且不含换行 → action);
|
||||
* 半角 `*…*` 仅当以 动作/旁白/内心/神态/轻声 开头才算旁白,否则保持普通文本(不吞 Markdown 强调)。
|
||||
* 关键词 旁白/内心 → narration,其余 → action。
|
||||
* - 未闭合/畸形的标签一律回落为普通文本,绝不抛异常。
|
||||
* - 落单标签容错:服务端确实会产出不配对的段落标签(实测形态 `<p><p>正文</p>`:多一个 `<p>`、
|
||||
* 只有一个 `</p>`),块外也可能出现孤立的旁白标签。这类标签一律**剥掉**,绝不落进正文——
|
||||
* 它们本就是服务端产出的标记、不是她说的话;渲染成正文等于往气泡里塞一串用户既看不懂、
|
||||
* 也改不掉的字符,而剥掉只是少显示几个本就不该出现的字符,正文一个字都不动。
|
||||
* 这只是客户端的容错,不代表服务端这样发是对的。
|
||||
* 判据([stripLooseTags])分两种情形:消息里**已有完整块**时,段落结构已被完整块定死,块内
|
||||
* 块外多出来的每个 `<p>` / `</p>`、以及块外孤立的旁白标签,都是冗余噪声,一律剥掉(块内的
|
||||
* 落单旁白标签例外,见最后一条);消息里**一个完整块都没有**时,按上一条整段回落字面文本,
|
||||
* 只再剥两类连「被截断的结构」都算不上的东西:落单的 `</p>`,与后面什么都没有的空开标签。
|
||||
* 只认「确定是标签」的形状:`<p>` / `<p 属性>` / `</p>` / `<action>` 等。`1 < 2`、`<3`、
|
||||
* `a<b`、`<p2>`、`<P>`(词表只有小写,与 `MessageEditText` 同一口径)、`<p`(残缺,没有 `>`)
|
||||
* 都原样保留——见到 `<` 就吃,才是真正会丢字的那种错。
|
||||
* - 未闭合/畸形的其余标签一律回落为普通文本,绝不抛异常。块内没配对的 `<action>` / `</action>`
|
||||
* 也保持字面文本,这是既有契约(`unclosedActionFallsBackToText` / `strayClosingTagStaysText`)。
|
||||
*
|
||||
* `ParsedBubble` 结构(每条气泡渲染一个):
|
||||
* - `body` :气泡正文(仅 Text 部分按原顺序拼接后 trim),已剔除全部旁白/动作块;
|
||||
@@ -37,6 +50,15 @@ import kotlin.math.min
|
||||
* 2) 尾部残缺标签(如 `<act`、`</act`、`<p`),仅当它可能继续补全为合法标签时才扣留;
|
||||
* 3) 最近一个完整标签之后(自由文本区)的孤立全角 `*`;
|
||||
* 4) 自由文本区中可能开启关键词半角星号(如 `*动`)的孤立 `*`。
|
||||
* - 落单标签的剥离**不需要**额外的扣留规则,这是「剥标签与扣留共用同一组标签正则」的直接结果:
|
||||
* 扣留点取「第一个没闭合的开标签」,而落在前缀里的每个 `<p>` / `<action>` 开标签必定在
|
||||
* **同一个前缀内**配到闭合标签(否则它就是那个没闭合的,扣留点会落在它前面)。所以
|
||||
* 「这个标签是不是落单的」在它被产出的那一刻就已经定死,中间态剥掉、收口时也不用再改口。
|
||||
* - 反过来放宽扣留是不行的:把重复的 `<p><p>` 当「外层冗余」让前缀继续推进,`<p>甲<p>乙`
|
||||
* 就会在中间态把 `<p>甲` 当字面文本吐上屏,等 `</p>` 到了又变成 `甲乙`——字会跳。
|
||||
* 剥标签与扣留的标签词表因此必须逐字一致(共用 OPEN_P_TAG_RE / CLOSE_P_TAG_RE /
|
||||
* OPEN_ROLE_TAG_RE / CLOSE_ROLE_TAG_RE,无大小写、无词表扩展),两边一旦分叉,
|
||||
* 就会出现「中间态当正文、收口时又剥掉」的跳动。
|
||||
* - 标签残缺判断只影响「何时产出」,不影响最终结果;扣留期间不会抛异常。
|
||||
* - 本类无副作用、非线程安全,调用方保证单线程喂入即可。
|
||||
*/
|
||||
@@ -91,6 +113,10 @@ private val BLANK_LINE_RE = Regex("\\n\\s*\\n")
|
||||
/** 无关键词的全角星块超过此长度即视为普通文本(对齐 Web `STAR_SHORT_RUNES`)。 */
|
||||
private const val FULL_STAR_MAX_LEN = 40
|
||||
|
||||
/**
|
||||
* 已知标签词表(只有小写,与 `MessageEditText` 同一口径):流式边界判定 [isPotentialTagPrefix]
|
||||
* 补全残缺标签时用;[stripLooseTags] 判「是不是标签」用的是下面这组同词表的正则,两边必须同源。
|
||||
*/
|
||||
private val TAG_NAMES = listOf("action", "narration", "p")
|
||||
private val OPEN_ROLE_TAG_RE = Regex("^<\\s*(action|narration)\\s*>$")
|
||||
private val CLOSE_ROLE_TAG_RE = Regex("^</\\s*(action|narration)\\s*>$")
|
||||
@@ -144,29 +170,112 @@ private sealed interface Tok {
|
||||
object Break : Tok
|
||||
}
|
||||
|
||||
/** 先切 `<p>` 块(决定气泡边界),再在每个文本区做旁白/星号解析。 */
|
||||
/** 先切 `<p>` 块(决定气泡边界),再按区剥落单标签,最后在每个文本区做旁白/星号解析。 */
|
||||
private fun tokenize(raw: String): List<Tok> {
|
||||
val pMatches = P_BLOCK_RE.findAll(raw).toList()
|
||||
val out = ArrayList<Tok>()
|
||||
if (pMatches.isEmpty()) {
|
||||
appendAnnotations(raw, out, splitBlankLines = true)
|
||||
// 一条完整块都没有:`<p>` 及其后内容按既有契约整段回落字面文本,`<action>` 这类旁白标签
|
||||
// 落单时同理(`MessageEditTextTest.没闭合的 action 同样原样留着`)。此处只剥掉连「被截断的
|
||||
// 结构」都算不上的东西:落单的 `</p>`,与后面什么都没有的空开标签。判据见 [stripLooseTags]。
|
||||
appendAnnotations(
|
||||
stripLooseTags(raw, markupMode = false, stripStrayRoleTags = false),
|
||||
out,
|
||||
splitBlankLines = true,
|
||||
)
|
||||
return out
|
||||
}
|
||||
var last = 0
|
||||
for (m in pMatches) {
|
||||
val lead = raw.substring(last, m.range.first)
|
||||
if (lead.isNotEmpty()) appendAnnotations(lead, out, splitBlankLines = false)
|
||||
if (lead.isNotEmpty()) {
|
||||
appendAnnotations(stripLooseTags(lead, true, stripStrayRoleTags = true), out, splitBlankLines = false)
|
||||
}
|
||||
out.add(Tok.Break)
|
||||
val inner = m.groupValues[2]
|
||||
if (inner.isNotEmpty()) appendAnnotations(inner, out, splitBlankLines = false)
|
||||
if (inner.isNotEmpty()) {
|
||||
// 块内只剥 `<p>` 族:`<action>` / `</action>` 落单时保留字面文本,是既有契约
|
||||
// (unclosedActionFallsBackToText / strayClosingTagStaysText 钉住的行为)。
|
||||
appendAnnotations(stripLooseTags(inner, true, stripStrayRoleTags = false), out, splitBlankLines = false)
|
||||
}
|
||||
out.add(Tok.Break)
|
||||
last = m.range.last + 1
|
||||
}
|
||||
val trail = raw.substring(last)
|
||||
if (trail.isNotEmpty()) appendAnnotations(trail, out, splitBlankLines = false)
|
||||
if (trail.isNotEmpty()) {
|
||||
appendAnnotations(stripLooseTags(trail, true, stripStrayRoleTags = true), out, splitBlankLines = false)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
/**
|
||||
* 剥掉「落单标签」,其余字符逐字保留(返回原对象:没剥到东西时不做新分配)。
|
||||
*
|
||||
* [markupMode] = 这条消息里**已有完整 `<p>…</p>` 块**,段落结构已被完整块定死,于是块内/块外
|
||||
* 多出来的每一个 `<p>` / `</p>` 都是冗余噪声,一律剥掉。
|
||||
* 没有完整块时(`parse` 会整段回落字面文本,见 `unclosedParagraphStaysLiteralText`;编辑框也靠
|
||||
* 这条保证不静默改字,见 `MessageEditTextTest.没闭合的 p 不抛异常`)只再剥两类不可能是正文的东西:
|
||||
* 落单的 `</p>`,以及后面什么都没有的**空**开标签——它们连「被截断的段落」都算不上。
|
||||
*
|
||||
* [stripStrayRoleTags] 只对**块外**文本区为 true:那里的 `<action>` / `</action>` 若没被 [ROLE_RE]
|
||||
* 认领(即没配成对),就是落单标签,剥掉。已配对的必须留给 [ROLE_RE] 解析成 Action/Narration,
|
||||
* 提前剥掉等于把旁白降级为正文(`textOutsideParagraphsIsPreserved` 钉住的行为)。
|
||||
*/
|
||||
private fun stripLooseTags(text: String, markupMode: Boolean, stripStrayRoleTags: Boolean): String {
|
||||
if (text.indexOf('<') < 0) return text
|
||||
val roleRanges = if (stripStrayRoleTags) ROLE_RE.findAll(text).map { it.range }.toList() else emptyList()
|
||||
val out = StringBuilder(text.length)
|
||||
var last = 0
|
||||
var i = 0
|
||||
while (i < text.length) {
|
||||
val lt = text.indexOf('<', i)
|
||||
if (lt < 0) break
|
||||
val gt = text.indexOf('>', lt)
|
||||
if (gt < 0) break
|
||||
val tag = text.substring(lt, gt + 1)
|
||||
val drop = when {
|
||||
OPEN_P_TAG_RE.matches(tag) -> markupMode || !hasContentAfter(text, gt + 1)
|
||||
CLOSE_P_TAG_RE.matches(tag) -> true
|
||||
!stripStrayRoleTags -> false
|
||||
roleRanges.any { lt in it } -> false
|
||||
else -> OPEN_ROLE_TAG_RE.matches(tag) || CLOSE_ROLE_TAG_RE.matches(tag)
|
||||
}
|
||||
if (drop) {
|
||||
out.append(text, last, lt)
|
||||
last = gt + 1
|
||||
i = last
|
||||
} else {
|
||||
i = lt + 1
|
||||
}
|
||||
}
|
||||
if (last == 0) return text
|
||||
out.append(text, last, text.length)
|
||||
return out.toString()
|
||||
}
|
||||
|
||||
/** [from] 之后是否还有正文:跳过空白与后续的 `<p>` 族标签后仍非空即为有。 */
|
||||
private fun hasContentAfter(text: String, from: Int): Boolean {
|
||||
var i = from
|
||||
while (i < text.length) {
|
||||
if (text[i].isWhitespace()) {
|
||||
i++
|
||||
continue
|
||||
}
|
||||
if (text[i] == '<') {
|
||||
val gt = text.indexOf('>', i)
|
||||
if (gt >= 0) {
|
||||
val tag = text.substring(i, gt + 1)
|
||||
if (OPEN_P_TAG_RE.matches(tag) || CLOSE_P_TAG_RE.matches(tag)) {
|
||||
i = gt + 1
|
||||
continue
|
||||
}
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
/** 在文本区内按左到右顺序切出 Text / Action / Narration。 */
|
||||
private fun appendAnnotations(text: String, out: MutableList<Tok>, splitBlankLines: Boolean) {
|
||||
var last = 0
|
||||
|
||||
@@ -245,6 +245,109 @@ class ChatContentParserTest {
|
||||
assertTrue(bubbles[0].parts.any { it is ContentPart.Action })
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------
|
||||
// 落单标签容错:宁可剥掉标签,也绝不把标签当正文
|
||||
//
|
||||
// 畸形 markup 来自服务端(客户端只做容错,不代表服务端这样发是对的)。
|
||||
// 这些断言是上面「畸形 / 未闭合」那组的另一面,两边必须同时成立。
|
||||
// ------------------------------------------------------------------
|
||||
|
||||
/** 线上实测的一条畸形消息:第三段多一个 `<p>`,只有一个 `</p>`。 */
|
||||
private val malformedRealMessage =
|
||||
"<p><action>满意地点点头</action></p>\n" +
|
||||
"<p>真乖♪ 这可是今天最让人家放心的一句。</p>\n" +
|
||||
"<p><p>那现在——想让人家陪你看会儿晚霞,还是安静坐着发会儿呆?</p>"
|
||||
|
||||
@Test
|
||||
fun realMalformedMessageRendersCleanBubbles() {
|
||||
val bubbles = parse(malformedRealMessage)
|
||||
assertEquals(
|
||||
listOf(
|
||||
"真乖♪ 这可是今天最让人家放心的一句。",
|
||||
"那现在——想让人家陪你看会儿晚霞,还是安静坐着发会儿呆?",
|
||||
),
|
||||
bubbles.map { it.body },
|
||||
)
|
||||
// 纯旁白块照既有规则依附到下一个有正文的气泡上(head)。
|
||||
assertEquals(listOf(ContentPart.Action("满意地点点头")), bubbles[0].head)
|
||||
assertNoLooseTagInRenderedText(bubbles)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun duplicatedOpeningTagIsDropped() {
|
||||
assertEquals(listOf("正文"), parse("<p><p>正文</p>").map { it.body })
|
||||
}
|
||||
|
||||
@Test
|
||||
fun strayClosingTagIsDropped() {
|
||||
assertEquals(listOf("正文"), parse("<p>正文</p></p>").map { it.body })
|
||||
}
|
||||
|
||||
@Test
|
||||
fun duplicatedOpeningTagInLaterBlockKeepsBubbleSplit() {
|
||||
assertEquals(listOf("甲", "乙"), parse("<p>甲</p><p><p>乙</p>").map { it.body })
|
||||
}
|
||||
|
||||
@Test
|
||||
fun duplicatedOpeningTagKeepsTheNoteAttachedToTheNextParagraph() {
|
||||
val bubble = parse("<p><action>点头</action></p><p><p>乙</p>").single()
|
||||
assertEquals("乙", bubble.body)
|
||||
assertEquals(listOf(ContentPart.Action("点头")), bubble.head)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun duplicatedOpeningTagWithAttributesIsDropped() {
|
||||
assertEquals(listOf("正文"), parse("<p><p class=\"line\">正文</p>").map { it.body })
|
||||
}
|
||||
|
||||
@Test
|
||||
fun looseTagsSurroundedByWhitespaceAreDropped() {
|
||||
assertEquals(listOf("正文"), parse(" <p>\n<p>正文</p>\n</p> ").map { it.body })
|
||||
}
|
||||
|
||||
@Test
|
||||
fun leadingAndTrailingLooseTagsLeaveNoEmptyBubble() {
|
||||
assertEquals(listOf("甲"), parse("</p><p>甲</p></p>").map { it.body })
|
||||
}
|
||||
|
||||
@Test
|
||||
fun loneParagraphTagsProduceNoBubble() {
|
||||
assertTrue(parse("<p>").isEmpty())
|
||||
assertTrue(parse("</p>").isEmpty())
|
||||
assertTrue(parse(" <p> ").isEmpty())
|
||||
assertTrue(parse("<p></p></p>").isEmpty())
|
||||
assertTrue(parse("<p><p>").isEmpty())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun unclosedTagAfterACompleteBlockIsDroppedNotShown() {
|
||||
// 段落结构已被前一个完整块定死,尾部这个没闭合的 `<p>` 只可能是标记。
|
||||
assertEquals(listOf("甲", "乙"), parse("<p>甲</p><p>乙").map { it.body })
|
||||
}
|
||||
|
||||
@Test
|
||||
fun strayRoleTagOutsideParagraphIsDropped() {
|
||||
assertEquals(listOf("甲"), parse("<p>甲</p></action>").map { it.body })
|
||||
assertEquals(listOf("甲"), parse("</action><p>甲</p>").map { it.body })
|
||||
}
|
||||
|
||||
@Test
|
||||
fun plainAngleBracketsAreNeverEaten() {
|
||||
assertEquals(listOf("1 < 2 且 3 > 2"), parse("1 < 2 且 3 > 2").map { it.body })
|
||||
assertEquals(listOf("<3 是想你的意思"), parse("<3 是想你的意思").map { it.body })
|
||||
assertEquals(listOf("a<b>c"), parse("a<b>c").map { it.body })
|
||||
assertEquals(listOf("<div>这是一段 HTML</div>"), parse("<div>这是一段 HTML</div>").map { it.body })
|
||||
assertEquals(listOf("<p2>这不关 p 标签的事</p2>"), parse("<p2>这不关 p 标签的事</p2>").map { it.body })
|
||||
assertEquals(listOf("<P>大小写不认</P>"), parse("<P>大小写不认</P>").map { it.body })
|
||||
assertEquals(listOf("你好<p"), parse("你好<p").map { it.body })
|
||||
}
|
||||
|
||||
@Test
|
||||
fun plainAngleBracketsInsideAParagraphAreNeverEaten() {
|
||||
assertEquals(listOf("甲 1 < 2", "乙"), parse("<p>甲 1 < 2</p><p>乙</p>").map { it.body })
|
||||
assertEquals(listOf("甲<p2>乙"), parse("<p>甲<p2>乙</p>").map { it.body })
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------
|
||||
// 流式增量:最终结果必须与一次性解析一致
|
||||
// ------------------------------------------------------------------
|
||||
@@ -257,6 +360,7 @@ class ChatContentParserTest {
|
||||
<p>你好*弯起眼睛*</p>
|
||||
<p>这是*强调*文字</p>
|
||||
<p>*旁白:其实偷偷开心了一下*</p>
|
||||
<p><p>那现在——想让人家陪你看会儿晚霞,还是安静坐着发会儿呆?</p>
|
||||
<p>晚安。</p>
|
||||
""".trimIndent()
|
||||
|
||||
@@ -315,6 +419,63 @@ class ChatContentParserTest {
|
||||
assertEquals(listOf("第一", "第二"), second.map { it.body })
|
||||
}
|
||||
|
||||
/** 逐字符喂实测畸形消息:中间态任何一帧都不许把标签当正文吐上屏。 */
|
||||
@Test
|
||||
fun streamingRealMalformedMessageNeverLeaksTheTag() {
|
||||
val parser = StreamingContentParser()
|
||||
for (ch in malformedRealMessage) {
|
||||
assertNoLooseTagInRenderedText(parser.onDelta(ch.toString()))
|
||||
}
|
||||
assertEquals(parse(malformedRealMessage), parser.finish())
|
||||
}
|
||||
|
||||
/** 中间态只许「加」,不许改口:已经显示过的正文不能因为后续 delta 变样(那才是跳动)。 */
|
||||
@Test
|
||||
fun streamingRealMalformedMessageOnlyGrowsWhatItAlreadyShowed() {
|
||||
val parser = StreamingContentParser()
|
||||
var shown = emptyList<String>()
|
||||
for (ch in malformedRealMessage) {
|
||||
val bodies = parser.onDelta(ch.toString()).map { it.body }
|
||||
assertTrue("气泡数回退了:$shown → $bodies", bodies.size >= shown.size)
|
||||
shown.forEachIndexed { i, body ->
|
||||
assertTrue("已显示的正文改口了:$body → ${bodies[i]}", bodies[i].startsWith(body))
|
||||
}
|
||||
shown = bodies
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun streamingRandomChunksOfMalformedMessageMatchOneShot() {
|
||||
val expected = parse(malformedRealMessage)
|
||||
val random = Random(20261006)
|
||||
repeat(25) {
|
||||
val parser = StreamingContentParser()
|
||||
var index = 0
|
||||
while (index < malformedRealMessage.length) {
|
||||
val size = 1 + random.nextInt(7)
|
||||
val end = minOf(index + size, malformedRealMessage.length)
|
||||
parser.onDelta(malformedRealMessage.substring(index, end))
|
||||
index = end
|
||||
}
|
||||
assertEquals(expected, parser.finish())
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 重复开标签必须连同它后面的正文一起被扣留到收口。
|
||||
*
|
||||
* 放宽扣留(把重复的 `<p>` 当「外层冗余」、让前缀继续推进)会把 `甲` 先吐上屏,
|
||||
* 等 `</p>` 到了再变成 `甲乙`——用户看到的字会跳,所以这里钉住「中间态一帧都不吐」。
|
||||
*/
|
||||
@Test
|
||||
fun streamingWithholdsDuplicateOpeningTagUntilItsPartnerArrives() {
|
||||
val parser = StreamingContentParser()
|
||||
for (chunk in listOf("<p>甲", "<p>乙", "</p>")) {
|
||||
assertNoLooseTagInRenderedText(parser.onDelta(chunk))
|
||||
}
|
||||
assertEquals(listOf("甲乙"), parser.finish().map { it.body })
|
||||
}
|
||||
|
||||
@Test
|
||||
fun streamingUnclosedActionFallsBackOnFinish() {
|
||||
val parser = StreamingContentParser()
|
||||
@@ -347,4 +508,13 @@ class ChatContentParserTest {
|
||||
assertTrue(parser.onDelta("").isEmpty())
|
||||
assertEquals(listOf("新"), parser.onDelta("<p>新</p>").map { it.body })
|
||||
}
|
||||
|
||||
/** 产出的任何片段里都不许出现段落标签本身——这是落单标签容错的底线。 */
|
||||
private fun assertNoLooseTagInRenderedText(bubbles: List<ParsedBubble>) {
|
||||
for (bubble in bubbles) {
|
||||
for (part in bubble.parts) {
|
||||
assertFalse("标签漏进了正文:${part.text}", part.text.contains("<p") || part.text.contains("</p"))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user