Runs as a Classification-policy pipeline step: it reads a bounded page window, asks the AI
+ * engine to classify the document against the caller's team 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.
+ */
+@Slf4j
+@Hidden
+@RestController
+@RequestMapping("/api/v1/ai/tools")
+@Tag(name = "AI Tools", description = "Dispatchable AI-backed tools.")
+public class ClassifyLabelController {
+
+ /** Pages read from each end of the document β mirrors the engine's window. */
+ private static final int WINDOW_PAGES = 2;
+
+ private static final String CLASSIFY_ENDPOINT = "/api/v1/documents/classify";
+
+ private final CustomPDFDocumentFactory pdfDocumentFactory;
+ private final TempFileManager tempFileManager;
+ private final PdfContentExtractor pdfContentExtractor;
+ private final PdfMetadataService pdfMetadataService;
+ private final AiEngineClient aiEngineClient;
+ private final ObjectMapper objectMapper;
+ private final UserServiceInterface userService;
+
+ /**
+ * Present only when the policy subsystem is enabled ({@code policies.enabled}); the store and
+ * team authority are gated on it. Null otherwise, in which case there are no team labels to
+ * classify against and the document is passed through unlabelled.
+ */
+ private final ClassificationLabelStore labelStore;
+
+ private final PolicyManagementAuthority policyManagementAuthority;
+
+ public ClassifyLabelController(
+ CustomPDFDocumentFactory pdfDocumentFactory,
+ TempFileManager tempFileManager,
+ PdfContentExtractor pdfContentExtractor,
+ PdfMetadataService pdfMetadataService,
+ AiEngineClient aiEngineClient,
+ ObjectMapper objectMapper,
+ @Autowired(required = false) UserServiceInterface userService,
+ @Autowired(required = false) ClassificationLabelStore labelStore,
+ @Autowired(required = false) PolicyManagementAuthority policyManagementAuthority) {
+ this.pdfDocumentFactory = pdfDocumentFactory;
+ this.tempFileManager = tempFileManager;
+ this.pdfContentExtractor = pdfContentExtractor;
+ this.pdfMetadataService = pdfMetadataService;
+ this.aiEngineClient = aiEngineClient;
+ this.objectMapper = objectMapper;
+ this.userService = userService;
+ this.labelStore = labelStore;
+ this.policyManagementAuthority = policyManagementAuthority;
+ }
+
+ @PostMapping(value = "/classify-and-label", consumes = MediaType.MULTIPART_FORM_DATA_VALUE)
+ @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.")
+ public ResponseEntity Mode option: "consume" (default) claims each file by moving it into {@code
- * .stirling/processing} then routes it to {@code .stirling/done} or {@code .stirling/error}, so
- * each file runs once; "snapshot" reads without moving, so every run sees the full set. Readiness
- * is checked first so files mid-write are skipped.
+ * Reads input files from a directory; each ready file is its own unit of work, claimed through the
+ * {@link ResolveContext} ledger rather than moved aside, so nothing accumulates in a work
+ * directory. Options: "mode" is "consume" (default: a processed file is removed once every policy
+ * that claimed it has settled successfully and it is still the version that ran; failures stay in
+ * place and are not retried until they change) or "snapshot" (stateless, every run sees the full
+ * set); "recursive" descends into subdirectories; "identity" is "stat" (default, any size/mtime
+ * change is a new version) or "hash" (content-verified, so a touch does not reprocess). Hidden
+ * files and directories, including the legacy {@code .stirling} work dir, are never picked up, and
+ * files mid-write are skipped by the readiness check.
*/
@Slf4j
@Service
@@ -38,11 +46,6 @@ import stirling.software.proprietary.policy.model.PolicyInputs;
public class FolderInputSource implements InputSource {
private static final String TYPE = FolderAccessGuard.FOLDER_TYPE;
- // Bookkeeping lives under one hidden dir so the watched folder stays tidy.
- private static final String WORK_SUBDIR = ".stirling";
- private static final String PROCESSING_SUBDIR = "processing";
- private static final String DONE_SUBDIR = "done";
- private static final String ERROR_SUBDIR = "error";
private final FileReadinessChecker readinessChecker;
private final FolderAccessGuard accessGuard;
@@ -68,70 +71,195 @@ public class FolderInputSource implements InputSource {
}
@Override
- public List