From e2eb1c54cb317be3919900d438754e1d72632d46 Mon Sep 17 00:00:00 2001 From: Birdasaur Date: Tue, 27 Oct 2026 17:41:40 -0400 Subject: [PATCH 1/3] Defect and corner case overhaul of Hyperdrive workflow. --- .../llm/EmbeddingsTextBatchInput.java | 31 + .../hyperdrive/BatchRequestManager.java | 445 +++++----- .../EmbeddingBatchResponseValidator.java | 24 + .../ImageEmbeddingsBatchLauncher.java | 94 +-- .../hyperdrive/LandmarkEmbeddingState.java | 43 + .../components/hyperdrive/LoadTextTask.java | 128 +-- .../hyperdrive/RequestTextEmbeddingsTask.java | 97 +-- .../RequestTextLandmarkSimilarityTask.java | 91 +-- .../hyperdrive/TextChunkParser.java | 224 +++++ .../TextEmbeddingsBatchLauncher.java | 37 + .../TextLandmarkSimilarityEngine.java | 51 ++ .../listviews/EmbeddingsTextListItem.java | 212 ++--- .../listviews/LandmarkListItem.java | 13 + .../listviews/LandmarkTextListItem.java | 64 +- .../components/panes/HyperdrivePane.java | 769 +++++++++++++----- .../messages/EmbeddingsTextCallback.java | 5 +- .../EmbeddingsTextLandmarkCallback.java | 15 +- .../trinity/messages/RestAccessLayer.java | 110 ++- .../hyperdrive/BatchRequestManagerTest.java | 83 ++ .../EmbeddingBatchResponseValidatorTest.java | 41 + .../LandmarkEmbeddingStateTest.java | 36 + .../hyperdrive/TextChunkParserTest.java | 102 +++ .../TextLandmarkSimilarityEngineTest.java | 57 ++ 23 files changed, 1890 insertions(+), 882 deletions(-) create mode 100644 src/main/java/edu/jhuapl/trinity/data/messages/llm/EmbeddingsTextBatchInput.java create mode 100644 src/main/java/edu/jhuapl/trinity/javafx/components/hyperdrive/EmbeddingBatchResponseValidator.java create mode 100644 src/main/java/edu/jhuapl/trinity/javafx/components/hyperdrive/LandmarkEmbeddingState.java create mode 100644 src/main/java/edu/jhuapl/trinity/javafx/components/hyperdrive/TextChunkParser.java create mode 100644 src/main/java/edu/jhuapl/trinity/javafx/components/hyperdrive/TextEmbeddingsBatchLauncher.java create mode 100644 src/main/java/edu/jhuapl/trinity/javafx/components/hyperdrive/TextLandmarkSimilarityEngine.java create mode 100644 src/test/java/edu/jhuapl/trinity/javafx/components/hyperdrive/BatchRequestManagerTest.java create mode 100644 src/test/java/edu/jhuapl/trinity/javafx/components/hyperdrive/EmbeddingBatchResponseValidatorTest.java create mode 100644 src/test/java/edu/jhuapl/trinity/javafx/components/hyperdrive/LandmarkEmbeddingStateTest.java create mode 100644 src/test/java/edu/jhuapl/trinity/javafx/components/hyperdrive/TextChunkParserTest.java create mode 100644 src/test/java/edu/jhuapl/trinity/javafx/components/hyperdrive/TextLandmarkSimilarityEngineTest.java diff --git a/src/main/java/edu/jhuapl/trinity/data/messages/llm/EmbeddingsTextBatchInput.java b/src/main/java/edu/jhuapl/trinity/data/messages/llm/EmbeddingsTextBatchInput.java new file mode 100644 index 00000000..96f045ee --- /dev/null +++ b/src/main/java/edu/jhuapl/trinity/data/messages/llm/EmbeddingsTextBatchInput.java @@ -0,0 +1,31 @@ +package edu.jhuapl.trinity.data.messages.llm; + +import com.fasterxml.jackson.annotation.JsonIgnoreProperties; +import com.fasterxml.jackson.annotation.JsonInclude; +import java.util.List; + +/** OpenAI-compatible batch of text inputs (one embedding per input string). */ +@JsonInclude(JsonInclude.Include.NON_NULL) +@JsonIgnoreProperties(ignoreUnknown = true) +public class EmbeddingsTextBatchInput { + private Object input; + private String model; + private String encoding_format = "float"; + private String embedding_type = "all"; + private int dimensions = 512; + private String user = "string"; + + public Object getInput() { return input; } + public void setInput(List input) { this.input = input; } + public void setInput(String input) { this.input = input; } + public String getModel() { return model; } + public void setModel(String model) { this.model = model; } + public String getEncoding_format() { return encoding_format; } + public void setEncoding_format(String value) { encoding_format = value; } + public String getEmbedding_type() { return embedding_type; } + public void setEmbedding_type(String value) { embedding_type = value; } + public int getDimensions() { return dimensions; } + public void setDimensions(int value) { dimensions = value; } + public String getUser() { return user; } + public void setUser(String value) { user = value; } +} diff --git a/src/main/java/edu/jhuapl/trinity/javafx/components/hyperdrive/BatchRequestManager.java b/src/main/java/edu/jhuapl/trinity/javafx/components/hyperdrive/BatchRequestManager.java index 337d3c14..ee2c9173 100644 --- a/src/main/java/edu/jhuapl/trinity/javafx/components/hyperdrive/BatchRequestManager.java +++ b/src/main/java/edu/jhuapl/trinity/javafx/components/hyperdrive/BatchRequestManager.java @@ -1,21 +1,16 @@ package edu.jhuapl.trinity.javafx.components.hyperdrive; -/** - * - * @author Sean Phillips - */ - import org.slf4j.Logger; import org.slf4j.LoggerFactory; +import java.util.ArrayDeque; import java.util.ArrayList; import java.util.Collection; import java.util.Collections; +import java.util.HashMap; import java.util.List; import java.util.Map; import java.util.Queue; -import java.util.concurrent.ConcurrentHashMap; -import java.util.concurrent.ConcurrentLinkedQueue; import java.util.concurrent.Executors; import java.util.concurrent.ScheduledExecutorService; import java.util.concurrent.ScheduledFuture; @@ -25,321 +20,269 @@ import java.util.function.Supplier; /** - * Generic manager for batching and throttling REST requests with retries and - * timeouts. - * - * @param T = your batch/request type (e.g. List) - * + * Bounded asynchronous batch dispatcher. All mutable dispatch state is owned by + * one scheduler thread. A request occupies a slot from its actual launch until + * completion/timeout; retries receive fresh request IDs, so late HTTP responses + * cannot complete a newer attempt. */ public class BatchRequestManager { - private static final Logger LOG = LoggerFactory.getLogger(BatchRequestManager.class); - private final Queue> pendingQueue = new ConcurrentLinkedQueue<>(); - private final AtomicInteger inFlight = new AtomicInteger(0); - private long requestDelayMillis = 0; - private int maxInFlight; - private long timeoutMillis; - private int maxRetries; - private int batchesCompleted = 0; - private int totalBatches = 0; - private final ScheduledExecutorService scheduler = Executors.newScheduledThreadPool(2); - private final Map> timeouts = new ConcurrentHashMap<>(); - private final Map batchStartTimes = new ConcurrentHashMap<>(); + private final Queue> pendingQueue = new ArrayDeque<>(); + private final Map> active = new HashMap<>(); + private final AtomicInteger inFlight = new AtomicInteger(); + private final ScheduledExecutorService scheduler = Executors.newSingleThreadScheduledExecutor(r -> { + Thread t = new Thread(r, "Trinity Batch Request Dispatcher"); + t.setDaemon(true); + return t; + }); private final List batchDurations = Collections.synchronizedList(new ArrayList<>()); - private final Map batchEndDurations = new ConcurrentHashMap<>(); - + private final Map batchEndDurations = new java.util.concurrent.ConcurrentHashMap<>(); private final Supplier requestIdSupplier; private final Supplier batchNumberSupplier; - private final TriFunction taskFactory; // (batch, batchNumber, reqId) -> Runnable + private final TriFunction taskFactory; private final Consumer> onComplete; + private volatile int maxInFlight; + private volatile long timeoutMillis; + private volatile long requestDelayMillis; + private volatile int maxRetries; + private volatile int batchesCompleted; + private volatile int totalBatches; + private volatile boolean busy; + private volatile long generation; + private long nextLaunchNanos; + private long lastActualLaunchNanos; public interface BatchResult { - int batchNumber(); - int requestId(); - T batch(); - Status status(); - int retryCount(); - Exception exception(); - - enum Status { - SUCCESS, FAILURE, TIMEOUT - } + enum Status { SUCCESS, FAILURE, TIMEOUT } } - // Internal wrapper private static class BatchWrapper { - final T batch; final int batchNumber; - final int requestId; // Typically per batch, but you can expand to per-item if you want int retries; - - BatchWrapper(T batch, int batchNumber, int requestId) { + BatchWrapper(T batch, int batchNumber) { this.batch = batch; this.batchNumber = batchNumber; + } + } + + private static class Attempt { + final BatchWrapper wrapper; + final int requestId; + ScheduledFuture launch; + ScheduledFuture timeout; + long startedNanos; + Attempt(BatchWrapper wrapper, int requestId) { + this.wrapper = wrapper; this.requestId = requestId; - this.retries = 0; } } - // TriFunction interface @FunctionalInterface public interface TriFunction { - R apply(A a, B b, C c); } - public BatchRequestManager( - int maxInFlight, - long timeoutMillis, - int maxRetries, - Supplier batchNumberSupplier, - Supplier requestIdSupplier, - TriFunction taskFactory, // (batch, batchNumber, reqId) -> Runnable - Consumer> onComplete - ) { - this.maxInFlight = maxInFlight; - this.timeoutMillis = timeoutMillis; - this.maxRetries = maxRetries; + public BatchRequestManager(int maxInFlight, long timeoutMillis, int maxRetries, + Supplier batchNumberSupplier, + Supplier requestIdSupplier, + TriFunction taskFactory, + Consumer> onComplete) { + setMaxInFlight(maxInFlight); + setTimeoutMillis(timeoutMillis); + setMaxRetries(maxRetries); this.batchNumberSupplier = batchNumberSupplier; this.requestIdSupplier = requestIdSupplier; this.taskFactory = taskFactory; this.onComplete = onComplete; } - public void enqueue(Collection batches) { - totalBatches = batches.size(); - batchesCompleted = 0; - batchDurations.clear(); - batchEndDurations.clear(); - batchStartTimes.clear(); - for (T batch : batches) { - int batchNum = batchNumberSupplier.get(); - int reqId = requestIdSupplier.get(); - pendingQueue.add(new BatchWrapper<>(batch, batchNum, reqId)); - } - // Prime the pipeline: fill all concurrency slots now - initialDispatch(); - } - - public void stopAndClear() { - pendingQueue.clear(); - // Cancel all outstanding timeouts - for (ScheduledFuture future : timeouts.values()) { - if (future != null && !future.isDone()) { - future.cancel(true); + /** Must not enqueue a second job while the previous one is still running. */ + public synchronized boolean enqueue(Collection batches) { + if (busy) return false; + if (batches.isEmpty()) return true; + busy = true; + long jobGeneration = ++generation; + scheduler.execute(() -> { + if (generation != jobGeneration) return; + batchDurations.clear(); + batchEndDurations.clear(); + batchesCompleted = 0; + totalBatches = batches.size(); + nextLaunchNanos = System.nanoTime(); + lastActualLaunchNanos = 0; + for (T batch : batches) { + pendingQueue.add(new BatchWrapper<>(batch, batchNumberSupplier.get())); } - } - timeouts.clear(); - clearCounters(); + dispatch(); + }); + return true; + } + + /** Invalidate all queued launches and late callbacks, including after a new job starts. */ + public synchronized void stopAndClear() { + ++generation; + busy = false; + scheduler.execute(() -> { + pendingQueue.clear(); + for (Attempt attempt : active.values()) { + if (attempt.launch != null) attempt.launch.cancel(false); + if (attempt.timeout != null) attempt.timeout.cancel(false); + } + active.clear(); + clearCountersInternal(); + }); } + /** Kept for existing callers; do not reset concurrency while requests are active. */ public void clearCounters() { - //clear batch durations, starts, etc. - batchStartTimes.clear(); - batchEndDurations.clear(); - batchDurations.clear(); - //reset counters + scheduler.execute(() -> { + if (!busy) clearCountersInternal(); + }); + } + + private void clearCountersInternal() { inFlight.set(0); batchesCompleted = 0; totalBatches = 0; - LOG.warn("BatchRequestManager: All queued batches and timers stopped and cleared by user."); - } - - // Call this from enqueue() only - private void initialDispatch() { - while (inFlight.get() < maxInFlight && !pendingQueue.isEmpty()) { - scheduleNextBatch(); - } + batchDurations.clear(); + batchEndDurations.clear(); } - // Call this from completion/failure/timeout private void dispatch() { - while (inFlight.get() < maxInFlight && !pendingQueue.isEmpty()) { - scheduleNextBatch(); + while (active.size() < maxInFlight && !pendingQueue.isEmpty()) { + BatchWrapper wrapper = pendingQueue.remove(); + int reqId = requestIdSupplier.get(); + Attempt attempt = new Attempt<>(wrapper, reqId); + active.put(reqId, attempt); + inFlight.set(active.size()); + long now = System.nanoTime(); + long scheduledAt = Math.max(now, nextLaunchNanos); + nextLaunchNanos = scheduledAt + TimeUnit.MILLISECONDS.toNanos(requestDelayMillis); + long waitNanos = Math.max(0, scheduledAt - now); + long epoch = generation; + attempt.launch = scheduler.schedule(() -> launchAttempt(attempt, epoch), + waitNanos, TimeUnit.NANOSECONDS); } - } - - private void scheduleNextBatch() { - BatchWrapper wrapper = pendingQueue.poll(); - if (wrapper != null) { - inFlight.incrementAndGet(); - LOG.info("Dispatching batch {} (batchNumber={}) (retry #{})", wrapper.requestId, wrapper.batchNumber, wrapper.retries); - - // Timeout for this batch - ScheduledFuture timeout = scheduler.schedule(() -> { - onTimeout(wrapper); - }, timeoutMillis, TimeUnit.MILLISECONDS); - timeouts.put(wrapper.requestId, timeout); - - // Schedule the batch with delay - scheduler.schedule(() -> { - Runnable task = taskFactory.apply(wrapper.batch, wrapper.batchNumber, wrapper.requestId); - try { - task.run(); - } catch (Exception ex) { - LOG.error("Batch {} (batchNumber={}) threw exception: {}", wrapper.requestId, wrapper.batchNumber, ex.toString()); - handleFailure(wrapper, ex); - } - }, requestDelayMillis, TimeUnit.MILLISECONDS); - - long now = System.currentTimeMillis(); - batchStartTimes.put(wrapper.requestId, now); + if (busy && active.isEmpty() && pendingQueue.isEmpty()) busy = false; + } + + private void launchAttempt(Attempt attempt, long epoch) { + if (generation != epoch || active.get(attempt.requestId) != attempt) return; + long now = System.nanoTime(); + long delayNanos = TimeUnit.MILLISECONDS.toNanos(requestDelayMillis); + if (lastActualLaunchNanos != 0 && now - lastActualLaunchNanos < delayNanos) { + long wait = delayNanos - (now - lastActualLaunchNanos); + attempt.launch = scheduler.schedule(() -> launchAttempt(attempt, epoch), + wait, TimeUnit.NANOSECONDS); + return; + } + lastActualLaunchNanos = now; + attempt.startedNanos = now; + attempt.timeout = scheduler.schedule(() -> finish(attempt.requestId, + BatchResult.Status.TIMEOUT, new java.util.concurrent.TimeoutException("Batch request timed out"), null), + timeoutMillis, TimeUnit.MILLISECONDS); + try { + taskFactory.apply(attempt.wrapper.batch, attempt.wrapper.batchNumber, attempt.requestId).run(); + } catch (Exception ex) { + finish(attempt.requestId, BatchResult.Status.FAILURE, ex, null); } } public void completeSuccess(int requestId, int batchNumber, T batch, int retries) { - handleCompletion(requestId, batchNumber, batch, retries, BatchResult.Status.SUCCESS, null); - } - - public void completeFailure(int requestId, int batchNumber, T batch, int retries, Exception ex) { - handleCompletion(requestId, batchNumber, batch, retries, BatchResult.Status.FAILURE, ex); + completeSuccess(requestId, batchNumber, batch, retries, null); } - private void onTimeout(BatchWrapper wrapper) { - LOG.warn("Batch {} (batchNumber={}) timed out after {}ms (retry #{})", - wrapper.requestId, wrapper.batchNumber, timeoutMillis, wrapper.retries); - handleFailure(wrapper, null); // handleFailure will call dispatch() + /** successAction is run only if this exact attempt is still active. */ + public void completeSuccess(int requestId, int batchNumber, T batch, int retries, Runnable successAction) { + scheduler.execute(() -> finish(requestId, BatchResult.Status.SUCCESS, null, successAction)); } - private void handleCompletion(int requestId, int batchNumber, T batch, int retries, BatchResult.Status status, Exception ex) { - ScheduledFuture timeout = timeouts.remove(requestId); - if (timeout != null) { - timeout.cancel(false); - } - inFlight.decrementAndGet(); - batchesCompleted++; - - //Compute and store batch duration - Long startTime = batchStartTimes.remove(requestId); - if (startTime != null) { - long duration = System.currentTimeMillis() - startTime; - batchDurations.add(duration); // keep this as "most recent" - LOG.info("Batch {} completed in {} ms (avg: {} ms)", requestId, duration, getAvgBatchDurationMillis()); + public void completeFailure(int requestId, int batchNumber, T batch, int retries, Exception ex) { + scheduler.execute(() -> finish(requestId, BatchResult.Status.FAILURE, ex, null)); + } + + private void finish(int requestId, BatchResult.Status status, Exception exception, Runnable successAction) { + Attempt attempt = active.remove(requestId); + if (attempt == null) return; // timeout, stop, or prior callback already completed this attempt + if (attempt.launch != null) attempt.launch.cancel(false); + if (attempt.timeout != null) attempt.timeout.cancel(false); + inFlight.set(active.size()); + BatchWrapper wrapper = attempt.wrapper; + if (attempt.startedNanos != 0) { + long duration = TimeUnit.NANOSECONDS.toMillis(System.nanoTime() - attempt.startedNanos); + batchDurations.add(duration); batchEndDurations.put(requestId, duration); } - - if (onComplete != null) { - onComplete.accept(new BatchResultImpl<>(requestId, batchNumber, batch, status, retries, ex)); - } - dispatch(); - } - - private void handleFailure(BatchWrapper wrapper, Exception ex) { - timeouts.remove(wrapper.requestId); - inFlight.decrementAndGet(); - if (wrapper.retries < maxRetries) { - wrapper.retries++; - LOG.info("Retrying batch {} (batchNumber={}) (retry #{})", wrapper.requestId, wrapper.batchNumber, wrapper.retries); + if (status == BatchResult.Status.SUCCESS) { + if (successAction != null) successAction.run(); + } else if (wrapper.retries < maxRetries) { + ++wrapper.retries; + LOG.warn("Batch {} attempt {} failed ({}); retrying", wrapper.batchNumber, requestId, status); pendingQueue.add(wrapper); + dispatch(); + return; } else { - LOG.error("Batch {} (batchNumber={}) failed after {} retries", wrapper.requestId, wrapper.batchNumber, wrapper.retries); - if (onComplete != null) { - onComplete.accept(new BatchResultImpl<>(wrapper.requestId, wrapper.batchNumber, wrapper.batch, BatchResult.Status.FAILURE, wrapper.retries, ex)); - } + LOG.error("Batch {} failed after {} retries: {}", wrapper.batchNumber, wrapper.retries, exception); + } + ++batchesCompleted; + if (onComplete != null) { + onComplete.accept(new BatchResultImpl<>(requestId, wrapper.batchNumber, wrapper.batch, + status, wrapper.retries, exception)); } dispatch(); } public double getAvgBatchDurationMillis() { - if (batchDurations.isEmpty()) return 0; - return batchDurations.stream().mapToLong(Long::longValue).average().orElse(0); + synchronized (batchDurations) { + return batchDurations.stream().mapToLong(Long::longValue).average().orElse(0); + } } - public long getTotalBatchDurationMillis() { - if (batchDurations.isEmpty()) return 0; - return batchDurations.stream().mapToLong(Long::longValue).sum(); + synchronized (batchDurations) { + return batchDurations.stream().mapToLong(Long::longValue).sum(); + } } - public long getLastBatchDurationMillis() { - if (batchDurations.isEmpty()) return 0; - return batchDurations.get(batchDurations.size() - 1); - } - - public long getBatchDurationByID(int id) { - return batchEndDurations.getOrDefault(id, 0L); - } - - public long getRequestDelayMillis() { - return requestDelayMillis; - } - - public void setRequestDelayMillis(long requestDelayMillis) { - this.requestDelayMillis = requestDelayMillis; - } - - public int getInFlight() { - return inFlight.get(); - } - - public int getMaxInFlight() { - return maxInFlight; - } - - public void setMaxInFlight(int maxInFlight) { - this.maxInFlight = maxInFlight; - } - - public long getTimeoutMillis() { - return timeoutMillis; - } - - public void setTimeoutMillis(long timeoutMillis) { - this.timeoutMillis = timeoutMillis; - } - - public int getMaxRetries() { - return maxRetries; - } - - public void setMaxRetries(int maxRetries) { - this.maxRetries = maxRetries; - } - - /** - * @return the batchesCompleted - */ - public int getBatchesCompleted() { - return batchesCompleted; - } - - /** - * @param batchesCompleted the batchesCompleted to set - */ - public void setBatchesCompleted(int batchesCompleted) { - this.batchesCompleted = batchesCompleted; - } - - /** - * @return the totalBatches - */ - public int getTotalBatches() { - return totalBatches; - } - - /** - * @param totalBatches the totalBatches to set - */ - public void setTotalBatches(int totalBatches) { - this.totalBatches = totalBatches; - } - - private record BatchResultImpl(int requestId, int batchNumber, T batch, Status status, int retryCount, Exception ex) implements BatchResult { - // Java records don't like deeply mutable values like this on interfaces - @Override - public Exception exception() { - return ex; + synchronized (batchDurations) { + return batchDurations.isEmpty() ? 0 : batchDurations.get(batchDurations.size() - 1); } } - - public void shutdown() { - scheduler.shutdownNow(); - } + public long getBatchDurationByID(int id) { return batchEndDurations.getOrDefault(id, 0L); } + public long getRequestDelayMillis() { return requestDelayMillis; } + public void setRequestDelayMillis(long ms) { requestDelayMillis = Math.max(0, ms); } + public int getInFlight() { return inFlight.get(); } + public boolean isBusy() { return busy; } + public int getMaxInFlight() { return maxInFlight; } + public void setMaxInFlight(int limit) { + if (limit < 1) throw new IllegalArgumentException("maxInFlight must be >= 1"); + maxInFlight = limit; + scheduler.execute(this::dispatch); + } + public long getTimeoutMillis() { return timeoutMillis; } + public void setTimeoutMillis(long ms) { + if (ms < 1) throw new IllegalArgumentException("timeoutMillis must be >= 1"); + timeoutMillis = ms; + } + public int getMaxRetries() { return maxRetries; } + public void setMaxRetries(int retries) { + if (retries < 0) throw new IllegalArgumentException("maxRetries must be >= 0"); + maxRetries = retries; + } + public int getBatchesCompleted() { return batchesCompleted; } + public void setBatchesCompleted(int completed) { batchesCompleted = completed; } + public int getTotalBatches() { return totalBatches; } + public void setTotalBatches(int total) { totalBatches = total; } + + private record BatchResultImpl(int requestId, int batchNumber, T batch, Status status, + int retryCount, Exception exception) implements BatchResult { } + + public void shutdown() { scheduler.shutdownNow(); } } diff --git a/src/main/java/edu/jhuapl/trinity/javafx/components/hyperdrive/EmbeddingBatchResponseValidator.java b/src/main/java/edu/jhuapl/trinity/javafx/components/hyperdrive/EmbeddingBatchResponseValidator.java new file mode 100644 index 00000000..768eeaaa --- /dev/null +++ b/src/main/java/edu/jhuapl/trinity/javafx/components/hyperdrive/EmbeddingBatchResponseValidator.java @@ -0,0 +1,24 @@ +package edu.jhuapl.trinity.javafx.components.hyperdrive; + +import edu.jhuapl.trinity.data.messages.llm.EmbeddingsImageData; +import edu.jhuapl.trinity.data.messages.llm.EmbeddingsImageOutput; +import java.util.HashSet; +import java.util.Set; + +/** Reject missing, extra, duplicate or out-of-range indexes before committing a batch. */ +public final class EmbeddingBatchResponseValidator { + private EmbeddingBatchResponseValidator() { } + + public static void validate(EmbeddingsImageOutput output, int expectedCount) { + if (output == null || output.getData() == null || output.getData().size() != expectedCount) { + throw new IllegalArgumentException("Embedding response count does not match the submitted batch"); + } + Set seen = new HashSet<>(); + for (EmbeddingsImageData item : output.getData()) { + if (item == null || item.getIndex() < 0 || item.getIndex() >= expectedCount + || !seen.add(item.getIndex()) || item.getEmbedding() == null || item.getEmbedding().isEmpty()) { + throw new IllegalArgumentException("Invalid or duplicate embedding response index/data"); + } + } + } +} diff --git a/src/main/java/edu/jhuapl/trinity/javafx/components/hyperdrive/ImageEmbeddingsBatchLauncher.java b/src/main/java/edu/jhuapl/trinity/javafx/components/hyperdrive/ImageEmbeddingsBatchLauncher.java index cca12910..1a74706b 100644 --- a/src/main/java/edu/jhuapl/trinity/javafx/components/hyperdrive/ImageEmbeddingsBatchLauncher.java +++ b/src/main/java/edu/jhuapl/trinity/javafx/components/hyperdrive/ImageEmbeddingsBatchLauncher.java @@ -1,12 +1,11 @@ package edu.jhuapl.trinity.javafx.components.hyperdrive; import edu.jhuapl.trinity.data.messages.llm.EmbeddingsImageBatchInput; +import edu.jhuapl.trinity.data.messages.llm.EmbeddingsImageOutput; import edu.jhuapl.trinity.data.messages.llm.EmbeddingsImageUrl; import edu.jhuapl.trinity.javafx.components.listviews.EmbeddingsImageListItem; -import edu.jhuapl.trinity.messages.EmbeddingsImageCallback; import edu.jhuapl.trinity.messages.RestAccessLayer; import edu.jhuapl.trinity.utils.ResourceUtils; -import javafx.scene.Scene; import org.slf4j.Logger; import org.slf4j.LoggerFactory; @@ -17,88 +16,49 @@ import static edu.jhuapl.trinity.data.messages.llm.EmbeddingsImageUrl.imageUrlFromImage; -/** - * Utility for launching a batch of image embedding requests and registering - * completion callbacks with the BatchRequestManager. - */ +/** Sends an image batch without applying results until its manager accepts completion. */ public class ImageEmbeddingsBatchLauncher { - private static final Logger LOG = LoggerFactory.getLogger(ImageEmbeddingsBatchLauncher.class); - - private final Scene scene; private String currentEmbeddingsModel; - public ImageEmbeddingsBatchLauncher(Scene scene, String model) { - this.scene = scene; + public ImageEmbeddingsBatchLauncher(javafx.scene.Scene scene, String model) { this.currentEmbeddingsModel = model; } - /** - * Launch a batch of embedding requests and register the completion - * callback. - * - * @param batch List of EmbeddingsImageListItem to process in this batch. - * @param batchNumber The unique batch number for this batch. - * @param reqId The request number for this REST batch submission. - * @param callback Completion callback, signature: (success, Exception) - */ public void launchBatch(List batch, int batchNumber, int reqId, - BiConsumer callback) { - //register the callback and fire the REST request. - EmbeddingsImageCallback.completionCallbacks.put(reqId, callback); - + long timeoutMillis, + BiConsumer callback) { List inputs = new ArrayList<>(); - - //Serial encoding (preserves GUI thread order) - for (int i = 0; i < batch.size(); i++) { - EmbeddingsImageListItem item = batch.get(i); - if (!item.isImageLoaded()) { - try { - inputs.add(imageUrlFromImage.apply( - ResourceUtils.loadImageFile(item.getFile()))); - } catch (IOException ex) { - LOG.error("Failed to load Image from source: " + item.getFile().getAbsolutePath()); - } - } else { - inputs.add(imageUrlFromImage.apply(item.getCurrentImage())); + try { + for (EmbeddingsImageListItem item : batch) { + inputs.add(imageUrlFromImage.apply(item.isImageLoaded() + ? item.getCurrentImage() : ResourceUtils.loadImageFile(item.getFile()))); } + } catch (IOException | RuntimeException ex) { + LOG.error("Unable to encode image batch {}", batchNumber, ex); + callback.accept(null, ex); + return; } - - // Now build and send the request as before: EmbeddingsImageBatchInput input = new EmbeddingsImageBatchInput(); input.setInput(inputs); input.setDimensions(512); input.setEmbedding_type("all"); input.setEncoding_format("float"); - input.setModel(getCurrentEmbeddingsModel()); + input.setModel(currentEmbeddingsModel); input.setUser("string"); - - try { - RestAccessLayer.requestImageEmbeddings( - input, - scene, - batch.stream().map(i -> i.imageID).toList(), - reqId - ); - LOG.info("Image batch (reqId={}, batchNumber={}) sent: {} items.", reqId, batchNumber, batch.size()); - } catch (Exception ex) { - LOG.error("Failed to send image embedding batch (reqId={}, batchNumber={}): {}", reqId, batchNumber, ex.getMessage()); - EmbeddingsImageCallback.completionCallbacks.remove(reqId); - callback.accept(false, ex); - } - } - - /** - * @return the currentEmbeddingsModel - */ - public String getCurrentEmbeddingsModel() { - return currentEmbeddingsModel; + RestAccessLayer.requestManagedImageEmbeddings(input, timeoutMillis, (output, error) -> { + if (error == null) { + try { + EmbeddingBatchResponseValidator.validate(output, batch.size()); + } catch (Exception ex) { + error = ex; + } + } + if (output != null) output.setRequestNumber(reqId); + callback.accept(output, error); + }); } - /** - * @param currentEmbeddingsModel the currentEmbeddingsModel to set - */ - public void setCurrentEmbeddingsModel(String currentEmbeddingsModel) { - this.currentEmbeddingsModel = currentEmbeddingsModel; - } + public String getCurrentEmbeddingsModel() { return currentEmbeddingsModel; } + public void setCurrentEmbeddingsModel(String model) { currentEmbeddingsModel = model; } } diff --git a/src/main/java/edu/jhuapl/trinity/javafx/components/hyperdrive/LandmarkEmbeddingState.java b/src/main/java/edu/jhuapl/trinity/javafx/components/hyperdrive/LandmarkEmbeddingState.java new file mode 100644 index 00000000..55550f3c --- /dev/null +++ b/src/main/java/edu/jhuapl/trinity/javafx/components/hyperdrive/LandmarkEmbeddingState.java @@ -0,0 +1,43 @@ +package edu.jhuapl.trinity.javafx.components.hyperdrive; + +import java.util.Objects; + +/** + * Pure Java request-version tracking for an editable text landmark. + * In-flight replies are rejected after an edit or a newer refresh request. + */ +public final class LandmarkEmbeddingState { + private long revision; + private boolean ready; + private String embeddedText; + private String embeddedModel; + + public void textChanged() { + revision++; + ready = false; + } + + public long beginRequest() { + revision++; + ready = false; + return revision; + } + + public boolean canAccept(long expectedRevision, String requestedText, + String requestedModel, String currentText, String currentModel) { + return revision == expectedRevision + && Objects.equals(requestedText, currentText) + && Objects.equals(requestedModel, currentModel); + } + + public void accepted(String text, String model) { + embeddedText = text; + embeddedModel = model; + ready = true; + } + + public boolean isFresh(String text, String model) { + return ready && Objects.equals(embeddedText, text) + && Objects.equals(embeddedModel, model); + } +} diff --git a/src/main/java/edu/jhuapl/trinity/javafx/components/hyperdrive/LoadTextTask.java b/src/main/java/edu/jhuapl/trinity/javafx/components/hyperdrive/LoadTextTask.java index eb6bb767..a6766d24 100644 --- a/src/main/java/edu/jhuapl/trinity/javafx/components/hyperdrive/LoadTextTask.java +++ b/src/main/java/edu/jhuapl/trinity/javafx/components/hyperdrive/LoadTextTask.java @@ -4,82 +4,116 @@ import edu.jhuapl.trinity.javafx.components.radial.CircleProgressIndicator; import edu.jhuapl.trinity.javafx.events.HyperdriveEvent; import edu.jhuapl.trinity.utils.ResourceUtils; -import edu.jhuapl.trinity.utils.Utils; import javafx.application.Platform; import javafx.scene.Scene; import org.slf4j.Logger; import org.slf4j.LoggerFactory; +import static edu.jhuapl.trinity.javafx.events.CommandTerminalEvent.notifyTerminalWarning; + import java.io.File; +import java.io.IOException; import java.nio.file.Files; import java.nio.file.Path; import java.util.ArrayList; import java.util.List; import java.util.concurrent.atomic.AtomicInteger; +import java.util.stream.Stream; - -/** - * @author Sean Phillips - */ +/** Parses text on a worker thread and creates JavaFX list items on the FX thread. */ public class LoadTextTask extends HyperdriveTask { private static final Logger LOG = LoggerFactory.getLogger(LoadTextTask.class); - List files; + private final List files; + private final TextChunkParser.Options options; public LoadTextTask(Scene scene, CircleProgressIndicator progressIndicator, List files) { super(scene, progressIndicator, new AtomicInteger(), null); - this.files = files; + this.files = List.copyOf(files); + // Consistent processing options for every file in this import job. + this.options = EmbeddingsTextListItem.parsingOptions(); } + private record ParsedFile(File file, List chunks) { } + @Override protected void processTask() throws Exception { - AtomicInteger atomicCount = new AtomicInteger(0); - if (null != progressIndicator) { - progressIndicator.setFadeTimeMS(250); - progressIndicator.setLabelLater("Loading " + atomicCount + " files..."); - progressIndicator.spin(true); - progressIndicator.fadeBusy(false); + if (progressIndicator != null) { + Platform.runLater(() -> { + progressIndicator.setFadeTimeMS(250); + progressIndicator.setLabelLater("Discovering text files..."); + progressIndicator.spin(true); + progressIndicator.fadeBusy(false); + }); } - ArrayList textFilesList = new ArrayList<>(); - LOG.info("Searching for files, filtering on ASCII/PDF...."); - long startTime = System.nanoTime(); + + List found = new ArrayList<>(); for (File file : files) { - //@DEBUB SMP LOG.info(file.getAbsolutePath()); + if (isCancelled()) return; if (file.isDirectory()) { - textFilesList.addAll( - Files.walk(file.toPath()) - .map(Path::toFile) - .filter(f -> ResourceUtils.isTextFile(f) || ResourceUtils.isPDF(file)) - .toList()); - } else { - if (ResourceUtils.isTextFile(file) || ResourceUtils.isPDF(file)) - textFilesList.add(file); + try (Stream paths = Files.walk(file.toPath())) { + paths.map(Path::toFile) + .filter(f -> ResourceUtils.isTextFile(f) || ResourceUtils.isPDF(f)) + .forEach(found::add); + } catch (IOException ex) { + LOG.error("Unable to discover files in {}", file, ex); + } + } else if (ResourceUtils.isTextFile(file) || ResourceUtils.isPDF(file)) { + found.add(file); + } + } + + // Deduplicate files discovered from overlapping input directories. + List discovered = new ArrayList<>(found.stream().distinct().toList()); + int total = discovered.size(); + int failed = 0; + int empty = 0; + List parsed = new ArrayList<>(); + for (int i = 0; i < total; i++) { + if (isCancelled()) return; + File file = discovered.get(i); + try { + List chunks = TextChunkParser.parse(file, options); + if (chunks.isEmpty()) { + empty++; + LOG.warn("No usable text found in {}", file); + } else { + parsed.add(new ParsedFile(file, chunks)); + } + } catch (Exception ex) { + failed++; + LOG.error("Unable to parse text file {}", file, ex); + } + int processed = i + 1; + if (progressIndicator != null) { + progressIndicator.setLabelLater("Read " + processed + " of " + total + " files"); } } - Utils.logTotalTime(startTime); - final double total = textFilesList.size(); - LOG.info("Loading textfiles into listitems...."); - startTime = System.nanoTime(); - List newItems = - textFilesList.parallelStream() - .map(EmbeddingsTextListItem.itemsSplitFromFile) - .flatMap(List::stream) - .peek(i -> { - double completed = atomicCount.incrementAndGet(); - if (null != progressIndicator) { - progressIndicator.setLabelLater(completed + " of " + total); - } - }).toList(); - Utils.logTotalTime(startTime); + int failedCount = failed; + int emptyCount = empty; + int parsedCount = parsed.size(); + LOG.info("Parsed {} text files ({} failures, {} empty)", parsedCount, failedCount, emptyCount); - LOG.info("loaded {} ASCII/PDF files.", textFilesList.size()); Platform.runLater(() -> { + List items = new ArrayList<>(); + ArrayList loadedFiles = new ArrayList<>(); + for (ParsedFile file : parsed) { + loadedFiles.add(file.file()); + for (TextChunkParser.ParsedChunk chunk : file.chunks()) { + items.add(EmbeddingsTextListItem.fromParsedChunk(file.file(), chunk)); + } + } scene.getRoot().fireEvent( - new HyperdriveEvent(HyperdriveEvent.NEW_BATCH_TEXTLOAD, newItems, textFilesList)); + new HyperdriveEvent(HyperdriveEvent.NEW_BATCH_TEXTLOAD, items, loadedFiles)); + if (failedCount + emptyCount > 0) { + notifyTerminalWarning("Text import: " + failedCount + " failed file(s), " + + emptyCount + " with no usable text (see application log).", scene); + } + if (progressIndicator != null) { + progressIndicator.setLabelLater("Loaded " + parsedCount + " files / " + items.size() + " chunks" + + (failedCount + emptyCount > 0 ? " (" + failedCount + " errors, " + emptyCount + " empty)" : "")); + progressIndicator.spin(false); + progressIndicator.fadeBusy(true); + } }); - if (null != progressIndicator) { - progressIndicator.setLabelLater("Complete"); - progressIndicator.spin(false); - progressIndicator.fadeBusy(true); - } } } diff --git a/src/main/java/edu/jhuapl/trinity/javafx/components/hyperdrive/RequestTextEmbeddingsTask.java b/src/main/java/edu/jhuapl/trinity/javafx/components/hyperdrive/RequestTextEmbeddingsTask.java index c14cac37..0bc169b7 100644 --- a/src/main/java/edu/jhuapl/trinity/javafx/components/hyperdrive/RequestTextEmbeddingsTask.java +++ b/src/main/java/edu/jhuapl/trinity/javafx/components/hyperdrive/RequestTextEmbeddingsTask.java @@ -1,93 +1,44 @@ package edu.jhuapl.trinity.javafx.components.hyperdrive; -import com.fasterxml.jackson.core.JsonProcessingException; -import edu.jhuapl.trinity.data.messages.llm.EmbeddingsImageInput; import edu.jhuapl.trinity.javafx.components.listviews.EmbeddingsTextListItem; -import edu.jhuapl.trinity.javafx.components.radial.CircleProgressIndicator; -import edu.jhuapl.trinity.messages.RestAccessLayer; -import edu.jhuapl.trinity.utils.Utils; +import javafx.application.Platform; import javafx.scene.Scene; -import org.slf4j.Logger; -import org.slf4j.LoggerFactory; - import java.util.ArrayList; import java.util.List; -import java.util.Map; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; import java.util.concurrent.atomic.AtomicInteger; +import java.util.function.Consumer; - -/** - * @author Sean Phillips - */ +/** Prepares file contents off the JavaFX thread; dispatch is handled by BatchRequestManager. */ public class RequestTextEmbeddingsTask extends HyperdriveTask { private static final Logger LOG = LoggerFactory.getLogger(RequestTextEmbeddingsTask.class); - String currentEmbeddingsModel; - List textEmbeddingsListItems; + private final List items; + private final Consumer> onReady; - public RequestTextEmbeddingsTask(Scene scene, String currentEmbeddingsModel) { + public RequestTextEmbeddingsTask(Scene scene, List items, + Consumer> onReady) { super(scene, null, new AtomicInteger(), null); - this.currentEmbeddingsModel = currentEmbeddingsModel; - this.textEmbeddingsListItems = null; - } - - public RequestTextEmbeddingsTask(Scene scene, CircleProgressIndicator progressIndicator, - AtomicInteger requestNumber, String currentEmbeddingsModel, - Map outstandingRequests, - List textEmbeddingsListItems) { - super(scene, progressIndicator, requestNumber, outstandingRequests); - this.currentEmbeddingsModel = currentEmbeddingsModel; - this.textEmbeddingsListItems = textEmbeddingsListItems; + this.items = List.copyOf(items); + this.onReady = onReady; } @Override - protected void processTask() throws Exception { - if (null != progressIndicator) { - progressIndicator.setFadeTimeMS(250); - progressIndicator.setLabelLater("Encoding Text..."); - progressIndicator.spin(true); - progressIndicator.fadeBusy(false); - } - LOG.info("Loading and Encoding Text..."); - long startTime = System.nanoTime(); - final int total = textEmbeddingsListItems.size(); - - int percent = 100; - if (total < 100) - percent = 10; - if (total < 10) - percent = 1; - int updatePercent = total / percent; - - double completed = 0; - for (EmbeddingsTextListItem item : textEmbeddingsListItems) { - if (null == item.contents) - item.readText(); - EmbeddingsImageInput input = EmbeddingsImageInput.defaultTextInput(item.contents); - if (null != currentEmbeddingsModel) - input.setModel(currentEmbeddingsModel); - List inputIDs = new ArrayList<>(); - inputIDs.add(item.textID); - try { - int rn = requestNumber.getAndIncrement(); - RestAccessLayer.requestTextEmbeddings( - input, scene, inputIDs, rn); - outstandingRequests.put(rn, REQUEST_STATUS.REQUESTED); - } catch (JsonProcessingException ex) { - LOG.error(null, ex); + protected void processTask() { + List valid = new ArrayList<>(); + for (EmbeddingsTextListItem item : items) { + if (isCancelled()) return; + if (item.contents == null) item.readText(); + if (item.contents == null || item.contents.isBlank()) { + LOG.warn("Skipping empty text embedding input from {}", item.getFile()); + continue; } - completed++; - if (null != progressIndicator) { - if (completed % updatePercent == 0) { - progressIndicator.setPercentComplete(completed / total); - } - progressIndicator.setLabelLater("Encoding " + completed + " of " + total); + // Retain the exact request payload as FeatureVector text provenance. + if (!item.contents.equals(item.getFeatureVector().getText())) { + item.getFeatureVector().setText(item.contents); } - Thread.sleep(getRequestDelay()); - } - Utils.logTotalTime(startTime); - if (null != progressIndicator) { - progressIndicator.setPercentComplete(completed / total); - progressIndicator.setLabelLater("Requested " + completed + " of " + total); + valid.add(item); } + if (!isCancelled()) Platform.runLater(() -> onReady.accept(valid)); } } diff --git a/src/main/java/edu/jhuapl/trinity/javafx/components/hyperdrive/RequestTextLandmarkSimilarityTask.java b/src/main/java/edu/jhuapl/trinity/javafx/components/hyperdrive/RequestTextLandmarkSimilarityTask.java index 2eeea620..a0cebea5 100644 --- a/src/main/java/edu/jhuapl/trinity/javafx/components/hyperdrive/RequestTextLandmarkSimilarityTask.java +++ b/src/main/java/edu/jhuapl/trinity/javafx/components/hyperdrive/RequestTextLandmarkSimilarityTask.java @@ -1,72 +1,55 @@ package edu.jhuapl.trinity.javafx.components.hyperdrive; -import edu.jhuapl.trinity.data.messages.xai.FeatureVector; import edu.jhuapl.trinity.javafx.components.listviews.EmbeddingsTextListItem; import edu.jhuapl.trinity.javafx.components.radial.CircleProgressIndicator; import edu.jhuapl.trinity.utils.metric.Metric; +import javafx.application.Platform; import javafx.scene.Scene; -import org.slf4j.Logger; -import org.slf4j.LoggerFactory; - +import java.util.ArrayList; import java.util.List; -import static edu.jhuapl.trinity.data.messages.xai.FeatureVector.mapToStateArray; - - -/** - * @author Sean Phillips - */ +/** Computes classifications off-thread; only the FX-thread success handler applies labels. */ public class RequestTextLandmarkSimilarityTask extends HyperdriveTask { - private static final Logger LOG = LoggerFactory.getLogger(RequestTextLandmarkSimilarityTask.class); - List items; - List landmarkFeatures; - Metric metric; + public record Target(EmbeddingsTextListItem item, String originalLabel, List originalData, + double[] values) { } + public record Classification(Target target, String landmarkLabel) { } + + private final List targets; + private final List landmarks; + private final Metric metric; + private final List classifications = new ArrayList<>(); + private int skipped; - public RequestTextLandmarkSimilarityTask(Scene scene, CircleProgressIndicator progressIndicator, - List items, List landmarkFeatures, Metric metric) { - super(scene, progressIndicator, null, null); - this.items = items; - this.landmarkFeatures = landmarkFeatures; + public RequestTextLandmarkSimilarityTask(Scene scene, CircleProgressIndicator indicator, + List targets, + List landmarks, + Metric metric) { + super(scene, indicator, null, null); + this.targets = List.copyOf(targets); + this.landmarks = List.copyOf(landmarks); this.metric = metric; } @Override - protected void processTask() throws Exception { - if (null != progressIndicator) { - progressIndicator.setFadeTimeMS(250); - progressIndicator.setLabelLater("Computing Landmark Similarity Distances..."); - progressIndicator.spin(true); - progressIndicator.fadeBusy(false); - } - final double total = items.size(); - int currentIndex = 0; - List landmarkVectors = landmarkFeatures.stream() - .map(mapToStateArray).toList(); - for (EmbeddingsTextListItem item : items) { - double[] itemVector = mapToStateArray.apply(item.getFeatureVector()); - Double shortestDistance = null; - Integer shortestLandmarkIndex = null; - for (int i = 0; i < landmarkVectors.size(); i++) { - double currentDistance = metric.distance(itemVector, landmarkVectors.get(i)); - //System.out.println(i + " : " + currentDistance); - if (null == shortestDistance || currentDistance < shortestDistance) { - shortestDistance = currentDistance; - shortestLandmarkIndex = i; - } - } - item.setFeatureVectorLabel( - landmarkFeatures.get(shortestLandmarkIndex).getLabel()); - currentIndex++; - double completed = Integer.valueOf(currentIndex).doubleValue(); - if (null != progressIndicator) { - progressIndicator.setPercentComplete(completed / total); - progressIndicator.setLabelLater("Computed " + currentIndex + " of " + total); + protected void processTask() { + int total = targets.size(); + for (int i = 0; i < total; i++) { + if (isCancelled()) return; + Target target = targets.get(i); + var match = TextLandmarkSimilarityEngine.nearest(target.values(), landmarks, metric); + if (match.isPresent()) classifications.add(new Classification(target, match.get().label())); + else skipped++; + int completed = i + 1; + if (progressIndicator != null && (completed == total || completed % 50 == 0)) { + Platform.runLater(() -> { + if (isCancelled()) return; + progressIndicator.setPercentComplete((double) completed / total); + progressIndicator.setLabelLater("Compared " + completed + " of " + total); + }); } } - if (null != progressIndicator) { - progressIndicator.setLabelLater("Complete"); - progressIndicator.spin(false); - progressIndicator.fadeBusy(true); - } } + + public List getClassifications() { return List.copyOf(classifications); } + public int getSkipped() { return skipped; } } diff --git a/src/main/java/edu/jhuapl/trinity/javafx/components/hyperdrive/TextChunkParser.java b/src/main/java/edu/jhuapl/trinity/javafx/components/hyperdrive/TextChunkParser.java new file mode 100644 index 00000000..cf51c6ad --- /dev/null +++ b/src/main/java/edu/jhuapl/trinity/javafx/components/hyperdrive/TextChunkParser.java @@ -0,0 +1,224 @@ +package edu.jhuapl.trinity.javafx.components.hyperdrive; + +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import org.apache.pdfbox.Loader; +import org.apache.pdfbox.pdmodel.PDDocument; +import org.apache.pdfbox.text.PDFTextStripper; + +import java.io.File; +import java.io.IOException; +import java.nio.file.Files; +import java.util.ArrayList; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; + +/** + * File parsing and text chunk creation without JavaFX dependencies. Parse on a worker + * thread, then create EmbeddingsTextListItems on the JavaFX Application Thread. + */ +public final class TextChunkParser { + private static final ObjectMapper MAPPER = new ObjectMapper(); + + private TextChunkParser() { } + + public record Options(boolean jsonProcessing, boolean csvExpansion, + boolean labelFromCsvColumn, int csvLabelColumn, + boolean breakOnNewlines, int chunkSize) { + public Options { + if (chunkSize < 1) throw new IllegalArgumentException("chunkSize must be positive"); + if (csvLabelColumn < 0) throw new IllegalArgumentException("csvLabelColumn must not be negative"); + } + } + + /** The text value is exactly what the embedding server should receive. */ + public record ParsedChunk(String text, String label, Map metadata) { + public ParsedChunk { + if (text == null || text.isBlank()) { + throw new IllegalArgumentException("Empty embedding chunk"); + } + metadata = Map.copyOf(metadata); + } + } + + public static String extractText(File file) throws IOException { + if (file == null || !file.isFile() || !file.canRead()) { + throw new IOException("File is not readable: " + file); + } + if (file.getName().toLowerCase(java.util.Locale.ROOT).endsWith(".pdf")) { + try (PDDocument document = Loader.loadPDF(file)) { + PDFTextStripper stripper = new PDFTextStripper(); + stripper.setSortByPosition(true); + return stripper.getText(document); + } + } + return Files.readString(file.toPath()); + } + + public static List parse(File file, Options options) throws IOException { + return parseText(file, extractText(file), options); + } + + /** Package-friendly deterministic entry point for tests of content and options. */ + public static List parseText(File file, String source, Options options) throws IOException { + if (source == null || source.isBlank()) return List.of(); + String name = file.getName(); + String trimmed = source.strip(); + List parsed; + if (options.csvExpansion() && name.toLowerCase(java.util.Locale.ROOT).endsWith(".csv")) { + parsed = parseCsv(name, source, options); + } else if (options.jsonProcessing() && (trimmed.startsWith("{") || trimmed.startsWith("["))) { + parsed = parseJson(name, trimmed); + } else if (options.breakOnNewlines()) { + parsed = parseLines(name, source, options.chunkSize()); + } else { + parsed = splitText(name, source, options.chunkSize(), Map.of()); + } + // Stable, zero-based provenance for all supported formats. + List indexed = new ArrayList<>(parsed.size()); + for (int i = 0; i < parsed.size(); i++) { + ParsedChunk chunk = parsed.get(i); + Map meta = new LinkedHashMap<>(chunk.metadata()); + meta.put("source_chunk_index", Integer.toString(i)); + meta.put("source_chunk_count", Integer.toString(parsed.size())); + indexed.add(new ParsedChunk(chunk.text(), chunk.label(), meta)); + } + return indexed; + } + + private static List parseJson(String name, String json) throws IOException { + JsonNode root = MAPPER.readTree(json); + List output = new ArrayList<>(); + if (root.isArray()) { + for (int i = 0; i < root.size(); i++) addJsonValue(output, root.get(i), name, i); + } else { + addJsonValue(output, root, name, 0); + } + return output; + } + + private static void addJsonValue(List output, JsonNode value, String name, int index) { + if (value == null || value.isNull()) return; + String text = value.toPrettyString(); + String label = name; + Map metadata = new LinkedHashMap<>(); + metadata.put("source_json_index", Integer.toString(index)); + if (value.isObject()) { + var fields = value.properties(); + for (var field : fields) { + String key = field.getKey(); + JsonNode child = field.getValue(); + String lower = key.toLowerCase(java.util.Locale.ROOT); + if (lower.equals("text") && child != null && !child.isNull()) { + String candidate = child.isTextual() ? child.asText() : child.toPrettyString(); + if (!candidate.isBlank()) text = candidate; + } else if ((lower.contains("name") || lower.contains("label")) && child != null) { + label = child.isTextual() ? child.asText() : child.toString(); + } else { + metadata.put(key, child == null ? "null" : child.toString()); + } + } + } + if (!text.isBlank()) output.add(new ParsedChunk(text, label, metadata)); + } + + private static List parseLines(String name, String source, int size) { + List result = new ArrayList<>(); + // Supports CRLF, LF and lone CR within one imported file. + String[] lines = source.split("\\r\\n|\\n|\\r", -1); + for (int i = 0; i < lines.length; i++) { + if (lines[i].isBlank()) continue; + result.addAll(splitText(name, lines[i], size, Map.of("source_line", Integer.toString(i + 1)))); + } + return result; + } + + private static List splitText(String name, String source, int size, Map metadata) { + List result = new ArrayList<>(); + for (int start = 0; start < source.length();) { + int end = Math.min(source.length(), start + size); + // Avoid slicing a surrogate pair across two embedding requests. + if (end < source.length() && end > start + && Character.isHighSurrogate(source.charAt(end - 1)) + && Character.isLowSurrogate(source.charAt(end))) end--; + if (end == start) end = Math.min(source.length(), start + 2); + String chunk = source.substring(start, end); + if (!chunk.isBlank()) result.add(new ParsedChunk(chunk, name, metadata)); + start = end; + } + return result; + } + + private static List parseCsv(String name, String source, Options options) throws IOException { + List> records = parseCsvRecords(source); + if (records.size() < 2) return List.of(); + List labels = records.get(0); + List chunks = new ArrayList<>(); + for (int rowIndex = 1; rowIndex < records.size(); rowIndex++) { + List record = records.get(rowIndex); + if (record.stream().allMatch(String::isBlank)) continue; + StringBuilder expanded = new StringBuilder(); + for (int col = 0; col < Math.min(labels.size(), record.size()); col++) { + expanded.append(labels.get(col).trim()).append(" = ") + .append(record.get(col).trim()).append(System.lineSeparator()); + } + if (expanded.isEmpty() || expanded.toString().isBlank()) continue; + String label = name; + if (options.labelFromCsvColumn() && options.csvLabelColumn() < record.size()) { + label = record.get(options.csvLabelColumn()); + } + chunks.add(new ParsedChunk(expanded.toString(), label, + Map.of("source_csv_record", Integer.toString(rowIndex + 1)))); + } + return chunks; + } + + /** RFC-4180-style CSV field parsing, including escaped quotes and multiline fields. */ + public static List> parseCsvRecords(String source) throws IOException { + List> records = new ArrayList<>(); + List fields = new ArrayList<>(); + StringBuilder field = new StringBuilder(); + boolean inQuotes = false; + boolean afterQuote = false; + boolean atFieldStart = true; + for (int i = 0; i < source.length(); i++) { + char c = source.charAt(i); + if (inQuotes) { + if (c == '"') { + if (i + 1 < source.length() && source.charAt(i + 1) == '"') { + field.append('"'); i++; + } else { + inQuotes = false; + afterQuote = true; + } + } else field.append(c); + } else if (c == ',' || c == '\n' || c == '\r') { + fields.add(field.toString()); + field.setLength(0); + atFieldStart = true; + afterQuote = false; + if (c != ',') { + if (c == '\r' && i + 1 < source.length() && source.charAt(i + 1) == '\n') i++; + records.add(fields); + fields = new ArrayList<>(); + } + } else if (c == '"' && atFieldStart) { + inQuotes = true; + atFieldStart = false; + } else { + if (afterQuote && !Character.isWhitespace(c)) { + throw new IOException("Unexpected character after closing CSV quote at offset " + i); + } + if (!afterQuote) field.append(c); + atFieldStart = false; + } + } + if (inQuotes) throw new IOException("Unterminated quoted CSV field"); + if (!fields.isEmpty() || !atFieldStart || field.length() > 0 || source.endsWith(",")) { + fields.add(field.toString()); + records.add(fields); + } + return records; + } +} diff --git a/src/main/java/edu/jhuapl/trinity/javafx/components/hyperdrive/TextEmbeddingsBatchLauncher.java b/src/main/java/edu/jhuapl/trinity/javafx/components/hyperdrive/TextEmbeddingsBatchLauncher.java new file mode 100644 index 00000000..f191fe14 --- /dev/null +++ b/src/main/java/edu/jhuapl/trinity/javafx/components/hyperdrive/TextEmbeddingsBatchLauncher.java @@ -0,0 +1,37 @@ +package edu.jhuapl.trinity.javafx.components.hyperdrive; + +import edu.jhuapl.trinity.data.messages.llm.EmbeddingsImageOutput; +import edu.jhuapl.trinity.data.messages.llm.EmbeddingsTextBatchInput; +import edu.jhuapl.trinity.javafx.components.listviews.EmbeddingsTextListItem; +import edu.jhuapl.trinity.messages.RestAccessLayer; +import java.util.List; +import java.util.function.BiConsumer; + +/** Submits a batch of text chunks with stable, positional input IDs. */ +public class TextEmbeddingsBatchLauncher { + private String model; + public TextEmbeddingsBatchLauncher(String model) { this.model = model; } + public void setModel(String model) { this.model = model; } + + public void launchBatch(List batch, int batchNumber, int reqId, + long timeoutMillis, BiConsumer callback) { + EmbeddingsTextBatchInput input = new EmbeddingsTextBatchInput(); + input.setModel(model); + if (batch.size() == 1) { + input.setInput(batch.get(0).contents); // preserve legacy single-string server compatibility + } else { + input.setInput(batch.stream().map(item -> item.contents).toList()); + } + RestAccessLayer.requestManagedTextEmbeddings(input, timeoutMillis, (output, error) -> { + if (error == null) { + try { + EmbeddingBatchResponseValidator.validate(output, batch.size()); + } catch (Exception ex) { + error = ex; + } + } + if (output != null) output.setRequestNumber(reqId); + callback.accept(output, error); + }); + } +} diff --git a/src/main/java/edu/jhuapl/trinity/javafx/components/hyperdrive/TextLandmarkSimilarityEngine.java b/src/main/java/edu/jhuapl/trinity/javafx/components/hyperdrive/TextLandmarkSimilarityEngine.java new file mode 100644 index 00000000..b30afb94 --- /dev/null +++ b/src/main/java/edu/jhuapl/trinity/javafx/components/hyperdrive/TextLandmarkSimilarityEngine.java @@ -0,0 +1,51 @@ +package edu.jhuapl.trinity.javafx.components.hyperdrive; + +import edu.jhuapl.trinity.utils.metric.Metric; +import edu.jhuapl.trinity.utils.metric.CosineMetric; +import edu.jhuapl.trinity.utils.metric.AngularMetric; +import java.util.List; +import java.util.Optional; + +/** Pure Java nearest-landmark classification; no JavaFX scene-graph access. */ +public final class TextLandmarkSimilarityEngine { + private TextLandmarkSimilarityEngine() { } + + public record Landmark(String label, double[] values) { } + public record Match(String label, double distance) { } + + public static boolean usable(double[] data) { + if (data == null || data.length == 0) return false; + for (double value : data) if (!Double.isFinite(value)) return false; + return true; + } + + public static boolean compatible(double[] target, double[] landmark) { + return usable(target) && usable(landmark) && target.length == landmark.length; + } + + private static boolean isZero(double[] values) { + for (double value : values) if (value != 0.0) return false; + return true; + } + + public static Optional nearest(double[] target, List landmarks, Metric metric) { + if (metric == null || !usable(target) || landmarks == null) return Optional.empty(); + Match best = null; + for (Landmark landmark : landmarks) { + if (landmark == null || !compatible(target, landmark.values())) continue; + // A zero vector has no meaningful direction for cosine/angular distance. + if ((metric instanceof CosineMetric || metric instanceof AngularMetric) + && (isZero(target) || isZero(landmark.values()))) continue; + double distance; + try { + distance = metric.distance(target, landmark.values()); + } catch (RuntimeException ex) { + // Some metrics cannot evaluate particular vectors (e.g. zero denominators). + continue; + } + if (!Double.isFinite(distance)) continue; + if (best == null || distance < best.distance()) best = new Match(landmark.label(), distance); + } + return Optional.ofNullable(best); + } +} diff --git a/src/main/java/edu/jhuapl/trinity/javafx/components/listviews/EmbeddingsTextListItem.java b/src/main/java/edu/jhuapl/trinity/javafx/components/listviews/EmbeddingsTextListItem.java index e2c83769..d7459d03 100644 --- a/src/main/java/edu/jhuapl/trinity/javafx/components/listviews/EmbeddingsTextListItem.java +++ b/src/main/java/edu/jhuapl/trinity/javafx/components/listviews/EmbeddingsTextListItem.java @@ -1,31 +1,21 @@ package edu.jhuapl.trinity.javafx.components.listviews; -import com.fasterxml.jackson.databind.JsonNode; -import com.fasterxml.jackson.databind.ObjectMapper; -import com.fasterxml.jackson.databind.node.JsonNodeType; import edu.jhuapl.trinity.data.messages.xai.FeatureVector; import edu.jhuapl.trinity.javafx.events.FeatureVectorEvent; -import edu.jhuapl.trinity.utils.MessageUtils; -import edu.jhuapl.trinity.utils.ResourceUtils; +import edu.jhuapl.trinity.javafx.components.hyperdrive.TextChunkParser; import javafx.application.Platform; import javafx.scene.control.Label; import javafx.scene.control.TextField; import javafx.scene.control.Tooltip; import javafx.scene.layout.HBox; -import org.apache.pdfbox.Loader; -import org.apache.pdfbox.pdmodel.PDDocument; -import org.apache.pdfbox.text.PDFTextStripper; import org.slf4j.Logger; import org.slf4j.LoggerFactory; import java.io.File; import java.io.IOException; -import java.nio.file.Files; import java.text.DecimalFormat; import java.text.NumberFormat; import java.util.ArrayList; -import java.util.Arrays; -import java.util.Collections; import java.util.List; import java.util.concurrent.atomic.AtomicInteger; import java.util.function.Function; @@ -99,15 +89,40 @@ public boolean embeddingsReceived() { } public void readText() { - if (null != file && file.isFile() && file.canRead()) { - try { - contents = Files.readString(file.toPath()); - featureVector.setText(contents); - } catch (IOException ex) { - contents = ""; - } - } else - contents = ""; + try { + setEmbeddingContents(TextChunkParser.extractText(file)); + } catch (IOException ex) { + LOG.error("Unable to read text from {}", file, ex); + setEmbeddingContents(""); + } + } + + /** Sets both the submitted embedding input and its FeatureVector provenance. */ + public void setEmbeddingContents(String text) { + contents = text == null ? "" : text; + featureVector.setText(contents); + } + + public File getFile() { + return file; + } + + /** Create UI nodes only on the JavaFX Application Thread. */ + public static EmbeddingsTextListItem fromParsedChunk(File file, TextChunkParser.ParsedChunk chunk) { + EmbeddingsTextListItem item = new EmbeddingsTextListItem(file, false); + item.setEmbeddingContents(chunk.text()); + item.setFeatureVectorLabel(chunk.label()); + chunk.metadata().forEach(item::addMetaData); + // JSON record metadata may itself contain a 'file' key. Always preserve + // the actual imported source path under an unambiguous provenance key. + item.addMetaData("source_file_path", file.getAbsolutePath()); + return item; + } + + public static TextChunkParser.Options parsingOptions() { + return new TextChunkParser.Options(ENABLE_JSON_PROCESSING, ENABLE_CSV_EXPANSION, + AUTOLABEL_FROM_CSVCOLUMN, CSV_DEFAULTLABEL_COLUMN, + BREAK_ON_NEWLINES, LARGEFILE_SPLIT_SIZE); } public void setEmbeddings(List data) { @@ -118,9 +133,11 @@ public void setEmbeddings(List data) { } public void setFeatureVectorLabel(String text) { - Platform.runLater(() -> { + if (Platform.isFxApplicationThread()) { labelTextField.setText(text); - }); + } else { + Platform.runLater(() -> labelTextField.setText(text)); + } featureVector.setLabel(text); } @@ -162,131 +179,60 @@ public void setFeatureVectorEntityID(String entityID) { public static Function itemNoParseFromFile = file -> { return new EmbeddingsTextListItem(file, false); }; + /** Compatibility adapter; callers should invoke it from the FX thread. */ public static Function> itemsSplitFromFile = file -> { - List list = new ArrayList<>(); try { - //First extract usable text - String fileString = null; - if (ResourceUtils.isPDF(file)) { - try (PDDocument document = Loader.loadPDF(file)) { - PDFTextStripper stripper = new PDFTextStripper(); - stripper.setSortByPosition(true); - fileString = stripper.getText(document); - //System.out.println(text); - } - } else { - fileString = Files.readString(file.toPath()); - } - //if JSON attempt intelligent object wise chunking - if (ENABLE_CSV_EXPANSION && MessageUtils.probablyCSV(file.getName())) { - //assumes first row is the column labels - return expandCSVAndChunk(file, fileString); - } else if (ENABLE_JSON_PROCESSING && MessageUtils.probablyJSON(fileString)) { - //if JSON attempt intelligent object wise chunking - final ObjectMapper mapper = new ObjectMapper(); - JsonNode jsonNode = mapper.readTree(fileString); - if (jsonNode.getNodeType() == JsonNodeType.ARRAY) { - for (final JsonNode objNode : jsonNode) { - //System.out.println(objNode); - EmbeddingsTextListItem item = new EmbeddingsTextListItem(file, false); - item.contents = objNode.toPrettyString(); - item.getFeatureVector().setText(item.contents); - item.setFeatureVectorLabel(file.getName()); - objNode.properties().forEach(obj -> { - String lower = obj.getKey().toLowerCase(); - //last found matching field will be used - if (lower.contains("name") || lower.contains("label")) { - item.setFeatureVectorLabel(obj.getValue().toString()); - } else if (lower.equalsIgnoreCase("text")) { - //overrides the above full contents - item.getFeatureVector().setText(obj.getValue().toPrettyString()); - } else { - item.addMetaData(obj.getKey(), obj.getValue().toString()); - } - }); - list.add(item); - } - } - } else { - //not smart but smarter than dumb newline delimited chunks - if (BREAK_ON_NEWLINES) { - return chunkByDelimiter(file, fileString, System.lineSeparator()); - } - //use naive bruteforce chunking - long total = file.length(); - if (total <= LARGEFILE_SPLIT_SIZE) { - list.add(new EmbeddingsTextListItem(file, true)); - return list; - } - return chunkString(file, fileString); - } - } catch (Exception ex) { - LOG.error("Wierdness trying to read in " + file.getName()); + return TextChunkParser.parse(file, parsingOptions()).stream() + .map(chunk -> fromParsedChunk(file, chunk)).toList(); + } catch (IOException ex) { + LOG.error("Could not parse text file {}", file, ex); + return List.of(); } - return list; }; public static List expandCSVAndChunk(File file, String fileString) throws IOException { - String[] csvRows = fileString.split(System.lineSeparator()); - if (csvRows.length <= 1) return Collections.EMPTY_LIST; - //get labels - String[] labels = csvRows[0].split(","); - List list = Arrays.asList(csvRows).stream() - .skip(1) //skip the first row (label row) - .map(s -> { - EmbeddingsTextListItem item = new EmbeddingsTextListItem(file, false); - String expandedContent = expandCSV(labels, s); - item.contents = expandedContent; - item.getFeatureVector().setText(expandedContent); - String[] csvTokens = s.split(","); - if (AUTOLABEL_FROM_CSVCOLUMN && csvTokens.length > CSV_DEFAULTLABEL_COLUMN) - item.setFeatureVectorLabel(csvTokens[CSV_DEFAULTLABEL_COLUMN]); - else - item.setFeatureVectorLabel(file.getName()); - return item; - }).toList(); - return list; + TextChunkParser.Options options = new TextChunkParser.Options(false, true, + AUTOLABEL_FROM_CSVCOLUMN, CSV_DEFAULTLABEL_COLUMN, false, LARGEFILE_SPLIT_SIZE); + return TextChunkParser.parseText(file, fileString, options).stream() + .map(chunk -> fromParsedChunk(file, chunk)).toList(); } public static String expandCSV(String[] labels, String csvRow) { - StringBuilder sb = new StringBuilder(); - String[] tokens = csvRow.split(","); - for (int i = 0; i < labels.length; i++) { - if (i >= tokens.length) break; //in case a row is incomplete - sb.append(labels[i].trim()).append(" = ").append(tokens[i].trim()).append(System.lineSeparator()); + try { + List> parsed = TextChunkParser.parseCsvRecords(csvRow); + if (parsed.isEmpty()) return ""; + List row = parsed.get(0); + StringBuilder expanded = new StringBuilder(); + for (int i = 0; i < Math.min(labels.length, row.size()); i++) { + expanded.append(labels[i].trim()).append(" = ") + .append(row.get(i).trim()).append(System.lineSeparator()); + } + return expanded.toString(); + } catch (IOException ex) { + LOG.error("Invalid CSV row", ex); + return ""; } - return sb.toString(); } - public static List chunkByDelimiter(File file, String fileString, String delimiter) throws IOException { - List list = Arrays.asList(fileString.split(delimiter)).stream() - .map(s -> { - EmbeddingsTextListItem item = new EmbeddingsTextListItem(file, false); - item.contents = s; - item.getFeatureVector().setText(s); - item.setFeatureVectorLabel(file.getName()); - return item; - }).toList(); - return list; + public static List chunkByDelimiter(File file, String text, String delimiter) { + if (delimiter == null || delimiter.isEmpty()) return chunkString(file, text); + List result = new ArrayList<>(); + for (String piece : text.split(java.util.regex.Pattern.quote(delimiter), -1)) { + if (piece.isBlank()) continue; + result.addAll(chunkString(file, piece)); + } + return result; } - public static List chunkString(File file, String fileString) { - List list = new ArrayList<>(); - int len = fileString.length(); - int currentStart = 0; - int currentEnd = LARGEFILE_SPLIT_SIZE; - while (currentStart <= len) { - if (currentEnd > len) - currentEnd = len; - String sub = fileString.substring(currentStart, currentEnd); - EmbeddingsTextListItem item = new EmbeddingsTextListItem(file, false); - item.contents = sub; - item.getFeatureVector().setText(sub); - item.setFeatureVectorLabel(file.getName()); - list.add(item); - currentStart += LARGEFILE_SPLIT_SIZE; - currentEnd += LARGEFILE_SPLIT_SIZE; + public static List chunkString(File file, String text) { + try { + TextChunkParser.Options options = new TextChunkParser.Options(false, false, + false, 0, false, LARGEFILE_SPLIT_SIZE); + return TextChunkParser.parseText(file, text, options).stream() + .map(chunk -> fromParsedChunk(file, chunk)).toList(); + } catch (IOException ex) { + // Plain string processing does not perform file I/O or JSON parsing. + throw new IllegalStateException("Unable to chunk string", ex); } - return list; } } diff --git a/src/main/java/edu/jhuapl/trinity/javafx/components/listviews/LandmarkListItem.java b/src/main/java/edu/jhuapl/trinity/javafx/components/listviews/LandmarkListItem.java index e719d875..2a4d3c06 100644 --- a/src/main/java/edu/jhuapl/trinity/javafx/components/listviews/LandmarkListItem.java +++ b/src/main/java/edu/jhuapl/trinity/javafx/components/listviews/LandmarkListItem.java @@ -20,6 +20,7 @@ public class LandmarkListItem extends HBox { private String landmarkLabel; private Label dimensionsLabel; private FeatureVector featureVector = null; + private boolean embeddingsReceived = false; public LandmarkListItem(String landmarkLabel) { this.landmarkLabel = landmarkLabel; @@ -37,6 +38,18 @@ public void setEmbeddings(List data) { featureVector.getData().clear(); featureVector.getData().addAll(data); dimensionsLabel.setText(format.format(data.size())); + embeddingsReceived = data != null && !data.isEmpty(); + } + + /** Placeholder FeatureVectors must never participate in similarity computation. */ + public boolean hasEmbeddings() { + return embeddingsReceived; + } + + public void invalidateEmbeddings() { + embeddingsReceived = false; + featureVector.getData().clear(); + dimensionsLabel.setText(format.format(0)); } public void setFeatureVectorLabel(String text) { diff --git a/src/main/java/edu/jhuapl/trinity/javafx/components/listviews/LandmarkTextListItem.java b/src/main/java/edu/jhuapl/trinity/javafx/components/listviews/LandmarkTextListItem.java index 5db4f8ad..9531cfd2 100644 --- a/src/main/java/edu/jhuapl/trinity/javafx/components/listviews/LandmarkTextListItem.java +++ b/src/main/java/edu/jhuapl/trinity/javafx/components/listviews/LandmarkTextListItem.java @@ -1,37 +1,77 @@ package edu.jhuapl.trinity.javafx.components.listviews; -import javafx.scene.control.Label; +import edu.jhuapl.trinity.javafx.components.hyperdrive.LandmarkEmbeddingState; import javafx.scene.control.TextField; -/** - * @author Sean Phillips - */ +import java.util.List; + +import static edu.jhuapl.trinity.javafx.events.CommandTerminalEvent.notifyTerminalWarning; + +/** Editable text landmark whose embeddings are invalidated on any text change. */ public class LandmarkTextListItem extends LandmarkListItem { public static double PREF_TEXTFIELD_WIDTH = 250; - private Label fileLabel; - private TextField labelTextField; + private final TextField labelTextField; + private final LandmarkEmbeddingState embeddingState = new LandmarkEmbeddingState(); + private boolean editedAfterEmbedding; public LandmarkTextListItem(String landmarkLabel) { super(landmarkLabel); - labelTextField = new TextField(); labelTextField.setEditable(true); labelTextField.setPrefWidth(PREF_TEXTFIELD_WIDTH); - labelTextField.setOnAction(e -> getFeatureVector().setLabel(labelTextField.getText())); - labelTextField.textProperty().addListener(e -> getFeatureVector().setLabel(labelTextField.getText())); labelTextField.setText(landmarkLabel); - getChildren().addAll(labelTextField); + labelTextField.textProperty().addListener((obs, previous, current) -> { + if (hasEmbeddings()) editedAfterEmbedding = true; + embeddingState.textChanged(); + invalidateEmbeddings(); + getFeatureVector().setLabel(current); + }); + // Do not spam the status line on each keystroke. + labelTextField.focusedProperty().addListener((obs, oldFocus, focused) -> { + if (!focused && editedAfterEmbedding && getScene() != null) { + notifyTerminalWarning("Text landmark edited; refresh its embedding before similarity classification.", getScene()); + editedAfterEmbedding = false; + } + }); + getChildren().add(labelTextField); setSpacing(20); setPrefHeight(32); } + /** Called on the FX thread immediately before sending a landmark request. */ + public long beginEmbeddingRequest() { + invalidateEmbeddings(); + editedAfterEmbedding = false; + return embeddingState.beginRequest(); + } + + /** Returns false for late or mismatched replies, without changing the landmark. */ + public boolean acceptEmbeddingResponse(long revision, String requestedText, + String requestedModel, String selectedModel, + List embedding) { + if (!embeddingState.canAccept(revision, requestedText, requestedModel, + labelTextField.getText(), selectedModel)) return false; + setEmbeddings(embedding); + embeddingState.accepted(requestedText, requestedModel); + return true; + } + + public boolean isEmbeddingFresh(String selectedModel) { + return hasEmbeddings() && embeddingState.isFresh(labelTextField.getText(), selectedModel); + } + @Override public void setFeatureVectorLabel(String text) { - super.setFeatureVectorLabel(text); labelTextField.setText(text); + super.setFeatureVectorLabel(text); + } + + @Override + public String getFeatureVectorLabel() { + return labelTextField.getText(); } public void setLabelWidth(double width) { - fileLabel.setPrefWidth(width); + labelTextField.setPrefWidth(width); } } diff --git a/src/main/java/edu/jhuapl/trinity/javafx/components/panes/HyperdrivePane.java b/src/main/java/edu/jhuapl/trinity/javafx/components/panes/HyperdrivePane.java index 7b45833c..1a0b5331 100644 --- a/src/main/java/edu/jhuapl/trinity/javafx/components/panes/HyperdrivePane.java +++ b/src/main/java/edu/jhuapl/trinity/javafx/components/panes/HyperdrivePane.java @@ -21,6 +21,8 @@ import edu.jhuapl.trinity.javafx.components.hyperdrive.ChooseCaptionsTask; import edu.jhuapl.trinity.javafx.components.hyperdrive.HyperdriveTask.REQUEST_STATUS; import edu.jhuapl.trinity.javafx.components.hyperdrive.ImageEmbeddingsBatchLauncher; +import edu.jhuapl.trinity.javafx.components.hyperdrive.TextEmbeddingsBatchLauncher; +import edu.jhuapl.trinity.javafx.components.hyperdrive.TextLandmarkSimilarityEngine; import edu.jhuapl.trinity.javafx.components.hyperdrive.LoadImagesTask; import edu.jhuapl.trinity.javafx.components.hyperdrive.LoadTextTask; import edu.jhuapl.trinity.javafx.components.hyperdrive.RequestCaptionsTask; @@ -33,6 +35,7 @@ import edu.jhuapl.trinity.javafx.components.listviews.LandmarkImageBuilderBox; import edu.jhuapl.trinity.javafx.components.listviews.LandmarkListItem; import edu.jhuapl.trinity.javafx.components.listviews.LandmarkTextBuilderBox; +import edu.jhuapl.trinity.javafx.components.listviews.LandmarkTextListItem; import edu.jhuapl.trinity.javafx.components.radial.CircleProgressIndicator; import edu.jhuapl.trinity.javafx.components.radial.ProgressStatus; import edu.jhuapl.trinity.javafx.events.FeatureVectorEvent; @@ -97,11 +100,15 @@ import java.util.List; import java.util.Map; import java.util.Optional; +import java.util.Objects; +import java.util.Set; +import java.util.HashSet; import java.util.concurrent.atomic.AtomicInteger; import static edu.jhuapl.trinity.data.messages.llm.EmbeddingsImageUrl.imageUrlFromImage; import static edu.jhuapl.trinity.javafx.events.CommandTerminalEvent.notifyTerminalSuccess; import static edu.jhuapl.trinity.javafx.events.CommandTerminalEvent.notifyTerminalWarning; +import static edu.jhuapl.trinity.javafx.events.CommandTerminalEvent.notifyTerminalError; import static edu.jhuapl.trinity.messages.RestAccessLayer.*; /** @@ -154,6 +161,32 @@ public class HyperdrivePane extends LitPathPane { Map outstandingRequests; ImageEmbeddingsBatchLauncher imageBatchLauncher; BatchRequestManager> imageEmbeddingManager; + TextEmbeddingsBatchLauncher textBatchLauncher; + BatchRequestManager> textEmbeddingManager; + Map imageOutstandingRequests = new HashMap<>(); + Map textOutstandingRequests = new HashMap<>(); + AtomicInteger textPreparationGeneration = new AtomicInteger(); + AtomicInteger imageJobGeneration = new AtomicInteger(); + AtomicInteger textJobGeneration = new AtomicInteger(); + boolean textPreparationInProgress; + + /** Only accessed on the JavaFX thread. */ + private final Map pendingTextLandmarkRequests = new HashMap<>(); + private final Map textLandmarkRefreshRuns = new HashMap<>(); + private int textLandmarkRefreshSequence; + private record PendingTextLandmarkRequest(int id, long revision, String requestedText, + String model, int runId) { } + private enum LandmarkRefreshOutcome { SUCCESS, FAILED, STALE } + private static final class TextLandmarkRefreshStats { + int total; + int complete; + int success; + int failed; + int stale; + int skipped; + } + private RequestTextLandmarkSimilarityTask activeTextSimilarityTask; + int batchSize = 1; int maxInFlightBatches = 10; @@ -213,8 +246,10 @@ public HyperdrivePane(Scene scene, Pane parent) { String text = textEmbeddingsListView.getSelectionModel() .getSelectedItems().get(0).contents; if (null == text) { - textEmbeddingsListView.getSelectionModel() - .getSelectedItems().get(0).readText(); + EmbeddingsTextListItem selected = textEmbeddingsListView.getSelectionModel() + .getSelectedItems().get(0); + selected.readText(); + text = selected.contents; } baseTextArea.setText(text); } @@ -248,24 +283,23 @@ public HyperdrivePane(Scene scene, Pane parent) { MenuItem textEmbbedingsTextLandmarkCaptionItem = new MenuItem("Label by Text Landmark Similarity"); textEmbbedingsTextLandmarkCaptionItem.setOnAction(e -> { - if (!textEmbeddingsListView.getSelectionModel().getSelectedItems().isEmpty()) - requestTextLandmarkSimilarityTask(textEmbeddingsListView.getSelectionModel().getSelectedItems() - , landmarkTextBuilderBox.getItems().stream() - .map(LandmarkListItem::getFeatureVector).toList()); + requestTextLandmarkSimilarityTask(textEmbeddingsListView.getSelectionModel().getSelectedItems(), + landmarkTextBuilderBox.getItems()); }); MenuItem imageEmbeddingsImageLandmarkCaptionItem = new MenuItem("Label by Image Landmark Similarity"); imageEmbeddingsImageLandmarkCaptionItem.setOnAction(e -> { - if (!textEmbeddingsListView.getSelectionModel().getSelectedItems().isEmpty()) { - requestTextLandmarkSimilarityTask(textEmbeddingsListView.getSelectionModel().getSelectedItems(), - landmarkImageBuilderBox.getItems().stream() - .map(LandmarkListItem::getFeatureVector).toList() - ); - } + requestTextLandmarkSimilarityTask(textEmbeddingsListView.getSelectionModel().getSelectedItems(), + landmarkImageBuilderBox.getItems()); }); MenuItem clearTextRequestsItem = new MenuItem("Clear Requests"); clearTextRequestsItem.setOnAction(e -> { - outstandingRequests.clear(); + if (activeTextSimilarityTask != null) activeTextSimilarityTask.cancel(); + textPreparationGeneration.incrementAndGet(); + textJobGeneration.incrementAndGet(); + textPreparationInProgress = false; + textEmbeddingManager.stopAndClear(); + textOutstandingRequests.clear(); textEmbeddingRequestIndicator.spin(false); textEmbeddingRequestIndicator.fadeBusy(true); }); @@ -297,9 +331,18 @@ public HyperdrivePane(Scene scene, Pane parent) { clearTextEmbeddingsButton.setWrapText(true); clearTextEmbeddingsButton.setTextAlignment(TextAlignment.CENTER); clearTextEmbeddingsButton.setOnAction(e -> { + if (activeTextSimilarityTask != null) activeTextSimilarityTask.cancel(); + textPreparationGeneration.incrementAndGet(); + textJobGeneration.incrementAndGet(); + textPreparationInProgress = false; + textEmbeddingManager.stopAndClear(); + textOutstandingRequests.clear(); + textEmbeddingRequestIndicator.spin(false); + textEmbeddingRequestIndicator.fadeBusy(true); currentTextFeatureList.clear(); textEmbeddingsListView.getItems().clear(); - textFilesCountLabel.setText(String.valueOf(textFilesList.size())); + refreshTextFileCount(); + baseTextArea.clear(); }); clearTextEmbeddingsButton.setCancelButton(true); @@ -312,6 +355,7 @@ public HyperdrivePane(Scene scene, Pane parent) { textEmbeddingsListView.getItems().clear(); currentTextFeatureList.clear(); textEmbeddingsListView.getItems().addAll(keep); + refreshTextFileCount(); }); Button injectTextFeaturesButton = new Button("Inject Features"); @@ -372,28 +416,28 @@ public HyperdrivePane(Scene scene, Pane parent) { imageEmbeddingsButton.setTextAlignment(TextAlignment.CENTER); imageEmbeddingsButton.setOnAction(e -> { - List items = imageEmbeddingsListView.getSelectionModel().getSelectedItems(); + if (imageEmbeddingManager.isBusy()) { + LOG.warn("Image embedding job already in progress"); + return; + } + List items = new ArrayList<>( + imageEmbeddingsListView.getSelectionModel().getSelectedItems()); + if (items.isEmpty()) return; List> batches = new ArrayList<>(); for (int i = 0; i < items.size(); i += batchSize) { - batches.add( - new ArrayList<>(items.subList(i, Math.min(i + batchSize, items.size()))) - ); + batches.add(new ArrayList<>(items.subList(i, Math.min(i + batchSize, items.size())))); } - //@DEBUG SMP - //System.out.println("Total Batches: " + batches.size()); - //Progress & OutstandingRequests Initialization - outstandingRequests.clear(); - for (EmbeddingsImageListItem item : items) { // use the flat, full list! - outstandingRequests.put(item.imageID, REQUEST_STATUS.REQUESTED); + imageJobGeneration.incrementAndGet(); + imageOutstandingRequests.clear(); + for (EmbeddingsImageListItem item : items) { + imageOutstandingRequests.put(item.imageID, REQUEST_STATUS.REQUESTED); } imageEmbeddingRequestIndicator.setPercentComplete(0); imageEmbeddingRequestIndicator.setTopLabelLater("Received 0 of " + items.size()); imageEmbeddingRequestIndicator.spin(true); - if (!imageEmbeddingRequestIndicator.inView()) - imageEmbeddingRequestIndicator.fadeBusy(false); - imageEmbeddingManager.clearCounters(); - // Launch batches - imageEmbeddingManager.enqueue(batches); + if (!imageEmbeddingRequestIndicator.inView()) imageEmbeddingRequestIndicator.fadeBusy(false); + imageBatchLauncher.setCurrentEmbeddingsModel(currentEmbeddingsModel); + if (!imageEmbeddingManager.enqueue(batches)) LOG.warn("Unable to enqueue image job while busy"); }); ImageView embeddingsPlaceholderIV = ResourceUtils.loadIcon("data", 50); @@ -494,7 +538,9 @@ public HyperdrivePane(Scene scene, Pane parent) { MenuItem clearRequestsItem = new MenuItem("Clear Requests"); clearRequestsItem.setOnAction(e -> { - outstandingRequests.clear(); + imageJobGeneration.incrementAndGet(); + imageEmbeddingManager.stopAndClear(); + imageOutstandingRequests.clear(); imageEmbeddingRequestIndicator.spin(false); imageEmbeddingRequestIndicator.fadeBusy(true); }); @@ -502,7 +548,7 @@ public HyperdrivePane(Scene scene, Pane parent) { stopAndClearMenuItem.setOnAction(e -> { imageEmbeddingManager.stopAndClear(); // Clear GUI progress and outstandingRequests map too, if desired - outstandingRequests.clear(); + imageOutstandingRequests.clear(); imageEmbeddingRequestIndicator.setPercentComplete(0); imageEmbeddingRequestIndicator.setTopLabelLater("Stopped"); imageEmbeddingRequestIndicator.spin(false); @@ -649,21 +695,41 @@ public HyperdrivePane(Scene scene, Pane parent) { //Controls to choose metric and refresh current landmark embeddings Button refreshLandmarkEmbeddingsButton = new Button("Refresh Embeddings"); refreshLandmarkEmbeddingsButton.setOnAction(e -> { - landmarkTextBuilderBox.getItems().stream() - .filter(i -> !i.getFeatureVectorLabel().isBlank()) - .forEach(item -> { - try { - EmbeddingsImageInput input = EmbeddingsImageInput.defaultTextInput(item.getFeatureVectorLabel()); - if (null != currentEmbeddingsModel) - input.setModel(currentEmbeddingsModel); - List inputIDs = new ArrayList<>(); - inputIDs.add(item.landmarkID); - RestAccessLayer.requestLandmarkTextEmbeddings( - input, scene, inputIDs, requestNumber.getAndIncrement()); - } catch (IOException ex) { - LOG.error(null, ex); - } - }); + List landmarks = List.copyOf(landmarkTextBuilderBox.getItems()); + if (landmarks.isEmpty() || landmarks.stream().allMatch(i -> i.getFeatureVectorLabel().isBlank())) { + showTextSimilarityAlert(Alert.AlertType.WARNING, "No Text Landmark Requests", + "Add at least one nonblank text landmark before refreshing embeddings."); + return; + } + int runId = ++textLandmarkRefreshSequence; + TextLandmarkRefreshStats stats = new TextLandmarkRefreshStats(); + stats.total = landmarks.size(); + textLandmarkRefreshRuns.put(runId, stats); + for (LandmarkTextListItem item : landmarks) { + String text = item.getFeatureVectorLabel(); + if (text == null || text.isBlank()) { + stats.skipped++; + stats.complete++; + continue; + } + int requestId = requestNumber.getAndIncrement(); + long revision = item.beginEmbeddingRequest(); + PendingTextLandmarkRequest pending = new PendingTextLandmarkRequest( + item.landmarkID, revision, text, currentEmbeddingsModel, runId); + pendingTextLandmarkRequests.put(requestId, pending); + try { + EmbeddingsImageInput input = EmbeddingsImageInput.defaultTextInput(text); + if (currentEmbeddingsModel != null) input.setModel(currentEmbeddingsModel); + RestAccessLayer.requestLandmarkTextEmbeddings( + input, scene, List.of(item.landmarkID), requestId); + } catch (IOException | RuntimeException ex) { + pendingTextLandmarkRequests.remove(requestId); + stats.failed++; + stats.complete++; + LOG.error("Unable to submit text landmark embedding request {}", requestId, ex); + } + } + finishTextLandmarkRefreshIfComplete(runId); }); Button refreshImageLandmarkEmbeddingsButton = new Button("Refresh Embeddings"); refreshImageLandmarkEmbeddingsButton.setOnAction(e -> { @@ -742,6 +808,7 @@ public HyperdrivePane(Scene scene, Pane parent) { timeoutSpinner.valueProperty().addListener(c -> { requestTimeoutMS = timeoutSpinner.getValue() * 1000; //spinner is in seconds imageEmbeddingManager.setTimeoutMillis(requestTimeoutMS); + textEmbeddingManager.setTimeoutMillis(requestTimeoutMS); }); timeoutSpinner.setEditable(true); timeoutSpinner.setPrefWidth(100); @@ -751,6 +818,7 @@ public HyperdrivePane(Scene scene, Pane parent) { Double delay = (Double) requestDelaySpinner.getValue(); requestDelay = delay.longValue(); imageEmbeddingManager.setRequestDelayMillis(requestDelay); + textEmbeddingManager.setRequestDelayMillis(requestDelay); }); requestDelaySpinner.setEditable(true); requestDelaySpinner.setPrefWidth(100); @@ -764,6 +832,7 @@ public HyperdrivePane(Scene scene, Pane parent) { outstandingSpinner.valueProperty().addListener(c -> { maxInFlightBatches = outstandingSpinner.getValue(); imageEmbeddingManager.setMaxInFlight(maxInFlightBatches); + textEmbeddingManager.setMaxInFlight(maxInFlightBatches); }); outstandingSpinner.setEditable(true); outstandingSpinner.setPrefWidth(100); @@ -824,7 +893,7 @@ public HyperdrivePane(Scene scene, Pane parent) { VBox chunkingSpinnerVBox = new VBox(30, new HBox(20, enableJSONCheckBox, enableCSVCheckBox), new HBox(20, labelFromColumnCheckBox, selectColumnHBox), - new HBox(20, breakOnNewLineCheckBox, new VBox(5, new Label("Chunk Size (bytes)"), chunkSizeSpinner)) + new HBox(20, breakOnNewLineCheckBox, new VBox(5, new Label("Chunk Size (characters)"), chunkSizeSpinner)) ); GridPane servicesGrid = new GridPane(20, 10); @@ -896,6 +965,7 @@ public HyperdrivePane(Scene scene, Pane parent) { if (null != selectedModel && !selectedModel.isBlank()) { currentEmbeddingsModel = selectedModel; imageBatchLauncher.setCurrentEmbeddingsModel(currentEmbeddingsModel); + textBatchLauncher.setModel(currentEmbeddingsModel); } }); @@ -1107,77 +1177,56 @@ public HyperdrivePane(Scene scene, Pane parent) { scene.getRoot().addEventHandler(RestEvent.NEW_EMBEDDINGS_IMAGE, event -> { EmbeddingsImageOutput output = (EmbeddingsImageOutput) event.object; List inputIDs = (List) event.object2; - - int totalListItems = outstandingRequests.size(); // Track total once at start - // For each result, mark as SUCCEEDED in outstandingRequests - for (int i = 0; i < output.getData().size(); i++) { - int currentInputID = inputIDs.get(i); - outstandingRequests.put(currentInputID, REQUEST_STATUS.SUCCEEDED); - // (Optionally update imageEmbeddingsListView items here as before) - EmbeddingsImageData currentOutput = output.getData().get(i); - if (currentInputID == EMBEDDINGS_IMAGE_TESTID) { + for (EmbeddingsImageData data : output.getData()) { + int index = data.getIndex(); + if (index < 0 || index >= inputIDs.size()) { + LOG.warn("Ignoring invalid image embedding index {}", index); + continue; + } + int id = inputIDs.get(index); + if (id == EMBEDDINGS_IMAGE_TESTID) { notifyTerminalSuccess("Image Embeddings Test Successful", scene); } else { - imageEmbeddingsListView.getItems() - .filtered(fi -> fi.imageID == currentInputID) + imageEmbeddingsListView.getItems().filtered(item -> item.imageID == id) .forEach(item -> { - item.setEmbeddings(currentOutput.getEmbedding()); - item.addMetaData("object", currentOutput.getObject()); - item.addMetaData("type", currentOutput.getType()); + item.setEmbeddings(data.getEmbedding()); + item.addMetaData("object", data.getObject()); + item.addMetaData("type", data.getType()); }); + if (imageOutstandingRequests.containsKey(id)) { + imageOutstandingRequests.put(id, REQUEST_STATUS.SUCCEEDED); + } } } - // Progress calculation - long succeeded = outstandingRequests.values().stream().filter(s -> s == REQUEST_STATUS.SUCCEEDED).count(); - long failed = outstandingRequests.values().stream().filter(s -> s == REQUEST_STATUS.FAILED).count(); - long completed = succeeded + failed; - - imageEmbeddingRequestIndicator.setPercentComplete(completed / (double) totalListItems); - imageEmbeddingRequestIndicator.setTopLabelLater("Received " + completed + " of " + totalListItems); - - // When ALL are done (succeeded or failed), clean up - if (completed == totalListItems) { - imageEmbeddingRequestIndicator.spin(false); - imageEmbeddingRequestIndicator.fadeBusy(true); - outstandingRequests.clear(); - } + updateImageEmbeddingsProgress(); }); scene.getRoot().addEventHandler(RestEvent.ERROR_EMBEDDINGS_IMAGE, event -> { List failedInputIDs = (List) event.object; - int totalListItems = outstandingRequests.size(); - - for (Integer failedId : failedInputIDs) { - outstandingRequests.put(failedId, REQUEST_STATUS.FAILED); - } - - long succeeded = outstandingRequests.values().stream().filter(s -> s == REQUEST_STATUS.SUCCEEDED).count(); - long failed = outstandingRequests.values().stream().filter(s -> s == REQUEST_STATUS.FAILED).count(); - long completed = succeeded + failed; - - imageEmbeddingRequestIndicator.setPercentComplete(completed / (double) totalListItems); - imageEmbeddingRequestIndicator.setTopLabelLater("Received " + completed + " of " + totalListItems); - - if (completed == totalListItems) { - imageEmbeddingRequestIndicator.spin(false); - imageEmbeddingRequestIndicator.fadeBusy(true); - outstandingRequests.clear(); + for (Integer id : failedInputIDs) { + if (imageOutstandingRequests.containsKey(id)) { + imageOutstandingRequests.put(id, REQUEST_STATUS.FAILED); + } } + updateImageEmbeddingsProgress(); }); scene.getRoot().addEventHandler(RestEvent.ERROR_EMBEDDINGS_TEXT, event -> { - List inputFiles = (List) event.object; + List inputIDs = (List) event.object; int request = (int) event.object2; - outstandingRequests.put(request, REQUEST_STATUS.FAILED); - long totalRequests = outstandingRequests.size(); - long remainingRequests = outstandingRequests.entrySet().stream() - .filter(t -> t.getValue() == REQUEST_STATUS.REQUESTED).count(); - textEmbeddingRequestIndicator.setTopLabelLater("Received " - + (totalRequests - remainingRequests) + " of " + totalRequests); - if (!outstandingRequests.containsValue(REQUEST_STATUS.REQUESTED)) { - outstandingRequests.clear(); - textEmbeddingRequestIndicator.spin(false); - textEmbeddingRequestIndicator.fadeBusy(true); + PendingTextLandmarkRequest landmarkFailure = pendingTextLandmarkRequests.remove(request); + if (landmarkFailure != null) { + recordTextLandmarkRefreshOutcome(landmarkFailure, LandmarkRefreshOutcome.FAILED); + return; + } + boolean managed = false; + for (Integer id : inputIDs) { + if (textOutstandingRequests.containsKey(id)) { + textOutstandingRequests.put(id, REQUEST_STATUS.FAILED); + managed = true; + } } + if (managed) updateTextEmbeddingsProgress(); + else outstandingRequests.put(request, REQUEST_STATUS.FAILED); }); scene.getRoot().addEventHandler(RestEvent.NEW_CHAT_COMPLETION, event -> { ChatCompletionsOutput output = (ChatCompletionsOutput) event.object; @@ -1217,75 +1266,69 @@ public HyperdrivePane(Scene scene, Pane parent) { outstandingRequests.put(request, REQUEST_STATUS.FAILED); }); scene.getRoot().addEventHandler(RestEvent.NEW_EMBEDDINGS_TEXT, event -> { - //Even though its a text embeddings event we reuse the same output data structure EmbeddingsImageOutput output = (EmbeddingsImageOutput) event.object; List inputIDs = (List) event.object2; - String msg = "Received " + output.getData().size() + " embeddings at " - + format.format(LocalDateTime.now()); - - int totalListItems = textEmbeddingsListView.getItems().size(); - for (int i = 0; i < output.getData().size(); i++) { - if (i <= totalListItems) { - int currentInputID = inputIDs.get(i); - if (currentInputID == EMBEDDINGS_TEXT_TESTID) { - notifyTerminalSuccess("Image Embeddings Test Successful", scene); - } else { - EmbeddingsImageData currentOutput = output.getData().get(i); - textEmbeddingsListView.getItems().stream() - .filter(li -> li.textID == currentInputID) - .forEach(item -> { - item.setEmbeddings(currentOutput.getEmbedding()); - item.addMetaData("object", currentOutput.getObject()); - item.addMetaData("type", currentOutput.getType()); - }); + for (EmbeddingsImageData data : output.getData()) { + int index = data.getIndex(); + if (index < 0 || index >= inputIDs.size()) { + LOG.warn("Ignoring invalid text embedding index {}", index); + continue; + } + int id = inputIDs.get(index); + if (id == EMBEDDINGS_TEXT_TESTID) { + notifyTerminalSuccess("Text Embeddings Test Successful", scene); + } else { + textEmbeddingsListView.getItems().stream().filter(item -> item.textID == id) + .forEach(item -> { + item.setEmbeddings(data.getEmbedding()); + item.addMetaData("object", data.getObject()); + item.addMetaData("type", data.getType()); + }); + if (textOutstandingRequests.containsKey(id)) { + textOutstandingRequests.put(id, REQUEST_STATUS.SUCCEEDED); } } } - - outstandingRequests.put(output.getRequestNumber(), REQUEST_STATUS.SUCCEEDED); - textEmbeddingRequestIndicator.setTopLabelLater(msg); - int totalRequests = outstandingRequests.size(); - long remainingRequests = outstandingRequests.entrySet().stream() - .filter(t -> t.getValue() == REQUEST_STATUS.REQUESTED).count(); - textEmbeddingRequestIndicator.setTopLabelLater("Received " - + (totalRequests - remainingRequests) + " of " + totalRequests); - if (!outstandingRequests.containsValue(REQUEST_STATUS.REQUESTED)) { - outstandingRequests.clear(); - textEmbeddingRequestIndicator.spin(false); - textEmbeddingRequestIndicator.fadeBusy(true); - } + updateTextEmbeddingsProgress(); }); scene.getRoot().addEventHandler(RestEvent.NEW_EMBEDDINGS_LANDMARKTEXT, event -> { - //Even though its a text embeddings event we reuse the same output data structure EmbeddingsImageOutput output = (EmbeddingsImageOutput) event.object; + if (output == null) { + LOG.warn("Text landmark response contained no output"); + return; + } + PendingTextLandmarkRequest pending = pendingTextLandmarkRequests.remove(output.getRequestNumber()); + if (pending == null) { + LOG.debug("Ignoring untracked text landmark response {}", output.getRequestNumber()); + return; + } List inputIDs = (List) event.object2; - String msg = "Received " + output.getData().size() + " embeddings at " - + format.format(LocalDateTime.now()); - - int totalListItems = landmarkTextBuilderBox.getItems().size(); - for (int i = 0; i < output.getData().size(); i++) { - if (i <= totalListItems) { - EmbeddingsImageData currentOutput = output.getData().get(i); - int currentInputID = inputIDs.get(i); - landmarkTextBuilderBox.getItems().stream() - .filter(li -> li.landmarkID == currentInputID) - .forEach(item -> { - item.setEmbeddings(currentOutput.getEmbedding()); - item.addMetaData("object", currentOutput.getObject()); - item.addMetaData("type", currentOutput.getType()); - }); - } + if (inputIDs == null || inputIDs.size() != 1 || inputIDs.get(0) != pending.id() + || output.getData() == null || output.getData().size() != 1 + || output.getData().get(0) == null || output.getData().get(0).getIndex() != 0) { + LOG.warn("Invalid text landmark response shape for request {}", output.getRequestNumber()); + recordTextLandmarkRefreshOutcome(pending, LandmarkRefreshOutcome.FAILED); + return; } - - outstandingRequests.put(output.getRequestNumber(), REQUEST_STATUS.SUCCEEDED); - textEmbeddingRequestIndicator.setTopLabelLater(msg); - //@DEBUG SMP - //System.out.println(msg); - outstandingRequests.remove(output.getRequestNumber()); - if (!outstandingRequests.containsValue(REQUEST_STATUS.REQUESTED)) { - textEmbeddingRequestIndicator.spin(false); - textEmbeddingRequestIndicator.fadeBusy(true); + EmbeddingsImageData data = output.getData().get(0); + double[] vector = toFiniteEmbedding(data.getEmbedding()); + if (!TextLandmarkSimilarityEngine.usable(vector)) { + LOG.warn("Invalid text landmark embedding for request {}", output.getRequestNumber()); + recordTextLandmarkRefreshOutcome(pending, LandmarkRefreshOutcome.FAILED); + return; + } + LandmarkTextListItem item = landmarkTextBuilderBox.getItems().stream() + .filter(li -> li.landmarkID == pending.id()).findFirst().orElse(null); + if (item == null || !item.acceptEmbeddingResponse(pending.revision(), pending.requestedText(), + pending.model(), currentEmbeddingsModel, data.getEmbedding())) { + LOG.debug("Discarding stale text landmark response {}", output.getRequestNumber()); + recordTextLandmarkRefreshOutcome(pending, LandmarkRefreshOutcome.STALE); + return; } + item.addMetaData("object", data.getObject()); + item.addMetaData("type", data.getType()); + if (pending.model() != null) item.addMetaData("embedding_model", pending.model()); + recordTextLandmarkRefreshOutcome(pending, LandmarkRefreshOutcome.SUCCESS); }); scene.getRoot().addEventHandler(ImageEvent.NEW_SCAN_IMAGE, event -> { Image scanImage = (Image) event.object; @@ -1334,7 +1377,7 @@ public HyperdrivePane(Scene scene, Pane parent) { ArrayList newImageFiles = (ArrayList) event.object2; textFilesList.addAll(newImageFiles); } - textFilesCountLabel.setText(String.valueOf(textFilesList.size())); + refreshTextFileCount(); if (!textEmbeddingsListView.getItems().isEmpty()) { textEmbeddingsListView.getSelectionModel().selectFirst(); @@ -1344,70 +1387,149 @@ public HyperdrivePane(Scene scene, Pane parent) { }); getStyleClass().add("hyperdrive-pane"); - // Initialize the batch launcher with current Scene and model imageBatchLauncher = new ImageEmbeddingsBatchLauncher(scene, currentEmbeddingsModel); - // Instantiate the manager + textBatchLauncher = new TextEmbeddingsBatchLauncher(currentEmbeddingsModel); imageEmbeddingManager = new BatchRequestManager<>( - maxInFlightBatches, // Maximum concurrent requests - requestTimeoutMS, // Timeout in ms (e.g., 60s) - 3, // Maximum retries per batch - batchNumber::getAndIncrement, // batchNumber supplier - requestNumber::getAndIncrement, // Unique request ID supplier - // Task Factory: launch a batch + maxInFlightBatches, requestTimeoutMS, 3, + batchNumber::getAndIncrement, requestNumber::getAndIncrement, (batch, batchNum, reqId) -> () -> { + int jobGeneration = imageJobGeneration.get(); Platform.runLater(() -> { - if (imageEmbeddingRequestIndicator != null) { - imageEmbeddingRequestIndicator.setFadeTimeMS(250); - imageEmbeddingRequestIndicator.spin(true); - if (!imageEmbeddingRequestIndicator.inView()) - imageEmbeddingRequestIndicator.fadeBusy(false); - imageEmbeddingRequestIndicator.setLabelLater( - "Encoding and sending Batch: " + batchNum - + " of " + imageEmbeddingManager.getTotalBatches() - ); - } - }); - imageBatchLauncher.launchBatch(batch, batchNum, reqId, (success, ex) -> { - if (success) { - imageEmbeddingManager.completeSuccess(reqId, batchNum, batch, 0); - } else { - imageEmbeddingManager.completeFailure(reqId, batchNum, batch, 0, ex); - } + imageEmbeddingRequestIndicator.setFadeTimeMS(250); + imageEmbeddingRequestIndicator.setLabelLater("Sending image batch " + batchNum); }); + imageBatchLauncher.launchBatch(batch, batchNum, reqId, + imageEmbeddingManager.getTimeoutMillis(), (output, ex) -> { + if (ex != null) { + imageEmbeddingManager.completeFailure(reqId, batchNum, batch, 0, ex); + } else { + imageEmbeddingManager.completeSuccess(reqId, batchNum, batch, 0, + () -> Platform.runLater(() -> { + if (jobGeneration == imageJobGeneration.get()) { + scene.getRoot().fireEvent(new RestEvent(RestEvent.NEW_EMBEDDINGS_IMAGE, + output, batch.stream().map(item -> item.imageID).toList())); + } + })); + } + }); }, - // onComplete: (success/failure/timeout) result -> { - //@DEBUG SMP - //System.out.println("Batch: " + result.getBatchNumber() - // + " Request: " + result.getRequestId() - // + " Status: " + result.getStatus() - // + " Attempt: " + result.getRetryCount() - // + " In Flight: " + imageEmbeddingManager.getInFlight() - //); - //System.out.println(" Duration: " + imageEmbeddingManager.getBatchDurationByID(result.getRequestId())/1000 + " s" - // + " Avg Duration: " + new DecimalFormat("#.000").format( - // imageEmbeddingManager.getAvgBatchDurationMillis()/1000) + " s" - // + " Total Duration: " + imageEmbeddingManager.getTotalBatchDurationMillis()/1000 + " s" - //); - + int jobGeneration = imageJobGeneration.get(); + if (result.status() != BatchRequestManager.BatchResult.Status.SUCCESS) { + Platform.runLater(() -> { + if (jobGeneration == imageJobGeneration.get()) { + scene.getRoot().fireEvent(new RestEvent(RestEvent.ERROR_EMBEDDINGS_IMAGE, + result.batch().stream().map(item -> item.imageID).toList(), result.requestId())); + } + }); + } + } + ); + textEmbeddingManager = new BatchRequestManager<>( + maxInFlightBatches, requestTimeoutMS, 3, + batchNumber::getAndIncrement, requestNumber::getAndIncrement, + (batch, batchNum, reqId) -> () -> { + int jobGeneration = textJobGeneration.get(); Platform.runLater(() -> { - // update progress indicator, show alerts on failure, etc. - long failed = outstandingRequests.values().stream().filter(s -> s == REQUEST_STATUS.FAILED).count(); - imageEmbeddingRequestIndicator.setLabelLater( - "Batches completed: " + imageEmbeddingManager.getBatchesCompleted() - + " of " + imageEmbeddingManager.getTotalBatches() - + (failed > 0 ? (" | Errors: " + failed) : "") - ); + textEmbeddingRequestIndicator.setFadeTimeMS(250); + textEmbeddingRequestIndicator.setLabelLater("Sending text batch " + batchNum); }); + textBatchLauncher.launchBatch(batch, batchNum, reqId, + textEmbeddingManager.getTimeoutMillis(), (output, ex) -> { + if (ex != null) { + textEmbeddingManager.completeFailure(reqId, batchNum, batch, 0, ex); + } else { + textEmbeddingManager.completeSuccess(reqId, batchNum, batch, 0, + () -> Platform.runLater(() -> { + if (jobGeneration == textJobGeneration.get()) { + scene.getRoot().fireEvent(new RestEvent(RestEvent.NEW_EMBEDDINGS_TEXT, + output, batch.stream().map(item -> item.textID).toList())); + } + })); + } + }); + }, + result -> { + int jobGeneration = textJobGeneration.get(); + if (result.status() != BatchRequestManager.BatchResult.Status.SUCCESS) { + Platform.runLater(() -> { + if (jobGeneration == textJobGeneration.get()) { + scene.getRoot().fireEvent(new RestEvent(RestEvent.ERROR_EMBEDDINGS_TEXT, + result.batch().stream().map(item -> item.textID).toList(), result.requestId())); + } + }); + } } ); + imageEmbeddingManager.setRequestDelayMillis(requestDelay); + textEmbeddingManager.setRequestDelayMillis(requestDelay); Runtime.getRuntime().addShutdownHook(new Thread(() -> { - if (this != null) { - imageEmbeddingManager.shutdown(); - } + imageEmbeddingManager.shutdown(); + textEmbeddingManager.shutdown(); })); } + /** Keep the visible file count consistent with the text chunks still in the list. */ + private void refreshTextFileCount() { + textFilesList.clear(); + textEmbeddingsListView.getItems().stream() + .map(EmbeddingsTextListItem::getFile) + .distinct().forEach(textFilesList::add); + textFilesCountLabel.setText(Integer.toString(textFilesList.size())); + } + + private void updateImageEmbeddingsProgress() { + if (imageOutstandingRequests.isEmpty()) return; + int total = imageOutstandingRequests.size(); + long complete = imageOutstandingRequests.values().stream() + .filter(status -> status != REQUEST_STATUS.REQUESTED).count(); + imageEmbeddingRequestIndicator.setPercentComplete(complete / (double) total); + imageEmbeddingRequestIndicator.setTopLabelLater("Received " + complete + " of " + total); + if (complete == total) { + imageEmbeddingRequestIndicator.spin(false); + imageEmbeddingRequestIndicator.fadeBusy(true); + imageOutstandingRequests.clear(); + } + } + + private void updateTextEmbeddingsProgress() { + if (textOutstandingRequests.isEmpty()) return; + int total = textOutstandingRequests.size(); + long complete = textOutstandingRequests.values().stream() + .filter(status -> status != REQUEST_STATUS.REQUESTED).count(); + textEmbeddingRequestIndicator.setPercentComplete(complete / (double) total); + textEmbeddingRequestIndicator.setTopLabelLater("Received " + complete + " of " + total); + if (complete == total) { + textEmbeddingRequestIndicator.spin(false); + textEmbeddingRequestIndicator.fadeBusy(true); + textOutstandingRequests.clear(); + } + } + + private void enqueueTextEmbeddings(List items) { + textPreparationInProgress = false; + if (items.isEmpty()) { + textEmbeddingRequestIndicator.setTopLabelLater("No nonempty text chunks to embed"); + textEmbeddingRequestIndicator.spin(false); + textEmbeddingRequestIndicator.fadeBusy(true); + notifyTerminalWarning("No nonempty text chunks were selected for embedding", scene); + return; + } + List> batches = new ArrayList<>(); + for (int i = 0; i < items.size(); i += batchSize) { + batches.add(new ArrayList<>(items.subList(i, Math.min(i + batchSize, items.size())))); + } + textJobGeneration.incrementAndGet(); + textOutstandingRequests.clear(); + for (EmbeddingsTextListItem item : items) { + textOutstandingRequests.put(item.textID, REQUEST_STATUS.REQUESTED); + } + textBatchLauncher.setModel(currentEmbeddingsModel); + if (!textEmbeddingManager.enqueue(batches)) { + LOG.warn("Text embedding job already in progress"); + } + } + public static void exportFeatureCollection(String title, FeatureCollection fc) { FileChooser saver = new FileChooser(); saver.setTitle(title); @@ -1459,15 +1581,179 @@ public void chooseCaptionsTask(List items, List t.start(); } + private void recordTextLandmarkRefreshOutcome(PendingTextLandmarkRequest pending, LandmarkRefreshOutcome outcome) { + TextLandmarkRefreshStats stats = textLandmarkRefreshRuns.get(pending.runId()); + if (stats == null) return; + stats.complete++; + switch (outcome) { + case SUCCESS -> stats.success++; + case STALE -> stats.stale++; + default -> stats.failed++; + } + finishTextLandmarkRefreshIfComplete(pending.runId()); + } + + private void finishTextLandmarkRefreshIfComplete(int runId) { + TextLandmarkRefreshStats stats = textLandmarkRefreshRuns.get(runId); + if (stats == null || stats.complete < stats.total) return; + textLandmarkRefreshRuns.remove(runId); + String message = "Text landmark refresh: " + stats.success + " updated, " + + stats.failed + " failed, " + stats.stale + " stale, " + stats.skipped + " skipped."; + LOG.info(message); + if (stats.failed > 0 && stats.success == 0) notifyTerminalError(message, scene); + else if (stats.failed > 0 || stats.stale > 0 || stats.skipped > 0) + notifyTerminalWarning(message, scene); + else notifyTerminalSuccess(message, scene); + } + + private void showTextSimilarityAlert(Alert.AlertType severity, String heading, String message) { + Alert alert = new Alert(severity); + alert.setTitle("Hyperdrive Text Landmark Similarity"); + alert.setHeaderText(heading); + alert.setContentText(message); + alert.initOwner(scene.getWindow()); + alert.showAndWait(); + } + + /** Convert a FeatureVector defensively without touching the JavaFX scene graph. */ + private static double[] toFiniteEmbedding(List values) { + if (values == null || values.isEmpty()) return null; + double[] vector = new double[values.size()]; + for (int i = 0; i < values.size(); i++) { + Double value = values.get(i); + if (value == null || !Double.isFinite(value)) return null; + vector[i] = value; + } + return vector; + } + + /** Only invoked on the FX thread: snapshot data before launching background computation. */ public void requestTextLandmarkSimilarityTask( - List items, List landmarkFeatures) { - Metric metric = Metric.getMetric(metricChoiceBox.getSelectionModel().getSelectedItem()); - RequestTextLandmarkSimilarityTask requestTask = - new RequestTextLandmarkSimilarityTask(scene, textEmbeddingRequestIndicator, - items, landmarkFeatures, metric); - Thread t = new Thread(requestTask, "Trinity Text Landmark Similarity Task"); - t.setDaemon(true); - t.start(); + List items, List landmarks) { + if (items == null || items.isEmpty()) { + showTextSimilarityAlert(Alert.AlertType.WARNING, "No Text Chunks Selected", + "Select one or more text chunks before computing landmark similarity."); + return; + } + if (landmarks == null || landmarks.isEmpty()) { + showTextSimilarityAlert(Alert.AlertType.WARNING, "No Landmarks Available", + "Add and embed at least one landmark before computing similarity."); + return; + } + + String metricName = metricChoiceBox.getSelectionModel().getSelectedItem(); + Metric metric; + try { + metric = Metric.getMetric(metricName); + } catch (IllegalArgumentException | NullPointerException ex) { + showTextSimilarityAlert(Alert.AlertType.ERROR, "Invalid Distance Metric", + "Select a supported similarity metric before classifying text chunks."); + return; + } + + List targets = new ArrayList<>(); + int invalidTargets = 0; + for (EmbeddingsTextListItem item : List.copyOf(items)) { + double[] values = toFiniteEmbedding(item.getFeatureVector().getData()); + if (!item.embeddingsReceived() || !TextLandmarkSimilarityEngine.usable(values)) { + invalidTargets++; + continue; + } + targets.add(new RequestTextLandmarkSimilarityTask.Target(item, + item.getFeatureVectorLabel(), List.copyOf(item.getFeatureVector().getData()), values)); + } + if (targets.isEmpty()) { + showTextSimilarityAlert(Alert.AlertType.WARNING, "No Valid Text Embeddings", + "All selected chunks have missing or invalid embeddings. Request embeddings first."); + return; + } + + List candidates = new ArrayList<>(); + int invalidLandmarks = 0; + for (LandmarkListItem item : List.copyOf(landmarks)) { + boolean fresh = !(item instanceof LandmarkTextListItem textLandmark) + || textLandmark.isEmbeddingFresh(currentEmbeddingsModel); + double[] values = toFiniteEmbedding(item.getFeatureVector().getData()); + if (!item.hasEmbeddings() || !fresh || !TextLandmarkSimilarityEngine.usable(values) + || item.getFeatureVectorLabel() == null || item.getFeatureVectorLabel().isBlank()) { + invalidLandmarks++; + continue; + } + candidates.add(new TextLandmarkSimilarityEngine.Landmark(item.getFeatureVectorLabel(), values)); + } + if (candidates.isEmpty()) { + showTextSimilarityAlert(Alert.AlertType.WARNING, "No Valid Landmarks", + "Every landmark is unembedded, stale, blank, or contains invalid values. Refresh embeddings."); + return; + } + + boolean hasCompatiblePairs = targets.stream().anyMatch(target -> candidates.stream() + .anyMatch(candidate -> TextLandmarkSimilarityEngine.compatible(target.values(), candidate.values()))); + if (!hasCompatiblePairs) { + showTextSimilarityAlert(Alert.AlertType.ERROR, "Embedding Dimensions Incompatible", + "None of the selected text chunks and valid landmarks have matching embedding dimensions."); + return; + } + Set targetDimensions = new HashSet<>(); + targets.forEach(target -> targetDimensions.add(target.values().length)); + int unmatchedLandmarks = (int) candidates.stream() + .filter(candidate -> !targetDimensions.contains(candidate.values().length)).count(); + invalidLandmarks += unmatchedLandmarks; + int excludedLandmarks = invalidLandmarks; + int excludedTargets = invalidTargets; + + if (activeTextSimilarityTask != null) activeTextSimilarityTask.cancel(); + RequestTextLandmarkSimilarityTask task = new RequestTextLandmarkSimilarityTask( + scene, textEmbeddingRequestIndicator, targets, candidates, metric); + activeTextSimilarityTask = task; + textEmbeddingRequestIndicator.setFadeTimeMS(250); + textEmbeddingRequestIndicator.setLabelLater("Computing text landmark similarity..."); + textEmbeddingRequestIndicator.spin(true); + textEmbeddingRequestIndicator.fadeBusy(false); + task.setOnSucceeded(e -> { + if (activeTextSimilarityTask != task) return; + int applied = 0; + int changedDuringOperation = 0; + for (RequestTextLandmarkSimilarityTask.Classification classification : task.getClassifications()) { + var target = classification.target(); + if (!textEmbeddingsListView.getItems().contains(target.item()) + || !Objects.equals(target.item().getFeatureVectorLabel(), target.originalLabel()) + || !Objects.equals(target.item().getFeatureVector().getData(), target.originalData())) { + changedDuringOperation++; + continue; + } + target.item().setFeatureVectorLabel(classification.landmarkLabel()); + applied++; + } + int skipped = excludedTargets + task.getSkipped() + changedDuringOperation; + String message = "Text landmark similarity: " + applied + " classified, " + + skipped + " skipped, " + excludedLandmarks + " landmarks excluded."; + LOG.info(message); + if (skipped > 0 || excludedLandmarks > 0 || applied == 0) notifyTerminalWarning(message, scene); + else notifyTerminalSuccess(message, scene); + finishTextSimilarityTask(task); + }); + task.setOnFailed(e -> { + if (activeTextSimilarityTask != task) return; + LOG.error("Text landmark similarity computation failed", task.getException()); + notifyTerminalError("Text landmark similarity failed; existing labels were preserved.", scene); + finishTextSimilarityTask(task); + }); + task.setOnCancelled(e -> { + if (activeTextSimilarityTask != task) return; + notifyTerminalWarning("Text landmark similarity cancelled; existing labels were preserved.", scene); + finishTextSimilarityTask(task); + }); + Thread thread = new Thread(task, "Trinity Text Landmark Similarity Task"); + thread.setDaemon(true); + thread.start(); + } + + private void finishTextSimilarityTask(RequestTextLandmarkSimilarityTask task) { + if (activeTextSimilarityTask != task) return; + activeTextSimilarityTask = null; + textEmbeddingRequestIndicator.spin(false); + textEmbeddingRequestIndicator.fadeBusy(true); } public void requestLandmarkSimilarityTask( @@ -1491,19 +1777,52 @@ public void requestCaptionsTask(List items) { } public void loadTextTask(List files) { - currentTextFeatureList.clear(); - textFilesList.clear(); + if (files == null || files.isEmpty()) return; LoadTextTask loadTask = new LoadTextTask(scene, textEmbeddingRequestIndicator, files); + loadTask.setOnFailed(event -> { + LOG.error("Unable to import text files", loadTask.getException()); + textEmbeddingRequestIndicator.spin(false); + textEmbeddingRequestIndicator.fadeBusy(true); + notifyTerminalWarning("Text import failed: " + loadTask.getException().getMessage(), scene); + }); Thread t = new Thread(loadTask, "Trinity Batch Text File Load Task"); t.setDaemon(true); t.start(); } public void requestTextEmbeddingsTask() { - RequestTextEmbeddingsTask requestTask = new RequestTextEmbeddingsTask( - scene, textEmbeddingRequestIndicator, requestNumber, currentEmbeddingsModel, - outstandingRequests, textEmbeddingsListView.getSelectionModel().getSelectedItems()); - Thread t = new Thread(requestTask, "Trinity Embeddings Text Request"); + if (textPreparationInProgress || textEmbeddingManager.isBusy()) { + LOG.warn("Text embedding job already in progress"); + return; + } + List items = new ArrayList<>( + textEmbeddingsListView.getSelectionModel().getSelectedItems()); + if (items.isEmpty()) return; + textPreparationInProgress = true; + int generation = textPreparationGeneration.incrementAndGet(); + textEmbeddingRequestIndicator.setPercentComplete(0); + textEmbeddingRequestIndicator.setTopLabelLater("Preparing " + items.size() + " text chunks"); + textEmbeddingRequestIndicator.spin(true); + if (!textEmbeddingRequestIndicator.inView()) textEmbeddingRequestIndicator.fadeBusy(false); + RequestTextEmbeddingsTask requestTask = new RequestTextEmbeddingsTask(scene, items, ready -> { + if (generation == textPreparationGeneration.get()) enqueueTextEmbeddings(ready); + }); + requestTask.setOnFailed(event -> { + if (generation == textPreparationGeneration.get()) { + textPreparationInProgress = false; + textEmbeddingRequestIndicator.spin(false); + textEmbeddingRequestIndicator.fadeBusy(true); + LOG.error("Failed preparing text embedding inputs", requestTask.getException()); + } + }); + requestTask.setOnCancelled(event -> { + if (generation == textPreparationGeneration.get()) { + textPreparationInProgress = false; + textEmbeddingRequestIndicator.spin(false); + textEmbeddingRequestIndicator.fadeBusy(true); + } + }); + Thread t = new Thread(requestTask, "Trinity Embeddings Text Preparation"); t.setDaemon(true); t.start(); } diff --git a/src/main/java/edu/jhuapl/trinity/messages/EmbeddingsTextCallback.java b/src/main/java/edu/jhuapl/trinity/messages/EmbeddingsTextCallback.java index 29321190..79ddc629 100644 --- a/src/main/java/edu/jhuapl/trinity/messages/EmbeddingsTextCallback.java +++ b/src/main/java/edu/jhuapl/trinity/messages/EmbeddingsTextCallback.java @@ -28,8 +28,8 @@ public EmbeddingsTextCallback(Scene scene, List inputIDs, int requestNu public void onFailure() { // LOG.error(e.getMessage()); ErrorEvent error = new ErrorEvent(ErrorEvent.REST_ERROR, getClass().getName() + " has failed."); - scene.getRoot().fireEvent(error); Platform.runLater(() -> { + scene.getRoot().fireEvent(error); scene.getRoot().fireEvent(new RestEvent(RestEvent.ERROR_EMBEDDINGS_TEXT, inputIDs, requestNumber)); }); } @@ -44,7 +44,8 @@ protected void processResponse(String responseBodyString) { scene.getRoot().fireEvent(new RestEvent(RestEvent.NEW_EMBEDDINGS_TEXT, output, inputIDs)); }); } catch (JsonProcessingException ex) { - LOG.error(null, ex); + LOG.error("Invalid text embedding response for request {}", requestNumber, ex); + onFailure(); } } } diff --git a/src/main/java/edu/jhuapl/trinity/messages/EmbeddingsTextLandmarkCallback.java b/src/main/java/edu/jhuapl/trinity/messages/EmbeddingsTextLandmarkCallback.java index 224ea327..5b507d37 100644 --- a/src/main/java/edu/jhuapl/trinity/messages/EmbeddingsTextLandmarkCallback.java +++ b/src/main/java/edu/jhuapl/trinity/messages/EmbeddingsTextLandmarkCallback.java @@ -2,7 +2,6 @@ import com.fasterxml.jackson.core.JsonProcessingException; import edu.jhuapl.trinity.data.messages.llm.EmbeddingsImageOutput; -import edu.jhuapl.trinity.javafx.events.ErrorEvent; import edu.jhuapl.trinity.javafx.events.RestEvent; import javafx.application.Platform; import javafx.scene.Scene; @@ -26,12 +25,9 @@ public EmbeddingsTextLandmarkCallback(Scene scene, List inputIDs, int r @Override public void onFailure() { -// LOG.error(e.getMessage()); - ErrorEvent error = new ErrorEvent(ErrorEvent.REST_ERROR, getClass().getName() + " has failed."); - scene.getRoot().fireEvent(error); - Platform.runLater(() -> { - scene.getRoot().fireEvent(new RestEvent(RestEvent.ERROR_EMBEDDINGS_TEXT, inputIDs, requestNumber)); - }); + LOG.warn("Text landmark embedding request {} failed", requestNumber); + Platform.runLater(() -> scene.getRoot().fireEvent( + new RestEvent(RestEvent.ERROR_EMBEDDINGS_TEXT, inputIDs, requestNumber))); } @Override @@ -43,8 +39,9 @@ protected void processResponse(String responseBodyString) { Platform.runLater(() -> { scene.getRoot().fireEvent(new RestEvent(RestEvent.NEW_EMBEDDINGS_LANDMARKTEXT, output, inputIDs)); }); - } catch (JsonProcessingException ex) { - LOG.error(null, ex); + } catch (JsonProcessingException | IllegalArgumentException ex) { + LOG.error("Invalid landmark embedding response for request {}", requestNumber, ex); + onFailure(); } } } diff --git a/src/main/java/edu/jhuapl/trinity/messages/RestAccessLayer.java b/src/main/java/edu/jhuapl/trinity/messages/RestAccessLayer.java index 56848c27..900c98ec 100644 --- a/src/main/java/edu/jhuapl/trinity/messages/RestAccessLayer.java +++ b/src/main/java/edu/jhuapl/trinity/messages/RestAccessLayer.java @@ -8,6 +8,8 @@ import edu.jhuapl.trinity.data.messages.llm.ChatCompletionsInput; import edu.jhuapl.trinity.data.messages.llm.EmbeddingsImageBatchInput; import edu.jhuapl.trinity.data.messages.llm.EmbeddingsImageInput; +import edu.jhuapl.trinity.data.messages.llm.EmbeddingsImageOutput; +import edu.jhuapl.trinity.data.messages.llm.EmbeddingsTextBatchInput; import edu.jhuapl.trinity.data.messages.llm.RestAccessLayerConfig; import edu.jhuapl.trinity.javafx.events.CommandTerminalEvent; import javafx.application.Platform; @@ -28,6 +30,10 @@ import java.nio.file.Files; import java.time.Duration; import java.util.List; +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.CompletionException; +import java.util.concurrent.TimeUnit; +import java.util.function.BiConsumer; import java.util.function.Function; /** @@ -104,6 +110,60 @@ public static RestAccessLayerConfig loadDefaultRestConfig() throws IOException { return restAccessLayerconfig = config; } + /** + * Managed batch request: returns one validated-deserializable response or an + * exception to the caller, with no UI events emitted by the REST layer. + * Callers decide whether the request is still active before applying results. + */ + private static void sendManagedEmbeddings(Object input, long timeoutMillis, + BiConsumer completion) { + if (!checkRestServiceInitialized()) { + completion.accept(null, new IllegalStateException("REST service is not initialized")); + return; + } + try { + String payload = objectMapper.writeValueAsString(input); + HttpRequest request = makeHttpPostRequest(payload, + restAccessLayerconfig.getImageEmbeddingsEndpoint(), timeoutMillis); + httpClient.sendAsync(request, BodyHandlers.ofString()) + .whenComplete((response, failure) -> { + if (failure != null) { + completion.accept(null, toException(failure)); + } else if (response.statusCode() != SUCCESS_STATUS_CODE) { + completion.accept(null, new IOException("Embedding HTTP " + response.statusCode() + + ": " + response.body())); + } else { + EmbeddingsImageOutput output; + try { + output = objectMapper.readValue(response.body(), EmbeddingsImageOutput.class); + } catch (Exception ex) { + completion.accept(null, ex); + return; + } + completion.accept(output, null); + } + }); + } catch (Exception ex) { + completion.accept(null, ex); + } + } + + private static Exception toException(Throwable error) { + Throwable cause = error instanceof CompletionException && error.getCause() != null + ? error.getCause() : error; + return cause instanceof Exception ex ? ex : new IOException(cause); + } + + public static void requestManagedTextEmbeddings(EmbeddingsTextBatchInput input, long timeoutMillis, + BiConsumer completion) { + sendManagedEmbeddings(input, timeoutMillis, completion); + } + + public static void requestManagedImageEmbeddings(EmbeddingsImageBatchInput input, long timeoutMillis, + BiConsumer completion) { + sendManagedEmbeddings(input, timeoutMillis, completion); + } + //Text and Image REST calls public static void requestQueryTextEmbeddings(EmbeddingsImageInput input, Scene scene, List inputIDs, int requestNumber) throws JsonProcessingException { @@ -115,10 +175,14 @@ public static void requestQueryTextEmbeddings(EmbeddingsImageInput input, httpClient.sendAsync(request, BodyHandlers.ofString()) .thenAcceptAsync(resp -> { if (resp.statusCode() != SUCCESS_STATUS_CODE) { - new EmbeddingsTextQueryCallback(scene, inputIDs, requestNumber).onFailure(); + Platform.runLater(() -> new EmbeddingsTextQueryCallback(scene, inputIDs, requestNumber).onFailure()); } else { new EmbeddingsTextQueryCallback(scene, inputIDs, requestNumber).processResponse(resp.body()); } + }).exceptionally(ex -> { + LOG.error("Asynchronous REST request failed", ex); + Platform.runLater(() -> new EmbeddingsTextQueryCallback(scene, inputIDs, requestNumber).onFailure()); + return null; }); } @@ -130,25 +194,36 @@ public static void requestTextEmbeddings(EmbeddingsImageInput input, httpClient.sendAsync(request, BodyHandlers.ofString()) .thenAcceptAsync(resp -> { if (resp.statusCode() != SUCCESS_STATUS_CODE) { - new EmbeddingsTextCallback(scene, inputIDs, requestNumber).onFailure(); + Platform.runLater(() -> new EmbeddingsTextCallback(scene, inputIDs, requestNumber).onFailure()); } else { new EmbeddingsTextCallback(scene, inputIDs, requestNumber).processResponse(resp.body()); } + }).exceptionally(ex -> { + LOG.error("Asynchronous REST request failed", ex); + Platform.runLater(() -> new EmbeddingsTextCallback(scene, inputIDs, requestNumber).onFailure()); + return null; }); } public static void requestLandmarkTextEmbeddings(EmbeddingsImageInput input, Scene scene, List inputIDs, int requestNumber) throws JsonProcessingException { - if (restServiceFailed(scene)) return; + if (restServiceFailed(scene)) { + new EmbeddingsTextLandmarkCallback(scene, inputIDs, requestNumber).onFailure(); + return; + } String inputJSON = objectMapper.writeValueAsString(input); HttpRequest request = makeHttpPostRequest(inputJSON, restAccessLayerconfig.getImageEmbeddingsEndpoint()); httpClient.sendAsync(request, BodyHandlers.ofString()) .thenAcceptAsync(resp -> { if (resp.statusCode() != SUCCESS_STATUS_CODE) { - new EmbeddingsTextLandmarkCallback(scene, inputIDs, requestNumber).onFailure(); + Platform.runLater(() -> new EmbeddingsTextLandmarkCallback(scene, inputIDs, requestNumber).onFailure()); } else { new EmbeddingsTextLandmarkCallback(scene, inputIDs, requestNumber).processResponse(resp.body()); } + }).exceptionally(ex -> { + LOG.error("Asynchronous REST request failed", ex); + Platform.runLater(() -> new EmbeddingsTextLandmarkCallback(scene, inputIDs, requestNumber).onFailure()); + return null; }); } @@ -160,10 +235,14 @@ public static void requestImageEmbeddings(EmbeddingsImageBatchInput input, httpClient.sendAsync(request, BodyHandlers.ofString()) .thenAcceptAsync(resp -> { if (resp.statusCode() != SUCCESS_STATUS_CODE) { - new EmbeddingsImageCallback(scene, inputIDs, requestNumber).onFailure(); + Platform.runLater(() -> new EmbeddingsImageCallback(scene, inputIDs, requestNumber).onFailure()); } else { new EmbeddingsImageCallback(scene, inputIDs, requestNumber).processResponse(resp.body()); } + }).exceptionally(ex -> { + LOG.error("Asynchronous REST request failed", ex); + Platform.runLater(() -> new EmbeddingsImageCallback(scene, inputIDs, requestNumber).onFailure()); + return null; }); } @@ -175,10 +254,14 @@ public static void requestLandmarkImageEmbeddings(EmbeddingsImageBatchInput inpu httpClient.sendAsync(request, BodyHandlers.ofString()) .thenAcceptAsync(resp -> { if (resp.statusCode() != SUCCESS_STATUS_CODE) { - new EmbeddingsImageLandmarkCallback(scene, inputIDs, requestNumber).onFailure(); + Platform.runLater(() -> new EmbeddingsImageLandmarkCallback(scene, inputIDs, requestNumber).onFailure()); } else { new EmbeddingsImageLandmarkCallback(scene, inputIDs, requestNumber).processResponse(resp.body()); } + }).exceptionally(ex -> { + LOG.error("Asynchronous REST request failed", ex); + Platform.runLater(() -> new EmbeddingsImageLandmarkCallback(scene, inputIDs, requestNumber).onFailure()); + return null; }); } @@ -189,10 +272,14 @@ public static void requestChatCompletion(ChatCompletionsInput input, Scene scene httpClient.sendAsync(request, BodyHandlers.ofString()) .thenAcceptAsync(resp -> { if (resp.statusCode() != SUCCESS_STATUS_CODE) { - new ChatCompletionCallback(scene, inputIDs, requestNumber).onFailure(); + Platform.runLater(() -> new ChatCompletionCallback(scene, inputIDs, requestNumber).onFailure()); } else { new ChatCompletionCallback(scene, inputIDs, requestNumber).processResponse(resp.body()); } + }).exceptionally(ex -> { + LOG.error("Asynchronous REST request failed", ex); + Platform.runLater(() -> new ChatCompletionCallback(scene, inputIDs, requestNumber).onFailure()); + return null; }); } @@ -229,9 +316,13 @@ public static void requestRestIsAlive(Scene scene) { } private static HttpRequest makeHttpPostRequest(String payload, String endPoint) { + return makeHttpPostRequest(payload, endPoint, TimeUnit.SECONDS.toMillis(DEFAULT_TIMEOUT_SECONDS)); + } + + private static HttpRequest makeHttpPostRequest(String payload, String endPoint, long timeoutMillis) { return HttpRequest.newBuilder() .uri(URI.create(restAccessLayerconfig.getBaseRestURL() + endPoint)) - .timeout(Duration.ofSeconds(DEFAULT_TIMEOUT_SECONDS)) + .timeout(Duration.ofMillis(Math.max(1, timeoutMillis))) .header(CONTENT_TYPE, JSON) .POST(BodyPublishers.ofString(payload)) .build(); @@ -257,7 +348,8 @@ private static void notifyTerminalError(String message, Scene scene) { } private static boolean checkRestServiceInitialized() { - return null != restAccessLayerconfig || null != restAccessLayerconfig.getBaseRestURL(); + return null != restAccessLayerconfig && null != restAccessLayerconfig.getBaseRestURL() + && null != restAccessLayerconfig.getImageEmbeddingsEndpoint(); } public static Function stringToChatCaptionResponse = rawText -> { diff --git a/src/test/java/edu/jhuapl/trinity/javafx/components/hyperdrive/BatchRequestManagerTest.java b/src/test/java/edu/jhuapl/trinity/javafx/components/hyperdrive/BatchRequestManagerTest.java new file mode 100644 index 00000000..5fee39b4 --- /dev/null +++ b/src/test/java/edu/jhuapl/trinity/javafx/components/hyperdrive/BatchRequestManagerTest.java @@ -0,0 +1,83 @@ +package edu.jhuapl.trinity.javafx.components.hyperdrive; + +import org.junit.jupiter.api.Test; +import java.io.IOException; +import java.util.ArrayList; +import java.util.Collections; +import java.util.List; +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.Executors; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicInteger; +import java.util.concurrent.atomic.AtomicReference; + +import static org.junit.jupiter.api.Assertions.*; + +class BatchRequestManagerTest { + @Test + void limitsConcurrentRequestsAndSpacesLaunches() throws Exception { + var ids = new AtomicInteger(); + var active = new AtomicInteger(); + var peak = new AtomicInteger(); + var starts = Collections.synchronizedList(new ArrayList()); + var done = new CountDownLatch(5); + var timer = Executors.newScheduledThreadPool(2); + @SuppressWarnings("unchecked") + BatchRequestManager>[] managers = new BatchRequestManager[1]; + try { + managers[0] = new BatchRequestManager<>(2, 1000, 0, ids::incrementAndGet, + ids::incrementAndGet, (batch, number, id) -> () -> { + peak.accumulateAndGet(active.incrementAndGet(), Math::max); + starts.add(System.nanoTime()); + timer.schedule(() -> { + active.decrementAndGet(); + managers[0].completeSuccess(id, number, batch, 0); + }, 75, TimeUnit.MILLISECONDS); + }, result -> done.countDown()); + managers[0].setRequestDelayMillis(35); + List> batches = new ArrayList<>(); + for (int i = 0; i < 23; i += 5) { + List batch = new ArrayList<>(); + for (int j = i; j < Math.min(i + 5, 23); j++) batch.add(j); + batches.add(batch); + } + assertTrue(managers[0].enqueue(batches)); + assertTrue(done.await(5, TimeUnit.SECONDS)); + assertEquals(5, starts.size()); + assertTrue(peak.get() <= 2); + for (int i = 1; i < starts.size(); i++) { + assertTrue(TimeUnit.NANOSECONDS.toMillis(starts.get(i) - starts.get(i - 1)) >= 20); + } + } finally { + if (managers[0] != null) managers[0].shutdown(); + timer.shutdownNow(); + } + } + + @Test + void retriesHaveNewIdsAndLateCompletionIsIgnored() throws Exception { + var ids = new AtomicInteger(); + var attempts = Collections.synchronizedList(new ArrayList()); + var done = new CountDownLatch(1); + var status = new AtomicReference(); + @SuppressWarnings("unchecked") + BatchRequestManager[] managers = new BatchRequestManager[1]; + try { + managers[0] = new BatchRequestManager<>(1, 55, 1, ids::incrementAndGet, + ids::incrementAndGet, (batch, number, id) -> () -> { + attempts.add(id); + if (attempts.size() == 2) managers[0].completeSuccess(id, number, batch, 0); + }, result -> { status.set(result.status().name()); done.countDown(); }); + assertTrue(managers[0].enqueue(List.of("chunk"))); + assertTrue(done.await(2, TimeUnit.SECONDS)); + assertEquals(2, attempts.size()); + assertNotEquals(attempts.get(0), attempts.get(1)); + managers[0].completeFailure(attempts.get(0), 1, "chunk", 0, new IOException("late")); + Thread.sleep(40); + assertEquals(1, managers[0].getBatchesCompleted()); + assertEquals("SUCCESS", status.get()); + } finally { + if (managers[0] != null) managers[0].shutdown(); + } + } +} diff --git a/src/test/java/edu/jhuapl/trinity/javafx/components/hyperdrive/EmbeddingBatchResponseValidatorTest.java b/src/test/java/edu/jhuapl/trinity/javafx/components/hyperdrive/EmbeddingBatchResponseValidatorTest.java new file mode 100644 index 00000000..b79392a4 --- /dev/null +++ b/src/test/java/edu/jhuapl/trinity/javafx/components/hyperdrive/EmbeddingBatchResponseValidatorTest.java @@ -0,0 +1,41 @@ +package edu.jhuapl.trinity.javafx.components.hyperdrive; + +import edu.jhuapl.trinity.data.messages.llm.EmbeddingsImageData; +import edu.jhuapl.trinity.data.messages.llm.EmbeddingsImageOutput; +import edu.jhuapl.trinity.data.messages.llm.EmbeddingsTextBatchInput; +import org.junit.jupiter.api.Test; +import java.util.List; +import static org.junit.jupiter.api.Assertions.*; + +class EmbeddingBatchResponseValidatorTest { + private static EmbeddingsImageData item(int index) { + var data = new EmbeddingsImageData(); + data.setIndex(index); + data.setEmbedding(List.of(0.1, 0.2)); + return data; + } + + @Test + void acceptsReorderedDataAndRejectsMissingDuplicateOrOutOfRangeIndexes() { + var response = new EmbeddingsImageOutput(); + response.setData(List.of(item(2), item(0), item(1))); + assertDoesNotThrow(() -> EmbeddingBatchResponseValidator.validate(response, 3)); + assertThrows(IllegalArgumentException.class, + () -> EmbeddingBatchResponseValidator.validate(response, 4)); + response.setData(List.of(item(0), item(0), item(2))); + assertThrows(IllegalArgumentException.class, + () -> EmbeddingBatchResponseValidator.validate(response, 3)); + response.setData(List.of(item(0), item(1), item(5))); + assertThrows(IllegalArgumentException.class, + () -> EmbeddingBatchResponseValidator.validate(response, 3)); + } + + @Test + void supportsLegacySingleStringAndMultiStringInputs() { + var input = new EmbeddingsTextBatchInput(); + input.setInput("one"); + assertInstanceOf(String.class, input.getInput()); + input.setInput(List.of("one", "two")); + assertInstanceOf(List.class, input.getInput()); + } +} diff --git a/src/test/java/edu/jhuapl/trinity/javafx/components/hyperdrive/LandmarkEmbeddingStateTest.java b/src/test/java/edu/jhuapl/trinity/javafx/components/hyperdrive/LandmarkEmbeddingStateTest.java new file mode 100644 index 00000000..184632d8 --- /dev/null +++ b/src/test/java/edu/jhuapl/trinity/javafx/components/hyperdrive/LandmarkEmbeddingStateTest.java @@ -0,0 +1,36 @@ +package edu.jhuapl.trinity.javafx.components.hyperdrive; + +import org.junit.jupiter.api.Test; + +import static org.junit.jupiter.api.Assertions.*; + +class LandmarkEmbeddingStateTest { + @Test + void acceptsOnlyTheMatchingRequestVersionAndModel() { + LandmarkEmbeddingState state = new LandmarkEmbeddingState(); + long version = state.beginRequest(); + assertTrue(state.canAccept(version, "intrusion", "model-a", "intrusion", "model-a")); + assertFalse(state.canAccept(version, "intrusion", "model-a", "intrusion", "model-b")); + state.accepted("intrusion", "model-a"); + assertTrue(state.isFresh("intrusion", "model-a")); + assertFalse(state.isFresh("intrusion", "model-b")); + } + + @Test + void editingInvalidatesEvenWhenRequestIsStillInFlight() { + LandmarkEmbeddingState state = new LandmarkEmbeddingState(); + long version = state.beginRequest(); + state.textChanged(); + assertFalse(state.canAccept(version, "old", "m", "new", "m")); + assertFalse(state.isFresh("new", "m")); + } + + @Test + void newestRefreshWinsWhenResponsesArriveOutOfOrder() { + LandmarkEmbeddingState state = new LandmarkEmbeddingState(); + long first = state.beginRequest(); + long second = state.beginRequest(); + assertFalse(state.canAccept(first, "X", "m", "X", "m")); + assertTrue(state.canAccept(second, "X", "m", "X", "m")); + } +} diff --git a/src/test/java/edu/jhuapl/trinity/javafx/components/hyperdrive/TextChunkParserTest.java b/src/test/java/edu/jhuapl/trinity/javafx/components/hyperdrive/TextChunkParserTest.java new file mode 100644 index 00000000..077dd7b1 --- /dev/null +++ b/src/test/java/edu/jhuapl/trinity/javafx/components/hyperdrive/TextChunkParserTest.java @@ -0,0 +1,102 @@ +package edu.jhuapl.trinity.javafx.components.hyperdrive; + +import org.apache.pdfbox.pdmodel.PDDocument; +import org.apache.pdfbox.pdmodel.PDPage; +import org.apache.pdfbox.pdmodel.PDPageContentStream; +import org.apache.pdfbox.pdmodel.font.Standard14Fonts; +import org.apache.pdfbox.pdmodel.font.PDType1Font; +import org.junit.jupiter.api.Test; + +import java.io.File; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.List; + +import static org.junit.jupiter.api.Assertions.*; + +class TextChunkParserTest { + private static final File TEXT = new File("sample.txt"); + private static final File JSON = new File("sample.json"); + private static final File CSV = new File("sample.csv"); + + private static TextChunkParser.Options plain(int size) { + return new TextChunkParser.Options(false, false, false, 0, false, size); + } + + @Test + void exactMultipleHasNoEmptyChunkAndKeepsProvenance() throws Exception { + var chunks = TextChunkParser.parseText(TEXT, "abcdefgh", plain(4)); + assertEquals(List.of("abcd", "efgh"), chunks.stream().map(TextChunkParser.ParsedChunk::text).toList()); + assertEquals("0", chunks.get(0).metadata().get("source_chunk_index")); + assertEquals("2", chunks.get(0).metadata().get("source_chunk_count")); + } + + @Test + void noBlankChunksAreEmitted() throws Exception { + assertTrue(TextChunkParser.parseText(TEXT, " \t ", plain(4)).isEmpty()); + assertEquals(2, TextChunkParser.parseText(TEXT, "a\r\n\r\nb\n", new TextChunkParser.Options(false, false, false, 0, true, 20)).size()); + } + + @Test + void jsonObjectTextMatchesEmbeddingSource() throws Exception { + var chunks = TextChunkParser.parseText(JSON, + "{\"name\":\"doc 1\",\"text\":\"actual content\",\"topic\":\"network\"}", + new TextChunkParser.Options(true, false, false, 0, false, 20)); + assertEquals(1, chunks.size()); + assertEquals("actual content", chunks.get(0).text()); + assertEquals("doc 1", chunks.get(0).label()); + assertEquals("\"network\"", chunks.get(0).metadata().get("topic")); + } + + @Test + void jsonArrayCreatesIndexedObjects() throws Exception { + var chunks = TextChunkParser.parseText(JSON, "[{\"text\":\"first\"},{\"text\":\"second\"}]", + new TextChunkParser.Options(true, false, false, 0, false, 20)); + assertEquals(List.of("first", "second"), chunks.stream().map(TextChunkParser.ParsedChunk::text).toList()); + assertEquals("1", chunks.get(1).metadata().get("source_json_index")); + } + + @Test + void csvHandlesQuotedCommasMultilineFieldsAndEscapedQuotes() throws Exception { + String content = "id,description,category\r\n" + + "1,\"high, unusual traffic\",warning\r\n" + + "2,\"first line\nsecond \"\"quoted\"\" line\",info\r\n"; + var chunks = TextChunkParser.parseText(CSV, content, + new TextChunkParser.Options(false, true, true, 0, false, 16)); + assertEquals(2, chunks.size()); + assertEquals("1", chunks.get(0).label()); + assertTrue(chunks.get(0).text().contains("description = high, unusual traffic")); + assertTrue(chunks.get(1).text().contains("first line\nsecond \"quoted\" line")); + assertEquals("3", chunks.get(1).metadata().get("source_csv_record")); + } + + @Test + void malformedCsvFailsExplicitly() { + assertThrows(java.io.IOException.class, + () -> TextChunkParser.parseCsvRecords("heading\n\"unclosed")); + } + + @Test + void smallPdfIsExtractedInsteadOfReadAsBinary() throws Exception { + Path file = Files.createTempFile("trinity-hyperdrive-pdf-", ".pdf"); + try { + try (PDDocument document = new PDDocument()) { + PDPage page = new PDPage(); + document.addPage(page); + try (PDPageContentStream stream = new PDPageContentStream(document, page)) { + stream.beginText(); + stream.setFont(new PDType1Font(Standard14Fonts.FontName.HELVETICA), 12); + stream.newLineAtOffset(40, 700); + stream.showText("PDF text preserved"); + stream.endText(); + } + document.save(file.toFile()); + } + var chunks = TextChunkParser.parse(file.toFile(), plain(2048)); + assertEquals(1, chunks.size()); + assertTrue(chunks.get(0).text().contains("PDF text preserved")); + } finally { + Files.deleteIfExists(file); + } + } +} diff --git a/src/test/java/edu/jhuapl/trinity/javafx/components/hyperdrive/TextLandmarkSimilarityEngineTest.java b/src/test/java/edu/jhuapl/trinity/javafx/components/hyperdrive/TextLandmarkSimilarityEngineTest.java new file mode 100644 index 00000000..07e2eca1 --- /dev/null +++ b/src/test/java/edu/jhuapl/trinity/javafx/components/hyperdrive/TextLandmarkSimilarityEngineTest.java @@ -0,0 +1,57 @@ +package edu.jhuapl.trinity.javafx.components.hyperdrive; + +import edu.jhuapl.trinity.utils.metric.Metric; +import org.junit.jupiter.api.Test; + +import java.util.List; + +import static org.junit.jupiter.api.Assertions.*; + +class TextLandmarkSimilarityEngineTest { + private final Metric euclidean = Metric.getMetric("euclidean"); + + @Test + void findsNearestLandmarkWithoutMutatingCandidates() { + var candidates = List.of( + new TextLandmarkSimilarityEngine.Landmark("A", new double[]{0, 0}), + new TextLandmarkSimilarityEngine.Landmark("B", new double[]{10, 0})); + var match = TextLandmarkSimilarityEngine.nearest(new double[]{9, 0}, candidates, euclidean); + assertEquals("B", match.orElseThrow().label()); + assertArrayEquals(new double[]{0, 0}, candidates.get(0).values()); + } + + @Test + void noLandmarksAndMismatchedDimensionsAreSafe() { + assertTrue(TextLandmarkSimilarityEngine.nearest(new double[]{1, 0}, List.of(), euclidean).isEmpty()); + assertTrue(TextLandmarkSimilarityEngine.nearest(new double[]{1, 0}, + List.of(new TextLandmarkSimilarityEngine.Landmark("bad", new double[]{1, 2, 3})), euclidean).isEmpty()); + } + + @Test + void excludesInvalidVectorsAndDistances() { + var landmarks = List.of( + new TextLandmarkSimilarityEngine.Landmark("invalid", new double[]{Double.NaN, 1}), + new TextLandmarkSimilarityEngine.Landmark("valid", new double[]{1, 0})); + assertEquals("valid", TextLandmarkSimilarityEngine.nearest(new double[]{0.9, 0}, landmarks, euclidean) + .orElseThrow().label()); + assertFalse(TextLandmarkSimilarityEngine.usable(new double[]{Double.POSITIVE_INFINITY})); + assertFalse(TextLandmarkSimilarityEngine.usable(new double[]{})); + assertFalse(TextLandmarkSimilarityEngine.usable(null)); + } + + @Test + void zeroVectorsAreNotClassifiedWithCosine() { + Metric cosine = Metric.getMetric("cosine"); + var landmarks = List.of(new TextLandmarkSimilarityEngine.Landmark("zero", new double[]{0, 0})); + assertTrue(TextLandmarkSimilarityEngine.nearest(new double[]{0, 0}, landmarks, cosine).isEmpty()); + assertTrue(TextLandmarkSimilarityEngine.nearest(new double[]{1, 0}, landmarks, cosine).isEmpty()); + } + + @Test + void rejectsUnsupportedMetricInputsWithoutAssigningArbitraryLabels() { + Metric haversine = Metric.getMetric("haversine"); + assertTrue(TextLandmarkSimilarityEngine.nearest(new double[]{1, 1, 1}, + List.of(new TextLandmarkSimilarityEngine.Landmark("three-dimensional", new double[]{1, 1, 1})), + haversine).isEmpty()); + } +} From d5641d4f8102c10b21e61eee1022770f5f59915b Mon Sep 17 00:00:00 2001 From: Birdasaur Date: Wed, 28 Oct 2026 16:50:37 -0400 Subject: [PATCH 2/3] FIrst cut at JSON Schema extractor and Topic Finder for Hyperdrive --- .../edu/jhuapl/trinity/AppAsyncManager.java | 46 +++ .../messages/llm/ChatCompletionsOutput.java | 27 ++ .../llm/ChatGenerationParameters.java | 6 +- .../trinity/data/messages/llm/ChatUsage.java | 42 ++ .../messages/llm/ResponseChatMessage.java | 14 + .../trinity/data/messages/llm/Stop.java | 36 ++ .../hyperdrive/JsonExtractionProfile.java | 141 +++++++ .../hyperdrive/JsonSchemaMappingPane.java | 228 +++++++++++ .../components/hyperdrive/LoadTextTask.java | 24 +- .../components/hyperdrive/MappingRow.java | 28 ++ .../hyperdrive/TextChunkParser.java | 47 ++- .../hyperdrive/TopicDiscoveryEngine.java | 224 +++++++++++ .../hyperdrive/TopicDiscoveryPane.java | 187 +++++++++ .../hyperdrive/TopicDiscoveryReviewPane.java | 194 +++++++++ .../listviews/LandmarkTextBuilderBox.java | 23 +- .../listviews/LandmarkTextListItem.java | 38 +- .../components/panes/HyperdrivePane.java | 368 ++++++++++++++++-- .../javafx/components/panes/LitPathPane.java | 17 +- .../javafx/events/HyperdriveEvent.java | 11 + .../messages/ChatCompletionCallback.java | 20 +- .../messages/EmbeddingsImageCallback.java | 5 +- .../trinity/messages/RestAccessLayer.java | 8 + .../hyperdrive/JsonExtractionProfileTest.java | 80 ++++ .../hyperdrive/TopicDiscoveryEngineTest.java | 66 ++++ .../hyperdrive/TopicKeywordContrastTest.java | 27 ++ 25 files changed, 1844 insertions(+), 63 deletions(-) create mode 100644 src/main/java/edu/jhuapl/trinity/data/messages/llm/ChatUsage.java create mode 100644 src/main/java/edu/jhuapl/trinity/data/messages/llm/Stop.java create mode 100644 src/main/java/edu/jhuapl/trinity/javafx/components/hyperdrive/JsonExtractionProfile.java create mode 100644 src/main/java/edu/jhuapl/trinity/javafx/components/hyperdrive/JsonSchemaMappingPane.java create mode 100644 src/main/java/edu/jhuapl/trinity/javafx/components/hyperdrive/MappingRow.java create mode 100644 src/main/java/edu/jhuapl/trinity/javafx/components/hyperdrive/TopicDiscoveryEngine.java create mode 100644 src/main/java/edu/jhuapl/trinity/javafx/components/hyperdrive/TopicDiscoveryPane.java create mode 100644 src/main/java/edu/jhuapl/trinity/javafx/components/hyperdrive/TopicDiscoveryReviewPane.java create mode 100644 src/test/java/edu/jhuapl/trinity/javafx/components/hyperdrive/JsonExtractionProfileTest.java create mode 100644 src/test/java/edu/jhuapl/trinity/javafx/components/hyperdrive/TopicDiscoveryEngineTest.java create mode 100644 src/test/java/edu/jhuapl/trinity/javafx/components/hyperdrive/TopicKeywordContrastTest.java diff --git a/src/main/java/edu/jhuapl/trinity/AppAsyncManager.java b/src/main/java/edu/jhuapl/trinity/AppAsyncManager.java index 7c91be11..bdad0f80 100644 --- a/src/main/java/edu/jhuapl/trinity/AppAsyncManager.java +++ b/src/main/java/edu/jhuapl/trinity/AppAsyncManager.java @@ -10,6 +10,10 @@ import edu.jhuapl.trinity.data.messages.xai.FeatureCollection; import edu.jhuapl.trinity.data.messages.xai.FeatureVector; import edu.jhuapl.trinity.javafx.components.MatrixOverlay; +import edu.jhuapl.trinity.javafx.components.hyperdrive.JsonExtractionProfile; +import edu.jhuapl.trinity.javafx.components.hyperdrive.JsonSchemaMappingPane; +import edu.jhuapl.trinity.javafx.components.hyperdrive.TopicDiscoveryEngine; +import edu.jhuapl.trinity.javafx.components.hyperdrive.TopicDiscoveryReviewPane; import edu.jhuapl.trinity.javafx.components.panes.AnalysisLogPane; import edu.jhuapl.trinity.javafx.components.panes.CocoViewerPane; import edu.jhuapl.trinity.javafx.components.panes.FeatureVectorManagerPane; @@ -43,6 +47,7 @@ import edu.jhuapl.trinity.javafx.events.FeatureVectorEvent; import edu.jhuapl.trinity.javafx.events.GaussianMixtureEvent; import edu.jhuapl.trinity.javafx.events.HitEvent; +import edu.jhuapl.trinity.javafx.events.HyperdriveEvent; import edu.jhuapl.trinity.javafx.events.ManifoldEvent; import edu.jhuapl.trinity.javafx.events.MissionTimerXEvent; import edu.jhuapl.trinity.javafx.events.RestEvent; @@ -143,6 +148,8 @@ public class AppAsyncManager extends Task { PixelSelectionPane pixelSelectionPane; ImageInspectorPane imageInspectorPane; HyperdrivePane hyperdrivePane; + JsonSchemaMappingPane jsonSchemaMappingPane; + TopicDiscoveryReviewPane topicDiscoveryReviewPane; MatrixOverlay matrixOverlay; RetroWavePane retroWavePane = null; @@ -775,6 +782,45 @@ protected Void call() throws Exception { projectorPane.scanAndAnimate(); } }); + // Hyperdrive editor overlays are independent LitPathPanes on the shared desktop. + // Hyperdrive and each overlay communicate only through HyperdriveEvent. + scene.addEventHandler(HyperdriveEvent.OPEN_JSON_SCHEMA_MAPPING, event -> { + JsonExtractionProfile profile = event.object1 instanceof JsonExtractionProfile p ? p.copy() : null; + if (jsonSchemaMappingPane != null && desktopPane.getChildren().contains(jsonSchemaMappingPane)) { + jsonSchemaMappingPane.toFront(); + jsonSchemaMappingPane.show(); + return; + } + jsonSchemaMappingPane = new JsonSchemaMappingPane(scene, desktopPane, profile); + desktopPane.getChildren().add(jsonSchemaMappingPane); + jsonSchemaMappingPane.setLayoutX(Math.max(20, (desktopPane.getWidth() - 1120) / 2)); + jsonSchemaMappingPane.setLayoutY(Math.max(20, (desktopPane.getHeight() - 710) / 2)); + jsonSchemaMappingPane.toFront(); + jsonSchemaMappingPane.slideInPane(); + }); + scene.addEventHandler(HyperdriveEvent.OPEN_TOPIC_REVIEW, event -> { + if (!(event.object1 instanceof java.util.List list) + || !list.stream().allMatch(TopicDiscoveryEngine.Topic.class::isInstance)) return; + java.util.List topics = new java.util.ArrayList<>(); + for (Object candidate : list) topics.add((TopicDiscoveryEngine.Topic) candidate); + if (topicDiscoveryReviewPane != null && desktopPane.getChildren().contains(topicDiscoveryReviewPane)) { + topicDiscoveryReviewPane.toFront(); + topicDiscoveryReviewPane.show(); + return; + } + topicDiscoveryReviewPane = new TopicDiscoveryReviewPane(scene, desktopPane, topics); + desktopPane.getChildren().add(topicDiscoveryReviewPane); + topicDiscoveryReviewPane.setLayoutX(Math.max(20, (desktopPane.getWidth() - 1000) / 2)); + topicDiscoveryReviewPane.setLayoutY(Math.max(20, (desktopPane.getHeight() - 690) / 2)); + topicDiscoveryReviewPane.toFront(); + topicDiscoveryReviewPane.slideInPane(); + }); + scene.addEventHandler(HyperdriveEvent.CLOSE_TOPIC_REVIEW, event -> { + if (topicDiscoveryReviewPane != null) { + topicDiscoveryReviewPane.close(); + topicDiscoveryReviewPane = null; + } + }); scene.addEventHandler(ApplicationEvent.SHOW_HYPERDRIVE_PANE, e -> { if (!desktopPane.getChildren().contains(hyperdrivePane)) { desktopPane.getChildren().add(hyperdrivePane); diff --git a/src/main/java/edu/jhuapl/trinity/data/messages/llm/ChatCompletionsOutput.java b/src/main/java/edu/jhuapl/trinity/data/messages/llm/ChatCompletionsOutput.java index 8d10cd8f..6ced1844 100644 --- a/src/main/java/edu/jhuapl/trinity/data/messages/llm/ChatCompletionsOutput.java +++ b/src/main/java/edu/jhuapl/trinity/data/messages/llm/ChatCompletionsOutput.java @@ -56,11 +56,14 @@ public class ChatCompletionsOutput extends MessageData { // // + private String id; + private Long created; private String object; private String model; private String prompt; private List choices; private ChatGenerationParameters generation_parameters; + private ChatUsage usage; // //extra helper fields @@ -74,6 +77,30 @@ public ChatCompletionsOutput() { // + public String getId() { + return id; + } + + public void setId(String id) { + this.id = id; + } + + public Long getCreated() { + return created; + } + + public void setCreated(Long created) { + this.created = created; + } + + public ChatUsage getUsage() { + return usage; + } + + public void setUsage(ChatUsage usage) { + this.usage = usage; + } + /** * @return the object */ diff --git a/src/main/java/edu/jhuapl/trinity/data/messages/llm/ChatGenerationParameters.java b/src/main/java/edu/jhuapl/trinity/data/messages/llm/ChatGenerationParameters.java index 52edebc7..d08f526f 100644 --- a/src/main/java/edu/jhuapl/trinity/data/messages/llm/ChatGenerationParameters.java +++ b/src/main/java/edu/jhuapl/trinity/data/messages/llm/ChatGenerationParameters.java @@ -31,7 +31,7 @@ public class ChatGenerationParameters { */ private List bad_words; - private String stop; + private Stop stop; private Integer output_length; private Integer top_k; private Integer top_p; @@ -65,14 +65,14 @@ public void setBad_words(List bad_words) { /** * @return the stop */ - public String getStop() { + public Stop getStop() { return stop; } /** * @param stop the stop to set */ - public void setStop(String stop) { + public void setStop(Stop stop) { this.stop = stop; } diff --git a/src/main/java/edu/jhuapl/trinity/data/messages/llm/ChatUsage.java b/src/main/java/edu/jhuapl/trinity/data/messages/llm/ChatUsage.java new file mode 100644 index 00000000..5d0f4c24 --- /dev/null +++ b/src/main/java/edu/jhuapl/trinity/data/messages/llm/ChatUsage.java @@ -0,0 +1,42 @@ +package edu.jhuapl.trinity.data.messages.llm; + +import com.fasterxml.jackson.annotation.JsonIgnoreProperties; +import com.fasterxml.jackson.annotation.JsonInclude; + +/** + * Token counts provided by a chat completion response. + */ +@JsonInclude(JsonInclude.Include.NON_NULL) +@JsonIgnoreProperties(ignoreUnknown = true) +public class ChatUsage { + private Integer completion_tokens; + private Integer prompt_tokens; + private Integer total_tokens; + + public ChatUsage() { + } + + public Integer getCompletion_tokens() { + return completion_tokens; + } + + public void setCompletion_tokens(Integer completion_tokens) { + this.completion_tokens = completion_tokens; + } + + public Integer getPrompt_tokens() { + return prompt_tokens; + } + + public void setPrompt_tokens(Integer prompt_tokens) { + this.prompt_tokens = prompt_tokens; + } + + public Integer getTotal_tokens() { + return total_tokens; + } + + public void setTotal_tokens(Integer total_tokens) { + this.total_tokens = total_tokens; + } +} diff --git a/src/main/java/edu/jhuapl/trinity/data/messages/llm/ResponseChatMessage.java b/src/main/java/edu/jhuapl/trinity/data/messages/llm/ResponseChatMessage.java index d1a5cb13..63aa507c 100644 --- a/src/main/java/edu/jhuapl/trinity/data/messages/llm/ResponseChatMessage.java +++ b/src/main/java/edu/jhuapl/trinity/data/messages/llm/ResponseChatMessage.java @@ -1,16 +1,30 @@ package edu.jhuapl.trinity.data.messages.llm; +import com.fasterxml.jackson.annotation.JsonIgnoreProperties; +import com.fasterxml.jackson.annotation.JsonInclude; + import java.util.ArrayList; import java.util.List; /** * @author Sean Phillips */ +@JsonInclude(JsonInclude.Include.NON_NULL) +@JsonIgnoreProperties(ignoreUnknown = true) public class ResponseChatMessage { private String role; private String content; + private String reasoning_content; private List tool_calls; + public String getReasoning_content() { + return reasoning_content; + } + + public void setReasoning_content(String reasoning_content) { + this.reasoning_content = reasoning_content; + } + public ResponseChatMessage() { tool_calls = new ArrayList<>(); } diff --git a/src/main/java/edu/jhuapl/trinity/data/messages/llm/Stop.java b/src/main/java/edu/jhuapl/trinity/data/messages/llm/Stop.java new file mode 100644 index 00000000..e22e7f64 --- /dev/null +++ b/src/main/java/edu/jhuapl/trinity/data/messages/llm/Stop.java @@ -0,0 +1,36 @@ +package edu.jhuapl.trinity.data.messages.llm; + +import com.fasterxml.jackson.annotation.JsonCreator; +import com.fasterxml.jackson.annotation.JsonValue; + +import java.util.ArrayList; +import java.util.List; + +/** + * Stop sequences returned in chat generation parameters. + * + * The observed response shape is a nested array, such as [[""]], + * rather than a JSON object. This value object preserves that shape + * with standard Jackson binding annotations. + */ +public class Stop { + private List> sequences; + + public Stop() { + this.sequences = new ArrayList<>(); + } + + @JsonCreator(mode = JsonCreator.Mode.DELEGATING) + public Stop(List> sequences) { + this.sequences = sequences; + } + + @JsonValue + public List> getSequences() { + return sequences; + } + + public void setSequences(List> sequences) { + this.sequences = sequences; + } +} diff --git a/src/main/java/edu/jhuapl/trinity/javafx/components/hyperdrive/JsonExtractionProfile.java b/src/main/java/edu/jhuapl/trinity/javafx/components/hyperdrive/JsonExtractionProfile.java new file mode 100644 index 00000000..b054465d --- /dev/null +++ b/src/main/java/edu/jhuapl/trinity/javafx/components/hyperdrive/JsonExtractionProfile.java @@ -0,0 +1,141 @@ +package edu.jhuapl.trinity.javafx.components.hyperdrive; + +import com.fasterxml.jackson.annotation.JsonIgnoreProperties; +import com.fasterxml.jackson.databind.JsonNode; +import java.util.ArrayList; +import java.util.LinkedHashMap; +import java.util.LinkedHashSet; +import java.util.List; +import java.util.Map; +import java.util.Set; + +/** Reusable, Jackson-serializable mapping from JSON paths to embedding input and provenance. */ +@JsonIgnoreProperties(ignoreUnknown = true) +public class JsonExtractionProfile { + private String name = "JSON Mapping"; + private List fields = new ArrayList<>(); + + public JsonExtractionProfile() { } + public String getName() { return name; } + public void setName(String name) { this.name = name == null ? "JSON Mapping" : name; } + public List getFields() { return fields; } + public void setFields(List fields) { + this.fields = fields == null ? new ArrayList<>() : new ArrayList<>(fields); + } + + @JsonIgnoreProperties(ignoreUnknown = true) + public static class FieldMapping { + private String path; + private boolean embedding; + private boolean label; + private boolean metadata; + + public FieldMapping() { } + public FieldMapping(String path, boolean embedding, boolean label, boolean metadata) { + this.path = path; + this.embedding = embedding; + this.label = label; + this.metadata = metadata; + } + public String getPath() { return path; } + public void setPath(String path) { this.path = path; } + public boolean isEmbedding() { return embedding; } + public void setEmbedding(boolean embedding) { this.embedding = embedding; } + public boolean isLabel() { return label; } + public void setLabel(boolean label) { this.label = label; } + public boolean isMetadata() { return metadata; } + public void setMetadata(boolean metadata) { this.metadata = metadata; } + } + + /** Take a stable snapshot before processing a background import. */ + public JsonExtractionProfile copy() { + JsonExtractionProfile snapshot = new JsonExtractionProfile(); + snapshot.setName(name); + for (FieldMapping field : fields) { + snapshot.fields.add(new FieldMapping(field.path, field.embedding, field.label, field.metadata)); + } + return snapshot; + } + + public boolean hasEmbeddingFields() { + return fields.stream().anyMatch(f -> f.embedding && f.path != null && !f.path.isBlank()); + } + + public record Extracted(String text, String label, Map metadata) { } + + /** The preview and importer share this identical extraction implementation. */ + public Extracted extract(JsonNode record, String fallbackLabel) { + StringBuilder text = new StringBuilder(); + String labelValue = fallbackLabel; + Map metadata = new LinkedHashMap<>(); + for (FieldMapping mapping : fields) { + if (mapping.path == null || mapping.path.isBlank()) continue; + List values = valuesAt(record, mapping.path); + if (values.isEmpty()) continue; + if (mapping.embedding) { + String display = displayName(mapping.path); + for (String value : values) { + if (!value.isBlank()) text.append(display).append(": ").append(value).append('\n'); + } + } + if (mapping.label && !values.get(0).isBlank()) labelValue = values.get(0); + if (mapping.metadata) metadata.put(mapping.path, String.join("; ", values)); + } + return new Extracted(text.toString().strip(), labelValue, metadata); + } + + private static String displayName(String path) { + String leaf = path.substring(path.lastIndexOf('.') + 1).replace("[]", ""); + return leaf.replace('_', ' ').replaceAll("([a-z])([A-Z])", "$1 $2"); + } + + public static List valuesAt(JsonNode node, String path) { + List output = new ArrayList<>(); + if (node == null || path == null || path.isBlank()) return output; + visitValues(node, path.split("\\."), 0, output); + return output; + } + + private static void visitValues(JsonNode node, String[] components, int i, List output) { + if (node == null || node.isNull()) return; + if (i == components.length) { + if (node.isArray()) { + for (JsonNode part : node) visitValues(part, components, i, output); + } else if (node.isValueNode()) { + String value = node.asText(); + if (!value.isBlank()) output.add(value); + } + return; + } + String part = components[i]; + if (part.endsWith("[]")) { + JsonNode array = node.get(part.substring(0, part.length() - 2)); + if (array != null && array.isArray()) for (JsonNode element : array) visitValues(element, components, i + 1, output); + } else if (node.isArray()) { + for (JsonNode element : node) visitValues(element, components, i, output); + } else { + visitValues(node.get(part), components, i + 1, output); + } + } + + /** Inspect a bounded sample of records and discover scalar field paths, including arrays. */ + public static List discoverPaths(JsonNode root) { + Set paths = new LinkedHashSet<>(); + if (root == null) return List.of(); + if (root.isArray()) { + for (int i = 0; i < Math.min(40, root.size()) && paths.size() < 300; i++) { + traverse(root.get(i), "", 0, paths); + } + } else traverse(root, "", 0, paths); + return List.copyOf(paths); + } + + private static void traverse(JsonNode node, String path, int depth, Set paths) { + if (node == null || node.isNull() || depth > 16 || paths.size() >= 300) return; + if (node.isObject()) { + node.properties().forEach(p -> traverse(p.getValue(), path.isEmpty() ? p.getKey() : path + "." + p.getKey(), depth + 1, paths)); + } else if (node.isArray()) { + for (int i = 0; i < Math.min(20, node.size()); i++) traverse(node.get(i), path + "[]", depth + 1, paths); + } else if (!path.isBlank()) paths.add(path); + } +} diff --git a/src/main/java/edu/jhuapl/trinity/javafx/components/hyperdrive/JsonSchemaMappingPane.java b/src/main/java/edu/jhuapl/trinity/javafx/components/hyperdrive/JsonSchemaMappingPane.java new file mode 100644 index 00000000..1dfdf4ab --- /dev/null +++ b/src/main/java/edu/jhuapl/trinity/javafx/components/hyperdrive/JsonSchemaMappingPane.java @@ -0,0 +1,228 @@ +package edu.jhuapl.trinity.javafx.components.hyperdrive; + +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import edu.jhuapl.trinity.css.StyleResourceProvider; +import edu.jhuapl.trinity.javafx.components.panes.LitPathPane; +import edu.jhuapl.trinity.javafx.events.HyperdriveEvent; +import java.io.File; +import java.nio.file.Files; +import java.util.LinkedHashMap; +import java.util.Map; +import javafx.geometry.Insets; +import javafx.geometry.Pos; +import javafx.scene.Scene; +import javafx.stage.FileChooser; +import javafx.scene.control.Alert; +import javafx.scene.control.Button; +import javafx.scene.control.ButtonType; +import javafx.scene.control.Label; +import javafx.scene.control.ScrollPane; +import javafx.scene.control.Spinner; +import javafx.scene.control.SpinnerValueFactory; +import javafx.scene.control.TextArea; +import javafx.scene.control.TextField; +import javafx.scene.layout.BorderPane; +import javafx.scene.layout.Pane; +import javafx.scene.control.ToggleGroup; +import javafx.scene.layout.HBox; +import javafx.scene.layout.Priority; +import javafx.scene.layout.VBox; + +/** Floating schema-aware JSON extraction editor. Applies changes via HyperdriveEvent. */ +public final class JsonSchemaMappingPane extends LitPathPane { + private static final ObjectMapper MAPPER = new ObjectMapper(); + private static final int MAX_SAMPLE_BYTES = 16 * 1024 * 1024; + private final Scene editorScene; + private final TextField profileName = new TextField("JSON Mapping"); + private final Label sampleLabel = new Label("Load a representative JSON file to detect fields."); + private final VBox fieldsBox = new VBox(5); + private final ToggleGroup labelGroup = new ToggleGroup(); + private final Map rows = new LinkedHashMap<>(); + private final TextArea preview = new TextArea(); + private final Spinner recordIndex = new Spinner<>(0, 0, 0); + private JsonNode sampleRoot; + + public JsonSchemaMappingPane(Scene scene, Pane parent, JsonExtractionProfile current) { + super(scene, parent, 1120, 710, new BorderPane(), + "Hyperdrive", "JSON Schema Mapping", 300.0, 400.0); + this.editorScene = scene; + getStylesheets().add(StyleResourceProvider.getResource("styles.css").toExternalForm()); + getStylesheets().add(StyleResourceProvider.getResource("covalent.css").toExternalForm()); + Button sampleButton = new Button("Load Sample JSON..."); + sampleButton.setOnAction(e -> loadSample()); + Button loadButton = new Button("Load Profile..."); + loadButton.setOnAction(e -> loadProfile()); + Button saveButton = new Button("Save Profile..."); + saveButton.setOnAction(e -> saveProfile()); + Button previewButton = new Button("Preview Extracted Text"); + previewButton.setOnAction(e -> refreshPreview()); + + HBox actions = new HBox(10, sampleButton, loadButton, saveButton); + HBox header = new HBox(12, new Label("Profile Name"), profileName); + HBox.setHgrow(profileName, Priority.ALWAYS); + VBox top = new VBox(10, header, actions, sampleLabel); + + HBox columnHead = new HBox(14); + Label pathHead = new Label("JSON field path"); + pathHead.setMinWidth(340); + pathHead.setMaxWidth(340); + columnHead.getChildren().addAll(pathHead, new Label("Embed"), new Label("Label"), new Label("Metadata")); + fieldsBox.setPadding(new Insets(6)); + ScrollPane fieldScroll = new ScrollPane(fieldsBox); + fieldScroll.setFitToWidth(true); + VBox left = new VBox(7, columnHead, fieldScroll); + VBox.setVgrow(fieldScroll, Priority.ALWAYS); + left.setPrefWidth(620); + + recordIndex.setEditable(true); + recordIndex.setPrefWidth(95); + preview.setEditable(false); + preview.setWrapText(true); + preview.setPrefRowCount(17); + VBox right = new VBox(9, new Label("Sample record index (zero-based)"), recordIndex, previewButton, + new Label("Exact embedding input / label / metadata preview"), preview); + right.setPrefWidth(420); + VBox.setVgrow(preview, Priority.ALWAYS); + HBox body = new HBox(14, left, right); + HBox.setHgrow(left, Priority.ALWAYS); + HBox.setHgrow(right, Priority.ALWAYS); + VBox layout = new VBox(12, top, body); + VBox.setVgrow(body, Priority.ALWAYS); + layout.setPadding(new Insets(12)); + Button applyButton = new Button("Apply Mapping"); + applyButton.setOnAction(e -> { + JsonExtractionProfile profile = profileFromControls(); + if (!profile.hasEmbeddingFields()) { + showError("Select at least one field in the Embed column."); + return; + } + editorScene.getRoot().fireEvent(new HyperdriveEvent( + HyperdriveEvent.JSON_SCHEMA_MAPPING_APPLIED, profile.copy())); + close(); + }); + Button closeButton = new Button("Close"); + closeButton.setOnAction(e -> close()); + HBox footer = new HBox(12, applyButton, closeButton); + footer.setAlignment(Pos.CENTER_RIGHT); + layout.getChildren().add(footer); + ((BorderPane) contentPane).setCenter(layout); + + if (current != null) applyProfile(current); + recordIndex.valueProperty().addListener((obs, oldValue, value) -> refreshPreview()); + refreshPreview(); + } + + private void addPath(String path) { + if (rows.containsKey(path)) return; + MappingRow row = new MappingRow(path, labelGroup); + rows.put(path, row); + fieldsBox.getChildren().add(row.node); + } + + private JsonExtractionProfile profileFromControls() { + JsonExtractionProfile profile = new JsonExtractionProfile(); + profile.setName(profileName.getText().isBlank() ? "JSON Mapping" : profileName.getText().strip()); + for (Map.Entry entry : rows.entrySet()) { + MappingRow row = entry.getValue(); + if (row.embed.isSelected() || row.label.isSelected() || row.metadata.isSelected()) { + profile.getFields().add(new JsonExtractionProfile.FieldMapping(entry.getKey(), + row.embed.isSelected(), row.label.isSelected(), row.metadata.isSelected())); + } + } + return profile; + } + + private void applyProfile(JsonExtractionProfile profile) { + profileName.setText(profile.getName()); + for (MappingRow row : rows.values()) { + row.embed.setSelected(false); + row.label.setSelected(false); + row.metadata.setSelected(false); + } + if (profile.getFields() != null) for (var mapping : profile.getFields()) { + if (mapping.getPath() == null || mapping.getPath().isBlank()) continue; + addPath(mapping.getPath()); + MappingRow row = rows.get(mapping.getPath()); + row.embed.setSelected(mapping.isEmbedding()); + row.label.setSelected(mapping.isLabel()); + row.metadata.setSelected(mapping.isMetadata()); + } + refreshPreview(); + } + + private void loadSample() { + FileChooser chooser = new FileChooser(); + chooser.setTitle("Select Representative JSON Document"); + chooser.getExtensionFilters().add(new FileChooser.ExtensionFilter("JSON Files", "*.json", "*.txt")); + File file = chooser.showOpenDialog(editorScene.getWindow()); + if (file == null) return; + try { + if (Files.size(file.toPath()) > MAX_SAMPLE_BYTES) { + showError("The preview sample must be smaller than 16 MB. Select a smaller representative file."); + return; + } + JsonNode node = MAPPER.readTree(file); + if (node == null || (!node.isArray() && !node.isObject())) { + showError("The sample must contain a JSON object or an array of objects."); + return; + } + sampleRoot = node; + for (String path : JsonExtractionProfile.discoverPaths(node)) addPath(path); + int max = node.isArray() ? Math.max(0, node.size() - 1) : 0; + recordIndex.setValueFactory(new SpinnerValueFactory.IntegerSpinnerValueFactory(0, max, 0)); + sampleLabel.setText(file.getName() + " — " + rows.size() + " detected scalar field paths"); + refreshPreview(); + } catch (Exception ex) { + showError("Unable to inspect JSON sample: " + ex.getMessage()); + } + } + + private void loadProfile() { + FileChooser chooser = new FileChooser(); + chooser.setTitle("Load JSON Extraction Profile"); + chooser.getExtensionFilters().add(new FileChooser.ExtensionFilter("JSON Profiles", "*.json")); + File file = chooser.showOpenDialog(editorScene.getWindow()); + if (file == null) return; + try { + applyProfile(MAPPER.readValue(file, JsonExtractionProfile.class)); + } catch (Exception ex) { showError("Unable to load profile: " + ex.getMessage()); } + } + + private void saveProfile() { + JsonExtractionProfile profile = profileFromControls(); + if (!profile.hasEmbeddingFields()) { showError("Choose at least one embedding field before saving."); return; } + FileChooser chooser = new FileChooser(); + chooser.setTitle("Save JSON Extraction Profile"); + chooser.setInitialFileName("hyperdrive-json-mapping.json"); + chooser.getExtensionFilters().add(new FileChooser.ExtensionFilter("JSON Profiles", "*.json")); + File file = chooser.showSaveDialog(editorScene.getWindow()); + if (file == null) return; + try { MAPPER.writerWithDefaultPrettyPrinter().writeValue(file, profile); } + catch (Exception ex) { showError("Unable to save profile: " + ex.getMessage()); } + } + + private void refreshPreview() { + if (sampleRoot == null) { + preview.setText("Load sample JSON to preview the exact text submitted for embeddings."); + return; + } + JsonNode record = sampleRoot.isArray() ? sampleRoot.path(recordIndex.getValue()) : sampleRoot; + JsonExtractionProfile.Extracted result = profileFromControls().extract(record, "Unlabeled JSON Record"); + StringBuilder content = new StringBuilder("EMBEDDING INPUT\n\n"); + content.append(result.text().isBlank() ? "(No selected field has usable content)" : result.text()); + content.append("\n\nLABEL\n").append(result.label()); + content.append("\n\nMETADATA\n"); + if (result.metadata().isEmpty()) content.append("(none)"); + else result.metadata().forEach((k, v) -> content.append(k).append(" = ").append(v).append('\n')); + preview.setText(content.toString()); + } + + private void showError(String message) { + Alert alert = new Alert(Alert.AlertType.WARNING, message, ButtonType.OK); + alert.setTitle("JSON Schema Mapping"); + alert.setHeaderText("Mapping configuration"); + alert.getDialogPane().getStylesheets().add(StyleResourceProvider.getResource("dialogstyles.css").toExternalForm()); + alert.showAndWait(); + } +} diff --git a/src/main/java/edu/jhuapl/trinity/javafx/components/hyperdrive/LoadTextTask.java b/src/main/java/edu/jhuapl/trinity/javafx/components/hyperdrive/LoadTextTask.java index a6766d24..1c5e4e46 100644 --- a/src/main/java/edu/jhuapl/trinity/javafx/components/hyperdrive/LoadTextTask.java +++ b/src/main/java/edu/jhuapl/trinity/javafx/components/hyperdrive/LoadTextTask.java @@ -27,10 +27,17 @@ public class LoadTextTask extends HyperdriveTask { private final TextChunkParser.Options options; public LoadTextTask(Scene scene, CircleProgressIndicator progressIndicator, List files) { + this(scene, progressIndicator, files, EmbeddingsTextListItem.parsingOptions()); + } + + public LoadTextTask(Scene scene, CircleProgressIndicator progressIndicator, List files, + TextChunkParser.Options options) { super(scene, progressIndicator, new AtomicInteger(), null); this.files = List.copyOf(files); - // Consistent processing options for every file in this import job. - this.options = EmbeddingsTextListItem.parsingOptions(); + // Capture the profile as a snapshot, avoiding changes during a running import. + this.options = new TextChunkParser.Options(options.jsonProcessing(), options.csvExpansion(), + options.labelFromCsvColumn(), options.csvLabelColumn(), options.breakOnNewlines(), + options.chunkSize(), options.jsonProfile() == null ? null : options.jsonProfile().copy()); } private record ParsedFile(File file, List chunks) { } @@ -67,6 +74,7 @@ protected void processTask() throws Exception { int total = discovered.size(); int failed = 0; int empty = 0; + int skippedMappedRecords = 0; List parsed = new ArrayList<>(); for (int i = 0; i < total; i++) { if (isCancelled()) return; @@ -78,6 +86,12 @@ protected void processTask() throws Exception { LOG.warn("No usable text found in {}", file); } else { parsed.add(new ParsedFile(file, chunks)); + String skipped = chunks.get(0).metadata().get("source_json_skipped_records"); + if (skipped != null) { + int count = Integer.parseInt(skipped); + skippedMappedRecords += count; + LOG.warn("Schema-mapped import skipped {} JSON records with no selected content in {}", count, file); + } } } catch (Exception ex) { failed++; @@ -91,6 +105,7 @@ protected void processTask() throws Exception { int failedCount = failed; int emptyCount = empty; int parsedCount = parsed.size(); + int skippedCount = skippedMappedRecords; LOG.info("Parsed {} text files ({} failures, {} empty)", parsedCount, failedCount, emptyCount); Platform.runLater(() -> { @@ -104,9 +119,10 @@ protected void processTask() throws Exception { } scene.getRoot().fireEvent( new HyperdriveEvent(HyperdriveEvent.NEW_BATCH_TEXTLOAD, items, loadedFiles)); - if (failedCount + emptyCount > 0) { + if (failedCount + emptyCount + skippedCount > 0) { notifyTerminalWarning("Text import: " + failedCount + " failed file(s), " - + emptyCount + " with no usable text (see application log).", scene); + + emptyCount + " empty file(s), " + skippedCount + + " JSON record(s) without selected content (see application log).", scene); } if (progressIndicator != null) { progressIndicator.setLabelLater("Loaded " + parsedCount + " files / " + items.size() + " chunks" diff --git a/src/main/java/edu/jhuapl/trinity/javafx/components/hyperdrive/MappingRow.java b/src/main/java/edu/jhuapl/trinity/javafx/components/hyperdrive/MappingRow.java new file mode 100644 index 00000000..9b870f1c --- /dev/null +++ b/src/main/java/edu/jhuapl/trinity/javafx/components/hyperdrive/MappingRow.java @@ -0,0 +1,28 @@ +package edu.jhuapl.trinity.javafx.components.hyperdrive; + +import javafx.geometry.Insets; +import javafx.scene.control.CheckBox; +import javafx.scene.control.Label; +import javafx.scene.control.RadioButton; +import javafx.scene.control.ToggleGroup; +import javafx.scene.layout.HBox; + +/** + * + * @author Birdasaur + */ +public class MappingRow { + final CheckBox embed = new CheckBox(); + final RadioButton label = new RadioButton(); + final CheckBox metadata = new CheckBox(); + final HBox node; + public MappingRow(String path, ToggleGroup group) { + label.setToggleGroup(group); + Label pathLabel = new Label(path); + pathLabel.setMinWidth(340); + pathLabel.setMaxWidth(340); + pathLabel.setWrapText(true); + node = new HBox(14, pathLabel, embed, label, metadata); + node.setPadding(new Insets(4)); + } +} diff --git a/src/main/java/edu/jhuapl/trinity/javafx/components/hyperdrive/TextChunkParser.java b/src/main/java/edu/jhuapl/trinity/javafx/components/hyperdrive/TextChunkParser.java index cf51c6ad..fc1cadb9 100644 --- a/src/main/java/edu/jhuapl/trinity/javafx/components/hyperdrive/TextChunkParser.java +++ b/src/main/java/edu/jhuapl/trinity/javafx/components/hyperdrive/TextChunkParser.java @@ -25,7 +25,12 @@ private TextChunkParser() { } public record Options(boolean jsonProcessing, boolean csvExpansion, boolean labelFromCsvColumn, int csvLabelColumn, - boolean breakOnNewlines, int chunkSize) { + boolean breakOnNewlines, int chunkSize, JsonExtractionProfile jsonProfile) { + public Options(boolean jsonProcessing, boolean csvExpansion, + boolean labelFromCsvColumn, int csvLabelColumn, + boolean breakOnNewlines, int chunkSize) { + this(jsonProcessing, csvExpansion, labelFromCsvColumn, csvLabelColumn, breakOnNewlines, chunkSize, null); + } public Options { if (chunkSize < 1) throw new IllegalArgumentException("chunkSize must be positive"); if (csvLabelColumn < 0) throw new IllegalArgumentException("csvLabelColumn must not be negative"); @@ -69,7 +74,7 @@ public static List parseText(File file, String source, Options opti if (options.csvExpansion() && name.toLowerCase(java.util.Locale.ROOT).endsWith(".csv")) { parsed = parseCsv(name, source, options); } else if (options.jsonProcessing() && (trimmed.startsWith("{") || trimmed.startsWith("["))) { - parsed = parseJson(name, trimmed); + parsed = parseJson(name, trimmed, options.jsonProfile()); } else if (options.breakOnNewlines()) { parsed = parseLines(name, source, options.chunkSize()); } else { @@ -87,17 +92,49 @@ public static List parseText(File file, String source, Options opti return indexed; } - private static List parseJson(String name, String json) throws IOException { + private static List parseJson(String name, String json, JsonExtractionProfile profile) throws IOException { JsonNode root = MAPPER.readTree(json); List output = new ArrayList<>(); + if (profile != null && !profile.hasEmbeddingFields()) { + throw new IOException("JSON mapping profile has no embedding fields: " + profile.getName()); + } if (root.isArray()) { - for (int i = 0; i < root.size(); i++) addJsonValue(output, root.get(i), name, i); + for (int i = 0; i < root.size(); i++) { + if (profile == null) addJsonValue(output, root.get(i), name, i); + else addMappedJsonValue(output, root.get(i), name, i, profile); + } } else { - addJsonValue(output, root, name, 0); + if (profile == null) addJsonValue(output, root, name, 0); + else addMappedJsonValue(output, root, name, 0, profile); + } + if (profile != null && output.isEmpty()) { + throw new IOException("JSON mapping profile '" + profile.getName() + + "' produced no embedding text for " + name); + } + if (profile != null && root.isArray() && output.size() < root.size()) { + int skipped = root.size() - output.size(); + List annotated = new ArrayList<>(output.size()); + for (ParsedChunk chunk : output) { + Map metadata = new LinkedHashMap<>(chunk.metadata()); + metadata.put("source_json_skipped_records", Integer.toString(skipped)); + annotated.add(new ParsedChunk(chunk.text(), chunk.label(), metadata)); + } + return annotated; } return output; } + private static void addMappedJsonValue(List output, JsonNode record, + String name, int index, JsonExtractionProfile profile) { + if (record == null || record.isNull()) return; + JsonExtractionProfile.Extracted extracted = profile.extract(record, name); + if (extracted.text().isBlank()) return; // Never silently revert to raw JSON. + Map metadata = new LinkedHashMap<>(extracted.metadata()); + metadata.put("source_json_index", Integer.toString(index)); + metadata.put("json_mapping_profile", profile.getName()); + output.add(new ParsedChunk(extracted.text(), extracted.label(), metadata)); + } + private static void addJsonValue(List output, JsonNode value, String name, int index) { if (value == null || value.isNull()) return; String text = value.toPrettyString(); diff --git a/src/main/java/edu/jhuapl/trinity/javafx/components/hyperdrive/TopicDiscoveryEngine.java b/src/main/java/edu/jhuapl/trinity/javafx/components/hyperdrive/TopicDiscoveryEngine.java new file mode 100644 index 00000000..4d19643c --- /dev/null +++ b/src/main/java/edu/jhuapl/trinity/javafx/components/hyperdrive/TopicDiscoveryEngine.java @@ -0,0 +1,224 @@ +package edu.jhuapl.trinity.javafx.components.hyperdrive; + +import com.clust4j.algo.*; +import edu.jhuapl.trinity.utils.clustering.ClusterMethod; +import edu.jhuapl.trinity.utils.clustering.GaussianMixtureModel; +import org.apache.commons.math3.linear.Array2DRowRealMatrix; + +import java.util.*; +import java.util.stream.Collectors; + +/** + * Topic discovery operates on the ORIGINAL N-dimensional embeddings, not 3D projections. + * No JavaFX dependencies: safe for a worker thread and deterministic regression tests. + */ +public final class TopicDiscoveryEngine { + private TopicDiscoveryEngine() { } + + public record Sample(int id, String text, double[] vector) { } + public record Options(ClusterMethod method, int clusters, int minimumClusterSize, + int minimumPoints, double epsilon, int maxIterations) { } + public record Result(List topics, int noiseCount, int sampleCount) { } + + public static final class Topic { + private final int id; + private final List members; + private final double[] centroid; + private final List representative; + private List keywords; + private boolean autoNamed = true; + private final double cohesion; + private double distinctiveness = Double.NaN; + private String name; + private String summary; + private boolean included = true; + + private Topic(int id, List members, int maxRepresentatives) { + this.id = id; + this.members = List.copyOf(members); + int dim = members.get(0).vector().length; + this.centroid = new double[dim]; + // Preserve the source embedding scale for the existing metric selector, + // including Euclidean and other magnitude-sensitive distances. + for (Sample s : members) { + for (int d = 0; d < dim; d++) centroid[d] += s.vector()[d] / members.size(); + } + double score = 0; + List sorted = new ArrayList<>(members); + sorted.sort(Comparator.comparingDouble(s -> -cosine(s.vector(), centroid))); + for (Sample sample : members) score += cosine(sample.vector(), centroid); + cohesion = score / members.size(); + representative = sorted.stream().map(Sample::text).filter(Objects::nonNull) + .filter(s -> !s.isBlank()).limit(maxRepresentatives).map(s -> truncate(s, 800)).toList(); + keywords = List.of(); + name = "Topic " + (id + 1); + summary = ""; + } + public int id() { return id; } + public List members() { return members; } + public int size() { return members.size(); } + public double[] centroid() { return centroid.clone(); } + public List representative() { return representative; } + public List keywords() { return keywords; } + public double cohesion() { return cohesion; } + public double distinctiveness() { return distinctiveness; } + public String name() { return name; } + public void setName(String name) { + if (name != null && !name.isBlank()) { + this.name = name.strip(); + this.autoNamed = false; + } + } + public String summary() { return summary; } + public void setSummary(String summary) { this.summary = summary == null ? "" : summary.strip(); } + public boolean included() { return included; } + public void setIncluded(boolean included) { this.included = included; } + @Override public String toString() { return (included ? "[x] " : "[ ] ") + name + " (" + size() + ")"; } + } + + /** Vectors should already be finite and of identical dimensions. */ + public static Result discover(List samples, Options options) { + if (samples == null || samples.size() < 2) throw new IllegalArgumentException("At least two embedded text chunks are required."); + if (options == null || options.method() == null) throw new IllegalArgumentException("Select a clustering method."); + int dim = samples.get(0).vector().length; + double[][] observations = new double[samples.size()][dim]; + for (int i = 0; i < samples.size(); i++) { + double[] v = samples.get(i).vector(); + if (!TextLandmarkSimilarityEngine.usable(v) || v.length != dim) + throw new IllegalArgumentException("All topic discovery embeddings must be finite and have matching dimensions."); + observations[i] = v.clone(); + if (!normalize(observations[i])) throw new IllegalArgumentException("Zero vectors cannot be clustered with normalized distance."); + } + int[] labels = fit(observations, options); + if (labels.length != samples.size()) throw new IllegalStateException("Clustering returned an incompatible number of assignments."); + Map> grouped = new TreeMap<>(); + int noise = 0; + for (int i = 0; i < labels.length; i++) { + if (labels[i] < 0) { noise++; continue; } + // Store original vectors for numeric landmark prototypes; only the + // clustering algorithm operates on the normalized working copy. + grouped.computeIfAbsent(labels[i], unused -> new ArrayList<>()) + .add(new Sample(samples.get(i).id(), samples.get(i).text(), samples.get(i).vector().clone())); + } + List topics = new ArrayList<>(); + int idx = 0; + for (List group : grouped.values()) topics.add(new Topic(idx++, group, 5)); + topics.sort(Comparator.comparingInt(Topic::size).reversed()); + scoreDistinctiveness(topics); + return new Result(List.copyOf(topics), noise, samples.size()); + } + + public static Topic merge(int id, Collection topics) { + List merged = topics.stream().flatMap(t -> t.members().stream()) + .collect(Collectors.toMap(Sample::id, s -> s, (a, b) -> a, LinkedHashMap::new)) + .values().stream().toList(); + if (merged.isEmpty()) throw new IllegalArgumentException("Select topics to merge."); + return new Topic(id, merged, 5); + } + + /** Distance from each centroid to its nearest competing centroid in cosine space. */ + public static void scoreDistinctiveness(List topics) { + // Refresh contrastive topic terms when clusters are discovered or merged. + refreshTopicKeywords(topics); + for (Topic topic : topics) { + double nearestSimilarity = -1; + for (Topic other : topics) { + if (topic == other) continue; + nearestSimilarity = Math.max(nearestSimilarity, cosine(topic.centroid, other.centroid)); + } + topic.distinctiveness = 1 - nearestSimilarity; + } + } + + private static int[] fit(double[][] samples, Options options) { + var data = new Array2DRowRealMatrix(samples); + int k = Math.max(1, Math.min(options.clusters(), samples.length)); + int iterations = Math.max(10, options.maxIterations()); + return switch (options.method()) { + case KMEANS -> new KMeansParameters(k).setMaxIter(iterations).setVerbose(false) + .fitNewModel(data).getLabels(); + case KMEDIODS -> new KMedoidsParameters(k).setMaxIter(iterations).setVerbose(false) + .fitNewModel(data).getLabels(); + case DBSCAN -> new DBSCANParameters().setEps(options.epsilon()) + .setMinPts(Math.max(2, options.minimumPoints())).setVerbose(false) + .fitNewModel(data).getLabels(); + case HDDBSCAN -> new HDBSCANParameters().setMinClustSize(Math.max(2, options.minimumClusterSize())) + .setMinPts(Math.max(2, options.minimumPoints())).setVerbose(false) + .fitNewModel(data).getLabels(); + case AFFINITY -> new AffinityPropagationParameters().setMaxIter(iterations) + .setDampingFactor(0.7).setVerbose(false).fitNewModel(data).getLabels(); + case EX_MAX -> { + GaussianMixtureModel gmm = GaussianMixtureModel.fit(k, samples, true); + int[] groups = new int[samples.length]; + for (int i = 0; i < groups.length; i++) groups[i] = gmm.maxPostProb(samples[i]).getKey(); + yield groups; + } + }; + } + + private static boolean normalize(double[] vector) { + double max = 0; + for (double x : vector) max = Math.max(max, Math.abs(x)); + if (max == 0) return false; + double sum = 0; + for (double x : vector) sum += (x / max) * (x / max); + double norm = max * Math.sqrt(sum); + for (int i = 0; i < vector.length; i++) vector[i] /= norm; + return true; + } + + private static double cosine(double[] a, double[] b) { + double dot = 0, aa = 0, bb = 0; + for (int i = 0; i < a.length; i++) { + dot += a[i] * b[i]; + aa += a[i] * a[i]; + bb += b[i] * b[i]; + } + return aa == 0 || bb == 0 ? 0 : dot / Math.sqrt(aa * bb); + } + + private static final Set STOP = Set.of("the", "and", "for", "with", "this", "that", "from", "are", "was", "were", "have", "has", "had", "into", "out", "not", "but", "its", "their", "then", "than", "about", "can", "could", "would", "should", "will", "being", "been", "which", "when", "where", "there", "these", "those", "they", "them", "you", "your", "our", "also", "using", "used", "such", "all", "one", "two", "per", "each", "any", "may", "more", "most", "other", "some"); + /** Class-based contrastive TF-IDF: distinguish a term in this topic from all other topics. */ + private static void refreshTopicKeywords(List topics) { + if (topics.isEmpty()) return; + List> counts = new ArrayList<>(topics.size()); + Map documentFrequency = new HashMap<>(); + for (Topic topic : topics) { + Map groupCounts = new HashMap<>(); + for (Sample sample : topic.members) { + if (sample.text() == null) continue; + String text = sample.text().length() > 8000 ? sample.text().substring(0, 8000) : sample.text(); + for (String word : text.toLowerCase(Locale.ROOT).split("[^\\p{L}\\p{N}]+")) { + if (word.length() >= 4 && !STOP.contains(word)) groupCounts.merge(word, 1, Integer::sum); + } + } + counts.add(groupCounts); + for (String word : groupCounts.keySet()) documentFrequency.merge(word, 1, Integer::sum); + } + for (int i = 0; i < topics.size(); i++) { + Topic topic = topics.get(i); + Map terms = counts.get(i); + long total = terms.values().stream().mapToLong(Integer::longValue).sum(); + List ranked = terms.entrySet().stream() + .sorted((a, b) -> { + int scoreOrder = Double.compare( + topicTermScore(b.getValue(), total, documentFrequency.get(b.getKey()), topics.size()), + topicTermScore(a.getValue(), total, documentFrequency.get(a.getKey()), topics.size())); + return scoreOrder != 0 ? scoreOrder : a.getKey().compareTo(b.getKey()); + }).limit(8).map(Map.Entry::getKey).toList(); + topic.keywords = ranked; + if (topic.autoNamed) { + topic.name = "Topic " + (topic.id + 1) + + (ranked.isEmpty() ? "" : ": " + String.join(", ", ranked.subList(0, Math.min(2, ranked.size())))); + } + } + } + + private static double topicTermScore(int frequency, long total, int topicFrequency, int topicCount) { + if (total == 0) return 0; + // Shared schema tokens occurring in every topic receive near-zero weight. + double idf = Math.log((topicCount + 0.5) / (topicFrequency + 0.5)) + 0.02; + return frequency / (double) total * idf * idf; + } + private static String truncate(String s, int max) { return s.length() <= max ? s : s.substring(0, max) + "..."; } +} diff --git a/src/main/java/edu/jhuapl/trinity/javafx/components/hyperdrive/TopicDiscoveryPane.java b/src/main/java/edu/jhuapl/trinity/javafx/components/hyperdrive/TopicDiscoveryPane.java new file mode 100644 index 00000000..34c688c9 --- /dev/null +++ b/src/main/java/edu/jhuapl/trinity/javafx/components/hyperdrive/TopicDiscoveryPane.java @@ -0,0 +1,187 @@ +package edu.jhuapl.trinity.javafx.components.hyperdrive; + +import edu.jhuapl.trinity.utils.clustering.ClusterMethod; +import edu.jhuapl.trinity.javafx.events.HyperdriveEvent; +import edu.jhuapl.trinity.css.StyleResourceProvider; +import static edu.jhuapl.trinity.javafx.events.CommandTerminalEvent.notifyTerminalError; +import static edu.jhuapl.trinity.javafx.events.CommandTerminalEvent.notifyTerminalSuccess; +import static edu.jhuapl.trinity.javafx.events.CommandTerminalEvent.notifyTerminalWarning; +import javafx.concurrent.Task; +import javafx.geometry.Insets; +import javafx.scene.Scene; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import java.util.ArrayList; +import java.util.List; +import javafx.scene.control.Alert; +import javafx.scene.control.Button; +import javafx.scene.control.ButtonType; +import javafx.scene.control.ChoiceBox; +import javafx.scene.control.Control; +import javafx.scene.control.Label; +import javafx.scene.control.ListView; +import javafx.scene.control.Spinner; +import javafx.scene.layout.GridPane; +import javafx.scene.layout.Priority; +import javafx.scene.layout.VBox; + +/** Topic candidate discovery and human review; no embedded text is mutated here. */ +public final class TopicDiscoveryPane extends VBox { + private static final Logger LOG = LoggerFactory.getLogger(TopicDiscoveryPane.class); + private final Scene scene; + private final ChoiceBox methodChoice = new ChoiceBox<>(); + private final ChoiceBox scopeChoice = new ChoiceBox<>(); + private final Spinner clusters = new Spinner<>(2, 500, 12); + private final Spinner minSize = new Spinner<>(2, 2000, 10); + private final Spinner minPts = new Spinner<>(2, 2000, 5); + private final Spinner epsilon = new Spinner<>(0.01, 5.0, 0.5, 0.05); + private final Spinner iterations = new Spinner<>(10, 1000, 100); + private final GridPane parameters = new GridPane(); + private final ListView candidates = new ListView<>(); + private final Label summaryLabel = new Label("No topics discovered"); + private final Button discoverButton = new Button("Discover Topics"); + private final Button reviewButton = new Button("Review Topics..."); + private final List topics = new ArrayList<>(); + private Task discoveryTask; + private long generation; + private String sourceEmbeddingModel; + + public TopicDiscoveryPane(Scene scene) { + this.scene = scene; + setPadding(new Insets(5)); + setSpacing(7); + methodChoice.getItems().setAll(ClusterMethod.values()); + methodChoice.setValue(ClusterMethod.HDDBSCAN); + scopeChoice.getItems().setAll("All embedded text", "Selected text chunks"); + scopeChoice.getSelectionModel().selectFirst(); + for (Spinner spinner : List.of(clusters, minSize, minPts, epsilon, iterations)) { + spinner.setEditable(true); + spinner.setPrefWidth(110); + } + parameters.setHgap(6); + parameters.setVgap(4); + methodChoice.valueProperty().addListener((obs, oldValue, value) -> updateParameters()); + updateParameters(); + candidates.setPlaceholder(new Label("Run Discover Topics")); + candidates.setMinHeight(90); + VBox.setVgrow(candidates, Priority.ALWAYS); + discoverButton.setMaxWidth(Double.MAX_VALUE); + reviewButton.setMaxWidth(Double.MAX_VALUE); + discoverButton.setOnAction(e -> scene.getRoot().fireEvent( + new HyperdriveEvent(HyperdriveEvent.REQUEST_TOPIC_DISCOVERY))); + reviewButton.setOnAction(e -> openReview()); + reviewButton.setDisable(true); + getChildren().addAll(new Label("Source"), scopeChoice, parameters, discoverButton, + summaryLabel, candidates, reviewButton); + scene.getRoot().addEventHandler(HyperdriveEvent.TOPIC_REVIEW_UPDATED, event -> { + if (!(event.object1 instanceof List list)) return; + if (!list.stream().allMatch(TopicDiscoveryEngine.Topic.class::isInstance)) return; + // Apply review edits to the same candidate collection used for landmark validation. + topics.clear(); + for (Object value : list) topics.add((TopicDiscoveryEngine.Topic) value); + candidates.getItems().setAll(topics); + reviewButton.setDisable(topics.isEmpty()); + }); + } + + /** Displayed in Hyperdrive's top toolbar, not duplicated inside this pane. */ + public ChoiceBox methodSelector() { return methodChoice; } + public boolean selectedOnly() { return scopeChoice.getSelectionModel().getSelectedIndex() == 1; } + public String sourceEmbeddingModel() { return sourceEmbeddingModel; } + public void refreshDisplay() { + candidates.refresh(); + scene.getRoot().fireEvent(new HyperdriveEvent(HyperdriveEvent.REFRESH_TOPIC_REVIEW)); + } + public boolean stillContains(TopicDiscoveryEngine.Topic topic) { return topics.contains(topic); } + + private void updateParameters() { + parameters.getChildren().clear(); + ClusterMethod method = methodChoice.getValue(); + if (method == null) return; + switch (method) { + case KMEANS, KMEDIODS, EX_MAX -> { + parameter("Target topics", clusters, 0); + if (method != ClusterMethod.EX_MAX) parameter("Max iterations", iterations, 1); + } + case DBSCAN -> { + parameter("Epsilon", epsilon, 0); + parameter("Min points", minPts, 1); + } + case HDDBSCAN -> { + parameter("Min cluster size", minSize, 0); + parameter("Min points", minPts, 1); + } + case AFFINITY -> parameter("Max iterations", iterations, 0); + } + } + private void parameter(String label, Control control, int row) { + parameters.add(new Label(label), 0, row); + parameters.add(control, 1, row); + } + + public void discover(List samples, int excluded, String embeddingModel) { + if (samples.size() < 2) { warn("Topic discovery requires two or more valid embeddings."); return; } + if (methodChoice.getValue() == ClusterMethod.AFFINITY && samples.size() > 1000) { + warn("Affinity Propagation is limited to 1,000 chunks. Select a subset or another method."); + return; + } + // Spinner editors may contain an uncommitted typed value. + for (Spinner spinner : List.of(clusters, minSize, minPts, epsilon, iterations)) spinner.commitValue(); + // Snapshot controls and vectors before starting the worker. + TopicDiscoveryEngine.Options settings = new TopicDiscoveryEngine.Options(methodChoice.getValue(), + clusters.getValue(), minSize.getValue(), minPts.getValue(), epsilon.getValue(), iterations.getValue()); + if (discoveryTask != null) discoveryTask.cancel(); + scene.getRoot().fireEvent(new HyperdriveEvent(HyperdriveEvent.CLOSE_TOPIC_REVIEW)); + long run = ++generation; + sourceEmbeddingModel = embeddingModel; + topics.clear(); + candidates.getItems().clear(); + reviewButton.setDisable(true); + discoverButton.setDisable(true); + summaryLabel.setText("Discovering topics..."); + discoveryTask = new Task<>() { + @Override protected TopicDiscoveryEngine.Result call() { + return TopicDiscoveryEngine.discover(samples, settings); + } + }; + Task task = discoveryTask; + task.setOnSucceeded(e -> { + if (run != generation) return; + TopicDiscoveryEngine.Result result = task.getValue(); + topics.addAll(result.topics()); + candidates.getItems().setAll(topics); + if (!topics.isEmpty()) candidates.getSelectionModel().selectFirst(); + summaryLabel.setText(topics.size() + " topics · " + result.noiseCount() + " noise · " + excluded + " excluded"); + discoverButton.setDisable(false); + reviewButton.setDisable(topics.isEmpty()); + if (topics.isEmpty()) notifyTerminalWarning("Topic discovery found no clusters. Adjust the method parameters.", scene); + else notifyTerminalSuccess("Discovered " + topics.size() + " candidate topics from " + result.sampleCount() + " text chunks.", scene); + }); + task.setOnFailed(e -> { + if (run != generation) return; + discoverButton.setDisable(false); + summaryLabel.setText("Discovery failed"); + LOG.error("Topic discovery failed", task.getException()); + notifyTerminalError("Topic discovery failed: " + task.getException().getMessage(), scene); + }); + task.setOnCancelled(e -> { if (run == generation) discoverButton.setDisable(false); }); + Thread worker = new Thread(task, "Trinity Hyperdrive Topic Discovery"); + worker.setDaemon(true); + worker.start(); + } + + private void warn(String message) { + Alert alert = new Alert(Alert.AlertType.WARNING, message, ButtonType.OK); + alert.setTitle("Topic Discovery"); + alert.setHeaderText("Unable to discover topics"); + alert.getDialogPane().getStylesheets().add( + StyleResourceProvider.getResource("dialogstyles.css").toExternalForm()); + alert.showAndWait(); + } + + private void openReview() { + if (topics.isEmpty()) return; + scene.getRoot().fireEvent(new HyperdriveEvent( + HyperdriveEvent.OPEN_TOPIC_REVIEW, List.copyOf(topics))); + } +} diff --git a/src/main/java/edu/jhuapl/trinity/javafx/components/hyperdrive/TopicDiscoveryReviewPane.java b/src/main/java/edu/jhuapl/trinity/javafx/components/hyperdrive/TopicDiscoveryReviewPane.java new file mode 100644 index 00000000..fbb29432 --- /dev/null +++ b/src/main/java/edu/jhuapl/trinity/javafx/components/hyperdrive/TopicDiscoveryReviewPane.java @@ -0,0 +1,194 @@ +package edu.jhuapl.trinity.javafx.components.hyperdrive; + +import edu.jhuapl.trinity.css.StyleResourceProvider; +import edu.jhuapl.trinity.javafx.components.panes.LitPathPane; +import edu.jhuapl.trinity.javafx.events.HyperdriveEvent; +import javafx.event.EventHandler; +import javafx.geometry.Insets; +import javafx.scene.Scene; +import javafx.scene.control.Alert; +import javafx.scene.control.Button; +import javafx.scene.control.ButtonType; +import javafx.scene.control.CheckBox; +import javafx.scene.control.Label; +import javafx.scene.control.ListView; +import javafx.scene.control.SelectionMode; +import javafx.scene.control.TextArea; +import javafx.scene.control.TextField; +import javafx.scene.layout.BorderPane; +import javafx.scene.layout.HBox; +import javafx.scene.layout.Pane; +import javafx.scene.layout.Priority; +import javafx.scene.layout.VBox; +import java.util.ArrayList; +import java.util.Comparator; +import java.util.List; +import java.util.Locale; + +/** Floating, non-modal review of candidate topics. All commands use HyperdriveEvent. */ +public final class TopicDiscoveryReviewPane extends LitPathPane { + private final Scene reviewScene; + private final List topics = new ArrayList<>(); + private final ListView list = new ListView<>(); + private final TextField title = new TextField(); + private final TextArea summary = new TextArea(); + private final TextArea evidence = new TextArea(); + private final Label stats = new Label(); + private final CheckBox include = new CheckBox("Include in generated landmarks"); + private int nextTopicId = 100000; + private final EventHandler refreshHandler = event -> refreshDisplay(); + + public TopicDiscoveryReviewPane(Scene scene, Pane parent, List candidates) { + super(scene, parent, 1000, 690, new BorderPane(), + "Hyperdrive", "Topic Discovery Review", 300.0, 400.0); + reviewScene = scene; + topics.addAll(candidates); + getStylesheets().add(StyleResourceProvider.getResource("styles.css").toExternalForm()); + getStylesheets().add(StyleResourceProvider.getResource("covalent.css").toExternalForm()); + + list.getItems().setAll(topics); + list.getSelectionModel().setSelectionMode(SelectionMode.MULTIPLE); + list.setPrefWidth(390); + list.setPrefHeight(470); + title.setPromptText("Topic name"); + summary.setWrapText(true); + summary.setPrefRowCount(3); + evidence.setEditable(false); + evidence.setWrapText(true); + evidence.setPrefRowCount(12); + VBox.setVgrow(evidence, Priority.ALWAYS); + + list.getSelectionModel().selectedItemProperty().addListener((obs, before, after) -> populate()); + include.setOnAction(e -> { + TopicDiscoveryEngine.Topic topic = list.getSelectionModel().getSelectedItem(); + if (topic == null) return; + topic.setIncluded(include.isSelected()); + notifyUpdated(); + }); + + Button save = new Button("Save Title / Summary"); + save.setOnAction(e -> { + TopicDiscoveryEngine.Topic topic = list.getSelectionModel().getSelectedItem(); + if (topic == null) return; + topic.setName(title.getText()); + topic.setSummary(summary.getText()); + notifyUpdated(); + populate(); + }); + Button merge = new Button("Merge Selected"); + merge.setOnAction(e -> { + List chosen = List.copyOf(list.getSelectionModel().getSelectedItems()); + if (chosen.size() < 2) { + warn("Select at least two topics to merge."); + return; + } + // Use a fresh ID even after reopening the review workspace. + while (containsTopicId(nextTopicId)) nextTopicId++; + TopicDiscoveryEngine.Topic merged = TopicDiscoveryEngine.merge(nextTopicId++, chosen); + topics.removeAll(chosen); + topics.add(merged); + topics.sort(Comparator.comparingInt(TopicDiscoveryEngine.Topic::size).reversed()); + TopicDiscoveryEngine.scoreDistinctiveness(topics); + list.getItems().setAll(topics); + list.getSelectionModel().select(merged); + notifyUpdated(); + }); + Button summarize = new Button("Summarize Selected (Chat Model)"); + summarize.setOnAction(e -> { + TopicDiscoveryEngine.Topic topic = list.getSelectionModel().getSelectedItem(); + if (topic == null) return; + topic.setName(title.getText()); + topic.setSummary(summary.getText()); + notifyUpdated(); + reviewScene.getRoot().fireEvent(new HyperdriveEvent( + HyperdriveEvent.SUMMARIZE_DISCOVERED_TOPIC, topic)); + }); + Button generate = new Button("Generate Selected Landmarks"); + generate.setOnAction(e -> { + List selected = topics.stream() + .filter(TopicDiscoveryEngine.Topic::included) + .filter(t -> !t.name().isBlank()) + .toList(); + if (selected.isEmpty()) { + warn("Select at least one topic for landmark generation."); + return; + } + reviewScene.getRoot().fireEvent(new HyperdriveEvent( + HyperdriveEvent.GENERATE_TOPIC_LANDMARKS, List.copyOf(selected))); + close(); + }); + Button closeButton = new Button("Close"); + closeButton.setOnAction(e -> close()); + + VBox left = new VBox(8, new Label("Candidate Topics (Ctrl-click to merge)"), + list, merge, generate); + left.setPrefWidth(400); + VBox.setVgrow(list, Priority.ALWAYS); + VBox details = new VBox(8, new Label("Topic Name"), title, + new Label("Description"), summary, include, stats, save, + summarize, new Label("Representative Content"), evidence); + details.setPrefWidth(520); + HBox.setHgrow(details, Priority.ALWAYS); + HBox body = new HBox(12, left, details); + HBox.setHgrow(left, Priority.ALWAYS); + VBox.setVgrow(body, Priority.ALWAYS); + HBox footer = new HBox(closeButton); + footer.setPadding(new Insets(5)); + VBox layout = new VBox(10, body, footer); + layout.setPadding(new Insets(10)); + VBox.setVgrow(body, Priority.ALWAYS); + ((BorderPane) contentPane).setCenter(layout); + reviewScene.getRoot().addEventHandler(HyperdriveEvent.REFRESH_TOPIC_REVIEW, refreshHandler); + list.getSelectionModel().selectFirst(); + } + + private boolean containsTopicId(int id) { + return topics.stream().anyMatch(topic -> topic.id() == id); + } + + private void populate() { + TopicDiscoveryEngine.Topic topic = list.getSelectionModel().getSelectedItem(); + if (topic == null) { + title.clear(); + summary.clear(); + evidence.clear(); + stats.setText(""); + return; + } + title.setText(topic.name()); + summary.setText(topic.summary()); + include.setSelected(topic.included()); + stats.setText(topic.size() + " chunks · cohesion " + + String.format(Locale.ROOT, "%.3f", topic.cohesion()) + + " · distinctiveness " + + String.format(Locale.ROOT, "%.3f", topic.distinctiveness()) + + " · keywords: " + String.join(", ", topic.keywords())); + evidence.setText(String.join("\n\n---\n\n", topic.representative())); + } + + private void refreshDisplay() { + list.refresh(); + populate(); + } + + private void notifyUpdated() { + list.refresh(); + reviewScene.getRoot().fireEvent(new HyperdriveEvent( + HyperdriveEvent.TOPIC_REVIEW_UPDATED, List.copyOf(topics))); + } + + private void warn(String message) { + Alert alert = new Alert(Alert.AlertType.WARNING, message, ButtonType.OK); + alert.setTitle("Topic Discovery"); + alert.setHeaderText("Review topics"); + alert.getDialogPane().getStylesheets().add( + StyleResourceProvider.getResource("dialogstyles.css").toExternalForm()); + alert.showAndWait(); + } + + @Override + public void onClose() { + reviewScene.getRoot().removeEventHandler(HyperdriveEvent.REFRESH_TOPIC_REVIEW, refreshHandler); + super.onClose(); + } +} diff --git a/src/main/java/edu/jhuapl/trinity/javafx/components/listviews/LandmarkTextBuilderBox.java b/src/main/java/edu/jhuapl/trinity/javafx/components/listviews/LandmarkTextBuilderBox.java index db7413c0..d211d3ec 100644 --- a/src/main/java/edu/jhuapl/trinity/javafx/components/listviews/LandmarkTextBuilderBox.java +++ b/src/main/java/edu/jhuapl/trinity/javafx/components/listviews/LandmarkTextBuilderBox.java @@ -24,7 +24,7 @@ public class LandmarkTextBuilderBox extends VBox { public static double LISTVIEW_PREF_HEIGHT = 450; - public static double BUTTON_PREF_WIDTH = 125; + public static double BUTTON_PREF_WIDTH = 80; Background transFillBack = new Background(new BackgroundFill( Color.ALICEBLUE.deriveColor(1, 1, 1, 0.1), CornerRadii.EMPTY, Insets.EMPTY)); @@ -49,11 +49,12 @@ public LandmarkTextBuilderBox() { Button clearButton = new Button("Clear"); clearButton.setPrefWidth(BUTTON_PREF_WIDTH); clearButton.setOnAction(e -> landmarksListView.getItems().clear()); - HBox buttonHBox = new HBox(10, entryLabel, addButton, clearButton); + HBox buttonHBox = new HBox(10, addButton, clearButton); buttonHBox.setAlignment(Pos.CENTER); landmarksListView = new ListView<>(); - landmarksListView.setPrefHeight(LISTVIEW_PREF_HEIGHT); + landmarksListView.setPrefHeight(320); + VBox.setVgrow(landmarksListView, javafx.scene.layout.Priority.ALWAYS); landmarksListView.setEditable(true); ImageView iv = ResourceUtils.loadIcon("console", 128); VBox placeholder = new VBox(10, iv, new Label("No Text Landmarks Acquired")); @@ -70,9 +71,23 @@ public LandmarkTextBuilderBox() { setBackground(transFillBack); } + /** Add an approved cluster prototype without making a new embedding-model request. */ + public LandmarkTextListItem addDiscoveredTopic(String name, String summary, + List centroid, int chunkCount, String model) { + LandmarkTextListItem item = new LandmarkTextListItem(name); + item.installDiscoveredCentroid(centroid, summary, chunkCount, model); + landmarksListView.getItems().add(item); + return item; + } + public void setChoices(List choices) { + // Caption-choice dialogs may rebuild this list. Retain existing discovered + // centroid prototypes when the same named topic remains selected. + List previous = List.copyOf(landmarksListView.getItems()); landmarksListView.getItems().setAll(choices.stream() - .map(c -> new LandmarkTextListItem(c)) + .map(choice -> previous.stream() + .filter(item -> item.isDiscoveredCentroid() && item.getFeatureVectorLabel().equals(choice)) + .findFirst().orElseGet(() -> new LandmarkTextListItem(choice))) .toList()); } diff --git a/src/main/java/edu/jhuapl/trinity/javafx/components/listviews/LandmarkTextListItem.java b/src/main/java/edu/jhuapl/trinity/javafx/components/listviews/LandmarkTextListItem.java index 9531cfd2..d291dd29 100644 --- a/src/main/java/edu/jhuapl/trinity/javafx/components/listviews/LandmarkTextListItem.java +++ b/src/main/java/edu/jhuapl/trinity/javafx/components/listviews/LandmarkTextListItem.java @@ -2,17 +2,22 @@ import edu.jhuapl.trinity.javafx.components.hyperdrive.LandmarkEmbeddingState; import javafx.scene.control.TextField; +import javafx.scene.control.Label; import java.util.List; +import java.util.Objects; import static edu.jhuapl.trinity.javafx.events.CommandTerminalEvent.notifyTerminalWarning; /** Editable text landmark whose embeddings are invalidated on any text change. */ public class LandmarkTextListItem extends LandmarkListItem { - public static double PREF_TEXTFIELD_WIDTH = 250; + public static double PREF_TEXTFIELD_WIDTH = 200; private final TextField labelTextField; private final LandmarkEmbeddingState embeddingState = new LandmarkEmbeddingState(); private boolean editedAfterEmbedding; + private boolean discoveredCentroid; + private String discoveredModel; + private final Label sourceBadge = new Label(); public LandmarkTextListItem(String landmarkLabel) { super(landmarkLabel); @@ -21,9 +26,11 @@ public LandmarkTextListItem(String landmarkLabel) { labelTextField.setPrefWidth(PREF_TEXTFIELD_WIDTH); labelTextField.setText(landmarkLabel); labelTextField.textProperty().addListener((obs, previous, current) -> { - if (hasEmbeddings()) editedAfterEmbedding = true; - embeddingState.textChanged(); - invalidateEmbeddings(); + if (!discoveredCentroid) { + if (hasEmbeddings()) editedAfterEmbedding = true; + embeddingState.textChanged(); + invalidateEmbeddings(); + } getFeatureVector().setLabel(current); }); // Do not spam the status line on each keystroke. @@ -33,13 +40,14 @@ public LandmarkTextListItem(String landmarkLabel) { editedAfterEmbedding = false; } }); - getChildren().add(labelTextField); + getChildren().addAll(labelTextField, sourceBadge); setSpacing(20); setPrefHeight(32); } /** Called on the FX thread immediately before sending a landmark request. */ public long beginEmbeddingRequest() { + if (discoveredCentroid) throw new IllegalStateException("Cluster centroids cannot be refreshed as text embeddings"); invalidateEmbeddings(); editedAfterEmbedding = false; return embeddingState.beginRequest(); @@ -57,9 +65,29 @@ public boolean acceptEmbeddingResponse(long revision, String requestedText, } public boolean isEmbeddingFresh(String selectedModel) { + if (discoveredCentroid) return hasEmbeddings() && Objects.equals(discoveredModel, selectedModel); return hasEmbeddings() && embeddingState.isFresh(labelTextField.getText(), selectedModel); } + /** A centroid is a numeric prototype, not an embedding of the displayed topic name. */ + public void installDiscoveredCentroid(List vector, String description, + int chunkCount, String embeddingModel) { + if (vector == null || vector.isEmpty() || vector.stream().anyMatch(v -> v == null || !Double.isFinite(v))) + throw new IllegalArgumentException("Invalid discovered centroid"); + discoveredCentroid = true; + discoveredModel = embeddingModel; + editedAfterEmbedding = false; + setEmbeddings(vector); + sourceBadge.setText("Cluster"); + sourceBadge.setTooltip(new javafx.scene.control.Tooltip("Discovered topic centroid; refresh by rediscovering topics")); + addMetaData("landmark_source", "discovered_topic_centroid"); + if (embeddingModel != null) addMetaData("embedding_model", embeddingModel); + addMetaData("topic_chunk_count", Integer.toString(chunkCount)); + if (description != null) getFeatureVector().setText(description); + } + + public boolean isDiscoveredCentroid() { return discoveredCentroid; } + @Override public void setFeatureVectorLabel(String text) { labelTextField.setText(text); diff --git a/src/main/java/edu/jhuapl/trinity/javafx/components/panes/HyperdrivePane.java b/src/main/java/edu/jhuapl/trinity/javafx/components/panes/HyperdrivePane.java index 1a0b5331..4e673da0 100644 --- a/src/main/java/edu/jhuapl/trinity/javafx/components/panes/HyperdrivePane.java +++ b/src/main/java/edu/jhuapl/trinity/javafx/components/panes/HyperdrivePane.java @@ -7,6 +7,8 @@ import edu.jhuapl.trinity.data.messages.llm.AliveModels; import edu.jhuapl.trinity.data.messages.llm.ChatCaptionResponse; import edu.jhuapl.trinity.data.messages.llm.ChatCompletionsInput; +import edu.jhuapl.trinity.data.messages.llm.ChatMessage; +import edu.jhuapl.trinity.data.messages.llm.Content; import edu.jhuapl.trinity.data.messages.llm.ChatCompletionsOutput; import edu.jhuapl.trinity.data.messages.llm.EmbeddingsImageBatchInput; import edu.jhuapl.trinity.data.messages.llm.EmbeddingsImageData; @@ -16,15 +18,20 @@ import edu.jhuapl.trinity.data.messages.llm.Prompts; import edu.jhuapl.trinity.data.messages.xai.FeatureCollection; import edu.jhuapl.trinity.data.messages.xai.FeatureVector; +import edu.jhuapl.trinity.javafx.components.hyperdrive.EmbeddingBatchResponseValidator; import edu.jhuapl.trinity.javafx.components.hyperdrive.BatchRequestManager; import edu.jhuapl.trinity.javafx.components.hyperdrive.CaptionChooserBox; import edu.jhuapl.trinity.javafx.components.hyperdrive.ChooseCaptionsTask; import edu.jhuapl.trinity.javafx.components.hyperdrive.HyperdriveTask.REQUEST_STATUS; import edu.jhuapl.trinity.javafx.components.hyperdrive.ImageEmbeddingsBatchLauncher; import edu.jhuapl.trinity.javafx.components.hyperdrive.TextEmbeddingsBatchLauncher; +import edu.jhuapl.trinity.javafx.components.hyperdrive.TopicDiscoveryEngine; +import edu.jhuapl.trinity.javafx.components.hyperdrive.TopicDiscoveryPane; import edu.jhuapl.trinity.javafx.components.hyperdrive.TextLandmarkSimilarityEngine; import edu.jhuapl.trinity.javafx.components.hyperdrive.LoadImagesTask; import edu.jhuapl.trinity.javafx.components.hyperdrive.LoadTextTask; +import edu.jhuapl.trinity.javafx.components.hyperdrive.TextChunkParser; +import edu.jhuapl.trinity.javafx.components.hyperdrive.JsonExtractionProfile; import edu.jhuapl.trinity.javafx.components.hyperdrive.RequestCaptionsTask; import edu.jhuapl.trinity.javafx.components.hyperdrive.RequestEmbeddingsTask; import edu.jhuapl.trinity.javafx.components.hyperdrive.RequestLandmarkSimilarityTask; @@ -95,9 +102,11 @@ import java.time.LocalDateTime; import java.time.format.DateTimeFormatter; import java.util.ArrayList; +import java.util.Arrays; import java.util.Collections; import java.util.HashMap; import java.util.List; +import java.util.Locale; import java.util.Map; import java.util.Optional; import java.util.Objects; @@ -106,10 +115,12 @@ import java.util.concurrent.atomic.AtomicInteger; import static edu.jhuapl.trinity.data.messages.llm.EmbeddingsImageUrl.imageUrlFromImage; +import static edu.jhuapl.trinity.messages.RestAccessLayer.currentChatModel; +import static edu.jhuapl.trinity.messages.RestAccessLayer.currentEmbeddingsModel; import static edu.jhuapl.trinity.javafx.events.CommandTerminalEvent.notifyTerminalSuccess; import static edu.jhuapl.trinity.javafx.events.CommandTerminalEvent.notifyTerminalWarning; import static edu.jhuapl.trinity.javafx.events.CommandTerminalEvent.notifyTerminalError; -import static edu.jhuapl.trinity.messages.RestAccessLayer.*; +import static edu.jhuapl.trinity.messages.RestAccessLayer.stringToChatCaptionResponse; /** * @author Sean Phillips @@ -139,6 +150,8 @@ public class HyperdrivePane extends LitPathPane { Tab servicesTab; Tab filesTab; CheckBox renderIconsCheckBox; + private ChoiceBox jsonProcessingModeChoice; + private JsonExtractionProfile activeJsonProfile; ArrayList currentFeatureList; ArrayList currentTextFeatureList; @@ -151,6 +164,10 @@ public class HyperdrivePane extends LitPathPane { CircleProgressIndicator textEmbeddingRequestIndicator; CircleProgressIndicator imageEmbeddingRequestIndicator; LandmarkTextBuilderBox landmarkTextBuilderBox; + TopicDiscoveryPane topicDiscoveryPane; + private final Map pendingTopicSummaries = new HashMap<>(); + private record PendingTopicSummary(TopicDiscoveryEngine.Topic topic, String titleAtRequest) { } + LandmarkImageBuilderBox landmarkImageBuilderBox; TextField embeddingsLocationTextField; TextField chatLocationTextField; @@ -696,16 +713,18 @@ public HyperdrivePane(Scene scene, Pane parent) { Button refreshLandmarkEmbeddingsButton = new Button("Refresh Embeddings"); refreshLandmarkEmbeddingsButton.setOnAction(e -> { List landmarks = List.copyOf(landmarkTextBuilderBox.getItems()); - if (landmarks.isEmpty() || landmarks.stream().allMatch(i -> i.getFeatureVectorLabel().isBlank())) { + if (landmarks.isEmpty() || landmarks.stream().noneMatch(i -> + !i.isDiscoveredCentroid() && !i.getFeatureVectorLabel().isBlank())) { showTextSimilarityAlert(Alert.AlertType.WARNING, "No Text Landmark Requests", "Add at least one nonblank text landmark before refreshing embeddings."); return; } int runId = ++textLandmarkRefreshSequence; TextLandmarkRefreshStats stats = new TextLandmarkRefreshStats(); - stats.total = landmarks.size(); + stats.total = (int) landmarks.stream().filter(i -> !i.isDiscoveredCentroid()).count(); textLandmarkRefreshRuns.put(runId, stats); for (LandmarkTextListItem item : landmarks) { + if (item.isDiscoveredCentroid()) continue; String text = item.getFeatureVectorLabel(); if (text == null || text.isBlank()) { stats.skipped++; @@ -759,13 +778,44 @@ public HyperdrivePane(Scene scene, Pane parent) { }); }); - HBox visionHBox = new HBox(20, - new VBox(5, new Label("Text Landmarks / Caption Labels"), landmarkTextBuilderBox, refreshLandmarkEmbeddingsButton), - new VBox(5, new Label("Image Landmarks"), landmarkImageBuilderBox, refreshImageLandmarkEmbeddingsButton), - new VBox(5, new Label("Distance Metric"), metricChoiceBox) - ); - visionHBox.setPadding(new Insets(10)); - similarityTab.setContent(visionHBox); + // Topic Discovery occupies the left column; assignment metric and clustering method + // remain accessible across the top without consuming ListView height. + topicDiscoveryPane = new TopicDiscoveryPane(scene); + scene.getRoot().addEventHandler(HyperdriveEvent.REQUEST_TOPIC_DISCOVERY, + event -> discoverTextTopics()); + scene.getRoot().addEventHandler(HyperdriveEvent.GENERATE_TOPIC_LANDMARKS, event -> { + if (event.object1 instanceof List list + && list.stream().allMatch(TopicDiscoveryEngine.Topic.class::isInstance)) { + List approved = new ArrayList<>(); + for (Object candidate : list) approved.add((TopicDiscoveryEngine.Topic) candidate); + installDiscoveredTextLandmarks(approved); + } + }); + scene.getRoot().addEventHandler(HyperdriveEvent.SUMMARIZE_DISCOVERED_TOPIC, event -> { + if (event.object1 instanceof TopicDiscoveryEngine.Topic topic) + summarizeDiscoveredTextTopic(topic); + }); + HBox metricToolbar = new HBox(14, + new Label("Landmark Assignment Metric"), metricChoiceBox, + new Label("Clustering Method"), topicDiscoveryPane.methodSelector()); + metricToolbar.setAlignment(Pos.CENTER_LEFT); + VBox discoveryColumn = new VBox(5, new Label("Topic Discovery"), topicDiscoveryPane); + VBox textLandmarksColumn = new VBox(5, + new Label("Text Landmarks / Caption Labels"), landmarkTextBuilderBox, refreshLandmarkEmbeddingsButton); + VBox imageLandmarksColumn = new VBox(5, + new Label("Image Landmarks"), landmarkImageBuilderBox, refreshImageLandmarkEmbeddingsButton); + discoveryColumn.setPrefWidth(335); + textLandmarksColumn.setPrefWidth(345); + imageLandmarksColumn.setPrefWidth(420); + VBox.setVgrow(topicDiscoveryPane, javafx.scene.layout.Priority.ALWAYS); + VBox.setVgrow(landmarkTextBuilderBox, javafx.scene.layout.Priority.ALWAYS); + VBox.setVgrow(landmarkImageBuilderBox, javafx.scene.layout.Priority.ALWAYS); + HBox visionHBox = new HBox(12, discoveryColumn, textLandmarksColumn, imageLandmarksColumn); + visionHBox.setAlignment(Pos.TOP_LEFT); + VBox similarityLayout = new VBox(12, metricToolbar, visionHBox); + similarityLayout.setPadding(new Insets(10)); + VBox.setVgrow(visionHBox, javafx.scene.layout.Priority.ALWAYS); + similarityTab.setContent(similarityLayout); //Services Tab //////////////////////////////////////////////// Button isAliveButton = new Button("Check Status"); @@ -842,12 +892,34 @@ public HyperdrivePane(Scene scene, Pane parent) { new VBox(5, new Label("Request Timeout (Seconds)"), timeoutSpinner) ); - CheckBox enableJSONCheckBox = new CheckBox("Enable Special JSON Processing"); - enableJSONCheckBox.selectedProperty().addListener(e -> { - EmbeddingsTextListItem.ENABLE_JSON_PROCESSING = enableJSONCheckBox.isSelected(); - enableJSONCheckBox.getScene().getRoot().fireEvent( - new HyperdriveEvent(HyperdriveEvent.ENABLE_JSON_PROCESSING, enableJSONCheckBox.isSelected())); - }); + // Explicitly distinguish plain text, the accepted legacy JSON parser, and + // schema-mapped extraction. The mapper is configured in a styled dialog. + jsonProcessingModeChoice = new ChoiceBox<>(); + jsonProcessingModeChoice.getItems().setAll("Disabled", "Legacy", "Schema-Mapped"); + jsonProcessingModeChoice.getSelectionModel().select("Disabled"); + jsonProcessingModeChoice.setPrefWidth(148); + Label jsonProfileLabel = new Label("No mapping selected"); + jsonProfileLabel.setMaxWidth(165); + Button configureJsonButton = new Button("Configure..."); + configureJsonButton.disableProperty().bind(jsonProcessingModeChoice.valueProperty().isNotEqualTo("Schema-Mapped")); + configureJsonButton.setOnAction(e -> scene.getRoot().fireEvent( + new HyperdriveEvent(HyperdriveEvent.OPEN_JSON_SCHEMA_MAPPING, + activeJsonProfile == null ? null : activeJsonProfile.copy()))); + scene.getRoot().addEventHandler(HyperdriveEvent.JSON_SCHEMA_MAPPING_APPLIED, event -> { + if (!(event.object1 instanceof JsonExtractionProfile profile)) return; + activeJsonProfile = profile.copy(); + jsonProfileLabel.setText(activeJsonProfile.getName()); + notifyTerminalSuccess("Active JSON mapping: " + activeJsonProfile.getName(), scene); + }); + jsonProcessingModeChoice.valueProperty().addListener((obs, oldValue, mode) -> { + boolean enabled = !"Disabled".equals(mode); + EmbeddingsTextListItem.ENABLE_JSON_PROCESSING = enabled; + scene.getRoot().fireEvent(new HyperdriveEvent(HyperdriveEvent.ENABLE_JSON_PROCESSING, enabled)); + }); + HBox jsonProcessingControls = new HBox(10, + new Label("JSON Processing"), jsonProcessingModeChoice, + configureJsonButton, jsonProfileLabel); + jsonProcessingControls.setAlignment(Pos.CENTER_LEFT); CheckBox enableCSVCheckBox = new CheckBox("Enable CSV Expansion"); enableCSVCheckBox.selectedProperty().addListener(e -> { EmbeddingsTextListItem.ENABLE_CSV_EXPANSION = enableCSVCheckBox.isSelected(); @@ -890,10 +962,11 @@ public HyperdrivePane(Scene scene, Pane parent) { chunkSizeSpinner.setEditable(true); chunkSizeSpinner.setPrefWidth(100); - VBox chunkingSpinnerVBox = new VBox(30, - new HBox(20, enableJSONCheckBox, enableCSVCheckBox), - new HBox(20, labelFromColumnCheckBox, selectColumnHBox), - new HBox(20, breakOnNewLineCheckBox, new VBox(5, new Label("Chunk Size (characters)"), chunkSizeSpinner)) + VBox chunkingSpinnerVBox = new VBox(12, + jsonProcessingControls, + new HBox(12, enableCSVCheckBox, labelFromColumnCheckBox, selectColumnHBox), + new HBox(16, breakOnNewLineCheckBox, + new VBox(5, new Label("Chunk Size (characters)"), chunkSizeSpinner)) ); GridPane servicesGrid = new GridPane(20, 10); @@ -976,16 +1049,41 @@ public HyperdrivePane(Scene scene, Pane parent) { }); Button testEmbeddingsImageButton = new Button("Test Image"); testEmbeddingsImageButton.setOnAction(e -> { + final String model = currentEmbeddingsModel; + if (model == null || model.isBlank()) { + notifyTerminalWarning("Select an embeddings model before testing image embeddings.", scene); + return; + } try { - List inputIDs = new ArrayList<>(); - inputIDs.add(EMBEDDINGS_IMAGE_TESTID); - List inputs = new ArrayList<>(); - inputs.add(imageUrlFromImage.apply(ResourceUtils.load3DTextureImage("carl-b-portrait"))); - new RequestEmbeddingsTask(scene, currentEmbeddingsModel). - requestEmbeddings(inputs, inputIDs); + // Use the same managed REST path and request payload as production image batches, + // without enqueueing a test into the user's active image processing job. + EmbeddingsImageBatchInput input = new EmbeddingsImageBatchInput(); + input.setInput(List.of(imageUrlFromImage.apply( + ResourceUtils.load3DTextureImage("carl-b-portrait")))); + input.setDimensions(512); + input.setEmbedding_type("all"); + input.setEncoding_format("float"); + input.setModel(model); + input.setUser("string"); + notifyTerminalWarning("Sent Image Embeddings Request test using Carl-b.", scene); - } catch (IOException ex) { - LOG.error(null, ex); + RestAccessLayer.requestManagedImageEmbeddings(input, requestTimeoutMS, (output, error) -> { + Exception failure = error; + if (failure == null) { + try { + EmbeddingBatchResponseValidator.validate(output, 1); + } catch (RuntimeException ex) { + failure = ex; + } + } + if (failure != null) { + reportImageEmbeddingsTestFailure(model, failure); + } else { + notifyTerminalSuccess("Image Embeddings Test Successful: " + model, scene); + } + }); + } catch (IOException | RuntimeException ex) { + reportImageEmbeddingsTestFailure(model, ex); } }); Button testTextEmbeddingButton = new Button("Test Text"); @@ -1230,6 +1328,7 @@ public HyperdrivePane(Scene scene, Pane parent) { }); scene.getRoot().addEventHandler(RestEvent.NEW_CHAT_COMPLETION, event -> { ChatCompletionsOutput output = (ChatCompletionsOutput) event.object; + if (handleTopicSummaryResponse(output)) return; //@DEBUG SMP //String msg = "Received " + output.getChoices().size() + " Chat Choices at " + LocalDateTime.now(); outstandingRequests.put(output.getRequestNumber(), REQUEST_STATUS.SUCCEEDED); @@ -1262,7 +1361,12 @@ public HyperdrivePane(Scene scene, Pane parent) { } }); scene.getRoot().addEventHandler(RestEvent.ERROR_CHAT_COMPLETIONS, event -> { - int request = (int) event.object2; + // ChatCompletionCallback puts request number in object, input ID in object2. + int request = (int) event.object; + if (pendingTopicSummaries.remove(request) != null) { + notifyTerminalError("Topic summarization request failed.", scene); + return; + } outstandingRequests.put(request, REQUEST_STATUS.FAILED); }); scene.getRoot().addEventHandler(RestEvent.NEW_EMBEDDINGS_TEXT, event -> { @@ -1478,6 +1582,26 @@ private void refreshTextFileCount() { textFilesCountLabel.setText(Integer.toString(textFilesList.size())); } + /** Classify unsupported image-capability errors as user-actionable warnings. */ + private void reportImageEmbeddingsTestFailure(String model, Exception error) { + String details = error.getMessage() == null ? error.toString() : error.getMessage(); + String lower = details.toLowerCase(Locale.ROOT); + boolean unsupportedImageModel = lower.contains("not a multimodal model") + || lower.contains("does not support image") + || lower.contains("doesn't support image") + || lower.contains("image input is not supported"); + if (unsupportedImageModel) { + LOG.warn("Image Embeddings Test rejected model '{}': {}", model, + details.substring(0, Math.min(details.length(), 4096))); + notifyTerminalWarning("Image Embeddings Test: '" + model + + "' does not support images. Select a multimodal embedding model.", scene); + } else { + LOG.error("Image Embeddings Test failed for model '{}'", model, error); + notifyTerminalError("Image Embeddings Test failed for '" + model + + "'. See trinity.log for details.", scene); + } + } + private void updateImageEmbeddingsProgress() { if (imageOutstandingRequests.isEmpty()) return; int total = imageOutstandingRequests.size(); @@ -1627,7 +1751,172 @@ private static double[] toFiniteEmbedding(List values) { return vector; } - /** Only invoked on the FX thread: snapshot data before launching background computation. */ + /** Clustering runs against snapshot copies of N-dimensional embeddings, never 3D projections. */ + private void discoverTextTopics() { + List source = topicDiscoveryPane.selectedOnly() + ? List.copyOf(textEmbeddingsListView.getSelectionModel().getSelectedItems()) + : List.copyOf(textEmbeddingsListView.getItems()); + if (source.isEmpty()) { + showTextSimilarityAlert(Alert.AlertType.WARNING, "No Text Available", + "Load embedded text chunks (or select some chunks) before discovering topics."); + return; + } + // Determine a common model dimension; exclude incomplete, invalid and mismatched inputs. + Map dimensions = new HashMap<>(); + for (EmbeddingsTextListItem item : source) { + double[] vector = toFiniteEmbedding(item.getFeatureVector().getData()); + if (item.embeddingsReceived() && TextLandmarkSimilarityEngine.usable(vector) + && Arrays.stream(vector).anyMatch(value -> value != 0.0)) + dimensions.merge(vector.length, 1, Integer::sum); + } + if (dimensions.isEmpty()) { + showTextSimilarityAlert(Alert.AlertType.WARNING, "No Valid Text Embeddings", + "Request text embeddings before discovering topics."); + return; + } + int dimension = dimensions.entrySet().stream().max(Map.Entry.comparingByValue()).orElseThrow().getKey(); + List samples = new ArrayList<>(); + for (EmbeddingsTextListItem item : source) { + double[] vector = toFiniteEmbedding(item.getFeatureVector().getData()); + if (!item.embeddingsReceived() || !TextLandmarkSimilarityEngine.usable(vector) + || vector.length != dimension || Arrays.stream(vector).allMatch(value -> value == 0.0)) continue; + samples.add(new TopicDiscoveryEngine.Sample(item.textID, item.contents, vector)); + } + topicDiscoveryPane.discover(List.copyOf(samples), source.size() - samples.size(), currentEmbeddingsModel); + } + + private void installDiscoveredTextLandmarks(List approved) { + int added = 0; + for (var topic : approved) { + if (!topicDiscoveryPane.stillContains(topic)) continue; + String name = topic.name(); + boolean duplicate = landmarkTextBuilderBox.getItems().stream() + .anyMatch(item -> item.getFeatureVectorLabel().equalsIgnoreCase(name)); + if (duplicate) continue; + List centroid = Arrays.stream(topic.centroid()).boxed().toList(); + landmarkTextBuilderBox.addDiscoveredTopic(name, topic.summary(), centroid, topic.size(), + topicDiscoveryPane.sourceEmbeddingModel()); + added++; + } + if (added == 0) notifyTerminalWarning("No new topic landmarks generated (duplicates or no selections).", scene); + else notifyTerminalSuccess("Generated " + added + " discovered text landmarks; classification remains manual.", scene); + } + + private void summarizeDiscoveredTextTopic(TopicDiscoveryEngine.Topic topic) { + if (currentChatModel == null || currentChatModel.isBlank()) { + showTextSimilarityAlert(Alert.AlertType.WARNING, "No Chat Model", + "Select a chat completion model in the Services tab to summarize topics."); + return; + } + if (!topicDiscoveryPane.stillContains(topic)) return; + if (pendingTopicSummaries.values().stream().anyMatch(p -> p.topic() == topic)) { + notifyTerminalWarning("A summary request for this topic is already in flight.", scene); + return; + } + if (RestAccessLayer.restAccessLayerconfig == null + || RestAccessLayer.restAccessLayerconfig.getBaseRestURL() == null + || RestAccessLayer.restAccessLayerconfig.getChatCompletionEndpoint() == null) { + showTextSimilarityAlert(Alert.AlertType.WARNING, "Chat Service Not Configured", + "Configure the chat completion endpoint in the Services tab before summarizing topics."); + return; + } + StringBuilder prompt = new StringBuilder("You are labeling a discovered cluster of document passages. " + + "Return exactly two lines: TITLE: a concise neutral category name; " + + "SUMMARY: a factual one-sentence description grounded ONLY in the excerpts. " + + "Do not invent details.\nKeywords: " + String.join(", ", topic.keywords())); + for (String passage : topic.representative()) prompt.append("\nEXCERPT: ").append(passage); + ChatCompletionsInput input = new ChatCompletionsInput(); + ChatMessage message = new ChatMessage(); + message.setRole("user"); + Content content = new Content(); + content.setTypeByEnum(Content.TYPE_ENUM.text); + content.setText(prompt.toString()); + message.addContent(content); + input.getMessages().add(message); + input.setModel(currentChatModel); + input.setMax_tokens(1024); + input.setTemperature(0.1); + int requestId = requestNumber.getAndIncrement(); + pendingTopicSummaries.put(requestId, new PendingTopicSummary(topic, topic.name())); + try { + // Negative input ID keeps topic responses distinct from image-caption responses. + RestAccessLayer.requestChatCompletion(input, scene, -1000000 - requestId, requestId); + notifyTerminalWarning("Requested chat-model summary for " + topic.name() + ".", scene); + } catch (Exception ex) { + pendingTopicSummaries.remove(requestId); + LOG.error("Unable to summarize discovered topic", ex); + notifyTerminalError("Unable to submit topic summarization request.", scene); + } + } + + private boolean handleTopicSummaryResponse(ChatCompletionsOutput output) { + if (output == null) return false; + PendingTopicSummary pending = pendingTopicSummaries.remove(output.getRequestNumber()); + if (pending == null) return false; + if (!topicDiscoveryPane.stillContains(pending.topic()) + || !Objects.equals(pending.topic().name(), pending.titleAtRequest())) { + notifyTerminalWarning("Ignored stale topic summary response.", scene); + return true; + } + String text = null; + if (output.getChoices() != null && !output.getChoices().isEmpty()) { + var choice = output.getChoices().get(0); + text = choice.getMessage() != null ? choice.getMessage().getContent() : null; + if (text == null || text.isBlank()) text = choice.getText(); + if (text == null || text.isBlank()) text = choice.getRaw(); + } + + if (text == null || text.isBlank()) { + var choice = output.getChoices() == null || output.getChoices().isEmpty() + ? null : output.getChoices().get(0); + + String finishReason = choice == null ? null : choice.getFinish_reason(); + String reasoning = choice == null || choice.getMessage() == null + ? null : choice.getMessage().getReasoning_content(); + Integer completionTokens = output.getUsage() == null + ? null : output.getUsage().getCompletion_tokens(); + LOG.warn( + "Empty topic summary: model={}, request={}, finish_reason={}, " + + "completion_tokens={}, reasoning_chars={}", + output.getModel(), + output.getRequestNumber(), + finishReason, + completionTokens, + reasoning == null ? 0 : reasoning.length() + ); + + if ("length".equalsIgnoreCase(finishReason)) { + notifyTerminalWarning( + "Topic summary exceeded the model's output token limit.", + scene + ); + } else { + notifyTerminalError( + "Chat model returned an empty topic summary.", + scene + ); + } + return true; + } + + String name = null; + String summary = null; + for (String line : text.split("\\R")) { + String value = line.strip(); + if (value.regionMatches(true, 0, "TITLE:", 0, 6)) name = value.substring(6).strip(); + if (value.regionMatches(true, 0, "SUMMARY:", 0, 8)) summary = value.substring(8).strip(); + } + if (name != null && !name.isBlank()) pending.topic().setName(name); + if (summary != null && !summary.isBlank()) pending.topic().setSummary(summary); + else pending.topic().setSummary(text.strip()); + topicDiscoveryPane.refreshDisplay(); + notifyTerminalSuccess("Topic summary received: " + pending.topic().name(), scene); + return true; + } + + /** Only invoked on the FX thread: snapshot data before launching background computation. + * @param items + * @param landmarks */ public void requestTextLandmarkSimilarityTask( List items, List landmarks) { if (items == null || items.isEmpty()) { @@ -1778,7 +2067,24 @@ public void requestCaptionsTask(List items) { public void loadTextTask(List files) { if (files == null || files.isEmpty()) return; - LoadTextTask loadTask = new LoadTextTask(scene, textEmbeddingRequestIndicator, files); + String jsonMode = jsonProcessingModeChoice == null ? "Disabled" : jsonProcessingModeChoice.getValue(); + if ("Schema-Mapped".equals(jsonMode) + && (activeJsonProfile == null || !activeJsonProfile.hasEmbeddingFields())) { + Alert alert = new Alert(Alert.AlertType.WARNING, + "Configure a JSON extraction profile and select at least one embedding field before importing.", ButtonType.OK); + alert.setTitle("Hyperdrive JSON Processing"); + alert.setHeaderText("JSON mapping is not configured"); + alert.getDialogPane().getStylesheets().add( + StyleResourceProvider.getResource("dialogstyles.css").toExternalForm()); + alert.showAndWait(); + return; + } + TextChunkParser.Options previous = EmbeddingsTextListItem.parsingOptions(); + TextChunkParser.Options jobOptions = new TextChunkParser.Options( + !"Disabled".equals(jsonMode), previous.csvExpansion(), previous.labelFromCsvColumn(), + previous.csvLabelColumn(), previous.breakOnNewlines(), previous.chunkSize(), + "Schema-Mapped".equals(jsonMode) ? activeJsonProfile.copy() : null); + LoadTextTask loadTask = new LoadTextTask(scene, textEmbeddingRequestIndicator, files, jobOptions); loadTask.setOnFailed(event -> { LOG.error("Unable to import text files", loadTask.getException()); textEmbeddingRequestIndicator.spin(false); diff --git a/src/main/java/edu/jhuapl/trinity/javafx/components/panes/LitPathPane.java b/src/main/java/edu/jhuapl/trinity/javafx/components/panes/LitPathPane.java index 52ab58c0..cb6f8734 100644 --- a/src/main/java/edu/jhuapl/trinity/javafx/components/panes/LitPathPane.java +++ b/src/main/java/edu/jhuapl/trinity/javafx/components/panes/LitPathPane.java @@ -49,8 +49,8 @@ public class LitPathPane extends PathPane { Scene scene; Pane parent; boolean animating = false; - boolean fadeEnabled = true; - private boolean opaqueEnabled = false; + boolean fadeEnabled = false; + private boolean opaqueEnabled = true; double fadeSideInset = -5; double hoverTopInset = -2; double hoverSideInset = -3; @@ -183,6 +183,9 @@ public LitPathPane(Scene scene, Pane parent, int width, int height, Pane userCon opaqueBackground = new Background(new BackgroundFill( Color.BLACK, CornerRadii.EMPTY, Insets.EMPTY)); defaultBackground = mainContentBorderFrame.getBackground(); + // New floating panes start opaque; the user can still toggle transparency. + setOpaqueEnabled(true); + contentPane.setOpacity(1.0); } public void setEffects() { @@ -223,6 +226,11 @@ CornerRadii.EMPTY, new BorderWidths(1), }); fadeLabel.setEffect(glow); fadeLabel.setBackground(background); + if (fadeEnabled) { + fadeLabel.setEffect(glow); + fadeLabel.setBackground(background); + fadeLabel.setBorder(activeBorder); + } fadeLabel.setOnMouseClicked(e -> { fadeEnabled = !fadeEnabled; if (fadeEnabled) { @@ -233,6 +241,7 @@ CornerRadii.EMPTY, new BorderWidths(1), fadeLabel.setEffect(null); fadeLabel.setBackground(null); fadeLabel.setBorder(null); + contentPane.setOpacity(1.0); } }); @@ -264,7 +273,7 @@ CornerRadii.EMPTY, new BorderWidths(1), gradientTimeline.playFromStart(); } } else - contentPane.setOpacity(0.8); + contentPane.setOpacity(1.0); }); addEventHandler(MouseEvent.MOUSE_EXITED, e -> { lastInsideMillis = System.currentTimeMillis(); @@ -272,7 +281,7 @@ CornerRadii.EMPTY, new BorderWidths(1), fade(100, 0.3); this.outerFrame.setFill(Color.TRANSPARENT); } else - contentPane.setOpacity(0.8); + contentPane.setOpacity(1.0); }); } diff --git a/src/main/java/edu/jhuapl/trinity/javafx/events/HyperdriveEvent.java b/src/main/java/edu/jhuapl/trinity/javafx/events/HyperdriveEvent.java index 2a4a3d09..aa25d4a4 100644 --- a/src/main/java/edu/jhuapl/trinity/javafx/events/HyperdriveEvent.java +++ b/src/main/java/edu/jhuapl/trinity/javafx/events/HyperdriveEvent.java @@ -20,6 +20,17 @@ public class HyperdriveEvent extends Event { public static final EventType NEW_BATCH_IMAGELOAD = new EventType(ANY, "NEW_BATCH_IMAGELOAD"); public static final EventType NEW_BATCH_TEXTLOAD = new EventType(ANY, "NEW_BATCH_TEXTLOAD"); + // Floating workspace requests and results. Pane ownership belongs to AppAsyncManager. + public static final EventType OPEN_JSON_SCHEMA_MAPPING = new EventType<>(ANY, "OPEN_JSON_SCHEMA_MAPPING"); + public static final EventType JSON_SCHEMA_MAPPING_APPLIED = new EventType<>(ANY, "JSON_SCHEMA_MAPPING_APPLIED"); + public static final EventType OPEN_TOPIC_REVIEW = new EventType<>(ANY, "OPEN_TOPIC_REVIEW"); + public static final EventType CLOSE_TOPIC_REVIEW = new EventType<>(ANY, "CLOSE_TOPIC_REVIEW"); + public static final EventType REQUEST_TOPIC_DISCOVERY = new EventType<>(ANY, "REQUEST_TOPIC_DISCOVERY"); + public static final EventType TOPIC_REVIEW_UPDATED = new EventType<>(ANY, "TOPIC_REVIEW_UPDATED"); + public static final EventType REFRESH_TOPIC_REVIEW = new EventType<>(ANY, "REFRESH_TOPIC_REVIEW"); + public static final EventType SUMMARIZE_DISCOVERED_TOPIC = new EventType<>(ANY, "SUMMARIZE_DISCOVERED_TOPIC"); + public static final EventType GENERATE_TOPIC_LANDMARKS = new EventType<>(ANY, "GENERATE_TOPIC_LANDMARKS"); + public HyperdriveEvent(EventType arg0) { super(arg0); } diff --git a/src/main/java/edu/jhuapl/trinity/messages/ChatCompletionCallback.java b/src/main/java/edu/jhuapl/trinity/messages/ChatCompletionCallback.java index 53464700..291a4250 100644 --- a/src/main/java/edu/jhuapl/trinity/messages/ChatCompletionCallback.java +++ b/src/main/java/edu/jhuapl/trinity/messages/ChatCompletionCallback.java @@ -24,10 +24,15 @@ public ChatCompletionCallback(Scene scene, int inputID, int requestNumber) { @Override public void onFailure() { - LOG.error("REST RequestNumber: " + requestNumber + " returned with HTTP failure code."); - ErrorEvent error = new ErrorEvent(ErrorEvent.REST_ERROR, getClass().getName() + " has failed."); - scene.getRoot().fireEvent(error); + String message = "Chat completion request " + requestNumber + + " failed (HTTP or transport error)."; + LOG.error(message); + reportFailure(message); + } + + private void reportFailure(String message) { Platform.runLater(() -> { + scene.getRoot().fireEvent(new ErrorEvent(ErrorEvent.REST_ERROR, message)); scene.getRoot().fireEvent(new RestEvent(RestEvent.ERROR_CHAT_COMPLETIONS, requestNumber, inputID)); }); } @@ -46,7 +51,14 @@ protected void processResponse(String responseBodyString) { scene.getRoot().fireEvent(new RestEvent(RestEvent.NEW_CHAT_COMPLETION, output)); }); } catch (JsonProcessingException ex) { - LOG.error(null, ex); + LOG.error("Cannot deserialize chat completion response for request {}", requestNumber, ex); + + // TEMPORARY DIAGNOSTIC: Log the unmodified JSON response + LOG.error("Raw Chat Completion JSON for request {}:\n{}", + requestNumber, responseBodyString); + + reportFailure("Chat completion response could not be deserialized for request " + + requestNumber + ": " + ex.getOriginalMessage()); } } } diff --git a/src/main/java/edu/jhuapl/trinity/messages/EmbeddingsImageCallback.java b/src/main/java/edu/jhuapl/trinity/messages/EmbeddingsImageCallback.java index 00eca460..18673d89 100644 --- a/src/main/java/edu/jhuapl/trinity/messages/EmbeddingsImageCallback.java +++ b/src/main/java/edu/jhuapl/trinity/messages/EmbeddingsImageCallback.java @@ -65,14 +65,13 @@ protected void processResponse(String responseBodyString) { } /** - * Utility to retrieve and invoke the completion callback for this request. + * Completion handlers are optional for legacy, event-driven callers. + * Managed embedding requests use their own completion callback directly. */ private void notifyCompletion(boolean success, Exception ex) { BiConsumer callback = completionCallbacks.remove(requestNumber); if (callback != null) { callback.accept(success, ex); - } else { - LOG.warn("No completion callback registered for requestNumber {}.", requestNumber); } } } diff --git a/src/main/java/edu/jhuapl/trinity/messages/RestAccessLayer.java b/src/main/java/edu/jhuapl/trinity/messages/RestAccessLayer.java index 900c98ec..1bfd2b6c 100644 --- a/src/main/java/edu/jhuapl/trinity/messages/RestAccessLayer.java +++ b/src/main/java/edu/jhuapl/trinity/messages/RestAccessLayer.java @@ -235,6 +235,14 @@ public static void requestImageEmbeddings(EmbeddingsImageBatchInput input, httpClient.sendAsync(request, BodyHandlers.ofString()) .thenAcceptAsync(resp -> { if (resp.statusCode() != SUCCESS_STATUS_CODE) { + String errorBody = resp.body(); + LOG.error( + "Image Embeddings Test failed. Model: {}, HTTP Status: {}, Response: {}", + input.getModel(), + resp.statusCode(), + errorBody == null ? "" : + errorBody.substring(0, Math.min(errorBody.length(), 4096)) + ); Platform.runLater(() -> new EmbeddingsImageCallback(scene, inputIDs, requestNumber).onFailure()); } else { new EmbeddingsImageCallback(scene, inputIDs, requestNumber).processResponse(resp.body()); diff --git a/src/test/java/edu/jhuapl/trinity/javafx/components/hyperdrive/JsonExtractionProfileTest.java b/src/test/java/edu/jhuapl/trinity/javafx/components/hyperdrive/JsonExtractionProfileTest.java new file mode 100644 index 00000000..c6ba4598 --- /dev/null +++ b/src/test/java/edu/jhuapl/trinity/javafx/components/hyperdrive/JsonExtractionProfileTest.java @@ -0,0 +1,80 @@ +package edu.jhuapl.trinity.javafx.components.hyperdrive; + +import com.fasterxml.jackson.databind.ObjectMapper; +import org.junit.jupiter.api.Test; + +import java.io.File; +import java.util.List; + +import static org.junit.jupiter.api.Assertions.*; + +class JsonExtractionProfileTest { + private static final ObjectMapper MAPPER = new ObjectMapper(); + private static final String SOURCE = """ + [{ + "properties": { + "definition": "The desired probability-of-kill of air-to-surface targets.", + "datatype": "NUMERIC(4,2)", + "note": "A separate table is used for varying target types." + }, + "entities_containing_the_attribute": [{ + "properties": {"field": "DesiredPk"}, "entity_name": "Air_Command" + }], + "attribute_name": "A2S_Desired_Kill_Probability" + }] + """; + + private static JsonExtractionProfile profile() { + JsonExtractionProfile p = new JsonExtractionProfile(); + p.setName("Attribute definitions"); + p.setFields(List.of( + new JsonExtractionProfile.FieldMapping("attribute_name", true, true, false), + new JsonExtractionProfile.FieldMapping("properties.definition", true, false, false), + new JsonExtractionProfile.FieldMapping("properties.note", true, false, false), + new JsonExtractionProfile.FieldMapping("properties.datatype", false, false, true), + new JsonExtractionProfile.FieldMapping("entities_containing_the_attribute[].entity_name", false, false, true) + )); + return p; + } + + @Test void discoversNestedFieldPathsAndArrayPaths() throws Exception { + var paths = JsonExtractionProfile.discoverPaths(MAPPER.readTree(SOURCE)); + assertTrue(paths.contains("properties.definition")); + assertTrue(paths.contains("entities_containing_the_attribute[].entity_name")); + assertTrue(paths.contains("attribute_name")); + } + + @Test void mappedImportPreservesOnlySelectedSemanticContent() throws Exception { + var chunks = TextChunkParser.parseText(new File("attributes.json"), SOURCE, + new TextChunkParser.Options(true, false, false, 0, false, 16384, profile())); + assertEquals(1, chunks.size()); + var chunk = chunks.get(0); + assertEquals("A2S_Desired_Kill_Probability", chunk.label()); + assertTrue(chunk.text().contains("desired probability-of-kill")); + assertTrue(chunk.text().contains("separate table")); + assertFalse(chunk.text().contains("entities_containing_the_attribute")); + assertFalse(chunk.text().contains("NUMERIC")); + assertEquals("NUMERIC(4,2)", chunk.metadata().get("properties.datatype")); + assertEquals("Air_Command", chunk.metadata().get("entities_containing_the_attribute[].entity_name")); + assertEquals("0", chunk.metadata().get("source_json_index")); + } + + @Test void emptyMappingDoesNotFallbackToRawJson() { + JsonExtractionProfile p = new JsonExtractionProfile(); + p.setFields(List.of(new JsonExtractionProfile.FieldMapping("missing.path", true, false, false))); + assertThrows(java.io.IOException.class, + () -> TextChunkParser.parseText(new File("attributes.json"), SOURCE, + new TextChunkParser.Options(true, false, false, 0, false, 16384, p))); + } + + @Test void profileJacksonRoundTripAndJobSnapshot() throws Exception { + JsonExtractionProfile original = profile(); + JsonExtractionProfile copy = original.copy(); + original.getFields().get(0).setEmbedding(false); + assertTrue(copy.getFields().get(0).isEmbedding()); + JsonExtractionProfile roundTrip = MAPPER.readValue( + MAPPER.writeValueAsString(copy), JsonExtractionProfile.class); + assertTrue(roundTrip.getFields().get(0).isEmbedding()); + assertEquals("Attribute definitions", roundTrip.getName()); + } +} diff --git a/src/test/java/edu/jhuapl/trinity/javafx/components/hyperdrive/TopicDiscoveryEngineTest.java b/src/test/java/edu/jhuapl/trinity/javafx/components/hyperdrive/TopicDiscoveryEngineTest.java new file mode 100644 index 00000000..e18cabfc --- /dev/null +++ b/src/test/java/edu/jhuapl/trinity/javafx/components/hyperdrive/TopicDiscoveryEngineTest.java @@ -0,0 +1,66 @@ +package edu.jhuapl.trinity.javafx.components.hyperdrive; + +import edu.jhuapl.trinity.utils.clustering.ClusterMethod; +import org.junit.jupiter.api.Test; + +import java.util.ArrayList; +import java.util.List; + +import static org.junit.jupiter.api.Assertions.*; + +class TopicDiscoveryEngineTest { + private static List sampleData() { + return List.of( + new TopicDiscoveryEngine.Sample(1, "satellite signal interference", new double[]{1, 0, 0}), + new TopicDiscoveryEngine.Sample(2, "signal jamming interference", new double[]{0.98, 0.02, 0}), + new TopicDiscoveryEngine.Sample(3, "battery power anomaly", new double[]{0, 1, 0}), + new TopicDiscoveryEngine.Sample(4, "power battery failure", new double[]{0.02, 0.98, 0}) + ); + } + + @Test void discoversCentroidsOnOriginalVectorDimensions() { + var result = TopicDiscoveryEngine.discover(sampleData(), + new TopicDiscoveryEngine.Options(ClusterMethod.KMEANS, 2, 2, 2, 0.5, 100)); + assertEquals(2, result.topics().size()); + assertEquals(4, result.topics().stream().mapToInt(TopicDiscoveryEngine.Topic::size).sum()); + for (var topic : result.topics()) { + assertEquals(3, topic.centroid().length); + assertTrue(java.util.Arrays.stream(topic.centroid()).allMatch(Double::isFinite)); + assertFalse(topic.representative().isEmpty()); + assertTrue(Double.isFinite(topic.cohesion())); + assertTrue(Double.isFinite(topic.distinctiveness())); + } + } + + @Test void mergeCombinesMembersAndDoesNotMutateExistingTopics() { + var result = TopicDiscoveryEngine.discover(sampleData(), + new TopicDiscoveryEngine.Options(ClusterMethod.KMEANS, 2, 2, 2, 0.5, 100)); + var original = result.topics(); + var merged = TopicDiscoveryEngine.merge(1000, original); + assertEquals(4, merged.size()); + assertEquals(2, original.size()); + assertNotSame(original.get(0).centroid(), merged.centroid()); + assertEquals(4, merged.members().stream().map(TopicDiscoveryEngine.Sample::id).distinct().count()); + } + + @Test void rejectedInvalidOrIncompatibleEmbeddings() { + var inputs = new ArrayList<>(sampleData()); + inputs.add(new TopicDiscoveryEngine.Sample(5, "bad dimensions", new double[]{1, 2})); + assertThrows(IllegalArgumentException.class, () -> TopicDiscoveryEngine.discover(inputs, + new TopicDiscoveryEngine.Options(ClusterMethod.KMEANS, 2, 2, 2, 0.5, 100))); + inputs.remove(4); + inputs.add(new TopicDiscoveryEngine.Sample(5, "zero", new double[]{0, 0, 0})); + assertThrows(IllegalArgumentException.class, () -> TopicDiscoveryEngine.discover(inputs, + new TopicDiscoveryEngine.Options(ClusterMethod.KMEANS, 2, 2, 2, 0.5, 100))); + } + + @Test void topicTitleEditsDoNotModifyNumericPrototypes() { + var topic = TopicDiscoveryEngine.discover(sampleData(), + new TopicDiscoveryEngine.Options(ClusterMethod.KMEANS, 2, 2, 2, 0.5, 100)).topics().get(0); + double[] original = topic.centroid(); + topic.setName("Reviewed Category"); + topic.setSummary("An analyst-supplied description"); + assertArrayEquals(original, topic.centroid()); + assertEquals("Reviewed Category", topic.name()); + } +} diff --git a/src/test/java/edu/jhuapl/trinity/javafx/components/hyperdrive/TopicKeywordContrastTest.java b/src/test/java/edu/jhuapl/trinity/javafx/components/hyperdrive/TopicKeywordContrastTest.java new file mode 100644 index 00000000..c6f12219 --- /dev/null +++ b/src/test/java/edu/jhuapl/trinity/javafx/components/hyperdrive/TopicKeywordContrastTest.java @@ -0,0 +1,27 @@ +package edu.jhuapl.trinity.javafx.components.hyperdrive; + +import edu.jhuapl.trinity.utils.clustering.ClusterMethod; +import org.junit.jupiter.api.Test; +import java.util.List; +import static org.junit.jupiter.api.Assertions.*; + +class TopicKeywordContrastTest { + @Test void sharedSchemaTermsDoNotDominateAllTopicNames() { + var samples = List.of( + new TopicDiscoveryEngine.Sample(1, "attribute containing properties satellite trajectory orbital", new double[]{1, 0}), + new TopicDiscoveryEngine.Sample(2, "attribute containing properties orbital satellite trajectory", new double[]{.99, .01}), + new TopicDiscoveryEngine.Sample(3, "attribute containing properties trajectory orbital satellite", new double[]{.98, .02}), + new TopicDiscoveryEngine.Sample(4, "attribute containing properties intrusion network firewall", new double[]{0, 1}), + new TopicDiscoveryEngine.Sample(5, "attribute containing properties network intrusion firewall", new double[]{.01, .99}), + new TopicDiscoveryEngine.Sample(6, "attribute containing properties firewall intrusion network", new double[]{.02, .98}) + ); + var result = TopicDiscoveryEngine.discover(samples, + new TopicDiscoveryEngine.Options(ClusterMethod.KMEANS, 2, 2, 2, 0.5, 100)); + assertEquals(2, result.topics().size()); + for (var topic : result.topics()) { + assertFalse(topic.keywords().isEmpty()); + assertFalse(topic.name().contains("attribute, containing")); + assertFalse(topic.keywords().subList(0, Math.min(2, topic.keywords().size())).contains("attribute")); + } + } +} From fe36dcca98e2b913059d89ca68943cbbc63ebb8b Mon Sep 17 00:00:00 2001 From: Birdasaur Date: Thu, 29 Oct 2026 14:24:54 -0400 Subject: [PATCH 3/3] Hyperdrive context and roadmap documents --- HYPERDRIVE_DEVELOPER_CONTEXT.md | 281 ++++++++++++++++++++++++ HYPERDRIVE_ROADMAP.md | 377 ++++++++++++++++++++++++++++++++ 2 files changed, 658 insertions(+) create mode 100644 HYPERDRIVE_DEVELOPER_CONTEXT.md create mode 100644 HYPERDRIVE_ROADMAP.md diff --git a/HYPERDRIVE_DEVELOPER_CONTEXT.md b/HYPERDRIVE_DEVELOPER_CONTEXT.md new file mode 100644 index 00000000..877e0579 --- /dev/null +++ b/HYPERDRIVE_DEVELOPER_CONTEXT.md @@ -0,0 +1,281 @@ +# Hyperdrive Developer Context and Implementation Guide + +**Purpose:** Provide human and AI-agent developers with a shared understanding of Hyperdrive's workflow, its accepted design decisions, and the constraints to preserve when extending it. + +**Project:** Trinity XAI — Java 25 / JavaFX application +**Subsystem:** Hyperdrive data ingestion, embeddings, topic discovery, landmark classification, and chat/vision service integration +**Document status:** Living development handoff. Update it when a workflow or architectural decision changes. + +--- + +## 1. Mission and scope + +Hyperdrive turns user-provided content into high-dimensional `FeatureVector` data so analysts can explore, categorize, and classify content using Trinity's existing visualization and analysis infrastructure. + +The intended workflow is not only "send content to an embedding endpoint." It includes: + +1. Load and parse text or structured data. +2. Define which source fields represent semantic content and which should remain metadata. +3. Request embeddings while respecting model-service capacity. +4. Discover possible topics from the embeddings when the analyst does not already know suitable categories. +5. Review, name, summarize, and downselect the discovered topics. +6. Generate topic landmarks from the selected clusters. +7. Use the existing distance-based landmark classifier to assign labels to source chunks. +8. Inspect results and refine the analyst's categories or workflow. + +Hyperdrive should preserve source identity and metadata wherever possible. It must not silently substitute raw or structurally noisy input when a configured extraction mapping fails. + +## 2. End-to-end workflow + +### 2.1 Text ingestion and structured JSON extraction + +Text loading starts in Hyperdrive's Text tab. Parsing and chunking should be performed by the non-UI processing layer (`TextChunkParser` and related model classes), not embedded in JavaFX view code. + +For JSON, Services exposes three processing modes: + +- **Disabled:** treat JSON as ordinary text. +- **Legacy:** use the existing automatic/special JSON extraction path. +- **Schema-Mapped:** use a selected `JsonExtractionProfile` to select fields for embedding input, labels, and metadata. + +Schema mapping is configured in the dedicated floating JSON Schema Mapping pane. The pane should support loading a representative JSON sample, discovering nested field paths (including array paths), configuring field roles, previewing the exact extracted embedding text, and saving/loading reusable profiles. + +Schema mapping is applied during import. Changing a profile does not retroactively alter existing `FeatureVector` objects or their embeddings. To change the representation, reimport and regenerate the embeddings. + +A mapping profile separates three concepts: + +- **Embedding text:** fields that contribute semantic content to the model input. +- **Label:** the value used as an initial human-readable record label, if configured. +- **Metadata:** source properties retained for traceability and later inspection but not embedded unless explicitly selected. + +For the database-attribute example, an appropriate initial profile embeds `attribute_name`, `properties.definition`, and `properties.note`; metadata may include datatype, entity name, validation rule, source file, and source field. This is an example, not schema-specific logic that should be hardcoded in the parser. + +If a Schema-Mapped profile has no embedding fields, fail visibly. If extraction yields no semantic text for a record, skip/report it rather than silently falling back to serialized JSON. + +### 2.2 Embedding requests and service capacity + +Services configuration controls request batching and dispatch parameters, including: + +- chunks per embedding request (`batchSize` / Request Batch Size), +- maximum concurrent batches (`maxInFlightBatches` / Max In Flight Batches), +- request pacing delay, +- request timeout. + +Text embedding requests support multiple input strings per request. The request manager must honor the configured batch size and concurrency, pace dispatches, and release in-flight capacity exactly once for every completion/failure/timeout path. + +Text and image embedding workflows use distinct request managers/accounting. Preserve the accepted managed request infrastructure. Avoid reviving the older unbounded one-request-per-chunk text path or creating a second competing throttling mechanism. + +Response association must use stable item identities and the embedding response indices where supplied; never assume a filtered input list remains positionally aligned with the original IDs. + +The standalone Services model-test buttons are not the same thing as production bulk ingestion. Test paths should reuse the managed REST capabilities where appropriate, but should not register their standalone operation as a fake production batch or interfere with a running job. + +### 2.3 Topic discovery + +Topic discovery runs on already embedded text chunks. It uses the **original N-dimensional embedding vectors**, not UMAP coordinates or other 2D/3D projections. Projection coordinates can distort distances and must not become the input to semantic clustering. + +The implementation uses Trinity's existing clustering library/implementations. Current method choices include K-Means, K-Medoids, DBSCAN, HDBSCAN (enum spelling `HDDBSCAN`), Affinity Propagation, and Gaussian Mixture (`EX_MAX`). Parameter controls are method-specific. + +`TopicDiscoveryEngine` is intended to be Java-only and independent of JavaFX so it can run in a worker thread and be tested separately. Its main data model includes: + +- `Sample`: source chunk ID, source text, embedding vector. +- `Options`: cluster method and algorithm parameters. +- `Result`: discovered topics, noise count, and sample count. +- `Topic`: members, centroid, representative text, keywords, cohesion, distinctiveness, name, summary, and inclusion state. + +The implementation normalizes a working copy of input vectors for clustering. Topic centroid prototypes are computed from the original vectors so that the generated landmark remains on the same embedding scale as the source data and remains compatible with the user-selected landmark distance metric. + +Topic representation is distinct from clustering: + +- Clustering determines which chunks are members of each topic. +- Contrastive / class-based TF-IDF-style keyword ranking finds terms that distinguish one cluster from the others. +- Optional chat-model summarization creates a readable title and one-sentence description from keywords and representative passages. + +The default topic names are placeholders plus keyword suggestions until a human edits the title or the chat model returns a summary. LLM summarization must not be treated as proof that all cluster members are coherent; representative excerpts need review. + +K-Means normally returns the requested number of clusters when its input/parameters are valid. Density-based methods may produce fewer topics or noise points. A valid empty/low-topic result must be reported rather than treated automatically as an exception. + +### 2.4 Topic review and landmark generation + +The Topic Discovery Review view is a **floating `LitPathPane` overlay**, not a modal JavaFX `Dialog`. The JSON Schema Mapping view is also a floating overlay. Overlay lifecycle is managed by `AppAsyncManager`, and panes communicate with Hyperdrive through `HyperdriveEvent`; new panes should not keep a direct `HyperdrivePane` reference. + +The review workflow supports inspecting a topic, editing its title and description, optionally asking the configured chat model to summarize it, merging selected topics, and toggling whether a topic is included in landmark generation. + +Merging changes membership and recomputes the merged cluster's centroid and representation. Renaming or editing a summary changes descriptive fields, not the vector representation. + +`Generate Selected Landmarks` uses topics whose **Include in generated landmarks** option is checked; it is not limited to the currently highlighted row. A generated landmark carries the discovered topic's centroid vector. It should not be refreshed by embedding its display label, because that would overwrite the cluster-derived prototype with an embedding of the name. + +Generating landmarks does not automatically classify source chunks. Classification remains a separate, explicit analyst action. + +### 2.5 Landmark similarity classification + +Hyperdrive already had vector-distance-based nearest-landmark classification before Topic Discovery was added. The new Java-only similarity engine is a refactoring/hardening of that behavior, not a new similarity algorithm. + +For each selected source chunk, compare its valid embedding to each valid landmark embedding using Trinity's existing `Metric` framework and the selected Landmark Assignment Metric (cosine distance is the typical/default choice). The nearest valid landmark receives the label. + +The engine should validate at least: + +- nonempty usable landmark set, +- actual completed embeddings (not placeholder vectors), +- matching vector dimensionality, +- finite values and finite distance results, +- cosine/angular inputs that are not zero vectors. + +Similarity calculation should occur away from the FX thread. Apply UI changes/results on the JavaFX Application Thread. Validate before mutation and preserve pre-existing labels for chunks not successfully classified or when the operation is cancelled/aborted. + +The current classifier always chooses the nearest valid landmark. A calibrated abstention threshold and a first-class unclassified outcome are future roadmap work, not behavior to assume exists today. + +### 2.6 Chat, vision, and embedding REST integration + +Trinity uses Jackson `ObjectMapper` POJO binding for request/response JSON. Do not replace this with a custom manual response parser or generic `JsonNode` response layer without a strong, reviewed reason. + +The Chat/Vision response model was extended based on a real Qwen response that included: + +- `generation_parameters.stop` as nested arrays (observed shape: `[[""]]`), +- `message.reasoning_content`, +- token `usage` values (`prompt_tokens`, `completion_tokens`, `total_tokens`). + +The response model uses typed classes (including a typed `Stop` wrapper and `ChatUsage`) rather than blindly coercing the response into a string. The outgoing chat request's `stop` field is a separate request concern; do not change it merely because the response's `stop` representation differs. + +Topic summarization needs a sufficient completion budget for reasoning-capable models. Its configured output token allowance was increased to 1024 after the smaller budget produced an empty final content result. `reasoning_content` must not be substituted for the user-facing summary. Diagnostics should inspect finish reason, token usage, reasoning length, and final content presence without logging prompts, document passages, or reasoning text unnecessarily. + +Image embedding requests require an image-capable embedding model. A text-only model can return a gateway HTTP 502 that wraps an upstream HTTP 400 such as `is not a multimodal model`. This is a user/model selection issue: report a `CommandTerminalEvent` warning naming the selected model, rather than asserting the image encoder is broken. Other failures should remain errors with diagnostic details. + +### 2.7 User notifications + +Use Trinity's existing `CommandTerminalEvent` patterns for the bottom-left singleton status line: + +- success notifications for completed operations, +- warnings for user-correctable configuration/capability problems and partial outcomes, +- errors for actual failed operations. + +Use JavaFX Alerts only for blocking preflight validation that needs user intervention before an operation can start. Do not present one modal alert per failed record. Aggregate partial exclusions and errors into one summary and log detailed diagnostics using SLF4J. + +Apply `dialogstyles.css` to actual JavaFX dialogs/Alerts where the codebase convention requires it. The floating JSON Schema Mapping and Topic Review views are `LitPathPane` overlays, not dialogs; do not convert them back to dialogs. Use existing overlay styles and lifecycle patterns. + +--- + +## 3. Key implementation areas + +Paths below are relative to `src/main/java/edu/jhuapl/trinity/` unless stated otherwise. Verify exact class locations in the current checkout before editing; source snapshots and deltas may lag local manual changes. + +| Area | Important implementation | Responsibility | +|---|---|---| +| Main Hyperdrive UI | `javafx/components/panes/HyperdrivePane.java` | Tabs, Services controls, task orchestration, result application, notifications | +| Text parsing | `javafx/components/hyperdrive/TextChunkParser.java` | Format parsing, chunk creation, JSON profile application, CSV handling | +| JSON field model | `javafx/components/hyperdrive/JsonExtractionProfile.java` and related mapping classes | Selected fields, profile persistence, extraction of text/label/metadata | +| JSON overlay | `javafx/components/hyperdrive/JsonSchemaMappingPane.java` in the latest accepted overlay design | Schema inspection, mapping editor, preview, apply/save/load profile | +| Topic discovery | `javafx/components/hyperdrive/TopicDiscoveryEngine.java` | Clustering on embeddings, topic membership, centroids, representative chunks, topic quality/keywords | +| Topic discovery UI | `javafx/components/hyperdrive/TopicDiscoveryPane.java` | Compact discovery controls and candidate list | +| Topic review overlay | `javafx/components/hyperdrive/TopicDiscoveryReviewPane.java` in the latest accepted overlay design | Inspect/edit/merge/summarize/include topics and request landmark generation | +| Similarity engine | `javafx/components/hyperdrive/TextLandmarkSimilarityEngine.java` | Pure-Java input validation and nearest-landmark calculations | +| Similarity task | `javafx/components/hyperdrive/RequestTextLandmarkSimilarityTask.java` | Worker/task coordination and result application lifecycle | +| Request management | `javafx/components/hyperdrive/BatchRequestManager.java`, launchers and REST access layer | Batching, concurrency, pacing, timeout and completion/failure accounting | +| Event decoupling | `javafx/events/HyperdriveEvent.java` | Typed event types/payload transport between panes and coordinator | +| Overlay ownership | `javafx/AppAsyncManager.java` | Create, attach, close and manage floating panes | +| Status line | `javafx/events/CommandTerminalEvent.java` | Success/warning/error status notifications | +| Chat models | `data/messages/llm/*`, `messages/ChatCompletionCallback.java` | Typed Jackson POJOs and asynchronous response processing | + +The class names for the floating views reflect the refactor after the `src(4).zip` snapshot. That archive predates the final overlay conversion and manual import fixes, so **do not treat `src(4).zip` as a complete representation of the currently verified tree**. The latest user-provided source tree and manually accepted fixes are authoritative. + +--- + +## 4. Design and contribution rules + +These rules are requirements for future Hyperdrive work. + +### 4.1 Preserve separation of concerns + +- Keep parsers and pure computation separate from JavaFX controls. +- Perform blocking parsing, clustering, LLM requests, and similarity calculations on background tasks as appropriate. +- Keep JavaFX scene graph updates on the JavaFX Application Thread. +- Treat views as presentation/controller surfaces, not as the source of domain algorithms. +- Avoid mixing source parsing, UI layout, request scheduling, and result classification into a single new class. + +### 4.2 Decouple panes with events + +- Use `HyperdriveEvent` (extend it when appropriate) for communication between Hyperdrive's independently managed panes. +- Let `AppAsyncManager` manage floating pane creation and lifecycle. +- Do not pass a direct `HyperdrivePane` reference into JSON mapping or topic review overlays. +- Add explicit event types and document payload expectations. Avoid opaque event payloads when a small typed payload record/class would make the contract clearer. +- Unregister listeners and close stale overlays on lifecycle transitions, such as rerunning discovery. + +### 4.3 Java code standards + +- **Never use wildcard imports.** Import each class explicitly. +- **Avoid inner classes wherever possible.** Put reusable UI rows, mapping rows, event payloads, and domain models in separate top-level files unless there is a compelling, documented reason. +- Prefer the project's existing Java style, explicit types, meaningful names, and minimal scope changes. +- Don't add dependencies or new frameworks when Trinity already provides an applicable implementation. +- Don't use ad hoc JSON parsing when a correct Jackson POJO mapping is possible. +- Keep request DTOs and response DTOs separate where wire schemas differ. + +### 4.4 JavaFX UI conventions + +- Use `LitPathPane` floating overlays for substantial secondary workspaces, including JSON Schema Mapping and Topic Review. +- Use JavaFX `Dialog`/`Alert` only for concise blocking interaction or validation. +- Apply the established `dialogstyles.css` to dialogs/Alerts as required; overlays should follow the existing `LitPathPane` and stylesheet conventions. +- Keep the Similarity tab's overview controls compact; put detailed mapping and topic review in overlays. +- Avoid introducing an additional main tab for a feature that belongs naturally to an existing workflow. + +### 4.5 Correctness and data integrity + +- Do not silently fall back to raw JSON when Schema-Mapped extraction is misconfigured or yields no useful text. +- Do not embed schema keys or metadata unless selected intentionally as semantic input. +- Preserve source IDs, labels, source paths, chunk/record indices, and selected metadata where the data model supports them. +- Validate response indices and correlate results to their original request items; do not assume a filtered or reordered list preserves positional identity. +- Do not use unembedded placeholder vectors in clustering or similarity. +- Do not overwrite cluster-derived centroid landmarks by embedding their display names. +- Keep topic discovery and landmark generation non-destructive: discovery must not silently relabel source chunks. +- Preserve existing labels when a classification result is invalid, skipped, cancelled, or fails. +- Distinguish a model-capability/user selection issue from an infrastructure/server failure in status messages. + +### 4.6 Testing and verification + +- Add pure Java tests for parser logic, keyword ranking, clustering-result handling, vector validation, and similarity math where practical. +- Test exact-size chunk boundaries, empty content, malformed structured data, nested JSON field paths, mixed line endings, and missing mapping fields. +- For asynchronous REST workflows, test success, HTTP error, connection failure, timeout, cancellation, malformed response, and out-of-order completion. +- When a feature spans multiple panes, test the event contract and overlay lifecycle as well as the pure computation. +- Verify the target runtime/build where possible. Never describe static inspection or an isolated smoke test as a full Trinity build/runtime test. +- Deliver changes as source-only deltas against the latest user-supplied authoritative tree when requested. Include a manifest and clearly list deletions/replacements. + +--- + +## 5. Accepted behavior and current verification + +The following have been reported as working by the user and should be preserved unless a new defect is reported: + +- Text embedding batching and Services-tab request settings. +- Text ingestion corrections covering PDFs, CSV quoting, JSON object/array handling, chunk boundaries, embedding-text consistency, provenance, and FX-thread UI creation. +- Text landmark similarity validation and stale/incomplete embedding handling. +- Topic discovery using original embedding vectors and the existing clustering implementations. +- Schema-mapped JSON field extraction, nested field selection, preview, profiles, labels, and metadata. +- LLM topic summarization after increasing the summary completion budget. +- Typed chat response model additions for nested `generation_parameters.stop`, `reasoning_content`, and token `usage`. +- Image embedding test warning when the selected embedding model is not multimodal. +- Floating-overlay approach for JSON Schema Mapping and Topic Review, with event-based decoupling. +- Topic discovery and selected centroid landmark generation producing useful results for the user's tested dataset. + +Some UI refactor changes were manually corrected by the user after earlier deltas (notably explicit static imports and extraction of `MappingRow` into its own class). Preserve those corrections. The last complete source archive may lag subsequent accepted patches; request/use the most recent source snapshot before any future implementation. + +--- + +## 6. Current known limitations (not assumed to be fixed) + +- Nearest-landmark classification always picks the nearest valid landmark; a calibrated reject/abstain threshold remains proposed work. +- Topic quality measures are useful diagnostics, not proof of semantic coherence or calibrated confidence. +- Chat summaries are based on a small set of representative excerpts, not necessarily every cluster member. +- Topic discovery results/experiments are not yet described as having durable, first-class persistence. Treat a persistent Topic Discovery Result model as a roadmap proposal until verified in the current tree. +- Clustering method quality varies by data and parameters. Density-based methods may label points as noise; K-Means will still partition mixed semantic content if asked to produce a fixed number of groups. +- Do not infer current UI/source behavior from a stale delta or source archive. Inspect the latest source tree first. + +--- + +## 7. Quick checklist before making a Hyperdrive change + +1. Identify the specific workflow stage: ingestion, embedding, discovery, review, landmark generation, classification, or reporting. +2. Inspect the latest source tree and existing event/request/overlay conventions. +3. Determine whether the change affects source data, embeddings, labels, or only display metadata. +4. Preserve stable identity between source records, request items, embedding responses, and resulting FeatureVectors. +5. Keep panes decoupled with events; keep algorithms out of UI classes. +6. Use explicit imports and top-level classes; no wildcard imports. +7. Decide how errors are surfaced: blocking Alert for invalid preflight, status-line summary for handled/partial outcomes, logs for diagnostic detail. +8. Add focused tests, then run the full project build/runtime checks when the build tree is available. +9. Report exactly what was changed and what was actually tested. diff --git a/HYPERDRIVE_ROADMAP.md b/HYPERDRIVE_ROADMAP.md new file mode 100644 index 00000000..8bbc4a05 --- /dev/null +++ b/HYPERDRIVE_ROADMAP.md @@ -0,0 +1,377 @@ +# Hyperdrive Roadmap + +**Purpose:** Capture candidate improvements to Hyperdrive after the successful implementation of structured text extraction, topic discovery, topic summarization, centroid-based landmarks, and existing landmark similarity classification. + +**Status:** Proposed roadmap, not a commitment that these features already exist. Priorities reflect expected analytical value and dependencies. + +--- + +## 1. Current baseline + +Hyperdrive now supports the following end-to-end workflow, verified by runtime testing on the current development dataset: + +1. Import text and structured JSON. +2. Configure JSON field extraction through a reusable schema-mapping profile. +3. Generate embeddings using managed text/image request workflows. +4. Discover candidate topics by clustering original N-dimensional text embeddings. +5. Inspect topic statistics, keywords, and representative passages. +6. Use an LLM to generate a topic name and short description. +7. Merge, edit, include, or exclude candidate topics. +8. Generate landmark prototypes from selected cluster centroids. +9. Use the existing distance-based classifier to assign text chunks to the nearest valid landmark. + +The roadmap below extends this working workflow. Future changes must preserve the accepted data-ingestion, batching, model-response, floating-overlay, event-decoupling, and landmark-similarity behavior. + +## 2. Prioritization overview + +| Priority | Initiative | Main value | Dependencies | +|---|---|---|---| +| P1 | Confidence-aware classification and abstention | Avoid false certainty when a chunk matches no landmark well | Existing similarity engine | +| P1 | Topic quality diagnostics | Distinguish useful semantic clusters from mathematically valid but poor clusters | Existing topic metrics and representatives | +| P2 | Topic experiment comparison | Compare clustering algorithms and parameters without losing prior results | A persistent discovery-result model | +| P2 | Hierarchical topic discovery | Move between broad categories and useful subtopics | Topic hierarchy model and UI | +| P2 | Analyst feedback and prototype refinement | Improve landmarks with human review | Stable record/topic identities and review workflow | +| P3 | Semantic search | Retrieve relevant chunks without requiring a category label | Existing embeddings and model configuration | +| P3 | Duplicate and near-duplicate detection | Detect repetition that distorts topic sizes and review | Text normalization and vector similarity | +| P3 | Richer topic representation | Improve keywords and evidence-grounded summaries | Current c-TF-IDF-style keyword ranking and chat workflow | +| Foundation | Persistent Topic Discovery Result model | Make experiments reproducible and support comparison/refinement | Needed by several P2/P3 items | + +Recommended sequencing: implement P1 classification reliability first, then topic quality diagnostics. Introduce persistence before building substantial experiment comparison or hierarchical workflows. + +--- + +## 3. P1 — Confidence-aware landmark classification + +### Problem + +The existing classifier selects the nearest valid landmark. Even when every landmark is a poor semantic match, the chunk still receives the label of the least-distant option. This can create misleading labels that appear definitive. + +### Proposed capabilities + +- Compute and retain the closest and second-closest landmark distances. +- Report the best-match distance and the margin between the best and runner-up. +- Support a configurable reject/abstain policy that leaves a chunk unclassified when evidence is insufficient. +- Introduce an explicit `Unclassified` result/status rather than inventing a category. +- Identify ambiguous assignments where two or more landmarks are nearly equally close. +- Provide a review list of low-confidence and ambiguous chunks. +- Keep the existing user-selected distance metric and avoid interpreting raw distance as a probability. + +### Design notes + +- Distance thresholds are model- and dataset-dependent. Do not choose a universal threshold without validation. +- A margin score measures separation between candidate landmarks; it is not a calibrated confidence probability. +- Validate thresholds against human-reviewed examples and show the threshold/metric used for each run. +- Preserve original labels for skipped, invalid, cancelled, or failed cases. +- Do not change the existing nearest-landmark result silently. Provide an explicit configuration or mode for abstention. + +### Acceptance criteria + +- A record that is close to no landmark can remain unclassified when configured to do so. +- A record almost equally close to two landmarks is flagged as ambiguous when configured. +- Existing nearest-landmark behavior remains available. +- Reports show counts for classified, unclassified, ambiguous, skipped, and failed records. +- Tests cover missing vectors, incompatible dimensions, invalid numeric values, ties, cancellations, and threshold boundaries. + +--- + +## 4. P1 — Topic quality diagnostics + +### Problem + +A clustering algorithm can produce clusters even when the source data does not contain that many coherent semantic topics. Topic names/summaries alone cannot prove quality. + +### Proposed capabilities + +For each topic, provide: + +- member count and proportion of the selected dataset, +- within-topic cohesion, +- distance/similarity to nearest competing topics, +- representative passages nearest to the centroid, +- examples near the cluster boundary (not only central examples), +- keyword overlap with other topics, +- warning indicators for very small clusters, low cohesion, excessive overlap, or mixed representatives. + +For a whole clustering run, provide: + +- number of topics and noise/unassigned points, +- percentage of source chunks represented, +- topic-size distribution, +- duplicate or near-duplicate topic descriptions, +- optional quality statistics supported by the selected algorithm and metric. + +### Design notes + +- Treat every score as a diagnostic, not a guarantee of semantic truth. +- Explain the meaning and scale of each statistic in the UI. +- Do not compare scores from different metrics or algorithms as if they had identical scales. +- Keep representative text visible so an analyst can judge quality directly. +- LLM-generated descriptions should be visibly distinguished from algorithmic metrics and analyst-edited content. + +### Acceptance criteria + +- An analyst can quickly identify dominant, small, diffuse, and overlapping topics. +- A topic can be flagged for review without automatically being discarded. +- The UI exposes enough evidence to diagnose whether poor labels originate in extraction, embeddings, clustering, or representation. + +--- + +## 5. Foundation — Persistent Topic Discovery Result model + +### Motivation + +Topic discovery runs currently involve a related bundle of source identities, embeddings, cluster assignments, centroids, keywords, summaries, and analyst edits. Persistence would make comparing, revisiting, and refining runs safer and more reproducible. + +### Proposed model + +A durable discovery result should preserve: + +- unique run ID and creation timestamp, +- selected source record/chunk IDs, +- source dataset identity or import-profile reference, +- embedding model identifier and dimensions, +- clustering method and full parameter configuration, +- cluster membership per source record, +- noise/unassigned records, +- centroid/prototype vectors, +- keyword results and topic metrics, +- topic title/summary and whether each is machine-generated or analyst-edited, +- inclusion/exclusion decisions, +- landmarks generated from the run and their provenance. + +### Design notes + +- Define serialization and versioning deliberately. Avoid storing JavaFX nodes in the result model. +- Keep domain results separate from views. +- Preserve the source identity needed to map a saved topic back to the current FeatureCollection. +- Handle stale/missing source IDs explicitly rather than silently remapping by list position. +- Consider storage size for embeddings and repeated content; avoid duplicating large source strings unnecessarily. + +### Acceptance criteria + +- A prior run can be reopened without rerunning clustering or LLM summarization. +- The selected method and parameters are visible and reproducible. +- A saved topic's members and prototype can be traced to source records. +- A subsequent experiment does not overwrite an earlier result. + +--- + +## 6. P2 — Topic experiment comparison + +### Problem + +The number and shape of natural topics are not known in advance. Analysts need to compare parameter sets and algorithms rather than rely on one run. + +### Proposed capabilities + +- Run and retain multiple clustering configurations against the same embedded dataset. +- Compare K-Means settings (for example K=5 versus K=10) with density-based methods such as HDBSCAN. +- Compare topic count, noise rate, size distribution, cohesion, distinctiveness, keyword overlap, representative passages, and summaries. +- Allow the analyst to promote one run as the current accepted topic set. +- Preserve alternate runs so a change of method does not destroy previous manual topic edits. + +### Dependencies + +A persistent Topic Discovery Result model is recommended first. + +### Acceptance criteria + +- Two runs can be compared side by side using the same source dataset. +- The UI clearly flags when runs use different source selections, models, dimensions, or normalization assumptions. +- Promoting a run is explicit and does not immediately relabel source chunks. + +--- + +## 7. P2 — Hierarchical topic discovery + +### Motivation + +A flat category list forces an analyst to choose between broad and narrow categories. Real datasets often have multilevel semantic structure. + +### Proposed capabilities + +- Discover or create parent/child relationships among topics. +- Support broader categories such as `Communications` with child topics such as `RF Interference`, `Link Degradation`, and `Protocol Failures`. +- Allow the analyst to select whether to generate landmarks at the parent level, child level, or a mixed level. +- Show topic size and representative evidence at each level. +- Support expanding and collapsing topic trees in the Review Topics overlay. + +### Possible approaches + +- Hierarchically cluster existing topic centroids. +- Recursively cluster members of a selected topic. +- Let the analyst manually group existing topics into a parent category. + +Start with a controlled, explainable approach rather than assuming an automatically generated hierarchy is always semantically correct. + +### Acceptance criteria + +- A topic can have a parent without losing its original membership/provenance. +- Parent centroids/prototypes have a documented derivation. +- Landmark generation clearly identifies the hierarchy level chosen. +- Merging, renaming, or removing a parent does not silently corrupt child topics. + +--- + +## 8. P2 — Analyst feedback and landmark refinement + +### Motivation + +Topic discovery and landmark classification should become an iterative workflow. Analysts will encounter incorrect labels, mixed topics, missing categories, and useful exceptions. + +### Proposed capabilities + +- Let analysts mark a classification as correct, incorrect, ambiguous, or unclassified. +- Allow moving a reviewed source chunk to a different category. +- Select verified examples as representative evidence for a topic. +- Recompute a topic prototype from an approved membership set. +- Add a new topic from reviewed examples when they reveal a missing category. +- Use the feedback to refine candidate landmarks or suggest a new discovery run. + +### Design notes + +- Separate the classifier's prediction from the analyst's accepted label. +- Retain provenance for manual corrections. +- Do not automatically alter an embedding vector when only the display label changes. +- Make prototype recomputation an explicit action and retain the prior version. +- Any future use of reviewed labels for supervised training should be a distinct, explicit feature. + +### Acceptance criteria + +- The UI can display both the predicted label and analyst-confirmed label. +- Manual corrections survive a rerun of classification unless the user explicitly asks to replace them. +- Prototype refinement documents which records contributed to the vector. +- A feedback action does not silently trigger new embedding requests. + +--- + +## 9. P3 — Semantic search over embedded content + +### Motivation + +Landmark classification answers, "Which category best fits this chunk?" Semantic search answers, "Which chunks are most relevant to this question?" Both can use the same embedding collection while serving different analytical needs. + +### Proposed capabilities + +- Enter a natural-language query. +- Embed the query using a compatible text embedding model. +- Compare the query vector with existing text vectors using a chosen supported metric. +- Rank and display the closest chunks with source label, record identity, and metadata. +- Allow opening/inspecting the original source record. +- Optionally filter results by assigned topic/landmark. + +### Design notes + +- Verify that the query and corpus embeddings use the same model and dimensionality. +- Do not mix vectors from incompatible embedding models. +- Display the metric and score semantics; do not present raw distance as relevance probability. +- Preserve a path from a result to the source file/record when provenance is available. + +### Acceptance criteria + +- A query returns ranked results without requiring landmark labels. +- Results are traceable to source chunks and metadata. +- Invalid model/dimension combinations are rejected clearly. + +--- + +## 10. P3 — Duplicate and near-duplicate detection + +### Motivation + +Repeated records and boilerplate can distort cluster sizes, keywords, centroids, and analyst perceptions of topic prevalence. + +### Proposed capabilities + +- Detect exact duplicates after a configurable text-normalization step. +- Detect near-duplicates using text similarity and/or embedding distance. +- Group duplicates for review rather than deleting them automatically. +- Show how duplicate removal or downweighting would change topic size and representation. +- Preserve links to all source records, even when one representative is chosen. + +### Design notes + +- Exact duplicates and semantic near-duplicates are different concepts; report them separately. +- Do not automatically delete source records or metadata. +- Avoid hiding repeated records if their repetition is analytically meaningful (for example, event frequency). +- Make normalization rules inspectable and consistent across a run. + +### Acceptance criteria + +- The analyst can inspect duplicate groups and choose how to handle them. +- Counts distinguish physical records, unique text values, and clustered unique representatives where applicable. +- Source provenance remains intact after any deduplication view. + +--- + +## 11. P3 — Richer topic representation + +### Current foundation + +Topic Discovery V2 uses contrastive/class-based TF-IDF-style ranking so words that appear across every topic receive less weight than terms distinctive to a specific cluster. The current workflow can also request LLM-generated titles and one-sentence descriptions based on keywords and representative passages. + +### Potential improvements + +- Extract meaningful multi-word phrases, not just individual tokens. +- Improve stopword configuration for technical and domain-specific datasets. +- Offer a reviewable list of candidate phrases and their scores. +- Use semantic ranking to identify phrases that represent the cluster centroid. +- Provide LLM summaries with explicit evidence excerpts and warn when evidence is sparse or contradictory. +- Retain generated versus manually edited title/description provenance. +- Allow re-running summary generation after merging or changing the member selection, without rerunning embedding generation. + +### Acceptance criteria + +- Keyword rankings suppress boilerplate and repeated structural terms where appropriate. +- Analysts can see the phrases/evidence that influenced a description. +- A summary does not overwrite a manually edited title without an explicit confirmation. + +--- + +## 12. Cross-cutting requirements + +Every roadmap initiative should preserve these constraints: + +### UI and architecture + +- Use JavaFX/Java 25 conventions already present in Trinity. +- Keep the Similarity tab compact; use floating `LitPathPane` overlays for detailed review/configuration workflows. +- Manage overlays via `AppAsyncManager` and decouple panes through `HyperdriveEvent` or another existing event pattern. +- Avoid direct dependencies from floating panes to `HyperdrivePane`. +- Use no wildcard imports and avoid new inner classes wherever a top-level class is feasible. +- Apply `dialogstyles.css` to actual dialogs/Alerts, not to floating overlays in place of their normal styling. + +### Processing and correctness + +- Operate on original N-dimensional embeddings, not visualization projections. +- Keep UI operations on the FX thread and heavy calculations in background tasks. +- Preserve separate request batching/concurrency accounting for text and image workflows. +- Keep typed Jackson POJO binding; avoid ad hoc response parsers. +- Preserve record identity, source provenance, and correct response-to-input association. +- Treat invalid vectors and mixed dimensions as validation failures, not as arbitrary distances. +- Distinguish user-correctable model/configuration issues from service and programming errors. + +### Notification policy + +- Use the bottom-left `CommandTerminalEvent` status line for singleton status changes, partial outcomes, asynchronous errors, and completion summaries. +- Use a blocking Alert for preflight errors that require user correction before work can begin. +- Aggregate skipped/failed record counts rather than showing one dialog per record. +- Put detailed diagnostic information in SLF4J logs, avoiding unnecessary capture of prompts, documents, or model reasoning. + +### Verification + +- Add unit tests for pure calculation/parsing logic. +- Include integration checks for the event flow and overlay lifecycle where a workflow spans multiple panes. +- Test both valid and failure paths: blank/malformed content, unsupported model modality, malformed response, timeout, cancellation, stale responses, incompatible embeddings, and out-of-order request completion. +- State clearly whether a deliverable received source inspection, unit/smoke testing, full build verification, or interactive runtime testing. +- Use the latest user-supplied source tree as authoritative. Earlier ZIP snapshots may predate manually accepted corrections. + +--- + +## 13. Recommended next work item + +**Start with P1: Confidence-Aware Landmark Classification.** + +It is the most direct extension of the now-verified Topic Discovery workflow. The first implementation should calculate and expose best-match distance and runner-up margin, identify ambiguous matches, and allow a configurable abstain/unclassified outcome while retaining existing nearest-landmark behavior as an option. + +Follow that with **Topic Quality Diagnostics**, so analysts can evaluate both the quality of discovered clusters and the reliability of labels assigned from generated landmarks.