AI 互動行銷頁實戰 — 目標九宮格 — 第 03 章目錄  

AI GOAL GRID COURSE — 第 03 章

AI 補格:Worker + LLM JSON 輸出

留空的格子交給 AI,自己填的格子一字不動 — prompt 設計與程式驗收的雙保險。

產品立場

null 是授權,有字是主權。
AI 是教練,不是代筆。

學完能做什麼

觀念:這次是程式在讀

架構:key 還是不能放前端

訪客瀏覽器 ──POST /fill──> Cloudflare Worker(GROQ_API_KEY 藏在這)──> Groq API
  GitHub Pages                順便做:每日額度、失敗退款、結果驗收

跟前課模組 6 同一個理由:key 放前端等於把信用卡貼在店門口。差別:這次 Worker 不只代打電話,還驗證輸入、扣額度、驗收輸出 — 一個 Worker,多個職責。

跟 AI 約好只回 JSON

const res = await fetch("https://api.groq.com/openai/v1/chat/completions", {
  method: "POST",
  headers: {
    Authorization: `Bearer ${env.GROQ_API_KEY}`,
    "Content-Type": "application/json",
  },
  body: JSON.stringify({
    model: "openai/gpt-oss-120b",
    messages: [{ role: "user", content: prompt }],
    response_format: { type: "json_object" }, // 跟 AI 約好:只准回 JSON
    max_tokens: 600,
    temperature: 0.8,
  }),
});

response_format: json_object 是 OpenAI 相容 API 的標準參數,強制模型只輸出 JSON。

仍要防禦性解析

export function parseJSONLoose(text) {
  const s = String(text ?? "");
  try { return JSON.parse(s); } catch { /* 繼續 */ }
  const m = s.match(/```json\s*([\s\S]*?)```/i) || s.match(/```\s*([\s\S]*?)```/);
  if (m) {
    try { return JSON.parse(m[1]); } catch { /* 落空 */ }
  }
  return null;
}

模型偶爾會把 JSON 包進 ```json 程式碼框 — 先直接 parse,失敗再去框裡撈一次。

補格 prompt 全文

你是目標設定教練,使用「九宮格目標法」(源自原田メソッド,
即大谷翔平用過的目標達成表的方法)。
使用者的 3×3 九宮格:中央(位置4)是核心目標,周圍 8 格(位置0-3、5-8)是子目標。
${核心目標;留空時改為:請先推斷一個具體可衡量的核心目標}
已填的格子:
${每個已填格一行:位置i:「文字」;全空時寫(其餘全空)}
請補滿所有空格。規則:
1. 子目標要具體、彼此不重複、共同支撐核心目標;涵蓋「心・技・體・生活」四面向。
2. 每格 2-8 個字,繁體中文(台灣用語),不用標點結尾,不用 emoji。
3. 已填的格子原樣保留,一字不改。
只輸出 JSON:{"cells":["位置0文字",...,"位置8文字"]},長度必為 9。

原始碼:worker-deploy/src/lib/prompts.js 的 corePrompt(cells)。

prompt 逐段拆解

Worker 端:先扣後跑、失敗退款、驗收輸出

// 先扣後跑:額度先 -1 再呼叫 AI(防同時送兩發繞過上限)
const quota = await takeQuota(env.DB, { kind: "fill", ip, limit: 12, day });
if (!quota.ok) return json({ error: "quota_exceeded", resetAt: taipeiResetAt() }, 429, cors);

let out = null;
try { out = await chatJSON(env, prompt, 600); } catch { out = null; }

// 驗收:必須是長度 9 的字串陣列,否則退款 + llm_failed
const arr = Array.isArray(out?.cells) ? out.cells : null;
if (!arr || arr.length !== 9 || arr.some((x) => typeof x !== "string" || !x.trim())) {
  await refundQuota(env.DB, { kind: "fill", ip, day });  // 確定失敗 → 退還額度
  return json({ error: "llm_failed" }, 502, cors);
}
const cells = arr.map((x) => x.trim());
normCells.forEach((c, i) => { if (c) cells[i] = c; });  // 已填格被 AI 改了?原值蓋回去

三件事值得停下來看

模型選型:一個真實的雷

常見坑

重點回顧

下一章 04 — 圖片上傳與前端縮圖:FileReader / canvas / base64