Day 20 結尾提到,assessments 表的 score 與 progress_logs 表的 difficulty_rating 雖然已存入資料庫,但沒有任何邏輯使用它們。測驗完成後,系統目前只會回傳分數。
今天會補上這個流程:測驗完成後,系統判斷主題是「需複習」、「待加強」或「已掌握」。分數低於 60 分的主題會直接加入複習任務,不需要使用者自行安排。
topic 欄位,今天補上Day 20 的 POST /assessments 請求裡有 topic 欄位,用來讓模型知道要針對哪個主題出題,但當時只把它當成生成題目的輸入,沒有把這個值存進資料庫。今天要追蹤「哪個主題掌握度多少」,勢必要知道每一份測驗當初考的是什麼主題,所以要幫 Assessment 補一個欄位:
檔案位置: backend/models.py
狀態: 修改檔案(找到 Day 4 的 Assessment 類別,補上一行)
用途: 記錄這份測驗考的是哪個主題,用來做能力追蹤
依賴: sqlalchemy
class Assessment(Base):
"""測驗"""
__tablename__ = "assessments"
id = Column(Integer, primary_key=True, index=True)
plan_id = Column(Integer, ForeignKey("plans.id"))
title = Column(String)
topic = Column(String, nullable=True) # 這份測驗考的主題,今天新增
questions = Column(JSON)
score = Column(Float)
created_at = Column(DateTime, default=datetime.utcnow)
順便回頭幫 Day 20 的 create_assessment 補上這一行,把呼叫端送來的 topic 存進去:
檔案位置: backend/main.py
狀態: 修改檔案,在 Day 20 create_assessment 裡建立 Assessment 的地方補上一個欄位
用途: 建立測驗時把主題一併存進資料庫
依賴: models
assessment = Assessment(
plan_id=plan.id,
title=generated.title,
topic=payload.topic, # 今天新增:把這次考的主題存下來
questions=[q.model_dump() for q in generated.questions],
)
一個主題如果被考過好幾次,今天的邏輯只拿「最新一次」的分數當作目前的掌握度,不會把所有歷史分數平均起來。原因很直接:使用者能力是會進步的,如果第一次考 40 分、後來複習過又考了 85 分,平均下來會變成 62.5 分,看起來像「待加強」,但使用者實際上已經掌握了,用平均分數會讓進步被舊分數拖累,看不出真實的現況。
topic 當作技能的識別單位,不是 plan_id 也不是 task_id一份 Plan 通常橫跨好幾週、好幾個不同主題(weekly_topics),拿 plan_id 當技能單位太粗,整份計畫只會有一個掌握度,看不出「EC2 學得不錯,但 IAM 還很弱」這種細節。反過來拿 task_id(單一天的任務)當單位又太細,一個主題底下可能有好幾天的任務,沒有必要每個任務都各自追蹤一個掌握度。topic 剛好是這次出題時已經在用的粒度,直接沿用,不用另外設計新的分類方式。
Day 1 PRD 就明確定義了門檻:低於 60% 需要複習,80% 以上可以往新內容前進。今天先把這兩個數字直接寫在判斷邏輯裡,不做成資料庫裡的可調設定值。如果之後想讓使用者自訂「我覺得 70 分才算及格」,需要把這兩個數字抽成 Profile 底下的一個設定欄位,今天先滿足 PRD 定義的固定標準。
POST /assessments/{id}/submit 算完分數後,若低於 60 分,會在同一份計畫新增一個 Task。它的 day 排在目前最後一個任務之後 7 天,標題為「複習:{主題}」。這是讓低分技能自動加入複習的簡化做法。
Day 22 要做的複習系統,會用間隔遞增(3 天、7 天、14 天)的方式安排真正的複習隊列,比今天「固定往後 7 天插一個任務」精緻得多。今天先求「分數真的會影響計畫」這件事成立,把複習排程做得更聰明,是明天的工作。
models.py,補上 topic 欄位如上一節所示,找到 Assessment 類別,加上 topic = Column(String, nullable=True)。
檔案位置: backend/schemas.py
狀態: 修改檔案(接續 Day 20 的內容,繼續往下加)
用途: 定義技能掌握度查詢的回應格式
依賴: pydantic
class SkillStatus(BaseModel):
"""單一主題目前的掌握狀態"""
topic: str
latest_score: float
status: Literal["需複習", "待加強", "已掌握"]
assessed_at: datetime
class SkillTrackingResponse(BaseModel):
"""GET /skills 回傳的整體掌握度清單"""
skills: list[SkillStatus]
create_assessment,把 topic 存進資料庫如「Day 20 漏掉的 topic 欄位」一節所示,在 Day 20 的 create_assessment 裡,建立 Assessment 的地方補上 topic=payload.topic 這一行。
檔案位置: backend/main.py
狀態: 修改檔案(接續 Day 20 的內容,繼續往下加)
用途: 依照分數判斷這個主題目前的掌握狀態
依賴: 無
def _determine_skill_status(score: float) -> str:
"""低於60分需複習,60到80分之間待加強,80分以上算已掌握"""
if score < 60:
return "需複習"
elif score < 80:
return "待加強"
else:
return "已掌握"
GET /skills檔案位置: backend/main.py
狀態: 修改檔案
用途: 依照使用者已完成的測驗,整理出每個主題目前的掌握度
依賴: schemas, models
先把新的 import 加上:
from schemas import SkillStatus, SkillTrackingResponse
@app.get("/skills", response_model=SkillTrackingResponse)
def get_skills(user_id: int, db: Session = Depends(get_db)) -> SkillTrackingResponse:
"""依照使用者已完成的測驗,整理出每個主題目前的掌握度"""
assessments = (
db.query(Assessment)
.join(Plan, Assessment.plan_id == Plan.id)
.filter(Plan.user_id == user_id)
.filter(Assessment.score.isnot(None))
.filter(Assessment.topic.isnot(None))
.order_by(Assessment.created_at.desc())
.all()
)
# 因為已經按時間新到舊排序,同一個topic第一次出現的就是最新一次
latest_by_topic: dict[str, Assessment] = {}
for assessment in assessments:
if assessment.topic not in latest_by_topic:
latest_by_topic[assessment.topic] = assessment
skills = [
SkillStatus(
topic=topic,
latest_score=assessment.score,
status=_determine_skill_status(assessment.score),
assessed_at=assessment.created_at,
)
for topic, assessment in latest_by_topic.items()
]
return SkillTrackingResponse(skills=skills)
Assessment.score.isnot(None) 把「還沒作答、只是生成出來」的測驗擋掉,只有真的提交過、有分數的測驗才會拿來算掌握度。
submit_assessment,分數不到 60 分就插入複習任務檔案位置: backend/main.py
狀態: 修改檔案(整段取代 Day 20 寫好的 submit_assessment,不是接在後面加)
用途: 分數低於60分時,在同一份計畫裡插入一個複習任務
依賴: models
找到 Day 20 那支 submit_assessment,從 @app.post(...) 那一行開始,一路選到函式結尾,整段刪掉,換成下面這個版本:
@app.post("/assessments/{assessment_id}/submit", response_model=SubmitResponse)
def submit_assessment(
assessment_id: int, payload: SubmitRequest, db: Session = Depends(get_db)
) -> SubmitResponse:
"""提交作答,逐題評分,計算總分並存回資料庫,分數不到60分時安排複習任務"""
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
# 分數不到60分,安排一個7天後的複習任務
if score < 60 and assessment.topic:
latest_task = (
db.query(Task)
.filter(Task.plan_id == assessment.plan_id)
.order_by(Task.day.desc())
.first()
)
next_day = (latest_task.day if latest_task else 0) + 7
db.add(
Task(
plan_id=assessment.plan_id,
day=next_day,
title=f"複習:{assessment.topic}",
description=f"上次「{assessment.topic}」測驗只拿到{score:.0f}分,安排複習",
estimated_hours=1.0,
status="待做",
deadline=datetime.now() + timedelta(days=7),
)
)
db.commit()
return SubmitResponse(
assessment_id=assessment.id,
score=score,
correct_count=correct_count,
question_count=len(questions),
detail=details,
)
if score < 60 and assessment.topic 這個判斷順便擋掉了 Day 20 舊資料:如果 app.db 裡本來就有測驗是在今天新增 topic 欄位之前建立的,這些舊測驗的 topic 會是 None,不會因為分數低就硬插入一個「複習:None」的奇怪任務。
因為 models.py 改了 Assessment 的欄位結構,先刪掉舊的 app.db 重建(Day 5 提過的做法),重新走一次使用者建立、生成計畫、核准計畫的流程。
啟動後端伺服器:
uvicorn main:app --reload
1. 生成並提交一份測驗,讓分數低於60分:照 Day 20 的流程呼叫 POST /assessments,主題設定為「EC2 與網路基礎」,接著呼叫 POST /assessments/{id}/submit,故意大部分題目都答錯或不答,讓分數低於 60。
2. 查看掌握度:GET /skills?user_id=1,應該看到:
{
"skills": [
{
"topic": "EC2 與網路基礎",
"latest_score": 40.0,
"status": "需複習",
"assessed_at": "2026-09-29T10:00:00"
}
]
}
3. 確認複習任務真的被排進去:GET /plans/{plan_id}?user_id=1,tasks 清單裡應該多了一筆 title 是「複習:EC2 與網路基礎」的新任務,day 是目前最大 day 再加 7。
4. 再考一次同主題,這次分數拿高分:重複步驟1,但這次全部答對,讓分數 80 分以上。再呼叫一次 GET /skills?user_id=1,應該看到同一個 topic 的 latest_score 更新成新分數、status 變成「已掌握」,而且不會因為這次分數高又多插入一個複習任務(複習任務只在分數不到60分時才會新增)。
models.py 之後,topic 欄位讀不到,或者複習任務沒有被排進去跟 Day 18 遇到的情況一樣,SQLite 沒有遷移工具,assessments 表如果是今天之前建立的,不會自動多出 topic 欄位。先刪掉 app.db 重建,讓新表包含 topic 欄位。
會,這是今天刻意接受的簡化。只要同一個主題連續好幾次都考不到60分,每次 submit 都會各自新增一個複習任務,不會檢查「是不是已經排過同主題的複習了」。Day 22 的複習系統會用更完整的方式管理這件事(收集答錯題目、按遺忘程度排序),今天先讓「低分會觸發複習任務」這個機制成立就好。
今天只在 GET /skills 把狀態標記成「已掌握」,資訊上呈現出來而已,系統目前沒有任何機制會因為某個主題「已掌握」就自動解鎖新內容或改變 Day 13 Planner 生成計畫的方式。要讓掌握度真正影響計畫生成,需要回頭修改 Planner 的 Prompt 或排程邏輯,讓它讀取 /skills 的結果,這是之後可以擴充的方向,不在今天的範圍內。
GET /skills 只看得到「考過測驗」的主題,那沒考過的主題呢沒錯,今天的掌握度完全依賴使用者真的去做過測驗,如果一個主題從來沒被考過,它根本不會出現在 GET /skills 的回應裡,不是「掌握度是0」,而是「還沒有資料可以判斷」,這兩者在今天的設計裡是有差別的。
今天插入複習任務的邏輯很粗糙:固定往後插7天,同一個主題考不好幾次會排出好幾個重複的複習任務,也沒有考慮使用者到底有沒有時間做這些複習。明天要把這件事做得更完整:收集所有答錯、考不好的內容,按照遺忘曲線的概念排出間隔遞增的複習隊列(3天、7天、14天),複習完的反饋還會回頭調整下一次複習的間隔,讓複習排程真正智慧起來。