mirror of
https://github.com/Stirling-Tools/Stirling-PDF.git
synced 2026-09-03 13:20:08 +03:00
Compare commits
2
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
40ccbc15cc | ||
|
|
62944d7423 |
@@ -436,6 +436,8 @@ public class EndpointConfiguration {
|
||||
// Adding endpoints to "DocParse" group (ingestion: chunk + index + export)
|
||||
addEndpointToGroup("DocParse", "rag-ingest");
|
||||
addEndpointToGroup("DocParse", "extract-tables");
|
||||
addEndpointToGroup("DocParse", "extract-fields");
|
||||
addEndpointToGroup("DocParse", "suggest-schema");
|
||||
|
||||
// Adding endpoints to "DeveloperTools" group
|
||||
addEndpointToGroup("DeveloperTools", "show-javascript");
|
||||
|
||||
+16
-1
@@ -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();
|
||||
|
||||
+2
-1
@@ -74,7 +74,8 @@ class ConfigControllerMoreTest {
|
||||
userService,
|
||||
showAdmin,
|
||||
licenseService,
|
||||
externalAppDepConfig);
|
||||
externalAppDepConfig,
|
||||
null);
|
||||
}
|
||||
|
||||
@SuppressWarnings("unchecked")
|
||||
|
||||
+2
-1
@@ -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
|
||||
|
||||
+70
@@ -31,14 +31,18 @@ import stirling.software.common.annotations.AutoJobPostMapping;
|
||||
import stirling.software.common.enumeration.ResourceWeight;
|
||||
import stirling.software.common.util.GeneralUtils;
|
||||
import stirling.software.common.util.WebResponseUtils;
|
||||
import stirling.software.proprietary.model.api.docparse.ExtractFieldsApiRequest;
|
||||
import stirling.software.proprietary.model.api.docparse.ExtractTablesApiRequest;
|
||||
import stirling.software.proprietary.model.api.docparse.RagIngestApiRequest;
|
||||
import stirling.software.proprietary.model.api.docparse.SuggestSchemaApiRequest;
|
||||
import stirling.software.proprietary.model.docparse.DocChunk;
|
||||
import stirling.software.proprietary.model.docparse.DocTable;
|
||||
import stirling.software.proprietary.model.docparse.DocparseCapabilitiesView;
|
||||
import stirling.software.proprietary.model.docparse.DocparseMode;
|
||||
import stirling.software.proprietary.model.docparse.ExtractFieldsResponse;
|
||||
import stirling.software.proprietary.model.docparse.ExtractTablesResponse;
|
||||
import stirling.software.proprietary.model.docparse.RagIngestResponse;
|
||||
import stirling.software.proprietary.model.docparse.SuggestSchemaResponse;
|
||||
import stirling.software.proprietary.service.AiToolResponseHeaders;
|
||||
import stirling.software.proprietary.service.DocParseService;
|
||||
|
||||
@@ -122,6 +126,72 @@ 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()));
|
||||
}
|
||||
|
||||
@GetMapping("/capabilities")
|
||||
@Operation(
|
||||
summary = "DocParse capability summary",
|
||||
|
||||
+29
@@ -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;
|
||||
}
|
||||
+16
@@ -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;
|
||||
}
|
||||
+19
@@ -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) {}
|
||||
+12
@@ -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;
|
||||
}
|
||||
}
|
||||
+16
@@ -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;
|
||||
}
|
||||
}
|
||||
+11
@@ -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) {}
|
||||
+12
@@ -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) {}
|
||||
+11
@@ -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;
|
||||
}
|
||||
}
|
||||
+4
@@ -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) {}
|
||||
+85
@@ -24,11 +24,16 @@ import stirling.software.proprietary.model.docparse.DocparseCapabilities;
|
||||
import stirling.software.proprietary.model.docparse.DocparseCapabilitiesView;
|
||||
import stirling.software.proprietary.model.docparse.DocparseMode;
|
||||
import stirling.software.proprietary.model.docparse.DocparseTier;
|
||||
import stirling.software.proprietary.model.docparse.ExtractFieldsRequest;
|
||||
import stirling.software.proprietary.model.docparse.ExtractFieldsResponse;
|
||||
import stirling.software.proprietary.model.docparse.ExtractTablesRequest;
|
||||
import stirling.software.proprietary.model.docparse.ExtractTablesResponse;
|
||||
import stirling.software.proprietary.model.docparse.RagIngestRequest;
|
||||
import stirling.software.proprietary.model.docparse.RagIngestResponse;
|
||||
import stirling.software.proprietary.model.docparse.SuggestSchemaRequest;
|
||||
import stirling.software.proprietary.model.docparse.SuggestSchemaResponse;
|
||||
|
||||
import tools.jackson.databind.JsonNode;
|
||||
import tools.jackson.databind.ObjectMapper;
|
||||
|
||||
/**
|
||||
@@ -43,6 +48,8 @@ public class DocParseService {
|
||||
|
||||
private static final String RAG_INGEST_ENDPOINT = "/api/v1/docparse/rag-ingest";
|
||||
private static final String TABLES_ENDPOINT = "/api/v1/docparse/tables";
|
||||
private static final String EXTRACT_ENDPOINT = "/api/v1/docparse/extract";
|
||||
private static final String SUGGEST_SCHEMA_ENDPOINT = "/api/v1/docparse/suggest-schema";
|
||||
|
||||
/** Below this average of extractable chars per page the document is treated as scanned. */
|
||||
static final int SCANNED_AVG_CHARS_PER_PAGE = 100;
|
||||
@@ -182,6 +189,84 @@ 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;
|
||||
}
|
||||
|
||||
/**
|
||||
* The settings mode wins when stricter: a settings {@code basic} always forces basic, a
|
||||
* settings {@code advanced} upgrades everything except an explicit basic request.
|
||||
|
||||
@@ -18,6 +18,7 @@ from stirling.agents import (
|
||||
from stirling.agents.ledger import MathAuditorAgent
|
||||
from stirling.agents.pdf_comment import PdfCommentAgent
|
||||
from stirling.config import AppSettings
|
||||
from stirling.docparse import ExtractFieldsAgent, SuggestSchemaAgent
|
||||
from stirling.documents import DocumentService, EmbeddingService
|
||||
from stirling.services import AppRuntime, build_runtime
|
||||
|
||||
@@ -35,6 +36,8 @@ class AppState:
|
||||
math_auditor_agent: MathAuditorAgent
|
||||
pdf_comment_agent: PdfCommentAgent
|
||||
document_classifier_agent: DocumentClassifierAgent
|
||||
extract_fields_agent: ExtractFieldsAgent
|
||||
suggest_schema_agent: SuggestSchemaAgent
|
||||
|
||||
|
||||
def build_app_state(
|
||||
@@ -63,6 +66,8 @@ def build_app_state(
|
||||
math_auditor_agent=MathAuditorAgent(runtime),
|
||||
pdf_comment_agent=PdfCommentAgent(runtime),
|
||||
document_classifier_agent=DocumentClassifierAgent(runtime),
|
||||
extract_fields_agent=ExtractFieldsAgent(runtime),
|
||||
suggest_schema_agent=SuggestSchemaAgent(runtime),
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -15,6 +15,7 @@ from stirling.agents import (
|
||||
from stirling.agents.ledger import MathAuditorAgent
|
||||
from stirling.agents.pdf_comment import PdfCommentAgent
|
||||
from stirling.config import AppSettings, load_settings
|
||||
from stirling.docparse import ExtractFieldsAgent, SuggestSchemaAgent
|
||||
from stirling.documents import DocumentService
|
||||
from stirling.models import UserId
|
||||
from stirling.services import AppRuntime, current_user_id
|
||||
@@ -60,6 +61,14 @@ def get_document_classifier_agent(request: Request) -> DocumentClassifierAgent:
|
||||
return request.app.state.document_classifier_agent
|
||||
|
||||
|
||||
def get_extract_fields_agent(request: Request) -> ExtractFieldsAgent:
|
||||
return request.app.state.extract_fields_agent
|
||||
|
||||
|
||||
def get_suggest_schema_agent(request: Request) -> SuggestSchemaAgent:
|
||||
return request.app.state.suggest_schema_agent
|
||||
|
||||
|
||||
def require_user_id() -> UserId:
|
||||
"""FastAPI dependency for routes that touch per-user storage.
|
||||
|
||||
|
||||
@@ -16,22 +16,34 @@ 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_suggest_schema_agent,
|
||||
require_user_id,
|
||||
)
|
||||
from stirling.config import AppSettings, load_settings
|
||||
from stirling.contracts.docparse import (
|
||||
DocChunk,
|
||||
DocparseCapabilities,
|
||||
DocparseMode,
|
||||
DocparseTier,
|
||||
ExtractFieldsRequest,
|
||||
ExtractFieldsResponse,
|
||||
ExtractTablesRequest,
|
||||
ExtractTablesResponse,
|
||||
ParseDocumentRequest,
|
||||
ParseDocumentResponse,
|
||||
RagIngestRequest,
|
||||
RagIngestResponse,
|
||||
SuggestSchemaRequest,
|
||||
SuggestSchemaResponse,
|
||||
)
|
||||
from stirling.docparse import basic_chunks, probe_capabilities
|
||||
from stirling.docparse.capability import models_dir
|
||||
from stirling.docparse.chunking import advanced_chunks
|
||||
from stirling.docparse.extractor import ExtractFieldsAgent, SchemaError, pages_from_parse
|
||||
from stirling.docparse.suggest_schema import SuggestSchemaAgent
|
||||
from stirling.documents import DocumentService
|
||||
from stirling.documents.service import CONTENT_TYPE_METADATA_KEY, DOCPARSE_CHUNK_CONTENT_TYPE
|
||||
from stirling.models import OwnerId, PrincipalId, UserId
|
||||
@@ -101,6 +113,63 @@ 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)
|
||||
|
||||
|
||||
def _chunk_metadata(chunk: DocChunk) -> dict[str, str]:
|
||||
meta = {CONTENT_TYPE_METADATA_KEY: DOCPARSE_CHUNK_CONTENT_TYPE}
|
||||
if chunk.page_start is not None:
|
||||
|
||||
@@ -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,72 @@ 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 DocparseCapabilities(ApiModel):
|
||||
"""What the engine can actually do right now; Java caches and republishes this."""
|
||||
|
||||
|
||||
@@ -8,8 +8,12 @@ from __future__ import annotations
|
||||
|
||||
from stirling.docparse.capability import activate_site, probe_capabilities
|
||||
from stirling.docparse.chunking import advanced_chunks, basic_chunks
|
||||
from stirling.docparse.extractor import ExtractFieldsAgent
|
||||
from stirling.docparse.suggest_schema import SuggestSchemaAgent
|
||||
|
||||
__all__ = [
|
||||
"ExtractFieldsAgent",
|
||||
"SuggestSchemaAgent",
|
||||
"activate_site",
|
||||
"advanced_chunks",
|
||||
"basic_chunks",
|
||||
|
||||
@@ -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())
|
||||
@@ -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)
|
||||
@@ -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)
|
||||
@@ -0,0 +1,64 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from pydantic import BaseModel
|
||||
|
||||
from stirling.contracts import PageText
|
||||
from stirling.docparse.extractor import (
|
||||
VALUE_GROUNDED_FLOOR,
|
||||
ExtractFieldsAgent,
|
||||
build_output_model,
|
||||
find_quote,
|
||||
)
|
||||
|
||||
|
||||
def _pages() -> list[PageText]:
|
||||
return [
|
||||
PageText(page_number=1, text="Invoice INV-123\nTotal due: 1,240.00 EUR"),
|
||||
PageText(page_number=2, text="Payment terms\nNet 30 days from receipt."),
|
||||
]
|
||||
|
||||
|
||||
def test_find_quote_exact() -> None:
|
||||
located = find_quote("Invoice INV-123", _pages())
|
||||
assert located is not None
|
||||
page, start, end = located
|
||||
assert page == 1
|
||||
assert start == 0
|
||||
|
||||
|
||||
def test_find_quote_is_whitespace_insensitive() -> None:
|
||||
located = find_quote("Total due: 1,240.00 EUR", _pages())
|
||||
assert located is not None
|
||||
assert located[0] == 1
|
||||
|
||||
|
||||
def test_find_quote_is_case_insensitive_and_crosses_pages() -> None:
|
||||
located = find_quote("net 30 DAYS", _pages())
|
||||
assert located is not None
|
||||
assert located[0] == 2
|
||||
|
||||
|
||||
def test_find_quote_missing_returns_none() -> None:
|
||||
assert find_quote("does not appear", _pages()) is None
|
||||
assert find_quote(" ", _pages()) is None
|
||||
|
||||
|
||||
def _answers(quote: str | None, confidence: float) -> BaseModel:
|
||||
model = build_output_model({"type": "object", "properties": {"invoice_number": {"type": "string"}}})
|
||||
return model.model_validate({"invoiceNumber": {"value": "INV-123", "quote": quote, "confidence": confidence}})
|
||||
|
||||
|
||||
def test_ground_falls_back_to_value_when_quote_missing() -> None:
|
||||
# Terse local models return the value but no quote; the value itself grounds.
|
||||
pages = [PageText(page_number=1, text="Invoice INV-123 issued today.")]
|
||||
fields = ExtractFieldsAgent._ground(_answers(quote=None, confidence=0.0), pages, None)
|
||||
assert fields[0].citations and fields[0].citations[0].page == 1
|
||||
assert fields[0].citations[0].quote == "INV-123"
|
||||
assert fields[0].confidence >= VALUE_GROUNDED_FLOOR
|
||||
|
||||
|
||||
def test_ground_penalises_when_nothing_grounds() -> None:
|
||||
pages = [PageText(page_number=1, text="completely unrelated text")]
|
||||
fields = ExtractFieldsAgent._ground(_answers(quote=None, confidence=0.9), pages, None)
|
||||
assert not fields[0].citations
|
||||
assert fields[0].confidence < 0.9
|
||||
@@ -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
|
||||
@@ -3573,6 +3573,13 @@ tags = "automation,folder,scanning,watch folder,hot folder,automatic processing,
|
||||
[devSsoGuide]
|
||||
tags = "SSO,single sign-on,authentication,SAML,OAuth,OIDC,login,enterprise,identity provider,IdP"
|
||||
|
||||
[docparse.intro]
|
||||
advancedOff = "Advanced parsing: off"
|
||||
advancedOn = "Advanced parsing: on"
|
||||
aiLayoutModel = "AI layout model"
|
||||
basicFallback = "Scanned documents fall back to basic text extraction - install the DocParse addon for layout AI"
|
||||
usesAi = "Uses AI"
|
||||
|
||||
[dropdownList]
|
||||
searchPlaceholder = "Search..."
|
||||
|
||||
@@ -3700,6 +3707,56 @@ _value = "Error"
|
||||
dismissAllErrors = "Dismiss All Errors"
|
||||
generic = "An error occurred"
|
||||
|
||||
[extractFields]
|
||||
intro = "Describe the fields you need and AI reads the document and returns each value with a confidence score and a citation you can verify."
|
||||
submit = "Extract fields"
|
||||
|
||||
[extractFields.error]
|
||||
failed = "Failed to extract fields"
|
||||
|
||||
[extractFields.fields]
|
||||
add = "Add field"
|
||||
description = "Description"
|
||||
descriptionPlaceholder = "What to look for"
|
||||
label = "Fields to extract"
|
||||
name = "Name"
|
||||
namePlaceholder = "invoice_number"
|
||||
remove = "Remove field"
|
||||
type = "Type"
|
||||
|
||||
[extractFields.instructions]
|
||||
label = "Instructions"
|
||||
placeholder = "e.g. Amounts are in EUR unless stated otherwise"
|
||||
|
||||
[extractFields.mode]
|
||||
advanced = "Advanced"
|
||||
auto = "Auto"
|
||||
basic = "Basic"
|
||||
label = "Mode"
|
||||
|
||||
[extractFields.presets]
|
||||
contract = "Contract"
|
||||
custom = "Custom"
|
||||
invoice = "Invoice"
|
||||
label = "Preset template"
|
||||
purchaseOrder = "Purchase order"
|
||||
receipt = "Receipt"
|
||||
|
||||
[extractFields.results]
|
||||
title = "Extraction report"
|
||||
|
||||
[extractFields.resultsPanel]
|
||||
title = "Extracted fields"
|
||||
|
||||
[extractFields.settings]
|
||||
title = "Extraction schema"
|
||||
|
||||
[extractFields.suggest]
|
||||
button = "Suggest fields (AI)"
|
||||
failed = "Could not suggest fields"
|
||||
failedBody = "The document could not be analyzed. Add fields manually or try again."
|
||||
needsFile = "Select a file first to suggest fields"
|
||||
|
||||
[extractImages]
|
||||
allowDuplicates = "Save duplicate images"
|
||||
selectText = "Select image format to convert extracted images to"
|
||||
@@ -4567,6 +4624,11 @@ desc = "Add or edit bookmarks and table of contents in PDF documents"
|
||||
tags = "bookmarks,contents,edit,table of contents,TOC,outline,navigation,chapters,sections,add bookmarks,edit bookmarks,PDF outline"
|
||||
title = "Edit Table of Contents"
|
||||
|
||||
[home.extractFields]
|
||||
desc = "Pull typed fields out of a document with confidence scores and citations"
|
||||
tags = "extract,fields,schema,structured data,invoice,form data,key value,confidence,citations,capture,parse"
|
||||
title = "Extract Fields"
|
||||
|
||||
[home.extractImages]
|
||||
desc = "Extracts all images from a PDF and saves them to zip"
|
||||
tags = "pull,save,export,extract images,get images,save images,export images,extract photos,extract pictures,pull images,download images,rip images,extract graphics,save photos"
|
||||
@@ -7877,6 +7939,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 +7983,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 +8114,7 @@ addWatermark = "Watermark"
|
||||
autoRedact = "Redact PII"
|
||||
classifyAndLabel = "Classify"
|
||||
compressPdf = "Compress"
|
||||
extractFields = "Extract fields"
|
||||
flatten = "Flatten"
|
||||
ocrPdf = "OCR"
|
||||
ragIngest = "Ingest into knowledge base"
|
||||
@@ -8270,6 +8349,10 @@ label = "Classify the document"
|
||||
desc = "Compresses the document to a smaller file size."
|
||||
label = "Reduce file size"
|
||||
|
||||
[portal.policies.wizard.capability.extractFields]
|
||||
desc = "Pulls the values you describe - like invoice numbers or dates - out of every document, each with a confidence score and a citation back to the page."
|
||||
label = "Extract structured fields"
|
||||
|
||||
[portal.policies.wizard.capability.flatten]
|
||||
desc = "Merges form fields and annotations into the page so they can't be edited."
|
||||
label = "Flatten the document"
|
||||
@@ -10750,6 +10833,7 @@ standardTools = "Standard Tools"
|
||||
advancedFormatting = "Advanced Formatting"
|
||||
automation = "Automation"
|
||||
developerTools = "Developer Tools"
|
||||
documentIntelligence = "Document intelligence"
|
||||
documentReview = "Document Review"
|
||||
documentSecurity = "Document Security"
|
||||
extraction = "Extraction"
|
||||
|
||||
@@ -22,6 +22,7 @@ import ViewAgendaRoundedIcon from "@mui/icons-material/ViewAgendaRounded";
|
||||
import FileDownloadRoundedIcon from "@mui/icons-material/FileDownloadRounded";
|
||||
import DeleteSweepRoundedIcon from "@mui/icons-material/DeleteSweepRounded";
|
||||
import SmartToyRoundedIcon from "@mui/icons-material/SmartToyRounded";
|
||||
import AutoAwesomeRoundedIcon from "@mui/icons-material/AutoAwesomeRounded";
|
||||
import BuildRoundedIcon from "@mui/icons-material/BuildRounded";
|
||||
import TuneRoundedIcon from "@mui/icons-material/TuneRounded";
|
||||
import CodeRoundedIcon from "@mui/icons-material/CodeRounded";
|
||||
@@ -34,6 +35,7 @@ export enum SubcategoryId {
|
||||
VERIFICATION = "verification",
|
||||
DOCUMENT_REVIEW = "documentReview",
|
||||
PAGE_FORMATTING = "pageFormatting",
|
||||
DOCUMENT_INTELLIGENCE = "documentIntelligence",
|
||||
EXTRACTION = "extraction",
|
||||
REMOVAL = "removal",
|
||||
AUTOMATION = "automation",
|
||||
@@ -95,6 +97,7 @@ export const SUBCATEGORY_ORDER: SubcategoryId[] = [
|
||||
SubcategoryId.VERIFICATION,
|
||||
SubcategoryId.DOCUMENT_REVIEW,
|
||||
SubcategoryId.PAGE_FORMATTING,
|
||||
SubcategoryId.DOCUMENT_INTELLIGENCE,
|
||||
SubcategoryId.EXTRACTION,
|
||||
SubcategoryId.REMOVAL,
|
||||
SubcategoryId.AUTOMATION,
|
||||
@@ -109,6 +112,7 @@ export const SUBCATEGORY_COLOR_MAP: Record<SubcategoryId, string> = {
|
||||
[SubcategoryId.VERIFICATION]: "var(--category-color-verification)", // Orange
|
||||
[SubcategoryId.DOCUMENT_REVIEW]: "var(--category-color-general)", // Blue
|
||||
[SubcategoryId.PAGE_FORMATTING]: "var(--category-color-formatting)", // Purple
|
||||
[SubcategoryId.DOCUMENT_INTELLIGENCE]: "var(--category-color-automation)", // Pink
|
||||
[SubcategoryId.EXTRACTION]: "var(--category-color-extraction)", // Cyan
|
||||
[SubcategoryId.REMOVAL]: "var(--category-color-removal)", // Red
|
||||
[SubcategoryId.AUTOMATION]: "var(--category-color-automation)", // Pink
|
||||
@@ -131,6 +135,8 @@ export const getSubcategoryIcon = (
|
||||
return React.createElement(RateReviewRoundedIcon);
|
||||
case SubcategoryId.PAGE_FORMATTING:
|
||||
return React.createElement(ViewAgendaRoundedIcon);
|
||||
case SubcategoryId.DOCUMENT_INTELLIGENCE:
|
||||
return React.createElement(AutoAwesomeRoundedIcon);
|
||||
case SubcategoryId.EXTRACTION:
|
||||
return React.createElement(FileDownloadRoundedIcon);
|
||||
case SubcategoryId.REMOVAL:
|
||||
|
||||
@@ -0,0 +1,10 @@
|
||||
import { useAppConfig } from "@app/contexts/AppConfigContext";
|
||||
|
||||
/**
|
||||
* Whether the DocParse layer is enabled, per the backend's app-config.
|
||||
* Gates the DocParse tools' visibility; flavors may shadow this hook.
|
||||
*/
|
||||
export function useDocparseEnabled(): boolean {
|
||||
const { config } = useAppConfig();
|
||||
return Boolean(config?.docparseEnabled);
|
||||
}
|
||||
@@ -62,6 +62,7 @@ export interface AppConfig {
|
||||
timestampCustomTsaUrls?: string[];
|
||||
timestampTsaPresets?: { label: string; url: string }[];
|
||||
aiEngineEnabled?: boolean;
|
||||
docparseEnabled?: boolean;
|
||||
}
|
||||
|
||||
export type AppConfigBootstrapMode = "blocking" | "non-blocking";
|
||||
|
||||
@@ -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}
|
||||
|
||||
+30
@@ -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;
|
||||
+36
@@ -0,0 +1,36 @@
|
||||
.schema {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
/* Two-line card per field: the side panel is too narrow for three columns. */
|
||||
.row {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1fr) 5.75rem auto;
|
||||
grid-template-areas:
|
||||
"name type remove"
|
||||
"desc desc desc";
|
||||
column-gap: 0.5rem;
|
||||
row-gap: 0.375rem;
|
||||
align-items: center;
|
||||
padding: 0.5rem;
|
||||
border: 1px solid var(--c-border-subtle);
|
||||
border-radius: 0.5rem;
|
||||
}
|
||||
|
||||
.nameInput {
|
||||
grid-area: name;
|
||||
}
|
||||
|
||||
.typeSelect {
|
||||
grid-area: type;
|
||||
}
|
||||
|
||||
.removeButton {
|
||||
grid-area: remove;
|
||||
}
|
||||
|
||||
.descriptionInput {
|
||||
grid-area: desc;
|
||||
}
|
||||
@@ -0,0 +1,238 @@
|
||||
import { useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Group, Select, Stack, Text, TextInput, Textarea } from "@mantine/core";
|
||||
import { Button } from "@app/ui";
|
||||
import DeleteOutlineRoundedIcon from "@mui/icons-material/DeleteOutlineRounded";
|
||||
import AddRoundedIcon from "@mui/icons-material/AddRounded";
|
||||
import AutoAwesomeRoundedIcon from "@mui/icons-material/AutoAwesomeRounded";
|
||||
import { alert } from "@app/components/toast";
|
||||
import type { ToolAutomationSettingsProps } from "@app/hooks/tools/shared/toolOperationTypes";
|
||||
import type { ExtractFieldsParameters } from "@app/hooks/tools/extractFields/useExtractFieldsParameters";
|
||||
import type { DocparseMode } from "@app/hooks/tools/shared/docparseTypes";
|
||||
import {
|
||||
FIELD_TYPES,
|
||||
emptyFieldRow,
|
||||
type FieldRow,
|
||||
type FieldType,
|
||||
} from "@app/hooks/tools/extractFields/fieldsSchema";
|
||||
import {
|
||||
CUSTOM_PRESET,
|
||||
FIELD_PRESET_IDS,
|
||||
matchPreset,
|
||||
presetRows,
|
||||
type FieldPresetId,
|
||||
} from "@app/hooks/tools/extractFields/fieldsSchemaPresets";
|
||||
import { requestSuggestedFields } from "@app/hooks/tools/extractFields/suggestSchema";
|
||||
import DocparseToolIntro from "@app/components/tools/docparse/DocparseToolIntro";
|
||||
import styles from "@app/components/tools/docparse/ExtractFieldsSettings.module.css";
|
||||
|
||||
interface ExtractFieldsSettingsProps extends ToolAutomationSettingsProps<ExtractFieldsParameters> {
|
||||
/** The selected input file; enables the AI schema suggestion. */
|
||||
selectedFile?: File | null;
|
||||
}
|
||||
|
||||
/** Schema builder: rows of name/type/description plus free-form instructions. */
|
||||
const ExtractFieldsSettings = ({
|
||||
parameters,
|
||||
onParameterChange,
|
||||
disabled,
|
||||
selectedFile,
|
||||
}: ExtractFieldsSettingsProps) => {
|
||||
const { t } = useTranslation();
|
||||
const [suggesting, setSuggesting] = useState(false);
|
||||
|
||||
const setRow = (index: number, patch: Partial<FieldRow>) => {
|
||||
const fields = parameters.fields.map((row, i) =>
|
||||
i === index ? { ...row, ...patch } : row,
|
||||
);
|
||||
onParameterChange("fields", fields);
|
||||
};
|
||||
|
||||
const removeRow = (index: number) => {
|
||||
const fields = parameters.fields.filter((_, i) => i !== index);
|
||||
onParameterChange("fields", fields.length > 0 ? fields : [emptyFieldRow()]);
|
||||
};
|
||||
|
||||
const applyPreset = (value: string | null) => {
|
||||
if (value === CUSTOM_PRESET) {
|
||||
onParameterChange("fields", [emptyFieldRow()]);
|
||||
} else if (value) {
|
||||
onParameterChange("fields", presetRows(value as FieldPresetId));
|
||||
}
|
||||
};
|
||||
|
||||
const suggestFields = async () => {
|
||||
if (!selectedFile || suggesting) return;
|
||||
setSuggesting(true);
|
||||
try {
|
||||
const rows = await requestSuggestedFields(selectedFile);
|
||||
onParameterChange("fields", rows.length > 0 ? rows : [emptyFieldRow()]);
|
||||
} catch {
|
||||
alert({
|
||||
alertType: "error",
|
||||
title: t("extractFields.suggest.failed", "Could not suggest fields"),
|
||||
body: t(
|
||||
"extractFields.suggest.failedBody",
|
||||
"The document could not be analyzed. Add fields manually or try again.",
|
||||
),
|
||||
expandable: false,
|
||||
});
|
||||
} finally {
|
||||
setSuggesting(false);
|
||||
}
|
||||
};
|
||||
|
||||
const presetLabels: Record<FieldPresetId, string> = {
|
||||
invoice: t("extractFields.presets.invoice", "Invoice"),
|
||||
receipt: t("extractFields.presets.receipt", "Receipt"),
|
||||
contract: t("extractFields.presets.contract", "Contract"),
|
||||
purchaseOrder: t("extractFields.presets.purchaseOrder", "Purchase order"),
|
||||
};
|
||||
|
||||
return (
|
||||
<Stack gap="sm">
|
||||
<DocparseToolIntro
|
||||
description={t(
|
||||
"extractFields.intro",
|
||||
"Describe the fields you need and AI reads the document and returns each value with a confidence score and a citation you can verify.",
|
||||
)}
|
||||
aiBadge="llm"
|
||||
/>
|
||||
<Select
|
||||
label={t("extractFields.presets.label", "Preset template")}
|
||||
value={matchPreset(parameters.fields)}
|
||||
onChange={applyPreset}
|
||||
data={[
|
||||
...FIELD_PRESET_IDS.map((preset) => ({
|
||||
value: preset,
|
||||
label: presetLabels[preset],
|
||||
})),
|
||||
{
|
||||
value: CUSTOM_PRESET,
|
||||
label: t("extractFields.presets.custom", "Custom"),
|
||||
},
|
||||
]}
|
||||
disabled={disabled}
|
||||
/>
|
||||
<Text size="sm" fw={500}>
|
||||
{t("extractFields.fields.label", "Fields to extract")}
|
||||
</Text>
|
||||
<div className={styles.schema}>
|
||||
{parameters.fields.map((row, index) => (
|
||||
<div key={index} className={styles.row}>
|
||||
<TextInput
|
||||
className={styles.nameInput}
|
||||
aria-label={t("extractFields.fields.name", "Name")}
|
||||
placeholder={t(
|
||||
"extractFields.fields.namePlaceholder",
|
||||
"invoice_number",
|
||||
)}
|
||||
value={row.name}
|
||||
onChange={(event) =>
|
||||
setRow(index, { name: event.currentTarget.value })
|
||||
}
|
||||
disabled={disabled}
|
||||
/>
|
||||
<Select
|
||||
className={styles.typeSelect}
|
||||
aria-label={t("extractFields.fields.type", "Type")}
|
||||
value={row.type}
|
||||
onChange={(value) =>
|
||||
setRow(index, { type: (value ?? "string") as FieldType })
|
||||
}
|
||||
data={FIELD_TYPES.map((type) => ({ value: type, label: type }))}
|
||||
disabled={disabled}
|
||||
/>
|
||||
<TextInput
|
||||
className={styles.descriptionInput}
|
||||
aria-label={t("extractFields.fields.description", "Description")}
|
||||
placeholder={t(
|
||||
"extractFields.fields.descriptionPlaceholder",
|
||||
"What to look for",
|
||||
)}
|
||||
value={row.description}
|
||||
onChange={(event) =>
|
||||
setRow(index, { description: event.currentTarget.value })
|
||||
}
|
||||
disabled={disabled}
|
||||
/>
|
||||
<Button
|
||||
variant="quiet"
|
||||
size="sm"
|
||||
shape="circle"
|
||||
leftSection={
|
||||
<DeleteOutlineRoundedIcon style={{ fontSize: "1.1rem" }} />
|
||||
}
|
||||
aria-label={t("extractFields.fields.remove", "Remove field")}
|
||||
onClick={() => removeRow(index)}
|
||||
disabled={disabled}
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<Group gap="xs">
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
leftSection={<AddRoundedIcon style={{ fontSize: "1rem" }} />}
|
||||
onClick={() =>
|
||||
onParameterChange("fields", [...parameters.fields, emptyFieldRow()])
|
||||
}
|
||||
disabled={disabled}
|
||||
>
|
||||
{t("extractFields.fields.add", "Add field")}
|
||||
</Button>
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
leftSection={<AutoAwesomeRoundedIcon style={{ fontSize: "1rem" }} />}
|
||||
onClick={suggestFields}
|
||||
loading={suggesting}
|
||||
disabled={disabled || !selectedFile}
|
||||
>
|
||||
{t("extractFields.suggest.button", "Suggest fields (AI)")}
|
||||
</Button>
|
||||
</Group>
|
||||
{!selectedFile && (
|
||||
<Text size="xs" c="dimmed">
|
||||
{t(
|
||||
"extractFields.suggest.needsFile",
|
||||
"Select a file first to suggest fields",
|
||||
)}
|
||||
</Text>
|
||||
)}
|
||||
<Select
|
||||
label={t("extractFields.mode.label", "Mode")}
|
||||
value={parameters.mode}
|
||||
onChange={(value) =>
|
||||
onParameterChange("mode", (value ?? "auto") as DocparseMode)
|
||||
}
|
||||
data={[
|
||||
{ value: "auto", label: t("extractFields.mode.auto", "Auto") },
|
||||
{ value: "basic", label: t("extractFields.mode.basic", "Basic") },
|
||||
{
|
||||
value: "advanced",
|
||||
label: t("extractFields.mode.advanced", "Advanced"),
|
||||
},
|
||||
]}
|
||||
disabled={disabled}
|
||||
/>
|
||||
<Textarea
|
||||
label={t("extractFields.instructions.label", "Instructions")}
|
||||
placeholder={t(
|
||||
"extractFields.instructions.placeholder",
|
||||
"e.g. Amounts are in EUR unless stated otherwise",
|
||||
)}
|
||||
value={parameters.instructions}
|
||||
onChange={(event) =>
|
||||
onParameterChange("instructions", event.currentTarget.value)
|
||||
}
|
||||
minRows={2}
|
||||
autosize
|
||||
disabled={disabled}
|
||||
/>
|
||||
</Stack>
|
||||
);
|
||||
};
|
||||
|
||||
export default ExtractFieldsSettings;
|
||||
@@ -0,0 +1,51 @@
|
||||
import { useMemo } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
import LocalIcon from "@app/components/shared/LocalIcon";
|
||||
import {
|
||||
SubcategoryId,
|
||||
ToolCategoryId,
|
||||
type ProprietaryToolRegistry,
|
||||
} from "@app/data/toolsTaxonomy";
|
||||
import { asRegistryConfig } from "@app/hooks/tools/shared/toolOperationTypes";
|
||||
import { useDocparseEnabled } from "@app/hooks/useDocparseEnabled";
|
||||
import { extractFieldsOperationConfig } from "@app/hooks/tools/extractFields/extractFieldsOperationConfig";
|
||||
import ExtractFields from "@app/tools/ExtractFields";
|
||||
import { getSynonyms } from "@app/utils/toolSynonyms";
|
||||
|
||||
const toolIcon = (icon: string) => (
|
||||
<LocalIcon icon={icon} width="1.5rem" height="1.5rem" />
|
||||
);
|
||||
|
||||
/**
|
||||
* Proprietary tool registry extension - the DocParse tool family.
|
||||
* Overrides the empty stub at {@code core/data/useProprietaryToolRegistry.tsx}.
|
||||
* Hidden entirely while the backend reports docparse disabled.
|
||||
*/
|
||||
export function useProprietaryToolRegistry(): ProprietaryToolRegistry {
|
||||
const { t } = useTranslation();
|
||||
const docparseEnabled = useDocparseEnabled();
|
||||
|
||||
return useMemo(() => {
|
||||
if (!docparseEnabled) return {} as ProprietaryToolRegistry;
|
||||
return {
|
||||
extractFields: {
|
||||
icon: toolIcon("fact-check-outline-rounded"),
|
||||
name: t("home.extractFields.title", "Extract Fields"),
|
||||
component: ExtractFields,
|
||||
description: t(
|
||||
"home.extractFields.desc",
|
||||
"Pull typed fields out of a document with confidence scores and citations",
|
||||
),
|
||||
categoryId: ToolCategoryId.STANDARD_TOOLS,
|
||||
subcategoryId: SubcategoryId.DOCUMENT_INTELLIGENCE,
|
||||
maxFiles: 1,
|
||||
endpoints: ["extract-fields"],
|
||||
operationConfig: asRegistryConfig(extractFieldsOperationConfig),
|
||||
automationSettings: null,
|
||||
synonyms: getSynonyms(t, "extractFields"),
|
||||
versionStatus: "beta",
|
||||
},
|
||||
} as ProprietaryToolRegistry;
|
||||
}, [docparseEnabled, t]);
|
||||
}
|
||||
+71
@@ -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);
|
||||
}
|
||||
+15
@@ -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"),
|
||||
),
|
||||
});
|
||||
};
|
||||
+35
@@ -0,0 +1,35 @@
|
||||
import { BaseParameters } from "@app/types/parameters";
|
||||
import {
|
||||
useBaseParameters,
|
||||
BaseParametersHook,
|
||||
} from "@app/hooks/tools/shared/useBaseParameters";
|
||||
import type { DocparseMode } from "@app/hooks/tools/shared/docparseTypes";
|
||||
import {
|
||||
emptyFieldRow,
|
||||
namedRows,
|
||||
type FieldRow,
|
||||
} from "@app/hooks/tools/extractFields/fieldsSchema";
|
||||
|
||||
export interface ExtractFieldsParameters extends BaseParameters {
|
||||
/** Schema-builder rows; serialized to fieldsSchema on execute. */
|
||||
fields: FieldRow[];
|
||||
instructions: string;
|
||||
mode: DocparseMode;
|
||||
}
|
||||
|
||||
export const defaultParameters: ExtractFieldsParameters = {
|
||||
fields: [emptyFieldRow()],
|
||||
instructions: "",
|
||||
mode: "auto",
|
||||
};
|
||||
|
||||
export type ExtractFieldsParametersHook =
|
||||
BaseParametersHook<ExtractFieldsParameters>;
|
||||
|
||||
export const useExtractFieldsParameters = (): ExtractFieldsParametersHook => {
|
||||
return useBaseParameters({
|
||||
defaultParameters,
|
||||
endpointName: "extract-fields",
|
||||
validateFn: (params) => namedRows(params.fields).length > 0,
|
||||
});
|
||||
};
|
||||
@@ -0,0 +1,12 @@
|
||||
/** Filename helpers shared by the DocParse tool processors. */
|
||||
|
||||
/** "invoice.pdf" -> "invoice"; keeps names without an extension intact. */
|
||||
export function stripExtension(fileName: string): string {
|
||||
const dot = fileName.lastIndexOf(".");
|
||||
return dot > 0 ? fileName.slice(0, dot) : fileName;
|
||||
}
|
||||
|
||||
/** Derived output name, e.g. deriveName("a.pdf", ".fields.json") -> "a.fields.json". */
|
||||
export function deriveName(inputName: string, suffix: string): string {
|
||||
return `${stripExtension(inputName)}${suffix}`;
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
/** Parse-tier request modes shared by the DocParse tool family. */
|
||||
export type DocparseMode = "auto" | "basic" | "advanced";
|
||||
@@ -0,0 +1,71 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import apiClient from "@app/services/apiClient";
|
||||
|
||||
/** The merged capability view served by GET /api/v1/docparse/capabilities. */
|
||||
export interface DocparseCapabilities {
|
||||
enabled: boolean;
|
||||
mode: string;
|
||||
advancedInstalled: boolean;
|
||||
engineReachable: boolean;
|
||||
doclingVersion: string | null;
|
||||
}
|
||||
|
||||
// Module-level cache so every intro card shares one fetch per page load,
|
||||
// mirroring the useEndpointConfig global-cache pattern.
|
||||
let cachedCapabilities: DocparseCapabilities | null = null;
|
||||
let inFlight: Promise<DocparseCapabilities | null> | null = null;
|
||||
|
||||
async function fetchCapabilities(): Promise<DocparseCapabilities | null> {
|
||||
try {
|
||||
const response = await apiClient.get<DocparseCapabilities>(
|
||||
"/api/v1/docparse/capabilities",
|
||||
{ suppressErrorToast: true, skipAuthRedirect: true },
|
||||
);
|
||||
cachedCapabilities = response.data;
|
||||
return cachedCapabilities;
|
||||
} catch {
|
||||
return null;
|
||||
} finally {
|
||||
inFlight = null;
|
||||
}
|
||||
}
|
||||
|
||||
/** Test seam: forget the cached capabilities so the next mount refetches. */
|
||||
export function resetDocparseCapabilitiesCache() {
|
||||
cachedCapabilities = null;
|
||||
inFlight = null;
|
||||
}
|
||||
|
||||
/**
|
||||
* The DocParse capability report (tier, engine reachability), module-cached so
|
||||
* the intro card on every docparse tool costs at most one request.
|
||||
*/
|
||||
export function useDocparseCapabilities(): {
|
||||
capabilities: DocparseCapabilities | null;
|
||||
loading: boolean;
|
||||
} {
|
||||
const [capabilities, setCapabilities] = useState<DocparseCapabilities | null>(
|
||||
cachedCapabilities,
|
||||
);
|
||||
const [loading, setLoading] = useState(cachedCapabilities === null);
|
||||
|
||||
useEffect(() => {
|
||||
if (cachedCapabilities) {
|
||||
setCapabilities(cachedCapabilities);
|
||||
setLoading(false);
|
||||
return;
|
||||
}
|
||||
let cancelled = false;
|
||||
inFlight = inFlight ?? fetchCapabilities();
|
||||
inFlight.then((result) => {
|
||||
if (cancelled) return;
|
||||
setCapabilities(result);
|
||||
setLoading(false);
|
||||
});
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, []);
|
||||
|
||||
return { capabilities, loading };
|
||||
}
|
||||
@@ -18,6 +18,7 @@ describe("POLICY_OPERATIONS", () => {
|
||||
"classify",
|
||||
"compress",
|
||||
"externalApiCall",
|
||||
"extractFields",
|
||||
"flatten",
|
||||
"ocr",
|
||||
"purviewApplyLabel",
|
||||
|
||||
@@ -37,7 +37,9 @@ export type IntegrationPolicyEndpoint =
|
||||
* {@link ToolEndpoint} union: the DocParse controllers live in the proprietary
|
||||
* module, outside the tool namespaces the generator reads.
|
||||
*/
|
||||
export type DocparsePolicyEndpoint = "/api/v1/docparse/rag-ingest";
|
||||
export type DocparsePolicyEndpoint =
|
||||
| "/api/v1/docparse/rag-ingest"
|
||||
| "/api/v1/docparse/extract-fields";
|
||||
|
||||
/** An endpoint typed here rather than by the generator. */
|
||||
export type UntypedPolicyEndpoint =
|
||||
@@ -97,6 +99,34 @@ function describeIntegrationOperation<TParams extends Record<string, string>>(
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract Fields as a policy step (the pipeline shape of the DocParse tool).
|
||||
* The schema is authored in the wizard and crosses the wire verbatim; a blank
|
||||
* `instructions` is dropped so the backend sees "absent", not "empty".
|
||||
*/
|
||||
function describeExtractFieldsOperation(): AiToolDescriptor<{
|
||||
fieldsSchema: string;
|
||||
mode: string;
|
||||
instructions: string;
|
||||
}> {
|
||||
return {
|
||||
endpoint: "/api/v1/docparse/extract-fields",
|
||||
defaultParameters: { fieldsSchema: "", mode: "auto", instructions: "" },
|
||||
toApi: (params) => ({
|
||||
fieldsSchema: params.fieldsSchema,
|
||||
mode: params.mode || "auto",
|
||||
...(params.instructions.trim()
|
||||
? { instructions: params.instructions.trim() }
|
||||
: {}),
|
||||
}),
|
||||
fromApi: (api) => ({
|
||||
fieldsSchema: api.fieldsSchema == null ? "" : String(api.fieldsSchema),
|
||||
mode: api.mode == null ? "auto" : String(api.mode),
|
||||
instructions: api.instructions == null ? "" : String(api.instructions),
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* RAG ingest as a policy step: chunk + embed + index in one step, with optional
|
||||
* corpus export (markdown, chunks JSONL) for external systems. Sizes and flags
|
||||
@@ -178,6 +208,7 @@ export const POLICY_OPERATIONS = {
|
||||
compressOperationConfig,
|
||||
),
|
||||
classify: describeAiToolOperation("/api/v1/ai/tools/classify-and-label"),
|
||||
extractFields: describeExtractFieldsOperation(),
|
||||
ragIngest: describeRagIngestOperation(),
|
||||
purviewApplyLabel: describeIntegrationOperation(
|
||||
"/api/v1/integration/purview-apply-label",
|
||||
|
||||
@@ -0,0 +1,120 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Badge, Group, Stack, Text } from "@mantine/core";
|
||||
import { createToolFlow } from "@app/components/tools/shared/createToolFlow";
|
||||
import { useBaseTool } from "@app/hooks/tools/shared/useBaseTool";
|
||||
import type { BaseToolProps } from "@app/types/tool";
|
||||
import ExtractFieldsSettings from "@app/components/tools/docparse/ExtractFieldsSettings";
|
||||
import { useExtractFieldsParameters } from "@app/hooks/tools/extractFields/useExtractFieldsParameters";
|
||||
import { useExtractFieldsOperation } from "@app/hooks/tools/extractFields/useExtractFieldsOperation";
|
||||
import type { ExtractFieldsResult } from "@app/hooks/tools/extractFields/extractFieldsOperationConfig";
|
||||
|
||||
/** Badge color mirroring the portal's confidence tones. */
|
||||
const confidenceColor = (confidence: number): string => {
|
||||
if (confidence < 0.6) return "red";
|
||||
if (confidence < 0.85) return "yellow";
|
||||
return "green";
|
||||
};
|
||||
|
||||
const ExtractFields = (props: BaseToolProps) => {
|
||||
const { t } = useTranslation();
|
||||
|
||||
const base = useBaseTool(
|
||||
"extractFields",
|
||||
useExtractFieldsParameters,
|
||||
useExtractFieldsOperation,
|
||||
props,
|
||||
);
|
||||
|
||||
// The processor stores the extraction report as the result file; re-read it
|
||||
// here so the fields render inline with confidence and quotes.
|
||||
const [result, setResult] = useState<ExtractFieldsResult | null>(null);
|
||||
const resultFile = base.operation.files[0] ?? null;
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
if (!resultFile) {
|
||||
setResult(null);
|
||||
return;
|
||||
}
|
||||
resultFile
|
||||
.text()
|
||||
.then((text) => {
|
||||
if (!cancelled) setResult(JSON.parse(text) as ExtractFieldsResult);
|
||||
})
|
||||
.catch(() => {
|
||||
if (!cancelled) setResult(null);
|
||||
});
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [resultFile]);
|
||||
|
||||
return createToolFlow({
|
||||
files: {
|
||||
selectedFiles: base.selectedFiles,
|
||||
isCollapsed: base.hasResults,
|
||||
},
|
||||
steps: [
|
||||
{
|
||||
title: t("extractFields.settings.title", "Extraction schema"),
|
||||
isCollapsed: false,
|
||||
content: (
|
||||
<ExtractFieldsSettings
|
||||
parameters={base.params.parameters}
|
||||
onParameterChange={base.params.updateParameter}
|
||||
disabled={base.endpointLoading}
|
||||
selectedFile={base.selectedFiles[0] ?? null}
|
||||
/>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: t("extractFields.resultsPanel.title", "Extracted fields"),
|
||||
isVisible: base.hasResults && result !== null,
|
||||
isCollapsed: false,
|
||||
content: (
|
||||
<Stack gap="sm">
|
||||
{(result?.fields ?? []).map((field) => (
|
||||
<Stack key={field.name} gap={2}>
|
||||
<Group gap="xs" wrap="nowrap">
|
||||
<Text size="sm" fw={600} style={{ flex: 1 }} truncate>
|
||||
{field.name}
|
||||
</Text>
|
||||
<Badge
|
||||
size="sm"
|
||||
variant="light"
|
||||
color={confidenceColor(field.confidence)}
|
||||
>
|
||||
{Math.round(field.confidence * 100)}%
|
||||
</Badge>
|
||||
</Group>
|
||||
<Text size="sm">{String(field.value ?? "-")}</Text>
|
||||
{field.citations?.[0]?.quote && (
|
||||
<Text size="xs" c="dimmed" fs="italic">
|
||||
“{field.citations[0].quote}”
|
||||
</Text>
|
||||
)}
|
||||
</Stack>
|
||||
))}
|
||||
</Stack>
|
||||
),
|
||||
},
|
||||
],
|
||||
executeButton: {
|
||||
text: t("extractFields.submit", "Extract fields"),
|
||||
isVisible: !base.hasResults,
|
||||
loadingText: t("loading"),
|
||||
onClick: base.handleExecute,
|
||||
endpointEnabled: base.endpointEnabled,
|
||||
paramsValid: base.params.validateParameters(),
|
||||
},
|
||||
review: {
|
||||
isVisible: base.hasResults,
|
||||
operation: base.operation,
|
||||
title: t("extractFields.results.title", "Extraction report"),
|
||||
onFileClick: base.handleThumbnailClick,
|
||||
onUndo: base.handleUndo,
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
export default ExtractFields;
|
||||
@@ -4,7 +4,8 @@
|
||||
* to add proprietary-specific tool IDs.
|
||||
*/
|
||||
|
||||
export const PROPRIETARY_REGULAR_TOOL_IDS = [] as const;
|
||||
// The DocParse tool family; visible only when the backend reports docparseEnabled.
|
||||
export const PROPRIETARY_REGULAR_TOOL_IDS = ["extractFields"] as const;
|
||||
|
||||
// "ai-workflow" is a generic marker stamped onto files produced by the agents
|
||||
// chat orchestrator (which may invoke one or more underlying tools). Lives here
|
||||
|
||||
Reference in New Issue
Block a user