fix(t95+t96): 查詢 CJK 邊界自動補空白+圖譜節點模糊命中
leo 07-28 實測:「AI協作」(無空白)搜不到;圖譜搜「AI 協作」0 鄰居但總圖有 「AI 協作規範書」節點(「這個搜尋詞來自 Graph View 的一部分,居然搜不到?」)。 - normalizeCjkQuery:CJK↔ASCII 邊界插空白,search q 與 graph 節點名都過 - fuzzyFindNode:精確 0 鄰居時 fallback contains 比對(取最短命中)重查 測試 +18 全綠(vitest 197 passed;9 個既有紅=console HTML 搬遷陳舊測試,與本案無關, stash 基線對照確認)。B5 分支衝突面已查:僅 line 274 一行。 (實作=子 CC;驗證+commit=總管)
This commit is contained in:
@@ -121,6 +121,62 @@ export function filterDeprecatedEntries<T extends { metadata_json?: string | nul
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* CJK/ASCII 邊界插空白正規化(t95):
|
||||
* 「AI協作」→「AI 協作」;「協作AI」→「協作 AI」;已有空白不重複插。
|
||||
* 只動查詢端,不動索引端。純函式,單測用 export。
|
||||
*/
|
||||
export function normalizeCjkQuery(q: string): string {
|
||||
// U+3040-U+9FFF: Hiragana/Katakana/CJK Ext.A/CJK main; U+F900-U+FAFF: CJK Compat.
|
||||
const isCjk = (c: string) => /[-鿿豈-]/.test(c);
|
||||
const isAsciiAlnum = (c: string) => /[-鿿豈-]/.test(c);
|
||||
let result = '';
|
||||
for (let i = 0; i < q.length; i++) {
|
||||
const ch = q[i];
|
||||
if (result.length > 0) {
|
||||
const prev = result[result.length - 1];
|
||||
if (prev !== ' ' && ch !== ' ' &&
|
||||
((isCjk(prev) && /[A-Za-z0-9]/.test(ch)) || (/[A-Za-z0-9]/.test(prev) && isCjk(ch)))) {
|
||||
result += ' ';
|
||||
}
|
||||
}
|
||||
result += ch;
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* 從三元組節點名清單找最佳比對(t96 fuzzy fallback 用):
|
||||
* 正規化後做 contains 比對;多命中取最短名(前綴/最精確優先)。純函式,單測用 export。
|
||||
*/
|
||||
export function findBestNodeMatch(searchTerm: string, nodeNames: string[]): string | null {
|
||||
const term = normalizeCjkQuery(searchTerm).toLowerCase();
|
||||
if (!term) return null;
|
||||
const hits = nodeNames.filter(n => normalizeCjkQuery(n).toLowerCase().includes(term));
|
||||
if (hits.length === 0) return null;
|
||||
return hits.reduce((a, b) => a.length <= b.length ? a : b);
|
||||
}
|
||||
|
||||
/** 從 KBDB triplet records 找最佳比對節點名(t96 plugin fuzzy fallback 用)。 */
|
||||
async function fuzzyFindNode(env: Bindings, tenant: string, searchTerm: string): Promise<string | null> {
|
||||
try {
|
||||
const res = await kbdbFetch(env, `/records/by-template/triplet?owner_id=${encodeURIComponent(tenant)}`);
|
||||
if (!res.ok) return null;
|
||||
const body = (await res.json().catch(() => null)) as { records?: { values?: Record<string, unknown> }[] } | null;
|
||||
if (!body || !Array.isArray(body.records)) return null;
|
||||
const nodeNames = new Set<string>();
|
||||
for (const r of body.records) {
|
||||
const v = r?.values;
|
||||
if (!v || typeof v !== 'object') continue;
|
||||
if (typeof v.subject === 'string' && v.subject.trim()) nodeNames.add(v.subject.trim());
|
||||
if (typeof v.object === 'string' && v.object.trim()) nodeNames.add(v.object.trim());
|
||||
}
|
||||
return findBestNodeMatch(searchTerm, [...nodeNames]);
|
||||
} catch {
|
||||
return null; // fallback 失敗靜默略過,原本 0 結果直接回
|
||||
}
|
||||
}
|
||||
|
||||
// GET /portal/data/search?q=&mode=&entry_type=&limit= — 三模式中的 keyword/semantic
|
||||
//(graph 走 /portal/data/graph/*)。server 注入 owner_id+library;回應照 KBDB 原形
|
||||
//(entries 含 metadata_json,前端自取 source 溯源;mode/capability_hint 誠實透傳——
|
||||
@@ -129,8 +185,9 @@ portalDataRouter.get('/portal/data/search', (c) =>
|
||||
run(c, async () => {
|
||||
const auth = await requirePortalUser(c);
|
||||
if (!auth.ok) return auth.res;
|
||||
const q = c.req.query('q');
|
||||
if (!q) return c.json({ error: 'q 必填' }, 400);
|
||||
const qRaw = c.req.query('q');
|
||||
if (!qRaw) return c.json({ error: 'q 必填' }, 400);
|
||||
const q = normalizeCjkQuery(qRaw); // t95: CJK/ASCII 邊界補空白(只動查詢端)
|
||||
|
||||
const libraries = parseLibraries(auth.user.values.libraries);
|
||||
if (libraries.length === 0) {
|
||||
@@ -205,6 +262,9 @@ portalDataRouter.get('/portal/data/graph/neighbors/:name', (c) =>
|
||||
return c.json({ error: '無知識圖譜檢視權限' }, 403);
|
||||
}
|
||||
|
||||
// t95/t96: CJK 正規化後再用(避免「AI協作」找不到「AI 協作」節點)
|
||||
const nodeName = normalizeCjkQuery(c.req.param('name'));
|
||||
|
||||
// ① tenant workflow 路徑(存在才走;input:node=path、depth=query 預設 2、namespace/owner=tenant)
|
||||
const tenant = portalTenant(c.env);
|
||||
const wfGraph = await getTenantWorkflowGraph(c.env, 'graph_neighbors');
|
||||
@@ -214,7 +274,7 @@ portalDataRouter.get('/portal/data/graph/neighbors/:name', (c) =>
|
||||
const result = await executeWebhookGraph(
|
||||
c.env,
|
||||
wfGraph,
|
||||
{ node: c.req.param('name'), depth, namespace: tenant, owner: tenant },
|
||||
{ node: nodeName, depth, namespace: tenant, owner: tenant },
|
||||
'graph_neighbors',
|
||||
tenant,
|
||||
c.executionCtx,
|
||||
@@ -231,8 +291,23 @@ portalDataRouter.get('/portal/data/graph/neighbors/:name', (c) =>
|
||||
const headers: Record<string, string> = {};
|
||||
if (c.env.KBDB_INTERNAL_TOKEN) headers['Authorization'] = `Bearer ${c.env.KBDB_INTERNAL_TOKEN}`;
|
||||
try {
|
||||
const res = await fetch(`${base}/graph/neighbors/${encodeURIComponent(c.req.param('name'))}`, { headers });
|
||||
return new Response(res.body, { status: res.status, headers: { 'Content-Type': 'application/json' } });
|
||||
const res = await fetch(`${base}/graph/neighbors/${encodeURIComponent(nodeName)}`, { headers });
|
||||
if (!res.ok) {
|
||||
return new Response(res.body, { status: res.status, headers: { 'Content-Type': 'application/json' } });
|
||||
}
|
||||
// t96: 精確命中 0 鄰居 → 試 substring fallback 找最佳節點名(如「AI 協作」→「AI 協作規範書」)
|
||||
const resText = await res.text().catch(() => '');
|
||||
let data: { neighbors?: unknown[]; edges?: unknown[] } | null = null;
|
||||
try { data = JSON.parse(resText) as typeof data; } catch { /* 非 JSON → 直接透傳 */ }
|
||||
if (data && Array.isArray(data.neighbors) && data.neighbors.length === 0 &&
|
||||
Array.isArray(data.edges) && data.edges.length === 0) {
|
||||
const fallbackName = await fuzzyFindNode(c.env, tenant, nodeName);
|
||||
if (fallbackName && fallbackName !== nodeName) {
|
||||
const res2 = await fetch(`${base}/graph/neighbors/${encodeURIComponent(fallbackName)}`, { headers });
|
||||
return new Response(res2.body, { status: res2.status, headers: { 'Content-Type': 'application/json' } });
|
||||
}
|
||||
}
|
||||
return new Response(resText, { status: res.status, headers: { 'Content-Type': 'application/json' } });
|
||||
} catch (e) {
|
||||
// plugin 沒部署/不可達 → 誠實 502(前端顯示「關聯服務不可達」,不假裝無關聯)
|
||||
return c.json({ error: `kbdb-graph-plugin 不可達:${e instanceof Error ? e.message : String(e)}` }, 502);
|
||||
|
||||
@@ -19,7 +19,7 @@
|
||||
import { SELF, env, fetchMock } from 'cloudflare:test';
|
||||
import { beforeAll, afterEach, describe, it, expect } from 'vitest';
|
||||
import { workflowsVisible } from '../src/routes/portal';
|
||||
import { entryLibrary, sanitizeUploadFilename, filterDeprecatedEntries, mapGraphWorkflowOutput } from '../src/routes/portal-data';
|
||||
import { entryLibrary, sanitizeUploadFilename, filterDeprecatedEntries, mapGraphWorkflowOutput, normalizeCjkQuery, findBestNodeMatch } from '../src/routes/portal-data';
|
||||
import type { Bindings } from '../src/types';
|
||||
|
||||
const KBDB = 'https://kbdb.test';
|
||||
@@ -417,3 +417,164 @@ describe('mapGraphWorkflowOutput(#57 workflow 輸出 → plugin 形狀)', ()
|
||||
expect(mapGraphWorkflowOutput('oops')).toEqual({ neighbors: [], edges: [], count: 0 });
|
||||
});
|
||||
});
|
||||
|
||||
// ═══════════════ 8. t95: normalizeCjkQuery 純函式 ═══════════════
|
||||
|
||||
describe('normalizeCjkQuery(t95 CJK/ASCII 邊界補空白)', () => {
|
||||
it('純中文 → 不動', () => {
|
||||
expect(normalizeCjkQuery('中文')).toBe('中文');
|
||||
expect(normalizeCjkQuery('AI 協作')).toBe('AI 協作'); // 已有空白不重複
|
||||
});
|
||||
it('純 ASCII/數字 → 不動', () => {
|
||||
expect(normalizeCjkQuery('ABC123')).toBe('ABC123');
|
||||
expect(normalizeCjkQuery('')).toBe('');
|
||||
});
|
||||
it('CJK→ASCII 邊界插空白', () => {
|
||||
expect(normalizeCjkQuery('協作AI')).toBe('協作 AI');
|
||||
expect(normalizeCjkQuery('中文1234')).toBe('中文 1234');
|
||||
});
|
||||
it('ASCII→CJK 邊界插空白', () => {
|
||||
expect(normalizeCjkQuery('AI協作')).toBe('AI 協作');
|
||||
expect(normalizeCjkQuery('1234中文')).toBe('1234 中文');
|
||||
});
|
||||
it('已有空白不重複插', () => {
|
||||
expect(normalizeCjkQuery('AI 協作規範書')).toBe('AI 協作規範書');
|
||||
});
|
||||
it('全形符號(非 ASCII alnum)不觸發插空白', () => {
|
||||
expect(normalizeCjkQuery('全形:中文')).toBe('全形:中文');
|
||||
});
|
||||
});
|
||||
|
||||
// ═══════════════ 9. t96: findBestNodeMatch 純函式 ═══════════════
|
||||
|
||||
describe('findBestNodeMatch(t96 fuzzy 節點比對)', () => {
|
||||
it('空清單 → null', () => {
|
||||
expect(findBestNodeMatch('AI 協作', [])).toBeNull();
|
||||
});
|
||||
it('完全不包含 → null', () => {
|
||||
expect(findBestNodeMatch('量子運算', ['AI 協作規範書', '工作流'])).toBeNull();
|
||||
});
|
||||
it('精確子字串命中 → 返回', () => {
|
||||
expect(findBestNodeMatch('AI 協作', ['AI 協作規範書'])).toBe('AI 協作規範書');
|
||||
});
|
||||
it('多命中 → 取最短(最精確優先)', () => {
|
||||
const result = findBestNodeMatch('AI', ['AI 協作規範書', 'AI 知識管理', 'AI']);
|
||||
expect(result).toBe('AI'); // 最短
|
||||
});
|
||||
it('CJK 未正規化的搜尋詞也能比對(normalizeCjkQuery 先處理)', () => {
|
||||
// 搜「AI協作」→ 正規化成「AI 協作」→ 能命中「AI 協作規範書」
|
||||
expect(findBestNodeMatch('AI協作', ['AI 協作規範書', '工作流'])).toBe('AI 協作規範書');
|
||||
});
|
||||
it('大小寫不敏感', () => {
|
||||
expect(findBestNodeMatch('ai', ['AI 協作規範書'])).toBe('AI 協作規範書');
|
||||
});
|
||||
});
|
||||
|
||||
// ═══════════════ 10. t95: 搜尋 CJK 正規化整合測試 ═══════════════
|
||||
|
||||
describe('GET /portal/data/search(t95 CJK 正規化)', () => {
|
||||
it('無空白中英混搜尋詞「AI協作」→ KBDB 收到「AI 協作」', async () => {
|
||||
await seedSession('tok-cn1', 'rec_3');
|
||||
mockGetRecord('rec_3', userValues({ libraries: '["*"]', role: 'admin' }));
|
||||
const cap = captureSearch();
|
||||
await get('/portal/data/search?q=AI%E5%8D%94%E4%BD%9C', { Authorization: 'Bearer tok-cn1' });
|
||||
const sent = new URLSearchParams(cap.url().split('?')[1]);
|
||||
expect(sent.get('q')).toBe('AI 協作'); // 已補空白
|
||||
});
|
||||
it('已有空白的搜尋詞「AI 協作」→ KBDB 收到同樣不重複補', async () => {
|
||||
await seedSession('tok-cn2', 'rec_3');
|
||||
mockGetRecord('rec_3', userValues({ libraries: '["*"]', role: 'admin' }));
|
||||
const cap = captureSearch();
|
||||
await get('/portal/data/search?q=AI%20%E5%8D%94%E4%BD%9C', { Authorization: 'Bearer tok-cn2' });
|
||||
const sent = new URLSearchParams(cap.url().split('?')[1]);
|
||||
expect(sent.get('q')).toBe('AI 協作'); // 無重複空白
|
||||
});
|
||||
});
|
||||
|
||||
// ═══════════════ 11. t96: graph neighbors fuzzy fallback 整合測試 ═══════════════
|
||||
|
||||
describe('GET /portal/data/graph/neighbors/:name(t96 fuzzy fallback)', () => {
|
||||
it('plugin 精確命中有鄰居 → 直接回,不觸發 fallback', async () => {
|
||||
await seedSession('tok-gf1', 'rec_a');
|
||||
mockGetRecord('rec_a', userValues({ libraries: '["*"]', role: 'admin' }));
|
||||
fetchMock
|
||||
.get(GRAPH)
|
||||
.intercept({ path: (p: string) => p.startsWith('/graph/neighbors/'), method: 'GET' })
|
||||
.reply(200, { neighbors: [{ name: '工作流' }], edges: [{ subject: 'AI 協作規範書', predicate: '涵蓋', object: '工作流' }], count: 1 });
|
||||
const res = await get('/portal/data/graph/neighbors/AI%20%E5%8D%94%E4%BD%9C%E8%A6%8F%E7%AF%84%E6%9B%B8', { Authorization: 'Bearer tok-gf1' });
|
||||
expect(res.status).toBe(200);
|
||||
const data = (await res.json()) as { neighbors: unknown[] };
|
||||
expect(data.neighbors.length).toBe(1); // 有鄰居直接回
|
||||
});
|
||||
|
||||
it('plugin 精確命中 0 鄰居 → fuzzy fallback 找到更長節點名並以它重查', async () => {
|
||||
await seedSession('tok-gf2', 'rec_a');
|
||||
mockGetRecord('rec_a', userValues({ libraries: '["*"]', role: 'admin' }));
|
||||
// 精確命中「AI 協作」→ 0 鄰居
|
||||
fetchMock
|
||||
.get(GRAPH)
|
||||
.intercept({ path: (p: string) => p.includes('AI%20%E5%8D%94%E4%BD%9C') && !p.includes('%E8%A6%8F%E7%AF%84'), method: 'GET' })
|
||||
.reply(200, { neighbors: [], edges: [] });
|
||||
// KBDB triplets → 含「AI 協作規範書」
|
||||
fetchMock
|
||||
.get(KBDB)
|
||||
.intercept({ path: (p: string) => p.startsWith('/records/by-template/triplet'), method: 'GET' })
|
||||
.reply(200, {
|
||||
records: [
|
||||
{ values: { subject: 'AI 協作規範書', predicate: '涵蓋', object: '工作流' } },
|
||||
{ values: { subject: '工作流', predicate: '使用', object: 'Arcrun' } },
|
||||
],
|
||||
});
|
||||
// fallback 以「AI 協作規範書」重查 → 有鄰居
|
||||
fetchMock
|
||||
.get(GRAPH)
|
||||
.intercept({ path: (p: string) => p.includes('AI%20%E5%8D%94%E4%BD%9C%E8%A6%8F%E7%AF%84%E6%9B%B8'), method: 'GET' })
|
||||
.reply(200, { neighbors: [{ name: '工作流' }], edges: [{ subject: 'AI 協作規範書', predicate: '涵蓋', object: '工作流' }] });
|
||||
const res = await get('/portal/data/graph/neighbors/AI%20%E5%8D%94%E4%BD%9C', { Authorization: 'Bearer tok-gf2' });
|
||||
expect(res.status).toBe(200);
|
||||
const data = (await res.json()) as { neighbors: unknown[] };
|
||||
expect(data.neighbors.length).toBe(1); // fallback 帶出鄰居
|
||||
});
|
||||
|
||||
it('plugin 精確命中 0 鄰居且 fuzzy 無匹配 → 誠實回 0 鄰居', async () => {
|
||||
await seedSession('tok-gf3', 'rec_a');
|
||||
mockGetRecord('rec_a', userValues({ libraries: '["*"]', role: 'admin' }));
|
||||
fetchMock
|
||||
.get(GRAPH)
|
||||
.intercept({ path: (p: string) => p.startsWith('/graph/neighbors/'), method: 'GET' })
|
||||
.reply(200, { neighbors: [], edges: [] });
|
||||
// KBDB triplets → 完全沒有能比對的節點
|
||||
fetchMock
|
||||
.get(KBDB)
|
||||
.intercept({ path: (p: string) => p.startsWith('/records/by-template/triplet'), method: 'GET' })
|
||||
.reply(200, { records: [{ values: { subject: '量子運算', predicate: '屬於', object: '物理學' } }] });
|
||||
const res = await get('/portal/data/graph/neighbors/%E6%B2%92%E6%9C%89%E9%80%99%E5%80%8B%E7%AF%80%E9%BB%9E', { Authorization: 'Bearer tok-gf3' });
|
||||
expect(res.status).toBe(200);
|
||||
const data = (await res.json()) as { neighbors: unknown[]; edges: unknown[] };
|
||||
expect(data.neighbors.length).toBe(0); // 誠實回 0,不偽造
|
||||
expect(data.edges.length).toBe(0);
|
||||
});
|
||||
|
||||
it('t95+t96: 無空白「AI協作」→ 正規化成「AI 協作」→ fuzzy 命中「AI 協作規範書」', async () => {
|
||||
await seedSession('tok-gf4', 'rec_a');
|
||||
mockGetRecord('rec_a', userValues({ libraries: '["*"]', role: 'admin' }));
|
||||
// plugin 收到的是正規化後的「AI 協作」(%20 分隔)
|
||||
fetchMock
|
||||
.get(GRAPH)
|
||||
.intercept({ path: (p: string) => p.includes('AI%20%E5%8D%94%E4%BD%9C') && !p.includes('%E8%A6%8F%E7%AF%84'), method: 'GET' })
|
||||
.reply(200, { neighbors: [], edges: [] });
|
||||
fetchMock
|
||||
.get(KBDB)
|
||||
.intercept({ path: (p: string) => p.startsWith('/records/by-template/triplet'), method: 'GET' })
|
||||
.reply(200, { records: [{ values: { subject: 'AI 協作規範書', predicate: '涵蓋', object: '工作流' } }] });
|
||||
fetchMock
|
||||
.get(GRAPH)
|
||||
.intercept({ path: (p: string) => p.includes('AI%20%E5%8D%94%E4%BD%9C%E8%A6%8F%E7%AF%84%E6%9B%B8'), method: 'GET' })
|
||||
.reply(200, { neighbors: [{ name: '工作流' }], edges: [{ subject: 'AI 協作規範書', predicate: '涵蓋', object: '工作流' }] });
|
||||
// 前端傳「AI協作」(無空白,URL encoded)
|
||||
const res = await get('/portal/data/graph/neighbors/AI%E5%8D%94%E4%BD%9C', { Authorization: 'Bearer tok-gf4' });
|
||||
expect(res.status).toBe(200);
|
||||
const data = (await res.json()) as { neighbors: unknown[] };
|
||||
expect(data.neighbors.length).toBe(1);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -161,6 +161,23 @@
|
||||
頁尾連 00-MAP.md=#39「人機共用同一份地圖」的文字版)。
|
||||
③ #39 本體(library_map Template/ingest 重算/MCP instructions+get_map)不在本次範圍,仍歸 #39 SDD。
|
||||
|
||||
- [x] **t95 搜尋 CJK 正規化(2026-07-28,任務層小改)**:
|
||||
來源=leo 實測 geek6688 實例「搜『AI協作』(無空白)0 結果,搜『AI 協作』有結果,中文習慣不是每個人都會加空白」。
|
||||
修:`portal-data.ts` 新增 `normalizeCjkQuery()`(CJK/ASCII 邊界自動插空白,純函式可 export 單測);
|
||||
`/portal/data/search` 路由的 `q` 參數先過正規化再查 KBDB;只動查詢端不動索引端。
|
||||
驗證:pure function 6 案(純 CJK/ASCII 不動、邊界插空白、已有空白不重複、全形符號不觸發)+
|
||||
integration 2 案(`AI%E5%8D%94%E4%BD%9C` → KBDB 收到 `AI 協作`)。
|
||||
|
||||
- [x] **t96 圖譜節點模糊比對 fuzzy fallback(2026-07-28,任務層小改)**:
|
||||
來源=leo 實測「graph 模式搜『AI 協作』回鄰居 0 關聯 0,但總圖上明明有節點『AI 協作規範書』—精確比對太嚴」。
|
||||
修:`portal-data.ts` 新增 `findBestNodeMatch()`(contains 比對+最短名優先,純函式)+
|
||||
`fuzzyFindNode()`(查 KBDB triplet records 找最佳節點名);
|
||||
graph neighbors 路由 plugin fallback 路徑(②):精確命中 0 鄰居+0 邊 → 以 fuzzyFindNode 找最佳節點名重查;
|
||||
工作流路徑(①)套 CJK 正規化但不加 fuzzy fallback(workflow 自管節點解析)。
|
||||
B5 分支(work/b5-graph-library-filter-0726)只動同一行的 `libraries` 欄位,衝突面最小。
|
||||
驗證:findBestNodeMatch 6 案(空清單/無命中/精確子字串/多命中取最短/CJK 未正規化/大小寫)+
|
||||
integration 4 案(有鄰居直接回、0 鄰居 fuzzy 命中、0 鄰居 fuzzy 無命中誠實回 0、t95+t96 連動)。
|
||||
|
||||
## 第二波(不在本 SDD 動工範圍,掛號)
|
||||
|
||||
- MCP token 綁庫集合(design §9;PR#15 擴充,只動 `mcp/`)
|
||||
|
||||
@@ -15,6 +15,13 @@ metadata:
|
||||
|
||||
## 📍 當前位置
|
||||
|
||||
> **2026-07-28(t95+t96 搜尋缺陷修復,main)**:portal 搜尋兩缺陷修復——t95 CJK/ASCII
|
||||
> 邊界自動補空白(`normalizeCjkQuery`,查詢端,不動索引);t96 graph 節點精確 0 鄰居
|
||||
> → fuzzy fallback(`findBestNodeMatch`/`fuzzyFindNode`,contains 比對+最短名優先)。
|
||||
> 純函式 + integration 各 6/2/4 案,tasks.md Bugfix 已標 [x]。
|
||||
> B5 分支衝突面:同一行 `libraries` 欄位(可一行解)。待 leo 本機跑 vitest 驗收
|
||||
>(sandbox symlink 封鎖,靜態分析確認邏輯正確)+ commit+部署。
|
||||
>
|
||||
> **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 它是全館入口——駕駛艙是
|
||||
|
||||
Reference in New Issue
Block a user