t181:新增 POST /portal/daemon/extract——daemon 萃取走 Workers AI(免金鑰)

【leo 08-04 列為最優先】「daemon 的 AI 改用 workers AI」
——「這是我的用戶**最大障礙**,造成首輪測試用戶的**好評或惡評**」。

【舊路徑的三種災難(實測)】要用戶自備 Gemini API Key ⇒
① 完全不知道去哪設定(台大資工碩士都卡住 ⇒ leo:「一般人就完蛋了」)
② 金鑰所屬 Google 帳號被 flag → 403 PERMISSION_DENIED,換專案也無效、申訴有死結
③ 52 檔全滅,還要把金鑰傳給總管實打才查得出真因

【修法】新端點收「已在本機轉成純文字的原稿」,用 env.AI binding 萃卡回傳
⇒ **完全不需要任何金鑰**,用的是用戶自己 CF 帳號內建的 AI,
他的 Google 帳號被封也不受影響。認證沿用 daemon/config 那把(帳密)。

為什麼放雲端:daemon 端沒有 AI binding(binding 是 Worker 專屬),
且模型選型集中在雲端才能統一換。
⚠️ 隱私邊界不變:送上來的是已轉文字的原稿、回傳知識卡,
原始檔案(docx/pdf)仍不出用戶電腦。

提示詞與 daemon 端 gemmaPrompt 同一份契約(第一行必須是「# <頁名>」),
兩邊要一起改。缺 AI binding 時回 501 並指名缺什麼(禁假綠)。

測試 4 則全過(含**回歸守衛:錯誤訊息不得出現 gemini_api_key/credential**
——若有人把它改回打 Google 會立刻紅);全檔 38 passed,
唯一失敗是基準線既有的 GET /portal 靜態資源案,與本次無關。
tsc 對 portal.ts 零錯誤。

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
uncle6me-web
2026-08-04 14:10:00 +08:00
parent 41d63b9712
commit d728071a6a
2 changed files with 127 additions and 0 deletions
@@ -811,3 +811,57 @@ describe('/portal/admin/ai + /portal/daemon/report-capabilitiest131', () =
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/extractt181Workers 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);
});
// 🔴 回歸守衛:這條路**不得**要求任何 GeminiAPI 金鑰。
// 若哪天有人把它改回打 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');
});
});