674e1b4fa2
leo 逐行複核 fix/embed-backfill-d68 後點出的破口+二次裁決(票上全文見 Leo/Arcrun#85): 一、向量化優先序(今天寫的立刻/本週在跑的先跑/有查詢紀錄的庫優先/半年前慢慢跑)表達 不出來——策略要能從外面(工作流)指定,不能焊死在資料層。新增 embed.ts 的 `SelectionCriteria`(owner_id/source/library/since/until),backfillEmbeddings 與 reconcileEmbedGeneration 共用同一套形狀;「按庫」那一層現在有資料可用即可運作(見下)。 二、世代核對(reconcileEmbedGeneration)不打 AI 但逐筆寫 D1,47 萬筆候選 ≈ 4.7 倍 D1 100,000 rows/日免費額度,先前零保護。新增 actions/maintenance-quota.ts(單一 entries 列/日的共用計數器,精神同 execution-log.ts/embed.ts 既有慣例,不新增表)。 三、leo 二度裁決:「標庫」與「時間分層」其實是一件事,判定標準要從第一天同時容納兩者, 不能先做一半再回頭改。新增 actions/library-backfill.ts 的 backfillEntryLibraryTags—— 呼叫端(ingest/daemon/Arcrun#87)決定要貼哪個庫、用 page_names(Gitea 原稿卡名精準 點名,leo 定案的正解)或 source_prefix/page_name_prefix 過渡 fallback 篩選候選,base 只負責安全、節流地寫入。owner_id 刻意必填(leo 點出「補錯 owner 等於白做」——實查卡片 掛在 owner_id=bfezv28v,換成 'leo' 查卻是空的)。 D69:reconcile 與標庫 backfill 共用同一顆「今天還剩多少 D1 寫入額度」計數器(不共用的話 其中一個會把另一個的閘繞過去);新增 POST /entries/backfill-library + GET .../status, 擴充 POST /embed/backfill 與 /embed/reconcile 吃 library/since/until 參數。 測試:92 → 39 個新增/擴充案例覆蓋 since/until/library 篩選、reconcile 額度真的擋 (含「拿掉 cap 會變紅」反向驗證)、標庫 backfill 冪等/owner_id 必填/page_names 精準比對、 以及兩個操作共用同一顆額度計數器的跨模組驗證(雙向:先 reconcile 耗盡再標庫、反之亦然)。 kbdb 全套 192 個測試綠燈,tsc --noEmit 除既有 auth.test.ts 舊缺陷外無新增錯誤。 紅線:未併 main、未部署、未動任何實例的 is_embedded 旗標(只在本地 SQLite 測試治具跑過)。 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
465 lines
29 KiB
TypeScript
465 lines
29 KiB
TypeScript
// Entries route — atomic data + tree (project/workflow). Base; embed is OPTIONAL (issue #7).
|
||
import { Hono } from 'hono';
|
||
import type { Bindings } from '../types';
|
||
import {
|
||
createEntry,
|
||
deprecateEntriesByLibrary,
|
||
embeddedIdsByLibrary,
|
||
markUnembedded,
|
||
getEntry,
|
||
listEntries,
|
||
updateEntry,
|
||
deleteEntry,
|
||
searchEntries,
|
||
isDeprecatedEntry,
|
||
} from '../actions/entry-crud';
|
||
import {
|
||
embedEnabled,
|
||
embedOnWrite,
|
||
semanticSearch,
|
||
relativeMinScore,
|
||
backfillStatus,
|
||
backfillEmbeddings,
|
||
EmbedQueryFailedError,
|
||
} from '../embed';
|
||
import { migrateLegacyCredentialsForOwner } from '../actions/credential-legacy-migration';
|
||
import { backfillEntryLibraryTags, libraryBackfillStatus } from '../actions/library-backfill';
|
||
|
||
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 {
|
||
if (!raw) return undefined;
|
||
const libs = raw.split(',').map((s) => s.trim()).filter(Boolean);
|
||
return libs.length > 0 ? libs : undefined;
|
||
}
|
||
|
||
// POST /entries — create (entry_type=block/value/project/workflow/...)
|
||
entryRoutes.post('/', async (c) => {
|
||
const body = await c.req.json().catch(() => null);
|
||
if (!body || !body.entry_type) return c.json({ success: false, error: 'entry_type required' }, 400);
|
||
const entry = await createEntry(c.env.DB, body);
|
||
// embed-on-write (#7 / #5 第4點):模組開 + entry 標 embed:true 才做;fire-and-forget,不阻塞回應、失敗不致命。
|
||
if (embedEnabled(c.env)) c.executionCtx.waitUntil(embedOnWrite(c.env, entry).catch(() => {}));
|
||
return c.json({ success: true, entry });
|
||
});
|
||
|
||
// GET /entries/libraries?owner_id=... — 這個租戶的資料裡實際出現過哪些庫(distinct)。
|
||
// t52(leo 2026-07-26:地端幾個資料夾=雲端幾個庫):庫由 ingest 蓋章決定,這裡直接從
|
||
// 資料反查,讓「蓋了章的庫」一定看得到,不必依賴任何登記動作。未蓋章的舊資料=general。
|
||
// 註冊在 '/' 之前——Hono 路由先到先比,放後面會被 '/:id' 之類的樣式吃掉。
|
||
entryRoutes.get('/libraries', async (c) => {
|
||
const owner = c.req.query('owner_id') || '';
|
||
const rows = await c.env.DB.prepare(
|
||
`SELECT DISTINCT COALESCE(NULLIF(json_extract(metadata_json, '$.library'), ''), 'general') AS library
|
||
FROM entries
|
||
WHERE (?1 = '' OR owner_id = ?1)
|
||
AND COALESCE(json_extract(metadata_json, '$.status'), '') != 'deprecated'
|
||
ORDER BY library`,
|
||
)
|
||
.bind(owner)
|
||
.all<{ library: string }>();
|
||
const libraries = (rows.results ?? []).map((r) => r.library).filter(Boolean);
|
||
return c.json({ success: true, libraries, count: libraries.length });
|
||
});
|
||
|
||
// GET /entries/library-stats?owner_id=... — 每個庫的知識卡數(distinct page_name,非 block 數)。
|
||
// t142(2026-07-29):政府驗收用——一眼看出每個庫有幾張卡(page 粒度,不是 block 粒度,
|
||
// 一張卡通常對應 3-5 個 block;不含 deprecated entries)。
|
||
// 只計 entry_type='block' 的條目,因為 block 才對應知識卡的一個段落(page_name 標記所屬頁面)。
|
||
entryRoutes.get('/library-stats', async (c) => {
|
||
const owner = c.req.query('owner_id') || '';
|
||
const rows = await c.env.DB.prepare(
|
||
`SELECT
|
||
COALESCE(NULLIF(json_extract(metadata_json, '$.library'), ''), 'general') AS library,
|
||
COUNT(DISTINCT page_name) AS card_count
|
||
FROM entries
|
||
WHERE (?1 = '' OR owner_id = ?1)
|
||
AND entry_type = 'block'
|
||
AND page_name IS NOT NULL
|
||
AND COALESCE(json_extract(metadata_json, '$.status'), '') != 'deprecated'
|
||
GROUP BY library
|
||
ORDER BY library`,
|
||
)
|
||
.bind(owner)
|
||
.all<{ library: string; card_count: number }>();
|
||
const stats = (rows.results ?? []).map((r) => ({ library: r.library, card_count: r.card_count }));
|
||
return c.json({ success: true, stats });
|
||
});
|
||
|
||
// GET /entries — list with filters (entry_type, owner_id, parent_id, page_name, source, q/search)
|
||
// e.g. list workflows under a project: ?parent_id=PROJECT&entry_type=workflow
|
||
// e.g. get one by idempotency key: ?page_name=skill-rag_with_arcrun
|
||
// e.g. filter by ingest source: ?source=logseq://vault/foo.md (issue #5.1)
|
||
// e.g. filter by library(多值逗號分隔,portal-auth P1): ?library=finance,hr(未標記舊資料歸 general)
|
||
// e.g. keyword filter: ?q=遷移 或 ?search=遷移(別名,Arcrun#3 發現①:caller 實測時打的是 search=,
|
||
// 舊版完全不接這個 filter;q 與 search 兩個名字都認,避免同一個坑再踩一次)。
|
||
// count = 本頁筆數(受 limit 影響);total = 符合條件全部筆數(不受 limit 影響,見 total 欄位)。
|
||
entryRoutes.get('/', async (c) => {
|
||
const entryType = c.req.query('entry_type') || undefined;
|
||
const ownerId = c.req.query('owner_id') || undefined;
|
||
// 自癒搬遷(D38 收尾,2026-08-08):credential 目錄查詢先確保舊表(若還在)已把這個
|
||
// 租戶的資料搬進 entries——冪等、per-owner scoped、成本近零(見 credential-legacy-
|
||
// migration.ts 檔頭)。只在 credential 讀取時觸發,不影響其餘 entry_type 的查詢路徑。
|
||
if (entryType === 'credential' && ownerId) {
|
||
await migrateLegacyCredentialsForOwner(c.env.DB, ownerId).catch(() => {
|
||
// 搬遷失敗不阻塞查詢本身(例如舊表結構意外損毀)——誠實地讓查詢照常進行,
|
||
// 缺席的 credential 由呼叫端既有的 fallback(cypher-executor 舊 KV)接住。
|
||
});
|
||
}
|
||
const { entries, total } = await listEntries(c.env.DB, {
|
||
entry_type: entryType,
|
||
owner_id: ownerId,
|
||
parent_id: c.req.query('parent_id') || undefined,
|
||
page_name: c.req.query('page_name') || undefined,
|
||
source: c.req.query('source') || undefined,
|
||
library: parseLibraryParam(c.req.query('library')),
|
||
q: c.req.query('q') || c.req.query('search') || undefined,
|
||
limit: c.req.query('limit') ? Number(c.req.query('limit')) : undefined,
|
||
offset: c.req.query('offset') ? Number(c.req.query('offset')) : undefined,
|
||
});
|
||
return c.json({ success: true, entries, count: entries.length, total });
|
||
});
|
||
|
||
// 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。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。未帶=全庫(行為不變)。
|
||
// - source:keyword 走 json_extract 謂詞(#66——#5.1 只接了 list 那半,這裡原本解析完即丟);
|
||
// semantic 走 Vectorize metadata filter(原本就有)。
|
||
// - top_k / min_score(#67,semantic 專用):topK 可調(預設 20、上限 100)+分數閾值
|
||
// (預設 0=不過濾)。未帶=行為與舊版一致(向後相容);semantic 回應的 entry 另附 score
|
||
// 欄讓 caller 自裁(加欄不改形,keyword 路徑不受影響)。
|
||
// - include_deprecated(daemon-beta t24,預設 false):兩 mode 預設都濾掉已下架
|
||
// (metadata_json.status==='deprecated')的 entry——這是本次修的洞(t11 斷點①②,總管
|
||
// 0.971 親復現:下架後 keyword/semantic 都照樣回傳)。傳 `include_deprecated=true`
|
||
// 保留給管理面查殘留(驗證下架有沒有真的生效、盤點待清的向量殘留),一般搜尋不帶。
|
||
entryRoutes.get('/search', async (c) => {
|
||
const q = c.req.query('q');
|
||
if (!q) return c.json({ success: false, error: 'q required' }, 400);
|
||
const owner_id = c.req.query('owner_id') || undefined;
|
||
const source = c.req.query('source') || undefined;
|
||
const entry_type = c.req.query('entry_type') || undefined;
|
||
const library = parseLibraryParam(c.req.query('library'));
|
||
const mode = c.req.query('mode') === 'semantic' ? 'semantic' : 'keyword';
|
||
const include_deprecated = c.req.query('include_deprecated') === 'true';
|
||
// 數字參數防呆:非數字/非正 → 當沒帶(回預設),不 400——與其他 filter「壞值靜默忽略」一致。
|
||
const topKNum = Number(c.req.query('top_k'));
|
||
const top_k = Number.isFinite(topKNum) && topKNum > 0 ? Math.floor(topKNum) : undefined;
|
||
const minScoreNum = Number(c.req.query('min_score'));
|
||
const min_score = Number.isFinite(minScoreNum) && minScoreNum > 0 ? minScoreNum : undefined;
|
||
|
||
if (mode === 'semantic') {
|
||
// 補位(daemon-beta t24):Vectorize 的 indexed metadata 沒存 status(見 embed.ts upsert,
|
||
// 只有 owner_id/entry_type/source/library),下架與否只能在 hydrate 回完整 entry 後才知道
|
||
// ——換句話說 Vectorize 端沒辦法直接濾掉已下架向量,濾一定發生在 hydrate 之後。
|
||
// 若濾完才截斷到請求的 topK,遇到「這頁命中大半已下架」(t11 ZZ-T10 實測案例:命中
|
||
// 25 顆全下架)就會整頁被吃光、回傳筆數遠低於 caller 要的量。故过濾生效時(非
|
||
// include_deprecated)**先多撈一批再濾再截斷**:單次 Vectorize query 成本不變(同一次
|
||
// query 只是 topK 參數變大,非多一次 subrequest),用查詢端的餘量換掉「整頁被下架品吃光」
|
||
// 的體驗劣化。這是單輪補位(非重試迴圈到湊滿為止)——若下架比例極高仍可能不足額,
|
||
// 已在 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);
|
||
// 🔴 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) {
|
||
// 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,
|
||
entries,
|
||
count: entries.length,
|
||
mode: 'keyword',
|
||
requested_mode: 'semantic',
|
||
degraded_reason: 'module_off',
|
||
capability_hint:
|
||
'語意搜尋目前故障,先用關鍵字幫你找了下面的結果。這是我們系統的問題,不是你的操作問題,你不需要做任何事,我們會修好它。',
|
||
admin_hint:
|
||
'故障: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);
|
||
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) => {
|
||
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 的正解一起帶走
|
||
// (=同日早上「0 命中」的翻版;t24 的 0.971 復現案就是這種殘影)。
|
||
// caller 顯式帶 min_score 時尊重他的絕對值,不再加碼。
|
||
if (min_score === undefined && entries.length > 1) {
|
||
const cut = relativeMinScore(entries[0].score);
|
||
entries = entries.filter((e) => e.score >= cut);
|
||
}
|
||
// 補位後截斷回 caller 實際要的量(多撈的餘量只用來墊背,不多回傳超過請求的筆數)。
|
||
entries = entries.slice(0, requestedTopK);
|
||
|
||
// 🔴 2026-08-08(總管交辦二修,Oscar 封測案:模組有開、但語意搜尋回空——回報後才發現
|
||
// 這條路徑比「模組沒開」的 capability_hint 更常撞到,卻完全沒有 hint,是「誠實但沉默」):
|
||
// count:0 對用戶而言是無資訊的——「我打的字不對」跟「這個庫的索引根本沒建好」需要的下一步
|
||
// 完全不同,系統卻兩種都回同一句「找不到」。分辨依據(不新開一套覆蓋率查詢,共用 embed.ts
|
||
// 既有的 backfillStatus——2026-08-07 檢修孔/診斷聚合端點已在用同一支,同一件事只留一套
|
||
// 實作,2026-08-08 credential 那次「兩套並存必然漂移」的教訓不重踩):
|
||
// - hits.length===0(Vectorize 端零命中,含 embed.ts 內建絕對門檻)
|
||
// → 查 backfillStatus(owner_id).embedded:
|
||
// 0 筆 → 'no_index'(這個租戶根本沒有索引資料,不是使用者的問題)
|
||
// >0 筆 → 'no_match'(有索引,這次查詢正常沒撞到——換句話說再搜)
|
||
// - hits.length>0 但濾光 → 'stale_index'。誠實核算過機制:relativeMinScore 的 cut
|
||
// 必然 <= 最高分(cut = max(絕對下限, top×0.8) <= top),所以「最高分那筆」永遠會
|
||
// 自己活下來,相對門檻**不可能**把非空結果砍成 0——這裡不能寫「相似度不夠」這種
|
||
// 不符合實際機制的話(誠實限制,mindset §7)。真正會讓 hits>0 卻 entries=0 的只有
|
||
// 兩種:命中的向量對應的資料**已下架**(isDeprecatedEntry 濾掉)、或**已被刪除**
|
||
// (getEntry 找不到,孤兒向量)——兩者都是「索引裡有,但實際資料不在了」,故稱
|
||
// stale_index(索引與資料兩邊不同步),不誤導使用者去猜「換個字」。
|
||
// 三態都給人話 capability_hint(給使用者)+ admin_hint(技術細節,給維運者/CC)。
|
||
// 正常有結果(entries.length>0)完全不受影響,回應形狀不變。
|
||
if (entries.length === 0) {
|
||
let empty_reason: 'no_index' | 'no_match' | 'stale_index';
|
||
let capability_hint: string;
|
||
let admin_hint: string;
|
||
if (hits.length === 0) {
|
||
const status = await backfillStatus(c.env, { owner_id });
|
||
if (status.embedded === 0 && status.pending > 0) {
|
||
// 資料在、索引卻一筆都沒建=故障(寫入時嵌入沒成功過)。順手自癒:
|
||
// 背景補嵌一批(冪等、分批),下次搜尋就有機會直接好——不叫使用者做任何事。
|
||
empty_reason = 'no_index';
|
||
capability_hint =
|
||
'語意搜尋的索引出了狀況,所以暫時搜不到——這是我們系統的問題,不是你打的字有問題。系統正在自動重建,稍後再搜一次看看。';
|
||
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)。本次已背景觸發向量清理(deleteByIds)。`;
|
||
}
|
||
return c.json({
|
||
success: true, entries, count: entries.length, mode: 'semantic',
|
||
empty_reason, capability_hint, admin_hint,
|
||
});
|
||
}
|
||
return c.json({ success: true, entries, count: entries.length, mode: 'semantic' });
|
||
}
|
||
|
||
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' });
|
||
});
|
||
|
||
// GET /entries/:id
|
||
entryRoutes.get('/:id', async (c) => {
|
||
const entry = await getEntry(c.env.DB, c.req.param('id'));
|
||
if (!entry) return c.json({ success: false, error: 'not found' }, 404);
|
||
return c.json({ success: true, entry });
|
||
});
|
||
|
||
// PATCH /entries/deprecate-by-library — body {owner_id, library}。
|
||
// t135:把某租戶某庫的所有 entries 標 deprecated,讓庫從 auto 清單消失。
|
||
// 此路由必須在 '/:id' 之前,否則 'deprecate-by-library' 會被當成 id 參數。
|
||
entryRoutes.patch('/deprecate-by-library', async (c) => {
|
||
const body = (await c.req.json().catch(() => null)) as { owner_id?: string; library?: string } | null;
|
||
const ownerId = String(body?.owner_id ?? '').trim();
|
||
const library = String(body?.library ?? '').trim();
|
||
if (!ownerId || !library) return c.json({ success: false, error: 'owner_id 與 library 必填' }, 400);
|
||
// 🔴 2026-08-05(leo:「理論上它的向量也要刪掉,就不會有殘影了吧?」):
|
||
// 先撈 id 再標下架——順序反過來就撈不到「還有向量」的那批(標完 status 不影響 is_embedded,
|
||
// 但先撈比較不依賴欄位語意,也讓失敗時不會留下「已標下架但向量還在」的中間態)。
|
||
const ids = embedEnabled(c.env) ? await embeddedIdsByLibrary(c.env.DB, ownerId, library) : [];
|
||
const count = await deprecateEntriesByLibrary(c.env.DB, ownerId, library);
|
||
let vectors_deleted = 0;
|
||
if (ids.length > 0) {
|
||
// 刪向量+把 is_embedded 歸零(讓 D1 與 Vectorize 不說兩套話)。
|
||
// 失敗不擋下架本體:D1 已標 deprecated,搜尋端仍會濾掉;殘留向量下次再清。
|
||
try {
|
||
await c.env.VECTORIZE!.deleteByIds(ids);
|
||
await markUnembedded(c.env.DB, ids);
|
||
vectors_deleted = ids.length;
|
||
} catch { /* 誠實回 0,不假裝清乾淨了 */ }
|
||
}
|
||
return c.json({ success: true, deprecated_count: count, vectors_deleted });
|
||
});
|
||
|
||
// POST /entries/backfill-library — 標庫補存量(Arcrun#85 二次裁決/相關票 Arcrun#87,2026-08-11)。
|
||
// body(必填 library + owner_id):{ library, owner_id, page_names?(string[],精準比對,
|
||
// leo 定案的正解——見 actions/library-backfill.ts 檔頭「拿原稿遍歷」), entry_type?,
|
||
// source_prefix?, page_name_prefix?(後兩者為過渡 fallback,精度不如 page_names),
|
||
// since?, until?, limit?(1-500,預設100) }。
|
||
// 冪等:只選「目前未標記 library」的候選;分批:單次 limit 上限,remaining>0 → 重複呼叫直到 0。
|
||
// budget:與 /embed/reconcile 共用同一顆每日 D1 寫入額度(見 actions/maintenance-quota.ts)——
|
||
// 兩者都是「多筆 D1 write、不打 AI」的背景維護操作,不共用額度的話補存量會把世代核對的閘繞過去。
|
||
// base 對內容語意無知:不猜「這批該貼哪個庫」,呼叫端(ingest/#87)決定 library 與篩選條件;
|
||
// owner_id 必填(同 /entries/deprecate-by-library 的既有防線——批次改一大片既有資料不准無租戶範圍地掃)。
|
||
// 此路由必須在 '/:id' 之前註冊,否則 'backfill-library' 會被當成 id 參數。
|
||
entryRoutes.post('/backfill-library', async (c) => {
|
||
const body = (await c.req.json().catch(() => ({}))) as {
|
||
library?: string;
|
||
owner_id?: string;
|
||
entry_type?: string;
|
||
page_names?: string[];
|
||
source_prefix?: string;
|
||
page_name_prefix?: string;
|
||
since?: number | string;
|
||
until?: number | string;
|
||
limit?: number | string;
|
||
};
|
||
const library = String(body.library ?? '').trim();
|
||
const ownerId = String(body.owner_id ?? '').trim();
|
||
if (!library || !ownerId) return c.json({ success: false, error: 'library 與 owner_id 必填' }, 400);
|
||
try {
|
||
const result = await backfillEntryLibraryTags(c.env.DB, c.env, {
|
||
library,
|
||
owner_id: ownerId,
|
||
entry_type: body.entry_type || undefined,
|
||
page_names: Array.isArray(body.page_names) && body.page_names.length > 0 ? body.page_names : undefined,
|
||
source_prefix: body.source_prefix || undefined,
|
||
page_name_prefix: body.page_name_prefix || undefined,
|
||
since: body.since !== undefined ? Number(body.since) : undefined,
|
||
until: body.until !== undefined ? Number(body.until) : undefined,
|
||
limit: body.limit !== undefined ? Number(body.limit) : undefined,
|
||
});
|
||
return c.json({ success: true, ...result });
|
||
} catch (e) {
|
||
return c.json({ success: false, error: e instanceof Error ? e.message : String(e) }, 400);
|
||
}
|
||
});
|
||
|
||
// GET /entries/backfill-library/status?owner_id=&entry_type=&source_prefix=&page_name_prefix=&since=&until=
|
||
// — 符合條件、目前未標記 library 的筆數(backfill 前後都能查,判斷還剩多少)。
|
||
entryRoutes.get('/backfill-library/status', async (c) => {
|
||
const status = await libraryBackfillStatus(c.env.DB, {
|
||
owner_id: c.req.query('owner_id') || undefined,
|
||
entry_type: c.req.query('entry_type') || undefined,
|
||
source_prefix: c.req.query('source_prefix') || undefined,
|
||
page_name_prefix: c.req.query('page_name_prefix') || undefined,
|
||
since: c.req.query('since') ? Number(c.req.query('since')) : undefined,
|
||
until: c.req.query('until') ? Number(c.req.query('until')) : undefined,
|
||
});
|
||
return c.json({ success: true, ...status });
|
||
});
|
||
|
||
// PATCH /entries/:id
|
||
entryRoutes.patch('/:id', async (c) => {
|
||
const body = await c.req.json().catch(() => ({}));
|
||
const entry = await updateEntry(c.env.DB, c.req.param('id'), body);
|
||
if (!entry) return c.json({ success: false, error: 'not found' }, 404);
|
||
// 內容改了 → 重 embed(保持向量新鮮)。embedOnWrite 內部自會檢查模組開 + entry 是否 embeddable。
|
||
if (embedEnabled(c.env) && body.content !== undefined) {
|
||
c.executionCtx.waitUntil(embedOnWrite(c.env, entry).catch(() => {}));
|
||
}
|
||
return c.json({ success: true, entry });
|
||
});
|
||
|
||
// DELETE /entries/:id
|
||
//
|
||
// 🔴 2026-08-10(arcrun-rag#46「刪掉的知識搜尋還撈得到」第 4 點:中途失敗要看得出來):
|
||
// 舊版把向量刪除包成 fire-and-forget(`waitUntil(...).catch(()=>{})`)——失敗被靜默吞掉,
|
||
// 呼叫端(rag_takedown_direct workflow/未來的 portal 刪除 UI)永遠不知道向量沒清乾淨,
|
||
// 使用者看到「刪除成功」,但語意搜尋可能還留著殘影,直到下次搜尋命中它才被自癒清掉
|
||
// (search 路徑的 orphan 清理是事後補救,不是保證)。
|
||
// 改法:與同檔 `/entries/deprecate-by-library`(見上)同款——**同步 await 再回應**,
|
||
// 誠實回報 `vector_deleted`(true=清了/false=清失敗,D1 仍照刪/null=模組未開,不適用)。
|
||
// D1 刪除永遠執行到底(entry 本體一定會消失),差別只在向量那一步呼叫端看不看得見失敗。
|
||
entryRoutes.delete('/:id', async (c) => {
|
||
const id = c.req.param('id');
|
||
let vector_deleted: boolean | null = null; // 模組未開=不適用,維持 null 誠實表達「這件事沒發生過」
|
||
if (embedEnabled(c.env)) {
|
||
try {
|
||
await c.env.VECTORIZE!.deleteByIds([id]);
|
||
vector_deleted = true;
|
||
} catch {
|
||
vector_deleted = false; // 誠實回 false,不假裝清乾淨了;D1 本體仍照刪,不因向量失敗而擋下
|
||
}
|
||
}
|
||
await deleteEntry(c.env.DB, id);
|
||
return c.json({ success: true, vector_deleted });
|
||
});
|