package com.k2fsa.sherpa.onnx import android.content.res.AssetManager import android.util.Log import org.json.JSONObject // ============================================================================ // SummarizePipeline — §5 摘要流水线(分块 → 滚动笔记 → 成稿) // // 提示词唯一事实源是 assets/summarize/prompts.md(用户可直接编辑/复制到 PC // llama.cpp 调试)。解析失败时退回本文件内置的同源副本 —— 两份不一致以 md // 为准,改提示词请同步改 md。 // // 只在摘要专用后台线程上 new 出来跑完就丢,绝不触碰录音/主线程对象。 // ============================================================================ class SummarizeCancelledException : Exception("summarize cancelled") /** 空笔记防线(用户要求 2026-09-11):笔记/成稿为空 → 提示并自动取消,不落盘。 */ class SummarizeEmptyException(msg: String) : Exception(msg) data class Prompts( val p1System: String, val p1UserTpl: String, val p2System: String, val p2UserTpl: String, ) data class MeetingSummary( val markdown: String, val language: String, // ja / zh / en val notesJson: String, val totalChunks: Int, val failedChunks: Int, ) interface SummarizeListener { /** stage 人话描述("正在加载模型…"、"第 3/12 块…")。在摘要线程回调。 */ fun onProgress(stage: String, done: Int, total: Int) fun isCancelled(): Boolean } // ---- prompts.md 解析 -------------------------------------------------------- object PromptsLoader { private const val TAG = "sherpa-onnx" private const val ASSET = "summarize/prompts.md" fun load(assets: AssetManager): Prompts { val md = try { assets.open(ASSET).bufferedReader().use { it.readText() } } catch (t: Throwable) { Log.w(TAG, "prompts.md missing/unreadable, using builtin: $t") return BUILTIN } return try { parse(md) } catch (t: Throwable) { Log.e(TAG, "prompts.md parse failed, using builtin", t) BUILTIN } } // "## P1 …" 小节里前两个代码围栏块依次是 SYSTEM、USER;"## P2" 同理。 internal fun parse(md: String): Prompts { fun section(marker: String): Pair { val start = md.indexOf(marker) require(start >= 0) { "section $marker not found" } val end = md.indexOf("\n## ", start + marker.length).let { if (it < 0) md.length else it } val body = md.substring(start, end) val fences = ArrayList() var i = 0 while (true) { val open = body.indexOf("```", i) if (open < 0) break val close = body.indexOf("```", open + 3) if (close < 0) break fences.add(body.substring(open + 3, close).trim('\n').trim()) i = close + 3 } require(fences.size >= 2) { "$marker: need 2 fenced blocks, got ${fences.size}" } return fences[0] to fences[1] } val (p1s, p1u) = section("## P1") val (p2s, p2u) = section("## P2") require(p1s.isNotBlank() && p2s.isNotBlank()) return Prompts(p1s, p1u, p2s, p2u) } // 与 prompts.md 同源的内置副本(md 丢失/被改坏时的兜底;2026-09-14 起中文)。 val BUILTIN = Prompts( p1System = "你是处理会议转写记录的笔记员。请把【新的断片】的内容合并进给定的【当前笔记】,只以JSON输出更新后的笔记。\n\n" + "严格要求:\n" + "- 只输出符合指定schema的JSON对象;禁止说明文字、代码围栏、开场语。\n" + "- schema的键名必须原样输出,不得翻译、转写或改写(程序按这些键校验)。\n" + "- 笔记内容用中文书写;人名、组织名、产品名等专有名词保留原文写法。\n" + "- 不得把输入笔记原样照抄:必须从【新的断片】中提取至少一条笔记尚未覆盖的信息并合并;断片确实没有新信息时,保留原笔记并只更新「前回末尾」。\n" + "- 数字、金额、日期时间、期限、次数、单位按原文准确转记,不许模糊概括。\n" + "- 不得删除否定、保留、反对的意图;「暂缓」「再议」「反对意见」本身就是重要信息。\n" + "- 每个条目在「出典」字段给出依据发言的时间段(格式:\"mm:ss-mm:ss\",多个用\";\"分隔)。\n" + "- 总计超过500字时,合并删除低价值的旧条目压缩;「行動項目」和「已定事項」最后才允许删。\n" + "- 「前回末尾」用一句话写最近的议题,供下一个断片衔接上下文。\n" + "- JSON字符串值内需要双引号时转义为\\\";换行用\\n。\n\n" + "schema(键名禁止改动):\n" + "{\"已定事項\":[{\"内容\":\"\",\"出典\":\"\"}],\"討論中\":[{\"内容\":\"\",\"出典\":\"\"}],\"行動項目\":[{\"内容\":\"\",\"担当者\":\"\",\"期限\":\"\",\"出典\":\"\"}],\"人物\":[{\"名前\":\"\",\"立場\":\"\"}],\"前回末尾\":\"\"}", p1UserTpl = "【当前笔记】\n{notes_json}\n\n" + "【新的断片】(时间戳为会议开始的 分:秒,[]内是说话人标签)\n{chunk_text}\n\n" + "请输出更新后的笔记JSON。", p2System = "你是会议的官方纪要撰写者。收到会议笔记(JSON)与术语表后,用指定成稿语言输出完整的Markdown会议纪要。\n\n" + "严格要求:\n" + "- 输出语言严格遵守指定的「成稿语言」,用该语言的书面体撰写。\n" + "- 正文只用成稿语言的文字书写:中文稿不得出现日文假名;英文稿不得出现汉字与假名;日文稿除产品名外不得残留英文句子。\n" + "- 人物、公司、品牌等专有名词可保留笔记/术语表中的原文写法(上述文字限制的例外)。\n" + "- 章节标题必须依序为「议题与经过」「决定事项」「行动项」「未决与待议」;成稿语言非中文时翻译成该语言的等价标题;没有条目的章节明确写\"(无)\"。\n" + "- 行动项用表格输出,列为 负责人 / 内容 / 期限 / 出处;负责人不明时写\"待定\"。\n" + "- 每个条目末尾给出处,格式 `出处: mm:ss-mm:ss`;出处只允许使用笔记JSON中「出典」字段的值,不得编造。\n" + "- 不得添加笔记中没有的事实、推测或补充说明。\n" + "- 人名、产品名以术语表的正式写法统一(术语表没有的沿用笔记写法)。\n" + "- 开头放置元信息:会议时间(如有)、发言人数、使用语言、转写总时长。\n" + "- 只输出Markdown正文,不要用代码围栏包裹全文。", p2UserTpl = "【成稿语言】{language_name}\n\n" + "【会议信息】开始: {meeting_start} / 发言人数: {n_speakers} / 总时长: {total_duration}\n\n" + "【术语表(正式写法列表;人名、产品名的错误写法以此统一)】\n{glossary_lines}\n\n" + "【会议笔记JSON】\n{notes_json}\n\n" + "请根据以上内容输出会议纪要Markdown。", ) } // ---- 流水线主体 -------------------------------------------------------------- class SummarizePipeline( private val engine: LlmEngine, private val prompts: Prompts, ) { companion object { private const val TAG = "sherpa-onnx" private const val CHUNK_TARGET = 2500 // §5:2500±500 字/块 private const val CHUNK_MIN = 2000 private const val CHUNK_MAX = 3000 // P1 输入(笔记+块+固定文案)字符预算:超了就对半拆块, // 保证 ≤8192 ctx 永远够用(日语 1 字 ≈ 1 token 多一点) private const val P1_MAX_PROMPT_CHARS = 6500 private const val NOTES_RESERVE = 1200 // 笔记+模板文案预留 val EMPTY_NOTES = "{\"已定事項\":[],\"討論中\":[],\"行動項目\":[],\"人物\":[],\"前回末尾\":\"\"}" // 脚本占比判语种:假名出现即日语信号(日语里汉字占比也高,不能只看汉字); // 无假名时看汉字 vs 拉丁;全兜底 en。 fun detectLanguage(text: String): String { var kana = 0 var han = 0 var latin = 0 for (ch in text) { val cp = ch.code when { cp in 0x3040..0x30FF || cp in 0x31F0..0x31FF || cp in 0xFF66..0xFF9F -> kana++ cp in 0x3400..0x4DBF || cp in 0x4E00..0x9FFF || cp in 0xF900..0xFAFF -> han++ cp in 0x41..0x5A || cp in 0x61..0x7A -> latin++ } } return when { kana > 0 && kana * 20 >= kana + han -> "ja" // 假名/汉字 ≥ 5% han >= latin && han > 0 -> "zh" else -> "en" } } fun languageName(lang: String) = when (lang) { "ja" -> "日本語" "zh" -> "中文(简体书面语)" else -> "English (formal written English)" } /** 笔记是否无任何实质条目(四类条目全空;"前回末尾"不算)。解析失败按空处理。 */ internal fun notesEntriesEmpty(notes: String): Boolean = runCatching { val o = JSONObject(notes) listOf("已定事項", "討論中", "行動項目", "人物").all { k -> o.optJSONArray(k)?.length() == 0 } }.getOrDefault(true) fun mmss(ms: Long): String { val t = ms / 1000 return "%02d:%02d".format(t / 60, t % 60) } fun formatDate(wallMs: Long): String = java.text.SimpleDateFormat("yyyy-MM-dd HH:mm", java.util.Locale.US) .format(java.util.Date(wallMs)) /** P2 成稿 user 提示词构造(滚动成稿与里程碑 1 共用,保证两路输出同构)。 */ fun buildDraftUserPrompt( prompts: Prompts, lang: String, meetingStartWallMs: Long, nSpeakers: Int, totalDurMs: Long, glossary: String, notes: String, ): String = prompts.p2UserTpl .replace("{language_name}", languageName(lang)) .replace("{meeting_start}", formatDate(meetingStartWallMs)) .replace("{n_speakers}", nSpeakers.toString()) .replace("{total_duration}", mmss(totalDurMs)) .replace("{glossary_lines}", glossary.ifBlank { "(なし)" }) .replace("{notes_json}", notes) // §5 分块铁律:句子边界聚合到 2500±500 字;块间重叠 1 句; // 同一说话人连续段(轮次)不拆开:切点落在同人连续段中间则延到段尾。 // 另:超 P1 预算的块按行二分一次,保证 prompt ≤ ctx。 /** * schema 键名别名表:中文系小模型常把日语/繁体键写成简体 * (已定事项/行动项目…),不规范化则 extractJson 永远判失败—— * 这正是"中文会议也解析不了"的头号嫌疑。 */ private val KEY_ALIASES = mapOf( "已定事项" to "已定事項", "讨论中" to "討論中", "行动项目" to "行動項目", "决定事项" to "已定事項", "待办事项" to "行動項目", "上次末尾" to "前回末尾", ) internal fun normalizeKeys(o: JSONObject): JSONObject { for ((from, to) in KEY_ALIASES) { if (o.has(from) && !o.has(to)) o.put(to, o.remove(from)) } return o } internal fun chunk(lines: List): List { val blocks = ArrayList() var i = 0 var overlap: String? = null while (i < lines.size) { var j = i var len = 0 while (j < lines.size) { val l = lines[j] if (len > 0 && len + l.length > CHUNK_MAX) break if (len >= CHUNK_TARGET) { // 与上一句同一说话人 → 轮次未完,继续收 if (j == i || speakerOf(l) != speakerOf(lines[j - 1])) { if (len >= CHUNK_MIN) break } } len += l.length + 1 j++ } if (j == i) j++ // 单句超长的极端情况:至少吞一句 blocks.add( StringBuilder().apply { overlap?.let { append(it).append('\n') } for (k in i until j) append(lines[k]).append('\n') }.toString().trimEnd() ) overlap = if (j < lines.size) lines[j - 1] else null i = j } val fine = ArrayList() for (c in blocks) { if (c.length + NOTES_RESERVE <= P1_MAX_PROMPT_CHARS) { fine.add(c) } else { val ls = c.split('\n').filter { it.isNotBlank() } val half = (ls.size / 2).coerceAtLeast(1) val a = ls.subList(0, half).joinToString("\n") val b = ls.subList(half, ls.size).joinToString("\n") if (a.isNotBlank()) fine.add(a) if (b.isNotBlank()) fine.add(b) } } return fine } private fun speakerOf(line: String): String { val a = line.indexOf('[') val b = line.indexOf(']', a + 1) return if (a >= 0 && b > a) line.substring(a, b) else line } // 笔记输出健壮性:取首个平衡 {...}(前后的客套话/代码围栏自然被忽略)、 // 必需键检查 internal fun extractJson(raw: String): String? { val start = raw.indexOf('{') if (start < 0) return null var depth = 0 var inStr = false var esc = false for (i in start until raw.length) { val c = raw[i] if (esc) { esc = false; continue } when { c == '\\' && inStr -> esc = true c == '"' -> inStr = !inStr !inStr && c == '{' -> depth++ !inStr && c == '}' -> { depth-- if (depth == 0) { val cand = raw.substring(start, i + 1) return try { // 键名先规范化(简体别名→schema),再校验必需键 val o = normalizeKeys(JSONObject(cand)) if (o.has("已定事項") && o.has("行動項目")) o.toString() else null } catch (t: Throwable) { null } } } } } return null } internal fun cleanDraft(md: String): String { var s = md.trim() if (s.startsWith("```")) { s = s.substringAfter('\n').substringBeforeLast("```") } s = s.replace( Regex("<\\|channel>thought.*?", RegexOption.DOT_MATCHES_ALL), "" ) // MiniCPM5 兜底:万一 think 块未被预填空掉仍然冒出来,一并剥除 s = s.replace(Regex(".*?", RegexOption.DOT_MATCHES_ALL), "") s = s.replace(Regex("\n{3,}"), "\n\n") return s.trim() } /** * 输出语种纯度统计(2026-09-11 起只用于对账日志,不再触发整篇重写): * zh → 假名 > 8 个判违规;en → 汉字+假名 > 6 个判违规;ja → 不检查。 * 返回违规样本描述,null = 通过。 */ internal fun scriptViolation(lang: String, text: String): String? { var kana = 0 var han = 0 var firstBad = -1 for (i in text.indices) { val cp = text[i].code when { cp in 0x3040..0x30FF || cp in 0xFF66..0xFF9F -> { kana++; if (firstBad < 0) firstBad = i } cp in 0x3400..0x4DBF || cp in 0x4E00..0x9FFF || cp in 0xF900..0xFAFF -> { han++ if (firstBad < 0) firstBad = i } } } fun sample(): String = text.substring( firstBad, (firstBad + 16).coerceAtMost(text.length) ).replace("\n", " ") return when (lang) { "zh" -> if (kana > 8) "假名 $kana 个(如「${sample()}」)" else null "en" -> if (kana + han > 6) "中日文字 $han+$kana 个(around \"${sample()}\")" else null else -> null } } } /** * @param labelOf 内部说话人键(S{n}/真名)→ 显示名(已识别人N/真名/未识别) * @param entries SessionTranscript 快照(定格顺序,不可为空) * @param resumeNotes/resumeFromIndex M2:会中滚动笔记已覆盖前 resumeFromIndex 句, * 仅补跑尾部(进度、成稿复用同一份笔记) */ fun run( labelOf: (String?) -> String, entries: List, meetingStartWallMs: Long, glossary: String, listener: SummarizeListener, resumeNotes: String? = null, resumeFromIndex: Int = 0, ): MeetingSummary { if (entries.isEmpty()) throw IllegalArgumentException("会议转写为空") val lang = detectLanguage(entries.joinToString("") { it.text }) // ---- 分块(从断点句开始;断点前借 1 句做上下文衔接,同块间重叠规则)---- val startIdx = resumeFromIndex.coerceIn(0, entries.size) fun renderLine(e: TranscriptEntry) = "(%s-%s)[%s] %s".format( mmss(e.startMs), mmss(e.endMs), labelOf(e.speaker), e.text.trim() ) val lines = ArrayList() if (startIdx > 0 && startIdx < entries.size) lines.add(renderLine(entries[startIdx - 1])) for (e in entries.drop(startIdx)) lines.add(renderLine(e)) val chunks = chunk(lines) val total = chunks.size // 用户要求(2026-09-11):没有新句可消化(再点生成纪要,断点已覆盖全部 // 转写)时不再空跑"成稿 0/0",直接提示并自动取消 if (total == 0) { throw SummarizeEmptyException(if (resumeNotes != null) "本场会议没有新增转写,纪要已是最新" else "没有可分析的转写内容") } Log.i(TAG, "SUMMARIZE: ${entries.size} utterances -> $total chunks, " + "lang=$lang, resumeIdx=$startIdx") // ---- 滚动笔记(§5.2)---- var notes = resumeNotes ?: EMPTY_NOTES var failed = 0 val tP1Start = System.currentTimeMillis() chunks.forEachIndexed { idx, chunkText -> if (listener.isCancelled()) throw SummarizeCancelledException() val user = prompts.p1UserTpl .replace("{notes_json}", notes) .replace("{chunk_text}", chunkText) listener.onProgress("滚动笔记", idx + 1, total) val t0 = System.currentTimeMillis() var out = engine.generate( systemPrompt = prompts.p1System, userPrompt = user, maxNewTokens = 900, temperature = 0f, ) var parsed = extractJson(out) // 空笔记视同失败:小模型吃不透指令时会把输入的空模板原样复读 // (EMPTY 进 EMPTY 出)。首轮空结果带一次强约束重试再判。 val emptyEcho = parsed != null && idx == 0 && resumeNotes == null && notesEntriesEmpty(parsed) if (parsed == null || emptyEcho) { // 一次修复重试:把畸形/空洞输出递回去(仍失败保留旧笔记,计 failed) if (listener.isCancelled()) throw SummarizeCancelledException() val why = if (emptyEcho) "上一次输出是没有任何条目的空笔记。" else "上一次输出不是合法JSON: " + out.take(200).replace("\n", " ") + "。" val fix = user + "\n\n(" + why + "请从【新的断片】中至少提取一条并合并,只输出修正后的笔记JSON," + "禁止照抄输入笔记。)" out = engine.generate( systemPrompt = prompts.p1System, userPrompt = fix, maxNewTokens = 900, temperature = 0f, ) val p2 = extractJson(out) if (p2 != null && !notesEntriesEmpty(p2)) parsed = p2 } if (parsed != null) notes = parsed else { failed++ // 诊断转储:解析失败时把模型原文头尾写进日志,归因不靠猜 Log.w(TAG, "P1 chunk=${idx + 1} UNPARSABLE raw(${out.length}) " + "head=[" + out.take(160).replace("\n", "\\n") + "] " + "tail=[" + out.takeLast(60).replace("\n", "\\n") + "]") // 及时止损:第一块都解析不出来,笔记链条已断,后面块只会白烧 token if (idx == 0) { throw SummarizeEmptyException("开头转写片段无法生成有效笔记(内容过少或输出不可解析)") } } // 测量点(baseline,不改行为):raw≫json 说明解码浪费在废话/思考上; // ms 是单块真实耗时,决定 diff 化/大块合并的参数 Log.i(TAG, "P1 chunk=${idx + 1}/$total prompt_len=${user.length} " + "raw=${out.length} json=${parsed?.length ?: 0} " + "retry=${if (parsed == null || out.length < 1) 1 else 0} " + "ms=${System.currentTimeMillis() - t0}") } Log.i(TAG, "P1 phase total_ms=${System.currentTimeMillis() - tP1Start} " + "chunks=$total failed=$failed") // 空笔记终检:全部块失败,或笔记仍是空壳(total==0 已在入口取消) if (failed == total || notesEntriesEmpty(notes)) { throw SummarizeEmptyException("整场转写未能形成任何笔记条目" + "(failed=$failed/$total, notes=" + notes.replace("\n", " ").take(120) + ")") } // ---- 成稿(§5.3)---- if (listener.isCancelled()) throw SummarizeCancelledException() listener.onProgress("成稿", total, total) val nSpeakers = entries.map { it.speaker ?: "?" }.distinct().size val user2 = buildDraftUserPrompt( prompts, lang, meetingStartWallMs, nSpeakers, entries.last().endMs, glossary, notes ) val tP2 = System.currentTimeMillis() val md = cleanDraft(engine.generate( systemPrompt = prompts.p2System, userPrompt = user2, maxNewTokens = 2500, temperature = 0.5f, topK = 64, topP = 0.95f, )) Log.i(TAG, "P2 draft prompt_len=${user2.length} draft_len=${md.length} " + "ms=${System.currentTimeMillis() - tP2}") // 语种"校正"(整篇重写)已按用户要求移除:识别是什么语言,纪要就存 // 什么语言,不再为混语倾向多花一次 2500 token 生成。这里只留一行 // 纯统计日志(零成本),纪要中的混语留给人工处理。 val viol = scriptViolation(lang, md) if (viol != null) Log.i(TAG, "SUMMARIZE langcheck NOTE($lang): $viol (no rewrite)") if (md.isBlank()) throw SummarizeEmptyException("成稿输出为空") Log.i(TAG, "SUMMARIZE wall clock total_ms=${System.currentTimeMillis() - tP1Start}") return MeetingSummary(md, lang, notes, total, failed) } }