新增: 跨会话召回四层架构与脉络卡
- 会话终态提炼: 删除前+停靠双挂点,快照与清水位同临界区 - 目标快照: 恢复引导按最终有效意图生成,旧目标不复活 - 知识检索项目维度软排序,WorkingContext 持久化迁移 - 脉络卡: published 知识按项目聚合纯读侧,带入上下文经输入框显式注入 - 压缩与缓存能力在主循环/子代理/提示词三面接线 - 两份专项设计文档与看板销账
This commit is contained in:
@@ -114,6 +114,8 @@ fn ai_conversation_from_row(row: &Row<'_>) -> std::result::Result<AiConversation
|
||||
loop_state: row.get("loop_state").unwrap_or(None),
|
||||
// status 列老库经 v50 迁移补建(NOT NULL DEFAULT 'active')。兜底迁移未跑/极端缺列时按 active。
|
||||
status: row.get("status").unwrap_or_else(|_| "active".to_string()),
|
||||
// working_context_json 列老库经 v53 迁移补建。unwrap_or(None) 兜底(NULL=无 WorkingContext)。
|
||||
working_context_json: row.get("working_context_json").unwrap_or(None),
|
||||
})
|
||||
}
|
||||
|
||||
@@ -191,14 +193,14 @@ impl_repo!(
|
||||
from_row => |row| ai_conversation_from_row(row),
|
||||
insert => |conn, rec| {
|
||||
conn.execute(
|
||||
"INSERT INTO ai_conversations (id, title, messages, provider_id, model, models, archived, pinned, prompt_tokens, completion_tokens, pinned_goals, pending_approvals, created_at, updated_at, origin, boundary_json, loop_state, status)
|
||||
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13, ?14, ?15, ?16, ?17, ?18)",
|
||||
"INSERT INTO ai_conversations (id, title, messages, provider_id, model, models, archived, pinned, prompt_tokens, completion_tokens, pinned_goals, pending_approvals, created_at, updated_at, origin, boundary_json, loop_state, status, working_context_json)
|
||||
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13, ?14, ?15, ?16, ?17, ?18, ?19)",
|
||||
params![
|
||||
rec.id, rec.title, rec.messages, rec.provider_id, rec.model, rec.models, rec.archived,
|
||||
if rec.pinned { 1i32 } else { 0i32 },
|
||||
rec.prompt_tokens, rec.completion_tokens,
|
||||
rec.pinned_goals, rec.pending_approvals, rec.created_at, rec.updated_at, rec.origin,
|
||||
rec.boundary_json, rec.loop_state, rec.status
|
||||
rec.boundary_json, rec.loop_state, rec.status, rec.working_context_json
|
||||
],
|
||||
)
|
||||
},
|
||||
@@ -207,13 +209,16 @@ impl_repo!(
|
||||
// update_full 的调用方(save_conversation 等)从 get_by_id 读旧记录再改元数据写回,
|
||||
// 读改写窗口内 loop 生命周期/终局态可变更——若 SET 含二者,旧值会覆盖新值(lost-update)。
|
||||
// 生命周期只经 set_loop_state / set_status 单点写。
|
||||
// working_context_json 在 SET 列(V53):与 pinned_goals/boundary_json 同为 save 每轮
|
||||
// 随行写的内存真相源快照,读改写窗口内无独立写入方,不存在 lost-update 面。
|
||||
conn.execute(
|
||||
"UPDATE ai_conversations SET title = ?1, messages = ?2, provider_id = ?3, model = ?4, models = ?5, archived = ?6, pinned = ?7, prompt_tokens = ?8, completion_tokens = ?9, pinned_goals = ?10, pending_approvals = ?11, updated_at = ?12, boundary_json = ?13 WHERE id = ?14",
|
||||
"UPDATE ai_conversations SET title = ?1, messages = ?2, provider_id = ?3, model = ?4, models = ?5, archived = ?6, pinned = ?7, prompt_tokens = ?8, completion_tokens = ?9, pinned_goals = ?10, pending_approvals = ?11, updated_at = ?12, boundary_json = ?13, working_context_json = ?14 WHERE id = ?15",
|
||||
params![
|
||||
rec.title, rec.messages, rec.provider_id, rec.model, rec.models, rec.archived,
|
||||
if rec.pinned { 1i32 } else { 0i32 },
|
||||
rec.prompt_tokens, rec.completion_tokens,
|
||||
rec.pinned_goals, rec.pending_approvals, rec.updated_at, rec.boundary_json, rec.id
|
||||
rec.pinned_goals, rec.pending_approvals, rec.updated_at, rec.boundary_json,
|
||||
rec.working_context_json, rec.id
|
||||
],
|
||||
)
|
||||
}
|
||||
@@ -1186,6 +1191,7 @@ mod tests {
|
||||
boundary_json: None,
|
||||
loop_state: None,
|
||||
status: "active".into(),
|
||||
working_context_json: None,
|
||||
})
|
||||
.await
|
||||
.expect("insert");
|
||||
@@ -1225,6 +1231,7 @@ mod tests {
|
||||
boundary_json: None,
|
||||
loop_state: None,
|
||||
status: "active".into(),
|
||||
working_context_json: None,
|
||||
})
|
||||
.await
|
||||
.expect("insert");
|
||||
@@ -1236,4 +1243,59 @@ mod tests {
|
||||
let got2 = repo.get_by_id("c1").await.expect("get").expect("row");
|
||||
assert_eq!(got2.status, "incomplete", "写 incomplete 应可读回");
|
||||
}
|
||||
|
||||
/// V53 契约:working_context_json 经 insert → get_by_id → update_full 全程原样存取
|
||||
/// (存储层按不透明 TEXT,结构解析归调用方);老库行(缺列插入)读回 None 不炸。
|
||||
#[tokio::test]
|
||||
async fn working_context_json_roundtrip_and_legacy_null() {
|
||||
let db = Database::open_in_memory().await.expect("open_in_memory");
|
||||
let repo = AiConversationRepo::new(&db);
|
||||
let wc_json = r#"{"goals":[{"id":"t1","text":"修登录bug","status":"Active"}],"decisions":[],"unresolved":["端口未定"],"current_step":null,"version":{"goals_changed_at":1,"decisions_changed_at":0,"unresolved_changed_at":0,"step_changed_at":0,"current_turn":1}}"#;
|
||||
|
||||
let mut rec = AiConversationRecord {
|
||||
id: "c1".into(),
|
||||
title: None,
|
||||
messages: "[]".into(),
|
||||
provider_id: None,
|
||||
model: None,
|
||||
models: None,
|
||||
archived: false,
|
||||
pinned: false,
|
||||
prompt_tokens: Some(0),
|
||||
completion_tokens: Some(0),
|
||||
pinned_goals: None,
|
||||
pending_approvals: None,
|
||||
created_at: "0".into(),
|
||||
updated_at: "0".into(),
|
||||
origin: None,
|
||||
boundary_json: None,
|
||||
loop_state: None,
|
||||
status: "active".into(),
|
||||
working_context_json: Some(wc_json.into()),
|
||||
};
|
||||
repo.insert(rec.clone()).await.expect("insert");
|
||||
let got = repo.get_by_id("c1").await.expect("get").expect("row");
|
||||
assert_eq!(got.working_context_json.as_deref(), Some(wc_json), "insert 后应原样读回");
|
||||
|
||||
// update_full 全量写(save_conversation 路径)后列随行更新
|
||||
rec.title = Some("t".into());
|
||||
rec.working_context_json = Some(r#"{"goals":[],"decisions":[],"unresolved":[],"current_step":null,"version":{"goals_changed_at":0,"decisions_changed_at":0,"unresolved_changed_at":0,"step_changed_at":0,"current_turn":0}}"#.into());
|
||||
repo.update_full(&rec).await.expect("update");
|
||||
let got2 = repo.get_by_id("c1").await.expect("get").expect("row");
|
||||
assert!(got2.working_context_json.as_deref().unwrap_or_default().contains(r#""goals":[]"#), "update_full 后应写新快照");
|
||||
|
||||
// 老库兼容:绕过 repo 直插不带该列的行(模拟 V53 前老数据)→ 读回 None
|
||||
{
|
||||
let conn = db.conn();
|
||||
let g = conn.lock().await;
|
||||
g.execute(
|
||||
"INSERT INTO ai_conversations (id, messages, created_at, updated_at, status) \
|
||||
VALUES ('legacy', '[]', '0', '0', 'active')",
|
||||
[],
|
||||
)
|
||||
.expect("raw insert legacy row");
|
||||
}
|
||||
let legacy = repo.get_by_id("legacy").await.expect("get").expect("legacy row");
|
||||
assert!(legacy.working_context_json.is_none(), "老库行应为 None(无 WorkingContext)");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -65,7 +65,9 @@ pub fn run(conn: &Connection) -> Result<()> {
|
||||
// V51 = 待发箱 ai_outbox 表(定时消息与待发箱持久化 P0 地基):队列/定时统一实体,
|
||||
// 调度器按 status+due_at 索引到点派发。纯新增表,可逆=DROP TABLE ai_outbox。
|
||||
// V52 = ai_messages.source 列(#958 消息溯源:外部渠道原始消息 ID JSON,NULL=桌面本端)。
|
||||
let steps: [(i32, fn(&Connection) -> Result<()>); 52] = [
|
||||
// V53 = WorkingContext 持久化(ai_conversations.working_context_json TEXT,跨会话召回 L4
|
||||
// 重启不失忆:save 随行写,会话载入与 parked 恢复读还原,NULL=无上下文行为同旧)。
|
||||
let steps: [(i32, fn(&Connection) -> Result<()>); 53] = [
|
||||
(1, migrate_v1),
|
||||
(2, migrate_v2),
|
||||
(3, migrate_v3),
|
||||
@@ -125,6 +127,8 @@ pub fn run(conn: &Connection) -> Result<()> {
|
||||
(51, migrate_v51),
|
||||
// V52 = ai_messages 加 source 列(#958 消息溯源:飞书等外部渠道原始消息 ID)。
|
||||
(52, migrate_v52),
|
||||
// V53 = WorkingContext 持久化(ai_conversations.working_context_json,跨会话召回 L4)。
|
||||
(53, migrate_v53),
|
||||
];
|
||||
|
||||
for (version, migrate_fn) in steps {
|
||||
@@ -1532,7 +1536,7 @@ fn migrate_v50(conn: &Connection) -> Result<()> {
|
||||
/// 调度器启动时 reset_stale_sending 复位为 pending。
|
||||
/// 迁移链顶点版本(单一真值来源):**新增迁移时必须同步 +1**,否则下方迁移测试会失败
|
||||
/// (测试用它断言「全量迁移后 schema_version == 顶点」「重跑仍到顶点」——既防链中断,也免多处写死)。
|
||||
pub const LATEST_SCHEMA_VERSION: i32 = 52;
|
||||
pub const LATEST_SCHEMA_VERSION: i32 = 53;
|
||||
|
||||
fn migrate_v51(conn: &Connection) -> Result<()> {
|
||||
if !table_exists(conn, "ai_outbox") {
|
||||
@@ -1584,6 +1588,27 @@ fn migrate_v52(conn: &Connection) -> Result<()> {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// V53: WorkingContext 持久化 — ai_conversations 加 working_context_json TEXT 列。
|
||||
///
|
||||
/// 跨会话召回 L4(重启不失忆):WorkingContext(自动目标/决策/未解项/当前步骤/脏标记版本)
|
||||
/// 此前纯内存,重启后压缩重锚定回填的决策与未解项全丢,恢复的会话半失忆续跑。本列随 save
|
||||
/// 每轮随行写、会话载入与 parked 恢复读还原;NULL=无上下文(老会话/全空),行为同旧。
|
||||
/// 目标快照裁决语义另批,本列只做存储载体。
|
||||
/// - **幂等**:column_exists 守卫(同 V44/V45/V49 模式),崩溃重跑/全链两遍不炸;
|
||||
/// - **可逆**:`ALTER TABLE ai_conversations DROP COLUMN working_context_json`(SQLite 3.35+)。
|
||||
fn migrate_v53(conn: &Connection) -> Result<()> {
|
||||
if !column_exists(conn, "ai_conversations", "working_context_json") {
|
||||
conn.execute(
|
||||
"ALTER TABLE ai_conversations ADD COLUMN working_context_json TEXT",
|
||||
[],
|
||||
)?;
|
||||
tracing::info!("v53: 补建 ai_conversations.working_context_json 列(WorkingContext 持久化)");
|
||||
}
|
||||
conn.execute("INSERT OR IGNORE INTO schema_version (version) VALUES (?)", [53])?;
|
||||
tracing::info!("迁移 v53 完成: ai_conversations 加 working_context_json 列");
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// 回填 conversation_checkpoints.token_total:按 snapshot JSON 内逐条消息的
|
||||
/// prompt_tokens+completion_tokens 求真实总量。
|
||||
///
|
||||
@@ -2951,4 +2976,51 @@ mod tests {
|
||||
"逐条相加,缺字段按 0"
|
||||
);
|
||||
}
|
||||
|
||||
/// V53 幂等:最小老库补建 working_context_json 列;重跑(含版本号回退模拟崩溃断点)
|
||||
/// 不炸不报 duplicate column;列可写可读(NULL 语义=无 WorkingContext)。
|
||||
#[test]
|
||||
fn v53_working_context_json_idempotent() {
|
||||
let conn = setup_legacy_db();
|
||||
assert!(
|
||||
!column_exists(&conn, "ai_conversations", "working_context_json"),
|
||||
"迁移前应无 working_context_json 列"
|
||||
);
|
||||
|
||||
migrate_v53(&conn).expect("v53 首次迁移应成功");
|
||||
assert!(column_exists(&conn, "ai_conversations", "working_context_json"), "应有 working_context_json 列");
|
||||
let v: i64 = conn
|
||||
.query_row("SELECT MAX(version) FROM schema_version", [], |r| r.get(0))
|
||||
.unwrap();
|
||||
assert_eq!(v, 53, "应写入版本号 53");
|
||||
|
||||
// 模拟崩溃断点:版本号回退但列已建 → 重跑走 column_exists 短路
|
||||
conn.execute("DELETE FROM schema_version WHERE version = 53", []).unwrap();
|
||||
migrate_v53(&conn).expect("列已存在时重跑应幂等跳过 ALTER 不报错");
|
||||
let v2: i64 = conn
|
||||
.query_row("SELECT MAX(version) FROM schema_version", [], |r| r.get(0))
|
||||
.unwrap();
|
||||
assert_eq!(v2, 53, "重跑后版本号恢复 53");
|
||||
|
||||
// 列可写可读;老会话行(不带该列插入)读回 NULL
|
||||
conn.execute(
|
||||
"INSERT INTO ai_conversations (id, messages, created_at, updated_at, working_context_json) \
|
||||
VALUES ('c1', '[]', '0', '0', '{\"goals\":[],\"decisions\":[]}')",
|
||||
[],
|
||||
)
|
||||
.unwrap();
|
||||
conn.execute(
|
||||
"INSERT INTO ai_conversations (id, messages, created_at, updated_at) VALUES ('c2', '[]', '0', '0')",
|
||||
[],
|
||||
)
|
||||
.unwrap();
|
||||
let got: Option<String> = conn
|
||||
.query_row("SELECT working_context_json FROM ai_conversations WHERE id = 'c1'", [], |r| r.get(0))
|
||||
.unwrap();
|
||||
assert!(got.is_some(), "working_context_json 应可写可读");
|
||||
let legacy: Option<String> = conn
|
||||
.query_row("SELECT working_context_json FROM ai_conversations WHERE id = 'c2'", [], |r| r.get(0))
|
||||
.unwrap();
|
||||
assert!(legacy.is_none(), "老会话行应为 NULL(无 WorkingContext)");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -400,6 +400,11 @@ pub struct AiConversationRecord {
|
||||
/// error=循环错误终局。与 loop_state(运行期 running/parked,管「能否续跑」)正交——
|
||||
/// 本列回答「会话如何结束」,供未完成率等统计。不进 update_full,走专用 set_status 单点写。
|
||||
pub status: String,
|
||||
/// WorkingContext 持久化(V53,跨会话召回 L4 重启不失忆):WorkingContext JSON 序列化,
|
||||
/// 存储层按不透明 TEXT 存取(结构归 df-ai)。NULL=无上下文(老会话/全空),行为同旧;
|
||||
/// save 随行写/会话载入与 parked 恢复读还原,随会话级联(对齐 boundary_json 模式)。
|
||||
#[serde(default)]
|
||||
pub working_context_json: Option<String>,
|
||||
}
|
||||
|
||||
/// AI 工具执行审计记录
|
||||
|
||||
Reference in New Issue
Block a user