Compare commits
No commits in common. "main" and "SUMMARIZE" have entirely different histories.
|
|
@ -22,5 +22,3 @@ local.properties
|
||||||
|
|
||||||
# 模型文件体积大,不入库;运行时手动放入 app/src/main/assets/
|
# 模型文件体积大,不入库;运行时手动放入 app/src/main/assets/
|
||||||
*.onnx
|
*.onnx
|
||||||
~$*.docx
|
|
||||||
models/
|
|
||||||
|
|
|
||||||
|
|
@ -5,9 +5,7 @@ plugins {
|
||||||
|
|
||||||
android {
|
android {
|
||||||
namespace 'com.k2fsa.sherpa.onnx'
|
namespace 'com.k2fsa.sherpa.onnx'
|
||||||
// appcompat 1.6.1(应用内语言切换所需)要求 compileSdk≥33;仅编译期提升,
|
compileSdk 32
|
||||||
// targetSdk 保持 32(运行时行为不变),minSdk 保持 21(装机范围不变)
|
|
||||||
compileSdk 33
|
|
||||||
|
|
||||||
defaultConfig {
|
defaultConfig {
|
||||||
applicationId "com.k2fsa.sherpa.onnx"
|
applicationId "com.k2fsa.sherpa.onnx"
|
||||||
|
|
@ -50,7 +48,7 @@ android {
|
||||||
dependencies {
|
dependencies {
|
||||||
|
|
||||||
implementation 'androidx.core:core-ktx:1.7.0'
|
implementation 'androidx.core:core-ktx:1.7.0'
|
||||||
implementation 'androidx.appcompat:appcompat:1.6.1'
|
implementation 'androidx.appcompat:appcompat:1.5.1'
|
||||||
implementation 'com.google.android.material:material:1.7.0'
|
implementation 'com.google.android.material:material:1.7.0'
|
||||||
implementation 'androidx.constraintlayout:constraintlayout:2.1.4'
|
implementation 'androidx.constraintlayout:constraintlayout:2.1.4'
|
||||||
testImplementation 'junit:junit:4.13.2'
|
testImplementation 'junit:junit:4.13.2'
|
||||||
|
|
|
||||||
|
|
@ -1,8 +1,7 @@
|
||||||
# 摘要功能三段提示词(里程碑 1)
|
# 摘要功能三段提示词(里程碑 1)
|
||||||
|
|
||||||
> 适用模型:`google/gemma-4-E2B-it-qat-q4_0-gguf` 的 `gemma-4-E2B_q4_0-it.gguf`
|
> 适用模型:`gemma-3-1b-it`(ggml-org/gemma-3-1b-it-GGUF,Q4_K_M;2026-09-11 由
|
||||||
> (架构 gemma4。2026-09-16 由 MiniCPM5-2B 切回:E2B 为真机验证过的稳定引擎,
|
> gemma-4-E2B 换小提速。此前:`google/gemma-4-E2B-it-qat-q4_0-gguf`,架构 gemma4,Apache-2.0)
|
||||||
> MiniCPM5 多次"空模板回显")
|
|
||||||
> 本文件是唯一事实源:App 内 `SummarizePipeline.kt` 逐字引用这里的文本。改提示词先改本文件。
|
> 本文件是唯一事实源:App 内 `SummarizePipeline.kt` 逐字引用这里的文本。改提示词先改本文件。
|
||||||
> PC 调试方法见文末「用 PC llama.cpp 复现」。
|
> PC 调试方法见文末「用 PC llama.cpp 复现」。
|
||||||
|
|
||||||
|
|
@ -10,81 +9,96 @@
|
||||||
|
|
||||||
| 步骤 | temperature | maxNewTokens | 备注 |
|
| 步骤 | temperature | maxNewTokens | 备注 |
|
||||||
|---|---|---|---|
|
|---|---|---|---|
|
||||||
| P1 内容概括更新 | 0(Greedy) | 900 | 可复现;概括要求确定性 |
|
| P1 滚动笔记更新 | 0(Greedy) | 900 | 可复现;笔记要求确定性 |
|
||||||
| P2 最终输出 | 0.5 / topK 64 / topP 0.95 | 1600 | 低温采样,兼顾自然度与可复现 |
|
| P2 成稿 | 0.5 / topK 64 / topP 0.95 | 2500 | Gemma 4 官方推荐采样档位,低温折中 |
|
||||||
|
|
||||||
思考(thinking)关闭:App 渲染模板时不注入 `<|think|>` 标记;模型若仍输出 `<|channel>thought…` 块,App 侧正则剔除。
|
思考(thinking)关闭:App 渲染模板时不注入 `<|think|>` 标记;模型若仍输出 `<|channel>thought…` 块,App 侧正则剔除。
|
||||||
|
|
||||||
## 1. 聊天模板(以代码为准)
|
## 1. 聊天模板(App 手工渲染,与 Gemma 4 canonical template 单轮路径等价)
|
||||||
|
|
||||||
模板由 `LlmEngine.renderChatPrompt` 渲染(gemma4;2026-09-16 起 gemma3/MiniCPM 分支已随各自引擎退役)。PC 调试直接加 --jinja 用内置模板即可。
|
```
|
||||||
|
<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 上下文耗尽即停。
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## P1 — 内容概括更新
|
## P1 — 滚动笔记更新
|
||||||
|
|
||||||
### SYSTEM(固定,勿动态改写)
|
### SYSTEM(固定,勿动态改写)
|
||||||
|
|
||||||
```
|
```
|
||||||
你是会议内容概括员。请把【新的断片】合并进【当前概括】,只以JSON输出更新后的概括。
|
あなたは会議の文字起こしを処理する記録係です。与えられた「現在のメモ」に「新しい断片」の内容を統合し、更新後のメモをJSONだけで出力してください。
|
||||||
|
|
||||||
严格要求:
|
厳守事項:
|
||||||
- 只输出JSON对象:{"概括":"..."};禁止说明文字、代码围栏、开场语。
|
- 出力は指定スキーマのJSONオブジェクトのみ。説明文・コードフェンス・前置き禁止。
|
||||||
- 键名必须原样输出(只有这一个键),不得翻译或改写(程序按此键校验)。
|
- メモは日本語で書く。ただし人名・組織名・製品名・固有名詞は原文表記をそのまま維持。
|
||||||
- 「概括」是一段连贯流畅的叙述性概括,覆盖会议至今的全部重要内容;是滚动重写,不是断片列表,不得照抄断片原文。
|
- 数字、金額、日時、期限、回数、単位は原文どおり正確に転記。ざっくり要約しない。
|
||||||
- 用与会议相同的语言书写;人名、组织名、产品名等专有名词保留原文写法。
|
- 否定・保留・反対の意図を消さない。「見送り」「再検討」「反対意見」はそれ自体が重要な情報。
|
||||||
- 数字、金额、日期时间、期限、次数、单位按原文准确转记,不许模糊概括。
|
- 各項目に根拠となる発話のタイムレンジを「出典」に付ける(形式: "mm:ss-mm:ss"、複数可 ";" 区切り)。
|
||||||
- 不得遗漏否定、保留、反对的意图;决定事项与未决事项均要体现。
|
- 断片に新情報がない場合はメモをほぼ変更しない(前回末尾は必ず更新)。
|
||||||
- 概括控制在400字以内;超出时压缩合并旧内容,保留主干与最新进展。
|
- 全体で500字を超えそうな場合、重要度の低い過去の項目を統合・削除して圧縮する。削除時は行動項目と已定事項を最後に守る。
|
||||||
- JSON字符串值内需要双引号时转义为\";换行用\n。
|
- 「前回末尾」には、次の断片と文脈が繋がるための直近話題を1文で書く。
|
||||||
|
- JSONの値の中にダブルクォートが必要なら \" にエスケープ。改行は \n を使う。
|
||||||
|
|
||||||
schema(键名禁止改动):
|
スキーマ(キー名は変更禁止):
|
||||||
{"概括":""}
|
{"已定事項":[{"内容":"","出典":""}],"討論中":[{"内容":"","出典":""}],"行動項目":[{"内容":"","担当者":"","期限":"","出典":""}],"人物":[{"名前":"","立場":""}],"前回末尾":""}
|
||||||
```
|
```
|
||||||
|
|
||||||
### USER(每块渲染;`{}` 为占位符)
|
### USER(每块渲染;`{}` 为占位符)
|
||||||
|
|
||||||
```
|
```
|
||||||
【当前概括】
|
【現在のメモ】
|
||||||
{notes_json}
|
{notes_json}
|
||||||
|
|
||||||
【新的断片】(时间戳为会议开始的 分:秒,[]内是说话人标签)
|
【新しい断片】(タイムスタンプは会議開始からの分:秒、[]内は話者ラベル)
|
||||||
{chunk_text}
|
{chunk_text}
|
||||||
|
|
||||||
请输出更新后的概括JSON。
|
更新後のメモJSONを出力してください。
|
||||||
```
|
```
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## P2 — 最终输出
|
## P2 — 最终成稿
|
||||||
|
|
||||||
### SYSTEM(固定)
|
### SYSTEM(固定)
|
||||||
|
|
||||||
```
|
```
|
||||||
你是会议概括撰写者。收到会议内容概括(JSON)与术语表后,用指定成稿语言输出内容概括。
|
あなたは会議の公式議事録作成者です。会議メモ(JSON)と用語集を受け取ったら、指定言語で完成した議事録をMarkdownで出力してください。
|
||||||
|
|
||||||
严格要求:
|
厳守事項:
|
||||||
- 输出两部分:先一段连贯的概括正文,空一行后给出要点。
|
- 出力言語は指定された「成稿言語」を厳守。日本語メモの内容をそのまま指定言語の書き言葉文体で記述する。
|
||||||
- 要点3~6条,每条单独一行、以「・」开头;不加小标题、不分类、不标时间、不写出处。
|
- 本文は成稿言語の文字のみで書く:中文稿なら仮名を一切出さない、English稿なら漢字・仮名を一切出さない、日本語稿なら製品名以外に英語の文を残さない。
|
||||||
- 概括正文目标200~400字,覆盖会议主干与结论;要点摘出最值得记住的3~6条。
|
- ただし人物・会社・ブランドなどの固有名詞は、メモ・用語集にある原文表記のまま保留してよい(上記の文字制限の例外)。
|
||||||
- 输出语言严格遵守「成稿语言」,用书面体:中文稿不得出现日文假名;英文稿不得出现汉字与假名;日文稿除专名外不得残留英文句子。
|
- 見出しは次の4つを必ずこの順で:「議題と経過」「決定事項」「行動項目」「未決事項・持ち帰り」。該当ゼロなら「(なし)」と明記。
|
||||||
- 人物、公司、品牌等专有名词可保留原文写法(上述限制的例外)。
|
- 行動項目は表形式(担当者 / 内容 / 期限 / 出典)。担当者が不明なら「未定」と書く。
|
||||||
- 不得添加概括中没有的事实、推测或补充说明;人名、产品名以术语表的正式写法统一。
|
- 各項目の末尾に出典を付ける。形式: `出典: mm:ss-mm:ss`。出典はメモJSONの「出典」フィールドのみを使う。捏造禁止。
|
||||||
- 只输出正文,不要Markdown标题、不要代码围栏。
|
- メモにない事実・推測・補足説明を追加しない。
|
||||||
|
- 人名・製品名は用語集の正式表記に統一する(用語集にない表記はメモのまま採用)。
|
||||||
|
- 冒頭にメタ情報を置く: 会議日時(指定があれば)、話者数、使用言語、文字起こし総尺。
|
||||||
|
- Markdown本文のみ出力。コードフェンスで全体を囲まない。
|
||||||
```
|
```
|
||||||
|
|
||||||
### USER
|
### USER
|
||||||
|
|
||||||
```
|
```
|
||||||
【成稿语言】{language_name}
|
【成稿言語】{language_name}
|
||||||
|
|
||||||
【术语表(正式写法列表;人名、产品名的错误写法以此统一)】
|
【会議メタ情報】開始: {meeting_start} / 話者数: {n_speakers} / 総尺: {total_duration}
|
||||||
|
|
||||||
|
【用語集(正式表記の一覧。人名・製品名の誤変換をこれに統一)】
|
||||||
{glossary_lines}
|
{glossary_lines}
|
||||||
|
|
||||||
【会议内容概括JSON】
|
【会議メモJSON】
|
||||||
{notes_json}
|
{notes_json}
|
||||||
|
|
||||||
请输出内容概括与要点。
|
上記に基づき、議事録Markdownを出力してください。
|
||||||
```
|
```
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
@ -101,18 +115,18 @@ schema(键名禁止改动):
|
||||||
|
|
||||||
- 目标 2500±500 字/块,按句子边界聚合,块间重叠 1 句;同一说话人轮次不拆开。
|
- 目标 2500±500 字/块,按句子边界聚合,块间重叠 1 句;同一说话人轮次不拆开。
|
||||||
- 行格式:`(mm:ss-mm:ss)[话者名] 文本`,时间戳来自 SessionTranscript。
|
- 行格式:`(mm:ss-mm:ss)[话者名] 文本`,时间戳来自 SessionTranscript。
|
||||||
- 首轮 `{notes_json}` = 空 schema(`{"概括":""}`)。
|
- 首轮 `{notes_json}` = 空 schema(`{"已定事項":[],"討論中":[],"行動項目":[],"人物":[],"前回末尾":""}`)。
|
||||||
- 单块+笔记的总输入若超过 ~5000 字符,Kotlin 先把该块对半拆成两次 P1 调用(块长自适应,不截断内容)。
|
- 单块+笔记的总输入若超过 ~5000 字符,Kotlin 先把该块对半拆成两次 P1 调用(块长自适应,不截断内容)。
|
||||||
|
|
||||||
## 用 PC llama.cpp 复现(提示词调优用)
|
## 用 PC llama.cpp 复现(提示词调优用)
|
||||||
|
|
||||||
1. 取 llama.cpp master(gemma3 及更早架构均支持,App 的 `libsummarize-llm-jni.so` 无需重编)。
|
1. 取 llama.cpp master(≥2026-09;gemma3 架构早已支持,App 的 `libsummarize-llm-jni.so` 无需重编)。
|
||||||
2. 模型:`gemma-4-E2B_q4_0-it.gguf`(google/gemma-4-E2B-it-qat-q4_0-gguf,架构 gemma4)。
|
2. 模型:`gemma-3-1b-it-Q4_K_M.gguf`(ggml-org/gemma-3-1b-it-GGUF)。
|
||||||
3. 跑 P1 示例(替换 sys/user 两段;`--jinja` 走 GGUF 内置模板,与 App 的
|
3. 跑 P1 示例(替换 sys/user 两段;`--jinja` 走 GGUF 内置模板,与 App 的
|
||||||
`renderGemma4Prompt`(<|turn> 系标记)等价):
|
`renderGemma3Prompt`——system 并入首个 user turn——等价):
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
llama-cli -m gemma-4-E2B_q4_0-it.gguf -c 8192 -ctk q8_0 -ctv f16 --jinja \
|
llama-cli -m gemma-3-1b-it-Q4_K_M.gguf -c 8192 -ctk q8_0 -ctv f16 --jinja \
|
||||||
--temp 0 -n 900 \
|
--temp 0 -n 900 \
|
||||||
--sys "$(sed -n '/^### SYSTEM/,/^```$/p' prompts.md | sed '1,2d;$d')" \
|
--sys "$(sed -n '/^### SYSTEM/,/^```$/p' prompts.md | sed '1,2d;$d')" \
|
||||||
-p "$USER_PROMPT"
|
-p "$USER_PROMPT"
|
||||||
|
|
|
||||||
|
|
@ -1,66 +0,0 @@
|
||||||
package com.k2fsa.sherpa.onnx
|
|
||||||
|
|
||||||
import android.content.Context
|
|
||||||
import androidx.appcompat.app.AppCompatDelegate
|
|
||||||
import androidx.core.os.LocaleListCompat
|
|
||||||
import java.util.Locale
|
|
||||||
|
|
||||||
// ============================================================================
|
|
||||||
// LanguageUtil — 应用界面语言(中/英/日 + 跟随系统)
|
|
||||||
//
|
|
||||||
// 持久化在 SharedPreferences;启动时 apply() 一次;切换走
|
|
||||||
// AppCompatDelegate.setApplicationLocales(appcompat 1.6 自带 API<33 回移),
|
|
||||||
// 由框架自动重建 Activity。业务数据(声纹内部键、纪要 schema 键、jsonl)
|
|
||||||
// 一律不受界面语言影响 —— 语言只影响"显示层"。
|
|
||||||
// ============================================================================
|
|
||||||
object LanguageUtil {
|
|
||||||
const val MODE_SYSTEM = "system"
|
|
||||||
const val MODE_ZH = "zh"
|
|
||||||
const val MODE_EN = "en"
|
|
||||||
const val MODE_JA = "ja"
|
|
||||||
|
|
||||||
private const val PREFS = "lang"
|
|
||||||
private const val KEY = "mode"
|
|
||||||
|
|
||||||
fun mode(ctx: Context): String =
|
|
||||||
ctx.getSharedPreferences(PREFS, Context.MODE_PRIVATE)
|
|
||||||
.getString(KEY, MODE_SYSTEM) ?: MODE_SYSTEM
|
|
||||||
|
|
||||||
fun apply(ctx: Context) {
|
|
||||||
val locales = when (mode(ctx)) {
|
|
||||||
MODE_ZH -> LocaleListCompat.forLanguageTags("zh")
|
|
||||||
MODE_EN -> LocaleListCompat.forLanguageTags("en")
|
|
||||||
MODE_JA -> LocaleListCompat.forLanguageTags("ja")
|
|
||||||
else -> LocaleListCompat.getEmptyLocaleList() // 跟随系统
|
|
||||||
}
|
|
||||||
// 值相同则为 no-op,不会触发重建
|
|
||||||
if (AppCompatDelegate.getApplicationLocales() != locales) {
|
|
||||||
AppCompatDelegate.setApplicationLocales(locales)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fun set(ctx: Context, m: String) {
|
|
||||||
ctx.getSharedPreferences(PREFS, Context.MODE_PRIVATE)
|
|
||||||
.edit().putString(KEY, m).apply()
|
|
||||||
// setApplicationLocales 会自动 recreate;system 模式下清空后回退
|
|
||||||
// 到当前显示语言,避免"空 locale 不生效导致不重建"
|
|
||||||
val target = if (m == MODE_SYSTEM) currentFallback() else m
|
|
||||||
val locales = when (target) {
|
|
||||||
MODE_JA -> LocaleListCompat.forLanguageTags("ja")
|
|
||||||
MODE_EN -> LocaleListCompat.forLanguageTags("en")
|
|
||||||
else -> LocaleListCompat.forLanguageTags("zh")
|
|
||||||
}
|
|
||||||
AppCompatDelegate.setApplicationLocales(locales)
|
|
||||||
if (m == MODE_SYSTEM) {
|
|
||||||
// 再切回真正的跟随系统(下一次启动生效)
|
|
||||||
ctx.getSharedPreferences(PREFS, Context.MODE_PRIVATE)
|
|
||||||
.edit().putString(KEY, MODE_SYSTEM).apply()
|
|
||||||
AppCompatDelegate.setApplicationLocales(
|
|
||||||
LocaleListCompat.forLanguageTags(currentFallback())
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun currentFallback(): String =
|
|
||||||
Locale.getDefault().language.takeIf { it == "ja" || it == "en" } ?: "zh"
|
|
||||||
}
|
|
||||||
|
|
@ -46,7 +46,7 @@ class LlmEngine(
|
||||||
nThreads: Int = 6, // 8 Gen3 大核 4 个,给摘要留余量:6 线程够用
|
nThreads: Int = 6, // 8 Gen3 大核 4 个,给摘要留余量:6 线程够用
|
||||||
kvTypeK: String = "q8_0", // KV 用 q8_0 压显存/内存(§3)
|
kvTypeK: String = "q8_0", // KV 用 q8_0 压显存/内存(§3)
|
||||||
kvTypeV: String = "f16", // V 保持 f16:K 量化到 q8_0 是 llama.cpp 推荐档
|
kvTypeV: String = "f16", // V 保持 f16:K 量化到 q8_0 是 llama.cpp 推荐档
|
||||||
chatTemplate: String = "gemma4", // 现仅 "gemma4";须与 GGUF 内嵌模板一致
|
chatTemplate: String = "gemma4", // "gemma4" | "gemma3",须与 GGUF 内嵌模板一致
|
||||||
) {
|
) {
|
||||||
companion object {
|
companion object {
|
||||||
private const val TAG = "sherpa-onnx"
|
private const val TAG = "sherpa-onnx"
|
||||||
|
|
@ -62,27 +62,23 @@ class LlmEngine(
|
||||||
append("<|turn>model\n")
|
append("<|turn>model\n")
|
||||||
}
|
}
|
||||||
|
|
||||||
// Gemma 3 模板分支 2026-09-16 随翻译单引擎化退役(历史见 git history)
|
// ---- Gemma 3 官方模板的单轮渲染(ggml-org/gemma-3-1b-it-GGUF)----
|
||||||
|
// 官方 jinja:system 不作为独立 turn,而是拼进第一个 user turn;
|
||||||
// MiniCPM5 模板分支 2026-09-16 随引擎退役摘除(三连空模板回显,
|
// <start_of_turn>user\n{system}\n\n{user}<end_of_turn>\n<start_of_turn>model\n
|
||||||
// 用户拍板弃用;历史实现见 git history)
|
// EOG token 是 <end_of_turn>(gemma3 的 eot),依赖 native 按 eog 停止。
|
||||||
|
fun renderGemma3Prompt(system: String, user: String): String =
|
||||||
// ---- Hunyuan-MT(Hy-MT2)canonical 模板,单轮 user ----
|
|
||||||
// 与 GGUF 内嵌模板(add_generation_prompt 路径,无 system)逐字等价:
|
|
||||||
// <|hy_begin▁of▁sentence|><|hy_User|>{user}<|hy_Assistant|>
|
|
||||||
// 生成结束依赖原生 eos(= <|hy_place▁holder▁no▁2|>,id 120020),
|
|
||||||
// JNI 现成的 eos 判定即可,无需额外停止串。
|
|
||||||
fun renderHunyuanPrompt(user: String): String =
|
|
||||||
buildString {
|
buildString {
|
||||||
append("<|hy_begin▁of▁sentence|>")
|
append("<start_of_turn>user\n")
|
||||||
append("<|hy_User|>").append(user.trim())
|
append(system.trim()).append("\n\n")
|
||||||
append("<|hy_Assistant|>")
|
append(user.trim())
|
||||||
|
append("<end_of_turn>\n")
|
||||||
|
append("<start_of_turn>model\n")
|
||||||
}
|
}
|
||||||
|
|
||||||
fun renderChatPrompt(system: String, user: String,
|
fun renderChatPrompt(system: String, user: String,
|
||||||
template: String = "gemma4"): String =
|
template: String = "gemma4"): String =
|
||||||
when (template) {
|
when (template) {
|
||||||
"hunyuan" -> renderHunyuanPrompt(user)
|
"gemma3" -> renderGemma3Prompt(system, user)
|
||||||
else -> renderGemma4Prompt(system, user)
|
else -> renderGemma4Prompt(system, user)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -104,10 +100,6 @@ class LlmEngine(
|
||||||
/**
|
/**
|
||||||
* 阻塞式生成(仅供摘要后台线程调用)。返回模型输出文本(不含输入)。
|
* 阻塞式生成(仅供摘要后台线程调用)。返回模型输出文本(不含输入)。
|
||||||
* temperature=0 为 Greedy。abort() 可从其它线程安全打断。
|
* temperature=0 为 Greedy。abort() 可从其它线程安全打断。
|
||||||
* repeatPenalty>1 启用重复惩罚(翻译路径传 1.1 治复读循环)。
|
|
||||||
* seed>0 = 显式随机种子(翻译路径每次点击换盐+重试错位,让重试真再抽签);
|
|
||||||
* seed<=0 = 旧行为 42+温度错位(摘要路径用)。
|
|
||||||
* minNew>0 = 前 minNew 步禁 EOS(防"空输出",翻译路径传 4;摘要传 0)。
|
|
||||||
*/
|
*/
|
||||||
fun generate(
|
fun generate(
|
||||||
systemPrompt: String,
|
systemPrompt: String,
|
||||||
|
|
@ -116,14 +108,11 @@ class LlmEngine(
|
||||||
temperature: Float = 0f,
|
temperature: Float = 0f,
|
||||||
topK: Int = 64,
|
topK: Int = 64,
|
||||||
topP: Float = 0.95f,
|
topP: Float = 0.95f,
|
||||||
repeatPenalty: Float = 0f,
|
|
||||||
seed: Int = 0,
|
|
||||||
minNew: Int = 0,
|
|
||||||
): String {
|
): String {
|
||||||
require(ptr != 0L) { "LlmEngine already released" }
|
require(ptr != 0L) { "LlmEngine already released" }
|
||||||
return nativeGenerate(
|
return nativeGenerate(
|
||||||
ptr, renderChatPrompt(systemPrompt, userPrompt, chatTemplate),
|
ptr, renderChatPrompt(systemPrompt, userPrompt, chatTemplate),
|
||||||
maxNewTokens, temperature, topK, topP, repeatPenalty, seed, minNew
|
maxNewTokens, temperature, topK, topP
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -153,8 +142,7 @@ class LlmEngine(
|
||||||
|
|
||||||
private external fun nativeGenerate(
|
private external fun nativeGenerate(
|
||||||
ptr: Long, prompt: String, maxNewTokens: Int,
|
ptr: Long, prompt: String, maxNewTokens: Int,
|
||||||
temperature: Float, topK: Int, topP: Float, repeatPenalty: Float,
|
temperature: Float, topK: Int, topP: Float
|
||||||
seed: Int, minNew: Int
|
|
||||||
): String
|
): String
|
||||||
|
|
||||||
private external fun nativeAbort(ptr: Long)
|
private external fun nativeAbort(ptr: Long)
|
||||||
|
|
|
||||||
File diff suppressed because it is too large
Load Diff
|
|
@ -39,13 +39,6 @@ class SessionTranscript private constructor(
|
||||||
private val entries = ArrayList<TranscriptEntry>()
|
private val entries = ArrayList<TranscriptEntry>()
|
||||||
private val bySid = HashMap<Long, TranscriptEntry>()
|
private val bySid = HashMap<Long, TranscriptEntry>()
|
||||||
private var writer: BufferedWriter? = null
|
private var writer: BufferedWriter? = null
|
||||||
// 暂停累计时长:定格句子的相对时间轴要扣除中场休息(需求:暂停/继续)
|
|
||||||
private var pauseOffsetMs = 0L
|
|
||||||
|
|
||||||
/** 恢复录音时调用,累计暂停掉的毫秒数。 */
|
|
||||||
fun addPaused(ms: Long) {
|
|
||||||
if (ms > 0) pauseOffsetMs += ms
|
|
||||||
}
|
|
||||||
|
|
||||||
companion object {
|
companion object {
|
||||||
private const val TAG = "sherpa-onnx"
|
private const val TAG = "sherpa-onnx"
|
||||||
|
|
@ -145,11 +138,10 @@ class SessionTranscript private constructor(
|
||||||
|
|
||||||
// ---- 主线程挂钩点 --------------------------------------------------------
|
// ---- 主线程挂钩点 --------------------------------------------------------
|
||||||
|
|
||||||
/** finalizeSentence 处调用:endMs=定格时刻(相对会话开始,已扣暂停)。 */
|
/** finalizeSentence 处调用:endMs=定格时刻(相对会话开始),startMs=endMs-dur。 */
|
||||||
fun onSettled(sid: Long, durMs: Long, text: String) {
|
fun onSettled(sid: Long, durMs: Long, text: String) {
|
||||||
if (text.isBlank()) return
|
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)
|
val e = TranscriptEntry(sid, (end - durMs).coerceAtLeast(0), end, null, text, false)
|
||||||
bySid[sid] = e; entries.add(e)
|
bySid[sid] = e; entries.add(e)
|
||||||
appendLine(JSONObject().apply {
|
appendLine(JSONObject().apply {
|
||||||
|
|
|
||||||
|
|
@ -99,12 +99,6 @@ class SpeakerRegistry {
|
||||||
auto.add(Pair(name, l2normalize(e)))
|
auto.add(Pair(name, l2normalize(e)))
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 撤销乐观切换时移除占位编号(2026-09-16:防被清掉的号继续被检索命中)。 */
|
|
||||||
@Synchronized
|
|
||||||
fun removeAuto(name: String) {
|
|
||||||
auto.removeAll { it.first == name }
|
|
||||||
}
|
|
||||||
|
|
||||||
// Enroll (or overwrite) a real name for a given embedding.
|
// Enroll (or overwrite) a real name for a given embedding.
|
||||||
@Synchronized
|
@Synchronized
|
||||||
fun addNamed(name: String, e: FloatArray) {
|
fun addNamed(name: String, e: FloatArray) {
|
||||||
|
|
@ -130,33 +124,23 @@ class SpeakerRegistry {
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 最近邻兜底(收紧版,P1):按"名字级"聚合所有嵌入,返回最优名与
|
* 最近邻兜底(用户要求 2026-09-11):"未识别"归到与 e 最相似的已注册者
|
||||||
* 名级最高/次高分。同人多嵌入只算一个竞争者,优势差判定才有意义;
|
* (命名条目优先,其后自动条目;相似度须 ≥ minScore)。全空/不达标返回 null。
|
||||||
* 调用方用 score≥minScore 且 (score-secondScore)≥margin 才归入,
|
|
||||||
* 模糊时判新人(宁多勿错)。
|
|
||||||
*/
|
*/
|
||||||
class Rank(val name: String?, val score: Float, val secondScore: Float)
|
|
||||||
|
|
||||||
@Synchronized
|
@Synchronized
|
||||||
fun nearestRank(e: FloatArray): Rank {
|
fun nearest(e: FloatArray, minScore: Float): String? {
|
||||||
val q = l2normalize(e)
|
val q = l2normalize(e)
|
||||||
val byName = LinkedHashMap<String, Float>()
|
var bestName: String? = null
|
||||||
|
var best = -1f
|
||||||
for ((name, emb) in named) {
|
for ((name, emb) in named) {
|
||||||
val s = dot(q, emb)
|
val s = dot(q, emb)
|
||||||
if (s > (byName[name] ?: -2f)) byName[name] = s
|
if (s > best) { best = s; bestName = name }
|
||||||
}
|
}
|
||||||
for ((name, emb) in auto) {
|
for ((name, emb) in auto) {
|
||||||
val s = dot(q, emb)
|
val s = dot(q, emb)
|
||||||
if (s > (byName[name] ?: -2f)) byName[name] = s
|
if (s > best) { best = s; bestName = name }
|
||||||
}
|
}
|
||||||
var best: String? = null
|
return if (best >= minScore) bestName else 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)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
@ -221,10 +205,9 @@ class SpeakerRegistry {
|
||||||
// Singleton holder shared by the UI thread, recording thread and the rolling
|
// Singleton holder shared by the UI thread, recording thread and the rolling
|
||||||
// speaker-check thread.
|
// speaker-check thread.
|
||||||
//
|
//
|
||||||
// 单模型方案(2026-09-16 用户拍板):仅 CAM++;eres2net 系整体退役。
|
// 双模型方案(E):准确模型(eres2netv2)负责 endpoint 权威判定与注册;
|
||||||
// fastReady()=false 时:computeEmbeddingFast 回落 CAM++,滚动检索直接走
|
// 轻量模型(eres2net base,可选)只负责滚动换人检测(推理快 ~4 倍)。
|
||||||
// 准确注册表(speakerThreshold 0.5 / verdictFallbackMin 0.42)。镜像注册
|
// ⚠ 两个模型的 embedding 不互通:各自配一份 registry,注册时双写镜像。
|
||||||
// 机制保留但不再有调用者(fastEmb 门控后恒 null)。
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
object SpeakerRecognition {
|
object SpeakerRecognition {
|
||||||
@Volatile private var _extractor: SpeakerEmbeddingExtractor? = null
|
@Volatile private var _extractor: SpeakerEmbeddingExtractor? = null
|
||||||
|
|
|
||||||
|
|
@ -5,7 +5,7 @@ import android.util.Log
|
||||||
import org.json.JSONObject
|
import org.json.JSONObject
|
||||||
|
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
// SummarizePipeline — §5 摘要流水线(分块 → 内容概括滚动更新 → 概要输出;2026-09-16 简化)
|
// SummarizePipeline — §5 摘要流水线(分块 → 滚动笔记 → 成稿)
|
||||||
//
|
//
|
||||||
// 提示词唯一事实源是 assets/summarize/prompts.md(用户可直接编辑/复制到 PC
|
// 提示词唯一事实源是 assets/summarize/prompts.md(用户可直接编辑/复制到 PC
|
||||||
// llama.cpp 调试)。解析失败时退回本文件内置的同源副本 —— 两份不一致以 md
|
// llama.cpp 调试)。解析失败时退回本文件内置的同源副本 —— 两份不一致以 md
|
||||||
|
|
@ -87,37 +87,41 @@ object PromptsLoader {
|
||||||
return Prompts(p1s, p1u, p2s, p2u)
|
return Prompts(p1s, p1u, p2s, p2u)
|
||||||
}
|
}
|
||||||
|
|
||||||
// 与 prompts.md 同源的内置副本(md 丢失/被改坏时的兜底;2026-09-16 起
|
// 与 prompts.md 同源的内置副本(md 丢失/被改坏时的兜底)。
|
||||||
// 简化为"内容概括"单项)。一致性单测逐字比对,改一处必须两处同改。
|
|
||||||
val BUILTIN = Prompts(
|
val BUILTIN = Prompts(
|
||||||
p1System = "你是会议内容概括员。请把【新的断片】合并进【当前概括】,只以JSON输出更新后的概括。\n\n" +
|
p1System = "あなたは会議の文字起こしを処理する記録係です。与えられた「現在のメモ」に「新しい断片」の内容を統合し、更新後のメモをJSONだけで出力してください。\n\n" +
|
||||||
"严格要求:\n" +
|
"厳守事項:\n" +
|
||||||
"- 只输出JSON对象:{\"概括\":\"...\"};禁止说明文字、代码围栏、开场语。\n" +
|
"- 出力は指定スキーマのJSONオブジェクトのみ。説明文・コードフェンス・前置き禁止。\n" +
|
||||||
"- 键名必须原样输出(只有这一个键),不得翻译或改写(程序按此键校验)。\n" +
|
"- メモは日本語で書く。ただし人名・組織名・製品名・固有名詞は原文表記をそのまま維持。\n" +
|
||||||
"- 「概括」是一段连贯流畅的叙述性概括,覆盖会议至今的全部重要内容;是滚动重写,不是断片列表,不得照抄断片原文。\n" +
|
"- 数字、金額、日時、期限、回数、単位は原文どおり正確に転記。ざっくり要約しない。\n" +
|
||||||
"- 用与会议相同的语言书写;人名、组织名、产品名等专有名词保留原文写法。\n" +
|
"- 否定・保留・反対の意図を消さない。「見送り」「再検討」「反対意見」はそれ自体が重要な情報。\n" +
|
||||||
"- 数字、金额、日期时间、期限、次数、单位按原文准确转记,不许模糊概括。\n" +
|
"- 各項目に根拠となる発話のタイムレンジを「出典」に付ける(形式: \"mm:ss-mm:ss\"、複数可 \";\" 区切り)。\n" +
|
||||||
"- 不得遗漏否定、保留、反对的意图;决定事项与未决事项均要体现。\n" +
|
"- 断片に新情報がない場合はメモをほぼ変更しない(前回末尾は必ず更新)。\n" +
|
||||||
"- 概括控制在400字以内;超出时压缩合并旧内容,保留主干与最新进展。\n" +
|
"- 全体で500字を超えそうな場合、重要度の低い過去の項目を統合・削除して圧縮する。削除時は行動項目と已定事項を最後に守る。\n" +
|
||||||
"- JSON字符串值内需要双引号时转义为\\\";换行用\\n。\n\n" +
|
"- 「前回末尾」には、次の断片と文脈が繋がるための直近話題を1文で書く。\n" +
|
||||||
"schema(键名禁止改动):\n" +
|
"- JSONの値の中にダブルクォートが必要なら \\\" にエスケープ。改行は \\n を使う。\n\n" +
|
||||||
"{\"概括\":\"\"}",
|
"スキーマ(キー名は変更禁止):\n" +
|
||||||
p1UserTpl = "【当前概括】\n{notes_json}\n\n" +
|
"{\"已定事項\":[{\"内容\":\"\",\"出典\":\"\"}],\"討論中\":[{\"内容\":\"\",\"出典\":\"\"}],\"行動項目\":[{\"内容\":\"\",\"担当者\":\"\",\"期限\":\"\",\"出典\":\"\"}],\"人物\":[{\"名前\":\"\",\"立場\":\"\"}],\"前回末尾\":\"\"}",
|
||||||
"【新的断片】(时间戳为会议开始的 分:秒,[]内是说话人标签)\n{chunk_text}\n\n" +
|
p1UserTpl = "【現在のメモ】\n{notes_json}\n\n" +
|
||||||
"请输出更新后的概括JSON。",
|
"【新しい断片】(タイムスタンプは会議開始からの分:秒、[]内は話者ラベル)\n{chunk_text}\n\n" +
|
||||||
p2System = "你是会议概括撰写者。收到会议内容概括(JSON)与术语表后,用指定成稿语言输出内容概括。\n\n" +
|
"更新後のメモJSONを出力してください。",
|
||||||
"严格要求:\n" +
|
p2System = "あなたは会議の公式議事録作成者です。会議メモ(JSON)と用語集を受け取ったら、指定言語で完成した議事録をMarkdownで出力してください。\n\n" +
|
||||||
"- 输出两部分:先一段连贯的概括正文,空一行后给出要点。\n" +
|
"厳守事項:\n" +
|
||||||
"- 要点3~6条,每条单独一行、以「・」开头;不加小标题、不分类、不标时间、不写出处。\n" +
|
"- 出力言語は指定された「成稿言語」を厳守。日本語メモの内容をそのまま指定言語の書き言葉文体で記述する。\n" +
|
||||||
"- 概括正文目标200~400字,覆盖会议主干与结论;要点摘出最值得记住的3~6条。\n" +
|
"- 本文は成稿言語の文字のみで書く:中文稿なら仮名を一切出さない、English稿なら漢字・仮名を一切出さない、日本語稿なら製品名以外に英語の文を残さない。\n" +
|
||||||
"- 输出语言严格遵守「成稿语言」,用书面体:中文稿不得出现日文假名;英文稿不得出现汉字与假名;日文稿除专名外不得残留英文句子。\n" +
|
"- ただし人物・会社・ブランドなどの固有名詞は、メモ・用語集にある原文表記のまま保留してよい(上記の文字制限の例外)。\n" +
|
||||||
"- 人物、公司、品牌等专有名词可保留原文写法(上述限制的例外)。\n" +
|
"- 見出しは次の4つを必ずこの順で:「議題と経過」「決定事項」「行動項目」「未決事項・持ち帰り」。該当ゼロなら「(なし)」と明記。\n" +
|
||||||
"- 不得添加概括中没有的事实、推测或补充说明;人名、产品名以术语表的正式写法统一。\n" +
|
"- 行動項目は表形式(担当者 / 内容 / 期限 / 出典)。担当者が不明なら「未定」と書く。\n" +
|
||||||
"- 只输出正文,不要Markdown标题、不要代码围栏。",
|
"- 各項目の末尾に出典を付ける。形式: `出典: mm:ss-mm:ss`。出典はメモJSONの「出典」フィールドのみを使う。捏造禁止。\n" +
|
||||||
p2UserTpl = "【成稿语言】{language_name}\n\n" +
|
"- メモにない事実・推測・補足説明を追加しない。\n" +
|
||||||
"【术语表(正式写法列表;人名、产品名的错误写法以此统一)】\n{glossary_lines}\n\n" +
|
"- 人名・製品名は用語集の正式表記に統一する(用語集にない表記はメモのまま採用)。\n" +
|
||||||
"【会议内容概括JSON】\n{notes_json}\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を出力してください。",
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -136,7 +140,8 @@ class SummarizePipeline(
|
||||||
// 保证 ≤8192 ctx 永远够用(日语 1 字 ≈ 1 token 多一点)
|
// 保证 ≤8192 ctx 永远够用(日语 1 字 ≈ 1 token 多一点)
|
||||||
private const val P1_MAX_PROMPT_CHARS = 6500
|
private const val P1_MAX_PROMPT_CHARS = 6500
|
||||||
private const val NOTES_RESERVE = 1200 // 笔记+模板文案预留
|
private const val NOTES_RESERVE = 1200 // 笔记+模板文案预留
|
||||||
val EMPTY_NOTES = "{\"概括\":\"\"}"
|
val EMPTY_NOTES =
|
||||||
|
"{\"已定事項\":[],\"討論中\":[],\"行動項目\":[],\"人物\":[],\"前回末尾\":\"\"}"
|
||||||
|
|
||||||
// 脚本占比判语种:假名出现即日语信号(日语里汉字占比也高,不能只看汉字);
|
// 脚本占比判语种:假名出现即日语信号(日语里汉字占比也高,不能只看汉字);
|
||||||
// 无假名时看汉字 vs 拉丁;全兜底 en。
|
// 无假名时看汉字 vs 拉丁;全兜底 en。
|
||||||
|
|
@ -165,9 +170,12 @@ class SummarizePipeline(
|
||||||
else -> "English (formal written English)"
|
else -> "English (formal written English)"
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 概括笔记是否为空(解析失败按空处理)。2026-09-16 起单项schema。 */
|
/** 笔记是否无任何实质条目(四类条目全空;"前回末尾"不算)。解析失败按空处理。 */
|
||||||
internal fun notesEntriesEmpty(notes: String): Boolean = runCatching {
|
internal fun notesEntriesEmpty(notes: String): Boolean = runCatching {
|
||||||
JSONObject(notes).optString("概括").isBlank()
|
val o = JSONObject(notes)
|
||||||
|
listOf("已定事項", "討論中", "行動項目", "人物").all { k ->
|
||||||
|
o.optJSONArray(k)?.length() == 0
|
||||||
|
}
|
||||||
}.getOrDefault(true)
|
}.getOrDefault(true)
|
||||||
|
|
||||||
fun mmss(ms: Long): String {
|
fun mmss(ms: Long): String {
|
||||||
|
|
@ -194,23 +202,6 @@ class SummarizePipeline(
|
||||||
// §5 分块铁律:句子边界聚合到 2500±500 字;块间重叠 1 句;
|
// §5 分块铁律:句子边界聚合到 2500±500 字;块间重叠 1 句;
|
||||||
// 同一说话人连续段(轮次)不拆开:切点落在同人连续段中间则延到段尾。
|
// 同一说话人连续段(轮次)不拆开:切点落在同人连续段中间则延到段尾。
|
||||||
// 另:超 P1 预算的块按行二分一次,保证 prompt ≤ ctx。
|
// 另:超 P1 预算的块按行二分一次,保证 prompt ≤ ctx。
|
||||||
|
|
||||||
/**
|
|
||||||
* schema 键名别名表:中文系小模型常把键写成变体(概要/总结/摘要…),
|
|
||||||
* 不规范化则 extractJson 永远判失败。2026-09-16 单项schema:都归「概括」。
|
|
||||||
*/
|
|
||||||
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> {
|
internal fun chunk(lines: List<String>): List<String> {
|
||||||
val blocks = ArrayList<String>()
|
val blocks = ArrayList<String>()
|
||||||
var i = 0
|
var i = 0
|
||||||
|
|
@ -283,11 +274,8 @@ class SummarizePipeline(
|
||||||
if (depth == 0) {
|
if (depth == 0) {
|
||||||
val cand = raw.substring(start, i + 1)
|
val cand = raw.substring(start, i + 1)
|
||||||
return try {
|
return try {
|
||||||
// 键名先规范化(变体别名→schema),再校验必需键
|
val o = JSONObject(cand)
|
||||||
val o = normalizeKeys(JSONObject(cand))
|
if (o.has("已定事項") && o.has("行動項目")) cand else null
|
||||||
if (o.optString("概括").isNotBlank())
|
|
||||||
o.toString()
|
|
||||||
else null
|
|
||||||
} catch (t: Throwable) {
|
} catch (t: Throwable) {
|
||||||
null
|
null
|
||||||
}
|
}
|
||||||
|
|
@ -378,7 +366,7 @@ class SummarizePipeline(
|
||||||
Log.i(TAG, "SUMMARIZE: ${entries.size} utterances -> $total chunks, " +
|
Log.i(TAG, "SUMMARIZE: ${entries.size} utterances -> $total chunks, " +
|
||||||
"lang=$lang, resumeIdx=$startIdx")
|
"lang=$lang, resumeIdx=$startIdx")
|
||||||
|
|
||||||
// ---- 内容概括滚动更新(§5.2)----
|
// ---- 滚动笔记(§5.2)----
|
||||||
var notes = resumeNotes ?: EMPTY_NOTES
|
var notes = resumeNotes ?: EMPTY_NOTES
|
||||||
var failed = 0
|
var failed = 0
|
||||||
val tP1Start = System.currentTimeMillis()
|
val tP1Start = System.currentTimeMillis()
|
||||||
|
|
@ -387,47 +375,31 @@ class SummarizePipeline(
|
||||||
val user = prompts.p1UserTpl
|
val user = prompts.p1UserTpl
|
||||||
.replace("{notes_json}", notes)
|
.replace("{notes_json}", notes)
|
||||||
.replace("{chunk_text}", chunkText)
|
.replace("{chunk_text}", chunkText)
|
||||||
listener.onProgress("内容概括", idx + 1, total)
|
listener.onProgress("滚动笔记", idx + 1, total)
|
||||||
val t0 = System.currentTimeMillis()
|
val t0 = System.currentTimeMillis()
|
||||||
var out = engine.generate(
|
var out = engine.generate(
|
||||||
systemPrompt = prompts.p1System, userPrompt = user,
|
systemPrompt = prompts.p1System, userPrompt = user,
|
||||||
maxNewTokens = 900, temperature = 0f,
|
maxNewTokens = 900, temperature = 0f,
|
||||||
)
|
)
|
||||||
var parsed = extractJson(out)
|
var parsed = extractJson(out)
|
||||||
// 空概括回显(EMPTY 进 EMPTY 出):小模型(尤其 MiniCPM5)会把提示词里
|
if (parsed == null) {
|
||||||
// 的空 schema 原样抄回。2026-09-16 现场 raw(9)={"概括":""}。
|
// 一次修复重试:把畸形输出递回去(仍失败则保留旧笔记,计 failed)
|
||||||
// 注意:extractJson 对空值已判 null,"parsed!=null"式旧探测永远
|
|
||||||
// 进不去——改为对原文正则探测,让重试提示词用准确措辞
|
|
||||||
val emptyEcho = parsed == null && idx == 0 && resumeNotes == null &&
|
|
||||||
Regex("\"概括\"\\s*:\\s*\"\\s*\"").containsMatchIn(out)
|
|
||||||
if (parsed == null || emptyEcho) {
|
|
||||||
// 一次修复重试:把畸形/空洞输出递回去(仍失败保留旧笔记,计 failed)
|
|
||||||
if (listener.isCancelled()) throw SummarizeCancelledException()
|
if (listener.isCancelled()) throw SummarizeCancelledException()
|
||||||
val why = if (emptyEcho)
|
val fix = user + "\n\n(前回の出力はJSONとして不正でした: " +
|
||||||
"上一次输出是空的概括。"
|
out.take(200).replace("\n", " ") + " 正しいJSONのみを再出力してください。)"
|
||||||
else
|
|
||||||
"上一次输出不是合法JSON: " +
|
|
||||||
out.take(200).replace("\n", " ") + "。"
|
|
||||||
val fix = user + "\n\n(" + why +
|
|
||||||
"请把【新的断片】的内容合并进概括,只输出修正后的JSON," +
|
|
||||||
"禁止照抄输入概括。)"
|
|
||||||
out = engine.generate(
|
out = engine.generate(
|
||||||
systemPrompt = prompts.p1System, userPrompt = fix,
|
systemPrompt = prompts.p1System, userPrompt = fix,
|
||||||
maxNewTokens = 900, temperature = 0f,
|
maxNewTokens = 900, temperature = 0f,
|
||||||
)
|
)
|
||||||
val p2 = extractJson(out)
|
parsed = extractJson(out)
|
||||||
if (p2 != null && !notesEntriesEmpty(p2)) parsed = p2
|
|
||||||
}
|
}
|
||||||
if (parsed != null) notes = parsed
|
if (parsed != null) notes = parsed
|
||||||
else {
|
else {
|
||||||
failed++
|
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
|
// 及时止损:第一块都解析不出来,笔记链条已断,后面块只会白烧 token
|
||||||
if (idx == 0) {
|
if (idx == 0) {
|
||||||
throw SummarizeEmptyException("开头转写片段无法生成有效概括(内容过少或输出不可解析)")
|
Log.w(TAG, "P1 first chunk unparsable, abort. raw=${out.take(120)}")
|
||||||
|
throw SummarizeEmptyException("开头转写片段无法生成有效笔记(内容过少或输出不可解析)")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
// 测量点(baseline,不改行为):raw≫json 说明解码浪费在废话/思考上;
|
// 测量点(baseline,不改行为):raw≫json 说明解码浪费在废话/思考上;
|
||||||
|
|
@ -441,9 +413,7 @@ class SummarizePipeline(
|
||||||
"chunks=$total failed=$failed")
|
"chunks=$total failed=$failed")
|
||||||
// 空笔记终检:全部块失败,或笔记仍是空壳(total==0 已在入口取消)
|
// 空笔记终检:全部块失败,或笔记仍是空壳(total==0 已在入口取消)
|
||||||
if (failed == total || notesEntriesEmpty(notes)) {
|
if (failed == total || notesEntriesEmpty(notes)) {
|
||||||
throw SummarizeEmptyException("整场转写未能形成有效概括" +
|
throw SummarizeEmptyException("整场转写未能形成任何笔记条目")
|
||||||
"(failed=$failed/$total, notes=" +
|
|
||||||
notes.replace("\n", " ").take(120) + ")")
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---- 成稿(§5.3)----
|
// ---- 成稿(§5.3)----
|
||||||
|
|
@ -457,7 +427,7 @@ class SummarizePipeline(
|
||||||
val tP2 = System.currentTimeMillis()
|
val tP2 = System.currentTimeMillis()
|
||||||
val md = cleanDraft(engine.generate(
|
val md = cleanDraft(engine.generate(
|
||||||
systemPrompt = prompts.p2System, userPrompt = user2,
|
systemPrompt = prompts.p2System, userPrompt = user2,
|
||||||
maxNewTokens = 1600, temperature = 0.5f, topK = 64, topP = 0.95f,
|
maxNewTokens = 2500, temperature = 0.5f, topK = 64, topP = 0.95f,
|
||||||
))
|
))
|
||||||
Log.i(TAG, "P2 draft prompt_len=${user2.length} draft_len=${md.length} " +
|
Log.i(TAG, "P2 draft prompt_len=${user2.length} draft_len=${md.length} " +
|
||||||
"ms=${System.currentTimeMillis() - tP2}")
|
"ms=${System.currentTimeMillis() - tP2}")
|
||||||
|
|
|
||||||
|
|
@ -11,53 +11,6 @@
|
||||||
android:layout_height="match_parent"
|
android:layout_height="match_parent"
|
||||||
android:orientation="vertical">
|
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
|
<FrameLayout
|
||||||
android:layout_width="match_parent"
|
android:layout_width="match_parent"
|
||||||
android:layout_height="0dp"
|
android:layout_height="0dp"
|
||||||
|
|
@ -104,13 +57,6 @@
|
||||||
android:layout_width="wrap_content"
|
android:layout_width="wrap_content"
|
||||||
android:layout_height="wrap_content"
|
android:layout_height="wrap_content"
|
||||||
android:text="@string/start" />
|
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>
|
</LinearLayout>
|
||||||
|
|
||||||
<!-- 纪要面板:状态一行 + 正文(hint 占位/markdown 正文叠放) -->
|
<!-- 纪要面板:状态一行 + 正文(hint 占位/markdown 正文叠放) -->
|
||||||
|
|
@ -135,7 +81,7 @@
|
||||||
android:layout_height="match_parent"
|
android:layout_height="match_parent"
|
||||||
android:gravity="center"
|
android:gravity="center"
|
||||||
android:padding="16dp"
|
android:padding="16dp"
|
||||||
android:text="@string/minutes_hint"
|
android:text="点击「生成纪要」离线总结本场对话;结果与上次纪要显示在这里"
|
||||||
android:textColor="#999999"
|
android:textColor="#999999"
|
||||||
android:textSize="13sp" />
|
android:textSize="13sp" />
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,82 +0,0 @@
|
||||||
<resources>
|
|
||||||
<string name="hint">Tap Start to begin live transcription.\n\nSource code and all models are open resources — see github.com/k2fsa/sherpa-onnx.\nTwo-pass recognition · speaker ID · offline meeting minutes</string>
|
|
||||||
<string name="start">Start</string>
|
|
||||||
<string name="stop">Stop</string>
|
|
||||||
<string name="pause">Pause</string>
|
|
||||||
<string name="resume">Resume</string>
|
|
||||||
<string name="dlg_ok">OK</string>
|
|
||||||
<string name="dlg_cancel">Cancel</string>
|
|
||||||
<string name="generate_minutes">Minutes</string>
|
|
||||||
<string name="cancel_minutes">Cancel</string>
|
|
||||||
<string name="copy_minutes">Copy</string>
|
|
||||||
<string name="history_button">History</string>
|
|
||||||
<string name="history_back">Back to live</string>
|
|
||||||
<string name="lang_button">Language</string>
|
|
||||||
<string name="lang_title">UI language</string>
|
|
||||||
<string name="lang_system">Follow system</string>
|
|
||||||
<string name="minutes_hint">Tap “Minutes” to summarize this meeting offline. The latest minutes appear here.</string>
|
|
||||||
|
|
||||||
<string name="loading_pass1">Loading 1st-pass model (nemotron-3.5)…</string>
|
|
||||||
<string name="loading_pass2">Loading 2nd-pass model (funasr-nano)…</string>
|
|
||||||
<string name="loading_speaker">Loading speaker model…</string>
|
|
||||||
<string name="load_done">Models ready.</string>
|
|
||||||
<string name="tap_start">Tap Start to begin</string>
|
|
||||||
<string name="speaker_on">Speaker ID enabled</string>
|
|
||||||
<string name="speaker_off">⚠ Speaker ID off: model %1$s missing (adb push it to %2$s)</string>
|
|
||||||
<string name="load_failed">Model loading failed: %1$s\n\nAll models live under %2$s — push them with adb</string>
|
|
||||||
|
|
||||||
<string name="label_identified">Identified %1$d</string>
|
|
||||||
<string name="label_unknown">Unknown</string>
|
|
||||||
|
|
||||||
<string name="naming_title_new">Name speaker “%1$s”</string>
|
|
||||||
<string name="naming_title_rename">Rename “%1$s” to…</string>
|
|
||||||
<string name="naming_hint">Real name for “%1$s”, e.g. Alice</string>
|
|
||||||
<string name="naming_bad_name">Name can’t be “S+digits” or a reserved display name</string>
|
|
||||||
<string name="naming_done">Renamed to “%1$s”; adjacent lines of the same speaker merged</string>
|
|
||||||
<string name="naming_wait_owner">Speaker not settled yet — finish a full sentence first</string>
|
|
||||||
|
|
||||||
<string name="hist_title">Past meetings</string>
|
|
||||||
<string name="hist_busy_recording">Recording — end this session before browsing history</string>
|
|
||||||
<string name="hist_none">No past meetings to show</string>
|
|
||||||
<string name="hist_item">%1$s · %2$d lines · %3$s%4$s</string>
|
|
||||||
<string name="hist_has_md"> · has minutes</string>
|
|
||||||
<string name="history_reading">History: %1$s (read-only)</string>
|
|
||||||
|
|
||||||
<string name="sum_title">Choose a meeting to summarize</string>
|
|
||||||
<string name="sum_current">[Current meeting] %1$d lines · %2$s</string>
|
|
||||||
<string name="sum_has_md"> · has minutes (continue)</string>
|
|
||||||
<string name="sum_none">Nothing to summarize yet — record a meeting first</string>
|
|
||||||
<string name="sum_empty_this">This meeting has no settled lines</string>
|
|
||||||
<string name="sum_generating">Minutes in progress — tap again to cancel</string>
|
|
||||||
<string name="sum_no_abi">This device ABI lacks the summarizer runtime (arm64-v8a only)</string>
|
|
||||||
<string name="sum_model_missing">Summarizer model missing. Run:\n%1$s</string>
|
|
||||||
<!-- Tap-to-translate -->
|
|
||||||
<string name="trans_pending">Translating…</string>
|
|
||||||
<string name="trans_busy">Please wait for the current translation</string>
|
|
||||||
<string name="trans_already">This sentence is already in your language</string>
|
|
||||||
<string name="trans_failed">Translation failed — tap the sentence again to retry</string>
|
|
||||||
<string name="trans_not_3lang">Only zh/ja/en translation is supported; this sentence is in another language</string>
|
|
||||||
<string name="trans_no_model">Translator model missing. Run:\n%1$s</string>
|
|
||||||
<string name="sum_loading">Loading summarizer model (%1$s)…</string>
|
|
||||||
<string name="sum_stage_notes">summarizing</string>
|
|
||||||
<string name="sum_stage_draft">drafting</string>
|
|
||||||
<string name="sum_progress">Running %1$s: %2$d/%3$d</string>
|
|
||||||
<string name="sum_ready_resume">Model ready, resuming from checkpoint (%1$d/%2$d lines done)…</string>
|
|
||||||
<string name="sum_ready">Model ready, %1$d lines — summarizing…</string>
|
|
||||||
<string name="sum_saved">Minutes saved (%1$s, %2$d chunks%3$s): %4$s</string>
|
|
||||||
<string name="sum_failed_chunks">, %1$d chunks failed</string>
|
|
||||||
<string name="sum_cancelled">Cancelled (previous minutes kept)</string>
|
|
||||||
<string name="sum_auto_cancel">Cancelled automatically: %1$s (previous minutes kept)</string>
|
|
||||||
<string name="sum_fail_toast">Failed: %1$s</string>
|
|
||||||
<string name="sum_cancel_toast">Cancelled</string>
|
|
||||||
<string name="sum_empty_toast">Cancelled automatically</string>
|
|
||||||
<string name="sum_recording_note">Summarizing — transcription continues uninterrupted</string>
|
|
||||||
<string name="sum_copied">Minutes copied to clipboard</string>
|
|
||||||
|
|
||||||
<string name="minutes_last">Last minutes: %1$s (will be overwritten by a new run)</string>
|
|
||||||
<string name="minutes_unfinished">Meeting “%1$s” has no minutes yet — tap “Minutes” to finish it (incremental)</string>
|
|
||||||
|
|
||||||
<string name="lang_busy_session">Session active — end recording before switching language</string>
|
|
||||||
<string name="copied">Copied to clipboard</string>
|
|
||||||
<string name="sum_canceling">Cancelling…</string>
|
|
||||||
</resources>
|
|
||||||
|
|
@ -1,82 +0,0 @@
|
||||||
<resources>
|
|
||||||
<string name="hint">「開始」を押すとリアルタイム文字起こしを開始します。\n\nソースコードとモデルはすべて公開されています: github.com/k2fsa/sherpa-onnx\n2パス認識 · 話者識別 · オフライン議事録</string>
|
|
||||||
<string name="start">開始</string>
|
|
||||||
<string name="stop">停止</string>
|
|
||||||
<string name="pause">一時停止</string>
|
|
||||||
<string name="resume">続行</string>
|
|
||||||
<string name="dlg_ok">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">第1パスモデル(nemotron-3.5)を読み込み中…</string>
|
|
||||||
<string name="loading_pass2">第2パスモデル(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 がありません(%2$s へ adb push してください)</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="trans_pending">翻訳中…</string>
|
|
||||||
<string name="trans_busy">現在の翻訳の完了をお待ちください</string>
|
|
||||||
<string name="trans_already">この文は表示言語と同じです</string>
|
|
||||||
<string name="trans_failed">翻訳に失敗しました。もう一度タップしてください</string>
|
|
||||||
<string name="trans_not_3lang">中日英間の翻訳のみ対応。この文は該当言語ではありません</string>
|
|
||||||
<string name="trans_no_model">翻訳モデルがありません。実行してください:\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>
|
|
||||||
|
|
@ -1,83 +1,16 @@
|
||||||
<resources>
|
<resources>
|
||||||
<string name="app_name">ASR2pass</string>
|
<string name="app_name">ASR2pass </string>
|
||||||
<string name="hint">点击「开始」进行实时转写。\n\n源代码与全部模型均为公开资源,详见 github.com/k2fsa/sherpa-onnx。\n双通道(两遍)识别 · 说话人区分 · 离线会议纪要</string>
|
<string name="hint">Click the Start button to play speech-to-text with Next-gen Kaldi.
|
||||||
<string name="start">开始</string>
|
\n
|
||||||
<string name="stop">停止</string>
|
\n\n\n
|
||||||
<string name="pause">暂停</string>
|
The source code and pre-trained models are publicly available.
|
||||||
<string name="resume">继续</string>
|
Please see https://github.com/k2fsa/sherpa-onnx for details.
|
||||||
<string name="dlg_ok">确定</string>
|
\n\n
|
||||||
<string name="dlg_cancel">取消</string>
|
Two-pass speech recognition with Next-gen Kaldi.
|
||||||
|
</string>
|
||||||
|
<string name="start">Start</string>
|
||||||
|
<string name="stop">Stop</string>
|
||||||
<string name="generate_minutes">生成纪要</string>
|
<string name="generate_minutes">生成纪要</string>
|
||||||
<string name="cancel_minutes">取消生成</string>
|
<string name="cancel_minutes">取消生成</string>
|
||||||
<string name="copy_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="trans_pending">翻译中…</string>
|
|
||||||
<string name="trans_busy">请等待当前翻译完成</string>
|
|
||||||
<string name="trans_already">该句已是当前界面语言</string>
|
|
||||||
<string name="trans_failed">翻译失败,可再次点击重试</string>
|
|
||||||
<string name="trans_not_3lang">仅支持中/日/英互译,该句不属于这三种语言</string>
|
|
||||||
<string name="trans_no_model">缺少翻译模型。请执行:\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>
|
</resources>
|
||||||
|
|
|
||||||
|
|
@ -15,7 +15,7 @@ class MeetingNotesTest {
|
||||||
fun persistRoundTripAndValidation() {
|
fun persistRoundTripAndValidation() {
|
||||||
val dir = File.createTempFile("notes", "dir").apply { delete(); mkdirs() }
|
val dir = File.createTempFile("notes", "dir").apply { delete(); mkdirs() }
|
||||||
val f = MeetingNotesIO.pathFor(dir, "20260911-1300")
|
val f = MeetingNotesIO.pathFor(dir, "20260911-1300")
|
||||||
val notes = """{"概括":"会议确认预算表9/30前提交,其余待议。"}"""
|
val notes = """{"已定事項":[],"討論中":[],"行動項目":[],"人物":[],"前回末尾":"x"}"""
|
||||||
MeetingNotesIO.save(f, MeetingNotes(notes, 42, 321_000L, 1, "20260911-1300"))
|
MeetingNotesIO.save(f, MeetingNotes(notes, 42, 321_000L, 1, "20260911-1300"))
|
||||||
val back = MeetingNotesIO.load(f, "20260911-1300")!!
|
val back = MeetingNotesIO.load(f, "20260911-1300")!!
|
||||||
assertEquals(42, back.entriesP1Done)
|
assertEquals(42, back.entriesP1Done)
|
||||||
|
|
@ -38,10 +38,8 @@ class MeetingNotesTest {
|
||||||
val u = SummarizePipeline.buildDraftUserPrompt(
|
val u = SummarizePipeline.buildDraftUserPrompt(
|
||||||
p, "ja", 1_789_000_000_000L, 3, 3_720_000L, "张三→張三", "NOTESJSON")
|
p, "ja", 1_789_000_000_000L, 3, 3_720_000L, "张三→張三", "NOTESJSON")
|
||||||
assertTrue(u.contains("日本語"))
|
assertTrue(u.contains("日本語"))
|
||||||
|
assertTrue(u.contains("62:00"))
|
||||||
assertTrue(u.contains("NOTESJSON"))
|
assertTrue(u.contains("NOTESJSON"))
|
||||||
assertTrue(u.contains("张三→張三"))
|
assertTrue(u.contains("张三→張三"))
|
||||||
// 2026-09-16 简化后模板不再含会议元信息占位符;替换后不得残留占位符
|
|
||||||
assertTrue("占位符未替换干净", !u.contains("{language_name}") &&
|
|
||||||
!u.contains("{glossary_lines}") && !u.contains("{notes_json}"))
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -77,9 +77,9 @@ class SummarizeLogicTest {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---- 概括 JSON 提取(2026-09-16 起单项 schema:{"概括":"..."}) ----
|
// ---- 笔记 JSON 提取 ----
|
||||||
private val goodJson =
|
private val goodJson =
|
||||||
"""{"概括":"会議はXを承認,Yは田中が9/30までに担当。Zの進め方は未決。"}"""
|
"""{"已定事項":[{"内容":"Xを承認","出典":"01:00-01:20"}],"討論中":[],"行動項目":[{"内容":"Y","担当者":"田中","期限":"9/30","出典":"02:00-02:10"}],"人物":[{"名前":"田中","立場":"PM"}],"前回末尾":"Yの進め方" }"""
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun extractJsonPlain() = assertEquals(goodJson, SummarizePipeline.extractJson(goodJson))
|
fun extractJsonPlain() = assertEquals(goodJson, SummarizePipeline.extractJson(goodJson))
|
||||||
|
|
@ -92,23 +92,15 @@ class SummarizeLogicTest {
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun extractJsonHandlesBracesInsideStrings() {
|
fun extractJsonHandlesBracesInsideStrings() {
|
||||||
val raw = """{"概括":"値は{要確認}なので未決事項として保留"}"""
|
val raw = """{"已定事項":[{"内容":"値は{要確認}","出典":""}],"討論中":[],"行動項目":[],"人物":[],"前回末尾":""}"""
|
||||||
assertNotNull(SummarizePipeline.extractJson(raw))
|
assertNotNull(SummarizePipeline.extractJson(raw))
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
|
||||||
fun extractJsonAcceptKeyAliases() {
|
|
||||||
assertNotNull(SummarizePipeline.extractJson("""{"概要":"会議の要約テキスト"}"""))
|
|
||||||
assertNotNull(SummarizePipeline.extractJson("""{"总结":"会议内容的一段总结"}"""))
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun extractJsonRejectsGarbageAndMissingKeys() {
|
fun extractJsonRejectsGarbageAndMissingKeys() {
|
||||||
assertNull(SummarizePipeline.extractJson("ごめんなさい、分かりません"))
|
assertNull(SummarizePipeline.extractJson("ごめんなさい、分かりません"))
|
||||||
assertNull(SummarizePipeline.extractJson("""{"hello":"world"}"""))
|
assertNull(SummarizePipeline.extractJson("""{"hello":"world"}"""))
|
||||||
assertNull(SummarizePipeline.extractJson("""{"已定事項":[],}""")) // 不平衡/畸形
|
assertNull(SummarizePipeline.extractJson("""{"已定事項":[],}""")) // 不平衡/畸形
|
||||||
assertNull(SummarizePipeline.extractJson("""{"已定事項":[]}""")) // 旧schema无概括键
|
|
||||||
assertNull(SummarizePipeline.extractJson("""{"概括":""}""")) // 空概括
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---- 成稿清洗 ----
|
// ---- 成稿清洗 ----
|
||||||
|
|
|
||||||
Binary file not shown.
|
|
@ -22,8 +22,6 @@ fi
|
||||||
cd llama.cpp
|
cd llama.cpp
|
||||||
git fetch --depth 1 origin "${LLAMA_COMMIT}" 2>/dev/null || true
|
git fetch --depth 1 origin "${LLAMA_COMMIT}" 2>/dev/null || true
|
||||||
git checkout "${LLAMA_COMMIT}" 2>/dev/null || git pull
|
git checkout "${LLAMA_COMMIT}" 2>/dev/null || git pull
|
||||||
# 应用本地修改:STQ1_0 1.25bit 内核移植(PR #22836)+ 私有编号重排(见 README)
|
|
||||||
git apply "../llama-df03399b-stq1_0.patch"
|
|
||||||
cd ..
|
cd ..
|
||||||
|
|
||||||
cmake -B "build-${ABI}" \
|
cmake -B "build-${ABI}" \
|
||||||
|
|
@ -32,13 +30,7 @@ cmake -B "build-${ABI}" \
|
||||||
-DANDROID_PLATFORM="${PLATFORM}" \
|
-DANDROID_PLATFORM="${PLATFORM}" \
|
||||||
-DANDROID_STL=c++_static \
|
-DANDROID_STL=c++_static \
|
||||||
-DANDROID_SUPPORT_FLEXIBLE_PAGE_SIZES=ON \
|
-DANDROID_SUPPORT_FLEXIBLE_PAGE_SIZES=ON \
|
||||||
-DGGML_CPU_ARM_ARCH=armv8.2-a+dotprod \
|
|
||||||
-DCMAKE_BUILD_TYPE=Release
|
-DCMAKE_BUILD_TYPE=Release
|
||||||
# 注:GGML_CPU_ARM_ARCH 给 ggml-cpu 编入 dotprod 真指令(dotprod 版 sdot 内核
|
|
||||||
# 是 STQ1_0 被独立验证过的路径;基线 armv8-a 编出的模拟路径在真机上有数值
|
|
||||||
# 偏差案)。运行时要求 CPU 支持 dotprod(高通 845 世代以后/所有目标机型)。
|
|
||||||
# 该 commit 的 GGML_CPU_ALL_VARIANTS 已强制 GGML_BACKEND_DL(多 .so),与
|
|
||||||
# 单 .so 打包不符,故用单变体开关。x86_64 构建忽略此变量。
|
|
||||||
cmake --build "build-${ABI}" -j "$(nproc)"
|
cmake --build "build-${ABI}" -j "$(nproc)"
|
||||||
|
|
||||||
mkdir -p "${OUT_DIR}"
|
mkdir -p "${OUT_DIR}"
|
||||||
|
|
|
||||||
|
|
@ -154,8 +154,7 @@ Java_com_k2fsa_sherpa_onnx_LlmEngine_nativeSetThreads(
|
||||||
JNIEXPORT jstring JNICALL
|
JNIEXPORT jstring JNICALL
|
||||||
Java_com_k2fsa_sherpa_onnx_LlmEngine_nativeGenerate(
|
Java_com_k2fsa_sherpa_onnx_LlmEngine_nativeGenerate(
|
||||||
JNIEnv * env, jclass /*clazz*/, jlong ptr,
|
JNIEnv * env, jclass /*clazz*/, jlong ptr,
|
||||||
jstring jPrompt, jint maxNewTokens, jfloat temperature, jint topK, jfloat topP,
|
jstring jPrompt, jint maxNewTokens, jfloat temperature, jint topK, jfloat topP) {
|
||||||
jfloat repeatPenalty, jint seed, jint minNew) {
|
|
||||||
|
|
||||||
auto * e = (Engine *) (uintptr_t) ptr;
|
auto * e = (Engine *) (uintptr_t) ptr;
|
||||||
if (e == nullptr || e->ctx == nullptr) {
|
if (e == nullptr || e->ctx == nullptr) {
|
||||||
|
|
@ -165,12 +164,6 @@ Java_com_k2fsa_sherpa_onnx_LlmEngine_nativeGenerate(
|
||||||
std::lock_guard<std::mutex> lock(e->mu);
|
std::lock_guard<std::mutex> lock(e->mu);
|
||||||
e->abort.store(false);
|
e->abort.store(false);
|
||||||
|
|
||||||
// KV 清除(2026-09-16 根治):原实现不清 KV,同引擎连续 generate 会复读
|
|
||||||
// 上一条输出 -> Kotlin 侧被迫"每次尝试重建引擎"(E2B 每次重载 4~6s,
|
|
||||||
// 是翻译单句 30~40s 的主因)。每次生成前清空内存,引擎即可安全复用;
|
|
||||||
// 顺带修复纪要流水线多块共享引擎的潜在污染。
|
|
||||||
llama_memory_clear(llama_get_memory(e->ctx), true);
|
|
||||||
|
|
||||||
const std::string prompt = jstringToUtf8(env, jPrompt);
|
const std::string prompt = jstringToUtf8(env, jPrompt);
|
||||||
if (prompt.empty()) {
|
if (prompt.empty()) {
|
||||||
throwJava(env, "nativeGenerate: empty prompt");
|
throwJava(env, "nativeGenerate: empty prompt");
|
||||||
|
|
@ -221,10 +214,7 @@ Java_com_k2fsa_sherpa_onnx_LlmEngine_nativeGenerate(
|
||||||
|
|
||||||
// --- decode loop ---
|
// --- decode loop ---
|
||||||
const llama_token eos = llama_vocab_eos(e->vocab);
|
const llama_token eos = llama_vocab_eos(e->vocab);
|
||||||
// seed:调用方显式给随机种子(翻译路径每次点击换盐、每次重试错位,让重试
|
std::mt19937 rng(42); // fixed seed => reproducible minutes across runs
|
||||||
// 是真正的再抽签);seed<=0 退回旧行为(42+温度错位,摘要路径用)
|
|
||||||
std::mt19937 rng(seed > 0 ? (uint32_t) seed
|
|
||||||
: (42 + (uint32_t) (temperature * 1000.0f)));
|
|
||||||
std::string out;
|
std::string out;
|
||||||
out.reserve(1024);
|
out.reserve(1024);
|
||||||
std::vector<char> piece(64);
|
std::vector<char> piece(64);
|
||||||
|
|
@ -233,12 +223,6 @@ Java_com_k2fsa_sherpa_onnx_LlmEngine_nativeGenerate(
|
||||||
const bool greedy = !(temperature > 0.0f);
|
const bool greedy = !(temperature > 0.0f);
|
||||||
const int k = topK > 0 ? topK : 64; // Gemma 4 recommended: 64
|
const int k = topK > 0 ? topK : 64; // Gemma 4 recommended: 64
|
||||||
const float p = topP > 0.0f ? topP : 0.95f; // recommended: ~0.95
|
const float p = topP > 0.0f ? topP : 0.95f; // recommended: ~0.95
|
||||||
// Repeat penalty (llama.cpp style, unique-token window): 1B 模型在垃圾/
|
|
||||||
// 歧义输入上极易进入复读循环。pen<=0 时禁用(摘要路径保持原行为),
|
|
||||||
// 翻译路径传 1.1。只惩罚本步已生成过的 token,窗口=最近 64 个生成 token。
|
|
||||||
const bool usePen = repeatPenalty > 1.0f;
|
|
||||||
std::vector<llama_token> recent;
|
|
||||||
std::vector<bool> penalized; // scratch for unique marking
|
|
||||||
|
|
||||||
for (int i = 0; i < maxNew; ++i) {
|
for (int i = 0; i < maxNew; ++i) {
|
||||||
if (e->abort.load()) break;
|
if (e->abort.load()) break;
|
||||||
|
|
@ -250,29 +234,6 @@ Java_com_k2fsa_sherpa_onnx_LlmEngine_nativeGenerate(
|
||||||
float * logits = llama_get_logits_ith(e->ctx, 0);
|
float * logits = llama_get_logits_ith(e->ctx, 0);
|
||||||
const int vsize = llama_vocab_n_tokens(e->vocab);
|
const int vsize = llama_vocab_n_tokens(e->vocab);
|
||||||
|
|
||||||
// 防空白输出(2026-09-16 真机实锤:极端量化下个别输入首 token 直落
|
|
||||||
// EOS,表现为 raw.len=0 的"空输出"):前 minNew 步把 EOS 置 -inf,
|
|
||||||
// 强制模型至少吐几个 token;有害输出交给上层质检闸门。
|
|
||||||
// minNew<=0 = 关闭(摘要路径用)。
|
|
||||||
if (minNew > 0 && i < minNew && eos >= 0 && eos < vsize) {
|
|
||||||
logits[eos] = -INFINITY;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (usePen && !recent.empty()) {
|
|
||||||
if ((int) recent.size() > 64)
|
|
||||||
recent.erase(recent.begin(), recent.begin() + (recent.size() - 64));
|
|
||||||
if (penalized.size() < (size_t) vsize)
|
|
||||||
penalized.assign((size_t) vsize + 4096, false);
|
|
||||||
for (llama_token t : recent) {
|
|
||||||
if (t >= 0 && t < vsize && !penalized[t]) {
|
|
||||||
penalized[t] = true;
|
|
||||||
float & lg = logits[t];
|
|
||||||
lg = lg < 0 ? lg * repeatPenalty : lg / repeatPenalty;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
for (llama_token t : recent) if (t >= 0 && t < vsize) penalized[t] = false;
|
|
||||||
}
|
|
||||||
|
|
||||||
// pick next token
|
// pick next token
|
||||||
llama_token next;
|
llama_token next;
|
||||||
if (greedy) {
|
if (greedy) {
|
||||||
|
|
@ -325,7 +286,6 @@ Java_com_k2fsa_sherpa_onnx_LlmEngine_nativeGenerate(
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
cur = next;
|
cur = next;
|
||||||
if (usePen) recent.push_back(next);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
LOGI("generated %d chars", (int) out.size());
|
LOGI("generated %d chars", (int) out.size());
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue