Commit a536104cbc0788d992140dba52edd28e3d80251f

Authored by yibo
1 parent 3512a84f

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>
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()