iT邦幫忙

2026 iThome 鐵人賽

DAY 23
0
Build on Google AI

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

Day 23:撰寫 codeReviewFlow,讓 Gemini 精準揪出三個 Bug

  • 分享至 

  • xImage
  •  

Day 22 把三個紅燈固定下來:非同步、SQL、邊界。

不過目前的 devpulse/ 裡只有 Jest,還沒有任何 AI。

按照這幾天的節奏,每天只增加一個真正需要的能力。今天要加進來的是 Genkit,並寫出第一版的 codeReviewFlow:

讀進一個程式檔,交給 Gemini,回傳格式固定的審查結果。

今天做三件事:

1. 把 Genkit 與 Gemini 金鑰裝進 DevPulse
2. 定義審查結果的 Zod Schema 與 System Prompt
3. 用 defineFlow 組成 codeReviewFlow,對三個 Bug 各跑一次

今天只負責「指出問題」,不修改任何程式碼。修復留到 Day 24。


一、今天想解決的問題

把程式碼貼給 Gemini,它通常答得出來。

但每次回答的長相都不一樣:有時是條列,有時是一大段文字,有時還漏掉行號。這種回覆人看得懂,程式卻接不起來。

後面 Day 24 要產生補丁、Day 25 要跑測試,都需要固定的欄位。所以今天的目標是讓每一份審查結果,都長成同一個樣子:

第幾行、哪一類問題、為什麼、怎麼改。


二、先把 Genkit 裝進 DevPulse

在 devpulse/ 目錄執行:

npm install genkit @genkit-ai/google-genai
npm install -g genkit-cli
  • genkit:核心套件,同時匯出 Day 11 用過的 z,不需要另外安裝 Zod。
  • @genkit-ai/google-genai:讓 Genkit 呼叫 Gemini 的外掛。
  • genkit-cli:提供 genkit start,用來開 Day 16 的 Developer UI。

接著建立 .env,放進 Day 2 那個「純免費專案」的金鑰:

GEMINI_API_KEY=你的金鑰

Genkit 的 Google 外掛會自動讀取 GEMINI_API_KEY,程式裡不需要出現金鑰。

再建立 .gitignore,確保金鑰和套件不會被上傳:

node_modules/
.env

今天新增的檔案如下:

devpulse/
├─ samples/            ← Day 22
├─ tests/              ← Day 22
├─ src/
│  └─ codeReviewFlow.mjs   ← 今天新增
├─ .env                ← 今天新增(不要上傳)
├─ .gitignore          ← 今天新增
├─ package.json
└─ ...

這裡用 .mjs 是有原因的:Day 22 的範例維持 CommonJS(.cjs),讓 Jest 直接就能跑;而 Genkit 的寫法是 ESM 的 import。副檔名分開,兩邊就不用改 package.json,也不會互相干擾。

裝完後可以再跑一次 npm test,應該還是三個紅燈。


三、先定義輸出格式:ReviewSchema

在 src/codeReviewFlow.mjs 先寫最上面的初始化與 Schema:

import { readFile } from "node:fs/promises";
import { googleAI } from "@genkit-ai/google-genai";
import { genkit, z } from "genkit";

const ai = genkit({
  plugins: [googleAI()],
});

// 一則審查結果:哪一行、什麼問題、為什麼、怎麼改
const IssueSchema = z.object({
  line: z.number().describe("問題所在的行號,從 1 開始"),
  severity: z.enum(["high", "medium", "low"]).describe("嚴重程度"),
  category: z
    .enum(["async", "security", "boundary", "logic", "other"])
    .describe("問題類型"),
  title: z.string().describe("一句話說明問題"),
  explanation: z.string().describe("用繁體中文向初學者說明為什麼會出事"),
  suggestion: z.string().describe("修改方向的文字建議,不要寫完整程式碼"),
});

export const ReviewSchema = z.object({
  summary: z.string().describe("整體審查結論,一到兩句話"),
  issues: z.array(IssueSchema).describe("沒有問題時回傳空陣列"),
});

這裡有三個重點:

  • .describe() 不只是註解,Genkit 會連同 Schema 一起送給模型,等於替每個欄位補了一句提示詞。
  • severity 和 category 用 z.enum(),把答案限縮在固定選項,之後要統計或上色,才不會同時出現「高」「嚴重」「High」。
  • 今天刻意不放 fixedCode 欄位,Day 24 再擴充。

四、Gemini 提示詞設定

接著在同一個檔案寫審查用的 System Prompt:

const SYSTEM_PROMPT = `你是一位耐心的資深工程師,正在為初學者做程式碼審查。
請遵守以下規則:
1. 只指出你有把握的問題,不要為了湊數而挑毛病。
2. 每個問題都要標示行號(程式碼每行開頭的數字就是行號)。
3. explanation 用繁體中文,說明「為什麼這樣寫會出事」。
4. suggestion 只描述修改方向,不要輸出完整的修正後程式碼。
5. 如果程式碼沒有問題,issues 請回傳空陣列。`;

規則 1 讓模型寧缺勿濫,規則 4 把「診斷」和「修復」切開,今天的輸出才不會偷跑到 Day 24 的範圍。

溫度則設為 0.2,Day 3 實測過 Temperature:審查程式碼要的是穩定,不是創意。


五、核心代碼:codeReviewFlow

最後把讀檔、加行號、呼叫 Gemini 串成 Flow:

// 行號交給程式加,不要讓模型自己數
function addLineNumbers(code) {
  return code
    .trimEnd()
    .split("\n")
    .map((text, i) => `${i + 1}: ${text}`)
    .join("\n");
}

export const codeReviewFlow = ai.defineFlow(
  {
    name: "codeReviewFlow",
    inputSchema: z.object({ filePath: z.string() }),
    outputSchema: ReviewSchema,
  },
  async ({ filePath }) => {
    // 包進 ai.run,讀檔也會出現在 Trace 的步驟裡
    const code = await ai.run("read-source-file", async () =>
      readFile(filePath, "utf-8"),
    );

    const { output } = await ai.generate({
      model: googleAI.model("gemini-flash-latest"),
      system: SYSTEM_PROMPT,
      prompt: `請審查以下程式碼(每行開頭的數字是行號):\n\n${addLineNumbers(code)}`,
      config: { temperature: 0.2 },
      output: { schema: ReviewSchema },
    });

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

    return output;
  },
);

重點解析:

  • 行號由程式加上去:模型要「讀」行號很容易,要「數」行號卻不一定準,所以送出前先把每一行編好號。
  • ai.run():把讀檔包成一個步驟,在 Developer UI 的 Trace 裡就能看到它。
  • output == null 檢查:模型回傳的內容若不符合 Schema,會拿到 null,這裡直接丟出錯誤,不讓壞資料流到下一步。

六、執行成果與反饋

在 devpulse/ 根目錄啟動 Developer UI(filePath 是相對路徑,所以要在根目錄執行):

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

--env-file=.env 是 Node 內建的選項(Node 20.6 以上),用來載入剛剛的金鑰。

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

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

按下執行,三個範例各跑一次。三個範例預期會抓到的重點如下:

範例 預期問題 預期行號 category
async-await.cjs forEach 不會等待 async callback 4(也可能標在 9) async
sql.cjs 使用者輸入直接拼進 SQL 字串 2 或 3 security
boundary.cjs profile 缺少時會直接報錯 2 boundary

SQL 那一句跨了兩行(第 2 行是 const sql =,第 3 行才是字串本體),所以模型標哪一行都算合理。

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

{
  "summary": "findUser 直接把 userName 拼進 SQL 字串,有 SQL Injection 風險。",
  "issues": [
    {
      "line": 3,
      "severity": "high",
      "category": "security",
      "title": "SQL 字串直接拼接使用者輸入",
      "explanation": "userName 被原封不動放進查詢字串。如果有人輸入 ' OR '1'='1,查詢條件就會被改寫,資料庫會回傳不該給的資料。",
      "suggestion": "改用參數化查詢,SQL 內用 ? 當佔位符,再把 userName 放進第二個參數陣列傳給 db.query。"
    }
  ]
}

跑完三次可以觀察到:每次回傳的格式都一樣,這是 Schema 的功勞;但說明的措辭每次不太一樣,這是模型的本性。DevPulse 要穩定的是欄位,不是文字。

另一個值得注意的地方是 async-await.cjs。還記得 Day 22 那個第一次竟然 PASS 的測試嗎?測試要靠執行時序才抓得到競態,Gemini 卻只是「讀」程式碼,就能指出 forEach 的問題。

但「AI 說有問題」不等於「真的有問題」,更不等於「改完就沒問題」。這份審查結果只是診斷,最後還是要回到 Day 25 的 Jest 驗證。


今日學習踩坑小記

第一個小觀念:能交給程式做的事,就不要賭模型做得準。行號就是一例,程式加一行 map() 就搞定。

第二個小錯誤:檔案結尾通常有一個換行,直接 split("\n") 會多出一行空白的行號,所以加上 trimEnd()。

今天的 codeReviewFlow 只會「說」哪裡有問題,還不會「動手」改。


Day 23 完成

今天完成:

✅ 安裝 Genkit 與 Google 外掛
✅ 設定 .env 與 .gitignore
✅ 定義 ReviewSchema
✅ 撰寫審查用 System Prompt
✅ 完成 codeReviewFlow
✅ 對三個 Bug 各產出一份結構化審查結果

下一篇進入 Day 24:在 Schema 加上修復後的程式碼欄位,讓 Gemini 從「指出問題」進一步產出可以套用的補丁。


上一篇
Day 22:專案正式合體!先替 DevPulse 準備三個真的會壞掉的 Bug
下一篇
Day 24:撰寫 patchFlow,讓 Gemini 產出可以直接套用的補丁
系列文
30 天玩轉 Google AI 全家桶:初學者的隨身 Coding 助理養成記 共 24 篇
圖片
  熱門推薦
圖片
{{ item.channelVendor }} | {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言