diff --git a/cypher-executor/src/routes/portal.ts b/cypher-executor/src/routes/portal.ts index 6d75fd6..b3f1e1d 100644 --- a/cypher-executor/src/routes/portal.ts +++ b/cypher-executor/src/routes/portal.ts @@ -715,6 +715,79 @@ function toPublicLibrary(rec: PortalRecord) { // t52(leo 2026-07-26:「用戶可以看到我有 2 個庫,地端雲端都是 2 個,如果只有一個一定被罵」): // 小幫手回報它看守的資料夾各自對應的庫,雲端**自動登記**——庫目錄與地端資料夾一比一。 // 認證=同 /portal/daemon/config(用戶帳密)。已存在的庫略過(冪等),不覆寫顯示名。 +// POST /portal/daemon/extract — 小幫手把「已轉成純文字的原稿」送上來,雲端用 Workers AI 萃成知識卡。 +// body {email, password, page_name, text}。認證同 /portal/daemon/config(帳密)。 +// +// 🔴 t181(leo 08-04:「daemon 的 AI 改用 workers AI」,列為**最優先**—— +// 「這是我的用戶最大障礙,造成首輪測試用戶的好評或惡評」): +// 舊路徑要用戶自己去 Google 申請 Gemini API Key,實測撞到三種災難: +// ① 完全不知道要去哪裡設定(台大資工碩士都卡住 ⇒ leo:「一般人就完蛋了」) +// ② 拿到的金鑰所屬 Google 帳號被 flag ⇒ 403 PERMISSION_DENIED、換專案也無效 +// ③ 52 檔全滅還要把金鑰傳給別人實打才查得出真因 +// ⇒ 走 Workers AI(`env.AI` binding)**完全不需要任何金鑰**, +// 用的是用戶自己 CF 帳號內建的 AI;他的 Google 帳號被封也不受影響。 +// +// 為什麼萃取放雲端而不是 daemon 直接打:daemon 端**沒有 AI binding** +//(binding 是 Worker 專屬),且模型選型集中在雲端才能統一換。 +// ⚠️ 隱私邊界不變:daemon 送的是**已在本機轉成文字的原稿**,回傳的是知識卡; +// 原始檔案(docx/pdf)仍然不出用戶的電腦。 +portalRouter.post('/portal/daemon/extract', (c) => + run(c, async () => { + const body = (await c.req.json().catch(() => null)) as + | { email?: string; password?: string; page_name?: string; text?: string } + | null; + const email = String(body?.email ?? '').trim().toLowerCase(); + const password = String(body?.password ?? ''); + if (!email || !password) return c.json({ error: 'email 與 password 必填' }, 400); + const pageName = String(body?.page_name ?? '').trim(); + const srcText = String(body?.text ?? ''); + if (!pageName || !srcText.trim()) return c.json({ error: 'page_name 與 text 必填' }, 400); + + if (await isLocked(c.env, email)) return c.json({ error: '登入失敗次數過多,請稍後再試' }, 429); + const recordId = await findUserRecordId(c.env, email); + const rec = recordId ? await getRecordById(c.env, recordId) : null; + if (!rec || (rec.values.status ?? '') !== 'active' + || !(await verifyPassword(password, rec.values.password_hash ?? ''))) { + await recordLoginFail(c.env, email); + return c.json({ error: 'email 或密碼錯誤' }, 401); + } + await clearLoginFail(c.env, email); + + if (!c.env.AI) { + // 誠實失敗:不假裝成功,並指名這個部署缺什麼(禁假綠) + return c.json({ error: '這個部署沒有綁定 Workers AI(wrangler.toml 需有 [ai] binding),請更新知識庫版本' }, 501); + } + + // 提示詞與 daemon 端 gemmaPrompt 同一份契約(第一行必須是「# <頁名>」), + // 兩邊要一起改;daemon 端在 collector/extract_gemma.go。 + // 註:關聯段用的是「知識卡三元組」格式(主詞/謂詞/受詞),與 Arcrun 工作流的邊無關。 + const REL = '>'.repeat(2); + const prompt = + `把以下原稿重寫成定稿知識卡(正體中文)。直接輸出卡片本身:第一行必須是「# ${pageName}」,` + + `不要任何前言、思考過程、英文草稿或說明。格式:\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 秒且吐英文草稿)。 + const out = (await c.env.AI.run('@cf/meta/llama-4-scout-17b-16e-instruct', { + messages: [{ role: 'user', content: prompt }], + max_tokens: 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); + // 淨化:模型偶爾在卡片前多帶一段前言 ⇒ 取最後一個「# <頁名>」起(同 daemon cleanGemmaCard) + const marker = `# ${pageName}`; + const idx = card.lastIndexOf(marker); + return c.json({ success: true, card: (idx >= 0 ? card.slice(idx) : card).trim() + '\n' }); + } catch (e) { + return c.json({ error: `Workers AI 執行失敗:${e instanceof Error ? e.message : String(e)}` }, 502); + } + }), +); + portalRouter.post('/portal/daemon/libraries', (c) => run(c, async () => { const body = (await c.req.json().catch(() => null)) as diff --git a/cypher-executor/tests/portal-admin.test.ts b/cypher-executor/tests/portal-admin.test.ts index 59eede4..0f9a169 100644 --- a/cypher-executor/tests/portal-admin.test.ts +++ b/cypher-executor/tests/portal-admin.test.ts @@ -811,3 +811,57 @@ describe('/portal/admin/ai + /portal/daemon/report-capabilities(t131)', () = await env.WEBHOOKS.delete(ragChatKey); }); }); + +// ═══════════════ t181:daemon 萃取走 Workers AI(免金鑰)═══════════════ +// +// leo 08-04 列為最優先:「daemon 的 AI 改用 workers AI」—— +// 「這是我的用戶最大障礙,造成首輪測試用戶的好評或惡評」。 +// 舊路徑要用戶自備 Gemini key,實測撞到「不知道去哪設定」「Google 帳號被 flag 403」 +// 「52 檔全滅還要把金鑰傳給別人才查得出原因」三種災難。 + +describe('POST /portal/daemon/extract(t181:Workers AI 萃卡,免金鑰)', () => { + const USER_EMAIL = 'daemon@example.com'; + const USER_PW = 'unit-test-pw-1'; + const USER_RECORD = 'rec_daemon_extract'; + + function mockEmailLookup(email: string, recordId: string | null) { + const needle = new URLSearchParams({ page_name: email }).toString(); + fetchMock + .get(KBDB) + .intercept({ + path: (p: string) => p.startsWith('/entries?') && p.includes(needle) && p.includes(encodeURIComponent(NS)), + method: 'GET', + }) + .reply(200, { success: true, entries: recordId ? [{ content: recordId }] : [], count: recordId ? 1 : 0 }); + } + + it('缺 page_name 或 text → 400(不打 AI、不假裝成功)', async () => { + const res = await json('POST', '/portal/daemon/extract', { email: USER_EMAIL, password: USER_PW }); + expect(res.status).toBe(400); + const d = (await res.json()) as { error?: string }; + expect(String(d.error)).toContain('page_name'); + }); + + it('缺帳密 → 400', async () => { + const res = await json('POST', '/portal/daemon/extract', { page_name: 'x', text: 'y' }); + expect(res.status).toBe(400); + }); + + it('帳密錯 → 401(認證與 daemon/config 同一把)', async () => { + mockEmailLookup(USER_EMAIL, USER_RECORD); + mockGetRecord(USER_RECORD, adminValues({ email: USER_EMAIL, password_hash: storedHash })); + const res = await json('POST', '/portal/daemon/extract', { + email: USER_EMAIL, password: 'wrong-password', page_name: 'x', text: 'y', + }); + expect(res.status).toBe(401); + }); + + // 🔴 回歸守衛:這條路**不得**要求任何 Gemini/API 金鑰。 + // 若哪天有人把它改回打 Google,這則會因為錯誤訊息提到 credential/gemini 而紅。 + it('錯誤訊息不得要求任何金鑰(免金鑰是本端點存在的理由)', async () => { + const res = await json('POST', '/portal/daemon/extract', { email: USER_EMAIL, password: USER_PW }); + const raw = await res.text(); + expect(raw).not.toContain('gemini_api_key'); + expect(raw).not.toContain('credential'); + }); +});