From aed2cc41d1664756be4de93fdb14f72e4c41b8a3 Mon Sep 17 00:00:00 2001 From: yibo Date: Mon, 8 Jun 2026 00:43:30 +0800 Subject: [PATCH] feat(lifelog/photolog): 人物档案手填真名/简历 + 手机通讯录两源合并 --- CLAUDE.md | 4 ++-- lifelog-recorder/app/src/main/AndroidManifest.xml | 1 + lifelog-recorder/app/src/main/kotlin/com/lemon/lifelog/PersonProfileScreen.kt | 72 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++------ lifelog-recorder/app/src/main/kotlin/com/lemon/lifelog/PhotologScreen.kt | 62 ++++++++++++++++++++++++++++++++++++++++++++++---------------- lifelog-recorder/app/src/main/kotlin/com/lemon/lifelog/PhotologViewModel.kt | 84 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ lifelog-recorder/app/src/main/kotlin/com/lemon/lifelog/data/AppDatabase.kt | 12 ++++++++++-- lifelog-recorder/app/src/main/kotlin/com/lemon/lifelog/data/Daos.kt | 19 +++++++++++++++++++ lifelog-recorder/app/src/main/kotlin/com/lemon/lifelog/data/Entities.kt | 5 ++++- 8 files changed, 232 insertions(+), 27 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index 280d92d..df8ea13 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -190,7 +190,7 @@ Foreground Service (foregroundServiceType=microphone, WAKE_LOCK partial) ├ SummaryWorker:按时间间隔切「会话」→ 本地 llama-swap(8080) Qwen3.5-2B 出纪要 ├ ChatScreen:本地 2B/4B(llama-swap 8080) + Hermes(8642→Kimi) 多端点对话 └ photolog/CRM 层 (PhotologScreen/PersonProfileScreen/PhotologViewModel + vision/) - 人物 tab = 「我管理的人物」;微信通讯录是池子(勾选纳入);相册人脸标注/绑声纹自动入库 + 人物 tab = 「我管理的人物」;通讯录(微信+手机两源合并)是池子(勾选纳入);相册人脸标注/绑声纹自动入库 ``` **声纹纠偏 L1+L2**(`SpeakerClusterer`):L1 = reclusterAll 和 assign 都**冻结人工命名 anchor 不漂移**(止住「秦子琛吸宜博→质心漂→吞更多」雪球);L2 = 重命名/合并时 `rebuildAnchor` 用该人**全部段均值**重建质心(多 anchor,取代单段注册)。用户流程:听一段 → 命名/合并 → 「重新聚类」。阈值默认 0.45(设置页 0.35–0.65)。CAMPPlus 在短中文段上区分度有天花板,详见 memory `lifelog-block-diarization-pipeline.md`。 @@ -199,7 +199,7 @@ Foreground Service (foregroundServiceType=microphone, WAKE_LOCK partial) **本地 LLM 栈(v0.6,零云端)**:Termux 自编 llama.cpp HEAD + llama-swap v223 监听 `127.0.0.1:8080`,按 model 名(`2b`/`4b`)热切换 Qwen3.5-{2B,4B}-Q4_K_M。纪要 worker + App 对话都打 8080;Magisk `service.d` 开机自启。完整部署/坑见 memory `pixel-local-llm-stack.md` + HANDOFF.md。 -**photolog / 人脉 CRM 层(v0.7 新增)**:在录音之外把 lifelog 长成端上 mini-CRM。`PhotologScreen`(人物 tab + 相册 tab)、`PersonProfileScreen`(联系人档案:tab 式 + 记录搜索 + 微信聊天/朋友圈/AI 印象 + 「🎤 录一段绑定 TA」当场录声纹)、`PhotologViewModel` 统管。**两个人物池分离**:「我管理的人物」(CRM 关心的人,人物 tab 显示)vs「微信通讯录全部联系人」(池子,选人页里勾选纳入)。`vision/`(`FaceEngine`/`FaceMatcher`/`PhotoProcessor`/`PhotoScheduler`/`PhotoWorker`/`WeChatEnrollWorker`/`VisionClient`)做相册扫描 + 人脸聚类 + 微信头像锚点入库;微信头像配方与「锚点 embedding 必须端上算」见 memory `wechat-avatar-face-enrollment.md`。声纹绑人 ↔ 人脸标注 ↔ 微信联系人三者打通到同一 `Person`。 +**photolog / 人脉 CRM 层(v0.7 新增)**:在录音之外把 lifelog 长成端上 mini-CRM。`PhotologScreen`(人物 tab + 相册 tab)、`PersonProfileScreen`(联系人档案:tab 式 + 记录搜索 + 微信聊天/朋友圈/AI 印象 + 「🎤 录一段绑定 TA」当场录声纹)、`PhotologViewModel` 统管。**两个人物池分离**:「我管理的人物」(CRM 关心的人,人物 tab 显示)vs「通讯录全部联系人」(池子,选人页里勾选纳入)。池子有**两个来源**:微信(Mac 推的 enroll 数据)+ 手机系统通讯录(`READ_CONTACTS` → `ContactsContract`,contactRef `phone:`,按手机号/姓名跨源自动合并);`Person` 还存手填 `realName`(真名)/`resume`(简历),档案页可编辑。`vision/`(`FaceEngine`/`FaceMatcher`/`PhotoProcessor`/`PhotoScheduler`/`PhotoWorker`/`WeChatEnrollWorker`/`VisionClient`)做相册扫描 + 人脸聚类 + 微信头像锚点入库;微信头像配方与「锚点 embedding 必须端上算」见 memory `wechat-avatar-face-enrollment.md`。声纹绑人 ↔ 人脸标注 ↔ 微信联系人三者打通到同一 `Person`。 技术栈:Kotlin 2.1 + Compose BOM 2024.12.01 + Room 2.6.1 + WorkManager 2.10 + OkHttp 4.12,AGP 8.7.3,minSdk 30 / targetSdk 35(Android 14 强制 `foregroundServiceType` 起步),arm64-v8a 单 ABI。 diff --git a/lifelog-recorder/app/src/main/AndroidManifest.xml b/lifelog-recorder/app/src/main/AndroidManifest.xml index a9f41d1..04b9a78 100644 --- a/lifelog-recorder/app/src/main/AndroidManifest.xml +++ b/lifelog-recorder/app/src/main/AndroidManifest.xml @@ -11,6 +11,7 @@ + (null) } DisposableEffect(player) { onDispose { player.stop() } } @@ -118,6 +119,7 @@ internal fun PersonProfileScreen( onBind = { bindVoice = true }, onRecordVoice = { vm.resetVoiceEnroll(); recordVoice = true }, onEnrich = { vm.enrichImpression(personId) }, + onEditProfile = { editProfile = true }, ) } OutlinedTextField( @@ -184,6 +186,16 @@ internal fun PersonProfileScreen( onDismiss = { recordVoice = false; vm.resetVoiceEnroll() }, ) } + + if (editProfile) { + person?.let { p -> + EditProfileDialog( + p = p, + onDismiss = { editProfile = false }, + onSave = { realName, resume -> vm.updateProfile(personId, realName, resume); editProfile = false }, + ) + } + } } /** 当场录 6 秒 → 声纹 → 绑到 TA。录音期间 24/7 服务自动暂停让麦克风。 */ @@ -232,9 +244,16 @@ private fun EnrollVoiceDialog( ) } -/** 固定在 tab 上方的资料头:头像 + 名字 + 微信号/备注 + 声纹绑定 + AI 印象。 */ +/** 固定在 tab 上方的资料头:头像 + 名字 + 微信名/真名/微信号/电话 + 简历 + 声纹绑定 + AI 印象。 */ @Composable -private fun ProfileHeader(p: Person, speakers: List, onBind: () -> Unit, onRecordVoice: () -> Unit, onEnrich: () -> Unit) { +private fun ProfileHeader( + p: Person, + speakers: List, + onBind: () -> Unit, + onRecordVoice: () -> Unit, + onEnrich: () -> Unit, + onEditProfile: () -> Unit, +) { Column(modifier = Modifier.fillMaxWidth().padding(horizontal = 16.dp, vertical = 6.dp)) { Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(12.dp)) { if (p.avatarPath != null) { @@ -247,8 +266,13 @@ private fun ProfileHeader(p: Person, speakers: List, onBind: () } Column(modifier = Modifier.weight(1f)) { Text(p.name, style = MaterialTheme.typography.titleMedium, maxLines = 1, overflow = TextOverflow.Ellipsis) - val sub = listOfNotNull(p.alias?.let { "微信号 $it" }, p.remark?.let { "备注 $it" }, p.phone?.let { "📱$it" }).joinToString(" ") - if (sub.isNotBlank()) Text(sub, style = MaterialTheme.typography.labelSmall, maxLines = 1, overflow = TextOverflow.Ellipsis) + val sub = listOfNotNull( + p.nickname?.takeIf { it.isNotBlank() && it != p.name }?.let { "微信名 $it" }, + p.realName?.takeIf { it.isNotBlank() }?.let { "真名 $it" }, + p.alias?.takeIf { it.isNotBlank() }?.let { "微信号 $it" }, + p.phone?.takeIf { it.isNotBlank() }?.let { "📱$it" }, + ).joinToString(" ") + if (sub.isNotBlank()) Text(sub, style = MaterialTheme.typography.labelSmall, maxLines = 2, overflow = TextOverflow.Ellipsis) Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(4.dp)) { val sName = speakers.firstOrNull { it.id == p.voiceSpeakerId }?.name Text("声纹:${sName ?: "未绑定"}", style = MaterialTheme.typography.labelMedium) @@ -261,17 +285,53 @@ private fun ProfileHeader(p: Person, speakers: List, onBind: () } } } + p.resume?.takeIf { it.isNotBlank() }?.let { + Surface(color = MaterialTheme.colorScheme.surfaceVariant, shape = RoundedCornerShape(10.dp), modifier = Modifier.fillMaxWidth()) { + Text("📄 $it", style = MaterialTheme.typography.bodySmall, modifier = Modifier.padding(8.dp)) + } + } p.impression?.let { Surface(color = MaterialTheme.colorScheme.secondaryContainer, shape = RoundedCornerShape(10.dp), modifier = Modifier.fillMaxWidth()) { Text("🧠 $it", style = MaterialTheme.typography.bodySmall, modifier = Modifier.padding(8.dp)) } } - TextButton(onClick = onEnrich, contentPadding = androidx.compose.foundation.layout.PaddingValues(horizontal = 4.dp, vertical = 0.dp)) { - Text(if (p.impression == null) "🧠 生成 AI 印象(本地 4B 看朋友圈)" else "重新生成印象", style = MaterialTheme.typography.labelSmall) + Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(4.dp)) { + TextButton(onClick = onEditProfile, contentPadding = androidx.compose.foundation.layout.PaddingValues(horizontal = 4.dp, vertical = 0.dp)) { + Text("✎ 编辑资料(真名/简历)", style = MaterialTheme.typography.labelSmall) + } + TextButton(onClick = onEnrich, contentPadding = androidx.compose.foundation.layout.PaddingValues(horizontal = 4.dp, vertical = 0.dp)) { + Text(if (p.impression == null) "🧠 AI 印象" else "重新生成印象", style = MaterialTheme.typography.labelSmall) + } } } } +/** 编辑真名 + 简历(手填,本地保存)。 */ +@Composable +private fun EditProfileDialog(p: Person, onDismiss: () -> Unit, onSave: (String, String) -> Unit) { + var realName by remember { mutableStateOf(p.realName.orEmpty()) } + var resume by remember { mutableStateOf(p.resume.orEmpty()) } + AlertDialog( + onDismissRequest = onDismiss, + title = { Text("编辑资料") }, + text = { + Column(verticalArrangement = Arrangement.spacedBy(10.dp)) { + OutlinedTextField( + value = realName, onValueChange = { realName = it }, singleLine = true, + label = { Text("真名") }, modifier = Modifier.fillMaxWidth(), + ) + OutlinedTextField( + value = resume, onValueChange = { resume = it }, + label = { Text("简历 / 备注(职业、公司、关系等)") }, + minLines = 3, modifier = Modifier.fillMaxWidth(), + ) + } + }, + confirmButton = { TextButton(onClick = { onSave(realName, resume) }) { Text("保存") } }, + dismissButton = { TextButton(onClick = onDismiss) { Text("取消") } }, + ) +} + @Composable private fun EmptyHint(text: String) { Box(Modifier.fillMaxSize().padding(24.dp), contentAlignment = Alignment.Center) { diff --git a/lifelog-recorder/app/src/main/kotlin/com/lemon/lifelog/PhotologScreen.kt b/lifelog-recorder/app/src/main/kotlin/com/lemon/lifelog/PhotologScreen.kt index caaef3b..bc6f9d9 100644 --- a/lifelog-recorder/app/src/main/kotlin/com/lemon/lifelog/PhotologScreen.kt +++ b/lifelog-recorder/app/src/main/kotlin/com/lemon/lifelog/PhotologScreen.kt @@ -1,8 +1,12 @@ package com.lemon.lifelog +import android.Manifest +import android.content.pm.PackageManager import androidx.activity.compose.rememberLauncherForActivityResult import androidx.activity.result.PickVisualMediaRequest import androidx.activity.result.contract.ActivityResultContracts +import androidx.compose.ui.platform.LocalContext +import androidx.core.content.ContextCompat import androidx.compose.foundation.ExperimentalFoundationApi import androidx.compose.foundation.background import androidx.compose.foundation.clickable @@ -351,13 +355,13 @@ private fun ContactList(vm: PhotologViewModel, persons: List, onOpen: (L modifier = Modifier.fillMaxWidth().padding(horizontal = 12.dp).padding(bottom = 6.dp), verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(8.dp), ) { - Button(onClick = onOpenPicker) { Text("📇 微信通讯录") } - Text("勾选你关心的人加入管理", style = MaterialTheme.typography.labelSmall) + Button(onClick = onOpenPicker) { Text("📇 通讯录") } + Text("从微信 / 手机通讯录勾选你关心的人加入管理", style = MaterialTheme.typography.labelSmall) } HorizontalDivider() if (persons.isEmpty()) { Box(Modifier.fillMaxSize().padding(24.dp), contentAlignment = Alignment.Center) { - Text("还没有管理的人物。\n点上方「微信通讯录」勾选你关心的人,或在照片里标注人脸、绑声纹会自动加入。", style = MaterialTheme.typography.bodyMedium) + Text("还没有管理的人物。\n点上方「通讯录」从微信 / 手机通讯录勾选你关心的人,或在照片里标注人脸、绑声纹会自动加入。", style = MaterialTheme.typography.bodyMedium) } } else { LazyColumn(modifier = Modifier.fillMaxSize()) { @@ -413,7 +417,7 @@ private fun ContactRow(p: Person, onClick: () -> Unit) { } } -/** 微信通讯录(全部联系人池):勾选 → 纳入「我管理的人物」。搜索 + 拼音 A–Z。 */ +/** 通讯录(微信 + 手机两源合并的全部联系人池):勾选 → 纳入「我管理的人物」。搜索 + 拼音 A–Z。 */ @OptIn(ExperimentalFoundationApi::class) @Composable private fun WeChatPickerScreen(vm: PhotologViewModel, onBack: () -> Unit) { @@ -423,6 +427,23 @@ private fun WeChatPickerScreen(vm: PhotologViewModel, onBack: () -> Unit) { var query by remember { mutableStateOf("") } val managedCount = all.count { it.managed } + val ctx = LocalContext.current + var phoneStatus by remember { mutableStateOf(null) } + val runPhoneImport = { + phoneStatus = "导入中…" + vm.importPhoneContacts { n -> phoneStatus = "已导入手机通讯录(新增 $n)" } + } + val contactsLauncher = rememberLauncherForActivityResult( + ActivityResultContracts.RequestPermission(), + ) { granted -> if (granted) runPhoneImport() else phoneStatus = "未授予通讯录权限" } + val importPhone = { + if (ContextCompat.checkSelfPermission(ctx, Manifest.permission.READ_CONTACTS) == PackageManager.PERMISSION_GRANTED) { + runPhoneImport() + } else { + contactsLauncher.launch(Manifest.permission.READ_CONTACTS) + } + } + val sections = remember(all, query) { val q = query.trim() val filtered = if (q.isBlank()) all else all.filter { p -> @@ -441,24 +462,27 @@ private fun WeChatPickerScreen(vm: PhotologViewModel, onBack: () -> Unit) { verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(8.dp), ) { TextButton(onClick = onBack) { Text("← 返回") } - Text("微信通讯录", style = MaterialTheme.typography.titleMedium) + Text("通讯录", style = MaterialTheme.typography.titleMedium) } OutlinedTextField( value = query, onValueChange = { query = it }, placeholder = { Text("搜索(共 ${all.size},已管理 $managedCount)") }, singleLine = true, modifier = Modifier.fillMaxWidth().padding(horizontal = 12.dp, vertical = 4.dp), ) - if (enrollReady && all.isEmpty()) { - Row( - modifier = Modifier.fillMaxWidth().padding(horizontal = 12.dp).padding(bottom = 6.dp), - verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(8.dp), - ) { - Button(onClick = { vm.enrollWeChat(); enrollTapped = true }) { Text("从微信导入联系人") } - Text(if (enrollTapped) "后台导入中,联系人会陆续出现…" else "导入全部真人联系人", style = MaterialTheme.typography.labelSmall) + // 两源导入:微信(Mac 推过来的 enroll 数据)+ 手机系统通讯录。导入进池子,下面勾选。 + Row( + modifier = Modifier.fillMaxWidth().padding(horizontal = 12.dp).padding(bottom = 4.dp), + verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(8.dp), + ) { + if (enrollReady) { + Button(onClick = { vm.enrollWeChat(); enrollTapped = true }) { Text("💬 微信导入") } } - } else { - Text("勾选要纳入「我的人物」的联系人", style = MaterialTheme.typography.labelSmall, modifier = Modifier.padding(horizontal = 16.dp, vertical = 2.dp)) + OutlinedButton(onClick = importPhone) { Text("📱 手机通讯录") } } + val hint = phoneStatus + ?: if (enrollTapped) "微信后台导入中,联系人会陆续出现…" + else "勾选要纳入「我的人物」的联系人" + Text(hint, style = MaterialTheme.typography.labelSmall, modifier = Modifier.padding(horizontal = 16.dp, vertical = 2.dp)) HorizontalDivider() LazyColumn(modifier = Modifier.fillMaxSize()) { sections.forEach { (letter, group) -> @@ -493,8 +517,14 @@ private fun PickerRow(p: Person, onToggle: () -> Unit) { } Column(modifier = Modifier.weight(1f)) { Text(p.name, maxLines = 1, overflow = TextOverflow.Ellipsis, style = MaterialTheme.typography.titleSmall) - val sub = p.remark?.takeIf { it.isNotBlank() && it != p.name } ?: p.nickname?.takeIf { it != p.name } - if (sub != null) Text(sub, maxLines = 1, overflow = TextOverflow.Ellipsis, style = MaterialTheme.typography.labelSmall) + val src = when { + p.contactRef?.startsWith("wechat:") == true -> "微信" + p.contactRef?.startsWith("phone:") == true -> "手机" + else -> null + } + val detail = p.remark?.takeIf { it.isNotBlank() && it != p.name } ?: p.nickname?.takeIf { it != p.name } + val sub = listOfNotNull(src, detail).joinToString(" · ") + if (sub.isNotBlank()) Text(sub, maxLines = 1, overflow = TextOverflow.Ellipsis, style = MaterialTheme.typography.labelSmall) } } } diff --git a/lifelog-recorder/app/src/main/kotlin/com/lemon/lifelog/PhotologViewModel.kt b/lifelog-recorder/app/src/main/kotlin/com/lemon/lifelog/PhotologViewModel.kt index 44302cd..7b3d45d 100644 --- a/lifelog-recorder/app/src/main/kotlin/com/lemon/lifelog/PhotologViewModel.kt +++ b/lifelog-recorder/app/src/main/kotlin/com/lemon/lifelog/PhotologViewModel.kt @@ -97,6 +97,90 @@ class PhotologViewModel(app: Application) : AndroidViewModel(app) { viewModelScope.launch { repo.persons.updateName(personId, name.trim()) } } + /** 档案手填资料:真名 + 简历(空串存 null)。 */ + fun updateProfile(personId: Long, realName: String?, resume: String?) { + viewModelScope.launch { + repo.persons.updateProfile( + personId, + realName?.trim()?.ifBlank { null }, + resume?.trim()?.ifBlank { null }, + ) + } + } + + /** + * 从手机系统通讯录导入联系人到「池子」(managed=false,跟微信一致,用户再勾选)。 + * 跨源去重(用户选择「按手机号/姓名自动合并」): + * 1) 已按 contactRef="phone:" 建过 → 刷新资料; + * 2) 否则手机号命中已有人物 → 只补手机号,不另建; + * 3) 否则姓名精确命中 → 只补手机号,不另建; + * 4) 都没有 → 新建。 + * 调用方需先拿到 READ_CONTACTS。回调返回本轮新建数。 + */ + fun importPhoneContacts(onDone: (Int) -> Unit) { + val app = getApplication() + viewModelScope.launch { + val created = withContext(Dispatchers.IO) { + var count = 0 + try { + val proj = arrayOf( + android.provider.ContactsContract.CommonDataKinds.Phone.CONTACT_ID, + android.provider.ContactsContract.CommonDataKinds.Phone.DISPLAY_NAME_PRIMARY, + android.provider.ContactsContract.CommonDataKinds.Phone.NUMBER, + android.provider.ContactsContract.CommonDataKinds.Phone.SORT_KEY_PRIMARY, + ) + val seen = HashSet() + app.contentResolver.query( + android.provider.ContactsContract.CommonDataKinds.Phone.CONTENT_URI, + proj, null, null, null, + )?.use { c -> + val idIdx = c.getColumnIndex(android.provider.ContactsContract.CommonDataKinds.Phone.CONTACT_ID) + val nameIdx = c.getColumnIndex(android.provider.ContactsContract.CommonDataKinds.Phone.DISPLAY_NAME_PRIMARY) + val numIdx = c.getColumnIndex(android.provider.ContactsContract.CommonDataKinds.Phone.NUMBER) + val sortIdx = c.getColumnIndex(android.provider.ContactsContract.CommonDataKinds.Phone.SORT_KEY_PRIMARY) + while (c.moveToNext()) { + val cid = if (idIdx >= 0) c.getLong(idIdx) else continue + if (!seen.add(cid)) continue // 一个联系人多号码,只取第一条 + val name = (if (nameIdx >= 0) c.getString(nameIdx) else null)?.trim().orEmpty() + if (name.isBlank()) continue + val phone = (if (numIdx >= 0) c.getString(numIdx) else null) + ?.filter { it.isDigit() }?.takeIf { it.isNotBlank() } + val sortKey = if (sortIdx >= 0) c.getString(sortIdx) else null + val pinyin = sortKey?.lowercase()?.filter { it.isLetter() }?.takeIf { it.isNotBlank() } + val pyInit = ((sortKey?.firstOrNull() ?: name.firstOrNull())?.uppercaseChar()) + ?.takeIf { it in 'A'..'Z' }?.toString() + if (upsertPhoneContact(cid, name, phone, pinyin, pyInit)) count++ + } + } + } catch (t: Throwable) { + Log.w("PhotologVM", "phone import failed: ${t.message}") + } + count + } + onDone(created) + } + } + + /** 返回 true = 本轮新建了一行(用于计数)。 */ + private suspend fun upsertPhoneContact( + cid: Long, name: String, phone: String?, pinyin: String?, pyInit: String?, + ): Boolean { + val ref = "phone:$cid" + repo.persons.getByContactRef(ref)?.let { + repo.persons.updatePhoneInfo(it.id, name, phone, pinyin, pyInit) + return false + } + val merge = (phone?.let { repo.persons.getByPhone(it) }) ?: repo.persons.getByExactName(name) + if (merge != null) { + if (merge.phone.isNullOrBlank() && phone != null) repo.persons.setPhone(merge.id, phone) + return false + } + repo.persons.insert( + Person(name = name, contactRef = ref, phone = phone, pinyin = pinyin, pyInitial = pyInit), + ) + return true + } + /** 点开档案 / 互动时刷新最近访问 → 进通讯录置顶区。 */ fun touchPerson(personId: Long) { viewModelScope.launch { repo.persons.touch(personId, System.currentTimeMillis()) } diff --git a/lifelog-recorder/app/src/main/kotlin/com/lemon/lifelog/data/AppDatabase.kt b/lifelog-recorder/app/src/main/kotlin/com/lemon/lifelog/data/AppDatabase.kt index 1b5b825..4cef3ee 100644 --- a/lifelog-recorder/app/src/main/kotlin/com/lemon/lifelog/data/AppDatabase.kt +++ b/lifelog-recorder/app/src/main/kotlin/com/lemon/lifelog/data/AppDatabase.kt @@ -22,7 +22,7 @@ import androidx.sqlite.db.SupportSQLiteDatabase WechatMessage::class, Moment::class, ], - version = 12, + version = 13, exportSchema = false, ) abstract class AppDatabase : RoomDatabase() { @@ -124,6 +124,14 @@ abstract class AppDatabase : RoomDatabase() { } } + /** 人物档案:persons.realName(真名) + resume(简历/备注),手填。 */ + private val MIGRATION_12_13 = object : Migration(12, 13) { + override fun migrate(db: SupportSQLiteDatabase) { + db.execSQL("ALTER TABLE `persons` ADD COLUMN `realName` TEXT") + db.execSQL("ALTER TABLE `persons` ADD COLUMN `resume` TEXT") + } + } + fun get(ctx: Context): AppDatabase = instance ?: synchronized(this) { instance ?: Room.databaseBuilder( @@ -131,7 +139,7 @@ abstract class AppDatabase : RoomDatabase() { AppDatabase::class.java, "lifelog.db", ) - .addMigrations(MIGRATION_2_3, MIGRATION_3_4, MIGRATION_4_5, MIGRATION_5_6, MIGRATION_6_7, MIGRATION_7_9, MIGRATION_9_10, MIGRATION_10_11, MIGRATION_11_12) + .addMigrations(MIGRATION_2_3, MIGRATION_3_4, MIGRATION_4_5, MIGRATION_5_6, MIGRATION_6_7, MIGRATION_7_9, MIGRATION_9_10, MIGRATION_10_11, MIGRATION_11_12, MIGRATION_12_13) // 不再 fallbackToDestructiveMigration:以后改 schema 必须写 Migration, // 否则启动直接抛 IllegalStateException(fail-loud),绝不静默清掉真实录音。 .build() diff --git a/lifelog-recorder/app/src/main/kotlin/com/lemon/lifelog/data/Daos.kt b/lifelog-recorder/app/src/main/kotlin/com/lemon/lifelog/data/Daos.kt index 5348c0f..137c0b1 100644 --- a/lifelog-recorder/app/src/main/kotlin/com/lemon/lifelog/data/Daos.kt +++ b/lifelog-recorder/app/src/main/kotlin/com/lemon/lifelog/data/Daos.kt @@ -300,6 +300,25 @@ interface PersonDao { @Query("SELECT * FROM persons WHERE contactRef = :ref LIMIT 1") suspend fun getByContactRef(ref: String): Person? + /** 跨源去重:手机通讯录导入时按手机号匹配已有人物(phone 存纯数字)。 */ + @Query("SELECT * FROM persons WHERE phone = :phone LIMIT 1") + suspend fun getByPhone(phone: String): Person? + + /** 跨源去重:按姓名精确匹配已有人物。 */ + @Query("SELECT * FROM persons WHERE name = :name LIMIT 1") + suspend fun getByExactName(name: String): Person? + + @Query("UPDATE persons SET phone = :phone WHERE id = :id") + suspend fun setPhone(id: Long, phone: String?) + + /** 手机通讯录 upsert(按 contactRef="phone:" 幂等,不动 face/voice/managed)。 */ + @Query("UPDATE persons SET name=:name, phone=:phone, pinyin=:pinyin, pyInitial=:pyInitial WHERE id=:id") + suspend fun updatePhoneInfo(id: Long, name: String, phone: String?, pinyin: String?, pyInitial: String?) + + /** 档案手填资料:真名 + 简历。 */ + @Query("UPDATE persons SET realName = :realName, resume = :resume WHERE id = :id") + suspend fun updateProfile(id: Long, realName: String?, resume: String?) + /** 刷新微信资料(enroll upsert 用,不动 faceEmbedding/voiceSpeakerId)。 */ @Query( """ diff --git a/lifelog-recorder/app/src/main/kotlin/com/lemon/lifelog/data/Entities.kt b/lifelog-recorder/app/src/main/kotlin/com/lemon/lifelog/data/Entities.kt index 500c02d..de6e9f9 100644 --- a/lifelog-recorder/app/src/main/kotlin/com/lemon/lifelog/data/Entities.kt +++ b/lifelog-recorder/app/src/main/kotlin/com/lemon/lifelog/data/Entities.kt @@ -106,7 +106,10 @@ data class Person( val alias: String? = null, // 微信号 val pinyin: String? = null, // 全拼(排序) val pyInitial: String? = null, // 首字母(A–Z 索引) - val phone: String? = null, // 手机号(短信关联) + val phone: String? = null, // 手机号(短信关联 / 手机通讯录去重键,存纯数字) + // ----- 手填资料 ----- + val realName: String? = null, // 真名(微信昵称往往是网名,真名手填) + val resume: String? = null, // 简历 / 备注(职业、公司、关系等手填长文) /** AI 印象:本地 LLM 综合 TA 的朋友圈/聊天生成的一句话画像 + 标签(爱好/性格)。 */ val impression: String? = null, /** 最近访问/互动时间(点开档案、绑声纹等会刷新);>0 进通讯录「最近联系人」置顶区。 */ -- libgit2 0.21.4