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 | 225 | ), |
| 226 | 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 | 310 | "get_person_timeline" -> getPersonTimeline(repo, args) |
| 272 | 311 | "refresh_person_data" -> refreshPersonData(repo, ctx, args) |
| 273 | 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 | 317 | else -> "未知工具:$name" |
| 275 | 318 | } |
| 276 | 319 | } catch (t: Throwable) { |
| ... | ... | @@ -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 | 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 | 61 | |
| 62 | 62 | val rows = acc.entries.map { (k, v) -> |
| 63 | 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 | 68 | rows.chunked(500).forEach { repo.relations.insertAll(it) } |
| 67 | 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 | 27 | Followup::class, |
| 28 | 28 | Topic::class, |
| 29 | 29 | TopicMention::class, |
| 30 | + Organization::class, | |
| 31 | + PersonOrg::class, | |
| 30 | 32 | ], |
| 31 | - version = 21, | |
| 33 | + version = 23, | |
| 32 | 34 | exportSchema = false, |
| 33 | 35 | ) |
| 34 | 36 | abstract class AppDatabase : RoomDatabase() { |
| ... | ... | @@ -46,6 +48,8 @@ abstract class AppDatabase : RoomDatabase() { |
| 46 | 48 | abstract fun relationDao(): RelationDao |
| 47 | 49 | abstract fun followupDao(): FollowupDao |
| 48 | 50 | abstract fun topicDao(): TopicDao |
| 51 | + abstract fun organizationDao(): OrganizationDao | |
| 52 | + abstract fun personOrgDao(): PersonOrgDao | |
| 49 | 53 | |
| 50 | 54 | companion object { |
| 51 | 55 | @Volatile |
| ... | ... | @@ -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 | 255 | fun get(ctx: Context): AppDatabase = |
| 234 | 256 | instance ?: synchronized(this) { |
| 235 | 257 | instance ?: Room.databaseBuilder( |
| ... | ... | @@ -237,7 +259,7 @@ abstract class AppDatabase : RoomDatabase() { |
| 237 | 259 | AppDatabase::class.java, |
| 238 | 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 | 263 | // 不再 fallbackToDestructiveMigration:以后改 schema 必须写 Migration, |
| 242 | 264 | // 否则启动直接抛 IllegalStateException(fail-loud),绝不静默清掉真实录音。 |
| 243 | 265 | .build() | ... | ... |
lifelog-recorder/app/src/main/kotlin/com/lemon/lifelog/data/Daos.kt
| 1 | 1 | package com.lemon.lifelog.data |
| 2 | 2 | |
| 3 | 3 | import androidx.room.Dao |
| 4 | +import androidx.room.Delete | |
| 4 | 5 | import androidx.room.Insert |
| 5 | 6 | import androidx.room.OnConflictStrategy |
| 6 | 7 | import androidx.room.Query |
| ... | ... | @@ -510,6 +511,92 @@ interface RelationDao { |
| 510 | 511 | """, |
| 511 | 512 | ) |
| 512 | 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 | 602 | @Dao | ... | ... |
lifelog-recorder/app/src/main/kotlin/com/lemon/lifelog/data/Entities.kt
| ... | ... | @@ -250,6 +250,10 @@ data class Relation( |
| 250 | 250 | val personB: Long, |
| 251 | 251 | val weight: Int, |
| 252 | 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 | 280 | val segmentId: Long, |
| 277 | 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