Commit 2186c946592013e8d38c00c75d6d9baf540aa7cf

Authored by yibo
1 parent a7af2318

fix(lifelog/chat+summary): 本地对话去 thinking/null + 纪要解耦 llama-swap + Room fail-loud

- ChatClient: 本地 Qwen 目标加 chat_template_kwargs.enable_thinking=false
  (Hermes/Kimi 不认会忽略,故只对 LOCAL 加);修 parseDelta —— delta.content
  为 JSON null(思考阶段/role-only 首块)时跳过,不再因 org.json.optString
  返回字面 "null" 污染回复开头。
- SummaryWorker: 纪要后端解耦到本地 llama-swap(LLM_BASE_URL=8080 / LLM_MODEL="2b"),
  独立于 keys.properties——那个留给聊天的 Hermes 选项(8642 网关 → Kimi)。
- AppDatabase: 删掉 fallbackToDestructiveMigration 改 fail-loud(已有 MIGRATION_2_3;
  以后改 schema 不写迁移直接抛异常,绝不再静默清掉真实录音)。

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
lifelog-recorder/app/src/main/kotlin/com/lemon/lifelog/chat/ChatClient.kt
@@ -46,6 +46,11 @@ class ChatClient { @@ -46,6 +46,11 @@ class ChatClient {
46 put("model", target.model) 46 put("model", target.model)
47 put("stream", true) 47 put("stream", true)
48 put("max_tokens", maxTokens) 48 put("max_tokens", maxTokens)
  49 + // 本地 Qwen3.5 默认开思考,聊天里直接出答案不走 <think>;
  50 + // Hermes/Kimi 不认这个参数(会被忽略),故只对本地目标加。
  51 + if (target.kind == ChatTargetKind.LOCAL) {
  52 + put("chat_template_kwargs", JSONObject().put("enable_thinking", false))
  53 + }
49 put("messages", JSONArray().apply { 54 put("messages", JSONArray().apply {
50 messages.forEach { (role, content) -> 55 messages.forEach { (role, content) ->
51 put(JSONObject().apply { 56 put(JSONObject().apply {
@@ -101,7 +106,11 @@ class ChatClient { @@ -101,7 +106,11 @@ class ChatClient {
101 return try { 106 return try {
102 val choices = JSONObject(data).optJSONArray("choices") ?: return null 107 val choices = JSONObject(data).optJSONArray("choices") ?: return null
103 if (choices.length() == 0) return null 108 if (choices.length() == 0) return null
104 - choices.getJSONObject(0).optJSONObject("delta")?.optString("content", "") 109 + val delta = choices.getJSONObject(0).optJSONObject("delta") ?: return null
  110 + // content 为 JSON null(思考阶段 / role-only 首块)时跳过——
  111 + // 否则 org.json.optString 会返回字面 "null" 字符串污染回复。
  112 + if (delta.isNull("content")) return null
  113 + delta.optString("content", "")
105 } catch (t: Throwable) { 114 } catch (t: Throwable) {
106 Log.w(TAG, "bad chunk: ${data.take(80)}") 115 Log.w(TAG, "bad chunk: ${data.take(80)}")
107 null 116 null
lifelog-recorder/app/src/main/kotlin/com/lemon/lifelog/data/AppDatabase.kt
@@ -51,7 +51,8 @@ abstract class AppDatabase : RoomDatabase() { @@ -51,7 +51,8 @@ abstract class AppDatabase : RoomDatabase() {
51 "lifelog.db", 51 "lifelog.db",
52 ) 52 )
53 .addMigrations(MIGRATION_2_3) 53 .addMigrations(MIGRATION_2_3)
54 - .fallbackToDestructiveMigration() // 1→x 等无迁移路径仍兜底(dev 早期库可丢) 54 + // 不再 fallbackToDestructiveMigration:以后改 schema 必须写 Migration,
  55 + // 否则启动直接抛 IllegalStateException(fail-loud),绝不静默清掉真实录音。
55 .build() 56 .build()
56 .also { instance = it } 57 .also { instance = it }
57 } 58 }
lifelog-recorder/app/src/main/kotlin/com/lemon/lifelog/summary/SummaryWorker.kt
@@ -47,6 +47,11 @@ class SummaryWorker( @@ -47,6 +47,11 @@ class SummaryWorker(
47 const val PREFS = "lifelog_prefs" 47 const val PREFS = "lifelog_prefs"
48 const val KEY_GAP_MIN = "session_gap_min" 48 const val KEY_GAP_MIN = "session_gap_min"
49 49
  50 + /** 纪要后端:本地 llama-swap 的 2B(与聊天共用 8080,按 model 名热切换)。
  51 + * 独立于 keys.properties(那个留给聊天的 Hermes 选项)。 */
  52 + const val LLM_BASE_URL = "http://127.0.0.1:8080"
  53 + const val LLM_MODEL = "2b"
  54 +
50 /** 纯函数:把按时间排好序的段切成若干会话。 */ 55 /** 纯函数:把按时间排好序的段切成若干会话。 */
51 fun groupSessions(segments: List<Segment>, gapMs: Long): List<List<Segment>> { 56 fun groupSessions(segments: List<Segment>, gapMs: Long): List<List<Segment>> {
52 val out = mutableListOf<MutableList<Segment>>() 57 val out = mutableListOf<MutableList<Segment>>()
@@ -98,7 +103,7 @@ class SummaryWorker( @@ -98,7 +103,7 @@ class SummaryWorker(
98 val userPrompt = buildPrompt(repo, session, speakerNames, start, end) 103 val userPrompt = buildPrompt(repo, session, speakerNames, start, end)
99 if (userPrompt == null) continue // 整段无有效转写 104 if (userPrompt == null) continue // 整段无有效转写
100 105
101 - val client = HermesClient() 106 + val client = HermesClient(baseUrl = LLM_BASE_URL, apiKey = "", model = LLM_MODEL)
102 val resp = try { 107 val resp = try {
103 client.chat(SYSTEM_PROMPT, userPrompt) 108 client.chat(SYSTEM_PROMPT, userPrompt)
104 } catch (t: Throwable) { 109 } catch (t: Throwable) {