Làm thế nào để trích xuất “Why” từ PR vào CHANGELOG bằng Jev? 🤔
Hoi hoi! 👋
Mình là @nyaomaru, một frontend engineer hiện đang xây dựng một trình tạo CHANGELOG có hỗ trợ AI và thử nghiệm với Jev. 😸
Gần đây, mình cứ nghĩ mãi về một vấn đề.
Làm thế nào để trích xuất phần này:
## Why
The previous implementation could leave stale release data after an update.
từ một pull request rồi đưa nó vào CHANGELOG?
### Fixed
- Fix cache invalidation
Why: The previous implementation could leave stale release data after an update.
Lấy WHAT thì tương đối dễ. Lấy WHY khó hơn nhiều. Và mình không muốn AI đơn giản là tự bịa ra một lý do nghe có vẻ hợp lý.
Hãy xem changelog-bot hiện đang tiếp cận vấn đề này như thế nào! 👀
https://github.com/nyaomaru/changelog-bot
📝 WHAT thì dễ. WHY thì khó.
Giả sử một PR có nội dung:
## Description
Extract shared runtime configuration into ProviderBase.
Update OpenAI, Anthropic, and Gemini adapters to use the shared implementation.
This reduces duplicated setup logic and makes adding new providers easier.
Hai câu đầu chủ yếu mô tả WHAT và HOW.
Nhưng câu này thì khác:
This reduces duplicated setup logic and makes adding new providers easier.
Nó gần với WHY hơn nhiều.
Ta hoàn toàn có thể đưa toàn bộ PR cho một LLM rồi hỏi:
Why was this change made?
Nhưng khi đó model có thể tóm tắt, diễn đạt lại, hoặc suy ra một lý do chưa từng được viết rõ ràng trong PR.
Với CHANGELOG, mình muốn nghiêm ngặt hơn.
Có lẽ việc extract WHY không phải là bài toán generation.
Có lẽ nó là một selection problem.
✂️ Trước tiên, tìm các candidate có thể là WHY
Trước khi gọi model, changelog-bot preprocess phần mô tả PR.
Nó tìm các tín hiệu như:
const STRONG_CANONICAL_SECTION_NAMES = new Set([
"why",
"reason",
"because",
"motivation",
"context",
"background",
"problem",
"rationale",
]);
const RATIONALE_MARKER_RE =
/\b(why|because|so that|in order to|reason|rationale|motivation|to avoid|to prevent|context|problem)\b|,\s*so\b/i;
Nó cũng loại bỏ noise từ template như checkbox, placeholder và các field không liên quan.
Sau đó nó tạo ra các candidate snippet nhỏ:
const candidates = buildWhyCandidateSnippets(
extracted.sections,
body,
options.maxCharsPerPr,
!extracted.hasTargetSection,
);
Như vậy, thay vì gửi một PR body rất lớn, ta có thể chỉ còn:
[
"Extract shared runtime configuration into ProviderBase.",
"This reduces duplicated setup logic and makes adding new providers easier.",
];
Bây giờ bài toán nhỏ hơn nhiều.
Candidate nào mới thực sự là WHY?
🧠 Hỏi Jev hai câu hỏi
Đây là lúc mình bắt đầu thử nghiệm với Jev.
Mình không yêu cầu Jev viết một câu WHY.
Thay vào đó, mỗi candidate được đánh giá bằng hai câu hỏi.
Đầu tiên:
question: "Does the target candidate explicitly state why this changelog change was made?";
Hiểu đơn giản là:
Đây có thực sự là một lý do không?
Nhưng chỉ vậy thì chưa đủ.
Hãy tưởng tượng:
We changed the cache invalidation logic.
The README was updated because the previous explanation was confusing.
Câu thứ hai rõ ràng có chứa một lý do.
Nhưng nó giải thích thay đổi ở README, chứ không phải thay đổi về cache invalidation.
Vì vậy chúng ta còn hỏi:
question: "Does the target candidate state a reason that applies to the identified changelog change?";
Giờ mỗi candidate có hai signal:
{
probability: explicitWhyProbability,
relevanceProbability: changeRelevanceProbability,
}
Vậy câu hỏi thực sự trở thành:
Đây có phải là một lý do không?
AND
Nó có phải là lý do cho change này không?
🎯 Chọn candidate
changelog-bot so sánh probability của các candidate:
const bestCandidate = candidateProbabilities.reduce((best, candidate) =>
combinedWhyCandidateProbability(candidate) >
combinedWhyCandidateProbability(best)
? candidate
: best,
);
Sau đó cả hai threshold tối thiểu đều phải đạt:
const accepted =
selectedCandidate !== undefined &&
bestCandidate.probability >= JEV_MIN_EXPLICIT_WHY_PROBABILITY &&
bestCandidate.relevanceProbability >= JEV_MIN_CHANGE_RELEVANCE_PROBABILITY;
Nếu candidate không đạt:
if (!accepted) continue;
Không có WHY nào được thêm vào.
Và mình nghĩ điều đó rất quan trọng.
Đôi khi câu trả lời đúng là:
There is no reliable WHY in this PR.
Như vậy còn tốt hơn là tự bịa ra một lý do.
🔒 Giữ nguyên text gốc của PR
Đây có lẽ là phần mình thích nhất.
Khi một candidate được chấp nhận:
items.push({
prNumber: item.prNumber,
why: selectedCandidate,
confidence: mappedConfidence,
});
Hãy để ý:
why: selectedCandidate;
WHY cuối cùng chính là text gốc từ PR.
Không phải prose mới được generate.
Vì vậy pipeline trở thành:
PR description
↓
candidate extraction
↓
Jev evaluates candidates
↓
select candidate
↓
original source text
↓
CHANGELOG
Jev quyết định.
Nhưng câu chữ vẫn do tác giả PR cung cấp. 😸
🧱 WHY nằm ở đâu trong pipeline v1?
Trong khi làm WHY extraction, mình cũng đang redesign pipeline của changelog-bot cho v1.
Ý tưởng chính là deterministic-first.
Một CHANGELOG hoàn chỉnh vẫn phải tồn tại ngay cả khi AI bị tắt hoặc thất bại.
Vì vậy AI nên enrich kết quả, chứ không nên sở hữu kết quả.
Pipeline mục tiêu của v1 trông gần như thế này:
source changes
↓
ReleaseChange[]
↓
deterministic classification
↓
complete ReleaseDraft
↓
optional editorial enrichment
+
optional WHY enrichment
↓
ReleaseResult
↓
deterministic renderer
↓
CHANGELOG
Do đó WHY extraction là một stage enrichment độc lập và optional.
Về mặt khái niệm:
ReleaseDraft / ReleaseChange[]
↓
find eligible WHY targets
↓
collect PR rationale candidates
↓
LLM or Jev
↓
trust / confidence validation
↓
attach structured WHY notes
↓
ReleaseResult
Một thay đổi quan trọng ở đây là WHY engine không cần kiểm soát Markdown cuối cùng.
Hiện tại, một phần pipeline vẫn tìm target từ Markdown đã generate rồi chèn WHY text vào sau đó.
Ở v1, mình muốn loại bỏ boundary đó.
Thay vào đó:
{
prNumber,
why: selectedCandidate,
confidence,
}
trước tiên sẽ trở thành structured release data.
Sau đó một deterministic renderer duy nhất quyết định cách hiển thị:
- Fix cache invalidation
- Why: The previous implementation could leave stale release data after an update.
Như vậy trách nhiệm trở nên rõ ràng hơn:
WHY engine
→ tìm evidence
Renderer
→ format evidence
Đây cũng là một phần của rule lớn hơn mà mình đang dùng cho migration lên v1:
Deterministic code sở hữu cấu trúc CHANGELOG. AI chỉ enrich nó.
Phase 1–4 của migration này đã được implement, còn boundary giữa structured rendering và enrichment là một phần của phase tiếp theo.
Bạn có thể xem toàn bộ thiết kế v1 tại đây 👇
https://github.com/nyaomaru/changelog-bot/blob/main/v1-design.md
🧪 Nhưng nên dùng threshold nào?
Tất nhiên, khi đã có probability thì một câu hỏi khác sẽ xuất hiện:
0.50?
0.60?
0.80?
Mình không muốn chọn một giá trị chỉ vì nó “có vẻ hợp lý”.
Vì vậy mình tạo một evaluation harness với corpus ban đầu gồm 14 case.
Ví dụ:
{
candidates: [
"Separate changelog execution into input-resolution and output-finalization phases.",
"This simplifies the main orchestration flow and runs independent GitHub lookups concurrently.",
],
expectedSelectedCandidateIndex: 1,
}
Chúng ta cũng cần negative examples:
{
candidates: [
"Extract shared provider runtime configuration into ProviderBase.",
"Refactor OpenAI, Anthropic, and Gemini adapters to inherit it.",
],
expectedSelectedCandidateIndex: null,
}
Có rất nhiều thông tin implementation.
Nhưng không có WHY rõ ràng đủ mạnh.
Vì vậy null mới là kết quả đúng.
🤝 Sau đó John tham gia project
Trong lúc mình đang làm experiment này, @johnnylemonny đã tham gia project với vai trò contributor. 🎉
John đề xuất mở rộng benchmark nhỏ này lên khoảng 50 case, bao gồm:
Explicit Rationale
Implementation-Only
Template & Noise
Multilingual PRs
Mình rất thích ý tưởng đó, nhưng muốn giữ một rule thật rõ:
Ground truth phải độc lập với threshold hiện tại của Jev.
Vì vậy benchmark trước tiên xác định candidate có thực sự hợp lệ hay không.
Sau đó mới đánh giá các threshold khác nhau.
John đã implement corpus mở rộng trong PR #212. 🙌
Benchmark tăng từ 14 lên 50 case, bao gồm cả những negative case khá khó.
Anh ấy cũng thêm các threshold sweep chi tiết hơn:
[0.4, 0.45, 0.5, 0.55, 0.6, 0.65, 0.7, 0.75, 0.8, 0.85, 0.9];
Bây giờ chúng ta có thể so sánh precision, recall và F1 thay vì nói:
0.8 có vẻ an toàn! 👍
Đo luôn thì tốt hơn. 😸
🧩 Pipeline hiện tại
Hiện tại, WHY extraction trông gần như thế này:
Pull Request
↓
Normalize PR body
↓
Extract WHY candidates
↓
Remove noise
↓
Local trust check
↓
Jev:
explicit rationale?
relevant to this change?
↓
Apply threshold
↓
Candidate or none
↓
Original PR text
↓
CHANGELOG
Khá nhiều bước chỉ để tạo ra một dòng Why: nhỏ. 😂
Nhưng chính điều đó khiến vấn đề này thú vị.
🎯 Mình đã học được gì
Khi bắt đầu làm feature này, mình nghĩ câu hỏi chính là:
How can AI write a good reason?
Giờ mình nghĩ câu hỏi thú vị hơn là:
How can we find evidence that the reason already exists?
Vì vậy approach hiện tại của mình khá đơn giản:
Use deterministic code to find evidence.
Use Jev to evaluate the evidence.
Keep the original evidence as the output.
Và nhờ contribution của John, giờ chúng ta có một cách tốt hơn rất nhiều để kiểm tra xem approach đó có thực sự hoạt động không.
Open source thú vị nhất là khi ai đó nhìn vào experiment của bạn rồi nói:
Hay là mình test chuyện này cho tử tế?
rồi thực sự cùng bạn xây nó. 🙌
changelog-bot vẫn đang tiếp tục phát triển, và support cho Jev vẫn còn ở giai đoạn experimental.
Mình rất tò mò xem approach này sẽ đi đến đâu tiếp theo!
https://github.com/nyaomaru/changelog-bot
Cảm ơn bạn đã đọc!
Hẹn gặp lại ở bài tiếp theo! 👋😸
All rights reserved