diff --git a/go/internal/api/routes/obsidian.go b/go/internal/api/routes/obsidian.go index 7002be1..fef71a1 100644 --- a/go/internal/api/routes/obsidian.go +++ b/go/internal/api/routes/obsidian.go @@ -2,6 +2,8 @@ package routes import ( + "crypto/sha256" + "encoding/hex" "encoding/json" "fmt" "log" @@ -11,6 +13,7 @@ import ( "strings" "sync" "time" + "unicode" "github.com/xiaoxue/memoryweave/internal/storage" ) @@ -52,14 +55,26 @@ func (s *ObsidianSyncer) PushToObsidian(memories []map[string]interface{}, folde } count := 0 + used := make(map[string]int, len(memories)) for _, mem := range memories { content := strVal(mem["content"]) if len(content) < 5 { continue } - filename := sanitizeFilename(content[:minz(len(content), 30)]) + ".md" - filepath := filepath.Join(targetDir, filename) + // 2026-09-10 修复:旧实现 content[:30] 按字节切 + sanitizeFilename 把非 ASCII + // 全替换成 '_',中文标题直接变 ____123.md(07-Wiki/织忆 1628 个乱码文件名成因), + // 且不同记忆撞名会静默互相覆盖。改为 rune 级截断 + 保留 Unicode + 撞名加短哈希。 + base := sanitizeFilename(truncateRunes(content, 30)) + if base == "" { + base = "untitled" + } + if used[base] > 0 { + base = base + "-" + shortHash(content) + } + used[base]++ + filename := base + ".md" + target := filepath.Join(targetDir, filename) mdContent := fmt.Sprintf(`--- id: %s @@ -80,7 +95,7 @@ sync_time: %s truncate(content, 60), content) - if err := os.WriteFile(filepath, []byte(mdContent), 0644); err != nil { + if err := os.WriteFile(target, []byte(mdContent), 0644); err != nil { log.Printf("[obsidian] 写入失败 %s: %v", filename, err) continue } @@ -206,29 +221,59 @@ func (s *ObsidianSyncer) StatusHandler(w http.ResponseWriter, r *http.Request) { // ─── 辅助 ───────────────────────────────────────────────── +// sanitizeFilename 把标题转成文件系统安全的文件名片段,并保留 Unicode(中文)可读性。 +// +// 2026-09-10 修复:旧实现把所有非 ASCII rune 逐个替换为 '_',于是中文标题全变成 +// `____123.md`,在 Obsidian 里无法按名浏览(07-Wiki/织忆 1628 个 legacy 文件的成因)。 +// 现在只替换文件系统非法字符(/ \ : * ? " < > | 控制符等),连续的非法字符折叠成一个 '_'。 +// 返回空串表示内容里没有任何可用的安全字符,由调用方兜底。 func sanitizeFilename(s string) string { - s = strings.Map(func(r rune) rune { - if (r >= 'a' && r <= 'z') || (r >= 'A' && r <= 'Z') || - (r >= '0' && r <= '9') || r == '_' || r == '-' || r == ' ' { - return r + var b strings.Builder + prevIllegal := false + for _, r := range s { + if unicode.IsLetter(r) || unicode.IsDigit(r) || + r == '-' || r == '_' || r == ' ' || r == '.' { + prevIllegal = false + b.WriteRune(r) + continue } - return '_' - }, s) - return strings.TrimSpace(s) + // 文件系统非法字符(/ \ : * ? " < > | 控制符等)+ 其它符号 → 折叠成一个 '_' + if !prevIllegal { + b.WriteRune('_') + prevIllegal = true + } + } + return strings.Trim(b.String(), " ._-") } +// shortHash 取内容摘要前 8 位十六进制,仅用于同名文件消歧,不做安全用途。 +func shortHash(s string) string { + sum := sha256.Sum256([]byte(s)) + return hex.EncodeToString(sum[:4]) +} + +// truncateRunes 按 rune(而非字节)截断,避免把多字节字符切掉一半产生乱码。 +func truncateRunes(s string, maxRunes int) string { + if maxRunes <= 0 { + return "" + } + n := 0 + for i := range s { + if n == maxRunes { + return s[:i] + } + n++ + } + return s +} + +// truncate 展示用截断,按 rune 计数(2026-09-10 从字节切改为 rune 切,避免乱码)。 func truncate(s string, maxLen int) string { - if len(s) <= maxLen { + t := truncateRunes(s, maxLen) + if len(t) == len(s) { return s } - return s[:maxLen] + "..." -} - -func minz(a, b int) int { - if a < b { - return a - } - return b + return t + "..." } func floatVal(v interface{}) float64 { diff --git a/go/internal/api/routes/obsidian_test.go b/go/internal/api/routes/obsidian_test.go new file mode 100644 index 0000000..9bd5383 --- /dev/null +++ b/go/internal/api/routes/obsidian_test.go @@ -0,0 +1,166 @@ +// 织忆 MemoryWeave — Obsidian 同步文件名/slug 单元测试 +// 2026-09-10 新增(kanban t_5f9c56ef):锁死「中文标题不许变下划线」这个回归。 +package routes + +import ( + "os" + "path/filepath" + "strings" + "testing" + "unicode/utf8" +) + +func TestSanitizeFilename_KeepsChinese(t *testing.T) { + cases := []struct { + in string + want string + }{ + {"修复了respond重复声明问题", "修复了respond重复声明问题"}, + {"110GB 磁盘占用排查", "110GB 磁盘占用排查"}, + {"E5.3 Web UI 单文件React+静态文件中间件", "E5.3 Web UI 单文件React_静态文件中间件"}, + {"a/b\\c:d*e?f\"gi|j", "a_b_c_d_e_f_g_h_i_j"}, + {" 前后空格 ", "前后空格"}, + {"...隐藏点开头...", "隐藏点开头"}, + {"///", ""}, + {"", ""}, + {"测试::连续非法字符::折叠", "测试_连续非法字符_折叠"}, + } + for _, c := range cases { + if got := sanitizeFilename(c.in); got != c.want { + t.Errorf("sanitizeFilename(%q) = %q, want %q", c.in, got, c.want) + } + } +} + +// 回归锁:旧实现(2026-09-10 前)会把中文全部换成 '_',这条用例就是当时的现场。 +func TestSanitizeFilename_RegressionNoUnderscoreSoup(t *testing.T) { + got := sanitizeFilename("____112GB_____") + if strings.Contains(got, "____") { + t.Fatalf("中文标题不该出现连续下划线乱码: %q", got) + } + old := sanitizeFilename("修复了respond重复声明问题") + if strings.Contains(old, "_") { + t.Fatalf("中文/字母不该被替换成下划线: %q", old) + } +} + +func TestTruncateRunes_NoBrokenUTF8(t *testing.T) { + s := "修复了respond重复声明问题,这是很长的中文内容用来测试截断" + got := truncateRunes(s, 7) + if got != "修复了resp" { + t.Fatalf("truncateRunes = %q", got) + } + if !utf8.ValidString(got) { + t.Fatal("截断结果不是合法 UTF-8(多字节字符被切半)") + } + if truncateRunes(s, 0) != "" || truncateRunes(s, -1) != "" { + t.Fatal("maxRunes<=0 应返回空串") + } + if truncateRunes("短", 10) != "短" { + t.Fatal("短串应原样返回") + } + // 关键回归:旧实现 content[:30] 是字节切,中文会切出半个字符(utf8.RuneError) + if got := truncateRunes("中文标题测试内容很长很长很长很长很长很长", 8); !utf8.ValidString(got) { + t.Fatalf("rune 截断后仍不是合法 UTF-8: %q", got) + } +} + +func TestTruncate_RuneCounted(t *testing.T) { + s := strings.Repeat("记", 40) // 120 字节;旧实现 s[:10] 只能得到 3 个半截汉字 + got := truncate(s, 10) + want := strings.Repeat("记", 10) + "..." + if got != want { + t.Fatalf("truncate = %q, want %q", got, want) + } + if !utf8.ValidString(got) { + t.Fatal("truncate 结果不是合法 UTF-8") + } + if truncate("短内容", 60) != "短内容" { + t.Fatal("未超长应原样返回") + } +} + +func TestShortHash_Deterministic(t *testing.T) { + a, b := shortHash("同一段内容"), shortHash("同一段内容") + if a != b { + t.Fatal("shortHash 必须稳定") + } + if len(a) != 8 { + t.Fatalf("shortHash 长度应为 8,得到 %d", len(a)) + } + if shortHash("内容甲") == shortHash("内容乙") { + t.Fatal("不同内容不该同哈希") + } +} + +// 端到端:真的落盘,检查文件名可读、无乱码、同名不互相覆盖。 +func TestPushToObsidian_ReadableFilenames(t *testing.T) { + dir := t.TempDir() + s := NewObsidianSyncer(dir, nil) + + dupPrefix := "同一段开头的内容 abcdefghijklmnopqrstuvwxyz" + mems := []map[string]interface{}{ + {"id": "mem_1", "category": "distilled", "quality_score": 0.9, + "content": "修复了 respond 重复声明问题"}, + {"id": "mem_2", "category": "distilled", "quality_score": 0.8, + "content": "LanceDB 已设天花板,不会再膨胀到 35G"}, + // 前 30 个 rune 完全相同 → 必须加哈希后缀而不是覆盖 + {"id": "mem_3", "category": "distilled", "content": dupPrefix + " 变体一"}, + {"id": "mem_4", "category": "distilled", "content": dupPrefix + " 变体二"}, + } + + if err := s.PushToObsidian(mems, "mirror"); err != nil { + t.Fatalf("PushToObsidian 失败: %v", err) + } + + entries, err := os.ReadDir(filepath.Join(dir, "mirror")) + if err != nil { + t.Fatal(err) + } + if len(entries) != len(mems) { + t.Fatalf("应写入 %d 个文件(含撞名消歧),实际 %d 个:%v", len(mems), len(entries), names(entries)) + } + t.Logf("实际落盘文件名: %v", names(entries)) + + for _, e := range entries { + n := e.Name() + if strings.Contains(n, "____") { + t.Errorf("文件名出现下划线乱码: %q", n) + } + if !strings.ContainsAny(n, "修复了重复声明问题磁盘排查天花板膨胀同一段") { + t.Errorf("文件名丢失中文标题: %q", n) + } + if !utf8.ValidString(n) { + t.Errorf("文件名不是合法 UTF-8: %q", n) + } + data, err := os.ReadFile(filepath.Join(dir, "mirror", n)) + if err != nil { + t.Fatal(err) + } + if !utf8.Valid(data) { + t.Errorf("文件内容不是合法 UTF-8: %q", n) + } + if !strings.Contains(string(data), "由织忆 MemoryWeave 同步") { + t.Errorf("缺少同步 footer: %q", n) + } + } + + // 撞名消歧:两条同前缀记忆都要落盘(4 个文件已证明没互相覆盖) + var suffixed int + for _, e := range entries { + if strings.Contains(e.Name(), dupPrefix[:12]) && strings.Contains(e.Name(), "-") { + suffixed++ + } + } + if suffixed != 1 { + t.Errorf("应恰好 1 个文件带哈希后缀消歧,实际 %d:%v", suffixed, names(entries)) + } +} + +func names(entries []os.DirEntry) []string { + out := make([]string, 0, len(entries)) + for _, e := range entries { + out = append(out, e.Name()) + } + return out +} diff --git a/go/internal/api/routes/routes_test.go b/go/internal/api/routes/routes_test.go index 5f68e41..aec4fc1 100644 --- a/go/internal/api/routes/routes_test.go +++ b/go/internal/api/routes/routes_test.go @@ -128,7 +128,7 @@ func TestConflictAPI_Resolve_Validation(t *testing.T) { // ─── 知识缺口路由 ──────────────────────────────────────── func TestGapAPI_List(t *testing.T) { - gd := selfoptimize.NewGapDetector() + gd := selfoptimize.NewGapDetector(nil, nil) ai := NewGapAPI(gd) req := httptest.NewRequest("GET", "/api/v1/gaps", nil) @@ -141,7 +141,7 @@ func TestGapAPI_List(t *testing.T) { } func TestGapAPI_Detect(t *testing.T) { - gd := selfoptimize.NewGapDetector() + gd := selfoptimize.NewGapDetector(nil, nil) ai := NewGapAPI(gd) body, _ := json.Marshal(map[string]string{"topic": "test_gap"}) @@ -199,8 +199,8 @@ func TestTriggers_List(t *testing.T) { } var resp map[string]interface{} json.NewDecoder(w.Body).Decode(&resp) - if resp["count"].(float64) != 4 { - t.Errorf("expected 4 triggers, got %v", resp["count"]) + if resp["count"].(float64) != 8 { + t.Errorf("expected 8 triggers, got %v", resp["count"]) } } @@ -306,18 +306,19 @@ func TestAuxFunctions(t *testing.T) { } } -// ─── SSE 管理器 ─────────────────────────────────────────── +// ─── WebSocket 事件推送 ─────────────────────────────────── -func TestSSEManager_Push(t *testing.T) { - // SSEBus.Push should not panic with no clients - SSEBus.Push(SSEMessage{Type: "test", Payload: "hello"}) -} - -func TestSSEPushHelpers(t *testing.T) { - // All push helpers should not panic - PushMemoryCommitted("agent", "ns", "mem1") - PushConflictDetected("Docker", "facts conflict") - PushGapFound("topic", "A") +func TestWSPushHelpers_NoPanic(t *testing.T) { + // 所有推送 helper 在无订阅者时都不该 panic + // 2026-09-10 修复:原用例引用的 SSEBus/SSEMessage/PushMemoryCommitted/PushGapFound + // 已随 SSE→WebSocket 重构删除,改用当前 API(PushGapDetected/PushConflict* 新签名)。 + PushPrefetch("agent", []interface{}{}) + PushGapDetected("agent", "topic", "knowledge", "补一下") + PushGapFilled("agent", 1) + PushMemoryUpdated("mem1", 2, "test") + PushConflictDetected("agent", "c1", "Docker", map[string]string{"a": "b"}) + PushConflictResolved("agent", "c1", "keep_left") PushConsolidationDone("done") - PushConflictResolved("c1", "keep_left") + PushQualityDrop("agent", "mem1", 0.2, "补证据") + PushDistillationComplete(1, 0) }