diff --git a/lifelog-recorder/app/src/main/kotlin/com/lemon/lifelog/chat/ChatViewModel.kt b/lifelog-recorder/app/src/main/kotlin/com/lemon/lifelog/chat/ChatViewModel.kt index e7e4c14..5b932bc 100644 --- a/lifelog-recorder/app/src/main/kotlin/com/lemon/lifelog/chat/ChatViewModel.kt +++ b/lifelog-recorder/app/src/main/kotlin/com/lemon/lifelog/chat/ChatViewModel.kt @@ -312,15 +312,21 @@ class ChatViewModel(app: Application) : AndroidViewModel(app) { 【关系网络 & 组织】 ⑲ get_stale_contacts — 列出长期未联系的人(args: min_days默认30, limit默认20) ⑳ get_org_members — 查某组织的成员列表(args: org_name) - ㉑ link_person_org — 将人物关联到组织/公司(args: person_name, org_name, kind可选company|school|org|project, role可选, start_date可选如2023-01, end_date可选) + ㉑ link_person_org — 将人物关联到组织/公司(args: person_name, org_name, kind可选, role可选, start_date可选) ㉒ find_relevant_persons — 根据目标找最相关的人「谁能帮我」(args: goal, limit默认10) + 【目标管理 Goal OS】 + ㉓ create_goal — 创建目标(args: text, deadline可选如2026-09-01, parent_goal_id可选, person_name可选, notes可选) + ㉔ get_goals — 查目标列表,含子目标树+关联待跟进(args: status=active|paused|done|cancelled|all,默认active) + ㉕ link_followup_to_goal — 把待跟进事项挂到目标(args: goal_id, followup_id或followup_text) + ㉖ update_goal_status — 更新目标状态(args: goal_id, status=active|paused|done|cancelled) + 【数据刷新】 - ㉓ refresh_person_data — 刷新某人微信聊天+朋友圈(args: person_name) + ㉗ refresh_person_data — 刷新某人微信聊天+朋友圈(args: person_name) ★ 查询某人聊天/朋友圈/时间线前,必须先调此工具确保数据最新! 【全网搜索】 - ㉔ web_search — Tavily 搜索互联网(args: query, max_results默认5;需设置中配置 Key) + ㉘ web_search — Tavily 搜索互联网(args: query, max_results默认5;需设置中配置 Key) 需要查询时,输出(每个工具独占一行,严格 JSON): TOOL_CALL: {"name": "工具名", "args": {"参数": "值"}} diff --git a/lifelog-recorder/app/src/main/kotlin/com/lemon/lifelog/chat/LifelogTools.kt b/lifelog-recorder/app/src/main/kotlin/com/lemon/lifelog/chat/LifelogTools.kt index 5e8cde2..bbc2f43 100644 --- a/lifelog-recorder/app/src/main/kotlin/com/lemon/lifelog/chat/LifelogTools.kt +++ b/lifelog-recorder/app/src/main/kotlin/com/lemon/lifelog/chat/LifelogTools.kt @@ -264,6 +264,45 @@ object LifelogTools { ), required = listOf("goal"), )) + put(schema( + name = "create_goal", + desc = "创建一个新目标(支持树形分解:可指定父目标 + 关联人物)", + props = mapOf( + "text" to prop("string", "目标内容,如「完成融资 A 轮」"), + "deadline" to prop("string", "截止日期 ISO 格式如 2026-09-01,可选"), + "parent_goal_id" to prop("integer", "父目标 id(子目标用),可选"), + "person_name" to prop("string", "关联人物姓名,可选"), + "notes" to prop("string", "备注,可选"), + ), + required = listOf("text"), + )) + put(schema( + name = "get_goals", + desc = "查看目标列表(支持按状态过滤,默认返回活跃目标;展示目标树结构 + 关联的待跟进事项)", + props = mapOf( + "status" to prop("string", "状态过滤:active(活跃)/ paused(暂停)/ done(完成)/ cancelled(放弃)/ all(全部),默认 active"), + ), + required = emptyList(), + )) + put(schema( + name = "link_followup_to_goal", + desc = "将一条待跟进事项(Followup)关联到某个目标,支持按 id 或关键词匹配", + props = mapOf( + "goal_id" to prop("integer", "目标 id(从 get_goals 获取)"), + "followup_id" to prop("integer", "待跟进事项 id(与 followup_text 二选一)"), + "followup_text" to prop("string", "待跟进事项关键词(模糊匹配,与 followup_id 二选一)"), + ), + required = listOf("goal_id"), + )) + put(schema( + name = "update_goal_status", + desc = "更新目标状态(完成/暂停/重启/放弃)", + props = mapOf( + "goal_id" to prop("integer", "目标 id"), + "status" to prop("string", "新状态:active / paused / done / cancelled"), + ), + required = listOf("goal_id", "status"), + )) } } @@ -314,6 +353,10 @@ object LifelogTools { "get_org_members" -> getOrgMembers(repo, args) "link_person_org" -> linkPersonOrg(repo, args) "find_relevant_persons" -> findRelevantPersons(repo, args) + "create_goal" -> createGoal(repo, args) + "get_goals" -> getGoals(repo, args) + "link_followup_to_goal" -> linkFollowupToGoal(repo, args) + "update_goal_status" -> updateGoalStatus(repo, args) else -> "未知工具:$name" } } catch (t: Throwable) { @@ -958,6 +1001,132 @@ object LifelogTools { } } + // ── P2 #8 Goal OS 工具 ──────────────────────────────────────────────────── + + private suspend fun createGoal(repo: Repository, args: JSONObject): String { + val text = args.optString("text", "").ifBlank { return "请提供目标内容" } + val deadline = args.optString("deadline", "").ifBlank { null } + val parentId = args.optLong("parent_goal_id", 0).takeIf { it > 0 } + val personName = args.optString("person_name", "").ifBlank { null } + val notes = args.optString("notes", "").ifBlank { null } + + // 可选关联人物 + val personId = personName?.let { q -> + repo.persons.all().firstOrNull { p -> + p.name.contains(q) || p.realName?.contains(q) == true || p.nickname?.contains(q) == true + }?.id + } + + // 校验父目标存在 + if (parentId != null && repo.goals.get(parentId) == null) + return "找不到父目标 id=$parentId,请先用 get_goals 确认 id。" + + val now = System.currentTimeMillis() + val id = repo.goals.insert( + com.lemon.lifelog.data.Goal( + text = text, + deadline = deadline, + parentGoalId = parentId, + personId = personId, + notes = notes, + createdAt = now, + updatedAt = now, + ) + ) + val parentLabel = if (parentId != null) repo.goals.get(parentId)?.text?.take(30)?.let { "「$it」的子目标" } ?: "" else "" + val deadlineStr = deadline?.let { ",截止 $it" } ?: "" + val personStr = personId?.let { repo.persons.get(it)?.name?.let { n -> ",关联:$n" } } ?: "" + return "✅ 目标已创建(id=$id):${text}${deadlineStr}${personStr}${if (parentLabel.isNotBlank()) "\n→ $parentLabel" else ""}" + } + + private suspend fun getGoals(repo: Repository, args: JSONObject): String { + val statusArg = args.optString("status", "active").ifBlank { "active" } + val allGoals = if (statusArg == "all") repo.goals.all() + else repo.goals.all().filter { it.status == statusArg } + if (allGoals.isEmpty()) return "暂无「$statusArg」状态的目标。" + + val allFollowups = repo.followups.all() + val followupsByGoal = allFollowups.groupBy { it.goalId } + val personMap = repo.persons.all().associate { it.id to (it.realName ?: it.nickname ?: it.name) } + + val statusLabel = mapOf("active" to "活跃", "paused" to "暂停", "done" to "完成", "cancelled" to "放弃") + + // 树形输出:先输出根目标,再缩进子目标 + val rootGoals = allGoals.filter { it.parentGoalId == null } + val childMap = allGoals.filter { it.parentGoalId != null }.groupBy { it.parentGoalId } + + fun goalBlock(g: com.lemon.lifelog.data.Goal, indent: String): String = buildString { + val status = statusLabel[g.status] ?: g.status + val dl = g.deadline?.let { " 截止$it" } ?: "" + val who = g.personId?.let { personMap[it]?.let { n -> " 关联:$n" } } ?: "" + appendLine("${indent}[${g.id}] $status ◆ ${g.text}$dl$who") + g.notes?.takeIf { it.isNotBlank() }?.let { appendLine("${indent} 备注:$it") } + val followups = followupsByGoal[g.id] + if (!followups.isNullOrEmpty()) { + val open = followups.filter { !it.done } + val done = followups.filter { it.done } + if (open.isNotEmpty()) open.forEach { f -> appendLine("${indent} ○ ${f.text}") } + if (done.isNotEmpty()) done.take(3).forEach { f -> appendLine("${indent} ✓ ${f.text}") } + } + } + + return buildString { + appendLine("目标列表(状态:$statusArg,共 ${allGoals.size} 条):") + rootGoals.forEach { root -> + append(goalBlock(root, "")) + childMap[root.id]?.forEach { child -> + append(goalBlock(child, " └ ")) + childMap[child.id]?.forEach { grandchild -> + append(goalBlock(grandchild, " └ ")) + } + } + } + // 有父目标但父不在当前筛选结果里的孤儿目标 + 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 } } + if (orphans.isNotEmpty()) { + appendLine("\n(其他子目标)") + orphans.forEach { append(goalBlock(it, " ")) } + } + } + } + + private suspend fun linkFollowupToGoal(repo: Repository, args: JSONObject): String { + val goalId = args.optLong("goal_id", 0).takeIf { it > 0 } + ?: return "请提供 goal_id(从 get_goals 获取)" + val goal = repo.goals.get(goalId) ?: return "找不到目标 id=$goalId" + + val followupId = args.optLong("followup_id", 0).takeIf { it > 0 } + val followupText = args.optString("followup_text", "").ifBlank { null } + + if (followupId == null && followupText == null) + return "请提供 followup_id 或 followup_text" + + val allFollowups = repo.followups.all() + val targets: List = when { + followupId != null -> allFollowups.filter { it.id == followupId } + else -> allFollowups.filter { it.text.contains(followupText!!, ignoreCase = true) }.take(5) + } + if (targets.isEmpty()) return "找不到匹配的待跟进事项(关键词:「${followupText ?: followupId}」)" + + targets.forEach { repo.followups.updateGoal(it.id, goalId) } + return buildString { + appendLine("✅ 已关联 ${targets.size} 条待跟进事项 → 目标「${goal.text}」:") + targets.forEach { appendLine(" - ${it.text}") } + } + } + + private suspend fun updateGoalStatus(repo: Repository, args: JSONObject): String { + val goalId = args.optLong("goal_id", 0).takeIf { it > 0 } + ?: return "请提供 goal_id" + val status = args.optString("status", "").ifBlank { return "请提供状态:active / paused / done / cancelled" } + val valid = setOf("active", "paused", "done", "cancelled") + if (status !in valid) return "无效状态「$status」,可选:${valid.joinToString(" / ")}" + val goal = repo.goals.get(goalId) ?: return "找不到目标 id=$goalId" + repo.goals.updateStatus(goalId, status, System.currentTimeMillis()) + val label = mapOf("active" to "重新激活", "paused" to "暂停", "done" to "标为完成 ✅", "cancelled" to "放弃") + return "目标「${goal.text}」已${label[status]}。" + } + // ── 人物时间线 ─────────────────────────────────────────────────────────── private suspend fun getPersonTimeline(repo: Repository, args: JSONObject): String { diff --git a/lifelog-recorder/app/src/main/kotlin/com/lemon/lifelog/data/AppDatabase.kt b/lifelog-recorder/app/src/main/kotlin/com/lemon/lifelog/data/AppDatabase.kt index 1b0c443..4791054 100644 --- a/lifelog-recorder/app/src/main/kotlin/com/lemon/lifelog/data/AppDatabase.kt +++ b/lifelog-recorder/app/src/main/kotlin/com/lemon/lifelog/data/AppDatabase.kt @@ -29,8 +29,9 @@ import androidx.sqlite.db.SupportSQLiteDatabase TopicMention::class, Organization::class, PersonOrg::class, + Goal::class, ], - version = 23, + version = 24, exportSchema = false, ) abstract class AppDatabase : RoomDatabase() { @@ -50,6 +51,7 @@ abstract class AppDatabase : RoomDatabase() { abstract fun topicDao(): TopicDao abstract fun organizationDao(): OrganizationDao abstract fun personOrgDao(): PersonOrgDao + abstract fun goalDao(): GoalDao companion object { @Volatile @@ -252,6 +254,26 @@ abstract class AppDatabase : RoomDatabase() { } } + /** P2 #6/#7:goals 新表 + followups.goalId 挂载字段。 */ + private val MIGRATION_23_24 = object : Migration(23, 24) { + override fun migrate(db: SupportSQLiteDatabase) { + db.execSQL( + "CREATE TABLE IF NOT EXISTS `goals` (" + + "`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, " + + "`text` TEXT NOT NULL, " + + "`status` TEXT NOT NULL DEFAULT 'active', " + + "`deadline` TEXT, " + + "`parentGoalId` INTEGER, " + + "`personId` INTEGER, " + + "`notes` TEXT, " + + "`createdAt` INTEGER NOT NULL, " + + "`updatedAt` INTEGER NOT NULL)" + ) + db.execSQL("CREATE INDEX IF NOT EXISTS `index_goals_status` ON `goals` (`status`)") + db.execSQL("ALTER TABLE `followups` ADD COLUMN `goalId` INTEGER") + } + } + fun get(ctx: Context): AppDatabase = instance ?: synchronized(this) { instance ?: Room.databaseBuilder( @@ -259,7 +281,7 @@ abstract class AppDatabase : RoomDatabase() { AppDatabase::class.java, "lifelog.db", ) - .addMigrations(MIGRATION_2_3, MIGRATION_3_4, MIGRATION_4_5, MIGRATION_5_6, MIGRATION_6_7, MIGRATION_7_9, MIGRATION_9_10, MIGRATION_10_11, MIGRATION_11_12, MIGRATION_12_13, MIGRATION_13_14, MIGRATION_14_15, MIGRATION_15_16, MIGRATION_16_17, MIGRATION_17_18, MIGRATION_18_19, MIGRATION_19_20, MIGRATION_20_21, MIGRATION_21_22, MIGRATION_22_23) + .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) // 不再 fallbackToDestructiveMigration:以后改 schema 必须写 Migration, // 否则启动直接抛 IllegalStateException(fail-loud),绝不静默清掉真实录音。 .build() diff --git a/lifelog-recorder/app/src/main/kotlin/com/lemon/lifelog/data/Daos.kt b/lifelog-recorder/app/src/main/kotlin/com/lemon/lifelog/data/Daos.kt index b4710ad..929c3f6 100644 --- a/lifelog-recorder/app/src/main/kotlin/com/lemon/lifelog/data/Daos.kt +++ b/lifelog-recorder/app/src/main/kotlin/com/lemon/lifelog/data/Daos.kt @@ -399,6 +399,7 @@ data class FollowupRow( val personName: String?, val sourceTs: Long, val done: Boolean, + val goalId: Long? = null, ) @Dao @@ -409,6 +410,9 @@ interface FollowupDao { @Query("UPDATE followups SET done = :done WHERE id = :id") suspend fun setDone(id: Long, done: Boolean) + @Query("UPDATE followups SET goalId = :goalId WHERE id = :id") + suspend fun updateGoal(id: Long, goalId: Long?) + @Query("DELETE FROM followups WHERE id = :id") suspend fun delete(id: Long) @@ -418,14 +422,66 @@ interface FollowupDao { /** 未完成在前、按来源时间倒序。 */ @Query( """ - SELECT f.id AS id, f.text AS text, f.personId AS personId, p.name AS personName, - f.sourceTs AS sourceTs, f.done AS done + 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 """, ) fun observeAll(): Flow> + + /** 挂在某个目标下的待跟进事项。 */ + @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 + WHERE f.goalId = :goalId + ORDER BY f.done ASC, f.sourceTs DESC + """, + ) + fun observeByGoal(goalId: Long): Flow> + + /** 一次性取,供 LLM 工具匹配文本。 */ + @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") + suspend fun all(): List +} + +// ── Goal OS ─────────────────────────────────────────────────────────────────── + +@Dao +interface GoalDao { + @Insert(onConflict = OnConflictStrategy.REPLACE) + suspend fun insert(g: Goal): Long + + @Query("SELECT * FROM goals ORDER BY status ASC, createdAt DESC") + fun observeAll(): Flow> + + @Query("SELECT * FROM goals WHERE status = :status ORDER BY createdAt DESC") + fun observeByStatus(status: String): Flow> + + @Query("SELECT * FROM goals WHERE id = :id") + suspend fun get(id: Long): Goal? + + @Query("SELECT * FROM goals WHERE parentGoalId IS NULL ORDER BY status ASC, createdAt DESC") + suspend fun roots(): List + + @Query("SELECT * FROM goals WHERE parentGoalId = :parentId ORDER BY createdAt DESC") + suspend fun children(parentId: Long): List + + @Query("UPDATE goals SET status = :status, updatedAt = :ts WHERE id = :id") + suspend fun updateStatus(id: Long, status: String, ts: Long) + + @Query("UPDATE goals SET notes = :notes, updatedAt = :ts WHERE id = :id") + suspend fun updateNotes(id: Long, notes: String?, ts: Long) + + @Query("SELECT * FROM goals ORDER BY status ASC, createdAt DESC") + suspend fun all(): List + + @Query("DELETE FROM goals WHERE id = :id") + suspend fun delete(id: Long) } /** 话题行(含被提到次数 + 最近时间)。 */ diff --git a/lifelog-recorder/app/src/main/kotlin/com/lemon/lifelog/data/Entities.kt b/lifelog-recorder/app/src/main/kotlin/com/lemon/lifelog/data/Entities.kt index d05ec90..7fde7df 100644 --- a/lifelog-recorder/app/src/main/kotlin/com/lemon/lifelog/data/Entities.kt +++ b/lifelog-recorder/app/src/main/kotlin/com/lemon/lifelog/data/Entities.kt @@ -230,6 +230,8 @@ data class Followup( val text: String, /** 关联人物(可空,从文本匹配 Person) */ val personId: Long? = null, + /** 挂载到的目标(可空,由 AI 或用户手动关联) */ + val goalId: Long? = null, /** 来源录音时间(窗口内最新段的墙钟) */ val sourceTs: Long, val done: Boolean = false, @@ -237,6 +239,28 @@ data class Followup( ) /** + * Goal OS 目标实体。支持树形分解([parentGoalId] 非空 = 子目标)。 + * [status]:active / paused / done / cancelled。 + */ +@Entity(tableName = "goals") +data class Goal( + @PrimaryKey(autoGenerate = true) val id: Long = 0, + val text: String, + /** active / paused / done / cancelled */ + val status: String = "active", + /** 截止日期,ISO 日期字符串如 "2026-09-01",可空 */ + val deadline: String? = null, + /** 父目标 id(非空 = 子目标,支持树形分解) */ + val parentGoalId: Long? = null, + /** 与哪个人相关(可空) */ + val personId: Long? = null, + /** 备注(AI 或用户填写) */ + val notes: String? = null, + val createdAt: Long = System.currentTimeMillis(), + val updatedAt: Long = System.currentTimeMillis(), +) + +/** * 人↔人「共现」无向边:同一会话(5min 静音切)里同时**在场**(声纹匹配)或**被提到**(mentions) * 的两个人 → 一条边,[weight] = 共现过的会话数。规范化存 [personA] < [personB],(A,B) 唯一。 */ diff --git a/lifelog-recorder/app/src/main/kotlin/com/lemon/lifelog/data/Repository.kt b/lifelog-recorder/app/src/main/kotlin/com/lemon/lifelog/data/Repository.kt index bc60b8d..7ce19be 100644 --- a/lifelog-recorder/app/src/main/kotlin/com/lemon/lifelog/data/Repository.kt +++ b/lifelog-recorder/app/src/main/kotlin/com/lemon/lifelog/data/Repository.kt @@ -20,4 +20,5 @@ class Repository(ctx: Context) { val topics = db.topicDao() val organizations = db.organizationDao() val personOrgs = db.personOrgDao() + val goals = db.goalDao() }