歡迎來到第二週的收尾~!再次強調,如果你有感受到這兩天的文章變水了,就跟水屬性的魔法師第11集一樣水,那你的感受是完全正確的!家族內的事情讓我實在很難抽出完整的時間寫文章,預計到Day15時我才會稍稍恢復自由之身,我會在那之後回顧一下這幾天的文章並加以完善,暫時還請你們稍微忍耐一下,感謝!
昨天我們成功的透過 Judge0 標準的流程建立了一套基本的程式碼評估系統,我們能正確地將使用者寫的程式碼提交並拿到執行後的結果,這麼一來,程式實作的題目現在我們也能至少處理最基本的演算法問題了!(前端相關的問題例如React或 Css實作則目前的設計還沒有能力處理,這會一路到第四週才作為挑戰題攻克),配合上之前 Supabase 實作的 RAG 系統,概念問題我們也能有著很不錯的回覆!最關鍵的兩大拼圖目前都已經在我們手上了,剩下的就是將拼圖拼到我們現有的框架中了,但在我們準備將兩者結合時,我們共同發現了一個至關重要的設計細節:並非所有題目都需要同時動用這兩大利器,兩者在評估正確性的部分時仰賴的是完全不同的系統,因此在將素材交給 AI 時我們需要下點工夫,免得拿水果刀切豬肉!
今天,我們就要來建立這個智慧系統的「大腦」——一個總指揮 API。它會懂得判斷題型,並為不同類型的問題指派最合適的評估工具。最終,它會命令 AI 將所有分析結果,彙整成一份穩定、可靠的結構化 JSON 報告,為我們的應用程式打下最堅實的資料基礎。
@google/genai
的內建功能,確保 AI 回傳語法正確的 JSON。/api/interview/evaluate
,完成整個評估流程的自動化調度。在我們目前的設計中,有分為「程式實作」與「概念問答」兩種題目,兩種題目有各自需要用的欄位,也有對應的判斷工具,大致上如下方的說明:
評估重點在於知識點的覆蓋度。因此,我們將使用 RAG 來檢索相關的 keyPoints
,作為 AI 評分的黃金標準。
評估重點在於程式碼的正確性與品質。因此,我們將使用 Judge0 來執行程式碼和測試案例,取得客觀的 stdout
和 stderr
作為主要證據。
我們的總指揮 API 將扮演智慧分流的角色,其工作流程如下:
graph TD
A[使用者提交答案] --> B{/api/interview/evaluate};
B --> C{判斷題目類型};
C -- 概念題 (concept) --> D[路徑 A];
C -- 實作題 (code) --> E[路徑 B];
subgraph "路徑 A:概念題評估"
D --> D1[1.執行 RAG];
D1 --> D2[Supabase: 取得相關 keyPoints];
D2 --> D3[2.組裝「概念題」Prompt];
D3 --> D4[3.呼叫 Gemini];
end
subgraph "路徑 B:實作題評估"
E --> E1[1.執行 Judge0];
E1 --> E2[取得 stdout/stderr];
E2 --> E3[2.組裝「實作題」Prompt];
E3 --> E4[3.呼叫 Gemini];
end
D4 --> F[回傳結構化 JSON];
E4 --> F;
這個設計確保了我們總是使用最合適的工具來完成任務,既高效又精準。
為了適應兩種評估路徑,我們的 JSON Schema 需要保持結構統一,同時具備靈活性。
{
"summary": "string",
"score": "number (1-5)",
"grounded_evidence": { // Judge0 的客觀證據
"tests_passed": "number",
"tests_failed": "number",
"stderr_excerpt": "string|null"
} | null, // 對於概念題,這個欄位將是 null
"pros": ["string"],
"cons": ["string"],
"next_practice": ["string"]
}
設計關鍵:grounded_evidence 欄位。對於程式題,它會是一個包含執行結果的物件;對於概念題,它將直接是 null。前端可以依此判斷是否要渲染測試結果相關的 UI。
我們為兩條路徑分別撰寫專用的 Prompt。
這個 Prompt 的目標是評估文字論述能力,核心是比對 keyPoints
。
<role>
You are a senior frontend technical interviewer evaluating a candidate's answer to a conceptual question.
</role>
<task>
Evaluate the <candidate_answer> based on whether it covers the concepts in <rag_context>.
Your response MUST be a single, valid JSON object that adheres to the provided schema. In this conceptual evaluation, the "grounded_evidence" field MUST be null.
Answer in Traditional Chinese in a must.
</task>
<json_schema>
{
"summary": "string",
"score": "number (1-5)",
"grounded_evidence": null,
"pros": ["string"],
"cons": ["string"],
"next_practice": ["string"]
}
</json_schema>
<rag_context>
\${ragContext}
</rag_context>
<candidate_answer>
\${userAnswer}
</candidate_answer>
這個 Prompt 的目標是進行 Code Review,核心是分析 Judge0 的執行結果。
<role>
You are a world-class senior frontend technical interviewer providing a comprehensive code review based strictly on the execution result and the code itself.
</role>
<task>
Evaluate the <user_code> based on the objective <judge0_result>. Analyze the code for quality, correctness, and best practices.
Your response MUST be a single, valid JSON object that adheres to the provided schema.
Answer in Traditional Chinese in a must.
</task>
<json_schema>
{
"summary": "string",
"score": "number (1-5)",
"grounded_evidence": { "tests_passed": "number", "tests_failed": "number", "stderr_excerpt": "string|null" },
"pros": ["string"],
"cons": ["string"],
"next_practice": ["string"]
}
</json_schema>
<judge0_result>
\${judge0Result}
</judge0_result>
<user_code>
\${userCode}
</user_code>
現在,我們來實作 /api/interview/evaluate,並啟用 @google/genai SDK 內建的 JSON 模式以確保穩定性。
// app/api/interview/evaluate/route.ts
import { NextResponse } from 'next/server';
import { createClient } from '@supabase/supabase-js';
import { GoogleGenAI, Content } from '@google/genai';
import questions from '@/data/questions.json';
// --- 初始化客戶端 ---
const supabase = createClient(
process.env.SUPABASE_URL!,
process.env.SUPABASE_SERVICE_KEY!
);
const GEMINI_API_KEY = process.env.GEMINI_API_KEY;
const genAI = new GoogleGenAI({ apiKey: GEMINI_API_KEY });
// --- Prompt 模板 ---
const conceptPromptTemplate = `<role>
You are a senior frontend technical interviewer evaluating a candidate's answer to a conceptual question.
</role>
<task>
Evaluate the <candidate_answer> based on whether it covers the concepts in <rag_context>.
Your response MUST be a single, valid JSON object that adheres to the provided schema. In this conceptual evaluation, the "grounded_evidence" field MUST be null.
Answer in Traditional Chinese in a must.
</task>
<json_schema>
{
"summary": "string",
"score": "number (1-5)",
"grounded_evidence": null,
"pros": ["string"],
"cons": ["string"],
"next_practice": ["string"]
}
</json_schema>
<rag_context>
\${ragContext}
</rag_context>
<candidate_answer>
\${userAnswer}
</candidate_answer>`;
const codePromptTemplate = `<role>
You are a world-class senior frontend technical interviewer providing a comprehensive code review based strictly on the execution result and the code itself.
</role>
<task>
Evaluate the <user_code> based on the objective <judge0_result>. Analyze the code for quality, correctness, and best practices.
Your response MUST be a single, valid JSON object that adheres to the provided schema.
Answer in Traditional Chinese in a must.
</task>
<json_schema>
{
"summary": "string",
"score": "number (1-5)",
"grounded_evidence": { "tests_passed": "number", "tests_failed": "number", "stderr_excerpt": "string|null" },
"pros": ["string"],
"cons": ["string"],
"next_practice": ["string"]
}
</json_schema>
<judge0_result>
\${judge0Result}
</judge0_result>
<user_code>
\${userCode}
</user_code>`;
export async function POST(request: Request) {
try {
const { questionId, answer } = await request.json();
const question = questions.find((q) => q.id === questionId);
if (!question) {
return NextResponse.json(
{ error: 'Question not found' },
{ status: 404 }
);
}
let finalPrompt = '';
if (question.type === 'concept') {
// --- 概念題路徑 (RAG) ---
const embeddingResponse = await genAI.models.embedContent({
model: 'gemini-embedding-001',
contents: answer,
config: {
outputDimensionality: 768,
},
});
if (
!embeddingResponse.embeddings ||
embeddingResponse.embeddings.length === 0
) {
return NextResponse.json(
{ error: 'Embedding response is empty' },
{ status: 500 }
);
}
const answerEmbedding = embeddingResponse.embeddings[0].values;
const { data: ragData, error: ragError } = await supabase.rpc(
'match_documents',
{
query_embedding: JSON.stringify(answerEmbedding),
match_threshold: 0.7,
match_count: 5,
question_id: questionId,
}
);
const ragContext =
!ragError && ragData?.length > 0
? ragData.map((d: any) => `- ${d.content}`).join('\n')
: 'No relevant context found.';
finalPrompt = conceptPromptTemplate.replace(
/\${ragContext}/g,
ragContext
);
finalPrompt = finalPrompt.replace(/\${userAnswer}/g, answer);
console.log('finalPrompt', finalPrompt);
} else if (question.type === 'code') {
const judge0Response = await fetch(
`${process.env.NEXT_PUBLIC_APP_URL}/api/judge0/execute`,
{
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ source_code: answer }),
}
);
const judge0Result = await judge0Response.json();
const judge0ResultText = `Status: ${
judge0Result.status.description
}\nStdout: ${judge0Result.stdout || 'N/A'}\nStderr: ${
judge0Result.stderr || 'N/A'
}`;
finalPrompt = codePromptTemplate.replace(
/\${judge0Result}/g,
judge0ResultText
);
finalPrompt = finalPrompt.replace(/\${userCode}/g, answer);
}
if (!finalPrompt) {
return NextResponse.json(
{ error: 'Invalid question type' },
{ status: 400 }
);
}
const contents: Content[] = [{ parts: [{ text: finalPrompt }] }];
const result = await genAI.models.generateContent({
model: 'gemini-2.5-flash',
contents: contents,
config: {
responseMimeType: 'application/json',
},
});
const responseText = result.text;
const jsonResponse = JSON.parse(responseText || '{}');
try {
return NextResponse.json(jsonResponse);
} catch (e) {
console.error('Gemini did not return valid JSON:', responseText);
return NextResponse.json(
{ error: 'AI response is not valid JSON' },
{ status: 500 }
);
}
} 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 }
);
}
}
最後,我們只需小幅修改面試頁面(app/(main)/interview/[sessionId]/page.tsx
)的 handleSubmit
函式。
// ...
const handleSubmit = async () => {
if (!answer || !currentQuestion) return;
const newHistory: ChatMessage[] = [
...chatHistory,
{ role: 'user', content: answer },
];
setChatHistory(newHistory);
setAnswer('');
setIsLoading(true);
try {
const response = await fetch('/api/interview/evaluate', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
questionId: currentQuestion.id,
answer: answer,
userId: 'anonymous-user',
}),
});
if (!response.ok) throw new Error('API request failed');
const data = await response.json();
console.log('Structured AI Feedback:', data);
const aiResponse: ChatMessage = {
role: 'ai',
content: data.summary,
evaluation: data,
};
setChatHistory([...newHistory, aiResponse]);
} catch (error) {
console.error('錯誤:', error);
const errorResponse: ChatMessage = {
role: 'ai',
content: '抱歉,我現在無法提供回饋,請稍後再試。',
};
setChatHistory([...newHistory, errorResponse]);
} finally {
setIsLoading(false);
}
};
// ...
提交答案後,打開瀏覽器的開發者工具 Console,你將看到一個工整的、結構化的 JSON 物件。我們的 AI 面試官,終於學會了邏輯清晰地思考和表達!
今天,我們的專案完成了一次巨大的進化,從一個有趣的玩具,蛻變成一個具備了穩健架構的應用雛形。
✅ 我們確立了「概念題用 RAG,實作題用 Judge0」的智慧分流設計。
✅ 我們設計了一套能靈活適應不同場景的統一 JSON Schema。
✅ 我們為兩種題型量身打造了專屬的 Prompt,提升了回饋的精準度。
✅ 我們學會了使用 SDK 內建的 JSON 模式,這是邁向生產級應用的關鍵一步。
✅ 我們成功建立了一個強大的總指揮 API,實現了整個評估流程的自動化。
高品質的 JSON 資料已經到手,但從提交到看見結果的等待時間,仍然是體驗上的一個痛點。
明天(Day 15),我們要正式進入第三週的主題,解決這個等待問題。我們將導入 Streaming 技術,讓 AI 的回饋能夠像打字機一樣,即時地、逐字地呈現在使用者面前,讓應用變得生動起來!
我們明天見!🚀