iT邦幫忙

2026 iThome 鐵人賽

DAY 22
1
AI Engineering

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

Day 22:複習系統 - 遺忘曲線與間隔重複

  • 分享至 

  • xImage
  •  

Day 21 會為低於 60 分的主題新增複習任務,但仍有兩個問題:同一主題多次考不好會累積多筆任務;複習也固定排在 7 天後,無法反映複習次數與記憶狀況。

今天以間隔重複(Spaced Repetition)取代 Day 21 的做法:答錯後 3 天複習;記得就延長到 7 天,再記得則延長到 14 天;忘記時回到 3 天。剛學的內容需要較短間隔,熟練後可逐步拉長。

為什麼今天要新增一張表,不是繼續用 Task

Day 21 直接在 tasks 表新增「複習:{主題}」任務,屬於暫時做法。日常任務有一次性的 deadline,複習則會反覆進行,間隔也會依結果變動;tasks 表沒有欄位記錄複習輪次與下次間隔。今天新增 review_items 表,分開管理複習與計畫排程。

用三個固定間隔,簡化版的 Leitner Box

真正的間隔重複演算法(例如 Anki 用的 SM-2)會根據使用者過去的表現動態計算精確的間隔天數,公式相對複雜。今天用最簡化的版本:只有三個固定階段,interval_stage 是 0、1、2,分別對應 3 天、7 天、14 天。複習時回報「記得」,interval_stage 往後推進一階(最多停在 14 天這一階,不會無限拉長);回報「忘記」,直接打回 interval_stage=0,重新從 3 天開始。這是 Leitner 卡片盒方法的簡化版,用固定的幾個抽屜取代連續的演算法計算,邏輯簡單很多,也已經足夠展示「間隔重複」這個概念。

一個使用者、一個主題只會有一筆 ReviewItem

Day 21 的問題是每次分數不到 60 分就新增一筆任務。今天改成先查「這個使用者、這個主題,有沒有已經存在的 ReviewItem」,有的話直接更新這一筆的 interval_stage 和 next_review_at,沒有才新增一筆。同一個主題不管考壞幾次,複習隊列裡永遠只會有一筆記錄反映「這個主題目前的複習狀態」,不會愈疊愈多。

今天只做「排程」,複習的實際內容留給呼叫端決定

GET /review-queue/today 今天只回傳「哪些主題到期該複習了」,不會附上題目或內容。要複習什麼、用什麼形式複習(重新考一次小測驗、還是只是提醒使用者自己回去看),今天先不管,讓呼叫端(之後的前端)自己決定,例如可以拿到期的 topic 再呼叫 Day 20 的 POST /assessments 重新出一份小測驗。今天的重點是先把「複習排程會不會隨著回報結果正確調整」這個機制做對。


實作步驟

步驟1:新增 ReviewItem 模型

檔案位置: backend/models.py
狀態: 修改檔案(在檔案最後新增一個新的 class)
用途: 定義複習隊列的資料表,一個使用者一個主題只會有一筆
依賴: sqlalchemy

class ReviewItem(Base):
    """複習隊列項目:一個使用者、一個主題只會有一筆"""
    __tablename__ = "review_items"

    id = Column(Integer, primary_key=True, index=True)
    user_id = Column(Integer, ForeignKey("users.id"))
    topic = Column(String)
    interval_stage = Column(Integer, default=0)  # 0、1、2,對應3、7、14天
    next_review_at = Column(DateTime)
    updated_at = Column(DateTime, default=datetime.utcnow)

步驟2:寫 review_scheduler.py,純邏輯不碰資料庫

檔案位置: backend/review_scheduler.py
狀態: 新增檔案
用途: 定義間隔天數表,以及推進、重置間隔階段的邏輯
依賴: 無

REVIEW_INTERVALS = [3, 7, 14]  # 天數,索引對應interval_stage


def next_stage_after_remembered(current_stage: int) -> int:
    """複習時記得,往後推進一個階段,最多停在最後一階(14天)"""
    return min(current_stage + 1, len(REVIEW_INTERVALS) - 1)


def compute_next_review_days(stage: int) -> int:
    """回傳這個階段對應的間隔天數"""
    return REVIEW_INTERVALS[stage]

這支檔案完全不呼叫模型、不碰資料庫,跟 Day 14 的 scheduler.py 是同一種寫法:純函式、輸入輸出都是單純的數字,好測試也好獨立驗證。

步驟3:先獨立測試 review_scheduler.py

檔案位置: backend/test_review_scheduler.py
狀態: 新增檔案
用途: 驗證間隔推進與重置的邏輯正確
依賴: review_scheduler

from review_scheduler import next_stage_after_remembered, compute_next_review_days

TEST_CASES = [
    (0, 1),  # 第0階記得,推進到第1階
    (1, 2),  # 第1階記得,推進到第2階
    (2, 2),  # 已經是最後一階,記得也不會再往後推進
]


def main() -> None:
    for current_stage, expected_next_stage in TEST_CASES:
        actual = next_stage_after_remembered(current_stage)
        mark = "✓" if actual == expected_next_stage else "✗"
        print(f"{mark} 第{current_stage}階記得 → 預期第{expected_next_stage}階,實際第{actual}階")
        assert actual == expected_next_stage

    for stage, expected_days in zip([0, 1, 2], [3, 7, 14]):
        actual_days = compute_next_review_days(stage)
        print(f"第{stage}階對應 {actual_days} 天")
        assert actual_days == expected_days

    print("\n全部通過")


if __name__ == "__main__":
    main()

執行:

python test_review_scheduler.py

應該看到三個階段推進案例都打勾,接著三個間隔天數都對得上,最後印出「全部通過」。

步驟4:定義 Schema

檔案位置: backend/schemas.py
狀態: 修改檔案(接續 Day 21 的內容,繼續往下加)
用途: 定義複習隊列查詢與回報的請求與回應格式
依賴: pydantic

class ReviewQueueItem(BaseModel):
    """單一複習項目的對外格式"""
    review_id: int
    topic: str
    interval_stage: int
    next_review_at: datetime


class ReviewQueueResponse(BaseModel):
    """GET /review-queue/today 回傳的今日複習清單"""
    items: list[ReviewQueueItem]


class ReviewFeedbackRequest(BaseModel):
    """POST /review/{review_id}/feedback 的請求格式"""
    user_id: int
    remembered: bool


class ReviewFeedbackResponse(BaseModel):
    """POST /review/{review_id}/feedback 的回應格式"""
    review_id: int
    topic: str
    interval_stage: int
    next_review_at: datetime

步驟5:把 Day 21 「插入複習任務」的邏輯,換成今天的 _schedule_review

這一步要修改的是 Day 21 寫的 submit_assessment。這是取代,不是在後面加東西:先把下面這段 Day 21 寫的舊程式碼整段刪掉——

    # 分數不到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),
            )
        )

刪掉之後,原本那個位置換成下面這段新的:

    # 分數不到60分,把這個主題排進複習隊列(重新從第0階開始)
    if score < 60 and assessment.topic:
        plan = db.query(Plan).filter(Plan.id == assessment.plan_id).first()
        _schedule_review(db, plan.user_id, assessment.topic)

submit_assessment 函式其他部分(前面逐題評分、後面的 db.commit() 和回傳值)完全不動,只有這一小段被換掉。

檔案位置: backend/main.py
狀態: 修改檔案,新增一個輔助函式,放在 submit_assessment 前面即可
用途: 查出或建立這個使用者、這個主題的複習項目,重置到第0階
依賴: models, review_scheduler

from models import ReviewItem
from review_scheduler import next_stage_after_remembered, compute_next_review_days
from schemas import (
    ReviewQueueItem,
    ReviewQueueResponse,
    ReviewFeedbackRequest,
    ReviewFeedbackResponse,
)


def _schedule_review(db: Session, user_id: int, topic: str) -> None:
    """把這個主題排進複習隊列:已經有記錄就更新,沒有就新增,一律重置到第0階"""
    review_item = (
        db.query(ReviewItem)
        .filter(ReviewItem.user_id == user_id, ReviewItem.topic == topic)
        .first()
    )
    if review_item is None:
        review_item = ReviewItem(user_id=user_id, topic=topic)
        db.add(review_item)

    review_item.interval_stage = 0
    review_item.next_review_at = datetime.now() + timedelta(
        days=compute_next_review_days(0)
    )
    review_item.updated_at = datetime.now()

步驟6:寫 GET /review-queue/today 與 POST /review/{review_id}/feedback

檔案位置: backend/main.py
狀態: 修改檔案(接續步驟5,繼續往下加)
用途: 查詢今天到期的複習項目,以及提交複習後的回報
依賴: schemas, models, review_scheduler

def _get_owned_review_item(review_id: int, user_id: int, db: Session) -> ReviewItem:
    """取出指定複習項目,並確認是呼叫者本人的"""
    review_item = db.query(ReviewItem).filter(ReviewItem.id == review_id).first()
    if review_item is None:
        raise HTTPException(status_code=404, detail="找不到這筆複習項目")
    if review_item.user_id != user_id:
        raise HTTPException(status_code=403, detail="這不是你的複習項目,無法操作")
    return review_item


@app.get("/review-queue/today", response_model=ReviewQueueResponse)
def get_review_queue_today(
    user_id: int, db: Session = Depends(get_db)
) -> ReviewQueueResponse:
    """查詢今天以前到期、還沒複習的項目,越早到期排越前面"""
    now = datetime.now()
    items = (
        db.query(ReviewItem)
        .filter(ReviewItem.user_id == user_id)
        .filter(ReviewItem.next_review_at <= now)
        .order_by(ReviewItem.next_review_at)
        .all()
    )
    return ReviewQueueResponse(
        items=[
            ReviewQueueItem(
                review_id=item.id,
                topic=item.topic,
                interval_stage=item.interval_stage,
                next_review_at=item.next_review_at,
            )
            for item in items
        ]
    )


@app.post("/review/{review_id}/feedback", response_model=ReviewFeedbackResponse)
def submit_review_feedback(
    review_id: int, payload: ReviewFeedbackRequest, db: Session = Depends(get_db)
) -> ReviewFeedbackResponse:
    """複習後回報記不記得,決定下一次複習排在哪個間隔"""
    review_item = _get_owned_review_item(review_id, payload.user_id, db)

    if payload.remembered:
        review_item.interval_stage = next_stage_after_remembered(review_item.interval_stage)
    else:
        review_item.interval_stage = 0

    days = compute_next_review_days(review_item.interval_stage)
    review_item.next_review_at = datetime.now() + timedelta(days=days)
    review_item.updated_at = datetime.now()

    db.commit()
    db.refresh(review_item)

    return ReviewFeedbackResponse(
        review_id=review_item.id,
        topic=review_item.topic,
        interval_stage=review_item.interval_stage,
        next_review_at=review_item.next_review_at,
    )

步驟7:測試

因為新增了 review_items 表,先刪掉舊的 app.db 重建,重新走一次使用者建立、生成並核准計畫的流程。

啟動後端伺服器:

 uvicorn main:app --reload

1. 觸發一次複習排程:照 Day 20 流程生成一份測驗、故意考低於 60 分讓 POST /assessments/{id}/submit 觸發 _schedule_review。

2. 查今日複習清單(應該是空的):GET /review-queue/today?user_id=1。因為 next_review_at 剛被設成 3 天後,還沒到期,這裡預期回傳 {"items": []},這是正常現象,不是 bug。

3. 手動把到期時間改成今天,才測得到 GET /review-queue/today:跟 Day 17 遇過的情況一樣,用 SQLite Browser 或一段小腳本,把剛剛那筆 review_items 的 next_review_at 改成現在的時間,再呼叫一次 GET /review-queue/today?user_id=1,這次應該看到剛剛那筆,interval_stage 是 0。

4. 回報「記得」:POST /review/{review_id}/feedback

{
  "user_id": 1,
  "remembered": true
}

回應的 interval_stage 應該變成 1,next_review_at 是 7 天後。

5. 再回報一次「記得」:interval_stage 應該變成 2,next_review_at 是 14 天後。

6. 回報「忘記」:

{
  "user_id": 1,
  "remembered": false
}

不管目前在第幾階,interval_stage 應該被打回 0,next_review_at 重新變成 3 天後。


常見問題

GET /review-queue/today 一直是空的

next_review_at 是根據「間隔天數」算出來的未來時間,剛觸發複習排程時預設是 3 天後,不會馬上出現在「今天」的清單裡。如果只是想測試 API 邏輯,需要手動把資料庫裡的 next_review_at 改成今天或更早的時間,這跟 Day 17 Daily Coach 的 deadline 需要手動調整測試是同一種情況。

同一個主題還會像 Day 21 一樣愈疊愈多嗎

不會。_schedule_review 每次都先查「這個使用者、這個主題有沒有已經存在的 ReviewItem」,有就更新那一筆,沒有才新增,同一個 (user_id, topic) 組合永遠只會有一筆記錄。

Day 21 之前插入的那些「複習:{主題}」任務,今天還在計畫裡嗎?

還在。今天沒有回頭清掉 Day 21 已經寫進 tasks 表的舊資料,只是把「以後」分數不到 60 分時要做的事換成新機制。如果想要乾淨的測試環境,直接刪掉 app.db 重建即可,兩套邏輯不會同時因為同一次低分而各自觸發,因為 Day 21 那段程式碼已經被今天的 _schedule_review 取代掉了。

GET /review-queue/today 只回傳主題名稱,使用者要怎麼真正複習?

今天故意不處理這件事。呼叫端(之後的前端)可以自己決定要怎麼呈現,例如拿到期的 topic 直接呼叫 Day 20 的 POST /assessments 重新出一份小測驗,複習完再呼叫今天的 POST /review/{id}/feedback 回報記得或忘記,這兩步之間目前沒有自動串接,需要呼叫端自己接起來。

為什麼「忘記」是直接打回第0階,不是只退後一階就好

這是 Leitner 卡片盒方法的核心精神:一旦忘記,代表這個間隔對使用者來說太長了,與其小幅度退一階,不如直接回到最短的間隔,確保這個主題會被更頻繁地複習,直到使用者真的記牢為止。這也是今天故意選擇簡化版演算法而不是更精細的 SM-2 的原因,簡單的規則比較容易講清楚、也比較容易驗證對錯。


後端到今天為止,四大能力都做完了:對話(/chat)、結構化操作(/plans、/tasks、/progress)、測驗評分(/assessments)、複習排程(/review-queue、/review)。接下來要換一個方向:這些 API 到目前為止都只能透過 Swagger 文件測試,明天要開始蓋前端介面,讓使用者能真的在瀏覽器裡看到今天畫的日曆和複習清單,不用再手動打 API。


上一篇
Day 21:基本能力追蹤 - 讓分數真正影響下一步
下一篇
Day 23:簡化版前端架構 - 三個核心頁面
系列文
30天用 Claude Code + LangGraph 實作個人化 AI 學習教練 共 25 篇
圖片
  熱門推薦
圖片
{{ item.channelVendor }} | {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言