feat(t152): workers_ai_chat 種子(auth: binding,免金鑰)+ 修 /init/seed 吃掉 3.12 欄位+ 修 D1 LIKE 長查詢 500
SDD: workflow-discovery 3.12/3.13(不是新規格;3.12 已 confirmed 並實作完成) ## 1) workers_ai_chat 種子(新) Cloudflare Workers AI 走 env.AI binding ⇒ 用戶不必填任何 API 金鑰就能問答。 放種子表而非產品安裝器:「裝好後預設有哪些 recipe」是平台能力(rule 07 薄殼原則)。 換模型/換供應商=改這一筆 recipe,workflow 不動。 選型實測(1.4.4 實例,真實長度 RAG prompt,每個模型連跑 2 次): llama-4-scout-17b 2373/2173 ms ✅ 答案最完整、引用正確 ← 選它 llama-3.3-70b-fp8-fast 3261/2147 ms ✅ 可用但波動較大 mistral-small-3.1-24b 3560/3631 ms qwen2.5-coder-32b 3572/3353 ms gpt-oss-120b 1971/2295 ms ❌ 回應形狀不同,response 取不到文字 gemma-3-12b-it ❌ 5018 帳號無權限 對照舊路徑 Gemini gemma-4-31b-it:同型提問 16.87 s,且吐整段英文思考草稿。 ## 2) 修 /init/seed 靜默吃掉 3.12 欄位 3.12 給 RecipeDefinition 加了 body_template/response_map/auth/binding_name, 但 /init/seed 是**列舉欄位重建** recipe record ⇒ 不在名單上的欄位被丟掉。 最惡劣的地方是「哪裡都不會紅」:recipe 查得到、endpoint 對,只有跑起來像沒設定過。 與 08-02 syncManifest 吃掉 manifest.daemon 欄同型(教訓:東西還在不在也要進機械閘)。 加 tests/init-seed-recipe-fields.test.ts:拿掉修復會紅、補回會綠(已實測會擋)。 ## 3) 修 D1 LIKE pattern 50 bytes 上限造成的 500 /entries/search?q=… 只要 q 超過 48 bytes 就回 HTTP 500,沒有錯誤訊息。 逐 byte 二分:48→200/49→500;中文 16 字→200/17 字→500。 判別實驗:q 固定 48 bytes、其他 filter 全塞滿讓 SQL 變很長 → 仍 200 ⇒ 爆的是 LIKE 的 pattern('%'+q+'%' = 50),不是 statement 長度。 中文問句超過 16 字是常態,而 rag_chat 用整句問題當 q ⇒ 聊天對正常問句等於不能用。 (=InkStoneCo status.md 待辦第 1 條「KBDB keyword 長查詢會炸」的根因。) 修法:q ≤ 48 bytes 走原路(行為逐字不變),超過才拆詞/切 UTF-8 邊界片段。 kbdb 全套 83 測全綠(含新增 8 項)。 ## 4) 順手 - 移除被 commit 進 repo 的 node_modules 壞 symlink(指向 leo Mac 的絕對路徑, 害任何 fresh clone 裝不起來、切分支還會把裝好的蓋掉——本次撞了兩次)。 - pending-changes.md 加 P2 提案(fan-out 並行執行)+等裁決,未動引擎。 驗證:cypher-executor 新增測試 17/17 綠;tsc 與基線逐字相同; 全套測試失敗集合與基線**逐字相同**(基線 14 個失敗,本分支 t173 既有,非本次引入)。
This commit is contained in:
@@ -84,7 +84,10 @@ export async function listEntries(db: D1Database, f: ListEntriesFilter = {}): Pr
|
||||
// no new column / no migration (表不變鐵律). Per issue #5.1 (頂層化 source 成可查 filter).
|
||||
if (f.source) { conds.push("json_extract(metadata_json, '$.source') = ?"); params.push(f.source); }
|
||||
if (f.library && f.library.length > 0) { conds.push(libraryPredicate(f.library)); params.push(...f.library); }
|
||||
if (f.q) { conds.push('content LIKE ?'); params.push(`%${f.q}%`); }
|
||||
if (f.q) {
|
||||
const m = buildContentLike(f.q); // D1 LIKE pattern 50 bytes 上限,見 buildContentLike
|
||||
conds.push(...m.conds); params.push(...m.params);
|
||||
}
|
||||
const where = conds.length ? `WHERE ${conds.join(' AND ')}` : '';
|
||||
const limit = Math.min(f.limit ?? 100, 1000);
|
||||
const offset = f.offset ?? 0;
|
||||
@@ -147,6 +150,77 @@ export async function deprecateEntriesByLibrary(db: D1Database, ownerId: string,
|
||||
return (result.meta?.changes as number | undefined) ?? 0;
|
||||
}
|
||||
|
||||
// ── content 關鍵字比對:D1 的 LIKE pattern 有 50 bytes 硬上限 ───────────────────
|
||||
//
|
||||
// 病徵(2026-08-03 在 1.4.4 實例上二分實測):`/entries/search?q=…` 只要 q **超過 48 bytes**
|
||||
// 就回 HTTP 500「Internal Server Error」——不是 400、沒有錯誤訊息,從外面看像伺服器壞了。
|
||||
// q = 48 bytes → 200|q = 49 bytes → 500(ASCII 逐 byte 二分)
|
||||
// 中文 16 字(48 bytes)→ 200|中文 17 字(51 bytes)→ 500
|
||||
// 判別實驗(排除「整句 SQL 太長」這個猜想):q 固定 48 bytes、把 owner_id/entry_type/source/
|
||||
// library 全塞滿讓 SQL 變很長 → 仍然 200 ⇒ **會爆的是 LIKE 的 pattern,不是 statement**。
|
||||
// pattern = '%' + q + '%' ⇒ 48+2 = 50 ⇒ 上限就是 50 bytes。
|
||||
// 對照:同一個長 q 走 mode=semantic 完全正常(那條路不經過 LIKE)。
|
||||
//
|
||||
// 為什麼要修(不是邊角):**中文問句超過 16 個字是常態**。
|
||||
// rag_chat 的 kw_search 用整句問題當 q ⇒ 使用者問任何一句正常長度的中文,
|
||||
// 整條問答鏈在第二個節點就 500 ⇒ 聊天功能等於不能用。
|
||||
// (這也是 InkStoneCo status.md 待辦第 1 條「KBDB keyword 長查詢會炸」的根因。)
|
||||
//
|
||||
// 修法(**短查詢行為逐字不變**):
|
||||
// · q ≤ 48 bytes → 走原本那條路,單一 `content LIKE '%q%'`,一個字都沒改。
|
||||
// · q > 48 bytes → 拆成詞,每個詞各一個 LIKE 用 AND 串(「每個詞都要出現」)。
|
||||
// 沒有空白可拆的長句(中文常見)→ 切成 ≤48 bytes 的片段(切在 UTF-8 邊界上,不切壞字)。
|
||||
// 詞數上限 6:再多對 D1 是白花成本,而且「要同時命中 7 個詞」本來就不會有結果。
|
||||
//
|
||||
// 誠實限制:對「無空白的長中文句」,拆片段是機械切分、不是斷詞 ⇒ 命中率不會變好。
|
||||
// 但它的對照組是 **500**,不是「更好的結果」;而且這種查詢原本就算不炸也幾乎命不中
|
||||
// (整句子字串比對)。真正的中文關鍵字檢索要走 FTS5 或斷詞,那是另一件事、要另外立案。
|
||||
const MAX_LIKE_Q_BYTES = 48; // D1: LIKE pattern 上限 50 bytes,pattern = '%' + q + '%'
|
||||
const MAX_LIKE_TERMS = 6;
|
||||
|
||||
const utf8Len = (s: string): number => new TextEncoder().encode(s).length;
|
||||
|
||||
/** 依 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 (cur) out.push(cur);
|
||||
cur = ch;
|
||||
} else {
|
||||
cur += ch;
|
||||
}
|
||||
}
|
||||
if (cur) out.push(cur);
|
||||
return out;
|
||||
}
|
||||
|
||||
/**
|
||||
* 把 q 轉成一組 `content LIKE ?` 謂詞與參數(純函式,單測用 export)。
|
||||
* 回 `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 };
|
||||
}
|
||||
const terms: string[] = [];
|
||||
for (const word of q.split(/\s+/).filter(Boolean)) {
|
||||
for (const piece of chunkByBytes(word, MAX_LIKE_Q_BYTES)) {
|
||||
terms.push(piece);
|
||||
if (terms.length >= MAX_LIKE_TERMS) break;
|
||||
}
|
||||
if (terms.length >= MAX_LIKE_TERMS) break;
|
||||
}
|
||||
// 理論上不會空(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}%`),
|
||||
split: true,
|
||||
};
|
||||
}
|
||||
|
||||
// 「庫」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 (…)))——
|
||||
// 語意完全相同(未標記/無 metadata_json 的舊資料歸 'general'),但單組佔位符、不用重複綁參數。
|
||||
@@ -209,8 +283,9 @@ export async function searchEntries(
|
||||
source?: string,
|
||||
includeDeprecated = false,
|
||||
): Promise<Entry[]> {
|
||||
const conds = ['content LIKE ?'];
|
||||
const params: unknown[] = [`%${q}%`];
|
||||
const m = buildContentLike(q); // D1 LIKE pattern 50 bytes 上限,見 buildContentLike
|
||||
const conds = [...m.conds];
|
||||
const params: unknown[] = [...m.params];
|
||||
if (owner_id) { conds.push('owner_id = ?'); params.push(owner_id); }
|
||||
if (entry_type) { conds.push('entry_type = ?'); params.push(entry_type); }
|
||||
if (source) { conds.push("json_extract(metadata_json, '$.source') = ?"); params.push(source); }
|
||||
|
||||
Reference in New Issue
Block a user