portal-auth P1(#24 #25):KBDB library filter 地基(不 merge,待總管審) (#50)

This commit was merged in pull request #50.
This commit is contained in:
Leo
2026-07-14 03:50:06 +00:00
parent 18c0846af0
commit 7f409646e5
6 changed files with 274 additions and 14 deletions
+14
View File
@@ -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(多值 ORportal-auth P1#24/#25)。
// 未帶=不過濾(向後相容硬驗收);未標記的舊資料視同 'general'design §3.2)。
q?: string; // keyword filter on content (LIKE). Arcrun#3 發現①:list 端點原本完全不吃
// search/qcaller 帶了也被靜默丟棄(不是 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 P1design §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 多值庫 filterportal-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
View File
@@ -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 過濾與語義共用)
// libraryportal-auth P1「庫」filter)。library 在寫入端正規化:未標記='general'design §3.2
// 「未蓋章的舊資料視同 general」——D1 側用查詢端 COALESCE fallbackVectorize 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.$.libraryportal-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')。模組未開 → 回 nullcaller 降級 keyword + 告知缺能力)。
* owner_id / source / entry_type 過濾走 Vectorize metadata filterentry_type 已 index,見上 upsert metadata)。
* entry_type 是 base 通用 filtercaller 傳任意 typebase 不寫死語意)。
* libraryportal-auth P1):多值庫 filter 走 `$in`(官方支援已核實 2026-07-14:文件明列 $in/$nin
* workers-types 原生 typingdesign §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,
}));
}
+17 -4
View File
@@ -13,6 +13,14 @@ import { embedEnabled, embedOnWrite, semanticSearch } from '../embed';
export const entryRoutes = new Hono<{ Bindings: Bindings }>();
// library 多值參數(逗號分隔,portal-auth P1design §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 LIKEbase,永遠可用)。
// - mode=semantic:需 embed 模組開(Vectorize+AI binding)。未開 → 降級 keyword + capability_hint 告知缺能力(#7 發現閉環)。
// - entry_typebase 通用 filtercaller 傳任意 type,如 workflowbase 不寫死語意,workflow-discovery Q4)。
// - library:多值庫 filter(逗號分隔,portal-auth P1)。keyword 走 json_extractNULL→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' });
});