fix(semantic): 故障照實說是故障——不再把壞掉說成「沒開通」(leo 2026-08-09 直令)

一、文案(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 <noreply@anthropic.com>
This commit is contained in:
uncle6me-web
2026-08-09 02:05:12 +08:00
parent 8eb10049b8
commit 6846d6ddae
10 changed files with 425 additions and 49 deletions
+7 -6
View File
@@ -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 部署)...'));
+7 -3
View File
@@ -84,9 +84,13 @@ export async function cmdUpdate(opts: { force?: boolean } = {}): Promise<void> {
// 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 });
+5 -3
View File
@@ -28,10 +28,12 @@ export interface ArcrunConfig {
mcp_url?: string;
multi_tenant?: boolean;
// 語義查詢開關(issue #7 / SDD T2.4self-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 keywordfree-tier 友善,不建 index、不花費)。
// 開法:設 kbdb_embed:true → redeployacr update)。「CC 幫開」=CC 寫此欄 true + 跑 acr update
// false → base 維持 LIKE keyword顯式選擇才有這個狀態;缺欄位不再等於關——
// 舊語意會讓 acr update 把正常實例的 binding 靜默剝掉,畫面再謊稱「沒開通」)
kbdb_embed?: boolean;
// 暴露 consent 閘已移除(leo 2026-06-29Arcrun#13)。此欄位保留只為向後相容舊 config.yaml
// (讀到不報錯,不再寫入/檢查)。
+8 -5
View File
@@ -970,7 +970,8 @@ function taipeiMonthDay(ms) { var d = new Date(ms + TAIPEI_OFFSET_MS); return {
if (!x.ok) { $('se-count').innerHTML = '<span class="err">' + esc(x.d.error || ('查詢失敗(HTTP ' + x.status + '')) + '</span>'; return; }
var d = x.d;
if (S.semantic && d.mode === 'keyword') {
$('se-banner').innerHTML = '<div class="honest" style="margin-top:18px"><div class="h">語意搜尋尚未啟用</div><div class="b">語意搜尋用「意思」找資料,不是字面比對。<br>' + esc(d.capability_hint || '部署端尚未開啟 Vectorize——不會假裝有語意結果,以下是關鍵字結果。') + '</div></div>';
// 2026-08-09 leo:語意搜尋是安裝即提供的功能,降級=故障,不說「尚未啟用」。
$('se-banner').innerHTML = '<div class="honest" style="margin-top:18px"><div class="h">語意搜尋目前故障</div><div class="b">' + esc(d.capability_hint || '語意搜尋目前故障(實例缺 Vectorize/AI 設定),以下先給關鍵字結果,不假裝是語意結果。') + '<br>維運資訊:' + esc(d.admin_hint || '(此版本後端未回報細節)') + '</div></div>';
}
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 ? '<br>維運資訊:' + esc(d.admin_hint) : '');
}
})
.catch(function () {
+16 -6
View File
@@ -1158,16 +1158,26 @@ function taipeiMonthDay(ms) { var d = new Date(ms + TAIPEI_OFFSET_MS); return {
if (!x.ok) { $('se-count').innerHTML = '<span class="err">' + esc(x.d.error || ('查詢失敗(HTTP ' + x.status + '')) + '</span>'; return; }
var d = x.d;
if (S.mode === 'semantic' && d.mode === 'keyword') {
// 2026-08-07:不直接透傳後端 capability_hint——那句話是寫給工程師看的
// (會出現「叫 CC」「vectorize」「redeploy」這類我們內部的修法指令,不是講給
// 一般用戶聽的)。前端固定顯示「發生了什麼+現在怎麼辦」,不假裝有語意結果,
// 也不留用戶不知道下一步的空白。
$('se-banner').innerHTML = '<div class="honest" style="margin-top:18px"><div class="h">語意搜尋還沒開通</div><div class="b">語意搜尋能理解你問題的「意思」找資料,不只是比對關鍵字。<br>這個知識庫目前還沒開通這個功能,以下顯示的是關鍵字搜尋結果(不會假裝有語意結果)。<br>想開通的話,請到你電腦上的 Arcrun(同步小幫手)「版本與更新」頁,用「疑難排解」匯出診斷檔給我們,我們會幫你打開。</div></div>';
// 🔴 2026-08-09 leo:「語義搜尋已經確定是一安裝就提供的功能⋯⋯我沒有不開通這個
// 功能,是壞了,沒有人會把 bug 美化成沒提供沒開通。」
// 走到這裡=這台實例的語意搜尋壞了(缺 binding 或向量化失敗),照實說是故障、
// 是我們的問題,不要求使用者做任何事。文案優先用後端 capability_hint(已是人話,
// 能分「暫時故障/部署故障」),舊版後端沒有就用底下的通用故障文案。
var bhint = (d.capability_hint && !/開通|尚未啟用/.test(d.capability_hint))
? d.capability_hint
: '語意搜尋目前故障,先用關鍵字幫你找了下面的結果。這是我們系統的問題,不是你的操作問題,你不需要做任何事,我們會修好它。';
$('se-banner').innerHTML = '<div class="honest" style="margin-top:18px"><div class="h">語意搜尋目前故障</div><div class="b">' + esc(bhint) + '</div></div>';
}
var entries = d.entries || [];
$('se-count').textContent = '命中 ' + entries.length + ' 筆・模式 ' + searchModeLabel(d.mode || 'keyword') + (d.note ? '・' + d.note : '');
if (!entries.length) {
$('se-results').innerHTML = '<div class="muted" style="padding:30px 10px;text-align:center;grid-column:1/-1">找不到「' + esc(q) + '」——換個關鍵字試試。</div>';
// 空結果不一律怪查詢字:語意模式的空結果,後端 capability_hint 會分
// 「真的沒命中(換字)」與「索引故障/還沒有資料(不是用戶的問題)」,照實顯示。
// 降級(mode=keyword)時故障說明已在上方橫幅,這裡不重複。
var emptyMsg = (d.capability_hint && d.mode === 'semantic')
? esc(d.capability_hint)
: '找不到「' + esc(q) + '」——換個關鍵字試試。';
$('se-results').innerHTML = '<div class="muted" style="padding:30px 10px;text-align:center;grid-column:1/-1">' + emptyMsg + '</div>';
return;
}
$('se-results').innerHTML = entries.map(function (e) {
+37 -3
View File
@@ -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' nullcaller keyword +
* owner_id / source / entry_type Vectorize metadata filterentry_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<SemanticHit[] | null> {
if (!embedEnabled(env)) return null;
const vec = await embedText(env, q);
if (!vec) return [];
// 空查詢=真的沒東西可查(route 層已擋 q 必填,這裡只兜底),不算故障。
if (!(q ?? '').trim()) return [];
// 🔴 2026-08-09leo 直令):向量化失敗**不准**回空結果集。空結果=「你的庫裡沒有」,
// 向量化失敗=「我們沒查成」——兩者對使用者是完全不同的事實,混在一起就是說謊。
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;
+96 -23
View File
@@ -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:有 executionCtxworkerd)就 waitUntil,測試環境沒有就 detach(吞錯不吵)。
// 給搜尋路徑的「自癒」動作用——修復是順手做的背景事,絕不拖慢也絕不弄壞查詢本身。
function fireAndForget(c: { executionCtx?: ExecutionContext }, p: Promise<unknown>): 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 P1design §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 LIKEbase,永遠可用)。
// - mode=semantic:需 embed 模組開(Vectorize+AI binding)。未開 → 降級 keyword +
// capability_hint 告知缺能力(#7 發現閉環)。capability_hint 是講給非技術使用者聽的人話
// capability_hint。capability_hint 是講給非技術使用者聽的人話
// 2026-08-08 修:曾經直接透傳到封測用戶眼前的工程師導向文字,見該欄位旁註);
// 技術細節另放 admin_hint 給維運者/CC 看。
// 🔴 2026-08-09leo 直令):語意搜尋是**一安裝就提供**的功能,模組不在=故障,
// 文案照實說「壞了、是我們的問題、使用者不用做任何事」,禁止說成「還沒開通/未啟用」。
// 降級回應帶 degraded_reasonmodule_off / embed_query_failed)供前端與診斷分流。
// - entry_typebase 通用 filtercaller 傳任意 type,如 workflowbase 不寫死語意,workflow-discovery Q4)。
// - library:多值庫 filter(逗號分隔,portal-auth P1)。keyword 走 json_extractNULL→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-09leo 直令):語意搜尋壞掉時**照實說是故障**。
// - 語意搜尋是一安裝就提供的功能。走到下面任一降級分支=這台實例壞了,
// 不是「還沒開通」「未啟用」——禁止把 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 bindingembedEnabled=false)。語意搜尋是安裝即提供的功能,缺 binding=部署層事故(常見:redeploy 沒帶 kbdb_embed 注入、或安裝時 Vectorize index 建立失敗被放行)。修法:確認 Vectorize index 存在後以 kbdb_embed:true 重部 kbdb。本次已降級關鍵字搜尋。',
});
}
// hydrate vector hits → 完整 entry(保持回應形狀與 keyword 一致)。
// #67entry 附 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<typeof e> => 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',
+15
View File
@@ -89,6 +89,21 @@ describe('embedSelfTest(檢修孔:卡片自我查詢,驗證 index 真的
expect(r.passed).toBe(false);
});
it('向量化本身失敗(AI 額度用完)→ tested:falsenote 說明故障,不 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: [] });
+208
View File
@@ -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 兩種計數;getEntryWHERE id = ?)回可配置 entry。
function makeFakeDB(opts: {
embeddedCount?: number;
pendingCount?: number;
hydrate?: Record<string, Entry | null>;
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<T>() {
if (sql.includes('WHERE id = ?')) {
const id = String(bound[0]);
return ((opts.hydrate ?? {})[id] ?? null) as unknown as T;
}
if (sql.includes('COUNT(*)')) {
// backfillStatuspending 用 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<T>() {
// 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<unknown>[] = [];
return {
ctx: { waitUntil: (p: Promise<unknown>) => { 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('誠實降級 keyworddegraded_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<string, unknown>;
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<string, unknown>;
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<string, unknown>;
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<string, unknown>;
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<string, unknown>;
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<string, unknown>;
expect(body.empty_reason).toBe('no_index');
expect(String(body.capability_hint)).toContain('還沒有');
expect(/故障/.test(String(body.capability_hint))).toBe(false);
});
});
+26
View File
@@ -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 時發現孤兒向量/下架殘影 → 背景 deleteByIdsis_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/10deploy-all DRY_RUN 24 worker 斷言 PASS。瀏覽器端到端(local wrangler
> dev 18787/18788portal 真登入)兩種故障畫面截圖驗過。**未部署 prod(D20 閘)**
> 別 session 未 commit 的 `.component-builds/*``graph-executor.ts` 未動未代提。
> **2026-08-07 晚(檢修孔第一版,local commit 未 pushmain**leo 直接指令「先把檢修孔做出來
> 發版,不必先知道 Oscar 的病是什麼」——解掉「查不出封測者的病,因為拿不到他那邊資料;拿不到
> 資料是因為沒有檢修孔」的死結。**規格中途被 leo 簡化過一次**:從「免授權層/同意後才交」兩層