iT邦幫忙

2026 iThome 鐵人賽

DAY 21
1
AI Engineering

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

Day 21:基本能力追蹤 - 讓分數真正影響下一步

  • 分享至 

  • xImage
  •  

Day 20 結尾提到,assessments 表的 score 與 progress_logs 表的 difficulty_rating 雖然已存入資料庫,但沒有任何邏輯使用它們。測驗完成後,系統目前只會回傳分數。

今天會補上這個流程:測驗完成後,系統判斷主題是「需複習」、「待加強」或「已掌握」。分數低於 60 分的主題會直接加入複習任務,不需要使用者自行安排。

Day 20 漏掉的 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 剛好是這次出題時已經在用的粒度,直接沿用,不用另外設計新的分類方式。

60 分、80 分兩個門檻先寫死在程式碼裡

Day 1 PRD 就明確定義了門檻:低於 60% 需要複習,80% 以上可以往新內容前進。今天先把這兩個數字直接寫在判斷邏輯裡,不做成資料庫裡的可調設定值。如果之後想讓使用者自訂「我覺得 70 分才算及格」,需要把這兩個數字抽成 Profile 底下的一個設定欄位,今天先滿足 PRD 定義的固定標準。

低於 60 分,直接在計畫裡插入一個複習任務

POST /assessments/{id}/submit 算完分數後,若低於 60 分,會在同一份計畫新增一個 Task。它的 day 排在目前最後一個任務之後 7 天,標題為「複習:{主題}」。這是讓低分技能自動加入複習的簡化做法。

Day 22 要做的複習系統,會用間隔遞增(3 天、7 天、14 天)的方式安排真正的複習隊列,比今天「固定往後 7 天插一個任務」精緻得多。今天先求「分數真的會影響計畫」這件事成立,把複習排程做得更聰明,是明天的工作。


實作步驟

步驟1:修改 models.py,補上 topic 欄位

如上一節所示,找到 Assessment 類別,加上 topic = Column(String, nullable=True)。

步驟2:定義 Schema

檔案位置: 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]

步驟3:修改 create_assessment,把 topic 存進資料庫

如「Day 20 漏掉的 topic 欄位」一節所示,在 Day 20 的 create_assessment 裡,建立 Assessment 的地方補上 topic=payload.topic 這一行。

步驟4:寫判斷掌握狀態的小函式

檔案位置: 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 "已掌握"

步驟5:寫 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) 把「還沒作答、只是生成出來」的測驗擋掉,只有真的提交過、有分數的測驗才會拿來算掌握度。

步驟6:修改 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」的奇怪任務。

步驟7:測試

因為 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 的複習系統會用更完整的方式管理這件事(收集答錯題目、按遺忘程度排序),今天先讓「低分會觸發複習任務」這個機制成立就好。

分數80分以上代表「可以學新內容」,這件事系統實際上做了什麼

今天只在 GET /skills 把狀態標記成「已掌握」,資訊上呈現出來而已,系統目前沒有任何機制會因為某個主題「已掌握」就自動解鎖新內容或改變 Day 13 Planner 生成計畫的方式。要讓掌握度真正影響計畫生成,需要回頭修改 Planner 的 Prompt 或排程邏輯,讓它讀取 /skills 的結果,這是之後可以擴充的方向,不在今天的範圍內。

GET /skills 只看得到「考過測驗」的主題,那沒考過的主題呢

沒錯,今天的掌握度完全依賴使用者真的去做過測驗,如果一個主題從來沒被考過,它根本不會出現在 GET /skills 的回應裡,不是「掌握度是0」,而是「還沒有資料可以判斷」,這兩者在今天的設計裡是有差別的。


今天插入複習任務的邏輯很粗糙:固定往後插7天,同一個主題考不好幾次會排出好幾個重複的複習任務,也沒有考慮使用者到底有沒有時間做這些複習。明天要把這件事做得更完整:收集所有答錯、考不好的內容,按照遺忘曲線的概念排出間隔遞增的複習隊列(3天、7天、14天),複習完的反饋還會回頭調整下一次複習的間隔,讓複習排程真正智慧起來。


上一篇
Day 20:簡化版測驗系統 - 用比對話更明確的方式驗收學習成效
下一篇
Day 22:複習系統 - 遺忘曲線與間隔重複
系列文
30天用 Claude Code + LangGraph 實作個人化 AI 學習教練 共 25 篇
圖片
  熱門推薦
圖片
{{ item.channelVendor }} | {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言