ASR-Android/app/src/main/java/com/k2fsa/sherpa/onnx/SummarizePipeline.kt

485 lines
24 KiB
Kotlin

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<String, String> {
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<String>()
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<String>): List<String> {
val blocks = ArrayList<String>()
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<String>()
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.*?<channel\\|>", RegexOption.DOT_MATCHES_ALL), ""
)
// MiniCPM5 兜底:万一 think 块未被预填空掉仍然冒出来,一并剥除
s = s.replace(Regex("<think>.*?</think>", 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<TranscriptEntry>,
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<String>()
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)
}
}