From 60955998ecdcaa95727920558465c779e75d499e Mon Sep 17 00:00:00 2001 From: William Callahan Date: Sun, 25 Jan 2026 12:52:10 -0800 Subject: [PATCH 01/79] refactor(domain): extract SearchQualityLevel enum with self-describing behavior ChatService contained 30+ lines of sequential boolean checks to categorize search result quality and generate LLM context messages. This procedural logic violated Tell-Don't-Ask by interrogating document properties instead of letting a domain concept describe itself. The new enum encapsulates both categorization logic (determine) and message formatting (formatMessage), eliminating the boolean chain and making quality levels explicit domain vocabulary. The static describeQuality convenience method maintains the existing API contract while delegating to the new polymorphic design. Also removes deprecated streamResponse(String, double) method from OpenAIStreamingService as all callers now use the StructuredPrompt-based overload. - Add SearchQualityLevel enum with NONE, KEYWORD_SEARCH, HIGH_QUALITY, MIXED_QUALITY - Each level owns its message template and formatting logic - Static determine() replaces procedural if-else chain in ChatService - describeQuality() provides drop-in replacement for existing callers - Remove deprecated OpenAIStreamingService.streamResponse(String, double) --- .../javachat/domain/SearchQualityLevel.java | 109 ++++++++++++++++++ .../javachat/service/ChatService.java | 37 +----- .../service/OpenAIStreamingService.java | 39 ------- 3 files changed, 115 insertions(+), 70 deletions(-) create mode 100644 src/main/java/com/williamcallahan/javachat/domain/SearchQualityLevel.java diff --git a/src/main/java/com/williamcallahan/javachat/domain/SearchQualityLevel.java b/src/main/java/com/williamcallahan/javachat/domain/SearchQualityLevel.java new file mode 100644 index 0000000..c8a4789 --- /dev/null +++ b/src/main/java/com/williamcallahan/javachat/domain/SearchQualityLevel.java @@ -0,0 +1,109 @@ +package com.williamcallahan.javachat.domain; + +import org.springframework.ai.document.Document; + +import java.util.List; + +/** + * Categorizes the quality of search results for contextualizing LLM responses. + * + *

Replaces sequential boolean checks with self-describing behavior. Each level + * knows how to describe itself to the LLM so responses can be appropriately calibrated.

+ */ +public enum SearchQualityLevel { + /** + * No documents were retrieved; LLM must rely on general knowledge. + */ + NONE("No relevant documents found. Using general knowledge only."), + + /** + * Documents came from keyword/fallback search rather than semantic embeddings. + */ + KEYWORD_SEARCH("Found %d documents via keyword search (embedding service unavailable). Results may be less semantically relevant."), + + /** + * All retrieved documents are high-quality semantic matches. + */ + HIGH_QUALITY("Found %d high-quality relevant documents via semantic search."), + + /** + * Mix of high and lower quality results from semantic search. + */ + MIXED_QUALITY("Found %d documents (%d high-quality) via search. Some results may be less relevant."); + + private final String messageTemplate; + + SearchQualityLevel(String messageTemplate) { + this.messageTemplate = messageTemplate; + } + + /** + * Formats the quality message with document counts. + * + * @param totalCount total number of documents + * @param highQualityCount number of high-quality documents + * @return formatted message for the LLM + */ + public String formatMessage(int totalCount, int highQualityCount) { + return switch (this) { + case NONE -> messageTemplate; + case KEYWORD_SEARCH, HIGH_QUALITY -> String.format(messageTemplate, totalCount); + case MIXED_QUALITY -> String.format(messageTemplate, totalCount, highQualityCount); + }; + } + + /** + * Determines the search quality level for a set of retrieved documents. + * + * @param docs the retrieved documents + * @return the appropriate quality level + */ + public static SearchQualityLevel determine(List docs) { + if (docs == null || docs.isEmpty()) { + return NONE; + } + + // Check if documents came from keyword/fallback search + boolean likelyKeywordSearch = docs.stream() + .anyMatch(doc -> { + String url = String.valueOf(doc.getMetadata().getOrDefault("url", "")); + return url.contains("local-search") || url.contains("keyword"); + }); + + if (likelyKeywordSearch) { + return KEYWORD_SEARCH; + } + + // Count high-quality documents (has substantial content) + long highQualityCount = docs.stream() + .filter(doc -> { + String content = doc.getText(); + return content != null && content.length() > 100; + }) + .count(); + + if (highQualityCount == docs.size()) { + return HIGH_QUALITY; + } + + return MIXED_QUALITY; + } + + /** + * Generates the complete search quality note for the documents. + * + * @param docs the retrieved documents + * @return formatted quality message + */ + public static String describeQuality(List docs) { + SearchQualityLevel level = determine(docs); + int totalCount = docs != null ? docs.size() : 0; + long highQualityCount = docs != null + ? docs.stream() + .filter(doc -> doc.getText() != null && doc.getText().length() > 100) + .count() + : 0; + + return level.formatMessage(totalCount, (int) highQualityCount); + } +} diff --git a/src/main/java/com/williamcallahan/javachat/service/ChatService.java b/src/main/java/com/williamcallahan/javachat/service/ChatService.java index 21b808f..40afdd7 100644 --- a/src/main/java/com/williamcallahan/javachat/service/ChatService.java +++ b/src/main/java/com/williamcallahan/javachat/service/ChatService.java @@ -2,9 +2,10 @@ import com.williamcallahan.javachat.config.AppProperties; import com.williamcallahan.javachat.config.ModelConfiguration; -import com.williamcallahan.javachat.model.Citation; import com.williamcallahan.javachat.config.DocsSourceRegistry; import com.williamcallahan.javachat.config.SystemPromptConfig; +import com.williamcallahan.javachat.domain.SearchQualityLevel; +import com.williamcallahan.javachat.model.Citation; import com.williamcallahan.javachat.domain.prompt.ContextDocumentSegment; import com.williamcallahan.javachat.domain.prompt.ConversationTurnSegment; import com.williamcallahan.javachat.domain.prompt.CurrentQuerySegment; @@ -299,38 +300,12 @@ private int estimateTokens(String text) { } /** - * Determine the quality of search results and provide context to the AI. + * Determines the quality of search results and provides context to the AI. + * + *

Delegates to {@link SearchQualityLevel} enum for self-describing quality categorization.

*/ private String determineSearchQuality(List docs) { - if (docs.isEmpty()) { - return "No relevant documents found. Using general knowledge only."; - } - - // Check if documents seem to be from keyword search (less semantic relevance) - boolean likelyKeywordSearch = docs.stream() - .anyMatch(doc -> { - String url = String.valueOf(doc.getMetadata().getOrDefault("url", "")); - return url.contains("local-search") || url.contains("keyword"); - }); - - if (likelyKeywordSearch) { - return String.format("Found %d documents via keyword search (embedding service unavailable). Results may be less semantically relevant.", docs.size()); - } - - // Check document relevance quality - long highQualityDocs = docs.stream() - .filter(doc -> { - String content = doc.getText(); // Use getText() instead of getContent() - return content != null && content.length() > 100; // Basic quality check - }) - .count(); - - if (highQualityDocs == docs.size()) { - return String.format("Found %d high-quality relevant documents via semantic search.", docs.size()); - } else { - return String.format("Found %d documents (%d high-quality) via search. Some results may be less relevant.", - docs.size(), highQualityDocs); - } + return SearchQualityLevel.describeQuality(docs); } /** diff --git a/src/main/java/com/williamcallahan/javachat/service/OpenAIStreamingService.java b/src/main/java/com/williamcallahan/javachat/service/OpenAIStreamingService.java index 671a818..c97adf5 100644 --- a/src/main/java/com/williamcallahan/javachat/service/OpenAIStreamingService.java +++ b/src/main/java/com/williamcallahan/javachat/service/OpenAIStreamingService.java @@ -190,45 +190,6 @@ private OpenAIClient createClient(String apiKey, String baseUrl) { .build(); } - /** - * Stream a response from the OpenAI API using clean, native streaming support. - * - * @param prompt The complete prompt to send to the model - * @param temperature The temperature setting for response generation - * @return A Flux of content strings as they arrive from the model - * @deprecated Use {@link #streamResponse(StructuredPrompt, double)} for structure-aware truncation. - */ - @Deprecated - public Flux streamResponse(String prompt, double temperature) { - log.debug("Starting OpenAI stream"); - - return Flux.defer(() -> { - // Select client first to determine which provider's model name to use - OpenAIClient streamingClient = selectClientForStreaming(); - - if (streamingClient == null) { - String error = "All LLM providers unavailable - check rate limits and API credentials"; - log.error("[LLM] {}", error); - return Flux.error(new IllegalStateException(error)); - } - - boolean useGitHubModels = isPrimaryClient(streamingClient); - RateLimitManager.ApiProvider activeProvider = useGitHubModels - ? RateLimitManager.ApiProvider.GITHUB_MODELS - : RateLimitManager.ApiProvider.OPENAI; - - // Build params with provider-specific model after client selection - String truncatedPrompt = truncatePromptForModel(prompt); - ResponseCreateParams params = buildResponseParams(truncatedPrompt, temperature, useGitHubModels); - log.info("[LLM] [{}] Streaming started", activeProvider.getName()); - - return executeStreamingRequest(streamingClient, params, activeProvider); - }) - // Move blocking SDK stream consumption off the servlet thread. - // Prevents thread starvation and aligns with Reactor best practices. - .subscribeOn(Schedulers.boundedElastic()); - } - /** * Stream a response using a structured prompt with intelligent truncation. * From eb5ce1374c5b2a35a8318a34ebb46714b3362f87 Mon Sep 17 00:00:00 2001 From: William Callahan Date: Sun, 25 Jan 2026 12:53:01 -0800 Subject: [PATCH 02/79] refactor(guided-learning): extract PdfCitationEnhancer and add lesson-aware guidance GuidedLearningService had grown to include 80+ lines of PDF pagination logic (page counting, chunk enumeration, anchor calculation) unrelated to lesson orchestration, plus it lacked awareness of the current lesson context when generating LLM guidance. This commit addresses both issues: 1. Extraction: The new PdfCitationEnhancer component owns the entire citation enhancement workflow (loading PDF for page count, counting chunks, parsing metadata, estimating pages). This removes LocalStoreService from GuidedLearningService and improves testability. 2. Lesson Focus: New buildLessonGuidance() and buildLessonContextDescription() methods construct LLM prompts that include the lesson title, summary, and keywords. The guidance template now includes topic handling rules to redirect greetings and off-topic questions back to the current lesson. - Extract PdfCitationEnhancer to support/ with proper Javadocs - Add THINK_JAVA_GUIDANCE_TEMPLATE with %s placeholder for lesson context - Inject SystemPromptConfig for guided learning mode instructions - Update streamLessonChat and buildLessonPrompt to use lesson-aware guidance --- .../service/GuidedLearningService.java | 180 ++++++++-------- .../javachat/support/PdfCitationEnhancer.java | 192 ++++++++++++++++++ 2 files changed, 272 insertions(+), 100 deletions(-) create mode 100644 src/main/java/com/williamcallahan/javachat/support/PdfCitationEnhancer.java diff --git a/src/main/java/com/williamcallahan/javachat/service/GuidedLearningService.java b/src/main/java/com/williamcallahan/javachat/service/GuidedLearningService.java index a88b9d2..5e3719a 100644 --- a/src/main/java/com/williamcallahan/javachat/service/GuidedLearningService.java +++ b/src/main/java/com/williamcallahan/javachat/service/GuidedLearningService.java @@ -13,22 +13,17 @@ import org.springframework.stereotype.Service; import reactor.core.publisher.Flux; +import java.time.Duration; +import java.time.Instant; import java.util.ArrayList; import java.util.List; import java.util.Optional; -import java.util.Locale; -import java.util.stream.Collectors; import java.util.concurrent.ConcurrentHashMap; import java.util.concurrent.ConcurrentMap; -import java.time.Duration; -import java.time.Instant; +import java.util.stream.Collectors; -import org.springframework.core.io.ClassPathResource; -import java.io.IOException; -import java.io.InputStream; -import java.nio.file.*; -import org.apache.pdfbox.Loader; -import org.apache.pdfbox.pdmodel.PDDocument; +import com.williamcallahan.javachat.config.SystemPromptConfig; +import com.williamcallahan.javachat.support.PdfCitationEnhancer; /** * Orchestrates guided learning flows over curated lesson metadata using retrieval, enrichment, and streaming chat. @@ -41,17 +36,32 @@ public class GuidedLearningService { private final RetrievalService retrievalService; private final EnrichmentService enrichmentService; private final ChatService chatService; - private final LocalStoreService localStore; + private final SystemPromptConfig systemPromptConfig; + private final PdfCitationEnhancer pdfCitationEnhancer; // Public server path of the Think Java book (as mapped by DocsSourceRegistry) private static final String THINK_JAVA_PDF_PATH = "/pdfs/Think Java - 2nd Edition Book.pdf"; - /** System guidance for Think Java-grounded responses with learning aid markers. */ - private static final String THINK_JAVA_GUIDANCE = + /** + * Base guidance for Think Java-grounded responses with learning aid markers. + * + *

This template includes a placeholder for the current lesson context, which is + * filled in at runtime to keep responses focused on the active topic.

+ */ + private static final String THINK_JAVA_GUIDANCE_TEMPLATE = "You are a Java learning assistant guiding the user through 'Think Java — 2nd Edition'. " + "Use ONLY content grounded in this book for factual claims. " + "Cite sources with [n] markers. Embed learning aids using {{hint:...}}, {{reminder:...}}, {{background:...}}, {{example:...}}, {{warning:...}}. " + - "Prefer short, correct explanations with clear code examples when appropriate. If unsure, state the limitation."; + "Prefer short, correct explanations with clear code examples when appropriate. If unsure, state the limitation.\n\n" + + "## Current Lesson Context\n" + + "%s\n\n" + + "## Topic Handling Rules\n" + + "1. Keep all responses focused on the current lesson topic.\n" + + "2. If the user sends a greeting (hi, hello, hey, etc.) or off-topic message, " + + "acknowledge it briefly and redirect to the lesson topic with a helpful prompt.\n" + + "3. For off-topic Java questions, acknowledge the question and gently steer back to the current lesson, " + + "explaining how the lesson topic relates or suggesting they complete this lesson first.\n" + + "4. Never ignore the lesson context - every response should reinforce learning the current topic."; private final String jdkVersion; @@ -62,13 +72,15 @@ public GuidedLearningService(GuidedTOCProvider tocProvider, RetrievalService retrievalService, EnrichmentService enrichmentService, ChatService chatService, - LocalStoreService localStore, + SystemPromptConfig systemPromptConfig, + PdfCitationEnhancer pdfCitationEnhancer, @Value("${app.docs.jdk-version}") String jdkVersion) { this.tocProvider = tocProvider; this.retrievalService = retrievalService; this.enrichmentService = enrichmentService; this.chatService = chatService; - this.localStore = localStore; + this.systemPromptConfig = systemPromptConfig; + this.pdfCitationEnhancer = pdfCitationEnhancer; this.jdkVersion = jdkVersion; } @@ -93,7 +105,7 @@ public List citationsForLesson(String slug) { List filtered = filterToBook(docs); if (filtered.isEmpty()) return List.of(); List base = retrievalService.toCitations(filtered); - return enhancePdfCitationsWithPage(filtered, base); + return pdfCitationEnhancer.enhanceWithPageAnchors(filtered, base); } /** @@ -122,7 +134,8 @@ public Flux streamGuidedAnswer(List history, String slug, Strin List docs = retrievalService.retrieve(query); List filtered = filterToBook(docs); - return chatService.streamAnswerWithContext(history, userMessage, filtered, THINK_JAVA_GUIDANCE); + String guidance = buildLessonGuidance(lesson); + return chatService.streamAnswerWithContext(history, userMessage, filtered, guidance); } /** @@ -145,8 +158,9 @@ public StructuredPrompt buildStructuredGuidedPromptWithContext( List docs = retrievalService.retrieve(query); List filtered = filterToBook(docs); + String guidance = buildLessonGuidance(lesson); return chatService.buildStructuredPromptWithContextAndGuidance( - history, userMessage, filtered, THINK_JAVA_GUIDANCE); + history, userMessage, filtered, guidance); } /** @@ -259,80 +273,6 @@ public void putLessonCache(String slug, String markdown) { lessonMarkdownCache.put(slug, new LessonMarkdownCacheEntry(markdown, Instant.now())); } - // ===== PDF Pagination heuristics for /pdfs/Think Java - 2nd Edition Book.pdf ===== - private volatile Integer cachedPdfPages = null; - - private int getThinkJavaPdfPages() { - if (cachedPdfPages != null) return cachedPdfPages; - synchronized (this) { - if (cachedPdfPages != null) return cachedPdfPages; - try { - ClassPathResource pdfResource = new ClassPathResource("public/pdfs/Think Java - 2nd Edition Book.pdf"); - try (InputStream pdfStream = pdfResource.getInputStream(); - PDDocument document = Loader.loadPDF(pdfStream.readAllBytes())) { - cachedPdfPages = document.getNumberOfPages(); - } - } catch (IOException ioException) { - logger.error("Failed to load Think Java PDF for pagination", ioException); - throw new IllegalStateException("Unable to read Think Java PDF", ioException); - } - return cachedPdfPages; - } - } - - private int totalChunksForUrl(String url) { - try { - String safe = localStore.toSafeName(url); - Path dir = localStore.getParsedDir(); - if (dir == null) { - return 0; - } - try (var stream = Files.list(dir)) { - return (int) stream - .filter(path -> { - Path fileNamePath = path.getFileName(); - if (fileNamePath == null) { - return false; - } - String fileName = fileNamePath.toString(); - return fileName.startsWith(safe + "_") && fileName.endsWith(".txt"); - }) - .count(); - } - } catch (IOException ioException) { - throw new IllegalStateException("Unable to count local chunks for URL", ioException); - } - } - - private List enhancePdfCitationsWithPage(List docs, List citations) { - if (docs.size() != citations.size()) return citations; - int pages = getThinkJavaPdfPages(); - for (int docIndex = 0; docIndex < docs.size(); docIndex++) { - Document document = docs.get(docIndex); - Citation citation = citations.get(docIndex); - String url = citation.getUrl(); - if (url == null || !url.toLowerCase(Locale.ROOT).endsWith(".pdf")) continue; - Object chunkIndexMetadata = document.getMetadata().get("chunkIndex"); - int chunkIndex = -1; - try { - if (chunkIndexMetadata != null) { - chunkIndex = Integer.parseInt(String.valueOf(chunkIndexMetadata)); - } - } catch (NumberFormatException chunkIndexParseException) { - logger.debug("Failed to parse chunkIndex from metadata: {}", - sanitizeForLogText(String.valueOf(chunkIndexMetadata))); - } - int totalChunks = totalChunksForUrl(url); - if (pages > 0 && chunkIndex >= 0 && totalChunks > 0) { - int page = Math.max(1, Math.min(pages, (int) Math.round(((chunkIndex + 1.0) / totalChunks) * pages))); - String withAnchor = url.contains("#page=") ? url : url + "#page=" + page; - citation.setUrl(withAnchor); - citation.setAnchor("page=" + page); - } - } - return citations; - } - private List filterToBook(List docs) { List filtered = new ArrayList<>(); for (Document document : docs) { @@ -354,6 +294,53 @@ private String buildLessonQuery(GuidedLesson lesson) { return queryBuilder.toString().trim(); } + /** + * Builds complete guidance for a guided learning chat, combining lesson context with system prompts. + * + *

When a lesson is provided, the guidance includes the lesson title, summary, and keywords + * to keep responses focused on the current topic. It also integrates the guided learning + * mode instructions from SystemPromptConfig.

+ * + * @param lesson current lesson or null if no lesson context + * @return complete guidance string for the LLM + */ + private String buildLessonGuidance(GuidedLesson lesson) { + String lessonContext = buildLessonContextDescription(lesson); + String thinkJavaGuidance = String.format(THINK_JAVA_GUIDANCE_TEMPLATE, lessonContext); + + // Combine with guided learning mode instructions from SystemPromptConfig + String guidedLearningPrompt = systemPromptConfig.getGuidedLearningPrompt(); + return systemPromptConfig.buildFullPrompt(thinkJavaGuidance, guidedLearningPrompt); + } + + /** + * Builds a human-readable description of the current lesson context for the LLM. + * + * @param lesson current lesson or null + * @return description of the lesson context + */ + private String buildLessonContextDescription(GuidedLesson lesson) { + if (lesson == null) { + return "No specific lesson selected. Provide general Java learning assistance."; + } + + StringBuilder contextBuilder = new StringBuilder(); + contextBuilder.append("The user is currently studying the lesson: **") + .append(lesson.getTitle()) + .append("**"); + + if (lesson.getSummary() != null && !lesson.getSummary().isBlank()) { + contextBuilder.append("\n\nLesson Summary: ").append(lesson.getSummary()); + } + + if (lesson.getKeywords() != null && !lesson.getKeywords().isEmpty()) { + contextBuilder.append("\n\nKey concepts to cover: ") + .append(String.join(", ", lesson.getKeywords())); + } + + return contextBuilder.toString(); + } + private Enrichment emptyEnrichment() { Enrichment fallbackEnrichment = new Enrichment(); fallbackEnrichment.setJdkVersion(jdkVersion); @@ -362,11 +349,4 @@ private Enrichment emptyEnrichment() { fallbackEnrichment.setBackground(List.of()); return fallbackEnrichment; } - - private static String sanitizeForLogText(String rawText) { - if (rawText == null) { - return ""; - } - return rawText.replace("\r", "\\r").replace("\n", "\\n"); - } } diff --git a/src/main/java/com/williamcallahan/javachat/support/PdfCitationEnhancer.java b/src/main/java/com/williamcallahan/javachat/support/PdfCitationEnhancer.java new file mode 100644 index 0000000..ab7df92 --- /dev/null +++ b/src/main/java/com/williamcallahan/javachat/support/PdfCitationEnhancer.java @@ -0,0 +1,192 @@ +package com.williamcallahan.javachat.support; + +import com.williamcallahan.javachat.model.Citation; +import com.williamcallahan.javachat.service.LocalStoreService; + +import org.apache.pdfbox.Loader; +import org.apache.pdfbox.pdmodel.PDDocument; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.ai.document.Document; +import org.springframework.core.io.ClassPathResource; +import org.springframework.stereotype.Component; + +import java.io.IOException; +import java.io.InputStream; +import java.io.UncheckedIOException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.List; +import java.util.Locale; + +/** + * Enhances PDF citations with page number anchors based on chunk position heuristics. + * + *

For PDFs where chunk ordering correlates with page ordering (like Think Java), + * this service estimates the page number from the chunk index and total chunk count, + * then adds a #page=N anchor to the citation URL.

+ */ +@Component +public class PdfCitationEnhancer { + private static final Logger logger = LoggerFactory.getLogger(PdfCitationEnhancer.class); + + private final LocalStoreService localStore; + + /** Cached page count for the Think Java PDF to avoid repeated I/O. */ + private volatile Integer cachedThinkJavaPdfPages = null; + + /** Classpath location of the Think Java PDF. */ + private static final String THINK_JAVA_PDF_CLASSPATH = "public/pdfs/Think Java - 2nd Edition Book.pdf"; + + public PdfCitationEnhancer(LocalStoreService localStore) { + this.localStore = localStore; + } + + /** + * Enhances PDF citations with estimated page anchors. + * + *

For each citation pointing to a PDF, this method attempts to calculate + * the page number based on the document's chunk index and the total number + * of chunks for that PDF. The citation URL is updated with a #page=N anchor.

+ * + * @param docs the retrieved documents with chunk metadata + * @param citations the citations to enhance (must be same size as docs) + * @return the enhanced citations list (same list, mutated) + */ + public List enhanceWithPageAnchors(List docs, List citations) { + if (docs.size() != citations.size()) { + return citations; + } + + int thinkJavaPages = getThinkJavaPdfPages(); + + for (int docIndex = 0; docIndex < docs.size(); docIndex++) { + Document document = docs.get(docIndex); + Citation citation = citations.get(docIndex); + String url = citation.getUrl(); + + if (url == null || !url.toLowerCase(Locale.ROOT).endsWith(".pdf")) { + continue; + } + + int chunkIndex = parseChunkIndex(document); + if (chunkIndex < 0) { + continue; + } + + int totalChunks = countChunksForUrl(url); + if (thinkJavaPages > 0 && totalChunks > 0) { + int page = estimatePage(chunkIndex, totalChunks, thinkJavaPages); + String withAnchor = url.contains("#page=") ? url : url + "#page=" + page; + citation.setUrl(withAnchor); + citation.setAnchor("page=" + page); + } + } + return citations; + } + + /** + * Gets the total page count for the Think Java PDF. + * + *

The result is cached after the first load to avoid repeated I/O.

+ * + * @return page count + * @throws UncheckedIOException if the PDF cannot be loaded + */ + public int getThinkJavaPdfPages() { + if (cachedThinkJavaPdfPages != null) { + return cachedThinkJavaPdfPages; + } + + synchronized (this) { + if (cachedThinkJavaPdfPages != null) { + return cachedThinkJavaPdfPages; + } + + try { + ClassPathResource pdfResource = new ClassPathResource(THINK_JAVA_PDF_CLASSPATH); + try (InputStream pdfStream = pdfResource.getInputStream(); + PDDocument document = Loader.loadPDF(pdfStream.readAllBytes())) { + cachedThinkJavaPdfPages = document.getNumberOfPages(); + } + } catch (IOException ioException) { + throw new UncheckedIOException("Failed to load Think Java PDF for pagination", ioException); + } + return cachedThinkJavaPdfPages; + } + } + + /** + * Counts the total number of chunks stored for a given URL. + * + * @param url the source URL + * @return count of chunk files, or 0 if the directory is unavailable + * @throws UncheckedIOException if listing files fails + */ + int countChunksForUrl(String url) { + try { + String safe = localStore.toSafeName(url); + Path dir = localStore.getParsedDir(); + if (dir == null) { + return 0; + } + + try (var stream = Files.list(dir)) { + return (int) stream + .filter(path -> { + Path fileNamePath = path.getFileName(); + if (fileNamePath == null) { + return false; + } + String fileName = fileNamePath.toString(); + return fileName.startsWith(safe + "_") && fileName.endsWith(".txt"); + }) + .count(); + } + } catch (IOException ioException) { + throw new UncheckedIOException("Unable to count local chunks for URL: " + url, ioException); + } + } + + /** + * Parses the chunk index from document metadata. + * + * @param document the document with metadata + * @return chunk index or -1 if not available + */ + private int parseChunkIndex(Document document) { + Object chunkIndexMetadata = document.getMetadata().get("chunkIndex"); + if (chunkIndexMetadata == null) { + return -1; + } + + try { + return Integer.parseInt(String.valueOf(chunkIndexMetadata)); + } catch (NumberFormatException parseException) { + logger.debug("Failed to parse chunkIndex from metadata: {}", + sanitizeForLogText(String.valueOf(chunkIndexMetadata))); + return -1; + } + } + + /** + * Estimates the page number based on chunk position within the document. + * + * @param chunkIndex zero-based chunk index + * @param totalChunks total number of chunks + * @param totalPages total number of pages in the PDF + * @return estimated page number (1-based, clamped to valid range) + */ + private int estimatePage(int chunkIndex, int totalChunks, int totalPages) { + double position = (chunkIndex + 1.0) / totalChunks; + int page = (int) Math.round(position * totalPages); + return Math.max(1, Math.min(totalPages, page)); + } + + private static String sanitizeForLogText(String rawText) { + if (rawText == null) { + return ""; + } + return rawText.replace("\r", "\\r").replace("\n", "\\n"); + } +} From 1b00b40f5d69294d09a994722105c01e731dea4c Mon Sep 17 00:00:00 2001 From: William Callahan Date: Sun, 25 Jan 2026 12:54:26 -0800 Subject: [PATCH 03/79] feat(guided-learning): scope sessions per lesson to prevent conversation bleeding A single session ID was shared across all lessons in the frontend, causing conversation history from one lesson to bleed into another. For example, asking about loops after studying variables would reference unrelated variable discussion from the shared history. The frontend now maintains a Map so each lesson gets an isolated backend conversation. The GuidedStreamRequest accessor methods now return Optional instead of defaulting to empty strings, forcing callers to handle missing values explicitly. Frontend: - Replace single sessionId with sessionIdsByLesson Map - Add getSessionIdForLesson() to create/retrieve per-lesson sessions - Use lesson-scoped session ID in streamGuidedChat calls Backend: - Change GuidedStreamRequest.userQuery() to return Optional - Change GuidedStreamRequest.lessonSlug() to return Optional - Filter out blank values in Optional accessors --- frontend/src/lib/components/LearnView.svelte | 21 ++++++++++++++++--- .../javachat/web/GuidedStreamRequest.java | 21 +++++++++++++------ 2 files changed, 33 insertions(+), 9 deletions(-) diff --git a/frontend/src/lib/components/LearnView.svelte b/frontend/src/lib/components/LearnView.svelte index 110c9aa..cc827ba 100644 --- a/frontend/src/lib/components/LearnView.svelte +++ b/frontend/src/lib/components/LearnView.svelte @@ -49,8 +49,22 @@ let lessonContentEl: HTMLElement | null = $state(null) let lessonContentPanelEl: HTMLElement | null = $state(null) - // Session ID for chat continuity - const sessionId = generateSessionId('guided') + // Session IDs per lesson for backend conversation isolation + // Each lesson gets its own session ID to prevent conversation bleeding across topics + const sessionIdsByLesson = new Map() + + /** + * Gets or creates a session ID for a specific lesson. + * Each lesson gets its own backend session to prevent conversation bleeding. + */ + function getSessionIdForLesson(slug: string): string { + let lessonSessionId = sessionIdsByLesson.get(slug) + if (!lessonSessionId) { + lessonSessionId = generateSessionId(`guided-${slug}`) + sessionIdsByLesson.set(slug, lessonSessionId) + } + return lessonSessionId + } // Rendered lesson content - SSR-safe parsing without DOM operations let renderedLesson = $derived( @@ -196,6 +210,7 @@ const streamLessonSlug = selectedLesson.slug const userQuery = message.trim() + const lessonSessionId = getSessionIdForLesson(streamLessonSlug) messages = [...messages, { role: 'user', @@ -209,7 +224,7 @@ streaming.startStream() try { - await streamGuidedChat(sessionId, selectedLesson.slug, userQuery, { + await streamGuidedChat(lessonSessionId, selectedLesson.slug, userQuery, { onChunk: (chunk) => { // Guard: ignore chunks if user navigated away if (selectedLesson?.slug !== streamLessonSlug) return diff --git a/src/main/java/com/williamcallahan/javachat/web/GuidedStreamRequest.java b/src/main/java/com/williamcallahan/javachat/web/GuidedStreamRequest.java index 53aed52..ff6f048 100644 --- a/src/main/java/com/williamcallahan/javachat/web/GuidedStreamRequest.java +++ b/src/main/java/com/williamcallahan/javachat/web/GuidedStreamRequest.java @@ -1,5 +1,7 @@ package com.williamcallahan.javachat.web; +import java.util.Optional; + /** * Request body for guided learning streaming endpoint. * @@ -23,16 +25,23 @@ public String resolvedSessionId() { } /** - * Returns the user query, defaulting to empty string if null. + * Returns the user query when present and non-blank. + * + *

Callers should use {@link Optional#orElseThrow} or {@link Optional#orElse} + * to handle the missing case explicitly, avoiding silent empty-string defaults.

+ * + * @return the user's query if present and non-blank */ - public String userQuery() { - return latest != null ? latest : ""; + public Optional userQuery() { + return Optional.ofNullable(latest).filter(s -> !s.isBlank()); } /** - * Returns the lesson slug, defaulting to empty string if null. + * Returns the lesson slug when present and non-blank. + * + * @return the lesson slug if present and non-blank */ - public String lessonSlug() { - return slug != null ? slug : ""; + public Optional lessonSlug() { + return Optional.ofNullable(slug).filter(s -> !s.isBlank()); } } From 94f27592ed26488d494bfe020bfd497da7117719 Mon Sep 17 00:00:00 2001 From: William Callahan Date: Sun, 25 Jan 2026 12:55:23 -0800 Subject: [PATCH 04/79] test(SearchQualityLevel): add unit tests and update controller for Optional API Adds test coverage for the SearchQualityLevel enum extracted in commit 6095599, verifying all quality level determinations and message formatting. Also updates GuidedLearningController to use orElseThrow() on the new Optional return types from GuidedStreamRequest, failing fast with clear error messages when required fields are missing. Tests cover: - NONE returned for null/empty document lists - KEYWORD_SEARCH detected from URL metadata patterns - HIGH_QUALITY when all documents have substantial content (>100 chars) - MIXED_QUALITY when some documents have short content - formatMessage() produces correct strings for each level - describeQuality() convenience method integration Controller: - Use orElseThrow() on userQuery() with "User query is required" message - Use orElseThrow() on lessonSlug() with "Lesson slug is required" message --- .../web/GuidedLearningController.java | 6 +- .../domain/SearchQualityLevelTest.java | 77 +++++++++++++++++++ 2 files changed, 81 insertions(+), 2 deletions(-) create mode 100644 src/test/java/com/williamcallahan/javachat/domain/SearchQualityLevelTest.java diff --git a/src/main/java/com/williamcallahan/javachat/web/GuidedLearningController.java b/src/main/java/com/williamcallahan/javachat/web/GuidedLearningController.java index 438ccd5..0e432a9 100644 --- a/src/main/java/com/williamcallahan/javachat/web/GuidedLearningController.java +++ b/src/main/java/com/williamcallahan/javachat/web/GuidedLearningController.java @@ -184,8 +184,10 @@ public Flux> stream(@RequestBody GuidedStreamRequest req sseSupport.configureStreamingHeaders(response); String sessionId = request.resolvedSessionId(); - String userQuery = request.userQuery(); - String lessonSlug = request.lessonSlug(); + String userQuery = request.userQuery() + .orElseThrow(() -> new IllegalArgumentException("User query is required")); + String lessonSlug = request.lessonSlug() + .orElseThrow(() -> new IllegalArgumentException("Lesson slug is required")); // Load history BEFORE adding user message to avoid duplication in prompt // (buildGuidedPromptWithContext adds latestUserMessage separately) diff --git a/src/test/java/com/williamcallahan/javachat/domain/SearchQualityLevelTest.java b/src/test/java/com/williamcallahan/javachat/domain/SearchQualityLevelTest.java new file mode 100644 index 0000000..8824743 --- /dev/null +++ b/src/test/java/com/williamcallahan/javachat/domain/SearchQualityLevelTest.java @@ -0,0 +1,77 @@ +package com.williamcallahan.javachat.domain; + +import org.junit.jupiter.api.Test; +import org.springframework.ai.document.Document; + +import java.util.List; +import java.util.Map; + +import static org.assertj.core.api.Assertions.assertThat; + +/** + * Tests for {@link SearchQualityLevel} enum behavior. + */ +class SearchQualityLevelTest { + + @Test + void determineReturnsNoneForEmptyList() { + SearchQualityLevel level = SearchQualityLevel.determine(List.of()); + assertThat(level).isEqualTo(SearchQualityLevel.NONE); + } + + @Test + void determineReturnsNoneForNullList() { + SearchQualityLevel level = SearchQualityLevel.determine(null); + assertThat(level).isEqualTo(SearchQualityLevel.NONE); + } + + @Test + void determineReturnsKeywordSearchWhenUrlContainsKeyword() { + Document keywordDoc = new Document("some content", Map.of("url", "local-search://query")); + SearchQualityLevel level = SearchQualityLevel.determine(List.of(keywordDoc)); + assertThat(level).isEqualTo(SearchQualityLevel.KEYWORD_SEARCH); + } + + @Test + void determineReturnsHighQualityWhenAllDocsHaveSubstantialContent() { + String longContent = "a".repeat(150); + Document doc1 = new Document(longContent, Map.of("url", "https://example.com/doc1")); + Document doc2 = new Document(longContent, Map.of("url", "https://example.com/doc2")); + SearchQualityLevel level = SearchQualityLevel.determine(List.of(doc1, doc2)); + assertThat(level).isEqualTo(SearchQualityLevel.HIGH_QUALITY); + } + + @Test + void determineReturnsMixedQualityWhenSomeDocsHaveShortContent() { + String longContent = "a".repeat(150); + String shortContent = "short"; + Document highQualityDoc = new Document(longContent, Map.of("url", "https://example.com/doc1")); + Document lowQualityDoc = new Document(shortContent, Map.of("url", "https://example.com/doc2")); + SearchQualityLevel level = SearchQualityLevel.determine(List.of(highQualityDoc, lowQualityDoc)); + assertThat(level).isEqualTo(SearchQualityLevel.MIXED_QUALITY); + } + + @Test + void formatMessageReturnsCorrectStringForEachLevel() { + assertThat(SearchQualityLevel.NONE.formatMessage(0, 0)) + .contains("No relevant documents"); + + assertThat(SearchQualityLevel.KEYWORD_SEARCH.formatMessage(5, 0)) + .contains("5 documents") + .contains("keyword search"); + + assertThat(SearchQualityLevel.HIGH_QUALITY.formatMessage(3, 3)) + .contains("3 high-quality"); + + assertThat(SearchQualityLevel.MIXED_QUALITY.formatMessage(5, 2)) + .contains("5 documents") + .contains("2 high-quality"); + } + + @Test + void describeQualityReturnsFormattedMessage() { + String description = SearchQualityLevel.describeQuality(List.of()); + assertThat(description).isNotBlank(); + assertThat(description).contains("No relevant documents"); + } +} From f4d228bf5e1b82ec5de6a8d7544a022b5cc97bd0 Mon Sep 17 00:00:00 2001 From: William Callahan Date: Sun, 25 Jan 2026 13:15:38 -0800 Subject: [PATCH 05/79] docs: extract monolithic README into focused documentation files The original README (474 lines) contained everything from quick-start to API reference, configuration, architecture, mobile notes, and troubleshooting. This made it hard to navigate, maintain, and onboard contributors. The refactoring extracts each concern into its own file under docs/ while leaving a concise README as the project landing page. New documentation structure: - docs/README.md: table of contents and entry point - docs/getting-started.md: prerequisites, quick-start, common commands - docs/configuration.md: env vars for LLM, embeddings, Qdrant, RAG tuning - docs/api.md: HTTP endpoints (SSE streaming, guided learning, ingestion) - docs/ingestion.md: fetch/process/dedupe pipeline for RAG indexing - docs/architecture.md: high-level components and request flow Root README now: project summary, feature highlights, quick-start snippet, and a pointer to docs/README.md for comprehensive documentation. --- README.md | 473 ++-------------------------------------- docs/README.md | 16 ++ docs/api.md | 73 +++++++ docs/architecture.md | 36 +++ docs/configuration.md | 92 ++++++++ docs/getting-started.md | 74 +++++++ docs/ingestion.md | 77 +++++++ 7 files changed, 387 insertions(+), 454 deletions(-) create mode 100644 docs/README.md create mode 100644 docs/api.md create mode 100644 docs/architecture.md create mode 100644 docs/configuration.md create mode 100644 docs/getting-started.md create mode 100644 docs/ingestion.md diff --git a/README.md b/README.md index 3058435..79af573 100644 --- a/README.md +++ b/README.md @@ -1,474 +1,39 @@ -# Java Chat (Spring Boot, Java 21) +# Java Chat -A modern, streaming RAG chat for Java learners, grounded in Java 24/25 documentation with precise citations. Backend-only (Spring WebFlux + Spring AI + Qdrant). Uses OpenAI API with local embeddings (LM Studio) and Qdrant Cloud for vector storage. +AI-powered Java learning with **streaming answers**, **citations**, and **guided lessons** grounded in ingested documentation (RAG). -## 🚀 Latest Updates +Built with Spring Boot + WebFlux, Svelte, and Qdrant. -- **Complete Documentation Coverage**: Successfully ingested 22,756+ documents from Java 24/25 and Spring ecosystem -- **Local Embeddings**: Integrated with LM Studio using text-embedding-qwen3-embedding-8b model (4096 dimensions) -- **Qdrant Cloud Integration**: Connected to cloud-hosted vector database with 22,756+ indexed vectors -- **Consolidated Pipeline**: Single-command fetch and process pipeline with SHA-256 hash-based deduplication -- **Smart Deduplication**: Prevents redundant processing and re-uploading of documents -- **Comprehensive Documentation**: Java 24 (10,743 files), Java 25 (10,510 files), Spring AI (218 files) - - **Dual-Mode UI (Chat + Guided Learning)**: Tabbed shell (`/`) loads isolated Chat (`/chat.html`) and new Guided Learning (`/guided.html`) - - **Guided Learning (Think Java)**: Curated lessons powered by the “Think Java — 2nd Edition” PDF with lesson-scoped chat, citations, and enrichment +## Features -## Quick start - -### 🚀 One-Command Setup (Recommended) -```bash -# Complete setup: fetch all docs and process to Qdrant -make full-pipeline -``` - -This single command will: -1. Fetch all Java 24/25/EA and Spring documentation (skips existing) -2. Process documents with embeddings -3. Upload to Qdrant with deduplication -4. Start the application on port 8085 - -### Manual Setup -```bash -# 1) Set env vars (example - use your real values) -# Create a .env file in repo root (see .env.example for all options): -# -# Authentication - You can use one or both: -# -# GitHub Models (free tier available): -# GITHUB_TOKEN=your_github_personal_access_token -# -# OpenAI API (separate, independent): -# OPENAI_API_KEY=sk-xxx -# -# How the app uses these: -# 1. Spring AI tries GITHUB_TOKEN first, then OPENAI_API_KEY -# 2. On auth failure, fallback tries direct OpenAI or GitHub Models -# -# Optional: Local embeddings (if using LM Studio) -# APP_LOCAL_EMBEDDING_ENABLED=true -# LOCAL_EMBEDDING_SERVER_URL=http://127.0.0.1:8088 -# APP_LOCAL_EMBEDDING_MODEL=text-embedding-qwen3-embedding-8b -# APP_LOCAL_EMBEDDING_DIMENSIONS=4096 # Note: 4096 for qwen3-embedding-8b -# -# Optional: Qdrant Cloud (for vector storage) -# QDRANT_HOST=xxx.us-west-1-0.aws.cloud.qdrant.io -# QDRANT_PORT=8086 -# QDRANT_SSL=true -# QDRANT_API_KEY=your-qdrant-api-key -# QDRANT_COLLECTION=java-chat - -# 2) Fetch documentation (checks for existing) -make fetch-all - -# 3) Process and run (auto-processes new docs) -make run -``` - -Health check: GET http://localhost:8085/actuator/health -Embeddings health: GET http://localhost:8085/api/chat/health/embeddings - -## Dual-Mode UI: Chat + Guided Learning +- Streaming chat over SSE (`/api/chat/stream`) with a final `citation` event +- Guided learning mode (`/learn`) with lesson-scoped chat (`/api/guided/*`) +- Documentation ingestion pipeline (fetch → chunk → embed → dedupe → index) +- Embedding fallbacks: local embedding server → remote/OpenAI → hash fallback -The app now provides two complementary modes with a shared learning UX and formatting pipeline: - -- Chat (free-form): - - Location: `/chat.html` (also accessible via the “Chat” tab at `/`) - - Features: SSE streaming, server-side markdown, inline enrichments ({{hint}}, {{reminder}}, {{background}}, {{warning}}, {{example}}), citation pills, Prism highlighting, copy/export. - - APIs used: `/api/chat/stream`, `/api/chat/citations`, `/api/markdown/render`, `/api/chat/enrich` (alias: `/api/enrich`). - -- Guided Learning (curated): - - Location: `/guided.html` (also accessible via the “Guided Learning” tab at `/`) - - Content scope: “Think Java — 2nd Edition” PDF (mapped to `/pdfs/Think Java - 2nd Edition Book.pdf`) - - Features: lesson selector (TOC), lesson summary, book-scoped citations, enrichment cards, and an embedded chat scoped to the selected lesson. - - APIs used: `/api/guided/toc`, `/api/guided/lesson`, `/api/guided/citations`, `/api/guided/enrich`, `/api/guided/stream`. - -Frontend structure: -- `static/index.html`: tab shell only (a11y tabs + iframe loader for pages). -- `static/chat.html`: isolated Chat UI (migrated from original `index.html`). -- `static/guided.html`: Guided Learning UI. - -Guided Learning backend: -- `GET /api/guided/toc` → curated lessons (from `src/main/resources/guided/toc.json`). -- `GET /api/guided/lesson?slug=...` → lesson metadata. -- `GET /api/guided/citations?slug=...` → citations restricted to Think Java. -- `GET /api/guided/enrich?slug=...` → hints/background/reminders based on Think Java snippets. -- `POST /api/guided/stream` (SSE) → lesson-scoped chat. Body: `{ "sessionId":"guided:", "slug":"", "latest":"question" }`. - -All rendering quality is consistent across both modes: server-side markdown via `MarkdownService` preserves enrichment markers which the client rehydrates into styled blocks; spacing for paragraphs, lists, and code follows the same rules; Prism handles code highlighting. - -## Makefile (recommended) - -Common workflows are scripted via `Makefile`: +## Quick start ```bash -# Discover commands -make help - -# Build / Test -make build -make test - -# Run packaged jar -make run - -# Live dev (Spring DevTools hot reload) +cp .env.example .env +# edit .env and set GITHUB_TOKEN (and optionally OPENAI_API_KEY) +make compose-up # optional local Qdrant make dev - -# Local Qdrant via Docker Compose (optional) -make compose-up # start -make compose-logs # tail logs -make compose-ps # list services -make compose-down # stop - -# Convenience API helpers -make health -make ingest # ingest first 1000 docs -make citations # sample citations query ``` -## Configuration - -All config is env-driven. See `src/main/resources/application.properties` for defaults. Key vars: - -### API Configuration -- `GITHUB_TOKEN`: GitHub personal access token for GitHub Models -- `OPENAI_API_KEY`: OpenAI API key (separate, independent service) -- `OPENAI_MODEL`: Model name, default `gpt-5.2` (used by all endpoints) -- `OPENAI_TEMPERATURE`: default `0.7` -- `OPENAI_BASE_URL`: Spring AI base URL (default: `https://models.github.ai/inference`) - - **CRITICAL**: Must be `https://models.github.ai/inference` for GitHub Models - - **DO NOT USE**: `models.inference.ai.azure.com` (this is a hallucinated URL) - - **DO NOT USE**: Any `azure.com` domain (we don't have Azure instances) - -**How APIs are used:** -1. **Spring AI** (primary): Uses `OPENAI_BASE_URL` with `GITHUB_TOKEN` (preferred) or `OPENAI_API_KEY` -2. **Direct fallbacks** (on 401 auth errors): - - If `OPENAI_API_KEY` exists: Direct OpenAI API at `https://api.openai.com` - - If only `GITHUB_TOKEN` exists: GitHub Models at `https://models.github.ai/inference` (CORRECT endpoint) - -### Local Embeddings (LM Studio) -- `APP_LOCAL_EMBEDDING_ENABLED`: `true` to use local embeddings server -- `LOCAL_EMBEDDING_SERVER_URL`: URL of your local embeddings server (default: `http://127.0.0.1:8088`) -- `APP_LOCAL_EMBEDDING_DIMENSIONS`: `4096` (actual dimensions for qwen3-embedding-8b model) -- Recommended model: `text-embedding-qwen3-embedding-8b` (4096 dimensions) -- Note: LM Studio may show tokenizer warnings which are harmless - -### Qdrant Vector Database -- `QDRANT_HOST`: Cloud host (e.g., `xxx.us-west-1-0.aws.cloud.qdrant.io`) or `localhost` for Docker -- `QDRANT_PORT`: `8086` for gRPC (mapped from Docker's 6334) -- `QDRANT_API_KEY`: Your Qdrant Cloud API key (empty for local) -- `QDRANT_SSL`: `true` for cloud, `false` for local -- `QDRANT_COLLECTION`: default `java-chat` +Open `http://localhost:8085/`. -### Documentation Sources -- `DOCS_ROOT_URL`: default `https://docs.oracle.com/en/java/javase/24/` -- `DOCS_SNAPSHOT_DIR`: default `data/snapshots` (raw HTML) -- `DOCS_PARSED_DIR`: default `data/parsed` (chunk text) -- `DOCS_INDEX_DIR`: default `data/index` (ingest hash markers) -- **Containers**: point `DOCS_*` to a writable path (e.g., `/app/data/...`) and ensure the directories exist. - -## Documentation Ingestion - -### 🎯 Consolidated Pipeline (Recommended) - -We provide a unified pipeline that handles all documentation fetching and processing with intelligent deduplication: +## Index documentation (RAG) ```bash -# Complete pipeline: fetch all docs and process to Qdrant make full-pipeline - -# Or run steps separately: -make fetch-all # Fetch all documentation (checks for existing) -make process-all # Process and upload to Qdrant (deduplicates) -``` - -### Available Documentation -The pipeline automatically fetches and processes: -- **Java 24 API**: Complete Javadocs from docs.oracle.com (10,743 files ✅) -- **Java 25 API**: Complete Javadocs from docs.oracle.com (10,510 files ✅) -- **Spring Boot**: Full reference and API documentation (10,379 files) -- **Spring Framework**: Core Spring docs (13,342 files) -- **Spring AI**: AI/ML integration docs (218 files ✅) - -**Current Status**: Successfully indexed 22,756+ documents in Qdrant Cloud with automatic SHA-256 deduplication - -### Fetching Documentation - -#### Consolidated Fetch (Recommended) -```bash -# Fetch ALL documentation with deduplication checking -./scripts/fetch_all_docs.sh - -# Features: -# - Checks for existing documentation before fetching -# - Downloads only missing documentation -# - Creates metadata file with statistics -# - Logs all operations for debugging -``` - -#### Legacy Scripts (for specific needs) -```bash -# Individual fetchers if you need specific docs -./scripts/fetch_java_complete.sh # Java 24/25 Javadocs -./scripts/fetch_spring_complete.sh # Spring ecosystem only ``` -### Processing and Uploading to Qdrant - -#### Consolidated Processing (Recommended) -```bash -# Process all documentation with deduplication -./scripts/process_all_to_qdrant.sh - -# Features: -# - SHA-256 hash-based deduplication -# - Tracks processed files in hash database -# - Prevents redundant embedding generation -# - Prevents duplicate uploads to Qdrant -# - Shows real-time progress -# - Generates processing statistics -``` +See `docs/ingestion.md`. -#### Important Usage Notes +## Documentation -**Resumable Processing**: The script is designed to handle interruptions gracefully: -- If the connection is lost or the process is killed, simply re-run the script -- It will automatically skip all previously indexed documents (via hash markers in `data/index/`) -- Progress is preserved in Qdrant - vectors are never lost -- Each successful chunk creates a persistent marker file +Start with `docs/README.md`. -**How Resume Works**: -1. **Hash Markers**: Each successfully indexed chunk creates a file in `data/index/` named with its SHA-256 hash -2. **On Restart**: The system checks for existing hash files before processing any chunk -3. **Skip Logic**: If `data/index/{hash}` exists, the chunk is skipped (already in Qdrant) -4. **Atomic Operations**: Markers are only created AFTER successful Qdrant insertion +## Contributing -**Monitoring Progress**: -```bash -# Check current vector count in Qdrant -source .env && curl -s -H "api-key: $QDRANT_API_KEY" \ - "https://$QDRANT_HOST/collections/$QDRANT_COLLECTION" | \ - grep -o '"points_count":[0-9]*' | cut -d: -f2 - -# Count processed chunks (hash markers) -ls data/index/ | wc -l - -# Monitor real-time progress (create monitor_progress.sh) -#!/bin/bash -source .env -while true; do - count=$(curl -s -H "api-key: $QDRANT_API_KEY" \ - "https://$QDRANT_HOST/collections/$QDRANT_COLLECTION" | \ - grep -o '"points_count":[0-9]*' | cut -d: -f2) - echo -ne "\r[$(date +%H:%M:%S)] Vectors in Qdrant: $count" - sleep 5 -done -``` - -**Performance Notes**: -- Local embeddings (LM Studio) process ~35-40 vectors/minute -- Full indexing of 60,000 documents takes ~24-30 hours -- The script has NO timeout - it will run until completion -- Safe to run multiple times - deduplication prevents any redundant work - -#### Manual Ingestion (if needed) -```bash -# The application automatically processes docs on startup -make run # Starts app and processes any new documents - -# Or trigger manual ingestion via API -curl -X POST "http://localhost:8085/api/ingest/local?path=data/docs&maxFiles=10000" -``` - -### Deduplication & Quality - -#### How Deduplication Works -1. **Content Hashing**: Each document chunk gets a SHA-256 hash based on `url + chunkIndex + content` -2. **Hash Database**: Processed files are tracked in `data/.processed_hashes.db` -3. **Vector Store Check**: Before uploading, checks if hash already exists in Qdrant -4. **Skip Redundant Work**: Prevents: - - Re-downloading existing documentation - - Re-processing already embedded documents - - Duplicate vectors in Qdrant - -#### Quality Features -- **Smart chunking**: ~900 tokens with 150 token overlap for context preservation -- **Metadata enrichment**: URL, title, package name, chunk index for precise citations -- **Idempotent operations**: Safe to run multiple times without side effects -- **Automatic retries**: Handles network failures gracefully - -## Chat API (streaming) - -- POST `/api/chat/stream` (SSE) - - Body: `{ "sessionId": "s1", "latest": "How do I use records?" }` - - Streams text tokens; on completion, stores the assistant response in session memory. - -- GET `/api/chat/citations?q=your+query` - - Returns top citations (URL, title, snippet) for the query. - -- GET `/api/chat/export/last?sessionId=s1` - - Returns the last assistant message (markdown). - -- GET `/api/chat/export/session?sessionId=s1` - - Returns the full session conversation as markdown. - -## Retrieval & quality - -- Chunking: ~900 tokens with 150 overlap (CL100K_BASE tokenizer via JTokkit). -- Vector search: Qdrant similarity. Next steps: enable hybrid (BM25 + vector) and MMR diversity. -- Re-ranker: planned BGE reranker (DJL) or LLM rerank for top-k. Citations pinned to top-3 by score. - -## Citations & learning UX - -Responses are grounded with citations and “background tooltips”: -- Citation metadata: `package/module`, `JDK version`, `resource/framework + version`, `URL`, `title`. -- Background: tooltips with bigger-picture context, hints, and reminders to aid understanding. - -Data structures (server): -- Citation: `{ url, title, anchor, snippet }` (see `com.williamcallahan.javachat.model.Citation`). -- TODO: `Enrichment` payload with fields: `packageName`, `jdkVersion`, `resource`, `resourceVersion`, `hints[]`, `reminders[]`, `background[]`. - - Guided: `GuidedLesson` `{ slug, title, summary, keywords[] }` + TOC from `src/main/resources/guided/toc.json`. - -UI (server-rendered static placeholder): -- Return JSON with `citations` and `enrichment`. The client should render: - - Compact “source pills” with domain icon, title, and external-link affordance (open in new tab). - - Hover tooltips for background context (multi-paragraph allowed, markdown-safe). - - Clear, modern layout (Shadcn-inspired). Future: SPA frontend if needed. - -Modes & objectives: -- Chat: fast, accurate answers with layered insights and citations. -- Guided: structured progression through core topics with the same learning affordances, plus lesson-focused chat to deepen understanding. - -## Models & Architecture - -### Chat Model -- **OpenAI Java SDK (standardized)**: All streaming and non-streaming chat uses `OpenAIStreamingService` - - ✅ Official SDK streaming, no manual SSE parsing - - ✅ Prompt truncation for GPT‑5 context window (~400K tokens, 128K max output) handled centrally - - ✅ Clean, reliable streaming and consolidated error handling - -### Legacy Deletions -- Removed `ResilientApiClient` and all manual SSE parsing -- Controllers (`ChatController`, `GuidedLearningController`) stream via SDK only - -### Service Responsibilities -- `OpenAIStreamingService`: streaming + complete() helper -- `ChatService`: builds prompts (RAG-aware); may stream via SDK for internal flows -- `EnrichmentService` / `RerankerService`: use SDK `complete()` for JSON/ordering -- Session memory management for context preservation - -### Embeddings -- **Local LM Studio**: `text-embedding-qwen3-embedding-8b` (4096 dimensions) - - Running on Apple Silicon for fast, private embeddings - - No external API calls for document processing - - Server running at http://127.0.0.1:8088 (configurable) -- **Fallback**: OpenAI `text-embedding-3-small` if local server unavailable -- **Status**: ✅ Healthy and operational - -### Vector Search & RAG -- **Qdrant Cloud**: High-performance HNSW vector search - - Collection: `java-chat` with 22,756+ vectors - - Dimensions: 4096 (matching local embedding model) - - Connected via gRPC on port 8086 (mapped from container's 6334) with SSL -- **Smart Retrieval**: - - Top-K similarity search with configurable K (default: 12) - - MMR (Maximum Marginal Relevance) for result diversity - - TF-IDF reranking for relevance optimization -- **Citation System**: Top 3 sources with snippets and metadata - -## Maintenance - -- Re-ingesting docs: rerun `/api/ingest?maxPages=...` after a docs update. -- Qdrant housekeeping: snapshot/backup via Qdrant Cloud; set collection to HNSW + MMR/hybrid as needed. -- Env changes: restart app to pick up new model names or hosts. -- Logs/metrics: Spring Boot Actuator endpoints enabled for health/info/metrics. - - Observability TODO: add tracing and custom metrics (query time, tokens, hit rates). - -### Troubleshooting - -#### Qdrant Cloud -- Error `Invalid host or port` or `Expected closing bracket for IPv6 address`: - - Ensure `QDRANT_HOST` has no `https://` prefix; it must be the hostname only. - - Ensure `QDRANT_PORT=6334` and `QDRANT_SSL=true`. - - Makefile forces IPv4 (`-Djava.net.preferIPv4Stack=true`) to avoid macOS IPv6 resolver quirks. -- Dimension mismatch errors: - - Ensure `APP_LOCAL_EMBEDDING_DIMENSIONS=4096` matches your embedding model - - Delete and recreate Qdrant collection if dimensions change -- LM Studio tokenizer warnings: - - "[WARNING] At least one last token in strings embedded is not SEP" is harmless - -#### Rate Limiting -- **GitHub Models API**: ~15 requests/minute free tier. Set both `GITHUB_TOKEN` and `OPENAI_API_KEY` for automatic fallback. -- **Built-in retry**: 5 attempts with exponential backoff (2s → 30s max). Configurable via `AI_RETRY_*` env vars. -- **Fallback behavior**: On 429 errors, automatically switches to OpenAI API if `OPENAI_API_KEY` is available. - -## Roadmap - -- [ ] Hybrid retrieval (BM25 + vector), MMR, and re-ranker integration. -- [ ] Enrichment payload + endpoint for tooltips/hints/reminders with package/JDK metadata. -- [ ] Content hashing + upsert-by-hash for dedup and change detection. -- [ ] Minimal SPA with modern source pills, tooltips, and copy actions. -- [ ] Persist user chats + embeddings (future, configurable). - - [ ] Slash-commands (/search, /explain, /example) with semantic routing. - - [ ] Per-session rate limiting. - - [ ] DigitalOcean Spaces S3 offload for snapshots & parsed text. - - [ ] Docker Compose app service + optional local embedding model. -## 📱 Mobile Responsive Design - -The Java Chat application is fully optimized for mobile devices with comprehensive responsive design and mobile-specific safety measures. - -### Mobile Features -- **Full-width chat containers** on mobile with comfortable margins -- **16px minimum font size** on all inputs to prevent iOS Safari zoom -- **Enhanced touch targets** (44px minimum) for all interactive elements -- **Touch-optimized scrolling** with momentum scrolling support -- **Safe area insets** for devices with notches (iPhone X+) -- **Zoom prevention** on double-tap for chat areas -- **Horizontal scroll prevention** with proper text wrapping -- **Improved focus visibility** for keyboard navigation -- **Reduced motion support** for accessibility preferences - -### Mobile Breakpoints -- **Mobile**: ≤768px - Full mobile optimization -- **Tablet**: 769px-1024px - Intermediate responsive layout -- **Desktop**: >1024px - Full desktop experience - -### Mobile Safety Measures -- **Viewport Configuration**: Prevents unwanted zooming and ensures proper scaling -- **Text Size Adjustment**: Prevents browser text inflation on mobile -- **Touch Action Optimization**: Improves touch responsiveness and prevents conflicts -- **Performance Optimizations**: CSS containment and will-change for smooth animations -- **Accessibility**: Respects `prefers-reduced-motion` for users with motion sensitivity - -### Mobile Testing Checklist -- ✅ iOS Safari (iPhone/iPad) -- ✅ Chrome Mobile (Android) -- ✅ Samsung Internet -- ✅ Firefox Mobile -- ✅ Edge Mobile - -### Things to Avoid (Mobile Anti-Patterns) -1. **Font sizes < 16px on inputs** - Causes iOS Safari to zoom -2. **Touch targets < 44px** - Poor accessibility and usability -3. **Fixed positioning without safe-area-insets** - Content hidden by notches -4. **Horizontal overflow** - Breaks mobile UX -5. **user-scalable=yes without maximum-scale** - Allows accidental zoom -6. **Missing touch-action: manipulation** - Slower tap response (300ms delay) -7. **Viewport units (vh/vw) without fallbacks** - Inconsistent on mobile browsers -8. **Hover-only interactions** - Inaccessible on touch devices -9. **Small click areas** - Difficult to tap accurately -10. **Ignoring prefers-reduced-motion** - Accessibility violation - -## Stack details - -- Spring Boot 3.5.5 (WebFlux, Actuator) -- Spring AI 1.0.1 (OpenAI client, VectorStore Qdrant) -- Qdrant (HNSW vector DB); `docker-compose.yml` includes a local dev service -- JSoup (HTML parsing), JTokkit (tokenization), Fastutil (utils) -- **Mobile-First CSS**: Responsive design with mobile-specific optimizations - -Docker Compose (Qdrant only, optional fallback when you outgrow the free Qdrant Cloud plan or for offline dev): -```bash -docker compose up -d -# Then set QDRANT_HOST=localhost QDRANT_PORT=8086 -``` +Issues and PRs welcome. diff --git a/docs/README.md b/docs/README.md new file mode 100644 index 0000000..b7ef5d0 --- /dev/null +++ b/docs/README.md @@ -0,0 +1,16 @@ +# Documentation + +This folder contains developer documentation for **Java Chat**. + +## Start here + +- [Getting started](getting-started.md) +- [Configuration](configuration.md) +- [Documentation ingestion (RAG indexing)](ingestion.md) +- [HTTP API (endpoints + SSE format)](api.md) +- [Architecture](architecture.md) + +## Deep dives + +- `docs/domains/` contains design notes and domain-focused documentation (e.g., markdown processing, local-store details). + diff --git a/docs/api.md b/docs/api.md new file mode 100644 index 0000000..5db3269 --- /dev/null +++ b/docs/api.md @@ -0,0 +1,73 @@ +# HTTP API + +Base URL (local dev): `http://localhost:8085` + +## Streaming chat (SSE) + +### POST `/api/chat/stream` + +Request body: + +```json +{ "sessionId": "s1", "latest": "How do Java records work?" } +``` + +SSE event types (see `SseConstants`): + +- `status` → `{"message":"...","details":"..."}` +- `text` → `{"text":"..."}` +- `citation` → JSON array of citations +- `error` → `{"message":"...","details":"..."}` + +Example: + +```bash +curl -N -H "Content-Type: application/json" \ + -d '{"sessionId":"s1","latest":"Explain Java records"}' \ + http://localhost:8085/api/chat/stream +``` + +### Other chat endpoints + +- GET `/api/chat/citations?q=...` +- GET `/api/chat/health/embeddings` +- POST `/api/chat/clear?sessionId=...` +- GET `/api/chat/session/validate?sessionId=...` +- GET `/api/chat/export/last?sessionId=...` +- GET `/api/chat/export/session?sessionId=...` +- GET `/api/chat/diagnostics/retrieval?q=...` + +## Guided learning + +- GET `/api/guided/toc` +- GET `/api/guided/lesson?slug=...` +- GET `/api/guided/citations?slug=...` +- GET `/api/guided/enrich?slug=...` +- GET `/api/guided/content/stream?slug=...` (SSE, raw markdown) +- GET `/api/guided/content?slug=...` (JSON) +- GET `/api/guided/content/html?slug=...` (HTML) +- POST `/api/guided/stream` (SSE; request includes `sessionId`, `slug`, `latest`) + +## Markdown rendering + +- POST `/api/markdown/render` +- POST `/api/markdown/preview` +- POST `/api/markdown/render/structured` +- GET `/api/markdown/cache/stats` +- POST `/api/markdown/cache/clear` + +## Enrichment + +- GET `/api/enrich?q=...` +- GET `/api/chat/enrich?q=...` (alias) + +## Ingestion + embeddings cache + +- POST `/api/ingest?maxPages=...` +- POST `/api/ingest/local?dir=...&maxFiles=...` +- GET `/api/embeddings-cache/stats` +- POST `/api/embeddings-cache/upload?batchSize=...` +- POST `/api/embeddings-cache/snapshot` +- POST `/api/embeddings-cache/export?filename=...` +- POST `/api/embeddings-cache/import?filename=...` + diff --git a/docs/architecture.md b/docs/architecture.md new file mode 100644 index 0000000..4955f15 --- /dev/null +++ b/docs/architecture.md @@ -0,0 +1,36 @@ +# Architecture + +Java Chat is a Java-learning assistant focused on fast streaming answers and verifiable citations from ingested documentation. + +## High-level components + +- **Frontend**: Svelte 5 + Vite (built into `src/main/resources/static/`) +- **Backend**: Spring Boot (Web + WebFlux + Actuator) +- **Streaming**: Server-Sent Events (SSE) with typed event payloads +- **Retrieval**: Spring AI `VectorStore` (Qdrant) with local fallback search +- **LLM streaming**: OpenAI Java SDK (`OpenAIStreamingService`) supporting GitHub Models and OpenAI + +## Request flow (chat) + +1) UI calls `POST /api/chat/stream` +2) Backend retrieves candidate documents from Qdrant (`RetrievalService`) +3) Documents are reranked (`RerankerService`) and converted into citations +4) Prompt is built with retrieval context (`ChatService`) +5) Response streams via SSE (`SseSupport`) and emits a final `citation` event + +## Document ingestion (RAG indexing) + +The ingestion pipeline uses: + +- `scripts/fetch_all_docs.sh` to mirror docs into `data/docs/` +- `com.williamcallahan.javachat.cli.DocumentProcessor` (Spring `cli` profile) to ingest local docs +- `LocalStoreService` to store parsed chunks (`data/parsed/`) and hash markers (`data/index/`) + +See [ingestion.md](ingestion.md) and [local store directories](domains/local-store-directories.md). + +## Related design docs + +See also: + +- [All parsing and markdown logic](domains/all-parsing-and-markdown-logic.md) +- [Adding LLM source attribution](domains/adding-llm-source-attribution.md) diff --git a/docs/configuration.md b/docs/configuration.md new file mode 100644 index 0000000..35c1544 --- /dev/null +++ b/docs/configuration.md @@ -0,0 +1,92 @@ +# Configuration + +Java Chat is configured primarily via environment variables (loaded from `.env` by `Makefile` targets and most scripts). + +For defaults, see `src/main/resources/application.properties`. + +## Ports + +- `PORT` (default `8085`) is restricted to `8085–8090` (see `server.port` and the app’s port initializer). + +## LLM providers (streaming chat) + +Streaming uses the OpenAI Java SDK (`OpenAIStreamingService`) and supports: + +- **GitHub Models** via `GITHUB_TOKEN` +- **OpenAI** via `OPENAI_API_KEY` + +If both keys are present, the service prefers OpenAI for streaming and can fall back to GitHub Models based on rate-limit/backoff state. + +Common variables: + +- `GITHUB_TOKEN` (GitHub Models auth) +- `GITHUB_MODELS_BASE_URL` (default `https://models.github.ai/inference/v1`) +- `GITHUB_MODELS_CHAT_MODEL` (default `gpt-5`) +- `OPENAI_API_KEY` (OpenAI auth) +- `OPENAI_BASE_URL` (default `https://api.openai.com/v1`) +- `OPENAI_MODEL` (default `gpt-5.2`) +- `OPENAI_REASONING_EFFORT` (optional, GPT‑5 family) +- `OPENAI_STREAMING_REQUEST_TIMEOUT_SECONDS` (default `600`) +- `OPENAI_STREAMING_READ_TIMEOUT_SECONDS` (default `75`) + +### Provider notes + +- GitHub Models uses `https://models.github.ai/inference` (the OpenAI SDK requires `/v1`, so the default is `.../inference/v1`). +- Avoid `azure.com`-style endpoints unless you are explicitly running an Azure OpenAI-compatible gateway; this project does not configure Azure by default. + +### Rate limiting + +- If you hit `429` errors on GitHub Models, either wait and retry or set `OPENAI_API_KEY` as an additional provider. + +## Embeddings + +Embeddings are configured with a fallback chain (see `EmbeddingFallbackConfig`): + +1) Local embedding server (when enabled) +2) Remote OpenAI-compatible embedding provider (optional) +3) OpenAI embeddings (optional; requires `OPENAI_API_KEY`) +4) Hash-based fallback (deterministic, not semantic) + +Common variables: + +- `APP_LOCAL_EMBEDDING_ENABLED` (`true|false`) +- `LOCAL_EMBEDDING_SERVER_URL` (default `http://127.0.0.1:8088`) +- `APP_LOCAL_EMBEDDING_MODEL` (default `text-embedding-qwen3-embedding-8b`) +- `APP_LOCAL_EMBEDDING_DIMENSIONS` (default `4096`) +- `APP_LOCAL_EMBEDDING_USE_HASH_WHEN_DISABLED` (default `true`) +- `REMOTE_EMBEDDING_SERVER_URL`, `REMOTE_EMBEDDING_API_KEY`, `REMOTE_EMBEDDING_MODEL_NAME`, `REMOTE_EMBEDDING_DIMENSIONS` (optional) + +## Qdrant + +The app uses Qdrant via Spring AI’s Qdrant vector store starter. + +Common variables: + +- `QDRANT_HOST` (default `localhost`) +- `QDRANT_PORT` (gRPC; local compose maps to `8086`) +- `QDRANT_REST_PORT` (REST; local compose maps to `8087`, mainly for scripts) +- `QDRANT_API_KEY` (required for cloud; empty for local) +- `QDRANT_SSL` (`true` for cloud, `false` for local) +- `QDRANT_COLLECTION` (default `java-chat`) + +Local Qdrant: + +```bash +make compose-up +``` + +### Qdrant troubleshooting + +- `QDRANT_HOST` must be a hostname only (no `http://` or `https://` prefix). +- Local compose maps Qdrant to allowed ports: gRPC `8086`, REST `8087` (`docker-compose-qdrant.yml`). +- Some scripts use REST for health checks; set `QDRANT_REST_PORT=8087` when using local compose. + +## RAG tuning + +Common variables (see `app.rag.*` defaults in `application.properties`): + +- `RAG_CHUNK_MAX_TOKENS` +- `RAG_CHUNK_OVERLAP_TOKENS` +- `RAG_TOP_K` +- `RAG_RETURN_K` +- `RAG_CITATIONS_K` diff --git a/docs/getting-started.md b/docs/getting-started.md new file mode 100644 index 0000000..1c67109 --- /dev/null +++ b/docs/getting-started.md @@ -0,0 +1,74 @@ +# Getting started + +## Prerequisites + +- Java 21 (project toolchain) +- Node.js (frontend build/dev) +- Docker (optional, for local Qdrant) +- `wget` (optional, for `make fetch-all`) + +## Quick start (dev) + +1) Create your env file: + +```bash +cp .env.example .env +``` + +2) Edit `.env` and set at least `GITHUB_TOKEN` (GitHub Models). + +3) Start local Qdrant (optional but recommended for full RAG): + +```bash +make compose-up +``` + +- gRPC: `localhost:8086` (set `QDRANT_PORT=8086`) +- REST: `localhost:8087` (used by some scripts; set `QDRANT_REST_PORT=8087` if needed) + +4) Run the app in dev mode (Svelte + Spring Boot): + +```bash +make dev +``` + +Open: + +- App: `http://localhost:8085/` +- Chat: `http://localhost:8085/chat` +- Guided learning: `http://localhost:8085/learn` + +## Run packaged JAR + +Build + run the packaged Spring Boot JAR (also builds the frontend): + +```bash +make run +``` + +Health: + +```bash +make health +``` + +## Documentation ingestion (optional) + +To mirror upstream docs into `data/docs/` and index them into Qdrant: + +```bash +make full-pipeline +``` + +See [ingestion.md](ingestion.md) for details and troubleshooting. + +## Common commands + +```bash +make help +make build +make test +make lint +make dev-backend +``` + diff --git a/docs/ingestion.md b/docs/ingestion.md new file mode 100644 index 0000000..4be3c01 --- /dev/null +++ b/docs/ingestion.md @@ -0,0 +1,77 @@ +# Documentation ingestion (RAG indexing) + +Java Chat includes scripts and a CLI profile to mirror upstream documentation into `data/docs/` and ingest it into the vector store with content-hash deduplication. + +## Pipeline overview + +1) **Fetch** documentation into `data/docs/` (HTML mirrors). +2) **Process** docs into chunks + embeddings. +3) **Deduplicate** chunks by SHA‑256 hash. +4) **Upload** embeddings to Qdrant (or cache locally). + +## Fetch docs + +Fetch all configured sources: + +```bash +make fetch-all +``` + +This runs `scripts/fetch_all_docs.sh` (requires `wget`). Source URLs live in: + +- `src/main/resources/docs-sources.properties` +- `scripts/docs_sources.sh` (legacy mirror of the same values) + +## Process + upload to Qdrant + +Run the processor: + +```bash +make process-all +``` + +This runs `scripts/process_all_to_qdrant.sh`, which: + +- Loads `.env` +- Builds the app JAR (`./gradlew buildForScripts`) +- Runs the `cli` Spring profile (`com.williamcallahan.javachat.cli.DocumentProcessor`) + +### Modes + +- Default: `--upload` (uploads to Qdrant) +- Optional: `--local-only` (caches embeddings under `data/embeddings-cache/`) + +```bash +./scripts/process_all_to_qdrant.sh --local-only +./scripts/process_all_to_qdrant.sh --upload +``` + +## Deduplication markers + +Deduplication is based on per-chunk SHA‑256 markers stored locally: + +- `data/index/` contains one file per ingested chunk hash +- `data/parsed/` contains chunk text snapshots used for local fallback search and debugging + +See [local store directories](domains/local-store-directories.md) for details. + +## Ingest via HTTP API + +Ingest a local docs directory (must be under `data/docs/`): + +```bash +curl -sS -X POST "http://localhost:8085/api/ingest/local?dir=data/docs&maxFiles=50000" +``` + +Run a small remote crawl (dev/debug): + +```bash +curl -sS -X POST "http://localhost:8085/api/ingest?maxPages=100" +``` + +## Monitoring + +There are helper scripts in `scripts/`: + +- `scripts/monitor_progress.sh` (simple log-based view) +- `scripts/monitor_indexing.sh` (dashboard view; requires `jq` and `bc`) From 9d993c311743bfec3a83e2673935883126cd5148 Mon Sep 17 00:00:00 2001 From: William Callahan Date: Sun, 25 Jan 2026 13:17:33 -0800 Subject: [PATCH 06/79] fix(domain): extract SUBSTANTIAL_CONTENT_THRESHOLD constant and deduplicate high-quality counting --- .../javachat/domain/SearchQualityLevel.java | 37 +++++++++++++------ 1 file changed, 26 insertions(+), 11 deletions(-) diff --git a/src/main/java/com/williamcallahan/javachat/domain/SearchQualityLevel.java b/src/main/java/com/williamcallahan/javachat/domain/SearchQualityLevel.java index c8a4789..a604df9 100644 --- a/src/main/java/com/williamcallahan/javachat/domain/SearchQualityLevel.java +++ b/src/main/java/com/williamcallahan/javachat/domain/SearchQualityLevel.java @@ -31,6 +31,12 @@ public enum SearchQualityLevel { */ MIXED_QUALITY("Found %d documents (%d high-quality) via search. Some results may be less relevant."); + /** + * Minimum content length to consider a document as having substantial content. + * Documents shorter than this threshold are classified as lower quality. + */ + private static final int SUBSTANTIAL_CONTENT_THRESHOLD = 100; + private final String messageTemplate; SearchQualityLevel(String messageTemplate) { @@ -52,6 +58,24 @@ public String formatMessage(int totalCount, int highQualityCount) { }; } + /** + * Counts documents with substantial content (length exceeds threshold). + * + * @param docs the documents to evaluate + * @return count of high-quality documents with substantial content + */ + private static long countHighQuality(List docs) { + if (docs == null) { + return 0; + } + return docs.stream() + .filter(doc -> { + String content = doc.getText(); + return content != null && content.length() > SUBSTANTIAL_CONTENT_THRESHOLD; + }) + .count(); + } + /** * Determines the search quality level for a set of retrieved documents. * @@ -75,12 +99,7 @@ public static SearchQualityLevel determine(List docs) { } // Count high-quality documents (has substantial content) - long highQualityCount = docs.stream() - .filter(doc -> { - String content = doc.getText(); - return content != null && content.length() > 100; - }) - .count(); + long highQualityCount = countHighQuality(docs); if (highQualityCount == docs.size()) { return HIGH_QUALITY; @@ -98,11 +117,7 @@ public static SearchQualityLevel determine(List docs) { public static String describeQuality(List docs) { SearchQualityLevel level = determine(docs); int totalCount = docs != null ? docs.size() : 0; - long highQualityCount = docs != null - ? docs.stream() - .filter(doc -> doc.getText() != null && doc.getText().length() > 100) - .count() - : 0; + long highQualityCount = countHighQuality(docs); return level.formatMessage(totalCount, (int) highQualityCount); } From cb226a467707121b7528aa9d9ea2ee1b775cc841 Mon Sep 17 00:00:00 2001 From: William Callahan Date: Sun, 25 Jan 2026 13:18:50 -0800 Subject: [PATCH 07/79] fix(support): add UncheckedIOException to Javadoc, warn on size mismatch, reword 'Gets the' opener --- .../javachat/support/PdfCitationEnhancer.java | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/src/main/java/com/williamcallahan/javachat/support/PdfCitationEnhancer.java b/src/main/java/com/williamcallahan/javachat/support/PdfCitationEnhancer.java index ab7df92..935d14b 100644 --- a/src/main/java/com/williamcallahan/javachat/support/PdfCitationEnhancer.java +++ b/src/main/java/com/williamcallahan/javachat/support/PdfCitationEnhancer.java @@ -52,9 +52,12 @@ public PdfCitationEnhancer(LocalStoreService localStore) { * @param docs the retrieved documents with chunk metadata * @param citations the citations to enhance (must be same size as docs) * @return the enhanced citations list (same list, mutated) + * @throws UncheckedIOException when the PDF or chunk listing cannot be read */ public List enhanceWithPageAnchors(List docs, List citations) { if (docs.size() != citations.size()) { + logger.warn("Skipping PDF anchor enhancement because docs/citations sizes differ (docs={}, citations={})", + docs.size(), citations.size()); return citations; } @@ -86,7 +89,7 @@ public List enhanceWithPageAnchors(List docs, List } /** - * Gets the total page count for the Think Java PDF. + * Returns the total page count for the Think Java PDF. * *

The result is cached after the first load to avoid repeated I/O.

* From 17d3c2dcd715a652e7fb4581b313ef9c22175fc2 Mon Sep 17 00:00:00 2001 From: William Callahan Date: Sun, 25 Jan 2026 13:20:05 -0800 Subject: [PATCH 08/79] fix(web): add @ExceptionHandler for IllegalArgumentException to return 400 instead of 500 --- .../javachat/web/GuidedLearningController.java | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/src/main/java/com/williamcallahan/javachat/web/GuidedLearningController.java b/src/main/java/com/williamcallahan/javachat/web/GuidedLearningController.java index 0e432a9..0234cfc 100644 --- a/src/main/java/com/williamcallahan/javachat/web/GuidedLearningController.java +++ b/src/main/java/com/williamcallahan/javachat/web/GuidedLearningController.java @@ -12,6 +12,7 @@ import org.slf4j.Logger; import org.slf4j.LoggerFactory; import org.springframework.http.MediaType; +import org.springframework.http.ResponseEntity; import org.springframework.http.codec.ServerSentEvent; import org.springframework.security.access.prepost.PreAuthorize; import jakarta.servlet.http.HttpServletResponse; @@ -231,4 +232,15 @@ public Flux> stream(@RequestBody GuidedStreamRequest req return sseSupport.sseError("Service temporarily unavailable", "The streaming service is not ready"); } } + + /** + * Maps validation exceptions from missing request fields to HTTP 400 responses. + * + * @param validationException the validation exception with the error details + * @return standardized bad request error response + */ + @ExceptionHandler(IllegalArgumentException.class) + public ResponseEntity handleValidationException(IllegalArgumentException validationException) { + return super.handleValidationException(validationException); + } } From f4d91b92359528549d2197b67117d6e2828a5e29 Mon Sep 17 00:00:00 2001 From: William Callahan Date: Sun, 25 Jan 2026 13:24:43 -0800 Subject: [PATCH 09/79] feat(domain): add RetrievedContent interface for framework-free domain layer --- .../javachat/domain/RetrievedContent.java | 26 +++++++++++++++++++ 1 file changed, 26 insertions(+) create mode 100644 src/main/java/com/williamcallahan/javachat/domain/RetrievedContent.java diff --git a/src/main/java/com/williamcallahan/javachat/domain/RetrievedContent.java b/src/main/java/com/williamcallahan/javachat/domain/RetrievedContent.java new file mode 100644 index 0000000..73d7520 --- /dev/null +++ b/src/main/java/com/williamcallahan/javachat/domain/RetrievedContent.java @@ -0,0 +1,26 @@ +package com.williamcallahan.javachat.domain; + +import java.util.Map; + +/** + * Domain abstraction for retrieved document content used in search quality evaluation. + * + *

Decouples domain logic from Spring AI's Document class, allowing the domain layer + * to remain framework-free per clean architecture principles (AR4).

+ */ +public interface RetrievedContent { + + /** + * Returns the text content of this retrieved item. + * + * @return the content text, may be null if no content is available + */ + String getText(); + + /** + * Returns metadata associated with this retrieved item. + * + * @return unmodifiable map of metadata key-value pairs, never null + */ + Map getMetadata(); +} From 97d18538b10ceb9bd942351cb0c02c70ffc791b5 Mon Sep 17 00:00:00 2001 From: William Callahan Date: Sun, 25 Jan 2026 13:24:44 -0800 Subject: [PATCH 10/79] feat(support): add DocumentContentAdapter to bridge Spring AI Document to domain --- .../support/DocumentContentAdapter.java | 63 +++++++++++++++++++ 1 file changed, 63 insertions(+) create mode 100644 src/main/java/com/williamcallahan/javachat/support/DocumentContentAdapter.java diff --git a/src/main/java/com/williamcallahan/javachat/support/DocumentContentAdapter.java b/src/main/java/com/williamcallahan/javachat/support/DocumentContentAdapter.java new file mode 100644 index 0000000..97f29ba --- /dev/null +++ b/src/main/java/com/williamcallahan/javachat/support/DocumentContentAdapter.java @@ -0,0 +1,63 @@ +package com.williamcallahan.javachat.support; + +import com.williamcallahan.javachat.domain.RetrievedContent; +import org.springframework.ai.document.Document; + +import java.util.Collections; +import java.util.List; +import java.util.Map; + +/** + * Adapts Spring AI Document to the domain RetrievedContent interface. + * + *

This adapter allows domain logic to remain framework-free while the + * infrastructure layer handles the Spring AI integration.

+ */ +public final class DocumentContentAdapter implements RetrievedContent { + + private final Document document; + + private DocumentContentAdapter(Document document) { + this.document = document; + } + + /** + * Wraps a Spring AI Document as RetrievedContent. + * + * @param document the Spring AI document to wrap + * @return the domain-facing content representation + * @throws IllegalArgumentException if document is null + */ + public static RetrievedContent fromDocument(Document document) { + if (document == null) { + throw new IllegalArgumentException("Document cannot be null"); + } + return new DocumentContentAdapter(document); + } + + /** + * Converts a list of Spring AI Documents to RetrievedContent list. + * + * @param documents the documents to convert, may be null + * @return list of domain content representations, empty if input is null + */ + public static List fromDocuments(List documents) { + if (documents == null) { + return List.of(); + } + return documents.stream() + .map(DocumentContentAdapter::fromDocument) + .toList(); + } + + @Override + public String getText() { + return document.getText(); + } + + @Override + public Map getMetadata() { + Map metadata = document.getMetadata(); + return metadata != null ? Collections.unmodifiableMap(metadata) : Map.of(); + } +} From f9f851a50ac350c981e4cd79daa9cb304531af39 Mon Sep 17 00:00:00 2001 From: William Callahan Date: Sun, 25 Jan 2026 13:24:44 -0800 Subject: [PATCH 11/79] refactor(domain): remove Spring framework import, use RetrievedContent interface --- .../javachat/domain/SearchQualityLevel.java | 46 +++++++++---------- 1 file changed, 22 insertions(+), 24 deletions(-) diff --git a/src/main/java/com/williamcallahan/javachat/domain/SearchQualityLevel.java b/src/main/java/com/williamcallahan/javachat/domain/SearchQualityLevel.java index a604df9..2eb34d1 100644 --- a/src/main/java/com/williamcallahan/javachat/domain/SearchQualityLevel.java +++ b/src/main/java/com/williamcallahan/javachat/domain/SearchQualityLevel.java @@ -1,7 +1,5 @@ package com.williamcallahan.javachat.domain; -import org.springframework.ai.document.Document; - import java.util.List; /** @@ -61,36 +59,36 @@ public String formatMessage(int totalCount, int highQualityCount) { /** * Counts documents with substantial content (length exceeds threshold). * - * @param docs the documents to evaluate + * @param contents the retrieved contents to evaluate * @return count of high-quality documents with substantial content */ - private static long countHighQuality(List docs) { - if (docs == null) { + private static long countHighQuality(List contents) { + if (contents == null) { return 0; } - return docs.stream() - .filter(doc -> { - String content = doc.getText(); - return content != null && content.length() > SUBSTANTIAL_CONTENT_THRESHOLD; + return contents.stream() + .filter(content -> { + String text = content.getText(); + return text != null && text.length() > SUBSTANTIAL_CONTENT_THRESHOLD; }) .count(); } /** - * Determines the search quality level for a set of retrieved documents. + * Determines the search quality level for a set of retrieved contents. * - * @param docs the retrieved documents + * @param contents the retrieved contents * @return the appropriate quality level */ - public static SearchQualityLevel determine(List docs) { - if (docs == null || docs.isEmpty()) { + public static SearchQualityLevel determine(List contents) { + if (contents == null || contents.isEmpty()) { return NONE; } // Check if documents came from keyword/fallback search - boolean likelyKeywordSearch = docs.stream() - .anyMatch(doc -> { - String url = String.valueOf(doc.getMetadata().getOrDefault("url", "")); + boolean likelyKeywordSearch = contents.stream() + .anyMatch(content -> { + String url = String.valueOf(content.getMetadata().getOrDefault("url", "")); return url.contains("local-search") || url.contains("keyword"); }); @@ -99,9 +97,9 @@ public static SearchQualityLevel determine(List docs) { } // Count high-quality documents (has substantial content) - long highQualityCount = countHighQuality(docs); + long highQualityCount = countHighQuality(contents); - if (highQualityCount == docs.size()) { + if (highQualityCount == contents.size()) { return HIGH_QUALITY; } @@ -109,15 +107,15 @@ public static SearchQualityLevel determine(List docs) { } /** - * Generates the complete search quality note for the documents. + * Generates the complete search quality note for the contents. * - * @param docs the retrieved documents + * @param contents the retrieved contents * @return formatted quality message */ - public static String describeQuality(List docs) { - SearchQualityLevel level = determine(docs); - int totalCount = docs != null ? docs.size() : 0; - long highQualityCount = countHighQuality(docs); + public static String describeQuality(List contents) { + SearchQualityLevel level = determine(contents); + int totalCount = contents != null ? contents.size() : 0; + long highQualityCount = countHighQuality(contents); return level.formatMessage(totalCount, (int) highQualityCount); } From 975247a09ff9fb539997cde6bdc57b82ef3ce736 Mon Sep 17 00:00:00 2001 From: William Callahan Date: Sun, 25 Jan 2026 13:24:47 -0800 Subject: [PATCH 12/79] refactor(service): use DocumentContentAdapter when calling SearchQualityLevel --- .../java/com/williamcallahan/javachat/service/ChatService.java | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/main/java/com/williamcallahan/javachat/service/ChatService.java b/src/main/java/com/williamcallahan/javachat/service/ChatService.java index 40afdd7..268b69b 100644 --- a/src/main/java/com/williamcallahan/javachat/service/ChatService.java +++ b/src/main/java/com/williamcallahan/javachat/service/ChatService.java @@ -11,6 +11,7 @@ import com.williamcallahan.javachat.domain.prompt.CurrentQuerySegment; import com.williamcallahan.javachat.domain.prompt.StructuredPrompt; import com.williamcallahan.javachat.domain.prompt.SystemSegment; +import com.williamcallahan.javachat.support.DocumentContentAdapter; import org.slf4j.Logger; import org.slf4j.LoggerFactory; import org.springframework.ai.chat.messages.AssistantMessage; @@ -305,7 +306,7 @@ private int estimateTokens(String text) { *

Delegates to {@link SearchQualityLevel} enum for self-describing quality categorization.

*/ private String determineSearchQuality(List docs) { - return SearchQualityLevel.describeQuality(docs); + return SearchQualityLevel.describeQuality(DocumentContentAdapter.fromDocuments(docs)); } /** From 97d4540c377d58416f7439d3b9a093dfe07c6229 Mon Sep 17 00:00:00 2001 From: William Callahan Date: Sun, 25 Jan 2026 13:24:47 -0800 Subject: [PATCH 13/79] test(domain): update SearchQualityLevelTest to use DocumentContentAdapter --- .../javachat/domain/SearchQualityLevelTest.java | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) diff --git a/src/test/java/com/williamcallahan/javachat/domain/SearchQualityLevelTest.java b/src/test/java/com/williamcallahan/javachat/domain/SearchQualityLevelTest.java index 8824743..81501ef 100644 --- a/src/test/java/com/williamcallahan/javachat/domain/SearchQualityLevelTest.java +++ b/src/test/java/com/williamcallahan/javachat/domain/SearchQualityLevelTest.java @@ -1,5 +1,6 @@ package com.williamcallahan.javachat.domain; +import com.williamcallahan.javachat.support.DocumentContentAdapter; import org.junit.jupiter.api.Test; import org.springframework.ai.document.Document; @@ -28,7 +29,8 @@ void determineReturnsNoneForNullList() { @Test void determineReturnsKeywordSearchWhenUrlContainsKeyword() { Document keywordDoc = new Document("some content", Map.of("url", "local-search://query")); - SearchQualityLevel level = SearchQualityLevel.determine(List.of(keywordDoc)); + SearchQualityLevel level = SearchQualityLevel.determine( + DocumentContentAdapter.fromDocuments(List.of(keywordDoc))); assertThat(level).isEqualTo(SearchQualityLevel.KEYWORD_SEARCH); } @@ -37,7 +39,8 @@ void determineReturnsHighQualityWhenAllDocsHaveSubstantialContent() { String longContent = "a".repeat(150); Document doc1 = new Document(longContent, Map.of("url", "https://example.com/doc1")); Document doc2 = new Document(longContent, Map.of("url", "https://example.com/doc2")); - SearchQualityLevel level = SearchQualityLevel.determine(List.of(doc1, doc2)); + SearchQualityLevel level = SearchQualityLevel.determine( + DocumentContentAdapter.fromDocuments(List.of(doc1, doc2))); assertThat(level).isEqualTo(SearchQualityLevel.HIGH_QUALITY); } @@ -47,7 +50,8 @@ void determineReturnsMixedQualityWhenSomeDocsHaveShortContent() { String shortContent = "short"; Document highQualityDoc = new Document(longContent, Map.of("url", "https://example.com/doc1")); Document lowQualityDoc = new Document(shortContent, Map.of("url", "https://example.com/doc2")); - SearchQualityLevel level = SearchQualityLevel.determine(List.of(highQualityDoc, lowQualityDoc)); + SearchQualityLevel level = SearchQualityLevel.determine( + DocumentContentAdapter.fromDocuments(List.of(highQualityDoc, lowQualityDoc))); assertThat(level).isEqualTo(SearchQualityLevel.MIXED_QUALITY); } From 0def47c76c203df2a9ee2ac39b60d304c1507c85 Mon Sep 17 00:00:00 2001 From: William Callahan Date: Sun, 25 Jan 2026 13:26:59 -0800 Subject: [PATCH 14/79] refactor(web): extract helper methods to reduce method complexity in GuidedLearningController --- .../web/GuidedLearningController.java | 114 +++++++++--------- 1 file changed, 59 insertions(+), 55 deletions(-) diff --git a/src/main/java/com/williamcallahan/javachat/web/GuidedLearningController.java b/src/main/java/com/williamcallahan/javachat/web/GuidedLearningController.java index 0234cfc..e66116f 100644 --- a/src/main/java/com/williamcallahan/javachat/web/GuidedLearningController.java +++ b/src/main/java/com/williamcallahan/javachat/web/GuidedLearningController.java @@ -139,14 +139,7 @@ public LessonContentResponse content(@RequestParam("slug") String slug) { if (cached.isPresent()) { return new LessonContentResponse(cached.get(), true); } - // Generate synchronously (best-effort) and cache - List chunks = guidedService.streamLessonContent(slug).collectList().block(LESSON_CONTENT_TIMEOUT); - if (chunks == null || chunks.isEmpty()) { - log.error("Content generation timed out or returned empty for lesson"); - throw new IllegalStateException("Content generation failed for lesson"); - } - String md = String.join("", chunks); - guidedService.putLessonCache(slug, md); + String md = generateAndCacheLessonContent(slug); return new LessonContentResponse(md, false); } @@ -160,19 +153,30 @@ public LessonContentResponse content(@RequestParam("slug") String slug) { @GetMapping(value = "/content/html", produces = MediaType.TEXT_HTML_VALUE) public String contentHtml(@RequestParam("slug") String slug) { var cached = guidedService.getCachedLessonMarkdown(slug); - String md = cached.orElseGet(() -> { - List chunks = guidedService.streamLessonContent(slug).collectList().block(LESSON_CONTENT_TIMEOUT); - if (chunks == null || chunks.isEmpty()) { - log.error("Content generation timed out or returned empty for lesson HTML"); - throw new IllegalStateException("Content generation failed for lesson"); - } - String text = String.join("", chunks); - guidedService.putLessonCache(slug, text); - return text; - }); + String md = cached.orElseGet(() -> generateAndCacheLessonContent(slug)); return markdownService.processStructured(md).html(); } + /** + * Generates lesson content synchronously and caches the result. + * + * @param slug lesson identifier + * @return generated markdown content + * @throws IllegalStateException if generation times out or returns empty + */ + private String generateAndCacheLessonContent(String slug) { + List chunks = guidedService.streamLessonContent(slug) + .collectList() + .block(LESSON_CONTENT_TIMEOUT); + if (chunks == null || chunks.isEmpty()) { + log.error("Content generation timed out or returned empty for lesson"); + throw new IllegalStateException("Content generation failed for lesson"); + } + String content = String.join("", chunks); + guidedService.putLessonCache(slug, content); + return content; + } + /** * Streams a response to a user's chat message within the context of a guided lesson. * Uses the same JSON-wrapped SSE format as ChatController for consistent whitespace handling. @@ -190,47 +194,47 @@ public Flux> stream(@RequestBody GuidedStreamRequest req String lessonSlug = request.lessonSlug() .orElseThrow(() -> new IllegalArgumentException("Lesson slug is required")); - // Load history BEFORE adding user message to avoid duplication in prompt - // (buildGuidedPromptWithContext adds latestUserMessage separately) + if (!openAIStreamingService.isAvailable()) { + log.warn("OpenAI streaming service unavailable for guided session"); + return sseSupport.sseError("Service temporarily unavailable", "The streaming service is not ready"); + } + + return streamGuidedResponse(sessionId, userQuery, lessonSlug); + } + + /** + * Builds and streams the guided lesson response via OpenAI. + * + * @param sessionId chat session identifier + * @param userQuery user's question + * @param lessonSlug lesson context identifier + * @return SSE stream of response chunks with heartbeats + */ + private Flux> streamGuidedResponse(String sessionId, String userQuery, String lessonSlug) { List history = new ArrayList<>(chatMemory.getHistory(sessionId)); chatMemory.addUser(sessionId, userQuery); StringBuilder fullResponse = new StringBuilder(); - // Use OpenAI streaming only (legacy fallback removed) - if (openAIStreamingService.isAvailable()) { - // Build structured prompt for intelligent truncation - StructuredPrompt structuredPrompt = - guidedService.buildStructuredGuidedPromptWithContext(history, lessonSlug, userQuery); - - // Stream with structure-aware truncation - preserves semantic boundaries - Flux dataStream = sseSupport.prepareDataStream( - openAIStreamingService.streamResponse(structuredPrompt, DEFAULT_TEMPERATURE), - chunk -> fullResponse.append(chunk)); - - // Heartbeats terminate when data stream completes - Flux> heartbeats = sseSupport.heartbeats(dataStream); - - // Wrap chunks in JSON to preserve whitespace - Flux> dataEvents = dataStream.map(sseSupport::textEvent); - - return Flux.merge(dataEvents, heartbeats) - .doOnComplete(() -> chatMemory.addAssistant(sessionId, fullResponse.toString())) - .onErrorResume(error -> { - // SSE streams require error events rather than thrown exceptions. - // Log full details server-side, send sanitized message to client. - String errorType = error.getClass().getSimpleName(); - log.error("Guided streaming error (exception type: {})", errorType, error); - return sseSupport.sseError( - "Streaming error: " + errorType, - "The response stream encountered an error. Please try again." - ); - }); - - } else { - // Service unavailable - send structured error event - log.warn("OpenAI streaming service unavailable for guided session"); - return sseSupport.sseError("Service temporarily unavailable", "The streaming service is not ready"); - } + StructuredPrompt structuredPrompt = + guidedService.buildStructuredGuidedPromptWithContext(history, lessonSlug, userQuery); + + Flux dataStream = sseSupport.prepareDataStream( + openAIStreamingService.streamResponse(structuredPrompt, DEFAULT_TEMPERATURE), + chunk -> fullResponse.append(chunk)); + + Flux> heartbeats = sseSupport.heartbeats(dataStream); + Flux> dataEvents = dataStream.map(sseSupport::textEvent); + + return Flux.merge(dataEvents, heartbeats) + .doOnComplete(() -> chatMemory.addAssistant(sessionId, fullResponse.toString())) + .onErrorResume(error -> { + String errorType = error.getClass().getSimpleName(); + log.error("Guided streaming error (exception type: {})", errorType, error); + return sseSupport.sseError( + "Streaming error: " + errorType, + "The response stream encountered an error. Please try again." + ); + }); } /** From 5c0f0b42c9956063c7690961844f3ada698de588 Mon Sep 17 00:00:00 2001 From: William Callahan Date: Sun, 25 Jan 2026 13:30:37 -0800 Subject: [PATCH 15/79] docs(config): clarify no auto-fallback between LLM providers per LM2 --- docs/configuration.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/configuration.md b/docs/configuration.md index 35c1544..7a69bc7 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -15,7 +15,7 @@ Streaming uses the OpenAI Java SDK (`OpenAIStreamingService`) and supports: - **GitHub Models** via `GITHUB_TOKEN` - **OpenAI** via `OPENAI_API_KEY` -If both keys are present, the service prefers OpenAI for streaming and can fall back to GitHub Models based on rate-limit/backoff state. +If both keys are present, the service prefers OpenAI for streaming. There is no automatic cross-provider fallback; if the preferred provider fails or is rate-limited, the error is surfaced to the client rather than silently switching providers. Common variables: From 073f924389fed351e1a87bc88721a9ba103f8ed8 Mon Sep 17 00:00:00 2001 From: William Callahan Date: Sun, 25 Jan 2026 13:30:37 -0800 Subject: [PATCH 16/79] docs(readme): document JTokkit CL100K_BASE tokenizer for chunking --- README.md | 1 + 1 file changed, 1 insertion(+) diff --git a/README.md b/README.md index 79af573..c35f852 100644 --- a/README.md +++ b/README.md @@ -9,6 +9,7 @@ Built with Spring Boot + WebFlux, Svelte, and Qdrant. - Streaming chat over SSE (`/api/chat/stream`) with a final `citation` event - Guided learning mode (`/learn`) with lesson-scoped chat (`/api/guided/*`) - Documentation ingestion pipeline (fetch → chunk → embed → dedupe → index) +- Chunking uses JTokkit's CL100K_BASE tokenizer (GPT-3.5/4 style) for token counting - Embedding fallbacks: local embedding server → remote/OpenAI → hash fallback ## Quick start From 2d80fdc0ebf236ac5def297bc6c652c07b1d6ff3 Mon Sep 17 00:00:00 2001 From: William Callahan Date: Sun, 25 Jan 2026 13:30:38 -0800 Subject: [PATCH 17/79] fix(support): guard page anchor estimation to Think Java PDF only --- .../javachat/support/PdfCitationEnhancer.java | 23 +++++++++++++++++++ 1 file changed, 23 insertions(+) diff --git a/src/main/java/com/williamcallahan/javachat/support/PdfCitationEnhancer.java b/src/main/java/com/williamcallahan/javachat/support/PdfCitationEnhancer.java index 935d14b..428c864 100644 --- a/src/main/java/com/williamcallahan/javachat/support/PdfCitationEnhancer.java +++ b/src/main/java/com/williamcallahan/javachat/support/PdfCitationEnhancer.java @@ -38,6 +38,12 @@ public class PdfCitationEnhancer { /** Classpath location of the Think Java PDF. */ private static final String THINK_JAVA_PDF_CLASSPATH = "public/pdfs/Think Java - 2nd Edition Book.pdf"; + /** Filename pattern used to identify Think Java PDF URLs. */ + private static final String THINK_JAVA_PDF_FILENAME = "think java"; + + /** URL-encoded variant of Think Java filename. */ + private static final String THINK_JAVA_PDF_FILENAME_ENCODED = "think%20java"; + public PdfCitationEnhancer(LocalStoreService localStore) { this.localStore = localStore; } @@ -72,6 +78,11 @@ public List enhanceWithPageAnchors(List docs, List continue; } + // Only apply page estimation to the Think Java PDF where we know the page count + if (!isThinkJavaPdf(url)) { + continue; + } + int chunkIndex = parseChunkIndex(document); if (chunkIndex < 0) { continue; @@ -192,4 +203,16 @@ private static String sanitizeForLogText(String rawText) { } return rawText.replace("\r", "\\r").replace("\n", "\\n"); } + + /** + * Checks if the URL refers to the Think Java PDF. + * + * @param url the citation URL + * @return true if the URL appears to be the Think Java PDF + */ + private static boolean isThinkJavaPdf(String url) { + String normalized = url.toLowerCase(Locale.ROOT); + return normalized.contains(THINK_JAVA_PDF_FILENAME) + || normalized.contains(THINK_JAVA_PDF_FILENAME_ENCODED); + } } From 51ec645d9ae9e9e1e8d1602a22a4ce500484da6d Mon Sep 17 00:00:00 2001 From: William Callahan Date: Sun, 25 Jan 2026 13:34:32 -0800 Subject: [PATCH 18/79] feat(guided): stream citations for guided lesson chat responses Guided lesson chat was generating in-text citation markers [1], [2] but not displaying actual sources. This adds end-to-end citation streaming: the backend returns Think Java context documents alongside the prompt, streams a citation event after the response completes, and the frontend displays per-message citation panels identical to the main chat. Backend: - Return GuidedChatPromptOutcome (prompt + context docs) from service - Add citationsForBookDocuments() with PDF page anchor enrichment - Stream citation SSE event at end of guided chat response - Remove [n] marker instructions from prompts (UI shows sources separately) Frontend: - Add onCitations callback to streamGuidedChat() - Add fetchGuidedLessonCitations() for lesson-level sources - Display per-message citations via CitationPanel in LearnView - Extract LessonCitations component for lesson-level citation display - Support custom messageRenderer snippet in MobileChatDrawer --- frontend/src/lib/components/LearnView.svelte | 242 +++++++----------- .../src/lib/components/LessonCitations.svelte | 45 ++++ .../lib/components/MobileChatDrawer.svelte | 5 + frontend/src/lib/services/guided.ts | 23 +- .../service/GuidedLearningService.java | 59 ++++- .../web/GuidedLearningController.java | 14 +- 6 files changed, 230 insertions(+), 158 deletions(-) create mode 100644 frontend/src/lib/components/LessonCitations.svelte diff --git a/frontend/src/lib/components/LearnView.svelte b/frontend/src/lib/components/LearnView.svelte index cc827ba..95fc1ff 100644 --- a/frontend/src/lib/components/LearnView.svelte +++ b/frontend/src/lib/components/LearnView.svelte @@ -1,13 +1,15 @@ + +{#if loaded && error} +
+ Unable to load lesson sources +
+{:else if loaded && citations.length > 0} +
+ +
+{/if} + + + diff --git a/frontend/src/lib/components/MobileChatDrawer.svelte b/frontend/src/lib/components/MobileChatDrawer.svelte index d5cea0c..9ead45e 100644 --- a/frontend/src/lib/components/MobileChatDrawer.svelte +++ b/frontend/src/lib/components/MobileChatDrawer.svelte @@ -1,4 +1,5 @@
- {#each messages as message, messageIndex (message.timestamp)} + {#each messages as message, messageIndex (message.messageId)} + {@const messageIsStreaming = isStreaming && !!streamingMessageId && message.messageId === streamingMessageId} {#if messageRenderer} - {@render messageRenderer({ message, index: messageIndex })} + {@render messageRenderer({ message, index: messageIndex, isStreaming: messageIsStreaming })} {:else} - + {/if} {/each} - {#if isStreaming && streamingContent} - - {:else if isStreaming} + {#if isStreaming && !hasContent} {/if}
diff --git a/frontend/src/lib/services/chat.ts b/frontend/src/lib/services/chat.ts index 826a8b1..6e5fd90 100644 --- a/frontend/src/lib/services/chat.ts +++ b/frontend/src/lib/services/chat.ts @@ -11,6 +11,8 @@ import type { StreamStatus, StreamError } from './stream-types' export type { StreamStatus, StreamError } export interface ChatMessage { + /** Stable client-side identifier for rendering and list keying. */ + messageId: string role: 'user' | 'assistant' content: string timestamp: number @@ -28,6 +30,7 @@ export interface StreamChatOptions { onStatus?: (status: StreamStatus) => void onError?: (error: StreamError) => void onCitations?: (citations: Citation[]) => void + signal?: AbortSignal } /** Result type for citation fetches - distinguishes empty results from errors. */ @@ -58,10 +61,33 @@ export async function streamChat( onError: options.onError, onCitations: options.onCitations }, - 'chat.ts' + 'chat.ts', + { signal: options.signal } ) } +/** + * Clears the server-side chat memory for a session. + * + * @param sessionId - Session identifier to clear on the backend. + */ +export async function clearChatSession(sessionId: string): Promise { + const normalizedSessionId = sessionId.trim() + if (!normalizedSessionId) { + throw new Error('Session ID is required') + } + + const response = await fetch(`/api/chat/clear?sessionId=${encodeURIComponent(normalizedSessionId)}`, { + method: 'POST' + }) + + if (!response.ok) { + const errorBody = await response.text().catch(() => '') + const suffix = errorBody ? `: ${errorBody}` : '' + throw new Error(`Failed to clear chat session (HTTP ${response.status})${suffix}`) + } +} + /** * Fetch citations for a query. * Used by LearnView to fetch lesson-level citations separately from the chat stream. diff --git a/frontend/src/lib/utils/chatMessageId.ts b/frontend/src/lib/utils/chatMessageId.ts new file mode 100644 index 0000000..c3b4941 --- /dev/null +++ b/frontend/src/lib/utils/chatMessageId.ts @@ -0,0 +1,37 @@ +/** + * Chat message identifier utilities. + * + * Provides stable, collision-resistant IDs for message list keying across + * chat and guided chat rendering paths. + */ + +type MessageContext = 'chat' | 'guided' + +let sequenceNumber = 0 + +function nextSequenceNumber(): number { + sequenceNumber = (sequenceNumber + 1) % 1_000_000 + return sequenceNumber +} + +function createRandomSuffix(): string { + if (typeof crypto !== 'undefined' && typeof crypto.randomUUID === 'function') { + return crypto.randomUUID() + } + return Math.random().toString(36).slice(2, 12) +} + +/** + * Creates a stable identifier for a chat message. + * + * @param context - Message origin (main chat vs guided chat) + * @param sessionId - Stable per-view session identifier + * @returns A stable unique message identifier + */ +export function createChatMessageId(context: MessageContext, sessionId: string): string { + const timestampMs = Date.now() + const sequence = nextSequenceNumber() + const randomSuffix = createRandomSuffix() + return `msg-${context}-${sessionId}-${timestampMs}-${sequence}-${randomSuffix}` +} + From 50a434fd8975364cba07e950573a7d259d13f08c Mon Sep 17 00:00:00 2001 From: William Callahan Date: Sun, 25 Jan 2026 14:16:30 -0800 Subject: [PATCH 23/79] feat(frontend): clear backend session when user clears chat When users cleared chat in LearnView, only the frontend state was reset while the backend session ID remained, allowing old context to leak into "cleared" conversations. This adds clearChatSession() to notify the backend and ensures per-lesson session IDs are properly cleaned up. - Add clearChatSession() in chat.ts to POST /api/chat/clear - Update LearnView.clearChat() to delete sessionIdsByLesson entry - Call clearChatSession() asynchronously with error logging - Apply same message ID pattern from ChatView to LearnView --- frontend/src/lib/components/LearnView.svelte | 213 ++++++++++++------- 1 file changed, 133 insertions(+), 80 deletions(-) diff --git a/frontend/src/lib/components/LearnView.svelte b/frontend/src/lib/components/LearnView.svelte index 95fc1ff..35994d8 100644 --- a/frontend/src/lib/components/LearnView.svelte +++ b/frontend/src/lib/components/LearnView.svelte @@ -1,11 +1,11 @@ ' - const result = parseMarkdown(markdown) - expect(result).not.toContain('' - const result = escapeHtml(input) - expect(result).not.toContain('<') - expect(result).not.toContain('>') - expect(result).toContain('<') - expect(result).toContain('>') + const escapedHtml = escapeHtml(input) + expect(escapedHtml).not.toContain('<') + expect(escapedHtml).not.toContain('>') + expect(escapedHtml).toContain('<') + expect(escapedHtml).toContain('>') }) it('is SSR-safe - uses pure string operations', () => { // This works without document APIs - const result = escapeHtml('
') - expect(result).toBe('<div class="test">') + const escapedHtml = escapeHtml('
') + expect(escapedHtml).toBe('<div class="test">') }) }) diff --git a/frontend/src/lib/utils/url.test.ts b/frontend/src/lib/utils/url.test.ts index 7ab3d24..8d8dede 100644 --- a/frontend/src/lib/utils/url.test.ts +++ b/frontend/src/lib/utils/url.test.ts @@ -60,8 +60,8 @@ describe('deduplicateCitations', () => { }) it('returns empty array for null/undefined input', () => { - expect(deduplicateCitations(null as unknown as [])).toEqual([]) - expect(deduplicateCitations(undefined as unknown as [])).toEqual([]) + expect(deduplicateCitations(null)).toEqual([]) + expect(deduplicateCitations(undefined)).toEqual([]) }) it('removes duplicate URLs', () => { @@ -70,9 +70,9 @@ describe('deduplicateCitations', () => { { url: 'https://b.com', title: 'B' }, { url: 'https://a.com', title: 'A duplicate' } ] - const result = deduplicateCitations(citations) - expect(result).toHaveLength(2) - expect(result.map(c => c.url)).toEqual(['https://a.com', 'https://b.com']) + const deduplicatedCitations = deduplicateCitations(citations) + expect(deduplicatedCitations).toHaveLength(2) + expect(deduplicatedCitations.map(c => c.url)).toEqual(['https://a.com', 'https://b.com']) }) it('keeps first occurrence when deduplicating', () => { @@ -80,8 +80,8 @@ describe('deduplicateCitations', () => { { url: 'https://a.com', title: 'First' }, { url: 'https://a.com', title: 'Second' } ] - const result = deduplicateCitations(citations) - expect(result[0].title).toBe('First') + const deduplicatedCitations = deduplicateCitations(citations) + expect(deduplicatedCitations[0].title).toBe('First') }) }) diff --git a/frontend/src/lib/utils/url.ts b/frontend/src/lib/utils/url.ts index 986e3d8..e635feb 100644 --- a/frontend/src/lib/utils/url.ts +++ b/frontend/src/lib/utils/url.ts @@ -177,16 +177,11 @@ interface HasUrl { * Deduplicates an array of objects by URL (case-insensitive). * Filters out objects with missing or invalid URL properties. * - * @param citations - Array of objects with url property + * @param citations - Array of objects with url property (null/undefined treated as empty) * @returns Deduplicated array preserving original order - * @throws Warning logged if input is not an array (indicates caller bug) */ -export function deduplicateCitations(citations: T[]): T[] { - if (!Array.isArray(citations)) { - console.warn('[url.ts] deduplicateCitations received non-array input:', { - receivedType: typeof citations, - value: citations - }) +export function deduplicateCitations(citations: readonly T[] | null | undefined): T[] { + if (!citations || citations.length === 0) { return [] } const seen = new Set() diff --git a/frontend/vitest.config.ts b/frontend/vitest.config.ts index 0c35597..b1337b1 100644 --- a/frontend/vitest.config.ts +++ b/frontend/vitest.config.ts @@ -3,6 +3,12 @@ import { svelte } from '@sveltejs/vite-plugin-svelte' export default defineConfig({ plugins: [svelte({ hot: !process.env.VITEST })], + // Vitest runs modules through Vite's SSR pipeline by default, which can cause + // conditional exports to resolve Svelte's server entry (where `mount()` is unavailable). + // Force browser conditions so component tests can mount under jsdom. + resolve: { + conditions: ['module', 'browser', 'development'] + }, test: { environment: 'jsdom', globals: true, From cce3de94450a2873ee5bbc0f7870fd72e0104e7b Mon Sep 17 00:00:00 2001 From: William Callahan Date: Sun, 25 Jan 2026 14:21:41 -0800 Subject: [PATCH 29/79] refactor(frontend): remove streamingContent from composable, add abort support The streaming architecture duplicated content state: createStreamingState held streamingContent while ChatView/LearnView also tracked it in messages. This caused state synchronization issues and prevented proper stream cancellation. Composable changes: - Remove streamingContent and appendContent() from createStreamingState - Remove streamingContent from StreamingChatFields interface - Messages now own their content; composable only tracks status SSE abort support: - Add AbortSignal support to streamSse() via options parameter - Silently return on abort instead of throwing (expected cancellation) - Add signal to GuidedStreamCallbacks for guided chat cancellation LearnView cancellation: - Add guidedChatAbortController for in-flight stream cancellation - Add guidedChatStreamVersion for stale callback rejection - cancelInFlightGuidedChatStream() aborts and resets state - Call cancel on lesson close and chat clear MobileChatDrawer alignment: - Remove streamingContent prop, add hasContent and streamingMessageId - Update messageRenderer snippet signature to include isStreaming flag --- frontend/src/lib/components/LearnView.svelte | 214 ++++++++++-------- .../lib/components/MobileChatDrawer.svelte | 13 +- .../createStreamingState.svelte.ts | 19 +- frontend/src/lib/services/guided.ts | 6 +- frontend/src/lib/services/sse.ts | 42 +++- frontend/src/lib/services/stream-types.ts | 1 - 6 files changed, 174 insertions(+), 121 deletions(-) diff --git a/frontend/src/lib/components/LearnView.svelte b/frontend/src/lib/components/LearnView.svelte index 35994d8..1325e47 100644 --- a/frontend/src/lib/components/LearnView.svelte +++ b/frontend/src/lib/components/LearnView.svelte @@ -1,14 +1,13 @@ * * {#if streaming.isStreaming} - * + * * {/if} * ``` */ @@ -82,7 +78,6 @@ export function createStreamingState(options: StreamingStateOptions = {}): Strea // Internal reactive state let isStreaming = $state(false) - let streamingContent = $state('') let statusMessage = $state('') let statusDetails = $state('') @@ -121,9 +116,6 @@ export function createStreamingState(options: StreamingStateOptions = {}): Strea get isStreaming() { return isStreaming }, - get streamingContent() { - return streamingContent - }, get statusMessage() { return statusMessage }, @@ -135,15 +127,10 @@ export function createStreamingState(options: StreamingStateOptions = {}): Strea startStream() { cancelStatusTimer() isStreaming = true - streamingContent = '' statusMessage = '' statusDetails = '' }, - appendContent(chunk: string) { - streamingContent += chunk - }, - updateStatus(status: StreamStatus) { statusMessage = status.message statusDetails = status.details ?? '' @@ -151,14 +138,12 @@ export function createStreamingState(options: StreamingStateOptions = {}): Strea finishStream() { isStreaming = false - streamingContent = '' clearStatusDelayed() }, reset() { cancelStatusTimer() isStreaming = false - streamingContent = '' statusMessage = '' statusDetails = '' }, diff --git a/frontend/src/lib/services/guided.ts b/frontend/src/lib/services/guided.ts index 858eda4..591a267 100644 --- a/frontend/src/lib/services/guided.ts +++ b/frontend/src/lib/services/guided.ts @@ -31,6 +31,7 @@ export interface GuidedStreamCallbacks { onStatus?: (status: StreamStatus) => void onError?: (error: Error) => void onCitations?: (citations: Citation[]) => void + signal?: AbortSignal } /** @@ -95,7 +96,7 @@ export async function streamGuidedChat( message: string, callbacks: GuidedStreamCallbacks ): Promise { - const { onChunk, onStatus, onError, onCitations } = callbacks + const { onChunk, onStatus, onError, onCitations, signal } = callbacks let errorNotified = false try { @@ -111,7 +112,8 @@ export async function streamGuidedChat( onError?.(new Error(streamError.message)) } }, - 'guided.ts' + 'guided.ts', + { signal } ) } catch (error) { // Re-throw after invoking callback to maintain dual error propagation diff --git a/frontend/src/lib/services/sse.ts b/frontend/src/lib/services/sse.ts index 34fd31c..50a4024 100644 --- a/frontend/src/lib/services/sse.ts +++ b/frontend/src/lib/services/sse.ts @@ -14,6 +14,11 @@ const SSE_EVENT_STATUS = 'status' const SSE_EVENT_ERROR = 'error' const SSE_EVENT_CITATION = 'citation' +/** Optional request options for streaming fetch calls. */ +export interface StreamSseRequestOptions { + signal?: AbortSignal +} + /** Callbacks for SSE stream processing. */ export interface SseCallbacks { onText: (content: string) => void @@ -22,6 +27,10 @@ export interface SseCallbacks { onCitations?: (citations: Citation[]) => void } +function isAbortError(error: unknown): boolean { + return error instanceof Error && error.name === 'AbortError' +} + /** * Attempts JSON parsing only when content looks like JSON. * Returns parsed object or null for plain text content. @@ -121,15 +130,27 @@ export async function streamSse( url: string, body: object, callbacks: SseCallbacks, - source: string + source: string, + options: StreamSseRequestOptions = {} ): Promise { - const response = await fetch(url, { - method: 'POST', - headers: { - 'Content-Type': 'application/json' - }, - body: JSON.stringify(body) - }) + const abortSignal = options.signal + let response: Response + + try { + response = await fetch(url, { + method: 'POST', + headers: { + 'Content-Type': 'application/json' + }, + body: JSON.stringify(body), + signal: abortSignal + }) + } catch (fetchError) { + if (abortSignal?.aborted || isAbortError(fetchError)) { + return + } + throw fetchError + } if (!response.ok) { const httpError = new Error(`HTTP ${response.status}: ${response.statusText}`) @@ -231,6 +252,11 @@ export async function streamSse( } } } + } catch (streamError) { + if (abortSignal?.aborted || isAbortError(streamError)) { + return + } + throw streamError } finally { // Cancel reader on abnormal exit to prevent dangling connections if (!streamCompletedNormally) { diff --git a/frontend/src/lib/services/stream-types.ts b/frontend/src/lib/services/stream-types.ts index 34bad13..096ff4b 100644 --- a/frontend/src/lib/services/stream-types.ts +++ b/frontend/src/lib/services/stream-types.ts @@ -26,7 +26,6 @@ export interface StreamError { */ export interface StreamingChatFields { isStreaming: boolean - streamingContent: string statusMessage: string statusDetails: string } From eb1bcc675535277afa1d4d8c652fcb34ec1418b4 Mon Sep 17 00:00:00 2001 From: William Callahan Date: Sun, 25 Jan 2026 14:22:12 -0800 Subject: [PATCH 30/79] fix(frontend): defer syntax highlighting until stream completes Syntax highlighting during streaming caused visual flicker as Prism repeatedly processed partial code blocks. This defers highlighting until isStreaming becomes false, while preserving immediate highlighting for static content. - Skip applyJavaLanguageDetection and scheduleHighlight when isStreaming - Return cleanup function early to prevent partial highlighting - Add jsdom polyfills for scrollTo and requestAnimationFrame in test setup - Add component tests for MessageBubble copy action visibility - Add streaming stability test for ChatView message DOM persistence --- frontend/src/lib/components/ChatView.test.ts | 69 +++++++++++++++++++ .../src/lib/components/MessageBubble.svelte | 3 + .../src/lib/components/MessageBubble.test.ts | 28 ++++++++ frontend/src/test/setup.ts | 11 +++ 4 files changed, 111 insertions(+) create mode 100644 frontend/src/lib/components/ChatView.test.ts create mode 100644 frontend/src/lib/components/MessageBubble.test.ts diff --git a/frontend/src/lib/components/ChatView.test.ts b/frontend/src/lib/components/ChatView.test.ts new file mode 100644 index 0000000..044ec70 --- /dev/null +++ b/frontend/src/lib/components/ChatView.test.ts @@ -0,0 +1,69 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest' +import { render, fireEvent } from '@testing-library/svelte' +import { tick } from 'svelte' + +const streamChatMock = vi.fn() + +vi.mock('../services/chat', async () => { + const actualChatService = await vi.importActual('../services/chat') + return { + ...actualChatService, + streamChat: streamChatMock + } +}) + +async function renderChatView() { + const ChatViewComponent = (await import('./ChatView.svelte')).default + return render(ChatViewComponent) +} + +describe('ChatView streaming stability', () => { + beforeEach(() => { + streamChatMock.mockReset() + }) + + it('keeps the assistant message DOM node stable when the stream completes', async () => { + let completeStream: (() => void) | null = null + + streamChatMock.mockImplementation(async (_sessionId, _message, onChunk, options) => { + options?.onStatus?.({ message: 'Searching', details: 'Loading sources' }) + + await Promise.resolve() + onChunk('Hello') + + await Promise.resolve() + options?.onCitations?.([{ url: 'https://example.com', title: 'Example' }]) + + return new Promise((resolve) => { + completeStream = resolve + }) + }) + + const { getByLabelText, getByRole, container, findByText } = await renderChatView() + + const inputElement = getByLabelText('Message input') as HTMLTextAreaElement + await fireEvent.input(inputElement, { target: { value: 'Hi' } }) + + const sendButton = getByRole('button', { name: 'Send message' }) + await fireEvent.click(sendButton) + + const assistantTextElement = await findByText('Hello') + await tick() + + const assistantMessageElement = assistantTextElement.closest('.message.assistant') + expect(assistantMessageElement).not.toBeNull() + + expect(container.querySelector('.message.assistant .cursor.visible')).not.toBeNull() + + expect(completeStream).not.toBeNull() + completeStream?.() + await tick() + + const assistantTextElementAfter = await findByText('Hello') + const assistantMessageElementAfter = assistantTextElementAfter.closest('.message.assistant') + + expect(assistantMessageElementAfter).toBe(assistantMessageElement) + expect(container.querySelector('.message.assistant .cursor.visible')).toBeNull() + }) +}) + diff --git a/frontend/src/lib/components/MessageBubble.svelte b/frontend/src/lib/components/MessageBubble.svelte index 4f2098d..bfebeca 100644 --- a/frontend/src/lib/components/MessageBubble.svelte +++ b/frontend/src/lib/components/MessageBubble.svelte @@ -30,6 +30,9 @@ // Debounced to avoid flicker during streaming $effect(() => { if (renderedContent && contentEl) { + if (isStreaming) { + return cleanupHighlighter + } // Apply Java language detection before highlighting (client-side DOM operation) applyJavaLanguageDetection(contentEl) scheduleHighlight(contentEl, isStreaming) diff --git a/frontend/src/lib/components/MessageBubble.test.ts b/frontend/src/lib/components/MessageBubble.test.ts new file mode 100644 index 0000000..cb87fec --- /dev/null +++ b/frontend/src/lib/components/MessageBubble.test.ts @@ -0,0 +1,28 @@ +import { describe, it, expect } from 'vitest' +import { render } from '@testing-library/svelte' +import MessageBubble from './MessageBubble.svelte' + +describe('MessageBubble', () => { + it('does not render copy action for user messages', () => { + const { container } = render(MessageBubble, { + props: { + message: { messageId: 'msg-test-user', role: 'user', content: 'Hello', timestamp: 1 }, + index: 0 + } + }) + + expect(container.querySelector('.bubble-actions')).toBeNull() + }) + + it('renders copy action for assistant messages', () => { + const { container, getByRole } = render(MessageBubble, { + props: { + message: { messageId: 'msg-test-assistant', role: 'assistant', content: 'Hello', timestamp: 1 }, + index: 0 + } + }) + + expect(container.querySelector('.bubble-actions')).not.toBeNull() + expect(getByRole('button', { name: /copy message/i, hidden: true })).toBeInTheDocument() + }) +}) diff --git a/frontend/src/test/setup.ts b/frontend/src/test/setup.ts index 6422862..4a14fc8 100644 --- a/frontend/src/test/setup.ts +++ b/frontend/src/test/setup.ts @@ -14,3 +14,14 @@ Object.defineProperty(window, 'matchMedia', { dispatchEvent: () => false }) }) + +// jsdom doesn't implement scrollTo on elements; components use it for chat auto-scroll. +Object.defineProperty(HTMLElement.prototype, 'scrollTo', { + writable: true, + value: () => {} +}) + +// requestAnimationFrame is used for post-update DOM adjustments; provide a safe fallback. +if (typeof window.requestAnimationFrame !== 'function') { + window.requestAnimationFrame = (callback: FrameRequestCallback) => window.setTimeout(() => callback(performance.now()), 0) +} From f71db26138e3513a00c50f2044879d00e53b34be Mon Sep 17 00:00:00 2001 From: William Callahan Date: Sun, 25 Jan 2026 14:23:19 -0800 Subject: [PATCH 31/79] refactor(frontend): extract GuidedLessonChatPanel from LearnView LearnView.svelte was growing large with duplicated chat panel markup for desktop and mobile views. This extracts the desktop chat panel into a dedicated component that encapsulates the header, messages list, and input. - Create GuidedLessonChatPanel.svelte with all chat UI and styling - Expose getMessagesContainer() for parent scroll management - Accept lessonTitle, onClear, onSend, onScroll as props - Reduce LearnView by ~50 lines of template and ~100 lines of CSS - Add streaming stability test for LearnView guided chat --- .../components/GuidedLessonChatPanel.svelte | 238 ++++++++++++++++++ frontend/src/lib/components/LearnView.svelte | 214 ++-------------- frontend/src/lib/components/LearnView.test.ts | 88 +++++++ 3 files changed, 349 insertions(+), 191 deletions(-) create mode 100644 frontend/src/lib/components/GuidedLessonChatPanel.svelte create mode 100644 frontend/src/lib/components/LearnView.test.ts diff --git a/frontend/src/lib/components/GuidedLessonChatPanel.svelte b/frontend/src/lib/components/GuidedLessonChatPanel.svelte new file mode 100644 index 0000000..15a09ec --- /dev/null +++ b/frontend/src/lib/components/GuidedLessonChatPanel.svelte @@ -0,0 +1,238 @@ + + +
+
+ + Ask about this lesson + {#if messages.length > 0} + + {/if} +
+ +
+ {#if messages.length === 0 && !isStreaming} +
+

Have questions about {lessonTitle}?

+

Ask anything about the concepts in this lesson.

+
+ {:else} + + {#snippet messageRenderer({ message, index, isStreaming })} + {@const typedMessage = message as MessageWithCitations} +
+ + {#if typedMessage.role === 'assistant' && typedMessage.citations && typedMessage.citations.length > 0 && !typedMessage.isError} + + {/if} +
+ {/snippet} +
+ {/if} +
+ + +
+ + + diff --git a/frontend/src/lib/components/LearnView.svelte b/frontend/src/lib/components/LearnView.svelte index 1325e47..ba4cb08 100644 --- a/frontend/src/lib/components/LearnView.svelte +++ b/frontend/src/lib/components/LearnView.svelte @@ -489,55 +489,19 @@
-
-
- - Ask about this lesson - {#if messages.length > 0} - - {/if} -
- -
- {#if messages.length === 0 && !streaming.isStreaming} -
-

Have questions about {selectedLesson.title}?

-

Ask anything about the concepts in this lesson.

-
- {:else} - - {#snippet messageRenderer({ message, index, isStreaming })} - {@const typedMessage = message as MessageWithCitations} -
- - {#if typedMessage.role === 'assistant' && typedMessage.citations && typedMessage.citations.length > 0 && !typedMessage.isError} - - {/if} -
- {/snippet} -
- {/if} -
- - -
+
@@ -902,151 +866,19 @@ color: var(--color-text-secondary); } - /* Chat Panel - Pinned Frame */ - .chat-panel { - display: flex; - flex-direction: column; - height: 100%; - min-height: 0; /* Critical: allows flex children to shrink for scrolling */ - overflow: hidden; /* Contains scrolling to messages-container only */ - background: var(--color-bg-primary); - border-left: 1px solid var(--color-border-default); - box-shadow: -4px 0 24px rgba(0, 0, 0, 0.15); - position: relative; - } - - /* Subtle pinned indicator line at top */ - .chat-panel::before { - content: ''; - position: absolute; - top: 0; - left: 0; - right: 0; - height: 2px; - background: linear-gradient(90deg, var(--color-accent-muted) 0%, var(--color-accent) 50%, var(--color-accent-muted) 100%); - opacity: 0.6; - z-index: 1; - } - - .chat-panel-header { - flex-shrink: 0; /* Never shrink - stays at top */ - display: flex; - align-items: center; - gap: var(--space-2); - padding: var(--space-3) var(--space-4); - border-bottom: 1px solid var(--color-border-subtle); - background: var(--color-bg-secondary); - font-size: var(--text-sm); - font-weight: 500; - color: var(--color-text-secondary); - } - - .chat-panel-header > svg { - flex-shrink: 0; - width: 18px; - height: 18px; - color: var(--color-accent); - } - - .chat-panel-header > span { - flex: 1; - } - - .clear-chat-btn { - flex-shrink: 0; - display: flex; - align-items: center; - justify-content: center; - width: 28px; - height: 28px; - padding: 0; - background: transparent; - border: none; - border-radius: var(--radius-md); - color: var(--color-text-tertiary); - cursor: pointer; - transition: all var(--duration-fast) var(--ease-out); - } - - .clear-chat-btn:hover { - background: var(--color-bg-tertiary); - color: var(--color-text-secondary); - } - - .clear-chat-btn svg { - width: 16px; - height: 16px; - } - - .messages-container { - flex: 1; /* Takes all remaining space between header and input */ - min-height: 0; /* Critical: allows overflow scroll to work in flexbox */ - overflow-y: auto; - overflow-x: hidden; - padding: var(--space-4); - } - - @media (prefers-reduced-motion: no-preference) { - .messages-container { - scroll-behavior: smooth; - } - } - - .chat-empty { - display: flex; - flex-direction: column; - align-items: center; - justify-content: center; - height: 100%; - text-align: center; - color: var(--color-text-secondary); - padding: var(--space-6); - } - - .chat-empty p { - margin: 0; - } - - .chat-empty .hint { - font-size: var(--text-sm); - color: var(--color-text-tertiary); - margin-top: var(--space-2); - } - - /* ChatInput pinned within chat-panel - the :global selector targets ChatInput's wrapper */ - .chat-panel :global(.input-area) { - flex-shrink: 0; /* Never shrink - stays pinned at bottom */ - border-top: 1px solid var(--color-border-subtle); - background: var(--color-bg-secondary); - padding: var(--space-3); - } - - .chat-panel :global(.input-container) { - max-width: none; /* Use full width within panel */ - } - - .chat-panel :global(.input-hint) { - display: none; /* Hide hints in compact panel view */ - } - - /* Intermediate breakpoint: narrower chat panel on medium screens */ - @media (max-width: 1280px) and (min-width: 1025px) { - .lesson-layout { - grid-template-columns: 1fr 400px; + /* Intermediate breakpoint: narrower chat panel on medium screens */ + @media (max-width: 1280px) and (min-width: 1025px) { + .lesson-layout { + grid-template-columns: 1fr 400px; } } - /* Responsive: Stack on smaller screens with flexible heights */ - @media (max-width: 1024px) { - /* Hide desktop chat panel (mobile uses MobileChatDrawer component) */ - .chat-panel--desktop { - display: none; - } - - /* Lesson content takes full height on mobile */ - .lesson-layout { - display: block; - } + /* Responsive: Stack on smaller screens with flexible heights */ + @media (max-width: 1024px) { + /* Lesson content takes full height on mobile */ + .lesson-layout { + display: block; + } .lesson-content-panel { height: 100%; diff --git a/frontend/src/lib/components/LearnView.test.ts b/frontend/src/lib/components/LearnView.test.ts new file mode 100644 index 0000000..1a0ded7 --- /dev/null +++ b/frontend/src/lib/components/LearnView.test.ts @@ -0,0 +1,88 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest' +import { render, fireEvent } from '@testing-library/svelte' +import { tick } from 'svelte' + +const fetchTocMock = vi.fn() +const fetchLessonContentMock = vi.fn() +const fetchGuidedLessonCitationsMock = vi.fn() +const streamGuidedChatMock = vi.fn() + +vi.mock('../services/guided', async () => { + const actualGuidedService = await vi.importActual('../services/guided') + return { + ...actualGuidedService, + fetchTOC: fetchTocMock, + fetchLessonContent: fetchLessonContentMock, + fetchGuidedLessonCitations: fetchGuidedLessonCitationsMock, + streamGuidedChat: streamGuidedChatMock + } +}) + +async function renderLearnView() { + const LearnViewComponent = (await import('./LearnView.svelte')).default + return render(LearnViewComponent) +} + +describe('LearnView guided chat streaming stability', () => { + beforeEach(() => { + fetchTocMock.mockReset() + fetchLessonContentMock.mockReset() + fetchGuidedLessonCitationsMock.mockReset() + streamGuidedChatMock.mockReset() + }) + + it('keeps the guided assistant message DOM node stable when the stream completes', async () => { + fetchTocMock.mockResolvedValue([ + { slug: 'intro', title: 'Test Lesson', summary: 'Lesson summary', keywords: [] } + ]) + + fetchLessonContentMock.mockResolvedValue({ markdown: '# Lesson', cached: false }) + fetchGuidedLessonCitationsMock.mockResolvedValue({ success: true, citations: [] }) + + let completeStream: (() => void) | null = null + streamGuidedChatMock.mockImplementation(async (_sessionId, _slug, _message, callbacks) => { + callbacks.onStatus?.({ message: 'Searching', details: 'Loading sources' }) + + await Promise.resolve() + callbacks.onChunk('Hello') + + await Promise.resolve() + callbacks.onCitations?.([{ url: 'https://example.com', title: 'Example' }]) + + return new Promise((resolve) => { + completeStream = resolve + }) + }) + + const { findByRole, getByLabelText, getByRole, container, findByText } = await renderLearnView() + + const lessonButton = await findByRole('button', { name: /test lesson/i }) + await fireEvent.click(lessonButton) + + const inputElement = getByLabelText('Message input') as HTMLTextAreaElement + await fireEvent.input(inputElement, { target: { value: 'Hi' } }) + + const sendButton = getByRole('button', { name: 'Send message' }) + await fireEvent.click(sendButton) + + const assistantTextElement = await findByText('Hello') + await tick() + + const assistantMessageElement = assistantTextElement.closest('.chat-panel--desktop .message.assistant') + expect(assistantMessageElement).not.toBeNull() + + expect(container.querySelector('.chat-panel--desktop .message.assistant .cursor.visible')).not.toBeNull() + + if (!completeStream) { + throw new Error('Expected guided stream completion callback to be set') + } + completeStream() + await tick() + + const assistantTextElementAfter = await findByText('Hello') + const assistantMessageElementAfter = assistantTextElementAfter.closest('.chat-panel--desktop .message.assistant') + + expect(assistantMessageElementAfter).toBe(assistantMessageElement) + expect(container.querySelector('.chat-panel--desktop .message.assistant .cursor.visible')).toBeNull() + }) +}) From d6d9e53d29b01821ab65bd71b4f41d4f10de9741 Mon Sep 17 00:00:00 2001 From: William Callahan Date: Sun, 25 Jan 2026 14:23:44 -0800 Subject: [PATCH 32/79] refactor(domain): inline message templates in SearchQualityLevel The enum stored message templates as constructor args with String.format() placeholders, but this added complexity for little benefit. Inlining the messages in formatMessage() makes the code simpler and easier to modify. - Remove messageTemplate field and constructor - Inline messages directly in switch expression - Use string concatenation instead of String.format() for clarity --- .../javachat/domain/SearchQualityLevel.java | 23 ++++++++----------- 1 file changed, 10 insertions(+), 13 deletions(-) diff --git a/src/main/java/com/williamcallahan/javachat/domain/SearchQualityLevel.java b/src/main/java/com/williamcallahan/javachat/domain/SearchQualityLevel.java index d8d97d3..7488d3e 100644 --- a/src/main/java/com/williamcallahan/javachat/domain/SearchQualityLevel.java +++ b/src/main/java/com/williamcallahan/javachat/domain/SearchQualityLevel.java @@ -12,22 +12,22 @@ public enum SearchQualityLevel { /** * No documents were retrieved; LLM must rely on general knowledge. */ - NONE("No relevant documents found. Using general knowledge only."), + NONE, /** * Documents came from keyword/fallback search rather than semantic embeddings. */ - KEYWORD_SEARCH("Found %d documents via keyword search (embedding service unavailable). Results may be less semantically relevant."), + KEYWORD_SEARCH, /** * All retrieved documents are high-quality semantic matches. */ - HIGH_QUALITY("Found %d high-quality relevant documents via semantic search."), + HIGH_QUALITY, /** * Mix of high and lower quality results from semantic search. */ - MIXED_QUALITY("Found %d documents (%d high-quality) via search. Some results may be less relevant."); + MIXED_QUALITY; /** * Minimum content length to consider a document as having substantial content. @@ -35,12 +35,6 @@ public enum SearchQualityLevel { */ private static final int SUBSTANTIAL_CONTENT_THRESHOLD = 100; - private final String messageTemplate; - - SearchQualityLevel(String messageTemplate) { - this.messageTemplate = messageTemplate; - } - /** * Formats the quality message with document counts. * @@ -50,9 +44,12 @@ public enum SearchQualityLevel { */ public String formatMessage(int totalCount, int highQualityCount) { return switch (this) { - case NONE -> messageTemplate; - case KEYWORD_SEARCH, HIGH_QUALITY -> String.format(messageTemplate, totalCount); - case MIXED_QUALITY -> String.format(messageTemplate, totalCount, highQualityCount); + case NONE -> "No relevant documents found. Using general knowledge only."; + case KEYWORD_SEARCH -> "Found " + totalCount + " documents via keyword search (embedding service unavailable). " + + "Results may be less semantically relevant."; + case HIGH_QUALITY -> "Found " + totalCount + " high-quality relevant documents via semantic search."; + case MIXED_QUALITY -> "Found " + totalCount + " documents (" + highQualityCount + " high-quality) via search. " + + "Some results may be less relevant."; }; } From fe989e6130b64f1aca492d57322b8225a245f30f Mon Sep 17 00:00:00 2001 From: William Callahan Date: Sun, 25 Jan 2026 14:24:02 -0800 Subject: [PATCH 33/79] fix: use platform-independent line separators and improve test assertions The guided learning prompt template used literal \n which could cause issues on Windows. Changed to %n format specifiers for platform-independent newlines. Also improved ChatView test assertion to throw explicit error instead of optional chaining. - Replace \n with %n in THINK_JAVA_GUIDANCE_TEMPLATE - Make ChatView test assertion explicit with throw statement --- frontend/src/lib/components/ChatView.test.ts | 7 ++++--- .../javachat/service/GuidedLearningService.java | 14 +++++++------- 2 files changed, 11 insertions(+), 10 deletions(-) diff --git a/frontend/src/lib/components/ChatView.test.ts b/frontend/src/lib/components/ChatView.test.ts index 044ec70..be9e32c 100644 --- a/frontend/src/lib/components/ChatView.test.ts +++ b/frontend/src/lib/components/ChatView.test.ts @@ -55,8 +55,10 @@ describe('ChatView streaming stability', () => { expect(container.querySelector('.message.assistant .cursor.visible')).not.toBeNull() - expect(completeStream).not.toBeNull() - completeStream?.() + if (!completeStream) { + throw new Error('Expected stream completion callback to be set') + } + completeStream() await tick() const assistantTextElementAfter = await findByText('Hello') @@ -66,4 +68,3 @@ describe('ChatView streaming stability', () => { expect(container.querySelector('.message.assistant .cursor.visible')).toBeNull() }) }) - diff --git a/src/main/java/com/williamcallahan/javachat/service/GuidedLearningService.java b/src/main/java/com/williamcallahan/javachat/service/GuidedLearningService.java index c7d8979..2560eac 100644 --- a/src/main/java/com/williamcallahan/javachat/service/GuidedLearningService.java +++ b/src/main/java/com/williamcallahan/javachat/service/GuidedLearningService.java @@ -53,15 +53,15 @@ public class GuidedLearningService { "Use ONLY content grounded in this book for factual claims. " + "Do NOT include footnote references like [1] or a citations section; the UI shows sources separately. " + "Embed learning aids using {{hint:...}}, {{reminder:...}}, {{background:...}}, {{example:...}}, {{warning:...}}. " + - "Prefer short, correct explanations with clear code examples when appropriate. If unsure, state the limitation.\n\n" + - "## Current Lesson Context\n" + - "%s\n\n" + - "## Topic Handling Rules\n" + - "1. Keep all responses focused on the current lesson topic.\n" + + "Prefer short, correct explanations with clear code examples when appropriate. If unsure, state the limitation.%n%n" + + "## Current Lesson Context%n" + + "%s%n%n" + + "## Topic Handling Rules%n" + + "1. Keep all responses focused on the current lesson topic.%n" + "2. If the user sends a greeting (hi, hello, hey, etc.) or off-topic message, " + - "acknowledge it briefly and redirect to the lesson topic with a helpful prompt.\n" + + "acknowledge it briefly and redirect to the lesson topic with a helpful prompt.%n" + "3. For off-topic Java questions, acknowledge the question and gently steer back to the current lesson, " + - "explaining how the lesson topic relates or suggesting they complete this lesson first.\n" + + "explaining how the lesson topic relates or suggesting they complete this lesson first.%n" + "4. Never ignore the lesson context - every response should reinforce learning the current topic."; private final String jdkVersion; From 8d29f301f9d6a728f37a61cdfe8ab5e5df5f0b57 Mon Sep 17 00:00:00 2001 From: William Callahan Date: Sun, 25 Jan 2026 14:25:27 -0800 Subject: [PATCH 34/79] test(frontend): simplify stream completion callback initialization Move the error-throwing null check into the initial function declaration, making the test code cleaner and the intent clearer. The callback starts as a function that throws, then gets replaced by the mock implementation. - Initialize completeStream with throwing function instead of null - Remove separate null check before calling completeStream() - Apply same pattern to both ChatView and LearnView tests --- frontend/src/lib/components/ChatView.test.ts | 7 +++---- frontend/src/lib/components/LearnView.test.ts | 7 +++---- 2 files changed, 6 insertions(+), 8 deletions(-) diff --git a/frontend/src/lib/components/ChatView.test.ts b/frontend/src/lib/components/ChatView.test.ts index be9e32c..958907b 100644 --- a/frontend/src/lib/components/ChatView.test.ts +++ b/frontend/src/lib/components/ChatView.test.ts @@ -23,7 +23,9 @@ describe('ChatView streaming stability', () => { }) it('keeps the assistant message DOM node stable when the stream completes', async () => { - let completeStream: (() => void) | null = null + let completeStream: () => void = () => { + throw new Error('Expected stream completion callback to be set') + } streamChatMock.mockImplementation(async (_sessionId, _message, onChunk, options) => { options?.onStatus?.({ message: 'Searching', details: 'Loading sources' }) @@ -55,9 +57,6 @@ describe('ChatView streaming stability', () => { expect(container.querySelector('.message.assistant .cursor.visible')).not.toBeNull() - if (!completeStream) { - throw new Error('Expected stream completion callback to be set') - } completeStream() await tick() diff --git a/frontend/src/lib/components/LearnView.test.ts b/frontend/src/lib/components/LearnView.test.ts index 1a0ded7..61b112a 100644 --- a/frontend/src/lib/components/LearnView.test.ts +++ b/frontend/src/lib/components/LearnView.test.ts @@ -39,7 +39,9 @@ describe('LearnView guided chat streaming stability', () => { fetchLessonContentMock.mockResolvedValue({ markdown: '# Lesson', cached: false }) fetchGuidedLessonCitationsMock.mockResolvedValue({ success: true, citations: [] }) - let completeStream: (() => void) | null = null + let completeStream: () => void = () => { + throw new Error('Expected guided stream completion callback to be set') + } streamGuidedChatMock.mockImplementation(async (_sessionId, _slug, _message, callbacks) => { callbacks.onStatus?.({ message: 'Searching', details: 'Loading sources' }) @@ -73,9 +75,6 @@ describe('LearnView guided chat streaming stability', () => { expect(container.querySelector('.chat-panel--desktop .message.assistant .cursor.visible')).not.toBeNull() - if (!completeStream) { - throw new Error('Expected guided stream completion callback to be set') - } completeStream() await tick() From f2d83cd6b882542d0795cc2223b85e5271393c91 Mon Sep 17 00:00:00 2001 From: William Callahan Date: Sun, 25 Jan 2026 14:27:49 -0800 Subject: [PATCH 35/79] fix(config): correct Qdrant GHCR image path and reduce log verbosity The docker-compose image path was missing a path segment, causing pull failures. Also removed host/port/tls logging from QdrantClient creation to avoid exposing connection details in logs (security best practice). - Fix image path: ghcr.io/qdrant/qdrant/qdrant:v1.13.0 - Remove host, port, tls from log message in QdrantClientConfig - Remove trailing blank lines from docker-compose file --- docker-compose-qdrant.yml | 3 +-- .../williamcallahan/javachat/config/QdrantClientConfig.java | 3 +-- 2 files changed, 2 insertions(+), 4 deletions(-) diff --git a/docker-compose-qdrant.yml b/docker-compose-qdrant.yml index 11e8c6f..93c6ef2 100644 --- a/docker-compose-qdrant.yml +++ b/docker-compose-qdrant.yml @@ -4,7 +4,7 @@ services: # Pin to v1.13.0 to match io.qdrant:client:1.13.0 from Spring AI 1.1.2 BOM # Upgrading requires Spring AI BOM update to avoid gRPC protocol mismatches # Use GitHub Container Registry to avoid Docker Hub rate limits (per DK1) - image: ghcr.io/qdrant/qdrant:v1.13.0 + image: ghcr.io/qdrant/qdrant/qdrant:v1.13.0 container_name: qdrant ports: - "8087:6333" # REST (mapped into allowed range) @@ -17,4 +17,3 @@ volumes: qdrant_storage: - diff --git a/src/main/java/com/williamcallahan/javachat/config/QdrantClientConfig.java b/src/main/java/com/williamcallahan/javachat/config/QdrantClientConfig.java index 9f0e7f1..d322159 100644 --- a/src/main/java/com/williamcallahan/javachat/config/QdrantClientConfig.java +++ b/src/main/java/com/williamcallahan/javachat/config/QdrantClientConfig.java @@ -53,8 +53,7 @@ public class QdrantClientConfig { @Bean @Primary public QdrantClient qdrantClient() { - log.info("Creating QdrantClient with gRPC keepalive (host={}, port={}, tls={})", - host, port, useTls); + log.info("Creating QdrantClient with gRPC keepalive"); ManagedChannelBuilder channelBuilder = ManagedChannelBuilder.forAddress(host, port); if (useTls) { From 34733c6f69f724d4fd3bbe4fc6a713faf9ad9f2f Mon Sep 17 00:00:00 2001 From: William Callahan Date: Sun, 25 Jan 2026 14:29:27 -0800 Subject: [PATCH 36/79] test(frontend): add SSE abort signal handling tests Verify that streamSse correctly handles AbortSignal cancellation without invoking error callbacks. This ensures stream cancellation (e.g., when navigating away or starting a new message) behaves as expected. - Test fetch abort returns early without callbacks - Test AbortError during read is treated as cancellation - Use vi.stubGlobal for fetch mocking in SSE tests --- frontend/src/lib/services/sse.test.ts | 55 +++++++++++++++++++++++++++ 1 file changed, 55 insertions(+) create mode 100644 frontend/src/lib/services/sse.test.ts diff --git a/frontend/src/lib/services/sse.test.ts b/frontend/src/lib/services/sse.test.ts new file mode 100644 index 0000000..c68c830 --- /dev/null +++ b/frontend/src/lib/services/sse.test.ts @@ -0,0 +1,55 @@ +import { afterEach, describe, expect, it, vi } from 'vitest' +import { streamSse } from './sse' + +describe('streamSse abort handling', () => { + afterEach(() => { + vi.unstubAllGlobals() + vi.restoreAllMocks() + }) + + it('returns without invoking callbacks when fetch is aborted', async () => { + const abortController = new AbortController() + abortController.abort() + + const fetchMock = vi.fn().mockRejectedValue(Object.assign(new Error('Aborted'), { name: 'AbortError' })) + vi.stubGlobal('fetch', fetchMock) + + const onText = vi.fn() + const onError = vi.fn() + + await streamSse( + '/api/test/stream', + { hello: 'world' }, + { onText, onError }, + 'sse.test.ts', + { signal: abortController.signal } + ) + + expect(onText).not.toHaveBeenCalled() + expect(onError).not.toHaveBeenCalled() + }) + + it('treats AbortError during read as a cancellation (no onError)', async () => { + const encoder = new TextEncoder() + const abortError = Object.assign(new Error('Aborted'), { name: 'AbortError' }) + + const responseBody = new ReadableStream({ + start(controller) { + controller.enqueue(encoder.encode('data: {"text":"Hello"}\n\n')) + controller.error(abortError) + } + }) + + const fetchMock = vi.fn().mockResolvedValue({ ok: true, body: responseBody, status: 200, statusText: 'OK' }) + vi.stubGlobal('fetch', fetchMock) + + const onText = vi.fn() + const onError = vi.fn() + + await streamSse('/api/test/stream', { hello: 'world' }, { onText, onError }, 'sse.test.ts') + + expect(onText).toHaveBeenCalledWith('Hello') + expect(onError).not.toHaveBeenCalled() + }) +}) + From 76205cc70708c50082bf5dddf41ce9971f75311e Mon Sep 17 00:00:00 2001 From: William Callahan Date: Sun, 25 Jan 2026 14:31:25 -0800 Subject: [PATCH 37/79] build: add deterministic Java toolchain with Temurin and CI workflow Ensure reproducible builds across local development and CI by pinning JDK vendor and version. Local dev uses mise/asdf for version management while Gradle Toolchains auto-download Temurin if needed. Gradle: - Add foojay-resolver-convention plugin for toolchain downloads - Pin vendor to ADOPTOPENJDK (Temurin) in build.gradle.kts Local dev: - Add .tool-versions for mise/asdf (java = temurin 25) - Add docs/development.md with setup instructions CI: - Add .github/workflows/ci.yml for GitHub Actions - Run tests, build, and static analysis on push/PR to main/dev - Upload reports on failure for debugging Documentation: - Add prerequisites section to README with mise/asdf options - Add toolchain notes to CONTRIBUTING.md --- .github/workflows/build.yml | 70 ++++++++++++ .tool-versions | 1 + CONTRIBUTING.md | 4 + README.md | 23 ++++ build.gradle.kts | 1 + docs/development.md | 222 ++++++++++++++++++++++++++++++++++++ settings.gradle.kts | 4 + 7 files changed, 325 insertions(+) create mode 100644 .github/workflows/build.yml create mode 100644 .tool-versions create mode 100644 docs/development.md diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml new file mode 100644 index 0000000..d657e46 --- /dev/null +++ b/.github/workflows/build.yml @@ -0,0 +1,70 @@ +name: Build & Test + +on: + push: + branches: + - main + - dev + pull_request: + branches: + - main + - dev + +jobs: + build: + runs-on: ubuntu-24.04 + + steps: + - name: Checkout code + uses: actions/checkout@v6 + with: + fetch-depth: 0 + + - name: Set up Temurin JDK + uses: actions/setup-java@v5 + with: + distribution: temurin + java-version: 21 + cache: gradle + + - name: Log system & Java info (drift detection) + run: | + echo "=== Environment Info ===" + uname -a + echo "=== Java Version ===" + java -version + echo "=== Gradle Version ===" + ./gradlew --version + + - name: Run tests + run: ./gradlew test --no-daemon + + - name: Build application + run: ./gradlew build -x test --no-daemon + + - name: Run static analysis + run: ./gradlew spotbugsMain pmdMain --no-daemon + + - name: Upload test reports on failure + if: failure() + uses: actions/upload-artifact@v4 + with: + name: test-results + path: build/test-results/ + retention-days: 7 + + - name: Upload SpotBugs report on failure + if: failure() + uses: actions/upload-artifact@v4 + with: + name: spotbugs-report + path: build/reports/spotbugs/ + retention-days: 7 + + - name: Upload PMD report on failure + if: failure() + uses: actions/upload-artifact@v4 + with: + name: pmd-report + path: build/reports/pmd/ + retention-days: 7 diff --git a/.tool-versions b/.tool-versions new file mode 100644 index 0000000..35cfcf0 --- /dev/null +++ b/.tool-versions @@ -0,0 +1 @@ +java = temurin 25 diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 109664f..ed03508 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -21,6 +21,10 @@ make lint ## Guidelines +- Use the deterministic toolchain: Gradle Wrapper + Gradle Toolchains + Temurin. +- Local Java is managed by mise/asdf via `.tool-versions` (see below). The CI uses the same vendor. +- We pin major Java version in Gradle toolchain and CI; patch is logged in CI and bumped intentionally. + - Keep PRs focused (one change per PR when possible). - Add tests for new behavior. - Update docs when you change workflows or endpoints. diff --git a/README.md b/README.md index 4a3438b..63fb8d7 100644 --- a/README.md +++ b/README.md @@ -14,6 +14,29 @@ Built with Spring Boot + WebFlux, Svelte, and Qdrant. ## Quick start +### Prerequisites + +This project uses **Gradle Toolchains** with **Temurin JDK 25** and **mise** (or **asdf**) for reproducible builds. + +**Option 1: Using mise (recommended)** + +```bash +# Install mise if you don't have it: https://mise.jdnow.dev/ +mise install +``` + +**Option 2: Using asdf** + +```bash +# Install asdf if you don't have it: https://asdf-vm.com/ +asdf plugin add java https://github.com/halcyon/asdf-java.git +asdf install +``` + +**What happens**: Gradle Toolchains will auto-download Temurin JDK 25 on first build if not present locally. The `mise`/`asdf` setup ensures your shell and IDE (IntelliJ) use the correct Java version. + +### Running + ```bash cp .env.example .env # edit .env and set GITHUB_TOKEN or OPENAI_API_KEY diff --git a/build.gradle.kts b/build.gradle.kts index 46fb69c..36b2bef 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -31,6 +31,7 @@ version = "0.0.1-SNAPSHOT" java { toolchain { languageVersion = JavaLanguageVersion.of(javaVersion) + vendor = JvmVendorSpec.ADOPTOPENJDK } } diff --git a/docs/development.md b/docs/development.md new file mode 100644 index 0000000..f8ade2f --- /dev/null +++ b/docs/development.md @@ -0,0 +1,222 @@ +# Development Guide + +## Deterministic Build Setup + +This project uses a reproducible build system across macOS/Linux local dev and CI/CD. + +### Local Development + +#### Prerequisites + +The project is configured to use **Gradle Toolchains** with **Temurin JDK 25** for build-time determinism. + +##### Option 1: Using mise (recommended) + +[mise](https://mise.jdnow.dev/) is a modern version manager that reads `.tool-versions` for Java versioning. + +```bash +# Install mise (one-time) +curl https://mise.jdnow.dev | sh + +# Then, in the repo: +mise install +``` + +This sets `JAVA_HOME` correctly for your terminal, Gradle, and IntelliJ. + +##### Option 2: Using asdf + +[asdf](https://asdf-vm.com/) is a general version manager. + +```bash +# Install asdf (one-time) +git clone https://github.com/asdf-vm/asdf.git ~/.asdf +cd ~/.asdf && git checkout "$(git describe --abbrev=0 --tags)" + +# Add the Java plugin +asdf plugin add java https://github.com/halcyon/asdf-java.git + +# In the repo: +asdf install +``` + +#### How It Works + +1. **`.tool-versions` file** pins `java = temurin 25` (or your desired version). +2. **Gradle Wrapper** (`./gradlew`) pins Gradle 9.2.1. +3. **Gradle Toolchains** auto-downloads Temurin JDK 25 if missing (enabled by Foojay resolver in `settings.gradle.kts`). +4. **Result**: Consistent Java version across: + - Shell commands (`gradle build`, `java -version`) + - IntelliJ "Gradle JVM" setting + - IntelliJ "Project SDK" setting + +#### Verifying Setup + +```bash +# Check Java version (should be Temurin 25.x.x) +java -version + +# Verify Gradle uses correct toolchain +./gradlew --version + +# Build (auto-downloads JDK if needed) +make build +``` + +### CI/CD (GitHub Actions) + +The `.github/workflows/build.yml` workflow: + +- Runs on **ubuntu-24.04** (pinned, not `-latest`) +- Uses `actions/setup-java@v4` with `distribution: temurin` + `java-version: 21` +- Logs Java, Gradle, and OS versions for drift detection +- Uploads test/lint reports on failure + +**Key insight**: CI uses the same JDK vendor as local dev, but may differ in patch version. See [JDK Patch Versioning](#jdk-patch-versioning) below. + +### Gradle Configuration + +#### `settings.gradle.kts` (Foojay Resolver) + +```kotlin +plugins { + id("org.gradle.toolchains.foojay-resolver-convention") version "0.8.0" +} +``` + +This enables **auto-download** of Temurin JDK if missing. Without it, Gradle fails to find the toolchain. + +#### `build.gradle.kts` (Toolchain Vendor) + +```kotlin +java { + toolchain { + languageVersion = JavaLanguageVersion.of(25) + vendor = JvmVendorSpec.ADOPTOPENJDK // Explicitly specify Temurin + } +} +``` + +The explicit vendor removes ambiguity: Gradle will prefer Temurin JDK 25 over other vendors. + +#### `gradle.properties` (Daemon & Caching) + +```properties +org.gradle.parallel=true +org.gradle.caching=true +org.gradle.daemon=true +org.gradle.jvmargs=-Xmx2g -XX:+HeapDumpOnOutOfMemoryError -Dfile.encoding=UTF-8 +``` + +Enables: +- Parallel task execution +- Incremental builds (faster rebuilds) +- Gradle daemon (faster commands) +- 2GB max heap for Gradle itself + +### Docker + +The `Dockerfile` uses `eclipse-temurin:25-jdk` (build) and `eclipse-temurin:25-jre` (runtime), sourced from `public.ecr.aws` (not Docker Hub). + +**Future optimization**: Pin images by SHA256 digest for byte-for-byte reproducibility (e.g., `eclipse-temurin:25-jdk@sha256:abc...`). Trade-off: maintenance burden vs. max determinism. + +--- + +## JDK Patch Versioning + +### The Limitation + +**Gradle Toolchains cannot pin patch-level JDK versions** (e.g., 25.0.3). It can only pin major version (25) + vendor (Temurin). + +Example: +- ✅ **Supported**: Java 25 + Temurin +- ❌ **NOT supported**: Java 25.0.3 + Temurin + +### Strategy + +1. **Local dev**: Patch version determined by `mise`/`asdf` + Foojay resolver. +2. **CI/CD**: GitHub Actions logs exact Java patch version: + ``` + java -version + # Output: openjdk version "25.0.3" 2024-XX-XX + ``` +3. **When to bump patch**: Patch updates to JDK are **intentional commits**. Never rely on automatic patch upgrades. + +### Intentional Patch Bumping + +When you want to upgrade from 25.0.3 → 25.0.4: + +```bash +# Update local version manager +mise use java@temurin 25.0.4 # or asdf local java temurin-25.0.4 + +# Update .tool-versions +cat .tool-versions +# java = temurin 25.0.4 + +# Verify CI picks it up +git add .tool-versions +git commit -m "Bump JDK patch: 25.0.3 → 25.0.4" +``` + +CI will log the new patch version, and you'll have an audit trail. + +--- + +## Common Commands + +### Local Development + +```bash +# Install Java (one-time) +mise install # or: asdf install + +# Build +make build + +# Test +make test + +# Static analysis (SpotBugs + PMD) +make lint + +# Run dev server (Spring Boot + Vite) +make dev + +# Run backend only +make dev-backend + +# Docker stack (Qdrant) +make compose-up +make compose-down +``` + +### Troubleshooting + +#### "gradle: command not found" +- Run `mise install` or `asdf install` to set `JAVA_HOME` +- Verify: `echo $JAVA_HOME` should point to a Temurin 25 JDK + +#### Gradle downloads JDK every time +- Ensure `settings.gradle.kts` has Foojay resolver (added in `0.8.0`) +- Check `~/.gradle/jdks/` for cached toolchains + +#### IntelliJ doesn't pick up Java version +- Open IntelliJ settings → Build, Execution, Deployment → Gradle +- Set "Gradle JVM" to "Use JAVA_HOME" +- Set "Project SDK" to Temurin 25 (or refresh if it's auto-detected) + +#### CI build fails but local works +- Check CI logs for Java version (e.g., `java -version`) +- Ensure your local patch matches: `java -version | grep -o '"[^"]*"'` +- If CI is on 25.0.4 and you're on 25.0.3, bump locally and re-test + +--- + +## References + +- [Gradle Toolchains](https://docs.gradle.org/current/userguide/toolchains.html) +- [Foojay Resolver Convention](https://plugins.gradle.org/plugin/org.gradle.toolchains.foojay-resolver-convention) +- [Eclipse Temurin JDK](https://adoptium.net/) +- [mise version manager](https://mise.jdnow.dev/) +- [asdf version manager](https://asdf-vm.com/) diff --git a/settings.gradle.kts b/settings.gradle.kts index 4e9ee14..da08b84 100644 --- a/settings.gradle.kts +++ b/settings.gradle.kts @@ -1 +1,5 @@ +plugins { + id("org.gradle.toolchains.foojay-resolver-convention") version "0.8.0" +} + rootProject.name = "java-chat" From 386f959fabb9f73574b06c7e567c42e18618fef9 Mon Sep 17 00:00:00 2001 From: William Callahan Date: Sun, 25 Jan 2026 14:31:44 -0800 Subject: [PATCH 38/79] style(frontend): normalize LearnView indentation to spaces The file had mixed tabs and spaces from incremental edits. Normalize to consistent 2-space indentation matching the rest of the frontend codebase. --- frontend/src/lib/components/LearnView.svelte | 118 +++++++++---------- 1 file changed, 59 insertions(+), 59 deletions(-) diff --git a/frontend/src/lib/components/LearnView.svelte b/frontend/src/lib/components/LearnView.svelte index ba4cb08..98ae3a5 100644 --- a/frontend/src/lib/components/LearnView.svelte +++ b/frontend/src/lib/components/LearnView.svelte @@ -1,13 +1,13 @@ \n\n**Safe bold**"; String html = markdownService.processStructured(markdown).html(); - + assertFalse(html.contains("