feat(daemon-beta t1): 多資料夾 config——watch_folders[]+單數相容、manifest 每根一份、多根掃描標 Root、托盤資料層相容;rag-wave1 closed→daemon-beta 實作卷 active
- direct.go: Folders() 正規化(去重保序)/manifestPathFor(單根沿用不丟狀態、多根 sha 尾碼)/RunDirectOnce 多根彙總 - 新測試 4 支全綠(單數相容/多根去重/缺欄驗證/多根 dry-run 標 Root);既有 7 支不動全綠 - tray: config 加 watch_folders 欄(防存檔洗掉)+addWatchFolder 資料層;勾選 UI=task 7
This commit is contained in:
+35
-13
@@ -16,9 +16,9 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
neturl "net/url"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
neturl "net/url"
|
||||
"os"
|
||||
"path/filepath"
|
||||
|
||||
@@ -36,16 +36,17 @@ import (
|
||||
// directConfig 是 collector direct 的設定(與 collector/direct.go 的 DirectConfig 同結構;
|
||||
// 這裡只需讀寫 watch_folder,其餘由安裝器帶入)。
|
||||
type directConfig struct {
|
||||
WatchFolder string `json:"watch_folder"`
|
||||
Manifest string `json:"manifest"`
|
||||
CypherURL string `json:"cypher_url"`
|
||||
Namespace string `json:"namespace"`
|
||||
APIKey string `json:"api_key,omitempty"`
|
||||
Library string `json:"library,omitempty"`
|
||||
IngestWF string `json:"ingest_workflow,omitempty"`
|
||||
RemovedWF string `json:"removed_workflow,omitempty"`
|
||||
PollSec int `json:"poll_interval_sec,omitempty"`
|
||||
MaxRemoved float64 `json:"max_removed_ratio,omitempty"`
|
||||
WatchFolder string `json:"watch_folder,omitempty"` // 單數舊制(第一個資料夾的鏡像,維持相容)
|
||||
WatchFolders []string `json:"watch_folders,omitempty"` // 多資料夾(daemon-beta task 1;完整勾選 UI=task 7)
|
||||
Manifest string `json:"manifest"`
|
||||
CypherURL string `json:"cypher_url"`
|
||||
Namespace string `json:"namespace"`
|
||||
APIKey string `json:"api_key,omitempty"`
|
||||
Library string `json:"library,omitempty"`
|
||||
IngestWF string `json:"ingest_workflow,omitempty"`
|
||||
RemovedWF string `json:"removed_workflow,omitempty"`
|
||||
PollSec int `json:"poll_interval_sec,omitempty"`
|
||||
MaxRemoved float64 `json:"max_removed_ratio,omitempty"`
|
||||
}
|
||||
|
||||
// appDir 是設定與 manifest 落地處:~/.arcrun-rag/
|
||||
@@ -69,15 +70,36 @@ func loadConfig() *directConfig {
|
||||
if data, err := os.ReadFile(configPath()); err == nil {
|
||||
_ = json.Unmarshal(data, c)
|
||||
}
|
||||
if c.WatchFolder == "" {
|
||||
if c.WatchFolder == "" && len(c.WatchFolders) == 0 {
|
||||
c.WatchFolder = defaultWatchFolder()
|
||||
}
|
||||
if c.WatchFolder == "" && len(c.WatchFolders) > 0 {
|
||||
c.WatchFolder = c.WatchFolders[0] // 單數欄位=第一根鏡像(舊 collector 相容)
|
||||
}
|
||||
if c.Manifest == "" {
|
||||
c.Manifest = filepath.Join(appDir(), "manifest.json")
|
||||
}
|
||||
return c
|
||||
}
|
||||
|
||||
// addWatchFolder 把資料夾加進監看清單(去重、保序),並維持單數欄位=第一根的鏡像。
|
||||
// 完整的「勾選/移除」UI 是 task 7;本函式先保證資料層正確。
|
||||
func addWatchFolder(c *directConfig, p string) {
|
||||
if p == "" {
|
||||
return
|
||||
}
|
||||
if len(c.WatchFolders) == 0 && c.WatchFolder != "" && c.WatchFolder != defaultWatchFolder() {
|
||||
c.WatchFolders = []string{c.WatchFolder}
|
||||
}
|
||||
for _, f := range c.WatchFolders {
|
||||
if f == p {
|
||||
return
|
||||
}
|
||||
}
|
||||
c.WatchFolders = append(c.WatchFolders, p)
|
||||
c.WatchFolder = c.WatchFolders[0]
|
||||
}
|
||||
|
||||
func saveConfig(c *directConfig) error {
|
||||
if err := os.MkdirAll(appDir(), 0o755); err != nil {
|
||||
return err
|
||||
@@ -123,7 +145,7 @@ func main() {
|
||||
if err != nil || uri == nil {
|
||||
return
|
||||
}
|
||||
cfg.WatchFolder = uri.Path()
|
||||
addWatchFolder(cfg, uri.Path())
|
||||
if err := saveConfig(cfg); err != nil {
|
||||
dialog.ShowError(err, win)
|
||||
return
|
||||
|
||||
@@ -21,6 +21,8 @@ package main
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
@@ -33,16 +35,17 @@ import (
|
||||
|
||||
// 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(空=沿用 namespace,demo 慣例)
|
||||
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"` // 輪詢間隔秒(空/0=5)
|
||||
MaxRemoved float64 `json:"max_removed_ratio"` // 大量刪除防呆門檻(空/0=0.4)
|
||||
WatchFolder string `json:"watch_folder,omitempty"` // 監看的知識資料夾(單數舊制;與 watch_folders 至少填一)
|
||||
WatchFolders []string `json:"watch_folders,omitempty"` // 監看的知識資料夾清單(daemon-beta task 1 多資料夾)
|
||||
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(空=沿用 namespace,demo 慣例)
|
||||
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"` // 輪詢間隔秒(空/0=5)
|
||||
MaxRemoved float64 `json:"max_removed_ratio"` // 大量刪除防呆門檻(空/0=0.4)
|
||||
}
|
||||
|
||||
// LoadDirectConfig 讀設定檔並補預設值 + 基本驗證。
|
||||
@@ -56,8 +59,8 @@ func LoadDirectConfig(path string) (*DirectConfig, error) {
|
||||
return nil, fmt.Errorf("config JSON 解析失敗:%w", err)
|
||||
}
|
||||
var missing []string
|
||||
if c.WatchFolder == "" {
|
||||
missing = append(missing, "watch_folder")
|
||||
if c.WatchFolder == "" && len(c.WatchFolders) == 0 {
|
||||
missing = append(missing, "watch_folder(或 watch_folders)")
|
||||
}
|
||||
if c.Manifest == "" {
|
||||
missing = append(missing, "manifest")
|
||||
@@ -93,6 +96,36 @@ func LoadDirectConfig(path string) (*DirectConfig, error) {
|
||||
return &c, nil
|
||||
}
|
||||
|
||||
// Folders 回傳監看根清單(正規化:單數舊制併入、去重、保序)。
|
||||
func (c *DirectConfig) Folders() []string {
|
||||
seen := map[string]bool{}
|
||||
var out []string
|
||||
add := func(p string) {
|
||||
if p == "" || seen[p] {
|
||||
return
|
||||
}
|
||||
seen[p] = true
|
||||
out = append(out, p)
|
||||
}
|
||||
add(c.WatchFolder)
|
||||
for _, p := range c.WatchFolders {
|
||||
add(p)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// manifestPathFor 回傳某根的 manifest 路徑。單根=沿用 cfg.Manifest(升級不丟既有狀態);
|
||||
// 多根=每根一份,基底名加 root 絕對路徑的 sha256 前 8 碼尾碼(路徑穩定=尾碼穩定)。
|
||||
func (c *DirectConfig) manifestPathFor(absRoot string) string {
|
||||
folders := c.Folders()
|
||||
if len(folders) <= 1 {
|
||||
return c.Manifest
|
||||
}
|
||||
sum := sha256.Sum256([]byte(absRoot))
|
||||
ext := filepath.Ext(c.Manifest)
|
||||
return strings.TrimSuffix(c.Manifest, ext) + "-" + hex.EncodeToString(sum[:4]) + ext
|
||||
}
|
||||
|
||||
// directHTTP 是 direct 模式共用的 HTTP client(萃取 workflow 可能同步跑 LLM,放寬 timeout)。
|
||||
var directHTTP = &http.Client{Timeout: 300 * time.Second}
|
||||
|
||||
@@ -136,6 +169,7 @@ func pageNameOf(relPath string) string {
|
||||
|
||||
// DirectResult 是單一事件的直送結果(隨每輪 log 輸出)。
|
||||
type DirectResult struct {
|
||||
Root string `json:"root,omitempty"` // 多資料夾時標明事件屬於哪個根
|
||||
Type string `json:"type"`
|
||||
Path string `json:"path"`
|
||||
Status string `json:"status"` // ingested | removed | planned | failed | skipped
|
||||
@@ -143,17 +177,41 @@ type DirectResult struct {
|
||||
Error string `json:"error,omitempty"`
|
||||
}
|
||||
|
||||
// RunDirectOnce 掃一輪、直送 added/modified/renamed、下架 removed,並在 2xx 後回寫 manifest。
|
||||
// 回傳本輪結果清單與退出碼建議(有失敗=1)。
|
||||
// RunDirectOnce 對每個監看根掃一輪並彙總結果(daemon-beta task 1 多資料夾)。
|
||||
// 單根行為與舊制完全相同(含 manifest 路徑)。回傳彙總結果與退出碼建議(任一根失敗=1)。
|
||||
func RunDirectOnce(cfg *DirectConfig, dryRun bool) ([]DirectResult, int, *TriggerPayload) {
|
||||
results := []DirectResult{}
|
||||
exit := 0
|
||||
var lastPayload *TriggerPayload
|
||||
multi := len(cfg.Folders()) > 1
|
||||
for _, root := range cfg.Folders() {
|
||||
r, e, p := runDirectOnceRoot(cfg, root, dryRun)
|
||||
if multi {
|
||||
for i := range r {
|
||||
r[i].Root = root
|
||||
}
|
||||
}
|
||||
results = append(results, r...)
|
||||
if e != 0 {
|
||||
exit = e
|
||||
}
|
||||
if p != nil {
|
||||
lastPayload = p
|
||||
}
|
||||
}
|
||||
return results, exit, lastPayload
|
||||
}
|
||||
|
||||
absRoot, err := filepath.Abs(cfg.WatchFolder)
|
||||
// runDirectOnceRoot 對單一根掃一輪、直送 added/modified/renamed、下架 removed,2xx 後回寫該根 manifest。
|
||||
func runDirectOnceRoot(cfg *DirectConfig, root string, dryRun bool) ([]DirectResult, int, *TriggerPayload) {
|
||||
results := []DirectResult{}
|
||||
exit := 0
|
||||
|
||||
absRoot, err := filepath.Abs(root)
|
||||
if err != nil {
|
||||
return append(results, DirectResult{Status: "failed", Error: err.Error()}), 1, nil
|
||||
}
|
||||
absManifest, err := filepath.Abs(cfg.Manifest)
|
||||
absManifest, err := filepath.Abs(cfg.manifestPathFor(absRoot))
|
||||
if err != nil {
|
||||
return append(results, DirectResult{Status: "failed", Error: err.Error()}), 1, nil
|
||||
}
|
||||
@@ -267,9 +325,9 @@ func runDirect(args []string) int {
|
||||
results, exit, _ := RunDirectOnce(cfg, *dryRun)
|
||||
out, _ := json.MarshalIndent(struct {
|
||||
At string `json:"at"`
|
||||
Folder string `json:"folder"`
|
||||
Folders []string `json:"folders"`
|
||||
Results []DirectResult `json:"results"`
|
||||
}{time.Now().Format(time.RFC3339), cfg.WatchFolder, results}, "", " ")
|
||||
}{time.Now().Format(time.RFC3339), cfg.Folders(), results}, "", " ")
|
||||
fmt.Println(string(out))
|
||||
return exit
|
||||
}
|
||||
@@ -279,7 +337,7 @@ func runDirect(args []string) int {
|
||||
}
|
||||
// 常駐輪詢:純 stdlib ticker,跨平台。首輪立即跑。
|
||||
fmt.Fprintf(os.Stderr, "collector direct daemon 啟動:監看 %s → %s(每 %ds 掃一輪)\n",
|
||||
cfg.WatchFolder, cfg.triggerURL(cfg.IngestWF), cfg.PollSec)
|
||||
strings.Join(cfg.Folders(), "、"), cfg.triggerURL(cfg.IngestWF), cfg.PollSec)
|
||||
runOne()
|
||||
ticker := time.NewTicker(time.Duration(cfg.PollSec) * time.Second)
|
||||
defer ticker.Stop()
|
||||
|
||||
@@ -0,0 +1,124 @@
|
||||
// direct_multi_test.go — daemon-beta task 1 多資料夾 config(單數相容/manifest 分根/多根掃描)。
|
||||
package main
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func writeDirectConfig(t *testing.T, dir string, cfg map[string]any) string {
|
||||
t.Helper()
|
||||
data, _ := json.Marshal(cfg)
|
||||
p := filepath.Join(dir, "config.json")
|
||||
if err := os.WriteFile(p, data, 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return p
|
||||
}
|
||||
|
||||
// 單數舊制照舊可載入;Folders() 正規化=一根。
|
||||
func TestLoadDirectConfigSingularCompat(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
p := writeDirectConfig(t, dir, map[string]any{
|
||||
"watch_folder": "/tmp/kb", "manifest": filepath.Join(dir, "m.json"),
|
||||
"cypher_url": "https://x.example", "namespace": "demo",
|
||||
})
|
||||
c, err := LoadDirectConfig(p)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got := c.Folders(); len(got) != 1 || got[0] != "/tmp/kb" {
|
||||
t.Fatalf("Folders()=%v", got)
|
||||
}
|
||||
// 單根 manifest 沿用原路徑(升級不丟狀態)
|
||||
if mp := c.manifestPathFor("/tmp/kb"); mp != c.Manifest {
|
||||
t.Fatalf("單根 manifest 應沿用原路徑,got %s", mp)
|
||||
}
|
||||
}
|
||||
|
||||
// 只填 watch_folders 也合法;單數與清單並存時去重保序。
|
||||
func TestLoadDirectConfigMulti(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
p := writeDirectConfig(t, dir, map[string]any{
|
||||
"watch_folder": "/tmp/a", "watch_folders": []string{"/tmp/b", "/tmp/a", "/tmp/c"},
|
||||
"manifest": filepath.Join(dir, "m.json"),
|
||||
"cypher_url": "https://x.example", "namespace": "demo",
|
||||
})
|
||||
c, err := LoadDirectConfig(p)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
got := c.Folders()
|
||||
want := []string{"/tmp/a", "/tmp/b", "/tmp/c"}
|
||||
if len(got) != len(want) {
|
||||
t.Fatalf("Folders()=%v want %v", got, want)
|
||||
}
|
||||
for i := range want {
|
||||
if got[i] != want[i] {
|
||||
t.Fatalf("Folders()[%d]=%s want %s", i, got[i], want[i])
|
||||
}
|
||||
}
|
||||
// 多根=每根 manifest 各一份、彼此不同、且尾碼對同一路徑穩定
|
||||
m1 := c.manifestPathFor("/tmp/a")
|
||||
m2 := c.manifestPathFor("/tmp/b")
|
||||
if m1 == m2 || m1 == c.Manifest {
|
||||
t.Fatalf("多根 manifest 應分根:%s vs %s", m1, m2)
|
||||
}
|
||||
if m1 != c.manifestPathFor("/tmp/a") {
|
||||
t.Fatal("同路徑尾碼應穩定")
|
||||
}
|
||||
}
|
||||
|
||||
// 兩欄都空=缺必填。
|
||||
func TestLoadDirectConfigMissingFolders(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
p := writeDirectConfig(t, dir, map[string]any{
|
||||
"manifest": filepath.Join(dir, "m.json"), "cypher_url": "https://x.example", "namespace": "demo",
|
||||
})
|
||||
if _, err := LoadDirectConfig(p); err == nil {
|
||||
t.Fatal("兩欄皆空應報缺必填")
|
||||
}
|
||||
}
|
||||
|
||||
// 多根 dry-run:各根的檔案都被掃到、事件標 Root、manifest 各自獨立。
|
||||
func TestRunDirectOnceMultiRootDryRun(t *testing.T) {
|
||||
base := t.TempDir()
|
||||
rootA := filepath.Join(base, "rootA")
|
||||
rootB := filepath.Join(base, "rootB")
|
||||
for _, d := range []string{rootA, rootB} {
|
||||
if err := os.MkdirAll(d, 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
if err := os.WriteFile(filepath.Join(rootA, "a.md"), []byte("# A"), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(filepath.Join(rootB, "b.md"), []byte("# B"), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
cfg := &DirectConfig{
|
||||
WatchFolders: []string{rootA, rootB},
|
||||
Manifest: filepath.Join(base, "manifest.json"),
|
||||
CypherURL: "https://x.example", Namespace: "demo",
|
||||
MaxRemoved: DefaultMaxRemovedRatio,
|
||||
}
|
||||
results, exit, _ := RunDirectOnce(cfg, true)
|
||||
if exit != 0 {
|
||||
t.Fatalf("dry-run 不應失敗:%+v", results)
|
||||
}
|
||||
byRoot := map[string][]string{}
|
||||
for _, r := range results {
|
||||
if r.Status != "planned" {
|
||||
t.Fatalf("dry-run 事件應為 planned:%+v", r)
|
||||
}
|
||||
byRoot[r.Root] = append(byRoot[r.Root], r.Path)
|
||||
}
|
||||
if len(byRoot[rootA]) != 1 || byRoot[rootA][0] != "a.md" {
|
||||
t.Fatalf("rootA 事件錯:%v", byRoot[rootA])
|
||||
}
|
||||
if len(byRoot[rootB]) != 1 || byRoot[rootB][0] != "b.md" {
|
||||
t.Fatalf("rootB 事件錯:%v", byRoot[rootB])
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user