Commit d19239ac34d4720f45d31ac89673a2d5a3bdb3f4
1 parent
07f3d222
feat(lifelog/photolog): 人脉里程碑2/4/5 — 微信聊天 + 朋友圈 + AI 印象
档案页扩成多源聚合(里程碑 2/4/5;3 短信因本机无数据暂缓)。 - data:WechatMessage / Moment 两表 + persons.impression 字段 + 迁移 v6→v7→v9; 对应 DAO observeByTalker / observeByUser / updateImpression - WeChatEnrollWorker 增 Pass1.5/1.6:导入 messages.json(聊天) + moments.json(朋友圈) (clear+重灌幂等,按 wxid 关联 Person.contactRef) - PersonProfileScreen:加「微信聊天」气泡 + 「朋友圈」列表两段 - M5 AI 印象:PhotologViewModel.enrichImpression 汇总 TA 朋友圈 → 本地 4B(8080) 生成一句话画像 + 标签(爱好/性格),存 persons.impression,档案页展示 + 按钮触发 - server/services/wechat/extract-wechat-chat-moments.py:Mac 侧提取脚本 (EnMicroMsg message 表解密 + SnsMicroMsg 明文 SnsInfo protobuf 文本近似提取) 实测:真机导入聊天 40 条 + 朋友圈 78 条,按 wxid 关联到联系人(火星人 10 条朋友圈)。 M3 短信:研究清楚(mmssms.db 明文 + contacts2 手机号源),但本机 SMS=0、通讯录=0 (纯微信用户),无数据暂缓,机制在有数据设备上可直接复用。 Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Showing
8 changed files
with
296 additions
and
3 deletions
lifelog-recorder/app/src/main/kotlin/com/lemon/lifelog/PersonProfileScreen.kt
| @@ -12,6 +12,7 @@ import androidx.compose.foundation.layout.height | @@ -12,6 +12,7 @@ import androidx.compose.foundation.layout.height | ||
| 12 | import androidx.compose.foundation.layout.padding | 12 | import androidx.compose.foundation.layout.padding |
| 13 | import androidx.compose.foundation.layout.size | 13 | import androidx.compose.foundation.layout.size |
| 14 | import androidx.compose.foundation.layout.width | 14 | import androidx.compose.foundation.layout.width |
| 15 | +import androidx.compose.foundation.layout.widthIn | ||
| 15 | import androidx.compose.foundation.lazy.LazyColumn | 16 | import androidx.compose.foundation.lazy.LazyColumn |
| 16 | import androidx.compose.foundation.lazy.LazyRow | 17 | import androidx.compose.foundation.lazy.LazyRow |
| 17 | import androidx.compose.foundation.lazy.items | 18 | import androidx.compose.foundation.lazy.items |
| @@ -47,8 +48,10 @@ import androidx.compose.ui.text.style.TextOverflow | @@ -47,8 +48,10 @@ import androidx.compose.ui.text.style.TextOverflow | ||
| 47 | import androidx.compose.ui.unit.dp | 48 | import androidx.compose.ui.unit.dp |
| 48 | import coil.compose.AsyncImage | 49 | import coil.compose.AsyncImage |
| 49 | import com.lemon.lifelog.audio.SegmentPlayer | 50 | import com.lemon.lifelog.audio.SegmentPlayer |
| 51 | +import com.lemon.lifelog.data.Moment | ||
| 50 | import com.lemon.lifelog.data.Person | 52 | import com.lemon.lifelog.data.Person |
| 51 | import com.lemon.lifelog.data.SpeakerProfile | 53 | import com.lemon.lifelog.data.SpeakerProfile |
| 54 | +import com.lemon.lifelog.data.WechatMessage | ||
| 52 | import java.io.File | 55 | import java.io.File |
| 53 | import java.text.SimpleDateFormat | 56 | import java.text.SimpleDateFormat |
| 54 | import java.util.Date | 57 | import java.util.Date |
| @@ -72,6 +75,15 @@ internal fun PersonProfileScreen( | @@ -72,6 +75,15 @@ internal fun PersonProfileScreen( | ||
| 72 | if (sid == null) kotlinx.coroutines.flow.flowOf(emptyList()) | 75 | if (sid == null) kotlinx.coroutines.flow.flowOf(emptyList()) |
| 73 | else vm.repo.segments.observeBySpeaker(sid) | 76 | else vm.repo.segments.observeBySpeaker(sid) |
| 74 | }.collectAsState(initial = emptyList()) | 77 | }.collectAsState(initial = emptyList()) |
| 78 | + val wxid = person?.contactRef?.removePrefix("wechat:")?.takeIf { person?.contactRef?.startsWith("wechat:") == true } | ||
| 79 | + val messages by remember(wxid) { | ||
| 80 | + if (wxid == null) kotlinx.coroutines.flow.flowOf(emptyList()) | ||
| 81 | + else vm.repo.wechatMessages.observeByTalker(wxid) | ||
| 82 | + }.collectAsState(initial = emptyList()) | ||
| 83 | + val moments by remember(wxid) { | ||
| 84 | + if (wxid == null) kotlinx.coroutines.flow.flowOf(emptyList()) | ||
| 85 | + else vm.repo.moments.observeByUser(wxid) | ||
| 86 | + }.collectAsState(initial = emptyList()) | ||
| 75 | 87 | ||
| 76 | var bindVoice by remember { mutableStateOf(false) } | 88 | var bindVoice by remember { mutableStateOf(false) } |
| 77 | val player = remember { SegmentPlayer() } | 89 | val player = remember { SegmentPlayer() } |
| @@ -116,6 +128,17 @@ internal fun PersonProfileScreen( | @@ -116,6 +128,17 @@ internal fun PersonProfileScreen( | ||
| 116 | Text("声纹:${sName ?: "未绑定"}", style = MaterialTheme.typography.bodyMedium) | 128 | Text("声纹:${sName ?: "未绑定"}", style = MaterialTheme.typography.bodyMedium) |
| 117 | OutlinedButton(onClick = { bindVoice = true }) { Text(if (p.voiceSpeakerId == null) "绑定" else "改绑") } | 129 | OutlinedButton(onClick = { bindVoice = true }) { Text(if (p.voiceSpeakerId == null) "绑定" else "改绑") } |
| 118 | } | 130 | } |
| 131 | + // AI 印象 | ||
| 132 | + Spacer(Modifier.height(6.dp)) | ||
| 133 | + p.impression?.let { | ||
| 134 | + Surface(color = MaterialTheme.colorScheme.secondaryContainer, shape = RoundedCornerShape(10.dp), modifier = Modifier.fillMaxWidth()) { | ||
| 135 | + Text("🧠 $it", style = MaterialTheme.typography.bodyMedium, modifier = Modifier.padding(10.dp)) | ||
| 136 | + } | ||
| 137 | + Spacer(Modifier.height(6.dp)) | ||
| 138 | + } | ||
| 139 | + OutlinedButton(onClick = { vm.enrichImpression(personId) }) { | ||
| 140 | + Text(if (p.impression == null) "生成 AI 印象(本地 4B,看朋友圈)" else "重新生成印象") | ||
| 141 | + } | ||
| 119 | } | 142 | } |
| 120 | HorizontalDivider() | 143 | HorizontalDivider() |
| 121 | } | 144 | } |
| @@ -169,6 +192,24 @@ internal fun PersonProfileScreen( | @@ -169,6 +192,24 @@ internal fun PersonProfileScreen( | ||
| 169 | } | 192 | } |
| 170 | } | 193 | } |
| 171 | } | 194 | } |
| 195 | + // ----- 微信聊天 ----- | ||
| 196 | + item { HorizontalDivider(); SectionHeader("微信聊天", messages.size) } | ||
| 197 | + if (wxid == null) { | ||
| 198 | + item { Text("非微信联系人。", style = MaterialTheme.typography.bodySmall, modifier = Modifier.padding(horizontal = 16.dp, vertical = 4.dp)) } | ||
| 199 | + } else if (messages.isEmpty()) { | ||
| 200 | + item { Text("这台设备上没有跟 TA 的聊天记录。", style = MaterialTheme.typography.bodySmall, modifier = Modifier.padding(horizontal = 16.dp, vertical = 4.dp)) } | ||
| 201 | + } else { | ||
| 202 | + items(messages, key = { it.id }) { m -> ChatBubble(m) } | ||
| 203 | + } | ||
| 204 | + // ----- 朋友圈 ----- | ||
| 205 | + item { HorizontalDivider(); SectionHeader("朋友圈", moments.size) } | ||
| 206 | + if (wxid == null) { | ||
| 207 | + item { Text("非微信联系人。", style = MaterialTheme.typography.bodySmall, modifier = Modifier.padding(horizontal = 16.dp, vertical = 4.dp)) } | ||
| 208 | + } else if (moments.isEmpty()) { | ||
| 209 | + item { Text("没有 TA 的朋友圈记录。", style = MaterialTheme.typography.bodySmall, modifier = Modifier.padding(horizontal = 16.dp, vertical = 4.dp)) } | ||
| 210 | + } else { | ||
| 211 | + items(moments, key = { it.id }) { mo -> MomentRow(mo) } | ||
| 212 | + } | ||
| 172 | item { Spacer(Modifier.height(24.dp)) } | 213 | item { Spacer(Modifier.height(24.dp)) } |
| 173 | } | 214 | } |
| 174 | } | 215 | } |
| @@ -185,6 +226,30 @@ internal fun PersonProfileScreen( | @@ -185,6 +226,30 @@ internal fun PersonProfileScreen( | ||
| 185 | } | 226 | } |
| 186 | 227 | ||
| 187 | @Composable | 228 | @Composable |
| 229 | +private fun MomentRow(m: Moment) { | ||
| 230 | + Column(modifier = Modifier.fillMaxWidth().padding(horizontal = 16.dp, vertical = 6.dp)) { | ||
| 231 | + Text(PROFILE_FMT.format(Date(m.createTime)), style = MaterialTheme.typography.labelSmall) | ||
| 232 | + Text(m.text, style = MaterialTheme.typography.bodyMedium) | ||
| 233 | + } | ||
| 234 | +} | ||
| 235 | + | ||
| 236 | +@Composable | ||
| 237 | +private fun ChatBubble(m: WechatMessage) { | ||
| 238 | + Row( | ||
| 239 | + modifier = Modifier.fillMaxWidth().padding(horizontal = 12.dp, vertical = 3.dp), | ||
| 240 | + horizontalArrangement = if (m.isSend) Arrangement.End else Arrangement.Start, | ||
| 241 | + ) { | ||
| 242 | + Surface( | ||
| 243 | + color = if (m.isSend) MaterialTheme.colorScheme.primaryContainer else MaterialTheme.colorScheme.surfaceVariant, | ||
| 244 | + shape = RoundedCornerShape(12.dp), | ||
| 245 | + modifier = Modifier.widthIn(max = 280.dp), | ||
| 246 | + ) { | ||
| 247 | + Text(m.content, modifier = Modifier.padding(horizontal = 12.dp, vertical = 8.dp), style = MaterialTheme.typography.bodyMedium) | ||
| 248 | + } | ||
| 249 | + } | ||
| 250 | +} | ||
| 251 | + | ||
| 252 | +@Composable | ||
| 188 | private fun InfoRow(label: String, value: String?) { | 253 | private fun InfoRow(label: String, value: String?) { |
| 189 | if (value.isNullOrBlank()) return | 254 | if (value.isNullOrBlank()) return |
| 190 | Row(modifier = Modifier.fillMaxWidth().padding(vertical = 3.dp), horizontalArrangement = Arrangement.spacedBy(10.dp)) { | 255 | Row(modifier = Modifier.fillMaxWidth().padding(vertical = 3.dp), horizontalArrangement = Arrangement.spacedBy(10.dp)) { |
lifelog-recorder/app/src/main/kotlin/com/lemon/lifelog/PhotologViewModel.kt
| @@ -11,6 +11,8 @@ import com.lemon.lifelog.data.Repository | @@ -11,6 +11,8 @@ import com.lemon.lifelog.data.Repository | ||
| 11 | import androidx.work.ExistingWorkPolicy | 11 | import androidx.work.ExistingWorkPolicy |
| 12 | import androidx.work.OneTimeWorkRequestBuilder | 12 | import androidx.work.OneTimeWorkRequestBuilder |
| 13 | import androidx.work.WorkManager | 13 | import androidx.work.WorkManager |
| 14 | +import com.lemon.lifelog.chat.ChatClient | ||
| 15 | +import com.lemon.lifelog.chat.ChatConfig | ||
| 14 | import com.lemon.lifelog.vision.FaceMatcher | 16 | import com.lemon.lifelog.vision.FaceMatcher |
| 15 | import com.lemon.lifelog.vision.ImageUtil | 17 | import com.lemon.lifelog.vision.ImageUtil |
| 16 | import com.lemon.lifelog.vision.PhotoScheduler | 18 | import com.lemon.lifelog.vision.PhotoScheduler |
| @@ -79,6 +81,30 @@ class PhotologViewModel(app: Application) : AndroidViewModel(app) { | @@ -79,6 +81,30 @@ class PhotologViewModel(app: Application) : AndroidViewModel(app) { | ||
| 79 | viewModelScope.launch { repo.persons.linkVoice(personId, speakerId) } | 81 | viewModelScope.launch { repo.persons.linkVoice(personId, speakerId) } |
| 80 | } | 82 | } |
| 81 | 83 | ||
| 84 | + /** AI 印象:汇总 TA 的朋友圈 + 聊天,本地 4B LLM 生成画像 + 标签(爱好/性格)。 */ | ||
| 85 | + fun enrichImpression(personId: Long) { | ||
| 86 | + viewModelScope.launch { | ||
| 87 | + val p = withContext(Dispatchers.IO) { repo.persons.get(personId) } ?: return@launch | ||
| 88 | + val wxid = p.contactRef?.removePrefix("wechat:")?.takeIf { p.contactRef.startsWith("wechat:") } | ||
| 89 | + val moments = if (wxid != null) withContext(Dispatchers.IO) { repo.moments.byUser(wxid, 40).map { it.text } } else emptyList() | ||
| 90 | + if (moments.isEmpty()) { | ||
| 91 | + repo.persons.updateImpression(personId, "(朋友圈/聊天资料不足,无法生成画像)") | ||
| 92 | + return@launch | ||
| 93 | + } | ||
| 94 | + repo.persons.updateImpression(personId, "生成中…") | ||
| 95 | + try { | ||
| 96 | + val prompt = "下面是「${p.name}」的微信朋友圈片段。请用中文输出对 TA 的**一句话画像** + " + | ||
| 97 | + "**3–6 个标签**(爱好/性格/职业方向/星座倾向等,只基于片段、不编造)。格式:先一句话,再「标签:A、B、C」。\n\n" + | ||
| 98 | + moments.joinToString("\n") { "- ${it.take(80)}" } | ||
| 99 | + val target = ChatConfig.targetById(getApplication(), ChatConfig.ID_4B) | ||
| 100 | + val full = ChatClient().stream(target, listOf("user" to prompt)) {} | ||
| 101 | + repo.persons.updateImpression(personId, full.trim().ifBlank { "(生成为空)" }) | ||
| 102 | + } catch (t: Throwable) { | ||
| 103 | + repo.persons.updateImpression(personId, "⚠ 生成失败:${t.message}") | ||
| 104 | + } | ||
| 105 | + } | ||
| 106 | + } | ||
| 107 | + | ||
| 82 | /** 设备上是否已备好微信 enroll 数据(Mac 推过来的头像+清单)。 */ | 108 | /** 设备上是否已备好微信 enroll 数据(Mac 推过来的头像+清单)。 */ |
| 83 | fun wechatEnrollReady(): Boolean = | 109 | fun wechatEnrollReady(): Boolean = |
| 84 | File(getApplication<Application>().filesDir, "wx_enroll/enroll.json").exists() | 110 | File(getApplication<Application>().filesDir, "wx_enroll/enroll.json").exists() |
lifelog-recorder/app/src/main/kotlin/com/lemon/lifelog/data/AppDatabase.kt
| @@ -19,8 +19,10 @@ import androidx.sqlite.db.SupportSQLiteDatabase | @@ -19,8 +19,10 @@ import androidx.sqlite.db.SupportSQLiteDatabase | ||
| 19 | Person::class, | 19 | Person::class, |
| 20 | Photo::class, | 20 | Photo::class, |
| 21 | Face::class, | 21 | Face::class, |
| 22 | + WechatMessage::class, | ||
| 23 | + Moment::class, | ||
| 22 | ], | 24 | ], |
| 23 | - version = 6, | 25 | + version = 9, |
| 24 | exportSchema = false, | 26 | exportSchema = false, |
| 25 | ) | 27 | ) |
| 26 | abstract class AppDatabase : RoomDatabase() { | 28 | abstract class AppDatabase : RoomDatabase() { |
| @@ -32,6 +34,8 @@ abstract class AppDatabase : RoomDatabase() { | @@ -32,6 +34,8 @@ abstract class AppDatabase : RoomDatabase() { | ||
| 32 | abstract fun personDao(): PersonDao | 34 | abstract fun personDao(): PersonDao |
| 33 | abstract fun photoDao(): PhotoDao | 35 | abstract fun photoDao(): PhotoDao |
| 34 | abstract fun faceDao(): FaceDao | 36 | abstract fun faceDao(): FaceDao |
| 37 | + abstract fun wechatMessageDao(): WechatMessageDao | ||
| 38 | + abstract fun momentDao(): MomentDao | ||
| 35 | 39 | ||
| 36 | companion object { | 40 | companion object { |
| 37 | @Volatile | 41 | @Volatile |
| @@ -80,6 +84,25 @@ abstract class AppDatabase : RoomDatabase() { | @@ -80,6 +84,25 @@ abstract class AppDatabase : RoomDatabase() { | ||
| 80 | } | 84 | } |
| 81 | } | 85 | } |
| 82 | 86 | ||
| 87 | + /** v0.7 人脉里程碑2:微信聊天消息表。 */ | ||
| 88 | + private val MIGRATION_6_7 = object : Migration(6, 7) { | ||
| 89 | + override fun migrate(db: SupportSQLiteDatabase) { | ||
| 90 | + db.execSQL( | ||
| 91 | + "CREATE TABLE IF NOT EXISTS `wechat_messages` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `talker` TEXT NOT NULL, `content` TEXT NOT NULL, `createTime` INTEGER NOT NULL, `isSend` INTEGER NOT NULL)", | ||
| 92 | + ) | ||
| 93 | + } | ||
| 94 | + } | ||
| 95 | + | ||
| 96 | + /** v0.7 人脉里程碑4/5:朋友圈表 + persons.impression(AI 印象)。 */ | ||
| 97 | + private val MIGRATION_7_9 = object : Migration(7, 9) { | ||
| 98 | + override fun migrate(db: SupportSQLiteDatabase) { | ||
| 99 | + db.execSQL( | ||
| 100 | + "CREATE TABLE IF NOT EXISTS `moments` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `userName` TEXT NOT NULL, `text` TEXT NOT NULL, `createTime` INTEGER NOT NULL)", | ||
| 101 | + ) | ||
| 102 | + db.execSQL("ALTER TABLE `persons` ADD COLUMN `impression` TEXT") | ||
| 103 | + } | ||
| 104 | + } | ||
| 105 | + | ||
| 83 | fun get(ctx: Context): AppDatabase = | 106 | fun get(ctx: Context): AppDatabase = |
| 84 | instance ?: synchronized(this) { | 107 | instance ?: synchronized(this) { |
| 85 | instance ?: Room.databaseBuilder( | 108 | instance ?: Room.databaseBuilder( |
| @@ -87,7 +110,7 @@ abstract class AppDatabase : RoomDatabase() { | @@ -87,7 +110,7 @@ abstract class AppDatabase : RoomDatabase() { | ||
| 87 | AppDatabase::class.java, | 110 | AppDatabase::class.java, |
| 88 | "lifelog.db", | 111 | "lifelog.db", |
| 89 | ) | 112 | ) |
| 90 | - .addMigrations(MIGRATION_2_3, MIGRATION_3_4, MIGRATION_4_5, MIGRATION_5_6) | 113 | + .addMigrations(MIGRATION_2_3, MIGRATION_3_4, MIGRATION_4_5, MIGRATION_5_6, MIGRATION_6_7, MIGRATION_7_9) |
| 91 | // 不再 fallbackToDestructiveMigration:以后改 schema 必须写 Migration, | 114 | // 不再 fallbackToDestructiveMigration:以后改 schema 必须写 Migration, |
| 92 | // 否则启动直接抛 IllegalStateException(fail-loud),绝不静默清掉真实录音。 | 115 | // 否则启动直接抛 IllegalStateException(fail-loud),绝不静默清掉真实录音。 |
| 93 | .build() | 116 | .build() |
lifelog-recorder/app/src/main/kotlin/com/lemon/lifelog/data/Daos.kt
| @@ -266,6 +266,9 @@ interface PersonDao { | @@ -266,6 +266,9 @@ interface PersonDao { | ||
| 266 | @Query("UPDATE persons SET faceEmbedding = :emb WHERE id = :id") | 266 | @Query("UPDATE persons SET faceEmbedding = :emb WHERE id = :id") |
| 267 | suspend fun updateFaceEmbedding(id: Long, emb: ByteArray) | 267 | suspend fun updateFaceEmbedding(id: Long, emb: ByteArray) |
| 268 | 268 | ||
| 269 | + @Query("UPDATE persons SET impression = :text WHERE id = :id") | ||
| 270 | + suspend fun updateImpression(id: Long, text: String) | ||
| 271 | + | ||
| 269 | @Query("UPDATE persons SET name = :name WHERE id = :id") | 272 | @Query("UPDATE persons SET name = :name WHERE id = :id") |
| 270 | suspend fun updateName(id: Long, name: String) | 273 | suspend fun updateName(id: Long, name: String) |
| 271 | 274 | ||
| @@ -358,6 +361,40 @@ interface FaceDao { | @@ -358,6 +361,40 @@ interface FaceDao { | ||
| 358 | } | 361 | } |
| 359 | 362 | ||
| 360 | @Dao | 363 | @Dao |
| 364 | +interface MomentDao { | ||
| 365 | + @Insert | ||
| 366 | + suspend fun insertAll(m: List<Moment>) | ||
| 367 | + | ||
| 368 | + @Query("DELETE FROM moments") | ||
| 369 | + suspend fun clear() | ||
| 370 | + | ||
| 371 | + @Query("SELECT COUNT(*) FROM moments") | ||
| 372 | + suspend fun count(): Int | ||
| 373 | + | ||
| 374 | + @Query("SELECT * FROM moments WHERE userName = :u ORDER BY createTime DESC") | ||
| 375 | + fun observeByUser(u: String): Flow<List<Moment>> | ||
| 376 | + | ||
| 377 | + @Query("SELECT * FROM moments WHERE userName = :u ORDER BY createTime DESC LIMIT :n") | ||
| 378 | + suspend fun byUser(u: String, n: Int): List<Moment> | ||
| 379 | +} | ||
| 380 | + | ||
| 381 | +@Dao | ||
| 382 | +interface WechatMessageDao { | ||
| 383 | + @Insert | ||
| 384 | + suspend fun insertAll(msgs: List<WechatMessage>) | ||
| 385 | + | ||
| 386 | + @Query("DELETE FROM wechat_messages") | ||
| 387 | + suspend fun clear() | ||
| 388 | + | ||
| 389 | + @Query("SELECT COUNT(*) FROM wechat_messages") | ||
| 390 | + suspend fun count(): Int | ||
| 391 | + | ||
| 392 | + /** 某联系人(wxid)的聊天,时间正序。 */ | ||
| 393 | + @Query("SELECT * FROM wechat_messages WHERE talker = :talker ORDER BY createTime ASC") | ||
| 394 | + fun observeByTalker(talker: String): Flow<List<WechatMessage>> | ||
| 395 | +} | ||
| 396 | + | ||
| 397 | +@Dao | ||
| 361 | interface ChatDao { | 398 | interface ChatDao { |
| 362 | @Insert | 399 | @Insert |
| 363 | suspend fun insertConversation(c: ChatConversation): Long | 400 | suspend fun insertConversation(c: ChatConversation): Long |
lifelog-recorder/app/src/main/kotlin/com/lemon/lifelog/data/Entities.kt
| @@ -103,7 +103,9 @@ data class Person( | @@ -103,7 +103,9 @@ data class Person( | ||
| 103 | val alias: String? = null, // 微信号 | 103 | val alias: String? = null, // 微信号 |
| 104 | val pinyin: String? = null, // 全拼(排序) | 104 | val pinyin: String? = null, // 全拼(排序) |
| 105 | val pyInitial: String? = null, // 首字母(A–Z 索引) | 105 | val pyInitial: String? = null, // 首字母(A–Z 索引) |
| 106 | - val phone: String? = null, // 手机号(未来短信关联,先占位) | 106 | + val phone: String? = null, // 手机号(短信关联) |
| 107 | + /** AI 印象:本地 LLM 综合 TA 的朋友圈/聊天生成的一句话画像 + 标签(爱好/性格)。 */ | ||
| 108 | + val impression: String? = null, | ||
| 107 | val createdAt: Long = System.currentTimeMillis(), | 109 | val createdAt: Long = System.currentTimeMillis(), |
| 108 | ) | 110 | ) |
| 109 | 111 | ||
| @@ -124,6 +126,25 @@ data class Photo( | @@ -124,6 +126,25 @@ data class Photo( | ||
| 124 | val processedAt: Long? = null, | 126 | val processedAt: Long? = null, |
| 125 | ) | 127 | ) |
| 126 | 128 | ||
| 129 | +/** 朋友圈(从 SnsMicroMsg SnsInfo 表导入,userName=发布者 wxid,text=正文)。 */ | ||
| 130 | +@Entity(tableName = "moments") | ||
| 131 | +data class Moment( | ||
| 132 | + @PrimaryKey(autoGenerate = true) val id: Long = 0, | ||
| 133 | + val userName: String, | ||
| 134 | + val text: String, | ||
| 135 | + val createTime: Long, | ||
| 136 | +) | ||
| 137 | + | ||
| 138 | +/** 微信聊天消息(从 EnMicroMsg message 表导入,talker=对方 wxid,关联 Person.contactRef)。 */ | ||
| 139 | +@Entity(tableName = "wechat_messages") | ||
| 140 | +data class WechatMessage( | ||
| 141 | + @PrimaryKey(autoGenerate = true) val id: Long = 0, | ||
| 142 | + val talker: String, // 对方 wxid | ||
| 143 | + val content: String, | ||
| 144 | + val createTime: Long, // 毫秒 | ||
| 145 | + val isSend: Boolean, // true = 我发的 | ||
| 146 | +) | ||
| 147 | + | ||
| 127 | /** 照片里检测到的一张脸:bbox + ArcFace embedding + 归属 Person。结构同 SpeakerProfile 的脸版。 */ | 148 | /** 照片里检测到的一张脸:bbox + ArcFace embedding + 归属 Person。结构同 SpeakerProfile 的脸版。 */ |
| 128 | @Entity(tableName = "faces") | 149 | @Entity(tableName = "faces") |
| 129 | data class Face( | 150 | data class Face( |
lifelog-recorder/app/src/main/kotlin/com/lemon/lifelog/data/Repository.kt
| @@ -12,4 +12,6 @@ class Repository(ctx: Context) { | @@ -12,4 +12,6 @@ class Repository(ctx: Context) { | ||
| 12 | val persons = db.personDao() | 12 | val persons = db.personDao() |
| 13 | val photos = db.photoDao() | 13 | val photos = db.photoDao() |
| 14 | val faces = db.faceDao() | 14 | val faces = db.faceDao() |
| 15 | + val wechatMessages = db.wechatMessageDao() | ||
| 16 | + val moments = db.momentDao() | ||
| 15 | } | 17 | } |
lifelog-recorder/app/src/main/kotlin/com/lemon/lifelog/vision/WeChatEnrollWorker.kt
| @@ -4,8 +4,10 @@ import android.content.Context | @@ -4,8 +4,10 @@ import android.content.Context | ||
| 4 | import android.util.Log | 4 | import android.util.Log |
| 5 | import androidx.work.CoroutineWorker | 5 | import androidx.work.CoroutineWorker |
| 6 | import androidx.work.WorkerParameters | 6 | import androidx.work.WorkerParameters |
| 7 | +import com.lemon.lifelog.data.Moment | ||
| 7 | import com.lemon.lifelog.data.Person | 8 | import com.lemon.lifelog.data.Person |
| 8 | import com.lemon.lifelog.data.Repository | 9 | import com.lemon.lifelog.data.Repository |
| 10 | +import com.lemon.lifelog.data.WechatMessage | ||
| 9 | import org.json.JSONArray | 11 | import org.json.JSONArray |
| 10 | import java.io.File | 12 | import java.io.File |
| 11 | import java.util.concurrent.atomic.AtomicBoolean | 13 | import java.util.concurrent.atomic.AtomicBoolean |
| @@ -62,6 +64,49 @@ class WeChatEnrollWorker(ctx: Context, params: WorkerParameters) : CoroutineWork | @@ -62,6 +64,49 @@ class WeChatEnrollWorker(ctx: Context, params: WorkerParameters) : CoroutineWork | ||
| 62 | } | 64 | } |
| 63 | Log.i(TAG, "资料 upsert:新建 $created(共 ${arr.length()})") | 65 | Log.i(TAG, "资料 upsert:新建 $created(共 ${arr.length()})") |
| 64 | 66 | ||
| 67 | + // ---- Pass 1.5: 微信聊天导入(clear + 重灌,幂等)---- | ||
| 68 | + val msgFile = File(dir, "messages.json") | ||
| 69 | + if (msgFile.exists()) { | ||
| 70 | + try { | ||
| 71 | + val m = JSONArray(msgFile.readText()) | ||
| 72 | + val msgs = ArrayList<WechatMessage>(m.length()) | ||
| 73 | + for (i in 0 until m.length()) { | ||
| 74 | + val o = m.getJSONObject(i) | ||
| 75 | + msgs.add( | ||
| 76 | + WechatMessage( | ||
| 77 | + talker = o.optString("talker"), | ||
| 78 | + content = o.optString("content"), | ||
| 79 | + createTime = o.optLong("createTime"), | ||
| 80 | + isSend = o.optInt("isSend") == 1, | ||
| 81 | + ), | ||
| 82 | + ) | ||
| 83 | + } | ||
| 84 | + repo.wechatMessages.clear() | ||
| 85 | + repo.wechatMessages.insertAll(msgs) | ||
| 86 | + Log.i(TAG, "微信聊天导入 ${msgs.size} 条") | ||
| 87 | + } catch (t: Throwable) { | ||
| 88 | + Log.w(TAG, "msg import failed: ${t.message}") | ||
| 89 | + } | ||
| 90 | + } | ||
| 91 | + | ||
| 92 | + // ---- Pass 1.6: 朋友圈导入 ---- | ||
| 93 | + val snsFile = File(dir, "moments.json") | ||
| 94 | + if (snsFile.exists()) { | ||
| 95 | + try { | ||
| 96 | + val a = JSONArray(snsFile.readText()) | ||
| 97 | + val ms = ArrayList<Moment>(a.length()) | ||
| 98 | + for (i in 0 until a.length()) { | ||
| 99 | + val o = a.getJSONObject(i) | ||
| 100 | + ms.add(Moment(userName = o.optString("userName"), text = o.optString("text"), createTime = o.optLong("createTime"))) | ||
| 101 | + } | ||
| 102 | + repo.moments.clear() | ||
| 103 | + repo.moments.insertAll(ms) | ||
| 104 | + Log.i(TAG, "朋友圈导入 ${ms.size} 条") | ||
| 105 | + } catch (t: Throwable) { | ||
| 106 | + Log.w(TAG, "moments import failed: ${t.message}") | ||
| 107 | + } | ||
| 108 | + } | ||
| 109 | + | ||
| 65 | // ---- Pass 2: 人脸锚点(仅 hasFace 且未建锚点)---- | 110 | // ---- Pass 2: 人脸锚点(仅 hasFace 且未建锚点)---- |
| 66 | val engine = FaceEngine.load(applicationContext) | 111 | val engine = FaceEngine.load(applicationContext) |
| 67 | var anchored = 0 | 112 | var anchored = 0 |
server/services/wechat/extract-wechat-chat-moments.py
0 → 100644
| 1 | +#!/usr/bin/env python3 | ||
| 2 | +""" | ||
| 3 | +微信聊天 + 朋友圈提取 —— 给 lifelog/photolog 人脉档案页用(里程碑 2/4)。 | ||
| 4 | + | ||
| 5 | +- 聊天:EnMicroMsg.db(SQLCipher,key e03ce81 + compat=1 + page1024)的 message 表, | ||
| 6 | + type=1 文本、talker 非 @chatroom → messages.json [{talker,isSend,createTime,content}]。 | ||
| 7 | + 注意 sqlcipher .mode json 输出开头有 PRAGMA 的 "ok",要 raw[raw.index('['):] 跳过, | ||
| 8 | + 否则 app 端 JSONArray 解析失败、消息导不进去(实测踩过)。 | ||
| 9 | +- 朋友圈:SnsMicroMsg.db **是明文 SQLite**(不加密!)的 SnsInfo 表,content 是 protobuf, | ||
| 10 | + 正文用「抽含中文最多的可读段」近似提取 → moments.json [{userName,createTime,text}]。 | ||
| 11 | + | ||
| 12 | +产物 push 到设备 filesDir/wx_enroll/{messages.json,moments.json},app 内「从微信导入联系人」 | ||
| 13 | +的 WeChatEnrollWorker 会 clear+重灌进 wechat_messages / moments 表,按 wxid 关联 Person。 | ||
| 14 | + | ||
| 15 | +用法:python3 extract-wechat-chat-moments.py /tmp/EnMicroMsg.db /tmp/SnsMicroMsg.db e03ce81 ./out | ||
| 16 | +""" | ||
| 17 | +import sys, os, re, json, subprocess | ||
| 18 | + | ||
| 19 | + | ||
| 20 | +def sqlcipher_json(db, key, sql): | ||
| 21 | + script = f'PRAGMA key="{key}";\nPRAGMA cipher_compatibility=1;\nPRAGMA cipher_page_size=1024;\n.mode json\n{sql}\n' | ||
| 22 | + out = subprocess.run(['sqlcipher', db], input=script, capture_output=True, text=True).stdout | ||
| 23 | + return json.loads(out[out.index('['):]) # 跳过 PRAGMA 的 "ok" | ||
| 24 | + | ||
| 25 | + | ||
| 26 | +def extract_messages(en_db, key): | ||
| 27 | + return sqlcipher_json(en_db, key, """ | ||
| 28 | + SELECT talker, isSend, createTime, content FROM message | ||
| 29 | + WHERE type=1 AND talker NOT LIKE '%@chatroom' AND content IS NOT NULL AND content != '' | ||
| 30 | + ORDER BY createTime ASC;""") | ||
| 31 | + | ||
| 32 | + | ||
| 33 | +def moment_text(blob): | ||
| 34 | + if not blob: | ||
| 35 | + return '' | ||
| 36 | + s = blob.decode('utf-8', 'ignore') | ||
| 37 | + segs = re.findall(r'[一-鿿A-Za-z0-9,。!?、,.!?#@::…~()()【】\-—\s]{2,}', s) | ||
| 38 | + | ||
| 39 | + def score(x): | ||
| 40 | + x = x.strip() | ||
| 41 | + if x.startswith(('http', 'wxid', 'mmsns', 'mp.weixin')): | ||
| 42 | + return -1 | ||
| 43 | + if re.fullmatch(r'[A-Za-z0-9]+', x): | ||
| 44 | + return -1 | ||
| 45 | + return len(re.findall(r'[一-鿿]', x)) | ||
| 46 | + best = max(segs, key=score, default='') | ||
| 47 | + return best.strip() if score(best) > 0 else '' | ||
| 48 | + | ||
| 49 | + | ||
| 50 | +def extract_moments(sns_db): | ||
| 51 | + import sqlite3 # SnsMicroMsg 是明文 | ||
| 52 | + c = sqlite3.connect(sns_db) | ||
| 53 | + out = [] | ||
| 54 | + for u, t, content in c.execute("SELECT userName, createTime, content FROM SnsInfo WHERE content IS NOT NULL"): | ||
| 55 | + txt = moment_text(content) | ||
| 56 | + if txt: | ||
| 57 | + ct = int(t) | ||
| 58 | + ct = ct * 1000 if ct < 1e12 else ct # 秒→毫秒 | ||
| 59 | + out.append({'userName': u, 'createTime': ct, 'text': txt}) | ||
| 60 | + return out | ||
| 61 | + | ||
| 62 | + | ||
| 63 | +def main(): | ||
| 64 | + en_db, sns_db, key, out_dir = sys.argv[1], sys.argv[2], sys.argv[3], sys.argv[4] | ||
| 65 | + os.makedirs(out_dir, exist_ok=True) | ||
| 66 | + msgs = extract_messages(en_db, key) | ||
| 67 | + json.dump(msgs, open(os.path.join(out_dir, 'messages.json'), 'w'), ensure_ascii=False) | ||
| 68 | + moments = extract_moments(sns_db) | ||
| 69 | + json.dump(moments, open(os.path.join(out_dir, 'moments.json'), 'w'), ensure_ascii=False) | ||
| 70 | + print(f'聊天 {len(msgs)} 条,朋友圈 {len(moments)} 条') | ||
| 71 | + | ||
| 72 | + | ||
| 73 | +if __name__ == '__main__': | ||
| 74 | + main() |