优化: 上下文管理与提示词链路批
- T1 usage 口径: anthropic usage 两层容错+cache 三口径归一(prompt=input+read+creation) - T2 压缩水位判据: compress_should_trigger 双模式+ratio 钳位,开关关逐字节等价 - T3 跨会话检索: history_search 工具(两跳式)+message_repo 关键词检索+prompt 指引 - T4 代语义闭环: status 终局补写+飞书停靠/换代推卡+交付引导+watcher 兜底补漏 - 段序缓存重排: build_runtime_prompt 恒静段上移/工作上下文压尾(前缀缓存失效面收窄) - 智谱缓存解析: anthropic 臂补 prompt_tokens_details.cached_tokens 嵌套回退 - 工时禁令: 提示词双语加「禁止预估工时,AI Coding 工时无意义」 - 拖拽文件落盘: 非图片附件存引用行,防整段文本灌入输入框 - 事实修正: 提示词「40+ 工具」→「80+」(基线 81);tool_failure_memory 测试串行锁除竞态 - UI 拆分: TopBar/ToolResultBody 壳层化(top-bar/tool-result-body 子件目录)
This commit is contained in:
@@ -705,8 +705,16 @@ impl AiConversationRepo {
|
||||
///
|
||||
/// A2-B9(G3.2 clearChat 裁决):原 `clear_messages` + `delete_range` 两条独立 DB 写非原子,
|
||||
/// DB 失败会致 messages JSON 列与 ai_messages 表不一致(如仅一条成功)。本方法一次 transaction
|
||||
/// 覆盖两条写(① UPDATE ai_conversations 置空消息 + 清零 token;② DELETE ai_messages 该 conv
|
||||
/// 全部行),成功全成功 / 失败回滚全失败。供 `ai_chat_clear` 先停 loop 再单事务清空。
|
||||
/// 覆盖两条写(① UPDATE ai_conversations 置空消息 + 清零 token + 生命周期标记归位;
|
||||
/// ② DELETE ai_messages 该 conv 全部行),成功全成功 / 失败回滚全失败。供 `ai_chat_clear`
|
||||
/// 先停 loop 再单事务清空。
|
||||
///
|
||||
/// 生命周期标记归位(P1-B 方案甲代史收尾,2026-10-21):硬清空=被清代的终局,对话壳虽
|
||||
/// 保留但属全新一代,旧代终局语义必须随事务归位防泄漏给同壳新任务——loop_state→NULL
|
||||
/// (被清的 parked 会话不得再进 list_parked 可恢复清单)、status→'active'(旧 completed/
|
||||
/// incomplete/error 不再代表新代)、boundary_json→NULL(V45「随会话同亡」:旧任务边界
|
||||
/// 不得约束同壳新任务;内存侧 per_conv.boundary 由调用方同步清,防下轮 save 随行覆写
|
||||
/// 复活 DB 侧刚清的快照)。
|
||||
///
|
||||
/// 对话壳保留(侧栏仍可见,可继续在该对话内聊);返回 Ok(())——调用方只关心成功与否
|
||||
/// (对齐 replace_conversation 语义,不返回受影响行数)。
|
||||
@@ -718,9 +726,9 @@ impl AiConversationRepo {
|
||||
let mut guard = conn.blocking_lock();
|
||||
let tx = guard.transaction().map_err(storage_err)?;
|
||||
{
|
||||
// ① ai_conversations.messages 置空 + token 清零(对话壳保留)
|
||||
// ① ai_conversations.messages 置空 + token 清零 + 生命周期标记归位(对话壳保留)
|
||||
tx.execute(
|
||||
"UPDATE ai_conversations SET messages = '[]', prompt_tokens = 0, completion_tokens = 0, updated_at = ?1 WHERE id = ?2",
|
||||
"UPDATE ai_conversations SET messages = '[]', prompt_tokens = 0, completion_tokens = 0, loop_state = NULL, status = 'active', boundary_json = NULL, updated_at = ?1 WHERE id = ?2",
|
||||
params![now, id],
|
||||
)
|
||||
.map_err(storage_err)?;
|
||||
@@ -1244,6 +1252,47 @@ mod tests {
|
||||
assert_eq!(got2.status, "incomplete", "写 incomplete 应可读回");
|
||||
}
|
||||
|
||||
/// P1-B 方案甲代史收尾契约(2026-10-21):硬清空=被清代的终局,生命周期标记必须随
|
||||
/// 同一事务归位——loop_state→NULL(被清的 parked 会话不得再进可恢复清单)、status→
|
||||
/// 'active'(旧终局态不得泄漏给同壳新任务)、boundary_json→NULL(旧任务边界随代同亡,
|
||||
/// 不得约束同壳新任务)。锁死 clear_conversation_atomic 的收尾语义(调用方 ai_chat_clear)。
|
||||
#[tokio::test]
|
||||
async fn clear_conversation_atomic_resets_lifecycle_markers() {
|
||||
let db = Database::open_in_memory().await.expect("open_in_memory");
|
||||
let repo = AiConversationRepo::new(&db);
|
||||
// 前置态:带旧边界 + parked + incomplete 的非空会话(清空前的典型停靠残留)
|
||||
repo.insert(AiConversationRecord {
|
||||
id: "c1".into(),
|
||||
title: None,
|
||||
messages: r#"[{"role":"user","content":"旧任务"}]"#.into(),
|
||||
provider_id: None,
|
||||
model: None,
|
||||
models: None,
|
||||
archived: false,
|
||||
pinned: false,
|
||||
prompt_tokens: Some(120),
|
||||
completion_tokens: Some(80),
|
||||
pinned_goals: None,
|
||||
pending_approvals: None,
|
||||
created_at: "0".into(),
|
||||
updated_at: "0".into(),
|
||||
origin: None,
|
||||
boundary_json: Some(r#"{"risk_ceiling":"Medium"}"#.into()),
|
||||
loop_state: Some("parked".into()),
|
||||
status: "incomplete".into(),
|
||||
working_context_json: None,
|
||||
})
|
||||
.await
|
||||
.expect("insert");
|
||||
|
||||
repo.clear_conversation_atomic("c1").await.expect("clear");
|
||||
let got = repo.get_by_id("c1").await.expect("get").expect("row");
|
||||
assert_eq!(got.loop_state, None, "清空后 loop_state 应归位 NULL(停靠残留不得进可恢复清单)");
|
||||
assert_eq!(got.status, "active", "清空后 status 应归位 active(旧代终局不得泄漏)");
|
||||
assert_eq!(got.boundary_json, None, "清空后 boundary_json 应随代同亡");
|
||||
assert_eq!(got.messages, "[]", "消息内容照旧清空");
|
||||
}
|
||||
|
||||
/// V53 契约:working_context_json 经 insert → get_by_id → update_full 全程原样存取
|
||||
/// (存储层按不透明 TEXT,结构解析归调用方);老库行(缺列插入)读回 None 不炸。
|
||||
#[tokio::test]
|
||||
|
||||
@@ -10,6 +10,7 @@
|
||||
//! P0 阶段(本文件):仅建 Repo + CRUD 方法,不接 ContextManager 读写路径
|
||||
//! (切读策略待用户决策,P2)。ContextManager 仍走旧 messages JSON 列双写期未启动。
|
||||
|
||||
use std::collections::HashMap;
|
||||
use std::sync::Arc;
|
||||
|
||||
use rusqlite::{params, Connection, Row};
|
||||
@@ -20,6 +21,7 @@ use df_types::error::Result;
|
||||
use crate::db::Database;
|
||||
use crate::models::AiMessageRecord;
|
||||
|
||||
use super::idea_repo::escape_like;
|
||||
use super::storage_err;
|
||||
|
||||
// ============================================================
|
||||
@@ -51,6 +53,72 @@ fn ai_message_from_row(row: &Row<'_>) -> std::result::Result<AiMessageRecord, ru
|
||||
})
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// 跨会话关键词检索(P1-A,对标 u-claw #5/#6)
|
||||
// ============================================================
|
||||
|
||||
/// LIKE 检索不做通配模糊,关键词为分词产物(调用方 src-tauri 层 extract_query_keywords
|
||||
/// 分好传入,本层不做分词——df-storage 在依赖下层,反向依赖 src-tauri 会成环)。
|
||||
///
|
||||
/// **FTS5 升级触发条件(2026-10-21 拍板留档)**:ai_messages 行数 > 50 万,或 role 过滤后
|
||||
/// LIKE 检索 P95 实测 > 500ms 时,再评估升级 FTS5 虚表(V54+ 建虚表 + 一次性回填,
|
||||
/// 检索接口签名不变)。当前生产库实测(32,349 行 / content 39.5MB):role 过滤后单关键词
|
||||
/// LIKE 113ms、双关键词 127ms,桌面场景百毫秒级无体感;且中文 unicode61 按字切分召回
|
||||
/// 质量反不如子串 LIKE,仓内 idea_repo/conversation_repo 两处既有「不上 FTS5」决策先例。
|
||||
|
||||
/// 关键词检索单条命中。
|
||||
///
|
||||
/// `snippet` 为命中片段:content 中首个关键词命中点前后各约 200 字,整体截 500 字
|
||||
/// (截断处以 … 标记),避免全文回灌撑爆调用方上下文。
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct MessageSearchHit {
|
||||
pub message_id: String,
|
||||
pub conversation_id: String,
|
||||
pub seq: i64,
|
||||
pub role: String,
|
||||
pub timestamp: Option<i64>,
|
||||
pub snippet: String,
|
||||
}
|
||||
|
||||
/// 会话摘要降级链数据源(无参列会话时逐会话取用)。
|
||||
///
|
||||
/// `last_system` = 该会话最近一条 role=system 消息(压缩摘要/关键词锚点的载体,
|
||||
/// 四段式「## 意图/## 决策…」);`first_user` = 首条 user 消息。调用方按
|
||||
/// 「最近 system 摘要 > 首条 user」降级链取会话概要,两级皆空(空会话)则无摘要。
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct ConversationSummarySource {
|
||||
pub last_system: Option<String>,
|
||||
pub first_user: Option<String>,
|
||||
}
|
||||
|
||||
/// 命中片段窗口:命中点前保留上下文与片段总长(按字符计,中文一字一符)。
|
||||
const SNIPPET_CONTEXT_CHARS: usize = 200;
|
||||
const SNIPPET_MAX_CHARS: usize = 500;
|
||||
|
||||
/// 从 content 提取命中片段:首个关键词命中点前后各约 200 字,整体截 500 字。
|
||||
///
|
||||
/// 大小写不敏感定位(与 SQLite LIKE 的 ASCII 大小写不敏感对齐);分词产物在 LIKE 层
|
||||
/// 已命中,理论必有命中点,兜底未找到时退回首部截断。全程按字符迭代操作,无字节
|
||||
/// 索引切边界的乱码风险。
|
||||
fn search_snippet(content: &str, keywords: &[String]) -> String {
|
||||
let ellipsis = |s: &str, head_cut: bool, tail_cut: bool| {
|
||||
format!("{}{}{}", if head_cut { "…" } else { "" }, s, if tail_cut { "…" } else { "" })
|
||||
};
|
||||
let lower = content.to_lowercase();
|
||||
let hit_char = keywords.iter().find_map(|kw| lower.find(&kw.to_lowercase())).map(|byte| {
|
||||
// 命中字节位 → 字符位(to_lowercase 极端 Unicode 下字符数可能漂移,
|
||||
// 但仅影响窗口中心偏移,chars().skip 的迭代本身无越界风险)
|
||||
lower[..byte].chars().count()
|
||||
});
|
||||
let Some(hit_char) = hit_char else {
|
||||
let head: String = content.chars().take(SNIPPET_MAX_CHARS).collect();
|
||||
return ellipsis(&head, false, content.chars().count() > SNIPPET_MAX_CHARS);
|
||||
};
|
||||
let start = hit_char.saturating_sub(SNIPPET_CONTEXT_CHARS);
|
||||
let body: String = content.chars().skip(start).take(SNIPPET_MAX_CHARS).collect();
|
||||
ellipsis(&body, start > 0, content.chars().count() > start + SNIPPET_MAX_CHARS)
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// AiMessageRepo
|
||||
// ============================================================
|
||||
@@ -375,6 +443,156 @@ impl AiMessageRepo {
|
||||
.await
|
||||
.map_err(storage_err)?
|
||||
}
|
||||
|
||||
/// 跨会话关键词检索(P1-A):LIKE OR 逐词命中任一即入候选,timestamp DESC,limit/offset 分页。
|
||||
///
|
||||
/// - 分词在调用方(src-tauri 层 extract_query_keywords)完成,本层只收关键词数组,
|
||||
/// 不反向依赖 src-tauri(依赖方向铁律,对齐 KnowledgeRepo.search_keywords_scoped 先例);
|
||||
/// - role 恒过滤 user+assistant+system(拍板④:tool 结果占内容量 86% 且噪音大,默认排除);
|
||||
/// - `conversation_id` 传入时限定单会话(两跳式深查第二跳);
|
||||
/// - LIKE 转义与 `ESCAPE '|'` 复用 [`super::idea_repo::escape_like`] 单一实现,
|
||||
/// 关键词含 %/_/| 时按字面匹配;
|
||||
/// - 返回带命中片段(500 字截断,见 [`search_snippet`]),全文不出存储层;
|
||||
/// - status 不过滤:compressed 消息原文完整保留(ai_messages = 全量账本),照常可检索。
|
||||
///
|
||||
/// FTS5 升级触发条件见模块顶部注释(留档,>50 万行或 P95>500ms 再评估)。
|
||||
pub async fn search_keywords(
|
||||
&self,
|
||||
keywords: &[String],
|
||||
conversation_id: Option<&str>,
|
||||
limit: usize,
|
||||
offset: usize,
|
||||
) -> Result<Vec<MessageSearchHit>> {
|
||||
if keywords.is_empty() {
|
||||
return Ok(Vec::new());
|
||||
}
|
||||
let conn = self.conn.clone();
|
||||
let conv_id = conversation_id.map(|s| s.to_owned());
|
||||
// 每个关键词占一个编号占位符;conversation_id 占 ?(n+1),LIMIT/OFFSET 依次后推。
|
||||
// 动态 WHERE 拼接模式对齐 AiToolExecutionRepo::list_by_query 先例。
|
||||
let mut patterns: Vec<String> = Vec::with_capacity(keywords.len());
|
||||
let mut where_parts: Vec<String> = Vec::with_capacity(keywords.len());
|
||||
for (i, kw) in keywords.iter().enumerate() {
|
||||
let n = i + 1;
|
||||
patterns.push(format!("%{}%", escape_like(kw)));
|
||||
where_parts.push(format!("content LIKE ?{n} ESCAPE '|'"));
|
||||
}
|
||||
let mut extra: Vec<String> = Vec::new();
|
||||
if let Some(c) = &conv_id {
|
||||
extra.push(c.clone());
|
||||
}
|
||||
// 编号必须连续(SQLite 参数计数取最大 ?N,跳号会产生未绑定空洞报错):
|
||||
// 无 conv 条件时 LIMIT/OFFSET 紧跟关键词占位符之后。
|
||||
let conv_n = patterns.len() + 1;
|
||||
let (limit_n, offset_n) = if conv_id.is_some() {
|
||||
(conv_n + 1, conv_n + 2)
|
||||
} else {
|
||||
(conv_n, conv_n + 1)
|
||||
};
|
||||
let limit_i = limit as i64;
|
||||
let offset_i = offset as i64;
|
||||
let conv_clause = if conv_id.is_some() {
|
||||
format!(" AND conversation_id = ?{conv_n}")
|
||||
} else {
|
||||
String::new()
|
||||
};
|
||||
let sql = format!(
|
||||
"SELECT id, conversation_id, seq, role, timestamp, content FROM ai_messages \
|
||||
WHERE role IN ('user','assistant','system'){conv_clause} AND ({}) \
|
||||
ORDER BY timestamp DESC LIMIT ?{limit_n} OFFSET ?{offset_n}",
|
||||
where_parts.join(" OR "),
|
||||
);
|
||||
let keywords_owned: Vec<String> = keywords.to_vec();
|
||||
tokio::task::spawn_blocking(move || {
|
||||
let guard = conn.blocking_lock();
|
||||
let mut stmt = guard.prepare(&sql).map_err(storage_err)?;
|
||||
// 参数序列:关键词 pattern × n [+ conv_id] + limit + offset
|
||||
let mut param_refs: Vec<&dyn rusqlite::ToSql> =
|
||||
patterns.iter().map(|p| p as &dyn rusqlite::ToSql).collect();
|
||||
for e in &extra {
|
||||
param_refs.push(e);
|
||||
}
|
||||
param_refs.push(&limit_i);
|
||||
param_refs.push(&offset_i);
|
||||
let rows = stmt
|
||||
.query_map(param_refs.as_slice(), |row| {
|
||||
let content: String = row.get("content")?;
|
||||
Ok(MessageSearchHit {
|
||||
message_id: row.get("id")?,
|
||||
conversation_id: row.get("conversation_id")?,
|
||||
seq: row.get("seq")?,
|
||||
role: row.get("role")?,
|
||||
timestamp: row.get("timestamp")?,
|
||||
snippet: search_snippet(&content, &keywords_owned),
|
||||
})
|
||||
})
|
||||
.map_err(storage_err)?;
|
||||
let mut results = Vec::new();
|
||||
for r in rows {
|
||||
results.push(r.map_err(storage_err)?);
|
||||
}
|
||||
Ok(results)
|
||||
})
|
||||
.await
|
||||
.map_err(storage_err)?
|
||||
}
|
||||
|
||||
/// 会话摘要降级链数据源(无参列最近会话用):一批会话各取「最近一条 system 消息」与
|
||||
/// 「首条 user 消息」content,逐会话单行返回(无消息的会话无行,调用方按无摘要兜底)。
|
||||
///
|
||||
/// 关联子查询走 idx_ai_messages_conv(conversation_id, seq) 索引,批量一次查询替代
|
||||
/// 逐会话 list_by_conversation 全量物化(276 条/会话均值,10 会话省 ~340KB 物化)。
|
||||
pub async fn summary_sources(
|
||||
&self,
|
||||
conversation_ids: &[String],
|
||||
) -> Result<HashMap<String, ConversationSummarySource>> {
|
||||
if conversation_ids.is_empty() {
|
||||
return Ok(HashMap::new());
|
||||
}
|
||||
let conn = self.conn.clone();
|
||||
// IN 占位符逐个编号(?1..?n)
|
||||
let placeholders: Vec<String> =
|
||||
(1..=conversation_ids.len()).map(|n| format!("?{n}")).collect();
|
||||
let sql = format!(
|
||||
"SELECT m.conversation_id, \
|
||||
(SELECT s.content FROM ai_messages s \
|
||||
WHERE s.conversation_id = m.conversation_id AND s.role = 'system' \
|
||||
ORDER BY s.seq DESC LIMIT 1) AS last_system, \
|
||||
(SELECT u.content FROM ai_messages u \
|
||||
WHERE u.conversation_id = m.conversation_id AND u.role = 'user' \
|
||||
ORDER BY u.seq ASC LIMIT 1) AS first_user \
|
||||
FROM ai_messages m \
|
||||
WHERE m.conversation_id IN ({}) \
|
||||
GROUP BY m.conversation_id",
|
||||
placeholders.join(","),
|
||||
);
|
||||
let ids: Vec<String> = conversation_ids.to_vec();
|
||||
tokio::task::spawn_blocking(move || {
|
||||
let guard = conn.blocking_lock();
|
||||
let mut stmt = guard.prepare(&sql).map_err(storage_err)?;
|
||||
let param_refs: Vec<&dyn rusqlite::ToSql> =
|
||||
ids.iter().map(|s| s as &dyn rusqlite::ToSql).collect();
|
||||
let rows = stmt
|
||||
.query_map(param_refs.as_slice(), |row| {
|
||||
Ok((
|
||||
row.get::<_, String>("conversation_id")?,
|
||||
ConversationSummarySource {
|
||||
last_system: row.get("last_system")?,
|
||||
first_user: row.get("first_user")?,
|
||||
},
|
||||
))
|
||||
})
|
||||
.map_err(storage_err)?;
|
||||
let mut out = HashMap::new();
|
||||
for r in rows {
|
||||
let (id, source) = r.map_err(storage_err)?;
|
||||
out.insert(id, source);
|
||||
}
|
||||
Ok(out)
|
||||
})
|
||||
.await
|
||||
.map_err(storage_err)?
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
@@ -801,4 +1019,137 @@ mod tests {
|
||||
assert_eq!(got3.len(), 1);
|
||||
assert_eq!(got3[0].seq, 1, "limit=1 应取 seq 最大(最新)一条");
|
||||
}
|
||||
|
||||
// ---------- search_keywords(P1-A 跨会话检索) ----------
|
||||
|
||||
/// role 过滤(tool 默认排除)+ timestamp DESC 排序 + conversation_id 限定深查。
|
||||
#[tokio::test]
|
||||
async fn search_keywords_filters_role_and_orders_desc() {
|
||||
let db = Database::open_in_memory().await.expect("open_in_memory");
|
||||
let repo = AiMessageRepo::new(&db);
|
||||
let mut u0 = mk_msg("u0", "conv_a", 0, "user", "怎么部署服务");
|
||||
u0.timestamp = Some(1000);
|
||||
let mut a0 = mk_msg("a0", "conv_a", 1, "assistant", "部署 分三步走");
|
||||
a0.timestamp = Some(2000);
|
||||
let mut hit_tool = mk_msg("t0", "conv_a", 2, "tool", "部署流程的 tool 结果");
|
||||
hit_tool.timestamp = Some(3000);
|
||||
let mut u1 = mk_msg("u1", "conv_b", 0, "user", "旧会话也提过部署");
|
||||
u1.timestamp = Some(500);
|
||||
repo.insert_batch(vec![u0, a0, hit_tool, u1])
|
||||
.await
|
||||
.expect("insert");
|
||||
|
||||
// tool role 默认排除:命中 3 条(两 conv 的 user/assistant),tool 结果不入检索面
|
||||
let hits = repo
|
||||
.search_keywords(&["部署".to_string()], None, 20, 0)
|
||||
.await
|
||||
.expect("search");
|
||||
assert_eq!(hits.len(), 3, "tool role 应被排除: {:?}", hits.iter().map(|h| &h.role).collect::<Vec<_>>());
|
||||
// timestamp DESC:conv_a(3000 之外的 2000/1000 应在 conv_b 100 之前)
|
||||
assert_eq!(hits[0].message_id, "a0");
|
||||
assert_eq!(hits[1].message_id, "u0");
|
||||
|
||||
// conversation_id 限定:只回该会话命中(两跳式深查第二跳)
|
||||
let scoped = repo
|
||||
.search_keywords(&["部署".to_string()], Some("conv_b"), 20, 0)
|
||||
.await
|
||||
.expect("search scoped");
|
||||
assert_eq!(scoped.len(), 1);
|
||||
assert_eq!(scoped[0].conversation_id, "conv_b");
|
||||
|
||||
// offset 分页:跳过第一条
|
||||
let paged = repo
|
||||
.search_keywords(&["部署".to_string()], None, 20, 1)
|
||||
.await
|
||||
.expect("search paged");
|
||||
assert_eq!(paged.len(), 2);
|
||||
assert_eq!(paged[0].message_id, "u0");
|
||||
}
|
||||
|
||||
/// LIKE 通配符按字面匹配(%/_ 不扩大命中面)+ 命中片段以关键词为中心截 500 字。
|
||||
#[tokio::test]
|
||||
async fn search_keywords_escapes_wildcards_and_centers_snippet() {
|
||||
let db = Database::open_in_memory().await.expect("open_in_memory");
|
||||
let repo = AiMessageRepo::new(&db);
|
||||
let long_prefix = "前".repeat(600); // 命中点前 600 字:窗口应前推 200 字且带 …
|
||||
let long_tail = "后".repeat(600);
|
||||
repo.insert_batch(vec![
|
||||
mk_msg("m0", "c", 0, "user", &format!("{long_prefix}配额100%封顶{long_tail}")),
|
||||
mk_msg("m1", "c", 1, "user", "配额100x封顶,百分号不应误命中"),
|
||||
mk_msg("m2", "c", 2, "user", "变量名 quota_limit 带下划线"),
|
||||
mk_msg("m3", "c", 3, "user", "quotaXlimit 下划线不该当通配命中"),
|
||||
])
|
||||
.await
|
||||
.expect("insert");
|
||||
|
||||
// % 按字面:只命中 m0,不命中 100x
|
||||
let pct = repo
|
||||
.search_keywords(&["100%".to_string()], None, 20, 0)
|
||||
.await
|
||||
.expect("search %");
|
||||
assert_eq!(pct.len(), 1, "100% 不应通配命中 100x: {:?}", pct.iter().map(|h| &h.message_id).collect::<Vec<_>>());
|
||||
assert_eq!(pct[0].message_id, "m0");
|
||||
|
||||
// _ 按字面:只命中 quota_limit,不命中 quotaXlimit
|
||||
let under = repo
|
||||
.search_keywords(&["quota_limit".to_string()], None, 20, 0)
|
||||
.await
|
||||
.expect("search _");
|
||||
assert_eq!(under.len(), 1);
|
||||
assert_eq!(under[0].message_id, "m2");
|
||||
|
||||
// 片段:命中点前约 200 字,总长 500+省略号,两端截断带 …
|
||||
let snip = &pct[0].snippet;
|
||||
assert!(snip.contains('…'), "两端截断应带省略号: {snip}");
|
||||
assert!(snip.contains("100%封顶"), "片段应以命中点为中心: {snip}");
|
||||
let body = snip.trim_matches('…');
|
||||
assert!(
|
||||
body.chars().count() <= 500,
|
||||
"片段正文应截 500 字,实际 {}",
|
||||
body.chars().count()
|
||||
);
|
||||
assert!(snip.starts_with('…'), "命中点前 600 字,片段应带前省略号");
|
||||
}
|
||||
|
||||
/// 空关键词防御:直返空集不发 SQL(LIKE %% 恒真会全表命中)。
|
||||
#[tokio::test]
|
||||
async fn search_keywords_empty_keywords_returns_empty() {
|
||||
let db = Database::open_in_memory().await.expect("open_in_memory");
|
||||
let repo = AiMessageRepo::new(&db);
|
||||
repo.insert_batch(vec![mk_msg("m0", "c", 0, "user", "任意内容")])
|
||||
.await
|
||||
.expect("insert");
|
||||
let hits = repo.search_keywords(&[], None, 20, 0).await.expect("search empty");
|
||||
assert!(hits.is_empty(), "空关键词应返空集");
|
||||
}
|
||||
|
||||
// ---------- summary_sources(摘要降级链数据源) ----------
|
||||
|
||||
/// 每会话取「最近一条 system」与「首条 user」;无消息会话无行;批量一次查询。
|
||||
#[tokio::test]
|
||||
async fn summary_sources_takes_last_system_and_first_user() {
|
||||
let db = Database::open_in_memory().await.expect("open_in_memory");
|
||||
let repo = AiMessageRepo::new(&db);
|
||||
repo.insert_batch(vec![
|
||||
mk_msg("u0", "c1", 0, "user", "首个问题"),
|
||||
mk_msg("a0", "c1", 1, "assistant", "回复"),
|
||||
mk_msg("s0", "c1", 2, "system", "旧锚点"),
|
||||
mk_msg("s1", "c1", 3, "system", "## 意图\n- 最新压缩摘要"),
|
||||
mk_msg("u1", "c2", 0, "user", "c2 的唯一用户消息"),
|
||||
])
|
||||
.await
|
||||
.expect("insert");
|
||||
|
||||
let out = repo
|
||||
.summary_sources(&["c1".to_string(), "c2".to_string(), "c3".to_string()])
|
||||
.await
|
||||
.expect("summary_sources");
|
||||
assert_eq!(out.len(), 2, "无消息的 c3 不应有行");
|
||||
let c1 = &out["c1"];
|
||||
assert_eq!(c1.last_system.as_deref(), Some("## 意图\n- 最新压缩摘要"), "应取 seq 最大的 system");
|
||||
assert_eq!(c1.first_user.as_deref(), Some("首个问题"));
|
||||
let c2 = &out["c2"];
|
||||
assert!(c2.last_system.is_none(), "无 system 消息应返 None(降级链走首条 user)");
|
||||
assert_eq!(c2.first_user.as_deref(), Some("c2 的唯一用户消息"));
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user