Files

181 lines
7.9 KiB
Go
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// 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 partagent-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 跑。契約(提示詞+parseWikiExtractJSONBuildWikiDoc)同住本 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, origin SourceOrigin) ([]string, error) {
if apiKey == "" {
return nil, fmt.Errorf("gemma 萃取路需要 gemini_api_keyconfig")
}
if model == "" {
model = defaultLLMModel
}
raw, err := os.ReadFile(filepath.Join(absRoot, filepath.FromSlash(relPath)))
if err != nil {
return nil, fmt.Errorf("讀原稿失敗:%w", err)
}
// 本地轉檔層(t73/t162026-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, origin, time.Now())
if berr != nil {
return nil, berr
}
// 沒有可萃概念=合法結果:不產卡,但 00-INDEX 已列「空」(差距 #10)。
return cards, nil
}