Compare commits

..

41 Commits

Author SHA1 Message Date
liwenhao2 edbfbe6434 说话人识别+翻译模型优化 2026-09-16 18:34:26 +08:00
liwenhao2 064fab39e0 取消声纹二次识别兜底(用户拍板):精确匹配制——判定链/滚动检索两处 nearestAttribution 硬归入全部移除,不中即新(不再对'最相似的人'做二次并人);nearestAttribution/nearestMargin/rollingFallbackMin 删除;verdictFallbackMin 专职切后命名的确认无人线;VERDICT-SCORE 日志去 fallback 字段;残余同类'兜底'仅剩:残料复用(嵌入算不出时的输入兜底)与灰带收编(不强制归属) 2026-09-16 17:18:13 +08:00
liwenhao2 12f008a4c1 声纹仅留 CAM++(用户拍板:eres2net 系识别效果太差整体退役):speakerModels 单条;initFastSpeakerModel 删除;单模型单空间——fastReady=false 时全部 fast 路径自动回落准确注册表(0.5/0.42 更严),滚动/快检/权威判定共用 CAM++;deferredNaming 镜像计算门控跳过;代价=每次滚动检查换 CAM++ 推理(检查线程串行,1s 节拍下可承受);设备 eres2net 两文件已无引用可删 2026-09-16 17:09:41 +08:00
liwenhao2 dea9622245 声纹识别整体调至稳健档(用户拍板:宽松一点,不要这么灵敏):4s主轴 drop 0.35→0.30;深低 0.22→0.18;快速通道 0.32→0.28;浅低连击 2→3;开口即查同人线 0.5→0.35(专用 onsetFireGate);minWindowRms 0.012→0.015 挡低能量碎音;原则=少切宁合,原值留档 git history 2026-09-16 16:59:12 +08:00
liwenhao2 2725c62b14 切后命名方案三(用户拍板):名字不诞生于开火,只诞生于切后纯净窗:
- 开火(FASTROLL/ONSET)不再发号发注册——只切行,grayLinePending 灰行先开(未识别);switchSerial/switchPrevActive 记录本切次
- deferredNaming(T+1.5~3.5s,取切后尾1.5s纯净素材,补喂机制保证全为新说法人声音):强匹配开火前主≥0.5→撤销误切;匹配已知人→上其名;确认无人(<0.42)→发新号双空间注册(素材干净永不产幽灵);灰色带→留灰由判定链收编
- adoptSwitchLabel(灰行就地冠名/未建行走预置);undoSwitch(重开抹迹/改签并回);serial 每次落地后自增防双发
- 根治病史:16:21 S6潮(单人在2.5min被8连发开火发出S7~S21七个幽灵,源自开火窗混合素材);新日志锚 NAMING-UNDO/ADOPT/NEW/WEAK
2026-09-16 16:43:54 +08:00
liwenhao2 31762d4627 主动撤销加强度门槛(实锤:短插嘴混合窗复核0.5~0.6弱匹配误撤了sim=0.04的真实异常):复核改用 nearestRank 名字级分数,CANCEL 需 score≥0.68(同人确认实测0.70~1.00安全带)且为当前人;弱匹配走 WEAK-SAME 留痕让切换立住;三类复核日志全部带分数,下次定罪不再靠猜 2026-09-16 16:17:38 +08:00
liwenhao2 898c5969e3 主动善后(用户反馈'善后不理想'):准确复核认出同人即撤销,不再被动等句子结束(长句错标挂几十秒):
- SpeakerRegistry.removeAuto(占位号清理)
- accurateRecheckAfterSwitch 的 CANCEL-LATE 分支→主线程 revertOnsetSwitch
- revert 两形态:占位行未建立(多数)→抹迹(预置作废/重开旧底行/防在途定格补封,零断行零闪烁);已建立→就地改签+并回+换色
- 仅当在途/落地切换仍指向该占位号时撤销,连环切换交被动链
2026-09-16 16:08:33 +08:00
liwenhao2 d004345ec3 快速通道终定2s窗(用户第三次拍板C:最快档+接受闪烁+并回兜底):fastWin=32000/fastMin=24000回退;1s周期/连击2/注册表复用/销案/冷却/并回自愈全保留;窗长演化与调参阶梯写入注释留档 2026-09-16 15:58:02 +08:00
liwenhao2 452bfc53bc 快速通道定案3s窗(用户选A):fastWin=48000/fastMin=32000(≥2s);窗长演化留档(2s版三度误切风暴后弃);1s周期/0.32/连击2/注册表复用/并回自愈保留;如有零星误切先抬阈值0.36 2026-09-16 15:52:12 +08:00
liwenhao2 88b5e9279c 2秒快速通道加连续两窗闸:单窗判决在2s+base模型上分不开换声与噪声(现场5分钟20发/21假号/负余弦-0.02铁证;同时刻4s通道0.85);连续两窗低分才开火——真人换声连窗相关性强(开火2.5~3s仍快于4s通道),孤立垃圾窗被滤;注册表销案/冷却/并回自愈保留 2026-09-16 15:44:41 +08:00
liwenhao2 944deb40ab 按用户决策改回2秒快速通道(明确接受短窗误切率):fastWin=32000/fastMin=24000/fastRollDrop=0.32回归;注册表复用编号、判定链并回自愈、3s冷却三道护持保留;注释留档:v8.1教训与两份现场证据,回4s/抬阈值即唯二旋钮 2026-09-16 15:33:19 +08:00
liwenhao2 7ade8283c7 快速通道修正(踩了v8.1短窗雷:2s窗+base模型同人相似度0.05~0.7狂震,90秒7次误切S2-S7):判定窗改回4s(与主通道同源)、周期1s保留(唯一提速来源)、阈值回0.35;占号前先查注册表——认出当前人销案/认出别人复用编号/陌生才发新号 2026-09-16 15:26:58 +08:00
liwenhao2 70acc03ce4 乐观快切(用户拍板:第一次识别先切、出错由并回自愈):
- 开口即查去掉'等准确复核'挡切换:fast 偏离即切+占号,复核降级为善后
- 新增快速滚动通道:2s 尾窗/1s 周期,快模型偏离锚点 0.32 即乐观快切(无缝场景检测延迟 3~5s→1.5~2.5s 目标)
- accurateRecheckAfterSwitch 善后:认出当前人→留痕(判定链并回自愈);真新人→补写准确空间防重发号;认出别人→留痕(判定链迁回)
- 冷却/锚点自弃/滞后喂料/标签钉住全套保留;fastRollDrop 为灵敏度钮
2026-09-16 15:22:05 +08:00
liwenhao2 699680a347 无缝强切占号双空间注册:S2双号实锤(05:01 S1只写fast镜像→sid5准确判定认不出→重发S2,同人S1/S2分裂,B的行标签漂移);占号时对同一窗口补算CAM++嵌入写入准确空间,后续判定稳定命中同一编号 2026-09-16 15:09:54 +08:00
liwenhao2 d1441acf69 ONSET-CANCEL 留痕:准确复核撤销切换(同人暂停续说)时输出 fastSim/label,验证复核关工作时可见 2026-09-16 15:03:17 +08:00
liwenhao2 02a27085f4 开口即查升级两级判定:误报实锤(A暂停续说,fast 短窗 simA<0.5 判新人,A续说句变'已识别人2'首句)——一级快锚点相似度(贴近=同人免复核),二级 CAM++ 准确空间复核(注册表认得出且为当前人→撤销切换;认得出别人→切给他;认不出→才当新人占号),占号时双空间镜像写入;ONSET-SWITCH 日志改 fastSim 2026-09-16 15:02:59 +08:00
liwenhao2 b6d4efe3f4 换行标签加固:onOnsetSwitch 落锤即写 lastConfirmedLabel(S1)——预置5s过期曾致新行开在但沿用旧标签,屏幕观感=没换人(S2案预置16s丢弃实锤);此后partial无论多晚到,新行都挂新标 2026-09-16 14:50:14 +08:00
liwenhao2 0fbeb8c893 切点风暴根治:冻结-补喂完成即作废锚点(windowRefEmb/PrevEmb=null+streak清零)——锚点重置原依赖'新人首句partial→开新行',碎片音/静默期无partial时旧锚点让每个噪声窗读深低→连环强切(现场14:35:47-59四连发);SWITCH-SUSPECT 日志补窗能量rms=便于区分真人换声/噪声戳穿底噪门 2026-09-16 14:39:28 +08:00
liwenhao2 97dfa9de17 恒定滞后喂料:换人切点零串行(用户拍板,静音轮替+无缝都治):
- 音频先进 pendingBuf,识别流滞后 feedLagSamples(1.5s)喂入;分析窗口保持实时(判定时机不变)
- 开口即查/SWITCH-FORCE 判定即置 cutRequested:录制线程冻结缓冲→强制断句→补喂冻结音频给新流,旧句在切点干净收尾;旧句已结算则直接补喂开新句;Stop 前补齐缓冲防丢尾
- 效果:静音轮替渗漏1.2s→≈0;无缝插话渗漏2~4s→≈1~2s(受缓冲深度约束);代价=全局实时字幕统一晚约1.5s;日志 ONSET-HOLD/ONSET-FLUSH 对账
2026-09-16 14:32:57 +08:00
liwenhao2 cea9892426 KV污染根治(还9/14的债):JNI 每次 generate 前 llama_memory_clear——翻译慢的主因(每批重建引擎=每次尝试重载E2B 4~6s)就此免除;Kotlin 侧 translateNumbered 改引擎复用(translateEngine ?: ensure),闲置60s卸载不变;纪要流水线多块共享引擎的潜在污染同步消除;.so 已重编部署(strip 4.32MB,6导出符号) 2026-09-16 14:20:43 +08:00
liwenhao2 fe7c696589 翻译单引擎化(用户拍板):E2B 唯一引擎,两级架构下线——translateOne1B+translateFallback 合并为 translateOne(E2B+温度递进重试+全质检+逐字止损),ensureTranslateEngine 改配 E2B(ctx8192/录音4线程/空闲6),tap 模型检查与 toast 改指 E2B;gemma-3-1b 两常量删除;LlmEngine gemma3 模板分支退役(仅剩 gemma4);净+24/-96 2026-09-16 14:07:39 +08:00
liwenhao2 a39a08ae1c MiniCPM5 相关代码整体摘除(用户拍板):useMinicpm5 开关+三派生常量删除(gemma4-e2b 固定);renderMinicpmPrompt 与 minicpm 模板分支删除;think 块剥除(MiniCPM专属防御)删除(<|channel>thought 通用防御保留);prompts.md 适用模型/家族列表/PC复现示例全部改E2B;历史注释保留于退役处 2026-09-16 13:58:55 +08:00
liwenhao2 531b558c72 摘要引擎切回 E2B(用户确认):useMinicpm5=true→false——MiniCPM5 三连空模板回显(9/14 起),E2B 系 9/11 真机验证稳定引擎;翻译兜底跟随同步回 E2B;prompts.md 头部适用模型说明同步 2026-09-16 13:42:46 +08:00
liwenhao2 b14c5486ef 空概括回显探测修复:extractJson收紧空值判null后,旧'parsed!=null'式emptyEcho探测永远进不去(重试提示词误标'不是合法JSON');改原文正则探测{"概括":""},重试用准确措辞'空概括'并附强约束;现场raw(9)=空schema回显(键名正确格式合法,纯EMPTY进EMPTY出) 2026-09-16 13:39:15 +08:00
liwenhao2 caee985dcc 摘要措辞统一(笔记→概括):修复重试提示词/两条异常消息;等 UNPARSABLE 原始输出行定性P1失败(白卷 or 格式) 2026-09-16 13:36:34 +08:00
liwenhao2 98a3098172 摘要简化为单项内容概括(用户拍板:只做概括,概括+无标题要点,无时间戳):
- prompts.md+BULTIN(逐字同步,脚本复刻一致性单测验证四段全MATCH):P1=滚动重写单键schema {概括},400字内,同会议语言;P2=200~400字概括正文+3~6条「・」无标题要点,去标题/表格/出处/元信息
- EMPTY_NOTES/notesEntriesEmpty/KEY_ALIASES(概要总结摘要…→概括)/extractJson必键校验全部换schema;进度阶段名滚动笔记→内容概括(三语strings+映射)
- P2 maxNewTokens 2500→1600;resume双闸:MeetingNotesIO.load(extractJson)已拦旧schema,MainActivity再加含"概括"栅;旧notes.json→自动全量重跑
- 单测更新:goodJson新schema、别名用例、旧schema/空概括拒绝用例、MeetingNotesTest样例与占位符断言
2026-09-16 13:33:16 +08:00
liwenhao2 f77f90e1e9 翻译改直译模式(用户拍板):去掉同轮上下文(≤600字)——触发点不再拼装、两级引擎签名去掉 context、transCtxBlock 删除、重试丢上下文环节随之废除;原先疑虑的'长上下文诱发漂移'变量一并消除;指代/省略句质量代价已知情 2026-09-16 11:44:06 +08:00
liwenhao2 5dc9fdaf74 Revert "翻译兜底引擎与摘要A/B解耦:固定 gemma4-e2b(日语强项+本机已验证),不再寄生 useMinicpm5(纪要方向的开关;MiniCPM5日语未标定、本工程纪要场景日语表现差);新增 fallbackModelDirName/GgufName/ChatTemplate 三常量,translateFallback 改用;摘要管线维持原开关不动"
This reverts commit d18554d731.
2026-09-16 11:40:12 +08:00
liwenhao2 d18554d731 翻译兜底引擎与摘要A/B解耦:固定 gemma4-e2b(日语强项+本机已验证),不再寄生 useMinicpm5(纪要方向的开关;MiniCPM5日语未标定、本工程纪要场景日语表现差);新增 fallbackModelDirName/GgufName/ChatTemplate 三常量,translateFallback 改用;摘要管线维持原开关不动 2026-09-16 11:31:43 +08:00
liwenhao2 9a8391d913 白卷事故诊断:单句路径转储提示词两段长度+首尾(usr.head/tail),排除提示词层脏数据;现场梳理:空raw=两引擎同现+PERFHAL 'Translate to physical failed'+频繁自重启→头号嫌疑转向设备/模型文件层,代码侧翻译路径在好/坏构建间零差异(逐提交核对) 2026-09-16 11:22:47 +08:00
liwenhao2 206037994f 强切换行哑火根治(实机病灶):封行动作被三处早退吞掉——①强切时无进行中句(owner=-1),强制endpoint落进无句分支只有resetBuffer,永无finalize;②finalizeSentence两个早退(sentenceById缺失/重复定格)在封行代码之前。修:封行移至finalizeSentence入口(早退前);无句分支补发closeOnsetLineIfPending;预置标签加5s保鲜期防误挂;修复后链路:强切→封行→新句新行挂S1,日志ONSET-CLOSE/ONSET-LINE可查 2026-09-16 11:05:29 +08:00
liwenhao2 6239f0a37e SWITCH-FORCE 3s 冷却闸:实测强切落地链需1~2s完成,期间下一深低窗再开一枪(双发);冷却期内 suppressed(cooldown) 只记日志不再触发,去噪+防叠发;首次真机开火验证通过(deep-low 0.21 首窗即切+label=S1 防双号命中) 2026-09-16 10:59:39 +08:00
liwenhao2 0b731978ef 翻译预算分配修复+genfail诊断:录音中1B重试预算降到1次(两次尝试~30s),给兜底留出时间窗——现场15s×3吃满45s预算致TAP-FALLBACK skipped=budget;TRANSLATE single/batch empty 诊断日志(原始输出头48字,区分模型白卷与输出被清洗成空) 2026-09-16 10:54:17 +08:00
liwenhao2 290e4b205b Revert "锚点污染根治:深疑窗禁吸收(driftAbsorbMin=0.30)——日志实锤换人过渡期相邻窗2s重叠使simP天然≥0.5,渐变吸收+误断防护两链连环把新人声纹吸收进锚点,simA假性回升0.18→0.71,连续低分永远攒不满,无缝换人零触发;两处修:渐变吸收加simA≥0.30条件,误断防护回拉同理,深疑窗一律计入连击交强切判定;深低0.22一票/浅低两票机制不变"
This reverts commit 2b622b68bd.
2026-09-16 10:37:58 +08:00
liwenhao2 2b622b68bd 锚点污染根治:深疑窗禁吸收(driftAbsorbMin=0.30)——日志实锤换人过渡期相邻窗2s重叠使simP天然≥0.5,渐变吸收+误断防护两链连环把新人声纹吸收进锚点,simA假性回升0.18→0.71,连续低分永远攒不满,无缝换人零触发;两处修:渐变吸收加simA≥0.30条件,误断防护回拉同理,深疑窗一律计入连击交强切判定;深低0.22一票/浅低两票机制不变 2026-09-16 10:34:31 +08:00
liwenhao2 bcf70e40f0 langdrift 日志补尾巴:原 sample 只打前24字,曾造成"译文看着没问题为何被拒"的误导——判错常在中后段(前半译中文尾巴滑回日语);增 tail(尾16字)+len 字段,证据一次到位 2026-09-16 10:25:41 +08:00
liwenhao2 e17f8c0659 兜底内存峰值加固:translateFallback 加载大引擎前先释放 1B 引擎(刚连败无保留价值),瞬时峰值降~0.8GB;现场:langdrift(日语续写式漂移被targetLangOk拒)→兜底加载时刻 App 重启,待用户确认崩否 2026-09-16 10:10:59 +08:00
liwenhao2 a4631a0735 无缝插话提速+切行即挂名:深低分一票强切(deepLowSwitch=0.22,同人实测最低~0.39/异人窗0.14~0.19,安全带中枢),浅低维持两票;SWITCH-FORCE 改走 onOnsetSwitch 落地通道,切行同时经 pendingOnsetLabel 挂已知标签或当场占号(与开口即查共用防双号/封行/时序作废),不再等下一句结算;无缝场景 4~6s→3~4s 且标签即时 2026-09-16 10:07:23 +08:00
liwenhao2 86cea18dcb 开口即查:静音后开口1.2s立即判换人并换行(用户三选:静音后开口/一票制/立即占位编号):
- 录制循环:≥0.6s静音后的开口攒1.2s语音,快模型(base)一次判定——贴当前人(≥0.5)不动;命中注册表其他人→立即换行+已知标签;都不像→当场 nextAutoLabel 占号+fastRegistry注册,不等endpoint
- 落地链:ONSET-SWITCH→onOnsetSwitch(底行有在途句→pendingForceEndpoint强定格+onsetClosePending封行;否则直接封行)→appendNewLine消费pendingOnsetLabel(pendingOnsetLabel=新行标签+lastConfirmedLabel沿用);onsetSeq在换行/会话开始时作废在途判定
- 防双号:postSpeakerVerdict新号分配前先查fastRegistry(0.4),命中则复用开口占号并补全双空间镜像
- 换行延迟4~6s→~1.5s;无缝插话仍由SWITCH-FORCE streak兜底;onsetEnabled开关可A/B关闭
- 老机制全保留:滚动窗口/误断防护/EMA/60s空闲换行蹲;ktlint parse通过
2026-09-16 10:03:13 +08:00
liwenhao2 2bdeee2147 翻译开头 ASCII 残片清理(aby/nope/fewest 族):cleanTrans 加原文对照剥除——开头2~6位小写字母+分隔符+CJK/假名,且该片段不在原文中(原文真词或大写开头如AI/OK不误伤);四处调用点传 src(兜底/单句/编号批/逐段兜底);'aby'与'html>'同族=模型困惑态首词元落训练残片,后续译文健康时旧闸门全放行 2026-09-16 09:46:29 +08:00
liwenhao2 5bbdfa19ed 翻译开头格式残片清理:cleanTrans 新增 transNoisePrefix(围栏符/半截HTML标签/模板词元/html尾巴),与答案标签剥除交替循环直至稳定;现场'html>'=Gemma词表'</html>'尾部BPE词元,困惑态冷启动首词元落残留、后续译文正常全部质检放行导致上屏;剥空→null走重试;TAP-NOISE日志 2026-09-16 09:43:38 +08:00
12 changed files with 809 additions and 506 deletions

View File

@ -1,8 +1,8 @@
# 摘要功能三段提示词(里程碑 1) # 摘要功能三段提示词(里程碑 1)
> 适用模型:`openbmb/MiniCPM5-2B-GGUF` 的 `MiniCPM5-2B-Q4_K_M.gguf`(架构 llama, > 适用模型:`google/gemma-4-E2B-it-qat-q4_0-gguf` 的 `gemma-4-E2B_q4_0-it.gguf`
> ChatML 模板,官方语言 en/zh;2026-09-12 由 Gemma 系换入。历史:gemma-3-1b-it / > (架构 gemma4。2026-09-16 由 MiniCPM5-2B 切回:E2B 为真机验证过的稳定引擎,
> google/gemma-4-E2B-it-qat-q4_0-gguf,模板分支保留在 LlmEngine 可回切) > MiniCPM5 多次"空模板回显")
> 本文件是唯一事实源:App 内 `SummarizePipeline.kt` 逐字引用这里的文本。改提示词先改本文件。 > 本文件是唯一事实源:App 内 `SummarizePipeline.kt` 逐字引用这里的文本。改提示词先改本文件。
> PC 调试方法见文末「用 PC llama.cpp 复现」。 > PC 调试方法见文末「用 PC llama.cpp 复现」。
@ -10,72 +10,67 @@
| 步骤 | temperature | maxNewTokens | 备注 | | 步骤 | temperature | maxNewTokens | 备注 |
|---|---|---|---| |---|---|---|---|
| P1 滚动笔记更新 | 0(Greedy) | 900 | 可复现;笔记要求确定性 | | P1 内容概括更新 | 0(Greedy) | 900 | 可复现;概括要求确定性 |
| P2 成稿 | 0.5 / topK 64 / topP 0.95 | 2500 | 低温采样,兼顾成稿自然度与可复现 | | P2 最终输出 | 0.5 / topK 64 / topP 0.95 | 1600 | 低温采样,兼顾自然度与可复现 |
思考(thinking)关闭:App 渲染模板时不注入 `<|think|>` 标记;模型若仍输出 `<|channel>thought…` 块,App 侧正则剔除。 思考(thinking)关闭:App 渲染模板时不注入 `<|think|>` 标记;模型若仍输出 `<|channel>thought…` 块,App 侧正则剔除。
## 1. 聊天模板(以代码为准) ## 1. 聊天模板(以代码为准)
模板由 `LlmEngine.renderChatPrompt` 按模型家族渲染:gemma4(旧)/ gemma3 / minicpm(ChatML im_start/im_end + 空 think 前缀禁用思考)。MiniCPM5 的确切 token 序列与特殊 id 以 `renderMinicpmPrompt` 与 GGUF 内嵌模板为准;PC 调试直接加 --jinja 用内置模板即可。 模板由 `LlmEngine.renderChatPrompt` 渲染(gemma4;2026-09-16 起 gemma3/MiniCPM 分支已随各自引擎退役)。PC 调试直接加 --jinja 用内置模板即可。
--- ---
## P1 — 滚动笔记更新 ## P1 — 内容概括更新
### SYSTEM(固定,勿动态改写) ### SYSTEM(固定,勿动态改写)
``` ```
你是处理会议转写记录的笔记员。请把【新的断片】的内容合并进给定的【当前笔记】,只以JSON输出更新后的笔记。 你是会议内容概括员。请把【新的断片】合并进【当前概括】,只以JSON输出更新后的概括。
严格要求: 严格要求:
- 只输出符合指定schema的JSON对象;禁止说明文字、代码围栏、开场语。 - 只输出JSON对象:{"概括":"..."};禁止说明文字、代码围栏、开场语。
- schema的键名必须原样输出,不得翻译、转写或改写(程序按这些键校验)。 - 键名必须原样输出(只有这一个键),不得翻译或改写(程序按此键校验)。
- 笔记内容用中文书写;人名、组织名、产品名等专有名词保留原文写法。 - 「概括」是一段连贯流畅的叙述性概括,覆盖会议至今的全部重要内容;是滚动重写,不是断片列表,不得照抄断片原文。
- 不得把输入笔记原样照抄:必须从【新的断片】中提取至少一条笔记尚未覆盖的信息并合并;断片确实没有新信息时,保留原笔记并只更新「前回末尾」。 - 用与会议相同的语言书写;人名、组织名、产品名等专有名词保留原文写法。
- 数字、金额、日期时间、期限、次数、单位按原文准确转记,不许模糊概括。 - 数字、金额、日期时间、期限、次数、单位按原文准确转记,不许模糊概括。
- 不得删除否定、保留、反对的意图;「暂缓」「再议」「反对意见」本身就是重要信息。 - 不得遗漏否定、保留、反对的意图;决定事项与未决事项均要体现。
- 每个条目在「出典」字段给出依据发言的时间段(格式:"mm:ss-mm:ss",多个用";"分隔)。 - 概括控制在400字以内;超出时压缩合并旧内容,保留主干与最新进展。
- 总计超过500字时,合并删除低价值的旧条目压缩;「行動項目」和「已定事項」最后才允许删。
- 「前回末尾」用一句话写最近的议题,供下一个断片衔接上下文。
- JSON字符串值内需要双引号时转义为\";换行用\n。 - JSON字符串值内需要双引号时转义为\";换行用\n。
schema(键名禁止改动): schema(键名禁止改动):
{"已定事項":[{"内容":"","出典":""}],"討論中":[{"内容":"","出典":""}],"行動項目":[{"内容":"","担当者":"","期限":"","出典":""}],"人物":[{"名前":"","立場":""}],"前回末尾":""} {"概括":""}
``` ```
### USER(每块渲染;`{}` 为占位符) ### USER(每块渲染;`{}` 为占位符)
``` ```
【当前笔记】 【当前概括】
{notes_json} {notes_json}
【新的断片】(时间戳为会议开始的 分:秒,[]内是说话人标签) 【新的断片】(时间戳为会议开始的 分:秒,[]内是说话人标签)
{chunk_text} {chunk_text}
请输出更新后的笔记JSON。 请输出更新后的概括JSON。
``` ```
--- ---
## P2 — 最终成稿 ## P2 — 最终输出
### SYSTEM(固定) ### SYSTEM(固定)
``` ```
你是会议的官方纪要撰写者。收到会议笔记(JSON)与术语表后,用指定成稿语言输出完整的Markdown会议纪要。 你是会议概括撰写者。收到会议内容概括(JSON)与术语表后,用指定成稿语言输出内容概括。
严格要求: 严格要求:
- 输出语言严格遵守指定的「成稿语言」,用该语言的书面体撰写。 - 输出两部分:先一段连贯的概括正文,空一行后给出要点。
- 正文只用成稿语言的文字书写:中文稿不得出现日文假名;英文稿不得出现汉字与假名;日文稿除产品名外不得残留英文句子。 - 要点3~6条,每条单独一行、以「・」开头;不加小标题、不分类、不标时间、不写出处。
- 人物、公司、品牌等专有名词可保留笔记/术语表中的原文写法(上述文字限制的例外)。 - 概括正文目标200~400字,覆盖会议主干与结论;要点摘出最值得记住的3~6条。
- 章节标题必须依序为「议题与经过」「决定事项」「行动项」「未决与待议」;成稿语言非中文时翻译成该语言的等价标题;没有条目的章节明确写"(无)"。 - 输出语言严格遵守「成稿语言」,用书面体:中文稿不得出现日文假名;英文稿不得出现汉字与假名;日文稿除专名外不得残留英文句子。
- 行动项用表格输出,列为 负责人 / 内容 / 期限 / 出处;负责人不明时写"待定"。 - 人物、公司、品牌等专有名词可保留原文写法(上述限制的例外)。
- 每个条目末尾给出处,格式 `出处: mm:ss-mm:ss`;出处只允许使用笔记JSON中「出典」字段的值,不得编造。 - 不得添加概括中没有的事实、推测或补充说明;人名、产品名以术语表的正式写法统一。
- 不得添加笔记中没有的事实、推测或补充说明。 - 只输出正文,不要Markdown标题、不要代码围栏。
- 人名、产品名以术语表的正式写法统一(术语表没有的沿用笔记写法)。
- 开头放置元信息:会议时间(如有)、发言人数、使用语言、转写总时长。
- 只输出Markdown正文,不要用代码围栏包裹全文。
``` ```
### USER ### USER
@ -83,15 +78,13 @@ schema(键名禁止改动):
``` ```
【成稿语言】{language_name} 【成稿语言】{language_name}
【会议信息】开始: {meeting_start} / 发言人数: {n_speakers} / 总时长: {total_duration}
【术语表(正式写法列表;人名、产品名的错误写法以此统一)】 【术语表(正式写法列表;人名、产品名的错误写法以此统一)】
{glossary_lines} {glossary_lines}
【会议笔记JSON】 【会议内容概括JSON】
{notes_json} {notes_json}
请根据以上内容输出会议纪要Markdown。 请输出内容概括与要点。
``` ```
--- ---
@ -108,18 +101,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(gemma3 及更早架构均支持,App 的 `libsummarize-llm-jni.so` 无需重编)。
2. 模型:`MiniCPM5-2B-Q4_K_M.gguf`(openbmb/MiniCPM5-2B-GGUF,架构 llama)。 2. 模型:`gemma-4-E2B_q4_0-it.gguf`(google/gemma-4-E2B-it-qat-q4_0-gguf,架构 gemma4)。
3. 跑 P1 示例(替换 sys/user 两段;`--jinja` 走 GGUF 内置模板,与 App 的 3. 跑 P1 示例(替换 sys/user 两段;`--jinja` 走 GGUF 内置模板,与 App 的
`renderMinicpmPrompt`——ChatML + 预填空 `<think>` 块禁用思考——等价): `renderGemma4Prompt`(<|turn> 系标记)等价):
```bash ```bash
llama-cli -m MiniCPM5-2B-Q4_K_M.gguf -c 8192 -ctk q8_0 -ctv f16 --jinja \ llama-cli -m gemma-4-E2B_q4_0-it.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"

View File

@ -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" | "gemma3",须与 GGUF 内嵌模板一致 chatTemplate: String = "gemma4", // 现仅 "gemma4";须与 GGUF 内嵌模板一致
) { ) {
companion object { companion object {
private const val TAG = "sherpa-onnx" private const val TAG = "sherpa-onnx"
@ -62,36 +62,27 @@ class LlmEngine(
append("<|turn>model\n") append("<|turn>model\n")
} }
// ---- Gemma 3 官方模板的单轮渲染(ggml-org/gemma-3-1b-it-GGUF)---- // Gemma 3 模板分支 2026-09-16 随翻译单引擎化退役(历史见 git history)
// 官方 jinja:system 不作为独立 turn,而是拼进第一个 user turn;
// <start_of_turn>user\n{system}\n\n{user}<end_of_turn>\n<start_of_turn>model\n
// EOG token 是 <end_of_turn>(gemma3 的 eot),依赖 native 按 eog 停止。
fun renderGemma3Prompt(system: String, user: String): String =
buildString {
append("<start_of_turn>user\n")
append(system.trim()).append("\n\n")
append(user.trim())
append("<end_of_turn>\n")
append("<start_of_turn>model\n")
}
// ---- MiniCPM5(ChatML,生成 prompt 默认带 think 块)---- // MiniCPM5 模板分支 2026-09-16 随引擎退役摘除(三连空模板回显,
// GGUF 模板 enable_thinking=false 时的 generation prompt 为: // 用户拍板弃用;历史实现见 git history)
// <|im_start|>assistant\n<think>\n\n</think>\n\n
// 显式预填空 think → 模型直接输出正文,不浪费解码在思考上。 // ---- Hunyuan-MT(Hy-MT2)canonical 模板,单轮 user ----
// EOG 为 <|im_end|>(llama.cpp 官方 GGUF,元数据已配)。 // 与 GGUF 内嵌模板(add_generation_prompt 路径,无 system)逐字等价:
fun renderMinicpmPrompt(system: String, user: String): String = // <|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("<|im_start|>system\n").append(system.trim()).append("<|im_end|>\n") append("<|hy_begin▁of▁sentence|>")
append("<|im_start|>user\n").append(user.trim()).append("<|im_end|>\n") append("<|hy_User|>").append(user.trim())
append("<|im_start|>assistant\n<think>\n\n</think>\n\n") append("<|hy_Assistant|>")
} }
fun renderChatPrompt(system: String, user: String, fun renderChatPrompt(system: String, user: String,
template: String = "gemma4"): String = template: String = "gemma4"): String =
when (template) { when (template) {
"gemma3" -> renderGemma3Prompt(system, user) "hunyuan" -> renderHunyuanPrompt(user)
"minicpm" -> renderMinicpmPrompt(system, user)
else -> renderGemma4Prompt(system, user) else -> renderGemma4Prompt(system, user)
} }
} }
@ -113,8 +104,10 @@ class LlmEngine(
/** /**
* 阻塞式生成(仅供摘要后台线程调用)。返回模型输出文本(不含输入)。 * 阻塞式生成(仅供摘要后台线程调用)。返回模型输出文本(不含输入)。
* temperature=0 为 Greedy。abort() 可从其它线程安全打断。 * temperature=0 为 Greedy。abort() 可从其它线程安全打断。
* repeatPenalty>1 启用重复惩罚(翻译路径传 1.1 治 1B 复读循环; * repeatPenalty>1 启用重复惩罚(翻译路径传 1.1 治复读循环)。
* 摘要路径保持默认 0=关闭,行为与已验证版本一致)。 * seed>0 = 显式随机种子(翻译路径每次点击换盐+重试错位,让重试真再抽签);
* seed<=0 = 旧行为 42+温度错位(摘要路径用)。
* minNew>0 = 前 minNew 步禁 EOS(防"空输出",翻译路径传 4;摘要传 0)。
*/ */
fun generate( fun generate(
systemPrompt: String, systemPrompt: String,
@ -124,11 +117,13 @@ class LlmEngine(
topK: Int = 64, topK: Int = 64,
topP: Float = 0.95f, topP: Float = 0.95f,
repeatPenalty: Float = 0f, 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 maxNewTokens, temperature, topK, topP, repeatPenalty, seed, minNew
) )
} }
@ -158,7 +153,8 @@ 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, repeatPenalty: 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

View File

@ -99,6 +99,12 @@ 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) {
@ -215,9 +221,10 @@ 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.
// //
// 双模型方案(E):准确模型(eres2netv2)负责 endpoint 权威判定与注册; // 单模型方案(2026-09-16 用户拍板):仅 CAM++;eres2net 系整体退役。
// 轻量模型(eres2net base,可选)只负责滚动换人检测(推理快 ~4 倍)。 // fastReady()=false 时:computeEmbeddingFast 回落 CAM++,滚动检索直接走
// ⚠ 两个模型的 embedding 不互通:各自配一份 registry,注册时双写镜像。 // 准确注册表(speakerThreshold 0.5 / verdictFallbackMin 0.42)。镜像注册
// 机制保留但不再有调用者(fastEmb 门控后恒 null)。
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
object SpeakerRecognition { object SpeakerRecognition {
@Volatile private var _extractor: SpeakerEmbeddingExtractor? = null @Volatile private var _extractor: SpeakerEmbeddingExtractor? = null

View File

@ -5,7 +5,7 @@ import android.util.Log
import org.json.JSONObject import org.json.JSONObject
// ============================================================================ // ============================================================================
// SummarizePipeline — §5 摘要流水线(分块 → 滚动笔记 → 成稿) // SummarizePipeline — §5 摘要流水线(分块 → 内容概括滚动更新 → 概要输出;2026-09-16 简化)
// //
// 提示词唯一事实源是 assets/summarize/prompts.md(用户可直接编辑/复制到 PC // 提示词唯一事实源是 assets/summarize/prompts.md(用户可直接编辑/复制到 PC
// llama.cpp 调试)。解析失败时退回本文件内置的同源副本 —— 两份不一致以 md // llama.cpp 调试)。解析失败时退回本文件内置的同源副本 —— 两份不一致以 md
@ -87,42 +87,37 @@ object PromptsLoader {
return Prompts(p1s, p1u, p2s, p2u) return Prompts(p1s, p1u, p2s, p2u)
} }
// 与 prompts.md 同源的内置副本(md 丢失/被改坏时的兜底;2026-09-14 起中文)。 // 与 prompts.md 同源的内置副本(md 丢失/被改坏时的兜底;2026-09-16 起
// 简化为"内容概括"单项)。一致性单测逐字比对,改一处必须两处同改。
val BUILTIN = Prompts( val BUILTIN = Prompts(
p1System = "你是处理会议转写记录的笔记员。请把【新的断片】的内容合并进给定的【当前笔记】,只以JSON输出更新后的笔记。\n\n" + p1System = "你是会议内容概括员。请把【新的断片】合并进【当前概括】,只以JSON输出更新后的概括。\n\n" +
"严格要求:\n" + "严格要求:\n" +
"- 只输出符合指定schema的JSON对象;禁止说明文字、代码围栏、开场语。\n" + "- 只输出JSON对象:{\"概括\":\"...\"};禁止说明文字、代码围栏、开场语。\n" +
"- schema的键名必须原样输出,不得翻译、转写或改写(程序按这些键校验)。\n" + "- 键名必须原样输出(只有这一个键),不得翻译或改写(程序按此键校验)。\n" +
"- 笔记内容用中文书写;人名、组织名、产品名等专有名词保留原文写法。\n" + "- 「概括」是一段连贯流畅的叙述性概括,覆盖会议至今的全部重要内容;是滚动重写,不是断片列表,不得照抄断片原文。\n" +
"- 不得把输入笔记原样照抄:必须从【新的断片】中提取至少一条笔记尚未覆盖的信息并合并;断片确实没有新信息时,保留原笔记并只更新「前回末尾」。\n" + "- 用与会议相同的语言书写;人名、组织名、产品名等专有名词保留原文写法。\n" +
"- 数字、金额、日期时间、期限、次数、单位按原文准确转记,不许模糊概括。\n" + "- 数字、金额、日期时间、期限、次数、单位按原文准确转记,不许模糊概括。\n" +
"- 不得删除否定、保留、反对的意图;「暂缓」「再议」「反对意见」本身就是重要信息。\n" + "- 不得遗漏否定、保留、反对的意图;决定事项与未决事项均要体现。\n" +
"- 每个条目在「出典」字段给出依据发言的时间段(格式:\"mm:ss-mm:ss\",多个用\";\"分隔)。\n" + "- 概括控制在400字以内;超出时压缩合并旧内容,保留主干与最新进展。\n" +
"- 总计超过500字时,合并删除低价值的旧条目压缩;「行動項目」和「已定事項」最后才允许删。\n" +
"- 「前回末尾」用一句话写最近的议题,供下一个断片衔接上下文。\n" +
"- JSON字符串值内需要双引号时转义为\\\";换行用\\n。\n\n" + "- JSON字符串值内需要双引号时转义为\\\";换行用\\n。\n\n" +
"schema(键名禁止改动):\n" + "schema(键名禁止改动):\n" +
"{\"已定事項\":[{\"内容\":\"\",\"出典\":\"\"}],\"討論中\":[{\"内容\":\"\",\"出典\":\"\"}],\"行動項目\":[{\"内容\":\"\",\"担当者\":\"\",\"期限\":\"\",\"出典\":\"\"}],\"人物\":[{\"名前\":\"\",\"立場\":\"\"}],\"前回末尾\":\"\"}", "{\"概括\":\"\"}",
p1UserTpl = "【当前笔记】\n{notes_json}\n\n" + p1UserTpl = "【当前概括】\n{notes_json}\n\n" +
"【新的断片】(时间戳为会议开始的 分:秒,[]内是说话人标签)\n{chunk_text}\n\n" + "【新的断片】(时间戳为会议开始的 分:秒,[]内是说话人标签)\n{chunk_text}\n\n" +
"请输出更新后的笔记JSON。", "请输出更新后的概括JSON。",
p2System = "你是会议的官方纪要撰写者。收到会议笔记(JSON)与术语表后,用指定成稿语言输出完整的Markdown会议纪要。\n\n" + p2System = "你是会议概括撰写者。收到会议内容概括(JSON)与术语表后,用指定成稿语言输出内容概括。\n\n" +
"严格要求:\n" + "严格要求:\n" +
"- 输出语言严格遵守指定的「成稿语言」,用该语言的书面体撰写。\n" + "- 输出两部分:先一段连贯的概括正文,空一行后给出要点。\n" +
"- 正文只用成稿语言的文字书写:中文稿不得出现日文假名;英文稿不得出现汉字与假名;日文稿除产品名外不得残留英文句子。\n" + "- 要点3~6条,每条单独一行、以「・」开头;不加小标题、不分类、不标时间、不写出处。\n" +
"- 人物、公司、品牌等专有名词可保留笔记/术语表中的原文写法(上述文字限制的例外)。\n" + "- 概括正文目标200~400字,覆盖会议主干与结论;要点摘出最值得记住的3~6条。\n" +
"- 章节标题必须依序为「议题与经过」「决定事项」「行动项」「未决与待议」;成稿语言非中文时翻译成该语言的等价标题;没有条目的章节明确写\"(无)\"。\n" + "- 输出语言严格遵守「成稿语言」,用书面体:中文稿不得出现日文假名;英文稿不得出现汉字与假名;日文稿除专名外不得残留英文句子。\n" +
"- 行动项用表格输出,列为 负责人 / 内容 / 期限 / 出处;负责人不明时写\"待定\"。\n" + "- 人物、公司、品牌等专有名词可保留原文写法(上述限制的例外)。\n" +
"- 每个条目末尾给出处,格式 `出处: mm:ss-mm:ss`;出处只允许使用笔记JSON中「出典」字段的值,不得编造。\n" + "- 不得添加概括中没有的事实、推测或补充说明;人名、产品名以术语表的正式写法统一。\n" +
"- 不得添加笔记中没有的事实、推测或补充说明。\n" + "- 只输出正文,不要Markdown标题、不要代码围栏。",
"- 人名、产品名以术语表的正式写法统一(术语表没有的沿用笔记写法)。\n" +
"- 开头放置元信息:会议时间(如有)、发言人数、使用语言、转写总时长。\n" +
"- 只输出Markdown正文,不要用代码围栏包裹全文。",
p2UserTpl = "【成稿语言】{language_name}\n\n" + p2UserTpl = "【成稿语言】{language_name}\n\n" +
"【会议信息】开始: {meeting_start} / 发言人数: {n_speakers} / 总时长: {total_duration}\n\n" +
"【术语表(正式写法列表;人名、产品名的错误写法以此统一)】\n{glossary_lines}\n\n" + "【术语表(正式写法列表;人名、产品名的错误写法以此统一)】\n{glossary_lines}\n\n" +
"【会议笔记JSON】\n{notes_json}\n\n" + "【会议内容概括JSON】\n{notes_json}\n\n" +
"请根据以上内容输出会议纪要Markdown。", "请输出内容概括与要点。",
) )
} }
@ -141,8 +136,7 @@ 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。
@ -171,12 +165,9 @@ 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 {
val o = JSONObject(notes) JSONObject(notes).optString("概括").isBlank()
listOf("已定事項", "討論中", "行動項目", "人物").all { k ->
o.optJSONArray(k)?.length() == 0
}
}.getOrDefault(true) }.getOrDefault(true)
fun mmss(ms: Long): String { fun mmss(ms: Long): String {
@ -205,14 +196,12 @@ class SummarizePipeline(
// 另:超 P1 预算的块按行二分一次,保证 prompt ≤ ctx。 // 另:超 P1 预算的块按行二分一次,保证 prompt ≤ ctx。
/** /**
* schema 键名别名表:中文系小模型常把日语/繁体键写成简体 * schema 键名别名表:中文系小模型常把键写成变体(概要/总结/摘要…),
* (已定事项/行动项目…),不规范化则 extractJson 永远判失败—— * 不规范化则 extractJson 永远判失败。2026-09-16 单项schema:都归「概括」。
* 这正是"中文会议也解析不了"的头号嫌疑。
*/ */
private val KEY_ALIASES = mapOf( private val KEY_ALIASES = mapOf(
"已定事项" to "已定事項", "讨论中" to "討論中", "概要" to "概括", "总结" to "概括", "摘要" to "概括",
"行动项目" to "行動項目", "决定事项" to "已定事項", "内容概括" to "概括", "総括" to "概括", "まとめ" to "概括",
"待办事项" to "行動項目", "上次末尾" to "前回末尾",
) )
internal fun normalizeKeys(o: JSONObject): JSONObject { internal fun normalizeKeys(o: JSONObject): JSONObject {
@ -294,9 +283,9 @@ 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),再校验必需键 // 键名先规范化(变体别名→schema),再校验必需键
val o = normalizeKeys(JSONObject(cand)) val o = normalizeKeys(JSONObject(cand))
if (o.has("已定事項") && o.has("行動項目")) if (o.optString("概括").isNotBlank())
o.toString() o.toString()
else null else null
} catch (t: Throwable) { } catch (t: Throwable) {
@ -317,8 +306,6 @@ class SummarizePipeline(
s = s.replace( s = s.replace(
Regex("<\\|channel>thought.*?<channel\\|>", RegexOption.DOT_MATCHES_ALL), "" 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") s = s.replace(Regex("\n{3,}"), "\n\n")
return s.trim() return s.trim()
} }
@ -391,7 +378,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()
@ -400,28 +387,30 @@ 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)会把提示词里
// (EMPTY 进 EMPTY 出)。首轮空结果带一次强约束重试再判。 // 的空 schema 原样抄回。2026-09-16 现场 raw(9)={"概括":""}。
val emptyEcho = parsed != null && idx == 0 && // 注意:extractJson 对空值已判 null,"parsed!=null"式旧探测永远
resumeNotes == null && notesEntriesEmpty(parsed) // 进不去——改为对原文正则探测,让重试提示词用准确措辞
val emptyEcho = parsed == null && idx == 0 && resumeNotes == null &&
Regex("\"概括\"\\s*:\\s*\"\\s*\"").containsMatchIn(out)
if (parsed == null || emptyEcho) { if (parsed == null || emptyEcho) {
// 一次修复重试:把畸形/空洞输出递回去(仍失败保留旧笔记,计 failed) // 一次修复重试:把畸形/空洞输出递回去(仍失败保留旧笔记,计 failed)
if (listener.isCancelled()) throw SummarizeCancelledException() if (listener.isCancelled()) throw SummarizeCancelledException()
val why = if (emptyEcho) val why = if (emptyEcho)
"上一次输出是没有任何条目的空笔记。" "上一次输出是空的概括。"
else else
"上一次输出不是合法JSON: " + "上一次输出不是合法JSON: " +
out.take(200).replace("\n", " ") + "。" out.take(200).replace("\n", " ") + "。"
val fix = user + "\n\n(" + why + val fix = user + "\n\n(" + why +
"请从【新的断片】中至少提取一条并合并,只输出修正后的笔记JSON," + "请把【新的断片】的内容合并进概括,只输出修正后的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,
@ -438,7 +427,7 @@ class SummarizePipeline(
"tail=[" + out.takeLast(60).replace("\n", "\\n") + "]") "tail=[" + out.takeLast(60).replace("\n", "\\n") + "]")
// 及时止损:第一块都解析不出来,笔记链条已断,后面块只会白烧 token // 及时止损:第一块都解析不出来,笔记链条已断,后面块只会白烧 token
if (idx == 0) { if (idx == 0) {
throw SummarizeEmptyException("开头转写片段无法生成有效笔记(内容过少或输出不可解析)") throw SummarizeEmptyException("开头转写片段无法生成有效概括(内容过少或输出不可解析)")
} }
} }
// 测量点(baseline,不改行为):raw≫json 说明解码浪费在废话/思考上; // 测量点(baseline,不改行为):raw≫json 说明解码浪费在废话/思考上;
@ -452,7 +441,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=" + "(failed=$failed/$total, notes=" +
notes.replace("\n", " ").take(120) + ")") notes.replace("\n", " ").take(120) + ")")
} }
@ -468,7 +457,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 = 2500, temperature = 0.5f, topK = 64, topP = 0.95f, maxNewTokens = 1600, 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}")

View File

@ -58,11 +58,11 @@
<string name="trans_not_3lang">Only zh/ja/en translation is supported; this sentence is in another language</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="trans_no_model">Translator model missing. Run:\n%1$s</string>
<string name="sum_loading">Loading summarizer model (%1$s)…</string> <string name="sum_loading">Loading summarizer model (%1$s)…</string>
<string name="sum_stage_notes">notes</string> <string name="sum_stage_notes">summarizing</string>
<string name="sum_stage_draft">drafting</string> <string name="sum_stage_draft">drafting</string>
<string name="sum_progress">Running %1$s: %2$d/%3$d</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_resume">Model ready, resuming from checkpoint (%1$d/%2$d lines done)…</string>
<string name="sum_ready">Model ready, %1$d lines — building notes…</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_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_failed_chunks">, %1$d chunks failed</string>
<string name="sum_cancelled">Cancelled (previous minutes kept)</string> <string name="sum_cancelled">Cancelled (previous minutes kept)</string>

View File

@ -58,11 +58,11 @@
<string name="trans_not_3lang">中日英間の翻訳のみ対応。この文は該当言語ではありません</string> <string name="trans_not_3lang">中日英間の翻訳のみ対応。この文は該当言語ではありません</string>
<string name="trans_no_model">翻訳モデルがありません。実行してください:\n%1$s</string> <string name="trans_no_model">翻訳モデルがありません。実行してください:\n%1$s</string>
<string name="sum_loading">要約モデル(%1$s)を読み込み中…</string> <string name="sum_loading">要約モデル(%1$s)を読み込み中…</string>
<string name="sum_stage_notes">メモ更新</string> <string name="sum_stage_notes">要約作成</string>
<string name="sum_stage_draft">清書</string> <string name="sum_stage_draft">清書</string>
<string name="sum_progress">%1$s実行中: %2$d/%3$d</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_resume">モデル準備完了、前回の中断点から続行(%1$d/%2$d 消化済み)…</string>
<string name="sum_ready">モデル準備完了、全 %1$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_saved">議事録を保存しました(%1$s、%2$d ブロック%3$s):%4$s</string>
<string name="sum_failed_chunks">、%1$d ブロック解析失敗</string> <string name="sum_failed_chunks">、%1$d ブロック解析失敗</string>
<string name="sum_cancelled">中止しました(前回の議事録は保持)</string> <string name="sum_cancelled">中止しました(前回の議事録は保持)</string>

View File

@ -59,11 +59,11 @@
<string name="trans_not_3lang">仅支持中/日/英互译,该句不属于这三种语言</string> <string name="trans_not_3lang">仅支持中/日/英互译,该句不属于这三种语言</string>
<string name="trans_no_model">缺少翻译模型。请执行:\n%1$s</string> <string name="trans_no_model">缺少翻译模型。请执行:\n%1$s</string>
<string name="sum_loading">正在加载摘要模型(%1$s)…</string> <string name="sum_loading">正在加载摘要模型(%1$s)…</string>
<string name="sum_stage_notes">滚动笔记</string> <string name="sum_stage_notes">内容概括</string>
<string name="sum_stage_draft">成稿</string> <string name="sum_stage_draft">成稿</string>
<string name="sum_progress">正在%1$s: %2$d/%3$d</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_resume">模型就绪,从上次断点续跑(%1$d/%2$d 句已消化)…</string>
<string name="sum_ready">模型就绪,共 %1$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_saved">纪要已保存(%1$s,%2$d 块%3$s):%4$s</string>
<string name="sum_failed_chunks">,%1$d 块解析失败</string> <string name="sum_failed_chunks">,%1$d 块解析失败</string>
<string name="sum_cancelled">已取消生成(保留上一次的纪要)</string> <string name="sum_cancelled">已取消生成(保留上一次的纪要)</string>

View File

@ -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 = """{"已定事項":[],"討論中":[],"行動項目":[],"人物":[],"前回末尾":"x"}""" val notes = """{"概括":"会议确认预算表9/30前提交,其余待议。"}"""
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,8 +38,10 @@ 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}"))
} }
} }

View File

@ -77,9 +77,9 @@ class SummarizeLogicTest {
} }
} }
// ---- 笔记 JSON 提取 ---- // ---- 概括 JSON 提取(2026-09-16 起单项 schema:{"概括":"..."}) ----
private val goodJson = private val goodJson =
"""{"已定事項":[{"内容":"Xを承認","出典":"01:00-01:20"}],"討論中":[],"行動項目":[{"内容":"Y","担当者":"田中","期限":"9/30","出典":"02:00-02:10"}],"人物":[{"名前":"田中","立場":"PM"}],"前回末尾":"Yの進め方" }""" """{"概括":"会議はXを承認,Yは田中が9/30までに担当。Zの進め方は未決。"}"""
@Test @Test
fun extractJsonPlain() = assertEquals(goodJson, SummarizePipeline.extractJson(goodJson)) fun extractJsonPlain() = assertEquals(goodJson, SummarizePipeline.extractJson(goodJson))
@ -92,15 +92,23 @@ 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("""{"概括":""}""")) // 空概括
} }
// ---- 成稿清洗 ---- // ---- 成稿清洗 ----

View File

@ -22,6 +22,8 @@ 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}" \
@ -30,7 +32,13 @@ 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}"

View File

@ -155,7 +155,7 @@ 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) { 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,6 +165,12 @@ 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");
@ -215,9 +221,10 @@ 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);
std::mt19937 rng(42 + (uint32_t) (temperature * 1000.0f)); // seed:调用方显式给随机种子(翻译路径每次点击换盐、每次重试错位,让重试
// seed 随温度错位:温度递进的重试不再抽同一条随机序列(此前固定 42, // 是真正的再抽签);seed<=0 退回旧行为(42+温度错位,摘要路径用)
// 0.3 重试与 0.0 贪心几乎同果,"跳出死路"名存实亡) 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);
@ -243,6 +250,14 @@ 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 (usePen && !recent.empty()) {
if ((int) recent.size() > 64) if ((int) recent.size() > 64)
recent.erase(recent.begin(), recent.begin() + (recent.size() - 64)); recent.erase(recent.begin(), recent.begin() + (recent.size() - 64));