Commit b4757640dcae4268683b5c4c04219873d99c8f81

Authored by yibo
1 parent 78b67a60

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 46 put("model", target.model)
47 47 put("stream", true)
48 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 54 put("messages", JSONArray().apply {
50 55 messages.forEach { (role, content) ->
51 56 put(JSONObject().apply {
... ... @@ -101,7 +106,11 @@ class ChatClient {
101 106 return try {
102 107 val choices = JSONObject(data).optJSONArray("choices") ?: return null
103 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 114 } catch (t: Throwable) {
106 115 Log.w(TAG, "bad chunk: ${data.take(80)}")
107 116 null
... ...
lifelog-recorder/app/src/main/kotlin/com/lemon/lifelog/data/AppDatabase.kt
... ... @@ -51,7 +51,8 @@ abstract class AppDatabase : RoomDatabase() {
51 51 "lifelog.db",
52 52 )
53 53 .addMigrations(MIGRATION_2_3)
54   - .fallbackToDestructiveMigration() // 1→x 等无迁移路径仍兜底(dev 早期库可丢)
  54 + // 不再 fallbackToDestructiveMigration:以后改 schema 必须写 Migration,
  55 + // 否则启动直接抛 IllegalStateException(fail-loud),绝不静默清掉真实录音。
55 56 .build()
56 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 47 const val PREFS = "lifelog_prefs"
48 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 56 fun groupSessions(segments: List<Segment>, gapMs: Long): List<List<Segment>> {
52 57 val out = mutableListOf<MutableList<Segment>>()
... ... @@ -98,7 +103,7 @@ class SummaryWorker(
98 103 val userPrompt = buildPrompt(repo, session, speakerNames, start, end)
99 104 if (userPrompt == null) continue // 整段无有效转写
100 105  
101   - val client = HermesClient()
  106 + val client = HermesClient(baseUrl = LLM_BASE_URL, apiKey = "", model = LLM_MODEL)
102 107 val resp = try {
103 108 client.chat(SYSTEM_PROMPT, userPrompt)
104 109 } catch (t: Throwable) {
... ...