出现未识别标签保留字幕bug修复:给兜底,与最相似声纹合并
This commit is contained in:
parent
7892e35d2d
commit
34e9fb3578
|
|
@ -5,7 +5,9 @@ plugins {
|
|||
|
||||
android {
|
||||
namespace 'com.k2fsa.sherpa.onnx'
|
||||
compileSdk 32
|
||||
// appcompat 1.6.1(应用内语言切换所需)要求 compileSdk≥33;仅编译期提升,
|
||||
// targetSdk 保持 32(运行时行为不变),minSdk 保持 21(装机范围不变)
|
||||
compileSdk 33
|
||||
|
||||
defaultConfig {
|
||||
applicationId "com.k2fsa.sherpa.onnx"
|
||||
|
|
@ -48,7 +50,7 @@ android {
|
|||
dependencies {
|
||||
|
||||
implementation 'androidx.core:core-ktx:1.7.0'
|
||||
implementation 'androidx.appcompat:appcompat:1.5.1'
|
||||
implementation 'androidx.appcompat:appcompat:1.6.1'
|
||||
implementation 'com.google.android.material:material:1.7.0'
|
||||
implementation 'androidx.constraintlayout:constraintlayout:2.1.4'
|
||||
testImplementation 'junit:junit:4.13.2'
|
||||
|
|
|
|||
|
|
@ -1,7 +1,8 @@
|
|||
# 摘要功能三段提示词(里程碑 1)
|
||||
|
||||
> 适用模型:`gemma-3-1b-it`(ggml-org/gemma-3-1b-it-GGUF,Q4_K_M;2026-09-11 由
|
||||
> gemma-4-E2B 换小提速。此前:`google/gemma-4-E2B-it-qat-q4_0-gguf`,架构 gemma4,Apache-2.0)
|
||||
> 适用模型:`openbmb/MiniCPM5-2B-GGUF` 的 `MiniCPM5-2B-Q4_K_M.gguf`(架构 llama,
|
||||
> ChatML 模板,官方语言 en/zh;2026-09-12 由 Gemma 系换入。历史:gemma-3-1b-it /
|
||||
> google/gemma-4-E2B-it-qat-q4_0-gguf,模板分支保留在 LlmEngine 可回切)
|
||||
> 本文件是唯一事实源:App 内 `SummarizePipeline.kt` 逐字引用这里的文本。改提示词先改本文件。
|
||||
> PC 调试方法见文末「用 PC llama.cpp 复现」。
|
||||
|
||||
|
|
@ -10,22 +11,13 @@
|
|||
| 步骤 | temperature | maxNewTokens | 备注 |
|
||||
|---|---|---|---|
|
||||
| P1 滚动笔记更新 | 0(Greedy) | 900 | 可复现;笔记要求确定性 |
|
||||
| P2 成稿 | 0.5 / topK 64 / topP 0.95 | 2500 | Gemma 4 官方推荐采样档位,低温折中 |
|
||||
| P2 成稿 | 0.5 / topK 64 / topP 0.95 | 2500 | 低温采样,兼顾成稿自然度与可复现 |
|
||||
|
||||
思考(thinking)关闭:App 渲染模板时不注入 `<|think|>` 标记;模型若仍输出 `<|channel>thought…` 块,App 侧正则剔除。
|
||||
|
||||
## 1. 聊天模板(App 手工渲染,与 Gemma 4 canonical template 单轮路径等价)
|
||||
## 1. 聊天模板(以代码为准)
|
||||
|
||||
```
|
||||
<bos>(tokenizer 自动)
|
||||
<|turn>system
|
||||
{SYSTEM}<turn|>
|
||||
<|turn>user
|
||||
{USER}<turn|>
|
||||
<|turn>model
|
||||
```
|
||||
|
||||
特殊 token id:`<bos>`=2,`<eos>`=1,`<|turn>`=105,`<turn|>`=106。生成遇 `<eos>`、`<turn|>`、或 8192 上下文耗尽即停。
|
||||
模板由 `LlmEngine.renderChatPrompt` 按模型家族渲染:gemma4(旧)/ gemma3 / minicpm(ChatML im_start/im_end + 空 think 前缀禁用思考)。MiniCPM5 的确切 token 序列与特殊 id 以 `renderMinicpmPrompt` 与 GGUF 内嵌模板为准;PC 调试直接加 --jinja 用内置模板即可。
|
||||
|
||||
---
|
||||
|
||||
|
|
@ -34,33 +26,34 @@
|
|||
### SYSTEM(固定,勿动态改写)
|
||||
|
||||
```
|
||||
あなたは会議の文字起こしを処理する記録係です。与えられた「現在のメモ」に「新しい断片」の内容を統合し、更新後のメモをJSONだけで出力してください。
|
||||
你是处理会议转写记录的笔记员。请把【新的断片】的内容合并进给定的【当前笔记】,只以JSON输出更新后的笔记。
|
||||
|
||||
厳守事項:
|
||||
- 出力は指定スキーマのJSONオブジェクトのみ。説明文・コードフェンス・前置き禁止。
|
||||
- メモは日本語で書く。ただし人名・組織名・製品名・固有名詞は原文表記をそのまま維持。
|
||||
- 数字、金額、日時、期限、回数、単位は原文どおり正確に転記。ざっくり要約しない。
|
||||
- 否定・保留・反対の意図を消さない。「見送り」「再検討」「反対意見」はそれ自体が重要な情報。
|
||||
- 各項目に根拠となる発話のタイムレンジを「出典」に付ける(形式: "mm:ss-mm:ss"、複数可 ";" 区切り)。
|
||||
- 断片に新情報がない場合はメモをほぼ変更しない(前回末尾は必ず更新)。
|
||||
- 全体で500字を超えそうな場合、重要度の低い過去の項目を統合・削除して圧縮する。削除時は行動項目と已定事項を最後に守る。
|
||||
- 「前回末尾」には、次の断片と文脈が繋がるための直近話題を1文で書く。
|
||||
- JSONの値の中にダブルクォートが必要なら \" にエスケープ。改行は \n を使う。
|
||||
严格要求:
|
||||
- 只输出符合指定schema的JSON对象;禁止说明文字、代码围栏、开场语。
|
||||
- schema的键名必须原样输出,不得翻译、转写或改写(程序按这些键校验)。
|
||||
- 笔记内容用中文书写;人名、组织名、产品名等专有名词保留原文写法。
|
||||
- 不得把输入笔记原样照抄:必须从【新的断片】中提取至少一条笔记尚未覆盖的信息并合并;断片确实没有新信息时,保留原笔记并只更新「前回末尾」。
|
||||
- 数字、金额、日期时间、期限、次数、单位按原文准确转记,不许模糊概括。
|
||||
- 不得删除否定、保留、反对的意图;「暂缓」「再议」「反对意见」本身就是重要信息。
|
||||
- 每个条目在「出典」字段给出依据发言的时间段(格式:"mm:ss-mm:ss",多个用";"分隔)。
|
||||
- 总计超过500字时,合并删除低价值的旧条目压缩;「行動項目」和「已定事項」最后才允许删。
|
||||
- 「前回末尾」用一句话写最近的议题,供下一个断片衔接上下文。
|
||||
- JSON字符串值内需要双引号时转义为\";换行用\n。
|
||||
|
||||
スキーマ(キー名は変更禁止):
|
||||
schema(键名禁止改动):
|
||||
{"已定事項":[{"内容":"","出典":""}],"討論中":[{"内容":"","出典":""}],"行動項目":[{"内容":"","担当者":"","期限":"","出典":""}],"人物":[{"名前":"","立場":""}],"前回末尾":""}
|
||||
```
|
||||
|
||||
### USER(每块渲染;`{}` 为占位符)
|
||||
|
||||
```
|
||||
【現在のメモ】
|
||||
【当前笔记】
|
||||
{notes_json}
|
||||
|
||||
【新しい断片】(タイムスタンプは会議開始からの分:秒、[]内は話者ラベル)
|
||||
【新的断片】(时间戳为会议开始的 分:秒,[]内是说话人标签)
|
||||
{chunk_text}
|
||||
|
||||
更新後のメモJSONを出力してください。
|
||||
请输出更新后的笔记JSON。
|
||||
```
|
||||
|
||||
---
|
||||
|
|
@ -70,35 +63,35 @@
|
|||
### SYSTEM(固定)
|
||||
|
||||
```
|
||||
あなたは会議の公式議事録作成者です。会議メモ(JSON)と用語集を受け取ったら、指定言語で完成した議事録をMarkdownで出力してください。
|
||||
你是会议的官方纪要撰写者。收到会议笔记(JSON)与术语表后,用指定成稿语言输出完整的Markdown会议纪要。
|
||||
|
||||
厳守事項:
|
||||
- 出力言語は指定された「成稿言語」を厳守。日本語メモの内容をそのまま指定言語の書き言葉文体で記述する。
|
||||
- 本文は成稿言語の文字のみで書く:中文稿なら仮名を一切出さない、English稿なら漢字・仮名を一切出さない、日本語稿なら製品名以外に英語の文を残さない。
|
||||
- ただし人物・会社・ブランドなどの固有名詞は、メモ・用語集にある原文表記のまま保留してよい(上記の文字制限の例外)。
|
||||
- 見出しは次の4つを必ずこの順で:「議題と経過」「決定事項」「行動項目」「未決事項・持ち帰り」。該当ゼロなら「(なし)」と明記。
|
||||
- 行動項目は表形式(担当者 / 内容 / 期限 / 出典)。担当者が不明なら「未定」と書く。
|
||||
- 各項目の末尾に出典を付ける。形式: `出典: mm:ss-mm:ss`。出典はメモJSONの「出典」フィールドのみを使う。捏造禁止。
|
||||
- メモにない事実・推測・補足説明を追加しない。
|
||||
- 人名・製品名は用語集の正式表記に統一する(用語集にない表記はメモのまま採用)。
|
||||
- 冒頭にメタ情報を置く: 会議日時(指定があれば)、話者数、使用言語、文字起こし総尺。
|
||||
- Markdown本文のみ出力。コードフェンスで全体を囲まない。
|
||||
严格要求:
|
||||
- 输出语言严格遵守指定的「成稿语言」,用该语言的书面体撰写。
|
||||
- 正文只用成稿语言的文字书写:中文稿不得出现日文假名;英文稿不得出现汉字与假名;日文稿除产品名外不得残留英文句子。
|
||||
- 人物、公司、品牌等专有名词可保留笔记/术语表中的原文写法(上述文字限制的例外)。
|
||||
- 章节标题必须依序为「议题与经过」「决定事项」「行动项」「未决与待议」;成稿语言非中文时翻译成该语言的等价标题;没有条目的章节明确写"(无)"。
|
||||
- 行动项用表格输出,列为 负责人 / 内容 / 期限 / 出处;负责人不明时写"待定"。
|
||||
- 每个条目末尾给出处,格式 `出处: mm:ss-mm:ss`;出处只允许使用笔记JSON中「出典」字段的值,不得编造。
|
||||
- 不得添加笔记中没有的事实、推测或补充说明。
|
||||
- 人名、产品名以术语表的正式写法统一(术语表没有的沿用笔记写法)。
|
||||
- 开头放置元信息:会议时间(如有)、发言人数、使用语言、转写总时长。
|
||||
- 只输出Markdown正文,不要用代码围栏包裹全文。
|
||||
```
|
||||
|
||||
### USER
|
||||
|
||||
```
|
||||
【成稿言語】{language_name}
|
||||
【成稿语言】{language_name}
|
||||
|
||||
【会議メタ情報】開始: {meeting_start} / 話者数: {n_speakers} / 総尺: {total_duration}
|
||||
【会议信息】开始: {meeting_start} / 发言人数: {n_speakers} / 总时长: {total_duration}
|
||||
|
||||
【用語集(正式表記の一覧。人名・製品名の誤変換をこれに統一)】
|
||||
【术语表(正式写法列表;人名、产品名的错误写法以此统一)】
|
||||
{glossary_lines}
|
||||
|
||||
【会議メモJSON】
|
||||
【会议笔记JSON】
|
||||
{notes_json}
|
||||
|
||||
上記に基づき、議事録Markdownを出力してください。
|
||||
请根据以上内容输出会议纪要Markdown。
|
||||
```
|
||||
|
||||
---
|
||||
|
|
@ -120,13 +113,13 @@
|
|||
|
||||
## 用 PC llama.cpp 复现(提示词调优用)
|
||||
|
||||
1. 取 llama.cpp master(≥2026-09;gemma3 架构早已支持,App 的 `libsummarize-llm-jni.so` 无需重编)。
|
||||
2. 模型:`gemma-3-1b-it-Q4_K_M.gguf`(ggml-org/gemma-3-1b-it-GGUF)。
|
||||
1. 取 llama.cpp master(gemma3 及更早架构均支持,App 的 `libsummarize-llm-jni.so` 无需重编)。
|
||||
2. 模型:`MiniCPM5-2B-Q4_K_M.gguf`(openbmb/MiniCPM5-2B-GGUF,架构 llama)。
|
||||
3. 跑 P1 示例(替换 sys/user 两段;`--jinja` 走 GGUF 内置模板,与 App 的
|
||||
`renderGemma3Prompt`——system 并入首个 user turn——等价):
|
||||
`renderMinicpmPrompt`——ChatML + 预填空 `<think>` 块禁用思考——等价):
|
||||
|
||||
```bash
|
||||
llama-cli -m gemma-3-1b-it-Q4_K_M.gguf -c 8192 -ctk q8_0 -ctv f16 --jinja \
|
||||
llama-cli -m MiniCPM5-2B-Q4_K_M.gguf -c 8192 -ctk q8_0 -ctv f16 --jinja \
|
||||
--temp 0 -n 900 \
|
||||
--sys "$(sed -n '/^### SYSTEM/,/^```$/p' prompts.md | sed '1,2d;$d')" \
|
||||
-p "$USER_PROMPT"
|
||||
|
|
|
|||
|
|
@ -75,10 +75,23 @@ class LlmEngine(
|
|||
append("<start_of_turn>model\n")
|
||||
}
|
||||
|
||||
// ---- MiniCPM5(ChatML,生成 prompt 默认带 think 块)----
|
||||
// GGUF 模板 enable_thinking=false 时的 generation prompt 为:
|
||||
// <|im_start|>assistant\n<think>\n\n</think>\n\n
|
||||
// 显式预填空 think → 模型直接输出正文,不浪费解码在思考上。
|
||||
// EOG 为 <|im_end|>(llama.cpp 官方 GGUF,元数据已配)。
|
||||
fun renderMinicpmPrompt(system: String, user: String): String =
|
||||
buildString {
|
||||
append("<|im_start|>system\n").append(system.trim()).append("<|im_end|>\n")
|
||||
append("<|im_start|>user\n").append(user.trim()).append("<|im_end|>\n")
|
||||
append("<|im_start|>assistant\n<think>\n\n</think>\n\n")
|
||||
}
|
||||
|
||||
fun renderChatPrompt(system: String, user: String,
|
||||
template: String = "gemma4"): String =
|
||||
when (template) {
|
||||
"gemma3" -> renderGemma3Prompt(system, user)
|
||||
"minicpm" -> renderMinicpmPrompt(system, user)
|
||||
else -> renderGemma4Prompt(system, user)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load Diff
|
|
@ -39,6 +39,13 @@ class SessionTranscript private constructor(
|
|||
private val entries = ArrayList<TranscriptEntry>()
|
||||
private val bySid = HashMap<Long, TranscriptEntry>()
|
||||
private var writer: BufferedWriter? = null
|
||||
// 暂停累计时长:定格句子的相对时间轴要扣除中场休息(需求:暂停/继续)
|
||||
private var pauseOffsetMs = 0L
|
||||
|
||||
/** 恢复录音时调用,累计暂停掉的毫秒数。 */
|
||||
fun addPaused(ms: Long) {
|
||||
if (ms > 0) pauseOffsetMs += ms
|
||||
}
|
||||
|
||||
companion object {
|
||||
private const val TAG = "sherpa-onnx"
|
||||
|
|
@ -138,10 +145,11 @@ class SessionTranscript private constructor(
|
|||
|
||||
// ---- 主线程挂钩点 --------------------------------------------------------
|
||||
|
||||
/** finalizeSentence 处调用:endMs=定格时刻(相对会话开始),startMs=endMs-dur。 */
|
||||
/** finalizeSentence 处调用:endMs=定格时刻(相对会话开始,已扣暂停)。 */
|
||||
fun onSettled(sid: Long, durMs: Long, text: String) {
|
||||
if (text.isBlank()) return
|
||||
val end = SystemClock.elapsedRealtime() - sessionStartElapsed
|
||||
val end = (SystemClock.elapsedRealtime() - sessionStartElapsed -
|
||||
pauseOffsetMs).coerceAtLeast(0L)
|
||||
val e = TranscriptEntry(sid, (end - durMs).coerceAtLeast(0), end, null, text, false)
|
||||
bySid[sid] = e; entries.add(e)
|
||||
appendLine(JSONObject().apply {
|
||||
|
|
|
|||
|
|
@ -124,23 +124,33 @@ class SpeakerRegistry {
|
|||
}
|
||||
|
||||
/**
|
||||
* 最近邻兜底(用户要求 2026-09-11):"未识别"归到与 e 最相似的已注册者
|
||||
* (命名条目优先,其后自动条目;相似度须 ≥ minScore)。全空/不达标返回 null。
|
||||
* 最近邻兜底(收紧版,P1):按"名字级"聚合所有嵌入,返回最优名与
|
||||
* 名级最高/次高分。同人多嵌入只算一个竞争者,优势差判定才有意义;
|
||||
* 调用方用 score≥minScore 且 (score-secondScore)≥margin 才归入,
|
||||
* 模糊时判新人(宁多勿错)。
|
||||
*/
|
||||
class Rank(val name: String?, val score: Float, val secondScore: Float)
|
||||
|
||||
@Synchronized
|
||||
fun nearest(e: FloatArray, minScore: Float): String? {
|
||||
fun nearestRank(e: FloatArray): Rank {
|
||||
val q = l2normalize(e)
|
||||
var bestName: String? = null
|
||||
var best = -1f
|
||||
val byName = LinkedHashMap<String, Float>()
|
||||
for ((name, emb) in named) {
|
||||
val s = dot(q, emb)
|
||||
if (s > best) { best = s; bestName = name }
|
||||
if (s > (byName[name] ?: -2f)) byName[name] = s
|
||||
}
|
||||
for ((name, emb) in auto) {
|
||||
val s = dot(q, emb)
|
||||
if (s > best) { best = s; bestName = name }
|
||||
if (s > (byName[name] ?: -2f)) byName[name] = s
|
||||
}
|
||||
return if (best >= minScore) bestName else null
|
||||
var best: String? = null
|
||||
var bestScore = -2f
|
||||
var secondScore = -2f
|
||||
for ((name, s) in byName) {
|
||||
if (s > bestScore) { secondScore = bestScore; bestScore = s; best = name }
|
||||
else if (s > secondScore) secondScore = s
|
||||
}
|
||||
return Rank(best, bestScore, secondScore)
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
|
|||
|
|
@ -87,41 +87,42 @@ object PromptsLoader {
|
|||
return Prompts(p1s, p1u, p2s, p2u)
|
||||
}
|
||||
|
||||
// 与 prompts.md 同源的内置副本(md 丢失/被改坏时的兜底)。
|
||||
// 与 prompts.md 同源的内置副本(md 丢失/被改坏时的兜底;2026-09-14 起中文)。
|
||||
val BUILTIN = Prompts(
|
||||
p1System = "あなたは会議の文字起こしを処理する記録係です。与えられた「現在のメモ」に「新しい断片」の内容を統合し、更新後のメモをJSONだけで出力してください。\n\n" +
|
||||
"厳守事項:\n" +
|
||||
"- 出力は指定スキーマのJSONオブジェクトのみ。説明文・コードフェンス・前置き禁止。\n" +
|
||||
"- メモは日本語で書く。ただし人名・組織名・製品名・固有名詞は原文表記をそのまま維持。\n" +
|
||||
"- 数字、金額、日時、期限、回数、単位は原文どおり正確に転記。ざっくり要約しない。\n" +
|
||||
"- 否定・保留・反対の意図を消さない。「見送り」「再検討」「反対意見」はそれ自体が重要な情報。\n" +
|
||||
"- 各項目に根拠となる発話のタイムレンジを「出典」に付ける(形式: \"mm:ss-mm:ss\"、複数可 \";\" 区切り)。\n" +
|
||||
"- 断片に新情報がない場合はメモをほぼ変更しない(前回末尾は必ず更新)。\n" +
|
||||
"- 全体で500字を超えそうな場合、重要度の低い過去の項目を統合・削除して圧縮する。削除時は行動項目と已定事項を最後に守る。\n" +
|
||||
"- 「前回末尾」には、次の断片と文脈が繋がるための直近話題を1文で書く。\n" +
|
||||
"- JSONの値の中にダブルクォートが必要なら \\\" にエスケープ。改行は \\n を使う。\n\n" +
|
||||
"スキーマ(キー名は変更禁止):\n" +
|
||||
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" +
|
||||
"- 本文は成稿言語の文字のみで書く:中文稿なら仮名を一切出さない、English稿なら漢字・仮名を一切出さない、日本語稿なら製品名以外に英語の文を残さない。\n" +
|
||||
"- ただし人物・会社・ブランドなどの固有名詞は、メモ・用語集にある原文表記のまま保留してよい(上記の文字制限の例外)。\n" +
|
||||
"- 見出しは次の4つを必ずこの順で:「議題と経過」「決定事項」「行動項目」「未決事項・持ち帰り」。該当ゼロなら「(なし)」と明記。\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を出力してください。",
|
||||
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。",
|
||||
)
|
||||
}
|
||||
|
||||
|
|
@ -202,6 +203,25 @@ class SummarizePipeline(
|
|||
// §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
|
||||
|
|
@ -274,8 +294,11 @@ class SummarizePipeline(
|
|||
if (depth == 0) {
|
||||
val cand = raw.substring(start, i + 1)
|
||||
return try {
|
||||
val o = JSONObject(cand)
|
||||
if (o.has("已定事項") && o.has("行動項目")) cand else null
|
||||
// 键名先规范化(简体别名→schema),再校验必需键
|
||||
val o = normalizeKeys(JSONObject(cand))
|
||||
if (o.has("已定事項") && o.has("行動項目"))
|
||||
o.toString()
|
||||
else null
|
||||
} catch (t: Throwable) {
|
||||
null
|
||||
}
|
||||
|
|
@ -294,6 +317,8 @@ class SummarizePipeline(
|
|||
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()
|
||||
}
|
||||
|
|
@ -382,23 +407,37 @@ class SummarizePipeline(
|
|||
maxNewTokens = 900, temperature = 0f,
|
||||
)
|
||||
var parsed = extractJson(out)
|
||||
if (parsed == null) {
|
||||
// 一次修复重试:把畸形输出递回去(仍失败则保留旧笔记,计 failed)
|
||||
// 空笔记视同失败:小模型吃不透指令时会把输入的空模板原样复读
|
||||
// (EMPTY 进 EMPTY 出)。首轮空结果带一次强约束重试再判。
|
||||
val emptyEcho = parsed != null && idx == 0 &&
|
||||
resumeNotes == null && notesEntriesEmpty(parsed)
|
||||
if (parsed == null || emptyEcho) {
|
||||
// 一次修复重试:把畸形/空洞输出递回去(仍失败保留旧笔记,计 failed)
|
||||
if (listener.isCancelled()) throw SummarizeCancelledException()
|
||||
val fix = user + "\n\n(前回の出力はJSONとして不正でした: " +
|
||||
out.take(200).replace("\n", " ") + " 正しいJSONのみを再出力してください。)"
|
||||
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,
|
||||
)
|
||||
parsed = extractJson(out)
|
||||
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) {
|
||||
Log.w(TAG, "P1 first chunk unparsable, abort. raw=${out.take(120)}")
|
||||
throw SummarizeEmptyException("开头转写片段无法生成有效笔记(内容过少或输出不可解析)")
|
||||
}
|
||||
}
|
||||
|
|
@ -413,7 +452,9 @@ class SummarizePipeline(
|
|||
"chunks=$total failed=$failed")
|
||||
// 空笔记终检:全部块失败,或笔记仍是空壳(total==0 已在入口取消)
|
||||
if (failed == total || notesEntriesEmpty(notes)) {
|
||||
throw SummarizeEmptyException("整场转写未能形成任何笔记条目")
|
||||
throw SummarizeEmptyException("整场转写未能形成任何笔记条目" +
|
||||
"(failed=$failed/$total, notes=" +
|
||||
notes.replace("\n", " ").take(120) + ")")
|
||||
}
|
||||
|
||||
// ---- 成稿(§5.3)----
|
||||
|
|
|
|||
|
|
@ -11,6 +11,53 @@
|
|||
android:layout_height="match_parent"
|
||||
android:orientation="vertical">
|
||||
|
||||
<!-- 顶栏:正常态只有左上角「历史会议」;历史态显示 标题+返回 -->
|
||||
<LinearLayout
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:gravity="center_vertical"
|
||||
android:orientation="horizontal"
|
||||
android:paddingStart="12dp"
|
||||
android:paddingEnd="12dp">
|
||||
|
||||
<Button
|
||||
android:id="@+id/history_button"
|
||||
style="?android:attr/buttonBarButtonStyle"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:text="@string/history_button"
|
||||
android:textSize="13sp" />
|
||||
|
||||
<TextView
|
||||
android:id="@+id/history_title"
|
||||
android:layout_width="0dp"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_weight="1"
|
||||
android:ellipsize="end"
|
||||
android:maxLines="1"
|
||||
android:paddingStart="8dp"
|
||||
android:textColor="#666666"
|
||||
android:textSize="13sp"
|
||||
android:visibility="gone" />
|
||||
|
||||
<Button
|
||||
android:id="@+id/lang_button"
|
||||
style="?android:attr/buttonBarButtonStyle"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:text="@string/lang_button"
|
||||
android:textSize="13sp" />
|
||||
|
||||
<Button
|
||||
android:id="@+id/history_back"
|
||||
style="?android:attr/buttonBarButtonStyle"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:text="@string/history_back"
|
||||
android:textSize="13sp"
|
||||
android:visibility="gone" />
|
||||
</LinearLayout>
|
||||
|
||||
<FrameLayout
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="0dp"
|
||||
|
|
@ -57,6 +104,13 @@
|
|||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:text="@string/start" />
|
||||
|
||||
<Button
|
||||
android:id="@+id/pause_button"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginStart="12dp"
|
||||
android:text="@string/pause" />
|
||||
</LinearLayout>
|
||||
|
||||
<!-- 纪要面板:状态一行 + 正文(hint 占位/markdown 正文叠放) -->
|
||||
|
|
@ -81,7 +135,7 @@
|
|||
android:layout_height="match_parent"
|
||||
android:gravity="center"
|
||||
android:padding="16dp"
|
||||
android:text="点击「生成纪要」离线总结本场对话;结果与上次纪要显示在这里"
|
||||
android:text="@string/minutes_hint"
|
||||
android:textColor="#999999"
|
||||
android:textSize="13sp" />
|
||||
|
||||
|
|
|
|||
|
|
@ -1,16 +1,76 @@
|
|||
<resources>
|
||||
<string name="app_name">ASR2pass </string>
|
||||
<string name="hint">Click the Start button to play speech-to-text with Next-gen Kaldi.
|
||||
\n
|
||||
\n\n\n
|
||||
The source code and pre-trained models are publicly available.
|
||||
Please see https://github.com/k2fsa/sherpa-onnx for details.
|
||||
\n\n
|
||||
Two-pass speech recognition with Next-gen Kaldi.
|
||||
</string>
|
||||
<string name="start">Start</string>
|
||||
<string name="stop">Stop</string>
|
||||
<string name="app_name">ASR2pass</string>
|
||||
<string name="hint">点击「开始」进行实时转写。\n\n源代码与全部模型均为公开资源,详见 github.com/k2fsa/sherpa-onnx。\n双通道(两遍)识别 · 说话人区分 · 离线会议纪要</string>
|
||||
<string name="start">开始</string>
|
||||
<string name="stop">停止</string>
|
||||
<string name="pause">暂停</string>
|
||||
<string name="resume">继续</string>
|
||||
<string name="dlg_ok">确定</string>
|
||||
<string name="dlg_cancel">取消</string>
|
||||
<string name="generate_minutes">生成纪要</string>
|
||||
<string name="cancel_minutes">取消生成</string>
|
||||
<string name="copy_minutes">复制纪要</string>
|
||||
<string name="history_button">历史会议</string>
|
||||
<string name="history_back">返回当前</string>
|
||||
<string name="lang_button">语言</string>
|
||||
<string name="lang_title">界面语言</string>
|
||||
<string name="lang_system">跟随系统</string>
|
||||
<string name="minutes_hint">点击「生成纪要」离线总结本场对话;结果与上次纪要显示在这里</string>
|
||||
|
||||
<string name="loading_pass1">正在加载第一遍模型(nemotron-3.5)…</string>
|
||||
<string name="loading_pass2">正在加载第二遍模型(funasr-nano)…</string>
|
||||
<string name="loading_speaker">正在加载声纹模型…</string>
|
||||
<string name="load_done">模型加载完成。</string>
|
||||
<string name="tap_start">点击「开始」进行识别</string>
|
||||
<string name="speaker_on">说话人功能已启用</string>
|
||||
<string name="speaker_off">⚠ 说话人功能未启用:缺少声纹模型 %1$s(请 adb push 到 %2$s)</string>
|
||||
<string name="load_failed">模型加载失败:%1$s\n\n模型统一放 %2$s,可用 adb push 推送</string>
|
||||
|
||||
<string name="label_identified">已识别人%1$d</string>
|
||||
<string name="label_unknown">未识别</string>
|
||||
|
||||
<string name="naming_title_new">命名说话人「%1$s」</string>
|
||||
<string name="naming_title_rename">将「%1$s」改名为…</string>
|
||||
<string name="naming_hint">「%1$s」的真名,如 张三</string>
|
||||
<string name="naming_bad_name">名字不能是 S+数字 或系统保留显示名</string>
|
||||
<string name="naming_done">已命名「%1$s」,相邻同名行已合并</string>
|
||||
<string name="naming_wait_owner">该行声纹归属尚未确定,先说一句完整的话再命名</string>
|
||||
|
||||
<string name="hist_title">历史会议</string>
|
||||
<string name="hist_busy_recording">正在录音,结束本场后再回看历史</string>
|
||||
<string name="hist_none">还没有可回看的历史会议</string>
|
||||
<string name="hist_item">%1$s · %2$d 句 · %3$s%4$s</string>
|
||||
<string name="hist_has_md"> · 已有纪要</string>
|
||||
<string name="history_reading">历史:%1$s(只读)</string>
|
||||
|
||||
<string name="sum_title">选择要总结的会议</string>
|
||||
<string name="sum_current">【当前会议】%1$d 句 · %2$s</string>
|
||||
<string name="sum_has_md"> · 已有纪要(续写)</string>
|
||||
<string name="sum_none">还没有可总结的会议,先录一场再生成纪要</string>
|
||||
<string name="sum_empty_this">这场会议没有定格句子</string>
|
||||
<string name="sum_generating">纪要正在生成中,再点一次可取消</string>
|
||||
<string name="sum_no_abi">本机 ABI 未包含摘要运行时库(仅 arm64-v8a 真机可用)</string>
|
||||
<string name="sum_model_missing">缺少摘要模型。请执行:\n%1$s</string>
|
||||
<string name="sum_loading">正在加载摘要模型(%1$s)…</string>
|
||||
<string name="sum_stage_notes">滚动笔记</string>
|
||||
<string name="sum_stage_draft">成稿</string>
|
||||
<string name="sum_progress">正在%1$s: %2$d/%3$d</string>
|
||||
<string name="sum_ready_resume">模型就绪,从上次断点续跑(%1$d/%2$d 句已消化)…</string>
|
||||
<string name="sum_ready">模型就绪,共 %1$d 句,开始滚动笔记…</string>
|
||||
<string name="sum_saved">纪要已保存(%1$s,%2$d 块%3$s):%4$s</string>
|
||||
<string name="sum_failed_chunks">,%1$d 块解析失败</string>
|
||||
<string name="sum_cancelled">已取消生成(保留上一次的纪要)</string>
|
||||
<string name="sum_auto_cancel">已自动取消:%1$s(上次纪要未覆盖)</string>
|
||||
<string name="sum_fail_toast">生成失败:%1$s</string>
|
||||
<string name="sum_cancel_toast">已取消生成</string>
|
||||
<string name="sum_empty_toast">已自动取消生成</string>
|
||||
<string name="sum_recording_note">纪要生成中,录音转写不会中断</string>
|
||||
<string name="sum_copied">纪要已复制到剪贴板</string>
|
||||
|
||||
<string name="minutes_last">上次纪要:%1$s(生成新纪要后会覆盖)</string>
|
||||
<string name="minutes_unfinished">上一场会议「%1$s」尚未出纪要,点「生成纪要」可续写(增量,只处理新增句子)</string>
|
||||
|
||||
<string name="lang_busy_session">会话进行中,结束本场后才能切换语言</string>
|
||||
<string name="copied">已复制到剪贴板</string>
|
||||
<string name="sum_canceling">正在取消…</string>
|
||||
</resources>
|
||||
|
|
|
|||
Loading…
Reference in New Issue