This is an automated email from the ASF dual-hosted git repository.

Croway pushed a commit to branch main
in repository https://gitbox.apache.org/repos/asf/camel.git


The following commit(s) were added to refs/heads/main by this push:
     new 6f6eb7862457 CAMEL-25536: camel-jbang-mcp - 
camel_migration_guide_search ranks topic sections with the source version in 
mind (#27814)
6f6eb7862457 is described below

commit 6f6eb7862457535165f2b139e979ed5bf8585701
Author: Federico Mariani <[email protected]>
AuthorDate: Wed Oct 14 14:03:09 2026 +0200

    CAMEL-25536: camel-jbang-mcp - camel_migration_guide_search ranks topic 
sections with the source version in mind (#27814)
    
    * CAMEL-25536: camel-jbang-mcp - camel_migration_guide_search ranks topic 
sections with the source version in mind
    
    Co-Authored-By: Claude Opus 5.5 <[email protected]>
    
    * CAMEL-25536: currentVersion/targetVersion like the sibling tools, a 
multiplier instead of a partition for the major guides, a test that tests
    
    Co-Authored-By: Claude Opus 5.5 <[email protected]>
    
    ---------
    
    Co-authored-by: Claude Opus 5.5 <[email protected]>
---
 .../modules/ROOT/pages/camel-jbang-mcp.adoc        |  11 +-
 .../dsl/jbang/core/commands/mcp/MigrationData.java | 499 +++++++++++++++++----
 .../jbang/core/commands/mcp/MigrationTools.java    |  57 ++-
 .../jbang/core/commands/mcp/PromptDefinitions.java |   1 +
 .../core/commands/mcp/MigrationDataSearchTest.java |  92 +++-
 .../core/commands/mcp/MigrationToolsTest.java      |  29 ++
 6 files changed, 566 insertions(+), 123 deletions(-)

diff --git a/docs/user-manual/modules/ROOT/pages/camel-jbang-mcp.adoc 
b/docs/user-manual/modules/ROOT/pages/camel-jbang-mcp.adoc
index f8e1b9bac1b5..95f4d04e752a 100644
--- a/docs/user-manual/modules/ROOT/pages/camel-jbang-mcp.adoc
+++ b/docs/user-manual/modules/ROOT/pages/camel-jbang-mcp.adoc
@@ -701,10 +701,13 @@ help validate, scaffold, and provide mock guidance for 
that workflow.
   and transform the code.
 
 | `camel_migration_guide_search`
-| Searches Camel migration and upgrade guides for a specific term or component 
name. Returns matching snippets
-  from the official guides with version info and URLs. Supports fuzzy matching 
for typo tolerance. Use this
-  instead of web search when looking up migration-related changes, removed 
components, API renames, or breaking
-  changes.
+| Searches Camel migration and upgrade guides for a specific term or component 
name. Each result is one topic
+  entry of a release (e.g. `Upgrading Camel 4.20 to 4.21 / camel-cxf`) with 
the lines around the best match,
+  version info and URL. Results are ranked by relevance with typo tolerance, 
generic words such as camel, migration
+  or upgrade are ignored, and no results are returned when nothing is 
relevant. The optional `currentVersion` and
+  `targetVersion` arguments (e.g. `2.25` and `4.22`) restrict the search to 
the guides of that migration and rank
+  the 2.x to 3.0 and 3.x to 4.0 migration guides higher. Use this instead of 
web search when looking up
+  migration-related changes, removed components, API renames, or breaking 
changes.
 
 | `camel_migration_wildfly_karaf`
 | Provides migration guidance for Camel projects running on WildFly, Karaf, or 
WAR-based application servers.
diff --git 
a/dsl/camel-jbang/camel-jbang-mcp/src/main/java/org/apache/camel/dsl/jbang/core/commands/mcp/MigrationData.java
 
b/dsl/camel-jbang/camel-jbang-mcp/src/main/java/org/apache/camel/dsl/jbang/core/commands/mcp/MigrationData.java
index d72bfe30d3ed..7d1230d9cdcd 100644
--- 
a/dsl/camel-jbang/camel-jbang-mcp/src/main/java/org/apache/camel/dsl/jbang/core/commands/mcp/MigrationData.java
+++ 
b/dsl/camel-jbang/camel-jbang-mcp/src/main/java/org/apache/camel/dsl/jbang/core/commands/mcp/MigrationData.java
@@ -25,8 +25,15 @@ import java.nio.charset.StandardCharsets;
 import java.util.ArrayList;
 import java.util.Arrays;
 import java.util.Comparator;
+import java.util.HashMap;
+import java.util.HashSet;
+import java.util.LinkedHashSet;
 import java.util.List;
 import java.util.Locale;
+import java.util.Map;
+import java.util.Set;
+import java.util.regex.Matcher;
+import java.util.regex.Pattern;
 import java.util.stream.Collectors;
 
 import jakarta.enterprise.context.ApplicationScoped;
@@ -283,87 +290,319 @@ public class MigrationData {
     private static final String GUIDES_RESOURCE_DIR = "migration-guides/";
     private static final String GUIDES_INDEX_FILE = GUIDES_RESOURCE_DIR + 
"index.txt";
 
-    private volatile List<GuideSection> guideIndex;
+    /**
+     * Words dropped from search queries: they appear in nearly every 
migration guide entry (camel, migration, upgrade,
+     * version, guide) or carry no meaning on their own (to, from, the, ...), 
so they only add noise to the ranking.
+     */
+    static final Set<String> STOP_WORDS = Set.of(
+            "camel", "apache", "migration", "migrate", "migrating", "upgrade", 
"upgrading", "upgraded", "version",
+            "versions", "guide", "guides", "release", "change", "changes", 
"changed",
+            "a", "an", "and", "are", "as", "at", "be", "by", "do", "does", 
"for", "from", "how", "in", "is", "it", "my",
+            "of", "on", "or", "the", "this", "to", "what", "when", "which", 
"with");
+
+    /** BM25 term frequency saturation. */
+    private static final double BM25_K1 = 1.2;
+    /** BM25 length normalisation: 0 ignores the entry length, 1 fully 
normalises by it. */
+    private static final double BM25_B = 0.5;
+    /** Extra weight, in units of the term idf, when a query term appears in 
the entry heading. */
+    private static final double TITLE_BOOST = 1.5;
+    /** Weight of a part of an unknown hyphenated query term, e.g. http4 for 
camel-http4. */
+    private static final double PART_WEIGHT = 0.7;
+    /** Weight of a vocabulary term matched by Levenshtein distance instead of 
exactly (typo tolerance). */
+    private static final double FUZZY_WEIGHT = 0.7;
+    /** Weight of a vocabulary term that only contains the query term as a 
substring. */
+    private static final double SUBSTRING_WEIGHT = 0.3;
+    /** Multiplier when the whole query appears verbatim in the entry. */
+    private static final double PHRASE_BOOST = 1.25;
+    /**
+     * Multiplier for the entries of the 2.x to 3.0 and 3.x to 4.0 migration 
guides in a version-scoped search, so they
+     * rank above comparable upgrade guide entries without pushing out strong 
ones.
+     */
+    static final double MAJOR_GUIDE_BOOST = 1.85;
+    /** Entries scoring below this are considered unrelated to the query and 
are not returned. */
+    static final double MIN_SCORE = 2.5;
+
+    private volatile GuideIndex guideIndex;
 
     /**
-     * Get the guide index, loading lazily on first access.
+     * Get the guide index entries (one per {@code ===} topic of each release 
section), loading lazily on first access.
      */
-    public synchronized List<GuideSection> getGuideIndex() {
+    public List<GuideSection> getGuideIndex() {
+        return index().sections();
+    }
+
+    private synchronized GuideIndex index() {
         if (guideIndex == null) {
-            guideIndex = loadGuideSections();
+            guideIndex = buildIndex(loadGuideSections());
         }
         return guideIndex;
     }
 
     /**
-     * Search migration guides for sections matching the query using fuzzy 
matching.
-     * <p>
-     * Scoring: +10 if the full query appears verbatim in the section content. 
Per query token: +3 exact token match, +2
-     * fuzzy match (Levenshtein distance &le; max(1, minTokenLen/4)), +1 
substring fallback. Results are sorted by
-     * descending score.
+     * Search all migration guides, see {@link #search(String, String, String, 
int)}.
      */
     public List<GuideSection> searchGuides(String query, int maxResults) {
-        List<String> queryTokens = tokenize(query);
-        String lowerQuery = query.toLowerCase(Locale.ROOT);
+        return search(query, null, null, 
maxResults).stream().map(SearchHit::section).toList();
+    }
 
-        List<GuideSection> index = getGuideIndex();
-        List<ScoredSection> scored = new ArrayList<>();
+    /**
+     * Search the migration guides for the entries matching the query.
+     * <p>
+     * Each entry is one {@code ===} topic (usually one component) of one 
release section of a guide. Entries are ranked
+     * with BM25 over the query terms (stop words such as camel, migration or 
upgrade removed), with a bonus for terms
+     * in the entry heading. A query term that does not occur verbatim also 
matches vocabulary terms within a small
+     * Levenshtein distance (typos) or containing it, at a reduced weight. 
Entries matching less than half of the query
+     * terms or scoring below {@link #MIN_SCORE} are dropped, so an unrelated 
query returns no results.
+     * <p>
+     * When {@code currentVersion} and/or {@code targetVersion} are given, 
only the guides for the releases after
+     * {@code currentVersion} up to {@code targetVersion} are searched, and 
the entries of the major migration guides
+     * (2.x to 3.0, 3.x to 4.0) in that range get a higher score.
+     *
+     * @param  query                    the search query
+     * @param  currentVersion           the Camel version migrated from (e.g. 
2.25 or 3.20.1), or null
+     * @param  targetVersion            the Camel version migrated to (e.g. 
4.22), or null
+     * @param  maxResults               the maximum number of results
+     * @return                          the matching entries, best first
+     * @throws IllegalArgumentException if a version cannot be parsed or 
currentVersion is after targetVersion
+     */
+    public List<SearchHit> search(String query, String currentVersion, String 
targetVersion, int maxResults) {
+        int[] from = parseVersion(currentVersion, "currentVersion");
+        int[] to = parseVersion(targetVersion, "targetVersion");
+        if (from != null && to != null && compareMajorMinor(from, to) > 0) {
+            throw new IllegalArgumentException(
+                    "currentVersion " + currentVersion + " is after 
targetVersion " + targetVersion);
+        }
+        boolean ranged = from != null || to != null;
 
-        for (GuideSection section : index) {
-            double score = scoreSection(section, queryTokens, lowerQuery);
-            if (score > 0) {
-                scored.add(new ScoredSection(section, score));
-            }
+        List<String> queryTerms = queryTerms(query);
+        if (queryTerms.isEmpty() || maxResults <= 0) {
+            return List.of();
         }
 
-        
scored.sort(Comparator.comparingDouble(ScoredSection::score).reversed());
+        GuideIndex index = index();
+        List<Map<String, Double>> expansions = new ArrayList<>();
+        for (String term : queryTerms) {
+            expansions.add(expandTerm(term, index));
+        }
+        String phrase = query.toLowerCase(Locale.ROOT).trim();
 
-        return scored.stream()
-                .limit(maxResults)
-                .map(ScoredSection::section)
-                .collect(Collectors.toList());
+        List<SearchHit> hits = new ArrayList<>();
+        for (GuideSection section : index.sections()) {
+            if (ranged && !inRange(section, from, to)) {
+                continue;
+            }
+            Set<String> matched = new LinkedHashSet<>();
+            double score = 0;
+            int matchedTerms = 0;
+            for (Map<String, Double> expansion : expansions) {
+                double best = 0;
+                String bestTerm = null;
+                for (Map.Entry<String, Double> variant : expansion.entrySet()) 
{
+                    double s = scoreTerm(variant.getKey(), section, index) * 
variant.getValue();
+                    if (s > best) {
+                        best = s;
+                        bestTerm = variant.getKey();
+                    }
+                }
+                if (bestTerm != null) {
+                    matched.add(bestTerm);
+                    matchedTerms++;
+                    score += best;
+                }
+            }
+            if (matchedTerms == 0 || matchedTerms * 2 < expansions.size()) {
+                // an entry matching less than half of the query is about 
something else
+                continue;
+            }
+            // favour entries covering more of the query
+            score *= 0.5 + 0.5 * matchedTerms / expansions.size();
+            if (queryTerms.size() > 1 && 
section.content().toLowerCase(Locale.ROOT).contains(phrase)) {
+                score *= PHRASE_BOOST;
+            }
+            if (ranged && section.majorMigration()) {
+                // migrating across a major version: favour the 2.x to 3.0 and 
3.x to 4.0 migration guides
+                score *= MAJOR_GUIDE_BOOST;
+            }
+            if (score >= MIN_SCORE) {
+                hits.add(new SearchHit(section, score, List.copyOf(matched)));
+            }
+        }
+
+        hits.sort(Comparator.comparingDouble(SearchHit::score).reversed());
+        return hits.size() > maxResults ? List.copyOf(hits.subList(0, 
maxResults)) : hits;
     }
 
-    private double scoreSection(GuideSection section, List<String> 
queryTokens, String lowerQuery) {
-        double score = 0;
-        String lowerContent = section.content().toLowerCase(Locale.ROOT);
+    /**
+     * Return the lines of the entry around its best match: the line 
containing the most (idf weighted) matched terms,
+     * with some lines of context before it, and at most {@code maxLines} 
lines.
+     */
+    public String snippet(SearchHit hit, int maxLines) {
+        String[] lines = hit.section().content().split("\n");
+        if (lines.length <= maxLines) {
+            return hit.section().content();
+        }
+        GuideIndex index = index();
+        int bestLine = 0;
+        double bestScore = 0;
+        for (int i = 0; i < lines.length; i++) {
+            Set<String> lineTerms = new HashSet<>(tokenize(lines[i]));
+            double s = 0;
+            for (String term : hit.matchedTerms()) {
+                if (lineTerms.contains(term)) {
+                    s += idf(term, index);
+                }
+            }
+            if (s > bestScore) {
+                bestScore = s;
+                bestLine = i;
+            }
+        }
+        int start = Math.max(0, bestLine - maxLines / 4);
+        int end = Math.min(lines.length, start + maxLines);
+        start = Math.max(0, end - maxLines);
 
-        // Bonus for exact substring match of the full query
-        if (lowerContent.contains(lowerQuery)) {
-            score += 10;
+        StringBuilder sb = new StringBuilder();
+        if (start > 0) {
+            sb.append("... (").append(start).append(" lines above)\n");
+        }
+        for (int i = start; i < end; i++) {
+            sb.append(lines[i]).append("\n");
         }
+        if (end < lines.length) {
+            sb.append("... (truncated, ").append(lines.length - end).append(" 
more lines)");
+        }
+        return sb.toString();
+    }
 
-        // Token-level scoring
-        for (String qt : queryTokens) {
-            boolean exactFound = false;
-            boolean fuzzyFound = false;
-            boolean substringFound = false;
+    private static double scoreTerm(String term, GuideSection section, 
GuideIndex index) {
+        Integer tf = section.termFrequencies().get(term);
+        if (tf == null) {
+            return 0;
+        }
+        double idf = idf(term, index);
+        double norm = BM25_K1 * (1 - BM25_B + BM25_B * section.length() / 
index.averageLength());
+        double score = idf * tf * (BM25_K1 + 1) / (tf + norm);
+        if (section.titleTerms().contains(term)) {
+            score += idf * TITLE_BOOST;
+        }
+        return score;
+    }
+
+    private static double idf(String term, GuideIndex index) {
+        int df = index.documentFrequencies().getOrDefault(term, 0);
+        int n = index.sections().size();
+        return Math.log(1 + (n - df + 0.5) / (df + 0.5));
+    }
 
-            for (String dt : section.tokens()) {
-                if (dt.equals(qt)) {
-                    exactFound = true;
-                    break;
+    /**
+     * The vocabulary terms a query term matches, with their weight: the term 
itself and the terms containing it. When
+     * the term itself does not occur in the guides, also the parts of a 
hyphenated term (http4 for camel-http4) or else
+     * the terms within a small Levenshtein distance (typos, e.g. directvm for 
direct-vm).
+     */
+    private static Map<String, Double> expandTerm(String term, GuideIndex 
index) {
+        Set<String> vocabulary = index.documentFrequencies().keySet();
+        Map<String, Double> variants = new HashMap<>();
+        if (vocabulary.contains(term)) {
+            variants.put(term, 1.0);
+        }
+        if (variants.isEmpty() && term.indexOf('-') > 0) {
+            for (String part : term.split("-+")) {
+                if (part.length() > 1 && !STOP_WORDS.contains(part) && 
vocabulary.contains(part)) {
+                    variants.putIfAbsent(part, PART_WEIGHT);
                 }
-                int maxDist = Math.max(1, Math.min(qt.length(), dt.length()) / 
4);
-                if (levenshteinDistance(qt, dt) <= maxDist) {
-                    fuzzyFound = true;
+            }
+        }
+        if (term.length() >= 4) {
+            boolean known = !variants.isEmpty();
+            for (String candidate : vocabulary) {
+                if (candidate.equals(term)) {
+                    continue;
+                }
+                int maxDist = Math.max(1, Math.min(term.length(), 
candidate.length()) / 4);
+                if (!known && Math.abs(candidate.length() - term.length()) <= 
maxDist
+                        && levenshteinDistance(term, candidate) <= maxDist) {
+                    variants.putIfAbsent(candidate, FUZZY_WEIGHT);
+                } else if (candidate.contains(term)) {
+                    variants.putIfAbsent(candidate, SUBSTRING_WEIGHT);
                 }
             }
+        }
+        return variants;
+    }
 
-            if (!exactFound && !fuzzyFound && lowerContent.contains(qt)) {
-                substringFound = true;
+    /**
+     * The distinct search terms of a query: its words without stop words and 
bare numbers. A hyphenated word stays one
+     * term, its parts are matched as variants of it.
+     */
+    static List<String> queryTerms(String query) {
+        if (query == null) {
+            return List.of();
+        }
+        Set<String> terms = new LinkedHashSet<>();
+        for (String token : words(query)) {
+            if (!STOP_WORDS.contains(token) && 
!token.chars().allMatch(Character::isDigit)) {
+                terms.add(token);
             }
+        }
+        return new ArrayList<>(terms);
+    }
 
-            if (exactFound) {
-                score += 3;
-            } else if (fuzzyFound) {
-                score += 2;
-            } else if (substringFound) {
-                score += 1;
+    private static boolean inRange(GuideSection section, int[] from, int[] to) 
{
+        int[] version;
+        try {
+            version = parseVersion(section.version(), "guide version");
+        } catch (IllegalArgumentException e) {
+            // skip a guide whose version cannot be read rather than failing 
the whole search
+            return false;
+        }
+        if (version == null) {
+            return false;
+        }
+        if (from != null) {
+            int cmp = compareMajorMinor(version, from);
+            // the guide of the starting release only matters for its patch 
releases
+            if (cmp < 0 || cmp == 0 && from[2] == 0) {
+                return false;
             }
         }
+        return to == null || compareMajorMinor(version, to) <= 0;
+    }
 
-        return score;
+    private static int compareMajorMinor(int[] a, int[] b) {
+        return a[0] != b[0] ? Integer.compare(a[0], b[0]) : 
Integer.compare(a[1], b[1]);
+    }
+
+    /**
+     * Parse a version such as 2.25, 3.20.1, 4.22.1-SNAPSHOT or 4 into {major, 
minor, patch}, or null when blank.
+     */
+    static int[] parseVersion(String version, String name) {
+        if (version == null || version.isBlank()) {
+            return null;
+        }
+        Matcher m = VERSION_PATTERN.matcher(version.trim());
+        if (!m.lookingAt()) {
+            throw new IllegalArgumentException(
+                    name + " must be a Camel version such as 2.25 or 4.22.1, 
got: " + version);
+        }
+        return new int[] {
+                Integer.parseInt(m.group(1)),
+                m.group(2) != null ? Integer.parseInt(m.group(2)) : 0,
+                m.group(3) != null ? Integer.parseInt(m.group(3)) : 0 };
+    }
+
+    private static final Pattern VERSION_PATTERN = 
Pattern.compile("(\\d+)(?:\\.(\\d+))?(?:\\.(\\d+))?");
+
+    private static GuideIndex buildIndex(List<GuideSection> sections) {
+        Map<String, Integer> df = new HashMap<>();
+        long totalLength = 0;
+        for (GuideSection section : sections) {
+            for (String term : section.termFrequencies().keySet()) {
+                df.merge(term, 1, Integer::sum);
+            }
+            totalLength += section.length();
+        }
+        double avg = sections.isEmpty() ? 1 : Math.max(1, (double) totalLength 
/ sections.size());
+        return new GuideIndex(List.copyOf(sections), Map.copyOf(df), avg);
     }
 
     private List<GuideSection> loadGuideSections() {
@@ -394,11 +633,7 @@ public class MigrationData {
             }
             try (BufferedReader reader = new BufferedReader(new 
InputStreamReader(is, StandardCharsets.UTF_8))) {
                 String content = 
reader.lines().collect(Collectors.joining("\n"));
-                String guideName = guideNameFromFilename(filename);
-                String version = versionFromFilename(filename);
-                String url = urlFromFilename(filename);
-
-                splitIntoSections(content, guideName, version, url, sections);
+                splitIntoSections(content, filename, sections);
             } catch (IOException e) {
                 // skip unreadable files
             }
@@ -407,62 +642,130 @@ public class MigrationData {
         return sections;
     }
 
-    private void splitIntoSections(
-            String content, String guideName, String version, String url,
-            List<GuideSection> sections) {
-        String[] lines = content.split("\n");
-        StringBuilder currentContent = new StringBuilder();
-        String currentTitle = guideName;
-
-        for (String line : lines) {
-            if (line.startsWith("== ") && !line.startsWith("===")) {
-                // Flush previous section
-                if (currentContent.length() > 0) {
-                    String sectionText = currentContent.toString().trim();
-                    if (!sectionText.isEmpty()) {
-                        sections.add(new GuideSection(
-                                guideName, version, currentTitle, sectionText, 
url,
-                                tokenize(sectionText)));
-                    }
-                }
-                currentTitle = line.substring(3).trim();
-                currentContent = new StringBuilder();
-            } else {
-                currentContent.append(line).append("\n");
+    /**
+     * Split a guide into one entry per {@code ===} topic. The {@code ==} 
release heading is kept as context in the
+     * entry title (e.g. "Upgrading Camel 4.20 to 4.21 / camel-cxf"); text 
between a release heading and its first topic
+     * becomes an entry titled with the release heading alone. Deeper headings 
stay inside their topic, and headings
+     * inside listing blocks are ignored.
+     */
+    static void splitIntoSections(String content, String filename, 
List<GuideSection> sections) {
+        String guideName = guideNameFromFilename(filename);
+        String version = versionFromFilename(filename);
+        String url = urlFromFilename(filename);
+        boolean major = isMajorMigrationGuide(filename);
+
+        StringBuilder current = new StringBuilder();
+        String release = null;
+        String topic = null;
+        String delimiter = null;
+
+        for (String line : content.split("\n")) {
+            if (delimiter == null && (line.equals("----") || 
line.equals("....") || line.equals("===="))) {
+                delimiter = line;
+            } else if (line.equals(delimiter)) {
+                delimiter = null;
+            } else if (delimiter == null && line.startsWith("== ")) {
+                flush(guideName, version, url, major, title(guideName, 
release, topic), current, sections);
+                release = line.substring(3).trim();
+                topic = null;
+                continue;
+            } else if (delimiter == null && line.startsWith("=== ")) {
+                flush(guideName, version, url, major, title(guideName, 
release, topic), current, sections);
+                topic = line.substring(4).trim();
+                continue;
             }
+            current.append(line).append("\n");
         }
+        flush(guideName, version, url, major, title(guideName, release, 
topic), current, sections);
+    }
 
-        // Flush last section
-        if (currentContent.length() > 0) {
-            String sectionText = currentContent.toString().trim();
-            if (!sectionText.isEmpty()) {
-                sections.add(new GuideSection(
-                        guideName, version, currentTitle, sectionText, url,
-                        tokenize(sectionText)));
-            }
+    private static String title(String guideName, String release, String 
topic) {
+        if (release == null) {
+            return topic != null ? topic : guideName;
+        }
+        return topic != null ? release + " / " + topic : release;
+    }
+
+    private static void flush(
+            String guideName, String version, String url, boolean major, 
String title,
+            StringBuilder current, List<GuideSection> sections) {
+        String text = current.toString().trim();
+        current.setLength(0);
+        if (text.isEmpty() || text.startsWith(":") && text.lines().allMatch(l 
-> l.isBlank() || l.startsWith(":"))) {
+            // nothing but blank lines or document attributes
+            return;
+        }
+        List<String> tokens = tokenize(text);
+        // only the topic part of the title gets the heading boost; the 
release heading is shared by many entries
+        String topic = title.contains(" / ") ? 
title.substring(title.lastIndexOf(" / ") + 3) : title;
+        Set<String> titleTerms = new HashSet<>(tokenize(topic));
+        Map<String, Integer> tf = new HashMap<>();
+        for (String token : tokens) {
+            tf.merge(token, 1, Integer::sum);
+        }
+        for (String token : titleTerms) {
+            tf.merge(token, 1, Integer::sum);
         }
+        sections.add(new GuideSection(
+                guideName, version, title, text, url, major, Map.copyOf(tf), 
Set.copyOf(titleTerms),
+                tokens.size() + titleTerms.size()));
     }
 
     /**
-     * Tokenize text into lowercase words, preserving hyphens within words 
(e.g., direct-vm).
+     * Tokenize text into lowercase words, preserving hyphens within words 
(e.g., direct-vm). A hyphenated word also
+     * yields its parts, so cxf matches camel-cxf.
      */
     static List<String> tokenize(String text) {
-        String[] parts = 
text.toLowerCase(Locale.ROOT).split("[^a-zA-Z0-9\\-]+");
         List<String> tokens = new ArrayList<>();
-        for (String part : parts) {
-            if (!part.isBlank() && part.length() > 1) {
-                tokens.add(part);
+        for (String word : words(text)) {
+            tokens.add(word);
+            if (word.indexOf('-') > 0) {
+                for (String sub : word.split("-+")) {
+                    if (sub.length() > 1) {
+                        tokens.add(sub);
+                    }
+                }
             }
         }
         return tokens;
     }
 
+    /**
+     * Split text into lowercase words of at least two characters, keeping 
hyphenated words whole.
+     */
+    static List<String> words(String text) {
+        List<String> words = new ArrayList<>();
+        for (String part : 
text.toLowerCase(Locale.ROOT).split("[^a-z0-9\\-]+")) {
+            String word = trimHyphens(part);
+            if (word.length() > 1) {
+                words.add(word);
+            }
+        }
+        return words;
+    }
+
+    private static String trimHyphens(String word) {
+        int start = 0;
+        int end = word.length();
+        while (start < end && word.charAt(start) == '-') {
+            start++;
+        }
+        while (end > start && word.charAt(end - 1) == '-') {
+            end--;
+        }
+        return word.substring(start, end);
+    }
+
     private static final LevenshteinDistance LEVENSHTEIN = 
LevenshteinDistance.getDefaultInstance();
 
     static int levenshteinDistance(String a, String b) {
         return LEVENSHTEIN.apply(a, b);
     }
 
+    private static boolean isMajorMigrationGuide(String filename) {
+        return filename.matches("camel-\\d+-migration-guide\\.adoc");
+    }
+
     private static String guideNameFromFilename(String filename) {
         String name = filename.replace(".adoc", "");
         if ("camel-3-migration-guide".equals(name)) {
@@ -500,16 +803,28 @@ public class MigrationData {
     public record MigrationGuide(String name, String title, String url, String 
summary) {
     }
 
+    /**
+     * One searchable entry: a {@code ===} topic of a release section of a 
guide.
+     */
     public record GuideSection(
             String guide,
             String version,
             String sectionTitle,
             String content,
             String url,
-            List<String> tokens) {
+            boolean majorMigration,
+            Map<String, Integer> termFrequencies,
+            Set<String> titleTerms,
+            int length) {
+    }
+
+    /**
+     * A search result: the entry, its score and the vocabulary terms that 
matched the query.
+     */
+    public record SearchHit(GuideSection section, double score, List<String> 
matchedTerms) {
     }
 
-    private record ScoredSection(GuideSection section, double score) {
+    private record GuideIndex(List<GuideSection> sections, Map<String, 
Integer> documentFrequencies, double averageLength) {
     }
 
     public record PomAnalysis(
diff --git 
a/dsl/camel-jbang/camel-jbang-mcp/src/main/java/org/apache/camel/dsl/jbang/core/commands/mcp/MigrationTools.java
 
b/dsl/camel-jbang/camel-jbang-mcp/src/main/java/org/apache/camel/dsl/jbang/core/commands/mcp/MigrationTools.java
index eb4b15e933d6..e5fe38144c71 100644
--- 
a/dsl/camel-jbang/camel-jbang-mcp/src/main/java/org/apache/camel/dsl/jbang/core/commands/mcp/MigrationTools.java
+++ 
b/dsl/camel-jbang/camel-jbang-mcp/src/main/java/org/apache/camel/dsl/jbang/core/commands/mcp/MigrationTools.java
@@ -41,6 +41,7 @@ public class MigrationTools {
     private static final String OPENREWRITE_VERSION = "6.29.0";
     private static final String CAMEL_UPGRADE_RECIPES_ARTIFACT = 
"camel-upgrade-recipes";
     private static final String CAMEL_SPRING_BOOT_UPGRADE_RECIPES_ARTIFACT = 
"camel-spring-boot-upgrade-recipes";
+    private static final int SNIPPET_LINES = 30;
 
     @Inject
     MigrationData migrationData;
@@ -289,53 +290,61 @@ public class MigrationTools {
     }
 
     /**
-     * Search migration guides for a specific term.
+     * Search migration guides for a specific term, optionally scoped to the 
versions being migrated between.
      */
     @Tool(annotations = @Tool.Annotations(readOnlyHint = true, destructiveHint 
= false, openWorldHint = false),
           description = "Search Camel migration and upgrade guides for a 
specific term or component name. "
-                        + "Returns matching snippets from the official guides 
with version info and URLs. "
-                        + "Supports fuzzy matching for typo tolerance. "
+                        + "Each result is one topic entry of a release (e.g. 
'Upgrading Camel 4.20 to 4.21 / camel-cxf') "
+                        + "with the lines around the best match, version info 
and URL. "
+                        + "Pass currentVersion/targetVersion (e.g. 2.25 and 
4.22) to search only the guides for that "
+                        + "migration, with the 2.x to 3.0 and 3.x to 4.0 
migration guides ranked higher. "
+                        + "Results are ranked by relevance with typo 
tolerance; generic words such as camel, "
+                        + "migration or upgrade are ignored, and no results 
are returned when nothing is relevant. "
                         + "Use this instead of web search when looking up 
migration-related changes, "
                         + "removed components, API renames, or breaking 
changes.")
     public GuideSearchResult camel_migration_guide_search(
             @ToolArg(description = "Search query — component name, API class, 
method, or keyword "
-                                   + "(e.g., direct-vm, getOut, camel-http4, 
ExchangePattern)") String query,
-            @ToolArg(description = "Maximum number of results to return 
(default: 3)", required = false) Integer limit) {
+                                   + "(e.g., direct-vm, getOut, camel-http4, 
cxf servlet, camel-mail subject header)") String query,
+            @ToolArg(description = "Maximum number of results to return 
(default: 3)", required = false) Integer limit,
+            @ToolArg(description = "Current Camel version, migrating from 
(e.g., 2.25 or 3.20.1). "
+                                   + "Guides for releases up to this version 
are omitted.",
+                     required = false) String currentVersion,
+            @ToolArg(description = "Target Camel version, migrating to (e.g., 
4.22). "
+                                   + "Guides for releases after this version 
are omitted.",
+                     required = false) String targetVersion) {
 
         if (query == null || query.isBlank()) {
             throw new ToolCallException("query is required", null);
         }
 
         int maxResults = limit != null && limit > 0 ? limit : 3;
-        List<MigrationData.GuideSection> matches = 
migrationData.searchGuides(query, maxResults);
+        List<MigrationData.SearchHit> hits;
+        try {
+            hits = migrationData.search(query, currentVersion, targetVersion, 
maxResults);
+        } catch (IllegalArgumentException e) {
+            throw new ToolCallException(e.getMessage(), e);
+        }
 
         List<GuideSnippet> snippets = new ArrayList<>();
-        for (MigrationData.GuideSection section : matches) {
-            // Truncate content to avoid huge responses
-            String snippet = truncateSnippet(section.content(), 30);
+        for (MigrationData.SearchHit hit : hits) {
+            MigrationData.GuideSection section = hit.section();
             snippets.add(new GuideSnippet(
                     section.guide(), section.version(), section.sectionTitle(),
-                    snippet, section.url()));
+                    migrationData.snippet(hit, SNIPPET_LINES), section.url()));
         }
 
-        String nextStep = "Call camel_migration_guide_search again for other 
terms, or call camel_migration_recipes.";
+        String nextStep;
+        if (snippets.isEmpty()) {
+            nextStep = "No migration guide entry is relevant to this query. 
Rephrase it with a component name, "
+                       + "option or API class (e.g. camel-cxf, getOut), widen 
or drop currentVersion/targetVersion, "
+                       + "or ask camel_catalog_doc for the component's current 
documentation.";
+        } else {
+            nextStep = "Call camel_migration_guide_search again for other 
terms, or call camel_migration_recipes.";
+        }
 
         return new GuideSearchResult(query, snippets.size(), snippets, 
nextStep);
     }
 
-    private String truncateSnippet(String content, int maxLines) {
-        String[] lines = content.split("\n");
-        if (lines.length <= maxLines) {
-            return content;
-        }
-        StringBuilder sb = new StringBuilder();
-        for (int i = 0; i < maxLines; i++) {
-            sb.append(lines[i]).append("\n");
-        }
-        sb.append("... (truncated, ").append(lines.length - maxLines).append(" 
more lines)");
-        return sb.toString();
-    }
-
     // Helpers
 
     private int parseMajorVersion(String version) {
diff --git 
a/dsl/camel-jbang/camel-jbang-mcp/src/main/java/org/apache/camel/dsl/jbang/core/commands/mcp/PromptDefinitions.java
 
b/dsl/camel-jbang/camel-jbang-mcp/src/main/java/org/apache/camel/dsl/jbang/core/commands/mcp/PromptDefinitions.java
index e3d5c36922a2..bb8b13d151de 100644
--- 
a/dsl/camel-jbang/camel-jbang-mcp/src/main/java/org/apache/camel/dsl/jbang/core/commands/mcp/PromptDefinitions.java
+++ 
b/dsl/camel-jbang/camel-jbang-mcp/src/main/java/org/apache/camel/dsl/jbang/core/commands/mcp/PromptDefinitions.java
@@ -137,6 +137,7 @@ public class PromptDefinitions {
                         4. **Recipes**: call `camel_migration_recipes` with 
runtime, versions, Java version, and `dryRun=true` \
                         to get the OpenRewrite Maven commands.
                         5. **Breaking changes**: for each detected component, 
call `camel_migration_guide_search` \
+                        with the current and target Camel versions as 
`currentVersion` and `targetVersion` \
                         to find renames and API changes.
                         6. **Summary**: report current state, target state, 
blockers, breaking changes, \
                         migration commands, and manual steps that OpenRewrite 
cannot automate.
diff --git 
a/dsl/camel-jbang/camel-jbang-mcp/src/test/java/org/apache/camel/dsl/jbang/core/commands/mcp/MigrationDataSearchTest.java
 
b/dsl/camel-jbang/camel-jbang-mcp/src/test/java/org/apache/camel/dsl/jbang/core/commands/mcp/MigrationDataSearchTest.java
index a3d2d637d9c6..5a693d07bc41 100644
--- 
a/dsl/camel-jbang/camel-jbang-mcp/src/test/java/org/apache/camel/dsl/jbang/core/commands/mcp/MigrationDataSearchTest.java
+++ 
b/dsl/camel-jbang/camel-jbang-mcp/src/test/java/org/apache/camel/dsl/jbang/core/commands/mcp/MigrationDataSearchTest.java
@@ -21,10 +21,11 @@ import java.util.List;
 import org.junit.jupiter.api.Test;
 
 import static org.assertj.core.api.Assertions.assertThat;
+import static org.assertj.core.api.Assertions.assertThatThrownBy;
 
 /**
  * Tests that the migration guide fuzzy search works end-to-end: loads the 
real .adoc guide files from the classpath,
- * indexes them, and returns relevant results for exact, fuzzy, and nonsense 
queries.
+ * indexes them per topic, and returns relevant results for exact, fuzzy, 
version-scoped and nonsense queries.
  */
 class MigrationDataSearchTest {
 
@@ -55,10 +56,95 @@ class MigrationDataSearchTest {
         assertThat(results).isEmpty();
     }
 
+    @Test
+    void unrelatedOrStopWordsOnlyQueryReturnsNoResults() {
+        // "smoothie" is within typo distance of "smoothing", but an entry 
matching a third of the query is unrelated
+        assertThat(migrationData.searchGuides("banana smoothie recipe", 
3)).isEmpty();
+        assertThat(migrationData.searchGuides("camel migration upgrade guide", 
3)).isEmpty();
+    }
+
+    @Test
+    void entriesAreTopicsWithTheReleaseHeadingAsContext() {
+        assertThat(migrationData.getGuideIndex())
+                .anyMatch(s -> s.sectionTitle().equals("Upgrading Camel 4.20 
to 4.21 / camel-cxf"))
+                .allMatch(s -> s.content().lines().noneMatch(l -> 
l.startsWith("== ") || l.startsWith("=== ")));
+    }
+
+    @Test
+    void cxfServletFindsCamelCxfEntry() {
+        List<MigrationData.GuideSection> results = 
migrationData.searchGuides("cxf servlet", 3);
+
+        assertThat(results).isNotEmpty();
+        assertThat(results.get(0).sectionTitle()).endsWith("/ camel-cxf");
+        assertThat(results.get(0).content()).contains("camel-servlet");
+    }
+
+    @Test
+    void camelMailSubjectHeaderFindsCamelMailEntry() {
+        List<MigrationData.GuideSection> results = 
migrationData.searchGuides("camel-mail subject header", 3);
+
+        assertThat(results).isNotEmpty();
+        assertThat(results.get(0).sectionTitle()).contains("/ camel-mail");
+        assertThat(results.get(0).content()).contains("MAIL_SUBJECT");
+    }
+
+    @Test
+    void versionRangeFavoursMajorMigrationGuides() {
+        List<MigrationData.SearchHit> hits
+                = migrationData.search("CXF SOAP web service migration Camel 2 
to 4", "2.25", "4.22", 5);
+
+        assertThat(hits).isNotEmpty();
+        assertThat(hits.get(0).section().guide()).isEqualTo("Camel 3 Migration 
Guide (2.x to 3.0)");
+        assertThat(hits.get(0).section().sectionTitle()).startsWith("Migrating 
Camel applications");
+        assertThat(hits.get(0).section().content()).contains("camel-cxf");
+    }
+
+    @Test
+    void versionRangeKeepsStrongUpgradeGuideEntries() {
+        // the major guide bonus is a multiplier, not a partition: exact topic 
entries of upgrade guides stay on top
+        List<MigrationData.SearchHit> hits = migrationData.search("cxf 
servlet", "2.25", "4.22", 3);
+
+        assertThat(hits).hasSize(3);
+        assertThat(hits).extracting(h -> 
h.section().sectionTitle()).anyMatch(t -> t.endsWith("/ camel-cxf"));
+    }
+
+    @Test
+    void versionRangeOmitsGuidesOutsideTheRange() {
+        List<MigrationData.SearchHit> hits = migrationData.search("cxf 
servlet", "4.10", "4.22", 20);
+
+        assertThat(hits).isNotEmpty();
+        assertThat(hits).extracting(h -> h.section().version())
+                .allMatch(v -> v.startsWith("4.") && 
Integer.parseInt(v.substring(2)) > 10
+                        && Integer.parseInt(v.substring(2)) <= 22);
+    }
+
+    @Test
+    void invalidVersionIsRejected() {
+        assertThatThrownBy(() -> migrationData.search("cxf", "four", null, 3))
+                .isInstanceOf(IllegalArgumentException.class)
+                .hasMessageContaining("currentVersion");
+        assertThatThrownBy(() -> migrationData.search("cxf", "4.22", "3.20", 
3))
+                .isInstanceOf(IllegalArgumentException.class)
+                .hasMessageContaining("is after targetVersion");
+    }
+
+    @Test
+    void snippetShowsTheLinesAroundTheMatch() {
+        List<MigrationData.SearchHit> hits = migrationData.search("camel-mail 
subject header", null, null, 1);
+
+        assertThat(hits).hasSize(1);
+        String content = hits.get(0).section().content();
+        String snippet = migrationData.snippet(hits.get(0), 10);
+
+        assertThat(content.lines().count()).isGreaterThan(10);
+        assertThat(content.lines().limit(10)).noneMatch(l -> 
l.contains("MAIL_SUBJECT"));
+        assertThat(snippet).contains("MAIL_SUBJECT").contains("lines above");
+    }
+
     @Test
     void searchRespectsLimit() {
-        List<MigrationData.GuideSection> results = 
migrationData.searchGuides("camel", 2);
+        List<MigrationData.GuideSection> results = 
migrationData.searchGuides("cxf", 2);
 
-        assertThat(results).hasSizeLessThanOrEqualTo(2);
+        assertThat(results).hasSize(2);
     }
 }
diff --git 
a/dsl/camel-jbang/camel-jbang-mcp/src/test/java/org/apache/camel/dsl/jbang/core/commands/mcp/MigrationToolsTest.java
 
b/dsl/camel-jbang/camel-jbang-mcp/src/test/java/org/apache/camel/dsl/jbang/core/commands/mcp/MigrationToolsTest.java
index ed801bae50fd..9142dd302933 100644
--- 
a/dsl/camel-jbang/camel-jbang-mcp/src/test/java/org/apache/camel/dsl/jbang/core/commands/mcp/MigrationToolsTest.java
+++ 
b/dsl/camel-jbang/camel-jbang-mcp/src/test/java/org/apache/camel/dsl/jbang/core/commands/mcp/MigrationToolsTest.java
@@ -16,9 +16,11 @@
  */
 package org.apache.camel.dsl.jbang.core.commands.mcp;
 
+import io.quarkiverse.mcp.server.ToolCallException;
 import org.junit.jupiter.api.Test;
 
 import static org.assertj.core.api.Assertions.assertThat;
+import static org.assertj.core.api.Assertions.assertThatThrownBy;
 
 class MigrationToolsTest {
 
@@ -95,4 +97,31 @@ class MigrationToolsTest {
 
         assertThat(result.warnings()).noneMatch(w -> w.contains("Sensitive 
data detected"));
     }
+
+    // ---- Guide search ----
+
+    @Test
+    void guideSearchAnswersWithTopicEntrySnippetAndNextStep() {
+        MigrationTools.GuideSearchResult result = 
tools.camel_migration_guide_search(
+                "CXF SOAP web service migration Camel 2 to 4", 5, "2.25", 
"4.22");
+
+        assertThat(result.resultCount()).isPositive();
+        
assertThat(result.results().get(0).sectionTitle()).startsWith("Migrating Camel 
applications");
+        
assertThat(result.results().get(0).snippet()).isNotBlank().containsIgnoringCase("cxf");
+        
assertThat(result.results()).extracting(MigrationTools.GuideSnippet::version)
+                .noneMatch(v -> v.equals("4.23") || v.equals("4.24"));
+        assertThat(result.nextStep()).contains("camel_migration_recipes");
+    }
+
+    @Test
+    void guideSearchNoMatchHintAndInvalidVersion() {
+        MigrationTools.GuideSearchResult result
+                = tools.camel_migration_guide_search("xyznonexistent12345", 
null, null, null);
+
+        assertThat(result.resultCount()).isZero();
+        
assertThat(result.nextStep()).contains("Rephrase").contains("camel_catalog_doc");
+        assertThatThrownBy(() -> tools.camel_migration_guide_search("cxf", 3, 
"latest", null))
+                .isInstanceOf(ToolCallException.class)
+                .hasMessageContaining("currentVersion");
+    }
 }

Reply via email to