12c2d41886
LLM 只回 JSON 判斷,格式/落點/連結閉合/索引/manifest 全由 wikishape.go 機械組裝: - 卡形=frontmatter(tags/gloss/created/updated)+← 上層+摘要/重點/實體(帶類型)/ 關聯(內文知識關係/卡片關係/出處)——差距表 #6 - 落點 <節點>/.wiki/、檔名=H1(.wiki 隱藏目錄自身即機器標記,machinemark 例外③)——#7 - 00-INDEX 機械維護:每行五樣照抄 frontmatter;每份原稿必列——#8 - 萃取端就切 1 hub+N 原子卡(gemma 路 prompt 改 JSON 契約)——#9 - 無可萃概念標「空」+理由,上索引不產卡——#10 機械保證對著 wiki-lint.py 寫:斷連結拆殼、index 式句改寫、三元組恰三項、 雙向邊自動補反向、佔用不覆蓋、原稿永不動;同名概念跨文件先消歧(merge 歸第⑤環)。 scan/convert 白名單補 .feature/.yaml/.yml/.org/.rst(規範洞 6); lint.go 新舊格式雙軌(workers-ai 雲端 prompt 屬 matrix/arcrun 核心,待開票); direct.go 只送文件卡上雲(takedown 配對鍵不變)、下架收走 .wiki 產物。 實測:真資料夾+真 Gemini → 2 節點 12 卡,wiki-lint 17/17 exit 0, 原稿 sha256 前後不變;go test collector+supervisor+arcrun-app 全綠(新增 9 案)。 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
176 lines
7.4 KiB
Go
176 lines
7.4 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}
|
||
|
||
// gemmaPrompt 組出「文件卡+N 張原子概念卡」的結構化萃取指令(InkStoneCo#44 ④)。
|
||
//
|
||
// 🔴 分工:模型只回 JSON(判斷),格式與落點全由 wikishape.go 機械組裝——
|
||
// 模型不寫 markdown、不決定檔名、不碰路徑。Luhmann ②(一卡一概念)在**萃取端**做,
|
||
// 否則 place_card 只會一直回 orphan(規範待裁 6 的裁定理由)。
|
||
func gemmaPrompt(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": gemmaPrompt(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
|
||
}
|