Day 21 會為低於 60 分的主題新增複習任務,但仍有兩個問題:同一主題多次考不好會累積多筆任務;複習也固定排在 7 天後,無法反映複習次數與記憶狀況。
今天以間隔重複(Spaced Repetition)取代 Day 21 的做法:答錯後 3 天複習;記得就延長到 7 天,再記得則延長到 14 天;忘記時回到 3 天。剛學的內容需要較短間隔,熟練後可逐步拉長。
TaskDay 21 直接在 tasks 表新增「複習:{主題}」任務,屬於暫時做法。日常任務有一次性的 deadline,複習則會反覆進行,間隔也會依結果變動;tasks 表沒有欄位記錄複習輪次與下次間隔。今天新增 review_items 表,分開管理複習與計畫排程。
真正的間隔重複演算法(例如 Anki 用的 SM-2)會根據使用者過去的表現動態計算精確的間隔天數,公式相對複雜。今天用最簡化的版本:只有三個固定階段,interval_stage 是 0、1、2,分別對應 3 天、7 天、14 天。複習時回報「記得」,interval_stage 往後推進一階(最多停在 14 天這一階,不會無限拉長);回報「忘記」,直接打回 interval_stage=0,重新從 3 天開始。這是 Leitner 卡片盒方法的簡化版,用固定的幾個抽屜取代連續的演算法計算,邏輯簡單很多,也已經足夠展示「間隔重複」這個概念。
ReviewItemDay 21 的問題是每次分數不到 60 分就新增一筆任務。今天改成先查「這個使用者、這個主題,有沒有已經存在的 ReviewItem」,有的話直接更新這一筆的 interval_stage 和 next_review_at,沒有才新增一筆。同一個主題不管考壞幾次,複習隊列裡永遠只會有一筆記錄反映「這個主題目前的複習狀態」,不會愈疊愈多。
GET /review-queue/today 今天只回傳「哪些主題到期該複習了」,不會附上題目或內容。要複習什麼、用什麼形式複習(重新考一次小測驗、還是只是提醒使用者自己回去看),今天先不管,讓呼叫端(之後的前端)自己決定,例如可以拿到期的 topic 再呼叫 Day 20 的 POST /assessments 重新出一份小測驗。今天的重點是先把「複習排程會不會隨著回報結果正確調整」這個機制做對。
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)
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 是同一種寫法:純函式、輸入輸出都是單純的數字,好測試也好獨立驗證。
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
應該看到三個階段推進案例都打勾,接著三個間隔天數都對得上,最後印出「全部通過」。
檔案位置: 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
_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()
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,
)
因為新增了 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 需要手動調整測試是同一種情況。
不會。_schedule_review 每次都先查「這個使用者、這個主題有沒有已經存在的 ReviewItem」,有就更新那一筆,沒有才新增,同一個 (user_id, topic) 組合永遠只會有一筆記錄。
還在。今天沒有回頭清掉 Day 21 已經寫進 tasks 表的舊資料,只是把「以後」分數不到 60 分時要做的事換成新機制。如果想要乾淨的測試環境,直接刪掉 app.db 重建即可,兩套邏輯不會同時因為同一次低分而各自觸發,因為 Day 21 那段程式碼已經被今天的 _schedule_review 取代掉了。
GET /review-queue/today 只回傳主題名稱,使用者要怎麼真正複習?今天故意不處理這件事。呼叫端(之後的前端)可以自己決定要怎麼呈現,例如拿到期的 topic 直接呼叫 Day 20 的 POST /assessments 重新出一份小測驗,複習完再呼叫今天的 POST /review/{id}/feedback 回報記得或忘記,這兩步之間目前沒有自動串接,需要呼叫端自己接起來。
這是 Leitner 卡片盒方法的核心精神:一旦忘記,代表這個間隔對使用者來說太長了,與其小幅度退一階,不如直接回到最短的間隔,確保這個主題會被更頻繁地複習,直到使用者真的記牢為止。這也是今天故意選擇簡化版演算法而不是更精細的 SM-2 的原因,簡單的規則比較容易講清楚、也比較容易驗證對錯。
後端到今天為止,四大能力都做完了:對話(/chat)、結構化操作(/plans、/tasks、/progress)、測驗評分(/assessments)、複習排程(/review-queue、/review)。接下來要換一個方向:這些 API 到目前為止都只能透過 Swagger 文件測試,明天要開始蓋前端介面,讓使用者能真的在瀏覽器裡看到今天畫的日曆和複習清單,不用再手動打 API。