merge: 免金鑰預設路的卡形狀——格式契約收歸 daemon 一份(Arcrun#134)

病徵:portal.ts 的 /portal/daemon/extract 產舊四段格式,而那是免金鑰預設路
=多數用戶實際走的那條 ⇒ daemon 修好了、雲端沒修 ⇒ 對多數用戶等於沒修。

修法比要求的更好:不是把 prompt 複製兩份再靠註解同步,而是
**讓 daemon 擁有格式契約(自帶 prompt),雲端端點退化成「用你自己的 AI 跑一段生成」
的執行器,它不認得也不需要認得卡的形狀**。
⇒ 兩份契約合成一份——與 v7「兩套機制合成一套」同一個手法。

它自己寫下的理由正是今天的主題:
「靠註解叮嚀『兩邊要一起改』同步⋯⋯本端點沒跟上 ⇒ 叮嚀式同步實證失效,這張票就是證據」

總管審過的:
- 舊 daemon(不帶 prompt)走 legacy 路,行為與回應完全不變;legacy 提示詞刻意凍結
  並註明「要改卡的形狀,去 collector/ 改那唯一一份」
- prompt 模式放寬 max_tokens 到 8192,理由寫在碼上(2048 會把 JSON 攔腰截斷)
- 🔴 測試對照:分支 14 failed/447 passed(461 支)vs main 14 failed/445 passed(459 支)
  ⇒ 同樣那 14 個是 main 上的既有紅(Arcrun#131),本分支零新增紅,且多的 2 支都過

⚠️ 併 main ≠ 送達:要部署 cypher worker,用戶才會走到新的那條。

📍 InkStoneCo#44 第 ④ 環|Arcrun#134

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
uncle6me-web
2026-08-15 22:31:04 +08:00
2 changed files with 58 additions and 13 deletions
+40 -13
View File
@@ -1309,7 +1309,8 @@ function toPublicLibrary(rec: PortalRecord) {
// 小幫手回報它看守的資料夾各自對應的庫,雲端**自動登記**——庫目錄與地端資料夾一比一。
// 認證=同 /portal/daemon/config(用戶帳密)。已存在的庫略過(冪等),不覆寫顯示名。
// POST /portal/daemon/extract — 小幫手把「已轉成純文字的原稿」送上來,雲端用 Workers AI 萃成知識卡。
// body {email, password, page_name, text}。認證同 /portal/daemon/config(帳密)。
// body {page_name, text}(舊 daemon)或 {page_name, text, prompt}Arcrun#134 起:
// daemon 自帶提示詞,回應改回 {success, output} 原文)。認證=X-Arcrun-API-Key(見下)。
//
// 🔴 t181leo 08-04:「daemon 的 AI 改用 workers AI」,列為**最優先**——
// 「這是我的用戶最大障礙,造成首輪測試用戶的好評或惡評」):
@@ -1348,41 +1349,67 @@ portalRouter.post('/portal/daemon/extract', (c) =>
if (!apiKey) return c.json({ error: '缺少 X-Arcrun-API-Key header' }, 401);
const body = (await c.req.json().catch(() => null)) as
| { page_name?: string; text?: string }
| { page_name?: string; text?: string; prompt?: string }
| null;
const pageName = String(body?.page_name ?? '').trim();
const srcText = String(body?.text ?? '');
if (!pageName || !srcText.trim()) return c.json({ error: 'page_name 與 text 必填' }, 400);
// 🔴 Arcrun#1342026-08-15):新版 daemon **自帶完整提示詞**`prompt` 欄位)。
//
// 為什麼:卡片格式契約原本存在兩份——daemon 端 gemmaPromptGo)與本端點(TS),
// 靠註解叮嚀「兩邊要一起改」同步。2026-08-15 daemon 端改成「LLM 只回 JSON 判斷、
// wikishape.go 機械組卡」(InkStoneCo#44 ④),本端點沒跟上 ⇒ 免金鑰預設路
// (=多數用戶實際走的路)繼續產舊格式卡——**叮嚀式同步實證失效,這張票就是證據**。
//
// ⇒ 格式契約唯一真相源改為 daemon(提示詞+JSON 解析+組卡同住 collector/
// 不可能再彼此漂移);本端點在 prompt 模式下只是「用實例自己的 env.AI 跑一段
// 生成」的執行器,**不認得也不需要認得卡的形狀**——與 cypher TS「只做 routing
// +提供能力、不長業務邏輯」的分工一致。
// 安全面不變:認證同上(租戶識別 key)、只生成、不寫資料、用戶自己的 AI 額度;
// `text` 本來就是任意內容,本欄位沒有放大任何攻擊面。
// 相容:舊 daemon 不帶 prompt ⇒ 走下方 legacy 路(行為與回應完全不變)。
const daemonPrompt = String(body?.prompt ?? '').trim();
if (!daemonPrompt && (!pageName || !srcText.trim()))
return c.json({ error: 'page_name 與 text 必填' }, 400);
if (!c.env.AI) {
// 誠實失敗:不假裝成功,並指名這個部署缺什麼(禁假綠)
return c.json({ error: '這個部署沒有綁定 Workers AIwrangler.toml 需有 [ai] binding),請更新知識庫版本' }, 501);
}
// 提示詞與 daemon 端 gemmaPrompt 同一份契約(第一行必須是「# <頁名>」),
// 兩邊要一起改;daemon 端在 collector/extract_gemma.go。
// legacy 提示詞:只服務**還沒更新的舊 daemon**(無 prompt 欄位)。舊 daemon 期待
// 「# <頁名>」開頭的 markdown 卡(收端 lint 新舊雙軌仍接受);新格式不再從這裡出生,
// 所以本段刻意凍結、不再演進——要改卡的形狀,去 daemon 端 collector/ 改那唯一一份。
// 註:關聯段用的是「知識卡三元組」格式(主詞/謂詞/受詞),與 Arcrun 工作流的邊無關。
const REL = '>'.repeat(2);
const prompt =
daemonPrompt ||
`把以下原稿重寫成定稿知識卡(正體中文)。直接輸出卡片本身:第一行必須是「# ${pageName}」,` +
`不要任何前言、思考過程、英文草稿或說明。格式:\n# ${pageName}\n## 一句話定義\n(一行)\n` +
`## 要點\n- (3-12 條,具體、含數字條件)\n## 關鍵實體\n- **實體名** — 一句說明\n` +
`## 關聯\n- 實體A ${REL} 關係 ${REL} 實體B3-8 行,用上面實體名)\n\n原稿:\n${srcText}`;
`不要任何前言、思考過程、英文草稿或說明。格式:\n# ${pageName}\n## 一句話定義\n(一行)\n` +
`## 要點\n- (3-12 條,具體、含數字條件)\n## 關鍵實體\n- **實體名** — 一句說明\n` +
`## 關聯\n- 實體A ${REL} 關係 ${REL} 實體B3-8 行,用上面實體名)\n\n原稿:\n${srcText}`;
try {
// 模型與 workers_ai_chat recipe 同一支(選型實測見 api-recipe-seeds.ts:140
// llama-4-scout 2373ms/答案最完整;對照 Gemini gemma-4-31b-it 16.87 秒且吐英文草稿)。
// prompt 模式放寬 max_tokens 到 8192:對齊 gemma 路的 generationConfig——
// JSON 契約要裝下「文件總覽+N 個原子概念」,2048 會把 JSON 攔腰截斷(解析必失敗)。
const out = (await c.env.AI.run('@cf/meta/llama-4-scout-17b-16e-instruct', {
messages: [{ role: 'user', content: prompt }],
max_tokens: 2048,
max_tokens: daemonPrompt ? 8192 : 2048,
temperature: 0.2,
} as never)) as { response?: string } | undefined;
const card = String(out?.response ?? '').trim();
if (!card) return c.json({ error: 'Workers AI 沒有回傳內容' }, 502);
const raw = String(out?.response ?? '').trim();
if (!raw) return c.json({ error: 'Workers AI 沒有回傳內容' }, 502);
if (daemonPrompt) {
// 原文回傳(`output` 欄位):淨化與解析屬於契約,契約住 daemon ⇒ 這裡一個字都不動。
// daemon 以「回應裡有沒有 output」分辨雲端新舊:舊雲端只會回 `card`(見下),
// 新 daemon 據此 fallback 到 legacy 卡處理——兩個方向的版本歪斜都有路可走。
return c.json({ success: true, output: raw });
}
// 淨化:模型偶爾在卡片前多帶一段前言 ⇒ 取最後一個「# <頁名>」起(同 daemon cleanGemmaCard
const marker = `# ${pageName}`;
const idx = card.lastIndexOf(marker);
return c.json({ success: true, card: (idx >= 0 ? card.slice(idx) : card).trim() + '\n' });
const idx = raw.lastIndexOf(marker);
return c.json({ success: true, card: (idx >= 0 ? raw.slice(idx) : raw).trim() + '\n' });
} catch (e) {
return c.json({ error: `Workers AI 執行失敗:${e instanceof Error ? e.message : String(e)}` }, 502);
}
@@ -748,4 +748,22 @@ describe('POST /portal/daemon/extractt181Workers AI 萃卡,免金鑰)'
expect(raw).not.toContain('gemini_api_key');
expect(raw).not.toContain('credential');
});
// 🔴 Arcrun#134:新 daemon 自帶提示詞(卡片格式契約的唯一真相源在 daemon 端
// collector/——提示詞+JSON 解析+wikishape 組卡同住一處,才不會像 t181 那樣
// 兩份 prompt 靠註解叮嚀同步而漂移)。雲端在 prompt 模式下只是執行器。
//
// 測試環境刻意沒有 [ai] binding ⇒ 走到 AI 分支必回 501。這裡拿 501 當「驗證已通過、
// 已進入 AI 分支」的探針:若有人把 prompt 模式改回要求 page_name/text,這則會看到 400。
it('帶 prompt 時不再要求 page_name/text#134:契約住 daemon 端)', async () => {
const res = await json('POST', '/portal/daemon/extract', { prompt: '任意契約內容' }, KEY);
expect(res.status).toBe(501); // 不是 400 ⇒ prompt 單獨成立
});
// 相容守衛:舊 daemon(無 prompt 欄位)行為凍結——缺欄仍 400(上面那則),
// 齊欄則同樣走到 AI 分支(501 探針)。若這則變 400,代表 legacy 驗證被動到了。
it('legacy(無 prompt)齊欄仍走原路(相容不回退)', async () => {
const res = await json('POST', '/portal/daemon/extract', { page_name: 'x', text: 'y' }, KEY);
expect(res.status).toBe(501);
});
});