feat(collector): workers-ai 路改吃共用契約——免金鑰預設路的卡跟 BYOK 一樣完整(Arcrun#134)

InkStoneCo#44 ④ 只修了 gemma BYOK 路(JSON 契約+wikishape 機械組卡),
免金鑰預設路(多數用戶實際走的)仍吃雲端舊 prompt 的舊格式卡 ⇒ 走預設路
拿到次級知識庫。

修法=契約單一真相源:wikiExtractPrompt(原 gemmaPrompt 改名,因為它已是
兩條路共用)由 daemon 整段帶上雲(request prompt 欄位),雲端只當執行器回
模型原文(response output),解析(parseWikiExtractJSON)與組卡(BuildWikiDoc)
回到本 package 與 gemma 路同一段程式碼——同形不再靠「兩邊要一起改」的叮嚀。

版本歪斜兩向都有路:舊雲端忽略 prompt 回 card ⇒ fallback 走 legacy 落卡
(#60 前綴/不覆蓋保護原封不動,收端 lint 新舊雙軌);舊 daemon 不帶 prompt
⇒ 雲端 legacy 行為不變(對向修法在 Arcrun work/extract-wiki-json-0815)。

測試:+2——①request 的 prompt 必須是 wikiExtractPrompt 本人(不是手抄第二份)
②同原稿+同判斷走兩條路,.wiki/ 產物逐位元組相同(卡+00-INDEX+manifest)。
既有兩則(stub 回 card)自動變成舊雲端 fallback 的守衛。go test ./... 全綠。

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-08-15 21:26:30 +08:00
parent 12c2d41886
commit 6c74b97718
6 changed files with 165 additions and 8 deletions
+1 -1
View File
@@ -12,7 +12,7 @@ import (
"testing"
)
// cardFixture 組出一份合格的萃取 JSONInkStoneCo#44 ④:gemmaPrompt 的契約改為
// cardFixture 組出一份合格的萃取 JSONInkStoneCo#44 ④:wikiExtractPrompt 的契約改為
// 「文件總覽+N 個原子概念」的 JSON,卡片格式由 wikishape.go 機械組裝)。
// 概念名固定為「<subject>·概念」——避免與各測試原稿的 H1/頁名撞名。
func cardFixture(subject, object string) string {
+8 -3
View File
@@ -27,12 +27,17 @@ const defaultLLMModel = "gemma-4-31b-it"
var gemmaHTTP = &http.Client{Timeout: 120 * time.Second}
// gemmaPrompt 組出「文件卡+N 張原子概念卡」的結構化萃取指令(InkStoneCo#44 ④)。
// wikiExtractPrompt 組出「文件卡+N 張原子概念卡」的結構化萃取指令(InkStoneCo#44 ④)。
//
// 🔴 這是**全部萃取路共用的唯一一份**提示詞(Arcrun#134 起原名 gemmaPrompt 改此名):
// gemma 路自己打 Gemini 用它;workers-ai 路把它整段帶去雲端(`prompt` 欄位)給
// env.AI 跑。契約(提示詞+parseWikiExtractJSONBuildWikiDoc)同住本 package
// ⇒ 兩條路的卡片形狀由同一份程式碼保證,不再靠「兩邊要一起改」的叮嚀。
//
// 🔴 分工:模型只回 JSON(判斷),格式與落點全由 wikishape.go 機械組裝——
// 模型不寫 markdown、不決定檔名、不碰路徑。Luhmann ②(一卡一概念)在**萃取端**做,
// 否則 place_card 只會一直回 orphan(規範待裁 6 的裁定理由)。
func gemmaPrompt(pageName, content string) string {
func wikiExtractPrompt(pageName, content string) string {
return `你是知識整理員。讀完原稿後,把它整理成「一份文件的總覽+N 個原子概念」。只輸出一個 JSON 物件,不要任何說明、markdown 圍欄或思考過程。
規則(違反任何一條都算失敗):
@@ -109,7 +114,7 @@ func ExtractWithGemma(apiKey, model, absRoot, relPath string) ([]string, error)
reqBody, _ := json.Marshal(map[string]any{
"contents": []map[string]any{
{"parts": []map[string]any{{"text": gemmaPrompt(pageName, srcText)}}},
{"parts": []map[string]any{{"text": wikiExtractPrompt(pageName, srcText)}}},
},
"generationConfig": map[string]any{"temperature": 0.2, "maxOutputTokens": 8192},
})
+32 -2
View File
@@ -22,6 +22,15 @@
// 讀原檔 → ConvertToText(本機轉檔)→ 送 API → 淨化 → 落卡
// ↑ 只有這一步不同
//
// 🔴 Arcrun#1342026-08-15):**提示詞也由 daemon 帶去**request 的 `prompt` 欄位)。
// 之前雲端自備一份 prompt、daemon 另有一份 gemma 用的,兩份靠註解叮嚀「一起改」——
// InkStoneCo#44 ④ 改了 gemma 那份(JSON 契約+wikishape 機械組卡),雲端沒跟上,
// 免金鑰預設路的用戶因此繼續拿舊格式卡。修法=契約只住本 package 一份
// wikiExtractPrompt parseWikiExtractJSON BuildWikiDoc 同進同出),
// 雲端只是「用實例自己的 env.AI 跑生成」的執行器,回應 `output` 原文。
// 版本歪斜:舊雲端會忽略 prompt、照舊回 `card`(舊格式 markdown)⇒ 本檔 fallback
// 走 legacy 落卡(收端 lint 新舊雙軌仍接受,#60 的前綴與不覆蓋保護原封不動)。
//
// ⚠️ 隱私邊界不變:送出去的是**已在本機轉成純文字的原稿**,回來的是知識卡;
// 原始檔案(docx/pdf/xlsx)仍然不出用戶的電腦。
package collector
@@ -66,9 +75,12 @@ func ExtractWithWorkersAI(cypherURL, apiKey, absRoot, relPath string) ([]string,
}
pageName := pageNameOf(relPath)
// #134prompt=與 gemma 路同一份契約(同 package 同函式,物理上不可能漂移)。
// page_name/text 仍照送:舊雲端不認得 prompt,會拿它們組 legacy 提示詞回舊卡。
reqBody, _ := json.Marshal(map[string]string{
"page_name": pageName,
"text": srcText,
"prompt": wikiExtractPrompt(pageName, srcText),
})
url := strings.TrimSuffix(strings.TrimSpace(cypherURL), "/") + "/portal/daemon/extract"
@@ -104,19 +116,37 @@ func ExtractWithWorkersAI(cypherURL, apiKey, absRoot, relPath string) ([]string,
var parsed struct {
Success bool `json:"success"`
Card string `json:"card"`
Output string `json:"output"` // #134:新雲端在 prompt 模式回模型原文
Error string `json:"error"`
}
if err := json.Unmarshal(body, &parsed); err != nil {
return nil, fmt.Errorf("雲端回應解析失敗:%w", err)
}
if !parsed.Success || strings.TrimSpace(parsed.Card) == "" {
if !parsed.Success || (strings.TrimSpace(parsed.Card) == "" && strings.TrimSpace(parsed.Output) == "") {
if parsed.Error != "" {
return nil, fmt.Errorf("雲端萃取失敗:%s", parsed.Error)
}
return nil, fmt.Errorf("雲端沒有回傳卡片內容")
}
// 與 gemma 路共用同一套淨化與落卡(卡片格式契約一致:第一行必須是「# <頁名>」)
// #134 主線:新雲端回 `output`(模型對 wikiExtractPrompt 的原始回應)⇒
// 與 gemma 路走**同一段**收尾:解析 JSON 判斷 → wikishape 機械組卡落 `.wiki/`。
// 兩條萃取路的卡片形狀從此由同一份程式碼保證,不是由兩份 prompt 各自維持。
if strings.TrimSpace(parsed.Output) != "" {
ex, perr := parseWikiExtractJSON(parsed.Output)
if perr != nil {
return nil, perr
}
cards, berr := BuildWikiDoc(absRoot, relPath, srcText, ex, time.Now())
if berr != nil {
return nil, berr
}
// 沒有可萃概念=合法結果:不產卡,00-INDEX 列「空」(同 gemma 路)。
return cards, nil
}
// legacy fallback:舊雲端(不認得 prompt)回 `card`(舊格式 markdown)。
// 與 gemma 舊路同一套淨化與落卡(第一行必須是「# <頁名>」),#60 保護不動。
card := cleanGemmaCard(parsed.Card, pageName)
if !strings.HasPrefix(card, "# ") {
return nil, fmt.Errorf("萃出內容不像卡片(未以 # 開頭):%.120s", card)
+122
View File
@@ -102,3 +102,125 @@ func TestExtractWithWorkersAI_NonVaultUnchanged(t *testing.T) {
t.Fatalf("非 vault 卡片路徑不對:%vwant [system-dev/wiki/cards/arcrun-note.md]", cards)
}
}
// ── Arcrun#134workers-ai 路與 gemma 路共用同一份契約 ─────────────────────────
//
// 修法=daemon 把 wikiExtractPrompt 整段帶去雲端(request `prompt` 欄位),
// 雲端只回模型原文(response `output`),解析與組卡回到本 package 與 gemma 路
// 同一段程式碼。⇒ 「兩條路的卡同形」不再是兩份 prompt 各自維持的巧合,
// 是同一份程式碼的必然。以下兩則就是這句話的機械守衛;
// 檔案上方兩則既有測試(stub 只回 `card`)則守住「舊雲端 fallback 不斷炊」。
// request 必帶 prompt,且必須就是 wikiExtractPrompt 本人——不是另一份手抄。
func TestExtractWithWorkersAI_SendsSharedPrompt(t *testing.T) {
root := t.TempDir()
const srcName = "報銷規則.md"
const srcBody = "# 報銷規則\n\n內文"
if err := os.WriteFile(filepath.Join(root, srcName), []byte(srcBody), 0o644); err != nil {
t.Fatal(err)
}
var gotPrompt string
url, closeFn := workersAIStub(t, func(w http.ResponseWriter, r *http.Request) {
var req map[string]string
_ = json.NewDecoder(r.Body).Decode(&req)
gotPrompt = req["prompt"]
_ = json.NewEncoder(w).Encode(map[string]any{
"success": true,
"output": cardFixture("報銷規則", "財務"),
})
})
defer closeFn()
if _, err := ExtractWithWorkersAI(url, "key123", root, srcName); err != nil {
t.Fatal(err)
}
srcText, err := ConvertToText(srcName, []byte(srcBody))
if err != nil {
t.Fatal(err)
}
if want := wikiExtractPrompt("報銷規則", srcText); gotPrompt != want {
t.Fatalf("送上雲的 prompt 不是共用那份 wikiExtractPromptlen got=%d want=%d", len(gotPrompt), len(want))
}
}
// wikiTreeOf 收齊 root 底下所有 .wiki/ 產物(相對路徑 → 內容)。
func wikiTreeOf(t *testing.T, root string) map[string]string {
t.Helper()
out := map[string]string{}
err := filepath.Walk(root, func(p string, info os.FileInfo, err error) error {
if err != nil || info.IsDir() {
return err
}
rel, _ := filepath.Rel(root, p)
if !strings.Contains(filepath.ToSlash(rel), ".wiki/") {
return nil
}
b, rerr := os.ReadFile(p)
if rerr != nil {
return rerr
}
out[filepath.ToSlash(rel)] = string(b)
return nil
})
if err != nil {
t.Fatal(err)
}
return out
}
// 🔴 本票的驗收本體:同一份原稿+同一份模型判斷,走 gemma 路與 workers-ai 路,
// `.wiki/` 產物(卡、00-INDEX、manifest)必須**逐位元組相同**。
func TestExtractWorkersAIAndGemmaProduceIdenticalWiki(t *testing.T) {
const srcName = "報銷規則.md"
const srcBody = "# 報銷規則\n\n機密內容 XYZZY"
fixture := cardFixture("報銷規則", "財務")
// gemma 路
rootG := t.TempDir()
if err := os.WriteFile(filepath.Join(rootG, srcName), []byte(srcBody), 0o644); err != nil {
t.Fatal(err)
}
undo := gemmaCardStub(t, fixture)
cardsG, errG := ExtractWithGemma("k-test", "", rootG, srcName)
undo()
if errG != nil {
t.Fatal(errG)
}
// workers-ai 路
rootW := t.TempDir()
if err := os.WriteFile(filepath.Join(rootW, srcName), []byte(srcBody), 0o644); err != nil {
t.Fatal(err)
}
url, closeFn := workersAIStub(t, func(w http.ResponseWriter, r *http.Request) {
_ = json.NewEncoder(w).Encode(map[string]any{"success": true, "output": fixture})
})
cardsW, errW := ExtractWithWorkersAI(url, "key123", rootW, srcName)
closeFn()
if errW != nil {
t.Fatal(errW)
}
if strings.Join(cardsG, "|") != strings.Join(cardsW, "|") {
t.Fatalf("兩條路回報的卡片清單不同:gemma=%v workers-ai=%v", cardsG, cardsW)
}
treeG, treeW := wikiTreeOf(t, rootG), wikiTreeOf(t, rootW)
if len(treeG) == 0 {
t.Fatal("gemma 路沒有產出任何 .wiki 檔案(測試前提壞了)")
}
for rel, want := range treeG {
got, ok := treeW[rel]
if !ok {
t.Errorf("workers-ai 路缺檔:%s", rel)
continue
}
if got != want {
t.Errorf("兩條路的 %s 內容不同(前 200 字):\n─ gemma ─\n%.200s\n─ workers-ai ─\n%.200s", rel, want, got)
}
}
for rel := range treeW {
if _, ok := treeG[rel]; !ok {
t.Errorf("workers-ai 路多出 gemma 路沒有的檔:%s", rel)
}
}
}
+1 -1
View File
@@ -36,7 +36,7 @@ func TestT210ProgressWiring(t *testing.T) {
// Gemini 替身:pageName 是 "stuck" 或 "pending" 的一律萃取失敗(模擬「本地萃取失敗」
// 這一種無法同步的成因),其餘("ok")成功萃出一張最簡卡片。用 prompt 裡「# <pageName>」
// 那行分辨是哪個檔(gemmaPrompt 的契約:第一行必須是「# <pageName>」)。
// 那行分辨是哪個檔(wikiExtractPrompt 的契約:第一行必須是「# <pageName>」)。
restoreGemma := gemmaStub(t, func(w http.ResponseWriter, r *http.Request) {
body, _ := io.ReadAll(r.Body)
switch {
+1 -1
View File
@@ -52,7 +52,7 @@ import (
// 為什麼不能沿用 gemmaCardStub(固定回同一份):這一支測試的 fixture 是「真的有
// 既有頁面的 vault」,一輪會萃好幾份(journals、pages、丟進去的原稿)。固定回同一份
// 會讓多份文件的概念卡同名互撞(wikishape 的佔用保護會擋下)⇒ 測試綠不了,
// 而那是替身的問題不是產品的問題。頁名從 gemmaPrompt 的「檔名:<頁名>)」裡取回來。
// 而那是替身的問題不是產品的問題。頁名從 wikiExtractPrompt 的「檔名:<頁名>)」裡取回來。
func gemmaEchoStub(t *testing.T) func() {
t.Helper()
return gemmaStub(t, func(w http.ResponseWriter, r *http.Request) {