From 6846d6ddae2d9bb9acf476c531231c6f522b7d9e Mon Sep 17 00:00:00 2001 From: uncle6me-web Date: Sun, 9 Aug 2026 02:05:12 +0800 Subject: [PATCH] =?UTF-8?q?fix(semantic):=20=E6=95=85=E9=9A=9C=E7=85=A7?= =?UTF-8?q?=E5=AF=A6=E8=AA=AA=E6=98=AF=E6=95=85=E9=9A=9C=E2=80=94=E2=80=94?= =?UTF-8?q?=E4=B8=8D=E5=86=8D=E6=8A=8A=E5=A3=9E=E6=8E=89=E8=AA=AA=E6=88=90?= =?UTF-8?q?=E3=80=8C=E6=B2=92=E9=96=8B=E9=80=9A=E3=80=8D=EF=BC=88leo=20202?= =?UTF-8?q?6-08-09=20=E7=9B=B4=E4=BB=A4=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 一、文案(portal/console/kbdb hint):語意搜尋是一安裝就提供的功能, 降級=故障。橫幅改「語意搜尋目前故障/我們的問題/你不用做任何事」, 拿掉「還沒開通、想開通請匯出診斷檔」這種要使用者申請開通的假框架。 kbdb 降級回應加 degraded_reason(module_off / embed_query_failed)。 二、查詢向量化失敗不再偽裝成空結果(leo 點名的謊): semanticSearch 舊行為「AI 額度用完 → 回 []」會讓使用者以為 自己的知識庫裡沒有這筆資料。改丟 EmbedQueryFailedError, route 誠實降級 keyword+照實告知是暫時故障。 三、源頭機制(裝好的實例為什麼會失去語意搜尋): - acr update:kbdb_embed 判斷 ===true → !==false。config 缺欄位時 redeploy 會把 [[vectorize]]+[ai] binding 靜默剝掉(wrangler deploy 整份覆蓋),一台正常實例就此壞掉。init 預設同步翻成 [Y/n]。 -(另 repo)deploy-all.mjs ensureVectorizeIndex 失敗改致命中止。 四、順手自癒:孤兒向量/下架殘影搜尋時背景清除;空結果且 pending>0 背景 backfill;no_index 拆「故障」vs「還沒有資料」兩態。 測試:kbdb 146/146(新增 degraded 6 案+selftest 1 案);cli 10/10; 瀏覽器端到端兩種故障畫面實測(local wrangler dev+portal 真登入)。 無 SDD 對應:leo 直令修故障(同 08-07 檢修孔前例的人閘直接授權路徑)。 Co-Authored-By: Claude Opus 5 --- cli/src/commands/init.ts | 13 +- cli/src/commands/update.ts | 10 +- cli/src/lib/config.ts | 8 +- console-ui/public/console/index.html | 13 +- console-ui/public/portal/index.html | 22 ++- kbdb/src/embed.ts | 40 +++- kbdb/src/routes/entries.ts | 119 ++++++++--- kbdb/tests/embed-selftest.test.ts | 15 ++ kbdb/tests/search-semantic-degraded.test.ts | 208 ++++++++++++++++++++ system-dev/wiki/status.md | 26 +++ 10 files changed, 425 insertions(+), 49 deletions(-) create mode 100644 kbdb/tests/search-semantic-degraded.test.ts diff --git a/cli/src/commands/init.ts b/cli/src/commands/init.ts index 9bab027..855a8d7 100644 --- a/cli/src/commands/init.ts +++ b/cli/src/commands/init.ts @@ -230,15 +230,16 @@ async function initSelfHosted( console.log(chalk.yellow(` ⚠ 查 subdomain 失敗(${e instanceof Error ? e.message : e}),稍後可手動補`)); } - // 3.5 語義查詢開關(issue #7 / T2.4):問用戶要不要開(預設關,free-tier 友善)。 - // 開 → deploy 建 CF Vectorize index + 注入 binding。關 → base 維持 LIKE keyword,零花費。 - // 之後想開:跟 CC 說「幫我開語義查詢」或設 kbdb_embed:true + acr update(不必重 init)。 + // 3.5 語義查詢(issue #7 / T2.4):**預設開**(2026-08-09 翻轉,leo:「語義搜尋已經 + // 確定是一安裝就提供的功能」——預設關會產出一批「看起來裝好了、其實少一條腿」的 + // 實例,之後畫面上還被誤說成「沒開通」)。顯式回答 n 才關(極端省額度者自選)。 + // 開 → deploy 建 CF Vectorize index + 注入 binding。關 → base 維持 LIKE keyword。 const embedAns = (await prompt( rl, - '要開語義查詢嗎?(KBDB 加 AI 向量搜尋;用 CF Vectorize,可能多花費;預設關,之後可隨時開) [y/N]', + '要開語義查詢嗎?(內建功能,建議保持開啟;用 CF Vectorize,有免費額度) [Y/n]', )).trim().toLowerCase(); - const kbdbEmbed = embedAns === 'y' || embedAns === 'yes'; - if (kbdbEmbed) console.log(chalk.gray(' → 已選開語義查詢:部署時會建 Vectorize index。')); + const kbdbEmbed = !(embedAns === 'n' || embedAns === 'no'); + if (!kbdbEmbed) console.log(chalk.yellow(' → 已選關語義查詢:這台實例將只有關鍵字搜尋(之後可設 kbdb_embed:true + acr update 補開)。')); // 4. 下載 repo 部署物(含預編譯 wasm)+ 注入 KV id + wrangler deploy 全部 Worker console.log(chalk.gray('\n → 下載部署物 + 部署 Worker(從 GitHub 拉預編譯 wasm,用你的 CF token 部署)...')); diff --git a/cli/src/commands/update.ts b/cli/src/commands/update.ts index 8865250..d9a3b18 100644 --- a/cli/src/commands/update.ts +++ b/cli/src/commands/update.ts @@ -84,9 +84,13 @@ export async function cmdUpdate(opts: { force?: boolean } = {}): Promise { // self-hosted → 注入 MULTI_TENANT="false"(mcp-account-source §5.5,修 acr update 部署的 MCP 401)。 // config 源頭:init 寫 multi_tenant:false + mode:'self-hosted'。acr update 只在 self-hosted 跑。 selfHosted: config.mode === 'self-hosted' || config.multi_tenant === false, - // 語義查詢開關(issue #7):config.kbdb_embed:true → 部署建 Vectorize index + 注入 binding。 - // 這也是「CC 幫開」的落地路徑:CC 寫 kbdb_embed:true 進 config → acr update redeploy 即生效。 - kbdbEmbed: config.kbdb_embed === true, + // 語義查詢(issue #7):預設**開**,只有 config 顯式寫 kbdb_embed:false 才關。 + // 🔴 2026-08-09 翻轉預設(leo:「語義搜尋已經確定是一安裝就提供的功能」)。 + // 舊判斷 `=== true` 的實害:config 沒這個欄位(舊 config / 一鍵安裝實例本機補跑 update) + // 時 redeploy 會把 kbdb 的 [[vectorize]]+[ai] binding 靜默剝掉——一台**原本正常**的 + // 實例就這樣失去語意搜尋,畫面上還被說成「還沒開通」。wrangler deploy 是整份覆蓋, + // binding 不在 toml 裡=直接消失,這正是「裝好的實例壞掉」的機制之一。 + kbdbEmbed: config.kbdb_embed !== false, }; const result = await downloadAndDeploy(ctx, 'main', { force: opts.force }); diff --git a/cli/src/lib/config.ts b/cli/src/lib/config.ts index e683eff..60d95a9 100644 --- a/cli/src/lib/config.ts +++ b/cli/src/lib/config.ts @@ -28,10 +28,12 @@ export interface ArcrunConfig { mcp_url?: string; multi_tenant?: boolean; // 語義查詢開關(issue #7 / SDD T2.4,self-hosted 從零做)。 - // true → deploy 時建 CF Vectorize index 並注入 kbdb worker 的 [[vectorize]]+[ai] binding; + // 🔴 2026-08-09 預設翻轉(leo:「語義搜尋已經確定是一安裝就提供的功能」): + // 未設 → **視同開**(init/update 皆以 `!== false` 判斷)。只有顯式 false 才關。 + // true/未設 → deploy 時建 CF Vectorize index 並注入 kbdb worker 的 [[vectorize]]+[ai] binding; // kbdb embed 模組啟用(寫入時對標記 embed 的 entry embed、search 支援 mode=semantic)。 - // 未設/false → base 維持 LIKE keyword(free-tier 友善,不建 index、不花費)。 - // 開法:設 kbdb_embed:true → redeploy(acr update)。「CC 幫開」=CC 寫此欄 true + 跑 acr update。 + // false → base 維持 LIKE keyword(顯式選擇才有這個狀態;缺欄位不再等於關—— + // 舊語意會讓 acr update 把正常實例的 binding 靜默剝掉,畫面再謊稱「沒開通」)。 kbdb_embed?: boolean; // 暴露 consent 閘已移除(leo 2026-06-29,Arcrun#13)。此欄位保留只為向後相容舊 config.yaml // (讀到不報錯,不再寫入/檢查)。 diff --git a/console-ui/public/console/index.html b/console-ui/public/console/index.html index a392fdf..223c119 100644 --- a/console-ui/public/console/index.html +++ b/console-ui/public/console/index.html @@ -970,7 +970,8 @@ function taipeiMonthDay(ms) { var d = new Date(ms + TAIPEI_OFFSET_MS); return { if (!x.ok) { $('se-count').innerHTML = '' + esc(x.d.error || ('查詢失敗(HTTP ' + x.status + ')')) + ''; return; } var d = x.d; if (S.semantic && d.mode === 'keyword') { - $('se-banner').innerHTML = '
語意搜尋尚未啟用
語意搜尋用「意思」找資料,不是字面比對。
' + esc(d.capability_hint || '部署端尚未開啟 Vectorize——不會假裝有語意結果,以下是關鍵字結果。') + '
'; + // 2026-08-09 leo:語意搜尋是安裝即提供的功能,降級=故障,不說「尚未啟用」。 + $('se-banner').innerHTML = '
語意搜尋目前故障
' + esc(d.capability_hint || '語意搜尋目前故障(實例缺 Vectorize/AI 設定),以下先給關鍵字結果,不假裝是語意結果。') + '
維運資訊:' + esc(d.admin_hint || '(此版本後端未回報細節)') + '
'; } var entries = d.entries || []; $('se-count').textContent = '命中 ' + entries.length + ' 筆・模式 ' + (d.mode || 'keyword') + @@ -1456,17 +1457,19 @@ function taipeiMonthDay(ms) { var d = new Date(ms + TAIPEI_OFFSET_MS); return { .then(function (d) { // t36:狀態照實顯示(live 探測 mode,不是讀設定值)。啟用時不再顯示任何操作指示—— // 沒有東西要用戶操作;未啟用才給一句人話與下一步。 + // 2026-08-09 leo:語意搜尋是安裝即提供的功能——探測到降級=這台實例壞了, + // 照實標「故障」,不說「尚未啟用」(那會把 bug 說成沒提供的功能)。 var on = d.mode === 'semantic'; $('st-vec').textContent = on - ? '● 已啟用——搜尋頁切到「語意」就能用意思找資料。' - : '○ 尚未啟用——目前用關鍵字搜尋,不會假裝有語意結果。'; + ? '● 正常——搜尋頁切到「語意」就能用意思找資料。' + : '○ 故障——語意搜尋是內建功能,這台實例現在少了它(系統端問題,不是操作問題)。'; var hint = $('st-vec-hint'); if (on) { hint.style.display = 'none'; } else { hint.style.display = ''; - hint.innerHTML = '一鍵安裝的實例會在安裝時自動開通語意索引。' - + '如果你這個實例是較早裝的、或安裝當下開通沒成功,重新跑一次安裝流程即可補上(已建好的資料不會重來)。'; + hint.innerHTML = '修復方式:重新跑一次安裝流程(用原本的 Cloudflare 帳號),會把缺的語意索引設定補回來;已建好的資料不會重來。' + + (d.admin_hint ? '
維運資訊:' + esc(d.admin_hint) : ''); } }) .catch(function () { diff --git a/console-ui/public/portal/index.html b/console-ui/public/portal/index.html index 8cd4d87..56393b2 100644 --- a/console-ui/public/portal/index.html +++ b/console-ui/public/portal/index.html @@ -1158,16 +1158,26 @@ function taipeiMonthDay(ms) { var d = new Date(ms + TAIPEI_OFFSET_MS); return { if (!x.ok) { $('se-count').innerHTML = '' + esc(x.d.error || ('查詢失敗(HTTP ' + x.status + ')')) + ''; return; } var d = x.d; if (S.mode === 'semantic' && d.mode === 'keyword') { - // 2026-08-07:不直接透傳後端 capability_hint——那句話是寫給工程師看的 - // (會出現「叫 CC」「vectorize」「redeploy」這類我們內部的修法指令,不是講給 - // 一般用戶聽的)。前端固定顯示「發生了什麼+現在怎麼辦」,不假裝有語意結果, - // 也不留用戶不知道下一步的空白。 - $('se-banner').innerHTML = '
語意搜尋還沒開通
語意搜尋能理解你問題的「意思」找資料,不只是比對關鍵字。
這個知識庫目前還沒開通這個功能,以下顯示的是關鍵字搜尋結果(不會假裝有語意結果)。
想開通的話,請到你電腦上的 Arcrun(同步小幫手)「版本與更新」頁,用「疑難排解」匯出診斷檔給我們,我們會幫你打開。
'; + // 🔴 2026-08-09 leo:「語義搜尋已經確定是一安裝就提供的功能⋯⋯我沒有不開通這個 + // 功能,是壞了,沒有人會把 bug 美化成沒提供沒開通。」 + // 走到這裡=這台實例的語意搜尋壞了(缺 binding 或向量化失敗),照實說是故障、 + // 是我們的問題,不要求使用者做任何事。文案優先用後端 capability_hint(已是人話, + // 能分「暫時故障/部署故障」),舊版後端沒有就用底下的通用故障文案。 + var bhint = (d.capability_hint && !/開通|尚未啟用/.test(d.capability_hint)) + ? d.capability_hint + : '語意搜尋目前故障,先用關鍵字幫你找了下面的結果。這是我們系統的問題,不是你的操作問題,你不需要做任何事,我們會修好它。'; + $('se-banner').innerHTML = '
語意搜尋目前故障
' + esc(bhint) + '
'; } var entries = d.entries || []; $('se-count').textContent = '命中 ' + entries.length + ' 筆・模式 ' + searchModeLabel(d.mode || 'keyword') + (d.note ? '・' + d.note : ''); if (!entries.length) { - $('se-results').innerHTML = '
找不到「' + esc(q) + '」——換個關鍵字試試。
'; + // 空結果不一律怪查詢字:語意模式的空結果,後端 capability_hint 會分 + // 「真的沒命中(換字)」與「索引故障/還沒有資料(不是用戶的問題)」,照實顯示。 + // 降級(mode=keyword)時故障說明已在上方橫幅,這裡不重複。 + var emptyMsg = (d.capability_hint && d.mode === 'semantic') + ? esc(d.capability_hint) + : '找不到「' + esc(q) + '」——換個關鍵字試試。'; + $('se-results').innerHTML = '
' + emptyMsg + '
'; return; } $('se-results').innerHTML = entries.map(function (e) { diff --git a/kbdb/src/embed.ts b/kbdb/src/embed.ts index c65b749..9f0e0ce 100644 --- a/kbdb/src/embed.ts +++ b/kbdb/src/embed.ts @@ -325,7 +325,16 @@ export async function embedSelfTest( return { enabled: true, tested: false, passed: null, note: '取樣卡片內容為空,跳過自我檢查' }; } // min_score:0——自我檢查要看「找不找得到」,不能被查詢端的相對門檻先濾掉。 - const hits = await semanticSearch(env, sample, { owner_id: opts.owner_id, topK: 10, min_score: 0 }); + let hits: SemanticHit[] | null; + try { + hits = await semanticSearch(env, sample, { owner_id: opts.owner_id, topK: 10, min_score: 0 }); + } catch (e) { + if (e instanceof EmbedQueryFailedError) { + // 向量化本身失敗(額度用完/模型故障)=「這條路現在是斷的」,誠實回報,不算 passed/failed。 + return { enabled: true, tested: false, passed: null, note: `自我檢查沒跑成:${e.message}(語義搜尋此刻同樣會故障,多半是 Workers AI 額度或服務問題)` }; + } + throw e; + } if (hits === null) { return { enabled: false, tested: false, passed: null, note: 'embed 模組回報未開(binding 檢查期間消失,罕見)' }; } @@ -349,6 +358,22 @@ export interface SemanticHit { library?: string; } +/** + * 查詢向量化失敗(2026-08-09 leo 直令:「查詢的向量化如果失敗(例如當天額度用完), + * 目前會回一個空的結果集——那是騙人,不是降級」)。 + * + * 舊行為:embedText 拿不到向量 → semanticSearch 回 [],caller 分不出 + * 「真的沒命中」和「根本沒查成」,使用者看到「查無資料」,以為知識庫裡沒有這筆東西。 + * 新行為:AI.run 丟錯(額度用完/模型故障)或回不出向量 → 丟這個錯, + * 由 route 層誠實降級 keyword +告知「這是我們的故障」,不再偽裝成空結果。 + */ +export class EmbedQueryFailedError extends Error { + constructor(detail: string) { + super(`查詢向量化失敗:${detail}`); + this.name = 'EmbedQueryFailedError'; + } +} + /** * 語義搜尋(mode:'semantic')。模組未開 → 回 null(caller 降級 keyword + 告知缺能力)。 * owner_id / source / entry_type 過濾走 Vectorize metadata filter(entry_type 已 index,見上 upsert metadata)。 @@ -369,8 +394,17 @@ export async function semanticSearch( opts: { owner_id?: string; source?: string; entry_type?: string; library?: string[]; topK?: number; min_score?: number } = {}, ): Promise { if (!embedEnabled(env)) return null; - const vec = await embedText(env, q); - if (!vec) return []; + // 空查詢=真的沒東西可查(route 層已擋 q 必填,這裡只兜底),不算故障。 + if (!(q ?? '').trim()) return []; + // 🔴 2026-08-09(leo 直令):向量化失敗**不准**回空結果集。空結果=「你的庫裡沒有」, + // 向量化失敗=「我們沒查成」——兩者對使用者是完全不同的事實,混在一起就是說謊。 + let vec: number[] | null; + try { + vec = await embedText(env, q); + } catch (e) { + throw new EmbedQueryFailedError(e instanceof Error ? e.message : String(e)); + } + if (!vec) throw new EmbedQueryFailedError('Workers AI 沒有回出向量(回應形狀異常或空回應)'); const filter: VectorizeVectorMetadataFilter = {}; if (opts.owner_id) filter.owner_id = opts.owner_id; if (opts.source) filter.source = opts.source; diff --git a/kbdb/src/routes/entries.ts b/kbdb/src/routes/entries.ts index 357c0c3..fcface1 100644 --- a/kbdb/src/routes/entries.ts +++ b/kbdb/src/routes/entries.ts @@ -13,11 +13,28 @@ import { searchEntries, isDeprecatedEntry, } from '../actions/entry-crud'; -import { embedEnabled, embedOnWrite, semanticSearch, relativeMinScore, backfillStatus } from '../embed'; +import { + embedEnabled, + embedOnWrite, + semanticSearch, + relativeMinScore, + backfillStatus, + backfillEmbeddings, + EmbedQueryFailedError, +} from '../embed'; import { migrateLegacyCredentialsForOwner } from '../actions/credential-legacy-migration'; export const entryRoutes = new Hono<{ Bindings: Bindings }>(); +// fire-and-forget:有 executionCtx(workerd)就 waitUntil,測試環境沒有就 detach(吞錯不吵)。 +// 給搜尋路徑的「自癒」動作用——修復是順手做的背景事,絕不拖慢也絕不弄壞查詢本身。 +function fireAndForget(c: { executionCtx?: ExecutionContext }, p: Promise): void { + let ctx: ExecutionContext | undefined; + try { ctx = c.executionCtx; } catch { ctx = undefined; } + if (ctx) ctx.waitUntil(p.catch(() => {})); + else void p.catch(() => {}); +} + // library 多值參數(逗號分隔,portal-auth P1,design §3.3)。空值/全空白 → undefined(=不過濾, // 行為與未帶參數一字不變——向後相容硬驗收)。 function parseLibraryParam(raw: string | undefined): string[] | undefined { @@ -116,9 +133,12 @@ entryRoutes.get('/', async (c) => { // GET /entries/search?q=...&owner_id=...&source=...&entry_type=...&library=...&mode=keyword|semantic // - mode=keyword(預設):D1 LIKE(base,永遠可用)。 // - mode=semantic:需 embed 模組開(Vectorize+AI binding)。未開 → 降級 keyword + -// capability_hint 告知缺能力(#7 發現閉環)。capability_hint 是講給非技術使用者聽的人話 +// capability_hint。capability_hint 是講給非技術使用者聽的人話 // (2026-08-08 修:曾經直接透傳到封測用戶眼前的工程師導向文字,見該欄位旁註); // 技術細節另放 admin_hint 給維運者/CC 看。 +// 🔴 2026-08-09(leo 直令):語意搜尋是**一安裝就提供**的功能,模組不在=故障, +// 文案照實說「壞了、是我們的問題、使用者不用做任何事」,禁止說成「還沒開通/未啟用」。 +// 降級回應帶 degraded_reason(module_off / embed_query_failed)供前端與診斷分流。 // - entry_type:base 通用 filter(caller 傳任意 type,如 workflow;base 不寫死語意,workflow-discovery Q4)。 // - library:多值庫 filter(逗號分隔,portal-auth P1)。keyword 走 json_extract+NULL→general; // semantic 走 Vectorize $in。未帶=全庫(行為不變)。 @@ -158,19 +178,41 @@ entryRoutes.get('/search', async (c) => { // 已在 PR 描述向 leo 說明這個 trade-off(多倍 margin vs 迴圈重撈的取捨)。 const requestedTopK = top_k ?? 20; // 與 embed.ts semanticSearch 的預設 topK 對齊 const fetchTopK = include_deprecated ? requestedTopK : Math.min(requestedTopK * 3, 100); - const hits = await semanticSearch(c.env, q, { - owner_id, source, entry_type, library, topK: fetchTopK, min_score, - }); + // 🔴 2026-08-09(leo 直令):語意搜尋壞掉時**照實說是故障**。 + // - 語意搜尋是一安裝就提供的功能。走到下面任一降級分支=這台實例壞了, + // 不是「還沒開通」「未啟用」——禁止把 bug 美化成沒提供(那會製造 + // 「請幫我開通」的客服工單,而真正的故障沒人修)。 + // - capability_hint 給一般使用者看:說清楚「是我們的問題、不是你的錯、 + // 你不用做任何事」;技術細節放 admin_hint 給維運者/CC。 + // - 降級仍回關鍵字結果:有退化的結果比空白有用,但誠實標示,不假裝是語意結果。 + let hits; + try { + hits = await semanticSearch(c.env, q, { + owner_id, source, entry_type, library, topK: fetchTopK, min_score, + }); + } catch (e) { + if (e instanceof EmbedQueryFailedError) { + // 查詢向量化失敗(Workers AI 額度用完/服務故障):舊版在這裡回空結果集 + // =把「我們沒查成」偽裝成「你的庫裡沒有」——leo 08-09 點名的謊。改誠實降級。 + const entries = await searchEntries(c.env.DB, q, owner_id, entry_type, undefined, library, source, include_deprecated); + return c.json({ + success: true, + entries, + count: entries.length, + mode: 'keyword', + requested_mode: 'semantic', + degraded_reason: 'embed_query_failed', + capability_hint: + '語意搜尋暫時故障,先用關鍵字幫你找了下面的結果。這是我們系統的問題,不是你的操作問題,你不需要做任何事,稍後它會自動恢復。', + admin_hint: `${e.message}。常見原因:Workers AI 當日額度用完或服務暫時異常;本次已降級關鍵字搜尋,資料與索引皆未受影響。`, + }); + } + throw e; + } if (hits === null) { - // 模組沒開:誠實降級 keyword(不假裝有語義)。 - // 🔴 2026-08-08(總管交辦,Oscar 封測回報「語義搜尋搜不到」的根因修復): - // capability_hint 是「誠實透傳」鏈路(cypher-executor portal-data.ts → portal 前端) - // 唯一一次主動告訴非技術使用者「發生什麼事+下一步」的機會,故預設文案改成人話: - // - 不假設讀者懂 vectorize / binding / redeploy / 「叫 CC」這些我們內部的修法指令 - // - 誠實承認這次是降級(不是「一樣好,只是換個名字」) - // - 給一個他自己做得到的下一步(換字重試 / 聯絡我們開通),不是要他自己修系統 - // 技術細節不丟掉,換到 admin_hint(機器可讀,給真正的維運者/CC 看)——薄殼原則: - // 這裡是 base API 的一個回應形狀,兩個欄位並存,caller 自己挑要顯示哪一個。 + // embed 模組不在(缺 VECTORIZE/AI binding):對一安裝就提供的功能而言,這**是故障** + // ——多半是某次部署把 binding 弄丟了(更新時沒帶 kbdb_embed、或安裝時 Vectorize + // 建立失敗被靜默放行)。誠實降級 keyword,照實說壞了,不說「還沒開通」。 const entries = await searchEntries(c.env.DB, q, owner_id, entry_type, undefined, library, source, include_deprecated); return c.json({ success: true, @@ -178,24 +220,43 @@ entryRoutes.get('/search', async (c) => { count: entries.length, mode: 'keyword', requested_mode: 'semantic', + degraded_reason: 'module_off', capability_hint: - '語意搜尋還沒開通,這次顯示的是關鍵字比對結果,不是用「意思」找的——你打的字要盡量貼近資料裡實際出現的詞才容易搜到。想啟用語意搜尋,請聯絡我們協助開通。', + '語意搜尋目前故障,先用關鍵字幫你找了下面的結果。這是我們系統的問題,不是你的操作問題,你不需要做任何事,我們會修好它。', admin_hint: - '語義查詢需先開 vectorize(embed 模組)。叫 CC「幫我開語義查詢」即可(設 kbdb_embed:true + redeploy)。本次已降級關鍵字搜尋。', + '故障:kbdb worker 缺 VECTORIZE/AI binding(embedEnabled=false)。語意搜尋是安裝即提供的功能,缺 binding=部署層事故(常見:redeploy 沒帶 kbdb_embed 注入、或安裝時 Vectorize index 建立失敗被放行)。修法:確認 Vectorize index 存在後以 kbdb_embed:true 重部 kbdb。本次已降級關鍵字搜尋。', }); } // hydrate vector hits → 完整 entry(保持回應形狀與 keyword 一致)。 // #67:entry 附 score(相似分數)——加欄不改形,既有 caller 不解析多的欄位不受影響。 + // 2026-08-09 自癒:hydrate 過程順手記下「索引裡有、資料已不在」的向量 + // - 孤兒(getEntry 找不到)→ 該向量已無對應資料,直接刪; + // - 殘影(已下架但向量還在,0.971 案的病原)→ 刪向量+is_embedded 歸零。 + // 背景執行(fireAndForget),失敗下次搜尋再清;查詢本身不受影響。 + const orphanIds: string[] = []; + const deprecatedIds: string[] = []; let entries = ( await Promise.all( hits.map(async (h) => { const e = await getEntry(c.env.DB, h.id); - return e ? { ...e, score: h.score } : null; + if (!e) { orphanIds.push(h.id); return null; } + return { ...e, score: h.score }; }), ) ).filter((e): e is NonNullable => e !== null); if (!include_deprecated) { - entries = entries.filter((e) => !isDeprecatedEntry(e)); + entries = entries.filter((e) => { + const dep = isDeprecatedEntry(e); + if (dep) deprecatedIds.push(e.id); + return !dep; + }); + } + const staleIds = [...orphanIds, ...deprecatedIds]; + if (staleIds.length > 0 && c.env.VECTORIZE) { + fireAndForget(c, (async () => { + await c.env.VECTORIZE!.deleteByIds(staleIds); + await markUnembedded(c.env.DB, deprecatedIds); + })()); } // 🔴 2026-08-05:相對門檻砍低分尾(leo 實測「關懷型 AI」命中 20 筆、只有前 3 筆相關)。 // **一定要接在濾掉下架的後面**——否則一筆 0.971 的下架殘影會把 0.6 的正解一起帶走 @@ -233,21 +294,33 @@ entryRoutes.get('/search', async (c) => { let admin_hint: string; if (hits.length === 0) { const status = await backfillStatus(c.env, { owner_id }); - if (status.embedded === 0) { + if (status.embedded === 0 && status.pending > 0) { + // 資料在、索引卻一筆都沒建=故障(寫入時嵌入沒成功過)。順手自癒: + // 背景補嵌一批(冪等、分批),下次搜尋就有機會直接好——不叫使用者做任何事。 empty_reason = 'no_index'; capability_hint = - '這個知識庫目前還沒有可供語意搜尋的資料,所以搜不到——不是你打的字有問題。請聯絡我們確認索引有沒有建好。'; - admin_hint = `owner_id=${owner_id ?? '(all)'} 範圍 backfillStatus.embedded=0:從未 embed,或 backfill 未跑過。`; + '語意搜尋的索引出了狀況,所以暫時搜不到——這是我們系統的問題,不是你打的字有問題。系統正在自動重建,稍後再搜一次看看。'; + admin_hint = `owner_id=${owner_id ?? '(all)'} 範圍 embedded=0 但 pending=${status.pending}:資料在、索引從沒建成=寫入端嵌入從未成功(故障)。本次已背景觸發 backfill 自癒(每批 100,冪等)。`; + fireAndForget(c, backfillEmbeddings(c.env, { owner_id, limit: 100 })); + } else if (status.embedded === 0) { + // 連「該被嵌的資料」都沒有=這個庫還沒有整理好的內容(新裝好還沒同步),不是故障。 + empty_reason = 'no_index'; + capability_hint = + '這個知識庫還沒有整理好的內容可以搜尋——通常是剛裝好、資料還沒同步進來。等同步小幫手跑完再來搜就有了。'; + admin_hint = `owner_id=${owner_id ?? '(all)'} 範圍 embedded=0 且 pending=0:沒有任何標記 embed:true 的 entry——多半是 ingest 還沒跑(正常的空),少數情況是 ingest 管線沒標 embed 旗標(要查管線)。`; } else { empty_reason = 'no_match'; capability_hint = '沒有找到符合的內容,換個說法或更具體的關鍵字再試試看。'; admin_hint = `owner_id=${owner_id ?? '(all)'} 已有 ${status.embedded} 筆嵌入資料,但本次查詢在 Vectorize 端零命中(含 embed.ts 絕對門檻過濾)。`; + // 順手自癒:pending>0=有卡片在寫入時漏嵌(embedOnWrite 失敗是 fire-and-forget, + // 沒有別的機制會回來補)。status 已經查了,不多花查詢,背景補一批。 + if (status.pending > 0) fireAndForget(c, backfillEmbeddings(c.env, { owner_id, limit: 100 })); } } else { empty_reason = 'stale_index'; capability_hint = - '找到的內容都已經被下架或移除了,所以沒有可顯示的結果——換個關鍵字再試試看,或聯絡我們確認索引有沒有過期。'; - admin_hint = `Vectorize 命中 ${hits.length} 筆,但 hydrate 後全部是已下架或找不到對應資料(孤兒向量),非分數門檻造成——相對門檻數學上不可能砍光非空結果(cut<=top)。`; + '這次比對到的內容源頭已經被移除或下架了,所以沒有可顯示的結果。系統已自動清理過期索引(我們的問題,你不用做任何事),換個關鍵字就能正常搜。'; + admin_hint = `Vectorize 命中 ${hits.length} 筆,但 hydrate 後全部是已下架或找不到對應資料(孤兒向量),非分數門檻造成——相對門檻數學上不可能砍光非空結果(cut<=top)。本次已背景觸發向量清理(deleteByIds)。`; } return c.json({ success: true, entries, count: entries.length, mode: 'semantic', diff --git a/kbdb/tests/embed-selftest.test.ts b/kbdb/tests/embed-selftest.test.ts index 9f9f779..619eb63 100644 --- a/kbdb/tests/embed-selftest.test.ts +++ b/kbdb/tests/embed-selftest.test.ts @@ -89,6 +89,21 @@ describe('embedSelfTest(檢修孔:卡片自我查詢,驗證 index 真的 expect(r.passed).toBe(false); }); + it('向量化本身失敗(AI 額度用完)→ tested:false+note 說明故障,不 throw 也不假 passed', async () => { + const store = [mkEntry('e1', '取樣內容', 'o1')]; + const env = { + DB: makeFakeDB(store), + ENVIRONMENT: 'test', + AI: { async run() { throw new Error('3040: daily limit'); } }, + VECTORIZE: { async query() { return { matches: [] }; } }, + } as unknown as Bindings; + const r = await embedSelfTest(env, { owner_id: 'o1' }); + expect(r.enabled).toBe(true); + expect(r.tested).toBe(false); + expect(r.passed).toBeNull(); + expect(r.note).toContain('沒跑成'); + }); + it('依 owner_id 隔離:別的租戶的已嵌入卡片不會被拿來測', async () => { const store = [mkEntry('e1', 'content', 'other-tenant')]; const env = makeEnv(store, { matches: [] }); diff --git a/kbdb/tests/search-semantic-degraded.test.ts b/kbdb/tests/search-semantic-degraded.test.ts new file mode 100644 index 0000000..9397900 --- /dev/null +++ b/kbdb/tests/search-semantic-degraded.test.ts @@ -0,0 +1,208 @@ +// 語意搜尋「故障要照實說是故障」的回歸測試(2026-08-09 leo 直令)。 +// +// 事故:portal 曾把「kbdb 缺 VECTORIZE/AI binding(=壞了)」顯示成「語意搜尋還沒開通, +// 想開通請匯出診斷檔給我們」——把 bug 美化成沒提供的功能,會製造「幫我開通」的客服工單, +// 而真正的故障沒人修。leo 原話:「沒有人會把 bug 美化成沒提供沒開通」。 +// +// 三條鐵則(本檔全部驗死): +// 1. 模組不在(module_off)=故障:文案說「故障/我們的問題/你不用做任何事」, +// 禁出現「開通/未啟用/尚未提供」這類把壞說成沒有的字眼,也不要求使用者任何動作。 +// 2. 查詢向量化失敗(embed_query_failed)=故障:**不准回空結果集**(舊行為= +// 使用者以為自己的庫裡沒有這筆資料)。誠實降級 keyword+帶 degraded_reason。 +// 3. 索引與資料不同步(孤兒向量/下架殘影)→ 搜尋順手自癒(背景刪向量),不留給用戶撞。 +import { describe, it, expect } from 'vitest'; +import { Hono } from 'hono'; +import { entryRoutes } from '../src/routes/entries'; +import type { Bindings, Entry } from '../src/types'; + +function mkEntry(id: string, opts: { deprecated?: boolean } = {}): Entry { + return { + id, content: '一些內容', entry_type: 'block', owner_id: 't1', parent_id: null, + page_name: null, refs_json: '[]', tags_json: '[]', task_status: null, content_hash: null, + is_embedded: 1, confidence: null, + metadata_json: opts.deprecated ? JSON.stringify({ status: 'deprecated', embed: true }) : JSON.stringify({ embed: true }), + created_at: 1, updated_at: 1, + }; +} + +// fake D1:紀錄所有 prepare 過的 SQL(驗自癒有沒有真的動手);COUNT 依 SQL 內容回 +// embedded/pending 兩種計數;getEntry(WHERE id = ?)回可配置 entry。 +function makeFakeDB(opts: { + embeddedCount?: number; + pendingCount?: number; + hydrate?: Record; + pendingRows?: Entry[]; +} = {}) { + const sqls: string[] = []; + const prepare = (sql: string) => { + sqls.push(sql); + let bound: unknown[] = []; + const stmt = { + bind(...args: unknown[]) { bound = args; return stmt; }, + async first() { + if (sql.includes('WHERE id = ?')) { + const id = String(bound[0]); + return ((opts.hydrate ?? {})[id] ?? null) as unknown as T; + } + if (sql.includes('COUNT(*)')) { + // backfillStatus:pending 用 BACKFILL_PREDICATE(含 is_embedded = 0),embedded 用 is_embedded = 1 + if (sql.includes('is_embedded = 0')) return { c: opts.pendingCount ?? 0 } as unknown as T; + return { c: opts.embeddedCount ?? 0 } as unknown as T; + } + return null as unknown as T; + }, + async all() { + // backfillEmbeddings 的候選 SELECT(含 is_embedded = 0) + if (sql.includes('is_embedded = 0') && sql.includes('SELECT *')) { + return { results: (opts.pendingRows ?? []) as unknown as T[] }; + } + return { results: [] as T[] }; + }, + async run() { return { success: true }; }, + }; + return stmt; + }; + return { db: { prepare } as unknown as D1Database, sqls }; +} + +function makeApp() { + const app = new Hono<{ Bindings: Bindings }>(); + app.route('/entries', entryRoutes); + return app; +} + +/** 收集 waitUntil 的 promise,測試結尾 await 全部,讓背景自癒動作跑完再斷言。 */ +function makeCtx() { + const tasks: Promise[] = []; + return { + ctx: { waitUntil: (p: Promise) => { tasks.push(p); }, passThroughOnException() {}, props: {} } as unknown as ExecutionContext, + flush: async () => { await Promise.allSettled(tasks); return tasks.length; }, + }; +} + +const NO_BLAME_USER = (hint: string) => { + // 禁把故障說成「沒提供/沒開通」;禁要求使用者做「申請開通」類動作 + expect(/開通|尚未啟用|未啟用|還沒啟用|尚未提供|沒有提供/.test(hint)).toBe(false); + expect(/請聯絡我們(開通|啟用)|匯出診斷/.test(hint)).toBe(false); + // 必須講明是系統端的問題、使用者不用動作 + expect(/我們(系統)?的問題|系統的問題/.test(hint)).toBe(true); +}; + +describe('mode=semantic 但 embed 模組不在(module_off)——故障,不是「沒開通」', () => { + it('誠實降級 keyword+degraded_reason=module_off,文案照實說故障、不叫使用者做事', async () => { + const app = makeApp(); + const { db } = makeFakeDB(); + const env = { DB: db, ENVIRONMENT: 'test' } as unknown as Bindings; // 無 AI/VECTORIZE + const res = await app.request('/entries/search?q=x&mode=semantic&owner_id=t1', {}, env); + expect(res.status).toBe(200); + const body = (await res.json()) as Record; + expect(body.mode).toBe('keyword'); + expect(body.requested_mode).toBe('semantic'); + expect(body.degraded_reason).toBe('module_off'); + const hint = body.capability_hint as string; + expect(hint).toContain('故障'); + NO_BLAME_USER(hint); + // admin_hint 保留技術細節給維運者 + expect(String(body.admin_hint)).toMatch(/VECTORIZE|binding/); + }); +}); + +describe('查詢向量化失敗(embed_query_failed)——不准偽裝成「查無資料」', () => { + it('AI.run 丟錯(額度用完)→ 200 誠實降級 keyword,不回空語意結果', async () => { + const app = makeApp(); + const { db } = makeFakeDB(); + const env = { + DB: db, ENVIRONMENT: 'test', + AI: { async run() { throw new Error('3040: daily limit exceeded'); } }, + VECTORIZE: { async query() { throw new Error('不應該走到 Vectorize'); } }, + } as unknown as Bindings; + const res = await app.request('/entries/search?q=閉環機&mode=semantic&owner_id=t1', {}, env); + expect(res.status).toBe(200); + const body = (await res.json()) as Record; + expect(body.mode).toBe('keyword'); + expect(body.requested_mode).toBe('semantic'); + expect(body.degraded_reason).toBe('embed_query_failed'); + const hint = body.capability_hint as string; + expect(hint).toContain('故障'); + NO_BLAME_USER(hint); + expect(String(body.admin_hint)).toContain('daily limit exceeded'); + }); + + it('AI.run 回不出向量(形狀異常)→ 同樣走誠實降級,不是空結果', async () => { + const app = makeApp(); + const { db } = makeFakeDB(); + const env = { + DB: db, ENVIRONMENT: 'test', + AI: { async run() { return {}; } }, // 沒有 data + VECTORIZE: { async query() { return { matches: [] }; } }, + } as unknown as Bindings; + const res = await app.request('/entries/search?q=x&mode=semantic&owner_id=t1', {}, env); + const body = (await res.json()) as Record; + expect(body.degraded_reason).toBe('embed_query_failed'); + expect(body.mode).toBe('keyword'); + }); +}); + +describe('索引與資料不同步 → 搜尋順手自癒(不留給下一個用戶撞)', () => { + it('孤兒向量+下架殘影:背景 deleteByIds 兩顆、殘影 is_embedded 歸零', async () => { + const app = makeApp(); + const deleted: string[][] = []; + const { db, sqls } = makeFakeDB({ + embeddedCount: 5, + hydrate: { live1: mkEntry('live1'), dep1: mkEntry('dep1', { deprecated: true }), gone1: null }, + }); + const env = { + DB: db, ENVIRONMENT: 'test', + AI: { async run() { return { data: [[0.1, 0.2]] }; } }, + VECTORIZE: { + async query() { + return { matches: [ { id: 'live1', score: 0.9 }, { id: 'dep1', score: 0.8 }, { id: 'gone1', score: 0.7 } ] }; + }, + async deleteByIds(ids: string[]) { deleted.push(ids); }, + }, + } as unknown as Bindings; + const { ctx, flush } = makeCtx(); + const res = await app.request('/entries/search?q=x&mode=semantic&owner_id=t1', {}, env, ctx); + const body = (await res.json()) as Record; + expect(body.count).toBe(1); // 正常結果不受自癒影響 + await flush(); + expect(deleted.flat().sort()).toEqual(['dep1', 'gone1']); + // 殘影(dep1)另外把 is_embedded 歸零,讓 D1 與 Vectorize 不說兩套話 + expect(sqls.some((s) => s.includes('SET is_embedded = 0'))).toBe(true); + }); + + it('no_index 且 pending>0(資料在、索引從沒建成=故障)→ 文案不怪用戶+背景觸發 backfill', async () => { + const app = makeApp(); + let aiCalls = 0; + const { db } = makeFakeDB({ embeddedCount: 0, pendingCount: 3, pendingRows: [mkEntry('p1')] }); + const env = { + DB: db, ENVIRONMENT: 'test', + AI: { async run() { aiCalls++; return { data: [[0.1, 0.2]] }; } }, + VECTORIZE: { async query() { return { matches: [] }; }, async upsert() {}, async deleteByIds() {} }, + } as unknown as Bindings; + const { ctx, flush } = makeCtx(); + const res = await app.request('/entries/search?q=x&mode=semantic&owner_id=t1', {}, env, ctx); + const body = (await res.json()) as Record; + expect(body.empty_reason).toBe('no_index'); + const hint = body.capability_hint as string; + NO_BLAME_USER(hint); + await flush(); + // backfill 有真的跑(查詢那次 + 補嵌那批 ≥ 2 次 AI.run) + expect(aiCalls).toBeGreaterThanOrEqual(2); + }); + + it('no_index 且 pending=0(庫真的還沒內容)→ 誠實說還沒有資料,不謊稱故障', async () => { + const app = makeApp(); + const { db } = makeFakeDB({ embeddedCount: 0, pendingCount: 0 }); + const env = { + DB: db, ENVIRONMENT: 'test', + AI: { async run() { return { data: [[0.1, 0.2]] }; } }, + VECTORIZE: { async query() { return { matches: [] }; } }, + } as unknown as Bindings; + const res = await app.request('/entries/search?q=x&mode=semantic&owner_id=t1', {}, env); + const body = (await res.json()) as Record; + expect(body.empty_reason).toBe('no_index'); + expect(String(body.capability_hint)).toContain('還沒有'); + expect(/故障/.test(String(body.capability_hint))).toBe(false); + }); +}); diff --git a/system-dev/wiki/status.md b/system-dev/wiki/status.md index 7bc92ec..34534bf 100644 --- a/system-dev/wiki/status.md +++ b/system-dev/wiki/status.md @@ -15,6 +15,32 @@ metadata: ## 📍 當前位置 +> **2026-08-09(語意搜尋「故障要照實說是故障」,leo 直令,local commit main)**: +> leo 看到 portal 橫幅「語意搜尋還沒開通⋯匯出診斷檔給我們幫你打開」原話痛罵: +> 「**語義搜尋已經確定是一安裝就提供的功能⋯我沒有不開通這個功能,是壞了, +> 沒有人會把 bug 美化成沒提供沒開通**」。本輪修四層: +> 1. **文案**:portal(`console-ui/public/portal/index.html`)+console 兩處橫幅與設定頁 +> 全改「語意搜尋目前故障/我們的問題/你不用做任何事」,禁「開通/尚未啟用」框架; +> kbdb `capability_hint` 同步改(`degraded_reason: module_off`)。 +> 2. **查詢向量化失敗不再偽裝成空結果**(leo 點名的謊):`embed.ts` `semanticSearch` +> 改丟 `EmbedQueryFailedError`(舊行為 `if(!vec) return []`=「額度用完」被顯示成 +> 「查無資料」);route 層接住 → 誠實降級 keyword+`degraded_reason: embed_query_failed`。 +> 瀏覽器實測:真 AI binding + bogus 模型(5007 No such model)→ 橫幅照實說暫時故障。 +> 3. **源頭機制修掉**(為什麼裝好的實例會失去語意搜尋): +> ① `cli update.ts` `kbdb_embed === true`→`!== false`——config 缺欄位時 redeploy 會把 +> [[vectorize]]+[ai] binding 靜默剝掉(wrangler deploy 整份覆蓋);init 預設同步翻成 Y/n。 +> ② `arcrun-rag deploy-all.mjs`:`ensureVectorizeIndex` 失敗以前只印 warning 續行 +> (youlin 07-20 那輪「本輪跳過」就這樣出貨)→ 改**致命中止**+失敗時 GET 複核 +> index 是否其實已存在。 +> 4. **順手自癒**:搜尋 hydrate 時發現孤兒向量/下架殘影 → 背景 deleteByIds+is_embedded +> 歸零(0.971 殘影病原不再累積);空結果且 pending>0 → 背景 backfill 一批(embedOnWrite +> fire-and-forget 失敗以前沒有任何機制會回來補)。no_index 拆兩態:pending>0=故障文案、 +> pending=0=誠實說「還沒有資料」(新裝未同步不是故障)。 +> **測試**:kbdb 146/146 綠(新增 `search-semantic-degraded.test.ts` 6 案+selftest 1 案); +> cli tsc 乾淨+10/10;deploy-all DRY_RUN 24 worker 斷言 PASS。瀏覽器端到端(local wrangler +> dev 18787/18788+portal 真登入)兩種故障畫面截圖驗過。**未部署 prod(D20 閘)**; +> 別 session 未 commit 的 `.component-builds/*` 與 `graph-executor.ts` 未動未代提。 + > **2026-08-07 晚(檢修孔第一版,local commit 未 push,main)**:leo 直接指令「先把檢修孔做出來 > 發版,不必先知道 Oscar 的病是什麼」——解掉「查不出封測者的病,因為拿不到他那邊資料;拿不到 > 資料是因為沒有檢修孔」的死結。**規格中途被 leo 簡化過一次**:從「免授權層/同意後才交」兩層