iT邦幫忙

2026 iThome 鐵人賽

DAY 18
0
佛心分享-SideProject30

為你自己蓋一座會複利的知識庫——WikiBrain系列 第 18 篇

Day 18 - 用雜湊比對分出規則頁改過沒有

  • 分享至 

  • xImage
  •  

前言

之前我們已經講過,編纂的內容規則不寫在程式裡,寫在知識庫的 schema/ 那一層,使用者自己就能改。

那第一份規則得有人給。新註冊的人打開一座空的知識庫,schema/ 裡什麼都沒有,agent 呼叫 get_instructions 拿到的是「這一層還沒有任何規則頁」。技術上沒錯,體驗上等於沒做。

這一篇講模版,以及一個我一開始沒想到的問題:規則改進了,已經開好的工作區怎麼辦。

模版是內容,不是介面

先界定範圍。這裡的模版只影響知識庫的內容,不影響任何介面。選研究者模版不會讓你多出一個分頁或少掉一顆按鈕,它只是在你的 schema/ 與 wiki/ 裡預先放幾頁。

這個限制是刻意的。一旦模版可以改介面,它就變成「方案」或「版本」,每加一種就要多測一輪;而現在它就是一疊 Markdown 檔案,多一種模版等於多一個資料夾。

內建的有四種:通用、研究者、專案管理、讀書。每種各有中英兩份。

套用是「加不覆蓋」

for (const path of files) {
  const content = await readFile(join(dir, path), 'utf8');
  try {
    await createNote(workspaceId, path, content, actor);
    created.push(path);
  } catch (e) {
    if (e instanceof NoteError && e.code === 'CONFLICT') skipped.push(path);
    else throw e;
  }
}
await recordApplied(workspaceId, info, lang, dir, files);

出處:src/templates.ts:53-63

它用的就是 create_note 背後的 createNote,路徑已存在就拋 CONFLICT。這裡把那個錯誤當成正常結果接住,記進 skipped。

所以套用模版永遠不會蓋掉任何東西。這帶來兩個好處:同一個模版可以重複套用(第二次全部 skip),而且兩個模版可以疊起來用(研究者疊在通用上面,重複的頁保留先來的那份)。

回傳值裡 created 跟 skipped 都給出去,前端才能告訴使用者「已建立 6 頁,跳過已存在的 2 頁」。

然後規則頁改版了

上線一段時間之後,我改寫了通用模版的規則頁,補上三層架構的說明、index 與 log 的角色、三個操作的定義。新註冊的人拿到新版,已經開好的工作區什麼都沒發生,因為套用是加不覆蓋,那幾頁早就存在,再套一次全部 skip。

舊使用者的 agent 行為跟新使用者不一樣,而這種差異在測試環境完全看不出來,測試每次都是從空的工作區開始。

直覺的修法是「提供一顆更新按鈕,把規則頁換成新版」。但這會直接踩到另一件事:有些人已經改過那一頁了。那是整個產品的賣點:規則是你的,你自己能改。一顆會覆蓋的按鈕,等於把賣點收回去。

分辨「沒動過」與「改過」

解法是套用的時候把交付的內容記下來:

async function recordApplied(ws, info, lang, dir, files): Promise<void> {
  const hashes: Record<string, string> = {}, contents: Record<string, string> = {};
  for (const path of files.filter(f => f.startsWith('schema/'))) {
    const text = await readFile(join(dir, path), 'utf8');
    hashes[path] = sha(text);
    contents[path] = text;          // the base for a later three-way merge
  }
  if (Object.keys(hashes).length === 0) return;
  await pool.query(
    `INSERT INTO template_applied (workspace_id, template_id, lang, version, files, contents)
     VALUES ($1, $2, $3, $4, $5, $6)
     ON CONFLICT (workspace_id, template_id) DO UPDATE
       SET lang = EXCLUDED.lang, version = EXCLUDED.version, files = EXCLUDED.files,
           contents = EXCLUDED.contents, applied_at = now()`,
    [ws, info.id, lang, info.version ?? 1, JSON.stringify(hashes), JSON.stringify(contents)]);
}

出處:src/templates.ts:71-86

記的是當初交出去的那幾頁:雜湊用來判斷有沒有被改過,整份內容留著當之後三方合併的共同祖先,再加上模版版本號。只記 schema/ 底下的,因為那是 agent 會讀的規則,也是唯一可能需要更新的東西。wiki/ 的起始頁是給使用者寫的,我沒有立場去更新它。

有了這份紀錄,就分得出四種狀態:

現在的內容 判斷 能不能動
跟交付時的雜湊一樣 untouched 可以換成新版,不會失去任何東西
改過,但跟新版合得起來 merged 可以寫合併後的結果
改過,而且合不起來 edited 不能碰,只能顯示差異
頁面不見了 missing 可以補回來
if (!note) { pages.push({ path, state: 'missing', current: '', next, merged: null }); continue; }
if (sha(note.content_md) === sha(next)) continue;    // already identical to the new version
if (sha(note.content_md) === deliveredHash) { pages.push({ path, state: 'untouched', current: note.content_md, next, merged: null }); continue; }
// Edited. With the delivered text as the base, their edits and ours can often both be kept.
const base = r.contents?.[path];
const m = base ? merge3(base, note.content_md, next) : { clean: false, text: null, conflicts: 1 };
pages.push({ path, state: m.clean ? 'merged' : 'edited', current: note.content_md, next, merged: m.text });

出處:src/templates.ts:120-126

merged 是後來才加的(commit 83d693bf)。原本只有「沒動過」跟「改過」兩種,而「改過」的結局是永遠拿不到新版。使用者只是在規則頁尾巴加了一行自己的慣例,從此所有改進都與他無關。這樣太重了。

有了交付當下的完整內容當共同祖先,這就是一個標準的三方合併:我們改的、他改的、共同的起點。只有雙方都動到同一個區域才算衝突。

而使用者自己寫的規則頁(沒有任何交付紀錄的那些)根本不會出現在這份清單裡。系統對它們沒有意見。

疊起來用的時候,這份紀錄會說謊

上面那行 await recordApplied(workspaceId, info, lang, dir, files) 傳的是 files,也就是這個模版的全部檔案,不是剛剛真的建立的 created。

大部分情況下沒差,但疊模版的時候就有差了。研究者疊在通用上面,兩邊都有 schema/instructions.md,這一頁會被 skip,使用者看到的是通用版的內容,交付紀錄裡記的卻是研究者版的雜湊。下次研究者模版改版,這一頁跟交付雜湊對不起來,於是走進合併那條路,結局要看兩份模版有多像:合不起來判成 edited,這一頁從此收不到更新;合得起來判成 merged,研究者版的新內容被縫進使用者以為是通用版的那一頁。這兩份規則頁本來就有大半相同,三十五行裡二十四行一字不差,所以第二種結局一點都不難發生。

寫這篇的時候才發現這件事,repo 裡目前就是這樣。修法不難,改傳 created 就好,那一頁本來就歸先來的模版管。但它說明了一件事:這種「記指紋」的機制,記錯的時候不會有任何警訊,只是安靜地把之後的每個判斷都帶偏。

更新時只碰證明得了沒被改過的

for (const page of updates.pages) {
  if (page.state === 'edited' && mode === 'safe') { kept.push(page.path); continue; }
  if (page.state === 'missing') { await createNote(ws, page.path, page.next, actor); updated.push(page.path); continue; }
  const note = await readNote(ws, page.path);
  const text = page.state === 'merged' && page.merged !== null ? page.merged : page.next;
  await updateNote(ws, page.path, text, note.version, actor);
  if (page.state === 'merged') merged.push(page.path);
  else if (page.state === 'edited') overwritten.push(page.path);
  else updated.push(page.path);
}

出處:src/templates.ts:143-152

更新走的是 updateNote 帶 if_version,也就是之前我們已經講過的樂觀鎖,所以就算使用者正在編輯那一頁也不會被蓋掉。

有兩個模式。safe 寫沒動過的跟合得起來的,其餘原封不動退回去;overwrite 連衝突的那幾頁也換成新版。後者不是破壞性的操作,因為每一次寫入都是一個新版本,舊的內容留在頁面歷史裡,一鍵就回得來。但這件事不講,使用者就不知道自己有退路,所以覆蓋按鈕旁邊寫著「覆蓋之後你原本的內容會留在該頁的版本歷史裡,隨時可以看差異並一鍵復原」(RuleUpdates.tsx:25)。

合併不產生衝突標記

merge3 有一個跟一般三方合併不一樣的決定:

/* Conflict markers are deliberately never produced. The result of a merge is a page an agent reads as instructions,
   and `<<<<<<<` in that page would be read as part of the rules. A conflict means "do not merge", not "merge messily". */

出處:src/merge3.ts:6-7

這就是這個產品跟一般編輯器的差別。Git 把衝突標記塞進檔案裡是安全的,因為讀那個檔案的是人,人看到 <<<<<<< 就知道要處理。但這裡讀規則頁的是 agent,它會把那七個角括號連同兩邊的版本一起當成規則的一部分。

所以合不起來就回報合不起來,不交出一份髒的結果。

版本號只在全部處理完才前進

await pool.query(`UPDATE template_applied SET version = $3, files = $4, contents = $5, applied_at = now() WHERE workspace_id = $1 AND template_id = $2`,
  [ws, templateId, kept.length ? updates.appliedVersion : updates.currentVersion, JSON.stringify(files), JSON.stringify(contents)]);

出處:src/templates.ts:166-167

兩個細節都是為了「下一次還能正確判斷」而存在。

只要有任何一頁被留下(kept.length),版本號就不前進。 那一頁的更新還沒處理完,如果版本號跳上去,pendingRuleUpdates 的 currentVersion <= r.version 就會讓整批更新從清單上消失,使用者再也看不到它。

只有真的被寫入的頁才換新的基準。 被留下的那一頁保留當初交付的文字當共同祖先。如果順手把它的基準換成使用者現在的內容,下次比對就會判成「沒動過」,然後直接覆蓋掉他的修改;而且未來的合併也失去了那個起點。

版本號這個欄位是跟這套機制一起出生的,四個模版從 1 開始。它第一次真的派上用場,是之前我們已經講過的那次規則頁修改:補上 create_note 與 NOT_FOUND 那幾句的同時,四個模版一起加到 2,已套用的工作區第一次收到更新通知。

十行變成一百行的地方

一顆「更新規則」的按鈕,暴力做法是十行,這個版本大概一百行。多出來的九十行全部花在同一件事上:證明這一頁使用者沒有動過。

答案只能是「跟交付的那一刻比對」,而那要求你在交付的當下就把證據存下來。這類需求幾乎不可能事後補。如果當初沒記雜湊,現在你手上只有「使用者的內容」跟「最新的模版」,永遠分不出中間那一頁是被改過還是從來沒改。

小結

模版要能更新,就得在交出去的那一刻留下證據;事後補不回來,因為那時候手上只剩使用者的版本跟最新的版本,中間那一份已經沒有了。疊模版那個洞也是這個形狀:記錯不會發出任何聲音,它只是讓某一頁從此收不到更新,或者收下不屬於它的內容。


上一篇
Day 17 - 工作紀錄與失敗步驟
下一篇
Day 19 - 接上 better-auth,用 AES-256-GCM 存使用者的 API key
系列文
為你自己蓋一座會複利的知識庫——WikiBrain 共 22 篇
圖片
  熱門推薦
圖片
{{ item.channelVendor }} | {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言