185 lines
8.0 KiB
Kotlin
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()
|
|
}
|
|
}
|