From 03cf4fc57647badad508b6c5edba82a384be8ad1 Mon Sep 17 00:00:00 2001 From: yibo Date: Thu, 11 Jun 2026 00:21:04 +0800 Subject: [PATCH] feat(crm): P1 #3/#4/#5 — 关系强度/组织实体/「谁能帮我」工具 --- lifelog-recorder/app/src/main/kotlin/com/lemon/lifelog/chat/LifelogTools.kt | 228 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ lifelog-recorder/app/src/main/kotlin/com/lemon/lifelog/crm/RelationBuilder.kt | 4 +++- lifelog-recorder/app/src/main/kotlin/com/lemon/lifelog/data/AppDatabase.kt | 26 ++++++++++++++++++++++++-- lifelog-recorder/app/src/main/kotlin/com/lemon/lifelog/data/Daos.kt | 87 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ lifelog-recorder/app/src/main/kotlin/com/lemon/lifelog/data/Entities.kt | 30 ++++++++++++++++++++++++++++++ lifelog-recorder/app/src/main/kotlin/com/lemon/lifelog/data/Repository.kt | 2 ++ 6 files changed, 374 insertions(+), 3 deletions(-) diff --git a/lifelog-recorder/app/src/main/kotlin/com/lemon/lifelog/chat/LifelogTools.kt b/lifelog-recorder/app/src/main/kotlin/com/lemon/lifelog/chat/LifelogTools.kt index 92ce914..5e8cde2 100644 --- a/lifelog-recorder/app/src/main/kotlin/com/lemon/lifelog/chat/LifelogTools.kt +++ b/lifelog-recorder/app/src/main/kotlin/com/lemon/lifelog/chat/LifelogTools.kt @@ -225,6 +225,45 @@ object LifelogTools { ), required = listOf("query"), )) + put(schema( + name = "get_stale_contacts", + desc = "列出长期未联系的人(按距今天数降序),帮助发现需要跟进的关系", + props = mapOf( + "min_days" to prop("integer", "至少多少天未联系,默认 30"), + "limit" to prop("integer", "返回人数,默认 20"), + ), + required = emptyList(), + )) + put(schema( + name = "get_org_members", + desc = "查询某个组织/公司/学校的成员列表(含角色和在职时间)", + props = mapOf( + "org_name" to prop("string", "组织名称"), + ), + required = listOf("org_name"), + )) + put(schema( + name = "link_person_org", + desc = "将某人与某个组织关联(建立「人↔公司/学校」关系),组织不存在则自动创建", + props = mapOf( + "person_name" to prop("string", "人物姓名"), + "org_name" to prop("string", "组织名称"), + "kind" to prop("string", "组织类型:company(公司)/ school(学校)/ org(机构)/ project(项目),默认 company"), + "role" to prop("string", "职位/角色,可选"), + "start_date" to prop("string", "入职/加入时间(如 2023-01),可选"), + "end_date" to prop("string", "离职时间,不填=至今,可选"), + ), + required = listOf("person_name", "org_name"), + )) + put(schema( + name = "find_relevant_persons", + desc = "根据目标/需求找出最相关的人(如「找一个做 AI 的朋友帮我看简历」),按相关度排序", + props = mapOf( + "goal" to prop("string", "目标或需求描述"), + "limit" to prop("integer", "返回人数,默认 10"), + ), + required = listOf("goal"), + )) } } @@ -271,6 +310,10 @@ object LifelogTools { "get_person_timeline" -> getPersonTimeline(repo, args) "refresh_person_data" -> refreshPersonData(repo, ctx, args) "web_search" -> webSearch(ctx, args) + "get_stale_contacts" -> getStaleContacts(repo, args) + "get_org_members" -> getOrgMembers(repo, args) + "link_person_org" -> linkPersonOrg(repo, args) + "find_relevant_persons" -> findRelevantPersons(repo, args) else -> "未知工具:$name" } } catch (t: Throwable) { @@ -730,6 +773,191 @@ object LifelogTools { } } + // ── P1 #3 失联联系人 ────────────────────────────────────────────────────── + + private suspend fun getStaleContacts(repo: Repository, args: JSONObject): String { + val minDays = args.optInt("min_days", 30).coerceAtLeast(1) + val limit = args.optInt("limit", 20).coerceIn(1, 60) + val nowMs = System.currentTimeMillis() + val cutoff = nowMs - minDays * 86_400_000L + + val persons = repo.persons.all().filter { it.managed } + if (persons.isEmpty()) return "CRM 中暂无管理的人物。" + + // 构建 wxid→Person 反查表 + val wxidToPersonId = persons + .filter { it.contactRef?.startsWith("wechat:") == true } + .associate { it.contactRef!!.removePrefix("wechat:") to it.id } + + // 微信最近联系时间 + val wxTimes = repo.wechatMessages.observeLastContactTimes().first() + .mapNotNull { tt -> wxidToPersonId[tt.talker]?.let { pid -> pid to tt.maxTime } } + .toMap() + // 录音提及最近时间 + val mentionTimes = repo.mentions.observeLastMentionTimes().first() + .associate { it.personId to it.maxTime } + + data class Stale(val person: com.lemon.lifelog.data.Person, val lastTs: Long) + val staleList = persons.map { p -> + val ts = maxOf( + wxTimes[p.id] ?: 0L, + mentionTimes[p.id] ?: 0L, + p.lastAccess, + ) + Stale(p, ts) + }.filter { it.lastTs < cutoff } + .sortedBy { it.lastTs } + .take(limit) + + if (staleList.isEmpty()) return "没有超过 $minDays 天未联系的人(很好!)" + return buildString { + appendLine("超过 $minDays 天未联系(${staleList.size} 人,失联最久在前):") + staleList.forEach { s -> + val days = ((nowMs - s.lastTs) / 86_400_000L).toInt() + val lastStr = if (s.lastTs == 0L) "从未" else "${days}天前(${TS.format(Date(s.lastTs))})" + appendLine("- ${s.person.name}:上次联系 $lastStr") + } + } + } + + // ── P1 #4 组织成员 ──────────────────────────────────────────────────────── + + private suspend fun getOrgMembers(repo: Repository, args: JSONObject): String { + val name = args.optString("org_name", "").ifBlank { return "请提供组织名称" } + val orgs = repo.organizations.observeAll().first().filter { it.name.contains(name) } + if (orgs.isEmpty()) return "未找到名称含「$name」的组织。" + return buildString { + orgs.take(3).forEach { org -> + appendLine("=== ${org.name}(${org.kind})===") + org.url?.takeIf { it.isNotBlank() }?.let { appendLine("网址:$it") } + org.notes?.takeIf { it.isNotBlank() }?.let { appendLine("备注:$it") } + val members = repo.personOrgs.observeMembersOfOrg(org.id).first() + if (members.isEmpty()) { + appendLine("(暂无成员)") + } else { + appendLine("成员(${members.size} 人):") + members.forEach { m -> + val period = buildString { + m.startDate?.let { append(it) } + if (m.startDate != null || m.endDate != null) append("–") + append(m.endDate ?: "至今") + } + val role = m.role?.let { " · $it" } ?: "" + appendLine(" - ${m.personName}$role${if (period.isNotBlank()) " $period" else ""}") + } + } + appendLine() + } + } + } + + private suspend fun linkPersonOrg(repo: Repository, args: JSONObject): String { + val personName = args.optString("person_name", "").ifBlank { return "请提供人物姓名" } + val orgName = args.optString("org_name", "").ifBlank { return "请提供组织名称" } + val kind = args.optString("kind", "company").ifBlank { "company" } + val role = args.optString("role", "").ifBlank { null } + val startDate = args.optString("start_date", "").ifBlank { null } + val endDate = args.optString("end_date", "").ifBlank { null } + + val person = repo.persons.all().firstOrNull { p -> + p.name.contains(personName) || p.realName?.contains(personName) == true || + p.nickname?.contains(personName) == true + } ?: return "未找到与「$personName」匹配的联系人。" + + // 找或建组织 + val orgs = repo.organizations.observeAll().first() + val org = orgs.firstOrNull { it.name == orgName } + ?: com.lemon.lifelog.data.Organization(name = orgName, kind = kind) + .let { repo.organizations.insert(it) } + .let { repo.organizations.get(it) } + ?: return "组织创建失败" + + // 检查是否已绑定 + val existing = repo.personOrgs.observeForPerson(person.id).first() + if (existing.any { it.orgId == org.id }) { + return "「${person.name}」已与「${org.name}」关联,无需重复绑定。" + } + + repo.personOrgs.insert( + com.lemon.lifelog.data.PersonOrg( + personId = person.id, + orgId = org.id, + role = role, + startDate = startDate, + endDate = endDate, + ) + ) + val roleStr = role?.let { "($it)" } ?: "" + val dateStr = if (startDate != null) " $startDate–${endDate ?: "至今"}" else "" + return "已关联:${person.name}$roleStr ↔ ${org.name}(${org.kind})$dateStr" + } + + // ── P1 #5 「谁能帮我」──────────────────────────────────────────────────── + + private suspend fun findRelevantPersons(repo: Repository, args: JSONObject): String { + val goal = args.optString("goal", "").ifBlank { return "请描述你的目标或需求" } + val limit = args.optInt("limit", 10).coerceIn(1, 30) + + val persons = repo.persons.all().filter { it.managed } + if (persons.isEmpty()) return "CRM 中暂无管理的人物。" + + // 把 goal 分词(按空格 + 标点切,简单起见,不用 jieba) + val keywords = goal.split(Regex("[\\s,。、!?,./;]+")).filter { it.length >= 2 } + + val nowMs = System.currentTimeMillis() + // 微信/提及最近联系时间 → 计算有效强度衰减 + val wxTimes = repo.wechatMessages.observeLastContactTimes().first() + val wxidToPid = persons.filter { it.contactRef?.startsWith("wechat:") == true } + .associate { it.contactRef!!.removePrefix("wechat:") to it.id } + val wxContact = wxTimes.mapNotNull { tt -> wxidToPid[tt.talker]?.let { pid -> pid to tt.maxTime } }.toMap() + val mentionTs = repo.mentions.observeLastMentionTimes().first().associate { it.personId to it.maxTime } + + // 共现强度(用 allEdges 无 flow,直接 suspend) + val relStrength = HashMap() + repo.relations.allEdges().forEach { e -> + val eff = minOf(e.weight * 0.5f, 5.0f) + relStrength[e.personA] = (relStrength[e.personA] ?: 0f) + eff + relStrength[e.personB] = (relStrength[e.personB] ?: 0f) + eff + } + + data class Scored(val person: com.lemon.lifelog.data.Person, val score: Float, val hits: List) + + val scored = persons.mapNotNull { p -> + val searchText = buildString { + p.name.let { append(it); append(' ') } + p.realName?.let { append(it); append(' ') } + p.resume?.let { append(it); append(' ') } + p.impression?.let { append(it); append(' ') } + p.profileJson?.let { append(it); append(' ') } + }.lowercase() + + val hits = keywords.filter { kw -> searchText.contains(kw.lowercase()) } + if (hits.isEmpty()) return@mapNotNull null + + var score = hits.size.toFloat() + // 关系强度权重(熟悉的人加权) + score += (relStrength[p.id] ?: 0f) * 0.3f + // 最近联系过的人加权(越近越容易联系到) + val lastTs = maxOf(wxContact[p.id] ?: 0L, mentionTs[p.id] ?: 0L, p.lastAccess) + if (lastTs > 0) { + val daysSince = ((nowMs - lastTs) / 86_400_000L).coerceAtLeast(1) + score += (30f / daysSince).coerceAtMost(2f) + } + Scored(p, score, hits) + }.sortedByDescending { it.score }.take(limit) + + if (scored.isEmpty()) return "没有找到与「$goal」明显相关的联系人。\n提示:CRM 中的简历/印象字段越丰富,匹配越准确。" + return buildString { + appendLine("与「$goal」最相关的联系人(${scored.size} 人):") + scored.forEach { s -> + val p = s.person + val resume = p.resume?.take(80)?.let { " $it" } ?: "" + val impression = p.impression?.take(60)?.let { " $it" } ?: "" + appendLine("- ${p.name}(命中:${s.hits.joinToString("/")})$resume$impression") + } + } + } + // ── 人物时间线 ─────────────────────────────────────────────────────────── private suspend fun getPersonTimeline(repo: Repository, args: JSONObject): String { diff --git a/lifelog-recorder/app/src/main/kotlin/com/lemon/lifelog/crm/RelationBuilder.kt b/lifelog-recorder/app/src/main/kotlin/com/lemon/lifelog/crm/RelationBuilder.kt index df3472b..f0232fc 100644 --- a/lifelog-recorder/app/src/main/kotlin/com/lemon/lifelog/crm/RelationBuilder.kt +++ b/lifelog-recorder/app/src/main/kotlin/com/lemon/lifelog/crm/RelationBuilder.kt @@ -61,7 +61,9 @@ object RelationBuilder { val rows = acc.entries.map { (k, v) -> val (a, b) = k.split(":") - Relation(personA = a.toLong(), personB = b.toLong(), weight = v.weight, lastTs = v.lastTs) + // strength = 共现次数初始化(上限 5.0),查询时由工具层再乘时间衰减。 + val s = minOf(v.weight * 0.5f, 5.0f) + Relation(personA = a.toLong(), personB = b.toLong(), weight = v.weight, lastTs = v.lastTs, strength = s) } rows.chunked(500).forEach { repo.relations.insertAll(it) } Log.i(TAG, "rebuild: ${segs.size} segs → ${rows.size} relations") 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 df3d0af..1b0c443 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 @@ -27,8 +27,10 @@ import androidx.sqlite.db.SupportSQLiteDatabase Followup::class, Topic::class, TopicMention::class, + Organization::class, + PersonOrg::class, ], - version = 21, + version = 23, exportSchema = false, ) abstract class AppDatabase : RoomDatabase() { @@ -46,6 +48,8 @@ abstract class AppDatabase : RoomDatabase() { abstract fun relationDao(): RelationDao abstract fun followupDao(): FollowupDao abstract fun topicDao(): TopicDao + abstract fun organizationDao(): OrganizationDao + abstract fun personOrgDao(): PersonOrgDao companion object { @Volatile @@ -230,6 +234,24 @@ abstract class AppDatabase : RoomDatabase() { } } + /** P1 #3:relations 加关系类型 + 强度衰减字段。 */ + private val MIGRATION_21_22 = object : Migration(21, 22) { + override fun migrate(db: SupportSQLiteDatabase) { + db.execSQL("ALTER TABLE `relations` ADD COLUMN `type` TEXT") + db.execSQL("ALTER TABLE `relations` ADD COLUMN `strength` REAL NOT NULL DEFAULT 1.0") + } + } + + /** P1 #4:Organization + PersonOrg 两张表(组织一等公民)。 */ + private val MIGRATION_22_23 = object : Migration(22, 23) { + override fun migrate(db: SupportSQLiteDatabase) { + db.execSQL("CREATE TABLE IF NOT EXISTS `organizations` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `name` TEXT NOT NULL, `kind` TEXT NOT NULL DEFAULT 'company', `url` TEXT, `notes` TEXT, `createdAt` INTEGER NOT NULL)") + db.execSQL("CREATE TABLE IF NOT EXISTS `person_orgs` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `personId` INTEGER NOT NULL, `orgId` INTEGER NOT NULL, `role` TEXT, `startDate` TEXT, `endDate` TEXT)") + db.execSQL("CREATE INDEX IF NOT EXISTS `index_person_orgs_personId` ON `person_orgs` (`personId`)") + db.execSQL("CREATE INDEX IF NOT EXISTS `index_person_orgs_orgId` ON `person_orgs` (`orgId`)") + } + } + fun get(ctx: Context): AppDatabase = instance ?: synchronized(this) { instance ?: Room.databaseBuilder( @@ -237,7 +259,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, MIGRATION_12_13, MIGRATION_13_14, MIGRATION_14_15, MIGRATION_15_16, MIGRATION_16_17, MIGRATION_17_18, MIGRATION_18_19, MIGRATION_19_20, MIGRATION_20_21) + .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, MIGRATION_13_14, MIGRATION_14_15, MIGRATION_15_16, MIGRATION_16_17, MIGRATION_17_18, MIGRATION_18_19, MIGRATION_19_20, MIGRATION_20_21, MIGRATION_21_22, MIGRATION_22_23) // 不再 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 b0f9453..20a0879 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 @@ -1,6 +1,7 @@ package com.lemon.lifelog.data import androidx.room.Dao +import androidx.room.Delete import androidx.room.Insert import androidx.room.OnConflictStrategy import androidx.room.Query @@ -510,6 +511,92 @@ interface RelationDao { """, ) fun observeRelatedTo(pid: Long): Flow> + + @Query("UPDATE relations SET type = :type WHERE id = :id") + suspend fun updateType(id: Long, type: String?) + + @Query("UPDATE relations SET strength = :strength WHERE id = :id") + suspend fun updateStrength(id: Long, strength: Float) +} + +// ── 组织 ────────────────────────────────────────────────────────────────────── + +data class PersonOrgRow( + val id: Long, + val personId: Long, + val orgId: Long, + val role: String?, + val startDate: String?, + val endDate: String?, + val orgName: String, + val orgKind: String, +) + +data class OrgMemberRow( + val id: Long, + val personId: Long, + val orgId: Long, + val role: String?, + val startDate: String?, + val endDate: String?, + val personName: String, +) + +@Dao +interface OrganizationDao { + @Insert(onConflict = OnConflictStrategy.IGNORE) + suspend fun insert(o: Organization): Long + + @Insert(onConflict = OnConflictStrategy.IGNORE) + suspend fun insertAll(os: List) + + @Query("SELECT * FROM organizations ORDER BY name ASC") + fun observeAll(): Flow> + + @Query("SELECT * FROM organizations WHERE id = :id") + suspend fun get(id: Long): Organization? + + @Query("SELECT * FROM organizations WHERE name LIKE '%' || :q || '%' ORDER BY name ASC") + suspend fun search(q: String): List + + @Query("DELETE FROM organizations WHERE id = :id") + suspend fun delete(id: Long) +} + +@Dao +interface PersonOrgDao { + @Insert(onConflict = OnConflictStrategy.REPLACE) + suspend fun insert(po: PersonOrg): Long + + @Delete + suspend fun delete(po: PersonOrg) + + @Query("DELETE FROM person_orgs WHERE personId = :personId AND orgId = :orgId") + suspend fun unlink(personId: Long, orgId: Long) + + @Query(""" + SELECT po.id, po.personId, po.orgId, po.role, po.startDate, po.endDate, + o.name AS orgName, o.kind AS orgKind + FROM person_orgs po JOIN organizations o ON o.id = po.orgId + WHERE po.personId = :personId ORDER BY po.startDate DESC + """) + fun observeForPerson(personId: Long): Flow> + + @Query(""" + SELECT po.id, po.personId, po.orgId, po.role, po.startDate, po.endDate, + p.name AS personName + FROM person_orgs po JOIN persons p ON p.id = po.personId + WHERE po.orgId = :orgId ORDER BY p.name ASC + """) + fun observeMembersOfOrg(orgId: Long): Flow> + + @Query(""" + SELECT po.id, po.personId, po.orgId, po.role, po.startDate, po.endDate, + p.name AS personName + FROM person_orgs po JOIN persons p ON p.id = po.personId + WHERE po.orgId = :orgId ORDER BY p.name ASC + """) + suspend fun membersOfOrg(orgId: Long): List } @Dao 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 e6e8c46..d05ec90 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 @@ -250,6 +250,10 @@ data class Relation( val personB: Long, val weight: Int, val lastTs: Long, + /** 关系类型:朋友 / 同事 / 投资人 / 客户 / 家人,AI 或用户手动设置 */ + val type: String? = null, + /** 由共现次数初始化的关系强度,查询时乘以时间衰减因子 0.9^月数 */ + val strength: Float = 1.0f, ) /** @@ -276,3 +280,29 @@ data class TopicMention( val segmentId: Long, val sourceTs: Long, ) + +/** 组织/公司/学校实体(CRM 一等公民,与 Person 通过 person_orgs 关联)。 */ +@Entity(tableName = "organizations") +data class Organization( + @PrimaryKey(autoGenerate = true) val id: Long = 0, + val name: String, + /** "company" | "school" | "org" | "project" */ + val kind: String = "company", + val url: String? = null, + val notes: String? = null, + val createdAt: Long = System.currentTimeMillis(), +) + +/** 人物 ↔ 组织关系(多对多,含角色和在职时间段)。 */ +@Entity( + tableName = "person_orgs", + indices = [Index("personId"), Index("orgId")], +) +data class PersonOrg( + @PrimaryKey(autoGenerate = true) val id: Long = 0, + val personId: Long, + val orgId: Long, + val role: String? = null, + val startDate: String? = null, // "2020-01" + val endDate: String? = null, // null = 至今 +) diff --git a/lifelog-recorder/app/src/main/kotlin/com/lemon/lifelog/data/Repository.kt b/lifelog-recorder/app/src/main/kotlin/com/lemon/lifelog/data/Repository.kt index c710795..bc60b8d 100644 --- a/lifelog-recorder/app/src/main/kotlin/com/lemon/lifelog/data/Repository.kt +++ b/lifelog-recorder/app/src/main/kotlin/com/lemon/lifelog/data/Repository.kt @@ -18,4 +18,6 @@ class Repository(ctx: Context) { val relations = db.relationDao() val followups = db.followupDao() val topics = db.topicDao() + val organizations = db.organizationDao() + val personOrgs = db.personOrgDao() } -- libgit2 0.21.4