feat(console): library-map M5 — 總庫搜尋頁藏書地圖首頁區塊(R4) #74
@@ -17,7 +17,12 @@
|
||||
* - 總庫搜尋:GET /kbdb/search(mode=semantic 未開 Vectorize 會誠實降級 + capability_hint;
|
||||
* chips 用 entry_type——KBDB 真能篩的欄位,不編造「電腦筆記/手機筆記」等不存在的分類);
|
||||
* 頭部統計來自 GET /console/kb-scale-data(精耕層 wiki 卡/三元組/已嵌入——leo 2026-07-07
|
||||
* 裁:45.8 萬 14-E 搬遷遺產 deprecated 不再當招牌數字;搜尋本身仍搜全庫)
|
||||
* 裁:45.8 萬 14-E 搬遷遺產 deprecated 不再當招牌數字;搜尋本身仍搜全庫);
|
||||
* 搜尋框上方=藏書地圖(library-map SDD M5/R4,Arcrun#39):GET /kbdb/map 全館庫卡片
|
||||
* (narrative+top entities+triplet 數+更新時間感),逐庫 GET /kbdb/map/:library 補
|
||||
* degree/跨庫 bridges/commit_hash(詳圖拉不到就維持名字版,不擋渲染);點庫卡片=
|
||||
* 設 library filter(/kbdb/search 既有 library 參數)進該庫搜尋;/map 空陣列或錯誤=
|
||||
* 誠實空狀態(指引 POST /map/recompute backfill),不擋搜尋等其他功能
|
||||
* - 卡片詳頁:GET /kbdb/entries/:id;關聯視圖走 GET /kbdb/graph/neighbors/:name
|
||||
* (cypher 代轉 kbdb-graph-plugin,kbdb-proxy.ts;查無 triplet → 「尚無關聯資料」)
|
||||
* - 工作流:GET /webhooks/named(list)+ POST /webhooks/named/:name/trigger(手動觸發)
|
||||
@@ -394,14 +399,22 @@ function renderConsoleHtml(registryBase: string, brand: string, profile: string)
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 總庫搜尋 -->
|
||||
<!-- 總庫搜尋(library-map SDD M5/R4:搜尋框上方=藏書地圖——先看全館有哪些庫,點庫卡片帶 library filter 進庫搜尋) -->
|
||||
<div class="view page wide" id="v-search">
|
||||
<div class="pagehead"><span class="t">總庫搜尋</span><span class="m mono" id="se-scale"></span></div>
|
||||
<div style="padding-top:20px">
|
||||
<div id="lm-section" style="margin-bottom:24px">
|
||||
<div style="display:flex;justify-content:space-between;align-items:baseline;margin-bottom:10px">
|
||||
<span class="subhead">藏書地圖</span><span id="lm-meta" style="font-size:13px;color:rgba(var(--ink-rgb),.4)"></span>
|
||||
</div>
|
||||
<div class="kgrid" id="lm-cards"><div class="muted" style="font-size:14px">地圖載入中…</div></div>
|
||||
<div id="lm-bridges"></div>
|
||||
</div>
|
||||
<div class="searchrow">
|
||||
<input id="se-q" placeholder="搜尋知識…" autocomplete="off">
|
||||
<button class="sembtn" id="se-sem">語意</button>
|
||||
</div>
|
||||
<div id="se-libbar"></div>
|
||||
<div class="hscroll" id="se-chips" style="margin-top:12px"></div>
|
||||
<div id="se-banner"></div>
|
||||
<div id="se-count" style="margin:20px 0 12px;font-size:14px;color:rgba(var(--ink-rgb),.5)"></div>
|
||||
@@ -552,6 +565,7 @@ function renderConsoleHtml(registryBase: string, brand: string, profile: string)
|
||||
token: localStorage.getItem('arcrun_console_session') || '',
|
||||
tenant: '',
|
||||
chip: '', // entry_type filter('' = 全部)
|
||||
library: '', // library filter('' = 全館;藏書地圖點庫卡片設定,M5/R4)
|
||||
semantic: false,
|
||||
cardId: '',
|
||||
graphCenter: '',
|
||||
@@ -860,6 +874,115 @@ function renderConsoleHtml(registryBase: string, brand: string, profile: string)
|
||||
searchInited = true;
|
||||
renderChips();
|
||||
loadScale();
|
||||
loadLibraryMap();
|
||||
}
|
||||
|
||||
// ── 藏書地圖(library-map SDD M5/R4)──
|
||||
// 資料源:GET /kbdb/map(cypher 代轉 KBDB 基本盤,kbdb-proxy.ts——同 /kbdb/search 慣例)。
|
||||
// 兩段式渲染:先用全館列表(top_entities=名字 top3)立即出卡片,再逐庫拉 /kbdb/map/:library
|
||||
// 補 degree/bridges/commit_hash(庫數少;詳圖拉不到就維持名字版——加分項不擋渲染)。
|
||||
// 空陣列/錯誤=誠實空狀態,只佔地圖區塊,不擋下方搜尋。
|
||||
var LM = { libs: null, details: {} }; // 地圖快取:點卡片重繪「搜尋中」標記時不用重打 API
|
||||
function lmHonest(head, body) {
|
||||
$('lm-cards').innerHTML = '<div class="honest" style="grid-column:1/-1"><div class="h">' + esc(head) + '</div><div class="b">' + body + '</div></div>';
|
||||
$('lm-bridges').innerHTML = '';
|
||||
$('lm-meta').textContent = '';
|
||||
}
|
||||
function lmEntityLine(ents) {
|
||||
// ents:詳圖版 [{name, degree}] 或全館列表版 ['name']——兩形都渲染,degree 有才顯示
|
||||
return (ents || []).slice(0, 5).map(function (t) {
|
||||
var name = typeof t === 'string' ? t : (t && t.name) || '';
|
||||
var deg = t && typeof t === 'object' && typeof t.degree === 'number' ? t.degree : null;
|
||||
return '<span class="tag green">' + esc(name) + (deg != null ? '<span style="opacity:.65">・' + deg + '</span>' : '') + '</span>';
|
||||
}).join(' ');
|
||||
}
|
||||
function renderLmCards(libs, details) {
|
||||
$('lm-meta').textContent = libs.length + ' 個庫・點卡片進該庫搜尋';
|
||||
$('lm-cards').innerHTML = libs.map(function (l) {
|
||||
var d = details[l.library];
|
||||
var ents = d && d.top_entities && d.top_entities.length ? d.top_entities : l.top_entities;
|
||||
// 更新時間感:commit_hash(詳圖有才顯示,短碼)+ updated_at(epoch 秒,台北時間)
|
||||
var stamp = (d && d.commit_hash ? '<span class="mono">' + esc(String(d.commit_hash).slice(0, 7)) + '</span>・' : '') +
|
||||
'更新 ' + esc(fmtDateTime(l.updated_at) || '—');
|
||||
return '<div class="kcard" data-lib="' + esc(l.library) + '">' +
|
||||
'<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>' : '') +
|
||||
'<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('');
|
||||
}
|
||||
function renderLmBridges(details) {
|
||||
// 跨庫橋:詳圖 bridges=[{entity, libraries[]}],多庫詳圖去重後列一小區;沒資料就整區不出
|
||||
var seen = {}, rows = [];
|
||||
Object.keys(details).forEach(function (k) {
|
||||
(details[k].bridges || []).forEach(function (b) {
|
||||
if (!b || !b.entity || seen[b.entity]) return;
|
||||
seen[b.entity] = true;
|
||||
rows.push(b);
|
||||
});
|
||||
});
|
||||
if (!rows.length) { $('lm-bridges').innerHTML = ''; return; }
|
||||
$('lm-bridges').innerHTML = '<div style="margin-top:14px;padding:14px 16px;border-radius:12px;background:rgba(var(--ok-rgb),.05);border:1px solid rgba(var(--ok-rgb),.2)">' +
|
||||
'<div style="font-size:13px;letter-spacing:.14em;color:rgba(var(--ink-rgb),.5);margin-bottom:8px">跨庫橋(同一 entity 出現在多個庫)</div>' +
|
||||
'<div style="display:flex;gap:8px;flex-wrap:wrap">' + rows.slice(0, 12).map(function (b) {
|
||||
return '<span class="tag green">' + esc(b.entity) + '</span><span class="dim" style="font-size:12.5px;align-self:center">' + esc((b.libraries || []).join(' ↔ ')) + '</span>';
|
||||
}).join('<span style="width:6px"></span>') + '</div></div>';
|
||||
}
|
||||
function loadLibraryMap() {
|
||||
fetch('/kbdb/map', { headers: apiHeaders() })
|
||||
.then(function (r) { return r.json().then(function (d) { return { ok: r.ok, status: r.status, d: d }; }); })
|
||||
.then(function (x) {
|
||||
if (!x.ok || x.d.success === false) {
|
||||
lmHonest('讀不到藏書地圖', esc(x.d.error || ('HTTP ' + x.status)) + '<br>不影響下方搜尋,可直接搜全庫。');
|
||||
return;
|
||||
}
|
||||
var libs = x.d.libraries || [];
|
||||
if (!libs.length) {
|
||||
lmHonest('還沒有藏書地圖', '還沒有任何庫跑過重算——對 KBDB 呼 <code style="font-size:12.5px">POST /map/recompute?library=庫名</code> backfill 後,這裡會出現全館導覽。<br>不影響下方搜尋,可直接搜全庫。');
|
||||
return;
|
||||
}
|
||||
LM.libs = libs; LM.details = {};
|
||||
renderLmCards(libs, {});
|
||||
Promise.allSettled(libs.map(function (l) {
|
||||
return fetch('/kbdb/map/' + encodeURIComponent(l.library), { headers: apiHeaders() })
|
||||
.then(function (r) { return r.ok ? r.json() : null; });
|
||||
})).then(function (rs) {
|
||||
var details = {};
|
||||
rs.forEach(function (r) {
|
||||
if (r.status === 'fulfilled' && r.value && r.value.map && r.value.map.library) details[r.value.map.library] = r.value.map;
|
||||
});
|
||||
LM.details = details;
|
||||
renderLmCards(libs, details);
|
||||
renderLmBridges(details);
|
||||
});
|
||||
})
|
||||
.catch(function (e) { lmHonest('地圖服務不可達', esc(friendlyErr(e)) + '<br>不影響下方搜尋,可直接搜全庫。'); });
|
||||
}
|
||||
// 點庫卡片=設 library filter(/kbdb/search 既有 library 參數,多值逗號分隔——此處單庫)進庫搜尋
|
||||
$('lm-cards').addEventListener('click', function (ev) {
|
||||
var t = ev.target.closest('[data-lib]');
|
||||
if (!t) return;
|
||||
S.library = t.getAttribute('data-lib');
|
||||
renderLibBar();
|
||||
if (LM.libs) renderLmCards(LM.libs, LM.details);
|
||||
$('se-q').focus();
|
||||
if ($('se-q').value.trim()) doSearch();
|
||||
toast('已鎖定庫「' + S.library + '」——輸入關鍵字搜這個庫');
|
||||
});
|
||||
function renderLibBar() {
|
||||
$('se-libbar').innerHTML = S.library
|
||||
? '<div style="margin-top:10px;display:flex;align-items:center;gap:8px;font-size:14px">' +
|
||||
'<span class="tag">庫 ' + esc(S.library) + '</span>' +
|
||||
'<button class="chip" id="se-libclear" style="padding:5px 12px;font-size:13px">✕ 清除庫篩選(搜全館)</button></div>'
|
||||
: '';
|
||||
var clear = document.getElementById('se-libclear');
|
||||
if (clear) clear.addEventListener('click', function () {
|
||||
S.library = '';
|
||||
renderLibBar();
|
||||
if (LM.libs) renderLmCards(LM.libs, LM.details);
|
||||
if ($('se-q').value.trim()) doSearch();
|
||||
});
|
||||
}
|
||||
function renderChips() {
|
||||
$('se-chips').innerHTML = CHIPS.map(function (c, i) {
|
||||
@@ -916,6 +1039,8 @@ function renderConsoleHtml(registryBase: string, brand: string, profile: string)
|
||||
$('se-results').innerHTML = '';
|
||||
var url = '/kbdb/search?q=' + encodeURIComponent(q) + '&mode=' + (S.semantic ? 'semantic' : 'keyword');
|
||||
if (S.chip) url += '&entry_type=' + encodeURIComponent(S.chip);
|
||||
// 藏書地圖進庫(M5/R4):library filter=/kbdb/search 既有參數(portal-auth P1 順延項),不新造
|
||||
if (S.library) url += '&library=' + encodeURIComponent(S.library);
|
||||
fetch(url, { headers: apiHeaders() })
|
||||
.then(function (r) { return r.json().then(function (d) { return { ok: r.ok, status: r.status, d: d }; }); })
|
||||
.then(function (x) {
|
||||
@@ -925,7 +1050,8 @@ function renderConsoleHtml(registryBase: string, brand: string, profile: string)
|
||||
$('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>';
|
||||
}
|
||||
var entries = d.entries || [];
|
||||
$('se-count').textContent = '命中 ' + entries.length + ' 筆・模式 ' + (d.mode || 'keyword') + '・搜尋範圍=全庫(含 14-E 遺產)';
|
||||
$('se-count').textContent = '命中 ' + entries.length + ' 筆・模式 ' + (d.mode || 'keyword') +
|
||||
'・搜尋範圍=' + (S.library ? '庫「' + S.library + '」' : '全庫(含 14-E 遺產)');
|
||||
if (!entries.length) {
|
||||
$('se-results').innerHTML = '<div class="muted" style="padding:30px 10px;text-align:center;grid-column:1/-1">找不到「' + esc(q) + '」——換個關鍵字試試。</div>';
|
||||
return;
|
||||
|
||||
@@ -218,6 +218,44 @@ kbdbProxyRouter.get('/kbdb/graph/neighbors/:name', async (c) => {
|
||||
}
|
||||
});
|
||||
|
||||
// ── map(藏書地圖,library-map SDD M5/R4:console 首頁全館地圖)──────────────────
|
||||
//
|
||||
// 純轉發(rule 07):聚合真身在 KBDB 基本盤(kbdb/src/routes/map.ts,M2 已 merge)。
|
||||
// owner_id **不強制注入、只選擇性透傳**——與 MCP kbdb_get_map(M4)同義,理由兩層:
|
||||
// 1. map block 是庫級聚合摘要(narrative+top entities+規模),非租戶私資料;
|
||||
// 2. 現行 backfill recompute 未帶 owner(map block owner_id=NULL)——若比照 search
|
||||
// 強制注入租戶,`e.owner_id = ?` 會把 NULL 列全濾掉 → 首頁永遠假空狀態(不誠實)。
|
||||
// 仍要求 X-Arcrun-API-Key(與本檔其他端點同閘;console 登入後才有 tenant 字串)。
|
||||
|
||||
// GET /kbdb/map — 全館地圖(每庫一行:library+narrative+top 3 entities+triplet_count+updated_at)。
|
||||
kbdbProxyRouter.get('/kbdb/map', async (c) => {
|
||||
if (!tenant(c)) return c.json(NEED_KEY, 401);
|
||||
const { base, headers } = kbdbBase(c.env);
|
||||
const owner = c.req.query('owner_id');
|
||||
const qs = owner ? `?owner_id=${encodeURIComponent(owner)}` : '';
|
||||
try {
|
||||
const res = await fetch(`${base}/map${qs}`, { headers });
|
||||
return new Response(res.body, { status: res.status, headers: { 'Content-Type': 'application/json' } });
|
||||
} catch (e) {
|
||||
// KBDB 不可達 → 誠實回報(前端顯示「地圖服務不可達」,不擋首頁其他功能)
|
||||
return c.json({ success: false, error: `KBDB 不可達(${base}):${e instanceof Error ? e.message : String(e)}` }, 502);
|
||||
}
|
||||
});
|
||||
|
||||
// GET /kbdb/map/:library — 該庫詳圖(完整 top_entities 帶 degree/relation_profile/bridges/commit_hash)。
|
||||
kbdbProxyRouter.get('/kbdb/map/:library', async (c) => {
|
||||
if (!tenant(c)) return c.json(NEED_KEY, 401);
|
||||
const { base, headers } = kbdbBase(c.env);
|
||||
const owner = c.req.query('owner_id');
|
||||
const qs = owner ? `?owner_id=${encodeURIComponent(owner)}` : '';
|
||||
try {
|
||||
const res = await fetch(`${base}/map/${encodeURIComponent(c.req.param('library'))}${qs}`, { headers });
|
||||
return new Response(res.body, { status: res.status, headers: { 'Content-Type': 'application/json' } });
|
||||
} catch (e) {
|
||||
return c.json({ success: false, error: `KBDB 不可達(${base}):${e instanceof Error ? e.message : String(e)}` }, 502);
|
||||
}
|
||||
});
|
||||
|
||||
// PATCH /kbdb/entries/:id — 更新單筆 entry。owner_id 不可被改(剝除 caller 自帶的 owner_id)。
|
||||
kbdbProxyRouter.patch('/kbdb/entries/:id', async (c) => {
|
||||
if (!tenant(c)) return c.json(NEED_KEY, 401);
|
||||
|
||||
@@ -0,0 +1,57 @@
|
||||
/**
|
||||
* GET /console 頁面測試 — 藏書地圖首頁區塊(library-map SDD M5/R4)
|
||||
*
|
||||
* console 是單檔 HTML 薄殼(server 端零資料請求,client JS 打 /kbdb/map),所以頁面測試
|
||||
* 驗的是「殼有沒有長對」:route 200、地圖區塊在搜尋框上方、進庫搜尋接的是既有 library
|
||||
* 參數、空狀態/錯誤誠實文案在(不擋其他功能)。轉發行為真身在 kbdb-map-proxy.test.ts。
|
||||
*/
|
||||
import { SELF } from 'cloudflare:test';
|
||||
import { describe, it, expect } from 'vitest';
|
||||
|
||||
async function page(): Promise<{ status: number; html: string }> {
|
||||
const res = await SELF.fetch('http://localhost/console');
|
||||
return { status: res.status, html: await res.text() };
|
||||
}
|
||||
|
||||
describe('GET /console — 藏書地圖區塊', () => {
|
||||
it('route 200、HTML 含地圖區塊(lm-section/lm-cards/lm-bridges)', async () => {
|
||||
const { status, html } = await page();
|
||||
expect(status).toBe(200);
|
||||
expect(html).toContain('藏書地圖');
|
||||
expect(html).toContain('id="lm-section"');
|
||||
expect(html).toContain('id="lm-cards"');
|
||||
expect(html).toContain('id="lm-bridges"');
|
||||
});
|
||||
|
||||
it('地圖區塊在搜尋框上方(DOM 順序:lm-section 先於 se-q)', async () => {
|
||||
const { html } = await page();
|
||||
expect(html.indexOf('id="lm-section"')).toBeGreaterThan(-1);
|
||||
expect(html.indexOf('id="lm-section"')).toBeLessThan(html.indexOf('id="se-q"'));
|
||||
});
|
||||
|
||||
it('資料源接 /kbdb/map(cypher 代轉慣例)+逐庫詳圖 /kbdb/map/', async () => {
|
||||
const { html } = await page();
|
||||
expect(html).toContain("fetch('/kbdb/map'");
|
||||
expect(html).toContain("'/kbdb/map/' + encodeURIComponent");
|
||||
});
|
||||
|
||||
it('點庫卡片=既有搜尋 library 參數進庫(不新造參數)', async () => {
|
||||
const { html } = await page();
|
||||
expect(html).toContain("'&library=' + encodeURIComponent(S.library)");
|
||||
expect(html).toContain('data-lib');
|
||||
});
|
||||
|
||||
it('空狀態/錯誤分支=誠實文案(recompute backfill 指引)且不擋搜尋', async () => {
|
||||
const { html } = await page();
|
||||
expect(html).toContain('還沒有藏書地圖');
|
||||
expect(html).toContain('/map/recompute');
|
||||
expect(html).toContain('不影響下方搜尋');
|
||||
expect(html).toContain('地圖服務不可達');
|
||||
});
|
||||
|
||||
it('品牌字樣走 CONSOLE_BRAND 變數(未設=預設 Arcrun),地圖區塊沒硬編品牌', async () => {
|
||||
const { html } = await page();
|
||||
// 測試環境未設 CONSOLE_BRAND → 預設 Arcrun(#21 rebrand:字樣由變數注入,非硬編 Mira)
|
||||
expect(html).toContain('<title>Arcrun Console</title>');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,134 @@
|
||||
/**
|
||||
* GET /kbdb/map + /kbdb/map/:library proxy 測試(library-map SDD M5/R4,console 首頁藏書地圖資料源)
|
||||
*
|
||||
* 驗證 IO 接線(聚合真身在 KBDB 基本盤 kbdb/src/routes/map.ts,M2 已測;這裡只測轉發):
|
||||
* 1. 租戶閘:無 X-Arcrun-API-Key → 401 不碰 KBDB(與本 proxy 其他端點同閘)
|
||||
* 2. 轉發:/kbdb/map → base /map;/kbdb/map/:library → base /map/:library(庫名 URL-encode)
|
||||
* 3. owner_id **不強制注入、只選擇性透傳**(與 MCP kbdb_get_map 同義)——現行 backfill 的
|
||||
* map block owner_id=NULL,強制注入租戶會讓首頁永遠假空狀態
|
||||
* 4. 空陣列原樣透傳(前端誠實空狀態的資料依據);404/KBDB 不可達 → 誠實回報不假裝
|
||||
*
|
||||
* KBDB 打 fetchMock 假 host(wrangler.test.toml KBDB_BASE_URL=https://kbdb.test)+
|
||||
* disableNetConnect——測試絕不外連。
|
||||
*/
|
||||
import { SELF, fetchMock } from 'cloudflare:test';
|
||||
import { beforeAll, afterEach, describe, it, expect } from 'vitest';
|
||||
|
||||
const KEY = { 'X-Arcrun-API-Key': 'leo' };
|
||||
|
||||
beforeAll(() => {
|
||||
fetchMock.activate();
|
||||
fetchMock.disableNetConnect();
|
||||
});
|
||||
afterEach(() => fetchMock.assertNoPendingInterceptors());
|
||||
|
||||
describe('GET /kbdb/map — 租戶閘', () => {
|
||||
it('無 X-Arcrun-API-Key → 401,不碰 KBDB', async () => {
|
||||
const res = await SELF.fetch('http://localhost/kbdb/map');
|
||||
expect(res.status).toBe(401);
|
||||
});
|
||||
it('/kbdb/map/:library 同閘 → 401', async () => {
|
||||
const res = await SELF.fetch('http://localhost/kbdb/map/kb');
|
||||
expect(res.status).toBe(401);
|
||||
});
|
||||
});
|
||||
|
||||
describe('GET /kbdb/map — 全館地圖轉發', () => {
|
||||
it('轉發 base /map、回應原樣透傳(不注入 owner_id)', async () => {
|
||||
const payload = {
|
||||
success: true,
|
||||
libraries: [
|
||||
{ library: 'kb', narrative: 'leo 的知識庫主庫', top_entities: ['00-INDEX'], triplet_count: 111, updated_at: 1784451880 },
|
||||
],
|
||||
count: 1,
|
||||
};
|
||||
fetchMock
|
||||
.get('https://kbdb.test')
|
||||
.intercept({ path: '/map', method: 'GET' })
|
||||
.reply(200, payload);
|
||||
const res = await SELF.fetch('http://localhost/kbdb/map', { headers: KEY });
|
||||
expect(res.status).toBe(200);
|
||||
const data = (await res.json()) as typeof payload;
|
||||
expect(data.count).toBe(1);
|
||||
expect(data.libraries[0].library).toBe('kb');
|
||||
expect(data.libraries[0].triplet_count).toBe(111);
|
||||
});
|
||||
|
||||
it('空陣列原樣透傳(前端據此顯示誠實空狀態,指引 recompute backfill)', async () => {
|
||||
fetchMock
|
||||
.get('https://kbdb.test')
|
||||
.intercept({ path: '/map', method: 'GET' })
|
||||
.reply(200, { success: true, libraries: [], count: 0 });
|
||||
const res = await SELF.fetch('http://localhost/kbdb/map', { headers: KEY });
|
||||
expect(res.status).toBe(200);
|
||||
const data = (await res.json()) as { libraries: unknown[]; count: number };
|
||||
expect(data.libraries).toEqual([]);
|
||||
expect(data.count).toBe(0);
|
||||
});
|
||||
|
||||
it('caller 帶 owner_id → 透傳給 base(選擇性,非強制注入)', async () => {
|
||||
fetchMock
|
||||
.get('https://kbdb.test')
|
||||
.intercept({ path: '/map', query: { owner_id: 'someone' }, method: 'GET' })
|
||||
.reply(200, { success: true, libraries: [], count: 0 });
|
||||
const res = await SELF.fetch('http://localhost/kbdb/map?owner_id=someone', { headers: KEY });
|
||||
expect(res.status).toBe(200);
|
||||
});
|
||||
});
|
||||
|
||||
describe('GET /kbdb/map/:library — 單庫詳圖轉發', () => {
|
||||
it('轉發 base /map/:library、詳圖(degree/bridges/commit_hash)原樣透傳', async () => {
|
||||
const map = {
|
||||
record_id: 'e_1',
|
||||
library: 'kb',
|
||||
narrative: 'leo 的知識庫主庫',
|
||||
top_entities: [{ name: '00-INDEX', degree: 29 }],
|
||||
relation_profile: [{ predicate: '連結至', count: 48 }],
|
||||
bridges: [{ entity: 'Gitea', libraries: ['kb', 'notes'] }],
|
||||
triplet_count: 111,
|
||||
commit_hash: 'abc1234def',
|
||||
status: 'active',
|
||||
updated_at: 1784451880,
|
||||
};
|
||||
fetchMock
|
||||
.get('https://kbdb.test')
|
||||
.intercept({ path: '/map/kb', method: 'GET' })
|
||||
.reply(200, { success: true, map });
|
||||
const res = await SELF.fetch('http://localhost/kbdb/map/kb', { headers: KEY });
|
||||
expect(res.status).toBe(200);
|
||||
const data = (await res.json()) as { map: typeof map };
|
||||
expect(data.map.top_entities[0].degree).toBe(29);
|
||||
expect(data.map.bridges[0].libraries).toEqual(['kb', 'notes']);
|
||||
});
|
||||
|
||||
it('庫名 URL-encode 轉發(中文庫名不炸)', async () => {
|
||||
fetchMock
|
||||
.get('https://kbdb.test')
|
||||
.intercept({ path: `/map/${encodeURIComponent('筆記庫')}`, method: 'GET' })
|
||||
.reply(200, { success: true, map: { library: '筆記庫' } });
|
||||
const res = await SELF.fetch(`http://localhost/kbdb/map/${encodeURIComponent('筆記庫')}`, { headers: KEY });
|
||||
expect(res.status).toBe(200);
|
||||
});
|
||||
|
||||
it('base 404(庫沒地圖)→ 原樣透傳 404,不假裝有資料', async () => {
|
||||
fetchMock
|
||||
.get('https://kbdb.test')
|
||||
.intercept({ path: '/map/nope', method: 'GET' })
|
||||
.reply(404, { success: false, error: 'not found' });
|
||||
const res = await SELF.fetch('http://localhost/kbdb/map/nope', { headers: KEY });
|
||||
expect(res.status).toBe(404);
|
||||
const data = (await res.json()) as { success: boolean };
|
||||
expect(data.success).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('KBDB 不可達 → 誠實 502(前端顯示地圖不可達,不擋搜尋)', () => {
|
||||
it('/kbdb/map fetch 炸 → 502 + success:false', async () => {
|
||||
// 不掛 interceptor:disableNetConnect 下 fetch 直接 throw → route catch → 502
|
||||
const res = await SELF.fetch('http://localhost/kbdb/map', { headers: KEY });
|
||||
expect(res.status).toBe(502);
|
||||
const data = (await res.json()) as { success: boolean; error: string };
|
||||
expect(data.success).toBe(false);
|
||||
expect(data.error).toContain('KBDB 不可達');
|
||||
});
|
||||
});
|
||||
@@ -15,6 +15,17 @@ metadata:
|
||||
|
||||
## 📍 當前位置
|
||||
|
||||
> **2026-07-19(#39 藏書地圖 M5,分支 `feat/console-library-map-home`)**:**library-map SDD M5(GUI
|
||||
> 首頁)PR 已開,等審+gated 部署(merge 後需 leo 閘 redeploy cypher-executor)**。R4 落點裁定=
|
||||
> console **總庫搜尋頁搜尋框上方**(rag profile 該頁即首頁;full profile 它是全館入口——駕駛艙是
|
||||
> 狀態面不是庫導覽面,地圖跟搜尋同頁「點庫卡片→帶 library filter 進庫搜尋」動線最短、一份實作
|
||||
> 兩 profile 全吃)。兩段式渲染:GET /kbdb/map 先出庫卡片(narrative+top entities+triplet 數+
|
||||
> 更新時間),逐庫 GET /kbdb/map/:library 補 degree/跨庫 bridges/commit_hash(詳圖失敗維持名字
|
||||
> 版不擋渲染);proxy 端(kbdb-proxy.ts)owner_id **不強制注入只透傳**(同 M4 MCP 語意——現行
|
||||
> backfill map block owner=NULL,強制注入=首頁永遠假空狀態);/map 空/錯誤=誠實空狀態(指引
|
||||
> recompute backfill)不擋搜尋。測試:proxy 9 新測+console 頁 6 新測全過;tsc 16 行既有
|
||||
> ExecutionContext.tracing 錯誤與 executor.test 1 失敗=main 基線就有(stash 對照實證),非本分支引入。
|
||||
>
|
||||
> **2026-07-19(#39 藏書地圖 M4,分支 `feat/mcp-library-map-inject`)**:**library-map SDD M4 PR 已開,
|
||||
> 等審+gated 部署(merge 後需 leo 閘 redeploy arcrun-mcp)**。兩件(design §4):
|
||||
> ① MCP tool `kbdb_get_map`(無參數=全館每庫一行;帶 library=詳圖,slot JSON 字串容錯 parse 成
|
||||
|
||||
Reference in New Issue
Block a user