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