Compare commits

...
49 changed files with 2605 additions and 100 deletions
@@ -433,6 +433,9 @@ public class EndpointConfiguration {
addEndpointToGroup("Automation", "automate"); // Alias for handleData (user-friendly name)
addEndpointToGroup("Automation", "pipeline");
// Adding endpoints to "DocParse" group (ingestion: chunk + index + export)
addEndpointToGroup("DocParse", "rag-ingest");
// Adding endpoints to "DeveloperTools" group
addEndpointToGroup("DeveloperTools", "show-javascript");
@@ -77,6 +77,7 @@ public class ApplicationProperties {
private ProcessExecutor processExecutor = new ProcessExecutor();
private PdfEditor pdfEditor = new PdfEditor();
private AiEngine aiEngine = new AiEngine();
private Docparse docparse = new Docparse();
private Mcp mcp = new Mcp();
private InternalApi internalApi = new InternalApi();
private Cluster cluster = new Cluster();
@@ -425,6 +426,21 @@ public class ApplicationProperties {
}
}
/**
* DocParse settings (top-level {@code docparse.*}): document understanding for ingestion
* pipelines. The basic tier (text layer) always works; the advanced tier lives in the engine's
* docparse addon.
*/
@Data
public static class Docparse {
/** Master switch; hides the DocParse endpoints when false. */
private boolean enabled = true;
/** Requested tier: 'auto', 'basic', or 'advanced'. 'auto' resolves per document. */
private String mode = "auto";
}
/**
* Model Context Protocol (MCP) server configuration. All keys live under the top-level {@code
* mcp.*} prefix. {@link #enabled} defaults to {@code false}: when off, no MCP beans are wired,
@@ -0,0 +1,16 @@
package stirling.software.common.service;
/**
* View of the engine's DocParse capability for modules that cannot see the proprietary
* implementation (e.g. ConfigController in core). Implemented by the proprietary
* DocparseCapabilityService; absent when the proprietary module is not loaded.
*/
public interface DocparseCapabilityServiceInterface {
/**
* Whether the engine reports the docparse addon (advanced tier) as installed. Must be cheap and
* non-blocking: returns the cached probe result, {@code false} when the engine is disabled,
* unreachable, or not yet probed.
*/
boolean isAdvancedInstalled();
}
@@ -53,7 +53,7 @@ public class InternalApiClient {
// ApiConnectionResolver.
private static final Pattern ALLOWED_ENDPOINT_PATH =
Pattern.compile(
"^/api/v1/(general|misc|security|convert|filter|integration)(/[A-Za-z0-9_-]+)+$"
"^/api/v1/(general|misc|security|convert|filter|integration|docparse)(/[A-Za-z0-9_-]+)+$"
+ "|^/api/v1/ai/tools(/[A-Za-z0-9_-]+)+$");
/**
@@ -396,6 +396,13 @@ aiEngine:
pdfComment: true # AI-authored PDF comments/annotations
classify: true # Automatic document classification/labelling
# DocParse: document understanding for ingestion pipelines (chunking + knowledge-base
# indexing). The basic tier (text layer) always works; the advanced tier (layout parsing)
# requires the engine's docparse addon. Env overrides: DOCPARSE_ENABLED, DOCPARSE_MODE.
docparse:
enabled: true # Master switch; hides the DocParse endpoints when false
mode: auto # Tier selection: 'auto' (best available), 'basic', or 'advanced'
policies:
# Folder automations can read from and write to the directories you allow here, so treat this as a
# security boundary. Leave allowedFolderRoots empty (default) to disable folder sources/outputs,
@@ -0,0 +1,178 @@
package stirling.software.proprietary.controller.api;
import java.io.ByteArrayOutputStream;
import java.io.IOException;
import java.nio.charset.StandardCharsets;
import java.util.zip.ZipEntry;
import java.util.zip.ZipOutputStream;
import org.springframework.core.io.ByteArrayResource;
import org.springframework.core.io.Resource;
import org.springframework.http.HttpHeaders;
import org.springframework.http.MediaType;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.ModelAttribute;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
import org.springframework.web.multipart.MultipartFile;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.tags.Tag;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import stirling.software.common.annotations.AutoJobPostMapping;
import stirling.software.common.enumeration.ResourceWeight;
import stirling.software.proprietary.model.api.docparse.RagIngestApiRequest;
import stirling.software.proprietary.model.docparse.DocChunk;
import stirling.software.proprietary.model.docparse.DocparseCapabilitiesView;
import stirling.software.proprietary.model.docparse.DocparseMode;
import stirling.software.proprietary.model.docparse.RagIngestResponse;
import stirling.software.proprietary.service.AiToolResponseHeaders;
import stirling.software.proprietary.service.DocParseService;
import tools.jackson.databind.ObjectMapper;
import tools.jackson.databind.node.ObjectNode;
/**
* Public DocParse ingestion API. Thin HTTP layer over {@link DocParseService}, which owns the
* engine wire contract; this class owns the pipeline step shape (report header, export ZIP).
*/
@Slf4j
@RestController
@RequestMapping("/api/v1/docparse")
@RequiredArgsConstructor
@Tag(
name = "DocParse",
description =
"Document ingestion: chunk, embed, and index documents into the searchable"
+ " knowledge base, or export the parsed content (markdown, chunks JSONL)"
+ " for external systems.")
public class DocParseController {
private final DocParseService docParseService;
private final ObjectMapper objectMapper;
@AutoJobPostMapping(
consumes = MediaType.MULTIPART_FORM_DATA_VALUE,
value = "/rag-ingest",
resourceWeight = ResourceWeight.LARGE_WEIGHT)
@Operation(
summary = "Chunk, embed, and index a document into the RAG store (pipeline shape)",
description =
"Ingests the document into the engine's RAG store under a stable documentId"
+ " (default: content hash). Returns the ORIGINAL PDF unchanged as the"
+ " body, with the ingest summary JSON in the X-Stirling-Tool-Report"
+ " header so policy pipelines pick it up as the step report. With"
+ " exportMarkdown/exportChunksJsonl the body becomes a ZIP holding the"
+ " original plus the corpus files, ready for delivery to external"
+ " systems. Input:PDF Output:PDF/ZIP Type:SISO")
public ResponseEntity<Resource> ragIngest(@ModelAttribute RagIngestApiRequest request)
throws IOException {
MultipartFile file = request.getFileInput();
boolean export = request.isExportMarkdown() || request.isExportChunksJsonl();
RagIngestResponse result =
docParseService.ragIngest(
file,
request.getDocumentId(),
request.getChunkSize(),
request.getOverlap(),
DocparseMode.fromWire(request.getMode()),
request.isIndex(),
request.isExportMarkdown(),
request.isExportChunksJsonl());
// The report header must stay small: summary fields only, never the echoed content.
ObjectNode report = objectMapper.createObjectNode();
report.put("mode", result.mode().wire());
report.put("documentId", result.documentId());
report.put("chunksIndexed", result.chunksIndexed());
report.put("pages", result.pages());
report.put("indexed", request.isIndex());
String fileName = DocParseService.fileName(file);
byte[] original = file.getBytes();
HttpHeaders headers = new HttpHeaders();
headers.set(AiToolResponseHeaders.TOOL_REPORT, objectMapper.writeValueAsString(report));
if (!export) {
headers.setContentType(MediaType.APPLICATION_PDF);
headers.setContentDispositionFormData("attachment", fileName);
headers.setContentLength(original.length);
return ResponseEntity.ok().headers(headers).body(new ByteArrayResource(original));
}
byte[] zip = exportZip(fileName, original, result, request);
headers.setContentType(MediaType.parseMediaType("application/zip"));
headers.setContentDispositionFormData("attachment", baseName(fileName) + "-ingested.zip");
headers.setContentLength(zip.length);
return ResponseEntity.ok().headers(headers).body(new ByteArrayResource(zip));
}
@GetMapping("/capabilities")
@Operation(
summary = "DocParse capability summary",
description =
"Merged view of the Java settings and the engine's capability probe, so"
+ " clients can gate advanced-tier UI.")
public ResponseEntity<DocparseCapabilitiesView> capabilities() {
return ResponseEntity.ok(docParseService.capabilitiesView());
}
/** Original + requested corpus files in one ZIP, so destinations receive them together. */
private byte[] exportZip(
String fileName, byte[] original, RagIngestResponse result, RagIngestApiRequest request)
throws IOException {
String base = baseName(fileName);
ByteArrayOutputStream out = new ByteArrayOutputStream();
try (ZipOutputStream zip = new ZipOutputStream(out)) {
zip.putNextEntry(new ZipEntry(fileName));
zip.write(original);
zip.closeEntry();
if (request.isExportMarkdown()) {
zip.putNextEntry(new ZipEntry(base + ".md"));
zip.write(
(result.markdown() == null ? "" : result.markdown())
.getBytes(StandardCharsets.UTF_8));
zip.closeEntry();
}
if (request.isExportChunksJsonl()) {
zip.putNextEntry(new ZipEntry(base + ".chunks.jsonl"));
zip.write(chunksJsonl(result).getBytes(StandardCharsets.UTF_8));
zip.closeEntry();
}
}
return out.toByteArray();
}
/** One chunk per line, each self-describing (documentId + source travel on every line). */
private String chunksJsonl(RagIngestResponse result) {
if (result.chunks() == null) {
return "";
}
StringBuilder lines = new StringBuilder();
for (DocChunk chunk : result.chunks()) {
ObjectNode line = objectMapper.createObjectNode();
line.put("documentId", result.documentId());
line.put("index", chunk.index());
line.put("text", chunk.text());
if (chunk.pageStart() != null) {
line.put("pageStart", chunk.pageStart());
}
if (chunk.pageEnd() != null) {
line.put("pageEnd", chunk.pageEnd());
}
var headings = line.putArray("headingPath");
chunk.headingPath().forEach(headings::add);
lines.append(objectMapper.writeValueAsString(line)).append('\n');
}
return lines.toString();
}
private static String baseName(String fileName) {
int dot = fileName.lastIndexOf('.');
return dot > 0 ? fileName.substring(0, dot) : fileName;
}
}
@@ -0,0 +1,52 @@
package stirling.software.proprietary.model.api.docparse;
import io.swagger.v3.oas.annotations.media.Schema;
import lombok.Data;
import lombok.EqualsAndHashCode;
import stirling.software.common.model.api.PDFFile;
@Data
@EqualsAndHashCode(callSuper = true)
public class RagIngestApiRequest extends PDFFile {
@Schema(
description =
"Stable identifier for the ingested document; re-ingesting the same id replaces"
+ " its chunks. Defaults to a content hash of the uploaded bytes.")
private String documentId;
@Schema(description = "Target chunk size in characters (64-32768)", defaultValue = "512")
private int chunkSize = 512;
@Schema(
description = "Overlap between adjacent chunks in characters (0-4096)",
defaultValue = "64")
private int overlap = 64;
@Schema(
description = "Tier to use: 'auto' picks per document, or force 'basic'/'advanced'",
allowableValues = {"auto", "basic", "advanced"},
defaultValue = "auto")
private String mode = "auto";
@Schema(
description = "Index the document into the built-in knowledge base",
defaultValue = "true")
private boolean index = true;
@Schema(
description =
"Also return the parsed document as a markdown file, for delivery to external"
+ " systems (vector DBs, training corpora)",
defaultValue = "false")
private boolean exportMarkdown = false;
@Schema(
description =
"Also return the chunks as a JSONL file (one chunk per line with page span and"
+ " heading breadcrumb), ready for external embedding or indexing",
defaultValue = "false")
private boolean exportChunksJsonl = false;
}
@@ -0,0 +1,12 @@
package stirling.software.proprietary.model.docparse;
import java.util.List;
/** One RAG chunk with page span and heading breadcrumb. Mirrors {@code docparse.py DocChunk}. */
public record DocChunk(
int index, String text, Integer pageStart, Integer pageEnd, List<String> headingPath) {
public DocChunk {
headingPath = headingPath == null ? List.of() : headingPath;
}
}
@@ -0,0 +1,25 @@
package stirling.software.proprietary.model.docparse;
import java.util.List;
/**
* What the engine can actually do right now; Java caches and republishes this. Mirrors {@code
* docparse.py DocparseCapabilities}.
*/
public record DocparseCapabilities(
boolean advancedInstalled,
String doclingVersion,
String torchVersion,
boolean modelsAvailable,
String modelsPath,
List<String> errors) {
public DocparseCapabilities {
errors = errors == null ? List.of() : errors;
}
/** The addon-absent view used when the engine is disabled, unreachable, or probing failed. */
public static DocparseCapabilities absent(String reason) {
return new DocparseCapabilities(false, null, null, false, null, List.of(reason));
}
}
@@ -0,0 +1,9 @@
package stirling.software.proprietary.model.docparse;
/** Merged capability view served by {@code GET /api/v1/docparse/capabilities} (Java side). */
public record DocparseCapabilitiesView(
boolean enabled,
String mode,
boolean advancedInstalled,
boolean engineReachable,
String doclingVersion) {}
@@ -0,0 +1,35 @@
package stirling.software.proprietary.model.docparse;
import java.util.Locale;
import com.fasterxml.jackson.annotation.JsonCreator;
import com.fasterxml.jackson.annotation.JsonValue;
/**
* What the caller asked for; {@code AUTO} resolves per request. Wire values are lowercase to match
* {@code engine/src/stirling/contracts/docparse.py DocparseMode}.
*/
public enum DocparseMode {
AUTO("auto"),
BASIC("basic"),
ADVANCED("advanced");
private final String wire;
DocparseMode(String wire) {
this.wire = wire;
}
@JsonValue
public String wire() {
return wire;
}
@JsonCreator
public static DocparseMode fromWire(String value) {
if (value == null || value.isBlank()) {
return AUTO;
}
return valueOf(value.trim().toUpperCase(Locale.ROOT));
}
}
@@ -0,0 +1,31 @@
package stirling.software.proprietary.model.docparse;
import java.util.Locale;
import com.fasterxml.jackson.annotation.JsonCreator;
import com.fasterxml.jackson.annotation.JsonValue;
/**
* Which implementation actually served a request. Wire values are lowercase to match {@code
* engine/src/stirling/contracts/docparse.py DocparseTier}.
*/
public enum DocparseTier {
BASIC("basic"),
ADVANCED("advanced");
private final String wire;
DocparseTier(String wire) {
this.wire = wire;
}
@JsonValue
public String wire() {
return wire;
}
@JsonCreator
public static DocparseTier fromWire(String value) {
return valueOf(value.trim().toUpperCase(Locale.ROOT));
}
}
@@ -0,0 +1,28 @@
package stirling.software.proprietary.model.docparse;
import java.time.Instant;
import java.util.List;
import stirling.software.proprietary.model.api.ai.AiPageText;
/**
* Engine request for {@code POST /api/v1/docparse/rag-ingest}. Owner semantics mirror {@code POST
* /api/v1/documents}: {@code ownerId} is the tenant, {@code readPrincipals} the explicit readers,
* and a null {@code expiresAt} keeps the ingested content until an explicit delete. {@code index}
* false skips the store (export-only); {@code includeMarkdown}/{@code includeChunks} echo the
* parsed content back so the caller can emit corpus files.
*/
public record RagIngestRequest(
String fileName,
String documentId,
String source,
String ownerId,
List<String> readPrincipals,
Instant expiresAt,
List<AiPageText> pages,
int chunkSize,
int overlap,
DocparseMode mode,
boolean index,
boolean includeMarkdown,
boolean includeChunks) {}
@@ -0,0 +1,15 @@
package stirling.software.proprietary.model.docparse;
import java.util.List;
/**
* Engine response for {@code POST /api/v1/docparse/rag-ingest}. {@code markdown} and {@code chunks}
* are only present when the request asked for them via includeMarkdown/includeChunks.
*/
public record RagIngestResponse(
DocparseTier mode,
String documentId,
int chunksIndexed,
int pages,
String markdown,
List<DocChunk> chunks) {}
@@ -0,0 +1,37 @@
package stirling.software.proprietary.policy.output;
import java.util.List;
import org.springframework.core.io.Resource;
import org.springframework.stereotype.Service;
import stirling.software.common.model.job.ResultFile;
import stirling.software.proprietary.policy.model.OutputSpec;
/**
* Swallows a run's output files: the run's side effects (knowledge-base indexing, exports pushed to
* explicit destinations) still happen and the run report is kept, but no processed file is stored
* or written back. The default for ingestion policies, whose product is the index, not a
* transformed PDF.
*/
@Service
public class DiscardOutputSink implements PolicyOutputSink {
private static final String TYPE = "discard";
@Override
public String type() {
return TYPE;
}
@Override
public boolean supports(OutputSpec spec) {
return spec != null && TYPE.equals(spec.type());
}
@Override
public List<ResultFile> deliver(
OutputDelivery delivery, List<Resource> outputs, OutputSpec spec) {
return List.of();
}
}
@@ -0,0 +1,51 @@
package stirling.software.proprietary.security.filter;
import java.io.IOException;
import org.slf4j.MDC;
import org.springframework.core.Ordered;
import org.springframework.core.annotation.Order;
import org.springframework.security.core.Authentication;
import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.stereotype.Component;
import org.springframework.web.filter.OncePerRequestFilter;
import jakarta.servlet.FilterChain;
import jakarta.servlet.ServletException;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
/**
* Stamps the authenticated principal into MDC for every request. Async job workers resolve the
* caller via UserService's MDC fallback; without this the fallback only worked when the audit
* aspect (a pro feature) happened to populate it.
*/
@Component
@Order(Ordered.LOWEST_PRECEDENCE)
public class PrincipalMdcFilter extends OncePerRequestFilter {
static final String MDC_KEY = "auditPrincipal";
@Override
protected void doFilterInternal(
HttpServletRequest request, HttpServletResponse response, FilterChain filterChain)
throws ServletException, IOException {
String previous = MDC.get(MDC_KEY);
Authentication authentication = SecurityContextHolder.getContext().getAuthentication();
boolean stamped = false;
if (previous == null
&& authentication != null
&& authentication.isAuthenticated()
&& !"anonymousUser".equals(authentication.getPrincipal())) {
MDC.put(MDC_KEY, authentication.getName());
stamped = true;
}
try {
filterChain.doFilter(request, response);
} finally {
if (stamped) {
MDC.remove(MDC_KEY);
}
}
}
}
@@ -254,6 +254,13 @@ public class AiEngineClient {
private void checkResponseStatus(HttpResponse<String> response) {
int status = response.statusCode();
// 501 = capability not implemented (e.g. docparse addon missing); keep the status and
// body so callers can surface the machine-readable addonRequired detail.
if (status == 501) {
throw new ResponseStatusException(
HttpStatus.NOT_IMPLEMENTED,
"AI engine capability not implemented: " + response.body());
}
if (status >= 500) {
throw new ResponseStatusException(
HttpStatus.BAD_GATEWAY, "AI engine returned error: " + status);
@@ -0,0 +1,201 @@
package stirling.software.proprietary.service;
import java.io.IOException;
import java.util.ArrayList;
import java.util.List;
import org.apache.pdfbox.pdmodel.PDDocument;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.http.HttpStatus;
import org.springframework.stereotype.Service;
import org.springframework.web.multipart.MultipartFile;
import org.springframework.web.server.ResponseStatusException;
import io.github.pixee.security.Filenames;
import lombok.extern.slf4j.Slf4j;
import stirling.software.common.model.ApplicationProperties;
import stirling.software.common.service.CustomPDFDocumentFactory;
import stirling.software.common.service.UserServiceInterface;
import stirling.software.proprietary.model.api.ai.AiPageText;
import stirling.software.proprietary.model.docparse.DocparseCapabilities;
import stirling.software.proprietary.model.docparse.DocparseCapabilitiesView;
import stirling.software.proprietary.model.docparse.DocparseMode;
import stirling.software.proprietary.model.docparse.RagIngestRequest;
import stirling.software.proprietary.model.docparse.RagIngestResponse;
import tools.jackson.databind.ObjectMapper;
/**
* DocParse ingestion: per-page text extraction (reusing the same {@link PdfContentExtractor} the AI
* chat path uses) plus engine dispatch for chunk + embed + index. The engine owns chunking,
* embedding, and the document store; Java owns identity, limits, and the wire contract from {@code
* engine/src/stirling/contracts/docparse.py}.
*/
@Slf4j
@Service
public class DocParseService {
private static final String RAG_INGEST_ENDPOINT = "/api/v1/docparse/rag-ingest";
private final AiEngineClient aiEngineClient;
private final DocparseCapabilityService capabilityService;
private final CustomPDFDocumentFactory pdfDocumentFactory;
private final PdfContentExtractor pdfContentExtractor;
private final ApplicationProperties applicationProperties;
private final ObjectMapper objectMapper;
private final FileIdStrategy fileIdStrategy;
private final UserServiceInterface userService;
public DocParseService(
AiEngineClient aiEngineClient,
DocparseCapabilityService capabilityService,
CustomPDFDocumentFactory pdfDocumentFactory,
PdfContentExtractor pdfContentExtractor,
ApplicationProperties applicationProperties,
ObjectMapper objectMapper,
FileIdStrategy fileIdStrategy,
@Autowired(required = false) UserServiceInterface userService) {
this.aiEngineClient = aiEngineClient;
this.capabilityService = capabilityService;
this.pdfDocumentFactory = pdfDocumentFactory;
this.pdfContentExtractor = pdfContentExtractor;
this.applicationProperties = applicationProperties;
this.objectMapper = objectMapper;
this.fileIdStrategy = fileIdStrategy;
this.userService = userService;
}
/** Throws 503 when the docparse.enabled master switch is off. */
public void requireEnabled() {
if (!applicationProperties.getDocparse().isEnabled()) {
throw new ResponseStatusException(
HttpStatus.SERVICE_UNAVAILABLE, "DocParse is disabled");
}
}
public DocparseCapabilitiesView capabilitiesView() {
ApplicationProperties.Docparse config = applicationProperties.getDocparse();
DocparseCapabilities capabilities = capabilityService.capabilities();
return new DocparseCapabilitiesView(
config.isEnabled(),
config.getMode(),
capabilities.advancedInstalled(),
capabilityService.isEngineReachable(),
capabilities.doclingVersion());
}
/**
* Chunk, embed, and index the document into the engine's RAG store, and/or echo the parsed
* content back for corpus export. Text extraction happens here (the engine's basic tier is
* text-only); the settings mode caps the requested mode, and {@code advanced} without the addon
* surfaces the engine's 501 addonRequired.
*/
public RagIngestResponse ragIngest(
MultipartFile file,
String documentId,
int chunkSize,
int overlap,
DocparseMode mode,
boolean index,
boolean includeMarkdown,
boolean includeChunks)
throws IOException {
requireEnabled();
if (!index && !includeMarkdown && !includeChunks) {
throw new ResponseStatusException(
HttpStatus.BAD_REQUEST,
"Nothing to do: enable index, exportMarkdown, or exportChunksJsonl");
}
// Content hash default: re-ingesting identical bytes dedupes to the same document.
String docId =
(documentId == null || documentId.isBlank())
? fileIdStrategy.idFor(file)
: documentId.trim();
List<AiPageText> pages;
try (PDDocument document = pdfDocumentFactory.load(file, true)) {
pages = extractPages(document);
}
String callerId = currentUserId();
// Null expiresAt = persistent until explicit delete; ingest here is a deliberate
// knowledge-base action, unlike the TTL'd auto-ingest in AiWorkflowService.
RagIngestRequest request =
new RagIngestRequest(
fileName(file),
docId,
fileName(file),
callerId,
// Engine forbids an empty list here (min_length=1); null means
// "default to the owner" on the engine side.
callerId == null ? null : List.of(callerId),
null,
pages,
Math.clamp(chunkSize, 64, 32_768),
Math.clamp(overlap, 0, 4_096),
effectiveMode(settingsMode(), mode),
index,
includeMarkdown,
includeChunks);
String responseJson =
aiEngineClient.postLongRunning(
RAG_INGEST_ENDPOINT, objectMapper.writeValueAsString(request), callerId);
return objectMapper.readValue(responseJson, RagIngestResponse.class);
}
/**
* The settings mode wins when stricter: a settings {@code basic} always forces basic, a
* settings {@code advanced} upgrades everything except an explicit basic request.
*/
static DocparseMode effectiveMode(DocparseMode settings, DocparseMode requested) {
DocparseMode request = requested == null ? DocparseMode.AUTO : requested;
if (settings == DocparseMode.BASIC) {
return DocparseMode.BASIC;
}
if (settings == DocparseMode.ADVANCED) {
return request == DocparseMode.BASIC ? DocparseMode.BASIC : DocparseMode.ADVANCED;
}
return request;
}
/** Extract per-page text for the engine, capped by the shared aiEngine limits. */
List<AiPageText> extractPages(PDDocument document) throws IOException {
ApplicationProperties.AiEngine.Limits limits =
applicationProperties.getAiEngine().getLimits();
int maxPages = Math.min(document.getNumberOfPages(), limits.getMaxPages());
int remainingCharacters = limits.getMaxCharacters();
List<AiPageText> pages = new ArrayList<>();
for (int page = 1; page <= maxPages && remainingCharacters > 0; page++) {
String text = pdfContentExtractor.extractPageTextRaw(document, page);
if (text == null || text.isBlank()) {
continue;
}
if (text.length() > remainingCharacters) {
text = text.substring(0, remainingCharacters);
}
pages.add(new AiPageText(page, text));
remainingCharacters -= text.length();
}
return pages;
}
private DocparseMode settingsMode() {
try {
return DocparseMode.fromWire(applicationProperties.getDocparse().getMode());
} catch (IllegalArgumentException e) {
log.warn(
"Unknown docparse.mode '{}'; falling back to auto",
applicationProperties.getDocparse().getMode());
return DocparseMode.AUTO;
}
}
public static String fileName(MultipartFile file) {
String name = Filenames.toSimpleFileName(file.getOriginalFilename());
return (name == null || name.isBlank()) ? "document.pdf" : name;
}
private String currentUserId() {
return userService != null ? userService.getCurrentUsername() : null;
}
}
@@ -0,0 +1,123 @@
package stirling.software.proprietary.service;
import java.time.Duration;
import java.time.Instant;
import java.util.concurrent.atomic.AtomicBoolean;
import org.springframework.stereotype.Service;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import stirling.software.common.model.ApplicationProperties;
import stirling.software.common.service.DocparseCapabilityServiceInterface;
import stirling.software.proprietary.model.docparse.DocparseCapabilities;
import tools.jackson.databind.ObjectMapper;
/**
* Probes the engine's {@code GET /api/v1/docparse/capabilities} and caches the answer for 5
* minutes. Reports "addon absent" when the AI engine is disabled or the probe fails, so callers can
* always route to the basic tier without special-casing errors.
*/
@Slf4j
@Service
@RequiredArgsConstructor
public class DocparseCapabilityService implements DocparseCapabilityServiceInterface {
private static final String CAPABILITIES_ENDPOINT = "/api/v1/docparse/capabilities";
private static final Duration CACHE_TTL = Duration.ofMinutes(5);
private final AiEngineClient aiEngineClient;
private final ApplicationProperties applicationProperties;
private final ObjectMapper objectMapper;
private record Snapshot(
DocparseCapabilities capabilities, boolean engineReachable, Instant fetchedAt) {}
private volatile Snapshot snapshot;
private final AtomicBoolean refreshing = new AtomicBoolean();
/** The cached capabilities, refreshed synchronously when stale. */
public DocparseCapabilities capabilities() {
return freshSnapshot().capabilities();
}
/** Whether the last capability probe reached the engine. */
public boolean isEngineReachable() {
Snapshot current = snapshot;
return current != null && current.engineReachable();
}
/** {@code refresh(true)} bypasses the cache and re-probes the engine now. */
public DocparseCapabilities refresh(boolean force) {
if (!force) {
return capabilities();
}
Snapshot fresh = fetch();
snapshot = fresh;
return fresh.capabilities();
}
/**
* Non-blocking read for app-config: returns the last known value and kicks off a background
* refresh when stale, so a slow/unreachable engine never delays page load.
*/
@Override
public boolean isAdvancedInstalled() {
Snapshot current = snapshot;
if (current == null || isStale(current)) {
triggerAsyncRefresh();
}
return current != null && current.capabilities().advancedInstalled();
}
private Snapshot freshSnapshot() {
Snapshot current = snapshot;
if (current != null && !isStale(current)) {
return current;
}
Snapshot fresh = fetch();
snapshot = fresh;
return fresh;
}
private void triggerAsyncRefresh() {
if (!refreshing.compareAndSet(false, true)) {
return;
}
Thread.ofVirtual()
.name("docparse-capability-refresh")
.start(
() -> {
try {
snapshot = fetch();
} finally {
refreshing.set(false);
}
});
}
private Snapshot fetch() {
if (!applicationProperties.getAiEngine().isEnabled()) {
return new Snapshot(
DocparseCapabilities.absent("AI engine is disabled"), false, Instant.now());
}
try {
String json = aiEngineClient.get(CAPABILITIES_ENDPOINT, null);
DocparseCapabilities capabilities =
objectMapper.readValue(json, DocparseCapabilities.class);
return new Snapshot(capabilities, true, Instant.now());
} catch (Exception e) {
log.debug("DocParse capability probe failed: {}", e.getMessage());
return new Snapshot(
DocparseCapabilities.absent("Capability probe failed: " + e.getMessage()),
false,
Instant.now());
}
}
private static boolean isStale(Snapshot current) {
return current.fetchedAt().plus(CACHE_TTL).isBefore(Instant.now());
}
}
@@ -0,0 +1,92 @@
package stirling.software.proprietary.model.docparse;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertNull;
import static org.junit.jupiter.api.Assertions.assertTrue;
import java.util.List;
import org.junit.jupiter.api.Test;
import stirling.software.proprietary.model.api.ai.AiPageText;
import tools.jackson.databind.JsonNode;
import tools.jackson.databind.json.JsonMapper;
/**
* The Java DTOs must serialize to exactly the camelCase wire shapes defined in {@code
* engine/src/stirling/contracts/docparse.py}; a drift here breaks ingestion silently.
*/
class DocparseWireContractTest {
private final JsonMapper mapper = JsonMapper.builder().build();
@Test
void ragIngestRequestSerializesTheEngineContract() {
RagIngestRequest request =
new RagIngestRequest(
"report.pdf",
"doc-1",
"report.pdf",
"user:alice",
List.of("user:alice"),
null,
List.of(new AiPageText(1, "hello")),
512,
64,
DocparseMode.AUTO,
true,
false,
true);
JsonNode json = mapper.readTree(mapper.writeValueAsString(request));
assertEquals("report.pdf", json.get("fileName").asText());
assertEquals("doc-1", json.get("documentId").asText());
assertEquals("user:alice", json.get("ownerId").asText());
assertEquals("user:alice", json.get("readPrincipals").get(0).asText());
assertEquals(1, json.get("pages").get(0).get("pageNumber").asInt());
assertEquals("hello", json.get("pages").get(0).get("text").asText());
assertEquals(512, json.get("chunkSize").asInt());
assertEquals(64, json.get("overlap").asInt());
assertEquals("auto", json.get("mode").asText());
assertTrue(json.get("index").asBoolean());
assertFalse(json.get("includeMarkdown").asBoolean());
assertTrue(json.get("includeChunks").asBoolean());
}
@Test
void ragIngestResponseReadsTheEngineShapeIncludingEchoedContent() {
String engineJson =
"{\"mode\":\"basic\",\"documentId\":\"doc-1\",\"chunksIndexed\":2,\"pages\":3,"
+ "\"markdown\":\"# Title\",\"chunks\":[{\"index\":0,\"text\":\"t\","
+ "\"pageStart\":1,\"pageEnd\":2,\"headingPath\":[\"Intro\"]}]}";
RagIngestResponse response = mapper.readValue(engineJson, RagIngestResponse.class);
assertEquals(DocparseTier.BASIC, response.mode());
assertEquals("doc-1", response.documentId());
assertEquals(2, response.chunksIndexed());
assertEquals(3, response.pages());
assertEquals("# Title", response.markdown());
assertEquals(1, response.chunks().size());
assertEquals(List.of("Intro"), response.chunks().get(0).headingPath());
}
@Test
void ragIngestResponseToleratesAbsentEchoFields() {
String engineJson =
"{\"mode\":\"basic\",\"documentId\":\"d\",\"chunksIndexed\":0,\"pages\":1}";
RagIngestResponse response = mapper.readValue(engineJson, RagIngestResponse.class);
assertNull(response.markdown());
assertNull(response.chunks());
}
@Test
void capabilitiesReadTheEngineProbeShape() {
String engineJson =
"{\"advancedInstalled\":false,\"doclingVersion\":null,\"torchVersion\":null,"
+ "\"modelsAvailable\":false,\"modelsPath\":null,\"errors\":[\"missing\"]}";
DocparseCapabilities capabilities =
mapper.readValue(engineJson, DocparseCapabilities.class);
assertFalse(capabilities.advancedInstalled());
assertEquals(List.of("missing"), capabilities.errors());
}
}
@@ -0,0 +1,32 @@
package stirling.software.proprietary.policy.output;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertTrue;
import java.util.List;
import java.util.Map;
import org.junit.jupiter.api.Test;
import org.springframework.core.io.ByteArrayResource;
import stirling.software.proprietary.policy.model.OutputSpec;
class DiscardOutputSinkTest {
private final DiscardOutputSink sink = new DiscardOutputSink();
@Test
void supportsOnlyDiscardSpecs() {
assertTrue(sink.supports(new OutputSpec("discard", Map.of())));
assertFalse(sink.supports(OutputSpec.inline()));
assertFalse(sink.supports(null));
}
@Test
void deliverSwallowsOutputsAndReturnsNoResults() {
List<org.springframework.core.io.Resource> outputs =
List.of(new ByteArrayResource(new byte[] {1, 2, 3}));
assertEquals(List.of(), sink.deliver(null, outputs, new OutputSpec("discard", Map.of())));
}
}
@@ -0,0 +1,78 @@
package stirling.software.proprietary.security.filter;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertNull;
import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.Test;
import org.slf4j.MDC;
import org.springframework.mock.web.MockHttpServletRequest;
import org.springframework.mock.web.MockHttpServletResponse;
import org.springframework.security.authentication.AnonymousAuthenticationToken;
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
import org.springframework.security.core.authority.AuthorityUtils;
import org.springframework.security.core.context.SecurityContextHolder;
class PrincipalMdcFilterTest {
private final PrincipalMdcFilter filter = new PrincipalMdcFilter();
@AfterEach
void cleanUp() {
SecurityContextHolder.clearContext();
MDC.remove(PrincipalMdcFilter.MDC_KEY);
}
@Test
void stampsAuthenticatedPrincipalDuringTheChainAndClearsAfter() throws Exception {
SecurityContextHolder.getContext()
.setAuthentication(
new UsernamePasswordAuthenticationToken(
"alice", "n/a", AuthorityUtils.createAuthorityList("ROLE_USER")));
String[] seen = new String[1];
filter.doFilter(
new MockHttpServletRequest(),
new MockHttpServletResponse(),
(req, res) -> seen[0] = MDC.get(PrincipalMdcFilter.MDC_KEY));
assertEquals("alice", seen[0]);
assertNull(MDC.get(PrincipalMdcFilter.MDC_KEY));
}
@Test
void anonymousRequestsAreNotStamped() throws Exception {
SecurityContextHolder.getContext()
.setAuthentication(
new AnonymousAuthenticationToken(
"key",
"anonymousUser",
AuthorityUtils.createAuthorityList("ROLE_ANONYMOUS")));
String[] seen = new String[] {"sentinel"};
filter.doFilter(
new MockHttpServletRequest(),
new MockHttpServletResponse(),
(req, res) -> seen[0] = MDC.get(PrincipalMdcFilter.MDC_KEY));
assertNull(seen[0]);
}
@Test
void existingMdcPrincipalIsLeftUntouched() throws Exception {
MDC.put(PrincipalMdcFilter.MDC_KEY, "policy-run-owner");
SecurityContextHolder.getContext()
.setAuthentication(
new UsernamePasswordAuthenticationToken(
"alice", "n/a", AuthorityUtils.createAuthorityList("ROLE_USER")));
String[] seen = new String[1];
filter.doFilter(
new MockHttpServletRequest(),
new MockHttpServletResponse(),
(req, res) -> seen[0] = MDC.get(PrincipalMdcFilter.MDC_KEY));
assertEquals("policy-run-owner", seen[0]);
assertEquals("policy-run-owner", MDC.get(PrincipalMdcFilter.MDC_KEY));
}
}
@@ -0,0 +1,235 @@
package stirling.software.proprietary.service;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertThrows;
import static org.junit.jupiter.api.Assertions.assertTrue;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.anyBoolean;
import static org.mockito.ArgumentMatchers.eq;
import static org.mockito.ArgumentMatchers.isNull;
import static org.mockito.Mockito.verifyNoInteractions;
import static org.mockito.Mockito.when;
import java.io.IOException;
import org.apache.pdfbox.pdmodel.PDDocument;
import org.apache.pdfbox.pdmodel.PDPage;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.ArgumentCaptor;
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;
import org.springframework.http.HttpStatus;
import org.springframework.mock.web.MockMultipartFile;
import org.springframework.web.multipart.MultipartFile;
import org.springframework.web.server.ResponseStatusException;
import stirling.software.common.model.ApplicationProperties;
import stirling.software.common.service.CustomPDFDocumentFactory;
import stirling.software.proprietary.model.docparse.DocparseMode;
import stirling.software.proprietary.model.docparse.RagIngestResponse;
import tools.jackson.databind.JsonNode;
import tools.jackson.databind.json.JsonMapper;
/** Wire-building and mode capping for the ingestion path. */
@ExtendWith(MockitoExtension.class)
class DocParseServiceTest {
private static final String ENGINE_RESPONSE =
"{\"mode\":\"basic\",\"documentId\":\"doc-1\",\"chunksIndexed\":3,\"pages\":2,"
+ "\"markdown\":null,\"chunks\":null}";
@Mock private AiEngineClient aiEngineClient;
@Mock private DocparseCapabilityService capabilityService;
@Mock private CustomPDFDocumentFactory pdfDocumentFactory;
@Mock private PdfContentExtractor pdfContentExtractor;
@Mock private FileIdStrategy fileIdStrategy;
private ApplicationProperties properties;
private DocParseService service;
private final JsonMapper jsonMapper = JsonMapper.builder().build();
@BeforeEach
void setUp() {
properties = new ApplicationProperties();
service =
new DocParseService(
aiEngineClient,
capabilityService,
pdfDocumentFactory,
pdfContentExtractor,
properties,
jsonMapper,
fileIdStrategy,
null);
}
// --- effectiveMode: the settings mode wins when stricter ---
@Test
void settingsBasicForcesBasic() {
assertEquals(
DocparseMode.BASIC,
DocParseService.effectiveMode(DocparseMode.BASIC, DocparseMode.ADVANCED));
assertEquals(
DocparseMode.BASIC,
DocParseService.effectiveMode(DocparseMode.BASIC, DocparseMode.AUTO));
}
@Test
void settingsAdvancedUpgradesAutoButRespectsExplicitBasic() {
assertEquals(
DocparseMode.ADVANCED,
DocParseService.effectiveMode(DocparseMode.ADVANCED, DocparseMode.AUTO));
assertEquals(
DocparseMode.BASIC,
DocParseService.effectiveMode(DocparseMode.ADVANCED, DocparseMode.BASIC));
}
@Test
void settingsAutoPassesRequestThroughAndNullMeansAuto() {
assertEquals(
DocparseMode.ADVANCED,
DocParseService.effectiveMode(DocparseMode.AUTO, DocparseMode.ADVANCED));
assertEquals(DocparseMode.AUTO, DocParseService.effectiveMode(DocparseMode.AUTO, null));
}
// --- ragIngest wire building ---
private MultipartFile pdfFile() {
return new MockMultipartFile("fileInput", "invoice.pdf", "application/pdf", new byte[] {1});
}
private JsonNode ingestAndCaptureRequest(
String documentId, boolean index, boolean markdown, boolean chunks) throws IOException {
try (PDDocument document = new PDDocument()) {
document.addPage(new PDPage());
document.addPage(new PDPage());
when(pdfDocumentFactory.load(any(MultipartFile.class), anyBoolean()))
.thenReturn(document);
when(pdfContentExtractor.extractPageTextRaw(any(), eq(1))).thenReturn("page one");
when(pdfContentExtractor.extractPageTextRaw(any(), eq(2))).thenReturn("page two");
ArgumentCaptor<String> body = ArgumentCaptor.forClass(String.class);
when(aiEngineClient.postLongRunning(
eq("/api/v1/docparse/rag-ingest"), body.capture(), isNull()))
.thenReturn(ENGINE_RESPONSE);
RagIngestResponse response =
service.ragIngest(
pdfFile(),
documentId,
512,
64,
DocparseMode.AUTO,
index,
markdown,
chunks);
assertEquals(3, response.chunksIndexed());
return jsonMapper.readTree(body.getValue());
}
}
@Test
void ragIngestExtractsPagesAndSendsTheWireContract() throws IOException {
JsonNode request = ingestAndCaptureRequest("doc-1", true, false, false);
assertEquals("invoice.pdf", request.get("fileName").asText());
assertEquals("doc-1", request.get("documentId").asText());
assertEquals("auto", request.get("mode").asText());
assertEquals(2, request.get("pages").size());
assertEquals("page one", request.get("pages").get(0).get("text").asText());
assertEquals(512, request.get("chunkSize").asInt());
assertEquals(64, request.get("overlap").asInt());
assertTrue(request.get("index").asBoolean());
assertFalse(request.get("includeMarkdown").asBoolean());
assertFalse(request.get("includeChunks").asBoolean());
}
@Test
void ragIngestDefaultsDocumentIdToContentHash() throws IOException {
when(fileIdStrategy.idFor(any(MultipartFile.class))).thenReturn("sha-abc");
JsonNode request = ingestAndCaptureRequest(" ", true, false, false);
assertEquals("sha-abc", request.get("documentId").asText());
}
@Test
void ragIngestForwardsExportFlags() throws IOException {
JsonNode request = ingestAndCaptureRequest("doc-1", false, true, true);
assertFalse(request.get("index").asBoolean());
assertTrue(request.get("includeMarkdown").asBoolean());
assertTrue(request.get("includeChunks").asBoolean());
}
@Test
void ragIngestSettingsBasicCapsTheWireMode() throws IOException {
properties.getDocparse().setMode("basic");
JsonNode request = ingestAndCaptureRequest("doc-1", true, false, false);
assertEquals("basic", request.get("mode").asText());
}
@Test
void ragIngestWithNothingToDoIs400() {
ResponseStatusException error =
assertThrows(
ResponseStatusException.class,
() ->
service.ragIngest(
pdfFile(),
"doc",
512,
64,
DocparseMode.AUTO,
false,
false,
false));
assertEquals(HttpStatus.BAD_REQUEST, error.getStatusCode());
verifyNoInteractions(aiEngineClient);
}
@Test
void ragIngestWhenDisabledIs503() {
properties.getDocparse().setEnabled(false);
ResponseStatusException error =
assertThrows(
ResponseStatusException.class,
() ->
service.ragIngest(
pdfFile(),
"doc",
512,
64,
DocparseMode.AUTO,
true,
false,
false));
assertEquals(HttpStatus.SERVICE_UNAVAILABLE, error.getStatusCode());
verifyNoInteractions(aiEngineClient);
}
@Test
void fileNameFallsBackWhenMissing() {
MultipartFile nameless =
new MockMultipartFile("fileInput", "", "application/pdf", new byte[] {1});
assertEquals("document.pdf", DocParseService.fileName(nameless));
assertEquals("invoice.pdf", DocParseService.fileName(pdfFile()));
}
@Test
void extractPagesSkipsBlankPagesAndCapsCharacters() throws IOException {
properties.getAiEngine().getLimits().setMaxCharacters(12);
try (PDDocument document = new PDDocument()) {
document.addPage(new PDPage());
document.addPage(new PDPage());
document.addPage(new PDPage());
when(pdfContentExtractor.extractPageTextRaw(any(), eq(1))).thenReturn("0123456789");
when(pdfContentExtractor.extractPageTextRaw(any(), eq(2))).thenReturn(" ");
when(pdfContentExtractor.extractPageTextRaw(any(), eq(3))).thenReturn("abcdef");
var pages = service.extractPages(document);
assertEquals(2, pages.size());
// Page 3 is truncated to the remaining budget (12 - 10 = 2 chars).
assertEquals("ab", pages.get(1).getText());
}
}
}
+2
View File
@@ -17,6 +17,7 @@ from stirling.api.routes import (
agent_capabilities_router,
agent_draft_router,
config_router,
docparse_router,
document_classifier_router,
document_router,
execution_router,
@@ -217,6 +218,7 @@ app.include_router(ledger_router, dependencies=_user_gate)
app.include_router(pdf_comments_router, dependencies=_user_gate)
app.include_router(agent_capabilities_router, dependencies=_user_gate)
app.include_router(document_classifier_router, dependencies=_user_gate)
app.include_router(docparse_router, dependencies=_user_gate)
# Config push is a system sync with no X-User-Id, so it is guarded by the shared secret
# and allow_config_push flag only, deliberately NOT the per-user identity gate.
app.include_router(config_router)
@@ -1,6 +1,7 @@
from .agent_capabilities import router as agent_capabilities_router
from .agent_drafts import router as agent_draft_router
from .config import router as config_router
from .docparse import router as docparse_router
from .document_classifier import router as document_classifier_router
from .documents import router as document_router
from .execution import router as execution_router
@@ -14,6 +15,7 @@ __all__ = [
"agent_capabilities_router",
"agent_draft_router",
"config_router",
"docparse_router",
"document_classifier_router",
"document_router",
"execution_router",
+119
View File
@@ -0,0 +1,119 @@
"""DocParse ingestion routes: capabilities and rag-ingest.
The basic tier chunks caller-extracted page text. Requests forcing the
advanced (layout) tier return 501 with a machine-readable ``addonRequired``
detail until the docparse addon ships; Java maps that onto its own error.
"""
from __future__ import annotations
import logging
from typing import Annotated
from fastapi import APIRouter, Depends, HTTPException, status
from stirling.api.dependencies import get_document_service, require_user_id
from stirling.config import AppSettings, load_settings
from stirling.contracts.docparse import (
DocChunk,
DocparseCapabilities,
DocparseMode,
RagIngestRequest,
RagIngestResponse,
)
from stirling.docparse import basic_chunks, probe_capabilities
from stirling.documents import DocumentService
from stirling.documents.service import CONTENT_TYPE_METADATA_KEY, DOCPARSE_CHUNK_CONTENT_TYPE
from stirling.models import OwnerId, PrincipalId, UserId
logger = logging.getLogger(__name__)
router = APIRouter(prefix="/api/v1/docparse", tags=["docparse"])
_ADDON_REQUIRED_DETAIL = {
"addonRequired": "docparse",
"message": "The docparse addon (Docling) is not installed on the engine. "
"The advanced tier is unavailable; use mode=basic or mode=auto.",
}
def _settings() -> AppSettings:
return load_settings()
@router.get("/capabilities", response_model=DocparseCapabilities)
async def capabilities(refresh: bool = False) -> DocparseCapabilities:
return probe_capabilities(_settings().docparse_home, refresh=refresh)
def _chunk_metadata(chunk: DocChunk) -> dict[str, str]:
meta = {CONTENT_TYPE_METADATA_KEY: DOCPARSE_CHUNK_CONTENT_TYPE}
if chunk.page_start is not None:
meta["page_start"] = str(chunk.page_start)
if chunk.page_end is not None:
meta["page_end"] = str(chunk.page_end)
if chunk.heading_path:
meta["heading_path"] = " > ".join(chunk.heading_path)
return meta
@router.post("/rag-ingest", response_model=RagIngestResponse)
async def rag_ingest(
request: RagIngestRequest,
documents: Annotated[DocumentService, Depends(get_document_service)],
user_id: Annotated[UserId, Depends(require_user_id)],
) -> RagIngestResponse:
"""Chunk the document, then embed and index into the document store.
Re-ingesting a documentId replaces its stored content (never duplicates).
``index=False`` skips the store; ``includeMarkdown``/``includeChunks``
echo the content back for corpus export."""
settings = _settings()
chunk_size = request.chunk_size if request.chunk_size is not None else settings.rag_chunk_size
overlap = request.overlap if request.overlap is not None else settings.rag_chunk_overlap
if request.mode is DocparseMode.ADVANCED:
raise HTTPException(status_code=status.HTTP_501_NOT_IMPLEMENTED, detail=_ADDON_REQUIRED_DETAIL)
if not request.pages:
raise HTTPException(
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
detail="send pages (extracted text); the advanced tier needs the docparse addon",
)
if not request.index and not request.include_markdown and not request.include_chunks:
raise HTTPException(
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
detail="nothing to do: enable index, includeMarkdown, or includeChunks",
)
chunked = basic_chunks(request.pages, chunk_size, overlap)
page_count = max(p.page_number for p in request.pages)
chunks_indexed = 0
if request.index:
# Owner/ACL semantics mirror IngestDocumentRequest; omitted values default
# to the authenticated caller (personal-doc behaviour).
owner_id = request.owner_id if request.owner_id is not None else OwnerId(user_id)
read_principals = request.read_principals or [PrincipalId(owner_id)]
chunks_indexed = await documents.ingest_prepared(
collection=request.document_id,
chunks=[(chunk.text, _chunk_metadata(chunk)) for chunk in chunked.chunks],
source=request.source,
owner_id=owner_id,
read_principals=read_principals,
expires_at=request.expires_at,
)
markdown = None
if request.include_markdown:
markdown = "\n\n".join(p.text for p in request.pages if p.text.strip())
logger.info(
"docparse: rag-ingested %s: %d chunks indexed, %d pages", request.document_id, chunks_indexed, page_count
)
return RagIngestResponse(
mode=chunked.mode,
document_id=request.document_id,
chunks_indexed=chunks_indexed,
pages=page_count,
markdown=markdown,
chunks=chunked.chunks if request.include_chunks else None,
)
+5
View File
@@ -101,6 +101,11 @@ class AppSettings(BaseSettings):
max_pages: int = Field(validation_alias="STIRLING_MAX_PAGES")
max_characters: int = Field(validation_alias="STIRLING_MAX_CHARACTERS")
# DocParse addon home: <home>/site holds a dynamically installed docling
# stack (prepended to sys.path at startup), <home>/models holds prefetched
# model weights. Empty = addon only usable when baked into the image.
docparse_home: str = Field(default="", validation_alias="STIRLING_DOCPARSE_HOME")
# When true, API routes reject requests that lack an X-User-Id header at
# the boundary. Self-hosted deployments with security disabled have no
# user identity and leave this off; multi-tenant deployments turn it on so
+89
View File
@@ -0,0 +1,89 @@
"""Wire contracts for the DocParse ingestion capability (chunking + rag-ingest).
Java counterpart DTOs live under ``stirling.software.proprietary.model.docparse``
and must stay in sync. Tier model: ``basic`` (text layer) runs everywhere;
``advanced`` (layout parsing) arrives with the docparse addon.
"""
from __future__ import annotations
from datetime import datetime
from enum import StrEnum
from pydantic import Field
from stirling.contracts.documents import PageText
from stirling.models import ApiModel, FileId, OwnerId, PrincipalId
class DocparseTier(StrEnum):
"""Which implementation actually served a request."""
BASIC = "basic"
ADVANCED = "advanced"
class DocparseMode(StrEnum):
"""What the caller asked for; AUTO resolves per-request."""
AUTO = "auto"
BASIC = "basic"
ADVANCED = "advanced"
class DocChunk(ApiModel):
index: int = Field(ge=0)
text: str
page_start: int | None = Field(default=None, ge=1)
page_end: int | None = Field(default=None, ge=1)
heading_path: list[str] = Field(default_factory=list)
class ChunkDocumentResponse(ApiModel):
mode: DocparseTier
chunks: list[DocChunk] = Field(default_factory=list)
class RagIngestRequest(ApiModel):
"""Chunk the document, then optionally embed and replace-index it.
``owner_id``/``read_principals`` default to the calling user (personal-doc
semantics); ``chunk_size``/``overlap`` default to the engine's RAG settings.
``index=False`` skips the store entirely (export-only ingestion);
``include_markdown``/``include_chunks`` echo the parsed content back so the
caller can emit corpus files (markdown, chunks JSONL).
"""
file_name: str = Field(min_length=1)
document_id: FileId = Field(min_length=1)
source: str = Field(default="docparse", min_length=1)
owner_id: OwnerId | None = None
read_principals: list[PrincipalId] | None = Field(default=None, min_length=1)
expires_at: datetime | None = None
pages: list[PageText] | None = None
chunk_size: int | None = Field(default=None, ge=64, le=32_768)
overlap: int | None = Field(default=None, ge=0, le=4_096)
mode: DocparseMode = DocparseMode.AUTO
index: bool = True
include_markdown: bool = False
include_chunks: bool = False
class RagIngestResponse(ApiModel):
mode: DocparseTier
document_id: FileId
chunks_indexed: int = Field(ge=0)
pages: int = Field(ge=0)
markdown: str | None = None
chunks: list[DocChunk] | None = None
class DocparseCapabilities(ApiModel):
"""What the engine can actually do right now; Java caches and republishes this."""
advanced_installed: bool
docling_version: str | None = None
torch_version: str | None = None
models_available: bool = False
models_path: str | None = None
errors: list[str] = Field(default_factory=list)
+16
View File
@@ -0,0 +1,16 @@
"""DocParse: document understanding for ingestion pipelines.
This package holds the basic (text-layer) tier; the advanced tier (Docling
layout parsing) is delivered as an optional addon and probed at runtime.
"""
from __future__ import annotations
from stirling.docparse.capability import activate_site, probe_capabilities
from stirling.docparse.chunking import basic_chunks
__all__ = [
"activate_site",
"basic_chunks",
"probe_capabilities",
]
+104
View File
@@ -0,0 +1,104 @@
"""Probe whether the docparse addon (Docling) is importable and its models present.
The addon arrives either baked into the engine image (uv extra) or dynamically
installed into ``$STIRLING_DOCPARSE_HOME/site`` on a mounted volume; in the
latter case :func:`activate_site` prepends that directory to ``sys.path`` at
startup so the probe and the parser see it.
"""
from __future__ import annotations
import importlib
import importlib.metadata
import importlib.util
import logging
import sys
import threading
from pathlib import Path
from stirling.contracts.docparse import DocparseCapabilities
logger = logging.getLogger(__name__)
_lock = threading.Lock()
_cached: DocparseCapabilities | None = None
def site_dir(docparse_home: str) -> Path | None:
return Path(docparse_home) / "site" if docparse_home else None
def models_dir(docparse_home: str) -> Path | None:
return Path(docparse_home) / "models" if docparse_home else None
def activate_site(docparse_home: str) -> bool:
"""Prepend the dynamic-install site dir to ``sys.path`` if it exists.
Idempotent; returns True when the path is active. Called once from the app
lifespan before the first capability probe.
"""
site = site_dir(docparse_home)
if site is None or not site.is_dir():
return False
site_str = str(site)
if site_str not in sys.path:
sys.path.insert(0, site_str)
logger.info("docparse: activated dynamic site dir %s", site_str)
return True
def _version_of(distribution: str) -> str | None:
try:
return importlib.metadata.version(distribution)
except importlib.metadata.PackageNotFoundError:
return None
def _models_available(docparse_home: str) -> tuple[bool, str | None]:
"""Models count as available when the prefetch dir has content, or when no
home is configured at all (Docling then downloads into its default cache)."""
directory = models_dir(docparse_home)
if directory is None:
return True, None
if directory.is_dir() and any(directory.iterdir()):
return True, str(directory)
return False, str(directory)
def probe_capabilities(docparse_home: str, *, refresh: bool = False) -> DocparseCapabilities:
"""Report what the docparse layer can do right now. Cached after first call
(imports are expensive); ``refresh=True`` re-probes, e.g. after a dynamic install."""
global _cached
with _lock:
if _cached is not None and not refresh:
return _cached
errors: list[str] = []
advanced = importlib.util.find_spec("docling") is not None
docling_version: str | None = None
torch_version: str | None = None
if advanced:
docling_version = _version_of("docling")
torch_version = _version_of("torch")
if torch_version is None:
advanced = False
errors.append("docling present but torch missing; install is incomplete")
models_ok, models_path = _models_available(docparse_home)
_cached = DocparseCapabilities(
advanced_installed=advanced,
docling_version=docling_version,
torch_version=torch_version,
models_available=models_ok,
models_path=models_path,
errors=errors,
)
logger.info(
"docparse capabilities: advanced=%s docling=%s torch=%s models=%s",
advanced,
docling_version,
torch_version,
models_ok,
)
return _cached
+26
View File
@@ -0,0 +1,26 @@
"""RAG chunking, basic tier: the existing character chunker applied per page.
The advanced tier (structure-aware packing over layout blocks) arrives with
the docparse addon. No tokenizer dependency; sizes are characters, matching
the rest of the engine."""
from __future__ import annotations
from stirling.contracts.docparse import ChunkDocumentResponse, DocChunk, DocparseTier
from stirling.contracts.documents import PageText
from stirling.documents.chunker import chunk_text
def basic_chunks(pages: list[PageText], chunk_size: int, overlap: int) -> ChunkDocumentResponse:
chunks: list[DocChunk] = []
for page in pages:
for piece in chunk_text(page.text, chunk_size=chunk_size, overlap=overlap):
chunks.append(
DocChunk(
index=len(chunks),
text=piece,
page_start=page.page_number,
page_end=page.page_number,
)
)
return ChunkDocumentResponse(mode=DocparseTier.BASIC, chunks=chunks)
+37
View File
@@ -13,6 +13,7 @@ logger = logging.getLogger(__name__)
PAGE_NUMBER_METADATA_KEY = "page_number"
CONTENT_TYPE_METADATA_KEY = "content_type"
PAGE_TEXT_CONTENT_TYPE = "page_text"
DOCPARSE_CHUNK_CONTENT_TYPE = "docparse_chunk"
class DocumentService:
@@ -109,6 +110,42 @@ class DocumentService:
await self._store.add_documents(collection, chunks, embeddings, owner_id)
return len(chunks)
async def ingest_prepared(
self,
collection: FileId,
chunks: list[tuple[str, dict[str, str]]],
source: str,
owner_id: OwnerId,
read_principals: list[PrincipalId],
expires_at: datetime | None,
) -> int:
"""Replace-ingest pre-chunked content (e.g. docparse structure-aware chunks).
Same lifecycle as :meth:`ingest` - wipe the ``(collection, owner_id)``
pair, recreate it, grant reads, embed in batches, upsert - but chunk
``(text, metadata)`` pairs arrive pre-built and no page representation
is written. Returns the number of vector chunks indexed.
"""
if not read_principals:
raise ValueError("read_principals must not be empty - every doc needs at least one reader")
await self._store.delete_collection(collection, owner_id)
await self._store.ensure_collection(collection, source, owner_id, expires_at)
await self._store.grant_read(collection, owner_id, read_principals)
documents: list[Document] = []
for i, (text, metadata) in enumerate(chunks):
if not text.strip():
continue
meta = {**metadata, "source": source, "chunk_index": str(i)}
documents.append(Document(id=f"{source}:docparse:{i}", text=text, metadata=meta))
if not documents:
return 0
embeddings = await self._embedder.embed_documents([doc.text for doc in documents])
await self._store.add_documents(collection, documents, embeddings, owner_id)
return len(documents)
async def search(
self,
query: str,
+247
View File
@@ -0,0 +1,247 @@
from __future__ import annotations
from collections.abc import Iterator
from datetime import datetime
from typing import Any
import pytest
from fastapi.testclient import TestClient
from stirling.api import app
from stirling.api.dependencies import get_document_service
from stirling.documents import DocumentService, SqliteVecStore
from stirling.models import FileId, OwnerId, PrincipalId
HEADERS = {"X-User-Id": "test-user"}
class StubDocumentService:
"""Records ingest_prepared calls so the route's passthrough can be asserted."""
def __init__(self) -> None:
self.calls: list[dict[str, Any]] = []
async def ingest_prepared(
self,
collection: FileId,
chunks: list[tuple[str, dict[str, str]]],
source: str,
owner_id: OwnerId,
read_principals: list[PrincipalId],
expires_at: datetime | None,
) -> int:
self.calls.append(
{
"collection": collection,
"chunks": chunks,
"source": source,
"owner_id": owner_id,
"read_principals": read_principals,
"expires_at": expires_at,
}
)
return len(chunks)
class StubEmbedder:
"""Deterministic embeddings: no network, no provider needed."""
def __init__(self, dim: int = 8) -> None:
self._dim = dim
async def embed_query(self, text: str) -> list[float]:
h = hash(text) % 1000
return [(h + i) / 1000.0 for i in range(self._dim)]
async def embed_documents(self, texts: list[str]) -> list[list[float]]:
return [await self.embed_query(t) for t in texts]
@pytest.fixture
def stub_service() -> StubDocumentService:
return StubDocumentService()
@pytest.fixture
def client(stub_service: StubDocumentService) -> Iterator[TestClient]:
app.dependency_overrides[get_document_service] = lambda: stub_service
try:
yield TestClient(app)
finally:
app.dependency_overrides.pop(get_document_service, None)
def test_rag_ingest_basic_tier_indexes_chunks_with_metadata(
client: TestClient, stub_service: StubDocumentService
) -> None:
response = client.post(
"/api/v1/docparse/rag-ingest",
json={
"fileName": "report.pdf",
"documentId": "doc-1",
"pages": [{"pageNumber": 1, "text": "para one"}, {"pageNumber": 2, "text": "para two"}],
"chunkSize": 64,
},
headers=HEADERS,
)
assert response.status_code == 200
body = response.json()
assert body["mode"] == "basic"
assert body["documentId"] == "doc-1"
assert body["chunksIndexed"] == 2
assert body["pages"] == 2
assert body["markdown"] is None
assert body["chunks"] is None
call = stub_service.calls[0]
assert call["collection"] == "doc-1"
assert call["source"] == "docparse"
text, metadata = call["chunks"][0]
assert text == "para one"
assert metadata["content_type"] == "docparse_chunk"
assert metadata["page_start"] == "1"
assert metadata["page_end"] == "1"
def test_rag_ingest_defaults_owner_and_readers_to_caller(client: TestClient, stub_service: StubDocumentService) -> None:
client.post(
"/api/v1/docparse/rag-ingest",
json={"fileName": "a.pdf", "documentId": "d", "pages": [{"pageNumber": 1, "text": "t"}]},
headers=HEADERS,
)
call = stub_service.calls[0]
assert call["owner_id"] == "test-user"
assert call["read_principals"] == ["test-user"]
assert call["expires_at"] is None
def test_rag_ingest_passes_explicit_owner_acl_and_expiry_through(
client: TestClient, stub_service: StubDocumentService
) -> None:
client.post(
"/api/v1/docparse/rag-ingest",
json={
"fileName": "a.pdf",
"documentId": "d",
"source": "handbook.pdf",
"ownerId": "org:acme",
"readPrincipals": ["group:eng", "user:bob"],
"expiresAt": "2030-01-01T00:00:00Z",
"pages": [{"pageNumber": 1, "text": "t"}],
},
headers=HEADERS,
)
call = stub_service.calls[0]
assert call["owner_id"] == "org:acme"
assert call["read_principals"] == ["group:eng", "user:bob"]
assert call["source"] == "handbook.pdf"
assert call["expires_at"] is not None
def test_rag_ingest_export_only_skips_the_store(client: TestClient, stub_service: StubDocumentService) -> None:
response = client.post(
"/api/v1/docparse/rag-ingest",
json={
"fileName": "a.pdf",
"documentId": "d",
"pages": [{"pageNumber": 1, "text": "alpha"}, {"pageNumber": 2, "text": "beta"}],
"index": False,
"includeMarkdown": True,
"includeChunks": True,
},
headers=HEADERS,
)
assert response.status_code == 200
body = response.json()
assert stub_service.calls == []
assert body["chunksIndexed"] == 0
assert body["markdown"] == "alpha\n\nbeta"
assert [c["text"] for c in body["chunks"]] == ["alpha", "beta"]
assert body["chunks"][0]["pageStart"] == 1
def test_rag_ingest_index_off_with_no_export_is_422(client: TestClient) -> None:
response = client.post(
"/api/v1/docparse/rag-ingest",
json={
"fileName": "a.pdf",
"documentId": "d",
"pages": [{"pageNumber": 1, "text": "t"}],
"index": False,
},
headers=HEADERS,
)
assert response.status_code == 422
def test_rag_ingest_advanced_mode_needs_the_addon(client: TestClient) -> None:
response = client.post(
"/api/v1/docparse/rag-ingest",
json={
"fileName": "x.pdf",
"documentId": "d",
"mode": "advanced",
"pages": [{"pageNumber": 1, "text": "t"}],
},
headers=HEADERS,
)
assert response.status_code == 501
assert response.json()["detail"]["addonRequired"] == "docparse"
def test_rag_ingest_without_pages_is_422(client: TestClient) -> None:
response = client.post(
"/api/v1/docparse/rag-ingest", json={"fileName": "x.pdf", "documentId": "d"}, headers=HEADERS
)
assert response.status_code == 422
def test_rag_ingest_rejects_missing_user_header(client: TestClient) -> None:
response = client.post(
"/api/v1/docparse/rag-ingest",
json={"fileName": "x.pdf", "documentId": "d", "pages": [{"pageNumber": 1, "text": "t"}]},
)
assert response.status_code == 401
def test_rag_ingest_rejects_empty_document_id(client: TestClient) -> None:
response = client.post(
"/api/v1/docparse/rag-ingest",
json={"fileName": "x.pdf", "documentId": "", "pages": [{"pageNumber": 1, "text": "t"}]},
headers=HEADERS,
)
assert response.status_code == 422
def test_capabilities_reports_probe_shape() -> None:
# Not asserting a value: a dev venv may genuinely have docling installed.
client = TestClient(app)
response = client.get("/api/v1/docparse/capabilities", headers=HEADERS)
assert response.status_code == 200
assert isinstance(response.json()["advancedInstalled"], bool)
# ── real service: replacement semantics ─────────────────────────────────
@pytest.mark.anyio
async def test_rag_ingest_reingest_replaces_instead_of_duplicating() -> None:
service = DocumentService(embedder=StubEmbedder(), store=SqliteVecStore.ephemeral(), default_top_k=3) # type: ignore[arg-type]
app.dependency_overrides[get_document_service] = lambda: service
try:
client = TestClient(app)
payload = {
"fileName": "report.pdf",
"documentId": "doc-replace",
"pages": [{"pageNumber": 1, "text": "first version"}],
}
assert client.post("/api/v1/docparse/rag-ingest", json=payload, headers=HEADERS).status_code == 200
payload["pages"] = [{"pageNumber": 1, "text": "second version"}]
assert client.post("/api/v1/docparse/rag-ingest", json=payload, headers=HEADERS).status_code == 200
finally:
app.dependency_overrides.pop(get_document_service, None)
results = await service.search("version", principals=[PrincipalId("test-user")], collection=FileId("doc-replace"))
assert [r.document.text for r in results] == ["second version"]
assert results[0].document.metadata["content_type"] == "docparse_chunk"
assert results[0].document.metadata["source"] == "docparse"
@@ -7764,6 +7764,23 @@ unsavedTitle = "Unsaved changes"
uploadUnsupported = "Uploaded files aren't supported in pipelines yet, so these steps can't be saved: {{tools}}."
usesDefaults = "Runs with default settings"
[portal.pipelines.builder.docIntelligence]
chunkSize = "Chunk size (characters)"
exportChunks = "Export chunks (JSONL)"
exportChunksHint = "Adds a .chunks.jsonl file to the step output, ready for external embedding or indexing."
exportMarkdown = "Export markdown"
exportMarkdownHint = "Adds a .md rendering of the parsed document to the step output."
index = "Store in built-in knowledge base"
indexHint = "Embed and store the chunks in the built-in knowledge base. Turn off for export-only ingestion."
mode = "Parsing mode"
modeAdvanced = "Advanced"
modeAuto = "Auto"
modeBasic = "Basic"
overlap = "Overlap (characters)"
ragIngest = "Ingest into knowledge base"
ragIngestHint = "Parses, chunks, embeds, and indexes each document in one step. Export toggles add corpus files (markdown, chunks JSONL) to the step output for delivery to external systems."
section = "Document intelligence"
[portal.pipelines.composer]
addTool = "Add tool"
cancel = "Cancel"
@@ -7861,7 +7878,7 @@ desc = "Enforce HIPAA, GDPR, SOC 2, or FedRAMP requirements on every document."
label = "Compliance"
[portal.policies.categories.ingestion]
desc = "Classify documents, extract structured data, enforce naming conventions, and normalize pages."
desc = "Normalize incoming documents - OCR scans, flatten forms - and index them into the searchable knowledge base, or export them as a clean corpus for your own systems."
label = "Ingestion"
[portal.policies.categories.retention]
@@ -7901,17 +7918,12 @@ onViolation = "When non-compliant"
2 = "Audit trail"
[portal.policies.config.ingestion]
summary = "Classifies documents, extracts structured data, enforces naming, and normalizes pages."
[portal.policies.config.ingestion.fields]
belowThreshold = "Below threshold"
minConfidence = "Min confidence"
summary = "Normalizes incoming documents and indexes them into the searchable knowledge base, with optional markdown/JSONL export."
[portal.policies.config.ingestion.rules]
0 = "Classify"
1 = "Extract"
2 = "Name"
3 = "Normalize"
0 = "OCR + flatten"
1 = "Index into knowledge base"
2 = "Export markdown / JSONL"
[portal.policies.config.purview]
label = "Apply a Microsoft Purview sensitivity label"
@@ -7936,6 +7948,25 @@ label = "Read a Microsoft Purview label"
connection = "Purview tenant"
connectionHelp = "Reads the label the document already carries, so later steps can act on it."
[portal.policies.config.ragIngest.fields]
chunkSize = "Chunk size (characters)"
chunkSizeHelp = "How much text each searchable passage holds. Larger chunks carry more context; smaller ones match more precisely."
exportChunks = "Export chunks (JSONL)"
exportChunksHelp = "Adds a .chunks.jsonl file to the step output - one chunk per line with page span - ready for external embedding or indexing."
exportMarkdown = "Export markdown"
exportMarkdownHelp = "Adds a .md rendering of the parsed document to the step output, for delivery to external systems."
index = "Store in built-in knowledge base"
indexHelp = "Embed and store the chunks in the built-in knowledge base. Turn off for export-only ingestion."
overlap = "Chunk overlap (characters)"
overlapHelp = "How much neighbouring chunks share, so answers spanning a boundary aren't cut in half."
[portal.policies.config.ragIngest.fields.mode]
advanced = "Advanced (layout parsing addon)"
auto = "Auto (best available)"
basic = "Basic (text layer)"
help = "Auto uses the best parser available. The advanced layout tier requires the engine's docparse addon."
label = "Parse tier"
[portal.policies.config.retention]
summary = "Enforces how long documents are kept, when to archive, and when to delete."
@@ -7976,6 +8007,7 @@ onEveryExport = "On every export"
onEveryUpload = "On every upload"
outputAsNewFile = "as a new file"
outputAsNewVersion = "as a new version"
outputDiscarded = "without saving a processed file"
recentActivity = "Recent activity"
retry = "Retry"
showLess = "Show less"
@@ -8007,6 +8039,7 @@ classifyAndLabel = "Classify"
compressPdf = "Compress"
flatten = "Flatten"
ocrPdf = "OCR"
ragIngest = "Ingest into knowledge base"
sanitizePdf = "Remove JavaScript"
[portal.policies.offline]
@@ -8239,13 +8272,17 @@ desc = "Compresses the document to a smaller file size."
label = "Reduce file size"
[portal.policies.wizard.capability.flatten]
desc = "Merges form fields and annotations into the page so they can't be edited."
desc = "Merges form fields and annotations into the page - so they can't be edited, and so filled-in answers are part of the extracted text."
label = "Flatten the document"
[portal.policies.wizard.capability.ocr]
desc = "Runs OCR so scanned pages become selectable, searchable text."
label = "Make text searchable"
[portal.policies.wizard.capability.ragIngest]
desc = "Parses, chunks, and embeds the document so it becomes searchable knowledge, or exports the parsed content (markdown, chunks JSONL) for your own systems."
label = "Index into the knowledge base"
[portal.policies.wizard.capability.redact]
desc = "Finds and blacks out sensitive details — like Social Security and card numbers — so they can't be read."
label = "Redact sensitive information"
@@ -8281,6 +8318,8 @@ prefix = "Prefix"
suffix = "Suffix"
[portal.policies.wizard.output.outputAs]
discard = "Don't save processed file (index and export only)"
discardHelp = "The document is normalized (OCR, flatten) only to feed extraction; nothing is written back. Exports still reach any configured destinations."
label = "Output as"
newFile = "New file"
newVersion = "New version"
+19 -22
View File
@@ -69,6 +69,8 @@ export interface PolicyConfigDef {
scopeLabel: string;
fields: PolicyField[];
defaultOperations: PolicyToolStep[];
/** Wizard default for "Output as"; e.g. ingestion discards the processed file. */
defaultOutputMode?: "new_file" | "new_version" | "discard";
}
export interface PolicyState {
@@ -78,7 +80,7 @@ export interface PolicyState {
scopeTypes: string[];
reviewerEmail: string;
fieldValues: Record<string, boolean | string | string[]>;
outputMode?: "new_file" | "new_version";
outputMode?: "new_file" | "new_version" | "discard";
outputName?: string;
outputNamePosition?: "prefix" | "suffix" | "auto-number";
runOn?: "upload" | "export";
@@ -93,7 +95,7 @@ export interface PolicySetupResult {
sources: string[];
scopeTypes: string[];
reviewerEmail: string;
outputMode: "new_file" | "new_version";
outputMode: "new_file" | "new_version" | "discard";
outputName: string;
outputNamePosition: "prefix" | "suffix" | "auto-number";
runOn: "upload" | "export";
@@ -138,7 +140,12 @@ export interface CatalogueEntry {
* {@link ToolEndpoint}s, plus the AI classify endpoint, which isn't part of the generated union.
*/
export const ENDPOINT_LABELS: Partial<
Record<ToolEndpoint | "/api/v1/ai/tools/classify-and-label", string>
Record<
| ToolEndpoint
| "/api/v1/ai/tools/classify-and-label"
| "/api/v1/docparse/rag-ingest",
string
>
> = {
"/api/v1/security/auto-redact": "portal.policies.endpoints.autoRedact",
"/api/v1/security/sanitize-pdf": "portal.policies.endpoints.sanitizePdf",
@@ -148,6 +155,7 @@ export const ENDPOINT_LABELS: Partial<
"/api/v1/misc/compress-pdf": "portal.policies.endpoints.compressPdf",
"/api/v1/ai/tools/classify-and-label":
"portal.policies.endpoints.classifyAndLabel",
"/api/v1/docparse/rag-ingest": "portal.policies.endpoints.ragIngest",
};
export function humanizeEndpoint(
@@ -179,8 +187,6 @@ export const POLICY_CATEGORIES: PolicyCategory[] = [
label: "portal.policies.categories.ingestion.label",
tone: "blue",
desc: "portal.policies.categories.ingestion.desc",
providesClassification: true,
comingSoon: true,
},
{
id: "security",
@@ -230,26 +236,17 @@ export const POLICY_CONFIG: Record<string, PolicyConfigDef> = {
"portal.policies.config.ingestion.rules.0",
"portal.policies.config.ingestion.rules.1",
"portal.policies.config.ingestion.rules.2",
"portal.policies.config.ingestion.rules.3",
],
scopeLabel: "portal.policies.config.scopeAll",
defaultOperations: [policyStep("ocr"), policyStep("flatten")],
fields: [
{
label: "portal.policies.config.ingestion.fields.minConfidence",
key: "minConfidence",
type: "select",
value: "p80",
options: ["p60", "p70", "p80", "p90", "p95"],
},
{
label: "portal.policies.config.ingestion.fields.belowThreshold",
key: "belowThreshold",
type: "select",
value: "flagForReview",
options: ["flagForReview", "routeToBucket", "hold"],
},
defaultOperations: [
policyStep("ocr"),
policyStep("flatten"),
policyStep("ragIngest"),
],
fields: [],
// Ingestion's product is the index/export, not a transformed PDF: the
// OCR+flatten normalization feeds extraction and is then discarded.
defaultOutputMode: "discard",
},
security: {
summary: "portal.policies.config.security.summary",
@@ -48,6 +48,16 @@ export function EditorIcon(props: IconProps) {
);
}
export function KnowledgeIcon(props: IconProps) {
return (
<Svg {...props}>
<ellipse cx="12" cy="5" rx="8" ry="3" />
<path d="M4 5v6c0 1.66 3.58 3 8 3s8-1.34 8-3V5" />
<path d="M4 11v6c0 1.66 3.58 3 8 3s8-1.34 8-3v-6" />
</Svg>
);
}
export function SourcesIcon(props: IconProps) {
return (
<Svg {...props}>
@@ -49,4 +49,30 @@ describe("PipelineStepSettings", () => {
).not.toThrow();
expect(screen.getByText("field")).toBeInTheDocument();
});
it("renders the rag-ingest form for the knowledge indexing operation", () => {
const ragStep = {
support: "unknown",
toolId: null,
operation: "/api/v1/docparse/rag-ingest",
params: { chunkSize: 512, overlap: 64, mode: "auto" },
} as unknown as WorkingToolStep;
render(
<MantineProvider>
<PipelineStepSettings
step={ragStep}
registry={{}}
onChange={() => {}}
/>
</MantineProvider>,
);
expect(
screen.getByText("portal.pipelines.builder.docIntelligence.chunkSize"),
).toBeInTheDocument();
expect(
screen.getByText(
"portal.pipelines.builder.docIntelligence.ragIngestHint",
),
).toBeInTheDocument();
});
});
@@ -10,6 +10,11 @@ import { type WorkingToolStep } from "@app/hooks/tools/shared/toolAutomation";
import { PolicyExternalApiConfig } from "@portal/components/policies/PolicyExternalApiConfig";
import { isIntegrationStep } from "@portal/components/pipelines/integrationStep";
import type { ExternalApiStepParams } from "@portal/components/policies/stepOperations";
import { RAG_INGEST_OPERATION } from "@portal/components/pipelines/docIntelligenceSteps";
import {
RagIngestStepConfig,
type RagIngestParams,
} from "@portal/components/pipelines/RagIngestStepConfig";
interface PipelineStepSettingsProps {
step: WorkingToolStep;
@@ -31,6 +36,16 @@ export function PipelineStepSettings({
// above useTranslation would change the hook count between renders and crash.
const { t } = useTranslation();
// Document-intelligence steps carry their own small forms; they have no registry entry.
if (step.operation === RAG_INGEST_OPERATION) {
return (
<RagIngestStepConfig
parameters={step.params as unknown as RagIngestParams}
onChange={(params) => onChange(params as never)}
/>
);
}
// An integration step is configured by the operations catalogue, not by a tool's settings UI:
// it has no registry entry to look one up from.
if (isIntegrationStep(step)) {
@@ -0,0 +1,112 @@
import { useTranslation } from "react-i18next";
import { FormField, Input, Select, ToggleSwitch } from "@app/ui";
/** Parameters for the "Ingest into knowledge base" pipeline step. */
export interface RagIngestParams {
chunkSize?: number;
overlap?: number;
mode?: string;
index?: boolean;
exportMarkdown?: boolean;
exportChunksJsonl?: boolean;
}
interface RagIngestStepConfigProps {
parameters: RagIngestParams;
onChange: (parameters: RagIngestParams) => void;
}
/** Settings for the rag-ingest step: destination toggles, chunking knobs, parse tier. */
export function RagIngestStepConfig({
parameters,
onChange,
}: RagIngestStepConfigProps) {
const { t } = useTranslation();
const setNumber = (key: "chunkSize" | "overlap", raw: string) => {
const value = Number(raw);
onChange({
...parameters,
[key]: Number.isFinite(value) && raw !== "" ? value : undefined,
});
};
return (
<div className="portal-policies__capability-config">
<ToggleSwitch
size="sm"
checked={parameters.index ?? true}
onChange={(checked) => onChange({ ...parameters, index: checked })}
label={t("portal.pipelines.builder.docIntelligence.index")}
description={t("portal.pipelines.builder.docIntelligence.indexHint")}
/>
<ToggleSwitch
size="sm"
checked={parameters.exportMarkdown ?? false}
onChange={(checked) =>
onChange({ ...parameters, exportMarkdown: checked })
}
label={t("portal.pipelines.builder.docIntelligence.exportMarkdown")}
description={t(
"portal.pipelines.builder.docIntelligence.exportMarkdownHint",
)}
/>
<ToggleSwitch
size="sm"
checked={parameters.exportChunksJsonl ?? false}
onChange={(checked) =>
onChange({ ...parameters, exportChunksJsonl: checked })
}
label={t("portal.pipelines.builder.docIntelligence.exportChunks")}
description={t(
"portal.pipelines.builder.docIntelligence.exportChunksHint",
)}
/>
<FormField
label={t("portal.pipelines.builder.docIntelligence.chunkSize")}
>
<Input
type="number"
inputSize="sm"
min={64}
max={32768}
value={parameters.chunkSize ?? ""}
onChange={(e) => setNumber("chunkSize", e.target.value)}
/>
</FormField>
<FormField label={t("portal.pipelines.builder.docIntelligence.overlap")}>
<Input
type="number"
inputSize="sm"
min={0}
max={4096}
value={parameters.overlap ?? ""}
onChange={(e) => setNumber("overlap", e.target.value)}
/>
</FormField>
<FormField label={t("portal.pipelines.builder.docIntelligence.mode")}>
<Select
value={parameters.mode ?? "auto"}
onChange={(value) =>
onChange({ ...parameters, mode: value ?? "auto" })
}
options={[
{
value: "auto",
label: t("portal.pipelines.builder.docIntelligence.modeAuto"),
},
{
value: "basic",
label: t("portal.pipelines.builder.docIntelligence.modeBasic"),
},
{
value: "advanced",
label: t("portal.pipelines.builder.docIntelligence.modeAdvanced"),
},
]}
/>
</FormField>
<p className="portal-pipelines__step-hint">
{t("portal.pipelines.builder.docIntelligence.ragIngestHint")}
</p>
</div>
);
}
@@ -11,7 +11,12 @@ import {
searchOperations,
type StepOperation,
} from "@portal/components/policies/stepOperations";
import {
DOC_INTELLIGENCE_STEPS,
type DocIntelligenceStep,
} from "@portal/components/pipelines/docIntelligenceSteps";
import { BrandMark } from "@portal/components/BrandMarks";
import { KnowledgeIcon } from "@portal/components/icons";
interface ToolPickerProps {
tools: ExecutableTool[];
@@ -24,6 +29,8 @@ interface ToolPickerProps {
*/
operations?: StepOperation[];
onPickOperation?: (operation: StepOperation) => void;
/** Document-intelligence policy steps (knowledge indexing). */
onPickDocStep?: (step: DocIntelligenceStep) => void;
}
/**
@@ -36,6 +43,7 @@ export function ToolPicker({
onClose,
operations = [],
onPickOperation,
onPickDocStep,
}: ToolPickerProps) {
const { t } = useTranslation();
const [query, setQuery] = useState("");
@@ -48,6 +56,14 @@ export function ToolPicker({
[operations, onPickOperation, query, t],
);
const matchedDocSteps = useMemo(() => {
if (!onPickDocStep) return [];
const q = query.trim().toLowerCase();
return DOC_INTELLIGENCE_STEPS.filter(
(step) => !q || t(step.labelKey).toLowerCase().includes(q),
);
}, [onPickDocStep, query, t]);
const groups = useMemo(() => {
const q = query.trim().toLowerCase();
const matched = q
@@ -84,7 +100,38 @@ export function ToolPicker({
/>
</div>
<div className="portal-pipelines__picker-list">
{groups.length === 0 && matchedOperations.length === 0 ? (
{matchedDocSteps.length > 0 && onPickDocStep ? (
<div className="portal-pipelines__picker-group">
<div className="portal-pipelines__picker-group-label">
{t("portal.pipelines.builder.docIntelligence.section")}
</div>
{matchedDocSteps.map((step) => (
<Button
key={step.operation}
variant="quiet"
justify="start"
fullWidth
className="portal-pipelines__picker-item"
onClick={() => onPickDocStep(step)}
leftSection={
<span
className="portal-pipelines__picker-icon"
aria-hidden="true"
>
<KnowledgeIcon size={17} />
</span>
}
>
<span className="portal-pipelines__picker-name">
{t(step.labelKey)}
</span>
</Button>
))}
</div>
) : null}
{groups.length === 0 &&
matchedOperations.length === 0 &&
matchedDocSteps.length === 0 ? (
<p className="portal-pipelines__picker-empty">
{t("portal.pipelines.builder.noToolMatches")}
</p>
@@ -0,0 +1,45 @@
import type { WorkingToolStep } from "@app/hooks/tools/shared/toolAutomation";
import type { ErasedToolParams } from "@app/hooks/tools/shared/toolOperationTypes";
/**
* First-class document-intelligence policy steps addable from the pipeline builder.
* They ride the unmapped-step path like integration steps (toolId null), but hit
* internal endpoints.
*/
export interface DocIntelligenceStep {
operation: string;
labelKey: string;
defaultParams: Record<string, unknown>;
}
export const RAG_INGEST_OPERATION = "/api/v1/docparse/rag-ingest";
export const DOC_INTELLIGENCE_STEPS: DocIntelligenceStep[] = [
{
operation: RAG_INGEST_OPERATION,
labelKey: "portal.pipelines.builder.docIntelligence.ragIngest",
defaultParams: {
chunkSize: 512,
overlap: 64,
mode: "auto",
index: true,
exportMarkdown: false,
exportChunksJsonl: false,
},
},
];
export function newDocIntelligenceStep(
step: DocIntelligenceStep,
): WorkingToolStep {
return {
toolId: null,
operation: step.operation,
params: { ...step.defaultParams } as ErasedToolParams,
support: "unknown",
};
}
export function isDocIntelligenceOperation(operation: string): boolean {
return DOC_INTELLIGENCE_STEPS.some((step) => step.operation === operation);
}
@@ -129,7 +129,9 @@ export function PolicyDetailPanel({
const outputLabel =
state.outputMode === "new_file"
? t("portal.policies.detail.outputAsNewFile")
: t("portal.policies.detail.outputAsNewVersion");
: state.outputMode === "discard"
? t("portal.policies.detail.outputDiscarded")
: t("portal.policies.detail.outputAsNewVersion");
function sourceLabel(id: string) {
if (id === "editor") return t("portal.sources.types.editor.label");
@@ -0,0 +1,116 @@
import { useTranslation } from "react-i18next";
import { FormField, Input, Select, ToggleSwitch } from "@app/ui";
/** Configures the rag-ingest step: destination toggles, chunk sizing, parse tier. */
export interface RagIngestParams {
chunkSize: string;
overlap: string;
mode: string;
index: string;
exportMarkdown: string;
exportChunksJsonl: string;
}
interface PolicyRagIngestConfigProps {
parameters: RagIngestParams;
onChange: (parameters: RagIngestParams) => void;
}
export function PolicyRagIngestConfig({
parameters,
onChange,
}: PolicyRagIngestConfigProps) {
const { t } = useTranslation();
const flag = (value: string | undefined, fallback: boolean): boolean =>
value === undefined ? fallback : value === "true";
return (
<div className="portal-policies__capability-config">
<ToggleSwitch
size="sm"
checked={flag(parameters.index, true)}
onChange={(checked) =>
onChange({ ...parameters, index: String(checked) })
}
label={t("portal.policies.config.ragIngest.fields.index")}
description={t("portal.policies.config.ragIngest.fields.indexHelp")}
/>
<ToggleSwitch
size="sm"
checked={flag(parameters.exportMarkdown, false)}
onChange={(checked) =>
onChange({ ...parameters, exportMarkdown: String(checked) })
}
label={t("portal.policies.config.ragIngest.fields.exportMarkdown")}
description={t(
"portal.policies.config.ragIngest.fields.exportMarkdownHelp",
)}
/>
<ToggleSwitch
size="sm"
checked={flag(parameters.exportChunksJsonl, false)}
onChange={(checked) =>
onChange({ ...parameters, exportChunksJsonl: String(checked) })
}
label={t("portal.policies.config.ragIngest.fields.exportChunks")}
description={t(
"portal.policies.config.ragIngest.fields.exportChunksHelp",
)}
/>
<FormField
label={t("portal.policies.config.ragIngest.fields.chunkSize")}
helperText={t("portal.policies.config.ragIngest.fields.chunkSizeHelp")}
>
<Input
type="number"
inputSize="sm"
min={64}
step={64}
value={parameters.chunkSize ?? ""}
onChange={(e) =>
onChange({ ...parameters, chunkSize: e.target.value })
}
/>
</FormField>
<FormField
label={t("portal.policies.config.ragIngest.fields.overlap")}
helperText={t("portal.policies.config.ragIngest.fields.overlapHelp")}
>
<Input
type="number"
inputSize="sm"
min={0}
step={16}
value={parameters.overlap ?? ""}
onChange={(e) => onChange({ ...parameters, overlap: e.target.value })}
/>
</FormField>
<FormField
label={t("portal.policies.config.ragIngest.fields.mode.label")}
helperText={t("portal.policies.config.ragIngest.fields.mode.help")}
>
<Select
inputSize="sm"
value={parameters.mode || "auto"}
onChange={(value) =>
onChange({ ...parameters, mode: value ?? "auto" })
}
options={[
{
value: "auto",
label: t("portal.policies.config.ragIngest.fields.mode.auto"),
},
{
value: "basic",
label: t("portal.policies.config.ragIngest.fields.mode.basic"),
},
{
value: "advanced",
label: t("portal.policies.config.ragIngest.fields.mode.advanced"),
},
]}
/>
</FormField>
</div>
);
}
@@ -40,6 +40,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 { PolicyRagIngestConfig } from "@portal/components/policies/PolicyRagIngestConfig";
import { ClassificationLabelsSection } from "@portal/components/policies/ClassificationLabelsSection";
import "@portal/views/Policies.css";
@@ -192,6 +193,14 @@ const CAPABILITY_META: Record<
descEn:
"Hands the document to a system you have connected, and records what it answered.",
},
ragIngest: {
labelKey: "portal.policies.wizard.capability.ragIngest.label",
labelEn: "Index into the knowledge base",
descKey: "portal.policies.wizard.capability.ragIngest.desc",
descEn:
"Parses, chunks, and embeds the document so it becomes searchable knowledge, or exports the" +
" parsed content (markdown, chunks JSONL) for your own systems.",
},
};
function seedTools(entry: CatalogueEntry): ToolState[] {
@@ -298,8 +307,10 @@ function PolicySetupWizardBody({
// Store username (which is the email in Spring Security) as reviewerEmail.
// See UserSelector.tsx in the editor for the grouping/display pattern.
const [reviewerEmail] = useState(policy?.state.reviewerEmail ?? "");
const [outputMode, setOutputMode] = useState<"new_file" | "new_version">(
policy?.state.outputMode ?? "new_version",
const [outputMode, setOutputMode] = useState<
"new_file" | "new_version" | "discard"
>(
policy?.state.outputMode ?? entry.config.defaultOutputMode ?? "new_version",
);
const [outputName, setOutputName] = useState(policy?.state.outputName ?? "");
const [outputNamePosition, setOutputNamePosition] = useState<
@@ -553,6 +564,14 @@ function PolicySetupWizardBody({
}
/>
)}
{tl.toolId === "ragIngest" && (
<PolicyRagIngestConfig
parameters={tl.params}
onChange={(params) =>
setToolParams("ragIngest", params)
}
/>
)}
</div>
)}
</div>
@@ -668,7 +687,8 @@ function PolicySetupWizardBody({
onChange={(value) => {
const mode = (value ?? "new_file") as
| "new_file"
| "new_version";
| "new_version"
| "discard";
setOutputMode(mode);
// Auto-number only applies to separate new files.
if (
@@ -691,61 +711,76 @@ function PolicySetupWizardBody({
"portal.policies.wizard.output.outputAs.newFile",
),
},
{
value: "discard",
label: t(
"portal.policies.wizard.output.outputAs.discard",
),
},
]}
/>
</FormField>
<FormField
label={t("portal.policies.wizard.output.filenameRule.label")}
>
<div className="portal-policies__name-row">
<Select
inputSize="sm"
value={outputNamePosition}
onChange={(value) =>
setOutputNamePosition(
(value ?? "suffix") as
| "prefix"
| "suffix"
| "auto-number",
)
}
options={[
{
value: "prefix",
label: t(
"portal.policies.wizard.output.filenameRule.prefix",
),
},
{
value: "suffix",
label: t(
"portal.policies.wizard.output.filenameRule.suffix",
),
},
...(outputMode === "new_file"
? [
{
value: "auto-number",
label: t(
"portal.policies.wizard.output.filenameRule.autoNumber",
),
},
]
: []),
]}
/>
{outputNamePosition !== "auto-number" && (
<Input
inputSize="sm"
value={outputName}
placeholder={t(
"portal.policies.wizard.output.filenameRule.placeholder",
)}
onChange={(e) => setOutputName(e.target.value)}
/>
{outputMode === "discard" && (
<p className="portal-policies__wizard-note">
{t("portal.policies.wizard.output.outputAs.discardHelp")}
</p>
)}
{outputMode !== "discard" && (
<FormField
label={t(
"portal.policies.wizard.output.filenameRule.label",
)}
</div>
</FormField>
>
<div className="portal-policies__name-row">
<Select
inputSize="sm"
value={outputNamePosition}
onChange={(value) =>
setOutputNamePosition(
(value ?? "suffix") as
| "prefix"
| "suffix"
| "auto-number",
)
}
options={[
{
value: "prefix",
label: t(
"portal.policies.wizard.output.filenameRule.prefix",
),
},
{
value: "suffix",
label: t(
"portal.policies.wizard.output.filenameRule.suffix",
),
},
...(outputMode === "new_file"
? [
{
value: "auto-number",
label: t(
"portal.policies.wizard.output.filenameRule.autoNumber",
),
},
]
: []),
]}
/>
{outputNamePosition !== "auto-number" && (
<Input
inputSize="sm"
value={outputName}
placeholder={t(
"portal.policies.wizard.output.filenameRule.placeholder",
)}
onChange={(e) => setOutputName(e.target.value)}
/>
)}
</div>
</FormField>
)}
</>
)}
{/* TODO: reviewer user-picker goes here */}
@@ -58,6 +58,12 @@ import { VIEW_PATHS, toPortalPath } from "@portal/contexts/ViewContext";
import { humanizeOperation } from "@portal/components/pipelines/pipelineOperations";
import { PipelineStepSettings } from "@portal/components/pipelines/PipelineStepSettings";
import { ToolPicker } from "@portal/components/pipelines/ToolPicker";
import {
DOC_INTELLIGENCE_STEPS,
isDocIntelligenceOperation,
newDocIntelligenceStep,
type DocIntelligenceStep,
} from "@portal/components/pipelines/docIntelligenceSteps";
import { STEP_OPERATIONS } from "@portal/components/policies/stepOperations";
import {
integrationStepConfigured,
@@ -261,6 +267,15 @@ export function PipelineBuilder() {
setPickerOpen(false);
}
function addDocIntelligenceStep(step: DocIntelligenceStep) {
setSteps((current) => {
const next = [...current, newDocIntelligenceStep(step)];
setSelectedIndex(next.length - 1);
return next;
});
setPickerOpen(false);
}
function addStep(tool: ExecutableTool) {
setSteps((current) => {
const next = [...current, newWorkingToolStep(tool, allTools)];
@@ -289,9 +304,12 @@ export function PipelineBuilder() {
function updateStepParams(index: number, params: ErasedToolParams) {
setSteps((current) =>
current.map((step, i) =>
// Integration steps are deliberately toolId-less, so they must be editable too; only a
// genuinely unrecognised step has no editor to send changes from.
i === index && (step.toolId !== null || isIntegrationStep(step))
// Integration and document-intelligence steps are deliberately toolId-less, so they
// must be editable too; only a genuinely unrecognised step has no editor.
i === index &&
(step.toolId !== null ||
isIntegrationStep(step) ||
isDocIntelligenceOperation(step.operation))
? { ...step, params }
: step,
),
@@ -303,6 +321,10 @@ export function PipelineBuilder() {
// "External api call" for all of them. Name it by the operation instead.
const op = stepOperation(step);
if (op) return t(op.labelKey);
const docStep = DOC_INTELLIGENCE_STEPS.find(
(s) => s.operation === step.operation,
);
if (docStep) return t(docStep.labelKey);
if (isIntegrationStep(step))
return t("portal.pipelines.builder.sendToSystem");
const entry = step.toolId ? allTools[step.toolId] : undefined;
@@ -803,7 +825,8 @@ export function PipelineBuilder() {
<span className="portal-builder__step-note">
{t("portal.pipelines.builder.usesDefaults")}
</span>
) : step.support === "unknown" ? (
) : step.support === "unknown" &&
!isDocIntelligenceOperation(step.operation) ? (
<span className="portal-builder__step-note">
{t("portal.pipelines.builder.unknownStep")}
</span>
@@ -852,6 +875,7 @@ export function PipelineBuilder() {
onPick={addStep}
operations={STEP_OPERATIONS}
onPickOperation={addOperationStep}
onPickDocStep={addDocIntelligenceStep}
onClose={() => setPickerOpen(false)}
/>
) : (
@@ -39,7 +39,12 @@ export function toWirePolicy(state: PolicyDecodedState): WirePolicy {
enabled: state.enabled,
trigger: null,
steps: state.steps,
output: { type: "inline", options },
// "discard" is a real sink type: side effects (indexing, exports to
// destinations) happen, but no processed file is stored or written back.
output: {
type: state.outputMode === "discard" ? "discard" : "inline",
options,
},
};
}
@@ -67,7 +72,12 @@ export function fromWirePolicy(policy: WirePolicy): PolicyDecodedState {
reviewerEmail: str(raw.reviewerEmail),
fieldValues: raw.fieldValues ?? {},
runOn: raw.runOn === "export" ? "export" : "upload",
outputMode: raw.mode === "new_file" ? "new_file" : "new_version",
outputMode:
raw.mode === "new_file"
? "new_file"
: raw.mode === "discard"
? "discard"
: "new_version",
outputName: str(raw.name),
outputNamePosition: position,
maxRetries: num(raw.maxRetries, DEFAULTS.maxRetries),
@@ -22,6 +22,7 @@ describe("POLICY_OPERATIONS", () => {
"ocr",
"purviewApplyLabel",
"purviewReadLabel",
"ragIngest",
"redact",
"sanitize",
"timestampPdf",
@@ -52,6 +53,41 @@ describe("POLICY_OPERATIONS", () => {
});
});
describe("rag ingest wire round-trip", () => {
test("sends positive integer sizes and real booleans, and round-trips them", () => {
const wire = policyStepToWire(
policyStep("ragIngest", {
chunkSize: "1024",
overlap: "32",
exportMarkdown: "true",
}),
);
expect(wire.operation).toBe("/api/v1/docparse/rag-ingest");
expect(wire.parameters).toEqual({
chunkSize: 1024,
overlap: 32,
mode: "auto",
index: true,
exportMarkdown: true,
exportChunksJsonl: false,
});
const back = policyStepFromWire(wire);
expect(back?.toolId).toBe("ragIngest");
if (back?.toolId === "ragIngest") {
expect(back.params.chunkSize).toBe("1024");
expect(back.params.exportMarkdown).toBe("true");
expect(back.params.index).toBe("true");
}
// Junk sizes fall back to defaults instead of sending garbage to the engine.
expect(
policyStepToWire(policyStep("ragIngest", { chunkSize: "junk" }))
.parameters.chunkSize,
).toBe(512);
});
});
describe("policyStep", () => {
test("merges partial params over the tool's defaults", () => {
const step = policyStep("redact", {
@@ -32,10 +32,18 @@ export type IntegrationPolicyEndpoint =
| "/api/v1/integration/purview-apply-label"
| "/api/v1/integration/purview-read-label";
/**
* Endpoints for DocParse pipeline steps. Excluded from the generated
* {@link ToolEndpoint} union: the DocParse controllers live in the proprietary
* module, outside the tool namespaces the generator reads.
*/
export type DocparsePolicyEndpoint = "/api/v1/docparse/rag-ingest";
/** An endpoint typed here rather than by the generator. */
export type UntypedPolicyEndpoint =
| AiPolicyEndpoint
| IntegrationPolicyEndpoint;
| IntegrationPolicyEndpoint
| DocparsePolicyEndpoint;
/** A tool usable in a policy whose endpoint isn't in the generated union. */
export interface AiToolDescriptor<TParams> {
@@ -89,6 +97,58 @@ function describeIntegrationOperation<TParams extends Record<string, string>>(
};
}
/**
* RAG ingest as a policy step: chunk + embed + index in one step, with optional
* corpus export (markdown, chunks JSONL) for external systems. Sizes and flags
* are flat strings in the params (like every step parameter); sizes cross the
* wire as positive integers and flags as booleans, falling back on junk input.
*/
function describeRagIngestOperation(): AiToolDescriptor<{
chunkSize: string;
overlap: string;
mode: string;
index: string;
exportMarkdown: string;
exportChunksJsonl: string;
}> {
const defaults = { chunkSize: 512, overlap: 64 };
const toInt = (raw: string, fallback: number): number => {
const parsed = Number(raw);
return Number.isFinite(parsed) && parsed > 0
? Math.round(parsed)
: fallback;
};
const toBool = (raw: unknown, fallback: boolean): boolean =>
raw === undefined || raw === null ? fallback : String(raw) === "true";
return {
endpoint: "/api/v1/docparse/rag-ingest",
defaultParameters: {
chunkSize: String(defaults.chunkSize),
overlap: String(defaults.overlap),
mode: "auto",
index: "true",
exportMarkdown: "false",
exportChunksJsonl: "false",
},
toApi: (params) => ({
chunkSize: toInt(params.chunkSize, defaults.chunkSize),
overlap: toInt(params.overlap, defaults.overlap),
mode: params.mode || "auto",
index: toBool(params.index, true),
exportMarkdown: toBool(params.exportMarkdown, false),
exportChunksJsonl: toBool(params.exportChunksJsonl, false),
}),
fromApi: (api) => ({
chunkSize: String(toInt(String(api.chunkSize ?? ""), defaults.chunkSize)),
overlap: String(toInt(String(api.overlap ?? ""), defaults.overlap)),
mode: api.mode == null ? "auto" : String(api.mode),
index: String(toBool(api.index, true)),
exportMarkdown: String(toBool(api.exportMarkdown, false)),
exportChunksJsonl: String(toBool(api.exportChunksJsonl, false)),
}),
};
}
export const POLICY_OPERATIONS = {
redact: describeToolOperation(
"/api/v1/security/auto-redact",
@@ -118,6 +178,7 @@ export const POLICY_OPERATIONS = {
compressOperationConfig,
),
classify: describeAiToolOperation("/api/v1/ai/tools/classify-and-label"),
ragIngest: describeRagIngestOperation(),
purviewApplyLabel: describeIntegrationOperation(
"/api/v1/integration/purview-apply-label",
{ connectionId: "", labelId: "", labelName: "", method: "STANDARD" },
@@ -19,7 +19,7 @@ export interface WirePipelineStep {
export interface WireOutputOptions {
runOn: "upload" | "export";
mode: "new_file" | "new_version";
mode: "new_file" | "new_version" | "discard";
name: string;
position: "prefix" | "suffix" | "auto-number";
maxRetries?: number;
@@ -32,7 +32,7 @@ export interface WireOutputOptions {
}
export interface WireOutputSpec {
type: "inline";
type: "inline" | "discard";
options: Partial<WireOutputOptions>;
}
@@ -84,7 +84,7 @@ export interface PolicyDecodedState {
reviewerEmail: string;
fieldValues: Record<string, boolean | string | string[]>;
runOn: "upload" | "export";
outputMode: "new_file" | "new_version";
outputMode: "new_file" | "new_version" | "discard";
outputName: string;
outputNamePosition: "prefix" | "suffix" | "auto-number";
maxRetries: number;