iT邦幫忙

2025 iThome 鐵人賽

DAY 28
0

前言

歡迎來到 Day 28!昨天我們成功建立了 React 評測引擎(react-executor.ts),並通
過 POC 驗證了方案的可行性。今天,我們要將這個強大的引擎正式整合到我們的 AI 面試官系統中,讓它能夠真正運作起來!
不過實際上,這個功能我會暫時放到一個獨立的分支處理,畢竟就像我昨天說的,目前我們雖然可以透過 POC 實踐的方案去完成 React 程式碼的驗證,但不論是安全性、比對的準確性都有著極大的優化空間,作為部署環境的功能還不夠格,最終我們的成品並不會包含這個實作功能,但我還是希望先透過一些最小化實作讓你了解實際上裡面的概念大致上是如何,並提供你工具和方向,有興趣的朋友可以透過我文章中工具的關鍵字與 AI 協作研究,這樣要完成足以上線的版本應該不至於太過於困難。

雖然這個東西是不上線,但該做的還是得做完,不然誰能證明我們的方案真的可行呢?讓我們開始吧!

今日目標

✅ 整合 API:修改評測 API 以支援 React 題目
✅ 新增題目:建立第一道 React 實作題
✅ 優化 Prompt:讓 AI 能針對 React 測試結果提供有價值的回饋
✅ 測試驗證:確保整個評測流程正常運作

Step 1: 整合評測 API — 讓系統識別 React 題目

現在我們要修改 app/api/interview/evaluate/route.ts,加入對 React 題目的支援。核心思路是:當偵測到題目類型為 React 程式題時,調用我們的 react-executor 而非 Judge0。
打開 app/api/interview/evaluate/route.ts,進行以下修改:

// app/api/interview/evaluate/route.ts
import { NextResponse } from 'next/server';
import questionsData from '@/data/questions.json';
import { formatChatHistory } from '@/app/lib/utils';
import { buildUnifiedPrompt } from '@/app/lib/prompt';
import { performRagSearch } from '@/app/lib/supabase/server';
import { generateEmbedding, generateContentStream } from '@/app/lib/gemini';
import { getFormattedJudge0Result } from '@/app/lib/judge0';
import {
  createAuthClient,
  supabase as adminSupabase,
} from '@/app/lib/supabase/server';
import {
  evaluateReactComponent,
  ReactTestCase,
  TestCaseResult,
} from '@/app/lib/react-executor';
import { Question } from '@/app/types/question';

const questions = questionsData as Question[];
function formatReactEvaluationResults(
  results: TestCaseResult[],
  error?: string
): string {
  if (error) {
    return `❌ 評測過程發生錯誤:${error}\n\n請檢查你的程式碼是否有語法錯誤或其他問題。`;
  }

  const passedCount = results.filter((r) => r.passed).length;
  const totalCount = results.length;

  let output = `## 測試結果總覽\n通過: ${passedCount}/${totalCount}\n\n`;

  results.forEach((result, index) => {
    output += `### 測試案例 ${index + 1}: ${result.name}\n`;

    if (result.passed) {
      output += `✅ **通過**\n`;
      output += `渲染結果符合預期\n\n`;
    } else {
      output += `❌ **失敗**\n`;

      if (result.missing && result.missing.length > 0) {
        output += `缺少以下預期內容:\n`;
        result.missing.forEach((pattern) => {
          output += `  - "${pattern}"\n`;
        });
      }

      output += `\n實際渲染的 HTML:\n\`\`\`html\n${result.actual}\n\`\`\`\n\n`;
    }
  });

  return output;
}

/**
 * 準備評估所需的上下文資料
 */
async function prepareEvaluationContext(
  question: Question,
  userAnswer: string
) {
  let judge0Result = 'not applicable for this question'; // 一般程式題結果
  let reactTestResult = 'not applicable for this question'; // React 測試結果(新增)
  let ragContext = 'not applicable for this question';

  // ========================================
  // React 程式題:使用我們的原生評測引擎
  // ========================================
  if (question.topic === 'React' && question.type === 'code') {
    console.log('🎯 偵測到 React 程式題,使用原生評測引擎');

    const testCases: ReactTestCase[] = question.testCases || [];

    const evaluation = await evaluateReactComponent(userAnswer, testCases);

    reactTestResult = formatReactEvaluationResults(
      evaluation.results,
      evaluation.error
    );

    console.log('✅ React 評測完成');
  }
  // ========================================
  // 一般程式題:使用 Judge0
  // ========================================
  else if (question.type === 'code') {
    console.log('📝 偵測到一般程式題,使用 Judge0');
    judge0Result = await getFormattedJudge0Result(userAnswer);
  }

  // ========================================
  // 概念題:使用 RAG
  // ========================================
  if (question.type === 'concept') {
    console.log('💡 偵測到概念題,執行 RAG 搜尋');
    const answerEmbedding = await generateEmbedding(userAnswer);
    ragContext = await performRagSearch(answerEmbedding, question.id);
  }

  return { ragContext, judge0Result, reactTestResult }; // 回傳三個欄位
}

export async function POST(request: Request) {
  try {
    // 1. 驗證使用者身分
    const supabase = await createAuthClient();
    const {
      data: { user },
    } = await supabase.auth.getUser();
    if (!user) {
      return new Response('Unauthorized', { status: 401 });
    }

    // 2. 取得 isFollowUp 旗標
    const { questionId, answer, history, isFollowUp } = await request.json();

    const question = questions.find((q) => q.id === questionId);
    if (!question) {
      return NextResponse.json(
        { error: 'Question not found' },
        { status: 404 }
      );
    }

    // 準備所有需要的上下文變數
    const formattedHistory = formatChatHistory(history);

    const { ragContext, judge0Result, reactTestResult } =
      await prepareEvaluationContext(question, answer);

    // 填充統一的 Prompt 模板
    const finalPrompt = buildUnifiedPrompt({
      isFollowUp,
      formattedHistory,
      question: question.question,
      ragContext,
      judge0Result: judge0Result,
      userAnswer: answer,
      reactTestResult: reactTestResult,
    });

    if (!finalPrompt) {
      return NextResponse.json(
        { error: 'Invalid question type' },
        { status: 400 }
      );
    }

    const stream = await generateContentStream(
      finalPrompt,
      async (fullJson) => {
        // 這個函式會在 gemini.ts 中被呼叫
        // 只有在不是追問的情況下,才執行資料庫寫入
        if (!isFollowUp) {
          try {
            const finalEvaluation = JSON.parse(fullJson);
            const recordToInsert = {
              user_id: user.id,
              question_id: questionId,
              user_answer: answer,
              evaluation: finalEvaluation,
              score: finalEvaluation.score,
            };

            const { error: insertError } = await adminSupabase
              .from('practice_records')
              .insert(recordToInsert);

            if (insertError) {
              console.error('Error in onComplete DB write:', insertError);
            }
          } catch (e) {
            console.error('Failed to parse or insert record in onComplete:', e);
          }
        }
      }
    );
    return new Response(stream, {
      headers: { 'Content-Type': 'application/json; charset=utf-8' },
    });
  } catch (error) {
    console.error('Error in evaluation API:', error);
    if (error instanceof Error) {
      return NextResponse.json({ error: error.message }, { status: 500 });
    }
    return NextResponse.json(
      { error: 'Internal Server Error' },
      { status: 500 }
    );
  }
}

程式碼改動摘要說明:

  • 引入 React 評測模組
import {
 evaluateReactComponent,
 ReactTestCase,
 TestCaseResult,
} from '@/app/lib/react-executor';
  • 新增結果格式化函式
    formatReactEvaluationResults: 將測試結果轉換為 Markdown 格式
    包含通過/失敗統計、缺失內容、實際渲染的 HTML

  • 新增問題評估函數
    prepareEvaluationContext將我們之前判斷題目的邏輯進行整合,並加入了 React 問題的判斷,如下方的邏輯:

if (question.topic === 'React' && question.type === 'code') {
 // 使用 React 原生評測
} else if (question.type === 'code') {
 // 使用 Judge0
}

Step 2: 新增第一道 React 題目

現在讓我們在題庫中新增一道 React 實作題。打開 data/questions.json,在陣列中加入以下題目:

{
  "id": "react-pro-001", // 由於是手動產生題目,暫時我們先用回我們之前設定的id結構,日後要像之前的文章做批量產生時可以繼續使用UUID的格式
  "topic": "React",
  "type": "code",
  "difficulty": "easy",
  "question": "請建立一個名為 `Counter` 的 React 元件。\n\n**需求:**\n- 接收一個 `initialCount` prop(預設值為 0)\n- 使用 `useState` 管理計數狀態\n- 顯示當前計數值\n- 包含「增加」和「減少」兩個按鈕\n\n**注意:** 我們只會驗證初始渲染的 HTML 結構,不會測試按鈕的實際點擊行為。",
  "hints": [
    "使用 `useState` hook 來管理計數器的狀態",
    "記得為 `initialCount` prop 設定預設值",
    "確保 JSX 中包含計數值的顯示元素和兩個按鈕"
  ],
  "keyPoints": [
    "正確使用 useState hook 並傳入 initialCount 作為初始值",
    "使用 JSX 語法建立 UI 結構",
    "正確顯示當前計數值",
    "包含文字為「增加」和「減少」的按鈕元素"
  ],
  "starterCode": "import React, { useState } from 'react';\n\nfunction Counter({ initialCount = 0 }) {\n  const [count, setCount] = useState(initialCount);\n  \n  return (\n    <div>\n      {/* TODO: 在此處實作你的 UI */}\n    </div>\n  );\n}\n\nexport default Counter;",
  "testCases": [
    {
      "name": "預設初始值 (0)",
      "props": {},
      "expectedPatterns": ["<div", "0", "增加", "減少", "<button"]
    },
    {
      "name": "初始值為 5",
      "props": { "initialCount": 5 },
      "expectedPatterns": ["5", "增加", "減少"]
    },
    {
      "name": "初始值為負數 (-3)",
      "props": { "initialCount": -3 },
      "expectedPatterns": ["-3", "增加", "減少"]
    }
  ]
}

題目設計說明:

  • testCases 結構

    • name: 測試案例的描述
    • props: 傳入元件的 props(對應 React 的 props)
    • expectedPatterns: 期望在渲染 HTML 中找到的字串陣列
  • 驗證策略

    • 我們使用簡單的字串 includes 檢查
    • 這適合驗證初始渲染的結構和內容
    • SSR 環境無法測試互動行為(如按鈕點擊)

為什麼這樣設計?

  • 簡單直接:不需要複雜的 DOM 查詢
  • 符合 SSR 限制:只驗證靜態渲染結果
  • 易於擴展:未來可以加入更多 pattern 或改用 Cheerio

Step 3: 優化 Prompt — 讓 Gemini 理解 React 測試結果

為了讓 Gemini 能更好地解讀 React 的測試結果,我們需要在 Prompt 中加入針對性的指示。
打開 app/lib/prompts.ts,找到 unifiedPromptTemplate 的變數,加入以下邏輯:

**Special Guidelines for React Component Evaluation:**
When evaluating React components, consider:
1. **Functional Correctness**: Does the component render the expected output? Check if all test cases passed.
2. **React Best Practices**: 
   - Is \`useState\` used correctly?
   - Are props handled properly with default values?
   - Is the JSX structure clean and semantic?
3. **Code Quality**:
   - Is the component logic clear and maintainable?
   - Are there any potential bugs or anti-patterns?
4. If tests failed, clearly explain:
   - Which test cases failed
   - What patterns were missing in the rendered HTML
   - Specific suggestions for fixing the issues

同時下方的型別與buildUnifiedPrompt函數都需要修改,加入 React Result的判斷:

interface PromptContext {
  isFollowUp: boolean;
  formattedHistory: string;
  question: string;
  ragContext: string;
  judge0Result: string;
  reactTestResult: string; // 新增:React 測試結果
  userAnswer: string;
}

export function buildUnifiedPrompt(context: PromptContext): string {
  return unifiedPromptTemplate
    .replace(/\${isFollowUp}/g, String(context.isFollowUp))
    .replace(/\${formattedHistory}/g, context.formattedHistory)
    .replace(/\${question}/g, context.question)
    .replace(/\${ragContext}/g, context.ragContext)
    .replace(/\${judge0Result}/g, context.judge0Result)
    .replace(/\${reactTestResult}/g, context.reactTestResult) // 新增
    .replace(/\${userAnswer}/g, context.userAnswer);
}

程式碼摘要說明:

  • 區分題目類型
    透過關鍵字判斷是 React 測試還是一般程式測試
    針對不同類型提供不同的評估指引

  • React 專屬指引

    • 強調 SSR 測試的特性
    • 引導 Gemini 關注 JSX、Hooks、元件結構
      +提供失敗時的分析框架

Step 4: 整合測試

現在所有的程式碼都已就位,讓我們進行完整的測試!

首先自然是啟動開發我們的伺服器

npm run dev

前往 http://localhost:3000
使用你的帳號登入後進入Dashboard後,選擇程式實作並點選 React 題目,在面試介面中,應該能看到新增的 React 題目。

嘗試提交以下正確的程式碼:

import React, { useState } from 'react';

   function Counter({ initialCount = 0 }) {
     const [count, setCount] = useState(initialCount);
     
     return (
       <div className="counter">
         <p>計數: {count}</p>
         <button onClick={() => setCount(count + 1)}>增加</button>
         <button onClick={() => setCount(count - 1)}>減少</button>
       </div>
     );
   }

   export default Counter;

預期結果:

✅ 所有 3 個測試案例都通過
✅ Gemini 給予正面評價
✅ 可能會指出程式碼的優點

提交錯誤答案測試

嘗試提交一個有問題的版本(例如少了「減少」按鈕):

import React, { useState } from 'react';

   function Counter({ initialCount = 0 }) {
     const [count, setCount] = useState(initialCount);
     
     return (
       <div>
         <p>{count}</p>
         <button>增加</button>
       </div>
     );
   }

   export default Counter;

預期結果:
❌ 部分測試案例失敗(缺少「減少」按鈕)
❌ Gemini 指出缺少的元素
✅ 提供具體的修正建議

在你的終端機中,應該能看到類似以下的 log:

   ============================================================
   📋 開始評測
      題目類型: React - code
      題目 ID: react-code-001
   ============================================================

   🎯 偵測到 React 程式題,使用原生評測引擎
   ✅ React 評測完成

今日回顧

恭喜你!今天我們完成了 React 評測功能的最後一哩路。讓我們回顧一下完成了什麼:
✅ API 整合:成功將 react-executor 引擎接入評測 API
✅ 題目建立:新增了第一道 React 實作題,包含完整的測試案例
✅ Prompt 優化:讓 Gemini 能理解並評估 React 測試結果
✅ 前端整合測試:驗證了整個評測流程的正確性

明日預告

我們的 AI 面試官系統已經功能完整了!明天(Day 29)我們將進入收尾階段:

  • UI/UX 最後打磨
  • 完善文件和使用說明
  • 最後的 bug 修復
  • 回顧整個專案的技術架構

上一篇
打造 React 評測引擎 :試著做出最小可行實作方案
下一篇
最後一哩路:補上缺少的 Landing Page
系列文
前端工程師的AI應用開發實戰:30天從Prompt到Production - 以打造AI前端面試官為例29
圖片
  熱門推薦
圖片
{{ item.channelVendor }} | {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言