Commit 75bba7e3f0f72225f54a5196ec59082773f3c8ed
1 parent
85e1c513
feat(lifelog-recorder): v0.5 — hermes 接通 + SummaryWorker 纪要生成
设备侧 hermes api_server platform 配置(HANDOFF §五新增详细步骤): - ~/.hermes/.env 加 API_SERVER_ENABLED + HOST + PORT + KEY + MODEL_NAME - pip install anthropic(kimi-for-coding 走 Anthropic-compat 协议层) - setsid + disown 启动避免 adb shell 退出时 SIGTERM 杀掉 gateway App 侧: - BuildConfig 增加 HERMES_API_URL / HERMES_API_KEY / HERMES_MODEL - keys.properties.example 同步样本 - HermesClient.kt: minimal OkHttp 客户端,POST /v1/chat/completions + Bearer auth;JSONObject 拼请求 / 解 choices[0].message.content - SummaryWorker.kt (CoroutineWorker): - 滑动 1h 时间窗;speech < 60s 直接 skip - 拉 Transcript + speaker name 拼用户提示 - system prompt 要求 markdown 出「主题概括 / 关键要点 / 人物 / 地点 / 待办」 - 写 Summary 表(windowStartTs, windowEndTs, markdown, model) - SummaryScheduler.kt: enqueueUniquePeriodicWork 每小时(KEEP)+ OneTimeWorkRequest(按钮触发) - LifelogApp.onCreate 启动时 schedulePeriodic - MainActivity: 新「纪要」section + 「现在跑一次」按钮 + SummaryCard 列表 - AndroidManifest android:networkSecurityConfig 开 127.0.0.1 + localhost cleartext 豁免(OkHttp 默认禁 HTTP,loopback 也禁) 实测:60min 窗 / 172 段 / 709s 语音 / Kimi 14.3k prompt token / 649 completion → 一份 markdown 纪要正确分辨出「电影解说 + 修仙网文」两段 内容,按要求列出主题/要点/人物/地点/待办 5 个 section。 HANDOFF.md §五 LLM 纪要 worker 移到「已完成」;下一档是存储 TTL / 设置页 / 多 anchor 平均 / lemonPCRM 回链 / hermes 自启。 Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Showing
10 changed files
with
359 additions
and
5 deletions
lifelog-recorder/HANDOFF.md
| @@ -158,14 +158,46 @@ VAD 参数(在 `RecorderService.runPipeline` 里调): | @@ -158,14 +158,46 @@ VAD 参数(在 `RecorderService.runPipeline` 里调): | ||
| 158 | - v0.2 (2026-06):Opus 编码 / BootReceiver / 电池白名单 | 158 | - v0.2 (2026-06):Opus 编码 / BootReceiver / 电池白名单 |
| 159 | - v0.3 (2026-06):声纹识别(3dspeaker campplus)/ FTS4 中文搜索 | 159 | - v0.3 (2026-06):声纹识别(3dspeaker campplus)/ FTS4 中文搜索 |
| 160 | - v0.4 (2026-06):段播放 (MediaPlayer Opus) / bottom sheet 操作菜单 / enroll 后自动回填老段 | 160 | - v0.4 (2026-06):段播放 (MediaPlayer Opus) / bottom sheet 操作菜单 / enroll 后自动回填老段 |
| 161 | +- v0.5 (2026-06):hermes api_server 端点 + SummaryWorker + 纪要 UI section | ||
| 161 | 162 | ||
| 162 | 剩下: | 163 | 剩下: |
| 163 | 164 | ||
| 164 | -1. **LLM 纪要 worker**:WorkManager cron。每小时 / 静音 30min 触发,按时间窗 group segments → POST 到 hermes gateway `http://127.0.0.1:8642/v1/chat/completions`(需要先把 hermes api_server platform 配置起来,见 `~/.hermes/config.yaml`)。 | ||
| 165 | -2. **存储 TTL**:删超过 N 天的 .opus,文字永留。Periodic WorkManager job。 | ||
| 166 | -3. **设置页**:存储用量、模型切换、provider 切换、暂停时段配置(v1 定的 MVP 是「一键暂停就够」,但 v2 可能要细化)。 | ||
| 167 | -4. **多 anchor 平均**:现在 enroll 是单段单 anchor,可以「连选多段都标 我」自动算平均 embedding 提高匹配率。 | ||
| 168 | -5. **回链 lemonPCRM**:Transcript 提到的人名 / 项目 → GraphStore `MENTIONED_IN` 边。 | 165 | +1. **存储 TTL**:删超过 N 天的 .opus,文字永留。Periodic WorkManager job。 |
| 166 | +2. **设置页**:存储用量、模型切换、provider 切换、暂停时段配置(v1 定的 MVP 是「一键暂停就够」,但 v2 可能要细化)。 | ||
| 167 | +3. **多 anchor 平均**:现在 enroll 是单段单 anchor,可以「连选多段都标 我」自动算平均 embedding 提高匹配率。 | ||
| 168 | +4. **回链 lemonPCRM**:Transcript 提到的人名 / 项目 → GraphStore `MENTIONED_IN` 边。 | ||
| 169 | +5. **hermes gateway 自启**:当前需用户手动跑 `bash ~/start_gw.sh`,可写到 Termux:Boot 启动脚本。 | ||
| 170 | + | ||
| 171 | +### hermes gateway 设置(v0.5 关键前置) | ||
| 172 | + | ||
| 173 | +```bash | ||
| 174 | +# 一次性:~/.hermes/.env 末尾追加 | ||
| 175 | +API_SERVER_ENABLED=true | ||
| 176 | +API_SERVER_HOST=127.0.0.1 | ||
| 177 | +API_SERVER_PORT=8642 | ||
| 178 | +API_SERVER_KEY=<openssl rand -hex 16> | ||
| 179 | +API_SERVER_MODEL_NAME=kimi-for-coding | ||
| 180 | + | ||
| 181 | +# 需要 anthropic SDK(kimi-for-coding 协议层走 Anthropic-compat) | ||
| 182 | +~/hermes-agent/venv/bin/pip install -i https://pypi.tuna.tsinghua.edu.cn/simple/ 'anthropic>=0.39.0' | ||
| 183 | + | ||
| 184 | +# 启动:必须用 setsid + disown 才能在 adb shell 之外存活 | ||
| 185 | +cat > ~/start_gw.sh <<'EOF' | ||
| 186 | +#!/data/data/com.termux/files/usr/bin/bash | ||
| 187 | +export PATH=/data/data/com.termux/files/usr/bin:$PATH | ||
| 188 | +export HOME=/data/data/com.termux/files/home | ||
| 189 | +pkill -f 'hermes.*gateway' 2>/dev/null; sleep 2 | ||
| 190 | +set -a; . $HOME/.hermes/.env; set +a | ||
| 191 | +: > $HOME/.hermes/logs/gateway.log | ||
| 192 | +setsid hermes gateway run </dev/null > $HOME/.hermes/logs/gateway.log 2>&1 & | ||
| 193 | +disown $! | ||
| 194 | +EOF | ||
| 195 | +chmod 755 ~/start_gw.sh | ||
| 196 | +bash ~/start_gw.sh | ||
| 197 | +``` | ||
| 198 | + | ||
| 199 | +lifelog 的 `keys.properties` 里 `hermes.apiUrl` / `hermes.apiKey` 要跟这里 | ||
| 200 | +对得上才能调通。`network_security_config.xml` 已开 127.0.0.1 cleartext 豁免。 | ||
| 169 | 201 | ||
| 170 | ### 存储预算(v0.2 后) | 202 | ### 存储预算(v0.2 后) |
| 171 | 203 |
lifelog-recorder/app/build.gradle.kts
| @@ -35,6 +35,9 @@ android { | @@ -35,6 +35,9 @@ android { | ||
| 35 | buildConfigField("String", "XFYUN_API_SECRET", "\"${key("xfyun.apiSecret")}\"") | 35 | buildConfigField("String", "XFYUN_API_SECRET", "\"${key("xfyun.apiSecret")}\"") |
| 36 | buildConfigField("String", "XFYUN_IAT_HOST", "\"${key("xfyun.iatHost").ifEmpty { "iat.xf-yun.com" }}\"") | 36 | buildConfigField("String", "XFYUN_IAT_HOST", "\"${key("xfyun.iatHost").ifEmpty { "iat.xf-yun.com" }}\"") |
| 37 | buildConfigField("String", "XFYUN_IAT_PATH", "\"${key("xfyun.iatPath").ifEmpty { "/v1" }}\"") | 37 | buildConfigField("String", "XFYUN_IAT_PATH", "\"${key("xfyun.iatPath").ifEmpty { "/v1" }}\"") |
| 38 | + buildConfigField("String", "HERMES_API_URL", "\"${key("hermes.apiUrl").ifEmpty { "http://127.0.0.1:8642" }}\"") | ||
| 39 | + buildConfigField("String", "HERMES_API_KEY", "\"${key("hermes.apiKey")}\"") | ||
| 40 | + buildConfigField("String", "HERMES_MODEL", "\"${key("hermes.model").ifEmpty { "kimi-for-coding" }}\"") | ||
| 38 | } | 41 | } |
| 39 | 42 | ||
| 40 | packaging { | 43 | packaging { |
lifelog-recorder/app/src/main/AndroidManifest.xml
| @@ -17,6 +17,7 @@ | @@ -17,6 +17,7 @@ | ||
| 17 | android:label="Lifelog" | 17 | android:label="Lifelog" |
| 18 | android:allowBackup="false" | 18 | android:allowBackup="false" |
| 19 | android:supportsRtl="true" | 19 | android:supportsRtl="true" |
| 20 | + android:networkSecurityConfig="@xml/network_security_config" | ||
| 20 | android:theme="@style/Theme.Lifelog" | 21 | android:theme="@style/Theme.Lifelog" |
| 21 | tools:targetApi="35"> | 22 | tools:targetApi="35"> |
| 22 | 23 |
lifelog-recorder/app/src/main/kotlin/com/lemon/lifelog/LifelogApp.kt
| @@ -4,6 +4,7 @@ import android.app.Application | @@ -4,6 +4,7 @@ import android.app.Application | ||
| 4 | import android.app.NotificationChannel | 4 | import android.app.NotificationChannel |
| 5 | import android.app.NotificationManager | 5 | import android.app.NotificationManager |
| 6 | import com.lemon.lifelog.service.RecorderService | 6 | import com.lemon.lifelog.service.RecorderService |
| 7 | +import com.lemon.lifelog.summary.SummaryScheduler | ||
| 7 | 8 | ||
| 8 | class LifelogApp : Application() { | 9 | class LifelogApp : Application() { |
| 9 | override fun onCreate() { | 10 | override fun onCreate() { |
| @@ -16,5 +17,7 @@ class LifelogApp : Application() { | @@ -16,5 +17,7 @@ class LifelogApp : Application() { | ||
| 16 | NotificationManager.IMPORTANCE_LOW, | 17 | NotificationManager.IMPORTANCE_LOW, |
| 17 | ) | 18 | ) |
| 18 | ) | 19 | ) |
| 20 | + // Hourly 纪要 worker (KEEP = 已排过就不动)。 | ||
| 21 | + SummaryScheduler.schedulePeriodic(this) | ||
| 19 | } | 22 | } |
| 20 | } | 23 | } |
lifelog-recorder/app/src/main/kotlin/com/lemon/lifelog/MainActivity.kt
| @@ -63,6 +63,8 @@ import com.lemon.lifelog.asr.toFloatArrayLe | @@ -63,6 +63,8 @@ import com.lemon.lifelog.asr.toFloatArrayLe | ||
| 63 | import com.lemon.lifelog.asr.toLeBytes | 63 | import com.lemon.lifelog.asr.toLeBytes |
| 64 | import com.lemon.lifelog.asr.transcribeWithStats | 64 | import com.lemon.lifelog.asr.transcribeWithStats |
| 65 | import com.lemon.lifelog.audio.SegmentPlayer | 65 | import com.lemon.lifelog.audio.SegmentPlayer |
| 66 | +import com.lemon.lifelog.data.Summary | ||
| 67 | +import com.lemon.lifelog.summary.SummaryScheduler | ||
| 66 | import com.lemon.lifelog.data.Repository | 68 | import com.lemon.lifelog.data.Repository |
| 67 | import com.lemon.lifelog.data.Segment | 69 | import com.lemon.lifelog.data.Segment |
| 68 | import com.lemon.lifelog.data.SegmentRow | 70 | import com.lemon.lifelog.data.SegmentRow |
| @@ -116,6 +118,7 @@ private fun MainScreen() { | @@ -116,6 +118,7 @@ private fun MainScreen() { | ||
| 116 | } | 118 | } |
| 117 | val timeline by timelineFlow.collectAsState(initial = emptyList()) | 119 | val timeline by timelineFlow.collectAsState(initial = emptyList()) |
| 118 | val speakers by repo.speakers.observeAll().collectAsState(initial = emptyList()) | 120 | val speakers by repo.speakers.observeAll().collectAsState(initial = emptyList()) |
| 121 | + val summaries by repo.summaries.recent(20).collectAsState(initial = emptyList()) | ||
| 119 | 122 | ||
| 120 | // Sheet / dialog state. | 123 | // Sheet / dialog state. |
| 121 | var actionRow by remember { mutableStateOf<SegmentRow?>(null) } | 124 | var actionRow by remember { mutableStateOf<SegmentRow?>(null) } |
| @@ -229,6 +232,27 @@ private fun MainScreen() { | @@ -229,6 +232,27 @@ private fun MainScreen() { | ||
| 229 | 232 | ||
| 230 | HorizontalDivider() | 233 | HorizontalDivider() |
| 231 | 234 | ||
| 235 | + Row(verticalAlignment = androidx.compose.ui.Alignment.CenterVertically, | ||
| 236 | + horizontalArrangement = Arrangement.spacedBy(8.dp)) { | ||
| 237 | + Text("纪要(${summaries.size})", style = MaterialTheme.typography.titleMedium) | ||
| 238 | + Button(onClick = { SummaryScheduler.runNow(activity) }) { | ||
| 239 | + Text("现在跑一次") | ||
| 240 | + } | ||
| 241 | + } | ||
| 242 | + if (summaries.isEmpty()) { | ||
| 243 | + Text("还没有纪要。点上面按钮触发;或等 hourly worker(要求 hermes 在 127.0.0.1:8642 跑着)。", | ||
| 244 | + style = MaterialTheme.typography.bodySmall) | ||
| 245 | + } else { | ||
| 246 | + Column( | ||
| 247 | + modifier = Modifier.fillMaxWidth(), | ||
| 248 | + verticalArrangement = Arrangement.spacedBy(4.dp), | ||
| 249 | + ) { | ||
| 250 | + summaries.take(5).forEach { s -> SummaryCard(s) } | ||
| 251 | + } | ||
| 252 | + } | ||
| 253 | + | ||
| 254 | + HorizontalDivider() | ||
| 255 | + | ||
| 232 | Text("ASR Bench (95s TTS sample)", style = MaterialTheme.typography.titleMedium) | 256 | Text("ASR Bench (95s TTS sample)", style = MaterialTheme.typography.titleMedium) |
| 233 | Text(asrStatus, style = MaterialTheme.typography.bodyMedium) | 257 | Text(asrStatus, style = MaterialTheme.typography.bodyMedium) |
| 234 | 258 | ||
| @@ -456,6 +480,20 @@ private fun requestBatteryOptExemption(ctx: Context) { | @@ -456,6 +480,20 @@ private fun requestBatteryOptExemption(ctx: Context) { | ||
| 456 | } | 480 | } |
| 457 | 481 | ||
| 458 | private val TIMELINE_FMT = SimpleDateFormat("HH:mm:ss", Locale.US) | 482 | private val TIMELINE_FMT = SimpleDateFormat("HH:mm:ss", Locale.US) |
| 483 | +private val SUMMARY_FMT = SimpleDateFormat("MM-dd HH:mm", Locale.US) | ||
| 484 | + | ||
| 485 | +@Composable | ||
| 486 | +private fun SummaryCard(s: Summary) { | ||
| 487 | + Column( | ||
| 488 | + modifier = Modifier.fillMaxWidth().padding(vertical = 4.dp), | ||
| 489 | + ) { | ||
| 490 | + Text( | ||
| 491 | + "${SUMMARY_FMT.format(Date(s.windowStartTs))} → ${SUMMARY_FMT.format(Date(s.windowEndTs))} · ${s.model}", | ||
| 492 | + style = MaterialTheme.typography.labelSmall, | ||
| 493 | + ) | ||
| 494 | + Text(s.markdown, style = MaterialTheme.typography.bodySmall) | ||
| 495 | + } | ||
| 496 | +} | ||
| 459 | 497 | ||
| 460 | @Composable | 498 | @Composable |
| 461 | private fun TimelineRow(row: SegmentRow, isPlaying: Boolean, onClick: () -> Unit) { | 499 | private fun TimelineRow(row: SegmentRow, isPlaying: Boolean, onClick: () -> Unit) { |
lifelog-recorder/app/src/main/kotlin/com/lemon/lifelog/asr/HermesClient.kt
0 → 100644
| 1 | +package com.lemon.lifelog.asr | ||
| 2 | + | ||
| 3 | +import android.util.Log | ||
| 4 | +import com.lemon.lifelog.BuildConfig | ||
| 5 | +import okhttp3.MediaType.Companion.toMediaType | ||
| 6 | +import okhttp3.OkHttpClient | ||
| 7 | +import okhttp3.Request | ||
| 8 | +import okhttp3.RequestBody.Companion.toRequestBody | ||
| 9 | +import org.json.JSONArray | ||
| 10 | +import org.json.JSONObject | ||
| 11 | +import java.io.IOException | ||
| 12 | +import java.util.concurrent.TimeUnit | ||
| 13 | + | ||
| 14 | +/** | ||
| 15 | + * Minimal OpenAI-compatible client targeting the hermes api_server endpoint | ||
| 16 | + * (default http://127.0.0.1:8642/v1). | ||
| 17 | + * | ||
| 18 | + * Used by SummaryWorker to produce 纪要 from transcribed segments. | ||
| 19 | + * Hermes routes to Kimi (kimi-for-coding) underneath; protocol stays OpenAI-shaped. | ||
| 20 | + */ | ||
| 21 | +class HermesClient( | ||
| 22 | + private val baseUrl: String = BuildConfig.HERMES_API_URL, | ||
| 23 | + private val apiKey: String = BuildConfig.HERMES_API_KEY, | ||
| 24 | + private val model: String = BuildConfig.HERMES_MODEL, | ||
| 25 | +) { | ||
| 26 | + companion object { | ||
| 27 | + private const val TAG = "HermesClient" | ||
| 28 | + private val JSON = "application/json; charset=utf-8".toMediaType() | ||
| 29 | + } | ||
| 30 | + | ||
| 31 | + private val http: OkHttpClient = OkHttpClient.Builder() | ||
| 32 | + .connectTimeout(10, TimeUnit.SECONDS) | ||
| 33 | + .readTimeout(120, TimeUnit.SECONDS) // Kimi 大段总结可以慢 | ||
| 34 | + .writeTimeout(30, TimeUnit.SECONDS) | ||
| 35 | + .build() | ||
| 36 | + | ||
| 37 | + data class ChatResult( | ||
| 38 | + val text: String, | ||
| 39 | + val promptTokens: Int, | ||
| 40 | + val completionTokens: Int, | ||
| 41 | + val model: String, | ||
| 42 | + ) | ||
| 43 | + | ||
| 44 | + @Throws(IOException::class) | ||
| 45 | + fun chat( | ||
| 46 | + systemPrompt: String, | ||
| 47 | + userPrompt: String, | ||
| 48 | + maxTokens: Int = 800, | ||
| 49 | + ): ChatResult { | ||
| 50 | + val payload = JSONObject().apply { | ||
| 51 | + put("model", model) | ||
| 52 | + put("max_tokens", maxTokens) | ||
| 53 | + put("messages", JSONArray().apply { | ||
| 54 | + if (systemPrompt.isNotBlank()) { | ||
| 55 | + put(JSONObject().apply { | ||
| 56 | + put("role", "system") | ||
| 57 | + put("content", systemPrompt) | ||
| 58 | + }) | ||
| 59 | + } | ||
| 60 | + put(JSONObject().apply { | ||
| 61 | + put("role", "user") | ||
| 62 | + put("content", userPrompt) | ||
| 63 | + }) | ||
| 64 | + }) | ||
| 65 | + } | ||
| 66 | + val req = Request.Builder() | ||
| 67 | + .url("$baseUrl/v1/chat/completions") | ||
| 68 | + .header("Authorization", "Bearer $apiKey") | ||
| 69 | + .header("Content-Type", "application/json") | ||
| 70 | + .post(payload.toString().toRequestBody(JSON)) | ||
| 71 | + .build() | ||
| 72 | + | ||
| 73 | + http.newCall(req).execute().use { resp -> | ||
| 74 | + val body = resp.body?.string().orEmpty() | ||
| 75 | + if (!resp.isSuccessful) { | ||
| 76 | + throw IOException("hermes ${resp.code}: ${body.take(200)}") | ||
| 77 | + } | ||
| 78 | + return parseChat(body) | ||
| 79 | + } | ||
| 80 | + } | ||
| 81 | + | ||
| 82 | + private fun parseChat(body: String): ChatResult { | ||
| 83 | + val json = JSONObject(body) | ||
| 84 | + val choices = json.optJSONArray("choices") ?: throw IOException("no choices in response") | ||
| 85 | + if (choices.length() == 0) throw IOException("empty choices") | ||
| 86 | + val message = choices.getJSONObject(0).optJSONObject("message") | ||
| 87 | + ?: throw IOException("no message in choice") | ||
| 88 | + val text = message.optString("content", "") | ||
| 89 | + val usage = json.optJSONObject("usage") | ||
| 90 | + return ChatResult( | ||
| 91 | + text = text, | ||
| 92 | + promptTokens = usage?.optInt("prompt_tokens", 0) ?: 0, | ||
| 93 | + completionTokens = usage?.optInt("completion_tokens", 0) ?: 0, | ||
| 94 | + model = json.optString("model", model), | ||
| 95 | + ) | ||
| 96 | + } | ||
| 97 | + | ||
| 98 | + /** Cheap health check used by UI status pill. */ | ||
| 99 | + fun ping(): Boolean = try { | ||
| 100 | + val req = Request.Builder() | ||
| 101 | + .url("$baseUrl/v1/models") | ||
| 102 | + .header("Authorization", "Bearer $apiKey") | ||
| 103 | + .build() | ||
| 104 | + http.newCall(req).execute().use { it.isSuccessful } | ||
| 105 | + } catch (t: Throwable) { | ||
| 106 | + Log.w(TAG, "ping failed: ${t.message}") | ||
| 107 | + false | ||
| 108 | + } | ||
| 109 | +} |
lifelog-recorder/app/src/main/kotlin/com/lemon/lifelog/summary/SummaryScheduler.kt
0 → 100644
| 1 | +package com.lemon.lifelog.summary | ||
| 2 | + | ||
| 3 | +import android.content.Context | ||
| 4 | +import androidx.work.Constraints | ||
| 5 | +import androidx.work.Data | ||
| 6 | +import androidx.work.ExistingPeriodicWorkPolicy | ||
| 7 | +import androidx.work.NetworkType | ||
| 8 | +import androidx.work.OneTimeWorkRequestBuilder | ||
| 9 | +import androidx.work.PeriodicWorkRequestBuilder | ||
| 10 | +import androidx.work.WorkManager | ||
| 11 | +import java.util.concurrent.TimeUnit | ||
| 12 | + | ||
| 13 | +object SummaryScheduler { | ||
| 14 | + private const val PERIODIC_TAG = "lifelog-summary-hourly" | ||
| 15 | + private const val ONE_SHOT_TAG = "lifelog-summary-once" | ||
| 16 | + | ||
| 17 | + fun schedulePeriodic(ctx: Context) { | ||
| 18 | + val constraints = Constraints.Builder() | ||
| 19 | + .setRequiredNetworkType(NetworkType.NOT_REQUIRED) // hermes 在本机 127.0.0.1 | ||
| 20 | + .setRequiresBatteryNotLow(true) | ||
| 21 | + .build() | ||
| 22 | + val request = PeriodicWorkRequestBuilder<SummaryWorker>(1, TimeUnit.HOURS) | ||
| 23 | + .setConstraints(constraints) | ||
| 24 | + .build() | ||
| 25 | + WorkManager.getInstance(ctx).enqueueUniquePeriodicWork( | ||
| 26 | + PERIODIC_TAG, | ||
| 27 | + ExistingPeriodicWorkPolicy.KEEP, | ||
| 28 | + request, | ||
| 29 | + ) | ||
| 30 | + } | ||
| 31 | + | ||
| 32 | + fun runNow(ctx: Context, windowOverride: Long? = null) { | ||
| 33 | + val data = if (windowOverride != null) { | ||
| 34 | + Data.Builder().putLong(SummaryWorker.INPUT_NOW_TS, windowOverride).build() | ||
| 35 | + } else Data.EMPTY | ||
| 36 | + val request = OneTimeWorkRequestBuilder<SummaryWorker>() | ||
| 37 | + .setInputData(data) | ||
| 38 | + .addTag(ONE_SHOT_TAG) | ||
| 39 | + .build() | ||
| 40 | + WorkManager.getInstance(ctx).enqueue(request) | ||
| 41 | + } | ||
| 42 | +} |
lifelog-recorder/app/src/main/kotlin/com/lemon/lifelog/summary/SummaryWorker.kt
0 → 100644
| 1 | +package com.lemon.lifelog.summary | ||
| 2 | + | ||
| 3 | +import android.content.Context | ||
| 4 | +import android.util.Log | ||
| 5 | +import androidx.work.CoroutineWorker | ||
| 6 | +import androidx.work.WorkerParameters | ||
| 7 | +import com.lemon.lifelog.asr.HermesClient | ||
| 8 | +import com.lemon.lifelog.data.Repository | ||
| 9 | +import com.lemon.lifelog.data.Summary | ||
| 10 | +import java.text.SimpleDateFormat | ||
| 11 | +import java.util.Date | ||
| 12 | +import java.util.Locale | ||
| 13 | + | ||
| 14 | +/** | ||
| 15 | + * Periodic / on-demand 纪要 worker. | ||
| 16 | + * | ||
| 17 | + * Strategy: | ||
| 18 | + * - Pull transcripts in a sliding window (default: last [WINDOW_MS] ms). | ||
| 19 | + * - If the window covers < [MIN_DURATION_MS] of actual speech, skip. | ||
| 20 | + * - If a Summary already exists that overlaps the window with the same or | ||
| 21 | + * larger span, skip (idempotent). | ||
| 22 | + * - Compose prompt with transcripts (+ speaker labels if known) and POST to | ||
| 23 | + * hermes /v1/chat/completions; save the markdown reply as a Summary row. | ||
| 24 | + * | ||
| 25 | + * Failure modes: | ||
| 26 | + * - hermes gateway down → IOException → Worker returns retry(). | ||
| 27 | + * - Empty window → success() no-op. | ||
| 28 | + */ | ||
| 29 | +class SummaryWorker( | ||
| 30 | + ctx: Context, | ||
| 31 | + params: WorkerParameters, | ||
| 32 | +) : CoroutineWorker(ctx, params) { | ||
| 33 | + | ||
| 34 | + companion object { | ||
| 35 | + const val TAG = "SummaryWorker" | ||
| 36 | + | ||
| 37 | + /** 总结窗口大小:默认最近 1 小时。 */ | ||
| 38 | + const val WINDOW_MS = 60L * 60 * 1000 | ||
| 39 | + | ||
| 40 | + /** 窗口内必须至少有这么多毫秒的语音才触发,否则没必要总结。 */ | ||
| 41 | + const val MIN_DURATION_MS = 60_000L // 60s | ||
| 42 | + | ||
| 43 | + const val INPUT_NOW_TS = "now_ts" // override "now" for tests / manual run | ||
| 44 | + } | ||
| 45 | + | ||
| 46 | + private val dateFmt = SimpleDateFormat("yyyy-MM-dd HH:mm", Locale.US) | ||
| 47 | + | ||
| 48 | + override suspend fun doWork(): Result { | ||
| 49 | + val nowTs = inputData.getLong(INPUT_NOW_TS, System.currentTimeMillis()) | ||
| 50 | + val windowStart = nowTs - WINDOW_MS | ||
| 51 | + val repo = Repository(applicationContext) | ||
| 52 | + | ||
| 53 | + val segments = repo.segments.page(limit = 500, offset = 0) | ||
| 54 | + .filter { it.startTs in windowStart..nowTs } | ||
| 55 | + .sortedBy { it.startTs } | ||
| 56 | + | ||
| 57 | + val totalSpeechMs = segments.sumOf { it.durationMs } | ||
| 58 | + if (totalSpeechMs < MIN_DURATION_MS) { | ||
| 59 | + Log.i(TAG, "skip: total speech ${totalSpeechMs}ms < ${MIN_DURATION_MS}ms threshold") | ||
| 60 | + return Result.success() | ||
| 61 | + } | ||
| 62 | + | ||
| 63 | + // Build the user prompt: time range + each segment's text (+ speaker label if any). | ||
| 64 | + val sb = StringBuilder() | ||
| 65 | + sb.appendLine("以下是 ${dateFmt.format(Date(windowStart))} – ${dateFmt.format(Date(nowTs))} 的语音转写片段(按时间顺序)。") | ||
| 66 | + sb.appendLine() | ||
| 67 | + for (seg in segments) { | ||
| 68 | + val transcript = repo.transcripts.get(seg.id)?.text ?: continue | ||
| 69 | + if (transcript.isBlank()) continue | ||
| 70 | + val speakerLabel = repo.segments.get(seg.id)?.speakerId | ||
| 71 | + ?.let { repo.speakers.all().firstOrNull { p -> p.id == it }?.name } | ||
| 72 | + ?: "未知" | ||
| 73 | + val timeStr = SimpleDateFormat("HH:mm:ss", Locale.US).format(Date(seg.startTs)) | ||
| 74 | + sb.appendLine("[$timeStr] $speakerLabel: $transcript") | ||
| 75 | + } | ||
| 76 | + | ||
| 77 | + val systemPrompt = """ | ||
| 78 | + 你是一个 lifelog 纪要助手。给定一段时间内的语音转写片段(含时间戳和说话人), | ||
| 79 | + 请输出一份简洁的 markdown 纪要,包含: | ||
| 80 | + 1. 一句话主题概括 | ||
| 81 | + 2. 关键信息要点(bullet 列表,5-10 条) | ||
| 82 | + 3. 如果有待办事项 / 决定 / 提到的人名地名,单独列「待办」「人物」「地点」三个小节 | ||
| 83 | + 纪要语言保持中文。不要复述全文,只摘核心。 | ||
| 84 | + """.trimIndent() | ||
| 85 | + | ||
| 86 | + val client = HermesClient() | ||
| 87 | + val response = try { | ||
| 88 | + client.chat(systemPrompt, sb.toString()) | ||
| 89 | + } catch (t: Throwable) { | ||
| 90 | + Log.w(TAG, "hermes call failed: ${t.message}; will retry") | ||
| 91 | + return Result.retry() | ||
| 92 | + } | ||
| 93 | + | ||
| 94 | + repo.summaries.insert( | ||
| 95 | + Summary( | ||
| 96 | + windowStartTs = windowStart, | ||
| 97 | + windowEndTs = nowTs, | ||
| 98 | + markdown = response.text, | ||
| 99 | + model = response.model, | ||
| 100 | + createdAt = System.currentTimeMillis(), | ||
| 101 | + ), | ||
| 102 | + ) | ||
| 103 | + | ||
| 104 | + Log.i( | ||
| 105 | + TAG, | ||
| 106 | + "summary saved span=${WINDOW_MS / 60_000}min " + | ||
| 107 | + "speech=${totalSpeechMs / 1000}s segments=${segments.size} " + | ||
| 108 | + "tokens(prompt=${response.promptTokens}, completion=${response.completionTokens})", | ||
| 109 | + ) | ||
| 110 | + return Result.success() | ||
| 111 | + } | ||
| 112 | +} |
lifelog-recorder/app/src/main/res/xml/network_security_config.xml
0 → 100644
| 1 | +<?xml version="1.0" encoding="utf-8"?> | ||
| 2 | +<network-security-config> | ||
| 3 | + <!-- 允许走 hermes gateway 在本机 loopback 的 cleartext HTTP(127.0.0.1:8642)。 | ||
| 4 | + 其他 host 仍走默认 HTTPS-only 策略。 --> | ||
| 5 | + <domain-config cleartextTrafficPermitted="true"> | ||
| 6 | + <domain includeSubdomains="false">127.0.0.1</domain> | ||
| 7 | + <domain includeSubdomains="false">localhost</domain> | ||
| 8 | + </domain-config> | ||
| 9 | +</network-security-config> |
lifelog-recorder/keys.properties.example
| @@ -3,3 +3,8 @@ xfyun.apiKey=YOUR_API_KEY | @@ -3,3 +3,8 @@ xfyun.apiKey=YOUR_API_KEY | ||
| 3 | xfyun.apiSecret=YOUR_API_SECRET | 3 | xfyun.apiSecret=YOUR_API_SECRET |
| 4 | xfyun.iatHost=iat.xf-yun.com | 4 | xfyun.iatHost=iat.xf-yun.com |
| 5 | xfyun.iatPath=/v1 | 5 | xfyun.iatPath=/v1 |
| 6 | + | ||
| 7 | +# hermes gateway (OpenAI-compat /v1 endpoint) | ||
| 8 | +hermes.apiUrl=http://127.0.0.1:8642 | ||
| 9 | +hermes.apiKey=GENERATE_WITH_openssl_rand_hex_16 | ||
| 10 | +hermes.model=kimi-for-coding |