修复: aichat上下文管理与信息组织P1批
逐件(证据:深查-aichat质量/信息UX-2026-10-17): - 恢复链套娃:RECOVERY_ARTIFACT_PREFIX双侧过滤+单点拦截(E1) - message_repo全量重写保留原created_at(E11,治1750条时间线污染) - search_files/grep 0命中附阴性存疑提示(E6,共享negative_hit_hint) - 项目清单三源活跃度排序+system prompt指纹对齐(E4跑题) - 会话列表活跃/归档offset分页+侧栏加载更多(F4-1,治50硬上限) - inject消息source标记(轮次分桶不误计发起,V52同源) - 提示词回复风格条款中英双语(反口水话,含测试断言) - i18n补键:复制/折叠量/发送title/历史定位提示(F1-1/F2-1/F6-1) 验收:cargo test --workspace 2281 passed 0 failed;vue-tsc EXIT 0
This commit is contained in:
@@ -265,6 +265,12 @@ impl AiMessageRepo {
|
||||
/// 全量重写语义——以入参 records 为该对话的**唯一真相**,先删该 conv 全部旧行再批量插。
|
||||
/// 单事务保证「删 + 插」原子,无中间空窗(reload 不会读到半删半插的中间态)。
|
||||
///
|
||||
/// created_at 保出生时间(2026-10-17 修复批量污染):created_at 语义 = 消息首次入库时间,
|
||||
/// 全量重写只该变内容不变出生时间。调用方(save_conversation)对整批 records 统一盖
|
||||
/// 对话级 created_at(老对话 None 时 now 兜底),直插会把全对话消息 created_at 抹成同一秒。
|
||||
/// 故 DELETE 前先取存量 `id → created_at` 映射,重插时同 id 行回填原值;新 id(真新消息)
|
||||
/// 保留入参值。ai_messages 表无 updated_at 列,内容变更无单独时间戳(不在此列语义内)。
|
||||
///
|
||||
/// 设计权衡(非 dirty 增量,留 P2.1 优化):
|
||||
/// - 内存 ContextManager 是运行时真相源,save 是同步点,每轮 save 全量回写简单可靠;
|
||||
/// - INSERT OR IGNORE 幂等:records 内 id 重复或与残留行(理论不应有,事务已 DELETE)冲突跳过;
|
||||
@@ -274,7 +280,7 @@ impl AiMessageRepo {
|
||||
pub async fn replace_conversation(
|
||||
&self,
|
||||
conv_id: &str,
|
||||
records: Vec<AiMessageRecord>,
|
||||
mut records: Vec<AiMessageRecord>,
|
||||
) -> Result<()> {
|
||||
let conn = self.conn.clone();
|
||||
let conv_id = conv_id.to_owned();
|
||||
@@ -282,7 +288,29 @@ impl AiMessageRepo {
|
||||
let mut guard = conn.blocking_lock();
|
||||
let tx = guard.transaction().map_err(storage_err)?;
|
||||
{
|
||||
// 先删该 conv 全部旧行(全量重写语义)
|
||||
// 先取存量 id → created_at 映射(DELETE 前留证,重插回填保出生时间)
|
||||
{
|
||||
let mut stmt = tx
|
||||
.prepare("SELECT id, created_at FROM ai_messages WHERE conversation_id = ?1")
|
||||
.map_err(storage_err)?;
|
||||
let rows = stmt
|
||||
.query_map(params![conv_id], |r| {
|
||||
Ok((r.get::<_, String>("id")?, r.get::<_, String>("created_at")?))
|
||||
})
|
||||
.map_err(storage_err)?;
|
||||
let mut original: std::collections::HashMap<String, String> =
|
||||
std::collections::HashMap::new();
|
||||
for r in rows {
|
||||
let (id, created_at) = r.map_err(storage_err)?;
|
||||
original.insert(id, created_at);
|
||||
}
|
||||
for rec in &mut records {
|
||||
if let Some(orig) = original.get(&rec.id) {
|
||||
rec.created_at = orig.clone();
|
||||
}
|
||||
}
|
||||
}
|
||||
// 再删该 conv 全部旧行(全量重写语义)
|
||||
tx.execute(
|
||||
"DELETE FROM ai_messages WHERE conversation_id = ?1",
|
||||
params![conv_id],
|
||||
@@ -693,6 +721,62 @@ mod tests {
|
||||
assert_eq!(got.len(), 1, "重复 replace 不应叠加");
|
||||
}
|
||||
|
||||
/// replace_conversation 保 created_at 出生时间(2026-10-17 批量污染回归):
|
||||
/// 调用方对整批统一盖一个 created_at(如对话级时间或 now),重写后既有 id 行
|
||||
/// 的 created_at 必须逐行保留原入库值,不得被统一时间抹平;新 id 行保留入参值。
|
||||
#[tokio::test]
|
||||
async fn replace_conversation_preserves_created_at() {
|
||||
let db = Database::open_in_memory().await.expect("open_in_memory");
|
||||
let repo = AiMessageRepo::new(&db);
|
||||
|
||||
// 预置两行,created_at 各异(模拟真实逐条入库的出生时间)
|
||||
let mut m0 = mk_msg("m0", "c", 0, "user", "v1");
|
||||
m0.created_at = "2026-10-01T10:00:00.000".into();
|
||||
let mut m1 = mk_msg("m1", "c", 1, "assistant", "v1");
|
||||
m1.created_at = "2026-10-02T11:30:00.000".into();
|
||||
repo.insert_batch(vec![m0, m1]).await.expect("insert");
|
||||
|
||||
// 重写:既有 id 内容变了、created_at 被调用方统一盖成同一时间;
|
||||
// 另带一条新 id 消息(真新消息,该保留入参时间)。
|
||||
let uniform_ts = "2026-10-17T21:25:01.000";
|
||||
let rewrite = |id: &str, seq: i64, role: &str, content: &str| {
|
||||
let mut m = mk_msg(id, "c", seq, role, content);
|
||||
m.created_at = uniform_ts.into();
|
||||
m
|
||||
};
|
||||
repo.replace_conversation(
|
||||
"c",
|
||||
vec![
|
||||
rewrite("m0", 0, "user", "v2-改写内容"),
|
||||
rewrite("m1", 1, "assistant", "v2-改写内容"),
|
||||
rewrite("m2_new", 2, "user", "新消息"),
|
||||
],
|
||||
)
|
||||
.await
|
||||
.expect("replace");
|
||||
|
||||
let got = repo.list_by_conversation("c").await.expect("list");
|
||||
assert_eq!(got.len(), 3);
|
||||
// 逐行断言:既有 id 的 created_at 保持原值,内容确实变了
|
||||
assert_eq!(got[0].id, "m0");
|
||||
assert_eq!(got[0].created_at, "2026-10-01T10:00:00.000", "既有行 created_at 不得被重写抹平");
|
||||
assert_eq!(got[0].content, "v2-改写内容");
|
||||
assert_eq!(got[1].id, "m1");
|
||||
assert_eq!(got[1].created_at, "2026-10-02T11:30:00.000", "既有行 created_at 不得被重写抹平");
|
||||
assert_eq!(got[1].content, "v2-改写内容");
|
||||
// 新 id(首次入库)保留入参 created_at
|
||||
assert_eq!(got[2].id, "m2_new");
|
||||
assert_eq!(got[2].created_at, uniform_ts, "新消息保留入参 created_at");
|
||||
|
||||
// 二次重写再验:出生时间已固化,连续重写不漂移
|
||||
repo.replace_conversation("c", vec![rewrite("m0", 0, "user", "v3")])
|
||||
.await
|
||||
.expect("replace again");
|
||||
let got = repo.list_by_conversation("c").await.expect("list");
|
||||
assert_eq!(got.len(), 1);
|
||||
assert_eq!(got[0].created_at, "2026-10-01T10:00:00.000", "连续重写 created_at 不漂移");
|
||||
}
|
||||
|
||||
/// limit=0 契约:返空集(直传 SQLite LIMIT 0),不再旧版 max(1) 钳成 1 条。
|
||||
/// 锁死语义防回归:请求 0 条却回 1 条会让调用方误判页内有数据。
|
||||
#[tokio::test]
|
||||
|
||||
Reference in New Issue
Block a user