iT邦幫忙

2026 iThome 鐵人賽

DAY 20
1
AI Engineering

30天用 Claude Code + LangGraph 實作個人化 AI 學習教練系列 第 20 篇

Day 20:簡化版測驗系統 - 用比對話更明確的方式驗收學習成效

  • 分享至 

  • xImage
  •  

Day 19 收尾時提過,後端現在有兩條路:/chat 這種對話式互動,跟 /plans、/tasks、/progress 這些結構化操作。但不管是聊天還是進度記錄,判斷「使用者到底學會了沒」,都只能靠使用者自己說(Day 18 的 difficulty_rating 也是主觀評分)。教練沒有一個客觀的方式去檢查,使用者說「這章我懂了」,跟真的懂了,中間可能有落差。

今天要做測驗系統:自動出題、使用者作答與自動評分。系統會以作答結果評估學習狀況,不再只依使用者回報。Day 4 已設計 assessments 表(plan_id、title、questions、score),今天會把它接上 API。

為什麼要出兩種題型,不是全部選擇題

選擇題可直接比對 choice_index 與正確答案索引,不需要呼叫模型。不過部分概念,例如「解釋 EC2 和 Lambda 的差別」,需要使用者自行組織答案。因此今天混合選擇題與簡答題,並採用不同評分方式。

選擇題比對答案,簡答題請模型當裁判

選擇題的評分邏輯很單純:使用者選的索引跟出題時存好的 correct_index 一不一樣。簡答題沒辦法這樣比,使用者不會逐字打出跟參考答案一模一樣的句子,比對純文字幾乎一定會誤判成全部錯誤。今天讓簡答題的評分也交給本機模型:把「題目」「參考答案」「使用者的作答」一起丟給模型,只要求它回傳「語意上算不算正確」這一個是非判斷,不要求它給分數或講評,讓輸出格式盡量單純、好驗證。

評分只有對或錯,沒有部分給分

Day 1 PRD 的評分公式是 (正確題數 / 總題數) * 100,每題只有對與錯兩種結果。簡答題也只要求模型回傳布林值,與選擇題採用相同粒度。若要支援部分給分,必須重新設計評分公式。

出題時,正確答案跟公開題目存在同一個 JSON 欄位裡

Day 4 設計的 Assessment.questions 欄位型別是 JSON,當時只打算拿來存「題目內容」。今天出題時,選擇題的 correct_index、簡答題的 reference_answer 也一起塞進同一個 questions 欄位存進資料庫,公開題目和正確答案沒有被分開存放。API 回傳給使用者的題目清單,會在程式邏輯裡把正解欄位過濾掉,不會讓正解直接外流,但資料庫層級沒有做這層區隔,這是今天刻意接受的簡化。如果之後要更嚴謹,可以把正解拆到另一個欄位甚至另一張表,不在今天的範圍內。

為什麼生成測驗要呼叫端自己指定主題

一份計畫(Plan)通常橫跨好幾週,weekly_topics 有好幾個不同的主題。如果讓系統自動挑一個主題出題,很容易挑到使用者還沒讀到、或者已經很熟的部分。今天讓 POST /assessments 的請求裡直接帶一個 topic 欄位,由呼叫端(之後會是前端介面)決定「現在要考哪個主題」,比系統自己亂猜準確。


實作步驟

步驟1:定義 Schema

檔案位置: backend/schemas.py
狀態: 修改檔案(接續 Day 19 的內容,繼續往下加)
用途: 定義測驗生成、查詢、提交評分需要的所有格式
依賴: pydantic

from typing import Literal


class AssessmentQuestion(BaseModel):
    """內部使用:包含正解的完整題目,只用在生成與評分邏輯,不會直接回傳給使用者"""
    type: Literal["選擇", "簡答"]
    question: str
    options: list[str] | None = None  # 選擇題才有,4個選項
    correct_index: int | None = None  # 選擇題才有,正確選項在options裡的索引
    reference_answer: str | None = None  # 簡答題才有,用來比對的參考答案


class GeneratedAssessment(BaseModel):
    """Planner Agent 生成的完整測驗,內部使用"""
    title: str
    questions: list[AssessmentQuestion]


class QuestionOut(BaseModel):
    """對外的題目格式,不含正解"""
    index: int
    type: Literal["選擇", "簡答"]
    question: str
    options: list[str] | None = None


class AssessmentCreateRequest(BaseModel):
    """POST /assessments 的請求格式"""
    user_id: int
    plan_id: int
    topic: str = Field(min_length=1, description="這次測驗要考的主題,例如計畫裡某一週的主題")
    question_count: int = Field(ge=5, le=10, description="出幾題,5到10題之間")


class AssessmentDetailResponse(BaseModel):
    """POST /assessments 與 GET /assessments/{id} 共用的回應格式"""
    assessment_id: int
    title: str
    questions: list[QuestionOut]


class AnswerIn(BaseModel):
    """單一題目的作答"""
    question_index: int
    choice_index: int | None = None  # 選擇題填這個
    text_answer: str | None = None  # 簡答題填這個


class SubmitRequest(BaseModel):
    """POST /assessments/{id}/submit 的請求格式"""
    user_id: int
    answers: list[AnswerIn]


class QuestionResult(BaseModel):
    """單一題目的評分結果"""
    question_index: int
    correct: bool


class SubmitResponse(BaseModel):
    """POST /assessments/{id}/submit 的回應格式"""
    assessment_id: int
    score: float
    correct_count: int
    question_count: int
    detail: list[QuestionResult]

AssessmentQuestion 跟 GeneratedAssessment 雖然也是 Pydantic 模型,但故意不會被拿去當任何端點的 response_model,只在 assessment_generator.py 生成題目、以及評分邏輯讀取正解時使用,公開的題目一律走 QuestionOut。

步驟2:寫 assessment_generator.py

檔案位置: backend/assessment_generator.py
狀態: 新增檔案
用途: 根據主題與使用者程度,生成結構化的測驗題目
依賴: langchain-ollama, schemas

from langchain_ollama import ChatOllama
from schemas import GeneratedAssessment

_llm = ChatOllama(model="llama3.1:8b", temperature=0.4)
_structured_llm = _llm.with_structured_output(GeneratedAssessment).with_retry(
    stop_after_attempt=3
)


def generate_assessment(
    topic: str, level: str, question_count: int
) -> GeneratedAssessment:
    """根據主題、使用者程度與題數,生成混合選擇題與簡答題的測驗"""
    prompt = (
        "你是一位學習教練,請針對以下主題出一份測驗:\n"
        f"- 主題:{topic}\n"
        f"- 使用者程度:{level}\n"
        f"- 題數:{question_count} 題\n\n"
        "選擇題和簡答題都要出,不要全部都同一種類型。"
        "選擇題要提供4個選項,correct_index是正確選項在options清單裡的索引(從0開始)。"
        "簡答題不需要options和correct_index,但要提供reference_answer作為評分參考。"
    )
    return _structured_llm.invoke(prompt)

跟 Day 13 的 planner.py 是同一種寫法:用 with_structured_output 強制模型輸出符合 GeneratedAssessment 格式的內容,with_retry 處理模型偶爾漏欄位的情況。

步驟3:寫 assessment_grader.py

檔案位置: backend/assessment_grader.py
狀態: 新增檔案
用途: 選擇題直接比對答案,簡答題請模型判斷語意是否正確
依賴: langchain-ollama, pydantic

完整的 assessment_grader.py 內容如下:

from pydantic import BaseModel
from langchain_ollama import ChatOllama


class GradeResult(BaseModel):
    """簡答題評分結果:只回傳對或錯,不給分數也不給講評"""
    correct: bool


_grade_llm = ChatOllama(model="llama3.1:8b", temperature=0)
_grade_structured_llm = _grade_llm.with_structured_output(GradeResult).with_retry(
    stop_after_attempt=3
)


def grade_choice(correct_index: int, given_index: int | None) -> bool:
    """選擇題評分:直接比對索引"""
    return given_index is not None and given_index == correct_index


def grade_short_answer(question: str, reference_answer: str, student_answer: str) -> bool:
    """簡答題評分:請模型判斷使用者的作答在語意上跟參考答案是否一致"""
    if not student_answer.strip():
        return False

    prompt = (
        "你是一位嚴謹但公平的閱卷老師,請判斷學生的作答是否正確:\n"
        f"題目:{question}\n"
        f"參考答案:{reference_answer}\n"
        f"學生作答:{student_answer}\n\n"
        "只要語意上抓到重點就算正確,不用跟參考答案逐字相同,用詞不同但意思對也算正確。"
    )
    result = _grade_structured_llm.invoke(prompt)
    return result.correct

grade_short_answer 先擋掉空字串:使用者沒作答,不需要浪費一次模型呼叫去問「這樣算不算對」,直接判定錯誤。temperature=0 是刻意選的,評分這件事要盡量穩定、少一點隨機性,跟 Day 17 激勵訊息故意調高 temperature 的方向完全相反。

步驟4:權限檢查 _get_owned_assessment

檔案位置: backend/main.py
狀態: 修改檔案(接續 Day 19 的內容,繼續往下加)
用途: 查出測驗並確認呼叫者是這份測驗所屬計畫的擁有者
依賴: models, fastapi

def _get_owned_assessment(assessment_id: int, user_id: int, db: Session) -> Assessment:
    """取出指定測驗,並確認這份測驗所屬的計畫是呼叫者本人的"""
    assessment = db.query(Assessment).filter(Assessment.id == assessment_id).first()
    if assessment is None:
        raise HTTPException(status_code=404, detail="找不到這份測驗")

    plan = db.query(Plan).filter(Plan.id == assessment.plan_id).first()
    if plan.user_id != user_id:
        raise HTTPException(status_code=403, detail="這不是你的測驗,無法操作")

    return assessment

跟 Day 18 的 _get_owned_task 是同一種寫法,只是今天查的是 Assessment 透過 plan_id 找回 Plan。

步驟5:寫 POST /assessments

檔案位置: backend/main.py
狀態: 修改檔案
用途: 生成一份測驗,存進資料庫,回傳不含正解的題目清單
依賴: schemas, models, assessment_generator

先把新的 import 加上:

from models import Assessment
from assessment_generator import generate_assessment
from assessment_grader import grade_choice, grade_short_answer
from schemas import (
    AssessmentCreateRequest,
    AssessmentDetailResponse,
    QuestionOut,
    SubmitRequest,
    SubmitResponse,
    QuestionResult,
)


def _to_question_out(index: int, question) -> QuestionOut:
    """把內部含正解的題目,轉成不含正解的對外格式"""
    return QuestionOut(
        index=index,
        type=question["type"],
        question=question["question"],
        options=question.get("options"),
    )
@app.post("/assessments", response_model=AssessmentDetailResponse)
def create_assessment(
    payload: AssessmentCreateRequest, db: Session = Depends(get_db)
) -> AssessmentDetailResponse:
    """生成一份測驗,存進資料庫,回傳不含正解的題目清單"""
    plan = _get_owned_plan(payload.plan_id, payload.user_id, db)

    profile = db.query(Profile).filter(Profile.user_id == payload.user_id).first()
    level = profile.level if profile else "初級"

    generated = generate_assessment(
        topic=payload.topic, level=level, question_count=payload.question_count
    )

    assessment = Assessment(
        plan_id=plan.id,
        title=generated.title,
        questions=[q.model_dump() for q in generated.questions],
    )
    db.add(assessment)
    db.commit()
    db.refresh(assessment)

    questions_out = [
        _to_question_out(i, q) for i, q in enumerate(assessment.questions)
    ]
    return AssessmentDetailResponse(
        assessment_id=assessment.id, title=assessment.title, questions=questions_out
    )

這裡重用了 Day 15 寫的 _get_owned_plan,確認呼叫者真的擁有這個 plan_id,才允許針對這份計畫出題。

步驟6:寫 GET /assessments/{assessment_id} 與 POST /assessments/{assessment_id}/submit

檔案位置: backend/main.py
狀態: 修改檔案
用途: 查詢已生成的測驗題目,以及提交作答並評分
依賴: schemas, models, assessment_grader

@app.get("/assessments/{assessment_id}", response_model=AssessmentDetailResponse)
def get_assessment(
    assessment_id: int, user_id: int, db: Session = Depends(get_db)
) -> AssessmentDetailResponse:
    """查詢已生成的測驗題目,不含正解"""
    assessment = _get_owned_assessment(assessment_id, user_id, db)
    questions_out = [
        _to_question_out(i, q) for i, q in enumerate(assessment.questions)
    ]
    return AssessmentDetailResponse(
        assessment_id=assessment.id, title=assessment.title, questions=questions_out
    )


@app.post("/assessments/{assessment_id}/submit", response_model=SubmitResponse)
def submit_assessment(
    assessment_id: int, payload: SubmitRequest, db: Session = Depends(get_db)
) -> SubmitResponse:
    """提交作答,逐題評分,計算總分並存回資料庫"""
    assessment = _get_owned_assessment(assessment_id, payload.user_id, db)
    questions = assessment.questions

    answer_map = {a.question_index: a for a in payload.answers}
    details: list[QuestionResult] = []
    correct_count = 0

    for i, question in enumerate(questions):
        answer = answer_map.get(i)

        if question["type"] == "選擇":
            given_index = answer.choice_index if answer else None
            is_correct = grade_choice(question["correct_index"], given_index)
        else:
            given_text = answer.text_answer if answer else ""
            is_correct = grade_short_answer(
                question["question"], question["reference_answer"], given_text or ""
            )

        details.append(QuestionResult(question_index=i, correct=is_correct))
        if is_correct:
            correct_count += 1

    score = (correct_count / len(questions)) * 100
    assessment.score = score
    db.commit()

    return SubmitResponse(
        assessment_id=assessment.id,
        score=score,
        correct_count=correct_count,
        question_count=len(questions),
        detail=details,
    )

answer_map 用 question_index 當 key,就算使用者漏答某一題(那一題根本沒出現在 payload.answers 裡),answer_map.get(i) 會拿到 None,選擇題會判定 given_index 是 None 直接算錯,簡答題會拿到空字串一樣算錯,不會因為漏答而讓程式出錯。

步驟7:測試

先確認 Ollama 本機模型服務已啟動,啟動後端伺服器:

uvicorn main:app --reload

進入 http://127.0.0.1:8000/docs,用一個已核准計畫的 plan_id(假設是 7,user_id 是 1)依序測試:

1. 生成測驗:POST /assessments

{
  "user_id": 1,
  "plan_id": 7,
  "topic": "EC2 與網路基礎",
  "question_count": 5
}

呼叫要跑一次完整生成,可能需要 10-20 秒。應該收到 assessment_id 和 5 題不含正解的題目,選擇題會帶 options,簡答題 options 是 null。

2. 查詢題目:GET /assessments/{assessment_id}?user_id=1,確認回應跟步驟1一樣,且一樣看不到正解。

3. 提交作答:POST /assessments/{assessment_id}/submit,假設第0題是選擇題、第1題是簡答題:

{
  "user_id": 1,
  "answers": [
    {"question_index": 0, "choice_index": 1},
    {"question_index": 1, "text_answer": "EC2是虛擬主機服務"}
  ]
}

沒作答的題目可以完全不放進 answers 清單。回應應該看到 score、correct_count,還有每一題的對錯明細。

4. 用別人的身分查詢(應該失敗):GET /assessments/{assessment_id}?user_id=999,應該回傳 403 這不是你的測驗,無法操作。


常見問題

簡答題評分每次呼叫都要等模型,會不會很慢

一份測驗如果有 5 題簡答題,代表 submit 這支端點裡要連續呼叫模型 5 次,確實會比純選擇題慢上不少,這是用模型當裁判必須付出的代價。如果之後題數變多、使用者變多,可以考慮把多題的評分改成非同步、平行處理,今天先用最直接的循序呼叫,把邏輯寫對優先於效能。

簡答題評分結果,我覺得模型判斷得不準

grade_short_answer 目前只要求模型回傳「對或錯」這一個布林值,沒有額外的講評或信心分數可以參考,準確度完全取決於 Prompt 寫得夠不夠清楚跟本機模型本身的能力。如果實測發現常常誤判,可以先調整 Prompt(例如加上「哪些關鍵重點一定要提到」),這是內容調校的問題,跟今天的整體架構是分開的兩件事。

Assessment.questions 欄位存了正解,會不會有資料外洩風險

今天的設計是在 API 邏輯層過濾正解(_to_question_out 只挑出不含正解的欄位),資料庫本身沒有做任何隔離,任何能直接查詢 app.db 的人都看得到正解。這是今天刻意接受的簡化,正式的正式環境如果要更嚴謹,需要把正解拆到另一張只有後端能查的表,不在今天的範圍內。

為什麼 POST /assessments 不順便自動決定要考哪個主題

Plan.content 裡雖然存了完整的 weekly_topics,但讓系統自動挑一週出題,很容易挑到使用者還沒讀到或已經很熟的部分。今天讓呼叫端自己決定 topic,之後前端介面可以讓使用者從自己計畫的週主題清單裡選一個,這樣出的測驗才會真的對應到使用者目前的進度。

score 已存入 assessments 表,Day 18 的 difficulty_rating 也已存入 progress_logs 表,但尚未整合使用。明天會建立能力追蹤:低於 60 分的技能排入下週複習,80 分以上則可前進到新內容。


上一篇
Day 19:把對話圖接上 API - Checkpoint 在真正服務裡也要撐得住
下一篇
Day 21:基本能力追蹤 - 讓分數真正影響下一步
系列文
30天用 Claude Code + LangGraph 實作個人化 AI 學習教練 共 25 篇
圖片
  熱門推薦
圖片
{{ item.channelVendor }} | {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言