Commit a1f1dbe1f44d593efaf0771f05d07ac4d422d1de
1 parent
cbe12020
feat(goal): P2 #6/#7/#8 — Goal OS 地基 + AI 工具
P2 #6 Goal 实体(DB v23→v24): - goals 表:id/text/status/deadline/parentGoalId/personId/notes/createdAt/updatedAt - status: active/paused/done/cancelled - parentGoalId 支持树形分解(创业→融资→找VC→约张总) - GoalDao:insert/get/all/observeAll/observeByStatus/roots/children/updateStatus/updateNotes/delete P2 #7 Followup→Goal 挂载: - followups 表加 goalId 列 - FollowupDao 加 updateGoal / observeByGoal / all(一次性查,供 LLM 文本匹配) - FollowupRow 加 goalId 字段 P2 #8 AI Goal 工具(4 个): - create_goal:建目标,支持截止日期/父目标/关联人物 - get_goals:树形展示目标+子目标+已挂待跟进,支持状态过滤 - link_followup_to_goal:按 id 或关键词匹配,批量关联 followup→goal - update_goal_status:标完成/暂停/重启/放弃 Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Showing
6 changed files
with
285 additions
and
7 deletions
lifelog-recorder/app/src/main/kotlin/com/lemon/lifelog/chat/ChatViewModel.kt
| @@ -312,15 +312,21 @@ class ChatViewModel(app: Application) : AndroidViewModel(app) { | @@ -312,15 +312,21 @@ class ChatViewModel(app: Application) : AndroidViewModel(app) { | ||
| 312 | 【关系网络 & 组织】 | 312 | 【关系网络 & 组织】 |
| 313 | ⑲ get_stale_contacts — 列出长期未联系的人(args: min_days默认30, limit默认20) | 313 | ⑲ get_stale_contacts — 列出长期未联系的人(args: min_days默认30, limit默认20) |
| 314 | ⑳ get_org_members — 查某组织的成员列表(args: org_name) | 314 | ⑳ get_org_members — 查某组织的成员列表(args: org_name) |
| 315 | - ㉑ link_person_org — 将人物关联到组织/公司(args: person_name, org_name, kind可选company|school|org|project, role可选, start_date可选如2023-01, end_date可选) | 315 | + ㉑ link_person_org — 将人物关联到组织/公司(args: person_name, org_name, kind可选, role可选, start_date可选) |
| 316 | ㉒ find_relevant_persons — 根据目标找最相关的人「谁能帮我」(args: goal, limit默认10) | 316 | ㉒ find_relevant_persons — 根据目标找最相关的人「谁能帮我」(args: goal, limit默认10) |
| 317 | 317 | ||
| 318 | + 【目标管理 Goal OS】 | ||
| 319 | + ㉓ create_goal — 创建目标(args: text, deadline可选如2026-09-01, parent_goal_id可选, person_name可选, notes可选) | ||
| 320 | + ㉔ get_goals — 查目标列表,含子目标树+关联待跟进(args: status=active|paused|done|cancelled|all,默认active) | ||
| 321 | + ㉕ link_followup_to_goal — 把待跟进事项挂到目标(args: goal_id, followup_id或followup_text) | ||
| 322 | + ㉖ update_goal_status — 更新目标状态(args: goal_id, status=active|paused|done|cancelled) | ||
| 323 | + | ||
| 318 | 【数据刷新】 | 324 | 【数据刷新】 |
| 319 | - ㉓ refresh_person_data — 刷新某人微信聊天+朋友圈(args: person_name) | 325 | + ㉗ refresh_person_data — 刷新某人微信聊天+朋友圈(args: person_name) |
| 320 | ★ 查询某人聊天/朋友圈/时间线前,必须先调此工具确保数据最新! | 326 | ★ 查询某人聊天/朋友圈/时间线前,必须先调此工具确保数据最新! |
| 321 | 327 | ||
| 322 | 【全网搜索】 | 328 | 【全网搜索】 |
| 323 | - ㉔ web_search — Tavily 搜索互联网(args: query, max_results默认5;需设置中配置 Key) | 329 | + ㉘ web_search — Tavily 搜索互联网(args: query, max_results默认5;需设置中配置 Key) |
| 324 | 330 | ||
| 325 | 需要查询时,输出(每个工具独占一行,严格 JSON): | 331 | 需要查询时,输出(每个工具独占一行,严格 JSON): |
| 326 | TOOL_CALL: {"name": "工具名", "args": {"参数": "值"}} | 332 | TOOL_CALL: {"name": "工具名", "args": {"参数": "值"}} |
lifelog-recorder/app/src/main/kotlin/com/lemon/lifelog/chat/LifelogTools.kt
| @@ -264,6 +264,45 @@ object LifelogTools { | @@ -264,6 +264,45 @@ object LifelogTools { | ||
| 264 | ), | 264 | ), |
| 265 | required = listOf("goal"), | 265 | required = listOf("goal"), |
| 266 | )) | 266 | )) |
| 267 | + put(schema( | ||
| 268 | + name = "create_goal", | ||
| 269 | + desc = "创建一个新目标(支持树形分解:可指定父目标 + 关联人物)", | ||
| 270 | + props = mapOf( | ||
| 271 | + "text" to prop("string", "目标内容,如「完成融资 A 轮」"), | ||
| 272 | + "deadline" to prop("string", "截止日期 ISO 格式如 2026-09-01,可选"), | ||
| 273 | + "parent_goal_id" to prop("integer", "父目标 id(子目标用),可选"), | ||
| 274 | + "person_name" to prop("string", "关联人物姓名,可选"), | ||
| 275 | + "notes" to prop("string", "备注,可选"), | ||
| 276 | + ), | ||
| 277 | + required = listOf("text"), | ||
| 278 | + )) | ||
| 279 | + put(schema( | ||
| 280 | + name = "get_goals", | ||
| 281 | + desc = "查看目标列表(支持按状态过滤,默认返回活跃目标;展示目标树结构 + 关联的待跟进事项)", | ||
| 282 | + props = mapOf( | ||
| 283 | + "status" to prop("string", "状态过滤:active(活跃)/ paused(暂停)/ done(完成)/ cancelled(放弃)/ all(全部),默认 active"), | ||
| 284 | + ), | ||
| 285 | + required = emptyList(), | ||
| 286 | + )) | ||
| 287 | + put(schema( | ||
| 288 | + name = "link_followup_to_goal", | ||
| 289 | + desc = "将一条待跟进事项(Followup)关联到某个目标,支持按 id 或关键词匹配", | ||
| 290 | + props = mapOf( | ||
| 291 | + "goal_id" to prop("integer", "目标 id(从 get_goals 获取)"), | ||
| 292 | + "followup_id" to prop("integer", "待跟进事项 id(与 followup_text 二选一)"), | ||
| 293 | + "followup_text" to prop("string", "待跟进事项关键词(模糊匹配,与 followup_id 二选一)"), | ||
| 294 | + ), | ||
| 295 | + required = listOf("goal_id"), | ||
| 296 | + )) | ||
| 297 | + put(schema( | ||
| 298 | + name = "update_goal_status", | ||
| 299 | + desc = "更新目标状态(完成/暂停/重启/放弃)", | ||
| 300 | + props = mapOf( | ||
| 301 | + "goal_id" to prop("integer", "目标 id"), | ||
| 302 | + "status" to prop("string", "新状态:active / paused / done / cancelled"), | ||
| 303 | + ), | ||
| 304 | + required = listOf("goal_id", "status"), | ||
| 305 | + )) | ||
| 267 | } | 306 | } |
| 268 | } | 307 | } |
| 269 | 308 | ||
| @@ -314,6 +353,10 @@ object LifelogTools { | @@ -314,6 +353,10 @@ object LifelogTools { | ||
| 314 | "get_org_members" -> getOrgMembers(repo, args) | 353 | "get_org_members" -> getOrgMembers(repo, args) |
| 315 | "link_person_org" -> linkPersonOrg(repo, args) | 354 | "link_person_org" -> linkPersonOrg(repo, args) |
| 316 | "find_relevant_persons" -> findRelevantPersons(repo, args) | 355 | "find_relevant_persons" -> findRelevantPersons(repo, args) |
| 356 | + "create_goal" -> createGoal(repo, args) | ||
| 357 | + "get_goals" -> getGoals(repo, args) | ||
| 358 | + "link_followup_to_goal" -> linkFollowupToGoal(repo, args) | ||
| 359 | + "update_goal_status" -> updateGoalStatus(repo, args) | ||
| 317 | else -> "未知工具:$name" | 360 | else -> "未知工具:$name" |
| 318 | } | 361 | } |
| 319 | } catch (t: Throwable) { | 362 | } catch (t: Throwable) { |
| @@ -958,6 +1001,132 @@ object LifelogTools { | @@ -958,6 +1001,132 @@ object LifelogTools { | ||
| 958 | } | 1001 | } |
| 959 | } | 1002 | } |
| 960 | 1003 | ||
| 1004 | + // ── P2 #8 Goal OS 工具 ──────────────────────────────────────────────────── | ||
| 1005 | + | ||
| 1006 | + private suspend fun createGoal(repo: Repository, args: JSONObject): String { | ||
| 1007 | + val text = args.optString("text", "").ifBlank { return "请提供目标内容" } | ||
| 1008 | + val deadline = args.optString("deadline", "").ifBlank { null } | ||
| 1009 | + val parentId = args.optLong("parent_goal_id", 0).takeIf { it > 0 } | ||
| 1010 | + val personName = args.optString("person_name", "").ifBlank { null } | ||
| 1011 | + val notes = args.optString("notes", "").ifBlank { null } | ||
| 1012 | + | ||
| 1013 | + // 可选关联人物 | ||
| 1014 | + val personId = personName?.let { q -> | ||
| 1015 | + repo.persons.all().firstOrNull { p -> | ||
| 1016 | + p.name.contains(q) || p.realName?.contains(q) == true || p.nickname?.contains(q) == true | ||
| 1017 | + }?.id | ||
| 1018 | + } | ||
| 1019 | + | ||
| 1020 | + // 校验父目标存在 | ||
| 1021 | + if (parentId != null && repo.goals.get(parentId) == null) | ||
| 1022 | + return "找不到父目标 id=$parentId,请先用 get_goals 确认 id。" | ||
| 1023 | + | ||
| 1024 | + val now = System.currentTimeMillis() | ||
| 1025 | + val id = repo.goals.insert( | ||
| 1026 | + com.lemon.lifelog.data.Goal( | ||
| 1027 | + text = text, | ||
| 1028 | + deadline = deadline, | ||
| 1029 | + parentGoalId = parentId, | ||
| 1030 | + personId = personId, | ||
| 1031 | + notes = notes, | ||
| 1032 | + createdAt = now, | ||
| 1033 | + updatedAt = now, | ||
| 1034 | + ) | ||
| 1035 | + ) | ||
| 1036 | + val parentLabel = if (parentId != null) repo.goals.get(parentId)?.text?.take(30)?.let { "「$it」的子目标" } ?: "" else "" | ||
| 1037 | + val deadlineStr = deadline?.let { ",截止 $it" } ?: "" | ||
| 1038 | + val personStr = personId?.let { repo.persons.get(it)?.name?.let { n -> ",关联:$n" } } ?: "" | ||
| 1039 | + return "✅ 目标已创建(id=$id):${text}${deadlineStr}${personStr}${if (parentLabel.isNotBlank()) "\n→ $parentLabel" else ""}" | ||
| 1040 | + } | ||
| 1041 | + | ||
| 1042 | + private suspend fun getGoals(repo: Repository, args: JSONObject): String { | ||
| 1043 | + val statusArg = args.optString("status", "active").ifBlank { "active" } | ||
| 1044 | + val allGoals = if (statusArg == "all") repo.goals.all() | ||
| 1045 | + else repo.goals.all().filter { it.status == statusArg } | ||
| 1046 | + if (allGoals.isEmpty()) return "暂无「$statusArg」状态的目标。" | ||
| 1047 | + | ||
| 1048 | + val allFollowups = repo.followups.all() | ||
| 1049 | + val followupsByGoal = allFollowups.groupBy { it.goalId } | ||
| 1050 | + val personMap = repo.persons.all().associate { it.id to (it.realName ?: it.nickname ?: it.name) } | ||
| 1051 | + | ||
| 1052 | + val statusLabel = mapOf("active" to "活跃", "paused" to "暂停", "done" to "完成", "cancelled" to "放弃") | ||
| 1053 | + | ||
| 1054 | + // 树形输出:先输出根目标,再缩进子目标 | ||
| 1055 | + val rootGoals = allGoals.filter { it.parentGoalId == null } | ||
| 1056 | + val childMap = allGoals.filter { it.parentGoalId != null }.groupBy { it.parentGoalId } | ||
| 1057 | + | ||
| 1058 | + fun goalBlock(g: com.lemon.lifelog.data.Goal, indent: String): String = buildString { | ||
| 1059 | + val status = statusLabel[g.status] ?: g.status | ||
| 1060 | + val dl = g.deadline?.let { " 截止$it" } ?: "" | ||
| 1061 | + val who = g.personId?.let { personMap[it]?.let { n -> " 关联:$n" } } ?: "" | ||
| 1062 | + appendLine("${indent}[${g.id}] $status ◆ ${g.text}$dl$who") | ||
| 1063 | + g.notes?.takeIf { it.isNotBlank() }?.let { appendLine("${indent} 备注:$it") } | ||
| 1064 | + val followups = followupsByGoal[g.id] | ||
| 1065 | + if (!followups.isNullOrEmpty()) { | ||
| 1066 | + val open = followups.filter { !it.done } | ||
| 1067 | + val done = followups.filter { it.done } | ||
| 1068 | + if (open.isNotEmpty()) open.forEach { f -> appendLine("${indent} ○ ${f.text}") } | ||
| 1069 | + if (done.isNotEmpty()) done.take(3).forEach { f -> appendLine("${indent} ✓ ${f.text}") } | ||
| 1070 | + } | ||
| 1071 | + } | ||
| 1072 | + | ||
| 1073 | + return buildString { | ||
| 1074 | + appendLine("目标列表(状态:$statusArg,共 ${allGoals.size} 条):") | ||
| 1075 | + rootGoals.forEach { root -> | ||
| 1076 | + append(goalBlock(root, "")) | ||
| 1077 | + childMap[root.id]?.forEach { child -> | ||
| 1078 | + append(goalBlock(child, " └ ")) | ||
| 1079 | + childMap[child.id]?.forEach { grandchild -> | ||
| 1080 | + append(goalBlock(grandchild, " └ ")) | ||
| 1081 | + } | ||
| 1082 | + } | ||
| 1083 | + } | ||
| 1084 | + // 有父目标但父不在当前筛选结果里的孤儿目标 | ||
| 1085 | + val orphans = allGoals.filter { it.parentGoalId != null && rootGoals.none { r -> r.id == it.parentGoalId } && !rootGoals.any { r -> childMap[r.id]?.any { c -> c.id == it.parentGoalId } == true } } | ||
| 1086 | + if (orphans.isNotEmpty()) { | ||
| 1087 | + appendLine("\n(其他子目标)") | ||
| 1088 | + orphans.forEach { append(goalBlock(it, " ")) } | ||
| 1089 | + } | ||
| 1090 | + } | ||
| 1091 | + } | ||
| 1092 | + | ||
| 1093 | + private suspend fun linkFollowupToGoal(repo: Repository, args: JSONObject): String { | ||
| 1094 | + val goalId = args.optLong("goal_id", 0).takeIf { it > 0 } | ||
| 1095 | + ?: return "请提供 goal_id(从 get_goals 获取)" | ||
| 1096 | + val goal = repo.goals.get(goalId) ?: return "找不到目标 id=$goalId" | ||
| 1097 | + | ||
| 1098 | + val followupId = args.optLong("followup_id", 0).takeIf { it > 0 } | ||
| 1099 | + val followupText = args.optString("followup_text", "").ifBlank { null } | ||
| 1100 | + | ||
| 1101 | + if (followupId == null && followupText == null) | ||
| 1102 | + return "请提供 followup_id 或 followup_text" | ||
| 1103 | + | ||
| 1104 | + val allFollowups = repo.followups.all() | ||
| 1105 | + val targets: List<com.lemon.lifelog.data.FollowupRow> = when { | ||
| 1106 | + followupId != null -> allFollowups.filter { it.id == followupId } | ||
| 1107 | + else -> allFollowups.filter { it.text.contains(followupText!!, ignoreCase = true) }.take(5) | ||
| 1108 | + } | ||
| 1109 | + if (targets.isEmpty()) return "找不到匹配的待跟进事项(关键词:「${followupText ?: followupId}」)" | ||
| 1110 | + | ||
| 1111 | + targets.forEach { repo.followups.updateGoal(it.id, goalId) } | ||
| 1112 | + return buildString { | ||
| 1113 | + appendLine("✅ 已关联 ${targets.size} 条待跟进事项 → 目标「${goal.text}」:") | ||
| 1114 | + targets.forEach { appendLine(" - ${it.text}") } | ||
| 1115 | + } | ||
| 1116 | + } | ||
| 1117 | + | ||
| 1118 | + private suspend fun updateGoalStatus(repo: Repository, args: JSONObject): String { | ||
| 1119 | + val goalId = args.optLong("goal_id", 0).takeIf { it > 0 } | ||
| 1120 | + ?: return "请提供 goal_id" | ||
| 1121 | + val status = args.optString("status", "").ifBlank { return "请提供状态:active / paused / done / cancelled" } | ||
| 1122 | + val valid = setOf("active", "paused", "done", "cancelled") | ||
| 1123 | + if (status !in valid) return "无效状态「$status」,可选:${valid.joinToString(" / ")}" | ||
| 1124 | + val goal = repo.goals.get(goalId) ?: return "找不到目标 id=$goalId" | ||
| 1125 | + repo.goals.updateStatus(goalId, status, System.currentTimeMillis()) | ||
| 1126 | + val label = mapOf("active" to "重新激活", "paused" to "暂停", "done" to "标为完成 ✅", "cancelled" to "放弃") | ||
| 1127 | + return "目标「${goal.text}」已${label[status]}。" | ||
| 1128 | + } | ||
| 1129 | + | ||
| 961 | // ── 人物时间线 ─────────────────────────────────────────────────────────── | 1130 | // ── 人物时间线 ─────────────────────────────────────────────────────────── |
| 962 | 1131 | ||
| 963 | private suspend fun getPersonTimeline(repo: Repository, args: JSONObject): String { | 1132 | private suspend fun getPersonTimeline(repo: Repository, args: JSONObject): String { |
lifelog-recorder/app/src/main/kotlin/com/lemon/lifelog/data/AppDatabase.kt
| @@ -29,8 +29,9 @@ import androidx.sqlite.db.SupportSQLiteDatabase | @@ -29,8 +29,9 @@ import androidx.sqlite.db.SupportSQLiteDatabase | ||
| 29 | TopicMention::class, | 29 | TopicMention::class, |
| 30 | Organization::class, | 30 | Organization::class, |
| 31 | PersonOrg::class, | 31 | PersonOrg::class, |
| 32 | + Goal::class, | ||
| 32 | ], | 33 | ], |
| 33 | - version = 23, | 34 | + version = 24, |
| 34 | exportSchema = false, | 35 | exportSchema = false, |
| 35 | ) | 36 | ) |
| 36 | abstract class AppDatabase : RoomDatabase() { | 37 | abstract class AppDatabase : RoomDatabase() { |
| @@ -50,6 +51,7 @@ abstract class AppDatabase : RoomDatabase() { | @@ -50,6 +51,7 @@ abstract class AppDatabase : RoomDatabase() { | ||
| 50 | abstract fun topicDao(): TopicDao | 51 | abstract fun topicDao(): TopicDao |
| 51 | abstract fun organizationDao(): OrganizationDao | 52 | abstract fun organizationDao(): OrganizationDao |
| 52 | abstract fun personOrgDao(): PersonOrgDao | 53 | abstract fun personOrgDao(): PersonOrgDao |
| 54 | + abstract fun goalDao(): GoalDao | ||
| 53 | 55 | ||
| 54 | companion object { | 56 | companion object { |
| 55 | @Volatile | 57 | @Volatile |
| @@ -252,6 +254,26 @@ abstract class AppDatabase : RoomDatabase() { | @@ -252,6 +254,26 @@ abstract class AppDatabase : RoomDatabase() { | ||
| 252 | } | 254 | } |
| 253 | } | 255 | } |
| 254 | 256 | ||
| 257 | + /** P2 #6/#7:goals 新表 + followups.goalId 挂载字段。 */ | ||
| 258 | + private val MIGRATION_23_24 = object : Migration(23, 24) { | ||
| 259 | + override fun migrate(db: SupportSQLiteDatabase) { | ||
| 260 | + db.execSQL( | ||
| 261 | + "CREATE TABLE IF NOT EXISTS `goals` (" + | ||
| 262 | + "`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, " + | ||
| 263 | + "`text` TEXT NOT NULL, " + | ||
| 264 | + "`status` TEXT NOT NULL DEFAULT 'active', " + | ||
| 265 | + "`deadline` TEXT, " + | ||
| 266 | + "`parentGoalId` INTEGER, " + | ||
| 267 | + "`personId` INTEGER, " + | ||
| 268 | + "`notes` TEXT, " + | ||
| 269 | + "`createdAt` INTEGER NOT NULL, " + | ||
| 270 | + "`updatedAt` INTEGER NOT NULL)" | ||
| 271 | + ) | ||
| 272 | + db.execSQL("CREATE INDEX IF NOT EXISTS `index_goals_status` ON `goals` (`status`)") | ||
| 273 | + db.execSQL("ALTER TABLE `followups` ADD COLUMN `goalId` INTEGER") | ||
| 274 | + } | ||
| 275 | + } | ||
| 276 | + | ||
| 255 | fun get(ctx: Context): AppDatabase = | 277 | fun get(ctx: Context): AppDatabase = |
| 256 | instance ?: synchronized(this) { | 278 | instance ?: synchronized(this) { |
| 257 | instance ?: Room.databaseBuilder( | 279 | instance ?: Room.databaseBuilder( |
| @@ -259,7 +281,7 @@ abstract class AppDatabase : RoomDatabase() { | @@ -259,7 +281,7 @@ abstract class AppDatabase : RoomDatabase() { | ||
| 259 | AppDatabase::class.java, | 281 | AppDatabase::class.java, |
| 260 | "lifelog.db", | 282 | "lifelog.db", |
| 261 | ) | 283 | ) |
| 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) | 284 | + .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, MIGRATION_23_24) |
| 263 | // 不再 fallbackToDestructiveMigration:以后改 schema 必须写 Migration, | 285 | // 不再 fallbackToDestructiveMigration:以后改 schema 必须写 Migration, |
| 264 | // 否则启动直接抛 IllegalStateException(fail-loud),绝不静默清掉真实录音。 | 286 | // 否则启动直接抛 IllegalStateException(fail-loud),绝不静默清掉真实录音。 |
| 265 | .build() | 287 | .build() |
lifelog-recorder/app/src/main/kotlin/com/lemon/lifelog/data/Daos.kt
| @@ -399,6 +399,7 @@ data class FollowupRow( | @@ -399,6 +399,7 @@ data class FollowupRow( | ||
| 399 | val personName: String?, | 399 | val personName: String?, |
| 400 | val sourceTs: Long, | 400 | val sourceTs: Long, |
| 401 | val done: Boolean, | 401 | val done: Boolean, |
| 402 | + val goalId: Long? = null, | ||
| 402 | ) | 403 | ) |
| 403 | 404 | ||
| 404 | @Dao | 405 | @Dao |
| @@ -409,6 +410,9 @@ interface FollowupDao { | @@ -409,6 +410,9 @@ interface FollowupDao { | ||
| 409 | @Query("UPDATE followups SET done = :done WHERE id = :id") | 410 | @Query("UPDATE followups SET done = :done WHERE id = :id") |
| 410 | suspend fun setDone(id: Long, done: Boolean) | 411 | suspend fun setDone(id: Long, done: Boolean) |
| 411 | 412 | ||
| 413 | + @Query("UPDATE followups SET goalId = :goalId WHERE id = :id") | ||
| 414 | + suspend fun updateGoal(id: Long, goalId: Long?) | ||
| 415 | + | ||
| 412 | @Query("DELETE FROM followups WHERE id = :id") | 416 | @Query("DELETE FROM followups WHERE id = :id") |
| 413 | suspend fun delete(id: Long) | 417 | suspend fun delete(id: Long) |
| 414 | 418 | ||
| @@ -418,14 +422,66 @@ interface FollowupDao { | @@ -418,14 +422,66 @@ interface FollowupDao { | ||
| 418 | /** 未完成在前、按来源时间倒序。 */ | 422 | /** 未完成在前、按来源时间倒序。 */ |
| 419 | @Query( | 423 | @Query( |
| 420 | """ | 424 | """ |
| 421 | - SELECT f.id AS id, f.text AS text, f.personId AS personId, p.name AS personName, | ||
| 422 | - f.sourceTs AS sourceTs, f.done AS done | 425 | + SELECT f.id, f.text, f.personId, p.name AS personName, |
| 426 | + f.sourceTs, f.done, f.goalId | ||
| 423 | FROM followups f | 427 | FROM followups f |
| 424 | LEFT JOIN persons p ON p.id = f.personId | 428 | LEFT JOIN persons p ON p.id = f.personId |
| 425 | ORDER BY f.done ASC, f.sourceTs DESC | 429 | ORDER BY f.done ASC, f.sourceTs DESC |
| 426 | """, | 430 | """, |
| 427 | ) | 431 | ) |
| 428 | fun observeAll(): Flow<List<FollowupRow>> | 432 | fun observeAll(): Flow<List<FollowupRow>> |
| 433 | + | ||
| 434 | + /** 挂在某个目标下的待跟进事项。 */ | ||
| 435 | + @Query( | ||
| 436 | + """ | ||
| 437 | + SELECT f.id, f.text, f.personId, p.name AS personName, | ||
| 438 | + f.sourceTs, f.done, f.goalId | ||
| 439 | + FROM followups f | ||
| 440 | + LEFT JOIN persons p ON p.id = f.personId | ||
| 441 | + WHERE f.goalId = :goalId | ||
| 442 | + ORDER BY f.done ASC, f.sourceTs DESC | ||
| 443 | + """, | ||
| 444 | + ) | ||
| 445 | + fun observeByGoal(goalId: Long): Flow<List<FollowupRow>> | ||
| 446 | + | ||
| 447 | + /** 一次性取,供 LLM 工具匹配文本。 */ | ||
| 448 | + @Query("SELECT f.id, f.text, f.personId, p.name AS personName, f.sourceTs, f.done, f.goalId FROM followups f LEFT JOIN persons p ON p.id = f.personId ORDER BY f.done ASC, f.sourceTs DESC") | ||
| 449 | + suspend fun all(): List<FollowupRow> | ||
| 450 | +} | ||
| 451 | + | ||
| 452 | +// ── Goal OS ─────────────────────────────────────────────────────────────────── | ||
| 453 | + | ||
| 454 | +@Dao | ||
| 455 | +interface GoalDao { | ||
| 456 | + @Insert(onConflict = OnConflictStrategy.REPLACE) | ||
| 457 | + suspend fun insert(g: Goal): Long | ||
| 458 | + | ||
| 459 | + @Query("SELECT * FROM goals ORDER BY status ASC, createdAt DESC") | ||
| 460 | + fun observeAll(): Flow<List<Goal>> | ||
| 461 | + | ||
| 462 | + @Query("SELECT * FROM goals WHERE status = :status ORDER BY createdAt DESC") | ||
| 463 | + fun observeByStatus(status: String): Flow<List<Goal>> | ||
| 464 | + | ||
| 465 | + @Query("SELECT * FROM goals WHERE id = :id") | ||
| 466 | + suspend fun get(id: Long): Goal? | ||
| 467 | + | ||
| 468 | + @Query("SELECT * FROM goals WHERE parentGoalId IS NULL ORDER BY status ASC, createdAt DESC") | ||
| 469 | + suspend fun roots(): List<Goal> | ||
| 470 | + | ||
| 471 | + @Query("SELECT * FROM goals WHERE parentGoalId = :parentId ORDER BY createdAt DESC") | ||
| 472 | + suspend fun children(parentId: Long): List<Goal> | ||
| 473 | + | ||
| 474 | + @Query("UPDATE goals SET status = :status, updatedAt = :ts WHERE id = :id") | ||
| 475 | + suspend fun updateStatus(id: Long, status: String, ts: Long) | ||
| 476 | + | ||
| 477 | + @Query("UPDATE goals SET notes = :notes, updatedAt = :ts WHERE id = :id") | ||
| 478 | + suspend fun updateNotes(id: Long, notes: String?, ts: Long) | ||
| 479 | + | ||
| 480 | + @Query("SELECT * FROM goals ORDER BY status ASC, createdAt DESC") | ||
| 481 | + suspend fun all(): List<Goal> | ||
| 482 | + | ||
| 483 | + @Query("DELETE FROM goals WHERE id = :id") | ||
| 484 | + suspend fun delete(id: Long) | ||
| 429 | } | 485 | } |
| 430 | 486 | ||
| 431 | /** 话题行(含被提到次数 + 最近时间)。 */ | 487 | /** 话题行(含被提到次数 + 最近时间)。 */ |
lifelog-recorder/app/src/main/kotlin/com/lemon/lifelog/data/Entities.kt
| @@ -230,6 +230,8 @@ data class Followup( | @@ -230,6 +230,8 @@ data class Followup( | ||
| 230 | val text: String, | 230 | val text: String, |
| 231 | /** 关联人物(可空,从文本匹配 Person) */ | 231 | /** 关联人物(可空,从文本匹配 Person) */ |
| 232 | val personId: Long? = null, | 232 | val personId: Long? = null, |
| 233 | + /** 挂载到的目标(可空,由 AI 或用户手动关联) */ | ||
| 234 | + val goalId: Long? = null, | ||
| 233 | /** 来源录音时间(窗口内最新段的墙钟) */ | 235 | /** 来源录音时间(窗口内最新段的墙钟) */ |
| 234 | val sourceTs: Long, | 236 | val sourceTs: Long, |
| 235 | val done: Boolean = false, | 237 | val done: Boolean = false, |
| @@ -237,6 +239,28 @@ data class Followup( | @@ -237,6 +239,28 @@ data class Followup( | ||
| 237 | ) | 239 | ) |
| 238 | 240 | ||
| 239 | /** | 241 | /** |
| 242 | + * Goal OS 目标实体。支持树形分解([parentGoalId] 非空 = 子目标)。 | ||
| 243 | + * [status]:active / paused / done / cancelled。 | ||
| 244 | + */ | ||
| 245 | +@Entity(tableName = "goals") | ||
| 246 | +data class Goal( | ||
| 247 | + @PrimaryKey(autoGenerate = true) val id: Long = 0, | ||
| 248 | + val text: String, | ||
| 249 | + /** active / paused / done / cancelled */ | ||
| 250 | + val status: String = "active", | ||
| 251 | + /** 截止日期,ISO 日期字符串如 "2026-09-01",可空 */ | ||
| 252 | + val deadline: String? = null, | ||
| 253 | + /** 父目标 id(非空 = 子目标,支持树形分解) */ | ||
| 254 | + val parentGoalId: Long? = null, | ||
| 255 | + /** 与哪个人相关(可空) */ | ||
| 256 | + val personId: Long? = null, | ||
| 257 | + /** 备注(AI 或用户填写) */ | ||
| 258 | + val notes: String? = null, | ||
| 259 | + val createdAt: Long = System.currentTimeMillis(), | ||
| 260 | + val updatedAt: Long = System.currentTimeMillis(), | ||
| 261 | +) | ||
| 262 | + | ||
| 263 | +/** | ||
| 240 | * 人↔人「共现」无向边:同一会话(5min 静音切)里同时**在场**(声纹匹配)或**被提到**(mentions) | 264 | * 人↔人「共现」无向边:同一会话(5min 静音切)里同时**在场**(声纹匹配)或**被提到**(mentions) |
| 241 | * 的两个人 → 一条边,[weight] = 共现过的会话数。规范化存 [personA] < [personB],(A,B) 唯一。 | 265 | * 的两个人 → 一条边,[weight] = 共现过的会话数。规范化存 [personA] < [personB],(A,B) 唯一。 |
| 242 | */ | 266 | */ |
lifelog-recorder/app/src/main/kotlin/com/lemon/lifelog/data/Repository.kt
| @@ -20,4 +20,5 @@ class Repository(ctx: Context) { | @@ -20,4 +20,5 @@ class Repository(ctx: Context) { | ||
| 20 | val topics = db.topicDao() | 20 | val topics = db.topicDao() |
| 21 | val organizations = db.organizationDao() | 21 | val organizations = db.organizationDao() |
| 22 | val personOrgs = db.personOrgDao() | 22 | val personOrgs = db.personOrgDao() |
| 23 | + val goals = db.goalDao() | ||
| 23 | } | 24 | } |