Day 12 的 plan_node 只是個空殼,聽到計畫意圖,回一句「好的,讓我們開始規劃」就沒了。今天要把這個空殼填滿:讀懂使用者的程度、可用時間、目標,真正生成一份有週數、有主題、有具體任務的學習計畫。
這是整個系統的核心功能,Day 1 的產品構想「AI 幫你排計畫」,今天第一次真正做出來。
Planner 要做的事很單純:輸入使用者的狀況,輸出一份排好的計畫。
輸入需要三樣東西(都是前幾天已經存在資料庫裡的資料):
Profile 表的 level
Profile 表的 available_hours
輸出是一份 4-8 週的計畫,包含:
Day 8 提過三個 Prompt 原則,今天實際套用在生成計畫這件事上:
計畫的結構如果設計成「週 → 每週裡面又包一個任務清單」這種巢狀結構,本機開源模型比較容易漏欄位或格式跑掉。今天故意設計成比較「扁平」的結構:一個週主題清單,加上一個任務清單,任務清單裡每個任務自己標記「這是第幾天」,不特別分組。
LearningPlan
├── title:計畫標題
├── duration_weeks:總共幾週
├── weekly_topics:["第1週主題", "第2週主題", ...]
└── tasks:[
{day: 1, title: "...", estimated_hours: 2},
{day: 2, title: "...", estimated_hours: 1.5},
...
]
這樣的形狀,本機模型比較容易穩定輸出,之後要存進 Day 4 設計的 tasks 表(day、title、estimated_hours)也幾乎是直接對應,不用額外轉換。
檔案位置: backend/schemas.py
狀態: 修改檔案(在 Day 9 的 StudyAdvice 後面加上這段)
用途: 定義 Planner Agent 的輸出格式
依賴: pydantic
class PlanTask(BaseModel):
"""計畫裡的一項具體任務"""
day: int = Field(gt=0, description="這是計畫開始後的第幾天,例如第5天")
title: str = Field(description="任務標題,具體到一個可執行的動作")
estimated_hours: float = Field(gt=0, description="預估花費的時數")
class LearningPlan(BaseModel):
"""Planner Agent 生成的完整學習計畫"""
title: str = Field(description="這份學習計畫的標題")
duration_weeks: int = Field(gt=0, le=8, description="計畫總共幾週,介於4到8週之間")
weekly_topics: list[str] = Field(description="每一週的主題,清單長度要等於duration_weeks")
tasks: list[PlanTask] = Field(description="具體任務清單,涵蓋整個計畫期間")
檔案位置: backend/planner.py
狀態: 新增檔案
用途: Planner Agent,根據使用者狀況與目標生成結構化學習計畫
依賴: langchain-ollama, schemas
from langchain_ollama import ChatOllama
from schemas import LearningPlan
_llm = ChatOllama(model="llama3.1:8b", temperature=0.3)
_structured_llm = _llm.with_structured_output(LearningPlan).with_retry(
stop_after_attempt=3
)
def generate_plan(
level: str, available_hours: float, learning_topic: str, goal_title: str
) -> LearningPlan:
"""根據使用者的程度、可用時間、想學的主題與目標,生成一份結構化學習計畫"""
prompt = (
"你是一位學習教練,請根據以下資訊生成一份學習計畫:\n"
f"- 學習程度:{level}\n"
f"- 每週可用時間:{available_hours} 小時\n"
f"- 想學的主題:{learning_topic}\n"
f"- 目標:{goal_title}\n\n"
"請生成 4 到 8 週的計畫,每週訂一個主題,並拆出具體的每日任務。"
"每週所有任務的預估時數加總,不要超過使用者每週可用時間。"
)
return _structured_llm.invoke(prompt)
檔案位置: backend/test_planner.py
狀態: 新增檔案
用途: 驗證 Planner Agent 能生成格式正確的結構化計畫
依賴: planner
from planner import generate_plan
def main() -> None:
plan = generate_plan(
level="初級",
available_hours=10.0,
learning_topic="AWS Solutions Architect 認證",
goal_title="3個月內通過AWS SA認證考試",
)
print(f"計畫標題:{plan.title}")
print(f"總共 {plan.duration_weeks} 週\n")
print("每週主題:")
for i, topic in enumerate(plan.weekly_topics, start=1):
print(f" 第{i}週:{topic}")
print(f"\n共 {len(plan.tasks)} 個任務,前5個:")
for task in plan.tasks[:5]:
print(f" 第{task.day}天:{task.title}(預估{task.estimated_hours}小時)")
if __name__ == "__main__":
main()
執行:
python test_planner.py
這一步會實際呼叫本機模型,生成一整份計畫需要比之前的測試多花一點時間,不是卡住。應該看到 duration_weeks 介於 4 到 8 之間、weekly_topics 的數量跟 duration_weeks 對得上、tasks 清單裡每一項都有 day、title、estimated_hours。
檔案位置: backend/schemas.py
狀態: 修改檔案(接續步驟1,繼續往下加)
用途: 定義 /plans/generate 端點的請求與回應格式
依賴: pydantic
class PlanGenerateRequest(BaseModel):
"""呼叫 /plans/generate 時,前端要送過來的格式"""
user_id: int
goal_title: str = Field(min_length=1, description="這次的學習目標")
goal_deadline_weeks: int = Field(gt=0, le=12, description="目標期限,幾週後")
class PlanGenerateResponse(BaseModel):
"""/plans/generate 回傳的格式"""
plan_id: int
title: str
duration_weeks: int
weekly_topics: list[str]
task_count: int
這個端點做四件事:確認使用者有學習檔案、建立 Goal、呼叫 Planner 生成計畫、把計畫和任務存進資料庫。
檔案位置: backend/main.py
狀態: 修改檔案(接續 Day 7 的內容,繼續往下加)
用途: 新增生成學習計畫的 API 端點
依賴: fastapi, sqlalchemy, schemas, models, planner
from datetime import datetime, timedelta
from schemas import PlanGenerateRequest, PlanGenerateResponse
from models import Goal, Plan, Task
from planner import generate_plan
@app.post("/plans/generate", response_model=PlanGenerateResponse)
def create_plan(
payload: PlanGenerateRequest, db: Session = Depends(get_db)
) -> PlanGenerateResponse:
"""建立目標,呼叫 Planner 生成計畫,並把計畫與任務存進資料庫"""
profile = db.query(Profile).filter(Profile.user_id == payload.user_id).first()
if profile is None:
raise HTTPException(
status_code=404, detail="這個使用者還沒有學習檔案,請先呼叫 /users/profile 建立"
)
# 建立目標
goal = Goal(
user_id=payload.user_id,
title=payload.goal_title,
deadline=datetime.now() + timedelta(weeks=payload.goal_deadline_weeks),
status="進行中",
)
db.add(goal)
db.flush() # 先取得 goal.id,還沒真的 commit
# 呼叫 Planner 生成結構化計畫
learning_plan = generate_plan(
level=profile.level,
available_hours=profile.available_hours,
learning_topic=profile.learning_topic,
goal_title=payload.goal_title,
)
# 把計畫存進 plans 表,content 欄位存整份 JSON
plan = Plan(
user_id=payload.user_id,
goal_id=goal.id,
title=learning_plan.title,
duration_weeks=learning_plan.duration_weeks,
status="草稿",
content=learning_plan.model_dump(),
)
db.add(plan)
db.flush()
# 把每個任務存進 tasks 表
for task in learning_plan.tasks:
db.add(
Task(
plan_id=plan.id,
day=task.day,
title=task.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(learning_plan.tasks),
)
db.flush() 是關鍵:Goal 和 Plan 剛 add 進去時還沒有 id(要等資料庫真的寫入才會產生),flush() 會先把目前的變更送到資料庫、取得自動產生的 id,但還不會真正 commit,所以如果後面任何一步出錯,整個交易還是可以一起回滾,不會存進一半的髒資料。
進入 http://127.0.0.1:8000/docs,找到 POST /plans/generate,按「Try it out」,輸入(假設 Day 5 的測試用戶 user_id 是 1,且已經完成 Day 7 建立過學習檔案):
{
"user_id": 1,
"goal_title": "3個月內通過AWS Solutions Architect Associate認證",
"goal_deadline_weeks": 12
}
按「Execute」,這次呼叫要跑完整個 Planner 流程,可能需要 10-30 秒。應該收到類似這樣的回應:
{
"plan_id": 1,
"title": "AWS SA 認證準備計畫",
"duration_weeks": 6,
"weekly_topics": ["EC2 與網路基礎", "S3 與儲存服務", "資料庫與 RDS", "安全與 IAM", "架構設計原則", "模擬考複習"],
"task_count": 24
}
再用 GET /plans/count(Day 6 寫的)確認資料庫裡的計畫數量真的增加了。
404 這個使用者還沒有學習檔案先用 Day 7 的 POST /users/profile 幫這個 user_id 建立學習檔案,Planner 需要 level、available_hours、learning_topic 這些資訊才能生成計畫。
duration_weeks 超出 4-8 週,或跟 weekly_topics 數量對不上開源模型偶爾會沒完全遵守 Prompt 裡的限制。Field(gt=0, le=8) 這個驗證條件能擋掉明顯超標的情況(超過 8 週會直接被 Pydantic 拒絕、觸發重試),但如果只是 duration_weeks 跟 weekly_topics 數量對不上(例如 6 週卻只給了 5 個主題),目前的 Schema 還沒有強制檢查這件事。如果實測常常發生,可以在 LearningPlan 加一個 Pydantic 的 model_validator,檢查兩者長度是否一致,不一致就丟出驗證錯誤觸發重試。
生成一整份 4-8 週的計畫,模型要輸出的內容量比之前幾天的範例大很多,本機模型跑起來可能要 10-30 秒甚至更久,視硬體而定,這是正常的,不是卡住。
Prompt 裡已經要求「不要超過每週可用時間」,但這只是提示,不是強制驗證,模型有時候還是會抓不準。這個問題會在 Day 14 用程式邏輯(排程演算法)處理,那天會用非 AI 的方式重新檢查並調整任務分配,確保真的不超時。
/plans/generate 裡,沒有獨立的 Goal API?30 天計畫裡沒有安排獨立的 Goal CRUD 端點,為了先把「生成計畫」這條主線做完整,今天先讓建立目標和生成計畫合併成一個步驟。如果之後想讓使用者能單獨管理多個目標,可以再拆出獨立的 Goal API,不影響現有的資料庫設計。
今天把 Day 12 的空殼 plan_node 概念,做成了真正能用的功能。planner.py 能根據使用者的程度、可用時間、目標,生成一份結構化的學習計畫,POST /plans/generate 把整個流程串起來:建立目標、呼叫 Planner、把計畫和任務存進資料庫。
系統現在是這樣的:
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 ⬜ 簡化的排程邏輯
今天生成的計畫,任務時數有沒有超過使用者可用時間,完全靠 Prompt 拜託模型自己抓。明天要用程式邏輯寫一個排程演算法,重新檢查並調整任務分配,確保計畫是真的排得進使用者的時間裡。