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() 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\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\n<|turn>user\nUSER\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() } }