Merge branch 'main' into SaaS

This commit is contained in:
Anthony Stirling
2026-06-10 14:58:44 +01:00
committed by GitHub
93 changed files with 6398 additions and 2421 deletions
+15 -2
View File
@@ -1,5 +1,12 @@
version: '3'
vars:
# Engine-specific names to avoid overriding the root Taskfile's FIND_FREE_PORT_*
# vars (Task merges included-file vars into the global scope).
# Paths are relative to the engine/ include dir.
ENGINE_FIND_FREE_PORT_SH: "bash ../scripts/find-free-port.sh"
ENGINE_FIND_FREE_PORT_PS: "powershell -NoProfile -File ../scripts/find-free-port.ps1"
tasks:
install:
desc: "Install engine dependencies"
@@ -29,7 +36,10 @@ tasks:
ignore_error: true
dir: src
vars:
PORT: '{{.PORT | default "5001"}}'
# When PORT is provided (e.g. from dev:all), use it directly.
# When running standalone, probe for a free port starting at 5001.
PORT:
sh: '{{if .PORT}}echo {{.PORT}}{{else if eq OS "windows"}}{{.ENGINE_FIND_FREE_PORT_PS}} 5001{{else}}{{.ENGINE_FIND_FREE_PORT_SH}} 5001{{end}}'
env:
PYTHONUNBUFFERED: "1"
cmds:
@@ -41,7 +51,10 @@ tasks:
ignore_error: true
dir: src
vars:
PORT: '{{.PORT | default "5001"}}'
# When PORT is provided (e.g. from dev:all), use it directly.
# When running standalone, probe for a free port starting at 5001.
PORT:
sh: '{{if .PORT}}echo {{.PORT}}{{else if eq OS "windows"}}{{.ENGINE_FIND_FREE_PORT_PS}} 5001{{else}}{{.ENGINE_FIND_FREE_PORT_SH}} 5001{{end}}'
env:
PYTHONUNBUFFERED: "1"
cmds:
@@ -1,73 +0,0 @@
package stirling.software.SPDF.pdf.parser;
import static stirling.software.SPDF.pdf.parser.PdfModels.*;
import java.io.IOException;
import java.util.List;
import org.apache.pdfbox.pdmodel.PDDocument;
import org.springframework.context.annotation.Primary;
import org.springframework.stereotype.Service;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
/**
* Chains table parsers in priority order: Tabula lattice → Tabula stream → {@link
* LineAlignmentTableParser}. The first parser returning a result above {@link
* #TABULA_CONFIDENCE_THRESHOLD} wins; results from different parsers are never mixed on one page.
*/
@Service
@Primary
@RequiredArgsConstructor
@Slf4j
public class CompositeTableParser implements TableParser {
/** Min Tabula confidence to accept results; below this LineAlignment is tried instead. */
static final float TABULA_CONFIDENCE_THRESHOLD = 0.5f;
private final TabulaTableParser tabulaParser;
private final LineAlignmentTableParser lineAlignmentParser;
@Override
public List<TableFragment> parse(PDDocument document, RawPage rawPage) throws IOException {
// Step 1: Tabula lattice mode (ruled/bordered tables).
List<TableFragment> latticeResults = filterConfident(tabulaParser.parse(document, rawPage));
if (!latticeResults.isEmpty()) {
log.debug(
"Page {}: using Tabula lattice ({} table(s))",
rawPage.pageNumber(),
latticeResults.size());
return latticeResults;
}
// Step 2: Tabula stream mode (borderless/whitespace-delimited tables).
// parseStream is not on the TableParser interface — this intentionally couples to the
// concrete TabulaTableParser since stream mode is a Tabula-specific concept.
List<TableFragment> streamResults =
filterConfident(tabulaParser.parseStream(document, rawPage));
if (!streamResults.isEmpty()) {
log.debug(
"Page {}: using Tabula stream ({} table(s))",
rawPage.pageNumber(),
streamResults.size());
return streamResults;
}
// Step 3: Geometry-based line-alignment fallback.
List<TableFragment> lineResults = lineAlignmentParser.parse(document, rawPage);
if (!lineResults.isEmpty()) {
log.debug(
"Page {}: using LineAlignment ({} table(s))",
rawPage.pageNumber(),
lineResults.size());
return lineResults;
}
return List.of();
}
private List<TableFragment> filterConfident(List<TableFragment> tables) {
return tables.stream().filter(t -> t.confidence() >= TABULA_CONFIDENCE_THRESHOLD).toList();
}
}
@@ -1,528 +0,0 @@
package stirling.software.SPDF.pdf.parser;
import static stirling.software.SPDF.pdf.parser.PdfModels.*;
import java.io.IOException;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.Collections;
import java.util.Comparator;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.Optional;
import java.util.TreeMap;
import java.util.regex.Pattern;
import org.apache.pdfbox.pdmodel.PDDocument;
import org.springframework.stereotype.Service;
import lombok.extern.slf4j.Slf4j;
/**
* Fallback {@link TableParser} for borderless financial tables using text geometry.
*
* <p>Identifies "anchor lines" (≥2 numeric tokens), builds a column grid from their right-edge
* positions, groups vertically proximate anchor lines into table candidates, then scores each group
* on column consistency and anchor density (confidence ceiling 0.85).
*/
@Service
@Slf4j
public class LineAlignmentTableParser implements TableParser {
/** Width in points of each column position bucket. */
static final float COLUMN_BUCKET_PT = 5f;
/** Tolerance in buckets when matching a token's right-edge to a confirmed column position. */
private static final int COLUMN_MATCH_BUCKETS = 2;
/** Maximum gap (as a multiple of modal line spacing) before splitting a group. */
private static final float MAX_GAP_FACTOR = 2.5f;
/** Minimum anchor rows (numeric-heavy) to form a valid table. */
static final int MIN_TABLE_ROWS = 3;
/** Minimum confirmed column positions to form a valid table. */
static final int MIN_COLUMNS = 2;
/**
* Min fraction of anchor lines a column must appear on to be confirmed (permissive for N/A
* rows).
*/
private static final double COLUMN_MIN_FREQUENCY = 0.40;
/**
* Matches financial numeric tokens: integers, decimals, parenthetical negatives, currency,
* percent, nil dashes.
*/
private static final Pattern NUMERIC =
Pattern.compile("^[\\(\\-\\$£€¥]?\\d[\\d,\\.]*[\\)%]?$|^[-–—]$");
/**
* Lines within this y-distance are merged into one row (restores rows split by LineBuilder's
* column-gap logic).
*/
static final float ROW_MERGE_TOLERANCE_PT = 2f;
// ── public API ───────────────────────────────────────────────────────────────────────────────
@Override
public List<TableFragment> parse(PDDocument document, RawPage rawPage) throws IOException {
List<RawLine> lines = rawPage.lines();
if (lines.size() < MIN_TABLE_ROWS) return List.of();
float modalSpacing = computeModalSpacing(lines);
List<TokenizedLine> tokenized =
mergeCoincidentLines(lines.stream().map(this::tokenize).toList());
List<TokenizedLine> anchors = tokenized.stream().filter(TokenizedLine::isAnchor).toList();
if (anchors.size() < MIN_TABLE_ROWS) return List.of();
List<Float> columnGrid = buildColumnGrid(anchors);
if (columnGrid.size() < MIN_COLUMNS) {
log.debug(
"Page {}: LineAlignment — fewer than {} confirmed columns, skipping",
rawPage.pageNumber(),
MIN_COLUMNS);
return List.of();
}
List<List<TokenizedLine>> groups = groupRows(tokenized, columnGrid, modalSpacing);
List<TableFragment> results = new ArrayList<>();
for (int i = 0; i < groups.size(); i++) {
buildFragment(groups.get(i), columnGrid, rawPage.pageNumber(), i)
.ifPresent(results::add);
}
log.debug(
"Page {}: LineAlignment detected {} table(s) ({} anchor lines, {} columns)",
rawPage.pageNumber(),
results.size(),
anchors.size(),
columnGrid.size());
return results;
}
// ── coincident-line merging ──────────────────────────────────────────────────────────────────
/**
* Merges tokenised lines sharing the same y-position into one row, rejoining label/value halves
* split by LineBuilder.
*/
List<TokenizedLine> mergeCoincidentLines(List<TokenizedLine> tokenized) {
if (tokenized.size() < 2) return tokenized;
List<TokenizedLine> result = new ArrayList<>();
int i = 0;
while (i < tokenized.size()) {
float baseY = tokenized.get(i).line().bounds().y();
int j = i + 1;
while (j < tokenized.size()
&& Math.abs(tokenized.get(j).line().bounds().y() - baseY)
<= ROW_MERGE_TOLERANCE_PT) {
j++;
}
if (j == i + 1) {
result.add(tokenized.get(i));
} else {
result.add(mergeGroup(tokenized.subList(i, j)));
}
i = j;
}
return result;
}
private TokenizedLine mergeGroup(List<TokenizedLine> group) {
List<TextFragment> mergedFragments =
group.stream()
.flatMap(tl -> tl.line().fragments().stream())
.sorted(Comparator.comparingDouble(f -> f.bounds().x()))
.toList();
Bounds mergedBounds =
group.stream()
.map(tl -> tl.line().bounds())
.reduce(Bounds::merge)
.orElse(group.get(0).line().bounds());
RawLine mergedLine =
new RawLine(
group.get(0).line().lineId(),
mergedFragments,
mergedBounds,
group.get(0).line().pageNumber());
return tokenize(mergedLine);
}
// ── tokenisation ─────────────────────────────────────────────────────────────────────────────
/**
* Splits fragments into word-level tokens; x-positions are estimated linearly within each
* fragment.
*/
TokenizedLine tokenize(RawLine line) {
List<LineToken> tokens = new ArrayList<>();
for (TextFragment frag : line.fragments()) {
tokens.addAll(tokensFromFragment(frag));
}
List<LineToken> numeric = tokens.stream().filter(LineToken::numeric).toList();
return new TokenizedLine(line, tokens, numeric);
}
private List<LineToken> tokensFromFragment(TextFragment frag) {
String raw = frag.text();
if (raw == null || raw.isBlank()) return List.of();
float fragX = frag.bounds().x();
float fragWidth = frag.bounds().width();
int rawLen = raw.length();
List<LineToken> result = new ArrayList<>();
int offset = 0;
for (String part : raw.split("\\s+")) {
if (part.isEmpty()) {
offset++;
continue;
}
int idx = raw.indexOf(part, offset);
if (idx < 0) idx = offset;
float tokenX = rawLen > 0 ? fragX + ((float) idx / rawLen) * fragWidth : fragX;
float tokenRight =
rawLen > 0
? fragX + ((float) (idx + part.length()) / rawLen) * fragWidth
: fragX + fragWidth;
result.add(new LineToken(part, tokenX, tokenRight, NUMERIC.matcher(part).matches()));
offset = idx + part.length();
}
return result;
}
// ── column grid ──────────────────────────────────────────────────────────────────────────────
/**
* Returns confirmed column right-edge positions — those appearing on ≥ {@value
* #COLUMN_MIN_FREQUENCY} × N anchor lines.
*/
private List<Float> buildColumnGrid(List<TokenizedLine> anchors) {
// bucket → set of line indices that contributed a numeric token to that bucket
Map<Integer, List<Integer>> bucketLines = new HashMap<>();
for (int i = 0; i < anchors.size(); i++) {
for (LineToken t : anchors.get(i).numeric()) {
int bucket = bucket(t.right());
bucketLines.computeIfAbsent(bucket, k -> new ArrayList<>()).add(i);
}
}
int minHits =
Math.max(MIN_TABLE_ROWS, (int) Math.ceil(anchors.size() * COLUMN_MIN_FREQUENCY));
// Confirmed buckets → average right-edge for that bucket
TreeMap<Integer, Float> confirmed = new TreeMap<>();
for (Map.Entry<Integer, List<Integer>> entry : bucketLines.entrySet()) {
// Count distinct lines
long distinctLines = entry.getValue().stream().distinct().count();
if (distinctLines >= minHits) {
double avg =
entry.getValue().stream()
.distinct() // weight each line equally regardless of token count
.mapToDouble(
lineIdx ->
avgRightEdgeForBucket(
anchors, lineIdx, entry.getKey()))
.average()
.orElse(entry.getKey() * (double) COLUMN_BUCKET_PT);
confirmed.put(entry.getKey(), (float) avg);
}
}
return new ArrayList<>(confirmed.values()); // already sorted by bucket (left to right)
}
/**
* Returns the average right-edge position of tokens in {@code line} whose bucket matches {@code
* targetBucket}, falling back to the bucket's nominal centre when no tokens match.
*/
private double avgRightEdgeForBucket(
List<TokenizedLine> anchors, int lineIdx, int targetBucket) {
return anchors.get(lineIdx).numeric().stream()
.filter(t -> bucket(t.right()) == targetBucket)
.mapToDouble(LineToken::right)
.average()
.orElse(targetBucket * (double) COLUMN_BUCKET_PT);
}
// ── grouping ─────────────────────────────────────────────────────────────────────────────────
/**
* Groups anchor lines into table candidates, including adjacent label rows; a gap &gt;
* MAX_GAP_FACTOR × modal spacing splits groups.
*/
private List<List<TokenizedLine>> groupRows(
List<TokenizedLine> all, List<Float> columnGrid, float modalSpacing) {
float maxGap = modalSpacing > 0 ? modalSpacing * MAX_GAP_FACTOR : 30f;
List<List<TokenizedLine>> groups = new ArrayList<>();
List<TokenizedLine> current = new ArrayList<>();
for (int i = 0; i < all.size(); i++) {
TokenizedLine tl = all.get(i);
boolean fits = tl.isAnchor() && matchesGrid(tl, columnGrid);
if (current.isEmpty()) {
if (fits) current.add(tl);
continue;
}
float gap =
tl.line().bounds().y()
- current.get(current.size() - 1).line().bounds().bottom();
if (gap > maxGap) {
groups.add(current);
current = new ArrayList<>();
if (fits) current.add(tl);
continue;
}
if (fits) {
current.add(tl);
} else if (!tl.line().text().isBlank()) {
// Include non-anchor lines (labels) only if they have text and are within
// proximity.
current.add(tl);
}
}
if (!current.isEmpty()) groups.add(current);
return groups.stream().filter(g -> hasEnoughAnchorRows(g, columnGrid)).toList();
}
private boolean hasEnoughAnchorRows(List<TokenizedLine> group, List<Float> columnGrid) {
return group.stream().filter(r -> r.isAnchor() && matchesGrid(r, columnGrid)).count()
>= MIN_TABLE_ROWS;
}
/** A line "matches" the grid when ≥ 60 % of its numeric tokens land in confirmed columns. */
private boolean matchesGrid(TokenizedLine tl, List<Float> columnGrid) {
if (tl.numeric().isEmpty()) return false;
long matches =
tl.numeric().stream()
.filter(t -> nearestColumnIndex(t.right(), columnGrid) >= 0)
.count();
return (double) matches / tl.numeric().size() >= 0.60;
}
private boolean hasInconsistentColumnMatch(TokenizedLine tl, List<Float> columnGrid) {
if (tl.numeric().isEmpty()) return false;
long hits =
tl.numeric().stream()
.filter(t -> nearestColumnIndex(t.right(), columnGrid) >= 0)
.count();
return (double) hits / tl.numeric().size() < 0.60;
}
// ── fragment assembly ────────────────────────────────────────────────────────────────────────
private Optional<TableFragment> buildFragment(
List<TokenizedLine> group, List<Float> columnGrid, int pageNumber, int tableIndex) {
long anchorCount =
group.stream().filter(r -> r.isAnchor() && matchesGrid(r, columnGrid)).count();
if (anchorCount < MIN_TABLE_ROWS) return Optional.empty();
List<String> warnings = new ArrayList<>();
List<List<String>> rawRows = new ArrayList<>();
List<TableRow> rows = new ArrayList<>();
for (int rowIdx = 0; rowIdx < group.size(); rowIdx++) {
TokenizedLine tl = group.get(rowIdx);
List<String> rawRow = buildRawRow(tl, columnGrid);
rawRows.add(Collections.unmodifiableList(rawRow));
rows.add(buildTableRow(rowIdx, tl, rawRow, columnGrid));
}
// Column count = 1 label column + confirmed numeric columns
int colCount = columnGrid.size() + 1;
Bounds bounds = computeGroupBounds(group);
float confidence = computeConfidence(group, columnGrid, warnings);
return Optional.of(
new TableFragment(
"tbl-la-p" + pageNumber + "-" + tableIndex,
pageNumber,
bounds,
List.of(),
Collections.unmodifiableList(rows),
Collections.unmodifiableList(rawRows),
colCount,
confidence,
Collections.unmodifiableList(warnings),
null));
}
/**
* Builds a raw row as a list of strings: index 0 = label text, indices 1..N = column values.
*/
private List<String> buildRawRow(TokenizedLine tl, List<Float> columnGrid) {
String[] cells = new String[columnGrid.size() + 1];
Arrays.fill(cells, "");
// Separate label tokens (those not landing in any confirmed column) from column tokens.
List<String> labelParts = new ArrayList<>();
for (LineToken token : tl.all()) {
int col = nearestColumnIndex(token.right(), columnGrid);
if (col >= 0 && token.numeric()) {
int cellIdx = col + 1;
cells[cellIdx] =
cells[cellIdx].isEmpty()
? token.text()
: cells[cellIdx] + " " + token.text();
} else {
labelParts.add(token.text());
}
}
cells[0] = String.join(" ", labelParts).trim();
return Arrays.asList(cells);
}
private TableRow buildTableRow(
int rowIdx, TokenizedLine tl, List<String> rawRow, List<Float> columnGrid) {
List<TableCell> cells = new ArrayList<>(rawRow.size());
// Label cell: use the line's full bounds as an approximation.
cells.add(TableCell.of(0, rawRow.get(0), tl.line().bounds()));
for (int col = 0; col < columnGrid.size(); col++) {
String text = col + 1 < rawRow.size() ? rawRow.get(col + 1) : "";
float right = columnGrid.get(col);
float left = col > 0 ? columnGrid.get(col - 1) : right - 50f;
Bounds cellBounds =
new Bounds(
left,
tl.line().bounds().y(),
right - left,
tl.line().bounds().height());
cells.add(TableCell.of(col + 1, text, cellBounds));
}
return new TableRow(rowIdx, Collections.unmodifiableList(cells));
}
// ── confidence scoring ───────────────────────────────────────────────────────────────────────
/**
* Heuristic score in [0.0, 0.85] (ceiling keeps results below Tabula lattice which starts at
* 1.0). Base 0.70; +0.05/col beyond 2 (max +0.10); +0.05 at ≥5 anchors, +0.05 at ≥8; 0.15 if
* &gt;30 % of anchors have inconsistent columns; 0.10 if non-anchors outnumber anchors.
*/
private float computeConfidence(
List<TokenizedLine> group, List<Float> columnGrid, List<String> warnings) {
float score = 0.70f;
long anchorCount =
group.stream().filter(r -> r.isAnchor() && matchesGrid(r, columnGrid)).count();
long totalRows = group.size();
// More columns
int extraCols = Math.min(columnGrid.size() - MIN_COLUMNS, 2);
score += extraCols * 0.05f;
// More anchor rows
if (anchorCount >= 5) score += 0.05f;
if (anchorCount >= 8) score += 0.05f;
// Inconsistent column matching
long inconsistent =
group.stream()
.filter(TokenizedLine::isAnchor)
.filter(tl -> hasInconsistentColumnMatch(tl, columnGrid))
.count();
if (inconsistent > anchorCount * 0.30) {
score -= 0.15f;
warnings.add(
"Column match inconsistent on "
+ inconsistent
+ "/"
+ anchorCount
+ " anchor rows");
}
// Label-heavy
long nonAnchor = totalRows - anchorCount;
if (nonAnchor > anchorCount) {
score -= 0.10f;
warnings.add(
"Non-anchor rows ("
+ nonAnchor
+ ") outnumber anchor rows ("
+ anchorCount
+ ")");
}
return Math.max(0f, Math.min(0.85f, score));
}
// ── utility ──────────────────────────────────────────────────────────────────────────────────
/**
* Returns the grid index nearest to {@code rightEdge}, or -1 if none is within {@value
* #COLUMN_MATCH_BUCKETS} buckets.
*/
private int nearestColumnIndex(float rightEdge, List<Float> grid) {
int nearest = -1;
float minDist = COLUMN_MATCH_BUCKETS * COLUMN_BUCKET_PT + 1f;
for (int i = 0; i < grid.size(); i++) {
float dist = Math.abs(rightEdge - grid.get(i));
if (dist < minDist) {
minDist = dist;
nearest = i;
}
}
return nearest;
}
private Bounds computeGroupBounds(List<TokenizedLine> group) {
return group.stream()
.map(tl -> tl.line().bounds())
.reduce(Bounds::merge)
.orElse(new Bounds(0, 0, 0, 0));
}
/** Modal gap between consecutive line edges, used to calibrate the group-split threshold. */
private float computeModalSpacing(List<RawLine> lines) {
if (lines.size() < 2) return 0f;
Map<Float, Long> freq = new HashMap<>();
for (int i = 1; i < lines.size(); i++) {
float gap = lines.get(i).bounds().y() - lines.get(i - 1).bounds().bottom();
if (gap > 0) freq.merge(Math.round(gap / 2f) * 2f, 1L, Long::sum);
}
return freq.entrySet().stream()
.max(Map.Entry.comparingByValue())
.map(Map.Entry::getKey)
.orElse(0f);
}
private static int bucket(float x) {
return Math.round(x / COLUMN_BUCKET_PT);
}
// ── private data types ───────────────────────────────────────────────────────────────────────
/** A word-level token with an approximate right-edge x-position. */
record LineToken(String text, float x, float right, boolean numeric) {}
/** A {@link RawLine} with tokens pre-computed; an "anchor" has ≥ 2 numeric tokens. */
record TokenizedLine(RawLine line, List<LineToken> all, List<LineToken> numeric) {
boolean isAnchor() {
return numeric.size() >= 2;
}
}
}
@@ -1,139 +0,0 @@
package stirling.software.SPDF.pdf.parser;
import static stirling.software.SPDF.pdf.parser.PdfModels.*;
import java.util.ArrayList;
import java.util.Comparator;
import java.util.List;
import org.springframework.stereotype.Service;
import lombok.extern.slf4j.Slf4j;
/**
* Groups {@link TextFragment} objects into visual {@link RawLine}s using baseline proximity.
*
* <p>Fragments are on the same line when their baselines are within a font-size-derived tolerance.
* A new line starts whenever the horizontal gap exceeds an adaptive column-gap threshold ({@code
* max(effectiveWidth * COLUMN_GAP_RATIO, COLUMN_GAP_MIN_PT)}), splitting two-column text.
*/
@Service
@Slf4j
public class LineBuilder {
/** Baseline tolerance as a fraction of font size; 0.5 keeps mixed-size text on one line. */
private static final float BASELINE_TOLERANCE_FACTOR = 0.5f;
/** Absolute minimum tolerance so tiny font sizes don't collapse multi-line content. */
private static final float MIN_BASELINE_TOLERANCE = 2f;
/**
* Column-gap threshold as a fraction of page width; 0.10 clears tab stops but stays below
* two-column gutters.
*/
static final float COLUMN_GAP_RATIO = 0.10f;
/** Floor for the column-gap threshold so narrow pages don't over-split lines. */
static final float COLUMN_GAP_MIN_PT = 40f;
public List<RawLine> build(List<TextFragment> fragments, int pageNumber) {
if (fragments.isEmpty()) return List.of();
float effectiveWidth = inferEffectiveWidth(fragments);
float columnGapThreshold = Math.max(effectiveWidth * COLUMN_GAP_RATIO, COLUMN_GAP_MIN_PT);
log.debug(
"LineBuilder page {}: effectiveWidth={:.1f}pt, columnGapThreshold={:.1f}pt",
pageNumber,
effectiveWidth,
columnGapThreshold);
// Sort top-to-bottom first, then left-to-right within the same baseline band.
List<TextFragment> sorted =
fragments.stream()
.sorted(
Comparator.comparingDouble(TextFragment::baseline)
.thenComparingDouble(f -> f.bounds().x()))
.toList();
List<List<TextFragment>> groups = groupByBaseline(sorted, columnGapThreshold);
List<RawLine> lines = new ArrayList<>(groups.size());
for (int i = 0; i < groups.size(); i++) {
List<TextFragment> group =
groups.get(i).stream()
.sorted(Comparator.comparingDouble(f -> f.bounds().x()))
.toList();
Bounds lineBounds =
group.stream()
.map(TextFragment::bounds)
.reduce(Bounds::merge)
.orElse(new Bounds(0, 0, 0, 0));
lines.add(new RawLine("ln-p" + pageNumber + "-" + i, group, lineBounds, pageNumber));
}
return lines;
}
private List<List<TextFragment>> groupByBaseline(
List<TextFragment> sorted, float columnGapThreshold) {
List<List<TextFragment>> groups = new ArrayList<>();
List<TextFragment> current = new ArrayList<>();
float currentBaseline = Float.NaN;
for (TextFragment fragment : sorted) {
if (current.isEmpty()) {
current.add(fragment);
currentBaseline = fragment.baseline();
continue;
}
float maxFontSize =
Math.max(
fragment.fontSize(),
(float)
current.stream()
.mapToDouble(TextFragment::fontSize)
.max()
.orElse(0));
float tolerance =
Math.max(maxFontSize * BASELINE_TOLERANCE_FACTOR, MIN_BASELINE_TOLERANCE);
boolean sameBaseline = Math.abs(fragment.baseline() - currentBaseline) <= tolerance;
boolean columnGap = sameBaseline && hasColumnGap(fragment, current, columnGapThreshold);
if (sameBaseline && !columnGap) {
current.add(fragment);
// Anchor to the weighted mean baseline so long lines stay stable.
currentBaseline =
(currentBaseline * (current.size() - 1) + fragment.baseline())
/ current.size();
} else {
groups.add(current);
current = new ArrayList<>();
current.add(fragment);
currentBaseline = fragment.baseline();
}
}
if (!current.isEmpty()) groups.add(current);
return groups;
}
/**
* True when the gap from the rightmost fragment in {@code group} to {@code next} exceeds {@code
* threshold}.
*/
private static boolean hasColumnGap(
TextFragment next, List<TextFragment> group, float threshold) {
float lastRight = group.get(group.size() - 1).bounds().right();
return next.bounds().x() - lastRight > threshold;
}
/** Infers effective page width from the rightmost fragment right-edge plus a 10 % margin. */
private static float inferEffectiveWidth(List<TextFragment> fragments) {
double maxRight =
fragments.stream().mapToDouble(f -> f.bounds().right()).max().orElse(500.0);
return (float) maxRight * 1.10f;
}
}
@@ -1,79 +0,0 @@
package stirling.software.SPDF.pdf.parser;
import static stirling.software.SPDF.pdf.parser.PdfModels.*;
import java.io.IOException;
import java.util.ArrayList;
import java.util.List;
import org.apache.pdfbox.pdmodel.PDDocument;
import org.apache.pdfbox.pdmodel.PDPage;
import org.apache.pdfbox.pdmodel.common.PDRectangle;
import org.springframework.stereotype.Service;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
/**
* Runs the per-page ingestion pipeline: {@link WordExtractingStripper} → {@link LineBuilder} →
* {@link TableParser}, producing a {@link PdfModels.ParsedPage} per page. The caller owns the
* {@link PDDocument} lifecycle.
*/
@Service
@RequiredArgsConstructor
@Slf4j
public class PdfIngester {
private final LineBuilder lineBuilder;
private final TableParser tableParser;
public List<ParsedPage> parse(PDDocument document) throws IOException {
return parse(document, document.getNumberOfPages());
}
public List<ParsedPage> parse(PDDocument document, int maxPages) throws IOException {
int pageCount = Math.min(document.getNumberOfPages(), maxPages);
List<ParsedPage> pages = new ArrayList<>(pageCount);
long fragmentsMs = 0;
long tablesMs = 0;
long t0 = System.currentTimeMillis();
for (int p = 1; p <= pageCount; p++) {
long ft = System.currentTimeMillis();
List<TextFragment> fragments = extractFragments(document, p);
fragmentsMs += System.currentTimeMillis() - ft;
PDPage page = document.getPage(p - 1);
PDRectangle mediaBox = page.getMediaBox();
List<RawLine> lines = lineBuilder.build(fragments, p);
RawPage rawPage = new RawPage(p, mediaBox.getWidth(), mediaBox.getHeight(), lines);
long tt = System.currentTimeMillis();
List<TableFragment> tables = tableParser.parse(document, rawPage);
tablesMs += System.currentTimeMillis() - tt;
log.debug(
"Page {}: {} fragments → {} lines, {} table(s)",
p,
fragments.size(),
lines.size(),
tables.size());
pages.add(new ParsedPage(p, mediaBox.getWidth(), mediaBox.getHeight(), tables, lines));
}
log.info(
"[timing] parse pages={} total={}ms fragments={}ms tables={}ms",
pageCount,
System.currentTimeMillis() - t0,
fragmentsMs,
tablesMs);
return pages;
}
private List<TextFragment> extractFragments(PDDocument document, int pageNumber)
throws IOException {
WordExtractingStripper stripper = new WordExtractingStripper(pageNumber);
stripper.getText(document);
return stripper.getFragments();
}
}
@@ -1,113 +0,0 @@
package stirling.software.SPDF.pdf.parser;
import static stirling.software.SPDF.pdf.parser.PdfModels.*;
import java.io.IOException;
import java.util.ArrayList;
import java.util.Collections;
import java.util.List;
import org.apache.pdfbox.pdmodel.PDPage;
import org.apache.pdfbox.pdmodel.font.PDFont;
import org.apache.pdfbox.text.PDFTextStripper;
import org.apache.pdfbox.text.TextPosition;
/**
* Extends {@link PDFTextStripper} to capture per-fragment geometry and font metadata.
*
* <p>Overrides {@link #writeString} to split each content-stream string into word-level {@link
* TextFragment}s with bounding boxes, baseline, font name, and bold flag. Coordinates are in
* PDFTextStripper space: (0,0) top-left, Y increases downward, {@code getY()} is the baseline.
*/
class WordExtractingStripper extends PDFTextStripper {
private final int targetPage;
private final List<TextFragment> fragments = new ArrayList<>();
private int fragmentIndex = 0;
WordExtractingStripper(int pageNumber) throws IOException {
this.targetPage = pageNumber;
setStartPage(pageNumber);
setEndPage(pageNumber);
setSortByPosition(true);
}
@Override
protected void startPage(PDPage page) throws IOException {
super.startPage(page);
fragments.clear();
fragmentIndex = 0;
}
@Override
protected void writeString(String text, List<TextPosition> textPositions) throws IOException {
if (text == null || text.isBlank()) return;
// Fast path: no whitespace → emit one fragment (most financial PDFs have each
// number as its own string operation, so this is the common case).
if (text.indexOf(' ') < 0) {
emitFragment(text, textPositions);
return;
}
// Per-word splitting requires 1:1 text-char to TextPosition correspondence.
// Fall back to one fragment when sizes differ (ligatures, encoding edge cases).
if (textPositions.size() != text.length()) {
emitFragment(text, textPositions);
return;
}
// Emit one TextFragment per whitespace-delimited word with accurate per-word bounds.
int start = 0;
for (int i = 0; i <= text.length(); i++) {
if (i == text.length() || text.charAt(i) == ' ') {
if (start < i) {
emitFragment(text.substring(start, i), textPositions.subList(start, i));
}
start = i + 1;
}
}
}
private void emitFragment(String text, List<TextPosition> positions) {
if (positions.isEmpty()) return;
float minX = Float.MAX_VALUE;
float minY = Float.MAX_VALUE;
float maxRight = -Float.MAX_VALUE;
float maxBaseline = -Float.MAX_VALUE;
TextPosition first = null;
for (TextPosition tp : positions) {
if (tp == null) continue;
if (first == null) first = tp;
float x = tp.getX();
// getY() is the baseline; top of character = getY() - getHeight().
float top = tp.getY() - tp.getHeight();
float right = x + tp.getWidth();
float baseline = tp.getY();
minX = Math.min(minX, x);
minY = Math.min(minY, top);
maxRight = Math.max(maxRight, right);
maxBaseline = Math.max(maxBaseline, baseline);
}
if (first == null) return;
PDFont font = first.getFont();
String fontName = font != null ? font.getName() : "";
boolean bold = fontName != null && fontName.toLowerCase().contains("bold");
// getHeight() gives the rendered glyph height, which is the most reliable visual size.
float fontSize = first.getHeight();
Bounds bounds = new Bounds(minX, minY, maxRight - minX, maxBaseline - minY);
String id = "tf-p" + targetPage + "-" + fragmentIndex++;
fragments.add(new TextFragment(id, text, bounds, maxBaseline, fontSize, fontName, bold));
}
List<TextFragment> getFragments() {
return Collections.unmodifiableList(fragments);
}
}
@@ -80,6 +80,7 @@ public class ApplicationProperties {
private Mcp mcp = new Mcp();
private InternalApi internalApi = new InternalApi();
private Cluster cluster = new Cluster();
private Policies policies = new Policies();
@Bean
public PropertySource<?> dynamicYamlPropertySource(ConfigurableEnvironment environment)
@@ -203,6 +204,45 @@ public class ApplicationProperties {
}
}
@Data
public static class Policies {
/**
* Absolute directories that policy folder input sources and output sinks may read from or
* write to. Empty (the default) disables folder access entirely, so a policy can never be
* pointed at an arbitrary server path. Stirling's own config directory is always
* off-limits, and folder access is always disabled in SaaS mode regardless of this list.
*/
private List<String> allowedFolderRoots = new java.util.ArrayList<>();
/** How often (seconds) the schedule trigger checks for policies whose schedule is due. */
private long scheduleSweepSeconds = 60;
/**
* How often (seconds) the folder-watch trigger reconciles its watch registrations and
* re-runs every folder-watch policy as a safety net for filesystem events that were missed
* (NFS, bind mounts, inotify-queue overflow).
*/
private long watchReconcileSeconds = 300;
/**
* How long (milliseconds) the folder-watch trigger keeps draining filesystem events after
* the first, so a burst from a single file copy coalesces into one run instead of many.
*/
private long watchQuietPeriodMs = 500;
/**
* SSE emitter timeout (milliseconds) for streamed runs; generous for long multi-step runs.
*/
private long streamTimeoutMs = 1800000;
/**
* How long (minutes) a finished run's in-memory state is retained before eviction,
* mirroring the job-result expiry so rich run state does not outlive the process. Active
* and paused runs are kept regardless of age.
*/
private int runExpiryMinutes = 30;
}
@Data
public static class PdfEditor {
private Cache cache = new Cache();
@@ -0,0 +1,191 @@
package stirling.software.common.pdf;
import java.util.ArrayList;
import java.util.Collections;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import stirling.software.jpdfium.text.PageText;
import stirling.software.jpdfium.text.TextChar;
import stirling.software.jpdfium.text.TextLine;
import stirling.software.jpdfium.text.TextWord;
final class HeadingDetector {
private HeadingDetector() {}
/** A heading is at most this many words; longer lines are treated as body text. */
private static final int MAX_HEADING_WORDS = 12;
/**
* Returns the Markdown heading prefix for a line. The decision combines several signals, never
* text matching, so a plain line that merely shares text with a heading is never promoted:
*
* <ul>
* <li><b>Size</b> — dominant glyph font size vs. the document body median (primary signal).
* Some PDFs encode visual size in the text matrix, so every glyph reports ~1.0; for those
* the line height is used as the proxy instead.
* <li><b>Brevity</b> — headings are short labels; a line over {@value #MAX_HEADING_WORDS}
* words is body text regardless of size.
* <li><b>Not a sentence</b> — a line ending in {@code . ! ?} reads as prose, not a heading.
* </ul>
*
* <p>Boldness is deliberately <em>not</em> a heading signal — a bold-but-not-larger line is
* emphasis, not a heading (see {@link #isBoldLabel}); promoting it to {@code #}/{@code ##} is
* the main source of false-positive headings.
*
* <ul>
* <li>size &gt; baseline * 1.4 → {@code "# "}
* <li>size &gt; baseline * 1.2 → {@code "## "}
* <li>otherwise → {@code ""}
* </ul>
*/
static String headingPrefix(TextLine line, float medianBodySize, float medianBodyHeight) {
String text = line.text().strip();
if (text.isEmpty() || wordCount(text) > MAX_HEADING_WORDS || endsLikeSentence(text)) {
return "";
}
float dominant = dominantFontSize(line);
float value;
float baseline;
if (dominant > 2f && medianBodySize > 2f) {
value = dominant;
baseline = medianBodySize;
} else {
value = line.height();
baseline = medianBodyHeight;
}
if (baseline <= 0f) {
return "";
}
float ratio = value / baseline;
if (ratio > 1.4f) {
return "# ";
}
if (ratio > 1.2f) {
return "## ";
}
return "";
}
/**
* True when a line should be emphasised as bold (rendered {@code **like this**}) rather than
* promoted to a heading: it is bold, short, and not a full sentence. Used for bold labels that
* are not large enough to be headings.
*/
static boolean isBoldLabel(TextLine line) {
String text = line.text().strip();
if (text.isEmpty() || wordCount(text) > MAX_HEADING_WORDS || endsLikeSentence(text)) {
return false;
}
return isBold(line);
}
private static int wordCount(String text) {
return text.split("\\s+").length;
}
private static boolean endsLikeSentence(String text) {
char last = text.charAt(text.length() - 1);
return last == '.' || last == '!' || last == '?';
}
/** True when the line's dominant font is bold, inferred from PostScript font names. */
private static boolean isBold(TextLine line) {
Map<String, Integer> counts = new HashMap<>();
for (TextWord word : line.words()) {
for (TextChar ch : word.chars()) {
if (ch.isWhitespace() || ch.isNewline()) {
continue;
}
String name = ch.fontName();
if (name != null && !name.isBlank()) {
counts.merge(name, 1, Integer::sum);
}
}
}
String dominantFont = "";
int max = -1;
for (Map.Entry<String, Integer> e : counts.entrySet()) {
if (e.getValue() > max) {
max = e.getValue();
dominantFont = e.getKey();
}
}
String lower = dominantFont.toLowerCase(java.util.Locale.ROOT);
return lower.contains("bold")
|| lower.contains("black")
|| lower.contains("heavy")
|| lower.contains("semibold");
}
/** Computes the median glyph font size across all pages. */
static float medianFontSize(List<PageText> allPages) {
List<Float> sizes = new ArrayList<>();
for (PageText page : allPages) {
for (TextChar ch : page.chars()) {
if (!ch.isWhitespace() && !ch.isNewline() && ch.fontSize() > 0f) {
sizes.add(ch.fontSize());
}
}
}
return median(sizes, 12f);
}
/** Computes the median TextLine height across all pages. Used when font size is degenerate. */
static float medianLineHeight(List<PageText> allPages) {
List<Float> heights = new ArrayList<>();
for (PageText page : allPages) {
for (TextLine line : page.lines()) {
if (line.height() > 0f && !line.text().isBlank()) {
heights.add(line.height());
}
}
}
return median(heights, 12f);
}
private static float median(List<Float> values, float fallback) {
if (values.isEmpty()) {
return fallback;
}
Collections.sort(values);
int mid = values.size() / 2;
if (values.size() % 2 == 0) {
return (values.get(mid - 1) + values.get(mid)) / 2f;
}
return values.get(mid);
}
/**
* Returns the font size that appears most often (by character count) in the given line. Ties
* are broken in favour of the larger size.
*/
private static float dominantFontSize(TextLine line) {
Map<Float, Integer> counts = new HashMap<>();
for (TextWord word : line.words()) {
for (TextChar ch : word.chars()) {
if (!ch.isWhitespace() && !ch.isNewline() && ch.fontSize() > 0f) {
counts.merge(ch.fontSize(), 1, Integer::sum);
}
}
}
if (counts.isEmpty()) {
return 0f;
}
float dominant = 0f;
int maxCount = -1;
for (Map.Entry<Float, Integer> entry : counts.entrySet()) {
int count = entry.getValue();
float size = entry.getKey();
if (count > maxCount || (count == maxCount && size > dominant)) {
maxCount = count;
dominant = size;
}
}
return dominant;
}
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,82 @@
package stirling.software.common.pdf;
import stirling.software.jpdfium.text.Table;
final class TableRenderer {
private TableRenderer() {}
/** Renders a Table as a GitHub-Flavoured Markdown table string. */
static String render(Table table) {
if (table.rowCount() == 0) {
return "";
}
String[][] grid = table.asGrid();
if (table.rowCount() < 2) {
// No separator row possible — return plain lines
StringBuilder sb = new StringBuilder();
for (int c = 0; c < grid[0].length; c++) {
if (c > 0) sb.append('\n');
sb.append(escape(grid[0][c].trim()));
}
return sb.toString();
}
int cols = grid[0].length;
// Compute column widths: max(3, max content length across all rows)
int[] widths = new int[cols];
for (int c = 0; c < cols; c++) {
widths[c] = 3;
}
for (String[] row : grid) {
for (int c = 0; c < cols; c++) {
String cell = c < row.length ? row[c].trim() : "";
widths[c] = Math.max(widths[c], escape(cell).length());
}
}
StringBuilder sb = new StringBuilder();
// Header row
sb.append(buildRow(grid[0], widths, cols));
sb.append('\n');
// Separator row
sb.append('|');
for (int c = 0; c < cols; c++) {
sb.append('-').append("-".repeat(widths[c])).append('-').append('|');
}
sb.append('\n');
// Data rows
for (int r = 1; r < grid.length; r++) {
sb.append(buildRow(grid[r], widths, cols));
if (r < grid.length - 1) {
sb.append('\n');
}
}
return sb.toString();
}
private static String buildRow(String[] row, int[] widths, int cols) {
StringBuilder sb = new StringBuilder();
sb.append('|');
for (int c = 0; c < cols; c++) {
String cell = c < row.length ? escape(row[c].trim()) : "";
sb.append(' ').append(padRight(cell, widths[c])).append(' ').append('|');
}
return sb.toString();
}
private static String escape(String cell) {
return cell.replace("|", "\\|");
}
private static String padRight(String s, int width) {
if (s.length() >= width) return s;
return s + " ".repeat(width - s.length());
}
}
@@ -1,153 +0,0 @@
package stirling.software.SPDF.pdf.parser;
import static org.assertj.core.api.Assertions.assertThat;
import static stirling.software.SPDF.pdf.parser.PdfModels.*;
import java.util.List;
import org.junit.jupiter.api.Test;
/**
* Unit tests for {@link LineAlignmentTableParser}, focused on the coincident-line merge logic and
* column-grid construction.
*/
class LineAlignmentTableParserTest {
private final LineAlignmentTableParser parser = new LineAlignmentTableParser();
// ── mergeCoincidentLines ─────────────────────────────────────────────────────────────────────
@Test
void mergeCoincidentLines_singleLine_unchanged() {
var lines = List.of(tokenized(rawLine(10f, 100f, "Revenue")));
assertThat(parser.mergeCoincidentLines(lines)).hasSize(1);
}
@Test
void mergeCoincidentLines_distinctYLines_unchanged() {
// Two lines at different y positions — must NOT be merged.
var lines =
List.of(
tokenized(rawLine(10f, 100f, "Revenue")),
tokenized(rawLine(10f, 115f, "Cost")));
assertThat(parser.mergeCoincidentLines(lines)).hasSize(2);
}
@Test
void mergeCoincidentLines_sameY_merged() {
// Simulates a financial-table row split by LineBuilder at the column gap:
// label fragment at x=72 → "Revenue"
// value fragment at x=350 → "1,234"
// Both have y=100. After merge they should form one TokenizedLine.
var label = rawLine(72f, 100f, "Revenue");
var value = rawLine(350f, 100f, "1,234");
var merged = parser.mergeCoincidentLines(List.of(tokenized(label), tokenized(value)));
assertThat(merged).hasSize(1);
// The merged line should contain tokens from both halves.
var tokens = merged.get(0).all();
assertThat(tokens.stream().map(t -> t.text()).toList())
.containsExactlyInAnyOrder("Revenue", "1,234");
}
@Test
void mergeCoincidentLines_sameY_mergedLineHasCorrectBounds() {
var label = rawLine(72f, 100f, "Revenue"); // 7 chars × 6pt = 42pt wide → right = 114
var value = rawLine(350f, 100f, "1,234"); // 5 chars × 6pt = 30pt wide → right = 380
var merged = parser.mergeCoincidentLines(List.of(tokenized(label), tokenized(value)));
var bounds = merged.get(0).line().bounds();
assertThat(bounds.x()).isEqualTo(72f);
assertThat(bounds.right()).isEqualTo(380f);
}
@Test
void mergeCoincidentLines_withinTolerance_merged() {
// Lines 1.5pt apart (within ROW_MERGE_TOLERANCE_PT = 2pt) should merge.
var a = rawLine(10f, 100.0f, "Alpha");
var b = rawLine(200f, 101.5f, "99");
var merged = parser.mergeCoincidentLines(List.of(tokenized(a), tokenized(b)));
assertThat(merged).hasSize(1);
}
@Test
void mergeCoincidentLines_beyondTolerance_notMerged() {
// Lines 3pt apart (beyond ROW_MERGE_TOLERANCE_PT = 2pt) should NOT merge.
var a = rawLine(10f, 100.0f, "Alpha");
var b = rawLine(200f, 103.0f, "99");
var merged = parser.mergeCoincidentLines(List.of(tokenized(a), tokenized(b)));
assertThat(merged).hasSize(2);
}
@Test
void mergeCoincidentLines_threeCoincident_allMerged() {
// Three fragments at the same y (e.g. wide financial table with two value columns).
var a = rawLine(72f, 100f, "Revenue");
var b = rawLine(300f, 100f, "1,234");
var c = rawLine(400f, 100f, "5,678");
var merged = parser.mergeCoincidentLines(List.of(tokenized(a), tokenized(b), tokenized(c)));
assertThat(merged).hasSize(1);
assertThat(merged.get(0).all()).hasSize(3);
}
@Test
void mergeCoincidentLines_coincidentPairFollowedByDistinctLine_twoGroups() {
var a = rawLine(72f, 100f, "Revenue");
var b = rawLine(350f, 100f, "1,234"); // same y as a → merges with a
var c = rawLine(10f, 115f, "Expenses"); // different y → stays separate
var merged = parser.mergeCoincidentLines(List.of(tokenized(a), tokenized(b), tokenized(c)));
assertThat(merged).hasSize(2);
}
@Test
void mergeCoincidentLines_numericAnchorStatus_correctAfterMerge() {
// After merging, the combined line should be an anchor (≥2 numeric tokens).
// "Revenue" alone → not an anchor. "1,234 567" alone → anchor.
// Merged → anchor with at least 2 numerics.
var label = rawLine(72f, 100f, "Revenue");
var values = rawLineMultiWord(350f, 100f, "1,234", 30f, "567", 30f);
var merged = parser.mergeCoincidentLines(List.of(tokenized(label), tokenized(values)));
assertThat(merged).hasSize(1);
assertThat(merged.get(0).isAnchor()).isTrue();
}
// ── helpers ──────────────────────────────────────────────────────────────────────────────────
/** Creates a RawLine with a single TextFragment of the given text at the given position. */
private static RawLine rawLine(float x, float y, String text) {
float width = text.length() * 6f; // ~6pt per char — rough but consistent
float height = 12f;
Bounds bounds = new Bounds(x, y, width, height);
TextFragment fragment =
new TextFragment("tf-test", text, bounds, y + height, 11f, "Helvetica", false);
return new RawLine("ln-test", List.of(fragment), bounds, 1);
}
/**
* Creates a RawLine with two TextFragments representing two words separated by a small gap.
* Used to simulate a values-only line with multiple numeric tokens.
*/
private static RawLine rawLineMultiWord(
float x, float y, String word1, float w1, String word2, float w2) {
float height = 12f;
Bounds b1 = new Bounds(x, y, w1, height);
Bounds b2 = new Bounds(x + w1 + 5f, y, w2, height);
TextFragment f1 = new TextFragment("tf-1", word1, b1, y + height, 11f, "Helvetica", false);
TextFragment f2 = new TextFragment("tf-2", word2, b2, y + height, 11f, "Helvetica", false);
Bounds lineBounds = new Bounds(x, y, x + w1 + 5f + w2 - x, height);
return new RawLine("ln-test", List.of(f1, f2), lineBounds, 1);
}
/** Tokenises a RawLine via the parser's own tokenise logic (package-private access). */
private LineAlignmentTableParser.TokenizedLine tokenized(RawLine line) {
return parser.tokenize(line);
}
}
@@ -0,0 +1,269 @@
package stirling.software.common.pdf;
import static org.junit.jupiter.api.Assertions.assertDoesNotThrow;
import static org.junit.jupiter.api.Assertions.assertTrue;
import static org.junit.jupiter.api.Assertions.fail;
import java.io.IOException;
import java.io.InputStream;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.ArrayList;
import java.util.List;
import java.util.stream.Stream;
import org.junit.jupiter.api.Disabled;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.io.TempDir;
import org.junit.jupiter.params.ParameterizedTest;
import org.junit.jupiter.params.provider.Arguments;
import org.junit.jupiter.params.provider.MethodSource;
import stirling.software.jpdfium.PdfDocument;
import stirling.software.jpdfium.text.TextLine;
import stirling.software.jpdfium.text.TextWord;
/**
* Accuracy and robustness tests for {@link PdfMarkdownConverter}, comparing conversion output
* against hand-authored golden Markdown for a set of owned/synthetic fixtures.
*
* <p>The {@link #gatedFixtures()} set is enforced in CI: those fixtures currently convert within
* the accuracy threshold and guard against regressions. Fixtures still being iterated on live in
* {@link #wipFixtures()} under a {@link Disabled} test so the goldens stay in the tree without
* breaking the build. Enable the WIP test locally to see per-fixture scores while working on the
* converter.
*/
class PdfMarkdownConverterTest {
/** Accuracy threshold: output must share at least this fraction of content with the golden. */
private static final double THRESHOLD = 0.95;
@TempDir Path tmp;
/** Fixtures that meet the accuracy threshold today and therefore gate CI. */
static Stream<Arguments> gatedFixtures() {
return Stream.of(
Arguments.of("multi-column-test_lorem.pdf", "multi-column-test_lorem.md"),
Arguments.of("bordered-table-test_widget.pdf", "bordered-table-test_widget.md"),
Arguments.of("many-tables-test_stress.pdf", "many-tables-test_stress.md"));
}
/** Fixtures still below the threshold; tracked here, enable locally to iterate. */
static Stream<Arguments> wipFixtures() {
return Stream.of(
Arguments.of(
"wrapped-cell-test_expense-report.pdf",
"wrapped-cell-test_expense-report.md"));
}
@ParameterizedTest(name = "{0}")
@MethodSource("gatedFixtures")
void convertMatchesGoldenMarkdown(String pdfName, String mdName) throws IOException {
assertConversionMatchesGolden(pdfName, mdName);
}
@Disabled("WIP fixtures below the accuracy threshold; enable locally to iterate")
@ParameterizedTest(name = "{0}")
@MethodSource("wipFixtures")
void convertMatchesGoldenMarkdownWip(String pdfName, String mdName) throws IOException {
assertConversionMatchesGolden(pdfName, mdName);
}
/**
* Degenerate/extreme geometry must not crash the converter. A crafted or malformed PDF can
* position text anywhere via a text matrix, so a row's words can span from near the origin to a
* coordinate beyond {@link Integer#MAX_VALUE}. The old column-detection code sized an {@code
* int[]} straight from {@code (int) Math.ceil(maxX) - lo}, which either allocated a multi-GB
* array (OutOfMemoryError) or overflowed to a negative length (NegativeArraySizeException) —
* taking down the request thread. Detection must instead bail out and return no columns.
*/
@Test
void columnDetectionSurvivesDegenerateGeometry() {
// x ≈ 2.5e9 is past Integer.MAX_VALUE; combined with a near-origin word it yields an
// implausible span that the pre-fix code turned into a fatal array allocation.
List<TextLine> rows = new ArrayList<>();
for (int r = 0; r < 4; r++) {
float y = 400f - r * 12f;
TextWord near = new TextWord(List.of(), 50f, y, 30f, 10f);
TextWord far = new TextWord(List.of(), 2_500_000_000f, y, 30f, 10f);
rows.add(new TextLine(List.of(near, far), 50f, y, 2_499_999_980f, 10f));
}
List<float[]> columns =
assertDoesNotThrow(() -> PdfMarkdownConverter.findColumnRangesFromLines(rows));
assertTrue(
columns.isEmpty(),
"implausible page span should disable column detection, not allocate from it");
}
private void assertConversionMatchesGolden(String pdfName, String mdName) throws IOException {
Path pdfPath = tmp.resolve(pdfName);
try (InputStream in =
getClass().getResourceAsStream("/pdf-ingestion-fixtures/" + pdfName)) {
if (in == null) {
fail("Fixture not found on classpath: /pdf-ingestion-fixtures/" + pdfName);
}
Files.copy(in, pdfPath);
}
String actual;
try (PdfDocument doc = PdfDocument.open(pdfPath)) {
actual = new PdfMarkdownConverter().convert(doc);
}
String expected;
try (InputStream in = getClass().getResourceAsStream("/pdf-ingestion-fixtures/" + mdName)) {
if (in == null) {
fail("Golden file not found on classpath: /pdf-ingestion-fixtures/" + mdName);
}
expected = new String(in.readAllBytes(), StandardCharsets.UTF_8);
}
// Image placeholders are not scored: their body text is a TODO ("ideally, add the info
// available about the image...") rather than real content, so comparing it would penalise
// output for matching a placeholder we intend to replace. Drop those lines from both sides.
expected = stripImagePlaceholders(expected);
actual = stripImagePlaceholders(actual);
double similarity = similarity(expected, actual);
if (similarity < THRESHOLD) {
fail(
String.format(
"Markdown output differs from golden file '%s' by %.1f%% (threshold %.0f%%):%n%s",
mdName,
(1.0 - similarity) * 100,
(1.0 - THRESHOLD) * 100,
unifiedDiff(expected, actual)));
}
}
/** Substring identifying an image-placeholder line, which is excluded from scoring. */
private static final String IMAGE_PLACEHOLDER_MARKER = "Image intentionally redacted";
/**
* Removes non-content lines from the comparison: image placeholders (TODO text we intend to
* replace) and GFM table separator rows (the {@code |---|---|} divider, whose exact dash count
* is cosmetic — any run of three or more dashes is valid Markdown).
*/
private static String stripImagePlaceholders(String md) {
StringBuilder sb = new StringBuilder();
for (String line : md.split("\n", -1)) {
if (line.contains(IMAGE_PLACEHOLDER_MARKER)
|| line.strip().startsWith("<image redacted")
|| isTableSeparatorRow(line)) {
continue;
}
if (sb.length() > 0) {
sb.append('\n');
}
sb.append(line);
}
return sb.toString();
}
/** True for a GFM table separator row, e.g. {@code |---|:--:|---|} (only |, -, :, space). */
private static boolean isTableSeparatorRow(String line) {
String t = line.strip();
if (!t.contains("-")) {
return false;
}
return t.chars().allMatch(c -> c == '|' || c == '-' || c == ':' || c == ' ');
}
/**
* Character-level similarity: proportion of expected characters that appear in the LCS. O(n*m)
* but golden files are small enough that this is fine.
*/
private static double similarity(String expected, String actual) {
if (expected.isEmpty() && actual.isEmpty()) return 1.0;
if (expected.isEmpty() || actual.isEmpty()) return 0.0;
// Strip all whitespace for a content-focused comparison
String e = expected.replaceAll("\\s+", " ").strip();
String a = actual.replaceAll("\\s+", " ").strip();
int lcs = lcsLength(e, a);
return (double) lcs / Math.max(e.length(), a.length());
}
private static int lcsLength(String a, String b) {
// Use two-row DP to keep memory reasonable
int m = a.length(), n = b.length();
int[] prev = new int[n + 1];
int[] curr = new int[n + 1];
for (int i = 1; i <= m; i++) {
for (int j = 1; j <= n; j++) {
if (a.charAt(i - 1) == b.charAt(j - 1)) {
curr[j] = prev[j - 1] + 1;
} else {
curr[j] = Math.max(curr[j - 1], prev[j]);
}
}
int[] tmp = prev;
prev = curr;
curr = tmp;
java.util.Arrays.fill(curr, 0);
}
return prev[n];
}
private static String unifiedDiff(String expected, String actual) {
String[] expectedLines = expected.split("\n", -1);
String[] actualLines = actual.split("\n", -1);
List<String> diff = new ArrayList<>();
diff.add("--- expected");
diff.add("+++ actual");
int maxLines = Math.max(expectedLines.length, actualLines.length);
int context = 3;
boolean inHunk = false;
int hunkStart = -1;
List<String> hunkLines = new ArrayList<>();
for (int i = 0; i < maxLines; i++) {
String exp = i < expectedLines.length ? expectedLines[i] : null;
String act = i < actualLines.length ? actualLines[i] : null;
boolean changed = exp == null || act == null || !exp.equals(act);
if (changed) {
if (!inHunk) {
inHunk = true;
hunkStart = Math.max(0, i - context);
// add context lines before change
for (int c = hunkStart; c < i; c++) {
hunkLines.add(" " + (c < expectedLines.length ? expectedLines[c] : ""));
}
}
if (exp != null) hunkLines.add("-" + exp);
if (act != null) hunkLines.add("+" + act);
} else {
if (inHunk) {
hunkLines.add(" " + exp);
// check if we're far enough past the last change to close the hunk
boolean moreChanges = false;
for (int j = i + 1; j < Math.min(i + context, maxLines); j++) {
String e2 = j < expectedLines.length ? expectedLines[j] : null;
String a2 = j < actualLines.length ? actualLines[j] : null;
if (e2 == null || a2 == null || !e2.equals(a2)) {
moreChanges = true;
break;
}
}
if (!moreChanges && (i - hunkStart) >= context) {
diff.add("@@ -" + (hunkStart + 1) + " @@");
diff.addAll(hunkLines);
hunkLines.clear();
inHunk = false;
}
}
}
}
if (inHunk && !hunkLines.isEmpty()) {
diff.add("@@ -" + (hunkStart + 1) + " @@");
diff.addAll(hunkLines);
}
return String.join("\n", diff);
}
}
@@ -0,0 +1,10 @@
# Widget Inventory Report
This report lists current stock levels for each warehouse.
| Region | Units | Status |
|---|---|---|
| North | 1200 | OK |
| South | 950 | Low |
| East | 1430 | OK |
| West | 875 | Low |
@@ -0,0 +1,74 @@
%PDF-1.4
%“Œ‹ž ReportLab Generated PDF document (opensource)
1 0 obj
<<
/F1 2 0 R /F2 3 0 R
>>
endobj
2 0 obj
<<
/BaseFont /Helvetica /Encoding /WinAnsiEncoding /Name /F1 /Subtype /Type1 /Type /Font
>>
endobj
3 0 obj
<<
/BaseFont /Helvetica-Bold /Encoding /WinAnsiEncoding /Name /F2 /Subtype /Type1 /Type /Font
>>
endobj
4 0 obj
<<
/Contents 8 0 R /MediaBox [ 0 0 612 792 ] /Parent 7 0 R /Resources <<
/Font 1 0 R /ProcSet [ /PDF /Text /ImageB /ImageC /ImageI ]
>> /Rotate 0 /Trans <<
>>
/Type /Page
>>
endobj
5 0 obj
<<
/PageMode /UseNone /Pages 7 0 R /Type /Catalog
>>
endobj
6 0 obj
<<
/Author (\(anonymous\)) /CreationDate (D:20260603003133+01'00') /Creator (\(unspecified\)) /Keywords () /ModDate (D:20260603003133+01'00') /Producer (ReportLab PDF Library - \(opensource\))
/Subject (\(unspecified\)) /Title (\(anonymous\)) /Trapped /False
>>
endobj
7 0 obj
<<
/Count 1 /Kids [ 4 0 R ] /Type /Pages
>>
endobj
8 0 obj
<<
/Filter [ /ASCII85Decode /FlateDecode ] /Length 500
>>
stream
Gas1[9i&Y\%))C:pc(6t3;pQ8%0T@tD+-Nf,MP8j(><jDruNO^VsZ'm=ue7^Ia=;l(`h-+jUU3'Knh#YjH-MEIFj6r!oqf+FjMh;(rCq>R4tMCHL4NI*\1+UNiI'V9NC%VeJKn/YI0J];XQt&X83?=ihrg<*Mcn1n!1nWcDaQPe\P"9gnJuHl(jf]JQgZ[,&^uobI4QF',k"*^S)3c;)GMWC(T'=",ErnS#U=YCUN0&q4+*KmK1Zd*NI\GQDiZUG7;PTja8lulb"\PWWO#WcfI[ZB:6s*3g$be%?JH<b[c(?4J1!2SLDmbmkh8.c&CU)B?Xu,cp$<hP@=fLc>(n`oaEJ[XE'%QW=HE04M<,;ERm[MS=uYF=nN3jG'f@#?O48Ia,6Y-3m&tTWVq1?DeiBkp.Ug*;lVZX`Z=P.eklHhNV;!R_?QOuoeJ<0%7idG7GM8boU$^>N.N,2^;25]0Z8M<<]XMCct>noC'Qfb?`*[Mo+,F9#>t~>endstream
endobj
xref
0 9
0000000000 65535 f
0000000061 00000 n
0000000102 00000 n
0000000209 00000 n
0000000321 00000 n
0000000514 00000 n
0000000582 00000 n
0000000862 00000 n
0000000921 00000 n
trailer
<<
/ID
[<ee376a59c53e3f2af87024f65fd4222c><ee376a59c53e3f2af87024f65fd4222c>]
% ReportLab generated PDF document -- digest (opensource)
/Info 6 0 R
/Root 5 0 R
/Size 9
>>
startxref
1511
%%EOF
@@ -0,0 +1,222 @@
Intro paragraph for section 1.
| Name | Qty |
|---|---|
| alpha | 101 |
| delta | 201 |
# Section 2 Heading
| Name | Qty | Price |
|---|---|---|
| alpha | 101 | charlie |
| delta | 201 | foxtrot |
| golf | 301 | india |
## Section 3 Heading
| Name | Qty | Price | Region |
|---|---|---|---|
| alpha | 101 | charlie | 3 |
| delta | 201 | foxtrot | 13 |
| golf | 301 | india | 23 |
| juliet | 401 | lima | 33 |
Intro paragraph for section 4.
| Name | Qty | Price | Region | Status |
|---|---|---|---|---|
| alpha | 101 | charlie | 3 | echo |
| delta | 201 | foxtrot | 13 | hotel |
| golf | 301 | india | 23 | kilo |
| juliet | 401 | lima | 33 | november |
| mike | 501 | oscar | 43 | alpha |
# Section 5 Heading
| Name | Qty |
|---|---|
| alpha | 101 |
| delta | 201 |
| golf | 301 |
| juliet | 401 |
| mike | 501 |
| papa | 601 |
| Name | Qty | Price |
|---|---|---|
| alpha | 101 | charlie |
| delta | 201 | foxtrot |
# Section 7 Heading
Intro paragraph for section 7.
| Name | Qty | Price | Region |
|---|---|---|---|
| alpha | 101 | charlie | 3 |
| delta | 201 | foxtrot | 13 |
| golf | 301 | india | 23 |
## Section 8 Heading
| Name | Qty | Price | Region | Status |
|---|---|---|---|---|
| alpha | 101 | charlie | 3 | echo |
| delta | 201 | foxtrot | 13 | hotel |
| golf | 301 | india | 23 | kilo |
| juliet | 401 | lima | 33 | november |
| Name | Qty |
|---|---|
| alpha | 101 |
| delta | 201 |
| golf | 301 |
| juliet | 401 |
| mike | 501 |
# Section 10 Heading
Intro paragraph for section 10.
| Name | Qty | Price |
|---|---|---|
| alpha | 101 | charlie |
| delta | 201 | foxtrot |
| golf | 301 | india |
| juliet | 401 | lima |
| mike | 501 | oscar |
| papa | 601 | bravo |
| Name | Qty | Price | Region |
|---|---|---|---|
| alpha | 101 | charlie | 3 |
| delta | 201 | foxtrot | 13 |
# Section 12 Heading
| Name | Qty | Price | Region | Status |
|---|---|---|---|---|
| alpha | 101 | charlie | 3 | echo |
| delta | 201 | foxtrot | 13 | hotel |
| golf | 301 | india | 23 | kilo |
## Section 13 Heading
Intro paragraph for section 13.
| Name | Qty |
|---|---|
| alpha | 101 |
| delta | 201 |
| golf | 301 |
| juliet | 401 |
| Name | Qty | Price |
|---|---|---|
| alpha | 101 | charlie |
| delta | 201 | foxtrot |
| golf | 301 | india |
| juliet | 401 | lima |
| mike | 501 | oscar |
# Section 15 Heading
| Name | Qty | Price | Region |
|---|---|---|---|
| alpha | 101 | charlie | 3 |
| delta | 201 | foxtrot | 13 |
| golf | 301 | india | 23 |
| juliet | 401 | lima | 33 |
| mike | 501 | oscar | 43 |
| papa | 601 | bravo | 53 |
Intro paragraph for section 16.
| Name | Qty | Price | Region | Status |
|---|---|---|---|---|
| alpha | 101 | charlie | 3 | echo |
| delta | 201 | foxtrot | 13 | hotel |
# Section 17 Heading
| Name | Qty |
|---|---|
| alpha | 101 |
| delta | 201 |
| golf | 301 |
## Section 18 Heading
| Name | Qty | Price |
|---|---|---|
| alpha | 101 | charlie |
| delta | 201 | foxtrot |
| golf | 301 | india |
| juliet | 401 | lima |
Intro paragraph for section 19.
| Name | Qty | Price | Region |
|---|---|---|---|
| alpha | 101 | charlie | 3 |
| delta | 201 | foxtrot | 13 |
| golf | 301 | india | 23 |
| juliet | 401 | lima | 33 |
| mike | 501 | oscar | 43 |
# Section 20 Heading
| Name | Qty | Price | Region | Status |
|---|---|---|---|---|
| alpha | 101 | charlie | 3 | echo |
| delta | 201 | foxtrot | 13 | hotel |
| golf | 301 | india | 23 | kilo |
| juliet | 401 | lima | 33 | november |
| mike | 501 | oscar | 43 | alpha |
| papa | 601 | bravo | 53 | delta |
| Name | Qty |
|---|---|
| alpha | 101 |
| delta | 201 |
# Section 22 Heading
Intro paragraph for section 22.
| Name | Qty | Price |
|---|---|---|
| alpha | 101 | charlie |
| delta | 201 | foxtrot |
| golf | 301 | india |
## Section 23 Heading
| Name | Qty | Price | Region |
|---|---|---|---|
| alpha | 101 | charlie | 3 |
| delta | 201 | foxtrot | 13 |
| golf | 301 | india | 23 |
| juliet | 401 | lima | 33 |
| Name | Qty | Price | Region | Status |
|---|---|---|---|---|
| alpha | 101 | charlie | 3 | echo |
| delta | 201 | foxtrot | 13 | hotel |
| golf | 301 | india | 23 | kilo |
| juliet | 401 | lima | 33 | november |
| mike | 501 | oscar | 43 | alpha |
# Section 25 Heading
Intro paragraph for section 25.
| Name | Qty |
|---|---|
| alpha | 101 |
| delta | 201 |
| golf | 301 |
| juliet | 401 |
| mike | 501 |
| papa | 601 |
@@ -0,0 +1,169 @@
%PDF-1.4
%“Œ‹ž ReportLab Generated PDF document (opensource)
1 0 obj
<<
/F1 2 0 R /F2 3 0 R
>>
endobj
2 0 obj
<<
/BaseFont /Helvetica /Encoding /WinAnsiEncoding /Name /F1 /Subtype /Type1 /Type /Font
>>
endobj
3 0 obj
<<
/BaseFont /Helvetica-Bold /Encoding /WinAnsiEncoding /Name /F2 /Subtype /Type1 /Type /Font
>>
endobj
4 0 obj
<<
/Contents 13 0 R /MediaBox [ 0 0 612 792 ] /Parent 12 0 R /Resources <<
/Font 1 0 R /ProcSet [ /PDF /Text /ImageB /ImageC /ImageI ]
>> /Rotate 0 /Trans <<
>>
/Type /Page
>>
endobj
5 0 obj
<<
/Contents 14 0 R /MediaBox [ 0 0 612 792 ] /Parent 12 0 R /Resources <<
/Font 1 0 R /ProcSet [ /PDF /Text /ImageB /ImageC /ImageI ]
>> /Rotate 0 /Trans <<
>>
/Type /Page
>>
endobj
6 0 obj
<<
/Contents 15 0 R /MediaBox [ 0 0 612 792 ] /Parent 12 0 R /Resources <<
/Font 1 0 R /ProcSet [ /PDF /Text /ImageB /ImageC /ImageI ]
>> /Rotate 0 /Trans <<
>>
/Type /Page
>>
endobj
7 0 obj
<<
/Contents 16 0 R /MediaBox [ 0 0 612 792 ] /Parent 12 0 R /Resources <<
/Font 1 0 R /ProcSet [ /PDF /Text /ImageB /ImageC /ImageI ]
>> /Rotate 0 /Trans <<
>>
/Type /Page
>>
endobj
8 0 obj
<<
/Contents 17 0 R /MediaBox [ 0 0 612 792 ] /Parent 12 0 R /Resources <<
/Font 1 0 R /ProcSet [ /PDF /Text /ImageB /ImageC /ImageI ]
>> /Rotate 0 /Trans <<
>>
/Type /Page
>>
endobj
9 0 obj
<<
/Contents 18 0 R /MediaBox [ 0 0 612 792 ] /Parent 12 0 R /Resources <<
/Font 1 0 R /ProcSet [ /PDF /Text /ImageB /ImageC /ImageI ]
>> /Rotate 0 /Trans <<
>>
/Type /Page
>>
endobj
10 0 obj
<<
/PageMode /UseNone /Pages 12 0 R /Type /Catalog
>>
endobj
11 0 obj
<<
/Author (\(anonymous\)) /CreationDate (D:20260603005358+01'00') /Creator (\(unspecified\)) /Keywords () /ModDate (D:20260603005358+01'00') /Producer (ReportLab PDF Library - \(opensource\))
/Subject (\(unspecified\)) /Title (\(anonymous\)) /Trapped /False
>>
endobj
12 0 obj
<<
/Count 6 /Kids [ 4 0 R 5 0 R 6 0 R 7 0 R 8 0 R 9 0 R ] /Type /Pages
>>
endobj
13 0 obj
<<
/Filter [ /ASCII85Decode /FlateDecode ] /Length 1007
>>
stream
GauHKgN)$k&:O:Sn<io8&IUqd[51N1e*/]GIR%m`>DZmeMUK]*(BdhV"2[X,j0;*\%_E*0HXJRc`<Z.PlPcH-)*5ON#Qr4-#h+U(ll5egXa[FW7^<l-+Mko1pU57W'%??d'1Q-,]\qWS80;JOp0HFGa1T[+D>q%4S#/VM`O[Rh4i5T.Phi,$]aT$05!q:PBuCQU/.p2]@1koN*Tb*K9k5G\ht+Dr\K=+8\NZ"alMaOEo**@OK:8-.O1X3-?Gg`@m3%,ti3'">T-&c=M&Wuu?cDbGDp.gO<KniopM)A\M"ajn9N]6p@tefHlX67:iW3SE/[O\LV1TKe.i9es,%kWY+1-s,ft.u27]:^>F0!r6&&CLC$tM?fIR"M37["/*k9@YkpKKSS@Np"1/4#R]`I^(g*1Sc,9L3Qt6N(T@F`A>oBGgL-!r#Y4)R\G`D,iVtFeJUE9u4iuUQ?D%C&SB4kkp.>D5tii>nDKJ"Y07jANhOb$R(_$=U7Stjs)-/KZ(6IBm`6<Z:IO1/g<Q6n^@fdK*]SO#g&Wj4B)g1,#O?30",57V_ONl0p`%uQN]'EAZV$475Z"'DApH2)Sg6UQ75XC?4^'<&6&d?YC`OF9F3eE;WIMt@a?'q)c\3oWQ_BD]d`4=O\,'aSUFV4HsE7)O:lPOi6Ht1,[dTm6JqQM6es[?9G4VC(YieF*)uM6[SZT#[TN2,BIir^1;/Ku"E1laH[\"M+F'8=n0!;?_#hHd!hkpV0u\Ij\4VC-km.[F&JhGmbc`6@I1=mf>u<3;i.Bh4+MFJ"H:.XWUQX6%LU(sg4Tt$_":5`p.2,gZkpUfdg,Hd(qR1)9\ltF2^8b5,,XbY%VfhD52O7A.c.u]dhTc5t%0<0L5E38`Bq+i;"%J7kcc#@i)@okdN-qiaA"33DdPgPrUs7;j%+47_cnGN@&ug9/dqGOAHA4f.N*&guHspf?E;GIc-Wt>:<(m1AmcS_Zc2VlEI'_S_>@#!MF^m1$$<mB1`gt\X~>endstream
endobj
14 0 obj
<<
/Filter [ /ASCII85Decode /FlateDecode ] /Length 987
>>
stream
GatU3gN&c;&:O:SkV;H,eI!J\f?X"LSPIZ'"4Y.F#oAAY?N.Y?8Iu:s)eV;,aNG1Lea>G$!MSc\rU6m7jF'AOI09V1,^TSTi$A+f4sZVQ%2^>uj$40\AI>WbZo9q!NL^CS9P3E9X.:XEMb$a/X"qH9hN6fY,]D!OEOVT,722%RJnVqK4f'[4d3(/hbJWJRs,>>28jk5A:nCl/%FlC#LntsAC5cE$q`QO_@Pk%Lp>7V23")NNrFkcXfuoI=SJJ-`g)_+K\@,SQ@8ORGg(_(B.[u[WXD%Q8@I";9d(]M62K_)Q+-<\kHY5,)o99%BB2bcQVkd:+MZVH=$Z`S@BhH]-XfPANk@qBN.:Jc'?.Kn\p7(aPI<Cb@g:XRZ9Km+RD-e6S\N*kQMA?P5>QOI(du7U]WDrNNPW%d"8P_j^Y8d%G<t1\CJ_5-m+>,'JJLXrV.UD!ah19&9b#*fgR2o730<9)QX)X0"EK6u;mBJs3/fl\d&K$B3bXI5R>F*E,^\%+b?0o8s$o.CuX4uDAT_?G0(p4N3La,8qfi'j?e`e88KrZ8*NDgc:62'icCb],4B]Z"SO&e/BRC*C$2PmbSKAjc\$FLD8?&qHVH3G4p834/77n[%()-p/JDRBapcO2b\,3dW%#U9u!ilSkd%2'rr&g[u".1O]W%0J<@#(ptUD;%0:^.^ls:.#.V6NgR[`2\RUW!k$-*GZIf3DO<K8np=hCA>BiB_Mu"=LVGlO\1Z]0pF(@UaiVQ=dh?h5kfQ_gj>$jHW$8TCt:C.jVU98ne.XoiMtoV;q8XhnlOE;3a]=dGJ8KL&+Khj"&P4Q=P-:nHsDprY"B4+#A3dF,<W=NC>;b&gf(sT\Ts*iH)f!KhfHV;\mMSHej.9.XrBG41_<:b)MG;-Y(:&nCKS0F]r&CGl>EfMc0td\0GHScl3Lr;?OUoo6619Qdr)S8S);]7L5rmWW&(5t4Dil"USDhhY/Y=!=!X:85**$:~>endstream
endobj
15 0 obj
<<
/Filter [ /ASCII85Decode /FlateDecode ] /Length 976
>>
stream
GatU3gN&c;'R]XVkgAc"70atdYFXp#3h7VV#7/=-%N$"G?N.YO&iX#cVsS_l_5i8)eum:1-)(7`mXJ7LnhqY0hGZal,We=q^e"$M]Lu;_=4AoIN$QOjOF.@i*Sg!_rLL=-'?kk;m;M&R`$D>'HCL7<qMHOgP.\NgCm:Zf5tLAgarh6YCKLONS%?!`3T6F7YKNJ(dBFh(U1"e7D6YdC:71/`?cjR"QrKtsGg*:'=Ck>]$A,-S`H1I/1T]4\B3=gQ9:I4j9f]+af*Z7*GH5H+;j73Z-T#DAcgpO<SFuA['l7@MF*6c^<pB]o84rG$&I@Cs3`KIG,N/bG0f<k/M@gUKb"%\W-lZ#AU+i=gUKkm46K-b>W(/'b?XF9+X+'-Dqi"+1U7?6=ZRJNBSJ6ojDTa+]o4RF^26C+i0s(9G2o;$@RNbs\(f75],L(SVpI0M!'7JVhZ"$t/%kb1+)F\p54/@jpVJF2+)7^F=gVcp8(h*m%F#'p9c%9Ep9?b7g^F5>(>I3t^d_"f'aue%^E\X4kN:g\P[;sC=g3h,nD/n9*`94uoM?sBCL<kr$AOqh:,Y&c90,npSDXN*bEfY(k^0LZ_\I@Y+L2nn64-qsuX5D'=o'B2"8rCsRJ?h/-4Ur6khb;ph%X"8-mXTUU5-!Uh$iVuRi1.=>4;XilfMPT4]&FC.U%DI(c6uf3*XPJP2MRdq@Ag3uYF().nqLFAE2Nih'(^p\5FnSNa>n+cI?U65A61_N1<9ZXH4Xr)Fgq(6md3H9[MQ3Q^%aq>E?XM:O:RKGjc+QV2MB!.I.17QpSt]X[C*6ap.`#'o;8F(ebZ.VMM6k`BkZB\I,2MNoX]J"k]Qd";,@($aWX&29q"6k;>Let)@]QHm:i/lf[DB_&U@ROq-0MLQp;@j+]?$"E)brXVun52AVQo$"[a7@!?!LT*>$&:5X_.R;9)'$l-S.>WjLQIVTo.JU%(H\,-*pl_kP9~>endstream
endobj
16 0 obj
<<
/Filter [ /ASCII85Decode /FlateDecode ] /Length 1024
>>
stream
GauHKflEQ9'Rf^WgrHc4<#\/Qm7c-rFIIq+TFSD%Z#L'6o(Nl&^_!k0ds=.%-s&pt#i0QBKE91*<^/MJJCcfoHA_e;aL?\&_BAj]Dt;HW$6(8Ql\%O7-5%uT8<qrc9!'@hZ!'`C3a&iu\ZDZi+5lF*E@>0ZIL#\l)(+[ABOTNKmq!2F,S:.F93Z6QIu[nf\Q[qCE`Y-=.8k$67iCR!7=El+50a@f&b^6'Yg'mqlcRQ-5j:%[K@'5!A_m0L)&VlW1T50^X)"3Ma3,U1P8Di$>uPZ)Zj_igBc0lm]WE0#e>*M53(Yh6\9jHYh6B+3bM\b[Q9j'O_9:!:W0<K\bX;'ZV[%iHrL=:rH.Wh5O-II8-Rh3..Sd^\dNItUUd<nli^`$=A@?\WW4/7Wnp>$Ijm,#(bKn_D?UYMGJ7LLqC0\[$l<8U*O5^GnN;!N6YB'?d[d8DAPt^>+E:r0<2$'VL/TtuNg;C@5iM#%KdS-GU->0]`/20eLXVW#qnjsU.P:Xj&=enVE[mpqDCn2!qDRuQhI/#cZ-#m6`U9'SI4"9\<WSWoVPaf@]<NRl?FrTPOd:V_=>"r:4p.\W>IK_$#C,EkU#uO1g6*HoUK':XiJjtQfI'Sdc)B3J1eqgGJ+l?"$<J+^?VHgiq^90uD"@6ar+/_WhKG+8t!Slo>//&C$^f'TA!2:K!0pd;^Zif)c[:f!C/8hNKc7+'+1WbOFKr9T7c_G$s*O634O2Zs:"iN>j,pWU+:kEQ>5YbF=$27*59iJE<glrr=EAMssR&N"lVT1:*a,OcI=8\"6/>A//7(KsL\pUG.G[+JB-r8t;Sih+(W.A'8Q*LZK*I9WjCLjEt>lT%2RFJZg0Ps:5c,HdK#tSNI:#Rp!]O$Ic=T%K0[._E_Fcjn>OcM,iSAf9iF7,,+Qe:@o'Y4o_:54t*l+TAg<i_F?ke9:\G<n<b*t@"_`_;08ft@[9;qtKC#H0Z6cJ+d)MdidY-OFV1K"=!=K=^?hK-Q.s+?\[I98%IXRiAI.!:AVTf`~>endstream
endobj
17 0 obj
<<
/Filter [ /ASCII85Decode /FlateDecode ] /Length 1075
>>
stream
GauHKgN&c;&:O:SkV;H,6&Q<FCM0)BVUK5trr=4p-3<-<P.#jI+b[BoQ`]$,GaorC,Bfp>Z[5)3-pL]0bpl':D91fR,o"F49.1/bfmFt32ljt6eO[j7!K]\nZ:^QQASu[l@3no(6*HL'@3I!Qfi7%,.N\.>A91O)D\n5*o@JWSOI@ng5p/"_I`g*Y$j0+5PWaso'25ltcglFd,QIaO'dPO0Yr\h5/,=m\PiP:P+Gg0&N;_5iLN$]BD.#VL+h,tSe\fL&,;+2fg&%\pAJiV#eVhq@ro%%/`[(&8n29YiYd>bDF^Bg[AVf/n>[FE(au"ZAe&>%Q[7/n3-QhsPXuPbp#<$d"UR8u-nBnr!Q$Wjd1;9WN/,KG8])Ca5rrDrI'Ue'*I0C%pn+f`Hd"4sB_&2*)S$pX=F/6"DC"TU`bMtd/m7u]?]=J5L[SNBuE:6Ri,NmW7.*qUp)97Tt+a84b\os`Ti8/uRD<mg2;n&q)23L#\f:[QJk+sYYhIC_O\TNS/;3<qfN@GE^L#jl^i&)"7_/%Cb;-M;;ooT&`R;C&\WTZ4"\YIcTZDa;0CKTX+?@ra\94$H:Q<l)r*NHFJWjMr7[tCBb^PX,G]H!fs0g3,5^'AZ%<4Zqp(JCd+;^lkoDJ%q(Bb84V=:PCgOtM#Rr-=oHW&DmO?/!mYrV;g]MR?14cGuH_H*]73!34(()[A.)c<V3:@I>lhrl*Xrn(a0\,%4%*k^rL\k3%6ako`&A3MoN^CXV77D!Qg<S_OEMIh5(T*nFt'&4]_38'Q<s*V7!;A%[email protected]/(oCu;?^7kb[#R/-P*GF@KHDm6E*dLMg/4Q$W(<Ta[fVpGB5td>%=j9Ge+0B@dZDHELb2fDU(,iiXXRl^*U,U#Fld!R72UIpK<KU`cn&R/_eYS//1PE/$d7p:t-BP62fL<3/QQ4%=NoD?Tlf:A,GpJc<]*uYaL6fSAqm"-E.#%h]idd9$6\DZKKp8Vd[>GWu#-DXi653a?U$fqCs18gt(5/#U%!u,gkMK;>OT_/='S:kn,iD@u]<8-rlTRu'+\7L$U7-#!5-5\WQn(n:q@-p24u!~>endstream
endobj
18 0 obj
<<
/Filter [ /ASCII85Decode /FlateDecode ] /Length 590
>>
stream
GasbXgMWc?&;KY!MRgt!"n`^Bf\9$c*`Q-Sb6tfd8P(q-dT1eng=QV-!OG*\kiWo2AGBdKLqI^%h,XNB)-gDk+GFVBa?g*a%:!J\97Vc8-jH8>8P>0S@Wr)o*"Hu<6qPp`EF:M3'l4@S\c2]`&[J%dO@5p$<(([H6:SlB;FS8L`&pO%6Y\V"/O7F]E%L@d.%>L@"4bK_XXs&";n_.W^Nr847HH,X@$.pC"4bYC?9RH,dN?h8<BTH9l89:QMq:es\ePUpFRc4?hV'h3b4`sE_b4r->a+8/!?Q=D\F41Nc@'B`';4XgMeh!;U23C+>AbMQD-o--lJ/":m#(Yt,X5(`1KL,GTMBLlr6=-1mi#k.-Ou\\T4$Pun2dEU(\?$&;1@T4dm^t!KuOD-U@N_AMr"&uVpsGm,+8I7B*f!%9.o4cC1<Nr^$d5!dH-#UIr,n6Z;JUFYWCTVA\,or>a[CZ12(hd>1*0bU`k2-MXo1[Gor#kmXGIM'#R49X#NSAOpdf'0ilUH4:M(^Snc3;m/+><UlJ1jSN&ZuoQ68F8`c/Y-e0[!aljuioC5fR7-VUAX`%g<0P5J7"CFlQJT1~>endstream
endobj
xref
0 19
0000000000 65535 f
0000000061 00000 n
0000000102 00000 n
0000000209 00000 n
0000000321 00000 n
0000000516 00000 n
0000000711 00000 n
0000000906 00000 n
0000001101 00000 n
0000001296 00000 n
0000001491 00000 n
0000001561 00000 n
0000001842 00000 n
0000001932 00000 n
0000003031 00000 n
0000004109 00000 n
0000005176 00000 n
0000006292 00000 n
0000007459 00000 n
trailer
<<
/ID
[<ba877f458edd6b06b69a7e843c67586d><ba877f458edd6b06b69a7e843c67586d>]
% ReportLab generated PDF document -- digest (opensource)
/Info 11 0 R
/Root 10 0 R
/Size 19
>>
startxref
8140
%%EOF
@@ -0,0 +1,25 @@
# Lorem Ipsum in Two Columns
## 1. Origins
Lorem ipsum dolor sit amet consectetur adipiscing elit. Sed do eiusmod tempor incididunt ut labore et dolore magna aliqua.
## 2. Structure
Ut enim ad minim veniam quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat.
## 3. Usage
Duis aute irure dolor in reprehenderit in voluptate velit esse cillum dolore eu fugiat nulla pariatur.
## 4. Variations
Excepteur sint occaecat cupidatat non proident sunt in culpa qui officia deserunt mollit anim id est laborum.
## 5. Typography
Curabitur pretium tincidunt lacus. Nulla gravida orci a odio. Nullam various turpis et commodo pharetra est.
## 6. Conclusion
Nunc nonummy metus. Vestibulum volutpat pretium libero. Cras id dui. Aenean ut eros et nisl sagittis vestibulum.
@@ -0,0 +1,74 @@
%PDF-1.4
%“Œ‹ž ReportLab Generated PDF document (opensource)
1 0 obj
<<
/F1 2 0 R /F2 3 0 R
>>
endobj
2 0 obj
<<
/BaseFont /Helvetica /Encoding /WinAnsiEncoding /Name /F1 /Subtype /Type1 /Type /Font
>>
endobj
3 0 obj
<<
/BaseFont /Helvetica-Bold /Encoding /WinAnsiEncoding /Name /F2 /Subtype /Type1 /Type /Font
>>
endobj
4 0 obj
<<
/Contents 8 0 R /MediaBox [ 0 0 612 792 ] /Parent 7 0 R /Resources <<
/Font 1 0 R /ProcSet [ /PDF /Text /ImageB /ImageC /ImageI ]
>> /Rotate 0 /Trans <<
>>
/Type /Page
>>
endobj
5 0 obj
<<
/PageMode /UseNone /Pages 7 0 R /Type /Catalog
>>
endobj
6 0 obj
<<
/Author (\(anonymous\)) /CreationDate (D:20260603021636+01'00') /Creator (\(unspecified\)) /Keywords () /ModDate (D:20260603021636+01'00') /Producer (ReportLab PDF Library - \(opensource\))
/Subject (\(unspecified\)) /Title (\(anonymous\)) /Trapped /False
>>
endobj
7 0 obj
<<
/Count 1 /Kids [ 4 0 R ] /Type /Pages
>>
endobj
8 0 obj
<<
/Filter [ /ASCII85Decode /FlateDecode ] /Length 861
>>
stream
Gat=i>Ak00&;B$5/*8Q/Z)fmNp(^2^OK'MC[dT6#Oq.&b^4c4;1No6>+D%TC.n+fice+l9R0[L%'(o<DJBpB+r$jsRcu23$RjI].E7rS*BB"3t\qRKaS-SYt[_<-]),Cp@'$i;6dQuJ#Q*+lGY?X<phgEWPa?op]_1g@kf_>[email protected]:T=#%ZIIl18hB7Zf<piVi-V+;]ct^jS<=Z4l:IOd[,o"%q.hK[[k]r1!^oX'%44os7T9[m%YrbZ3KU/;aR0$cV$;D._".OT8)jeEBKU'l<A`'aD&R'L"Bo]Q)R+=cKOEld8s#M0ZYZN4[lRm1*F6^d9-Gfl5>I(hVU+?`]UK%;aabAH:q>9NUGQq^.=u])Wt-ETI))C'a[FE@elj!RSrf2Q'F>URAO.C,!DneTPqrj#4e2kb9%1"4qfZ)"#&^0j9nHQ?9nF!j7mVPP5\*Uq'_jMVS]9%`kQB\8*AF_bpr/hGj;HCUOSQU-%5:6S79Ud\b!*tPbr_'pCr$Ea#(FYP31NFhSX.-("1M:$cgH#hX8L(2]R3Q>'BYHCS%pI!;=WdJp,'ii[`QPZ_9mcd\baZ2U(_;c\-p,8EoIEpQ*lstL>]LE;C#\dLnT2R:)BM-fTc['3_He[U,k'!Bo".uERd>SkhRj^J+koSIrZ_dEf_5L'/1h.`+DTK(R:P,WH)h5\se=SZ"L/5b8b..,e/E\o+4YQ+*im^C>AERG/TieEK\)#>U@HXnJ,H0A9-MqhhkDp8%.6Lr,OrK*lih;B<-opZ8%EU?,$r^jmCDAQ`-0/-8/`[p]7Fm%0f:E&S*FV)DX2>#q\bRqA=^_`43#8EA$u%8r6F5`rc9>K@q>E4q^*~>endstream
endobj
xref
0 9
0000000000 65535 f
0000000061 00000 n
0000000102 00000 n
0000000209 00000 n
0000000321 00000 n
0000000514 00000 n
0000000582 00000 n
0000000862 00000 n
0000000921 00000 n
trailer
<<
/ID
[<21a9fbd0a0991a91b6e6e2db0856056e><21a9fbd0a0991a91b6e6e2db0856056e>]
% ReportLab generated PDF document -- digest (opensource)
/Info 6 0 R
/Root 5 0 R
/Size 9
>>
startxref
1872
%%EOF
@@ -0,0 +1,62 @@
# Employee Expense Report
Reimbursement Request
EMP-1047
**Report Header**
| Employee Name | Michael Tran |
|---|---|
| Employee ID | EMP-1047 |
| Department | Client Services |
| Report Date | January 20th, 2026 |
| Reporting Period | January 5th16th, 2026 |
| Manager Approver | Laura Simmons |
**Company Information**
| Company | Summit Consulting Partners |
|---|---|
| Company Address | 88 Riverside Plaza, Suite 1400, New York, NY 10069 |
| Accounting Department Email | expenses@example.com |
**Trip Purpose**
The trip was undertaken for client onsite meetings with Atlantic Energy Solutions in Boston, MA.
**Expense Details**
| Description | Amount | Date | Category |
|---|---|---|---|
| Flight (NYC to Boston roundtrip) | $325.40 | January 5th, 2026 | Airline ticket |
| Hotel (3 nights at Harborview Hotel) | $822.75 | January 5th8th, 2026 | Lodging |
| Taxi from airport to hotel | $48.00 | January 5th, 2026 | Ground transportation |
| Client dinner (3 attendees) | $186.20 | January 6th, 2026 | Meals |
| Parking at JFK Airport | $72.00 | January 5th8th, 2026 | Parking |
| Breakfast (per diem not used) | $18.50 | January 7th, 2026 | Meals |
| Description | Amount | Date | Category |
|---|---|---|---|
| Uber to client office | $22.10 | January 7th, 2026 | Ground transportation |
| Printing + presentation materials | $46.90 | January 8th, 2026 | Materials |
| Lunch with client | $39.75 | January 8th, 2026 | Meals |
| Office supplies (notebooks, pens) | $27.60 | January 10th, 2026 | Supplies |
| Mileage reimbursement (client visit in NJ, 42 miles @ $0.67/mile) | $28.14 | January 14th, 2026 | Mileage |
| Team lunch meeting (internal) | $64.30 | January 15th, 2026 | Meals |
Total Expenses $1,701.64
Reimbursement Method
Reimbursement method Direct deposit
Notes
All receipts are attached. Expenses are business-related and comply with company travel policy.
**Approval**
Michael Tran, Employee
Laura Simmons, Manager
@@ -1,11 +1,13 @@
package stirling.software.SPDF.model.api.converters;
import org.springframework.core.io.Resource;
import java.nio.charset.StandardCharsets;
import org.springframework.http.MediaType;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.ModelAttribute;
import org.springframework.web.multipart.MultipartFile;
import io.github.pixee.security.Filenames;
import io.swagger.v3.oas.annotations.Operation;
import lombok.RequiredArgsConstructor;
@@ -15,8 +17,11 @@ import stirling.software.common.annotations.AutoJobPostMapping;
import stirling.software.common.annotations.api.ConvertApi;
import stirling.software.common.enumeration.ResourceWeight;
import stirling.software.common.model.api.PDFFile;
import stirling.software.common.util.PDFToFile;
import stirling.software.common.pdf.PdfMarkdownConverter;
import stirling.software.common.util.TempFile;
import stirling.software.common.util.TempFileManager;
import stirling.software.common.util.WebResponseUtils;
import stirling.software.jpdfium.PdfDocument;
@ConvertApi
@RequiredArgsConstructor
@@ -33,10 +38,27 @@ public class ConvertPDFToMarkdown {
summary = "Convert PDF to Markdown",
description =
"This endpoint converts a PDF file to Markdown format. Input:PDF Output:Markdown Type:SISO")
public ResponseEntity<Resource> processPdfToMarkdown(@ModelAttribute PDFFile file)
public ResponseEntity<byte[]> processPdfToMarkdown(@ModelAttribute PDFFile file)
throws Exception {
MultipartFile inputFile = file.getFileInput();
PDFToFile pdfToFile = new PDFToFile(tempFileManager);
return pdfToFile.processPdfToMarkdown(inputFile);
String originalName = Filenames.toSimpleFileName(inputFile.getOriginalFilename());
String baseName =
originalName.contains(".")
? originalName.substring(0, originalName.lastIndexOf('.'))
: originalName;
String markdown;
try (TempFile tempInput = new TempFile(tempFileManager, ".pdf")) {
inputFile.transferTo(tempInput.getFile());
try (PdfDocument doc = PdfDocument.open(tempInput.getPath())) {
markdown = new PdfMarkdownConverter().convert(doc);
}
}
return WebResponseUtils.bytesToWebResponse(
markdown.getBytes(StandardCharsets.UTF_8),
baseName + ".md",
MediaType.valueOf("text/markdown"));
}
}
@@ -364,6 +364,18 @@ aiEngine:
url: http://localhost:5001 # URL of the Python AI engine
timeoutSeconds: 120 # Timeout in seconds for AI engine requests
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
# entirely; list absolute directories to permit folder access only within them. Stirling's own
# config directory is always off-limits, and folder access is always disabled in SaaS mode.
allowedFolderRoots: [] # e.g. ["/data/inbox", "/data/outbox"]
scheduleSweepSeconds: 60 # How often (seconds) scheduled policies are checked for being due
watchReconcileSeconds: 300 # How often (seconds) folder-watch re-syncs watches and re-runs as a safety net for missed events
watchQuietPeriodMs: 500 # How long (ms) folder-watch coalesces a burst of file events into a single run
streamTimeoutMs: 1800000 # SSE timeout (ms) for live run-progress streams
runExpiryMinutes: 30 # How long (minutes) a finished run's in-memory state is kept before eviction
# Model Context Protocol (MCP) server. Exposes Stirling's PDF tools (grouped by namespace)
# plus the AI agents to MCP clients (Inspector, Claude Desktop, custom). OAuth-protected.
# Disabled by default - enable explicitly per deployment after configuring mcp.auth.
@@ -1,16 +1,17 @@
package stirling.software.SPDF.model.api.converters;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.Mockito.*;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.multipart;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.*;
import java.io.File;
import java.nio.charset.StandardCharsets;
import java.nio.file.Path;
import org.junit.jupiter.api.Test;
import org.mockito.ArgumentCaptor;
import org.mockito.MockedConstruction;
import org.mockito.MockedStatic;
import org.mockito.Mockito;
import org.springframework.core.io.ByteArrayResource;
import org.springframework.core.io.Resource;
@@ -21,9 +22,10 @@ import org.springframework.test.web.servlet.MockMvc;
import org.springframework.test.web.servlet.setup.MockMvcBuilders;
import org.springframework.web.bind.annotation.ExceptionHandler;
import org.springframework.web.bind.annotation.RestControllerAdvice;
import org.springframework.web.multipart.MultipartFile;
import stirling.software.common.util.PDFToFile;
import stirling.software.common.pdf.PdfMarkdownConverter;
import stirling.software.common.util.TempFile;
import stirling.software.jpdfium.PdfDocument;
class ConvertPDFToMarkdownTest {
@@ -47,68 +49,68 @@ class ConvertPDFToMarkdownTest {
@Test
void pdfToMarkdownReturnsMarkdownBytes() throws Exception {
byte[] md = "# heading\n\ncontent\n".getBytes(StandardCharsets.UTF_8);
String expectedMd = "# heading\n\ncontent\n";
try (MockedConstruction<PDFToFile> construction =
Mockito.mockConstruction(
PDFToFile.class,
(mock, ctx) -> {
when(mock.processPdfToMarkdown(any(MultipartFile.class)))
.thenAnswer(
inv ->
ResponseEntity.ok()
.header("Content-Type", "text/markdown")
.body(new ByteArrayResource(md)));
})) {
File tmpFile = File.createTempFile("test", ".pdf");
tmpFile.deleteOnExit();
MockMvc mvc = mockMvc();
try (MockedConstruction<TempFile> tempMock =
Mockito.mockConstruction(
TempFile.class,
(mock, ctx) -> {
when(mock.getFile()).thenReturn(tmpFile);
when(mock.getPath()).thenReturn(tmpFile.toPath());
});
MockedStatic<PdfDocument> docStatic = Mockito.mockStatic(PdfDocument.class);
MockedConstruction<PdfMarkdownConverter> converterMock =
Mockito.mockConstruction(
PdfMarkdownConverter.class,
(mock, ctx) -> when(mock.convert(any())).thenReturn(expectedMd))) {
PdfDocument mockDoc = Mockito.mock(PdfDocument.class);
docStatic.when(() -> PdfDocument.open(any(Path.class))).thenReturn(mockDoc);
MockMultipartFile file =
new MockMultipartFile(
"fileInput", // must match the field name in PDFFile
"input.pdf",
"application/pdf",
new byte[] {1, 2, 3});
"fileInput", "input.pdf", "application/pdf", new byte[] {1, 2, 3});
// ResponseEntity<Resource> is written synchronously on the request thread,
// so there is no async dispatch to wait for (unlike the old StreamingResponseBody
// path).
mvc.perform(multipart("/api/v1/convert/pdf/markdown").file(file))
mockMvc()
.perform(multipart("/api/v1/convert/pdf/markdown").file(file))
.andExpect(status().isOk())
.andExpect(header().string("Content-Type", "text/markdown"))
.andExpect(content().bytes(md));
// Verify that exactly one instance was created
assert construction.constructed().size() == 1;
// And that the uploaded file was passed to processPdfToMarkdown()
PDFToFile created = construction.constructed().get(0);
ArgumentCaptor<MultipartFile> captor = ArgumentCaptor.forClass(MultipartFile.class);
verify(created, times(1)).processPdfToMarkdown(captor.capture());
MultipartFile passed = captor.getValue();
// Minimal plausibility checks
assertEquals("input.pdf", passed.getOriginalFilename());
assertEquals("application/pdf", passed.getContentType());
}
}
@Test
void pdfToMarkdownWhenServiceThrowsReturns500() throws Exception {
try (MockedConstruction<PDFToFile> ignored =
Mockito.mockConstruction(
PDFToFile.class,
(mock, ctx) -> {
when(mock.processPdfToMarkdown(any(MultipartFile.class)))
.thenThrow(new RuntimeException("boom"));
})) {
File tmpFile = File.createTempFile("test", ".pdf");
tmpFile.deleteOnExit();
MockMvc mvc = mockMvc();
try (MockedConstruction<TempFile> tempMock =
Mockito.mockConstruction(
TempFile.class,
(mock, ctx) -> {
when(mock.getFile()).thenReturn(tmpFile);
when(mock.getPath()).thenReturn(tmpFile.toPath());
});
MockedStatic<PdfDocument> docStatic = Mockito.mockStatic(PdfDocument.class);
MockedConstruction<PdfMarkdownConverter> converterMock =
Mockito.mockConstruction(
PdfMarkdownConverter.class,
(mock, ctx) ->
when(mock.convert(any()))
.thenThrow(new RuntimeException("boom")))) {
PdfDocument mockDoc = Mockito.mock(PdfDocument.class);
docStatic.when(() -> PdfDocument.open(any(Path.class))).thenReturn(mockDoc);
MockMultipartFile file =
new MockMultipartFile(
"fileInput", "x.pdf", "application/pdf", new byte[] {0x01});
mvc.perform(multipart("/api/v1/convert/pdf/markdown").file(file))
mockMvc()
.perform(multipart("/api/v1/convert/pdf/markdown").file(file))
.andExpect(status().isInternalServerError());
}
}
@@ -21,7 +21,8 @@ public enum AiWorkflowOutcome {
COMPLETED("completed"),
UNSUPPORTED_CAPABILITY("unsupported_capability"),
CANNOT_CONTINUE("cannot_continue"),
GENERATE_FILE("generate_file");
GENERATE_FILE("generate_file"),
CONVERT_MARKDOWN("convert_markdown");
private final String value;
@@ -0,0 +1,106 @@
package stirling.software.proprietary.policy.config;
import java.nio.file.Path;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.List;
import org.springframework.core.env.Environment;
import org.springframework.stereotype.Component;
import stirling.software.common.configuration.InstallationPathConfig;
import stirling.software.common.model.ApplicationProperties;
import stirling.software.proprietary.policy.model.Policy;
/**
* The single authority on which filesystem locations a policy may read from or write to. Folder
* sources and sinks take a configured directory, so without this a user who can save a policy could
* point one at Stirling's own config/secrets directory and exfiltrate (or overwrite) it. Every
* folder source and sink runs its directory through {@link #requirePermitted(Path)} at save time
* and again at run time.
*
* <p>Enforced fail-closed, in order:
*
* <ul>
* <li><b>Disabled in SaaS</b> - folder access is never allowed when the {@code saas} profile is
* active; a tenant must not reach the host filesystem at all.
* <li><b>Protected paths</b> - Stirling's own config directory (settings, database, keys,
* backups) is always rejected, even if an allowed root were misconfigured to contain it.
* <li><b>Allowlist</b> - the directory must resolve within one of {@code
* policies.allowedFolderRoots}; with none configured, all folder access is refused.
* </ul>
*
* <p>Paths are compared after normalisation, so {@code ..} segments cannot walk out of an allowed
* root. (Symlink escape is not defended here; an operator who configures an allowed root containing
* a symlink to a sensitive location is trusted.)
*/
@Component
public class FolderAccessGuard {
public static final String FOLDER_TYPE = "folder";
private final boolean saasActive;
private final List<Path> allowedRoots;
private final List<Path> protectedRoots;
public FolderAccessGuard(ApplicationProperties applicationProperties, Environment environment) {
this.saasActive = Arrays.asList(environment.getActiveProfiles()).contains("saas");
this.allowedRoots =
normalizeAll(applicationProperties.getPolicies().getAllowedFolderRoots());
this.protectedRoots = List.of(normalize(Path.of(InstallationPathConfig.getConfigPath())));
}
/**
* Check that {@code dir} is a permitted folder location, returning its normalised absolute
* form.
*
* @throws IllegalArgumentException if folder access is disabled (SaaS or no roots configured),
* the path is inside a protected directory, or it falls outside every allowed root
*/
public Path requirePermitted(Path dir) {
if (saasActive) {
throw new IllegalArgumentException(
"folder sources and outputs are not available in SaaS mode");
}
Path normalized = normalize(dir);
for (Path protectedRoot : protectedRoots) {
if (normalized.startsWith(protectedRoot)) {
throw new IllegalArgumentException(
"folder may not point inside a protected Stirling directory");
}
}
if (allowedRoots.isEmpty()) {
throw new IllegalArgumentException(
"folder access is disabled; set policies.allowedFolderRoots to permit it");
}
boolean within = allowedRoots.stream().anyMatch(normalized::startsWith);
if (!within) {
throw new IllegalArgumentException(
"folder '" + normalized + "' is outside the allowed folder roots");
}
return normalized;
}
/** Whether this policy reads from or writes to a folder, and so is subject to these rules. */
public boolean usesFolderAccess(Policy policy) {
boolean readsFolder =
policy.sources().stream().anyMatch(spec -> FOLDER_TYPE.equals(spec.type()));
boolean writesFolder =
policy.output() != null && FOLDER_TYPE.equals(policy.output().type());
return readsFolder || writesFolder;
}
private static List<Path> normalizeAll(List<String> roots) {
List<Path> result = new ArrayList<>();
for (String root : roots) {
if (root != null && !root.isBlank()) {
result.add(normalize(Path.of(root)));
}
}
return result;
}
private static Path normalize(Path path) {
return path.toAbsolutePath().normalize();
}
}
@@ -6,7 +6,6 @@ import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.core.io.FileSystemResource;
import org.springframework.core.io.Resource;
import org.springframework.http.HttpStatus;
@@ -34,11 +33,16 @@ import io.swagger.v3.oas.annotations.tags.Tag;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import stirling.software.common.model.ApplicationProperties;
import stirling.software.common.model.job.JobResponse;
import stirling.software.common.service.UserServiceInterface;
import stirling.software.common.util.TempFile;
import stirling.software.common.util.TempFileManager;
import stirling.software.proprietary.policy.config.FolderAccessGuard;
import stirling.software.proprietary.policy.engine.PolicyRunHandle;
import stirling.software.proprietary.policy.engine.PolicyRunRegistry;
import stirling.software.proprietary.policy.engine.PolicyRunner;
import stirling.software.proprietary.policy.engine.PolicyValidator;
import stirling.software.proprietary.policy.model.PipelineDefinition;
import stirling.software.proprietary.policy.model.Policy;
import stirling.software.proprietary.policy.model.PolicyInputs;
@@ -47,7 +51,6 @@ import stirling.software.proprietary.policy.model.PolicyRunStatus;
import stirling.software.proprietary.policy.model.PolicyRunView;
import stirling.software.proprietary.policy.progress.PolicyProgressListener;
import stirling.software.proprietary.policy.store.PolicyStore;
import stirling.software.proprietary.policy.trigger.ManualTrigger;
import stirling.software.proprietary.security.config.PremiumEndpoint;
import tools.jackson.core.JacksonException;
@@ -71,16 +74,16 @@ import tools.jackson.databind.ObjectMapper;
@Tag(name = "Policies", description = "Run tool pipelines on the backend")
public class PolicyController {
private final ManualTrigger manualTrigger;
private final PolicyRunner policyRunner;
private final PolicyRunRegistry runRegistry;
private final PolicyStore policyStore;
private final PolicyValidator policyValidator;
private final FolderAccessGuard folderAccessGuard;
private final UserServiceInterface userService;
private final ApplicationProperties applicationProperties;
private final ObjectMapper objectMapper;
private final TempFileManager tempFileManager;
/** SSE emitter timeout, generous enough for long multi-step runs on large files. */
@Value("${stirling.policies.streamTimeoutMs:1800000}")
private long streamTimeoutMs;
@PostMapping(value = "/run", consumes = MediaType.MULTIPART_FORM_DATA_VALUE)
@Operation(
summary = "Run a tool pipeline",
@@ -95,7 +98,8 @@ public class PolicyController {
throws IOException {
PipelineDefinition definition = parseDefinition(json);
PolicyInputs inputs = collectInputs(request);
String runId = manualTrigger.fire(definition, inputs, PolicyProgressListener.NOOP).runId();
String runId =
policyRunner.runAdHoc(definition, inputs, PolicyProgressListener.NOOP).runId();
return ResponseEntity.accepted().body(new JobResponse<>(true, runId, null));
}
@@ -112,10 +116,11 @@ public class PolicyController {
PipelineDefinition definition = parseDefinition(json);
PolicyInputs inputs = collectInputs(request);
SseEmitter emitter = new SseEmitter(streamTimeoutMs);
SseEmitter emitter =
new SseEmitter(applicationProperties.getPolicies().getStreamTimeoutMs());
emitter.onError(e -> log.warn("Policy run SSE emitter error", e));
PolicyRunHandle handle = manualTrigger.fire(definition, inputs, streamListener(emitter));
PolicyRunHandle handle = policyRunner.runAdHoc(definition, inputs, streamListener(emitter));
// Close the stream with a terminal event once the run finishes. whenComplete runs on the
// engine's worker thread after the run is done, so this never races the step events.
handle.completion()
@@ -155,7 +160,34 @@ public class PolicyController {
"Stores a policy (trigger config + steps + output + metadata). A blank id is"
+ " assigned; returns the stored policy with its id.")
public ResponseEntity<Policy> savePolicy(@RequestBody String json) {
return ResponseEntity.ok(policyStore.save(parsePolicy(json)));
Policy policy = parsePolicy(json);
requireAuthorizedForFolderAccess(policy);
try {
policyValidator.validate(policy);
} catch (IllegalArgumentException e) {
throw new ResponseStatusException(HttpStatus.BAD_REQUEST, e.getMessage());
}
return ResponseEntity.ok(policyStore.save(policy));
}
/**
* A policy that reads from or writes to a server folder grants whoever saves it access to that
* path, so restrict it to administrators on multi-user deployments. Single-user deployments
* (login disabled, e.g. desktop) trust the local operator. The {@link FolderAccessGuard} still
* enforces SaaS-off and the path allowlist during validation regardless of who saves.
*/
private void requireAuthorizedForFolderAccess(Policy policy) {
if (!folderAccessGuard.usesFolderAccess(policy)) {
return;
}
if (!applicationProperties.getSecurity().isEnableLogin()) {
return;
}
if (!userService.isCurrentUserAdmin()) {
throw new ResponseStatusException(
HttpStatus.FORBIDDEN,
"Folder sources and outputs may only be configured by an administrator");
}
}
@GetMapping
@@ -199,7 +231,7 @@ public class PolicyController {
new ResponseStatusException(
HttpStatus.NOT_FOUND, "No policy: " + policyId));
PolicyInputs inputs = collectInputs(request);
String runId = manualTrigger.run(policy, inputs, PolicyProgressListener.NOOP).runId();
String runId = policyRunner.runWith(policy, inputs, PolicyProgressListener.NOOP).runId();
return ResponseEntity.accepted().body(new JobResponse<>(true, runId, null));
}
@@ -9,13 +9,13 @@ import java.util.concurrent.Executors;
import java.util.concurrent.ScheduledExecutorService;
import java.util.concurrent.TimeUnit;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Service;
import jakarta.annotation.PreDestroy;
import lombok.extern.slf4j.Slf4j;
import stirling.software.common.model.ApplicationProperties;
import stirling.software.proprietary.policy.model.PolicyRun;
/**
@@ -24,9 +24,9 @@ import stirling.software.proprietary.policy.model.PolicyRun;
* TaskManager}.
*
* <p>Finished runs are evicted on a fixed interval once they age past {@code
* stirling.policies.runExpiryMinutes}, mirroring the job-result expiry in {@code TaskManager} so a
* run's rich in-memory state does not outlive the process. Only terminal runs are evicted; active
* and paused ({@code WAITING_FOR_INPUT}) runs are retained regardless of age. Result files are not
* policies.runExpiryMinutes}, mirroring the job-result expiry in {@code TaskManager} so a run's
* rich in-memory state does not outlive the process. Only terminal runs are evicted; active and
* paused ({@code WAITING_FOR_INPUT}) runs are retained regardless of age. Result files are not
* touched here: a run shares its runId with a {@code TaskManager} job, which owns file-lifecycle
* cleanup, so eviction only frees this map's entry.
*/
@@ -41,8 +41,8 @@ public class PolicyRunRegistry {
Executors.newSingleThreadScheduledExecutor(
Thread.ofVirtual().name("policy-run-cleanup-", 0).factory());
public PolicyRunRegistry(
@Value("${stirling.policies.runExpiryMinutes:30}") int runExpiryMinutes) {
public PolicyRunRegistry(ApplicationProperties applicationProperties) {
int runExpiryMinutes = applicationProperties.getPolicies().getRunExpiryMinutes();
this.runExpiry = Duration.ofMinutes(runExpiryMinutes);
cleanupExecutor.scheduleAtFixedRate(this::evictExpiredRuns, 10, 10, TimeUnit.MINUTES);
log.debug(
@@ -0,0 +1,116 @@
package stirling.software.proprietary.policy.engine;
import java.io.IOException;
import java.util.List;
import java.util.function.Consumer;
import org.springframework.stereotype.Service;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import stirling.software.proprietary.policy.input.InputSource;
import stirling.software.proprietary.policy.input.ResolvedInput;
import stirling.software.proprietary.policy.model.InputSpec;
import stirling.software.proprietary.policy.model.PipelineDefinition;
import stirling.software.proprietary.policy.model.Policy;
import stirling.software.proprietary.policy.model.PolicyInputs;
import stirling.software.proprietary.policy.model.PolicyRun;
import stirling.software.proprietary.policy.model.PolicyRunStatus;
import stirling.software.proprietary.policy.progress.PolicyProgressListener;
/**
* Runs policies, and is the one place that knows how to turn a policy's configured {@link InputSpec
* sources} into actual runs. Triggers (schedule, and future webhook/folder-watch) decide
* <em>when</em> to run and call {@link #run(Policy)}; they never touch sources themselves. The
* controller uses the supplied-input and ad-hoc entry points for on-demand work.
*
* <p>This is the seam that keeps triggers and sources independent: a trigger depends on the runner,
* the runner depends on the {@link InputSource} beans, and a source depends on neither - it just
* yields {@link ResolvedInput units of work}, each carrying its own completion hook.
*/
@Slf4j
@Service
@RequiredArgsConstructor
public class PolicyRunner {
private final PolicyEngine policyEngine;
private final List<InputSource> inputSources;
/**
* Run a policy by pulling from every source it configures: each source yields zero or more
* units of work, and each unit becomes its own run so one failure does not affect the others. A
* policy with no sources runs once with no input files (a generator pipeline). Used by
* automatic triggers.
*/
public void run(Policy policy) {
List<InputSpec> sources = policy.sources();
if (sources.isEmpty()) {
startRun(policy, PolicyInputs.of(List.of()), unused -> {});
return;
}
for (InputSpec spec : sources) {
pullAndRun(policy, spec);
}
}
/**
* Run a stored policy on files supplied directly by the caller (e.g. a manual run with
* uploads), bypassing its configured sources. Returns the run handle so callers can stream
* progress.
*/
public PolicyRunHandle runWith(
Policy policy, PolicyInputs inputs, PolicyProgressListener listener) {
return policyEngine.runPolicy(policy, inputs, listener);
}
/** Run an ad-hoc pipeline with no stored policy (AI/Automate one-offs). */
public PolicyRunHandle runAdHoc(
PipelineDefinition definition, PolicyInputs inputs, PolicyProgressListener listener) {
return policyEngine.submit(definition, inputs, listener);
}
private void pullAndRun(Policy policy, InputSpec spec) {
InputSource source = sourceFor(spec);
if (source == null) {
log.warn(
"No input source for type '{}' (policy {}); skipping",
spec.type(),
policy.id());
return;
}
List<ResolvedInput> work;
try {
work = source.resolve(spec);
} catch (IOException | RuntimeException e) {
log.warn(
"Failed to resolve source '{}' for policy {}: {}",
spec.type(),
policy.id(),
e.getMessage());
return;
}
for (ResolvedInput unit : work) {
startRun(policy, unit.inputs(), unit.onComplete());
}
}
private void startRun(Policy policy, PolicyInputs inputs, Consumer<Boolean> onComplete) {
log.info("Running policy {} ({})", policy.id(), policy.name());
PolicyRunHandle handle =
policyEngine.runPolicy(policy, inputs, PolicyProgressListener.NOOP);
handle.completion()
.whenComplete((run, throwable) -> onComplete.accept(succeeded(run, throwable)));
}
private static boolean succeeded(PolicyRun run, Throwable throwable) {
return throwable == null && run != null && run.getStatus() == PolicyRunStatus.COMPLETED;
}
private InputSource sourceFor(InputSpec spec) {
return inputSources.stream()
.filter(source -> source.supports(spec))
.findFirst()
.orElse(null);
}
}
@@ -0,0 +1,74 @@
package stirling.software.proprietary.policy.engine;
import java.util.List;
import org.springframework.stereotype.Service;
import lombok.RequiredArgsConstructor;
import stirling.software.proprietary.policy.input.InputSource;
import stirling.software.proprietary.policy.model.InputSpec;
import stirling.software.proprietary.policy.model.OutputSpec;
import stirling.software.proprietary.policy.model.Policy;
import stirling.software.proprietary.policy.model.TriggerConfig;
import stirling.software.proprietary.policy.output.PolicyOutputSink;
import stirling.software.proprietary.policy.trigger.PolicyTrigger;
/**
* Validates a policy's trigger, sources, and output configuration by delegating each facet to the
* bean that handles its type. Called when a policy is saved so a misconfigured schedule, missing
* folder directory, or unknown type fails fast instead of silently misbehaving at run time.
*
* <p>The trigger is optional (a {@code null} trigger is a manual-only policy and needs no
* validation); every configured source is validated.
*/
@Service
@RequiredArgsConstructor
public class PolicyValidator {
private final List<PolicyTrigger> triggers;
private final List<InputSource> inputSources;
private final List<PolicyOutputSink> outputSinks;
/**
* @throws IllegalArgumentException if any facet's type is unknown or its configuration is
* invalid
*/
public void validate(Policy policy) {
if (policy.trigger() != null) {
triggerFor(policy.trigger()).validate(policy);
}
for (InputSpec source : policy.sources()) {
inputSourceFor(source).validate(source);
}
outputSinkFor(policy.output()).validate(policy.output());
}
private PolicyTrigger triggerFor(TriggerConfig config) {
return triggers.stream()
.filter(trigger -> trigger.type().equals(config.type()))
.findFirst()
.orElseThrow(
() ->
new IllegalArgumentException(
"unknown trigger type: " + config.type()));
}
private InputSource inputSourceFor(InputSpec spec) {
return inputSources.stream()
.filter(source -> source.supports(spec))
.findFirst()
.orElseThrow(
() ->
new IllegalArgumentException(
"unknown input source type: " + spec.type()));
}
private PolicyOutputSink outputSinkFor(OutputSpec spec) {
return outputSinks.stream()
.filter(sink -> sink.supports(spec))
.findFirst()
.orElseThrow(
() -> new IllegalArgumentException("unknown output type: " + spec.type()));
}
}
@@ -0,0 +1,186 @@
package stirling.software.proprietary.policy.input;
import java.io.IOException;
import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.StandardCopyOption;
import java.util.ArrayList;
import java.util.List;
import java.util.Map;
import java.util.stream.Stream;
import org.springframework.core.io.FileSystemResource;
import org.springframework.core.io.Resource;
import org.springframework.stereotype.Service;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import stirling.software.common.util.FileReadinessChecker;
import stirling.software.proprietary.policy.config.FolderAccessGuard;
import stirling.software.proprietary.policy.model.InputSpec;
import stirling.software.proprietary.policy.model.PolicyInputs;
/**
* Reads input files from a directory. Each ready file becomes its own unit of work (one run per
* file) so a failure on one file does not affect the others.
*
* <p>Two modes via the {@code mode} option:
*
* <ul>
* <li>{@code "consume"} (default) - claim each file by moving it into {@code
* .stirling/processing}, then route it to {@code .stirling/done} or {@code .stirling/error}
* when its run finishes. Each file is processed once; right for "process new arrivals" (and
* the basis of watched folders).
* <li>{@code "snapshot"} - read the directory's current files without moving them; every run sees
* the full set again. Right for "always regenerate from a fixed input set".
* </ul>
*
* Readiness is checked first (via {@link FileReadinessChecker}) so files mid-write are skipped.
*/
@Slf4j
@Service
@RequiredArgsConstructor
public class FolderInputSource implements InputSource {
private static final String TYPE = FolderAccessGuard.FOLDER_TYPE;
// Bookkeeping lives under one hidden namespace dir so the watched folder stays tidy.
private static final String WORK_SUBDIR = ".stirling";
private static final String PROCESSING_SUBDIR = "processing";
private static final String DONE_SUBDIR = "done";
private static final String ERROR_SUBDIR = "error";
private final FileReadinessChecker readinessChecker;
private final FolderAccessGuard accessGuard;
@Override
public String type() {
return TYPE;
}
@Override
public boolean supports(InputSpec spec) {
return spec != null && TYPE.equals(spec.type());
}
@Override
public void validate(InputSpec spec) {
accessGuard.requirePermitted(FolderConfig.from(spec.options()).directory());
}
@Override
public List<Path> watchTargets(InputSpec spec) {
return List.of(FolderConfig.from(spec.options()).directory());
}
@Override
public List<ResolvedInput> resolve(InputSpec spec) throws IOException {
FolderConfig config = FolderConfig.from(spec.options());
Path inputDir = accessGuard.requirePermitted(config.directory());
if (!Files.isDirectory(inputDir)) {
log.debug("Folder input dir does not exist: {}", inputDir);
return List.of();
}
List<Path> ready = new ArrayList<>();
try (Stream<Path> entries = Files.list(inputDir)) {
entries.filter(Files::isRegularFile)
.filter(readinessChecker::isReady)
.forEach(ready::add);
}
List<ResolvedInput> work = new ArrayList<>();
for (Path file : ready) {
if (config.snapshot()) {
work.add(ResolvedInput.of(PolicyInputs.of(List.of(fileResource(file)))));
} else {
Path claimed = claim(inputDir, file);
if (claimed == null) {
continue; // another sweep/process grabbed it
}
work.add(
new ResolvedInput(
PolicyInputs.of(List.of(fileResource(claimed))),
success -> route(inputDir, claimed, success)));
}
}
return work;
}
private Path claim(Path inputDir, Path file) {
try {
Path processingDir = workDir(inputDir, PROCESSING_SUBDIR);
Files.createDirectories(processingDir);
Path claimed = uniqueTarget(processingDir, file.getFileName().toString());
Files.move(file, claimed, StandardCopyOption.ATOMIC_MOVE);
return claimed;
} catch (IOException e) {
log.debug("Could not claim {}: {}", file, e.getMessage());
return null;
}
}
private void route(Path inputDir, Path claimed, boolean success) {
String subdir = success ? DONE_SUBDIR : ERROR_SUBDIR;
try {
Path destDir = workDir(inputDir, subdir);
Files.createDirectories(destDir);
Files.move(
claimed,
uniqueTarget(destDir, claimed.getFileName().toString()),
StandardCopyOption.ATOMIC_MOVE);
} catch (IOException e) {
log.warn(
"Could not move processed input {} to {}: {}", claimed, subdir, e.getMessage());
}
}
/** A bookkeeping subdirectory under the watched folder's {@code .stirling} namespace. */
private static Path workDir(Path inputDir, String subdir) {
return inputDir.resolve(WORK_SUBDIR).resolve(subdir);
}
private static Resource fileResource(Path path) {
String name = path.getFileName().toString();
return new FileSystemResource(path.toFile()) {
@Override
public String getFilename() {
return name;
}
};
}
private static Path uniqueTarget(Path dir, String filename) {
Path candidate = dir.resolve(filename);
if (!Files.exists(candidate)) {
return candidate;
}
int dot = filename.lastIndexOf('.');
String base = dot < 0 ? filename : filename.substring(0, dot);
String ext = dot < 0 ? "" : filename.substring(dot);
for (int n = 1; ; n++) {
Path next = dir.resolve(base + " (" + n + ")" + ext);
if (!Files.exists(next)) {
return next;
}
}
}
/** The typed, validated form of a folder source's options: the directory and dedup mode. */
record FolderConfig(Path directory, boolean snapshot) {
private static final String DIRECTORY_OPTION = "directory";
private static final String MODE_OPTION = "mode";
private static final String MODE_SNAPSHOT = "snapshot";
static FolderConfig from(Map<String, Object> options) {
Object directory = options.get(DIRECTORY_OPTION);
if (directory == null || directory.toString().isBlank()) {
throw new IllegalArgumentException("folder input requires a 'directory' option");
}
Object mode = options.get(MODE_OPTION);
boolean snapshot = mode != null && MODE_SNAPSHOT.equals(mode.toString());
return new FolderConfig(Path.of(directory.toString()), snapshot);
}
}
}
@@ -0,0 +1,49 @@
package stirling.software.proprietary.policy.input;
import java.io.IOException;
import java.nio.file.Path;
import java.util.List;
import stirling.software.proprietary.policy.model.InputSpec;
/**
* Resolves one of a policy's {@link InputSpec sources} into the files to run on - answering
* <em>where</em> a run's files come from, independent of <em>when</em> it runs. The counterpart of
* {@code PolicyOutputSink}: implementations are beans selected by {@link #supports(InputSpec)}, so
* a new source kind (folder, S3) is just a new bean.
*
* <p>Driven by the {@code PolicyRunner}, which a trigger calls when a policy is due; a source is
* passive and knows nothing about what triggered the run. A manual run may instead supply files
* directly and bypass sources entirely.
*/
public interface InputSource {
/** Stable identifier for this source, matching {@code InputSpec.type()} (e.g. "folder"). */
String type();
/** Whether this source can handle the given spec. */
boolean supports(InputSpec spec);
/**
* Check that an input spec is usable, throwing {@link IllegalArgumentException} if not. Called
* when a policy is saved so misconfiguration fails fast rather than at run time.
*/
default void validate(InputSpec spec) {}
/**
* Resolve the spec into zero or more units of work, each carrying the files for one run and a
* completion hook. Returning an empty list means there is nothing to run right now.
*/
List<ResolvedInput> resolve(InputSpec spec) throws IOException;
/**
* The local filesystem directories this source draws from, if any, for triggers that want to
* react to changes there (the folder-watch trigger) rather than poll. Advisory only: it merely
* tells a trigger <em>where</em> to watch; resolving the spec into files is still this source's
* job via {@link #resolve}. Non-filesystem sources (S3, ...) return an empty list and so are
* simply not watchable. Default: nothing to watch.
*/
default List<Path> watchTargets(InputSpec spec) {
return List.of();
}
}
@@ -0,0 +1,23 @@
package stirling.software.proprietary.policy.input;
import java.util.function.Consumer;
import stirling.software.proprietary.policy.model.PolicyInputs;
/**
* One unit of work produced by an {@link InputSource}: the files to run plus a completion callback
* invoked with the run's success once it finishes (e.g. a folder source routes the input to {@code
* .stirling/done} or {@code .stirling/error}). A source may return several of these (e.g. one per
* file).
*/
public record ResolvedInput(PolicyInputs inputs, Consumer<Boolean> onComplete) {
public ResolvedInput {
onComplete = onComplete == null ? success -> {} : onComplete;
}
/** A unit of work with no completion side effect. */
public static ResolvedInput of(PolicyInputs inputs) {
return new ResolvedInput(inputs, success -> {});
}
}
@@ -0,0 +1,26 @@
package stirling.software.proprietary.policy.model;
import java.util.Map;
/**
* One source a policy's input files come from. {@code type} selects an {@code InputSource}
* ("folder", and in future "s3", ...); {@code options} carries source-specific configuration (a
* directory, a bucket, a dedup mode, ...).
*
* <p>A policy holds a list of these; a run pulls from every one. An empty list means the policy
* runs with no input files (a generator pipeline, or files supplied directly to a manual run).
*
* <p>Data-driven and parallel to {@link OutputSpec}/{@link TriggerConfig}: a new source kind is a
* new {@code type} handled by a new {@code InputSource} bean.
*/
public record InputSpec(String type, Map<String, Object> options) {
public InputSpec {
options = options == null ? Map.of() : options;
}
/** Read input files from a directory on disk. */
public static InputSpec folder(String directory) {
return new InputSpec("folder", Map.of("directory", directory));
}
}
@@ -18,4 +18,9 @@ public record OutputSpec(String type, Map<String, Object> options) {
public static OutputSpec inline() {
return new OutputSpec("inline", Map.of());
}
/** Write outputs to a directory on disk. */
public static OutputSpec folder(String directory) {
return new OutputSpec("folder", Map.of("directory", directory));
}
}
@@ -3,18 +3,16 @@ package stirling.software.proprietary.policy.model;
import java.util.List;
/**
* A stored, owned automation: how it is triggered, the ordered tool steps to run, and where its
* output goes, plus identity and metadata.
* A stored automation: an ordered chain of tool steps, the sources its input files come from, and
* an output destination for the results.
*
* <p>This is the central object of the feature. Everything that runs a chain of tools is a use of a
* Policy: a watched folder is a Policy with a folder {@link TriggerConfig} and a folder {@link
* OutputSpec}; a scheduled job is a Policy with a schedule trigger; manual/Automate/AI runs execute
* a Policy (or an ad-hoc {@link PipelineDefinition}) on demand. The engine itself only ever
* executes the {@link PipelineDefinition} this exposes via {@link #toDefinition()} - it is
* trigger-agnostic.
* <p>Every policy can always be run on demand (manually). It may additionally carry one automatic
* {@link TriggerConfig} - usually a schedule - that fires it without a person asking; a {@code
* null} trigger means manual-only. A trigger decides <em>when</em> a run happens and a {@link
* InputSpec source} decides <em>where</em> its files come from; the two are independent, and a run
* pulls from every configured source.
*
* <p>{@code enabled} gates automatic triggering (a disabled policy is not picked up by its
* trigger); it does not block an explicit manual run.
* <p>This is the feature's central configuration object - what a user defines and the engine runs.
*/
public record Policy(
String id,
@@ -22,15 +20,28 @@ public record Policy(
String owner,
boolean enabled,
TriggerConfig trigger,
List<InputSpec> sources,
List<PipelineStep> steps,
OutputSpec output) {
public Policy {
trigger = trigger == null ? TriggerConfig.manual() : trigger;
sources = sources == null ? List.of() : List.copyOf(sources);
steps = steps == null ? List.of() : steps;
output = output == null ? OutputSpec.inline() : output;
}
/** A policy with no configured sources (a generator, or files supplied directly to a run). */
public Policy(
String id,
String name,
String owner,
boolean enabled,
TriggerConfig trigger,
List<PipelineStep> steps,
OutputSpec output) {
this(id, name, owner, enabled, trigger, List.of(), steps, output);
}
/** The engine-level, trigger-agnostic view of this policy's pipeline. */
public PipelineDefinition toDefinition() {
return new PipelineDefinition(name, steps, output);
@@ -0,0 +1,141 @@
package stirling.software.proprietary.policy.model;
import java.time.DayOfWeek;
import java.time.LocalTime;
import java.time.ZonedDateTime;
import java.util.EnumSet;
import java.util.Set;
import com.fasterxml.jackson.annotation.JsonIgnoreProperties;
import com.fasterxml.jackson.annotation.JsonSubTypes;
import com.fasterxml.jackson.annotation.JsonTypeInfo;
/**
* When a scheduled policy should fire, expressed as intent ("every day at 02:00") rather than a
* cron string. A frontend builds one of these from a friendly picker; the schedule trigger asks it
* for the next firing after a given moment.
*
* <p>Sealed so every cadence is an explicit, self-validating shape and adding a new one forces a
* new subtype rather than another string convention to learn. The {@code type} discriminator stays
* on the wire so the frontend can switch on it without knowing the Java hierarchy.
*
* <p>Wall-clock kinds ({@link Daily}, {@link Weekly}, {@link Monthly}) are evaluated in the zone of
* the {@code after} argument; {@link Every} is a fixed offset and ignores wall-clock time.
*/
@JsonTypeInfo(use = JsonTypeInfo.Id.NAME, property = "type")
@JsonSubTypes({
@JsonSubTypes.Type(value = Schedule.Every.class, name = "every"),
@JsonSubTypes.Type(value = Schedule.Daily.class, name = "daily"),
@JsonSubTypes.Type(value = Schedule.Weekly.class, name = "weekly"),
@JsonSubTypes.Type(value = Schedule.Monthly.class, name = "monthly"),
})
@JsonIgnoreProperties(ignoreUnknown = true)
public sealed interface Schedule {
/** The next firing strictly after {@code after}, evaluated in {@code after}'s zone. */
ZonedDateTime nextAfter(ZonedDateTime after);
/** The granularities a fixed-interval schedule can repeat on. */
enum Unit {
MINUTES,
HOURS,
DAYS
}
/**
* A fixed cadence repeating from when the policy was last seen: "every 15 minutes", "every 6
* hours". Time of day is irrelevant.
*/
record Every(long count, Unit unit) implements Schedule {
public Every {
if (count <= 0) {
throw new IllegalArgumentException("'every' schedule needs a positive count");
}
if (unit == null) {
throw new IllegalArgumentException("'every' schedule needs a unit");
}
}
@Override
public ZonedDateTime nextAfter(ZonedDateTime after) {
return switch (unit) {
case MINUTES -> after.plusMinutes(count);
case HOURS -> after.plusHours(count);
case DAYS -> after.plusDays(count);
};
}
}
/** Once a day at a wall-clock time: "every day at 02:00". */
record Daily(LocalTime at) implements Schedule {
public Daily {
requireTime(at);
}
@Override
public ZonedDateTime nextAfter(ZonedDateTime after) {
ZonedDateTime today = after.with(at);
return today.isAfter(after) ? today : today.plusDays(1);
}
}
/** On chosen weekdays at a wall-clock time: "every Monday and Thursday at 09:00". */
record Weekly(Set<DayOfWeek> days, LocalTime at) implements Schedule {
public Weekly {
if (days == null || days.isEmpty()) {
throw new IllegalArgumentException("'weekly' schedule needs at least one day");
}
requireTime(at);
days = EnumSet.copyOf(days);
}
@Override
public ZonedDateTime nextAfter(ZonedDateTime after) {
// The soonest of the next 7 days that lands on a chosen weekday, at the configured
// time.
for (int i = 0; i <= 7; i++) {
ZonedDateTime candidate = after.plusDays(i).with(at);
if (candidate.isAfter(after) && days.contains(candidate.getDayOfWeek())) {
return candidate;
}
}
throw new IllegalStateException("unreachable: a chosen weekday recurs within 8 days");
}
}
/**
* On a day of the month at a wall-clock time: "the 1st at 00:00". Months too short for the
* chosen day (e.g. the 31st in February) are skipped, not clamped.
*/
record Monthly(int dayOfMonth, LocalTime at) implements Schedule {
public Monthly {
if (dayOfMonth < 1 || dayOfMonth > 31) {
throw new IllegalArgumentException("'monthly' day-of-month must be 1-31");
}
requireTime(at);
}
@Override
public ZonedDateTime nextAfter(ZonedDateTime after) {
ZonedDateTime firstOfMonth = after.withDayOfMonth(1).with(at);
// Scan forward a few years' worth of months to skip ones without the chosen day.
for (int i = 0; i < 48; i++) {
ZonedDateTime month = firstOfMonth.plusMonths(i);
if (month.toLocalDate().lengthOfMonth() >= dayOfMonth) {
ZonedDateTime fire = month.withDayOfMonth(dayOfMonth);
if (fire.isAfter(after)) {
return fire;
}
}
}
throw new IllegalStateException(
"unreachable: a month with the chosen day recurs yearly");
}
}
private static void requireTime(LocalTime at) {
if (at == null) {
throw new IllegalArgumentException("schedule needs a time of day ('at')");
}
}
}
@@ -3,23 +3,21 @@ package stirling.software.proprietary.policy.model;
import java.util.Map;
/**
* How a {@link Policy} is automatically triggered. {@code type} selects a trigger kind ("manual",
* "folder", "schedule", "s3"); {@code options} carries type-specific configuration (a folder path,
* a cron expression, a bucket, ...).
* A {@link Policy}'s optional automatic trigger - what fires it without a person asking. {@code
* type} selects a trigger kind ("schedule", and in future "webhook", "folder-watch", ...); {@code
* options} carries type-specific configuration (a {@link Schedule}, a webhook secret, ...).
*
* <p>Manual running is <em>not</em> a trigger kind: every policy can always be run on demand, so a
* policy with no automatic trigger simply has a {@code null} {@code TriggerConfig}. A trigger
* answers only "when"; where a run's files come from is a separate concern owned by the policy's
* {@link InputSpec sources}.
*
* <p>Data-driven and parallel to {@link OutputSpec}: new trigger kinds are new {@code type} values
* handled by a new trigger bean, with no change to the model. {@code "manual"} means there is no
* automatic trigger - the policy is only ever run on demand.
* handled by a new trigger bean, with no change to the model.
*/
public record TriggerConfig(String type, Map<String, Object> options) {
public TriggerConfig {
type = type == null || type.isBlank() ? "manual" : type;
options = options == null ? Map.of() : options;
}
/** No automatic trigger; the policy is run on demand only. */
public static TriggerConfig manual() {
return new TriggerConfig("manual", Map.of());
}
}
@@ -0,0 +1,130 @@
package stirling.software.proprietary.policy.output;
import java.io.IOException;
import java.io.InputStream;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.ArrayList;
import java.util.List;
import java.util.UUID;
import org.apache.commons.io.FilenameUtils;
import org.springframework.core.io.Resource;
import org.springframework.http.MediaType;
import org.springframework.http.MediaTypeFactory;
import org.springframework.stereotype.Service;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import stirling.software.common.model.job.ResultFile;
import stirling.software.proprietary.policy.config.FolderAccessGuard;
import stirling.software.proprietary.policy.model.OutputSpec;
/**
* Writes a run's output files to a directory on disk. The destination is the {@code directory}
* option of the {@link OutputSpec}.
*
* <p>Files are streamed to disk (so large outputs are not buffered) and given unique names to avoid
* clobbering existing files. The returned {@link ResultFile}s describe what was written (path +
* size); they carry a synthetic id because the deliverable is the file on disk, not a {@code
* FileStorage} entry, so folder outputs are not downloadable via {@code /files/{id}}.
*/
@Slf4j
@Service
@RequiredArgsConstructor
public class FolderOutputSink implements PolicyOutputSink {
static final String TYPE = FolderAccessGuard.FOLDER_TYPE;
static final String DIRECTORY_OPTION = "directory";
private final FolderAccessGuard accessGuard;
@Override
public String type() {
return TYPE;
}
@Override
public boolean supports(OutputSpec spec) {
return spec != null && TYPE.equals(spec.type());
}
@Override
public void validate(OutputSpec spec) {
accessGuard.requirePermitted(directoryOf(spec));
}
@Override
public List<ResultFile> deliver(String runId, List<Resource> outputs, OutputSpec spec)
throws IOException {
Path targetDir = accessGuard.requirePermitted(directoryOf(spec));
Files.createDirectories(targetDir);
List<ResultFile> results = new ArrayList<>();
for (int i = 0; i < outputs.size(); i++) {
Resource resource = outputs.get(i);
String name = safeName(resource.getFilename(), i);
Path target = uniqueTarget(targetDir, name);
try (InputStream is = resource.getInputStream()) {
Files.copy(is, target);
}
long size = Files.size(target);
String contentType =
MediaTypeFactory.getMediaType(name)
.orElse(MediaType.APPLICATION_OCTET_STREAM)
.toString();
results.add(
ResultFile.builder()
.fileId(UUID.randomUUID().toString())
.fileName(target.toString())
.contentType(contentType)
.fileSize(size)
.build());
log.debug("Wrote policy run {} output to {}", runId, target);
}
return results;
}
private static Path directoryOf(OutputSpec spec) {
Object directory = spec.options().get(DIRECTORY_OPTION);
if (directory == null || directory.toString().isBlank()) {
throw new IllegalArgumentException(
"folder output requires a '" + DIRECTORY_OPTION + "' option");
}
return Path.of(directory.toString());
}
/**
* The resource's filename reduced to a bare, traversal-free name: any directory component or
* "../" is stripped so a crafted output name cannot escape {@code targetDir}. Falls back to a
* synthetic name when the filename is absent or reduces to nothing usable.
*/
private static String safeName(String filename, int index) {
if (filename == null || filename.isBlank()) {
return "output-" + index;
}
String name = FilenameUtils.getName(filename);
if (name.isBlank() || ".".equals(name) || "..".equals(name)) {
return "output-" + index;
}
return name;
}
/** Resolve a non-colliding path in {@code dir}, appending " (n)" before the extension. */
private static Path uniqueTarget(Path dir, String filename) {
Path candidate = dir.resolve(filename);
if (!Files.exists(candidate)) {
return candidate;
}
String base = FilenameUtils.getBaseName(filename);
String ext = FilenameUtils.getExtension(filename);
String suffix = ext.isEmpty() ? "" : "." + ext;
for (int n = 1; ; n++) {
Path next = dir.resolve(base + " (" + n + ")" + suffix);
if (!Files.exists(next)) {
return next;
}
}
}
}
@@ -23,6 +23,12 @@ public interface PolicyOutputSink {
/** Whether this sink can handle the given output spec. */
boolean supports(OutputSpec spec);
/**
* Check that an output spec is usable, throwing {@link IllegalArgumentException} if not. Called
* when a policy is saved so misconfiguration fails fast rather than at run time.
*/
default void validate(OutputSpec spec) {}
/**
* Persist/deliver the output files and return their descriptors.
*
@@ -30,6 +30,7 @@ public class InProcessPolicyStore implements PolicyStore {
policy.owner(),
policy.enabled(),
policy.trigger(),
policy.sources(),
policy.steps(),
policy.output());
policies.put(id, stored);
@@ -50,6 +51,7 @@ public class InProcessPolicyStore implements PolicyStore {
public List<Policy> findByTriggerType(String triggerType) {
return policies.values().stream()
.filter(Policy::enabled)
.filter(policy -> policy.trigger() != null)
.filter(policy -> triggerType.equals(policy.trigger().type()))
.toList();
}
@@ -37,6 +37,7 @@ public class JpaPolicyStore implements PolicyStore {
policy.owner(),
policy.enabled(),
policy.trigger(),
policy.sources(),
policy.steps(),
policy.output());
@@ -45,7 +46,7 @@ public class JpaPolicyStore implements PolicyStore {
entity.setName(stored.name());
entity.setOwner(stored.owner());
entity.setEnabled(stored.enabled());
entity.setTriggerType(stored.trigger().type());
entity.setTriggerType(stored.trigger() == null ? null : stored.trigger().type());
entity.setPolicyJson(objectMapper.writeValueAsString(stored));
repository.save(entity);
return stored;
@@ -0,0 +1,302 @@
package stirling.software.proprietary.policy.trigger;
import static java.nio.file.StandardWatchEventKinds.ENTRY_CREATE;
import static java.nio.file.StandardWatchEventKinds.ENTRY_MODIFY;
import java.io.IOException;
import java.nio.file.ClosedWatchServiceException;
import java.nio.file.FileSystems;
import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.WatchKey;
import java.nio.file.WatchService;
import java.util.ArrayList;
import java.util.HashSet;
import java.util.List;
import java.util.Map;
import java.util.Set;
import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.Executors;
import java.util.concurrent.ScheduledExecutorService;
import java.util.concurrent.TimeUnit;
import org.springframework.stereotype.Service;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import stirling.software.common.model.ApplicationProperties;
import stirling.software.proprietary.policy.engine.PolicyRunner;
import stirling.software.proprietary.policy.input.InputSource;
import stirling.software.proprietary.policy.model.InputSpec;
import stirling.software.proprietary.policy.model.Policy;
import stirling.software.proprietary.policy.store.PolicyStore;
/**
* Fires policies the moment a file lands in one of their folder sources, instead of polling on a
* timer. The trigger only reads that location; turning it into files is still the source's job.
*
* <p>The watcher is a latency optimisation, not a source of truth, so this pairs an event watch
* with a low-frequency <b>reconcile</b> sweep ({@code watchReconcileSeconds}). The reconcile both
* (a) re-syncs which directories are watched as policies are created/edited/deleted and folders
* appear on disk, and (b) runs every folder-watch policy once, catching files that pre-dated the
* watch, events lost to inotify-queue overflow, and changes on filesystems that do not deliver
* events at all (NFS, many container bind mounts). Both paths just call {@link PolicyRunner#run};
* the {@link InputSource} does the claiming, so a redundant run finds nothing to claim and is
* harmless.
*
* <p>Like {@link ScheduleTrigger}, watch state is per-node and in memory; this assumes a single
* node and rebuilds its registrations on restart from the {@link PolicyStore}.
*/
@Slf4j
@Service
@RequiredArgsConstructor
public class FolderWatchTrigger implements PolicyTrigger {
private static final String TYPE = "folder-watch";
private final PolicyStore policyStore;
private final PolicyRunner policyRunner;
private final List<InputSource> inputSources;
private final ApplicationProperties applicationProperties;
private final Map<Path, WatchKey> keysByDir = new ConcurrentHashMap<>();
private final Map<WatchKey, Path> dirByKey = new ConcurrentHashMap<>();
private volatile boolean running;
// Package-visible (not private) so tests can drive syncRegistrations() against a real service.
volatile WatchService watchService;
private volatile ScheduledExecutorService reconciler;
@Override
public String type() {
return TYPE;
}
@Override
public void validate(Policy policy) {
if (watchDirsOf(policy).isEmpty()) {
throw new IllegalArgumentException(
"folder-watch trigger requires at least one watchable (folder) input source");
}
}
@Override
public synchronized void start() {
if (watchService != null) {
return;
}
try {
watchService = FileSystems.getDefault().newWatchService();
} catch (IOException e) {
log.error("Could not start folder-watch trigger: {}", e.getMessage(), e);
return;
}
running = true;
Thread.ofVirtual().name("policy-folder-watch").start(this::watchLoop);
long reconcileSeconds = applicationProperties.getPolicies().getWatchReconcileSeconds();
reconciler =
Executors.newSingleThreadScheduledExecutor(
Thread.ofVirtual().name("policy-folder-reconcile-", 0).factory());
// First reconcile runs immediately so pre-existing files are picked up at startup.
reconciler.scheduleAtFixedRate(this::safeReconcile, 0, reconcileSeconds, TimeUnit.SECONDS);
log.info("Folder-watch trigger started (reconcile every {}s)", reconcileSeconds);
}
@Override
public synchronized void stop() {
running = false;
if (reconciler != null) {
reconciler.shutdownNow();
reconciler = null;
}
if (watchService != null) {
try {
watchService.close(); // wakes the watch loop with ClosedWatchServiceException
} catch (IOException e) {
log.debug("Error closing folder watch service: {}", e.getMessage());
}
watchService = null;
}
keysByDir.clear();
dirByKey.clear();
}
private void watchLoop() {
// Capture the service once: stop() may null the field, and a local avoids racing that to an
// NPE (close() still wakes take()/poll() on this same instance).
WatchService watcher = watchService;
if (watcher == null) {
return;
}
while (running) {
WatchKey first;
try {
first = watcher.take();
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
return;
} catch (ClosedWatchServiceException e) {
return;
}
runForChangedDirs(drainBurst(watcher, first));
}
}
/**
* Collect the directories touched by {@code first} and any further events that arrive within
* the quiet period, so a burst of file-system events becomes a single set of affected
* directories. The event kinds are irrelevant: any event on a watched dir just means "go look".
*/
private Set<Path> drainBurst(WatchService watcher, WatchKey first) {
long quietPeriodMs = applicationProperties.getPolicies().getWatchQuietPeriodMs();
Set<Path> changed = new HashSet<>();
WatchKey key = first;
while (key != null) {
key.pollEvents();
Path dir = dirByKey.get(key);
if (dir != null) {
changed.add(dir);
}
key.reset();
try {
key = watcher.poll(quietPeriodMs, TimeUnit.MILLISECONDS);
} catch (ClosedWatchServiceException | InterruptedException e) {
break;
}
}
return changed;
}
/** Run every folder-watch policy that draws from one of the changed directories. */
void runForChangedDirs(Set<Path> changedDirs) {
if (changedDirs.isEmpty()) {
return;
}
for (Policy policy : policyStore.findByTriggerType(TYPE)) {
List<Path> dirs;
try {
dirs = watchDirsOf(policy);
} catch (RuntimeException e) {
log.warn(
"Folder-watch policy {} is misconfigured: {}", policy.id(), e.getMessage());
continue;
}
if (dirs.stream().anyMatch(changedDirs::contains)) {
log.debug("Folder-watch policy {} ({}) saw activity", policy.id(), policy.name());
policyRunner.run(policy);
}
}
}
private void safeReconcile() {
try {
syncRegistrations();
runAll();
} catch (RuntimeException e) {
log.error("Folder-watch reconcile failed: {}", e.getMessage(), e);
}
}
/** The reconcile safety net: run every folder-watch policy regardless of watch events. */
void runAll() {
for (Policy policy : policyStore.findByTriggerType(TYPE)) {
try {
policyRunner.run(policy);
} catch (RuntimeException e) {
log.warn(
"Folder-watch reconcile run failed for policy {}: {}",
policy.id(),
e.getMessage());
}
}
}
/**
* Bring the set of watched directories in line with the current folder-watch policies: register
* newly required directories that exist on disk, and cancel ones no longer wanted.
*/
synchronized void syncRegistrations() {
if (watchService == null) {
return;
}
Set<Path> desired = desiredDirs();
keysByDir
.entrySet()
.removeIf(
entry -> {
if (desired.contains(entry.getKey())) {
return false;
}
entry.getValue().cancel();
dirByKey.remove(entry.getValue());
return true;
});
for (Path dir : desired) {
if (keysByDir.containsKey(dir)) {
continue;
}
try {
WatchKey key = dir.register(watchService, ENTRY_CREATE, ENTRY_MODIFY);
keysByDir.put(dir, key);
dirByKey.put(key, dir);
log.info("Watching {} for folder-watch policies", dir);
} catch (IOException | RuntimeException e) {
log.warn("Could not watch {}: {}", dir, e.getMessage());
}
}
}
/** The directories currently registered with the watch service. Visible for tests. */
Set<Path> watchedDirs() {
return Set.copyOf(keysByDir.keySet());
}
/** Every existing directory any current folder-watch policy wants watched. */
private Set<Path> desiredDirs() {
Set<Path> dirs = new HashSet<>();
for (Policy policy : policyStore.findByTriggerType(TYPE)) {
try {
for (Path dir : watchDirsOf(policy)) {
if (Files.isDirectory(dir)) {
dirs.add(dir);
}
}
} catch (RuntimeException e) {
log.warn(
"Folder-watch policy {} is misconfigured: {}", policy.id(), e.getMessage());
}
}
return dirs;
}
/**
* The normalised, absolute directories this policy's sources expose to watch. Normalisation
* makes registration keys and event-time matching comparable regardless of how the path was
* configured.
*/
private List<Path> watchDirsOf(Policy policy) {
List<Path> dirs = new ArrayList<>();
for (InputSpec spec : policy.sources()) {
InputSource source = sourceFor(spec);
if (source == null) {
continue;
}
for (Path dir : source.watchTargets(spec)) {
dirs.add(dir.toAbsolutePath().normalize());
}
}
return dirs;
}
private InputSource sourceFor(InputSpec spec) {
return inputSources.stream()
.filter(source -> source.supports(spec))
.findFirst()
.orElse(null);
}
}
@@ -1,41 +0,0 @@
package stirling.software.proprietary.policy.trigger;
import org.springframework.stereotype.Service;
import lombok.RequiredArgsConstructor;
import stirling.software.proprietary.policy.engine.PolicyEngine;
import stirling.software.proprietary.policy.engine.PolicyRunHandle;
import stirling.software.proprietary.policy.model.PipelineDefinition;
import stirling.software.proprietary.policy.model.Policy;
import stirling.software.proprietary.policy.model.PolicyInputs;
import stirling.software.proprietary.policy.progress.PolicyProgressListener;
/**
* Runs policies on demand, in response to a request (the {@code PolicyController} endpoints, an AI,
* or another automation). It is the request-driven trigger: no background lifecycle, it just
* forwards to the engine. Any policy can be run manually regardless of its configured trigger type.
*/
@Service
@RequiredArgsConstructor
public class ManualTrigger implements PolicyTrigger {
private final PolicyEngine policyEngine;
@Override
public String type() {
return "manual";
}
/** Run a stored policy immediately and return its run handle. */
public PolicyRunHandle run(
Policy policy, PolicyInputs inputs, PolicyProgressListener listener) {
return policyEngine.runPolicy(policy, inputs, listener);
}
/** Run an ad-hoc pipeline (no stored policy), e.g. for AI or Automate one-offs. */
public PolicyRunHandle fire(
PipelineDefinition definition, PolicyInputs inputs, PolicyProgressListener listener) {
return policyEngine.submit(definition, inputs, listener);
}
}
@@ -1,22 +1,36 @@
package stirling.software.proprietary.policy.trigger;
import stirling.software.proprietary.policy.model.Policy;
/**
* Activates policies of one trigger type. A trigger owns a {@link #type()} (matching {@code
* TriggerConfig.type()}); when its condition fires it runs the relevant {@code Policy} through the
* {@code PolicyEngine}.
* An automatic trigger: the thing that decides <em>when</em> a policy runs without a person asking.
* A trigger owns a {@link #type()} (matching {@code TriggerConfig.type()}); when its condition
* fires it hands the policy to the {@code PolicyRunner}, which pulls the policy's sources and
* starts the runs. A trigger never resolves sources itself.
*
* <p>Background triggers (folder watcher, schedule) are driven by configuration: on {@link
* #start()} they begin watching/scheduling for the policies returned by {@code
* PolicyStore.findByTriggerType(type())}, and stop on {@link #stop()}. Request-driven triggers
* (manual) have no background lifecycle and run a policy directly in response to a call. New
* trigger kinds are new beans of this type; the engine and the {@code Policy} model do not change.
* <p>Triggers are background, configuration-driven beans (schedule, and in future webhook or
* folder-watch): on {@link #start()} they begin watching/scheduling for the policies returned by
* {@code PolicyStore.findByTriggerType(type())}, and stop on {@link #stop()}. New trigger kinds are
* new beans of this type; the runner and the {@code Policy} model do not change.
*
* <p>Manual running is not a trigger - every policy can always be run on demand via the {@code
* PolicyRunner} regardless of whether it has a trigger.
*/
public interface PolicyTrigger {
/** Stable identifier for this trigger kind, matching {@code TriggerConfig.type()}. */
String type();
/** Begin activating policies of this type (e.g. start a folder watcher). No-op for manual. */
/**
* Check that this trigger is usable for the given policy, throwing {@link
* IllegalArgumentException} if not. Called when a policy is saved so misconfiguration fails
* fast rather than at fire time. Receives the whole {@link Policy} (not just its {@code
* TriggerConfig}) so a trigger whose firing depends on the policy's sources (folder-watch) can
* assert that relationship; most triggers only inspect {@code policy.trigger()}.
*/
default void validate(Policy policy) {}
/** Begin activating policies of this type (e.g. start the schedule sweep). */
default void start() {}
/** Stop activating and release any resources. */
@@ -0,0 +1,56 @@
package stirling.software.proprietary.policy.trigger;
import java.util.List;
import org.springframework.context.SmartLifecycle;
import org.springframework.stereotype.Service;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
/**
* Starts and stops every {@link PolicyTrigger} with the application lifecycle. Background triggers
* (schedule, and future folder/S3) begin watching on {@link #start()} and release resources on
* {@link #stop()}; request-driven triggers (manual) are no-ops.
*
* <p>This is the single activation point for triggers - a new background trigger only has to be a
* {@link PolicyTrigger} bean.
*/
@Slf4j
@Service
@RequiredArgsConstructor
public class PolicyTriggerManager implements SmartLifecycle {
private final List<PolicyTrigger> triggers;
private volatile boolean running;
@Override
public void start() {
for (PolicyTrigger trigger : triggers) {
try {
trigger.start();
} catch (RuntimeException e) {
log.error("Failed to start trigger '{}': {}", trigger.type(), e.getMessage(), e);
}
}
running = true;
}
@Override
public void stop() {
for (PolicyTrigger trigger : triggers) {
try {
trigger.stop();
} catch (RuntimeException e) {
log.error("Failed to stop trigger '{}': {}", trigger.type(), e.getMessage(), e);
}
}
running = false;
}
@Override
public boolean isRunning() {
return running;
}
}
@@ -0,0 +1,158 @@
package stirling.software.proprietary.policy.trigger;
import java.time.Instant;
import java.time.ZoneId;
import java.time.ZoneOffset;
import java.time.ZonedDateTime;
import java.util.Map;
import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.Executors;
import java.util.concurrent.ScheduledExecutorService;
import java.util.concurrent.TimeUnit;
import org.springframework.stereotype.Service;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import stirling.software.common.model.ApplicationProperties;
import stirling.software.proprietary.policy.engine.PolicyRunner;
import stirling.software.proprietary.policy.model.Policy;
import stirling.software.proprietary.policy.model.Schedule;
import stirling.software.proprietary.policy.store.PolicyStore;
import tools.jackson.databind.ObjectMapper;
/**
* Fires policies on a {@link Schedule}. On {@link #start()} it sweeps on a fixed interval; each
* sweep finds the enabled "schedule" policies and runs any whose next firing has come due since it
* last fired.
*
* <p>The trigger only decides <em>when</em>: once a policy is due it hands it to the {@link
* PolicyRunner}, which pulls from the policy's configured sources and starts the runs. The trigger
* knows nothing about folders, buckets, or how many runs a sweep produces.
*
* <p>Caveat: last-fire times are tracked <b>in memory</b>, so this assumes a single node and resets
* on restart; cluster-wide coordination (leader election) is a follow-up.
*/
@Slf4j
@Service
@RequiredArgsConstructor
public class ScheduleTrigger implements PolicyTrigger {
private static final String TYPE = "schedule";
private final PolicyStore policyStore;
private final PolicyRunner policyRunner;
private final ObjectMapper objectMapper;
private final ApplicationProperties applicationProperties;
private final Map<String, Instant> lastFiredByPolicy = new ConcurrentHashMap<>();
private volatile ScheduledExecutorService scheduler;
@Override
public String type() {
return TYPE;
}
@Override
public void validate(Policy policy) {
ScheduleConfig.from(objectMapper, policy.trigger().options());
}
@Override
public synchronized void start() {
if (scheduler != null) {
return;
}
long sweepSeconds = applicationProperties.getPolicies().getScheduleSweepSeconds();
scheduler =
Executors.newSingleThreadScheduledExecutor(
Thread.ofVirtual().name("policy-schedule-", 0).factory());
scheduler.scheduleAtFixedRate(
this::safeSweep, sweepSeconds, sweepSeconds, TimeUnit.SECONDS);
log.info("Schedule trigger started (sweep every {}s)", sweepSeconds);
}
@Override
public synchronized void stop() {
if (scheduler != null) {
scheduler.shutdownNow();
scheduler = null;
}
}
private void safeSweep() {
try {
sweep(Instant.now());
} catch (RuntimeException e) {
log.error("Schedule sweep failed: {}", e.getMessage(), e);
}
}
/** Fire every scheduled policy that is due as of {@code now}. Package-visible for testing. */
void sweep(Instant now) {
for (Policy policy : policyStore.findByTriggerType(TYPE)) {
ScheduleConfig config;
try {
config = ScheduleConfig.from(objectMapper, policy.trigger().options());
} catch (IllegalArgumentException e) {
log.warn("Scheduled policy {} is misconfigured: {}", policy.id(), e.getMessage());
continue;
}
// First time we see a policy, baseline its last-fire to now so it does not fire
// immediately; subsequent sweeps fire it once its next firing has passed.
Instant last = lastFiredByPolicy.computeIfAbsent(policy.id(), id -> now);
ZonedDateTime next = config.schedule().nextAfter(last.atZone(config.zone()));
if (!next.toInstant().isAfter(now)) {
lastFiredByPolicy.put(policy.id(), now);
log.info("Scheduled policy {} ({}) is due", policy.id(), policy.name());
policyRunner.run(policy);
}
}
}
/**
* The typed, validated form of a schedule trigger's options: the {@link Schedule} and the zone
* its wall-clock kinds are evaluated in (default UTC). Construction fails for a missing/invalid
* schedule or zone.
*/
record ScheduleConfig(Schedule schedule, ZoneId zone) {
private static final String SCHEDULE_OPTION = "schedule";
private static final String ZONE_OPTION = "zone";
static ScheduleConfig from(ObjectMapper mapper, Map<String, Object> options) {
Object scheduleNode = options.get(SCHEDULE_OPTION);
if (scheduleNode == null) {
throw new IllegalArgumentException("schedule trigger requires a 'schedule'");
}
Schedule schedule;
try {
schedule = mapper.convertValue(scheduleNode, Schedule.class);
} catch (RuntimeException e) {
throw new IllegalArgumentException("invalid schedule: " + rootMessage(e), e);
}
ZoneId zone = ZoneOffset.UTC;
Object zoneNode = options.get(ZONE_OPTION);
if (zoneNode != null && !zoneNode.toString().isBlank()) {
try {
zone = ZoneId.of(zoneNode.toString());
} catch (RuntimeException e) {
throw new IllegalArgumentException("invalid zone '" + zoneNode + "'");
}
}
return new ScheduleConfig(schedule, zone);
}
private static String rootMessage(Throwable t) {
Throwable cause = t;
while (cause.getCause() != null) {
cause = cause.getCause();
}
return cause.getMessage();
}
}
}
@@ -66,6 +66,7 @@ import tools.jackson.databind.ObjectMapper;
public class AiWorkflowService {
private static final String DOCUMENTS_ENDPOINT = "/api/v1/documents";
private static final String PDF_TO_MARKDOWN_ENDPOINT = "/api/v1/convert/pdf/markdown";
private final CustomPDFDocumentFactory pdfDocumentFactory;
private final AiEngineClient aiEngineClient;
@@ -194,6 +195,7 @@ public class AiWorkflowService {
return switch (response.getOutcome()) {
case NEED_CONTENT -> onNeedContent(response, filesById, request, listener);
case NEED_INGEST -> onNeedIngest(response, filesById, request, listener);
case CONVERT_MARKDOWN -> onConvertMarkdown(response, filesById, listener);
case TOOL_CALL -> onToolCall(response, filesById, listener);
case PLAN -> onPlan(response, filesById, request, listener);
case ANSWER -> onAnswer(response, filesById, request, listener);
@@ -330,6 +332,77 @@ public class AiWorkflowService {
return new WorkflowState.Pending(nextRequest);
}
/**
* Deterministically convert each requested PDF to Markdown via the {@code
* /convert/pdf/markdown} endpoint (backed by {@code PdfMarkdownConverter}) and return the
* {@code .md} file(s) as a completed result. No AI resume — the conversion output is the final
* answer.
*/
private WorkflowState onConvertMarkdown(
AiWorkflowResponse response,
Map<String, MultipartFile> filesById,
ProgressListener listener) {
List<AiFile> filesToConvert = response.getFilesToIngest();
if (filesToConvert == null || filesToConvert.isEmpty()) {
return new WorkflowState.Terminal(
cannotContinue(
"AI engine requested markdown conversion without listing any files."));
}
try {
List<Resource> resultFiles = new ArrayList<>();
List<String> inputNames = new ArrayList<>();
for (int i = 0; i < filesToConvert.size(); i++) {
AiFile file = filesToConvert.get(i);
MultipartFile multipartFile = filesById.get(file.getId());
if (multipartFile == null) {
return new WorkflowState.Terminal(
cannotContinue(
"AI engine requested markdown conversion for unknown file: "
+ file.getName()));
}
listener.onProgress(
AiWorkflowProgressEvent.executingTool(
PDF_TO_MARKDOWN_ENDPOINT, i + 1, filesToConvert.size()));
Resource input = toResource(multipartFile);
PipelineDefinition definition =
new PipelineDefinition(
"convert-markdown",
List.of(new PipelineStep(PDF_TO_MARKDOWN_ENDPOINT, Map.of())),
null);
PolicyExecutionResult result =
policyExecutor.execute(
definition,
PolicyInputs.of(List.of(input)),
PolicyProgressListener.NOOP);
resultFiles.addAll(result.files());
inputNames.add(multipartFile.getOriginalFilename());
}
return new WorkflowState.Terminal(
buildCompletedResponse(null, resultFiles, inputNames, null));
} catch (InternalApiTimeoutException e) {
log.error("PDF to Markdown conversion timed out: {}", e.getMessage());
return new WorkflowState.Terminal(
cannotContinue(toolTimeoutMessage(PDF_TO_MARKDOWN_ENDPOINT, e)));
} catch (Exception e) {
log.error("Failed to convert PDF to Markdown: {}", e.getMessage(), e);
return new WorkflowState.Terminal(
cannotContinue(toolFailureMessage(PDF_TO_MARKDOWN_ENDPOINT, e)));
}
}
private Resource toResource(MultipartFile file) throws IOException {
TempFile tempFile = tempFileManager.createManagedTempFile("ai-workflow");
file.transferTo(tempFile.getPath());
final String originalName = Filenames.toSimpleFileName(file.getOriginalFilename());
return new FileSystemResource(tempFile.getFile()) {
@Override
public String getFilename() {
return originalName;
}
};
}
private void ingestFile(AiFile file, MultipartFile multipartFile) throws IOException {
List<AiPageText> pages = new ArrayList<>();
try (PDDocument document = pdfDocumentFactory.load(multipartFile, true)) {
@@ -551,16 +624,7 @@ public class AiWorkflowService {
private List<Resource> toResources(Map<String, MultipartFile> filesById) throws IOException {
List<Resource> resources = new ArrayList<>();
for (MultipartFile file : filesById.values()) {
TempFile tempFile = tempFileManager.createManagedTempFile("ai-workflow");
file.transferTo(tempFile.getPath());
final String originalName = Filenames.toSimpleFileName(file.getOriginalFilename());
resources.add(
new FileSystemResource(tempFile.getFile()) {
@Override
public String getFilename() {
return originalName;
}
});
resources.add(toResource(file));
}
return resources;
}
@@ -30,11 +30,7 @@ import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import stirling.software.SPDF.pdf.parser.PageImageLocator;
import stirling.software.SPDF.pdf.parser.PdfIngester;
import stirling.software.SPDF.pdf.parser.PdfModels.ParsedPage;
import stirling.software.SPDF.pdf.parser.PdfModels.RawLine;
import stirling.software.SPDF.pdf.parser.PdfModels.TableFragment;
import stirling.software.SPDF.pdf.parser.PdfModels.TextFragment;
import stirling.software.SPDF.pdf.parser.TabulaTableParser;
import stirling.software.common.util.ExceptionUtils;
import stirling.software.common.util.PdfUtils;
@@ -50,7 +46,6 @@ import stirling.software.proprietary.model.api.ai.FolioType;
public class PdfContentExtractor {
private final TabulaTableParser tabulaTableParser;
private final PdfIngester pdfIngester;
private static final int MAX_CHARACTERS_PER_PAGE = 4_000;
@@ -196,8 +191,6 @@ public class PdfContentExtractor {
case PAGE_TEXT, FULL_TEXT ->
Optional.<PdfContentResult>ofNullable(
extractText(lf, fileReq, remainingPages, remainingCharacters));
case PAGE_LAYOUT ->
Optional.<PdfContentResult>ofNullable(extractPageLayout(lf, remainingPages));
default -> {
log.warn(
"Content type {} not yet implemented, skipping for {}",
@@ -222,35 +215,6 @@ public class PdfContentExtractor {
return extracted.isEmpty() ? null : buildExtractedFileText(lf.fileName(), extracted);
}
private PageLayoutFileResult extractPageLayout(LoadedFile lf, int maxPages) throws IOException {
List<ParsedPage> parsedPages = pdfIngester.parse(lf.document(), maxPages);
List<LayoutPage> pages = new ArrayList<>();
for (ParsedPage pp : parsedPages) {
if (pp.layoutLines().isEmpty()) continue;
List<LayoutLine> lines = new ArrayList<>();
for (RawLine rawLine : pp.layoutLines()) {
List<LayoutFragment> fragments = new ArrayList<>();
for (TextFragment tf : rawLine.fragments()) {
fragments.add(
new LayoutFragment(
tf.text(),
tf.bounds().x(),
tf.bounds().y(),
tf.bounds().width(),
tf.fontSize(),
tf.bold()));
}
lines.add(new LayoutLine(rawLine.bounds().y(), fragments));
}
pages.add(new LayoutPage(pp.pageNumber(), lines));
}
if (pages.isEmpty()) return null;
PageLayoutFileResult result = new PageLayoutFileResult();
result.setFileName(lf.fileName());
result.setPages(pages);
return result;
}
private WorkflowArtifact buildArtifact(ArtifactKind kind, List<PdfContentResult> results) {
return switch (kind) {
case EXTRACTED_TEXT -> {
@@ -258,11 +222,6 @@ public class PdfContentExtractor {
artifact.setFiles(results.stream().map(ExtractedFileText.class::cast).toList());
yield artifact;
}
case PAGE_LAYOUT -> {
PageLayoutArtifact artifact = new PageLayoutArtifact();
artifact.setFiles(results.stream().map(PageLayoutFileResult.class::cast).toList());
yield artifact;
}
case TOOL_REPORT ->
throw new IllegalArgumentException(
"TOOL_REPORT artifacts are not produced by PdfContentExtractor");
@@ -569,7 +528,6 @@ public class PdfContentExtractor {
*/
enum ArtifactKind {
EXTRACTED_TEXT("extracted_text"),
PAGE_LAYOUT("page_layout"),
TOOL_REPORT("tool_report");
private final String value;
@@ -633,40 +591,4 @@ public class PdfContentExtractor {
this.report = report;
}
}
// Serialization contract with the Python engine — see PageLayoutArtifactContractTest.
/** One text fragment with its bounding-box geometry and font properties. */
record LayoutFragment(
String text, float x, float y, float width, float fontSize, boolean bold) {}
/** A visual line on the page: y-coordinate and all fragments on that line. */
record LayoutLine(float y, List<LayoutFragment> fragments) {}
/** All layout lines for a single page. */
record LayoutPage(int pageNumber, List<LayoutLine> lines) {}
/** Page layout data for one file, as a PdfContentResult. */
@Data
static final class PageLayoutFileResult implements PdfContentResult {
private String fileName;
private List<LayoutPage> pages = new ArrayList<>();
@Override
public ArtifactKind getArtifactKind() {
return ArtifactKind.PAGE_LAYOUT;
}
@Override
public int pagesConsumed() {
return pages.size();
}
}
/** Artifact carrying full spatial page layout for all input files. */
@Data
static final class PageLayoutArtifact implements WorkflowArtifact {
private final ArtifactKind kind = ArtifactKind.PAGE_LAYOUT;
private List<PageLayoutFileResult> files = new ArrayList<>();
}
}
@@ -0,0 +1,99 @@
package stirling.software.proprietary.policy.config;
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 java.nio.file.Path;
import java.util.List;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.io.TempDir;
import org.springframework.core.env.StandardEnvironment;
import stirling.software.common.configuration.InstallationPathConfig;
import stirling.software.common.model.ApplicationProperties;
import stirling.software.proprietary.policy.model.InputSpec;
import stirling.software.proprietary.policy.model.OutputSpec;
import stirling.software.proprietary.policy.model.Policy;
/**
* Tests for {@link FolderAccessGuard}: folder access is fail-closed, confined to the configured
* allowed roots, never reaches Stirling's own config directory, and is off entirely under SaaS.
*/
class FolderAccessGuardTest {
@TempDir Path tempDir;
private FolderAccessGuard guard(List<String> allowedRoots, String... activeProfiles) {
ApplicationProperties properties = new ApplicationProperties();
properties.getPolicies().setAllowedFolderRoots(allowedRoots);
StandardEnvironment environment = new StandardEnvironment();
environment.setActiveProfiles(activeProfiles);
return new FolderAccessGuard(properties, environment);
}
@Test
void permitsAndNormalisesADirectoryWithinAnAllowedRoot() {
FolderAccessGuard guard = guard(List.of(tempDir.toString()));
Path within = tempDir.resolve("inbox");
assertEquals(within.toAbsolutePath().normalize(), guard.requirePermitted(within));
}
@Test
void rejectsADirectoryOutsideEveryAllowedRoot() {
FolderAccessGuard guard = guard(List.of(tempDir.toString()));
assertThrows(
IllegalArgumentException.class,
() -> guard.requirePermitted(tempDir.resolveSibling("elsewhere")));
}
@Test
void rejectsTraversalThatWalksOutOfAnAllowedRoot() {
FolderAccessGuard guard = guard(List.of(tempDir.toString()));
assertThrows(
IllegalArgumentException.class,
() -> guard.requirePermitted(tempDir.resolve("..").resolve("escaped")));
}
@Test
void rejectsEverythingWhenNoRootsAreConfigured() {
FolderAccessGuard guard = guard(List.of());
assertThrows(IllegalArgumentException.class, () -> guard.requirePermitted(tempDir));
}
@Test
void rejectsTheStirlingConfigDirectoryEvenWhenItWouldBeInsideAnAllowedRoot() {
Path configDir =
Path.of(InstallationPathConfig.getConfigPath()).toAbsolutePath().normalize();
// Allow the config dir's parent, so only the protected-path rule can reject it.
FolderAccessGuard guard = guard(List.of(configDir.getParent().toString()));
assertThrows(
IllegalArgumentException.class,
() -> guard.requirePermitted(configDir.resolve("settings.yml")));
}
@Test
void refusesAllFolderAccessUnderTheSaasProfile() {
FolderAccessGuard guard = guard(List.of(tempDir.toString()), "saas");
assertThrows(IllegalArgumentException.class, () -> guard.requirePermitted(tempDir));
}
@Test
void usesFolderAccessDetectsFolderSourcesAndOutputs() {
FolderAccessGuard guard = guard(List.of(tempDir.toString()));
assertTrue(
guard.usesFolderAccess(
policy(List.of(InputSpec.folder("/in")), OutputSpec.inline())));
assertTrue(guard.usesFolderAccess(policy(List.of(), OutputSpec.folder("/out"))));
assertFalse(guard.usesFolderAccess(policy(List.of(), OutputSpec.inline())));
}
private static Policy policy(List<InputSpec> sources, OutputSpec output) {
return new Policy("p1", "p", "owner", true, null, sources, List.of(), output);
}
}
@@ -50,7 +50,6 @@ import stirling.software.proprietary.policy.model.Policy;
import stirling.software.proprietary.policy.model.PolicyInputs;
import stirling.software.proprietary.policy.model.PolicyRun;
import stirling.software.proprietary.policy.model.PolicyRunStatus;
import stirling.software.proprietary.policy.model.TriggerConfig;
import stirling.software.proprietary.policy.output.InlineOutputSink;
import stirling.software.proprietary.policy.progress.PolicyProgressListener;
@@ -93,7 +92,7 @@ class PolicyEngineTest {
toolMetadataService,
tempFileManager,
JsonMapper.builder().build());
registry = new PolicyRunRegistry(30);
registry = new PolicyRunRegistry(new ApplicationProperties());
InlineOutputSink sink = new InlineOutputSink(fileStorage);
engine =
new PolicyEngine(
@@ -190,7 +189,7 @@ class PolicyEngineTest {
"rotate",
"owner",
true,
TriggerConfig.manual(),
null,
List.of(new PipelineStep(ROTATE, Map.of())),
OutputSpec.inline());
@@ -12,6 +12,7 @@ import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import stirling.software.common.model.ApplicationProperties;
import stirling.software.proprietary.policy.model.PipelineDefinition;
import stirling.software.proprietary.policy.model.PolicyRun;
import stirling.software.proprietary.policy.model.WaitState;
@@ -25,7 +26,7 @@ class PolicyRunRegistryTest {
@BeforeEach
void setUp() {
registry = new PolicyRunRegistry(30);
registry = new PolicyRunRegistry(new ApplicationProperties());
}
@AfterEach
@@ -0,0 +1,159 @@
package stirling.software.proprietary.policy.engine;
import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertSame;
import static org.junit.jupiter.api.Assertions.assertTrue;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.eq;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.times;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.verifyNoInteractions;
import static org.mockito.Mockito.when;
import java.util.List;
import java.util.Map;
import java.util.concurrent.CompletableFuture;
import java.util.concurrent.atomic.AtomicBoolean;
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 stirling.software.proprietary.policy.input.InputSource;
import stirling.software.proprietary.policy.input.ResolvedInput;
import stirling.software.proprietary.policy.model.InputSpec;
import stirling.software.proprietary.policy.model.OutputSpec;
import stirling.software.proprietary.policy.model.PipelineStep;
import stirling.software.proprietary.policy.model.Policy;
import stirling.software.proprietary.policy.model.PolicyInputs;
import stirling.software.proprietary.policy.model.PolicyRun;
import stirling.software.proprietary.policy.model.PolicyRunStatus;
import stirling.software.proprietary.policy.progress.PolicyProgressListener;
/**
* Tests for {@link PolicyRunner}: the one place that turns a policy's sources into runs. Verifies
* it pulls every source, runs one job per unit of work, feeds each unit's completion hook the run
* outcome, and that a generator (no sources) still runs once.
*/
@ExtendWith(MockitoExtension.class)
class PolicyRunnerTest {
@Mock private PolicyEngine policyEngine;
@Mock private InputSource folderSource;
private PolicyRunner runner;
@BeforeEach
void setUp() {
runner = new PolicyRunner(policyEngine, List.of(folderSource));
}
@Test
void runsOnceWithNoFilesWhenThePolicyHasNoSources() {
Policy policy = policy(List.of());
when(policyEngine.runPolicy(eq(policy), any(), any()))
.thenReturn(new PolicyRunHandle("r", new CompletableFuture<>()));
runner.run(policy);
ArgumentCaptor<PolicyInputs> inputs = ArgumentCaptor.forClass(PolicyInputs.class);
verify(policyEngine).runPolicy(eq(policy), inputs.capture(), any());
assertTrue(inputs.getValue().primary().isEmpty());
}
@Test
void pullsEverySourceAndRunsOnePerUnitOfWork() throws Exception {
InputSpec spec = InputSpec.folder("/in");
Policy policy = policy(List.of(spec));
when(folderSource.supports(spec)).thenReturn(true);
when(folderSource.resolve(spec))
.thenReturn(
List.of(
ResolvedInput.of(PolicyInputs.of(List.of())),
ResolvedInput.of(PolicyInputs.of(List.of()))));
when(policyEngine.runPolicy(any(), any(), any()))
.thenReturn(new PolicyRunHandle("r", new CompletableFuture<>()));
runner.run(policy);
verify(policyEngine, times(2)).runPolicy(eq(policy), any(), any());
}
@Test
void feedsEachUnitsCompletionHookTheRunOutcome() throws Exception {
InputSpec spec = InputSpec.folder("/in");
Policy policy = policy(List.of(spec));
AtomicBoolean outcome = new AtomicBoolean(false);
ResolvedInput unit = new ResolvedInput(PolicyInputs.of(List.of()), outcome::set);
when(folderSource.supports(spec)).thenReturn(true);
when(folderSource.resolve(spec)).thenReturn(List.of(unit));
CompletableFuture<PolicyRun> completion = new CompletableFuture<>();
when(policyEngine.runPolicy(any(), any(), any()))
.thenReturn(new PolicyRunHandle("r", completion));
runner.run(policy);
PolicyRun run = mock(PolicyRun.class);
when(run.getStatus()).thenReturn(PolicyRunStatus.COMPLETED);
completion.complete(run);
assertTrue(outcome.get());
}
@Test
void reportsFailureToTheCompletionHookWhenTheRunDoesNotComplete() throws Exception {
InputSpec spec = InputSpec.folder("/in");
Policy policy = policy(List.of(spec));
AtomicBoolean outcome = new AtomicBoolean(true);
ResolvedInput unit = new ResolvedInput(PolicyInputs.of(List.of()), outcome::set);
when(folderSource.supports(spec)).thenReturn(true);
when(folderSource.resolve(spec)).thenReturn(List.of(unit));
CompletableFuture<PolicyRun> completion = new CompletableFuture<>();
when(policyEngine.runPolicy(any(), any(), any()))
.thenReturn(new PolicyRunHandle("r", completion));
runner.run(policy);
completion.completeExceptionally(new RuntimeException("boom"));
assertFalse(outcome.get());
}
@Test
void skipsSourcesWithNoMatchingBean() {
InputSpec spec = new InputSpec("s3", Map.of());
Policy policy = policy(List.of(spec));
when(folderSource.supports(spec)).thenReturn(false);
runner.run(policy);
verifyNoInteractions(policyEngine);
}
@Test
void runWithSuppliedInputsBypassesSources() {
Policy policy = policy(List.of(InputSpec.folder("/in")));
PolicyInputs inputs = PolicyInputs.of(List.of());
PolicyRunHandle handle = new PolicyRunHandle("r", new CompletableFuture<>());
when(policyEngine.runPolicy(policy, inputs, PolicyProgressListener.NOOP))
.thenReturn(handle);
assertSame(handle, runner.runWith(policy, inputs, PolicyProgressListener.NOOP));
verifyNoInteractions(folderSource);
}
private static Policy policy(List<InputSpec> sources) {
return new Policy(
"p1",
"p",
"owner",
true,
null,
sources,
List.of(new PipelineStep("/api/v1/misc/compress-pdf", Map.of())),
OutputSpec.inline());
}
}
@@ -0,0 +1,114 @@
package stirling.software.proprietary.policy.engine;
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.Mockito.doThrow;
import static org.mockito.Mockito.never;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
import java.util.List;
import java.util.Map;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;
import stirling.software.proprietary.policy.input.InputSource;
import stirling.software.proprietary.policy.model.InputSpec;
import stirling.software.proprietary.policy.model.OutputSpec;
import stirling.software.proprietary.policy.model.Policy;
import stirling.software.proprietary.policy.model.TriggerConfig;
import stirling.software.proprietary.policy.output.PolicyOutputSink;
import stirling.software.proprietary.policy.trigger.PolicyTrigger;
/** Tests for {@link PolicyValidator}: routes each facet to its handler and surfaces failures. */
@ExtendWith(MockitoExtension.class)
class PolicyValidatorTest {
@Mock private PolicyTrigger trigger;
@Mock private InputSource inputSource;
@Mock private PolicyOutputSink outputSink;
private PolicyValidator validator;
@BeforeEach
void setUp() {
validator =
new PolicyValidator(List.of(trigger), List.of(inputSource), List.of(outputSink));
}
@Test
void delegatesEachFacetToItsHandler() {
when(trigger.type()).thenReturn("schedule");
when(inputSource.supports(any())).thenReturn(true);
when(outputSink.supports(any())).thenReturn(true);
Policy policy = policy("schedule");
validator.validate(policy);
verify(trigger).validate(policy);
verify(inputSource).validate(policy.sources().get(0));
verify(outputSink).validate(policy.output());
}
@Test
void skipsTriggerValidationForAManualOnlyPolicy() {
when(inputSource.supports(any())).thenReturn(true);
when(outputSink.supports(any())).thenReturn(true);
validator.validate(manualOnly());
verify(trigger, never()).validate(any());
}
@Test
void surfacesAnInvalidConfigFromAHandler() {
when(trigger.type()).thenReturn("schedule");
doThrow(new IllegalArgumentException("invalid schedule")).when(trigger).validate(any());
IllegalArgumentException ex =
assertThrows(
IllegalArgumentException.class,
() -> validator.validate(policy("schedule")));
assertTrue(ex.getMessage().contains("schedule"));
}
@Test
void rejectsAnUnknownTriggerType() {
when(trigger.type()).thenReturn("schedule");
IllegalArgumentException ex =
assertThrows(
IllegalArgumentException.class,
() -> validator.validate(policy("mystery")));
assertTrue(ex.getMessage().contains("unknown trigger type"));
}
private static Policy policy(String triggerType) {
return new Policy(
"p1",
"p",
"owner",
true,
new TriggerConfig(triggerType, Map.of()),
List.of(InputSpec.folder("/in")),
List.of(),
OutputSpec.inline());
}
private static Policy manualOnly() {
return new Policy(
"p1",
"p",
"owner",
true,
null,
List.of(InputSpec.folder("/in")),
List.of(),
OutputSpec.inline());
}
}
@@ -0,0 +1,137 @@
package stirling.software.proprietary.policy.input;
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.Mockito.lenient;
import java.io.IOException;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.List;
import java.util.Map;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.junit.jupiter.api.io.TempDir;
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;
import org.springframework.core.env.StandardEnvironment;
import stirling.software.common.model.ApplicationProperties;
import stirling.software.common.util.FileReadinessChecker;
import stirling.software.proprietary.policy.config.FolderAccessGuard;
import stirling.software.proprietary.policy.model.InputSpec;
/** Tests for {@link FolderInputSource}: consume (claim + route) and snapshot (read-only) modes. */
@ExtendWith(MockitoExtension.class)
class FolderInputSourceTest {
@Mock private FileReadinessChecker readinessChecker;
@TempDir Path tempDir;
private FolderInputSource source;
@BeforeEach
void setUp() {
ApplicationProperties properties = new ApplicationProperties();
properties.getPolicies().setAllowedFolderRoots(List.of(tempDir.toString()));
FolderAccessGuard guard = new FolderAccessGuard(properties, new StandardEnvironment());
source = new FolderInputSource(readinessChecker, guard);
// Lenient: the missing-dir / nonexistent-dir cases return before any readiness check.
lenient().when(readinessChecker.isReady(any())).thenReturn(true);
}
@Test
void consumeClaimsFilesAndRoutesToDoneOnSuccess() throws IOException {
Path inputDir = Files.createDirectories(tempDir.resolve("in"));
Files.writeString(inputDir.resolve("doc.pdf"), "data");
List<ResolvedInput> work = source.resolve(InputSpec.folder(inputDir.toString()));
assertEquals(1, work.size());
assertEquals(1, work.get(0).inputs().primary().size());
// Claimed out of the input dir.
assertFalse(Files.exists(inputDir.resolve("doc.pdf")));
assertTrue(
Files.exists(
inputDir.resolve(".stirling").resolve("processing").resolve("doc.pdf")));
work.get(0).onComplete().accept(true);
assertTrue(Files.exists(inputDir.resolve(".stirling").resolve("done").resolve("doc.pdf")));
assertFalse(
Files.exists(
inputDir.resolve(".stirling").resolve("processing").resolve("doc.pdf")));
}
@Test
void consumeRoutesToErrorOnFailure() throws IOException {
Path inputDir = Files.createDirectories(tempDir.resolve("in"));
Files.writeString(inputDir.resolve("doc.pdf"), "data");
List<ResolvedInput> work = source.resolve(InputSpec.folder(inputDir.toString()));
work.get(0).onComplete().accept(false);
assertTrue(Files.exists(inputDir.resolve(".stirling").resolve("error").resolve("doc.pdf")));
}
@Test
void snapshotReadsWithoutClaiming() throws IOException {
Path inputDir = Files.createDirectories(tempDir.resolve("in"));
Files.writeString(inputDir.resolve("doc.pdf"), "data");
List<ResolvedInput> work =
source.resolve(
new InputSpec(
"folder",
Map.of("directory", inputDir.toString(), "mode", "snapshot")));
assertEquals(1, work.size());
// Not moved, and completing the run is a no-op.
assertTrue(Files.exists(inputDir.resolve("doc.pdf")));
work.get(0).onComplete().accept(true);
assertTrue(Files.exists(inputDir.resolve("doc.pdf")));
}
@Test
void missingDirectoryOptionFails() {
assertThrows(
IllegalArgumentException.class,
() -> source.resolve(new InputSpec("folder", Map.of())));
}
@Test
void nonexistentDirectoryYieldsNoWork() throws IOException {
List<ResolvedInput> work =
source.resolve(InputSpec.folder(tempDir.resolve("nope").toString()));
assertTrue(work.isEmpty());
}
@Test
void validateRejectsMissingDirectory() {
assertThrows(
IllegalArgumentException.class,
() -> source.validate(new InputSpec("folder", Map.of())));
}
@Test
void rejectsADirectoryOutsideTheAllowedRoots() {
Path outside = tempDir.resolveSibling("not-allowed");
assertThrows(
IllegalArgumentException.class,
() -> source.resolve(InputSpec.folder(outside.toString())));
assertThrows(
IllegalArgumentException.class,
() -> source.validate(InputSpec.folder(outside.toString())));
}
@Test
void watchTargetsIsTheConfiguredDirectory() {
Path inputDir = tempDir.resolve("in");
assertEquals(List.of(inputDir), source.watchTargets(InputSpec.folder(inputDir.toString())));
}
}
@@ -0,0 +1,105 @@
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.assertThrows;
import static org.junit.jupiter.api.Assertions.assertTrue;
import java.io.IOException;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.List;
import java.util.Map;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.io.TempDir;
import org.springframework.core.env.StandardEnvironment;
import org.springframework.core.io.ByteArrayResource;
import org.springframework.core.io.Resource;
import stirling.software.common.model.ApplicationProperties;
import stirling.software.common.model.job.ResultFile;
import stirling.software.proprietary.policy.config.FolderAccessGuard;
import stirling.software.proprietary.policy.model.OutputSpec;
/** Tests for {@link FolderOutputSink}: outputs are written to the configured directory on disk. */
class FolderOutputSinkTest {
@TempDir Path tempDir;
private FolderOutputSink sink;
@BeforeEach
void setUp() {
ApplicationProperties properties = new ApplicationProperties();
properties.getPolicies().setAllowedFolderRoots(List.of(tempDir.toString()));
sink = new FolderOutputSink(new FolderAccessGuard(properties, new StandardEnvironment()));
}
@Test
void writesEachOutputToTheDirectory() throws IOException {
Path out = tempDir.resolve("out");
List<Resource> outputs = List.of(named("a.pdf", "aaa"), named("b.pdf", "bb"));
List<ResultFile> results =
sink.deliver("run-1", outputs, OutputSpec.folder(out.toString()));
assertEquals(2, results.size());
assertTrue(Files.exists(out.resolve("a.pdf")));
assertEquals("aaa", Files.readString(out.resolve("a.pdf")));
assertEquals("bb", Files.readString(out.resolve("b.pdf")));
}
@Test
void collidingNamesGetAUniqueSuffix() throws IOException {
Path out = tempDir.resolve("out");
List<Resource> outputs = List.of(named("a.pdf", "first"), named("a.pdf", "second"));
sink.deliver("run-1", outputs, OutputSpec.folder(out.toString()));
assertTrue(Files.exists(out.resolve("a.pdf")));
assertTrue(Files.exists(out.resolve("a (1).pdf")));
}
@Test
void missingDirectoryOptionIsRejected() {
OutputSpec noDir = new OutputSpec("folder", Map.of());
assertThrows(IllegalArgumentException.class, () -> sink.validate(noDir));
assertThrows(
IllegalArgumentException.class,
() -> sink.deliver("run-1", List.of(named("a.pdf", "x")), noDir));
}
@Test
void aDirectoryOutsideTheAllowedRootsIsRejected() {
OutputSpec outside = OutputSpec.folder(tempDir.resolveSibling("not-allowed").toString());
assertThrows(IllegalArgumentException.class, () -> sink.validate(outside));
assertThrows(
IllegalArgumentException.class,
() -> sink.deliver("run-1", List.of(named("a.pdf", "x")), outside));
}
@Test
void filenamesWithPathTraversalAreConfinedToTheDirectory() throws IOException {
Path out = tempDir.resolve("out");
List<Resource> outputs =
List.of(named("../escape.pdf", "x"), named("nested/deep.pdf", "y"));
sink.deliver("run-1", outputs, OutputSpec.folder(out.toString()));
// Each name is reduced to its bare form inside the target dir; nothing escapes.
assertTrue(Files.exists(out.resolve("escape.pdf")));
assertTrue(Files.exists(out.resolve("deep.pdf")));
assertFalse(Files.exists(tempDir.resolve("escape.pdf")));
}
private static ByteArrayResource named(String filename, String content) {
return new ByteArrayResource(content.getBytes()) {
@Override
public String getFilename() {
return filename;
}
};
}
}
@@ -28,7 +28,7 @@ class InProcessPolicyStoreTest {
@Test
void savedPolicyGetsAnIdAndIsRetrievable() {
Policy saved = store.save(policy(null, "compress", "manual", true));
Policy saved = store.save(policy(null, "compress", null, true));
assertNotNull(saved.id());
assertFalse(saved.id().isBlank());
@@ -37,7 +37,7 @@ class InProcessPolicyStoreTest {
@Test
void savingWithAnExistingIdUpdatesInPlace() {
Policy created = store.save(policy(null, "before", "manual", true));
Policy created = store.save(policy(null, "before", null, true));
store.save(
new Policy(
@@ -45,7 +45,7 @@ class InProcessPolicyStoreTest {
"after",
"owner",
true,
TriggerConfig.manual(),
null,
List.of(),
OutputSpec.inline()));
@@ -55,19 +55,20 @@ class InProcessPolicyStoreTest {
@Test
void findByTriggerTypeReturnsOnlyEnabledMatches() {
store.save(policy(null, "watch", "folder", true));
store.save(policy(null, "watch-disabled", "folder", false));
store.save(policy(null, "nightly", "schedule", true));
store.save(policy(null, "nightly-disabled", "schedule", false));
store.save(policy(null, "hooked", "webhook", true));
store.save(policy(null, "on-demand", null, true)); // manual-only: no trigger
List<Policy> folder = store.findByTriggerType("folder");
List<Policy> scheduled = store.findByTriggerType("schedule");
assertEquals(1, folder.size());
assertEquals("watch", folder.get(0).name());
assertEquals(1, scheduled.size());
assertEquals("nightly", scheduled.get(0).name());
}
@Test
void deleteRemovesThePolicy() {
Policy saved = store.save(policy(null, "p", "manual", true));
Policy saved = store.save(policy(null, "p", null, true));
assertTrue(store.delete(saved.id()));
assertTrue(store.get(saved.id()).isEmpty());
@@ -75,12 +76,14 @@ class InProcessPolicyStoreTest {
}
private static Policy policy(String id, String name, String triggerType, boolean enabled) {
TriggerConfig trigger =
triggerType == null ? null : new TriggerConfig(triggerType, Map.of());
return new Policy(
id,
name,
"owner",
enabled,
new TriggerConfig(triggerType, Map.of()),
trigger,
List.of(new PipelineStep("/api/v1/misc/compress-pdf", Map.of())),
OutputSpec.inline());
}
@@ -18,6 +18,7 @@ import org.mockito.ArgumentCaptor;
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;
import stirling.software.proprietary.policy.model.InputSpec;
import stirling.software.proprietary.policy.model.OutputSpec;
import stirling.software.proprietary.policy.model.PipelineStep;
import stirling.software.proprietary.policy.model.Policy;
@@ -53,7 +54,8 @@ class JpaPolicyStoreTest {
"compress incoming",
"alice",
true,
new TriggerConfig("folder", Map.of("path", "/in")),
new TriggerConfig("schedule", Map.of()),
List.of(InputSpec.folder("/in")),
List.of(new PipelineStep("/api/v1/misc/compress-pdf", Map.of())),
OutputSpec.inline()));
@@ -62,7 +64,7 @@ class JpaPolicyStoreTest {
verify(repository).save(captor.capture());
PolicyEntity entity = captor.getValue();
assertEquals(saved.id(), entity.getId());
assertEquals("folder", entity.getTriggerType());
assertEquals("schedule", entity.getTriggerType());
assertTrue(entity.isEnabled());
// The stored JSON round-trips back to an equal policy.
assertEquals(saved, objectMapper.readValue(entity.getPolicyJson(), Policy.class));
@@ -76,7 +78,7 @@ class JpaPolicyStoreTest {
"rotate",
"alice",
true,
TriggerConfig.manual(),
null, // manual-only: no automatic trigger
List.of(
new PipelineStep(
"/api/v1/general/rotate-pdf", Map.of("angle", 90))),
@@ -94,16 +96,16 @@ class JpaPolicyStoreTest {
"watch",
"alice",
true,
new TriggerConfig("folder", Map.of()),
new TriggerConfig("schedule", Map.of()),
List.of(new PipelineStep("/api/v1/misc/compress-pdf", Map.of())),
OutputSpec.inline());
when(repository.findByTriggerTypeAndEnabledTrue("folder"))
when(repository.findByTriggerTypeAndEnabledTrue("schedule"))
.thenReturn(List.of(entityFor(policy)));
List<Policy> folder = store.findByTriggerType("folder");
List<Policy> scheduled = store.findByTriggerType("schedule");
assertEquals(1, folder.size());
assertEquals("p1", folder.get(0).id());
assertEquals(1, scheduled.size());
assertEquals("p1", scheduled.get(0).id());
}
@Test
@@ -122,7 +124,7 @@ class JpaPolicyStoreTest {
entity.setName(policy.name());
entity.setOwner(policy.owner());
entity.setEnabled(policy.enabled());
entity.setTriggerType(policy.trigger().type());
entity.setTriggerType(policy.trigger() == null ? null : policy.trigger().type());
entity.setPolicyJson(objectMapper.writeValueAsString(policy));
return entity;
}
@@ -0,0 +1,178 @@
package stirling.software.proprietary.policy.trigger;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertThrows;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.Mockito.lenient;
import static org.mockito.Mockito.never;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.verifyNoInteractions;
import static org.mockito.Mockito.when;
import java.nio.file.FileSystems;
import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.WatchService;
import java.util.List;
import java.util.Map;
import java.util.Set;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.junit.jupiter.api.io.TempDir;
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;
import stirling.software.common.model.ApplicationProperties;
import stirling.software.proprietary.policy.engine.PolicyRunner;
import stirling.software.proprietary.policy.input.InputSource;
import stirling.software.proprietary.policy.model.InputSpec;
import stirling.software.proprietary.policy.model.OutputSpec;
import stirling.software.proprietary.policy.model.PipelineStep;
import stirling.software.proprietary.policy.model.Policy;
import stirling.software.proprietary.policy.model.TriggerConfig;
import stirling.software.proprietary.policy.store.PolicyStore;
/**
* Tests for {@link FolderWatchTrigger}'s dispatch logic via the package-visible {@code
* runForChangedDirs}/{@code runAll}, plus its cross-facet validation. The OS watch loop and
* scheduled reconcile are thin glue around these and are not exercised here (a real {@code
* WatchService} is timing-dependent), mirroring how {@link ScheduleTriggerTest} drives {@code
* sweep} directly. The folder source is stubbed to mirror {@code FolderInputSource.watchTargets}.
*/
@ExtendWith(MockitoExtension.class)
class FolderWatchTriggerTest {
@Mock private PolicyStore policyStore;
@Mock private PolicyRunner policyRunner;
@Mock private InputSource folderSource;
@TempDir Path tempDir;
private FolderWatchTrigger trigger;
@BeforeEach
void setUp() {
trigger =
new FolderWatchTrigger(
policyStore,
policyRunner,
List.of(folderSource),
new ApplicationProperties());
lenient().when(folderSource.supports(any())).thenReturn(true);
lenient()
.when(folderSource.watchTargets(any()))
.thenAnswer(
invocation -> {
InputSpec spec = invocation.getArgument(0);
Object dir = spec.options().get("directory");
if (dir == null) {
throw new IllegalArgumentException(
"folder input requires a 'directory' option");
}
return List.of(Path.of(dir.toString()));
});
}
@Test
void validateRejectsPolicyWithNoWatchableSource() {
assertThrows(
IllegalArgumentException.class,
() -> trigger.validate(folderWatch("p1", List.of())));
}
@Test
void validateAcceptsPolicyWithAFolderSource() {
trigger.validate(folderWatch("p1", List.of(InputSpec.folder("/in"))));
}
@Test
void runsOnlyPoliciesDrawingFromTheChangedDirectory() {
Policy a = folderWatch("a", List.of(InputSpec.folder("/in/a")));
Policy b = folderWatch("b", List.of(InputSpec.folder("/in/b")));
when(policyStore.findByTriggerType("folder-watch")).thenReturn(List.of(a, b));
trigger.runForChangedDirs(Set.of(normalized("/in/a")));
verify(policyRunner).run(a);
verify(policyRunner, never()).run(b);
}
@Test
void skipsAMisconfiguredPolicyButStillRunsTheOthers() {
Policy bad = folderWatch("bad", List.of(new InputSpec("folder", Map.of())));
Policy good = folderWatch("good", List.of(InputSpec.folder("/in/a")));
when(policyStore.findByTriggerType("folder-watch")).thenReturn(List.of(bad, good));
trigger.runForChangedDirs(Set.of(normalized("/in/a")));
verify(policyRunner).run(good);
verify(policyRunner, never()).run(bad);
}
@Test
void anEmptyChangeSetDoesNothing() {
trigger.runForChangedDirs(Set.of());
verifyNoInteractions(policyStore, policyRunner);
}
@Test
void reconcileRunsEveryFolderWatchPolicyAsASafetyNet() {
Policy a = folderWatch("a", List.of(InputSpec.folder("/in/a")));
Policy b = folderWatch("b", List.of(InputSpec.folder("/in/b")));
when(policyStore.findByTriggerType("folder-watch")).thenReturn(List.of(a, b));
trigger.runAll();
verify(policyRunner).run(a);
verify(policyRunner).run(b);
}
@Test
void syncRegistrationsWatchesExistingDirsAndCancelsRemovedOnes() throws Exception {
Path dirA = Files.createDirectories(tempDir.resolve("a"));
Path dirB = Files.createDirectories(tempDir.resolve("b"));
Path missing = tempDir.resolve("missing"); // never created on disk
Policy a = folderWatch("a", List.of(InputSpec.folder(dirA.toString())));
Policy b = folderWatch("b", List.of(InputSpec.folder(dirB.toString())));
Policy m = folderWatch("m", List.of(InputSpec.folder(missing.toString())));
WatchService service = FileSystems.getDefault().newWatchService();
try {
trigger.watchService = service;
when(policyStore.findByTriggerType("folder-watch")).thenReturn(List.of(a, b, m));
trigger.syncRegistrations();
// Existing dirs are watched; the non-existent one is skipped.
assertEquals(
Set.of(normalized(dirA.toString()), normalized(dirB.toString())),
trigger.watchedDirs());
// b's policy is removed: its registration is cancelled, a remains.
when(policyStore.findByTriggerType("folder-watch")).thenReturn(List.of(a));
trigger.syncRegistrations();
assertEquals(Set.of(normalized(dirA.toString())), trigger.watchedDirs());
} finally {
service.close();
}
}
private static Path normalized(String dir) {
return Path.of(dir).toAbsolutePath().normalize();
}
private static Policy folderWatch(String id, List<InputSpec> sources) {
return new Policy(
id,
"watcher",
"owner",
true,
new TriggerConfig("folder-watch", Map.of()),
sources,
List.of(new PipelineStep("/api/v1/misc/compress-pdf", Map.of())),
OutputSpec.inline());
}
}
@@ -0,0 +1,51 @@
package stirling.software.proprietary.policy.trigger;
import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertTrue;
import static org.mockito.Mockito.doThrow;
import static org.mockito.Mockito.verify;
import java.util.List;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;
/**
* Tests for {@link PolicyTriggerManager}: starts/stops every trigger, tolerating individual
* failures.
*/
@ExtendWith(MockitoExtension.class)
class PolicyTriggerManagerTest {
@Mock private PolicyTrigger triggerA;
@Mock private PolicyTrigger triggerB;
@Test
void startsAndStopsAllTriggers() {
PolicyTriggerManager manager = new PolicyTriggerManager(List.of(triggerA, triggerB));
assertFalse(manager.isRunning());
manager.start();
verify(triggerA).start();
verify(triggerB).start();
assertTrue(manager.isRunning());
manager.stop();
verify(triggerA).stop();
verify(triggerB).stop();
assertFalse(manager.isRunning());
}
@Test
void oneTriggerFailingToStartDoesNotBlockTheOthers() {
doThrow(new RuntimeException("boom")).when(triggerA).start();
PolicyTriggerManager manager = new PolicyTriggerManager(List.of(triggerA, triggerB));
manager.start();
verify(triggerB).start();
assertTrue(manager.isRunning());
}
}
@@ -0,0 +1,147 @@
package stirling.software.proprietary.policy.trigger;
import static org.junit.jupiter.api.Assertions.assertThrows;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.eq;
import static org.mockito.Mockito.never;
import static org.mockito.Mockito.times;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
import java.time.DayOfWeek;
import java.time.Instant;
import java.time.LocalTime;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import java.util.Set;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;
import stirling.software.common.model.ApplicationProperties;
import stirling.software.proprietary.policy.engine.PolicyRunner;
import stirling.software.proprietary.policy.model.OutputSpec;
import stirling.software.proprietary.policy.model.PipelineStep;
import stirling.software.proprietary.policy.model.Policy;
import stirling.software.proprietary.policy.model.Schedule;
import stirling.software.proprietary.policy.model.TriggerConfig;
import stirling.software.proprietary.policy.store.PolicyStore;
import tools.jackson.databind.json.JsonMapper;
/**
* Tests for {@link ScheduleTrigger}'s due-firing logic via the package-visible {@code
* sweep(Instant)}. The trigger only decides when a policy is due; pulling sources and starting runs
* is the {@link PolicyRunner}'s job, so these assert it delegates to the runner. Schedules default
* to UTC, so explicit UTC instants make these deterministic.
*/
@ExtendWith(MockitoExtension.class)
class ScheduleTriggerTest {
@Mock private PolicyStore policyStore;
@Mock private PolicyRunner policyRunner;
private ScheduleTrigger trigger;
@BeforeEach
void setUp() {
trigger =
new ScheduleTrigger(
policyStore,
policyRunner,
JsonMapper.builder().build(),
new ApplicationProperties());
}
@Test
void firesOncePerScheduleWhenItComesDue() {
Policy policy = scheduled("p1", new Schedule.Every(1, Schedule.Unit.MINUTES));
when(policyStore.findByTriggerType("schedule")).thenReturn(List.of(policy));
Instant t0 = Instant.parse("2026-06-05T10:00:30Z");
trigger.sweep(t0); // first sight: baseline, must not fire immediately
verify(policyRunner, never()).run(any());
trigger.sweep(t0.plusSeconds(120)); // the one-minute mark has passed
verify(policyRunner, times(1)).run(eq(policy));
}
@Test
void doesNotFireBeforeTheNextScheduledTime() {
Policy policy = scheduled("p1", new Schedule.Daily(LocalTime.of(3, 0))); // 03:00 UTC daily
when(policyStore.findByTriggerType("schedule")).thenReturn(List.of(policy));
Instant t0 = Instant.parse("2026-06-05T10:00:00Z");
trigger.sweep(t0);
trigger.sweep(t0.plusSeconds(60)); // next 03:00 is far away
verify(policyRunner, never()).run(any());
}
@Test
void firesWeeklyOnAChosenDay() {
// 2026-06-05 is a Friday; the next Monday 09:00 is the soonest firing.
Policy policy =
scheduled("p1", new Schedule.Weekly(Set.of(DayOfWeek.MONDAY), LocalTime.of(9, 0)));
when(policyStore.findByTriggerType("schedule")).thenReturn(List.of(policy));
Instant friday = Instant.parse("2026-06-05T10:00:00Z");
trigger.sweep(friday); // baseline
trigger.sweep(Instant.parse("2026-06-08T09:00:00Z")); // Monday 09:00
verify(policyRunner, times(1)).run(eq(policy));
}
@Test
void skipsPoliciesWithAnInvalidSchedule() {
Policy policy = scheduledWithRawOptions("p1", Map.of()); // no schedule
when(policyStore.findByTriggerType("schedule")).thenReturn(List.of(policy));
trigger.sweep(Instant.parse("2026-06-05T10:00:00Z"));
verify(policyRunner, never()).run(any());
}
@Test
void validateRejectsMissingSchedule() {
assertThrows(
IllegalArgumentException.class,
() -> trigger.validate(scheduledWithRawOptions("p1", Map.of())));
}
@Test
void validateRejectsAnInvalidSchedule() {
Map<String, Object> options =
Map.of("schedule", Map.of("type", "every", "count", -5, "unit", "MINUTES"));
assertThrows(
IllegalArgumentException.class,
() -> trigger.validate(scheduledWithRawOptions("p1", options)));
}
@Test
void validateAcceptsAValidScheduleAndZone() {
Map<String, Object> options = new LinkedHashMap<>();
options.put("schedule", new Schedule.Daily(LocalTime.of(2, 0)));
options.put("zone", "Europe/London");
trigger.validate(scheduledWithRawOptions("p1", options));
}
private static Policy scheduled(String id, Schedule schedule) {
return scheduledWithRawOptions(id, Map.of("schedule", schedule));
}
private static Policy scheduledWithRawOptions(String id, Map<String, Object> options) {
return new Policy(
id,
"nightly",
"owner",
true,
new TriggerConfig("schedule", options),
List.of(new PipelineStep("/api/v1/misc/compress-pdf", Map.of())),
OutputSpec.inline());
}
}
@@ -439,6 +439,35 @@ class AiWorkflowServiceTest {
verify(internalApiClient, never()).post(anyString(), any());
}
@Test
void convertMarkdownRunsDeterministicConversionAndReturnsMdFile() throws IOException {
MockMultipartFile input = pdf("multi-column-test_lorem.pdf", "pdf-bytes");
when(fileIdStrategy.idFor(any())).thenReturn("doc-1");
stubOrchestrator(
"""
{
"outcome":"convert_markdown",
"reason":"PDF to Markdown requested.",
"filesToIngest":[{"id":"doc-1","name":"multi-column-test_lorem.pdf"}]
}
""");
when(toolMetadataService.shouldUnpackZipResponse("/api/v1/convert/pdf/markdown"))
.thenReturn(false);
stubEndpoint(
"/api/v1/convert/pdf/markdown",
pdfResource("# Title", "multi-column-test_lorem.md"));
AtomicInteger ids = stubFileStorage();
AiWorkflowResponse result = service.orchestrate(requestFor(input, "convert to markdown"));
assertEquals(AiWorkflowOutcome.COMPLETED, result.getOutcome());
assertEquals(1, result.getResultFiles().size());
// Extension changes (pdf -> md), so the converter's response filename wins.
assertEquals("multi-column-test_lorem.md", result.getResultFiles().get(0).getFileName());
assertEquals(1, ids.get());
verify(internalApiClient, times(1)).post(eq("/api/v1/convert/pdf/markdown"), any());
}
@Test
void toolCallWithoutEndpointFallsBackToCannotContinue() throws IOException {
MockMultipartFile input = pdf("input.pdf", "bytes");
@@ -1,66 +0,0 @@
package stirling.software.proprietary.service;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertTrue;
import java.util.List;
import org.junit.jupiter.api.Test;
import stirling.software.proprietary.service.PdfContentExtractor.LayoutFragment;
import stirling.software.proprietary.service.PdfContentExtractor.LayoutLine;
import stirling.software.proprietary.service.PdfContentExtractor.LayoutPage;
import stirling.software.proprietary.service.PdfContentExtractor.PageLayoutArtifact;
import stirling.software.proprietary.service.PdfContentExtractor.PageLayoutFileResult;
import tools.jackson.databind.JsonNode;
import tools.jackson.databind.json.JsonMapper;
/**
* Contract test: verifies that {@link PageLayoutArtifact} serializes to the JSON field names that
* the Python engine expects in {@code engine/src/stirling/contracts/pdf_to_markdown.py}.
*
* <p>The companion Python test in {@code tests/test_pdf_to_markdown.py} deserializes the same JSON
* literal and asserts field values. If either side renames a field, one of these tests fails.
*/
class PageLayoutArtifactContractTest {
static final String CONTRACT_JSON =
"""
{"kind":"page_layout","files":[{"fileName":"test.pdf","pages":[{"pageNumber":1,"lines":[{"y":10.0,"fragments":[{"text":"Hello","x":1.0,"y":2.0,"width":30.0,"fontSize":12.0,"bold":true}]}]}]}]}""";
@Test
void pageLayoutArtifact_serialisesToExpectedJson() throws Exception {
LayoutFragment fragment = new LayoutFragment("Hello", 1.0f, 2.0f, 30.0f, 12.0f, true);
LayoutLine line = new LayoutLine(10.0f, List.of(fragment));
LayoutPage page = new LayoutPage(1, List.of(line));
PageLayoutFileResult fileResult = new PageLayoutFileResult();
fileResult.setFileName("test.pdf");
fileResult.setPages(List.of(page));
PageLayoutArtifact artifact = new PageLayoutArtifact();
artifact.setFiles(List.of(fileResult));
JsonNode json = new JsonMapper().valueToTree(artifact);
assertEquals("page_layout", json.get("kind").asText());
JsonNode file = json.get("files").get(0);
assertEquals("test.pdf", file.get("fileName").asText());
JsonNode pg = file.get("pages").get(0);
assertEquals(1, pg.get("pageNumber").asInt());
JsonNode ln = pg.get("lines").get(0);
assertEquals(10.0, ln.get("y").asDouble(), 0.001);
JsonNode frag = ln.get("fragments").get(0);
assertEquals("Hello", frag.get("text").asText());
assertEquals(1.0, frag.get("x").asDouble(), 0.001);
assertEquals(2.0, frag.get("y").asDouble(), 0.001);
assertEquals(30.0, frag.get("width").asDouble(), 0.001);
assertEquals(12.0, frag.get("fontSize").asDouble(), 0.001);
assertTrue(frag.get("bold").asBoolean());
}
}
-2
View File
@@ -5,7 +5,6 @@ from .orchestrator import OrchestratorAgent
from .pdf_edit import PdfEditAgent, PdfEditParameterSelector, PdfEditPlanSelection
from .pdf_questions import PdfQuestionAgent
from .pdf_review import PdfReviewAgent
from .pdf_to_markdown import PdfToMarkdownAgent
from .user_spec import UserSpecAgent
__all__ = [
@@ -16,6 +15,5 @@ __all__ = [
"PdfEditPlanSelection",
"PdfQuestionAgent",
"PdfReviewAgent",
"PdfToMarkdownAgent",
"UserSpecAgent",
]
+15 -20
View File
@@ -11,14 +11,13 @@ from pydantic_ai.tools import RunContext
from stirling.agents.pdf_edit import PdfEditAgent
from stirling.agents.pdf_questions import PdfQuestionAgent
from stirling.agents.pdf_review import PdfReviewAgent
from stirling.agents.pdf_to_markdown import PdfToMarkdownAgent
from stirling.agents.user_spec import UserSpecAgent
from stirling.contracts import (
AgentDraftWorkflowResponse,
ConvertMarkdownResponse,
ExtractedTextArtifact,
OrchestratorRequest,
OrchestratorResponse,
PageLayoutArtifact,
PdfEditResponse,
PdfQuestionOrchestrateResponse,
PdfReviewOrchestrateResponse,
@@ -27,7 +26,6 @@ from stirling.contracts import (
format_conversation_history,
format_file_names,
)
from stirling.contracts.pdf_to_markdown import PdfToMarkdownOrchestrateResponse
from stirling.services import AppRuntime
logger = logging.getLogger(__name__)
@@ -72,9 +70,11 @@ class OrchestratorAgent:
),
),
ToolOutput(
self.delegate_pdf_to_markdown,
name="delegate_pdf_to_markdown",
description=("Delegate requests to reconstruct a PDF as a Markdown document."),
self.delegate_pdf_ingest,
name="delegate_pdf_ingest",
description=(
"Delegate requests to convert a PDF to Markdown or extract its content as readable text."
),
),
ToolOutput(
self.unsupported_capability,
@@ -92,8 +92,8 @@ class OrchestratorAgent:
"Use delegate_pdf_review when the user wants the PDF returned with review"
" comments attached — anything like 'review this', 'annotate with comments',"
" 'leave feedback on the PDF'. "
"Use delegate_pdf_to_markdown for any request to convert a PDF to Markdown "
"or reconstruct its content as readable text. "
"Use delegate_pdf_ingest for any request to convert a PDF to Markdown "
"or extract its content as readable text. "
"Use unsupported_capability when the user asks about the assistant itself "
"or when none of the other outputs fit; supply a helpful message."
),
@@ -133,13 +133,12 @@ class OrchestratorAgent:
return await self._run_pdf_edit(request)
case SupportedCapability.AGENT_DRAFT:
return await self._run_agent_draft(request)
case SupportedCapability.PDF_TO_MARKDOWN:
return await self._run_pdf_to_markdown(request)
case (
SupportedCapability.ORCHESTRATE
| SupportedCapability.AGENT_REVISE
| SupportedCapability.AGENT_NEXT_ACTION
| SupportedCapability.MATH_AUDITOR_AGENT
| SupportedCapability.PDF_TO_MARKDOWN
):
raise ValueError(f"Cannot resume orchestrator with capability: {capability}")
case _ as unreachable:
@@ -163,11 +162,12 @@ class OrchestratorAgent:
async def _run_agent_draft(self, request: OrchestratorRequest) -> AgentDraftWorkflowResponse:
return await UserSpecAgent(self.runtime).orchestrate(request)
async def delegate_pdf_to_markdown(self, ctx: RunContext[OrchestratorDeps]) -> PdfToMarkdownOrchestrateResponse:
return await self._run_pdf_to_markdown(ctx.deps.request)
async def _run_pdf_to_markdown(self, request: OrchestratorRequest) -> PdfToMarkdownOrchestrateResponse:
return await PdfToMarkdownAgent(self.runtime).orchestrate(request)
async def delegate_pdf_ingest(self, ctx: RunContext[OrchestratorDeps]) -> ConvertMarkdownResponse:
request = ctx.deps.request
return ConvertMarkdownResponse(
reason="PDF to Markdown requested — Java converts deterministically.",
files_to_ingest=request.files,
)
async def delegate_pdf_review(self, ctx: RunContext[OrchestratorDeps]) -> PdfReviewOrchestrateResponse:
return await self._run_pdf_review(ctx.deps.request)
@@ -204,10 +204,5 @@ class OrchestratorAgent:
file_names = [f.file_name for f in artifact.files]
descriptions.append(f"- extracted_text: {total_pages} pages from {file_names}")
continue
if isinstance(artifact, PageLayoutArtifact):
total_pages = sum(len(f.pages) for f in artifact.files)
file_names = [f.file_name for f in artifact.files]
descriptions.append(f"- page_layout: {total_pages} pages from {file_names}")
continue
descriptions.append("- unknown artifact")
return "\n".join(descriptions)
@@ -1,3 +0,0 @@
from .agent import PdfToMarkdownAgent
__all__ = ["PdfToMarkdownAgent"]
@@ -1,435 +0,0 @@
"""PDF to Markdown Agent.
Converts a parsed PDF document into a single clean Markdown document, preserving
headings, paragraphs, and tables in reading order.
"""
from __future__ import annotations
import asyncio
import logging
import re
import time
from pydantic import BaseModel, Field
from pydantic_ai import Agent
from pydantic_ai.output import NativeOutput
from stirling.contracts import (
EditCannotDoResponse,
GenerateFileResponse,
NeedContentFileRequest,
NeedContentResponse,
OrchestratorRequest,
PdfContentType,
SupportedCapability,
format_conversation_history,
)
from stirling.contracts.pdf_to_markdown import (
PageLayout,
PageLayoutArtifact,
PdfToMarkdownCannotDoResponse,
PdfToMarkdownOrchestrateResponse,
PdfToMarkdownRequest,
PdfToMarkdownResponse,
PdfToMarkdownSuccessResponse,
)
from stirling.services import AppRuntime
logger = logging.getLogger(__name__)
# Warn when output tokens are close to the typical model output limit (~8192 for most
# configurations). The actual limit is model-specific; this threshold catches likely truncation.
_OUTPUT_TOKEN_TRUNCATION_THRESHOLD = 7500
# Chunking limits — keep each LLM call to a manageable payload size.
# Fragment count is the primary driver of JSON payload size (each fragment carries x/y/width/
# fontSize/bold metadata beyond its text). Page cap prevents low-text pages accumulating.
_MAX_CHUNK_FRAGMENTS = 1_000
_MAX_CHUNK_PAGES = 10
# Max concurrent LLM calls — limits API rate pressure on large documents.
_MAX_PARALLEL_CHUNKS = 3
# ── LLM output model ────────────────────────────────────────────────────────────────────────────
class _ReconstructionOutput(BaseModel):
markdown: str = Field(description="Full document reconstructed as clean Markdown.")
# ── Agent ────────────────────────────────────────────────────────────────────────────────────────
class PdfToMarkdownAgent:
def __init__(self, runtime: AppRuntime) -> None:
self.runtime = runtime
self._sem = asyncio.Semaphore(_MAX_PARALLEL_CHUNKS)
self._reconstruct_agent = Agent(
model=runtime.smart_model,
output_type=NativeOutput(_ReconstructionOutput),
system_prompt=(
"You reconstruct PDF pages into clean Markdown from spatial fragment data.\n"
"Input: PAGE LAYOUT — per-fragment x/y/font data for structural analysis.\n\n"
"COLUMN DETECTION (for tables in page_layout):\n"
"- Look at the x-positions of fragments across 3+ consecutive lines.\n"
"- If fragments cluster at the same x-positions across multiple lines, those are table columns.\n"
"- Each distinct x-cluster is one column."
" Name them from the header row (the first line in the cluster).\n"
"- Do NOT merge values from different x-columns into one cell.\n\n"
"ROW DETECTION:\n"
"- Each unique y-coordinate (or group within 3pt) is one table row.\n"
"- Every line of layout data is its own row — do not merge rows.\n"
"- If a column has no fragment on a given y-row, that cell is empty.\n\n"
"TABLE RENDERING:\n"
"- Render as: | col1 | col2 | col3 |\n"
" | --- | --- | --- |\n"
" | val | val | val |\n"
"- One source row = one table row. Never collapse multiple rows into one.\n"
"- Preserve numeric values exactly (no rounding, no formatting changes).\n"
"- Bold cells: wrap with ** in the Markdown cell.\n"
"- CRITICAL: the separator row `| --- | --- |` appears EXACTLY ONCE per table, immediately\n"
" after the header row. NEVER put `| --- |` after a data row or between data rows.\n"
" NEVER put a blank line inside a table. All rows (header + data) must be consecutive.\n"
"- Do NOT produce a header-only table followed by a second table with the data rows.\n"
" One logical table = one markdown table block, with header, one separator, then all data.\n\n"
"GROUP HEADERS (label-only rows inside a table):\n"
"- A row is a group header when: the first column has text AND every numeric column is empty.\n"
"- Do NOT render group headers as table rows with empty cells.\n"
"- Break the table, emit the label as **bold text** on its own line,"
" then start a new table for the rows that follow.\n"
"- Example labels: 'Policy functions', 'Non-current assets'.\n\n"
"TOTAL AND SUBTOTAL ROWS:\n"
"- Detect rows whose first cell contains (case-insensitive):"
" total, subtotal, surplus, balance, net, sum.\n"
"- These rows have numeric content — they are NOT group headers.\n"
"- Render the entire row in bold: | **Total income** | **1,234** | **5,678** |\n"
"- Keep total rows attached to the group they summarise.\n\n"
"MULTI-LEVEL TABLES (year or period as a row label):\n"
"- Detect when a row contains only a single label (a year like '2010' or period like 'Q1 2023')"
" with no numeric content, followed by repeated metric rows.\n"
"- Do NOT render the year as a table row.\n"
"- Normalise: add 'Year' as the first column, 'Metric' as the second,"
" and repeat the year value on each metric row.\n\n"
"PROSE REGIONS:\n"
"- Lines where x-positions vary across lines (not repeating columns) are prose.\n"
"- Merge lines at the same x-level into paragraphs. Separate indented lines.\n\n"
"HEADINGS:\n"
"- A line is a heading when it is bold OR font_size ≥2pt above body.\n"
" CRITICAL EXCEPTION: a bold fragment is a TABLE HEADER CELL, not a document heading, when\n"
" the same y-row in page_layout contains other fragments at different x-positions.\n"
" Only classify a bold line as a document heading when it is the SOLE fragment on its y-row.\n"
" Example: 'Non-current assets' at y=120 with '2010'@x=350, '2009'@x=420, '2008'@x=490\n"
" → this is a table header row, NOT a heading. Render it as the first cell of the table.\n"
"- Use ## for section headings, ### for sub-headings. Use # only for the document title.\n\n"
"ORDERING:\n"
"- Process content top-to-bottom as it appears on the page.\n"
"- Interleave prose blocks and table blocks in page order.\n"
"- Do not move text that appears before a table to after it, or vice versa.\n\n"
"FIDELITY:\n"
"- Do NOT invent, summarise, or omit any content.\n"
"- Do NOT add commentary, metadata, or JSON — output Markdown only."
),
model_settings={
**runtime.smart_model_settings,
"temperature": 0.0,
"max_tokens": _OUTPUT_TOKEN_TRUNCATION_THRESHOLD,
},
)
async def orchestrate(self, request: OrchestratorRequest) -> PdfToMarkdownOrchestrateResponse:
"""Entry point for the orchestrator delegate.
First turn: requests PAGE_LAYOUT extraction from Java via NeedContentResponse.
Resume turn: runs the LLM reconstruction and returns a write-file plan step.
"""
layout_artifact = next(
(a for a in request.artifacts if isinstance(a, PageLayoutArtifact)),
None,
)
if layout_artifact is None:
return NeedContentResponse(
resume_with=SupportedCapability.PDF_TO_MARKDOWN,
reason="Page layout data is required to reconstruct the document.",
files=[
NeedContentFileRequest(file=f, content_types=[PdfContentType.PAGE_LAYOUT]) for f in request.files
],
max_pages=self.runtime.settings.max_pages,
max_characters=self.runtime.settings.max_characters,
)
page_layout = [page for entry in layout_artifact.files for page in entry.pages]
file_names = [f.name for f in request.files]
result = await self.handle(
PdfToMarkdownRequest(
user_message=request.user_message,
file_names=file_names,
conversation_history=request.conversation_history,
page_layout=page_layout,
)
)
if isinstance(result, PdfToMarkdownCannotDoResponse):
return EditCannotDoResponse(reason=result.reason)
base = file_names[0].rsplit(".", 1)[0] if file_names else "document"
return GenerateFileResponse(
content=result.markdown,
filename=f"{base}-reconstruction.md",
summary="Reconstructed the document as a Markdown file.",
)
async def handle(self, request: PdfToMarkdownRequest) -> PdfToMarkdownResponse:
total_fragments = sum(len(line.fragments) for page in request.page_layout for line in page.lines)
logger.info(
"[pdf-to-markdown] received layout-pages=%d fragments=%d",
len(request.page_layout),
total_fragments,
)
if not request.page_layout:
logger.warning("[pdf-to-markdown] no content extracted from document; returning cannot_do")
return PdfToMarkdownCannotDoResponse(
reason=(
"No content was extracted from the document. "
"The file may be a scanned image PDF with no readable text. "
"Try running OCR on the document first."
)
)
chunks = _build_page_chunks(request.page_layout)
logger.info("[pdf-to-markdown] chunks=%d (max %d in parallel)", len(chunks), _MAX_PARALLEL_CHUNKS)
if len(chunks) == 1:
return await self._reconstruct_chunk(request, chunks[0], chunk_num=1, total_chunks=1)
total = len(chunks)
results = await asyncio.gather(
*(
self._reconstruct_chunk(request, chunk, chunk_num=i + 1, total_chunks=total)
for i, chunk in enumerate(chunks)
)
)
markdown_parts: list[str] = []
for result in results:
if isinstance(result, PdfToMarkdownSuccessResponse) and result.markdown:
markdown_parts.append(result.markdown)
elif isinstance(result, PdfToMarkdownCannotDoResponse):
logger.warning("[pdf-to-markdown] chunk dropped: %s", result.reason)
if not markdown_parts:
return PdfToMarkdownCannotDoResponse(reason="The document could not be reconstructed. All chunks failed.")
logger.info("[pdf-to-markdown] assembly: %d/%d chunks produced output", len(markdown_parts), len(chunks))
return PdfToMarkdownSuccessResponse(markdown="\n\n".join(markdown_parts))
async def _reconstruct_chunk(
self,
request: PdfToMarkdownRequest,
pages: list[PageLayout],
chunk_num: int,
total_chunks: int,
) -> PdfToMarkdownResponse:
chunk_request = PdfToMarkdownRequest(
user_message=request.user_message,
file_names=request.file_names,
conversation_history=request.conversation_history,
page_layout=pages,
)
try:
async with self._sem:
return await self._reconstruct_document(chunk_request, chunk_num, total_chunks)
except Exception as e:
logger.error("[pdf-to-markdown] chunk %d/%d failed: %s", chunk_num, total_chunks, e, exc_info=True)
return PdfToMarkdownCannotDoResponse(
reason="The document could not be reconstructed. The AI model failed to process it."
)
async def _reconstruct_document(
self, request: PdfToMarkdownRequest, chunk_num: int = 1, total_chunks: int = 1
) -> PdfToMarkdownSuccessResponse:
content = _build_reconstruction_prompt(request)
logger.info("[timing] chunk %d/%d llm-call prompt-chars=%d", chunk_num, total_chunks, len(content))
t0 = time.monotonic()
result = await self._reconstruct_agent.run([content])
llm_ms = int((time.monotonic() - t0) * 1000)
output: _ReconstructionOutput = result.output
usage = result.usage()
logger.info(
"[timing] chunk %d/%d llm-done ms=%d input-tokens=%s output-tokens=%s markdown-chars=%d",
chunk_num,
total_chunks,
llm_ms,
usage.input_tokens,
usage.output_tokens,
len(output.markdown),
)
if usage.output_tokens and usage.output_tokens >= _OUTPUT_TOKEN_TRUNCATION_THRESHOLD:
logger.warning(
"[timing] chunk %d/%d output likely truncated (output-tokens=%d)",
chunk_num,
total_chunks,
usage.output_tokens,
)
markdown = _remove_extra_separators(_fix_markdown_tables(_merge_orphaned_table_rows(output.markdown)))
return PdfToMarkdownSuccessResponse(markdown=markdown)
# ── Chunking ────────────────────────────────────────────────────────────────────────────────────
def _build_page_chunks(pages: list[PageLayout]) -> list[list[PageLayout]]:
chunks: list[list[PageLayout]] = []
current: list[PageLayout] = []
current_fragments = 0
for page in pages:
page_fragments = sum(len(line.fragments) for line in page.lines)
fragment_full = current and current_fragments + page_fragments > _MAX_CHUNK_FRAGMENTS
page_full = len(current) >= _MAX_CHUNK_PAGES
if fragment_full or page_full:
chunks.append(current)
current = []
current_fragments = 0
current.append(page)
current_fragments += page_fragments
if current:
chunks.append(current)
return chunks
# ── Prompt builders (module-level, no state) ────────────────────────────────────────────────────
def _build_reconstruction_prompt(request: PdfToMarkdownRequest) -> str:
history = format_conversation_history(request.conversation_history)
file_names = ", ".join(request.file_names) if request.file_names else "Unknown files"
layout_section = _format_layout(request.page_layout)
return (
f"Files: {file_names}\n\n"
f"User request: {request.user_message}\n\n"
f"Conversation history:\n{history}\n\n"
"PAGE LAYOUT (structural source — x/y fragment positions):\n"
"Each line is: y=NNN | text@(x,y) fs=N text@(x,y) fs=N ...\n"
"- y=NNN is the vertical position (row). Lines close in y are the same visual row.\n"
"- x=NNN is the horizontal position (column). Consistent x across rows = a column.\n"
"- fs=N is font size. Larger = likely a heading.\n"
"- **bold** markers indicate bold text.\n\n"
f"{layout_section}"
)
# ── LLM output post-processing ──────────────────────────────────────────────────────────────────
def _fix_markdown_tables(markdown: str) -> str:
"""Remove blank lines between table rows produced by the LLM."""
lines = markdown.split("\n")
result: list[str] = []
i = 0
while i < len(lines):
result.append(lines[i])
if lines[i].strip().startswith("|"):
j = i + 1
while j < len(lines) and lines[j].strip() == "":
j += 1
if j < len(lines) and lines[j].strip().startswith("|"):
i = j
continue
i += 1
return "\n".join(result)
_SEP_CELL = re.compile(r"^:?-+:?$")
def _is_sep_row(line: str) -> bool:
"""Return True when a pipe row is a Markdown table separator (| --- | --- |)."""
stripped = line.strip()
if not stripped.startswith("|"):
return False
cells = [c.strip() for c in stripped.split("|") if c.strip()]
return bool(cells) and all(_SEP_CELL.match(c) for c in cells)
def _merge_orphaned_table_rows(markdown: str) -> str:
"""Merge pipe-row blocks that lack a separator into the preceding table.
When the LLM incorrectly breaks a table (e.g. on a false group-header), it emits
orphaned pipe rows with no header or separator. These are invalid markdown and get
merged back into the preceding table, discarding the intervening non-table content.
"""
lines = markdown.split("\n")
segments: list[tuple[str, list[str]]] = []
i = 0
while i < len(lines):
if lines[i].strip().startswith("|"):
block: list[str] = []
while i < len(lines) and lines[i].strip().startswith("|"):
block.append(lines[i])
i += 1
has_sep = any(_is_sep_row(row) for row in block)
segments.append(("table" if has_sep else "orphan", block))
else:
block = []
while i < len(lines) and not lines[i].strip().startswith("|"):
block.append(lines[i])
i += 1
segments.append(("prose", block))
result: list[tuple[str, list[str]]] = []
last_table_idx: int | None = None
for seg_type, seg_lines in segments:
if seg_type == "orphan":
if last_table_idx is not None:
result = result[: last_table_idx + 1]
result[-1] = ("table", result[-1][1] + seg_lines)
else:
result.append((seg_type, seg_lines))
else:
if seg_type == "table":
last_table_idx = len(result)
result.append((seg_type, seg_lines))
return "\n".join(line for _, seg_lines in result for line in seg_lines)
def _remove_extra_separators(markdown: str) -> str:
"""Within each contiguous table block, keep only the first separator row."""
lines = markdown.split("\n")
result: list[str] = []
seen_sep = False
for line in lines:
if not line.strip().startswith("|"):
seen_sep = False
result.append(line)
continue
if _is_sep_row(line):
if seen_sep:
continue
seen_sep = True
result.append(line)
return "\n".join(result)
# ── Formatting helpers (module-level, no state) ──────────────────────────────────────────────────
def _format_layout(pages: list[PageLayout]) -> str:
if not pages:
return "None"
parts: list[str] = []
for page in pages:
line_strs: list[str] = []
for line in page.lines:
frags = " ".join(
f"{'**' if f.bold else ''}{f.text}{'**' if f.bold else ''}@({f.x:.0f},{f.y:.0f}) fs={f.font_size:.0f}"
for f in line.fragments
)
line_strs.append(f"y={line.y:.0f} | {frags}")
parts.append(f"--- Page {page.page_number} ---\n" + "\n".join(line_strs))
return "\n\n".join(parts)
+2 -20
View File
@@ -13,6 +13,7 @@ from .common import (
AiFile,
ArtifactKind,
ConversationMessage,
ConvertMarkdownResponse,
ExtractedFileText,
GenerateFileResponse,
MathAuditorToolReportArtifact,
@@ -96,17 +97,6 @@ from .pdf_questions import (
PdfQuestionTerminalResponse,
)
from .pdf_review import PdfReviewOrchestrateResponse
from .pdf_to_markdown import (
LayoutFragment,
LayoutLine,
PageLayout,
PageLayoutArtifact,
PageLayoutFileEntry,
PdfToMarkdownCannotDoResponse,
PdfToMarkdownOrchestrateResponse,
PdfToMarkdownRequest,
PdfToMarkdownResponse,
)
from .progress import (
ProgressEvent,
WholeDocCompressionRound,
@@ -139,10 +129,6 @@ __all__ = [
"ConversationMessage",
"DeleteDocumentResponse",
"PurgeOwnerResponse",
"PdfToMarkdownCannotDoResponse",
"PdfToMarkdownOrchestrateResponse",
"PdfToMarkdownRequest",
"PdfToMarkdownResponse",
"Discrepancy",
"DiscrepancyKind",
"EditCannotDoResponse",
@@ -166,15 +152,11 @@ __all__ = [
"NeedContentFileRequest",
"NeedContentResponse",
"NeedIngestResponse",
"ConvertMarkdownResponse",
"NextExecutionAction",
"OrchestratorRequest",
"OrchestratorResponse",
"LayoutFragment",
"LayoutLine",
"Page",
"PageLayout",
"PageLayoutArtifact",
"PageLayoutFileEntry",
"PageRange",
"PageText",
"PdfCommentInstruction",
+14
View File
@@ -62,6 +62,7 @@ class WorkflowOutcome(StrEnum):
CANNOT_CONTINUE = "cannot_continue"
UNSUPPORTED_CAPABILITY = "unsupported_capability"
GENERATE_FILE = "generate_file"
CONVERT_MARKDOWN = "convert_markdown"
class ArtifactKind(StrEnum):
@@ -183,6 +184,19 @@ class NeedIngestResponse(ApiModel):
content_types: list[PdfContentType] = Field(default_factory=list)
class ConvertMarkdownResponse(ApiModel):
"""Terminal signal: convert the listed files to Markdown deterministically.
This is a deterministic, non-AI conversion. Java runs the PDFMarkdown converter
(``PdfMarkdownConverter``) on each file and returns the resulting ``.md`` file(s) as a
completed result. There is no resume turn the conversion output is the final answer.
"""
outcome: Literal[WorkflowOutcome.CONVERT_MARKDOWN] = WorkflowOutcome.CONVERT_MARKDOWN
reason: str
files_to_ingest: list[AiFile]
class ToolOperationStep(ApiModel):
kind: Literal[StepKind.TOOL] = StepKind.TOOL
tool: AnyToolId
@@ -11,6 +11,7 @@ from .common import (
AiFile,
ArtifactKind,
ConversationMessage,
ConvertMarkdownResponse,
ExtractedFileText,
GenerateFileResponse,
NeedContentResponse,
@@ -23,7 +24,6 @@ from .common import (
from .execution import NextExecutionAction
from .pdf_edit import PdfEditTerminalResponse
from .pdf_questions import PdfQuestionTerminalResponse
from .pdf_to_markdown import PageLayoutArtifact
class ExtractedTextArtifact(ApiModel):
@@ -32,7 +32,7 @@ class ExtractedTextArtifact(ApiModel):
WorkflowArtifact = Annotated[
ExtractedTextArtifact | PageLayoutArtifact | ToolReportArtifact,
ExtractedTextArtifact | ToolReportArtifact,
Field(discriminator="kind"),
]
@@ -61,6 +61,7 @@ type OrchestratorResponse = Annotated[
| GenerateFileResponse
| NeedContentResponse
| NeedIngestResponse
| ConvertMarkdownResponse
| AgentDraftResponse
| NextExecutionAction
| UnsupportedCapabilityResponse,
@@ -1,105 +0,0 @@
"""Contracts for the PDF to Markdown Agent.
The agent accepts a parsed document and returns a single Markdown document that
faithfully reconstructs the PDF content headings, paragraphs, and tables in
reading order, using page_layout as the primary source of truth for structure.
Java extracts page layout via PdfIngester and returns it as a PageLayoutArtifact
through the orchestrator resume_with pattern.
"""
from __future__ import annotations
from typing import Annotated, Literal
from pydantic import Field
from stirling.models import ApiModel
from .common import ArtifactKind, ConversationMessage, GenerateFileResponse, NeedContentResponse
from .pdf_edit import EditCannotDoResponse
# ── Input: layout models (mirror Java's RawLine / TextFragment geometry) ────────────────────────
class LayoutFragment(ApiModel):
"""One text fragment with its bounding-box geometry and font properties."""
text: str
x: float
y: float
width: float
font_size: float
bold: bool
class LayoutLine(ApiModel):
"""A visual line on the page: one y-coordinate and all fragments on that line."""
y: float
fragments: list[LayoutFragment]
class PageLayout(ApiModel):
"""All layout lines for a single page, in top-to-bottom order."""
page_number: int
lines: list[LayoutLine]
# ── Artifact: page layout (produced by Java, consumed by orchestrate()) ──────────────────────────
class PageLayoutFileEntry(ApiModel):
"""Page layout data for one file, as extracted by Java's PdfIngester."""
file_name: str
pages: list[PageLayout] = Field(default_factory=list)
class PageLayoutArtifact(ApiModel):
"""Artifact carrying full spatial page layout for all input files."""
kind: Literal[ArtifactKind.PAGE_LAYOUT] = ArtifactKind.PAGE_LAYOUT
files: list[PageLayoutFileEntry] = Field(default_factory=list)
# ── Input: full request ──────────────────────────────────────────────────────────────────────────
class PdfToMarkdownRequest(ApiModel):
"""Request sent by Java after PdfIngester has parsed the document.
page_layout: per-fragment positional data from the original (y-sorted) line order.
Each fragment carries its x/y position, width, font size, and bold flag.
This is the primary source of truth for column detection and heading hierarchy.
"""
user_message: str
file_names: list[str] = Field(default_factory=list)
conversation_history: list[ConversationMessage] = Field(default_factory=list)
page_layout: list[PageLayout] = Field(default_factory=list)
# ── Output: response variants ────────────────────────────────────────────────────────────────────
class PdfToMarkdownSuccessResponse(ApiModel):
outcome: Literal["document_reconstructed"] = "document_reconstructed"
markdown: str
class PdfToMarkdownCannotDoResponse(ApiModel):
outcome: Literal["cannot_do"] = "cannot_do"
reason: str
type PdfToMarkdownResponse = Annotated[
PdfToMarkdownSuccessResponse | PdfToMarkdownCannotDoResponse,
Field(discriminator="outcome"),
]
type PdfToMarkdownOrchestrateResponse = Annotated[
GenerateFileResponse | EditCannotDoResponse | NeedContentResponse,
Field(discriminator="outcome"),
]
-138
View File
@@ -1,138 +0,0 @@
"""Tests for PDF to Markdown agent.
Two cases:
1. Narrative-only page: request validates and routes to reconstruction.
2. Mixed text + table page: layout with table region validates correctly.
"""
from __future__ import annotations
from stirling.contracts.pdf_to_markdown import (
LayoutFragment,
LayoutLine,
PageLayout,
PageLayoutArtifact,
PdfToMarkdownRequest,
PdfToMarkdownSuccessResponse,
)
def _frag(text: str, x: float, y: float, font_size: float = 10.0, bold: bool = False) -> LayoutFragment:
return LayoutFragment(text=text, x=x, y=y, width=float(len(text) * 6), font_size=font_size, bold=bold)
def _line(y: float, *frags: LayoutFragment) -> LayoutLine:
return LayoutLine(y=y, fragments=list(frags))
# ── Test 1: Narrative-only reconstruction ────────────────────────────────────────────────────────
# ── Contract test: Java serialization ↔ Python deserialization ──────────────────────────────────
# This JSON is also asserted field-by-field in PageLayoutArtifactContractTest.java.
# If either side renames a field, one of these tests fails.
_CONTRACT_JSON = (
'{"kind":"page_layout","files":[{"fileName":"test.pdf","pages":'
'[{"pageNumber":1,"lines":[{"y":10.0,"fragments":'
'[{"text":"Hello","x":1.0,"y":2.0,"width":30.0,"fontSize":12.0,"bold":true}]}]}]}]}'
)
def test_page_layout_artifact_deserialises_java_json() -> None:
artifact = PageLayoutArtifact.model_validate_json(_CONTRACT_JSON)
assert artifact.kind == "page_layout"
assert artifact.files[0].file_name == "test.pdf"
page = artifact.files[0].pages[0]
assert page.page_number == 1
line = page.lines[0]
assert line.y == 10.0
frag = line.fragments[0]
assert frag.text == "Hello"
assert frag.x == 1.0
assert frag.y == 2.0
assert frag.width == 30.0
assert frag.font_size == 12.0
assert frag.bold is True
def test_narrative_reconstruction_request_validates() -> None:
"""A prose-only page with no tables produces a valid PdfToMarkdownRequest."""
layout = PageLayout(
page_number=1,
lines=[
_line(72.0, _frag("Annual Report 2023", x=72.0, y=72.0, font_size=18.0, bold=True)),
_line(100.0, _frag("Our revenue grew significantly", x=72.0, y=100.0)),
_line(114.0, _frag("during the fiscal year ended", x=72.0, y=114.0)),
_line(128.0, _frag("December 31, 2023.", x=72.0, y=128.0)),
],
)
request = PdfToMarkdownRequest(
user_message="reconstruct this document",
page_layout=[layout],
)
assert len(request.page_layout) == 1
assert len(request.page_layout[0].lines) == 4
assert request.page_layout[0].lines[0].fragments[0].bold is True
assert request.page_layout[0].lines[0].fragments[0].font_size == 18.0
def test_narrative_reconstruction_response_validates() -> None:
"""PdfToMarkdownSuccessResponse accepts markdown and returns document_reconstructed outcome."""
response = PdfToMarkdownSuccessResponse(
markdown="# Annual Report 2023\n\nOur revenue grew significantly during the fiscal year.",
)
assert response.outcome == "document_reconstructed"
assert response.markdown.startswith("#")
# ── Test 2: Mixed text + table reconstruction ─────────────────────────────────────────────────────
def test_mixed_page_layout_validates() -> None:
"""A page with both prose lines and a table region produces a valid request."""
layout = PageLayout(
page_number=1,
lines=[
# Prose heading
_line(50.0, _frag("Projects in Development", x=72.0, y=50.0, font_size=14.0, bold=True)),
# Table header row
_line(
80.0,
_frag("Project Name", x=72.0, y=80.0, bold=True),
_frag("Location", x=200.0, y=80.0, bold=True),
_frag("Size (MW)", x=290.0, y=80.0, bold=True),
),
# Table data rows
_line(
95.0,
_frag("Chaplin Wind 1", x=72.0, y=95.0),
_frag("Saskatchewan", x=200.0, y=95.0),
_frag("177", x=290.0, y=95.0),
),
_line(
110.0,
_frag("Amherst Island 2", x=72.0, y=110.0),
_frag("Ontario", x=200.0, y=110.0),
_frag("75", x=290.0, y=110.0),
),
# Prose after table
_line(140.0, _frag("Notes:", x=72.0, y=140.0, bold=True)),
_line(154.0, _frag("1 PPA signed", x=85.0, y=154.0)),
],
)
request = PdfToMarkdownRequest(
user_message="markdown",
page_layout=[layout],
)
assert len(request.page_layout[0].lines) == 6
# Header line has 3 fragments at distinct x-positions (column detection)
header_line = request.page_layout[0].lines[1]
xs = [f.x for f in header_line.fragments]
assert xs == [72.0, 200.0, 290.0]
# Data rows have matching x-positions
data_row = request.page_layout[0].lines[2]
assert [f.x for f in data_row.fragments] == [72.0, 200.0, 290.0]
@@ -2700,6 +2700,13 @@ whole_doc_compression_round = "Consolidating notes..."
whole_doc_read_done = "Finished reading the document..."
whole_doc_read_started = "Reading the document..."
whole_doc_slice_done = "Reading the document... ({{percent}}% complete)"
ranForSeconds = "Ran for {{count}} seconds"
ranForSeconds_one = "Ran for 1 second"
ranForSeconds_other = "Ran for {{count}} seconds"
ranForMinutes = "Ran for {{count}} minutes"
ranForMinutes_one = "Ran for 1 minute"
ranForMinutes_other = "Ran for {{count}} minutes"
ranForMinutesSeconds = "Ran for {{minutes}} min, {{seconds}} sec"
[chat.quickActions]
browseYourFiles = "Browse your files"
@@ -3042,6 +3049,37 @@ impact = "Any applications or services currently using these keys will stop work
title = "Refresh API Keys"
warning = "⚠️ Warning: This action will generate new API keys and make your previous keys invalid."
[config.mcp]
description = "Model Context Protocol (MCP) lets AI assistants like Claude use your Stirling PDF tools directly. Connect a client once and your assistant can convert, edit, secure and process documents on your behalf."
navLabel = "MCP Server"
tip = "Every action your assistant runs is performed as your account and counts towards your usage, just like using Stirling PDF directly."
title = "MCP Server"
viewApiKeys = "View API keys"
[config.mcp.copy]
configLabel = "Config"
copied = "Copied"
copy = "Copy"
endpointLabel = "Endpoint URL"
tooltip = "Copy {{label}}"
tooltipCopied = "{{label}} copied"
[config.mcp.endpoint]
label = "Your MCP endpoint"
[config.mcp.setup]
addTo = "Add to"
hint = "Pick your client, paste the snippet into the file shown, then restart it. You'll sign in with your Stirling account on first use - no keys to copy."
title = "Connect your AI assistant"
[config.mcp.tools]
ai = "AI"
convert = "Convert"
misc = "Misc"
pages = "Pages"
security = "Security"
title = "What your assistant can do"
[config.overview]
description = "Current application settings and configuration details."
error = "Error"
@@ -3203,7 +3241,6 @@ creditsRemaining = "{{current}} of {{total}} remaining"
creditsThisMonth = "Monthly credits"
current = "Current Plan"
customPricing = "Custom"
customWatchedFolders = "Custom Watched Folders"
dedicatedSupportSlas = "Dedicated support & SLAs"
enterpriseSubscription = "Enterprise"
everythingInCredits = "Everything in Credits, plus:"
@@ -3511,7 +3548,6 @@ addFiles = "Add Files"
[fileManager]
active = "Active"
addToWatchedFolder = "Add to Watched Folder"
addToUpload = "Add to Upload"
changesNotUploaded = "Changes not uploaded"
clearAll = "Clear All"
@@ -4471,7 +4507,7 @@ alreadyHaveAccount = "Already have an account?"
choosePassword = "Choose a password"
confirmPassword = "Confirm password"
confirmPasswordPlaceholder = "Re-enter your password"
createAccount = "Create Account"
createAccount = "Create account"
creating = "Creating Account..."
email = "Email address"
emailPlaceholder = "Enter your email address"
@@ -4554,11 +4590,10 @@ sending = "Sending…"
sendMagicLink = "Send Magic Link"
sendResetLink = "Send reset link"
sessionExpired = "Your session has expired. Please sign in again."
createAccount = "Create an account"
signin = "Sign in"
signInAnonymously = "Sign Up as a Guest"
signingIn = "Signing in..."
signInWith = "Sign in with"
subtitle = "Sign back in to Stirling PDF"
title = "Sign in"
unexpectedError = "Unexpected error: {{message}}"
updatePassword = "Update password"
@@ -5774,7 +5809,6 @@ label = "SMTP Username"
[quickAccess]
access = "Access"
watchedFolders = "Watched Folders"
accessAddPerson = "Add another person"
accessBack = "Back"
accessCopyLink = "Copy link"
@@ -6865,6 +6899,7 @@ createFailed = "Failed to create signing request"
[signup]
accountCreatedSuccessfully = "Account created successfully! You can now sign in."
alreadyHaveAccount = "I already have an account"
checkEmailConfirmation = "Check your email for a confirmation link to complete your registration."
confirmPassword = "Confirm password"
confirmPasswordPlaceholder = "Confirm password"
@@ -6885,10 +6920,11 @@ passwordsDoNotMatch = "Passwords do not match"
passwordTooShort = "Password must be at least 6 characters long"
pleaseFillAllFields = "Please fill in all fields"
signUp = "Sign Up"
signUpWith = "Sign up with"
skip = "Skip"
subtitle = "Join Stirling PDF to get started"
title = "Create an account"
unexpectedError = "Unexpected error: {{message}}"
useEmailInstead = "Use Email Instead"
[sizes]
large = "Large"
@@ -8162,20 +8198,14 @@ newFolder = "New folder"
noFolders = "No watched folders yet"
sidebarTitle = "Watched Folders"
title = "Watched Folders"
sidebarFiles = "My Files"
[watchedFolders.modal]
automation = "Automation"
automationSaved = "Automation saved"
createFolder = "Create Folder"
stepsSaved = "Steps saved — click Create Folder to finish"
automationRequired = "Add at least one configured step before saving."
color = "Accent color"
createTitle = "New Watched Folder"
description = "Description"
descriptionPlaceholder = "What does this folder do?"
editTitle = "Edit Watched Folder"
icon = "Icon"
name = "Folder name"
namePlaceholder = "My Watched Folder"
nameRequired = "Folder name is required"
@@ -8183,17 +8213,13 @@ nameTooLong = "Folder name must be 50 characters or less"
saveChanges = "Save Changes"
saveFailed = "Failed to save folder. Please try again."
automationNameFallback = "Watched Folder automation"
retryLabel = "Auto-retry"
maxRetries = "Max retries"
maxRetriesDesc = "0 to disable"
retryDelay = "Delay (minutes)"
outputLabel = "Output"
outputModeVersion = "Replace original?"
outputModeVersionDesc = "Output becomes a new version of the input file rather than a separate file"
outputModeNewDesc = "Output is saved as a new separate file"
outputName = "Output filename prefix"
outputNameSuffix = "Suffix"
outputNamePlaceholderVersion = "Same as original"
sectionFolder = "Folder"
sectionSourceOutput = "Source & Output"
sectionSteps = "Steps"
@@ -8220,14 +8246,10 @@ positionSuffix = "Suffix"
[watchedFolders.home]
create = "Create your first Watched Folder"
dropHere = "Drop to process"
editFolder = "Edit folder"
empty = "No watched folders yet"
file = "file"
files = "files"
openFolder = "Open folder"
title = "Watched Folders"
subtitle = "Folders that automatically process PDFs with your configured pipeline"
emptyTitle = "Automate your PDF workflows"
emptyDesc = "Set up a Watched Folder once. Drop PDFs in and they're automatically compressed, OCR'd, split, merged — whatever your pipeline does."
addAnother = "Add another Watched Folder"
@@ -8235,7 +8257,6 @@ addAnotherDesc = "Automatically process files with a new pipeline"
resume = "Resume"
pause = "Pause"
deleteFolder = "Delete folder"
noSteps = "No automation steps configured"
[watchedFolders.card]
edit = "Edit folder"
@@ -8318,7 +8339,6 @@ previewLoadFailed = "Could not load file preview."
back = "Back"
dismiss = "Dismiss"
retry = "Retry"
view = "View"
download = "Download"
downloadInput = "Download input"
downloadOutput = "Download output"
File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 7.8 KiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 7.8 KiB

@@ -10,6 +10,7 @@ export function useChat() {
isOpen: false,
isLoading: false,
progress: null,
progressLog: [] as never[],
toggleOpen: () => {},
setOpen: (_open: boolean) => {},
sendMessage: async (_content: string) => {},
@@ -19,12 +19,7 @@ test.describe("1. Authentication and Login", () => {
.first(),
).toBeVisible();
// Step 3: Confirm the heading for "Sign In" / "Login" is visible
await expect(
page.getByRole("heading", { name: /sign in|login|masuk/i }),
).toBeVisible();
// Step 4: Confirm a "Username" text input field is present and empty
// Step 3: Confirm a "Username" text input field is present and empty
const usernameInput = page.locator("#email");
await expect(usernameInput).toBeVisible();
await expect(usernameInput).toHaveValue("");
@@ -37,6 +37,14 @@ export interface ChatMessage {
* turns that answered without running any tool.
*/
toolsUsed?: string[];
/**
* Full ordered progress log captured during the AI turn that produced this message.
* Only set on assistant messages; used to render the "Ran for X seconds" collapsed
* history dropdown above the response.
*/
progressLog?: AiWorkflowProgress[];
/** Wall-clock duration of the AI turn in milliseconds. Only set on assistant messages. */
durationMs?: number;
}
export enum AiWorkflowPhase {
@@ -178,12 +186,20 @@ interface ChatState {
isOpen: boolean;
isLoading: boolean;
progress: AiWorkflowProgress | null;
/** Ordered log of every progress event in the current request. UI shows the last N entries. */
progressLog: AiWorkflowProgress[];
}
/**
* Maximum number of progress steps retained in the live buffer.
*/
export const PROGRESS_LOG_MAX = 4;
type ChatAction =
| { type: "ADD_MESSAGE"; message: ChatMessage }
| { type: "SET_LOADING"; loading: boolean }
| { type: "SET_PROGRESS"; progress: AiWorkflowProgress | null }
| { type: "APPEND_PROGRESS"; progress: AiWorkflowProgress }
| { type: "TOGGLE_OPEN" }
| { type: "SET_OPEN"; open: boolean }
| { type: "CLEAR" };
@@ -193,15 +209,40 @@ function chatReducer(state: ChatState, action: ChatAction): ChatState {
case "ADD_MESSAGE":
return { ...state, messages: [...state.messages, action.message] };
case "SET_LOADING":
return { ...state, isLoading: action.loading };
// Reset the log on both start (true) and end (false) of a request.
return {
...state,
isLoading: action.loading,
progress: action.loading ? state.progress : null,
progressLog: [],
};
case "SET_PROGRESS":
return { ...state, progress: action.progress };
case "APPEND_PROGRESS":
// Cap the live buffer so each append copies at most PROGRESS_LOG_MAX elements
return {
...state,
progress: action.progress,
progressLog:
state.progressLog.length < PROGRESS_LOG_MAX
? [...state.progressLog, action.progress]
: [
...state.progressLog.slice(1 - PROGRESS_LOG_MAX),
action.progress,
],
};
case "TOGGLE_OPEN":
return { ...state, isOpen: !state.isOpen };
case "SET_OPEN":
return { ...state, isOpen: action.open };
case "CLEAR":
return { ...state, messages: [], isLoading: false, progress: null };
return {
...state,
messages: [],
isLoading: false,
progress: null,
progressLog: [],
};
}
}
@@ -325,6 +366,8 @@ interface ChatContextValue {
isOpen: boolean;
isLoading: boolean;
progress: AiWorkflowProgress | null;
/** Ordered log of every progress event for the current in-flight request. */
progressLog: AiWorkflowProgress[];
toggleOpen: () => void;
setOpen: (open: boolean) => void;
sendMessage: (content: string) => Promise<void>;
@@ -339,6 +382,7 @@ const initialState: ChatState = {
isOpen: false,
isLoading: false,
progress: null,
progressLog: [],
};
export function ChatProvider({ children }: { children: ReactNode }) {
@@ -440,6 +484,11 @@ export function ChatProvider({ children }: { children: ReactNode }) {
abortRef.current = controller;
const priorMessages = messagesRef.current;
const startTime = Date.now();
// Mirror every progress event locally so we can attach the full log to
// the assistant message when the result arrives — without needing a ref
// into the reducer state.
const progressLogLocal: AiWorkflowProgress[] = [];
const userMessage: ChatMessage = {
id: generateId(),
@@ -503,16 +552,15 @@ export function ChatProvider({ children }: { children: ReactNode }) {
) {
toolsUsed.push(data.tool);
}
dispatch({
type: "SET_PROGRESS",
progress: {
phase: data.phase as AiWorkflowPhase,
tool: data.tool,
stepIndex: data.stepIndex,
stepCount: data.stepCount,
engineDetail: data.engineDetail,
},
});
const progressItem: AiWorkflowProgress = {
phase: data.phase as AiWorkflowPhase,
tool: data.tool,
stepIndex: data.stepIndex,
stepCount: data.stepCount,
engineDetail: data.engineDetail,
};
progressLogLocal.push(progressItem);
dispatch({ type: "APPEND_PROGRESS", progress: progressItem });
},
onResult: (data) => {
receivedResult = true;
@@ -526,6 +574,11 @@ export function ChatProvider({ children }: { children: ReactNode }) {
content: replyContent,
timestamp: Date.now(),
toolsUsed: toolsUsed.length > 0 ? toolsUsed : undefined,
progressLog:
progressLogLocal.length > 0
? [...progressLogLocal]
: undefined,
durationMs: Date.now() - startTime,
},
});
if (data.fileId || data.resultFiles?.length) {
@@ -599,6 +652,7 @@ export function ChatProvider({ children }: { children: ReactNode }) {
isOpen: state.isOpen,
isLoading: state.isLoading,
progress: state.progress,
progressLog: state.progressLog,
toggleOpen,
setOpen,
sendMessage,
@@ -396,7 +396,182 @@
margin-bottom: 0;
}
/* Thinking indicator */
/* ─── Progress step log ─────────────────────────────────────────────────── */
/*
* Shown while the AI is working. Each step slides in from below as events
* arrive; the active (last) step is full-opacity with a pulsing icon, and
* earlier completed steps are dimmed. Only the most-recent N steps are
* rendered there is no scrollable list.
*/
.chat-progress-log {
display: flex;
flex-direction: column;
padding: 0.15rem 0.5rem 0.35rem;
/* No gap — the connector line div provides the inter-step spacing. */
}
.chat-progress-step {
display: flex;
align-items: flex-start;
gap: 0.65rem;
/*
* Use color rather than opacity for past-step dimming so the connector
* line (a sibling element inside __left, not a text node) is NOT affected
* and stays clearly visible. currentColor flows into SVG icon fills;
* color inherits into the label span automatically.
*/
color: var(--text-muted);
transition: color 250ms ease-out;
animation: chat-step-enter 300ms cubic-bezier(0.22, 1, 0.36, 1) both;
}
/*
* Active step: white text on dark mode so currentColor feeds directly into
* the StirlingLogoAnimated SVG paths and the label at the same time.
* Light-mode falls back to the default text colour so it's not invisible.
*/
/* Active step: icon and label are coloured independently so the logo can be
blue while the text uses the normal text colour. */
.chat-progress-step--active .chat-progress-step__icon {
color: var(--mantine-color-blue-filled);
}
.chat-progress-step--active .chat-progress-step__label {
color: var(--mantine-color-text);
}
[data-mantine-color-scheme="dark"]
.chat-progress-step--active
.chat-progress-step__label {
color: #ffffff;
}
.chat-progress-step__left {
display: flex;
flex-direction: column;
align-items: center;
flex-shrink: 0;
width: 20px;
}
/* Icon cell — fixed height so the connector line aligns cleanly. */
.chat-progress-step__icon {
width: 20px;
height: 24px;
display: flex;
align-items: center;
justify-content: center;
overflow: hidden;
flex-shrink: 0;
/* Inherits color from .chat-progress-step so currentColor flows into icons. */
}
/*
* Connector line between steps. Not a child of the label so it is unaffected
* by the step's color; it sits on its own and reads clearly against the
* toolbar background.
*/
.chat-progress-step__line {
width: 1px;
height: 18px;
background: var(--border-subtle);
flex-shrink: 0;
margin-top: 2px;
}
/* Active step label — slightly larger, inherits the active color. */
.chat-progress-step__label {
font-size: 0.875rem;
padding-top: 3px;
line-height: 1.35;
}
/* Past step labels — a touch smaller so the active row reads as the primary. */
.chat-progress-step:not(.chat-progress-step--active)
.chat-progress-step__label {
font-size: 0.8rem;
padding-top: 4px;
}
/*
* Wrapper that scales a registry tool icon (LocalIcon, typically 1.5rem/24px)
* down to fit the 20px icon column. Transform does not affect layout, so the
* parent overflow:hidden clips the un-scaled footprint cleanly.
*/
.chat-step-icon-scaled {
display: inline-flex;
align-items: center;
justify-content: center;
transform: scale(0.75);
transform-origin: center;
flex-shrink: 0;
}
/* Entry: steps slide up from a few pixels below their final position. */
@keyframes chat-step-enter {
from {
transform: translateY(7px);
}
to {
transform: translateY(0);
}
}
/* ─── Completed progress log dropdown ──────────────────────────────────── */
/*
* Shown above each completed assistant turn. The toggle displays a small
* "Ran for X seconds" label; expanding reveals the full ordered step list
* for that turn in a compact, read-only format.
*/
.chat-completed-log {
margin-bottom: 0.55rem;
}
.chat-completed-log__toggle {
display: inline-flex;
align-items: center;
padding: 0.1rem 0.25rem 0.1rem 0.1rem;
border-radius: 0.35rem;
color: var(--text-muted);
transition: background 120ms ease-out;
}
.chat-completed-log__toggle:hover {
background: var(--mantine-color-default-hover);
}
.chat-completed-log__steps {
display: flex;
flex-direction: column;
padding: 0.35rem 0.4rem 0.2rem 0.25rem;
margin-top: 0.2rem;
}
/* In the completed log every step is a past step no active highlighting,
no entry animation (the content is static once opened). */
.chat-completed-log__steps .chat-progress-step {
animation: none;
}
/* Tighten the icon and connector dimensions for the denser historical view. */
.chat-completed-log__steps .chat-progress-step__icon {
height: 20px;
}
.chat-completed-log__steps .chat-progress-step__line {
height: 14px;
}
.chat-completed-log__steps .chat-progress-step__label {
font-size: 0.775rem;
padding-top: 2px;
}
/* Legacy thinking indicator (kept in case any other code references it). */
.chat-thinking {
display: flex;
align-items: center;
@@ -405,6 +580,13 @@
color: var(--mantine-color-blue-filled);
}
@media (prefers-reduced-motion: reduce) {
.chat-progress-step {
animation: none;
transition: none;
}
}
/* ─── Stirling logo thinking animation ─────────────────────────────────── */
.stirling-thinking__path-right {
@@ -4,6 +4,7 @@ import {
useEffect,
useState,
type KeyboardEvent,
type ReactNode,
} from "react";
import { renderMarkdown } from "@app/components/viewer/nonpdf/MarkdownRenderer";
import { TFunction } from "i18next";
@@ -13,7 +14,6 @@ import {
Box,
Collapse,
Group,
List,
Menu,
Paper,
ScrollArea,
@@ -23,7 +23,10 @@ import {
UnstyledButton,
} from "@mantine/core";
import ArrowUpwardIcon from "@mui/icons-material/ArrowUpward";
import ArticleOutlinedIcon from "@mui/icons-material/ArticleOutlined";
import BuildOutlinedIcon from "@mui/icons-material/BuildOutlined";
import CloseIcon from "@mui/icons-material/Close";
import CloudOutlinedIcon from "@mui/icons-material/CloudOutlined";
import ContentCopyIcon from "@mui/icons-material/ContentCopy";
import DeleteSweepIcon from "@mui/icons-material/DeleteSweep";
import ExpandLessIcon from "@mui/icons-material/ExpandLess";
@@ -33,14 +36,15 @@ import {
useChat,
AiWorkflowPhase,
ChatRole,
PROGRESS_LOG_MAX,
isKnownEngineProgressDetail,
type AiWorkflowProgress,
type AnyEngineProgressDetail,
} from "@app/components/chat/ChatContext";
import { formatRelativeTime } from "@app/utils/timeUtils";
import { useTranslatedToolCatalog } from "@app/data/useTranslatedToolRegistry";
import { StirlingLogoOutline } from "@app/components/agents/StirlingLogoOutline";
import { StirlingLogoAnimated } from "@app/components/agents/StirlingLogoAnimated";
import { StirlingLogoOutline } from "@app/components/agents/StirlingLogoOutline";
import { ChatQuickActions } from "@app/components/chat/ChatQuickActions";
import "@app/components/chat/ChatPanel.css";
@@ -68,6 +72,27 @@ function useToolNameResolver(): ToolNameResolver {
}, [allTools]);
}
/** Resolver mapping a tool endpoint path to its registry icon ReactNode. */
type ToolIconResolver = (endpoint: string) => ReactNode | null;
/**
* Look up a tool's icon ReactNode from the tool catalog, keyed by API endpoint path.
* Returns null when the endpoint is not found (use a generic fallback icon in that case).
*/
function useToolIconResolver(): ToolIconResolver {
const { allTools } = useTranslatedToolCatalog();
return useMemo(() => {
const iconByEndpoint = new Map<string, ReactNode>();
Object.values(allTools).forEach((tool) => {
const endpoint = tool.operationConfig?.endpoint;
if (typeof endpoint === "string") {
iconByEndpoint.set(endpoint, tool.icon);
}
});
return (endpoint: string) => iconByEndpoint.get(endpoint) ?? null;
}, [allTools]);
}
function formatProgress(
progress: AiWorkflowProgress,
t: TranslateFn,
@@ -125,31 +150,152 @@ function formatEngineProgress(
}
}
function ToolsUsedBlock({
tools,
resolveToolName,
/**
* Choose an icon for a progress step.
*
* The active (current) step always shows the animated Stirling logo so it reads
* as the "live" indicator. Past steps get a phase-specific icon so the trail
* is scannable at a glance.
*/
function progressStepIcon(
progress: AiWorkflowProgress,
resolveToolIcon: ToolIconResolver,
isActive: boolean,
): ReactNode {
if (isActive) {
return <StirlingLogoAnimated size={18} />;
}
if (progress.phase === AiWorkflowPhase.EXECUTING_TOOL) {
const registryIcon = progress.tool ? resolveToolIcon(progress.tool) : null;
if (registryIcon) {
return <span className="chat-step-icon-scaled">{registryIcon}</span>;
}
return <BuildOutlinedIcon sx={{ fontSize: 17 }} />;
}
if (
progress.phase === AiWorkflowPhase.EXTRACTING_CONTENT ||
progress.phase === AiWorkflowPhase.ENGINE_PROGRESS
) {
return <ArticleOutlinedIcon sx={{ fontSize: 17 }} />;
}
return <CloudOutlinedIcon sx={{ fontSize: 17 }} />;
}
/**
* Animated step-by-step progress log shown while the AI is working.
* Displays the last {@link PROGRESS_LOG_VISIBLE} steps from the live event stream,
* with the active (most recent) step highlighted and older steps dimmed.
*/
function ProgressLogDisplay({
progressLog,
t,
resolveToolName,
resolveToolIcon,
}: {
tools: string[];
resolveToolName: ToolNameResolver;
progressLog: AiWorkflowProgress[];
t: TranslateFn;
resolveToolName: ToolNameResolver;
resolveToolIcon: ToolIconResolver;
}) {
// Placeholder shown before the first SSE event arrives.
if (progressLog.length === 0) {
return (
<div className="chat-progress-log">
<div className="chat-progress-step chat-progress-step--active">
<div className="chat-progress-step__left">
<div className="chat-progress-step__icon">
<StirlingLogoAnimated size={18} />
</div>
</div>
<span className="chat-progress-step__label">
{t("chat.progress.thinking")}
</span>
</div>
</div>
);
}
// Chronological order: oldest at top, newest (active) at bottom.
// The reducer already caps progressLog at PROGRESS_LOG_MAX entries, so this
// slice is effectively a no-op but kept for defensive correctness.
const visibleSteps = progressLog.slice(-PROGRESS_LOG_MAX);
const startIndex = progressLog.length - visibleSteps.length;
return (
<div className="chat-progress-log">
{visibleSteps.map((step, i) => {
// Stable key based on absolute position in the full log — React reuses
// existing DOM elements and only mounts (and animates) new ones.
const globalIndex = startIndex + i;
const isActive = i === visibleSteps.length - 1; // last = newest = bottom
// Connector runs below every step except the active one at the bottom.
const showConnector = i < visibleSteps.length - 1;
const label = formatProgress(step, t, resolveToolName);
return (
<div
key={globalIndex}
className={`chat-progress-step${isActive ? " chat-progress-step--active" : ""}`}
>
<div className="chat-progress-step__left">
<div className="chat-progress-step__icon">
{progressStepIcon(step, resolveToolIcon, isActive)}
</div>
{showConnector && <div className="chat-progress-step__line" />}
</div>
<span className="chat-progress-step__label">{label}</span>
</div>
);
})}
</div>
);
}
function formatDuration(ms: number, t: TranslateFn): string {
const totalSeconds = Math.max(1, Math.round(ms / 1000));
const minutes = Math.floor(totalSeconds / 60);
const seconds = totalSeconds % 60;
if (minutes === 0) {
return t("chat.progress.ranForSeconds", { count: totalSeconds });
}
if (seconds === 0) {
return t("chat.progress.ranForMinutes", { count: minutes });
}
return t("chat.progress.ranForMinutesSeconds", { minutes, seconds });
}
/**
* Collapsed "Ran for X seconds" dropdown that appears above each completed
* assistant turn. Expands to show the full ordered progress log for that turn.
*/
function CompletedProgressLogDropdown({
progressLog,
durationMs,
t,
resolveToolName,
resolveToolIcon,
}: {
progressLog: AiWorkflowProgress[];
durationMs: number;
t: TranslateFn;
resolveToolName: ToolNameResolver;
resolveToolIcon: ToolIconResolver;
}) {
const [expanded, setExpanded] = useState(false);
const names = tools.map(
(endpoint) => resolveToolName(endpoint) ?? t("chat.toolsUsed.unknownTool"),
);
const label = t("chat.toolsUsed.summary", { count: tools.length });
const label = formatDuration(durationMs, t);
return (
<Box mt={6}>
<div className="chat-completed-log">
<UnstyledButton
className="chat-completed-log__toggle"
onClick={() => setExpanded((v) => !v)}
aria-expanded={expanded}
>
<Group gap={4} wrap="nowrap">
<Group gap={4} wrap="nowrap" align="center">
{expanded ? (
<ExpandLessIcon sx={{ fontSize: 14 }} />
<ExpandLessIcon sx={{ fontSize: 12 }} />
) : (
<ExpandMoreIcon sx={{ fontSize: 14 }} />
<ExpandMoreIcon sx={{ fontSize: 12 }} />
)}
<Text size="xs" c="dimmed">
{label}
@@ -157,19 +303,30 @@ function ToolsUsedBlock({
</Group>
</UnstyledButton>
<Collapse in={expanded}>
<List
type="ordered"
size="xs"
mt={4}
pl="lg"
styles={{ itemWrapper: { lineHeight: 1.4 } }}
>
{names.map((name, i) => (
<List.Item key={i}>{name}</List.Item>
))}
</List>
<div className="chat-completed-log__steps">
{progressLog.map((step, i) => {
const showConnector = i < progressLog.length - 1;
const stepLabel = formatProgress(step, t, resolveToolName);
return (
<div
key={i}
className="chat-progress-step chat-progress-step--done"
>
<div className="chat-progress-step__left">
<div className="chat-progress-step__icon">
{progressStepIcon(step, resolveToolIcon, false)}
</div>
{showConnector && (
<div className="chat-progress-step__line" />
)}
</div>
<span className="chat-progress-step__label">{stepLabel}</span>
</div>
);
})}
</div>
</Collapse>
</Box>
</div>
);
}
@@ -177,15 +334,19 @@ function ChatMessageBubble({
role,
content,
timestamp,
toolsUsed,
progressLog,
durationMs,
resolveToolName,
resolveToolIcon,
t,
}: {
role: ChatRole;
content: string;
timestamp: number;
toolsUsed?: string[];
progressLog?: AiWorkflowProgress[];
durationMs?: number;
resolveToolName: ToolNameResolver;
resolveToolIcon: ToolIconResolver;
t: TranslateFn;
}) {
const [copied, setCopied] = useState(false);
@@ -231,16 +392,18 @@ function ChatMessageBubble({
return (
<div className="chat-message chat-message-assistant">
<div className="chat-bubble-assistant">
{progressLog && progressLog.length > 0 && durationMs != null && (
<CompletedProgressLogDropdown
progressLog={progressLog}
durationMs={durationMs}
t={t}
resolveToolName={resolveToolName}
resolveToolIcon={resolveToolIcon}
/>
)}
<Text size="sm" component="div">
{renderMarkdown(content)}
</Text>
{toolsUsed && toolsUsed.length > 0 && (
<ToolsUsedBlock
tools={toolsUsed}
resolveToolName={resolveToolName}
t={t}
/>
)}
{actions}
</div>
</div>
@@ -256,20 +419,55 @@ export interface ChatPanelProps {
export function ChatPanel({ onBack, backLabel }: ChatPanelProps) {
const { t } = useTranslation();
const { messages, isLoading, progress, sendMessage, clearChat } = useChat();
const { messages, isLoading, progressLog, sendMessage, clearChat } =
useChat();
const resolveToolName = useToolNameResolver();
const resolveToolIcon = useToolIconResolver();
const [input, setInput] = useState("");
const scrollRef = useRef<HTMLDivElement>(null);
const inputRef = useRef<HTMLTextAreaElement>(null);
// Tracks whether the user manually scrolled away from the bottom.
// A ref (not state) so scroll events don't cause re-renders.
const userScrolledUp = useRef(false);
// Jump to the bottom on first render so existing conversations open at the
// most recent message rather than the top.
useEffect(() => {
if (scrollRef.current) {
scrollRef.current.scrollTo({
top: scrollRef.current.scrollHeight,
behavior: "smooth",
requestAnimationFrame(() => {
const el = scrollRef.current;
if (el) el.scrollTop = el.scrollHeight;
});
}, []);
// Attach a passive scroll listener to track whether the user has scrolled
// away from the bottom (breaks auto-scroll) or returned to it (re-latches).
useEffect(() => {
const el = scrollRef.current;
if (!el) return;
const onScroll = () => {
const distFromBottom = el.scrollHeight - el.scrollTop - el.clientHeight;
userScrolledUp.current = distFromBottom > 50;
};
el.addEventListener("scroll", onScroll, { passive: true });
return () => el.removeEventListener("scroll", onScroll);
}, []);
// Scroll to the bottom when messages arrive or live progress steps update,
// unless the user has scrolled up (they're reading history).
// Scrolling back to the bottom resets the ref, so the next update re-latches.
//
// RAF defers the scroll until after the browser has laid out the new nodes,
// so scrollHeight is correct. Direct scrollTop assignment avoids the
// smooth-scroll interruption problem that occurs when SSE events arrive
// faster than a smooth animation can complete.
useEffect(() => {
if (!userScrolledUp.current) {
requestAnimationFrame(() => {
const el = scrollRef.current;
if (el) el.scrollTop = el.scrollHeight;
});
}
}, [messages]);
}, [messages, progressLog]);
useEffect(() => {
inputRef.current?.focus();
@@ -343,21 +541,21 @@ export function ChatPanel({ onBack, backLabel }: ChatPanelProps) {
role={msg.role}
content={msg.content}
timestamp={msg.timestamp}
toolsUsed={msg.toolsUsed}
progressLog={msg.progressLog}
durationMs={msg.durationMs}
resolveToolName={resolveToolName}
resolveToolIcon={resolveToolIcon}
t={t}
/>
))}
{isLoading && (
<div className="chat-message chat-message-assistant">
<div className="chat-thinking">
<StirlingLogoAnimated size={20} />
<Text size="sm" c="dimmed">
{progress
? formatProgress(progress, t, resolveToolName)
: t("chat.progress.thinking")}
</Text>
</div>
<ProgressLogDisplay
progressLog={progressLog}
t={t}
resolveToolName={resolveToolName}
resolveToolIcon={resolveToolIcon}
/>
</div>
)}
</Stack>
@@ -17,12 +17,11 @@ import { useDocumentMeta } from "@app/hooks/useDocumentMeta";
import AuthLayout from "@app/routes/authShared/AuthLayout";
import { useBackendProbe } from "@app/hooks/useBackendProbe";
import apiClient from "@app/services/apiClient";
import { BASE_PATH } from "@app/constants/app";
import { BASE_PATH, withBasePath } from "@app/constants/app";
import { type OAuthProvider } from "@app/auth/oauthTypes";
import { updateSupportedLanguages } from "@app/i18n";
// Import login components
import LoginHeader from "@app/routes/login/LoginHeader";
import ErrorMessage from "@app/routes/login/ErrorMessage";
import EmailPasswordForm from "@app/routes/login/EmailPasswordForm";
import OAuthButtons, {
@@ -74,7 +73,6 @@ export default function Login() {
const autoLoginErrorRecorded = useRef(false);
const isUserPassAllowed = loginMethod === "all" || loginMethod === "normal";
const isSsoOnlyMode = loginMethod !== "all" && loginMethod !== "normal";
const isSingleSsoOnly = !isUserPassAllowed && enabledProviders.length === 1;
const AUTO_LOGIN_ATTEMPTS_KEY = "stirling_sso_auto_login_attempts";
const AUTO_LOGIN_ERRORS_KEY = "stirling_sso_auto_login_errors";
@@ -461,7 +459,6 @@ export default function Login() {
// If backend isn't ready yet, show a lightweight status screen instead of the form
if (backendProbe.status !== "up" && !loginDisabled) {
const backendTitle = t("backendStartup.notFoundTitle", "Backend not found");
const handleRetry = async () => {
const result = await backendProbe.probe();
if (result.status === "up") {
@@ -471,7 +468,18 @@ export default function Login() {
};
return (
<AuthLayout>
<LoginHeader title={backendTitle} />
<div className="auth-logo-block">
<img
src={withBasePath("/modern-logo/LoginLightModeHeader.svg")}
alt="Stirling PDF"
className="auth-logo-header auth-logo-header--light"
/>
<img
src={withBasePath("/modern-logo/LoginDarkModeHeader.svg")}
alt="Stirling PDF"
className="auth-logo-header auth-logo-header--dark"
/>
</div>
<div
className="auth-section"
style={{
@@ -555,10 +563,18 @@ export default function Login() {
return (
<AuthLayout>
<LoginHeader
title={isSingleSsoOnly ? "" : t("login.login") || "Sign in"}
centerOnly={isSingleSsoOnly}
/>
<div className="auth-logo-block">
<img
src={withBasePath("/modern-logo/LoginLightModeHeader.svg")}
alt="Stirling PDF"
className="auth-logo-header auth-logo-header--light"
/>
<img
src={withBasePath("/modern-logo/LoginDarkModeHeader.svg")}
alt="Stirling PDF"
className="auth-logo-header auth-logo-header--dark"
/>
</div>
{/* Success message */}
{successMessage && (
@@ -5,10 +5,9 @@ import { useDocumentMeta } from "@app/hooks/useDocumentMeta";
import { useAuth } from "@app/auth/UseSession";
import AuthLayout from "@app/routes/authShared/AuthLayout";
import "@app/routes/authShared/auth.css";
import { BASE_PATH } from "@app/constants/app";
import { BASE_PATH, withBasePath } from "@app/constants/app";
// Import signup components
import LoginHeader from "@app/routes/login/LoginHeader";
import ErrorMessage from "@app/routes/login/ErrorMessage";
import DividerWithText from "@app/components/shared/DividerWithText";
import SignupForm from "@app/routes/signup/SignupForm";
@@ -92,10 +91,18 @@ export default function Signup() {
return (
<AuthLayout>
<LoginHeader
title={t("signup.title", "Create an account")}
subtitle={t("signup.subtitle", "Join Stirling PDF")}
/>
<div className="auth-logo-block">
<img
src={withBasePath("/modern-logo/LoginLightModeHeader.svg")}
alt="Stirling PDF"
className="auth-logo-header auth-logo-header--light"
/>
<img
src={withBasePath("/modern-logo/LoginDarkModeHeader.svg")}
alt="Stirling PDF"
className="auth-logo-header auth-logo-header--dark"
/>
</div>
<ErrorMessage error={error} />
@@ -615,3 +615,90 @@
background-color: #af3434 !important;
opacity: 0.6 !important;
}
/* ── Logo block ─────────────────────────────────────────────────────── */
.auth-logo-block {
display: flex;
flex-direction: column;
align-items: center;
margin-bottom: 2rem;
margin-top: 0.5rem;
animation: authFadeUp 0.4s ease both;
}
.auth-logo-header {
height: 8rem;
width: auto;
}
.auth-logo-header--dark {
display: none;
}
/* ── Page entrance animation ────────────────────────────────────────── */
@keyframes authFadeUp {
from {
opacity: 0;
transform: translateY(12px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
/* ── Expandable trigger button state ────────────────────────────────── */
.auth-expandable-trigger {
transition:
background-color 180ms ease,
box-shadow 180ms ease,
border-color 180ms ease;
}
.auth-expandable-trigger--active {
border-color: #af3434 !important;
box-shadow: 0 0 0 3px rgba(175, 52, 52, 0.12);
}
/* ── Animated expand/collapse via grid-template-rows ───────────────── */
.auth-expand-grid {
display: grid;
grid-template-rows: 0fr;
transition:
grid-template-rows 280ms cubic-bezier(0.4, 0, 0.2, 1),
opacity 220ms ease;
opacity: 0;
}
.auth-expand-grid--open {
grid-template-rows: 1fr;
opacity: 1;
}
.auth-expand-inner {
overflow: hidden;
min-height: 0;
}
/* ── Icon+label group — keeps icons vertically aligned across buttons ── */
.oauth-btn-group {
display: inline-flex;
align-items: center;
min-width: 12.5rem;
}
.oauth-btn-label {
flex: 1;
text-align: center;
}
/* ── Icon helpers inside oauth-button-fullwidth ─────────────────────── */
.auth-at-icon {
font-size: 1.25rem;
line-height: 1;
margin-right: 0.5rem;
display: inline-block;
width: 1.75rem;
text-align: center;
flex-shrink: 0;
}
@@ -0,0 +1,262 @@
import { useMemo } from "react";
import { useTranslation } from "react-i18next";
import {
Stack,
Paper,
Text,
Group,
Alert,
Code,
Badge,
Button,
CopyButton,
Tabs,
Tooltip,
ThemeIcon,
} from "@mantine/core";
import LocalIcon from "@app/components/shared/LocalIcon";
import { useAppConfig } from "@app/contexts/AppConfigContext";
import { openAppSettings } from "@app/utils/appSettings";
/** Strip a single trailing slash so we can safely append paths. */
function trimTrailingSlash(url: string): string {
return url.endsWith("/") ? url.slice(0, -1) : url;
}
/** A small copy-to-clipboard button that sits inline next to a URL/snippet. */
function CopyInline({ value, label }: { value: string; label: string }) {
const { t } = useTranslation();
return (
<CopyButton value={value} timeout={1500}>
{({ copied, copy }) => (
<Tooltip
label={
copied
? t("config.mcp.copy.tooltipCopied", "{{label}} copied", {
label,
})
: t("config.mcp.copy.tooltip", "Copy {{label}}", { label })
}
withArrow
>
<Button
size="compact-xs"
variant="subtle"
color={copied ? "teal" : "gray"}
onClick={copy}
leftSection={
<LocalIcon
icon={copied ? "check-rounded" : "content-copy-rounded"}
width={14}
height={14}
/>
}
>
{copied
? t("config.mcp.copy.copied", "Copied")
: t("config.mcp.copy.copy", "Copy")}
</Button>
</Tooltip>
)}
</CopyButton>
);
}
// SaaS MCP guide: always shown, explains how to point an AI assistant at the
// OAuth-protected /mcp endpoint with per-client config.
export default function McpSection() {
const { t } = useTranslation();
const { config } = useAppConfig();
const baseUrl = useMemo(() => {
const raw =
config?.baseUrl ||
(typeof window !== "undefined" ? window.location.origin : "");
return trimTrailingSlash(raw);
}, [config?.baseUrl]);
const mcpUrl = `${baseUrl}/mcp`;
// Per-client connection snippets pointing at this deployment's /mcp endpoint.
const clients = useMemo(
() => [
{
value: "claude",
label: "Claude Desktop",
file: "claude_desktop_config.json",
config: JSON.stringify(
{ mcpServers: { "stirling-pdf": { type: "http", url: mcpUrl } } },
null,
2,
),
},
{
value: "codex",
label: "Codex CLI",
file: "~/.codex/config.toml",
config: `[mcp_servers.stirling-pdf]\nurl = "${mcpUrl}"`,
},
{
value: "vscode",
label: "VS Code",
file: ".vscode/mcp.json",
config: JSON.stringify(
{ servers: { "stirling-pdf": { type: "http", url: mcpUrl } } },
null,
2,
),
},
],
[mcpUrl],
);
const tools: { icon: string; key: string; fallback: string }[] = [
{
icon: "sync-alt-rounded",
key: "config.mcp.tools.convert",
fallback: "Convert",
},
{
icon: "description-rounded",
key: "config.mcp.tools.pages",
fallback: "Pages",
},
{ icon: "lock", key: "config.mcp.tools.security", fallback: "Security" },
{
icon: "construction-rounded",
key: "config.mcp.tools.misc",
fallback: "Misc",
},
{ icon: "smart-toy-rounded", key: "config.mcp.tools.ai", fallback: "AI" },
];
return (
<div className="settings-section-container">
<Stack gap="md" className="settings-section-content">
<div>
<Group gap="sm" align="center">
<ThemeIcon variant="light" size="lg" radius="md">
<LocalIcon icon="smart-toy-rounded" width={22} height={22} />
</ThemeIcon>
<Text fw={600} size="lg">
{t("config.mcp.title", "MCP Server")}
</Text>
</Group>
<Text size="sm" c="dimmed" mt={6}>
{t(
"config.mcp.description",
"Model Context Protocol (MCP) lets AI assistants like Claude use your Stirling PDF tools directly. Connect a client once and your assistant can convert, edit, secure and process documents on your behalf.",
)}
</Text>
</div>
{/* Endpoint */}
<Paper withBorder p="sm" radius="md">
<Group gap="xs" wrap="nowrap" align="center">
<Stack gap={2} style={{ flex: 1, minWidth: 0 }}>
<Text fw={500} size="sm">
{t("config.mcp.endpoint.label", "Your MCP endpoint")}
</Text>
<Code style={{ overflowX: "auto" }}>{mcpUrl}</Code>
</Stack>
<CopyInline
value={mcpUrl}
label={t("config.mcp.copy.endpointLabel", "Endpoint URL")}
/>
</Group>
</Paper>
{/* Per-client setup */}
<Paper withBorder p="sm" radius="md">
<Stack gap="xs">
<Text fw={500} size="sm">
{t("config.mcp.setup.title", "Connect your AI assistant")}
</Text>
<Text size="xs" c="dimmed">
{t(
"config.mcp.setup.hint",
"Pick your client, paste the snippet into the file shown, then restart it. You'll sign in with your Stirling account on first use - no keys to copy.",
)}
</Text>
<Tabs defaultValue="claude" variant="pills" radius="md" mt={4}>
<Tabs.List>
{clients.map((c) => (
<Tabs.Tab key={c.value} value={c.value}>
{c.label}
</Tabs.Tab>
))}
</Tabs.List>
{clients.map((c) => (
<Tabs.Panel key={c.value} value={c.value} pt="sm">
<Stack gap="xs">
<Group justify="space-between" align="center" wrap="nowrap">
<Text size="xs" c="dimmed">
{t("config.mcp.setup.addTo", "Add to")}{" "}
<Code>{c.file}</Code>
</Text>
<CopyInline
value={c.config}
label={t("config.mcp.copy.configLabel", "Config")}
/>
</Group>
<Code block>{c.config}</Code>
</Stack>
</Tabs.Panel>
))}
</Tabs>
</Stack>
</Paper>
{/* Available tools */}
<div>
<Text fw={500} size="sm" mb="xs">
{t("config.mcp.tools.title", "What your assistant can do")}
</Text>
<Group gap="xs">
{tools.map((tool) => (
<Badge
key={tool.key}
variant="light"
color="gray"
radius="sm"
size="lg"
leftSection={
<LocalIcon icon={tool.icon} width={13} height={13} />
}
>
{t(tool.key, tool.fallback)}
</Badge>
))}
</Group>
</div>
{/* Tip / cross-link */}
<Alert
variant="light"
color="blue"
icon={<LocalIcon icon="info-rounded" width="1rem" height="1rem" />}
>
<Group justify="space-between" align="center" wrap="nowrap" gap="sm">
<Text size="sm">
{t(
"config.mcp.tip",
"Every action your assistant runs is performed as your account and counts towards your usage, just like using Stirling PDF directly.",
)}
</Text>
<Button
size="xs"
variant="light"
style={{ flexShrink: 0 }}
leftSection={
<LocalIcon icon="key-rounded" width={14} height={14} />
}
onClick={() => openAppSettings("api-keys")}
>
{t("config.mcp.viewApiKeys", "View API keys")}
</Button>
</Group>
</Alert>
</Stack>
</div>
);
}
@@ -9,6 +9,7 @@ import GeneralSection from "@app/components/shared/config/configSections/General
import PasswordSecurity from "@app/components/shared/config/configSections/PasswordSecurity";
import ApiKeys from "@app/components/shared/config/configSections/ApiKeys";
import Plan from "@app/components/shared/config/configSections/Plan";
import McpSection from "@app/components/shared/config/configSections/McpSection";
type OverviewComponent = React.ComponentType<{ onLogoutClick: () => void }>;
@@ -108,6 +109,44 @@ function appendBillingSection(
];
}
// Add an "MCP Server" tab in the Developer section. Always shown in SaaS;
// purely informational, so it appears for anonymous users too.
function appendMcpSection(
sections: ConfigNavSection[],
t: TFunction<"translation", undefined>,
): ConfigNavSection[] {
const hasMcp = sections.some((section) =>
section.items.some((item) => item.key === "mcp"),
);
if (hasMcp) {
return sections;
}
const mcpItem = {
key: "mcp" as const,
label: t("config.mcp.navLabel", "MCP Server"),
icon: "smart-toy-rounded",
component: <McpSection />,
};
const developerIndex = sections.findIndex((section) =>
section.items.some(
(item) => item.key === "developer" || item.key === "api-keys",
),
);
if (developerIndex === -1) {
return [...sections, { title: "Developer", items: [mcpItem] }];
}
return sections.map((section, index) =>
index === developerIndex
? { ...section, items: [...section.items, mcpItem] }
: section,
);
}
export function createSaasConfigNavSections(
Overview: OverviewComponent,
onLogoutClick: () => void,
@@ -151,6 +190,7 @@ export function createSaasConfigNavSections(
sections = ensurePreferencesSection(sections);
sections = appendDeveloperSection(sections);
sections = appendMcpSection(sections, t);
if (!isAnonymous) {
sections = appendBillingSection(sections, t);
@@ -1,8 +1,9 @@
import { VALID_NAV_KEYS as CORE_NAV_KEYS } from "@core/components/shared/config/types";
// SaaS adds an "overview" account section. All other keys (including ones
// SaaS doesn't render today) come from core - subtracting them here would
// just break the type union without affecting runtime nav.
export const VALID_NAV_KEYS = [...CORE_NAV_KEYS, "overview"] as const;
// SaaS adds an "overview" account section and an "mcp" integrations tab. All
// other keys (including ones SaaS doesn't render today) come from core -
// subtracting them here would just break the type union without affecting
// runtime nav.
export const VALID_NAV_KEYS = [...CORE_NAV_KEYS, "overview", "mcp"] as const;
export type NavKey = (typeof VALID_NAV_KEYS)[number];
+196 -104
View File
@@ -7,17 +7,18 @@ import { useDocumentMeta } from "@app/hooks/useDocumentMeta";
import AuthLayout from "@app/routes/authShared/AuthLayout";
import "@app/routes/authShared/auth.css";
import "@app/routes/authShared/saas-auth.css";
import GuestSignInButton from "@app/routes/authShared/GuestSignInButton";
import {
absoluteWithBasePath,
getBaseUrl,
withBasePath,
} from "@app/constants/app";
import LinkRoundedIcon from "@mui/icons-material/LinkRounded";
// Import login components
import LoginHeader from "@app/routes/login/LoginHeader";
import ErrorMessage from "@app/routes/login/ErrorMessage";
import EmailPasswordForm from "@app/routes/login/EmailPasswordForm";
import MagicLinkForm from "@app/routes/login/MagicLinkForm";
import OAuthButtons from "@app/routes/login/OAuthButtons";
import DividerWithText from "@app/components/shared/DividerWithText";
import LoggedInState from "@app/routes/login/LoggedInState";
import { absoluteWithBasePath, getBaseUrl } from "@app/constants/app";
export default function Login() {
const navigate = useNavigate();
@@ -25,11 +26,13 @@ export default function Login() {
const { t } = useTranslation();
const [isSigningIn, setIsSigningIn] = useState(false);
const [error, setError] = useState<string | null>(null);
const [showMagicLink, setShowMagicLink] = useState(false);
const [showMagicLinkForm, setShowMagicLinkForm] = useState(false);
const [showEmailForm, setShowEmailForm] = useState(false);
const [email, setEmail] = useState("");
const [password, setPassword] = useState("");
const [magicLinkEmail, setMagicLinkEmail] = useState("");
const [magicLinkSent, setMagicLinkSent] = useState(false);
// Prefill email from query param (e.g. after password reset)
useEffect(() => {
try {
@@ -37,6 +40,7 @@ export default function Login() {
const emailFromQuery = url.searchParams.get("email");
if (emailFromQuery) {
setEmail(emailFromQuery);
setShowEmailForm(true);
}
} catch (_) {
// ignore
@@ -174,12 +178,9 @@ export default function Login() {
setError(error.message);
} else {
setError(null);
alert(t("login.magicLinkSent", { email: magicLinkEmail }));
setMagicLinkEmail("");
setShowMagicLink(false);
setMagicLinkSent(true);
}
} catch (err) {
console.error("[Login] Unexpected error:", err);
setError(
t("login.unexpectedError", {
message: err instanceof Error ? err.message : "Unknown error",
@@ -190,10 +191,6 @@ export default function Login() {
}
};
const handleForgotPassword = () => {
navigate("/auth/reset");
};
const handleAnonymousSignIn = async () => {
try {
setIsSigningIn(true);
@@ -227,115 +224,210 @@ export default function Login() {
}
};
const toggleEmailForm = () => {
setShowEmailForm((v) => !v);
setShowMagicLinkForm(false);
setMagicLinkSent(false);
};
const toggleMagicLink = () => {
setShowMagicLinkForm((v) => !v);
setShowEmailForm(false);
setMagicLinkSent(false);
};
return (
<AuthLayout isEmailFormExpanded={showEmailForm}>
<LoginHeader
title={t("login.login")}
subtitle={t("login.subtitle", "Sign back in to Stirling PDF")}
/>
<AuthLayout isEmailFormExpanded={showEmailForm || showMagicLinkForm}>
{/* Centered logo */}
<div className="auth-logo-block">
<img
src={withBasePath("/modern-logo/LoginLightModeHeader.svg")}
alt="Stirling PDF"
className="auth-logo-header auth-logo-header--light"
/>
<img
src={withBasePath("/modern-logo/LoginDarkModeHeader.svg")}
alt="Stirling PDF"
className="auth-logo-header auth-logo-header--dark"
/>
</div>
<ErrorMessage error={error} />
{/* OAuth first */}
<OAuthButtons
onProviderClick={signInWithProvider}
isSubmitting={isSigningIn}
layout="fullwidth"
/>
{/* OAuth + magic link group — single flex column so gap is uniform */}
<div
style={{
display: "flex",
flexDirection: "column",
gap: "0.75rem",
marginBottom: "2.5rem",
}}
>
<OAuthButtons
onProviderClick={signInWithProvider}
isSubmitting={isSigningIn}
layout="fullwidth"
labelPrefix={`${t("login.signInWith", "Sign in with")} `}
/>
{/* Divider between OAuth and Email */}
<DividerWithText
text={t("signup.or", "or")}
respondsToDarkMode={false}
opacity={0.4}
/>
{/* Magic link button + its expandable form as one unit */}
<div>
<button
type="button"
disabled={isSigningIn}
onClick={toggleMagicLink}
className={`oauth-button-fullwidth auth-expandable-trigger ${showMagicLinkForm ? "auth-expandable-trigger--active" : ""}`}
>
<span className="oauth-btn-group">
<LinkRoundedIcon
style={{
width: "1.75rem",
height: "1.75rem",
marginRight: "0.5rem",
flexShrink: 0,
}}
/>
<span className="oauth-btn-label">
{t("login.useMagicLink", "Use magic link")}
</span>
</span>
</button>
{/* Sign in with email button (primary color to match signup CTA) */}
<div className="auth-section">
<div
className={`auth-expand-grid ${showMagicLinkForm ? "auth-expand-grid--open" : ""}`}
>
<div className="auth-expand-inner">
<div style={{ paddingTop: "0.25rem" }}>
{magicLinkSent ? (
<p
style={{
fontSize: "0.875rem",
color: "#059669",
margin: 0,
}}
>
{t("login.magicLinkSent", { email: magicLinkEmail })}
</p>
) : (
<div className="auth-magic-row">
<input
type="email"
placeholder={t(
"login.enterEmailForMagicLink",
"Enter your email",
)}
value={magicLinkEmail}
onChange={(e) => setMagicLinkEmail(e.target.value)}
onKeyDown={(e) =>
e.key === "Enter" &&
!isSigningIn &&
signInWithMagicLink()
}
className="auth-input"
/>
<button
onClick={signInWithMagicLink}
disabled={isSigningIn || !magicLinkEmail}
className="auth-magic-button"
>
{isSigningIn
? t("login.sending")
: t("login.sendMagicLink")}
</button>
</div>
)}
</div>
</div>
</div>
</div>
</div>
{/* Email & Password button */}
<button
type="button"
disabled={isSigningIn}
onClick={toggleEmailForm}
className={`oauth-button-fullwidth auth-expandable-trigger ${showEmailForm ? "auth-expandable-trigger--active" : ""}`}
style={{ marginBottom: "0.75rem" }}
>
<span className="oauth-btn-group">
<span className="auth-at-icon">@</span>
<span className="oauth-btn-label">{`${t("login.signInWith", "Sign in with")} email`}</span>
</span>
</button>
{/* Email form — animated expand */}
<div
className={`auth-expand-grid ${showEmailForm ? "auth-expand-grid--open" : ""}`}
>
<div className="auth-expand-inner">
<div style={{ paddingBottom: "0.5rem" }}>
<EmailPasswordForm
email={email}
password={password}
setEmail={setEmail}
setPassword={setPassword}
onSubmit={signInWithEmail}
isSubmitting={isSigningIn}
submitButtonText={
isSigningIn ? t("login.loggingIn") : t("login.login")
}
/>
<button
type="button"
onClick={() => navigate("/auth/reset")}
className="auth-link-black"
style={{ fontSize: "0.8125rem", marginTop: "0.25rem" }}
>
{t("login.forgotPassword", "Forgot your password?")}
</button>
</div>
</div>
</div>
{/* Skip */}
<div style={{ textAlign: "center", margin: "1rem 0" }}>
<button
type="button"
onClick={() => setShowEmailForm((v) => !v)}
onClick={handleAnonymousSignIn}
disabled={isSigningIn}
className="w-full px-4 py-[0.75rem] rounded-[0.625rem] text-base font-semibold mb-2 cursor-pointer border-0 disabled:opacity-50 disabled:cursor-not-allowed auth-cta-button"
style={{
background: "none",
border: "none",
cursor: "pointer",
fontSize: "1rem",
fontWeight: 700,
color: "#000000",
}}
>
{t("login.useEmailInstead", "Sign in with email")}
{isSigningIn
? t("login.signingIn", "Signing in...")
: `${t("signup.skip", "Skip")}`}
</button>
</div>
{showEmailForm && (
<EmailPasswordForm
email={email}
password={password}
setEmail={setEmail}
setPassword={setPassword}
onSubmit={signInWithEmail}
isSubmitting={isSigningIn}
submitButtonText={
isSigningIn ? t("login.loggingIn") : t("login.login")
}
/>
)}
{showEmailForm && (
<div className="auth-section-sm">
<button
type="button"
onClick={handleForgotPassword}
className="auth-link-black"
>
{t("login.forgotPassword", "Forgot your password?")}
</button>
</div>
)}
{/* Divider then Guest */}
<DividerWithText
text={t("signup.or", "or")}
respondsToDarkMode={false}
opacity={0.4}
/>
<GuestSignInButton
onClick={handleAnonymousSignIn}
disabled={isSigningIn}
label={
isSigningIn
? t("login.signingIn", "Signing in...")
: t("login.signInAnonymously", "Sign in as a Guest")
}
/>
<div className="auth-bottom-row">
<button
type="button"
onClick={() => setShowMagicLink(true)}
className="auth-link-black"
>
{t("login.useMagicLink", "Sign in with magic link")}
</button>
{/* Bottom */}
<div
style={{
textAlign: "center",
marginTop: "auto",
paddingTop: "1rem",
}}
>
<button
type="button"
onClick={() => navigate("/signup")}
className="auth-link-black"
style={{
background: "none",
border: "none",
cursor: "pointer",
fontSize: "0.875rem",
color: "#9ca3af",
}}
>
{t("signup.signUp", "Sign up")}
{t("login.createAccount", "Create an account")}
</button>
</div>
{/* Magic link form renders on demand */}
{showMagicLink && (
<div style={{ marginTop: "0.5rem" }}>
<MagicLinkForm
showMagicLink={showMagicLink}
magicLinkEmail={magicLinkEmail}
setMagicLinkEmail={setMagicLinkEmail}
setShowMagicLink={setShowMagicLink}
onSubmit={signInWithMagicLink}
isSubmitting={isSigningIn}
/>
</div>
)}
</AuthLayout>
);
}
+84 -59
View File
@@ -4,18 +4,15 @@ import { signInAnonymously } from "@app/auth/supabase";
import { useAuth } from "@app/auth/UseSession";
import { useTranslation } from "@app/hooks/useTranslation";
import { useDocumentMeta } from "@app/hooks/useDocumentMeta";
import { getBaseUrl } from "@app/constants/app";
import { getBaseUrl, withBasePath } from "@app/constants/app";
import AuthLayout from "@app/routes/authShared/AuthLayout";
import "@app/routes/authShared/auth.css";
import "@app/routes/authShared/saas-auth.css";
import GuestSignInButton from "@app/routes/authShared/GuestSignInButton";
import { alert } from "@app/components/toast";
// Import signup components
import LoginHeader from "@app/routes/login/LoginHeader";
import ErrorMessage from "@app/routes/login/ErrorMessage";
import OAuthButtons from "@app/routes/login/OAuthButtons";
import DividerWithText from "@app/components/shared/DividerWithText";
import SignupForm from "@app/routes/signup/SignupForm";
import {
useSignupFormValidation,
@@ -184,84 +181,112 @@ export default function Signup() {
return (
<AuthLayout isEmailFormExpanded={showEmailForm}>
<LoginHeader title={t("signup.title")} subtitle={t("signup.subtitle")} />
{/* Centered logo */}
<div className="auth-logo-block">
<img
src={withBasePath("/modern-logo/LoginLightModeHeader.svg")}
alt="Stirling PDF"
className="auth-logo-header auth-logo-header--light"
/>
<img
src={withBasePath("/modern-logo/LoginDarkModeHeader.svg")}
alt="Stirling PDF"
className="auth-logo-header auth-logo-header--dark"
/>
</div>
<ErrorMessage error={error} />
{/* OAuth first */}
<div style={{ marginBottom: "0.5rem" }}>
{/* OAuth providers */}
<div>
<OAuthButtons
onProviderClick={handleProviderSignIn}
isSubmitting={isSigningUp}
layout="fullwidth"
labelPrefix={`${t("signup.signUpWith", "Sign up with")} `}
/>
</div>
{/* Divider between OAuth and Email */}
<div style={{ margin: "0.5rem 0" }}>
<DividerWithText
text={t("signup.or", "or")}
respondsToDarkMode={false}
opacity={0.4}
/>
{/* Email & Password button */}
<button
type="button"
disabled={isSigningUp}
onClick={() => setShowEmailForm((v) => !v)}
className={`oauth-button-fullwidth auth-expandable-trigger ${showEmailForm ? "auth-expandable-trigger--active" : ""}`}
style={{ marginTop: "2.5rem", marginBottom: "0.75rem" }}
>
<span className="oauth-btn-group">
<span className="auth-at-icon">@</span>
<span className="oauth-btn-label">{`${t("signup.signUpWith", "Sign up with")} email`}</span>
</span>
</button>
{/* Email form — animated expand */}
<div
className={`auth-expand-grid ${showEmailForm ? "auth-expand-grid--open" : ""}`}
>
<div className="auth-expand-inner">
<div style={{ paddingBottom: "0.5rem" }}>
<SignupForm
name={name}
email={email}
password={password}
confirmPassword={confirmPassword}
agree={agree}
setName={setName}
setEmail={setEmail}
setPassword={setPassword}
setConfirmPassword={setConfirmPassword}
setAgree={setAgree}
onSubmit={handleSignUp}
isSubmitting={isSigningUp}
fieldErrors={fieldErrors}
/>
</div>
</div>
</div>
{/* Use Email Instead button (toggles email form) */}
<div className="auth-section">
{/* Skip */}
<div style={{ textAlign: "center", margin: "1rem 0" }}>
<button
type="button"
onClick={handleAnonymousSignIn}
disabled={isSigningUp}
onClick={() => setShowEmailForm((v) => !v)}
className="w-full px-4 py-[0.75rem] rounded-[0.625rem] text-base font-semibold mb-2 cursor-pointer border-0 disabled:opacity-50 disabled:cursor-not-allowed auth-cta-button"
style={{
background: "none",
border: "none",
cursor: "pointer",
fontSize: "1rem",
fontWeight: 700,
color: "#000000",
}}
>
{t("signup.useEmailInstead", "Use Email Instead")}
{isSigningUp
? t("login.signingIn", "Signing in...")
: `${t("signup.skip", "Skip")}`}
</button>
</div>
{showEmailForm && (
<SignupForm
name={name}
email={email}
password={password}
confirmPassword={confirmPassword}
agree={agree}
setName={setName}
setEmail={setEmail}
setPassword={setPassword}
setConfirmPassword={setConfirmPassword}
setAgree={setAgree}
onSubmit={handleSignUp}
isSubmitting={isSigningUp}
fieldErrors={fieldErrors}
/>
)}
<div className="auth-section-sm">
<DividerWithText
text={t("signup.or", "or")}
respondsToDarkMode={false}
opacity={0.4}
/>
</div>
<GuestSignInButton
onClick={handleAnonymousSignIn}
disabled={isSigningUp}
label={
isSigningUp
? t("login.signingIn", "Signing in...")
: t("login.signInAnonymously", "Sign in as a Guest")
}
/>
{/* Bottom row */}
<div className="auth-bottom-right">
{/* Bottom */}
<div
style={{
textAlign: "center",
marginTop: "auto",
paddingTop: "1rem",
}}
>
<button
type="button"
onClick={() => navigate("/login")}
className="auth-link-black"
style={{
background: "none",
border: "none",
cursor: "pointer",
fontSize: "0.875rem",
color: "#9ca3af",
}}
>
{t("login.logIn", "Log In")}
{t("signup.alreadyHaveAccount", "I already have an account")}
</button>
</div>
</AuthLayout>
@@ -14,7 +14,7 @@
justify-content: center;
padding: 0.75rem 1rem;
border: 1px solid #d1d5db;
border-radius: 0.625rem;
border-radius: 100px;
background-color: #ffffff;
font-size: 1rem;
font-weight: 600;
@@ -22,6 +22,10 @@
cursor: pointer;
gap: 0.5rem;
box-shadow: 0 0.125rem 0.375rem rgba(0, 0, 0, 0.04);
transition:
background-color 150ms ease,
box-shadow 150ms ease,
border-color 150ms ease;
}
.oauth-button-fullwidth:disabled {
@@ -29,6 +33,11 @@
opacity: 0.6;
}
.oauth-button-fullwidth:hover:not(:disabled) {
background-color: #fafafa;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.08);
}
.auth-dropdown-wrapper {
position: relative;
}
@@ -72,3 +81,90 @@
color: #9c2f30;
border: 2px solid currentColor;
}
/* ── Logo block ─────────────────────────────────────────────────────── */
.auth-logo-block {
display: flex;
flex-direction: column;
align-items: center;
margin-bottom: 2rem;
margin-top: 0.5rem;
animation: authFadeUp 0.4s ease both;
}
.auth-logo-header {
height: 8rem;
width: auto;
}
.auth-logo-header--dark {
display: none;
}
/* ── Page entrance animation ────────────────────────────────────────── */
@keyframes authFadeUp {
from {
opacity: 0;
transform: translateY(12px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
/* ── Expandable trigger button state ────────────────────────────────── */
.auth-expandable-trigger {
transition:
background-color 180ms ease,
box-shadow 180ms ease,
border-color 180ms ease;
}
.auth-expandable-trigger--active {
border-color: #af3434 !important;
box-shadow: 0 0 0 3px rgba(175, 52, 52, 0.12);
}
/* ── Animated expand/collapse via grid-template-rows ───────────────── */
.auth-expand-grid {
display: grid;
grid-template-rows: 0fr;
transition:
grid-template-rows 280ms cubic-bezier(0.4, 0, 0.2, 1),
opacity 220ms ease;
opacity: 0;
}
.auth-expand-grid--open {
grid-template-rows: 1fr;
opacity: 1;
}
.auth-expand-inner {
overflow: hidden;
min-height: 0;
}
/* ── Icon+label group — keeps icons vertically aligned across buttons ── */
.oauth-btn-group {
display: inline-flex;
align-items: center;
min-width: 12.5rem;
}
.oauth-btn-label {
flex: 1;
text-align: center;
}
/* ── Icon helpers inside oauth-button-fullwidth ─────────────────────── */
.auth-at-icon {
font-size: 1.25rem;
line-height: 1;
margin-right: 0.5rem;
display: inline-block;
width: 1.75rem;
text-align: center;
flex-shrink: 0;
}
@@ -17,6 +17,7 @@ interface OAuthButtonsProps {
isSubmitting: boolean;
layout?: "vertical" | "grid" | "icons" | "fullwidth";
enabledProviders?: string[]; // List of enabled provider IDs from backend
labelPrefix?: string;
}
export default function OAuthButtons({
@@ -24,6 +25,7 @@ export default function OAuthButtons({
isSubmitting,
layout = "vertical",
enabledProviders: _enabledProviders = [],
labelPrefix = "",
}: OAuthButtonsProps) {
const { t } = useTranslation();
@@ -92,12 +94,18 @@ export default function OAuthButtons({
className="oauth-button-fullwidth"
title={p.label}
>
<img
src={withBasePath(`/Login/${p.file}`)}
alt={p.label}
className={`oauth-icon-medium ${p.isDisabled ? "opacity-20" : ""}`}
/>
{p.label}
<span className="oauth-btn-group">
<img
src={withBasePath(`/Login/${p.file}`)}
alt={p.label}
className={`oauth-icon-medium ${p.isDisabled ? "opacity-20" : ""}`}
style={{ marginRight: "0.5rem", flexShrink: 0 }}
/>
<span className="oauth-btn-label">
{labelPrefix}
{p.label}
</span>
</span>
</button>
))}
</div>
@@ -13,6 +13,15 @@ vi.mock("@app/auth/supabase", () => ({
},
}));
// Stub apiClient's heavy UI/util deps so importing it stays cheap. None are
// exercised here (toast, plan settings and handleHttpError paths aren't hit),
// and re-importing the real toast graph per test made these tests time out.
vi.mock("@app/components/toast", () => ({ alert: vi.fn() }));
vi.mock("@app/utils/appSettings", () => ({ openPlanSettings: vi.fn() }));
vi.mock("@app/services/httpErrorHandler", () => ({
handleHttpError: vi.fn().mockResolvedValue(undefined),
}));
describe("apiClient", () => {
beforeEach(() => {
vi.clearAllMocks();
@@ -35,7 +35,7 @@
--auth-label-text: #2b3230;
--auth-button-bg: #af3434;
--auth-button-text: #ffffff;
--auth-magic-button-bg: #8b5cf6;
--auth-magic-button-bg: #af3434;
--auth-magic-button-text: #ffffff;
/* Light-only auth colors (no dark mode equivalents) used for login/signup */
@@ -45,7 +45,7 @@
--auth-label-text-light-only: #2b3230;
--auth-button-bg-light-only: #af3434;
--auth-button-text-light-only: #ffffff;
--auth-magic-button-bg-light-only: #8b5cf6;
--auth-magic-button-bg-light-only: #af3434;
--auth-magic-button-text-light-only: #ffffff;
--auth-bg-color-light-only: #ffffff;
--auth-card-bg-light-only: #ffffff;
+1 -2
View File
@@ -233,8 +233,7 @@ Feature: API Validation
When I send the API request to the endpoint "/api/v1/convert/pdf/markdown"
Then the response status code should be 200
And the response file should have size greater than 100
And the response file should have extension ".zip"
And the response ZIP should contain 4 files
And the response file should have extension ".md"
@positive @pdftocsv
+3 -3
View File
@@ -911,7 +911,7 @@ main() {
CUCUMBER_JUNIT_DIR="$PROJECT_ROOT/testing/cucumber/junit"
mkdir -p "$CUCUMBER_JUNIT_DIR"
cd "testing/cucumber"
start_test_timer "Stirling-PDF-Regression"
start_test_timer "Stirling-PDF-Regression $CONTAINER_NAME"
# Snapshot docker log line count before behave so we can extract only behave-window logs
DOCKER_LOG_BEFORE=$(docker logs "$CONTAINER_NAME" 2>&1 | wc -l)
@@ -999,7 +999,7 @@ main() {
# Save docker logs from the behave window to a dedicated file
local cucumber_log="$REPORT_DIR/cucumber-docker-context.log"
docker logs "$CONTAINER_NAME" 2>&1 | tail -n +"$((DOCKER_LOG_BEFORE + 1))" > "$cucumber_log" 2>/dev/null || true
test_failure_logs["Stirling-PDF-Regression"]="$cucumber_log"
test_failure_logs["Stirling-PDF-Regression $CONTAINER_NAME"]="$cucumber_log"
gha_group "Docker logs during behave run: $CONTAINER_NAME"
tail -100 "$cucumber_log"
@@ -1012,7 +1012,7 @@ main() {
capture_file_list "$CONTAINER_NAME" "$AFTER_FILE"
compare_file_lists "$BEFORE_FILE" "$AFTER_FILE" "$DIFF_FILE" "$CONTAINER_NAME"
fi
stop_test_timer "Stirling-PDF-Regression"
stop_test_timer "Stirling-PDF-Regression $CONTAINER_NAME"
fi
# `down` with the override removes the agent bind-mount cleanly. The
# SIGTERM that `down` sends is what triggers dumponexit=true in the