diff --git a/crates/df-storage/src/crud/idea_repo.rs b/crates/df-storage/src/crud/idea_repo.rs index 90d9926..d671440 100644 --- a/crates/df-storage/src/crud/idea_repo.rs +++ b/crates/df-storage/src/crud/idea_repo.rs @@ -1178,6 +1178,74 @@ impl KnowledgeRepo { .await .map_err(storage_err)? } + + /// 知识路标读侧(devflow.md 投影通道):已发布且(人工验证 或 复用达标)的条目选摘。 + /// + /// 与检索族(search/search_keywords)的差别:路标无查询词,是「库里有什么」的静态选摘, + /// 供 system prompt 路标段铺开让 agent 知晓库藏、想起检索。入选门槛 = published 且 + /// (verified = 1 OR reuse_count >= 3);排序 = 复用降序(唯一客观信号,对齐 search/top_used), + /// 同复用按 created_at 降序定稳定次序。verified 只作入选不作排序(复用是更强的实战信号)。 + /// + /// 两档拼接:项目档(source_project = project_id,上限 project_limit)在前——会话归属 + /// 项目的知识最相关;跨项目补充档(非本项目含无归属条目,上限 global_limit)在后。 + /// 两档 WHERE 互斥(= pid vs IS NULL/≠ pid),结果天然无重复。 + /// project_id = None → 仅补充档(会话未解析出归属项目,项目档无从谈起)。 + pub async fn list_waymark( + &self, + project_id: Option<&str>, + project_limit: usize, + global_limit: usize, + ) -> Result> { + let conn = self.conn.clone(); + let project_id = project_id.map(|s| s.to_owned()); + tokio::task::spawn_blocking(move || { + let guard = conn.blocking_lock(); + // 入选门槛与排序两档共用(verified 在 SQLite 存 0/1;复用达标压线 3 即入选) + let qualify = "status = 'published' AND (verified = 1 OR reuse_count >= 3)"; + let order = " ORDER BY reuse_count DESC, created_at DESC LIMIT "; + let mut results = Vec::new(); + + // 项目档:会话归属项目内的合格条目(复用降序,上限 project_limit) + if let Some(pid) = &project_id { + let sql = format!( + "SELECT {KNOWLEDGE_COLS} FROM knowledges WHERE {qualify} AND source_project = ?1{order}?2" + ); + let mut stmt = guard.prepare(&sql).map_err(storage_err)?; + let rows = stmt + .query_map(params![pid, project_limit as i64], knowledge_from_row) + .map_err(storage_err)?; + for r in rows { + results.push(r.map_err(storage_err)?); + } + } + + // 跨项目补充档:非本项目(含无归属全局条目)按复用降序补足 global_limit; + // WHERE 与项目档互斥 → 天然去重。project_id = None 时无排他条件,即全量合格条目。 + // LIMIT 占位符编号随排他条件有无而变(?1 空档排他时归 LIMIT,有排他时排他占 ?1、LIMIT 占 ?2) + let exclude = if project_id.is_some() { + " AND (source_project IS NULL OR source_project != ?1)" + } else { + "" + }; + let lim_n = if project_id.is_some() { 2 } else { 1 }; + let sql = format!("SELECT {KNOWLEDGE_COLS} FROM knowledges WHERE {qualify}{exclude}{order}?{lim_n}"); + let mut stmt = guard.prepare(&sql).map_err(storage_err)?; + let rows = match &project_id { + Some(pid) => stmt + .query_map(params![pid, global_limit as i64], knowledge_from_row) + .map_err(storage_err)?, + None => stmt + .query_map(params![global_limit as i64], knowledge_from_row) + .map_err(storage_err)?, + }; + for r in rows { + results.push(r.map_err(storage_err)?); + } + Ok(results) + }) + .await + .map_err(storage_err)? + } } impl_repo!( @@ -1715,6 +1783,107 @@ mod tests { assert_eq!(hits[0].id, "k1"); } + // ---------- 知识路标读侧(list_waymark,devflow.md 投影通道) ---------- + + /// 入选门槛:published 且(verified 或 复用达标);candidate/archived/复用不足均淘汰 + #[tokio::test] + async fn waymark_qualifies_verified_or_reused_published_only() { + let repo = setup_repo().await; + // verified=true 复用 0 → 入选(验证豁免复用门槛) + let mut v = krec("v", "已验证条目", "...", "lesson", "published", None, 0); + v.verified = true; + repo.insert(v).await.unwrap(); + // 未验证复用 3 → 入选(压线达标) + repo.insert(krec("r3", "复用达标条目", "...", "lesson", "published", None, 3)) + .await + .unwrap(); + // 未验证复用 2 → 淘汰 + repo.insert(krec("r2", "复用不足条目", "...", "lesson", "published", None, 2)) + .await + .unwrap(); + // 复用再高,状态非 published(candidate)→ 淘汰 + repo.insert(krec("cand", "候选未发布", "...", "lesson", "candidate", None, 99)) + .await + .unwrap(); + // archived → 淘汰 + repo.insert(krec("arch", "已归档", "...", "lesson", "archived", None, 99)) + .await + .unwrap(); + + let hits = repo.list_waymark(None, 20, 10).await.unwrap(); + let ids: Vec<_> = hits.iter().map(|h| h.id.as_str()).collect(); + assert_eq!(ids.len(), 2, "只应入选 已验证 + 复用达标 两条"); + assert!(ids.contains(&"v") && ids.contains(&"r3")); + } + + /// 两档序:项目档在前(复用降序,verified 豁免复用但仍按复用参与排序), + /// 跨项目补充档在后(复用降序);异项目高复用压不过同项目低复用(档位优先于复用) + #[tokio::test] + async fn waymark_project_tier_first_global_supplement_after() { + let repo = setup_repo().await; + repo.insert(krec("p9", "同项目复用9", "...", "lesson", "published", None, 9)) + .await + .unwrap(); + repo.insert(krec("p5", "同项目复用5", "...", "lesson", "published", None, 5)) + .await + .unwrap(); + // 同项目 verified 但零复用:入选项目档,排序落在复用条目之后 + let mut pv = krec("pv", "同项目已验证零复用", "...", "lesson", "published", None, 0); + pv.verified = true; + repo.insert(pv).await.unwrap(); + // 异项目高复用 → 补充档 + let mut f = krec("f99", "异项目复用99", "...", "lesson", "published", None, 99); + f.source_project = Some("proj-2".to_string()); + repo.insert(f).await.unwrap(); + // 无归属全局条目 → 补充档 + let mut g = krec("g10", "全局复用10", "...", "lesson", "published", None, 10); + g.source_project = None; + repo.insert(g).await.unwrap(); + + let hits = repo.list_waymark(Some("proj-1"), 20, 10).await.unwrap(); + let ids: Vec<_> = hits.iter().map(|h| h.id.as_str()).collect(); + assert_eq!( + ids, + vec!["p9", "p5", "pv", "f99", "g10"], + "项目档在前(复用降序),补充档在后(复用降序:异项目99 > 全局10)" + ); + // 两档 WHERE 互斥的去重属性:任一条目恰好出现一次 + assert_eq!(ids.iter().filter(|i| **i == "p9").count(), 1, "同项目条目不得因两档重复出现"); + } + + /// 各档上限独立生效:项目档取复用最高的 project_limit 条,补充档另计 global_limit 条 + /// (条目复用全部压线达标,确保裁剪只来自 LIMIT 而非入选门槛) + #[tokio::test] + async fn waymark_limits_cap_tiers_independently() { + let repo = setup_repo().await; + for reuse in 3..=5 { + repo.insert(krec(&format!("p{reuse}"), "同项目", "...", "lesson", "published", None, reuse)) + .await + .unwrap(); + } + for reuse in 3..=5 { + let mut g = krec(&format!("g{reuse}"), "全局", "...", "lesson", "published", None, reuse); + g.source_project = None; + repo.insert(g).await.unwrap(); + } + + let hits = repo.list_waymark(Some("proj-1"), 2, 2).await.unwrap(); + let ids: Vec<_> = hits.iter().map(|h| h.id.as_str()).collect(); + assert_eq!( + ids, + vec!["p5", "p4", "g5", "g4"], + "项目档取复用最高的 2 条,补充档独立取全局最高的 2 条" + ); + } + + /// 空库 → 空结果(路标子块整体缺席的前提,投影侧据此零追加) + #[tokio::test] + async fn waymark_empty_db_returns_empty() { + let repo = setup_repo().await; + assert!(repo.list_waymark(Some("proj-1"), 20, 10).await.unwrap().is_empty()); + assert!(repo.list_waymark(None, 20, 10).await.unwrap().is_empty()); + } + // ---------- 复用即信任(auto_publish_if_reused) ---------- #[tokio::test] diff --git a/src-tauri/src/commands/ai/devflow_md.rs b/src-tauri/src/commands/ai/devflow_md.rs index 84871a1..a9f33ad 100644 --- a/src-tauri/src/commands/ai/devflow_md.rs +++ b/src-tauri/src/commands/ai/devflow_md.rs @@ -1,24 +1,35 @@ -//! devflow.md / CLAUDE.md 项目认知投影(项目规则文件 → system prompt 稳定尾段)。 +//! devflow.md / CLAUDE.md 项目认知投影(项目规则文件 + 知识库路标 → system prompt 稳定尾段)。 //! //! 定位:对标 CLAUDE.md 的"启动读一遍静态规则文件",本土化为双文件并读—— //! - `devflow.md`:agent 生态自己的项目认知文件,v1 取两个分区(规范纪律 / 领单); //! - `CLAUDE.md`:既有通用约定,同目录存在则并入(增强非依赖)。 //! 两文件并存时 devflow.md 优先(段内排序在前 + 显式冲突注明)。 //! +//! 知识路标子段(知识投影 v2):规则文件只说"该怎么做",路标补"库里有啥"——已发布且 +//! (人工验证或复用达标)的知识条目按「同项目档 + 跨项目补充档」铺开成紧凑行, +//! 让 agent 知晓知识库藏、在工具故障/重复失败时想起用 knowledge_search 检索, +//! 而不是死磕。读侧走 df-storage [`KnowledgeRepo::list_waymark`],会话项目归属复用 +//! [`resolve_source_project`] 既有机制,不自写 SQL 不造双轨。 +//! //! 缓存红线:投影段纯追加在 system prompt 全部既有段之后,内容按会话缓存—— -//! 同一会话(含其派生的子代理)只在首次组装时读一次盘,此后复用同一文本, +//! 同一会话(含其派生的子代理)只在首次组装时读一次盘 + 查一次库,此后复用同一文本, //! 保证段内容会话内逐字节不变,不击穿 provider 的 prompt 缓存前缀。 -//! 会话粒度取 conv_id 作缓存键:新会话重新读盘(拿到文件最新内容),旧会话不受扰动。 +//! 会话粒度取 conv_id 作缓存键:新会话重新组装(拿到文件与库的最新内容),旧会话不受扰动。 //! -//! 海纳川流:两文件缺失 / 读取失败 / 内容为空 → 投影段为空,prompt 与不接本模块 -//! 逐字一致(规则文件是增强输入,缺失不影响内生运作)。 +//! 海纳川流:两文件缺失 / 读取失败 / 内容为空,且知识库无合格条目 → 投影段为空, +//! prompt 与不接本模块逐字一致(规则文件与路标都是增强输入,缺失不影响内生运作)。 //! -//! 裁剪纪律:注入的 token 才是成本——按分区截取 + 字符上限,禁全量灌入。 -//! 超限按 Unicode 字符截断(中文一字一符,按字节会截断 UTF-8 序列产生乱码)并注明。 +//! 裁剪纪律:注入的 token 才是成本——规则分区按字符上限截断,路标条目数被查询 +//! LIMIT 封顶 + 行内标题截断,整体体量有界。超限按 Unicode 字符截断 +//! (中文一字一符,按字节会截断 UTF-8 序列产生乱码)并注明。 use std::collections::HashMap; use std::path::{Path, PathBuf}; -use std::sync::{Mutex, OnceLock}; +use std::sync::{Arc, Mutex, OnceLock}; + +use df_storage::crud::KnowledgeRepo; +use df_storage::db::Database; +use df_storage::models::KnowledgeRecord; use crate::state::AllowedDirs; @@ -39,6 +50,17 @@ const SECTION_MAX_CHARS: usize = 6000; /// (清空后下次组装重读盘,内容只会更新不会错,正确性不受损)。 const SECTION_CACHE_MAX: usize = 64; +// ── 知识路标子段参数 ── +// +// 条目数由查询 LIMIT 封顶(项目档 + 补充档),行内标题截断注省略号——双层结构上界 +// 使路标块体量有界(约 30 行 × 行宽上限),无须再设整块字符闸。 +/// 会话归属项目档上限(条) +const WAYMARK_PROJECT_MAX: usize = 20; +/// 跨项目补充档上限(条) +const WAYMARK_GLOBAL_MAX: usize = 10; +/// 路标行标题上限(字符):超长截断防长标题破坏紧凑行形态 +const WAYMARK_TITLE_MAX_CHARS: usize = 60; + /// devflow.md 规范分区识别的标题关键字(领单优先判,防"领单纪律"被纪律关键字截胡) const CLAIM_HEADING_KEY: &str = "领单"; const DISCIPLINE_HEADING_KEYS: [&str; 2] = ["纪律", "规范"]; @@ -49,6 +71,16 @@ fn section_cache() -> &'static Mutex> { SECTION_CACHE.get_or_init(|| Mutex::new(HashMap::new())) } +/// 知识库读侧句柄:进程级 AppHandle → AppState.db(生产装配点,与 spawn_agent 全局 +/// 同款"注册期无法捕获、运行期读取"形态)。AppHandle 未注入 / AppState 未托管 +/// (早期启动、单元测试)→ None,路标子块整体缺席,其余组装零扰动。 +fn knowledge_db() -> Option> { + use tauri::Manager; + let app = crate::commands::ai::tools::spawn_agent::app_handle()?; + let state = app.try_state::()?; + Some(state.db.clone()) +} + /// 工作目录解析:首个持久授权目录(排序取首稳定)。 /// /// 与主链被监视工作区取同一约定,保证主会话与子代理对"项目根"的认知一致; @@ -64,17 +96,19 @@ pub(crate) fn work_dir_of(allowed: &AllowedDirs) -> Option { /// 无投影内容时原样返回(调用方 prompt 逐字不变);有则 `\n\n` 接续, /// 调用方既有段内容不受任何影响(稳定尾段语义)。 pub(crate) async fn append_projection(prompt: String, work_dir: Option<&str>, conv_id: &str) -> String { - match projection_section(work_dir, conv_id).await { + let kb = knowledge_db(); + match projection_section(work_dir, conv_id, kb).await { Some(seg) => format!("{}\n\n{}", prompt, seg), None => prompt, } } -/// 取会话的投影段:缓存命中直接复用(同会话零重复读盘);未命中读盘一次并入缓存。 +/// 取会话的投影段:缓存命中直接复用(同会话零重复读盘查库);未命中组装一次并入缓存。 /// -/// 缓存值空串 = 本会话已知无投影(文件缺失),同样走缓存避免每轮白读盘。 +/// 缓存值空串 = 本会话已知无投影(文件缺失且库无合格条目),同样走缓存避免每轮白查。 /// 子代理以父会话 conv_id 作键,天然复用父会话已读内容。 -async fn projection_section(work_dir: Option<&str>, conv_id: &str) -> Option { +/// `kb`:知识库读侧句柄(None = 单元测试/未托管态,路标子块整体缺席)。 +async fn projection_section(work_dir: Option<&str>, conv_id: &str, kb: Option>) -> Option { { let cache = section_cache().lock().unwrap_or_else(|p| p.into_inner()); if let Some(hit) = cache.get(conv_id) { @@ -82,15 +116,23 @@ async fn projection_section(work_dir: Option<&str>, conv_id: &str) -> Option build_waymark(&db, &conv).await, + None => None, + } + } + ); + let (devflow, claude) = files.unwrap_or_else(|_| (None, None)); let (discipline, claiming) = devflow .as_deref() .map(extract_partitions) .unwrap_or((String::new(), String::new())); - let section = build_section(&discipline, &claiming, claude.as_deref()); + let section = combine_sections(build_section(&discipline, &claiming, claude.as_deref()), waymark); let mut cache = section_cache().lock().unwrap_or_else(|p| p.into_inner()); if cache.len() >= SECTION_CACHE_MAX { cache.clear(); @@ -99,6 +141,59 @@ async fn projection_section(work_dir: Option<&str>, conv_id: &str) -> Option, waymark: Option) -> Option { + match (rules, waymark) { + (Some(mut rules), Some(wm)) => { + rules.push_str("\n\n"); + rules.push_str(&wm); + Some(rules) + } + (Some(rules), None) => Some(rules), + (None, Some(wm)) => Some(format!("## 项目认知投影\n\n{wm}")), + (None, None) => None, + } +} + +/// 组装知识路标子块:解析会话归属项目(既有机制) → 两档查询 → 渲染紧凑行。 +/// 查询失败降级缺席(warn 留痕,不阻断组装:路标是增强输入,故障不该影响 prompt 主链)。 +async fn build_waymark(db: &Arc, conv_id: &str) -> Option { + let project = crate::commands::ai::knowledge_inject::resolve_source_project(db, conv_id, &[]).await; + match KnowledgeRepo::new(db) + .list_waymark(project.as_deref(), WAYMARK_PROJECT_MAX, WAYMARK_GLOBAL_MAX) + .await + { + Ok(list) if !list.is_empty() => Some(render_waymark(&list)), + Ok(_) => None, + Err(e) => { + tracing::warn!(error = %e, conv_id, "[devflow-md] 知识路标查询失败,子块缺席"); + None + } + } +} + +/// 渲染路标子块(纯函数):段首说明 + 紧凑行 + 段尾固定提示。 +/// 标题压平空白(库内标题可能含换行,直拼会破坏单行形态)并截断注省略号; +/// 空白标题条目跳过(渲染不出可读行的脏数据不入段)。 +fn render_waymark(entries: &[KnowledgeRecord]) -> String { + let mut wm = String::from("### 知识库路标\n项目知识库路标(详情用 knowledge_search 按标题关键词检索)"); + for e in entries { + let flat = e.title.split_whitespace().collect::>().join(" "); + if flat.is_empty() { + continue; + } + let count = flat.chars().count(); + let mut title: String = flat.chars().take(WAYMARK_TITLE_MAX_CHARS).collect(); + if count > WAYMARK_TITLE_MAX_CHARS { + title.push('…'); + } + wm.push_str(&format!("\n- {title} | 复用{}次", e.reuse_count)); + } + wm.push_str("\n上述经验条目可直接复用;遇到工具故障/重复失败先检索路标"); + wm +} + /// 读工作目录下的两份规则文件,内容 trim 后返回;目录缺失 / 读失败 / 空白内容 → None。 fn read_pair(dir: Option) -> (Option, Option) { let Some(dir) = dir else { @@ -114,8 +209,8 @@ fn read_trimmed(path: &Path) -> Option { .filter(|s| !s.is_empty()) } -/// 拼装投影段(纯函数):分区截取、上限截断、优先级注明都在这里定形。 -/// 三路输入全空 → None(调用方零追加)。 +/// 拼装规则文件投影段(纯函数):分区截取、上限截断、优先级注明都在这里定形。 +/// 三路输入全空 → None(路标子块在场时由 combine_sections 单独成段)。 fn build_section(discipline: &str, claiming: &str, claude: Option<&str>) -> Option { let claude = claude.map(str::trim).filter(|c| !c.is_empty()); if discipline.is_empty() && claiming.is_empty() && claude.is_none() { @@ -417,4 +512,191 @@ mod tests { assert!(!only_claim.contains("### 规范纪律"), "空分区不得产生空小节"); assert!(!only_claim.contains("冲突以"), "单文件不得注明双文件优先级"); } + + // ============================================================ + // 知识路标子段(kb 句柄经 projection_section 显式注入,不经进程级全局: + // 单测并发零串扰,append_projection 生产路径的句柄解析另由 app_handle 缺席态天然覆盖) + // ============================================================ + + use df_storage::crud::{AiConversationRepo, ProjectRepo}; + use df_storage::models::{AiConversationRecord, KnowledgeRecord}; + + /// 知识条目 fixture(published,未验证,归属可选) + fn kentry(id: &str, title: &str, reuse: i32, verified: bool, project: Option<&str>) -> KnowledgeRecord { + KnowledgeRecord { + id: id.to_string(), + kind: "lesson".to_string(), + title: title.to_string(), + content: "正文".to_string(), + tags: Some("[]".to_string()), + status: "published".to_string(), + confidence: None, + reuse_count: reuse, + verified, + source_project: project.map(|p| p.to_string()), + source_ref: None, + reasoning: None, + embedding_status: None, + created_at: "1700000000000".to_string(), + updated_at: "1700000000000".to_string(), + } + } + + /// 插入测试项目(同 knowledge_inject 测试口径) + async fn ins_project(db: &Database, id: &str, path: &str) { + ProjectRepo::new(db) + .insert(df_storage::models::ProjectRecord { + id: id.into(), + name: "路标项目".into(), + description: String::new(), + status: df_types::types::ProjectStatus::Planning, + idea_id: None, + path: Some(path.into()), + stack: None, + archived: false, + created_at: "2026-01-01".into(), + updated_at: "2026-01-01".into(), + }) + .await + .unwrap(); + } + + /// 插入测试会话,带 boundary_json(dir_allowlist 单目录 → 级 a 归属解析用) + async fn ins_conv_with_boundary(db: &Database, conv_id: &str, allow_dir: &str) { + let boundary = crate::commands::ai::boundary::TaskBoundary { + risk_ceiling: df_ai::ai_tools::RiskLevel::Medium, + dir_allowlist: vec![allow_dir.to_string()], + forbid_destructive: true, + budget: None, + }; + AiConversationRepo::new(db) + .insert(AiConversationRecord { + id: conv_id.into(), + title: Some("t".into()), + messages: "[]".into(), + provider_id: None, + model: None, + models: None, + archived: false, + pinned: false, + prompt_tokens: None, + completion_tokens: None, + pinned_goals: None, + pending_approvals: None, + created_at: "2026-01-01".into(), + updated_at: "2026-01-01".into(), + origin: None, + boundary_json: Some(serde_json::to_string(&boundary).unwrap()), + loop_state: None, + status: "active".into(), + working_context_json: None, + }) + .await + .unwrap(); + } + + async fn memory_kb() -> Arc { + Arc::new(Database::open_in_memory().await.expect("内存库")) + } + + // ── 空库零回归:注入空库句柄,投影段与无库时逐字一致 ── + #[tokio::test] + async fn empty_kb_zero_regression() { + let db = memory_kb().await; + let dir = temp_project_dir(); + // 无规则文件 + 空库 → None(prompt 逐字不变) + let none = projection_section(Some(dir.to_str().unwrap()), &format!("conv-{}", unique_tag()), Some(db.clone())).await; + assert_eq!(none, None, "空库且无规则文件,投影段必须缺席"); + // 有规则文件 + 空库 → 规则照常注入,路标子块零出现(其余行为零变化) + write_file(&dir, "CLAUDE.md", "- 规则条目"); + let rules_only = projection_section(Some(dir.to_str().unwrap()), &format!("conv-{}", unique_tag()), Some(db)).await; + let sec = rules_only.expect("有规则文件必有段"); + assert!(sec.contains("### 项目规范(CLAUDE.md)")); + assert!(!sec.contains("知识库路标"), "空库不得出现路标子段"); + std::fs::remove_dir_all(&dir).ok(); + } + + // ── 入选过滤:verified 或复用达标才入段,渲染为紧凑行 + 段首说明 + 段尾固定提示 ── + #[tokio::test] + async fn waymark_selects_verified_or_reused() { + let db = memory_kb().await; + let repo = KnowledgeRepo::new(&db); + repo.insert(kentry("ok-v", "已验证经验", 0, true, None)).await.unwrap(); + repo.insert(kentry("ok-r", "复用达标经验", 3, false, None)).await.unwrap(); + repo.insert(kentry("no-r", "复用不足应淘汰", 2, false, None)).await.unwrap(); + let mut cand = kentry("no-c", "候选未发布应淘汰", 99, false, None); + cand.status = "candidate".to_string(); + repo.insert(cand).await.unwrap(); + + let sec = projection_section(None, &format!("conv-{}", unique_tag()), Some(db)) + .await + .expect("有合格条目必有段"); + assert!(sec.starts_with("## 项目认知投影"), "仅路标在场时须独占投影段"); + assert!(sec.contains("项目知识库路标(详情用 knowledge_search 按标题关键词检索)"), "段首说明行"); + assert!(sec.contains("\n- 已验证经验 | 复用0次"), "verified 豁免复用门槛入段"); + assert!(sec.contains("\n- 复用达标经验 | 复用3次"), "复用压线达标入段"); + assert!(!sec.contains("复用不足应淘汰"), "复用不足不入段"); + assert!(!sec.contains("候选未发布应淘汰"), "非 published 不入段"); + assert!( + sec.ends_with("上述经验条目可直接复用;遇到工具故障/重复失败先检索路标"), + "段尾固定提示行" + ); + } + + // ── 项目优先:boundary 解析出归属项目后,项目档在前,跨项目补充档按复用降序 ── + #[tokio::test] + async fn waymark_project_tier_first() { + let db = memory_kb().await; + ins_project(&db, "p-way", "e:/wk-lab/waymark-proj").await; + ins_conv_with_boundary(&db, "conv-way", "e:/wk-lab/waymark-proj").await; + let repo = KnowledgeRepo::new(&db); + repo.insert(kentry("same1", "同项目条目", 5, false, Some("p-way"))).await.unwrap(); + repo.insert(kentry("foreign", "异项目高复用条目", 99, false, Some("p-other"))).await.unwrap(); + repo.insert(kentry("global1", "全局无归属条目", 10, false, None)).await.unwrap(); + + let sec = projection_section(None, "conv-way", Some(db)).await.expect("必有段"); + let same = sec.find("同项目条目").expect("同项目条目须入项目档"); + let global = sec.find("全局无归属条目").expect("全局条目须入补充档"); + let foreign = sec.find("异项目高复用条目").expect("异项目条目须入补充档"); + assert!( + same < foreign && foreign < global, + "项目档在前;补充档内复用降序(异项目99 先于全局10)" + ); + } + + // ── 会话缓存:同会话复用首查结果,库内追加不影响;新会话查到新条目 ── + #[tokio::test] + async fn waymark_cached_per_conversation() { + let db = memory_kb().await; + let repo = KnowledgeRepo::new(&db); + repo.insert(kentry("a1", "首批经验", 3, false, None)).await.unwrap(); + let conv = format!("conv-{}", unique_tag()); + + let first = projection_section(None, &conv, Some(db.clone())).await.expect("必有段"); + assert!(first.contains("首批经验")); + + repo.insert(kentry("b2", "次批经验", 3, false, None)).await.unwrap(); + let again = projection_section(None, &conv, Some(db.clone())).await.expect("必有段"); + assert_eq!(first, again, "同会话必须复用缓存(库内追加不影响)"); + assert!(!again.contains("次批经验")); + + let fresh = projection_section(None, &format!("{conv}-new"), Some(db)).await.expect("必有段"); + assert!(fresh.contains("次批经验"), "新会话须重查库拿到新条目"); + } + + // ── 渲染纯函数:标题压平换行 + 截断注省略号 + 空白标题跳过 ── + #[test] + fn render_waymark_flattens_and_caps_title() { + let mut long = kentry("long", "", 7, false, None); + long.title = format!("长{}标题", "词".repeat(60)); // 63 字,超 60 上限 + let mut multiline = kentry("multi", "含\n换行\t与制表", 2, true, None); + multiline.verified = true; + let blank = kentry("blank", " \n ", 1, true, None); + let out = render_waymark(&[long, multiline, blank]); + let line = out.lines().find(|l| l.starts_with("- 长")).expect("截断标题行在场"); + assert!(line.ends_with("… | 复用7次"), "超长标题须截断注省略号"); + assert!(line.matches('词').count() <= WAYMARK_TITLE_MAX_CHARS, "标题不得超上限"); + assert!(out.contains("- 含 换行 与制表 | 复用2次"), "换行/制表压平为单空格"); + assert!(!out.contains("- |"), "空白标题条目须跳过"); + } }