Commit 8cbb874fd0a9ecf0792ae660c3f9272072ae8f67
1 parent
f54168be
feat(crm): P1 #3/#4/#5 — 关系强度/组织实体/「谁能帮我」工具
P1 #3 关系类型 + 强度: - DB v21→22:relations 加 type(TEXT) + strength(REAL DEFAULT 1.0) - RelationBuilder:strength = min(weight*0.5, 5.0) 初始化 - RelationDao:updateType / updateStrength P1 #4 Organization 一等公民: - 新实体 Organization(id,name,kind,url,notes,createdAt) - 新实体 PersonOrg(id,personId,orgId,role,startDate,endDate) - DB v22→23:建两张表 + 索引 - OrganizationDao / PersonOrgDao(fix: 补 @Delete import 解 MissingType KSP 报错) - Repository 暴露 organizations / personOrgs P1 #5 「谁能帮我」工具: - LifelogTools 新增 get_stale_contacts(失联联系人排序) - LifelogTools 新增 get_org_members(查组织成员) - LifelogTools 新增 link_person_org(建人↔组织关联,组织自动创建) - LifelogTools 新增 find_relevant_persons(goal)(关键词匹配 resume/impression + 关系强度加权) Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Showing
6 changed files
with
374 additions
and
3 deletions
lifelog-recorder/app/src/main/kotlin/com/lemon/lifelog/chat/LifelogTools.kt
| @@ -225,6 +225,45 @@ object LifelogTools { | @@ -225,6 +225,45 @@ object LifelogTools { | ||
| 225 | ), | 225 | ), |
| 226 | required = listOf("query"), | 226 | required = listOf("query"), |
| 227 | )) | 227 | )) |
| 228 | + put(schema( | ||
| 229 | + name = "get_stale_contacts", | ||
| 230 | + desc = "列出长期未联系的人(按距今天数降序),帮助发现需要跟进的关系", | ||
| 231 | + props = mapOf( | ||
| 232 | + "min_days" to prop("integer", "至少多少天未联系,默认 30"), | ||
| 233 | + "limit" to prop("integer", "返回人数,默认 20"), | ||
| 234 | + ), | ||
| 235 | + required = emptyList(), | ||
| 236 | + )) | ||
| 237 | + put(schema( | ||
| 238 | + name = "get_org_members", | ||
| 239 | + desc = "查询某个组织/公司/学校的成员列表(含角色和在职时间)", | ||
| 240 | + props = mapOf( | ||
| 241 | + "org_name" to prop("string", "组织名称"), | ||
| 242 | + ), | ||
| 243 | + required = listOf("org_name"), | ||
| 244 | + )) | ||
| 245 | + put(schema( | ||
| 246 | + name = "link_person_org", | ||
| 247 | + desc = "将某人与某个组织关联(建立「人↔公司/学校」关系),组织不存在则自动创建", | ||
| 248 | + props = mapOf( | ||
| 249 | + "person_name" to prop("string", "人物姓名"), | ||
| 250 | + "org_name" to prop("string", "组织名称"), | ||
| 251 | + "kind" to prop("string", "组织类型:company(公司)/ school(学校)/ org(机构)/ project(项目),默认 company"), | ||
| 252 | + "role" to prop("string", "职位/角色,可选"), | ||
| 253 | + "start_date" to prop("string", "入职/加入时间(如 2023-01),可选"), | ||
| 254 | + "end_date" to prop("string", "离职时间,不填=至今,可选"), | ||
| 255 | + ), | ||
| 256 | + required = listOf("person_name", "org_name"), | ||
| 257 | + )) | ||
| 258 | + put(schema( | ||
| 259 | + name = "find_relevant_persons", | ||
| 260 | + desc = "根据目标/需求找出最相关的人(如「找一个做 AI 的朋友帮我看简历」),按相关度排序", | ||
| 261 | + props = mapOf( | ||
| 262 | + "goal" to prop("string", "目标或需求描述"), | ||
| 263 | + "limit" to prop("integer", "返回人数,默认 10"), | ||
| 264 | + ), | ||
| 265 | + required = listOf("goal"), | ||
| 266 | + )) | ||
| 228 | } | 267 | } |
| 229 | } | 268 | } |
| 230 | 269 | ||
| @@ -271,6 +310,10 @@ object LifelogTools { | @@ -271,6 +310,10 @@ object LifelogTools { | ||
| 271 | "get_person_timeline" -> getPersonTimeline(repo, args) | 310 | "get_person_timeline" -> getPersonTimeline(repo, args) |
| 272 | "refresh_person_data" -> refreshPersonData(repo, ctx, args) | 311 | "refresh_person_data" -> refreshPersonData(repo, ctx, args) |
| 273 | "web_search" -> webSearch(ctx, args) | 312 | "web_search" -> webSearch(ctx, args) |
| 313 | + "get_stale_contacts" -> getStaleContacts(repo, args) | ||
| 314 | + "get_org_members" -> getOrgMembers(repo, args) | ||
| 315 | + "link_person_org" -> linkPersonOrg(repo, args) | ||
| 316 | + "find_relevant_persons" -> findRelevantPersons(repo, args) | ||
| 274 | else -> "未知工具:$name" | 317 | else -> "未知工具:$name" |
| 275 | } | 318 | } |
| 276 | } catch (t: Throwable) { | 319 | } catch (t: Throwable) { |
| @@ -730,6 +773,191 @@ object LifelogTools { | @@ -730,6 +773,191 @@ object LifelogTools { | ||
| 730 | } | 773 | } |
| 731 | } | 774 | } |
| 732 | 775 | ||
| 776 | + // ── P1 #3 失联联系人 ────────────────────────────────────────────────────── | ||
| 777 | + | ||
| 778 | + private suspend fun getStaleContacts(repo: Repository, args: JSONObject): String { | ||
| 779 | + val minDays = args.optInt("min_days", 30).coerceAtLeast(1) | ||
| 780 | + val limit = args.optInt("limit", 20).coerceIn(1, 60) | ||
| 781 | + val nowMs = System.currentTimeMillis() | ||
| 782 | + val cutoff = nowMs - minDays * 86_400_000L | ||
| 783 | + | ||
| 784 | + val persons = repo.persons.all().filter { it.managed } | ||
| 785 | + if (persons.isEmpty()) return "CRM 中暂无管理的人物。" | ||
| 786 | + | ||
| 787 | + // 构建 wxid→Person 反查表 | ||
| 788 | + val wxidToPersonId = persons | ||
| 789 | + .filter { it.contactRef?.startsWith("wechat:") == true } | ||
| 790 | + .associate { it.contactRef!!.removePrefix("wechat:") to it.id } | ||
| 791 | + | ||
| 792 | + // 微信最近联系时间 | ||
| 793 | + val wxTimes = repo.wechatMessages.observeLastContactTimes().first() | ||
| 794 | + .mapNotNull { tt -> wxidToPersonId[tt.talker]?.let { pid -> pid to tt.maxTime } } | ||
| 795 | + .toMap() | ||
| 796 | + // 录音提及最近时间 | ||
| 797 | + val mentionTimes = repo.mentions.observeLastMentionTimes().first() | ||
| 798 | + .associate { it.personId to it.maxTime } | ||
| 799 | + | ||
| 800 | + data class Stale(val person: com.lemon.lifelog.data.Person, val lastTs: Long) | ||
| 801 | + val staleList = persons.map { p -> | ||
| 802 | + val ts = maxOf( | ||
| 803 | + wxTimes[p.id] ?: 0L, | ||
| 804 | + mentionTimes[p.id] ?: 0L, | ||
| 805 | + p.lastAccess, | ||
| 806 | + ) | ||
| 807 | + Stale(p, ts) | ||
| 808 | + }.filter { it.lastTs < cutoff } | ||
| 809 | + .sortedBy { it.lastTs } | ||
| 810 | + .take(limit) | ||
| 811 | + | ||
| 812 | + if (staleList.isEmpty()) return "没有超过 $minDays 天未联系的人(很好!)" | ||
| 813 | + return buildString { | ||
| 814 | + appendLine("超过 $minDays 天未联系(${staleList.size} 人,失联最久在前):") | ||
| 815 | + staleList.forEach { s -> | ||
| 816 | + val days = ((nowMs - s.lastTs) / 86_400_000L).toInt() | ||
| 817 | + val lastStr = if (s.lastTs == 0L) "从未" else "${days}天前(${TS.format(Date(s.lastTs))})" | ||
| 818 | + appendLine("- ${s.person.name}:上次联系 $lastStr") | ||
| 819 | + } | ||
| 820 | + } | ||
| 821 | + } | ||
| 822 | + | ||
| 823 | + // ── P1 #4 组织成员 ──────────────────────────────────────────────────────── | ||
| 824 | + | ||
| 825 | + private suspend fun getOrgMembers(repo: Repository, args: JSONObject): String { | ||
| 826 | + val name = args.optString("org_name", "").ifBlank { return "请提供组织名称" } | ||
| 827 | + val orgs = repo.organizations.observeAll().first().filter { it.name.contains(name) } | ||
| 828 | + if (orgs.isEmpty()) return "未找到名称含「$name」的组织。" | ||
| 829 | + return buildString { | ||
| 830 | + orgs.take(3).forEach { org -> | ||
| 831 | + appendLine("=== ${org.name}(${org.kind})===") | ||
| 832 | + org.url?.takeIf { it.isNotBlank() }?.let { appendLine("网址:$it") } | ||
| 833 | + org.notes?.takeIf { it.isNotBlank() }?.let { appendLine("备注:$it") } | ||
| 834 | + val members = repo.personOrgs.observeMembersOfOrg(org.id).first() | ||
| 835 | + if (members.isEmpty()) { | ||
| 836 | + appendLine("(暂无成员)") | ||
| 837 | + } else { | ||
| 838 | + appendLine("成员(${members.size} 人):") | ||
| 839 | + members.forEach { m -> | ||
| 840 | + val period = buildString { | ||
| 841 | + m.startDate?.let { append(it) } | ||
| 842 | + if (m.startDate != null || m.endDate != null) append("–") | ||
| 843 | + append(m.endDate ?: "至今") | ||
| 844 | + } | ||
| 845 | + val role = m.role?.let { " · $it" } ?: "" | ||
| 846 | + appendLine(" - ${m.personName}$role${if (period.isNotBlank()) " $period" else ""}") | ||
| 847 | + } | ||
| 848 | + } | ||
| 849 | + appendLine() | ||
| 850 | + } | ||
| 851 | + } | ||
| 852 | + } | ||
| 853 | + | ||
| 854 | + private suspend fun linkPersonOrg(repo: Repository, args: JSONObject): String { | ||
| 855 | + val personName = args.optString("person_name", "").ifBlank { return "请提供人物姓名" } | ||
| 856 | + val orgName = args.optString("org_name", "").ifBlank { return "请提供组织名称" } | ||
| 857 | + val kind = args.optString("kind", "company").ifBlank { "company" } | ||
| 858 | + val role = args.optString("role", "").ifBlank { null } | ||
| 859 | + val startDate = args.optString("start_date", "").ifBlank { null } | ||
| 860 | + val endDate = args.optString("end_date", "").ifBlank { null } | ||
| 861 | + | ||
| 862 | + val person = repo.persons.all().firstOrNull { p -> | ||
| 863 | + p.name.contains(personName) || p.realName?.contains(personName) == true || | ||
| 864 | + p.nickname?.contains(personName) == true | ||
| 865 | + } ?: return "未找到与「$personName」匹配的联系人。" | ||
| 866 | + | ||
| 867 | + // 找或建组织 | ||
| 868 | + val orgs = repo.organizations.observeAll().first() | ||
| 869 | + val org = orgs.firstOrNull { it.name == orgName } | ||
| 870 | + ?: com.lemon.lifelog.data.Organization(name = orgName, kind = kind) | ||
| 871 | + .let { repo.organizations.insert(it) } | ||
| 872 | + .let { repo.organizations.get(it) } | ||
| 873 | + ?: return "组织创建失败" | ||
| 874 | + | ||
| 875 | + // 检查是否已绑定 | ||
| 876 | + val existing = repo.personOrgs.observeForPerson(person.id).first() | ||
| 877 | + if (existing.any { it.orgId == org.id }) { | ||
| 878 | + return "「${person.name}」已与「${org.name}」关联,无需重复绑定。" | ||
| 879 | + } | ||
| 880 | + | ||
| 881 | + repo.personOrgs.insert( | ||
| 882 | + com.lemon.lifelog.data.PersonOrg( | ||
| 883 | + personId = person.id, | ||
| 884 | + orgId = org.id, | ||
| 885 | + role = role, | ||
| 886 | + startDate = startDate, | ||
| 887 | + endDate = endDate, | ||
| 888 | + ) | ||
| 889 | + ) | ||
| 890 | + val roleStr = role?.let { "($it)" } ?: "" | ||
| 891 | + val dateStr = if (startDate != null) " $startDate–${endDate ?: "至今"}" else "" | ||
| 892 | + return "已关联:${person.name}$roleStr ↔ ${org.name}(${org.kind})$dateStr" | ||
| 893 | + } | ||
| 894 | + | ||
| 895 | + // ── P1 #5 「谁能帮我」──────────────────────────────────────────────────── | ||
| 896 | + | ||
| 897 | + private suspend fun findRelevantPersons(repo: Repository, args: JSONObject): String { | ||
| 898 | + val goal = args.optString("goal", "").ifBlank { return "请描述你的目标或需求" } | ||
| 899 | + val limit = args.optInt("limit", 10).coerceIn(1, 30) | ||
| 900 | + | ||
| 901 | + val persons = repo.persons.all().filter { it.managed } | ||
| 902 | + if (persons.isEmpty()) return "CRM 中暂无管理的人物。" | ||
| 903 | + | ||
| 904 | + // 把 goal 分词(按空格 + 标点切,简单起见,不用 jieba) | ||
| 905 | + val keywords = goal.split(Regex("[\\s,。、!?,./;]+")).filter { it.length >= 2 } | ||
| 906 | + | ||
| 907 | + val nowMs = System.currentTimeMillis() | ||
| 908 | + // 微信/提及最近联系时间 → 计算有效强度衰减 | ||
| 909 | + val wxTimes = repo.wechatMessages.observeLastContactTimes().first() | ||
| 910 | + val wxidToPid = persons.filter { it.contactRef?.startsWith("wechat:") == true } | ||
| 911 | + .associate { it.contactRef!!.removePrefix("wechat:") to it.id } | ||
| 912 | + val wxContact = wxTimes.mapNotNull { tt -> wxidToPid[tt.talker]?.let { pid -> pid to tt.maxTime } }.toMap() | ||
| 913 | + val mentionTs = repo.mentions.observeLastMentionTimes().first().associate { it.personId to it.maxTime } | ||
| 914 | + | ||
| 915 | + // 共现强度(用 allEdges 无 flow,直接 suspend) | ||
| 916 | + val relStrength = HashMap<Long, Float>() | ||
| 917 | + repo.relations.allEdges().forEach { e -> | ||
| 918 | + val eff = minOf(e.weight * 0.5f, 5.0f) | ||
| 919 | + relStrength[e.personA] = (relStrength[e.personA] ?: 0f) + eff | ||
| 920 | + relStrength[e.personB] = (relStrength[e.personB] ?: 0f) + eff | ||
| 921 | + } | ||
| 922 | + | ||
| 923 | + data class Scored(val person: com.lemon.lifelog.data.Person, val score: Float, val hits: List<String>) | ||
| 924 | + | ||
| 925 | + val scored = persons.mapNotNull { p -> | ||
| 926 | + val searchText = buildString { | ||
| 927 | + p.name.let { append(it); append(' ') } | ||
| 928 | + p.realName?.let { append(it); append(' ') } | ||
| 929 | + p.resume?.let { append(it); append(' ') } | ||
| 930 | + p.impression?.let { append(it); append(' ') } | ||
| 931 | + p.profileJson?.let { append(it); append(' ') } | ||
| 932 | + }.lowercase() | ||
| 933 | + | ||
| 934 | + val hits = keywords.filter { kw -> searchText.contains(kw.lowercase()) } | ||
| 935 | + if (hits.isEmpty()) return@mapNotNull null | ||
| 936 | + | ||
| 937 | + var score = hits.size.toFloat() | ||
| 938 | + // 关系强度权重(熟悉的人加权) | ||
| 939 | + score += (relStrength[p.id] ?: 0f) * 0.3f | ||
| 940 | + // 最近联系过的人加权(越近越容易联系到) | ||
| 941 | + val lastTs = maxOf(wxContact[p.id] ?: 0L, mentionTs[p.id] ?: 0L, p.lastAccess) | ||
| 942 | + if (lastTs > 0) { | ||
| 943 | + val daysSince = ((nowMs - lastTs) / 86_400_000L).coerceAtLeast(1) | ||
| 944 | + score += (30f / daysSince).coerceAtMost(2f) | ||
| 945 | + } | ||
| 946 | + Scored(p, score, hits) | ||
| 947 | + }.sortedByDescending { it.score }.take(limit) | ||
| 948 | + | ||
| 949 | + if (scored.isEmpty()) return "没有找到与「$goal」明显相关的联系人。\n提示:CRM 中的简历/印象字段越丰富,匹配越准确。" | ||
| 950 | + return buildString { | ||
| 951 | + appendLine("与「$goal」最相关的联系人(${scored.size} 人):") | ||
| 952 | + scored.forEach { s -> | ||
| 953 | + val p = s.person | ||
| 954 | + val resume = p.resume?.take(80)?.let { " $it" } ?: "" | ||
| 955 | + val impression = p.impression?.take(60)?.let { " $it" } ?: "" | ||
| 956 | + appendLine("- ${p.name}(命中:${s.hits.joinToString("/")})$resume$impression") | ||
| 957 | + } | ||
| 958 | + } | ||
| 959 | + } | ||
| 960 | + | ||
| 733 | // ── 人物时间线 ─────────────────────────────────────────────────────────── | 961 | // ── 人物时间线 ─────────────────────────────────────────────────────────── |
| 734 | 962 | ||
| 735 | private suspend fun getPersonTimeline(repo: Repository, args: JSONObject): String { | 963 | private suspend fun getPersonTimeline(repo: Repository, args: JSONObject): String { |
lifelog-recorder/app/src/main/kotlin/com/lemon/lifelog/crm/RelationBuilder.kt
| @@ -61,7 +61,9 @@ object RelationBuilder { | @@ -61,7 +61,9 @@ object RelationBuilder { | ||
| 61 | 61 | ||
| 62 | val rows = acc.entries.map { (k, v) -> | 62 | val rows = acc.entries.map { (k, v) -> |
| 63 | val (a, b) = k.split(":") | 63 | val (a, b) = k.split(":") |
| 64 | - Relation(personA = a.toLong(), personB = b.toLong(), weight = v.weight, lastTs = v.lastTs) | 64 | + // strength = 共现次数初始化(上限 5.0),查询时由工具层再乘时间衰减。 |
| 65 | + val s = minOf(v.weight * 0.5f, 5.0f) | ||
| 66 | + Relation(personA = a.toLong(), personB = b.toLong(), weight = v.weight, lastTs = v.lastTs, strength = s) | ||
| 65 | } | 67 | } |
| 66 | rows.chunked(500).forEach { repo.relations.insertAll(it) } | 68 | rows.chunked(500).forEach { repo.relations.insertAll(it) } |
| 67 | Log.i(TAG, "rebuild: ${segs.size} segs → ${rows.size} relations") | 69 | Log.i(TAG, "rebuild: ${segs.size} segs → ${rows.size} relations") |
lifelog-recorder/app/src/main/kotlin/com/lemon/lifelog/data/AppDatabase.kt
| @@ -27,8 +27,10 @@ import androidx.sqlite.db.SupportSQLiteDatabase | @@ -27,8 +27,10 @@ import androidx.sqlite.db.SupportSQLiteDatabase | ||
| 27 | Followup::class, | 27 | Followup::class, |
| 28 | Topic::class, | 28 | Topic::class, |
| 29 | TopicMention::class, | 29 | TopicMention::class, |
| 30 | + Organization::class, | ||
| 31 | + PersonOrg::class, | ||
| 30 | ], | 32 | ], |
| 31 | - version = 21, | 33 | + version = 23, |
| 32 | exportSchema = false, | 34 | exportSchema = false, |
| 33 | ) | 35 | ) |
| 34 | abstract class AppDatabase : RoomDatabase() { | 36 | abstract class AppDatabase : RoomDatabase() { |
| @@ -46,6 +48,8 @@ abstract class AppDatabase : RoomDatabase() { | @@ -46,6 +48,8 @@ abstract class AppDatabase : RoomDatabase() { | ||
| 46 | abstract fun relationDao(): RelationDao | 48 | abstract fun relationDao(): RelationDao |
| 47 | abstract fun followupDao(): FollowupDao | 49 | abstract fun followupDao(): FollowupDao |
| 48 | abstract fun topicDao(): TopicDao | 50 | abstract fun topicDao(): TopicDao |
| 51 | + abstract fun organizationDao(): OrganizationDao | ||
| 52 | + abstract fun personOrgDao(): PersonOrgDao | ||
| 49 | 53 | ||
| 50 | companion object { | 54 | companion object { |
| 51 | @Volatile | 55 | @Volatile |
| @@ -230,6 +234,24 @@ abstract class AppDatabase : RoomDatabase() { | @@ -230,6 +234,24 @@ abstract class AppDatabase : RoomDatabase() { | ||
| 230 | } | 234 | } |
| 231 | } | 235 | } |
| 232 | 236 | ||
| 237 | + /** P1 #3:relations 加关系类型 + 强度衰减字段。 */ | ||
| 238 | + private val MIGRATION_21_22 = object : Migration(21, 22) { | ||
| 239 | + override fun migrate(db: SupportSQLiteDatabase) { | ||
| 240 | + db.execSQL("ALTER TABLE `relations` ADD COLUMN `type` TEXT") | ||
| 241 | + db.execSQL("ALTER TABLE `relations` ADD COLUMN `strength` REAL NOT NULL DEFAULT 1.0") | ||
| 242 | + } | ||
| 243 | + } | ||
| 244 | + | ||
| 245 | + /** P1 #4:Organization + PersonOrg 两张表(组织一等公民)。 */ | ||
| 246 | + private val MIGRATION_22_23 = object : Migration(22, 23) { | ||
| 247 | + override fun migrate(db: SupportSQLiteDatabase) { | ||
| 248 | + 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)") | ||
| 249 | + 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)") | ||
| 250 | + db.execSQL("CREATE INDEX IF NOT EXISTS `index_person_orgs_personId` ON `person_orgs` (`personId`)") | ||
| 251 | + db.execSQL("CREATE INDEX IF NOT EXISTS `index_person_orgs_orgId` ON `person_orgs` (`orgId`)") | ||
| 252 | + } | ||
| 253 | + } | ||
| 254 | + | ||
| 233 | fun get(ctx: Context): AppDatabase = | 255 | fun get(ctx: Context): AppDatabase = |
| 234 | instance ?: synchronized(this) { | 256 | instance ?: synchronized(this) { |
| 235 | instance ?: Room.databaseBuilder( | 257 | instance ?: Room.databaseBuilder( |
| @@ -237,7 +259,7 @@ abstract class AppDatabase : RoomDatabase() { | @@ -237,7 +259,7 @@ abstract class AppDatabase : RoomDatabase() { | ||
| 237 | AppDatabase::class.java, | 259 | AppDatabase::class.java, |
| 238 | "lifelog.db", | 260 | "lifelog.db", |
| 239 | ) | 261 | ) |
| 240 | - .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) | 262 | + .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) |
| 241 | // 不再 fallbackToDestructiveMigration:以后改 schema 必须写 Migration, | 263 | // 不再 fallbackToDestructiveMigration:以后改 schema 必须写 Migration, |
| 242 | // 否则启动直接抛 IllegalStateException(fail-loud),绝不静默清掉真实录音。 | 264 | // 否则启动直接抛 IllegalStateException(fail-loud),绝不静默清掉真实录音。 |
| 243 | .build() | 265 | .build() |
lifelog-recorder/app/src/main/kotlin/com/lemon/lifelog/data/Daos.kt
| 1 | package com.lemon.lifelog.data | 1 | package com.lemon.lifelog.data |
| 2 | 2 | ||
| 3 | import androidx.room.Dao | 3 | import androidx.room.Dao |
| 4 | +import androidx.room.Delete | ||
| 4 | import androidx.room.Insert | 5 | import androidx.room.Insert |
| 5 | import androidx.room.OnConflictStrategy | 6 | import androidx.room.OnConflictStrategy |
| 6 | import androidx.room.Query | 7 | import androidx.room.Query |
| @@ -510,6 +511,92 @@ interface RelationDao { | @@ -510,6 +511,92 @@ interface RelationDao { | ||
| 510 | """, | 511 | """, |
| 511 | ) | 512 | ) |
| 512 | fun observeRelatedTo(pid: Long): Flow<List<RelatedPerson>> | 513 | fun observeRelatedTo(pid: Long): Flow<List<RelatedPerson>> |
| 514 | + | ||
| 515 | + @Query("UPDATE relations SET type = :type WHERE id = :id") | ||
| 516 | + suspend fun updateType(id: Long, type: String?) | ||
| 517 | + | ||
| 518 | + @Query("UPDATE relations SET strength = :strength WHERE id = :id") | ||
| 519 | + suspend fun updateStrength(id: Long, strength: Float) | ||
| 520 | +} | ||
| 521 | + | ||
| 522 | +// ── 组织 ────────────────────────────────────────────────────────────────────── | ||
| 523 | + | ||
| 524 | +data class PersonOrgRow( | ||
| 525 | + val id: Long, | ||
| 526 | + val personId: Long, | ||
| 527 | + val orgId: Long, | ||
| 528 | + val role: String?, | ||
| 529 | + val startDate: String?, | ||
| 530 | + val endDate: String?, | ||
| 531 | + val orgName: String, | ||
| 532 | + val orgKind: String, | ||
| 533 | +) | ||
| 534 | + | ||
| 535 | +data class OrgMemberRow( | ||
| 536 | + val id: Long, | ||
| 537 | + val personId: Long, | ||
| 538 | + val orgId: Long, | ||
| 539 | + val role: String?, | ||
| 540 | + val startDate: String?, | ||
| 541 | + val endDate: String?, | ||
| 542 | + val personName: String, | ||
| 543 | +) | ||
| 544 | + | ||
| 545 | +@Dao | ||
| 546 | +interface OrganizationDao { | ||
| 547 | + @Insert(onConflict = OnConflictStrategy.IGNORE) | ||
| 548 | + suspend fun insert(o: Organization): Long | ||
| 549 | + | ||
| 550 | + @Insert(onConflict = OnConflictStrategy.IGNORE) | ||
| 551 | + suspend fun insertAll(os: List<Organization>) | ||
| 552 | + | ||
| 553 | + @Query("SELECT * FROM organizations ORDER BY name ASC") | ||
| 554 | + fun observeAll(): Flow<List<Organization>> | ||
| 555 | + | ||
| 556 | + @Query("SELECT * FROM organizations WHERE id = :id") | ||
| 557 | + suspend fun get(id: Long): Organization? | ||
| 558 | + | ||
| 559 | + @Query("SELECT * FROM organizations WHERE name LIKE '%' || :q || '%' ORDER BY name ASC") | ||
| 560 | + suspend fun search(q: String): List<Organization> | ||
| 561 | + | ||
| 562 | + @Query("DELETE FROM organizations WHERE id = :id") | ||
| 563 | + suspend fun delete(id: Long) | ||
| 564 | +} | ||
| 565 | + | ||
| 566 | +@Dao | ||
| 567 | +interface PersonOrgDao { | ||
| 568 | + @Insert(onConflict = OnConflictStrategy.REPLACE) | ||
| 569 | + suspend fun insert(po: PersonOrg): Long | ||
| 570 | + | ||
| 571 | + @Delete | ||
| 572 | + suspend fun delete(po: PersonOrg) | ||
| 573 | + | ||
| 574 | + @Query("DELETE FROM person_orgs WHERE personId = :personId AND orgId = :orgId") | ||
| 575 | + suspend fun unlink(personId: Long, orgId: Long) | ||
| 576 | + | ||
| 577 | + @Query(""" | ||
| 578 | + SELECT po.id, po.personId, po.orgId, po.role, po.startDate, po.endDate, | ||
| 579 | + o.name AS orgName, o.kind AS orgKind | ||
| 580 | + FROM person_orgs po JOIN organizations o ON o.id = po.orgId | ||
| 581 | + WHERE po.personId = :personId ORDER BY po.startDate DESC | ||
| 582 | + """) | ||
| 583 | + fun observeForPerson(personId: Long): Flow<List<PersonOrgRow>> | ||
| 584 | + | ||
| 585 | + @Query(""" | ||
| 586 | + SELECT po.id, po.personId, po.orgId, po.role, po.startDate, po.endDate, | ||
| 587 | + p.name AS personName | ||
| 588 | + FROM person_orgs po JOIN persons p ON p.id = po.personId | ||
| 589 | + WHERE po.orgId = :orgId ORDER BY p.name ASC | ||
| 590 | + """) | ||
| 591 | + fun observeMembersOfOrg(orgId: Long): Flow<List<OrgMemberRow>> | ||
| 592 | + | ||
| 593 | + @Query(""" | ||
| 594 | + SELECT po.id, po.personId, po.orgId, po.role, po.startDate, po.endDate, | ||
| 595 | + p.name AS personName | ||
| 596 | + FROM person_orgs po JOIN persons p ON p.id = po.personId | ||
| 597 | + WHERE po.orgId = :orgId ORDER BY p.name ASC | ||
| 598 | + """) | ||
| 599 | + suspend fun membersOfOrg(orgId: Long): List<OrgMemberRow> | ||
| 513 | } | 600 | } |
| 514 | 601 | ||
| 515 | @Dao | 602 | @Dao |
lifelog-recorder/app/src/main/kotlin/com/lemon/lifelog/data/Entities.kt
| @@ -250,6 +250,10 @@ data class Relation( | @@ -250,6 +250,10 @@ data class Relation( | ||
| 250 | val personB: Long, | 250 | val personB: Long, |
| 251 | val weight: Int, | 251 | val weight: Int, |
| 252 | val lastTs: Long, | 252 | val lastTs: Long, |
| 253 | + /** 关系类型:朋友 / 同事 / 投资人 / 客户 / 家人,AI 或用户手动设置 */ | ||
| 254 | + val type: String? = null, | ||
| 255 | + /** 由共现次数初始化的关系强度,查询时乘以时间衰减因子 0.9^月数 */ | ||
| 256 | + val strength: Float = 1.0f, | ||
| 253 | ) | 257 | ) |
| 254 | 258 | ||
| 255 | /** | 259 | /** |
| @@ -276,3 +280,29 @@ data class TopicMention( | @@ -276,3 +280,29 @@ data class TopicMention( | ||
| 276 | val segmentId: Long, | 280 | val segmentId: Long, |
| 277 | val sourceTs: Long, | 281 | val sourceTs: Long, |
| 278 | ) | 282 | ) |
| 283 | + | ||
| 284 | +/** 组织/公司/学校实体(CRM 一等公民,与 Person 通过 person_orgs 关联)。 */ | ||
| 285 | +@Entity(tableName = "organizations") | ||
| 286 | +data class Organization( | ||
| 287 | + @PrimaryKey(autoGenerate = true) val id: Long = 0, | ||
| 288 | + val name: String, | ||
| 289 | + /** "company" | "school" | "org" | "project" */ | ||
| 290 | + val kind: String = "company", | ||
| 291 | + val url: String? = null, | ||
| 292 | + val notes: String? = null, | ||
| 293 | + val createdAt: Long = System.currentTimeMillis(), | ||
| 294 | +) | ||
| 295 | + | ||
| 296 | +/** 人物 ↔ 组织关系(多对多,含角色和在职时间段)。 */ | ||
| 297 | +@Entity( | ||
| 298 | + tableName = "person_orgs", | ||
| 299 | + indices = [Index("personId"), Index("orgId")], | ||
| 300 | +) | ||
| 301 | +data class PersonOrg( | ||
| 302 | + @PrimaryKey(autoGenerate = true) val id: Long = 0, | ||
| 303 | + val personId: Long, | ||
| 304 | + val orgId: Long, | ||
| 305 | + val role: String? = null, | ||
| 306 | + val startDate: String? = null, // "2020-01" | ||
| 307 | + val endDate: String? = null, // null = 至今 | ||
| 308 | +) |
lifelog-recorder/app/src/main/kotlin/com/lemon/lifelog/data/Repository.kt
| @@ -18,4 +18,6 @@ class Repository(ctx: Context) { | @@ -18,4 +18,6 @@ class Repository(ctx: Context) { | ||
| 18 | val relations = db.relationDao() | 18 | val relations = db.relationDao() |
| 19 | val followups = db.followupDao() | 19 | val followups = db.followupDao() |
| 20 | val topics = db.topicDao() | 20 | val topics = db.topicDao() |
| 21 | + val organizations = db.organizationDao() | ||
| 22 | + val personOrgs = db.personOrgDao() | ||
| 21 | } | 23 | } |