Commit 03cf4fc57647badad508b6c5edba82a384be8ad1

Authored by yibo
1 parent aac9f1d0

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>
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
... ... @@ -18,4 +18,6 @@ class Repository(ctx: Context) {
18 18 val relations = db.relationDao()
19 19 val followups = db.followupDao()
20 20 val topics = db.topicDao()
  21 + val organizations = db.organizationDao()
  22 + val personOrgs = db.personOrgDao()
21 23 }
... ...