From a4fd10b156aeeadae01d20bc0c43102562c04f92 Mon Sep 17 00:00:00 2001 From: Anthony Stirling <77850077+Frooodle@users.noreply.github.com> Date: Wed, 19 Aug 2026 16:29:15 +0100 Subject: [PATCH] Make classification an authorable pipeline task and skip already-classified files --- .../api/ClassifyLabelController.java | 49 ++++++++++++--- .../proprietary/policy/model/Policy.java | 5 ++ .../DefaultClassificationPolicySeeder.java | 37 +++++++++-- .../api/ClassifyLabelControllerTest.java | 49 ++++++++++++++- ...DefaultClassificationPolicySeederTest.java | 30 ++++++++- .../scripts/generate-tool-api-types.mts | 8 +-- .../data/classifyIsAPipelineTask.test.tsx | 63 +++++++++++++++++++ .../core/data/useTranslatedToolRegistry.tsx | 23 +++++++ .../tools/classify/useClassifyOperation.ts | 52 +++++++++++++++ .../editor/src/core/types/toolApiTypes.ts | 24 +++++++ frontend/editor/src/core/types/toolIO.ts | 5 ++ frontend/editor/src/core/types/toolId.ts | 1 + 12 files changed, 329 insertions(+), 17 deletions(-) create mode 100644 frontend/editor/src/core/data/classifyIsAPipelineTask.test.tsx create mode 100644 frontend/editor/src/core/hooks/tools/classify/useClassifyOperation.ts diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/controller/api/ClassifyLabelController.java b/app/proprietary/src/main/java/stirling/software/proprietary/controller/api/ClassifyLabelController.java index 798de71df9..dc422433f0 100644 --- a/app/proprietary/src/main/java/stirling/software/proprietary/controller/api/ClassifyLabelController.java +++ b/app/proprietary/src/main/java/stirling/software/proprietary/controller/api/ClassifyLabelController.java @@ -9,6 +9,7 @@ import java.util.Map; import java.util.Set; import org.apache.pdfbox.pdmodel.PDDocument; +import org.apache.pdfbox.pdmodel.PDDocumentInformation; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.core.io.Resource; import org.springframework.http.MediaType; @@ -20,12 +21,13 @@ import org.springframework.web.bind.annotation.RestController; import org.springframework.web.multipart.MultipartFile; import io.github.pixee.security.Filenames; -import io.swagger.v3.oas.annotations.Hidden; import io.swagger.v3.oas.annotations.Operation; import io.swagger.v3.oas.annotations.tags.Tag; import lombok.extern.slf4j.Slf4j; +import stirling.software.common.model.tool.ToolFormat; +import stirling.software.common.model.tool.ToolIO; import stirling.software.common.service.CustomPDFDocumentFactory; import stirling.software.common.service.PdfMetadataService; import stirling.software.common.service.UserServiceInterface; @@ -48,11 +50,13 @@ import tools.jackson.databind.node.ObjectNode; *
Runs as a Classification-policy pipeline step: it reads a bounded page window, asks the AI * engine to classify the document against the built-in label set, and stores the engine's JSON * answer — minus the transport-only {@code outcome} field — in the custom Info-dictionary key - * {@link PdfMetadataService#CLASSIFICATION_KEY}. Returns the labelled PDF. Not intended for direct - * client use. + * {@link PdfMetadataService#CLASSIFICATION_KEY}. Returns the labelled PDF. + * + *
Published in the API spec rather than hidden, so the tool-model generator emits it and a
+ * pipeline can name it as a step like any other tool. Classification is a thing a pipeline does,
+ * not a thing only the Classification policy may do.
*/
@Slf4j
-@Hidden
@RestController
@RequestMapping("/api/v1/ai/tools")
@Tag(name = "AI Tools", description = "Dispatchable AI-backed tools.")
@@ -99,19 +103,31 @@ public class ClassifyLabelController {
}
@PostMapping(value = "/classify-and-label", consumes = MediaType.MULTIPART_FORM_DATA_VALUE)
+ // PDF in, the same PDF out with a verdict on it, so a chain can be checked across this step.
+ @ToolIO(accepts = ToolFormat.PDF, produces = ToolFormat.PDF)
@Operation(
summary = "Classify a PDF and label its metadata",
description =
"Reads the first two and last two pages, classifies the document via the AI"
+ " engine, and stores the result in the StirlingPDFClassification"
- + " metadata field. Dispatched by the Classification policy; not"
- + " intended for direct client use.")
+ + " metadata field. A document that already carries a verdict is"
+ + " passed through untouched unless reclassify=true.")
public ResponseEntity This only reads back what a previous run of this step wrote. It is not a statement that
+ * the verdict is trustworthy: the key is ordinary PDF metadata that whoever supplied the file
+ * can set. Skipping the engine on the strength of it is safe because the cost of being wrong is
+ * a missing re-classification, not a wrong decision. Anything that makes a SECURITY decision
+ * from this field - routing a document somewhere on the strength of its label, say - must
+ * classify with {@code reclassify=true} rather than trust what arrived.
+ */
+ private static boolean isClassified(PDDocument document) {
+ PDDocumentInformation info = document.getDocumentInformation();
+ if (info == null) {
+ return false;
+ }
+ String existing = info.getCustomMetadataValue(PdfMetadataService.CLASSIFICATION_KEY);
+ return existing != null && !existing.isBlank();
+ }
+
private List The policy is owned by the internal API user rather than a placeholder name. An owner is not
+ * only an attribution label: a run with no triggering user (a sweep, a schedule) falls back to it
+ * for output ownership, and a step dispatch authenticates as it. A name with no user row behind it
+ * fails both, so the one identity that always exists is used.
*/
@Slf4j
@Component
@@ -34,6 +40,11 @@ public class DefaultClassificationPolicySeeder {
private static final String CLASSIFY_ENDPOINT = "/api/v1/ai/tools/classify-and-label";
private static final String POLICY_NAME = "Classification Policy";
+ /**
+ * Pre-existing seeds used this placeholder, which was never a user; see {@link #repairOwner}.
+ */
+ private static final String LEGACY_OWNER = "system";
+
private final PolicyStore policyStore;
private final TeamRepository teamRepository;
@@ -58,16 +69,34 @@ public class DefaultClassificationPolicySeeder {
if (teamId == null || TeamService.INTERNAL_TEAM_NAME.equals(teamName)) {
return;
}
- boolean alreadySeeded =
+ Policy existing =
policyStore.findByTeam(teamId).stream()
- .anyMatch(DefaultClassificationPolicySeeder::isClassification);
- if (alreadySeeded) {
+ .filter(DefaultClassificationPolicySeeder::isClassification)
+ .findFirst()
+ .orElse(null);
+ if (existing != null) {
+ repairOwner(existing);
return;
}
policyStore.save(defaultPolicy(teamId));
log.info("Seeded default Classification policy for team {}", teamId);
}
+ /**
+ * Move a policy seeded before the owner was a real identity onto the internal API user. Left
+ * alone otherwise, so an owner someone deliberately changed is never overwritten.
+ */
+ private void repairOwner(Policy policy) {
+ if (!LEGACY_OWNER.equals(policy.owner())) {
+ return;
+ }
+ policyStore.save(policy.withOwner(Role.INTERNAL_API_USER.getRoleId()));
+ log.info(
+ "Re-owned Classification policy {} from '{}' to the internal API user",
+ policy.id(),
+ LEGACY_OWNER);
+ }
+
private static boolean isClassification(Policy policy) {
return policy.output() != null
&& CATEGORY.equals(policy.output().options().get("categoryId"));
@@ -85,7 +114,7 @@ public class DefaultClassificationPolicySeeder {
return new Policy(
null,
POLICY_NAME,
- "system",
+ Role.INTERNAL_API_USER.getRoleId(),
true,
List.of(),
List.of(new PipelineStep(CLASSIFY_ENDPOINT, Map.of())),
diff --git a/app/proprietary/src/test/java/stirling/software/proprietary/controller/api/ClassifyLabelControllerTest.java b/app/proprietary/src/test/java/stirling/software/proprietary/controller/api/ClassifyLabelControllerTest.java
index 74749289a7..637f05b284 100644
--- a/app/proprietary/src/test/java/stirling/software/proprietary/controller/api/ClassifyLabelControllerTest.java
+++ b/app/proprietary/src/test/java/stirling/software/proprietary/controller/api/ClassifyLabelControllerTest.java
@@ -14,6 +14,7 @@ import static org.mockito.Mockito.when;
import java.util.List;
import org.apache.pdfbox.pdmodel.PDDocument;
+import org.apache.pdfbox.pdmodel.PDDocumentInformation;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.ArgumentCaptor;
@@ -76,7 +77,7 @@ class ClassifyLabelControllerTest {
.thenReturn("{\"outcome\":\"classification\",\"labels\":[\"invoice\"]}");
try {
- controller.classifyAndLabel(file);
+ controller.classifyAndLabel(file, false);
} catch (Exception ignored) {
// WebResponseUtils.pdfDocToWebResponse needs a real temp file; the engine call and
// metadata write we assert on have already happened by the time it runs.
@@ -89,6 +90,52 @@ class ClassifyLabelControllerTest {
return objectMapper.readTree(body.getValue());
}
+ /** Stubs a document that already carries a verdict, as a second run over a batch would see. */
+ private MultipartFile alreadyClassifiedDocument() throws Exception {
+ PDDocument document = mock(PDDocument.class);
+ PDDocumentInformation info = mock(PDDocumentInformation.class);
+ when(document.getDocumentInformation()).thenReturn(info);
+ when(info.getCustomMetadataValue(PdfMetadataService.CLASSIFICATION_KEY))
+ .thenReturn("{\"labels\":[\"invoice\"]}");
+ MultipartFile file = mock(MultipartFile.class);
+ when(file.getOriginalFilename()).thenReturn("invoice.pdf");
+ when(pdfDocumentFactory.load(any(MultipartFile.class), eq(true))).thenReturn(document);
+ return file;
+ }
+
+ @Test
+ void classifyAndLabel_skipsADocumentThatAlreadyCarriesAVerdict() throws Exception {
+ withLabels(List.of(new ClassificationLabel("invoice", "Invoice", null)));
+ MultipartFile file = alreadyClassifiedDocument();
+
+ try {
+ controller.classifyAndLabel(file, false);
+ } catch (Exception ignored) {
+ // The response needs a real temp file; the decision under test happens before it.
+ }
+
+ // No second engine call, and no charge for one: re-classifying buys the same answer twice.
+ verify(aiEngineClient, never()).post(anyString(), anyString(), any());
+ verify(pdfMetadataService, never()).setClassificationMetadata(any(), anyString());
+ }
+
+ @Test
+ void classifyAndLabel_reclassifiesWhenAskedTo() throws Exception {
+ withLabels(List.of(new ClassificationLabel("invoice", "Invoice", null)));
+ MultipartFile file = alreadyClassifiedDocument();
+ when(pdfContentExtractor.extractPageTextRaw(any(), eq(1))).thenReturn("Invoice total");
+ when(aiEngineClient.post(eq("/api/v1/documents/classify"), anyString(), isNull()))
+ .thenReturn("{\"outcome\":\"classification\",\"labels\":[\"receipt\"]}");
+
+ try {
+ controller.classifyAndLabel(file, true);
+ } catch (Exception ignored) {
+ // As above.
+ }
+
+ verify(aiEngineClient).post(eq("/api/v1/documents/classify"), anyString(), isNull());
+ }
+
@Test
void classifyAndLabel_writesClassificationWithoutOutcome() throws Exception {
withLabels(List.of(new ClassificationLabel("invoice", "Invoice", null)));
diff --git a/app/proprietary/src/test/java/stirling/software/proprietary/policy/seed/DefaultClassificationPolicySeederTest.java b/app/proprietary/src/test/java/stirling/software/proprietary/policy/seed/DefaultClassificationPolicySeederTest.java
index 49159e7d6e..f51f474043 100644
--- a/app/proprietary/src/test/java/stirling/software/proprietary/policy/seed/DefaultClassificationPolicySeederTest.java
+++ b/app/proprietary/src/test/java/stirling/software/proprietary/policy/seed/DefaultClassificationPolicySeederTest.java
@@ -17,6 +17,7 @@ import org.mockito.ArgumentCaptor;
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;
+import stirling.software.common.model.enumeration.Role;
import stirling.software.proprietary.model.Team;
import stirling.software.proprietary.model.TeamCreatedEvent;
import stirling.software.proprietary.policy.model.OutputSpec;
@@ -36,10 +37,14 @@ class DefaultClassificationPolicySeederTest {
}
private static Policy classificationPolicy(Long teamId) {
+ return classificationPolicy(teamId, Role.INTERNAL_API_USER.getRoleId());
+ }
+
+ private static Policy classificationPolicy(Long teamId, String owner) {
return new Policy(
"p1",
"Classification Policy",
- "system",
+ owner,
true,
List.of(),
List.of(),
@@ -77,6 +82,29 @@ class DefaultClassificationPolicySeederTest {
verify(policyStore, never()).save(any());
}
+ @Test
+ void reOwnsAPolicySeededUnderThePlaceholderName() {
+ when(policyStore.findByTeam(7L)).thenReturn(List.of(classificationPolicy(7L, "system")));
+
+ seeder().onTeamCreated(new TeamCreatedEvent(7L, "Acme"));
+
+ // "system" was never a user row: a sweep-fired run would fall back to it for output
+ // ownership, and a step dispatch would authenticate as it. Both need a real identity.
+ ArgumentCaptor