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:
@@ -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(見下)。
|
||||||
//
|
//
|
||||||
// 🔴 t181(leo 08-04:「daemon 的 AI 改用 workers AI」,列為**最優先**——
|
// 🔴 t181(leo 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#134(2026-08-15):新版 daemon **自帶完整提示詞**(`prompt` 欄位)。
|
||||||
|
//
|
||||||
|
// 為什麼:卡片格式契約原本存在兩份——daemon 端 gemmaPrompt(Go)與本端點(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 AI(wrangler.toml 需有 [ai] binding),請更新知識庫版本' }, 501);
|
return c.json({ error: '這個部署沒有綁定 Workers AI(wrangler.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/extract(t181:Workers 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);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user