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,每題只有對與錯兩種結果。簡答題也只要求模型回傳布林值,與選擇題採用相同粒度。若要支援部分給分,必須重新設計評分公式。
Day 4 設計的 Assessment.questions 欄位型別是 JSON,當時只打算拿來存「題目內容」。今天出題時,選擇題的 correct_index、簡答題的 reference_answer 也一起塞進同一個 questions 欄位存進資料庫,公開題目和正確答案沒有被分開存放。API 回傳給使用者的題目清單,會在程式邏輯裡把正解欄位過濾掉,不會讓正解直接外流,但資料庫層級沒有做這層區隔,這是今天刻意接受的簡化。如果之後要更嚴謹,可以把正解拆到另一個欄位甚至另一張表,不在今天的範圍內。
一份計畫(Plan)通常橫跨好幾週,weekly_topics 有好幾個不同的主題。如果讓系統自動挑一個主題出題,很容易挑到使用者還沒讀到、或者已經很熟的部分。今天讓 POST /assessments 的請求裡直接帶一個 topic 欄位,由呼叫端(之後會是前端介面)決定「現在要考哪個主題」,比系統自己亂猜準確。
檔案位置: 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。
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 處理模型偶爾漏欄位的情況。
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 的方向完全相反。
_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。
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,才允許針對這份計畫出題。
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 直接算錯,簡答題會拿到空字串一樣算錯,不會因為漏答而讓程式出錯。
先確認 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 分以上則可前進到新內容。