portal-auth P1(#24 #25):KBDB library filter 地基(不 merge,待總管審) (#50)
This commit was merged in pull request #50.
This commit is contained in:
@@ -58,6 +58,8 @@ export interface ListEntriesFilter {
|
||||
parent_id?: string;
|
||||
page_name?: string; // exact-match lookup (e.g. skill-/example- idempotency key)
|
||||
source?: string; // filter by metadata_json.$.source (ingest envelope source.uri). issue #5.1
|
||||
library?: string[]; // filter by metadata_json.$.library(多值 OR;portal-auth P1,#24/#25)。
|
||||
// 未帶=不過濾(向後相容硬驗收);未標記的舊資料視同 'general'(design §3.2)。
|
||||
q?: string; // keyword filter on content (LIKE). Arcrun#3 發現①:list 端點原本完全不吃
|
||||
// search/q,caller 帶了也被靜默丟棄(不是 458K 筆搜不到,是這個 filter 沒接)。
|
||||
limit?: number;
|
||||
@@ -81,6 +83,7 @@ export async function listEntries(db: D1Database, f: ListEntriesFilter = {}): Pr
|
||||
// source is queryable via SQLite json_extract on the existing metadata_json TEXT column —
|
||||
// no new column / no migration (表不變鐵律). Per issue #5.1 (頂層化 source 成可查 filter).
|
||||
if (f.source) { conds.push("json_extract(metadata_json, '$.source') = ?"); params.push(f.source); }
|
||||
if (f.library && f.library.length > 0) { conds.push(libraryPredicate(f.library)); params.push(...f.library); }
|
||||
if (f.q) { conds.push('content LIKE ?'); params.push(`%${f.q}%`); }
|
||||
const where = conds.length ? `WHERE ${conds.join(' AND ')}` : '';
|
||||
const limit = Math.min(f.limit ?? 100, 1000);
|
||||
@@ -123,19 +126,30 @@ export async function deleteEntry(db: D1Database, id: string): Promise<void> {
|
||||
await db.prepare('DELETE FROM entries WHERE id = ?').bind(id).run();
|
||||
}
|
||||
|
||||
// 「庫」filter 的 SQL 謂詞(portal-auth P1,design §3.2/§3.3;零建表,同 #5.1 source 的 json_extract 先例)。
|
||||
// COALESCE(x,'general') IN (…) ≡ SDD §3.3 寫的 (x IN (…) OR (x IS NULL AND 'general' IN (…)))——
|
||||
// 語意完全相同(未標記/無 metadata_json 的舊資料歸 'general'),但單組佔位符、不用重複綁參數。
|
||||
function libraryPredicate(libraries: string[]): string {
|
||||
const placeholders = libraries.map(() => '?').join(',');
|
||||
return `COALESCE(json_extract(metadata_json, '$.library'), 'general') IN (${placeholders})`;
|
||||
}
|
||||
|
||||
// D1 LIKE keyword search (base; semantic search is the optional embed module).
|
||||
// entry_type: optional base filter (generic — caller passes any type, base stays type-agnostic).
|
||||
// library: optional 多值庫 filter(portal-auth P1);未帶=行為與舊版一字不變(向後相容)。
|
||||
export async function searchEntries(
|
||||
db: D1Database,
|
||||
q: string,
|
||||
owner_id?: string,
|
||||
entry_type?: string,
|
||||
limit = 50,
|
||||
library?: string[],
|
||||
): Promise<Entry[]> {
|
||||
const conds = ['content LIKE ?'];
|
||||
const params: unknown[] = [`%${q}%`];
|
||||
if (owner_id) { conds.push('owner_id = ?'); params.push(owner_id); }
|
||||
if (entry_type) { conds.push('entry_type = ?'); params.push(entry_type); }
|
||||
if (library && library.length > 0) { conds.push(libraryPredicate(library)); params.push(...library); }
|
||||
const res = await db
|
||||
.prepare(`SELECT * FROM entries WHERE ${conds.join(' AND ')} ORDER BY updated_at DESC LIMIT ?`)
|
||||
.bind(...params, Math.min(limit, 200))
|
||||
|
||||
+22
-3
@@ -44,11 +44,15 @@ export async function embedOnWrite(env: Bindings, entry: Entry): Promise<boolean
|
||||
{
|
||||
id: entry.id,
|
||||
values: vec,
|
||||
// metadata 走 indexed 範圍:owner_id(租戶隔離)、entry_type、source(#5.1 過濾與語義共用)。
|
||||
// metadata 走 indexed 範圍:owner_id(租戶隔離)、entry_type、source(#5.1 過濾與語義共用)、
|
||||
// library(portal-auth P1「庫」filter)。library 在寫入端正規化:未標記='general'(design §3.2
|
||||
// 「未蓋章的舊資料視同 general」——D1 側用查詢端 COALESCE fallback,Vectorize filter 做不了
|
||||
// COALESCE,故在 upsert 時蓋 'general',查詢端單純 $in 即可)。
|
||||
metadata: {
|
||||
owner_id: entry.owner_id ?? '',
|
||||
entry_type: entry.entry_type,
|
||||
source: readSource(entry) ?? '',
|
||||
library: readLibrary(entry) ?? 'general',
|
||||
},
|
||||
},
|
||||
]);
|
||||
@@ -69,6 +73,13 @@ function readSource(entry: Entry): string | null {
|
||||
return typeof s === 'string' ? s : null;
|
||||
}
|
||||
|
||||
/** metadata_json.$.library(portal-auth P1)。非字串/空字串一律視同未標記(→ caller fallback 'general')。 */
|
||||
function readLibrary(entry: Entry): string | null {
|
||||
const meta = parseMeta(entry.metadata_json);
|
||||
const l = meta?.library;
|
||||
return typeof l === 'string' && l.trim() !== '' ? l : null;
|
||||
}
|
||||
|
||||
function parseMeta(json: string | null): Record<string, unknown> | null {
|
||||
if (!json) return null;
|
||||
try {
|
||||
@@ -150,6 +161,7 @@ export async function backfillEmbeddings(
|
||||
owner_id: x.e.owner_id ?? '',
|
||||
entry_type: x.e.entry_type,
|
||||
source: readSource(x.e) ?? '',
|
||||
library: readLibrary(x.e) ?? 'general', // 同 embedOnWrite:寫入端正規化(P1)
|
||||
},
|
||||
}));
|
||||
if (vectors.length > 0) {
|
||||
@@ -199,25 +211,31 @@ export interface SemanticHit {
|
||||
owner_id?: string;
|
||||
entry_type?: string;
|
||||
source?: string;
|
||||
library?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* 語義搜尋(mode:'semantic')。模組未開 → 回 null(caller 降級 keyword + 告知缺能力)。
|
||||
* owner_id / source / entry_type 過濾走 Vectorize metadata filter(entry_type 已 index,見上 upsert metadata)。
|
||||
* entry_type 是 base 通用 filter(caller 傳任意 type,base 不寫死語意)。
|
||||
* library(portal-auth P1):多值庫 filter 走 `$in`(官方支援已核實 2026-07-14:文件明列 $in/$nin+
|
||||
* workers-types 原生 typing;design §3.3 的 fan-out fallback 不需啟用)。未帶=行為不變。
|
||||
* 註:向量 metadata 的 library 在寫入端已正規化(未標記='general'),故 $in 不需 NULL 處理;
|
||||
* 但「建 library metadata index 之前」upsert 的既有向量沒有此欄 → 部署清單強制 reindex backfill。
|
||||
*/
|
||||
export async function semanticSearch(
|
||||
env: Bindings,
|
||||
q: string,
|
||||
opts: { owner_id?: string; source?: string; entry_type?: string; topK?: number } = {},
|
||||
opts: { owner_id?: string; source?: string; entry_type?: string; library?: string[]; topK?: number } = {},
|
||||
): Promise<SemanticHit[] | null> {
|
||||
if (!embedEnabled(env)) return null;
|
||||
const vec = await embedText(env, q);
|
||||
if (!vec) return [];
|
||||
const filter: Record<string, string> = {};
|
||||
const filter: VectorizeVectorMetadataFilter = {};
|
||||
if (opts.owner_id) filter.owner_id = opts.owner_id;
|
||||
if (opts.source) filter.source = opts.source;
|
||||
if (opts.entry_type) filter.entry_type = opts.entry_type;
|
||||
if (opts.library && opts.library.length > 0) filter.library = { $in: opts.library };
|
||||
const res = await env.VECTORIZE!.query(vec, {
|
||||
topK: Math.min(opts.topK ?? 20, 100),
|
||||
returnMetadata: 'indexed',
|
||||
@@ -229,5 +247,6 @@ export async function semanticSearch(
|
||||
owner_id: m.metadata?.owner_id as string | undefined,
|
||||
entry_type: m.metadata?.entry_type as string | undefined,
|
||||
source: m.metadata?.source as string | undefined,
|
||||
library: m.metadata?.library as string | undefined,
|
||||
}));
|
||||
}
|
||||
|
||||
@@ -13,6 +13,14 @@ import { embedEnabled, embedOnWrite, semanticSearch } from '../embed';
|
||||
|
||||
export const entryRoutes = new Hono<{ Bindings: Bindings }>();
|
||||
|
||||
// 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);
|
||||
@@ -27,6 +35,7 @@ entryRoutes.post('/', async (c) => {
|
||||
// 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 欄位)。
|
||||
@@ -37,6 +46,7 @@ entryRoutes.get('/', async (c) => {
|
||||
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,
|
||||
@@ -44,23 +54,26 @@ entryRoutes.get('/', async (c) => {
|
||||
return c.json({ success: true, entries, count: entries.length, total });
|
||||
});
|
||||
|
||||
// GET /entries/search?q=...&owner_id=...&source=...&entry_type=...&mode=keyword|semantic
|
||||
// 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 發現閉環)。
|
||||
// - entry_type:base 通用 filter(caller 傳任意 type,如 workflow;base 不寫死語意,workflow-discovery Q4)。
|
||||
// - library:多值庫 filter(逗號分隔,portal-auth P1)。keyword 走 json_extract+NULL→general;
|
||||
// semantic 走 Vectorize $in。未帶=全庫(行為不變)。
|
||||
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';
|
||||
|
||||
if (mode === 'semantic') {
|
||||
const hits = await semanticSearch(c.env, q, { owner_id, source, entry_type });
|
||||
const hits = await semanticSearch(c.env, q, { owner_id, source, entry_type, library });
|
||||
if (hits === null) {
|
||||
// 模組沒開:誠實降級 keyword + 告知「叫 CC 幫你開 vectorize」(不假裝有語義)。
|
||||
const entries = await searchEntries(c.env.DB, q, owner_id, entry_type);
|
||||
const entries = await searchEntries(c.env.DB, q, owner_id, entry_type, undefined, library);
|
||||
return c.json({
|
||||
success: true,
|
||||
entries,
|
||||
@@ -78,7 +91,7 @@ entryRoutes.get('/search', async (c) => {
|
||||
return c.json({ success: true, entries, count: entries.length, mode: 'semantic' });
|
||||
}
|
||||
|
||||
const entries = await searchEntries(c.env.DB, q, owner_id, entry_type);
|
||||
const entries = await searchEntries(c.env.DB, q, owner_id, entry_type, undefined, library);
|
||||
return c.json({ success: true, entries, count: entries.length, mode: 'keyword' });
|
||||
});
|
||||
|
||||
|
||||
Reference in New Issue
Block a user