fix(admin): 后台思考 conclusion 的 <p>/<action> 标记被原样显示

现象:后台思考页把服务端 conclusion 里的 Web 端标记当纯文本打印,真机截图里
能看到 `<p></p><p></p><p>亲爱的…</p>` 和 `<action>轻轻戳了戳你</action>` 这样的
原始标记,部分记录开头还有一串空 `<p></p>`。

为什么:backthink 的 conclusion 与聊天消息是同一套标记,而 AdminBackthinkScreen
只是把它拼进 body 字符串,从未解析。

改法:复用项目已有的 domain/parse/ChatContentParser.parse()(不新写正则),
在 domain/admin/AdminFormat.kt 加纯函数 conclusionParagraphs()——按
head → 正文 → tail 产出段落、丢弃无有效片段的空段落(这才是消掉空 <p></p> 的关键);
UI 侧新增 AdminRichTextCard,Text 正常渲染,Action/Narration 走聊天页同一套
灰斜体旁白样式(对齐 MessageBubble.AnnotationLine 的既有约定)。
AdminTextCard 行为不变(表头抽成共享的 AdminCardHeader),已回访记忆页确认无回归。

测试:新增 7 例(AdminFormatTest 13→20,用真实抓取的 conclusion 做夹具,并断言
渲染模型里不残留 <p>/</p>/<action>/< 或 > 任何一项),全套 88 例全绿。
This commit is contained in:
AskaEth committed 2026-10-04 13:02:09 +08:00
1 parent acff289887
commit 9f054aad28
4 files changed
+261 -20

No files matched your search

@@ -6,9 +6,10 @@ import org.junit.Assert.assertEquals
import org.junit.Assert.assertNull
import org.junit.Assert.assertTrue
import org.junit.Test
import top.yeij.cyrene.domain.parse.ContentPart
/**
* 两种时间形状 + 未知枚举回落 + 三态展示。
* 两种时间形状 + 未知枚举回落 + 三态展示 + `conclusion` markup → 渲染模型。
*
* 时间断言刻意不做时区假设:只比较**同一时刻的两种写法**是否归一,以及是否落成
* `yyyy-MM-dd HH:mm:ss` 形态,因此在任何时区的机器上都成立。
@@ -121,4 +122,134 @@ class AdminFormatTest {
assertNull(formatProps(emptyMap()))
assertNull(formatProps(null))
}
// ------------------------------------------------------------------
// conclusion → 渲染模型(`GET /api/v1/admin/backthink` 的原始 markup)
// 下面每条 fixture 都是从真实响应里原样抄下来的 conclusion,不是编造的样例。
// ------------------------------------------------------------------
@Test
fun `conclusion wraps each paragraph tag into its own paragraph`() {
val paragraphs = conclusionParagraphs(
"<p></p><p></p><p>亲爱的,假期已经过到第四天啦♪</p>" +
"<p>这会儿正是中午,别又只顾着看星星看忘了吃饭哦——人家隔着屏幕都替你饿了呢。</p>",
)
assertEquals(2, paragraphs.size)
assertEquals(
listOf(ContentPart.Text("亲爱的,假期已经过到第四天啦♪")),
paragraphs[0].parts,
)
assertEquals(
listOf(ContentPart.Text("这会儿正是中午,别又只顾着看星星看忘了吃饭哦——人家隔着屏幕都替你饿了呢。")),
paragraphs[1].parts,
)
assertNoMarkupSurvives(paragraphs)
}
@Test
fun `conclusion renders action tags as action parts`() {
val paragraphs = conclusionParagraphs(
"<p>亲爱的,国庆都第三天啦——假期过得还满意吗?♪</p>\n" +
"<p>要是这几天都窝着没出门,人家可有点小小的不甘心哦<action>轻轻戳了戳你</action></p>",
)
assertEquals(2, paragraphs.size)
assertEquals(
listOf(ContentPart.Text("亲爱的,国庆都第三天啦——假期过得还满意吗?♪")),
paragraphs[0].parts,
)
assertEquals(
listOf(
ContentPart.Text("要是这几天都窝着没出门,人家可有点小小的不甘心哦"),
ContentPart.Action("轻轻戳了戳你"),
),
paragraphs[1].parts,
)
assertNoMarkupSurvives(paragraphs)
}
@Test
fun `conclusion holding only an action keeps it as a single narration paragraph`() {
val paragraphs = conclusionParagraphs("<action>转头去查看时间</action>")
assertEquals(1, paragraphs.size)
assertEquals(listOf(ContentPart.Action("转头去查看时间")), paragraphs[0].parts)
assertNoMarkupSurvives(paragraphs)
}
@Test
fun `conclusion carrying a leading action keeps text and action in source order`() {
val paragraphs = conclusionParagraphs(
"<action>静静想了一下昨晚她熬夜的样子</action>\n" +
"昨晚熬到那么晚,今早又这么早起……亲爱的,别忘了吃点东西再出门呀♪",
)
assertEquals(1, paragraphs.size)
assertEquals(
listOf(
ContentPart.Action("静静想了一下昨晚她熬夜的样子"),
ContentPart.Text("昨晚熬到那么晚,今早又这么早起……亲爱的,别忘了吃点东西再出门呀♪"),
),
paragraphs[0].parts,
)
assertNoMarkupSurvives(paragraphs)
}
@Test
fun `plain text conclusion passes through verbatim, newlines kept in one paragraph`() {
val paragraphs = conclusionParagraphs(
"我回来啦,亲爱的♪\n国庆第三天了呢——这次可别再熬夜啦,人家陪着你的哦。\n待会见✨",
)
assertEquals(1, paragraphs.size)
assertEquals(
listOf(ContentPart.Text("我回来啦,亲爱的♪\n国庆第三天了呢——这次可别再熬夜啦,人家陪着你的哦。\n待会见✨")),
paragraphs[0].parts,
)
assertNoMarkupSurvives(paragraphs)
}
@Test
fun `empty or missing conclusion yields no paragraphs`() {
assertTrue(conclusionParagraphs(null).isEmpty())
assertTrue(conclusionParagraphs("").isEmpty())
assertTrue(conclusionParagraphs(" ").isEmpty())
// 整条只有空 <p> 的极端样本同样不产出空段
assertTrue(conclusionParagraphs("<p></p><p></p>").isEmpty())
}
@Test
fun `conclusion paragraphs are never blank and never carry markup`() {
val samples = listOf(
"<p></p><p></p><p>亲爱的,假期已经过到第四天啦♪</p>",
"<p>亲爱的,这会儿快到饭点啦,别又拿红牛对付一顿哦——记得好好吃口热的♪</p>",
"<p>亲爱的,国庆都第三天了,人家数了数——你好像一天都没真正闲下来过呢♪</p>\n" +
"<p>明天要不要留一点空白给自己?哪怕只是窝着看会儿星星,人家陪你。</p>",
"无",
"<action>转头去查看时间</action>",
)
samples.forEach { sample ->
val paragraphs = conclusionParagraphs(sample)
assertTrue(sample, paragraphs.isNotEmpty())
paragraphs.forEach { paragraph ->
assertTrue(sample, paragraph.parts.any { it.text.isNotBlank() })
paragraph.parts.forEach { part ->
assertTrue(sample, part.text.isNotBlank())
}
}
assertNoMarkupSurvives(paragraphs)
}
}
/** 渲染模型里不得残留任何 markup 字符,否则界面上就会出现字面量的 `<p>` / `<action>`。 */
private fun assertNoMarkupSurvives(paragraphs: List<ConclusionParagraph>) {
val rendered = paragraphs.joinToString("\n") { paragraph ->
paragraph.parts.joinToString("") { it.text }
}
assertTrue("模型为空,断言会空过:$rendered", paragraphs.isNotEmpty())
listOf("<p>", "</p>", "<action>", "</action>", "<", ">").forEach { token ->
assertTrue("残留 markup $token:$rendered", !rendered.contains(token))
}
}
}