diff --git a/direct_extract_test.go b/direct_extract_test.go index 7b4af02..5a338e9 100644 --- a/direct_extract_test.go +++ b/direct_extract_test.go @@ -12,7 +12,7 @@ import ( "testing" ) -// cardFixture 組出一份合格的萃取 JSON(InkStoneCo#44 ④:gemmaPrompt 的契約改為 +// cardFixture 組出一份合格的萃取 JSON(InkStoneCo#44 ④:wikiExtractPrompt 的契約改為 // 「文件總覽+N 個原子概念」的 JSON,卡片格式由 wikishape.go 機械組裝)。 // 概念名固定為「·概念」——避免與各測試原稿的 H1/頁名撞名。 func cardFixture(subject, object string) string { diff --git a/extract_gemma.go b/extract_gemma.go index 15825d8..8eceb0e 100644 --- a/extract_gemma.go +++ b/extract_gemma.go @@ -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 跑。契約(提示詞+parseWikiExtractJSON+BuildWikiDoc)同住本 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}, }) diff --git a/extract_workersai.go b/extract_workersai.go index cdfaeba..6f02de2 100644 --- a/extract_workersai.go +++ b/extract_workersai.go @@ -22,6 +22,15 @@ // 讀原檔 → ConvertToText(本機轉檔)→ 送 API → 淨化 → 落卡 // ↑ 只有這一步不同 // +// 🔴 Arcrun#134(2026-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) + // #134:prompt=與 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) diff --git a/extract_workersai_test.go b/extract_workersai_test.go index e4d4043..a4a7760 100644 --- a/extract_workersai_test.go +++ b/extract_workersai_test.go @@ -102,3 +102,125 @@ func TestExtractWithWorkersAI_NonVaultUnchanged(t *testing.T) { t.Fatalf("非 vault 卡片路徑不對:%v,want [system-dev/wiki/cards/arcrun-note.md]", cards) } } + +// ── Arcrun#134:workers-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 不是共用那份 wikiExtractPrompt(len 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) + } + } +} diff --git a/progress_wiring_test.go b/progress_wiring_test.go index 378e4ce..b3ed849 100644 --- a/progress_wiring_test.go +++ b/progress_wiring_test.go @@ -36,7 +36,7 @@ func TestT210ProgressWiring(t *testing.T) { // Gemini 替身:pageName 是 "stuck" 或 "pending" 的一律萃取失敗(模擬「本地萃取失敗」 // 這一種無法同步的成因),其餘("ok")成功萃出一張最簡卡片。用 prompt 裡「# 」 - // 那行分辨是哪個檔(gemmaPrompt 的契約:第一行必須是「# 」)。 + // 那行分辨是哪個檔(wikiExtractPrompt 的契約:第一行必須是「# 」)。 restoreGemma := gemmaStub(t, func(w http.ResponseWriter, r *http.Request) { body, _ := io.ReadAll(r.Body) switch { diff --git a/vault_footprint_test.go b/vault_footprint_test.go index ea3ab67..ff2d0c9 100644 --- a/vault_footprint_test.go +++ b/vault_footprint_test.go @@ -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) {