Day 13 的 Planner 已經能生成包含週數、主題與任務的學習計畫。不過任務時數是否超過使用者每週可用時間,目前仍只靠 Prompt 要求模型留意,沒有程式規則把關。開源模型可能排出每週 15 小時的計畫,但使用者只有 10 小時可用。
今天改用純程式邏輯實作排程演算法。它會接收 Planner 生成的原始任務清單,重新安排日期,確保不超過每週可用時間,並在每週固定加入複習。排程規則明確,用 if/else 與排序處理即可,不需要 LLM 介入。
今天完成後,你會有:
scheduler.py,輸入原始任務清單和每週可用時間,輸出重新排程過的日曆POST /plans/generate 串接排程演算法,存進資料庫的任務時間都經過驗證Planner 生成的任務清單包含 day(第幾天)和 estimated_hours(預估時數),但尚未驗證安排是否可行。今天的排程會做五件事:
day,越早要做的先排這五步都有明確規則,適合寫成函式處理。
Day 4 設計 tasks 表時沒有 difficulty 欄位,Day 13 的 PlanTask Schema 也不要求模型標示難度。因此今天直接以時數作為難度訊號:預估時數短的任務通常較簡單,長的通常較難。用門檻值換算即可,無須調整資料庫或增加模型輸出欄位。
演算法以「本週時數預算」安排任務。每週預算等於使用者的 available_hours 扣除複習固定占用的 1 小時。任務依排序逐一加入;若加入後超過預算,就先插入複習任務,再開啟下一週。所有任務排完後,最後一週也會補上複習。
任務清單(按day排序)
↓
本週還裝得下嗎?
├─ 裝得下 → 加進本週,累計時數
└─ 裝不下 → 插入本週複習 → 開新的一週 → 這個任務排進新一週
↓
(重複直到任務排完)
↓
補上最後一週的複習
檔案位置: backend/scheduler.py
狀態: 新增檔案
用途: 純程式邏輯的排程演算法,不呼叫任何LLM
依賴: dataclasses(Python標準庫)
from dataclasses import dataclass
REVIEW_HOURS = 1.0 # 複習固定佔用的時數,直接從每週可用時間裡扣除
@dataclass
class ScheduledTask:
"""排程後的一項任務"""
day: int
title: str
estimated_hours: float
difficulty: str
is_review: bool = False
def _difficulty(estimated_hours: float) -> str:
"""用預估時數推算任務難度,作為分組依據"""
if estimated_hours <= 1.0:
return "簡單"
if estimated_hours <= 3.0:
return "中等"
return "困難"
def _review_task(week_num: int) -> ScheduledTask:
"""產生固定放在每週最後一天(第7天)的複習任務"""
return ScheduledTask(
day=week_num * 7,
title=f"複習第{week_num}週內容",
estimated_hours=REVIEW_HOURS,
difficulty="簡單",
is_review=True,
)
def reschedule_tasks(
tasks: list[dict], available_hours_per_week: float
) -> list[ScheduledTask]:
"""
重新排程Planner生成的原始任務清單。
tasks: 每項至少要有day、title、estimated_hours三個key(Day13 PlanTask的格式)
available_hours_per_week: 使用者每週可用時間,來自Day7的Profile
回傳:排序後的ScheduledTask清單,每週最後一天固定是複習任務,
且每一週的時數加總都不會超過available_hours_per_week
"""
if available_hours_per_week <= REVIEW_HOURS:
raise ValueError("每週可用時間必須大於複習所需的時數")
budget = available_hours_per_week - REVIEW_HOURS
sorted_tasks = sorted(tasks, key=lambda t: t["day"])
schedule: list[ScheduledTask] = []
week_num = 1
week_hours = 0.0
day_in_week = 1
for task in sorted_tasks:
hours = task["estimated_hours"]
# 這個任務會讓本週超時:先收尾插入複習,再開新的一週
if week_hours + hours > budget:
schedule.append(_review_task(week_num))
week_num += 1
week_hours = 0.0
day_in_week = 1
schedule.append(
ScheduledTask(
day=(week_num - 1) * 7 + day_in_week,
title=task["title"],
estimated_hours=hours,
difficulty=_difficulty(hours),
)
)
week_hours += hours
day_in_week = min(day_in_week + 1, 6) # 第7天固定留給複習
schedule.append(_review_task(week_num)) # 補上最後一週的複習
return schedule
def validate_weekly_hours(
schedule: list[ScheduledTask], available_hours_per_week: float
) -> bool:
"""驗收用:檢查排程後每一週的總時數是否都沒有超過使用者可用時間"""
weekly_totals: dict[int, float] = {}
for task in schedule:
week = (task.day - 1) // 7 + 1
weekly_totals[week] = weekly_totals.get(week, 0.0) + task.estimated_hours
return all(total <= available_hours_per_week for total in weekly_totals.values())
檔案位置: backend/test_scheduler.py
狀態: 新增檔案
用途: 驗證排程演算法確實不會讓任何一週超過可用時間
依賴: scheduler
from scheduler import reschedule_tasks, validate_weekly_hours
# 故意設計成會超時的原始任務清單:11個任務、每個2小時,
# 但使用者每週只有10小時可用,模擬Planner排太滿的情況
raw_tasks = [
{"day": i, "title": f"任務{i}", "estimated_hours": 2.0} for i in range(1, 12)
]
def main() -> None:
available_hours = 10.0
schedule = reschedule_tasks(raw_tasks, available_hours)
print(f"原始任務數:{len(raw_tasks)},排程後任務數(含複習):{len(schedule)}\n")
for task in schedule:
tag = "【複習】" if task.is_review else f"【{task.difficulty}】"
print(f"第{task.day}天 {tag} {task.title}({task.estimated_hours}小時)")
ok = validate_weekly_hours(schedule, available_hours)
print(f"\n每週時數都沒有超過{available_hours}小時:{ok}")
assert ok, "排程驗證失敗,某一週超時了"
if __name__ == "__main__":
main()
執行:
python test_scheduler.py
應該會看到 11 個任務被分成多週,每週滿了就插入複習再換下一週,最後顯示 每週時數都沒有超過10.0小時:True。這支測試不需要 Ollama;相同輸入會得到相同的輸出。
Day 13 的 create_plan 端點原本是「呼叫 Planner → 直接把 learning_plan.tasks 存進資料庫」。今天在存資料庫之前,多插入一步排程。
檔案位置: backend/main.py
狀態: 修改檔案(取代 Day13 create_plan 裡「把每個任務存進tasks表」那段迴圈)
用途: 生成計畫後先排程,再把排程後的任務存進資料庫
依賴: scheduler
from scheduler import reschedule_tasks
# ...create_plan 函式裡,接續Day13呼叫generate_plan之後:
# 呼叫排程演算法,依使用者每週可用時間重新分配任務日期,並插入複習
raw_tasks = [task.model_dump() for task in learning_plan.tasks]
scheduled_tasks = reschedule_tasks(raw_tasks, profile.available_hours)
# 把排程後的任務存進tasks表,複習任務用標題前綴標記,不需要改資料庫欄位
for task in scheduled_tasks:
title = f"【複習】{task.title}" if task.is_review else task.title
db.add(
Task(
plan_id=plan.id,
day=task.day,
title=title,
estimated_hours=task.estimated_hours,
status="待做",
deadline=datetime.now() + timedelta(days=task.day),
)
)
db.commit()
return PlanGenerateResponse(
plan_id=plan.id,
title=plan.title,
duration_weeks=plan.duration_weeks,
weekly_topics=learning_plan.weekly_topics,
task_count=len(scheduled_tasks), # 含複習任務的實際數量
)
task_count 改為計算 scheduled_tasks 的長度。排程後每週多一個複習任務,因此它代表實際存進資料庫的任務數。
先確認 Ollama 本機模型服務已啟動,在 backend 目錄下啟動後端伺服器:
uvicorn main:app --reload
進入 http://127.0.0.1:8000/docs,呼叫 POST /plans/generate,用跟 Day 13 一樣的請求(假設 user_id 是 1,已經完成 Day 7 的學習檔案):
{
"user_id": 1,
"goal_title": "3個月內通過AWS Solutions Architect Associate認證",
"goal_deadline_weeks": 12
}
回應的 task_count 應該比 Day 13 測試時多(多了複習任務)。接著用資料庫工具(Day 5 提過的 SQLite Browser)打開 tasks 表,用 plan_id 篩選這次生成的任務,確認:
day 都落在合理範圍內,沒有一堆任務擠在同一天day 都是 7 的倍數day 落在同一個 7 天區間)裡的 estimated_hours 加起來,不會超過這個使用者的 available_hours
duration_weeks 多這是預期行為。Planner 產生的 duration_weeks 是模型的估計,Prompt 無法強制任務時數塞進指定週數。排程演算法會重新檢查時數;若任務量超出使用者實際可用時間,就把多出的任務延到後續週次,因此總週數可能增加。
今天的複習規則採固定位置,不會依使用者答錯狀況動態調整。Day 22 才會根據遺忘曲線與答錯記錄調整複習間隔。現階段只確保每週結束都有一個複習任務。
ValueError: 每週可用時間必須大於複習所需的時數代表這個使用者的 available_hours 小於等於 1 小時(REVIEW_HOURS 的值),排不進任何複習時段。檢查 Day 7 建立的學習檔案,available_hours 是不是誤填成太小的數字。
例如某個任務 estimated_hours 是 12 小時,但使用者每週只有 10 小時可用,這種任務無論排到哪一週都會讓那一週超時。目前的演算法沒有處理「拆解單一任務」這件事,這種任務會被排入新的一週,但那一週仍然會超時,validate_weekly_hours 會抓到。實務上這種情況通常代表 Planner 生成的單一任務範圍設計得太大,比較合理的處理方式是回頭調整 Day 13 的 Prompt,要求模型把任務拆得更細(例如限制單一任務不超過某個時數),而不是在排程階段硬拆。
difficulty 和 is_review 欄位?今天以時數推算難度,並用標題前綴標示複習,避免調整 Day 4 已定案的資料庫結構。這些資訊只在排程階段使用;存入資料庫後,前端目前只需讀取標題與時間。未來若要以不同樣式呈現複習任務,再為 tasks 表新增欄位即可。
今天完成不呼叫 LLM 的 scheduler.py。它透過排序、分組、時數預算與固定插入複習,驗證 Planner 生成的任務是否能排進使用者的時間。POST /plans/generate 現在會先生成計畫、再排程,最後才存入資料庫。
系統現在是這樣的:
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
計畫現在能生成與排程,但使用者仍無法單獨查詢、修改或刪除計畫,也不能將任務標記為完成、跳過或延期。明天會補齊這些操作,完成 CRUD API。