Day 17 的 Daily Coach 已能診斷任務狀態,但依據只有 Task.status 的完成或待做。任務實際花費時間、主觀難度與卡住的概念都尚未保存。Day 4 已預留 progress_logs 表,但一直沒有 API 寫入資料。
今天把這張表接上 API。使用者完成任務後,可回報實際花費時間、主觀難度、心得筆記與學習證據,例如程式碼連結。Day 21 的能力追蹤會讀取 difficulty_rating,Day 22 的複習系統也會使用這些記錄。
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 回傳的是完整清單,不會因為多寫幾筆就搞丟舊資料。
models.py,補上 evidence 欄位如上一節所示,找到 Day 4 定義的 ProgressLog 類別,加上 evidence = Column(String, nullable=True) 這一行。
檔案位置: 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。
_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)
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
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 當篩選條件,天生就排除了別人的資料,不需要另外查出單一物件再比對。
因為 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 的能力追蹤才會使用這些評分判斷待複習的技能。