feat(portal): /portal/daemon/extract 接受 daemon 自帶提示詞——卡片格式契約回歸單一真相源(Arcrun#134)

免金鑰預設路(Workers AI)一直用雲端自己那份舊格式 prompt(一句話定義/要點/
關鍵實體/關聯),而 daemon 端 2026-08-15 已改成「LLM 只回 JSON 判斷、wikishape.go
機械組卡」(InkStoneCo#44 ④)⇒ 走預設路的用戶拿到次級卡。兩份 prompt 靠註解
叮嚀「兩邊要一起改」同步,這張票就是叮嚀失效的證據。

修法=契約搬回唯一一處:daemon 送 {page_name, text, prompt},雲端在 prompt 模式
只當執行器(用實例自己的 env.AI 跑生成、max_tokens 對齊 gemma 路的 8192),
原文以 {success, output} 回傳,淨化與解析全在 daemon。雲端不再認得卡的形狀。

相容兩個方向都有路:舊 daemon(無 prompt)走 legacy 路,行為與回應完全不變;
新 daemon 撞舊雲端(回 card 無 output)由 daemon fallback 到 legacy 卡處理。

測試:portal-admin +2(prompt 單獨成立=501 探針非 400;legacy 齊欄行為凍結),
全套 447 passed,14 failed 與基準(main)逐一相同=非本改動引入。

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
uncle6me-web
2026-08-15 21:21:48 +08:00
parent 969acff325
commit 9fa88065ab
2 changed files with 58 additions and 13 deletions
+37 -10
View File
@@ -1309,7 +1309,8 @@ function toPublicLibrary(rec: PortalRecord) {
// 小幫手回報它看守的資料夾各自對應的庫,雲端**自動登記**——庫目錄與地端資料夾一比一。 // 小幫手回報它看守的資料夾各自對應的庫,雲端**自動登記**——庫目錄與地端資料夾一比一。
// 認證=同 /portal/daemon/config(用戶帳密)。已存在的庫略過(冪等),不覆寫顯示名。 // 認證=同 /portal/daemon/config(用戶帳密)。已存在的庫略過(冪等),不覆寫顯示名。
// POST /portal/daemon/extract — 小幫手把「已轉成純文字的原稿」送上來,雲端用 Workers AI 萃成知識卡。 // 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」,列為**最優先**—— // 🔴 t181leo 08-04:「daemon 的 AI 改用 workers AI」,列為**最優先**——
// 「這是我的用戶最大障礙,造成首輪測試用戶的好評或惡評」): // 「這是我的用戶最大障礙,造成首輪測試用戶的好評或惡評」):
@@ -1348,22 +1349,40 @@ portalRouter.post('/portal/daemon/extract', (c) =>
if (!apiKey) return c.json({ error: '缺少 X-Arcrun-API-Key header' }, 401); if (!apiKey) return c.json({ error: '缺少 X-Arcrun-API-Key header' }, 401);
const body = (await c.req.json().catch(() => null)) as const body = (await c.req.json().catch(() => null)) as
| { page_name?: string; text?: string } | { page_name?: string; text?: string; prompt?: string }
| null; | null;
const pageName = String(body?.page_name ?? '').trim(); const pageName = String(body?.page_name ?? '').trim();
const srcText = String(body?.text ?? ''); 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) { if (!c.env.AI) {
// 誠實失敗:不假裝成功,並指名這個部署缺什麼(禁假綠) // 誠實失敗:不假裝成功,並指名這個部署缺什麼(禁假綠)
return c.json({ error: '這個部署沒有綁定 Workers AIwrangler.toml 需有 [ai] binding),請更新知識庫版本' }, 501); return c.json({ error: '這個部署沒有綁定 Workers AIwrangler.toml 需有 [ai] binding),請更新知識庫版本' }, 501);
} }
// 提示詞與 daemon 端 gemmaPrompt 同一份契約(第一行必須是「# <頁名>」), // legacy 提示詞:只服務**還沒更新的舊 daemon**(無 prompt 欄位)。舊 daemon 期待
// 兩邊要一起改;daemon 端在 collector/extract_gemma.go。 // 「# <頁名>」開頭的 markdown 卡(收端 lint 新舊雙軌仍接受);新格式不再從這裡出生,
// 所以本段刻意凍結、不再演進——要改卡的形狀,去 daemon 端 collector/ 改那唯一一份。
// 註:關聯段用的是「知識卡三元組」格式(主詞/謂詞/受詞),與 Arcrun 工作流的邊無關。 // 註:關聯段用的是「知識卡三元組」格式(主詞/謂詞/受詞),與 Arcrun 工作流的邊無關。
const REL = '>'.repeat(2); const REL = '>'.repeat(2);
const prompt = const prompt =
daemonPrompt ||
`把以下原稿重寫成定稿知識卡(正體中文)。直接輸出卡片本身:第一行必須是「# ${pageName}」,` + `把以下原稿重寫成定稿知識卡(正體中文)。直接輸出卡片本身:第一行必須是「# ${pageName}」,` +
`不要任何前言、思考過程、英文草稿或說明。格式:\n# ${pageName}\n## 一句話定義\n(一行)\n` + `不要任何前言、思考過程、英文草稿或說明。格式:\n# ${pageName}\n## 一句話定義\n(一行)\n` +
`## 要點\n- (3-12 條,具體、含數字條件)\n## 關鍵實體\n- **實體名** — 一句說明\n` + `## 要點\n- (3-12 條,具體、含數字條件)\n## 關鍵實體\n- **實體名** — 一句說明\n` +
@@ -1372,17 +1391,25 @@ portalRouter.post('/portal/daemon/extract', (c) =>
try { try {
// 模型與 workers_ai_chat recipe 同一支(選型實測見 api-recipe-seeds.ts:140 // 模型與 workers_ai_chat recipe 同一支(選型實測見 api-recipe-seeds.ts:140
// llama-4-scout 2373ms/答案最完整;對照 Gemini gemma-4-31b-it 16.87 秒且吐英文草稿)。 // 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', { const out = (await c.env.AI.run('@cf/meta/llama-4-scout-17b-16e-instruct', {
messages: [{ role: 'user', content: prompt }], messages: [{ role: 'user', content: prompt }],
max_tokens: 2048, max_tokens: daemonPrompt ? 8192 : 2048,
temperature: 0.2, temperature: 0.2,
} as never)) as { response?: string } | undefined; } as never)) as { response?: string } | undefined;
const card = String(out?.response ?? '').trim(); const raw = String(out?.response ?? '').trim();
if (!card) return c.json({ error: 'Workers AI 沒有回傳內容' }, 502); 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 // 淨化:模型偶爾在卡片前多帶一段前言 ⇒ 取最後一個「# <頁名>」起(同 daemon cleanGemmaCard
const marker = `# ${pageName}`; const marker = `# ${pageName}`;
const idx = card.lastIndexOf(marker); const idx = raw.lastIndexOf(marker);
return c.json({ success: true, card: (idx >= 0 ? card.slice(idx) : card).trim() + '\n' }); return c.json({ success: true, card: (idx >= 0 ? raw.slice(idx) : raw).trim() + '\n' });
} catch (e) { } catch (e) {
return c.json({ error: `Workers AI 執行失敗:${e instanceof Error ? e.message : String(e)}` }, 502); 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('gemini_api_key');
expect(raw).not.toContain('credential'); 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);
});
}); });