補上合併漏收的修改檔(t189/t181/CIS portal 本體)
上一筆5d78806我只 add 了新增檔(favicon 三件套),四個修改檔沒收進去 ⇒ 合併內容只推了一半,t189 的 tenant 比對修復其實還躺在工作區。 本次補上(實測與已刪分支 HEAD23f2fd4逐檔相同): · cypher-executor/src/routes/portal.ts +81(t181 /portal/daemon/extract、t189 tenant) · cypher-executor/src/routes/portal-data.ts +21 · console-ui/public/portal/index.html +169(CIS 視覺、版本卡) · cypher-executor/tests/portal-admin.test.ts +45 教訓:merge --no-commit 後要 git add -A,不能只 add status 裡看到的第一類。 Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
File diff suppressed because one or more lines are too long
@@ -225,7 +225,26 @@ portalDataRouter.get('/portal/data/search', (c) =>
|
||||
if (!libraries.includes('*')) params.set('library', libraries.join(','));
|
||||
// 透傳的只有「在權限範圍內再收窄」的 filter;owner_id/library 上面已由 server 定死,
|
||||
// caller 傳什麼都不看(URLSearchParams 是新建的,蓋不掉)。
|
||||
if (c.req.query('mode') === 'semantic') params.set('mode', 'semantic');
|
||||
if (c.req.query('mode') === 'semantic') {
|
||||
params.set('mode', 'semantic');
|
||||
// 🔴 t183(leo 08-04 實撞:「語義搜尋搜到一大堆不相關的內容」
|
||||
// ——搜「火星座標」卻跑出 n8n 版本比較表、Leo 填答):
|
||||
// Vectorize 會**硬湊滿 topK 筆**,湊不到就把低分的塞進來 ⇒ 尾巴全是無關內容。
|
||||
// kbdb 早就支援 min_score(`kbdb/src/embed.ts:225`,issue #67),
|
||||
// 但 portal **從來沒傳** ⇒ 等同沒有閾值,低分尾全端到用戶面前。
|
||||
//
|
||||
// 0.75 怎麼來的(**實測分數分布,不是猜的**;youlin 實例搜「火星座標 奧林帕斯山」):
|
||||
// 0.908 / 0.881 / 0.881 / 0.880 / 0.870 / 0.815 / 0.798 / 0.787 ← 全是火星座標,真相關
|
||||
// ─────────────────────── 斷崖 ───────────────────────
|
||||
// 0.742 姨媽說故事 / 0.740 ax-academy / 0.739×8 n8n 版本比較表 ← 全是雜訊
|
||||
// 斷崖落在 0.787 與 0.742 之間 ⇒ 取 0.75:相關的全留、雜訊全砍。
|
||||
//
|
||||
// 允許前端覆寫(想放寬看更多可傳 min_score),但**不接受 0/負數**
|
||||
// ——那等於關掉閾值,正是這次要修的病本身。
|
||||
const msRaw = Number(c.req.query('min_score'));
|
||||
const minScore = Number.isFinite(msRaw) && msRaw > 0 && msRaw < 1 ? msRaw : 0.75;
|
||||
params.set('min_score', String(minScore));
|
||||
}
|
||||
const entryType = c.req.query('entry_type');
|
||||
if (entryType) params.set('entry_type', entryType);
|
||||
const limit = c.req.query('limit');
|
||||
|
||||
@@ -718,6 +718,87 @@ 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)仍然不出用戶的電腦。
|
||||
// 🔑 認證用 `X-Arcrun-API-Key`(=namespace),**不是帳密**:
|
||||
// daemon 的密碼**只在連線精靈當下用過就丟、不落地**(config.json 沒有密碼欄,刻意的安全設計),
|
||||
// 但背景萃取是每輪自動跑的 ⇒ 根本拿不到密碼。
|
||||
// 而 daemon 送卡片上雲時本來就帶這個 header(collector/direct.go:355),沿用同一把最自然。
|
||||
portalRouter.post('/portal/daemon/extract', (c) =>
|
||||
run(c, async () => {
|
||||
// 🔴 t189(leo 08-04 實撞:geek6688 萃取回 401「X-Arcrun-API-Key 不正確」):
|
||||
//
|
||||
// t181 那一輪我改成 `apiKey !== portalTenant(c.env)` 就 401,
|
||||
// **但那假設了「daemon 的 api_key = 實例的 CONSOLE_TENANT」——這個假設是錯的**:
|
||||
// geek6688:實例 tenant = ckxt8yr9,daemon config 的 api_key = yuga3bse ⇒ 永遠 401
|
||||
// youlin :兩者碰巧都是 yuga3bse ⇒ 「看起來是好的」
|
||||
// 又一次「在 A 能動不代表 B 能動」(與 t188 同源)。
|
||||
// ⚠️ 當時我還寫了「key 錯就 401」的測試,**把錯誤假設固化成綠燈**——
|
||||
// 測試只證明「符合我的假設」,不證明「假設是對的」。
|
||||
//
|
||||
// 正解=照本 repo 既有慣例:這把 key 是**租戶識別**,不是要比對的共用密語
|
||||
// (見 `webhooks-named.ts` 的 `owner_id: apiKey` 用法)。
|
||||
// 本端點只用實例自己的 `env.AI` 生成、**不寫任何資料**、不回傳庫內內容
|
||||
// ⇒ 有帶 key 即可,不做等值比對。
|
||||
const apiKey = (c.req.header('X-Arcrun-API-Key') ?? '').trim();
|
||||
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 }
|
||||
| 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);
|
||||
|
||||
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
|
||||
|
||||
@@ -629,3 +629,48 @@ describe('GET /portal(P4 admin 頁 HTML 殼)', () => {
|
||||
});
|
||||
});
|
||||
|
||||
// t131/t122 測試已隨 main 的 t176(刪除雲端下發 LLM 設定)一併移除;
|
||||
// 此處只保留 t181(daemon 走 Workers AI)的守衛。
|
||||
|
||||
describe('POST /portal/daemon/extract(t181:Workers AI 萃卡,免金鑰)', () => {
|
||||
// 認證=X-Arcrun-API-Key(=namespace,wrangler.test.toml CONSOLE_TENANT=leo),
|
||||
// **不是帳密**:daemon 密碼不落地(連線精靈用完即丟),背景萃取拿不到密碼。
|
||||
const KEY = { 'X-Arcrun-API-Key': 'leo' };
|
||||
|
||||
it('沒帶 API Key → 401', async () => {
|
||||
const res = await json('POST', '/portal/daemon/extract', { page_name: 'x', text: 'y' });
|
||||
expect(res.status).toBe(401);
|
||||
});
|
||||
|
||||
// 🔴 t189:這則原本是「API Key 錯 → 401(租戶隔離)」,**是錯的,而且害我看到假綠**。
|
||||
//
|
||||
// 它假設「daemon 的 api_key = 實例的 CONSOLE_TENANT」,但實測不成立:
|
||||
// geek6688:tenant=ckxt8yr9、daemon api_key=yuga3bse ⇒ 真用戶**永遠 401**、萃不了
|
||||
// youlin :兩者碰巧相同 ⇒ 我這邊測起來都對
|
||||
// 舊測試只證明「符合我的假設」,不證明「假設是對的」——
|
||||
// **把錯誤假設寫成測試,就是把假綠焊死。**
|
||||
//
|
||||
// 翻轉成守衛:**key 與 tenant 不同也要能萃**(這正是 leo 撞到的情境)。
|
||||
// 若哪天有人又加回等值比對,這則會紅。
|
||||
it('key 與實例 tenant 不同也要能用(t189:多帳號 daemon 的常態)', async () => {
|
||||
const res = await json('POST', '/portal/daemon/extract',
|
||||
{ page_name: 'x', text: 'y' }, { 'X-Arcrun-API-Key': 'another-tenant-key' });
|
||||
expect(res.status).not.toBe(401);
|
||||
});
|
||||
|
||||
it('缺 page_name 或 text → 400(不打 AI、不假裝成功)', async () => {
|
||||
const res = await json('POST', '/portal/daemon/extract', {}, KEY);
|
||||
expect(res.status).toBe(400);
|
||||
const d = (await res.json()) as { error?: string };
|
||||
expect(String(d.error)).toContain('page_name');
|
||||
});
|
||||
|
||||
// 🔴 回歸守衛:這條路**不得**要求任何 Gemini/API 金鑰——免金鑰正是它存在的理由。
|
||||
// 若哪天有人把它改回打 Google,錯誤訊息會出現 credential/gemini_api_key ⇒ 這則會紅。
|
||||
it('錯誤訊息不得要求任何金鑰(免金鑰是本端點存在的理由)', async () => {
|
||||
const res = await json('POST', '/portal/daemon/extract', {}, KEY);
|
||||
const raw = await res.text();
|
||||
expect(raw).not.toContain('gemini_api_key');
|
||||
expect(raw).not.toContain('credential');
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user