Compare commits

...
101 changed files with 7273 additions and 42 deletions
+63
View File
@@ -13,6 +13,11 @@ on:
required: false
type: boolean
default: true
build_engine:
description: "Build & push the stirling-pdf-engine image (plus the -docparse addon variant)."
required: false
type: boolean
default: false
force_unoserver_rebuild:
description: "Rebuild stirling-unoserver even if its source hash is unchanged."
required: false
@@ -51,6 +56,8 @@ jobs:
env:
RUN_MAIN_APP: ${{ github.event_name != 'workflow_dispatch' || inputs.build_main_app }}
RUN_UNOSERVER: ${{ github.event_name != 'workflow_dispatch' || inputs.build_unoserver }}
# Engine images are dispatch-only for now; flip the default once the addon stabilises.
RUN_ENGINE: ${{ github.event_name == 'workflow_dispatch' && inputs.build_engine }}
steps:
- name: Harden Runner
uses: step-security/harden-runner@ab7a9404c0f3da075243ca237b5fac12c98deaa5 # v2.19.3
@@ -219,6 +226,62 @@ jobs:
cosign sign --key env://COSIGN_PRIVATE_KEY --yes "${tag}@${DIGEST}"
done
- name: Generate tags for engine
id: meta-engine
uses: docker/metadata-action@80c7e94dd9b9319bd5eb7a0e0fe9291e23a2a2e9 # v6.1.0
if: env.RUN_ENGINE == 'true'
with:
images: |
ghcr.io/${{ steps.repoowner.outputs.lowercase }}/stirling-pdf-engine
${{ secrets.DOCKER_HUB_ORG_USERNAME }}/stirling-pdf-engine
tags: |
type=raw,value=${{ steps.versionNumber.outputs.versionNumber }}
type=raw,value=latest
- name: Build and push engine image
id: build-push-engine
uses: docker/build-push-action@53b7df96c91f9c12dcc8a07bcb9ccacbed38856a # v7.3.0
if: env.RUN_ENGINE == 'true' && steps.meta-engine.outputs.tags != ''
with:
builder: ${{ steps.buildx.outputs.name }}
context: ./engine
push: true
cache-from: type=gha,scope=stirling-pdf-engine
cache-to: type=gha,mode=max,scope=stirling-pdf-engine
tags: ${{ steps.meta-engine.outputs.tags }}
labels: ${{ steps.meta-engine.outputs.labels }}
platforms: linux/amd64,linux/arm64/v8
provenance: true
sbom: true
- name: Generate tags for engine docparse addon
id: meta-engine-docparse
uses: docker/metadata-action@80c7e94dd9b9319bd5eb7a0e0fe9291e23a2a2e9 # v6.1.0
if: env.RUN_ENGINE == 'true'
with:
images: |
ghcr.io/${{ steps.repoowner.outputs.lowercase }}/stirling-pdf-engine
${{ secrets.DOCKER_HUB_ORG_USERNAME }}/stirling-pdf-engine
tags: |
type=raw,value=${{ steps.versionNumber.outputs.versionNumber }}-docparse
type=raw,value=latest-docparse
- name: Build and push engine docparse addon image
uses: docker/build-push-action@53b7df96c91f9c12dcc8a07bcb9ccacbed38856a # v7.3.0
if: env.RUN_ENGINE == 'true' && steps.meta-engine-docparse.outputs.tags != ''
with:
builder: ${{ steps.buildx.outputs.name }}
context: ./engine
push: true
cache-from: type=gha,scope=stirling-pdf-engine-docparse
cache-to: type=gha,mode=max,scope=stirling-pdf-engine-docparse
tags: ${{ steps.meta-engine-docparse.outputs.tags }}
labels: ${{ steps.meta-engine-docparse.outputs.labels }}
build-args: DOCPARSE=true
platforms: linux/amd64
provenance: true
sbom: true
- name: Generate tags for ultra-lite
id: meta-lite
uses: docker/metadata-action@80c7e94dd9b9319bd5eb7a0e0fe9291e23a2a2e9 # v6.1.0
@@ -433,6 +433,12 @@ public class EndpointConfiguration {
addEndpointToGroup("Automation", "automate"); // Alias for handleData (user-friendly name)
addEndpointToGroup("Automation", "pipeline");
// Adding endpoints to "DocParse" group (ingestion: chunk + index + export)
addEndpointToGroup("DocParse", "rag-ingest");
addEndpointToGroup("DocParse", "extract-tables");
addEndpointToGroup("DocParse", "extract-fields");
addEndpointToGroup("DocParse", "suggest-schema");
// Adding endpoints to "DeveloperTools" group
addEndpointToGroup("DeveloperTools", "show-javascript");
@@ -77,6 +77,7 @@ public class ApplicationProperties {
private ProcessExecutor processExecutor = new ProcessExecutor();
private PdfEditor pdfEditor = new PdfEditor();
private AiEngine aiEngine = new AiEngine();
private Docparse docparse = new Docparse();
private Mcp mcp = new Mcp();
private InternalApi internalApi = new InternalApi();
private Cluster cluster = new Cluster();
@@ -425,6 +426,24 @@ public class ApplicationProperties {
}
}
/**
* DocParse settings (top-level {@code docparse.*}): document understanding for ingestion
* pipelines. The basic tier (text layer) always works; the advanced tier lives in the engine's
* docparse addon.
*/
@Data
public static class Docparse {
/** Master switch; hides the DocParse endpoints when false. */
private boolean enabled = true;
/** Requested tier: 'auto', 'basic', or 'advanced'. 'auto' resolves per document. */
private String mode = "auto";
/** Mirrors DOCPARSE_AUTO_INSTALL for the engine's boot-time addon install script. */
private boolean autoInstall = false;
}
/**
* Model Context Protocol (MCP) server configuration. All keys live under the top-level {@code
* mcp.*} prefix. {@link #enabled} defaults to {@code false}: when off, no MCP beans are wired,
@@ -0,0 +1,16 @@
package stirling.software.common.service;
/**
* View of the engine's DocParse capability for modules that cannot see the proprietary
* implementation (e.g. ConfigController in core). Implemented by the proprietary
* DocparseCapabilityService; absent when the proprietary module is not loaded.
*/
public interface DocparseCapabilityServiceInterface {
/**
* Whether the engine reports the docparse addon (advanced tier) as installed. Must be cheap and
* non-blocking: returns the cached probe result, {@code false} when the engine is disabled,
* unreachable, or not yet probed.
*/
boolean isAdvancedInstalled();
}
@@ -53,7 +53,7 @@ public class InternalApiClient {
// ApiConnectionResolver.
private static final Pattern ALLOWED_ENDPOINT_PATH =
Pattern.compile(
"^/api/v1/(general|misc|security|convert|filter|integration)(/[A-Za-z0-9_-]+)+$"
"^/api/v1/(general|misc|security|convert|filter|integration|docparse)(/[A-Za-z0-9_-]+)+$"
+ "|^/api/v1/ai/tools(/[A-Za-z0-9_-]+)+$");
/**
@@ -24,6 +24,7 @@ import stirling.software.common.annotations.api.ConfigApi;
import stirling.software.common.configuration.AppConfig;
import stirling.software.common.configuration.interfaces.ShowAdminInterface;
import stirling.software.common.model.ApplicationProperties;
import stirling.software.common.service.DocparseCapabilityServiceInterface;
import stirling.software.common.service.ServerCertificateServiceInterface;
import stirling.software.common.service.UserServiceInterface;
import stirling.software.common.util.GeneralUtils;
@@ -41,6 +42,7 @@ public class ConfigController {
private final ShowAdminInterface showAdmin;
private final stirling.software.common.service.LicenseServiceInterface licenseService;
private final stirling.software.SPDF.config.ExternalAppDepConfig externalAppDepConfig;
private final DocparseCapabilityServiceInterface docparseCapabilityService;
public ConfigController(
ApplicationProperties applicationProperties,
@@ -54,7 +56,9 @@ public class ConfigController {
ShowAdminInterface showAdmin,
@org.springframework.beans.factory.annotation.Autowired(required = false)
stirling.software.common.service.LicenseServiceInterface licenseService,
stirling.software.SPDF.config.ExternalAppDepConfig externalAppDepConfig) {
stirling.software.SPDF.config.ExternalAppDepConfig externalAppDepConfig,
@org.springframework.beans.factory.annotation.Autowired(required = false)
DocparseCapabilityServiceInterface docparseCapabilityService) {
this.applicationProperties = applicationProperties;
this.applicationContext = applicationContext;
this.endpointConfiguration = endpointConfiguration;
@@ -63,6 +67,7 @@ public class ConfigController {
this.showAdmin = showAdmin;
this.licenseService = licenseService;
this.externalAppDepConfig = externalAppDepConfig;
this.docparseCapabilityService = docparseCapabilityService;
}
/**
@@ -350,6 +355,16 @@ public class ConfigController {
Map.entry("pdfComment", aiFeatures.isPdfComment()),
Map.entry("classify", aiFeatures.isClassify())));
// DocParse settings; "advanced" reflects the cached engine capability probe and is
// false when the engine is disabled, unreachable, or the proprietary module is absent.
boolean docparseEnabled = applicationProperties.getDocparse().isEnabled();
configData.put("docparseEnabled", docparseEnabled);
configData.put(
"docparseAdvanced",
docparseEnabled
&& docparseCapabilityService != null
&& docparseCapabilityService.isAdvancedInstalled());
// Timestamp TSA settings — single source of truth for presets + admin URLs
ApplicationProperties.Security.Timestamp tsConfig =
applicationProperties.getSecurity().getTimestamp();
@@ -396,6 +396,14 @@ aiEngine:
pdfComment: true # AI-authored PDF comments/annotations
classify: true # Automatic document classification/labelling
# DocParse: document understanding for ingestion pipelines (chunking + knowledge-base
# indexing). The basic tier (text layer) always works; the advanced tier (layout parsing)
# requires the engine's docparse addon. Env overrides: DOCPARSE_ENABLED, DOCPARSE_MODE.
docparse:
enabled: true # Master switch; hides the DocParse endpoints when false
mode: auto # Tier selection: 'auto' (best available), 'basic', or 'advanced'
autoInstall: false # Mirrors DOCPARSE_AUTO_INSTALL for the engine's boot-time addon install script
policies:
# Folder automations can read from and write to the directories you allow here, so treat this as a
# security boundary. Leave allowedFolderRoots empty (default) to disable folder sources/outputs,
@@ -74,7 +74,8 @@ class ConfigControllerMoreTest {
userService,
showAdmin,
licenseService,
externalAppDepConfig);
externalAppDepConfig,
null);
}
@SuppressWarnings("unchecked")
@@ -52,7 +52,8 @@ class ConfigControllerTest {
userService,
showAdmin,
licenseService,
mock(stirling.software.SPDF.config.ExternalAppDepConfig.class));
mock(stirling.software.SPDF.config.ExternalAppDepConfig.class),
null);
}
@Test
@@ -0,0 +1,303 @@
package stirling.software.proprietary.controller.api;
import java.io.ByteArrayOutputStream;
import java.io.IOException;
import java.io.StringWriter;
import java.nio.charset.StandardCharsets;
import java.util.List;
import java.util.zip.ZipEntry;
import java.util.zip.ZipOutputStream;
import org.apache.commons.csv.CSVFormat;
import org.apache.commons.csv.CSVPrinter;
import org.springframework.core.io.ByteArrayResource;
import org.springframework.core.io.Resource;
import org.springframework.http.HttpHeaders;
import org.springframework.http.MediaType;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.ModelAttribute;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
import org.springframework.web.multipart.MultipartFile;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.tags.Tag;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import stirling.software.common.annotations.AutoJobPostMapping;
import stirling.software.common.enumeration.ResourceWeight;
import stirling.software.common.util.GeneralUtils;
import stirling.software.common.util.WebResponseUtils;
import stirling.software.proprietary.model.api.docparse.ExtractFieldsApiRequest;
import stirling.software.proprietary.model.api.docparse.ExtractTablesApiRequest;
import stirling.software.proprietary.model.api.docparse.RagIngestApiRequest;
import stirling.software.proprietary.model.api.docparse.SuggestSchemaApiRequest;
import stirling.software.proprietary.model.docparse.DocChunk;
import stirling.software.proprietary.model.docparse.DocTable;
import stirling.software.proprietary.model.docparse.DocparseCapabilitiesView;
import stirling.software.proprietary.model.docparse.DocparseMode;
import stirling.software.proprietary.model.docparse.ExtractFieldsResponse;
import stirling.software.proprietary.model.docparse.ExtractTablesResponse;
import stirling.software.proprietary.model.docparse.RagIngestResponse;
import stirling.software.proprietary.model.docparse.SuggestSchemaResponse;
import stirling.software.proprietary.service.AiToolResponseHeaders;
import stirling.software.proprietary.service.DocParseService;
import tools.jackson.databind.ObjectMapper;
import tools.jackson.databind.node.ObjectNode;
/**
* Public DocParse ingestion API. Thin HTTP layer over {@link DocParseService}, which owns the
* engine wire contract; this class owns the pipeline step shape (report header, export ZIP).
*/
@Slf4j
@RestController
@RequestMapping("/api/v1/docparse")
@RequiredArgsConstructor
@Tag(
name = "DocParse",
description =
"Document ingestion: chunk, embed, and index documents into the searchable"
+ " knowledge base, or export the parsed content (markdown, chunks JSONL)"
+ " for external systems.")
public class DocParseController {
private static final MediaType CSV = MediaType.parseMediaType("text/csv");
private final DocParseService docParseService;
private final ObjectMapper objectMapper;
@AutoJobPostMapping(
consumes = MediaType.MULTIPART_FORM_DATA_VALUE,
value = "/rag-ingest",
resourceWeight = ResourceWeight.LARGE_WEIGHT)
@Operation(
summary = "Chunk, embed, and index a document into the RAG store (pipeline shape)",
description =
"Ingests the document into the engine's RAG store under a stable documentId"
+ " (default: content hash). Returns the ORIGINAL PDF unchanged as the"
+ " body, with the ingest summary JSON in the X-Stirling-Tool-Report"
+ " header so policy pipelines pick it up as the step report. With"
+ " exportMarkdown/exportChunksJsonl the body becomes a ZIP holding the"
+ " original plus the corpus files, ready for delivery to external"
+ " systems. Input:PDF Output:PDF/ZIP Type:SISO")
public ResponseEntity<Resource> ragIngest(@ModelAttribute RagIngestApiRequest request)
throws IOException {
MultipartFile file = request.getFileInput();
boolean export = request.isExportMarkdown() || request.isExportChunksJsonl();
RagIngestResponse result =
docParseService.ragIngest(
file,
request.getDocumentId(),
request.getChunkSize(),
request.getOverlap(),
DocparseMode.fromWire(request.getMode()),
request.isIndex(),
request.isExportMarkdown(),
request.isExportChunksJsonl());
// The report header must stay small: summary fields only, never the echoed content.
ObjectNode report = objectMapper.createObjectNode();
report.put("mode", result.mode().wire());
report.put("documentId", result.documentId());
report.put("chunksIndexed", result.chunksIndexed());
report.put("pages", result.pages());
report.put("indexed", request.isIndex());
String fileName = DocParseService.fileName(file);
byte[] original = file.getBytes();
HttpHeaders headers = new HttpHeaders();
headers.set(AiToolResponseHeaders.TOOL_REPORT, objectMapper.writeValueAsString(report));
if (!export) {
headers.setContentType(MediaType.APPLICATION_PDF);
headers.setContentDispositionFormData("attachment", fileName);
headers.setContentLength(original.length);
return ResponseEntity.ok().headers(headers).body(new ByteArrayResource(original));
}
byte[] zip = exportZip(fileName, original, result, request);
headers.setContentType(MediaType.parseMediaType("application/zip"));
headers.setContentDispositionFormData("attachment", baseName(fileName) + "-ingested.zip");
headers.setContentLength(zip.length);
return ResponseEntity.ok().headers(headers).body(new ByteArrayResource(zip));
}
@AutoJobPostMapping(
consumes = MediaType.MULTIPART_FORM_DATA_VALUE,
value = "/extract-fields",
resourceWeight = ResourceWeight.LARGE_WEIGHT)
@Operation(
summary = "Extract typed fields from a document (pipeline shape)",
description =
"Extracts the fields described by the JSON Schema and returns the ORIGINAL PDF"
+ " unchanged as the body, with the extraction JSON in the"
+ " X-Stirling-Tool-Report header so policy pipelines pick it up as the"
+ " step report. Use /extract-fields/json for the raw JSON."
+ " Input:PDF Output:PDF Type:SISO")
public ResponseEntity<Resource> extractFields(@ModelAttribute ExtractFieldsApiRequest request)
throws IOException {
MultipartFile file = request.getFileInput();
ExtractFieldsResponse result =
docParseService.extractFields(
file,
request.getFieldsSchema(),
DocparseMode.fromWire(request.getMode()),
request.getInstructions());
byte[] original = file.getBytes();
HttpHeaders headers = new HttpHeaders();
headers.setContentType(MediaType.APPLICATION_PDF);
headers.setContentDispositionFormData("attachment", DocParseService.fileName(file));
headers.setContentLength(original.length);
headers.set(AiToolResponseHeaders.TOOL_REPORT, objectMapper.writeValueAsString(result));
return ResponseEntity.ok().headers(headers).body(new ByteArrayResource(original));
}
@AutoJobPostMapping(
consumes = MediaType.MULTIPART_FORM_DATA_VALUE,
value = "/extract-fields/json",
resourceWeight = ResourceWeight.LARGE_WEIGHT)
@Operation(
summary = "Extract typed fields from a document (JSON)",
description =
"Extracts the fields described by the JSON Schema and returns the extraction"
+ " result (fields, confidence, citations) as JSON."
+ " Input:PDF Output:JSON Type:SISO")
public ResponseEntity<ExtractFieldsResponse> extractFieldsJson(
@ModelAttribute ExtractFieldsApiRequest request) throws IOException {
return ResponseEntity.ok(
docParseService.extractFields(
request.getFileInput(),
request.getFieldsSchema(),
DocparseMode.fromWire(request.getMode()),
request.getInstructions()));
}
@AutoJobPostMapping(
consumes = MediaType.MULTIPART_FORM_DATA_VALUE,
value = "/suggest-schema",
resourceWeight = ResourceWeight.LARGE_WEIGHT)
@Operation(
summary = "Suggest an extraction schema for a document",
description =
"Reads the document and proposes the fields worth extracting (name, type,"
+ " description), ready to feed into /extract-fields as a JSON Schema."
+ " Input:PDF Output:JSON Type:SISO")
public ResponseEntity<SuggestSchemaResponse> suggestSchema(
@ModelAttribute SuggestSchemaApiRequest request) throws IOException {
return ResponseEntity.ok(
docParseService.suggestSchema(request.getFileInput(), request.getMaxFields()));
}
@GetMapping("/capabilities")
@Operation(
summary = "DocParse capability summary",
description =
"Merged view of the Java settings and the engine's capability probe, so"
+ " clients can gate advanced-tier UI.")
public ResponseEntity<DocparseCapabilitiesView> capabilities() {
return ResponseEntity.ok(docParseService.capabilitiesView());
}
@AutoJobPostMapping(
consumes = MediaType.MULTIPART_FORM_DATA_VALUE,
value = "/extract-tables",
resourceWeight = ResourceWeight.LARGE_WEIGHT)
@Operation(
summary = "Extract tables from a document",
description =
"Extracts table structure and returns CSV (all tables concatenated, blank line"
+ " between them) or the structured JSON table list."
+ " Input:PDF Output:CSV/JSON Type:SISO")
public ResponseEntity<?> extractTables(@ModelAttribute ExtractTablesApiRequest request)
throws IOException {
ExtractTablesResponse result = docParseService.tables(request.getFileInput());
if ("json".equalsIgnoreCase(request.getOutputFormat())) {
return ResponseEntity.ok(result);
}
return WebResponseUtils.bytesToWebResponse(
tablesToCsv(result.tables()).getBytes(StandardCharsets.UTF_8),
outputName(request.getFileInput(), "_tables.csv"),
CSV);
}
/** Original + requested corpus files in one ZIP, so destinations receive them together. */
private byte[] exportZip(
String fileName, byte[] original, RagIngestResponse result, RagIngestApiRequest request)
throws IOException {
String base = baseName(fileName);
ByteArrayOutputStream out = new ByteArrayOutputStream();
try (ZipOutputStream zip = new ZipOutputStream(out)) {
zip.putNextEntry(new ZipEntry(fileName));
zip.write(original);
zip.closeEntry();
if (request.isExportMarkdown()) {
zip.putNextEntry(new ZipEntry(base + ".md"));
zip.write(
(result.markdown() == null ? "" : result.markdown())
.getBytes(StandardCharsets.UTF_8));
zip.closeEntry();
}
if (request.isExportChunksJsonl()) {
zip.putNextEntry(new ZipEntry(base + ".chunks.jsonl"));
zip.write(chunksJsonl(result).getBytes(StandardCharsets.UTF_8));
zip.closeEntry();
}
}
return out.toByteArray();
}
/** One chunk per line, each self-describing (documentId + source travel on every line). */
private String chunksJsonl(RagIngestResponse result) {
if (result.chunks() == null) {
return "";
}
StringBuilder lines = new StringBuilder();
for (DocChunk chunk : result.chunks()) {
ObjectNode line = objectMapper.createObjectNode();
line.put("documentId", result.documentId());
line.put("index", chunk.index());
line.put("text", chunk.text());
if (chunk.pageStart() != null) {
line.put("pageStart", chunk.pageStart());
}
if (chunk.pageEnd() != null) {
line.put("pageEnd", chunk.pageEnd());
}
var headings = line.putArray("headingPath");
chunk.headingPath().forEach(headings::add);
lines.append(objectMapper.writeValueAsString(line)).append('\n');
}
return lines.toString();
}
private static String baseName(String fileName) {
int dot = fileName.lastIndexOf('.');
return dot > 0 ? fileName.substring(0, dot) : fileName;
}
private static String tablesToCsv(List<DocTable> tables) throws IOException {
CSVFormat format = CSVFormat.EXCEL.builder().setEscape('"').build();
StringWriter writer = new StringWriter();
try (CSVPrinter printer = format.print(writer)) {
boolean first = true;
for (DocTable table : tables) {
if (!first) {
printer.println();
}
first = false;
for (List<String> row : table.cells()) {
printer.printRecord(row);
}
}
}
return writer.toString();
}
private static String outputName(MultipartFile file, String suffix) {
return GeneralUtils.removeExtension(DocParseService.fileName(file)) + suffix;
}
}
@@ -0,0 +1,29 @@
package stirling.software.proprietary.model.api.docparse;
import io.swagger.v3.oas.annotations.media.Schema;
import lombok.Data;
import lombok.EqualsAndHashCode;
import stirling.software.common.model.api.PDFFile;
@Data
@EqualsAndHashCode(callSuper = true)
public class ExtractFieldsApiRequest extends PDFFile {
@Schema(
description = "JSON Schema object describing the fields to extract, as a JSON string",
requiredMode = Schema.RequiredMode.REQUIRED,
example =
"{\"type\":\"object\",\"properties\":{\"invoiceNumber\":{\"type\":\"string\"}}}")
private String fieldsSchema;
@Schema(
description = "Tier to use: 'auto' picks per document, or force 'basic'/'advanced'",
allowableValues = {"auto", "basic", "advanced"},
defaultValue = "auto")
private String mode = "auto";
@Schema(description = "Optional natural-language guidance for the extraction")
private String instructions;
}
@@ -0,0 +1,19 @@
package stirling.software.proprietary.model.api.docparse;
import io.swagger.v3.oas.annotations.media.Schema;
import lombok.Data;
import lombok.EqualsAndHashCode;
import stirling.software.common.model.api.PDFFile;
@Data
@EqualsAndHashCode(callSuper = true)
public class ExtractTablesApiRequest extends PDFFile {
@Schema(
description = "Response format: CSV text or the structured JSON table list",
allowableValues = {"csv", "json"},
defaultValue = "csv")
private String outputFormat = "csv";
}
@@ -0,0 +1,52 @@
package stirling.software.proprietary.model.api.docparse;
import io.swagger.v3.oas.annotations.media.Schema;
import lombok.Data;
import lombok.EqualsAndHashCode;
import stirling.software.common.model.api.PDFFile;
@Data
@EqualsAndHashCode(callSuper = true)
public class RagIngestApiRequest extends PDFFile {
@Schema(
description =
"Stable identifier for the ingested document; re-ingesting the same id replaces"
+ " its chunks. Defaults to a content hash of the uploaded bytes.")
private String documentId;
@Schema(description = "Target chunk size in characters (64-32768)", defaultValue = "512")
private int chunkSize = 512;
@Schema(
description = "Overlap between adjacent chunks in characters (0-4096)",
defaultValue = "64")
private int overlap = 64;
@Schema(
description = "Tier to use: 'auto' picks per document, or force 'basic'/'advanced'",
allowableValues = {"auto", "basic", "advanced"},
defaultValue = "auto")
private String mode = "auto";
@Schema(
description = "Index the document into the built-in knowledge base",
defaultValue = "true")
private boolean index = true;
@Schema(
description =
"Also return the parsed document as a markdown file, for delivery to external"
+ " systems (vector DBs, training corpora)",
defaultValue = "false")
private boolean exportMarkdown = false;
@Schema(
description =
"Also return the chunks as a JSONL file (one chunk per line with page span and"
+ " heading breadcrumb), ready for external embedding or indexing",
defaultValue = "false")
private boolean exportChunksJsonl = false;
}
@@ -0,0 +1,16 @@
package stirling.software.proprietary.model.api.docparse;
import io.swagger.v3.oas.annotations.media.Schema;
import lombok.Data;
import lombok.EqualsAndHashCode;
import stirling.software.common.model.api.PDFFile;
@Data
@EqualsAndHashCode(callSuper = true)
public class SuggestSchemaApiRequest extends PDFFile {
@Schema(description = "Maximum number of fields to suggest (1-20)", defaultValue = "10")
private int maxFields = 10;
}
@@ -0,0 +1,12 @@
package stirling.software.proprietary.model.docparse;
import java.util.List;
/** One RAG chunk with page span and heading breadcrumb. Mirrors {@code docparse.py DocChunk}. */
public record DocChunk(
int index, String text, Integer pageStart, Integer pageEnd, List<String> headingPath) {
public DocChunk {
headingPath = headingPath == null ? List.of() : headingPath;
}
}
@@ -0,0 +1,12 @@
package stirling.software.proprietary.model.docparse;
import java.util.List;
/** One extracted table. Mirrors {@code docparse.py DocTable}. */
public record DocTable(
int page, List<Double> bbox, List<List<String>> cells, String markdown, Double confidence) {
public DocTable {
cells = cells == null ? List.of() : cells;
}
}
@@ -0,0 +1,25 @@
package stirling.software.proprietary.model.docparse;
import java.util.List;
/**
* What the engine can actually do right now; Java caches and republishes this. Mirrors {@code
* docparse.py DocparseCapabilities}.
*/
public record DocparseCapabilities(
boolean advancedInstalled,
String doclingVersion,
String torchVersion,
boolean modelsAvailable,
String modelsPath,
List<String> errors) {
public DocparseCapabilities {
errors = errors == null ? List.of() : errors;
}
/** The addon-absent view used when the engine is disabled, unreachable, or probing failed. */
public static DocparseCapabilities absent(String reason) {
return new DocparseCapabilities(false, null, null, false, null, List.of(reason));
}
}
@@ -0,0 +1,9 @@
package stirling.software.proprietary.model.docparse;
/** Merged capability view served by {@code GET /api/v1/docparse/capabilities} (Java side). */
public record DocparseCapabilitiesView(
boolean enabled,
String mode,
boolean advancedInstalled,
boolean engineReachable,
String doclingVersion) {}
@@ -0,0 +1,35 @@
package stirling.software.proprietary.model.docparse;
import java.util.Locale;
import com.fasterxml.jackson.annotation.JsonCreator;
import com.fasterxml.jackson.annotation.JsonValue;
/**
* What the caller asked for; {@code AUTO} resolves per request. Wire values are lowercase to match
* {@code engine/src/stirling/contracts/docparse.py DocparseMode}.
*/
public enum DocparseMode {
AUTO("auto"),
BASIC("basic"),
ADVANCED("advanced");
private final String wire;
DocparseMode(String wire) {
this.wire = wire;
}
@JsonValue
public String wire() {
return wire;
}
@JsonCreator
public static DocparseMode fromWire(String value) {
if (value == null || value.isBlank()) {
return AUTO;
}
return valueOf(value.trim().toUpperCase(Locale.ROOT));
}
}
@@ -0,0 +1,31 @@
package stirling.software.proprietary.model.docparse;
import java.util.Locale;
import com.fasterxml.jackson.annotation.JsonCreator;
import com.fasterxml.jackson.annotation.JsonValue;
/**
* Which implementation actually served a request. Wire values are lowercase to match {@code
* engine/src/stirling/contracts/docparse.py DocparseTier}.
*/
public enum DocparseTier {
BASIC("basic"),
ADVANCED("advanced");
private final String wire;
DocparseTier(String wire) {
this.wire = wire;
}
@JsonValue
public String wire() {
return wire;
}
@JsonCreator
public static DocparseTier fromWire(String value) {
return valueOf(value.trim().toUpperCase(Locale.ROOT));
}
}
@@ -0,0 +1,19 @@
package stirling.software.proprietary.model.docparse;
import java.util.List;
import stirling.software.proprietary.model.api.ai.AiPageText;
import tools.jackson.databind.JsonNode;
/**
* Engine request for {@code POST /api/v1/docparse/extract}. {@code pages} drives the basic tier
* (Java-extracted text); {@code contentBase64} lets the advanced tier parse the raw file itself.
*/
public record ExtractFieldsRequest(
String fileName,
JsonNode fieldsSchema,
List<AiPageText> pages,
String contentBase64,
DocparseMode mode,
String instructions) {}
@@ -0,0 +1,12 @@
package stirling.software.proprietary.model.docparse;
import java.util.List;
/** Engine response for {@code POST /api/v1/docparse/extract}. */
public record ExtractFieldsResponse(
DocparseTier mode, List<ExtractedField> fields, double overallConfidence) {
public ExtractFieldsResponse {
fields = fields == null ? List.of() : fields;
}
}
@@ -0,0 +1,4 @@
package stirling.software.proprietary.model.docparse;
/** Engine request for {@code POST /api/v1/docparse/tables}. */
public record ExtractTablesRequest(String fileName, String contentBase64) {}
@@ -0,0 +1,11 @@
package stirling.software.proprietary.model.docparse;
import java.util.List;
/** Engine response for {@code POST /api/v1/docparse/tables}. */
public record ExtractTablesResponse(DocparseTier mode, List<DocTable> tables) {
public ExtractTablesResponse {
tables = tables == null ? List.of() : tables;
}
}
@@ -0,0 +1,16 @@
package stirling.software.proprietary.model.docparse;
import java.util.List;
import tools.jackson.databind.JsonNode;
/**
* One extracted field with confidence and citations. Mirrors {@code docparse.py ExtractedField}.
*/
public record ExtractedField(
String name, JsonNode value, double confidence, List<FieldCitation> citations) {
public ExtractedField {
citations = citations == null ? List.of() : citations;
}
}
@@ -0,0 +1,11 @@
package stirling.software.proprietary.model.docparse;
import java.util.List;
/**
* Where a value came from. {@code quote} is always set; {@code bbox} only when a layout parse ran
* (advanced tier); offsets index into the cited page's text. Mirrors {@code docparse.py
* FieldCitation}.
*/
public record FieldCitation(
Integer page, List<Double> bbox, String quote, Integer startOffset, Integer endOffset) {}
@@ -0,0 +1,29 @@
package stirling.software.proprietary.model.docparse;
import java.time.Instant;
import java.util.List;
import stirling.software.proprietary.model.api.ai.AiPageText;
/**
* Engine request for {@code POST /api/v1/docparse/rag-ingest}. Owner semantics mirror {@code POST
* /api/v1/documents}: {@code ownerId} is the tenant, {@code readPrincipals} the explicit readers,
* and a null {@code expiresAt} keeps the ingested content until an explicit delete. {@code index}
* false skips the store (export-only); {@code includeMarkdown}/{@code includeChunks} echo the
* parsed content back so the caller can emit corpus files.
*/
public record RagIngestRequest(
String fileName,
String documentId,
String source,
String ownerId,
List<String> readPrincipals,
Instant expiresAt,
List<AiPageText> pages,
String contentBase64,
int chunkSize,
int overlap,
DocparseMode mode,
boolean index,
boolean includeMarkdown,
boolean includeChunks) {}
@@ -0,0 +1,15 @@
package stirling.software.proprietary.model.docparse;
import java.util.List;
/**
* Engine response for {@code POST /api/v1/docparse/rag-ingest}. {@code markdown} and {@code chunks}
* are only present when the request asked for them via includeMarkdown/includeChunks.
*/
public record RagIngestResponse(
DocparseTier mode,
String documentId,
int chunksIndexed,
int pages,
String markdown,
List<DocChunk> chunks) {}
@@ -0,0 +1,12 @@
package stirling.software.proprietary.model.docparse;
import java.util.List;
import stirling.software.proprietary.model.api.ai.AiPageText;
/**
* Engine request for {@code POST /api/v1/docparse/suggest-schema}. {@code pages} drives the basic
* tier (Java-extracted text); {@code contentBase64} lets the advanced tier parse the raw file.
*/
public record SuggestSchemaRequest(
String fileName, List<AiPageText> pages, String contentBase64, int maxFields) {}
@@ -0,0 +1,11 @@
package stirling.software.proprietary.model.docparse;
import java.util.List;
/** Engine response for {@code POST /api/v1/docparse/suggest-schema}. */
public record SuggestSchemaResponse(DocparseTier mode, List<SuggestedField> fields) {
public SuggestSchemaResponse {
fields = fields == null ? List.of() : fields;
}
}
@@ -0,0 +1,4 @@
package stirling.software.proprietary.model.docparse;
/** One field the engine proposes for an extraction schema. */
public record SuggestedField(String name, String type, String description) {}
@@ -0,0 +1,51 @@
package stirling.software.proprietary.security.filter;
import java.io.IOException;
import org.slf4j.MDC;
import org.springframework.core.Ordered;
import org.springframework.core.annotation.Order;
import org.springframework.security.core.Authentication;
import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.stereotype.Component;
import org.springframework.web.filter.OncePerRequestFilter;
import jakarta.servlet.FilterChain;
import jakarta.servlet.ServletException;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
/**
* Stamps the authenticated principal into MDC for every request. Async job workers resolve the
* caller via UserService's MDC fallback; without this the fallback only worked when the audit
* aspect (a pro feature) happened to populate it.
*/
@Component
@Order(Ordered.LOWEST_PRECEDENCE)
public class PrincipalMdcFilter extends OncePerRequestFilter {
static final String MDC_KEY = "auditPrincipal";
@Override
protected void doFilterInternal(
HttpServletRequest request, HttpServletResponse response, FilterChain filterChain)
throws ServletException, IOException {
String previous = MDC.get(MDC_KEY);
Authentication authentication = SecurityContextHolder.getContext().getAuthentication();
boolean stamped = false;
if (previous == null
&& authentication != null
&& authentication.isAuthenticated()
&& !"anonymousUser".equals(authentication.getPrincipal())) {
MDC.put(MDC_KEY, authentication.getName());
stamped = true;
}
try {
filterChain.doFilter(request, response);
} finally {
if (stamped) {
MDC.remove(MDC_KEY);
}
}
}
}
@@ -254,6 +254,13 @@ public class AiEngineClient {
private void checkResponseStatus(HttpResponse<String> response) {
int status = response.statusCode();
// 501 = capability not implemented (e.g. docparse addon missing); keep the status and
// body so callers can surface the machine-readable addonRequired detail.
if (status == 501) {
throw new ResponseStatusException(
HttpStatus.NOT_IMPLEMENTED,
"AI engine capability not implemented: " + response.body());
}
if (status >= 500) {
throw new ResponseStatusException(
HttpStatus.BAD_GATEWAY, "AI engine returned error: " + status);
@@ -0,0 +1,370 @@
package stirling.software.proprietary.service;
import java.io.IOException;
import java.util.ArrayList;
import java.util.Base64;
import java.util.List;
import org.apache.pdfbox.pdmodel.PDDocument;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.http.HttpStatus;
import org.springframework.stereotype.Service;
import org.springframework.web.multipart.MultipartFile;
import org.springframework.web.server.ResponseStatusException;
import io.github.pixee.security.Filenames;
import lombok.extern.slf4j.Slf4j;
import stirling.software.common.model.ApplicationProperties;
import stirling.software.common.service.CustomPDFDocumentFactory;
import stirling.software.common.service.UserServiceInterface;
import stirling.software.proprietary.model.api.ai.AiPageText;
import stirling.software.proprietary.model.docparse.DocparseCapabilities;
import stirling.software.proprietary.model.docparse.DocparseCapabilitiesView;
import stirling.software.proprietary.model.docparse.DocparseMode;
import stirling.software.proprietary.model.docparse.DocparseTier;
import stirling.software.proprietary.model.docparse.ExtractFieldsRequest;
import stirling.software.proprietary.model.docparse.ExtractFieldsResponse;
import stirling.software.proprietary.model.docparse.ExtractTablesRequest;
import stirling.software.proprietary.model.docparse.ExtractTablesResponse;
import stirling.software.proprietary.model.docparse.RagIngestRequest;
import stirling.software.proprietary.model.docparse.RagIngestResponse;
import stirling.software.proprietary.model.docparse.SuggestSchemaRequest;
import stirling.software.proprietary.model.docparse.SuggestSchemaResponse;
import tools.jackson.databind.JsonNode;
import tools.jackson.databind.ObjectMapper;
/**
* DocParse ingestion: per-page text extraction (reusing the same {@link PdfContentExtractor} the AI
* chat path uses) plus engine dispatch for chunk + embed + index. The engine owns chunking,
* embedding, and the document store; Java owns identity, limits, and the wire contract from {@code
* engine/src/stirling/contracts/docparse.py}.
*/
@Slf4j
@Service
public class DocParseService {
private static final String RAG_INGEST_ENDPOINT = "/api/v1/docparse/rag-ingest";
private static final String TABLES_ENDPOINT = "/api/v1/docparse/tables";
private static final String EXTRACT_ENDPOINT = "/api/v1/docparse/extract";
private static final String SUGGEST_SCHEMA_ENDPOINT = "/api/v1/docparse/suggest-schema";
/** Below this average of extractable chars per page the document is treated as scanned. */
static final int SCANNED_AVG_CHARS_PER_PAGE = 100;
/** Pages sampled for the scanned heuristic; keeps the probe cheap on huge documents. */
private static final int SCANNED_SAMPLE_PAGES = 20;
private final AiEngineClient aiEngineClient;
private final DocparseCapabilityService capabilityService;
private final CustomPDFDocumentFactory pdfDocumentFactory;
private final PdfContentExtractor pdfContentExtractor;
private final ApplicationProperties applicationProperties;
private final ObjectMapper objectMapper;
private final FileIdStrategy fileIdStrategy;
private final UserServiceInterface userService;
public DocParseService(
AiEngineClient aiEngineClient,
DocparseCapabilityService capabilityService,
CustomPDFDocumentFactory pdfDocumentFactory,
PdfContentExtractor pdfContentExtractor,
ApplicationProperties applicationProperties,
ObjectMapper objectMapper,
FileIdStrategy fileIdStrategy,
@Autowired(required = false) UserServiceInterface userService) {
this.aiEngineClient = aiEngineClient;
this.capabilityService = capabilityService;
this.pdfDocumentFactory = pdfDocumentFactory;
this.pdfContentExtractor = pdfContentExtractor;
this.applicationProperties = applicationProperties;
this.objectMapper = objectMapper;
this.fileIdStrategy = fileIdStrategy;
this.userService = userService;
}
/** Throws 503 when the docparse.enabled master switch is off. */
public void requireEnabled() {
if (!applicationProperties.getDocparse().isEnabled()) {
throw new ResponseStatusException(
HttpStatus.SERVICE_UNAVAILABLE, "DocParse is disabled");
}
}
public DocparseCapabilitiesView capabilitiesView() {
ApplicationProperties.Docparse config = applicationProperties.getDocparse();
DocparseCapabilities capabilities = capabilityService.capabilities();
return new DocparseCapabilitiesView(
config.isEnabled(),
config.getMode(),
capabilities.advancedInstalled(),
capabilityService.isEngineReachable(),
capabilities.doclingVersion());
}
/**
* Chunk, embed, and index the document into the engine's RAG store, and/or echo the parsed
* content back for corpus export. Text extraction and tier routing happen here; the engine
* handles both basic (text-only) and advanced (layout) tiers.
*/
public RagIngestResponse ragIngest(
MultipartFile file,
String documentId,
int chunkSize,
int overlap,
DocparseMode mode,
boolean index,
boolean includeMarkdown,
boolean includeChunks)
throws IOException {
requireEnabled();
if (!index && !includeMarkdown && !includeChunks) {
throw new ResponseStatusException(
HttpStatus.BAD_REQUEST,
"Nothing to do: enable index, exportMarkdown, or exportChunksJsonl");
}
// Content hash default: re-ingesting identical bytes dedupes to the same document.
String docId =
(documentId == null || documentId.isBlank())
? fileIdStrategy.idFor(file)
: documentId.trim();
List<AiPageText> pages;
DocparseTier tier;
try (PDDocument document = pdfDocumentFactory.load(file, true)) {
tier =
resolveTier(
mode, capabilityService.capabilities(), false, looksScanned(document));
// The advanced tier parses the raw file engine-side; extracting pages
// too would double both the PDFBox work and the payload.
pages = tier == DocparseTier.BASIC ? extractPages(document) : null;
}
String callerId = currentUserId();
// Null expiresAt = persistent until explicit delete; ingest here is a deliberate
// knowledge-base action, unlike the TTL'd auto-ingest in AiWorkflowService.
RagIngestRequest request =
new RagIngestRequest(
fileName(file),
docId,
fileName(file),
callerId,
// Engine forbids an empty list here (min_length=1); null means
// "default to the owner" on the engine side.
callerId == null ? null : List.of(callerId),
null,
pages,
tier == DocparseTier.ADVANCED ? encodeBase64(file) : null,
Math.clamp(chunkSize, 64, 32_768),
Math.clamp(overlap, 0, 4_096),
toMode(tier),
index,
includeMarkdown,
includeChunks);
String responseJson =
aiEngineClient.postLongRunning(
RAG_INGEST_ENDPOINT, objectMapper.writeValueAsString(request), callerId);
return objectMapper.readValue(responseJson, RagIngestResponse.class);
}
/**
* Resolve the tier that will serve a request. The settings mode wins when stricter: a settings
* {@code basic} always forces basic, a settings {@code advanced} upgrades everything except an
* explicit basic request. {@code auto} picks advanced only when the addon is installed and the
* document actually needs it (scanned, or the operation needs layout).
*/
DocparseTier resolveTier(
DocparseMode requested,
DocparseCapabilities capability,
boolean needsLayout,
boolean looksScanned) {
DocparseMode effective = effectiveMode(settingsMode(), requested);
return switch (effective) {
case BASIC -> DocparseTier.BASIC;
case ADVANCED -> requireAdvanced(capability);
case AUTO ->
capability.advancedInstalled() && (looksScanned || needsLayout)
? DocparseTier.ADVANCED
: DocparseTier.BASIC;
};
}
/** Extract the fields described by a JSON Schema, with confidence and citations. */
public ExtractFieldsResponse extractFields(
MultipartFile file, String fieldsSchemaJson, DocparseMode mode, String instructions)
throws IOException {
requireEnabled();
JsonNode schema = parseJsonObject(fieldsSchemaJson, "fieldsSchema");
List<AiPageText> pages;
DocparseTier tier;
try (PDDocument document = pdfDocumentFactory.load(file, true)) {
pages = extractPages(document);
tier =
resolveTier(
mode, capabilityService.capabilities(), false, looksScanned(document));
}
ExtractFieldsRequest request =
new ExtractFieldsRequest(
fileName(file),
schema,
pages,
tier == DocparseTier.ADVANCED ? encodeBase64(file) : null,
toMode(tier),
instructions);
String responseJson =
aiEngineClient.postLongRunning(
EXTRACT_ENDPOINT,
objectMapper.writeValueAsString(request),
currentUserId());
return objectMapper.readValue(responseJson, ExtractFieldsResponse.class);
}
/** Propose an extraction schema from the document's first pages. */
public SuggestSchemaResponse suggestSchema(MultipartFile file, int maxFields)
throws IOException {
requireEnabled();
List<AiPageText> pages;
DocparseTier tier;
try (PDDocument document = pdfDocumentFactory.load(file, true)) {
pages = extractPages(document);
tier =
resolveTier(
DocparseMode.AUTO,
capabilityService.capabilities(),
false,
looksScanned(document));
}
SuggestSchemaRequest request =
new SuggestSchemaRequest(
fileName(file),
pages,
tier == DocparseTier.ADVANCED ? encodeBase64(file) : null,
Math.clamp(maxFields, 1, 20));
String responseJson =
aiEngineClient.postLongRunning(
SUGGEST_SCHEMA_ENDPOINT,
objectMapper.writeValueAsString(request),
currentUserId());
return objectMapper.readValue(responseJson, SuggestSchemaResponse.class);
}
private JsonNode parseJsonObject(String json, String fieldName) {
if (json == null || json.isBlank()) {
throw new ResponseStatusException(
HttpStatus.BAD_REQUEST, "'" + fieldName + "' is required");
}
JsonNode node;
try {
node = objectMapper.readTree(json);
} catch (Exception e) {
throw new ResponseStatusException(
HttpStatus.BAD_REQUEST, "'" + fieldName + "' is not valid JSON");
}
if (!node.isObject()) {
throw new ResponseStatusException(
HttpStatus.BAD_REQUEST, "'" + fieldName + "' must be a JSON object");
}
return node;
}
/**
* The settings mode wins when stricter: a settings {@code basic} always forces basic, a
* settings {@code advanced} upgrades everything except an explicit basic request.
*/
static DocparseMode effectiveMode(DocparseMode settings, DocparseMode requested) {
DocparseMode request = requested == null ? DocparseMode.AUTO : requested;
if (settings == DocparseMode.BASIC) {
return DocparseMode.BASIC;
}
if (settings == DocparseMode.ADVANCED) {
return request == DocparseMode.BASIC ? DocparseMode.BASIC : DocparseMode.ADVANCED;
}
return request;
}
/** True when the sampled average of extractable chars per page falls below the threshold. */
boolean looksScanned(PDDocument document) throws IOException {
int pageCount = document.getNumberOfPages();
if (pageCount == 0) {
return false;
}
int sampled = Math.min(pageCount, SCANNED_SAMPLE_PAGES);
long totalChars = 0;
for (int page = 1; page <= sampled; page++) {
String text = pdfContentExtractor.extractPageTextRaw(document, page);
totalChars += text == null ? 0 : text.length();
}
return (totalChars / sampled) < SCANNED_AVG_CHARS_PER_PAGE;
}
private DocparseTier requireAdvanced(DocparseCapabilities capability) {
if (!capability.advancedInstalled()) {
throw new ResponseStatusException(
HttpStatus.NOT_IMPLEMENTED,
"The advanced DocParse tier requires the docparse addon"
+ " (addonRequired=docparse); install it or use mode=basic");
}
return DocparseTier.ADVANCED;
}
private static DocparseMode toMode(DocparseTier tier) {
return tier == DocparseTier.ADVANCED ? DocparseMode.ADVANCED : DocparseMode.BASIC;
}
private static String encodeBase64(MultipartFile file) throws IOException {
return Base64.getEncoder().encodeToString(file.getBytes());
}
public ExtractTablesResponse tables(MultipartFile file) throws IOException {
requireEnabled();
// Tables need layout, so a forced-advanced setting without the addon must 501 here
// rather than let the engine silently fall back; the engine picks the tier otherwise.
resolveTier(DocparseMode.AUTO, capabilityService.capabilities(), true, false);
ExtractTablesRequest request = new ExtractTablesRequest(fileName(file), encodeBase64(file));
String responseJson =
aiEngineClient.postLongRunning(
TABLES_ENDPOINT, objectMapper.writeValueAsString(request), currentUserId());
return objectMapper.readValue(responseJson, ExtractTablesResponse.class);
}
/** Extract per-page text for the engine, capped by the shared aiEngine limits. */
List<AiPageText> extractPages(PDDocument document) throws IOException {
ApplicationProperties.AiEngine.Limits limits =
applicationProperties.getAiEngine().getLimits();
int maxPages = Math.min(document.getNumberOfPages(), limits.getMaxPages());
int remainingCharacters = limits.getMaxCharacters();
List<AiPageText> pages = new ArrayList<>();
for (int page = 1; page <= maxPages && remainingCharacters > 0; page++) {
String text = pdfContentExtractor.extractPageTextRaw(document, page);
if (text == null || text.isBlank()) {
continue;
}
if (text.length() > remainingCharacters) {
text = text.substring(0, remainingCharacters);
}
pages.add(new AiPageText(page, text));
remainingCharacters -= text.length();
}
return pages;
}
private DocparseMode settingsMode() {
try {
return DocparseMode.fromWire(applicationProperties.getDocparse().getMode());
} catch (IllegalArgumentException e) {
log.warn(
"Unknown docparse.mode '{}'; falling back to auto",
applicationProperties.getDocparse().getMode());
return DocparseMode.AUTO;
}
}
public static String fileName(MultipartFile file) {
String name = Filenames.toSimpleFileName(file.getOriginalFilename());
return (name == null || name.isBlank()) ? "document.pdf" : name;
}
private String currentUserId() {
return userService != null ? userService.getCurrentUsername() : null;
}
}
@@ -0,0 +1,123 @@
package stirling.software.proprietary.service;
import java.time.Duration;
import java.time.Instant;
import java.util.concurrent.atomic.AtomicBoolean;
import org.springframework.stereotype.Service;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import stirling.software.common.model.ApplicationProperties;
import stirling.software.common.service.DocparseCapabilityServiceInterface;
import stirling.software.proprietary.model.docparse.DocparseCapabilities;
import tools.jackson.databind.ObjectMapper;
/**
* Probes the engine's {@code GET /api/v1/docparse/capabilities} and caches the answer for 5
* minutes. Reports "addon absent" when the AI engine is disabled or the probe fails, so callers can
* always route to the basic tier without special-casing errors.
*/
@Slf4j
@Service
@RequiredArgsConstructor
public class DocparseCapabilityService implements DocparseCapabilityServiceInterface {
private static final String CAPABILITIES_ENDPOINT = "/api/v1/docparse/capabilities";
private static final Duration CACHE_TTL = Duration.ofMinutes(5);
private final AiEngineClient aiEngineClient;
private final ApplicationProperties applicationProperties;
private final ObjectMapper objectMapper;
private record Snapshot(
DocparseCapabilities capabilities, boolean engineReachable, Instant fetchedAt) {}
private volatile Snapshot snapshot;
private final AtomicBoolean refreshing = new AtomicBoolean();
/** The cached capabilities, refreshed synchronously when stale. */
public DocparseCapabilities capabilities() {
return freshSnapshot().capabilities();
}
/** Whether the last capability probe reached the engine. */
public boolean isEngineReachable() {
Snapshot current = snapshot;
return current != null && current.engineReachable();
}
/** {@code refresh(true)} bypasses the cache and re-probes the engine now. */
public DocparseCapabilities refresh(boolean force) {
if (!force) {
return capabilities();
}
Snapshot fresh = fetch();
snapshot = fresh;
return fresh.capabilities();
}
/**
* Non-blocking read for app-config: returns the last known value and kicks off a background
* refresh when stale, so a slow/unreachable engine never delays page load.
*/
@Override
public boolean isAdvancedInstalled() {
Snapshot current = snapshot;
if (current == null || isStale(current)) {
triggerAsyncRefresh();
}
return current != null && current.capabilities().advancedInstalled();
}
private Snapshot freshSnapshot() {
Snapshot current = snapshot;
if (current != null && !isStale(current)) {
return current;
}
Snapshot fresh = fetch();
snapshot = fresh;
return fresh;
}
private void triggerAsyncRefresh() {
if (!refreshing.compareAndSet(false, true)) {
return;
}
Thread.ofVirtual()
.name("docparse-capability-refresh")
.start(
() -> {
try {
snapshot = fetch();
} finally {
refreshing.set(false);
}
});
}
private Snapshot fetch() {
if (!applicationProperties.getAiEngine().isEnabled()) {
return new Snapshot(
DocparseCapabilities.absent("AI engine is disabled"), false, Instant.now());
}
try {
String json = aiEngineClient.get(CAPABILITIES_ENDPOINT, null);
DocparseCapabilities capabilities =
objectMapper.readValue(json, DocparseCapabilities.class);
return new Snapshot(capabilities, true, Instant.now());
} catch (Exception e) {
log.debug("DocParse capability probe failed: {}", e.getMessage());
return new Snapshot(
DocparseCapabilities.absent("Capability probe failed: " + e.getMessage()),
false,
Instant.now());
}
}
private static boolean isStale(Snapshot current) {
return current.fetchedAt().plus(CACHE_TTL).isBefore(Instant.now());
}
}
@@ -0,0 +1,93 @@
package stirling.software.proprietary.model.docparse;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertNull;
import static org.junit.jupiter.api.Assertions.assertTrue;
import java.util.List;
import org.junit.jupiter.api.Test;
import stirling.software.proprietary.model.api.ai.AiPageText;
import tools.jackson.databind.JsonNode;
import tools.jackson.databind.json.JsonMapper;
/**
* The Java DTOs must serialize to exactly the camelCase wire shapes defined in {@code
* engine/src/stirling/contracts/docparse.py}; a drift here breaks ingestion silently.
*/
class DocparseWireContractTest {
private final JsonMapper mapper = JsonMapper.builder().build();
@Test
void ragIngestRequestSerializesTheEngineContract() {
RagIngestRequest request =
new RagIngestRequest(
"report.pdf",
"doc-1",
"report.pdf",
"user:alice",
List.of("user:alice"),
null,
List.of(new AiPageText(1, "hello")),
null,
512,
64,
DocparseMode.AUTO,
true,
false,
true);
JsonNode json = mapper.readTree(mapper.writeValueAsString(request));
assertEquals("report.pdf", json.get("fileName").asText());
assertEquals("doc-1", json.get("documentId").asText());
assertEquals("user:alice", json.get("ownerId").asText());
assertEquals("user:alice", json.get("readPrincipals").get(0).asText());
assertEquals(1, json.get("pages").get(0).get("pageNumber").asInt());
assertEquals("hello", json.get("pages").get(0).get("text").asText());
assertEquals(512, json.get("chunkSize").asInt());
assertEquals(64, json.get("overlap").asInt());
assertEquals("auto", json.get("mode").asText());
assertTrue(json.get("index").asBoolean());
assertFalse(json.get("includeMarkdown").asBoolean());
assertTrue(json.get("includeChunks").asBoolean());
}
@Test
void ragIngestResponseReadsTheEngineShapeIncludingEchoedContent() {
String engineJson =
"{\"mode\":\"basic\",\"documentId\":\"doc-1\",\"chunksIndexed\":2,\"pages\":3,"
+ "\"markdown\":\"# Title\",\"chunks\":[{\"index\":0,\"text\":\"t\","
+ "\"pageStart\":1,\"pageEnd\":2,\"headingPath\":[\"Intro\"]}]}";
RagIngestResponse response = mapper.readValue(engineJson, RagIngestResponse.class);
assertEquals(DocparseTier.BASIC, response.mode());
assertEquals("doc-1", response.documentId());
assertEquals(2, response.chunksIndexed());
assertEquals(3, response.pages());
assertEquals("# Title", response.markdown());
assertEquals(1, response.chunks().size());
assertEquals(List.of("Intro"), response.chunks().get(0).headingPath());
}
@Test
void ragIngestResponseToleratesAbsentEchoFields() {
String engineJson =
"{\"mode\":\"basic\",\"documentId\":\"d\",\"chunksIndexed\":0,\"pages\":1}";
RagIngestResponse response = mapper.readValue(engineJson, RagIngestResponse.class);
assertNull(response.markdown());
assertNull(response.chunks());
}
@Test
void capabilitiesReadTheEngineProbeShape() {
String engineJson =
"{\"advancedInstalled\":false,\"doclingVersion\":null,\"torchVersion\":null,"
+ "\"modelsAvailable\":false,\"modelsPath\":null,\"errors\":[\"missing\"]}";
DocparseCapabilities capabilities =
mapper.readValue(engineJson, DocparseCapabilities.class);
assertFalse(capabilities.advancedInstalled());
assertEquals(List.of("missing"), capabilities.errors());
}
}
@@ -0,0 +1,78 @@
package stirling.software.proprietary.security.filter;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertNull;
import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.Test;
import org.slf4j.MDC;
import org.springframework.mock.web.MockHttpServletRequest;
import org.springframework.mock.web.MockHttpServletResponse;
import org.springframework.security.authentication.AnonymousAuthenticationToken;
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
import org.springframework.security.core.authority.AuthorityUtils;
import org.springframework.security.core.context.SecurityContextHolder;
class PrincipalMdcFilterTest {
private final PrincipalMdcFilter filter = new PrincipalMdcFilter();
@AfterEach
void cleanUp() {
SecurityContextHolder.clearContext();
MDC.remove(PrincipalMdcFilter.MDC_KEY);
}
@Test
void stampsAuthenticatedPrincipalDuringTheChainAndClearsAfter() throws Exception {
SecurityContextHolder.getContext()
.setAuthentication(
new UsernamePasswordAuthenticationToken(
"alice", "n/a", AuthorityUtils.createAuthorityList("ROLE_USER")));
String[] seen = new String[1];
filter.doFilter(
new MockHttpServletRequest(),
new MockHttpServletResponse(),
(req, res) -> seen[0] = MDC.get(PrincipalMdcFilter.MDC_KEY));
assertEquals("alice", seen[0]);
assertNull(MDC.get(PrincipalMdcFilter.MDC_KEY));
}
@Test
void anonymousRequestsAreNotStamped() throws Exception {
SecurityContextHolder.getContext()
.setAuthentication(
new AnonymousAuthenticationToken(
"key",
"anonymousUser",
AuthorityUtils.createAuthorityList("ROLE_ANONYMOUS")));
String[] seen = new String[] {"sentinel"};
filter.doFilter(
new MockHttpServletRequest(),
new MockHttpServletResponse(),
(req, res) -> seen[0] = MDC.get(PrincipalMdcFilter.MDC_KEY));
assertNull(seen[0]);
}
@Test
void existingMdcPrincipalIsLeftUntouched() throws Exception {
MDC.put(PrincipalMdcFilter.MDC_KEY, "policy-run-owner");
SecurityContextHolder.getContext()
.setAuthentication(
new UsernamePasswordAuthenticationToken(
"alice", "n/a", AuthorityUtils.createAuthorityList("ROLE_USER")));
String[] seen = new String[1];
filter.doFilter(
new MockHttpServletRequest(),
new MockHttpServletResponse(),
(req, res) -> seen[0] = MDC.get(PrincipalMdcFilter.MDC_KEY));
assertEquals("policy-run-owner", seen[0]);
assertEquals("policy-run-owner", MDC.get(PrincipalMdcFilter.MDC_KEY));
}
}
@@ -0,0 +1,377 @@
package stirling.software.proprietary.service;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertThrows;
import static org.junit.jupiter.api.Assertions.assertTrue;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.anyBoolean;
import static org.mockito.ArgumentMatchers.anyInt;
import static org.mockito.ArgumentMatchers.eq;
import static org.mockito.ArgumentMatchers.isNull;
import static org.mockito.Mockito.verifyNoInteractions;
import static org.mockito.Mockito.when;
import java.io.IOException;
import java.util.List;
import org.apache.pdfbox.pdmodel.PDDocument;
import org.apache.pdfbox.pdmodel.PDPage;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.ArgumentCaptor;
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;
import org.springframework.http.HttpStatus;
import org.springframework.mock.web.MockMultipartFile;
import org.springframework.web.multipart.MultipartFile;
import org.springframework.web.server.ResponseStatusException;
import stirling.software.common.model.ApplicationProperties;
import stirling.software.common.service.CustomPDFDocumentFactory;
import stirling.software.proprietary.model.docparse.DocparseCapabilities;
import stirling.software.proprietary.model.docparse.DocparseMode;
import stirling.software.proprietary.model.docparse.DocparseTier;
import stirling.software.proprietary.model.docparse.RagIngestResponse;
import tools.jackson.databind.JsonNode;
import tools.jackson.databind.json.JsonMapper;
/** Wire-building and mode capping for the ingestion path. */
@ExtendWith(MockitoExtension.class)
class DocParseServiceTest {
private static final String ENGINE_RESPONSE =
"{\"mode\":\"basic\",\"documentId\":\"doc-1\",\"chunksIndexed\":3,\"pages\":2,"
+ "\"markdown\":null,\"chunks\":null}";
@Mock private AiEngineClient aiEngineClient;
@Mock private DocparseCapabilityService capabilityService;
@Mock private CustomPDFDocumentFactory pdfDocumentFactory;
@Mock private PdfContentExtractor pdfContentExtractor;
@Mock private FileIdStrategy fileIdStrategy;
private ApplicationProperties properties;
private DocParseService service;
private final JsonMapper jsonMapper = JsonMapper.builder().build();
@BeforeEach
void setUp() {
properties = new ApplicationProperties();
service =
new DocParseService(
aiEngineClient,
capabilityService,
pdfDocumentFactory,
pdfContentExtractor,
properties,
jsonMapper,
fileIdStrategy,
null);
}
// --- effectiveMode: the settings mode wins when stricter ---
@Test
void settingsBasicForcesBasic() {
assertEquals(
DocparseMode.BASIC,
DocParseService.effectiveMode(DocparseMode.BASIC, DocparseMode.ADVANCED));
assertEquals(
DocparseMode.BASIC,
DocParseService.effectiveMode(DocparseMode.BASIC, DocparseMode.AUTO));
}
@Test
void settingsAdvancedUpgradesAutoButRespectsExplicitBasic() {
assertEquals(
DocparseMode.ADVANCED,
DocParseService.effectiveMode(DocparseMode.ADVANCED, DocparseMode.AUTO));
assertEquals(
DocparseMode.BASIC,
DocParseService.effectiveMode(DocparseMode.ADVANCED, DocparseMode.BASIC));
}
@Test
void settingsAutoPassesRequestThroughAndNullMeansAuto() {
assertEquals(
DocparseMode.ADVANCED,
DocParseService.effectiveMode(DocparseMode.AUTO, DocparseMode.ADVANCED));
assertEquals(DocparseMode.AUTO, DocParseService.effectiveMode(DocparseMode.AUTO, null));
}
// --- ragIngest wire building ---
private MultipartFile pdfFile() {
return new MockMultipartFile("fileInput", "invoice.pdf", "application/pdf", new byte[] {1});
}
private JsonNode ingestAndCaptureRequest(
String documentId, boolean index, boolean markdown, boolean chunks) throws IOException {
try (PDDocument document = new PDDocument()) {
document.addPage(new PDPage());
document.addPage(new PDPage());
when(pdfDocumentFactory.load(any(MultipartFile.class), anyBoolean()))
.thenReturn(document);
when(pdfContentExtractor.extractPageTextRaw(any(), eq(1))).thenReturn("page one");
when(pdfContentExtractor.extractPageTextRaw(any(), eq(2))).thenReturn("page two");
when(capabilityService.capabilities()).thenReturn(absent());
ArgumentCaptor<String> body = ArgumentCaptor.forClass(String.class);
when(aiEngineClient.postLongRunning(
eq("/api/v1/docparse/rag-ingest"), body.capture(), isNull()))
.thenReturn(ENGINE_RESPONSE);
RagIngestResponse response =
service.ragIngest(
pdfFile(),
documentId,
512,
64,
DocparseMode.AUTO,
index,
markdown,
chunks);
assertEquals(3, response.chunksIndexed());
return jsonMapper.readTree(body.getValue());
}
}
@Test
void ragIngestExtractsPagesAndSendsTheWireContract() throws IOException {
JsonNode request = ingestAndCaptureRequest("doc-1", true, false, false);
assertEquals("invoice.pdf", request.get("fileName").asText());
assertEquals("doc-1", request.get("documentId").asText());
// AUTO resolves to a concrete tier before the wire; without the addon that is basic.
assertEquals("basic", request.get("mode").asText());
assertEquals(2, request.get("pages").size());
assertEquals("page one", request.get("pages").get(0).get("text").asText());
assertEquals(512, request.get("chunkSize").asInt());
assertEquals(64, request.get("overlap").asInt());
assertTrue(request.get("index").asBoolean());
assertFalse(request.get("includeMarkdown").asBoolean());
assertFalse(request.get("includeChunks").asBoolean());
}
@Test
void ragIngestDefaultsDocumentIdToContentHash() throws IOException {
when(fileIdStrategy.idFor(any(MultipartFile.class))).thenReturn("sha-abc");
JsonNode request = ingestAndCaptureRequest(" ", true, false, false);
assertEquals("sha-abc", request.get("documentId").asText());
}
@Test
void ragIngestForwardsExportFlags() throws IOException {
JsonNode request = ingestAndCaptureRequest("doc-1", false, true, true);
assertFalse(request.get("index").asBoolean());
assertTrue(request.get("includeMarkdown").asBoolean());
assertTrue(request.get("includeChunks").asBoolean());
}
@Test
void ragIngestSettingsBasicCapsTheWireMode() throws IOException {
properties.getDocparse().setMode("basic");
JsonNode request = ingestAndCaptureRequest("doc-1", true, false, false);
assertEquals("basic", request.get("mode").asText());
}
@Test
void ragIngestWithNothingToDoIs400() {
ResponseStatusException error =
assertThrows(
ResponseStatusException.class,
() ->
service.ragIngest(
pdfFile(),
"doc",
512,
64,
DocparseMode.AUTO,
false,
false,
false));
assertEquals(HttpStatus.BAD_REQUEST, error.getStatusCode());
verifyNoInteractions(aiEngineClient);
}
@Test
void ragIngestWhenDisabledIs503() {
properties.getDocparse().setEnabled(false);
ResponseStatusException error =
assertThrows(
ResponseStatusException.class,
() ->
service.ragIngest(
pdfFile(),
"doc",
512,
64,
DocparseMode.AUTO,
true,
false,
false));
assertEquals(HttpStatus.SERVICE_UNAVAILABLE, error.getStatusCode());
verifyNoInteractions(aiEngineClient);
}
// --- tier resolution: auto and explicit requests ---
private DocparseCapabilities installed() {
return new DocparseCapabilities(true, "2.55.0", "2.6.0", true, "/models", List.of());
}
private DocparseCapabilities absent() {
return new DocparseCapabilities(false, null, null, false, null, List.of());
}
private void settingsMode(String mode) {
properties.getDocparse().setMode(mode);
}
@Test
void autoPicksAdvancedWhenScannedAndInstalled() {
settingsMode("auto");
assertEquals(
DocparseTier.ADVANCED,
service.resolveTier(DocparseMode.AUTO, installed(), false, true));
}
@Test
void autoPicksAdvancedWhenLayoutNeededAndInstalled() {
settingsMode("auto");
assertEquals(
DocparseTier.ADVANCED,
service.resolveTier(DocparseMode.AUTO, installed(), true, false));
}
@Test
void autoPicksBasicForBornDigitalDocument() {
settingsMode("auto");
assertEquals(
DocparseTier.BASIC,
service.resolveTier(DocparseMode.AUTO, installed(), false, false));
}
@Test
void autoPicksBasicWhenAddonMissingEvenIfScanned() {
settingsMode("auto");
assertEquals(
DocparseTier.BASIC, service.resolveTier(DocparseMode.AUTO, absent(), false, true));
}
@Test
void explicitBasicRequestAlwaysBasic() {
settingsMode("auto");
assertEquals(
DocparseTier.BASIC,
service.resolveTier(DocparseMode.BASIC, installed(), true, true));
}
@Test
void explicitAdvancedRequestUsesAdvancedWhenInstalled() {
settingsMode("auto");
assertEquals(
DocparseTier.ADVANCED,
service.resolveTier(DocparseMode.ADVANCED, installed(), false, false));
}
@Test
void explicitAdvancedRequestWithoutAddonReturns501() {
settingsMode("auto");
ResponseStatusException e =
assertThrows(
ResponseStatusException.class,
() -> service.resolveTier(DocparseMode.ADVANCED, absent(), false, false));
assertEquals(HttpStatus.NOT_IMPLEMENTED, e.getStatusCode());
}
@Test
void settingsBasicOverridesAdvancedRequest() {
settingsMode("basic");
assertEquals(
DocparseTier.BASIC,
service.resolveTier(DocparseMode.ADVANCED, installed(), true, true));
}
@Test
void settingsAdvancedUpgradesAutoRequest() {
settingsMode("advanced");
assertEquals(
DocparseTier.ADVANCED,
service.resolveTier(DocparseMode.AUTO, installed(), false, false));
}
@Test
void settingsAdvancedHonoursStricterBasicRequest() {
settingsMode("advanced");
assertEquals(
DocparseTier.BASIC,
service.resolveTier(DocparseMode.BASIC, installed(), false, false));
}
@Test
void settingsAdvancedWithoutAddonReturns501() {
settingsMode("advanced");
ResponseStatusException e =
assertThrows(
ResponseStatusException.class,
() -> service.resolveTier(DocparseMode.AUTO, absent(), false, false));
assertEquals(HttpStatus.NOT_IMPLEMENTED, e.getStatusCode());
}
@Test
void nullRequestBehavesAsAuto() {
settingsMode("auto");
assertEquals(DocparseTier.BASIC, service.resolveTier(null, installed(), false, false));
assertEquals(DocparseTier.ADVANCED, service.resolveTier(null, installed(), false, true));
}
// --- scanned heuristic ---
@Test
void looksScannedWhenAveragePageTextBelowThreshold() throws IOException {
try (PDDocument document = new PDDocument()) {
document.addPage(new PDPage());
document.addPage(new PDPage());
when(pdfContentExtractor.extractPageTextRaw(eq(document), anyInt()))
.thenReturn("short");
assertTrue(service.looksScanned(document));
}
}
@Test
void doesNotLookScannedWithRealTextLayer() throws IOException {
try (PDDocument document = new PDDocument()) {
document.addPage(new PDPage());
document.addPage(new PDPage());
when(pdfContentExtractor.extractPageTextRaw(eq(document), anyInt()))
.thenReturn("x".repeat(500));
assertFalse(service.looksScanned(document));
}
}
@Test
void fileNameFallsBackWhenMissing() {
MultipartFile nameless =
new MockMultipartFile("fileInput", "", "application/pdf", new byte[] {1});
assertEquals("document.pdf", DocParseService.fileName(nameless));
assertEquals("invoice.pdf", DocParseService.fileName(pdfFile()));
}
@Test
void extractPagesSkipsBlankPagesAndCapsCharacters() throws IOException {
properties.getAiEngine().getLimits().setMaxCharacters(12);
try (PDDocument document = new PDDocument()) {
document.addPage(new PDPage());
document.addPage(new PDPage());
document.addPage(new PDPage());
when(pdfContentExtractor.extractPageTextRaw(any(), eq(1))).thenReturn("0123456789");
when(pdfContentExtractor.extractPageTextRaw(any(), eq(2))).thenReturn(" ");
when(pdfContentExtractor.extractPageTextRaw(any(), eq(3))).thenReturn("abcdef");
var pages = service.extractPages(document);
assertEquals(2, pages.size());
// Page 3 is truncated to the remaining budget (12 - 10 = 2 chars).
assertEquals("ab", pages.get(1).getText());
}
}
}
@@ -0,0 +1,59 @@
# Stirling-PDF + AI engine with the DocParse addon.
# Two ways to get the addon; pick ONE per deployment:
# 1. Dynamic install (default here): standard engine image downloads the addon
# (~1.6 GB, one-time) into the docparse-data volume at first boot.
# 2. Baked image (air-gapped): build the engine with `--build-arg DOCPARSE=true`
# (uncomment DOCPARSE below) and drop DOCPARSE_AUTO_INSTALL.
services:
stirling-pdf:
build:
context: ../..
dockerfile: docker/embedded/Dockerfile.fat
container_name: stirling-pdf-docparse
restart: unless-stopped
healthcheck:
test: ["CMD-SHELL", "curl -f http://localhost:8080$${SYSTEM_ROOTURIPATH:-''}/api/v1/info/status | grep -q 'UP'"]
interval: 5s
timeout: 10s
retries: 16
ports:
- "8080:8080"
volumes:
- ../../stirling/latest/data:/usr/share/tessdata:rw
- ../../stirling/latest/config:/configs:rw
- ../../stirling/latest/logs:/logs:rw
environment:
SECURITY_ENABLELOGIN: "false"
SYSTEM_DEFAULTLOCALE: en-US
AIENGINE_ENABLED: "true"
AIENGINE_URL: http://stirling-pdf-engine:5001
DOCPARSE_ENABLED: "true"
DOCPARSE_MODE: auto
STIRLING_ENGINE_SHARED_SECRET: change-me
depends_on:
- stirling-pdf-engine
networks:
- stirling-network
stirling-pdf-engine:
build:
context: ../../engine
# args:
# DOCPARSE: "true" # bake the addon + models into the image instead
container_name: stirling-pdf-engine
restart: unless-stopped
volumes:
- docparse-data:/configs/docparse:rw
environment:
DOCPARSE_AUTO_INSTALL: "true"
STIRLING_ENGINE_REQUIRE_AUTH: "true"
STIRLING_ENGINE_SHARED_SECRET: change-me
networks:
- stirling-network
networks:
stirling-network:
driver: bridge
volumes:
docparse-data:
+11 -1
View File
@@ -15,11 +15,19 @@ WORKDIR /app/engine
COPY pyproject.toml uv.lock .env ./
COPY scripts/ ./scripts/
# DOCPARSE=true bakes the docparse addon (Docling + CPU torch, ~1.6 GB) and its
# model weights into the image for air-gapped deployments; default stays lean.
ARG DOCPARSE=false
RUN --mount=type=cache,target=/root/.cache/uv \
uv sync --frozen --no-dev
if [ "$DOCPARSE" = "true" ]; then uv sync --frozen --no-dev --extra docparse; else uv sync --frozen --no-dev; fi
COPY src/ ./src/
RUN if [ "$DOCPARSE" = "true" ]; then \
mkdir -p /opt/docparse/models \
&& .venv/bin/python scripts/prefetch_docparse_models.py --output /opt/docparse/models; \
fi
WORKDIR /app
COPY Taskfile.yml ./
COPY .taskfiles/ ./.taskfiles/
@@ -34,4 +42,6 @@ ENV STIRLING_ENGINE_PORT=5001
EXPOSE 5001
RUN chmod +x /app/engine/scripts/docker-entrypoint.sh /app/engine/scripts/init_docparse.sh
ENTRYPOINT ["/app/engine/scripts/docker-entrypoint.sh"]
CMD ["task", "engine:run"]
+21
View File
@@ -20,6 +20,16 @@ dependencies = [
"posthog>=3.0.0",
]
[project.optional-dependencies]
# DocParse advanced tier (layout parsing, tables, OCR, bbox citations).
# ~1.6 GB installed with CPU torch; never in the default image. Delivered via
# the addon engine image or the runtime dynamic install.
docparse = [
"docling>=2.55.0",
"torch>=2.6.0",
"torchvision>=0.21.0",
]
[dependency-groups]
dev = [
"anyio>=4.0.0",
@@ -34,6 +44,17 @@ dev = [
requires = ["hatchling"]
build-backend = "hatchling.build"
# Linux installs of the docparse extra must never pull CUDA wheels (~7 GB);
# pin torch to the CPU index there. Windows/macOS PyPI wheels are already CPU.
[[tool.uv.index]]
name = "pytorch-cpu"
url = "https://download.pytorch.org/whl/cpu"
explicit = true
[tool.uv.sources]
torch = [{ index = "pytorch-cpu", marker = "sys_platform == 'linux'" }]
torchvision = [{ index = "pytorch-cpu", marker = "sys_platform == 'linux'" }]
[tool.hatch.build.targets.wheel]
packages = ["src"]
exclude = [
+16
View File
@@ -0,0 +1,16 @@
#!/bin/sh
# Engine entrypoint: resolve where the docparse addon lives (baked vs dynamic)
# before handing off to the server command.
set -e
# Baked addon image (--build-arg DOCPARSE=true) prefetches models here.
if [ -z "${STIRLING_DOCPARSE_HOME:-}" ] && [ -d /opt/docparse/models ]; then
export STIRLING_DOCPARSE_HOME=/opt/docparse
fi
if [ "${DOCPARSE_AUTO_INSTALL:-false}" = "true" ]; then
export STIRLING_DOCPARSE_HOME="${STIRLING_DOCPARSE_HOME:-/configs/docparse}"
/app/engine/scripts/init_docparse.sh || echo "[docparse] dynamic install failed; continuing with basic tier" >&2
fi
exec "$@"
+43
View File
@@ -0,0 +1,43 @@
#!/bin/sh
# Dynamic docparse install: put the addon's locked package delta into
# $STIRLING_DOCPARSE_HOME/site (a volume, so it survives image upgrades) and
# prefetch model weights into $STIRLING_DOCPARSE_HOME/models.
# Idempotent: keyed on a marker derived from uv.lock, re-runs after upgrades.
set -e
ENGINE_DIR=/app/engine
HOME_DIR="${STIRLING_DOCPARSE_HOME:-/configs/docparse}"
SITE_DIR="$HOME_DIR/site"
MODELS_DIR="$HOME_DIR/models"
PYTHON="$ENGINE_DIR/.venv/bin/python"
mkdir -p "$SITE_DIR" "$MODELS_DIR"
LOCK_HASH=$(sha256sum "$ENGINE_DIR/uv.lock" | cut -c1-16)
MARKER="$HOME_DIR/.installed-$LOCK_HASH"
if [ ! -f "$MARKER" ]; then
echo "[docparse] installing addon packages into $SITE_DIR (one-time, ~1.6 GB)"
cd "$ENGINE_DIR"
# Delta = locked docparse resolution minus what the base venv already has.
uv export --frozen --no-dev --no-emit-project --no-hashes -o /tmp/docparse-base.req
uv export --frozen --extra docparse --no-dev --no-emit-project --no-hashes -o /tmp/docparse-full.req
grep -vxFf /tmp/docparse-base.req /tmp/docparse-full.req > /tmp/docparse-delta.req || true
uv pip install \
--python "$PYTHON" \
--target "$SITE_DIR" \
--no-deps \
--extra-index-url https://download.pytorch.org/whl/cpu \
--index-strategy unsafe-best-match \
-r /tmp/docparse-delta.req
rm -f "$HOME_DIR"/.installed-* /tmp/docparse-*.req
touch "$MARKER"
fi
if [ ! -d "$MODELS_DIR/docling" ] && [ -z "$(ls -A "$MODELS_DIR" 2>/dev/null)" ]; then
echo "[docparse] prefetching model weights into $MODELS_DIR"
PYTHONPATH="$SITE_DIR" "$PYTHON" "$ENGINE_DIR/scripts/prefetch_docparse_models.py" --output "$MODELS_DIR" \
|| echo "[docparse] model prefetch failed; docling will fetch into its cache on first use" >&2
fi
echo "[docparse] ready (site=$SITE_DIR, models=$MODELS_DIR)"
@@ -0,0 +1,39 @@
"""Prefetch Docling model weights for offline/air-gapped docparse.
Usage:
uv run --extra docparse python scripts/prefetch_docparse_models.py --output /configs/docparse/models
The output directory is what STIRLING_DOCPARSE_HOME/models points at; the
parser passes it to Docling as ``artifacts_path`` so no network is touched at
request time.
"""
from __future__ import annotations
import argparse
import importlib
import sys
from pathlib import Path
def main() -> int:
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("--output", required=True, help="Directory to download model weights into")
args = parser.parse_args()
try:
# importlib keeps this file typecheckable without the docparse extra installed
downloader = importlib.import_module("docling.utils.model_downloader")
except ImportError:
print("docling is not installed; run with: uv run --extra docparse ...", file=sys.stderr)
return 1
output = Path(args.output)
output.mkdir(parents=True, exist_ok=True)
path = downloader.download_models(output_dir=output, progress=True)
print(f"docparse models ready at {path}")
return 0
if __name__ == "__main__":
sys.exit(main())
+5
View File
@@ -17,6 +17,7 @@ from stirling.api.routes import (
agent_capabilities_router,
agent_draft_router,
config_router,
docparse_router,
document_classifier_router,
document_router,
execution_router,
@@ -30,6 +31,7 @@ from stirling.api.routes.config import CONFIG_APPLY_ERRORS, apply_to_app, resolv
from stirling.config import AppSettings, load_settings
from stirling.config.config_cache import cache_stamp, load_config
from stirling.contracts import HealthResponse
from stirling.docparse import activate_site
from stirling.documents import DocumentService, EmbeddingService
from stirling.services import setup_posthog_tracking
@@ -145,6 +147,8 @@ def _restore_cached_config(
async def lifespan(fast_api: FastAPI):
# Load env vars on startup so we can immediately crash if required env vars aren't set
settings = _load_startup_settings(fast_api)
# Initialize docparse addon if available (engine boot-time setup).
activate_site(settings.docparse_home)
# Precedence: env < persisted cache < live push. Stamp first so a push landing mid-boot
# is re-adopted by the watcher rather than mistaken for the config we just restored.
fast_api.state.config_cache_stamp = cache_stamp()
@@ -217,6 +221,7 @@ app.include_router(ledger_router, dependencies=_user_gate)
app.include_router(pdf_comments_router, dependencies=_user_gate)
app.include_router(agent_capabilities_router, dependencies=_user_gate)
app.include_router(document_classifier_router, dependencies=_user_gate)
app.include_router(docparse_router, dependencies=_user_gate)
# Config push is a system sync with no X-User-Id, so it is guarded by the shared secret
# and allow_config_push flag only, deliberately NOT the per-user identity gate.
app.include_router(config_router)
+5
View File
@@ -18,6 +18,7 @@ from stirling.agents import (
from stirling.agents.ledger import MathAuditorAgent
from stirling.agents.pdf_comment import PdfCommentAgent
from stirling.config import AppSettings
from stirling.docparse import ExtractFieldsAgent, SuggestSchemaAgent
from stirling.documents import DocumentService, EmbeddingService
from stirling.services import AppRuntime, build_runtime
@@ -35,6 +36,8 @@ class AppState:
math_auditor_agent: MathAuditorAgent
pdf_comment_agent: PdfCommentAgent
document_classifier_agent: DocumentClassifierAgent
extract_fields_agent: ExtractFieldsAgent
suggest_schema_agent: SuggestSchemaAgent
def build_app_state(
@@ -63,6 +66,8 @@ def build_app_state(
math_auditor_agent=MathAuditorAgent(runtime),
pdf_comment_agent=PdfCommentAgent(runtime),
document_classifier_agent=DocumentClassifierAgent(runtime),
extract_fields_agent=ExtractFieldsAgent(runtime),
suggest_schema_agent=SuggestSchemaAgent(runtime),
)
+9
View File
@@ -15,6 +15,7 @@ from stirling.agents import (
from stirling.agents.ledger import MathAuditorAgent
from stirling.agents.pdf_comment import PdfCommentAgent
from stirling.config import AppSettings, load_settings
from stirling.docparse import ExtractFieldsAgent, SuggestSchemaAgent
from stirling.documents import DocumentService
from stirling.models import UserId
from stirling.services import AppRuntime, current_user_id
@@ -60,6 +61,14 @@ def get_document_classifier_agent(request: Request) -> DocumentClassifierAgent:
return request.app.state.document_classifier_agent
def get_extract_fields_agent(request: Request) -> ExtractFieldsAgent:
return request.app.state.extract_fields_agent
def get_suggest_schema_agent(request: Request) -> SuggestSchemaAgent:
return request.app.state.suggest_schema_agent
def require_user_id() -> UserId:
"""FastAPI dependency for routes that touch per-user storage.
@@ -1,6 +1,7 @@
from .agent_capabilities import router as agent_capabilities_router
from .agent_drafts import router as agent_draft_router
from .config import router as config_router
from .docparse import router as docparse_router
from .document_classifier import router as document_classifier_router
from .documents import router as document_router
from .execution import router as execution_router
@@ -14,6 +15,7 @@ __all__ = [
"agent_capabilities_router",
"agent_draft_router",
"config_router",
"docparse_router",
"document_classifier_router",
"document_router",
"execution_router",
+263
View File
@@ -0,0 +1,263 @@
"""DocParse routes: parse, tables, rag-ingest, capabilities.
Tier routing: requests carrying raw file bytes can use the advanced (Docling)
path when the addon is installed; text-only requests run the basic path.
Forcing ``advanced`` without the addon returns 501 with a machine-readable
``addonRequired`` detail that Java maps onto its own error.
"""
from __future__ import annotations
import base64
import binascii
import logging
from typing import Annotated
import anyio.to_thread
from fastapi import APIRouter, Depends, HTTPException, status
from stirling.api.dependencies import (
get_document_service,
get_extract_fields_agent,
get_suggest_schema_agent,
require_user_id,
)
from stirling.config import AppSettings, load_settings
from stirling.contracts.docparse import (
DocChunk,
DocparseCapabilities,
DocparseMode,
DocparseTier,
ExtractFieldsRequest,
ExtractFieldsResponse,
ExtractTablesRequest,
ExtractTablesResponse,
ParseDocumentRequest,
ParseDocumentResponse,
RagIngestRequest,
RagIngestResponse,
SuggestSchemaRequest,
SuggestSchemaResponse,
)
from stirling.docparse import basic_chunks, probe_capabilities
from stirling.docparse.capability import models_dir
from stirling.docparse.chunking import advanced_chunks
from stirling.docparse.extractor import ExtractFieldsAgent, SchemaError, pages_from_parse
from stirling.docparse.suggest_schema import SuggestSchemaAgent
from stirling.documents import DocumentService
from stirling.documents.service import CONTENT_TYPE_METADATA_KEY, DOCPARSE_CHUNK_CONTENT_TYPE
from stirling.models import OwnerId, PrincipalId, UserId
logger = logging.getLogger(__name__)
router = APIRouter(prefix="/api/v1/docparse", tags=["docparse"])
_ADDON_REQUIRED_DETAIL = {
"addonRequired": "docparse",
"message": "The docparse addon (Docling) is not installed on the engine. "
"Install the engine's 'docparse' extra or enable DOCPARSE_AUTO_INSTALL.",
}
def _settings() -> AppSettings:
return load_settings()
def _capabilities(settings: AppSettings, *, refresh: bool = False) -> DocparseCapabilities:
return probe_capabilities(settings.docparse_home, refresh=refresh)
def _require_advanced(settings: AppSettings) -> str | None:
caps = _capabilities(settings)
if not caps.advanced_installed:
raise HTTPException(status_code=status.HTTP_501_NOT_IMPLEMENTED, detail=_ADDON_REQUIRED_DETAIL)
directory = models_dir(settings.docparse_home)
return str(directory) if caps.models_available and directory is not None else None
def _decode_content(content_base64: str) -> bytes:
try:
return base64.b64decode(content_base64, validate=True)
except (binascii.Error, ValueError) as error:
raise HTTPException(
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, detail="contentBase64 is not valid base64"
) from error
async def _parse_advanced(content_base64: str, file_name: str, *, with_ocr: bool, artifacts: str | None):
"""Run the Docling parse off-thread; unparsable documents are a caller error."""
from stirling.docparse.parser import parse_pdf_bytes # deferred: touches docling
data = _decode_content(content_base64)
try:
return await anyio.to_thread.run_sync(
lambda: parse_pdf_bytes(data, file_name, with_ocr=with_ocr, artifacts_path=artifacts)
)
except ValueError as error:
raise HTTPException(status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, detail=str(error)) from error
@router.get("/capabilities", response_model=DocparseCapabilities)
async def capabilities(refresh: bool = False) -> DocparseCapabilities:
return _capabilities(_settings(), refresh=refresh)
@router.post("/parse", response_model=ParseDocumentResponse)
async def parse_document(request: ParseDocumentRequest) -> ParseDocumentResponse:
"""Advanced-tier layout parse. The basic tier lives Java-side (PDFBox) and
never reaches the engine, so this endpoint requires the addon outright."""
settings = _settings()
artifacts = _require_advanced(settings)
return await _parse_advanced(
request.content_base64, request.file_name, with_ocr=request.with_ocr, artifacts=artifacts
)
@router.post("/extract", response_model=ExtractFieldsResponse)
async def extract_fields(
request: ExtractFieldsRequest,
agent: Annotated[ExtractFieldsAgent, Depends(get_extract_fields_agent)],
) -> ExtractFieldsResponse:
settings = _settings()
caps = _capabilities(settings)
use_advanced = request.mode is DocparseMode.ADVANCED or (
request.mode is DocparseMode.AUTO and caps.advanced_installed and request.content_base64 is not None
)
parse = None
if use_advanced:
artifacts = _require_advanced(settings)
if request.content_base64 is None:
raise HTTPException(
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
detail="advanced extraction needs contentBase64 (the raw file)",
)
parse = await _parse_advanced(request.content_base64, request.file_name, with_ocr=True, artifacts=artifacts)
pages = request.pages or (pages_from_parse(parse) if parse is not None else None)
if not pages:
raise HTTPException(
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
detail="send pages (extracted text) or contentBase64 with the addon installed",
)
try:
return await agent.extract(request, pages, parse)
except SchemaError as error:
raise HTTPException(status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, detail=str(error)) from error
@router.post("/suggest-schema", response_model=SuggestSchemaResponse)
async def suggest_schema(
request: SuggestSchemaRequest,
agent: Annotated[SuggestSchemaAgent, Depends(get_suggest_schema_agent)],
) -> SuggestSchemaResponse:
"""Propose an extraction schema from the document's first pages.
Tier routing: pages -> basic; contentBase64 + addon -> advanced parse."""
settings = _settings()
caps = _capabilities(settings)
pages = request.pages
tier = DocparseTier.BASIC
if not pages and request.content_base64 is not None and caps.advanced_installed:
artifacts = _require_advanced(settings)
parse = await _parse_advanced(request.content_base64, request.file_name, with_ocr=True, artifacts=artifacts)
pages = pages_from_parse(parse)
tier = DocparseTier.ADVANCED
if not pages:
raise HTTPException(
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
detail="send pages (extracted text) or contentBase64 with the addon installed",
)
return await agent.suggest(request, pages, tier)
def _chunk_metadata(chunk: DocChunk) -> dict[str, str]:
meta = {CONTENT_TYPE_METADATA_KEY: DOCPARSE_CHUNK_CONTENT_TYPE}
if chunk.page_start is not None:
meta["page_start"] = str(chunk.page_start)
if chunk.page_end is not None:
meta["page_end"] = str(chunk.page_end)
if chunk.heading_path:
meta["heading_path"] = " > ".join(chunk.heading_path)
return meta
@router.post("/rag-ingest", response_model=RagIngestResponse)
async def rag_ingest(
request: RagIngestRequest,
documents: Annotated[DocumentService, Depends(get_document_service)],
user_id: Annotated[UserId, Depends(require_user_id)],
) -> RagIngestResponse:
"""Chunk the document, then embed and index into the document store.
Re-ingesting a documentId replaces its stored content (never duplicates).
``index=False`` skips the store; ``includeMarkdown``/``includeChunks``
echo the content back for corpus export."""
settings = _settings()
caps = _capabilities(settings)
chunk_size = request.chunk_size if request.chunk_size is not None else settings.rag_chunk_size
overlap = request.overlap if request.overlap is not None else settings.rag_chunk_overlap
if not request.index and not request.include_markdown and not request.include_chunks:
raise HTTPException(
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
detail="nothing to do: enable index, includeMarkdown, or includeChunks",
)
use_advanced = request.mode is DocparseMode.ADVANCED or (
request.mode is DocparseMode.AUTO and caps.advanced_installed and request.content_base64 is not None
)
if use_advanced:
artifacts = _require_advanced(settings)
if request.content_base64 is None:
raise HTTPException(
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
detail="advanced chunking needs contentBase64 (the raw file)",
)
parse = await _parse_advanced(request.content_base64, request.file_name, with_ocr=True, artifacts=artifacts)
chunked = advanced_chunks(parse, chunk_size, overlap)
page_count = parse.pages
markdown = parse.markdown if request.include_markdown else None
else:
if not request.pages:
raise HTTPException(
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
detail="send pages (extracted text) or contentBase64 with the addon installed",
)
chunked = basic_chunks(request.pages, chunk_size, overlap)
page_count = max(p.page_number for p in request.pages)
markdown = None
if request.include_markdown:
markdown = "\n\n".join(p.text for p in request.pages if p.text.strip())
chunks_indexed = 0
if request.index:
# Owner/ACL semantics mirror IngestDocumentRequest; omitted values default
# to the authenticated caller (personal-doc behaviour).
owner_id = request.owner_id if request.owner_id is not None else OwnerId(user_id)
read_principals = request.read_principals or [PrincipalId(owner_id)]
chunks_indexed = await documents.ingest_prepared(
collection=request.document_id,
chunks=[(chunk.text, _chunk_metadata(chunk)) for chunk in chunked.chunks],
source=request.source,
owner_id=owner_id,
read_principals=read_principals,
expires_at=request.expires_at,
)
logger.info(
"docparse: rag-ingested %s: %d chunks indexed, %d pages", request.document_id, chunks_indexed, page_count
)
return RagIngestResponse(
mode=chunked.mode,
document_id=request.document_id,
chunks_indexed=chunks_indexed,
pages=page_count,
markdown=markdown,
chunks=chunked.chunks if request.include_chunks else None,
)
@router.post("/tables", response_model=ExtractTablesResponse)
async def extract_tables(request: ExtractTablesRequest) -> ExtractTablesResponse:
settings = _settings()
artifacts = _require_advanced(settings)
parse = await _parse_advanced(request.content_base64, request.file_name, with_ocr=True, artifacts=artifacts)
return ExtractTablesResponse(mode=parse.mode, tables=parse.tables)
+5
View File
@@ -101,6 +101,11 @@ class AppSettings(BaseSettings):
max_pages: int = Field(validation_alias="STIRLING_MAX_PAGES")
max_characters: int = Field(validation_alias="STIRLING_MAX_CHARACTERS")
# DocParse addon home: <home>/site holds a dynamically installed docling
# stack (prepended to sys.path at startup), <home>/models holds prefetched
# model weights. Empty = addon only usable when baked into the image.
docparse_home: str = Field(default="", validation_alias="STIRLING_DOCPARSE_HOME")
# When true, API routes reject requests that lack an X-User-Id header at
# the boundary. Self-hosted deployments with security disabled have no
# user identity and leave this off; multi-tenant deployments turn it on so
+217
View File
@@ -0,0 +1,217 @@
"""Wire contracts for the DocParse ingestion capability (chunking + rag-ingest).
Java counterpart DTOs live under ``stirling.software.proprietary.model.docparse``
and must stay in sync. Tier model: ``basic`` (text layer) runs everywhere;
``advanced`` (layout parsing) arrives with the docparse addon.
"""
from __future__ import annotations
from datetime import datetime
from enum import StrEnum
from pydantic import Field, JsonValue
from stirling.contracts.documents import PageText
from stirling.models import ApiModel, FileId, OwnerId, PrincipalId
class DocparseTier(StrEnum):
"""Which implementation actually served a request."""
BASIC = "basic"
ADVANCED = "advanced"
class DocparseMode(StrEnum):
"""What the caller asked for; AUTO resolves per-request."""
AUTO = "auto"
BASIC = "basic"
ADVANCED = "advanced"
class BlockType(StrEnum):
"""Normalized layout block labels; Docling labels map onto these."""
HEADING = "heading"
PARAGRAPH = "paragraph"
LIST_ITEM = "list_item"
TABLE = "table"
FIGURE = "figure"
CAPTION = "caption"
CODE = "code"
FORMULA = "formula"
PAGE_HEADER = "page_header"
PAGE_FOOTER = "page_footer"
FOOTNOTE = "footnote"
OTHER = "other"
class DocBlock(ApiModel):
"""One layout block. ``bbox`` is [x0, y0, x1, y1] normalized to 0..1 with a
top-left origin; ``None`` in basic tier (no layout model ran)."""
type: BlockType
text: str
page: int = Field(ge=1)
bbox: list[float] | None = None
confidence: float | None = Field(default=None, ge=0.0, le=1.0)
class DocTable(ApiModel):
page: int = Field(ge=1)
bbox: list[float] | None = None
cells: list[list[str]]
markdown: str
confidence: float | None = Field(default=None, ge=0.0, le=1.0)
class ParseDocumentRequest(ApiModel):
file_name: str = Field(min_length=1)
content_base64: str = Field(min_length=1)
with_ocr: bool = True
class ParseDocumentResponse(ApiModel):
mode: DocparseTier
pages: int = Field(ge=0)
blocks: list[DocBlock] = Field(default_factory=list)
tables: list[DocTable] = Field(default_factory=list)
markdown: str = ""
ocr_applied: bool = False
class ExtractTablesRequest(ApiModel):
file_name: str = Field(min_length=1)
content_base64: str = Field(min_length=1)
class ExtractTablesResponse(ApiModel):
mode: DocparseTier
tables: list[DocTable] = Field(default_factory=list)
class DocChunk(ApiModel):
index: int = Field(ge=0)
text: str
page_start: int | None = Field(default=None, ge=1)
page_end: int | None = Field(default=None, ge=1)
heading_path: list[str] = Field(default_factory=list)
class ChunkDocumentResponse(ApiModel):
mode: DocparseTier
chunks: list[DocChunk] = Field(default_factory=list)
class RagIngestRequest(ApiModel):
"""Chunk the document, then optionally embed and replace-index it.
``owner_id``/``read_principals`` default to the calling user (personal-doc
semantics); ``chunk_size``/``overlap`` default to the engine's RAG settings.
``index=False`` skips the store entirely (export-only ingestion);
``include_markdown``/``include_chunks`` echo the parsed content back so the
caller can emit corpus files (markdown, chunks JSONL).
"""
file_name: str = Field(min_length=1)
document_id: FileId = Field(min_length=1)
source: str = Field(default="docparse", min_length=1)
owner_id: OwnerId | None = None
read_principals: list[PrincipalId] | None = Field(default=None, min_length=1)
expires_at: datetime | None = None
pages: list[PageText] | None = None
content_base64: str | None = None
chunk_size: int | None = Field(default=None, ge=64, le=32_768)
overlap: int | None = Field(default=None, ge=0, le=4_096)
mode: DocparseMode = DocparseMode.AUTO
index: bool = True
include_markdown: bool = False
include_chunks: bool = False
class RagIngestResponse(ApiModel):
mode: DocparseTier
document_id: FileId
chunks_indexed: int = Field(ge=0)
pages: int = Field(ge=0)
markdown: str | None = None
chunks: list[DocChunk] | None = None
class FieldCitation(ApiModel):
"""Where a value came from. ``quote`` is always set; ``bbox`` only when a
layout parse ran (advanced tier); offsets index into the cited page's text."""
page: int | None = Field(default=None, ge=1)
bbox: list[float] | None = None
quote: str
start_offset: int | None = Field(default=None, ge=0)
end_offset: int | None = Field(default=None, ge=0)
class ExtractedField(ApiModel):
name: str
value: JsonValue = None
confidence: float = Field(ge=0.0, le=1.0)
citations: list[FieldCitation] = Field(default_factory=list)
class ExtractFieldsRequest(ApiModel):
"""``pages`` drives the basic tier (caller-extracted text); ``content_base64``
lets the advanced tier parse the raw file itself. Send either or both."""
file_name: str = Field(min_length=1)
fields_schema: dict[str, JsonValue]
pages: list[PageText] | None = None
content_base64: str | None = None
mode: DocparseMode = DocparseMode.AUTO
instructions: str | None = None
class ExtractFieldsResponse(ApiModel):
mode: DocparseTier
fields: list[ExtractedField] = Field(default_factory=list)
overall_confidence: float = Field(ge=0.0, le=1.0)
class SuggestedFieldType(StrEnum):
"""Scalar types the schema suggester may propose; the extractor's leaf subset."""
STRING = "string"
NUMBER = "number"
INTEGER = "integer"
BOOLEAN = "boolean"
class SuggestedField(ApiModel):
name: str = Field(description="snake_case field identifier, e.g. 'invoice_number'.")
type: SuggestedFieldType
description: str = ""
class SuggestSchemaRequest(ApiModel):
"""``pages`` drives the basic tier (caller-extracted text); ``content_base64``
lets the advanced tier parse the raw file itself. Send either."""
file_name: str = Field(min_length=1)
pages: list[PageText] | None = None
content_base64: str | None = None
max_fields: int = Field(default=8, ge=1, le=20)
class SuggestSchemaResponse(ApiModel):
mode: DocparseTier
fields: list[SuggestedField] = Field(default_factory=list)
class DocparseCapabilities(ApiModel):
"""What the engine can actually do right now; Java caches and republishes this."""
advanced_installed: bool
docling_version: str | None = None
torch_version: str | None = None
models_available: bool = False
models_path: str | None = None
errors: list[str] = Field(default_factory=list)
+21
View File
@@ -0,0 +1,21 @@
"""DocParse: document understanding for ingestion pipelines.
This package holds the basic (text-layer) tier; the advanced tier (Docling
layout parsing) is delivered as an optional addon and probed at runtime.
"""
from __future__ import annotations
from stirling.docparse.capability import activate_site, probe_capabilities
from stirling.docparse.chunking import advanced_chunks, basic_chunks
from stirling.docparse.extractor import ExtractFieldsAgent
from stirling.docparse.suggest_schema import SuggestSchemaAgent
__all__ = [
"ExtractFieldsAgent",
"SuggestSchemaAgent",
"activate_site",
"advanced_chunks",
"basic_chunks",
"probe_capabilities",
]
+104
View File
@@ -0,0 +1,104 @@
"""Probe whether the docparse addon (Docling) is importable and its models present.
The addon arrives either baked into the engine image (uv extra) or dynamically
installed into ``$STIRLING_DOCPARSE_HOME/site`` on a mounted volume; in the
latter case :func:`activate_site` prepends that directory to ``sys.path`` at
startup so the probe and the parser see it.
"""
from __future__ import annotations
import importlib
import importlib.metadata
import importlib.util
import logging
import sys
import threading
from pathlib import Path
from stirling.contracts.docparse import DocparseCapabilities
logger = logging.getLogger(__name__)
_lock = threading.Lock()
_cached: DocparseCapabilities | None = None
def site_dir(docparse_home: str) -> Path | None:
return Path(docparse_home) / "site" if docparse_home else None
def models_dir(docparse_home: str) -> Path | None:
return Path(docparse_home) / "models" if docparse_home else None
def activate_site(docparse_home: str) -> bool:
"""Prepend the dynamic-install site dir to ``sys.path`` if it exists.
Idempotent; returns True when the path is active. Called once from the app
lifespan before the first capability probe.
"""
site = site_dir(docparse_home)
if site is None or not site.is_dir():
return False
site_str = str(site)
if site_str not in sys.path:
sys.path.insert(0, site_str)
logger.info("docparse: activated dynamic site dir %s", site_str)
return True
def _version_of(distribution: str) -> str | None:
try:
return importlib.metadata.version(distribution)
except importlib.metadata.PackageNotFoundError:
return None
def _models_available(docparse_home: str) -> tuple[bool, str | None]:
"""Models count as available when the prefetch dir has content, or when no
home is configured at all (Docling then downloads into its default cache)."""
directory = models_dir(docparse_home)
if directory is None:
return True, None
if directory.is_dir() and any(directory.iterdir()):
return True, str(directory)
return False, str(directory)
def probe_capabilities(docparse_home: str, *, refresh: bool = False) -> DocparseCapabilities:
"""Report what the docparse layer can do right now. Cached after first call
(imports are expensive); ``refresh=True`` re-probes, e.g. after a dynamic install."""
global _cached
with _lock:
if _cached is not None and not refresh:
return _cached
errors: list[str] = []
advanced = importlib.util.find_spec("docling") is not None
docling_version: str | None = None
torch_version: str | None = None
if advanced:
docling_version = _version_of("docling")
torch_version = _version_of("torch")
if torch_version is None:
advanced = False
errors.append("docling present but torch missing; install is incomplete")
models_ok, models_path = _models_available(docparse_home)
_cached = DocparseCapabilities(
advanced_installed=advanced,
docling_version=docling_version,
torch_version=torch_version,
models_available=models_ok,
models_path=models_path,
errors=errors,
)
logger.info(
"docparse capabilities: advanced=%s docling=%s torch=%s models=%s",
advanced,
docling_version,
torch_version,
models_ok,
)
return _cached
+96
View File
@@ -0,0 +1,96 @@
"""RAG chunking, both tiers.
Basic: the existing character chunker per page. Advanced: structure-aware
packing over parse blocks - heading-bounded, heading breadcrumbs attached,
page ranges tracked. No tokenizer dependency; sizes are characters, matching
the rest of the engine."""
from __future__ import annotations
from stirling.contracts.docparse import BlockType, ChunkDocumentResponse, DocChunk, DocparseTier, ParseDocumentResponse
from stirling.contracts.documents import PageText
from stirling.documents.chunker import chunk_text
# Blocks that are noise for retrieval purposes.
_SKIP_TYPES = {BlockType.PAGE_HEADER, BlockType.PAGE_FOOTER}
def basic_chunks(pages: list[PageText], chunk_size: int, overlap: int) -> ChunkDocumentResponse:
chunks: list[DocChunk] = []
for page in pages:
for piece in chunk_text(page.text, chunk_size=chunk_size, overlap=overlap):
chunks.append(
DocChunk(
index=len(chunks),
text=piece,
page_start=page.page_number,
page_end=page.page_number,
)
)
return ChunkDocumentResponse(mode=DocparseTier.BASIC, chunks=chunks)
def advanced_chunks(parse: ParseDocumentResponse, chunk_size: int, overlap: int) -> ChunkDocumentResponse:
"""Pack layout blocks into chunks that never straddle a heading boundary."""
chunks: list[DocChunk] = []
heading_path: list[str] = []
buffer: list[str] = []
buffer_len = 0
page_start: int | None = None
page_end: int | None = None
buffer_headings: list[str] = []
def flush() -> None:
nonlocal buffer, buffer_len, page_start, page_end, buffer_headings
text = "\n\n".join(buffer).strip()
if text:
chunks.append(
DocChunk(
index=len(chunks),
text=text,
page_start=page_start,
page_end=page_end,
heading_path=list(buffer_headings),
)
)
buffer = []
buffer_len = 0
page_start = None
page_end = None
buffer_headings = list(heading_path)
buffer_headings = []
for block in parse.blocks:
if block.type in _SKIP_TYPES:
continue
if block.type is BlockType.HEADING:
flush()
heading_path = [*heading_path[-2:], block.text.strip()] if block.text.strip() else heading_path
buffer_headings = list(heading_path)
continue
text = block.text
if not text.strip():
continue
if buffer_len + len(text) > chunk_size and buffer:
flush()
# A single oversized block falls back to the character chunker.
if len(text) > chunk_size:
for piece in chunk_text(text, chunk_size=chunk_size, overlap=overlap):
chunks.append(
DocChunk(
index=len(chunks),
text=piece,
page_start=block.page,
page_end=block.page,
heading_path=list(buffer_headings),
)
)
continue
buffer.append(text)
buffer_len += len(text)
page_start = block.page if page_start is None else min(page_start, block.page)
page_end = block.page if page_end is None else max(page_end, block.page)
flush()
return ChunkDocumentResponse(mode=DocparseTier.ADVANCED, chunks=chunks)
+146
View File
@@ -0,0 +1,146 @@
"""Extraction accuracy harness: score a gold-labelled case set against a live engine.
Case layout (one directory per document):
cases/
invoice-001/
input.pdf # the document
expected.json # {"fieldsSchema": {...}, "fields": {"invoice_number": "INV-1", ...},
# "pages": [{"pageNumber": 1, "text": "..."}]? (optional, for basic tier)}
Run:
uv run python -m stirling.docparse.evals cases/ --engine http://localhost:5001 --output report.json
Scoring per field: exact match, then normalized match (case/whitespace/currency
punctuation collapsed, numeric tolerance 1e-6). The report aggregates per-case
and overall accuracy so pipeline changes can be regression-tracked. Stdlib
HTTP only - the harness must run anywhere the engine runs.
"""
from __future__ import annotations
import argparse
import base64
import json
import re
import sys
import urllib.error
import urllib.request
from pathlib import Path
from typing import Any
_NORMALIZE_STRIP = re.compile(r"[\s,€$£%]+")
def normalized_equal(expected: Any, actual: Any) -> bool:
if expected is None or actual is None:
return expected is actual
try:
return abs(float(expected) - float(actual)) < 1e-6
except (TypeError, ValueError):
pass
if isinstance(expected, list) and isinstance(actual, list):
return len(expected) == len(actual) and all(normalized_equal(e, a) for e, a in zip(expected, actual))
return _NORMALIZE_STRIP.sub("", str(expected)).casefold() == _NORMALIZE_STRIP.sub("", str(actual)).casefold()
def _call_extract(engine: str, secret: str | None, payload: dict[str, Any]) -> dict[str, Any]:
request = urllib.request.Request(
f"{engine.rstrip('/')}/api/v1/docparse/extract",
data=json.dumps(payload).encode("utf-8"),
headers={"Content-Type": "application/json", **({"X-Engine-Auth": secret} if secret else {})},
method="POST",
)
with urllib.request.urlopen(request, timeout=600) as response:
return json.loads(response.read().decode("utf-8"))
def score_case(engine: str, secret: str | None, case_dir: Path) -> dict[str, Any]:
expected_spec = json.loads((case_dir / "expected.json").read_text(encoding="utf-8"))
payload: dict[str, Any] = {
"fileName": "input.pdf",
"fieldsSchema": expected_spec["fieldsSchema"],
}
input_pdf = case_dir / "input.pdf"
if input_pdf.exists():
payload["contentBase64"] = base64.b64encode(input_pdf.read_bytes()).decode("ascii")
if expected_spec.get("pages"):
payload["pages"] = expected_spec["pages"]
response = _call_extract(engine, secret, payload)
actual = {field["name"]: field for field in response.get("fields", [])}
fields: list[dict[str, Any]] = []
exact = 0
normalized = 0
for name, expected_value in expected_spec.get("fields", {}).items():
actual_field = actual.get(name, {})
actual_value = actual_field.get("value")
is_exact = expected_value == actual_value
is_normalized = is_exact or normalized_equal(expected_value, actual_value)
exact += is_exact
normalized += is_normalized
fields.append(
{
"name": name,
"expected": expected_value,
"actual": actual_value,
"exact": is_exact,
"normalized": is_normalized,
"confidence": actual_field.get("confidence"),
"cited": bool(actual_field.get("citations")),
}
)
total = len(fields) or 1
return {
"case": case_dir.name,
"mode": response.get("mode"),
"fields": fields,
"exactAccuracy": round(exact / total, 4),
"normalizedAccuracy": round(normalized / total, 4),
}
def run(cases_root: Path, engine: str, secret: str | None) -> dict[str, Any]:
case_dirs = sorted(d for d in cases_root.iterdir() if d.is_dir() and (d / "expected.json").exists())
results: list[dict[str, Any]] = []
failures: list[dict[str, str]] = []
for case_dir in case_dirs:
try:
results.append(score_case(engine, secret, case_dir))
except (urllib.error.URLError, OSError, KeyError, ValueError) as error:
failures.append({"case": case_dir.name, "error": str(error)})
scored = [r for r in results if r["fields"]]
overall = {
"cases": len(case_dirs),
"scored": len(scored),
"errors": failures,
"exactAccuracy": round(sum(r["exactAccuracy"] for r in scored) / len(scored), 4) if scored else 0.0,
"normalizedAccuracy": round(sum(r["normalizedAccuracy"] for r in scored) / len(scored), 4) if scored else 0.0,
"results": results,
}
return overall
def main() -> int:
parser = argparse.ArgumentParser(description="Score docparse extraction against a gold case set.")
parser.add_argument("cases", help="Directory of case subdirectories")
parser.add_argument("--engine", default="http://localhost:5001", help="Engine base URL")
parser.add_argument("--secret", default=None, help="X-Engine-Auth shared secret, if the engine requires it")
parser.add_argument("--output", default=None, help="Write the JSON report here (default: stdout)")
args = parser.parse_args()
report = run(Path(args.cases), args.engine, args.secret)
rendered = json.dumps(report, indent=2)
if args.output:
Path(args.output).write_text(rendered, encoding="utf-8")
print(f"exact={report['exactAccuracy']} normalized={report['normalizedAccuracy']} -> {args.output}")
else:
print(rendered)
return 0 if not report["errors"] else 1
if __name__ == "__main__":
sys.exit(main())
+320
View File
@@ -0,0 +1,320 @@
"""Schema-driven field extraction with grounded citations and confidence.
The caller supplies a JSON Schema (subset: scalar types, enums, arrays of
scalars, nested objects). We build a dynamic pydantic output model where every
leaf answers ``{value, quote, confidence}``, run one smart-model pass, then
ground each quote against the page text in code. Model confidence is damped
when a quote can't be found - the model asserts, the grounding decides how
much to believe it.
"""
from __future__ import annotations
import logging
import re
from typing import Any
from pydantic import BaseModel, Field, JsonValue, create_model
from pydantic_ai import Agent
from stirling.agents.output_mode import output_retries, structured_output
from stirling.contracts.docparse import (
DocparseTier,
ExtractedField,
ExtractFieldsRequest,
ExtractFieldsResponse,
FieldCitation,
ParseDocumentResponse,
)
from stirling.contracts.documents import PageText
from stirling.models import ApiModel
from stirling.services import AppRuntime
logger = logging.getLogger(__name__)
# Confidence multiplier when the supporting quote can't be found in the document.
UNGROUNDED_PENALTY = 0.6
# Floor when the model omitted quote/confidence but the value itself is found
# verbatim in the document - the grounding is real even if the model was terse.
VALUE_GROUNDED_FLOOR = 0.5
MAX_SCHEMA_DEPTH = 3
MAX_FIELDS = 100
_SYSTEM_PROMPT = (
"You extract structured fields from a document.\n"
"\n"
"Rules:\n"
"- For every field, return the value exactly as the schema types it, a short VERBATIM quote "
"from the document that supports it, and your confidence from 0.0 to 1.0.\n"
"- The quote must be copied character-for-character from the document text, at most 200 characters.\n"
"- If the document does not contain the field, return value null, quote null, confidence 0.0. "
"Never guess or fabricate.\n"
"- Dates: return them formatted as the schema/description asks; quote the original text.\n"
"- The document may be in any language."
)
class SchemaError(ValueError):
"""The supplied JSON Schema is outside the supported subset."""
def _scalar_type(spec: dict[str, Any]) -> Any:
# Enums stay str-typed; the allowed values travel in the field description
# (dynamic Literal types don't typecheck and local models handle them badly).
match spec.get("type"):
case "string":
return str
case "integer":
return int
case "number":
return float
case "boolean":
return bool
case _:
raise SchemaError(f"Unsupported schema type: {spec.get('type')!r}")
def _describe(spec: dict[str, Any]) -> str | None:
description = spec.get("description") if isinstance(spec.get("description"), str) else None
enum = spec.get("enum")
if isinstance(enum, list) and enum:
allowed = ", ".join(str(v) for v in enum)
description = f"{description + ' ' if description else ''}Allowed values: {allowed}."
return description
def _leaf_answer_model(name: str, value_type: Any, description: str | None) -> type[BaseModel]:
return create_model(
f"Answer_{re.sub(r'[^A-Za-z0-9]', '_', name)}",
__base__=ApiModel,
value=(value_type | None, Field(default=None, description=description or None)),
quote=(str | None, Field(default=None, max_length=400)),
confidence=(float, Field(default=0.0, ge=0.0, le=1.0)),
)
def build_output_model(
fields_schema: dict[str, Any], *, _depth: int = 0, _name: str = "ExtractionOutput"
) -> type[BaseModel]:
"""Turn the caller's JSON Schema into a pydantic model of leaf answers."""
if _depth > MAX_SCHEMA_DEPTH:
raise SchemaError(f"Schema nesting deeper than {MAX_SCHEMA_DEPTH} is not supported")
properties = fields_schema.get("properties")
if not isinstance(properties, dict) or not properties:
raise SchemaError("Schema must be an object with a non-empty 'properties' map")
if len(properties) > MAX_FIELDS:
raise SchemaError(f"Schema has more than {MAX_FIELDS} fields")
model_fields: dict[str, Any] = {}
for raw_name, spec in properties.items():
name = str(raw_name)
if not re.fullmatch(r"[A-Za-z_][A-Za-z0-9_]*", name):
raise SchemaError(f"Field name {name!r} must be a valid identifier")
if not isinstance(spec, dict):
raise SchemaError(f"Field {name!r} must map to a schema object")
description = _describe(spec)
if spec.get("type") == "object":
nested = build_output_model(spec, _depth=_depth + 1, _name=f"{_name}_{name}")
model_fields[name] = (nested, Field(...))
elif spec.get("type") == "array":
items = spec.get("items")
if not isinstance(items, dict):
raise SchemaError(f"Array field {name!r} needs an 'items' schema")
if items.get("type") == "object":
raise SchemaError(f"Array field {name!r}: arrays of objects are not supported yet")
item_type: Any = _scalar_type(items)
answer = _leaf_answer_model(name, list[item_type], description)
model_fields[name] = (answer, Field(...))
else:
answer = _leaf_answer_model(name, _scalar_type(spec), description)
model_fields[name] = (answer, Field(...))
return create_model(_name, __base__=ApiModel, **model_fields)
_WHITESPACE = re.compile(r"\s+")
def _normalize(text: str) -> str:
return _WHITESPACE.sub(" ", text).strip().casefold()
def find_quote(quote: str, pages: list[PageText]) -> tuple[int, int, int] | None:
"""Locate ``quote`` in the page texts, whitespace-insensitively.
Returns (page_number, start_offset, end_offset) into the page's raw text,
or None. Offsets are approximate under whitespace collapsing: we search the
normalized page, then map back by counting non-space characters.
"""
needle = _normalize(quote)
if not needle:
return None
for page in pages:
haystack = _normalize(page.text)
idx = haystack.find(needle)
if idx < 0:
continue
start = _denormalize_offset(page.text, idx)
end = _denormalize_offset(page.text, idx + len(needle))
return page.page_number, start, min(end, len(page.text))
return None
def _denormalize_offset(raw: str, normalized_offset: int) -> int:
"""Map an offset in the normalized string back into the raw string."""
count = 0
in_space = True # leading whitespace is stripped by _normalize
for i, ch in enumerate(raw):
if ch.isspace():
if in_space:
continue
in_space = True
else:
in_space = False
if count >= normalized_offset:
return i
count += 1
return len(raw)
def _bbox_for_quote(quote: str, parse: ParseDocumentResponse | None) -> list[float] | None:
if parse is None:
return None
needle = _normalize(quote)
if not needle:
return None
for block in parse.blocks:
if block.bbox is not None and needle in _normalize(block.text):
return block.bbox
return None
def flatten_answers(output: BaseModel, prefix: str = "") -> list[tuple[str, JsonValue, str | None, float]]:
"""Walk the dynamic output model into (dotted_name, value, quote, confidence) leaves."""
leaves: list[tuple[str, JsonValue, str | None, float]] = []
for name in type(output).model_fields:
node = getattr(output, name)
dotted = f"{prefix}{name}"
if isinstance(node, BaseModel) and "confidence" in type(node).model_fields:
value = getattr(node, "value", None)
quote = getattr(node, "quote", None)
confidence = float(getattr(node, "confidence", 0.0) or 0.0)
leaves.append((dotted, value, quote, confidence))
elif isinstance(node, BaseModel):
leaves.extend(flatten_answers(node, prefix=f"{dotted}."))
return leaves
def _format_pages(pages: list[PageText], max_characters: int) -> str:
parts: list[str] = []
used = 0
for page in pages:
snippet = page.text[: max(0, max_characters - used)]
parts.append(f"[Page {page.page_number}]\n{snippet}")
used += len(snippet)
if used >= max_characters:
break
return "\n\n".join(parts) if parts else "(no extractable text)"
def pages_from_parse(parse: ParseDocumentResponse) -> list[PageText]:
"""Rebuild per-page text from parse blocks (advanced path with no caller text)."""
by_page: dict[int, list[str]] = {}
for block in parse.blocks:
by_page.setdefault(block.page, []).append(block.text)
return [PageText(page_number=n, text="\n".join(t)) for n, t in sorted(by_page.items())]
class ExtractFieldsAgent:
"""One smart-model pass over the document, then code-side grounding."""
def __init__(self, runtime: AppRuntime) -> None:
self.runtime = runtime
async def extract(
self,
request: ExtractFieldsRequest,
pages: list[PageText],
parse: ParseDocumentResponse | None,
) -> ExtractFieldsResponse:
output_model = build_output_model(dict(request.fields_schema))
provider = self.runtime.settings.chat_provider
agent: Agent[None, BaseModel] = Agent(
model=self.runtime.smart_model,
output_type=structured_output([output_model], chat_provider=provider),
system_prompt=_SYSTEM_PROMPT,
model_settings=self.runtime.smart_model_settings,
retries=output_retries(provider),
)
prompt = self._build_prompt(request, pages)
result = await agent.run(prompt)
fields = self._ground(result.output, pages, parse)
overall = round(min((f.confidence for f in fields), default=0.0), 4)
tier = DocparseTier.ADVANCED if parse is not None else DocparseTier.BASIC
return ExtractFieldsResponse(mode=tier, fields=fields, overall_confidence=overall)
def _build_prompt(self, request: ExtractFieldsRequest, pages: list[PageText]) -> str:
instructions = f"Additional instructions: {request.instructions}\n\n" if request.instructions else ""
return (
f"{instructions}"
f"Document file name: {request.file_name}\n"
f"Document content:\n{_format_pages(pages, self.runtime.settings.max_characters)}"
)
@staticmethod
def _ground(
output: BaseModel,
pages: list[PageText],
parse: ParseDocumentResponse | None,
) -> list[ExtractedField]:
fields: list[ExtractedField] = []
for name, value, quote, model_confidence in flatten_answers(output):
citations: list[FieldCitation] = []
confidence = max(0.0, min(1.0, model_confidence))
if value is None:
confidence = 0.0
elif quote:
located = find_quote(quote, pages)
if located is not None:
page_number, start, end = located
citations.append(
FieldCitation(
page=page_number,
bbox=_bbox_for_quote(quote, parse),
quote=quote,
start_offset=start,
end_offset=end,
)
)
else:
citations.append(FieldCitation(page=None, bbox=None, quote=quote))
confidence *= UNGROUNDED_PENALTY
else:
# Terse models (local Ollama especially) often skip the quote;
# grounding the value itself keeps citations and a usable score.
value_text = _render_value(value)
located = find_quote(value_text, pages) if value_text else None
if located is not None:
page_number, start, end = located
citations.append(
FieldCitation(
page=page_number,
bbox=_bbox_for_quote(value_text, parse),
quote=value_text,
start_offset=start,
end_offset=end,
)
)
confidence = max(confidence, VALUE_GROUNDED_FLOOR)
else:
confidence *= UNGROUNDED_PENALTY
fields.append(ExtractedField(name=name, value=value, confidence=round(confidence, 4), citations=citations))
return fields
def _render_value(value: JsonValue) -> str:
"""A searchable text form of a leaf value; empty when nothing sensible exists."""
if value is None or isinstance(value, (dict, list)) or isinstance(value, bool):
return ""
return str(value)
+188
View File
@@ -0,0 +1,188 @@
"""Advanced-tier document parsing via Docling.
Everything Docling is imported lazily through :mod:`importlib` so this module
imports cleanly (and pyright passes) when the addon isn't installed. Callers
must check :func:`stirling.docparse.capability.probe_capabilities` first; the
route returns 501 otherwise.
"""
from __future__ import annotations
import importlib
import io
import logging
import threading
from typing import Any
from stirling.contracts.docparse import (
BlockType,
DocBlock,
DocparseTier,
DocTable,
ParseDocumentResponse,
)
logger = logging.getLogger(__name__)
_converter_lock = threading.Lock()
_converter: Any | None = None
_converter_key: tuple[str, bool] | None = None
# Docling DocItemLabel values → our normalized block vocabulary.
_LABEL_MAP: dict[str, BlockType] = {
"title": BlockType.HEADING,
"section_header": BlockType.HEADING,
"paragraph": BlockType.PARAGRAPH,
"text": BlockType.PARAGRAPH,
"list_item": BlockType.LIST_ITEM,
"table": BlockType.TABLE,
"picture": BlockType.FIGURE,
"chart": BlockType.FIGURE,
"caption": BlockType.CAPTION,
"code": BlockType.CODE,
"formula": BlockType.FORMULA,
"page_header": BlockType.PAGE_HEADER,
"page_footer": BlockType.PAGE_FOOTER,
"footnote": BlockType.FOOTNOTE,
}
def _get_converter(artifacts_path: str | None, with_ocr: bool) -> Any:
"""Build (once) and cache the Docling converter; model load costs seconds."""
global _converter, _converter_key
key = (artifacts_path or "", with_ocr)
with _converter_lock:
if _converter is not None and _converter_key == key:
return _converter
pdf_options_mod = importlib.import_module("docling.datamodel.pipeline_options")
converter_mod = importlib.import_module("docling.document_converter")
base_models = importlib.import_module("docling.datamodel.base_models")
pipeline_options = pdf_options_mod.PdfPipelineOptions()
pipeline_options.do_ocr = with_ocr
pipeline_options.do_table_structure = True
if artifacts_path:
pipeline_options.artifacts_path = artifacts_path
# Never reach the network when a prefetched model dir is configured.
pipeline_options.enable_remote_services = False
input_format = base_models.InputFormat.PDF
pdf_format_option = converter_mod.PdfFormatOption(pipeline_options=pipeline_options)
_converter = converter_mod.DocumentConverter(format_options={input_format: pdf_format_option})
_converter_key = key
return _converter
def _normalize_bbox(prov: Any, page_sizes: dict[int, tuple[float, float]]) -> tuple[int, list[float] | None]:
"""Docling prov → (page, [x0, y0, x1, y1] normalized, top-left origin)."""
page_no = int(getattr(prov, "page_no", 1) or 1)
bbox = getattr(prov, "bbox", None)
size = page_sizes.get(page_no)
if bbox is None or size is None or size[0] <= 0 or size[1] <= 0:
return page_no, None
width, height = size
try:
# Docling boxes are bottom-left origin; flip to top-left before normalizing.
top_left = bbox.to_top_left_origin(page_height=height) if hasattr(bbox, "to_top_left_origin") else bbox
x0 = float(top_left.l) / width
y0 = float(top_left.t) / height
x1 = float(top_left.r) / width
y1 = float(top_left.b) / height
except (AttributeError, TypeError, ValueError):
return page_no, None
clamp = lambda v: max(0.0, min(1.0, v)) # noqa: E731
x0, x1 = sorted((clamp(x0), clamp(x1)))
y0, y1 = sorted((clamp(y0), clamp(y1)))
return page_no, [round(x0, 5), round(y0, 5), round(x1, 5), round(y1, 5)]
def _document_confidence(result: Any) -> float | None:
"""Pull a single 0..1 confidence out of Docling's confidence report, if any."""
report = getattr(result, "confidence", None)
if report is None:
return None
for attr in ("mean_score", "mean_grade_score", "score"):
value = getattr(report, attr, None)
if isinstance(value, (int, float)) and 0.0 <= float(value) <= 1.0:
return round(float(value), 4)
return None
def _table_cells(item: Any) -> list[list[str]]:
data = getattr(item, "data", None)
grid = getattr(data, "grid", None) or []
cells: list[list[str]] = []
for row in grid:
cells.append([str(getattr(cell, "text", "") or "") for cell in row])
return cells
def _cells_to_markdown(cells: list[list[str]]) -> str:
if not cells:
return ""
esc = lambda s: s.replace("|", "\\|").replace("\n", " ") # noqa: E731
lines = ["| " + " | ".join(esc(c) for c in cells[0]) + " |"]
lines.append("|" + "---|" * len(cells[0]))
for row in cells[1:]:
lines.append("| " + " | ".join(esc(c) for c in row) + " |")
return "\n".join(lines)
def parse_pdf_bytes(
data: bytes,
file_name: str,
*,
with_ocr: bool = True,
artifacts_path: str | None = None,
) -> ParseDocumentResponse:
"""Synchronous, CPU-heavy; call from a worker thread (routes use ``anyio.to_thread``)."""
io_mod = importlib.import_module("docling_core.types.io")
converter = _get_converter(artifacts_path, with_ocr)
stream = io_mod.DocumentStream(name=file_name or "document.pdf", stream=io.BytesIO(data))
try:
result = converter.convert(stream)
except Exception as error:
raise ValueError(f"document could not be parsed: {error}") from error
doc = result.document
page_sizes: dict[int, tuple[float, float]] = {}
for page_no, page in (getattr(doc, "pages", None) or {}).items():
size = getattr(page, "size", None)
if size is not None:
page_sizes[int(page_no)] = (float(size.width), float(size.height))
doc_confidence = _document_confidence(result)
blocks: list[DocBlock] = []
tables: list[DocTable] = []
for item, _level in doc.iterate_items():
provs = getattr(item, "prov", None) or []
page, bbox = _normalize_bbox(provs[0], page_sizes) if provs else (1, None)
label = str(getattr(item, "label", "") or "").lower()
block_type = _LABEL_MAP.get(label, BlockType.OTHER)
if block_type is BlockType.TABLE:
cells = _table_cells(item)
tables.append(
DocTable(
page=page, bbox=bbox, cells=cells, markdown=_cells_to_markdown(cells), confidence=doc_confidence
)
)
text = str(getattr(item, "text", "") or "")
if not text and block_type not in (BlockType.TABLE, BlockType.FIGURE):
continue
blocks.append(DocBlock(type=block_type, text=text, page=page, bbox=bbox, confidence=doc_confidence))
markdown = doc.export_to_markdown()
pages = len(page_sizes) or len(getattr(doc, "pages", None) or {})
logger.info("docparse: parsed %s: %d pages, %d blocks, %d tables", file_name, pages, len(blocks), len(tables))
return ParseDocumentResponse(
mode=DocparseTier.ADVANCED,
pages=pages,
blocks=blocks,
tables=tables,
markdown=markdown,
ocr_applied=with_ocr,
)
@@ -0,0 +1,120 @@
"""Schema suggestion: the fast model proposes extractable fields for a document.
Reads a bounded window of the first pages (the fields worth extracting from a
document type are evident from its opening) and answers with candidate fields,
which are then validated in code: names coerced to snake_case, duplicates and
unsupported types dropped, capped at the caller's maxFields."""
from __future__ import annotations
import logging
import re
from pydantic import Field
from pydantic_ai import Agent
from stirling.agents.output_mode import output_retries, structured_output
from stirling.contracts.docparse import (
DocparseTier,
SuggestedField,
SuggestedFieldType,
SuggestSchemaRequest,
SuggestSchemaResponse,
)
from stirling.contracts.documents import PageText
from stirling.models import ApiModel
from stirling.services import AppRuntime
logger = logging.getLogger(__name__)
# First pages read; a fixed window keeps cost flat regardless of length.
WINDOW_PAGES = 3
# Per-page preview budget; field candidates show up near page openings.
PAGE_PREVIEW_CHARS = 2_000
_SYSTEM_PROMPT = (
"You design an extraction schema for a document type.\n"
"\n"
"You are shown the first pages of a document. Propose the most useful fields "
"a user would want extracted from documents of this type.\n"
"Rules:\n"
"- Name each field as a snake_case identifier (e.g. 'invoice_number').\n"
"- Type each field as one of: string, number, integer, boolean.\n"
"- Give each field a one-sentence description of what it holds.\n"
"- Propose fields for the document TYPE, not only values visible on these pages.\n"
"- Order fields from most to least useful."
)
class _SuggestedField(ApiModel):
# Loosely typed on purpose: bad names/types are dropped in code, not retried.
name: str = Field(description="snake_case identifier for the field.")
type: str = Field(description="One of: string, number, integer, boolean.")
description: str = ""
class _SuggestOutput(ApiModel):
fields: list[_SuggestedField] = Field(default_factory=list)
_IDENTIFIER = re.compile(r"[a-z][a-z0-9_]*")
_CAMEL_BOUNDARY = re.compile(r"(?<=[a-z0-9])(?=[A-Z])")
_NON_ALNUM = re.compile(r"[^A-Za-z0-9]+")
_VALID_TYPES = {t.value for t in SuggestedFieldType}
def to_snake_case(name: str) -> str:
"""Coerce a model-proposed name into snake_case ('Invoice No.' -> 'invoice_no')."""
return _NON_ALNUM.sub("_", _CAMEL_BOUNDARY.sub("_", name.strip())).strip("_").lower()
def validate_fields(output: _SuggestOutput, max_fields: int) -> list[SuggestedField]:
"""Keep unique snake_case names with supported types, capped at ``max_fields``."""
kept: list[SuggestedField] = []
seen: set[str] = set()
for field in output.fields:
name = to_snake_case(field.name)
type_name = field.type.strip().lower()
if not _IDENTIFIER.fullmatch(name) or name in seen or type_name not in _VALID_TYPES:
continue
seen.add(name)
kept.append(
SuggestedField(name=name, type=SuggestedFieldType(type_name), description=field.description.strip())
)
if len(kept) == max_fields:
break
return kept
def _format_pages(pages: list[PageText]) -> str:
shown = pages[:WINDOW_PAGES]
parts = [f"[Page {p.page_number}] {p.text[:PAGE_PREVIEW_CHARS]}" for p in shown]
if len(pages) > WINDOW_PAGES:
parts.append(f"({len(pages) - WINDOW_PAGES} further pages omitted)")
return "\n\n".join(parts) if parts else "(no extractable text)"
class SuggestSchemaAgent:
def __init__(self, runtime: AppRuntime) -> None:
self.runtime = runtime
provider = runtime.settings.chat_provider
self._agent: Agent[None, _SuggestOutput] = Agent(
model=runtime.fast_model,
output_type=structured_output([_SuggestOutput], chat_provider=provider),
system_prompt=_SYSTEM_PROMPT,
model_settings=runtime.fast_model_settings,
retries=output_retries(provider),
)
async def suggest(
self, request: SuggestSchemaRequest, pages: list[PageText], tier: DocparseTier
) -> SuggestSchemaResponse:
prompt = (
f"Propose up to {request.max_fields} fields.\n\n"
f"Document file name: {request.file_name}\n"
f"Document content (first pages):\n{_format_pages(pages)}"
)
result = await self._agent.run(prompt)
fields = validate_fields(result.output, request.max_fields)
logger.info("docparse: suggested %d fields for %s", len(fields), request.file_name)
return SuggestSchemaResponse(mode=tier, fields=fields)
+37
View File
@@ -13,6 +13,7 @@ logger = logging.getLogger(__name__)
PAGE_NUMBER_METADATA_KEY = "page_number"
CONTENT_TYPE_METADATA_KEY = "content_type"
PAGE_TEXT_CONTENT_TYPE = "page_text"
DOCPARSE_CHUNK_CONTENT_TYPE = "docparse_chunk"
class DocumentService:
@@ -109,6 +110,42 @@ class DocumentService:
await self._store.add_documents(collection, chunks, embeddings, owner_id)
return len(chunks)
async def ingest_prepared(
self,
collection: FileId,
chunks: list[tuple[str, dict[str, str]]],
source: str,
owner_id: OwnerId,
read_principals: list[PrincipalId],
expires_at: datetime | None,
) -> int:
"""Replace-ingest pre-chunked content (e.g. docparse structure-aware chunks).
Same lifecycle as :meth:`ingest` - wipe the ``(collection, owner_id)``
pair, recreate it, grant reads, embed in batches, upsert - but chunk
``(text, metadata)`` pairs arrive pre-built and no page representation
is written. Returns the number of vector chunks indexed.
"""
if not read_principals:
raise ValueError("read_principals must not be empty - every doc needs at least one reader")
await self._store.delete_collection(collection, owner_id)
await self._store.ensure_collection(collection, source, owner_id, expires_at)
await self._store.grant_read(collection, owner_id, read_principals)
documents: list[Document] = []
for i, (text, metadata) in enumerate(chunks):
if not text.strip():
continue
meta = {**metadata, "source": source, "chunk_index": str(i)}
documents.append(Document(id=f"{source}:docparse:{i}", text=text, metadata=meta))
if not documents:
return 0
embeddings = await self._embedder.embed_documents([doc.text for doc in documents])
await self._store.add_documents(collection, documents, embeddings, owner_id)
return len(documents)
async def search(
self,
query: str,
+48
View File
@@ -0,0 +1,48 @@
from __future__ import annotations
from stirling.contracts import PageText
from stirling.contracts.docparse import BlockType, DocBlock, DocparseTier, ParseDocumentResponse
from stirling.docparse.chunking import advanced_chunks, basic_chunks
def test_basic_chunks_carry_page_numbers() -> None:
pages = [
PageText(page_number=1, text="alpha " * 200),
PageText(page_number=2, text="beta " * 200),
]
result = basic_chunks(pages, chunk_size=256, overlap=32)
assert result.mode is DocparseTier.BASIC
assert len(result.chunks) >= 4
assert {c.page_start for c in result.chunks} == {1, 2}
assert [c.index for c in result.chunks] == list(range(len(result.chunks)))
def _parse_fixture() -> ParseDocumentResponse:
blocks = [
DocBlock(type=BlockType.PAGE_HEADER, text="CONFIDENTIAL", page=1),
DocBlock(type=BlockType.HEADING, text="1. Introduction", page=1),
DocBlock(type=BlockType.PARAGRAPH, text="Short intro paragraph.", page=1),
DocBlock(type=BlockType.PARAGRAPH, text="Second paragraph on same topic.", page=1),
DocBlock(type=BlockType.HEADING, text="2. Terms", page=2),
DocBlock(type=BlockType.PARAGRAPH, text="terms " * 300, page=2),
]
return ParseDocumentResponse(mode=DocparseTier.ADVANCED, pages=2, blocks=blocks, tables=[], markdown="")
def test_advanced_chunks_respect_headings_and_skip_furniture() -> None:
result = advanced_chunks(_parse_fixture(), chunk_size=512, overlap=64)
assert result.mode is DocparseTier.ADVANCED
texts = [c.text for c in result.chunks]
assert all("CONFIDENTIAL" not in t for t in texts)
intro = next(c for c in result.chunks if "Short intro" in c.text)
assert intro.heading_path[-1] == "1. Introduction"
assert intro.page_start == 1
# Intro chunk must not bleed into the Terms section.
assert "terms" not in intro.text
def test_advanced_chunks_split_oversized_blocks() -> None:
result = advanced_chunks(_parse_fixture(), chunk_size=512, overlap=64)
terms_chunks = [c for c in result.chunks if c.heading_path and c.heading_path[-1] == "2. Terms"]
assert len(terms_chunks) > 1
assert all(c.page_start == 2 for c in terms_chunks)
+64
View File
@@ -0,0 +1,64 @@
from __future__ import annotations
from pydantic import BaseModel
from stirling.contracts import PageText
from stirling.docparse.extractor import (
VALUE_GROUNDED_FLOOR,
ExtractFieldsAgent,
build_output_model,
find_quote,
)
def _pages() -> list[PageText]:
return [
PageText(page_number=1, text="Invoice INV-123\nTotal due: 1,240.00 EUR"),
PageText(page_number=2, text="Payment terms\nNet 30 days from receipt."),
]
def test_find_quote_exact() -> None:
located = find_quote("Invoice INV-123", _pages())
assert located is not None
page, start, end = located
assert page == 1
assert start == 0
def test_find_quote_is_whitespace_insensitive() -> None:
located = find_quote("Total due: 1,240.00 EUR", _pages())
assert located is not None
assert located[0] == 1
def test_find_quote_is_case_insensitive_and_crosses_pages() -> None:
located = find_quote("net 30 DAYS", _pages())
assert located is not None
assert located[0] == 2
def test_find_quote_missing_returns_none() -> None:
assert find_quote("does not appear", _pages()) is None
assert find_quote(" ", _pages()) is None
def _answers(quote: str | None, confidence: float) -> BaseModel:
model = build_output_model({"type": "object", "properties": {"invoice_number": {"type": "string"}}})
return model.model_validate({"invoiceNumber": {"value": "INV-123", "quote": quote, "confidence": confidence}})
def test_ground_falls_back_to_value_when_quote_missing() -> None:
# Terse local models return the value but no quote; the value itself grounds.
pages = [PageText(page_number=1, text="Invoice INV-123 issued today.")]
fields = ExtractFieldsAgent._ground(_answers(quote=None, confidence=0.0), pages, None)
assert fields[0].citations and fields[0].citations[0].page == 1
assert fields[0].citations[0].quote == "INV-123"
assert fields[0].confidence >= VALUE_GROUNDED_FLOOR
def test_ground_penalises_when_nothing_grounds() -> None:
pages = [PageText(page_number=1, text="completely unrelated text")]
fields = ExtractFieldsAgent._ground(_answers(quote=None, confidence=0.9), pages, None)
assert not fields[0].citations
assert fields[0].confidence < 0.9
+247
View File
@@ -0,0 +1,247 @@
from __future__ import annotations
from collections.abc import Iterator
from datetime import datetime
from typing import Any
import pytest
from fastapi.testclient import TestClient
from stirling.api import app
from stirling.api.dependencies import get_document_service
from stirling.documents import DocumentService, SqliteVecStore
from stirling.models import FileId, OwnerId, PrincipalId
HEADERS = {"X-User-Id": "test-user"}
class StubDocumentService:
"""Records ingest_prepared calls so the route's passthrough can be asserted."""
def __init__(self) -> None:
self.calls: list[dict[str, Any]] = []
async def ingest_prepared(
self,
collection: FileId,
chunks: list[tuple[str, dict[str, str]]],
source: str,
owner_id: OwnerId,
read_principals: list[PrincipalId],
expires_at: datetime | None,
) -> int:
self.calls.append(
{
"collection": collection,
"chunks": chunks,
"source": source,
"owner_id": owner_id,
"read_principals": read_principals,
"expires_at": expires_at,
}
)
return len(chunks)
class StubEmbedder:
"""Deterministic embeddings: no network, no provider needed."""
def __init__(self, dim: int = 8) -> None:
self._dim = dim
async def embed_query(self, text: str) -> list[float]:
h = hash(text) % 1000
return [(h + i) / 1000.0 for i in range(self._dim)]
async def embed_documents(self, texts: list[str]) -> list[list[float]]:
return [await self.embed_query(t) for t in texts]
@pytest.fixture
def stub_service() -> StubDocumentService:
return StubDocumentService()
@pytest.fixture
def client(stub_service: StubDocumentService) -> Iterator[TestClient]:
app.dependency_overrides[get_document_service] = lambda: stub_service
try:
yield TestClient(app)
finally:
app.dependency_overrides.pop(get_document_service, None)
def test_rag_ingest_basic_tier_indexes_chunks_with_metadata(
client: TestClient, stub_service: StubDocumentService
) -> None:
response = client.post(
"/api/v1/docparse/rag-ingest",
json={
"fileName": "report.pdf",
"documentId": "doc-1",
"pages": [{"pageNumber": 1, "text": "para one"}, {"pageNumber": 2, "text": "para two"}],
"chunkSize": 64,
},
headers=HEADERS,
)
assert response.status_code == 200
body = response.json()
assert body["mode"] == "basic"
assert body["documentId"] == "doc-1"
assert body["chunksIndexed"] == 2
assert body["pages"] == 2
assert body["markdown"] is None
assert body["chunks"] is None
call = stub_service.calls[0]
assert call["collection"] == "doc-1"
assert call["source"] == "docparse"
text, metadata = call["chunks"][0]
assert text == "para one"
assert metadata["content_type"] == "docparse_chunk"
assert metadata["page_start"] == "1"
assert metadata["page_end"] == "1"
def test_rag_ingest_defaults_owner_and_readers_to_caller(client: TestClient, stub_service: StubDocumentService) -> None:
client.post(
"/api/v1/docparse/rag-ingest",
json={"fileName": "a.pdf", "documentId": "d", "pages": [{"pageNumber": 1, "text": "t"}]},
headers=HEADERS,
)
call = stub_service.calls[0]
assert call["owner_id"] == "test-user"
assert call["read_principals"] == ["test-user"]
assert call["expires_at"] is None
def test_rag_ingest_passes_explicit_owner_acl_and_expiry_through(
client: TestClient, stub_service: StubDocumentService
) -> None:
client.post(
"/api/v1/docparse/rag-ingest",
json={
"fileName": "a.pdf",
"documentId": "d",
"source": "handbook.pdf",
"ownerId": "org:acme",
"readPrincipals": ["group:eng", "user:bob"],
"expiresAt": "2030-01-01T00:00:00Z",
"pages": [{"pageNumber": 1, "text": "t"}],
},
headers=HEADERS,
)
call = stub_service.calls[0]
assert call["owner_id"] == "org:acme"
assert call["read_principals"] == ["group:eng", "user:bob"]
assert call["source"] == "handbook.pdf"
assert call["expires_at"] is not None
def test_rag_ingest_export_only_skips_the_store(client: TestClient, stub_service: StubDocumentService) -> None:
response = client.post(
"/api/v1/docparse/rag-ingest",
json={
"fileName": "a.pdf",
"documentId": "d",
"pages": [{"pageNumber": 1, "text": "alpha"}, {"pageNumber": 2, "text": "beta"}],
"index": False,
"includeMarkdown": True,
"includeChunks": True,
},
headers=HEADERS,
)
assert response.status_code == 200
body = response.json()
assert stub_service.calls == []
assert body["chunksIndexed"] == 0
assert body["markdown"] == "alpha\n\nbeta"
assert [c["text"] for c in body["chunks"]] == ["alpha", "beta"]
assert body["chunks"][0]["pageStart"] == 1
def test_rag_ingest_index_off_with_no_export_is_422(client: TestClient) -> None:
response = client.post(
"/api/v1/docparse/rag-ingest",
json={
"fileName": "a.pdf",
"documentId": "d",
"pages": [{"pageNumber": 1, "text": "t"}],
"index": False,
},
headers=HEADERS,
)
assert response.status_code == 422
def test_rag_ingest_advanced_mode_needs_the_addon(client: TestClient) -> None:
response = client.post(
"/api/v1/docparse/rag-ingest",
json={
"fileName": "x.pdf",
"documentId": "d",
"mode": "advanced",
"pages": [{"pageNumber": 1, "text": "t"}],
},
headers=HEADERS,
)
assert response.status_code == 501
assert response.json()["detail"]["addonRequired"] == "docparse"
def test_rag_ingest_without_pages_is_422(client: TestClient) -> None:
response = client.post(
"/api/v1/docparse/rag-ingest", json={"fileName": "x.pdf", "documentId": "d"}, headers=HEADERS
)
assert response.status_code == 422
def test_rag_ingest_rejects_missing_user_header(client: TestClient) -> None:
response = client.post(
"/api/v1/docparse/rag-ingest",
json={"fileName": "x.pdf", "documentId": "d", "pages": [{"pageNumber": 1, "text": "t"}]},
)
assert response.status_code == 401
def test_rag_ingest_rejects_empty_document_id(client: TestClient) -> None:
response = client.post(
"/api/v1/docparse/rag-ingest",
json={"fileName": "x.pdf", "documentId": "", "pages": [{"pageNumber": 1, "text": "t"}]},
headers=HEADERS,
)
assert response.status_code == 422
def test_capabilities_reports_probe_shape() -> None:
# Not asserting a value: a dev venv may genuinely have docling installed.
client = TestClient(app)
response = client.get("/api/v1/docparse/capabilities", headers=HEADERS)
assert response.status_code == 200
assert isinstance(response.json()["advancedInstalled"], bool)
# ── real service: replacement semantics ─────────────────────────────────
@pytest.mark.anyio
async def test_rag_ingest_reingest_replaces_instead_of_duplicating() -> None:
service = DocumentService(embedder=StubEmbedder(), store=SqliteVecStore.ephemeral(), default_top_k=3) # type: ignore[arg-type]
app.dependency_overrides[get_document_service] = lambda: service
try:
client = TestClient(app)
payload = {
"fileName": "report.pdf",
"documentId": "doc-replace",
"pages": [{"pageNumber": 1, "text": "first version"}],
}
assert client.post("/api/v1/docparse/rag-ingest", json=payload, headers=HEADERS).status_code == 200
payload["pages"] = [{"pageNumber": 1, "text": "second version"}]
assert client.post("/api/v1/docparse/rag-ingest", json=payload, headers=HEADERS).status_code == 200
finally:
app.dependency_overrides.pop(get_document_service, None)
results = await service.search("version", principals=[PrincipalId("test-user")], collection=FileId("doc-replace"))
assert [r.document.text for r in results] == ["second version"]
assert results[0].document.metadata["content_type"] == "docparse_chunk"
assert results[0].document.metadata["source"] == "docparse"
@@ -0,0 +1,79 @@
from __future__ import annotations
from typing import Any
import pytest
from stirling.docparse.extractor import MAX_SCHEMA_DEPTH, SchemaError, build_output_model, flatten_answers
def _schema(properties: dict[str, Any]) -> dict[str, Any]:
return {"type": "object", "properties": properties}
def test_builds_scalar_fields() -> None:
model = build_output_model(
_schema(
{
"invoice_number": {"type": "string", "description": "The invoice id"},
"total": {"type": "number"},
"line_count": {"type": "integer"},
"paid": {"type": "boolean"},
}
)
)
instance = model.model_validate(
{
"invoiceNumber": {"value": "INV-1", "quote": "Invoice INV-1", "confidence": 0.9},
"total": {"value": 12.5, "quote": None, "confidence": 0.8},
"lineCount": {"value": 3, "quote": None, "confidence": 0.7},
"paid": {"value": True, "quote": None, "confidence": 0.6},
}
)
leaves = dict((name, value) for name, value, _quote, _conf in flatten_answers(instance))
assert leaves == {"invoice_number": "INV-1", "total": 12.5, "line_count": 3, "paid": True}
def test_nested_objects_flatten_to_dotted_names() -> None:
model = build_output_model(_schema({"vendor": {"type": "object", "properties": {"name": {"type": "string"}}}}))
instance = model.model_validate({"vendor": {"name": {"value": "ACME", "quote": None, "confidence": 0.5}}})
names = [name for name, _v, _q, _c in flatten_answers(instance)]
assert names == ["vendor.name"]
def test_arrays_of_scalars() -> None:
model = build_output_model(_schema({"tags": {"type": "array", "items": {"type": "string"}}}))
instance = model.model_validate({"tags": {"value": ["a", "b"], "quote": None, "confidence": 1.0}})
leaves = flatten_answers(instance)
assert leaves[0][1] == ["a", "b"]
def test_enum_lands_in_description_not_type() -> None:
model = build_output_model(_schema({"currency": {"type": "string", "enum": ["EUR", "USD"]}}))
answer_model = model.model_fields["currency"].annotation
description = answer_model.model_fields["value"].description # type: ignore[union-attr]
assert "EUR" in description and "USD" in description
@pytest.mark.parametrize(
"schema",
[
{"type": "object", "properties": {}},
{"type": "object"},
{"type": "object", "properties": {"bad-name": {"type": "string"}}},
{"type": "object", "properties": {"x": {"type": "date"}}},
{"type": "object", "properties": {"x": {"type": "array"}}},
{"type": "object", "properties": {"x": {"type": "array", "items": {"type": "object"}}}},
],
)
def test_rejects_unsupported_schemas(schema: dict[str, Any]) -> None:
with pytest.raises(SchemaError):
build_output_model(schema)
def test_rejects_over_deep_nesting() -> None:
schema: dict = {"type": "string"}
for _ in range(MAX_SCHEMA_DEPTH + 2):
schema = {"type": "object", "properties": {"child": schema}}
with pytest.raises(SchemaError):
build_output_model(schema)
@@ -0,0 +1,129 @@
from __future__ import annotations
import base64
from collections.abc import Iterator
import pytest
from fastapi.testclient import TestClient
from stirling.api import app
from stirling.api.dependencies import get_suggest_schema_agent
from stirling.api.routes import docparse as docparse_routes
from stirling.contracts.docparse import (
DocparseCapabilities,
DocparseTier,
SuggestedField,
SuggestedFieldType,
SuggestSchemaRequest,
SuggestSchemaResponse,
)
from stirling.contracts.documents import PageText
from stirling.docparse.suggest_schema import _SuggestedField, _SuggestOutput, to_snake_case, validate_fields
def _force_addon(monkeypatch: pytest.MonkeyPatch, installed: bool) -> None:
caps = DocparseCapabilities(advanced_installed=installed, models_available=installed)
monkeypatch.setattr(docparse_routes, "probe_capabilities", lambda _home, refresh=False: caps)
class StubSuggestAgent:
def __init__(self) -> None:
self.seen_pages: list[PageText] | None = None
async def suggest(
self, _request: SuggestSchemaRequest, pages: list[PageText], tier: DocparseTier
) -> SuggestSchemaResponse:
self.seen_pages = pages
return SuggestSchemaResponse(
mode=tier,
fields=[SuggestedField(name="invoice_number", type=SuggestedFieldType.STRING, description="The number.")],
)
@pytest.fixture
def stub_agent() -> StubSuggestAgent:
return StubSuggestAgent()
@pytest.fixture
def client(stub_agent: StubSuggestAgent) -> Iterator[TestClient]:
app.dependency_overrides[get_suggest_schema_agent] = lambda: stub_agent
try:
yield TestClient(app)
finally:
app.dependency_overrides.pop(get_suggest_schema_agent, None)
# ── validation (model proposes, code decides) ───────────────────────────
def _output(*fields: tuple[str, str]) -> _SuggestOutput:
return _SuggestOutput(fields=[_SuggestedField(name=n, type=t, description="d") for n, t in fields])
def test_names_are_coerced_to_snake_case() -> None:
fields = validate_fields(_output(("Invoice Number", "string"), ("dueDate", "string")), max_fields=8)
assert [f.name for f in fields] == ["invoice_number", "due_date"]
def test_invalid_types_are_dropped() -> None:
fields = validate_fields(_output(("total", "money"), ("count", "integer"), ("date", "datetime")), max_fields=8)
assert [f.name for f in fields] == ["count"]
assert fields[0].type is SuggestedFieldType.INTEGER
def test_duplicate_names_collapse_to_first() -> None:
fields = validate_fields(_output(("total", "number"), ("Total", "string"), ("total", "integer")), max_fields=8)
assert len(fields) == 1
assert fields[0].type is SuggestedFieldType.NUMBER
def test_result_is_capped_at_max_fields() -> None:
fields = validate_fields(_output(*[(f"field_{i}", "string") for i in range(10)]), max_fields=3)
assert [f.name for f in fields] == ["field_0", "field_1", "field_2"]
def test_names_that_cannot_become_identifiers_are_dropped() -> None:
fields = validate_fields(_output(("123abc", "string"), ("!!!", "string"), ("ok_name", "string")), max_fields=8)
assert [f.name for f in fields] == ["ok_name"]
def test_snake_case_coercion_examples() -> None:
assert to_snake_case("Invoice No.") == "invoice_no"
assert to_snake_case("invoiceNumber") == "invoice_number"
assert to_snake_case("TotalUSD") == "total_usd"
assert to_snake_case(" already_snake ") == "already_snake"
# ── route ───────────────────────────────────────────────────────────────
def test_suggest_schema_basic_tier_from_pages(client: TestClient) -> None:
response = client.post(
"/api/v1/docparse/suggest-schema",
json={"fileName": "invoice.pdf", "pages": [{"pageNumber": 1, "text": "Invoice INV-1"}]},
)
assert response.status_code == 200
body = response.json()
assert body["mode"] == "basic"
assert body["fields"][0] == {"name": "invoice_number", "type": "string", "description": "The number."}
def test_suggest_schema_without_pages_or_content_is_422(client: TestClient) -> None:
response = client.post("/api/v1/docparse/suggest-schema", json={"fileName": "x.pdf"})
assert response.status_code == 422
def test_suggest_schema_content_without_addon_is_422(client: TestClient, monkeypatch: pytest.MonkeyPatch) -> None:
_force_addon(monkeypatch, installed=False)
payload = {"fileName": "scan.pdf", "contentBase64": base64.b64encode(b"%PDF-1.4").decode()}
response = client.post("/api/v1/docparse/suggest-schema", json=payload)
assert response.status_code == 422
def test_suggest_schema_rejects_max_fields_above_cap(client: TestClient) -> None:
response = client.post(
"/api/v1/docparse/suggest-schema",
json={"fileName": "x.pdf", "pages": [{"pageNumber": 1, "text": "t"}], "maxFields": 21},
)
assert response.status_code == 422
+1125 -4
View File
File diff suppressed because it is too large Load Diff
@@ -3573,6 +3573,13 @@ tags = "automation,folder,scanning,watch folder,hot folder,automatic processing,
[devSsoGuide]
tags = "SSO,single sign-on,authentication,SAML,OAuth,OIDC,login,enterprise,identity provider,IdP"
[docparse.intro]
advancedOff = "Advanced parsing: off"
advancedOn = "Advanced parsing: on"
aiLayoutModel = "AI layout model"
basicFallback = "Scanned documents fall back to basic text extraction - install the DocParse addon for layout AI"
usesAi = "Uses AI"
[dropdownList]
searchPlaceholder = "Search..."
@@ -3700,6 +3707,56 @@ _value = "Error"
dismissAllErrors = "Dismiss All Errors"
generic = "An error occurred"
[extractFields]
intro = "Describe the fields you need and AI reads the document and returns each value with a confidence score and a citation you can verify."
submit = "Extract fields"
[extractFields.error]
failed = "Failed to extract fields"
[extractFields.fields]
add = "Add field"
description = "Description"
descriptionPlaceholder = "What to look for"
label = "Fields to extract"
name = "Name"
namePlaceholder = "invoice_number"
remove = "Remove field"
type = "Type"
[extractFields.instructions]
label = "Instructions"
placeholder = "e.g. Amounts are in EUR unless stated otherwise"
[extractFields.mode]
advanced = "Advanced"
auto = "Auto"
basic = "Basic"
label = "Mode"
[extractFields.presets]
contract = "Contract"
custom = "Custom"
invoice = "Invoice"
label = "Preset template"
purchaseOrder = "Purchase order"
receipt = "Receipt"
[extractFields.results]
title = "Extraction report"
[extractFields.resultsPanel]
title = "Extracted fields"
[extractFields.settings]
title = "Extraction schema"
[extractFields.suggest]
button = "Suggest fields (AI)"
failed = "Could not suggest fields"
failedBody = "The document could not be analyzed. Add fields manually or try again."
needsFile = "Select a file first to suggest fields"
[extractImages]
allowDuplicates = "Save duplicate images"
selectText = "Select image format to convert extracted images to"
@@ -4567,6 +4624,11 @@ desc = "Add or edit bookmarks and table of contents in PDF documents"
tags = "bookmarks,contents,edit,table of contents,TOC,outline,navigation,chapters,sections,add bookmarks,edit bookmarks,PDF outline"
title = "Edit Table of Contents"
[home.extractFields]
desc = "Pull typed fields out of a document with confidence scores and citations"
tags = "extract,fields,schema,structured data,invoice,form data,key value,confidence,citations,capture,parse"
title = "Extract Fields"
[home.extractImages]
desc = "Extracts all images from a PDF and saves them to zip"
tags = "pull,save,export,extract images,get images,save images,export images,extract photos,extract pictures,pull images,download images,rip images,extract graphics,save photos"
@@ -7764,6 +7826,23 @@ unsavedTitle = "Unsaved changes"
uploadUnsupported = "Uploaded files aren't supported in pipelines yet, so these steps can't be saved: {{tools}}."
usesDefaults = "Runs with default settings"
[portal.pipelines.builder.docIntelligence]
chunkSize = "Chunk size (characters)"
exportChunks = "Export chunks (JSONL)"
exportChunksHint = "Adds a .chunks.jsonl file to the step output, ready for external embedding or indexing."
exportMarkdown = "Export markdown"
exportMarkdownHint = "Adds a .md rendering of the parsed document to the step output."
index = "Index into the knowledge base"
indexHint = "Embed and store the chunks in the built-in knowledge base. Turn off for export-only ingestion."
mode = "Parsing mode"
modeAdvanced = "Advanced"
modeAuto = "Auto"
modeBasic = "Basic"
overlap = "Overlap (characters)"
ragIngest = "Ingest into knowledge base"
ragIngestHint = "Parses, chunks, embeds, and indexes each document in one step. Export toggles add corpus files (markdown, chunks JSONL) to the step output for delivery to external systems."
section = "Document intelligence"
[portal.pipelines.composer]
addTool = "Add tool"
cancel = "Cancel"
@@ -7860,8 +7939,12 @@ label = "Classification"
desc = "Enforce HIPAA, GDPR, SOC 2, or FedRAMP requirements on every document."
label = "Compliance"
[portal.policies.categories.docIntelligence]
desc = "Extract the structured fields you describe from every document, with confidence scores and citations."
label = "Document intelligence"
[portal.policies.categories.ingestion]
desc = "Classify documents, extract structured data, enforce naming conventions, and normalize pages."
desc = "Normalize incoming documents - OCR scans, flatten forms - and index them into the searchable knowledge base, or export them as a clean corpus for your own systems."
label = "Ingestion"
[portal.policies.categories.retention]
@@ -7900,18 +7983,25 @@ onViolation = "When non-compliant"
1 = "Enforce action"
2 = "Audit trail"
[portal.policies.config.ingestion]
summary = "Classifies documents, extracts structured data, enforces naming, and normalizes pages."
[portal.policies.config.docIntelligence]
summary = "Extracts the fields you describe from every document, with confidence and citations."
[portal.policies.config.ingestion.fields]
belowThreshold = "Below threshold"
minConfidence = "Min confidence"
[portal.policies.config.docIntelligence.rules]
0 = "Extract the fields you describe, with confidence and citations"
[portal.policies.config.extractFields.fields]
instructions = "Extraction guidance (optional)"
instructionsHelp = "Plain-language hints for tricky fields, e.g. \"the invoice number is top right\"."
schema = "Fields to extract (JSON Schema)"
schemaHelp = "A JSON Schema object describing the fields. Each extracted value carries a confidence score and a citation."
[portal.policies.config.ingestion]
summary = "Normalizes incoming documents and indexes them into the searchable knowledge base, with optional markdown/JSONL export."
[portal.policies.config.ingestion.rules]
0 = "Classify"
1 = "Extract"
2 = "Name"
3 = "Normalize"
0 = "OCR scans and flatten forms so every page is readable"
1 = "Parse, chunk, embed, and index into the knowledge base"
2 = "Optionally export markdown and chunks JSONL for external systems"
[portal.policies.config.purview]
label = "Apply a Microsoft Purview sensitivity label"
@@ -7936,6 +8026,25 @@ label = "Read a Microsoft Purview label"
connection = "Purview tenant"
connectionHelp = "Reads the label the document already carries, so later steps can act on it."
[portal.policies.config.ragIngest.fields]
chunkSize = "Chunk size (characters)"
chunkSizeHelp = "How much text each searchable passage holds. Larger chunks carry more context; smaller ones match more precisely."
exportChunks = "Export chunks (JSONL)"
exportChunksHelp = "Adds a .chunks.jsonl file to the step output - one chunk per line with page span - ready for external embedding or indexing."
exportMarkdown = "Export markdown"
exportMarkdownHelp = "Adds a .md rendering of the parsed document to the step output, for delivery to external systems."
index = "Index into the knowledge base"
indexHelp = "Embed and store the chunks in the built-in knowledge base. Turn off for export-only ingestion."
overlap = "Chunk overlap (characters)"
overlapHelp = "How much neighbouring chunks share, so answers spanning a boundary aren't cut in half."
[portal.policies.config.ragIngest.fields.mode]
advanced = "Advanced (layout parsing addon)"
auto = "Auto (best available)"
basic = "Basic (text layer)"
help = "Auto uses the best parser available. The advanced layout tier requires the engine's docparse addon."
label = "Parse tier"
[portal.policies.config.retention]
summary = "Enforces how long documents are kept, when to archive, and when to delete."
@@ -8005,8 +8114,10 @@ addWatermark = "Watermark"
autoRedact = "Redact PII"
classifyAndLabel = "Classify"
compressPdf = "Compress"
extractFields = "Extract fields"
flatten = "Flatten"
ocrPdf = "OCR"
ragIngest = "Ingest into knowledge base"
sanitizePdf = "Remove JavaScript"
[portal.policies.offline]
@@ -8238,6 +8349,10 @@ label = "Classify the document"
desc = "Compresses the document to a smaller file size."
label = "Reduce file size"
[portal.policies.wizard.capability.extractFields]
desc = "Pulls the values you describe - like invoice numbers or dates - out of every document, each with a confidence score and a citation back to the page."
label = "Extract structured fields"
[portal.policies.wizard.capability.flatten]
desc = "Merges form fields and annotations into the page so they can't be edited."
label = "Flatten the document"
@@ -8246,6 +8361,10 @@ label = "Flatten the document"
desc = "Runs OCR so scanned pages become selectable, searchable text."
label = "Make text searchable"
[portal.policies.wizard.capability.ragIngest]
desc = "Parses, chunks, and embeds the document so it becomes searchable knowledge, or exports the parsed content (markdown, chunks JSONL) for your own systems."
label = "Index into the knowledge base"
[portal.policies.wizard.capability.redact]
desc = "Finds and blacks out sensitive details — like Social Security and card numbers — so they can't be read."
label = "Redact sensitive information"
@@ -10714,6 +10833,7 @@ standardTools = "Standard Tools"
advancedFormatting = "Advanced Formatting"
automation = "Automation"
developerTools = "Developer Tools"
documentIntelligence = "Document intelligence"
documentReview = "Document Review"
documentSecurity = "Document Security"
extraction = "Extraction"
@@ -22,6 +22,7 @@ import ViewAgendaRoundedIcon from "@mui/icons-material/ViewAgendaRounded";
import FileDownloadRoundedIcon from "@mui/icons-material/FileDownloadRounded";
import DeleteSweepRoundedIcon from "@mui/icons-material/DeleteSweepRounded";
import SmartToyRoundedIcon from "@mui/icons-material/SmartToyRounded";
import AutoAwesomeRoundedIcon from "@mui/icons-material/AutoAwesomeRounded";
import BuildRoundedIcon from "@mui/icons-material/BuildRounded";
import TuneRoundedIcon from "@mui/icons-material/TuneRounded";
import CodeRoundedIcon from "@mui/icons-material/CodeRounded";
@@ -34,6 +35,7 @@ export enum SubcategoryId {
VERIFICATION = "verification",
DOCUMENT_REVIEW = "documentReview",
PAGE_FORMATTING = "pageFormatting",
DOCUMENT_INTELLIGENCE = "documentIntelligence",
EXTRACTION = "extraction",
REMOVAL = "removal",
AUTOMATION = "automation",
@@ -95,6 +97,7 @@ export const SUBCATEGORY_ORDER: SubcategoryId[] = [
SubcategoryId.VERIFICATION,
SubcategoryId.DOCUMENT_REVIEW,
SubcategoryId.PAGE_FORMATTING,
SubcategoryId.DOCUMENT_INTELLIGENCE,
SubcategoryId.EXTRACTION,
SubcategoryId.REMOVAL,
SubcategoryId.AUTOMATION,
@@ -109,6 +112,7 @@ export const SUBCATEGORY_COLOR_MAP: Record<SubcategoryId, string> = {
[SubcategoryId.VERIFICATION]: "var(--category-color-verification)", // Orange
[SubcategoryId.DOCUMENT_REVIEW]: "var(--category-color-general)", // Blue
[SubcategoryId.PAGE_FORMATTING]: "var(--category-color-formatting)", // Purple
[SubcategoryId.DOCUMENT_INTELLIGENCE]: "var(--category-color-automation)", // Pink
[SubcategoryId.EXTRACTION]: "var(--category-color-extraction)", // Cyan
[SubcategoryId.REMOVAL]: "var(--category-color-removal)", // Red
[SubcategoryId.AUTOMATION]: "var(--category-color-automation)", // Pink
@@ -131,6 +135,8 @@ export const getSubcategoryIcon = (
return React.createElement(RateReviewRoundedIcon);
case SubcategoryId.PAGE_FORMATTING:
return React.createElement(ViewAgendaRoundedIcon);
case SubcategoryId.DOCUMENT_INTELLIGENCE:
return React.createElement(AutoAwesomeRoundedIcon);
case SubcategoryId.EXTRACTION:
return React.createElement(FileDownloadRoundedIcon);
case SubcategoryId.REMOVAL:
@@ -0,0 +1,10 @@
import { useAppConfig } from "@app/contexts/AppConfigContext";
/**
* Whether the DocParse layer is enabled, per the backend's app-config.
* Gates the DocParse tools' visibility; flavors may shadow this hook.
*/
export function useDocparseEnabled(): boolean {
const { config } = useAppConfig();
return Boolean(config?.docparseEnabled);
}
@@ -62,6 +62,7 @@ export interface AppConfig {
timestampCustomTsaUrls?: string[];
timestampTsaPresets?: { label: string; url: string }[];
aiEngineEnabled?: boolean;
docparseEnabled?: boolean;
}
export type AppConfigBootstrapMode = "blocking" | "non-blocking";
+28 -20
View File
@@ -138,7 +138,13 @@ export interface CatalogueEntry {
* {@link ToolEndpoint}s, plus the AI classify endpoint, which isn't part of the generated union.
*/
export const ENDPOINT_LABELS: Partial<
Record<ToolEndpoint | "/api/v1/ai/tools/classify-and-label", string>
Record<
| ToolEndpoint
| "/api/v1/ai/tools/classify-and-label"
| "/api/v1/docparse/rag-ingest"
| "/api/v1/docparse/extract-fields",
string
>
> = {
"/api/v1/security/auto-redact": "portal.policies.endpoints.autoRedact",
"/api/v1/security/sanitize-pdf": "portal.policies.endpoints.sanitizePdf",
@@ -148,6 +154,8 @@ export const ENDPOINT_LABELS: Partial<
"/api/v1/misc/compress-pdf": "portal.policies.endpoints.compressPdf",
"/api/v1/ai/tools/classify-and-label":
"portal.policies.endpoints.classifyAndLabel",
"/api/v1/docparse/rag-ingest": "portal.policies.endpoints.ragIngest",
"/api/v1/docparse/extract-fields": "portal.policies.endpoints.extractFields",
};
export function humanizeEndpoint(
@@ -174,13 +182,18 @@ const DEFAULT_PII_PATTERNS: string[] = [
/** `label`/`desc` values are i18n keys — render with t(). */
export const POLICY_CATEGORIES: PolicyCategory[] = [
// First on purpose: document intelligence is the processor's flagship flow.
{
id: "docIntelligence",
label: "portal.policies.categories.docIntelligence.label",
tone: "blue",
desc: "portal.policies.categories.docIntelligence.desc",
},
{
id: "ingestion",
label: "portal.policies.categories.ingestion.label",
tone: "blue",
desc: "portal.policies.categories.ingestion.desc",
providesClassification: true,
comingSoon: true,
},
{
id: "security",
@@ -224,32 +237,27 @@ export const POLICY_CATEGORIES: PolicyCategory[] = [
* stay as stable values (translating them would corrupt saved configs).
*/
export const POLICY_CONFIG: Record<string, PolicyConfigDef> = {
docIntelligence: {
summary: "portal.policies.config.docIntelligence.summary",
rules: ["portal.policies.config.docIntelligence.rules.0"],
scopeLabel: "portal.policies.config.scopeAll",
defaultOperations: [policyStep("extractFields")],
fields: [],
},
ingestion: {
summary: "portal.policies.config.ingestion.summary",
rules: [
"portal.policies.config.ingestion.rules.0",
"portal.policies.config.ingestion.rules.1",
"portal.policies.config.ingestion.rules.2",
"portal.policies.config.ingestion.rules.3",
],
scopeLabel: "portal.policies.config.scopeAll",
defaultOperations: [policyStep("ocr"), policyStep("flatten")],
fields: [
{
label: "portal.policies.config.ingestion.fields.minConfidence",
key: "minConfidence",
type: "select",
value: "p80",
options: ["p60", "p70", "p80", "p90", "p95"],
},
{
label: "portal.policies.config.ingestion.fields.belowThreshold",
key: "belowThreshold",
type: "select",
value: "flagForReview",
options: ["flagForReview", "routeToBucket", "hold"],
},
defaultOperations: [
policyStep("ocr"),
policyStep("flatten"),
policyStep("ragIngest"),
],
fields: [],
},
security: {
summary: "portal.policies.config.security.summary",
@@ -48,6 +48,16 @@ export function EditorIcon(props: IconProps) {
);
}
export function KnowledgeIcon(props: IconProps) {
return (
<Svg {...props}>
<ellipse cx="12" cy="5" rx="8" ry="3" />
<path d="M4 5v6c0 1.66 3.58 3 8 3s8-1.34 8-3V5" />
<path d="M4 11v6c0 1.66 3.58 3 8 3s8-1.34 8-3v-6" />
</Svg>
);
}
export function SourcesIcon(props: IconProps) {
return (
<Svg {...props}>
@@ -49,4 +49,30 @@ describe("PipelineStepSettings", () => {
).not.toThrow();
expect(screen.getByText("field")).toBeInTheDocument();
});
it("renders the rag-ingest form for the knowledge indexing operation", () => {
const ragStep = {
support: "unknown",
toolId: null,
operation: "/api/v1/docparse/rag-ingest",
params: { chunkSize: 512, overlap: 64, mode: "auto" },
} as unknown as WorkingToolStep;
render(
<MantineProvider>
<PipelineStepSettings
step={ragStep}
registry={{}}
onChange={() => {}}
/>
</MantineProvider>,
);
expect(
screen.getByText("portal.pipelines.builder.docIntelligence.chunkSize"),
).toBeInTheDocument();
expect(
screen.getByText(
"portal.pipelines.builder.docIntelligence.ragIngestHint",
),
).toBeInTheDocument();
});
});
@@ -10,6 +10,11 @@ import { type WorkingToolStep } from "@app/hooks/tools/shared/toolAutomation";
import { PolicyExternalApiConfig } from "@portal/components/policies/PolicyExternalApiConfig";
import { isIntegrationStep } from "@portal/components/pipelines/integrationStep";
import type { ExternalApiStepParams } from "@portal/components/policies/stepOperations";
import { RAG_INGEST_OPERATION } from "@portal/components/pipelines/docIntelligenceSteps";
import {
RagIngestStepConfig,
type RagIngestParams,
} from "@portal/components/pipelines/RagIngestStepConfig";
interface PipelineStepSettingsProps {
step: WorkingToolStep;
@@ -31,6 +36,16 @@ export function PipelineStepSettings({
// above useTranslation would change the hook count between renders and crash.
const { t } = useTranslation();
// Document-intelligence steps carry their own small forms; they have no registry entry.
if (step.operation === RAG_INGEST_OPERATION) {
return (
<RagIngestStepConfig
parameters={step.params as unknown as RagIngestParams}
onChange={(params) => onChange(params as never)}
/>
);
}
// An integration step is configured by the operations catalogue, not by a tool's settings UI:
// it has no registry entry to look one up from.
if (isIntegrationStep(step)) {
@@ -0,0 +1,112 @@
import { useTranslation } from "react-i18next";
import { FormField, Input, Select, ToggleSwitch } from "@app/ui";
/** Parameters for the "Ingest into knowledge base" pipeline step. */
export interface RagIngestParams {
chunkSize?: number;
overlap?: number;
mode?: string;
index?: boolean;
exportMarkdown?: boolean;
exportChunksJsonl?: boolean;
}
interface RagIngestStepConfigProps {
parameters: RagIngestParams;
onChange: (parameters: RagIngestParams) => void;
}
/** Settings for the rag-ingest step: destination toggles, chunking knobs, parse tier. */
export function RagIngestStepConfig({
parameters,
onChange,
}: RagIngestStepConfigProps) {
const { t } = useTranslation();
const setNumber = (key: "chunkSize" | "overlap", raw: string) => {
const value = Number(raw);
onChange({
...parameters,
[key]: Number.isFinite(value) && raw !== "" ? value : undefined,
});
};
return (
<div className="portal-policies__capability-config">
<ToggleSwitch
size="sm"
checked={parameters.index ?? true}
onChange={(checked) => onChange({ ...parameters, index: checked })}
label={t("portal.pipelines.builder.docIntelligence.index")}
description={t("portal.pipelines.builder.docIntelligence.indexHint")}
/>
<ToggleSwitch
size="sm"
checked={parameters.exportMarkdown ?? false}
onChange={(checked) =>
onChange({ ...parameters, exportMarkdown: checked })
}
label={t("portal.pipelines.builder.docIntelligence.exportMarkdown")}
description={t(
"portal.pipelines.builder.docIntelligence.exportMarkdownHint",
)}
/>
<ToggleSwitch
size="sm"
checked={parameters.exportChunksJsonl ?? false}
onChange={(checked) =>
onChange({ ...parameters, exportChunksJsonl: checked })
}
label={t("portal.pipelines.builder.docIntelligence.exportChunks")}
description={t(
"portal.pipelines.builder.docIntelligence.exportChunksHint",
)}
/>
<FormField
label={t("portal.pipelines.builder.docIntelligence.chunkSize")}
>
<Input
type="number"
inputSize="sm"
min={64}
max={32768}
value={parameters.chunkSize ?? ""}
onChange={(e) => setNumber("chunkSize", e.target.value)}
/>
</FormField>
<FormField label={t("portal.pipelines.builder.docIntelligence.overlap")}>
<Input
type="number"
inputSize="sm"
min={0}
max={4096}
value={parameters.overlap ?? ""}
onChange={(e) => setNumber("overlap", e.target.value)}
/>
</FormField>
<FormField label={t("portal.pipelines.builder.docIntelligence.mode")}>
<Select
value={parameters.mode ?? "auto"}
onChange={(value) =>
onChange({ ...parameters, mode: value ?? "auto" })
}
options={[
{
value: "auto",
label: t("portal.pipelines.builder.docIntelligence.modeAuto"),
},
{
value: "basic",
label: t("portal.pipelines.builder.docIntelligence.modeBasic"),
},
{
value: "advanced",
label: t("portal.pipelines.builder.docIntelligence.modeAdvanced"),
},
]}
/>
</FormField>
<p className="portal-pipelines__step-hint">
{t("portal.pipelines.builder.docIntelligence.ragIngestHint")}
</p>
</div>
);
}
@@ -11,7 +11,12 @@ import {
searchOperations,
type StepOperation,
} from "@portal/components/policies/stepOperations";
import {
DOC_INTELLIGENCE_STEPS,
type DocIntelligenceStep,
} from "@portal/components/pipelines/docIntelligenceSteps";
import { BrandMark } from "@portal/components/BrandMarks";
import { KnowledgeIcon } from "@portal/components/icons";
interface ToolPickerProps {
tools: ExecutableTool[];
@@ -24,6 +29,8 @@ interface ToolPickerProps {
*/
operations?: StepOperation[];
onPickOperation?: (operation: StepOperation) => void;
/** Document-intelligence policy steps (knowledge indexing). */
onPickDocStep?: (step: DocIntelligenceStep) => void;
}
/**
@@ -36,6 +43,7 @@ export function ToolPicker({
onClose,
operations = [],
onPickOperation,
onPickDocStep,
}: ToolPickerProps) {
const { t } = useTranslation();
const [query, setQuery] = useState("");
@@ -48,6 +56,14 @@ export function ToolPicker({
[operations, onPickOperation, query, t],
);
const matchedDocSteps = useMemo(() => {
if (!onPickDocStep) return [];
const q = query.trim().toLowerCase();
return DOC_INTELLIGENCE_STEPS.filter(
(step) => !q || t(step.labelKey).toLowerCase().includes(q),
);
}, [onPickDocStep, query, t]);
const groups = useMemo(() => {
const q = query.trim().toLowerCase();
const matched = q
@@ -84,7 +100,38 @@ export function ToolPicker({
/>
</div>
<div className="portal-pipelines__picker-list">
{groups.length === 0 && matchedOperations.length === 0 ? (
{matchedDocSteps.length > 0 && onPickDocStep ? (
<div className="portal-pipelines__picker-group">
<div className="portal-pipelines__picker-group-label">
{t("portal.pipelines.builder.docIntelligence.section")}
</div>
{matchedDocSteps.map((step) => (
<Button
key={step.operation}
variant="quiet"
justify="start"
fullWidth
className="portal-pipelines__picker-item"
onClick={() => onPickDocStep(step)}
leftSection={
<span
className="portal-pipelines__picker-icon"
aria-hidden="true"
>
<KnowledgeIcon size={17} />
</span>
}
>
<span className="portal-pipelines__picker-name">
{t(step.labelKey)}
</span>
</Button>
))}
</div>
) : null}
{groups.length === 0 &&
matchedOperations.length === 0 &&
matchedDocSteps.length === 0 ? (
<p className="portal-pipelines__picker-empty">
{t("portal.pipelines.builder.noToolMatches")}
</p>
@@ -0,0 +1,45 @@
import type { WorkingToolStep } from "@app/hooks/tools/shared/toolAutomation";
import type { ErasedToolParams } from "@app/hooks/tools/shared/toolOperationTypes";
/**
* First-class document-intelligence policy steps addable from the pipeline builder.
* They ride the unmapped-step path like integration steps (toolId null), but hit
* internal endpoints.
*/
export interface DocIntelligenceStep {
operation: string;
labelKey: string;
defaultParams: Record<string, unknown>;
}
export const RAG_INGEST_OPERATION = "/api/v1/docparse/rag-ingest";
export const DOC_INTELLIGENCE_STEPS: DocIntelligenceStep[] = [
{
operation: RAG_INGEST_OPERATION,
labelKey: "portal.pipelines.builder.docIntelligence.ragIngest",
defaultParams: {
chunkSize: 512,
overlap: 64,
mode: "auto",
index: true,
exportMarkdown: false,
exportChunksJsonl: false,
},
},
];
export function newDocIntelligenceStep(
step: DocIntelligenceStep,
): WorkingToolStep {
return {
toolId: null,
operation: step.operation,
params: { ...step.defaultParams } as ErasedToolParams,
support: "unknown",
};
}
export function isDocIntelligenceOperation(operation: string): boolean {
return DOC_INTELLIGENCE_STEPS.some((step) => step.operation === operation);
}
@@ -0,0 +1,54 @@
import { useTranslation } from "react-i18next";
import { FormField, Input } from "@app/ui";
/** Configures the extract-fields step: the schema and optional guidance. */
export interface ExtractFieldsStepParams {
fieldsSchema: string;
mode: string;
instructions: string;
}
interface PolicyExtractFieldsConfigProps {
parameters: ExtractFieldsStepParams;
onChange: (parameters: ExtractFieldsStepParams) => void;
}
export function PolicyExtractFieldsConfig({
parameters,
onChange,
}: PolicyExtractFieldsConfigProps) {
const { t } = useTranslation();
return (
<div className="portal-policies__capability-config">
<FormField
label={t("portal.policies.config.extractFields.fields.schema")}
helperText={t("portal.policies.config.extractFields.fields.schemaHelp")}
>
<textarea
className="portal-sources__connection-textarea"
rows={4}
value={parameters.fieldsSchema ?? ""}
placeholder='{"type": "object", "properties": {"invoice_number": {"type": "string"}}}'
onChange={(e) =>
onChange({ ...parameters, fieldsSchema: e.target.value })
}
/>
</FormField>
<FormField
label={t("portal.policies.config.extractFields.fields.instructions")}
helperText={t(
"portal.policies.config.extractFields.fields.instructionsHelp",
)}
>
<Input
inputSize="sm"
value={parameters.instructions ?? ""}
onChange={(e) =>
onChange({ ...parameters, instructions: e.target.value })
}
/>
</FormField>
</div>
);
}
@@ -0,0 +1,116 @@
import { useTranslation } from "react-i18next";
import { FormField, Input, Select, ToggleSwitch } from "@app/ui";
/** Configures the rag-ingest step: destination toggles, chunk sizing, parse tier. */
export interface RagIngestParams {
chunkSize: string;
overlap: string;
mode: string;
index: string;
exportMarkdown: string;
exportChunksJsonl: string;
}
interface PolicyRagIngestConfigProps {
parameters: RagIngestParams;
onChange: (parameters: RagIngestParams) => void;
}
export function PolicyRagIngestConfig({
parameters,
onChange,
}: PolicyRagIngestConfigProps) {
const { t } = useTranslation();
const flag = (value: string | undefined, fallback: boolean): boolean =>
value === undefined ? fallback : value === "true";
return (
<div className="portal-policies__capability-config">
<ToggleSwitch
size="sm"
checked={flag(parameters.index, true)}
onChange={(checked) =>
onChange({ ...parameters, index: String(checked) })
}
label={t("portal.policies.config.ragIngest.fields.index")}
description={t("portal.policies.config.ragIngest.fields.indexHelp")}
/>
<ToggleSwitch
size="sm"
checked={flag(parameters.exportMarkdown, false)}
onChange={(checked) =>
onChange({ ...parameters, exportMarkdown: String(checked) })
}
label={t("portal.policies.config.ragIngest.fields.exportMarkdown")}
description={t(
"portal.policies.config.ragIngest.fields.exportMarkdownHelp",
)}
/>
<ToggleSwitch
size="sm"
checked={flag(parameters.exportChunksJsonl, false)}
onChange={(checked) =>
onChange({ ...parameters, exportChunksJsonl: String(checked) })
}
label={t("portal.policies.config.ragIngest.fields.exportChunks")}
description={t(
"portal.policies.config.ragIngest.fields.exportChunksHelp",
)}
/>
<FormField
label={t("portal.policies.config.ragIngest.fields.chunkSize")}
helperText={t("portal.policies.config.ragIngest.fields.chunkSizeHelp")}
>
<Input
type="number"
inputSize="sm"
min={64}
step={64}
value={parameters.chunkSize ?? ""}
onChange={(e) =>
onChange({ ...parameters, chunkSize: e.target.value })
}
/>
</FormField>
<FormField
label={t("portal.policies.config.ragIngest.fields.overlap")}
helperText={t("portal.policies.config.ragIngest.fields.overlapHelp")}
>
<Input
type="number"
inputSize="sm"
min={0}
step={16}
value={parameters.overlap ?? ""}
onChange={(e) => onChange({ ...parameters, overlap: e.target.value })}
/>
</FormField>
<FormField
label={t("portal.policies.config.ragIngest.fields.mode.label")}
helperText={t("portal.policies.config.ragIngest.fields.mode.help")}
>
<Select
inputSize="sm"
value={parameters.mode || "auto"}
onChange={(value) =>
onChange({ ...parameters, mode: value ?? "auto" })
}
options={[
{
value: "auto",
label: t("portal.policies.config.ragIngest.fields.mode.auto"),
},
{
value: "basic",
label: t("portal.policies.config.ragIngest.fields.mode.basic"),
},
{
value: "advanced",
label: t("portal.policies.config.ragIngest.fields.mode.advanced"),
},
]}
/>
</FormField>
</div>
);
}
@@ -40,6 +40,8 @@ import { PolicyCategoryBadge } from "@portal/components/policies/PolicyCategoryI
import { PolicyRedactConfig } from "@app/components/policies/PolicyRedactConfig";
import { PolicyWatermarkConfig } from "@app/components/policies/PolicyWatermarkConfig";
import { PolicyPurviewConfig } from "@portal/components/policies/PolicyPurviewConfig";
import { PolicyExtractFieldsConfig } from "@portal/components/policies/PolicyExtractFieldsConfig";
import { PolicyRagIngestConfig } from "@portal/components/policies/PolicyRagIngestConfig";
import { ClassificationLabelsSection } from "@portal/components/policies/ClassificationLabelsSection";
import "@portal/views/Policies.css";
@@ -97,6 +99,8 @@ const DISABLED_BY_DEFAULT = new Set<PolicyToolId>([
"purviewApplyLabel",
"purviewReadLabel",
"externalApiCall",
// Needs a fields schema before a run can succeed.
"extractFields",
]);
// Steps that cannot work without a Purview tenant connection, so they are hidden entirely until one
@@ -192,6 +196,21 @@ const CAPABILITY_META: Record<
descEn:
"Hands the document to a system you have connected, and records what it answered.",
},
extractFields: {
labelKey: "portal.policies.wizard.capability.extractFields.label",
labelEn: "Extract structured fields",
descKey: "portal.policies.wizard.capability.extractFields.desc",
descEn:
"Pulls the values you describe - like invoice numbers or dates - out of every document, each with a confidence score and a citation back to the page.",
},
ragIngest: {
labelKey: "portal.policies.wizard.capability.ragIngest.label",
labelEn: "Index into the knowledge base",
descKey: "portal.policies.wizard.capability.ragIngest.desc",
descEn:
"Parses, chunks, and embeds the document so it becomes searchable knowledge, or exports the" +
" parsed content (markdown, chunks JSONL) for your own systems.",
},
};
function seedTools(entry: CatalogueEntry): ToolState[] {
@@ -553,6 +572,22 @@ function PolicySetupWizardBody({
}
/>
)}
{tl.toolId === "extractFields" && (
<PolicyExtractFieldsConfig
parameters={tl.params}
onChange={(params) =>
setToolParams("extractFields", params)
}
/>
)}
{tl.toolId === "ragIngest" && (
<PolicyRagIngestConfig
parameters={tl.params}
onChange={(params) =>
setToolParams("ragIngest", params)
}
/>
)}
</div>
)}
</div>
@@ -58,6 +58,10 @@ import { VIEW_PATHS, toPortalPath } from "@portal/contexts/ViewContext";
import { humanizeOperation } from "@portal/components/pipelines/pipelineOperations";
import { PipelineStepSettings } from "@portal/components/pipelines/PipelineStepSettings";
import { ToolPicker } from "@portal/components/pipelines/ToolPicker";
import {
newDocIntelligenceStep,
type DocIntelligenceStep,
} from "@portal/components/pipelines/docIntelligenceSteps";
import { STEP_OPERATIONS } from "@portal/components/policies/stepOperations";
import {
integrationStepConfigured,
@@ -261,6 +265,15 @@ export function PipelineBuilder() {
setPickerOpen(false);
}
function addDocIntelligenceStep(step: DocIntelligenceStep) {
setSteps((current) => {
const next = [...current, newDocIntelligenceStep(step)];
setSelectedIndex(next.length - 1);
return next;
});
setPickerOpen(false);
}
function addStep(tool: ExecutableTool) {
setSteps((current) => {
const next = [...current, newWorkingToolStep(tool, allTools)];
@@ -852,6 +865,7 @@ export function PipelineBuilder() {
onPick={addStep}
operations={STEP_OPERATIONS}
onPickOperation={addOperationStep}
onPickDocStep={addDocIntelligenceStep}
onClose={() => setPickerOpen(false)}
/>
) : (
@@ -0,0 +1,30 @@
.card {
display: flex;
flex-direction: column;
gap: 0.5rem;
padding: 0.625rem 0.75rem;
border: 1px solid var(--c-border-subtle);
border-radius: 8px;
background: var(--c-surface-sunken);
}
.description {
margin: 0;
font-size: 0.8125rem;
line-height: 1.45;
color: var(--c-text-subtle);
}
.badges {
display: flex;
flex-wrap: wrap;
gap: 0.375rem;
}
.fallback {
margin: 0;
font-size: 0.75rem;
line-height: 1.4;
color: var(--c-text-subtle);
font-style: italic;
}
@@ -0,0 +1,62 @@
import { useTranslation } from "react-i18next";
import { Badge } from "@mantine/core";
import { useDocparseCapabilities } from "@app/hooks/useDocparseCapabilities";
import styles from "@app/components/tools/docparse/DocparseToolIntro.module.css";
interface DocparseToolIntroProps {
/** 1-2 sentence "how this works" copy for the tool, already translated. */
description: string;
/**
* Which AI badge fits the tool: "llm" tools always call the language model;
* "layout" tools use the layout AI model only when advanced parsing is on.
*/
aiBadge?: "llm" | "layout";
/** Hide the scanned-docs fallback note where it cannot apply (DOCX input). */
showFallbackNote?: boolean;
}
/** Compact "how this works" card at the top of every DocParse settings panel. */
const DocparseToolIntro = ({
description,
aiBadge,
showFallbackNote = true,
}: DocparseToolIntroProps) => {
const { t } = useTranslation();
const { capabilities } = useDocparseCapabilities();
const advanced = capabilities ? capabilities.advancedInstalled : null;
return (
<div className={styles.card}>
<p className={styles.description}>{description}</p>
<div className={styles.badges}>
{aiBadge === "llm" && (
<Badge size="sm" variant="light" color="grape">
{t("docparse.intro.usesAi", "Uses AI")}
</Badge>
)}
{aiBadge === "layout" && advanced === true && (
<Badge size="sm" variant="light" color="grape">
{t("docparse.intro.aiLayoutModel", "AI layout model")}
</Badge>
)}
{advanced !== null && (
<Badge size="sm" variant="light" color={advanced ? "teal" : "gray"}>
{advanced
? t("docparse.intro.advancedOn", "Advanced parsing: on")
: t("docparse.intro.advancedOff", "Advanced parsing: off")}
</Badge>
)}
</div>
{advanced === false && showFallbackNote && (
<p className={styles.fallback}>
{t(
"docparse.intro.basicFallback",
"Scanned documents fall back to basic text extraction - install the DocParse addon for layout AI",
)}
</p>
)}
</div>
);
};
export default DocparseToolIntro;
@@ -0,0 +1,36 @@
.schema {
display: flex;
flex-direction: column;
gap: 0.5rem;
}
/* Two-line card per field: the side panel is too narrow for three columns. */
.row {
display: grid;
grid-template-columns: minmax(0, 1fr) 5.75rem auto;
grid-template-areas:
"name type remove"
"desc desc desc";
column-gap: 0.5rem;
row-gap: 0.375rem;
align-items: center;
padding: 0.5rem;
border: 1px solid var(--c-border-subtle);
border-radius: 0.5rem;
}
.nameInput {
grid-area: name;
}
.typeSelect {
grid-area: type;
}
.removeButton {
grid-area: remove;
}
.descriptionInput {
grid-area: desc;
}
@@ -0,0 +1,238 @@
import { useState } from "react";
import { useTranslation } from "react-i18next";
import { Group, Select, Stack, Text, TextInput, Textarea } from "@mantine/core";
import { Button } from "@app/ui";
import DeleteOutlineRoundedIcon from "@mui/icons-material/DeleteOutlineRounded";
import AddRoundedIcon from "@mui/icons-material/AddRounded";
import AutoAwesomeRoundedIcon from "@mui/icons-material/AutoAwesomeRounded";
import { alert } from "@app/components/toast";
import type { ToolAutomationSettingsProps } from "@app/hooks/tools/shared/toolOperationTypes";
import type { ExtractFieldsParameters } from "@app/hooks/tools/extractFields/useExtractFieldsParameters";
import type { DocparseMode } from "@app/hooks/tools/shared/docparseTypes";
import {
FIELD_TYPES,
emptyFieldRow,
type FieldRow,
type FieldType,
} from "@app/hooks/tools/extractFields/fieldsSchema";
import {
CUSTOM_PRESET,
FIELD_PRESET_IDS,
matchPreset,
presetRows,
type FieldPresetId,
} from "@app/hooks/tools/extractFields/fieldsSchemaPresets";
import { requestSuggestedFields } from "@app/hooks/tools/extractFields/suggestSchema";
import DocparseToolIntro from "@app/components/tools/docparse/DocparseToolIntro";
import styles from "@app/components/tools/docparse/ExtractFieldsSettings.module.css";
interface ExtractFieldsSettingsProps extends ToolAutomationSettingsProps<ExtractFieldsParameters> {
/** The selected input file; enables the AI schema suggestion. */
selectedFile?: File | null;
}
/** Schema builder: rows of name/type/description plus free-form instructions. */
const ExtractFieldsSettings = ({
parameters,
onParameterChange,
disabled,
selectedFile,
}: ExtractFieldsSettingsProps) => {
const { t } = useTranslation();
const [suggesting, setSuggesting] = useState(false);
const setRow = (index: number, patch: Partial<FieldRow>) => {
const fields = parameters.fields.map((row, i) =>
i === index ? { ...row, ...patch } : row,
);
onParameterChange("fields", fields);
};
const removeRow = (index: number) => {
const fields = parameters.fields.filter((_, i) => i !== index);
onParameterChange("fields", fields.length > 0 ? fields : [emptyFieldRow()]);
};
const applyPreset = (value: string | null) => {
if (value === CUSTOM_PRESET) {
onParameterChange("fields", [emptyFieldRow()]);
} else if (value) {
onParameterChange("fields", presetRows(value as FieldPresetId));
}
};
const suggestFields = async () => {
if (!selectedFile || suggesting) return;
setSuggesting(true);
try {
const rows = await requestSuggestedFields(selectedFile);
onParameterChange("fields", rows.length > 0 ? rows : [emptyFieldRow()]);
} catch {
alert({
alertType: "error",
title: t("extractFields.suggest.failed", "Could not suggest fields"),
body: t(
"extractFields.suggest.failedBody",
"The document could not be analyzed. Add fields manually or try again.",
),
expandable: false,
});
} finally {
setSuggesting(false);
}
};
const presetLabels: Record<FieldPresetId, string> = {
invoice: t("extractFields.presets.invoice", "Invoice"),
receipt: t("extractFields.presets.receipt", "Receipt"),
contract: t("extractFields.presets.contract", "Contract"),
purchaseOrder: t("extractFields.presets.purchaseOrder", "Purchase order"),
};
return (
<Stack gap="sm">
<DocparseToolIntro
description={t(
"extractFields.intro",
"Describe the fields you need and AI reads the document and returns each value with a confidence score and a citation you can verify.",
)}
aiBadge="llm"
/>
<Select
label={t("extractFields.presets.label", "Preset template")}
value={matchPreset(parameters.fields)}
onChange={applyPreset}
data={[
...FIELD_PRESET_IDS.map((preset) => ({
value: preset,
label: presetLabels[preset],
})),
{
value: CUSTOM_PRESET,
label: t("extractFields.presets.custom", "Custom"),
},
]}
disabled={disabled}
/>
<Text size="sm" fw={500}>
{t("extractFields.fields.label", "Fields to extract")}
</Text>
<div className={styles.schema}>
{parameters.fields.map((row, index) => (
<div key={index} className={styles.row}>
<TextInput
className={styles.nameInput}
aria-label={t("extractFields.fields.name", "Name")}
placeholder={t(
"extractFields.fields.namePlaceholder",
"invoice_number",
)}
value={row.name}
onChange={(event) =>
setRow(index, { name: event.currentTarget.value })
}
disabled={disabled}
/>
<Select
className={styles.typeSelect}
aria-label={t("extractFields.fields.type", "Type")}
value={row.type}
onChange={(value) =>
setRow(index, { type: (value ?? "string") as FieldType })
}
data={FIELD_TYPES.map((type) => ({ value: type, label: type }))}
disabled={disabled}
/>
<TextInput
className={styles.descriptionInput}
aria-label={t("extractFields.fields.description", "Description")}
placeholder={t(
"extractFields.fields.descriptionPlaceholder",
"What to look for",
)}
value={row.description}
onChange={(event) =>
setRow(index, { description: event.currentTarget.value })
}
disabled={disabled}
/>
<Button
variant="quiet"
size="sm"
shape="circle"
leftSection={
<DeleteOutlineRoundedIcon style={{ fontSize: "1.1rem" }} />
}
aria-label={t("extractFields.fields.remove", "Remove field")}
onClick={() => removeRow(index)}
disabled={disabled}
/>
</div>
))}
</div>
<Group gap="xs">
<Button
variant="secondary"
size="sm"
leftSection={<AddRoundedIcon style={{ fontSize: "1rem" }} />}
onClick={() =>
onParameterChange("fields", [...parameters.fields, emptyFieldRow()])
}
disabled={disabled}
>
{t("extractFields.fields.add", "Add field")}
</Button>
<Button
variant="secondary"
size="sm"
leftSection={<AutoAwesomeRoundedIcon style={{ fontSize: "1rem" }} />}
onClick={suggestFields}
loading={suggesting}
disabled={disabled || !selectedFile}
>
{t("extractFields.suggest.button", "Suggest fields (AI)")}
</Button>
</Group>
{!selectedFile && (
<Text size="xs" c="dimmed">
{t(
"extractFields.suggest.needsFile",
"Select a file first to suggest fields",
)}
</Text>
)}
<Select
label={t("extractFields.mode.label", "Mode")}
value={parameters.mode}
onChange={(value) =>
onParameterChange("mode", (value ?? "auto") as DocparseMode)
}
data={[
{ value: "auto", label: t("extractFields.mode.auto", "Auto") },
{ value: "basic", label: t("extractFields.mode.basic", "Basic") },
{
value: "advanced",
label: t("extractFields.mode.advanced", "Advanced"),
},
]}
disabled={disabled}
/>
<Textarea
label={t("extractFields.instructions.label", "Instructions")}
placeholder={t(
"extractFields.instructions.placeholder",
"e.g. Amounts are in EUR unless stated otherwise",
)}
value={parameters.instructions}
onChange={(event) =>
onParameterChange("instructions", event.currentTarget.value)
}
minRows={2}
autosize
disabled={disabled}
/>
</Stack>
);
};
export default ExtractFieldsSettings;
@@ -0,0 +1,51 @@
import { useMemo } from "react";
import { useTranslation } from "react-i18next";
import LocalIcon from "@app/components/shared/LocalIcon";
import {
SubcategoryId,
ToolCategoryId,
type ProprietaryToolRegistry,
} from "@app/data/toolsTaxonomy";
import { asRegistryConfig } from "@app/hooks/tools/shared/toolOperationTypes";
import { useDocparseEnabled } from "@app/hooks/useDocparseEnabled";
import { extractFieldsOperationConfig } from "@app/hooks/tools/extractFields/extractFieldsOperationConfig";
import ExtractFields from "@app/tools/ExtractFields";
import { getSynonyms } from "@app/utils/toolSynonyms";
const toolIcon = (icon: string) => (
<LocalIcon icon={icon} width="1.5rem" height="1.5rem" />
);
/**
* Proprietary tool registry extension - the DocParse tool family.
* Overrides the empty stub at {@code core/data/useProprietaryToolRegistry.tsx}.
* Hidden entirely while the backend reports docparse disabled.
*/
export function useProprietaryToolRegistry(): ProprietaryToolRegistry {
const { t } = useTranslation();
const docparseEnabled = useDocparseEnabled();
return useMemo(() => {
if (!docparseEnabled) return {} as ProprietaryToolRegistry;
return {
extractFields: {
icon: toolIcon("fact-check-outline-rounded"),
name: t("home.extractFields.title", "Extract Fields"),
component: ExtractFields,
description: t(
"home.extractFields.desc",
"Pull typed fields out of a document with confidence scores and citations",
),
categoryId: ToolCategoryId.STANDARD_TOOLS,
subcategoryId: SubcategoryId.DOCUMENT_INTELLIGENCE,
maxFiles: 1,
endpoints: ["extract-fields"],
operationConfig: asRegistryConfig(extractFieldsOperationConfig),
automationSettings: null,
synonyms: getSynonyms(t, "extractFields"),
versionStatus: "beta",
},
} as ProprietaryToolRegistry;
}, [docparseEnabled, t]);
}
@@ -0,0 +1,71 @@
import apiClient from "@app/services/apiClient";
import {
defineCustomTool,
CustomProcessorResult,
} from "@app/hooks/tools/shared/toolOperationTypes";
import { deriveName } from "@app/hooks/tools/shared/docparseFilenames";
import { rowsToSchemaString } from "@app/hooks/tools/extractFields/fieldsSchema";
import {
ExtractFieldsParameters,
defaultParameters,
} from "@app/hooks/tools/extractFields/useExtractFieldsParameters";
// The JSON variant; the plain /extract-fields form is the pipeline shape.
export const EXTRACT_FIELDS_ENDPOINT = "/api/v1/docparse/extract-fields/json";
/** One extracted field in the backend's extraction report. */
export interface ExtractedField {
name: string;
value: unknown;
confidence: number;
citations?: { page: number; bbox?: number[] | null; quote?: string }[];
}
export interface ExtractFieldsResult {
mode: string;
fields: ExtractedField[];
overallConfidence?: number;
}
export const buildExtractFieldsFormData = (
parameters: ExtractFieldsParameters,
file: File,
): FormData => {
const formData = new FormData();
formData.append("fileInput", file);
formData.append("fieldsSchema", rowsToSchemaString(parameters.fields));
formData.append("mode", parameters.mode);
if (parameters.instructions.trim()) {
formData.append("instructions", parameters.instructions.trim());
}
return formData;
};
/** POST the PDF + schema; keep the extraction report as a JSON result file. */
const processExtractFields = async (
parameters: ExtractFieldsParameters,
files: File[],
): Promise<CustomProcessorResult> => {
if (files.length === 0) return { files: [] };
const [inputFile] = files;
const response = await apiClient.post<ExtractFieldsResult>(
EXTRACT_FIELDS_ENDPOINT,
buildExtractFieldsFormData(parameters, inputFile),
);
const resultFile = new File(
[JSON.stringify(response.data, null, 2)],
deriveName(inputFile.name, ".fields.json"),
{ type: "application/json" },
);
return { files: [resultFile] };
};
export const extractFieldsOperationConfig =
defineCustomTool<ExtractFieldsParameters>({
operationType: "extractFields",
endpoint: EXTRACT_FIELDS_ENDPOINT,
customProcessor: processExtractFields,
defaultParameters,
});
@@ -0,0 +1,59 @@
import { describe, expect, test } from "vitest";
import {
emptyFieldRow,
namedRows,
rowsFromSchemaString,
rowsToSchemaString,
type FieldRow,
} from "@app/hooks/tools/extractFields/fieldsSchema";
const rows: FieldRow[] = [
{ name: "invoice_number", type: "string", description: "The invoice id" },
{ name: "total", type: "number", description: "" },
{ name: "paid", type: "boolean", description: "Whether settled" },
];
describe("fieldsSchema", () => {
test("serializes rows to a JSON Schema object string", () => {
const schema = JSON.parse(rowsToSchemaString(rows));
expect(schema.type).toBe("object");
expect(schema.required).toEqual(["invoice_number", "total", "paid"]);
expect(schema.properties.invoice_number).toEqual({
type: "string",
description: "The invoice id",
});
// Blank descriptions are omitted, not sent as empty strings.
expect(schema.properties.total).toEqual({ type: "number" });
});
test("round-trips rows through the schema string", () => {
expect(rowsFromSchemaString(rowsToSchemaString(rows))).toEqual(rows);
});
test("ignores unnamed builder rows", () => {
const withBlank = [...rows, emptyFieldRow()];
expect(namedRows(withBlank)).toHaveLength(3);
const schema = JSON.parse(rowsToSchemaString(withBlank));
expect(Object.keys(schema.properties)).toHaveLength(3);
});
test("trims names and descriptions on serialize", () => {
const schema = JSON.parse(
rowsToSchemaString([
{ name: " due_date ", type: "string", description: " When due " },
]),
);
expect(schema.properties.due_date).toEqual({
type: "string",
description: "When due",
});
});
test("parses junk defensively", () => {
expect(rowsFromSchemaString("not json")).toEqual([]);
expect(rowsFromSchemaString("{}")).toEqual([]);
expect(
rowsFromSchemaString('{"properties":{"x":{"type":"weird"}}}'),
).toEqual([{ name: "x", type: "string", description: "" }]);
});
});
@@ -0,0 +1,65 @@
/**
* The Extract Fields schema builder model: flat rows of {name, type, description}
* serialized to the JSON Schema object string the backend's fieldsSchema expects.
*/
export const FIELD_TYPES = ["string", "number", "integer", "boolean"] as const;
export type FieldType = (typeof FIELD_TYPES)[number];
export interface FieldRow {
name: string;
type: FieldType;
description: string;
}
export const emptyFieldRow = (): FieldRow => ({
name: "",
type: "string",
description: "",
});
/** Rows with a non-empty name, i.e. the ones worth serializing. */
export function namedRows(rows: FieldRow[]): FieldRow[] {
return rows.filter((row) => row.name.trim().length > 0);
}
/** Serialize builder rows to the JSON Schema string sent as fieldsSchema. */
export function rowsToSchemaString(rows: FieldRow[]): string {
const properties: Record<string, { type: FieldType; description?: string }> =
{};
for (const row of namedRows(rows)) {
properties[row.name.trim()] = {
type: row.type,
...(row.description.trim()
? { description: row.description.trim() }
: {}),
};
}
return JSON.stringify({
type: "object",
properties,
required: Object.keys(properties),
});
}
const isFieldType = (value: unknown): value is FieldType =>
typeof value === "string" && FIELD_TYPES.includes(value as FieldType);
/** Parse a fieldsSchema string back into builder rows; [] on anything unusable. */
export function rowsFromSchemaString(schema: string): FieldRow[] {
try {
const parsed = JSON.parse(schema) as {
properties?: Record<string, { type?: unknown; description?: unknown }>;
};
if (!parsed || typeof parsed !== "object" || !parsed.properties) return [];
return Object.entries(parsed.properties).map(([name, prop]) => ({
name,
type: isFieldType(prop?.type) ? prop.type : "string",
description:
typeof prop?.description === "string" ? prop.description : "",
}));
} catch {
return [];
}
}
@@ -0,0 +1,47 @@
import { describe, expect, test } from "vitest";
import {
emptyFieldRow,
rowsFromSchemaString,
rowsToSchemaString,
} from "@app/hooks/tools/extractFields/fieldsSchema";
import {
CUSTOM_PRESET,
FIELD_PRESET_IDS,
matchPreset,
presetRows,
} from "@app/hooks/tools/extractFields/fieldsSchemaPresets";
describe("fieldsSchemaPresets", () => {
test("every preset has 4-6 named, described fields", () => {
for (const preset of FIELD_PRESET_IDS) {
const rows = presetRows(preset);
expect(rows.length).toBeGreaterThanOrEqual(4);
expect(rows.length).toBeLessThanOrEqual(6);
for (const row of rows) {
expect(row.name).toMatch(/^[a-z0-9_]+$/);
expect(row.description.length).toBeGreaterThan(0);
}
}
});
test("preset rows survive the schema-string round trip", () => {
for (const preset of FIELD_PRESET_IDS) {
const rows = presetRows(preset);
expect(rowsFromSchemaString(rowsToSchemaString(rows))).toEqual(rows);
}
});
test("matchPreset spots untouched presets and demotes edits to custom", () => {
const rows = presetRows("invoice");
expect(matchPreset(rows)).toBe("invoice");
rows[0] = { ...rows[0], name: "order_ref" };
expect(matchPreset(rows)).toBe(CUSTOM_PRESET);
expect(matchPreset([emptyFieldRow()])).toBe(CUSTOM_PRESET);
});
test("presetRows hands out fresh copies, not shared references", () => {
const first = presetRows("receipt");
first[0].name = "mutated";
expect(presetRows("receipt")[0].name).not.toBe("mutated");
});
});
@@ -0,0 +1,151 @@
import type { FieldRow } from "@app/hooks/tools/extractFields/fieldsSchema";
/**
* Ready-made extraction schemas for the common document types, so users start
* from a sensible field list instead of a blank builder.
*/
export const FIELD_PRESET_IDS = [
"invoice",
"receipt",
"contract",
"purchaseOrder",
] as const;
export type FieldPresetId = (typeof FIELD_PRESET_IDS)[number];
/** The select value when the rows match no preset (hand-built schema). */
export const CUSTOM_PRESET = "custom" as const;
const PRESETS: Record<FieldPresetId, FieldRow[]> = {
invoice: [
{
name: "invoice_number",
type: "string",
description: "The invoice identifier",
},
{
name: "invoice_date",
type: "string",
description: "Date the invoice was issued (ISO format if possible)",
},
{
name: "vendor_name",
type: "string",
description: "Name of the company issuing the invoice",
},
{
name: "total_amount",
type: "number",
description: "Grand total including tax",
},
{
name: "currency",
type: "string",
description: "Currency code or symbol",
},
{
name: "due_date",
type: "string",
description: "Date payment is due",
},
],
receipt: [
{
name: "merchant_name",
type: "string",
description: "Store or merchant name",
},
{
name: "purchase_date",
type: "string",
description: "Date of the purchase",
},
{ name: "total_amount", type: "number", description: "Total paid" },
{
name: "tax_amount",
type: "number",
description: "Tax portion of the total",
},
{
name: "payment_method",
type: "string",
description: "How it was paid, e.g. card or cash",
},
],
contract: [
{
name: "party_a",
type: "string",
description: "First contracting party's legal name",
},
{
name: "party_b",
type: "string",
description: "Second contracting party's legal name",
},
{
name: "effective_date",
type: "string",
description: "Date the agreement takes effect",
},
{
name: "termination_date",
type: "string",
description: "Date the agreement ends or renews",
},
{
name: "governing_law",
type: "string",
description: "Jurisdiction governing the agreement",
},
{
name: "auto_renews",
type: "boolean",
description: "Whether the contract renews automatically",
},
],
purchaseOrder: [
{
name: "po_number",
type: "string",
description: "Purchase order identifier",
},
{ name: "order_date", type: "string", description: "Date of the order" },
{
name: "supplier_name",
type: "string",
description: "Supplier the order is placed with",
},
{
name: "delivery_date",
type: "string",
description: "Requested or promised delivery date",
},
{ name: "total_amount", type: "number", description: "Order total" },
],
};
/** A fresh copy of the preset's rows, safe to mutate in the builder. */
export function presetRows(preset: FieldPresetId): FieldRow[] {
return PRESETS[preset].map((row) => ({ ...row }));
}
/** The preset the rows exactly match, or "custom" for anything hand-edited. */
export function matchPreset(rows: FieldRow[]): FieldPresetId | "custom" {
for (const preset of FIELD_PRESET_IDS) {
const candidate = PRESETS[preset];
if (
rows.length === candidate.length &&
rows.every(
(row, i) =>
row.name === candidate[i].name &&
row.type === candidate[i].type &&
row.description === candidate[i].description,
)
) {
return preset;
}
}
return CUSTOM_PRESET;
}
@@ -0,0 +1,35 @@
import { describe, expect, test } from "vitest";
import { suggestedFieldsToRows } from "@app/hooks/tools/extractFields/suggestSchema";
describe("suggestedFieldsToRows", () => {
test("maps engine proposals to builder rows", () => {
expect(
suggestedFieldsToRows([
{ name: "invoice_number", type: "string", description: "The id" },
{ name: "total", type: "number" },
]),
).toEqual([
{ name: "invoice_number", type: "string", description: "The id" },
{ name: "total", type: "number", description: "" },
]);
});
test("coerces unknown types to string and trims values", () => {
expect(
suggestedFieldsToRows([
{ name: " due_date ", type: "date", description: " When due " },
]),
).toEqual([{ name: "due_date", type: "string", description: "When due" }]);
});
test("drops unusable entries defensively", () => {
expect(
suggestedFieldsToRows([
{ name: "", type: "string" },
{ type: "string", description: "no name" },
undefined as never,
]),
).toEqual([]);
expect(suggestedFieldsToRows(undefined)).toEqual([]);
});
});
@@ -0,0 +1,53 @@
import apiClient from "@app/services/apiClient";
import {
FIELD_TYPES,
type FieldRow,
type FieldType,
} from "@app/hooks/tools/extractFields/fieldsSchema";
export const SUGGEST_SCHEMA_ENDPOINT = "/api/v1/docparse/suggest-schema";
/** Cap on proposed fields; keeps the builder scannable in the side panel. */
export const SUGGEST_MAX_FIELDS = 8;
/** One field the backend proposes for an extraction schema. */
export interface SuggestedSchemaField {
name?: string;
type?: string;
description?: string;
}
export interface SuggestSchemaResponse {
mode?: string;
fields?: SuggestedSchemaField[];
}
const isFieldType = (value: unknown): value is FieldType =>
typeof value === "string" && FIELD_TYPES.includes(value as FieldType);
/** Map the engine's proposals to builder rows, dropping unusable entries. */
export function suggestedFieldsToRows(
fields: SuggestedSchemaField[] | undefined,
): FieldRow[] {
if (!Array.isArray(fields)) return [];
return fields
.filter((field) => typeof field?.name === "string" && field.name.trim())
.map((field) => ({
name: field.name!.trim(),
type: isFieldType(field.type) ? field.type : "string",
description:
typeof field.description === "string" ? field.description.trim() : "",
}));
}
/** POST the document; get AI-proposed schema rows for the builder. */
export async function requestSuggestedFields(file: File): Promise<FieldRow[]> {
const formData = new FormData();
formData.append("fileInput", file);
formData.append("maxFields", String(SUGGEST_MAX_FIELDS));
const response = await apiClient.post<SuggestSchemaResponse>(
SUGGEST_SCHEMA_ENDPOINT,
formData,
);
return suggestedFieldsToRows(response.data?.fields);
}
@@ -0,0 +1,15 @@
import { useTranslation } from "react-i18next";
import { useToolOperation } from "@app/hooks/tools/shared/useToolOperation";
import { createStandardErrorHandler } from "@app/utils/toolErrorHandler";
import { extractFieldsOperationConfig } from "@app/hooks/tools/extractFields/extractFieldsOperationConfig";
export const useExtractFieldsOperation = () => {
const { t } = useTranslation();
return useToolOperation({
...extractFieldsOperationConfig,
getErrorMessage: createStandardErrorHandler(
t("extractFields.error.failed", "Failed to extract fields"),
),
});
};
@@ -0,0 +1,35 @@
import { BaseParameters } from "@app/types/parameters";
import {
useBaseParameters,
BaseParametersHook,
} from "@app/hooks/tools/shared/useBaseParameters";
import type { DocparseMode } from "@app/hooks/tools/shared/docparseTypes";
import {
emptyFieldRow,
namedRows,
type FieldRow,
} from "@app/hooks/tools/extractFields/fieldsSchema";
export interface ExtractFieldsParameters extends BaseParameters {
/** Schema-builder rows; serialized to fieldsSchema on execute. */
fields: FieldRow[];
instructions: string;
mode: DocparseMode;
}
export const defaultParameters: ExtractFieldsParameters = {
fields: [emptyFieldRow()],
instructions: "",
mode: "auto",
};
export type ExtractFieldsParametersHook =
BaseParametersHook<ExtractFieldsParameters>;
export const useExtractFieldsParameters = (): ExtractFieldsParametersHook => {
return useBaseParameters({
defaultParameters,
endpointName: "extract-fields",
validateFn: (params) => namedRows(params.fields).length > 0,
});
};
@@ -0,0 +1,12 @@
/** Filename helpers shared by the DocParse tool processors. */
/** "invoice.pdf" -> "invoice"; keeps names without an extension intact. */
export function stripExtension(fileName: string): string {
const dot = fileName.lastIndexOf(".");
return dot > 0 ? fileName.slice(0, dot) : fileName;
}
/** Derived output name, e.g. deriveName("a.pdf", ".fields.json") -> "a.fields.json". */
export function deriveName(inputName: string, suffix: string): string {
return `${stripExtension(inputName)}${suffix}`;
}
@@ -0,0 +1,2 @@
/** Parse-tier request modes shared by the DocParse tool family. */
export type DocparseMode = "auto" | "basic" | "advanced";
@@ -0,0 +1,71 @@
import { useEffect, useState } from "react";
import apiClient from "@app/services/apiClient";
/** The merged capability view served by GET /api/v1/docparse/capabilities. */
export interface DocparseCapabilities {
enabled: boolean;
mode: string;
advancedInstalled: boolean;
engineReachable: boolean;
doclingVersion: string | null;
}
// Module-level cache so every intro card shares one fetch per page load,
// mirroring the useEndpointConfig global-cache pattern.
let cachedCapabilities: DocparseCapabilities | null = null;
let inFlight: Promise<DocparseCapabilities | null> | null = null;
async function fetchCapabilities(): Promise<DocparseCapabilities | null> {
try {
const response = await apiClient.get<DocparseCapabilities>(
"/api/v1/docparse/capabilities",
{ suppressErrorToast: true, skipAuthRedirect: true },
);
cachedCapabilities = response.data;
return cachedCapabilities;
} catch {
return null;
} finally {
inFlight = null;
}
}
/** Test seam: forget the cached capabilities so the next mount refetches. */
export function resetDocparseCapabilitiesCache() {
cachedCapabilities = null;
inFlight = null;
}
/**
* The DocParse capability report (tier, engine reachability), module-cached so
* the intro card on every docparse tool costs at most one request.
*/
export function useDocparseCapabilities(): {
capabilities: DocparseCapabilities | null;
loading: boolean;
} {
const [capabilities, setCapabilities] = useState<DocparseCapabilities | null>(
cachedCapabilities,
);
const [loading, setLoading] = useState(cachedCapabilities === null);
useEffect(() => {
if (cachedCapabilities) {
setCapabilities(cachedCapabilities);
setLoading(false);
return;
}
let cancelled = false;
inFlight = inFlight ?? fetchCapabilities();
inFlight.then((result) => {
if (cancelled) return;
setCapabilities(result);
setLoading(false);
});
return () => {
cancelled = true;
};
}, []);
return { capabilities, loading };
}
@@ -18,10 +18,12 @@ describe("POLICY_OPERATIONS", () => {
"classify",
"compress",
"externalApiCall",
"extractFields",
"flatten",
"ocr",
"purviewApplyLabel",
"purviewReadLabel",
"ragIngest",
"redact",
"sanitize",
"timestampPdf",
@@ -52,6 +54,41 @@ describe("POLICY_OPERATIONS", () => {
});
});
describe("rag ingest wire round-trip", () => {
test("sends positive integer sizes and real booleans, and round-trips them", () => {
const wire = policyStepToWire(
policyStep("ragIngest", {
chunkSize: "1024",
overlap: "32",
exportMarkdown: "true",
}),
);
expect(wire.operation).toBe("/api/v1/docparse/rag-ingest");
expect(wire.parameters).toEqual({
chunkSize: 1024,
overlap: 32,
mode: "auto",
index: true,
exportMarkdown: true,
exportChunksJsonl: false,
});
const back = policyStepFromWire(wire);
expect(back?.toolId).toBe("ragIngest");
if (back?.toolId === "ragIngest") {
expect(back.params.chunkSize).toBe("1024");
expect(back.params.exportMarkdown).toBe("true");
expect(back.params.index).toBe("true");
}
// Junk sizes fall back to defaults instead of sending garbage to the engine.
expect(
policyStepToWire(policyStep("ragIngest", { chunkSize: "junk" }))
.parameters.chunkSize,
).toBe(512);
});
});
describe("policyStep", () => {
test("merges partial params over the tool's defaults", () => {
const step = policyStep("redact", {
@@ -32,10 +32,20 @@ export type IntegrationPolicyEndpoint =
| "/api/v1/integration/purview-apply-label"
| "/api/v1/integration/purview-read-label";
/**
* Endpoints for DocParse pipeline steps. Excluded from the generated
* {@link ToolEndpoint} union: the DocParse controllers live in the proprietary
* module, outside the tool namespaces the generator reads.
*/
export type DocparsePolicyEndpoint =
| "/api/v1/docparse/rag-ingest"
| "/api/v1/docparse/extract-fields";
/** An endpoint typed here rather than by the generator. */
export type UntypedPolicyEndpoint =
| AiPolicyEndpoint
| IntegrationPolicyEndpoint;
| IntegrationPolicyEndpoint
| DocparsePolicyEndpoint;
/** A tool usable in a policy whose endpoint isn't in the generated union. */
export interface AiToolDescriptor<TParams> {
@@ -89,6 +99,86 @@ function describeIntegrationOperation<TParams extends Record<string, string>>(
};
}
/**
* Extract Fields as a policy step (the pipeline shape of the DocParse tool).
* The schema is authored in the wizard and crosses the wire verbatim; a blank
* `instructions` is dropped so the backend sees "absent", not "empty".
*/
function describeExtractFieldsOperation(): AiToolDescriptor<{
fieldsSchema: string;
mode: string;
instructions: string;
}> {
return {
endpoint: "/api/v1/docparse/extract-fields",
defaultParameters: { fieldsSchema: "", mode: "auto", instructions: "" },
toApi: (params) => ({
fieldsSchema: params.fieldsSchema,
mode: params.mode || "auto",
...(params.instructions.trim()
? { instructions: params.instructions.trim() }
: {}),
}),
fromApi: (api) => ({
fieldsSchema: api.fieldsSchema == null ? "" : String(api.fieldsSchema),
mode: api.mode == null ? "auto" : String(api.mode),
instructions: api.instructions == null ? "" : String(api.instructions),
}),
};
}
/**
* RAG ingest as a policy step: chunk + embed + index in one step, with optional
* corpus export (markdown, chunks JSONL) for external systems. Sizes and flags
* are flat strings in the params (like every step parameter); sizes cross the
* wire as positive integers and flags as booleans, falling back on junk input.
*/
function describeRagIngestOperation(): AiToolDescriptor<{
chunkSize: string;
overlap: string;
mode: string;
index: string;
exportMarkdown: string;
exportChunksJsonl: string;
}> {
const defaults = { chunkSize: 512, overlap: 64 };
const toInt = (raw: string, fallback: number): number => {
const parsed = Number(raw);
return Number.isFinite(parsed) && parsed > 0
? Math.round(parsed)
: fallback;
};
const toBool = (raw: unknown, fallback: boolean): boolean =>
raw === undefined || raw === null ? fallback : String(raw) === "true";
return {
endpoint: "/api/v1/docparse/rag-ingest",
defaultParameters: {
chunkSize: String(defaults.chunkSize),
overlap: String(defaults.overlap),
mode: "auto",
index: "true",
exportMarkdown: "false",
exportChunksJsonl: "false",
},
toApi: (params) => ({
chunkSize: toInt(params.chunkSize, defaults.chunkSize),
overlap: toInt(params.overlap, defaults.overlap),
mode: params.mode || "auto",
index: toBool(params.index, true),
exportMarkdown: toBool(params.exportMarkdown, false),
exportChunksJsonl: toBool(params.exportChunksJsonl, false),
}),
fromApi: (api) => ({
chunkSize: String(toInt(String(api.chunkSize ?? ""), defaults.chunkSize)),
overlap: String(toInt(String(api.overlap ?? ""), defaults.overlap)),
mode: api.mode == null ? "auto" : String(api.mode),
index: String(toBool(api.index, true)),
exportMarkdown: String(toBool(api.exportMarkdown, false)),
exportChunksJsonl: String(toBool(api.exportChunksJsonl, false)),
}),
};
}
export const POLICY_OPERATIONS = {
redact: describeToolOperation(
"/api/v1/security/auto-redact",
@@ -118,6 +208,8 @@ export const POLICY_OPERATIONS = {
compressOperationConfig,
),
classify: describeAiToolOperation("/api/v1/ai/tools/classify-and-label"),
extractFields: describeExtractFieldsOperation(),
ragIngest: describeRagIngestOperation(),
purviewApplyLabel: describeIntegrationOperation(
"/api/v1/integration/purview-apply-label",
{ connectionId: "", labelId: "", labelName: "", method: "STANDARD" },
@@ -0,0 +1,120 @@
import { useEffect, useState } from "react";
import { useTranslation } from "react-i18next";
import { Badge, Group, Stack, Text } from "@mantine/core";
import { createToolFlow } from "@app/components/tools/shared/createToolFlow";
import { useBaseTool } from "@app/hooks/tools/shared/useBaseTool";
import type { BaseToolProps } from "@app/types/tool";
import ExtractFieldsSettings from "@app/components/tools/docparse/ExtractFieldsSettings";
import { useExtractFieldsParameters } from "@app/hooks/tools/extractFields/useExtractFieldsParameters";
import { useExtractFieldsOperation } from "@app/hooks/tools/extractFields/useExtractFieldsOperation";
import type { ExtractFieldsResult } from "@app/hooks/tools/extractFields/extractFieldsOperationConfig";
/** Badge color mirroring the portal's confidence tones. */
const confidenceColor = (confidence: number): string => {
if (confidence < 0.6) return "red";
if (confidence < 0.85) return "yellow";
return "green";
};
const ExtractFields = (props: BaseToolProps) => {
const { t } = useTranslation();
const base = useBaseTool(
"extractFields",
useExtractFieldsParameters,
useExtractFieldsOperation,
props,
);
// The processor stores the extraction report as the result file; re-read it
// here so the fields render inline with confidence and quotes.
const [result, setResult] = useState<ExtractFieldsResult | null>(null);
const resultFile = base.operation.files[0] ?? null;
useEffect(() => {
let cancelled = false;
if (!resultFile) {
setResult(null);
return;
}
resultFile
.text()
.then((text) => {
if (!cancelled) setResult(JSON.parse(text) as ExtractFieldsResult);
})
.catch(() => {
if (!cancelled) setResult(null);
});
return () => {
cancelled = true;
};
}, [resultFile]);
return createToolFlow({
files: {
selectedFiles: base.selectedFiles,
isCollapsed: base.hasResults,
},
steps: [
{
title: t("extractFields.settings.title", "Extraction schema"),
isCollapsed: false,
content: (
<ExtractFieldsSettings
parameters={base.params.parameters}
onParameterChange={base.params.updateParameter}
disabled={base.endpointLoading}
selectedFile={base.selectedFiles[0] ?? null}
/>
),
},
{
title: t("extractFields.resultsPanel.title", "Extracted fields"),
isVisible: base.hasResults && result !== null,
isCollapsed: false,
content: (
<Stack gap="sm">
{(result?.fields ?? []).map((field) => (
<Stack key={field.name} gap={2}>
<Group gap="xs" wrap="nowrap">
<Text size="sm" fw={600} style={{ flex: 1 }} truncate>
{field.name}
</Text>
<Badge
size="sm"
variant="light"
color={confidenceColor(field.confidence)}
>
{Math.round(field.confidence * 100)}%
</Badge>
</Group>
<Text size="sm">{String(field.value ?? "-")}</Text>
{field.citations?.[0]?.quote && (
<Text size="xs" c="dimmed" fs="italic">
&ldquo;{field.citations[0].quote}&rdquo;
</Text>
)}
</Stack>
))}
</Stack>
),
},
],
executeButton: {
text: t("extractFields.submit", "Extract fields"),
isVisible: !base.hasResults,
loadingText: t("loading"),
onClick: base.handleExecute,
endpointEnabled: base.endpointEnabled,
paramsValid: base.params.validateParameters(),
},
review: {
isVisible: base.hasResults,
operation: base.operation,
title: t("extractFields.results.title", "Extraction report"),
onFileClick: base.handleThumbnailClick,
onUndo: base.handleUndo,
},
});
};
export default ExtractFields;

Some files were not shown because too many files have changed in this diff Show More