做到 Day 28 的時候,我的專案其實已經有兩套可以獨立運作的東西。
一套是原本的任務管理 Web App:
Flask
↓
MySQL
↓
新增 / 編輯 / 刪除任務
↓
狀態、分類、篩選
另一套則是 Day 25~27 完成的 AI 任務拆解:
groq_api_test.py
↓
Groq API
↓
openai/gpt-oss-20b
↓
Structured Outputs
↓
parse_tasks()
↓
5~8 筆任務
但前面這兩條線一直都是分開的。
我可以在網頁管理任務,也可以在終端機叫 AI 拆解任務,但還不能直接在 Web App 裡使用 AI。
所以 Day 29 的目標很明確:
把 Groq AI 任務拆解正式接進 Flask Web App。
而且不是 AI 一產生內容就直接寫進資料庫,而是:
輸入大型目標
↓
AI 拆解
↓
先預覽
↓
使用者確認
↓
再寫進 MySQL
這也是今天最主要的流程。
最後實作出來的流程是:
首頁
↓
AI 任務拆解
↓
輸入大型目標
↓
Groq API
↓
取得 5~8 筆 title / description
↓
網頁顯示預覽
↓
確認建立
↓
一次寫進 MySQL
↓
回首頁看到任務
而且確認建立時:
不會再呼叫一次 Groq
使用者在預覽頁看到什麼,最後存進資料庫的就是同一批內容。
這點是我今天特別想保留的設計。
前幾天 AI 的所有程式都放在:
groq_api_test.py
但裡面其實混了兩種東西。
一種是真正可以重用的 AI 核心:
SYSTEM_PROMPT
TASKS_SCHEMA
parse_tasks()
Groq client
API 呼叫
錯誤處理
另一種只是終端機測試需要的:
sys.argv
print()
sys.exit()
token 顯示
命令列提示
如果 Flask 直接去:
from groq_api_test import ...
雖然不一定不能跑,但結構會很奇怪。
所以 Day 29 新增了一個:
ai_service.py
現在的分工變成:
ai_service.py
→ AI 共用核心
groq_api_test.py
→ CLI 測試入口
app.py
→ Flask Web App
而且 ai_service.py 不會 import:
Flask
db
Task
Category
它只負責一件事:
大型目標
↓
呼叫 Groq
↓
驗證結果
↓
回傳 tasks
這樣 AI 邏輯和 Web 邏輯就分開了。
Day 27 已經測過的核心內容直接沿用:
BASE_URL
MODEL
SYSTEM_PROMPT
TASKS_SCHEMA
MIN_TASKS
MAX_TASKS
TASK_KEYS
parse_tasks()
Structured Outputs
reasoning_effort="low"
max_completion_tokens=2048
Prompt 沒有重新改。
JSON Schema 沒有改。parse_tasks() 規則也沒有改。
所以 Day 29 做的是「整合」,不是重新調模型。
Day 29 新增了一個獨立頁面:
/ai/breakdown
我沒有把整個 AI 表單直接塞進首頁。
因為首頁本來已經有:
任務列表
狀態篩選
分類篩選
新增任務
再加一大塊 AI 預覽會變得太擠。
所以首頁只增加:
AI 任務拆解
按下後才進 AI 頁面。
AI 頁面則有一個大型目標輸入框。
例如:
我想開始學攝影,希望兩個月後可以自己拍出一組看起來完整的作品。
按下:
產生預覽
之後才會呼叫 Groq。
前端雖然有表單驗證,但我不想只依賴 HTML。
後端還是會先做:
goal = (request.form.get("goal") or "").strip()
所以:
空字串
純空白
缺少 goal
都不會呼叫 Groq。
而是直接顯示:
請輸入大型目標
目前另外設定:
最多 1000 字
前端有:
maxlength=1000
後端也會再檢查一次。
這樣即使有人繞過 HTML,也還是會被後端擋住。
這是今天最重要的設計之一。
如果流程是:
輸入目標
↓
Groq
↓
直接 INSERT
問題是使用者根本還沒看 AI 拆得好不好。
而我前面已經實際看到:
同樣一個大型目標,重新產生後,任務切法可能不一樣。
所以如果沒有預覽,AI 一產生就直接寫進資料庫,使用者根本沒機會選。
因此現在一定先:
AI 拆解
↓
預覽
產生預覽後,畫面會列出:
這個時候:
MySQL 還沒有增加資料
只有按:
確認建立 N 筆任務
才會真的寫入。
這裡是今天一個滿重要的設計問題。
因為 Flask 是:
request
↓
response
↓
request
預覽是一個 request。
確認建立又是下一個 request。
那第二次 request 要怎麼知道剛才 AI 產生了哪些任務?
我最後沒有加入:
session
新資料表
server-side cache
而是直接用 hidden input:
<input type="hidden" name="tasks_json" ...>
概念是:
AI 回傳 tasks
↓
放進預覽頁
↓
同時放進 hidden tasks_json
↓
按確認
↓
tasks_json POST 回 Flask
這個方法很簡單,也不用新增 SECRET_KEY 或暫存資料表。
這點我有特別驗證。
流程是:
POST /ai/breakdown
↓
Groq 呼叫 1 次
↓
產生預覽
POST /ai/breakdown/confirm
↓
Groq 呼叫 0 次
↓
直接寫入剛才那批預覽
所以不會發生:
預覽看到 A
↓
按確認
↓
AI 又重新產生 B
↓
資料庫存 B
使用者看到的,就是最後存進去的。
hidden input 雖然藏在頁面裡,但使用者還是可以開 DevTools 修改。
所以確認建立時,後端不能直接:
收到 tasks_json
↓
直接寫 DB
而是會重新:
parse_tasks()
確認:
是不是 JSON
是不是 {"tasks": [...]}
是不是 5~8 筆
是不是只有 title / description
是不是 string
是不是空字串
全部通過才繼續。
一開始實作時,預覽頁存的是:
[
{
"title": "...",
"description": "..."
}
]
也就是只有 list。
但 parse_tasks() 從 Day 26 開始期待的格式其實是:
{
"tasks": [
{
"title": "...",
"description": "..."
}
]
}
所以原本流程變成:
預覽正常
↓
按確認
↓
parse_tasks(tasks_json)
↓
格式錯
↓
400
也就是:
每次確認都一定失敗。
後來把 hidden input 改成保存完整:
{
"tasks": [...]
}
確認 route 才可以直接重用原本的 parse_tasks()。
這個 Bug 也讓我更確定:
前面先把資料格式固定好,後面整合時真的比較容易抓問題。
AI 目前只負責:
title
description
不讓 AI 決定:
status
category
due_date
所以確認建立時,每一筆都會變成:
title
→ AI 回傳內容
description
→ AI 回傳內容
status
→ todo
category_id
→ None
due_date
→ None
也就是使用者之後在首頁看到的是:
待處理
未分類
未設定截止日期

目前 Task model 的 title 是:
db.String(200)
所以 AI 產生的 title 在真正寫入以前,會再檢查:
是否超過 Task.title.type.length
也就是直接從 model 取得長度。
不是另外硬寫:
200
這樣如果 model 之後改長度,驗證比較不容易忘記一起改。
假設 AI 產生 7 筆任務。
我不希望這樣:
第 1 筆
commit
第 2 筆
commit
第 3 筆
commit
因為如果第 4 筆失敗,前面 3 筆已經進資料庫了。
最後就會變成:
原本 7 筆
↓
只留下 3 筆
所以現在是:
db.session.add_all(...)
db.session.commit()
整批只 commit 一次。
如果過程出錯:
db.session.rollback()
最後就會:
全部成功
或
全部失敗
不會只留下半批。
這是今天一個比較有趣的小細節。
AI 拆出來的任務本來是有順序的。
例如:
1. 規劃內容
2. 設計版面
3. 建立網站
4. 測試網站
我希望首頁看到的也是這個順序。
但目前首頁的排序方式是:
created_at DESC
id DESC
意思是「最新建立的任務排在最上面」。
如果照 1 → 4 的順序寫進資料庫,id 會一路變大:
1 → id 25
2 → id 26
3 → id 27
4 → id 28
首頁又會把 id 比較大的排前面,所以最後顯示會變成:
4
3
2
1
這樣就把 AI 原本的任務順序顛倒了。
所以目前的做法是:
寫入資料庫時先從最後一筆開始:
4
3
2
1
這樣 id 會變成:
4 → id 25
3 → id 26
2 → id 27
1 → id 28
等首頁再用 id DESC 排序後,就會重新顯示成:
1
2
3
4
所以「反向寫入」不是因為 AI 的資料本身要倒過來,而是為了配合首頁「新資料排前面」的排序方式。
雖然同一批 AI 任務的 id 大小會和原本順序相反,但首頁顯示時仍會維持 AI 原本的 1→N 閱讀順序。
AI 正式接進 Web App 後,錯誤處理就不能再只靠終端機 print。
目前會處理像:
沒有 GROQ_API_KEY
401 Authentication
429 Rate Limit
json_validate_failed
其他 API Status Error
連線失敗
逾時
finish_reason=length
空內容
parse_tasks() 驗證失敗
使用者看到的都是比較簡單的中文訊息。
例如:
AI 這次產生的格式有誤,請再產生一次。
而不是:
failed_generation
request_id
完整 API response
更不會顯示 API Key。
Day 27 已經實際遇過:
json_validate_failed
那次是模型生成 JSON 時少了一個冒號。
今天正式接進 Web 後,我最後還是決定:
先不自動 retry
所以現在如果遇到:
json_validate_failed
就顯示:
AI 這次產生的格式有誤,請再產生一次。
讓使用者自己重新按:
重新產生預覽
先把流程維持簡單。
AI API 接進網頁後,如果 API 卡住,使用者就會一直看著頁面等。
所以 client 現在有:
timeout=30
避免單次請求無限制等待。
不過這裡還有一個要注意的地方。
OpenAI SDK 本身仍然可能對:
429
5xx
連線錯誤
做自己的 retry。
所以:
timeout=30
不代表整個使用者等待時間一定只會有 30 秒。
這部分目前沒有另外修改,留作已知限制。
確認建立表單現在有:
onsubmit="this.querySelector('button[type=submit]').disabled = true;"
意思是:
第一次按確認
↓
submit event
↓
按鈕 disabled
↓
送出 POST
所以一般快速連點時,可以降低同一個表單送出兩次的機會。
不過我實際操作時完全沒看到按鈕「變灰」。
後來檢查才發現原因很簡單。
第一個:
沒有 :disabled CSS
所以外觀不一定明顯。
第二個:
本機 Flask + MySQL 太快
按下去後馬上:
POST
↓
commit
↓
302
↓
首頁
根本沒有停留在 disabled 狀態給我看。
所以這段現在只是:
基本前端防連點
不是完整的伺服器端防重複提交。
例如:
兩個分頁同時確認
還是可能建立兩批。
但對目前本機單人專案來說,我先不繼續擴大。
自動測試跑完後,我還用真的 Groq 跑了一次。
輸入:
我要準備期末專題
Groq 產生:
8 筆任務
在預覽階段:
MySQL 筆數沒有增加
按下確認後:
Groq 呼叫次數仍然只有 1
代表 confirm 沒有再叫一次 AI。
然後 8 筆一次寫進 MySQL。
最後回首頁也可以按照:
第 1 → 第 8 筆
的順序看到。
今天測試其實做得滿多。
主要確認:
✓ AI 頁面可以打開
✓ 首頁有 AI 任務拆解入口
✓ 空白目標不呼叫 Groq
✓ 1000 字可以送出
✓ 1001 字會被擋
✓ 預覽時 DB 不新增
✓ confirm 不重新呼叫 Groq
✓ 預覽與寫入內容一致
✓ status = todo
✓ category_id = None
✓ due_date = None
✓ hidden JSON 非法時不寫入
✓ 批次失敗會 rollback
✓ InnoDB transaction 正常
✓ API 錯誤顯示友善訊息
✓ json_validate_failed 不 retry
✓ CLI 仍然可以正常呼叫 Groq
✓ SYSTEM_PROMPT 沒變
✓ TASKS_SCHEMA 沒變
✓ parse_tasks() 規則沒變
✓ 首頁正常
✓ AI 預覽頁正常
✓ MySQL 寫入正常
這次有修改:
ai_service.py
app.py
templates/ai_breakdown.html
templates/index.html
static/css/style.css
groq_api_test.py
其中:
ai_service.py
是新檔案。
沒有修改:
Task model
Category model
schema.sql
config.py
requirements.txt
資料庫 schema 也完全不用加新欄位。
Day 28 以前,其實還是:
Web App ── MySQL
和:
Groq ── AI 任務拆解
兩條分開的線。
Day 29 做完後,第一次變成:
使用者
↓
Flask Web App
↓
Groq AI
↓
Structured Outputs
↓
預覽
↓
使用者確認
↓
MySQL
↓
任務列表
到這裡,這個專案才真的符合:
AI Task Manager
這個名字。
目前功能已經可以用,但還有一些已知限制。
第一個:
預覽頁重新整理
↓
瀏覽器重新送 POST
↓
可能再呼叫一次 Groq
第二個:
timeout=30
但 SDK 自身 retry 可能讓實際等待時間更久。
第三個:
disabled
只有基本前端防連點。
沒有伺服器端 idempotency。
第四個:
json_validate_failed
目前 Web 上只有用假的錯誤物件測過,實際瀏覽器操作時還沒有剛好遇到真的一次。
這些目前都不是阻塞問題,所以先留著。
今天我覺得最重要的其實不是:
成功呼叫 Groq API
因為這件事 Day 25 就已經做到了。
今天真正重要的是:
把 AI 變成 Web App 裡一個完整的使用流程。
API 能呼叫,只代表:
技術上能用
但真正放進產品流程後,還要考慮:
輸入怎麼驗證?
AI 結果要不要先預覽?
確認時要不要重新呼叫?
資料怎麼在兩次 request 之間保存?
寫資料庫失敗怎麼 rollback?
怎麼避免錯誤直接變成 500?
AI 結果怎麼對應現有 Task?
這些才是 Day 29 真正花時間的地方。