Compare commits

..

1 Commits

Author SHA1 Message Date
uncle6me-web 179dd60571 fix(kbdb): 三元組版標庫批次補存量+對外通道(Arcrun#87 二次收尾)
藏書地圖(library-map.ts)讀的是三元組 record 自己的 'library' slot(entry_values),
不是 entries.metadata_json.library——Arcrun#85 建的 POST /entries/backfill-library
補的是後者,補了地圖依然是 0。既有能補三元組 library 值的通道只有單筆
PATCH /kbdb/records/:id(b6ef0f0,已在 main),母體上千筆時逐筆呼叫不現實,也沒有
節流/冪等保護。

新增:
- kbdb/src/actions/library-backfill.ts:backfillTripletLibraryTags +
  tripletLibraryBackfillStatus,安全原則與既有 entries 版逐條對齊——呼叫端決定
  library 值+source_prefix 篩選(base 不猜語意)、冪等(NOT EXISTS 找缺 library 的
  那半)、與 entries 版/embed reconcile 共用同一顆 D69 每日 D1 寫入額度、owner_id 必填、
  寫入沿用既有 updateRecord(不手刻第二套 entry_values UPSERT SQL)。
- kbdb/src/routes/records.ts:POST /records/backfill-library + GET .../status。
- cypher-executor/src/routes/kbdb-proxy.ts:對外通道,owner_id 強制用租戶身份(同本檔
  既有 POST /kbdb/records 慣例,不信任 caller 自帶 owner_id)。

測試:kbdb 新增 5 案(含跨租戶隔離/冪等/D69 共用額度截斷/status 統計),全套
218/218 綠燈;cypher-executor 新增 5 案(租戶閘/參數驗證/轉發/owner_id 覆蓋),
兩份既有失敗(auth.test.ts ExecutionContext 型別缺陷、portal-data/portal-admin/
console-library-map-page/executor.test.ts 訊息措辭)複驗與本次改動無關(stash 前後
一致,pre-existing)。

紅線:未併 main、未部署、未對 leo21c 執行任何實際 backfill(缺安全的寫入通道——
MCP 工具集無 update/patch record 能力,CLI 全域設定指著 leo21c 不可裸跑)。
批次執行是下一步,需總管/leo 決定執行方式與時機。

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-13 11:12:56 +08:00
11 changed files with 499 additions and 283 deletions
+1 -6
View File
@@ -840,12 +840,7 @@ function taipeiMonthDay(ms) { var d = new Date(ms + TAIPEI_OFFSET_MS); return {
'<div class="kt">' + esc(l.library) + (S.library === l.library ? ' <span class="tag" style="font-size:11.5px;vertical-align:middle">搜尋中</span>' : '') + '</div>' +
'<div class="ks">' + (l.narrative ? esc(l.narrative) : '<span class="dim">(此庫尚無 narrative——recompute 時可帶入)</span>') + '</div>' +
(ents && ents.length ? '<div style="display:flex;gap:6px;flex-wrap:wrap">' + lmEntityLine(ents) + '</div>' : '') +
// Arcrun#87 三次收尾(2026-08-13):triplet_count=0 不等於這庫沒有知識——entries 有標庫、
// 只是三元組萃取沒對它跑過(kb 以外幾乎全庫皆此況)。entry_count>0 時附一句,別讓看板
// 的「0 三元組」被讀成「這庫是空的」(同 MCP 端 kbdb_map.ts 的 entryOnlyHint 同一件事)。
'<div class="km"><span class="mono" style="color:var(--amber)">' + (Number(l.triplet_count) || 0) + ' 三元組' +
(!(Number(l.triplet_count) || 0) && (Number(l.entry_count) || 0) > 0
? '<span class="dim">・' + (Number(l.entry_count) || 0) + ' 筆原始內容(尚未萃取)</span>' : '') + '</span>' +
'<div class="km"><span class="mono" style="color:var(--amber)">' + (Number(l.triplet_count) || 0) + ' 三元組</span>' +
'<span class="dim" style="margin-left:auto;font-size:12.5px">' + stamp + '</span></div></div>';
}).join('');
}
+39
View File
@@ -111,6 +111,45 @@ kbdbProxyRouter.get('/kbdb/records/by-template/:template', async (c) => {
return new Response(res.body, { status: res.status, headers: { 'Content-Type': 'application/json' } });
});
// POST /kbdb/records/backfill-library — 三元組版標庫補存量對外通道(Arcrun#87 二次收尾,2026-08-13)。
// 基本盤(kbdb/src/routes/records.ts)的 POST /records/backfill-library 缺對外通道——同
// PATCH /kbdb/records/:recordId 那條的破口(能力在 base,插件/工作流打不到)。純轉發,
// owner_id 強制用租戶身份(同本檔 POST /kbdb/records 的既有慣例,不信任 caller 自帶 owner_id)。
// 此路由必須在 '/:recordId' 之前註冊,否則 'backfill-library' 會被當成 recordId 參數。
kbdbProxyRouter.post('/kbdb/records/backfill-library', async (c) => {
const owner = tenant(c);
if (!owner) return c.json(NEED_KEY, 401);
const body = await c.req.json().catch(() => null);
if (!body || !body.library) return c.json({ error: 'library 必填' }, 400);
const { base, headers } = kbdbBase(c.env);
const res = await fetch(`${base}/records/backfill-library`, {
method: 'POST',
headers,
body: JSON.stringify({
library: body.library,
owner_id: owner,
triplet_template: body.triplet_template,
source_prefix: body.source_prefix,
limit: body.limit,
}),
});
return new Response(res.body, { status: res.status, headers: { 'Content-Type': 'application/json' } });
});
// GET /kbdb/records/backfill-library/status — 待補標統計(三元組版),owner_id 同樣強制用租戶身份。
kbdbProxyRouter.get('/kbdb/records/backfill-library/status', async (c) => {
const owner = tenant(c);
if (!owner) return c.json(NEED_KEY, 401);
const { base, headers } = kbdbBase(c.env);
const params = new URLSearchParams({ owner_id: owner });
for (const k of ['triplet_template', 'source_prefix']) {
const v = c.req.query(k);
if (v) params.set(k, v);
}
const res = await fetch(`${base}/records/backfill-library/status?${params.toString()}`, { headers });
return new Response(res.body, { status: res.status, headers: { 'Content-Type': 'application/json' } });
});
// GET /kbdb/records/:recordId — 取單筆 record。
kbdbProxyRouter.get('/kbdb/records/:recordId', async (c) => {
if (!tenant(c)) return c.json(NEED_KEY, 401);
@@ -0,0 +1,100 @@
/**
* POST /kbdb/records/backfill-library + GET .../status proxy Arcrun#87 2026-08-13
*
* kbdb/src/routes/records.ts
* backfillTripletLibraryTags record 'library' slot
* entries backfill-library metadata_json.library cypher proxy
* kbdb-records-patch-proxy.test.ts base
* /owner_id caller owner_id
* POST /kbdb/records
*
* IO KBDB kbdb-records-patch-proxy.test.ts
* 1. X-Arcrun-API-Key 401 KBDB
* 2. body library 400
* 3. owner_id 使 caller owner_id
* 4. GET statusowner_id query
*
* KBDB fetchMock hostdisableNetConnect
*/
import { SELF, fetchMock } from 'cloudflare:test';
import { beforeAll, afterEach, describe, it, expect } from 'vitest';
const KEY = { 'X-Arcrun-API-Key': 'leo', 'Content-Type': 'application/json' };
beforeAll(() => {
fetchMock.activate();
fetchMock.disableNetConnect();
});
afterEach(() => fetchMock.assertNoPendingInterceptors());
describe('POST /kbdb/records/backfill-library — 租戶閘', () => {
it('無 X-Arcrun-API-Key → 401,不碰 KBDB', async () => {
const res = await SELF.fetch('http://localhost/kbdb/records/backfill-library', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ library: 'arcrun' }),
});
expect(res.status).toBe(401);
});
});
describe('POST /kbdb/records/backfill-library — 參數驗證', () => {
it('body 沒有 library → 400,不轉發', async () => {
const res = await SELF.fetch('http://localhost/kbdb/records/backfill-library', {
method: 'POST',
headers: KEY,
body: JSON.stringify({ source_prefix: 'gitea:Leo/Arcrun@' }),
});
expect(res.status).toBe(400);
});
});
describe('POST /kbdb/records/backfill-library — 轉發', () => {
it('owner_id 一律用租戶身份覆蓋,即使 caller 自帶了別的 owner_id', async () => {
fetchMock
.get('https://kbdb.test')
.intercept({
path: '/records/backfill-library',
method: 'POST',
body: JSON.stringify({
library: 'arcrun',
owner_id: 'leo', // 來自 X-Arcrun-API-Key,不是 body 裡的 'someone-else'
triplet_template: undefined,
source_prefix: 'gitea:Leo/Arcrun@',
limit: 200,
}),
})
.reply(200, { success: true, library: 'arcrun', scanned: 208, tagged: 200, remaining: 8, quota_limit: 2000, quota_used_today: 200, quota_exceeded: true });
const res = await SELF.fetch('http://localhost/kbdb/records/backfill-library', {
method: 'POST',
headers: KEY,
body: JSON.stringify({ library: 'arcrun', owner_id: 'someone-else', source_prefix: 'gitea:Leo/Arcrun@', limit: 200 }),
});
expect(res.status).toBe(200);
const data = (await res.json()) as { success: boolean; tagged: number; remaining: number };
expect(data.success).toBe(true);
expect(data.tagged).toBe(200);
expect(data.remaining).toBe(8);
});
});
describe('GET /kbdb/records/backfill-library/status — 租戶閘 + 轉發', () => {
it('無 X-Arcrun-API-Key → 401', async () => {
const res = await SELF.fetch('http://localhost/kbdb/records/backfill-library/status?source_prefix=gitea:Leo/Arcrun@');
expect(res.status).toBe(401);
});
it('owner_id 強制用租戶身份,其餘 query 參數透傳', async () => {
fetchMock
.get('https://kbdb.test')
.intercept({ path: '/records/backfill-library/status?owner_id=leo&source_prefix=gitea%3ALeo%2FArcrun%40', method: 'GET' })
.reply(200, { success: true, pending: 8 });
const res = await SELF.fetch('http://localhost/kbdb/records/backfill-library/status?source_prefix=gitea:Leo/Arcrun@', {
headers: KEY,
});
expect(res.status).toBe(200);
const data = (await res.json()) as { success: boolean; pending: number };
expect(data.success).toBe(true);
expect(data.pending).toBe(8);
});
});
+129
View File
@@ -29,6 +29,8 @@
// 這件事不只管向量化,也要管補標,否則做標庫時就會把補算的閘繞過去」)。
import type { Bindings } from '../types';
import { maintenanceBudgetToday, addMaintenanceUsage } from './maintenance-quota';
import { updateRecord } from './record-crud';
import { ensureTripletLibrarySlot, DEFAULT_TRIPLET_TEMPLATE } from './library-map';
// IN 清單長度上限(避開 D1/SQLite bound-parameter 上限;一次點名這麼多張卡已經很夠用,
// 呼叫端清單更長就自然分批呼叫,跟 limit 分頁是同一種節奏)。
@@ -166,3 +168,130 @@ export async function libraryBackfillStatus(
.first<{ c: number }>();
return { pending: row?.c ?? 0 };
}
// ── 三元組(triplet)版:藏書地圖真正讀的那一半(Arcrun#87 二次收尾,2026-08-13)──────────
//
// 上面 backfillEntryLibraryTags 補的是 entries.metadata_json.$.library(卡片/搜尋/embed 層)。
// 藏書地圖(library-map.ts 的 recomputeLibraryMapliveTripletCountsByLibrary)讀的是**三元組
// record 自己的 'library' slot**entry_values,經 record-crud 的 updateRecord 寫入)——兩者是
// 兩個互不相干的存放處(票上 2026-08-11 14:21 comment「庫值有兩個互不相干的存放處」段已釐清),
// 補了前者地圖依然是 0。
//
// 既有通道只有「單筆 PATCH /kbdb/records/:id」(b6ef0f02026-08-11)——沒有批次版本。
// 補標母體上千筆時逐筆 PATCH 不現實(也不安全:呼叫端要自己刻節流/冪等,容易漏做)。
// 本函式是三元組版的批次 backfill,安全原則與上面 entries 版逐條對齊,不重新發明:
// - 呼叫端決定 library 值+篩選條件(base 對內容語意無知,不猜哪個 source_uri 該歸哪個庫)
// - 冪等:只選「目前沒有 library slot 值」的候選(NOT EXISTS 找缺 library 的那半,同 entries
// 版用「library 為空」而非覆蓋已標記過的)
// - D69 節流:與 entries 版、embed reconcile 共用同一顆每日 D1 寫入額度計數器(不共用會被繞過)
// - owner_id 必填(2026-08-11 leo 直令:批次改一大片既有資料不准無租戶範圍地掃)
// - 寫入沿用既有 updateRecordrecord-crud.ts)——不手刻第二套 entry_values UPSERT SQL
// 這條寫入路徑已經被 triplet-library-backfill.test.ts 驗證過語意正確(源頭順序/存量補標/
// 冪等三案),本函式只是把它包成「呼叫端給 library+source_prefix,一次處理一批」的批次版。
export interface TripletLibraryBackfillCriteria {
owner_id?: string;
triplet_template?: string; // 預設 DEFAULT_TRIPLET_TEMPLATE'triplet'
source_prefix?: string; // source_uri LIKE prefix%(本票的規則:^gitea:Leo/<repo>@ → 各庫;^kb:// → kb
}
function tripletCriteriaSql(c: TripletLibraryBackfillCriteria & { owner_id: string }): { where: string; params: unknown[] } {
// 候選:這個 template 底下、有 source_uri 值、owner 符合、source_uri 符合前綴、
// 且目前這個 record 沒有任何 'library' slot 值的 record_idNOT EXISTS 保冪等)。
const conds = [
'ev.slot_name = ?',
'e.content LIKE ? || \'%\'',
'e.owner_id = ?',
`NOT EXISTS (SELECT 1 FROM entry_values lev WHERE lev.record_id = ev.record_id AND lev.slot_name = 'library')`,
];
const params: unknown[] = ['source_uri', c.source_prefix ?? '', c.owner_id];
return { where: conds.join(' AND '), params };
}
/**
* library record target library
* + limit + budget entries /embed reconcile D1
* daemon#87 source_uri library
* recordbase backfillEntryLibraryTags
*/
export async function backfillTripletLibraryTags(
db: D1Database,
env: Pick<Bindings, 'KBDB_MAINTENANCE_DAILY_WRITE_LIMIT'>,
opts: { library: string; owner_id: string; limit?: number } & TripletLibraryBackfillCriteria,
): Promise<LibraryBackfillResult> {
const library = (opts.library ?? '').trim();
if (!library) throw new Error('library required');
const ownerId = (opts.owner_id ?? '').trim();
if (!ownerId) throw new Error('owner_id required(標庫是跨大量既有資料的批次寫入,不准無租戶範圍地掃全庫——2026-08-11 leo 直令)');
const limit = Math.min(Math.max(opts.limit ?? 100, 1), HARD_LIMIT_CAP);
const tripletTemplateName = opts.triplet_template ?? DEFAULT_TRIPLET_TEMPLATE;
// 冪等地確保 template 有 library slot(同 recomputeLibraryMap 的既有慣例,不動表)。
await ensureTripletLibrarySlot(db, tripletTemplateName);
const tpl = await db.prepare(`SELECT id FROM templates WHERE name = ?`).bind(tripletTemplateName).first<{ id: string }>();
if (!tpl) throw new Error(`triplet template not found: ${tripletTemplateName}`);
const sel = tripletCriteriaSql({ ...opts, owner_id: ownerId });
const res = await db
.prepare(
`SELECT ev.record_id AS id FROM entry_values ev JOIN entries e ON ev.entry_id = e.id
WHERE ev.template_id = ? AND ${sel.where} ORDER BY ev.record_id ASC LIMIT ?`,
)
.bind(tpl.id, ...sel.params, limit)
.all<{ id: string }>();
const scannedIds = (res.results ?? []).map((r) => r.id);
const scanned = scannedIds.length;
// D69:額度截斷——每個候選最多 1 次 D1 writeupdateRecord 對「缺 slot」的 grow 路徑正是 1 次
// INSERT),與 entries 版/reconcile 共用同一顆計數器。
const budget = await maintenanceBudgetToday(env, db);
const ids = scannedIds.slice(0, budget.remaining);
const quotaExceeded = scanned > ids.length;
let tagged = 0;
for (const id of ids) {
const updated = await updateRecord(db, id, { library });
if (updated) tagged += 1;
}
try {
await addMaintenanceUsage(db, tagged);
} catch {
// fail-open:額度計數寫入失敗不影響已經完成的標庫寫入(精神同 embed.ts 的做法)。
}
const remRow = await db
.prepare(
`SELECT COUNT(*) as c FROM entry_values ev JOIN entries e ON ev.entry_id = e.id WHERE ev.template_id = ? AND ${sel.where}`,
)
.bind(tpl.id, ...sel.params)
.first<{ c: number }>();
return {
library,
scanned,
tagged,
remaining: remRow?.c ?? 0,
quota_limit: budget.limit,
quota_used_today: budget.used + tagged,
quota_exceeded: quotaExceeded,
};
}
/** 待補標統計(三元組版,回報用):符合條件、目前未標記 library 的三元組筆數。 */
export async function tripletLibraryBackfillStatus(
db: D1Database,
opts: TripletLibraryBackfillCriteria & { owner_id: string },
): Promise<{ pending: number }> {
const tripletTemplateName = opts.triplet_template ?? DEFAULT_TRIPLET_TEMPLATE;
const tpl = await db.prepare(`SELECT id FROM templates WHERE name = ?`).bind(tripletTemplateName).first<{ id: string }>();
if (!tpl) return { pending: 0 };
const sel = tripletCriteriaSql(opts);
const row = await db
.prepare(
`SELECT COUNT(*) as c FROM entry_values ev JOIN entries e ON ev.entry_id = e.id WHERE ev.template_id = ? AND ${sel.where}`,
)
.bind(tpl.id, ...sel.params)
.first<{ c: number }>();
return { pending: row?.c ?? 0 };
}
+11 -65
View File
@@ -38,13 +38,6 @@ export interface LibraryMapRow {
narrative: string | null;
top_entities: string[]; // 全館視圖只回 top 3 名字(數百 token 內,design §4 MCP instructions 用)
triplet_count: number;
// Arcrun#87 三次收尾(2026-08-13,「藏書地圖說實話」):triplet_count=0 不等於「這庫沒有知識」——
// entriesingest 進來的原始卡片/skill 內容)與 triplet(從 entries 萃取出的三元組)是兩件事,
// 一個庫可能有大量 entries、但三元組萃取從沒對它跑過(實測:kb 以外 7 庫皆此況,entries 存在
// 且 library 標記正確,triplet 一筆都沒有)。entry_count 讓讀端(AIMCP 渲染層)分得清
// 「真的沒東西」與「有東西但還沒被萃取成三元組」,不再把後者誤讀成前者、誤判庫是空的而放棄查詢
// (見本檔 liveEntryCountsByLibrary 的計數口徑)。
entry_count: number;
updated_at: number;
}
@@ -57,7 +50,6 @@ export interface LibraryMapDetail {
relation_profile: RelationStat[];
bridges: Bridge[];
triplet_count: number;
entry_count: number; // 同 LibraryMapRow.entry_count(見該欄位註解)
commit_hash: string | null;
status: string;
updated_at: number;
@@ -293,10 +285,6 @@ export async function recomputeLibraryMap(db: D1Database, input: RecomputeInput)
superseded.push(row.rid);
}
// entry_countArcrun#87 三次收尾):recompute 只重算三元組那一半,entry_count 是即時算的
// 另一半,兩者同樣的道理一起回傳(見 LibraryMapDetail.entry_count 欄位註解)。
const entryCount = (await liveEntryCountsByLibrary(db, owner)).get(library) ?? 0;
return {
map: {
record_id: blockEntry.id,
@@ -307,7 +295,6 @@ export async function recomputeLibraryMap(db: D1Database, input: RecomputeInput)
relation_profile: relationProfile,
bridges,
triplet_count: tripletCount,
entry_count: entryCount,
commit_hash: input.commit_hash ?? null,
status: 'active',
updated_at: blockEntry.created_at,
@@ -380,34 +367,6 @@ async function liveTripletCountsByLibrary(
return m;
}
// 這個 owner 底下、依 entries 自己的 metadata_json.$.library 分組的即時「原始內容」數
//Arcrun#87 三次收尾,2026-08-13)——與 liveTripletCountsByLibrary 算的是兩張完全不同的帳:
// 那個算「萃取出幾條三元組」,這個算「ingest 進來幾筆原始卡片/skill/block」。兩者天生會不一樣
// (萃取是下游、有延遲甚至從沒對某些庫跑過),刻意分開算、分開回傳,不是同一數字的兩種寫法。
//
// 排除口徑(都是結構性排除,不是內容語意判斷——沒有違反「base 對內容語意無知」):
// - entry_type='value'record-crud.ts 的通用機制,任何 template(含 triplet 本身)建 record
// 時每個 slot 值都會落一筆這種 entry,是儲存實作細節,不是使用者看得到的「一筆知識」。
// - metadata.kind='library_map' 的 block:地圖自己每次 recompute 產出的摘要 block(含歷史
// superseded 的),算進去會自我膨脹、且無限接近雞生蛋——地圖不該把自己算進地圖裡。
async function liveEntryCountsByLibrary(db: D1Database, owner_id?: string): Promise<LibraryCountMap> {
const params: unknown[] = owner_id ? [owner_id] : [];
const res = await db
.prepare( // kbdb-sql-ok:牆內本體(kbdb/src/actions/),checkout 開在巢狀 worktree /private/tmp/wt-arcrun-library-map-honesty-87/(同 962d863/5919c6b 已記載的假警報成因:hook 逐字比對 matrix/arcrun/kbdb/src/ 吃不到中間多出的 worktree 目錄層,非繞牆)
`SELECT COALESCE(NULLIF(json_extract(metadata_json, '$.library'), ''), 'general') AS library,
COUNT(*) AS n
FROM entries
WHERE ${owner_id ? 'owner_id = ? AND ' : ''}entry_type != 'value'
AND NOT (entry_type = 'block' AND COALESCE(json_extract(metadata_json, '$.kind'), '') = 'library_map')
GROUP BY COALESCE(NULLIF(json_extract(metadata_json, '$.library'), ''), 'general')`,
)
.bind(...params)
.all<{ library: string; n: number }>();
const m: LibraryCountMap = new Map();
for (const r of res.results ?? []) m.set(r.library, r.n);
return m;
}
// 「已知庫名」集合:即使目前三元組數是 0,只要蓋過章(entries metadata.libraryt52 慣例)或
// 登記過(portal_library record),就不算「查無此庫」——用來分辨 GET /map/:library 的
// 「這庫是空的」(回 200triplet_count:0vs「查無此庫」(回 404)。kbdb base 對 portal_library
@@ -494,26 +453,20 @@ interface MapPivotRow {
ts: number;
}
// 全館地圖:每庫一行(librarynarrativetop 3 entitiestriplet_countentry_count),MCP
// instructions 直接嵌用(設計上限=數百 token,R3)。template 還不存在(從未 recompute)→
// 誠實回空清單。entry_count 與 triplet_count 並排回傳(Arcrun#87 三次收尾):一個查「原始內容
// 有沒有」、一個查「萃取出幾條三元組」,兩者不同源、允許不一致(見 liveEntryCountsByLibrary
// 註解),讀端不該把其中一個的 0 當成另一個的答案。
// 全館地圖:每庫一行(librarynarrativetop 3 entitiestriplet_count),MCP instructions
// 直接嵌用(設計上限=數百 token,R3)。template 還不存在(從未 recompute)→ 誠實回空清單。
export async function listLibraryMaps(db: D1Database, owner_id?: string): Promise<LibraryMapRow[]> {
const tpl = await getTemplate(db, LIBRARY_MAP_TEMPLATE_NAME);
if (!tpl) return [];
const params: unknown[] = owner_id ? [tpl.id, owner_id] : [tpl.id];
const [res, entryCounts] = await Promise.all([
db
.prepare( // kbdb-sql-ok:牆內本體(kbdb/src/actions/),既有查詢(listLibraryMaps 原本就有)此次改包進 Promise.all 才重新觸發掃描,非新增違規;worktree 路徑假警報同上方 liveEntryCountsByLibrary 註解
`WITH m AS (${mapPivotSql(!!owner_id)})
SELECT * FROM m WHERE COALESCE(m.status, 'active') = 'active' AND m.library IS NOT NULL
ORDER BY m.ts DESC`,
)
.bind(...params)
.all<MapPivotRow>(),
liveEntryCountsByLibrary(db, owner_id),
]);
const res = await db
.prepare(
`WITH m AS (${mapPivotSql(!!owner_id)})
SELECT * FROM m WHERE COALESCE(m.status, 'active') = 'active' AND m.library IS NOT NULL
ORDER BY m.ts DESC`,
)
.bind(...params)
.all<MapPivotRow>();
// 每庫只留最新 activesupersede 失敗殘留多個 active 時,讀端自癒取最新——順序安全的另一半)。
const byLib = new Map<string, LibraryMapRow>();
for (const r of res.results ?? []) {
@@ -523,7 +476,6 @@ export async function listLibraryMaps(db: D1Database, owner_id?: string): Promis
narrative: r.narrative || null,
top_entities: parseJsonArray<TopEntity>(r.top_entities).slice(0, 3).map((t) => t.name),
triplet_count: Number(r.triplet_count ?? 0) || 0,
entry_count: entryCounts.get(r.library) ?? 0,
updated_at: r.ts,
});
}
@@ -549,12 +501,7 @@ export async function getLibraryMapDetail(
.first<MapPivotRow>();
if (!row) return null;
// record_idmap block entry idrecompute 寫入時綁定);entry 若被外力刪除,content 誠實回 null。
// entry_count 與全館視圖(listLibraryMaps)同一套計法(liveEntryCountsByLibrary),單庫詳圖
// 只取自己那一庫的數字——Arcrun#87 三次收尾,理由見 LibraryMapDetail.entry_count 欄位註解。
const [blockEntry, entryCounts] = await Promise.all([
getEntry(db, row.rid),
liveEntryCountsByLibrary(db, owner_id),
]);
const blockEntry = await getEntry(db, row.rid);
return {
record_id: row.rid,
library,
@@ -564,7 +511,6 @@ export async function getLibraryMapDetail(
relation_profile: parseJsonArray<RelationStat>(row.relation_profile),
bridges: parseJsonArray<Bridge>(row.bridges),
triplet_count: Number(row.triplet_count ?? 0) || 0,
entry_count: entryCounts.get(library) ?? 0,
commit_hash: row.commit_hash || null,
status: row.status ?? 'active',
updated_at: row.ts,
+49
View File
@@ -2,6 +2,7 @@
import { Hono } from 'hono';
import type { Bindings } from '../types';
import { createRecord, deleteRecord, getRecord, searchByTemplate, updateRecord } from '../actions/record-crud';
import { backfillTripletLibraryTags, tripletLibraryBackfillStatus } from '../actions/library-backfill';
export const recordRoutes = new Hono<{ Bindings: Bindings }>();
@@ -57,6 +58,54 @@ recordRoutes.get('/by-template/:template', async (c) => {
return c.json({ success: true, records, count: records.length });
});
// POST /records/backfill-library — 三元組版標庫補存量(Arcrun#87 二次收尾,2026-08-13)。
// entries 版(POST /entries/backfill-libraryArcrun#85)補的是卡片層 metadata_json.library
// 藏書地圖讀的是三元組 record 自己的 'library' slot,兩者是不同存放處——本端點補後者,
// 地圖(GET /map)才會真的從 0 變成有意義的數字。
// body(必填 library + owner_id):{ library, owner_id, triplet_template?(預設 'triplet',
// source_prefix?(必要篩選:本票的規則=gitea:Leo/<repo>@ 前綴 → 對應庫;kb:// → 'kb',
// limit?(1-500,預設100) }。
// 冪等:只選「目前沒有 library slot 值」的三元組;分批:單次 limit 上限,remaining>0 → 重複呼叫直到 0。
// budget:與 /entries/backfill-library、/embed/reconcile 共用同一顆每日 D1 寫入額度(D69)。
// 此路由必須在 '/:recordId' 之前註冊,否則 'backfill-library' 會被當成 recordId 參數。
recordRoutes.post('/backfill-library', async (c) => {
const body = (await c.req.json().catch(() => ({}))) as {
library?: string;
owner_id?: string;
triplet_template?: string;
source_prefix?: 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 backfillTripletLibraryTags(c.env.DB, c.env, {
library,
owner_id: ownerId,
triplet_template: body.triplet_template || undefined,
source_prefix: body.source_prefix || 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 /records/backfill-library/status?owner_id=&triplet_template=&source_prefix=
// — 符合條件、目前未標記 library 的三元組筆數(backfill 前後都能查,判斷還剩多少)。
recordRoutes.get('/backfill-library/status', async (c) => {
const ownerId = c.req.query('owner_id') || '';
if (!ownerId) return c.json({ success: false, error: 'owner_id 必填' }, 400);
const status = await tripletLibraryBackfillStatus(c.env.DB, {
owner_id: ownerId,
triplet_template: c.req.query('triplet_template') || undefined,
source_prefix: c.req.query('source_prefix') || undefined,
});
return c.json({ success: true, ...status });
});
// GET /records/:recordId
recordRoutes.get('/:recordId', async (c) => {
const rec = await getRecord(c.env.DB, c.req.param('recordId'));
-57
View File
@@ -361,63 +361,6 @@ describe('M3 收尾 — 即時新鮮度(ensureFreshLibraryMaps,讀端自動
expect(unknown.status).toBe(404); // 真的從沒出現過的名字才 404
});
it('entry_count 讓「有原始內容但三元組從沒萃取過」與「真的什麼都沒有」分得清(Arcrun#87 三次收尾)', async () => {
// 情境沿用上一案的 'hr' 庫(entries 蓋過章、triplet_count:0),這正是 leo21c 實測到的真實
// 現況(kb 以外 7 庫皆此況):地圖過去只回 triplet_count,AI 讀到 0 就誤判「這庫沒有知識」,
// 但實際上 kbdb_search 找得到內容——因為 entries 一直都在,只是沒被萃取成三元組。
const db = makeSqliteD1();
await seedTripletTemplate(db);
await ensureTripletLibrarySlot(db, 'triplet');
await createEntry(db, {
content: '人資資料 A',
entry_type: 'block',
owner_id: 'leo',
metadata_json: JSON.stringify({ library: 'hr' }),
});
await createEntry(db, {
content: '人資資料 B',
entry_type: 'block',
owner_id: 'leo',
metadata_json: JSON.stringify({ library: 'hr' }),
});
const { app, env } = makeApp(db);
// 單庫詳圖:triplet_count 仍是 0(沒騙這件事),但 entry_count 誠實回 2。
const detailRes = await app.request('/map/hr?owner_id=leo', {}, env);
const detailBody = (await detailRes.json()) as { map: { triplet_count: number; entry_count: number } };
expect(detailBody.map.triplet_count).toBe(0);
expect(detailBody.map.entry_count).toBe(2);
// 全館視圖:同一個庫、同一組數字,兩個 consumerGET /map、GET /map/:library)不能對不上。
const listRes = await app.request('/map?owner_id=leo', {}, env);
const listBody = (await listRes.json()) as { libraries: { library: string; triplet_count: number; entry_count: number }[] };
const hr = listBody.libraries.find((l) => l.library === 'hr');
expect(hr).toBeDefined();
expect(hr!.triplet_count).toBe(0);
expect(hr!.entry_count).toBe(2);
});
it('entry_count 的計數口徑排除 value 型 entries 與地圖自己的歷史摘要 block(不自我膨脹)', async () => {
// record-crud 建 triplet record 時,每個 slot 值都會落一筆 entry_type='value' 的 entry
// (儲存實作細節,不是「一筆知識」);地圖 recompute 也會建 entry_type='block' 且
// metadata.kind='library_map' 的摘要 entry——兩者都不該被算進 entry_count,否則地圖會把
// 自己的內部管線雜訊當成「使用者知識」回報,數字沒有意義。
const db = makeSqliteD1();
await seedTripletTemplate(db);
await ensureTripletLibrarySlot(db, 'triplet');
// 建一條 kb 三元組 → 連帶產生數筆 entry_type='value' 的 entries(不該被算進 entry_count)。
await seedTriplet(db, { s: 'A', p: '連結至', o: 'B', library: 'kb' });
// 觸發一次 recompute → 產生一筆 entry_type='block'/metadata.kind='library_map' 的摘要 entry
// (同樣不該被算進 entry_count)。
await recomputeLibraryMap(db, { library: 'kb' });
const detail = await getLibraryMapDetail(db, 'kb');
// 這個情境下 kb 沒有任何「真的 ingest 進來的原始內容」entry(只有 value 碎片與地圖自己的摘要),
// entry_count 應誠實回 0——不能因為底層 entries 表其實有好幾筆就報出一個誤導的非零數字。
expect(detail!.entry_count).toBe(0);
expect(detail!.triplet_count).toBe(1); // 對照:triplet_count 不受這個排除規則影響,維持原樣。
});
it('owner 隔離:即時新鮮度層不會把別的 owner 的三元組算進來', async () => {
const db = makeSqliteD1();
await seedTripletTemplate(db);
@@ -0,0 +1,164 @@
// 三元組版標庫「批次」backfillArcrun#87 二次收尾,2026-08-13)測試。
//
// 背景:kbdb/tests/triplet-library-backfill.test.ts2026-08-11)已經證明「逐筆 updateRecord
// 補標庫」這條寫入路徑語意正確(源頭順序/存量補標/冪等三案)。但那份測試操作的是逐筆手刻的
// backfillPass() helper,不是可對外呼叫的批次能力——通道只有單筆 PATCH /kbdb/records/:id
// (b6ef0f0),母體上千筆時逐筆 PATCH 給呼叫端自己刻節流/冪等不現實。本檔驗證新加的
// backfillTripletLibraryTagskbdb/src/actions/library-backfill.ts):把「呼叫端給 library
// + source_prefix,一次處理一批」包成一個安全、節流的函式,語意與 entries 版
// library-backfill.test.ts)逐條對齊。
//
// 覆蓋:
// 1. 只補「符合 source_prefix、目前未標記 library」的候選三元組;已標記的不動(冪等)
// 2. owner_id 必填(缺了要拋錯,同 2026-08-11 leo 直令)
// 3. 與 entries 版 backfillEntryLibraryTags 共用同一顆每日 D1 寫入額度(D69:兩者都要受管,
// 任一邊獨立跑就是把另一邊的閘繞過去)
// 4. status 欄位存在的 record 也一起處理(backfill 不管 active/superseded,那是地圖聚合層的
// 篩選責任,不是補標層的責任——見 library-map.ts withLib 的 COALESCE 判準)
//
// 測試手法沿 triplet-library-backfill.test.tslibrary-backfill.test.ts 慣例:真 node:sqlite
// 套 migrations/0001_base.sql 原檔(本檔只用得到 templates/entries/entry_values 三張表,
// 不涉 library_map,不需 0003),kbdb-sql-ok 行尾標記同既有慣例(測試治具本身,非牆外業務邏輯繞過 API)。
import { describe, it, expect } from 'vitest';
import { DatabaseSync } from 'node:sqlite';
import { readFileSync } from 'node:fs';
import { backfillTripletLibraryTags, tripletLibraryBackfillStatus } from '../src/actions/library-backfill';
import { backfillEntryLibraryTags } from '../src/actions/library-backfill';
import { createTemplate, createRecord, getRecord } from '../src/actions/record-crud';
import type { Bindings } from '../src/types';
function makeSqliteD1(): D1Database {
const raw = new DatabaseSync(':memory:');
raw.exec(readFileSync(new URL('../migrations/0001_base.sql', import.meta.url), 'utf8')); // kbdb-sql-ok:測試治具(node:sqlite→D1 shim)套 migration 原檔
function stmt(sql: string, params: unknown[]) {
const s = {
bind(...args: unknown[]) { return stmt(sql, args); },
async all<T>() { return { results: raw.prepare(sql).all(...params) as T[] }; }, // kbdb-sql-ok:測試治具(node:sqlite→D1 shim
async first<T>() { return (raw.prepare(sql).get(...params) ?? null) as T | null; }, // kbdb-sql-ok:測試治具(node:sqlite→D1 shim
async run() {
const r = raw.prepare(sql).run(...params); // kbdb-sql-ok:測試治具(node:sqlite→D1 shim),非牆外業務邏輯繞過 API
return { success: true, meta: { changes: r.changes } };
},
};
return s;
}
return { prepare: (sql: string) => stmt(sql, []) } as unknown as D1Database;
}
function makeEnv(db: D1Database, opts: { maintenanceLimit?: string } = {}): Bindings {
return {
DB: db,
ENVIRONMENT: 'test',
KBDB_MAINTENANCE_DAILY_WRITE_LIMIT: opts.maintenanceLimit,
} as unknown as Bindings;
}
// prod 實際 triplet template 的 slotslibrary-map.test.tstriplet-library-backfill.test.ts 同款常數)。
const PROD_TRIPLET_SLOTS = [
'subject', 'predicate', 'object', 'source_block_id', 'confidence', 'clusters_json',
'bridge_score', 'subject_entity_type', 'object_entity_type', 'status', 'superseded_by',
'source_uri', 'content_hash', 'source_anchor', 'predicate_embed',
];
async function seedTripletTemplate(db: D1Database): Promise<void> {
await createTemplate(db, { id: 'tpl-triplet-test', name: 'triplet', slots: PROD_TRIPLET_SLOTS, created_by: 'kbdb-graph' });
}
describe('backfillTripletLibraryTags — 批次補三元組的 library slot(藏書地圖真正讀的那一半)', () => {
it('只補符合 source_prefix、目前缺 library 的三元組;owner 不符/已標記過的不動', async () => {
const db = makeSqliteD1();
await seedTripletTemplate(db);
const env = makeEnv(db);
const r1 = await createRecord(db, { template: 'triplet', values: { subject: 'A', predicate: 'r', object: 'B', source_uri: 'gitea:Leo/Arcrun@a.md' }, owner_id: 'bfezv28v' });
const r2 = await createRecord(db, { template: 'triplet', values: { subject: 'C', predicate: 'r', object: 'D', source_uri: 'gitea:Leo/Arcrun@b.md' }, owner_id: 'bfezv28v' });
// 別的 owner,同前綴——不該被補到(跨租戶隔離)。
const r3 = await createRecord(db, { template: 'triplet', values: { subject: 'E', predicate: 'r', object: 'F', source_uri: 'gitea:Leo/Arcrun@c.md' }, owner_id: 'someone-else' });
// 不符 source_prefix——不該被補到。
const r4 = await createRecord(db, { template: 'triplet', values: { subject: 'G', predicate: 'r', object: 'H', source_uri: 'gitea:Leo/mira@d.md' }, owner_id: 'bfezv28v' });
const result = await backfillTripletLibraryTags(db, env, {
library: 'arcrun',
owner_id: 'bfezv28v',
source_prefix: 'gitea:Leo/Arcrun@',
});
expect(result.scanned).toBe(2);
expect(result.tagged).toBe(2);
expect(result.remaining).toBe(0);
const rec1 = await getRecord(db, r1.record_id);
const rec2 = await getRecord(db, r2.record_id);
const rec3 = await getRecord(db, r3.record_id);
const rec4 = await getRecord(db, r4.record_id);
expect(rec1!.values.library).toBe('arcrun');
expect(rec2!.values.library).toBe('arcrun');
expect(rec3!.values.library).toBeUndefined(); // 別的 owner,沒被動到
expect(rec4!.values.library).toBeUndefined(); // 不符前綴,沒被動到
});
it('冪等:已標記過 library 的三元組不會被第二輪 touch(不會覆蓋成別的值)', async () => {
const db = makeSqliteD1();
await seedTripletTemplate(db);
const env = makeEnv(db);
const r1 = await createRecord(db, { template: 'triplet', values: { subject: 'A', predicate: 'r', object: 'B', source_uri: 'gitea:Leo/kb@a.md' }, owner_id: 'bfezv28v' });
const first = await backfillTripletLibraryTags(db, env, { library: 'kb', owner_id: 'bfezv28v', source_prefix: 'gitea:Leo/kb@' });
expect(first.tagged).toBe(1);
// 第二輪同條件再跑一次:這筆已經有 library 值了,不該再被選中,即使再給一個不同的 library 值。
const second = await backfillTripletLibraryTags(db, env, { library: 'something-else', owner_id: 'bfezv28v', source_prefix: 'gitea:Leo/kb@' });
expect(second.scanned).toBe(0);
expect(second.tagged).toBe(0);
const rec1 = await getRecord(db, r1.record_id);
expect(rec1!.values.library).toBe('kb'); // 沒被第二輪的 'something-else' 洗掉
});
it('owner_id 必填 — 缺了直接拋錯,不准無租戶範圍地掃全庫', async () => {
const db = makeSqliteD1();
await seedTripletTemplate(db);
const env = makeEnv(db);
await expect(backfillTripletLibraryTags(db, env, { library: 'arcrun', owner_id: '' })).rejects.toThrow(/owner_id required/);
});
it('與 entries 版 backfillEntryLibraryTags 共用同一顆每日 D1 寫入額度(D69:任一邊都不能繞過去)', async () => {
const db = makeSqliteD1();
await seedTripletTemplate(db);
const env = makeEnv(db, { maintenanceLimit: '3' }); // 每日只剩 3 次背景維護寫入額度
// entries 版先用掉 2 筆額度。
await db.prepare(`INSERT INTO entries (id, content, entry_type, owner_id, created_at, updated_at) VALUES (?, 'x', 'block', 'bfezv28v', 1000, 1000)`).bind('e1').run(); // kbdb-sql-ok:測試治具灌資料
await db.prepare(`INSERT INTO entries (id, content, entry_type, owner_id, created_at, updated_at) VALUES (?, 'x', 'block', 'bfezv28v', 1000, 1000)`).bind('e2').run(); // kbdb-sql-ok:測試治具灌資料
const entriesResult = await backfillEntryLibraryTags(db, env, { library: 'notes', owner_id: 'bfezv28v' });
expect(entriesResult.tagged).toBe(2);
// 三元組版此時只剩 1 次額度可用,即使候選有 2 筆,也只能補 1 筆、如實回報 quota_exceeded。
await createRecord(db, { template: 'triplet', values: { subject: 'A', predicate: 'r', object: 'B', source_uri: 'gitea:Leo/Arcrun@a.md' }, owner_id: 'bfezv28v' });
await createRecord(db, { template: 'triplet', values: { subject: 'C', predicate: 'r', object: 'D', source_uri: 'gitea:Leo/Arcrun@b.md' }, owner_id: 'bfezv28v' });
const tripletResult = await backfillTripletLibraryTags(db, env, { library: 'arcrun', owner_id: 'bfezv28v', source_prefix: 'gitea:Leo/Arcrun@' });
expect(tripletResult.scanned).toBe(2);
expect(tripletResult.tagged).toBe(1); // 只剩 1 額度,被截斷
expect(tripletResult.quota_exceeded).toBe(true);
expect(tripletResult.remaining).toBe(1); // 還有 1 筆沒補到
});
it('tripletLibraryBackfillStatus 回報還剩多少待補(backfill 前後都能查)', async () => {
const db = makeSqliteD1();
await seedTripletTemplate(db);
const env = makeEnv(db);
await createRecord(db, { template: 'triplet', values: { subject: 'A', predicate: 'r', object: 'B', source_uri: 'gitea:Leo/notes@a.md' }, owner_id: 'bfezv28v' });
await createRecord(db, { template: 'triplet', values: { subject: 'C', predicate: 'r', object: 'D', source_uri: 'gitea:Leo/notes@b.md' }, owner_id: 'bfezv28v' });
const before = await tripletLibraryBackfillStatus(db, { owner_id: 'bfezv28v', source_prefix: 'gitea:Leo/notes@' });
expect(before.pending).toBe(2);
await backfillTripletLibraryTags(db, env, { library: 'notes', owner_id: 'bfezv28v', source_prefix: 'gitea:Leo/notes@' });
const after = await tripletLibraryBackfillStatus(db, { owner_id: 'bfezv28v', source_prefix: 'gitea:Leo/notes@' });
expect(after.pending).toBe(0);
});
});
+3 -22
View File
@@ -27,10 +27,6 @@ export interface LibraryMapRow {
narrative: string | null;
top_entities: unknown; // 正常是 string[];防禦:舊部署可能回 JSON 字串形
triplet_count: number | string;
// Arcrun#87 三次收尾(2026-08-13,「藏書地圖說實話」):triplet_count=0 不等於這庫沒有知識——
// 可能只是三元組萃取從沒對它跑過,但 entries(原始 ingest 內容)還在,kbdb_search 找得到。
// 選填是因為舊部署(未帶這次修法)的 kbdb base 回應裡不會有這個欄位,容錯當 0。
entry_count?: number | string;
updated_at?: number;
}
@@ -69,15 +65,7 @@ export function entityNames(raw: unknown, limit: number): string[] {
const MAX_LIBRARY_LINES = 30;
const MAX_NARRATIVE_CHARS = 60;
/**
* GET /map libraries[] design §4 null
*
* Arcrun#87 2026-08-13 session MCP
* triplet_countkb 0 triplets
* session 7 entries ingest
* kbdb_search triplet_count0
* entry_count0
*/
/** 把 GET /map 的 libraries[] 渲染成緊湊文字(每庫一行,design §4 指定格式)。空清單回 null。 */
export function renderLibraryMapLines(libraries: LibraryMapRow[]): string | null {
const rows = libraries.filter((l) => l && typeof l.library === "string" && l.library);
if (rows.length === 0) return null;
@@ -86,15 +74,8 @@ export function renderLibraryMapLines(libraries: LibraryMapRow[]): string | null
const clipped =
narrative.length > MAX_NARRATIVE_CHARS ? `${narrative.slice(0, MAX_NARRATIVE_CHARS)}` : narrative;
const core = entityNames(l.top_entities, 3);
const tripletCount = Number(l.triplet_count ?? 0) || 0;
const entryCount = Number(l.entry_count ?? 0) || 0;
const countPart =
tripletCount > 0
? `${tripletCount} triplets`
: entryCount > 0
? `0 triplets${entryCount} 筆原始內容(尚未萃取關係,kbdb_search 查得到)`
: `0 triplets0 內容`;
return `- ${l.library}${clipped}|核心:${core.length ? core.join("、") : "(尚無)"}${countPart}`;
const count = Number(l.triplet_count ?? 0) || 0;
return `- ${l.library}${clipped}|核心:${core.length ? core.join("、") : "(尚無)"}${count} triplets`;
});
const omitted = rows.length > MAX_LIBRARY_LINES ? `\n(其餘 ${rows.length - MAX_LIBRARY_LINES} 庫略,kbdb_get_map 可看全部)` : "";
return lines.join("\n") + omitted;
+3 -37
View File
@@ -5,20 +5,13 @@
* MCP KBDB service bindingkbdbFetch GET /map/map/:library
* D1 binding #68 kbdb_graph_neighbors D17 KBDB MCP kbdb_*
*
* kbdb/src/routes/map.tsM2 mergeentry_count Arcrun#87
* kbdb/src/routes/map.tsM2 merge
* GET /map { success, libraries:[{library, narrative, top_entities( top3),
* triplet_count, entry_count, updated_at}], count }
* triplet_count, updated_at}], count }
* GET /map/:library { success, map:{record_id, library, narrative, content, top_entities,
* relation_profile, bridges, triplet_count, entry_count, commit_hash,
* status, updated_at} }
* relation_profile, bridges, triplet_count, commit_hash, status, updated_at} }
* 404 { success:false, error:'not found' } recompute
*
* entry_count vs triplet_count
* triplet_countentry_countingest /skill/block
* 0 0 triplet_count0
* entry_count0 kbdb_search triplet_count0
*
*
* slot top_entities/relation_profile/bridges JSON base parse
* live parse crash
*/
@@ -66,23 +59,11 @@ interface LibraryMapDetail {
relation_profile?: unknown;
bridges?: unknown;
triplet_count?: number | string;
// Arcrun#87 三次收尾:同 lib/library-map.ts LibraryMapRow.entry_count 的欄位(見該檔註解)。
entry_count?: number | string;
commit_hash?: string | null;
status?: string;
updated_at?: number;
}
/** triplet_count0 但 entry_count0 時給的提示(別讓「0 triplets」被讀成「這庫沒有知識」)。 */
function entryOnlyHint(tripletCount: number, entryCount: number, library?: string): string[] {
if (tripletCount > 0 || entryCount <= 0) return [];
const lib = library ? `${library}` : "這個庫";
return [
`${lib} triplet_count0,但有 ${entryCount} 筆原始內容(entries)——三元組萃取還沒對它跑過,` +
"不代表沒有知識。用 kbdb_search(關鍵字或語義)直接查得到內容。",
];
}
/**
* kbdb_get_map library
* design §6 retrieval get_map(library) graph/search
@@ -132,7 +113,6 @@ export function registerGetMap(server: McpServer, env: Env, identity: KnowledgeI
// 防禦:top_entities 若是 JSON 字串形就 parse 成名字清單(失敗當空,誠實不 crash)。
top_entities: entityNames(l.top_entities, 3),
triplet_count: Number(l.triplet_count ?? 0) || 0,
entry_count: Number(l.entry_count ?? 0) || 0,
}));
if (libraries.length === 0) {
// 空庫誠實回報:不是錯誤(端點正常)。地圖是讀時即時核對重算的(見 RECOMPUTE_HINTS
@@ -148,21 +128,9 @@ export function registerGetMap(server: McpServer, env: Env, identity: KnowledgeI
...RECOMPUTE_HINTS,
]);
}
// Arcrun#87 三次收尾:某些庫 triplet_count0 但 entry_count0entries 有、三元組
// 萃取沒跑過)——這件事只在「這種庫真的存在」時才提一次,不是每個庫都印一行(避免洗版),
// 讓讀這份地圖的 session 知道「觸目所及的 0」不能直接當成「沒有知識」。
const entryOnlyLibs = libraries.filter(
(l) => (l.triplet_count ?? 0) === 0 && (l.entry_count ?? 0) > 0,
);
return successResponse({ libraries, count: libraries.length }, [
"要看某庫細節:kbdb_get_map(library='庫名')",
"進庫查內容:kbdb_search(關鍵字/語義);查關係:kbdb_graph_neighbors",
...(entryOnlyLibs.length > 0
? [
`${entryOnlyLibs.map((l) => l.library).join("、")} 這幾庫 triplet_count0 但 entry_count0` +
"有原始內容,只是還沒萃取出三元組關係——別把 0 triplets 讀成「沒有知識」,直接 kbdb_search 進去查。",
]
: []),
]);
}
@@ -199,12 +167,10 @@ export function registerGetMap(server: McpServer, env: Env, identity: KnowledgeI
relation_profile: parseSlotArray<{ predicate: string; count: number }>(raw.relation_profile),
bridges: parseSlotArray<{ entity: string; libraries: string[] }>(raw.bridges),
triplet_count: Number(raw.triplet_count ?? 0) || 0,
entry_count: Number(raw.entry_count ?? 0) || 0,
};
return successResponse({ map }, [
"bridges=此庫 entity 同時出現在哪些其他庫(只有兩側三元組都標了 library 值才抓得到,舊資料若沒標會偏稀疏,是誠實現況不是 bug)",
"沿核心 entity 挖關係:kbdb_graph_neighbors(subject=entity 名)",
...entryOnlyHint(map.triplet_count, map.entry_count, library),
]);
} catch (e) {
return errorResponse("internal_error", e instanceof Error ? e.message : String(e), ["稍後重試"]);
-96
View File
@@ -121,46 +121,6 @@ describe("kbdb_get_map: 全館地圖(無參數)", () => {
expect(calls[0].url.searchParams.get("owner_id")).toBe("leo");
});
it("Arcrun#87 三次收尾:triplet_count=0 但 entry_count>0 的庫附上「別當成沒有知識」的提示", async () => {
// leo21c 實測現況:kb 以外幾乎每庫都長這樣——entries 有(且 library 標對),三元組萃取沒跑過。
const emptyTripletRow = {
library: "arcrun",
narrative: null,
top_entities: [],
triplet_count: 0,
entry_count: 15,
updated_at: 1786330534,
};
const { server, tools } = makeServer();
const { env } = makeEnv(
() => new Response(JSON.stringify({ success: true, libraries: [emptyTripletRow], count: 1 })),
);
registerGetMap(server, env, SERVICE);
const res = await tools.get("kbdb_get_map")!.handler({});
const body = parseResult(res);
const data = body.data as { libraries: { library: string; triplet_count: number; entry_count: number }[] };
expect(data.libraries[0].entry_count).toBe(15);
expect(data.libraries[0].triplet_count).toBe(0);
// 提示要點名該庫、講清楚「有內容只是沒萃取」,別讓 AI 讀到 0 triplets 就跳過這個庫。
const hints = (body.hints as string[]).join(" ");
expect(hints).toContain("arcrun");
expect(hints).toContain("entry_count");
expect(hints).toContain("kbdb_search");
});
it("entry_count 缺席(舊部署未帶此欄位)→ 容錯當 0,不 crash、不誤發提示", async () => {
const legacyRow = { ...KB_ROW }; // KB_ROW 本身沒有 entry_count 欄位(模擬舊部署回應)
const { server, tools } = makeServer();
const { env } = makeEnv(
() => new Response(JSON.stringify({ success: true, libraries: [legacyRow], count: 1 })),
);
registerGetMap(server, env, SERVICE);
const res = await tools.get("kbdb_get_map")!.handler({});
const body = parseResult(res);
const data = body.data as { libraries: { entry_count: number }[] };
expect(data.libraries[0].entry_count).toBe(0);
});
it("top_entities in JSON-string form is parsed defensively (live-observed slot shape)", async () => {
const { server, tools } = makeServer();
const row = {
@@ -253,39 +213,6 @@ describe("kbdb_get_map: 單庫詳圖(library 參數)", () => {
expect(map.bridges).toEqual([{ entity: "Gitea", libraries: ["notes"] }]);
});
it("Arcrun#87 三次收尾:單庫詳圖 triplet_count=0、entry_count>0 → 附「別當空庫」提示", async () => {
const emptyTripletDetail = {
...DETAIL,
library: "arcrun",
triplet_count: 0,
entry_count: 15,
top_entities: [],
relation_profile: [],
bridges: [],
};
const { server, tools } = makeServer();
const { env } = makeEnv(() => new Response(JSON.stringify({ success: true, map: emptyTripletDetail })));
registerGetMap(server, env, SERVICE);
const res = await tools.get("kbdb_get_map")!.handler({ library: "arcrun" });
const body = parseResult(res);
const map = (body.data as { map: { entry_count: number; triplet_count: number } }).map;
expect(map.triplet_count).toBe(0);
expect(map.entry_count).toBe(15);
const hints = (body.hints as string[]).join(" ");
expect(hints).toContain("arcrun");
expect(hints).toContain("kbdb_search");
});
it("triplet_count>0 的庫不附「別當空庫」提示(不需要時別洗版)", async () => {
const { server, tools } = makeServer();
const { env } = makeEnv(() => new Response(JSON.stringify({ success: true, map: DETAIL }))); // DETAIL.triplet_count=111
registerGetMap(server, env, SERVICE);
const res = await tools.get("kbdb_get_map")!.handler({ library: "kb" });
const body = parseResult(res);
const hints = (body.hints as string[]).join(" ");
expect(hints).not.toContain("entry_count");
});
it("JSON-string slot values are parsed into objects(任務規格); broken JSON → 空陣列", async () => {
const { server, tools } = makeServer();
const raw = {
@@ -416,29 +343,6 @@ describe("renderLibraryMapLines", () => {
it("empty input → null", () => {
expect(renderLibraryMapLines([])).toBeNull();
});
it("Arcrun#87 三次收尾:triplet_count=0 但 entry_count>0 → 開場那行不再讀成「這庫沒有知識」", () => {
// 這是 leo21c 的實際現況(kb 以外 7 庫皆此況)——這段渲染出的文字是全新 session 連上 MCP
// 第一眼看到的地圖,過去只印「0 triplets」,讀起來像空庫,session 因此跳過不查。
const text = renderLibraryMapLines([
{ library: "arcrun", narrative: null, top_entities: [], triplet_count: 0, entry_count: 15 },
]);
expect(text).toBe("- arcrun:(narrative 待補)|核心:(尚無)|0 triplets/15 筆原始內容(尚未萃取關係,kbdb_search 查得到)");
});
it("triplet_count=0 且 entry_count=0(真的沒有任何內容)→ 誠實講兩個都是 0", () => {
const text = renderLibraryMapLines([
{ library: "empty-lib", narrative: null, top_entities: [], triplet_count: 0, entry_count: 0 },
]);
expect(text).toBe("- empty-lib:(narrative 待補)|核心:(尚無)|0 triplets0 內容");
});
it("entry_count 缺席(舊部署未帶欄位)→ 容錯當 0,維持既有 triplet-only 措辭不 crash", () => {
const text = renderLibraryMapLines([
{ library: "kb", narrative: "摘要", top_entities: ["A"], triplet_count: 5 },
]);
expect(text).toBe("- kb:摘要|核心:A5 triplets");
});
});
// ── 2026-08-12:地圖也要跟著登入者的權限走 ────────────────────────────────────