新增: 批次工作落地(推进链/评估闭环/事件总线/并发/加固) + 技术债清理 + 文档整理

后端:
- 工作流推进链(D-03):advance_task/状态机/闸门走 df-nodes Node trait,conditions 条件引擎扩展
- 想法评估闭环:启发式评分+对抗评估,df-ideas/scoring + df-storage/idea_eval_repo + idea 前端打通
- 全局事件数据总线:df-ai/context+context_helpers+augmentation 跨模块解耦
- AI planner/plan_hint/intent:aichat B 路线并行多轮基础
- patch_file 加固(TD-03/04):读改写整体锁防 lost update,expected_hash 合约闭环
- 压缩超时兜底(F-15 卡死根治)
- F-09 多会话并发:LlmConcurrency per-conv + streamingGuard 前端守护 + verify 脚本
- 知识注入 DRY/skills/audit 扩展

清理:
- aichat 技术债(误报 allow/死导入/过时注释 30 项)
- URGENT.md 删除(11 项加急全解决/迁 todo)
- 文档整理(todo/待决策/待审查/ARCHITECTURE/INDEX + 总线/技术债审查新文档)
This commit is contained in:
2026-06-21 20:51:26 +08:00
parent 330bb7f505
commit bd6a41fe6e
111 changed files with 11932 additions and 1034 deletions

View File

@@ -17,22 +17,38 @@ use super::{now_millis_str, storage_err, validate_column_name};
// 知识库 SELECT 列清单(防 COLS 漂移)
// ============================================================
/// `knowledges` 表对应 `KnowledgeRecord` 14 个字段的列名(顺序与结构体一致)。
/// `knowledges` 表对应 `KnowledgeRecord` 15 个字段的列名(顺序与结构体一致)。
///
/// 多处 `search`/`search_vector` 内联 COLS 串的 DRY 收口(CR-260615-03):集中一处定义,
/// 配合下方 `KNOWLEDGE_COL_COUNT` 断言,任一处加列漏改会被测试 `test_knowledge_cols_matches_record`
/// 立即捕获(`knowledge_from_row` 按 name 取列,SELECT 漏列会运行时 rusqlite 报错,故提前断言)。
const KNOWLEDGE_COLS: &str = "id,kind,title,content,tags,status,confidence,reuse_count,verified,source_project,source_ref,reasoning,created_at,updated_at";
///
/// V23 新增 embedding_status 列(嵌入失败可补偿重试),已纳入列清单 + 计数。
const KNOWLEDGE_COLS: &str = "id,kind,title,content,tags,status,confidence,reuse_count,verified,source_project,source_ref,reasoning,embedding_status,created_at,updated_at";
/// `KnowledgeRecord` 字段数(与上面列清单的逗号分隔项数一致,被测试断言)。
/// 仅测试期消费(列漂移断言);保留为非 `cfg(test)` 以便测试外的阅读者一眼看到字段数。
#[cfg_attr(not(test), allow(dead_code))]
const KNOWLEDGE_COL_COUNT: usize = 14;
const KNOWLEDGE_COL_COUNT: usize = 15;
/// `search_vector` 用的列清单:KNOWLEDGE_COLS + embedding(余弦计算用,不入 KnowledgeRecord)。
const KNOWLEDGE_COLS_WITH_EMBEDDING: &str = concat!(
"id,kind,title,content,tags,status,confidence,reuse_count,verified,",
"source_project,source_ref,reasoning,created_at,updated_at,embedding"
"source_project,source_ref,reasoning,embedding_status,created_at,updated_at,embedding"
);
/// `ideas` 表对应 `IdeaRecord` 14 个字段的列名(顺序与结构体一致)。
///
/// 同 KNOWLEDGE_COLS 的列漂移防护(CR-260615-03):idea 表 INSERT/UPDATE/from_row 三处
/// 各写一份列名串,加列须三处同步(如 V24 加 related_ids 即三处齐改),漏一处
/// 只在运行时 rusqlite 报错(INSERT 列数与参数数不匹配 / from_row 取不到列)。集中一处
/// 定义 + 配合 `IDEA_COL_COUNT` 断言 + 测试 `test_idea_cols_matches_record`,加列漏改即捕获。
///
/// V24 新增 related_ids 列(灵感间关联关系持久化打底),已纳入列清单 + 计数。
#[cfg_attr(not(test), allow(dead_code))]
const IDEA_COLS: &str = "id,title,description,status,priority,score,tags,source,promoted_to,ai_analysis,scores,related_ids,created_at,updated_at";
/// `IdeaRecord` 字段数(与上面列清单的逗号分隔项数一致,被测试断言)。
#[cfg_attr(not(test), allow(dead_code))]
const IDEA_COL_COUNT: usize = 14;
// ============================================================
// 向量工具 — embedding BLOB 序列化 + 余弦相似度
// ============================================================
@@ -74,6 +90,7 @@ fn idea_from_row(row: &Row<'_>) -> std::result::Result<IdeaRecord, rusqlite::Err
promoted_to: row.get("promoted_to")?,
ai_analysis: row.get("ai_analysis")?,
scores: row.get("scores")?,
related_ids: row.get("related_ids")?,
created_at: row.get("created_at")?,
updated_at: row.get("updated_at")?,
})
@@ -93,6 +110,7 @@ fn knowledge_from_row(row: &Row<'_>) -> std::result::Result<KnowledgeRecord, rus
source_project: row.get("source_project")?,
source_ref: row.get("source_ref")?,
reasoning: row.get("reasoning")?,
embedding_status: row.get("embedding_status")?,
created_at: row.get("created_at")?,
updated_at: row.get("updated_at")?,
})
@@ -121,22 +139,22 @@ impl_repo!(
from_row => |row| idea_from_row(row),
insert => |conn, rec| {
conn.execute(
"INSERT INTO ideas (id, title, description, status, priority, score, tags, source, promoted_to, ai_analysis, scores, created_at, updated_at)
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13)",
"INSERT INTO ideas (id, title, description, status, priority, score, tags, source, promoted_to, ai_analysis, scores, related_ids, created_at, updated_at)
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13, ?14)",
params![
rec.id, rec.title, rec.description, rec.status, rec.priority,
rec.score, rec.tags, rec.source, rec.promoted_to, rec.ai_analysis,
rec.scores, rec.created_at, rec.updated_at
rec.scores, rec.related_ids, rec.created_at, rec.updated_at
],
)
},
update => |conn, rec| {
conn.execute(
"UPDATE ideas SET title = ?1, description = ?2, status = ?3, priority = ?4, score = ?5, tags = ?6, source = ?7, promoted_to = ?8, ai_analysis = ?9, scores = ?10, updated_at = ?11 WHERE id = ?12",
"UPDATE ideas SET title = ?1, description = ?2, status = ?3, priority = ?4, score = ?5, tags = ?6, source = ?7, promoted_to = ?8, ai_analysis = ?9, scores = ?10, related_ids = ?11, updated_at = ?12 WHERE id = ?13",
params![
rec.title, rec.description, rec.status, rec.priority,
rec.score, rec.tags, rec.source, rec.promoted_to, rec.ai_analysis,
rec.scores, rec.updated_at, rec.id
rec.scores, rec.related_ids, rec.updated_at, rec.id
],
)
}
@@ -151,23 +169,23 @@ impl_repo!(
insert => |conn, rec| {
let verified = if rec.verified { 1i32 } else { 0i32 };
conn.execute(
"INSERT INTO knowledges (id, kind, title, content, tags, status, confidence, reuse_count, verified, source_project, source_ref, reasoning, created_at, updated_at)
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13, ?14)",
"INSERT INTO knowledges (id, kind, title, content, tags, status, confidence, reuse_count, verified, source_project, source_ref, reasoning, embedding_status, created_at, updated_at)
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13, ?14, ?15)",
params![
rec.id, rec.kind, rec.title, rec.content, rec.tags, rec.status, rec.confidence,
rec.reuse_count, verified, rec.source_project, rec.source_ref, rec.reasoning,
rec.created_at, rec.updated_at
rec.embedding_status, rec.created_at, rec.updated_at
],
)
},
update => |conn, rec| {
let verified = if rec.verified { 1i32 } else { 0i32 };
conn.execute(
"UPDATE knowledges SET kind = ?1, title = ?2, content = ?3, tags = ?4, status = ?5, confidence = ?6, reuse_count = ?7, verified = ?8, source_project = ?9, source_ref = ?10, reasoning = ?11, updated_at = ?12 WHERE id = ?13",
"UPDATE knowledges SET kind = ?1, title = ?2, content = ?3, tags = ?4, status = ?5, confidence = ?6, reuse_count = ?7, verified = ?8, source_project = ?9, source_ref = ?10, reasoning = ?11, embedding_status = ?12, updated_at = ?13 WHERE id = ?14",
params![
rec.kind, rec.title, rec.content, rec.tags, rec.status, rec.confidence,
rec.reuse_count, verified, rec.source_project, rec.source_ref, rec.reasoning,
rec.updated_at, rec.id
rec.embedding_status, rec.updated_at, rec.id
],
)
}
@@ -223,17 +241,15 @@ impl KnowledgeRepo {
tokio::task::spawn_blocking(move || {
let guard = conn.blocking_lock();
let mut stmt = guard
.prepare(
"SELECT id,kind,title,content,tags,status,confidence,reuse_count,verified,\
source_project,source_ref,reasoning,created_at,updated_at \
FROM knowledges WHERE status = ?1
.prepare(&format!(
"SELECT {KNOWLEDGE_COLS} FROM knowledges WHERE status = ?1
ORDER BY CASE confidence
WHEN 'high' THEN 3
WHEN 'medium' THEN 2
WHEN 'low' THEN 1
ELSE 0
END DESC, created_at DESC",
)
))
.map_err(storage_err)?;
let rows = stmt
.query_map(params![status], |row| knowledge_from_row(row))
@@ -270,6 +286,7 @@ impl KnowledgeRepo {
/// 写入向量嵌入(BLOB = Vec<f32> 小端字节序列化)
///
/// embedding 列不进 KnowledgeRecord(IPC 不需要传向量给前端),专用方法读写。
/// V23:同时把 embedding_status 置 'done'(成功标记),供补偿重试逻辑判别。
pub async fn set_embedding(&self, id: &str, embedding: &[f32]) -> Result<bool> {
let conn = self.conn.clone();
let id = id.to_owned();
@@ -278,7 +295,7 @@ impl KnowledgeRepo {
let guard = conn.blocking_lock();
let affected = guard
.execute(
"UPDATE knowledges SET embedding = ?1 WHERE id = ?2",
"UPDATE knowledges SET embedding = ?1, embedding_status = 'done' WHERE id = ?2",
params![blob, id],
)
.map_err(storage_err)?;
@@ -288,6 +305,54 @@ impl KnowledgeRepo {
.map_err(storage_err)?
}
/// 标记嵌入生成失败(embedding_status = 'failed'),供补偿重试逻辑定位。
///
/// 失败时不写 embedding(保持 NULL,检索侧 `embedding IS NOT NULL` 自然跳过该条走 LIKE)。
/// 幂等:重复标记 failed 无副作用(同值覆写)。
pub async fn mark_embedding_failed(&self, id: &str) -> Result<bool> {
let conn = self.conn.clone();
let id = id.to_owned();
tokio::task::spawn_blocking(move || {
let guard = conn.blocking_lock();
let affected = guard
.execute(
"UPDATE knowledges SET embedding_status = 'failed' WHERE id = ?1",
params![id],
)
.map_err(storage_err)?;
Ok(affected > 0)
})
.await
.map_err(storage_err)?
}
/// 列出 embedding_status = 'failed' 的已发布知识(补偿重试入口用)。
///
/// 仅返回 published(候选/归档不参与检索,重试无意义),按 created_at 升序(老条目优先补)。
pub async fn list_failed_embeddings(&self) -> Result<Vec<KnowledgeRecord>> {
let conn = self.conn.clone();
tokio::task::spawn_blocking(move || {
let guard = conn.blocking_lock();
let mut stmt = guard
.prepare(&format!(
"SELECT {KNOWLEDGE_COLS} FROM knowledges \
WHERE status = 'published' AND embedding_status = 'failed' \
ORDER BY created_at ASC"
))
.map_err(storage_err)?;
let rows = stmt
.query_map([], |row| knowledge_from_row(row))
.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)?
}
/// 向量检索: 加载全部 published 且有 embedding 的记录,纯 Rust 余弦相似度取 top-N
///
/// 返回 (记录, 相似度分数)。数据量 <50k 时暴力遍历 <50ms,够用;
@@ -308,7 +373,7 @@ impl KnowledgeRepo {
let query_vec = query_vec.to_vec();
tokio::task::spawn_blocking(move || {
let guard = conn.blocking_lock();
// 显式列: 14 个 KnowledgeRecord 字段 + embedding(余弦计算用,不入 KnowledgeRecord)
// 显式列: 15 个 KnowledgeRecord 字段(含 embedding_status)+ embedding(余弦计算用,不入 KnowledgeRecord)
let mut stmt = guard
.prepare(&format!(
"SELECT {KNOWLEDGE_COLS_WITH_EMBEDDING} FROM knowledges WHERE status = 'published' AND embedding IS NOT NULL"
@@ -347,17 +412,15 @@ impl KnowledgeRepo {
tokio::task::spawn_blocking(move || {
let guard = conn.blocking_lock();
let mut stmt = guard
.prepare(
"SELECT id,kind,title,content,tags,status,confidence,reuse_count,verified,\
source_project,source_ref,reasoning,created_at,updated_at \
FROM knowledges WHERE status != 'archived'
.prepare(&format!(
"SELECT {KNOWLEDGE_COLS} FROM knowledges WHERE status != 'archived'
ORDER BY CASE confidence
WHEN 'high' THEN 3
WHEN 'medium' THEN 2
WHEN 'low' THEN 1
ELSE 0
END DESC, created_at DESC",
)
))
.map_err(storage_err)?;
let rows = stmt
.query_map([], |row| knowledge_from_row(row))
@@ -379,7 +442,9 @@ impl KnowledgeRepo {
tokio::task::spawn_blocking(move || {
let guard = conn.blocking_lock();
let mut stmt = guard
.prepare("SELECT id,kind,title,content,tags,status,confidence,reuse_count,verified,source_project,source_ref,reasoning,created_at,updated_at FROM knowledges WHERE status = 'published' ORDER BY reuse_count DESC LIMIT ?1")
.prepare(&format!(
"SELECT {KNOWLEDGE_COLS} FROM knowledges WHERE status = 'published' ORDER BY reuse_count DESC LIMIT ?1"
))
.map_err(storage_err)?;
let rows = stmt
.query_map(params![limit_i], |row| knowledge_from_row(row))
@@ -539,6 +604,23 @@ mod tests {
}
}
/// IDEA_COLS 列数须等于 IDEA_COL_COUNT(任一处漂移:加列漏改 INSERT/UPDATE/from_row
/// 三处之一 → 立即失败)。`idea_from_row` 按 name 取列,SELECT/INSERT 漏列会在运行时被
/// rusqlite 报错;此断言提前到测试期捕获(本次 V24 加 related_ids 已改 3 处的回归保险)。
#[test]
fn test_idea_cols_matches_record() {
let count = IDEA_COLS.split(',').count();
assert_eq!(
count, IDEA_COL_COUNT,
"IDEA_COLS({count}列) ≠ IDEA_COL_COUNT({IDEA_COL_COUNT}); \
修改一处须同步另一处(INSERT/UPDATE/from_row/IDEA_COLS/IDEA_COL_COUNT 五处)"
);
// 每个列名须能被 split 出来(防末尾多逗号 / 空段)
for col in IDEA_COLS.split(',') {
assert!(!col.is_empty(), "IDEA_COLS 含空列名段");
}
}
// ---------- 向量纯函数 ----------
#[test]
@@ -631,6 +713,7 @@ mod tests {
source_project: Some("proj-1".to_string()),
source_ref: Some("conv:c1".to_string()),
reasoning: None,
embedding_status: None,
created_at: "1700000000000".to_string(),
updated_at: "1700000000000".to_string(),
}
@@ -868,4 +951,105 @@ mod tests {
let results = repo.search_vector(&[1.0, 0.0], 10).await.unwrap();
assert!(results.is_empty());
}
// ---------- V23 embedding_status 补偿重试 ----------
#[tokio::test]
async fn set_embedding_marks_status_done() {
// V23:set_embedding 成功写入时应同步置 embedding_status='done'
let repo = setup_repo().await;
repo.insert(krec("k1", "t", "c", "lesson", "published", None, 0))
.await
.unwrap();
repo.set_embedding("k1", &[1.0, 0.0]).await.unwrap();
let rec = repo.get_by_id("k1").await.unwrap().expect("记录存在");
assert_eq!(rec.embedding_status.as_deref(), Some("done"));
}
#[tokio::test]
async fn mark_embedding_failed_sets_status() {
let repo = setup_repo().await;
repo.insert(krec("k1", "t", "c", "lesson", "published", None, 0))
.await
.unwrap();
assert!(repo.mark_embedding_failed("k1").await.unwrap());
let rec = repo.get_by_id("k1").await.unwrap().expect("记录存在");
assert_eq!(rec.embedding_status.as_deref(), Some("failed"));
}
#[tokio::test]
async fn mark_embedding_failed_idempotent() {
// 重复标记 failed 无副作用
let repo = setup_repo().await;
repo.insert(krec("k1", "t", "c", "lesson", "published", None, 0))
.await
.unwrap();
repo.mark_embedding_failed("k1").await.unwrap();
assert!(repo.mark_embedding_failed("k1").await.unwrap());
let rec = repo.get_by_id("k1").await.unwrap().unwrap();
assert_eq!(rec.embedding_status.as_deref(), Some("failed"));
}
#[tokio::test]
async fn mark_embedding_failed_missing_row_returns_false() {
let repo = setup_repo().await;
// 不存在的 id → affected=0
let ok = repo.mark_embedding_failed("ghost").await.unwrap();
assert!(!ok);
}
#[tokio::test]
async fn list_failed_embeddings_only_returns_published_failed() {
let repo = setup_repo().await;
// k1:published + failed → 应列出
repo.insert(krec("k1", "t", "c", "lesson", "published", None, 0))
.await
.unwrap();
repo.mark_embedding_failed("k1").await.unwrap();
// k2:candidate + failed → 不应列出(候选不参与检索,重试无意义)
repo.insert(krec("k2", "t", "c", "lesson", "candidate", None, 0))
.await
.unwrap();
repo.mark_embedding_failed("k2").await.unwrap();
// k3:published + done → 不应列出
repo.insert(krec("k3", "t", "c", "lesson", "published", None, 0))
.await
.unwrap();
repo.set_embedding("k3", &[1.0, 0.0]).await.unwrap();
// k4:published + 未标记(NULL)→ 不应列出
repo.insert(krec("k4", "t", "c", "lesson", "published", None, 0))
.await
.unwrap();
let failed = repo.list_failed_embeddings().await.unwrap();
let ids: Vec<_> = failed.iter().map(|r| r.id.as_str()).collect();
assert_eq!(ids, vec!["k1"], "只应返回 published + failed 的条目");
}
#[tokio::test]
async fn list_failed_embeddings_empty_when_none_failed() {
let repo = setup_repo().await;
repo.insert(krec("k1", "t", "c", "lesson", "published", None, 0))
.await
.unwrap();
repo.set_embedding("k1", &[1.0, 0.0]).await.unwrap();
let failed = repo.list_failed_embeddings().await.unwrap();
assert!(failed.is_empty());
}
#[tokio::test]
async fn failed_retry_flow_done_after_set_embedding() {
// 补偿重试完整流程:failed → set_embedding 成功 → done(不再出现在 list_failed)
let repo = setup_repo().await;
repo.insert(krec("k1", "t", "c", "lesson", "published", None, 0))
.await
.unwrap();
repo.mark_embedding_failed("k1").await.unwrap();
assert_eq!(repo.list_failed_embeddings().await.unwrap().len(), 1);
// 重试成功
repo.set_embedding("k1", &[0.5, 0.5]).await.unwrap();
assert!(repo.list_failed_embeddings().await.unwrap().is_empty());
let rec = repo.get_by_id("k1").await.unwrap().unwrap();
assert_eq!(rec.embedding_status.as_deref(), Some("done"));
}
}