Files
arcrun-collector/direct.go
T
雲端總管 ad34a87210 feat(daemon): direct 直送萃取同步模式(無 R2/Gitea,youlin live e2e 綠)
SDD ingest-hash-trigger task 11:daemon 跨平台核心 + 產品承諾核心實證
(丟檔進資料夾 → AI 查得到)。

- collector 新增 direct 子命令(direct.go):監看資料夾 → hash 差異偵測(沿用 Scan)
  → 讀檔內容 inline POST 進實例 rag_ingest_direct workflow;刪檔 → POST {page_name,path}
  進 rag_takedown_direct。設定走 --config JSON(install/direct-config.sample.json)。
  常駐輪詢=純 stdlib ticker,零 CGo,darwin/arm64 + windows/amd64 交叉編譯 OK。
- workflows/rag-ingest-direct:ask_llm(rag_extract_one)+ parse_card/post_block/post_triplet
  (rag_ingest)拼成單一自足直鏈,繞開 R2 撈原稿與 Gitea 落卡。blocks/triplets 寫法與
  rag_ingest 逐欄一致 → rag_chat 檢索得到。
- workflows/rag-takedown-direct:不含 __CARDS_PREFIX__ 閘的下架(rag_ingest removed 分支的
  前綴閘會擋掉資料夾根的 direct 檔——2026-07-20 live e2e 實撞)。

dogfooding(D29 daemon 薄殼豁免)守住:daemon 只監看/讀檔/算 hash/HTTP POST,
萃取/切塊/RAG 全在 Arcrun workflow。

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Xabu4T28MmQEiYDwT2KnKW
2026-07-20 11:15:21 +00:00

291 lines
10 KiB
Go
Raw 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.
// direct.go — daemon「直送萃取、無 R2」同步模式(SDD ingest-hash-trigger task 11)。
//
// 既有 sync 走 collector → R2 → rag_ingest(需 R2 bucket=綁卡)。direct 模式繞開 R2/Gitea
// 監看資料夾 → 偵測新增/改動檔(沿用 Scan 的 hash 差異偵測)→ 讀檔內容 inline POST 進實例的
// rag_ingest_direct workflowLLM 萃卡 → 機械切塊 → 寫 kbdb,全在 Arcrun workflow 裡完成)。
// 刪檔 → 把 removed 事件(collector-trigger.v1POST 進實例的 rag_ingest workflow removed 分支
// (只按 page_name 讀 kbdb blocks 並標 deprecated,不碰 R2)。
//
// dogfoodingD29 daemon 薄殼豁免):本檔只做「監看/讀檔/算 hash/HTTP POST」——原生 Go。
// 萃取/切塊/RAG 一律在實例 workflowdaemon 內零 LLM/切塊邏輯。
//
// 用法:
//
// collector direct --config <config.json> [--once] [--dry-run]
//
// --once:掃一輪就退出(測試/cron 用);預設常駐輪詢(poll_interval_sec)。
// --dry-run:只列出會送出的動作,不 POST、不寫 manifest。
//
// 跨平台:純 stdlib、輪詢式偵測(不依賴 fsnotify)=零 CGodarwin/arm64、windows/amd64 直接交叉編譯。
package main
import (
"bytes"
"encoding/json"
"fmt"
"io"
"net/http"
"os"
"path/filepath"
"strings"
"time"
)
// DirectConfig 是 direct 模式的設定檔(JSON)。設定只走檔案/環境,不落 code。
type DirectConfig struct {
WatchFolder string `json:"watch_folder"` // 監看的知識資料夾(必填)
Manifest string `json:"manifest"` // manifest JSON 路徑(必填;不存在會建新)
CypherURL string `json:"cypher_url"` // 實例 cypher base(必填),如 https://arcrun-cypher-executor.<acct>.workers.dev
Namespace string `json:"namespace"` // 租戶 namespace(必填),如 demo
APIKey string `json:"api_key"` // X-Arcrun-API-Key(空=沿用 namespacedemo 慣例)
Library string `json:"library"` // 藏書地圖歸庫鍵(空=kb
IngestWF string `json:"ingest_workflow"` // 直送萃取 workflow 名(空=rag_ingest_direct
RemovedWF string `json:"removed_workflow"` // 下架 workflow 名(空=rag_takedown_direct;吃 {page_name,path}
PollSec int `json:"poll_interval_sec"` // 輪詢間隔秒(空/05
MaxRemoved float64 `json:"max_removed_ratio"` // 大量刪除防呆門檻(空/0=0.4)
}
// LoadDirectConfig 讀設定檔並補預設值 + 基本驗證。
func LoadDirectConfig(path string) (*DirectConfig, error) {
data, err := os.ReadFile(path)
if err != nil {
return nil, fmt.Errorf("讀 config 失敗:%w", err)
}
var c DirectConfig
if err := json.Unmarshal(data, &c); err != nil {
return nil, fmt.Errorf("config JSON 解析失敗:%w", err)
}
var missing []string
if c.WatchFolder == "" {
missing = append(missing, "watch_folder")
}
if c.Manifest == "" {
missing = append(missing, "manifest")
}
if c.CypherURL == "" {
missing = append(missing, "cypher_url")
}
if c.Namespace == "" {
missing = append(missing, "namespace")
}
if len(missing) > 0 {
return nil, fmt.Errorf("config 缺必填欄位:%s", strings.Join(missing, ", "))
}
if c.APIKey == "" {
c.APIKey = c.Namespace
}
if c.Library == "" {
c.Library = "kb"
}
if c.IngestWF == "" {
c.IngestWF = "rag_ingest_direct"
}
if c.RemovedWF == "" {
c.RemovedWF = "rag_takedown_direct"
}
if c.PollSec <= 0 {
c.PollSec = 5
}
if c.MaxRemoved <= 0 {
c.MaxRemoved = DefaultMaxRemovedRatio
}
c.CypherURL = strings.TrimSuffix(c.CypherURL, "/")
return &c, nil
}
// directHTTP 是 direct 模式共用的 HTTP client(萃取 workflow 可能同步跑 LLM,放寬 timeout)。
var directHTTP = &http.Client{Timeout: 300 * time.Second}
// triggerURL 組出 named-webhook 觸發完整 URL。
func (c *DirectConfig) triggerURL(workflow string) string {
return fmt.Sprintf("%s/webhooks/named/%s/%s/trigger", c.CypherURL, c.Namespace, workflow)
}
// postJSON POST 一個 JSON body 到 url,回傳 HTTP 狀態碼與回應片段。非 2xx 視為錯誤。
func (c *DirectConfig) postJSON(url string, body any) (int, string, error) {
data, err := json.Marshal(body)
if err != nil {
return 0, "", err
}
req, err := http.NewRequest(http.MethodPost, url, bytes.NewReader(data))
if err != nil {
return 0, "", err
}
req.Header.Set("Content-Type", "application/json")
req.Header.Set("X-Arcrun-API-Key", c.APIKey)
resp, err := directHTTP.Do(req)
if err != nil {
return 0, "", err
}
defer resp.Body.Close()
snippet, _ := io.ReadAll(io.LimitReader(resp.Body, 1024))
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
return resp.StatusCode, string(snippet), fmt.Errorf("HTTP %d%s", resp.StatusCode, strings.TrimSpace(string(snippet)))
}
return resp.StatusCode, string(snippet), nil
}
// pageNameOf 從相對路徑導出頁名(basename 去副檔名),與 rag_ingest collect_changed 的 pageOf 同語意。
func pageNameOf(relPath string) string {
base := relPath
if i := strings.LastIndex(base, "/"); i >= 0 {
base = base[i+1:]
}
return strings.TrimSuffix(base, filepath.Ext(base))
}
// DirectResult 是單一事件的直送結果(隨每輪 log 輸出)。
type DirectResult struct {
Type string `json:"type"`
Path string `json:"path"`
Status string `json:"status"` // ingested | removed | planned | failed | skipped
HTTPStatus int `json:"http_status,omitempty"`
Error string `json:"error,omitempty"`
}
// RunDirectOnce 掃一輪、直送 added/modified/renamed、下架 removed,並在 2xx 後回寫 manifest。
// 回傳本輪結果清單與退出碼建議(有失敗=1)。
func RunDirectOnce(cfg *DirectConfig, dryRun bool) ([]DirectResult, int, *TriggerPayload) {
results := []DirectResult{}
exit := 0
absRoot, err := filepath.Abs(cfg.WatchFolder)
if err != nil {
return append(results, DirectResult{Status: "failed", Error: err.Error()}), 1, nil
}
absManifest, err := filepath.Abs(cfg.Manifest)
if err != nil {
return append(results, DirectResult{Status: "failed", Error: err.Error()}), 1, nil
}
m, err := LoadManifest(absManifest, absRoot)
if err != nil {
return append(results, DirectResult{Status: "failed", Error: err.Error()}), 1, nil
}
payload, err := Scan(absRoot, m, ScanOptions{
MaxRemovedRatio: cfg.MaxRemoved,
SkipPaths: map[string]bool{absManifest: true},
})
if err != nil {
return append(results, DirectResult{Status: "failed", Error: err.Error()}), 1, nil
}
now := time.Now().Unix()
for _, ev := range payload.Events {
switch ev.Type {
case "added", "modified", "renamed":
// renamed 在 direct 模式視同 added:內容未變但為求 kbdb 有這頁名的卡,重送一次萃取
//(頁名可能改變=要新頁名的卡)。冪等由 kbdb 端承擔(同頁名覆蓋語意)。
res := DirectResult{Type: ev.Type, Path: ev.Path}
full := filepath.Join(absRoot, filepath.FromSlash(ev.Path))
content, rerr := os.ReadFile(full)
if rerr != nil {
res.Status, res.Error = "failed", "讀檔失敗:"+rerr.Error()
results = append(results, res)
exit = 1
continue
}
if dryRun {
res.Status = "planned"
results = append(results, res)
continue
}
status, _, perr := cfg.postJSON(cfg.triggerURL(cfg.IngestWF), map[string]any{
"page_name": pageNameOf(ev.Path),
"path": ev.Path,
"content": string(content),
"library": cfg.Library,
})
res.HTTPStatus = status
if perr != nil {
res.Status, res.Error = "failed", perr.Error()
exit = 1
} else {
res.Status = "ingested"
m.MarkIngested(ev.Path, ev.SourceHash, now) // 2xx 才回寫(下輪不重送)
}
results = append(results, res)
case "removed":
res := DirectResult{Type: ev.Type, Path: ev.Path}
if dryRun {
res.Status = "planned"
results = append(results, res)
continue
}
// 下架=POST {page_name, path} 進 rag_takedown_direct(按 page_name 讀 kbdb blocks
// 標 deprecated,不碰 R2;獨立於 rag_ingest 的 __CARDS_PREFIX__ 閘——direct 模式檔在
// 資料夾根,會被 rag_ingest 的前綴閘擋掉,故自帶不含前綴閘的下架 workflow)。
status, _, perr := cfg.postJSON(cfg.triggerURL(cfg.RemovedWF), map[string]any{
"page_name": pageNameOf(ev.Path),
"path": ev.Path,
})
res.HTTPStatus = status
if perr != nil {
res.Status, res.Error = "failed", perr.Error()
exit = 1
} else {
res.Status = "removed"
}
results = append(results, res)
}
}
// 防呆警告輪:Scan 已壓下 removed 事件,這裡只回報警告不下架。
for _, w := range payload.Warnings {
results = append(results, DirectResult{Type: "warning", Status: "skipped", Error: w.Code + ": " + w.Message})
}
if !dryRun {
if err := m.Save(absManifest); err != nil {
results = append(results, DirectResult{Status: "failed", Error: "manifest 存檔失敗:" + err.Error()})
exit = 1
}
}
return results, exit, payload
}
// runDirect 是 `collector direct` 子命令主體。
func runDirect(args []string) int {
fs := newFlagSet()
configPath := fs.String("config", "", "direct 模式設定檔(JSON)路徑(必填)")
once := fs.Bool("once", false, "掃一輪即退出(測試/cron;預設常駐輪詢)")
dryRun := fs.Bool("dry-run", false, "只列出會送出的動作,不 POST、不寫 manifest")
if err := fs.Parse(args); err != nil {
return 2
}
if *configPath == "" {
fmt.Fprintln(os.Stderr, "錯誤:--config 為必填")
return 2
}
cfg, err := LoadDirectConfig(*configPath)
if err != nil {
fmt.Fprintln(os.Stderr, "collector direct:", err)
return 2
}
runOne := func() int {
results, exit, _ := RunDirectOnce(cfg, *dryRun)
out, _ := json.MarshalIndent(struct {
At string `json:"at"`
Folder string `json:"folder"`
Results []DirectResult `json:"results"`
}{time.Now().Format(time.RFC3339), cfg.WatchFolder, results}, "", " ")
fmt.Println(string(out))
return exit
}
if *once {
return runOne()
}
// 常駐輪詢:純 stdlib ticker,跨平台。首輪立即跑。
fmt.Fprintf(os.Stderr, "collector direct daemon 啟動:監看 %s → %s(每 %ds 掃一輪)\n",
cfg.WatchFolder, cfg.triggerURL(cfg.IngestWF), cfg.PollSec)
runOne()
ticker := time.NewTicker(time.Duration(cfg.PollSec) * time.Second)
defer ticker.Stop()
for range ticker.C {
runOne()
}
return 0
}