diff --git a/src/main/java/com/williamcallahan/javachat/application/search/JavaApiMethodSelector.java b/src/main/java/com/williamcallahan/javachat/application/search/JavaApiMethodSelector.java
index 058a29fe..adc81608 100644
--- a/src/main/java/com/williamcallahan/javachat/application/search/JavaApiMethodSelector.java
+++ b/src/main/java/com/williamcallahan/javachat/application/search/JavaApiMethodSelector.java
@@ -1,5 +1,6 @@
package com.williamcallahan.javachat.application.search;
+import com.williamcallahan.javachat.domain.javaapi.JavaPackageName;
import java.util.ArrayList;
import java.util.List;
import java.util.Objects;
@@ -94,25 +95,20 @@ public String typePageFileName() {
/**
* Determines whether a Javadoc URL path identifies this selector's declaring type.
*
- *
Unqualified selectors match the type filename in any package. Qualified selectors require
- * the exact package path immediately before that filename, preventing same-named JDK types in
- * different packages from receiving the same citation priority.
+ * Qualified selectors use their query package and ignore the candidate package. Unqualified
+ * selectors require a canonical Java package derived from the candidate source URL, which
+ * keeps package-relative API pages from being mistaken for canonical type pages.
*
* @param javadocPath decoded Javadoc URL path
- * @return true when the path names this selector's declaring type
+ * @param candidatePackageName package derived from the candidate source URL, or {@code null}
+ * when absent
+ * @return true when the path names this selector's declaring type in its expected package
*/
- public boolean matchesJavadocPath(String javadocPath) {
+ public boolean matchesJavadocPath(String javadocPath, String candidatePackageName) {
Objects.requireNonNull(javadocPath, "javadocPath");
- int filenameStartIndex = javadocPath.lastIndexOf('/') + 1;
- String candidateFilename = javadocPath.substring(filenameStartIndex);
- if (!typePageFileName().equals(candidateFilename)) {
- return false;
- }
- if (packageName.isBlank()) {
- return true;
- }
- String qualifiedPagePathSuffix = "/" + packageName.replace('.', '/') + "/" + typePageFileName();
- return javadocPath.endsWith(qualifiedPagePathSuffix);
+ return expectedPackageName(candidatePackageName)
+ .map(expectedPackageName -> matchesPackageTypePath(javadocPath, expectedPackageName))
+ .orElse(false);
}
/**
@@ -124,6 +120,18 @@ public String sparseQueryTerms() {
return typePageName;
}
+ private Optional expectedPackageName(String candidatePackageName) {
+ if (!packageName.isBlank()) {
+ return JavaPackageName.from(packageName);
+ }
+ return JavaPackageName.from(candidatePackageName);
+ }
+
+ private boolean matchesPackageTypePath(String javadocPath, JavaPackageName expectedPackageName) {
+ String expectedPagePath = "/" + expectedPackageName.javadocPath() + "/" + typePageFileName();
+ return javadocPath.endsWith(expectedPagePath);
+ }
+
private static ParsedQualifiedName parseQualifiedName(String query, int startIndex) {
List segments = new ArrayList<>();
int currentIndex = startIndex;
diff --git a/src/main/java/com/williamcallahan/javachat/domain/javaapi/JavaPackageName.java b/src/main/java/com/williamcallahan/javachat/domain/javaapi/JavaPackageName.java
new file mode 100644
index 00000000..c34b35c1
--- /dev/null
+++ b/src/main/java/com/williamcallahan/javachat/domain/javaapi/JavaPackageName.java
@@ -0,0 +1,54 @@
+package com.williamcallahan.javachat.domain.javaapi;
+
+import java.util.Optional;
+import javax.lang.model.SourceVersion;
+
+/**
+ * Represents an exact, syntactically valid Java 25 package name.
+ *
+ * The value preserves its source spelling so URL projections and query selectors share one
+ * package-identity boundary without trimming or accepting documentation-only path segments.
+ *
+ * @param qualifiedName dot-separated Java package name
+ */
+public record JavaPackageName(String qualifiedName) {
+
+ /**
+ * Enforces exact Java 25 package-name syntax for every constructed value.
+ *
+ * @throws IllegalArgumentException when the package name is null, blank, padded, or invalid
+ */
+ public JavaPackageName {
+ if (!isValid(qualifiedName)) {
+ throw new IllegalArgumentException("qualifiedName must be an exact Java 25 package name");
+ }
+ }
+
+ /**
+ * Creates a package name only when the candidate satisfies the Java 25 name grammar exactly.
+ *
+ * @param candidateQualifiedName candidate dot-separated package name
+ * @return validated package name, or empty when the candidate is absent or invalid
+ */
+ public static Optional from(String candidateQualifiedName) {
+ return isValid(candidateQualifiedName)
+ ? Optional.of(new JavaPackageName(candidateQualifiedName))
+ : Optional.empty();
+ }
+
+ /**
+ * Projects the package into the path syntax used by canonical Javadoc URLs.
+ *
+ * @return slash-separated package path without leading or trailing delimiters
+ */
+ public String javadocPath() {
+ return qualifiedName.replace('.', '/');
+ }
+
+ private static boolean isValid(String candidateQualifiedName) {
+ return candidateQualifiedName != null
+ && !candidateQualifiedName.isBlank()
+ && candidateQualifiedName.equals(candidateQualifiedName.trim())
+ && SourceVersion.isName(candidateQualifiedName, SourceVersion.RELEASE_25);
+ }
+}
diff --git a/src/main/java/com/williamcallahan/javachat/service/CitationCandidateRanker.java b/src/main/java/com/williamcallahan/javachat/service/CitationCandidateRanker.java
index 391d443f..1ec4dfa0 100644
--- a/src/main/java/com/williamcallahan/javachat/service/CitationCandidateRanker.java
+++ b/src/main/java/com/williamcallahan/javachat/service/CitationCandidateRanker.java
@@ -2,6 +2,7 @@
import com.williamcallahan.javachat.application.search.JavaApiMethodSelector;
import com.williamcallahan.javachat.config.DocsSourceRegistry;
+import com.williamcallahan.javachat.service.ingestion.JavaPackageExtractor;
import java.net.URI;
import java.util.ArrayList;
import java.util.Comparator;
@@ -86,7 +87,8 @@ private static boolean matchesTypePage(JavaApiMethodSelector selector, Document
if (documentPath == null || documentPath.isBlank()) {
return false;
}
- return selector.matchesJavadocPath(documentPath);
+ String candidatePackageName = JavaPackageExtractor.extractJavaApiPackage(sourceUrl);
+ return selector.matchesJavadocPath(documentPath, candidatePackageName);
}
private static boolean hasMethodDeclarationEvidence(JavaApiMethodSelector selector, Document citationCandidate) {
diff --git a/src/main/java/com/williamcallahan/javachat/service/RetrievalService.java b/src/main/java/com/williamcallahan/javachat/service/RetrievalService.java
index b389a06a..a6f409fc 100644
--- a/src/main/java/com/williamcallahan/javachat/service/RetrievalService.java
+++ b/src/main/java/com/williamcallahan/javachat/service/RetrievalService.java
@@ -4,6 +4,7 @@
import com.williamcallahan.javachat.config.DocsSourceRegistry;
import com.williamcallahan.javachat.config.ModelConfiguration;
import com.williamcallahan.javachat.model.Citation;
+import com.williamcallahan.javachat.service.ingestion.JavaPackageExtractor;
import com.williamcallahan.javachat.util.QueryVersionExtractor;
import com.williamcallahan.javachat.util.QueryVersionExtractor.VersionFilterPatterns;
import java.util.ArrayList;
@@ -415,10 +416,8 @@ public CitationOutcome toCitations(List documents) {
Map sourceDocMetadata = sourceDocument.getMetadata();
String rawUrl = stringMetadataValue(sourceDocMetadata, QdrantPayloadFieldSchema.URL_FIELD);
String title = stringMetadataValue(sourceDocMetadata, QdrantPayloadFieldSchema.TITLE_FIELD);
- String packageName = stringMetadataValue(sourceDocMetadata, QdrantPayloadFieldSchema.PACKAGE_FIELD);
String documentType = stringMetadataValue(sourceDocMetadata, QdrantPayloadFieldSchema.DOC_TYPE_FIELD);
- String refinedCitationUrl =
- refineCitationUrl(rawUrl, sourceDocument.getText(), packageName, documentType);
+ String refinedCitationUrl = refineCitationUrl(rawUrl, sourceDocument.getText(), documentType);
String citationIdentity = citationIdentityFor(rawUrl, refinedCitationUrl);
if (!citationIdentity.isBlank() && !retainedCitationIdentities.add(citationIdentity)) {
continue;
@@ -478,10 +477,11 @@ private static String citationIdentityFor(String rawUrl, String citationUrl) {
* Refines a raw document URL and gates Javadoc member anchors to {@code api-docs} metadata.
*
*/
- private String refineCitationUrl(String rawUrl, String documentText, String packageName, String documentType) {
+ private String refineCitationUrl(String rawUrl, String documentText, String documentType) {
String normalizedUrl = DocsSourceRegistry.normalizeDocUrl(rawUrl);
String citationUrl = normalizedUrl;
if (DOCUMENT_TYPE_API_DOCS.equals(documentType)) {
+ String packageName = JavaPackageExtractor.extractJavaApiPackage(normalizedUrl);
String nestedTypeRefinedUrl = com.williamcallahan.javachat.util.JavadocLinkResolver.refineNestedTypeUrl(
citationUrl, documentText);
citationUrl = com.williamcallahan.javachat.util.JavadocLinkResolver.refineMemberAnchorUrl(
diff --git a/src/main/java/com/williamcallahan/javachat/service/ingestion/JavaPackageExtractor.java b/src/main/java/com/williamcallahan/javachat/service/ingestion/JavaPackageExtractor.java
index 1edb882c..d5d0a619 100644
--- a/src/main/java/com/williamcallahan/javachat/service/ingestion/JavaPackageExtractor.java
+++ b/src/main/java/com/williamcallahan/javachat/service/ingestion/JavaPackageExtractor.java
@@ -2,6 +2,7 @@
import com.williamcallahan.javachat.config.DocsSourceRegistry;
import com.williamcallahan.javachat.config.DocsSourceRegistry.JavaApiDocumentationSource;
+import com.williamcallahan.javachat.domain.javaapi.JavaPackageName;
import com.williamcallahan.javachat.support.AsciiTextNormalizer;
import java.net.URI;
import java.net.URISyntaxException;
@@ -34,6 +35,25 @@ public static boolean isJavaApiUrl(String url) {
return findJavaApiSourceUrl(url).isPresent();
}
+ /**
+ * Derives the Java package encoded by a manifest-governed Java API source URL.
+ *
+ * URL consumers use this projection instead of persisted package metadata so package
+ * identity follows the canonical source path across ingestion generations.
+ *
+ * @param url source URL
+ * @return package encoded by the canonical Java API path, or an empty string when the URL is
+ * not a canonical Java API source or its path does not identify a package
+ */
+ public static String extractJavaApiPackage(String url) {
+ Objects.requireNonNull(url, "url");
+ return findJavaApiSourceUrl(url)
+ .map(JavaPackageExtractor::extractPackageFromJavaApiPath)
+ .flatMap(JavaPackageName::from)
+ .map(JavaPackageName::qualifiedName)
+ .orElse("");
+ }
+
/**
* Attempts to derive a package name from the URL and extracted page text.
*
@@ -45,9 +65,7 @@ public static String extractPackage(String url, String bodyText) {
Objects.requireNonNull(url, "url");
Objects.requireNonNull(bodyText, "bodyText");
- String pathDerivedPackageName = findJavaApiSourceUrl(url)
- .map(JavaPackageExtractor::extractPackageFromJavaApiPath)
- .orElse("");
+ String pathDerivedPackageName = extractJavaApiPackage(url);
if (!pathDerivedPackageName.isBlank()) {
return pathDerivedPackageName;
}
@@ -58,7 +76,10 @@ public static String extractPackage(String url, String bodyText) {
String snippet = bodyText.substring(packageIndex, end);
for (String token : snippet.split("\\s+")) {
if (AsciiTextNormalizer.toLowerAscii(token).startsWith("java.")) {
- return token.replaceAll("[,.;]$", "");
+ String candidatePackageName = token.replaceAll("[,.;]$", "");
+ return JavaPackageName.from(candidatePackageName)
+ .map(JavaPackageName::qualifiedName)
+ .orElse("");
}
}
}
diff --git a/src/main/java/com/williamcallahan/javachat/util/JavadocLinkResolver.java b/src/main/java/com/williamcallahan/javachat/util/JavadocLinkResolver.java
index 15498cbf..dfc62ba8 100644
--- a/src/main/java/com/williamcallahan/javachat/util/JavadocLinkResolver.java
+++ b/src/main/java/com/williamcallahan/javachat/util/JavadocLinkResolver.java
@@ -24,7 +24,7 @@ public static String refineNestedTypeUrl(String url, String text) {
*
* @param url Javadoc page URL ending with .html
* @param text Extracted chunk text from the same page
- * @param packageName Package name of the type (from metadata), can be empty
+ * @param packageName Java package derived from the canonical Javadoc source URL, can be empty
* @return URL with a fragment to the member if a confident match is found; original URL otherwise
*/
public static String refineMemberAnchorUrl(String url, String text, String packageName) {
diff --git a/src/test/java/com/williamcallahan/javachat/application/search/JavaApiMethodSelectorTest.java b/src/test/java/com/williamcallahan/javachat/application/search/JavaApiMethodSelectorTest.java
index 2ad56ae2..065a4549 100644
--- a/src/test/java/com/williamcallahan/javachat/application/search/JavaApiMethodSelectorTest.java
+++ b/src/test/java/com/williamcallahan/javachat/application/search/JavaApiMethodSelectorTest.java
@@ -57,7 +57,7 @@ void normalizesWhitespaceWhenConstructedDirectly() {
assertEquals("of", selector.methodName());
assertEquals("List.html", selector.typePageFileName());
assertEquals("List", selector.sparseQueryTerms());
- assertTrue(selector.matchesJavadocPath("/java.base/java/util/List.html"));
+ assertTrue(selector.matchesJavadocPath("/java.base/java/util/List.html", null));
}
@Test
@@ -76,6 +76,25 @@ void rejectsFilenameShapedPseudoMethods() {
void requiresCaseSensitiveJavadocTypePageNames() {
JavaApiMethodSelector selector = new JavaApiMethodSelector("java.util", "List", "of");
- assertFalse(selector.matchesJavadocPath("/java.base/java/util/list.html"));
+ assertFalse(selector.matchesJavadocPath("/java.base/java/util/list.html", null));
+ }
+
+ @Test
+ void requiresCanonicalCandidatePackageForUnqualifiedSelectors() {
+ JavaApiMethodSelector selector = new JavaApiMethodSelector("", "List", "of");
+
+ assertTrue(selector.matchesJavadocPath("/java.base/java/util/List.html", "java.util"));
+ assertFalse(selector.matchesJavadocPath("/java.base/java/util/class-use/List.html", "java.util.class-use"));
+ assertFalse(selector.matchesJavadocPath("/List.html", ""));
+ assertFalse(selector.matchesJavadocPath("/java.base/java/util/List.html", null));
+ }
+
+ @Test
+ void matchesQualifiedSelectorsByPathRegardlessOfCandidateMetadata() {
+ JavaApiMethodSelector selector = new JavaApiMethodSelector("java.util", "Date", "toString");
+
+ assertTrue(selector.matchesJavadocPath("/java.base/java/util/Date.html", null));
+ assertTrue(selector.matchesJavadocPath("/java.base/java/util/Date.html", "java.sql"));
+ assertFalse(selector.matchesJavadocPath("/java.sql/java/sql/Date.html", "java.util"));
}
}
diff --git a/src/test/java/com/williamcallahan/javachat/domain/javaapi/JavaPackageNameTest.java b/src/test/java/com/williamcallahan/javachat/domain/javaapi/JavaPackageNameTest.java
new file mode 100644
index 00000000..733bf706
--- /dev/null
+++ b/src/test/java/com/williamcallahan/javachat/domain/javaapi/JavaPackageNameTest.java
@@ -0,0 +1,45 @@
+package com.williamcallahan.javachat.domain.javaapi;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertThrows;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+import java.util.List;
+import org.junit.jupiter.api.Test;
+
+/** Verifies exact Java 25 package validation and Javadoc path projection. */
+class JavaPackageNameTest {
+
+ @Test
+ void acceptsJavaPackageNamesAndProjectsJavadocPaths() {
+ JavaPackageName packageName =
+ JavaPackageName.from("java.util.concurrent").orElseThrow();
+
+ assertEquals("java.util.concurrent", packageName.qualifiedName());
+ assertEquals("java/util/concurrent", packageName.javadocPath());
+ }
+
+ @Test
+ void rejectsAbsentPaddedAndInvalidPackageNames() {
+ List invalidPackageNames = List.of(
+ "",
+ " ",
+ " java.util",
+ "java.util ",
+ "java..util",
+ "java.util.class-use",
+ "java.class",
+ "java.true",
+ "java.false",
+ "java.null");
+
+ assertTrue(JavaPackageName.from(null).isEmpty());
+ invalidPackageNames.forEach(candidatePackageName ->
+ assertTrue(JavaPackageName.from(candidatePackageName).isEmpty()));
+ }
+
+ @Test
+ void preventsDirectConstructionOfInvalidPackageNames() {
+ assertThrows(IllegalArgumentException.class, () -> new JavaPackageName("java.util.class-use"));
+ }
+}
diff --git a/src/test/java/com/williamcallahan/javachat/service/CitationCandidateRankerTest.java b/src/test/java/com/williamcallahan/javachat/service/CitationCandidateRankerTest.java
index f0537cdc..093b2b1e 100644
--- a/src/test/java/com/williamcallahan/javachat/service/CitationCandidateRankerTest.java
+++ b/src/test/java/com/williamcallahan/javachat/service/CitationCandidateRankerTest.java
@@ -33,6 +33,30 @@ void prioritizesExactJavadocTypePagesOverHigherRankedMethodOnlyMatches() {
orderedCandidates.stream().map(Document::getId).toList());
}
+ @Test
+ void derivesCanonicalPackageFromSourceUrlsInsteadOfLegacyPackageMetadata() {
+ Document classUseCandidate = javaApiJavadocCandidateWithPackageMetadata(
+ "class-use",
+ "static List of(E element)",
+ javadocPage("java.util.class-use", "List.html"),
+ "java.util");
+ Document rootPageCandidate =
+ javaApiRootJavadocCandidate("root-page", "static List of(E element)", "List.html", "java.util");
+ Document canonicalListCandidate = javaApiJavadocCandidateWithPackageMetadata(
+ "canonical-list",
+ "static List of(E element)",
+ javaUtilJavadocPage("List.html"),
+ "java.base.java.util");
+
+ List orderedCandidates = CitationCandidateRanker.orderForCitationQuery(
+ "What does Java List.of() return?",
+ List.of(classUseCandidate, rootPageCandidate, canonicalListCandidate));
+
+ assertEquals(
+ List.of("canonical-list", "class-use", "root-page"),
+ orderedCandidates.stream().map(Document::getId).toList());
+ }
+
@Test
void recognizesUnparenthesizedTypeMethodSelectorsDuringCandidateOrdering() {
Document classFileCandidate = javaApiJavadocCandidate(
@@ -63,6 +87,22 @@ void matchesTheDeclaredPackageWhenASelectorIsFullyQualified() {
orderedCandidates.stream().map(Document::getId).toList());
}
+ @Test
+ void matchesQualifiedSelectorsByUrlWhenCandidatePackageMetadataIsAbsentOrWrong() {
+ Document incorrectSqlDateCandidate = javaApiJavadocCandidateWithPackageMetadata(
+ "sql-date", "String toString()", javadocPage("java.sql", "Date.html"), "java.util");
+ Document correctUtilDateCandidate = javaApiJavadocCandidateWithoutPackageMetadata(
+ "util-date", "String toString()", javaUtilJavadocPage("Date.html"));
+
+ List orderedCandidates = CitationCandidateRanker.orderForCitationQuery(
+ "What does java.util.Date.toString return?",
+ List.of(incorrectSqlDateCandidate, correctUtilDateCandidate));
+
+ assertEquals(
+ List.of("util-date", "sql-date"),
+ orderedCandidates.stream().map(Document::getId).toList());
+ }
+
@Test
void excludesNonApiDocumentationFromSelectorEvidence() {
Document apiMethodOnlyCandidate =
@@ -114,6 +154,7 @@ void propagatesMalformedApiDocumentationUrlsInsteadOfSilentlyIgnoringTheirEviden
.text("static List of(E element)")
.metadata(QdrantPayloadFieldSchema.URL_FIELD, "https://docs.example.test/java util/List.html")
.metadata(QdrantPayloadFieldSchema.DOC_TYPE_FIELD, DocsSourceRegistry.JAVA_API_DOCUMENT_TYPE)
+ .metadata(QdrantPayloadFieldSchema.PACKAGE_FIELD, "java.util")
.build();
assertThrows(
@@ -127,28 +168,65 @@ private static Document javaApiJavadocCandidate(String documentId, String docume
documentId, documentText, javadocPage, DocsSourceRegistry.JAVA_API_DOCUMENT_TYPE);
}
+ private static Document javaApiJavadocCandidateWithPackageMetadata(
+ String documentId, String documentText, JavadocPage javadocPage, String candidatePackageName) {
+ return Document.builder()
+ .id(documentId)
+ .text(documentText)
+ .metadata(QdrantPayloadFieldSchema.URL_FIELD, javaApiJavadocUrl(javadocPage))
+ .metadata(QdrantPayloadFieldSchema.DOC_TYPE_FIELD, DocsSourceRegistry.JAVA_API_DOCUMENT_TYPE)
+ .metadata(QdrantPayloadFieldSchema.PACKAGE_FIELD, candidatePackageName)
+ .build();
+ }
+
+ private static Document javaApiJavadocCandidateWithoutPackageMetadata(
+ String documentId, String documentText, JavadocPage javadocPage) {
+ return Document.builder()
+ .id(documentId)
+ .text(documentText)
+ .metadata(QdrantPayloadFieldSchema.URL_FIELD, javaApiJavadocUrl(javadocPage))
+ .metadata(QdrantPayloadFieldSchema.DOC_TYPE_FIELD, DocsSourceRegistry.JAVA_API_DOCUMENT_TYPE)
+ .build();
+ }
+
private static Document tutorialJavadocCandidate(String documentId, String documentText, JavadocPage javadocPage) {
return javadocCandidateWithDocumentType(documentId, documentText, javadocPage, TUTORIAL_DOCUMENT_TYPE);
}
private static Document javadocCandidateWithDocumentType(
String documentId, String documentText, JavadocPage javadocPage, String documentType) {
+ return Document.builder()
+ .id(documentId)
+ .text(documentText)
+ .metadata(QdrantPayloadFieldSchema.URL_FIELD, javaApiJavadocUrl(javadocPage))
+ .metadata(QdrantPayloadFieldSchema.DOC_TYPE_FIELD, documentType)
+ .metadata(QdrantPayloadFieldSchema.PACKAGE_FIELD, javadocPage.packageName())
+ .build();
+ }
+
+ private static Document javaApiRootJavadocCandidate(
+ String documentId, String documentText, String filename, String candidatePackageName) {
DocsSourceRegistry.JavaApiDocumentationSource representedJavaApiSource =
DocsSourceRegistry.javaApiDocumentationSources().getFirst();
return Document.builder()
.id(documentId)
.text(documentText)
- .metadata(
- QdrantPayloadFieldSchema.URL_FIELD,
- representedJavaApiSource.remoteBaseUrl()
- + "java.base/"
- + javadocPage.packageName().replace('.', '/')
- + "/"
- + javadocPage.filename())
- .metadata(QdrantPayloadFieldSchema.DOC_TYPE_FIELD, documentType)
+ .metadata(QdrantPayloadFieldSchema.URL_FIELD, representedJavaApiSource.remoteBaseUrl() + filename)
+ .metadata(QdrantPayloadFieldSchema.DOC_TYPE_FIELD, DocsSourceRegistry.JAVA_API_DOCUMENT_TYPE)
+ .metadata(QdrantPayloadFieldSchema.PACKAGE_FIELD, candidatePackageName)
.build();
}
+ private static String javaApiJavadocUrl(JavadocPage javadocPage) {
+ DocsSourceRegistry.JavaApiDocumentationSource representedJavaApiSource =
+ DocsSourceRegistry.javaApiDocumentationSources().getFirst();
+ return representedJavaApiSource.remoteBaseUrl()
+ + "java.base/"
+ + javadocPage.packageName().replace('.', '/')
+ + "/"
+ + javadocPage.filename();
+ }
+
private static JavadocPage javaUtilJavadocPage(String filename) {
return javadocPage("java.util", filename);
}
diff --git a/src/test/java/com/williamcallahan/javachat/service/RetrievalServiceCitationTest.java b/src/test/java/com/williamcallahan/javachat/service/RetrievalServiceCitationTest.java
index 9778eaae..7ee423c9 100644
--- a/src/test/java/com/williamcallahan/javachat/service/RetrievalServiceCitationTest.java
+++ b/src/test/java/com/williamcallahan/javachat/service/RetrievalServiceCitationTest.java
@@ -64,6 +64,25 @@ void retainsDistinctFullUrlIdentitiesWhileProjectingAnchorsOutOfTheUrl() {
assertEquals(0, citationOutcome.failedConversionCount());
}
+ @Test
+ void derivesMemberAnchorPackageFromCanonicalUrlInsteadOfLegacyMetadata() {
+ String mapJavadocUrl = javaUtilMapJavadocUrl();
+ Document legacyMapDocument = Document.builder()
+ .id("legacy-map-package")
+ .text("copyOf(Map.Entry entry)")
+ .metadata(QdrantPayloadFieldSchema.URL_FIELD, mapJavadocUrl)
+ .metadata(QdrantPayloadFieldSchema.PACKAGE_FIELD, "java.base.java.util")
+ .metadata(QdrantPayloadFieldSchema.DOC_TYPE_FIELD, DocsSourceRegistry.JAVA_API_DOCUMENT_TYPE)
+ .build();
+
+ Citation citation = citationService()
+ .toCitations(List.of(legacyMapDocument))
+ .citations()
+ .getFirst();
+
+ assertEquals("copyOf(java.util.Map.Entry)", citation.getAnchor());
+ }
+
@Test
void serializedCitationSplitsAtTheFirstFragmentDelimiterWithoutDecodingTheAnchor() {
String citationPageUrl = "https://example.test/reference/arrays";
@@ -258,6 +277,11 @@ private static String javaLangStringJavadocUrl() {
+ "java.base/java/lang/String.html";
}
+ private static String javaUtilMapJavadocUrl() {
+ return DocsSourceRegistry.javaApiDocumentationSources().getFirst().remoteBaseUrl()
+ + "java.base/java/util/Map.html";
+ }
+
/** Simulates malformed metadata values whose string conversion fails at runtime. */
private static final class BrokenUrlValue {
@Override
diff --git a/src/test/java/com/williamcallahan/javachat/service/RetrievalServiceTest.java b/src/test/java/com/williamcallahan/javachat/service/RetrievalServiceTest.java
index 18b3de49..c8323af8 100644
--- a/src/test/java/com/williamcallahan/javachat/service/RetrievalServiceTest.java
+++ b/src/test/java/com/williamcallahan/javachat/service/RetrievalServiceTest.java
@@ -127,13 +127,11 @@ void citationDiscoveryReranksATypePageBeyondTheFirstThreeCandidatesBeforeFinalLi
String citationQuery = "What does List.of return?";
String listPageUrl = javaApiPageUrl("java.util", "List.html");
List qdrantCandidates = List.of(
+ apiDocumentationCitationCandidate("object", "A utility of() method", "java.lang", "Object.html"),
+ apiDocumentationCitationCandidate("string", "A utility of() method", "java.lang", "String.html"),
+ apiDocumentationCitationCandidate("integer", "A utility of() method", "java.lang", "Integer.html"),
apiDocumentationCitationCandidate(
- "object", "A utility of() method", javaApiPageUrl("java.lang", "Object.html")),
- apiDocumentationCitationCandidate(
- "string", "A utility of() method", javaApiPageUrl("java.lang", "String.html")),
- apiDocumentationCitationCandidate(
- "integer", "A utility of() method", javaApiPageUrl("java.lang", "Integer.html")),
- apiDocumentationCitationCandidate("list", "static List of(E element)", listPageUrl));
+ "list", "static List of(E element)", "java.util", "List.html"));
when(hybridSearchService.searchDocumentationCitationsOutcome(
eq(citationQuery), eq(4), same(officialDocumentationConstraint)))
.thenReturn(new HybridSearchService.SearchOutcome(qdrantCandidates, List.of()));
@@ -222,12 +220,13 @@ private static Document citationCandidateDocument(
}
private static Document apiDocumentationCitationCandidate(
- String documentId, String documentText, String sourceUrl) {
+ String documentId, String documentText, String packageName, String pageFilename) {
return Document.builder()
.id(documentId)
.text(documentText)
- .metadata(QdrantPayloadFieldSchema.URL_FIELD, sourceUrl)
+ .metadata(QdrantPayloadFieldSchema.URL_FIELD, javaApiPageUrl(packageName, pageFilename))
.metadata(QdrantPayloadFieldSchema.DOC_TYPE_FIELD, DocsSourceRegistry.JAVA_API_DOCUMENT_TYPE)
+ .metadata(QdrantPayloadFieldSchema.PACKAGE_FIELD, packageName)
.build();
}
diff --git a/src/test/java/com/williamcallahan/javachat/service/ingestion/JavaPackageExtractorTest.java b/src/test/java/com/williamcallahan/javachat/service/ingestion/JavaPackageExtractorTest.java
index 631d3391..435dcbaf 100644
--- a/src/test/java/com/williamcallahan/javachat/service/ingestion/JavaPackageExtractorTest.java
+++ b/src/test/java/com/williamcallahan/javachat/service/ingestion/JavaPackageExtractorTest.java
@@ -25,7 +25,23 @@ void classifiesManifestJavaApiPathWhileIgnoringQueryAndFragment() {
+ "?redirect=/api/#append-java.lang.String-";
assertTrue(JavaPackageExtractor.isJavaApiUrl(javadocUrl));
- assertEquals("java.lang", JavaPackageExtractor.extractPackage(javadocUrl, ""));
+ assertEquals("java.lang", JavaPackageExtractor.extractJavaApiPackage(javadocUrl));
+ assertEquals("java.lang", JavaPackageExtractor.extractPackage(javadocUrl, "Package java.fake"));
+ }
+
+ @Test
+ void derivesOnlyValidPackagesEncodedByCanonicalJavaApiPaths() {
+ String javaApiBaseUrl =
+ DocsSourceRegistry.javaApiDocumentationSources().getFirst().remoteBaseUrl();
+
+ assertEquals(
+ "java.util",
+ JavaPackageExtractor.extractJavaApiPackage(javaApiBaseUrl + "java.base/java/util/List.html"));
+ assertEquals(
+ "",
+ JavaPackageExtractor.extractJavaApiPackage(javaApiBaseUrl + "java.base/java/util/class-use/List.html"));
+ assertEquals("", JavaPackageExtractor.extractJavaApiPackage(javaApiBaseUrl + "List.html"));
+ assertEquals("", JavaPackageExtractor.extractJavaApiPackage(SPRING_BOOT_API_URL));
}
@Test
@@ -44,4 +60,13 @@ void doesNotClassifyApiTextOnlyInQueryOrFragment() {
assertFalse(JavaPackageExtractor.isJavaApiUrl(springBootReferenceUrl));
}
+
+ @Test
+ void validatesBodyTextPackageFallbackThroughTheCanonicalPackageOwner() {
+ String referenceUrl = "https://example.test/java/reference";
+
+ assertEquals("java.util", JavaPackageExtractor.extractPackage(referenceUrl, "Package java.util,"));
+ assertEquals("", JavaPackageExtractor.extractPackage(referenceUrl, "Package java.util.class-use"));
+ assertEquals("", JavaPackageExtractor.extractPackage(referenceUrl, "Package java.class"));
+ }
}