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 ≤ 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");
+ }
}