Compare commits

..
54 changed files with 2698 additions and 8 deletions
@@ -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");
@@ -24,6 +24,7 @@ import stirling.software.common.annotations.api.ConfigApi;
import stirling.software.common.configuration.AppConfig;
import stirling.software.common.configuration.interfaces.ShowAdminInterface;
import stirling.software.common.model.ApplicationProperties;
import stirling.software.common.service.DocparseCapabilityServiceInterface;
import stirling.software.common.service.ServerCertificateServiceInterface;
import stirling.software.common.service.UserServiceInterface;
import stirling.software.common.util.GeneralUtils;
@@ -41,6 +42,7 @@ public class ConfigController {
private final ShowAdminInterface showAdmin;
private final stirling.software.common.service.LicenseServiceInterface licenseService;
private final stirling.software.SPDF.config.ExternalAppDepConfig externalAppDepConfig;
private final DocparseCapabilityServiceInterface docparseCapabilityService;
public ConfigController(
ApplicationProperties applicationProperties,
@@ -54,7 +56,9 @@ public class ConfigController {
ShowAdminInterface showAdmin,
@org.springframework.beans.factory.annotation.Autowired(required = false)
stirling.software.common.service.LicenseServiceInterface licenseService,
stirling.software.SPDF.config.ExternalAppDepConfig externalAppDepConfig) {
stirling.software.SPDF.config.ExternalAppDepConfig externalAppDepConfig,
@org.springframework.beans.factory.annotation.Autowired(required = false)
DocparseCapabilityServiceInterface docparseCapabilityService) {
this.applicationProperties = applicationProperties;
this.applicationContext = applicationContext;
this.endpointConfiguration = endpointConfiguration;
@@ -63,6 +67,7 @@ public class ConfigController {
this.showAdmin = showAdmin;
this.licenseService = licenseService;
this.externalAppDepConfig = externalAppDepConfig;
this.docparseCapabilityService = docparseCapabilityService;
}
/**
@@ -350,6 +355,16 @@ public class ConfigController {
Map.entry("pdfComment", aiFeatures.isPdfComment()),
Map.entry("classify", aiFeatures.isClassify())));
// DocParse settings; "advanced" reflects the cached engine capability probe and is
// false when the engine is disabled, unreachable, or the proprietary module is absent.
boolean docparseEnabled = applicationProperties.getDocparse().isEnabled();
configData.put("docparseEnabled", docparseEnabled);
configData.put(
"docparseAdvanced",
docparseEnabled
&& docparseCapabilityService != null
&& docparseCapabilityService.isAdvancedInstalled());
// Timestamp TSA settings — single source of truth for presets + admin URLs
ApplicationProperties.Security.Timestamp tsConfig =
applicationProperties.getSecurity().getTimestamp();
@@ -74,7 +74,8 @@ class ConfigControllerMoreTest {
userService,
showAdmin,
licenseService,
externalAppDepConfig);
externalAppDepConfig,
null);
}
@SuppressWarnings("unchecked")
@@ -52,7 +52,8 @@ class ConfigControllerTest {
userService,
showAdmin,
licenseService,
mock(stirling.software.SPDF.config.ExternalAppDepConfig.class));
mock(stirling.software.SPDF.config.ExternalAppDepConfig.class),
null);
}
@Test
@@ -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",
@@ -0,0 +1,29 @@
package stirling.software.proprietary.model.api.docparse;
import io.swagger.v3.oas.annotations.media.Schema;
import lombok.Data;
import lombok.EqualsAndHashCode;
import stirling.software.common.model.api.PDFFile;
@Data
@EqualsAndHashCode(callSuper = true)
public class ExtractFieldsApiRequest extends PDFFile {
@Schema(
description = "JSON Schema object describing the fields to extract, as a JSON string",
requiredMode = Schema.RequiredMode.REQUIRED,
example =
"{\"type\":\"object\",\"properties\":{\"invoiceNumber\":{\"type\":\"string\"}}}")
private String fieldsSchema;
@Schema(
description = "Tier to use: 'auto' picks per document, or force 'basic'/'advanced'",
allowableValues = {"auto", "basic", "advanced"},
defaultValue = "auto")
private String mode = "auto";
@Schema(description = "Optional natural-language guidance for the extraction")
private String instructions;
}
@@ -0,0 +1,16 @@
package stirling.software.proprietary.model.api.docparse;
import io.swagger.v3.oas.annotations.media.Schema;
import lombok.Data;
import lombok.EqualsAndHashCode;
import stirling.software.common.model.api.PDFFile;
@Data
@EqualsAndHashCode(callSuper = true)
public class SuggestSchemaApiRequest extends PDFFile {
@Schema(description = "Maximum number of fields to suggest (1-20)", defaultValue = "10")
private int maxFields = 10;
}
@@ -0,0 +1,19 @@
package stirling.software.proprietary.model.docparse;
import java.util.List;
import stirling.software.proprietary.model.api.ai.AiPageText;
import tools.jackson.databind.JsonNode;
/**
* Engine request for {@code POST /api/v1/docparse/extract}. {@code pages} drives the basic tier
* (Java-extracted text); {@code contentBase64} lets the advanced tier parse the raw file itself.
*/
public record ExtractFieldsRequest(
String fileName,
JsonNode fieldsSchema,
List<AiPageText> pages,
String contentBase64,
DocparseMode mode,
String instructions) {}
@@ -0,0 +1,12 @@
package stirling.software.proprietary.model.docparse;
import java.util.List;
/** Engine response for {@code POST /api/v1/docparse/extract}. */
public record ExtractFieldsResponse(
DocparseTier mode, List<ExtractedField> fields, double overallConfidence) {
public ExtractFieldsResponse {
fields = fields == null ? List.of() : fields;
}
}
@@ -0,0 +1,16 @@
package stirling.software.proprietary.model.docparse;
import java.util.List;
import tools.jackson.databind.JsonNode;
/**
* One extracted field with confidence and citations. Mirrors {@code docparse.py ExtractedField}.
*/
public record ExtractedField(
String name, JsonNode value, double confidence, List<FieldCitation> citations) {
public ExtractedField {
citations = citations == null ? List.of() : citations;
}
}
@@ -0,0 +1,11 @@
package stirling.software.proprietary.model.docparse;
import java.util.List;
/**
* Where a value came from. {@code quote} is always set; {@code bbox} only when a layout parse ran
* (advanced tier); offsets index into the cited page's text. Mirrors {@code docparse.py
* FieldCitation}.
*/
public record FieldCitation(
Integer page, List<Double> bbox, String quote, Integer startOffset, Integer endOffset) {}
@@ -0,0 +1,12 @@
package stirling.software.proprietary.model.docparse;
import java.util.List;
import stirling.software.proprietary.model.api.ai.AiPageText;
/**
* Engine request for {@code POST /api/v1/docparse/suggest-schema}. {@code pages} drives the basic
* tier (Java-extracted text); {@code contentBase64} lets the advanced tier parse the raw file.
*/
public record SuggestSchemaRequest(
String fileName, List<AiPageText> pages, String contentBase64, int maxFields) {}
@@ -0,0 +1,11 @@
package stirling.software.proprietary.model.docparse;
import java.util.List;
/** Engine response for {@code POST /api/v1/docparse/suggest-schema}. */
public record SuggestSchemaResponse(DocparseTier mode, List<SuggestedField> fields) {
public SuggestSchemaResponse {
fields = fields == null ? List.of() : fields;
}
}
@@ -0,0 +1,4 @@
package stirling.software.proprietary.model.docparse;
/** One field the engine proposes for an extraction schema. */
public record SuggestedField(String name, String type, String description) {}
@@ -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.
+5
View File
@@ -18,6 +18,7 @@ from stirling.agents import (
from stirling.agents.ledger import MathAuditorAgent
from stirling.agents.pdf_comment import PdfCommentAgent
from stirling.config import AppSettings
from stirling.docparse import ExtractFieldsAgent, SuggestSchemaAgent
from stirling.documents import DocumentService, EmbeddingService
from stirling.services import AppRuntime, build_runtime
@@ -35,6 +36,8 @@ class AppState:
math_auditor_agent: MathAuditorAgent
pdf_comment_agent: PdfCommentAgent
document_classifier_agent: DocumentClassifierAgent
extract_fields_agent: ExtractFieldsAgent
suggest_schema_agent: SuggestSchemaAgent
def build_app_state(
@@ -63,6 +66,8 @@ def build_app_state(
math_auditor_agent=MathAuditorAgent(runtime),
pdf_comment_agent=PdfCommentAgent(runtime),
document_classifier_agent=DocumentClassifierAgent(runtime),
extract_fields_agent=ExtractFieldsAgent(runtime),
suggest_schema_agent=SuggestSchemaAgent(runtime),
)
+9
View File
@@ -15,6 +15,7 @@ from stirling.agents import (
from stirling.agents.ledger import MathAuditorAgent
from stirling.agents.pdf_comment import PdfCommentAgent
from stirling.config import AppSettings, load_settings
from stirling.docparse import ExtractFieldsAgent, SuggestSchemaAgent
from stirling.documents import DocumentService
from stirling.models import UserId
from stirling.services import AppRuntime, current_user_id
@@ -60,6 +61,14 @@ def get_document_classifier_agent(request: Request) -> DocumentClassifierAgent:
return request.app.state.document_classifier_agent
def get_extract_fields_agent(request: Request) -> ExtractFieldsAgent:
return request.app.state.extract_fields_agent
def get_suggest_schema_agent(request: Request) -> SuggestSchemaAgent:
return request.app.state.suggest_schema_agent
def require_user_id() -> UserId:
"""FastAPI dependency for routes that touch per-user storage.
+70 -1
View File
@@ -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:
+67 -1
View File
@@ -10,7 +10,7 @@ from __future__ import annotations
from datetime import datetime
from enum import StrEnum
from pydantic import Field
from pydantic import Field, JsonValue
from stirling.contracts.documents import PageText
from stirling.models import ApiModel, FileId, OwnerId, PrincipalId
@@ -140,6 +140,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."""
+4
View File
@@ -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",
+146
View File
@@ -0,0 +1,146 @@
"""Extraction accuracy harness: score a gold-labelled case set against a live engine.
Case layout (one directory per document):
cases/
invoice-001/
input.pdf # the document
expected.json # {"fieldsSchema": {...}, "fields": {"invoice_number": "INV-1", ...},
# "pages": [{"pageNumber": 1, "text": "..."}]? (optional, for basic tier)}
Run:
uv run python -m stirling.docparse.evals cases/ --engine http://localhost:5001 --output report.json
Scoring per field: exact match, then normalized match (case/whitespace/currency
punctuation collapsed, numeric tolerance 1e-6). The report aggregates per-case
and overall accuracy so pipeline changes can be regression-tracked. Stdlib
HTTP only - the harness must run anywhere the engine runs.
"""
from __future__ import annotations
import argparse
import base64
import json
import re
import sys
import urllib.error
import urllib.request
from pathlib import Path
from typing import Any
_NORMALIZE_STRIP = re.compile(r"[\s,€$£%]+")
def normalized_equal(expected: Any, actual: Any) -> bool:
if expected is None or actual is None:
return expected is actual
try:
return abs(float(expected) - float(actual)) < 1e-6
except (TypeError, ValueError):
pass
if isinstance(expected, list) and isinstance(actual, list):
return len(expected) == len(actual) and all(normalized_equal(e, a) for e, a in zip(expected, actual))
return _NORMALIZE_STRIP.sub("", str(expected)).casefold() == _NORMALIZE_STRIP.sub("", str(actual)).casefold()
def _call_extract(engine: str, secret: str | None, payload: dict[str, Any]) -> dict[str, Any]:
request = urllib.request.Request(
f"{engine.rstrip('/')}/api/v1/docparse/extract",
data=json.dumps(payload).encode("utf-8"),
headers={"Content-Type": "application/json", **({"X-Engine-Auth": secret} if secret else {})},
method="POST",
)
with urllib.request.urlopen(request, timeout=600) as response:
return json.loads(response.read().decode("utf-8"))
def score_case(engine: str, secret: str | None, case_dir: Path) -> dict[str, Any]:
expected_spec = json.loads((case_dir / "expected.json").read_text(encoding="utf-8"))
payload: dict[str, Any] = {
"fileName": "input.pdf",
"fieldsSchema": expected_spec["fieldsSchema"],
}
input_pdf = case_dir / "input.pdf"
if input_pdf.exists():
payload["contentBase64"] = base64.b64encode(input_pdf.read_bytes()).decode("ascii")
if expected_spec.get("pages"):
payload["pages"] = expected_spec["pages"]
response = _call_extract(engine, secret, payload)
actual = {field["name"]: field for field in response.get("fields", [])}
fields: list[dict[str, Any]] = []
exact = 0
normalized = 0
for name, expected_value in expected_spec.get("fields", {}).items():
actual_field = actual.get(name, {})
actual_value = actual_field.get("value")
is_exact = expected_value == actual_value
is_normalized = is_exact or normalized_equal(expected_value, actual_value)
exact += is_exact
normalized += is_normalized
fields.append(
{
"name": name,
"expected": expected_value,
"actual": actual_value,
"exact": is_exact,
"normalized": is_normalized,
"confidence": actual_field.get("confidence"),
"cited": bool(actual_field.get("citations")),
}
)
total = len(fields) or 1
return {
"case": case_dir.name,
"mode": response.get("mode"),
"fields": fields,
"exactAccuracy": round(exact / total, 4),
"normalizedAccuracy": round(normalized / total, 4),
}
def run(cases_root: Path, engine: str, secret: str | None) -> dict[str, Any]:
case_dirs = sorted(d for d in cases_root.iterdir() if d.is_dir() and (d / "expected.json").exists())
results: list[dict[str, Any]] = []
failures: list[dict[str, str]] = []
for case_dir in case_dirs:
try:
results.append(score_case(engine, secret, case_dir))
except (urllib.error.URLError, OSError, KeyError, ValueError) as error:
failures.append({"case": case_dir.name, "error": str(error)})
scored = [r for r in results if r["fields"]]
overall = {
"cases": len(case_dirs),
"scored": len(scored),
"errors": failures,
"exactAccuracy": round(sum(r["exactAccuracy"] for r in scored) / len(scored), 4) if scored else 0.0,
"normalizedAccuracy": round(sum(r["normalizedAccuracy"] for r in scored) / len(scored), 4) if scored else 0.0,
"results": results,
}
return overall
def main() -> int:
parser = argparse.ArgumentParser(description="Score docparse extraction against a gold case set.")
parser.add_argument("cases", help="Directory of case subdirectories")
parser.add_argument("--engine", default="http://localhost:5001", help="Engine base URL")
parser.add_argument("--secret", default=None, help="X-Engine-Auth shared secret, if the engine requires it")
parser.add_argument("--output", default=None, help="Write the JSON report here (default: stdout)")
args = parser.parse_args()
report = run(Path(args.cases), args.engine, args.secret)
rendered = json.dumps(report, indent=2)
if args.output:
Path(args.output).write_text(rendered, encoding="utf-8")
print(f"exact={report['exactAccuracy']} normalized={report['normalizedAccuracy']} -> {args.output}")
else:
print(rendered)
return 0 if not report["errors"] else 1
if __name__ == "__main__":
sys.exit(main())
+320
View File
@@ -0,0 +1,320 @@
"""Schema-driven field extraction with grounded citations and confidence.
The caller supplies a JSON Schema (subset: scalar types, enums, arrays of
scalars, nested objects). We build a dynamic pydantic output model where every
leaf answers ``{value, quote, confidence}``, run one smart-model pass, then
ground each quote against the page text in code. Model confidence is damped
when a quote can't be found - the model asserts, the grounding decides how
much to believe it.
"""
from __future__ import annotations
import logging
import re
from typing import Any
from pydantic import BaseModel, Field, JsonValue, create_model
from pydantic_ai import Agent
from stirling.agents.output_mode import output_retries, structured_output
from stirling.contracts.docparse import (
DocparseTier,
ExtractedField,
ExtractFieldsRequest,
ExtractFieldsResponse,
FieldCitation,
ParseDocumentResponse,
)
from stirling.contracts.documents import PageText
from stirling.models import ApiModel
from stirling.services import AppRuntime
logger = logging.getLogger(__name__)
# Confidence multiplier when the supporting quote can't be found in the document.
UNGROUNDED_PENALTY = 0.6
# Floor when the model omitted quote/confidence but the value itself is found
# verbatim in the document - the grounding is real even if the model was terse.
VALUE_GROUNDED_FLOOR = 0.5
MAX_SCHEMA_DEPTH = 3
MAX_FIELDS = 100
_SYSTEM_PROMPT = (
"You extract structured fields from a document.\n"
"\n"
"Rules:\n"
"- For every field, return the value exactly as the schema types it, a short VERBATIM quote "
"from the document that supports it, and your confidence from 0.0 to 1.0.\n"
"- The quote must be copied character-for-character from the document text, at most 200 characters.\n"
"- If the document does not contain the field, return value null, quote null, confidence 0.0. "
"Never guess or fabricate.\n"
"- Dates: return them formatted as the schema/description asks; quote the original text.\n"
"- The document may be in any language."
)
class SchemaError(ValueError):
"""The supplied JSON Schema is outside the supported subset."""
def _scalar_type(spec: dict[str, Any]) -> Any:
# Enums stay str-typed; the allowed values travel in the field description
# (dynamic Literal types don't typecheck and local models handle them badly).
match spec.get("type"):
case "string":
return str
case "integer":
return int
case "number":
return float
case "boolean":
return bool
case _:
raise SchemaError(f"Unsupported schema type: {spec.get('type')!r}")
def _describe(spec: dict[str, Any]) -> str | None:
description = spec.get("description") if isinstance(spec.get("description"), str) else None
enum = spec.get("enum")
if isinstance(enum, list) and enum:
allowed = ", ".join(str(v) for v in enum)
description = f"{description + ' ' if description else ''}Allowed values: {allowed}."
return description
def _leaf_answer_model(name: str, value_type: Any, description: str | None) -> type[BaseModel]:
return create_model(
f"Answer_{re.sub(r'[^A-Za-z0-9]', '_', name)}",
__base__=ApiModel,
value=(value_type | None, Field(default=None, description=description or None)),
quote=(str | None, Field(default=None, max_length=400)),
confidence=(float, Field(default=0.0, ge=0.0, le=1.0)),
)
def build_output_model(
fields_schema: dict[str, Any], *, _depth: int = 0, _name: str = "ExtractionOutput"
) -> type[BaseModel]:
"""Turn the caller's JSON Schema into a pydantic model of leaf answers."""
if _depth > MAX_SCHEMA_DEPTH:
raise SchemaError(f"Schema nesting deeper than {MAX_SCHEMA_DEPTH} is not supported")
properties = fields_schema.get("properties")
if not isinstance(properties, dict) or not properties:
raise SchemaError("Schema must be an object with a non-empty 'properties' map")
if len(properties) > MAX_FIELDS:
raise SchemaError(f"Schema has more than {MAX_FIELDS} fields")
model_fields: dict[str, Any] = {}
for raw_name, spec in properties.items():
name = str(raw_name)
if not re.fullmatch(r"[A-Za-z_][A-Za-z0-9_]*", name):
raise SchemaError(f"Field name {name!r} must be a valid identifier")
if not isinstance(spec, dict):
raise SchemaError(f"Field {name!r} must map to a schema object")
description = _describe(spec)
if spec.get("type") == "object":
nested = build_output_model(spec, _depth=_depth + 1, _name=f"{_name}_{name}")
model_fields[name] = (nested, Field(...))
elif spec.get("type") == "array":
items = spec.get("items")
if not isinstance(items, dict):
raise SchemaError(f"Array field {name!r} needs an 'items' schema")
if items.get("type") == "object":
raise SchemaError(f"Array field {name!r}: arrays of objects are not supported yet")
item_type: Any = _scalar_type(items)
answer = _leaf_answer_model(name, list[item_type], description)
model_fields[name] = (answer, Field(...))
else:
answer = _leaf_answer_model(name, _scalar_type(spec), description)
model_fields[name] = (answer, Field(...))
return create_model(_name, __base__=ApiModel, **model_fields)
_WHITESPACE = re.compile(r"\s+")
def _normalize(text: str) -> str:
return _WHITESPACE.sub(" ", text).strip().casefold()
def find_quote(quote: str, pages: list[PageText]) -> tuple[int, int, int] | None:
"""Locate ``quote`` in the page texts, whitespace-insensitively.
Returns (page_number, start_offset, end_offset) into the page's raw text,
or None. Offsets are approximate under whitespace collapsing: we search the
normalized page, then map back by counting non-space characters.
"""
needle = _normalize(quote)
if not needle:
return None
for page in pages:
haystack = _normalize(page.text)
idx = haystack.find(needle)
if idx < 0:
continue
start = _denormalize_offset(page.text, idx)
end = _denormalize_offset(page.text, idx + len(needle))
return page.page_number, start, min(end, len(page.text))
return None
def _denormalize_offset(raw: str, normalized_offset: int) -> int:
"""Map an offset in the normalized string back into the raw string."""
count = 0
in_space = True # leading whitespace is stripped by _normalize
for i, ch in enumerate(raw):
if ch.isspace():
if in_space:
continue
in_space = True
else:
in_space = False
if count >= normalized_offset:
return i
count += 1
return len(raw)
def _bbox_for_quote(quote: str, parse: ParseDocumentResponse | None) -> list[float] | None:
if parse is None:
return None
needle = _normalize(quote)
if not needle:
return None
for block in parse.blocks:
if block.bbox is not None and needle in _normalize(block.text):
return block.bbox
return None
def flatten_answers(output: BaseModel, prefix: str = "") -> list[tuple[str, JsonValue, str | None, float]]:
"""Walk the dynamic output model into (dotted_name, value, quote, confidence) leaves."""
leaves: list[tuple[str, JsonValue, str | None, float]] = []
for name in type(output).model_fields:
node = getattr(output, name)
dotted = f"{prefix}{name}"
if isinstance(node, BaseModel) and "confidence" in type(node).model_fields:
value = getattr(node, "value", None)
quote = getattr(node, "quote", None)
confidence = float(getattr(node, "confidence", 0.0) or 0.0)
leaves.append((dotted, value, quote, confidence))
elif isinstance(node, BaseModel):
leaves.extend(flatten_answers(node, prefix=f"{dotted}."))
return leaves
def _format_pages(pages: list[PageText], max_characters: int) -> str:
parts: list[str] = []
used = 0
for page in pages:
snippet = page.text[: max(0, max_characters - used)]
parts.append(f"[Page {page.page_number}]\n{snippet}")
used += len(snippet)
if used >= max_characters:
break
return "\n\n".join(parts) if parts else "(no extractable text)"
def pages_from_parse(parse: ParseDocumentResponse) -> list[PageText]:
"""Rebuild per-page text from parse blocks (advanced path with no caller text)."""
by_page: dict[int, list[str]] = {}
for block in parse.blocks:
by_page.setdefault(block.page, []).append(block.text)
return [PageText(page_number=n, text="\n".join(t)) for n, t in sorted(by_page.items())]
class ExtractFieldsAgent:
"""One smart-model pass over the document, then code-side grounding."""
def __init__(self, runtime: AppRuntime) -> None:
self.runtime = runtime
async def extract(
self,
request: ExtractFieldsRequest,
pages: list[PageText],
parse: ParseDocumentResponse | None,
) -> ExtractFieldsResponse:
output_model = build_output_model(dict(request.fields_schema))
provider = self.runtime.settings.chat_provider
agent: Agent[None, BaseModel] = Agent(
model=self.runtime.smart_model,
output_type=structured_output([output_model], chat_provider=provider),
system_prompt=_SYSTEM_PROMPT,
model_settings=self.runtime.smart_model_settings,
retries=output_retries(provider),
)
prompt = self._build_prompt(request, pages)
result = await agent.run(prompt)
fields = self._ground(result.output, pages, parse)
overall = round(min((f.confidence for f in fields), default=0.0), 4)
tier = DocparseTier.ADVANCED if parse is not None else DocparseTier.BASIC
return ExtractFieldsResponse(mode=tier, fields=fields, overall_confidence=overall)
def _build_prompt(self, request: ExtractFieldsRequest, pages: list[PageText]) -> str:
instructions = f"Additional instructions: {request.instructions}\n\n" if request.instructions else ""
return (
f"{instructions}"
f"Document file name: {request.file_name}\n"
f"Document content:\n{_format_pages(pages, self.runtime.settings.max_characters)}"
)
@staticmethod
def _ground(
output: BaseModel,
pages: list[PageText],
parse: ParseDocumentResponse | None,
) -> list[ExtractedField]:
fields: list[ExtractedField] = []
for name, value, quote, model_confidence in flatten_answers(output):
citations: list[FieldCitation] = []
confidence = max(0.0, min(1.0, model_confidence))
if value is None:
confidence = 0.0
elif quote:
located = find_quote(quote, pages)
if located is not None:
page_number, start, end = located
citations.append(
FieldCitation(
page=page_number,
bbox=_bbox_for_quote(quote, parse),
quote=quote,
start_offset=start,
end_offset=end,
)
)
else:
citations.append(FieldCitation(page=None, bbox=None, quote=quote))
confidence *= UNGROUNDED_PENALTY
else:
# Terse models (local Ollama especially) often skip the quote;
# grounding the value itself keeps citations and a usable score.
value_text = _render_value(value)
located = find_quote(value_text, pages) if value_text else None
if located is not None:
page_number, start, end = located
citations.append(
FieldCitation(
page=page_number,
bbox=_bbox_for_quote(value_text, parse),
quote=value_text,
start_offset=start,
end_offset=end,
)
)
confidence = max(confidence, VALUE_GROUNDED_FLOOR)
else:
confidence *= UNGROUNDED_PENALTY
fields.append(ExtractedField(name=name, value=value, confidence=round(confidence, 4), citations=citations))
return fields
def _render_value(value: JsonValue) -> str:
"""A searchable text form of a leaf value; empty when nothing sensible exists."""
if value is None or isinstance(value, (dict, list)) or isinstance(value, bool):
return ""
return str(value)
@@ -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)
+64
View File
@@ -0,0 +1,64 @@
from __future__ import annotations
from pydantic import BaseModel
from stirling.contracts import PageText
from stirling.docparse.extractor import (
VALUE_GROUNDED_FLOOR,
ExtractFieldsAgent,
build_output_model,
find_quote,
)
def _pages() -> list[PageText]:
return [
PageText(page_number=1, text="Invoice INV-123\nTotal due: 1,240.00 EUR"),
PageText(page_number=2, text="Payment terms\nNet 30 days from receipt."),
]
def test_find_quote_exact() -> None:
located = find_quote("Invoice INV-123", _pages())
assert located is not None
page, start, end = located
assert page == 1
assert start == 0
def test_find_quote_is_whitespace_insensitive() -> None:
located = find_quote("Total due: 1,240.00 EUR", _pages())
assert located is not None
assert located[0] == 1
def test_find_quote_is_case_insensitive_and_crosses_pages() -> None:
located = find_quote("net 30 DAYS", _pages())
assert located is not None
assert located[0] == 2
def test_find_quote_missing_returns_none() -> None:
assert find_quote("does not appear", _pages()) is None
assert find_quote(" ", _pages()) is None
def _answers(quote: str | None, confidence: float) -> BaseModel:
model = build_output_model({"type": "object", "properties": {"invoice_number": {"type": "string"}}})
return model.model_validate({"invoiceNumber": {"value": "INV-123", "quote": quote, "confidence": confidence}})
def test_ground_falls_back_to_value_when_quote_missing() -> None:
# Terse local models return the value but no quote; the value itself grounds.
pages = [PageText(page_number=1, text="Invoice INV-123 issued today.")]
fields = ExtractFieldsAgent._ground(_answers(quote=None, confidence=0.0), pages, None)
assert fields[0].citations and fields[0].citations[0].page == 1
assert fields[0].citations[0].quote == "INV-123"
assert fields[0].confidence >= VALUE_GROUNDED_FLOOR
def test_ground_penalises_when_nothing_grounds() -> None:
pages = [PageText(page_number=1, text="completely unrelated text")]
fields = ExtractFieldsAgent._ground(_answers(quote=None, confidence=0.9), pages, None)
assert not fields[0].citations
assert fields[0].confidence < 0.9
@@ -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";
+17 -1
View File
@@ -141,7 +141,8 @@ export const ENDPOINT_LABELS: Partial<
Record<
| ToolEndpoint
| "/api/v1/ai/tools/classify-and-label"
| "/api/v1/docparse/rag-ingest",
| "/api/v1/docparse/rag-ingest"
| "/api/v1/docparse/extract-fields",
string
>
> = {
@@ -154,6 +155,7 @@ export const ENDPOINT_LABELS: Partial<
"/api/v1/ai/tools/classify-and-label":
"portal.policies.endpoints.classifyAndLabel",
"/api/v1/docparse/rag-ingest": "portal.policies.endpoints.ragIngest",
"/api/v1/docparse/extract-fields": "portal.policies.endpoints.extractFields",
};
export function humanizeEndpoint(
@@ -180,6 +182,13 @@ const DEFAULT_PII_PATTERNS: string[] = [
/** `label`/`desc` values are i18n keys — render with t(). */
export const POLICY_CATEGORIES: PolicyCategory[] = [
// First on purpose: document intelligence is the processor's flagship flow.
{
id: "docIntelligence",
label: "portal.policies.categories.docIntelligence.label",
tone: "blue",
desc: "portal.policies.categories.docIntelligence.desc",
},
{
id: "ingestion",
label: "portal.policies.categories.ingestion.label",
@@ -228,6 +237,13 @@ export const POLICY_CATEGORIES: PolicyCategory[] = [
* stay as stable values (translating them would corrupt saved configs).
*/
export const POLICY_CONFIG: Record<string, PolicyConfigDef> = {
docIntelligence: {
summary: "portal.policies.config.docIntelligence.summary",
rules: ["portal.policies.config.docIntelligence.rules.0"],
scopeLabel: "portal.policies.config.scopeAll",
defaultOperations: [policyStep("extractFields")],
fields: [],
},
ingestion: {
summary: "portal.policies.config.ingestion.summary",
rules: [
@@ -0,0 +1,54 @@
import { useTranslation } from "react-i18next";
import { FormField, Input } from "@app/ui";
/** Configures the extract-fields step: the schema and optional guidance. */
export interface ExtractFieldsStepParams {
fieldsSchema: string;
mode: string;
instructions: string;
}
interface PolicyExtractFieldsConfigProps {
parameters: ExtractFieldsStepParams;
onChange: (parameters: ExtractFieldsStepParams) => void;
}
export function PolicyExtractFieldsConfig({
parameters,
onChange,
}: PolicyExtractFieldsConfigProps) {
const { t } = useTranslation();
return (
<div className="portal-policies__capability-config">
<FormField
label={t("portal.policies.config.extractFields.fields.schema")}
helperText={t("portal.policies.config.extractFields.fields.schemaHelp")}
>
<textarea
className="portal-sources__connection-textarea"
rows={4}
value={parameters.fieldsSchema ?? ""}
placeholder='{"type": "object", "properties": {"invoice_number": {"type": "string"}}}'
onChange={(e) =>
onChange({ ...parameters, fieldsSchema: e.target.value })
}
/>
</FormField>
<FormField
label={t("portal.policies.config.extractFields.fields.instructions")}
helperText={t(
"portal.policies.config.extractFields.fields.instructionsHelp",
)}
>
<Input
inputSize="sm"
value={parameters.instructions ?? ""}
onChange={(e) =>
onChange({ ...parameters, instructions: e.target.value })
}
/>
</FormField>
</div>
);
}
@@ -40,6 +40,7 @@ import { PolicyCategoryBadge } from "@portal/components/policies/PolicyCategoryI
import { PolicyRedactConfig } from "@app/components/policies/PolicyRedactConfig";
import { PolicyWatermarkConfig } from "@app/components/policies/PolicyWatermarkConfig";
import { PolicyPurviewConfig } from "@portal/components/policies/PolicyPurviewConfig";
import { PolicyExtractFieldsConfig } from "@portal/components/policies/PolicyExtractFieldsConfig";
import { PolicyRagIngestConfig } from "@portal/components/policies/PolicyRagIngestConfig";
import { ClassificationLabelsSection } from "@portal/components/policies/ClassificationLabelsSection";
import "@portal/views/Policies.css";
@@ -98,6 +99,8 @@ const DISABLED_BY_DEFAULT = new Set<PolicyToolId>([
"purviewApplyLabel",
"purviewReadLabel",
"externalApiCall",
// Needs a fields schema before a run can succeed.
"extractFields",
]);
// Steps that cannot work without a Purview tenant connection, so they are hidden entirely until one
@@ -193,6 +196,13 @@ const CAPABILITY_META: Record<
descEn:
"Hands the document to a system you have connected, and records what it answered.",
},
extractFields: {
labelKey: "portal.policies.wizard.capability.extractFields.label",
labelEn: "Extract structured fields",
descKey: "portal.policies.wizard.capability.extractFields.desc",
descEn:
"Pulls the values you describe - like invoice numbers or dates - out of every document, each with a confidence score and a citation back to the page.",
},
ragIngest: {
labelKey: "portal.policies.wizard.capability.ragIngest.label",
labelEn: "Index into the knowledge base",
@@ -562,6 +572,14 @@ function PolicySetupWizardBody({
}
/>
)}
{tl.toolId === "extractFields" && (
<PolicyExtractFieldsConfig
parameters={tl.params}
onChange={(params) =>
setToolParams("extractFields", params)
}
/>
)}
{tl.toolId === "ragIngest" && (
<PolicyRagIngestConfig
parameters={tl.params}
@@ -0,0 +1,30 @@
.card {
display: flex;
flex-direction: column;
gap: 0.5rem;
padding: 0.625rem 0.75rem;
border: 1px solid var(--c-border-subtle);
border-radius: 8px;
background: var(--c-surface-sunken);
}
.description {
margin: 0;
font-size: 0.8125rem;
line-height: 1.45;
color: var(--c-text-subtle);
}
.badges {
display: flex;
flex-wrap: wrap;
gap: 0.375rem;
}
.fallback {
margin: 0;
font-size: 0.75rem;
line-height: 1.4;
color: var(--c-text-subtle);
font-style: italic;
}
@@ -0,0 +1,62 @@
import { useTranslation } from "react-i18next";
import { Badge } from "@mantine/core";
import { useDocparseCapabilities } from "@app/hooks/useDocparseCapabilities";
import styles from "@app/components/tools/docparse/DocparseToolIntro.module.css";
interface DocparseToolIntroProps {
/** 1-2 sentence "how this works" copy for the tool, already translated. */
description: string;
/**
* Which AI badge fits the tool: "llm" tools always call the language model;
* "layout" tools use the layout AI model only when advanced parsing is on.
*/
aiBadge?: "llm" | "layout";
/** Hide the scanned-docs fallback note where it cannot apply (DOCX input). */
showFallbackNote?: boolean;
}
/** Compact "how this works" card at the top of every DocParse settings panel. */
const DocparseToolIntro = ({
description,
aiBadge,
showFallbackNote = true,
}: DocparseToolIntroProps) => {
const { t } = useTranslation();
const { capabilities } = useDocparseCapabilities();
const advanced = capabilities ? capabilities.advancedInstalled : null;
return (
<div className={styles.card}>
<p className={styles.description}>{description}</p>
<div className={styles.badges}>
{aiBadge === "llm" && (
<Badge size="sm" variant="light" color="grape">
{t("docparse.intro.usesAi", "Uses AI")}
</Badge>
)}
{aiBadge === "layout" && advanced === true && (
<Badge size="sm" variant="light" color="grape">
{t("docparse.intro.aiLayoutModel", "AI layout model")}
</Badge>
)}
{advanced !== null && (
<Badge size="sm" variant="light" color={advanced ? "teal" : "gray"}>
{advanced
? t("docparse.intro.advancedOn", "Advanced parsing: on")
: t("docparse.intro.advancedOff", "Advanced parsing: off")}
</Badge>
)}
</div>
{advanced === false && showFallbackNote && (
<p className={styles.fallback}>
{t(
"docparse.intro.basicFallback",
"Scanned documents fall back to basic text extraction - install the DocParse addon for layout AI",
)}
</p>
)}
</div>
);
};
export default DocparseToolIntro;
@@ -0,0 +1,36 @@
.schema {
display: flex;
flex-direction: column;
gap: 0.5rem;
}
/* Two-line card per field: the side panel is too narrow for three columns. */
.row {
display: grid;
grid-template-columns: minmax(0, 1fr) 5.75rem auto;
grid-template-areas:
"name type remove"
"desc desc desc";
column-gap: 0.5rem;
row-gap: 0.375rem;
align-items: center;
padding: 0.5rem;
border: 1px solid var(--c-border-subtle);
border-radius: 0.5rem;
}
.nameInput {
grid-area: name;
}
.typeSelect {
grid-area: type;
}
.removeButton {
grid-area: remove;
}
.descriptionInput {
grid-area: desc;
}
@@ -0,0 +1,238 @@
import { useState } from "react";
import { useTranslation } from "react-i18next";
import { Group, Select, Stack, Text, TextInput, Textarea } from "@mantine/core";
import { Button } from "@app/ui";
import DeleteOutlineRoundedIcon from "@mui/icons-material/DeleteOutlineRounded";
import AddRoundedIcon from "@mui/icons-material/AddRounded";
import AutoAwesomeRoundedIcon from "@mui/icons-material/AutoAwesomeRounded";
import { alert } from "@app/components/toast";
import type { ToolAutomationSettingsProps } from "@app/hooks/tools/shared/toolOperationTypes";
import type { ExtractFieldsParameters } from "@app/hooks/tools/extractFields/useExtractFieldsParameters";
import type { DocparseMode } from "@app/hooks/tools/shared/docparseTypes";
import {
FIELD_TYPES,
emptyFieldRow,
type FieldRow,
type FieldType,
} from "@app/hooks/tools/extractFields/fieldsSchema";
import {
CUSTOM_PRESET,
FIELD_PRESET_IDS,
matchPreset,
presetRows,
type FieldPresetId,
} from "@app/hooks/tools/extractFields/fieldsSchemaPresets";
import { requestSuggestedFields } from "@app/hooks/tools/extractFields/suggestSchema";
import DocparseToolIntro from "@app/components/tools/docparse/DocparseToolIntro";
import styles from "@app/components/tools/docparse/ExtractFieldsSettings.module.css";
interface ExtractFieldsSettingsProps extends ToolAutomationSettingsProps<ExtractFieldsParameters> {
/** The selected input file; enables the AI schema suggestion. */
selectedFile?: File | null;
}
/** Schema builder: rows of name/type/description plus free-form instructions. */
const ExtractFieldsSettings = ({
parameters,
onParameterChange,
disabled,
selectedFile,
}: ExtractFieldsSettingsProps) => {
const { t } = useTranslation();
const [suggesting, setSuggesting] = useState(false);
const setRow = (index: number, patch: Partial<FieldRow>) => {
const fields = parameters.fields.map((row, i) =>
i === index ? { ...row, ...patch } : row,
);
onParameterChange("fields", fields);
};
const removeRow = (index: number) => {
const fields = parameters.fields.filter((_, i) => i !== index);
onParameterChange("fields", fields.length > 0 ? fields : [emptyFieldRow()]);
};
const applyPreset = (value: string | null) => {
if (value === CUSTOM_PRESET) {
onParameterChange("fields", [emptyFieldRow()]);
} else if (value) {
onParameterChange("fields", presetRows(value as FieldPresetId));
}
};
const suggestFields = async () => {
if (!selectedFile || suggesting) return;
setSuggesting(true);
try {
const rows = await requestSuggestedFields(selectedFile);
onParameterChange("fields", rows.length > 0 ? rows : [emptyFieldRow()]);
} catch {
alert({
alertType: "error",
title: t("extractFields.suggest.failed", "Could not suggest fields"),
body: t(
"extractFields.suggest.failedBody",
"The document could not be analyzed. Add fields manually or try again.",
),
expandable: false,
});
} finally {
setSuggesting(false);
}
};
const presetLabels: Record<FieldPresetId, string> = {
invoice: t("extractFields.presets.invoice", "Invoice"),
receipt: t("extractFields.presets.receipt", "Receipt"),
contract: t("extractFields.presets.contract", "Contract"),
purchaseOrder: t("extractFields.presets.purchaseOrder", "Purchase order"),
};
return (
<Stack gap="sm">
<DocparseToolIntro
description={t(
"extractFields.intro",
"Describe the fields you need and AI reads the document and returns each value with a confidence score and a citation you can verify.",
)}
aiBadge="llm"
/>
<Select
label={t("extractFields.presets.label", "Preset template")}
value={matchPreset(parameters.fields)}
onChange={applyPreset}
data={[
...FIELD_PRESET_IDS.map((preset) => ({
value: preset,
label: presetLabels[preset],
})),
{
value: CUSTOM_PRESET,
label: t("extractFields.presets.custom", "Custom"),
},
]}
disabled={disabled}
/>
<Text size="sm" fw={500}>
{t("extractFields.fields.label", "Fields to extract")}
</Text>
<div className={styles.schema}>
{parameters.fields.map((row, index) => (
<div key={index} className={styles.row}>
<TextInput
className={styles.nameInput}
aria-label={t("extractFields.fields.name", "Name")}
placeholder={t(
"extractFields.fields.namePlaceholder",
"invoice_number",
)}
value={row.name}
onChange={(event) =>
setRow(index, { name: event.currentTarget.value })
}
disabled={disabled}
/>
<Select
className={styles.typeSelect}
aria-label={t("extractFields.fields.type", "Type")}
value={row.type}
onChange={(value) =>
setRow(index, { type: (value ?? "string") as FieldType })
}
data={FIELD_TYPES.map((type) => ({ value: type, label: type }))}
disabled={disabled}
/>
<TextInput
className={styles.descriptionInput}
aria-label={t("extractFields.fields.description", "Description")}
placeholder={t(
"extractFields.fields.descriptionPlaceholder",
"What to look for",
)}
value={row.description}
onChange={(event) =>
setRow(index, { description: event.currentTarget.value })
}
disabled={disabled}
/>
<Button
variant="quiet"
size="sm"
shape="circle"
leftSection={
<DeleteOutlineRoundedIcon style={{ fontSize: "1.1rem" }} />
}
aria-label={t("extractFields.fields.remove", "Remove field")}
onClick={() => removeRow(index)}
disabled={disabled}
/>
</div>
))}
</div>
<Group gap="xs">
<Button
variant="secondary"
size="sm"
leftSection={<AddRoundedIcon style={{ fontSize: "1rem" }} />}
onClick={() =>
onParameterChange("fields", [...parameters.fields, emptyFieldRow()])
}
disabled={disabled}
>
{t("extractFields.fields.add", "Add field")}
</Button>
<Button
variant="secondary"
size="sm"
leftSection={<AutoAwesomeRoundedIcon style={{ fontSize: "1rem" }} />}
onClick={suggestFields}
loading={suggesting}
disabled={disabled || !selectedFile}
>
{t("extractFields.suggest.button", "Suggest fields (AI)")}
</Button>
</Group>
{!selectedFile && (
<Text size="xs" c="dimmed">
{t(
"extractFields.suggest.needsFile",
"Select a file first to suggest fields",
)}
</Text>
)}
<Select
label={t("extractFields.mode.label", "Mode")}
value={parameters.mode}
onChange={(value) =>
onParameterChange("mode", (value ?? "auto") as DocparseMode)
}
data={[
{ value: "auto", label: t("extractFields.mode.auto", "Auto") },
{ value: "basic", label: t("extractFields.mode.basic", "Basic") },
{
value: "advanced",
label: t("extractFields.mode.advanced", "Advanced"),
},
]}
disabled={disabled}
/>
<Textarea
label={t("extractFields.instructions.label", "Instructions")}
placeholder={t(
"extractFields.instructions.placeholder",
"e.g. Amounts are in EUR unless stated otherwise",
)}
value={parameters.instructions}
onChange={(event) =>
onParameterChange("instructions", event.currentTarget.value)
}
minRows={2}
autosize
disabled={disabled}
/>
</Stack>
);
};
export default ExtractFieldsSettings;
@@ -0,0 +1,51 @@
import { useMemo } from "react";
import { useTranslation } from "react-i18next";
import LocalIcon from "@app/components/shared/LocalIcon";
import {
SubcategoryId,
ToolCategoryId,
type ProprietaryToolRegistry,
} from "@app/data/toolsTaxonomy";
import { asRegistryConfig } from "@app/hooks/tools/shared/toolOperationTypes";
import { useDocparseEnabled } from "@app/hooks/useDocparseEnabled";
import { extractFieldsOperationConfig } from "@app/hooks/tools/extractFields/extractFieldsOperationConfig";
import ExtractFields from "@app/tools/ExtractFields";
import { getSynonyms } from "@app/utils/toolSynonyms";
const toolIcon = (icon: string) => (
<LocalIcon icon={icon} width="1.5rem" height="1.5rem" />
);
/**
* Proprietary tool registry extension - the DocParse tool family.
* Overrides the empty stub at {@code core/data/useProprietaryToolRegistry.tsx}.
* Hidden entirely while the backend reports docparse disabled.
*/
export function useProprietaryToolRegistry(): ProprietaryToolRegistry {
const { t } = useTranslation();
const docparseEnabled = useDocparseEnabled();
return useMemo(() => {
if (!docparseEnabled) return {} as ProprietaryToolRegistry;
return {
extractFields: {
icon: toolIcon("fact-check-outline-rounded"),
name: t("home.extractFields.title", "Extract Fields"),
component: ExtractFields,
description: t(
"home.extractFields.desc",
"Pull typed fields out of a document with confidence scores and citations",
),
categoryId: ToolCategoryId.STANDARD_TOOLS,
subcategoryId: SubcategoryId.DOCUMENT_INTELLIGENCE,
maxFiles: 1,
endpoints: ["extract-fields"],
operationConfig: asRegistryConfig(extractFieldsOperationConfig),
automationSettings: null,
synonyms: getSynonyms(t, "extractFields"),
versionStatus: "beta",
},
} as ProprietaryToolRegistry;
}, [docparseEnabled, t]);
}
@@ -0,0 +1,71 @@
import apiClient from "@app/services/apiClient";
import {
defineCustomTool,
CustomProcessorResult,
} from "@app/hooks/tools/shared/toolOperationTypes";
import { deriveName } from "@app/hooks/tools/shared/docparseFilenames";
import { rowsToSchemaString } from "@app/hooks/tools/extractFields/fieldsSchema";
import {
ExtractFieldsParameters,
defaultParameters,
} from "@app/hooks/tools/extractFields/useExtractFieldsParameters";
// The JSON variant; the plain /extract-fields form is the pipeline shape.
export const EXTRACT_FIELDS_ENDPOINT = "/api/v1/docparse/extract-fields/json";
/** One extracted field in the backend's extraction report. */
export interface ExtractedField {
name: string;
value: unknown;
confidence: number;
citations?: { page: number; bbox?: number[] | null; quote?: string }[];
}
export interface ExtractFieldsResult {
mode: string;
fields: ExtractedField[];
overallConfidence?: number;
}
export const buildExtractFieldsFormData = (
parameters: ExtractFieldsParameters,
file: File,
): FormData => {
const formData = new FormData();
formData.append("fileInput", file);
formData.append("fieldsSchema", rowsToSchemaString(parameters.fields));
formData.append("mode", parameters.mode);
if (parameters.instructions.trim()) {
formData.append("instructions", parameters.instructions.trim());
}
return formData;
};
/** POST the PDF + schema; keep the extraction report as a JSON result file. */
const processExtractFields = async (
parameters: ExtractFieldsParameters,
files: File[],
): Promise<CustomProcessorResult> => {
if (files.length === 0) return { files: [] };
const [inputFile] = files;
const response = await apiClient.post<ExtractFieldsResult>(
EXTRACT_FIELDS_ENDPOINT,
buildExtractFieldsFormData(parameters, inputFile),
);
const resultFile = new File(
[JSON.stringify(response.data, null, 2)],
deriveName(inputFile.name, ".fields.json"),
{ type: "application/json" },
);
return { files: [resultFile] };
};
export const extractFieldsOperationConfig =
defineCustomTool<ExtractFieldsParameters>({
operationType: "extractFields",
endpoint: EXTRACT_FIELDS_ENDPOINT,
customProcessor: processExtractFields,
defaultParameters,
});
@@ -0,0 +1,59 @@
import { describe, expect, test } from "vitest";
import {
emptyFieldRow,
namedRows,
rowsFromSchemaString,
rowsToSchemaString,
type FieldRow,
} from "@app/hooks/tools/extractFields/fieldsSchema";
const rows: FieldRow[] = [
{ name: "invoice_number", type: "string", description: "The invoice id" },
{ name: "total", type: "number", description: "" },
{ name: "paid", type: "boolean", description: "Whether settled" },
];
describe("fieldsSchema", () => {
test("serializes rows to a JSON Schema object string", () => {
const schema = JSON.parse(rowsToSchemaString(rows));
expect(schema.type).toBe("object");
expect(schema.required).toEqual(["invoice_number", "total", "paid"]);
expect(schema.properties.invoice_number).toEqual({
type: "string",
description: "The invoice id",
});
// Blank descriptions are omitted, not sent as empty strings.
expect(schema.properties.total).toEqual({ type: "number" });
});
test("round-trips rows through the schema string", () => {
expect(rowsFromSchemaString(rowsToSchemaString(rows))).toEqual(rows);
});
test("ignores unnamed builder rows", () => {
const withBlank = [...rows, emptyFieldRow()];
expect(namedRows(withBlank)).toHaveLength(3);
const schema = JSON.parse(rowsToSchemaString(withBlank));
expect(Object.keys(schema.properties)).toHaveLength(3);
});
test("trims names and descriptions on serialize", () => {
const schema = JSON.parse(
rowsToSchemaString([
{ name: " due_date ", type: "string", description: " When due " },
]),
);
expect(schema.properties.due_date).toEqual({
type: "string",
description: "When due",
});
});
test("parses junk defensively", () => {
expect(rowsFromSchemaString("not json")).toEqual([]);
expect(rowsFromSchemaString("{}")).toEqual([]);
expect(
rowsFromSchemaString('{"properties":{"x":{"type":"weird"}}}'),
).toEqual([{ name: "x", type: "string", description: "" }]);
});
});
@@ -0,0 +1,65 @@
/**
* The Extract Fields schema builder model: flat rows of {name, type, description}
* serialized to the JSON Schema object string the backend's fieldsSchema expects.
*/
export const FIELD_TYPES = ["string", "number", "integer", "boolean"] as const;
export type FieldType = (typeof FIELD_TYPES)[number];
export interface FieldRow {
name: string;
type: FieldType;
description: string;
}
export const emptyFieldRow = (): FieldRow => ({
name: "",
type: "string",
description: "",
});
/** Rows with a non-empty name, i.e. the ones worth serializing. */
export function namedRows(rows: FieldRow[]): FieldRow[] {
return rows.filter((row) => row.name.trim().length > 0);
}
/** Serialize builder rows to the JSON Schema string sent as fieldsSchema. */
export function rowsToSchemaString(rows: FieldRow[]): string {
const properties: Record<string, { type: FieldType; description?: string }> =
{};
for (const row of namedRows(rows)) {
properties[row.name.trim()] = {
type: row.type,
...(row.description.trim()
? { description: row.description.trim() }
: {}),
};
}
return JSON.stringify({
type: "object",
properties,
required: Object.keys(properties),
});
}
const isFieldType = (value: unknown): value is FieldType =>
typeof value === "string" && FIELD_TYPES.includes(value as FieldType);
/** Parse a fieldsSchema string back into builder rows; [] on anything unusable. */
export function rowsFromSchemaString(schema: string): FieldRow[] {
try {
const parsed = JSON.parse(schema) as {
properties?: Record<string, { type?: unknown; description?: unknown }>;
};
if (!parsed || typeof parsed !== "object" || !parsed.properties) return [];
return Object.entries(parsed.properties).map(([name, prop]) => ({
name,
type: isFieldType(prop?.type) ? prop.type : "string",
description:
typeof prop?.description === "string" ? prop.description : "",
}));
} catch {
return [];
}
}
@@ -0,0 +1,47 @@
import { describe, expect, test } from "vitest";
import {
emptyFieldRow,
rowsFromSchemaString,
rowsToSchemaString,
} from "@app/hooks/tools/extractFields/fieldsSchema";
import {
CUSTOM_PRESET,
FIELD_PRESET_IDS,
matchPreset,
presetRows,
} from "@app/hooks/tools/extractFields/fieldsSchemaPresets";
describe("fieldsSchemaPresets", () => {
test("every preset has 4-6 named, described fields", () => {
for (const preset of FIELD_PRESET_IDS) {
const rows = presetRows(preset);
expect(rows.length).toBeGreaterThanOrEqual(4);
expect(rows.length).toBeLessThanOrEqual(6);
for (const row of rows) {
expect(row.name).toMatch(/^[a-z0-9_]+$/);
expect(row.description.length).toBeGreaterThan(0);
}
}
});
test("preset rows survive the schema-string round trip", () => {
for (const preset of FIELD_PRESET_IDS) {
const rows = presetRows(preset);
expect(rowsFromSchemaString(rowsToSchemaString(rows))).toEqual(rows);
}
});
test("matchPreset spots untouched presets and demotes edits to custom", () => {
const rows = presetRows("invoice");
expect(matchPreset(rows)).toBe("invoice");
rows[0] = { ...rows[0], name: "order_ref" };
expect(matchPreset(rows)).toBe(CUSTOM_PRESET);
expect(matchPreset([emptyFieldRow()])).toBe(CUSTOM_PRESET);
});
test("presetRows hands out fresh copies, not shared references", () => {
const first = presetRows("receipt");
first[0].name = "mutated";
expect(presetRows("receipt")[0].name).not.toBe("mutated");
});
});
@@ -0,0 +1,151 @@
import type { FieldRow } from "@app/hooks/tools/extractFields/fieldsSchema";
/**
* Ready-made extraction schemas for the common document types, so users start
* from a sensible field list instead of a blank builder.
*/
export const FIELD_PRESET_IDS = [
"invoice",
"receipt",
"contract",
"purchaseOrder",
] as const;
export type FieldPresetId = (typeof FIELD_PRESET_IDS)[number];
/** The select value when the rows match no preset (hand-built schema). */
export const CUSTOM_PRESET = "custom" as const;
const PRESETS: Record<FieldPresetId, FieldRow[]> = {
invoice: [
{
name: "invoice_number",
type: "string",
description: "The invoice identifier",
},
{
name: "invoice_date",
type: "string",
description: "Date the invoice was issued (ISO format if possible)",
},
{
name: "vendor_name",
type: "string",
description: "Name of the company issuing the invoice",
},
{
name: "total_amount",
type: "number",
description: "Grand total including tax",
},
{
name: "currency",
type: "string",
description: "Currency code or symbol",
},
{
name: "due_date",
type: "string",
description: "Date payment is due",
},
],
receipt: [
{
name: "merchant_name",
type: "string",
description: "Store or merchant name",
},
{
name: "purchase_date",
type: "string",
description: "Date of the purchase",
},
{ name: "total_amount", type: "number", description: "Total paid" },
{
name: "tax_amount",
type: "number",
description: "Tax portion of the total",
},
{
name: "payment_method",
type: "string",
description: "How it was paid, e.g. card or cash",
},
],
contract: [
{
name: "party_a",
type: "string",
description: "First contracting party's legal name",
},
{
name: "party_b",
type: "string",
description: "Second contracting party's legal name",
},
{
name: "effective_date",
type: "string",
description: "Date the agreement takes effect",
},
{
name: "termination_date",
type: "string",
description: "Date the agreement ends or renews",
},
{
name: "governing_law",
type: "string",
description: "Jurisdiction governing the agreement",
},
{
name: "auto_renews",
type: "boolean",
description: "Whether the contract renews automatically",
},
],
purchaseOrder: [
{
name: "po_number",
type: "string",
description: "Purchase order identifier",
},
{ name: "order_date", type: "string", description: "Date of the order" },
{
name: "supplier_name",
type: "string",
description: "Supplier the order is placed with",
},
{
name: "delivery_date",
type: "string",
description: "Requested or promised delivery date",
},
{ name: "total_amount", type: "number", description: "Order total" },
],
};
/** A fresh copy of the preset's rows, safe to mutate in the builder. */
export function presetRows(preset: FieldPresetId): FieldRow[] {
return PRESETS[preset].map((row) => ({ ...row }));
}
/** The preset the rows exactly match, or "custom" for anything hand-edited. */
export function matchPreset(rows: FieldRow[]): FieldPresetId | "custom" {
for (const preset of FIELD_PRESET_IDS) {
const candidate = PRESETS[preset];
if (
rows.length === candidate.length &&
rows.every(
(row, i) =>
row.name === candidate[i].name &&
row.type === candidate[i].type &&
row.description === candidate[i].description,
)
) {
return preset;
}
}
return CUSTOM_PRESET;
}
@@ -0,0 +1,35 @@
import { describe, expect, test } from "vitest";
import { suggestedFieldsToRows } from "@app/hooks/tools/extractFields/suggestSchema";
describe("suggestedFieldsToRows", () => {
test("maps engine proposals to builder rows", () => {
expect(
suggestedFieldsToRows([
{ name: "invoice_number", type: "string", description: "The id" },
{ name: "total", type: "number" },
]),
).toEqual([
{ name: "invoice_number", type: "string", description: "The id" },
{ name: "total", type: "number", description: "" },
]);
});
test("coerces unknown types to string and trims values", () => {
expect(
suggestedFieldsToRows([
{ name: " due_date ", type: "date", description: " When due " },
]),
).toEqual([{ name: "due_date", type: "string", description: "When due" }]);
});
test("drops unusable entries defensively", () => {
expect(
suggestedFieldsToRows([
{ name: "", type: "string" },
{ type: "string", description: "no name" },
undefined as never,
]),
).toEqual([]);
expect(suggestedFieldsToRows(undefined)).toEqual([]);
});
});
@@ -0,0 +1,53 @@
import apiClient from "@app/services/apiClient";
import {
FIELD_TYPES,
type FieldRow,
type FieldType,
} from "@app/hooks/tools/extractFields/fieldsSchema";
export const SUGGEST_SCHEMA_ENDPOINT = "/api/v1/docparse/suggest-schema";
/** Cap on proposed fields; keeps the builder scannable in the side panel. */
export const SUGGEST_MAX_FIELDS = 8;
/** One field the backend proposes for an extraction schema. */
export interface SuggestedSchemaField {
name?: string;
type?: string;
description?: string;
}
export interface SuggestSchemaResponse {
mode?: string;
fields?: SuggestedSchemaField[];
}
const isFieldType = (value: unknown): value is FieldType =>
typeof value === "string" && FIELD_TYPES.includes(value as FieldType);
/** Map the engine's proposals to builder rows, dropping unusable entries. */
export function suggestedFieldsToRows(
fields: SuggestedSchemaField[] | undefined,
): FieldRow[] {
if (!Array.isArray(fields)) return [];
return fields
.filter((field) => typeof field?.name === "string" && field.name.trim())
.map((field) => ({
name: field.name!.trim(),
type: isFieldType(field.type) ? field.type : "string",
description:
typeof field.description === "string" ? field.description.trim() : "",
}));
}
/** POST the document; get AI-proposed schema rows for the builder. */
export async function requestSuggestedFields(file: File): Promise<FieldRow[]> {
const formData = new FormData();
formData.append("fileInput", file);
formData.append("maxFields", String(SUGGEST_MAX_FIELDS));
const response = await apiClient.post<SuggestSchemaResponse>(
SUGGEST_SCHEMA_ENDPOINT,
formData,
);
return suggestedFieldsToRows(response.data?.fields);
}
@@ -0,0 +1,15 @@
import { useTranslation } from "react-i18next";
import { useToolOperation } from "@app/hooks/tools/shared/useToolOperation";
import { createStandardErrorHandler } from "@app/utils/toolErrorHandler";
import { extractFieldsOperationConfig } from "@app/hooks/tools/extractFields/extractFieldsOperationConfig";
export const useExtractFieldsOperation = () => {
const { t } = useTranslation();
return useToolOperation({
...extractFieldsOperationConfig,
getErrorMessage: createStandardErrorHandler(
t("extractFields.error.failed", "Failed to extract fields"),
),
});
};
@@ -0,0 +1,35 @@
import { BaseParameters } from "@app/types/parameters";
import {
useBaseParameters,
BaseParametersHook,
} from "@app/hooks/tools/shared/useBaseParameters";
import type { DocparseMode } from "@app/hooks/tools/shared/docparseTypes";
import {
emptyFieldRow,
namedRows,
type FieldRow,
} from "@app/hooks/tools/extractFields/fieldsSchema";
export interface ExtractFieldsParameters extends BaseParameters {
/** Schema-builder rows; serialized to fieldsSchema on execute. */
fields: FieldRow[];
instructions: string;
mode: DocparseMode;
}
export const defaultParameters: ExtractFieldsParameters = {
fields: [emptyFieldRow()],
instructions: "",
mode: "auto",
};
export type ExtractFieldsParametersHook =
BaseParametersHook<ExtractFieldsParameters>;
export const useExtractFieldsParameters = (): ExtractFieldsParametersHook => {
return useBaseParameters({
defaultParameters,
endpointName: "extract-fields",
validateFn: (params) => namedRows(params.fields).length > 0,
});
};
@@ -0,0 +1,12 @@
/** Filename helpers shared by the DocParse tool processors. */
/** "invoice.pdf" -> "invoice"; keeps names without an extension intact. */
export function stripExtension(fileName: string): string {
const dot = fileName.lastIndexOf(".");
return dot > 0 ? fileName.slice(0, dot) : fileName;
}
/** Derived output name, e.g. deriveName("a.pdf", ".fields.json") -> "a.fields.json". */
export function deriveName(inputName: string, suffix: string): string {
return `${stripExtension(inputName)}${suffix}`;
}
@@ -0,0 +1,2 @@
/** Parse-tier request modes shared by the DocParse tool family. */
export type DocparseMode = "auto" | "basic" | "advanced";
@@ -0,0 +1,71 @@
import { useEffect, useState } from "react";
import apiClient from "@app/services/apiClient";
/** The merged capability view served by GET /api/v1/docparse/capabilities. */
export interface DocparseCapabilities {
enabled: boolean;
mode: string;
advancedInstalled: boolean;
engineReachable: boolean;
doclingVersion: string | null;
}
// Module-level cache so every intro card shares one fetch per page load,
// mirroring the useEndpointConfig global-cache pattern.
let cachedCapabilities: DocparseCapabilities | null = null;
let inFlight: Promise<DocparseCapabilities | null> | null = null;
async function fetchCapabilities(): Promise<DocparseCapabilities | null> {
try {
const response = await apiClient.get<DocparseCapabilities>(
"/api/v1/docparse/capabilities",
{ suppressErrorToast: true, skipAuthRedirect: true },
);
cachedCapabilities = response.data;
return cachedCapabilities;
} catch {
return null;
} finally {
inFlight = null;
}
}
/** Test seam: forget the cached capabilities so the next mount refetches. */
export function resetDocparseCapabilitiesCache() {
cachedCapabilities = null;
inFlight = null;
}
/**
* The DocParse capability report (tier, engine reachability), module-cached so
* the intro card on every docparse tool costs at most one request.
*/
export function useDocparseCapabilities(): {
capabilities: DocparseCapabilities | null;
loading: boolean;
} {
const [capabilities, setCapabilities] = useState<DocparseCapabilities | null>(
cachedCapabilities,
);
const [loading, setLoading] = useState(cachedCapabilities === null);
useEffect(() => {
if (cachedCapabilities) {
setCapabilities(cachedCapabilities);
setLoading(false);
return;
}
let cancelled = false;
inFlight = inFlight ?? fetchCapabilities();
inFlight.then((result) => {
if (cancelled) return;
setCapabilities(result);
setLoading(false);
});
return () => {
cancelled = true;
};
}, []);
return { capabilities, loading };
}
@@ -18,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">
&ldquo;{field.citations[0].quote}&rdquo;
</Text>
)}
</Stack>
))}
</Stack>
),
},
],
executeButton: {
text: t("extractFields.submit", "Extract fields"),
isVisible: !base.hasResults,
loadingText: t("loading"),
onClick: base.handleExecute,
endpointEnabled: base.endpointEnabled,
paramsValid: base.params.validateParameters(),
},
review: {
isVisible: base.hasResults,
operation: base.operation,
title: t("extractFields.results.title", "Extraction report"),
onFileClick: base.handleThumbnailClick,
onUndo: base.handleUndo,
},
});
};
export default ExtractFields;
@@ -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