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 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 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. + * + *

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 extractWindow(PDDocument document) throws IOException { List pages = new ArrayList<>(); for (int pageNumber : windowPageNumbers(document.getNumberOfPages(), WINDOW_PAGES)) { diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/policy/model/Policy.java b/app/proprietary/src/main/java/stirling/software/proprietary/policy/model/Policy.java index a3d43b712d..14b1eb325c 100644 --- a/app/proprietary/src/main/java/stirling/software/proprietary/policy/model/Policy.java +++ b/app/proprietary/src/main/java/stirling/software/proprietary/policy/model/Policy.java @@ -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 newOutputIds) { return new Policy(id, name, owner, enabled, inputs, steps, output, newOutputIds, teamId); diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/policy/seed/DefaultClassificationPolicySeeder.java b/app/proprietary/src/main/java/stirling/software/proprietary/policy/seed/DefaultClassificationPolicySeeder.java index 63ebae833a..40ecfe91a9 100644 --- a/app/proprietary/src/main/java/stirling/software/proprietary/policy/seed/DefaultClassificationPolicySeeder.java +++ b/app/proprietary/src/main/java/stirling/software/proprietary/policy/seed/DefaultClassificationPolicySeeder.java @@ -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. + * + *

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 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")); diff --git a/frontend/editor/scripts/generate-tool-api-types.mts b/frontend/editor/scripts/generate-tool-api-types.mts index d5990096af..47438ab26a 100644 --- a/frontend/editor/scripts/generate-tool-api-types.mts +++ b/frontend/editor/scripts/generate-tool-api-types.mts @@ -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 diff --git a/frontend/editor/src/core/data/classifyIsAPipelineTask.test.tsx b/frontend/editor/src/core/data/classifyIsAPipelineTask.test.tsx new file mode 100644 index 0000000000..6313c9e48b --- /dev/null +++ b/frontend/editor/src/core/data/classifyIsAPipelineTask.test.tsx @@ -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 }); + }); +}); diff --git a/frontend/editor/src/core/data/useTranslatedToolRegistry.tsx b/frontend/editor/src/core/data/useTranslatedToolRegistry.tsx index 5ae8075d0f..703bf73011 100644 --- a/frontend/editor/src/core/data/useTranslatedToolRegistry.tsx +++ b/frontend/editor/src/core/data/useTranslatedToolRegistry.tsx @@ -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: ( + + ), + 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: ( 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 => ({ + 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, +}); diff --git a/frontend/editor/src/core/types/toolApiTypes.ts b/frontend/editor/src/core/types/toolApiTypes.ts index bf498eb450..d0891ff5ca 100644 --- a/frontend/editor/src/core/types/toolApiTypes.ts +++ b/frontend/editor/src/core/types/toolApiTypes.ts @@ -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", diff --git a/frontend/editor/src/core/types/toolIO.ts b/frontend/editor/src/core/types/toolIO.ts index 96572c65f4..6565aef920 100644 --- a/frontend/editor/src/core/types/toolIO.ts +++ b/frontend/editor/src/core/types/toolIO.ts @@ -99,6 +99,11 @@ export interface ToolIOSpec { export type ToolIOTable = Partial>; 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", diff --git a/frontend/editor/src/core/types/toolId.ts b/frontend/editor/src/core/types/toolId.ts index 13799ad869..0762c2e81d 100644 --- a/frontend/editor/src/core/types/toolId.ts +++ b/frontend/editor/src/core/types/toolId.ts @@ -50,6 +50,7 @@ export const CORE_REGULAR_TOOL_IDS = [ "removeCertSign", "unlockPDFForms", "compress", + "classify", "extractPages", "reorganizePages", "extractImages",