diff --git a/mateclaw-server/src/main/java/vip/mate/tool/builtin/WorkspaceMemoryTool.java b/mateclaw-server/src/main/java/vip/mate/tool/builtin/WorkspaceMemoryTool.java index 4083b2f4..cfe453c4 100644 --- a/mateclaw-server/src/main/java/vip/mate/tool/builtin/WorkspaceMemoryTool.java +++ b/mateclaw-server/src/main/java/vip/mate/tool/builtin/WorkspaceMemoryTool.java @@ -9,12 +9,16 @@ import org.springframework.ai.tool.annotation.Tool; import org.springframework.ai.tool.annotation.ToolParam; import org.springframework.stereotype.Component; import vip.mate.memory.service.MemoryRecallTracker; +import vip.mate.workspace.document.MemorySearchHit; import vip.mate.workspace.document.WorkspaceFileService; import vip.mate.workspace.document.model.WorkspaceFileEntity; import java.nio.charset.StandardCharsets; import java.util.Comparator; +import java.util.HashSet; +import java.util.LinkedHashSet; import java.util.List; +import java.util.Set; /** * 基于数据库工作区文件的长期记忆工具。 @@ -197,6 +201,88 @@ public class WorkspaceMemoryTool { return JSONUtil.toJsonPrettyStr(result); } + @Tool(description = """ + Search agent's workspace memory files (MEMORY.md, PROFILE.md, AGENTS.md, memory/*.md) \ + by keyword. Use this BEFORE read_workspace_memory_file when looking for a fact across \ + many memory entries. Returns ranked hits with filename, line number, and snippet \ + (matched terms wrapped in [[...]]).""") + public String search_workspace_memory( + @ToolParam(description = "当前 Agent 的 ID") Long agentId, + @ToolParam(description = "关键词或短语,2-64 字符") String query, + @ToolParam(description = "搜索范围:all(全部)/ memory(MEMORY.md 与 memory/)/ profile / persona,默认 all", + required = false) String scope, + @ToolParam(description = "返回的最大命中数,默认 10,上限 30", required = false) Integer limit) { + + if (agentId == null) { + return error("agentId 不能为空"); + } + if (query == null || query.isBlank()) { + return error("query 不能为空"); + } + String trimmed = query.trim(); + if (trimmed.length() < 2) { + return error("query 至少 2 个字符"); + } + if (trimmed.length() > 64) { + return error("query 不能超过 64 个字符"); + } + + int effectiveLimit = limit == null ? 10 : Math.min(Math.max(limit, 1), 30); + Set prefixes = resolveScope(scope); + + List hits = workspaceFileService.searchSnippets( + agentId, trimmed, prefixes, effectiveLimit); + + // Treat each unique file in the results as an active retrieval signal — + // boosts that file's weight in the dream-consolidation ranker the same + // way an explicit read_workspace_memory_file call would. + Set retrieved = new HashSet<>(); + for (MemorySearchHit hit : hits) { + if (retrieved.add(hit.filename())) { + WorkspaceFileEntity file = workspaceFileService.getFile(agentId, hit.filename()); + if (file != null && file.getContent() != null) { + memoryRecallTracker.trackActiveRetrieval(agentId, hit.filename(), file.getContent()); + } + } + } + + JSONArray hitsJson = new JSONArray(); + for (MemorySearchHit hit : hits) { + JSONObject h = new JSONObject(); + h.set("filename", hit.filename()); + h.set("lineNumber", hit.lineNumber()); + h.set("snippet", hit.snippet()); + h.set("score", hit.score()); + hitsJson.add(h); + } + JSONObject result = new JSONObject(); + result.set("agentId", agentId); + result.set("query", trimmed); + result.set("scope", scope == null || scope.isBlank() ? "all" : scope); + result.set("totalHits", hits.size()); + result.set("hits", hitsJson); + if (!hits.isEmpty()) { + result.set("hint", "Use read_workspace_memory_file to get full context of any hit."); + } + return JSONUtil.toJsonPrettyStr(result); + } + + /** Map the {@code scope} tool argument to a filename-prefix whitelist. + * {@code "all"} (or null/blank) targets every memory-class file rather + * than every workspace file the agent has, so a search doesn't surface + * unrelated docs the user happens to store in the same workspace. */ + private static Set resolveScope(String scope) { + if (scope == null || scope.isBlank() || "all".equalsIgnoreCase(scope.trim())) { + return new LinkedHashSet<>(List.of("memory/", "MEMORY.md", "PROFILE.md", "AGENTS.md")); + } + return switch (scope.trim().toLowerCase()) { + case "memory" -> new LinkedHashSet<>(List.of("memory/", "MEMORY.md")); + case "profile" -> new LinkedHashSet<>(List.of("PROFILE.md")); + case "persona" -> new LinkedHashSet<>(List.of("AGENTS.md")); + default -> new LinkedHashSet<>(List.of("memory/", "MEMORY.md", "PROFILE.md", "AGENTS.md")); + }; + } + private String validate(Long agentId, String filename) { if (agentId == null) { return "agentId 不能为空"; diff --git a/mateclaw-server/src/main/java/vip/mate/workspace/document/MemorySearchHit.java b/mateclaw-server/src/main/java/vip/mate/workspace/document/MemorySearchHit.java new file mode 100644 index 00000000..6b9d4290 --- /dev/null +++ b/mateclaw-server/src/main/java/vip/mate/workspace/document/MemorySearchHit.java @@ -0,0 +1,17 @@ +package vip.mate.workspace.document; + +/** + * One snippet-level hit from {@link WorkspaceFileService#searchSnippets}. + * + * @param filename Workspace filename the hit was extracted from (e.g. + * {@code MEMORY.md}, {@code memory/2026-05-10.md}). + * @param lineNumber 1-based line number of the hit within {@code filename}. + * @param snippet The original line clipped to {@code head(80) + match + + * tail(80)} and with each matched term wrapped in + * {@code [[...]]} for downstream highlighting. + * @param score Composite relevance: number of terms matched on this line + * weighted by per-file importance (see + * {@code WorkspaceFileService#fileWeight}). + */ +public record MemorySearchHit(String filename, int lineNumber, String snippet, double score) { +} diff --git a/mateclaw-server/src/main/java/vip/mate/workspace/document/WorkspaceFileService.java b/mateclaw-server/src/main/java/vip/mate/workspace/document/WorkspaceFileService.java index 6c193aca..58b6c4fa 100644 --- a/mateclaw-server/src/main/java/vip/mate/workspace/document/WorkspaceFileService.java +++ b/mateclaw-server/src/main/java/vip/mate/workspace/document/WorkspaceFileService.java @@ -9,8 +9,12 @@ import vip.mate.workspace.document.model.WorkspaceFileEntity; import vip.mate.workspace.document.repository.WorkspaceFileMapper; import java.nio.charset.StandardCharsets; +import java.util.ArrayList; +import java.util.Collections; import java.util.Comparator; +import java.util.LinkedHashSet; import java.util.List; +import java.util.Set; import java.util.stream.Collectors; /** @@ -128,6 +132,300 @@ public class WorkspaceFileService { } } + // ==================== Snippet search ==================== + + /** Hard cap on terms passed to the DB filter — keeps the {@code LIKE} chain + * short and bounds the per-line scoring cost. + *

+ * Sized so a typical CJK query of up to 12 chars (= 6 non-overlapping + * 2-char windows) lands all of its tokens, not just the first four. + * The trade-off: every extra term tightens the AND-LIKE filter, so a + * user typing a long phrase whose words are spread across files will + * match fewer candidates. Acceptable here because the typical search + * is a short phrase ("用户跑步"), and the per-line scorer's term-hit + * count keeps relevance ranking stable when only a subset matches. + * Sliding-bigram tokenization is the next lever if real query data + * shows a recall problem on long phrases. */ + private static final int MAX_TERMS = 6; + + /** Minimum query length below which search is a no-op. Two characters + * comfortably covers a single CJK word; one character would return the + * bulk of every file. */ + private static final int MIN_QUERY_LENGTH = 2; + + /** Candidate file cap. Picked large enough that scope filtering plus + * AND-LIKE almost always leaves room for the relevant memory set, but + * small enough that line-level scoring stays cheap. */ + private static final int CANDIDATE_FILE_CAP = 50; + + /** Per-file hit cap. Prevents a single very long file (multi-week daily + * ledger, copy-pasted log) from monopolising the result set. */ + private static final int PER_FILE_HIT_CAP = 5; + + /** Snippet context window: characters before and after the first matched + * term on the line. The original line is clipped to this window before + * highlighting. */ + private static final int SNIPPET_CONTEXT = 80; + + /** + * Keyword-search the agent's workspace files and return line-level snippet + * hits ranked by term-match count weighted by per-file importance. + *

+ * Pipeline: + *

    + *
  1. Tokenize {@code query} on whitespace and CJK/Latin boundaries. + * CJK runs are split into non-overlapping 2-char windows; Latin / + * digit runs are kept as whole tokens. Dedupe and cap at {@value + * #MAX_TERMS}.
  2. + *
  3. Pull candidate rows via {@link com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper}: + * {@code agent_id} match, optional {@code filename LIKE prefix%} OR + * group, and one {@code content LIKE %term%} per token (AND).
  4. + *
  5. For each candidate file, scan lines and collect up to {@value + * #PER_FILE_HIT_CAP} hits scored by {@code termsHit * fileWeight}.
  6. + *
  7. Build a head/match/tail snippet around the first matched term on + * the line, wrap each term in {@code [[...]]}, sort by score + * descending and return the top {@code limit} results.
  8. + *
+ * + * @param agentId The agent whose workspace files are searched. + * Required; null returns empty. + * @param query The user-supplied search string. Returns empty + * if {@code null}, blank, or shorter than {@value + * #MIN_QUERY_LENGTH} characters after trim. + * @param filenamePrefixes Optional filename prefix whitelist (each value + * is a {@code likeRight} prefix). {@code null} or + * empty means "all files for this agent". + * @param limit Maximum number of hits returned across all + * files. Capped at the candidate-file scan + * output naturally; the caller is expected to + * choose a sensible value (1-30 typical). + */ + public List searchSnippets(Long agentId, String query, + Set filenamePrefixes, int limit) { + if (agentId == null || limit <= 0) { + return List.of(); + } + if (query == null || query.trim().length() < MIN_QUERY_LENGTH) { + return List.of(); + } + List terms = tokenize(query); + if (terms.isEmpty()) { + return List.of(); + } + + LambdaQueryWrapper wrapper = new LambdaQueryWrapper<>(); + wrapper.eq(WorkspaceFileEntity::getAgentId, agentId); + + if (filenamePrefixes != null && !filenamePrefixes.isEmpty()) { + // Group prefix conditions inside a single AND-bracketed OR chain + // so they don't interleave with the content-LIKE terms. + wrapper.and(w -> { + boolean[] first = {true}; + for (String prefix : filenamePrefixes) { + if (prefix == null || prefix.isEmpty()) continue; + if (first[0]) { + w.likeRight(WorkspaceFileEntity::getFilename, prefix); + first[0] = false; + } else { + w.or().likeRight(WorkspaceFileEntity::getFilename, prefix); + } + } + }); + } + for (String term : terms) { + wrapper.like(WorkspaceFileEntity::getContent, term); + } + // Dialect-transparent LIMIT; @TableLogic appends deleted = 0 for us. + wrapper.last("LIMIT " + CANDIDATE_FILE_CAP); + + List candidates = fileMapper.selectList(wrapper); + if (candidates.isEmpty()) { + return List.of(); + } + + List all = new ArrayList<>(); + for (WorkspaceFileEntity file : candidates) { + all.addAll(extractHitsFromFile(file, terms)); + } + all.sort(Comparator.comparingDouble(MemorySearchHit::score).reversed() + .thenComparing(MemorySearchHit::filename) + .thenComparingInt(MemorySearchHit::lineNumber)); + if (all.size() > limit) { + return new ArrayList<>(all.subList(0, limit)); + } + return all; + } + + /** Split a query into searchable terms. Whitespace and non-letter/digit + * separators end a token; CJK ↔ Latin/digit transitions end a token; + * CJK runs of length ≥ 2 are further chunked into non-overlapping + * 2-char windows (e.g. "用户喜欢" → ["用户", "喜欢"]). The result is + * deduped (preserving order) and capped at {@value #MAX_TERMS}. */ + static List tokenize(String query) { + if (query == null) return List.of(); + List raw = new ArrayList<>(); + StringBuilder cur = new StringBuilder(); + int prevType = -1; // 0 = CJK, 1 = Latin/digit, -1 = separator / not started + for (int i = 0; i < query.length(); i++) { + char c = query.charAt(i); + int type = classify(c); + if (type == -1) { + flush(cur, raw); + prevType = -1; + continue; + } + if (prevType != -1 && type != prevType) { + flush(cur, raw); + } + cur.append(c); + prevType = type; + } + flush(cur, raw); + + // Split long CJK runs into 2-char windows so a query like + // "用户喜欢周日早上跑步" yields multiple usable terms instead of a + // single substring rarely present verbatim in stored memory. + List exploded = new ArrayList<>(); + for (String token : raw) { + if (!token.isEmpty() && classify(token.charAt(0)) == 0 && token.length() > 2) { + for (int i = 0; i + 2 <= token.length(); i += 2) { + exploded.add(token.substring(i, i + 2)); + } + if (token.length() % 2 == 1) { + // Trailing single char: pair it with the previous char as + // an overlap so it can still match (better than dropping). + exploded.add(token.substring(token.length() - 2)); + } + } else { + exploded.add(token); + } + } + + LinkedHashSet dedupe = new LinkedHashSet<>(); + for (String t : exploded) { + if (!t.isEmpty()) dedupe.add(t); + } + if (dedupe.size() <= MAX_TERMS) { + return List.copyOf(dedupe); + } + List capped = new ArrayList<>(MAX_TERMS); + int i = 0; + for (String t : dedupe) { + if (i++ >= MAX_TERMS) break; + capped.add(t); + } + return Collections.unmodifiableList(capped); + } + + private static int classify(char c) { + if (c >= 0x4E00 && c <= 0x9FFF) return 0; // CJK Unified Ideographs + if (c >= 0x3400 && c <= 0x4DBF) return 0; // CJK Extension A + if (Character.isLetterOrDigit(c)) return 1; + return -1; + } + + private static void flush(StringBuilder cur, List sink) { + if (cur.length() > 0) { + sink.add(cur.toString()); + cur.setLength(0); + } + } + + private List extractHitsFromFile(WorkspaceFileEntity file, List terms) { + String content = file.getContent(); + if (content == null || content.isEmpty()) return List.of(); + String[] lines = content.split("\n", -1); + double weight = fileWeight(file.getFilename()); + List hits = new ArrayList<>(); + for (int i = 0; i < lines.length; i++) { + String line = lines[i]; + int termsHit = 0; + for (String term : terms) { + if (line.contains(term)) termsHit++; + } + if (termsHit == 0) continue; + double score = termsHit * weight; + hits.add(new MemorySearchHit(file.getFilename(), i + 1, + buildSnippet(line, terms), score)); + if (hits.size() >= PER_FILE_HIT_CAP) break; + } + return hits; + } + + /** Per-file importance multiplier. {@code MEMORY.md} is the consolidated + * long-term memory; daily ledger files under {@code memory/} are + * high-recall but noisier; {@code PROFILE.md} is mostly static persona + * facts; {@code AGENTS.md} is mostly tool / behavior config and rarely + * the right answer to a memory query. */ + private double fileWeight(String filename) { + if (filename == null) return 0.2; + if ("MEMORY.md".equals(filename)) return 1.0; + if (filename.startsWith("memory/")) return 0.7; + if ("PROFILE.md".equals(filename)) return 0.5; + if ("AGENTS.md".equals(filename)) return 0.3; + return 0.2; + } + + /** Build a {@code head + match + tail} snippet around the first matched + * term and wrap every non-overlapping match inside the window with + * {@code [[...]]}. Term boundaries are preserved — adjacent matches from + * two different terms render as {@code [[t1]][[t2]]}, not as one merged + * bracket. Longer terms claim their span first so a shorter overlapping + * term cannot fragment a longer match. */ + private String buildSnippet(String line, List terms) { + int firstMatch = Integer.MAX_VALUE; + for (String term : terms) { + if (term.isEmpty()) continue; + int idx = line.indexOf(term); + if (idx >= 0 && idx < firstMatch) firstMatch = idx; + } + if (firstMatch == Integer.MAX_VALUE) { + return line; + } + + int start = Math.max(0, firstMatch - SNIPPET_CONTEXT); + int end = Math.min(line.length(), firstMatch + SNIPPET_CONTEXT); + String clipped = line.substring(start, end); + boolean hasPrefix = start > 0; + boolean hasSuffix = end < line.length(); + + List longestFirst = new ArrayList<>(terms); + longestFirst.sort(Comparator.comparingInt(String::length).reversed()); + + boolean[] covered = new boolean[clipped.length()]; + List spans = new ArrayList<>(); + for (String term : longestFirst) { + if (term.isEmpty()) continue; + int from = 0; + int idx; + while ((idx = clipped.indexOf(term, from)) >= 0) { + int spanEnd = idx + term.length(); + boolean overlap = false; + for (int k = idx; k < spanEnd; k++) { + if (covered[k]) { overlap = true; break; } + } + if (!overlap) { + for (int k = idx; k < spanEnd; k++) covered[k] = true; + spans.add(new int[]{idx, spanEnd}); + } + from = spanEnd; + } + } + spans.sort(Comparator.comparingInt(s -> s[0])); + + StringBuilder sb = new StringBuilder(clipped.length() + spans.size() * 4 + 6); + if (hasPrefix) sb.append("..."); + int pos = 0; + for (int[] s : spans) { + sb.append(clipped, pos, s[0]); + sb.append("[[").append(clipped, s[0], s[1]).append("]]"); + pos = s[1]; + } + sb.append(clipped, pos, clipped.length()); + if (hasSuffix) sb.append("..."); + return sb.toString(); + } + /** * 将启用的工作区文件拼接为系统提示词 *

diff --git a/mateclaw-server/src/test/java/vip/mate/workspace/document/WorkspaceMemorySearchTest.java b/mateclaw-server/src/test/java/vip/mate/workspace/document/WorkspaceMemorySearchTest.java new file mode 100644 index 00000000..8989778c --- /dev/null +++ b/mateclaw-server/src/test/java/vip/mate/workspace/document/WorkspaceMemorySearchTest.java @@ -0,0 +1,280 @@ +package vip.mate.workspace.document; + +import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper; +import com.baomidou.mybatisplus.core.metadata.TableInfoHelper; +import org.apache.ibatis.builder.MapperBuilderAssistant; +import org.apache.ibatis.session.Configuration; +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.ArgumentCaptor; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import vip.mate.workspace.document.model.WorkspaceFileEntity; +import vip.mate.workspace.document.repository.WorkspaceFileMapper; + +import java.util.ArrayList; +import java.util.LinkedHashSet; +import java.util.List; +import java.util.Set; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.when; + +/** + * Contract for {@link WorkspaceFileService#searchSnippets} — the back end + * of the {@code search_workspace_memory} agent tool. + *

+ * Tests run against a Mockito-stubbed {@link WorkspaceFileMapper}: the DB-side + * AND-LIKE narrowing is MyBatis-Plus's problem, not this service's — what we + * verify here is the post-fetch pipeline (tokenization, per-line extraction, + * weighted scoring, snippet rendering) plus the wrapper construction so we + * don't silently drop scope filters or term groups. + */ +@ExtendWith(MockitoExtension.class) +class WorkspaceMemorySearchTest { + + @Mock private WorkspaceFileMapper fileMapper; + private WorkspaceFileService service; + + @BeforeAll + static void initMyBatisPlusCache() { + // LambdaQueryWrapper resolves SFunction → column via TableInfoHelper. + // Spring would init it during mapper scan; here we trigger it manually. + TableInfoHelper.initTableInfo( + new MapperBuilderAssistant(new Configuration(), ""), + WorkspaceFileEntity.class); + } + + @BeforeEach + void setUp() { + service = new WorkspaceFileService(fileMapper); + } + + // ---------- tokenize ---------- + + @Test + @DisplayName("tokenize: null / blank / single-char yields empty list") + void tokenizeShortInputs() { + assertThat(WorkspaceFileService.tokenize(null)).isEmpty(); + assertThat(WorkspaceFileService.tokenize("")).isEmpty(); + assertThat(WorkspaceFileService.tokenize(" ")).isEmpty(); + // A single CJK char DOES produce one token — query-length guard at + // searchSnippets level is what enforces the 2-char minimum. + assertThat(WorkspaceFileService.tokenize("好")).containsExactly("好"); + } + + @Test + @DisplayName("tokenize: long CJK runs split into non-overlapping 2-char windows (within cap)") + void tokenizeCjkPairs() { + // 10-char CJK run → 5 windows; all five survive under MAX_TERMS = 6. + assertThat(WorkspaceFileService.tokenize("用户喜欢周日早上跑步")) + .containsExactly("用户", "喜欢", "周日", "早上", "跑步"); + } + + @Test + @DisplayName("tokenize: CJK/Latin boundary splits, Latin runs stay intact") + void tokenizeMixedScripts() { + // "用户Foo123跑步" → CJK run "用户" (2-char window) + Latin/digit run + // "Foo123" + CJK run "跑步". + assertThat(WorkspaceFileService.tokenize("用户Foo123跑步")) + .containsExactly("用户", "Foo123", "跑步"); + } + + @Test + @DisplayName("tokenize: dedupe + cap at 6") + void tokenizeDedupeAndCap() { + // Whitespace splits; "abc" repeats are dedup'd; only first 6 kept. + // The 7th and 8th tokens ("ppp" / "qqq") get trimmed by the cap. + assertThat(WorkspaceFileService.tokenize("abc abc def ghi jkl mno opq ppp qqq")) + .containsExactly("abc", "def", "ghi", "jkl", "mno", "opq"); + } + + @Test + @DisplayName("tokenize: a 12-char CJK run yields 6 windows and stops at the cap") + void tokenizeLongCjkFitsCap() { + // 12-char run → 6 windows; the 7th and 8th would be "测一" / "下下" + // if we extended the query, but the cap stops at 6 either way. + assertThat(WorkspaceFileService.tokenize("用户喜欢周日早上跑步公园")) + .containsExactly("用户", "喜欢", "周日", "早上", "跑步", "公园"); + } + + // ---------- searchSnippets contract ---------- + + @Test + @DisplayName("Short query: returns empty without touching the mapper") + void shortQueryReturnsEmpty() { + assertThat(service.searchSnippets(1L, "a", null, 10)).isEmpty(); + assertThat(service.searchSnippets(1L, " ", null, 10)).isEmpty(); + assertThat(service.searchSnippets(1L, "", null, 10)).isEmpty(); + assertThat(service.searchSnippets(1L, null, null, 10)).isEmpty(); + // limit <= 0 short-circuits. + assertThat(service.searchSnippets(1L, "running", null, 0)).isEmpty(); + } + + @Test + @DisplayName("No candidate files: returns empty list") + void noCandidateRowsReturnsEmpty() { + when(fileMapper.selectList(any())).thenReturn(List.of()); + assertThat(service.searchSnippets(1L, "running", null, 10)).isEmpty(); + } + + @Test + @DisplayName("Score ordering: MEMORY > memory/* > PROFILE > AGENTS for identical term-hit counts") + void scoreOrdering() { + List candidates = List.of( + file("AGENTS.md", "Line about running.\n"), + file("MEMORY.md", "Line about running.\n"), + file("PROFILE.md", "Line about running.\n"), + file("memory/2026-05-10.md", "Line about running.\n")); + when(fileMapper.selectList(any())).thenReturn(candidates); + + List hits = service.searchSnippets(1L, "running", null, 10); + + assertThat(hits).extracting(MemorySearchHit::filename) + .containsExactly("MEMORY.md", "memory/2026-05-10.md", "PROFILE.md", "AGENTS.md"); + assertThat(hits.get(0).score()).isGreaterThan(hits.get(1).score()); + assertThat(hits.get(1).score()).isGreaterThan(hits.get(2).score()); + assertThat(hits.get(2).score()).isGreaterThan(hits.get(3).score()); + } + + @Test + @DisplayName("Term-hit count: line matching 2 terms scores 2× the per-file weight") + void termHitCountAffectsScore() { + List candidates = List.of( + file("MEMORY.md", + "Line with running and shoes.\n" + + "Line with only running.\n")); + when(fileMapper.selectList(any())).thenReturn(candidates); + + List hits = service.searchSnippets(1L, "running shoes", null, 10); + + // Both lines surface; the 2-term line ranks first with score 2.0, the + // 1-term line ranks second with score 1.0 (MEMORY weight = 1.0). + assertThat(hits).hasSize(2); + assertThat(hits.get(0).score()).isEqualTo(2.0); + assertThat(hits.get(0).snippet()).contains("[[running]]").contains("[[shoes]]"); + assertThat(hits.get(1).score()).isEqualTo(1.0); + } + + @Test + @DisplayName("Per-file hit cap: a flood of matching lines in one file caps at 5") + void perFileHitCap() { + StringBuilder content = new StringBuilder(); + for (int i = 0; i < 12; i++) content.append("line ").append(i).append(" running\n"); + List candidates = List.of(file("MEMORY.md", content.toString())); + when(fileMapper.selectList(any())).thenReturn(candidates); + + List hits = service.searchSnippets(1L, "running", null, 30); + + assertThat(hits).hasSize(5); + // First 5 lines only. + assertThat(hits).extracting(MemorySearchHit::lineNumber) + .containsExactly(1, 2, 3, 4, 5); + } + + @Test + @DisplayName("Limit caps the total result count after global ranking") + void limitClampsResults() { + StringBuilder mem = new StringBuilder(); + for (int i = 0; i < 5; i++) mem.append("MEM running\n"); + StringBuilder daily = new StringBuilder(); + for (int i = 0; i < 5; i++) daily.append("DAILY running\n"); + when(fileMapper.selectList(any())).thenReturn(List.of( + file("MEMORY.md", mem.toString()), + file("memory/2026-05-10.md", daily.toString()))); + + List hits = service.searchSnippets(1L, "running", null, 3); + + assertThat(hits).hasSize(3); + // Highest-weight hits come first — all from MEMORY.md. + assertThat(hits).extracting(MemorySearchHit::filename) + .containsExactly("MEMORY.md", "MEMORY.md", "MEMORY.md"); + } + + @Test + @DisplayName("Snippet: each matched term is wrapped in [[...]] with term boundaries preserved") + void snippetHighlightingTermBoundaries() { + List candidates = List.of(file("MEMORY.md", + "用户喜欢在公园跑步\n")); + when(fileMapper.selectList(any())).thenReturn(candidates); + + List hits = service.searchSnippets(1L, "用户喜欢跑步", null, 10); + // tokenize("用户喜欢跑步") → ["用户","喜欢","跑步"]; line contains all three. + assertThat(hits).hasSize(1); + assertThat(hits.get(0).snippet()) + .contains("[[用户]]") + .contains("[[喜欢]]") + .contains("[[跑步]]") + // Adjacent term matches keep their boundary, not merged into one bracket. + .contains("[[用户]][[喜欢]]"); + } + + @Test + @DisplayName("Snippet: long line is clipped to ±80 chars around the first match with ellipses") + void snippetTruncation() { + String lead = "x".repeat(200); + String tail = "y".repeat(200); + String line = lead + " running " + tail; + when(fileMapper.selectList(any())).thenReturn(List.of(file("MEMORY.md", line + "\n"))); + + List hits = service.searchSnippets(1L, "running", null, 10); + + assertThat(hits).hasSize(1); + String snippet = hits.get(0).snippet(); + assertThat(snippet).startsWith("..."); + assertThat(snippet).endsWith("..."); + assertThat(snippet).contains("[[running]]"); + // Clip window is 80 chars on each side plus the match (7 chars) plus + // two "..." markers (6 chars). Should be << original length. + assertThat(snippet.length()).isLessThan(line.length()); + } + + @Test + @DisplayName("Wrapper carries one content-LIKE per token plus the prefix group and LIMIT 50") + void wrapperContainsTermsAndPrefixes() { + when(fileMapper.selectList(any())).thenReturn(List.of()); + Set prefixes = new LinkedHashSet<>(List.of("memory/", "MEMORY.md")); + service.searchSnippets(42L, "running shoes 跑步", prefixes, 10); + + @SuppressWarnings("unchecked") + ArgumentCaptor> captor = + ArgumentCaptor.forClass(LambdaQueryWrapper.class); + org.mockito.Mockito.verify(fileMapper).selectList(captor.capture()); + LambdaQueryWrapper wrapper = captor.getValue(); + + // Force SQL rendering so paramNameValuePairs gets populated; assert + // on the SQL shape (placeholders only — column-name casing depends + // on global underscore-camelCase config not present in this unit + // test) and on the bound literal values. + String sql = wrapper.getTargetSql(); + // One equality on agent + two prefix LIKEs in an OR group + three + // content LIKEs, ANDed together, suffixed with the candidate cap. + assertThat(sql).contains("LIKE ? OR") + .contains("AND content") + .contains("LIMIT 50"); + assertThat(sql.chars().filter(ch -> ch == '?').count()) + .as("one agentId + two prefix LIKEs + three content LIKEs = 6 bind params") + .isEqualTo(6); + + List values = new ArrayList<>(wrapper.getParamNameValuePairs().values()); + assertThat(values).contains(42L); + // Each content-LIKE term gets %term% by MyBatis-Plus's like(). + assertThat(values).contains("%running%", "%shoes%", "%跑步%"); + // likeRight produces "prefix%" — confirms both prefixes were bound. + assertThat(values).contains("memory/%", "MEMORY.md%"); + } + + // ---------- helpers ---------- + + private static WorkspaceFileEntity file(String filename, String content) { + WorkspaceFileEntity e = new WorkspaceFileEntity(); + e.setAgentId(1L); + e.setFilename(filename); + e.setContent(content); + return e; + } +}