Compare commits

..

5 Commits

Author SHA1 Message Date
uncle6me-web a5e4caf5cb fix(portal): 讀不到就說讀不到——總圖不再把「讀不到」畫成「你沒有」(Arcrun#100)
leo 2026-08-12 打開總圖看到:「0 個實體 · 0 條關聯/知識庫還沒有任何關聯——
上傳文件後 AI 會自動織網」。**而他庫裡有 1854 條三元組。**
那句話會叫他去做一件不需要做的事。

三個獨立的洞疊起來才變成那句謊:
  ① console-dashboard.ts 打 kbdb-graph-plugin 沒帶認證(同段落打 kbdb 的兩支都有帶)
  ② 那顆 worker 不在更新的部署清單裡 ⇒ token 一換它就落單
  ③ **畫面把 null 畫成 0**——後端已經誠實回 null 了,是前端把它變成謊話

為什麼一直沒被發現:graph plugin 原本身上沒有 token ⇒ 門開著 ⇒ 沒帶也進得去。
2026-08-12 輪替後它有了 token,門關上,401 才浮出來。

📍 repo:matrix/arcrun(cypher-executor/src/routes/、console-ui/public/)
📍 票:Leo/Arcrun#100
2026-08-12 13:33:53 +08:00
uncle6me-web e69d6bbc03 chore(worker-builds): 重編——kbdb 的跳脫修法要進執行檔才算數(Arcrun#94)
今天已經在這件事上被咬過一次(8e10f1d):源碼改了、執行檔沒重編,
出貨會全綠地送出舊的。這次併完就重編,不留同一個坑。

kbdb source 525faaf → c497ec4(sha256 7bc66656 → ffb8d434)。
其餘四顆源碼沒動、位元組也沒動。
2026-08-12 12:22:10 +08:00
uncle6me-web b302c03ea8 merge: 搜尋框打的 % 和 _ 是字,不是萬用字元(Arcrun#94)
總管逐筆審過兩筆,並自己跑了測試(那條線被權限閘擋住沒能執行):
  前後對照  查 100% 改前撈回「共有 100 個待辦項目」→ 改後只回真的含 100% 的
            查 owner_id 改前連 ownerXid 也中 → 改後只回字面命中
            單打 % 或 _ 改前整個庫都回來 → 改後只回字面命中
  反向驗證  拿掉跳脫 → 8 failed(紅的正是病徵本身)
            拿掉 ESCAPE → 5 failed(含既有 search-long-query 兩條)
  既有測試  18 檔 197 pass → 19 檔 213 pass

它連帶處理了一個我沒想到的:跳脫會讓 pattern 變長,所以長度上限改用「跳脫後」的
長度算——否則打 48 個 % 會產生 98 bytes 的 pattern,退回 08-03 修掉的那個 HTTP 500。

誠實標記:只在本機真 SQLite 上驗過,沒對任何線上實例跑過。
同款漏洞另有兩處未動(library-map.ts:169、library-backfill.ts:75-76,吃維運端前綴參數)。
2026-08-12 12:21:48 +08:00
uncle6me-web c497ec418e test(kbdb): 用真 SQLite 釘住 #94 的前後對照與跳脫邊界
只驗 SQL 形狀不算數——「% 被當成萬用字元」這件事,只有真的跑一次 LIKE 才看得見。
用 node:sqlite(與 library-map/embed-backfill 同款治具)跑真 SQL,每組驗收同時跑
「舊寫法」與「現行寫法」,讓前後對照直接長在測試裡。

守五件事:
  ① 前後對照   —— 100% / owner_id / 單打符號,改前撈回不相干的、改後只回字面命中
  ② 跳脫邊界   —— `\` 不跳脫會漏掉真正的 `C:\`,也會讓 `_` 的跳脫失效、萬用字元漏回來
  ③ ESCAPE 齊全 —— 沒有任何 `content LIKE ?` 是裸的(漏一個就等於那條路沒修)
  ④ 不退化     —— 不含 % _ \ 的查詢 pattern 逐字不變,最熱路徑仍是單一 LIKE
  ⑤ byte 預算  —— 滿是 % 的長查詢仍在 D1 的 50 bytes 上限內(承 2026-08-03 的 500 修復)

反向驗證(把修法拿掉,兩半分別驗,兩半都是承重的):
  拿掉跳脫      → 8 failed / 204 passed,紅的正是病徵本身
  拿掉 ESCAPE   → 5 failed / 207 passed(含既有 search-long-query 2 條)
  復原後        → 19 檔 213 pass(基線 18 檔 197 pass)
2026-08-12 11:59:36 +08:00
uncle6me-web 6985bf4850 fix(kbdb): 搜尋框打的 % 和 _ 是字,不是萬用字元(Arcrun#94)
使用者在搜尋框打 `%` 或 `_`,搜出來一堆跟他打的字無關的東西。

病根:pattern 一直是 `'%' + 使用者輸入 + '%'` 直接內插,而 SQLite 的 LIKE 有
兩個萬用字元(`%` 任意長度、`_` 任意一字)且**沒有預設跳脫字元**——不寫
ESCAPE 就沒有任何辦法表示「字面上的 %」。所以他打的符號被當成 pattern 語法:

    `100%`     → `%100%%`  → 「100」後面接什麼都算 ⇒ 撈回一堆不相干的
    `owner_id` → `%owner_id%` → `_` 匹配任一字元 ⇒ ownerXid 也中
    單打 `%`/`_` → `%%%`/`%_%` → 整個庫都回來

舊病,不是 08-10 斷詞(#84)引進的:pattern 從來就是這樣拼的。之前關鍵字搜尋
幾乎恆為 0 命中,這個洞被那個洞蓋住;斷詞讓搜尋真的會回東西之後才浮出來。
斷詞那段一個字都沒動。

修法:pattern 產生點全部收斂到兩支 helper——
  · escapeLikeLiteral():跳脫 `%` `_` `\` 三個字元
  · CONTENT_LIKE 常數:每個 `content LIKE ?` 一律帶 `ESCAPE '\'`

為什麼跳脫字元本身(`\`)也要處理:宣告 ESCAPE 之後 `\` 就變成 pattern 裡有
意義的字元,而且它會把後面那個字吃掉、且不報錯——打 `C:\` 會變成去找 `C:%`
(真正的 `C:\` 反而漏掉);只跳脫 %/_ 而漏掉 `\`,打 `C:\_temp` 時 `\\` 先被
讀成字面 `\`、後面的 `_` 變回萬用字元 ⇒ 撈到 `C:\Xtemp`。三個是一組的。
`[` `]` `?` `*` 不需要跳脫(那是 GLOB/別的方言,LIKE 不吃),多跳只會白白吃掉
pattern 的 byte 預算。

連帶:跳脫會變長(`%`→`\%`),所以 50 bytes 上限改用「跳脫後」的長度算
(likeBytes),否則打 48 個 `%` 會產生 98 bytes 的 pattern ⇒ 退回 2026-08-03
修掉的那個 HTTP 500。不含這三個字元的查詢 likeBytes ≡ utf8Len ⇒ 既有查詢逐字不變。

search-long-query.test.ts 兩條「逐字比對謂詞字串」的斷言跟著新字串更新——改的是
比對用的常數、不是放寬檢查(仍逐字相等比對),pattern 本身一個字都沒變。

沒有引進第三種搜尋機制,沒有動資料層(三表不變),沒有動斷詞。
2026-08-12 11:59:36 +08:00
14 changed files with 758 additions and 59 deletions
+18 -11
View File
@@ -2189,11 +2189,18 @@ async function deprecateEntriesByLibrary(db, ownerId, library) {
var MAX_LIKE_Q_BYTES = 48;
var MAX_LIKE_TERMS = 6;
var utf8Len = (s) => new TextEncoder().encode(s).length;
var LIKE_ESCAPE = "\\";
var CONTENT_LIKE = `content LIKE ? ESCAPE '${LIKE_ESCAPE}'`;
function escapeLikeLiteral(s) {
return s.replace(/[\\%_]/g, (ch) => LIKE_ESCAPE + ch);
}
var likeBytes = (s) => utf8Len(escapeLikeLiteral(s));
var likePattern = (s) => `%${escapeLikeLiteral(s)}%`;
function chunkByBytes(s, maxBytes) {
const out = [];
let cur = "";
for (const ch of s) {
if (utf8Len(cur + ch) > maxBytes) {
if (likeBytes(cur + ch) > maxBytes) {
if (cur) out.push(cur);
cur = ch;
} else {
@@ -2204,8 +2211,8 @@ function chunkByBytes(s, maxBytes) {
return out;
}
function buildContentLike(q) {
if (utf8Len(q) <= MAX_LIKE_Q_BYTES) {
return { conds: ["content LIKE ?"], params: [`%${q}%`], split: false };
if (likeBytes(q) <= MAX_LIKE_Q_BYTES) {
return { conds: [CONTENT_LIKE], params: [likePattern(q)], split: false };
}
const terms = [];
for (const word of q.split(/\s+/).filter(Boolean)) {
@@ -2217,8 +2224,8 @@ function buildContentLike(q) {
}
if (terms.length === 0) terms.push(chunkByBytes(q, MAX_LIKE_Q_BYTES)[0] ?? "");
return {
conds: terms.map(() => "content LIKE ?"),
params: terms.map((t) => `%${t}%`),
conds: terms.map(() => CONTENT_LIKE),
params: terms.map(likePattern),
split: true
};
}
@@ -2335,7 +2342,7 @@ function buildSearchScore(q) {
if (terms.length === 0) {
const m = buildContentLike(trimmed);
return {
scoreExpr: m.conds.map(() => "CASE WHEN content LIKE ? THEN 1 ELSE 0 END").join(" + "),
scoreExpr: m.conds.map(() => `CASE WHEN ${CONTENT_LIKE} THEN 1 ELSE 0 END`).join(" + "),
scoreParams: m.params,
terms: [],
legacyShape: true
@@ -2344,14 +2351,14 @@ function buildSearchScore(q) {
const parts = [];
const params = [];
for (const { term, weight } of terms) {
parts.push(`CASE WHEN content LIKE ? THEN ${weight} ELSE 0 END`);
params.push(`%${term}%`);
parts.push(`CASE WHEN ${CONTENT_LIKE} THEN ${weight} ELSE 0 END`);
params.push(likePattern(term));
}
const single = terms.length === 1 && terms[0].term === trimmed;
if (!single && utf8Len(trimmed) <= MAX_LIKE_Q_BYTES) {
if (!single && likeBytes(trimmed) <= MAX_LIKE_Q_BYTES) {
const bonus = terms.reduce((s, t) => s + t.weight, 0);
parts.push(`CASE WHEN content LIKE ? THEN ${bonus} ELSE 0 END`);
params.push(`%${trimmed}%`);
parts.push(`CASE WHEN ${CONTENT_LIKE} THEN ${bonus} ELSE 0 END`);
params.push(likePattern(trimmed));
}
return { scoreExpr: parts.join(" + "), scoreParams: params, terms, legacyShape: single };
}
+5 -5
View File
@@ -1,8 +1,8 @@
{
"schema": 1,
"built_for": "arcrun-tier2-worker-artifacts",
"generated_at": "2026-08-12T01:50:15.065Z",
"repo_head": "3eb8b31f2bfa029e15a8119e229082fbafb8d2b1",
"generated_at": "2026-08-12T04:21:48.581Z",
"repo_head": "b302c03ea8076bcfe82bbcebb1523dcec2d1e830",
"repo_dirty": false,
"workers": [
{
@@ -58,11 +58,11 @@
{
"name": "arcrun-kbdb",
"source_dir": "kbdb",
"source_commit": "3eb8b31f2bfa029e15a8119e229082fbafb8d2b1",
"source_commit": "c497ec418eba6cd94b1d5872671c51fd5812c11c",
"main_module": "worker.mjs",
"main_file": "arcrun-kbdb/worker.mjs",
"js_bytes": 149234,
"content_sha256": "7bc666568f453a2fc5fc9339fc997c12d3927fb8a293439f2c80695be8da9767",
"js_bytes": 149533,
"content_sha256": "ffb8d43467d0cefbd7545fdc0d347f2b965e3c3de20b3315eed7613f20266891",
"modules": [],
"compat_date": "2025-02-19",
"compat_flags": [
@@ -277,9 +277,12 @@ function taipeiMonthDay(ms) { var d = new Date(ms + TAIPEI_OFFSET_MS); return {
} else {
rows.push(sysRow('語意嵌入', '狀態讀不到', 'off'));
}
rows.push(sys.graph && sys.graph.ok
? sysRow('知識圖譜', '● 正常・三元組 ' + (sys.graph.triplets == null ? '?' : sys.graph.triplets), 'ok')
: sysRow('知識圖譜', '● 打不通', 'bad'));
// Arcrun#100:「服務活著嗎」與「庫裡有幾條」拆兩列。混一列時,圖服務打不通會把
// 「其實有 1854 條」整個吞掉,畫面看起來就像知識庫是空的。數字讀不到寫「讀不到」,不寫 0。
var gOk = !!(sys.graph && sys.graph.ok);
var tri = sys.graph && sys.graph.triplets != null ? sys.graph.triplets : null;
rows.push(sysRow('知識圖譜服務', gOk ? '● 正常' : '● 打不通', gOk ? 'ok' : 'bad'));
rows.push(sysRow('三元組(關聯)', tri == null ? '讀不到' : tri.toLocaleString() + ' 條', tri == null ? 'off' : ''));
rows.push(sysRow('工作流', sys.workflow_total == null ? '讀不到' : sys.workflow_total + ' 條', sys.workflow_total == null ? 'off' : ''));
// 精耕層 wiki 卡(leo 2026-07-07 裁:14-E 遺產總數 deprecated 不再顯示,只顯示真的新的;
// 三元組/已嵌入 已各有一列)
+5 -4
View File
@@ -934,14 +934,15 @@ function taipeiMonthDay(ms) { var d = new Date(ms + TAIPEI_OFFSET_MS); return {
fetch(API_BASE + '/console/kb-scale-data')
.then(function (r) { return r.ok ? r.json() : null; })
.then(function (d) {
if (!d) return;
var n = function (v) { return v == null ? '' : v.toLocaleString(); };
// #100:讀不到就明說讀不到(原本靜默 return,會把上一輪的舊數字留在畫面上)
if (!d) { $('se-scale').textContent = '精耕層 讀不到(規模統計讀取失敗,不影響搜尋)'; return; }
var n = function (v) { return v == null ? '讀不到' : v.toLocaleString(); };
var parts = ['wiki 卡 ' + n(d.wiki_card_total), '三元組 ' + n(d.triplets_total), '已嵌入 ' + n(d.embedded)];
var latest = d.wiki_card_latest_ago_minutes;
$('se-scale').textContent = '精耕層 ' + parts.join('・') +
(latest != null && latest >= 0 ? '・最近寫入 ' + ckAge(latest) : '');
})
.catch(function () { /* 規模感拿不到不擋搜尋 */ });
.catch(function () { $('se-scale').textContent = '精耕層 讀不到(規模統計讀取失敗,不影響搜尋)'; });
}
$('se-sem').addEventListener('click', function () {
S.semantic = !S.semantic;
@@ -1504,7 +1505,7 @@ function taipeiMonthDay(ms) { var d = new Date(ms + TAIPEI_OFFSET_MS); return {
]).then(function (rs) {
var svc = rs[0].status === 'fulfilled' ? rs[0].value : {};
var kb = rs[1].status === 'fulfilled' ? rs[1].value : null;
var n = function (v) { return v == null ? '' : v.toLocaleString(); };
var n = function (v) { return v == null ? '讀不到' : v.toLocaleString(); };
var rows = '';
rows += '<div class="kvline"><span class="muted">服務</span><span class="mono" style="font-size:14px">' + esc(svc.service || 'arcrun-cypher-executor') + '</span></div>';
rows += '<div class="kvline"><span class="muted">版本</span><span class="mono" style="color:var(--amber)">' + esc(svc.version || '—') + '</span></div>';
+29 -5
View File
@@ -1486,7 +1486,14 @@ function taipeiMonthDay(ms) { var d = new Date(ms + TAIPEI_OFFSET_MS); return {
$('se-q').value = name;
doGraphSearch(name);
}
// 讀不到就明說「讀不到」——標題列**絕不**留著 0 或舊數字(Arcrun#100leo 看到
// 「0 個實體・0 條關聯」以為要去上傳文件,其實庫裡有 1854 條,只是這支讀失敗了)。
function mapUnavailable(html) {
$('map-meta').textContent = '讀不到';
$('map-box').innerHTML = '<div class="err" style="padding:30px 10px">' + html + '</div>';
}
function loadMap() {
$('map-meta').textContent = '';
$('map-box').innerHTML = '<div class="muted" style="padding:30px 10px">載入總圖中…</div>';
$('map-md-link').innerHTML = SOURCE_WEB_BASE
? '<a href="' + esc(SOURCE_WEB_BASE + '/system-dev/wiki/00-MAP.md') + '" target="_blank" rel="noopener" style="color:var(--amber)">00-MAP.md ↗</a>'
@@ -1495,14 +1502,31 @@ function taipeiMonthDay(ms) { var d = new Date(ms + TAIPEI_OFFSET_MS); return {
.then(function (r) { return safeJson(r).then(function (d) { return { ok: r.ok, status: r.status, d: d }; }); })
.then(function (x) {
if (guard401(x.status)) return;
if (!x.ok) { $('map-box').innerHTML = '<div class="err">' + esc(x.d.error || ('總圖載入失敗(HTTP ' + x.status + '')) + '</div>'; return; }
var nodes = x.d.nodes || [];
var edges = x.d.edges || [];
$('map-meta').textContent = nodes.length + ' 個實體・' + edges.length + ' 條關聯' + (x.d.truncated ? '・已達上限截斷' : '');
if (!x.ok) { mapUnavailable(esc(x.d.error || ('總圖載入失敗(HTTP ' + x.status + ''))); return; }
// Arcrun#100:「0」只准在後端確認過真的是 0 的時候出現。
// 形狀不對 → 讀不到(不是空庫);nodes 為空但 empty_confirmed 不成立 → 讀不到。
if (!Array.isArray(x.d.nodes) || !Array.isArray(x.d.edges)) {
mapUnavailable('總圖回應格式不對——沒有拿到關聯資料。這不代表知識庫是空的。');
return;
}
var nodes = x.d.nodes, edges = x.d.edges;
var total = typeof x.d.triplets_total === 'number' ? x.d.triplets_total : null;
if (!nodes.length && x.d.empty_confirmed !== true) {
mapUnavailable(x.d.empty_reason === 'scope_mismatch'
? '讀不到你這個帳號的關聯資料——知識庫裡有三元組'
+ (total ? '(本帳號範圍算到 ' + total.toLocaleString() + ' 條)' : '')
+ ',但這張圖一條都抽不出來。<br>'
+ '<b>這不是「還沒有關聯」,不用去上傳文件</b>;比較像資料的歸屬範圍對不上,請通知管理員。'
: '讀不到知識庫的關聯資料,無法確認庫裡有沒有關聯。<br>'
+ '<b>這不是「還沒有關聯」,不用去上傳文件</b>——是這次讀取失敗,請稍後重整或通知管理員。');
return;
}
$('map-meta').textContent = nodes.length + ' 個實體・' + edges.length + ' 條關聯'
+ (total !== null && x.d.truncated ? '(全庫共 ' + total.toLocaleString() + ' 條,已達單次上限)' : x.d.truncated ? '・已達上限截斷' : '');
if (!nodes.length) { $('map-box').innerHTML = '<div class="muted" style="padding:30px 10px">知識庫還沒有任何關聯——上傳文件後 AI 會自動織網。</div>'; return; }
renderMap(nodes, edges);
})
.catch(function (e) { $('map-box').innerHTML = '<div class="err">請求失敗:' + esc(friendlyErr(e)) + '</div>'; });
.catch(function (e) { mapUnavailable('請求失敗:' + esc(friendlyErr(e))); });
}
function renderMap(nodes, edges) {
var N = nodes.length;
@@ -48,7 +48,7 @@
*/
import { Hono } from 'hono';
import type { Bindings } from '../types';
import { kbdbBase, graphBase } from './kbdb-proxy';
import { kbdbBase, graphBase, graphHeaders } from './kbdb-proxy';
import { validateConsoleSession } from './console-auth';
import {
type KbdbEntry,
@@ -104,6 +104,31 @@ async function fetchJson<T>(url: string, headers?: Record<string, string>): Prom
}
}
/**
* ****null = 0
*
* 🔴 Arcrun#100 graph-plugin `/triplets/stats` `total`
* `total` **** COUNT `/records/by-template/triplet`KBDB
* `searchByTemplate` limit=100 500 owner 1854
* 100 401 0100
* KBDB `/records/triplet-stats` SQL COUNT(*) owner_id
* `{ success, stats: [{ library, triplet_count }] }`
*/
async function fetchTripletTotal(env: Bindings, tenant: string): Promise<number | null> {
const { base, headers } = kbdbBase(env);
const data = await fetchJson<{ stats?: { triplet_count?: unknown }[] }>(
`${base}/records/triplet-stats?owner_id=${encodeURIComponent(tenant)}`,
headers,
);
if (!data || !Array.isArray(data.stats)) return null;
let total = 0;
for (const row of data.stats) {
if (typeof row?.triplet_count !== 'number') return null; // 形狀不對 → 誠實回讀不到,不半信半疑加總
total += row.triplet_count;
}
return total;
}
/** KBDB entries 符合條件的總數(limit=1 只拿 total 欄,不搬資料)。null = 讀不到。 */
async function fetchEntryTotal(env: Bindings, filters: Record<string, string>): Promise<number | null> {
const { base, headers } = kbdbBase(env);
@@ -254,6 +279,7 @@ consoleDashboardRouter.get('/console/dashboard-data', async (c) => {
kbdbHealth,
embedStatus,
graphStats,
tripletTotal,
entriesTotal,
wikiCardTotal,
workflowTotal,
@@ -265,7 +291,13 @@ consoleDashboardRouter.get('/console/dashboard-data', async (c) => {
cachedGiteaSprint(c.env, now, (p) => c.executionCtx.waitUntil(p)),
fetchJson<{ ok?: boolean }>(`${kbdbUrl}/health`, kbdbHeaders),
fetchJson<{ enabled?: boolean; pending?: number; embedded?: number }>(`${kbdbUrl}/embed/backfill/status`, kbdbHeaders),
fetchJson<{ total?: number; recent?: { today?: number; this_week?: number } }>(`${graphUrl}/triplets/stats`),
// graph-plugin 只拿來判「圖服務活著沒」(燈號)——數字不從這裡拿,見 fetchTripletTotal。
// headers 一定要帶:plugin 的 /triplets 前綴掛 Bearer 閘,漏帶=永遠 401=永遠假紅燈(#100)。
fetchJson<{ total?: number; recent?: { today?: number; this_week?: number } }>(
`${graphUrl}/triplets/stats`,
graphHeaders(c.env),
),
fetchTripletTotal(c.env, tenant),
// owner_id 一律鎖本租戶:原本不帶 owner 會混到別租戶(實測 459,137 vs leo 的 458,732
fetchEntryTotal(c.env, { owner_id: tenant }),
fetchEntryTotal(c.env, { entry_type: 'wiki_card', owner_id: tenant }),
@@ -400,13 +432,14 @@ consoleDashboardRouter.get('/console/dashboard-data', async (c) => {
embed: embedStatus
? { enabled: embedStatus.enabled === true, embedded: embedStatus.embedded ?? null, pending: embedStatus.pending ?? null }
: null,
graph: graphStats ? { ok: true, triplets: graphStats.total ?? null } : { ok: false, triplets: null },
// ok = plugin 通不通(graphStats 讀得到就是通);triplets = KBDB 真 COUNT(與 plugin 分頁長度無關)
graph: { ok: graphStats !== null, triplets: tripletTotal },
workflow_total: workflowTotal,
},
kb: {
entries_total: entriesTotal,
wiki_card_total: wikiCardTotal,
triplets_total: graphStats?.total ?? null,
triplets_total: tripletTotal,
},
generated_at: new Date(now).toISOString(),
});
@@ -420,15 +453,15 @@ consoleDashboardRouter.get('/console/dashboard-data', async (c) => {
consoleDashboardRouter.get('/console/kb-scale-data', async (c) => {
const tenant = c.env.CONSOLE_TENANT || 'leo';
const { base, headers } = kbdbBase(c.env);
const graphUrl = graphBase(c.env);
const now = Date.now();
const [wikiCards, graphStats, embedStatus] = await Promise.all([
const [wikiCards, tripletTotal, embedStatus] = await Promise.all([
// limit=1 順手拿最新一筆 created_atlist 為 created_at DESC)=「最近寫入時間」
fetchJson<{ total?: number; entries?: { created_at?: string | number }[] }>(
`${base}/entries?${new URLSearchParams({ owner_id: tenant, entry_type: 'wiki_card', limit: '1' }).toString()}`,
headers,
),
fetchJson<{ total?: number }>(`${graphUrl}/triplets/stats`),
// #100:三元組數改讀 KBDB 真 COUNT,不再讀 graph-plugin 的分頁長度(見 fetchTripletTotal 註)
fetchTripletTotal(c.env, tenant),
fetchJson<{ enabled?: boolean; embedded?: number; pending?: number }>(`${base}/embed/backfill/status`, headers),
]);
const latestMs = parseCreatedAtMs(wikiCards?.entries?.[0]?.created_at ?? null);
@@ -436,7 +469,7 @@ consoleDashboardRouter.get('/console/kb-scale-data', async (c) => {
return c.json({
wiki_card_total: typeof wikiCards?.total === 'number' ? wikiCards.total : null,
wiki_card_latest_ago_minutes: latestMs === null ? -1 : agoMinutes(now, latestMs),
triplets_total: typeof graphStats?.total === 'number' ? graphStats.total : null,
triplets_total: tripletTotal,
embedded: embedStatus?.embedded ?? null,
embed_enabled: embedStatus ? embedStatus.enabled === true : null,
generated_at: new Date(now).toISOString(),
+16 -2
View File
@@ -223,13 +223,27 @@ export function graphBase(env: Bindings): string {
return `https://kbdb-graph-plugin.${env.WORKER_SUBDOMAIN}.workers.dev`;
}
/**
* kbdb-graph-plugin internal headers** plugin **Arcrun#100
*
* plugin kbdb-graph-plugin/src/index.ts `/triplets` `/graph` `/search` `/entities`
* Bearer KBDB_INTERNAL_TOKEN 401
* neighborsportal-data neighborsconsole-dashboard stats
* `/triplets/stats` 401 0
*
*/
export function graphHeaders(env: Bindings): Record<string, string> {
const headers: Record<string, string> = {};
if (env.KBDB_INTERNAL_TOKEN) headers['Authorization'] = `Bearer ${env.KBDB_INTERNAL_TOKEN}`;
return headers;
}
// GET /kbdb/graph/neighbors/:name — 查某節點(entity/卡片名)的鄰居 + 邊。
// 查無 triplet 資料時 plugin 回空陣列——前端據此顯示「尚無關聯資料」(誠實,不編造關聯)。
kbdbProxyRouter.get('/kbdb/graph/neighbors/:name', async (c) => {
if (!tenant(c)) return c.json(NEED_KEY, 401);
const base = graphBase(c.env);
const headers: Record<string, string> = {};
if (c.env.KBDB_INTERNAL_TOKEN) headers['Authorization'] = `Bearer ${c.env.KBDB_INTERNAL_TOKEN}`;
const headers = graphHeaders(c.env);
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' } });
+74 -6
View File
@@ -24,7 +24,7 @@ import { Hono } from 'hono';
import type { Context } from 'hono';
import type { Bindings } from '../types';
import { kbdbFetch, run, requirePortalUser, parseLibraries, portalTenant, hasGraphAccess, workflowsVisible, uploadEnabled, buildDiagnostics } from './portal';
import { graphBase } from './kbdb-proxy';
import { graphBase, graphHeaders } from './kbdb-proxy';
import { executeWebhookGraph } from '../actions/webhook-handlers';
export const portalDataRouter = new Hono<{ Bindings: Bindings }>();
@@ -186,6 +186,45 @@ export function findBestNodeMatch(searchTerm: string, nodeNames: string[]): stri
return hits.reduce((a, b) => a.length <= b.length ? a : b);
}
/**
* KBDB `/records/triplet-stats` SQL COUNTowner '' KBDB
* `?1 = '' OR e.owner_id = ?1`nullcaller 0
*/
async function tripletCount(env: Bindings, owner: string): Promise<number | null> {
try {
const res = await kbdbFetch(env, `/records/triplet-stats?owner_id=${encodeURIComponent(owner)}`);
if (!res.ok) return null;
const body = (await res.json().catch(() => null)) as { stats?: { triplet_count?: unknown }[] } | null;
if (!body || !Array.isArray(body.stats)) return null;
let total = 0;
for (const row of body.stats) {
if (typeof row?.triplet_count !== 'number') return null;
total += row.triplet_count;
}
return total;
} catch {
return null;
}
}
/**
* Arcrun#100
*
* leo portal.ts §.5 daemon diagnostics**
* **t161 record owner_id None owner_id
* 0 ****
* owner
* owned>0
* owned=0 any=0 0
* owned=0 any>0 owner_id /
* owned=null
*/
async function tripletCensus(env: Bindings, tenant: string): Promise<{ owned: number | null; any: number | null }> {
const owned = await tripletCount(env, tenant);
if (owned !== 0) return { owned, any: null }; // 非 0(含 null)不必多問一次
return { owned, any: await tripletCount(env, '') };
}
/** 從 KBDB triplet records 找最佳比對節點名(t96 plugin fuzzy fallback 用)。 */
async function fuzzyFindNode(env: Bindings, tenant: string, searchTerm: string): Promise<string | null> {
try {
@@ -343,8 +382,7 @@ portalDataRouter.get('/portal/data/graph/neighbors/:name', (c) =>
// ② plugin fallbackMira/leo21c 相容)
const base = graphBase(c.env);
const headers: Record<string, string> = {};
if (c.env.KBDB_INTERNAL_TOKEN) headers['Authorization'] = `Bearer ${c.env.KBDB_INTERNAL_TOKEN}`;
const headers = graphHeaders(c.env);
try {
const res = await fetch(`${base}/graph/neighbors/${encodeURIComponent(nodeName)}`, { headers });
if (!res.ok) {
@@ -383,14 +421,23 @@ portalDataRouter.get('/portal/data/graph/overview', (c) =>
return c.json({ error: '無知識圖譜檢視權限' }, 403);
}
const tenant = portalTenant(c.env);
const res = await kbdbFetch(c.env, `/records/by-template/triplet?owner_id=${encodeURIComponent(tenant)}`);
const [res, census] = await Promise.all([
kbdbFetch(c.env, `/records/by-template/triplet?owner_id=${encodeURIComponent(tenant)}&limit=500`),
tripletCensus(c.env, tenant),
]);
const tripletsTotal = census.owned;
if (!res.ok) {
return new Response(res.body, { status: res.status, headers: { 'Content-Type': 'application/json' } });
}
const body = (await res.json().catch(() => null)) as
| { records?: { values?: Record<string, unknown> }[] }
| null;
const records = body && Array.isArray(body.records) ? body.records : [];
// #100:形狀不對 ≠ 沒有資料。原本 `: []` 會把「讀不出來」變成一張空圖,
// 前端照著印「0 個實體・0 條關聯」——那是畫面在說謊。讀不出來就誠實 502。
if (!body || !Array.isArray(body.records)) {
return c.json({ error: '三元組讀取失敗:KBDB 回應不是預期的 records 清單' }, 502);
}
const records = body.records;
const EDGE_CAP = 500;
const seen = new Set<string>();
const edges: { subject: string; predicate: string; object: string }[] = [];
@@ -413,7 +460,28 @@ portalDataRouter.get('/portal/data/graph/overview', (c) =>
degree.set(o, (degree.get(o) ?? 0) + 1);
}
const nodes = [...degree.entries()].map(([name, d]) => ({ name, degree: d }));
return c.json({ nodes, edges, node_count: nodes.length, edge_count: edges.length, truncated });
// #100:一張空圖有三種成因,前端必須分得出來(判準留在 server,不留給前端猜)——
// confirmed_empty :本租戶真的一條都沒有,全庫也沒有 → 才准印「0 個實體・0 條關聯」
// scope_mismatch :全庫有、本租戶查不到 → owner_id/範圍對不上,不是空庫(t161 前科)
// unreadable :連條數都讀不到 → 只能說讀不到
let emptyReason: 'confirmed_empty' | 'scope_mismatch' | 'unreadable' | null = null;
if (nodes.length === 0) {
if (census.owned === null) emptyReason = 'unreadable';
else if (census.owned > 0) emptyReason = 'scope_mismatch'; // 有條數卻抽不出邊
else if (census.any === null) emptyReason = 'unreadable';
else emptyReason = census.any > 0 ? 'scope_mismatch' : 'confirmed_empty';
}
return c.json({
nodes,
edges,
node_count: nodes.length,
edge_count: edges.length,
// 取到的 record 已達 KBDB 單頁上限 → 這張圖只是全庫的一部分,別讓 meta 看起來像全部
truncated: truncated || records.length >= 500,
triplets_total: tripletsTotal,
empty_confirmed: nodes.length > 0 || emptyReason === 'confirmed_empty',
empty_reason: emptyReason,
});
}),
);
@@ -0,0 +1,147 @@
/**
* Arcrun#100 0 0
*
* leo 0 0 AI
* 1854
*
*
* kbdb-graph-plugin `/triplets` Bearer cypher ** token**
* console-dashboard stats 401
* KBDB `/records/triplet-stats` SQL COUNT owner
* **** plugin `/triplets/stats` `total`KBDB 100/500
* 1854 100 401 0100
* null / 502 / empty_confirmed=false**退 0**
*
* KBDBgraph-plugin fetchMock hostwrangler.test.toml KBDB_BASE_URL=https://kbdb.test、
* KBDB_GRAPH_URL=https://graph.test)+disableNetConnect——絕不外連。
*/
import { SELF, env, fetchMock } from 'cloudflare:test';
import { beforeAll, afterEach, describe, it, expect } from 'vitest';
import { graphHeaders, graphBase } from '../src/routes/kbdb-proxy';
import type { Bindings } from '../src/types';
const KBDB = 'https://kbdb.test';
const GRAPH = 'https://graph.test';
const TENANT = 'leo'; // wrangler.test.toml CONSOLE_TENANT
beforeAll(() => {
fetchMock.activate();
fetchMock.disableNetConnect();
});
afterEach(() => fetchMock.assertNoPendingInterceptors());
/** KBDB `/records/triplet-stats` — 真 COUNT 的形狀:{ success, stats: [{library, triplet_count}] } */
function mockTripletStats(rows: { library: string; triplet_count: number }[] | null, status = 200) {
fetchMock
.get(KBDB)
.intercept({ path: (p: string) => p.startsWith('/records/triplet-stats'), method: 'GET' })
.reply(status, rows === null ? { success: false, error: 'boom' } : { success: true, stats: rows });
}
// ═══════════════ 1. graphHeaders:打 plugin 的 header 只有一份 ═══════════════
describe('graphHeaders#100 漂移的根:三處手拼 → 一支函式)', () => {
it('有 KBDB_INTERNAL_TOKEN → 帶 Bearerplugin 的 /triplets /graph /search /entities 全靠它)', () => {
expect(graphHeaders({ KBDB_INTERNAL_TOKEN: 'tok-abc' } as unknown as Bindings)).toEqual({
Authorization: 'Bearer tok-abc',
});
});
it('沒設 token → 空 headersplugin 未設 secret 時本來就開放,不硬塞空 Bearer)', () => {
expect(graphHeaders({} as unknown as Bindings)).toEqual({});
});
it('graphBase 仍照舊(KBDB_GRAPH_URL 優先、去尾斜線)', () => {
expect(graphBase({ KBDB_GRAPH_URL: 'https://graph.test/' } as unknown as Bindings)).toBe('https://graph.test');
});
});
// ═══════════════ 2. /console/kb-scale-data:數字對得上庫裡真正的數量 ═══════════════
describe('GET /console/kb-scale-data — 三元組數=KBDB 真 COUNT', () => {
it('庫裡 1854 條(跨三個庫)→ triplets_total 回 1854,不是 plugin 的分頁長度 100', async () => {
mockTripletStats([
{ library: 'general', triplet_count: 1200 },
{ library: 'finance', triplet_count: 600 },
{ library: 'ops', triplet_count: 54 },
]);
const res = await SELF.fetch('http://localhost/console/kb-scale-data');
expect(res.status).toBe(200);
const d = (await res.json()) as { triplets_total: number | null };
expect(d.triplets_total).toBe(1854);
});
it('反向:triplet-stats 讀不到(500)→ triplets_total = null**不是 0**', async () => {
mockTripletStats(null, 500);
const res = await SELF.fetch('http://localhost/console/kb-scale-data');
expect(res.status).toBe(200);
const d = (await res.json()) as { triplets_total: number | null };
expect(d.triplets_total).toBeNull();
expect(d.triplets_total).not.toBe(0); // 這一行就是 #100 的整個重點
});
it('反向:回應形狀不對(stats 不是陣列)→ null,不半信半疑當 0', async () => {
fetchMock
.get(KBDB)
.intercept({ path: (p: string) => p.startsWith('/records/triplet-stats'), method: 'GET' })
.reply(200, { success: true, stats: 'oops' });
const res = await SELF.fetch('http://localhost/console/kb-scale-data');
const d = (await res.json()) as { triplets_total: number | null };
expect(d.triplets_total).toBeNull();
});
it('真的是 0(庫存在但沒有任何三元組)→ 誠實回 0(0 只在這種時候出現)', async () => {
mockTripletStats([]);
const res = await SELF.fetch('http://localhost/console/kb-scale-data');
const d = (await res.json()) as { triplets_total: number | null };
expect(d.triplets_total).toBe(0);
});
it('kb-scale-data 不再打 graph-plugin(沒有 plugin interceptor 也能拿到數字)', async () => {
mockTripletStats([{ library: 'general', triplet_count: 7 }]);
const res = await SELF.fetch('http://localhost/console/kb-scale-data');
const d = (await res.json()) as { triplets_total: number | null };
expect(d.triplets_total).toBe(7); // 打 GRAPH 的話 disableNetConnect 會讓它變 null
});
});
// ═══════════════ 3. /console/dashboard-data:燈號問 plugin、數字問 KBDB ═══════════════
describe('GET /console/dashboard-data — 圖服務健康 vs 三元組數量是兩件事', () => {
it('打 plugin /triplets/stats **有帶 Bearer** → graph.ok=true;數量仍取 KBDB 真 COUNT', async () => {
// headers matcher:漏帶 Authorization 就配不到這個 interceptor → 請求失敗 → graph.ok=false
fetchMock
.get(GRAPH)
.intercept({
path: (p: string) => p.startsWith('/triplets/stats'),
method: 'GET',
headers: { authorization: `Bearer ${env.KBDB_INTERNAL_TOKEN}` },
})
.reply(200, { total: 100 }); // plugin 的分頁長度,故意與真值不同
mockTripletStats([{ library: 'general', triplet_count: 1854 }]);
const res = await SELF.fetch('http://localhost/console/dashboard-data');
expect(res.status).toBe(200);
const d = (await res.json()) as {
system: { graph: { ok: boolean; triplets: number | null } };
kb: { triplets_total: number | null };
};
expect(d.system.graph.ok).toBe(true); // 帶了 token 才會是 true#100 迴歸閘)
expect(d.system.graph.triplets).toBe(1854); // 不是 plugin 的 100
expect(d.kb.triplets_total).toBe(1854);
});
it('反向:plugin 打不通 → graph.ok=false,但三元組數照樣是真的(不被服務狀態吞掉)', async () => {
mockTripletStats([{ library: 'general', triplet_count: 1854 }]);
const res = await SELF.fetch('http://localhost/console/dashboard-data');
const d = (await res.json()) as { system: { graph: { ok: boolean; triplets: number | null } } };
expect(d.system.graph.ok).toBe(false);
expect(d.system.graph.triplets).toBe(1854);
});
it('反向:兩邊都讀不到 → ok=false + triplets=null(不是 0', async () => {
const res = await SELF.fetch('http://localhost/console/dashboard-data');
const d = (await res.json()) as { system: { graph: { ok: boolean; triplets: number | null } } };
expect(d.system.graph.ok).toBe(false);
expect(d.system.graph.triplets).toBeNull();
});
});
+88
View File
@@ -942,3 +942,91 @@ describe('GET /portal/daemon/diagnosticst213 daemon 版)', () => {
expect(JSON.stringify(body.notes)).not.toContain('截圖');
});
});
// ═══ Arcrun#100: 總圖的「0」只准在真的是 0 的時候出現 ═══
describe('GET /portal/data/graph/overview#100 空圖三態)', () => {
/** KBDB `/records/triplet-stats`:帶 owner 與不帶 owner 是兩條不同路徑,分別攔。 */
function mockCount(scoped: number | null, global?: number | null) {
fetchMock
.get(KBDB)
.intercept({ path: (p: string) => p.startsWith(`/records/triplet-stats?owner_id=${TENANT}`), method: 'GET' })
.reply(scoped === null ? 500 : 200, scoped === null ? { error: 'boom' } : { success: true, stats: [{ library: 'general', triplet_count: scoped }] });
if (global !== undefined) {
fetchMock
.get(KBDB)
.intercept({ path: (p: string) => p === '/records/triplet-stats?owner_id=', method: 'GET' })
.reply(global === null ? 500 : 200, global === null ? { error: 'boom' } : { success: true, stats: [{ library: 'general', triplet_count: global }] });
}
}
function mockTriplets(body: object, status = 200) {
fetchMock
.get(KBDB)
.intercept({ path: (p: string) => p.startsWith('/records/by-template/triplet'), method: 'GET' })
.reply(status, body);
}
async function overview(token: string) {
await seedSession(token, `rec_${token}`);
mockGetRecord(`rec_${token}`, userValues({ libraries: '["*"]', role: 'admin' }));
return get('/portal/data/graph/overview', { Authorization: `Bearer ${token}` });
}
it('有資料 → 照常回圖,並附上全庫真實條數', async () => {
mockTriplets({ success: true, records: [{ values: { subject: 'A', predicate: '連到', object: 'B' } }] });
mockCount(1854);
const res = await overview('tok-ov1');
expect(res.status).toBe(200);
const d = (await res.json()) as { node_count: number; triplets_total: number; empty_confirmed: boolean };
expect(d.node_count).toBe(2);
expect(d.triplets_total).toBe(1854);
expect(d.empty_confirmed).toBe(true);
});
it('真的空(本租戶 0、全庫也 0)→ empty_confirmed=true,畫面才准印 0', async () => {
mockTriplets({ success: true, records: [] });
mockCount(0, 0);
const res = await overview('tok-ov2');
const d = (await res.json()) as { node_count: number; empty_confirmed: boolean; empty_reason: string };
expect(d.node_count).toBe(0);
expect(d.empty_confirmed).toBe(true);
expect(d.empty_reason).toBe('confirmed_empty');
});
it('🔴 反向:本租戶查到 0、全庫卻有 1854(t161 owner_id 對不上)→ 不准說空,回 scope_mismatch', async () => {
mockTriplets({ success: true, records: [] });
mockCount(0, 1854);
const res = await overview('tok-ov3');
const d = (await res.json()) as { empty_confirmed: boolean; empty_reason: string };
expect(d.empty_confirmed).toBe(false);
expect(d.empty_reason).toBe('scope_mismatch');
});
it('🔴 反向:條數讀不到 → unreadable(不是 confirmed_empty,畫面顯示「讀不到」)', async () => {
mockTriplets({ success: true, records: [] });
mockCount(null);
const res = await overview('tok-ov4');
const d = (await res.json()) as { empty_confirmed: boolean; empty_reason: string; triplets_total: number | null };
expect(d.empty_confirmed).toBe(false);
expect(d.empty_reason).toBe('unreadable');
expect(d.triplets_total).toBeNull();
});
it('🔴 反向:有條數卻一條邊都抽不出來 → scope_mismatch,不是空庫', async () => {
mockTriplets({ success: true, records: [{ values: { subject: '', object: '' } }] });
mockCount(1854);
const res = await overview('tok-ov5');
const d = (await res.json()) as { node_count: number; empty_confirmed: boolean; empty_reason: string };
expect(d.node_count).toBe(0);
expect(d.empty_reason).toBe('scope_mismatch');
expect(d.empty_confirmed).toBe(false);
});
it('🔴 反向:KBDB 回應形狀不對(沒有 records 陣列)→ 502,不再回一張空圖', async () => {
mockTriplets({ success: true, items: [] }); // 欄位名不對=讀不出來
mockCount(1854);
const res = await overview('tok-ov6');
expect(res.status).toBe(502);
const d = (await res.json()) as { error: string };
expect(d.error).toContain('三元組讀取失敗');
});
});
+4
View File
@@ -49,6 +49,10 @@ KBDB_BASE_URL = "https://kbdb.test"
CONSOLE_TENANT = "leo"
# portal-auth P3graph 粗閘放行後的轉發目標也指假 host(fetchMock 攔截,絕不外連)
KBDB_GRAPH_URL = "https://graph.test"
# Arcrun#100kbdb-graph-plugin 對 /triplets /graph /search /entities 掛 Bearer 閘。測試環境要有
# 這把(明顯的假字串、非真實金鑰)才驗得出「cypher 打 plugin 有沒有帶 token」——原本兩支
# /triplets/stats 漏帶 → 永遠 401 → 前端「三元組 0」。真實部署仍走 wrangler secret put。
KBDB_INTERNAL_TOKEN = "test-fake-not-a-real-token" # credential-ok:測試假值,同上方 CF_SECRETS_API_TOKEN 慣例
# D61ADR D61 / Leo/arcrun-rag#55):認證儲存(lib/portal-auth-store.ts)走 CF Workers
# Scripts secrets 管理 APIhttps://api.cloudflare.com/...),authStoreWritable() 只看這兩項
# 存不存在。測試環境預設就緒(比照真實已裝妥的實例),值是明顯的假字串、非真實金鑰;實際的
+66 -13
View File
@@ -216,12 +216,63 @@ const MAX_LIKE_TERMS = 6;
const utf8Len = (s: string): number => new TextEncoder().encode(s).length;
/** 依 UTF-8 byte 上限切片,不切壞多位元組字元。 */
// ── 使用者打的 `%` 與 `_` 是「要找的字」,不是萬用字元(Arcrun#94)────────────────
//
// 病徵:搜尋框打 `100%` 或 `owner_id`,回來一堆跟那些字無關的東西。
// SQLite LIKE 只有兩個萬用字元——`%`(任意長度)與 `_`(任意一個字元),而且
// **沒有預設跳脫字元**(不寫 ESCAPE 就沒有任何辦法表示「字面上的 %」)。
// 我們把使用者輸入直接內插成 `'%' + q + '%'` ⇒ 他打的符號被當成 pattern 語法:
// `100%` → `%100%%` → 「100」開頭後面接什麼都算 ⇒ 撈回一堆不相干的
// `owner_id` → `%owner_id%` → `_` 匹配任一字元 ⇒ `ownerXid`、`owner-id` 也中
// `%``_` 單打 → `%%%``%_%` → **整個庫都回來**(`_` 只要有一個字元就中)
//
// 這是舊病,不是 08-10 斷詞(47c6aae→本檔上一段)引進的:pattern 一直都是這樣拼的。
// 之前關鍵字搜尋幾乎恆為 0 命中(整串比對),這個洞被那個洞蓋住,看不出來;
// 斷詞讓搜尋真的會回東西之後它才浮出來。**斷詞那段一個字都沒動。**
//
// 修法:三個字元都跳脫,並在每個 LIKE 後面掛 `ESCAPE '\'`。
//
// 為什麼**跳脫字元本身(`\`)也要跳脫**(邊界問題的答案,不是順手多做):
// 一旦宣告了 ESCAPE`\` 在 pattern 裡就變成有意義的字元,於是「使用者打的 `\`」
// 同樣會被誤讀——而且是更糟的一種,因為它會**把後面那個字吃掉**:
// 使用者打 `100\%` → 不跳脫 `\` ⇒ pattern `%100\%%` ⇒ `\%`=字面 %
// ⇒ 實際找的是 `100%`,**跟他打的字不一樣**
// 使用者打 `C:\` → pattern `%C:\%` ⇒ 尾巴 `\%`=字面 %
// ⇒ 找的是 `C:%`,而真正的 `C:\` 反而找不到
// ⇒ 三個字元是一組的:宣告 ESCAPE 卻不跳脫 `\` 等於用新的漏洞換掉舊的。
// SQLite 對「`\` 後面接其他字元」是寬容的——照字面匹配下一個字、不報錯——
// 所以不跳脫不會炸,只會靜靜地找錯東西,正是最難發現的那種。)
//
// 為什麼**只有這三個**SQLite 的 LIKE 萬用字元就只有 `%` 和 `_``[...]`、`?`、`*`
// 是別的方言/GLOB 的東西,LIKE 不吃),加上自己宣告的跳脫字元 `\`,就這三個。
// 不多跳脫其他字元——跳脫沒有語法意義的字元只會白白吃掉 pattern 的 byte 預算。
//
// 🔴 與 50 bytes 上限的交互作用(不能只加跳脫就收工):跳脫會**變長**(`%`→`\%`),
// 所以所有 byte 預算改用「跳脫後」的長度算(likeBytes),否則使用者打一串 `%`
// 會讓 pattern 膨脹回 50 bytes 以上 ⇒ 退回 2026-08-03 那個 500。
// 不含這三個字元的查詢,likeBytes ≡ utf8Len ⇒ **既有查詢的行為逐字不變**。
const LIKE_ESCAPE = '\\';
/** 每個 `content LIKE ?` 都要帶著它的 ESCAPE 宣告,否則跳脫過的 pattern 反而被當字面。 */
const CONTENT_LIKE = `content LIKE ? ESCAPE '${LIKE_ESCAPE}'`;
/** 把使用者輸入當「字面字串」送進 LIKE(純函式,單測用 export)。 */
export function escapeLikeLiteral(s: string): string {
// 一次掃描、每個字元各自替換 ⇒ 不會發生「先換 % 再換 \ 把剛加的跳脫又跳脫一次」。
return s.replace(/[\\%_]/g, (ch) => LIKE_ESCAPE + ch);
}
/** 這段文字**跳脫後**佔的 byte 數(=它在 LIKE pattern 裡真正佔的長度)。 */
const likeBytes = (s: string): number => utf8Len(escapeLikeLiteral(s));
/** 子字串比對用的 pattern:只有頭尾那兩個 `%` 是萬用字元,中間全是字面。 */
const likePattern = (s: string): string => `%${escapeLikeLiteral(s)}%`;
/** 依 UTF-8 byte 上限切片,不切壞多位元組字元。上限算的是**跳脫後**的長度。 */
function chunkByBytes(s: string, maxBytes: number): string[] {
const out: string[] = [];
let cur = '';
for (const ch of s) {
if (utf8Len(cur + ch) > maxBytes) {
if (likeBytes(cur + ch) > maxBytes) {
if (cur) out.push(cur);
cur = ch;
} else {
@@ -237,8 +288,8 @@ function chunkByBytes(s: string, maxBytes: number): string[] {
* `split=false` LIKE
*/
export function buildContentLike(q: string): { conds: string[]; params: string[]; split: boolean } {
if (utf8Len(q) <= MAX_LIKE_Q_BYTES) {
return { conds: ['content LIKE ?'], params: [`%${q}%`], split: false };
if (likeBytes(q) <= MAX_LIKE_Q_BYTES) {
return { conds: [CONTENT_LIKE], params: [likePattern(q)], split: false };
}
const terms: string[] = [];
for (const word of q.split(/\s+/).filter(Boolean)) {
@@ -251,8 +302,8 @@ export function buildContentLike(q: string): { conds: string[]; params: string[]
// 理論上不會空(q 非空才進得來),但空陣列會產出 `WHERE` 沒有條件 ⇒ 保底退回單一截斷 LIKE
if (terms.length === 0) terms.push(chunkByBytes(q, MAX_LIKE_Q_BYTES)[0] ?? '');
return {
conds: terms.map(() => 'content LIKE ?'),
params: terms.map((t) => `%${t}%`),
conds: terms.map(() => CONTENT_LIKE),
params: terms.map(likePattern),
split: true,
};
}
@@ -428,7 +479,7 @@ export function buildSearchScore(q: string): SearchScorePlan {
if (terms.length === 0) {
const m = buildContentLike(trimmed);
return {
scoreExpr: m.conds.map(() => 'CASE WHEN content LIKE ? THEN 1 ELSE 0 END').join(' + '),
scoreExpr: m.conds.map(() => `CASE WHEN ${CONTENT_LIKE} THEN 1 ELSE 0 END`).join(' + '),
scoreParams: m.params,
terms: [],
legacyShape: true,
@@ -438,16 +489,16 @@ export function buildSearchScore(q: string): SearchScorePlan {
const parts: string[] = [];
const params: string[] = [];
for (const { term, weight } of terms) {
parts.push(`CASE WHEN content LIKE ? THEN ${weight} ELSE 0 END`);
params.push(`%${term}%`);
parts.push(`CASE WHEN ${CONTENT_LIKE} THEN ${weight} ELSE 0 END`);
params.push(likePattern(term));
}
// 單詞查詢:整句 == 那個詞 ⇒ 不重複加一次 LIKE。送出的 SQL 與舊版一模一樣(成本也一樣)。
const single = terms.length === 1 && terms[0].term === trimmed;
if (!single && utf8Len(trimmed) <= MAX_LIKE_Q_BYTES) {
if (!single && likeBytes(trimmed) <= MAX_LIKE_Q_BYTES) {
const bonus = terms.reduce((s, t) => s + t.weight, 0);
parts.push(`CASE WHEN content LIKE ? THEN ${bonus} ELSE 0 END`);
params.push(`%${trimmed}%`);
parts.push(`CASE WHEN ${CONTENT_LIKE} THEN ${bonus} ELSE 0 END`);
params.push(likePattern(trimmed));
}
return { scoreExpr: parts.join(' + '), scoreParams: params, terms, legacyShape: single };
@@ -512,7 +563,9 @@ export function isDeprecatedEntry(entry: { metadata_json?: string | null }): boo
// includeDeprecateddaemon-beta t24):預設 false=濾掉 status=deprecated 的下架內容。
// 保留 true 選項給管理面查殘留(審計/驗證下架有沒有真的生效)用,正常搜尋路徑不帶。
// 加在參數最尾端,既有 positional callersource 之後)一個都不用改。
// 2026-08-10(本次):q 改走 buildSearchScore——**斷詞 + 覆蓋率排序**,取代整串 LIKE。
// 2026-08-12Arcrun#94):q 裡的 `%` `_` `\` 一律當字面字元(escapeLikeLiteral ESCAPE 宣告)
// ——使用者打什麼字就照那些字找。舊病,見上面 LIKE_ESCAPE 那段。
// 2026-08-10q 改走 buildSearchScore——**斷詞 + 覆蓋率排序**,取代整串 LIKE。
// 回傳的 entry 多一個 match_score 欄(加欄不改形,同 semantic 路徑的 score 慣例;
// 既有 caller 不解析多的欄位,不受影響)。詳細理由見上面那段長註解。
export async function searchEntries(
+253
View File
@@ -0,0 +1,253 @@
// 搜尋框裡的 `%` 與 `_` 是「要找的字」,不是萬用字元 —— Arcrun#942026-08-12
//
// 病徵(leo 回報):搜尋框打 `%` 或 `_`,搜出來一堆跟他打的字**無關**的東西。
// 根因:pattern 一直是 `'%' + 使用者輸入 + '%'` 直接內插,而 SQLite 的 LIKE 有兩個
// 萬用字元 `%`/`_` 且**沒有預設跳脫字元** ⇒ 使用者打的符號被當成 pattern 語法。
//
// 舊病,不是 08-10 斷詞(search-tokenize.test.ts)引進的:pattern 從來就是這樣拼的。
// 之前關鍵字搜尋幾乎恆為 0 命中,這個洞被那個洞蓋住;斷詞讓搜尋真的會回東西之後才浮出來。
//
// 測試策略:**用真 SQLite 跑真的 SQL**node:sqlite,與 library-mapembed-backfill 同款 adapter)。
// 只驗 SQL 形狀不算數——「% 被當成萬用字元」這件事,只有真的跑一次 LIKE 才看得見。
// 每組驗收都同時跑「舊寫法」與「現行寫法」,讓前後對照直接長在測試裡(legacyPattern)。
//
// 註:直接對 SQLite 治具下 SQL 的行集中在下面的 helper(測試治具本身,非牆外業務邏輯繞過
// API),每行標 kbdb-sql-ok 留痕——與 embed-backfilllibrary-backfill 等既有測試同慣例。
import { describe, it, expect } from 'vitest';
import { DatabaseSync } from 'node:sqlite';
import { readFileSync } from 'node:fs';
import {
escapeLikeLiteral,
buildContentLike,
buildSearchScore,
searchEntries,
createEntry,
} from '../src/actions/entry-crud';
// ── node:sqlite → D1 最小 adapter ────────────────────────────────────────────
function makeSqliteD1(): D1Database {
const raw = new DatabaseSync(':memory:'); // kbdb-sql-ok:記憶體測試替身,非真 KBDB D1
raw.exec(readFileSync(new URL('../migrations/0001_base.sql', import.meta.url), 'utf8')); // kbdb-sql-ok:測試治具套 migration 原檔
function stmt(sql: string, params: unknown[]) {
return {
bind(...args: unknown[]) { return stmt(sql, args); },
async all<T>() { return { results: raw.prepare(sql).all(...(params as never[])) as T[] }; }, // kbdb-sql-ok:測試治具(node:sqlite→D1 shim
async first<T>() { return (raw.prepare(sql).get(...(params as never[])) ?? null) as T | null; }, // kbdb-sql-ok:測試治具(node:sqlite→D1 shim
async run() { raw.prepare(sql).run(...(params as never[])); return { success: true }; }, // kbdb-sql-ok:測試治具(node:sqlite→D1 shim
};
}
return { prepare: (sql: string) => stmt(sql, []) } as unknown as D1Database;
}
/** 舊寫法(本次修掉的那個):使用者輸入直接內插、LIKE 不帶 ESCAPE。前後對照用。 */
const legacyPattern = (q: string) => `%${q}%`;
/** 對真 SQLite 跑一次 `content LIKE ?`,回命中的 content(要不要帶 ESCAPE 可選)。 */
async function likeHits(db: D1Database, pattern: string, escape: boolean): Promise<string[]> {
const pred = escape ? "content LIKE ? ESCAPE '\\'" : 'content LIKE ?';
const res = await db
.prepare(`SELECT content FROM entries WHERE ${pred} ORDER BY id`) // kbdb-sql-ok:測試治具讀回斷言用
.bind(pattern)
.all<{ content: string }>();
return (res.results ?? []).map((x) => x.content);
}
/** 把 searchEntries 送出的 SQL 側錄下來(不改行為,只是中間插一層)。 */
function sqlSpy(db: D1Database): { spy: D1Database; sqls: string[] } {
const sqls: string[] = [];
const spy = {
prepare: (sql: string) => { sqls.push(sql); return db.prepare(sql); }, // kbdb-sql-ok:測試治具側錄,轉呼叫同一顆治具 DB
} as unknown as D1Database;
return { spy, sqls };
}
const bytes = (s: string) => new TextEncoder().encode(s).length;
const MAX_PATTERN = 50; // D1 LIKE pattern 硬上限(承 2026-08-03 的 500 修復)
// 一組刻意設計的語料:每一筆都用來分辨「字面命中」與「萬用字元誤中」。
const CORPUS = [
'毛利率 100% 達成', // 含字面 %
'共有 100 個待辦項目', // 含 100 但不含 %`%100%%` 會誤中它
'owner_id 是租戶隔離的欄位', // 含字面 _
'ownerXid 是打錯的欄位名', // `_` 當萬用字元才會中
'路徑 C:\\_temp 底下', // 含字面「反斜線+底線」(跳脫字元本身 + 萬用字元)
'路徑 C:\\Xtemp 底下', // 反斜線後接任一字元——`_` 漏成萬用字元才會中
'完全無關的一筆內容', // 對照組:什麼都不該中
];
async function seeded(): Promise<D1Database> {
const db = makeSqliteD1();
for (const [i, content] of CORPUS.entries()) {
await createEntry(db, { id: `e${i}`, content, entry_type: 'block', owner_id: 'leo' });
}
return db;
}
describe('① 前後對照:使用者打什麼字,就照那些字找', () => {
it('`100%`:舊寫法把 % 當萬用字元、連「100 個待辦」都撈回來;現行只回真的含 100% 的', async () => {
const db = await seeded();
const before = await likeHits(db, legacyPattern('100%'), false);
const after = await likeHits(db, buildContentLike('100%').params[0], true);
expect(before).toEqual(['毛利率 100% 達成', '共有 100 個待辦項目']); // ← 病徵:多了不相干的
expect(after).toEqual(['毛利率 100% 達成']); // ← 只有真的含「100%」的
});
it('`owner_id`:舊寫法 _ 匹配任一字元、把 ownerXid 也撈回來;現行只回字面相符的', async () => {
const db = await seeded();
expect(await likeHits(db, legacyPattern('owner_id'), false)).toEqual([
'owner_id 是租戶隔離的欄位',
'ownerXid 是打錯的欄位名', // ← 病徵
]);
expect(await likeHits(db, buildContentLike('owner_id').params[0], true)).toEqual([
'owner_id 是租戶隔離的欄位',
]);
});
it('只打一個 `%` 或 `_`:舊寫法把**整個庫**倒回來(leo 回報的那個畫面)', async () => {
const db = await seeded();
// `%%%` 匹配任何字串;`%_%` 只要有一個字元就中 ⇒ 兩者都等於「全庫」
expect(await likeHits(db, legacyPattern('%'), false)).toHaveLength(CORPUS.length);
expect(await likeHits(db, legacyPattern('_'), false)).toHaveLength(CORPUS.length);
// 現行:只回真的含那個字元的(`_` 有兩筆——欄位名那筆與路徑那筆,兩筆都是字面命中)
expect(await likeHits(db, buildContentLike('%').params[0], true)).toEqual(['毛利率 100% 達成']);
expect(await likeHits(db, buildContentLike('_').params[0], true)).toEqual([
'owner_id 是租戶隔離的欄位',
'路徑 C:\\_temp 底下',
]);
});
it('走完整搜尋路徑(searchEntries,含斷詞與算分)結果一致——不是只有底層函式對', async () => {
const db = await seeded();
expect((await searchEntries(db, '100%', 'leo')).map((e) => e.content)).toEqual(['毛利率 100% 達成']);
expect((await searchEntries(db, 'owner_id', 'leo')).map((e) => e.content)).toEqual([
'owner_id 是租戶隔離的欄位',
]);
// 單打一個符號:以前是全庫,現在是「真的含那個字的那一筆」
expect((await searchEntries(db, '%', 'leo')).map((e) => e.content)).toEqual(['毛利率 100% 達成']);
expect((await searchEntries(db, '_', 'leo')).map((e) => e.content).sort()).toEqual(
['owner_id 是租戶隔離的欄位', '路徑 C:\\_temp 底下'].sort(),
);
});
});
describe('② 邊界:跳脫字元本身(`\\`)也必須跳脫', () => {
// 為什麼這組必須存在:宣告了 ESCAPE 之後,`\` 就變成 pattern 裡有意義的字元。
// 只跳脫 % 和 _、不跳脫 `\`,等於用新的漏洞換掉舊的——而且更難發現,因為它
// **不會報錯**,只會靜靜地把後面那個字吃掉、去找一個使用者沒打過的字串。
const halfDone = (q: string) => `%${q.replace(/[%_]/g, (c) => '\\' + c)}%`; // 只跳脫 %/_ 的假想修法
it('打 `C:\\`:不跳脫反斜線的話尾巴變成「字面 %」,反而找不到任何真正含 `C:\\` 的內容', async () => {
const db = await seeded();
// pattern `%C:\%` ⇒ 尾巴的 `\%` 被讀成「字面的 %」⇒ 實際去找 `C:%`,庫裡沒有 ⇒ 全漏
expect(await likeHits(db, halfDone('C:\\'), true)).toEqual([]);
expect(await likeHits(db, buildContentLike('C:\\').params[0], true)).toEqual([
'路徑 C:\\_temp 底下',
'路徑 C:\\Xtemp 底下',
]);
});
it('打 `C:\\_temp`:反斜線沒跳脫 ⇒ 它把 `_` 的跳脫吃掉,萬用字元漏回來、撈到不相干的', async () => {
const db = await seeded();
// `%C:\\_temp%``\\` 先被讀成「字面 \」,後面那個 `_` 就變回萬用字元 ⇒ C:\Xtemp 也中
expect(await likeHits(db, halfDone('C:\\_temp'), true)).toEqual([
'路徑 C:\\_temp 底下',
'路徑 C:\\Xtemp 底下', // ← 使用者沒打過這個字
]);
expect(await likeHits(db, buildContentLike('C:\\_temp').params[0], true)).toEqual([
'路徑 C:\\_temp 底下',
]);
});
it('打 `100\\%`:三個都不跳脫 ⇒ `\\%` 被讀成「字面 %」⇒ 去找 `100%`,跟他打的不一樣', async () => {
const db = await seeded();
// 原始寫法(一個都不跳脫)= pattern `%100\%%``\%`=字面 %、尾巴那個 `%`=萬用字元
expect(await likeHits(db, legacyPattern('100\\%'), true)).toEqual(['毛利率 100% 達成']); // ← 找錯東西
// 正解:庫裡沒有字面的 `100\%` ⇒ 就該零命中,而不是拿別的東西充數
expect(await likeHits(db, buildContentLike('100\\%').params[0], true)).toEqual([]);
});
it('escapeLikeLiteral 只碰這三個字元,且不會把自己剛加的跳脫再跳脫一次', () => {
expect(escapeLikeLiteral('100%')).toBe('100\\%');
expect(escapeLikeLiteral('owner_id')).toBe('owner\\_id');
expect(escapeLikeLiteral('C:\\')).toBe('C:\\\\');
expect(escapeLikeLiteral('%_\\')).toBe('\\%\\_\\\\'); // 三個各自跳脫一次,不是兩次
// LIKE 沒有 [] ? * 這些萬用字元(那是 GLOB/別的方言)⇒ 不該白白吃掉 byte 預算
expect(escapeLikeLiteral('a[b]?c*d 中文')).toBe('a[b]?c*d 中文');
});
});
describe('③ 每個 LIKE 都要帶 ESCAPE 宣告,否則跳脫過的 pattern 反而被當字面', () => {
it('buildContentLikebuildSearchScore 產生的謂詞都含 ESCAPE', () => {
for (const c of buildContentLike('100%').conds) expect(c).toContain("ESCAPE '\\'");
for (const c of buildContentLike('a'.repeat(200)).conds) expect(c).toContain("ESCAPE '\\'");
expect(buildSearchScore('Gemini 逃生口').scoreExpr).toContain("ESCAPE '\\'");
expect(buildSearchScore('。。。').scoreExpr).toContain("ESCAPE '\\'"); // 一個詞都拆不出來的退路
});
it('沒有任何 `content LIKE ?` 是裸的(漏掉一個就等於那條路沒修)', async () => {
const { spy, sqls } = sqlSpy(await seeded());
for (const q of ['100%', 'Gemini 逃生口', '。。。', 'a'.repeat(200)]) await searchEntries(spy, q, 'leo');
expect(sqls.length).toBeGreaterThan(0);
for (const sql of sqls) expect(sql.match(/content LIKE \?(?! ESCAPE)/g) ?? []).toHaveLength(0);
});
it('ESCAPE 宣告本身是合法 SQL(D1=SQLite;真的跑得起來,不是形狀對而已)', async () => {
const db = await seeded();
await expect(likeHits(db, '%100\\%%', true)).resolves.toEqual(['毛利率 100% 達成']);
});
});
describe('④ 不退化:不含 % _ \\ 的查詢,行為與修改前逐字相同', () => {
it('pattern 一個字都沒變(跳脫對這些字串是恆等變換)', () => {
for (const q of ['語意檢索', 'arcrun', 'Gemini 逃生口', '為什麼今天額度用完']) {
expect(escapeLikeLiteral(q)).toBe(q);
}
expect(buildContentLike('語意檢索').params).toEqual(['%語意檢索%']);
expect(buildSearchScore('arcrun').scoreParams).toEqual(['%arcrun%']);
expect(buildSearchScore('語意檢索').legacyShape).toBe(true); // 最熱路徑仍是單一 LIKE
});
it('斷詞(08-10Arcrun#84 已判定留下)沒被動到:問句照樣拆得開', () => {
expect(buildSearchScore('Gemini 逃生口').scoreParams).toEqual(
expect.arrayContaining(['%Gemini%', '%逃生口%']),
);
});
});
describe('⑤ 跳脫會變長 ⇒ byte 預算要用「跳脫後」的長度算,否則退回 2026-08-03 那個 500', () => {
it('滿是 % 的長查詢,每個 pattern 仍在 D1 的 50 bytes 上限內', () => {
const qs = [
'%'.repeat(200), // 每個字元跳脫後變 2 bytes
'_'.repeat(60),
'\\'.repeat(60),
`${'%'.repeat(30)}中文${'_'.repeat(30)}`,
'a'.repeat(24) + '%'.repeat(24), // 卡在舊上限附近的混合
];
for (const q of qs) {
for (const p of buildContentLike(q).params) expect(bytes(p)).toBeLessThanOrEqual(MAX_PATTERN);
const plan = buildSearchScore(q);
expect(plan.scoreParams.length).toBeGreaterThan(0); // 永不空條件(空條件=WHERE 塌掉)
for (const p of plan.scoreParams) expect(bytes(p)).toBeLessThanOrEqual(MAX_PATTERN);
}
});
it('48 個 `%`(跳脫前剛好在舊上限內)不會產生 98 bytes 的 pattern', () => {
const q = '%'.repeat(48);
expect(bytes(q)).toBe(48); // 用舊的算法看,它「在上限內」
const m = buildContentLike(q);
expect(m.split).toBe(true); // 用跳脫後的長度看,它必須被拆開
for (const p of m.params) expect(bytes(p)).toBeLessThanOrEqual(MAX_PATTERN);
});
it('拆片段仍切在字元邊界上,不會把跳脫序列切成半個', async () => {
const db = await seeded();
for (const p of buildContentLike(`${'%'.repeat(40)}中文${'_'.repeat(40)}`).params) {
expect(p).not.toContain('\uFFFD');
// 切壞的跳脫序列(尾巴是落單的 `\`)會讓 SQLite 把後面的 `%` 讀成字面 ⇒ 語意錯掉
expect(/(^|[^\\])(\\\\)*\\%$/.test(p)).toBe(false);
await expect(likeHits(db, p, true)).resolves.toBeDefined(); // 真的送得進 SQLite
}
});
});
+6 -2
View File
@@ -16,12 +16,16 @@ import { buildContentLike, searchEntries } from '../src/actions/entry-crud';
const bytes = (s: string) => new TextEncoder().encode(s).length;
const MAX_PATTERN = 50; // D1 上限
// 謂詞字串在 Arcrun#94 多了 ESCAPE 宣告(`content LIKE ? ESCAPE '\'`)。這裡跟著改的是
// **比對用的常數**,不是放寬檢查——底下仍然逐字相等比對,只是比的是現在正確的那個字串。
// pattern 本身('%語意檢索%')一個字都沒變:那句話裡沒有 % _ \,跳脫後與原文相同。
const LIKE_PRED = "content LIKE ? ESCAPE '\\'";
describe('buildContentLike:不得產生超過 D1 上限的 LIKE pattern', () => {
it('短查詢(≤48 bytes)=與舊版逐字相同的單一 LIKE', () => {
const m = buildContentLike('語意檢索');
expect(m.split).toBe(false);
expect(m.conds).toEqual(['content LIKE ?']);
expect(m.conds).toEqual([LIKE_PRED]);
expect(m.params).toEqual(['%語意檢索%']);
});
@@ -43,7 +47,7 @@ describe('buildContentLike:不得產生超過 D1 上限的 LIKE pattern', () =
const m = buildContentLike('語意檢索 排名 選頁 雜訊 出處 門檻 正規化 三元組 知識庫');
expect(m.split).toBe(true);
expect(m.conds.length).toBeGreaterThan(1);
expect(m.conds.every((c) => c === 'content LIKE ?')).toBe(true);
expect(m.conds.every((c) => c === LIKE_PRED)).toBe(true);
expect(m.params).toContain('%語意檢索%');
expect(m.conds.length).toBeLessThanOrEqual(6); // 詞數上限
});