iT邦幫忙

2026 iThome 鐵人賽

DAY 18
1
AI Engineering

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

Day 18:進度記錄系統 - 讓資料撐起真正的個人化

  • 分享至 

  • xImage
  •  

Day 17 的 Daily Coach 已能診斷任務狀態,但依據只有 Task.status 的完成或待做。任務實際花費時間、主觀難度與卡住的概念都尚未保存。Day 4 已預留 progress_logs 表,但一直沒有 API 寫入資料。

今天把這張表接上 API。使用者完成任務後,可回報實際花費時間、主觀難度、心得筆記與學習證據,例如程式碼連結。Day 21 的能力追蹤會讀取 difficulty_rating,Day 22 的複習系統也會使用這些記錄。

Day 4 漏掉的 evidence 欄位,今天補上

Day 4 設計 progress_logs 表時只有 actual_hours、notes、completed_at、difficulty_rating 四個欄位,但 Day 1 的 PRD 其實提到使用者要能附上「學習證據」,例如寫完一個練習後貼上自己的程式碼連結。這是 Day 4 當時的疏漏,今天在 models.py 補上:

檔案位置: backend/models.py
狀態: 修改檔案(找到 Day 4 的 ProgressLog 類別,補上一行)
用途: 讓進度記錄能附上學習證據
依賴: sqlalchemy

class ProgressLog(Base):
    """進度記錄"""
    __tablename__ = "progress_logs"

    id = Column(Integer, primary_key=True, index=True)
    task_id = Column(Integer, ForeignKey("tasks.id"))
    actual_hours = Column(Float)
    notes = Column(Text)
    completed_at = Column(DateTime)
    difficulty_rating = Column(Integer)
    evidence = Column(String, nullable=True)  # 學習證據,例如程式碼連結,今天新增

    task = relationship("Task", back_populates="progress_logs")

evidence 允許是 None:不是每個任務都有明確的產出物可以附連結,強迫使用者一定要填,只會讓 API 變得難用。

權限檢查抽成 _get_owned_task,Day 15 就預告過這件事

Day 15 寫 PATCH /tasks/{task_id} 時,權限檢查是直接寫在端點裡:查 Task、再透過 task.plan_id 查 Plan、比對 user_id。當時的常見問題已經寫明「如果之後任務相關的端點變多(例如 Day 18 的進度記錄也要做同樣的權限檢查),再回頭把這段邏輯抽出來」。今天就是那個時機:POST /progress/log 需要一模一樣的檢查,於是把它抽成共用函式 _get_owned_task,順便讓 Day 15 的 PATCH /tasks/{task_id} 也改用它。

提交進度記錄,順便把任務標記完成

今天讓 POST /progress/log 同時新增 ProgressLog,並把對應的 Task.status 改成「完成」。回報進度通常表示任務已完成,這樣可避免使用者還要另外呼叫 Day 15 的 PATCH /tasks/{task_id}。

這個設計的代價是:如果使用者只是想「先記一筆心得,但這個任務其實還沒做完」,今天的 API 沒辦法支援,一提交就會被標記完成。這是今天刻意接受的簡化,如果之後要支援這種情境,需要拿掉自動改狀態的邏輯,改成讓呼叫端自己決定要不要額外呼叫 PATCH /tasks/{task_id}。

同一個任務允許多筆進度記錄

POST /progress/log 每次呼叫都是新增一筆 ProgressLog,不是更新舊的那筆。使用者可能事後回來幫同一個任務補寫心得、或者分好幾次記錄同一個任務的不同階段,允許重複寫入是刻意的,GET /progress/history 回傳的是完整清單,不會因為多寫幾筆就搞丟舊資料。


實作步驟

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

如上一節所示,找到 Day 4 定義的 ProgressLog 類別,加上 evidence = Column(String, nullable=True) 這一行。

步驟2:定義 Schema

檔案位置: backend/schemas.py
狀態: 修改檔案(接續 Day 17 的內容,繼續往下加)
用途: 定義進度記錄的請求與回應格式
依賴: pydantic

class ProgressLogCreateRequest(BaseModel):
    """POST /progress/log 的請求格式"""
    user_id: int  # Day27前先用這個做簡化的身分驗證,之後會換成JWT
    task_id: int
    actual_hours: float = Field(gt=0)
    difficulty_rating: int = Field(ge=1, le=5)
    notes: str | None = None
    evidence: str | None = None


class ProgressLogResponse(BaseModel):
    """單筆進度記錄的對外格式"""
    id: int
    task_id: int
    actual_hours: float
    difficulty_rating: int
    notes: str | None
    evidence: str | None
    completed_at: datetime

    class Config:
        from_attributes = True


class ProgressHistoryResponse(BaseModel):
    """GET /progress/history 回傳的歷史清單"""
    logs: list[ProgressLogResponse]

actual_hours 用 Field(gt=0) 擋掉 0 或負數,difficulty_rating 用 ge=1, le=5 直接在 Pydantic 層擋掉超出範圍的評分,不合法的請求會在進到端點邏輯前就被 FastAPI 擋下來,回傳 422。

步驟3:把權限檢查抽成 _get_owned_task

檔案位置: backend/main.py
狀態: 修改檔案,取代 Day 15 PATCH /tasks/{task_id} 裡內聯的權限檢查
用途: 查出任務並確認呼叫者就是這個任務所屬計畫的擁有者
依賴: models, fastapi

def _get_owned_task(task_id: int, user_id: int, db: Session) -> Task:
    """取出指定任務,並確認這個任務所屬的計畫是呼叫者本人的"""
    task = db.query(Task).filter(Task.id == task_id).first()
    if task is None:
        raise HTTPException(status_code=404, detail="找不到這個任務")

    plan = db.query(Plan).filter(Plan.id == task.plan_id).first()
    if plan.user_id != user_id:
        raise HTTPException(status_code=403, detail="這不是你的任務,無法操作")

    return task

把 Day 15 update_task 裡原本手寫的兩段查詢,換成呼叫這個函式:

@app.patch("/tasks/{task_id}", response_model=TaskUpdateResponse)
def update_task(
    task_id: int, payload: TaskUpdateRequest, db: Session = Depends(get_db)
) -> TaskUpdateResponse:
    """標記任務完成、跳過,或把任務延期指定天數"""
    task = _get_owned_task(task_id, payload.user_id, db)

    if payload.action == "完成":
        task.status = "完成"
    elif payload.action == "跳過":
        task.status = "跳過"
    elif payload.action == "延期":
        if payload.postpone_days is None:
            raise HTTPException(status_code=400, detail="延期時要提供postpone_days")
        task.day += payload.postpone_days
        task.deadline += timedelta(days=payload.postpone_days)

    db.commit()
    db.refresh(task)

    return TaskUpdateResponse(task_id=task.id, status=task.status, deadline=task.deadline)

步驟4:寫 POST /progress/log

檔案位置: backend/main.py
狀態: 修改檔案
用途: 提交一筆進度記錄,同時把任務標記完成
依賴: schemas, models

先把新的 import 加上:

from models import ProgressLog
from schemas import ProgressLogCreateRequest, ProgressLogResponse, ProgressHistoryResponse
@app.post("/progress/log", response_model=ProgressLogResponse)
def log_progress(
    payload: ProgressLogCreateRequest, db: Session = Depends(get_db)
) -> ProgressLogResponse:
    """提交任務的進度記錄,同時把對應任務標記為完成"""
    task = _get_owned_task(payload.task_id, payload.user_id, db)

    log = ProgressLog(
        task_id=task.id,
        actual_hours=payload.actual_hours,
        difficulty_rating=payload.difficulty_rating,
        notes=payload.notes,
        evidence=payload.evidence,
        completed_at=datetime.now(),
    )
    db.add(log)

    task.status = "完成"

    db.commit()
    db.refresh(log)

    return log

步驟5:寫 GET /progress/history

檔案位置: backend/main.py
狀態: 修改檔案
用途: 查詢使用者的進度記錄歷史,可選擇只看單一任務
依賴: schemas, models

@app.get("/progress/history", response_model=ProgressHistoryResponse)
def get_progress_history(
    user_id: int, task_id: int | None = None, db: Session = Depends(get_db)
) -> ProgressHistoryResponse:
    """查詢使用者的進度記錄歷史,帶task_id時只回傳單一任務的記錄"""
    query = (
        db.query(ProgressLog)
        .join(Task, ProgressLog.task_id == Task.id)
        .join(Plan, Task.plan_id == Plan.id)
        .filter(Plan.user_id == user_id)
    )
    if task_id is not None:
        query = query.filter(ProgressLog.task_id == task_id)

    logs = query.order_by(ProgressLog.completed_at.desc()).all()
    return ProgressHistoryResponse(logs=logs)

這裡沒有另外寫 _get_owned_task 之類的檢查,因為 GET /progress/history 本來就是「查我自己的記錄」,直接用 Plan.user_id == user_id 當篩選條件,天生就排除了別人的資料,不需要另外查出單一物件再比對。

步驟6:測試

因為 models.py 改了 ProgressLog 的欄位結構,先刪掉舊的 app.db(Day 5 提過 SQLite 是零配置,沒有遷移工具,改欄位最簡單的方式就是刪掉重建),重新跑一次建表腳本,再照 Day 7-17 的流程建立使用者、生成並核准一份計畫。

啟動後端伺服器:

uvicorn main:app --reload

進入 http://127.0.0.1:8000/docs,用已核准計畫底下的某個 task_id(假設是 14,user_id 是 1)依序測試:

1. 提交進度記錄:POST /progress/log

{
  "user_id": 1,
  "task_id": 14,
  "actual_hours": 2.5,
  "difficulty_rating": 3,
  "notes": "EC2實例類型記不太起來,反覆看了兩次文件",
  "evidence": "https://github.com/example/notes/aws-ec2.md"
}

應該回傳這筆記錄,並且 completed_at 有值。

2. 確認任務被標記完成:GET /plans/{plan_id}?user_id=1,檢查 task_id=14 的 status 是不是變成「完成」。

3. 查詢歷史:GET /progress/history?user_id=1,應該看到剛才那筆記錄。

4. 只查單一任務:GET /progress/history?user_id=1&task_id=14,結果應該跟步驟3一樣(因為目前只有這一筆)。

5. 用別人的身分提交(應該失敗):把 user_id 換成 999 重送步驟1的請求,應該回傳 403 這不是你的任務,無法操作。

6. 難度評分超出範圍(應該被擋下):把 difficulty_rating 改成 6 重送,應該回傳 422,不會進到端點邏輯。


常見問題

改了 models.py 之後,evidence 欄位還是查不到

SQLite 沒有內建遷移工具(Day 5 提過),Base.metadata.create_all() 只會建立「還不存在」的表,不會幫已經存在的舊表補欄位。如果你的 app.db 是在今天之前建立的,progress_logs 表裡本來就沒有 evidence 這一欄,程式讀寫時就會出錯。最簡單的解法是今天先刪掉整個 app.db 重建,正式環境要處理欄位變更,需要專門的遷移工具(例如 Alembic),但這不在初學者版本的範圍內。

為什麼提交進度記錄會自動把任務改成「完成」,我只是想先記一筆心得

今天的設計把「回報進度」等同於「這件事做完了」,如果你的情境是任務做到一半、只想先記錄心得,現在的 API 沒辦法區分這兩種情況,一提交就會被標記完成。如果之後要支援「還沒做完但先記一筆」,需要在 ProgressLogCreateRequest 加一個欄位讓呼叫端自己決定要不要連動改狀態,今天先用最簡單的假設把流程走通。

同一個 task_id 提交了兩次 POST /progress/log,會不會互相覆蓋

不會。log_progress 每次呼叫都是 db.add(log) 新增一筆記錄,不是更新舊的那筆,GET /progress/history 會把兩筆都列出來。這是刻意的設計,因為使用者可能想事後補寫心得,或者針對同一個任務記錄好幾次不同階段的心得。

GET /progress/history 沒有分頁,資料量大了怎麼辦

今天先不處理分頁,因為初期使用者的記錄數量不會太多,order_by(ProgressLog.completed_at.desc()) 已經把最新的記錄排在前面。如果之後這支 API 要應付長期使用者累積出的大量記錄,可以加上 limit 和 offset 參數,這不在今天的範圍內。


進度回顧

今天將 Day 4 設計的 progress_logs 表接上 API。使用者可回報實際花費時間、主觀難度、心得與學習證據;POST /progress/log 會同步標記任務完成,GET /progress/history 可查詢完整歷史。權限檢查也抽成共用的 _get_owned_task。

系統現在是這樣的:

Day 1  ✓ 產品定義完成
Day 2  ✓ 開發環境準備
Day 3  ✓ 專案架構設計
Day 4  ✓ 資料庫設計
Day 5  ✓ SQLite 資料庫建置
Day 6  ✓ FastAPI 基礎
Day 7  ✓ 使用者檔案 API
Day 8  ✓ 理解 LLM Agent 的本質
Day 9  ✓ 連接 Ollama 本機模型
Day 10 ✓ LangGraph 最小範例
Day 11 ✓ Thread 與 State 管理
Day 12 ✓ 簡化的意圖路由
Day 13 ✓ 計畫生成 Agent
Day 14 ✓ 簡化的排程邏輯
Day 15 ✓ 計畫 CRUD API
Day 16 ✓ 計畫審核與用戶反饋
Day 17 ✓ Daily Coach
Day 18 ✓ 進度記錄系統(今天)
Day 19 ⬜ Checkpoint 快速入門

difficulty_rating 現已存入資料庫,但尚未被其他邏輯讀取。明天會補齊 LangGraph 對話的 Checkpoint 機制;Day 21 的能力追蹤才會使用這些評分判斷待複習的技能。


上一篇
Day 17:Daily Coach
下一篇
Day 19:把對話圖接上 API - Checkpoint 在真正服務裡也要撐得住
系列文
30天用 Claude Code + LangGraph 實作個人化 AI 學習教練 共 25 篇
圖片
  熱門推薦
圖片
{{ item.channelVendor }} | {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

1 則留言

0
tsengyulun
iT邦新手 5 級 ‧ 2026-10-04 00:44:29

可以教我看論文嗎

我要留言

立即登入留言