Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
19 commits
Select commit Hold shift + click to select a range
020bc46
fix(ingestion): reindex chunks on metadata drift
WilliamAGH Feb 9, 2026
b8be796
fix(health): prevent stuck Qdrant DOWN state
WilliamAGH Feb 9, 2026
70a8b1c
refactor(chat): unify session state updates atomically
WilliamAGH Feb 9, 2026
de8de1d
fix(web): harden exception detail building for nulls
WilliamAGH Feb 9, 2026
7851c46
fix(rate-limit): preserve failure streak counters
WilliamAGH Feb 9, 2026
4f3966c
fix(embedding): align dimension defaults and hints
WilliamAGH Feb 9, 2026
2fa2b02
fix(frontend): strengthen session ID generation
WilliamAGH Feb 9, 2026
1a2e141
chore(format): apply spotless formatting leftovers
WilliamAGH Feb 9, 2026
bf29e08
test(frontend): deduplicate session test timer setup
WilliamAGH Feb 9, 2026
5cb023c
feat(analytics): add Simple Analytics via Vite transformIndexHtml plugin
WilliamAGH Feb 10, 2026
ae6e4ce
fix(health): prevent checkInProgress stuck and startup race in Extern…
WilliamAGH Feb 11, 2026
fecd724
fix(web): extract session validation message constants in ChatController
WilliamAGH Feb 11, 2026
d036370
fix(ingestion): rename banned abbreviation doc to pageDocument in Chu…
WilliamAGH Feb 11, 2026
36f5c63
refactor(ingestion): extract shared metadataText helper to DocumentFa…
WilliamAGH Feb 11, 2026
c967883
fix(chat): replace unused lambda param with underscore in ChatMemoryS…
WilliamAGH Feb 11, 2026
60f0226
test(web): extract constants and rename generic variables in session …
WilliamAGH Feb 11, 2026
8fe7868
test(health): add retry-gate and backoff progression tests for Extern…
WilliamAGH Feb 11, 2026
2f990c2
test(ingestion): add Base64 metadata round-trip and drift detection t…
WilliamAGH Feb 11, 2026
24261e3
chore(spotbugs): suppress CRLF_INJECTION_LOGS for ExternalServiceHealth
WilliamAGH Feb 11, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions config/spotbugs/spotbugs-exclude.xml
Original file line numberDiff line numberDiff line change
Expand Up@@ -170,6 +170,10 @@
<Bug pattern="CRLF_INJECTION_LOGS"/>
<Class name="com.williamcallahan.javachat.web.GuidedLearningController"/>
</Match>
<Match>
<Bug pattern="CRLF_INJECTION_LOGS"/>
<Class name="com.williamcallahan.javachat.service.ExternalServiceHealth"/>
</Match>

<!--
Ingestion service bundles intentionally aggregate Spring-managed collaborators so processors
Expand Down
52 changes: 52 additions & 0 deletions frontend/src/lib/utils/session.test.ts
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,52 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { generateSessionId } from './session'

describe('generateSessionId', () => {
beforeEach(() => {
vi.useFakeTimers()
vi.setSystemTime(new Date('2026-02-09T12:00:00.000Z'))
})

afterEach(() => {
vi.useRealTimers()
vi.restoreAllMocks()
vi.unstubAllGlobals()
})

it('uses crypto.randomUUID when available', () => {
vi.stubGlobal('crypto', {
randomUUID: () => 'uuid-test-value',
} as unknown as Crypto)

const sessionId = generateSessionId('chat')
expect(sessionId).toBe('chat-1770638400000-uuid-test-value')
})

it('uses crypto.getRandomValues when randomUUID is unavailable', () => {
vi.stubGlobal('crypto', {
getRandomValues: (randomBytes: Uint8Array) => {
randomBytes.fill(15)
return randomBytes
},
} as unknown as Crypto)

const sessionId = generateSessionId('chat')
const sessionParts = sessionId.split('-')
const randomSuffix = sessionParts[sessionParts.length - 1]
expect(randomSuffix).toHaveLength(32)
expect(/^[0-9a-f]+$/.test(randomSuffix)).toBe(true)
})

it('falls back to padded Math.random output when crypto is unavailable', () => {
vi.stubGlobal('crypto', undefined)
vi.spyOn(Math, 'random').mockReturnValue(0)

const sessionId = generateSessionId('chat')
const sessionParts = sessionId.split('-')
const randomSuffix = sessionParts[sessionParts.length - 1]
expect(sessionId.endsWith('-')).toBe(false)
expect(randomSuffix).toHaveLength(12)
expect(randomSuffix).toBe('000000000000')
})
})

17 changes: 16 additions & 1 deletion frontend/src/lib/utils/session.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -2,6 +2,20 @@
* Session identifier utilities for client-side chat session management.
*/

function createSessionRandomPart(): string {
if (typeof crypto !== 'undefined') {
if (typeof crypto.randomUUID === 'function') {
return crypto.randomUUID()
}
if (typeof crypto.getRandomValues === 'function') {
const randomBytes = new Uint8Array(16)
crypto.getRandomValues(randomBytes)
return Array.from(randomBytes, (randomByte) => randomByte.toString(16).padStart(2, '0')).join('')
}
}
return Math.random().toString(36).slice(2, 14).padEnd(12, '0')
}

/**
* Generates a unique session identifier with a domain-specific prefix.
*
Expand All@@ -12,5 +26,6 @@
* @returns Unique session ID string in format "{prefix}-{timestamp}-{random}"
*/
export function generateSessionId(prefix: string): string {
return `${prefix}-${Date.now()}-${Math.random().toString(36).slice(2, 15)}`
const randomPart = createSessionRandomPart()
return `${prefix}-${Date.now()}-${randomPart}`
}
27 changes: 23 additions & 4 deletions frontend/vite.config.ts
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,28 @@
import { defineConfig } from 'vite'
import { svelte } from '@sveltejs/vite-plugin-svelte'

export default defineConfig({
plugins: [svelte()],
// Serve from root
const SIMPLE_ANALYTICS_CDN = 'https://scripts.simpleanalyticscdn.com'

export default defineConfig(({ mode }) => ({
plugins: [
svelte(),
{
name: 'simple-analytics',
transformIndexHtml() {
const scriptFile = mode === 'development' ? 'latest.dev.js' : 'latest.js'
return [
{
tag: 'script',
attrs: {
async: true,
src: `${SIMPLE_ANALYTICS_CDN}/${scriptFile}`,
},
injectTo: 'head',
},
]
},
},
Comment thread
WilliamAGH marked this conversation as resolved.
],
base: '/',
server: {
port: 5173,
Expand DownExpand Up@@ -37,4 +56,4 @@ export default defineConfig({
}
}
}
})
}))
Original file line numberDiff line numberDiff line change
Expand Up@@ -476,7 +476,7 @@ QdrantCollections validateConfiguration() {

/** Embedding vector configuration. */
public static class Embeddings {
private int dimensions = 1536;
private int dimensions = 4096;

public int getDimensions() {
return dimensions;
Expand Down
Original file line numberDiff line numberDiff line change
Expand Up@@ -40,6 +40,9 @@ public QdrantHealthIndicator(ExternalServiceHealth externalServiceHealth) {
*/
@Override
public Health health() {
// Trigger retry checks when unhealthy and backoff has elapsed.
externalServiceHealth.isHealthy(ExternalServiceHealth.SERVICE_QDRANT);

ExternalServiceHealth.HealthSnapshot healthSnapshot =
externalServiceHealth.getHealthSnapshot(ExternalServiceHealth.SERVICE_QDRANT);

Expand Down
Original file line numberDiff line numberDiff line change
Expand Up@@ -2,7 +2,6 @@

import com.williamcallahan.javachat.model.ChatTurn;
import java.util.ArrayList;
import java.util.Collections;
import java.util.List;
import java.util.Objects;
import java.util.concurrent.ConcurrentHashMap;
Expand All@@ -23,31 +22,19 @@ public class ChatMemoryService {

private static final String REQUIRE_SESSION_ID = "sessionId";

// Use synchronizedList wrapper to ensure thread-safe list operations.
// ConcurrentHashMap only protects map operations, not the contained lists.
private final ConcurrentMap<String, List<Message>> sessionToMessages = new ConcurrentHashMap<>();
private final ConcurrentMap<String, List<ChatTurn>> sessionToTurns = new ConcurrentHashMap<>();
private final ConcurrentMap<String, SessionConversation> sessionConversations = new ConcurrentHashMap<>();

/**
* Returns a thread-safe snapshot of the history for the given session.
* Callers receive an independent copy that can be safely iterated without synchronization.
*/
public List<Message> getHistory(String sessionId) {
Objects.requireNonNull(sessionId, REQUIRE_SESSION_ID);
List<Message> history =
sessionToMessages.computeIfAbsent(sessionId, _ -> Collections.synchronizedList(new ArrayList<>()));
// Return a snapshot to avoid ConcurrentModificationException during iteration
synchronized (history) {
return new ArrayList<>(history);
SessionConversation sessionConversation = sessionConversations.get(sessionId);
if (sessionConversation == null) {
return List.of();
}
}

/**
* Returns the internal synchronized list for direct modification.
* Use with care - prefer addUser/addAssistant for adding messages.
*/
List<Message> getHistoryInternal(String sessionId) {
return sessionToMessages.computeIfAbsent(sessionId, _ -> Collections.synchronizedList(new ArrayList<>()));
return sessionConversation.historySnapshot();
}

/**
Expand All@@ -58,8 +45,9 @@ List<Message> getHistoryInternal(String sessionId) {
*/
public void addUser(String sessionId, String text) {
Objects.requireNonNull(sessionId, REQUIRE_SESSION_ID);
getHistoryInternal(sessionId).add(new UserMessage(text));
getTurnsInternal(sessionId).add(new ChatTurn("user", text));
SessionConversation sessionConversation =
sessionConversations.computeIfAbsent(sessionId, _ -> new SessionConversation());
sessionConversation.addUserMessage(text);
}

/**
Expand All@@ -70,8 +58,9 @@ public void addUser(String sessionId, String text) {
*/
public void addAssistant(String sessionId, String text) {
Objects.requireNonNull(sessionId, REQUIRE_SESSION_ID);
getHistoryInternal(sessionId).add(new AssistantMessage(text));
getTurnsInternal(sessionId).add(new ChatTurn("assistant", text));
SessionConversation sessionConversation =
sessionConversations.computeIfAbsent(sessionId, _ -> new SessionConversation());
sessionConversation.addAssistantMessage(text);
}

/**
Expand All@@ -81,27 +70,30 @@ public void addAssistant(String sessionId, String text) {
*/
public void clear(String sessionId) {
Objects.requireNonNull(sessionId, REQUIRE_SESSION_ID);
sessionToMessages.remove(sessionId);
sessionToTurns.remove(sessionId);
sessionConversations.remove(sessionId);
}

/**
* Returns a thread-safe snapshot of the turns for the given session.
*/
public List<ChatTurn> getTurns(String sessionId) {
Objects.requireNonNull(sessionId, REQUIRE_SESSION_ID);
List<ChatTurn> turns =
sessionToTurns.computeIfAbsent(sessionId, _ -> Collections.synchronizedList(new ArrayList<>()));
synchronized (turns) {
return new ArrayList<>(turns);
SessionConversation sessionConversation = sessionConversations.get(sessionId);
if (sessionConversation == null) {
return List.of();
}
return sessionConversation.turnSnapshot();
}

/**
* Returns the internal synchronized list for direct modification.
* Returns true when the server currently recognizes the given session identifier.
*
* @param sessionId session identifier
* @return true when the session has been created in memory
*/
List<ChatTurn> getTurnsInternal(String sessionId) {
return sessionToTurns.computeIfAbsent(sessionId, _ -> Collections.synchronizedList(new ArrayList<>()));
public boolean hasSession(String sessionId) {
Objects.requireNonNull(sessionId, REQUIRE_SESSION_ID);
return sessionConversations.containsKey(sessionId);
}

// TODO: Persist chat history embeddings to Qdrant for long-term memory (future feature)
Expand All@@ -114,4 +106,30 @@ List<ChatTurn> getTurnsInternal(String sessionId) {
// - Embedding strategy for chat turns (user messages + AI responses)
// - Semantic similarity search for relevant historical context
// - Privacy and data retention compliance

/**
* Holds per-session conversation state and synchronizes updates across message and turn views.
*/
private static final class SessionConversation {
private final List<Message> historyMessages = new ArrayList<>();
private final List<ChatTurn> turnHistory = new ArrayList<>();

synchronized void addUserMessage(String text) {
historyMessages.add(new UserMessage(text));
turnHistory.add(new ChatTurn("user", text));
}

synchronized void addAssistantMessage(String text) {
historyMessages.add(new AssistantMessage(text));
turnHistory.add(new ChatTurn("assistant", text));
}

synchronized List<Message> historySnapshot() {
return List.copyOf(historyMessages);
}

synchronized List<ChatTurn> turnSnapshot() {
return List.copyOf(turnHistory);
}
}
}
Original file line numberDiff line numberDiff line change
Expand Up@@ -38,7 +38,17 @@ public ChunkProcessingService(
this.chunker = Objects.requireNonNull(chunker, "chunker");
this.hasher = Objects.requireNonNull(hasher, "hasher");
this.documentFactory = Objects.requireNonNull(documentFactory, "documentFactory");
this.hashIngestionLookup = requiredLocalStore::isHashIngested;
this.hashIngestionLookup = new HashIngestionLookup() {
@Override
public boolean isHashIngested(String hash) {
return requiredLocalStore.isHashIngested(hash);
}

@Override
public boolean hasMetadataChanged(String hash, String title, String packageName) {
return requiredLocalStore.hasHashMetadataChanged(hash, title, packageName);
}
};
this.chunkTextStore = requiredLocalStore::saveChunkText;
this.pdfExtractor = Objects.requireNonNull(pdfExtractor, "pdfExtractor");
}
Expand DownExpand Up@@ -71,7 +81,8 @@ public ChunkProcessingOutcome processAndStoreChunks(String text, String url, Str
allChunkHashes.add(hash);

// Skip if already processed (deduplication)
if (hashIngestionLookup.isHashIngested(hash)) {
boolean hashAlreadyIngested = hashIngestionLookup.isHashIngested(hash);
if (hashAlreadyIngested && !hashIngestionLookup.hasMetadataChanged(hash, title, packageName)) {
skipped++;
continue;
}
Expand DownExpand Up@@ -172,10 +183,11 @@ public ChunkProcessingOutcome processPdfAndStoreWithPages(
totalChunks++;
String hash = hasher.generateChunkHash(url, globalIndex, chunkText);
allChunkHashes.add(hash);
if (!hashIngestionLookup.isHashIngested(hash)) {
Document doc = documentFactory.createDocumentWithPages(
boolean hashAlreadyIngested = hashIngestionLookup.isHashIngested(hash);
if (!hashAlreadyIngested || hashIngestionLookup.hasMetadataChanged(hash, title, packageName)) {
Document pageDocument = documentFactory.createDocumentWithPages(
chunkText, url, title, globalIndex, packageName, hash, pageIndex + 1, pageIndex + 1);
pageDocuments.add(doc);
pageDocuments.add(pageDocument);
chunkTextStore.saveChunkText(url, globalIndex, chunkText, hash);
} else {
skipped++;
Expand DownExpand Up@@ -266,12 +278,16 @@ public boolean skippedAllChunks() {
/**
* Reads whether a chunk hash has already been indexed.
*/
@FunctionalInterface
private interface HashIngestionLookup {
/**
* Returns true when the chunk hash already has an ingest marker.
*/
boolean isHashIngested(String hash);

/**
* Returns true when stored metadata for an ingested hash differs from current metadata.
*/
boolean hasMetadataChanged(String hash, String title, String packageName);
}

/**
Expand Down
Original file line numberDiff line numberDiff line change
Expand Up@@ -157,8 +157,10 @@ private void markDocumentsIngested(List<org.springframework.ai.document.Document
if (hashMetadata == null) {
continue;
}
String title = DocumentFactory.metadataText(doc, "title");
String packageName = DocumentFactory.metadataText(doc, "package");
try {
localStore.markHashIngested(hashMetadata.toString());
localStore.markHashIngested(hashMetadata.toString(), title, packageName);
} catch (IOException markHashException) {
throw new IllegalStateException("Failed to mark hash as ingested: " + hashMetadata, markHashException);
}
Expand Down
Original file line numberDiff line numberDiff line change
Expand Up@@ -112,6 +112,21 @@ public org.springframework.ai.document.Document createDocumentWithPages(
return doc;
}

/**
* Extracts a metadata value as a string, returning empty string when absent.
*
* @param document the document to read metadata from
* @param metadataKey the metadata key to look up
* @return the metadata value as a string, or empty string when the key is absent or null
*/
public static String metadataText(org.springframework.ai.document.Document document, String metadataKey) {
Object metadataRaw = document.getMetadata().get(metadataKey);
if (metadataRaw == null) {
return "";
}
return metadataRaw.toString();
}

private org.springframework.ai.document.Document createDocumentWithOptionalId(String text, String hash) {
if (hash == null || hash.isBlank()) {
return new org.springframework.ai.document.Document(text);
Expand Down
Loading