Skip to content
Clone

feat(agent): write summaries from commit bodies and diff scale

Titles alone cannot tell a refactor from a one-line fix, so the model
could only paraphrase what it was given: format_commits now appends each
commit body - first paragraph, trailers such as Signed-off-by dropped,
capped at 400 characters so one long message cannot eat the budget, and
skipped when the body merely repeats the subject. The user prompt adds
the interval-level scale (files changed, +adds/-deletes via
diff_short_stat) and how many commits were actually included, so the
write-up can size itself instead of producing the same paragraph for any
input; a missing stat degrades to the old prompt.

summary_system becomes an editorial brief: Markdown organised by theme
rather than by commit, 600-1200 characters, a 3-5 sentence lead on where
the work went and why, ## sections named after real content, named API /
config / behaviour changes, no invented motivation, 4-8 falsifiable
highlights, and an empty risks array when the material supports none.
ZhenYi committed
parent 971c37c commit 4c3ba81 Browse files

3 files changed

+152 −6

File tree 3
Modified lib/agent/src/context.rs View lib/agent/src/context.rs
@@ -79,6 +79,11 @@
7979 }
8080
8181 /// 提交列表(评审用不到,但总结/release 复用同一格式化)。
82 ///
83 /// 附上正文首段:提交标题往往只有「fix bug」这类动作,真正说明意图、影响面
84 /// 与取舍的信息在正文里。总结与 release 文案靠这些内容才能写出实质差别,
85 /// 只喂标题时模型只能把标题换个说法复述一遍——那正是输出干瘪的直接原因。
86 /// 正文按提交截断,避免一条超长 message 把预算吃光。
8287 pub(crate) fn format_commits(commits: &[git::service::merge::MergeCommitDto]) -> String {
8388 let mut out = String::new();
8489 for commit in commits {
@@ -89,6 +94,9 @@
8994 commit.author_name,
9095 commit.author_at
9196 ));
97 if let Some(body) = commit_body(&commit.message, &commit.subject) {
98 out.push_str(&body);
99 }
92100 }
93101 out
94102 }
@@ -140,5 +148,85 @@
140148 sha.chars().take(8).collect()
141149}
142150
151/// 单条提交正文进入提示词的字符上限。
152const COMMIT_BODY_MAX_CHARS: usize = 400;
153
154/// 提交正文的摘要片段:跳过标题行、去掉签名等尾部噪音、保留首个实质段落。
155fn commit_body(message: &str, subject: &str) -> Option<String> {
156 let mut lines = message.lines();
157 // 第一行是标题,调用方已经输出过。
158 lines.next();
159 let mut body = String::new();
160 for line in lines {
161 let trimmed = line.trim();
162 if trimmed.is_empty() {
163 // 首个空行代表首个段落结束;段落之前的前导空行直接跳过。
164 if !body.is_empty() {
165 break;
166 }
167 continue;
168 }
169 // 「Signed-off-by:」「Co-authored-by:」这类 trailer 不描述变更本身。
170 let lower = trimmed.to_ascii_lowercase();
171 if lower.starts_with("signed-off-by:") || lower.starts_with("co-authored-by:") {
172 continue;
173 }
174 body.push_str(trimmed);
175 body.push(' ');
176 if body.chars().count() >= COMMIT_BODY_MAX_CHARS {
177 break;
178 }
179 }
180 let body = body.trim();
181 if body.is_empty() || body == subject.trim() {
182 return None;
183 }
184 Some(format!(
185 " 正文:{}\n",
186 truncate_chars(body, COMMIT_BODY_MAX_CHARS)
187 ))
188}
189
190fn truncate_chars(text: &str, limit: usize) -> String {
191 if text.chars().count() <= limit {
192 return text.to_string();
193 }
194 let mut out: String = text.chars().take(limit).collect();
195 out.push('…');
196 out
197}
198
199#[cfg(test)]
200mod tests {
201 use super::*;
202
203 /// 提交正文是总结质量的来源:只喂标题时模型无从区分「一次重构」与
204 /// 「一行修复」,输出必然退化成标题复述。这条测试把正文必须进入提示词
205 /// 这件事钉住,同时确认签名之类的尾部噪音不会被带上。
206 #[test]
207 fn commit_body_keeps_prose_and_drops_trailers() {
208 let message = "fix: 修正接收器计数\n\nBufferedUdpMetricSink 在批量发送后少记一次,\n对监控口径有影响。\n\nSigned-off-by: Someone <s@example.com>\n";
209 let body = commit_body(message, "fix: 修正接收器计数").expect("正文应被保留");
210 assert!(body.contains("少记一次"), "正文要点必须保留: {body}");
211 assert!(!body.contains("Signed-off-by"), "trailer 不应进入提示词: {body}");
212 }
213
214 /// 没有正文、或正文与标题重复时不必占用预算。
215 #[test]
216 fn commit_body_is_skipped_when_empty_or_identical() {
217 assert!(commit_body("only a subject", "only a subject").is_none());
218 assert!(commit_body("subject\n\nsubject", "subject").is_none());
219 }
220
221 /// 超长正文按字符截断,避免一条 message 吃掉整段提示词预算。
222 #[test]
223 fn commit_body_is_bounded() {
224 let long = format!("subject\n\n{}", "改".repeat(COMMIT_BODY_MAX_CHARS * 2));
225 let body = commit_body(&long, "subject").expect("应有正文");
226 assert!(body.chars().count() < COMMIT_BODY_MAX_CHARS + 16);
227 assert!(body.ends_with("…\n"));
228 }
229}
230
143231/// 让 `GitContext` 在本 crate 内可用(避免调用方各自引入 git crate)。
144232pub type RepoContext = GitContext;
Modified lib/agent/src/prompt.rs View lib/agent/src/prompt.rs
@@ -111,10 +111,26 @@
111111/// 仓库总结系统提示词。
112112pub fn summary_system(ctx: &PromptContext) -> String {
113113 format!(
114 "你是技术负责人,为团队撰写仓库近期更新的总结。\
115 基于给定的提交列表与变更统计归纳主题、亮点与风险,不复述提交消息原文。\n{lang}\
114 "你是资深技术负责人,为团队撰写仓库近期更新的评审级总结。\
115 读者是维护者与下游使用者:他们要靠这份总结判断「这段时间仓库往哪走了、\
116 有没有需要立刻关注的风险」,而不是看一遍提交标题。\n\
117 \n\
118 写作要求:\n\
119 1. content 使用 Markdown,按主题组织而不是按提交罗列,篇幅以 600-1200 字为宜:\
120 先用 3-5 句概述本批更新的整体走向与动机,再用 `##` 小节分主题展开\
121 (例如「核心功能」「稳定性与修复」「工程与依赖」「文档与测试」——\
122 按实际内容取舍,不要为了凑齐而编造主题)。\
123 2. 每个主题下写清:改了什么、为什么这么改、对使用者的可观察影响、\
124 以及值得注意的取舍或兼容性变化。有 API/配置/行为变更时必须点名。\
125 3. 只依据给定材料归纳,不臆测未出现的代码、动机或时间线;\
126 材料不足时如实说明范围有限,不要用套话填充。\
127 4. 不复述提交消息原文,不逐条翻译标题;同一主题下多条提交合并叙述。\
128 5. highlights 写 4-8 条具体成果,每条一句话、含可验证的信息\
129 (改了哪个组件/行为,而非「改进了性能」这类空话)。\
130 6. risks 只在材料支持时给出,每条写清风险点、成立条件与建议关注方向;\
131 确实没有明显风险时给出空数组,不要为凑数制造问题。\n{lang}\
116132 按以下 JSON 结构输出,不要输出任何解释文字:\
117 {{\"content\":\"Markdown 总结\",\"highlights\":[\"亮点\"],\"risks\":[\"风险\"]}}。",
133 {{\"content\":\"Markdown 总结正文\",\"highlights\":[\"具体亮点\"],\"risks\":[\"风险点\"]}}。",
118134 lang = ctx.language_clause()
119135 )
120136}
@@ -177,9 +193,24 @@
177193}
178194
179195/// 组装仓库总结请求的用户消息。
180pub fn summary_user_prompt(repo_name: &str, revision: &str, commits: &str) -> String {
196///
197/// `scale` 是区间级的改动规模(文件数 / 增删行),`commit_count` 是实际纳入的
198/// 提交条数:两者一起让模型知道自己看到的是一大批工作还是零星几次提交,
199/// 从而决定内容的详略,而不是对任何输入都写同样长短的一段话。
200pub fn summary_user_prompt(
201 repo_name: &str,
202 revision: &str,
203 commit_count: usize,
204 scale: Option<&str>,
205 commits: &str,
206) -> String {
207 let scale_line = match scale {
208 Some(value) => format!("改动规模:{value}\n"),
209 None => String::new(),
210 };
181211 format!(
182 "仓库:{repo_name}\n分支/版本:{revision}\n\n以下是最新提交(从新到旧)与变更统计:\n\n{commits}\n"
212 "仓库:{repo_name}\n分支/版本:{revision}\n提交条数:{commit_count}\n{scale_line}\n\
213 以下是最新提交(从新到旧),每条含标题与正文摘要:\n\n{commits}\n"
183214 )
184215}
185216
Modified lib/agent/src/service.rs View lib/agent/src/service.rs
@@ -496,7 +496,34 @@
496496 .map_err(|e| AgentError::Execution(format!("list commits: {e}")))?,
497497 };
498498 let commit_text = Self::format_commits(&trimmed);
499 let prompt = summary_user_prompt(&repo_name, head_sha, &commit_text);
499 // 区间级的改动规模:让模型知道这批更新是「大量工作」还是「零星几次
500 // 提交」,据此决定详略。取不到就省略,不影响生成。
501 let scale = match base_sha {
502 Some(base) => self
503 .git
504 .diff_short_stat(
505 space.clone(),
506 repo_name.clone(),
507 base.to_string(),
508 head_sha.to_string(),
509 )
510 .await
511 .ok()
512 .map(|stat| {
513 format!(
514 "{} 个文件,+{} / -{} 行",
515 stat.files_changed, stat.additions, stat.deletions
516 )
517 }),
518 None => None,
519 };
520 let prompt = summary_user_prompt(
521 &repo_name,
522 head_sha,
523 trimmed.len(),
524 scale.as_deref(),
525 &commit_text,
526 );
500527 let lang = self
501528 .resolve_language(None, language.as_deref(), *repo_id)
502529 .await;