From 9fa88065abdc5a795d3847711f04101480621af0 Mon Sep 17 00:00:00 2001 From: uncle6me-web Date: Sat, 15 Aug 2026 21:21:48 +0800 Subject: [PATCH] =?UTF-8?q?feat(portal):=20/portal/daemon/extract=20?= =?UTF-8?q?=E6=8E=A5=E5=8F=97=20daemon=20=E8=87=AA=E5=B8=B6=E6=8F=90?= =?UTF-8?q?=E7=A4=BA=E8=A9=9E=E2=80=94=E2=80=94=E5=8D=A1=E7=89=87=E6=A0=BC?= =?UTF-8?q?=E5=BC=8F=E5=A5=91=E7=B4=84=E5=9B=9E=E6=AD=B8=E5=96=AE=E4=B8=80?= =?UTF-8?q?=E7=9C=9F=E7=9B=B8=E6=BA=90=EF=BC=88Arcrun#134=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 免金鑰預設路(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 --- cypher-executor/src/routes/portal.ts | 53 ++++++++++++++++------ cypher-executor/tests/portal-admin.test.ts | 18 ++++++++ 2 files changed, 58 insertions(+), 13 deletions(-) diff --git a/cypher-executor/src/routes/portal.ts b/cypher-executor/src/routes/portal.ts index c393c69..385b3bd 100644 --- a/cypher-executor/src/routes/portal.ts +++ b/cypher-executor/src/routes/portal.ts @@ -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(見下)。 // // 🔴 t181(leo 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#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) { // 誠實失敗:不假裝成功,並指名這個部署缺什麼(禁假綠) return c.json({ error: '這個部署沒有綁定 Workers AI(wrangler.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} 實體B(3-8 行,用上面實體名)\n\n原稿:\n${srcText}`; + `不要任何前言、思考過程、英文草稿或說明。格式:\n# ${pageName}\n## 一句話定義\n(一行)\n` + + `## 要點\n- (3-12 條,具體、含數字條件)\n## 關鍵實體\n- **實體名** — 一句說明\n` + + `## 關聯\n- 實體A ${REL} 關係 ${REL} 實體B(3-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); } diff --git a/cypher-executor/tests/portal-admin.test.ts b/cypher-executor/tests/portal-admin.test.ts index a2c5403..9298cf1 100644 --- a/cypher-executor/tests/portal-admin.test.ts +++ b/cypher-executor/tests/portal-admin.test.ts @@ -748,4 +748,22 @@ describe('POST /portal/daemon/extract(t181:Workers 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); + }); });