Move form detection off the AI endpoint prefix and into Automation

This commit is contained in:
Anthony Stirling
2026-08-22 19:47:54 +01:00
parent 5a0f7e88cf
commit 8e30da622c
14 changed files with 35 additions and 27 deletions
@@ -443,7 +443,7 @@ public class ApplicationProperties {
/**
* Auto Form Detection settings. The model itself is downloaded on demand by an admin (see
* {@code /api/v1/ai/form-detection-model/*}); only lightweight pointers are persisted here.
* {@code /api/v1/form/form-detection-model/*}); only lightweight pointers are persisted here.
*/
@Data
public static class FormDetection {
@@ -41,10 +41,14 @@ import stirling.software.proprietary.formdetection.service.FormDetectionModelMan
* Server-side detection endpoint. Gated behind the {@code form-detection} endpoint key, which is
* disabled until a model is installed (so the tool tile is greyed in the UI). Returns the shared
* detection schema, or - when {@code applyToPdf=true} - the AcroForm-applied PDF.
*
* <p>Deliberately under {@code /api/v1/form}, not {@code /api/v1/ai}: anything on the AI prefix is
* classified billable by {@code BillableOperationClassifier} and blocked with 402 when the instance
* is unlinked - which would gate on-device detection that never touches the server.
*/
@Slf4j
@RestController
@RequestMapping("/api/v1/ai/form-detection")
@RequestMapping("/api/v1/form/form-detection")
@ConditionalOnClass(name = "ai.onnxruntime.OrtEnvironment")
@RequiredArgsConstructor
@Tag(name = "Auto Form Detection")
@@ -26,10 +26,14 @@ import stirling.software.proprietary.formdetection.service.FormDetectionModelMan
* Admin-managed lifecycle for the Auto Form Detection model. Lives under the never-gated {@code
* form-detection-model} endpoint key so install/status stay reachable while the feature itself (the
* {@code form-detection} detect endpoint) is disabled until a model is ready.
*
* <p>Deliberately under {@code /api/v1/form}, not {@code /api/v1/ai}: anything on the AI prefix is
* classified billable by {@code BillableOperationClassifier} and blocked with 402 when the instance
* is unlinked - which would gate on-device detection that never touches the server.
*/
@Slf4j
@RestController
@RequestMapping("/api/v1/ai/form-detection-model")
@RequestMapping("/api/v1/form/form-detection-model")
@RequiredArgsConstructor
@Tag(name = "Auto Form Detection")
public class FormDetectionModelController {
@@ -36,7 +36,7 @@ import stirling.software.proprietary.formdetection.service.FormDetectionModelMan
*/
@Slf4j
@RestController
@RequestMapping("/api/v1/ai/form-detection-model")
@RequestMapping("/api/v1/form/form-detection-model")
@RequiredArgsConstructor
@Tag(name = "Auto Form Detection")
public class FormDetectionModelServeController {
@@ -7,7 +7,7 @@ import lombok.Data;
import lombok.NoArgsConstructor;
/**
* Snapshot returned by {@code GET /api/v1/ai/form-detection-model/status}. Includes the full
* Snapshot returned by {@code GET /api/v1/form/form-detection-model/status}. Includes the full
* catalog so the browser can read the active model's parity-critical pipeline spec.
*/
@Data
@@ -46,7 +46,7 @@ class FormDetectionControllerTest {
Mockito.when(manager.isReady()).thenReturn(false);
mvc(manager, Mockito.mock(OnnxFormDetector.class), Mockito.mock(PageRasterizer.class))
.perform(multipart("/api/v1/ai/form-detection/detect").file(pdf()))
.perform(multipart("/api/v1/form/form-detection/detect").file(pdf()))
.andExpect(status().isServiceUnavailable())
.andExpect(jsonPath("$.reason").value("DEPENDENCY"));
}
@@ -62,7 +62,7 @@ class FormDetectionControllerTest {
.thenReturn(List.of()); // no pages -> no detections, detector never called
mvc(manager, Mockito.mock(OnnxFormDetector.class), rasterizer)
.perform(multipart("/api/v1/ai/form-detection/detect").file(pdf()))
.perform(multipart("/api/v1/form/form-detection/detect").file(pdf()))
.andExpect(status().isOk())
.andExpect(jsonPath("$.detections").isArray())
.andExpect(jsonPath("$.detections").isEmpty());
@@ -83,7 +83,7 @@ class FormDetectionControllerTest {
Mockito.when(rasterizer.rasterize(Mockito.any(), Mockito.anyInt())).thenReturn(tooMany);
mvc(manager, Mockito.mock(OnnxFormDetector.class), rasterizer)
.perform(multipart("/api/v1/ai/form-detection/detect").file(pdf()))
.perform(multipart("/api/v1/form/form-detection/detect").file(pdf()))
.andExpect(status().isBadRequest())
.andExpect(jsonPath("$.reason").value("LIMIT"));
}
@@ -99,7 +99,7 @@ class FormDetectionControllerTest {
mvc(manager, Mockito.mock(OnnxFormDetector.class), rasterizer)
.perform(
multipart("/api/v1/ai/form-detection/detect")
multipart("/api/v1/form/form-detection/detect")
.file(pdf())
.param("confThreshold", "-42"))
.andExpect(status().isOk())
@@ -34,7 +34,7 @@ class FormDetectionModelControllerTest {
Mockito.when(manager.status()).thenReturn(notInstalled());
mvc(manager)
.perform(get("/api/v1/ai/form-detection-model/status"))
.perform(get("/api/v1/form/form-detection-model/status"))
.andExpect(status().isOk())
.andExpect(jsonPath("$.status").value("not_installed"))
.andExpect(jsonPath("$.writable").value(true));
@@ -47,7 +47,7 @@ class FormDetectionModelControllerTest {
mvc(manager)
.perform(
post("/api/v1/ai/form-detection-model/install")
post("/api/v1/form/form-detection-model/install")
.contentType(MediaType.APPLICATION_JSON)
.content("{\"modelId\":\"\"}"))
.andExpect(status().isBadRequest());
@@ -60,7 +60,7 @@ class FormDetectionModelControllerTest {
mvc(manager)
.perform(
post("/api/v1/ai/form-detection-model/install")
post("/api/v1/form/form-detection-model/install")
.contentType(MediaType.APPLICATION_JSON)
.content("{\"modelId\":\"ffdnet-s\"}"))
.andExpect(status().isAccepted());
@@ -76,7 +76,7 @@ class FormDetectionModelControllerTest {
mvc(manager)
.perform(
post("/api/v1/ai/form-detection-model/install")
post("/api/v1/form/form-detection-model/install")
.contentType(MediaType.APPLICATION_JSON)
.content("{\"modelId\":\"ffdnet-s\"}"))
.andExpect(status().isConflict());
@@ -88,7 +88,7 @@ class FormDetectionModelControllerTest {
Mockito.when(manager.status()).thenReturn(notInstalled());
mvc(manager)
.perform(delete("/api/v1/ai/form-detection-model"))
.perform(delete("/api/v1/form/form-detection-model"))
.andExpect(status().isOk())
.andExpect(jsonPath("$.status").value("not_installed"));
}
@@ -486,7 +486,7 @@ export function useTranslatedToolCatalog(): TranslatedToolCatalog {
"Automatically detect form fields with AI and make your PDF fillable.",
),
categoryId: ToolCategoryId.STANDARD_TOOLS,
subcategoryId: SubcategoryId.GENERAL,
subcategoryId: SubcategoryId.AUTOMATION,
maxFiles: 1,
endpoints: ["form-detection"],
unavailableMessage: t(
@@ -22,8 +22,8 @@ import {
resolveConfidence,
} from "@app/hooks/tools/autoFormDetection/useAutoFormDetectionParameters";
const DETECT_ENDPOINT = "/api/v1/ai/form-detection/detect";
const STATUS_URL = "/api/v1/ai/form-detection-model/status";
const DETECT_ENDPOINT = "/api/v1/form/form-detection/detect";
const STATUS_URL = "/api/v1/form/form-detection-model/status";
export const buildAutoFormDetectionFormData = (
parameters: AutoFormDetectionParameters,
@@ -51,10 +51,10 @@ export interface FormDetectionModelStatus {
downloadingModelId?: string | null;
}
const STATUS_URL = "/api/v1/ai/form-detection-model/status";
const INSTALL_URL = "/api/v1/ai/form-detection-model/install";
const CONFIG_URL = "/api/v1/ai/form-detection-model/config";
const MODEL_URL = "/api/v1/ai/form-detection-model";
const STATUS_URL = "/api/v1/form/form-detection-model/status";
const INSTALL_URL = "/api/v1/form/form-detection-model/install";
const CONFIG_URL = "/api/v1/form/form-detection-model/config";
const MODEL_URL = "/api/v1/form/form-detection-model";
/**
* Polls the Auto Form Detection model status and exposes admin install/uninstall actions.
@@ -1,7 +1,7 @@
// Fetch the active .onnx from the backend serve endpoint, verify its SHA-256, and keep it in the
// Cache API keyed by checksum so it is downloaded only once per device (then reused across reloads).
const MODEL_FILE_URL = "/api/v1/ai/form-detection-model/file";
const MODEL_FILE_URL = "/api/v1/form/form-detection-model/file";
const CACHE_NAME = "stirling-form-detection-models";
function toHex(buf: ArrayBuffer): string {
@@ -73,7 +73,7 @@ function modelStatus(overrides: Record<string, unknown> = {}) {
}
async function stubStatus(page: Page, overrides: Record<string, unknown> = {}) {
await page.route("**/api/v1/ai/form-detection-model/status", (r: Route) =>
await page.route("**/api/v1/form/form-detection-model/status", (r: Route) =>
r.fulfill({ json: modelStatus(overrides) }),
);
}
@@ -111,7 +111,7 @@ async function stubDetect(
) {
const pdf = fs.readFileSync(SAMPLE_PDF);
const detections = opts.detections ?? DETECTIONS;
await page.route("**/api/v1/ai/form-detection/detect", async (r: Route) => {
await page.route("**/api/v1/form/form-detection/detect", async (r: Route) => {
if (opts.delayMs) await new Promise((res) => setTimeout(res, opts.delayMs));
const body = r.request().postData() ?? "";
if (body.includes("applyToPdf") && body.includes("true")) {
@@ -35,7 +35,7 @@ const MODEL_STATUS_NOT_INSTALLED = {
};
async function stubModelStatus(page: Page) {
await page.route("**/api/v1/ai/form-detection-model/status", (route) =>
await page.route("**/api/v1/form/form-detection-model/status", (route) =>
route.fulfill({ json: MODEL_STATUS_NOT_INSTALLED }),
);
}
@@ -77,7 +77,7 @@ function modelStatus(overrides: StatusOverrides = {}) {
}
async function stubStatus(page: Page, overrides: StatusOverrides = {}) {
await page.route("**/api/v1/ai/form-detection-model/status", (r: Route) =>
await page.route("**/api/v1/form/form-detection-model/status", (r: Route) =>
r.fulfill({ json: modelStatus(overrides) }),
);
}
@@ -117,7 +117,7 @@ async function stubDetect(page: Page, delayMs = 0) {
confidence: 0.74,
},
];
await page.route("**/api/v1/ai/form-detection/detect", async (r: Route) => {
await page.route("**/api/v1/form/form-detection/detect", async (r: Route) => {
if (delayMs) await new Promise((res) => setTimeout(res, delayMs));
const body = r.request().postData() ?? "";
if (body.includes("applyToPdf") && body.includes("true")) {