iT邦幫忙

2026 iThome 鐵人賽

DAY 16
1

Day 13 到 15 已完成 AI 生成計畫與使用者操作計畫的功能,但計畫生成後會直接生效。Day 15 的 PUT /plans/{plan_id} 甚至允許把 status 改成任何字串,沒有轉換規則。

AI 生成的計畫可能有時數估算或格式問題。計畫從生成到使用者實際執行之間,需要讓使用者查看、停止或要求調整的流程,這就是「人在迴路」(human-in-the-loop):AI 生成草案,使用者負責把關。

今天會用狀態機取代 Day 15 可任意修改狀態的做法。

狀態機長什麼樣子

草稿(draft)
   │
   │ GET /plans/{id}/review(第一次查看,自動轉換)
   ▼
審核中(under_review) ──── POST /plans/{id}/feedback ────┐
   │                                                        │
   │ PUT /plans/{id}/status,action=核准                    │ 重新生成後
   ▼                                                        │ 狀態留在審核中
已核准(approved)                                           │ 等使用者再看一次
                                                             │
審核中(under_review)◄─────────────────────────────────────┘
   │
   │ PUT /plans/{id}/status,action=拒絕
   ▼
已拒絕(rejected)

已核准 和 已拒絕 都是終態,之後不能再變更。反饋只允許在「審核中」提出;重新生成後仍維持「審核中」,等待使用者再次確認。

Day 15 的 PUT /plans/{plan_id} 要收斂

Day 15 的 PlanUpdateRequest 讓呼叫者可以直接把 status 改成任何字串,包含 "已核准",這代表任何人都能繞過審核流程,直接把草稿標成已核准,人在迴路的設計形同虛設。今天把這個端點的職責收斂:只能改標題,狀態改變只交給今天新增的兩個專門端點,各自帶著自己的規則檢查,不再有第三條路可以繞過去。


實作步驟

步驟1:修改 Planner,支援帶著反饋重新生成

檔案位置: backend/planner.py
狀態: 修改檔案(修改 Day13 的 generate_plan 函式簽名與 Prompt)
用途: 讓 Planner 能根據使用者的反饋調整計畫內容
依賴: 無新增依賴

def generate_plan(
    level: str,
    available_hours: float,
    learning_topic: str,
    goal_title: str,
    feedback: str | None = None,
) -> LearningPlan:
    """根據使用者的程度、可用時間、想學的主題與目標,生成一份結構化學習計畫;
    如果有帶反饋,代表這是根據上一版草案調整過的新版本"""
    prompt = (
        "你是一位學習教練,請根據以下資訊生成一份學習計畫:\n"
        f"- 學習程度:{level}\n"
        f"- 每週可用時間:{available_hours} 小時\n"
        f"- 想學的主題:{learning_topic}\n"
        f"- 目標:{goal_title}\n\n"
        "請生成 4 到 8 週的計畫,每週訂一個主題,並拆出具體的每日任務。"
        "每週所有任務的預估時數加總,不要超過使用者每週可用時間。"
    )

    if feedback:
        prompt += f"\n\n使用者對上一版草案的意見:「{feedback}」\n請根據這個意見調整計畫內容。"

    return _structured_llm.invoke(prompt)

feedback 預設是 None,Day 13 原本呼叫 generate_plan(...) 的地方不用修改,維持向下相容。

步驟2:定義新的 Schema

檔案位置: backend/schemas.py
狀態: 修改檔案(接續 Day 15 的內容,繼續往下加)
用途: 定義審核、反饋、狀態變更的請求與回應格式
依賴: pydantic

class PlanReviewResponse(BaseModel):
    """GET /plans/{plan_id}/review 回傳的草案內容"""
    plan_id: int
    title: str
    status: str
    duration_weeks: int
    tasks: list[TaskOut]


class PlanFeedbackRequest(BaseModel):
    """POST /plans/{plan_id}/feedback 的請求格式"""
    user_id: int
    feedback: str = Field(min_length=1, description="對草案的意見,例如覺得太難、時間太少")


class PlanFeedbackResponse(BaseModel):
    """POST /plans/{plan_id}/feedback 的回應格式,回傳重新生成後的新版本"""
    plan_id: int
    status: str
    title: str
    duration_weeks: int
    task_count: int


class PlanStatusUpdateRequest(BaseModel):
    """PUT /plans/{plan_id}/status 的請求格式"""
    user_id: int
    action: Literal["核准", "拒絕"]


class PlanStatusUpdateResponse(BaseModel):
    """PUT /plans/{plan_id}/status 的回應格式"""
    plan_id: int
    status: str

步驟3:收斂 Day 15 的 PUT /plans/{plan_id},拿掉改狀態的能力

檔案位置: backend/schemas.py
狀態: 修改檔案(修改 Day15 的 PlanUpdateRequest)
用途: 移除status欄位,狀態變更改由今天的專門端點負責

class PlanUpdateRequest(BaseModel):
    """PUT /plans/{plan_id} 的請求格式,只能改標題。
    狀態變更改由 GET /plans/{id}/review 和 PUT /plans/{id}/status 負責"""
    user_id: int
    title: str | None = None

檔案位置: backend/main.py
狀態: 修改檔案(修改 Day15 的 update_plan 函式內容)
用途: 拿掉更新status的分支

@app.put("/plans/{plan_id}", response_model=PlanUpdateResponse)
def update_plan(
    plan_id: int, payload: PlanUpdateRequest, db: Session = Depends(get_db)
) -> PlanUpdateResponse:
    """更新計畫標題,狀態不再從這裡改"""
    plan = _get_owned_plan(plan_id, payload.user_id, db)

    if payload.title is not None:
        plan.title = payload.title

    db.commit()
    db.refresh(plan)

    return PlanUpdateResponse(plan_id=plan.id, title=plan.title, status=plan.status)

步驟4:寫 GET /plans/{plan_id}/review

檔案位置: backend/main.py
狀態: 修改檔案
用途: 展示草案內容,第一次查看自動把狀態從草稿轉成審核中
依賴: schemas, models

先把新的 Schema 加進 Day 15 已經有的那段 import,改成:

from schemas import (
    PlanDetailResponse,
    PlanUpdateRequest,
    PlanUpdateResponse,
    TaskUpdateRequest,
    TaskUpdateResponse,
    PlanReviewResponse,
    PlanFeedbackRequest,
    PlanFeedbackResponse,
    PlanStatusUpdateRequest,
    PlanStatusUpdateResponse,
)
@app.get("/plans/{plan_id}/review", response_model=PlanReviewResponse)
def review_plan(
    plan_id: int, user_id: int, db: Session = Depends(get_db)
) -> PlanReviewResponse:
    """展示計畫草案,第一次查看時把狀態從草稿轉成審核中"""
    plan = _get_owned_plan(plan_id, user_id, db)

    if plan.status == "草稿":
        plan.status = "審核中"
        db.commit()
        db.refresh(plan)

    tasks = db.query(Task).filter(Task.plan_id == plan_id).order_by(Task.day).all()

    return PlanReviewResponse(
        plan_id=plan.id,
        title=plan.title,
        status=plan.status,
        duration_weeks=plan.duration_weeks,
        tasks=tasks,
    )

步驟5:寫 POST /plans/{plan_id}/feedback

檔案位置: backend/main.py
狀態: 修改檔案
用途: 帶著使用者反饋,重新呼叫Planner生成新版本,並重新排程
依賴: schemas, models, planner, scheduler

@app.post("/plans/{plan_id}/feedback", response_model=PlanFeedbackResponse)
def submit_feedback(
    plan_id: int, payload: PlanFeedbackRequest, db: Session = Depends(get_db)
) -> PlanFeedbackResponse:
    """根據使用者反饋重新生成計畫,取代舊版本的內容和任務"""
    plan = _get_owned_plan(plan_id, payload.user_id, db)

    if plan.status != "審核中":
        raise HTTPException(
            status_code=400,
            detail=f"計畫目前狀態是「{plan.status}」,要先呼叫 GET /plans/{{id}}/review 進入審核中才能提供反饋",
        )

    profile = db.query(Profile).filter(Profile.user_id == payload.user_id).first()
    goal = db.query(Goal).filter(Goal.id == plan.goal_id).first()

    # 帶著反饋重新生成計畫內容
    learning_plan = generate_plan(
        level=profile.level,
        available_hours=profile.available_hours,
        learning_topic=profile.learning_topic,
        goal_title=goal.title,
        feedback=payload.feedback,
    )

    # 重新排程新版本的任務
    raw_tasks = [task.model_dump() for task in learning_plan.tasks]
    scheduled_tasks = reschedule_tasks(raw_tasks, profile.available_hours)

    # 舊版本的任務整批刪除,換成反饋後重新生成的新任務
    db.query(Task).filter(Task.plan_id == plan_id).delete()

    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),
            )
        )

    plan.title = learning_plan.title
    plan.duration_weeks = learning_plan.duration_weeks
    plan.content = learning_plan.model_dump()
    # 狀態留在審核中:新版本一樣需要使用者再確認一次,不會自動核准
    db.commit()
    db.refresh(plan)

    return PlanFeedbackResponse(
        plan_id=plan.id,
        status=plan.status,
        title=plan.title,
        duration_weeks=plan.duration_weeks,
        task_count=len(scheduled_tasks),
    )

步驟6:寫 PUT /plans/{plan_id}/status

檔案位置: backend/main.py
狀態: 修改檔案
用途: 核准或拒絕計畫,只有審核中的計畫能做這個動作
依賴: schemas, models

@app.put("/plans/{plan_id}/status", response_model=PlanStatusUpdateResponse)
def update_plan_status(
    plan_id: int, payload: PlanStatusUpdateRequest, db: Session = Depends(get_db)
) -> PlanStatusUpdateResponse:
    """核准或拒絕計畫,這是唯一能把計畫轉成終態的入口"""
    plan = _get_owned_plan(plan_id, payload.user_id, db)

    if plan.status != "審核中":
        raise HTTPException(
            status_code=400,
            detail=f"計畫目前狀態是「{plan.status}」,只有「審核中」的計畫能核准或拒絕",
        )

    plan.status = "已核准" if payload.action == "核准" else "已拒絕"
    db.commit()
    db.refresh(plan)

    return PlanStatusUpdateResponse(plan_id=plan.id, status=plan.status)

步驟7:測試

啟動後端伺服器(記得先啟用 venv):

uvicorn main:app --reload

用 Day 13 的 /plans/generate 生成一份新計畫,拿到 plan_id 後照順序測試:

1. 查看草案,確認狀態自動轉成審核中:GET /plans/{plan_id}/review?user_id=1,回應的 status 應該是 "審核中"。

2. 還沒審核就想核准,應該失敗:如果 plan_id 是全新生成、還沒呼叫過 review,直接呼叫 PUT /plans/{plan_id}/status:

{ "user_id": 1, "action": "核准" }

應該收到 400 計畫目前狀態是「草稿」,只有「審核中」的計畫能核准或拒絕。

3. 提交反饋:POST /plans/{plan_id}/feedback

{ "user_id": 1, "feedback": "每週的任務太滿了,我希望多留一點緩衝時間" }

呼叫要跑完整個 Planner + 排程流程,跟 Day 13 一樣可能要等 10-30 秒。回應的 status 應該還是 "審核中",task_count 可能跟第一版不一樣。

4. 正式核准:PUT /plans/{plan_id}/status

{ "user_id": 1, "action": "核准" }

回應的 status 應該變成 "已核准"。

5. 核准後再想改,應該失敗:再呼叫一次 POST /plans/{plan_id}/feedback 或 PUT /plans/{plan_id}/status,都應該收到 400,因為 "已核准" 是終態。


常見問題

400 計畫目前狀態是「草稿」,只有「審核中」的計畫能核准或拒絕

代表你跳過了 GET /plans/{id}/review 這一步。狀態機設計成一定要先「看過草案」(呼叫 review)才能進入審核中,這是刻意的:核准或拒絕之前,系統要確保使用者至少查看過一次內容,不能生成完直接核准。

提交反饋後,之前已經標記完成的任務會不會被清掉?

會。submit_feedback 是把整個 plan_id 底下的任務刪掉重建,所以如果使用者已經開始執行、標記了幾個任務完成(Day 15 的 PATCH /tasks/{id}),反饋一送出這些記錄就會消失。這是今天刻意的限制:反饋只能發生在「審核中」狀態,而審核中代表使用者「還沒開始照著計畫做」,一旦核准開始執行,就不該再透過反饋機制整批重建任務。如果核准後想調整,應該用 Day 15 的單一任務操作(延期、標記跳過),不是回頭修改整份計畫。

為什麼「已拒絕」不會自動重新生成一份新計畫?

拒絕代表使用者對這個目標方向本身不滿意,不只是任務排得不好,這種情況需要使用者重新想清楚要學什麼、目標是什麼,回頭呼叫 POST /plans/generate 開一個新的 Goal 和 Plan,而不是在這個已經被拒絕的 plan_id 上面繼續修修改改。讓「已拒絕」保持乾淨的終態,之後任何統計或列表要濾掉已拒絕的計畫時,邏輯也比較單純。

Day 15 寫的 PUT /plans/{plan_id} 現在功能變少了,會不會浪費掉?

不會,職責分工反而更清楚:PUT /plans/{plan_id} 專心處理「跟狀態機無關」的欄位(目前只有標題,以後如果加其他一般性欄位也放這裡),狀態變更全部收斂到今天這兩個有規則檢查的專門端點。如果兩邊都能改狀態,等於留了一個後門可以繞過審核流程,這是 Day 15 當時還沒設計狀態機、暫時放寬的做法,今天狀態機做出來了,就要把後門補上。


進度回顧

今天加入計畫審核程序:草案生成後先由使用者查看,可提出反饋讓系統重新生成,最後由使用者核准或拒絕。Day 15 可任意修改狀態的問題也已收斂,狀態變更必須經過規則檢查。

系統現在是這樣的:

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 - 每日建議

計畫現在有了完整的生命週期:生成、審核、反饋、核准。但核准之後呢?使用者今天起床,要自己想起來去查計畫、查任務,系統完全是被動的,只會等使用者來問。明天要做 Daily Coach,讓系統主動整理「今天該做什麼」,加上一句激勵訊息,變成使用者早上起床第一個看到的陪伴,不再只是一個被動回答問題的 API。


上一篇
Day 15:計畫 CRUD API - 讓使用者能真正控制 AI 的計畫
系列文
30天用 Claude Code + LangGraph 實作個人化 AI 學習教練 共 16 篇
圖片
  熱門推薦
圖片
{{ item.channelVendor }} | {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

1 則留言

0
tsengyulun
iT邦新手 5 級 ‧ 2026-09-30 22:29:34

有準時發不錯

我要留言

立即登入留言