diff --git a/.taskfiles/frontend.yml b/.taskfiles/frontend.yml
index 43f0511176..9d5d17a394 100644
--- a/.taskfiles/frontend.yml
+++ b/.taskfiles/frontend.yml
@@ -239,6 +239,7 @@ tasks:
- task: typecheck:saas
- task: typecheck:desktop
- task: typecheck:scripts
+ - task: typecheck:prototypes
# ============================================================
# Quality Gate
diff --git a/app/common/src/main/java/stirling/software/common/model/api/comments/AnnotationLocation.java b/app/common/src/main/java/stirling/software/common/model/api/comments/AnnotationLocation.java
new file mode 100644
index 0000000000..e254ea9e51
--- /dev/null
+++ b/app/common/src/main/java/stirling/software/common/model/api/comments/AnnotationLocation.java
@@ -0,0 +1,15 @@
+package stirling.software.common.model.api.comments;
+
+/**
+ * Absolute position of a PDF annotation in the document.
+ *
+ *
Coordinates are in PDF user-space with the origin at the page's bottom-left, consistent with
+ * PDFBox's {@code PDRectangle} convention.
+ *
+ * @param pageIndex 0-indexed page number the annotation lives on.
+ * @param x bottom-left x coordinate of the annotation rectangle.
+ * @param y bottom-left y coordinate of the annotation rectangle.
+ * @param width width of the annotation rectangle, in user-space units.
+ * @param height height of the annotation rectangle, in user-space units.
+ */
+public record AnnotationLocation(int pageIndex, float x, float y, float width, float height) {}
diff --git a/app/common/src/main/java/stirling/software/common/model/api/comments/StickyNoteSpec.java b/app/common/src/main/java/stirling/software/common/model/api/comments/StickyNoteSpec.java
new file mode 100644
index 0000000000..781f3b859c
--- /dev/null
+++ b/app/common/src/main/java/stirling/software/common/model/api/comments/StickyNoteSpec.java
@@ -0,0 +1,15 @@
+package stirling.software.common.model.api.comments;
+
+/**
+ * Description of a single sticky-note (PDF Text) annotation to place on a document.
+ *
+ *
{@code author} and {@code subject} are optional — callers that pass {@code null} get a default
+ * author/subject from {@code PdfAnnotationService}.
+ *
+ * @param location where to anchor the annotation icon, in PDF user-space.
+ * @param text the comment body shown in the popup (required, non-blank).
+ * @param author optional author label shown in the popup; {@code null} → service default.
+ * @param subject optional subject line shown in the popup; {@code null} → service default.
+ */
+public record StickyNoteSpec(
+ AnnotationLocation location, String text, String author, String subject) {}
diff --git a/app/common/src/main/java/stirling/software/common/service/InternalApiClient.java b/app/common/src/main/java/stirling/software/common/service/InternalApiClient.java
index ba53e7fdef..70a20a6d54 100644
--- a/app/common/src/main/java/stirling/software/common/service/InternalApiClient.java
+++ b/app/common/src/main/java/stirling/software/common/service/InternalApiClient.java
@@ -34,11 +34,17 @@
@Slf4j
public class InternalApiClient {
- // Allowlist for internal dispatch. Matches a fixed namespace prefix,
+ // Allowlist for internal dispatch. Matches fixed namespace prefixes,
// but rejects traversal (..), URL-encoding (%), query/fragment, backslashes, and any other
// character that could alter the resolved endpoint on the local Spring server.
+ //
+ // The second alternation carves out `/api/v1/ai/tools/*` specifically — AI tools are
+ // dispatchable, but the broader `/api/v1/ai/` surface (orchestrate, health, etc.) is
+ // intentionally NOT permitted to avoid plan steps re-entering the orchestrator.
private static final Pattern ALLOWED_ENDPOINT_PATH =
- Pattern.compile("^/api/v1/(general|misc|security|convert|filter)(/[A-Za-z0-9_-]+)+$");
+ Pattern.compile(
+ "^/api/v1/(general|misc|security|convert|filter)(/[A-Za-z0-9_-]+)+$"
+ + "|^/api/v1/ai/tools(/[A-Za-z0-9_-]+)+$");
private final ServletContext servletContext;
private final UserServiceInterface userService;
diff --git a/app/common/src/main/java/stirling/software/common/service/PdfAnnotationService.java b/app/common/src/main/java/stirling/software/common/service/PdfAnnotationService.java
new file mode 100644
index 0000000000..c5a69afbb0
--- /dev/null
+++ b/app/common/src/main/java/stirling/software/common/service/PdfAnnotationService.java
@@ -0,0 +1,157 @@
+package stirling.software.common.service;
+
+import java.util.Calendar;
+import java.util.List;
+
+import org.apache.pdfbox.cos.COSName;
+import org.apache.pdfbox.pdmodel.PDDocument;
+import org.apache.pdfbox.pdmodel.common.PDRectangle;
+import org.apache.pdfbox.pdmodel.graphics.color.PDColor;
+import org.apache.pdfbox.pdmodel.graphics.color.PDDeviceRGB;
+import org.apache.pdfbox.pdmodel.interactive.annotation.PDAnnotationText;
+import org.springframework.stereotype.Service;
+
+import lombok.extern.slf4j.Slf4j;
+
+import stirling.software.common.model.api.comments.AnnotationLocation;
+import stirling.software.common.model.api.comments.StickyNoteSpec;
+
+/**
+ * Shared primitive for adding sticky-note (PDF Text) annotations to a document.
+ *
+ *
specs) {
+ if (specs == null || specs.isEmpty()) {
+ return 0;
+ }
+ int totalPages = doc.getNumberOfPages();
+ Calendar now = Calendar.getInstance();
+ int applied = 0;
+ for (int i = 0; i < specs.size(); i++) {
+ StickyNoteSpec spec = specs.get(i);
+ if (!isValid(spec, totalPages, i)) {
+ continue;
+ }
+ apply(doc, spec, now);
+ applied++;
+ }
+ if (applied < specs.size()) {
+ log.warn(
+ "Applied {}/{} sticky notes; {} skipped due to invalid specs.",
+ applied,
+ specs.size(),
+ specs.size() - applied);
+ }
+ return applied;
+ }
+
+ /**
+ * Add a single sticky note. Convenience wrapper; prefer {@link #addStickyNotes(PDDocument,
+ * List)} when placing multiple annotations so log output is batched.
+ */
+ public void addStickyNote(PDDocument doc, StickyNoteSpec spec) {
+ addStickyNotes(doc, List.of(spec));
+ }
+
+ private boolean isValid(StickyNoteSpec spec, int totalPages, int index) {
+ if (spec == null || spec.location() == null) {
+ log.warn("Skipping sticky-note[{}]: spec or location is null.", index);
+ return false;
+ }
+ if (spec.text() == null || spec.text().isBlank()) {
+ log.warn("Skipping sticky-note[{}]: text is blank.", index);
+ return false;
+ }
+ if (spec.text().length() > MAX_COMMENT_TEXT_LENGTH) {
+ log.warn(
+ "Skipping sticky-note[{}]: text length {} exceeds limit {}.",
+ index,
+ spec.text().length(),
+ MAX_COMMENT_TEXT_LENGTH);
+ return false;
+ }
+ AnnotationLocation loc = spec.location();
+ if (loc.width() <= 0f || loc.height() <= 0f) {
+ log.warn(
+ "Skipping sticky-note[{}]: non-positive dimensions width={} height={}.",
+ index,
+ loc.width(),
+ loc.height());
+ return false;
+ }
+ int page = loc.pageIndex();
+ if (page < 0 || page >= totalPages) {
+ log.warn(
+ "Skipping sticky-note[{}]: pageIndex={} out of range [0, {}).",
+ index,
+ page,
+ totalPages);
+ return false;
+ }
+ return true;
+ }
+
+ private void apply(PDDocument doc, StickyNoteSpec spec, Calendar now) {
+ AnnotationLocation loc = spec.location();
+
+ PDAnnotationText annot = new PDAnnotationText();
+ annot.setContents(spec.text());
+ annot.setRectangle(new PDRectangle(loc.x(), loc.y(), loc.width(), loc.height()));
+ annot.setSubject(nonBlankOr(spec.subject(), DEFAULT_SUBJECT));
+ annot.setTitlePopup(nonBlankOr(spec.author(), DEFAULT_AUTHOR));
+ annot.setColor(new PDColor(STICKY_NOTE_COLOR_RGB, PDDeviceRGB.INSTANCE));
+ annot.setCreationDate(now);
+ annot.setConstantOpacity(ANNOTATION_OPACITY);
+ annot.getCOSObject().setName(COSName.NAME, ANNOTATION_ICON_NAME);
+
+ try {
+ doc.getPage(loc.pageIndex()).getAnnotations().add(annot);
+ } catch (java.io.IOException e) {
+ log.warn(
+ "Failed to attach sticky note to page {}: {}", loc.pageIndex(), e.getMessage());
+ }
+ }
+
+ private static String nonBlankOr(String value, String fallback) {
+ return value != null && !value.isBlank() ? value : fallback;
+ }
+}
diff --git a/app/common/src/main/java/stirling/software/common/util/PdfTextLocator.java b/app/common/src/main/java/stirling/software/common/util/PdfTextLocator.java
new file mode 100644
index 0000000000..60aa65f74b
--- /dev/null
+++ b/app/common/src/main/java/stirling/software/common/util/PdfTextLocator.java
@@ -0,0 +1,139 @@
+package stirling.software.common.util;
+
+import java.io.IOException;
+import java.util.ArrayList;
+import java.util.List;
+import java.util.Locale;
+import java.util.Optional;
+
+import org.apache.pdfbox.pdmodel.PDDocument;
+import org.apache.pdfbox.pdmodel.common.PDRectangle;
+import org.apache.pdfbox.text.PDFTextStripper;
+import org.apache.pdfbox.text.TextPosition;
+import org.springframework.stereotype.Component;
+
+import lombok.extern.slf4j.Slf4j;
+
+/**
+ * Locate text on a specific PDF page and return its bounding box in PDF user-space (bottom-left
+ * origin). Used by tools that receive "anchor by text" hints — e.g. {@code
+ * /api/v1/misc/add-comments} when callers supply an {@code anchorText} instead of explicit
+ * coordinates.
+ *
+ * Matching is tolerant: case-insensitive with punctuation/whitespace stripped on both sides, so
+ * a caller-supplied needle of {@code "215000"} matches page text {@code "$215,000"}, and {@code
+ * "Total Revenue"} matches {@code "Total Revenue."}.
+ */
+@Slf4j
+@Component
+public class PdfTextLocator {
+
+ /** One found line of text with its user-space bounding box. */
+ public record MatchedBox(float x, float y, float width, float height) {}
+
+ /**
+ * Find the first line on {@code pageIndex} (0-indexed) whose text contains {@code needle} under
+ * the tolerant match. Returns empty when no match, when the page index is out of range, or when
+ * the needle is blank.
+ */
+ public Optional findOnPage(PDDocument doc, int pageIndex, String needle) {
+ if (doc == null
+ || needle == null
+ || needle.isBlank()
+ || pageIndex < 0
+ || pageIndex >= doc.getNumberOfPages()) {
+ return Optional.empty();
+ }
+ String normalizedNeedle = normalize(needle);
+ if (normalizedNeedle.isEmpty()) {
+ return Optional.empty();
+ }
+
+ List lines = new ArrayList<>();
+ LineCapturingStripper stripper;
+ try {
+ stripper = new LineCapturingStripper(lines);
+ stripper.setStartPage(pageIndex + 1);
+ stripper.setEndPage(pageIndex + 1);
+ stripper.setSortByPosition(true);
+ // Side effect: populates `lines`. We don't need the concatenated text.
+ stripper.getText(doc);
+ } catch (IOException e) {
+ log.warn(
+ "PdfTextLocator failed to extract text on page {}: {}",
+ pageIndex,
+ e.getMessage());
+ return Optional.empty();
+ }
+
+ PDRectangle mediaBox = doc.getPage(pageIndex).getMediaBox();
+ float pageHeight = mediaBox.getHeight();
+
+ for (CapturedLine line : lines) {
+ if (normalize(line.text).contains(normalizedNeedle)) {
+ // PDFBox's *DirAdj coords descend from the top of the page; convert to PDF
+ // user-space (origin = bottom-left) so the bbox can feed a PDRectangle directly.
+ float userSpaceY = pageHeight - line.yTopDown - line.height;
+ return Optional.of(new MatchedBox(line.x, userSpaceY, line.width, line.height));
+ }
+ }
+ return Optional.empty();
+ }
+
+ /** Strip everything non-alphanumeric and lowercase for tolerant matching. */
+ private static String normalize(String s) {
+ return s.replaceAll("[^A-Za-z0-9]", "").toLowerCase(Locale.ROOT);
+ }
+
+ private static final class CapturedLine {
+ String text;
+ float x;
+ float yTopDown;
+ float width;
+ float height;
+ }
+
+ private static final class LineCapturingStripper extends PDFTextStripper {
+ private final List lines;
+
+ LineCapturingStripper(List sink) throws IOException {
+ super();
+ this.lines = sink;
+ }
+
+ @Override
+ protected void writeString(String text, List textPositions)
+ throws IOException {
+ if (textPositions != null && !textPositions.isEmpty()) {
+ CapturedLine line = new CapturedLine();
+ line.text = text;
+
+ float minX = Float.MAX_VALUE;
+ float maxRight = 0f;
+ float minY = Float.MAX_VALUE;
+ float maxHeight = 0f;
+ for (TextPosition p : textPositions) {
+ float x = p.getXDirAdj();
+ float y = p.getYDirAdj();
+ float w = p.getWidthDirAdj();
+ float h = p.getHeightDir();
+ if (h == 0f) {
+ // Workaround: some fonts report 0 height via TextPosition; fall back to
+ // the nominal font size so downstream bboxes are never zero-height.
+ h = p.getFontSizeInPt();
+ }
+ if (x < minX) minX = x;
+ if (x + w > maxRight) maxRight = x + w;
+ if (y < minY) minY = y;
+ if (h > maxHeight) maxHeight = h;
+ }
+ line.x = minX;
+ line.width = maxRight - minX;
+ line.yTopDown = minY;
+ line.height = maxHeight;
+ lines.add(line);
+ }
+ super.writeString(text, textPositions);
+ }
+ }
+}
diff --git a/app/common/src/test/java/stirling/software/common/service/InternalApiClientTest.java b/app/common/src/test/java/stirling/software/common/service/InternalApiClientTest.java
index f815e92e71..6f18ca079c 100644
--- a/app/common/src/test/java/stirling/software/common/service/InternalApiClientTest.java
+++ b/app/common/src/test/java/stirling/software/common/service/InternalApiClientTest.java
@@ -92,6 +92,49 @@ void postRejectsDisallowedPath() {
assertThrows(SecurityException.class, () -> client.post("/api/v1/admin/settings", body));
}
+ @Test
+ void postRejectsAiEndpointsOutsideToolsSubnamespace() {
+ // /api/v1/ai/orchestrate and other non-tool AI endpoints are not internally
+ // dispatchable. Only /api/v1/ai/tools/* and the general/misc/security/convert/filter
+ // namespaces are on the allowlist — letting a plan step re-enter /orchestrate would
+ // introduce recursion risk.
+ MultiValueMap body = new LinkedMultiValueMap<>();
+ assertThrows(SecurityException.class, () -> client.post("/api/v1/ai/orchestrate", body));
+ }
+
+ @Test
+ void postAcceptsAiToolsSubnamespace() throws Exception {
+ // Agent tool paths like /api/v1/ai/tools/pdf-comment-agent are on the allowlist and
+ // should be dispatchable by the orchestrator's plan executor.
+ MultiValueMap body = new LinkedMultiValueMap<>();
+ body.add("fileInput", namedResource("input.pdf", "data"));
+
+ Path tempPath = Files.createTempFile("internal-api-ai-tools-test", ".tmp");
+ TempFile tempFile = mock(TempFile.class);
+ when(tempFile.getPath()).thenReturn(tempPath);
+ when(tempFile.getFile()).thenReturn(tempPath.toFile());
+ when(tempFileManager.createManagedTempFile("internal-api")).thenReturn(tempFile);
+
+ try (var ignored =
+ mockConstruction(
+ RestTemplate.class,
+ (rt, ctx) -> {
+ when(rt.httpEntityCallback(any(), eq(Resource.class)))
+ .thenReturn((RequestCallback) req -> {});
+ when(rt.execute(anyString(), eq(HttpMethod.POST), any(), any()))
+ .thenAnswer(inv -> fakeOkResponse(inv.getArgument(3)));
+ })) {
+
+ ResponseEntity response =
+ client.post("/api/v1/ai/tools/pdf-comment-agent", body);
+
+ assertNotNull(response);
+ assertEquals(HttpStatus.OK, response.getStatusCode());
+ } finally {
+ Files.deleteIfExists(tempPath);
+ }
+ }
+
@Test
void postRejectsPathTraversal() {
MultiValueMap body = new LinkedMultiValueMap<>();
diff --git a/app/common/src/test/java/stirling/software/common/service/PdfAnnotationServiceTest.java b/app/common/src/test/java/stirling/software/common/service/PdfAnnotationServiceTest.java
new file mode 100644
index 0000000000..cdc9f56978
--- /dev/null
+++ b/app/common/src/test/java/stirling/software/common/service/PdfAnnotationServiceTest.java
@@ -0,0 +1,191 @@
+package stirling.software.common.service;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertNotNull;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+import java.io.ByteArrayOutputStream;
+import java.io.IOException;
+import java.util.ArrayList;
+import java.util.List;
+
+import org.apache.pdfbox.Loader;
+import org.apache.pdfbox.pdmodel.PDDocument;
+import org.apache.pdfbox.pdmodel.PDPage;
+import org.apache.pdfbox.pdmodel.common.PDRectangle;
+import org.apache.pdfbox.pdmodel.interactive.annotation.PDAnnotation;
+import org.apache.pdfbox.pdmodel.interactive.annotation.PDAnnotationText;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+
+import stirling.software.common.model.api.comments.AnnotationLocation;
+import stirling.software.common.model.api.comments.StickyNoteSpec;
+
+class PdfAnnotationServiceTest {
+
+ private PdfAnnotationService service;
+
+ @BeforeEach
+ void setUp() {
+ service = new PdfAnnotationService();
+ }
+
+ @Test
+ void addStickyNotesPlacesOneAnnotationPerValidSpec() throws IOException {
+ byte[] bytes = twoPagePdfBytes();
+ try (PDDocument doc = Loader.loadPDF(bytes)) {
+ List specs =
+ List.of(
+ spec(0, 72f, 700f, "First comment", "alice", null),
+ spec(1, 100f, 650f, "Second comment", null, "Second"));
+
+ int applied = service.addStickyNotes(doc, specs);
+
+ assertEquals(2, applied);
+ byte[] saved = save(doc);
+ try (PDDocument reloaded = Loader.loadPDF(saved)) {
+ assertEquals(1, textAnnotations(reloaded.getPage(0).getAnnotations()).size());
+ assertEquals(1, textAnnotations(reloaded.getPage(1).getAnnotations()).size());
+
+ PDAnnotationText first =
+ textAnnotations(reloaded.getPage(0).getAnnotations()).get(0);
+ assertEquals("First comment", first.getContents());
+ assertEquals("alice", first.getTitlePopup(), "author override propagates");
+ assertNotNull(first.getSubject(), "subject falls back to default when null");
+ }
+ }
+ }
+
+ @Test
+ void skipsSpecsWithBlankText() throws IOException {
+ byte[] bytes = twoPagePdfBytes();
+ try (PDDocument doc = Loader.loadPDF(bytes)) {
+ List specs =
+ List.of(
+ spec(0, 72f, 700f, "Valid", null, null),
+ spec(0, 72f, 680f, " ", null, null));
+
+ int applied = service.addStickyNotes(doc, specs);
+
+ assertEquals(1, applied, "Blank-text spec must be skipped");
+ }
+ }
+
+ @Test
+ void skipsSpecsWithOutOfRangePageIndex() throws IOException {
+ byte[] bytes = twoPagePdfBytes();
+ try (PDDocument doc = Loader.loadPDF(bytes)) {
+ List specs =
+ List.of(
+ spec(0, 72f, 700f, "OK", null, null),
+ spec(99, 72f, 700f, "Too far", null, null),
+ spec(-1, 72f, 700f, "Negative", null, null));
+
+ int applied = service.addStickyNotes(doc, specs);
+
+ assertEquals(1, applied, "Only the in-range spec should be applied");
+ }
+ }
+
+ @Test
+ void handlesNullAndEmptySpecList() throws IOException {
+ byte[] bytes = twoPagePdfBytes();
+ try (PDDocument doc = Loader.loadPDF(bytes)) {
+ assertEquals(0, service.addStickyNotes(doc, null));
+ assertEquals(0, service.addStickyNotes(doc, List.of()));
+ }
+ }
+
+ @Test
+ void skipsSpecsWithNonPositiveDimensions() throws IOException {
+ byte[] bytes = twoPagePdfBytes();
+ try (PDDocument doc = Loader.loadPDF(bytes)) {
+ StickyNoteSpec zeroWidth =
+ new StickyNoteSpec(
+ new AnnotationLocation(0, 72f, 700f, 0f, 20f),
+ "Zero width",
+ null,
+ null);
+ StickyNoteSpec negativeHeight =
+ new StickyNoteSpec(
+ new AnnotationLocation(0, 72f, 680f, 20f, -5f),
+ "Negative height",
+ null,
+ null);
+ List specs =
+ List.of(spec(0, 72f, 660f, "OK", null, null), zeroWidth, negativeHeight);
+
+ int applied = service.addStickyNotes(doc, specs);
+
+ assertEquals(1, applied, "Only the positively-sized spec should be applied");
+ }
+ }
+
+ @Test
+ void skipsSpecsWithOverlongText() throws IOException {
+ byte[] bytes = twoPagePdfBytes();
+ try (PDDocument doc = Loader.loadPDF(bytes)) {
+ String overlong = "x".repeat(100_001);
+ List specs =
+ List.of(
+ spec(0, 72f, 700f, "Short", null, null),
+ spec(0, 72f, 680f, overlong, null, null));
+
+ int applied = service.addStickyNotes(doc, specs);
+
+ assertEquals(1, applied, "Overlong-text spec must be skipped");
+ }
+ }
+
+ @Test
+ void appliesDefaultAuthorAndSubjectWhenAbsent() throws IOException {
+ byte[] bytes = twoPagePdfBytes();
+ try (PDDocument doc = Loader.loadPDF(bytes)) {
+ service.addStickyNote(doc, spec(0, 72f, 700f, "No author given", null, null));
+
+ byte[] saved = save(doc);
+ try (PDDocument reloaded = Loader.loadPDF(saved)) {
+ PDAnnotationText annot =
+ textAnnotations(reloaded.getPage(0).getAnnotations()).get(0);
+ assertTrue(
+ annot.getTitlePopup() != null && !annot.getTitlePopup().isBlank(),
+ "Default author should be applied");
+ assertTrue(
+ annot.getSubject() != null && !annot.getSubject().isBlank(),
+ "Default subject should be applied");
+ }
+ }
+ }
+
+ // --- helpers ---
+
+ private static StickyNoteSpec spec(
+ int page, float x, float y, String text, String author, String subject) {
+ return new StickyNoteSpec(
+ new AnnotationLocation(page, x, y, 20f, 20f), text, author, subject);
+ }
+
+ private static byte[] twoPagePdfBytes() throws IOException {
+ try (PDDocument doc = new PDDocument()) {
+ doc.addPage(new PDPage(PDRectangle.A4));
+ doc.addPage(new PDPage(PDRectangle.A4));
+ return save(doc);
+ }
+ }
+
+ private static byte[] save(PDDocument doc) throws IOException {
+ ByteArrayOutputStream baos = new ByteArrayOutputStream();
+ doc.save(baos);
+ return baos.toByteArray();
+ }
+
+ private static List textAnnotations(List annotations) {
+ List out = new ArrayList<>();
+ for (PDAnnotation a : annotations) {
+ if (a instanceof PDAnnotationText t) {
+ out.add(t);
+ }
+ }
+ return out;
+ }
+}
diff --git a/app/common/src/test/java/stirling/software/common/util/PdfTextLocatorTest.java b/app/common/src/test/java/stirling/software/common/util/PdfTextLocatorTest.java
new file mode 100644
index 0000000000..b7e2ff73bd
--- /dev/null
+++ b/app/common/src/test/java/stirling/software/common/util/PdfTextLocatorTest.java
@@ -0,0 +1,97 @@
+package stirling.software.common.util;
+
+import static org.assertj.core.api.Assertions.assertThat;
+
+import java.awt.Color;
+import java.io.ByteArrayOutputStream;
+import java.util.Optional;
+
+import org.apache.pdfbox.Loader;
+import org.apache.pdfbox.pdmodel.PDDocument;
+import org.apache.pdfbox.pdmodel.PDPage;
+import org.apache.pdfbox.pdmodel.PDPageContentStream;
+import org.apache.pdfbox.pdmodel.common.PDRectangle;
+import org.apache.pdfbox.pdmodel.font.PDType1Font;
+import org.apache.pdfbox.pdmodel.font.Standard14Fonts;
+import org.junit.jupiter.api.Test;
+
+import stirling.software.common.util.PdfTextLocator.MatchedBox;
+
+class PdfTextLocatorTest {
+
+ private final PdfTextLocator locator = new PdfTextLocator();
+
+ @Test
+ void findsLineContainingNeedleAndReturnsUserSpaceBox() throws Exception {
+ byte[] pdf = pdfWithLines(new String[] {"Revenue: $215,000", "Expenses: $120,000"});
+ try (PDDocument doc = Loader.loadPDF(pdf)) {
+ Optional match = locator.findOnPage(doc, 0, "215000");
+ assertThat(match).isPresent();
+ MatchedBox box = match.get();
+ // Line was drawn at y=720 in user-space (bottom-left origin); locator
+ // should return a bbox close to that height band with non-zero width.
+ assertThat(box.width()).isGreaterThan(0f);
+ assertThat(box.height()).isGreaterThan(0f);
+ assertThat(box.y()).isBetween(700f, 740f);
+ }
+ }
+
+ @Test
+ void matchIsCaseAndPunctuationInsensitive() throws Exception {
+ byte[] pdf = pdfWithLines(new String[] {"Total Revenue.", "Q4 summary"});
+ try (PDDocument doc = Loader.loadPDF(pdf)) {
+ Optional match = locator.findOnPage(doc, 0, "total revenue");
+ assertThat(match).isPresent();
+ }
+ }
+
+ @Test
+ void returnsEmptyWhenNeedleNotFound() throws Exception {
+ byte[] pdf = pdfWithLines(new String[] {"Nothing to see here"});
+ try (PDDocument doc = Loader.loadPDF(pdf)) {
+ Optional match = locator.findOnPage(doc, 0, "not-on-this-page");
+ assertThat(match).isEmpty();
+ }
+ }
+
+ @Test
+ void returnsEmptyForBlankNeedle() throws Exception {
+ byte[] pdf = pdfWithLines(new String[] {"Any text"});
+ try (PDDocument doc = Loader.loadPDF(pdf)) {
+ assertThat(locator.findOnPage(doc, 0, "")).isEmpty();
+ assertThat(locator.findOnPage(doc, 0, " ")).isEmpty();
+ assertThat(locator.findOnPage(doc, 0, null)).isEmpty();
+ }
+ }
+
+ @Test
+ void returnsEmptyForOutOfRangePage() throws Exception {
+ byte[] pdf = pdfWithLines(new String[] {"Single page"});
+ try (PDDocument doc = Loader.loadPDF(pdf)) {
+ assertThat(locator.findOnPage(doc, -1, "single")).isEmpty();
+ assertThat(locator.findOnPage(doc, 99, "single")).isEmpty();
+ }
+ }
+
+ private static byte[] pdfWithLines(String[] lines) throws Exception {
+ try (PDDocument doc = new PDDocument()) {
+ PDPage page = new PDPage(PDRectangle.A4);
+ doc.addPage(page);
+ try (PDPageContentStream cs = new PDPageContentStream(doc, page)) {
+ cs.setFont(new PDType1Font(Standard14Fonts.FontName.HELVETICA), 12);
+ cs.setNonStrokingColor(Color.BLACK);
+ float y = 720f;
+ for (String line : lines) {
+ cs.beginText();
+ cs.newLineAtOffset(72f, y);
+ cs.showText(line);
+ cs.endText();
+ y -= 20f;
+ }
+ }
+ ByteArrayOutputStream baos = new ByteArrayOutputStream();
+ doc.save(baos);
+ return baos.toByteArray();
+ }
+ }
+}
diff --git a/app/core/src/main/java/stirling/software/SPDF/controller/api/misc/AddCommentsController.java b/app/core/src/main/java/stirling/software/SPDF/controller/api/misc/AddCommentsController.java
new file mode 100644
index 0000000000..32b131772e
--- /dev/null
+++ b/app/core/src/main/java/stirling/software/SPDF/controller/api/misc/AddCommentsController.java
@@ -0,0 +1,174 @@
+package stirling.software.SPDF.controller.api.misc;
+
+import java.io.IOException;
+import java.util.ArrayList;
+import java.util.List;
+import java.util.Optional;
+
+import org.apache.pdfbox.pdmodel.PDDocument;
+import org.springframework.core.io.Resource;
+import org.springframework.http.HttpStatus;
+import org.springframework.http.MediaType;
+import org.springframework.http.ResponseEntity;
+import org.springframework.web.bind.annotation.ModelAttribute;
+import org.springframework.web.multipart.MultipartFile;
+import org.springframework.web.server.ResponseStatusException;
+
+import io.swagger.v3.oas.annotations.Operation;
+
+import lombok.RequiredArgsConstructor;
+import lombok.extern.slf4j.Slf4j;
+
+import stirling.software.SPDF.config.swagger.StandardPdfResponse;
+import stirling.software.SPDF.model.api.misc.AddCommentsRequest;
+import stirling.software.common.annotations.AutoJobPostMapping;
+import stirling.software.common.annotations.api.MiscApi;
+import stirling.software.common.model.api.comments.AnnotationLocation;
+import stirling.software.common.model.api.comments.StickyNoteSpec;
+import stirling.software.common.service.CustomPDFDocumentFactory;
+import stirling.software.common.service.PdfAnnotationService;
+import stirling.software.common.util.GeneralUtils;
+import stirling.software.common.util.PdfTextLocator;
+import stirling.software.common.util.PdfTextLocator.MatchedBox;
+import stirling.software.common.util.TempFile;
+import stirling.software.common.util.TempFileManager;
+import stirling.software.common.util.WebResponseUtils;
+
+import tools.jackson.core.JacksonException;
+import tools.jackson.core.type.TypeReference;
+import tools.jackson.databind.ObjectMapper;
+
+/**
+ * Deterministic Java tool: add sticky-note comments to a PDF at caller-supplied positions.
+ * Composable primitive used by AI agents (that generate comment specs) and by any other caller —
+ * Automate workflows, scripts, unit tests — that has comment positions and text in hand.
+ *
+ * Each {@code CommentSpec} element accepts either absolute coordinates ({@code x, y, width,
+ * height}) or an {@code anchorText} hint. When {@code anchorText} is present, the tool scans the
+ * target page, finds the first line whose text contains the needle (tolerant match — case and
+ * punctuation insensitive), and anchors the sticky-note icon at that line's bounding box. Falls
+ * back to the supplied coordinates when no match is found.
+ *
+ *
Pairs with {@link PdfAnnotationService} (annotation creation) and {@link PdfTextLocator}
+ * (anchor resolution).
+ */
+@Slf4j
+@MiscApi
+@RequiredArgsConstructor
+public class AddCommentsController {
+
+ /** Sticky-note icon size in PDF user-space units. Matches the agents' default. */
+ private static final float ANCHOR_ICON_SIZE = 20f;
+
+ private final CustomPDFDocumentFactory pdfDocumentFactory;
+ private final TempFileManager tempFileManager;
+ private final PdfAnnotationService pdfAnnotationService;
+ private final PdfTextLocator pdfTextLocator;
+ private final ObjectMapper objectMapper;
+
+ @AutoJobPostMapping(value = "/add-comments", consumes = MediaType.MULTIPART_FORM_DATA_VALUE)
+ @StandardPdfResponse
+ @Operation(
+ summary = "Add sticky-note comments to a PDF at specified positions or anchored text",
+ description =
+ "Attaches PDF Text (sticky-note) annotations to the document."
+ + " Each CommentSpec can either supply absolute coordinates or an"
+ + " `anchorText` hint; when provided, the tool locates the first matching"
+ + " line on the target page and anchors the icon there (falling back to"
+ + " the coordinates if no match). Input:PDF Output:PDF Type:SISO")
+ public ResponseEntity addComments(@ModelAttribute AddCommentsRequest request)
+ throws IOException {
+
+ MultipartFile file = request.getFileInput();
+ if (file == null || file.isEmpty()) {
+ throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "fileInput is required");
+ }
+ String commentsJson = request.getComments();
+ if (commentsJson == null || commentsJson.isBlank()) {
+ throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "comments JSON is required");
+ }
+
+ List dtos;
+ try {
+ dtos = objectMapper.readValue(commentsJson, new TypeReference<>() {});
+ } catch (JacksonException e) {
+ throw new ResponseStatusException(
+ HttpStatus.BAD_REQUEST, "comments must be a JSON array of CommentSpec objects");
+ }
+
+ try (PDDocument document = pdfDocumentFactory.load(file)) {
+ List specs = resolveSpecs(document, dtos);
+ pdfAnnotationService.addStickyNotes(document, specs);
+
+ TempFile tempOut = tempFileManager.createManagedTempFile(".pdf");
+ try {
+ document.save(tempOut.getFile());
+ } catch (IOException e) {
+ tempOut.close();
+ throw e;
+ }
+ return WebResponseUtils.pdfFileToWebResponse(
+ tempOut,
+ GeneralUtils.generateFilename(file.getOriginalFilename(), "_commented.pdf"));
+ }
+ }
+
+ /**
+ * Convert the wire DTOs into {@link StickyNoteSpec}s, resolving any {@code anchorText} hints
+ * against the PDF. Each spec is resolved independently so a miss falls back locally without
+ * affecting other specs.
+ */
+ private List resolveSpecs(PDDocument document, List dtos) {
+ List specs = new ArrayList<>(dtos.size());
+ for (CommentSpecDto dto : dtos) {
+ specs.add(toSpec(document, dto));
+ }
+ return specs;
+ }
+
+ private StickyNoteSpec toSpec(PDDocument document, CommentSpecDto d) {
+ AnnotationLocation location = resolveLocation(document, d);
+ return new StickyNoteSpec(location, d.text, d.author, d.subject);
+ }
+
+ private AnnotationLocation resolveLocation(PDDocument document, CommentSpecDto d) {
+ if (d.anchorText == null || d.anchorText.isBlank()) {
+ return new AnnotationLocation(d.pageIndex, d.x, d.y, d.width, d.height);
+ }
+ Optional match = pdfTextLocator.findOnPage(document, d.pageIndex, d.anchorText);
+ if (match.isEmpty()) {
+ log.debug(
+ "add-comments: no match for anchorText {!r} on page {}; using fallback coords",
+ d.anchorText,
+ d.pageIndex);
+ return new AnnotationLocation(d.pageIndex, d.x, d.y, d.width, d.height);
+ }
+ MatchedBox box = match.get();
+ // Anchor the icon at the top-left of the matched line, matching the convention used by
+ // PdfCommentAgentOrchestrator for its chunk-based placement.
+ float iconX = box.x();
+ float iconY = box.y() + box.height() - ANCHOR_ICON_SIZE;
+ return new AnnotationLocation(
+ d.pageIndex, iconX, iconY, ANCHOR_ICON_SIZE, ANCHOR_ICON_SIZE);
+ }
+
+ /**
+ * Wire-format DTO for a single element in the {@code comments} JSON array. Flat record-like
+ * shape keeps the JSON simple for humans, AI-engine plan parameters, and Automate steps alike.
+ *
+ * {@code anchorText} is optional. When present, the server locates the first line on {@code
+ * pageIndex} containing that text (tolerant match) and places the icon there; the {@code
+ * x/y/width/height} act as fallback when no match is found.
+ */
+ private static final class CommentSpecDto {
+ public int pageIndex;
+ public float x;
+ public float y;
+ public float width;
+ public float height;
+ public String text;
+ public String author;
+ public String subject;
+ public String anchorText;
+ }
+}
diff --git a/app/core/src/main/java/stirling/software/SPDF/model/api/misc/AddCommentsRequest.java b/app/core/src/main/java/stirling/software/SPDF/model/api/misc/AddCommentsRequest.java
new file mode 100644
index 0000000000..e7c1032244
--- /dev/null
+++ b/app/core/src/main/java/stirling/software/SPDF/model/api/misc/AddCommentsRequest.java
@@ -0,0 +1,33 @@
+package stirling.software.SPDF.model.api.misc;
+
+import io.swagger.v3.oas.annotations.media.Schema;
+import io.swagger.v3.oas.annotations.media.Schema.RequiredMode;
+
+import lombok.Data;
+import lombok.EqualsAndHashCode;
+
+import stirling.software.common.model.api.PDFFile;
+
+/**
+ * Request body for {@code POST /api/v1/misc/add-comments}.
+ *
+ *
The {@code comments} field is a JSON-encoded array of {@code CommentSpec} objects rather than
+ * a nested multipart part so the endpoint stays compatible with {@code InternalApiClient}'s flat
+ * multipart form body (orchestrator plan dispatch). Jackson parses it controller-side.
+ */
+@Data
+@EqualsAndHashCode(callSuper = true)
+public class AddCommentsRequest extends PDFFile {
+
+ @Schema(
+ description =
+ "JSON array of comment specs. Each element has: {pageIndex, x, y, width,"
+ + " height, text, author?, subject?}. Coordinates are PDF user-space with"
+ + " origin at the page's bottom-left.",
+ example =
+ "[{\"pageIndex\":0,\"x\":72,\"y\":720,\"width\":20,\"height\":20,"
+ + "\"text\":\"Check this paragraph\",\"author\":\"Reviewer\","
+ + "\"subject\":\"Unclear wording\"}]",
+ requiredMode = RequiredMode.REQUIRED)
+ private String comments;
+}
diff --git a/app/core/src/test/java/stirling/software/SPDF/controller/api/misc/AddCommentsControllerTest.java b/app/core/src/test/java/stirling/software/SPDF/controller/api/misc/AddCommentsControllerTest.java
new file mode 100644
index 0000000000..f88af46f69
--- /dev/null
+++ b/app/core/src/test/java/stirling/software/SPDF/controller/api/misc/AddCommentsControllerTest.java
@@ -0,0 +1,286 @@
+package stirling.software.SPDF.controller.api.misc;
+
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.assertj.core.api.Assertions.assertThatThrownBy;
+import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.ArgumentMatchers.anyString;
+import static org.mockito.Mockito.lenient;
+import static org.mockito.Mockito.when;
+
+import java.awt.Color;
+import java.io.ByteArrayOutputStream;
+import java.io.File;
+import java.nio.file.Files;
+import java.nio.file.Path;
+import java.util.ArrayList;
+import java.util.List;
+
+import org.apache.pdfbox.Loader;
+import org.apache.pdfbox.pdmodel.PDDocument;
+import org.apache.pdfbox.pdmodel.PDPage;
+import org.apache.pdfbox.pdmodel.PDPageContentStream;
+import org.apache.pdfbox.pdmodel.common.PDRectangle;
+import org.apache.pdfbox.pdmodel.font.PDType1Font;
+import org.apache.pdfbox.pdmodel.font.Standard14Fonts;
+import org.apache.pdfbox.pdmodel.interactive.annotation.PDAnnotation;
+import org.apache.pdfbox.pdmodel.interactive.annotation.PDAnnotationText;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.extension.ExtendWith;
+import org.junit.jupiter.api.io.TempDir;
+import org.mockito.Mock;
+import org.mockito.junit.jupiter.MockitoExtension;
+import org.springframework.core.io.Resource;
+import org.springframework.http.HttpStatus;
+import org.springframework.http.MediaType;
+import org.springframework.http.ResponseEntity;
+import org.springframework.mock.web.MockMultipartFile;
+import org.springframework.web.multipart.MultipartFile;
+import org.springframework.web.server.ResponseStatusException;
+
+import stirling.software.SPDF.model.api.misc.AddCommentsRequest;
+import stirling.software.common.service.CustomPDFDocumentFactory;
+import stirling.software.common.service.PdfAnnotationService;
+import stirling.software.common.util.PdfTextLocator;
+import stirling.software.common.util.TempFile;
+import stirling.software.common.util.TempFileManager;
+
+import tools.jackson.databind.ObjectMapper;
+import tools.jackson.databind.json.JsonMapper;
+
+@ExtendWith(MockitoExtension.class)
+class AddCommentsControllerTest {
+
+ @TempDir Path tempDir;
+ @Mock private CustomPDFDocumentFactory pdfDocumentFactory;
+ @Mock private TempFileManager tempFileManager;
+
+ private PdfAnnotationService pdfAnnotationService;
+ private PdfTextLocator pdfTextLocator;
+ private ObjectMapper objectMapper;
+ private AddCommentsController controller;
+
+ @BeforeEach
+ void setUp() throws Exception {
+ pdfAnnotationService = new PdfAnnotationService();
+ pdfTextLocator = new PdfTextLocator();
+ objectMapper = JsonMapper.builder().build();
+ controller =
+ new AddCommentsController(
+ pdfDocumentFactory,
+ tempFileManager,
+ pdfAnnotationService,
+ pdfTextLocator,
+ objectMapper);
+
+ lenient()
+ .when(tempFileManager.createManagedTempFile(anyString()))
+ .thenAnswer(
+ inv -> {
+ File file =
+ Files.createTempFile(tempDir, "addcomments", ".pdf").toFile();
+ TempFile tf = org.mockito.Mockito.mock(TempFile.class);
+ lenient().when(tf.getPath()).thenReturn(file.toPath());
+ lenient().when(tf.getFile()).thenReturn(file);
+ return tf;
+ });
+ }
+
+ @Test
+ void appliesEachCommentSpecAsStickyNote() throws Exception {
+ MockMultipartFile file = pdf("doc.pdf", twoPagePdfBytes());
+ when(pdfDocumentFactory.load(any(MultipartFile.class)))
+ .thenAnswer(inv -> Loader.loadPDF(file.getBytes()));
+
+ AddCommentsRequest request = new AddCommentsRequest();
+ request.setFileInput(file);
+ request.setComments(
+ """
+ [{"pageIndex":0,"x":72,"y":700,"width":20,"height":20,"text":"First","author":"me","subject":"S1"},
+ {"pageIndex":1,"x":100,"y":650,"width":20,"height":20,"text":"Second"}]
+ """);
+
+ ResponseEntity response = controller.addComments(request);
+
+ assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK);
+ byte[] result = drainBody(response);
+ try (PDDocument reloaded = Loader.loadPDF(result)) {
+ List p0 = textAnnotations(reloaded.getPage(0).getAnnotations());
+ List p1 = textAnnotations(reloaded.getPage(1).getAnnotations());
+ assertThat(p0).hasSize(1);
+ assertThat(p1).hasSize(1);
+ assertThat(p0.get(0).getContents()).isEqualTo("First");
+ assertThat(p1.get(0).getContents()).isEqualTo("Second");
+ }
+ }
+
+ @Test
+ void anchorsStickyNoteAtLocatedTextWhenAnchorTextMatches() throws Exception {
+ byte[] pdfBytes = singlePagePdfWithLine("Revenue: $215,000");
+ MockMultipartFile file = pdf("doc.pdf", pdfBytes);
+ when(pdfDocumentFactory.load(any(MultipartFile.class)))
+ .thenAnswer(inv -> Loader.loadPDF(file.getBytes()));
+
+ AddCommentsRequest request = new AddCommentsRequest();
+ request.setFileInput(file);
+ // Fallback coords deliberately far from the line so we can tell which path ran.
+ request.setComments(
+ """
+ [{"pageIndex":0,"x":10,"y":10,"width":5,"height":5,
+ "text":"Check this total","author":"tester","subject":"S",
+ "anchorText":"215000"}]
+ """);
+
+ ResponseEntity response = controller.addComments(request);
+
+ assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK);
+ try (PDDocument reloaded = Loader.loadPDF(drainBody(response))) {
+ List notes = textAnnotations(reloaded.getPage(0).getAnnotations());
+ assertThat(notes).hasSize(1);
+ PDRectangle rect = notes.get(0).getRectangle();
+ // Line was drawn at user-space y=720 with font size 12; icon should land in that band,
+ // not at the fallback y=10. Width/height fixed to 20 by the anchor path.
+ assertThat(rect.getWidth()).isEqualTo(20f);
+ assertThat(rect.getHeight()).isEqualTo(20f);
+ assertThat(rect.getLowerLeftY()).isBetween(700f, 740f);
+ assertThat(rect.getLowerLeftX()).isGreaterThan(50f);
+ }
+ }
+
+ @Test
+ void fallsBackToAbsoluteCoordsWhenAnchorTextMisses() throws Exception {
+ byte[] pdfBytes = singlePagePdfWithLine("Revenue: $215,000");
+ MockMultipartFile file = pdf("doc.pdf", pdfBytes);
+ when(pdfDocumentFactory.load(any(MultipartFile.class)))
+ .thenAnswer(inv -> Loader.loadPDF(file.getBytes()));
+
+ AddCommentsRequest request = new AddCommentsRequest();
+ request.setFileInput(file);
+ request.setComments(
+ """
+ [{"pageIndex":0,"x":55,"y":33,"width":7,"height":9,
+ "text":"No match","anchorText":"not-on-this-page"}]
+ """);
+
+ ResponseEntity response = controller.addComments(request);
+
+ try (PDDocument reloaded = Loader.loadPDF(drainBody(response))) {
+ List notes = textAnnotations(reloaded.getPage(0).getAnnotations());
+ assertThat(notes).hasSize(1);
+ PDRectangle rect = notes.get(0).getRectangle();
+ assertThat(rect.getLowerLeftX()).isEqualTo(55f);
+ assertThat(rect.getLowerLeftY()).isEqualTo(33f);
+ assertThat(rect.getWidth()).isEqualTo(7f);
+ assertThat(rect.getHeight()).isEqualTo(9f);
+ }
+ }
+
+ @Test
+ void rejectsBlankCommentsJson() {
+ AddCommentsRequest request = new AddCommentsRequest();
+ request.setFileInput(pdf("doc.pdf", new byte[] {1, 2, 3}));
+ request.setComments("");
+
+ assertThatThrownBy(() -> controller.addComments(request))
+ .isInstanceOf(ResponseStatusException.class)
+ .extracting(e -> ((ResponseStatusException) e).getStatusCode())
+ .isEqualTo(HttpStatus.BAD_REQUEST);
+ }
+
+ @Test
+ void rejectsInvalidJson() {
+ AddCommentsRequest request = new AddCommentsRequest();
+ request.setFileInput(pdf("doc.pdf", new byte[] {1, 2, 3}));
+ request.setComments("not-json");
+
+ assertThatThrownBy(() -> controller.addComments(request))
+ .isInstanceOf(ResponseStatusException.class)
+ .extracting(e -> ((ResponseStatusException) e).getStatusCode())
+ .isEqualTo(HttpStatus.BAD_REQUEST);
+ }
+
+ @Test
+ void rejectsMissingFileInput() {
+ AddCommentsRequest request = new AddCommentsRequest();
+ request.setComments("[]");
+
+ assertThatThrownBy(() -> controller.addComments(request))
+ .isInstanceOf(ResponseStatusException.class)
+ .extracting(e -> ((ResponseStatusException) e).getStatusCode())
+ .isEqualTo(HttpStatus.BAD_REQUEST);
+ }
+
+ @Test
+ void returnsSuccessForEmptyCommentsArray() throws Exception {
+ // An empty JSON array is a valid payload — nothing to annotate, but the caller
+ // should still get back the input PDF without any error so pipelines that
+ // produce zero comments don't have to special-case the empty result.
+ MockMultipartFile file = pdf("doc.pdf", twoPagePdfBytes());
+ when(pdfDocumentFactory.load(any(MultipartFile.class)))
+ .thenAnswer(inv -> Loader.loadPDF(file.getBytes()));
+
+ AddCommentsRequest request = new AddCommentsRequest();
+ request.setFileInput(file);
+ request.setComments("[]");
+
+ ResponseEntity response = controller.addComments(request);
+
+ assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK);
+ try (PDDocument reloaded = Loader.loadPDF(drainBody(response))) {
+ assertThat(textAnnotations(reloaded.getPage(0).getAnnotations())).isEmpty();
+ assertThat(textAnnotations(reloaded.getPage(1).getAnnotations())).isEmpty();
+ }
+ }
+
+ // --- helpers ---
+
+ private static MockMultipartFile pdf(String name, byte[] bytes) {
+ return new MockMultipartFile("fileInput", name, MediaType.APPLICATION_PDF_VALUE, bytes);
+ }
+
+ private static byte[] twoPagePdfBytes() throws Exception {
+ try (PDDocument doc = new PDDocument()) {
+ doc.addPage(new PDPage(PDRectangle.A4));
+ doc.addPage(new PDPage(PDRectangle.A4));
+ ByteArrayOutputStream baos = new ByteArrayOutputStream();
+ doc.save(baos);
+ return baos.toByteArray();
+ }
+ }
+
+ private static byte[] singlePagePdfWithLine(String line) throws Exception {
+ try (PDDocument doc = new PDDocument()) {
+ PDPage page = new PDPage(PDRectangle.A4);
+ doc.addPage(page);
+ try (PDPageContentStream cs = new PDPageContentStream(doc, page)) {
+ cs.setFont(new PDType1Font(Standard14Fonts.FontName.HELVETICA), 12);
+ cs.setNonStrokingColor(Color.BLACK);
+ cs.beginText();
+ cs.newLineAtOffset(72f, 720f);
+ cs.showText(line);
+ cs.endText();
+ }
+ ByteArrayOutputStream baos = new ByteArrayOutputStream();
+ doc.save(baos);
+ return baos.toByteArray();
+ }
+ }
+
+ private static byte[] drainBody(ResponseEntity response) throws java.io.IOException {
+ ByteArrayOutputStream baos = new ByteArrayOutputStream();
+ try (java.io.InputStream is = response.getBody().getInputStream()) {
+ is.transferTo(baos);
+ }
+ return baos.toByteArray();
+ }
+
+ private static List textAnnotations(List annotations) {
+ List out = new ArrayList<>();
+ for (PDAnnotation a : annotations) {
+ if (a instanceof PDAnnotationText t) {
+ out.add(t);
+ }
+ }
+ return out;
+ }
+}
diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/controller/api/MathAuditorAgentController.java b/app/proprietary/src/main/java/stirling/software/proprietary/controller/api/MathAuditorAgentController.java
index 3691266b79..20ca5c109f 100644
--- a/app/proprietary/src/main/java/stirling/software/proprietary/controller/api/MathAuditorAgentController.java
+++ b/app/proprietary/src/main/java/stirling/software/proprietary/controller/api/MathAuditorAgentController.java
@@ -20,21 +20,30 @@
import lombok.extern.slf4j.Slf4j;
import stirling.software.proprietary.model.api.ai.Verdict;
+import stirling.software.proprietary.service.AiToolInputValidator;
import stirling.software.proprietary.service.MathAuditorOrchestrator;
/**
* Public entry point for the Math Auditor Agent (mathAuditorAgent).
*
* Accepts a PDF from the client, hands it to the {@link MathAuditorOrchestrator} which runs the
- * multi-round Java-Python negotiation, and returns the Auditor's {@link Verdict}.
+ * multi-round Java-Python negotiation, and returns the Auditor's {@link Verdict} as JSON.
+ *
+ *
This endpoint is a pure specialist — it produces the structured finding and nothing more.
+ * Presentation (rendering as a chat answer, projecting to PDF comments, etc.) is the responsibility
+ * of the caller (e.g. the orchestrator's {@code delegate_pdf_question} or {@code
+ * delegate_pdf_review} meta-agents).
+ *
+ *
Lives under {@code /api/v1/ai/tools/} so it is dispatchable by the AI orchestrator via the
+ * standard {@code InternalApiClient} allowlist — no special-case plumbing needed.
*
*
The raw PDF never leaves Java. Python receives only structured text and CSV data.
*/
@Slf4j
@RestController
-@RequestMapping("/api/v1/ai")
+@RequestMapping("/api/v1/ai/tools")
@RequiredArgsConstructor
-@Tag(name = "AI Engine", description = "AI-powered document analysis endpoints.")
+@Tag(name = "AI Tools", description = "Dispatchable AI-backed tools.")
public class MathAuditorAgentController {
private final MathAuditorOrchestrator orchestrator;
@@ -49,11 +58,12 @@ public class MathAuditorAgentController {
The auditor checks:
- Table row and column totals (tally errors)
- Inline arithmetic expressions (e.g. "100 + 200 = 300")
- - Cross-page figure consistency (same figure cited differently on different pages)
+ - Cross-page figure consistency
- Prose claims about percentages, growth rates, and comparisons
- The PDF is processed entirely on the Java side; only extracted text and table data
- are sent to the AI engine.
+ Returns a JSON Verdict describing every discrepancy found. How the Verdict is
+ presented to the end user (chat answer, PDF annotations, etc.) is up to the
+ caller.
Input: PDF Output: JSON Type: SISO
""")
@@ -68,11 +78,7 @@ public ResponseEntity mathAuditorAgent(
@RequestParam(value = "tolerance", defaultValue = "0.01")
BigDecimal tolerance) {
- String contentType = fileInput.getContentType();
- if (contentType == null || !contentType.equals("application/pdf")) {
- return ResponseEntity.badRequest().build();
- }
-
+ AiToolInputValidator.validatePdfUpload(fileInput);
if (tolerance.compareTo(BigDecimal.ZERO) < 0) {
return ResponseEntity.badRequest().build();
}
@@ -89,9 +95,6 @@ public ResponseEntity mathAuditorAgent(
} catch (IOException e) {
log.error("[math-auditor-agent] IO error during audit", e);
return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR).build();
- } catch (Exception e) {
- log.error("[math-auditor-agent] unexpected error during audit", e);
- return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR).build();
}
}
}
diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/controller/api/PdfCommentAgentController.java b/app/proprietary/src/main/java/stirling/software/proprietary/controller/api/PdfCommentAgentController.java
new file mode 100644
index 0000000000..ef0ceaba25
--- /dev/null
+++ b/app/proprietary/src/main/java/stirling/software/proprietary/controller/api/PdfCommentAgentController.java
@@ -0,0 +1,120 @@
+package stirling.software.proprietary.controller.api;
+
+import java.io.IOException;
+
+import org.springframework.core.io.ByteArrayResource;
+import org.springframework.core.io.Resource;
+import org.springframework.http.HttpHeaders;
+import org.springframework.http.MediaType;
+import org.springframework.http.ResponseEntity;
+import org.springframework.web.bind.annotation.PostMapping;
+import org.springframework.web.bind.annotation.RequestMapping;
+import org.springframework.web.bind.annotation.RequestParam;
+import org.springframework.web.bind.annotation.RestController;
+import org.springframework.web.multipart.MultipartFile;
+
+import io.swagger.v3.oas.annotations.Operation;
+import io.swagger.v3.oas.annotations.Parameter;
+import io.swagger.v3.oas.annotations.tags.Tag;
+
+import lombok.RequiredArgsConstructor;
+import lombok.extern.slf4j.Slf4j;
+
+import stirling.software.proprietary.service.AiToolResponseHeaders;
+import stirling.software.proprietary.service.PdfCommentAgentOrchestrator;
+import stirling.software.proprietary.service.PdfCommentAgentOrchestrator.AnnotatedPdf;
+
+import tools.jackson.databind.ObjectMapper;
+import tools.jackson.databind.node.ObjectNode;
+
+/**
+ * Public entry point for the PDF Comment Agent (pdfCommentAgent).
+ *
+ * Accepts a PDF and a natural-language prompt, delegates to {@link PdfCommentAgentOrchestrator}
+ * which consults the Python engine and applies {@code PDAnnotationText} sticky-note annotations,
+ * then streams the annotated PDF back in the response body. This shape matches the rest of the
+ * Stirling tool endpoints ({@code /api/v1/misc/*}, {@code /api/v1/general/*}) and is what the AI
+ * workflow orchestrator expects when dispatching this tool as a plan step.
+ *
+ *
The raw PDF never leaves Java. Python only receives positioned text chunks.
+ */
+@Slf4j
+@RestController
+@RequestMapping("/api/v1/ai/tools")
+@RequiredArgsConstructor
+@Tag(name = "AI Tools", description = "Dispatchable AI-backed tools.")
+public class PdfCommentAgentController {
+
+ private final PdfCommentAgentOrchestrator orchestrator;
+ private final ObjectMapper objectMapper;
+
+ @PostMapping(
+ value = "/pdf-comment-agent",
+ consumes = MediaType.MULTIPART_FORM_DATA_VALUE,
+ produces = MediaType.APPLICATION_PDF_VALUE)
+ @Operation(
+ summary = "Annotate a PDF with AI-generated sticky-note comments",
+ description =
+ """
+ Runs the PDF Comment Agent against the supplied PDF. Java extracts positioned
+ text chunks from the document, ships them (with the user's prompt) to the
+ AI engine, then applies the returned comments as standard PDF Text
+ annotations (sticky notes) anchored to the relevant chunks.
+
+ The annotated PDF is streamed back in the response body with
+ Content-Type: application/pdf.
+
+ Input: PDF + prompt Output: PDF Type: SISO
+ """)
+ public ResponseEntity pdfCommentAgent(
+ @Parameter(description = "The PDF document to annotate", required = true)
+ @RequestParam("fileInput")
+ MultipartFile fileInput,
+ @Parameter(
+ description =
+ "Natural-language instructions for the AI — what to comment on",
+ required = true)
+ @RequestParam("prompt")
+ String prompt)
+ throws IOException {
+
+ String safeName =
+ fileInput.getOriginalFilename() != null
+ ? fileInput.getOriginalFilename().replaceAll("[\\r\\n]", "_")
+ : "";
+ log.info(
+ "[pdf-comment-agent] request file={} promptLen={}",
+ safeName,
+ prompt == null ? 0 : prompt.length());
+
+ // ResponseStatusException (validation errors) propagates to Spring's default handler;
+ // IOException is re-thrown to produce a 500. Other RuntimeExceptions likewise propagate.
+ AnnotatedPdf annotated = orchestrator.applyComments(fileInput, prompt);
+ HttpHeaders headers = new HttpHeaders();
+ headers.setContentType(MediaType.APPLICATION_PDF);
+ headers.setContentDispositionFormData("attachment", annotated.fileName());
+ headers.setContentLength(annotated.bytes().length);
+ headers.set(AiToolResponseHeaders.TOOL_REPORT, buildReportHeader(annotated));
+ return ResponseEntity.ok().headers(headers).body(new ByteArrayResource(annotated.bytes()));
+ }
+
+ /**
+ * Build the metadata JSON surfaced in {@link AiToolResponseHeaders#TOOL_REPORT} alongside the
+ * annotated PDF. Kept small (fits comfortably in a header): counts and the agent's rationale so
+ * a chat UI can show "Added 3 comments: " alongside the downloaded file.
+ */
+ private String buildReportHeader(AnnotatedPdf annotated) {
+ ObjectNode node = objectMapper.createObjectNode();
+ node.put("annotationsApplied", annotated.annotationsApplied());
+ node.put("instructionsReceived", annotated.instructionsReceived());
+ if (annotated.rationale() != null) {
+ node.put("rationale", annotated.rationale());
+ }
+ try {
+ return objectMapper.writeValueAsString(node);
+ } catch (Exception e) {
+ log.warn("Failed to serialise pdf-comment-agent report header: {}", e.getMessage());
+ return "{\"annotationsApplied\":" + annotated.annotationsApplied() + "}";
+ }
+ }
+}
diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/model/api/ai/AiWorkflowEditPlan.java b/app/proprietary/src/main/java/stirling/software/proprietary/model/api/ai/AiWorkflowEditPlan.java
new file mode 100644
index 0000000000..c41d6450af
--- /dev/null
+++ b/app/proprietary/src/main/java/stirling/software/proprietary/model/api/ai/AiWorkflowEditPlan.java
@@ -0,0 +1,35 @@
+package stirling.software.proprietary.model.api.ai;
+
+import java.util.ArrayList;
+import java.util.List;
+import java.util.Map;
+
+import io.swagger.v3.oas.annotations.media.Schema;
+
+import lombok.Data;
+
+/**
+ * Embedded plan optionally carried inside a question answer response. When present, the consumer
+ * (Java) runs the plan steps before delivering the answer; on the resume turn the engine returns
+ * the real answer using the captured tool reports.
+ *
+ * Mirrors the engine's {@code EditPlanResponse} shape but is nested inside an answer rather than
+ * acting as the top-level outcome — matches the engine's {@code
+ * PdfQuestionAnswerResponse.edit_plan} field.
+ */
+@Data
+@Schema(description = "Plan that must run before the answer is final")
+public class AiWorkflowEditPlan {
+
+ @Schema(description = "Optional human-readable summary of the plan")
+ private String summary;
+
+ @Schema(description = "Optional rationale for the plan")
+ private String rationale;
+
+ @Schema(description = "Tool steps to execute before resuming")
+ private List