Compare commits
3
Commits
9142e30714
...
7548896f6d
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
7548896f6d | ||
|
|
5faaa88302 | ||
|
|
8c4aa5dc5b |
@@ -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]
|
||||
|
||||
@@ -640,6 +640,11 @@
|
||||
|
||||
### B5 无人值守续作引擎(2026-10-06 用户实证"总停下确定性任务"的根治,挂机治理核心缺口)
|
||||
- [ ] **B5a 挂钟闸分任务定级**:确定性任务(看板任务/用户拍板项)挂钟闸放宽(如 120min)或按预估时长配置;闸到点改「检查点+续跑」(存进度不停工),仅非确定性/失控疑似才硬停靠。
|
||||
- [ ] **目标提取质量闸**(2026-10-12 目标丢失事故实锤:用户抱怨句「丢失 会话上下文了 找回目标」被注册成任务目标、「继续」单字成目标、半截命令「ssh-proxy upload --name i」以 active 驱动续跑):写入侧把关——短文本/纯沟通词/疑问句不入 goals,含参数的命令目标须完整形态;恢复侧放行前过同闸
|
||||
- [ ] **恢复消息递归套娃**(2026-10-12 实锤:重启恢复消息整段被当用户输入再提取,目标里嵌套上一次恢复全文,层层放大):恢复消息注入走旁路,不进目标提取源
|
||||
- [ ] **G2 停滞判定叠输出变化维度**(2026-10-12 OSS迁移会话实证:轮询 grep DONE 同参数连发5次被防卡死拦截,但输出 skip 8000→8500 在变=有进展硬证据):样本键叠加输出内容哈希,输出仍在变不算停滞(真死循环输出也不变,非放松防线);stall_detector.rs 签名族
|
||||
- [ ] **恢复目标完整性守卫**(同上实证:残缺目标「ssh-proxy upload --name i」进快照被恢复引导放行,AI 强行续跑):R4 恢复消息对参数明显不完整的目标标注「须先向用户确认参数」;偏产品语义,与快照线同族
|
||||
- [ ] **压缩失败路径召回句柄核查**(同上实证:压缩失败兜底摘要+archive jsonl 归档正常,但截断参数仍丢失):核查④压缩可召回句柄行在压缩失败路径是否注入/AI 是否被引导召回;与知识路标层同族问题
|
||||
- [ ] **B5b 停靠自动续作**:停靠/parked/批收敛后自动回读看板——有 parked 可续任务→自动 resume;有未完批次→自动领单续作;真需决策才停(附决策项)。无人值守的"永动"机制,CLAUDE.md 领单纪律的运行时化。
|
||||
- [ ] **B5c 审批无人值守策略**:挂机中审批拦截按用户预设策略处置(低风险自动批+留痕,高风险停下+通知),策略 yml 可配。验收:挂机 4h 连续推进模拟(确定性批次队列),零人工干预零停顿;超大任务挂钟闸到点走检查点续跑。
|
||||
|
||||
|
||||
@@ -39,7 +39,7 @@ use df_storage::models::AiProviderRecord;
|
||||
// ProviderAcquire 已随 stream_round 迁至 stream_relay.rs。
|
||||
use crate::state::{AppState, LlmConcurrency};
|
||||
|
||||
use super::audit::{process_tool_calls, ToolExecPolicy};
|
||||
use super::audit::{process_tool_calls, take_tool_fail_park, ToolExecPolicy};
|
||||
use super::event_bus::emit_ai_chat_event;
|
||||
// compress_via_llm 已随压缩逻辑迁至 context_lifecycle.rs(maybe_auto_compress 内调用)。
|
||||
use super::conversation::{save_conversation, TokenAccumulator};
|
||||
@@ -2417,6 +2417,24 @@ async fn process_round_tools(
|
||||
return ToolsOutcome::Exit;
|
||||
}
|
||||
|
||||
// B5d 工具连败熔断终局:同会话失败记忆拦截连击达阈且期间无成功 → 强制停靠。
|
||||
// 拦截计数在 audit 层(execute_with_heartbeat)累积,停靠在 loop 层执行:guard 复位
|
||||
// 与 emit_task_parked 正典序列只有本层触达,audit 层只判定不执行。停靠即终局,
|
||||
// 不再给模型无视 blocked 提示继续重试的机会(实证:拦截后模型仍原样重试 10+ 次)。
|
||||
if take_tool_fail_park(conv_id) {
|
||||
tracing::warn!(
|
||||
conv_id = %conv_id,
|
||||
"[ai] 工具连续失败熔断:失败记忆拦截连击达阈,任务强制停靠"
|
||||
);
|
||||
guard.reset().await;
|
||||
crate::commands::ai::emit_task_parked(
|
||||
app_handle, conv_id,
|
||||
"工具连续失败熔断(同一调用被失败记忆反复拦截仍重试),任务安全停靠;已完成进度已保存,调整思路后重发消息可继续",
|
||||
"circuit_breaker",
|
||||
);
|
||||
return ToolsOutcome::Exit;
|
||||
}
|
||||
|
||||
// AC-2 ①:单轮工具调用数超限 → 注入软提示(机制化,非静默,弱模型能感知)。
|
||||
// 治单轮并行调用爆炸:LLM 看不到自己发了多少调用,必须在消息流里显式警告。
|
||||
if is_tool_call_over_limit(round_tool_call_count) {
|
||||
|
||||
@@ -228,6 +228,76 @@ fn sub_agent_tool_clamped(allowed: Option<&[String]>, tool_name: &str) -> bool {
|
||||
}
|
||||
}
|
||||
|
||||
// B5d 工具连败升级(快速放弃+换路引导)。三级全部复用既有失败记忆(tool_failure_memory)
|
||||
// 的连败计数,不另造计数轨道:首败=裸错误原样;第 2 次失败=错误包附软提示(勿原样重试);
|
||||
// 第 3 次起=入口拦截包升级为强制换路通牒(换工具/查知识库/向用户说明)。拦截在本会话
|
||||
// 连续达 PARK_AFTER_BLOCKS 且期间无任何工具成功 → take_tool_fail_park 请求 loop 层停靠
|
||||
// (2026-10-12 实证:拦截后模型仍原样重试 10+ 次,软提示拦不住,须有终局)。
|
||||
|
||||
/// 第 2 次失败附带的软提示:提醒而非禁止,给模型自行调参换路的机会。
|
||||
const HINT_AVOID_RETRY: &str = "已连续失败 2 次,勿盲目重试同命令;考虑调整参数或换路径";
|
||||
|
||||
/// 第 3 次起拦截包附带的强制换路提示(终局通牒,含知识库检索引导)。
|
||||
const HINT_MUST_SWITCH: &str = "连续失败≥3次,禁止重试同命令,必须:①换工具或换路径 ②调 knowledge_search 查既有经验 ③仍无法绕过则向用户说明并停手";
|
||||
|
||||
/// 同会话失败记忆拦截连续达此值(且期间无任何工具成功)→ 请求 loop 层强制停靠。
|
||||
const PARK_AFTER_BLOCKS: u32 = 5;
|
||||
|
||||
/// 同会话失败记忆拦截连击计数:键=conv_id,值=自该会话最近一次成功执行以来的拦截次数。
|
||||
/// 与失败记忆分开记:拦截不触发工具执行,失败记忆的连败数停在阈值不再增长,连击数
|
||||
/// 只能另记;按会话而非按调用键记,参数抖动绕开调用键时连击依旧成立。
|
||||
static BLOCK_STREAKS: std::sync::OnceLock<std::sync::Mutex<HashMap<String, u32>>> =
|
||||
std::sync::OnceLock::new();
|
||||
|
||||
fn bump_block_streak(conv_id: &str) -> u32 {
|
||||
let streaks = BLOCK_STREAKS.get_or_init(|| std::sync::Mutex::new(HashMap::new()));
|
||||
let mut m = streaks.lock().unwrap_or_else(|p| p.into_inner());
|
||||
let n = m.entry(conv_id.to_string()).or_insert(0);
|
||||
*n += 1;
|
||||
*n
|
||||
}
|
||||
|
||||
/// 任一工具成功即打断连击(会话有产出,不再是纯挣扎状态)。
|
||||
fn clear_block_streak(conv_id: &str) {
|
||||
if let Some(m) = BLOCK_STREAKS.get() {
|
||||
m.lock().unwrap_or_else(|p| p.into_inner()).remove(conv_id);
|
||||
}
|
||||
}
|
||||
|
||||
/// loop 层消费停靠请求:连击达 PARK_AFTER_BLOCKS 且未被成功清零 → 取走标志返 true。
|
||||
pub(crate) fn take_tool_fail_park(conv_id: &str) -> bool {
|
||||
let Some(m) = BLOCK_STREAKS.get() else { return false };
|
||||
let mut map = m.lock().unwrap_or_else(|p| p.into_inner());
|
||||
match map.get(conv_id) {
|
||||
Some(&n) if n >= PARK_AFTER_BLOCKS => {
|
||||
map.remove(conv_id);
|
||||
true
|
||||
}
|
||||
_ => false,
|
||||
}
|
||||
}
|
||||
|
||||
/// 第 2 次失败的错误包附软提示;首败原样透传。
|
||||
fn with_streak_hint(err: anyhow::Error, streak_hit: bool) -> anyhow::Error {
|
||||
if streak_hit {
|
||||
anyhow::anyhow!("{err}\n{HINT_AVOID_RETRY}")
|
||||
} else {
|
||||
err
|
||||
}
|
||||
}
|
||||
|
||||
/// 拦截包升级:hint 字段换为强制换路通牒,status/tool/consecutive_failures/last_error
|
||||
/// 保留不丢。提示文本跨次恒定(不带递增计数):L1 断路器按错误文本哈希归一 key,
|
||||
/// 变化文本会打散其连败累计;连击数走 tracing 日志与停靠判定,不进模型可见包。
|
||||
fn upgrade_blocked_hint(blocked: &str) -> String {
|
||||
let mut v: serde_json::Value = serde_json::from_str(blocked)
|
||||
.unwrap_or_else(|_| serde_json::json!({ "status": "blocked_by_failure_memory" }));
|
||||
if let Some(obj) = v.as_object_mut() {
|
||||
obj.insert("hint".into(), serde_json::json!(HINT_MUST_SWITCH));
|
||||
}
|
||||
v.to_string()
|
||||
}
|
||||
|
||||
/// 处理流式接收的工具调用:Low 风险并行执行(join_all),Med/High 进审批门控
|
||||
/// 返回待审批的工具数量(0 = 全部自动执行完成)
|
||||
///
|
||||
@@ -261,17 +331,20 @@ pub(crate) async fn execute_with_heartbeat(
|
||||
use tokio::time::Duration;
|
||||
|
||||
// ── B5d 工具失败记忆:执行前拦截「同 (conv,tool,args) 连败 ≥2 次」的重复调用 ──
|
||||
// 治「404 连环十几次无退避换路」:拦截时回填结构化换路提示(改参数/换工具/报告放弃),
|
||||
// 治「404 连环十几次无退避换路」:拦截时回填结构化换路提示(强制换路通牒,升级版),
|
||||
// 引导 LLM 换路而非原样重试。成功即清零(record 侧),防误杀。
|
||||
// 拦截直接返 Err(错误 tool_result),不执行工具——process_tool_calls 侧按既有错误路径处理。
|
||||
// 拦截同时累加会话连击,达 PARK_AFTER_BLOCKS 由 loop 层(process_round_tools)停靠收场。
|
||||
if let Some(blocked) = super::tool_failure_memory::precheck(name, &args) {
|
||||
let blocks = bump_block_streak(conv_id);
|
||||
tracing::warn!(
|
||||
conv_id = %conv_id,
|
||||
tool = %name,
|
||||
tool_call_id = %tool_call_id,
|
||||
"[failure-memory] 同调用连败达标,执行前拦截"
|
||||
blocks,
|
||||
"[failure-memory] 同调用连败达标,执行前拦截(本会话累计第 {blocks} 次)"
|
||||
);
|
||||
return Err(anyhow::anyhow!(blocked));
|
||||
return Err(anyhow::anyhow!(upgrade_blocked_hint(&blocked)));
|
||||
}
|
||||
// record 侧需 args 计算 memory_key,但下方 tools.execute(name, args) 会 move args。
|
||||
// 此处留一份 clone 供执行后 record 使用(仅工具执行路径,非热循环,开销可接受)。
|
||||
@@ -516,13 +589,22 @@ pub(crate) async fn execute_with_heartbeat(
|
||||
exec_result
|
||||
};
|
||||
|
||||
// ── B5d 工具失败记忆:执行后回写(成功清零 / 失败递增并记最近错误) ──
|
||||
// 与入口 precheck 配对:成功即删记忆(知识保鲜,防误杀);失败累加计数,
|
||||
// ── B5d 工具失败记忆:执行后回写 + 连败升级提示 ──
|
||||
// 与入口 precheck 配对:成功即删记忆(知识保鲜,防误杀)并打断拦截连击;失败累加计数,
|
||||
// 达阈值后下次 precheck 拦截。error 摘要取 Err 文案(超时/提前退出/工具自身错误同源)。
|
||||
match &exec_result {
|
||||
Ok(_) => super::tool_failure_memory::record(name, &args_for_memory, true, None),
|
||||
Err(e) => super::tool_failure_memory::record(name, &args_for_memory, false, Some(e.to_string())),
|
||||
}
|
||||
// 失败侧回探 precheck:命中即本次为第 2 次失败,错误包附软提示(第 3 次起由入口拦截包接管)。
|
||||
let exec_result = match exec_result {
|
||||
Ok(v) => {
|
||||
super::tool_failure_memory::record(name, &args_for_memory, true, None);
|
||||
clear_block_streak(conv_id);
|
||||
Ok(v)
|
||||
}
|
||||
Err(e) => {
|
||||
super::tool_failure_memory::record(name, &args_for_memory, false, Some(e.to_string()));
|
||||
let streak_hit = super::tool_failure_memory::precheck(name, &args_for_memory).is_some();
|
||||
Err(with_streak_hint(e, streak_hit))
|
||||
}
|
||||
};
|
||||
|
||||
exec_result
|
||||
}
|
||||
@@ -1726,4 +1808,78 @@ mod tests {
|
||||
let (reason_0, _) = approval_timeout_park_parts(0);
|
||||
assert!(reason_0.contains("0"), "任意分钟数可格式化(0=禁用超时不会到点,防御)");
|
||||
}
|
||||
|
||||
/// B5d 连败升级三级:首败裸错误 / 第 2 次失败附软提示 / 第 3 次起拦截包升级强制换路
|
||||
/// 通牒(含 knowledge_search 引导);成功一次即清零,再败回到首败无提示。
|
||||
/// 走真失败记忆(进程级静态),键取本测试专属路径防并行互踩。
|
||||
#[test]
|
||||
fn failure_streak_hint_escalates_three_levels() {
|
||||
use crate::commands::ai::tool_failure_memory::{precheck, record};
|
||||
let args = serde_json::json!({ "path": "/tmp/b5d-escalation-check.txt" });
|
||||
|
||||
// 首败:未达拦截阈值,无提示
|
||||
record("read_file", &args, false, Some("not found".into()));
|
||||
assert!(!precheck("read_file", &args).is_some(), "首败不拦截");
|
||||
|
||||
// 第 2 次失败:记录后回探预检命中,错误包附软提示
|
||||
record("read_file", &args, false, Some("not found".into()));
|
||||
let streak_hit = precheck("read_file", &args).is_some();
|
||||
assert!(streak_hit, "两连败后回探应命中");
|
||||
let hinted = with_streak_hint(anyhow::anyhow!("工具执行失败"), streak_hit).to_string();
|
||||
assert!(hinted.contains(HINT_AVOID_RETRY), "第 2 次失败附软提示: {hinted}");
|
||||
assert!(hinted.contains("工具执行失败"), "原始错误保留: {hinted}");
|
||||
|
||||
// 第 3 次起:拦截包升级为强制换路通牒,状态字段保留,文本跨次恒定(L1 断路器
|
||||
// 按错误文本哈希归 key,变化文本会打散其累计)
|
||||
let blocked = precheck("read_file", &args).unwrap();
|
||||
let upgraded = upgrade_blocked_hint(&blocked);
|
||||
let upgraded_again = upgrade_blocked_hint(&blocked);
|
||||
assert!(upgraded.contains("blocked_by_failure_memory"), "状态字段保留: {upgraded}");
|
||||
assert!(upgraded.contains(HINT_MUST_SWITCH), "拦截包附强制换路通牒: {upgraded}");
|
||||
assert!(upgraded.contains("knowledge_search"), "通牒含知识库检索引导: {upgraded}");
|
||||
assert_eq!(upgraded, upgraded_again, "升级文本跨次恒定");
|
||||
|
||||
// 成功一次即清零:再败回到首败,无提示
|
||||
record("read_file", &args, true, None);
|
||||
record("read_file", &args, false, Some("not found".into()));
|
||||
assert!(!precheck("read_file", &args).is_some(), "成功清零后首败无提示");
|
||||
assert!(
|
||||
!with_streak_hint(anyhow::anyhow!("x"), precheck("read_file", &args).is_some())
|
||||
.to_string()
|
||||
.contains(HINT_AVOID_RETRY),
|
||||
"首败错误包不附提示"
|
||||
);
|
||||
}
|
||||
|
||||
/// B5d 熔断终局:同会话失败记忆拦截连击达 PARK_AFTER_BLOCKS 触发停靠,取走即消费
|
||||
/// (不重复触发);未达阈不触发。
|
||||
#[test]
|
||||
fn blocked_streak_parks_after_five() {
|
||||
let conv = "conv-b5d-park-check";
|
||||
clear_block_streak(conv);
|
||||
for _ in 0..(PARK_AFTER_BLOCKS - 1) {
|
||||
bump_block_streak(conv);
|
||||
}
|
||||
assert!(!take_tool_fail_park(conv), "未达阈不停靠");
|
||||
bump_block_streak(conv);
|
||||
assert!(take_tool_fail_park(conv), "拦截连击达 5 触发停靠");
|
||||
assert!(!take_tool_fail_park(conv), "停靠取走即消费,不重复触发");
|
||||
}
|
||||
|
||||
/// B5d 成功打断连击:达阈前任一工具成功即清零,连击从头再计(会话有产出不停靠)。
|
||||
#[test]
|
||||
fn tool_success_clears_block_streak() {
|
||||
let conv = "conv-b5d-success-clear";
|
||||
clear_block_streak(conv);
|
||||
for _ in 0..(PARK_AFTER_BLOCKS - 1) {
|
||||
bump_block_streak(conv);
|
||||
}
|
||||
clear_block_streak(conv);
|
||||
assert!(!take_tool_fail_park(conv), "成功打断连击,不停靠");
|
||||
for _ in 0..PARK_AFTER_BLOCKS {
|
||||
bump_block_streak(conv);
|
||||
}
|
||||
assert!(take_tool_fail_park(conv), "清零后重新连击达阈仍停靠");
|
||||
clear_block_streak(conv);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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("- |"), "空白标题条目须跳过");
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user