6c74b97718
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>
181 lines
7.8 KiB
Go
181 lines
7.8 KiB
Go
// extract_gemma.go — gemma 本地萃取路(daemon-beta task 4)。
|
||
//
|
||
// prompt 與淨化規則抄自實戰版 rag_extract_one workflow(同一套格式:一句話定義/要點/
|
||
// 關鍵實體/關聯),差別只在跑的位置:workflow 在雲端實例跑、這裡在用戶機器上由 daemon 直呼。
|
||
// 原文內容過境 Gemini API 一次,不落任何雲端儲存(四步定稿邊界)。
|
||
//
|
||
// gemma-4-31b 是思考型模型:parts[0] 常是 thought=true 的思考草稿,真答案在最後一個
|
||
// 非 thought 的 text part(agent-memory §7.6 實撞教訓,讀法照抄)。
|
||
package collector
|
||
|
||
import (
|
||
"bytes"
|
||
"encoding/json"
|
||
"fmt"
|
||
"io"
|
||
"net/http"
|
||
"os"
|
||
"path/filepath"
|
||
"strings"
|
||
"time"
|
||
)
|
||
|
||
// gemmaBaseURL 可注入(測試用 httptest 替身)。
|
||
var gemmaBaseURL = "https://generativelanguage.googleapis.com"
|
||
|
||
const defaultLLMModel = "gemma-4-31b-it"
|
||
|
||
var gemmaHTTP = &http.Client{Timeout: 120 * time.Second}
|
||
|
||
// 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 wikiExtractPrompt(pageName, content string) string {
|
||
return `你是知識整理員。讀完原稿後,把它整理成「一份文件的總覽+N 個原子概念」。只輸出一個 JSON 物件,不要任何說明、markdown 圍欄或思考過程。
|
||
|
||
規則(違反任何一條都算失敗):
|
||
- 卡片內容是你的**判斷與重組**(正體中文),禁止整句照抄原稿。
|
||
- 概念數由內容決定(多數文件 1-5 個);每個概念要能**離開原稿獨立成立**。
|
||
- 報價單、發票、純待辦、純流水帳=沒有可萃取概念:回 {"no_concept":true,"reason":"一句話理由"} 即可。
|
||
- gloss=一句話(40 字內)。summary=一小段(80-200 字)。points=3-8 條判斷句(不是條列複述)。
|
||
- 文件層的 points 每條要把相關概念名用 [[概念名]] 嵌在**句子中間**(不可放句首當標題)。
|
||
- entities:每個實體帶 type(人物/組織/工具/概念/地點/事件/檔案 擇一)與一句描述。
|
||
- facts=[主詞,述詞,受詞] 三元組,端點盡量用 entities 的名字;任何欄位不得含雙箭頭符號。
|
||
- relations=概念之間的關係(to 填另一個概念的 name)。
|
||
|
||
JSON 形狀(照這個結構填):
|
||
{"gloss":"","tags":[""],"summary":"","points":["…句子中間嵌 [[概念名]]…"],
|
||
"no_concept":false,"reason":"",
|
||
"concepts":[{"name":"","gloss":"","tags":[""],"summary":"","points":[""],
|
||
"entities":[{"name":"","type":"","desc":""}],
|
||
"facts":[["","",""]],
|
||
"relations":[{"to":"","pred":""}]}]}
|
||
|
||
原稿(檔名:` + pageName + `):
|
||
` + content
|
||
}
|
||
|
||
// parseWikiExtractJSON 從模型輸出撈出 JSON 並解析成 DocExtract。
|
||
// thinking 模型可能在 JSON 前後夾雜文字/圍欄:取第一個 '{' 到最後一個 '}'。
|
||
func parseWikiExtractJSON(text string) (*DocExtract, error) {
|
||
start := strings.Index(text, "{")
|
||
end := strings.LastIndex(text, "}")
|
||
if start < 0 || end <= start {
|
||
return nil, fmt.Errorf("模型輸出裡找不到 JSON 物件:%.120s", text)
|
||
}
|
||
var ex DocExtract
|
||
if err := json.Unmarshal([]byte(text[start:end+1]), &ex); err != nil {
|
||
return nil, fmt.Errorf("萃取 JSON 解析失敗:%w(%.120s)", err, text[start:end+1])
|
||
}
|
||
return &ex, nil
|
||
}
|
||
|
||
// cleanGemmaCard 淨化思考型模型輸出:取最後一個「# <pageName>」起的內容(前面全是草稿)。
|
||
func cleanGemmaCard(text, pageName string) string {
|
||
marker := "# " + pageName
|
||
if i := strings.LastIndex(text, marker); i >= 0 {
|
||
return strings.TrimSpace(text[i:]) + "\n"
|
||
}
|
||
return strings.TrimSpace(text) + "\n"
|
||
}
|
||
|
||
// ExtractWithGemma 讀原稿 → 呼 Gemini 萃卡 → 卡片落地 system-dev/wiki/cards/。
|
||
// 回傳產出的卡片相對路徑(單檔一卡)。
|
||
func ExtractWithGemma(apiKey, model, absRoot, relPath string) ([]string, error) {
|
||
if apiKey == "" {
|
||
return nil, fmt.Errorf("gemma 萃取路需要 gemini_api_key(config)")
|
||
}
|
||
if model == "" {
|
||
model = defaultLLMModel
|
||
}
|
||
raw, err := os.ReadFile(filepath.Join(absRoot, filepath.FromSlash(relPath)))
|
||
if err != nil {
|
||
return nil, fmt.Errorf("讀原稿失敗:%w", err)
|
||
}
|
||
|
||
// 本地轉檔層(t73/t16,2026-07-27):任何格式在這裡變成「模型可讀的純文字」。
|
||
// 純文字檔原樣通過;.docx 等走對應抽取器。**LLM 只會收到文字,永遠不會收到二進位**
|
||
//(那正是這一層存在的理由——見 convert.go 檔頭與 pdf-extraction-options.md 洞 B)。
|
||
srcText, err := ConvertToText(relPath, raw)
|
||
if err != nil {
|
||
// 這裡刻意**不吞錯**:靜默略過正是 leo 撞到的病(丟檔進去沒反應)。
|
||
// ErrNoText=掃描件/空檔,ErrUnsupported=還沒支援的格式,兩者訊息不同但都要說出來。
|
||
return nil, fmt.Errorf("轉檔失敗(%s):%w", relPath, err)
|
||
}
|
||
|
||
pageName := pageNameOf(relPath)
|
||
|
||
reqBody, _ := json.Marshal(map[string]any{
|
||
"contents": []map[string]any{
|
||
{"parts": []map[string]any{{"text": wikiExtractPrompt(pageName, srcText)}}},
|
||
},
|
||
"generationConfig": map[string]any{"temperature": 0.2, "maxOutputTokens": 8192},
|
||
})
|
||
url := fmt.Sprintf("%s/v1beta/models/%s:generateContent", gemmaBaseURL, model)
|
||
req, err := http.NewRequest(http.MethodPost, url, bytes.NewReader(reqBody))
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
req.Header.Set("Content-Type", "application/json")
|
||
req.Header.Set("x-goog-api-key", apiKey)
|
||
resp, err := gemmaHTTP.Do(req)
|
||
if err != nil {
|
||
return nil, fmt.Errorf("Gemini API 連線失敗:%w", err)
|
||
}
|
||
defer resp.Body.Close()
|
||
body, _ := io.ReadAll(io.LimitReader(resp.Body, 4<<20))
|
||
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
|
||
return nil, fmt.Errorf("Gemini API HTTP %d:%.300s", resp.StatusCode, string(body))
|
||
}
|
||
|
||
// 解析:candidates[0].content.parts → 最後一個非 thought 的 text part
|
||
var parsed struct {
|
||
Candidates []struct {
|
||
Content struct {
|
||
Parts []struct {
|
||
Thought bool `json:"thought"`
|
||
Text string `json:"text"`
|
||
} `json:"parts"`
|
||
} `json:"content"`
|
||
} `json:"candidates"`
|
||
}
|
||
if err := json.Unmarshal(body, &parsed); err != nil {
|
||
return nil, fmt.Errorf("Gemini 回應解析失敗:%w", err)
|
||
}
|
||
var text string
|
||
if len(parsed.Candidates) > 0 {
|
||
parts := parsed.Candidates[0].Content.Parts
|
||
for i := len(parts) - 1; i >= 0; i-- {
|
||
if !parts[i].Thought && parts[i].Text != "" {
|
||
text = parts[i].Text
|
||
break
|
||
}
|
||
}
|
||
}
|
||
if text == "" {
|
||
return nil, fmt.Errorf("Gemini 回應沒有可用文字(thought-only 或空回應)")
|
||
}
|
||
|
||
// InkStoneCo#44 ④(2026-08-15):產出改走塑形層——模型回 JSON(判斷),
|
||
// wikishape.go 機械組裝出規範形的 `.wiki/` 卡(文件卡+原子概念卡+索引+manifest)。
|
||
// 舊的「單檔一卡落 cards/」由此淘汰(差距表 #6–#10 的現狀);
|
||
// #60 的兩條保護換了形式仍在:落點是隱藏目錄(不進筆記軟體)、既有檔一律不覆蓋。
|
||
ex, perr := parseWikiExtractJSON(text)
|
||
if perr != nil {
|
||
return nil, perr
|
||
}
|
||
cards, berr := BuildWikiDoc(absRoot, relPath, srcText, ex, time.Now())
|
||
if berr != nil {
|
||
return nil, berr
|
||
}
|
||
// 沒有可萃概念=合法結果:不產卡,但 00-INDEX 已列「空」(差距 #10)。
|
||
return cards, nil
|
||
}
|