Compare commits

..
113 changed files with 6187 additions and 43 deletions
@@ -433,9 +433,19 @@ public class EndpointConfiguration {
addEndpointToGroup("Automation", "automate"); // Alias for handleData (user-friendly name)
addEndpointToGroup("Automation", "pipeline");
// Adding endpoints to "DocParse" group (ingestion: chunk + index + export)
// Adding endpoints to "DocParse" group (parsing, splitting, chunking, extraction,
// templating)
addEndpointToGroup("DocParse", "parse-document");
addEndpointToGroup("DocParse", "extract-fields");
addEndpointToGroup("DocParse", "smart-split");
addEndpointToGroup("DocParse", "chunk-document");
addEndpointToGroup("DocParse", "rag-ingest");
addEndpointToGroup("DocParse", "rag-documents");
addEndpointToGroup("DocParse", "rag-search");
addEndpointToGroup("DocParse", "rag-ask");
addEndpointToGroup("DocParse", "extract-tables");
addEndpointToGroup("DocParse", "suggest-schema");
addEndpointToGroup("DocParse", "fill-template");
// Adding endpoints to "DeveloperTools" group
addEndpointToGroup("DeveloperTools", "show-javascript");
@@ -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();
@@ -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
@@ -4,22 +4,32 @@ import java.io.ByteArrayOutputStream;
import java.io.IOException;
import java.io.StringWriter;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.StandardCopyOption;
import java.util.Base64;
import java.util.List;
import java.util.Locale;
import java.util.zip.ZipEntry;
import java.util.zip.ZipOutputStream;
import org.apache.commons.csv.CSVFormat;
import org.apache.commons.csv.CSVPrinter;
import org.apache.pdfbox.pdmodel.PDDocument;
import org.springframework.core.io.ByteArrayResource;
import org.springframework.core.io.Resource;
import org.springframework.http.HttpHeaders;
import org.springframework.http.HttpStatus;
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.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;
import org.springframework.web.multipart.MultipartFile;
import org.springframework.web.server.ResponseStatusException;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.tags.Tag;
@@ -29,16 +39,35 @@ import lombok.extern.slf4j.Slf4j;
import stirling.software.common.annotations.AutoJobPostMapping;
import stirling.software.common.enumeration.ResourceWeight;
import stirling.software.common.service.CustomPDFDocumentFactory;
import stirling.software.common.util.FormUtils;
import stirling.software.common.util.GeneralUtils;
import stirling.software.common.util.TempFile;
import stirling.software.common.util.TempFileManager;
import stirling.software.common.util.WebResponseUtils;
import stirling.software.proprietary.model.api.docparse.ChunkDocumentApiRequest;
import stirling.software.proprietary.model.api.docparse.ExtractFieldsApiRequest;
import stirling.software.proprietary.model.api.docparse.ExtractTablesApiRequest;
import stirling.software.proprietary.model.api.docparse.ParseDocumentApiRequest;
import stirling.software.proprietary.model.api.docparse.RagAskApiRequest;
import stirling.software.proprietary.model.api.docparse.RagIngestApiRequest;
import stirling.software.proprietary.model.api.docparse.RagSearchApiRequest;
import stirling.software.proprietary.model.api.docparse.SmartSplitApiRequest;
import stirling.software.proprietary.model.api.docparse.SuggestSchemaApiRequest;
import stirling.software.proprietary.model.docparse.ChunkDocumentResponse;
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.FillDocxResponse;
import stirling.software.proprietary.model.docparse.ParseDocumentResponse;
import stirling.software.proprietary.model.docparse.RagIngestResponse;
import stirling.software.proprietary.model.docparse.RagStatsView;
import stirling.software.proprietary.model.docparse.SmartSplitResponse;
import stirling.software.proprietary.model.docparse.SplitPart;
import stirling.software.proprietary.model.docparse.SuggestSchemaResponse;
import stirling.software.proprietary.service.AiToolResponseHeaders;
import stirling.software.proprietary.service.DocParseService;
@@ -63,7 +92,14 @@ public class DocParseController {
private static final MediaType CSV = MediaType.parseMediaType("text/csv");
private static final MediaType MARKDOWN = MediaType.parseMediaType("text/markdown");
private static final MediaType DOCX =
MediaType.parseMediaType(
"application/vnd.openxmlformats-officedocument.wordprocessingml.document");
private final DocParseService docParseService;
private final CustomPDFDocumentFactory pdfDocumentFactory;
private final TempFileManager tempFileManager;
private final ObjectMapper objectMapper;
@AutoJobPostMapping(
@@ -122,6 +158,189 @@ public class DocParseController {
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()));
}
@AutoJobPostMapping(
consumes = MediaType.MULTIPART_FORM_DATA_VALUE,
value = "/parse-document",
resourceWeight = ResourceWeight.XLARGE_WEIGHT)
@Operation(
summary = "Parse a document into structured blocks, tables, and markdown",
description =
"Parses the PDF into layout blocks, tables, and a markdown rendering. The"
+ " basic tier reads the text layer; the advanced tier (docparse addon)"
+ " adds OCR, real table structure, and bounding boxes."
+ " Input:PDF Output:JSON Type:SISO")
public ResponseEntity<?> parseDocument(@ModelAttribute ParseDocumentApiRequest request)
throws IOException {
ParseDocumentResponse result =
docParseService.parse(
request.getFileInput(),
DocparseMode.fromWire(request.getMode()),
request.isWithOcr());
if ("markdown".equalsIgnoreCase(request.getOutputFormat())) {
return WebResponseUtils.bytesToWebResponse(
result.markdown().getBytes(StandardCharsets.UTF_8),
outputName(request.getFileInput(), "_parsed.md"),
MARKDOWN);
}
return ResponseEntity.ok(result);
}
@AutoJobPostMapping(
consumes = MediaType.MULTIPART_FORM_DATA_VALUE,
value = "/smart-split",
resourceWeight = ResourceWeight.LARGE_WEIGHT)
@Operation(
summary = "Split a document at content-derived boundaries",
description =
"Asks the engine where sub-documents start (per the natural-language rule) and"
+ " returns a ZIP with one PDF per part, named from the part labels."
+ " Input:PDF Output:ZIP-PDF Type:SIMO")
public ResponseEntity<Resource> smartSplit(@ModelAttribute SmartSplitApiRequest request)
throws IOException {
MultipartFile file = request.getFileInput();
SmartSplitResponse split =
docParseService.split(file, request.getRule(), request.getMaxParts());
if (split.parts().isEmpty()) {
throw new ResponseStatusException(
HttpStatus.UNPROCESSABLE_ENTITY,
"The split rule produced no parts for this document");
}
TempFile zipTempFile = tempFileManager.createManagedTempFile(".zip");
try {
try (TempFile sourceTempFile = new TempFile(tempFileManager, ".pdf")) {
Files.copy(
file.getInputStream(),
sourceTempFile.getPath(),
StandardCopyOption.REPLACE_EXISTING);
try (ZipOutputStream zipOut =
new ZipOutputStream(Files.newOutputStream(zipTempFile.getPath()))) {
writeParts(sourceTempFile, split.parts(), zipOut);
}
}
return WebResponseUtils.zipFileToWebResponse(
zipTempFile,
GeneralUtils.generateFilename(file.getOriginalFilename(), "_split.zip"));
} catch (Exception e) {
zipTempFile.close();
throw e;
}
}
@AutoJobPostMapping(
consumes = MediaType.MULTIPART_FORM_DATA_VALUE,
value = "/chunk-document",
resourceWeight = ResourceWeight.MEDIUM_WEIGHT)
@Operation(
summary = "Chunk a document for RAG",
description =
"Splits the document text into overlapping chunks with page spans and (advanced"
+ " tier) heading breadcrumbs. Input:PDF Output:JSON Type:SISO")
public ResponseEntity<ChunkDocumentResponse> chunkDocument(
@ModelAttribute ChunkDocumentApiRequest request) throws IOException {
return ResponseEntity.ok(
docParseService.chunk(
request.getFileInput(),
request.getChunkSize(),
request.getOverlap(),
DocparseMode.fromWire(request.getMode())));
}
@AutoJobPostMapping(
consumes = MediaType.MULTIPART_FORM_DATA_VALUE,
value = "/fill-template",
resourceWeight = ResourceWeight.SMALL_WEIGHT)
@Operation(
summary = "Fill a DOCX template with JSON data",
description =
"Replaces the template's placeholders with values from the JSON object and"
+ " returns the filled DOCX. Replacement counts and missing keys ride"
+ " the X-Stirling-Tool-Report header."
+ " Input:DOCX Output:DOCX Type:SISO")
public ResponseEntity<Resource> fillTemplate(
@RequestParam("templateFile") MultipartFile templateFile,
@RequestParam("data") String data)
throws IOException {
FillDocxResponse result = docParseService.fillDocx(templateFile, data);
byte[] filled = Base64.getDecoder().decode(result.docxBase64());
HttpHeaders headers = new HttpHeaders();
headers.setContentType(DOCX);
headers.setContentDispositionFormData(
"attachment",
GeneralUtils.generateFilename(templateFile.getOriginalFilename(), "_filled.docx"));
headers.setContentLength(filled.length);
headers.set(
AiToolResponseHeaders.TOOL_REPORT,
objectMapper.writeValueAsString(
new FillDocxResponse("", result.replaced(), result.missing())));
return ResponseEntity.ok().headers(headers).body(new ByteArrayResource(filled));
}
@GetMapping("/capabilities")
@Operation(
summary = "DocParse capability summary",
@@ -154,6 +373,52 @@ public class DocParseController {
CSV);
}
@GetMapping("/rag-stats")
@Operation(
summary = "RAG store statistics",
description =
"The engine's document-store totals (backend, documents, chunks, embedding"
+ " model) merged with the DocParse capability fields. Answers with"
+ " zeros and engineReachable=false when the engine is down.")
public ResponseEntity<RagStatsView> ragStats() {
return ResponseEntity.ok(docParseService.ragStats());
}
@GetMapping("/rag-documents")
@Operation(
summary = "List documents in the RAG store",
description =
"Engine passthrough of the caller-visible indexed documents (documentId,"
+ " source, chunk count).")
public ResponseEntity<String> ragDocuments() throws IOException {
return jsonPassthrough(docParseService.ragDocuments());
}
@PostMapping(value = "/rag-search", consumes = MediaType.APPLICATION_JSON_VALUE)
@Operation(
summary = "Semantic search over the RAG store",
description =
"Searches the caller-visible indexed documents and returns the top passages"
+ " with scores, page spans, and heading breadcrumbs.")
public ResponseEntity<String> ragSearch(@RequestBody RagSearchApiRequest request)
throws IOException {
return jsonPassthrough(docParseService.ragSearch(request.getQuery(), request.getTopK()));
}
@PostMapping(value = "/rag-ask", consumes = MediaType.APPLICATION_JSON_VALUE)
@Operation(
summary = "Ask a question over the RAG store",
description =
"Answers the question from the caller-visible indexed documents and returns"
+ " the answer with its supporting passages.")
public ResponseEntity<String> ragAsk(@RequestBody RagAskApiRequest request) throws IOException {
return jsonPassthrough(docParseService.ragAsk(request.getQuestion(), request.getTopK()));
}
private static ResponseEntity<String> jsonPassthrough(String engineJson) {
return ResponseEntity.ok().contentType(MediaType.APPLICATION_JSON).body(engineJson);
}
/** Original + requested corpus files in one ZIP, so destinations receive them together. */
private byte[] exportZip(
String fileName, byte[] original, RagIngestResponse result, RagIngestApiRequest request)
@@ -209,6 +474,40 @@ public class DocParseController {
return dot > 0 ? fileName.substring(0, dot) : fileName;
}
private void writeParts(TempFile sourceTempFile, List<SplitPart> parts, ZipOutputStream zipOut)
throws IOException {
for (int i = 0; i < parts.size(); i++) {
SplitPart part = parts.get(i);
// Load per part and remove pages outside the range: avoids the PDFBox cross-document
// addPage pitfalls while keeping shared resources intact.
try (PDDocument partDoc = pdfDocumentFactory.load(sourceTempFile.getFile())) {
int pageCount = partDoc.getNumberOfPages();
int start = Math.clamp(part.startPage(), 1, pageCount);
int end = Math.clamp(part.endPage(), start, pageCount);
for (int p = pageCount - 1; p >= 0; p--) {
int pageNumber = p + 1;
if (pageNumber < start || pageNumber > end) {
partDoc.removePage(p);
}
}
FormUtils.pruneOrphanedFormFields(partDoc);
zipOut.putNextEntry(new ZipEntry(partEntryName(i, part)));
partDoc.save(zipOut);
zipOut.closeEntry();
}
}
}
private static String partEntryName(int index, SplitPart part) {
String label = part.label() == null ? "" : part.label().trim();
String sanitized = label.replaceAll("[^A-Za-z0-9 ._-]", "_").replaceAll("\\s+", "_");
if (sanitized.isBlank() || sanitized.chars().allMatch(c -> c == '_' || c == '.')) {
sanitized = "part";
}
// Index prefix keeps entries unique even when labels repeat.
return String.format(Locale.ROOT, "%02d_%s.pdf", index + 1, sanitized);
}
private static String tablesToCsv(List<DocTable> tables) throws IOException {
CSVFormat format = CSVFormat.EXCEL.builder().setEscape('"').build();
StringWriter writer = new StringWriter();
@@ -0,0 +1,27 @@
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 ChunkDocumentApiRequest extends PDFFile {
@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";
}
@@ -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,30 @@
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 ParseDocumentApiRequest extends PDFFile {
@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 = "Apply OCR when parsing scanned pages (advanced tier only)",
defaultValue = "true")
private boolean withOcr = true;
@Schema(
description = "Response format: full JSON result or the markdown rendering only",
allowableValues = {"json", "markdown"},
defaultValue = "json")
private String outputFormat = "json";
}
@@ -0,0 +1,17 @@
package stirling.software.proprietary.model.api.docparse;
import io.swagger.v3.oas.annotations.media.Schema;
import lombok.Data;
@Data
public class RagAskApiRequest {
@Schema(
description = "Question to answer from the indexed documents",
requiredMode = Schema.RequiredMode.REQUIRED)
private String question;
@Schema(description = "Number of passages to ground the answer on (1-20)", defaultValue = "5")
private int topK = 5;
}
@@ -0,0 +1,17 @@
package stirling.software.proprietary.model.api.docparse;
import io.swagger.v3.oas.annotations.media.Schema;
import lombok.Data;
@Data
public class RagSearchApiRequest {
@Schema(
description = "Natural-language search query",
requiredMode = Schema.RequiredMode.REQUIRED)
private String query;
@Schema(description = "Number of passages to return (1-50)", defaultValue = "10")
private int topK = 10;
}
@@ -0,0 +1,21 @@
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 SmartSplitApiRequest extends PDFFile {
@Schema(
description = "Natural-language boundary rule, e.g. 'split where a new invoice starts'",
requiredMode = Schema.RequiredMode.REQUIRED)
private String rule;
@Schema(description = "Maximum number of parts to produce (1-500)", defaultValue = "50")
private int maxParts = 50;
}
@@ -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,14 @@
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/chunk}. */
public record ChunkDocumentRequest(
String fileName,
List<AiPageText> pages,
String contentBase64,
int chunkSize,
int overlap,
DocparseMode mode) {}
@@ -0,0 +1,11 @@
package stirling.software.proprietary.model.docparse;
import java.util.List;
/** Engine response for {@code POST /api/v1/docparse/chunk}. */
public record ChunkDocumentResponse(DocparseTier mode, List<DocChunk> chunks) {
public ChunkDocumentResponse {
chunks = chunks == null ? List.of() : chunks;
}
}
@@ -0,0 +1,9 @@
package stirling.software.proprietary.model.docparse;
import java.util.List;
/**
* One layout block. {@code bbox} is [x0, y0, x1, y1] normalized to 0..1 with a top-left origin;
* {@code null} in basic tier (no layout model ran). Mirrors {@code docparse.py DocBlock}.
*/
public record DocBlock(String type, String text, int page, List<Double> bbox, Double confidence) {}
@@ -0,0 +1,5 @@
package stirling.software.proprietary.model.docparse;
/** Engine response for {@code GET /api/v1/documents/stats}: the RAG document store totals. */
public record DocumentStoreStats(
String backend, long documents, long chunks, String embeddingModel) {}
@@ -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,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,6 @@
package stirling.software.proprietary.model.docparse;
import tools.jackson.databind.JsonNode;
/** Engine request for {@code POST /api/v1/docparse/fill-docx}. */
public record FillDocxRequest(String templateBase64, JsonNode data) {}
@@ -0,0 +1,11 @@
package stirling.software.proprietary.model.docparse;
import java.util.List;
/** Engine response for {@code POST /api/v1/docparse/fill-docx}. */
public record FillDocxResponse(String docxBase64, int replaced, List<String> missing) {
public FillDocxResponse {
missing = missing == null ? List.of() : missing;
}
}
@@ -0,0 +1,4 @@
package stirling.software.proprietary.model.docparse;
/** Engine request for {@code POST /api/v1/docparse/parse}. */
public record ParseDocumentRequest(String fileName, String contentBase64, boolean withOcr) {}
@@ -0,0 +1,21 @@
package stirling.software.proprietary.model.docparse;
import java.util.List;
/**
* Engine response for {@code POST /api/v1/docparse/parse}; also produced by the Java basic tier.
*/
public record ParseDocumentResponse(
DocparseTier mode,
int pages,
List<DocBlock> blocks,
List<DocTable> tables,
String markdown,
boolean ocrApplied) {
public ParseDocumentResponse {
blocks = blocks == null ? List.of() : blocks;
tables = tables == null ? List.of() : tables;
markdown = markdown == null ? "" : markdown;
}
}
@@ -0,0 +1,4 @@
package stirling.software.proprietary.model.docparse;
/** Engine request for {@code POST /api/v1/documents/ask}: grounded Q&A over the RAG store. */
public record RagAskRequest(String question, int topK) {}
@@ -0,0 +1,4 @@
package stirling.software.proprietary.model.docparse;
/** Engine request for {@code POST /api/v1/documents/search}: semantic search over the RAG store. */
public record RagSearchRequest(String query, int topK) {}
@@ -0,0 +1,15 @@
package stirling.software.proprietary.model.docparse;
/**
* Merged RAG store view served by {@code GET /api/v1/docparse/rag-stats} (Java side): the engine's
* document-store totals plus the cached DocParse capability fields. When the engine is unreachable
* the totals are zero and {@code engineReachable} is false.
*/
public record RagStatsView(
String backend,
long documents,
long chunks,
String embeddingModel,
boolean advancedInstalled,
String doclingVersion,
boolean engineReachable) {}
@@ -0,0 +1,9 @@
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/split}. */
public record SmartSplitRequest(
String fileName, String rule, List<AiPageText> pages, int maxParts) {}
@@ -0,0 +1,11 @@
package stirling.software.proprietary.model.docparse;
import java.util.List;
/** Engine response for {@code POST /api/v1/docparse/split}. */
public record SmartSplitResponse(List<SplitPart> parts) {
public SmartSplitResponse {
parts = parts == null ? List.of() : parts;
}
}
@@ -0,0 +1,4 @@
package stirling.software.proprietary.model.docparse;
/** One sub-document page range (1-based, inclusive). Mirrors {@code docparse.py SplitPart}. */
public record SplitPart(int startPage, int endPage, String label, double confidence) {}
@@ -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) {}
@@ -20,15 +20,33 @@ 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.ChunkDocumentRequest;
import stirling.software.proprietary.model.docparse.ChunkDocumentResponse;
import stirling.software.proprietary.model.docparse.DocBlock;
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.DocumentStoreStats;
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.FillDocxRequest;
import stirling.software.proprietary.model.docparse.FillDocxResponse;
import stirling.software.proprietary.model.docparse.ParseDocumentRequest;
import stirling.software.proprietary.model.docparse.ParseDocumentResponse;
import stirling.software.proprietary.model.docparse.RagAskRequest;
import stirling.software.proprietary.model.docparse.RagIngestRequest;
import stirling.software.proprietary.model.docparse.RagIngestResponse;
import stirling.software.proprietary.model.docparse.RagSearchRequest;
import stirling.software.proprietary.model.docparse.RagStatsView;
import stirling.software.proprietary.model.docparse.SmartSplitRequest;
import stirling.software.proprietary.model.docparse.SmartSplitResponse;
import stirling.software.proprietary.model.docparse.SuggestSchemaRequest;
import stirling.software.proprietary.model.docparse.SuggestSchemaResponse;
import tools.jackson.databind.JsonNode;
import tools.jackson.databind.ObjectMapper;
/**
@@ -41,8 +59,18 @@ import tools.jackson.databind.ObjectMapper;
@Service
public class DocParseService {
private static final String RAG_INGEST_ENDPOINT = "/api/v1/docparse/rag-ingest";
private static final String PARSE_ENDPOINT = "/api/v1/docparse/parse";
private static final String EXTRACT_ENDPOINT = "/api/v1/docparse/extract";
private static final String SPLIT_ENDPOINT = "/api/v1/docparse/split";
private static final String CHUNK_ENDPOINT = "/api/v1/docparse/chunk";
private static final String TABLES_ENDPOINT = "/api/v1/docparse/tables";
private static final String FILL_DOCX_ENDPOINT = "/api/v1/docparse/fill-docx";
private static final String SUGGEST_SCHEMA_ENDPOINT = "/api/v1/docparse/suggest-schema";
private static final String RAG_INGEST_ENDPOINT = "/api/v1/docparse/rag-ingest";
private static final String DOCUMENT_STATS_ENDPOINT = "/api/v1/documents/stats";
private static final String DOCUMENT_LIST_ENDPOINT = "/api/v1/documents/list";
private static final String DOCUMENT_SEARCH_ENDPOINT = "/api/v1/documents/search";
private static final String DOCUMENT_ASK_ENDPOINT = "/api/v1/documents/ask";
/** Below this average of extractable chars per page the document is treated as scanned. */
static final int SCANNED_AVG_CHARS_PER_PAGE = 100;
@@ -97,6 +125,72 @@ public class DocParseService {
capabilities.doclingVersion());
}
public ParseDocumentResponse parse(
MultipartFile file, DocparseMode requestedMode, boolean withOcr) throws IOException {
requireEnabled();
DocparseTier tier;
try (PDDocument document = pdfDocumentFactory.load(file, true)) {
tier =
resolveTier(
requestedMode,
capabilityService.capabilities(),
false,
looksScanned(document));
if (tier == DocparseTier.BASIC) {
return basicParse(document);
}
}
ParseDocumentRequest request =
new ParseDocumentRequest(fileName(file), encodeBase64(file), withOcr);
String responseJson =
aiEngineClient.postLongRunning(
PARSE_ENDPOINT, objectMapper.writeValueAsString(request), currentUserId());
return objectMapper.readValue(responseJson, ParseDocumentResponse.class);
}
public SmartSplitResponse split(MultipartFile file, String rule, int maxParts)
throws IOException {
requireEnabled();
if (rule == null || rule.isBlank()) {
throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "A split rule is required");
}
List<AiPageText> pages;
try (PDDocument document = pdfDocumentFactory.load(file, true)) {
pages = extractPages(document);
}
SmartSplitRequest request =
new SmartSplitRequest(fileName(file), rule, pages, Math.clamp(maxParts, 1, 500));
String responseJson =
aiEngineClient.post(
SPLIT_ENDPOINT, objectMapper.writeValueAsString(request), currentUserId());
return objectMapper.readValue(responseJson, SmartSplitResponse.class);
}
public ChunkDocumentResponse chunk(
MultipartFile file, int chunkSize, int overlap, DocparseMode mode) throws IOException {
requireEnabled();
List<AiPageText> pages;
DocparseTier tier;
try (PDDocument document = pdfDocumentFactory.load(file, true)) {
pages = extractPages(document);
tier =
resolveTier(
mode, capabilityService.capabilities(), false, looksScanned(document));
}
ChunkDocumentRequest request =
new ChunkDocumentRequest(
fileName(file),
pages,
tier == DocparseTier.ADVANCED ? encodeBase64(file) : null,
Math.clamp(chunkSize, 64, 32_768),
Math.clamp(overlap, 0, 4_096),
toMode(tier));
String responseJson =
aiEngineClient.postLongRunning(
CHUNK_ENDPOINT, objectMapper.writeValueAsString(request), currentUserId());
return objectMapper.readValue(responseJson, ChunkDocumentResponse.class);
}
/**
* 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
@@ -182,6 +276,146 @@ public class DocParseService {
};
}
/** 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;
}
private static void requireNonBlank(String value, String fieldName) {
if (value == null || value.isBlank()) {
throw new ResponseStatusException(
HttpStatus.BAD_REQUEST, "'" + fieldName + "' is required");
}
}
/** Engine RAG store totals merged with the cached capability fields; graceful when down. */
public RagStatsView ragStats() {
DocparseCapabilities capabilities = capabilityService.capabilities();
try {
// The engine's documents routes are user-gated; an id-less probe 401s
// and would read as "engine offline" in the UI.
String json = aiEngineClient.get(DOCUMENT_STATS_ENDPOINT, currentUserId());
DocumentStoreStats stats = objectMapper.readValue(json, DocumentStoreStats.class);
return new RagStatsView(
stats.backend(),
stats.documents(),
stats.chunks(),
stats.embeddingModel(),
capabilities.advancedInstalled(),
capabilities.doclingVersion(),
true);
} catch (Exception e) {
log.debug("RAG stats probe failed: {}", e.getMessage());
return new RagStatsView(
null,
0,
0,
null,
capabilities.advancedInstalled(),
capabilities.doclingVersion(),
false);
}
}
/** Engine document-list passthrough; X-User-Id scopes it to the caller's ACLs. */
public String ragDocuments() throws IOException {
requireEnabled();
return aiEngineClient.get(DOCUMENT_LIST_ENDPOINT, currentUserId());
}
/** Semantic-search passthrough over the caller-visible RAG documents. */
public String ragSearch(String query, int topK) throws IOException {
requireEnabled();
requireNonBlank(query, "query");
RagSearchRequest request = new RagSearchRequest(query, Math.clamp(topK, 1, 50));
return aiEngineClient.post(
DOCUMENT_SEARCH_ENDPOINT,
objectMapper.writeValueAsString(request),
currentUserId());
}
/** Grounded-answer passthrough; long-running because local models answer slowly. */
public String ragAsk(String question, int topK) throws IOException {
requireEnabled();
requireNonBlank(question, "question");
RagAskRequest request = new RagAskRequest(question, Math.clamp(topK, 1, 20));
return aiEngineClient.postLongRunning(
DOCUMENT_ASK_ENDPOINT, objectMapper.writeValueAsString(request), currentUserId());
}
/**
* The settings mode wins when stricter: a settings {@code basic} always forces basic, a
* settings {@code advanced} upgrades everything except an explicit basic request.
@@ -242,6 +476,41 @@ public class DocParseService {
return objectMapper.readValue(responseJson, ExtractTablesResponse.class);
}
public FillDocxResponse fillDocx(MultipartFile templateFile, String dataJson)
throws IOException {
requireEnabled();
JsonNode data = parseJsonObject(dataJson, "data");
FillDocxRequest request =
new FillDocxRequest(
Base64.getEncoder().encodeToString(templateFile.getBytes()), data);
String responseJson =
aiEngineClient.post(
FILL_DOCX_ENDPOINT,
objectMapper.writeValueAsString(request),
currentUserId());
return objectMapper.readValue(responseJson, FillDocxResponse.class);
}
/** Basic tier parse: PDFBox text layer only, one paragraph block per non-blank page. */
ParseDocumentResponse basicParse(PDDocument document) throws IOException {
int pageCount = document.getNumberOfPages();
List<DocBlock> blocks = new ArrayList<>();
StringBuilder markdown = new StringBuilder();
for (int page = 1; page <= pageCount; page++) {
String text = pdfContentExtractor.extractPageTextRaw(document, page);
if (text == null || text.isBlank()) {
continue;
}
blocks.add(new DocBlock("paragraph", text, page, null, null));
if (!markdown.isEmpty()) {
markdown.append("\n\n");
}
markdown.append(text);
}
return new ParseDocumentResponse(
DocparseTier.BASIC, pageCount, blocks, List.of(), markdown.toString(), false);
}
/** Extract per-page text for the engine, capped by the shared aiEngine limits. */
List<AiPageText> extractPages(PDDocument document) throws IOException {
ApplicationProperties.AiEngine.Limits limits =
+2
View File
@@ -14,6 +14,8 @@ dependencies = [
"pydantic-ai-slim[voyageai]>=1.99.0,<2.0.0",
"pydantic-settings>=2.0.0",
"python-dotenv>=1.2.1",
# Small (MIT) and always installed: DOCX template filling needs no addon.
"python-docx>=1.1.2",
"sqlite-vec>=0.1.6",
"uvicorn>=0.35.0",
"opentelemetry-sdk>=1.39.0",
+2
View File
@@ -2,6 +2,7 @@
from .document_classifier import DocumentClassifierAgent
from .execution import ExecutionPlanningAgent
from .knowledge_ask import KnowledgeAskAgent
from .orchestrator import OrchestratorAgent
from .pdf_create import PdfCreateAgent
from .pdf_edit import PdfEditAgent, PdfEditParameterSelector, PdfEditPlanSelection
@@ -12,6 +13,7 @@ from .user_spec import UserSpecAgent
__all__ = [
"DocumentClassifierAgent",
"ExecutionPlanningAgent",
"KnowledgeAskAgent",
"OrchestratorAgent",
"PdfCreateAgent",
"PdfEditAgent",
+134
View File
@@ -0,0 +1,134 @@
"""Grounded Q&A over the caller's stored documents.
Retrieval runs the same cross-collection search as ``POST /documents/search``;
one smart-model pass then answers only from the retrieved passages, citing
document and page inline. No retrieval hit means a plain "not found" answer.
"""
from __future__ import annotations
import logging
from pydantic import Field
from pydantic_ai import Agent
from stirling.agents.output_mode import output_retries, structured_output
from stirling.contracts import AskDocumentsRequest, AskDocumentsResponse, DocumentPassage
from stirling.documents import CollectionSearchHit
from stirling.documents.service import PAGE_NUMBER_METADATA_KEY
from stirling.models import ApiModel, PrincipalId
from stirling.services import AppRuntime
logger = logging.getLogger(__name__)
# Metadata keys written by docparse rag-ingest (_chunk_metadata) for structure-aware chunks.
_PAGE_START_KEY = "page_start"
_PAGE_END_KEY = "page_end"
_HEADING_PATH_KEY = "heading_path"
_HEADING_PATH_SEPARATOR = " > "
_SYSTEM_PROMPT = (
"You answer questions using ONLY the numbered passages you are given.\n"
"\n"
"Rules:\n"
"- Every statement must come from the passages. Never use outside knowledge, never guess.\n"
"- Cite the document and page inline right after each fact, "
'e.g. "(invoice.pdf p.2)" or "(report.pdf p.4-6)", using the names and pages '
"shown in each passage header.\n"
"- If the passages do not answer the question, say plainly that the stored "
"documents do not cover it. Do not attempt a partial guess.\n"
"- Answer in the same language as the question."
)
_NO_PASSAGES_ANSWER = "I couldn't find anything relevant to that question in your stored documents."
class _AskOutput(ApiModel):
"""Raw model answer for the single ask pass."""
answer: str = Field(description="The answer grounded in the passages, with inline citations.")
def _meta_int(value: str | None) -> int | None:
if value is None:
return None
try:
return int(value)
except ValueError:
return None
def passage_from_hit(hit: CollectionSearchHit) -> DocumentPassage:
"""Map a store search hit onto the wire passage shape.
Docparse chunks carry page bounds and a heading path; plain page-text
chunks only carry ``page_number``, which maps to both bounds.
"""
meta = hit.result.document.metadata
page_start = _meta_int(meta.get(_PAGE_START_KEY))
page_end = _meta_int(meta.get(_PAGE_END_KEY))
if page_start is None and page_end is None:
page_start = page_end = _meta_int(meta.get(PAGE_NUMBER_METADATA_KEY))
heading = meta.get(_HEADING_PATH_KEY)
source = meta.get("source")
if source and ":page:" in source:
# Page-text chunk sources look like "report.pdf:page:3"; show the file name.
source = source.rsplit(":page:", 1)[0]
return DocumentPassage(
document_id=hit.collection,
text=hit.result.document.text,
score=hit.result.score,
page_start=page_start,
page_end=page_end,
heading_path=heading.split(_HEADING_PATH_SEPARATOR) if heading else [],
source=source or None,
)
def format_passages(passages: list[DocumentPassage]) -> str:
"""Render passages for the prompt with the citation handle in each header."""
return "\n\n".join(_format_passage(i, passage) for i, passage in enumerate(passages, 1))
def _format_passage(index: int, passage: DocumentPassage) -> str:
name = passage.source or passage.document_id
if passage.page_start is None:
pages = ""
elif passage.page_end is not None and passage.page_end != passage.page_start:
pages = f" p.{passage.page_start}-{passage.page_end}"
else:
pages = f" p.{passage.page_start}"
return f"[Passage {index} | {name}{pages}]\n{passage.text}"
class KnowledgeAskAgent:
"""Answers a question from the caller's stored documents.
Retrieves the top passages the caller can read (same path as the search
endpoint), then runs one smart-model pass over just those passages.
"""
def __init__(self, runtime: AppRuntime) -> None:
self.runtime = runtime
# Ollama/custom block tool-calling under native json-schema output; see agents.output_mode.
provider = runtime.settings.chat_provider
self._agent: Agent[None, _AskOutput] = Agent(
model=runtime.smart_model,
output_type=structured_output([_AskOutput], chat_provider=provider),
system_prompt=_SYSTEM_PROMPT,
model_settings=runtime.smart_model_settings,
retries=output_retries(provider),
)
async def ask(self, request: AskDocumentsRequest, principals: list[PrincipalId]) -> AskDocumentsResponse:
hits = await self.runtime.documents.search_with_collections(
request.question, principals=principals, top_k=request.top_k
)
passages = [passage_from_hit(hit) for hit in hits]
if not passages:
logger.info("[knowledge-ask] question=%r -> 0 passages", request.question)
return AskDocumentsResponse(answer=_NO_PASSAGES_ANSWER, passages=[])
prompt = f"Question: {request.question}\n\nPassages:\n{format_passages(passages)}"
logger.debug("[knowledge-ask] prompt:\n%s", prompt)
result = await self._agent.run(prompt)
return AskDocumentsResponse(answer=result.output.answer, passages=passages)
+10
View File
@@ -10,6 +10,7 @@ from pydantic_ai.models import Model
from stirling.agents import (
DocumentClassifierAgent,
ExecutionPlanningAgent,
KnowledgeAskAgent,
OrchestratorAgent,
PdfEditAgent,
PdfQuestionAgent,
@@ -18,6 +19,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, SmartSplitAgent, SuggestSchemaAgent
from stirling.documents import DocumentService, EmbeddingService
from stirling.services import AppRuntime, build_runtime
@@ -35,6 +37,10 @@ class AppState:
math_auditor_agent: MathAuditorAgent
pdf_comment_agent: PdfCommentAgent
document_classifier_agent: DocumentClassifierAgent
knowledge_ask_agent: KnowledgeAskAgent
extract_fields_agent: ExtractFieldsAgent
smart_split_agent: SmartSplitAgent
suggest_schema_agent: SuggestSchemaAgent
def build_app_state(
@@ -63,6 +69,10 @@ def build_app_state(
math_auditor_agent=MathAuditorAgent(runtime),
pdf_comment_agent=PdfCommentAgent(runtime),
document_classifier_agent=DocumentClassifierAgent(runtime),
knowledge_ask_agent=KnowledgeAskAgent(runtime),
extract_fields_agent=ExtractFieldsAgent(runtime),
smart_split_agent=SmartSplitAgent(runtime),
suggest_schema_agent=SuggestSchemaAgent(runtime),
)
+18
View File
@@ -7,6 +7,7 @@ from fastapi import Depends, HTTPException, Request, status
from stirling.agents import (
DocumentClassifierAgent,
ExecutionPlanningAgent,
KnowledgeAskAgent,
OrchestratorAgent,
PdfEditAgent,
PdfQuestionAgent,
@@ -15,6 +16,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, SmartSplitAgent, SuggestSchemaAgent
from stirling.documents import DocumentService
from stirling.models import UserId
from stirling.services import AppRuntime, current_user_id
@@ -60,6 +62,22 @@ def get_document_classifier_agent(request: Request) -> DocumentClassifierAgent:
return request.app.state.document_classifier_agent
def get_knowledge_ask_agent(request: Request) -> KnowledgeAskAgent:
return request.app.state.knowledge_ask_agent
def get_extract_fields_agent(request: Request) -> ExtractFieldsAgent:
return request.app.state.extract_fields_agent
def get_smart_split_agent(request: Request) -> SmartSplitAgent:
return request.app.state.smart_split_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.
+127 -7
View File
@@ -1,9 +1,9 @@
"""DocParse routes: parse, tables, rag-ingest, capabilities.
"""DocParse routes: parse, extract, split, chunk, tables, fill, 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.
Tier routing happens here: 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
@@ -16,22 +16,42 @@ from typing import Annotated
import anyio.to_thread
from fastapi import APIRouter, Depends, HTTPException, status
from stirling.api.dependencies import get_document_service, require_user_id
from stirling.api.dependencies import (
get_document_service,
get_extract_fields_agent,
get_smart_split_agent,
get_suggest_schema_agent,
require_user_id,
)
from stirling.config import AppSettings, load_settings
from stirling.contracts.docparse import (
ChunkDocumentRequest,
ChunkDocumentResponse,
DocChunk,
DocparseCapabilities,
DocparseMode,
DocparseTier,
ExtractFieldsRequest,
ExtractFieldsResponse,
ExtractTablesRequest,
ExtractTablesResponse,
FillDocxRequest,
FillDocxResponse,
ParseDocumentRequest,
ParseDocumentResponse,
RagIngestRequest,
RagIngestResponse,
SmartSplitRequest,
SmartSplitResponse,
SuggestSchemaRequest,
SuggestSchemaResponse,
)
from stirling.docparse import basic_chunks, probe_capabilities
from stirling.docparse import basic_chunks, fill_docx, 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.splitter import SmartSplitAgent
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
@@ -101,6 +121,96 @@ async def parse_document(request: ParseDocumentRequest) -> ParseDocumentResponse
)
@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)
@router.post("/split", response_model=SmartSplitResponse)
async def smart_split(
request: SmartSplitRequest,
agent: Annotated[SmartSplitAgent, Depends(get_smart_split_agent)],
) -> SmartSplitResponse:
return await agent.split(request)
@router.post("/chunk", response_model=ChunkDocumentResponse)
async def chunk_document(request: ChunkDocumentRequest) -> ChunkDocumentResponse:
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
)
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)
return advanced_chunks(parse, request.chunk_size, request.overlap)
if not request.pages:
raise HTTPException(
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
detail="send pages (extracted text) or contentBase64 with the addon installed",
)
return basic_chunks(request.pages, request.chunk_size, request.overlap)
def _chunk_metadata(chunk: DocChunk) -> dict[str, str]:
meta = {CONTENT_TYPE_METADATA_KEY: DOCPARSE_CHUNK_CONTENT_TYPE}
if chunk.page_start is not None:
@@ -192,3 +302,13 @@ async def extract_tables(request: ExtractTablesRequest) -> ExtractTablesResponse
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)
@router.post("/fill-docx", response_model=FillDocxResponse)
async def fill_docx_template(request: FillDocxRequest) -> FillDocxResponse:
try:
return await anyio.to_thread.run_sync(lambda: fill_docx(request))
except (KeyError, ValueError) as error:
raise HTTPException(
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, detail=f"invalid docx template: {error}"
) from error
+77 -2
View File
@@ -5,13 +5,24 @@ from typing import Annotated
from fastapi import APIRouter, Depends
from stirling.api.dependencies import get_document_service, require_user_id
from stirling.agents.knowledge_ask import KnowledgeAskAgent, passage_from_hit
from stirling.api.dependencies import get_document_service, get_knowledge_ask_agent, require_user_id
from stirling.config import load_settings
from stirling.contracts import (
DeleteDocumentResponse,
IngestDocumentRequest,
IngestDocumentResponse,
)
from stirling.contracts.documents import PurgeOwnerResponse
from stirling.contracts.documents import (
AskDocumentsRequest,
AskDocumentsResponse,
DocumentStatsResponse,
DocumentSummary,
ListDocumentsResponse,
PurgeOwnerResponse,
SearchDocumentsRequest,
SearchDocumentsResponse,
)
from stirling.documents import DocumentService
from stirling.models import FileId, OwnerId, PrincipalId, UserId
@@ -45,6 +56,70 @@ async def ingest_document(
return IngestDocumentResponse(document_id=request.document_id, chunks_indexed=chunks_indexed)
@router.get("/stats", response_model=DocumentStatsResponse)
async def document_stats(
documents: Annotated[DocumentService, Depends(get_document_service)],
_user_id: Annotated[UserId, Depends(require_user_id)],
) -> DocumentStatsResponse:
"""Deployment-wide store counts for the admin dashboard.
Not tenant-filtered: counts cover every owner's content, so this reports
what the whole store holds, not what the caller can read.
"""
settings = load_settings()
counts = await documents.stats()
return DocumentStatsResponse(
backend=settings.documents_backend.value,
documents=counts.documents,
chunks=counts.chunks,
embedding_model=settings.rag_embedding_model,
)
@router.get("/list", response_model=ListDocumentsResponse)
async def list_documents(
documents: Annotated[DocumentService, Depends(get_document_service)],
user_id: Annotated[UserId, Depends(require_user_id)],
) -> ListDocumentsResponse:
"""Per-document rollup of what the caller can read: distinct document ids
with their stored source label and chunk count. Never shows another
principal's documents.
"""
summaries = await documents.list_documents([PrincipalId(user_id)])
return ListDocumentsResponse(
documents=[
DocumentSummary(document_id=FileId(s.collection), source=s.source, chunks=s.chunks) for s in summaries
]
)
@router.post("/search", response_model=SearchDocumentsResponse)
async def search_documents(
request: SearchDocumentsRequest,
documents: Annotated[DocumentService, Depends(get_document_service)],
user_id: Annotated[UserId, Depends(require_user_id)],
) -> SearchDocumentsResponse:
"""Semantic search across every document the caller can read.
Same retrieval path as the RAG toolset: embed the query, search the
caller's readable collections, merge by score.
"""
hits = await documents.search_with_collections(
request.query, principals=[PrincipalId(user_id)], top_k=request.top_k
)
return SearchDocumentsResponse(passages=[passage_from_hit(hit) for hit in hits])
@router.post("/ask", response_model=AskDocumentsResponse)
async def ask_documents(
request: AskDocumentsRequest,
agent: Annotated[KnowledgeAskAgent, Depends(get_knowledge_ask_agent)],
user_id: Annotated[UserId, Depends(require_user_id)],
) -> AskDocumentsResponse:
"""Answer a question from the caller's stored documents with inline citations."""
return await agent.ask(request, principals=[PrincipalId(user_id)])
@router.delete("/by-id/{document_id}", response_model=DeleteDocumentResponse)
async def delete_document(
document_id: FileId,
+82 -8
View File
@@ -42,6 +42,36 @@ from .contradiction import (
ContradictionReport,
ContradictionSeverity,
)
from .docparse import (
BlockType,
ChunkDocumentRequest,
ChunkDocumentResponse,
DocBlock,
DocChunk,
DocparseCapabilities,
DocparseMode,
DocparseTier,
DocTable,
ExtractedField,
ExtractFieldsRequest,
ExtractFieldsResponse,
ExtractTablesRequest,
ExtractTablesResponse,
FieldCitation,
FillDocxRequest,
FillDocxResponse,
ParseDocumentRequest,
ParseDocumentResponse,
RagIngestRequest,
RagIngestResponse,
SmartSplitRequest,
SmartSplitResponse,
SplitPart,
SuggestedField,
SuggestedFieldType,
SuggestSchemaRequest,
SuggestSchemaResponse,
)
from .document_classifier import (
ClassifyDocumentRequest,
ClassifyDocumentResponse,
@@ -49,13 +79,21 @@ from .document_classifier import (
LabelOption,
)
from .documents import (
AskDocumentsRequest,
AskDocumentsResponse,
DeleteDocumentResponse,
DocumentPassage,
DocumentStatsResponse,
DocumentSummary,
IngestDocumentRequest,
IngestDocumentResponse,
ListDocumentsResponse,
Page,
PageRange,
PageText,
PurgeOwnerResponse,
SearchDocumentsRequest,
SearchDocumentsResponse,
)
from .execution import (
AgentExecutionRequest,
@@ -140,10 +178,15 @@ __all__ = [
"AiFile",
"AiToolAgentStep",
"ArtifactKind",
"AskDocumentsRequest",
"AskDocumentsResponse",
"BlockType",
"CannotContinueExecutionAction",
"ChunkDocumentRequest",
"ChunkDocumentResponse",
"Claim",
"ClassifyDocumentRequest",
"ClassifyDocumentResponse",
"Claim",
"CommentSpec",
"CompletedExecutionAction",
"ConfigApplyResponse",
@@ -156,30 +199,45 @@ __all__ = [
"ContradictionSeverity",
"ConversationMessage",
"DeleteDocumentResponse",
"PurgeOwnerResponse",
"Discrepancy",
"DocumentClassificationResponse",
"LabelOption",
"DocumentMeta",
"DocumentSections",
"DiscrepancyKind",
"DocBlock",
"DocChunk",
"DocTable",
"DocparseCapabilities",
"DocparseMode",
"DocparseTier",
"DocumentClassificationResponse",
"DocumentMeta",
"DocumentPassage",
"DocumentSections",
"DocumentStatsResponse",
"DocumentSummary",
"EditCannotDoResponse",
"EditClarificationRequest",
"EditPlanResponse",
"Evidence",
"ExecutionContext",
"ExecutionStepResult",
"ExtractFieldsRequest",
"ExtractFieldsResponse",
"ExtractTablesRequest",
"ExtractTablesResponse",
"ExtractedField",
"ExtractedFileText",
"ExtractedTextArtifact",
"FieldCitation",
"FillDocxRequest",
"FillDocxResponse",
"Folio",
"FolioManifest",
"FolioType",
"format_conversation_history",
"format_file_names",
"GenerateFileResponse",
"HealthResponse",
"IngestDocumentRequest",
"IngestDocumentResponse",
"LabelOption",
"ListDocumentsResponse",
"MathAuditorToolReportArtifact",
"NeedContentFileRequest",
"NeedContentResponse",
@@ -190,6 +248,8 @@ __all__ = [
"Page",
"PageRange",
"PageText",
"ParseDocumentRequest",
"ParseDocumentResponse",
"PdfCommentInstruction",
"PdfCommentReport",
"PdfCommentRequest",
@@ -212,9 +272,21 @@ __all__ = [
"PdfReviewOrchestrateResponse",
"PdfTextSelection",
"ProgressEvent",
"PurgeOwnerResponse",
"RagIngestRequest",
"RagIngestResponse",
"Requisition",
"SearchDocumentsRequest",
"SearchDocumentsResponse",
"Severity",
"SmartSplitRequest",
"SmartSplitResponse",
"SplitPart",
"StepKind",
"SuggestSchemaRequest",
"SuggestSchemaResponse",
"SuggestedField",
"SuggestedFieldType",
"SupportedCapability",
"TextChunk",
"ToolCallExecutionAction",
@@ -228,4 +300,6 @@ __all__ = [
"WholeDocSliceDone",
"WorkflowArtifact",
"WorkflowOutcome",
"format_conversation_history",
"format_file_names",
]
+107 -1
View File
@@ -10,7 +10,7 @@ from __future__ import annotations
from datetime import datetime
from enum import StrEnum
from pydantic import Field
from pydantic import Field, JsonValue
from stirling.contracts.documents import PageText
from stirling.models import ApiModel, FileId, OwnerId, PrincipalId
@@ -140,6 +140,112 @@ class RagIngestResponse(ApiModel):
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 SplitPart(ApiModel):
start_page: int = Field(ge=1)
end_page: int = Field(ge=1)
label: str
confidence: float = Field(ge=0.0, le=1.0)
class SmartSplitRequest(ApiModel):
file_name: str = Field(min_length=1)
rule: str = Field(
min_length=1, description="Natural-language boundary rule, e.g. 'split where a new invoice starts'."
)
pages: list[PageText]
max_parts: int = Field(default=50, ge=1, le=500)
class SmartSplitResponse(ApiModel):
parts: list[SplitPart] = Field(default_factory=list)
class ChunkDocumentRequest(ApiModel):
file_name: str = Field(min_length=1)
pages: list[PageText] | None = None
content_base64: str | None = None
chunk_size: int = Field(default=512, ge=64, le=32_768)
overlap: int = Field(default=64, ge=0, le=4_096)
mode: DocparseMode = DocparseMode.AUTO
class FillDocxRequest(ApiModel):
template_base64: str = Field(min_length=1)
data: dict[str, JsonValue]
class FillDocxResponse(ApiModel):
docx_base64: str
replaced: int = Field(ge=0)
missing: list[str] = Field(default_factory=list)
class DocparseCapabilities(ApiModel):
"""What the engine can actually do right now; Java caches and republishes this."""
@@ -75,3 +75,65 @@ class PurgeOwnerResponse(ApiModel):
owner_id: OwnerId
deleted: int = Field(ge=0)
class DocumentStatsResponse(ApiModel):
"""Returned by ``GET /api/v1/documents/stats``. Deployment-wide counts
(every owner's content) powering the admin dashboard."""
backend: str
documents: int = Field(ge=0)
chunks: int = Field(ge=0)
embedding_model: str
class DocumentSummary(ApiModel):
"""One stored document the caller can read: its id, source label, chunk count."""
document_id: FileId
source: str
chunks: int = Field(ge=0)
class ListDocumentsResponse(ApiModel):
"""Returned by ``GET /api/v1/documents/list``. Caller-scoped rollup."""
documents: list[DocumentSummary]
class SearchDocumentsRequest(ApiModel):
"""Semantic search over every document the caller can read."""
query: str = Field(min_length=1)
top_k: int = Field(default=8, ge=1, le=50)
class DocumentPassage(ApiModel):
"""A retrieved chunk on the wire. Page bounds and heading path come from
chunk metadata when present (docparse chunks carry them); nulls otherwise."""
document_id: FileId
text: str
score: float
page_start: int | None = None
page_end: int | None = None
heading_path: list[str] = Field(default_factory=list)
source: str | None = None
class SearchDocumentsResponse(ApiModel):
passages: list[DocumentPassage]
class AskDocumentsRequest(ApiModel):
"""Question answered only from the caller's stored documents."""
question: str = Field(min_length=1)
top_k: int = Field(default=8, ge=1, le=20)
class AskDocumentsResponse(ApiModel):
"""Grounded answer with inline citations plus the passages it drew from."""
answer: str
passages: list[DocumentPassage]
+8
View File
@@ -8,10 +8,18 @@ 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.docxfill import fill_docx
from stirling.docparse.extractor import ExtractFieldsAgent
from stirling.docparse.splitter import SmartSplitAgent
from stirling.docparse.suggest_schema import SuggestSchemaAgent
__all__ = [
"ExtractFieldsAgent",
"SmartSplitAgent",
"SuggestSchemaAgent",
"activate_site",
"advanced_chunks",
"basic_chunks",
"fill_docx",
"probe_capabilities",
]
+178
View File
@@ -0,0 +1,178 @@
"""Fill DOCX templates from JSON data: ``{{ dotted.path }}`` placeholders.
Scalar placeholders are replaced everywhere (body, tables, headers, footers).
A table row whose text contains ``{{#items.field}}`` markers is treated as a
row template: it is cloned once per element of the ``items`` array. Unresolved
placeholders are left in place and reported back so the caller can surface them.
Formatting caveat: a placeholder split across styled runs collapses that
paragraph's text into its first run's style.
"""
from __future__ import annotations
import base64
import copy
import io
import re
from typing import Any
from pydantic import JsonValue
from stirling.contracts.docparse import FillDocxRequest, FillDocxResponse
_PLACEHOLDER = re.compile(r"\{\{\s*(#?[\w.]+)\s*\}\}")
def _resolve(path: str, data: dict[str, Any]) -> Any | None:
node: Any = data
for part in path.split("."):
if isinstance(node, dict) and part in node:
node = node[part]
else:
return None
return node
def _render_scalar(value: Any) -> str:
if value is None:
return ""
if isinstance(value, bool):
return "true" if value else "false"
if isinstance(value, list):
return ", ".join(_render_scalar(v) for v in value)
return str(value)
class _Stats:
def __init__(self) -> None:
self.replaced = 0
self.missing: set[str] = set()
def _fill_paragraph(paragraph: Any, data: dict[str, Any], stats: _Stats) -> None:
text = paragraph.text
if "{{" not in text:
return
def substitute(match: re.Match[str]) -> str:
path = match.group(1)
if path.startswith("#"):
return match.group(0) # row-template marker, handled at table level
value = _resolve(path, data)
if value is None:
stats.missing.add(path)
return match.group(0)
stats.replaced += 1
return _render_scalar(value)
rendered = _PLACEHOLDER.sub(substitute, text)
if rendered == text:
return
# Collapse into the first run to survive placeholders split across runs.
if paragraph.runs:
paragraph.runs[0].text = rendered
for run in paragraph.runs[1:]:
run.text = ""
else:
paragraph.add_run(rendered)
def _row_template_array(row: Any) -> str | None:
"""Return the array name when the row carries ``{{#name.field}}`` markers."""
names = {
match.group(1)[1:].split(".")[0]
for cell in row.cells
for match in _PLACEHOLDER.finditer(cell.text)
if match.group(1).startswith("#")
}
return names.pop() if len(names) == 1 else None
def _fill_table(table: Any, data: dict[str, Any], stats: _Stats) -> None:
for row in list(table.rows):
array_name = _row_template_array(row)
if array_name is None:
continue
items = _resolve(array_name, data)
if not isinstance(items, list):
stats.missing.add(array_name)
continue
for _ in items:
new_row = copy.deepcopy(row._tr)
row._tr.addprevious(new_row)
# Clones sit before the template; rewrite their markers, then drop the template.
_rewrite_cloned_rows(table, row, array_name, items, data, stats)
row._tr.getparent().remove(row._tr)
def _rewrite_cloned_rows(
table: Any, template_row: Any, array_name: str, items: list[Any], data: dict[str, Any], stats: _Stats
) -> None:
marker_prefix = f"#{array_name}"
clones = [
r for r in table.rows if r._tr is not template_row._tr and marker_prefix in "".join(c.text for c in r.cells)
]
for row, item in zip(clones, items, strict=False):
scoped = dict(data)
scoped[array_name] = item if isinstance(item, dict) else {"value": item}
for cell in row.cells:
for paragraph in cell.paragraphs:
text = paragraph.text
def substitute(match: re.Match[str]) -> str:
path = match.group(1)
if not path.startswith(marker_prefix):
return match.group(0)
item_path = path[1:] # "#items.field" -> "items.field"
value = _resolve(item_path, scoped)
if value is None and "." not in item_path:
value = scoped[array_name].get("value") if isinstance(scoped[array_name], dict) else None
if value is None:
stats.missing.add(item_path)
return match.group(0)
stats.replaced += 1
return _render_scalar(value)
rendered = _PLACEHOLDER.sub(substitute, text)
if rendered != text:
if paragraph.runs:
paragraph.runs[0].text = rendered
for run in paragraph.runs[1:]:
run.text = ""
else:
paragraph.add_run(rendered)
def _walk_paragraphs(document: Any) -> list[tuple[Any, Any]]:
"""Yield (paragraph, containing table or None) across body, tables, headers, footers."""
found: list[tuple[Any, Any]] = [(p, None) for p in document.paragraphs]
for table in document.tables:
for row in table.rows:
for cell in row.cells:
found.extend((p, table) for p in cell.paragraphs)
for section in document.sections:
for part in (section.header, section.footer):
found.extend((p, None) for p in part.paragraphs)
return found
def fill_docx(request: FillDocxRequest) -> FillDocxResponse:
import docx # local import: python-docx is small but only needed here
data: dict[str, JsonValue] = dict(request.data)
document = docx.Document(io.BytesIO(base64.b64decode(request.template_base64)))
stats = _Stats()
for table in document.tables:
_fill_table(table, data, stats)
for paragraph, _table in _walk_paragraphs(document):
_fill_paragraph(paragraph, data, stats)
out = io.BytesIO()
document.save(out)
return FillDocxResponse(
docx_base64=base64.b64encode(out.getvalue()).decode("ascii"),
replaced=stats.replaced,
missing=sorted(stats.missing),
)
+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)
+105
View File
@@ -0,0 +1,105 @@
"""Content-based document splitting: an LLM finds sub-document boundaries.
Works entirely from caller-supplied page text (basic tier friendly); the fast
model sees a bounded per-page preview and answers with boundary start pages,
which are then validated in code (monotonic, in range, capped)."""
from __future__ import annotations
import logging
from pydantic import Field
from pydantic_ai import Agent
from stirling.agents.output_mode import output_retries, structured_output
from stirling.contracts.docparse import SmartSplitRequest, SmartSplitResponse, SplitPart
from stirling.contracts.documents import PageText
from stirling.models import ApiModel
from stirling.services import AppRuntime
logger = logging.getLogger(__name__)
# Per-page preview budget; boundaries are recognisable from page openings.
PAGE_PREVIEW_CHARS = 600
_SYSTEM_PROMPT = (
"You split a multi-document file into its component documents.\n"
"\n"
"You are shown the beginning of every page. Apply the user's splitting rule and "
"answer with every page where a NEW component document starts.\n"
"Rules:\n"
"- Page 1 always starts the first component.\n"
"- Give each component a short descriptive label (e.g. 'Invoice #4821', 'Cover letter').\n"
"- Give your confidence 0.0-1.0 per boundary.\n"
"- If the rule doesn't match anything, return just the page-1 component spanning the whole file."
)
class _Boundary(ApiModel):
start_page: int = Field(ge=1, description="First page of this component document.")
label: str = Field(description="Short human label for the component.")
confidence: float = Field(ge=0.0, le=1.0)
class _SplitOutput(ApiModel):
boundaries: list[_Boundary] = Field(default_factory=list)
def _format_pages(pages: list[PageText], max_pages: int) -> str:
shown = pages[:max_pages]
parts = [f"[Page {p.page_number}] {p.text[:PAGE_PREVIEW_CHARS]}" for p in shown]
if len(pages) > max_pages:
parts.append(f"({len(pages) - max_pages} further pages omitted)")
return "\n\n".join(parts) if parts else "(no extractable text)"
def validate_boundaries(output: _SplitOutput, page_count: int, max_parts: int) -> list[SplitPart]:
"""Coerce the model's boundaries into a clean, complete partition of 1..page_count."""
starts: dict[int, _Boundary] = {}
for boundary in output.boundaries:
if 1 <= boundary.start_page <= page_count and boundary.start_page not in starts:
starts[boundary.start_page] = boundary
if 1 not in starts:
starts[1] = _Boundary(start_page=1, label="Document", confidence=1.0)
ordered = [starts[k] for k in sorted(starts)][:max_parts]
parts: list[SplitPart] = []
for i, boundary in enumerate(ordered):
end_page = ordered[i + 1].start_page - 1 if i + 1 < len(ordered) else page_count
parts.append(
SplitPart(
start_page=boundary.start_page,
end_page=end_page,
label=boundary.label.strip() or f"Part {i + 1}",
confidence=round(boundary.confidence, 4),
)
)
return parts
class SmartSplitAgent:
def __init__(self, runtime: AppRuntime) -> None:
self.runtime = runtime
provider = runtime.settings.chat_provider
self._agent: Agent[None, _SplitOutput] = Agent(
model=runtime.fast_model,
output_type=structured_output([_SplitOutput], chat_provider=provider),
system_prompt=_SYSTEM_PROMPT,
model_settings=runtime.fast_model_settings,
retries=output_retries(provider),
)
async def split(self, request: SmartSplitRequest) -> SmartSplitResponse:
pages = request.pages
if not pages:
return SmartSplitResponse(parts=[])
page_count = max(p.page_number for p in pages)
prompt = (
f"Splitting rule: {request.rule}\n\n"
f"Document file name: {request.file_name}\n"
f"Pages:\n{_format_pages(pages, self.runtime.settings.max_pages)}"
)
result = await self._agent.run(prompt)
parts = validate_boundaries(result.output, page_count, request.max_parts)
logger.info("docparse: split %s into %d parts", request.file_name, len(parts))
return SmartSplitResponse(parts=parts)
@@ -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)
+12 -2
View File
@@ -3,11 +3,20 @@ from __future__ import annotations
from stirling.documents.embedder import EmbeddingService
from stirling.documents.pgvector_store import PgVectorStore
from stirling.documents.rag_capability import RagCapability
from stirling.documents.service import DocumentService
from stirling.documents.service import CollectionSearchHit, DocumentService
from stirling.documents.sqlite_vec_store import SqliteVecStore
from stirling.documents.store import Document, DocumentStore, SearchResult, StoredPage
from stirling.documents.store import (
CollectionSummary,
Document,
DocumentStore,
SearchResult,
StoredPage,
StoreStats,
)
__all__ = [
"CollectionSearchHit",
"CollectionSummary",
"Document",
"DocumentService",
"DocumentStore",
@@ -16,5 +25,6 @@ __all__ = [
"RagCapability",
"SearchResult",
"SqliteVecStore",
"StoreStats",
"StoredPage",
]
@@ -10,7 +10,14 @@ from pgvector.psycopg import register_vector_async
from psycopg_pool import AsyncConnectionPool
from stirling.contracts.documents import Page, PageRange
from stirling.documents.store import Document, DocumentStore, SearchResult, StoredPage
from stirling.documents.store import (
CollectionSummary,
Document,
DocumentStore,
SearchResult,
StoredPage,
StoreStats,
)
from stirling.models import OwnerId, PrincipalId
_READ_PERMISSION = "read"
@@ -411,5 +418,44 @@ class PgVectorStore(DocumentStore):
rows = await cur.fetchall()
return [r[0] for r in rows]
async def list_collection_summaries(self, principals: list[PrincipalId]) -> list[CollectionSummary]:
if not principals:
return []
await self._ensure_ready()
async with self._pool.connection() as conn:
async with conn.cursor() as cur:
# MIN(owner_id) mirrors _readable_owner_for's ORDER BY owner_id LIMIT 1.
await cur.execute(
"""
SELECT r.collection, m.source, COUNT(d.id)
FROM (
SELECT collection, MIN(owner_id) AS owner_id
FROM document_acl
WHERE permission = %s AND principal_id = ANY(%s)
GROUP BY collection
) r
JOIN documents_meta m ON m.collection = r.collection AND m.owner_id = r.owner_id
LEFT JOIN rag_documents d ON d.collection = r.collection AND d.owner_id = r.owner_id
GROUP BY r.collection, m.source
ORDER BY r.collection
""",
(_READ_PERMISSION, list(principals)),
)
rows = await cur.fetchall()
return [CollectionSummary(collection=r[0], source=r[1], chunks=int(r[2])) for r in rows]
async def stats(self) -> StoreStats:
await self._ensure_ready()
async with self._pool.connection() as conn:
async with conn.cursor() as cur:
await cur.execute("SELECT COUNT(DISTINCT collection) FROM documents_meta")
doc_row = await cur.fetchone()
await cur.execute("SELECT COUNT(*) FROM rag_documents")
chunk_row = await cur.fetchone()
return StoreStats(
documents=int(doc_row[0]) if doc_row else 0,
chunks=int(chunk_row[0]) if chunk_row else 0,
)
async def close(self) -> None:
await self._pool.close()
+52 -1
View File
@@ -1,15 +1,32 @@
from __future__ import annotations
import logging
from dataclasses import dataclass
from datetime import datetime
from stirling.contracts.documents import Page, PageRange, PageText
from stirling.documents.embedder import EmbeddingService
from stirling.documents.store import Document, DocumentStore, SearchResult, StoredPage
from stirling.documents.store import (
CollectionSummary,
Document,
DocumentStore,
SearchResult,
StoredPage,
StoreStats,
)
from stirling.models import FileId, OwnerId, PrincipalId
logger = logging.getLogger(__name__)
@dataclass(frozen=True)
class CollectionSearchHit:
"""A search result tagged with the collection it came from."""
collection: FileId
result: SearchResult
PAGE_NUMBER_METADATA_KEY = "page_number"
CONTENT_TYPE_METADATA_KEY = "content_type"
PAGE_TEXT_CONTENT_TYPE = "page_text"
@@ -187,6 +204,32 @@ class DocumentService:
all_results.sort(key=lambda r: r.score, reverse=True)
return all_results[:k]
async def search_with_collections(
self,
query: str,
principals: list[PrincipalId],
top_k: int | None = None,
) -> list[CollectionSearchHit]:
"""Cross-collection search like :meth:`search`, but every result keeps
the collection it came from. Restricted to what ``principals`` can read.
"""
k = top_k if top_k is not None else self._default_top_k
query_embedding = await self._embedder.embed_query(query)
hits: list[CollectionSearchHit] = []
for col_name in await self._store.list_collections(principals):
try:
results = await self._store.search(col_name, query_embedding, k, principals)
except Exception: # noqa: BLE001 - any backend error on one collection should not stop the others
logger.warning(
"Skipping collection %s during cross-collection search",
col_name,
exc_info=True,
)
continue
hits.extend(CollectionSearchHit(collection=FileId(col_name), result=r) for r in results)
hits.sort(key=lambda hit: hit.result.score, reverse=True)
return hits[:k]
async def read_pages(
self,
collection: FileId,
@@ -223,6 +266,10 @@ class DocumentService:
"""List collections readable by at least one of ``principals``."""
return [FileId(name) for name in await self._store.list_collections(principals)]
async def list_documents(self, principals: list[PrincipalId]) -> list[CollectionSummary]:
"""Per-document rollup (source, chunk count) readable by ``principals``."""
return await self._store.list_collection_summaries(principals)
async def grant_read(
self,
collection: FileId,
@@ -241,6 +288,10 @@ class DocumentService:
"""Revoke a principal's access on an existing doc."""
await self._store.revoke(collection, owner_id, principal)
async def stats(self) -> StoreStats:
"""Deployment-wide document/chunk counts from the backing store."""
return await self._store.stats()
async def close(self) -> None:
"""Release the underlying store's resources."""
await self._store.close()
@@ -11,7 +11,14 @@ from pathlib import Path
import sqlite_vec
from stirling.contracts.documents import Page, PageRange
from stirling.documents.store import Document, DocumentStore, SearchResult, StoredPage
from stirling.documents.store import (
CollectionSummary,
Document,
DocumentStore,
SearchResult,
StoredPage,
StoreStats,
)
from stirling.models import OwnerId, PrincipalId
_READ_PERMISSION = "read"
@@ -538,6 +545,42 @@ class SqliteVecStore(DocumentStore):
).fetchall()
return [r[0] for r in rows]
async def list_collection_summaries(self, principals: list[PrincipalId]) -> list[CollectionSummary]:
async with self._lock:
return await asyncio.to_thread(self._sync_list_collection_summaries, principals)
def _sync_list_collection_summaries(self, principals: list[PrincipalId]) -> list[CollectionSummary]:
if not principals:
return []
placeholders = ",".join("?" * len(principals))
# MIN(owner_id) mirrors _readable_owner_for's ORDER BY owner_id LIMIT 1.
rows = self._conn.execute(
f"""
SELECT r.collection, m.source, COUNT(d.id)
FROM (
SELECT collection, MIN(owner_id) AS owner_id
FROM document_acl
WHERE permission = ? AND principal_id IN ({placeholders})
GROUP BY collection
) r
JOIN documents_meta m ON m.collection = r.collection AND m.owner_id = r.owner_id
LEFT JOIN documents d ON d.collection = r.collection AND d.owner_id = r.owner_id
GROUP BY r.collection, m.source
ORDER BY r.collection
""",
(_READ_PERMISSION, *principals),
).fetchall()
return [CollectionSummary(collection=r[0], source=r[1], chunks=int(r[2])) for r in rows]
async def stats(self) -> StoreStats:
async with self._lock:
return await asyncio.to_thread(self._sync_stats)
def _sync_stats(self) -> StoreStats:
documents = self._conn.execute("SELECT COUNT(DISTINCT collection) FROM documents_meta").fetchone()[0]
chunks = self._conn.execute("SELECT COUNT(*) FROM documents").fetchone()[0]
return StoreStats(documents=int(documents), chunks=int(chunks))
async def close(self) -> None:
async with self._lock:
await asyncio.to_thread(self._sync_close)
+31
View File
@@ -34,6 +34,23 @@ class StoredPage:
char_count: int
@dataclass
class StoreStats:
"""Deployment-wide counts: distinct document ids and total vector-chunk rows."""
documents: int
chunks: int
@dataclass
class CollectionSummary:
"""Rollup row for one readable collection: stored source label + chunk count."""
collection: str
source: str
chunks: int
class DocumentStore(ABC):
"""Abstract interface for document storage backends.
@@ -148,6 +165,20 @@ class DocumentStore(ABC):
async def list_collections(self, principals: list[PrincipalId]) -> list[str]:
"""Return collection names readable by at least one of ``principals``."""
@abstractmethod
async def list_collection_summaries(self, principals: list[PrincipalId]) -> list[CollectionSummary]:
"""Per-collection rollup (source + chunk count) readable by ``principals``.
Counts cover the same owner's copy a read would resolve to, so the
rollup never leaks another tenant's content.
"""
# ── deployment-wide stats (not tenant-scoped) ──────────────────────────
@abstractmethod
async def stats(self) -> StoreStats:
"""Count every owner's content: distinct document ids + total chunk rows."""
# ── lifecycle ──────────────────────────────────────────────────────────
@abstractmethod
+70
View File
@@ -0,0 +1,70 @@
from __future__ import annotations
import base64
import io
from typing import Any
import docx
from stirling.contracts.docparse import FillDocxRequest
from stirling.docparse.docxfill import fill_docx
def _template_base64() -> str:
document = docx.Document()
document.add_paragraph("Dear {{ customer.name }},")
document.add_paragraph("Your total is {{ total }}.")
document.add_paragraph("Unknown: {{ nowhere.field }}")
table = document.add_table(rows=2, cols=2)
table.rows[0].cells[0].text = "Item"
table.rows[0].cells[1].text = "Price"
table.rows[1].cells[0].text = "{{#items.name}}"
table.rows[1].cells[1].text = "{{#items.price}}"
buffer = io.BytesIO()
document.save(buffer)
return base64.b64encode(buffer.getvalue()).decode("ascii")
def _load(response_base64: str) -> Any:
return docx.Document(io.BytesIO(base64.b64decode(response_base64)))
def test_fills_scalars_tables_and_reports_missing() -> None:
request = FillDocxRequest(
template_base64=_template_base64(),
data={
"customer": {"name": "ACME GmbH"},
"total": 12.5,
"items": [
{"name": "Widget", "price": "2.00"},
{"name": "Gadget", "price": "10.50"},
],
},
)
response = fill_docx(request)
filled = _load(response.docx_base64)
paragraphs = [p.text for p in filled.paragraphs]
assert "Dear ACME GmbH," in paragraphs
assert "Your total is 12.5." in paragraphs
# Unresolved placeholders stay put and are reported.
assert any("{{ nowhere.field }}" in p for p in paragraphs)
assert response.missing == ["nowhere.field"]
table = filled.tables[0]
rendered_rows = [[cell.text for cell in row.cells] for row in table.rows]
assert ["Widget", "2.00"] in rendered_rows
assert ["Gadget", "10.50"] in rendered_rows
# The template row is gone.
assert all("{{#" not in cell for row in rendered_rows for cell in row)
assert response.replaced >= 6
def test_empty_items_removes_template_row() -> None:
request = FillDocxRequest(
template_base64=_template_base64(),
data={"customer": {"name": "X"}, "total": 1, "items": []},
)
response = fill_docx(request)
filled = _load(response.docx_base64)
assert len(filled.tables[0].rows) == 1 # only the header remains
+92
View File
@@ -0,0 +1,92 @@
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,
)
from stirling.docparse.splitter import _Boundary, _SplitOutput, validate_boundaries
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 test_validate_boundaries_partitions_cleanly() -> None:
output = _SplitOutput(
boundaries=[
_Boundary(start_page=4, label="Invoice B", confidence=0.8),
_Boundary(start_page=1, label="Invoice A", confidence=0.9),
_Boundary(start_page=4, label="dup", confidence=0.1),
_Boundary(start_page=99, label="out of range", confidence=0.5),
]
)
parts = validate_boundaries(output, page_count=6, max_parts=10)
assert [(p.start_page, p.end_page) for p in parts] == [(1, 3), (4, 6)]
assert parts[0].label == "Invoice A"
def test_validate_boundaries_inserts_page_one() -> None:
output = _SplitOutput(boundaries=[_Boundary(start_page=3, label="Part", confidence=0.7)])
parts = validate_boundaries(output, page_count=5, max_parts=10)
assert parts[0].start_page == 1
assert parts[1].start_page == 3
assert parts[-1].end_page == 5
def test_validate_boundaries_empty_output_spans_whole_file() -> None:
parts = validate_boundaries(_SplitOutput(), page_count=7, max_parts=10)
assert [(p.start_page, p.end_page) for p in parts] == [(1, 7)]
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
@@ -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
+88 -1
View File
@@ -7,7 +7,7 @@ from stirling.documents.chunker import chunk_text
from stirling.documents.rag_capability import RagCapability
from stirling.documents.service import DocumentService
from stirling.documents.sqlite_vec_store import SqliteVecStore
from stirling.documents.store import Document, SearchResult
from stirling.documents.store import CollectionSummary, Document, SearchResult
from stirling.models import FileId, OwnerId, PrincipalId
# Personal-doc tests reuse the same opaque string in all three roles — keeps the
@@ -178,6 +178,27 @@ class TestSqliteVecStore:
assert await store.list_collections(OWNER_PRINCIPALS) == []
assert await store.list_collections(OTHER_OWNER_PRINCIPALS) == ["c.pdf"]
@pytest.mark.anyio
async def test_stats_count_distinct_document_ids_and_chunk_rows(self) -> None:
"""Stats span every owner; a document id shared by two owners counts once."""
store = SqliteVecStore.ephemeral()
empty = await store.stats()
assert (empty.documents, empty.chunks) == (0, 0)
for owner, principals, name, texts in (
(OWNER, OWNER_PRINCIPALS, "doc-a", ["one", "two"]),
(OWNER, OWNER_PRINCIPALS, "doc-b", ["three"]),
(OTHER_OWNER, OTHER_OWNER_PRINCIPALS, "doc-b", ["four"]),
):
await store.ensure_collection(name, f"{name}.pdf", owner, None)
await store.grant_read(name, owner, principals)
docs = [Document(id=str(i), text=t, metadata={}) for i, t in enumerate(texts)]
await store.add_documents(name, docs, [[1.0, 0.0]] * len(docs), owner)
stats = await store.stats()
assert stats.documents == 2
assert stats.chunks == 4
@pytest.mark.anyio
async def test_reap_expired_drops_collections_past_expires_at(self) -> None:
"""TTL backstop: rows with ``expires_at`` in the past go away on reap."""
@@ -239,6 +260,47 @@ class TestSqliteVecStore:
# Owner still can.
assert await store.has_collection("doc", OWNER_PRINCIPALS) is True
@pytest.mark.anyio
async def test_list_collection_summaries_rolls_up_readable_collections(self) -> None:
"""Rollup: one row per readable collection with its source and chunk count."""
store = SqliteVecStore.ephemeral()
await store.ensure_collection("doc-a", "a.pdf", OWNER, None)
await store.grant_read("doc-a", OWNER, OWNER_PRINCIPALS)
docs = [Document(id="1", text="one", metadata={}), Document(id="2", text="two", metadata={})]
await store.add_documents("doc-a", docs, [[1.0, 0.0], [0.0, 1.0]], OWNER)
# Collection with no vector chunks yet: still listed, zero count.
await store.ensure_collection("doc-b", "b.pdf", OWNER, None)
await store.grant_read("doc-b", OWNER, OWNER_PRINCIPALS)
summaries = await store.list_collection_summaries(OWNER_PRINCIPALS)
assert summaries == [
CollectionSummary(collection="doc-a", source="a.pdf", chunks=2),
CollectionSummary(collection="doc-b", source="b.pdf", chunks=0),
]
@pytest.mark.anyio
async def test_list_collection_summaries_scoped_to_principals(self) -> None:
"""One principal's rollup never lists, or counts, another owner's copy."""
store = SqliteVecStore.ephemeral()
await store.ensure_collection("shared-id", "alice.pdf", OWNER, None)
await store.grant_read("shared-id", OWNER, OWNER_PRINCIPALS)
await store.add_documents("shared-id", [Document(id="1", text="alice", metadata={})], [[1.0, 0.0]], OWNER)
await store.ensure_collection("shared-id", "bob.pdf", OTHER_OWNER, None)
await store.grant_read("shared-id", OTHER_OWNER, OTHER_OWNER_PRINCIPALS)
bob_docs = [Document(id="1", text="bob", metadata={}), Document(id="2", text="bob2", metadata={})]
await store.add_documents("shared-id", bob_docs, [[1.0, 0.0], [0.0, 1.0]], OTHER_OWNER)
await store.ensure_collection("bob-only", "bob-only.pdf", OTHER_OWNER, None)
await store.grant_read("bob-only", OTHER_OWNER, OTHER_OWNER_PRINCIPALS)
assert await store.list_collection_summaries(OWNER_PRINCIPALS) == [
CollectionSummary(collection="shared-id", source="alice.pdf", chunks=1)
]
assert await store.list_collection_summaries(OTHER_OWNER_PRINCIPALS) == [
CollectionSummary(collection="bob-only", source="bob-only.pdf", chunks=0),
CollectionSummary(collection="shared-id", source="bob.pdf", chunks=2),
]
assert await store.list_collection_summaries([]) == []
# DocumentService (with stub embedder)
@@ -398,6 +460,31 @@ class TestDocumentService:
multi_results = await documents.search("deploy", principals=[hr_group, eng_group])
assert len(multi_results) > 0
@pytest.mark.anyio
async def test_search_with_collections_tags_results_and_respects_acl(self, documents: DocumentService) -> None:
"""Collection-tagged search only reaches collections the caller can read."""
await documents.ingest(
FileId("col-a"),
_pages("Alpha content."),
source="a.pdf",
owner_id=OWNER,
read_principals=OWNER_PRINCIPALS,
expires_at=None,
)
await documents.ingest(
FileId("col-b"),
_pages("Beta content."),
source="b.pdf",
owner_id=OTHER_OWNER,
read_principals=OTHER_OWNER_PRINCIPALS,
expires_at=None,
)
hits = await documents.search_with_collections("content", principals=OWNER_PRINCIPALS)
assert hits
assert {hit.collection for hit in hits} == {"col-a"}
assert all(hit.result.document.text for hit in hits)
@pytest.mark.anyio
async def test_delete_collection(self, documents: DocumentService) -> None:
await documents.ingest(
+271 -2
View File
@@ -6,9 +6,10 @@ import pytest
from fastapi.testclient import TestClient
from stirling.api import app
from stirling.api.dependencies import get_document_service
from stirling.api.dependencies import get_document_service, get_knowledge_ask_agent
from stirling.contracts import AskDocumentsRequest, AskDocumentsResponse, DocumentPassage
from stirling.documents import Document, DocumentService, SqliteVecStore
from stirling.models import FileId, PrincipalId, UserId
from stirling.models import FileId, OwnerId, PrincipalId, UserId
USER = UserId("test-user")
USER_PRINCIPALS = [PrincipalId("test-user")]
@@ -348,6 +349,274 @@ def test_purge_by_owner_rejects_missing_user_header(client: TestClient) -> None:
assert response.status_code == 401
# ── GET /documents/list ─────────────────────────────────────────────────
def _ingest(client: TestClient, document_id: str, source: str, texts: list[str], owner: str) -> None:
client.post(
"/api/v1/documents",
json={
"documentId": document_id,
"source": source,
"pageText": [{"pageNumber": i, "text": t} for i, t in enumerate(texts, 1)],
"ownerId": owner,
"readPrincipals": [owner],
"expiresAt": None,
},
headers={"X-User-Id": owner},
)
def test_list_documents_returns_caller_rollup(client: TestClient) -> None:
_ingest(client, "list-a", "a.pdf", ["Page one text.", "Page two text."], USER)
_ingest(client, "list-b", "b.pdf", ["Only page."], USER)
response = client.get("/api/v1/documents/list", headers=HEADERS)
assert response.status_code == 200
documents = response.json()["documents"]
assert [d["documentId"] for d in documents] == ["list-a", "list-b"]
by_id = {d["documentId"]: d for d in documents}
assert by_id["list-a"]["source"] == "a.pdf"
assert by_id["list-a"]["chunks"] >= 2
assert by_id["list-b"]["source"] == "b.pdf"
assert by_id["list-b"]["chunks"] >= 1
def test_list_documents_empty_for_new_user(client: TestClient) -> None:
response = client.get("/api/v1/documents/list", headers=HEADERS)
assert response.status_code == 200
assert response.json() == {"documents": []}
def test_list_documents_hides_other_users_documents(client: TestClient) -> None:
"""User A must never see user B's documents in the rollup."""
_ingest(client, "alice-doc", "alice.pdf", ["alice content"], "alice")
_ingest(client, "bob-doc", "bob.pdf", ["bob content"], "bob")
alice_docs = client.get("/api/v1/documents/list", headers={"X-User-Id": "alice"}).json()["documents"]
bob_docs = client.get("/api/v1/documents/list", headers={"X-User-Id": "bob"}).json()["documents"]
assert [d["documentId"] for d in alice_docs] == ["alice-doc"]
assert [d["documentId"] for d in bob_docs] == ["bob-doc"]
def test_list_documents_rejects_missing_user_header(client: TestClient) -> None:
assert client.get("/api/v1/documents/list").status_code == 401
# ── POST /documents/search ──────────────────────────────────────────────
def test_search_documents_maps_page_text_chunks(client: TestClient) -> None:
"""Plain ingested chunks only carry page_number: both bounds map to it and
the ":page:N" suffix is stripped off the source."""
client.post(
"/api/v1/documents",
json={
"documentId": "report",
"source": "report.pdf",
"pageText": [{"pageNumber": 3, "text": "The launch is planned for October."}],
"ownerId": USER,
"readPrincipals": [USER],
"expiresAt": None,
},
headers=HEADERS,
)
response = client.post("/api/v1/documents/search", json={"query": "launch", "topK": 5}, headers=HEADERS)
assert response.status_code == 200
passages = response.json()["passages"]
assert len(passages) >= 1
passage = passages[0]
assert passage["documentId"] == "report"
assert passage["pageStart"] == 3
assert passage["pageEnd"] == 3
assert passage["headingPath"] == []
assert passage["source"] == "report.pdf"
assert "launch" in passage["text"]
assert isinstance(passage["score"], float)
@pytest.mark.anyio
async def test_search_documents_maps_docparse_chunk_metadata(client: TestClient, service: DocumentService) -> None:
"""Docparse chunks carry page bounds + heading path; they map straight onto the wire."""
await service.ingest_prepared(
collection=FileId("dp-doc"),
chunks=[
(
"Revenue grew 12% in Q2.",
{
"content_type": "docparse_chunk",
"page_start": "2",
"page_end": "3",
"heading_path": "Report > Finance",
},
)
],
source="q2.pdf",
owner_id=OwnerId(USER),
read_principals=USER_PRINCIPALS,
expires_at=None,
)
response = client.post("/api/v1/documents/search", json={"query": "revenue"}, headers=HEADERS)
assert response.status_code == 200
passage = response.json()["passages"][0]
assert passage["documentId"] == "dp-doc"
assert passage["pageStart"] == 2
assert passage["pageEnd"] == 3
assert passage["headingPath"] == ["Report", "Finance"]
assert passage["source"] == "q2.pdf"
def test_search_documents_cannot_see_other_users_documents(client: TestClient) -> None:
"""User B searching for user A's content must get nothing back."""
_ingest(client, "alice-doc", "alice.pdf", ["The secret launch code is October."], "alice")
bob = client.post("/api/v1/documents/search", json={"query": "secret launch"}, headers={"X-User-Id": "bob"})
assert bob.status_code == 200
assert bob.json()["passages"] == []
alice = client.post("/api/v1/documents/search", json={"query": "secret launch"}, headers={"X-User-Id": "alice"})
assert alice.json()["passages"] != []
def test_search_documents_rejects_empty_query(client: TestClient) -> None:
response = client.post("/api/v1/documents/search", json={"query": ""}, headers=HEADERS)
assert response.status_code == 422
def test_search_documents_rejects_top_k_above_cap(client: TestClient) -> None:
response = client.post("/api/v1/documents/search", json={"query": "x", "topK": 51}, headers=HEADERS)
assert response.status_code == 422
def test_search_documents_rejects_missing_user_header(client: TestClient) -> None:
assert client.post("/api/v1/documents/search", json={"query": "x"}).status_code == 401
# ── POST /documents/ask ─────────────────────────────────────────────────
class StubKnowledgeAskAgent:
"""Stands in for KnowledgeAskAgent so route tests don't call a model."""
def __init__(self, response: AskDocumentsResponse) -> None:
self._response = response
self.calls: list[tuple[AskDocumentsRequest, list[PrincipalId]]] = []
async def ask(self, request: AskDocumentsRequest, principals: list[PrincipalId]) -> AskDocumentsResponse:
self.calls.append((request, principals))
return self._response
@pytest.fixture
def ask_agent() -> StubKnowledgeAskAgent:
return StubKnowledgeAskAgent(
AskDocumentsResponse(
answer="Revenue grew 12% (q2.pdf p.2).",
passages=[
DocumentPassage(
document_id=FileId("dp-doc"),
text="Revenue grew 12% in Q2.",
score=0.91,
page_start=2,
page_end=3,
heading_path=["Report", "Finance"],
source="q2.pdf",
)
],
)
)
@pytest.fixture
def ask_client(client: TestClient, ask_agent: StubKnowledgeAskAgent) -> Iterator[TestClient]:
app.dependency_overrides[get_knowledge_ask_agent] = lambda: ask_agent
try:
yield client
finally:
app.dependency_overrides.pop(get_knowledge_ask_agent, None)
def test_ask_documents_returns_answer_and_passages(ask_client: TestClient) -> None:
response = ask_client.post("/api/v1/documents/ask", json={"question": "How did revenue do?"}, headers=HEADERS)
assert response.status_code == 200
body = response.json()
assert body["answer"] == "Revenue grew 12% (q2.pdf p.2)."
assert body["passages"] == [
{
"documentId": "dp-doc",
"text": "Revenue grew 12% in Q2.",
"score": 0.91,
"pageStart": 2,
"pageEnd": 3,
"headingPath": ["Report", "Finance"],
"source": "q2.pdf",
}
]
def test_ask_documents_scopes_to_calling_user(ask_client: TestClient, ask_agent: StubKnowledgeAskAgent) -> None:
"""The route hands the agent exactly the caller's principal set."""
ask_client.post("/api/v1/documents/ask", json={"question": "anything"}, headers=HEADERS)
request, principals = ask_agent.calls[0]
assert principals == [PrincipalId(USER)]
assert request.top_k == 8
def test_ask_documents_rejects_empty_question(ask_client: TestClient) -> None:
response = ask_client.post("/api/v1/documents/ask", json={"question": ""}, headers=HEADERS)
assert response.status_code == 422
def test_ask_documents_rejects_top_k_above_cap(ask_client: TestClient) -> None:
response = ask_client.post("/api/v1/documents/ask", json={"question": "x", "topK": 21}, headers=HEADERS)
assert response.status_code == 422
def test_ask_documents_rejects_missing_user_header(ask_client: TestClient) -> None:
assert ask_client.post("/api/v1/documents/ask", json={"question": "x"}).status_code == 401
# ── GET /documents/stats ────────────────────────────────────────────────
def test_stats_on_empty_store_reports_zero(client: TestClient) -> None:
response = client.get("/api/v1/documents/stats", headers=HEADERS)
assert response.status_code == 200
body = response.json()
assert body["documents"] == 0
assert body["chunks"] == 0
assert body["backend"] in ("sqlite", "pgvector")
assert body["embeddingModel"]
def test_stats_counts_seeded_documents_across_owners(client: TestClient) -> None:
"""Stats are deployment-wide: both owners' content is counted."""
for owner, doc in (("alice", "doc-a"), ("bob", "doc-b")):
client.post(
"/api/v1/documents",
json={
"documentId": doc,
"source": f"{doc}.pdf",
"pageText": [{"pageNumber": 1, "text": "Some content for the stats endpoint."}],
"ownerId": owner,
"readPrincipals": [owner],
"expiresAt": None,
},
headers={"X-User-Id": owner},
)
response = client.get("/api/v1/documents/stats", headers=HEADERS)
assert response.status_code == 200
body = response.json()
assert body["documents"] == 2
assert body["chunks"] >= 2
def test_stats_rejects_missing_user_header(client: TestClient) -> None:
assert client.get("/api/v1/documents/stats").status_code == 401
def test_delete_document_only_affects_calling_user(client: TestClient) -> None:
"""Two users with the same document id: one user's delete must not remove the other's."""
alice_body = {
+91
View File
@@ -0,0 +1,91 @@
from __future__ import annotations
import pytest
from stirling.agents.knowledge_ask import KnowledgeAskAgent, format_passages, passage_from_hit
from stirling.config import AppSettings
from stirling.contracts import AskDocumentsRequest, DocumentPassage
from stirling.documents import CollectionSearchHit, Document, DocumentService, SearchResult, SqliteVecStore
from stirling.models import FileId, PrincipalId
from stirling.services import build_runtime
PRINCIPALS = [PrincipalId("test-user")]
def _hit(metadata: dict[str, str], text: str = "chunk text", score: float = 0.8) -> CollectionSearchHit:
return CollectionSearchHit(
collection=FileId("doc-1"),
result=SearchResult(document=Document(id="c1", text=text, metadata=metadata), score=score),
)
# ── passage_from_hit ────────────────────────────────────────────────────
def test_passage_from_hit_maps_docparse_metadata() -> None:
passage = passage_from_hit(
_hit({"source": "q2.pdf", "page_start": "2", "page_end": "3", "heading_path": "Report > Finance"})
)
assert passage.document_id == "doc-1"
assert passage.page_start == 2
assert passage.page_end == 3
assert passage.heading_path == ["Report", "Finance"]
assert passage.source == "q2.pdf"
def test_passage_from_hit_falls_back_to_page_number() -> None:
"""Plain page-text chunks: page_number fills both bounds, source drops the page suffix."""
passage = passage_from_hit(_hit({"source": "report.pdf:page:4", "page_number": "4"}))
assert passage.page_start == 4
assert passage.page_end == 4
assert passage.heading_path == []
assert passage.source == "report.pdf"
def test_passage_from_hit_tolerates_missing_and_bad_metadata() -> None:
passage = passage_from_hit(_hit({"page_start": "not-a-number"}))
assert passage.page_start is None
assert passage.page_end is None
assert passage.heading_path == []
assert passage.source is None
# ── format_passages ─────────────────────────────────────────────────────
def test_format_passages_includes_citation_handles() -> None:
passages = [
DocumentPassage(document_id=FileId("d1"), text="Alpha.", score=0.9, page_start=2, page_end=3, source="a.pdf"),
DocumentPassage(document_id=FileId("d2"), text="Beta.", score=0.5, page_start=7, page_end=7, source="b.pdf"),
DocumentPassage(document_id=FileId("d3"), text="Gamma.", score=0.4),
]
rendered = format_passages(passages)
assert "[Passage 1 | a.pdf p.2-3]\nAlpha." in rendered
assert "[Passage 2 | b.pdf p.7]\nBeta." in rendered
# No source or pages: fall back to the document id alone.
assert "[Passage 3 | d3]\nGamma." in rendered
# ── KnowledgeAskAgent ───────────────────────────────────────────────────
class _StubEmbedder:
"""Deterministic embeddings so the agent test needs no provider."""
async def embed_query(self, text: str) -> list[float]:
return [1.0, 0.0]
async def embed_documents(self, texts: list[str]) -> list[list[float]]:
return [[1.0, 0.0] for _ in texts]
@pytest.mark.anyio
async def test_ask_answers_plainly_when_nothing_retrieved(app_settings: AppSettings) -> None:
"""Empty retrieval short-circuits: no model call, honest not-found answer."""
documents = DocumentService(embedder=_StubEmbedder(), store=SqliteVecStore.ephemeral(), default_top_k=3) # type: ignore[arg-type]
runtime = build_runtime(app_settings, documents=documents)
agent = KnowledgeAskAgent(runtime)
response = await agent.ask(AskDocumentsRequest(question="What is the launch date?"), principals=PRINCIPALS)
assert response.passages == []
assert "couldn't find" in response.answer
+11 -9
View File
@@ -494,14 +494,14 @@ name = "cohere"
version = "7.0.4"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "fastavro" },
{ name = "httpx" },
{ name = "pydantic" },
{ name = "pydantic-core" },
{ name = "requests" },
{ name = "tokenizers" },
{ name = "types-requests" },
{ name = "typing-extensions" },
{ name = "fastavro", marker = "sys_platform != 'emscripten'" },
{ name = "httpx", marker = "sys_platform != 'emscripten'" },
{ name = "pydantic", marker = "sys_platform != 'emscripten'" },
{ name = "pydantic-core", marker = "sys_platform != 'emscripten'" },
{ name = "requests", marker = "sys_platform != 'emscripten'" },
{ name = "tokenizers", marker = "sys_platform != 'emscripten'" },
{ name = "types-requests", marker = "sys_platform != 'emscripten'" },
{ name = "typing-extensions", marker = "sys_platform != 'emscripten'" },
]
sdist = { url = "https://files.pythonhosted.org/packages/cf/3c/670631ee223d7b64d157dc3f309bf93bde65efe0bb1a8341d9b575f407d3/cohere-7.0.4.tar.gz", hash = "sha256:35b6a397d35ae6eafa1a02921f42c2a98309a990874533e5238efaf3426b6a21", size = 208794, upload-time = "2026-06-11T15:17:52.994Z" }
wheels = [
@@ -855,6 +855,7 @@ dependencies = [
{ name = "pydantic-ai" },
{ name = "pydantic-ai-slim", extra = ["voyageai"] },
{ name = "pydantic-settings" },
{ name = "python-docx" },
{ name = "python-dotenv" },
{ name = "sqlite-vec" },
{ name = "uvicorn" },
@@ -892,6 +893,7 @@ requires-dist = [
{ name = "pydantic-ai", specifier = ">=1.99.0,<2.0.0" },
{ name = "pydantic-ai-slim", extras = ["voyageai"], specifier = ">=1.99.0,<2.0.0" },
{ name = "pydantic-settings", specifier = ">=2.0.0" },
{ name = "python-docx", specifier = ">=1.1.2" },
{ name = "python-dotenv", specifier = ">=1.2.1" },
{ name = "sqlite-vec", specifier = ">=0.1.6" },
{ name = "torch", marker = "sys_platform == 'linux' and extra == 'docparse'", specifier = ">=2.6.0", index = "https://download.pytorch.org/whl/cpu" },
@@ -4360,7 +4362,7 @@ name = "types-requests"
version = "2.33.0.20260518"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "urllib3" },
{ name = "urllib3", marker = "sys_platform != 'emscripten'" },
]
sdist = { url = "https://files.pythonhosted.org/packages/e0/01/c5a19253fe1ac159159ddf9a3a07cec8bb5e486ec4d9002ad2821da0e5d2/types_requests-2.33.0.20260518.tar.gz", hash = "sha256:df7bd3bfe0ca8402dfb841e7d9be714bb5578203283d66d7dc4ef69343449a5e", size = 24752, upload-time = "2026-05-18T06:07:37.966Z" }
wheels = [
@@ -2986,6 +2986,38 @@ summary_one = "Ran 1 tool"
summary_other = "Ran {{count}} tools"
unknownTool = "Unknown tool"
[chunkDocument]
intro = "Turns a document into retrieval-ready chunks in three layers, so answers cite the right section instead of a random page."
processorCallout = "To index automatically, add the 'Index into knowledge base' step to an ingestion policy in the"
processorLink = "Processor"
submit = "Prepare chunks"
[chunkDocument.chunkSize]
label = "Chunk size (characters)"
[chunkDocument.error]
failed = "Failed to chunk document"
[chunkDocument.layers]
chunk = "Structure-aware chunks: each carries its heading breadcrumb and page range"
embed = "Ready to embed: exported as JSONL for any vector store"
parse = "Layout-aware parse: headings, paragraphs, and tables are recognized as structure"
[chunkDocument.mode]
advanced = "Advanced"
auto = "Auto"
basic = "Basic"
label = "Mode"
[chunkDocument.overlap]
label = "Overlap (characters)"
[chunkDocument.results]
title = "Chunks (JSONL)"
[chunkDocument.settings]
title = "Chunking settings"
[cloudBadge]
tooltip = "This operation will use your cloud credits"
@@ -3573,6 +3605,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 +3739,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"
@@ -4183,6 +4272,24 @@ upload = "Upload"
uploadFile = "Upload File"
uploadFiles = "Upload Files"
[fillTemplate]
hint = "The input file must be a .docx template (not a PDF). Each placeholder in the template is replaced with the matching JSON value."
intro = "Replaces the placeholders in a Word (.docx) template with your JSON data and returns the filled document - deterministic, no AI involved."
submit = "Fill template"
[fillTemplate.data]
invalid = "Enter a valid JSON object"
label = "Data (JSON)"
[fillTemplate.error]
failed = "Failed to fill template"
[fillTemplate.results]
title = "Filled document"
[fillTemplate.settings]
title = "Template data"
[firstLogin]
allFieldsRequired = "All fields are required"
changePassword = "Change Password"
@@ -4522,6 +4629,11 @@ desc = "Change document restrictions and permissions"
tags = "permissions,restrictions,rights,access control,allow,deny,printing,copying,editing,modify permissions,security settings,user rights"
title = "Change Permissions"
[home.chunkDocument]
desc = "Layout-aware parse to structure-aware chunks with heading breadcrumbs and page ranges, ready to embed"
tags = "chunk,RAG,prepare,split text,segments,embedding,vector,ingest,LLM,retrieval,JSONL,overlap,knowledge base,index"
title = "Prepare for RAG"
[home.compare]
desc = "Compares and shows the differences between 2 PDF Documents"
tags = "difference,compare,diff,compare PDFs,compare documents,find differences,show differences,changes,what changed,track changes,revisions,version compare,side by side,contrast,delta"
@@ -4567,6 +4679,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"
@@ -4577,6 +4694,11 @@ desc = "Extract specific pages from a PDF document"
tags = "pull,select,copy,extract,extract pages,get pages,pull out,save pages,export pages,copy pages,select pages,specific pages"
title = "Extract Pages"
[home.fillTemplate]
desc = "Fill a DOCX template's placeholders from JSON data"
tags = "template,DOCX,fill,merge fields,mail merge,generate document,placeholders,letters,contracts,Word"
title = "Fill Template"
[home.flatten]
desc = "Remove all interactive elements and forms from a PDF"
tags = "simplify,remove,interactive,flatten,flatten form,remove form fields,make static,finalize form,lock form,disable editing,convert to image,non-editable"
@@ -4630,6 +4752,11 @@ desc = "Merge multiple pages of a PDF document into a single page"
tags = "layout,arrange,combine,N-up,2-up,4-up,multiple per page,pages per sheet,layout pages,tile,grid layout,multi-page layout,combine on page,handout"
title = "Multi-Page Layout"
[home.parseDocument]
desc = "Layout-aware parsing to structured JSON or Markdown, with optional OCR"
tags = "parse,layout,structure,blocks,markdown,JSON,docling,OCR,scan,document understanding,convert"
title = "Parse Document"
[home.pdfCommentAgent]
desc = "Ask AI to annotate a PDF with sticky-note comments based on your prompt"
tags = "AI,agent,comment,annotate,sticky note,review,feedback,notes"
@@ -4743,6 +4870,11 @@ desc = "Adds signature to PDF by drawing, text or image"
tags = "signature,autograph,e-sign,electronic signature,digital signature,sign document,approval,signoff,authorize,endorse,ink signature,handwriting"
title = "Sign"
[home.smartSplit]
desc = "Split a PDF into sub-documents using a natural-language boundary rule"
tags = "split,smart,boundaries,separate,invoices,batches,content split,divide,rules,auto split"
title = "Smart Split"
[home.split]
desc = "Split PDFs into multiple documents"
tags = "divide,separate,break,split,extract pages,separate pages,divide document,break apart,separate files,unbind,split by page,divide by chapter"
@@ -5501,6 +5633,33 @@ title = "Page Ranges"
bullet1 = "<strong>all</strong> → selects all pages"
title = "Special Keywords"
[parseDocument]
intro = "Reads the document's layout - headings, paragraphs, tables - and turns it into clean structured JSON or Markdown you can feed to other systems."
submit = "Parse document"
[parseDocument.error]
failed = "Failed to parse document"
[parseDocument.mode]
advanced = "Advanced"
auto = "Auto"
basic = "Basic"
label = "Mode"
[parseDocument.outputFormat]
json = "JSON"
label = "Output format"
markdown = "Markdown"
[parseDocument.results]
title = "Parsed output"
[parseDocument.settings]
title = "Parse settings"
[parseDocument.withOcr]
label = "Apply OCR to scanned pages (recommended)"
[payg.activity]
docs = "docs"
empty = "No billable activity yet this period."
@@ -7877,6 +8036,10 @@ 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 = "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"
@@ -7917,6 +8080,18 @@ onViolation = "When non-compliant"
1 = "Enforce action"
2 = "Audit trail"
[portal.policies.config.docIntelligence]
summary = "Extracts the fields you describe from every document, with confidence and citations."
[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."
@@ -8036,6 +8211,7 @@ addWatermark = "Watermark"
autoRedact = "Redact PII"
classifyAndLabel = "Classify"
compressPdf = "Compress"
extractFields = "Extract fields"
flatten = "Flatten"
ocrPdf = "OCR"
ragIngest = "Ingest into knowledge base"
@@ -8270,6 +8446,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"
@@ -10284,6 +10464,26 @@ medium = "Medium"
small = "Small"
x-large = "X-Large"
[smartSplit]
intro = "Describe where sub-documents start in plain language and AI reads the content to find those boundaries - no page numbers needed."
submit = "Split document"
[smartSplit.error]
failed = "Failed to split document"
[smartSplit.maxParts]
label = "Maximum parts"
[smartSplit.results]
title = "Split documents"
[smartSplit.rule]
label = "Split rule"
placeholder = "e.g. Start a new document at every invoice header"
[smartSplit.settings]
title = "Split settings"
[split]
resultsTitle = "Split Results"
selectMethod = "Select a split method"
@@ -10750,6 +10950,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);
}
@@ -0,0 +1,165 @@
import { test, expect } from "@app/tests/helpers/stub-test-base";
import type { Page, Route } from "@playwright/test";
import path from "node:path";
/** DocParse walkthrough: the five workbench tools.
* Dumps PNGs to screenshots/docparse; light + dark per view, RTL spot checks. */
const SCREENSHOTS_DIR = path.resolve(process.cwd(), "screenshots", "docparse");
function shotPath(name: string): string {
return path.join(SCREENSHOTS_DIR, `${name}.png`);
}
async function settle(page: Page, ms = 400): Promise<void> {
await page.waitForTimeout(ms);
}
async function stubApis(page: Page): Promise<void> {
// Narrow fallbacks only: a blanket /api/v1/** would out-rank the stub
// fixture's own /auth/me route (last-registered wins) and break the session.
await page.route("**/api/v1/policies/**", (route: Route) =>
route.fulfill({ json: [] }),
);
await page.route("**/api/v1/proprietary/ui-data/**", (route: Route) =>
route.fulfill({ json: [] }),
);
// enableLogin true: the portal only builds a session when login mode is on
// (matches live behavior; with login off the portal shows its login screen).
const configPayload = {
appVersion: "test",
enableLogin: true,
isAdmin: true,
languages: ["en-US"],
defaultLocale: "en-US",
aiEngineEnabled: true,
docparseEnabled: true,
docparseAdvanced: true,
storageEnabled: false,
premiumEnabled: true,
runningProOrHigher: true,
};
await page.route("**/api/v1/config/app-config", (route: Route) =>
route.fulfill({ json: configPayload }),
);
// The auth layer decides login mode from public-config; keep it in sync.
await page.route("**/api/v1/config/public-config", (route: Route) =>
route.fulfill({
json: { enableLogin: true, languages: ["en-US"], defaultLocale: "en-US" },
}),
);
await page.route(
"**/api/v1/config/endpoints-availability**",
(route: Route) => route.fulfill({ json: {} }),
);
await page.route("**/api/v1/config/endpoint-enabled**", (route: Route) =>
route.fulfill({ json: { enabled: true } }),
);
// DocparseToolIntro probes live capabilities for its tier badges.
await page.route("**/api/v1/docparse/capabilities", (route: Route) =>
route.fulfill({
json: {
enabled: true,
mode: "auto",
advancedInstalled: true,
engineReachable: true,
doclingVersion: "2.116.0",
},
}),
);
}
async function enableDarkMode(page: Page): Promise<void> {
await page.addInitScript(() => {
localStorage.setItem("mantine-color-scheme", "dark");
localStorage.setItem("mantine-color-scheme-value", "dark");
});
await page.emulateMedia({ colorScheme: "dark" });
}
async function enableRtl(page: Page): Promise<void> {
await page.addInitScript(() => {
localStorage.setItem("i18nextLng", "ar-AR");
localStorage.setItem("stirling-language", "ar-AR");
localStorage.setItem("stirling-language-source", "user");
const applyDir = () => {
document.documentElement.setAttribute("dir", "rtl");
document.documentElement.setAttribute("lang", "ar-AR");
};
if (document.documentElement) applyDir();
else document.addEventListener("DOMContentLoaded", applyDir);
});
}
const TOOLS = [
{ id: "parseDocument", url: "/parse-document", waitText: /Parse/i },
{ id: "extractFields", url: "/extract-fields", waitText: /Extract/i },
{ id: "smartSplit", url: "/smart-split", waitText: /Split/i },
{ id: "chunkDocument", url: "/chunk-document", waitText: /Chunk/i },
{ id: "fillTemplate", url: "/fill-template", waitText: /Template|Fill/i },
];
async function openTool(page: Page, url: string): Promise<void> {
await page.goto(url, { waitUntil: "domcontentloaded" });
await expect(page.locator("body").first()).not.toBeEmpty();
// The tool panel is the left rail; give lazy chunks a moment.
await settle(page, 900);
}
test.describe("DocParse walkthrough", () => {
test.use({
autoGoto: false,
viewport: { width: 1600, height: 900 },
seedJwt: true,
});
// ─── Editor tools, light ──────────────────────────────────────────────────
for (const [i, tool] of TOOLS.entries()) {
test(`t${i}_${tool.id}_light`, async ({ page }) => {
await stubApis(page);
await openTool(page, tool.url);
await page.screenshot({ path: shotPath(`0${i + 1}_${tool.id}_light`) });
});
test(`t${i}_${tool.id}_dark`, async ({ page }) => {
await enableDarkMode(page);
await stubApis(page);
await openTool(page, tool.url);
await page.screenshot({ path: shotPath(`0${i + 1}_${tool.id}_dark`) });
});
}
// ─── Extract Fields with builder rows filled ─────────────────────────────
for (const theme of ["light", "dark"] as const) {
test(`extract_fields_populated_${theme}`, async ({ page }) => {
if (theme === "dark") await enableDarkMode(page);
await stubApis(page);
await openTool(page, "/extract-fields");
// Fill the first schema-builder row when present; tolerate layout drift.
const nameInput = page.getByPlaceholder(/name/i).first();
if (await nameInput.isVisible().catch(() => false)) {
await nameInput.fill("invoice_number");
const addButton = page.getByRole("button", { name: /add/i }).first();
if (await addButton.isVisible().catch(() => false)) {
await addButton.click();
const second = page.getByPlaceholder(/name/i).nth(1);
if (await second.isVisible().catch(() => false)) {
await second.fill("total_due");
}
}
}
await settle(page);
await page.screenshot({
path: shotPath(`06_extract_fields_populated_${theme}`),
});
});
}
// ─── RTL spot checks ──────────────────────────────────────────────────────
test("rtl_extract_fields", async ({ page }) => {
await enableRtl(page);
await stubApis(page);
await openTool(page, "/extract-fields");
await page.screenshot({ path: shotPath("11_extract_fields_rtl") });
});
});
@@ -62,6 +62,7 @@ export interface AppConfig {
timestampCustomTsaUrls?: string[];
timestampTsaPresets?: { label: string; url: string }[];
aiEngineEnabled?: boolean;
docparseEnabled?: boolean;
}
export type AppConfigBootstrapMode = "blocking" | "non-blocking";
+17 -1
View File
@@ -141,7 +141,8 @@ export const ENDPOINT_LABELS: Partial<
Record<
| ToolEndpoint
| "/api/v1/ai/tools/classify-and-label"
| "/api/v1/docparse/rag-ingest",
| "/api/v1/docparse/rag-ingest"
| "/api/v1/docparse/extract-fields",
string
>
> = {
@@ -154,6 +155,7 @@ export const ENDPOINT_LABELS: Partial<
"/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(
@@ -180,6 +182,13 @@ 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",
@@ -228,6 +237,13 @@ 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: [
@@ -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>
);
}
@@ -40,6 +40,7 @@ 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";
@@ -98,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
@@ -193,6 +196,13 @@ 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",
@@ -562,6 +572,14 @@ function PolicySetupWizardBody({
}
/>
)}
{tl.toolId === "extractFields" && (
<PolicyExtractFieldsConfig
parameters={tl.params}
onChange={(params) =>
setToolParams("extractFields", params)
}
/>
)}
{tl.toolId === "ragIngest" && (
<PolicyRagIngestConfig
parameters={tl.params}
@@ -0,0 +1,91 @@
import { useTranslation } from "react-i18next";
import { Anchor, List, NumberInput, Select, Stack, Text } from "@mantine/core";
import type { ToolAutomationSettingsProps } from "@app/hooks/tools/shared/toolOperationTypes";
import type { ChunkDocumentParameters } from "@app/hooks/tools/chunkDocument/useChunkDocumentParameters";
import type { DocparseMode } from "@app/hooks/tools/parseDocument/useParseDocumentParameters";
import DocparseToolIntro from "@app/components/tools/docparse/DocparseToolIntro";
const ChunkDocumentSettings = ({
parameters,
onParameterChange,
disabled,
}: ToolAutomationSettingsProps<ChunkDocumentParameters>) => {
const { t } = useTranslation();
return (
<Stack gap="sm">
<DocparseToolIntro
description={t(
"chunkDocument.intro",
"Turns a document into retrieval-ready chunks in three layers, so answers cite the right section instead of a random page.",
)}
aiBadge="layout"
/>
<List type="ordered" size="sm" spacing={4}>
<List.Item>
{t(
"chunkDocument.layers.parse",
"Layout-aware parse: headings, paragraphs, and tables are recognized as structure",
)}
</List.Item>
<List.Item>
{t(
"chunkDocument.layers.chunk",
"Structure-aware chunks: each carries its heading breadcrumb and page range",
)}
</List.Item>
<List.Item>
{t(
"chunkDocument.layers.embed",
"Ready to embed: exported as JSONL for any vector store",
)}
</List.Item>
</List>
<NumberInput
label={t("chunkDocument.chunkSize.label", "Chunk size (characters)")}
value={parameters.chunkSize}
onChange={(value) =>
onParameterChange("chunkSize", typeof value === "number" ? value : 0)
}
min={1}
disabled={disabled}
/>
<NumberInput
label={t("chunkDocument.overlap.label", "Overlap (characters)")}
value={parameters.overlap}
onChange={(value) =>
onParameterChange("overlap", typeof value === "number" ? value : 0)
}
min={0}
disabled={disabled}
/>
<Select
label={t("chunkDocument.mode.label", "Mode")}
value={parameters.mode}
onChange={(value) =>
onParameterChange("mode", (value ?? "auto") as DocparseMode)
}
data={[
{ value: "auto", label: t("chunkDocument.mode.auto", "Auto") },
{ value: "basic", label: t("chunkDocument.mode.basic", "Basic") },
{
value: "advanced",
label: t("chunkDocument.mode.advanced", "Advanced"),
},
]}
disabled={disabled}
/>
<Text size="xs" c="dimmed">
{t(
"chunkDocument.processorCallout",
"To index automatically, add the 'Index into knowledge base' step to an ingestion policy in the",
)}{" "}
<Anchor href="/processor/policies" size="xs">
{t("chunkDocument.processorLink", "Processor")}
</Anchor>
</Text>
</Stack>
);
};
export default ChunkDocumentSettings;
@@ -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,54 @@
import { useTranslation } from "react-i18next";
import { Stack, Text, Textarea } from "@mantine/core";
import type { ToolAutomationSettingsProps } from "@app/hooks/tools/shared/toolOperationTypes";
import {
isJsonObjectString,
type FillTemplateParameters,
} from "@app/hooks/tools/fillTemplate/useFillTemplateParameters";
import DocparseToolIntro from "@app/components/tools/docparse/DocparseToolIntro";
const FillTemplateSettings = ({
parameters,
onParameterChange,
disabled,
}: ToolAutomationSettingsProps<FillTemplateParameters>) => {
const { t } = useTranslation();
const dataJson = parameters.dataJson;
const jsonError =
dataJson.trim().length > 0 && !isJsonObjectString(dataJson)
? t("fillTemplate.data.invalid", "Enter a valid JSON object")
: null;
return (
<Stack gap="sm">
<DocparseToolIntro
description={t(
"fillTemplate.intro",
"Replaces the placeholders in a Word (.docx) template with your JSON data and returns the filled document - deterministic, no AI involved.",
)}
showFallbackNote={false}
/>
<Text size="sm" c="dimmed">
{t(
"fillTemplate.hint",
"The input file must be a .docx template (not a PDF). Each placeholder in the template is replaced with the matching JSON value.",
)}
</Text>
<Textarea
label={t("fillTemplate.data.label", "Data (JSON)")}
placeholder='{"customer": "ACME Corp", "total": "128.00"}'
value={dataJson}
onChange={(event) =>
onParameterChange("dataJson", event.currentTarget.value)
}
minRows={5}
autosize
error={jsonError}
disabled={disabled}
/>
</Stack>
);
};
export default FillTemplateSettings;
@@ -0,0 +1,78 @@
import { useTranslation } from "react-i18next";
import { Checkbox, Select, Stack } from "@mantine/core";
import type { ToolAutomationSettingsProps } from "@app/hooks/tools/shared/toolOperationTypes";
import type {
DocparseMode,
ParseDocumentParameters,
} from "@app/hooks/tools/parseDocument/useParseDocumentParameters";
import DocparseToolIntro from "@app/components/tools/docparse/DocparseToolIntro";
const ParseDocumentSettings = ({
parameters,
onParameterChange,
disabled,
}: ToolAutomationSettingsProps<ParseDocumentParameters>) => {
const { t } = useTranslation();
return (
<Stack gap="sm">
<DocparseToolIntro
description={t(
"parseDocument.intro",
"Reads the document's layout - headings, paragraphs, tables - and turns it into clean structured JSON or Markdown you can feed to other systems.",
)}
aiBadge="layout"
/>
<Select
label={t("parseDocument.mode.label", "Mode")}
value={parameters.mode}
onChange={(value) =>
onParameterChange("mode", (value ?? "auto") as DocparseMode)
}
data={[
{ value: "auto", label: t("parseDocument.mode.auto", "Auto") },
{ value: "basic", label: t("parseDocument.mode.basic", "Basic") },
{
value: "advanced",
label: t("parseDocument.mode.advanced", "Advanced"),
},
]}
disabled={disabled}
/>
<Select
label={t("parseDocument.outputFormat.label", "Output format")}
value={parameters.outputFormat}
onChange={(value) =>
onParameterChange(
"outputFormat",
(value ?? "json") as ParseDocumentParameters["outputFormat"],
)
}
data={[
{
value: "json",
label: t("parseDocument.outputFormat.json", "JSON"),
},
{
value: "markdown",
label: t("parseDocument.outputFormat.markdown", "Markdown"),
},
]}
disabled={disabled}
/>
<Checkbox
label={t(
"parseDocument.withOcr.label",
"Apply OCR to scanned pages (recommended)",
)}
checked={parameters.withOcr}
onChange={(event) =>
onParameterChange("withOcr", event.currentTarget.checked)
}
disabled={disabled}
/>
</Stack>
);
};
export default ParseDocumentSettings;
@@ -0,0 +1,51 @@
import { useTranslation } from "react-i18next";
import { NumberInput, Stack, Textarea } from "@mantine/core";
import type { ToolAutomationSettingsProps } from "@app/hooks/tools/shared/toolOperationTypes";
import type { SmartSplitParameters } from "@app/hooks/tools/smartSplit/useSmartSplitParameters";
import DocparseToolIntro from "@app/components/tools/docparse/DocparseToolIntro";
const SmartSplitSettings = ({
parameters,
onParameterChange,
disabled,
}: ToolAutomationSettingsProps<SmartSplitParameters>) => {
const { t } = useTranslation();
return (
<Stack gap="sm">
<DocparseToolIntro
description={t(
"smartSplit.intro",
"Describe where sub-documents start in plain language and AI reads the content to find those boundaries - no page numbers needed.",
)}
aiBadge="llm"
/>
<Textarea
label={t("smartSplit.rule.label", "Split rule")}
placeholder={t(
"smartSplit.rule.placeholder",
"e.g. Start a new document at every invoice header",
)}
value={parameters.rule}
onChange={(event) =>
onParameterChange("rule", event.currentTarget.value)
}
minRows={3}
autosize
disabled={disabled}
/>
<NumberInput
label={t("smartSplit.maxParts.label", "Maximum parts")}
value={parameters.maxParts}
onChange={(value) =>
onParameterChange("maxParts", typeof value === "number" ? value : 1)
}
min={1}
max={100}
disabled={disabled}
/>
</Stack>
);
};
export default SmartSplitSettings;
@@ -0,0 +1,128 @@
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 { parseDocumentOperationConfig } from "@app/hooks/tools/parseDocument/parseDocumentOperationConfig";
import { extractFieldsOperationConfig } from "@app/hooks/tools/extractFields/extractFieldsOperationConfig";
import { smartSplitOperationConfig } from "@app/hooks/tools/smartSplit/smartSplitOperationConfig";
import { chunkDocumentOperationConfig } from "@app/hooks/tools/chunkDocument/chunkDocumentOperationConfig";
import { fillTemplateOperationConfig } from "@app/hooks/tools/fillTemplate/fillTemplateOperationConfig";
import ParseDocument from "@app/tools/ParseDocument";
import ExtractFields from "@app/tools/ExtractFields";
import SmartSplit from "@app/tools/SmartSplit";
import ChunkDocument from "@app/tools/ChunkDocument";
import FillTemplate from "@app/tools/FillTemplate";
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 {
parseDocument: {
icon: toolIcon("quick-reference-all-outline-rounded"),
name: t("home.parseDocument.title", "Parse Document"),
component: ParseDocument,
description: t(
"home.parseDocument.desc",
"Layout-aware parsing to structured JSON or Markdown, with optional OCR",
),
categoryId: ToolCategoryId.STANDARD_TOOLS,
subcategoryId: SubcategoryId.DOCUMENT_INTELLIGENCE,
maxFiles: 1,
endpoints: ["parse-document"],
operationConfig: asRegistryConfig(parseDocumentOperationConfig),
automationSettings: null,
synonyms: getSynonyms(t, "parseDocument"),
versionStatus: "beta",
},
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",
},
smartSplit: {
icon: toolIcon("content-cut-rounded"),
name: t("home.smartSplit.title", "Smart Split"),
component: SmartSplit,
description: t(
"home.smartSplit.desc",
"Split a PDF into sub-documents using a natural-language boundary rule",
),
categoryId: ToolCategoryId.STANDARD_TOOLS,
subcategoryId: SubcategoryId.DOCUMENT_INTELLIGENCE,
maxFiles: 1,
endpoints: ["smart-split"],
operationConfig: asRegistryConfig(smartSplitOperationConfig),
automationSettings: null,
synonyms: getSynonyms(t, "smartSplit"),
versionStatus: "beta",
},
chunkDocument: {
icon: toolIcon("layers-outline-rounded"),
name: t("home.chunkDocument.title", "Prepare for RAG"),
component: ChunkDocument,
description: t(
"home.chunkDocument.desc",
"Layout-aware parse to structure-aware chunks with heading breadcrumbs and page ranges, ready to embed",
),
categoryId: ToolCategoryId.STANDARD_TOOLS,
subcategoryId: SubcategoryId.DOCUMENT_INTELLIGENCE,
maxFiles: 1,
endpoints: ["chunk-document"],
operationConfig: asRegistryConfig(chunkDocumentOperationConfig),
automationSettings: null,
synonyms: getSynonyms(t, "chunkDocument"),
versionStatus: "beta",
},
fillTemplate: {
icon: toolIcon("assignment-outline-rounded"),
name: t("home.fillTemplate.title", "Fill Template"),
component: FillTemplate,
description: t(
"home.fillTemplate.desc",
"Fill a DOCX template's placeholders from JSON data",
),
categoryId: ToolCategoryId.STANDARD_TOOLS,
subcategoryId: SubcategoryId.DOCUMENT_INTELLIGENCE,
maxFiles: 1,
supportedFormats: ["docx"],
endpoints: ["fill-template"],
operationConfig: asRegistryConfig(fillTemplateOperationConfig),
automationSettings: null,
synonyms: getSynonyms(t, "fillTemplate"),
versionStatus: "beta",
},
} as ProprietaryToolRegistry;
}, [t, docparseEnabled]);
}
@@ -0,0 +1,42 @@
import { describe, expect, test } from "vitest";
import {
buildChunkDocumentFormData,
chunksFromResponse,
chunksToJsonl,
} from "@app/hooks/tools/chunkDocument/chunkDocumentOperationConfig";
import { defaultParameters } from "@app/hooks/tools/chunkDocument/useChunkDocumentParameters";
describe("chunkDocument operation helpers", () => {
test("accepts both bare-array and wrapped chunk responses", () => {
const chunks = [{ text: "a" }, { text: "b" }];
expect(chunksFromResponse(chunks)).toEqual(chunks);
expect(chunksFromResponse({ chunks })).toEqual(chunks);
expect(chunksFromResponse({ nope: true })).toEqual([]);
expect(chunksFromResponse(null)).toEqual([]);
});
test("emits one JSON document per JSONL line", () => {
const jsonl = chunksToJsonl([
{ text: "a", page: 1 },
{ text: "b", page: 2 },
]);
const lines = jsonl.split("\n");
expect(lines).toHaveLength(2);
expect(lines.map((line) => JSON.parse(line))).toEqual([
{ text: "a", page: 1 },
{ text: "b", page: 2 },
]);
});
test("builds the multipart form the backend contract expects", () => {
const file = new File(["pdf"], "doc.pdf", { type: "application/pdf" });
const form = buildChunkDocumentFormData(
{ ...defaultParameters, chunkSize: 800, overlap: 50, mode: "advanced" },
file,
);
expect(form.get("fileInput")).toBe(file);
expect(form.get("chunkSize")).toBe("800");
expect(form.get("overlap")).toBe("50");
expect(form.get("mode")).toBe("advanced");
});
});
@@ -0,0 +1,64 @@
import apiClient from "@app/services/apiClient";
import {
defineCustomTool,
CustomProcessorResult,
} from "@app/hooks/tools/shared/toolOperationTypes";
import { deriveName } from "@app/hooks/tools/shared/docparseFilenames";
import {
ChunkDocumentParameters,
defaultParameters,
} from "@app/hooks/tools/chunkDocument/useChunkDocumentParameters";
export const CHUNK_DOCUMENT_ENDPOINT = "/api/v1/docparse/chunk-document";
export const buildChunkDocumentFormData = (
parameters: ChunkDocumentParameters,
file: File,
): FormData => {
const formData = new FormData();
formData.append("fileInput", file);
formData.append("chunkSize", String(parameters.chunkSize));
formData.append("overlap", String(parameters.overlap));
formData.append("mode", parameters.mode);
return formData;
};
/** The chunks array, whether the backend returns it bare or wrapped. */
export function chunksFromResponse(data: unknown): unknown[] {
if (Array.isArray(data)) return data;
const wrapped = (data as { chunks?: unknown[] } | null)?.chunks;
return Array.isArray(wrapped) ? wrapped : [];
}
/** JSON chunks -> one JSONL line per chunk, the standard RAG-ingest shape. */
export function chunksToJsonl(chunks: unknown[]): string {
return chunks.map((chunk) => JSON.stringify(chunk)).join("\n");
}
const processChunkDocument = async (
parameters: ChunkDocumentParameters,
files: File[],
): Promise<CustomProcessorResult> => {
if (files.length === 0) return { files: [] };
const [inputFile] = files;
const response = await apiClient.post<unknown>(
CHUNK_DOCUMENT_ENDPOINT,
buildChunkDocumentFormData(parameters, inputFile),
);
const resultFile = new File(
[chunksToJsonl(chunksFromResponse(response.data))],
deriveName(inputFile.name, ".chunks.jsonl"),
{ type: "application/x-ndjson" },
);
return { files: [resultFile] };
};
export const chunkDocumentOperationConfig =
defineCustomTool<ChunkDocumentParameters>({
operationType: "chunkDocument",
endpoint: CHUNK_DOCUMENT_ENDPOINT,
customProcessor: processChunkDocument,
defaultParameters,
});
@@ -0,0 +1,15 @@
import { useTranslation } from "react-i18next";
import { useToolOperation } from "@app/hooks/tools/shared/useToolOperation";
import { createStandardErrorHandler } from "@app/utils/toolErrorHandler";
import { chunkDocumentOperationConfig } from "@app/hooks/tools/chunkDocument/chunkDocumentOperationConfig";
export const useChunkDocumentOperation = () => {
const { t } = useTranslation();
return useToolOperation({
...chunkDocumentOperationConfig,
getErrorMessage: createStandardErrorHandler(
t("chunkDocument.error.failed", "Failed to chunk document"),
),
});
};
@@ -0,0 +1,34 @@
import { BaseParameters } from "@app/types/parameters";
import {
useBaseParameters,
BaseParametersHook,
} from "@app/hooks/tools/shared/useBaseParameters";
import type { DocparseMode } from "@app/hooks/tools/parseDocument/useParseDocumentParameters";
export interface ChunkDocumentParameters extends BaseParameters {
/** Target chunk size in characters. */
chunkSize: number;
/** Characters of overlap carried between neighbouring chunks. */
overlap: number;
mode: DocparseMode;
}
export const defaultParameters: ChunkDocumentParameters = {
chunkSize: 1000,
overlap: 100,
mode: "auto",
};
export type ChunkDocumentParametersHook =
BaseParametersHook<ChunkDocumentParameters>;
export const useChunkDocumentParameters = (): ChunkDocumentParametersHook => {
return useBaseParameters({
defaultParameters,
endpointName: "chunk-document",
validateFn: (params) =>
params.chunkSize > 0 &&
params.overlap >= 0 &&
params.overlap < params.chunkSize,
});
};
@@ -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,55 @@
import apiClient from "@app/services/apiClient";
import {
defineCustomTool,
CustomProcessorResult,
} from "@app/hooks/tools/shared/toolOperationTypes";
import { deriveName } from "@app/hooks/tools/shared/docparseFilenames";
import {
FillTemplateParameters,
defaultParameters,
} from "@app/hooks/tools/fillTemplate/useFillTemplateParameters";
export const FILL_TEMPLATE_ENDPOINT = "/api/v1/docparse/fill-template";
const DOCX_TYPE =
"application/vnd.openxmlformats-officedocument.wordprocessingml.document";
export const buildFillTemplateFormData = (
parameters: FillTemplateParameters,
file: File,
): FormData => {
const formData = new FormData();
formData.append("templateFile", file);
formData.append("data", parameters.dataJson.trim());
return formData;
};
/** POST the DOCX template + data; the filled DOCX comes straight back. */
const processFillTemplate = async (
parameters: FillTemplateParameters,
files: File[],
): Promise<CustomProcessorResult> => {
if (files.length === 0) return { files: [] };
const [templateFile] = files;
const response = await apiClient.post<Blob>(
FILL_TEMPLATE_ENDPOINT,
buildFillTemplateFormData(parameters, templateFile),
{ responseType: "blob" },
);
const resultFile = new File(
[response.data],
deriveName(templateFile.name, "-filled.docx"),
{ type: DOCX_TYPE },
);
return { files: [resultFile] };
};
export const fillTemplateOperationConfig =
defineCustomTool<FillTemplateParameters>({
operationType: "fillTemplate",
endpoint: FILL_TEMPLATE_ENDPOINT,
customProcessor: processFillTemplate,
defaultParameters,
});
@@ -0,0 +1,15 @@
import { useTranslation } from "react-i18next";
import { useToolOperation } from "@app/hooks/tools/shared/useToolOperation";
import { createStandardErrorHandler } from "@app/utils/toolErrorHandler";
import { fillTemplateOperationConfig } from "@app/hooks/tools/fillTemplate/fillTemplateOperationConfig";
export const useFillTemplateOperation = () => {
const { t } = useTranslation();
return useToolOperation({
...fillTemplateOperationConfig,
getErrorMessage: createStandardErrorHandler(
t("fillTemplate.error.failed", "Failed to fill template"),
),
});
};
@@ -0,0 +1,37 @@
import { BaseParameters } from "@app/types/parameters";
import {
useBaseParameters,
BaseParametersHook,
} from "@app/hooks/tools/shared/useBaseParameters";
export interface FillTemplateParameters extends BaseParameters {
/** JSON object whose keys fill the template's placeholders. */
dataJson: string;
}
export const defaultParameters: FillTemplateParameters = {
dataJson: "",
};
/** True when the text parses to a plain JSON object. */
export function isJsonObjectString(text: string): boolean {
try {
const parsed = JSON.parse(text);
return (
typeof parsed === "object" && parsed !== null && !Array.isArray(parsed)
);
} catch {
return false;
}
}
export type FillTemplateParametersHook =
BaseParametersHook<FillTemplateParameters>;
export const useFillTemplateParameters = (): FillTemplateParametersHook => {
return useBaseParameters({
defaultParameters,
endpointName: "fill-template",
validateFn: (params) => isJsonObjectString(params.dataJson),
});
};
@@ -0,0 +1,56 @@
import apiClient from "@app/services/apiClient";
import {
defineCustomTool,
CustomProcessorResult,
} from "@app/hooks/tools/shared/toolOperationTypes";
import { deriveName } from "@app/hooks/tools/shared/docparseFilenames";
import {
ParseDocumentParameters,
defaultParameters,
} from "@app/hooks/tools/parseDocument/useParseDocumentParameters";
// Not part of the generated ToolEndpoint union; DocParse is an optional addon.
export const PARSE_DOCUMENT_ENDPOINT = "/api/v1/docparse/parse-document";
export const buildParseDocumentFormData = (
parameters: ParseDocumentParameters,
file: File,
): FormData => {
const formData = new FormData();
formData.append("fileInput", file);
formData.append("mode", parameters.mode);
formData.append("withOcr", String(parameters.withOcr));
formData.append("outputFormat", parameters.outputFormat);
return formData;
};
/** POST the PDF; wrap the JSON or markdown result as a downloadable file. */
const processParseDocument = async (
parameters: ParseDocumentParameters,
files: File[],
): Promise<CustomProcessorResult> => {
if (files.length === 0) return { files: [] };
const [inputFile] = files;
const response = await apiClient.post<Blob>(
PARSE_DOCUMENT_ENDPOINT,
buildParseDocumentFormData(parameters, inputFile),
{ responseType: "blob" },
);
const isMarkdown = parameters.outputFormat === "markdown";
const resultFile = new File(
[response.data],
deriveName(inputFile.name, isMarkdown ? ".md" : ".parsed.json"),
{ type: isMarkdown ? "text/markdown" : "application/json" },
);
return { files: [resultFile] };
};
export const parseDocumentOperationConfig =
defineCustomTool<ParseDocumentParameters>({
operationType: "parseDocument",
endpoint: PARSE_DOCUMENT_ENDPOINT,
customProcessor: processParseDocument,
defaultParameters,
});
@@ -0,0 +1,15 @@
import { useTranslation } from "react-i18next";
import { useToolOperation } from "@app/hooks/tools/shared/useToolOperation";
import { createStandardErrorHandler } from "@app/utils/toolErrorHandler";
import { parseDocumentOperationConfig } from "@app/hooks/tools/parseDocument/parseDocumentOperationConfig";
export const useParseDocumentOperation = () => {
const { t } = useTranslation();
return useToolOperation({
...parseDocumentOperationConfig,
getErrorMessage: createStandardErrorHandler(
t("parseDocument.error.failed", "Failed to parse document"),
),
});
};
@@ -0,0 +1,30 @@
import { BaseParameters } from "@app/types/parameters";
import {
useBaseParameters,
BaseParametersHook,
} from "@app/hooks/tools/shared/useBaseParameters";
export type DocparseMode = "auto" | "basic" | "advanced";
export interface ParseDocumentParameters extends BaseParameters {
mode: DocparseMode;
outputFormat: "json" | "markdown";
withOcr: boolean;
}
// withOcr defaults true to match the API's default behaviour.
export const defaultParameters: ParseDocumentParameters = {
mode: "auto",
outputFormat: "json",
withOcr: true,
};
export type ParseDocumentParametersHook =
BaseParametersHook<ParseDocumentParameters>;
export const useParseDocumentParameters = (): ParseDocumentParametersHook => {
return useBaseParameters({
defaultParameters,
endpointName: "parse-document",
});
};
@@ -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}`;
}

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