新增: 知识路标投影进系统提示词

- published 且验证或高复用条目按项目优先进投影稳定段
- 段尾固定引导工具故障先检索路标,治有经验不想查
- 与规则文件投影共用会话级缓存,空库零回归
This commit is contained in:
lxy
2026-10-13 13:29:16 +08:00
parent 8c4aa5dc5b
commit 5faaa88302
2 changed files with 470 additions and 19 deletions
+169
View File
@@ -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<Vec<KnowledgeRecord>> {
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]
+301 -19
View File
@@ -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<HashMap<String, String>> {
SECTION_CACHE.get_or_init(|| Mutex::new(HashMap::new()))
}
/// 知识库读侧句柄:进程级 AppHandle → AppState.db(生产装配点,与 spawn_agent 全局
/// 同款"注册期无法捕获、运行期读取"形态)。AppHandle 未注入 / AppState 未托管
/// (早期启动、单元测试)→ None,路标子块整体缺席,其余组装零扰动。
fn knowledge_db() -> Option<Arc<Database>> {
use tauri::Manager;
let app = crate::commands::ai::tools::spawn_agent::app_handle()?;
let state = app.try_state::<crate::state::AppState>()?;
Some(state.db.clone())
}
/// 工作目录解析:首个持久授权目录(排序取首稳定)。
///
/// 与主链被监视工作区取同一约定,保证主会话与子代理对"项目根"的认知一致;
@@ -64,17 +96,19 @@ pub(crate) fn work_dir_of(allowed: &AllowedDirs) -> Option<String> {
/// 无投影内容时原样返回(调用方 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<String> {
/// `kb`:知识库读侧句柄(None = 单元测试/未托管态,路标子块整体缺席)。
async fn projection_section(work_dir: Option<&str>, conv_id: &str, kb: Option<Arc<Database>>) -> Option<String> {
{
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<Str
}
}
let dir = work_dir.map(PathBuf::from);
// 文件读放阻塞线程池(约定与 system prompt 指纹查询一致),失败按缺失处理
let (devflow, claude) = tokio::task::spawn_blocking(move || read_pair(dir))
.await
.unwrap_or_else(|_| (None, None));
let conv = conv_id.to_string();
// 规则文件读盘(阻塞线程池,失败按缺失处理)与知识路标查库相互独立,并发取
let (files, waymark) = tokio::join!(
tokio::task::spawn_blocking(move || read_pair(dir)),
async {
match kb {
Some(db) => 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<Str
section
}
/// 规则文件投影 + 知识路标子块的拼装(纯函数):路标恒居规则投影之后(同一稳定尾段内);
/// 双双缺席 → None(调用方零追加,零回归)。仅路标在场 → 路标独占投影段(规则文件头语不出现)。
fn combine_sections(rules: Option<String>, waymark: Option<String>) -> Option<String> {
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<Database>, conv_id: &str) -> Option<String> {
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::<Vec<_>>().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<PathBuf>) -> (Option<String>, Option<String>) {
let Some(dir) = dir else {
@@ -114,8 +209,8 @@ fn read_trimmed(path: &Path) -> Option<String> {
.filter(|s| !s.is_empty())
}
/// 拼装投影段(纯函数):分区截取、上限截断、优先级注明都在这里定形。
/// 三路输入全空 → None(调用方零追加)。
/// 拼装规则文件投影段(纯函数):分区截取、上限截断、优先级注明都在这里定形。
/// 三路输入全空 → None(路标子块在场时由 combine_sections 单独成段)。
fn build_section(discipline: &str, claiming: &str, claude: Option<&str>) -> Option<String> {
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<Database> {
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("- |"), "空白标题条目须跳过");
}
}