mirror of
https://github.com/Stirling-Tools/Stirling-PDF.git
synced 2026-09-03 05:10:16 +03:00
Make classification an authorable pipeline task and skip already-classified files
This commit is contained in:
+42
-7
@@ -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;
|
||||
* <p>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.
|
||||
*
|
||||
* <p>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<Resource> classifyAndLabel(
|
||||
@RequestParam("fileInput") MultipartFile fileInput) throws IOException {
|
||||
@RequestParam("fileInput") MultipartFile fileInput,
|
||||
@RequestParam(value = "reclassify", defaultValue = "false") boolean reclassify)
|
||||
throws IOException {
|
||||
aiFeatureGate.requireClassify();
|
||||
try (PDDocument document = pdfDocumentFactory.load(fileInput, true)) {
|
||||
String fileName = safeFileName(fileInput.getOriginalFilename());
|
||||
|
||||
if (!reclassify && isClassified(document)) {
|
||||
// Classifying twice costs a second engine call and charges for it, and a document
|
||||
// that already carries a verdict has nothing new to learn. A pipeline can run this
|
||||
// step over a mixed batch without paying for the ones already done.
|
||||
log.debug("[classify-and-label] {} already classified; passing through", fileName);
|
||||
return WebResponseUtils.pdfDocToWebResponse(document, fileName, tempFileManager);
|
||||
}
|
||||
|
||||
List<EngineLabel> allowed = resolveAllowedLabels();
|
||||
if (allowed.isEmpty()) {
|
||||
// No vocabulary to classify against: pass the file through unlabelled rather than
|
||||
@@ -135,6 +151,25 @@ public class ClassifyLabelController {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether a verdict is already on the document.
|
||||
*
|
||||
* <p>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<AiPageText> extractWindow(PDDocument document) throws IOException {
|
||||
List<AiPageText> pages = new ArrayList<>();
|
||||
for (int pageNumber : windowPageNumbers(document.getNumberOfPages(), WINDOW_PAGES)) {
|
||||
|
||||
@@ -85,6 +85,11 @@ public record Policy(
|
||||
return new Policy(id, name, owner, enabled, inputs, steps, resolved, outputIds, teamId);
|
||||
}
|
||||
|
||||
/** A copy under a different owner (e.g. moving a seed off a placeholder name). */
|
||||
public Policy withOwner(String newOwner) {
|
||||
return new Policy(id, name, newOwner, enabled, inputs, steps, output, outputIds, teamId);
|
||||
}
|
||||
|
||||
/** A copy referencing the given saved output destinations. */
|
||||
public Policy withOutputIds(List<String> newOutputIds) {
|
||||
return new Policy(id, name, owner, enabled, inputs, steps, output, newOutputIds, teamId);
|
||||
|
||||
+33
-4
@@ -13,6 +13,7 @@ import org.springframework.transaction.event.TransactionalEventListener;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
|
||||
import stirling.software.common.model.enumeration.Role;
|
||||
import stirling.software.proprietary.model.TeamCreatedEvent;
|
||||
import stirling.software.proprietary.policy.model.OutputSpec;
|
||||
import stirling.software.proprietary.policy.model.PipelineStep;
|
||||
@@ -24,6 +25,11 @@ import stirling.software.proprietary.security.service.TeamService;
|
||||
/**
|
||||
* Seeds an enabled Classification policy per team so classification is on by default. Idempotent;
|
||||
* skips the internal team.
|
||||
*
|
||||
* <p>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())),
|
||||
|
||||
+48
-1
@@ -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)));
|
||||
|
||||
+29
-1
@@ -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<Policy> saved = ArgumentCaptor.forClass(Policy.class);
|
||||
verify(policyStore).save(saved.capture());
|
||||
assertThat(saved.getValue().owner()).isEqualTo(Role.INTERNAL_API_USER.getRoleId());
|
||||
assertThat(saved.getValue().id()).isEqualTo("p1");
|
||||
}
|
||||
|
||||
@Test
|
||||
void leavesADeliberatelyChosenOwnerAlone() {
|
||||
when(policyStore.findByTeam(7L)).thenReturn(List.of(classificationPolicy(7L, "alice")));
|
||||
|
||||
seeder().onTeamCreated(new TeamCreatedEvent(7L, "Acme"));
|
||||
|
||||
verify(policyStore, never()).save(any());
|
||||
}
|
||||
|
||||
@Test
|
||||
void doesNotSeedForTheInternalTeam() {
|
||||
seeder().onTeamCreated(new TeamCreatedEvent(2L, "Internal"));
|
||||
|
||||
@@ -12,12 +12,11 @@ import { parseArgs } from "node:util";
|
||||
import { compile, type JSONSchema } from "json-schema-to-typescript";
|
||||
import * as prettier from "prettier";
|
||||
|
||||
// The API namespaces whose endpoints a pipeline can reference. `/api/v1/ai/tools/`
|
||||
// is absent from the spec, so it cannot appear here. Extend this list when other
|
||||
// The API namespaces whose endpoints a pipeline can reference. Extend this list when other
|
||||
// namespaces become tools.
|
||||
//
|
||||
// `/api/v1/filter/` and `/api/v1/integration/` are included even though neither is a
|
||||
// user-facing tool: a stored pipeline can contain one, and ToolEndpoint keys the I/O
|
||||
// `/api/v1/filter/`, `/api/v1/integration/` and `/api/v1/ai/tools/` are included even though none
|
||||
// is an ordinary user-facing tool: a stored pipeline can contain one, and ToolEndpoint keys the I/O
|
||||
// table, so leaving them out would stop a chain being checked past such a step.
|
||||
const ALLOWED_PATH_PREFIXES = [
|
||||
"/api/v1/general/",
|
||||
@@ -26,6 +25,7 @@ const ALLOWED_PATH_PREFIXES = [
|
||||
"/api/v1/convert/",
|
||||
"/api/v1/filter/",
|
||||
"/api/v1/integration/",
|
||||
"/api/v1/ai/tools/",
|
||||
];
|
||||
|
||||
// File plumbing, not user parameters: `fileInput` is the uploaded document and
|
||||
|
||||
@@ -0,0 +1,63 @@
|
||||
/**
|
||||
* Classification is a thing a pipeline can do, not a thing only the Classification policy may do.
|
||||
*
|
||||
* The chain that used to stop it: `getExecutableTools` drops any tool whose endpoint is not a
|
||||
* member of the generated `ToolEndpoint` union; that union comes from the OpenAPI spec, gated by
|
||||
* the generator's namespace allowlist; and the classify controller was `@Hidden`, so it never
|
||||
* reached the spec at all. These tests pin each link, because any one of them silently removes the
|
||||
* step from the builder's picker rather than failing loudly.
|
||||
*/
|
||||
import { describe, expect, test, vi } from "vitest";
|
||||
import { renderHook } from "@testing-library/react";
|
||||
import { useTranslatedToolCatalog } from "@app/data/useTranslatedToolRegistry";
|
||||
import { getExecutableTools } from "@app/hooks/tools/shared/toolAutomation";
|
||||
import { isToolEndpoint } from "@app/hooks/tools/shared/toolApiMapping";
|
||||
import { TOOL_IO } from "@app/types/toolIO";
|
||||
|
||||
vi.mock("react-i18next", () => ({
|
||||
useTranslation: () => ({
|
||||
t: (key: string, fallback?: string) => fallback ?? key,
|
||||
i18n: { changeLanguage: vi.fn(), language: "en-US" },
|
||||
}),
|
||||
Trans: ({ children }: { children?: unknown }) => children,
|
||||
}));
|
||||
|
||||
const CLASSIFY_ENDPOINT = "/api/v1/ai/tools/classify-and-label";
|
||||
|
||||
describe("classify as a pipeline task", () => {
|
||||
test("the classify endpoint is a generated ToolEndpoint", () => {
|
||||
// Fails if the controller goes back to @Hidden, or the generator's allowlist drops the
|
||||
// /api/v1/ai/tools/ namespace, or nobody regenerated after either.
|
||||
expect(isToolEndpoint(CLASSIFY_ENDPOINT)).toBe(true);
|
||||
});
|
||||
|
||||
test("the builder offers it as a step", () => {
|
||||
const { result } = renderHook(() => useTranslatedToolCatalog());
|
||||
|
||||
const executable = getExecutableTools(result.current.regularTools);
|
||||
const classify = executable.find((tool) => tool.toolId === "classify");
|
||||
|
||||
expect(classify).toBeDefined();
|
||||
expect(classify?.endpoint).toBe(CLASSIFY_ENDPOINT);
|
||||
});
|
||||
|
||||
test("it declares PDF in, PDF out, so a chain can be checked across it", () => {
|
||||
// Without this the builder shows "Can't check what this step accepts" and validation stops
|
||||
// dead at the step - the I/O table is keyed by endpoint and comes from @ToolIO in the spec.
|
||||
expect(TOOL_IO[CLASSIFY_ENDPOINT]).toEqual({
|
||||
accepts: ["PDF"],
|
||||
produces: "PDF",
|
||||
arity: "SISO",
|
||||
});
|
||||
});
|
||||
|
||||
test("it does not re-classify by default", () => {
|
||||
const { result } = renderHook(() => useTranslatedToolCatalog());
|
||||
|
||||
const config = result.current.regularTools.classify?.operationConfig;
|
||||
|
||||
// The step is idempotent unless asked otherwise: a second run on a classified document
|
||||
// would be a second engine call, and a second charge, for the same answer.
|
||||
expect(config?.defaultParameters).toEqual({ reclassify: false });
|
||||
});
|
||||
});
|
||||
@@ -20,6 +20,7 @@ import {
|
||||
import { adjustContrastOperationConfig } from "@app/hooks/tools/adjustContrast/useAdjustContrastOperation";
|
||||
import { getSynonyms } from "@app/utils/toolSynonyms";
|
||||
import { useProprietaryToolRegistry } from "@app/data/useProprietaryToolRegistry";
|
||||
import { classifyOperationConfig } from "@app/hooks/tools/classify/useClassifyOperation";
|
||||
import { compressOperationConfig } from "@app/hooks/tools/compress/useCompressOperation";
|
||||
import { splitOperationConfig } from "@app/hooks/tools/split/useSplitOperation";
|
||||
import { addPasswordOperationConfig } from "@app/hooks/tools/addPassword/useAddPasswordOperation";
|
||||
@@ -1338,6 +1339,28 @@ export function useTranslatedToolCatalog(): TranslatedToolCatalog {
|
||||
synonyms: getSynonyms(t, "compare"),
|
||||
supportsAutomate: false,
|
||||
},
|
||||
classify: {
|
||||
icon: (
|
||||
<LocalIcon
|
||||
icon="label-outline-rounded"
|
||||
width="1.5rem"
|
||||
height="1.5rem"
|
||||
/>
|
||||
),
|
||||
name: t("home.classify.title", "Classify"),
|
||||
// No interactive UI: this is a pipeline step, registered so a pipeline can name it.
|
||||
component: null,
|
||||
description: t(
|
||||
"home.classify.desc",
|
||||
"Identify what kind of document this is and tag it.",
|
||||
),
|
||||
categoryId: ToolCategoryId.ADVANCED_TOOLS,
|
||||
subcategoryId: SubcategoryId.GENERAL,
|
||||
maxFiles: -1,
|
||||
endpoints: ["classify-and-label"],
|
||||
operationConfig: asRegistryConfig(classifyOperationConfig),
|
||||
automationSettings: null,
|
||||
},
|
||||
compress: {
|
||||
icon: (
|
||||
<LocalIcon
|
||||
|
||||
@@ -0,0 +1,52 @@
|
||||
import { defineSingleFileTool } from "@app/hooks/tools/shared/useToolOperation";
|
||||
import {
|
||||
objectToFormData,
|
||||
type ToolApiParams,
|
||||
type ToolEndpoint,
|
||||
} from "@app/hooks/tools/shared/toolApiMapping";
|
||||
|
||||
/**
|
||||
* Classification as an ordinary pipeline step.
|
||||
*
|
||||
* <p>The tool reads a window of the document, asks the AI engine what kind of document it is, and
|
||||
* writes the verdict into the PDF's metadata. It has no interactive UI - there is nothing for a
|
||||
* user to set beyond whether to redo work already done - so it exists in the registry purely so a
|
||||
* pipeline can name it, the same way the Classification policy always could.
|
||||
*/
|
||||
const ENDPOINT = "/api/v1/ai/tools/classify-and-label" satisfies ToolEndpoint;
|
||||
type ClassifyApiParams = ToolApiParams[typeof ENDPOINT];
|
||||
|
||||
export interface ClassifyParameters {
|
||||
/**
|
||||
* Classify again even when the document already carries a verdict. Off by default: re-running
|
||||
* costs a second engine call, and a document that has been classified has nothing new to say.
|
||||
*/
|
||||
reclassify: boolean;
|
||||
}
|
||||
|
||||
export const defaultParameters: ClassifyParameters = { reclassify: false };
|
||||
|
||||
export const classifyToApiParams = (
|
||||
parameters: ClassifyParameters,
|
||||
): ClassifyApiParams => ({ reclassify: parameters.reclassify });
|
||||
|
||||
export const classifyFromApiParams = (
|
||||
apiParams: ClassifyApiParams,
|
||||
): Partial<ClassifyParameters> => ({
|
||||
reclassify: apiParams.reclassify ?? defaultParameters.reclassify,
|
||||
});
|
||||
|
||||
export const buildClassifyFormData = (
|
||||
parameters: ClassifyParameters,
|
||||
file: File,
|
||||
): FormData =>
|
||||
objectToFormData(classifyToApiParams(parameters), { fileInput: file });
|
||||
|
||||
export const classifyOperationConfig = defineSingleFileTool({
|
||||
buildFormData: buildClassifyFormData,
|
||||
toApiParams: classifyToApiParams,
|
||||
fromApiParams: classifyFromApiParams,
|
||||
operationType: "classify",
|
||||
endpoint: ENDPOINT,
|
||||
defaultParameters,
|
||||
});
|
||||
@@ -201,6 +201,21 @@ export interface AddWatermarkRequest {
|
||||
*/
|
||||
widthSpacer?: number;
|
||||
}
|
||||
export interface AiToolsClassifyAndLabelRequest {
|
||||
reclassify?: boolean;
|
||||
}
|
||||
export interface AiToolsMathAuditorAgentRequest {
|
||||
/**
|
||||
* Arithmetic tolerance — differences smaller than this are ignored (default: 0.01)
|
||||
*/
|
||||
tolerance?: number;
|
||||
}
|
||||
export interface AiToolsPdfCommentAgentRequest {
|
||||
/**
|
||||
* Natural-language instructions for the AI — what to comment on
|
||||
*/
|
||||
prompt: string;
|
||||
}
|
||||
export interface AutoRotatePdfRequest {
|
||||
/**
|
||||
* Minimum Tesseract OSD orientation confidence required before a correction is applied. Matches OCRmyPDF's --rotate-pages-threshold scale
|
||||
@@ -1441,6 +1456,9 @@ export interface UrlToPdfRequest {
|
||||
|
||||
/** Endpoint path for a generated tool operation (the operation identity across languages). */
|
||||
export type ToolEndpoint =
|
||||
| "/api/v1/ai/tools/classify-and-label"
|
||||
| "/api/v1/ai/tools/math-auditor-agent"
|
||||
| "/api/v1/ai/tools/pdf-comment-agent"
|
||||
| "/api/v1/convert/cbr/pdf"
|
||||
| "/api/v1/convert/cbz/pdf"
|
||||
| "/api/v1/convert/ebook/pdf"
|
||||
@@ -1541,6 +1559,9 @@ export type ToolEndpoint =
|
||||
|
||||
/** Backend request-parameter model for each tool endpoint. */
|
||||
export interface ToolApiParams {
|
||||
"/api/v1/ai/tools/classify-and-label": AiToolsClassifyAndLabelRequest;
|
||||
"/api/v1/ai/tools/math-auditor-agent": AiToolsMathAuditorAgentRequest;
|
||||
"/api/v1/ai/tools/pdf-comment-agent": AiToolsPdfCommentAgentRequest;
|
||||
"/api/v1/convert/cbr/pdf": ConvertCbrToPdfRequest;
|
||||
"/api/v1/convert/cbz/pdf": ConvertCbzToPdfRequest;
|
||||
"/api/v1/convert/ebook/pdf": ConvertEbookToPdfRequest;
|
||||
@@ -1642,6 +1663,9 @@ export interface ToolApiParams {
|
||||
|
||||
/** Every generated tool endpoint, for iteration. */
|
||||
export const TOOL_ENDPOINTS = [
|
||||
"/api/v1/ai/tools/classify-and-label",
|
||||
"/api/v1/ai/tools/math-auditor-agent",
|
||||
"/api/v1/ai/tools/pdf-comment-agent",
|
||||
"/api/v1/convert/cbr/pdf",
|
||||
"/api/v1/convert/cbz/pdf",
|
||||
"/api/v1/convert/ebook/pdf",
|
||||
|
||||
@@ -99,6 +99,11 @@ export interface ToolIOSpec {
|
||||
export type ToolIOTable = Partial<Record<ToolEndpoint, ToolIOSpec>>;
|
||||
|
||||
export const TOOL_IO: ToolIOTable = {
|
||||
"/api/v1/ai/tools/classify-and-label": {
|
||||
accepts: ["PDF"],
|
||||
produces: "PDF",
|
||||
arity: "SISO",
|
||||
},
|
||||
"/api/v1/convert/cbr/pdf": {
|
||||
accepts: ["CBR"],
|
||||
produces: "PDF",
|
||||
|
||||
@@ -50,6 +50,7 @@ export const CORE_REGULAR_TOOL_IDS = [
|
||||
"removeCertSign",
|
||||
"unlockPDFForms",
|
||||
"compress",
|
||||
"classify",
|
||||
"extractPages",
|
||||
"reorganizePages",
|
||||
"extractImages",
|
||||
|
||||
Reference in New Issue
Block a user