ASR-Android/app/src/test/java/com/k2fsa/sherpa/onnx/SummarizeLogicTest.kt

185 lines
8.0 KiB
Kotlin

package com.k2fsa.sherpa.onnx
import org.junit.Assert.assertEquals
import org.junit.Assert.assertNotNull
import org.junit.Assert.assertNull
import org.junit.Assert.assertTrue
import org.junit.Test
import java.io.File
// ============================================================================
// 摘要流水线纯逻辑 JVM 单测(不依赖设备/native):
// ./gradlew :app:testDebugUnitTest
// 注意:unit test classpath 里放了真实 org.json(build.gradle testImplementation)。
// ============================================================================
class SummarizeLogicTest {
// ---- prompts.md ↔ 内置 BUILTIN 一致性(md 是唯一事实源,漂移即失败) ----
@Test
fun builtinMatchesPromptsMd() {
val md = File("src/main/assets/summarize/prompts.md")
assertTrue("prompts.md not found at ${md.absolutePath}", md.isFile)
val parsed = PromptsLoader.parse(md.readText())
val b = PromptsLoader.BUILTIN
assertEquals("P1 SYSTEM 与 md 不一致", b.p1System, parsed.p1System)
assertEquals("P1 USER 与 md 不一致", b.p1UserTpl, parsed.p1UserTpl)
assertEquals("P2 SYSTEM 与 md 不一致", b.p2System, parsed.p2System)
assertEquals("P2 USER 与 md 不一致", b.p2UserTpl, parsed.p2UserTpl)
}
// ---- 分块 ----
private fun line(spk: String, i: Int) =
"(00:%02d-00:%02d)[%s] %s".format(i % 60, i % 60, spk, "あ".repeat(80) + i)
@Test
fun chunkCoversEveryUtteranceOnceIgnoringOverlap() {
val lines = (0 until 60).map { line(if (it / 7 % 2 == 0) "S0" else "S1", it) }
val blocks = SummarizePipeline.chunk(lines)
// 每条原句(去掉重叠复制)合起来必须完整覆盖
val originals = blocks.flatMap { it.split('\n') }.filter { it.isNotBlank() }
val dedup = LinkedHashSet(originals)
assertEquals("有句子丢失", 60, dedup.size)
assertTrue("块数不合常理", blocks.size in 2..12)
}
@Test
fun chunkNeverSplitsSameSpeakerRun() {
// 每 7 句一组同人轮次;切点不得落在组中间
val lines = ArrayList<String>()
var spk = 0
var k = 0
repeat(70) {
if (it % 7 == 0) { spk++; }
lines.add(line("S$spk", k++))
}
val blocks = SummarizePipeline.chunk(lines)
fun spkOf(l: String): String =
l.substringAfterLast('[').substringBeforeLast(']')
// 相邻块之间:上一块最后一句与下一块第二句(第一句是重叠)必须不同人
for (i in 0 until blocks.size - 1) {
val lastOfPrev = blocks[i].split('\n').last()
val firstRealOfNext = blocks[i + 1].split('\n').drop(1).first()
assertTrue(
"轮次被拆:prev尾=${lastOfPrev.take(30)} next=${firstRealOfNext.take(30)}",
spkOf(lastOfPrev) != spkOf(firstRealOfNext)
)
}
}
@Test
fun chunkOverlapsOneSentence() {
val lines = (0 until 40).map { line("S${it % 3}", it) }
val blocks = SummarizePipeline.chunk(lines)
for (i in 0 until blocks.size - 1) {
val tail = blocks[i].split('\n').last()
val nextHead = blocks[i + 1].split('\n').first()
assertEquals("块 ${i + 1}->${i + 2} 缺少 1 句重叠", tail, nextHead)
}
}
// ---- 概括 JSON 提取(2026-09-16 起单项 schema:{"概括":"..."}) ----
private val goodJson =
"""{"概括":"会議はXを承認,Yは田中が9/30までに担当。Zの進め方は未決。"}"""
@Test
fun extractJsonPlain() = assertEquals(goodJson, SummarizePipeline.extractJson(goodJson))
@Test
fun extractJsonWithFenceAndProse() {
val raw = "はい、更新します。\n```json\n$goodJson\n```\n以上です。"
assertEquals(goodJson, SummarizePipeline.extractJson(raw))
}
@Test
fun extractJsonHandlesBracesInsideStrings() {
val raw = """{"概括":"値は{要確認}なので未決事項として保留"}"""
assertNotNull(SummarizePipeline.extractJson(raw))
}
@Test
fun extractJsonAcceptKeyAliases() {
assertNotNull(SummarizePipeline.extractJson("""{"概要":"会議の要約テキスト"}"""))
assertNotNull(SummarizePipeline.extractJson("""{"总结":"会议内容的一段总结"}"""))
}
@Test
fun extractJsonRejectsGarbageAndMissingKeys() {
assertNull(SummarizePipeline.extractJson("ごめんなさい、分かりません"))
assertNull(SummarizePipeline.extractJson("""{"hello":"world"}"""))
assertNull(SummarizePipeline.extractJson("""{"已定事項":[],}""")) // 不平衡/畸形
assertNull(SummarizePipeline.extractJson("""{"已定事項":[]}""")) // 旧schema无概括键
assertNull(SummarizePipeline.extractJson("""{"概括":""}""")) // 空概括
}
// ---- 成稿清洗 ----
@Test
fun cleanDraftStripsFenceAndThinking() {
val raw = "```markdown\n<|channel>thought\n長い熟考…\n<channel|>\n# 議題\n\n\n\n本文\n```"
val out = SummarizePipeline.cleanDraft(raw)
assertTrue(out.startsWith("# 議題"))
assertTrue(!out.contains("thought"))
assertTrue(!out.contains("```"))
}
// ---- 语言判定 ----
@Test
fun detectLanguageBasics() {
assertEquals("ja", SummarizePipeline.detectLanguage("会議を始めます。本日はよろしくお願いいたします。"))
assertEquals("zh", SummarizePipeline.detectLanguage("今天开会讨论季度预算和市场推广计划"))
assertEquals("en", SummarizePipeline.detectLanguage("Let us review the quarterly budget plan together"))
}
// ---- 语种纯度校验(中→全中/英→全英,专名例外;阈值=句子级混语) ----
@Test
fun scriptViolationEnforcement() {
// 中文纪要 + 少量假名专名(田中さくら)→ 通过(专名例外)
assertNull(SummarizePipeline.scriptViolation(
"zh", "行动项:请 田中さくら 在9月30日前提交预算表,负责人张三。"))
// 中文纪要被日语句子污染 → 违规
assertNotNull(SummarizePipeline.scriptViolation(
"zh", "决定事项:来月の予算を承認しました。本件は保留となります。"))
// 英文纪要混入汉字 → 违规
assertNotNull(SummarizePipeline.scriptViolation(
"en", "Action: 佐藤が担当する。Deliver the draft by Friday and review together."))
// 英文纪要单个汉字公司名(专名级)→ 阈值内通过
assertNull(SummarizePipeline.scriptViolation(
"en", "Decision: approve partnership with 丰田汽车 by Q4."))
// 日文纪要不做字符检查(汉字假名天然混排)
assertNull(SummarizePipeline.scriptViolation("ja", "意思決定:予算を承認。We agree と結論"))
}
// ---- Gemma 4 模板渲染 ----
@Test
fun chatTemplateRendering() {
val p = LlmEngine.renderChatPrompt("SYS ", " USER")
assertEquals(
"<|turn>system\nSYS<turn|>\n<|turn>user\nUSER<turn|>\n<|turn>model\n", p
)
}
// ---- SessionTranscript JSONL 回放 ----
@Test
fun transcriptReplayFoldsEvents() {
val dir = File.createTempFile("mtg", "dir").apply { delete(); mkdirs() }
val f = File(dir, "test.jsonl")
f.writeText(
"""{"t":"H","wall":1000}
{"t":"S","sid":1,"b":0,"e":1000,"x":"第一稿"}
{"t":"S","sid":2,"b":1000,"e":2000,"x":"第二句"}
{"t":"R","sid":1,"x":"精修后第一稿","r":1}
{"t":"P","sid":1,"sp":"S0"}
{"t":"P","sid":2,"sp":"S1"}
{"t":"N","from":"S1","to":"张三"}
""".replace("\n", System.lineSeparator())
)
val out = SessionTranscript.load(f)
assertEquals(2, out.size)
assertEquals("精修后第一稿", out[0].text)
assertTrue(out[0].refined)
assertEquals("S0", out[0].speaker)
assertEquals("张三", out[1].speaker)
assertEquals(1000L, SessionTranscript.fileStartWallMs(f))
dir.deleteRecursively()
}
}