iT邦幫忙

2025 iThome 鐵人賽

DAY 25
0

前言

歡迎來到 Day 25!放假囉放假囉~!每次連假前的最後一個上班日都相對挺有趣的,下午就可以看到大家越來越心不在焉:D 我們昨天成功地將前端 UI 與後端 Supabase 資料庫完全串接,讓練習紀錄與詳情頁面都活了起來,顯示的是使用者專屬的真實數據。至此,我們應用程式的核心功能——練習、評估、紀錄、回顧——已經形成一個完整的閉環。

然而,一個 AI 面試官的價值,與其「知識庫」的品質和廣度息息相關。目前我們陽春的 questions.json 檔案顯然不足以應付真實的前端面試。今天,我們不只要為題庫增加內容,更重要的是要建立一套高效、可重複的流程,讓我們未來可以輕易地擴充題庫,並確保 AI 的 RAG 系統能同步更新、理解這些新知識。

但我必須要先說,如果你是期待我們今天會把題庫上雲端也給 Supabase管理的話你可能要失望了,在這系列文的實作中我不打算將這一部分完成,主要原因有三:

  1. 速度與效能:當使用者瀏覽題目列表或開始一個練習時,應用程式是直接從本地(或 CDN)讀取 questions.json。這個過程極快,完全沒有資料庫查詢的延遲,使用者體驗更好。
  2. 開發流程簡潔:身為開發者,要新增或修改題目,你只需要修改一個 JSON 檔案,然後 commit 到 Git。整個題庫跟著程式碼版本一起控管,非常直覺。
  3. 成本效益:我們只在需要「語意搜尋」這種複雜查詢時,才去讀取 Supabase 的 documents 表。單純地顯示題目內容,則完全不消耗資料庫的讀取額度。

當然,未來若題庫成長或是有其他的需求(例如你需要讓PM或是其他人可以編輯題庫),那麼到時候上雲會是個很不錯的選擇,但暫時我們這樣規模的小專案,一個 JSON 檔案肯定綽綽有餘了。

今日目標

  • 設計一個新的 Prompt 樣板:打造一個專門用來生成高品質、符合我們詳細資料結構的面試題目 JSON 的 Prompt。
  • 擴充題庫內容:使用上述 Prompt,為我們的 data/questions.json 檔案加入新的 React 概念題與 JavaScript 實作題。
  • 更新向量資料庫:重新執行批量 Embedding 腳本,將新增題目的 keyPoints 轉換為向量,並寫入 Supabase,確保 RAG 系統能檢索到最新資訊。

Step 1: 工欲善其事,必先利其器——打造題目生成 Prompt

手動編寫包含測試案例的複雜 JSON 既繁瑣又容易出錯。既然我們有強大的大型語言模型,何不讓它來幫我們產出符合格式的題目呢? 這時候我們第一週學到的提示詞知識又再度派上用場了,你可以編寫一個類似這樣,符合我們 Question 介面的 Prompt 樣板:

You are a world-class senior frontend technical lead. Your task is to generate a single, valid JSON object for a new interview question based on the provided <topic> and <difficulty>.

<topic>
React Hooks
</topic>

<difficulty>
medium
</difficulty>

<rules>
1.  The entire JSON output, including all string values, MUST be in **Traditional Chinese**.
2.  The JSON object must strictly adhere to the provided <json_schema>.
3.  `id` must be a unique UUID v4.
4.  `type` must be either 'concept' or 'code'.
5.  **If `type` is 'concept', `keyPoints` array must contain 3-5 core concepts for evaluating the answer.**
6.  **If `type` is 'code', `keyPoints` MUST be an empty array `[]`.**
7.  If `type` is 'concept', `starterCode` and `testCases` must be `null`.
8.  If `type` is 'code', `starterCode` must be a non-empty string, and `testCases` must be a non-empty array of test case objects.
9.  Each object in `testCases` must have `name`, `setup` (where `YOUR_CODE_HERE` is the placeholder for the user's code), `test` (the code to run the test), and `expected` (the expected `console.log` output).
10. Your response must ONLY be the raw JSON object, without any explanatory text, comments, or markdown formatting.
</rules>

<json_schema>
{
  "id": "string",
  "topic": "string",
  "type": "'concept' | 'code'",
  "difficulty": "'easy' | 'medium' | 'hard'",
  "question": "string",
  "hints": "string[]",
  "keyPoints": "string[]",
  "starterCode": "string | null",
  "testCases": "{ name: string; setup: string; test: string; expected: string; }[] | null"
}
</json_schema>

當然你也可以用動態的模板自動去產生不同主題、不同難度的題目,配合串接 LLM 模型去做到更自動化的題目產出然後自動更新我們的 JSON 檔案,一切都取決於你!在這邊我們只是貼出基本模板,你可以修改或直接丟去你習慣的 LLM 然後把產出的題目複製下來就好。

Step 2: 新增 React 與 JavaScript 題目

現在,我們就用上面的 Prompt 來新增幾道題目。打開你的 data/questions.json 檔案,並將以下的新題目物件加入到陣列中,完整的檔案目前會長這樣:

[
  {
    "id": "a1b2c3d4-e5f6-7890-1234-567890abcdef",
    "topic": "JavaScript",
    "type": "concept",
    "difficulty": "easy",
    "question": "請解釋 JavaScript 中的 hoisting 是什麼?",
    "hints": ["想想變數宣告和函數宣告的行為", "var 和 let/const 的差異"],
    "keyPoints": [
      "變數和函數宣告會被提升到其作用域的頂部",
      "只有宣告被提升,賦值不會",
      "let 和 const 也有 hoisting,但因存在暫時性死區 (TDZ),在宣告前存取會拋出錯誤"
    ],
    "starterCode": null,
    "testCases": null
  },
  {
    "id": "b2c3d4e5-f6a7-8901-2345-67890abcdef1",
    "topic": "JavaScript",
    "type": "code",
    "difficulty": "medium",
    "question": "實作一個 flatten 函數,將巢狀陣列攤平成一維陣列",
    "starterCode": "function flatten(arr) {\n  // 例如:[1, [2, 3], [4, [5]]] => [1, 2, 3, 4, 5]\n}",
    "hints": ["可以用遞迴", "檢查元素是否為陣列用 Array.isArray()"],
    "keyPoints": [],
    "testCases": [
      {
        "name": "基本攤平",
        "setup": "const flatten = YOUR_CODE_HERE;",
        "test": "console.log(JSON.stringify(flatten([1, [2, 3]])));",
        "expected": "[1,2,3]"
      },
      {
        "name": "深層巢狀",
        "setup": "const flatten = YOUR_CODE_HERE;",
        "test": "console.log(JSON.stringify(flatten([1, [2, [3, [4]]]])));",
        "expected": "[1,2,3,4]"
      },
      {
        "name": "空陣列",
        "setup": "const flatten = YOUR_CODE_HERE;",
        "test": "console.log(JSON.stringify(flatten([])));",
        "expected": "[]"
      }
    ]
  },
  {
    "id": "c3d4e5f6-a7b8-9012-3456-7890abcdef23",
    "topic": "React",
    "type": "concept",
    "difficulty": "medium",
    "question": "請解釋 React 中的 Class Component 和 Functional Component 之間的差異,以及 Hooks 的出現帶來了什麼改變?",
    "hints": ["生命週期方法", "state 管理方式", "程式碼複用"],
    "keyPoints": [
      "Class Component 使用 'this' 和 'extends React.Component'",
      "Functional Component 是純函式,過去被稱為無狀態元件",
      "Hooks 讓 Functional Component 也能擁有 state 和生命週期等特性",
      "Hooks (如 custom hooks) 改善了邏輯複用的問題,解決了 HOC/Render Props 的複雜性"
    ],
    "starterCode": null,
    "testCases": null
  },
  {
    "id": "d4e5f6a7-b8c9-0123-4567-890abcdef345",
    "topic": "JavaScript",
    "type": "code",
    "difficulty": "hard",
    "question": "實作一個 Promise.all() 的 polyfill,命名為 promiseAll",
    "starterCode": "function promiseAll(promises) {\n  // promises 是一個 promise 物件的陣列\n}",
    "hints": [
      "回傳一個新的 Promise",
      "需要一個計數器來追蹤已完成的 promise",
      "注意處理空陣列的邊界情況",
      "結果陣列的順序需要和傳入的 promises 陣列順序一致"
    ],
    "keyPoints": [],
    "testCases": [
      {
        "name": "全部成功",
        "setup": "const promiseAll = YOUR_CODE_HERE;",
        "test": "const p1 = Promise.resolve(1); const p2 = 2; const p3 = new Promise((res) => setTimeout(() => res(3), 50)); promiseAll([p1, p2, p3]).then(vals => console.log(JSON.stringify(vals)));",
        "expected": "[1,2,3]"
      },
      {
        "name": "其中一個失敗",
        "setup": "const promiseAll = YOUR_CODE_HERE;",
        "test": "const p1 = Promise.resolve(1); const p2 = Promise.reject('error'); promiseAll([p1, p2]).catch(err => console.log(err));",
        "expected": "error"
      },
      {
        "name": "傳入空陣列",
        "setup": "const promiseAll = YOUR_CODE_HERE;",
        "test": "promiseAll([]).then(vals => console.log(JSON.stringify(vals)));",
        "expected": "[]"
      }
    ]
  },
  {
    "id": "e5f6a7b8-c9d0-1234-5678-90abcdef4567",
    "topic": "CSS",
    "type": "concept",
    "difficulty": "easy",
    "question": "請解釋 CSS Box Model (盒子模型) 是什麼,以及 `box-sizing: border-box;` 的作用?",
    "hints": ["content, padding, border, margin", "width 和 height 的計算方式"],
    "keyPoints": [
      "標準盒子模型的 width/height 只包含 content",
      "border-box 的 width/height 包含 content, padding, 和 border",
      "`box-sizing` 改變了寬高計算的行為,讓排版更直觀"
    ],
    "starterCode": null,
    "testCases": null
  },
  {
    "id": "c5d8e2f1-7b3a-4e9c-8a1d-9f0b3c6a7e2b",
    "topic": "JavaScript",
    "type": "code",
    "difficulty": "medium",
    "question": "請實作一個 `debounce` 函式。這個函式接收一個函式 `func` 和一個延遲時間 `delay`,並回傳一個新的函式。當這個新的函式被連續呼叫時,它只會在最後一次呼叫後的 `delay` 毫秒執行一次 `func`。",
    "hints": [
      "你需要使用 `setTimeout` 來延遲函式的執行。",
      "在每次呼叫時,需要清除之前設定的計時器。",
      "考慮如何將參數和 `this` 上下文正確地傳遞給原始函式。"
    ],
    "keyPoints": [],
    "starterCode": "function debounce(func, delay) {\n  // your code here\n}",
    "testCases": [
      {
        "name": "基本功能測試",
        "setup": "const debounce = YOUR_CODE_HERE;",
        "test": "let count = 0; const increment = () => { count++; }; const debouncedIncrement = debounce(increment, 50); debouncedIncrement(); debouncedIncrement(); debouncedIncrement(); new Promise(resolve => setTimeout(resolve, 100)).then(() => { console.log(count); });",
        "expected": "1"
      },
      {
        "name": "參數傳遞測試",
        "setup": "const debounce = YOUR_CODE_HERE;",
        "test": "let result = ''; const log = (val) => { result = val; }; const debouncedLog = debounce(log, 50); debouncedLog('first'); debouncedLog('last'); new Promise(resolve => setTimeout(resolve, 100)).then(() => { console.log(result); });",
        "expected": "last"
      }
    ]
  }
]

當然,若你一口氣產生更多題目,那你的檔案內容肯定不止如此,上述僅供示範參考,不過眼尖的你可能會注意到,我們之前明明是將id做topic-type-number的設計,怎麼在這次的更新後全部都變為UUID了?主要是為了配合這樣的批量生產題目,若還是維持之前的設計,在提示詞上就需要更下功夫,不利於之後的每次新增,所以只好忍痛改掉了。

Step 3: 更新 Supabase 向量資料庫

新增了題目知識點 (keyPoints) 後,我們的 RAG 系統還不知道它們的存在。我們必須執行之前建立的 seed-all-vectors.js 腳本,它會讀取最新的 questions.json,為所有 keyPoints 生成新的 Embedding 向量,然後將它們寫入 Supabase。

幸運的是,我們當初設計的腳本只關心 id 和 keyPoints 欄位,所以即使我們擴充了 Question 的資料結構,腳本依然可以無痛運作。

這個腳本的設計是冪等 (idempotent) 的,意思是你可以安全地重複執行它。每次執行,它都會先清空舊的 documents 表,再寫入全新的資料,確保資料庫的狀態永遠與 questions.json 同步。

打開你的終端機,執行以下指令:

node scripts/seed-all-vectors.js

你應該會看到類似以下的輸出,其中 keyPoints 的總數會增加:

正在清空舊的 documents 資料...
舊資料已清空。
開始處理所有 questions.json 中的 keyPoints...
總共找到 [新的總數] 個 keyPoints 待處理。
- 正在處理第 1 / N 批資料...
  成功寫入 5 筆資料。
- 正在處理第 2 / N 批資料...
  成功寫入 5 筆資料。
...
🎉 所有 KeyPoints 已成功寫入 Supabase!

完成後,我們的 AI 面試官就正式擁有了關於 React Hooks 和 Debounce 的「長期記憶」,可以在接下來的面試中運用這些知識了!

今日回顧

今天我們日子過得很輕鬆,我們基本上只是把之前學過的東西再次應用在今天的內容,為專案建立了可持續發展的基礎。一個好的產品不僅功能要完整,其內容也必須能輕易地擴展與維護。

✅ 我們設計了一個更嚴謹、精確的 Prompt 樣板,可以快速生成符合我們複雜資料結構的面試題目。
✅ 我們成功為題庫新增了 React 和 JavaScript 相關的題目,擴展了 AI 面試官的知識廣度。
✅ 我們重新執行了 seed 腳本,將最新的知識點轉換為向量並同步到 Supabase,確保了 RAG 系統的準確性。

明日預告

我們的應用程式功能越來越強大,但一直以來我們都忽略了在真實世界中至關重要的一環:成本、安全與效能。明天 (Day 26),我們將從開發者的象牙塔中走出來,全面檢視我們的應用程式,探討如何監控 API 使用成本、加強安全性(例如 RLS 規則的完善),以及處理外部服務的速率限制,確保我們的 AI 面試官不僅聰明,而且穩健、可靠、可持續。


上一篇
讓紀錄活起來!動態串接 Supabase 資料
系列文
前端工程師的AI應用開發實戰:30天從Prompt到Production - 以打造AI前端面試官為例25
圖片
  熱門推薦
圖片
{{ item.channelVendor }} | {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言