iT邦幫忙

2026 iThome 鐵人賽

DAY 17
0

Day 16 已完成計畫的生成、審核、反饋與核准流程。不過核准後,使用者仍要自行查詢 GET /plans/{id},再判斷今天該做哪個任務。

今天要做 Daily Coach:GET /coach/today。使用者早上呼叫這支 API,就能取得今日任務、激勵訊息,以及昨天是否有未完成任務的診斷。

Daily Coach 只服務「已核准」的計畫

Day 16 的狀態機中,只有 已核准 表示使用者決定執行這份計畫。草稿 和 審核中 仍在評估階段,已拒絕 則不再使用。Daily Coach 只從已核准計畫中找任務,其他狀態不會出現在今日建議。

用 deadline 判斷「今天」,不是用 day

Task.day 是相對計畫開始算的第幾天(Day 13、14 一直在用的欄位),Task.deadline 才是建立當下算出來的實際日期時間(Day 13 deadline=datetime.now() + timedelta(days=task.day))。要回答「今天該做什麼」,得比對真正的日曆日期,只有 deadline 能拿來跟「今天」比較,day 只是計畫內部的相對順序,不能直接當日曆用。

三個職責,一次呼叫全部給

  • 今日任務:查 deadline 落在今天的所有任務
  • 激勵訊息:拿今天的任務清單和目標,請本機模型生成一句簡短的鼓勵
  • 診斷卡點:查昨天 deadline 落在昨天、但狀態還是「待做」的任務,提醒使用者昨天有東西沒做完

三項資訊會放在同一個回應中,使用者一次呼叫即可取得。


實作步驟

步驟1:寫 daily_coach.py

檔案位置: backend/daily_coach.py
狀態: 新增檔案
用途: 生成激勵訊息、診斷昨天的完成狀況
依賴: langchain-ollama

from langchain_ollama import ChatOllama

_llm = ChatOllama(model="llama3.1:8b", temperature=0.7)


def generate_motivation(task_titles: list[str], goal_title: str) -> str:
    """根據今天要做的任務和目標,生成一句簡短的激勵訊息"""
    if not task_titles:
        prompt = (
            "你是一位學習教練,使用者今天沒有排定的任務,"
            "請給一句簡短、正向的訊息(不超過50字),鼓勵使用者休息或提前預習。"
        )
    else:
        tasks_text = "、".join(task_titles)
        prompt = (
            "你是一位學習教練,請給使用者一句簡短、有活力的激勵訊息(不超過50字),"
            f"對象正在朝著「{goal_title}」努力,今天要做的任務是:{tasks_text}。"
        )

    response = _llm.invoke(prompt)
    return response.content.strip()


def diagnose_yesterday(unfinished_tasks: list) -> str | None:
    """檢查昨天的任務有沒有完成,沒完成就給一句診斷訊息;昨天全部完成或沒有任務則回傳None"""
    if not unfinished_tasks:
        return None

    titles = "、".join(task.title for task in unfinished_tasks)
    return f"昨天有 {len(unfinished_tasks)} 個任務還沒完成({titles}),要不要今天先補上?"

generate_motivation 直接回傳純文字,沒有像 Day 13 的 Planner 那樣用 Pydantic 定義結構化輸出。一句激勵訊息只是一段話,不需要固定欄位,強迫模型輸出結構化格式反而多一層可能失敗、觸發重試的風險,這裡故意保持簡單。

步驟2:定義 Schema

檔案位置: backend/schemas.py
狀態: 修改檔案(接續 Day 16 的內容,繼續往下加)
用途: 定義 /coach/today 的回應格式
依賴: pydantic

class CoachTodayResponse(BaseModel):
    """GET /coach/today 回傳的每日建議"""
    date: str
    tasks: list[TaskOut]
    motivation: str
    diagnosis: str | None = None

步驟3:寫 GET /coach/today

檔案位置: backend/main.py
狀態: 修改檔案(接續 Day 16 的內容,繼續往下加)
用途: 整理今天的任務、激勵訊息、昨天的診斷
依賴: schemas, models, daily_coach, sqlalchemy

先把新的 import 加上:

from sqlalchemy import func
from daily_coach import generate_motivation, diagnose_yesterday
from schemas import CoachTodayResponse
@app.get("/coach/today", response_model=CoachTodayResponse)
def coach_today(user_id: int, db: Session = Depends(get_db)) -> CoachTodayResponse:
    """回傳今天該做的任務、一句激勵訊息,以及昨天沒完成任務的診斷"""
    approved_plans = (
        db.query(Plan).filter(Plan.user_id == user_id, Plan.status == "已核准").all()
    )
    if not approved_plans:
        raise HTTPException(status_code=404, detail="這個使用者還沒有已核准的計畫")

    plan_ids = [plan.id for plan in approved_plans]
    today = datetime.now().date()
    yesterday = today - timedelta(days=1)

    today_tasks = (
        db.query(Task)
        .filter(Task.plan_id.in_(plan_ids))
        .filter(func.date(Task.deadline) == today)
        .order_by(Task.day)
        .all()
    )

    yesterday_unfinished = (
        db.query(Task)
        .filter(Task.plan_id.in_(plan_ids))
        .filter(func.date(Task.deadline) == yesterday)
        .filter(Task.status == "待做")
        .all()
    )

    # 多個已核准計畫時,先簡化成只拿第一個計畫的目標當激勵訊息的情境
    goal = db.query(Goal).filter(Goal.id == approved_plans[0].goal_id).first()

    motivation = generate_motivation(
        task_titles=[task.title for task in today_tasks],
        goal_title=goal.title if goal else "你的學習目標",
    )
    diagnosis = diagnose_yesterday(yesterday_unfinished)

    return CoachTodayResponse(
        date=today.isoformat(),
        tasks=today_tasks,
        motivation=motivation,
        diagnosis=diagnosis,
    )

func.date(Task.deadline) == today 是關鍵:Task.deadline 存的是完整的日期時間,直接拿來跟一個「日期」比較不會相等,要用 func.date() 把它轉成只剩日期的形式,才能跟 today(一個 date 物件)比對。

步驟4:先獨立測試 daily_coach.py,不透過 API

檔案位置: backend/test_daily_coach.py
狀態: 新增檔案
用途: 驗證激勵訊息生成與診斷邏輯,不需要先建立資料庫資料
依賴: daily_coach

from dataclasses import dataclass
from daily_coach import generate_motivation, diagnose_yesterday


@dataclass
class FakeTask:
    """診斷邏輯只需要title這個欄位,用一個假物件模擬Task,不用先建資料庫資料"""
    title: str


def main() -> None:
    motivation = generate_motivation(
        task_titles=["AWS 服務概覽", "AWS 安全性"],
        goal_title="3個月內通過AWS SA認證",
    )
    print(f"有任務時的激勵訊息:{motivation}")

    empty_motivation = generate_motivation(task_titles=[], goal_title="3個月內通過AWS SA認證")
    print(f"沒有任務時的激勵訊息:{empty_motivation}")

    diagnosis = diagnose_yesterday([FakeTask(title="AWS 服務概覽")])
    print(f"有未完成任務時的診斷:{diagnosis}")
    assert diagnosis is not None

    no_diagnosis = diagnose_yesterday([])
    print(f"沒有未完成任務時的診斷:{no_diagnosis}")
    assert no_diagnosis is None


if __name__ == "__main__":
    main()

執行:

python test_daily_coach.py

應該看到兩句不同情境的激勵訊息(有任務 vs 沒任務),還有診斷邏輯在有/沒有未完成任務時分別回傳訊息和 None。

步驟5:測試 API

先確認 Ollama 本機模型服務已啟動,啟動後端伺服器:

uvicorn main:app --reload

用 Day 16 已經核准的 plan_id(假設 user_id 是 1),呼叫 GET /coach/today?user_id=1。因為 Day 13 生成任務時 deadline 是從生成當下往後算,如果你是今天才第一次跑完 Day 13-16 的流程,day=1 的任務 deadline 差不多就是今天,應該能看到至少一筆任務出現在回應裡。

如果目前沒有任何任務的 deadline 剛好是今天,tasks 會是空陣列,motivation 會是「沒有任務時」的鼓勵文字,這是正常的,不是 bug,驗證方式可以先手動把資料庫裡某筆任務的 deadline 改成今天的日期,再測一次。


常見問題

404 這個使用者還沒有已核准的計畫

檢查這個 user_id 底下的計畫,狀態是不是還停在「草稿」或「審核中」。Daily Coach 只服務已核准的計畫,這是 Day 16 狀態機的規則,先照 Day 16 的流程走完 GET /plans/{id}/review → (可選)POST /plans/{id}/feedback → PUT /plans/{id}/status 核准,才會有資料可以顯示。

明明有任務,但 today_tasks 是空的

Task.deadline 是在 Day 13 生成計畫「當下」算出來的絕對時間(datetime.now() + timedelta(days=task.day)),如果核准的動作是在生成好幾天後才做(例如今天先生成,過三天才點核准),任務的 deadline 早就已經算好了,不會因為核准時間往後延。這是目前系統的簡化:deadline 只在生成的當下決定一次,之後不會跟著核准時間重新校準。如果你的測試流程是「生成」和「核准」隔了好幾天才做,可以先手動修改資料庫裡任務的 deadline 來測試,正式的日期校準機制不在今天的範圍內,未來若要處理,可以在 PUT /plans/{id}/status 核准的當下,重新用核准時間加回 day 重算所有任務的 deadline。

兩次呼叫 /coach/today,motivation 完全不一樣,這是正常的嗎?

正常。daily_coach.py 用 temperature=0.7(比 Day 13 Planner 的 0.3 高),代表同樣的輸入,每次生成的文字都會有變化,這是刻意的:激勵訊息如果每次都一模一樣,聽起來就會很像罐頭訊息,故意讓它有一點隨機性,但任務清單和診斷邏輯是根據資料庫查詢結果決定的,不會因為呼叫兩次就改變。

使用者同時有多個已核准的計畫,goal_title 只拿第一個,會不會漏掉別的目標?

會,這是今天刻意的簡化。如果一個使用者同時在準備兩個不同的目標(例如 AWS 認證加上學 Python),今天的邏輯只把第一個已核准計畫的目標塞進激勵訊息的 Prompt 裡,其他目標的任務雖然還是會出現在 tasks 清單裡,但不會被提到。如果之後要讓激勵訊息涵蓋所有目標,可以把 approved_plans 全部的目標標題都列進 Prompt,今天先聚焦在讓 Daily Coach 的基本框架跑起來。


進度回顧

今天完成每日建議功能。daily_coach.py 會生成激勵訊息並判斷昨天是否有未完成任務;GET /coach/today 一次回傳今日任務、鼓勵與診斷,且只服務 Day 16 狀態機中已核准的計畫。

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 ⬜ 進度記錄系統

Daily Coach 現在能診斷「昨天有沒有落下任務」,但診斷的依據只有 Task.status,使用者實際花了多少時間、覺得任務難不難、有沒有留下心得,這些更細緻的資訊完全沒有地方存。明天要做進度記錄系統,讓使用者能針對每個任務回報實際花費時間、主觀難度評分和心得筆記,這些資料之後會回頭影響 Daily Coach 的診斷,也會是 Day 21 能力追蹤的基礎。


上一篇
Day 16:計畫審核與用戶反饋
系列文
30天用 Claude Code + LangGraph 實作個人化 AI 學習教練 共 17 篇
圖片
  熱門推薦
圖片
{{ item.channelVendor }} | {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言