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

后端:
- 工作流推进链(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

@@ -0,0 +1,299 @@
//! 灵感评估历史 Repo:IdeaEvalRepo(追加型审计表 idea_evaluations,V22)
//!
//! 每次灵感 AI 评估追加一行快照(version 单调递增),保留评估历史可追溯。
//! 对齐 [`KnowledgeEventsRepo`](crate::crud::KnowledgeEventsRepo) 模式:
//! `impl_repo!` 宏生成基础 CRUD(insert/get_by_id/list_all/query/update_field/delete/
//! update_full)+ 额外专用方法 `list_by_idea`(按 idea_id 过滤,version DESC 排序)。
//!
//! # ⚠️ 宏 list_all / query 不可用(表无 created_at)
//!
//! 本表时间列是 `evaluated_at`(非 `created_at`),但 `impl_repo!` 宏生成的
//! `list_all` / `query` 硬编码 `ORDER BY created_at DESC`(见 crud/mod.rs 宏内
//! `ORDER BY created_at DESC` 字面量)。误调 `state.idea_eval.list_all()` 或
//! `state.idea_eval.query(...)` 会触发 SQLite "no such column: created_at",运行时崩。
//! **跨灵感按时间倒序浏览评估历史须走专用方法 [`IdeaEvalRepo::list_recent_idea_evals`]**
//! (按 evaluated_at DESC + limit 钳制 200),对标 `KnowledgeEventsRepo::list_recent`
//! 对 knowledge_events 表的同款兜底处理(那张表亦无 created_at,时间列名是 timestamp)。
use std::sync::Arc;
use rusqlite::{params, Connection, OptionalExtension, Row};
use tokio::sync::Mutex;
use df_types::error::Result;
use crate::db::Database;
use crate::models::IdeaEvaluationRecord;
use super::impl_repo;
use super::{now_millis_str, storage_err, validate_column_name};
// ============================================================
// from_row 辅助函数
// ============================================================
/// 按 name 取 idea_evaluations 表 8 列 → IdeaEvaluationRecord。
fn idea_eval_from_row(row: &Row<'_>) -> std::result::Result<IdeaEvaluationRecord, rusqlite::Error> {
Ok(IdeaEvaluationRecord {
id: row.get("id")?,
idea_id: row.get("idea_id")?,
version: row.get("version")?,
ai_analysis: row.get("ai_analysis")?,
scores: row.get("scores")?,
score: row.get("score")?,
evaluated_by: row.get("evaluated_by")?,
evaluated_at: row.get("evaluated_at")?,
})
}
// ============================================================
// Repo 实现
// ============================================================
impl_repo!(
/// 灵感评估历史表 CRUD(追加型审计表,只增不改)
IdeaEvalRepo,
IdeaEvaluationRecord,
"idea_evaluations",
from_row => |row| idea_eval_from_row(row),
insert => |conn, rec| {
conn.execute(
"INSERT INTO idea_evaluations (id, idea_id, version, ai_analysis, scores, score, evaluated_by, evaluated_at)
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8)",
params![
rec.id, rec.idea_id, rec.version, rec.ai_analysis,
rec.scores, rec.score, rec.evaluated_by, rec.evaluated_at
],
)
},
update => |_conn, _rec| {
// 加固(审计不可篡改):追加型审计表语义禁止 UPDATE(历史快照只增不改,版本单调递增)。
// 宏(impl_repo!)要求 update 闭包生成 update_full,但本表设计上仅 insert 追加新版本。
// 若有调用方误调 update_full → 返回 Err(SQLITE_ERROR)而非真执行 UPDATE,杜绝历史被
// 静默篡改(否则审计追溯失真,版本单调性被破坏)。Err 经 storage_err 映射为
// Error::Storage,调用方拿到 Err 可发现误用。注:update_field 走宏通用 SQL 路径,
// 不经此闭包,故本表 update_field 同样不应被调用(调用方约束,非编译期保证)。
//
// 显式标注 rusqlite::Result<usize>(宏吃闭包 expr 不能写 -> 返回类型,改 let 绑定标注):
// 闭包返回 Err 经 update_full 的 .map_err(storage_err)? → Err(Error::Storage),
// affected=usize 的类型锚点确保宏内 `affected > 0` 编译。
let res: rusqlite::Result<usize> = Err(rusqlite::Error::SqliteFailure(
rusqlite::ffi::Error::new(rusqlite::ffi::SQLITE_ERROR),
Some("idea_evaluations 为追加型审计表,禁止 UPDATE(仅 insert 追加新版本)".to_string()),
));
res
}
);
impl IdeaEvalRepo {
/// 按 idea_id 查询全部评估历史(version DESC,最新版本在前)。
///
/// 对标 [`KnowledgeEventsRepo::list_by_knowledge`](crate::crud::KnowledgeEventsRepo::list_by_knowledge)
/// 的 spawn_blocking + prepare + query_map 模式。前端取最新评估直接取返回 Vec 首元素。
pub async fn list_by_idea(&self, idea_id: &str) -> Result<Vec<IdeaEvaluationRecord>> {
let conn = self.conn.clone();
let idea_id = idea_id.to_owned();
tokio::task::spawn_blocking(move || {
let guard = conn.blocking_lock();
let mut stmt = guard
.prepare(
"SELECT id,idea_id,version,ai_analysis,scores,score,evaluated_by,evaluated_at \
FROM idea_evaluations WHERE idea_id = ?1 ORDER BY version DESC",
)
.map_err(storage_err)?;
let rows = stmt
.query_map(params![idea_id], |row| idea_eval_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)?
}
/// 跨灵感列最近 N 条评估历史(全表 evaluated_at DESC,top-N)。
///
/// **专用兜底方法**:本表时间列名是 `evaluated_at` 而非 `created_at`,但
/// `impl_repo!` 宏生成的 `query()` / `list_all()` 硬编码 `ORDER BY created_at`
/// (见 crud/mod.rs 宏内 `ORDER BY created_at DESC` 字面量),误调
/// `state.idea_eval.list_all()` 或 `state.idea_eval.query(...)` 会触发 SQLite
/// "no such column: created_at"。本方法走专用 SELECT 绕过宏硬编码,供需要跨灵感
/// 按评估时间倒序浏览历史的调用方使用(对标 [`KnowledgeEventsRepo::list_recent`]
/// 对 knowledge_events 表的同款兜底处理——那张表同样无 created_at,时间列名是 timestamp)。
/// limit 上限钳制 200,防前端恶意/失误传超大值。
pub async fn list_recent_idea_evals(&self, limit: u32) -> Result<Vec<IdeaEvaluationRecord>> {
let conn = self.conn.clone();
// 钳制 limit 防滥用(最大 200)
let safe_limit = limit.min(200) as i64;
tokio::task::spawn_blocking(move || {
let guard = conn.blocking_lock();
let mut stmt = guard
.prepare(
"SELECT id,idea_id,version,ai_analysis,scores,score,evaluated_by,evaluated_at \
FROM idea_evaluations ORDER BY evaluated_at DESC LIMIT ?1",
)
.map_err(storage_err)?;
let rows = stmt
.query_map(params![safe_limit], |row| idea_eval_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)?
}
}
// ============================================================
// 单元测试 — IdeaEvalRepo 内存 DB(insert + list_by_idea 排序)
// ============================================================
#[cfg(test)]
mod tests {
use super::*;
use crate::db::Database;
fn erec(id: &str, idea_id: &str, version: i64, score: Option<f64>) -> IdeaEvaluationRecord {
IdeaEvaluationRecord {
id: id.to_string(),
idea_id: idea_id.to_string(),
version,
ai_analysis: Some("{\"ok\":true}".to_string()),
scores: Some("{\"overall\":1}".to_string()),
score,
evaluated_by: Some("glm-4".to_string()),
evaluated_at: "1700000000000".to_string(),
}
}
async fn setup_repo() -> IdeaEvalRepo {
let db = Database::open_in_memory().await.expect("open_in_memory");
IdeaEvalRepo::new(&db)
}
#[tokio::test]
async fn list_by_idea_orders_version_desc() {
let repo = setup_repo().await;
repo.insert(erec("e1", "idea_a", 1, Some(0.5))).await.unwrap();
repo.insert(erec("e2", "idea_a", 3, Some(0.9))).await.unwrap();
repo.insert(erec("e3", "idea_a", 2, Some(0.7))).await.unwrap();
repo.insert(erec("e4", "idea_b", 1, Some(0.1))).await.unwrap();
let list = repo.list_by_idea("idea_a").await.unwrap();
assert_eq!(list.len(), 3, "仅 idea_a 的 3 条评估");
let versions: Vec<i64> = list.iter().map(|r| r.version).collect();
assert_eq!(versions, vec![3, 2, 1], "version DESC: 最新在前");
let list_b = repo.list_by_idea("idea_b").await.unwrap();
assert_eq!(list_b.len(), 1);
assert_eq!(list_b[0].version, 1);
}
#[tokio::test]
async fn list_by_idea_empty_for_unknown_idea() {
let repo = setup_repo().await;
let list = repo.list_by_idea("nope").await.unwrap();
assert!(list.is_empty());
}
#[tokio::test]
async fn list_recent_idea_evals_orders_evaluated_at_desc_and_clamps_limit() {
// 兜底方法:跨灵感按 evaluated_at DESC + limit 钳制 200(规避宏硬编码
// ORDER BY created_at 致本表崩)。evaluated_at 为毫秒字符串,字典序与时间序一致。
let repo = setup_repo().await;
// erec 默认 evaluated_at 同值,这里覆盖以验证排序
let mut a = erec("e1", "idea_a", 1, Some(0.5));
a.evaluated_at = "1700000000001".to_string();
let mut b = erec("e2", "idea_b", 1, Some(0.6));
b.evaluated_at = "1700000000003".to_string();
let mut c = erec("e3", "idea_a", 2, Some(0.7));
c.evaluated_at = "1700000000002".to_string();
repo.insert(a).await.unwrap();
repo.insert(b).await.unwrap();
repo.insert(c).await.unwrap();
// limit=10 取全部,按 evaluated_at DESC(跨灵感)
let list = repo.list_recent_idea_evals(10).await.unwrap();
assert_eq!(list.len(), 3);
let times: Vec<&str> = list.iter().map(|r| r.evaluated_at.as_str()).collect();
assert_eq!(times, vec!["1700000000003", "1700000000002", "1700000000001"]);
// limit=2 截断到前 2
let list2 = repo.list_recent_idea_evals(2).await.unwrap();
assert_eq!(list2.len(), 2);
assert_eq!(list2[0].id, "e2");
assert_eq!(list2[1].id, "e3");
// limit 钳制:超大值被压到 200(不会崩,仅返回实际行数)
let list_big = repo.list_recent_idea_evals(u32::MAX).await.unwrap();
assert_eq!(list_big.len(), 3, "u32::MAX 钳制到 200,但表仅 3 行");
// 空表
let repo_empty = setup_repo().await;
assert!(repo_empty.list_recent_idea_evals(10).await.unwrap().is_empty());
}
#[tokio::test]
async fn insert_then_get_by_id_roundtrip() {
let repo = setup_repo().await;
let rec = erec("e1", "idea_a", 1, Some(0.42));
let id = repo.insert(rec.clone()).await.unwrap();
assert_eq!(id, "e1");
let got = repo.get_by_id("e1").await.unwrap().expect("记录存在");
assert_eq!(got.idea_id, "idea_a");
assert_eq!(got.version, 1);
assert!((got.score.unwrap() - 0.42).abs() < 1e-9);
}
#[tokio::test]
async fn optional_fields_persist_none() {
let repo = setup_repo().await;
let rec = IdeaEvaluationRecord {
id: "e1".to_string(),
idea_id: "idea_a".to_string(),
version: 1,
ai_analysis: None,
scores: None,
score: None,
evaluated_by: None,
evaluated_at: "1700000000000".to_string(),
};
repo.insert(rec).await.unwrap();
let got = repo.get_by_id("e1").await.unwrap().expect("记录存在");
assert!(got.ai_analysis.is_none());
assert!(got.scores.is_none());
assert!(got.score.is_none());
assert!(got.evaluated_by.is_none());
}
#[tokio::test]
async fn update_full_rejected_for_append_only_audit() {
// 加固(审计不可篡改):追加型审计表禁止 UPDATE。update_full 应返 Err 而非真执行
// (防误用静默篡改历史快照,破坏版本单调性 + 审计追溯)。调用方应走 insert 追加新版本。
let repo = setup_repo().await;
repo.insert(erec("e1", "idea_a", 1, Some(0.5))).await.unwrap();
// 试 update_full(篡改 version 1 → 2 + 改 score)
let mut rec = repo.get_by_id("e1").await.unwrap().expect("记录存在");
rec.version = 2;
rec.score = Some(0.99);
let res = repo.update_full(&rec).await;
assert!(
res.is_err(),
"update_full 应被拒(追加型审计表禁止 UPDATE),实际: {:?}",
res
);
// 原记录未被篡改(version 仍 1,score 仍 0.5)
let got = repo.get_by_id("e1").await.unwrap().expect("记录存在");
assert_eq!(got.version, 1, "原记录 version 不应被 update_full 篡改");
assert!(
(got.score.unwrap() - 0.5).abs() < 1e-9,
"原记录 score 不应被 update_full 篡改"
);
}
}