From b33aa986ebed2578f4114263e58c2b40d2f26b20 Mon Sep 17 00:00:00 2001 From: William Callahan Date: Wed, 10 Jun 2026 00:21:39 -0700 Subject: [PATCH 1/4] fix(embeddings): keep-alive probes hold their cadence and stay out of pipeline logs fixedRate replaces fixedDelay so a slow cold-start probe cannot push the next probe past the provider's idle-unload TTL, and probe latency is measured with the monotonic nanoTime clock. The probe now calls a dedicated EmbeddingClient.warmUp() whose internal embed call is a self-invocation outside the AOP proxy, so the RAG pipeline aspect's 'STEP 1: EMBEDDING GENERATION' logs stay scoped to real requests instead of firing every 4 minutes. --- .../javachat/service/EmbeddingClient.java | 15 +++++++++++++++ .../javachat/service/EmbeddingModelKeepAlive.java | 13 +++++++------ .../service/EmbeddingModelKeepAliveTest.java | 1 + 3 files changed, 23 insertions(+), 6 deletions(-) diff --git a/src/main/java/com/williamcallahan/javachat/service/EmbeddingClient.java b/src/main/java/com/williamcallahan/javachat/service/EmbeddingClient.java index 626819a6..dc0bf8e6 100644 --- a/src/main/java/com/williamcallahan/javachat/service/EmbeddingClient.java +++ b/src/main/java/com/williamcallahan/javachat/service/EmbeddingClient.java @@ -37,4 +37,19 @@ default float[] embed(String text) { * @return embedding vector dimensions */ int dimensions(); + + /** + * Issues a minimal embedding request so the provider keeps its model resident. + * + *

Distinct from {@link #embed(List)} so scheduled warm-up probes are not + * advised by the RAG pipeline logging aspect (which matches {@code embed} + * executions): the internal call below is a self-invocation on the target + * and bypasses the Spring AOP proxy, keeping "STEP 1" pipeline logs scoped + * to real requests.

+ * + * @throws EmbeddingServiceUnavailableException when the provider cannot serve the probe + */ + default void warmUp() { + embed(List.of("embedding model warm-up probe")); + } } diff --git a/src/main/java/com/williamcallahan/javachat/service/EmbeddingModelKeepAlive.java b/src/main/java/com/williamcallahan/javachat/service/EmbeddingModelKeepAlive.java index 5e0976c2..092f2de2 100644 --- a/src/main/java/com/williamcallahan/javachat/service/EmbeddingModelKeepAlive.java +++ b/src/main/java/com/williamcallahan/javachat/service/EmbeddingModelKeepAlive.java @@ -1,6 +1,5 @@ package com.williamcallahan.javachat.service; -import java.util.List; import org.slf4j.Logger; import org.slf4j.LoggerFactory; import org.springframework.scheduling.annotation.Scheduled; @@ -31,7 +30,7 @@ public class EmbeddingModelKeepAlive { /** Probe latency above this means the model was cold and a reload just happened. */ private static final long COLD_MODEL_WARN_THRESHOLD_MILLIS = 5_000L; - private static final List KEEP_ALIVE_PROBE_TEXTS = List.of("embedding keep-alive probe"); + private static final long NANOS_PER_MILLISECOND = 1_000_000L; private final EmbeddingClient embeddingClient; @@ -49,17 +48,19 @@ public EmbeddingModelKeepAlive(EmbeddingClient embeddingClient) { * and the next tick retries. Unexpected runtime failures propagate to the scheduler's * error handler rather than being swallowed here.

*/ - @Scheduled(initialDelay = STARTUP_WARMUP_DELAY_MILLIS, fixedDelay = KEEP_ALIVE_INTERVAL_MILLIS) + // fixedRate keeps probe *starts* on the cadence: with fixedDelay a slow cold + // start would push the next probe past the provider's idle-unload TTL. + @Scheduled(initialDelay = STARTUP_WARMUP_DELAY_MILLIS, fixedRate = KEEP_ALIVE_INTERVAL_MILLIS) public void keepEmbeddingModelWarm() { - long probeStartMillis = System.currentTimeMillis(); + long probeStartNanos = System.nanoTime(); try { - embeddingClient.embed(KEEP_ALIVE_PROBE_TEXTS); + embeddingClient.warmUp(); } catch (EmbeddingServiceUnavailableException exception) { log.warn( "[EMBEDDING] Keep-alive probe failed; the next chat request may pay a model cold start", exception); return; } - long probeDurationMillis = System.currentTimeMillis() - probeStartMillis; + long probeDurationMillis = (System.nanoTime() - probeStartNanos) / NANOS_PER_MILLISECOND; if (probeDurationMillis > COLD_MODEL_WARN_THRESHOLD_MILLIS) { log.warn( "[EMBEDDING] Keep-alive probe took {}ms — embedding model was cold and has been reloaded", diff --git a/src/test/java/com/williamcallahan/javachat/service/EmbeddingModelKeepAliveTest.java b/src/test/java/com/williamcallahan/javachat/service/EmbeddingModelKeepAliveTest.java index dffd6952..fe1c9224 100644 --- a/src/test/java/com/williamcallahan/javachat/service/EmbeddingModelKeepAliveTest.java +++ b/src/test/java/com/williamcallahan/javachat/service/EmbeddingModelKeepAliveTest.java @@ -18,6 +18,7 @@ void keepEmbeddingModelWarmProbesTheEmbeddingProvider() { new EmbeddingModelKeepAlive(recordingEmbeddingClient).keepEmbeddingModelWarm(); + // warmUp() routes through embed(List) by default, so one probe = one embed call assertEquals(1, recordingEmbeddingClient.embedInvocationCount); } From f1c461015d91643d7d8b9a864189202d281f8744 Mon Sep 17 00:00:00 2001 From: William Callahan Date: Wed, 10 Jun 2026 08:10:53 -0700 Subject: [PATCH 2/4] fix(frontend): scroll expanded sources list into view in scrollable containers The sources disclosure trigger sits at the very bottom of every scroll container it appears in (chat messages, lesson content panel, mobile chat drawer), and the citation list expands downward. Browsers never auto-scroll to reveal newly inserted content below the fold, so the expanded list was partially clipped on desktop and rendered entirely off-screen on mobile, where tapping the trigger appeared to do nothing at all. Reveal the list with scrollIntoView(block: 'nearest') once it mounts while expanded, and give it scroll-margin-bottom clearance because the scroll fires while the slide-down entry animation still holds the list 4px above its settled position. - Bind the expanded list element and scroll it into view from an $effect - Add scroll-margin-bottom to out-clear the translateY(-4px) entry offset - Cover expand, reveal-scroll, and collapse paths in CitationPanel tests - Polyfill scrollIntoView in the jsdom test setup alongside scrollTo --- .../src/lib/components/CitationPanel.svelte | 15 ++++- .../src/lib/components/CitationPanel.test.ts | 65 +++++++++++++++++++ frontend/src/test/setup.ts | 7 ++ 3 files changed, 86 insertions(+), 1 deletion(-) create mode 100644 frontend/src/lib/components/CitationPanel.test.ts diff --git a/frontend/src/lib/components/CitationPanel.svelte b/frontend/src/lib/components/CitationPanel.svelte index 33635081..f894ab92 100644 --- a/frontend/src/lib/components/CitationPanel.svelte +++ b/frontend/src/lib/components/CitationPanel.svelte @@ -19,6 +19,16 @@ let { citations, visible = true, panelId }: Props = $props() let isExpanded = $state(false) + let citationListElement = $state(null) + + // The trigger usually sits at the very bottom of a scrollable container + // (chat messages, lesson panel, mobile drawer). The list expands downward, + // so without this the new content renders below the fold and stays hidden. + $effect(() => { + if (isExpanded && citationListElement) { + citationListElement.scrollIntoView({ block: 'nearest' }) + } + }) /** * Simple hash for deterministic ID generation from citation content. @@ -75,7 +85,7 @@ {#if isExpanded} -
    +
      {#each uniqueCitations as citation (citation.url)} {@const citationType = getCitationType(citation.url)} {@const displaySource = getDisplaySource(citation.url)} @@ -201,6 +211,9 @@ flex-direction: column; gap: var(--citation-list-gap); animation: slide-down var(--citation-transition-normal) ease-out; + /* Clearance for scrollIntoView: must exceed the slide-down translateY(-4px) + start offset, since the scroll happens while the entry animation runs. */ + scroll-margin-bottom: var(--space-3, 12px); } @keyframes slide-down { diff --git a/frontend/src/lib/components/CitationPanel.test.ts b/frontend/src/lib/components/CitationPanel.test.ts new file mode 100644 index 00000000..c6e3bdac --- /dev/null +++ b/frontend/src/lib/components/CitationPanel.test.ts @@ -0,0 +1,65 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { render, fireEvent } from "@testing-library/svelte"; +import { tick } from "svelte"; +import CitationPanel from "./CitationPanel.svelte"; +import type { Citation } from "../services/chat"; + +const SINGLE_CITATION: Citation[] = [ + { + url: "https://example.com/pdfs/think-java.pdf", + title: "Think Java: How to Think Like a Computer Scientist", + snippet: "Think Java 2nd Edition Book", + }, +]; + +describe("CitationPanel", () => { + // The prototype polyfill lives in src/test/setup.ts; spying here captures calls + // from the panel revealing the expanded list inside its scroll container. + const scrollIntoViewSpy = vi.spyOn(HTMLElement.prototype, "scrollIntoView"); + + beforeEach(() => { + scrollIntoViewSpy.mockClear(); + }); + + it("expands the citation list when the trigger is clicked", async () => { + const { getByRole, container } = render(CitationPanel, { + props: { citations: SINGLE_CITATION }, + }); + + const trigger = getByRole("button", { name: /1 source/i }); + expect(container.querySelector(".citation-list")).toBeNull(); + + await fireEvent.click(trigger); + await tick(); + + expect(trigger).toHaveAttribute("aria-expanded", "true"); + expect(container.querySelector(".citation-list")).not.toBeNull(); + }); + + it("scrolls the expanded list into view so it is never hidden below the fold", async () => { + const { getByRole } = render(CitationPanel, { + props: { citations: SINGLE_CITATION }, + }); + + await fireEvent.click(getByRole("button", { name: /1 source/i })); + await tick(); + + expect(scrollIntoViewSpy).toHaveBeenCalledWith(expect.objectContaining({ block: "nearest" })); + }); + + it("does not scroll when collapsing the list", async () => { + const { getByRole } = render(CitationPanel, { + props: { citations: SINGLE_CITATION }, + }); + + const trigger = getByRole("button", { name: /1 source/i }); + await fireEvent.click(trigger); + await tick(); + scrollIntoViewSpy.mockClear(); + + await fireEvent.click(trigger); + await tick(); + + expect(scrollIntoViewSpy).not.toHaveBeenCalled(); + }); +}); diff --git a/frontend/src/test/setup.ts b/frontend/src/test/setup.ts index 4d9051ee..9bb60615 100644 --- a/frontend/src/test/setup.ts +++ b/frontend/src/test/setup.ts @@ -22,6 +22,13 @@ Object.defineProperty(HTMLElement.prototype, "scrollTo", { value: () => {}, }); +// jsdom doesn't implement scrollIntoView; CitationPanel uses it to reveal the expanded list. +// oxlint-disable-next-line no-extend-native -- jsdom polyfill, not production code +Object.defineProperty(HTMLElement.prototype, "scrollIntoView", { + writable: true, + value: () => {}, +}); + // requestAnimationFrame is used for post-update DOM adjustments; provide a safe fallback. if (typeof window.requestAnimationFrame !== "function") { window.requestAnimationFrame = (callback: FrameRequestCallback) => From cd8e17752765712a45a706b754b56cdcbcd8d24d Mon Sep 17 00:00:00 2001 From: William Callahan Date: Wed, 10 Jun 2026 09:32:38 -0700 Subject: [PATCH 3/4] fix embeddings warm-up logging bypass --- .../javachat/service/EmbeddingClient.java | 15 +++++++-------- .../javachat/service/LocalEmbeddingClient.java | 9 +++++++++ .../OpenAiCompatibleEmbeddingClient.java | 9 +++++++++ .../service/EmbeddingModelKeepAliveTest.java | 18 +++++++++++++----- 4 files changed, 38 insertions(+), 13 deletions(-) diff --git a/src/main/java/com/williamcallahan/javachat/service/EmbeddingClient.java b/src/main/java/com/williamcallahan/javachat/service/EmbeddingClient.java index dc0bf8e6..7ced0e87 100644 --- a/src/main/java/com/williamcallahan/javachat/service/EmbeddingClient.java +++ b/src/main/java/com/williamcallahan/javachat/service/EmbeddingClient.java @@ -7,6 +7,8 @@ * Defines the application's embedding port independent from Spring AI abstractions. */ public interface EmbeddingClient { + /** Minimal provider input used by keep-alive probes. */ + String EMBEDDING_WARM_UP_PROBE_TEXT = "embedding model warm-up probe"; /** * Produces one dense embedding vector per input text, preserving input order. @@ -41,15 +43,12 @@ default float[] embed(String text) { /** * Issues a minimal embedding request so the provider keeps its model resident. * - *

      Distinct from {@link #embed(List)} so scheduled warm-up probes are not - * advised by the RAG pipeline logging aspect (which matches {@code embed} - * executions): the internal call below is a self-invocation on the target - * and bypasses the Spring AOP proxy, keeping "STEP 1" pipeline logs scoped - * to real requests.

      + *

      Implementations must call their provider-specific request path directly instead + * of delegating to {@link #embed(List)}. The RAG pipeline logging aspect advises + * public {@code embed} executions, so routing scheduled probes around that method + * keeps "STEP 1" pipeline logs scoped to real requests.

      * * @throws EmbeddingServiceUnavailableException when the provider cannot serve the probe */ - default void warmUp() { - embed(List.of("embedding model warm-up probe")); - } + void warmUp(); } diff --git a/src/main/java/com/williamcallahan/javachat/service/LocalEmbeddingClient.java b/src/main/java/com/williamcallahan/javachat/service/LocalEmbeddingClient.java index 106899b4..b99f834f 100644 --- a/src/main/java/com/williamcallahan/javachat/service/LocalEmbeddingClient.java +++ b/src/main/java/com/williamcallahan/javachat/service/LocalEmbeddingClient.java @@ -70,6 +70,15 @@ public List embed(List texts) { if (texts == null || texts.isEmpty()) { return List.of(); } + return fetchValidatedEmbeddings(texts); + } + + @Override + public void warmUp() { + fetchValidatedEmbeddings(List.of(EMBEDDING_WARM_UP_PROBE_TEXT)); + } + + private List fetchValidatedEmbeddings(List texts) { try { return callEmbeddingApi(texts); } catch (org.springframework.web.client.RestClientResponseException apiException) { diff --git a/src/main/java/com/williamcallahan/javachat/service/OpenAiCompatibleEmbeddingClient.java b/src/main/java/com/williamcallahan/javachat/service/OpenAiCompatibleEmbeddingClient.java index bb16ad83..afb7f71e 100644 --- a/src/main/java/com/williamcallahan/javachat/service/OpenAiCompatibleEmbeddingClient.java +++ b/src/main/java/com/williamcallahan/javachat/service/OpenAiCompatibleEmbeddingClient.java @@ -85,6 +85,15 @@ public List embed(List texts) { if (texts == null || texts.isEmpty()) { return List.of(); } + return createEmbeddings(texts); + } + + @Override + public void warmUp() { + createEmbeddings(List.of(EMBEDDING_WARM_UP_PROBE_TEXT)); + } + + private List createEmbeddings(List texts) { EmbeddingCreateParams.Builder embeddingRequestBuilder = EmbeddingCreateParams.builder().model(modelName).inputOfArrayOfStrings(texts); if (supportsDimensionOverride(modelName)) { diff --git a/src/test/java/com/williamcallahan/javachat/service/EmbeddingModelKeepAliveTest.java b/src/test/java/com/williamcallahan/javachat/service/EmbeddingModelKeepAliveTest.java index fe1c9224..9b1ab860 100644 --- a/src/test/java/com/williamcallahan/javachat/service/EmbeddingModelKeepAliveTest.java +++ b/src/test/java/com/williamcallahan/javachat/service/EmbeddingModelKeepAliveTest.java @@ -18,8 +18,7 @@ void keepEmbeddingModelWarmProbesTheEmbeddingProvider() { new EmbeddingModelKeepAlive(recordingEmbeddingClient).keepEmbeddingModelWarm(); - // warmUp() routes through embed(List) by default, so one probe = one embed call - assertEquals(1, recordingEmbeddingClient.embedInvocationCount); + assertEquals(1, recordingEmbeddingClient.warmUpInvocationCount); } @Test @@ -30,12 +29,16 @@ void keepEmbeddingModelWarmDoesNotPropagateProviderUnavailability() { } private static final class RecordingEmbeddingClient implements EmbeddingClient { - private int embedInvocationCount; + private int warmUpInvocationCount; @Override public List embed(List texts) { - embedInvocationCount++; - return List.of(new float[] {0.0f}); + throw new AssertionError("keep-alive probes must not call embed(List)"); + } + + @Override + public void warmUp() { + warmUpInvocationCount++; } @Override @@ -50,6 +53,11 @@ public List embed(List texts) { throw new EmbeddingServiceUnavailableException("provider offline for test"); } + @Override + public void warmUp() { + throw new EmbeddingServiceUnavailableException("provider offline for test"); + } + @Override public int dimensions() { return 1; From 6c3dd9d08ab23a4c2d5e0c89deff123ff9c2dce3 Mon Sep 17 00:00:00 2001 From: William Callahan Date: Wed, 10 Jun 2026 10:37:40 -0700 Subject: [PATCH 4/4] fix(dev): expose deployment metadata and label mobile tabs Mobile navigation collapsed to icon-only tabs without accessible names, and the dev deployment did not expose commit metadata for verification. - Add accessible names and regression coverage for the Chat and Learn tabs - Generate Spring Boot build info with the deployed source commit - Surface deployment commit details through actuator info --- Dockerfile | 5 ++++- build.gradle.kts | 8 ++++++++ frontend/src/lib/components/Header.svelte | 2 ++ frontend/src/lib/components/Header.test.ts | 14 ++++++++++++++ src/main/resources/application.properties | 4 ++++ 5 files changed, 32 insertions(+), 1 deletion(-) create mode 100644 frontend/src/lib/components/Header.test.ts diff --git a/Dockerfile b/Dockerfile index 65461aa0..d8bea2c8 100644 --- a/Dockerfile +++ b/Dockerfile @@ -27,6 +27,7 @@ RUN npm run build # BACKEND BUILD STAGE # ================================ FROM public.ecr.aws/docker/library/eclipse-temurin:25-jdk AS builder +ARG SOURCE_COMMIT=unknown WORKDIR /app # 1. Gradle wrapper (rarely changes) @@ -51,13 +52,14 @@ COPY --from=frontend-builder /app/src/main/resources/static ./src/main/resources # 6. Build application with cache mount RUN --mount=type=cache,target=/root/.gradle \ - ./gradlew clean build -x test --no-daemon && \ + SOURCE_COMMIT="${SOURCE_COMMIT}" ./gradlew clean build -x test --no-daemon && \ cp $(ls build/libs/*.jar | grep -v '\-plain\.jar' | head -n 1) build/app.jar # ================================ # RUNTIME STAGE # ================================ FROM public.ecr.aws/docker/library/eclipse-temurin:25-jre AS runtime +ARG SOURCE_COMMIT=unknown # 1. System packages (never changes) - FIRST for maximum cache reuse RUN apt-get update && apt-get install -y --no-install-recommends curl \ @@ -79,6 +81,7 @@ ENV APP_KILL_ON_CONFLICT=false ENV DOCS_SNAPSHOT_DIR=/app/data/snapshots ENV DOCS_PARSED_DIR=/app/data/parsed ENV DOCS_INDEX_DIR=/app/data/index +ENV SOURCE_COMMIT=${SOURCE_COMMIT} # 5. Application JAR (changes every build) - LAST for optimal caching COPY --from=builder /app/build/app.jar app.jar diff --git a/build.gradle.kts b/build.gradle.kts index 637dd721..8d7da62a 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -12,9 +12,17 @@ plugins { val spotbugsToolVersion = "4.9.8" val pmdToolVersion = "7.20.0" val palantirVersion = "2.85.0" +val sourceCommitEnvironmentVariable = "SOURCE_COMMIT" +val missingSourceCommit = "unknown" +val sourceCommit = providers.environmentVariable(sourceCommitEnvironmentVariable).orElse(missingSourceCommit) springBoot { mainClass.set("com.williamcallahan.javachat.JavaChatApplication") + buildInfo { + properties { + additional.put("commit", sourceCommit) + } + } } group = "com.williamcallahan" diff --git a/frontend/src/lib/components/Header.svelte b/frontend/src/lib/components/Header.svelte index 30af6b23..dcdb5b66 100644 --- a/frontend/src/lib/components/Header.svelte +++ b/frontend/src/lib/components/Header.svelte @@ -24,6 +24,7 @@ role="tab" class="nav-tab" class:active={currentView === 'chat'} + aria-label="Chat" aria-selected={currentView === 'chat'} onclick={() => currentView = 'chat'} > @@ -38,6 +39,7 @@ role="tab" class="nav-tab" class:active={currentView === 'learn'} + aria-label="Learn" aria-selected={currentView === 'learn'} onclick={() => currentView = 'learn'} > diff --git a/frontend/src/lib/components/Header.test.ts b/frontend/src/lib/components/Header.test.ts new file mode 100644 index 00000000..a644f3ae --- /dev/null +++ b/frontend/src/lib/components/Header.test.ts @@ -0,0 +1,14 @@ +import { describe, expect, it } from "vitest"; +import { render } from "@testing-library/svelte"; +import Header from "./Header.svelte"; + +describe("Header navigation accessibility", () => { + it("names icon-only mobile navigation tabs", () => { + const { getByRole } = render(Header, { + props: { currentView: "chat" }, + }); + + expect(getByRole("tab", { name: "Chat" })).toHaveAttribute("aria-selected", "true"); + expect(getByRole("tab", { name: "Learn" })).toHaveAttribute("aria-selected", "false"); + }); +}); diff --git a/src/main/resources/application.properties b/src/main/resources/application.properties index da0e2b81..8b9fad44 100644 --- a/src/main/resources/application.properties +++ b/src/main/resources/application.properties @@ -145,6 +145,10 @@ app.cors.max-age-seconds=3600 # Actuator management.endpoints.web.exposure.include=health,info,metrics +management.info.build.enabled=true +management.info.env.enabled=true +info.application.name=${spring.application.name} +info.deployment.commit=${SOURCE_COMMIT:unknown} # Reduce Qdrant client warning verbosity via logging logging.level.io.qdrant=ERROR # Suppress PDFBox font mapping warnings (these are harmless)