feat: 增强 Claude 项目理解能力——历史快照、近期提交、任务卡详情、AI 笔记

- db: 新增 project_snapshots 表,每日一条,UNIQUE(project_id, snapshot_date)
- mentor: 启动扫描写入快照(days_since_commit / blueprint_done_rate / blocked / errors)
- mentor: ProjectHealth 加入 recent_commits(最近 10 条含日期)
- mentor: MentorContext 加入 active_tasks(进行中/阻塞任务卡含 files/acceptance)
- mentor: build_mentor_markdown 加入 AI 笔记段落(跨会话记忆)
- mcp: 新增 get_project_snapshots 工具,Claude 可查询历史趋势数据
This commit is contained in:
lanrtop 2026-04-08 04:03:42 +09:00
parent f1cecd1601
commit 8d646c6cbb
3 changed files with 358 additions and 17 deletions

View File

@ -11,6 +11,8 @@ pub struct ProjectHealth {
pub has_git: bool,
/// git log --oneline -1 输出
pub last_commit: Option<String>,
/// 最近 10 条提交hash + 日期 + message
pub recent_commits: Vec<String>,
/// 项目记录的技术栈
pub tech_stack: Option<String>,
}
@ -35,12 +37,23 @@ pub struct RuntimeLogEntry {
pub message: String,
}
#[derive(Debug, Serialize)]
pub struct ActiveTaskCard {
pub module_name: String,
pub title: String,
pub status: String,
pub files: Option<String>,
pub acceptance: Option<String>,
pub blocked_reason: Option<String>,
}
#[derive(Debug, Serialize)]
pub struct MentorContext {
pub project_id: String,
pub project_name: String,
pub health: ProjectHealth,
pub blueprint_summary: Option<BlueprintSummary>,
pub active_tasks: Vec<ActiveTaskCard>,
pub recent_errors: Vec<RuntimeLogEntry>,
/// 格式化好的 Markdown可直接复制给 Claude
pub mentor_markdown: String,
@ -96,22 +109,24 @@ pub fn get_mentor_context(project_id: String) -> Result<MentorContext, String> {
};
// 3. 本地健康检查
let (path_valid, has_git, last_commit) = match (is_wsl, &wsl_linux_path, &wsl_distro) {
let (path_valid, has_git, last_commit, recent_commits) = match (is_wsl, &wsl_linux_path, &wsl_distro) {
(true, Some(linux_path), Some(distro)) => {
let pv = wsl_test_dir(distro, linux_path);
let hg = pv && wsl_test_dir(distro, &format!("{}/.git", linux_path));
let lc = if hg { wsl_git_log(distro, linux_path) } else { None };
(pv, hg, lc)
let rc = if hg { wsl_git_log_recent(distro, linux_path) } else { vec![] };
(pv, hg, lc, rc)
}
(true, _, _) => (false, false, None),
(true, _, _) => (false, false, None, vec![]),
(false, _, _) => match win_path.as_deref().filter(|p| !p.is_empty()).map(Path::new) {
Some(root) => {
let pv = root.exists();
let hg = root.join(".git").exists();
let lc = if hg { win_git_log(root) } else { None };
(pv, hg, lc)
let rc = if hg { win_git_log_recent(root) } else { vec![] };
(pv, hg, lc, rc)
}
None => (false, false, None),
None => (false, false, None, vec![]),
},
};
@ -119,6 +134,7 @@ pub fn get_mentor_context(project_id: String) -> Result<MentorContext, String> {
path_valid,
has_git,
last_commit,
recent_commits,
tech_stack,
};
@ -133,16 +149,35 @@ pub fn get_mentor_context(project_id: String) -> Result<MentorContext, String> {
}
};
// 4b. 进行中 / 阻塞任务卡详情
let active_tasks = match (is_wsl, &wsl_linux_path, &wsl_distro) {
(true, Some(linux_path), Some(distro)) => {
let unc_root = linux_to_unc(linux_path, distro);
read_active_task_cards(&unc_root)
}
(true, _, _) => vec![],
(false, _, _) => {
win_path.as_deref().filter(|p| !p.is_empty()).map(Path::new)
.map(read_active_task_cards)
.unwrap_or_default()
}
};
// 5. 近期错误日志
let recent_errors = read_recent_errors(&conn, &project_id);
// 5b. AI 笔记(最近 5 条,作为跨会话记忆)
let ai_notes = read_ai_notes(&conn, &project_id);
// 6. 生成 mentor_markdown
let mentor_markdown = build_mentor_markdown(
&project_id,
&project_name,
&health,
&blueprint_summary,
&active_tasks,
&recent_errors,
&ai_notes,
);
Ok(MentorContext {
@ -150,6 +185,7 @@ pub fn get_mentor_context(project_id: String) -> Result<MentorContext, String> {
project_name,
health,
blueprint_summary,
active_tasks,
recent_errors,
mentor_markdown,
})
@ -277,6 +313,41 @@ fn win_git_log(root: &Path) -> Option<String> {
.filter(|s| !s.is_empty())
}
/// 最近 10 条提交,格式:`abc1234 2026-04-08 fix: something`
fn win_git_log_recent(root: &Path) -> Vec<String> {
let output = crate::silent_cmd("git")
.args(["log", "--pretty=format:%h %ad %s", "--date=short", "-10"])
.current_dir(root)
.output();
match output {
Ok(o) if o.status.success() => String::from_utf8_lossy(&o.stdout)
.lines()
.filter(|l| !l.is_empty())
.map(|l| l.to_string())
.collect(),
_ => vec![],
}
}
/// WSL 版最近 10 条提交
fn wsl_git_log_recent(distro: &str, linux_path: &str) -> Vec<String> {
let output = crate::silent_cmd("wsl")
.args([
"-d", distro, "--", "git", "-C", linux_path,
"log", "--pretty=format:%h %ad %s", "--date=short", "-10",
])
.stderr(std::process::Stdio::null())
.output();
match output {
Ok(o) if o.status.success() => String::from_utf8_lossy(&o.stdout)
.lines()
.filter(|l| !l.is_empty())
.map(|l| l.to_string())
.collect(),
_ => vec![],
}
}
// ── 蓝图摘要 ──────────────────────────────────────────────────────────────────
/// WSL 项目:用 wsl 命令判断 manifest 存在性,再通过 UNC 路径读取内容
@ -335,6 +406,31 @@ fn parse_blueprint_summary(root: &Path) -> Option<BlueprintSummary> {
}
}
/// 从项目根路径读取蓝图,返回所有进行中/阻塞的任务卡
fn read_active_task_cards(root: &Path) -> Vec<ActiveTaskCard> {
match crate::commands::blueprint::get_blueprint(root.to_string_lossy().to_string()) {
Ok(Some(data)) => data
.manifest
.modules
.into_iter()
.flat_map(|m| {
let module_name = m.name.clone();
m.tasks.into_iter()
.filter(|t| t.status == "in_progress" || t.status == "blocked")
.map(move |t| ActiveTaskCard {
module_name: module_name.clone(),
title: t.title,
status: t.status,
files: t.files,
acceptance: t.acceptance,
blocked_reason: t.blocked_reason,
})
})
.collect(),
_ => vec![],
}
}
// ── 错误日志 ──────────────────────────────────────────────────────────────────
fn read_recent_errors(
@ -362,6 +458,29 @@ fn read_recent_errors(
result.unwrap_or_default()
}
fn read_ai_notes(conn: &rusqlite::Connection, project_id: &str) -> Vec<ProjectNote> {
let mut stmt = match conn.prepare(
"SELECT id, project_id, source, content, created_at
FROM project_notes
WHERE project_id = ?1
ORDER BY id DESC LIMIT 5",
) {
Ok(s) => s,
Err(_) => return vec![],
};
stmt.query_map(params![project_id], |row| {
Ok(ProjectNote {
id: row.get(0)?,
project_id: row.get(1)?,
source: row.get(2)?,
content: row.get(3)?,
created_at: row.get(4)?,
})
})
.map(|iter| iter.filter_map(|r| r.ok()).collect())
.unwrap_or_default()
}
// ── Markdown 生成 ─────────────────────────────────────────────────────────────
fn build_mentor_markdown(
@ -369,7 +488,9 @@ fn build_mentor_markdown(
project_name: &str,
health: &ProjectHealth,
blueprint: &Option<BlueprintSummary>,
active_tasks: &[ActiveTaskCard],
errors: &[RuntimeLogEntry],
ai_notes: &[ProjectNote],
) -> String {
let mut md = String::new();
@ -389,6 +510,16 @@ fn build_mentor_markdown(
md.push_str(&format!("- 最近提交:`{}`\n", commit));
}
// ── 近期提交历史 ──────────────────────────────────────────────────────────
md.push_str("\n## 近期提交(最近 10 条)\n\n");
if health.recent_commits.is_empty() {
md.push_str("暂无提交记录。\n");
} else {
for c in &health.recent_commits {
md.push_str(&format!("- `{}`\n", c));
}
}
// ── 蓝图状态 ──────────────────────────────────────────────────────────────
md.push_str("\n## 蓝图状态\n\n");
if let Some(ref bp) = blueprint {
@ -409,6 +540,30 @@ fn build_mentor_markdown(
md.push_str("该项目尚未接入蓝图(无 .blueprint/manifest.yaml\n");
}
// ── 进行中 / 阻塞任务卡 ───────────────────────────────────────────────────
if !active_tasks.is_empty() {
let in_progress: Vec<_> = active_tasks.iter().filter(|t| t.status == "in_progress").collect();
let blocked: Vec<_> = active_tasks.iter().filter(|t| t.status == "blocked").collect();
if !in_progress.is_empty() {
md.push_str("\n## 进行中任务\n\n");
for t in &in_progress {
md.push_str(&format!("### [{}] {}\n", t.module_name, t.title));
if let Some(ref f) = t.files { md.push_str(&format!("- files: `{}`\n", f)); }
if let Some(ref a) = t.acceptance { md.push_str(&format!("- acceptance: {}\n", a)); }
}
}
if !blocked.is_empty() {
md.push_str("\n## 阻塞任务\n\n");
for t in &blocked {
md.push_str(&format!("### [{}] {}\n", t.module_name, t.title));
if let Some(ref r) = t.blocked_reason { md.push_str(&format!("- blocked_reason: {}\n", r)); }
if let Some(ref f) = t.files { md.push_str(&format!("- files: `{}`\n", f)); }
}
}
}
// ── 近期错误日志 ──────────────────────────────────────────────────────────
md.push_str("\n## 近期错误日志(最多 20 条)\n\n");
if errors.is_empty() {
@ -419,7 +574,15 @@ fn build_mentor_markdown(
}
}
md.push_str("\n---\n\n请根据以上信息,对该项目的当前状态、质量和下一步方向给出你的判断与建议。\n");
// ── AI 笔记(跨会话记忆)────────────────────────────────────────────────────
if !ai_notes.is_empty() {
md.push_str("\n## AI 笔记(历史观察)\n\n");
for note in ai_notes {
md.push_str(&format!("- `{}` {}\n", note.created_at, note.content));
}
}
md.push_str("\n---\n\n请根据以上信息,对该项目的当前状态、质量和下一步方向给出你的判断与建议。如需历史趋势数据,可调用 `get_project_snapshots` 工具。\n");
md
}
@ -525,7 +688,8 @@ pub fn run_startup_health_scan() {
for (project_id, project_name, win_path, wsl_path, platform) in rows {
// 1. 检查 git 活跃度(超 14 天无提交)
// 直接获取距今天数None 表示路径不存在或无 git跳过不是"停滞"
if let Some(days) = get_days_since_last_commit(platform.as_deref(), win_path.as_deref(), wsl_path.as_deref(), &conn) {
let days_opt = get_days_since_last_commit(platform.as_deref(), win_path.as_deref(), wsl_path.as_deref(), &conn);
if let Some(days) = days_opt {
if days > 14 {
// \x1f (ASCII Unit Separator) 作分隔符,避免项目名含 | 导致解析错位
crate::db::log_event(
@ -561,8 +725,25 @@ pub fn run_startup_health_scan() {
win_path.as_deref().map(|p| std::path::Path::new(p).join(".blueprint"))
};
if let Some(bp_dir) = blueprint_path_opt {
let blocked_count = count_blocked_tasks(&bp_dir);
let blocked_count = blueprint_path_opt
.as_ref()
.map(|bp_dir| count_blocked_tasks(bp_dir))
.unwrap_or(0);
let (bp_done_rate, bp_in_progress) = blueprint_path_opt
.as_ref()
.and_then(|bp_dir| bp_dir.parent())
.and_then(parse_blueprint_summary)
.map(|s| {
let rate = if s.module_count > 0 {
Some(s.done_count as f64 / s.module_count as f64)
} else {
None
};
(rate, s.in_progress)
})
.unwrap_or((None, 0));
if blocked_count > 0 {
crate::db::log_event(
"startup_alert",
@ -571,7 +752,18 @@ pub fn run_startup_health_scan() {
Some(&format!(r#"{{"project_id":"{}"}}"#, project_id)),
);
}
}
// 写入每日快照INSERT OR IGNORE 保证每天只存一条)
let has_errors = has_recent_errors(&conn, &project_id);
save_project_snapshot(
&conn,
&project_id,
days_opt,
bp_done_rate,
bp_in_progress,
blocked_count,
has_errors,
);
}
}
@ -632,6 +824,45 @@ fn count_blocked_tasks(blueprint_dir: &std::path::Path) -> usize {
count
}
/// 检查该项目在最近 7 天内是否有 error 级别的日志
fn has_recent_errors(conn: &rusqlite::Connection, project_id: &str) -> bool {
conn.query_row(
"SELECT COUNT(*) FROM runtime_logs
WHERE level = 'error'
AND (json_extract(context, '$.project_id') = ?1 OR context IS NULL)
AND ts >= datetime('now', '-7 days')",
params![project_id],
|r| r.get::<_, i64>(0),
)
.unwrap_or(0) > 0
}
/// 写入项目每日快照INSERT OR IGNORE 保证每天只存一条
fn save_project_snapshot(
conn: &rusqlite::Connection,
project_id: &str,
days_since_commit: Option<u64>,
blueprint_done_rate: Option<f64>,
blueprint_in_progress: u32,
blueprint_blocked: usize,
has_errors: bool,
) {
let _ = conn.execute(
"INSERT OR IGNORE INTO project_snapshots
(project_id, snapshot_date, days_since_commit, blueprint_done_rate,
blueprint_in_progress, blueprint_blocked, has_errors)
VALUES (?1, date('now', 'localtime'), ?2, ?3, ?4, ?5, ?6)",
params![
project_id,
days_since_commit.map(|d| d as i64),
blueprint_done_rate,
blueprint_in_progress,
blueprint_blocked as i64,
if has_errors { 1i64 } else { 0i64 },
],
);
}
#[tauri::command]
pub fn get_startup_alerts() -> Result<Vec<StartupAlert>, String> {
let conn = pool().get().map_err(|e| e.to_string())?;

View File

@ -514,6 +514,22 @@ fn migrate(conn: &rusqlite::Connection) {
sort_order INTEGER DEFAULT 0
);
").expect("create global_tools table");
// 增量迁移:项目历史快照(每日一条,供 Claude 做趋势分析)
conn.execute_batch("
CREATE TABLE IF NOT EXISTS project_snapshots (
id INTEGER PRIMARY KEY AUTOINCREMENT,
project_id TEXT NOT NULL,
snapshot_date TEXT NOT NULL,
days_since_commit INTEGER,
blueprint_done_rate REAL,
blueprint_in_progress INTEGER DEFAULT 0,
blueprint_blocked INTEGER DEFAULT 0,
has_errors INTEGER DEFAULT 0,
created_at TEXT DEFAULT (datetime('now')),
UNIQUE(project_id, snapshot_date)
);
").expect("create project_snapshots table");
}
#[cfg(test)]

View File

@ -180,6 +180,24 @@ fn tools_list_result() -> Value {
},
"required": ["project_id", "content"]
}
},
{
"name": "get_project_snapshots",
"description": "获取项目的历史健康快照(每日一条),包含 git 提交间隔、蓝图完成率、阻塞任务数、错误情况。用于分析趋势、识别高价值改进变量。",
"inputSchema": {
"type": "object",
"properties": {
"project_id": {
"type": "string",
"description": "项目 workspace ID来自 list_group_projects 的返回结果)"
},
"days": {
"type": "integer",
"description": "查询最近 N 天的快照,默认 30"
}
},
"required": ["project_id"]
}
}
]
})
@ -223,6 +241,11 @@ async fn tools_call(group_id: &str, params: Option<&Value>) -> Value {
let content = args.get("content").and_then(|v| v.as_str()).unwrap_or("");
append_project_note(&gid, pid, content)
}
"get_project_snapshots" => {
let pid = args.get("project_id").and_then(|v| v.as_str()).unwrap_or("");
let days = args.get("days").and_then(|v| v.as_u64()).unwrap_or(30) as u32;
get_project_snapshots(&gid, pid, days)
}
_ => Err(format!("未知工具: {}", tool_name)),
})
.await;
@ -502,3 +525,74 @@ fn append_project_note(group_id: &str, project_id: &str, content: &str) -> Resul
.map_err(|e| e.to_string())?;
Ok("笔记已保存 ✅".to_string())
}
fn get_project_snapshots(group_id: &str, project_id: &str, days: u32) -> Result<String, String> {
let conn = db::pool().get().map_err(|e| e.to_string())?;
// 校验项目属于该产品组
let exists: bool = conn
.query_row(
"SELECT COUNT(*) FROM project_group_map WHERE group_id = ?1 AND project_id = ?2",
[group_id, project_id],
|r| r.get::<_, i64>(0),
)
.map(|n| n > 0)
.unwrap_or(false);
if !exists {
return Err(format!("项目 {} 不属于产品组 {},或项目不存在", project_id, group_id));
}
let mut stmt = conn
.prepare(
"SELECT snapshot_date, days_since_commit, blueprint_done_rate,
blueprint_in_progress, blueprint_blocked, has_errors
FROM project_snapshots
WHERE project_id = ?1
AND snapshot_date >= date('now', 'localtime', ?2)
ORDER BY snapshot_date ASC",
)
.map_err(|e| e.to_string())?;
let offset = format!("-{} days", days);
let rows: Vec<(String, Option<i64>, Option<f64>, i64, i64, i64)> = stmt
.query_map(rusqlite::params![project_id, offset], |r| {
Ok((r.get(0)?, r.get(1)?, r.get(2)?, r.get(3)?, r.get(4)?, r.get(5)?))
})
.map_err(|e| e.to_string())?
.filter_map(|r| r.ok())
.collect();
if rows.is_empty() {
return Ok(format!(
"项目 `{}` 暂无历史快照(快照在每次应用启动时写入,最多保留 {} 天)。",
project_id, days
));
}
let mut md = format!(
"# 项目历史快照:`{}`\n\n最近 {} 天,共 {} 条记录\n\n",
project_id,
days,
rows.len()
);
md.push_str("| 日期 | 距上次提交(天) | 蓝图完成率 | 进行中模块 | 阻塞任务 | 有错误 |\n");
md.push_str("|------|--------------|-----------|-----------|---------|------|\n");
for (date, days_commit, done_rate, in_prog, blocked, has_err) in &rows {
let commit_col = days_commit
.map(|d| d.to_string())
.unwrap_or_else(|| "".to_string());
let rate_col = done_rate
.map(|r| format!("{:.0}%", r * 100.0))
.unwrap_or_else(|| "".to_string());
let err_col = if *has_err == 1 { "⚠️" } else { "" };
md.push_str(&format!(
"| {} | {} | {} | {} | {} | {} |\n",
date, commit_col, rate_col, in_prog, blocked, err_col
));
}
md.push_str("\n---\n\n请根据以上趋势数据,分析哪些变量与项目停滞或健康度下降最相关,并给出改进建议。\n");
Ok(md)
}