Compare commits
9 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| a7e23badf2 | |||
| eebb691426 | |||
| 417d69ceb3 | |||
| 035e8b255b | |||
| 1c630ecfd4 | |||
| a99d3e5a3e | |||
| 894408181f | |||
| d022ca067b | |||
| 6715402bcc |
@@ -821,7 +821,20 @@ function taipeiMonthDay(ms) { var d = new Date(ms + TAIPEI_OFFSET_MS); return {
|
|||||||
fetch(API_BASE + '/portal/session', { headers: authHeaders() })
|
fetch(API_BASE + '/portal/session', { headers: authHeaders() })
|
||||||
.then(function (r) { return safeJson(r).then(function (d) { return { ok: r.ok, status: r.status, d: d }; }); })
|
.then(function (r) { return safeJson(r).then(function (d) { return { ok: r.ok, status: r.status, d: d }; }); })
|
||||||
.then(function (x) {
|
.then(function (x) {
|
||||||
if (!x.ok) { dropSession(); return; }
|
// 🔴 arcrun-rag#66:**只有 401 才算「你被登出了」**。
|
||||||
|
// 舊版是 `if (!x.ok) dropSession()` = 任何非 2xx 都清掉 token——
|
||||||
|
// 包含改完密碼後那幾十秒的 503(認證 secret 正在鋪開)與 502(KBDB 暫時不可達)。
|
||||||
|
// 那正是 leo 08-10「改完密碼、重新整理就回不去」的最後一哩:
|
||||||
|
// 後端就算不刪 KV,前端自己把鑰匙丟了,結果一樣。
|
||||||
|
if (x.status === 401) { dropSession(); return; }
|
||||||
|
if (!x.ok) {
|
||||||
|
// 暫時性故障:留著 session,告訴他這是暫時的、下一步做什麼。
|
||||||
|
showAuth();
|
||||||
|
$('login-status').textContent = (x.d && x.d.code === 'auth_store_propagating')
|
||||||
|
? '認證資料正在更新中(通常幾十秒),請稍候重新整理——你並沒有被登出。'
|
||||||
|
: '服務暫時不可用,請稍後重新整理(你的登入沒有失效)。';
|
||||||
|
return;
|
||||||
|
}
|
||||||
S.profile = x.d;
|
S.profile = x.d;
|
||||||
showApp();
|
showApp();
|
||||||
})
|
})
|
||||||
@@ -1185,6 +1198,12 @@ function taipeiMonthDay(ms) { var d = new Date(ms + TAIPEI_OFFSET_MS); return {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// 任何 data 請求收到 401 → session 失效 → 回登入殼
|
// 任何 data 請求收到 401 → session 失效 → 回登入殼
|
||||||
|
//
|
||||||
|
// 🔴 arcrun-rag#66 的前端那一半:後端在「認證資料正在鋪開」時改回 503
|
||||||
|
// `auth_store_propagating`(不再回 401、也不再刪 KV 那筆 session)。
|
||||||
|
// 這裡**必須跟著只認 401**——若前端把任何錯誤都當登出,後端不刪也沒用:
|
||||||
|
// 使用者手上的 token 會被自己的瀏覽器丟掉,症狀跟被踢出去一模一樣。
|
||||||
|
// (503 由各呼叫點自己顯示錯誤訊息,session 原封不動。)
|
||||||
function guard401(status) {
|
function guard401(status) {
|
||||||
if (status === 401) { dropSession(); return true; }
|
if (status === 401) { dropSession(); return true; }
|
||||||
return false;
|
return false;
|
||||||
|
|||||||
@@ -143,7 +143,18 @@ export function authStoreWritable(env: Bindings): boolean {
|
|||||||
*/
|
*/
|
||||||
export function readAuthStore(env: Bindings): AuthStoreData {
|
export function readAuthStore(env: Bindings): AuthStoreData {
|
||||||
if (overlay && Date.now() - overlayAt < AUTH_OVERLAY_TTL_MS) return overlay;
|
if (overlay && Date.now() - overlayAt < AUTH_OVERLAY_TTL_MS) return overlay;
|
||||||
|
return readAuthStoreFromEnv(env);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 只讀 `env` 那一版(**跳過 overlay**)。
|
||||||
|
*
|
||||||
|
* 為什麼要分出這一支(#66 修補的一半):read-modify-write 時,overlay 與 env 兩份都可能
|
||||||
|
* 各自「有對方沒有的帳號」——overlay 可能來自加速器(別台 isolate 剛寫的),
|
||||||
|
* env 可能是**比加速器更新**的一版(加速器過期、或這顆 isolate 已經吃到新版本)。
|
||||||
|
* 只採信其中一份就會把另一份獨有的帳號寫掉,而 secret 是唯一真相源 ⇒ **永久消失**。
|
||||||
|
*/
|
||||||
|
function readAuthStoreFromEnv(env: Bindings): AuthStoreData {
|
||||||
const bag = env as unknown as Record<string, unknown>;
|
const bag = env as unknown as Record<string, unknown>;
|
||||||
const out = emptyStore();
|
const out = emptyStore();
|
||||||
for (const name of shardNames(env)) {
|
for (const name of shardNames(env)) {
|
||||||
@@ -268,13 +279,50 @@ export async function hydrateFromAccelerator(env: Bindings): Promise<boolean> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 讀出來 → 改 → 寫回去(同一支,避免各處自己拼 read/modify/write)。 */
|
/**
|
||||||
|
* 這台實例「剛剛才寫過認證儲存」嗎——亦即現在是不是**傳播空窗期**。
|
||||||
|
*
|
||||||
|
* 🔴 #66 用它分辨兩件長得一樣、後果完全相反的事:
|
||||||
|
* - 「查不到這個帳號」= 帳號真的被刪了 → 該擋(401)
|
||||||
|
* - 「查不到這個帳號」= secret 新版本還沒鋪到這顆 isolate → **不該擋,更不該刪 session**
|
||||||
|
* 加速器的 key 只在寫入後存活 `ACCEL_TTL_SECONDS`,它存在就代表「最近有人動過認證儲存」。
|
||||||
|
* 讀不到(KV 掛了/沒設)⇒ 回 false,退回舊行為,不會比現在更糟。
|
||||||
|
*/
|
||||||
|
export async function authStoreRecentlyWritten(env: Bindings): Promise<boolean> {
|
||||||
|
try {
|
||||||
|
return Boolean(await env.SESSIONS_KV.get(ACCEL_KEY));
|
||||||
|
} catch {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 兩份 store 取聯集:同一個 id 以 `updated_at` 新者為準;只在一邊出現的一律保留。 */
|
||||||
|
function unionStores(a: AuthStoreData, b: AuthStoreData): AuthStoreData {
|
||||||
|
const byId = new Map<string, AuthUserRecord>();
|
||||||
|
for (const u of [...a.users, ...b.users]) {
|
||||||
|
const prev = byId.get(u.id);
|
||||||
|
if (!prev || (u.updated_at ?? '') >= (prev.updated_at ?? '')) byId.set(u.id, u);
|
||||||
|
}
|
||||||
|
return { version: 1, console: a.console ?? b.console ?? null, users: [...byId.values()] };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 讀出來 → 改 → 寫回去(同一支,避免各處自己拼 read/modify/write)。
|
||||||
|
*
|
||||||
|
* 🔴 #66:**改之前先把手上這份補齊**。舊版直接 `readAuthStore(env)` 當底稿,而 `writeAuthStore`
|
||||||
|
* 會把整份重切分片並刪掉多出來的舊分片 ⇒ 若底稿是「某個帳號被建立之前」的版本,
|
||||||
|
* 那個帳號會在這次寫入中**被抹掉,且再也回不來**(secret 是唯一真相源,沒有第二份可還原)。
|
||||||
|
* 這正是「改一次密碼=有人被鎖在門外」的另一半病因。
|
||||||
|
*
|
||||||
|
* 補法:先問一次加速器,再把 env 版與 overlay 版**取聯集**當底稿——
|
||||||
|
* 兩邊獨有的帳號都留下來;刪除仍然有效,因為 `fn()` 是在聯集**之後**才跑。
|
||||||
|
*/
|
||||||
export async function mutateAuthStore(
|
export async function mutateAuthStore(
|
||||||
env: Bindings,
|
env: Bindings,
|
||||||
fn: (data: AuthStoreData) => void | Promise<void>,
|
fn: (data: AuthStoreData) => void | Promise<void>,
|
||||||
): Promise<AuthStoreData> {
|
): Promise<AuthStoreData> {
|
||||||
const data = readAuthStore(env);
|
await hydrateFromAccelerator(env);
|
||||||
const next: AuthStoreData = { version: 1, console: data.console, users: [...data.users] };
|
const next = unionStores(readAuthStore(env), readAuthStoreFromEnv(env));
|
||||||
await fn(next);
|
await fn(next);
|
||||||
await writeAuthStore(env, next);
|
await writeAuthStore(env, next);
|
||||||
return next;
|
return next;
|
||||||
|
|||||||
@@ -33,6 +33,7 @@ import { storeCredential, hasCredential } from './credentials';
|
|||||||
// KBDB 只保留為「舊實例的既有帳號」回退讀路徑,且讀到就順手搬進新家(見 promoteLegacyUser)。
|
// KBDB 只保留為「舊實例的既有帳號」回退讀路徑,且讀到就順手搬進新家(見 promoteLegacyUser)。
|
||||||
import {
|
import {
|
||||||
AuthStoreWriteError,
|
AuthStoreWriteError,
|
||||||
|
authStoreRecentlyWritten,
|
||||||
authStoreStatus,
|
authStoreStatus,
|
||||||
findAuthUserByEmail,
|
findAuthUserByEmail,
|
||||||
findAuthUserById,
|
findAuthUserById,
|
||||||
@@ -413,6 +414,23 @@ export type AuthResult = { ok: true; user: AuthedUser } | { ok: false; res: Resp
|
|||||||
/**
|
/**
|
||||||
* portal session 閘:token → KV → record_id → **回讀 record**(唯一真相源)→ status=active。
|
* portal session 閘:token → KV → record_id → **回讀 record**(唯一真相源)→ status=active。
|
||||||
* 停用即時生效(design §4.3);停用/孤兒 session 順手刪 KV(best-effort,正確性不依賴它)。
|
* 停用即時生效(design §4.3);停用/孤兒 session 順手刪 KV(best-effort,正確性不依賴它)。
|
||||||
|
*
|
||||||
|
* 🔴 `Leo/arcrun-rag#66`(2026-08-10,leo 本人被鎖在 stage 外面的那條):
|
||||||
|
* **「這一瞬間讀不到」不可以觸發不可逆的動作。**
|
||||||
|
* 認證的家是 CF Workers Secret,改它(改密碼/建帳號/停用)會產生 worker 新版本,
|
||||||
|
* **既有 isolate 讀到的還是舊 env**(`#55` 實測 ≥15 秒)。舊版在那個空窗裡:
|
||||||
|
* ① `getRecordById` 讀不到 → ② **直接把 session 從 KV 刪掉** → ③ 回 401
|
||||||
|
* ⇒ 帶著一個**完全有效的 token**,登入狀態被當場銷毀,等 secret 鋪開也回不來。
|
||||||
|
* `#55` 補的「讀不到就再問一次加速器」只加在登入路徑(`findAndVerifyUser`),這道門沒有。
|
||||||
|
*
|
||||||
|
* 三段修法(缺一不可):
|
||||||
|
* 1. **先問一次加速器再判定**——與登入路徑同一招,同一支 `hydrateFromAccelerator`。
|
||||||
|
* 2. **永不因「讀不到」刪 session**。刪是 best-effort 清潔工,而它清掉的是使用者唯一的
|
||||||
|
* 憑據;KV 的 TTL 本來就會回收,這件事沒有非做不可的理由。
|
||||||
|
* 3. 仍然讀不到且**正在傳播空窗**(加速器 key 還在)→ 回 **503 `auth_store_propagating`**,
|
||||||
|
* 不是 401。理由在前端:portal 的 `guard401()` 一看到 401 就清 localStorage 踢回登入頁
|
||||||
|
* ⇒ 就算 KV 那筆還在,使用者手上的 token 也被自己的瀏覽器丟掉了。
|
||||||
|
* **後端不刪、前端不丟,這件事才算真的修好。**
|
||||||
*/
|
*/
|
||||||
export async function requirePortalUser(c: Context<{ Bindings: Bindings }>): Promise<AuthResult> {
|
export async function requirePortalUser(c: Context<{ Bindings: Bindings }>): Promise<AuthResult> {
|
||||||
const token = bearerToken(c);
|
const token = bearerToken(c);
|
||||||
@@ -426,15 +444,33 @@ export async function requirePortalUser(c: Context<{ Bindings: Bindings }>): Pro
|
|||||||
/* fallthrough */
|
/* fallthrough */
|
||||||
}
|
}
|
||||||
if (!recordId) {
|
if (!recordId) {
|
||||||
|
// 這一筆 session 的內容本身壞掉=確定的事實(不是讀不到),刪它是對的。
|
||||||
await c.env.SESSIONS_KV.delete(`${SESSION_PREFIX}${token}`);
|
await c.env.SESSIONS_KV.delete(`${SESSION_PREFIX}${token}`);
|
||||||
return { ok: false, res: c.json({ error: 'session 無效或已過期' }, 401) };
|
return { ok: false, res: c.json({ error: 'session 無效或已過期' }, 401) };
|
||||||
}
|
}
|
||||||
const rec = await getRecordById(c.env, recordId);
|
let rec = await getRecordById(c.env, recordId);
|
||||||
|
if (!rec && (await hydrateFromAccelerator(c.env))) {
|
||||||
|
rec = await getRecordById(c.env, recordId); // ①:與登入路徑同一招,再問一次加速器
|
||||||
|
}
|
||||||
if (!rec) {
|
if (!rec) {
|
||||||
await c.env.SESSIONS_KV.delete(`${SESSION_PREFIX}${token}`);
|
// ③:分辨「傳播空窗」與「帳號真的沒了」——前者不可以把人踢出去。
|
||||||
|
if (await authStoreRecentlyWritten(c.env)) {
|
||||||
|
return {
|
||||||
|
ok: false,
|
||||||
|
res: c.json(
|
||||||
|
{
|
||||||
|
error: '認證資料正在更新中(Cloudflare 正在鋪開新版本),請稍候幾秒再試——你並沒有被登出。',
|
||||||
|
code: 'auth_store_propagating',
|
||||||
|
},
|
||||||
|
503,
|
||||||
|
),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
// ②:查無此帳號(可能真的被刪了)→ 擋下即可,**不刪 session**(KV TTL 自己會回收)。
|
||||||
return { ok: false, res: c.json({ error: 'session 無效或已過期' }, 401) };
|
return { ok: false, res: c.json({ error: 'session 無效或已過期' }, 401) };
|
||||||
}
|
}
|
||||||
if ((rec.values.status ?? '') !== 'active') {
|
if ((rec.values.status ?? '') !== 'active') {
|
||||||
|
// 讀得到 record = 確定的事實,停用要即時生效,刪 session 是對的。
|
||||||
await c.env.SESSIONS_KV.delete(`${SESSION_PREFIX}${token}`);
|
await c.env.SESSIONS_KV.delete(`${SESSION_PREFIX}${token}`);
|
||||||
return { ok: false, res: c.json({ error: '帳號已停用' }, 403) };
|
return { ok: false, res: c.json({ error: '帳號已停用' }, 403) };
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -257,6 +257,209 @@ export function buildContentLike(q: string): { conds: string[]; params: string[]
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── 查詢斷詞 + 覆蓋率排序:讓「AI 問一個問句」查得到東西 ─────────────────────────
|
||||||
|
//
|
||||||
|
// 病徵(2026-08-10 總管在 leo21c 上實測,有對照組):
|
||||||
|
// kbdb_search("Gemini 逃生口") → 0 筆
|
||||||
|
// kbdb_search("Gemini") → 50 筆 / 864 行 ← 知識明明就在庫裡
|
||||||
|
// kbdb_search("local arcrun") → 5 筆 ← 這兩個字剛好字面相鄰
|
||||||
|
// ⇒ 對照組證明:**查詢字串是整串拿去比對的,從來沒有被拆開**。
|
||||||
|
// 上面 buildContentLike 只在 q > 48 bytes(=那次 500 的閘)時才拆,短查詢一律單一
|
||||||
|
// `content LIKE '%整句%'`;而且拆開後是 AND(每個詞都要出現)。
|
||||||
|
//
|
||||||
|
// 為什麼這是**結構性**故障、不是準度問題:
|
||||||
|
// **AI 問的永遠是問句,不是單一關鍵字。** 一個問句的詞幾乎不可能在原文裡剛好相鄰
|
||||||
|
// ⇒ 對 AI 而言這條路的回傳值恆為 0。leo 2026-08-10:「沒有 MCP 你就是瞎的」——
|
||||||
|
// 接上了也還是瞎的,因為接上之後查什麼都沒有。
|
||||||
|
// (語意搜尋救不了:同一次實測 50 筆裡 41 筆沒有向量,82% 的內容語意搜尋看不見。)
|
||||||
|
//
|
||||||
|
// 這件 47c6aae(2026-08-03 修 50 bytes 500)就寫明是「另一件事、要另外立案」的那件事;
|
||||||
|
// 本次只動**查詢端**,buildContentLike 一個字不動(那支修的是 pattern 長度,不是斷詞)。
|
||||||
|
//
|
||||||
|
// 修法:查詢端斷詞 → 每個詞各自比對 → **用覆蓋率排序**,不是用 AND 過濾。
|
||||||
|
// · 只要命中任一個詞就是候選(OR),但**排序由「命中了多少份量的詞」決定**,
|
||||||
|
// 所以「詞存在但不相鄰」查得到東西,而相關的排在前面。
|
||||||
|
// · 詞的份量=詞長(字數)。長詞/英數詞比較專指,雙字詞比較泛
|
||||||
|
// ⇒「Gemini 在這套系統裡的角色是什麼」裡 Gemini(6) 的份量遠大於 系統(2)、角色(2)
|
||||||
|
// ⇒ 含 Gemini 的內容自然壓過只含「系統」的雜訊。這就是相關性不崩壞的機制。
|
||||||
|
// · **整句相鄰**另外加一份重賞(phraseBonus)⇒ 舊行為(字面相鄰)永遠排第一,
|
||||||
|
// `local arcrun` 那 5 筆不會被稀釋掉。
|
||||||
|
// · 相對門檻砍低分尾(沿用 embed.ts relativeMinScore 的既有做法,不另立第二套):
|
||||||
|
// 只留 >= 最高分 × KEYWORD_RELATIVE_CUT 的,避免「為了有結果就把整個庫撈回來」。
|
||||||
|
//
|
||||||
|
// 回歸保證(不是靠測試碰運氣,是靠構造):
|
||||||
|
// · **單詞查詢送出的 SQL 與舊版逐字相同**(一個 LIKE、同一個 pattern),
|
||||||
|
// 所有分數相等 ⇒ 排序也退化回 updated_at DESC。一個字都沒變。
|
||||||
|
// · 多詞查詢的結果集是舊版的**超集**(含整句的內容一定也含每一個詞),
|
||||||
|
// 而整句命中因 phraseBonus 排最前 ⇒ 原本查得到的不可能變成查不到。
|
||||||
|
//
|
||||||
|
// 誠實限制:這是「查詢端斷詞」,不是真正的中文斷詞器(沒有詞典)。CJK 靠虛詞切段
|
||||||
|
// +長段補雙字組合,命中率一定不如詞典;真正的解是 FTS5/斷詞索引,那要動索引端、
|
||||||
|
// 要另外立案。本次的對照組是 **0 筆**,不是「更好的排序」。
|
||||||
|
// 成本:一次查詢最多掃 MAX_SEARCH_TERMS(+1) 個 LIKE,而舊版是 1 個 ⇒ 全表掃描成本上升到
|
||||||
|
// 最多 7 倍。**單詞查詢仍是 1 個**(最常見的路徑不受影響);多詞查詢用這個成本換掉「恆為 0」。
|
||||||
|
const MAX_SEARCH_TERMS = 6; // 每多一個詞就多比對一次,6 是成本與召回的折衷(與 MAX_LIKE_TERMS 同數)
|
||||||
|
const MAX_TERM_WEIGHT = 8; // 單一詞份量上限,避免一個超長詞獨大到蓋掉其他訊號
|
||||||
|
// 相對門檻取 0.6 是**實測調出來的**,不是拍的(2026-08-10,3915 筆真實語料本機對照):
|
||||||
|
// 0.5 時「這個系統的搜尋是怎麼做的」把只含「系統」或只含「搜尋」的也撈進來(滿 50 筆雜訊尾);
|
||||||
|
// 0.6 時只留同時含兩個詞的 ⇒ 尾巴收乾淨,而驗收題(Gemini 逃生口)不受影響
|
||||||
|
// ——那題最高分那群本來就只有 Gemini 一個詞命中,相對門檻是對「最高分」取比例,不是對「滿分」,
|
||||||
|
// 所以「全庫沒有第二個詞」的情況不會被自己的門檻誤殺(這正是不能用滿分當分母的原因)。
|
||||||
|
const KEYWORD_RELATIVE_CUT = 0.6;
|
||||||
|
|
||||||
|
// CJK 虛詞:**只拿來過濾雙字組合,絕不拿來切段。**
|
||||||
|
//
|
||||||
|
// 🔴 這條是自己的測試擋出來的(2026-08-10):第一版用虛詞「切段」,結果
|
||||||
|
// 「向量化」被 `向` 切成「量化」、「功能」被 `能` 切掉 ⇒ **把使用者真正要查的詞切爛了**。
|
||||||
|
// 沒有詞典的中文,切段一定會誤傷實詞(能/更/要/者/使/則/因/項/過/得 全都
|
||||||
|
// 同時是虛詞與實詞的組成部分)。
|
||||||
|
// ⇒ 改成:**整段原樣保留**,雙字組合只是補充;只有「雙字裡有虛詞」的組合才丟掉。
|
||||||
|
// 這個方向誤傷不了實詞——因為實詞從來沒有被拆過,只是多了幾個候選。
|
||||||
|
//
|
||||||
|
// 收字原則:**拿不準就不收**。噪音組合很便宜(比不中就是 0 分,只佔一個名額),
|
||||||
|
// 誤殺實詞很貴(那個查詢就永遠找不到了)。所以像 個/為/能/要/者/因/所/中/裡
|
||||||
|
// 這些「也會出現在實詞裡」的字**一律不收**,寧可留下「一個」「為什」這種比不中的噪音。
|
||||||
|
const CJK_STOP_CHARS = new Set(
|
||||||
|
'的了是在我你他她它們這那哪誰嗎呢吧啊呀嘛喔哦什麼怎之乎而但並卻就都也很太只還又再每些把被跟讓若'.split(''),
|
||||||
|
);
|
||||||
|
|
||||||
|
// 英文虛詞:同理,問句裡的 what/how/why 不是查詢訊號。
|
||||||
|
const ASCII_STOP_WORDS = new Set([
|
||||||
|
'the', 'a', 'an', 'and', 'or', 'of', 'to', 'in', 'on', 'at', 'is', 'are', 'was', 'were',
|
||||||
|
'be', 'do', 'does', 'did', 'for', 'it', 'its', 'this', 'that', 'these', 'those', 'with',
|
||||||
|
'what', 'how', 'why', 'when', 'where', 'who', 'which', 'can', 'could', 'should', 'would',
|
||||||
|
'my', 'our', 'your', 'their', 'me', 'we', 'you', 'they',
|
||||||
|
]);
|
||||||
|
|
||||||
|
const isCjkChar = (ch: string): boolean => /[-ヿ㐀-䶿一-鿿豈-]/.test(ch);
|
||||||
|
const isWordChar = (ch: string): boolean => /[A-Za-z0-9_.-]/.test(ch);
|
||||||
|
|
||||||
|
/** 把查詢切成「連續的同類字串」:CJK 一段、英數一段,其餘(空白/標點/全形符號)當分隔。 */
|
||||||
|
export function splitRuns(q: string): { text: string; cjk: boolean }[] {
|
||||||
|
const runs: { text: string; cjk: boolean }[] = [];
|
||||||
|
let cur = ''; let curCjk = false;
|
||||||
|
const flush = () => { if (cur) runs.push({ text: cur, cjk: curCjk }); cur = ''; };
|
||||||
|
for (const ch of q) {
|
||||||
|
const cjk = isCjkChar(ch);
|
||||||
|
if (!cjk && !isWordChar(ch)) { flush(); continue; } // 空白與標點=分隔
|
||||||
|
if (cur && cjk !== curCjk) flush(); // CJK↔英數 邊界也切(吸收 t95 normalizeCjkQuery 的用意)
|
||||||
|
cur += ch; curCjk = cjk;
|
||||||
|
}
|
||||||
|
flush();
|
||||||
|
return runs;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 相鄰雙字組合,丟掉「含虛詞」的那些(在這/的角/是什…=噪音,不是查詢訊號)。 */
|
||||||
|
function contentBigrams(run: string): string[] {
|
||||||
|
const chars = [...run];
|
||||||
|
const out: string[] = [];
|
||||||
|
for (let i = 0; i + 1 < chars.length; i++) {
|
||||||
|
if (CJK_STOP_CHARS.has(chars[i]) || CJK_STOP_CHARS.has(chars[i + 1])) continue;
|
||||||
|
out.push(chars[i] + chars[i + 1]);
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SearchTerm { term: string; weight: number }
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 把查詢句拆成帶份量的查詢詞(純函式,單測用 export)。
|
||||||
|
* 份量=字數(上限 MAX_TERM_WEIGHT);愈長愈專指 ⇒ 排序時壓過泛詞。
|
||||||
|
* 依份量由大到小截斷到 MAX_SEARCH_TERMS,確保被砍掉的是最泛的那幾個。
|
||||||
|
*/
|
||||||
|
export function tokenizeQuery(q: string): SearchTerm[] {
|
||||||
|
const found = new Map<string, number>();
|
||||||
|
const add = (t: string, w: number) => {
|
||||||
|
for (const piece of chunkByBytes(t, MAX_LIKE_Q_BYTES)) { // 仍受 D1 LIKE pattern 50 bytes 上限約束
|
||||||
|
if (!piece) continue;
|
||||||
|
found.set(piece, Math.max(found.get(piece) ?? 0, Math.min(w, MAX_TERM_WEIGHT)));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const runs = splitRuns(q);
|
||||||
|
// 「使用者只打一個詞」vs「AI 問一句話」是兩種東西,處理方式必須不同:
|
||||||
|
// · 只有一段 → **就照舊版做**(一個 LIKE),這條路本來就好好的,不准動它。
|
||||||
|
// · 有多段(=問句)→ 才補雙字組合去拉召回。這是本次要修的那條路。
|
||||||
|
// 🔴 這個判斷是既有回歸測試擋出來的(search-long-query.test.ts「短查詢:SQL 裡只有
|
||||||
|
// 一個 content LIKE」):不分情況一律補雙字組合,會讓「語意檢索」這種**最常見的
|
||||||
|
// 中文單詞查詢**從 1 個 LIKE 變 5 個 ⇒ 最熱路徑成本 ×5,而它根本沒壞。
|
||||||
|
const isQuestion = runs.length > 1;
|
||||||
|
|
||||||
|
for (const run of runs) {
|
||||||
|
if (!run.cjk) {
|
||||||
|
const w = run.text.toLowerCase();
|
||||||
|
if (w.length >= 2 && !ASCII_STOP_WORDS.has(w)) add(run.text, run.text.length);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const chars = [...run.text];
|
||||||
|
// 短段(≤4 字)多半**本身就是一個詞**(語意檢索/專案管理/系統/角色)→ 原樣當查詢詞。
|
||||||
|
if (chars.length >= 2 && chars.length <= 4) add(run.text, chars.length);
|
||||||
|
// 長段(>4 字)多半是「一句話沒有空白」,整段拿去比對必然比不中 ⇒ 只靠雙字組合。
|
||||||
|
// 問句裡的每一段也補雙字組合(含實詞的那些),這才是「拆得開」的來源。
|
||||||
|
if (isQuestion || chars.length > 4) for (const bg of contentBigrams(run.text)) add(bg, 2);
|
||||||
|
}
|
||||||
|
|
||||||
|
return [...found.entries()]
|
||||||
|
.map(([term, weight]) => ({ term, weight }))
|
||||||
|
.sort((a, b) => b.weight - a.weight || a.term.localeCompare(b.term))
|
||||||
|
.slice(0, MAX_SEARCH_TERMS);
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SearchScorePlan {
|
||||||
|
/** SQL 算分表達式(含 ? 佔位符),對應 scoreParams。 */
|
||||||
|
scoreExpr: string;
|
||||||
|
scoreParams: string[];
|
||||||
|
terms: SearchTerm[];
|
||||||
|
/** true = 送出的 SQL 與舊版單一 LIKE 逐字相同(單詞查詢的回歸保證)。 */
|
||||||
|
legacyShape: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 產生「覆蓋率分數」的 SQL 表達式(純函式,單測用 export)。
|
||||||
|
*
|
||||||
|
* 整句相鄰另給一份重賞(=所有詞份量總和),確保**舊行為排最前**:
|
||||||
|
* 含整句的內容分數必然高於只含零散詞的,`local arcrun` 那 5 筆永遠在最上面。
|
||||||
|
*/
|
||||||
|
export function buildSearchScore(q: string): SearchScorePlan {
|
||||||
|
const trimmed = q.trim();
|
||||||
|
const terms = tokenizeQuery(trimmed);
|
||||||
|
|
||||||
|
// 一個詞都拆不出來(例:全是標點/單字虛詞)→ 退回舊版單一 LIKE,行為不變、不會空條件。
|
||||||
|
if (terms.length === 0) {
|
||||||
|
const m = buildContentLike(trimmed);
|
||||||
|
return {
|
||||||
|
scoreExpr: m.conds.map(() => 'CASE WHEN content LIKE ? THEN 1 ELSE 0 END').join(' + '),
|
||||||
|
scoreParams: m.params,
|
||||||
|
terms: [],
|
||||||
|
legacyShape: true,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
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}%`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 單詞查詢:整句 == 那個詞 ⇒ 不重複加一次 LIKE。送出的 SQL 與舊版一模一樣(成本也一樣)。
|
||||||
|
const single = terms.length === 1 && terms[0].term === trimmed;
|
||||||
|
if (!single && utf8Len(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}%`);
|
||||||
|
}
|
||||||
|
|
||||||
|
return { scoreExpr: parts.join(' + '), scoreParams: params, terms, legacyShape: single };
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 相對門檻:砍掉低於「最高分 × KEYWORD_RELATIVE_CUT」的雜訊尾巴(純函式,單測用 export)。 */
|
||||||
|
export function applyRelativeCut<T extends { match_score: number }>(rows: T[]): T[] {
|
||||||
|
if (rows.length <= 1) return rows;
|
||||||
|
const cut = rows[0].match_score * KEYWORD_RELATIVE_CUT;
|
||||||
|
return rows.filter((r) => r.match_score >= cut);
|
||||||
|
}
|
||||||
|
|
||||||
// 「庫」filter 的 SQL 謂詞(portal-auth P1,design §3.2/§3.3;零建表,同 #5.1 source 的 json_extract 先例)。
|
// 「庫」filter 的 SQL 謂詞(portal-auth P1,design §3.2/§3.3;零建表,同 #5.1 source 的 json_extract 先例)。
|
||||||
// COALESCE(x,'general') IN (…) ≡ SDD §3.3 寫的 (x IN (…) OR (x IS NULL AND 'general' IN (…)))——
|
// COALESCE(x,'general') IN (…) ≡ SDD §3.3 寫的 (x IN (…) OR (x IS NULL AND 'general' IN (…)))——
|
||||||
// 語意完全相同(未標記/無 metadata_json 的舊資料歸 'general'),但單組佔位符、不用重複綁參數。
|
// 語意完全相同(未標記/無 metadata_json 的舊資料歸 'general'),但單組佔位符、不用重複綁參數。
|
||||||
@@ -309,6 +512,9 @@ export function isDeprecatedEntry(entry: { metadata_json?: string | null }): boo
|
|||||||
// includeDeprecated(daemon-beta t24):預設 false=濾掉 status=deprecated 的下架內容。
|
// includeDeprecated(daemon-beta t24):預設 false=濾掉 status=deprecated 的下架內容。
|
||||||
// 保留 true 選項給管理面查殘留(審計/驗證下架有沒有真的生效)用,正常搜尋路徑不帶。
|
// 保留 true 選項給管理面查殘留(審計/驗證下架有沒有真的生效)用,正常搜尋路徑不帶。
|
||||||
// 加在參數最尾端,既有 positional caller(source 之後)一個都不用改。
|
// 加在參數最尾端,既有 positional caller(source 之後)一個都不用改。
|
||||||
|
// 2026-08-10(本次):q 改走 buildSearchScore——**斷詞 + 覆蓋率排序**,取代整串 LIKE。
|
||||||
|
// 回傳的 entry 多一個 match_score 欄(加欄不改形,同 semantic 路徑的 score 慣例;
|
||||||
|
// 既有 caller 不解析多的欄位,不受影響)。詳細理由見上面那段長註解。
|
||||||
export async function searchEntries(
|
export async function searchEntries(
|
||||||
db: D1Database,
|
db: D1Database,
|
||||||
q: string,
|
q: string,
|
||||||
@@ -318,18 +524,29 @@ export async function searchEntries(
|
|||||||
library?: string[],
|
library?: string[],
|
||||||
source?: string,
|
source?: string,
|
||||||
includeDeprecated = false,
|
includeDeprecated = false,
|
||||||
): Promise<Entry[]> {
|
): Promise<(Entry & { match_score: number })[]> {
|
||||||
const m = buildContentLike(q); // D1 LIKE pattern 50 bytes 上限,見 buildContentLike
|
const plan = buildSearchScore(q); // 斷詞+算分;單詞查詢=與舊版逐字相同的單一 LIKE
|
||||||
const conds = [...m.conds];
|
const conds: string[] = [];
|
||||||
const params: unknown[] = [...m.params];
|
const params: unknown[] = [...plan.scoreParams];
|
||||||
if (owner_id) { conds.push('owner_id = ?'); params.push(owner_id); }
|
if (owner_id) { conds.push('owner_id = ?'); params.push(owner_id); }
|
||||||
if (entry_type) { conds.push('entry_type = ?'); params.push(entry_type); }
|
if (entry_type) { conds.push('entry_type = ?'); params.push(entry_type); }
|
||||||
if (source) { conds.push("json_extract(metadata_json, '$.source') = ?"); params.push(source); }
|
if (source) { conds.push("json_extract(metadata_json, '$.source') = ?"); params.push(source); }
|
||||||
if (library && library.length > 0) { conds.push(libraryPredicate(library)); params.push(...library); }
|
if (library && library.length > 0) { conds.push(libraryPredicate(library)); params.push(...library); }
|
||||||
if (!includeDeprecated) { conds.push(NOT_DEPRECATED_PREDICATE); }
|
if (!includeDeprecated) { conds.push(NOT_DEPRECATED_PREDICATE); }
|
||||||
|
// 分數在子查詢算、外層才篩 match_score > 0:SQLite 不保證能在 WHERE 引用 SELECT 別名,
|
||||||
|
// 用子查詢就不必把整組 LIKE 參數再綁一次(參數重複=將來改一邊漏一邊的漂移來源)。
|
||||||
|
// 其他 filter 留在**內層**,讓 owner/library/deprecated 先篩掉,算分只發生在該算的列上。
|
||||||
|
const inner = conds.length > 0 ? `WHERE ${conds.join(' AND ')}` : '';
|
||||||
const res = await db
|
const res = await db
|
||||||
.prepare(`SELECT * FROM entries WHERE ${conds.join(' AND ')} ORDER BY updated_at DESC LIMIT ?`)
|
.prepare(
|
||||||
|
`SELECT * FROM (
|
||||||
|
SELECT *, (${plan.scoreExpr}) AS match_score FROM entries ${inner}
|
||||||
|
) WHERE match_score > 0
|
||||||
|
ORDER BY match_score DESC, updated_at DESC
|
||||||
|
LIMIT ?`,
|
||||||
|
)
|
||||||
.bind(...params, Math.min(limit, 200))
|
.bind(...params, Math.min(limit, 200))
|
||||||
.all<Entry>();
|
.all<Entry & { match_score: number }>();
|
||||||
return res.results ?? [];
|
// 相對門檻砍雜訊尾巴(「有結果」不等於「把整個庫撈回來」)。單詞查詢分數全等 ⇒ 一筆都不會被砍。
|
||||||
|
return applyRelativeCut(res.results ?? []);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -380,11 +380,26 @@ entryRoutes.patch('/:id', async (c) => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
// DELETE /entries/:id
|
// DELETE /entries/:id
|
||||||
|
//
|
||||||
|
// 🔴 2026-08-10(arcrun-rag#46「刪掉的知識搜尋還撈得到」第 4 點:中途失敗要看得出來):
|
||||||
|
// 舊版把向量刪除包成 fire-and-forget(`waitUntil(...).catch(()=>{})`)——失敗被靜默吞掉,
|
||||||
|
// 呼叫端(rag_takedown_direct workflow/未來的 portal 刪除 UI)永遠不知道向量沒清乾淨,
|
||||||
|
// 使用者看到「刪除成功」,但語意搜尋可能還留著殘影,直到下次搜尋命中它才被自癒清掉
|
||||||
|
// (search 路徑的 orphan 清理是事後補救,不是保證)。
|
||||||
|
// 改法:與同檔 `/entries/deprecate-by-library`(見上)同款——**同步 await 再回應**,
|
||||||
|
// 誠實回報 `vector_deleted`(true=清了/false=清失敗,D1 仍照刪/null=模組未開,不適用)。
|
||||||
|
// D1 刪除永遠執行到底(entry 本體一定會消失),差別只在向量那一步呼叫端看不看得見失敗。
|
||||||
entryRoutes.delete('/:id', async (c) => {
|
entryRoutes.delete('/:id', async (c) => {
|
||||||
// 模組開 → 連帶刪向量(避免孤兒向量)。失敗不致命。
|
const id = c.req.param('id');
|
||||||
|
let vector_deleted: boolean | null = null; // 模組未開=不適用,維持 null 誠實表達「這件事沒發生過」
|
||||||
if (embedEnabled(c.env)) {
|
if (embedEnabled(c.env)) {
|
||||||
c.executionCtx.waitUntil(c.env.VECTORIZE!.deleteByIds([c.req.param('id')]).then(() => {}).catch(() => {}));
|
try {
|
||||||
|
await c.env.VECTORIZE!.deleteByIds([id]);
|
||||||
|
vector_deleted = true;
|
||||||
|
} catch {
|
||||||
|
vector_deleted = false; // 誠實回 false,不假裝清乾淨了;D1 本體仍照刪,不因向量失敗而擋下
|
||||||
}
|
}
|
||||||
await deleteEntry(c.env.DB, c.req.param('id'));
|
}
|
||||||
return c.json({ success: true });
|
await deleteEntry(c.env.DB, id);
|
||||||
|
return c.json({ success: true, vector_deleted });
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -0,0 +1,84 @@
|
|||||||
|
// arcrun-rag#46「刪掉的知識搜尋還撈得到」第 4 點:中途失敗要看得出來。
|
||||||
|
//
|
||||||
|
// DELETE /entries/:id 舊版把向量刪除包成 fire-and-forget(waitUntil(...).catch(()=>{}))——
|
||||||
|
// 呼叫端完全看不到向量清除是否成功。本測試鎖住新行為:同步 await+回應帶 vector_deleted,
|
||||||
|
// 且無論向量刪除成功或失敗,D1 本體都要真的被刪掉(不因向量失敗而擋下本體刪除)。
|
||||||
|
//
|
||||||
|
// 測試手法同 search-deprecated-filter.test.ts:fake D1 捕捉 SQL 形狀;mock VECTORIZE 可控
|
||||||
|
// deleteByIds 成功/失敗,驗證 route 層如何把結果誠實透傳給呼叫端。
|
||||||
|
import { describe, it, expect } from 'vitest';
|
||||||
|
import { Hono } from 'hono';
|
||||||
|
import { entryRoutes } from '../src/routes/entries';
|
||||||
|
import type { Bindings } from '../src/types';
|
||||||
|
|
||||||
|
interface Captured { sql: string; params: unknown[] }
|
||||||
|
|
||||||
|
function makeCaptureDB(captured: Captured[]) {
|
||||||
|
const prepare = (sql: string) => {
|
||||||
|
const rec: Captured = { sql, params: [] };
|
||||||
|
captured.push(rec);
|
||||||
|
const stmt = {
|
||||||
|
bind(...args: unknown[]) { rec.params = args; return stmt; },
|
||||||
|
async all<T>() { return { results: [] as T[] }; },
|
||||||
|
async first<T>() { return null as unknown as T; },
|
||||||
|
async run() { return { success: true }; },
|
||||||
|
};
|
||||||
|
return stmt;
|
||||||
|
};
|
||||||
|
return { prepare } as unknown as D1Database;
|
||||||
|
}
|
||||||
|
|
||||||
|
function makeApp(captured: Captured[], extraEnv: Record<string, unknown> = {}) {
|
||||||
|
const app = new Hono<{ Bindings: Bindings }>();
|
||||||
|
app.route('/entries', entryRoutes);
|
||||||
|
const env = { DB: makeCaptureDB(captured), ENVIRONMENT: 'test', ...extraEnv } as unknown as Bindings;
|
||||||
|
return { app, env };
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('arcrun-rag#46 — DELETE /entries/:id 失敗可見性', () => {
|
||||||
|
it('embed 模組未開 → vector_deleted:null(不適用,非「清成功了」的謊)+ D1 仍照刪', async () => {
|
||||||
|
const captured: Captured[] = [];
|
||||||
|
const { app, env } = makeApp(captured); // 無 VECTORIZE/AI
|
||||||
|
const res = await app.request('/entries/e123', { method: 'DELETE' }, env);
|
||||||
|
expect(res.status).toBe(200);
|
||||||
|
const body = (await res.json()) as { success: boolean; vector_deleted: boolean | null };
|
||||||
|
expect(body.success).toBe(true);
|
||||||
|
expect(body.vector_deleted).toBe(null);
|
||||||
|
expect(captured.some((c) => c.sql.includes('DELETE FROM entries WHERE id = ?'))).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('模組開+向量刪除成功 → vector_deleted:true(同步等到結果才回應,不是猜的)', async () => {
|
||||||
|
const captured: Captured[] = [];
|
||||||
|
const deletedIds: string[][] = [];
|
||||||
|
const { app, env } = makeApp(captured, {
|
||||||
|
VECTORIZE: {
|
||||||
|
async deleteByIds(ids: string[]) { deletedIds.push(ids); return { count: ids.length }; },
|
||||||
|
},
|
||||||
|
AI: { async run() { return { data: [[0.1]] }; } },
|
||||||
|
});
|
||||||
|
const res = await app.request('/entries/e123', { method: 'DELETE' }, env);
|
||||||
|
const body = (await res.json()) as { success: boolean; vector_deleted: boolean | null };
|
||||||
|
expect(body.success).toBe(true);
|
||||||
|
expect(body.vector_deleted).toBe(true);
|
||||||
|
expect(deletedIds).toEqual([['e123']]); // 真的呼叫了、帶對 id,不是没做就回真
|
||||||
|
});
|
||||||
|
|
||||||
|
it('🔴 模組開+向量刪除失敗 → vector_deleted:false 誠實回報,且 D1 本體仍真的刪掉', async () => {
|
||||||
|
const captured: Captured[] = [];
|
||||||
|
const { app, env } = makeApp(captured, {
|
||||||
|
VECTORIZE: {
|
||||||
|
async deleteByIds() { throw new Error('Vectorize 503(模擬故障)'); },
|
||||||
|
},
|
||||||
|
AI: { async run() { return { data: [[0.1]] }; } },
|
||||||
|
});
|
||||||
|
const res = await app.request('/entries/e123', { method: 'DELETE' }, env);
|
||||||
|
// 舊版這裡的失敗會被 waitUntil(...).catch(()=>{}) 吞掉、caller 永遠看不到;
|
||||||
|
// 新版:HTTP 仍是 200(D1 本體真的刪了,這件事沒有失敗),但誠實標出向量那一步失敗了。
|
||||||
|
expect(res.status).toBe(200);
|
||||||
|
const body = (await res.json()) as { success: boolean; vector_deleted: boolean | null };
|
||||||
|
expect(body.success).toBe(true);
|
||||||
|
expect(body.vector_deleted).toBe(false);
|
||||||
|
// D1 本體不因向量失敗而被擋下——刪除的「本體一定會消失」承諾不打折扣。
|
||||||
|
expect(captured.some((c) => c.sql.includes('DELETE FROM entries WHERE id = ?'))).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,202 @@
|
|||||||
|
// 查詢斷詞 + 覆蓋率排序 —— 讓「AI 問一個問句」查得到東西(2026-08-10,Leo/mira#4)
|
||||||
|
//
|
||||||
|
// 病徵(總管在 leo21c 上實測,有對照組,非推論):
|
||||||
|
// kbdb_search("Gemini 逃生口") → 0 筆
|
||||||
|
// kbdb_search("Gemini") → 50 筆 / 864 行 ← 知識明明就在庫裡
|
||||||
|
// kbdb_search("local arcrun") → 5 筆 ← 這兩個字剛好字面相鄰
|
||||||
|
// ⇒ 對照組證明:查詢字串是**整串**拿去 LIKE 的,從來沒被拆開。
|
||||||
|
// ⇒ 而 **AI 問的永遠是問句**,問句的詞不可能在原文裡剛好相鄰 ⇒ 這條路對 AI 恆為 0。
|
||||||
|
//
|
||||||
|
// 本檔守四件事:
|
||||||
|
// ① 拆得開 —— 詞存在但不相鄰的問句要能命中
|
||||||
|
// ② 不退化 —— 單詞查詢送出的 SQL 與舊版**逐字相同**(最熱路徑一個字都不能變)
|
||||||
|
// ③ 不崩壞 —— 相關的排前面、雜訊尾巴被相對門檻砍掉,不是把整個庫撈回來
|
||||||
|
// ④ 不再炸 —— 每個 LIKE pattern 仍在 D1 的 50 bytes 上限內(承 2026-08-03 的 500 修復)
|
||||||
|
import { describe, it, expect } from 'vitest';
|
||||||
|
import {
|
||||||
|
tokenizeQuery,
|
||||||
|
buildSearchScore,
|
||||||
|
applyRelativeCut,
|
||||||
|
searchEntries,
|
||||||
|
} from '../src/actions/entry-crud';
|
||||||
|
|
||||||
|
const bytes = (s: string) => new TextEncoder().encode(s).length;
|
||||||
|
const MAX_PATTERN = 50; // D1 LIKE pattern 硬上限
|
||||||
|
const termsOf = (q: string) => tokenizeQuery(q).map((t) => t.term);
|
||||||
|
const weightOf = (q: string, term: string) => tokenizeQuery(q).find((t) => t.term === term)?.weight;
|
||||||
|
|
||||||
|
describe('① 拆得開:問句要被拆成詞', () => {
|
||||||
|
it('「Gemini 逃生口」拆成兩個詞(就是驗收題本身)', () => {
|
||||||
|
expect(termsOf('Gemini 逃生口')).toEqual(expect.arrayContaining(['Gemini', '逃生口']));
|
||||||
|
});
|
||||||
|
|
||||||
|
it('沒有空白的 CJK/ASCII 交界也要切開(吸收 t95 normalizeCjkQuery 的用意)', () => {
|
||||||
|
expect(termsOf('Gemini逃生口')).toEqual(expect.arrayContaining(['Gemini', '逃生口']));
|
||||||
|
expect(termsOf('AI協作')).toEqual(expect.arrayContaining(['AI', '協作']));
|
||||||
|
});
|
||||||
|
|
||||||
|
it('自然語言問句:虛詞被丟掉,只留實詞', () => {
|
||||||
|
const t = termsOf('Gemini 在這套系統裡的角色是什麼?');
|
||||||
|
expect(t).toEqual(expect.arrayContaining(['Gemini', '系統', '角色']));
|
||||||
|
// 「這/的/是/什麼/套」是虛詞與量詞,不該變成查詢詞——否則會把整個庫撈回來
|
||||||
|
for (const junk of ['這', '的', '是', '什麼', '套系統']) expect(t).not.toContain(junk);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('標點(含全形)當分隔,不會混進詞裡', () => {
|
||||||
|
expect(termsOf('額度、向量化;為什麼?')).toEqual(expect.arrayContaining(['額度', '向量化']));
|
||||||
|
for (const t of termsOf('額度、向量化;為什麼?')) {
|
||||||
|
expect(t).not.toMatch(/[、;?,。]/);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it('超過 4 字的黏著長段補雙字組合(「專案管理工具」要能命中「專案管理」的寫法)', () => {
|
||||||
|
expect(termsOf('專案管理工具')).toEqual(expect.arrayContaining(['專案', '管理']));
|
||||||
|
});
|
||||||
|
|
||||||
|
it('任何查詢都至少留下一個詞,不會一個都不剩', () => {
|
||||||
|
for (const q of ['是什麼', '的', '。。。', 'a']) {
|
||||||
|
expect(buildSearchScore(q).scoreParams.length).toBeGreaterThan(0);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// 🔴 這組是「第一版寫錯、被自己的測試擋下來」的那個錯(2026-08-10):
|
||||||
|
// 第一版拿虛詞去**切段**,結果 `向` 把「向量化」切成「量化」、`能` 把「功能」切掉
|
||||||
|
// ⇒ 使用者真正要查的詞被切爛。沒有詞典的中文,切段一定誤傷實詞。
|
||||||
|
// 現在的做法是「整段不動、只過濾雙字組合」,這組測試就是不准再走回去。
|
||||||
|
it('實詞不准被虛詞切爛(向量化/功能/需要/使用者/規則/原因)', () => {
|
||||||
|
expect(termsOf('額度、向量化;為什麼?')).toContain('向量化');
|
||||||
|
expect(termsOf('這個功能是什麼')).toContain('功能');
|
||||||
|
for (const [q, word] of [
|
||||||
|
['系統需要什麼', '需要'], ['使用者是誰', '使用'], ['這個規則是什麼', '規則'],
|
||||||
|
['原因是什麼', '原因'], ['更新了什麼', '更新'],
|
||||||
|
] as const) {
|
||||||
|
expect(termsOf(q)).toContain(word);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it('leo 的第二題「今天額度為什麼用完」要抓得到「額度」', () => {
|
||||||
|
expect(termsOf('今天額度為什麼用完')).toContain('額度');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('② 不退化:單詞查詢與舊版逐字相同', () => {
|
||||||
|
it('單一英文詞 → 一個詞、legacyShape、一個 LIKE', () => {
|
||||||
|
const p = buildSearchScore('arcrun');
|
||||||
|
expect(p.terms.map((t) => t.term)).toEqual(['arcrun']);
|
||||||
|
expect(p.legacyShape).toBe(true);
|
||||||
|
expect(p.scoreParams).toEqual(['%arcrun%']);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('單一四字中文詞(最常見的中文查詢)→ 仍然只有一個 LIKE', () => {
|
||||||
|
// 這條是被既有 search-long-query.test.ts 擋出來的:雙字組合門檻若設 3,
|
||||||
|
// 「語意檢索」會從 1 個 LIKE 變成 5 個 ⇒ 最熱路徑成本 ×5。
|
||||||
|
const p = buildSearchScore('語意檢索');
|
||||||
|
expect(p.legacyShape).toBe(true);
|
||||||
|
expect(p.scoreParams).toEqual(['%語意檢索%']);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('searchEntries:單詞查詢送出的 SQL 只有一個 content LIKE,pattern 與舊版相同', async () => {
|
||||||
|
const { db, captured } = fakeDb();
|
||||||
|
await searchEntries(db, '語意檢索', 'demo');
|
||||||
|
expect(captured[0].sql.match(/content LIKE \?/g)).toHaveLength(1);
|
||||||
|
expect(captured[0].params[0]).toBe('%語意檢索%');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('單詞查詢分數全等 ⇒ 相對門檻一筆都砍不掉(排序退化回 updated_at DESC)', () => {
|
||||||
|
const rows = Array.from({ length: 50 }, (_, i) => ({ id: `e${i}`, match_score: 3 }));
|
||||||
|
expect(applyRelativeCut(rows)).toHaveLength(50);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('③ 不崩壞:相關的排前面,雜訊尾巴砍掉', () => {
|
||||||
|
it('詞愈長份量愈重(specific 壓過泛詞,這是相關性不崩壞的機制)', () => {
|
||||||
|
const q = 'Gemini 在這套系統裡的角色是什麼?';
|
||||||
|
expect(weightOf(q, 'Gemini')!).toBeGreaterThan(weightOf(q, '系統')!);
|
||||||
|
expect(weightOf(q, 'Gemini')!).toBeGreaterThan(weightOf(q, '角色')!);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('整句相鄰另給重賞 ⇒ 字面命中永遠壓過零散命中(`local arcrun` 那 5 筆不會被稀釋)', () => {
|
||||||
|
const p = buildSearchScore('local arcrun');
|
||||||
|
expect(p.legacyShape).toBe(false);
|
||||||
|
expect(p.scoreParams).toContain('%local arcrun%'); // 整句那一項存在
|
||||||
|
const bonus = p.terms.reduce((s, t) => s + t.weight, 0);
|
||||||
|
const scattered = p.terms.reduce((s, t) => s + t.weight, 0); // 全部詞都命中但不相鄰
|
||||||
|
expect(bonus + scattered).toBeGreaterThan(scattered); // 相鄰者必然更高分
|
||||||
|
});
|
||||||
|
|
||||||
|
it('相對門檻砍掉低於最高分 60% 的尾巴', () => {
|
||||||
|
const rows = [
|
||||||
|
{ id: 'a', match_score: 10 }, // 兩個詞都中
|
||||||
|
{ id: 'b', match_score: 6 }, // 只中重的那個
|
||||||
|
{ id: 'c', match_score: 2 }, // 只中泛詞 ⇒ 雜訊,砍掉
|
||||||
|
];
|
||||||
|
expect(applyRelativeCut(rows).map((r) => r.id)).toEqual(['a', 'b']);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('相對門檻是對「最高分」取比例、不是對「滿分」——否則驗收題會被自己的門檻誤殺', () => {
|
||||||
|
// 「Gemini 逃生口」:全庫沒有「逃生口」,最高分那群只中了 Gemini 一個詞。
|
||||||
|
// 若拿滿分當分母,這群會全部低於門檻 ⇒ 又回到 0 筆。
|
||||||
|
const onlyOneTermHit = Array.from({ length: 40 }, (_, i) => ({ id: `g${i}`, match_score: 6 }));
|
||||||
|
expect(applyRelativeCut(onlyOneTermHit)).toHaveLength(40);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('查詢詞數有上限(每多一個詞就多掃一次全表)', () => {
|
||||||
|
const t = tokenizeQuery('語意檢索 排名 選頁 雜訊 出處 門檻 正規化 三元組 知識庫 額度 向量');
|
||||||
|
expect(t.length).toBeLessThanOrEqual(6);
|
||||||
|
// 被砍掉的必須是最泛的那些 ⇒ 留下來的按份量遞減
|
||||||
|
const w = t.map((x) => x.weight);
|
||||||
|
expect([...w].sort((a, b) => b - a)).toEqual(w);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('④ 不再炸:LIKE pattern 仍在 D1 上限內(承 2026-08-03 的 500 修復)', () => {
|
||||||
|
it('任何查詢(含超長中文句)產生的每個 pattern 都 ≤ 50 bytes', () => {
|
||||||
|
const qs = [
|
||||||
|
'a'.repeat(300),
|
||||||
|
'為什麼不直接用語意檢索排名來選頁面而要用字面重疊加權來計分呢',
|
||||||
|
'Gemini 在這套系統裡的角色是什麼?今天額度為什麼用完?',
|
||||||
|
'。'.repeat(60),
|
||||||
|
];
|
||||||
|
for (const q of qs) {
|
||||||
|
const p = buildSearchScore(q);
|
||||||
|
expect(p.scoreParams.length).toBeGreaterThan(0); // 永不空條件(空條件=WHERE 塌掉)
|
||||||
|
for (const pattern of p.scoreParams) expect(bytes(pattern)).toBeLessThanOrEqual(MAX_PATTERN);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('searchEntries 送出的 SQL', () => {
|
||||||
|
it('多詞查詢:拆成多個 CASE WHEN,且只回 match_score > 0 的、按分數排序', async () => {
|
||||||
|
const { db, captured } = fakeDb();
|
||||||
|
await searchEntries(db, 'Gemini 逃生口', 'demo');
|
||||||
|
const sql = captured[0].sql;
|
||||||
|
expect((sql.match(/CASE WHEN content LIKE \?/g) ?? []).length).toBeGreaterThan(1);
|
||||||
|
expect(sql).toContain('match_score > 0');
|
||||||
|
expect(sql).toContain('ORDER BY match_score DESC');
|
||||||
|
expect(captured[0].params).toEqual(expect.arrayContaining(['%Gemini%', '%逃生口%']));
|
||||||
|
});
|
||||||
|
|
||||||
|
it('其他 filter(owner/library/deprecated)留在內層,先篩再算分', async () => {
|
||||||
|
const { db, captured } = fakeDb();
|
||||||
|
await searchEntries(db, 'Gemini 逃生口', 'demo', undefined, 50, ['kb'], 'kb://x');
|
||||||
|
const inner = captured[0].sql.split('WHERE match_score')[0];
|
||||||
|
expect(inner).toContain('owner_id = ?');
|
||||||
|
expect(inner).toContain("json_extract(metadata_json, '$.source')");
|
||||||
|
expect(inner).toContain('json_extract(metadata_json, \'$.status\')'); // NOT_DEPRECATED
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
function fakeDb() {
|
||||||
|
const captured: { sql: string; params: unknown[] }[] = [];
|
||||||
|
const db = {
|
||||||
|
prepare(sql: string) {
|
||||||
|
return {
|
||||||
|
bind(...params: unknown[]) {
|
||||||
|
captured.push({ sql, params });
|
||||||
|
return { all: async () => ({ results: [] }) };
|
||||||
|
},
|
||||||
|
};
|
||||||
|
},
|
||||||
|
} as unknown as D1Database;
|
||||||
|
return { db, captured };
|
||||||
|
}
|
||||||
@@ -14,6 +14,24 @@ const _app = new Hono<{ Bindings: Env; Variables: { org_namespace: string; partn
|
|||||||
// 安全模型見 mcp/OAUTH.md。註冊在 basePath 之前,落在同一份共享 router。
|
// 安全模型見 mcp/OAUTH.md。註冊在 basePath 之前,落在同一份共享 router。
|
||||||
registerOAuthRoutes(_app);
|
registerOAuthRoutes(_app);
|
||||||
|
|
||||||
|
// ── GET /health — 讓「這台跑的是哪一版 MCP」用一條 curl 看得到 ─────────────────────
|
||||||
|
// 為什麼要有:cypher-executor 早就有 /health(bundle_version + auth_store 探針),
|
||||||
|
// arcrun-mcp 沒有 ⇒ 要判斷某台實例的 MCP 是哪一代認證,只能打 /authorize 剖 HTML 數欄位
|
||||||
|
// (ops-facts 2026-08-10 的土法)。那個判準脆弱又難教。
|
||||||
|
//
|
||||||
|
// ⚠️ 誠實界定(別把它當世代判準用過頭):本端點是**這個 commit 之後才有的**,所以
|
||||||
|
// 「/health 回 404」只代表「比本版舊」,**不代表就是 owner_secret 世代**——
|
||||||
|
// 現階段判世代仍要看 /authorize 的欄位(一個 owner_secret =舊;email+password =新)。
|
||||||
|
// 等這版推到各實例之後,`auth` 欄位才會變成一眼可讀的世代判準。
|
||||||
|
// 不需認證、不吐任何機密;MCP_BUILD 是部署標記,由各實例 toml [vars] 帶入。
|
||||||
|
_app.get("/health", (c) => c.json({
|
||||||
|
ok: true,
|
||||||
|
service: "arcrun-mcp",
|
||||||
|
auth: "portal-login",
|
||||||
|
build: c.env.MCP_BUILD ?? "unknown",
|
||||||
|
oauth_kv: c.env.OAUTH_KV ? "present" : "missing",
|
||||||
|
}));
|
||||||
|
|
||||||
const app = _app.basePath('/mcp');
|
const app = _app.basePath('/mcp');
|
||||||
|
|
||||||
app.use("*", cors({
|
app.use("*", cors({
|
||||||
|
|||||||
@@ -38,6 +38,9 @@ export interface Env {
|
|||||||
// 允許的 redirect_uri host 白名單(逗號分隔)。DCR 無狀態故靠此擋 open-redirect/釣魚。
|
// 允許的 redirect_uri host 白名單(逗號分隔)。DCR 無狀態故靠此擋 open-redirect/釣魚。
|
||||||
// 未設 → 預設只允許 claude.ai / claude.com / anthropic.com(含子網域)+ localhost。
|
// 未設 → 預設只允許 claude.ai / claude.com / anthropic.com(含子網域)+ localhost。
|
||||||
MCP_ALLOWED_REDIRECT_HOSTS?: string;
|
MCP_ALLOWED_REDIRECT_HOSTS?: string;
|
||||||
|
// 部署標記(非機密):GET /health 原樣回報,讓「這台跑的是哪一版」用一條 curl 看得到。
|
||||||
|
// 由各實例的 wrangler toml [vars] 帶入;沒帶 → /health 回 build:"unknown"(誠實,不假裝)。
|
||||||
|
MCP_BUILD?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface ToolContext {
|
export interface ToolContext {
|
||||||
|
|||||||
@@ -0,0 +1,56 @@
|
|||||||
|
# ── arcrun-mcp STAGE(youlin 帳號)────────────────────────────────────────────────
|
||||||
|
#
|
||||||
|
# 這支存在的理由(2026-08-10,Leo/mira#4):
|
||||||
|
# `arcrun-mcp` 不在安裝器出貨的那批裡,所以「升級某台的 arcrun-mcp」一直沒有測試場,
|
||||||
|
# 要驗只能拿 leo21c(唯一一份 47.9 萬筆知識的真身)去冒險。這支把 stage 補上:
|
||||||
|
# youlin = stage(agent-memory「兩台分工」:AI 測 youlin、leo 核實 prod)。
|
||||||
|
#
|
||||||
|
# 用法(在 mcp/ 目錄下跑,-c 必須與 main="src/index.ts" 同目錄,否則找不到入口):
|
||||||
|
# cd mcp
|
||||||
|
# CLOUDFLARE_ACCOUNT_ID=1129efd7df2e8899d537e9c8fbabb6cb \
|
||||||
|
# CLOUDFLARE_API_TOKEN="$CLOUDFLARE_API_TOKEN_YOULIN_CC_USE" \
|
||||||
|
# npx wrangler deploy -c wrangler.stage.toml
|
||||||
|
#
|
||||||
|
# 與出貨用的 wrangler.toml 的差別,就是「手動直推 arcrun-mcp 到自架帳號」的兩個坑
|
||||||
|
# (agent-memory 已記,這裡是把它變成檔案而不是靠人記得):
|
||||||
|
# ① 拿掉 [[routes]] —— `mcp.arcrun.dev` 那個 zone 在 uncle6,自架帳號沒有,帶著會部署失敗
|
||||||
|
# ② OAUTH_KV 填真 id —— 出貨 toml 是佔位符 REPLACE_WITH_REAL_KV_ID,只有走 acr init/update
|
||||||
|
# 才會被自動注入;直推必須自己填(沒填 → /authorize 回 503,OAuth 整條死)
|
||||||
|
#
|
||||||
|
# 🔴 [vars] 必須列**全**:wrangler deploy 會用本檔的 vars 整組取代線上的,漏一個就是靜默清掉。
|
||||||
|
# 下方七個 var 是 2026-08-10 從線上 arcrun-mcp 抓下來的原樣值。
|
||||||
|
# Secrets(KBDB_INTERNAL_TOKEN / MCP_OWNER_SECRET)不進本檔也不會被 deploy 洗掉。
|
||||||
|
|
||||||
|
name = "arcrun-mcp"
|
||||||
|
main = "src/index.ts"
|
||||||
|
compatibility_date = "2024-11-27"
|
||||||
|
compatibility_flags = [ "nodejs_compat" ]
|
||||||
|
workers_dev = true
|
||||||
|
|
||||||
|
[vars]
|
||||||
|
MULTI_TENANT = "false"
|
||||||
|
CF_ACCOUNT_ID = "1129efd7df2e8899d537e9c8fbabb6cb"
|
||||||
|
WORKER_SUBDOMAIN = "youlin-hsieh-dev"
|
||||||
|
CONSOLE_TENANT = "yuga3bse"
|
||||||
|
MCP_OWNER_NAMESPACE = "yuga3bse"
|
||||||
|
KBDB_BASE_URL = "https://arcrun-kbdb.youlin-hsieh-dev.workers.dev"
|
||||||
|
UI_ORIGINS = "https://arcrun-rag-ui.youlin-hsieh-dev.workers.dev"
|
||||||
|
# 部署標記,GET /health 原樣回報。每次重推 stage 就換一個,別讓它跟線上脫節。
|
||||||
|
MCP_BUILD = "stage-2026-08-10+45f1c09"
|
||||||
|
|
||||||
|
[[services]]
|
||||||
|
binding = "COMPONENT_REGISTRY"
|
||||||
|
service = "arcrun-registry"
|
||||||
|
|
||||||
|
[[services]]
|
||||||
|
binding = "CYPHER_EXECUTOR"
|
||||||
|
service = "arcrun-cypher-executor"
|
||||||
|
|
||||||
|
[[services]]
|
||||||
|
binding = "KBDB"
|
||||||
|
service = "arcrun-kbdb"
|
||||||
|
|
||||||
|
# youlin 的 OAuth KV(安裝器建的 arcrun-rag-yuga3bse-kv-oauth_kv)。
|
||||||
|
[[kv_namespaces]]
|
||||||
|
binding = "OAUTH_KV"
|
||||||
|
id = "6a8d4dd621994607b3998ade4c7e9944"
|
||||||
Reference in New Issue
Block a user