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:
@@ -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 部署)...'));
|
||||
|
||||
@@ -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 });
|
||||
|
||||
@@ -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
|
||||
// (讀到不報錯,不再寫入/檢查)。
|
||||
|
||||
@@ -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 () {
|
||||
|
||||
@@ -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
@@ -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<SemanticHit[] | null> {
|
||||
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;
|
||||
|
||||
+96
-23
@@ -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<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 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<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',
|
||||
|
||||
@@ -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: [] });
|
||||
|
||||
@@ -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<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(*)')) {
|
||||
// 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<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('誠實降級 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<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);
|
||||
});
|
||||
});
|
||||
@@ -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 簡化過一次**:從「免授權層/同意後才交」兩層
|
||||
|
||||
Reference in New Issue
Block a user