iT邦幫忙

2026 iThome 鐵人賽

DAY 24
0
Build on Google AI

30 天玩轉 Google AI 全家桶:初學者的隨身 Coding 助理養成記系列 第 24 篇

Day 24:撰寫 patchFlow,讓 Gemini 產出可以直接套用的補丁

  • 分享至 

  • xImage
  •  

Day 23 的 codeReviewFlow 已經會指出問題了:第幾行、哪一類、為什麼。

不過它只會「說」,suggestion 是一段給人看的文字,程式沒辦法拿去改檔案。

今天要加進來的能力是 補丁(patch),並寫出第一版的 patchFlow:

先審查,再讓 Gemini 產出「替換用的程式碼」,最後寫進一份新檔案。

今天做三件事:

1. 定義補丁的 Zod Schema 與 System Prompt
2. 寫一個 applyPatches 函式,把補丁套用到原始碼上
3. 用 defineFlow 組成 patchFlow,對三個 Bug 各產出一份修復檔

今天只負責「產生修復檔」,不驗證它改得對不對。驗證留到 Day 25。


一、今天想解決的問題

要讓程式自動改檔案,光有「問題在第 3 行」還不夠,還要知道:改哪一段、改成什麼。

最直覺的做法是叫 Gemini 把整份檔案重寫一遍。但整份重寫有兩個毛病:模型可能順手「優化」你沒要它改的地方,而且你得自己比對才知道它到底動了什麼。

所以今天改用補丁的做法,每一個補丁只回答四件事:

startLine   從第幾行開始換
endLine     換到第幾行(含這一行)
replacement 換成什麼程式碼
reason      為什麼這樣換

這四個欄位,剛好就是 Day 23 打好的行號基礎加上「修改後代碼」與「修改理由」。補丁範圍以外的每一行,都會原封不動。


二、先改兩個地方

patchFlow 要用到 Day 23 的 Flow,所以先在 src/codeReviewFlow.mjs 加上兩個 export:

// 原本是 const ai = genkit(...),前面加上 export
export const ai = genkit({
  plugins: [googleAI()],
});

// 原本是 function addLineNumbers(...),前面加上 export
export function addLineNumbers(code) {
  // 內容不變
}

把 ai 匯出來,是為了讓兩個 Flow 共用同一個 Genkit 實例。這樣 Developer UI 才會同時看到兩個 Flow,Trace 也能串在一起。

另外在 .gitignore 加一行,因為修復檔每次都會重新產生:

fixed/

今天新增的檔案如下:

devpulse/
├─ samples/            ← Day 22(原檔,今天不會動它)
├─ tests/              ← Day 22
├─ src/
│  ├─ codeReviewFlow.mjs   ← Day 23(加兩個 export)
│  └─ patchFlow.mjs        ← 今天新增
├─ fixed/              ← 今天自動產生
├─ .gitignore          ← 加上 fixed/
└─ ...

修復檔會寫到 fixed/,檔名跟原檔一樣(例如 fixed/sql.cjs)。原檔不動,三個範例各有對應的修復檔,Day 25 要比對也很方便。


三、先定義補丁格式:PatchSchema

在 src/patchFlow.mjs 先寫最上面的引入與 Schema:

import { mkdir, readFile, writeFile } from "node:fs/promises";
import { basename, join } from "node:path";
import { googleAI } from "@genkit-ai/google-genai";
import { z } from "genkit";
import { addLineNumbers, ai, codeReviewFlow } from "./codeReviewFlow.mjs";

// 一個補丁:把 startLine ~ endLine 換成 replacement
const PatchSchema = z.object({
  startLine: z.number().describe("要被替換的第一行,從 1 開始"),
  endLine: z
    .number()
    .describe("要被替換的最後一行(含這一行),只改一行時與 startLine 相同"),
  replacement: z
    .string()
    .describe("取代該範圍的新程式碼,純程式碼,不含行號與說明文字"),
  reason: z.string().describe("用繁體中文向初學者說明為什麼這樣改"),
});

const PatchResultSchema = z.object({
  patches: z.array(PatchSchema).describe("沒有需要修改的地方時回傳空陣列"),
});

這裡有兩個重點:

  • startLine 和 endLine 的意思是「包含頭尾」,describe 裡把這件事講白,模型才不容易少算一行。
  • replacement 的 describe 直接寫明「不含行號」,因為送給模型的程式碼每行開頭都有行號,模型有可能順手抄進去。

四、Gemini 提示詞設定

接著寫補丁用的 System Prompt:

const SYSTEM_PROMPT = `你是一位謹慎的資深工程師,負責把審查結果轉成可以直接套用的補丁。
請遵守以下規則:
1. 只修改審查結果提到的問題,不要順手重構或調整不相關的程式碼。
2. 行號就是程式碼每行開頭的數字,startLine 與 endLine 都包含在內。
3. replacement 只放純程式碼:不要行號、不要 markdown 的程式碼圍欄、不要說明文字。
4. replacement 要保留原本的縮排,並且能直接取代 startLine 到 endLine。
5. 補丁之間的行號範圍不可以重疊;能用一個補丁解決的問題,就不要拆成多個。
6. reason 用繁體中文,說明「為什麼這樣改才對」。
7. 如果審查結果沒有任何問題,patches 請回傳空陣列。`;

規則 1 守住範圍,規則 3、4 是「輸出乾淨修復代碼」的關鍵,規則 5 則是替等一下的補丁演算法先鋪路。

溫度設為 0.1,比審查時的 0.2 再保守一點:修復要的是穩,不是變化。


五、核心代碼:補丁演算法與 patchFlow

先寫補丁演算法。它的任務很單純:把一組補丁套到原始碼上。

function applyPatches(code, patches) {
  const lines = code.trimEnd().split("\n");
  const total = lines.length;

  // 行號由大到小排,從檔案最下面開始改
  const sorted = [...patches].sort((a, b) => b.startLine - a.startLine);

  let prevStart = Infinity;
  for (const p of sorted) {
    const outOfRange =
      p.startLine < 1 || p.endLine < p.startLine || p.endLine > total;
    if (outOfRange || p.endLine >= prevStart) {
      throw new Error(`補丁行號不合法或互相重疊:${p.startLine}-${p.endLine}`);
    }
    lines.splice(
      p.startLine - 1,
      p.endLine - p.startLine + 1,
      ...p.replacement.split("\n"),
    );
    prevStart = p.startLine;
  }

  return lines.join("\n") + "\n";
}

再來是 Flow:先呼叫 Day 23 的審查,再請 Gemini 依審查結果寫補丁,最後套用並寫檔。

export const patchFlow = ai.defineFlow(
  {
    name: "patchFlow",
    inputSchema: z.object({ filePath: z.string() }),
    outputSchema: z.object({
      outputPath: z.string(),
      patches: z.array(PatchSchema),
    }),
  },
  async ({ filePath }) => {
    const code = await ai.run("read-source-file", async () =>
      readFile(filePath, "utf-8"),
    );

    // Flow 可以像一般函式一樣被呼叫
    const review = await codeReviewFlow({ filePath });

    let patches = [];
    if (review.issues.length > 0) {
      const { output } = await ai.generate({
        model: googleAI.model("gemini-flash-latest"),
        system: SYSTEM_PROMPT,
        prompt: `原始程式碼(每行開頭的數字是行號):\n\n${addLineNumbers(code)}\n\n審查結果:\n${JSON.stringify(review.issues, null, 2)}`,
        config: { temperature: 0.1 },
        output: { schema: PatchResultSchema },
      });

      if (output == null) {
        throw new Error("Gemini 回傳的內容不符合 PatchResultSchema");
      }
      patches = output.patches;
    }

    const fixedCode = applyPatches(code, patches);
    const outputPath = join("fixed", basename(filePath));

    await ai.run("write-fixed-file", async () => {
      await mkdir("fixed", { recursive: true });
      await writeFile(outputPath, fixedCode, "utf-8");
    });

    return { outputPath, patches };
  },
);

重點解析:

  • 由下往上套用:假設第 2 行的補丁把 1 行換成 3 行,那原本的第 4 行就會變成第 6 行,後面補丁的行號全部對不上。先改最下面的補丁,上面的行號就不會被推動。這就是今天的「補丁演算法」。
  • 寧可報錯,不寫壞檔:行號超出檔案、頭尾顛倒、範圍重疊,applyPatches 一律直接丟錯誤,不會把壞掉的結果寫進 fixed/。
  • codeReviewFlow({ filePath }):Flow 本身就是可以呼叫的函式,所以「審查 → 補丁」可以直接串起來,診斷和修復也維持在兩次獨立的呼叫。
  • review.issues.length > 0:沒有問題就不必再問 Gemini,原樣複製一份到 fixed/,這樣三個範例的輸出結構一致。

六、執行成果與反饋

這次入口換成 patchFlow.mjs,它會把 codeReviewFlow 一起載入,所以兩個 Flow 都會出現在 Developer UI:

genkit start -- node --env-file=.env --watch src/patchFlow.mjs

開啟 http://localhost:4000,在 Run 頁籤選擇 patchFlow,輸入:

{ "filePath": "samples/sql.cjs" }

三個範例各跑一次。預期的補丁方向如下:

範例 預期補丁
async-await.cjs 把 forEach 改成 for...of 搭配 await(或改用 Promise.all),兩種都算合理
sql.cjs SQL 改成 ? 佔位符,而且 db.query 那一行要一起補上參數陣列
boundary.cjs 對 profile 缺少的情況加上防禦(例如 ?. 或提早 return)

以 sql.cjs 為例,輸出大致會長這樣(示意):

{
  "outputPath": "fixed/sql.cjs",
  "patches": [
    {
      "startLine": 2,
      "endLine": 4,
      "replacement": "  const sql = \"SELECT * FROM users WHERE name = ?\";\n  return db.query(sql, [userName]);",
      "reason": "SQL 裡改用 ? 佔位符,讓 userName 只被當成資料,不會被當成 SQL 語法執行。"
    }
  ]
}

跑完之後,用兩個指令檢查結果:

git diff --no-index samples/sql.cjs fixed/sql.cjs
node --check fixed/sql.cjs

第一個看補丁到底改了哪幾行,第二個確認修復檔至少語法沒壞。

可以觀察到三件事:

  • fixed/ 裡多了三份修復檔,samples/ 的原檔一個字都沒變。
  • 補丁的範圍很小,沒有被改到的行都跟原檔一模一樣。
  • 這次補丁成功寫入,不代表程式真的修好了。例如 SQL 那個範例,如果模型只改了字串、忘了改 db.query 的參數,補丁照樣套得進去,程式卻還是壞的。

這時候 npm test 仍然是三個紅燈,因為測試載入的還是 samples/ 的原檔。「AI 說這樣改就對了」只是一個說法,最後還是要由測試來驗證,這就是 Day 25 的工作。


今日學習踩坑小記

第一個小觀念:改檔案要由下往上改。每一個補丁都可能讓行數變多或變少,從上面開始改,下面的行號就全部漂移了。

第二個小提醒:補丁的行號範圍一旦重疊或超出檔案,寧可直接報錯,也不要硬寫。壞掉的補丁寫進檔案,會變成 Day 25 很難查的「假紅燈」,因為你分不清是 AI 改錯,還是自己的程式把檔案寫壞。

今天的 patchFlow 已經會「動手」改了,但改得對不對,還沒有人驗證。


Day 24 完成

今天完成:

✅ 匯出 ai 與 addLineNumbers,讓兩個 Flow 共用
✅ 定義 PatchSchema
✅ 撰寫補丁用 System Prompt
✅ 實作 applyPatches(由下往上套用,並擋下不合法的補丁)
✅ 完成 patchFlow,把修復檔寫進 fixed/
✅ 對三個 Bug 各產出一份修復檔

下一篇進入 Day 25:讓 Jest 改跑 fixed/ 裡的修復檔,親眼看三個紅燈變綠燈,驗證 AI 的補丁到底改對了沒有。


上一篇
Day 23:撰寫 codeReviewFlow,讓 Gemini 精準揪出三個 Bug
系列文
30 天玩轉 Google AI 全家桶:初學者的隨身 Coding 助理養成記 共 24 篇
圖片
  熱門推薦
圖片
{{ item.channelVendor }} | {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言